跳转到内容

排序算法分类 ​

按排序思想可以分为: 插入排序、交换排序、选择排序、归并排序、基数排序。

时间复杂度: 常见比较排序的平均时间复杂度有 O(n²) 和 O(n log₂ n) 等。具体分析需要区分最好、平均和最坏情况;希尔排序还取决于增量序列,基数排序则取决于记录数、位数和基数。

稳定性: 若排序后,关键字相等的记录仍保持原来的相对顺序,则称该排序算法是稳定的。

例如按数值关键字排序时,[2A, 1, 2B] 的稳定排序结果是 [1, 2A, 2B],两个关键字为 2 的记录仍按 A、B 排列。字母只用于标识记录,不参与比较;这些标记是概念示例,不是 JavaScript 数值。

以下代码按数值升序排序,除特别说明外,会直接修改传入的数组。

各代码块可独立运行。比较排序示例假定输入为稠密数组(没有空位),每个元素均为有限的 Number,不包含 NaN;示例不重复校验这些前置条件。基数排序的输入范围和校验在对应章节说明。

插入排序 ​

基本思想:

每一步将一个待排序的记录,按其关键字大小插入前面已排序区间的适当位置,直到所有记录有序。

具体实现的3️⃣种不同算法:

  • 直接插入排序:顺序查找插入位置,实现简单。
  • 折半插入排序:折半查找插入位置。
  • 希尔排序:按逐趟缩小的增量进行插入排序。

1️⃣直接插入排序 ​

排序过程: 对非空数组进行 n-1 趟插入:将第 1 个记录看成有序区间,从第 2 个记录开始逐个插入,直至整个数组有序。

js
function insertionSort(L) {
  for (let i = 1; i < L.length; i++) {
    if (L[i] < L[i - 1]) {
      const temp = L[i];
      let j = i - 1;
      while (j >= 0 && temp < L[j]) {
        L[j + 1] = L[j];
        --j;
      }
      L[j + 1] = temp;
    }
  }
  return L;
}
console.log(insertionSort([2,6,3,1,4])); // [1,2,3,4,6]

算法分析:

时间复杂度: 已有序时最好为 O(n),平均和最坏为 O(n²)。
额外空间复杂度: O(1)。
稳定性: 稳定,只移动关键字严格大于待插入记录的元素,不改变相等记录的相对顺序。

2️⃣折半插入排序 ​

排序过程: 插入 L[i] 时,利用折半查找在前面的有序区间中寻找插入位置,再向后移动元素以腾出位置。为保持稳定性,相等时继续向右查找,将新记录放在已有相等记录之后。

js
function binaryInsertionSort(L) {
  for (let i = 1; i < L.length; ++i) {
    const temp = L[i];
    let low = 0;
    let high = i - 1;
    while (low <= high) {
      const m = low + Math.floor((high - low) / 2);
      if (temp < L[m]) {
        high = m - 1;
      } else {
        low = m + 1;
      }
    }
    // 查找结束时 low = high + 1,即插入位置
    for (let j = i; j > low; --j) {
      L[j] = L[j - 1];
    }
    L[low] = temp;
  }
  return L;
}
console.log(binaryInsertionSort([8,2,3,1,5,6,4]));

算法分析:

减少了定位插入位置所需的比较次数,但没有减少元素移动次数。实际性能取决于输入是否有序,以及比较和移动的成本。

时间复杂度: 本实现即使输入已有序,也会逐次执行折半查找,最好为 O(n log₂ n);平均和最坏仍为 O(n²)。
额外空间复杂度: O(1)。
稳定性: 稳定,相等时向右查找,保留相等记录的原有顺序。

3️⃣希尔排序 ​

基本思想:

将相隔增量 dk 的记录组成一个子序列,分别进行直接插入排序;这里的分组不是连续分段。逐趟缩小增量,最后以增量 1 对全体记录进行直接插入排序。

排序过程: 下图和代码默认采用 3、2、1。前几趟通过跨位置移动减少逆序,最后一趟完成全局排序。采用合适的增量序列时,通常可以减少直接插入排序所需的移动,实际性能取决于增量和输入。

希尔排序按增量 3、2、1 排序的过程

图中初始序列为 21、25、49、25*、16、08,星号用于区分两个关键字同为 25 的记录。排序后 25* 出现在普通 25 之前,说明希尔排序不稳定。

增量参数 gaps 必须是非空数组,各增量为正安全整数、严格递减,且最后一个为 1。函数先校验整个增量序列,再修改输入数组,避免错误参数导致部分排序或返回未排序的结果。

