0%

用JavaScript实现八大排序

八大排序分别是 冒泡排序、选择排序、插入排序、希尔排序、快速排序、归并排序、堆排序、基数排序。本文介绍这八大排序算法的基本思想和过程,并尝试使用JavaScript实现它们。

各个排序的复杂度如下表所示:

排序 平均情况 最好情况 最坏情况 辅助空间 稳定性
冒泡排序 O(n^2) O(n) O(n^2) O(1) 稳定
选择排序 O(n^2) O(n^2) O(n^2) O(1) 不稳定
插入排序 O(n^2) O(n) O(n^2) O(1) 稳定
希尔排序 O(n^1.3) O(n logn) O(n^2) O(1) 不稳定
快速排序 O(n logn) O(n logn) O(n^2) O(n logn) 不稳定
归并排序 O(n logn) O(n logn) O(n logn) O(n) 稳定
堆排序 O(n logn) O(n logn) O(n logn) O(1) 不稳定
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+rd)) O(n+rd) 稳定

0 JavaScript自带排序

Array.prototype.sort(),原理为插入排序与快速排序混合。sort会改变原数组,并返回排序后的数组(不过一般没人使用其返回值)。

注意,sort不含参数时,默认是按字母顺序对数组进行升序排序。

1
2
3
4
5
6
7
let fruits = ["banana", "apple", "orange", "mongo"];
fruits.sort();
console.log(fruits); // [ 'apple', 'banana', 'mongo', 'orange' ]

let nums = [40, 100, 1, 5, 25, 10];
nums.sort();
console.log(nums); // [ 1, 10, 100, 25, 40, 5 ]

为使结果按数字顺序进行排序,sort接受一个回调函数作为参数,如下所示:

1
function(a, b) {return a - b;}

可以直接简写为箭头函数:

1
(a, b) => a - b

回调函数的两个参数代表数组中准备比较的两个值,并根据返回值决定排序:

  1. 返回值为负值,将a向前排
  2. 返回值为正值,将b向前排
  3. 返回值为零,ab顺序不变

因此,可以为回调函数填写不同的返回值,以进行不同的排序方式。

1
2
3
4
5
6
7
8
9
let nums = [40, 100, 1, 5, 25, 10];
nums.sort((a, b) => a - b); // 数字升序排序
console.log(nums); // [ 1, 5, 10, 25, 40, 100 ]
nums.sort((a, b) => b - a); // 数字降序排序
console.log(nums); // [ 100, 40, 25, 10, 5, 1 ]

let students = [{id: 1, scores: 85}, {id: 2, scores: 90}, {id: 3, scores: 82}];
students.sort((a, b) => a.scores - b.scores); // 按每个元素的scores属性升序排序
console.log(students); // [ { id: 3, scores: 82 }, { id: 1, scores: 85 }, { id: 2, scores: 90 } ]

注意回调函数的返回值是数值,而非布尔值,不要写成(a, b) => a < b

1 冒泡排序(Bubble Sort)

典型的排序算法。两个数两两比较大小,大数后移(下沉),小数前移(冒泡)。

这里用一个大小为2的窗口,每次从后往前滑动比较,直到最小的数被顶到最前。紧接着对后面n - 1个数进行同样的操作,直至比较完毕。

一共进行n - 1趟比较,第i趟(i从0开始)对n - i个数进行n - i - 1次比较,每趟结束后有一个数被排好位置,因此复杂度为O(n^2)

冒泡排序

1
2
3
4
5
6
7
8
9
10
11
function bubbleSort(arr) {
const final = [...arr], n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = n - 1; j > i; j--) {
if (final[j - 1] > final[j]) {
[final[j - 1], final[j]] = [final[j], final[j - 1]];
}
}
}
return final;
}

优化:n - 1趟比较需要全部完成,如果某趟比较已经完成排序,则进行了多余的遍历。

