排序算法的时间和空间复杂度

# 排序算法的时间和空间复杂度

参见此处

# 排序算法的稳定性

  • 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且 r[i]在 r[j]之前,而在排序后的序列中,r[i]仍在 r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。排序算法的稳定性 (opens new window)

  • 稳定的排序算法有:冒泡排序、插入排序、归并排序、计数排序、桶排序和基数排序。

  • 不稳定的排序算法有:选择排序、快速排序、希尔排序、堆排序。

# 冒泡排序 ✅

# 冒泡排序的思路

  1. 依次比较相邻的两个数,如果第一个比第二个小,不变。如果第一个比第二个大,调换顺序。一轮下来,最后一个是最大的数。

  2. 对除了最后一个之外的数重复第一步,直到只剩一个数。

# 冒泡排序的实现

// 交换数组中下标 i 和 j 的两个元素
function swap(arr, i, j) {
  const temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

// 从前往后冒泡
// 时间复杂度是 O(n²),总比较次数是 n(n-1)/2;空间复杂度是 O(1),原地交换
function bubbleSort(arr) {
  if (arr.length < 2) {
    return arr;
  }
  const len = arr.length;
  // 外层控制轮数,每一轮结束后,未排序部分的最大值 "冒" 到末尾
  for (let i = 0; i < len; i++) {
    // 末尾 i 个元素已就位,属于有序区,不用再比,所以边界是 len - 1 - i
    for (let j = 0; j < len - 1 - i; j++) {
      // 相邻两两比较,大的往后换;相等不交换,保证了排序的稳定性
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
      }
    }
  }
  return arr;
}

// 从后往前冒泡:本质和上面相同,只是外层循环变量换成递减计数
// 时间复杂度是 O(n²);空间复杂度是 O(1)
function bubbleSort(arr) {
  if (arr.length < 2) {
    return arr;
  }
  const len = arr.length;
  // i 表示本轮参与比较的元素个数,每轮结束后减 1(最大值已就位)
  for (let i = len; i >= 2; i--) {
    // 只需比到 i - 1 为止,arr[i - 1] 之后是有序区
    for (let j = 0; j < i - 1; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
      }
    }
  }
  return arr;
}
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
37
38
39
40
41
42
43
44
45

# 冒泡排序的优化

📌 优化点一

冒泡有一个最大的问题就是这种算法不管不管你有序还是没序,闭着眼睛把你循环比较了再说。

比如这个例子:[ 9,8,7,6,5 ],一个有序的数组,根本不需要排序,它仍然是双层循环一个不少的把数据遍历干净,这其实就是做了没必要做的事情,属于浪费资源。

针对这个问题,我们可以设定一个临时遍历来标记该数组是否已经有序。如果在本轮排序中,元素有交换,则说明数列无序;如果没有元素交换,则说明数列已然有序,然后直接跳出大循环。

// 优化一:有序标记,提前退出
// 最好情况(数组本身有序)时间复杂度降到 O(n),最坏和平均仍是 O(n²)
function bubbleSort(arr) {
  const len = arr.length;
  for (let i = 0; i < len; i++) {
    // 有序标记,每一轮的初始值都是 true
    let isSort = true;
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
        // 有元素交换,说明还无序,标记变为 false
        isSort = false;
      }
    }
    // 一整轮下来没有发生任何交换,说明数组已经有序,直接跳出大循环
    if (isSort) {
      break;
    }
  }
  return arr;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

📌 优化点二

以上只是冒泡排序优化的第一步,我们还可以进一步来提升它的性能。

比如有一个新的数组:

3, 4, 2, 1, 5, 6, 7, 8

这个数组的特点是前半部分的元素(3、4、2、1)无序,后半部分的元素(5、6、7、8)按升序排列,并且后半部分元素中的最小值也大于前半部分元素的最大值。

其实这个数组右面的许多元素已经是有序的了,可是如果按照现在的冒泡排序,每一轮还是白白地比较了许多次。这正是冒泡排序中另一个需要优化的点。

这个问题的关键点在于对数组有序区的界定。

那么,该如何避免这种情况呢?我们可以在每一轮排序后,记录下来最后一次元素交换的位置,该位置即为无序数列的边界,再往后就是有序区了。

// 优化二:在优化一的基础上,记录有序区边界,进一步减少无效比较
// 适合 "后半段本来就有序" 的数据;最好情况 O(n),最坏和平均仍是 O(n²)
function bubbleSort(arr) {
  // 记录最后一次交换的位置
  let lastExchangeIndex = 0;
  // 无序数列的边界,每次比较只需要比到这里为止
  let sortBorder = arr.length - 1;
  for (let i = 0; i < arr.length - 1; i++) {
    // 有序标记,每一轮的初始值都是 true
    let isSort = true;
    for (let j = 0; j < sortBorder; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
        // 因为有元素进行交换,所以不是有序的,标记变为 false
        isSort = false;
        // 更新为最后一次交换元素的位置。交换发生在 j 和 j+1 之间,较大的元素落在 j+1 上,
        // 本轮 j+1 之后再没发生过交换,说明从 j+1 开始到末尾都是有序区,所以无序区边界取 j+1
        lastExchangeIndex = j + 1;
      }
    }
    // 最后一次交换的位置之后再没发生过交换,说明后面都是有序区,下一轮只比到这里
    sortBorder = lastExchangeIndex;
    // 一整轮下来没有发生任何交换,说明数组已经有序,直接跳出大循环
    if (isSort) {
      break;
    }
  }
  return arr;
}
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

