十大排序算法 & 外部排序算法

外部排序

01 引言

如果要对一个很大的数据集,进行排序,而没办法一次性在内存排序,这时候怎么办?

如果遇到这样的面试题,首先可以来向面试官确认一下已有的硬件环境,比如面试官可能会告诉你,你现在有 1GB 的内存可用。那么我们知道整个 1TB 的文件,至少要读 1024 次才能遍历一遍,所以直接在内存里排序显然是不现实的。

但是文件其实是可以一部分一部分读的,如果内存中一次放不下全部的数据,也许我们可以将文件分成若干段,分别读入内存中,并采用常见的内排序算法(比如堆排序),对这段可以在内存中存储的段落进行排序;得到若干个有序的文件段后,最后通过一些合并的方式,得到整体有序的文件。

当然在这个过程里会有大量的中间结果,比如那些有序的文件片段,这些我们都需要借助外存存储,这个思路就是最常见的一种外部排序的方式。

其实你会发现我们刚刚描述的想法和归并排序如出一辙,归并排序也是常用的外排实现方式,只不过我们在学习它的时候,一般都是针对数组,也就是在内存中排序的场景。

02 基于归并排序的外排过程

我们用严谨的语言来重新描述一下基于归并思想的外排过程,整体分为两个阶段:

  1. 部分排序阶段:我们根据内存大小,将待排序的文件拆成多个部分,使得每个部分都是足以存入内存中的。然后选择合适的内排序算法,将多个文件部分排序,并输出到容量可以更大的外存临时文件中,每个临时文件都是有序排列的,我们将其称之为一个“顺段”。
  2. 归并阶段:我们对前面的多个“顺段”进行合并,思想和归并排序其实是一样的。以 2 路归并为例,每次都将两个连续的顺段合并成一个更大的顺段。因为内存限制,每次可能只能读入两个顺段的部分内容,所以我们需要一部分一部分读入,在内存里将可以确定顺序的部分排列,并输出到外存里的文件中,不断重复这个过程,直至两个顺段被完整遍历。这样经过多层的归并之后,最终会得到一个完整的顺序文件。