可以设置标志位flag,表示本趟比较是否进行了冒泡交换,如果一趟下来都没有进行冒泡交换,表示元素已经有序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function bubbleSort(arr) {
const final = [...arr], n = arr.length;
for (let i = 0; i < n - 1; i++) {
let flag = false;
for (let j = n - 1; j > i; j--) {
if (final[j - 1] > final[j]) {
[final[j - 1], final[j]] = [final[j], final[j - 1]];
flag = true;
}
}
if (!flag) {
break;
}
}
return final;
}

2 选择排序(Selection Sort)

找数组中元素的最小值,并顶到最前,然后在后面剩下的元素中重复操作。

i次(i从0开始)遍历,在后面n - i个数中找出最小值,与第i个元素交换并固定下来,一共进行n - 1次遍历,每次遍历有一个数被排好位置,因此复杂度为O(n^2)

选择排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function selectionSort(arr) {
const final = [...arr], n = arr.length;
for (let i = 0; i < n - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < n; j++) {
if (final[j] < final[minIndex]) {
minIndex = j;
}
}
if (minIndex !== i) {
[final[i], final[minIndex]] = [final[minIndex], final[i]];
}
}
return final;
}

3 插入排序(Insertion Sort)

类似于插扑克牌排序,在一个已经排好序的序列中,插入一个新的数,使得新序列依然有序。

每次插入一个数,一共插入n次。第i次(i从0开始)插入下标为i的数时,为比较数组前i + 1个数的大小,需要对这i + 1个数进行一遍冒泡排序。因此复杂度为O(n^2)

代码实现时,下标为0的数默认已插入,且不需要比较,i可以从1开始循环;此外,冒泡排序时,由于元素是有序的,从后往前找插入位置时,找到后即可进入下一轮遍历。

插入排序

插扑克牌排序

1
2
3
4
5
6
7
8
9
10
11
12
13
function insertionSort(arr) {
const final = [...arr], n = arr.length;
for (let i = 1; i < n; i++) {
for (let j = i; j > 0; j--) {
if (final[j - 1] > final[j]) {
[final[j - 1], final[j]] = [final[j], final[j - 1]];
} else {
break;
}
}
}
return final;
}

4 希尔排序(Shell Sort)

当数列基本排好序时,使用插入排序会更高效。因此可以分步长成子序列,对每个子序列做插入排序。

基本思路是,设置初始步长为数组长度的一半,将数组分为若干个子数组,子数组个数即为步长数。对每个子数组做插入排序,过程同上。依次缩小步长,直至步长为1,此时数组经过插入排序后已经完全排序。

时间复杂度为O(n^1.3-2)

插入排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function shellSort(arr) {
const final = [...arr], n = arr.length;
let increment = n;
while (true) {
increment = Math.floor(increment / 2);
// 一共有increment个子数组,起始下标从0到increment-1
for (let k = 0; k < increment; k++) {
// 以下是插入排序
for (let i = k + increment; i < n; i += increment) {
for (let j = i; j > k; j -= increment) {
if (final[j - increment] > final[j]) {
[final[j - increment], final[j]] = [final[j], final[j - increment]];
} else {
break;
}
}
}
}
if (increment === 1) {
break;
}
}
return final;
}

5 快速排序(Quick Sort)

快排yyds!基本思想是分治(分而治之),从数列中选出一个key值,并让其他数分边站(小于往左,大于等于往右),在左右两边重复这一步骤,直至区间长度为1。

如何让数分边站?需要用到挖坑填数的方法。

  • 设这样的一个数组:[72, 6, 57, 88, 60, 42, 83, 73, 48, 85],初始时,设i = 0j = 9key选取第一个数a[0] = 72
  • a[0]被选走后,相当于a[0]出现了一个空洞,我们从后往前(j--)找出一个比key小的数,发现j = 8a[8] = 48满足这个条件,将a[8]填到a[0]中。
  • 这时a[8]又形成了空洞,我们从前往后(i++)找出一个比key大的数,发现i = 3a[3] = 88满足这个条件,将a[3]填到a[8]中。
  • 这一轮结束后,i = 3j = 7key = 72。再重复上述操作,先从后往前找,再从前往后找。如下一轮,找到j = 5a[5] = 42,将a[5]填到a[3]中;找到i = 5,此时i === j,退出。
  • 再将key = 72填到最后的空洞a[5]中,此时数组为:[48, 6, 57, 42, 60, 72, 83, 73, 88, 85],可以看出a[5] = 72前面的数都大于它,后面的数都小于它。再对a[0...4]a[6...9]重复上述步骤即可。

