第 8 章 排序
本章地位:排序是 408 数据结构的收官重镇,几乎每年必考 2 道以上选择题,且「给一个序列手工模拟排序过程」(快排每趟、建堆与堆调整、希尔各趟、归并趟数)是常考大题题型。本章全章统一用初始序列 \(\{49,38,65,97,76,13,27,49\}\)(记号 49₂ 表示第二个 49),每种算法都「一趟一趟」列出中间结果并与最终答案核对;学完后必须能默写九种排序对比总表(8.7 节),这是 408 最著名的必背表。
8.1 排序的基本概念
8.1.1 稳定性:相等元素的相对次序
稳定性是算法的内在性质:「能找到某个序列被排乱」才算不稳定,「某次没排乱」不能证明稳定。对于简单类型整数,稳定与否看不出差别,但对多重关键字排序(先按总分排、总分相同按数学排)意义重大。
对序列 \(\{3,\,2_a,\,2_b,\,1\}\)(\(2_a\)、\(2_b\) 关键字相等且 \(2_a\) 在前)分别用直接插入、冒泡、简单选择、快速排序,哪些方法一定保持两个 2 的相对次序?
查看解答
直接插入、冒泡的比较在遇到相等关键字时即停止后移 / 不交换,一定保持 \(2_a\) 在 \(2_b\) 前;简单选择不能保持:第一趟选出最小值 1(位置 4)与位置 1 的 3 交换得 \(\{1,2_a,2_b,3\}\)——此例恰好没乱,但换 \(\{2_a,2_b,1\}\):第一趟 1 与 \(2_a\) 交换得 \(\{1,2_b,2_a\}\),相对序被破坏,故不稳定;快速排序同样不稳定:对 \(\{3_a,3_b,2\}\)(以首元素 3 为枢轴做一趟划分),右端的 2 填入左端空坑后,最终得 \(\{2,3_b,3_a\}\),\(3_b\) 反超 \(3_a\)。
结论口诀(8.7 总表):「插冒归基」稳定(直接插入、冒泡、归并、基数,外加折半插入),「希选快堆」不稳定(希尔、选择、快排、堆)。
8.1.2 内部排序与外部排序
② 外部排序:数据量太大,内存无法一次装下,排序过程需要在内、外存之间交换数据(8.5.3 节)。
内部排序算法的两种基本操作:比较两个关键字的大小、移动记录。
判断正误:(1) 稳定的排序方法一定优于不稳定的方法;(2) 排序的稳定性是指排序后关键字不再改变;(3) 外部排序是指排序过程需要访问外存的排序。
查看答案
(1) 错:稳定性只保证相等元素相对次序不变,与时间 / 空间效率无关(快排不稳定却是平均最快的内排序之一);
(2) 错:稳定性指相等关键字记录的相对次序在排序前后是否一致;
(3) 对:这正是外部排序的定义(数据量超出内存容量)。
8.2 插入排序
8.2.1 直接插入排序
// 直接插入排序(a[1..n] 存记录,a[0] 留空;从后往前比较并后移,边找边腾位置)
void InsertSort(int a[], int n) {
for (int i = 2; i <= n; i++) { // 依次将 a[i] 插入前面的有序区
if (a[i] < a[i-1]) { // 已经有序就不动,正序时由此得 O(n)
a[0] = a[i]; // a[0] 作哨兵,存待插元素
int j = i - 1;
for (; a[j] > a[0]; j--) // 从后向前:比它大的都后移
a[j+1] = a[j];
a[j+1] = a[0]; // 腾出的空位放入待插元素
}
}
}
写出对 \(\{49,38,65,97,76,13,27,49_2\}\) 直接插入排序的每一趟结果(下划线区为有序区,加粗为该趟新插入元素)。
查看解答
共 7 趟(\(n-1=7\)):
初始 (49) 38 65 97 76 13 27 49₂
第1趟 38 49 65 97 76 13 27 49₂ 插 38,比较1次
第2趟 38 49 65 97 76 13 27 49₂ 插 65,比较1次
第3趟 38 49 65 97 76 13 27 49₂ 插 97,比较1次
第4趟 38 49 65 76 97 13 27 49₂ 插 76,97 后移
第5趟 13 38 49 65 76 97 27 49₂ 插 13,前面全部后移
第6趟 13 27 38 49 65 76 97 49₂ 插 27
第7趟 13 27 38 49 49₂ 65 76 97 插 49₂(等于 49 时停止后移)✓
核对:最终序列 \(13,27,38,49,49_2,65,76,97\) 全局非递减 ✓,且 \(49_2\) 仍在 49 之后(该例未破坏稳定性,与「直接插入稳定」一致)。
8.2.2 折半插入排序
// 折半插入排序:先用二分找到插入点,再统一后移
void BinInsertSort(int a[], int n) {
for (int i = 2; i <= n; i++) {
a[0] = a[i];
int low = 1, high = i - 1;
while (low <= high) { // 折半找插入位置(保持在右半段)
int mid = (low + high) / 2;
if (a[mid] > a[0]) high = mid - 1;
else low = mid + 1;
}
for (int j = i - 1; j >= high + 1; j--) // 统一后移
a[j+1] = a[j];
a[high+1] = a[0]; // 插入点为 high+1
}
}
对 \(n=8\) 的序列做折半插入排序:(1) 第 \(i\) 趟最多比较多少次?(2) 总比较次数是多少?若直接插入正序输入,比较次数又是多少?
查看解答
(1) 第 \(i\) 趟在长度 \(i-1\) 的有序区中二分查找,最多比较 \(\lceil\log_2(i+1)\rceil\) 次(成功查找判定树深度)。
(2) 总计 \(\sum_{i=2}^{8}\lceil\log_2(i+1)\rceil=2+2+3+3+3+3+4=20\) 次,与输入是否有序无关;而直接插入在正序输入下只需 \(n-1=7\) 次比较——所以「输入基本有序」时直接插入反而更快,折半插入的优势在「随机 / 逆序」输入(省比较,移动照旧 \(O(n^{2})\))。
8.2.3 希尔排序(缩小增量排序)
// 希尔排序:增量序列 dk 递减且最后一趟 dk = 1
void ShellSort(int a[], int n) {
for (int dk = n / 2; dk >= 1; dk = dk / 2) { // 例:n=8 → 4,2,1
for (int i = dk + 1; i <= n; i++) { // 各子序列轮流插入
if (a[i] < a[i-dk]) {
a[0] = a[i];
int j = i - dk;
for (; j > 0 && a[j] > a[0]; j -= dk)
a[j+dk] = a[j]; // 组内后移步长为 dk
a[j+dk] = a[0];
}
}
}
}
对 \(\{49,38,65,97,76,13,27,49_2\}\) 按增量序列 \(\{5,3,1\}\) 做希尔排序,写出每一趟的结果。
查看解答
第一趟 dk=5,子序列为按位置相差 5 的分组:\((49,13)\)、\((38,27)\)、\((65,49_2)\)、\((97)\)、\((76)\),各组内插入排序:
dk=5: 13 27 49₂ 97 76 49 38 65 ← (49↔13)(38↔27)(65↔49₂) 各组交换
第二趟 dk=3,分组:位置 \((1,4,7)\)=\(\{13,97,38\}\)、\((2,5,8)\)=\(\{27,76,65\}\)、\((3,6)\)=\(\{49_2,49\}\),组内插入:
dk=3: 13 27 49₂ 38 65 49 97 76 ← 97,38 交换;76,65 交换
第三趟 dk=1,就是一趟直接插入(此时序列已基本有序,只有少量元素需移动:38、49、76 各插一次):
dk=1: 13 27 38 49 49₂ 65 76 97 ✓
核对:三趟后全局有序 ✓。注意 dk=3 趟后 \(49_2\)(位置 3)仍在 49(位置 6)之前,但最终结果 \(49_2\) 排在 49 之后——相等元素的相对次序被改变,希尔排序不稳定的直接证据。
(1) 折半插入排序的比较次数为什么与初始序列无关?它的移动次数呢?(2) 希尔排序的增量序列必须满足什么条件?为什么希尔排序不能像直接插入那样方便地用于链表?
查看答案
(1) 每趟的比较次数只由有序区长度决定(二分判定树形态固定),与关键字排列无关;移动次数仍与初始序列有关(后移量不变,最坏 \(O(n^{2})\))——「省了比较、省不了移动」。
(2) 最后一趟增量必须是 1(保证全局有序),且增量序列最好互质、递减到 1;链表不支持按序号随机定位,取第 i−dk 个结点要 O(n) 扫描,无法体现「跳跃式移动」的优势,故仅适用于顺序存储。
8.3 交换排序
8.3.1 冒泡排序
// 冒泡排序(带交换标志 flag,正序输入一趟即结束 → 最好 O(n))
void BubbleSort(int a[], int n) {
for (int i = 1; i < n; i++) { // 最多 n-1 趟
bool flag = false; // 本趟是否发生过交换
for (int j = 1; j <= n - i; j++) // 无序区:一趟沉底一个最大值
if (a[j] > a[j+1]) {
int t = a[j]; a[j] = a[j+1]; a[j+1] = t;
flag = true;
}
if (!flag) return; // 整趟无交换 → 已有序,提前结束
}
}
写出对 \(\{49,38,65,97,76,13,27,49_2\}\) 冒泡排序的每一趟结果(竖线后为有序区)。
查看解答
初始 49 38 65 97 76 13 27 49₂
第1趟 38 49 65 76 13 27 49₂ | 97 97 沉底(交换 5 次)
第2趟 38 49 65 13 27 49₂ | 76 97
第3趟 38 49 13 27 49₂ | 65 76 97
第4趟 38 13 27 49₂ | 49 65 76 97 49₂ 与 49 相等不交换(稳定的体现)
第5趟 13 27 38 | 49 49₂ 65 76 97
第6趟 全趟无交换 → 提前结束 ✓
性能:最好 \(O(n)\)(正序,一趟检出),最坏 / 平均 \(O(n^{2})\)(逆序比较 \(\frac{n(n-1)}{2}\) 次);移动均为元素交换(3 次赋值 / 次);空间 \(O(1)\);稳定(相邻相等不交换)。
8.3.2 快速排序 高频考点
// 快速排序:Partition 用「双端交替填坑」实现,枢轴取区间首元素
int Partition(int a[], int low, int high) {
int pivot = a[low]; // 挖出枢轴,low 位置成为「坑」
while (low < high) {
while (low < high && a[high] >= pivot) high--; // 右端找小
a[low] = a[high]; // 小者填左坑,high 位置成新坑
while (low < high && a[low] <= pivot) low++; // 左端找大
a[high] = a[low]; // 大者填右坑,low 位置成新坑
}
a[low] = pivot; // 两指针相遇处即枢轴最终位
return low; // 返回枢轴最终位置
}
void QuickSort(int a[], int low, int high) {
if (low < high) {
int p = Partition(a, low, high); // 一趟划分,枢轴落位
QuickSort(a, low, p - 1); // 递归排左半
QuickSort(a, p + 1, high); // 递归排右半
}
}
对 \(\{49,38,65,97,76,13,27,49_2\}\) 快速排序(枢轴取子区间首元素),写出每一趟划分后的结果与每趟确定的元素位置,并画出递归树。
查看解答
第 1 趟对全区间划分(过程见图 8-1):枢轴 49 挖出 → 27 填左坑 → 65 填右坑 → 13 填左坑 → 97 填右坑 → 相遇于位置 4 → 49 入位:
第1趟 27 38 13 [49] 76 97 65 49₂ 枢轴 49 → 最终位置 4
第2趟 13 [27] 38 49 76 97 65 49₂ 左段枢轴 27 → 位置 2
第3趟 13 27 38 49 49₂ 65 [76] 97 右段枢轴 76 → 位置 7
第4趟 13 27 38 49 [49₂] 65 76 97 段 {49₂,65} 枢轴 49₂ → 位置 5(本趟无移动)✓
递归树(每个结点是一次划分的区间):
[1..8]枢轴49
├─[1..3]枢轴27 └─[5..8]枢轴76
│ ├─[1..1]13 ├─[5..6]枢轴49₂ └─[8..8]97
│ └─[3..3]38 ├─[5..5] └─[7..7]65(单元素区间不再划分)
核对:最终 \(13,27,38,49,49_2,65,76,97\) 有序 ✓。注意 \(49_2\) 最终排到 49 之后而初始在其前——快排不稳定的证据。
下列序列中,可能是某个初始序列经快速排序一趟划分后的结果的是( )
A. 41 24 30 12 58 63 90 5 B. 5 12 24 30 41 58 63 90
C. 24 5 12 41 30 90 63 58 D. 90 63 58 41 30 24 12 5
查看解答
技巧:快排一趟后必存在一个「枢轴位」\(i\):\(a[i]\) 左边全部 ≤ 它、右边全部 ≥ 它。逐项检验每个位置是否可能是枢轴位:
A:逐个排除——41 右侧有 24 ✗;58 右侧有 5 ✗;63 右侧有 5 ✗;90 右侧有 5 ✗;24/30/12 左侧有 41、5 左侧有 41 ✗。无候选,不可能。
B:末位 90 是最大值,左边全部 ≤ 90 ✓——可作为枢轴位(例如初始序列 \(\{90,12,24,30,41,58,63,5\}\) 一趟划分结果恰为 B),可能。
C:41 右侧有 30 ✗;30 左侧有 41 ✗;90 右侧有 63、58 ✗;其余位置左侧均有更大值 ✗。不可能。
D:逆序,任何位置左侧含 90、右侧含 5,两头都不满足 ✗(顺带记忆:正序 / 逆序正是快排最坏输入)。不可能。
故选 B。
(1) 对 \(n=8\) 的正序序列做快速排序(枢轴取首元素),共比较多少次?递归栈最深多少层?(2) 为什么「枢轴随机选取」或「三者取中」能避免最坏情况?
查看答案
(1) 每趟划分枢轴是最小值,右部缩 1、左部为空,共 \(n-1=7\) 趟,比较 \(7+6+\cdots+1=\frac{8\times7}{2}=28=\frac{n(n-1)}{2}\) 次;递归「先排空段、再排 \(n-1\) 段」,栈深最深 \(n=8\) 层(每层只有一次有效划分挂起)。
(2) 最坏情况的根源是枢轴总取到当前区间的最值。随机选取或「首、中、尾三元素取中值」作枢轴,使划分趋于平衡,递归树深度稳定在 \(O(\log_2 n)\),期望时间 \(O(n\log_2 n)\)。
8.4 选择排序
8.4.1 简单选择排序
// 简单选择排序:每趟在无序区选最小,与无序区首元素交换
void SelectSort(int a[], int n) {
for (int i = 1; i < n; i++) { // n-1 趟
int min = i;
for (int j = i + 1; j <= n; j++) // 无序区扫描找最小
if (a[j] < a[min]) min = j;
if (min != i) {
int t = a[i]; a[i] = a[min]; a[min] = t;
}
}
}
(1) 对任意 \(n=8\) 的序列,简单选择排序共比较多少次?(2) 最好、最坏情况下各移动多少次?
查看解答
(1) 第 \(i\) 趟在长度 \(n-i+1\) 的无序区比较 \(n-i\) 次,总计固定为 \[ \sum_{i=1}^{n-1}(n-i)=\frac{n(n-1)}{2}=\frac{8\times7}{2}=28\ \text{次} \] ——与初始序列无关(正序、逆序都比 28 次),这一点常被直接考查。
(2) 每趟至多 1 次交换(3 次移动):最好(正序)移动 0 次,最坏移动 \(3(n-1)\) 次。时间恒为 \(O(n^{2})\),空间 \(O(1)\),不稳定(跨越式交换可破坏相等元素相对序,反例见例 1 的 \(\{2_a,2_b,1\}\))。对 \(\{49,38,65,97,76,13,27,49_2\}\):第 1 趟 13↔49、第 2 趟 27↔38、……第 7 趟得 \(13,27,38,49,49_2,65,76,97\) ✓。
8.4.2 堆排序 高频考点
// 堆排序(1..n 存放):SiftDown 把以 k 为根的子树调整为大顶堆
void SiftDown(int a[], int k, int n) { // n 为当前堆的规模
a[0] = a[k]; // 暂存根
for (int i = 2*k; i <= n; i *= 2) { // 沿较大孩子下坠
if (i < n && a[i] < a[i+1]) i++; // 选两个孩子中较大者
if (a[0] >= a[i]) break; // 根不小于大孩子:到位
a[k] = a[i]; k = i; // 大孩子上浮,根下移一层
}
a[k] = a[0]; // 放入最终位置
}
void HeapSort(int a[], int n) {
for (int i = n / 2; i >= 1; i--) // 建堆:自底向上逐结点筛选
SiftDown(a, i, n);
for (int m = n; m > 1; m--) { // n-1 趟:堆顶与堆尾交换
int t = a[1]; a[1] = a[m]; a[m] = t;
SiftDown(a, 1, m - 1); // 堆规模减 1,重新筛堆顶
}
}
对 \(\{49,38,65,97,76,13,27,49_2\}\):(1) 建立大顶堆,写出每个结点筛选后的序列;(2) 给出前 3 趟堆排序结果。
查看解答
(1) 从 \(i=\lfloor 8/2\rfloor=4\) 到 1 依次筛选(对照图 8-2):
筛选结点4(97):孩子 49₂,97 ≥ 49₂ → 不动
筛选结点3(65):孩子 13、27,均小于 65 → 不动
筛选结点2(38):大孩子 97 > 38 → 交换;继续:孩子 49₂ > 38 → 交换
49 97 65 49₂ 76 13 27 38
筛选结点1(49):大孩子 97 > 49 → 交换;大孩子 76 > 49 → 交换
建堆完成: 97 76 65 49₂ 49 13 27 38 ✓(每个结点 ≥ 孩子)
(2) 每趟「堆顶 ↔ 无序区末尾交换,再筛堆顶」:
第1趟 76 49₂ 65 38 49 13 27 | 97 (97 到位)
第2趟 65 49₂ 27 38 49 13 | 76 97 (76 到位)
第3趟 49 49₂ 27 38 13 | 65 76 97 (65 到位)
……继续 5 趟后:13 27 38 49 49₂ 65 76 97 ✓
要点:建堆时间 \(O(n)\);每趟筛选 \(O(\log_2 n)\),共 \(n-1\) 趟 → 堆排序最好 / 平均 / 最坏均为 \(O(n\log_2 n)\),空间 \(O(1)\),不稳定(跨越式交换,49₂ 与 49 相对序已变)。
大顶堆 \(H=(91,53,72,44,24,65)\):(1) 插入 60 后调整;(2) 接着删除堆顶元素,写出结果并统计比较次数。
查看解答
(1) 插入:新元素放在堆尾(位置 7),再向上与双亲比较(「上浮」):60 的双亲是 53 → 60 > 53 交换;再与 91 比较 → 60 < 91 停止。结果 \[ (91,\,60,\,72,\,44,\,24,\,65,\,53) \] 2 次比较,时间 \(O(\log_2 n)\)。
(2) 删除:堆顶 91 出堆,堆尾元素 53 补到堆顶,再向下筛选:孩子 60、72 中取大者 72 → 53 < 72 交换;孩子 65 → 53 < 65 交换;无孩子停止。结果 \[ (72,\,60,\,65,\,44,\,24,\,53) \] 共 3 次比较(2 次选大孩子之外的比较:根层 2 次 + 结点 3 层 1 次),时间 \(O(\log_2 n)\)。
结论:插入上浮、删除下沉,两者都只沿一条根—叶路径操作,故都是 \(O(\log_2 n)\)——这是选择题高频答案。
(1) 堆排序求升序序列为什么要用大顶堆?(2) 堆仅剩一个元素时,还需比较、移动吗?(3) 树形选择排序为什么 408 不展开?
查看答案
(1) 堆顶是全局最大值,算法动作是「堆顶 ↔ 无序区末尾交换」:大顶堆每趟把当前最大值送到序列末端,从右往左产出升序;若用小顶堆,最小值被换到末端,只能得到降序(或需从头部取走,与顺序存储的交换式实现不符)。
(2) 不需要:仅剩一个元素时排序已结束,比较 0 次、移动 0 次(堆排序共 \(n-1\) 趟交换 / 筛选,最后一趟只剩 2 个元素时也仅需 1 次比较)。
(3) 树形选择排序(锦标赛排序)用胜者树每选出最小需 \(\lceil\log_2 n\rceil\) 次比较,时间 \(O(n\log_2 n)\) 但需要 \(O(n)\) 辅助空间且实现复杂,堆排序以同样的阶做到空间 \(O(1)\),故教材以堆排序为选择类的代表,树形选择了解思想即可。
8.5 归并、基数与外部排序
8.5.1 2 路归并排序
// 一趟归并:把相邻长度为 len 的有序段两两合并(辅助数组 B 空间 O(n) 的来源)
void Merge(int a[], int b[], int low, int mid, int high) {
int i = low, j = mid + 1, k = low;
while (i <= mid && j <= high) // 两段都未取尽:取小者
b[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
while (i <= mid) b[k++] = a[i++]; // 收尾:抄剩余段
while (j <= high) b[k++] = a[j++];
for (k = low; k <= high; k++) a[k] = b[k];
}
void MergeSort(int a[], int b[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2; // 从中间划分
MergeSort(a, b, low, mid); // 递归排左半
MergeSort(a, b, mid + 1, high); // 递归排右半
Merge(a, b, low, mid, high); // 合并两个有序段
}
}
对 \(\{49,38,65,97,76,13,27,49_2\}\) 做 2 路归并排序,写出每趟结果并验证趟数公式。
查看解答
初始 (49)(38)(65)(97)(76)(13)(27)(49₂) 8 个长度 1 的段
第1趟 (38 49)(65 97)(13 76)(27 49₂) 段长 1→2
第2趟 (38 49 65 97)(13 27 49₂ 76) 段长 2→4
第3趟 (13 27 38 49 49₂ 65 76 97) 段长 4→8 ✓
每趟段长翻倍,从 1 到 \(n\) 需要 \(\lceil\log_2 n\rceil\) 趟:\(n=8\to\lceil\log_2 8\rceil=3\) 趟 ✓;若 \(n=100\),则需 \(\lceil\log_2 100\rceil=7\) 趟。
性能:每趟全体元素恰被扫描一次 \(O(n)\),共 \(O(\log_2 n)\) 趟 → 最好 / 平均 / 最坏均为 \(O(n\log_2 n)\);空间 \(O(n)\)(辅助数组 B);稳定(Merge 中 \(a[i]\le a[j]\) 时优先取左段,相等不越过);归并是外部排序的基础(8.5.3)。注意:归并比较次数与初始序列有关(介于 \(\lceil n/2\rceil\log_2 n\) 与 \((n-1)\log_2 n\) 量级之间)。
8.5.2 基数排序
对 \(\{49,38,65,97,76,13,27,49_2\}\)(两位十进制数,\(d=2\),\(r=10\))做 LSD 基数排序。
查看解答
第 1 趟按个位分配(队列内保持到达顺序):Q₃←13;Q₅←65;Q₆←76;Q₇←97,27;Q₈←38;Q₉←49,49₂。收集(Q₀→Q₉ 依次): \[ 13,\ 65,\ 76,\ 97,\ 27,\ 38,\ 49,\ 49_2 \]
第 2 趟按十位分配:Q₁←13;Q₂←27;Q₃←38;Q₄←49,49₂;Q₆←65;Q₇←76;Q₉←97。收集: \[ 13,\ 27,\ 38,\ 49,\ 49_2,\ 65,\ 76,\ 97\quad ✓ \] (\(49_2\) 与 49 同入 Q₄ 且保持先来在前——队列先进先出保证稳定。)
性能:每趟分配 \(n\) 次、收集 \(n+r\) 次(\(r\) 个队列依次接起来),共 \(d\) 趟:时间 \(O(d(n+r))\),与 \(n\log_2 n\) 无关;空间 \(O(r)\)(\(r\) 个队列);适合位数少(\(d\) 小)而取值范围大(\(r^{d}\) 大)的关键字,如整数不比较却胜过比较类排序的典型场景;若关键字位数多(\(d\) 大)则反而不划算。
8.5.3 外部排序概念
② 置换-选择:利用「最小者出、新元素进」的工作区滚动,生成的初始归并段平均长度约为工作区大小的 2 倍——想生成更长的初始归并段时用它;
③ 最佳归并树:各段长度不等时按哈夫曼思想「短段先归并」安排归并顺序,使总 I/O(带权路径长度)最小(段数补 \((k-1)\) 的倍数个虚段)——段长不等的多趟归并时用它。
(1) 80 个等长初始归并段,4 路归并需几趟?(2) 一句话说明败者树、置换-选择、最佳归并树各自的适用时机;(3) 基数排序为什么适合「位数少、取值范围大」的关键字?
查看答案
(1) \(\lceil\log_4 80\rceil=\lceil\log_2 80/\log_2 4\rceil=\lceil 3.16\rceil=4\) 趟(80→20→5→2→1)。
(2) 败者树:k 路归并 k 较大时,减少每选一元素的比较次数(\(O(\log_2 k)\));置换-选择:生成初始归并段阶段,让平均段长翻倍以减少段数;最佳归并树:各初始段长度不等、归并趟数 ≥2 时,安排「短段先并」使总读写次数最少。
(3) 时间 \(O(d(n+r))\) 只随位数 \(d\) 线性增长:比较类排序至少 \(O(n\log_2 n)\),当 \(d\lt\log_2 n\) 时基数更快,而值域大小只影响队列数 \(r\),不引入 \(\log_2 n\) 因子。
8.5.4 专题:一趟之后能确定什么 高频选择题
「一趟后不能保证任何元素在最终位置」的算法:直接插入 / 折半插入(只保证前缀子序列局部有序,后面更小的元素还可能插到前面)、希尔(只保证各子序列分别有序,整体未必)、2 路归并(只保证段内有序,段与段还会重排)、基数(一趟后仅「按当前位」有序)。
另两个常考变形:① 快排一趟后还能保证「枢轴左边 ≤ 枢轴 ≤ 右边」;② 插入 / 希尔 / 归并「每趟产生局部有序区」,与「最终位置」严格区分。
下列排序中,一趟结束后能保证某个元素放在其最终位置上的是( )
A. 直接插入排序 B. 快速排序 C. 2 路归并排序 D. 希尔排序
查看解答
B。快排每趟划分后枢轴左边全部 ≤ 它、右边全部 ≥ 它,枢轴此后不再移动,处于最终位置。A 只形成局部有序前缀(例 2 第 4 趟的 76 后被 13、27 顶到后面);C 只把段长翻倍(例 11 第 1 趟的 38、49 之后还会被拆开重排);D 只保证各增量子序列有序(例 4 第一趟后 13 在位置 1,看似就位纯属巧合,不保证)。
同类真题常把「冒泡 / 简单选择 / 堆 / 快排」打包为正确选项组合,务必对应 warn-box 默写。
8.6 章末自测 真题风格
限时 60 分钟,先做后看答案。难度:★★ 基础 / ★★★ 强化 / ★★★★ 冲刺。所有过程模拟题先在草稿纸上独立完成再核对中间趟结果。
下列排序算法中,不稳定的有( )
① 直接插入 ② 希尔 ③ 冒泡 ④ 简单选择 ⑤ 快速排序 ⑥ 堆排序 ⑦ 2 路归并 ⑧ 基数
A. ①③⑤⑦ B. ②④⑥⑧ C. ②④⑤⑥ D. ①④⑤⑥
查看答案
C。口诀「希选快堆」不稳定(②④⑤⑥);直接插入、冒泡、归并、基数(以及折半插入)稳定。基数排序按队列分配收集,天然保持相对次序。
快速排序在最好情况下的时间复杂度与空间复杂度分别是( )
A. \(O(n\log_2 n)\),\(O(\log_2 n)\) B. \(O(n\log_2 n)\),\(O(n)\) C. \(O(n^{2})\),\(O(1)\) D. \(O(n\log_2 n)\),\(O(1)\)
查看答案
A。划分平衡时递归树深 \(\log_2 n\)、每层 \(O(n)\);空间是递归栈深度 = 树深 \(\log_2 n\)。注意最坏(初始正序 / 逆序)时间为 \(O(n^{2})\)、栈深 \(O(n)\);快排没有 \(O(1)\) 空间的实现。
对同一初始序列(\(n\gt3\)、含相等元素)分别排序,一趟后不能保证任何元素到达最终位置的是( )
A. 冒泡排序 B. 简单选择排序 C. 快速排序 D. 直接插入排序
查看答案
D。冒泡一趟沉底一个最大值、简单选择一趟就位一个最小值、快排一趟枢轴落位;直接插入一趟只形成「局部有序前缀」,后面更小的元素仍会插到前面,前缀里除最大者外均可能在后续趟移动。
排序过程中比较次数与初始序列无关的算法是( )
A. 快速排序 B. 简单选择排序 C. 直接插入排序 D. 2 路归并排序
查看答案
B。简单选择每趟无序区全扫描,固定 \(\frac{n(n-1)}{2}\) 次比较。折半插入的比较次数同样与初态无关(\(O(n\log_2 n)\),但其移动量仍随初态变化);快排、直接插入、归并的比较次数均与初态有关。
对 \(n=10\) 的序列做简单选择排序与冒泡排序(最坏情形),比较次数分别是( )
A. 45,45 B. 45,90 C. 90,45 D. 45,54
查看答案
A。两者最坏(逆序)都做 \(\frac{n(n-1)}{2}=\frac{10\times9}{2}=45\) 次比较;区别在移动:简单选择每趟至多 1 次交换(共 ≤27 次移动),冒泡最坏 \(\frac{n(n-1)}{2}\) 次交换(远多于前者)。
关于希尔排序,正确的说法是( )
A. 增量序列必须递增 B. 最后一趟增量必须为 1 C. 适合链式存储 D. 是稳定算法
查看答案
B。增量序列递减且最后一趟必须是 1(否则不能保证全局有序);链表无法 O(1) 定位「相差 dk」的元素,只适用于顺序存储;分组跳跃交换使相等元素相对序可被破坏,不稳定(例 4 已演示)。
对 \(n=100\) 的序列做 2 路归并排序,共需归并 \(\underline{\hspace{1cm}}\) 趟;每趟需要的辅助空间数量级为 \(\underline{\hspace{1cm}}\)。
查看答案
趟数 \(\lceil\log_2 100\rceil=7\)(段长 1→2→4→8→16→32→64→128 翻 7 倍过 100);每趟归并需与 \(n\) 同阶的辅助数组,\(O(n)\)(整个算法只需一个可复用的 \(O(n)\) 数组)。
要在 \(10^{7}\) 个数中找出最大的 10 个数,且内存只够放 10 个数,应选择的算法是:先读入前 10 个数建立 \(\underline{\hspace{1cm}}\) 堆,此后每个数与堆顶比较、更大则替换并调整,时间复杂度 \(\underline{\hspace{1cm}}\)。
查看答案
小顶堆(10 个最小者中最大的是堆顶,充当「Top-10 门槛」);每个数 O(1) 比较 + 至多 \(O(\log_2 10)\) 调整,总计 \(O(n\log_2 10)\approx O(n)\)。这是堆排序思想在「海量数据 Top-k」上的经典应用——若建大顶堆则门槛是第 1 大,无法淘汰较小的候选。
判断序列 \(\{95,73,82,46,58,67,24\}\) 是否为大顶堆;若是,写出堆排序第一趟结束后的序列。
查看解答
按完全二叉树检验每个结点 ≥ 孩子:95≥73,82;73≥46,58;82≥67,24 ✓——是大顶堆(判定技巧:自上而下每个双亲都不小于孩子即可,无需建堆过程)。
第一趟:堆顶 95 与末尾 24 交换 → \(\{24,73,82,46,58,67\},\underline{95}\);对 24 下沉:大孩子 82 > 24 交换;孩子 67 > 24 交换。结果: \[ 82,\ 73,\ 67,\ 46,\ 58,\ 24\ \big|\ \underline{95} \] 核对:95 到达最终位置 7,前 6 个仍成大顶堆 ✓。
对序列 \(\{23,45,12,78,56,34,89,11\}\) 做快速排序(枢轴取首元素),写出第一趟划分后的结果并指出枢轴的最终位置。
查看解答
枢轴 23 挖出(坑在位置 1):high 找小——11 < 23 填入位置 1,坑移到位置 8;low 找大——12≤23 越过、45 > 23 填入位置 8,坑移到位置 3;high 继续找小——34、56、78、89 均 ≥ 23,11 已用 → 与 low 相遇于位置 3;23 入坑。
\[ 11,\ 12,\ \underline{23},\ 78,\ 56,\ 34,\ 89,\ 45 \]核对:位置 3 左边 \(\{11,12\}\le23\)、右边 \(\{78,56,34,89,45\}\ge23\) ✓,枢轴 23 最终位置为 3;此后递归处理 \([1..2]\) 与 \([4..8]\)。
8.7 本章考点总结
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定 | 一趟后是否产生有序区 / 全局效果 |
|---|---|---|---|---|---|---|
| 直接插入 | \(O(n)\)(正序) | \(O(n^{2})\) | \(O(n^{2})\) | \(O(1)\) | 稳定 | 前缀局部有序,无元素保证到位 |
| 折半插入 | \(O(n\log_2 n)\) | \(O(n^{2})\) | \(O(n^{2})\) | \(O(1)\) | 稳定 | 比较 \(O(n\log_2 n)\) 与初态无关,移动仍 \(O(n^{2})\) |
| 希尔 | —(依赖增量) | 约 \(O(n^{1.3})\) | \(O(n^{2})\) | \(O(1)\) | 不稳定 | 各子序列分别有序,仅顺序存储 |
| 冒泡 | \(O(n)\)(正序) | \(O(n^{2})\) | \(O(n^{2})\) | \(O(1)\) | 稳定 | 每趟一个最大值沉底到位 |
| 快速 | \(O(n\log_2 n)\) | \(O(n\log_2 n)\)(平均最快) | \(O(n^{2})\)(正 / 逆序) | \(O(\log_2 n)\) 栈(最坏 \(O(n)\)) | 不稳定 | 一趟枢轴到位,左 ≤ 枢轴 ≤ 右 |
| 简单选择 | \(O(n^{2})\) | \(O(n^{2})\) | \(O(n^{2})\) | \(O(1)\) | 不稳定 | 每趟一个最小值到位,比较固定 \(\frac{n(n-1)}{2}\) |
| 堆排序 | \(O(n\log_2 n)\) | \(O(n\log_2 n)\) | \(O(n\log_2 n)\) | \(O(1)\) | 不稳定 | 每趟堆顶交换到位;建堆 \(O(n)\) |
| 2 路归并 | \(O(n\log_2 n)\) | \(O(n\log_2 n)\) | \(O(n\log_2 n)\) | \(O(n)\) | 稳定 | 段长每趟翻倍,\(\lceil\log_2 n\rceil\) 趟;外部排序基础 |
| 基数 | \(O(d(n+r))\) | \(O(d(n+r))\) | \(O(d(n+r))\) | \(O(r)\) | 稳定 | 一趟后按当前位有序;不基于比较 |
| 考点 | 常考题型 | 热度 | 核心方法 |
|---|---|---|---|
| 稳定性判断 | 选择 | ★★★★★ 高频 | 「插冒归基」稳定,「希选快堆」不稳定;能举反例 \(\{2_a,2_b,1\}\)、\(\{3_a,3_b,2\}\) |
| 快排过程与一趟判断 | 大题 + 选择 | ★★★★★ 高频 | 填坑法逐步模拟;「找枢轴位:左边全 ≤、右边全 ≥」秒杀选择题 |
| 建堆、堆调整与插入删除 | 大题 + 选择 | ★★★★★ 高频 | 自 \(\lfloor n/2\rfloor\) 向上筛选;插入上浮、删除下沉,均 \(O(\log_2 n)\) |
| 希尔各趟 / 归并趟数 | 大题 / 填空 | ★★★★ | 增量分组逐趟列;归并趟数 \(\lceil\log_2 n\rceil\)(n=8→3 趟验证) |
| 「一趟后能确定什么」 | 选择 | ★★★★ 高频 | 到位四兄弟:冒泡 / 简选 / 堆 / 快排(枢轴);插入 / 希尔 / 归并只局部有序 |
| 复杂度与比较次数 | 选择 / 计算 | ★★★★ | 简单选择固定 \(\frac{n(n-1)}{2}\);折半插入比较 \(O(n\log_2 n)\) 与初态无关;快排最坏 \(\frac{n(n-1)}{2}\) |
| 外部排序概念 | 选择 | ★★★ | 归并段 + k 路归并;败者树(比较 \(O(\log_2 k)\))/ 置换-选择(长初始段)/ 最佳归并树(最省 I/O) |