js
function shellSort(L, gaps = [3, 2, 1]) {
  const message = "增量序列必须为正安全整数、严格递减并以 1 结尾";
  if (!Array.isArray(gaps) || gaps.length === 0 || gaps[gaps.length - 1] !== 1) {
    throw new RangeError(message);
  }
  let previous = Infinity;
  for (const gap of gaps) {
    if (!Number.isSafeInteger(gap) || gap <= 0 || gap >= previous) {
      throw new RangeError(message);
    }
    previous = gap;
  }
  for (const gap of gaps) {
    shellInsert(L, gap); // 按当前增量进行一趟插入排序
  }
  return L;
}

function shellInsert(L, dk) {
  for (let i = dk; i < L.length; ++i) {
    if (L[i] < L[i - dk]) {
      const temp = L[i];
      let j = i - dk;
      while (j >= 0 && temp < L[j]) {
        L[j + dk] = L[j];
        j -= dk;
      }
      L[j + dk] = temp;
    }
  }
}
console.log(shellSort([21,25,49,25,16,8], [3,2,1])); // [8,16,21,25,25,49]

算法分析:

时间复杂度: 取决于记录数 n 和增量序列,不能统一写成某个经验幂次。本文固定使用 3、2、1 时,已有序输入最好为 O(n),平均(随机排列)和最坏均为 O(n²):第一趟对 3 个长度约为 n/3 的子序列进行直接插入排序,其平均和最坏工作量就已是二次量级。自定义 m 个增量时,校验还需要 O(m) 时间。
额外空间复杂度: O(1)。
稳定性: 不稳定,跨位置移动可能改变相等记录的相对顺序。

默认增量用于配合图示演示;较长数组应结合理论分析和实际表现选择增量序列。希尔排序依赖按下标访问相隔增量的元素,不适合直接用于链表。

交换排序 ​

基本思想:

两两比较,如果发生逆序则交换,直到所有记录都排好序为止。

1️⃣冒泡排序 ​

排序过程: 从左到右比较相邻记录,出现逆序就交换,使当前未排序区间的最大值移到末尾。每趟缩小一个位置;某趟没有交换时,说明数组已有序,可以提前结束。

js
function bubbleSort(L) {
  let m = L.length - 1;
  let flag = true;
  while (m > 0 && flag) {
    flag = false;
    for (let j = 0; j < m; j++) {
      if (L[j] > L[j + 1]) { // 从小到大排
        flag = true;
        [L[j], L[j + 1]] = [L[j + 1], L[j]]; // 交换数据
      }
    }
    m--;
  }
  return L;
}
console.log(bubbleSort([21,25,49,25,16,18,9,88,7,3,6,30]));

算法分析:

设记录数为 n,比较次数和移动次数与初始排列有关。

最好情况: 已有序时只需一趟检查,比较 n-1 次且不交换,时间为 O(n)。
最坏情况: 逆序时需要 n(n-1)/2 次比较和交换,时间为 O(n²)。
平均时间复杂度: O(n²)。
额外空间复杂度: O(1)。
稳定性: 稳定,相等记录不会互换。

2️⃣快速排序 ​

基本思想:

选择一个元素(如第一个)作为基准值(pivot)。

通过一次划分,将基准放到最终位置,左侧元素不大于基准,右侧元素不小于基准。再递归排序左右子序列,直到子序列只剩一个元素或为空。

本文快速排序实现的特点:

  1. 首元素作为基准,两个下标从两端向中间交替扫描并交换记录,完成一次划分。这是本文采用的划分方式,其他实现也可以使用不同方式。
  2. 递归排序基准两侧的子序列,子序列为空或只有一个元素时结束。
js
// 快排
function quickSort(L, low = 0, high = L.length - 1) {
  if (low < high) {
    const pivotloc = partition(L, low, high);
    quickSort(L, low, pivotloc - 1);
    quickSort(L, pivotloc + 1, high);
  }
  return L;
}
// 快速排序的一次划分
function partition(L, low, high) {
  const pivotkey = L[low];
  while (low < high) {
    while (low < high && L[high] >= pivotkey) {
      --high;
    }
    [L[low], L[high]] = [L[high], L[low]]; // 交换两个位置
    while (low < high && L[low] <= pivotkey) {
      ++low;
    }
    [L[low], L[high]] = [L[high], L[low]]; // 交换位置
  }
  return low;
}
const L = [21,25,49,25,16,18,9,88,7,3,6,30];
console.log(quickSort(L));

