数据结构 · 2027 考研计算机 408

第 8 章 排序

目标院校:四川大学 / 电子科技大学 | 建议用时:概念 3 小时 + 例题练习 5 小时

本章地位:排序是 408 数据结构的收官重镇,几乎每年必考 2 道以上选择题,且「给一个序列手工模拟排序过程」(快排每趟、建堆与堆调整、希尔各趟、归并趟数)是常考大题题型。本章全章统一用初始序列 \(\{49,38,65,97,76,13,27,49\}\)(记号 49₂ 表示第二个 49),每种算法都「一趟一趟」列出中间结果并与最终答案核对;学完后必须能默写九种排序对比总表(8.7 节),这是 408 最著名的必背表。

8.1 排序的基本概念

定义排序:将一个数据的任意序列,重新排列成一个按关键字有序(非递减或非递增)的序列。设含 \(n\) 个记录的序列为 \(\{R_1,R_2,\dots,R_n\}\),其关键字为 \(\{k_1,k_2,\dots,k_n\}\),排序就是确定一个排列 \(p_1,p_2,\dots,p_n\),使 \(k_{p_1}\le k_{p_2}\le\cdots\le k_{p_n}\)。

8.1.1 稳定性:相等元素的相对次序

稳定性设 \(k_i=k_j\) 且排序前 \(R_i\) 在 \(R_j\) 之前(\(i\lt j\))。若排序后仍保证 \(R_i\) 在 \(R_j\) 之前,则称该排序方法是稳定的;否则为不稳定的。
稳定性是算法的内在性质:「能找到某个序列被排乱」才算不稳定,「某次没排乱」不能证明稳定。对于简单类型整数,稳定与否看不出差别,但对多重关键字排序(先按总分排、总分相同按数学排)意义重大。
例 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

判断正误:(1) 稳定的排序方法一定优于不稳定的方法;(2) 排序的稳定性是指排序后关键字不再改变;(3) 外部排序是指排序过程需要访问外存的排序。

查看答案

(1) 错:稳定性只保证相等元素相对次序不变,与时间 / 空间效率无关(快排不稳定却是平均最快的内排序之一);
(2) 错:稳定性指相等关键字记录的相对次序在排序前后是否一致;
(3) 对:这正是外部排序的定义(数据量超出内存容量)。

8.2 插入排序

8.2.1 直接插入排序

思想把序列分成有序前缀 + 无序后缀:第 \(i\) 趟将无序区的第一个元素 \(a[i]\) 插入到前面有序区 \(a[1..i-1]\) 中的正确位置,有序区长度加 1。就像摸扑克牌:手里总是有序的,每摸一张插到合适位置。初始时 \(a[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];                // 腾出的空位放入待插元素
        }
    }
}
性能比较次数与移动次数均与初始序列有关:最好(正序)比较 \(n-1\) 次、移动 0 次,为 \(O(n)\);最坏(逆序)每趟都把前面整个有序区逐个后移,比较与移动均约 \(\frac{n^{2}}{2}\) 次,为 \(O(n^{2})\);平均约 \(\frac{n^{2}}{4}\) 次比较与移动;空间 \(O(1)\)(原地);稳定(相等时停止后移,插在等值元素之后);顺序、链式存储均可实现,也适合基本有序或 n 小的场合。
例 2 直接插入排序全过程模拟

写出对 \(\{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\):约 \(\sum_{i=2}^{n}\lceil\log_2(i+1)\rceil\approx n\log_2 n=O(n\log_2 n)\);但后移次数不变(最坏 \(O(n^{2})\)),故总时间最坏 / 平均仍为 \(O(n^{2})\),正序时移动为 0、总时间为 \(O(n\log_2 n)\)。空间 \(O(1)\),稳定(二分定位后统一后移,插在等值段之后)。
例 3 方法 折半插入的比较次数

对 \(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 希尔排序(缩小增量排序)

思想直接插入排序在「基本有序」和「n 小」时很快。希尔排序据此分两步利用:先按较大增量分组做插入排序(每组元素少),增量逐趟缩小(大步长先把远处的元素搬到位,使序列渐趋有序),最后一趟增量必须为 1(即直接插入,此时序列已基本有序,只需少量移动)。

// 希尔排序:增量序列 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];
            }
        }
    }
}
例 4 真题风格 大题:希尔排序逐趟模拟