这个过程重复n次,每次对数组对半分,复杂度为O(n logn)

key有多种选择方法,如中间数或随机数,会影响算法复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function quickSort(arr) {
const final = [...arr], n = arr.length;
sorting(0, n - 1);
return final;

function sorting(left, right) {
if (left >= right) {
return;
}
let i = left, j = right;
const key = final[left];
while (i < j) {
while (i < j && final[j] >= key) { // 从右往左找第一个小于key的值
j--;
}
if (i < j) {
final[i] = final[j];
i++;
}
while (i < j && final[i] < key) { // 从左往右找第一个大于key的值
i++;
}
if (i < j) {
final[j] = final[i];
j--;
}
}
// 此时i === j
final[i] = key;
sorting(left, i - 1); // 对左子区间递归调用
sorting(i + 1, right); // 对右子区间递归调用
}
}

6 归并排序(Merge Sort)

基本思想同样是分治。通过归并若干个已经排好序的子序列,达到整个序列完成排序的目的。

首先考虑,如何合并两个有序数列?可以比较两个数列的第一个数,然后取出较小的一个,并在原数列删除该数,再重复操作,如果一个数列已空,依次取出另一个数列的元素即可。代码实现时,不用实际删除,可以用指针移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function test(a, b) {
const m = a.length, n = b.length;
const c = new Array(m + n);
let i = 0, j = 0, k = 0;
while (i < m && j < n) {
if (a[i] < b[j]) {
c[k++] = a[i++];
} else {
c[k++] = b[j++];
}
}
while (i < m) {
c[k++] = a[i++];
}
while (j < n) {
c[k++] = b[j++];
}
return c;
}

根据上面的思想,如果数组分成两组A、B且这两组有序,就可以方便地合并。

如何使A、B有序?将A、B各自再分成两组,以此类推,直到小组内只有1个数据时,视为小组有序,再合并相邻的两个小组即可。也就是先递归分解数列,再合并数列

将数列分开小数列需要logn步,每一步合并有序数列需要O(n),总的时间复杂度为O(n logn)

归并排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function mergeSort(arr) {
const final = [...arr], n = arr.length;
sorting(0, n - 1);
return final;

function sorting(left, right) {
if (left < right) {
let middle = Math.floor((left + right) / 2);
sorting(left, middle); // 对左子区间递归调用
sorting(middle + 1, right); // 对右子区间递归调用
mergeArray(left, middle, right); // 归并左右子区间
}
}

function mergeArray(left, middle, right) {
let i = left, m = middle, j = middle + 1, n = right;
let k = 0;
const temp = new Array(right - left + 1);
while (i <= m && j <= n) {
if (final[i] < final[j]) {
temp[k++] = final[i++];
} else {
temp[k++] = final[j++];
}
}
while (i <= m) {
temp[k++] = final[i++];
}
while (j <= n) {
temp[k++] = final[j++];
}
for (let ii = 0; ii < k; ii++) {
final[left + ii] = temp[ii];
}
}
}

7 堆排序(Heap Sort)

首先,了解什么是最小堆,以及shiftDown操作。堆是用完全二叉树结构(只有二叉结构,且都在左边)存储数据的结构,也称二叉堆。最小堆是指堆中某个节点值不小于其父节点的值,因此最顶端总为最小值。

下图是一个最小堆,堆顶是最小值,每个节点都不小于其父节点。并且可以按照图中所示顺序(从左到右,从上到下),确定每个节点的下标。

最小堆和其在数组中的下标