以一个具体场景为例:用容量仅为 3 个数字的内存,对磁盘上包含 6 个数字的文件进行 2 路归并排序。

  • 外存待排文件[7, 1, 5, 2, 9, 3](大小 \(N = 6\)
  • 内存上限:最多只能容纳 3 个数字(容量 \(M = 3\)

第一阶段:生成初始顺段(Run Generation)

由于内存每次只能装 3 个数,分两批读入并排序:

  1. 处理第 1 批
    • 读入 [7, 1, 5] 到内存 \(\to\) 内部排序为 [1, 5, 7]
    • 写回磁盘临时文件,得到 顺段 1 (Run 1)[1, 5, 7]
  2. 处理第 2 批
    • 读入 [2, 9, 3] 到内存 \(\to\) 内部排序为 [2, 3, 9]
    • 写回磁盘临时文件,得到 顺段 2 (Run 2)[2, 3, 9]

至此,外存中有两个各自有序的顺段,每个长度为 3。

第二阶段:流式外部归并(External Merge)

要将 Run 1 和 Run 2 合并,但内存总共只能放 3 个数。因此在内存中划出 3 个大小为 1 的缓冲区

  • 输入缓冲区 1 (Buf 1):放来自 Run 1 的当前元素。
  • 输入缓冲区 2 (Buf 2):放来自 Run 2 的当前元素。
  • 输出缓冲区 (Out Buf):暂存当前比出的较小值,满了就写入目标文件。

逐步执行过程:

步骤 内存状态 (Buf 1, Buf 2, Out Buf) 比较与决策 磁盘写出 / 补位动作
初始加载 Buf 1: [1] Buf 2: [2] Out Buf: [] 比较 Buf 1 和 Buf 2:1 < 21 进入 Out Buf。 Out Buf 满,写出 1 到目标文件。 Buf 1 变空,从 Run 1 补读下一个数 5
第 2 轮 Buf 1: [5] Buf 2: [2] Out Buf: [] 比较 Buf 1 和 Buf 2:2 < 52 进入 Out Buf。 Out Buf 满,写出 2 到目标文件。 Buf 2 变空,从 Run 2 补读下一个数 3
第 3 轮 Buf 1: [5] Buf 2: [3] Out Buf: [] 比较 Buf 1 和 Buf 2:3 < 53 进入 Out Buf。 Out Buf 满,写出 3 到目标文件。 Buf 2 变空,从 Run 2 补读下一个数 9
第 4 轮 Buf 1: [5] Buf 2: [9] Out Buf: [] 比较 Buf 1 和 Buf 2:5 < 95 进入 Out Buf。 Out Buf 满,写出 5 到目标文件。 Buf 1 变空,从 Run 1 补读下一个数 7
第 5 轮 Buf 1: [7] Buf 2: [9] Out Buf: [] 比较 Buf 1 和 Buf 2:7 < 97 进入 Out Buf。 Out Buf 满,写出 7 到目标文件。 Run 1 全部读完,Buf 1 彻底耗尽。
收尾 Buf 2 还剩 [9] 无需比较,直接将剩余数据追加输出。 写出 9 到目标文件

最终目标文件得到全局有序序列:[1, 2, 3, 5, 7, 9]

整个归并过程中,内存从未同时加载过所有数据,而是像“传送带”一样,进一个、比一个、写一个。在工业界实现中,每个单元从“1 个数字”放大为“1 个磁盘块(如 4KB/64KB)”,逻辑完全一致。

03 运行时间

那么,整个过程里,运行时间主要和哪些因素有关呢?

  • 在第一个阶段部分排序中,由于内存可以装下每个顺段的所有元素,所以几种主流的 O(nlogn) 的算法都是可以的,其中快速排序在大部分场景下是最快的,因此我们可以首选快速排序。
  • 比较复杂的是归并阶段。因为内存不足以装下所有需要排序的元素,所以 O(nlogn) 的堆排和快排都已经没办法被应用在外排的场景中了,但基于分治思想的归并排序却依然可以很好地发挥作用。而且相比很多其他排序方式比如选择排序、冒泡排序,归并排序 O(nlogn) 的复杂度已经是理论上相当好的复杂度了。
  • 当然在一些特定场景下我们也可以用一些线性排序算法比如桶排序来解决外部排序问题,感兴趣的同学可以自己搜索了解一下。

不过需要注意的是,和内排中的归并排序不同,外部排序场景下,我们还有个非常大的时间消耗就是 磁盘 I/O,也就是输入输出。相比内存中的读写操作,在磁盘中的读写是一千倍以上的时间开销差距。所以考虑外排效率时,非常重要的一点就是我们要尽量减少从磁盘中读取数据的耗时,而这主要关系要访问多少次外存。

那我们在外存中需要读取多少次数据呢?从图中其实可以看出来,每一层我们读取外存的数据总量其实是一样的,本质上就是将所有的数据都遍历一遍。

而内存大小是一样的,所以每一层中读取外存的次数也就是一样的,那么显然关系我们 读取次数的多少主要就取决于所需归并的层数了

因此,我们要做的事情就是让归并的层数越低越好。怎么样做到这件事呢?答案很简单也很直观,就是增加更多的归并路数或者降低初始的顺段数量。

04 如何降低归并层数

我们先算出归并层数,以 2 路归并为例,每次合并两个连续的顺段,如果上一层有 n 个顺段,到下一层就会有 \(\frac{n}{2}\) 个顺段,每一层的顺段都会减少一半,直至只剩一个顺段,也就是需要的排序结果。因而,假设初始一共有 n 个顺段,那么我们大致需要 \(log_2^n\) 层。同样的道理,如果进行 k 路归并,每一层的顺段数量都会变成上一层的 \(\frac{1}{k}\),所以就大概只需要 \(log_k^n\) 层即可完成整个归并。

所以,为了降低归并层数,也就是尽量增加 k。

另外为了降低初始 n 个顺段的数量,我们会做的事情也很简单,就是在第一次进行逐段内排序的时候尽可能多地将数据读入内存中并进行内排。

但是增加 k 的大小,其实也会导致每次归并的时候合并的成本变大,一个显著的问题就是在 k 路归并中,我们需要从 k 个元素中选择出最小的元素,代价比 2 路归并的更高。如果用最暴力的方式,遍历 k 个元素,每次选择最小的元素的过程将产生 O(k) 的时间复杂度,这一定程度上会抵消前面通过增加 k 减少磁盘 I/O 所带来的时间提升。

但是我们仔细想想这个问题,选择 k 个元素中的最小元素,显然有优于暴力遍历 O(k) 复杂度的算法。比如,上一讲介绍的堆就可以解决这个问题。

败者树,则是解决从 k 个元素中选取最小元素并可以动态更新的另一种方法,也是更广泛运用于多路归并中的算法,我们来学习一下它的思路。

05 败者树

败者树也被称为,淘汰赛树,也就是 Tournament Tree,思想来自体育比赛。

我们知道在淘汰赛中,每一场比赛都有两个参与者,其中胜者可以晋级下一轮。整体可以画成一颗树的形状,如果看过足球比赛,相信你对这个图一点也不陌生。

TOURAMENT TREE

败者树算法就是基于这一思想实现的。我们用叶子节点存储所有待比较的元素,对叶子结点两两比赛,在它们的父节点中存储失败者;然后对获胜者节点再两两比较,得到更上一层的败者,取出胜者继续往上比较,这个过程和归并的思路其实也是比较相似的;这样一层一层往上比较,最后就可以得到一颗锦标赛状的树。

因为除了叶子结点外的每一层的父节点存储的都是其子节点中的失败者,所以我们称其为败者树。根节点我们会稍微做一点特别的处理,除了在根结点存储失败者,同时,我们在根节点之上会悬挂上整棵树的最终获胜者。

我们可以给刚刚的例子画出对应的败者树,大概就是下面这个图的样子。其中根节点上的方框存储的就是整个树的胜者,也就是 1,它一定是所有元素中的最小值,和锦标赛的冠军是一个意思。

在这里因为我们要找最小的元素,所以失败者就是更大的那个元素。

如果所示,除了叶子节点:

  • 每个节点存储的是失败者(更大的那个值)
  • 胜利者向上比较(边上的数字)

和堆一样,我们也会需要对败者树进行类似于出队的操作;在上面的例子中,就是我们需要将 1 从败者树中取出,寻找下一个最小的元素。

这里有两种情况,一种是我们取出 1 之后,用一个新的元素替代 1;另一种就是取出 1 之后不再添加新的元素,分别对应某一路元素被取出之后仍有元素未取完,和该路元素已经全部取出的情况。在这两种情况中,我们其实都只需要对整个树重新比赛一次即可,只是在第二种情况里,我们会用一个无限大的数字替换 1,因为无限大的数字一定不会在这次重赛中胜出。

最后我们来分析一下败者树的整体时间复杂度,我们假设一共有 k 个节点。首先,初始化的过程需要花费 O(k) 的时间,因为对于 k 个子节点,一共只需要进行 k-1 场比赛即可完成淘汰赛。

然后在归并排序的每一次合并中,只需要进行 replay 操作,从新元素到根路径上逐一重赛。在每一层中,只需要进行一次比较。由于树是平衡的,从叶子结点到根路径仅包含 O(logk) 元素(这里高度的计算和之前讲解堆的推导是差不多的,你可以复习之前的章节)。所以,总的时间复杂度为 O(klog k) 。

现在有了败者树的加持,多路归并排序就可以比较高效地解决外部排序的问题了。对于 1TB 任意文本的排序问题,大致思路就是:

  1. 先用内排序算法,尽可能多的加载源文件,将其变成 n 个有序顺段。
  2. 在内存有限的前提下每 k 个文件为一组,每次流式地从各个文件中读取一个单词,借助败者树选出字典序最低的一个,输出到文件中,这样就可以将 k 个顺段合并到一个顺段中了;反复执行这样的操作,直至所有顺段被归并到同一个顺段。

这里稍微补充一下,看起来我们每次从文件中只读取了一个单词,但操作系统在读文件的时候是会按页为单位读取并缓存下来的,所以某一次磁盘访问之后的若干次访问,其实都会直接命中 cache,也就是说,并不是每次从败者树中取出元素时都会真的产生磁盘 IO,请不用担心。

当然在工业级实现中肯定还是有很多优化空间的。比如待合并的文件比较大的时候,我们可以利用二分搜索对文件进行分段,并行地合并,相关研究也比较多,感兴趣你可以自行搜索了解。

06 补充:堆、胜者树、败者树

为什么外部归并排序选择败者树,而不是堆或胜者树?

堆(Heap)是一种特殊的完全二叉树数据结构,分为最大堆最小堆

  • 最大堆:每个节点的值都大于等于其子节点,根节点是最大值。
  • 最小堆:每个节点的值都小于等于其子节点,根节点是最小值。

堆常用于实现优先队列,支持快速获取最大值或最小值,基本操作(插入和删除)时间复杂度为 O(logn)。

具体操作如下:

  1. 取出一个元素总是发生在堆顶,因为堆顶的元素是最小的(小顶堆中)。
  2. 使用堆中最后一个元素来填补空缺位置
  3. 对顶部元素进行下沉,如果左右孩子有比自己小的,则选择选择最小的那个进行交换。重复进行下沉操作,以满足堆的性质。即每次下沉需要进行两次比较操作

胜者树

胜者树(Winner Tree)是一种特殊的完全二叉树,用于多路归并排序。它的叶子节点表示参与归并的多个数据流,非叶子节点存储比较中获胜者(较小或较大值),根节点最终存储最终胜者(全局最小值或最大值)。

胜者树支持快速找到当前最小值并动态更新(时间复杂度 O(logk),k 为数据流数量),常用于归并排序流式处理场景。

胜者树满足:

  • 胜者树一棵完全二叉树。其中的叶结点是要排序的元素,非叶结点是两个子结点中胜者的代表。
  • 根节点代表着所有元素中的胜者。

img

当每次有新的元素填充进来,需要不断上浮,每次上浮与兄弟元素比较一次即可,但是每个节点指向的是父节点,而不是兄弟节点,所以需要先拿到父节点才能拿到兄弟节点,即上浮需要两次访存。

败者树

败者树是胜者树的一种变体,它也是一棵完全二叉树。和胜者树不同的是,败者树的节点存储的是败者:在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。

img

当每次有新的元素填充进来,需要不断上浮,每次上浮与父节点比较一次即可,即一次访存。

总结

  • 堆:每次新增元素需要下沉元素,每次下沉需要两次访存+两次比较
  • 胜者树:每次新增元素需要上浮元素,每次上浮需要两次访存+一次比较
  • 败者树:每次新增元素需要上浮元素,每次上浮需要一次访存+一次比较

07 补充:基于线性思想构筑的外部排序

在实际的工程应用(如数据库系统和大数据处理框架)中,基于线性排序(时间复杂度为 \(O(n)\) 的分配类排序)思想构建的外部排序是非常经典且高效的方案。

传统的外部归并排序(External Merge Sort)的核心思想是“自底向上”合并(先生成内存大小的有序块,再合并)。而基于线性排序思想的外部排序,核心是“自顶向下”分发/划分(Distribution/Partitioning)

以下是三种线性排序算法在外部排序中的具体应用方式:

1. 外部桶排序 (External Bucket Sort / Range Partitioning)

这是应用最广泛的线性外部排序思想。它不依赖元素间的比较,而是依赖元素的值域分布。

  • 实现思路:
    1. 确定边界: 遍历一遍大文件(或者通过采样),确定数据的全局最大值、最小值,并根据内存大小划分出 \(K\) 个值域区间(桶)。
    2. 分发到磁盘(Partitioning): 再次顺序读取大文件,根据每个元素的值,将其追加写入到对应的 \(K\) 个磁盘子文件(桶)中。
    3. 内部排序: 只要桶的边界划分得当,此时每个子文件的大小应该都能被装入内存。将它们依次读入内存,使用快速排序等算法进行内部排序,然后直接输出。
    4. 直接拼接: 因为桶与桶之间本来就是有序的(例如桶 A 放 1-100,桶 B 放 101-200),所以不需要像归并排序那样进行复杂的跨文件合并,直接按顺序将各个有序的子文件拼接起来即可。
  • 适用场景: 数据分布相对均匀的场景。
  • 致命弱点(数据倾斜): 如果大量数据集中在某一个桶的范围内,导致该子文件依然大于内存容量,就必须对这个“超大桶”进行递归的二次桶划分

2. 外部计数排序 (External Counting Sort)

外部计数排序非常极端,它的可行性不取决于数据的数量(\(N\),而是取决于数据的值域范围(\(M\)

  • 实现思路:
    • 假设你要对 100 亿个用户的年龄进行排序(文件达到几十 GB)。年龄的范围通常在 0 到 150 之间。
    • 虽然数据量巨大无法放入内存,但“值域”非常小。你只需要在内存中维护一个大小为 151 的整型数组 count[150]
    • 顺序读取几十 GB 的磁盘文件,每读到一个年龄 x,就在内存中执行 count[x]++
    • 读取完毕后,根据 count 数组的信息,顺序往新文件中写入对应数量的年龄值。
  • 优势: 无论磁盘数据量有多大,只要内存能装下值域范围的频率数组,只需要对磁盘进行一次完全顺序扫描,I/O 效率极高。
  • 局限: 只能用于整数键,且要求最大值和最小值的差值较小。如果是对 UUID、长字符串或范围极广的浮点数排序,此方法直接失效。

3. 外部基数排序 (External Radix Sort)

外部基数排序通常采用 MSB (Most Significant Bit/Digit,最高有效位) 的方式进行,其本质也是不断地拆分文件。

  • 实现思路(以二进制高位为例):
    1. 读取大文件,根据所有数据的最高位是 0 还是 1,将数据分发到两个不同的磁盘文件(File_0 和 File_1)中。此时,File_0 中的所有数据必然小于 File_1 中的所有数据。
    2. 对 File_0 和 File_1,再根据次高位进行拆分,分别生成 File_00, File_01, File_10, File_11。
    3. 递归分发: 如此递归下去,直到被拆分出来的子文件大小可以完全放入内存为止。
    4. 在内存中对小文件进行排序并输出,最后按文件名的字典序拼接即可。
  • 优势: 非常适合对定长字符串(如固定长度的哈希值、电话号码)或者已知位数的整数进行排序。它避免了外部桶排序中难以确定“边界”的麻烦。

总结:为什么我们通常先学外部归并排序?

既然分布式的线性外部排序可以避免最后昂贵的合并阶段(直接拼接即可),为什么教科书和很多通用系统(如关系型数据库的默认 ORDER BY)更倾向于使用外部归并排序?

  1. 通用性: 归并排序只需要元素之间可以进行比较(实现了比较器),它可以排对象、多列复合键等。而线性思想的外部排序强依赖于数据的类型(必须能映射为整数或位)和分布
  2. 稳定性与内存可控: 外部归并排序生成初始有序段(Run)时,每次吃满内存就吐出一个块,每个块的大小是严格确定的。而外部桶排序/基数排序在分发时,一旦遇到严重的数据倾斜,某个桶极其庞大,会导致不可控的递归 I/O,甚至出现系统颠簸。

在现代的大数据计算引擎(如 Spark 里的 Shuffle 排序)中,往往会结合这两者的思想:先尝试使用基于 Hash/Range 的 Partitioning(类似于外部桶排序的思想)将数据分发到不同的节点或磁盘文件上,而在每个节点内部由于内存限制,又会退化为使用外部归并排序来处理单节点上的超大文件。

十大排序算法

  • 插入排序是稳定的,最优时间复杂度为 O(N)
  • 选择排序是不稳定的,最优时间复杂度为 O(N^2)
  • 希尔排序:插入排序改进
  • 计数排序:空间换事件
  • 桶排序:计数排序相当于桶大小为 1 的桶排序
  • 基数排序:

参考 这里

inner_sort

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10, M = 1e7 + 10;

int a[N], n;
int c[N];       // 归并排序
int w[M], s[M]; // 桶排序,理论上M要和数据规模一样大(1e9),但显然是不太现实的

void directInsertSort       (int *q, int l, int r);
void binarySearchInsertSort (int *q, int l, int r);
void bubbleSort             (int *q, int l, int r);
void selectSort             (int *q, int l, int r);
void shellSort              (int *q, int l, int r);
void quickSort              (int *q, int l, int r);
void mergeSort              (int *q, int l, int r);
void maxHeapSort            (int *q);
void minHeapSort            (int *q);
void bucketSort             (int *q, int l, int r);
void radisSort              (int *q, int d, int r);

void mySort(int *q, int l, int r)
{
    // radisSort(q, 5, 100);
    radisSort(q, 10, 10);
    // radisSort(q, 31, 2);
}

int main()
{
    cin >> n;
    for(int i = 0; i < n; i ++ )    cin >> a[i];
    mySort(a, 1, n);
    for(int i = 0; i < n; i ++ )    cout << a[i] << ' ';
    cout << endl;
    return 0;
}

/* ---------------------------------------------------------------------------- */

void directInsertSort(int *q, int l, int r) // 直接插入排序
{
    if(l >= r)  return ;
    for(int i = l; i <= r; i ++ )
    {
        int t = q[i], j = i; // t为当前要插入的元素,j为空出来的位置
        while(j > l && q[j - 1] > t) // 找到第一个比t小的元素,将t放在该元素的后面
        {
            q[j] = q[j - 1];    // 元素后移
            j -- ;
        }
        q[j] = t; // 插入元素
    }
}

void binarySearchInsertSort(int *q, int l, int r) // 折半插入排序
{
    for(int i = l; i <= r; i ++ )
    {
        if(q[i - 1] <= q[i])    continue;
        int t = q[i];
        int l = 0, r = i - 1;
        while(l < r) // 找到第一个比t大的元素的位置l
        {
            int mid = l + r >> 1;;
            if(q[mid] > t)  r = mid;
            else    l = mid + 1;
        }
        for(int j = i - 1; j >= l; j -- )   
            q[j + 1] = q[j];
        q[l] = t;
    }
}

void bubbleSort(int *q, int l, int r) // 冒泡排序
{
    // 因为要递增排序,因此小的位置l就是水面
    for(int i = l; i <= r; i ++ )
    {
        bool has_swap = false;  // 冒泡排序的优化,如果某一轮没有发生元素交换,说明此时已经有序了
        for(int j = l; j <= r - (i - l) - 1; j ++ )
        {
            if(q[j] > q[j + 1]) // 大的元素下沉
            {
               swap(q[j], q[j + 1]);
              has_swap = true;
            }
        }
        if(!has_swap)    return ;
    }
}

void selectSort(int *q, int l, int r)   // 简单选择排序
{
    for(int i = l; i < r; i ++ )    // 第 r 个元素不用排
    {
        int k = i;
        for(int j = i + 1; j <= r; j ++ )   //  找到第 i 小的
            if(q[j] < q[k])
                k = j;
        swap(q[i], q[k]);
    }
}

void shellSort(int *q, int l, int r)
{
    // for(int d = n / 2; d; d /= 2) // 枚举所有公差
    for(int d = n / 3; d; d = d == 2 ? 1 : d /= 3)  // 当底数为3时,可能出现公差没有1的情况,例如n=6,因此需要特判 
    {
        for(int start = l; start < l + d; start ++ )    // 枚举每个等差数列的起点
        {
            for(int i = start + d; i <= r; i += d)  // 枚举组内的所有元素
            {
                int t = q[i], j = i;
                while(j > start && q[j - d] > t)
                {
                    q[j] = q[j - d];    //  元素后移
                    j -= d;
                }
                q[j] = t; // 放入要插入的元素
            }
        }
    }
}

void quickSort(int *q, int l, int r)    //  快速排序
{
    if(l >= r)  return ;
    int i = l - 1, j = r + 1, mid = q[l + r + 1 >> 1];
    while(i < j)
    {
        do i ++ ;   while(q[i] < mid);
        do j -- ;   while(q[j] > mid);
        if(i < j)   swap(q[i], q[j]);   
    }
    quickSort(q, l, i - 1);
    quickSort(q, i, r);
}

void mergeSort(int *q, int l, int r)    // 二路归并排序
{
    if(l >= r)  return ;
    int mid = l + r >> 1;
    mergeSort(q, l, mid);
    mergeSort(q, mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while(i <= mid && j <= r)
    {
        if(q[i] <= q[j])    c[k ++ ] = q[i ++ ];
        else                c[k ++ ] = q[j ++ ];
    }
    while(i <= mid) c[k ++ ] = q[i ++ ];
    while(j <= r)   c[k ++ ] = q[j ++ ];
    for(int t = l; t <= r; t ++ )   q[t] = c[t];
}

void maxHeapSort(int *q) // 堆排序,构建大顶堆
{
    // 注意堆排序要求元素起点是1而不是0
    int sz = n;
    // down 函数
    function<void(int)> down = [&](int u) { 
        int t = u;
        if(u * 2 <= sz && q[u * 2] > q[t])   t = u * 2;
        if(u * 2 + 1 <= sz && q[u * 2 + 1] > q[t])  t = u * 2 + 1;
        if(u != t)
        {
            swap(q[u], q[t]);
            down(t);
        }
    };    
    // 自下而上构建 heap
    for(int i = n / 2; i; i -- )   down(i);
    // 现在堆顶元素就是最大的元素
    for(int i = 1; i < n; i ++ )    // 只需要整理前n-1个元素    
    {
        swap(q[1], q[sz]);  //  将最大的元素放在最后
        sz -- ; // 该位置已经被占用,往前移动一位
        down(1);    // 对新的堆顶 down
    }
}

void minHeapSort(int *q) // 堆排序,构建小 顶堆
{
    int sz = n;
    function<void(int)> down = [&](int u) {
        int t = u;
        if(u * 2 <= sz && q[u * 2] < q[t])     t = u * 2;;
        if(u * 2 + 1 <= sz && q[u * 2 + 1] < q[t])  t = u * 2 + 1;
        if(t != u)
        {
            swap(q[t], q[u]);
            down(t);
        }
    };
    // 构建小顶堆
    for(int i = n / 2; i >= 1; i -- )   down(i);
    for(int i = 1; i < n; i ++ )
    {
        swap(q[sz -- ], q[1]);
        down(1);
    }
    // for(int i = n; i; i -- )    cout << q[i] << ' ';    cout << endl;    // 升序
}

void bucketSort(int *q, int l, int r)   // 桶排序
{
    // 初始化前缀和数组 s
    for(int i = 0; i < n; i ++ )    s[q[i]] ++ ;
    for(int i = 1; i < M; i ++ )    s[i] += s[i - 1];   // 注意这里是N,也可以是max(q, q + n) 
    // w 数组用来保存答案,因为我们还需要使用原数组(q[i]),所以要额外使用一个数组来保存答案
    for(int i = n - 1; i >= 0; i -- )
    {
        // w[s[q[i]] - 1] = q[i];
        // s[q[i]] -- ;
        w[ -- s[q[i]]] = q[i];
    }
    // 讲答案放到原数组
    for(int i = 0; i < n; i ++ )    q[i] = w[i];
}

void radisSort(int *q, int d, int r)
{
    // d 为关键字数量(位数),r 进制
    int radix = 1;
    for(int i = 0; i < d; i ++ )   // 从地位到高位枚举
    {
        // 因为重复利用,所以需要清空所有桶
        // r 进制就需要 r 个桶
        for(int j = 0; j < r; j ++ )    s[j] = 0;
        // 前缀和
        for(int j = 0; j < n; j ++ )    s[q[j] / radix % r] ++ ;
        for(int j = 1; j < r; j ++ )    s[j] += s[j - 1];   // 注意从1开始
        // 从后往前处理
        for(int j = n - 1; j >= 0; j -- )  w[ -- s[q[j] / radix % r]] = q[j];
        // copy到原数组
        for(int j = 0; j < n; j ++ )    q[j] = w[j];
        radix *= r;
    }
    
    // radisSort(q, 5, 100);
    // radisSort(q, 10, 10);
    // radisSort(q, 31, 2);
}

Shell Sort

理解希尔排序之前,必须先理解一个问题:

1. 插入排序有什么缺点?

假设:

1 2 3 4 5 6 7 8 0

插入排序处理最后一个 0 时:

1 2 3 4 5 6 7 8 0
                ←

0 要一路往前移动:

1 2 3 4 5 6 7 0 8
1 2 3 4 5 6 0 7 8
1 2 3 4 5 0 6 7 8
...
0 1 2 3 4 5 6 7 8

需要移动很多次。

所以插入排序最大的弱点是:元素一次只能移动一个位置。

如果一个很小的数字在数组末尾,它要经过大量移动才能跑到前面。

这也是插入排序最坏 \(O(n^2)\) 的重要原因。

2. 希尔排序解决了什么?

Donald Shell 的想法很直接:能不能一开始让元素“大步移动”,快速接近最终位置,然后最后再用普通插入排序收尾?

这就是希尔排序。

假设:

8 9 1 7 2 3 5 4 6 0

普通插入排序:

gap = 1

也就是相邻元素之间处理。

希尔排序一开始可能:

gap = n / 2 = 5

于是按照距离 5 分组。

下标:0 1 2 3 4 5 6 7 8 9
数组:8 9 1 7 2 3 5 4 6 0

gap = 5 时,相当于五组:

[8,3]
[9,5]
[1,4]
[7,6]
[2,0]

分别进行插入排序。

得到:

3 5 1 6 0 8 9 4 7 2

注意发生了一件重要的事情:

原来最后面的:

0

一下子从:

index = 9

跑到了:

index = 4

一次跨了 5 个位置

这就是希尔排序的关键。

3. 不断缩小 gap

第一次:

gap = 5

下一次可能:

gap = 2

最后:

gap = 1

当:

gap = 1

的时候,本质上就是普通插入排序。

但此时数组已经变得:基本有序。

而插入排序有一个非常重要的特点:数组越接近有序,插入排序越快。

因此希尔排序实际上是在给最后一次插入排序“铺路”。

4. 希尔排序本质

一句话:希尔排序 = 带间隔的插入排序 + 不断缩小间隔。

核心代码大概是:

for (int gap = n / 2; gap > 0; gap /= 2) {
    // 把 arr[i] 插入到合适的位置,这个位置肯定在前面
    for (int i = gap; i < n; i ++ ) {
        int temp = arr[i];
        int j = i;
        while (j - gap >= 0 && arr[j - gap] > temp) {
            arr[j] = arr[j - gap]; // 后移
            j -= gap;
        }
        arr[j] = temp;
    }
}

注意这一段:

j -= gap;

普通插入排序是:

j --

希尔排序则是:

j -= gap

这基本就是二者最核心的区别。

5. 希尔排序为什么不稳定?

假设有 5A 2 5B 1,其中 5A == 5B,稳定排序要求 5A 始终在 5B 前面。但希尔排序会发生 跨距离移动,元素可能直接跳过另一个相等元素。

所以:希尔排序是不稳定排序。

6. 希尔排序复杂度为什么很难说?

因为它 高度依赖 gap 序列。

最简单:

n/2
n/4
n/8
...
1

称为 Shell 原始增量序列。

不同 gap 序列,例如 Knuth、Sedgewick、Tokuda 等,会产生不同复杂度表现。

所以不希尔排序时间复杂度在 \(O(n^{1.3})\) ~ \(O(n^2)\)

比较安全的理解:希尔排序通常明显快于普通 \(O(n^2)\) 的插入排序,但复杂度取决于 gap 序列;某些简单 gap 序列最坏仍可能达到 \(O(n^2)\)

Counting Sort

计数排序的思想和希尔排序完全不同。

假设:

2 5 3 0 2 3 0 3

如果让你排序,你可能比较:

2 < 5 ?
5 > 3 ?
...

计数排序说:我为什么要比较?

我直接数:

0 出现几次?
1 出现几次?
2 出现几次?
3 出现几次?
4 出现几次?
5 出现几次?

统计:

数字        0 1 2 3 4 5
出现次数    2 0 2 3 0 1

于是直接知道:

0 两个
2 两个
3 三个
5 一个

结果:

0 0 2 2 3 3 3 5

排序完成。

完全没有:

a > b

这样的比较。

1. 为什么计数排序是 O(n+k)?

第一遍扫描 n 个数据:O(n)。

然后遍历计数数组,如果数字范围 0 ~ k,需要 O(k)。

假设 n = 100 万,数字只有 0~100,那么计数排序非常爽。接近 O(n)。

2. 计数排序最大的限制

假设只有 10 个数字:

1
999999999
23
45
...

你如果创建:

count[1000000000]

显然非常浪费。

所以计数排序特别适合:数据数量很多,但是值域比较小。

例如 100 万学生的考试分数,分数 0~100,那么只需要 count[101],这简直是计数排序的天堂。

3. 负数怎么办?

假设:

-5 -2 0 3 -2

不能写 count[-5],可以找到 min = -5,然后映射 value - min

例如:

-5 → 0
-2 → 3
 0 → 5
 3 → 8

所以 count 数组大小需要 max−min+1

4. 计数排序为什么说“可以稳定”?

简单版本 count[value],然后:

0 0 2 2 3...

只适合纯数字。

如果数据是:

学生A 分数90
学生B 分数80
学生C 分数90

我们希望排序后:

学生B 80
学生A 90
学生C 90

也即 A、C 的原始顺序不能交换。

需要做:前缀和。

原始 count:

数字      0 1 2 3
count    2 0 2 3

计算累计:

2 2 4 7

意思是:

<= 0 有2个
<= 1 有2个
<= 2 有4个
<= 3 有7个

因此可以精确计算每个元素应该放到结果数组什么位置。

并且通常从原数组从右向左遍历,这样就能保持稳定性。这是由前缀和的定义决定的,count 数组做完前缀和后,prefix[v] 的含义是:所有分数值 \(\le v\) 的元素,在结果数组中占用的最右边界(开区间上限)

  • 比如 <= 90 分的共有 3 人,说明 90 分这批学生在排好序的数组中,最靠右的位置就是下标 2

因为前缀和提供的是最右侧槽位,为了维持先来后到的相对次序:

  • 从右往左遍历原数组:先遇到原序列靠后的学生 C,直接放进当前最靠右的槽位(下标 2);
  • 每放一个就将 prefix[v]--:相当于把下一个同分槽位向左推进一位;
  • 随后遇到靠前的学生 A,就被顺理成章地填入较靠左的槽位(下标 1)。

后来的放右边,先来的放左边,相对顺序自然完全保留。

遍历步骤 当前读取对象 分数 v 填入位置(prefix[v] - 1) res 数组当前状态 更新前缀和
第 1 个 (最右) 学生C 90 \(3 - 1 = \mathbf{2}\) [ _ , _ , 学生C ] prefix[90] 减为 2
第 2 个 (中间) 学生B 80 \(1 - 1 = \mathbf{0}\) [ 学生B, _ , 学生C ] prefix[80] 减为 0
第 3 个 (最左) 学生A 90 \(2 - 1 = \mathbf{1}\) [ 学生B, 学生A, 学生C ] prefix[90] 减为 1

另外需要注意的是,在前缀和构建好之后,后续的单点修改是不需要再维护前缀和的,因为 整体的前缀关系已经没用了。此时 prefix 数组的角色已经从“区间累计”退化成了一个普通的索引游标(Pointer/Cursor)

  • 比如 prefix[90] 只是记录:“下一个人来的时候,请坐在下标几”
  • 当一个人坐下后,执行 prefix[90]--,只是把 90 分专属的坑位向左挪了一格。

Bucket Sort

桶排序和计数排序特别容易混。

假设一群人的成绩:

12
37
21
98
72
65
45
89

桶排序不会给:

12
13
14
15
...
98

每一个数字创建计数器。

而是 划分范围:

0~19
20~39
40~59
60~79
80~99

得到 5 个桶:

桶0:0~19
桶1:20~39
桶2:40~59
桶3:60~79
桶4:80~99

分桶:

桶0:[12]
桶1:[37,21]
桶2:[45]
桶3:[72,65]
桶4:[98,89]

然后 每个桶内部排序,得到:

桶0:[12]
桶1:[21,37]
桶2:[45]
桶3:[65,72]
桶4:[89,98]

最后拼起来:

12 21 37 45 65 72 89 98

排序完成。

1. 桶排序真正依赖什么?

桶排序依赖 数据分布。

假设有 100 万个数字分成 1000 个桶,如果数据非常均匀:每个桶大约 1000 个,那么就很好。但如果 99 万个数据全部进入一个桶,而其它桶元素很少,那么分桶几乎没有意义。最后还是得在一个巨大桶里排序。

所以桶排序真正希望的是 数据均匀散落到各个桶。

2. 桶排序为什么平均可以接近 O(n)?

理想情况 n 个元素分进 n 个桶,如果分布非常均匀,每个桶大约一个元素,那么:

  • 分桶:O(n)
  • 拼接:O(n)

桶内几乎不用排序。所以总体 O(n)。

但是最坏情况下,所有元素进入一个桶。如果桶内用插入排序:O(n^2)。

因此桶排序通常讲:

  • 平均:O(n)
  • 最坏:O(n²)

具体复杂度取决于桶数量、数据分布和桶内排序算法。

3. 计数排序和桶排序到底有什么区别?

这是最值得理解的地方。

计数排序每一个“具体值”一个槽位。

例如:

0
1
2
3
4
5

统计:

count[0]
count[1]
count[2]
...

而桶排序每一个“范围”一个桶。

比如:

0~9
10~19
20~29

并且桶里可能有多个不同值,所以还需要桶内排序。

基数排序

基数排序(Radix Sort)是一种 非比较型 整数排序算法。它的核心思想是将整数按位数切割成不同的数字,然后从最低位(或最高位)开始,依次对每一位进行排序。

与基于比较的排序算法(如快排、归并)不同,基数排序不依赖元素间的大小比较,因此它可以突破 O(nlogn) 的理论下界,在特定条件下达到 线性时间复杂度 O(n)。

以下是关于基数排序的详细解析:

1. 核心原理

基数排序利用了计数排序(Counting Sort)桶排序(Bucket Sort)作为稳定的子过程。它遵循以下逻辑:

  • 分解:将所有待排序数值统一为相同的数位长度(短数前面补零)。
  • 逐位排序从最低位(LSD)或最高位(MSD)开始,依次对每一位进行稳定排序。
  • 稳定性是关键:必须使用稳定排序算法处理每一位。只有这样,在对高位排序时,相同高位元素的相对顺序才能保持低位排序的结果,从而保证最终整体的有序性。

2. 两种主要策略

策略 全称 方向 特点 适用场景
LSD Least Significant Digit 从最低位到最高位 实现简单,最常用;必须使用稳定排序 定长整数、字符串
MSD Most Significant Digit 从最高位到最低位 可实现递归分治;可提前终止(前缀已区分) 变长字符串、字典序排序

💡 通常所说的"基数排序"默认指 LSD 基数排序,因为它的迭代式实现更直观且缓存友好。

3. LSD 基数排序执行示例

假设对数组 [170, 45, 75, 90, 802, 24, 2, 66] 进行 LSD 基数排序(十进制,基数 r=10):

原始数据:  170, 045, 075, 090, 802, 024, 002, 066

第1轮(个位): 170, 090, 802, 002, 024, 045, 075, 066
             ↑ 按个位 0,0,2,2,4,5,5,6 稳定排序

第2轮(十位): 802, 002, 024, 045, 066, 170, 075, 090
             ↑ 按十位 0,0,2,4,6,7,7,9 稳定排序
             (注意: 802和002十位都是0,保持了上一轮的相对顺序)

第3轮(百位): 002, 024, 045, 066, 075, 090, 170, 802
             ↑ 按百位 0,0,0,0,0,0,1,8 稳定排序

✅ 排序完成!

4. 复杂度分析

设 n 为元素个数, d 为最大位数, r 为基数(桶的数量):

指标 复杂度 说明
时间 O(d×(n+r)) 共 d 轮,每轮计数排序耗时 O(n+r)
空间 O(n+r) 需要 n 个输出缓冲 + r 个计数/桶
稳定性 ✅ 稳定 前提是子排序算法稳定

基数排序是非原地(Out-of-place) 算法。在每一轮的"分配-收集"过程中:

  • 你不能直接在原数组上交换元素,因为这会破坏其他尚未处理元素的相对顺序(破坏稳定性)。
  • 你必须先将元素写入一个独立的输出数组,然后再将其拷贝回原数组(或者交替使用两个数组作为双缓冲)。

因此我们需要一个大小为 O(n) 的输出缓冲。

另外在排序过程中,如果我们是桶排序的话,还需要一个大小为 O(r) 的桶。

⚠️ 关键洞察:为什么有时比快排慢?

虽然理论上是线性的,但实际性能取决于常数因子:

  • d 的影响:如果数据范围很大(如 64 位整数), d 可能达到 20(以 r=10)或 8(以 r=256),此时 d⋅n 可能大于 nlog⁡n 。
  • 缓存不友好:基数排序需要多次遍历数组并写入临时缓冲区,内存访问模式不如原地排序的局部性好。
  • 最佳实践:取 r=2k(如 256 或 65536),使位运算替代除法/取模,并利用 CPU 缓存行大小优化桶数量。

关于缓存不友好的问题,看下面的伪代码:

// 伪代码:分配阶段
for (int i = 0; i < n; i++) {
    int digit = get_digit(arr[i]);
    output[ count[digit]++ ] = arr[i];  // ⚠️ 问题在这里
}
  • 读操作是顺序的arr[i] 是从左到右线性扫描的,这对 CPU 预取器(Prefetcher)非常友好,L1/L2 Cache 能完美命中。
  • 写操作是随机的output 数组的写入位置取决于 digit 的值。如果数据的当前位是随机分布的,那么写入地址就是在整个 output 数组范围内随机跳转的。

随机写对缓存是灾难性的。

  • 缓存行浪费:CPU 缓存以 Cache Line(通常 64B)为单位加载。当你随机写入 output[k] 时,CPU 必须把包含 output[k] 的整个 64B 缓存行从内存加载到 Cache 中。如果你只写了这 64B 中的 4 字节就跳到另一个遥远的地址,剩下的 60B 带宽和缓存空间就被浪费了。
  • 频繁的 Cache Miss:由于写入地址分散,刚刚加载进来的缓存行很可能在你下次需要写入同一区域之前就被驱逐(Evict)了。这导致大量的 Write-Miss,迫使 CPU 等待慢速的主存(DRAM)。
  • TLB Miss:如果 output 数组很大(跨越多个内存页),随机访问还会导致 TLB(Translation Lookaside Buffer)频繁未命中,进一步增加地址翻译的延迟。

5. 优缺点总结

优点:

  • 时间复杂度为线性,当 d 较小且 n 极大时显著优于比较排序
  • 稳定排序,适合多关键字排序(如先按班级再按成绩)
  • 易于并行化(每位独立或 MSD 分治)

缺点:

  • 仅适用于整数或可映射为整数的数据类型(浮点数需特殊处理)
  • 额外空间开销较大(非原地排序)
  • 对数据分布敏感:若所有数都很长但差异仅在最高位,LSD 仍需跑完所有位
  • 实际常数大,小规模数据不如插入排序/快排

6. 典型应用场景

  • 后缀数组构造(SA-IS 等算法内部使用基数排序)
  • 大规模整数/IP地址/日期排序
  • GPU 并行排序(基数排序非常适合 SIMD/GPU 架构)
  • 数据库多字段索引排序
  • 字符串排序(MSD 基数排序用于字典序)
posted @ 2026-09-08 15:05  光風霽月  阅读(9)  评论(0)    收藏  举报