对 \(\{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 之后——相等元素的相对次序被改变,希尔排序不稳定的直接证据。

性能时间复杂度依赖增量序列,经验上约 \(O(n^{1.3})\),最坏 \(O(n^{2})\);空间 \(O(1)\);不稳定;仅适用于顺序存储——组内元素下标相差 dk,链表无法按位置快速定位第 i±dk 个结点,只能顺序扫描,增量优势荡然无存。
练习 2 易错

(1) 折半插入排序的比较次数为什么与初始序列无关?它的移动次数呢?(2) 希尔排序的增量序列必须满足什么条件?为什么希尔排序不能像直接插入那样方便地用于链表?

查看答案

(1) 每趟的比较次数只由有序区长度决定(二分判定树形态固定),与关键字排列无关;移动次数仍与初始序列有关(后移量不变,最坏 \(O(n^{2})\))——「省了比较、省不了移动」。

(2) 最后一趟增量必须是 1(保证全局有序),且增量序列最好互质、递减到 1;链表不支持按序号随机定位,取第 i−dk 个结点要 O(n) 扫描,无法体现「跳跃式移动」的优势,故仅适用于顺序存储。

8.3 交换排序

8.3.1 冒泡排序

思想从后往前(或从前往后)两两比较相邻元素,逆序则交换,每趟把无序区的最大值「沉」到无序区末尾,无序区长度减 1;最多 \(n-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;                 // 整趟无交换 → 已有序,提前结束
    }
}
例 5 冒泡排序逐趟模拟

写出对 \(\{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 快速排序 高频考点

思想任取一个元素为枢轴(pivot)(教材版本取子区间首个元素),一趟 Partition 划分把序列分成「左部 ≤ 枢轴 ≤ 右部」两块,枢轴落位;再对左右两块递归同样处理。快排是冒泡的改进:冒泡只比较相邻元素,快排比较的是「跳跃」的两端,一趟能消除多个逆序。

// 快速排序: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);         // 递归排右半
    }
}
初始: 4938 6597 7613 2749₂ 枢轴挖出成「坑」 high 自右向左找 < 49 者(27 填入左坑) low 自左向右找 > 49 者(65 填入右坑)……交替进行至相遇 一趟后: 2738 1349 7697 6549₂ 左部全部 ≤ 49 枢轴 49 到达最终位置 4 右部全部 ≥ 49 之后再对左部 {27,38,13}、右部 {76,97,65,49₂} 分别递归划分
图 8-1 快速排序一趟划分(填坑法):枢轴取首元素,high / low 双端交替「找小填左坑、找大填右坑」,相遇处即枢轴最终位
例 6 真题风格 大题:快速排序每趟结果

对 \(\{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 之后而初始在其前——快排不稳定的证据。

性能每趟划分区间约对半分时递归树平衡:最好 / 平均 \(O(n\log_2 n)\),平均性能居内排序前列;最坏(初始已正序或逆序——枢轴总取到最值,划分出空段)退化为 \(n-1\) 趟、比较 \(\frac{n(n-1)}{2}\) 次,\(O(n^{2})\)。空间为递归工作栈:最好 / 平均 \(O(\log_2 n)\),最坏 \(O(n)\)。不稳定。每趟划分后:枢轴到达最终位置,其左、右部整体有序化但内部无序。
例 7 高频考点 「一趟之后」的判断技巧

下列序列中,可能是某个初始序列经快速排序一趟划分后的结果的是( )
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。

练习 3

(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 简单选择排序

思想第 \(i\) 趟从无序区 \(a[i..n]\) 中选出最小元素与 \(a[i]\) 交换,放到有序区末尾——「先选后排」:比较全部做完才移动一次,每趟恰好 0 或 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;
        }
    }
}
例 8 比较次数与初始序列无关

(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 堆排序 高频考点

大顶堆的定义\(n\) 个元素的序列 \(\{k_1,\dots,k_n\}\) 满足 \[ k_i\ge k_{2i}\ \text{且}\ k_i\ge k_{2i+1}\quad\big(i=1,\dots,\lfloor n/2\rfloor\big) \] 称为大顶堆(大根堆)(相应地把 ≤ 情形称小顶堆)。把序列看作完全二叉树的顺序存储:任意结点 ≥ 其孩子,堆顶(\(k_1\))是全局最大值。第 \(i\) 个结点的双亲是 \(\lfloor i/2\rfloor\),孩子是 \(2i\)、\(2i+1\)。
初始完全二叉树(非堆) 49 3865 9776 132749₂ 结点 38:孩子 97 > 38 → 需下坠筛选 自底向上建堆 建成的大顶堆:97 76 65 49 49₂ 13 27 38 97 76 65 49 49₂ 13 27 38 每条「结点 ≥ 孩子」路径都成立:堆顶 97 为全局最大
图 8-2 对初始序列自底向上建大顶堆:从最后一个非叶结点(位置 \(\lfloor n/2\rfloor=4\))到根逐个「下坠」筛选,最终 97 浮到堆顶

// 堆排序(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,重新筛堆顶
    }
}
例 9 真题风格 大题:建堆与堆排序逐趟模拟