算法分析:

时间复杂度: 各次划分均衡时,最好为 O(n log₂ n);在关键字互异且输入随机排列的假设下,平均也为 O(n log₂ n)。实际性能受基准选择、输入顺序和重复关键字数量影响。

最坏时,每次基准都是当前子序列的最小值或最大值,仅排除一个元素。各次划分的工作量依次约为 n-1、n-2、…、1,总时间为 O(n²)。本文选择首元素为基准,已有序、逆序或全部相等的输入都会导致这种退化。

额外空间复杂度: 划分过程为 O(1),递归栈空间取决于递归树高度;在上述随机排列假设下,平均为 O(log₂ n),最坏为 O(n)。
稳定性: 不稳定,跨位置交换可能改变相等记录的相对顺序。

本例用于演示首元素基准的递归实现。较大的退化输入还可能导致 JavaScript 调用栈溢出;实际应用中可结合随机基准、针对重复关键字的三路划分,以及只递归较短一侧、循环处理较长一侧等方式改进。

选择排序 ​

基本思想:

每一趟从尚未排序的区间中选出关键字最小或最大的记录,使有序区间增加一个元素。简单选择排序直接查找最小值,堆排序则利用堆结构取得最大值。

1️⃣简单选择排序 ​

排序过程: 每趟找到最小记录后,将其与尚未排序区间的第一个记录交换。

js
function selectionSort(L) {
  for (let i = 0; i < L.length - 1; i++) {
    let k = i;
    for (let j = i + 1; j < L.length; j++) {
      if (L[j] < L[k]) {
        k = j;
      }
    }
    if (k !== i) {
      [L[i], L[k]] = [L[k], L[i]];
    }
  }
  return L;
}
const L = [21,25,49,25,16,18,9,88,7,3,6,30];
console.log(selectionSort(L));

算法分析:

每趟至多交换一次,总交换次数至多为 n-1。若按一次交换相当于 3 次记录赋值的传统计数方式计算移动次数:

最少移动次数: 0。
最多移动次数: 3(n-1)。
时间复杂度: 最好、平均和最坏均为 O(n²),比较次数为 n(n-1)/2。
额外空间复杂度: O(1)。
稳定性: 不稳定。例如 [2A, 2B, 1C] 第一趟交换后变为 [1C, 2B, 2A],两个关键字为 2 的记录顺序发生变化。

2️⃣堆排序 ​

堆可以用数组表示为完全二叉树。大顶堆要求每个父结点的值不小于其子结点,小顶堆则要求父结点的值不大于其子结点;堆并不要求左右子树之间有大小关系。

大顶堆的根结点是最大值。将其与有效堆范围内的最后一个元素交换,把最大值放入数组末尾的已排序区;缩小堆的范围,再恢复大顶堆。重复此过程,得到升序数组。

如何将堆顶移到末尾后,恢复大顶堆?

  1. 交换堆顶与末尾元素,排除末尾已排序元素,新的根结点可能需要下沉。
  2. 比较当前结点的两个孩子,选择较大的一个;若孩子更大,则交换,并移动到该孩子的位置继续检查。
  3. 当前结点不小于孩子,或已没有孩子时,调整结束。
从最后一个非叶子结点开始构建大顶堆的过程

上图演示的是初始建堆:从最后一个非叶子结点 42 开始,自底向上调整。最后调整根结点时,46 先与 94 交换,再沿左子树与 70 交换。交换后需要继续向下检查,才能恢复整个子树的堆性质。

js
// 大顶堆
function adjustHeap(L, i, length) {
  let k = i * 2 + 1; // 当前结点的左孩子
  while (k < length) {
    if (k + 1 < length && L[k] < L[k + 1]) {
      ++k; // 选择较大的孩子
    }
    if (L[i] >= L[k]) {
      break;
    }
    [L[i], L[k]] = [L[k], L[i]];
    i = k; // 沿交换后的孩子位置继续下沉
    k = i * 2 + 1;
  }
}

function heapSort(arr) {
  // 从最后一个非叶子结点开始,自底向上构建大顶堆
  for (let i = Math.floor(arr.length / 2) - 1; i >= 0; --i) {
    adjustHeap(arr, i, arr.length);
  }
  // 交换堆顶与末尾元素,缩小有效堆的范围并重新调整
  for (let j = arr.length - 1; j > 0; --j) {
    [arr[0], arr[j]] = [arr[j], arr[0]];
    adjustHeap(arr, 0, j);
  }
  return arr;
}

