0. 算法复杂度
排序算法 | 时间复杂度(平均) | 空间复杂度 | 稳定性 |
---|---|---|---|
冒泡排序 | O(n2) | O(1) | 稳定 |
快速排序 | O(nlogn) | O(1) | 不稳定 |
简单插入排序 | O(n2) | O(1) | 稳定 |
shell 排序 | O(n1.3) | O(1) | 不稳定 |
简单选择排序 | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(1) | 不稳定 |
二路归并排序 | O(nlogn) | O(n) | 稳定 |
计数排序 | O(n + k) | O(n + k) | 稳定 |
桶排序 | O(n + k) | O(n + k) | 稳定 |
基数排序 | O(n * k) | O(n + k) | 稳定 |
TIP:
以下算法的说明与实现,均以升序排列为例。
1. 冒泡排序
基本思想:
- 从数组第一个元素开始,重复比较前后相邻的 2 个数组元素,如果前面一个元素大于后面一个元素,则交换 2 个元素的位置;
- 经过步骤 1 描述的一轮循环后,无序数列中的最大值将被放置在无序数列的末尾(此时,该值已有序)。至此,无序数列长度减 1;
- 对无序数列重复步骤 1、2,直至整个数列有序(无序数列长度为 0)。
复杂度分析:
- 时间复杂度:
循环最内层语句的执行次数为:(n - 1)*(n - 1 - i)。保留最高指数,得到时间复杂度为:O(n2)。
- 空间复杂度:
该算法中,对数组的操作都是就地操作(in-place),没有用到额外的存储空间,因此空间复杂度为:O(1)。
- 稳定性
在对相邻数据进行比较时,只有在 arr[i] > arr[j](i > j) 的情况下,才进行交换位置的操作,这样就可以保证相等的两个元素在完成比较后,依旧保留原本的前后相对位置。因此,冒泡排序是稳定的。
代码实现:
// 冒泡排序(升序)
function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
bubbleSort([3, 1, 6, 5, 7]);
优化:
可以使用一个变量来表示上一轮冒泡是否交换过数据,如果没有,就表示数列以及有序了,可以跳过后面的冒泡操作,直接返回了!
2. 快速排序
基本思想:
- 选择数列中的一个元素(在这选择待比较数列第一个元素)作为基准(pivot);
- 把小于基准值的元素放到基准的左边,把大于基准值的元素放到基准的右边,与基准值大小相等的元素放置在左边还是右边都可以,这一操作称为分区(partition)。至此,可以得到左边和右边的 2 个子数列;
- 对得到的子数列重复进行步骤 1、2,直至每个分区操作的数列都只剩下一个元素,这时候,整个数列就有序了。
复杂度分析:
时间复杂度:
对应一个长度为 n 的数组,需要进行 log2n 次分区操作;完成每次分区操作,需要对每个数据都进行比较操作,总的要进行 n 次比较。因此,快速排序的时间复杂度为:O(nlog2n)。
空间复杂度:
对数组元素的操作是就地操作,不会消耗额外的存储空间。所以,快速排序的空间复杂度为:O(1)。
稳定性:
对于数组 [2,5,5,3,1,7],使用快速排序来排序后,2 个 5 的前后相对位置变了。因此,快速排序是不稳定的。
代码实现:
function exchange(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
function partition(arr, left, right) {
let pivot = arr[left];
let pivotIndex = left;
if (left >= right) {
return;
}
while (left < right) {
while (arr[right] > pivot && left < right) {
right--;
}
while (arr[left] <= pivot && left < right) {
left++;
}
if (left < right) {
exchange(arr, left, right);
}
}
// left === right
exchange(arr, pivotIndex, left);
return left;
}
function quickSort(arr, left, right) {
left = typeof left !== 'number' || Number.isNaN(left) ? 0 : left;
right = typeof right !== 'number' || Number.isNaN(right) ? arr.length - 1 : right;
if (left < right) {
let pivotIndex = partition(arr, left, right);
quickSort(arr, left, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, right);
}
return arr;
}
quickSort([3, 1, 2]);
3. 简单插入排序
基本思想:
- 默认地,将数组中的第一个元素作为有序数组, 剩下的元素作为无序数组;
- 从后往前遍历有序数组,找到合适的位置,插入无序数组的首个元素。至此,有序数组中增加一个元素,无序数组中减少一个元素;
- 重复进行步骤 2,直至无序数组中的全部元素都插入到了有序数组中。
复杂度分析:
- 时间复杂度:
算法中出现了嵌套 2 层的循环。因此,简单插入排序的时间复杂度为:O(n2)。
- 空间复杂度:
算法中对数组是就地操作的,没有消耗额外的空间。因此,简单插入排序的空间复杂度为:O(1)。
- 稳定性:
遍历有序数组的过程中,是将比待插入数组元素大的元素向后移动,否则就将待插入元素插至比较元素之后(这样的话,满足:比较元素 <= 待插入元素),这样的话,可以保证相同元素之间前后相对顺序。因此,插入排序是稳定的。
代码实现:
function insertionSort(arr) {
// 遍历无序数列
for (let i = 1; i < arr.length; i++) {
// 待插入的一个无序数据
let current = arr[i];
// 默认第一个数据为有序的
let j = i - 1;
// 从后往前遍历有序数列
while (j >= 0 && current < arr[j]) {
// 将有序数列中大于无序数据的部分从后往前依次向后移动
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
insertionSort([3, 1, 6, 5, 7]);
4. shell 排序(缩小增量排序)
基本思想:
- 选择一个增量序列t1,t2,…,tk,其中 ti > tj,tk = 1;
- 按增量序列个数 k,对序列进行 k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
复杂度分析:
- 时间复杂度:
与增量序列的选择有关,太复杂了,本人是推不出,就记一个 O(n1.3) 好了。
- 空间复杂度:
在这个算法中,对数组的操作都是就地完成的,不会消耗额外的存储空间。因此,shell 排序的空间复杂度为:O(1)。
- 稳定性:
不稳定
代码实现:
function shellSort(arr) {
for (let gap = Math.floor(arr.length / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < arr.length; i++) {
let current = arr[i];
let j = i;
while (j - gap >= 0 && arr[j - gap] > arr[j]) {
arr[j - gap + 1] = arr[j - gap];
j = j - gap;
}
arr[j] = current;
}
}
return arr;
}
shellSort([3, 1, 6, 5, 7]);
5. 简单选择排序
基本思想:
- 初始时,认为:数组中有序数列为空,无序数列为整个数组。
- 继续遍历无序数列,找出其中的最小值,并把它与有序数列的末尾后面的一个元素交换位置;
- 重复进行步骤 2,经过 n - 1 轮的循环后,整个数组就有序了。
换成一句话概况: 选择未排序的数列中的最小值,放在未排序数列的首位。
复杂度分析:
- 时间复杂度:
2 层嵌套的循环。因此,选择排序的时间复杂度为:O(n2)。
- 空间复杂度:
数组是就地操作的。因此,选择排序的空间复杂度为:O(1)。
- 稳定性:
对于数组 [5, 8, 5, 2, 9],经过选择排序进行排序之后,2 个 5 的前后相对顺序就被破坏了。因此,选择排序是不稳定的。
代码实现:
function selectionSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
return arr;
}
selectionSort([3, 1, 6, 5, 7, 6]);
6. 堆排序
基本思想:
- 将初始待排序关键字序列 (R1,R2….Rn) 构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区 (R1,R2,……Rn-1) 和新的有序区 (Rn) ,且满足 R[1,2…n-1]<=R[n] ;
- 由于交换后新的堆顶 R[1] 可能违反大顶堆的性质,因此需要对当前无序区 (R1,R2,……Rn-1) 调整为新堆;
- 然后再次将 R[1] 与无序区最后一个元素交换,得到新的无序区 (R1,R2….Rn-2) 和新的有序区 (Rn-1,Rn) 。不断重复此过程直到有序区的元素个数为 n-1 ,则整个排序过程完成。
复杂度分析:
- 时间复杂度:
O(nlog2n)
- 空间复杂度:
数组是就地操作的。因此,选择排序的空间复杂度为:O(1)。
- 稳定性:
不稳定
代码实现:
function exchange(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
function shiftDown(arr, i, length) {
for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
if (j + 1 < length && arr[j] < arr[j + 1]) {
j++;
}
if (arr[j] > arr[i]) {
exchange(arr, i, j);
i = j;
} else {
break;
}
}
}
function heapSort(arr) {
// 从下至上,从右至左将数据初始化为大顶堆
for (let i = Math.floor(arr.length / 2 - 1); i >= 0; i--) {
// i 为非叶子节点
shiftDown(arr, i, arr.length);
}
// 将栈顶元素与未排序数列最后一个数据交换,交换后需要将剩下数据组成的数调整为大顶堆
for (let i = arr.length - 1; i > 0; i--) {
exchange(arr, 0, i);
shiftDown(arr, 0, i);
}
return arr;
}
heapSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2]);
7. 二路归并排序
基本思想:
- 将一个长度为 n 的数组分割为 2 个长度为 n / 2 的数组;
- 对 2 个数组分别递归地调用归并排序;
- 将排好序的 2 个数组合并为 1 个数组。
复杂度分析:
- 时间复杂度:
数列的归并树高度为 log2n,每层总的需要进行约 n 次比较。因此,归并排序的时间复杂度为 O(nlog2n)。
- 空间复杂度:
代码执行过程中,需要一个长度为 n 的数组来存储排序结果。因此,归并排序的空间复杂度为:O(n)。
- 稳定性:
合并排序好的 2 个数组的时候,判断条件 left[0] <= right[0] 为 true 时,说明 left 数组和 right 数组的首元素相同,这时候 选择先将 left 数组的首元素移入结果数组中, 就能保证这 2 个相同元素的前后相对位置与原来的一致。因此,归并排序是稳定的。
代码实现:
function merge(left, right) {
let result = [];
while (left.length > 0 && right.length > 0) {
if (left[0] <= right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
return result.concat(left, right);
}
function mergeSort(arr) {
if (arr.length <= 1) {
return arr;
}
let middleIndex = Math.floor(arr.length / 2);
return merge(mergeSort(arr.slice(0, middleIndex)), mergeSort(arr.slice(middleIndex)));
}
mergeSort([3, 1, 6, 5, 7]);
8. 计数排序
基本思想:
- 找到数组中的最大的元素,记为 maxValue;
- 创建一个长度为 maxValue + 1 的数据,用作后续的计数数组 countingArr;
- 遍历数组 arr,累计 countArr[arr[i]] 的值;
- 遍历 countingArr,反向填充得到结果数组 result。
当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法
复杂度分析:
将数组的最大值记为 k
- 时间复杂度:
n + (n + k) => O(n + k)
- 空间复杂度:
算法需要创建一个长度为 n + k 的计数数组 和一个长度为 n 的结果数组。因此,空间复杂度为 O(n + k)。
- 稳定性:
该算法不会改变原数组的元素的位置。因此,计数排序是稳定的。
代码实现:
function countingSort(arr, maxValue) {
maxValue = maxValue || Math.max(...arr);
let countingArr = new Array(maxValue + 1);
let result = [];
for (let i = 0; i < arr.length; i++) {
if (!countingArr[arr[i]]) {
// 初始化键值对应的计数值
countingArr[arr[i]] = 0;
}
// 键值对应的数据出现,计数值加一
countingArr[arr[i]]++;
}
for (let i = 0; i < countingArr.length; i++) {
while (countingArr[i] > 0) {
result.push(i);
countingArr[i]--;
}
}
return result;
}
countingSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2], 9);
9. 桶排序
基本思想:
- 将数据分配到桶中;
- 对桶中的数据进行排序;
- 将排序好的数据合并到结果数组中。
复杂度分析:
- 时间复杂度:
O(n + k)
- 空间复杂度:
O(n + k)
- 稳定性:
稳定,理由同计数排序。
代码实现:
function insertionSort(arr) {
// 遍历无序数列
for (let i = 1; i < arr.length; i++) {
// 待插入的一个无序数据
let current = arr[i];
// 默认第一个数据为有序的
let j = i - 1;
// 从后往前遍历有序数列
while (j >= 0 && current < arr[j]) {
// 将有序数列中大于无序数据的部分从后往前依次向后移动
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
function bucketSort(arr, size) {
let bucketSize = size || 5;
let result = [];
if (arr.length <= 1) {
return arr;
}
let minValue = arr[0];
let maxValue = arr[0];
// 找到数组中的最大值和最小值
for (let i = 1; i < arr.length; i++) {
if (minValue > arr[i]) {
minValue = arr[i];
} else if (maxValue < arr[i]) {
maxValue = arr[i];
}
}
let bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
let buckets = new Array(bucketCount);
// 初始化桶
for (let i = 0; i < bucketCount; i++) {
buckets[i] = [];
}
// 将数据分配到桶中
for (let i = 0; i < arr.length; i++) {
let bucketIndex = Math.floor((arr[i] - minValue) / bucketSize);
buckets[bucketIndex].push(arr[i]);
}
// 使用插入排序,对桶内数据进行排序
for (let i = 0; i < buckets.length; i++) {
insertionSort(buckets[i]);
// 将排序完成的数据放进 result 数组中
result = [...result, ...buckets[i]];
}
return result;
}
bucketSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2]);
10. 基数排序
基本思想:
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成 radix 数组;
- 对 radix 进行计数排序(利用计数排序适用于小范围数的特点);
复杂度分析:
- 时间复杂度:
O(n * k)
- 空间复杂度:
O(n + k)
- 稳定性:
稳定,理由同计数排序。
代码实现:
let counter = [];
function radixSort(arr, maxDigit) {
let mod = 10;
let dev = 1;
for (let i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
for (let j = 0; j < arr.length; j++) {
let bucket = parseInt((arr[j] % mod) / dev);
if (counter[bucket] == null) {
counter[bucket] = [];
}
counter[bucket].push(arr[j]);
}
let pos = 0;
for (let j = 0; j < counter.length; j++) {
let value = null;
if (counter[j] != null) {
while ((value = counter[j].shift()) != null) {
arr[pos++] = value;
}
}
}
}
return arr;
}
radixSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2], 1);