概述
排序是计算机科学中被研究得最透彻的问题之一。从冒泡排序笨拙的交换到快速排序精巧的分治,从基于比较的理论下界到突破限制的线性时间算法,每一种排序算法都是时间、空间、稳定性与实现复杂度之间的精密权衡。在真实业务系统中,排序绝非简单的“调用一个API”,它牵动着数据库索引设计、大数据Shuffle策略、UI分页一致性乃至微服务缓存击穿。本文旨在为资深工程师构建排序算法的完整知识体系:我们将以算法原理为主线,深入每种经典排序的内部机理,再从 TimSort 与 DualPivotQuicksort 等工业级实现中汲取工程智慧,最终帮助你在真实场景中做出最优的排序决策。
- 评价维度:时间(最好/平均/最坏)、空间(原地/非原地)、稳定性、适应性、基于比较 vs 非比较。理解这些维度是选型的前提。
- 算法分类:基础 O(n²)(冒泡、选择、插入)→ 高效 O(n log n)(希尔、归并、快排、堆排)→ 线性 O(n)(计数、基数、桶)。学习路径应当由简入繁,再回归本质。
- 工程实现:Java 对象排序用 TimSort(稳定、自适应),基本类型用 DualPivotQuicksort(高性能、不稳定),并行排序用 parallelSort(ForkJoin 归并)。三者映射了不同的工程哲学。
- 核心思想:分治(归并、快排)、增量(希尔)、选择(堆)、空间换时间(基数)、利用数据特征(TimSort)。思想是算法的灵魂。
- 适用场景:数据库 ORDER BY、搜索引擎结果排序、大数据 Shuffle、实时排行榜、多级分页展示等。场景决定了算法的最终选择。
文章组织架构
flowchart TD
subgraph A[概述与分类]
A1[排序定义与评价维度]
A2[算法分类图谱]
A3[选择决策树]
A4[反模式]
end
subgraph B[基础排序算法]
B1[冒泡排序]
B2[选择排序]
B3[插入排序与希尔排序]
end
subgraph C[高效比较排序]
C1[归并排序]
C2[快速排序]
C3[堆排序]
end
subgraph D[线性时间排序]
D1[计数排序]
D2[基数排序]
D3[桶排序]
end
subgraph E[外部与并行排序]
E1[外部排序]
E2[并行排序]
end
subgraph F[工业级排序剖析]
F1[TimSort]
F2[DualPivotQuicksort]
end
subgraph G[工程实践与面试]
G1[工程避坑]
G2[面试高频专题]
end
A --> B --> C --> D --> E --> F --> G
图表说明:该流程图展示了本文的七大模块及其逻辑递进关系。我们从排序的宏观分类与评价体系开始,建立全局骨架;然后逐步深入到具体算法,先基础后高效,再扩展至外部与并行两大进阶话题;接着聚焦工业级实现的深度剖析,最后落地到工程实践与面试。这一路径遵循“为什么排序 → 有哪些排序 → 怎么选 → 内部如何工作 → 如何极致优化 → 生产环境避坑”的认知规律,理论建立骨架,算法填充血肉,工业实现注入灵魂。
模块 1:排序算法概述与分类体系
1.1 排序的定义与形式化
给定一个包含 n 条记录的序列,每条记录包含一个键。排序的目标是确定一个排列,使得对于任意两个记录 i < j,有 key[i] ≤ key[j](升序)。如果存在相等键,则排列可以有多种,此时稳定性决定了相等键之间的原始相对顺序是否得以保留。
1.2 评价维度的严格定义
- 时间复杂度:比较次数、交换次数、递归深度等操作总量的渐近行为。通常区分最好情况、最坏情况和平均情况。基于比较的排序算法下界为 Ω(n log n)。
- 空间复杂度:额外内存占用。原地排序(in-place)通常指额外空间为 O(1) 或 O(log n)(递归栈)。非原地排序通常需要 O(n) 辅助空间(如归并排序)。
- 稳定性:若两个记录的键相等,排序后它们的相对顺序不变,则称该排序是稳定的。稳定性对于多级排序至关重要。
- 适应性:算法能否利用输入数据的部分有序性来提升性能。插入排序和 TimSort 是典型的自适应算法。
- 基于比较 vs 非比较:基于比较的排序需要通过比较键值来确定顺序,其理论下界为 Ω(n log n);非比较排序利用键的额外性质(如取值范围固定)突破这一界限,可达 O(n)。
- 并行潜力:算法能否被高效地并行化。归并排序天然适合分治并行,快速排序分区也可并行。
1.3 算法分类图谱
graph LR
A["排序算法"] --> B{"基于比较"}
B -->|"是"| C["比较排序 下界 Ω n log n"]
B -->|"否"| D["非比较排序 可达 O n"]
C --> E["内排序"]
C --> F["外排序"]
E --> E1["交换排序"]
E --> E2["选择排序"]
E --> E3["插入排序"]
E --> E4["归并排序"]
E --> E5["混合排序"]
E1 --> E11["冒泡排序"]
E1 --> E12["快速排序"]
E2 --> E21["简单选择排序"]
E2 --> E22["堆排序"]
E3 --> E31["直接插入排序"]
E3 --> E32["希尔排序"]
E4 --> E41["归并排序"]
E5 --> E51["TimSort 和 DualPivotQuicksort"]
F --> F1["多路归并"]
D --> D1["计数排序"]
D --> D2["基数排序"]
D --> D3["桶排序"]
图表说明:该分类图谱以 “基于比较” 作为顶层划分依据,这是最本质的分类。比较排序受限于 Ω(n log n) 的理论下界,又分为内排序(内存可容纳全部数据)和外排序(数据在磁盘上)。内排序进一步按操作分为交换、选择、插入、归并、混合五大类,涵盖了从冒泡到 TimSort 的所有典型算法。非比较排序通过空间换时间或利用键的域约束,可达线性时间。图中 Java 中的映射十分清晰:TimSort 属于混合排序,DualPivotQuicksort 属于交换排序,parallelSort 基于归并,计数/基数排序有独立实现。
1.4 排序算法选择决策树
flowchart TD
Start[排序需求] --> Q1{数据量大小?}
Q1 -->|n < 50| Small[插入排序]
Q1 -->|n < 1000| Medium{稳定性/适应性?}
Q1 -->|海量数据| Big{内存能否容纳?}
Medium -->|需要稳定| MS[TimSort / 归并排序]
Medium -->|无需稳定| MQ[快速排序 / 双轴快排]
Big -->|能| Large{键值范围?}
Big -->|不能| Ext[外部排序]
Large -->|范围小| Cnt[计数排序 / 基数排序]
Large -->|范围大| Lge{稳定性?}
Lge -->|需要| MS
Lge -->|不需要| MQ
图表说明:该决策树根据数据规模、稳定性需求、内存限制、键值范围四个维度给出推荐算法。小规模数据(n<50)直接使用插入排序,因为它的常数因子极低且对缓存友好;中等规模及大规模数据视稳定性需求分流到 TimSort/归并或快速排序;海量数据内存无法容纳则走外部排序;键值范围固定且较小时非比较排序是碾压级优势。选型时务必结合数据特征和业务约束,避免盲目套用。
1.5 反模式
| 编号 | 反模式 | 后果 | 正确做法 |
|---|---|---|---|
| 1 | 忽略稳定性导致分页异常 | 多字段排序后前序顺序被破坏,分页数据跳动 | 对对象排序使用稳定排序或组合比较器 |
| 2 | 小数组使用递归排序 | 递归开销远大于排序本身,性能倒退 | 设定阈值(如47)切换至插入排序 |
| 3 | 对任意类型强制使用非比较排序 | 无法定义键的分布,结果错误 | 仅正整数或固定范围数据可用 |
| 4 | 频繁创建临时数组 | 大量 GC 压力,吞吐下降 | 预分配数组,复用缓冲区 |
| 5 | 过于追求理论最优忽略工程常数 | 小规模数据下复杂算法常数劣势明显 | 按规模选择算法,混合策略 |
| 6 | 单线程处理海量数据 | CPU 单核瓶颈,响应慢 | 评估并行排序收益,达到阈值后启用 |
模块 2:基础 O(n²) 排序算法及其适应性
2.1 冒泡排序
算法原理
冒泡排序重复遍历数组,比较相邻元素并交换顺序错误者。每趟遍历将剩余未排序部分的最大元素“冒泡”到末尾。优化点在于设置交换标志:若一趟遍历没有发生任何交换,说明数组已经完全有序,可提前终止,将最好情况时间复杂度降为 O(n)。
时间复杂度:最好 O(n)(已有序),最坏/平均 O(n²)。空间复杂度:O(1)。稳定性:稳定(相等元素不会交换)。
Java 实现及详细说明
import java.util.Arrays;
public class BubbleSort {
/**
* 冒泡排序(带提前终止优化)
*
* 算法步骤:
* 1. 外层循环 i 从 0 到 n-2,每趟确定一个最大值的位置
* 2. 内层循环 j 从 0 到 n-2-i,比较 arr[j] 与 arr[j+1]
* 3. 若 arr[j] > arr[j+1],则交换两元素
* 4. 若一整趟内层循环没有任何交换,说明数组已有序,提前退出外层循环
*
* @param arr 待排序的整型数组
*/
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
// swapped 标记本趟是否发生过交换
boolean swapped = false;
// 每趟将 arr[0..n-1-i] 中的最大值移动到 arr[n-1-i]
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 交换相邻元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true; // 记录发生了交换
}
}
// 如果一趟下来没有交换,说明数组已经完全有序,可以提前终止
if (!swapped) {
break;
}
}
}
public static void main(String[] args) {
int[] array = {5, 1, 4, 2, 8};
System.out.println("排序前: " + Arrays.toString(array));
bubbleSort(array);
System.out.println("排序后: " + Arrays.toString(array)); // [1, 2, 4, 5, 8]
}
}
代码逐层解析:
- 外层循环
i:控制排序的趟数。每完成一趟,数组末尾i个元素已经是全局有序的最大值,因此内层循环的右边界逐渐左移。 - 内层循环
j:执行相邻元素的比较和交换。这是冒泡排序的核心操作,每次比较都可能触发交换。 swapped标志:实现了算法的适应性。如果输入数据已经几乎有序,可能在第一趟或前几趟就检测到swapped=false,然后直接退出,避免无谓的比较。这一优化也让冒泡排序在最好情况下降至 O(n) 时间。- 常量空间:整个过程只使用了
temp、swapped等有限变量,是典型的原地排序。 - 稳定性:由于只有
arr[j] > arr[j+1]时才交换,相等的元素永远相邻而不触发交换,因此原相对顺序得以保持。
过程演示(Mermaid 分步图)
flowchart TD
subgraph 初始化
direction LR
S0["初始数组: [5, 1, 4, 2, 8]\n已排序部分: 无"]
end
subgraph 第1趟i0["第1趟 (i=0): 目标将最大值8冒到最后"]
direction LR
S1a["比较 j=0: 5 > 1 ? 是 → 交换"] --> S1b["状态: [1, 5, 4, 2, 8]"]
S1b --> S1c["比较 j=1: 5 > 4 ? 是 → 交换"]
S1c --> S1d["状态: [1, 4, 5, 2, 8]"]
S1d --> S1e["比较 j=2: 5 > 2 ? 是 → 交换"]
S1e --> S1f["状态: [1, 4, 2, 5, 8]"]
S1f --> S1g["比较 j=3: 5 > 8 ? 否 → 不交换"]
S1g --> S1h["趟结束, 已排序: [8]"]
end
subgraph 第2趟i1["第2趟 (i=1): 目标将5冒到倒数第二"]
direction LR
S2a["比较 j=0: 1 > 4 ? 否"] --> S2b["比较 j=1: 4 > 2 ? 是 → 交换"]
S2b --> S2c["状态: [1, 2, 4, 5, 8]"]
S2c --> S2d["比较 j=2: 4 > 5 ? 否"]
S2d --> S2e["趟结束, 已排序: [5, 8]"]
end
subgraph 第3趟i2["第3趟 (i=2): 未发生交换 → 提前终止"]
direction LR
S3a["比较所有相邻对,均有序,swapped=false"] --> S3b["算法结束"]
end
S0 --> 第1趟i0
第1趟i0 --> 第2趟i1
第2趟i1 --> 第3趟i2
第3趟i2 --> Result["最终结果: [1, 2, 4, 5, 8]"]
图表说明:该图模拟了数组 [5,1,4,2,8] 的冒泡排序过程。第一趟中,最大值 8 通过三次交换被推至最右端;第二趟只需将次大值 5 推到倒数第二。每趟结束后,已排序的尾部区域扩大。图中方框为每一步的数组状态,虚线箭头表示内层 j 循环的推进。通过分步展示,可以直观看到交换如何逐步将“大元素”向右“冒泡”。
2.2 选择排序
算法原理
选择排序每轮在未排序部分中扫描找出最小元素,并将其与未排序部分的第一个元素交换。这样,已排序部分逐渐扩大。由于其交换次数恒为 n−1,在所有 O(n²) 算法中具有最少的写操作次数,但算法不稳定,且无论输入是否有序,时间复杂度始终为 Θ(n²)。
时间复杂度:最好/平均/最坏均为 Θ(n²)。空间复杂度:O(1)。稳定性:不稳定。
Java 实现及详细说明
public class SelectionSort {
/**
* 选择排序
* 算法步骤:
* 1. 外层循环 i:将数组分为 [0, i-1] 已排序 和 [i, n-1] 未排序
* 2. 在未排序部分中找到最小元素的下标 minIdx
* 3. 将 arr[minIdx] 与 arr[i] 交换,将其归入已排序部分末尾
* 4. 重复直到所有元素有序
*
* @param arr 整型数组
*/
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
// 假设未排序部分的第一个元素就是最小的
int minIdx = i;
// 在 arr[i+1..n-1] 中查找更小元素的下标
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
// 将最小元素交换到当前位置 i
// 即使 minIdx == i,也进行交换,但可以加判断避免自负交换
if (minIdx != i) {
int temp = arr[minIdx];
arr[minIdx] = arr[i];
arr[i] = temp;
}
}
}
public static void main(String[] args) {
int[] a = {64, 25, 12, 22, 11};
selectionSort(a);
System.out.println(Arrays.toString(a)); // [11, 12, 22, 25, 64]
}
}
代码逐层解析:
- 外层循环:
i是已排序与未排序的分界线。[0, i-1]已经有序,且其中的元素均小于后面任何未排序元素。 - 内层循环:只在未排序部分
[i, n-1]中扫描,仅仅记录最小值的下标minIdx,不做任何写操作。这避免了多余的交换,将交换次数降低到 O(n)。 - 交换:每轮只做一次交换,将最小元素放到
i位置。如果最小值恰好就在i本身,可以省略交换(优化常量因子)。 - 不稳定性分析:例如序列
[5A, 8, 5B, 2],第一轮会将最小值 2 与 5A 交换,导致5A和5B的相对顺序改变。
过程演示
flowchart TD
Start["初始: [64, 25, 12, 22, 11]\n未排序: 全部"] --> Step1
subgraph Step1["第1步 i=0"]
direction LR
A1["扫描 [64,25,12,22,11] 最小值 11 在索引4"] --> A2["交换 64 和 11"] --> A3["[11, 25, 12, 22, 64]\n已排序: 11"]
end
Step1 --> Step2
subgraph Step2["第2步 i=1"]
direction LR
B1["扫描 [25,12,22,64] 最小值 12 在索引2"] --> B2["交换 25 和 12"] --> B3["[11, 12, 25, 22, 64]\n已排序: 11,12"]
end
Step2 --> Step3
subgraph Step3["第3步 i=2"]
direction LR
C1["扫描 [25,22,64] 最小值 22 在索引3"] --> C2["交换 25 和 22"] --> C3["[11, 12, 22, 25, 64]\n已排序: 11,12,22"]
end
Step3 --> Step4
subgraph Step4["第4步 i=3"]
direction LR
D1["扫描 [25,64] 最小值 25 在索引3"] --> D2["无需交换"] --> D3["[11, 12, 22, 25, 64]\n全部有序"]
end
图表说明:每一步展示 i、当前未排序子数组及找到的最小值。绿色背景表示已排序区域,橙色表示本轮选中的最小元素。选择排序的每一步都精确确定一个最终位置,具有“选择”的特点。
2.3 插入排序
直接插入排序
插入排序维护一个有序前缀。初始时第一个元素自成有序区。然后依次将后续元素插入到前缀的正确位置,通过不断将大于新元素的已排序元素向右移动。该算法具有极强的适应性:对几乎有序的数据,内层 while 循环几乎不执行,复杂度接近 O(n)。而且它是稳定的,常数因子极小,因此常作为高效排序中小规模数据的基础。
时间复杂度:最好 O(n)(已有序),最坏/平均 O(n²)。空间:O(1)。稳定。
Java 实现及详细说明
public class InsertionSort {
/**
* 直接插入排序
*
* 算法步骤:
* 1. 从第二个元素 (i=1) 开始,将 arr[i] 插入到 [0..i-1] 有序前缀中
* 2. 保存当前元素为 key
* 3. 从 i-1 向前扫描,如果已排序元素大于 key,则将该元素右移一位
* 4. 找到第一个 <= key 的位置,将 key 插入其后
* 5. 由于移动操作是顺序后移,且 key 在循环结束后一次性插入,算法是稳定的
*
* @param arr 待排序数组
*/
public static void insertionSort(int[] arr) {
int n = arr.length;
// 外层循环:依次将 arr[i] 插入到有序前缀
for (int i = 1; i < n; i++) {
int key = arr[i]; // 待插入的元素
int j = i - 1;
// 内层循环:将大于 key 的元素向右移动一格
// 条件 `arr[j] > key` 确保相等时不移动,维持稳定性
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j]; // 元素右移
j--;
}
// 此时 j 指向第一个 <= key 的位置,将 key 放在 j+1
arr[j + 1] = key;
}
}
public static void main(String[] args) {
int[] a = {12, 11, 13, 5, 6};
insertionSort(a);
System.out.println(Arrays.toString(a)); // [5, 6, 11, 12, 13]
}
}
代码逐层解析:
- 外层循环 i:代表当前待插入元素的下标。每次循环前,
arr[0..i-1]是已排序前缀。 - 变量
key:暂存待插入的值,避免在移动过程中被覆盖。 - 内层 while 循环:右移操作。当
arr[j] > key时,将arr[j]移动到j+1,然后j向前移动。这一步利用了数组的连续存储,移动速度极快。 - 稳定性关键:条件
arr[j] > key而不是>=,这意味着当遇到相等元素时不再移动,key会被放到相等元素的后面,从而保持原始顺序。 - 插入点:
arr[j + 1] = key。此时j可能是 -1(key 比所有前缀元素都小),此时插入到位置 0。 - 局部性优势:右移操作在连续内存中进行,具有极佳的空间局部性,CPU 缓存命中率非常高。这正是插入排序在小规模数据上击败许多 O(n log n) 算法的原因。
过程演示(分步动画图)
flowchart TD
subgraph 初始
D0["数组: [12, 11, 13, 5, 6]\n有序前缀: [12]"]
end
subgraph 插入11
direction LR
A1["i=1, key=11"] --> A2["比较: 12 > 11 → 右移12"]
A2 --> A3["插入11到索引0"] --> A4["变为: [11, 12, 13, 5, 6]"]
end
subgraph 插入13
direction LR
B1["i=2, key=13"] --> B2["比较: 12 ≤ 13 → 不移动"]
B2 --> B3["直接插入索引2"] --> B4["变为: [11, 12, 13, 5, 6]"]
end
subgraph 插入5
direction LR
C1["i=3, key=5"] --> C2["比较: 13>5→右移; 12>5→右移; 11>5→右移"]
C2 --> C3["插入5到索引0"] --> C4["变为: [5, 11, 12, 13, 6]"]
end
subgraph 插入6
direction LR
E1["i=4, key=6"] --> E2["比较: 13>6→右移; 12>6→右移; 11>6→右移; 5≤6→停止"]
E2 --> E3["插入6到索引1"] --> E4["最终: [5, 6, 11, 12, 13]"]
end
初始 --> 插入11
插入11 --> 插入13
插入13 --> 插入5
插入5 --> 插入6
图表说明:此图详细刻画了数组 [12,11,13,5,6] 的插入排序轨迹。每个 i 对应一次插入操作,框中分别展示有序前缀状态、元素移动过程和最终插入位置。可以清晰看到:当元素几乎有序(如 i=2)时,内层循环几乎不执行;而逆序元素(如 key=5)会导致大量右移操作。通过分步展示,插入排序的自适应特性一目了然。
折半插入排序
在插入排序中,使用二分查找代替线性查找来确定插入位置,可将比较次数从 O(n) 降至 O(log n)。但由于移动次数不变,总时间复杂度仍为 O(n²)。该优化在键值比较成本很高时(如长字符串比较)才具有实际价值。
Java 实现:
public class BinaryInsertionSort {
public static void binaryInsertionSort(int[] arr) {
int n = arr.length;
for (int i = 1; i < n; i++) {
int key = arr[i];
// 二分查找插入位置(在 [0, i] 之前有序区间查找)
int left = 0, right = i;
while (left < right) {
int mid = (left + right) >>> 1;
if (arr[mid] <= key) {
left = mid + 1; // 继续在右半区查找
} else {
right = mid; // 插入点可能在 mid 或左边
}
}
// 此时 left 即为插入位置,将 [left, i-1] 的元素后移一位
System.arraycopy(arr, left, arr, left + 1, i - left);
arr[left] = key;
}
}
}
代码解析:二分查找使用左闭右开区间 [left, right),查找后 left 就是第一个大于 key 的位置,所有 >= key 的元素均位于 left 之后。System.arraycopy 一次性移动整段元素,比循环逐个移动更快。虽然比较次数减少,但移动次数不变,适合比较成本高昂的场景。
模块 3:希尔排序 —— 插入排序的增量扩展
算法原理
希尔排序是对插入排序的改进,通过引入 增量 (gap) 将数组分成若干个“交织”的子序列,先对这些子序列分别进行插入排序,然后逐步减小 gap 直至 1。初期 gap 较大时,元素能够进行长距离跳跃,快速消除大量逆序;后期 gap 较小时,数组已基本有序,插入排序接近 O(n)。整体性能依赖于增量序列。
常见增量序列:
- Shell 序列:n/2, n/4, ..., 1(最坏 O(n²))
- Hibbard 序列:1, 3, 7, ..., 2^k-1(最坏 O(n^(3/2)))
- Sedgewick 序列:1, 5, 19, 41, 109...(最坏 O(n^(4/3)))
希尔排序不稳定,因为相等的元素可能分布在不同子序列中被交换位置。
Java 实现(Shell 增量)
public class ShellSort {
/**
* 希尔排序
* 算法步骤:
* 1. 计算初始 gap = n/2
* 2. 对每个 gap,从 gap 开始到 n-1,对元素执行隔 gap 的插入排序
* 3. gap 折半,重复直到 gap=0
*
* @param arr 整型数组
*/
public static void shellSort(int[] arr) {
int n = arr.length;
// 增量逐步减小
for (int gap = n / 2; gap > 0; gap /= 2) {
// 对间隔为 gap 的子序列进行插入排序
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
// 在子序列中向前比较和移动
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
}
public static void main(String[] args) {
int[] a = {12, 34, 54, 2, 3, 7, 21, 4};
shellSort(a);
System.out.println(Arrays.toString(a)); // [2, 3, 4, 7, 12, 21, 34, 54]
}
}
代码逐层解析:
- 外层 gap 循环:使用 Shell 序列 n/2, n/4, ... 逐步细化排序。gap 的选取对性能有决定性影响。
- 中间 for i 循环:
i从gap开始向右扫描,保证每个子序列的“当前元素”都被处理,相当于多路交织的插入排序。 - 内层 while 循环:在间隔为 gap 的子序列上进行插入排序。条件
arr[j - gap] > temp控制稳定性和移动,但因为 gap 可能大于 1,相等元素可能跨子序列移动,所以希尔排序不稳定。 - 整体效果:gap 较大时,子序列短,插入排序极快;gap 减小时,数组近乎有序,插入排序也很快。这种“先粗后细”的策略极大改善了平均性能。
过程演示
graph TD
A["初始数组 gap等于4 12 34 54 2 3 7 21 4"] --> B1
subgraph B["gap等于4 排序"]
B1["子序列1 12 3 排序后 3 12"] --> B2["子序列2 34 7 排序后 7 34"]
B2 --> B3["子序列3 54 21 排序后 21 54"]
B3 --> B4["子序列4 2 4 排序后 2 4"]
end
B4 --> Bfinal["合并 3 7 21 2 12 34 54 4"]
Bfinal --> C1
subgraph C["gap等于2 排序"]
C1["子序列1 3 21 12 54 排序后 3 12 21 54"] --> C2["子序列2 7 2 34 4 排序后 2 4 7 34"]
end
C2 --> Cfinal["合并 3 2 12 4 21 7 54 34"]
Cfinal --> D1
subgraph D["gap等于1 排序"]
D1["常规插入排序"] --> Dfinal["结果 2 3 4 7 12 21 34 54"]
end
图表说明:此图展示了希尔排序在不同 gap 下的状态。gap=4 时,元素被分为 4 个子序列,分别插入排序;gap=2 时,数据已经部分有序;gap=1 就是一次标准插入排序,但由于前期工作,此时数组已基本有序,插入排序接近 O(n)。整个过程逐步消去长距离逆序,最终局部调整,完美体现了希尔排序的设计哲学。
模块 4:归并排序 —— 稳定性与外部排序的基石
4.1 自顶向下递归归并
算法原理
归并排序采用分治策略:
- 分解:将数组从中间分成两个子数组。
- 解决:递归地对左右子数组进行归并排序。
- 合并:将两个有序的子数组合并成一个有序数组,使用临时数组暂存合并结果。
时间复杂度:始终为 O(n log n)。空间复杂度:O(n)(临时数组)或 O(n + log n) 包含递归栈。稳定性:稳定(合并时相等元素保留左侧优先)。
Java 实现及详细说明
public class MergeSort {
public static void mergeSort(int[] arr) {
// 预分配一个全局临时数组,避免递归中反复创建
int[] tmp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, tmp);
}
/**
* 递归归并排序核心
* @param arr 原数组
* @param left 左边界(包含)
* @param right 右边界(包含)
* @param tmp 临时数组
*/
private static void mergeSort(int[] arr, int left, int right, int[] tmp) {
if (left >= right) return; // 递归终止:区间长度 <= 1
int mid = (left + right) >>> 1; // 防溢出计算中点
mergeSort(arr, left, mid, tmp); // 递归排序左半
mergeSort(arr, mid + 1, right, tmp); // 递归排序右半
merge(arr, left, mid, right, tmp); // 合并左右两个有序段
}
/**
* 合并 arr[left..mid] 和 arr[mid+1..right] 到临时数组,再拷贝回原数组
*/
private static void merge(int[] arr, int left, int mid, int right, int[] tmp) {
int i = left; // 左半部分指针
int j = mid + 1; // 右半部分指针
int k = left; // 临时数组指针
// 双指针遍历两个有序段,较小者放入 tmp
while (i <= mid && j <= right) {
// 注意:使用 <= 保证稳定性,相等时左元素优先
if (arr[i] <= arr[j]) {
tmp[k++] = arr[i++];
} else {
tmp[k++] = arr[j++];
}
}
// 拷贝左半剩余元素
while (i <= mid) {
tmp[k++] = arr[i++];
}
// 拷贝右半剩余元素
while (j <= right) {
tmp[k++] = arr[j++];
}
// 将临时数组合并区间的内容整体复制回原数组
System.arraycopy(tmp, left, arr, left, right - left + 1);
}
public static void main(String[] args) {
int[] a = {38, 27, 43, 3, 9, 82, 10};
mergeSort(a);
System.out.println(Arrays.toString(a)); // [3, 9, 10, 27, 38, 43, 82]
}
}
代码逐层解析:
- 预分配临时数组:在公开方法中一次性分配
tmp,然后传递给递归函数复用。这样避免了每次合并都新建数组,降低了 GC 压力。 - 递归终止条件:
left >= right,即区间长度 ≤ 1,天然有序。 - 中点计算:
(left + right) >>> 1无符号右移,防止整数溢出。 - 合并过程:采用经典双指针。
arr[i] <= arr[j]的条件确保了当左右元素相等时,左半部的元素优先进入临时数组,从而保证稳定性。 - 数组拷贝:
System.arraycopy是 native 方法,内存复制效率极高,远优于手动循环。
归并过程示意图
flowchart
subgraph Left["左半有序: [27, 38, 43]"]
L1[27] --> L2[38] --> L3[43]
end
subgraph Right["右半有序: [3, 9, 82]"]
R1[3] --> R2[9] --> R3[82]
end
Left --> Merge
Right --> Merge
subgraph Merge["合并动态过程"]
direction LR
M1["i=0,j=0: 27 vs 3 → 取3, j++"] --> M2["i=0,j=1: 27 vs 9 → 取9, j++"]
M2 --> M3["i=0,j=2: 27 vs 82 → 取27, i++"]
M3 --> M4["i=1,j=2: 38 vs 82 → 取38, i++"]
M4 --> M5["i=2,j=2: 43 vs 82 → 取43, i++"]
M5 --> M6["左半耗尽,追加右半剩余 82"]
M6 --> M7["tmp: [3,9,27,38,43,82]"]
end
Merge --> Result["拷贝回原数组"]
flowchart TD
A["[38,27,43,3,9,82,10]"] --> B1["[38,27,43]"] --> B1a["[38]"] & B1b["[27,43]"] --> B1b1["[27]"] & B1b2["[43]"]
A --> C1["[3,9,82,10]"] --> C1a["[3,9]"] --> C1a1["[3]"] & C1a2["[9]"]
C1 --> C1b["[82,10]"] --> C1b1["[82]"] & C1b2["[10]"]
B1a & B1b1 & B1b2 --> M1["合并: [27,38,43]"]
C1a1 & C1a2 & C1b1 & C1b2 --> M2["合并: [3,9,10,82]"]
M1 & M2 --> Final["最终合并: [3,9,10,27,38,43,82]"]
图表说明:此图详细刻画了合并操作的双指针推进逻辑。左右两个有序段各有一个索引指针,每次比较两指针所指元素,较小者进入 tmp 并移动指针。相等时左指针优先,这是归并排序稳定性的根源。最终所有元素依次进入 tmp,再拷贝回原数组,完成一次合并。
4.2 自底向上迭代归并
不采用递归,而是直接从长度为 1 的子数组开始,逐步两两合并,将 size 翻倍,直至覆盖整个数组。这种方法避免了递归开销,特别适合链表排序。
Java 迭代版实现:
public class MergeSortBottomUp {
public static void mergeSort(int[] arr) {
int n = arr.length;
int[] tmp = new int[n];
// size 表示当前归并的子数组长度,1, 2, 4, 8 ...
for (int size = 1; size < n; size *= 2) {
// left 为左子数组的起始索引
for (int left = 0; left < n - size; left += 2 * size) {
int mid = left + size - 1;
int right = Math.min(left + 2 * size - 1, n - 1);
merge(arr, left, mid, right, tmp); // 复用自顶向下的 merge 方法
}
}
}
// merge 方法与前面递归版相同,此处省略
}
代码说明:迭代版并无原理差异,只是控制流程显式化。外层循环 size 倍增,内层循环按 2*size 跨步合并。由于深层递归消除,可应用于栈空间极受限的环境。
4.3 链表归并排序
链表归并排序利用链表 O(1) 时间拆分和合并的特点,能在 O(1) 额外空间 内完成稳定排序,是链表排序的首选。
class ListNode {
int val;
ListNode next;
ListNode(int v) { val = v; }
}
public class LinkedListMergeSort {
public static ListNode sortList(ListNode head) {
if (head == null || head.next == null) return head;
// 快慢指针找到链表中点,并断开成两个链表
ListNode slow = head, fast = head.next;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
ListNode mid = slow.next;
slow.next = null; // 切断
ListNode left = sortList(head);
ListNode right = sortList(mid);
return mergeTwoLists(left, right);
}
private static ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
tail.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
4.4 优化:小数组切换插入排序
在实际工程中(如 JDK TimSort),当子数组长度小于某一阈值(例如 32)时,转而使用插入排序。原因是插入排序在小规模数据上常数因子极低,且归并和递归的额外开销在小数组上性价比不高。我们可以在递归函数中加入如下判断:
if (right - left < 32) {
insertionSort(arr, left, right);
return;
}
模块 5:快速排序 —— 最广泛使用的比较排序
5.1 分区策略:Hoare 分区与三数取中
算法原理
快速排序通过选择一个基准元素 (pivot),将数组划分为两部分:左侧元素 ≤ pivot,右侧元素 ≥ pivot,然后递归地对两个子数组排序。分区算法核心是重排元素。
- Lomuto 分区:单指针扫描,代码简单但交换次数多,效率较低。
- Hoare 分区:双指针分别从两端向中间扫描,遇到逆序对就交换,直到指针交错。Hoare 分区的交换次数约为 Lomuto 的 1/3,且能更好地利用 CPU 缓存,因此现代工业实现普遍采用。
pivot 选取直接影响性能:
- 固定选首或尾元素:遇到已排序数据退化为 O(n²)。
- 随机选取:将最坏概率降至极低,但有随机数开销。
- 三数取中 (median-of-three):取首、中、尾三个元素的中位数,兼顾了良好划分和极低开销,是工业界最常用的策略。
Java 实现(Hoare 分区 + 三数取中 + 插入排序回退)
public class QuickSort {
public static void quickSort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int low, int high) {
if (low >= high) return;
// 优化:小数组使用插入排序,阈值设为 47(JDK 风格)
if (high - low < 47) {
insertionSort(arr, low, high);
return;
}
// 获取分区点(pivot 的最终位置)
int pivotIndex = partition(arr, low, high);
// 注意:Hoare 分区后左段包含 pivot,递归至 pivotIndex,而非 pivotIndex-1
quickSort(arr, low, pivotIndex);
quickSort(arr, pivotIndex + 1, high);
}
/**
* Hoare 分区算法,返回分区点下标 j(左段的结束位置)
* 最终 arr[low .. j] <= pivot,arr[j+1 .. high] >= pivot
*/
private static int partition(int[] arr, int low, int high) {
// 三数取中选择 pivot 值(不交换到别处,纯选取值)
int mid = (low + high) >>> 1;
int pivot = medianOfThree(arr[low], arr[mid], arr[high]);
// 双指针初始化:i 为 low-1,j 为 high+1
int i = low - 1;
int j = high + 1;
while (true) {
// 从左向右找到第一个 >= pivot 的元素
do {
i++;
} while (arr[i] < pivot);
// 从右向左找到第一个 <= pivot 的元素
do {
j--;
} while (arr[j] > pivot);
// 指针交错,分区完成
if (i >= j) {
return j; // j 是左段最后一个元素的下标
}
// 交换这两个错位元素
swap(arr, i, j);
}
}
/**
* 三数取中,返回中间大小的数值
*/
private static int medianOfThree(int a, int b, int c) {
// 异或交换版本或 if 版本,确保三数有序排列后取第二个
if (a > b) { int t = a; a = b; b = t; }
if (a > c) { int t = a; a = c; c = t; }
if (b > c) { int t = b; b = c; c = t; }
return b;
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
// 针对小数组的插入排序
private static void insertionSort(int[] arr, int low, int high) {
for (int i = low + 1; i <= high; i++) {
int key = arr[i];
int j = i - 1;
while (j >= low && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
public static void main(String[] args) {
int[] a = {9, -3, 5, 2, 6, 8, -6, 1, 3};
quickSort(a);
System.out.println(Arrays.toString(a));
}
}
代码逐层解析:
- 三数取中函数
medianOfThree:通过对三个数排序,返回中间值。该值作为 pivot 的值参与比较,但并不将 pivot 交换到某个固定位置,而是在 Hoare 分区中通过do-while首次定位自动找到其位置。 - Hoare 分区:
i和j分别从两侧向中间移动,一旦发现arr[i] >= pivot和arr[j] <= pivot就交换。循环结束后j成为左右段的分界点,左段元素 ≤ pivot,右段元素 ≥ pivot。 - 递归调用:
quickSort(arr, low, pivotIndex);注意左段包含 pivot,因此递归范围包括pivotIndex。这是 Hoare 分区的巧妙之处:pivot 不一定恰好在分界点,但保证左段都 ≤ 右段,无需再处理分界元素。 - 小数组阈值:当数组长度小于 47 时,归并到插入排序,避免递归栈开销和分区复杂度的常数代价。
- 稳定性:由于交换可能跨越大段距离,快速排序不稳定。
分区过程演示(Hoare 分区动画图)
flowchart TD
subgraph Step1["初始 low=0 high=8, pivot=5 (三数取中后)"]
A1["i=-1, j=9<br>数组: 9,-3,5,2,6,8,-6,1,3"]
end
subgraph Step2["第一轮扫描"]
B1["i++: i=0(9>=5)"] --> B2["j--: j=8(3<=5)"] --> B3["交换 9 和 3<br>数组: 3,-3,5,2,6,8,-6,1,9"]
end
subgraph Step3["继续扫描"]
C1["i++: i=1(-3<5) i=2(5>=5)"] --> C2["j--: j=7(1<=5)"] --> C3["交换 5 和 1<br>数组: 3,-3,1,2,6,8,-6,5,9"]
end
subgraph Step4["继续扫描 i=3(2<5) i=4(6>=5); j=6(-6<=5)"]
D1["交换 6 和 -6<br>数组: 3,-3,1,2,-6,8,6,5,9"]
end
subgraph Step5["i=j=5(8>=5), j=5(8>5) j-- j=4(-6<5)"]
E1["i=5 >= j=4: 交错,返回 j=4"]
end
E1 --> Final["分区结果: 左段 [3,-3,1,2,-6] 右段 [8,6,5,9] 完成"]
图表说明:此系列图详细展示了 Hoare 分区的完整扫描过程。双指针从两端交替移动,每当找到一对“i 位置元素大于等于 pivot 且 j 位置元素小于等于 pivot”时便交换,直到两指针交错。最终所有小于等于 pivot 的元素落到左部,大于等于的落在右部。Hoare 分区的交换次数远少于 Lomuto 分区,效率更高。
5.2 三向切分快速排序
当数组中包含大量重复元素时,三向切分将数组划分为 < pivot, = pivot, > pivot 三个区域,可以将重复元素的处理时间降至接近 O(n)。Java 7 开始对基本类型排序使用的 DualPivotQuicksort 就蕴含了类似三向切分的思想。
Java 实现
public class Quick3Way {
public static void sort(int[] arr) {
sort(arr, 0, arr.length - 1);
}
private static void sort(int[] arr, int low, int high) {
if (high <= low) return;
int lt = low, i = low + 1, gt = high;
int pivot = arr[low];
while (i <= gt) {
int cmp = Integer.compare(arr[i], pivot);
if (cmp < 0) {
swap(arr, lt++, i++);
} else if (cmp > 0) {
swap(arr, i, gt--);
} else {
i++;
}
}
// arr[low..lt-1] < pivot, arr[lt..gt] = pivot, arr[gt+1..high] > pivot
sort(arr, low, lt - 1);
sort(arr, gt + 1, high);
}
// swap 省略
}
代码说明:三个指针 lt, i, gt 分别维护小于区、等于区、大于区的边界。当 i 遇到大于 pivot 的元素时与 gt 交换,gt 左移;遇到小于的元素与 lt 交换,lt 和 i 都右移;相等时 i 直接右移。这样等于区的元素被“压”到中间,无需参与后续递归。
模块 6:堆排序 —— 原地且最坏 O(n log n)
6.1 算法原理
堆排序使用二叉堆维护一个偏序结构。首先将数组原地构建为最大堆(建堆过程 O(n)),然后反复将堆顶(最大值)与未排序部分末尾交换,并对新的堆顶进行下沉(siftDown)操作以恢复堆性质。如此执行 n-1 次,完成排序。
时间复杂度:建堆 O(n),排序 O(n log n),整体 O(n log n) 且无最坏退化。空间复杂度:O(1),是原地排序。不稳定(交换堆顶可能破坏相等元素的相对次序)。
Java 实现及详细说明
public class HeapSort {
public static void heapSort(int[] arr) {
int n = arr.length;
// 1. 构建最大堆:从最后一个非叶子节点开始向前下沉
// 最后一个非叶子节点下标为 (n/2 - 1)
for (int i = n / 2 - 1; i >= 0; i--) {
siftDown(arr, n, i);
}
// 2. 逐一将堆顶(最大值)交换到尾部,并调整堆
for (int i = n - 1; i > 0; i--) {
// 交换堆顶与当前堆的最后一个元素
swap(arr, 0, i);
// 对新的堆顶(原尾部元素)进行下沉,堆大小减少为 i
siftDown(arr, i, 0);
}
}
/**
* 下沉操作:调整以 root 为根的子树,使其满足最大堆性质
* @param arr 数组表示的堆
* @param heapSize 当前堆的有效大小
* @param root 需要下沉调整的根下标
*/
private static void siftDown(int[] arr, int heapSize, int root) {
int largest = root; // 假设根最大
int left = 2 * root + 1; // 左子节点索引
int right = 2 * root + 2; // 右子节点索引
// 如果左子存在且大于当前最大,更新 largest
if (left < heapSize && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子存在且大于当前最大,更新 largest
if (right < heapSize && arr[right] > arr[largest]) {
largest = right;
}
// 若最大值不是根,则交换并递归下沉
if (largest != root) {
swap(arr, root, largest);
// 递归调整被交换的子树
siftDown(arr, heapSize, largest);
}
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public static void main(String[] args) {
int[] a = {12, 11, 13, 5, 6, 7};
heapSort(a);
System.out.println(Arrays.toString(a)); // [5, 6, 7, 11, 12, 13]
}
}
代码逐层解析:
- 建堆循环:
for (int i = n / 2 - 1; i >= 0; i--)自底向上对每个非叶节点执行siftDown。为什么时间复杂度是 O(n)?因为大部分节点层级较低,下沉次数很少。数学可以证明总操作次数上限为Σ ceil(log n) - h_i,结果收敛于 O(n)。 - 下沉操作
siftDown:比较root与其左右子节点,选出最大值。如果最大值不是root,则交换root与最大值对应子节点,并递归调整被交换的子树。这是堆排序的核心原语。 - 排序循环:每次交换
arr[0](堆顶,当前最大值)与arr[i](堆尾),然后堆大小缩减为i,对新的堆顶下沉。经过 n-1 次,数组从左至右升序排列。 - 不稳定性:例如
[3, 2a, 2b],建堆后可能为[3,2a,2b],第一次交换3和2b,得到[2b,2a,3],即打乱了两个 2 的顺序。
过程示意图
flowchart
subgraph 建堆["建堆阶段 (从 i=2 到 0)"]
direction LR
H1["初始: [12,11,13,5,6,7]"] --> H2["i=2 (值13) 下沉 → 无交换"]
H2 --> H3["i=1 (值11) 与右子6比较,11>6无需交换"]
H3 --> H4["i=0 (值12) 与左13比较,13>12 交换 → [13,11,12,5,6,7],继续下沉12与右7比较不交换"]
H4 --> H5["最大堆完成: [13,11,12,5,6,7]"]
end
subgraph 排序["排序阶段"]
direction LR
S1["交换堆顶13与末尾7 → [7,11,12,5,6,13]"] --> S2["对7下沉: 12>7且12>11? 12最大,交换7与12 → [12,11,7,5,6,13],下沉7与6比较不交换"]
S2 --> S3["交换堆顶12与6 → [6,11,7,5,12,13]"] --> S4["下沉6: 11最大,交换6与11 → [11,6,7,5,12,13]"]
S4 --> S5["继续..."] --> Final["最终有序: [5,6,7,11,12,13]"]
end
建堆 --> 排序
图表说明:图中上半部分为建堆过程,箭头表示从非叶节点向前执行下沉,最终堆顶为全局最大值。下半部分为排序阶段,每次交换堆顶与末尾,并调整堆,最终产生升序序列。
模块 7:线性时间排序 —— 突破 O(n log n) 的边界
7.1 计数排序
算法原理
计数排序不经过元素间的比较,而是利用键值的范围信息,通过统计每个值出现的频率,然后计算累积分布来确定每个元素在最终数组中的位置。必须依赖额外的计数数组,故属于空间换时间策略,且其稳定性通过反向遍历原数组得以保障。
时间复杂度:O(n + k),k 为值域大小(max-min+1)。空间复杂度:O(n + k)。稳定。
Java 实现(支持负数)
public class CountingSort {
/**
* 计数排序,支持包含负数的数组
*/
public static void countingSort(int[] arr) {
if (arr == null || arr.length == 0) return;
// 找出最大值和最小值,以确定计数数组范围
int max = arr[0], min = arr[0];
for (int v : arr) {
if (v > max) max = v;
if (v < min) min = v;
}
int range = max - min + 1; // 值域大小
int[] count = new int[range]; // 计数数组,下标偏移 min
// 1. 统计频率
for (int v : arr) {
count[v - min]++;
}
// 2. 累积频率,count[i] 表示值 (i+min) 在结果中的最后一个位置索引+1
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 3. 反向遍历原数组,根据累积位置稳定放入输出数组
int[] output = new int[arr.length];
for (int i = arr.length - 1; i >= 0; i--) {
int val = arr[i];
// 找到 val 对应的累积位置,减 1 得到在 output 中的索引
int pos = count[val - min] - 1;
output[pos] = val;
count[val - min]--; // 向前移动该值的写入位置
}
// 4. 将输出数组拷贝回原数组
System.arraycopy(output, 0, arr, 0, arr.length);
}
public static void main(String[] args) {
int[] a = {4, 2, -2, 8, 3, 3, 1};
countingSort(a);
System.out.println(Arrays.toString(a)); // [-2, 1, 2, 3, 3, 4, 8]
}
}
代码逐层解析:
- 范围确定:通过
max - min + 1计算计数数组长度,并用v - min将值映射到非负索引,完美支持负数排序。 - 频率统计:第一遍遍历,
count[idx]++统计每个值的出现次数。 - 累积分布:
count[i] += count[i-1]后,count[idx]变为最后一个该值元素在输出数组中的位置+1。例如值为val的元素要放到output[ count[val-min] - 1 ]。 - 反向遍历保持稳定:之所以从
arr末尾向前遍历,是因为累积分布给了值的最后一个位置,反向遍历时,后出现的相同值会先放在靠后的位置,然后count[idx]--,使得前一个相同值放在更前的位置,从而保持原始相对顺序。 - 输出拷贝:利用
System.arraycopy高效复制回原数组。
过程示意图
flowchart
subgraph Step1["统计频率"]
A["原数组: 4,2,8,3,3,1"] --> C1["count: [0:1, 1:1, 2:1, 3:2, 4:1, 5:0,6:0,7:0,8:1]"]
end
subgraph Step2["累积分布"]
C1 --> C2["累积后: [0:1, 1:2, 2:3, 3:5, 4:6, ...]"]
end
subgraph Step3["反向放置"]
C2 --> P1["i=5 val=1 count[1]=2 -> output[1]=1"]
P1 --> P2["i=4 val=3 count[3]=5 -> output[4]=3"]
P2 --> P3["i=3 val=3 count[3]=4 -> output[3]=3"]
P3 --> P4["i=2 val=8 count[8]=... -> output[5]=8"]
P4 --> P5["i=1 val=2 count[2]=3 -> output[2]=2"]
P5 --> P6["i=0 val=4 count[4]=6 -> output[5]=4"]
end
Step3 --> Result["结果: 1,2,3,3,4,8"]
图表说明:详细描绘了计数排序的三大步骤。第一步统计频率;第二步将频率累积为每个值的最终位置;第三步反向遍历原数组并放置元素。注意两个 3 的放置顺序:后遍历到的 3(位置 3)放到了 output[3],前一个 3(位置 4)放到了 output[4],相对顺序不变,稳定性得以保证。
7.2 基数排序(LSD)
算法原理
基数排序将所有键值视为等长的数字序列,从最低有效位(LSD)开始,对每一位使用稳定的计数排序,逐步向高位推进。由于每一趟排序是稳定的,当高位相同时,低位排序的结果得到保留,从而最终整体有序。
时间复杂度:O(d * (n + k)),d 为最大位数,k 为基数(十进制为 10)。空间复杂度:O(n + k)。稳定。
Java 实现(LSD,处理正整数)
public class RadixSort {
public static void radixSort(int[] arr) {
if (arr == null || arr.length == 0) return;
// 找出最大值以确定最高位数
int max = Arrays.stream(arr).max().getAsInt();
// 对每一位进行计数排序,exp = 1, 10, 100, ...
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, exp);
}
}
/**
* 根据当前位 exp 对数组 arr 进行稳定计数排序
*/
private static void countingSortByDigit(int[] arr, int exp) {
int n = arr.length;
int[] output = new int[n];
int[] count = new int[10]; // 基数 10
// 1. 统计当前位数字的频率
for (int value : arr) {
int digit = (value / exp) % 10;
count[digit]++;
}
// 2. 累积频率
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 3. 反向遍历原数组,根据当前位数字放置到 output
for (int i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
// 4. 将本轮排序结果拷贝回 arr,供下一位使用
System.arraycopy(output, 0, arr, 0, n);
}
public static void main(String[] args) {
int[] a = {170, 45, 75, 90, 802, 24, 2, 66};
radixSort(a);
System.out.println(Arrays.toString(a)); // [2, 24, 45, 66, 75, 90, 170, 802]
}
}
代码逐层解析:
- 外层按位循环:
exp从 1 开始每次乘 10,直到超过最大值。每一趟处理一个十进制位。 - 按位计数排序:除了提取
digit = (value / exp) % 10,其余步骤与标准计数排序完全一致,都是稳定的。稳定性传导机制是 LSD 基数排序正确的关键:低位排序的相对顺序会保留到高位排序中。 - 处理负数:如果是包含负数的数组,可以先按符号分开,或者所有数加上一个偏移量统一转为非负,排序后再减回去。
基数排序过程图
flowchart
A["原始: 170,45,75,90,802,24,2,66"] --> B["个位排序 (exp=1)"]
B --> B1["按个位分配到桶: 0:170,90,802; 2:2; 4:24; 5:45,75; 6:66"]
B1 --> B2["收集: 170,90,802,2,24,45,75,66"]
B2 --> C["十位排序 (exp=10)"]
C --> C1["按十位分桶: 0:802,2; 2:24; 4:45; 6:66; 7:170,75; 9:90"]
C1 --> C2["收集: 802,2,24,45,66,170,75,90"]
C2 --> D["百位排序 (exp=100)"]
D --> D1["按百位分桶: 0:2,24,45,66,75,90; 1:170; 8:802"]
D1 --> D2["最终: 2,24,45,66,75,90,170,802"]
图表说明:该图完整展示了 LSD 基数排序的分配-收集过程。每次按一位数字分配到 0-9 桶中,然后按桶顺序收集。由于每趟使用的计数排序是稳定的,高位排序不会打乱低位已经排好的顺序。例如,经过个位排序后,个位相同的 170 和 90 的顺序在后续高位排序中仍然保持。
7.3 桶排序
假设输入数据均匀分布,桶排序将元素分配到若干个有序的桶区间中,每个桶内再用插入排序(或其它排序)排序,最后合并。期望时间复杂度 O(n),但如果数据严重不均匀,最坏退化到 O(n²)。
Java 实现示例(前面已有,不再赘述,但此处进行完善):
public class BucketSort {
public static void bucketSort(int[] arr) {
int n = arr.length;
if (n <= 0) return;
int max = arr[0], min = arr[0];
for (int v : arr) { max = Math.max(max, v); min = Math.min(min, v); }
// 根据数据范围与 n 确定桶数量,每个桶大小约为 (max-min)/n
int bucketCount = (max - min) / n + 1;
List<List<Integer>> buckets = new ArrayList<>(bucketCount);
for (int i = 0; i < bucketCount; i++) buckets.add(new ArrayList<>());
// 分配
for (int v : arr) {
int idx = (v - min) / n; // 映射到桶索引
buckets.get(idx).add(v);
}
// 桶内排序并合并
int idx = 0;
for (List<Integer> bucket : buckets) {
Collections.sort(bucket); // 使用 TimSort,适合小数据
for (int v : bucket) arr[idx++] = v;
}
}
}
关键点:桶的个数与数据规模 n 相近,期望每个桶元素很少,这样桶内排序开销极小。前提假设数据分布均匀,否则个别桶会堆积大量元素,退化。
模块 8:外部排序 —— 当数据超越内存
8.1 外部排序模型
当待排序文件远远大于可用内存时,不能一次性将所有数据加载到内存进行排序,必须借助外部存储(磁盘)进行外部排序。经典流程包含两个阶段:
- 生成初始归并段:将大文件顺序读入内存(每次读入一个块),在内存中对这一块数据进行内部排序(如快速排序),然后将有序结果写回磁盘,形成一个有序段。如此重复,生成多个有序段文件。
- 多路归并:将这些有序段通过 k 路归并算法合并成一个全局有序文件。归并时,每次从 k 个段中读取一块数据放入输入缓冲区,利用最小堆或败者树选择当前所有段首部的最小值,输出到最终文件。
磁盘 I/O 成本:外部排序的主要时间花在磁盘读写上。总 I/O 量大约是 2 × 数据总量 × (归并趟数 + 1)。优化目标:减少归并趟数。
graph LR
Disk["大文件"] --> Block1["块1 内部排序"] --> Seg1["有序段1"]
Disk --> Block2["块2 内部排序"] --> Seg2["有序段2"]
Disk --> Block3["块3 内部排序"] --> Seg3["有序段3"]
Seg1 --> Merging["多路归并 堆或败者树"]
Seg2 --> Merging
Seg3 --> Merging
Merging --> Output["输出全局有序文件"]
8.2 置换-选择排序
为了产生更长的初始有序段(减少段的数量,从而减少归并趟数),可以采用置换-选择排序。它利用一个有限大小的优先队列模拟内排序,动态输出最小元素并吸收新元素,可以产生平均长度为内存容量 2 倍的有序段。
8.3 多路归并的 Java 模拟实现
public class ExternalSortSimulation {
/**
* 模拟外部排序:将数据分块排序,然后多路归并
* @param data 全部数据(内存中模拟)
* @param blockSize 每个块的大小(模拟内存限制)
* @return 排序后的列表
*/
public static List<Integer> externalSort(List<Integer> data, int blockSize) {
// 1. 生成初始有序段(内存中模拟写入磁盘的 sorted runs)
List<List<Integer>> sortedRuns = new ArrayList<>();
for (int i = 0; i < data.size(); i += blockSize) {
int end = Math.min(i + blockSize, data.size());
List<Integer> block = new ArrayList<>(data.subList(i, end));
Collections.sort(block); // 内部排序
sortedRuns.add(block); // 视为写入磁盘的有序段
}
// 2. 多路归并:用一个最小堆维持每个 run 的当前头部元素
PriorityQueue<RunElement> minHeap = new PriorityQueue<>();
int k = sortedRuns.size();
// 每个 run 的当前读取位置索引
int[] pointers = new int[k];
for (int i = 0; i < k; i++) {
List<Integer> run = sortedRuns.get(i);
if (!run.isEmpty()) {
minHeap.offer(new RunElement(run.get(0), i));
}
}
List<Integer> result = new ArrayList<>();
while (!minHeap.isEmpty()) {
RunElement minElem = minHeap.poll(); // 取出当前最小值
result.add(minElem.value);
int runIdx = minElem.runIndex;
pointers[runIdx]++;
List<Integer> run = sortedRuns.get(runIdx);
// 如果该 run 还有元素,将下一个元素加入堆
if (pointers[runIdx] < run.size()) {
minHeap.offer(new RunElement(run.get(pointers[runIdx]), runIdx));
}
}
return result;
}
static class RunElement implements Comparable<RunElement> {
int value;
int runIndex;
RunElement(int v, int idx) { value = v; runIndex = idx; }
public int compareTo(RunElement o) { return Integer.compare(value, o.value); }
}
public static void main(String[] args) {
List<Integer> data = Arrays.asList(5,2,9,1,5,6,7,3,8,4,10,0);
List<Integer> sorted = externalSort(data, 4); // 块大小4
System.out.println(sorted); // [0,1,2,3,4,5,5,6,7,8,9,10]
}
}
代码解析:externalSort 方法模拟了外部排序的完整流程。第 1 阶段用 blockSize 控制内存块大小,分块内部排序产生有序段;第 2 阶段使用 PriorityQueue(对应实际中的败者树)实现 k 路归并。每个 RunElement 记录元素值及其来源段索引,确保可以正确读取下一个元素。该方法完全模拟了外部排序的逻辑,只不过在内存中完成。
外部排序多路归并示意图
flowchart LR
S1["有序段1<br>1,4,7,10"] --> M[多路归并<br>最小堆]
S2["有序段2<br>2,5,8,11"] --> M
S3["有序段3<br>3,6,9,12"] --> M
M --> O[输出: 1,2,3,4,5,6,7,8,9,10,11,12]
图表说明:多个有序段的数据像水流一样涌入归并器,归并器通过最小堆不断比较各路当前头部,将最小值弹出并输出,同时从相应有序段读入下一个元素。如此边读边写,最终汇集成全局有序输出。
模块 9:并行排序 —— 利用多核
9.1 Arrays.parallelSort 原理
Java 8 引入的 Arrays.parallelSort() 基于 ForkJoinPool 实现并行归并排序。它将数组拆分成多个子任务,每个子任务在超过阈值(如 8192)时继续拆分,否则调用 Arrays.sort()(双轴快排或 TimSort)完成子数组排序,然后并行地合并有序子数组。框架使用工作窃取算法平衡负载。
9.2 使用示例与注意事项
import java.util.Arrays;
public class ParallelSortDemo {
public static void main(String[] args) {
int size = 10_000_000;
int[] arr = new int[size];
Arrays.parallelSetAll(arr, i -> ThreadLocalRandom.current().nextInt());
long start = System.nanoTime();
Arrays.parallelSort(arr);
long end = System.nanoTime();
System.out.printf("并行排序耗时: %.2f ms%n", (end - start) / 1e6);
}
}
注意事项:
- 并行排序有线程调度开销,适用于大型数组(通常 > 8192),小数据反而更慢。
- 默认使用公共
ForkJoinPool,可能与系统中其他并行任务争抢资源,大型应用可自定义线程池。 - 并行排序是
DualPivotQuicksort或TimSort内部排序 + 外部归并的混合,因此对基本类型不稳定,对对象类型稳定。
并行排序序列图
sequenceDiagram
participant Main as 主线程
participant FJ as ForkJoinPool
participant W1 as Worker1
participant W2 as Worker2
Main->>FJ: parallelSort(arr, 0, n-1)
FJ->>W1: 左半部分排序任务
FJ->>W2: 右半部分排序任务
W1->>W1: 内部排序 (DualPivotQuicksort/TimSort)
W2->>W2: 内部排序
W1-->>FJ: 左半有序
W2-->>FJ: 右半有序
FJ->>FJ: 合并两个有序子数组
FJ-->>Main: 排序完成
图表说明:该序列图描绘了并行排序的整个执行流程。主线程将任务提交到 ForkJoinPool,由两个 Worker 并行执行内部排序,之后合并结果。工作窃取机制允许完成快的 Worker 协助完成未完成的任务,最大化 CPU 利用率。
模块 10:工业级排序深度剖析 —— TimSort 与 DualPivotQuicksort
10.1 TimSort(稳定,对象排序)
设计动机与核心思想
现实世界的数据往往部分有序(例如按时间戳排序的日志、连续插入的数据)。TimSort 充分利用了这一特点,它扫描数组找出自然的连续有序段(称为 run),然后通过归并这些 run 完成排序。如果 run 太短,就用插入排序扩展至最小长度 minrun。最终通过栈合并策略维持 O(n log n) 的最坏时间,并在最好情况(数据已有序)达到 O(n)。
流程详解:
- 查找 run:扫描数组,若为严格降序则反转成升序,得到一个升序 run。
- 扩展 run:如果 run 长度小于
minrun(通过数学公式计算),则用二分插入排序将后续元素加入 run,使其长度达到minrun,同时保证这一段的稳定性。 - 栈管理:将 run 的信息(起始位置、长度)压栈。每次压入后,检查栈顶三个 run 是否满足不等式
X > Z + Y和Y > Z(其中 XYZ 是栈顶向下数的三个 run),若不满足则合并其中某些 run,以维持斐波那契式的合并树,确保总合并开销最小。 - 合并优化:合并相邻 run 时,使用临时数组拷贝较小的 run,然后进行归并。归并中若检测到某个 run 的元素连续多次胜出(如 7 次),则切换为 Galloping 模式(指数搜索),跳过连续区域,减少比较次数。
关键特性:稳定、自适应、最坏 O(n log n)。
TimSort run 栈状态图
stateDiagram-v2
[*] --> 寻找Run
寻找Run --> 扩展至minrun: 插入排序
扩展至minrun --> 压入栈
压入栈 --> 检查栈不变量
检查栈不变量 --> 归并相邻run: 违反不变量
归并相邻run --> 检查栈不变量
检查栈不变量 --> 继续寻找Run: 未处理完
继续寻找Run --> 寻找Run
寻找Run --> 强制归并: 所有run已入栈
强制归并 --> [*]
图表说明:状态图表达了 TimSort 的主要阶段。寻找Run 和 扩展至minrun 利用了数据的局部有序性;检查栈不变量 与 归并相邻run 确保合并开销最小;最终 强制归并 完成所有 run 的合并。这种设计是工程优化与算法理论完美结合的典范。
10.2 DualPivotQuicksort(不稳定,基本类型排序)
设计动机
Java 基本类型数组排序不需要稳定性(基本类型的相等值无法区分),因此可以追求极致性能。DualPivotQuicksort 在 2009 年由 Yaroslavskiy 提出并集成到 JDK 7,相比经典单轴快排,通过两个 pivot 将数组分为三个区域进一步减少递归深度和比较次数,尤其对重复元素多的场景收益显著。
双轴分区步骤(简化):
- 选取两个 pivot
p1和p2,保证p1 <= p2。 - 维护三个指针
less(< p1 区的右边界)、great(> p2 区的左边界)、k(扫描指针)。 - 从左到右扫描:
- 若
arr[k] < p1,则交换到 less 区,less++,k++。 - 若
arr[k] > p2,则不断将great左移,直到arr[great] <= p2,交换arr[k]与arr[great],great--;交换后若arr[k] < p1再次处理。 - 否则
arr[k]在 p1 和 p2 之间,k++。
- 若
- 最后将
p1放到less-1,p2放到great+1,递归排序三个区间。
此外,JDK 实现中还包含大量针对小数组(< 47 用插入排序)、中等数组、以及对特定分布(如已排序数组)的优化处理。
双轴分区示意图
flowchart TD
subgraph 双轴分区结果
direction LR
P1["< p1"] --> P2["p1 ≤ x ≤ p2"] --> P3["> p2"]
end
图表说明:双轴快排将数组划分为三个区。中间区的元素无需参与后续递归,对于含有大量重复元素的数据,中间区会非常庞大,极大地减少递归开销。JDK 中基本类型排序(int[], long[] 等)使用的正是该算法。
模块 11:工程最佳实践与避坑指南
11.1 API 选用原则
Arrays.sort(int[])→ DualPivotQuicksort,速度快但不稳定,适用于基本类型。Arrays.sort(Object[])→ TimSort,稳定且自适应,适合对象数组。Arrays.parallelSort()→ 并行归并排序,适合大规模数据,但注意阈值和线程池。Collections.sort(List)→ 底层转为Arrays.sort。
11.2 Comparator 设计铁律
Comparator 必须满足自反性、对称性、传递性,且最好与 equals 一致,否则 TimSort 合并时会检测到逻辑矛盾,抛出 Comparison method violates its general contract!。编写 Comparator 时,建议使用 Comparator.comparing 和 thenComparing 链式构建,避免手动犯错。
错误 Comparator 示例(违反传递性):
Comparator<Integer> wrong = (a, b) -> a % 2 - b % 2;
// 例如 a=1,b=2,c=3: compare(1,2)=1>0, compare(2,3)=0, compare(1,3)=0 不满足传递性
该错误 Comparator 会使 TimSort 在运行时抛出异常,因为其内部检查无法满足比较逻辑的一致性。
11.3 稳定排序与 UI 分页
多字段排序必须使用稳定排序或复合比较器。例如先按时间排序,再按 ID 排序。若用不稳定排序,可能打乱已排好的时间顺序,导致分页游标错乱、数据重复或丢失。
11.4 大数据量排序的 GC 调优
排序对象数组可能引起大量对象移动,触发 GC。可考虑:
- 排序索引数组而非原对象数组。
- 使用基本类型存储键值,避免装箱。
- 预先扩大年轻代或使用 G1 等低延迟收集器。
11.5 性能测试与 JMH
正确测量排序性能需避免 JIT 优化死代码,使用 JMH:
@Benchmark
public void sortBenchmark(Blackhole bh) {
int[] arr = Arrays.copyOf(data, data.length);
Arrays.sort(arr);
bh.consume(arr); // 消费结果,避免被优化掉
}
11.6 工程避坑清单
| 陷阱 | 表现 | 原因 | 解决方案 |
|---|---|---|---|
| 对象排序不稳定导致分页重复 | 翻页看到相同数据 | 快排打乱相等键顺序 | 使用稳定排序或一次性复合Comparator |
| 比较器违反传递性 | TimSort抛异常 | 不满足契约 | 使用Comparator.comparing等标准方法构建 |
| 依赖基本类型排序稳定性 | 相同值顺序不确定 | 快排不稳定 | 包装为对象使用TimSort |
| 超大对象数组导致Full GC | 长时间STW | 排序产生大量写操作和对象移动 | 排序索引列表、扩大堆、使用G1 |
LinkedList直接调Collections.sort | 性能极低 | 底层转为数组再排序 | 转为ArrayList或自实现归并 |
| 汉字排序期望按拼音 | 结果按Unicode码点 | 未设置Locale | 使用Collator.getInstance(Locale.CHINA) |
parallelSort耗尽公共ForkJoinPool | 其他并行任务阻塞 | 共用线程池 | 评估并行收益,必要时自建线程池 |
| 忽略近似有序数据特性 | 性能浪费 | 未使用适应性算法 | 直接使用TimSort(默认具备适应性) |
| 频繁创建Comparator | 内存与性能开销 | 每次排序new对象 | 复用Comparator实例或预提取排序键 |
| 键提取操作昂贵 | 排序耗时主要在getter | 比较器内反复计算 | 预计算排序键并缓存 |
模块 12:面试高频专题
说明:以下题目统一归集,每题包含标准回答、多角度追问、加分回答。
1. 手写快速排序,分析最好/最坏情况,如何避免最坏?
标准回答:使用 Hoare 分区或 Lomuto 分区递归实现。最好情况每次 pivot 均分数组,O(n log n);最坏情况数组已有序且 pivot 选首/尾,退化为 O(n²)。避免最坏的手段:随机化 pivot、三数取中、三向切分处理重复元素、递归深度过深切换堆排序(Introsort)。
追问:
- Hoare 与 Lomuto 的常数差异?→ Hoare 交换次数约为 Lomuto 的 1/3。
- 三数取中如何选择?→ 首、尾、中间三个元素的中位数。
- 尾递归如何减少栈空间?→ 只对较小的分区递归,较大的用循环迭代。
加分回答:JDK DualPivotQuicksort 用两个 pivot 进一步降低递归深度;C++ std::sort 使用 Introsort(快排+堆排混合),Java 选择双轴快排,体现了不同设计趋向。
2. 手写归并排序,如何用链表实现?分析稳定性与空间复杂度。
标准回答:自顶向下递归归并或自底向上迭代。稳定性源于合并时左指针优先。空间:数组版 O(n),链表版 O(1) 额外空间。
追问:如何避免递归?→ 自底向上迭代。链表归并排序如何 O(1) 空间?→ 利用指针操作拆分和合并。归并排序的适应性如何?→ 较好,可检测若前一段最大 ≤ 后一段最小则直接拼接。
加分回答:归并排序是外部排序基石;Python TimSort 和 JDK TimSort 都基于归并;在 Spark 的 Shuffle Sort 中也使用改进的归并。
3. 堆排序原理,建堆为什么是 O(n)?与快排对比优缺点。
标准回答:建堆从最后一个非叶节点向前下沉,各节点下沉高度总和收敛于 O(n)。排序 O(n log n)。优点:原地、最坏保证;缺点:缓存不友好,实际慢于快排,且不稳定。
追问:怎样证明建堆 O(n)? 数学归纳,层节点数乘以高度。为什么实际慢?缓存跳跃、分支预测失败。堆排序适合什么场景?内存极度有限且需最坏保证的实时系统。
加分回答:Go 语言优先队列使用堆;Java PriorityQueue 使用二叉堆;堆排序可无锁化实现,有一定并行潜力。
4. 比较计数排序与基数排序,为什么它们能达到 O(n)?对数据有什么要求?
标准回答:不基于比较,利用键的整数结构。计数排序 O(n+k),基数 O(d(n+k))。要求数据范围有限且可离散化。
追问:LSD 为什么稳定传导?每趟稳定计数排序。如何处理负数?加偏移量。桶排序何时优于它们?数据连续均匀分布。
加分回答:数据库列存中常用计数排序;MapReduce Shuffle 阶段使用 Counting Sort 分组;基数排序可用于字符串排序。
5. 解释排序算法的稳定性,哪些是稳定的?在真实业务中为什么重要?
标准回答:稳定:归并、插入、冒泡、计数、基数、桶、TimSort;不稳定:快排、堆、选择、希尔。业务多级排序必须稳定,否则前序排序被破坏,分页错乱。
追问:如何将不稳定排序“稳定化”?附加原索引作为二级键。为什么 Java 对象排序用稳定排序?保证多级排序与 API 契约。数据库索引与稳定性关系?索引维持有序,但查询时可能使用不稳定排序,需用 sort 强制稳定。
加分回答:C++ 的 std::stable_sort 提供稳定排序;Python sorted 稳定;Swift 也是稳定排序,这是语言设计哲学。
6. Java 中 Arrays.sort(int[]) 和 Arrays.sort(Object[]) 内部用了什么算法?为什么不同?
标准回答:int[] 用 DualPivotQuicksort(不稳定),Object[] 用 TimSort(稳定)。基本类型无需稳定性,可放手优化;对象排序需稳定且数据常部分有序,TimSort 更佳。
追问:如果基本类型也想稳定怎么办?包装为对象。对象排序能回退到快排吗?不能,JDK 强制稳定。并行排序呢?parallelSort 对基本类型用并行快排,对对象用并行 TimSort。
加分回答:JDK 7 之前对象排序用归并排序,后引入 TimSort 大幅提升性能。双轴快排由 Yaroslavskiy 提出,JDK 内部针对结构化数据做了大量优化。
7. TimSort 的核心思想是什么?它是如何发现并利用数据中的有序片段的?
标准回答:扫描数组识别自然 run(降序反转),不足 minrun 则插入排序扩展;入栈并维持栈不变量决定合并时机;合并用 Galloping 优化。充分利用现实数据常部分有序的特点。
追问:minrun 怎么计算?取 n/2 接近的 2 的幂次,范围 32~64。Galloping 触发条件?连续 7 次比较来自同一 run。栈不变量具体表达式?X > Z + Y 且 Y > Z。
加分回答:Tim Peters 为 Python 设计;JDK 源码 TimSort.java 实现及其注释非常详尽;TimSort 的稳定性使其适用于 UI 表现。
8. 什么是双轴快排?它比经典快排好在哪里?
标准回答:使用两个 pivot 将数组分为三区,减少递归深度,中间区元素不再参与排序,特别适合重复元素多的场景。比单轴快排提升约 10%。
追问:若 p1 == p2 会发生什么?退化为三向切分。为什么 JDK 双轴快排对小数组回退插入排序?常数因子,避免递归开销。与三向切分有何区别?双轴快排分三个区但 pivot 是两个值,三向切分是一个 pivot 三个区。
加分回答:Yaroslavskiy 的论文包含详细性能基准;JDK 实现含有大量数据分布探测代码,如 already sorted 优化。
9. 外部排序的基本过程是怎样的?如果数据有 500GB 而内存只有 1GB,如何排序?
标准回答:分 500 块每块 1GB 内部排序产生有序段,然后 500 路归并(实际 K 可能受内存限制设为 200,做多趟归并)。使用败者树或堆。总 I/O 约 2×500GB×(趟数+1)。
追问:如何减少归并趟数?用置换-选择排序产生更长的初始段。多路归并如何选择 k?受限于内存能容纳的输入缓冲区数量。归并过程中如何避免频繁磁盘寻道?使用大块缓冲区顺序读写。
加分回答:数据库 ORDER BY 大数据集使用此类算法;Hadoop MapReduce Shuffle 阶段使用多路归并;PostgreSQL 源码 tuplesort.c 值得研读。
10. 在并发环境下如何高效排序?Arrays.parallelSort 的原理和注意事项?
标准回答:基于 ForkJoin 拆分数组,并行内部排序然后归并。注意阈值、线程池共用、小数据开销。
追问:工作窃取如何帮助?平衡负载。为什么非比较排序不提供并行版?计数排序全局累积需要同步,开销大。如何监控并行排序?自定义 FJPool 观察活跃线程。
加分回答:parallelSort 是 Java 8 引入,Benchmark 显示 8 核可加速 3-4 倍。但要注意与 Stream.parallel 区分。
11. 给定 10 亿个整数,找出前 1000 大的数,有哪些方法?分析各自复杂度。
标准回答:全排序 O(n log n) 不必要;最小堆 O(n log k) k=1000;快速选择期望 O(n) 最坏 O(n²);范围有限可用计数或基数。
追问:数据无法全部内存呢?分块用堆筛选或外排序。快速选择如何避免最坏?中位数之中位数选 pivot。为什么不直接全排序?时间空间浪费。
加分回答:Spark takeOrdered 使用分布式堆;Elasticsearch 聚合使用 Top-K 堆;数据库 TOP N 查询也基于优先队列。
12. 多字段排序如何实现(如先按年龄降序,再按姓名升序)?如何保证性能?
标准回答:构建复合 Comparator,使用 thenComparing。避免在 Comparator 中做昂贵操作,预计算排序键。
追问:动态排序字段如何处理?反射或预编译。怎样缓存排序键?提取成 byte 数组或预计算 int 值。数据库如何高效?复合索引。
加分回答:JDK Comparator.comparing 支持链式调用;Lucene 索引排序也是预计算排序键。
13. 为什么基本类型排序不使用稳定排序?从性能、内存、必要性角度分析。
标准回答:相等值无法区分,稳定性无意义;稳定排序往往需要额外空间或更大常数;双轴快排原地且常数极低,性能最优。
追问:如果偏要怎么办?包装为对象。C++ 基本类型排序稳定吗?std::sort 不保证,但 std::stable_sort 保证。Swift 为什么统一稳定?简化语义。
加分回答:Go 的 sort.Ints 使用快速排序,不保证稳定。Python 的 list.sort 稳定。Java 这种区分是一种极致的性能权衡。
14. 怎样正确地对中文进行排序?Collator 的作用以及 locale 敏感问题。
标准回答:使用 Collator.getInstance(Locale.CHINA) 按拼音排序;可设置强度。注意不同 Locale 排序结果不同。
追问:性能优化?预计算 CollationKey。数据库怎么做?COLLATE 子句。Elasticsearch 怎么办?ICU 分析器。
加分回答:ICU 库提供更全面的 Unicode 排序支持;Java 9+ 默认使用 Unicode 10.0 排序规则。
15. 什么是比较器的契约?违反传递性会导致什么问题?可以举一个导致 TimSort 崩溃的例子吗?
标准回答:契约:自反性、对称性、传递性。违反传递性会导致 TimSort 在合并 run 时检测到矛盾,抛出 Comparison method violates its general contract!。例如 Comparator<Integer> c = (a,b) -> a%2 - b%2,对于 a=1,b=2,c=3,有 1>2, 2=3, 但 1=3,矛盾。
追问:TimSort 如何检测?合并过程中检查比较关系是否自洽。为什么旧归并排序没检测?TimSort 加入了安全断言。如何调试此类异常?对 Comparator 编写单元测试检验传递性。
加分回答:JDK 源码中 TimSort 的 mergeLo 和 mergeHi 方法中包含契约检查的断言;这种做法促使开发者写出正确比较器。
16. (系统设计题一)设计一个大型电商的商品列表服务,要求支持多种排序方式(价格、销量、评分),且分页游标不能错乱,需保证极高并发。请设计缓存、排序、分页策略。
标准回答:
- 服务端:基于 Elasticsearch 存储商品信息,对各种排序字段建立正排索引和预排序(如文档值)。
- 分页:使用游标分页(
search_after),避免跳页。游标 key 为sort_field + 唯一ID,确保顺序稳定。 - 缓存:热门排序结果的前几页缓存于 Redis ZSET 中,按分值排序。写入时异步更新。
- 稳定性:排序规则必须包含唯一二级键(商品ID),即使主排序键相同也不会跳页。
- 高并发:索引层水平扩展,本地缓存 + 远程 Redis 集群,服务无状态。
追问:价格频繁变动如何实时?秒级延迟,使用增量索引更新。游标分页如何防止游标篡改?对游标签名或加密。数据库分库分表如何排序?中间件归并排序。
加分回答:淘宝、Amazon 均采用类似架构;Elasticsearch 的 search_after 可以有效支持深度分页;如果需求允许,可采用定时刷新榜单缓存的方式减轻实时查询压力。
17. (系统设计题二)设计一个日志分析系统,每天产生 TB 级半结构化日志,需要按时间戳排序后进行关联查询。请设计数据存储、排序管道和索引方案。
标准回答:
- 摄取:Kafka 收集日志,按时间分区(如 5 分钟),保证同一时间分区内有序。
- 排序管道:使用 Flink 按事件时间分配 Watermark,对微批数据按时间戳排序(内部使用 TimSort),处理乱序。
- 存储:以 Parquet 格式写入 HDFS,按小时/天分区,每个文件内按时间戳排序,并记录 min/max 统计元数据。
- 查询:使用 Presto/Spark SQL 读取时利用分区裁剪和文件级统计信息跳过无关文件,关联查询采用 Sort Merge Join。
- 实时查询:实时层写入 ClickHouse 或 Elasticsearch,支持近实时排序和分析。
追问:如何处理严重乱序日志?设置允许的乱序延迟,超时丢弃或单独处理。为什么用 Parquet?列存 + 有序性提升谓词下推和压缩。Flink 中如何保证排序?利用 KeyedProcessFunction 配合定时器排序。
加分回答:Flink 的 Watermark 机制天然适合时间排序;大规模日志分析通常采用 Lambda 架构,批处理层保证最终一致;Iceberg 等表格式也可利用排序优化查询。
延伸阅读
- 《算法导论》(Thomas H. Cormen 等) —— 第 2、6、7、8 章,排序与选择经典理论,建堆线性时间证明等。
- 《算法》(Robert Sedgewick) —— 第 2 章排序,讲解清晰,包含 Lomuto/Hoare 分区细节及三向切分。
- “TimSort” 原始文档及 Java 源码注释 —— Tim Peters 的原始 Python TimSort 描述,
java.util.TimSort注释。 - “Dual-Pivot Quicksort” 论文 (V. Yaroslavskiy) —— 原始双轴快排论文,JDK 实现的奠基之作。
- 《深入理解 Java 虚拟机》(周志明)—— 第 11 章有关编译优化与 GC 对排序性能影响。
- “External Sorting” 经典综述—— 数据库教科书中的外部排序与置换-选择算法描述。
Arrays.parallelSort官方使用指南—— Oracle JDK 文档,阈值与 ForkJoinPool 使用说明。- “Engineering a Sort Function” (Bentley & McIlroy)—— C 标准库
qsort的工程实现,蕴含适应性排序早期思想。
本文从排序的哲学根基出发,历经基础算法、高效比较、线性突破、外部与并行,直至工业级巨匠 TimSort 和 DualPivotQuicksort 的微观手术,构建了一幅完整而深刻的排序知识地图。希望它能助你在面试中侃侃而谈,在系统设计上洞悉本质,在实际编码中避开陷阱。排序的艺术,远不止于“写出一个 API 调用”,而在于理解其内在权衡并做出最优抉择。