一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
前言
大家好,我是一知。
学习这件事情,尤其是算法这种东西,光看是不太行的,很多时候觉得自己理解了,但是代码却可能不太容易写出来,所以还是得自己理解完并动手实现代码,而且即便是自己动手实现了,可能过时间长了,还是会忘记,所以还需要定期地总结和回顾。
本篇文章是个人近期对比较常见的6种排序算法的梳理总结,通过本篇文章你将了解到
- 冒泡排序
- 选择排序
- 插入排序
- 快速排序
- 归并排序
- 计数排序
这6种常见排序算法的基本思想、实现思路、及代码实现。
名词解释
时间复杂度:
时间复杂度用于描述算法运行的时间。我们把算法需要运算的次数用输入大小为n的函数来表示,记作 。时间复杂度通常用 来表示,有 , 为代码中基本操作重复执行的次数。
空间复杂度:
空间复杂度指算法在计算机内执行时所需存储空间的度量。记作 。算法执行期间所需要的存储空间包括3个部分:
- 算法程序所占的空间;
- 输入的初始数据所占的存储空间;
- 算法执行过程中所需要的额外空间。
排序算法稳定性:
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,A1=A2,且A1在A2之前,而在排序后的序列中,A1仍在A2之前,则称这种排序算法是稳定的;否则称为不稳定的。
1. 冒泡排序
1.1 基本思想
我们知道水中的泡泡在往上冒的时候,会越来越大,冒泡排序,顾名思义就是一个让大的往上”冒“的排序过程。从无序序列中的首位遍历到末位的一次完整过程称为一次冒泡,通过判断相邻的元素(a, b)大小,比如左边的a比右边的b大,那么交换a和b的位置。每一次冒泡可以确定一个元素的位置,同时可以缩小无序序列的范围,然后再对无序序列进行冒泡,直到最终所有元素的位置都确定,即完成排序。
1.2 实现思路
- 遍历无序序列,遇到左边元素大于右边元素的,交换它们俩位置;
- 将整个无序序列遍历完一遍之后,能够将一个大数推到无序序列的最左边,然后缩小无序序列的右边界;
- 重复上述步骤1和2,直至无序序列只剩下一个元素,排序完成。
1.3 排序动画
1.4 代码实现
1.4.1 方法一
常规的单向冒泡
/**
* 常规的单向遍历冒泡
* @param {*} arr
*/
const bubbleSort = (arr) => {
console.time('常规的单向遍历冒泡');
const len = arr.length;
for (let i = 0; i < len; i++) {
let j = 0;
// 无序序列的右端点
const end = len - 1 - i;
while (j < end) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
j++;
}
}
console.timeEnd('常规的单向遍历冒泡');
return arr;
};
// test case:
const arr = Array.from({ length: 50 }).map(() =>
Math.floor(Math.random() * 100)
);
console.log(bubbleSort(arr));
复制代码
1.4.2 方法二
双端冒泡。
上面的方法一是从左到右冒泡,每次对无序序列只进行一次从小到大的冒泡,一次冒泡完,只缩小无序序列的右边界。而双端冒泡的不同之处在于,每次对无序序列进行冒泡过程中,从小到大冒一次,再从大到小冒一次,可以同时缩小无序序列的左右边界。
/**
* 双端冒泡排序实现
*/
const bubbleSort = (arr) => {
console.time('双端冒泡排序耗时');
let low = 0;
let high = arr.length - 1;
let temp;
// 6, 8, 35, 28, 32, 38, 42
// ↑ ↑
// low high
// 每经历一次while循环,左右两边都进行一轮遍历交换,就能将low和high间的无序范围缩小2
while (low < high) {
// 从低到高遍历,每经历完一轮for循环能确定一个大的数字
for (let i = low; i < high; i++) {
// 如果左边比右边大,则进行交换
if (arr[i] > arr[i + 1]) {
temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
}
}
high--;
// 再从高到低遍历,每经历完一轮for循环能确定一个小的数字
for (let j = high; j > low; j--) {
if (arr[j] < arr[j - 1]) {
temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}
}
low++;
}
console.timeEnd('双端冒泡排序耗时');
return arr;
};
// test case:
const arr = Array.from({ length: 50 }).map(() =>
Math.floor(Math.random() * 100)
);
console.log(bubbleSort(arr));
复制代码
1.4.3 区别
从算法时间复杂度和空间复杂度上来看,两种都是一样的,经个人测试,数组长度不大时,两种方法的实际耗时基本相差不大,而在数组长度较大时,双端冒泡实际耗时比常规的单向冒泡要少一些。
- 数组长度为100时:
2. 数组长度为100000时:
1.5 算法复杂度
时间复杂度:( );
空间复杂度:使用常数空间存储若干临时变量,空间复杂度为O(1)。
1.6 排序算法稳定性
我们只在左边比右边大时才会进行交换,所以两个相同的元素在排序后的相对位置不会发生变化,是稳定的。
2. 选择排序
2.1 基本思想
从无序序列中找到最小的一个数,将其放到有序序列中,重复这一过程,直到无序序列仅剩一个(最大的数),排序完成。
2.2 实现思路
- 初始的有序序列为空,无序序列为整个待排序数组;
- 遍历无序序列,找到其中一个最小的数,通过位置交换的方式将其放到左侧的有序序列的末位,然后缩小无序序列的起始边界;
- 重复步骤2,直到无序序列仅剩一个数,这个数已是最大数,并且已自动排在了最后一位,此时排序完成。
2.3 排序动画
2.4 代码实现
/**
* 选择排序
* 每次都从无序序列中找到一个最小(或最大)的数,放到有序的序列中
*/
const selectionSort = (arr) => {
console.time('选择排序耗时');
const len = arr.length;
let minIndex;
// 无序序列的起始索引是0
let i = 0;
let temp;
while (i < len - 1) {
minIndex = i;
let j = i;
// 从无序序列中找到最小的数的索引
while (j < len) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
j++;
}
// 将上面从无序序列中找到的最小的数放到左侧的有序序列中
temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
// 将无序序列的起始索引右移一位
i++;
}
console.timeEnd('选择排序耗时');
return arr;
};
// test case:
const arr = Array.from({ length: 50 }).map(() =>
Math.floor(Math.random() * 100)
);
console.log(selectionSort(arr));
复制代码
2.5 算法复杂度
时间复杂度:O( );
空间复杂度:O(1)。
2.6 排序算法稳定性
如果一个元素A比当前元素B1小,而这个A出现在一个和当前元素相等的B2元素后面,那么交换后稳定性就被破坏了。举个例子,序列4,9,4,2,7,在第一遍选择过程中,第1个元素4会和2交换,那么原序列中两个4的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。
3. 插入排序
3.1 基本思想
插入排序的过程类似于我们打扑克时,摸牌并将摸到的牌按单张牌的大小插入到它应该在的相应位置,比如我们当前手里有的牌是3、4、6、9、10、J,那么如果我们此时摸到一张8,我们应该把它插入到6和9的中间,并将9、10、J依次往后移一位。在插入排序过程中,我们会从无序序列中选取一个数target,然后从后往前扫描此时的有序序列,找到第一个比target小或者等于target的数,然后将target插入到这个数的后面;然后再从无序序列中选取一个数,并重复这一过程,最终完成排序。
3.2 实现思路
- 无序序列的起始索引
i
从 1 开始,因为第 0 个可以认为已经处于有序序列中; - 选取右侧的无序序列中的第一个数作为要插入的数
target
; - 在左侧的有序序列中从后往前扫描,找到第一个比
target
小或者等于target
的数,将target
插入到这个数的后面,并将有序序列中target
被插入的位置后面的数依次往后移动一位,然后将无序序列的左边界后移一位; - 重复步骤2和3,完成排序。
3.3 排序动画
3.4 代码实现
3.4.1 方法一
/**
* 插入排序
*/
const insertionSort = (arr) => {
console.time('插入排序耗时');
const len = arr.length;
// i为无序序列的起始索引,从1开始即可,因为第0个可以认为已经处于有序序列中
for (let i = 1; i < len; i++) {
// 要插入的数
const target = arr[i];
// scanIndex为扫描指针,初始值为有序序列的末位索引
let scanIndex = i - 1;
// 终止条件为:找到第一个比target小或者等于target的数或者扫描到有序序列左端点
while (scanIndex >= 0 && arr[scanIndex] > target) {
// 扫描过程中,将比target大的数依次往后移一位
arr[scanIndex + 1] = arr[scanIndex];
// 扫描指针往前移一位
scanIndex--;
}
// 将target插入到对应位置
arr[scanIndex + 1] = target;
}
console.timeEnd('插入排序耗时');
return arr;
};
// test case:
const arr = Array.from({ length: 100 }).map(() =>
Math.floor(Math.random() * 1000)
);
console.log(insertionSort(arr));
复制代码
3.4.2 方法二
思路与方法一基本一致,不同之处在于查找第一个比target小或者等于target的数时使用二分查找。
/**
* 插入排序
*/
const insertionSort = (arr) => {
console.time('使用二分查找的插入排序耗时');
const len = arr.length;
// i为无序序列的起始索引,从1开始即可,因为第0个可以认为已经处于有序序列中
for (let i = 1; i < len; i++) {
// 要插入的数
const target = arr[i];
// 二分查找指针
let left = 0;
let right = i - 1;
let mid;
// while循环结束后,left对应的索引就是我们要找的结果(第一个比target小的数或者有序序列左端点)
while (left <= right) {
// 二分查找
mid = left + Math.floor((right - left) / 2);
if (arr[mid] <= target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
for (let j = i - 1; j >= left; j--) {
// 将比target大的数依次往后移一位
arr[j + 1] = arr[j];
}
// 将target插入到对应位置
arr[left] = target;
}
console.timeEnd('使用二分查找的插入排序耗时');
return arr;
};
// test case:
const arr = Array.from({ length: 100 }).map(() =>
Math.floor(Math.random() * 1000)
);
console.log(insertionSort(arr));
复制代码
3.4.3 区别
从算法时间复杂度和空间复杂度上来看,两种都是一样的,经个人测试,数组长度不大时,两种方法的实际耗时基本相差不大,而数组长度较大时,使用二分查找的插入排序实际耗时比常规插入排序要少一些。具体的测试截图就不再放了,大家有兴趣的话可以自己试一下,本文末尾也会放出代码地址。
3.5 算法复杂度
时间复杂度:O( );
空间复杂度:O(1)。
3.6 排序算法稳定性
我们每次都是选取无序序列中的第一位,所以排序后相同元素的相对位置不会发生变化,是稳定的。
4. 快速排序
4.1 基本思想
从待排序数组中选取一个基准数,将比它小的数都放到它左边,比它大的放到它右边,此时它将一个无序序列划分成了两个子序列,然后使用递归方式对左右两个子序列进行同样的处理。递归的终点是要处理的子序列中只有1或0个数,则认为该子序列是有序的,最终整个序列将是有序的。这是一种典型的分治思想。
4.2 实现思路
- 从序列中选取一个基准数
pivot
,序列中任意一个数都是可以作为基准数的,为了方便,我们选取第一个; - 遍历无序序列中的数,如果比基准数小,就放到基准数的左边,比它大就放到它的右边,与它相等就不动,因为放两边都是可以的;
- 在上述遍历过程中,我们首先从序列的右边开始往左边找,我们设这个下标为
j
,如果arr[j]
大于或者等于基准数,就执行j--
操作,直到找到第 1 个比基准数小的值;接着从左边开始往右边找,设这个下标为i
,如果arr[i]
小于或者等于基准数,就执行i++
操作,直到找到第 1 个比基准数大的值,然后将arr[i]
和arr[j]
交换位置;然后继续重复前面的从右往左的查找过程,直到i
与j
重合时结束。而此时基准值还在第一位的位置,所以需要将arr[i]
与基准值交换一下位置,此时就达到了基准数左边的数都小于等于它、基准数右边的数都大于等于它的效果了。 - 然后使用递归对上述左右两个子序列进行相同的处理;
- 当要处理的子序列中数的个数为 1 或者 0 时,认为该子序列是有序的,最终整个序列就将是序的。
4.3 排序动画
4.4 代码实现
/**
* 交换数组中的某两项
*/
const swap = (arr, i, j) => {
const temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
};
const quickSort = (arr, left, right) => {
// 如果left >= right,说明该序列中没有元素或者只有一个元素,不需要排序
if (left >= right) return;
// 取序列中第一个数作为基准数
const pivot = arr[left];
let i = left;
let j = right;
// i等于j时,while终止
while (i < j) {
// 这个while终止,说明遇到了小于基准数的,就停止
while (i < j && arr[j] >= pivot) {
j--;
}
// 然后从左到右,找到第一个大于基准数的
while (i < j && arr[i] <= pivot) {
i++;
}
// 交换这两个数的位置
swap(arr, i, j);
}
// 将基准数换到中间来
swap(arr, i, left);
// 再递归地对左右两个子序列进行相同的排序处理
quickSort(arr, left, i - 1);
quickSort(arr, i + 1, right);
};
// test case:
const arr = Array.from({ length: 100 }).map(() =>
Math.floor(Math.random() * 1000)
);
quickSort(arr, 0, arr.length - 1);
console.log(arr);
复制代码
4.5 算法复杂度
时间复杂度:O( );
空间复杂度:使用递归,每次递归中只使用了常数空间,故空间复杂度为递归的深度,为O( )。
4.6 排序算法稳定性
不稳定。
5. 归并排序
5.1 基本思想
将一个待排序序列分成两个子序列 (分),再使用归并排序分别对这两个子序列排序 (治),将排好序的两个子序列合并成一个有序序列 (合),也是运用了分治思想。
5.2 排序动画
5.3 实现思路
5.3.1 递归法(自顶向下)
- 将待排序序列从中间位置一分为二,分成两个子序列;
- 再递归对这两个子序列分别进行归并排序,递归的终点是子序列中只有一个数,则认为该序列有序;
- 将有序的子序列两两合并得到一个更大的有序序列;合并过程如下:
- 创建一个结果数组用于保存合并结果;
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入结果数组,并移动指针到下一位置;
- 重复上一个步骤直到某一指针到达序列尾;
- 然后将另一序列剩下的所有元素直接复制到合并序列尾。
5.3.2 代码实现一
这种实现需要开辟left和right数组的内存空间,且排序后的是一个新数组,非原数组;
/**
* 合并两个子序列
*/
const merge = (left, right) => {
const result = [];
while (left.length && right.length) {
// 因为left和right都是有序的,比较两个数组中谁的头部数字小,就先放进结果数组,保证result是从小到大的顺序
if (left[0] < right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
// 当一个数组已经空了,就把另一个数组依次push进result中
while (left.length) {
result.push(left.shift());
}
while (right.length) {
result.push(right.shift());
}
return result;
};
const mergeSort = (arr) => {
const len = arr.length;
// 如果仅有1项或者0项,无需排序
if (len < 2) return arr;
// 在中间位置,将序列分成两个子序列
const mid = Math.floor(len / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid);
// 使两个子数组有序,
// 再将这两个有序子数组合并成一个有序的数组
return merge(mergeSort(left), mergeSort(right));
};
// test case:
const arr = Array.from({ length: 30 }).map(() =>
Math.floor(Math.random() * 1000)
);
console.log(mergeSort(arr));
复制代码
5.3.3 代码实现二
这种实现使用指针替代了开辟left和right数组内存空间,且在原数组上进行排序。
/**
* 合并两个子序列
* 子序列是数组中的[left, right]区间内以mid为界被分开的两个子序列
*/
const merge = (arr, left, mid, right) => {
let i = left;
let j = mid + 1;
// 用于临时存放合并好的结果
const result = [];
// 判断两个子序列的头部元素,哪个更小,就优先放进result数组
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
result.push(arr[i++]);
} else {
result.push(arr[j++]);
}
}
// 将剩下的一个子序列中的剩余元素依次放进result数组
while (i <= mid) {
result.push(arr[i++]);
}
while (j <= right) {
result.push(arr[j++]);
}
// 将result中保存的元素依次复制到arr中的对应位置
// 此处我们从后往前复制
while (right >= left) {
arr[right] = result[right - left];
right--;
}
};
const sort = (arr, left, right) => {
// 如果子序列仅有1项或者0项,无需排序
if (right <= left) return;
const mid = left + Math.floor((right - left) / 2);
// 使两个子数组有序,
sort(arr, left, mid);
sort(arr, mid + 1, right);
// 再将这两个有序的子序列合并成一个大的有序序列
merge(arr, left, mid, right);
};
const mergeSort = (arr) => {
sort(arr, 0, arr.length - 1);
};
// test case:
const arr = Array.from({ length: 30 }).map(() =>
Math.floor(Math.random() * 1000)
);
mergeSort(arr);
console.log(arr);
复制代码
5.3.4 迭代法(自底向上)
- 将序列每相邻两个数字进行归并操作,形成 个序列,排序后每个序列包含两个(或一个)元素;
- 若此时序列数大于1个则将上述序列再次归并,形成 个序列,每个序列包含四个(或三个)元素;
- 重复步骤2,直到所有元素排序完毕,即序列数为1。
5.3.5 代码实现
该实现中的merge过程及代码与上述递归法中第二种实现相同,并且也是在原数组上排序。
/**
* 合并两个子序列
* 子序列是数组中的[left, right]区间内以mid为界被分开的两个子序列
*/
const merge = (arr, left, mid, right) => {
let i = left;
let j = mid + 1;
// 用于临时存放合并好的结果
const result = [];
// 判断两个子序列的头部元素,哪个更小,就优先放进result数组
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
result.push(arr[i++]);
} else {
result.push(arr[j++]);
}
}
// 将剩下的一个子序列中的剩余元素依次放进result数组
while (i <= mid) {
result.push(arr[i++]);
}
while (j <= right) {
result.push(arr[j++]);
}
// 将result中保存的元素依次复制到arr中的对应位置
// 此处我们从后往前复制
while (right >= left) {
arr[right] = result[right - left];
right--;
}
};
const mergeSort = (arr) => {
const len = arr.length;
if (len < 2) return;
// 子序列元素个数,为1时,子序列仅有1个元素,如[3]和[5]合并,以此类推
let k = 1;
while (k < len) {
// 两个完整子序列(完整:子序列元素个数为k)合并完后的元素个数
const count = 2 * k;
let i;
// i < len - count是为了保证在这个for循环中的合并都是完整子序列合并
for (i = 0; i < len - count; i += count) {
merge(arr, i, i + k - 1, i + count - 1);
}
// 最后可能存在一个子序列,则不需要合并,因为每个子序列已是有序的;
// 最后如果存在两个子序列(i + k < len 的情况),两个子序列可能不全是完整子序列,比如[3,5,8,16]和[2,7],
// 如果按照上面的for循环中right传入i + count - 1的话,将会超出arr的长度,故单独处理
if (i + k < len) {
merge(arr, i, i + k - 1, len - 1);
}
k *= 2;
}
};
// test case:
const arr = Array.from({ length: 90 }).map(() =>
Math.floor(Math.random() * 1000)
);
mergeSort(arr);
console.log(arr);
复制代码
5.4 算法复杂度
时间复杂度:与原数组的初始状态无关,不管元素在什么情况下,都要执行这些步骤,最好和最坏的时间复杂度都是O( );
空间复杂度:使用递归法时,merge
过程临时的结果数组result
和递归时压入栈的数据占用的空间:n + logn;迭代法时,只有merge
过程临时的结果数组result
占用空间:n,所以归并排序的空间复杂度为 O(
)。
5.5 算法稳定性
关键在于merge
方法中,我们设定了arr[i] <= arr[j]
,所以当两个元素相同时,我们会优先把位于左边的arr[i]
放入结果数组,它们的相对位置没有发生变化,因此归并排序是一种稳定的排序算法。
6. 计数排序
6.1 基本思想
利用数组索引是有序的这个特点,使用一个计数数组
count
,将待排序数组中的元素arr[i]
对应为数组的索引(桶),count[arr[i]]
对应为这个数在待排序数组中出现的次数,然后依次读取计数数组生成一个有序数组,即为排序结果。这种算法使用值的大小作为数组索引,因此更适用于待排序数组中的元素取值范围不大的场景。
6.2 实现思路
- 创建一个计数数组
count
; - 遍历待排序数组,将数组中值为a的元素出现的次数存入计数数组的第a项
count[a]
; - 创建一个结果数组,遍历计数数组,
count[i]
大于 0 则往结果数组中push一个i
,同时执行count[i]--
,如果count[i]
不存在计数或者等于 0,则i++
,然后再进行相同的处理; - 计数数组遍历结束,返回结果数组。
6.3 排序动画
6.4 代码实现
const countSort = (arr) => {
let len = arr.length;
const count = [];
// 计数
for (let i = 0; i < len; i++) {
count[arr[i]] = (count[arr[i]] || 0) + 1;
}
len = count.length;
const result = [];
for (let i = 0; i < len; i++) {
while (count[i]) {
result.push(i);
count[i]--;
}
}
return result;
};
// test case:
const arr = Array.from({ length: 300 }).map(() =>
Math.floor(Math.random() * 100)
);
console.log(countSort(arr));
复制代码
6.5 算法复杂度
时间复杂度:O( ),n为数组长度,k取决于数组元素的最大值;
空间复杂度:count
的长度取决于数组元素的最大值,空间复杂度为O(
)。
6.6 排序算法稳定性
稳定。
本文出现的代码都可以在这里找到。