对 \(\{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 相对序已变)。

例 10 真题风格 堆的插入与删除(2011 / 2015 风格)

大顶堆 \(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)\)——这是选择题高频答案。

练习 4 易错

(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 路归并排序

思想归并:把两个各自有序的表合并成一个有序表(每次取两表表头较小者)。归并排序先把 \(n\) 个元素视为 \(n\) 个长度为 1 的有序段,每趟相邻两两归并、段长翻倍,\(\lceil\log_2 n\rceil\) 趟后合为一个全长段。

// 一趟归并:把相邻长度为 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);       // 合并两个有序段
    }
}
第 3 趟(段长 4→8) 第 2 趟(段长 2→4) 第 1 趟(段长 1→2) 49 38 65 97 76 13 27 49₂ 38 49 65 97 13 76 27 49₂ 38 49 65 97 13 27 49₂ 76 → 13 27 38 49 49₂ 65 76 97
图 8-3 2 路归并的归并树(\(n=8\)):自下而上 3 趟,每趟段长翻倍,层数 \(\lceil\log_2 8\rceil=3\) 即趟数
例 11 归并排序逐趟模拟与趟数公式

对 \(\{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 基数排序

思想不基于「比较」,而基于多关键字分配与收集:设关键字有 \(d\) 位(多重 MSD / LSD 两种,408 考最低位优先 LSD),每趟按某一位把全部元素分配到 \(r\) 个队列(\(r\) 为基数,十进制 \(r=10\)),再按队列号从 0 到 \(r-1\) 收集回来;从最低位到最高位共 \(d\) 趟。
例 12 基数排序分配—收集过程

对 \(\{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 外部排序概念

框架文件太大放不进内存:① 生成初始归并段(内存工作区能装多少就内部排序成多长的有序段,逐段写回外存);② 对归并段做多趟 k 路归并,直到合成一个有序文件。总 I/O 次数 ≈ 2×(初始生成) + 归并趟数×每次读写——减少归并趟数、增大初始段长度是优化主线(趟数 \(=\lceil\log_k m\rceil\),\(m\) 为段数)。
三个优化名词① 败者树:k 路归并时从 k 个段头选最小者的树形比较结构,每选一个只需 \(O(\log_2 k)\) 次比较——k 较大时用它加速归并;
② 置换-选择:利用「最小者出、新元素进」的工作区滚动,生成的初始归并段平均长度约为工作区大小的 2 倍——想生成更长的初始归并段时用它;
③ 最佳归并树:各段长度不等时按哈夫曼思想「短段先归并」安排归并顺序,使总 I/O(带权路径长度)最小(段数补 \((k-1)\) 的倍数个虚段)——段长不等的多趟归并时用它。
练习 5

(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 路归并(只保证段内有序,段与段还会重排)、基数(一趟后仅「按当前位」有序)。
另两个常考变形:① 快排一趟后还能保证「枢轴左边 ≤ 枢轴 ≤ 右边」;② 插入 / 希尔 / 归并「每趟产生局部有序区」,与「最终位置」严格区分。
例 13 高频考点 「一趟后…」辨析

下列排序中,一趟结束后能保证某个元素放在其最终位置上的是( )
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 分钟,先做后看答案。难度:★★ 基础 / ★★★ 强化 / ★★★★ 冲刺。所有过程模拟题先在草稿纸上独立完成再核对中间趟结果。

自测 1(选择 · ★★)

下列排序算法中,不稳定的有( )
① 直接插入 ② 希尔 ③ 冒泡 ④ 简单选择 ⑤ 快速排序 ⑥ 堆排序 ⑦ 2 路归并 ⑧ 基数
A. ①③⑤⑦ B. ②④⑥⑧ C. ②④⑤⑥ D. ①④⑤⑥

查看答案

C。口诀「希选快堆」不稳定(②④⑤⑥);直接插入、冒泡、归并、基数(以及折半插入)稳定。基数排序按队列分配收集,天然保持相对次序。

自测 2(选择 · ★★★)

快速排序在最好情况下的时间复杂度与空间复杂度分别是( )
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)\) 空间的实现。

自测 3(选择 · ★★★ 高频考点)

对同一初始序列(\(n\gt3\)、含相等元素)分别排序,一趟后不能保证任何元素到达最终位置的是( )
A. 冒泡排序 B. 简单选择排序 C. 快速排序 D. 直接插入排序

查看答案

D。冒泡一趟沉底一个最大值、简单选择一趟就位一个最小值、快排一趟枢轴落位;直接插入一趟只形成「局部有序前缀」,后面更小的元素仍会插到前面,前缀里除最大者外均可能在后续趟移动。

自测 4(选择 · ★★★)

排序过程中比较次数与初始序列无关的算法是( )
A. 快速排序 B. 简单选择排序 C. 直接插入排序 D. 2 路归并排序

查看答案

B。简单选择每趟无序区全扫描,固定 \(\frac{n(n-1)}{2}\) 次比较。折半插入的比较次数同样与初态无关(\(O(n\log_2 n)\),但其移动量仍随初态变化);快排、直接插入、归并的比较次数均与初态有关。

自测 5(选择 · ★★)

对 \(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}\) 次交换(远多于前者)。

自测 6(选择 · ★★★)

关于希尔排序,正确的说法是( )
A. 增量序列必须递增 B. 最后一趟增量必须为 1 C. 适合链式存储 D. 是稳定算法

查看答案

B。增量序列递减且最后一趟必须是 1(否则不能保证全局有序);链表无法 O(1) 定位「相差 dk」的元素,只适用于顺序存储;分组跳跃交换使相等元素相对序可被破坏,不稳定(例 4 已演示)。

自测 7(填空 · ★★)

对 \(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)\) 数组)。

自测 8(填空 · ★★★)

要在 \(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 大,无法淘汰较小的候选。

自测 9(解答 · ★★★★ 冲刺)

判断序列 \(\{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 个仍成大顶堆 ✓。

自测 10(解答 · ★★★★ 冲刺)

对序列 \(\{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)\)稳定一趟后按当前位有序;不基于比较
三句定论:平均性能快排最快(但不稳定、最坏退化);归并稳定且是外部排序的基础(代价 \(O(n)\) 辅助空间);堆排序空间 \(O(1)\) 且最坏不退化(适合最坏保障 / Top-k 场景)。
考点常考题型热度核心方法
稳定性判断选择★★★★★ 高频「插冒归基」稳定,「希选快堆」不稳定;能举反例 \(\{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)
下一步本章过关标准:例题全部独立重做(重点例 4、6、9、10 的手工模拟);自测 10 题中至少 8 题正确;能默写九种排序对比总表与「到位四兄弟」辨析;任给序列能在 5 分钟内写出一趟快排 / 建堆结果并自检。至此 408 数据结构八个章节全部完成,进入第二阶段: 计算机组成原理 第 1 章 计算机系统概述——带着「复杂度直觉」去学硬件,会发现存储层次与流水线同样是时空权衡的艺术。