发现规律:对当前节点下标i,有:

  • 父元素在数组中的下标为:Math.floor((i - 1) / 2)
  • 左子元素在数组中的下标为:2 * i + 1
  • 右子元素在数组中的下标为:2 * i + 2

shiftDown操作是指对于一个堆的根节点,判断其与子节点的大小关系。如果该节点更大,则不满足最小堆定义,此时该节点与子节点互换(下移)。重复这个操作,直到该节点找到它的位置(不再大于其两个子节点)。

接下来介绍堆排序。堆排序的思想是,构造一个最小堆,然后依次取出堆顶最小值,直到堆为空。

首先构造最小堆。对于已有的数组,可以按下标顺序转换为这样一个二叉树。然后,我们找到最后一个含有子节点的节点,这个节点应是最后一个节点n - 1的父节点,根据上面的规律,其下标是Math.floor((n - 2) / 2)

初始二叉树

从后往前遍历,对每个有子节点的节点,都把其当做根节点,对其下面的完全二叉树shiftDown操作(这个过程称为建立堆的调整),直到整个二叉堆满足最小堆,最小堆构造完成。

上述二叉树调整完后,构造出的最小堆

构造完成后,数组并非排好序,因为堆只满足父子节点的大小关系。为此,我们需要依次取出最小值,每次重复取出,直至堆为空。

堆中只能取出最大优先级的元素,即根节点,最小堆的根节点就是最小值。取出最小值后,会在堆顶形成空洞,这时需要把数组最后一个元素放到堆顶(根节点),然后对新的根节点进行shiftDown操作(这个过程称为最小堆的恢复)。

取出最小值后shiftDown

代码实现时,我们可以将设置shiftDown的操作范围,对于已经放到末尾的最小值,shiftDown时不再处理它。

最终得到的数组是降序排序的数组,为了得到升序排序,我们对其reverse即可。

完成堆排序时的场景

每次恢复堆的时间复杂度为O(logn),共n - 1次恢复堆操作,建立堆时也需要调整n / 2次,每次O(logn),总的时间复杂度为O(n logn)

参考:基础堆排序 | 菜鸟教程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function heapSort(arr) {
const final = [...arr], n = arr.length;
makeMinHeap(); // 构造最小堆
for (let i = n - 1; i > 0; i--) {
[final[0], final[i]] = [final[i], final[0]]; // 将最小值放到队尾,队尾填补堆顶
minHeapShiftDown(0, i); // 堆顶元素做shiftDown,为了不影响队尾最小值,设置i为最后子节点位置(不包括i)
}
return final.reverse(); // 将降序数组反转为升序数组

function makeMinHeap() {
// 构造最小堆,从后往前对有子节点的节点做shiftDown
for (let i = Math.floor((n - 2) / 2); i >= 0; i--) {
minHeapShiftDown(i, n);
}
}

function minHeapShiftDown (i, n) {
// i是需要shiftDown的根节点位置,n是最后一个子节点的位置(不包括n)
let j = 2 * i + 1; // 找左子节点位置
while (j < n) {
if (j + 1 < n && final[j] > final[j + 1]) {
j++; // 在左右节点中找较小值
}
if (final[i] <= final[j]) {
break; // 如果父节点小于等于子节点,则该节点有效,无需下移
}
[final[i], final[j]] = [final[j], final[i]]; // 节点下移
i = j;
j = 2 * i + 1;
}
}
}

8 基数排序(Radix Sort)

首先,了解什么是桶排序(Bin Sort,又称箱排序、计数排序)。

最基础的桶排序创建一个大小为max + 1的数组countmax为原数组的最大数字)用来存储每个数字的个数,初始值记为0,然后做两轮遍历:

  1. 遍历原数组nums,将数字对应下标的计数值加1,即count[num]++,这一轮复杂度为O(n)
  2. 遍历计数数组count,将不为0的下标值输出,这一轮复杂度为O(max)

桶排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function binSort(arr) {
const final = [...arr], n = arr.length;
const count = new Array(Math.max(...final) + 1).fill(0);
for (const num of final) {
count[num]++;
}
let k = 0;
for (let i = 0; i < count.length; i++) {
for (let j = 0; j < count[i]; j++) {
final[k++] = i;
}
}
return final;
}