在这一版代码中,sortBorder 就是无序数列的边界。在每一轮排序过程中,处于 sortBorder 之后的元素就不需要再进行比较了,肯定是有序的。

# 鸡尾酒排序

鸡尾酒排序是基于冒泡排序的一种升级排序算法。

# 鸡尾酒排序的思路

鸡尾酒排序的元素比较和交换过程是双向的。

下面举一个例子。

由 8 个数字组成一个无序数列{2,3,4,5,6,7,8,1},希望对其进行从小到大的排序。

如果按照冒泡排序的思想,排序过程如下。

algorithm

元素 2、3、4、5、6、7、8 已经是有序的了,只有元素 1 的位置不对,却还要进行 7 轮排序。

鸡尾酒排序正是要解决这个问题。

如果按照鸡尾酒排序,详细过程如下。

第 1 轮(和冒泡排序一样,8 和 1 交换)

algorithm

第 2 轮

此时开始不一样了,我们反过来从右往左比较并进行交换。

algorithm

algorithm

第 3 轮(虽然实际上已经有序,但是流程并没有结束)

在鸡尾酒排序的第 3 轮,需要重新从左向右比较并进行交换。

1 和 2 比较,位置不变;2 和 3 比较,位置不变;3 和 4 比较,位置不变......6 和 7 比较,位置不变。

没有元素位置进行交换,证明已经有序,排序结束。

这就是鸡尾酒排序的思路。排序过程就像钟摆一样,第 1 轮从左到右,第 2 轮从右到左,第 3 轮再从左到右......

这样,本来要用 7 轮排序的场景,用 3 轮就解决了。

# 鸡尾酒排序的实现

function swap(arr, i, j) {
  const temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

function cocktailSort(arr) {
  const len = arr.length;
  for (let i = 0; i < len / 2; i++) {
    // 有序标记,每一轮的初始值都是 true
    let isSort = true;
    // 奇数轮,从左向右比较和交换
    for (let j = i; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
        // 有元素交换,所以不是有序的,标记变为 false
        isSort = false;
      }
    }
    if (isSort) {
      break;
    }
    // 在偶数轮之前,将 isSort 重新标记为 true
    isSort = true;
    // 偶数轮,从右向左比较和交换
    for (let j = len - 1 - i; j > i; j--) {
      if (arr[j] < arr[j - 1]) {
        swap(arr, j, j - 1);
        // 因为有元素进行交换,所以不是有序的,标记变为 false
        isSort = false;
      }
    }
    if (isSort) {
      break;
    }
  }
}
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
37

这段代码是鸡尾酒排序的原始实现。代码外层的大循环控制着所有排序回合,大循环内包含 2 个小循环,第 1 个小循环从左向右比较并交换元素,第 2 个小循环从右向左比较并交换元素。

# 鸡尾酒排序的适用场景

  • 鸡尾酒排序的优点是能够在特定条件下,减少排序的回合数;而缺点也很明显,就是代码量几乎增加了 1 倍。

  • 因此,鸡尾酒排序能发挥出优势的场景,就是大部分元素已经有序的情况。

# 选择排序 ✅

# 选择排序的思路

  1. 首先,找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。

  2. 至于选大还是选小,这个都无所谓,你也可以每次选择最大的拎出来排,也可以每次选择最小的拎出来的排,只要你的排序的手段是这种方式,都叫选择排序。

# 选择排序的实现