const arr = [46,55,13,42,94,5,17,70];
console.log(heapSort(arr));

算法分析:

时间复杂度: 自底向上建堆需要 O(n) 时间;每次取出堆顶后,调整至多需要 O(log₂ n) 时间,整体最坏为 O(n log₂ n)。本实现全相等时下沉立即结束,最好为 O(n);平均为 O(n log₂ n)。
额外空间复杂度: O(1)。
稳定性: 不稳定,堆顶与末尾元素的交换可能改变相等记录的相对顺序。

归并排序(二路归并) ​

基本思想: 将两个有序子序列合并为一个新的有序序列。

排序过程: 本文代码采用自顶向下递归:先把数组拆成左右两半,分别排序,再合并两个有序数组。数组为空或只有一个元素时直接返回副本。

也可以自底向上:将初始数组看成 n 个长度为 1 的有序子序列,两两合并,第一趟得到 ⌈n/2⌉ 个长度为 2 或 1 的子序列,再逐趟合并,直至整个数组有序;⌈n/2⌉ 表示向上取整。

以下实现通过两个下标合并有序数组,不反复删除数组首项,并返回新数组,保留原数组。

js
// 归并排序
function merge(left, right) {
  const result = [];
  let i = 0;
  let j = 0;
  while (i < left.length && j < right.length) {
    if (left[i] <= right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }
  while (i < left.length) result.push(left[i++]);
  while (j < right.length) result.push(right[j++]);
  return result;
}
function mergeSort(items) {
  if (items.length <= 1) {
    return items.slice();
  }
  const middle = Math.floor(items.length / 2);
  const left = items.slice(0, middle);
  const right = items.slice(middle);
  return merge(mergeSort(left), mergeSort(right));
}
const L = [21,25,49,25,16,18,9,88,7,3,6,30];
console.log(mergeSort(L));

算法分析:

时间复杂度: 本文常规实现的最好、平均和最坏均为 O(n log₂ n)。
额外空间复杂度: O(n),用于合并数组;递归栈另需 O(log₂ n),总额外空间仍为 O(n)。
稳定性: 稳定,合并时相等的关键字优先取左侧记录。

基数排序 ​

前面的排序方法主要通过比较关键字来确定顺序。基数排序按各位关键字进行“分配”和“收集”,不直接比较完整关键字之间的大小。

按关键字的处理顺序可以分为:

  1. 最高位优先 MSD(Most Significant Digit first)。
  2. 最低位优先 LSD(Least Significant Digit first)。

按存储结构: 可以使用数组桶或链表。链式基数排序是使用链表的实现方式,本节按 LSD 顺序讲解。

下面给出十进制 LSD 基数排序的数组桶实现,仅支持由非负安全整数组成的稠密数组(每个数值在 0 到 Number.MAX_SAFE_INTEGER 之间),返回新数组,保留原数组。数组长度直接从 data.length 取得,桶数量由基数 10 决定。

使用 for...of 校验每个元素,空位会被读为 undefined 并拒绝;some() 等跳过空位的方法无法完成这项校验。

js
// 求数组中数值的最大十进制位数,0 按一位处理
function maxDigits(data) {
  let digits = 1;
  let threshold = 10;
  for (const value of data) {
    while (value >= threshold) {
      threshold *= 10;
      ++digits;
    }
  }
  return digits;
}

function radixSort(data) {
  if (!Array.isArray(data)) {
    throw new TypeError("基数排序的输入必须是数组");
  }
  for (const value of data) {
    if (!Number.isSafeInteger(value) || value < 0) {
      throw new RangeError("基数排序仅支持由非负安全整数组成的稠密数组");
    }
  }
  let result = data.slice();
  if (result.length <= 1) return result;

  const digits = maxDigits(result);
  const base = 10;
  let place = 1; // 依次处理个位、十位、百位……
  for (let i = 0; i < digits; ++i) {
    const buckets = Array.from({ length: base }, () => []);
    for (const value of result) {
      const digit = Math.floor(value / place) % base;
      buckets[digit].push(value); // 保持相同位值记录的原有顺序
    }

    const next = [];
    for (const bucket of buckets) {
      for (const value of bucket) next.push(value);
    }
    result = next;
    place *= base;
  }
  return result;
}

const L = [21,25,49,25,16,18,9,88,7,3,6,30];
console.log(radixSort(L)); // [3,6,7,9,16,18,21,25,25,30,49,88]

算法分析: 设记录数为 n,最大位数为 d,基数为 r。

时间复杂度: 每趟分配 n 个记录并收集 r 个桶,共 d 趟,为 O(d(n + r));输入校验需要 O(n) 时间,不改变整体复杂度。
额外空间复杂度: O(n + r)。
稳定性: 稳定,桶内按原顺序加入和收集,每趟都保留相同位值记录的相对顺序。

1️⃣最高位优先法(MSD) ​

先按最高位关键字 k₁ 分组,再在各组内按次高位关键字 k₂ 继续分组,依次处理到最低位,最后按组的顺序连接。

十进制数可以看作多关键字记录,位数不足时在前面补 0。下文的 063、008 仅用于补零显示,JavaScript 数值应写作 63、8。

以下用 MSD 顺序排序:

按最高位排序

{063,064,008,083},{109,184},{278,269},{589,505},

在各组内按次高位排序

{008},{063,064},{083},{109},{184},{269},{278},{505},{589},

按最低位排序

{008},{063},{064},{083},{109},{184},{269},{278},{505},{589},

最后将所有的子序列依次链接在一起就得到排好的序列

2️⃣最低位优先法(LSD) ​

先按最低位排序,再按次低位排序,直到处理完最高位。每一趟都对整个序列排序。

关键条件: 每一趟排序必须稳定,才能保留前面低位排序的结果。本节代码通过桶内顺序追加、顺序收集来保证稳定性。

最低位优先法

278,109,063,930,184,589,269,008,083

按个位排序

930,063,083,184,278,008,109,589,269

按十位排序

008,109,930,063,269,278,083,184,589

按百位排序

008,063,083,109,184,269,278,589,930

链式基数排序 ​

先决条件:

  • 知道各级关键字的主次关系
  • 知道各级关键字的取值范围

利用“分配”和“收集”对关键字进行排序:

首先处理最低位,各个记录按照此位关键字的值“分配”到相应的链式队列中,同一队列的记录按原顺序入队。

按照位值从小到大依次连接各队列,完成“收集”,然后继续处理下一位。使用队列的先进先出顺序,保证每一趟分配和收集稳定。

算法分析: 与前述数组桶实现一样,时间复杂度为 O(d(n + r)),额外空间为 O(n + r),稳定性由队列的顺序入队和收集保证。采用静态链表表示时,可使用 n 个记录链接位置,以及 r 个队列的头、尾指针,共 n + 2r 个附加链接位置。

排序算法比较 ​

以下对照本文的实现。额外空间包含递归栈;基数排序中 d 为最大位数,r 为基数。

排序方法最好时间平均时间最坏时间额外空间稳定性修改原数组
直接插入排序O(n)O(n²)O(n²)O(1)稳定是
折半插入排序O(n log₂ n)O(n²)O(n²)O(1)稳定是
希尔排序(增量 3、2、1)O(n)O(n²)O(n²)O(1)不稳定是
冒泡排序O(n)O(n²)O(n²)O(1)稳定是
快速排序O(n log₂ n)O(n log₂ n)O(n²)平均 O(log₂ n),最坏 O(n)不稳定是
简单选择排序O(n²)O(n²)O(n²)O(1)不稳定是
堆排序O(n)(全相等时)O(n log₂ n)O(n log₂ n)O(1)不稳定是
归并排序O(n log₂ n)O(n log₂ n)O(n log₂ n)O(n)稳定否
LSD 基数排序O(d(n + r))O(d(n + r))O(d(n + r))O(n + r)稳定否

希尔排序一行对应本文默认的固定增量,平均时间按随机排列分析;更换增量序列时需要重新分析。快速排序的平均复杂度采用前文的关键字互异、随机排列假设。堆排序的 O(n) 最好时间对应本文全相等时提前结束下沉的实现。

原地修改的方法返回传入的数组;归并排序和基数排序始终返回新数组,包括空数组和单元素数组。

选择时可以结合这些特点:

  • 数组较小或接近有序时,可考虑直接插入排序;若比较成本较高,折半插入可以减少查找插入位置的比较次数,但仍需移动元素。
  • 需要最坏 O(n log₂ n) 的比较排序时,可以考虑堆排序或归并排序;归并排序还保持稳定性,并需要 O(n) 额外空间。
  • 非负安全整数且位数有限时,可以考虑本文的 LSD 基数排序,同时评估桶所需的额外空间。希尔排序和快速排序的表现则需要结合增量或基准策略、输入分布来判断。