第八章 排序
第八章 排序
8.1 排序的基本概念
排序:将一组无序数据按照关键字递增(或递减)排列。
稳定性:若排序前两个相等的元素 和 (),排序后 仍在 前面,则称排序算法是稳定的,否则是不稳定的。
🗣️ 大白话:假设班里有两个学生都叫"张三",排序前 A张三 在 B张三 前面。如果排序后 A张三 还在前面,这种排序就是"稳定"的。
内部排序 vs 外部排序:
- 内部排序:数据全部在内存中
- 外部排序:数据量大,需借助外存
🔗 【跨学科联动·OS/计组】 排序算法的选择与 OS 和计组紧密关联:
- OS·外部排序:当数据无法全部装入内存时,需要利用 OS 的文件系统和缓冲区管理进行外部归并排序。归并路数受内存缓冲区数量限制
- OS·虚拟内存:排序时若访问模式不佳(如快排的随机跳转访问),可能导致大量缺页中断,严重影响性能
- 计组·Cache 友好性:归并排序的顺序访问模式比快排的随机访问更Cache 友好,但快排的常数因子更小。实际工程中常在小规模子问题切换为插入排序以提升 Cache 命中率
- OS·进程调度:优先级队列调度本质就是用堆排序的思想——每次取出优先级最高的进程执行
8.2 插入排序
一、直接插入排序
思想:将待排序元素插入到已排好的有序子序列中的适当位置。
void InsertSort(ElemType A[], int n) {
int i, j;
for (i = 2; i <= n; i++) { // 从第2个元素开始
if (A[i] < A[i-1]) { // 若当前元素小于前驱
A[0] = A[i]; // 复制为哨兵
for (j = i - 1; A[0] < A[j]; j--)
A[j+1] = A[j]; // 记录后移
A[j+1] = A[0]; // 复制到插入位置
}
}
}
// 直接插入排序(Java版,下标从0开始)
void insertSort(int[] A, int n) {
for (int i = 1; i < n; i++) {
if (A[i] < A[i - 1]) {
int temp = A[i];
int j;
for (j = i - 1; j >= 0 && temp < A[j]; j--)
A[j + 1] = A[j]; // 记录后移
A[j + 1] = temp; // 复制到插入位置
}
}
}
| 分析项 | 值 |
|---|---|
| 时间复杂度(最好) | (已有序) |
| 时间复杂度(最坏) | (逆序) |
| 时间复杂度(平均) | |
| 空间复杂度 | |
| 稳定性 | ✅ 稳定 |
| 适用场景 | 基本有序、数据量小 |
二、折半插入排序
思想:在直接插入排序的基础上,用折半查找来确定插入位置(减少比较次数,但移动次数不变)。
void InsertSort_Binary(ElemType A[], int n) {
int i, j, low, high, mid;
for (i = 2; i <= n; i++) {
A[0] = A[i]; // 暂存到哨兵
low = 1; high = i - 1;
while (low <= high) { // 折半查找插入位置
mid = (low + high) / 2;
if (A[mid] > A[0]) high = mid - 1;
else low = mid + 1; // A[mid] <= A[0] → 右半区(保证稳定性)
}
for (j = i - 1; j >= high + 1; j--)
A[j+1] = A[j]; // 统一后移
A[high+1] = A[0]; // 插入
}
}
// 折半插入排序(Java版,下标从0开始)
void insertSortBinary(int[] A, int n) {
for (int i = 1; i < n; i++) {
int temp = A[i];
int low = 0, high = i - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (A[mid] > temp) high = mid - 1;
else low = mid + 1; // 保证稳定性
}
for (int j = i - 1; j >= high + 1; j--)
A[j + 1] = A[j];
A[high + 1] = temp;
}
}
⚠️ 注意:折半查找中,当
A[mid] == A[0]时,应继续在右半区查找(low = mid + 1),这样才能保证排序的稳定性。
| 分析项 | 值 |
|---|---|
| 时间复杂度 | (移动次数与直接插入排序相同) |
| 比较次数 | (优化了比较) |
| 稳定性 | ✅ 稳定 |
| 适用 | 数据量不大的排序;对链表无法使用(需折半查找) |
三、希尔排序(缩小增量排序)
思想:将序列按增量 分成若干组,对每组进行直接插入排序;逐步减小增量直到 。
例:增量序列 d = {5, 3, 1},序列 = {49, 38, 65, 97, 76, 13, 27, 49, 55, 4}
第1趟:d=5,分5组 {49,13} {38,27} {65,49} {97,55} {76,4}
组内排序后:13 27 49 55 4 49 38 65 97 76
第2趟:d=3,分3组 {13,55,38,76} {27,4,65} {49,49,97}
组内排序后:13 4 49 38 27 49 55 65 97 76
第3趟:d=1,整体插入排序(此时已基本有序,很快)
最终结果:4 13 27 38 49 49 55 65 76 97
void ShellSort(ElemType A[], int n) {
int d, i, j;
for (d = n/2; d >= 1; d = d/2) { // 增量变化
for (i = d + 1; i <= n; i++) { // 对每个组进行插入排序
if (A[i] < A[i-d]) {
A[0] = A[i]; // 暂存(A[0]仅作暂存,不做哨兵)
for (j = i - d; j > 0 && A[0] < A[j]; j -= d)
A[j+d] = A[j]; // 记录后移
A[j+d] = A[0];
}
}
}
}
// 希尔排序(Java版,下标从0开始)
void shellSort(int[] A, int n) {
for (int d = n / 2; d >= 1; d /= 2) {
for (int i = d; i < n; i++) {
if (A[i] < A[i - d]) {
int temp = A[i];
int j;
for (j = i - d; j >= 0 && temp < A[j]; j -= d)
A[j + d] = A[j];
A[j + d] = temp;
}
}
}
}
⚠️ 增量序列的选择:希尔提出的 在最坏情况下仍为 。Hibbard增量序列 可达 。增量序列的最后一个值必须为1。各增量值应互质,否则前面趟次的排序工作可能被"浪费"。
| 分析项 | 值 |
|---|---|
| 时间复杂度 | 取决于增量序列,最坏 ,某些增量序列可达 |
| 空间复杂度 | |
| 稳定性 | ❌ 不稳定(相同关键字可能被分到不同子表,相对位置改变) |
| 适用 | 仅适用于顺序表(需随机访问) |
8.3 交换排序
一、冒泡排序
思想:从后往前(或从前往后)两两比较相邻元素,如果逆序就交换。每趟确定一个元素的最终位置。
void BubbleSort(ElemType A[], int n) {
for (int i = 0; i < n - 1; i++) {
bool flag = false;
for (int j = n - 1; j > i; j--) {
if (A[j-1] > A[j]) {
swap(A[j-1], A[j]);
flag = true;
}
}
if (flag == false) return; // 没有交换,已经有序
}
}
void bubbleSort(int[] A, int n) {
for (int i = 0; i < n - 1; i++) {
boolean flag = false;
for (int j = n - 1; j > i; j--) {
if (A[j - 1] > A[j]) {
int temp = A[j - 1]; A[j - 1] = A[j]; A[j] = temp;
flag = true;
}
}
if (!flag) return; // 没有交换,已经有序
}
}
| 分析项 | 值 |
|---|---|
| 时间复杂度(最好) | (已有序,只需一趟扫描) |
| 时间复杂度(最坏) | (逆序) |
| 时间复杂度(平均) | |
| 空间复杂度 | |
| 稳定性 | ✅ 稳定 |
冒泡排序特征:每趟排序至少确定一个元素的最终位置。
二、快速排序(⭐ 最重要的排序算法)
思想:分治法。选择一个枢轴(pivot),将序列分成两部分——小于枢轴的放左边,大于枢轴的放右边,然后递归处理左右两部分。
void QuickSort(ElemType A[], int low, int high) {
if (low < high) {
int pivotpos = Partition(A, low, high);
QuickSort(A, low, pivotpos - 1); // 排左半部分
QuickSort(A, pivotpos + 1, high); // 排右半部分
}
}
int Partition(ElemType A[], int low, int high) {
ElemType pivot = A[low]; // 取第一个元素为枢轴
while (low < high) {
while (low < high && A[high] >= pivot) high--;
A[low] = A[high]; // 比枢轴小的移到左端

while (low < high && A[low] <= pivot) low++;
A[high] = A[low]; // 比枢轴大的移到右端
}
A[low] = pivot; // 枢轴元素存放到最终位置
return low;
}
void quickSort(int[] A, int low, int high) {
if (low < high) {
int pivotpos = partition(A, low, high);
quickSort(A, low, pivotpos - 1); // 排左半部分
quickSort(A, pivotpos + 1, high); // 排右半部分
}
}
int partition(int[] A, int low, int high) {
int pivot = A[low]; // 取第一个元素为枢轴
while (low < high) {
while (low < high && A[high] >= pivot) high--;
A[low] = A[high]; // 比枢轴小的移到左端
while (low < high && A[low] <= pivot) low++;
A[high] = A[low]; // 比枢轴大的移到右端
}
A[low] = pivot; // 枢轴元素存放到最终位置
return low;
}
| 分析项 | 值 |
|---|---|
| 时间复杂度(最好) | (每次枢轴都恰好在中间) |
| 时间复杂度(最坏) | (已有序或逆序,退化为冒泡) |
| 时间复杂度(平均) | (所有内部排序中平均性能最优) |
| 空间复杂度 | (递归栈深度),最坏 |
| 稳定性 | ❌ 不稳定 |
🗣️ 大白话:快速排序像分苹果——先选一个标准苹果(枢轴),比它小的放左边筐,比它大的放右边筐,然后对两个筐分别重复这个过程。
⚠️ 快排的关键:枢轴的选择。如果每次都选到最大或最小的元素,效率退化为 。
枢轴优化策略:
- 三数取中法(Median-of-Three):取
A[low]、A[mid]、A[high]三者的中间值作为枢轴,可以有效避免退化 - 随机选枢轴:在
[low, high]中随机选取一个元素与A[low]交换,再按常规方法划分 - 当子序列很短(如 n ≤ 10)时切换为直接插入排序,减少递归开销
⚠️ 区分概念:一次划分(Partition)确定一个元素的最终位置 ≠ 一趟排序。"一趟排序"指的是对当前递归层的所有子表各进行一次划分。例如第2趟排序可能同时对左右两个子表进行划分,确定2个元素的最终位置。
快速排序的重要特征:
- 每次Partition都能确定一个元素(枢轴)的最终位置
- 快速排序是所有内部排序中平均性能最好的
- 快排的递归栈深度:最好 ,最坏
- 快排不产生有序子序列(不像冒泡/选择那样每趟生成全局最值),但每趟排序后枢轴元素一定在最终位置
- 若要判断某序列是否可能是快排某趟后的结果,可检查是否恰好有对应个数的元素已到了最终位置
8.4 选择排序
一、简单选择排序
思想:每趟从待排序元素中选出最小的元素,放到已排序序列的末尾。
void SelectSort(ElemType A[], int n) {
for (int i = 0; i < n - 1; i++) {
int min = i;
for (int j = i + 1; j < n; j++)
if (A[j] < A[min]) min = j;
if (min != i) swap(A[i], A[min]);
}
}
void selectSort(int[] A, int n) {
for (int i = 0; i < n - 1; i++) {
int min = i;
for (int j = i + 1; j < n; j++)
if (A[j] < A[min]) min = j;
if (min != i) {
int temp = A[i]; A[i] = A[min]; A[min] = temp;
}
}
}
| 分析项 | 值 |
|---|---|
| 时间复杂度 | 始终 (无论是否有序,比较次数相同) |
| 空间复杂度 | |
| 稳定性 | ❌ 不稳定(例:) |
| 移动次数 | 最好 ,最坏 |
二、堆排序(⭐ 重要)
堆的定义:
- 大根堆(最大堆): 且 (每个结点 ≥ 其孩子)
- 小根堆(最小堆): 且 (每个结点 ≤ 其孩子)
🗣️ 大白话:大根堆就像公司的层级结构——老板(根)的工资最高,每个经理的工资都比手下高。
建堆:从最后一个非叶子结点 开始,自下而上下滤调整。时间复杂度:
💡 建堆 的证明:设完全二叉树高度为 ,第 层最多有 个结点,每个结点最多下滤 层。总比较次数 。令 ,得 。由差比数列求和知 ,故总次数 ,即 。
🗣️ 大白话:建堆之所以是 而不是 ,是因为大部分结点都在底层、需要调整的层数很少,而少数结点在顶层虽然需要调整很多层但数量极少。
// 大根堆的下滤调整(将以k为根的子树调整为大根堆)
void HeapAdjust(ElemType A[], int k, int len) {
A[0] = A[k]; // 暂存
for (int i = 2*k; i <= len; i *= 2) {
if (i < len && A[i] < A[i+1])
i++; // 选较大的孩子
if (A[0] >= A[i])
break; // 已经满足堆性质
else {
A[k] = A[i]; // 将孩子上移
k = i;
}
}
A[k] = A[0];
}
// 大根堆的下滤调整(Java版,下标从1开始)
void heapAdjust(int[] A, int k, int len) {
int temp = A[k]; // 暂存
for (int i = 2 * k; i <= len; i *= 2) {
if (i < len && A[i] < A[i + 1])
i++; // 选较大的孩子
if (temp >= A[i])
break; // 已经满足堆性质
else {
A[k] = A[i]; // 将孩子上移
k = i;
}
}
A[k] = temp;
}
堆排序过程(升序用大根堆):
- 建立大根堆
- 将堆顶元素(最大值)与末尾元素交换
- 对剩余 个元素重新调整为大根堆
- 重复2-3
void HeapSort(ElemType A[], int len) {
// 建堆
for (int i = len/2; i > 0; i--)
HeapAdjust(A, i, len);
// 排序
for (int i = len; i > 1; i--) {
swap(A[1], A[i]); // 堆顶与末尾交换
HeapAdjust(A, 1, i-1); // 调整剩余元素
}
}
void heapSort(int[] A, int len) {
// 建堆
for (int i = len / 2; i > 0; i--)
heapAdjust(A, i, len);
// 排序
for (int i = len; i > 1; i--) {
int temp = A[1]; A[1] = A[i]; A[i] = temp; // 堆顶与末尾交换
heapAdjust(A, 1, i - 1); // 调整剩余元素
}
}
| 分析项 | 值 |
|---|---|
| 时间复杂度 | 始终 (建堆 + 调整 ) |
| 空间复杂度 | |
| 稳定性 | ❌ 不稳定 |
堆的插入和删除:
- 插入:将新元素放在末尾,然后上滤调整(Sift Up)
- 删除堆顶:将末尾元素替换堆顶,然后下滤调整(Sift Down)
- 删除任意位置:将末尾元素替换到删除位置,然后视情况上滤或下滤
// 大根堆的上滤操作(插入新元素后,从末尾向上调整)
void SiftUp(ElemType A[], int k) {
// k为新元素下标(已放置在末尾)
A[0] = A[k]; // 暂存
int i = k / 2; // 父结点
while (i > 0 && A[i] < A[0]) {
A[k] = A[i]; // 父结点下移
k = i;
i = k / 2;
}
A[k] = A[0];
}
// 大根堆的上滤操作(Java版,下标从1开始)
void siftUp(int[] A, int k) {
int temp = A[k];
int i = k / 2;
while (i > 0 && A[i] < temp) {
A[k] = A[i];
k = i;
i = k / 2;
}
A[k] = temp;
}
⚠️ 注意:对堆进行插入/删除操作后,调整的时间复杂度为 ,而非 。建堆才是 ,不要混淆!
💡 堆可以用于:优先队列(任务调度)、Top-K 问题(从海量数据中找前K个最大/最小值)
8.5 归并排序与基数排序
一、归并排序(2路归并)
思想:分治法。将序列分成两半,分别排序,然后将两个有序序列合并成一个有序序列。
void MergeSort(ElemType A[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
MergeSort(A, low, mid); // 排左半部分
MergeSort(A, mid + 1, high); // 排右半部分
Merge(A, low, mid, high); // 合并
}
}
void mergeSort(int[] A, int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
mergeSort(A, low, mid); // 排左半部分
mergeSort(A, mid + 1, high); // 排右半部分
merge(A, low, mid, high); // 合并
}
}
// 合并操作
void merge(int[] A, int low, int mid, int high) {
int[] temp = new int[high - low + 1];
int i = low, j = mid + 1, k = 0;
while (i <= mid && j <= high) {
if (A[i] <= A[j]) temp[k++] = A[i++];
else temp[k++] = A[j++];
}
while (i <= mid) temp[k++] = A[i++];
while (j <= high) temp[k++] = A[j++];
System.arraycopy(temp, 0, A, low, temp.length);
}
合并操作:需要一个辅助数组。
| 分析项 | 值 |
|---|---|
| 时间复杂度 | 始终 |
| 空间复杂度 | (需要辅助数组) |
| 稳定性 | ✅ 稳定 |
🗣️ 大白话:归并排序就像两队已经排好队的人合并成一队——每次从两队头部各看一个人,较小的先站过来,直到两队都站完。
二、基数排序
思想:不比较关键字大小。把关键字拆成若干"位"(如个位、十位、百位),按位进行分配和收集。
**LSD(最低位优先)**方法:
- 先按个位分配到0~9号桶,再按顺序收集
- 再按十位分配和收集
- 再按百位……直到最高位
**MSD(最高位优先)**方法:先按最高位分组,各组内递归。
LSD示例:对 {278, 109, 063, 930, 589, 184, 505, 269, 008, 083} 排序
初始:278 109 063 930 589 184 505 269 008 083
按个位分配(d=1):
桶0: 930
桶1:
桶2:
桶3: 063, 083
桶4: 184
桶5: 505
桶6:
桶7:
桶8: 278, 008
桶9: 109, 589, 269
收集:930 063 083 184 505 278 008 109 589 269
按十位分配(d=2):
桶0: 505, 008, 109
桶6: 063, 269
桶7: 278
桶8: 083, 184, 589
桶9: 930
收集:505 008 109 930 063 269 278 083 184 589
按百位分配(d=3):
桶0: 008, 063, 083
桶1: 109, 184
桶2: 269, 278
桶5: 505, 589
桶9: 930
收集:008 063 083 109 184 269 278 505 589 930 ✅
时间复杂度:( 是位数, 是元素个数, 是基数,如十进制 )
空间复杂度:( 个桶/队列)
稳定性:✅ 稳定
⚠️ 基数排序的适用条件:
- 关键字可以方便地拆分成 个"组",且 较小
- 每个"组"的取值范围 不大
- 关键字之间的比较困难但拆分容易(如字符串、日期)
- 当 很大、 很小、 较小时效率极高
不适用:关键字取值范围很大、无法拆分为有限位的浮点数等
三、计数排序
思想:统计每个元素出现的次数,然后直接确定每个元素的位置。
三步法:
- 统计:遍历数组,用辅助数组
C[k]记录值为 的元素个数 - 累加:,此时
C[k]表示 的元素个数 - 放置:逆序扫描原数组,将
A[i]放到B[C[A[i]]-1]位置,同时C[A[i]]--(逆序扫描保证稳定性)
// 计数排序(Java版)
void countingSort(int[] A, int n, int maxVal) {
int[] C = new int[maxVal + 1]; // 计数数组
int[] B = new int[n]; // 输出数组
// 第1步:统计每个值出现次数
for (int i = 0; i < n; i++) C[A[i]]++;
// 第2步:累加
for (int i = 1; i <= maxVal; i++) C[i] += C[i - 1];
// 第3步:逆序放置(保证稳定性)
for (int i = n - 1; i >= 0; i--) {
B[C[A[i]] - 1] = A[i];
C[A[i]]--;
}
System.arraycopy(B, 0, A, 0, n);
}
时间复杂度:( 是数据范围)
空间复杂度:(输出数组 + 计数数组)
稳定性:✅ 稳定
⚠️ 适用条件:数据范围 不能太大。当 时,不如基于比较的排序 。适合数据范围有限的非负整数排序。
8.6 内部排序算法总结与比较(⭐ 超级重要!)
各排序算法综合对比表
| 排序算法 | 最好时间 | 平均时间 | 最坏时间 | 空间 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入排序 | ✅ 稳定 | ||||
| 折半插入排序 | ✅ 稳定 | ||||
| 希尔排序 | — | — | ❌ 不稳定 | ||
| 冒泡排序 | ✅ 稳定 | ||||
| 快速排序 | ❌ 不稳定 | ||||
| 简单选择排序 | ❌ 不稳定 | ||||
| 堆排序 | ❌ 不稳定 | ||||
| 归并排序 | ✅ 稳定 | ||||
| 基数排序 | ✅ 稳定 | ||||
| 计数排序 | ✅ 稳定 |
记忆口诀
不稳定排序:"快希选堆"(快速排序、希尔排序、简单选择排序、堆排序)
🗣️ 记忆法:想象一个人"快些选堆"(快点儿选那堆东西),但他比较急躁所以不稳定。
重要结论
- 没有一种排序在任何情况下都最优
- 快速排序的平均性能最好(内部排序中)
- 基于比较的排序算法的时间复杂度下界为
💡 判定树下界定理(CLRS 定理 8.1)严格证明:
- 任何基于比较的排序算法都可以用一棵**判定树(Decision Tree)**表示:内部结点对应一次 的比较,叶结点对应排序结果(排列)
- 个元素有 种排列,故判定树至少有 个叶结点
- 高度为 的二叉树最多有 个叶结点,因此 ,即
- 由 Stirling 公式 ,得
- 因此最坏情况下比较次数的下界为
推论:归并排序和堆排序已达到此下界,是渐近最优的基于比较的排序算法。
突破下界:计数排序、基数排序、桶排序等非比较排序可以绕开判定树下界,达到 ,但需要对输入数据有额外约束(如数据范围有限)。
- 堆排序和归并排序在最坏情况下仍为 ,但常数因子不同
- 当序列基本有序时,直接插入排序最快 ;而此时快速排序最慢
- 当 较小时,直接插入排序或简单选择排序效率高
- 稳定排序:直接插入、冒泡、归并、基数、计数
- 每趟排序能确定一个元素最终位置的:选择排序、快速排序、冒泡排序、堆排序
- 排序趟数与初始序列有关的:冒泡排序(可提前终止)、快速排序(递归树结构依赖划分)
- 排序趟数与初始序列无关的:直接插入排序(始终 趟)、简单选择排序(始终 趟)、归并排序(始终 趟)、基数排序(始终 趟)
- 比较次数与初始序列无关的:简单选择排序(始终 次)、折半插入排序(仅比较次数无关)、归并排序、基数排序
- 关键字比较次数和移动次数都与初始序列无关的:基数排序、归并排序、简单选择排序(比较次数无关,移动次数有关)
- 不适用于链表存储的排序算法:希尔排序(需要按增量随机访问)、堆排序(需要按下标访问父/子结点)、折半插入排序(需要随机访问中间元素)。直接插入排序、冒泡排序、归并排序、基数排序均可用于链表。
- 排序算法选择决策:
- 较小():直接插入排序或简单选择排序
- 较大且要求稳定:归并排序
- 较大且不要求稳定:快速排序(平均最优)或堆排序(最坏稳定 )
- 基本有序的序列:直接插入排序(,避免快排退化)
- 数据范围有限的整数:计数排序或基数排序( 级别)
算法设计范式总结(⭐ 贯穿全书的思想框架)
💡 408 数据结构中的所有算法,都可以归入以下几大设计范式。掌握范式后,遇到新问题能快速确定解题方向。
| 设计范式 | 核心思想 | 数据结构中的典型应用 | 跨学科联动 |
|---|---|---|---|
| 分治法(Divide & Conquer) | 大问题分解为相同类型的子问题,递归求解后合并 | 归并排序、快速排序、折半查找、二叉树遍历、线段树 | — |
| 贪心法(Greedy) | 每步做局部最优选择,期望全局最优 | Prim、Kruskal、Dijkstra、哈夫曼编码、简单选择排序、拓扑排序(取入度为0的顶点) | OSPF路由(计网) |
| 动态规划(DP) | 重叠子问题 + 最优子结构 → 记录中间结果避免重复计算 | Floyd最短路径、Bellman-Ford、最优BST、最佳归并树(哈夫曼)、矩阵链乘法、KMP 中 next 数组的递推 | — |
| 回溯法(Backtracking) | DFS + 剪枝 | 图的 DFS 遍历、八皇后、迷宫求解 | — |
| 迭代/松弛 | 不断逼近最优解直到收敛 | Bellman-Ford 松弛迭代、并查集路径压缩、堆调整 | RIP 路由(计网) |
| 哈希/散列 | 由关键字直接计算存储地址 | 散列表、布隆过滤器 | Cache映射(计组)、页表(OS) |
| 空间换时间 | 额外存储加速查询 | 散列表、后缀数组/树、判定树、邻接矩阵 vs 邻接表 | Cache(计组)、TLB(OS+计组) |
⚠️ 考试中的"设计一个高效算法":
- 题目要求 → 考虑一趟扫描(双指针/三指针/打标记法/Boyer-Moore投票)
- 题目要求 → 考虑分治或排序后处理
- 题目要求 → 考虑折半查找/平衡树操作
- 题目有"最优/最小/最大" → 考虑贪心或DP
- 题目涉及"连通/可达/路径" → 考虑BFS/DFS/最短路径
根据排序结果判断排序算法(高频选择题技巧)
⚠️ 2010年真题考法:给出初始序列和前几趟排序结果,判断使用的排序方法。
判断技巧:
| 特征现象 | 对应排序算法 |
|---|---|
| 每趟结果有一个元素到达最终位置(末尾) | 冒泡排序(每趟将最大元素"冒泡"到末尾) |
| 每趟结果有一个元素到达最终位置(前端) | 简单选择排序(每趟选最小放到前端) |
| 前 趟后前 个元素有序 | 直接插入排序(前面有序区逐步扩大) |
| 长度为2的子段有序→长度为4→长度为8... | 归并排序(子段长度按2的幂增长) |
| 不相邻元素发生交换,整体部分有序 | 希尔排序(按增量分组排序) |
| 每趟枢轴元素到达最终位置,左小右大 | 快速排序 |
例(2010真题):对 (2, 12, 16, 88, 5, 10):
- 第1趟:2, 12, 16, 5, 10, 88 → 仅88移到末尾 ✓冒泡
- 验证排除:若希尔排序则 (12, 88, 10) 应变为 (10, 12, 88),不符;若归并则长度2子段应有序,不符。
- 答案:冒泡排序
折半插入排序 vs 直接插入排序的区别(2012年真题):
- 排序趟数相同(都是 )
- 移动次数相同(取决于初始序列)
- 比较次数不同!折半插入与序列初态无关 ,直接插入与序列初态有关
- 辅助空间都是
8.7 外部排序
一、外部排序的基本概念
问题:数据量太大,无法全部装入内存,需要借助外存(磁盘)进行排序。
基本方法:归并排序的思想
- 把文件分成若干段,每段读入内存排序(生成初始归并段/顺串)
- 对初始归并段进行多趟归并,直到整个文件有序
外部排序时间 = 读写外存时间(主要矛盾!)+ 内部排序时间 + 内部归并时间
🗣️ 大白话:外存(磁盘)读写速度比内存慢几个数量级,所以外部排序的核心优化目标就是减少磁盘I/O次数。
详细分析示例:设文件有2000个记录,内存工作区可容纳500个记录。
- 第一步:分4段,每段500个记录 → 生成4个初始归并段()
- 第二步:2路归并 → 归并趟数 趟
每个记录占1个磁盘块,初始4个归并段分布在两个磁盘上:
| 步骤 | 读磁盘块数 | 写磁盘块数 | 总I/O次数 |
|---|---|---|---|
| 生成初始归并段 | 2000 | 2000 | 4000 |
| 第1趟归并 | 2000 | 2000 | 4000 |
| 第2趟归并 | 2000 | 2000 | 4000 |
| 总计 | 12000 |
若改用4路归并: 趟,总I/O = 4000 + 4000 = 8000,显著减少!
性能瓶颈:磁盘I/O次数(读/写磁盘的时间远大于内存中排序的时间)
减少I/O次数的关键:减少归并趟数
其中 为归并路数, 为初始归并段个数。
提高效率的两个方向:
- 增大归并路数 → 多路归并(但 增大会导致每次选最小元素的比较次数增加 → 用败者树优化)
- 减少初始归并段个数 → 使初始归并段更长(用置换-选择排序实现)
二、多路归并与败者树
问题: 路归并时,每次从 个段中选出最小元素需要比较 次。增大 虽然减少趟数,但增加了每趟的比较次数,总比较次数可能不降反升。
败者树(Loser Tree):一种特殊的完全二叉树,可以将每次选最小值的比较次数从 降低到 。
败者树的结构:
- 个叶结点:存放 个归并段的当前元素
- 个内部结点:记录"比赛的失败者"(较大的元素)
- 额外根结点 :记录"冠军"(最小元素所在归并段编号)
工作原理:
- 初始化: 个叶结点两两比较,败者留在父结点,胜者继续向上比较
- 每次取出最小元素后,从该元素所在归并段读入下一个元素到对应叶结点
- 仅需沿该叶结点到根的路径重新比较,路径长度
🗣️ 大白话:败者树就像淘汰赛——每次比赛的失败者被记录在结点中,胜利者继续向上比赛。最终冠军就是最小元素。当冠军的归并段送来新选手时,只需沿着"往届对手"重新比赛即可确定新冠军。
败者树的关键性能:
- 构建败者树: 次比较
- 此后每选一个最小元素:仅需 次比较
- 个元素、 路归并的总比较次数:,与 无关的量级!
- 每趟归并比较次数:( 个元素选出 个最小值,第一个用 次建树,此后每次 次)
- 因此增大 不会增加总比较次数,只会减少归并趟数
⚠️ 注意: 也不能无限增大。 路归并需要 个输入缓冲区,受内存限制。实际中常取 。
三、置换-选择排序(生成更长的初始归并段)
问题:内存工作区大小为 ,传统方法生成的初始归并段长度也是 。
置换-选择排序可以生成比工作区更长的初始归并段(平均长度为 )。
算法步骤(设工作区WA容量为 ):
- 从文件读入 个记录到工作区WA
- 从WA中选出关键字最小的记录 MINIMAX,输出到当前归并段RUN
- 若文件不空,从文件读入下一个记录补入WA
- 从WA中所有关键字 MINIMAX 的记录中,选出最小者作为新的MINIMAX输出
- 重复3-4,直到WA中没有 MINIMAX 的记录
- 结束当前归并段,开启新的归并段,转步骤2
详细示例(,输入:{17, 21, 05, 44, 10, 12, 56, 32, 29}):
WA初始:{17, 21, 05},文件:44 10 12 56 32 29
RUN1:
选min{17,21,05}=05 → 输出05,读入44 → WA={17,21,44}
选min{17,21,44}=17 → 输出17,读入10 → WA={10,21,44}
选≥17的min{21,44}=21 → 输出21,读入12 → WA={10,12,44}
选≥21的min{44}=44 → 输出44,读入56 → WA={10,12,56}
选≥44的min{56}=56 → 输出56,读入32 → WA={10,12,32}
选≥56的:无 → RUN1结束 → 输出:{05,17,21,44,56}(长度5 > w=3!)
RUN2:
选min{10,12,32}=10 → 输出10,读入29 → WA={29,12,32}
选min{12,29,32}=12 → 输出12,文件空 → WA={29,32}
选≥12的min{29,32}=29 → 输出29 → WA={32}
选≥29的min{32}=32 → 输出32 → WA为空
→ RUN2结束 → 输出:{10,12,29,32}(长度4 > w=3!)
最终生成2个归并段(传统方法需要3个),减少了归并趟数!
四、最佳归并树
问题:初始归并段长度不同时,如何安排归并顺序使磁盘I/O次数最少?
解决方案:用哈夫曼树的思想构造最佳归并树——短的归并段放在树的低层(多归并几次),长的放在高层(少归并几次)。
其中 WPL 为带权路径长度,权值为各归并段的长度(记录数)。
对于 路归并,构造 叉哈夫曼树:
⚠️ 虚段补充规则(非常重要!):
对于 路归并,设有 个初始归并段(叶结点)。严格 叉树满足 ( 为度为 的结点数),即 。
若 ,则需补充 个长度为0的虚段(不增加I/O,仅占位),使得 。
虚段在哈夫曼树中一定处于最底层(权值为0,最先被选中合并)。
示例:3路归并,8个初始归并段(长度分别为 2, 3, 6, 7, 9, 12, 18, 24)
- ,需补充 个虚段(长度0)
- 9个段构造3叉哈夫曼树:每次选3个最小的合并
- 第1次:{0, 2, 3} → 合并为5
- 第2次:{5, 6, 7} → 合并为18'
- 第3次:{9, 12, 18} → 合并为39
- 第4次:{18', 24, 39} → 合并为81
💡 最佳归并树 = k叉哈夫曼树。它使得长度短的归并段被多次读写(在深层),长度长的归并段被少次读写(在浅层),从而总I/O次数最少。
📝 真题剖析
【2012年408真题】 在内部排序过程中,对尚未确定最终位置的所有元素进行一遍处理称为一"趟"。下列排序方法中,每一趟处理都能确保将一个尚未确定最终位置之的元素放到其最终位置上的是()。
Ⅰ. 简单选择排序 Ⅱ. 希尔排序 Ⅲ. 快速排序 Ⅳ. 堆排序 Ⅴ. 二路归并排序
A. 仅Ⅰ、Ⅲ、Ⅳ B. 仅Ⅰ、Ⅱ、Ⅲ C. 仅Ⅱ、Ⅲ、Ⅳ D. 仅Ⅲ、Ⅳ、Ⅴ
解析:
- 简单选择排序 ✅:每趟选出最小/最大的放到最终位置
- 希尔排序 ❌:每趟只是让序列"基本有序",不保证任何元素到最终位置
- 快速排序 ✅:每趟确定枢轴的最终位置
- 堆排序 ✅:每趟将堆顶(最大/最小值)放到最终位置
- 二路归并排序 ❌:直到最后一趟合并完成前,元素位置可能还会变化
答案选A。
【2012年408真题·综合题】 设有6个有序表 、、、、、(括号内为元素个数),通过5次两两合并最终合成1个升序表,求最坏情况下比较总次数最少的合并方案。
解题方法:哈夫曼树(最佳归并树)思想
两个长度为 、 的有序表合并,最坏情况下比较 次。为使总比较次数最少,每次选最短的两个表合并(贪心思想):
| 第几次合并 | 合并内容 | 新表长度 | 最坏比较次数 |
|---|---|---|---|
| 1 | 45 | ||
| 2 | 85 | ||
| 3 | 110 | ||
| 4 | 195 | ||
| 5 | 395 |
💡 推广:()个不等长升序表的合并策略——借用哈夫曼树构造思想,每次选最短的两个表合并,可获得最坏情况下的最佳合并效率。本题考点横跨了排序(归并)和树(哈夫曼)两大知识板块。
【2021年408应用题】 实现"比较计数排序"并使其稳定。
**比较计数排序(Comparison Counting Sort)**思想:对每个元素,统计序列中比它小的元素个数 ,则 就是该元素在有序序列中的最终下标。
// 比较计数排序(不稳定版)
void compCountSort(int[] A, int[] B, int n) {
int[] count = new int[n]; // count[i] = A中比A[i]小的元素个数
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if (A[i] < A[j]) count[j]++;
else count[i]++; // A[i] >= A[j]
for (int i = 0; i < n; i++)
B[count[i]] = A[i];
}
⚠️ 稳定性修复:上述代码中
A[i] >= A[j]时count[i]++,等值元素中先出现的被计数更多,导致先出现的反而排在后面 → 不稳定。修复方法:将
else count[i]++改为严格地只在A[i] > A[j]时count[i]++,并对A[i] == A[j]时按原始下标顺序决定谁的 count 更大:javaif (A[i] < A[j]) count[j]++; else if (A[i] > A[j]) count[i]++; // A[i] == A[j] 时:j > i,不操作 → count[j] 自然较大 → j 排在 i 后面 → 稳定
时间复杂度 ,空间复杂度 。
【2023年408应用题】 置换-选择排序:内存工作区大小 ,输入 个元素,生成初始归并段。
解题要点:
- 初始读入 个元素到工作区
- 每次从工作区中选 当前归并段已输出最大值 的最小元素输出
- 工作区中无满足条件的元素时,结束当前归并段,开始新的归并段
- 该题生成了 3 个初始归并段
💡 置换-选择排序的性质:
- 初始归并段平均长度 = ( 为工作区大小)
- 初始归并段最小长度 = (输入完全逆序时)
- 初始归并段最大长度 = (输入完全升序时,所有元素构成一个归并段)
【2024年408真题】 大根堆 ,连续删除两次堆顶后的结果?
堆删除步骤(大根堆):
- 将堆顶(最大值)与最后一个元素交换
- 堆的规模减1
- 从堆顶开始向下调整(siftDown):与较大的子结点交换,直到满足堆的性质
第1次删除堆顶28:
初始:{28, 22, 20, 19, 8, 12, 15, 5}
交换28和5:{5, 22, 20, 19, 8, 12, 15, | 28}
下调5:5<22→交换 → {22, 5, 20, 19, 8, 12, 15}
5<19→交换 → {22, 19, 20, 5, 8, 12, 15}
结果:{22, 19, 20, 5, 8, 12, 15}
第2次删除堆顶22:
交换22和15:{15, 19, 20, 5, 8, 12, | 22}
下调15:15<20→交换 → {20, 19, 15, 5, 8, 12}
15>12→停止
结果:{20, 19, 15, 5, 8, 12}
【2024年408真题】 败者树(Loser Tree)中,"冠军"结点 存储的是什么?
💡 存储的是最小关键字所在归并段的段号(不是关键字值本身!)。败者树中间结点存"失败者"(较大值的段号),而 存"冠军"(最小值的段号)。
【2018年408真题】 根据希尔排序某趟结果判断增量。
方法:比较排序前后的序列,找出哪些位置的元素发生了交换。如果间隔为 的位置发生了子序列排序,则该趟的增量就是 。
【2025年408应用题】 数组问题 CalMulMax:给定数组 ,求 的最大值。
三种解法对比:
| 方法 | 时间 | 空间 | 思路 |
|---|---|---|---|
| 暴力枚举 | 双重循环求每个 | ||
| 后缀数组 | 预处理 suffixMax/suffixMin 数组 | ||
| 后缀变量 | 仅用两个变量维护后缀 max/min |
最优解法(后缀变量, 时间 空间):
// CalMulMax:求 a[i] × max(a[i+1..n-1]) 的最大值
int calMulMax(int[] a, int n) {
int suffMax = a[n - 1]; // 后缀最大值
int suffMin = a[n - 1]; // 后缀最小值(处理负数×负数情况)
int ans = Integer.MIN_VALUE;
for (int i = n - 2; i >= 0; i--) {
// a[i] > 0 时乘 suffMax 最大;a[i] < 0 时乘 suffMin 可能更大
int cand1 = a[i] * suffMax;
int cand2 = a[i] * suffMin;
ans = Math.max(ans, Math.max(cand1, cand2));
suffMax = Math.max(suffMax, a[i]);
suffMin = Math.min(suffMin, a[i]);
}
return ans;
}
💡 关键技巧:从右往左扫描,同时维护后缀最大值和最小值。考虑负数乘负数为正的情况,因此需要同时维护 suffMax 和 suffMin。
【排序方法识别——根据中间结果反推排序算法】(高频选择题):
| 线索 | 对应排序算法 |
|---|---|
| 每趟确定一个元素的最终位置(最大/最小) | 简单选择排序 / 堆排序 |
| 第 趟后前 个元素有序 | 直接插入排序 |
| 第 趟后最小的 个元素已就位 | 简单选择排序 |
| 第 趟后最大的 个元素已就位 | 冒泡排序(大值上浮) |
| 枢轴元素在最终位置,左小右大 | 快速排序 |
| 间隔 的子序列分别有序 | 希尔排序(增量=d) |
| 子序列长度翻倍逐步有序 | 归并排序(子表从2到4到8...) |
| 按位/数位分配 | 基数排序 |
📌 第八章总结
| 核心知识点 | 考研要求 |
|---|---|
| 排序的稳定性概念 | 必须理解 |
| 直接插入排序 | 必须掌握 |
| 折半插入排序 | 了解与直接插入的区别 |
| 希尔排序 | 理解思想,考选择题 |
| 冒泡排序 | 必须掌握 |
| ⭐ 快速排序(Partition过程) | 重中之重,大题必考 |
| 简单选择排序 | 必须掌握 |
| ⭐ 堆排序(建堆、调整) | 重中之重,大题常考 |
| ⭐ 归并排序 | 必须掌握 |
| 基数排序 | 理解思想,考选择题 |
| ⭐ 内部排序大比较表 | 必须完全熟记 |
| 不稳定排序口诀"快希选堆" | 必须牢记 |
| 外部排序的基本概念 | 理解 |
| 败者树 | 理解其优化作用 |
| 置换-选择排序 | 理解思想 |
| 最佳归并树(哈夫曼思想) | 需要掌握 |