// 交换数组中下标 i 和 j 的两个元素
function swap(arr, i, j) {
  const temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

// 时间复杂度是 O(n²),比较次数固定是 n(n-1)/2,最好、最坏、平均都一样,和数据是否有序无关
// 空间复杂度是 O(1),原地排序
// 不稳定排序:跨距离交换会破坏相等元素的相对次序,例如 [5a, 5b, 2] 第一轮后变成 [2, 5b, 5a]
function selectionSort(arr) {
  if (arr.length < 2) {
    return arr;
  }
  const len = arr.length;
  // [0, i) 是已排序区,[i, len) 是未排序区,每轮把未排序区的最小值放到位置 i
  // 只需 len - 1 轮,前 n-1 个都就位后,最后一个元素自动就位
  for (let i = 0; i < len - 1; i++) {
    let min = i; // 先假设当前位置就是最小值
    // 内层只比较、不交换,用 min 记住未排序区最小值的下标。
    // 如果边比边换,一轮里会产生很多次无谓的交换(最坏 O(n²) 次);
    // 先扫完确定最小值在哪,再做唯一一次必要的交换,总交换次数就能控制在 n-1 次以内
    for (let j = i + 1; j < len; j++) {
      if (arr[j] < arr[min]) {
        min = j;
      }
    }
    // 一整轮扫描结束后,一次性把最小值换到位置 i,所以交换次数最多 n-1 次
    swap(arr, i, min);
  }
  return arr;
}
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

# 插入排序 ✅

# 插入排序的思路

  1. 插入排序的思想和我们打扑克摸牌的时候一样,从牌堆里一张一张摸起来的牌都是乱序的,我们会把摸起来的牌插入到左手中合适的位置,让左手中的牌时刻保持一个有序的状态。

  2. 实现时会把数组分为已排序和未排序两部分,第一个数为已排序,其余为未排序。然后从未排序抽出第一个数,和已排序部分比较,插入到合适的位置。重复此操作直到数组排序完成。

# 插入排序的实现

// 插入排序:将数组分成左侧已排序区和右侧未排序区,
// 每轮取出未排序区的第一个元素,将它插入已排序区的合适位置。
// 最好时间复杂度 O(n),平均和最坏时间复杂度 O(n²),空间复杂度 O(1)。
function insertionSort(arr) {
  if (arr.length < 2) {
    return arr;
  }

  // 下标 0 可以看作初始的已排序区,所以从下标 1 开始处理
  for (let i = 1; i < arr.length; i++) {
    // 暂存当前待插入的元素,防止后续右移元素时将它覆盖
    const value = arr[i];
    let j = 0;

    // 从已排序区末尾向前查找插入位置
    // 凡是比 value 大的元素,都向右移动一位,为 value 腾出位置
    for (j = i - 1; j >= 0 && arr[j] > value; j--) {
      arr[j + 1] = arr[j];
    }

    // 循环结束后,j + 1 就是 value 应该插入的位置
    // 条件使用 > 而不是 >=,相等元素不会交换位置,因此排序是稳定的
    arr[j + 1] = value;
  }

  return arr;
}
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

# 希尔排序

# 希尔排序的思路

  1. 希尔排序这个名字,来源于它的发明者希尔,也称作“缩小增量排序”,是插入排序的一种更高效的改进版本。它通过定义一个间隔序列来表示在排序过程中进行比较的元素间隔。

  2. 我们知道,插入排序对于大规模的乱序数组的时候效率是比较慢的,因为它每次只能将数据移动一位,希尔排序为了加快插入的速度,让数据移动的时候可以实现跳跃移动,节省了一部分的时间开支。当区间为 1 的时候,它使用的排序方式就是插入排序。

# 希尔排序的实现

function shellSort(arr) {
  const len = arr.length;
  let gap = 1;
  while (gap < len) {
    gap = gap * 3 + 1;
  }
  while (gap > 0) {
    for (let i = gap; i < len; i++) {
      const temp = arr[i];
      let j = i - gap;
      // 跨区间排序
      while (j >= 0 && arr[j] > temp) {
        arr[j + gap] = arr[j];
        j -= gap;
      }
      arr[j + gap] = temp;
    }
    gap = Math.floor(gap / 3);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

可能你会问为什么区间要以 gap = gap*3 + 1 去计算,其实最优的区间计算方法是没有答案的,这是一个长期未解决的问题,不过差不多都会取在二分之一到三分之一附近。

# 归并排序(合并排序)✅

# 归并排序的思路

归并算法的核心思想是分治法,就是将一个数组一刀切两半,递归切,直到切成单个元素,然后重新组装合并,单个元素合并成小数组,两个小数组合并成大数组,直到最终合并完成,排序完毕。

algorithm

# 归并排序的实现

// 时间复杂度是 O(nlogn),空间复杂度是 O(n)
function mergeSort(arr) {
  // 空数组或只有一个元素的数组本身就是有序的,也是递归的终止条件
  if (arr.length < 2) {
    return arr;
  }

  // 从中间将数组拆分成左右两部分;slice 不会修改原数组
  const middle = Math.floor(arr.length / 2);
  const leftArr = arr.slice(0, middle);
  const rightArr = arr.slice(middle);

  // 分别递归排序左右两部分,再合并两个有序数组
  return merge(mergeSort(leftArr), mergeSort(rightArr));
}

// 使用双指针合并两个有序数组
function merge(leftArr, rightArr) {
  // 存放合并结果,因此该实现不是原地排序
  const res = [];
  let l = 0,
    r = 0;

  // 比较两个指针指向的元素,每次将较小者放入结果数组
  while (l < leftArr.length && r < rightArr.length) {
    // 相等时优先取左侧元素,以保持相等元素原有的相对顺序
    if (leftArr[l] <= rightArr[r]) {
      res.push(leftArr[l]);
      l++;
    } else {
      res.push(rightArr[r]);
      r++;
    }
  }

  // 循环结束后,至少有一侧已经处理完,直接追加另一侧的剩余元素
  return res.concat(leftArr.slice(l)).concat(rightArr.slice(r));
}
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
37
38
// 时间复杂度是 O(nlogn),空间复杂度是 O(n)
// 注意:JavaScript 数组的 shift() 可能需要移动后续元素,频繁调用会影响实际性能
function mergeSort(arr) {
  // 空数组或只有一个元素的数组本身就是有序的,也是递归的终止条件
  if (arr.length < 2) {
    return arr;
  }

  // 从中间将数组拆分成左右两部分;slice 不会修改原数组
  const middle = Math.floor(arr.length / 2);
  const leftArr = arr.slice(0, middle);
  const rightArr = arr.slice(middle);

  // 分别递归排序左右两部分,再合并成一个有序数组
  return merge(mergeSort(leftArr), mergeSort(rightArr));
}

// 合并两个已经有序的数组
function merge(leftArr, rightArr) {
  // 保存最终的合并结果
  const res = [];

  // 两侧都有元素时,比较各自的第一个元素,将较小者放入结果数组
  while (leftArr.length && rightArr.length) {
    if (leftArr[0] <= rightArr[0]) {
      res.push(leftArr.shift());
    } else {
      res.push(rightArr.shift());
    }
  }

  // 右侧处理完后,将左侧剩余的有序元素依次追加到结果中
  while (leftArr.length) {
    res.push(leftArr.shift());
  }

  // 左侧处理完后,将右侧剩余的有序元素依次追加到结果中
  while (rightArr.length) {
    res.push(rightArr.shift());
  }

  // 返回合并完成的新数组
  return res;
}
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
37
38
39
40
41
42
43
44

# 快速排序 ✅

# 快速排序的思路

  1. 快速排序的核心思想也是分治法,分而治之。它的实现方式是每次从序列中选出一个基准值(可以随机选取),其他数依次和基准值做比较,比基准值大的放右边,比基准值小的放左边,然后再对左边和右边的两组数分别选出一个基准值,进行同样的比较移动,重复步骤,直到最后都变成单个元素,整个数组就成了有序的序列。

  2. 基准元素的选择,以及元素的交换,都是快速排序的核心问题。

  3. 基准元素的选择最简单的可以选择数组的第 1 个元素。元素的交换方法有双边循环法和单边循环法两种。

# 基准元素的选择

最简单的方式是选择数列的第 1 个元素。

这种选择在绝大多数情况下是没有问题的。但是,假如有一个原本逆序的数列,期望排序成顺序数列,那么会出现什么情况呢?

algorithm

我们会发现,整个数列并没有被分成两半,每一轮都只确定了基准元素的位置。

在这种情况下,数列的第 1 个元素要么是最小值,要么是最大值,根本无法发挥分治法的优势。

在这种极端情况下,快速排序需要进行 n 轮,时间复杂度退化成了 O(n^2)。

那么,该怎么避免这种情况发生呢?

其实很简单,我们可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素交换位置。

这样一来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。

当然,即使是随机选择基准元素,也会有极小的几率选到数列的最大值或最小值,同样会影响分治的效果。

所以,虽然快速排序的平均时间复杂度是 O(nlogn),但最坏情况下的时间复杂度是 O(n^2)。

# 快速排序的简单实现

// 选择第一个元素作为基准值
// 时间复杂度:平均 O(nlogn),最坏 O(n²);空间复杂度:平均 O(n),最坏 O(n²)
function quickSort(arr) {
  // 递归出口:空数组或单个元素已经有序
  if (arr.length < 2) {
    return arr;
  }

  const pivot = arr[0];
  const left = [],
    right = [];

  // 小于基准值的放左边,其余元素放右边
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }

  // 递归排序左右两部分,再和基准值拼接
  return quickSort(left).concat(pivot, quickSort(right));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 选择中间位置的元素作为基准值,可以降低有序数组划分失衡的概率
// 时间复杂度:平均 O(nlogn),最坏 O(n²);空间复杂度:平均 O(n),最坏 O(n²)
function quickSort(arr) {
  // 递归出口:空数组或单个元素已经有序
  if (arr.length < 2) {
    return arr;
  }

  const pivotIndex = Math.floor(arr.length / 2);
  // 取出基准值;splice 会修改传入的数组
  const pivot = arr.splice(pivotIndex, 1)[0];
  const left = [],
    right = [];

  // 小于基准值的放左边,其余元素放右边
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }

  // 递归排序左右两部分,再和基准值拼接
  return quickSort(left).concat(pivot, quickSort(right));
}
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

# 双边循环法的思路

给出原始数列如下,要求对其从小到大进行排序。

4,7,6,5,3,2,8,1

首先,选定基准元素 pivot,并且设置两个指针 left 和 right,指向数列的最左和最右两个元素。

algorithm

接下来进行第 1 次循环,从 right 指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于 pivot,则指针向左移动;如果小于 pivot,则 right 指针停止移动,切换到 left 指针。

在当前数列中,1 < 4,所以 right 直接停止移动,换到 left 指针,进行下一步行动。

轮到 left 指针行动,让指针所指向的元素和基准元素做比较。如果小于或等于 pivot,则指针向右移动;如果大于 pivot,则 left 指针停止移动。

由于 left 开始指向的是基准元素,判断肯定相等,所以 left 右移 1 位。

algorithm

由于 7 > 4,left 指针在元素 7 的位置停下。这时,让 left 和 right 指针所指向的元素进行交换。

algorithm

接下来,进入第 2 次循环,重新切换到 right 指针,向左移动。right 指针先移动到 8,8>4,继续左移。由于 2<4,停止在 2 的位置。

按照这个思路,后续步骤如图所示。

algorithm

# 双边循环法的实现

function quickSort(arr, startIndex, endIndex) {
  // 递归结束条件: startIndex 大于或等于 endIndex 时
  if (startIndex >= endIndex) {
    return;
  }
  // 得到基准元素位置
  const pivotIndex = partition(arr, startIndex, endIndex);
  // 根据基准元素,分成两部分进行递归排序
  quickSort(arr, startIndex, pivotIndex - 1);
  quickSort(arr, pivotIndex + 1, endIndex);
}

/*
 * 分治(双边循环法)
 * @param arr 待交换的数组
 * @param startIndex 起始下标
 * @param endIndex 结束下标
 */
function partition(arr, startIndex, endIndex) {
  // 取第1个位置(也可以选择随机位置)的元素作为基准元素
  const pivot = arr[startIndex];
  let left = startIndex;
  let right = endIndex;

  while (left !== right) {
    // 控制 right 指针比较并左移
    while (left < right && arr[right] > pivot) {
      right--;
    }
    // 控制 left 指针比较并右移
    while (left < right && arr[left] <= pivot) {
      left++;
    }
    // 交换 left 和 right 指针所指向的元素
    if (left < right) {
      const temp = arr[left];
      arr[left] = arr[right];
      arr[right] = temp;
    }
  }

  // pivot 和指针重合点交换
  arr[startIndex] = arr[left];
  arr[left] = pivot;
  return left;
}
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
37
38
39
40
41
42
43
44
45
46

# 单边循环法的思路

给出原始数列如下,要求对其从小到大进行排序。

4,7,6,5,3,2,8,1

开始和双边循环法相似,首先选定基准元素 pivot。同时,设置一个 mark 指针指向数列起始位置,这个 mark 指针代表小于基准元素的区域边界。

algorithm

接下来,从基准元素的下一个位置开始遍历数组。

如果遍历到的元素大于基准元素,就继续往后遍历。

如果遍历到的元素小于基准元素,则需要做两件事:第一,把 mark 指针右移 1 位,因为小于 pivot 的区域边界增大了 1;第二,让最新遍历到的元素和 mark 指针所在位置的元素交换位置,因为最新遍历的元素归属于小于 pivot 的区域。

首先遍历到元素 7,7>4,所以继续遍历。

algorithm

接下来遍历到的元素是 3,3<4,所以 mark 指针右移 1 位。

algorithm

随后,让元素 3 和 mark 指针所在位置的元素交换,因为元素 3 归属于小于 pivot 的区域。

algorithm

按照这个思路,继续遍历,后续步骤如图所示。

algorithm

# 单边循环法的实现

单边循环法和双边循环法的区别在于 partition 函数的实现。

function quickSort(arr, startIndex, endIndex) {
  // 递归结束条件: startIndex 大于或等于 endIndex 时
  if (startIndex >= endIndex) {
    return;
  }
  // 得到基准元素位置
  const pivotIndex = partition(arr, startIndex, endIndex);
  // 根据基准元素,分成两部分进行递归排序
  quickSort(arr, startIndex, pivotIndex - 1);
  quickSort(arr, pivotIndex + 1, endIndex);
}

/*
 * 分治(单边循环法)
 * @param arr 待交换的数组
 * @param startIndex 起始下标
 * @param endIndex 结束下标
 */
function partition(arr, startIndex, endIndex) {
  // 取第1个位置(也可以选择随机位置)的元素作为基准元素
  const pivot = arr[startIndex];
  let mark = startIndex;

  for (let i = startIndex + 1; i <= endIndex; i++) {
    if (arr[i] < pivot) {
      mark++;
      const temp = arr[mark];
      arr[mark] = arr[i];
      arr[i] = temp;
    }
  }

  arr[startIndex] = arr[mark];
  arr[mark] = pivot;
  return mark;
}
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 quickSort(arr, startIndex, endIndex) {
  // 用一个栈来代替函数的递归栈
  const stack = [];
  // 整个数组的起止下标,以哈希的形式入栈
  const indexMap = new Map();
  indexMap.set("startIndex", startIndex);
  indexMap.set("endIndex", endIndex);
  stack.push(indexMap);

  // 循环结束条件:栈为空时
  while (stack.length) {
    // 栈顶元素出栈,得到起止下标
    const map = stack.pop();
    // 得到基准元素位置
    const pivotIndex = partition(
      arr,
      map.get("startIndex"),
      map.get("endIndex")
    );
    // 根据基准元素分成两部分, 把每一部分的起止下标入栈
    if (map.get("startIndex") < pivotIndex - 1) {
      const leftMap = new Map();
      leftMap.set("startIndex", map.get("startIndex"));
      leftMap.set("endIndex", pivotIndex - 1);
      stack.push(leftMap);
    }
    if (pivotIndex + 1 < map.get("endIndex")) {
      const rightMap = new Map();
      rightMap.set("startIndex", pivotIndex + 1);
      rightMap.set("endIndex", map.get("endIndex"));
      stack.push(rightMap);
    }
  }

  /*
   * 分治(单边循环法)
   * @param arr 待交换的数组
   * @param startIndex 起始下标
   * @param endIndex 结束下标
   */
  function partition(arr, startIndex, endIndex) {
    // 取第1个位置(也可以选择随机位置)的元素作为基准元素
    const pivot = arr[startIndex];
    let mark = startIndex;

    for (let i = startIndex + 1; i <= endIndex; i++) {
      if (arr[i] < pivot) {
        mark++;
        const temp = arr[mark];
        arr[mark] = arr[i];
        arr[i] = temp;
      }
    }

    arr[startIndex] = arr[mark];
    arr[mark] = pivot;
    return mark;
  }
}
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# 计数排序

# 计数排序的思路

  1. 计数排序、桶排序、基数排序都是不基于元素比较的排序算法。其中,计数排序是利用数组下标来确定元素的正确位置的。

  2. 计数排序的基本思路如下:

假设数组中有 20 个随机整数,取值范围为 0~10,要求用最快的速度把这 20 个整数从小到大进行排序。

如何给这些无序的随机整数进行排序呢?

考虑到这些整数只能够在 0、1、2、3、4、5、6、7、8、9、10 这 11 个数中取值,取值范围有限。所以,可以根据这有限的范围,建立一个长度为 11 的数组。数组下标从 0 到 10,元素初始值全为 0。

algorithm

假设 20 个随机整数的值如下所示。

9,3,5,4,9,1,2,7,8,1,3,6,5,3,4,0,10,9,7,9

下面就开始遍历这个无序的随机数列,每一个整数按照其值对号入座,同时,对应数组下标的元素进行加 1 操作。

例如第 1 个整数是 9,那么数组下标为 9 的元素加 1。

algorithm

第 2 个整数是 3,那么数组下标为 3 的元素加 1。

algorithm

继续遍历数列并修改数组......

最终,当数列遍历完毕时,数组的状态如下。

algorithm

该数组中每一个下标位置的值代表数列中对应整数出现的次数。

有了这个统计结果,排序就很简单了。直接遍历数组,输出数组元素的下标值,元素的值是几,就输出几次。

0,1,1,2,3,3,3,4,4,5,5,6,7,7,8,9,9,9,9,10

显然,现在输出的数列已经是有序的了。

# 计数排序的实现

function countSort(arr) {
  // 1.得到数列的最大值
  let max = arr[0];
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] > max) {
      max = arr[i];
    }
  }
  // 2.根据数列最大值确定统计数组的长度
  const countArr = new Array(max + 1).fill(0);
  // 3.遍历数列,填充统计数组
  for (let i = 0; i < arr.length; i++) {
    countArr[arr[i]]++;
  }
  //4.遍历统计数组,输出结果
  let index = 0;
  for (let i = 0; i < countArr.length; i++) {
    if (countArr[i] > 0) {
      arr[index++] = i;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 计数排序的优化

上述的代码虽然可以实现整数的排序,但是也存在一些问题。

📌 优化点一

问题就在于,我们只以数列的最大值来决定统计数组的长度,这其实并不严谨。比如下面这个例子。

95,94,91,98,99,90,99,93,91,92

这个数列的最大值是 99,但最小的整数是 90。如果创建长度为 100 的数组,那么前面从 0 到 89 的空间位置就都浪费了!

如何解决这个问题呢?

很简单,只要不再以输入数列的最大值+1 作为统计数组的长度,而是以数列最大值-最小值+1作为统计数组的长度即可。

同时,数列的最小值作为一个偏移量,用于计算整数在统计数组中的下标。

以刚才的数列为例,统计出数组的长度为 99-90+1=10,偏移量等于数列的最小值 90。

对于第 1 个整数 95,对应的统计数组下标是 95-90 = 5,如图所示。

algorithm

这确实对计数排序进行了优化。此外,朴素版的计数排序只是简单地按照统计数组的下标输出元素值,并没有真正给原始数列进行排序。

📌 优化点二

如果只是单纯地给整数排序,这样做并没有问题。但如果在现实业务里,例如给学生的考试分数进行排序,遇到相同的分数就会分不清谁是谁。

比如下面这个例子。

algorithm

给出一个学生成绩表,要求按成绩从低到高进行排序,如果成绩相同,则遵循原表固有顺序。

那么,当我们填充统计数组以后,只知道有两个成绩并列为 95 分的同学,却不知道哪一个是小红,哪一个是小绿。

algorithm

在这种情况下,需要稍微改变之前的逻辑,在填充完统计数组以后,对统计数组做一下变形。

仍然以刚才的学生成绩表为例,将之前的统计数组变形成下面的样子。

algorithm

这是如何变形的呢?其实就是从统计数组的第 2 个元素开始,每一个元素都加上前面所有元素之和。

为什么要相加呢?

样相加的目的,是让统计数组存储的元素值,等于相应整数的最终排序位置的序号。例如下标是 9 的元素值为 5,代表原始数列的整数 9,最终的排序在第 5 位。

接下来,创建输出数组 sortedArray,长度和输入数列一致。然后从后向前遍历输入数列。

第 1 步,遍历成绩表最后一行的小绿同学的成绩。

小绿的成绩是 95 分,找到 countArray 下标是 5 的元素,值是 4,代表小绿的成绩排名位置在第 4 位。

同时,给 countArray 下标是 5 的元素值减 1,从 4 变成 3,代表下次再遇到 95 分的成绩时,最终排名是第 3。

algorithm

第 2 步,遍历成绩表倒数第 2 行的小白同学的成绩。

小白的成绩是 94 分,找到 countArray 下标是 4 的元素,值是 2,代表小白的成绩排名位置在第 2 位。

同时,给 countArray 下标是 4 的元素值减 1,从 2 变成 1,代表下次再遇到 94 分的 成绩时(实际上已经遇不到了),最终排名是第 1。

algorithm

第 3 步,遍历成绩表倒数第 3 行的小红同学的成绩。

小红的成绩是 95 分,找到 countArray 下标是 5 的元素,值是 3(最初是 4,减 1 变 成了 3),代表小红的成绩排名位置在第 3 位。

同时,给 countArray 下标是 5 的元素值减 1,从 3 变成 2,代表下次再遇到 95 分的成绩时(实际上已经遇不到了),最终排名是第 2。

algorithm

这样一来,同样是 95 分的小红和小绿就能够清楚地排出顺序了,也正因为此,优化版本的计数排序属于稳定排序。

后面的遍历过程以此类推。

function countSort(array) {
  // 1.得到数列的最大值和最小值,并算出差值d
  let min = array[0];
  let max = array[0];
  for (let i = 1; i < array.length; i++) {
    if (array[i] > max) {
      max = array[i];
    }
    if (array[i] < min) {
      min = array[i];
    }
  }
  const d = max - min;
  // 2.创建统计数组并统计对应元素的个数
  const countArray = new Array(d + 1).fill(0);
  for (let i = 0; i < array.length; i++) {
    // 数列的最小值作为一个偏移量,用于计算整数在统计数组中的下标
    countArray[array[i] - min]++;
  }
  // 3.统计数组做变形,后面的元素等于前面的元素之和
  for (let i = 1; i < countArray.length; i++) {
    countArray[i] += countArray[i - 1];
  }
  // 4.倒序遍历原始数列,从统计数组找到正确位置,输出到结果数组
  const sortedArray = new Array(array.length).fill(0);
  for (let i = array.length - 1; i >= 0; i--) {
    const offset = array[i] - min;
    sortedArray[countArray[offset] - 1] = array[i];
    countArray[offset]--;
  }
  return sortedArray;
}
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

# 计数排序的局限性

1. 当数列最大和最小值差距过大时,并不适合用计数排序。

例如给出 20 个随机整数,范围在 0 到 1 亿之间,这时如果使用计数排序,需要创建长度为 1 亿的数组。不但严重浪费空间,而且时间复杂度也会随之升高。

2. 当数列元素不是整数时,也不适合用计数排序。

如果数列中的元素都是小数,如 25.213,或 0.00 000 001 这样的数字,则无法创建对应的统计数组。这样显然无法进行计数排序。

因此,计数排序只适用于正整数并且取值范围相差不大的数组排序使用,它的排序的速度是非常可观的。

# 桶排序

# 桶排序的思路

  1. 桶排序同样是一种线性时间的排序算法。类似于计数排序所创建的统计数组,桶排序需要创建若干个桶来协助排序。

  2. 每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。

  3. 桶排序的原理如下。

假设有一个非整数数列如下:

4.5,0.84,3.25,2.18,0.5

桶排序的第 1 步,就是创建这些桶,并确定每一个桶的区间范围。

algorithm

具体需要建立多少个桶,如何确定桶的区间范围,有很多种不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除最后一个桶只包含数列最大值外,前面各个桶的区间按照比例来确定。

区间跨度 = (最大值-最小值)/ (桶的数量 - 1)

第 2 步,遍历原始数列,把元素对号入座放入各个桶中。

algorithm

第 3 步,对每个桶内部的元素分别进行排序(显然,只有第 1 个桶需要排序)。

algorithm

第 4 步,遍历所有的桶,输出所有元素。

0.5,0.84,2.18,3.25,4.5

到此为止,排序结束。

# 桶排序的实现

// 默认情况下,我们会使用 5 个桶。桶排序在所有元素平分到各个桶中时的表现最好。
// 如果元素非常稀疏,则使用更多的桶会更好。如果元素非常密集,则使用较少的桶会更好。
function bucketSort(array, bucketSize = 5) {
  if (array.length < 2) {
    return array;
  }
  // 创建桶并将元素分布到不同的桶中
  const buckets = createBuckets(array, bucketSize);
  // 对每个桶执行插入排序算法和将所有桶合并为排序后的结果数组
  return sortBuckets(buckets);
}

// 创建桶
function createBuckets(array, bucketSize) {
  let minValue = array[0];
  let maxValue = array[0];
  for (let i = 1; i < array.length; i++) {
    if (array[i] < minValue) {
      minValue = array[i];
    } else if (array[i] > maxValue) {
      maxValue = array[i];
    }
  }
  // 计算每个桶中需要分布的元素个数
  // 计算方法是:计算数组最大值和最小值的差值并与桶的大小进行除法计算
  const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
  const buckets = [];
  for (let i = 0; i < bucketCount; i++) {
    buckets[i] = [];
  }
  // 遍历数组中的每个元素,计算要将元素放到哪个桶中,并将元素插入正确的桶中
  for (let i = 0; i < array.length; i++) {
    const bucketIndex = Math.floor((array[i] - minValue) / bucketSize);
    buckets[bucketIndex].push(array[i]);
  }
  return buckets;
}

// 将每个桶进行排序
function sortBuckets(buckets) {
  const sortedArray = [];
  for (let i = 0; i < buckets.length; i++) {
    if (buckets[i] != null) {
      // 遍历每个可迭代的桶并应用插入排序,根据场景,我们还可以应用其他的排序算法,例如快速排序
      insertSort(buckets[i]);
      sortedArray.push(...buckets[i]);
    }
  }
  return sortedArray;
}

// 插入排序
function insertSort(arr) {
  for (let i = 1; i < arr.length; i++) {
    const value = arr[i];
    let j = 0; // 插入的位置
    for (j = i - 1; j >= 0 && arr[j] > value; j--) {
      arr[j + 1] = arr[j]; // 移动数据
    }
    arr[j + 1] = value; // 插入数据
  }
}
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

# 基数排序

# 基数排序的思路

基数排序也是一个分布式排序算法,它根据数字的有效位或基数(这也是它为什么叫基数排序)将整数分布到桶中。基数是基于数组中值的记数制的。

比如,对于十进制数,使用的基数是 10。因此,算法将会使用 10 个桶用来分布元素并且首先基于个位数字进行排序,然后基于十位数字,然后基于百位数字,以此类推。

algorithm

# 基数排序的实现

function radixSort(array, radixBase = 10) {
  if (array.length < 2) {
    return array;
  }
  const minValue = Math.min(...array);
  const maxValue = Math.max(...array);
  // 基数排序也用来排序整数,我们就从最后一位开始排序所有的数,这个算法也可以被修改成支持排序字母字符。
  let significantDigit = 1;
  // 首先只会基于最后一位有效位对数字进行排序,在下次迭代时,我们会基于第二个有效位进行排序(十位数字),
  // 然后是第三个有效位(百位数字),以此类推。我们继续这个过程直到没有待排序的有效位
  // 这也是为什么我们需要知道数组中的最小值和最大值。
  // 如果数组中包含的值都在 1~9,以下循环只会执行一次。如果值都小于 99,则循环会执行第二次,以此类推。
  while ((maxValue - minValue) / significantDigit >= 1) {
    array = countingSortForRadix(array, radixBase, significantDigit, minValue);
    significantDigit *= radixBase;
  }
  return array;
}

function countingSortForRadix(array, radixBase, significantDigit, minValue) {
  let bucketsIndex;
  const buckets = [];
  const res = [];
  // 基于基数初始化桶,由于我们排序的是十进制数,那么需要 10 个桶。
  for (let i = 0; i < radixBase; i++) {
    buckets[i] = 0;
  }
  // 基于数组中数的有效位进行计数排序
  for (let i = 0; i < array.length; i++) {
    bucketsIndex = Math.floor(
      ((array[i] - minValue) / significantDigit) % radixBase
    ); // 有效位
    buckets[bucketsIndex]++; // 计数排序
  }
  // 由于我们进行的是计数排序,我们还需要计算累积结果来得到正确的计数值
  for (let i = 1; i < radixBase; i++) {
    buckets[i] += buckets[i - 1];
  }
  // 对原始数组中的每个值,我们会再次获取它的有效位并将它的值移动到 res 数组中(从 buckets 数组中减去它的计数值)
  for (let i = array.length - 1; i >= 0; i--) {
    bucketsIndex = Math.floor(
      ((array[i] - minValue) / significantDigit) % radixBase
    );
    res[--buckets[bucketsIndex]] = array[i];
  }
  return res;
}
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
37
38
39
40
41
42
43
44
45
46
47
上次更新时间: 2026年09月18日 02:16:22