排序算法是面试题中的常客,今天我们来梳理一下经典的几个排序算法,我们对这几个排序算法的掌握程度应该达到能够快速默写,理解算法思路以及能够分析算法复杂度。废话少说,下面我们来一个一个详细说明(都按升序来分析)。
冒泡排序
冒泡排序是最基础也是最经典的排序算法,其时间复杂度为O(n^2)。
思路
冒泡排序的实现思路是遍历数组,并依次比较相邻元素的大小,将二者之中较大的元素放在右侧,这样遍历一遍后,数组中最大的元素就会被移到尾部固定。之后再次遍历该数组到倒数第二个位置,会把数组中第二大的元素放到到数第二个位置。就这样依次遍历后,所有的元素都会按照升序排列。
手撕代码
function bubbleSort(arr){
const len = arr.length;
for(let i = 0; i < len; ++i){
for(let j = 0; j < len - 1; ++j){
if(arr[j] > arr[j+1]){
[arr[j],arr[j+1]] = [arr[j+1],arr[j]];
}
}
}
return arr;
}
代码优化
因为基本版代码没有考虑到已排序的尾部元素其实无需再次进行比较,所以我们可以在内部循环中重新定义一下范围。
function betterBubbleSort(arr){
const len = arr.length;
for(let i = 0; i < len; ++i){
let flag = false;
for(let j = 0; j < len - 1 - i; ++j){
ifarr[j] > arr[j+1]){
[arr[j],arr[j+1]] = [arr[j+1],arr[j]];
}
}
}
return arr;
}
冒泡排序的平均复杂度为O(n^2), 面对最好情况的时间复杂度为O(n),但是我们需要使用一个标志位来帮助我们。第一次循环标志位就没有被改变的话说明数组本来就是有序的,直接返回即可,所以时间复杂度为O(n)。
function bestBubbleSort(arr){
const len = arr.length;
for(let i = 0; i < len; ++i){
let flag = false;
for(let j = 0; j < len - 1 - i; ++j){
if(arr[j] > arr[j+1]){
[arr[j],arr[j+1]] = [arr[j+1],arr[j]];
flag = true;
}
}
if(flag === true){
return arr;
}
}
}
选择排序
思路
选择排序,顾名思义就是选择符合顺序的那个值放在当前范围的头部,之后当前范围的序列的左指针右移一位。就这样循环遍历该数组,最终数组将按顺序排列。
选择排序的时间复杂度最好,最差,平均都为O(n^2)。
手撕代码
function selectSort(arr){
// 首先我们可以缓存数组的长度
const len = arr.length;
// 这里非常关键,我们需要定义一个变量来存储最小的索引
let minIndex;
// 外层循环, i可以理解为考虑范围的下限
for(let i = 0; i < len; ++i){
// 初始化最小索引为当前遍历的i值,之后它会自动地逐一右移,不用去考虑之前已排好序的元素
minIndex = i;
// 内层循环, j可以理解为考虑范围的上限,从外层循环的索引处开始
for(let j = i; j < len; ++j){
// 一旦发现某个数据比最小索引处的数据还要小,说明需要排序
if(arr[j] < arr[minIndex]){
// 先更新当前索引为最小索引,之后继续在遍历中寻找
// 这样的话,最小的索引一定会被找到并保存
minIndex = j;
}
}
// 循环结束后,我们只需要判断最小索引是否是初始化的当前下限索引
// 是的话无需处理,否则需要将头部值arr[i]和最小索引值arr[minIndex]交换位置
if(minIndex !== i){
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
}
return arr;
}
插入排序
思路
什么是插入排序?插入排序的原理是找到当前元素在它前面序列中的正确位置,这就要求当前元素之前的序列必须是有序的。正是因为这个大前提,我们可以在有序序列中从后往前做比较,安置当前元素。
插入排序的时间复杂度最好为O(n),最差为O(n^2),平均为O(n^2)。
手撕代码
function insertSort(arr){
// 缓存数组长度
const len = arr.length;
// 定义一个变量存储当前考虑的元素
let temp;
// 外部循环遍历,注意我们应该从索引1除开始,因为第一个元素一定是有序的
// 可以直接从第二个元素开始考虑
for(let i = 1; i < len; ++i){
// 循环中定义一个变量j用于帮助我们获得temp这个当前元素的位置
// 从当前循环索引处考虑,方向是往前的
let j = i;
// 当前元素就是外层循环的当前遍历值
temp = arr[i];
// 这里使用while循环帮助我们快速寻找temp的位置
// j > 0是边界条件,arr[j-1] > 当前值说明需要向后为其留出位置
while(j > 0 && arr[j-1] > temp){
// j后移一位,为temp腾出位置
arr[j] = arr[j-1];
// 继续往前考虑
j--;
}
/ /最终我们一定会从while循环中获取到j就是temp的正确索引位置
// 此时直接插入temp即可
arr[j] = temp;
}
return arr;
}
归并排序
思路
归并排序使用的核心策略就是分治思想,也就是把一个大问题分解为若干个小问题,将每个小问题求解后再合并得到大问题的解。归并排序正是基于这种思路而被提出的,它可以被分为以下三个步骤。
- 分解子问题:将需要排序的数组对半分开,然后继续二分,最终我们会得到一系列只有一个元素的子数组。
- 求解子问题:将这些最小粒度的子数组两两合并,这里注意我们每次合并的结果必须是有序的。
- 合并子问题的解:将子问题的解,也就是稍大粒度的有序数组继续合并得到原数组规模的有序数组,从而完成归并排序。
时间复杂度O(nlog(n))。
手撕代码
function mergeSort(arr){
// 缓存数组长度
const len = arr.length;
// 讨论数组为空或者数组只有一个元素的情况,这也是递归的边界条件!
if(len <= 1){
return arr;
}
// 获得当前数组的中间位置索引,二分法的基础步骤
const mid = Math.floor(len / 2);
// 递归调用归并排序来排序左子数组
const leftArr = mergeSort(arr.slice(0,mid));
// 递归调用归并排序来排序右子数组,不需要考虑递归内部怎么实现的
// 只需要知道按照回溯机制,我们会得到合并好的有序序列
const rightArr = mergeSort(arr.slice(mid,len));
// 合并两个有序数组并更新arr
arr = mergeArr(leftArr, rightArr);
// 一定要返回得到的arr,递归需要用到这个返回的值
return arr;
}
// 定义一个合并有序数组的辅助函数
function mergeArr(arr1, arr2){
// 定义两个指针,各自指向数组的首位
let i = 0, j = 0;
// 定义一个空数组来存储结果
const res = [];
// 缓存数组长度
const len1 = arr1.length;
const len2 = arr2.length;
// 使用while循环判断i和j的当前范围是不是在数组中,一旦有一个超出范围,则跳出循环
while(i < len1 && j < len2){
// 比较两个值的大小,将小的那个插入到结果数组中
if(arr1[i] < arr2[j]){
res.push(arr1[i]);
// 该索引右移
i++;
}else{
// 同理
res.push(arr2[j]);
j++;
}
}
// 最后要处理数组不等长的情况,有一个数组依然还有为遍历到的数据
// 对于arr1更长的情况,直接将剩余的arr1数据连接到结果数组尾部即可
// 反之,操作arr2
// 这里,我们用到arr.concat方法和arr.slice()方法,非常高效
if(i < len1){
return res.concat(arr1.slice(i));
}else{
return res.concat(arr2.slice(j));
}
}
快速排序
终于来到大名鼎鼎的快速排序了,激不激动,马上我们就可以弄清楚快排的原理以及快速默写出快排的代码了,话不多说,我们开始操作。
思路
快速排序也是坚决使用了“分治”思想,它和归并排序的区别在于它没有新建一个空数组,也没有分拆一个数组再合并,而是简洁地在原数组中操作。快速排序会将原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组。快速排序还有一个特别之处,即它使用了基准值,基准值的选取有很多方式,最简单的一种是将基准值选为数组中间的值。为了更好的理解快速排序原理,我们可以模拟快速排序的实现步骤。
这里,我们假定一个待排序的数组arr:[5,1,3,6,2,0,7]。 首先,左右指针分别指向首元素和尾元素,中间值6即我们的基准值。然后我们要移动我们的左指针,直到找到一个不小于基准值的数据停止;之后再移动右指针直到找到一个不大于基准值的数据停止。下面我们模拟这个过程。 左指针移动到基准值6时满足条件停止移动,右指针移动到数据0时满足条件停止移动。这个时候我们要做的是交换数字6和0,6依然是我们的基准值,这个是不变的,但是位置现在更新为以下模样:[5,1,3,0,2,6,7]。紧接着,两个指针都往中间移动一步(也就是继续搜索),此时,左指针指向的数据2不满足不小于基准值的条件,继续向右移动,直到其指向数据6满足条件;右指针往左移动一步指向数据2满足条件停下来,就这样,我们一定会发现左指针指向的数字左侧的数组元素都比该数字小,而左指针右侧的数据都比该数字大,因此我们把一个数组分为了两个子数组:[5,1,3,0,2]和[6,7]。针对这两个子数组,我们再继续使用相同的方法直到数组完全有序为止。
快速排序的时间复杂度由基准值决定,最好是O(nlogn),最差是O(n^2),平均复杂度为O(nlogn)。
手撕代码
// 快排递归主逻辑,入参为数组、左指针索引、右指针索引,初始值为首尾索引
function quickSort(arr, left=0, right=arr.length-1){
// 只有数组非空非单个元素才有必要使用排序算法,否则直接返回该数组
if(arr.length > 1){
// 调用partition辅助函数获得基准值
const datum = partition(arr,left,right);
// 如果左边子数组的长度不小于1,则递归快排这个子数组
if(left < datum-1){
quickSort(arr, left, datum-1);
}
// 如果右边子数组的长度不小于1,则递归快排这个子数组
if(datum < right){
quickSort(arr,datum,right);
}
}
// 返回结果
return arr;
}
// 定义辅助函数,寻找最新的基准值,入参也是数组、左右指针索引
function partition(arr,left,right){
// 选择当前数组中间值作为基准值
let datumValue = arr[Math.floor(left+(right-left)/2)];
// 初始化左右指针
let i = left;
let j = right;
// 当左右指针不越界执行循环
while(i<=j){
// 左指针指向的元素不大于基准值索引,则右移左指针,否则跳出循环停止移动
while(arr[i] < datumValue){
i++;
}
// 右指针指向的元素不小于基准值,则左移右指针,否则跳出循环停止移动
while(arr[j] > datumValue){
j--;
}
// 若i<j,则意味着基准值左边存在较大元素或者右边存在较小的元素
// 交换两个元素的位置然后移动指针
if(i<=j){
swap(arr,i,j);
i++;
j--;
}
}
// 返回左指针索引作为下一个基准值的索引
return i;
}
// 定义交换元素的辅助函数
function swap(arr,i,j){
[arr[i],arr[j]] = [arr[j],arr[i]];
}