Sorting
假设数据库要处理一张包含 1000 万行记录的表:
ORDER BY key 要求执行器产生按关键码(key)全局有序的结果;排序因此是查询执行计划的一部分,而不是孤立算法。
真实系统会结合数据规模、可用内存、稳定性(stability)要求,以及是否只需要前 N 条结果来选择具体排序策略。
如果只问一句“哪种排序最快”,答案并不可靠,因为执行环境可能完全不同:
ORDER BY ... LIMIT 100条件一变,数据库执行器选择的策略也会跟着变。
排序的核心问题:根据数据特征、数据规模、可用内存以及实际目标,选择总体代价合适的方法。
同一种需求会以不同形式出现:
ORDER BY、只取前 N 条记录和归并连接(merge join)都依赖排序这些例子共同提出了一组比“升序还是降序”更重要的问题:比较多少次、移动多少次、需要多少额外空间、是否稳定、是否利用已有有序性,以及数据规模是否超过内存。
本章因此不会把算法看成孤立技巧,而是围绕插入、交换、选择、归并、分配等不同策略,建立一套排序算法的选择框架。
真正被排序的通常不是一串孤立数字,而是一组完整记录。每条记录包含:
因此,后面讨论“交换”“移动”和“稳定性”时,指的都是完整记录,而不只是屏幕上显示的关键码。
Record 把关键码和附带数据绑定成一个整体;排序过程中二者不能分开移动。
compareTo(…) 定义两条记录怎样比较;这里比较依据只有关键码。
compareTo 返回 0;这些记录最终是否保持原来的先后顺序,要由具体排序算法决定。
| 维度 | 含义 |
|---|---|
| 比较次数 | 关键码之间比较的次数 |
| 移动次数 | 记录移动或交换次数 |
| 时间复杂度 | 最好、最坏、平均 |
| 空间复杂度 | 是否需要额外数组或递归栈 |
| 稳定性 | 相等关键码的相对顺序是否保持 |
| 原地性(in-place) | 是否只使用 \(O(1)\) 或少量额外空间 |
| 输入敏感性 | 是否利用已有有序性 |
若两个对象的关键码相等,排序前后它们的相对顺序保持不变,则排序算法稳定。
例如:
\[ 25_a,\ 18,\ 25_b,\ 10 \]
稳定排序后:
\[ 10,\ 18,\ 25_a,\ 25_b \]
不稳定排序可能得到:
\[ 10,\ 18,\ 25_b,\ 25_a \]
内排序:所有待排序记录都能放入内存。
外排序:数据规模超过内存,需要访问外存。
本章主要讨论内排序。
外排序的核心通常是多路归并和缓冲区管理。
设第 \(i\) 轮开始时,\(R[0],R[1],\ldots,R[i-1]\) 已经有序。
先把 \(R[i]\) 暂存起来,在有序前缀中寻找它应该插入的位置;比它大的记录逐个右移,空位一路向左移动,最后再把暂存记录放进空位。
insertionSort(…) 逐个扩展有序前缀;每次把新元素插入它在前缀中的正确位置。
p 轮开始时,a[0..p-1] 已经有序;先暂存 a[p],等价于在有序前缀右侧开出一个待安放的元素。
tmp,就把它右移一格并让空位向左推进;相等时不移动,因此已有相等记录不会被新记录跨过,排序保持稳定。
tmp 写入最终空位后,有序前缀从长度 p 扩大到 p+1;近乎有序时接近 O(n),最坏 O(n²),只用 O(1) 额外空间且保持稳定。
直接插入排序适合小规模数据,也特别适合已经基本有序的数据。
折半插入排序只优化一件事:在已经有序的左侧区间里,用二分查找更快地确定插入位置。 找到位置以后,数组仍然必须把这一位置之后的元素整体右移。
动画使用 [12,5,9,2,14,7,1,10,6]。从第 1 轮开始就会发生真正的插入:先把 5 从下标 1 取出,在左侧有序前缀 [12] 中确定位置,再把 12 右移。随着有序前缀变长,二分查找才会逐渐体现“把候选区间一半一半缩小”的作用。
static <T extends Comparable<? super T>> void binaryInsertionSort(T[] a) {
for (int p = 1; p < a.length; p++) {
T tmp = a[p];
int left = 0;
int right = p;
while (left < right) {
int mid = left + (right - left) / 2;
if (tmp.compareTo(a[mid]) < 0) {
right = mid;
} else {
left = mid + 1;
}
}
for (int j = p; j > left; j--) {
a[j] = a[j - 1];
}
a[left] = tmp;
}
}binaryInsertionSort(…) 用二分查找确定插入位置,再搬移区间;比较次数下降,但搬移仍可能是线性的。
p 轮仍把 a[p] 插入有序前缀,只是先把候选插入位置设为半开区间 [left,right);这里 right=p 明确排除尚未归位的 tmp。
left=mid+1,把新记录放到已有相等记录之后,这是折半插入仍能保持稳定性的关键。
left 后,数组并不会因为“查找更快”就免于搬移:仍需把 a[left..p-1] 整体右移一格,为 tmp 腾出槽位。
tmp 后本轮前缀重新有序;比较次数降到 O(n log n) 量级,但移动最坏仍是 O(n²),所以总时间仍 O(n²);额外空间 O(1),且右侧插入规则保持稳定。
直接插入排序每次只能让记录向左或向右移动一格
如果一个很小的记录一开始离目标位置很远,就要经过很多次搬移。希尔排序先用较大的间隔做“远距离插入”,再逐步缩小间隔。
不同轮次间隔的规则:
\[ gap_0=\left\lfloor\frac{n}{2}\right\rfloor,\qquad gap_{k+1}=\left\lfloor\frac{gap_k}{2}\right\rfloor \]
直到 gap=1。动画有 12 个元素,所以 gap 依次为 6 → 3 → 1。
每个 gap 下,把下标相差 gap 的记录看成同一条子序列,并在这条子序列上执行插入排序
最后 gap=1 时就是一次普通插入排序,此时完成整体排序。
shellSort(…) 按逐渐缩小的间隔做分组插入,最终在 gap=1 时完成整体排序。
tmp 大的前驱向后搬一个间隔,空位沿同一条子序列向左移动,直到 tmp 能保持这条子序列有序。
tmp 后当前间隔子序列恢复有序;跨距离移动可能越过相等关键码,所以希尔排序通常不稳定。性能取决于增量序列;本实现反复把增量减半,最坏仍为 O(n²),额外空间 O(1)。
不同的增量序列会显著影响希尔排序的性能。
冒泡排序从左到右反复比较相邻记录,只有顺序颠倒时才交换。一次完整扫描结束后,当前未排序区中的最大记录一定被推到最右端,因此右侧“已经确定”的区间会一趟一趟扩大。
bubbleSort(…) 反复执行相邻逆序交换;每一趟都会把一个极值固定到已排序边界。
[0,n);上一趟已经把一个最大元素送到右端,所以有序后缀不断扩大,本趟先把 swapped 重置为 false。
O(n),平均/最坏 O(n²),额外空间 O(1);只交换严格逆序的相邻记录,因此稳定。
快速排序先不追求“一步把整个数组排好”,而是先解决一个更小的问题:选一个枢轴(pivot),把它放到最终位置,并把其余记录分到枢轴两侧。 本课的划分规则是:
关键点是:一次划分只保证枢轴位置正确,并不保证左右两边各自已经有序。
一次划分结束后,枢轴左侧都不大于它,右侧都大于它,但左右区间内部仍可能是乱序的。因此快速排序把这两个更小的区间当成两个同类问题,继续划分;区间缩到 0 个或 1 个记录时自然结束。
static <T extends Comparable<? super T>> int partition(T[] a, int left, int right) {
T pivot = a[right];
int store = left;
for (int i = left; i < right; i++) {
if (a[i].compareTo(pivot) <= 0) {
T tmp = a[store];
a[store] = a[i];
a[i] = tmp;
store++;
}
}
T tmp = a[store];
a[store] = a[right];
a[right] = tmp;
return store;
}partition(…) 要把枢轴放到最终位置,并保证它左侧不大于枢轴、右侧大于枢轴。
store=left;此后 a[left..store-1] 始终表示已经确认“不大于枢轴”的左区。
a[i] ≤ 枢轴 才交换到 store 并扩张左区;因此循环结束时左侧全都 ≤ 枢轴,尚未归入左区的元素全都大于枢轴。
store,枢轴才处于最终下标;这种跨距离交换可能越过相等记录,因此这种原地划分通常不稳定。
划分完成后,枢轴位于最终位置。
static <T extends Comparable<? super T>> void quickSort(T[] a) {
quickSortRange(a, 0, a.length - 1);
}
private static <T extends Comparable<? super T>> void quickSortRange(
T[] a, int left, int right) {
if (left >= right) {
return;
}
int pivot = partition(a, left, right);
quickSortRange(a, left, pivot - 1);
quickSortRange(a, pivot + 1, right);
}quickSort(…) 通过划分固定一个枢轴,再递归处理左右两个互不重叠的子区间。
[left,right],因此空区间和单元素区间都由同一个递归终止条件收住。
left < right 时才需要划分;划分返回的枢轴已在最终位置,所以它本身不再参加任何后续递归。
O(n log n)、栈深 O(log n),长期选择极端枢轴会退化到 O(n²) 与 O(n) 栈。划分过程原地完成但通常不稳定。
工程实现还常加入三数取中、小数组改用插入排序、随机选择枢轴、减少递归深度等优化。
第 \(i\) 趟只做一件事:完整扫描未排序区间,记住其中最小记录的位置;扫描结束后,再把这个最小记录放到位置 \(i\)。与冒泡排序不同,它不会在扫描过程中反复交换。
selectionSort(…) 每轮把未排序区的最小元素放到边界位置,并逐步扩大已确认有序前缀。
i 轮开始时,前缀 a[0..i-1] 已经是最终结果;本轮只需在未排序后缀中确定“谁应该永久放到位置 i”。
i..end 并维护当前最小位置 min;无论输入是否已有序,都必须看完这个后缀,因此比较次数固定为 O(n²)。
i,移动次数因此较少;额外空间 O(1),但比较仍是 Θ(n²)。远距离交换可能跨过相等关键码,所以直接选择排序通常不稳定。
如果每次找最小值都从头扫描剩余记录,就会重复做很多已经知道结果的比较。锦标赛排序把比较过程保存成一棵“比赛树”:两个记录先两两比较,较小者晋级;再让各组胜者继续比较,最后根结点得到全局最小值。
输出最小值后,只需要让它原来经过的那条路径重新比赛,其它分支的比较结果可以继续使用。
建立锦标赛树(tournament tree)需要 \(O(n)\) 次比较;每输出一个最小值后,只需要沿着该元素原来经过的路径重赛,路径高度为 \(O(\log n)\)。
如果要输出全部 \(n\) 个元素,总时间复杂度为 \(O(n\log n)\);如果只需要最小值或前若干小值,它的比较复用更有意义。
锦标赛树本身需要 \(O(n)\) 额外空间,所以数组版本不是原地排序。稳定性取决于胜负规则和是否保留原始位置作为平局判定依据;若只保存关键码而不处理相等关键码的先后,不能默认稳定。
堆排序直接复用第 6 章学过的最大堆:堆顶始终是当前堆区中的最大记录。
流程:
static <T extends Comparable<? super T>> void heapSort(T[] a) {
for (int i = a.length / 2 - 1; i >= 0; i--) {
percolateDown(a, i, a.length);
}
for (int end = a.length - 1; end > 0; end--) {
T tmp = a[0];
a[0] = a[end];
a[end] = tmp;
percolateDown(a, 0, end);
}
}
private static <T extends Comparable<? super T>> void percolateDown(T[] a, int hole, int size) {
T tmp = a[hole];
while (hole * 2 + 1 < size) {
int child = hole * 2 + 1;
if (child + 1 < size && a[child].compareTo(a[child + 1]) < 0) {
child++;
}
if (tmp.compareTo(a[child]) >= 0) {
break;
}
a[hole] = a[child];
hole = child;
}
a[hole] = tmp;
}heapSort(…) 维护一个最大堆,把堆顶最大值逐个交换到数组尾部的已排序区。
O(n)。
end,右侧已排序后缀扩大一格;随后只在缩短后的堆区间 [0,end) 恢复堆序。
percolateDown(…) 只修复从指定结点向下的一条堆序路径,使该子树重新满足堆不变量。
hole 沿这条路径继续下降。
hole 后堆序恢复;排序阶段做 n-1 次 O(log n) 下滤,总时间 O(n log n),原地但不稳定。
归并排序抓住一个简单事实:两个已经有序的序列,可以只比较两边当前最小的记录,线性地合并成一个更长的有序序列。 因此整体思路是先把大问题拆小,等小区间各自有序后,再一层一层合并回来。
递归版本可以概括为:
动画用自底向上的方式展示“有序段从长度 1 逐步合并成 2、4、8”;递归实现虽然执行顺序不同,但最终完成的是同一批“合并两个有序区间”的操作。
static <T extends Comparable<? super T>> void merge(T[] a, T[] tmp,
int left, int right, int rightEnd) {
int leftEnd = right - 1;
int pos = left;
int start = left;
while (left <= leftEnd && right <= rightEnd) {
if (a[left].compareTo(a[right]) <= 0) {
tmp[pos++] = a[left++];
} else {
tmp[pos++] = a[right++];
}
}
while (left <= leftEnd) {
tmp[pos++] = a[left++];
}
while (right <= rightEnd) {
tmp[pos++] = a[right++];
}
for (int i = start; i <= rightEnd; i++) {
a[i] = tmp[i];
}
}tmp;相等时用 ≤ 优先取左段记录,因此跨两段的相等关键码仍保持原相对顺序。
tmp 中的合并结果复制回原区间;数组版归并排序因而需要 O(n) 辅助空间,而稳定性来自前一步“相等时优先取左段”的规则。
相等时优先取左边元素,保证稳定性。
static <T extends Comparable<? super T>> void mergeSort(T[] a) {
T[] tmp = Arrays.copyOf(a, a.length);
mergeSortRange(a, tmp, 0, a.length - 1);
}
private static <T extends Comparable<? super T>> void mergeSortRange(
T[] a, T[] tmp, int left, int right) {
if (left >= right) {
return;
}
int center = left + (right - left) / 2;
mergeSortRange(a, tmp, left, center);
mergeSortRange(a, tmp, center + 1, right);
merge(a, tmp, left, center + 1, right);
}mergeSort(…) 递归把区间分成两半并稳定合并两个有序子区间。
tmp,避免每层重复申请线性空间。
O(n)、层数 O(log n),因此总时间稳定在 O(n log n)。
递归深度为 \(O(\log n)\);每一层的合并总成本为 \(O(n)\)。
迭代归并从长度 1 的有序段开始:
\[ [1]\to[2]\to[4]\to[8]\to\cdots \]
每一趟把相邻的两个有序段合并为更长的有序段。
迭代版避免递归调用,但仍需要辅助数组。
数组归并需要把记录写入辅助数组,再复制回原数组;链表没有这种连续存储限制。合并两个有序链表时,只要比较两个头结点,并把较小的那个结点接到结果链表尾部即可,原链表中其它结点不需要整体搬移。
链表不适合随机访问,却很适合这种“从头到尾顺序合并”的过程:合并时主要重连指针,不需要像数组那样整体搬移一段记录。
链表归并排序常用于链表结构;它同样有 \(O(\log n)\) 层递归,每层只顺序扫描并重连结点一次,因此最好、平均、最坏时间复杂度都是 \(O(n\log n)\)。
合并两个链表时不需要数组辅助区,主要额外成本是递归栈 \(O(\log n)\);若用自底向上(bottom-up)的迭代归并,还可以把栈空间降到 \(O(1)\)。但它不是数组意义上的原地排序:它依赖链表结点和指针重连,而不是在连续数组中原地交换。
只要相等关键码时优先取左链表结点,链表归并排序稳定。
到这里学过的插入、交换、选择、堆和归并排序,都要通过“比较两个关键码”来判断谁应该排在前面。一个自然的问题是:能不能设计出一种通用的比较排序,把最坏比较次数降到 \(O(n)\)?
只通过两两比较决定顺序的排序过程,可以抽象成一棵二叉决策树:每个内部结点代表一次比较,两条分支代表比较的两种结果,每个叶结点对应一种最终排列。
长度为 \(n\) 的输入共有 \(n!\) 种可能排列,所以决策树至少需要 \(n!\) 个叶结点。若最坏情况下经过 \(h\) 次比较,二叉决策树最多容纳 \(2^h\) 个叶结点,因此必须有 \(2^h \ge n!\),从而得到 \(h \ge \log_2(n!) \approx n\log n\)。
这说明:在只靠比较决定顺序的模型里,通用排序的最坏比较次数不可能低于 \(O(n\log n)\)。 这并不是说所有排序都受这个下界限制;如果关键码本身还有可以利用的结构,就可能绕开“两两比较”。
十进制整数天然由个位、十位、百位等数位组成。
与其反复问“这两个整数谁更小”,我们也可以利用这些数位,把记录直接分配到若干桶里。
基数排序就是沿着这条思路工作。
基数排序不属于比较排序。
不直接比较两个完整关键码,而是按关键码的各个数位反复“分配到桶中,再按桶号收集”。
以低位优先(LSD)的十进制整数排序为例:先按个位稳定分桶并收集,再按十位、百位继续处理。
每一趟都必须保持同一桶中记录原来的先后顺序,否则前一趟已经建立的低位次序会被破坏。
若关键码位数为 \(d\),基数为 \(r\),元素数为 \(n\),每一趟分配和收集成本为 \(O(n+r)\),共 \(d\) 趟:
\[ O(d(n+r)) \]
基数排序需要桶、计数或输出数组,空间复杂度通常为 \(O(n+r)\),不是原地排序。
稳定性不是附带性质,而是正确性条件:低位优先基数排序(Least Significant Digit Radix Sort,LSD 基数排序)必须让每一趟分配保持上一趟已经建立的低位顺序。因此基数排序通常使用稳定的分桶或计数过程。
它适合定长整数或字符串;若 \(d\) 很大、关键码长短不齐或需要复杂比较函数,优势会下降。
前面已经逐个分析了每种算法,复习时不要只背结论,先问四个问题:
学完前面的具体算法后,再回来看工程实现就容易理解了:真实系统通常会根据数据类型、数据规模和运行环境组合多种策略,而不是固定使用某一种课堂算法。
Java 标准库 标准库会按数据类型选择工程化实现;课程代码只展示各算法最核心的机制
Python Timsort 会利用输入中已经存在的有序段,减少不必要的工作
PostgreSQL 外排序和只取前 N 条记录时,会同时考虑内存、磁盘和执行计划
系统工具 大文件无法一次装入内存时,常采用分块排序、缓冲区和多路归并
所以工程选型不能只看一个 \(O(\cdot)\):还要同时考虑稳定性、额外空间、输入是否接近有序、递归深度以及数据是否能全部放入内存。
| 场景 | 推荐 |
|---|---|
| 小数组或基本有序 | 插入排序 |
| 通用内存排序 | 快速排序 |
| 需要稳定性 | 归并排序 |
| 空间受限且不要求稳定 | 堆排序 |
| 链表排序 | 归并排序 |
| 整数关键码位数受限 | 基数排序 |
| 只取最大的或最小的前 K 个结果 | 堆 |