这样的桶排序有一定的问题:如果数组中有负数,就没有对应下标;或者最小值远大于0,此时前面的空间都被浪费了。

优化的桶排序:找出最小值min和最大值max,计数数组长度为max - min + 1,下标也进行偏移,下标为i的元素对原数组中的i + min进行计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function binSort(arr) {
const final = [...arr], n = arr.length;
const min = Math.min(...final), max = Math.max(...final);
const count = new Array(Math.max(max - min + 1)).fill(0);
for (const num of final) {
count[num - min]++;
}
let k = 0;
for (let i = 0; i < count.length; i++) {
for (let j = 0; j < count[i]; j++) {
final[k++] = i + min;
}
}
return final;
}

由于上述桶排序利用count存储原数组的数值信息,丢失了原数组的其他信息(如两个相同数值输出的结果是不稳定的),实际操作中,还会将数组count中的计数改为前缀和,用来快速获得每个元素值的下标(排名)。

这里推荐在获取下标时,从后往前遍历,这样对于相同值的元素,其顺序与原数组保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function binSort(arr) {
const final = [...arr], n = arr.length;
const min = Math.min(...final), max = Math.max(...final);
const count = new Array(Math.max(max - min + 1)).fill(0);
for (const num of final) {
count[num - min]++;
}
for (let i = 1; i < count.length; i++) {
count[i] += count[i - 1]; // 前缀和,其含义是小于等于当前数的个数
}
const temp = new Array(n);
for (let i = n - 1; i >= 0; i--) { // 从后往前遍历保证排序稳定性
count[final[i] - min]--; // 获取下标
temp[count[final[i] - min]] = final[i]; // 赋值
}
for (let i = 0; i < n; i++) {
final[i] = temp[i];
}
return final;
}

桶排序的时间复杂度为O(n + max - min),空间复杂度为O(max - min)。本质上,这是一种拿空间换时间的做法,有时可以达到线性复杂度,优于快排。

可以看出,如果max - min较大,就会浪费过多的时间和空间。

基数排序是在桶排序的基础上,通过基数的限制来减小开销。

如下图所示,确定基数为10,数组长度就为10,每个数会在这10个数中寻找位置。对于某个数如34,不会像桶排序一样把其放在下标34中,而是将其分成3和4,第一轮排序将其放在下标4处,第二轮排序将其放在下标3处。

如何确定排序轮数?排序轮数也就是数组最大值的位数,即log(r) max + 1向下取整。代码实现时,如果某一轮排序所有数字均计数在第0位,表明已经完成排序。

复杂度为O(d(n+r))d为排序轮数,n为数组长度,r为基数大小,d = log(r) max + 1向下取整。

先排个位数,再排十位数

基数排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function radixSort(arr, r) {
const final = [...arr], n = arr.length;
r = r === undefined ? 10 : r;
const count = new Array(r).fill(0);
for (let i = 0, round = 1; ; i++, round *= r) {
count.fill(0);
// 计数
for (const num of final) {
count[Math.floor(num / round) % r]++;
}
// 确定位数,如果所有数字均计数在第0位,表明当前round已经超过最大值
if (count[0] === n) {
break;
}
// 处理为前缀和
for (let j = 1; j < r; j++) {
count[j] = count[j - 1] + count[j];
}
const temp = new Array(n);
// 倒序取出元素,前缀和的值就表示排序后元素的下标位置
for (let j = n - 1; j >= 0; j--) {
count[Math.floor(final[j] / round) % r]--;
temp[count[Math.floor(final[j] / round) % r]] = final[j];
}
for (let j = 0; j < n; j++) {
final[j] = temp[j];
}
}
return final;
}

Reference

  1. JavaScript 数组排序
  2. 排序算法总结 | 菜鸟教程
  3. 用typescript实现八大排序-递增 - Kingfish404
  4. 堆的基本存储 | 菜鸟教程