数据结构精讲
写给刚高中毕业、没写过程序的同学:从“C 语言里一个变量是什么”讲起,一直讲到能独立做完 2009—2026 年全部 233 道数据结构真题,并且能在考场上写出拿满分的算法大题。
数据结构在 408 里占 45 分,是四门里分最多的(和计组并列):11 道选择题(第 1~11 题,每题 2 分,共 22 分;2009 年是 10 道)加 2 道综合题(第 41、42 题,共 23 分,一道 10 分左右、一道 13~15 分)。两道综合题里,几乎每年都有一道是“设计一个算法并用 C 语言写出来”,另一道是“画图、手算、说明原理”的应用题。
这门课的好消息是:它最讲道理。每个结构、每个算法都有一个“为什么要这样设计”的理由,只要把理由想通,就不需要死记;选择题换数字、换说法,底层就是那十几个套路。坏消息是:它要求你会写代码。所以这份讲义第 0 篇先带你把 C 语言里考试要用的部分学会,后面每一章的算法都给出能直接抄到答题卡上的完整代码,并且全部在电脑上编译运行验证过。
- 按顺序读。第 0 篇是 C 语言和递归,没写过代码的同学一定要从这里开始;第 1 篇讲“复杂度”这把尺子,后面每一章都用它来衡量好坏;第 2~8 篇是七大块内容,前面的是后面的基础(树用到栈和队列,图用到树,查找和排序用到前面所有结构)。
- 每一节都按同一个顺序讲(“七步一图”):为什么要学(问题引入)→ 大白话比喻(并指出比喻哪里不像)→ 严格定义 → 图 → 跟我做一遍 → 真题怎么考 → 易错点和随堂小测;算法一律给出带注释的完整代码、逐步跟踪表和复杂度。每篇开头有本篇地图(各节真题数、难度、建议学时),结尾有一页总结和必会题型。
- 几种框的意思:绿色的「跟我做一遍」是手把手的例题,请拿出纸笔一起算;蓝色的「补课」补的是高中数学里不够用的部分(对数、求和、组合数……);标着 拓展 的内容超出考纲,可以跳过;「考纲补充(尚无真题)」卡片是考纲要求、但还没正面考过的点的自编题。
- 书里的每一个数字、每一趟排序结果、每一张逐步表都由程序验算过,每段代码都编译后用大量随机数据和“标准答案”对拍过——可以放心照着练。
- 遇到 真题卡 先自己做,再点「展开解析」。解析写出每一步依据,并逐个说明错误选项错在哪里,最后一句“记住”是这道题留给你的结论。
- 每个小节标题下面有一条“真题标签”:★★★ 高频 表示这一节考得多;后面的题号点一下就弹出题目和讲解,点「逐题做」可以连续做完这一节挂的全部真题。做题记录只存在你自己的浏览器里,题号会变绿(对)或变红(错)。
- 每个考过选择题的小节末尾都有「选择题变式」(虚线框):每道都由一道真题改动一两个条件而来,“变化点”告诉你改了什么、答案为什么跟着变。做变式是检验“真懂还是背答案”的最好办法。
- 带 ⚙ 的是可以动手的小工具:出栈序列判定、中缀转后缀、KMP 的 next 数组、二叉树遍历、哈夫曼树、图算法(Dijkstra / Prim / Kruskal / 拓扑 / 关键路径)、散列表、堆、各种排序的每一趟……改数字就能看到每一步,拿来检查自己的手算结果。
- 第 9 篇「大题满分训练营」把 36 道综合题分成 8 类,每类讲“这类题长什么样 → 必须会的几件事 → 解题模板 → 完整示范 → 自编模拟大题 → 举一反三变式表 → 丢分点”,每类还附评分细则与“只会暴力解时怎么拿分”;最后有三套整卷模拟和 6 道预测题(并查集、堆、非递归遍历、拓扑排序、Dijkstra、三色划分)。算法题的代码全部编译并用多组数据测试过。
- 第 10 篇「考场手册」是考前一周用的:全书一页纸、代码模板、复杂度总表、公式速查、易错清单(90 条)、按年份刷完全部真题。
- 第 11 篇「满分冲刺」解决“稳不稳”:18 年命题规律与 2027 预测、考纲有而真题少的点专项 24 题、Ⅰ/Ⅱ/Ⅲ 概念辨析 16 题、三套数据结构 45 分全真模拟卷(11 选择 + 41 + 42,限时 60 分钟,附评分表),以及一张 153 条、覆盖全部 233 道真题的知识点细目表(可勾选)。全书的变式题、考纲补充题和第 11 篇的练习题都可以直接点选项作答,立刻判对错;模拟卷带 60 分钟计时和“交卷”计分。
一张图看懂:这份讲义讲的是什么
| 篇 | 内容 | 选择题真题数 |
|---|---|---|
| 第 0 篇 | 导读与 C 语言起步:变量、数组、循环、函数、指针、结构体、动态内存、递归;算法大题的答题规范 | 零基础必读 |
| 第 1 篇 | 绪论:数据结构三要素、算法五特性、时间复杂度与空间复杂度的计算 | 8 |
| 第 2 篇 | 线性表:顺序表、单链表、双链表、循环链表、静态链表;算法题基本功 | 8 |
| 第 3 篇 | 栈、队列和数组:出栈序列、循环队列、双端队列、表达式求值、递归、特殊矩阵压缩 | 26 |
| 第 4 篇 | 串:朴素匹配、KMP 的 next 与 nextval | 3 |
| 第 5 篇 | 树与二叉树:性质、遍历、线索化、树与森林、哈夫曼树、并查集 | 41(最多) |
| 第 6 篇 | 图:存储、BFS / DFS、最小生成树、最短路径、拓扑排序、关键路径 | 33 |
| 第 7 篇 | 查找:折半、分块、BST、AVL、红黑树、B / B+ 树、散列表 | 39 |
| 第 8 篇 | 排序:九种内部排序与外部排序 | 39 |
| 第 9 篇 | 大题满分训练营:36 道综合题分 8 类,每类有模板、示范、模拟题和变式表;两套整卷模拟 | 综合题专项 |
| 第 10 篇 | 考场手册:代码模板、复杂度总表、易错清单、按年份刷题 | 考前冲刺 |
| 第 11 篇 | 满分冲刺:命题规律、新考点专项、概念辨析、三套 45 分全真模拟卷、知识点细目表 | 最后四周 |
小节标题下的星级按“这一小节挂了多少道真题”自动算(选择题算 1、综合题算 3:≥9 为三星,≥4 为两星)。每道题挂在哪个小节、讲解怎么写,都是对照题目逐道确定的。题干和答案来自本机题库(2009—2026 年数据结构共 233 题:选择题 197 道、综合题 36 道;2016 年的综合题是第 42、43 题,2014 年第 42 题虽然用了路由器的外壳,考的是图)。原题带图的,题干里的图按原卷重画。
导读与 C 语言起步:先学会“用代码说话”
数据结构讲的是“数据怎么组织、操作怎么实现”,而“实现”要用程序写出来。408 要求用 C 或 C++ 写算法,所以这一篇先把考试会用到的 C 语言讲清楚:变量、数组、循环、函数、指针、结构体、动态内存,再讲贯穿全书的递归,最后讲算法大题的答题规范——怎样写才能拿到分。已经会 C 语言的同学可以快速浏览 0.3 的“指针与结构体”和 0.5 的答题规范。
0.1数据结构在 408 里考什么、怎么考
408 是一张 150 分、3 小时的卷子,四门课合考。数据结构排在最前面:第 1~11 题是选择题,第 41、42 题是综合应用题。
| 题型 | 分值 | 考法 | 对应讲义 |
|---|---|---|---|
| 单项选择题 | 11 × 2 = 22 分 | 概念辨析(“下列说法正确的是”)、小计算(结点个数、下标、ASL、比较次数)、给过程判断结果(出栈序列、某一趟排序结果、遍历序列) | 第 1~8 篇每节的真题卡和变式 |
| 41 或 42:算法设计题 | 13~15 分 | “设计一个时间和空间都尽可能高效的算法”:写思想、写 C 代码、写复杂度 | 第 9 篇 t1~t4 |
| 41 或 42:应用题 | 8~10 分 | 画散列表算 ASL、求最小生成树 / 最短路径 / 关键路径、外部排序、树的性质推导、设计一种存储结构 | 第 9 篇 t5~t8 |
全书地图:八篇知识 + 三篇实战
高频考点清单(按出现次数)
选择题的“年年见”
- 排序:某一趟结果是哪种算法、稳定性、比较 / 移动次数(每年 2~3 道)
- 树:遍历序列、结点数计算、森林与二叉树转换、哈夫曼树
- 查找:B 树的结点与关键字个数、平衡二叉树旋转、散列表 ASL
- 图:拓扑序列、Dijkstra 的顺序、关键路径、最小生成树
- 栈与队列:出栈序列、循环队列、中缀转后缀
- 复杂度:给一段循环求时间复杂度(几乎每年第 1 题)
大题的“两个套路”
- 算法题:数组(循环左移、主元素、中位数、未出现的最小正整数、三元组距离……)、链表(倒数第 k 个、公共后缀、去重、重排)、二叉树(WPL、表达式树、判定 BST)、图(邻接矩阵上数度、判定拓扑唯一)
- 应用题:散列表画表 + ASL、关键路径、最小生成树、Dijkstra、外部排序与最佳归并树、树 / 编码 / 队列的设计与推导
- 第一遍(约 4 周):按顺序读 0~8 篇,每节的真题卡当例题做,代码自己在纸上抄一遍、在电脑上跑一遍。
- 第二遍(约 2 周):每篇末尾“本篇真题闯关”全部做完,错题回到对应小节重读;每节的“选择题变式”做完。
- 第三遍(约 2 周):第 9 篇训练营,每类先看模板和示范,再限时做模拟大题(算法题 25 分钟、应用题 15 分钟)。
- 考前四周:第 11 篇三套全真模拟卷(每套限时 60 分钟)+ 新考点专项;考前一周:第 10 篇考场手册 + 第 11 篇知识点细目表逐条打勾。
目标:数据结构 45 分拿 40 分
若 408 的目标是 125 分,数据结构这 45 分要拿到 40 分左右——它是四门里最“可练”的一门:题型固定、方法固定,花时间就有回报。40 分大致这样分配:
| 部分 | 满分 | 目标 | 怎么做到 |
|---|---|---|---|
| 选择题 11 道 | 22 | 20 | 最多错 1 道。每篇的真题卡 + 变式全部做过,每类题都有固定方法(各篇“必会题型”表) |
| 应用题(41 或 42) | 8 ~ 10 | 满分或只差 1 分 | 散列表、最短路径、关键路径、外部排序……都是“按步骤画表”,第 9 篇 t5 ~ t8 每类练 3 道 |
| 算法设计题(41 或 42) | 13 ~ 15 | 11 ~ 13 | 想不出最优解就写次优解(见 0.5 得分分档);第 9 篇 t1 ~ t4 每类先背模板再限时练 |
| 合计 | 45 | ≈ 40 |
| 篇 | 1 绪论 | 2 线性表 | 3 栈队列 | 4 串 | 5 树 | 6 图 | 7 查找 | 8 排序 | 合计 |
|---|---|---|---|---|---|---|---|---|---|
| 第一遍学时(各篇地图之和) | 4.5 h | 9.5 h | 9 h | 4 h | 15 h | 14.5 h | 13 h | 12.5 h | 82 h |
- 算总量:第 1 ~ 8 篇约 82 小时,加上第 0 篇约 3 小时,第一遍共约 85 小时。
- 定节奏:每天能给数据结构 3 小时,就是约 28 天,和上面“第一遍约 4 周”一致;每天 2 小时就排 6 周。
- 排顺序:按 0 → 8 篇往下学,但第 5 篇(树)留出整整一周——它分最多、后面查找和排序都要用。
- 留缓冲:每学完一篇空出半天,把这一篇的“本篇自测”和“必会题型”过一遍,错的回原节重看。
- 数据结构的选择题在 408 试卷中是第几题到第几题?共多少分?
答案
第 1 ~ 11 题,每题 2 分,共 22 分。 - 树、图、查找、排序四篇的选择题约占数据结构选择题的几成?
答案
41 + 33 + 39 + 39 = 152 道,占 197 道的约 77%。
0.2C 语言(一):变量、数组、循环、函数
先看一个完整的 C 程序,它求一个数组里所有数的和:
#include <stdio.h> // 使用 printf 要包含这个头文件 int sum(int a[], int n) { // 函数:接收数组 a 和它的长度 n,返回 int int s = 0; // 定义一个整型变量 s,初值 0 for (int i = 0; i < n; i++) // i 从 0 数到 n-1 s = s + a[i]; // 把第 i 个元素加进来 return s; // 把结果交回给调用者 } int main() { // 程序从 main 开始执行 int a[5] = {3, 1, 4, 1, 5}; // 一个有 5 个元素的数组 printf("%d\n", sum(a, 5)); // 调用 sum,打印 14 return 0; }
考试只要求写函数(不用写 main、不用写 #include),但要看懂这个完整的样子。下面把用到的零件逐个讲。
变量:内存里一个有名字的格子
内存可以想成一长排带编号的小格子(每格 1 字节),编号就是地址。写 int x = 5; 就是:找 4 个连续的格子(int 一般占 4 字节),起名叫 x,里面放 5。之后写 x 就是读这个格子里的值,写 x = 7; 就是把 7 放进去(原来的 5 被覆盖)。
| 类型 | 一般大小 | 存什么 | 例子 |
|---|---|---|---|
int | 4 字节 | 整数(约 ±21 亿) | int n = 100; |
char | 1 字节 | 一个字符(本质是一个小整数,'a' 是 97) | char c = 'a'; |
float / double | 4 / 8 字节 | 小数 | double x = 3.14; |
bool | 1 字节 | true / false(C 语言要 #include <stdbool.h>,C++ 直接用) | bool ok = true; |
运算符里最容易错的几个
| 写法 | 含义 | 例子与陷阱 |
|---|---|---|
= 与 == | 一个等号是“赋值”,两个是“判断相等” | if (x = 0) 是把 0 赋给 x、条件永远为假,要写 if (x == 0) |
/ | 两个整数相除,结果向零取整(舍掉小数) | 7 / 2 是 3,(low + high) / 2 就是取中间偏左的下标 |
% | 取余数(模) | 17 % 5 是 2;循环队列 (rear + 1) % MaxSize、散列 key % 7 都靠它 |
i++ / ++i | 都让 i 加 1;作为表达式时 i++ 的值是加之前的,++i 是加之后的 | a[i++] = x; 先用 i 当下标,再加 1。2017-01 的 sum += ++i 是先加再用 |
&& || ! | 与、或、非;短路:A && B 中 A 为假就不再算 B | while (p != NULL && p->data != x):先保证 p 不空,才去读 p->data,顺序不能反 |
+= 等 | s += a[i] 就是 s = s + a[i] | k *= 2 让 k 翻倍,是“对数级”循环的标志 |
int 有多大:常见的 int 占 4 字节,能表示 −2147483648 ~ 2147483647(约 ±21 亿),头文件 limits.h 里叫 INT_MIN、INT_MAX。超出范围会“溢出”——INT_MAX 再加 1 会变成一个很大的负数,程序不会报错,只会悄悄算错。数据结构的代码里有两处要特别留心:① 用 INT_MAX 表示“无穷大”时,dist[u] + INF 会溢出成负数(第 6 篇 Dijkstra 专门讲了这个坑),所以要么先判断“有没有边”,要么把无穷大取成一个不会溢出的大数;② 折半查找的 (low + high) / 2 在下标特别大时也可能溢出(第 7 篇),考试中的数组都很小,一般不用担心。
三种控制结构
if / else:选择
if (x > 0) y = 1; else if (x == 0) y = 0; else y = -1;
while:先判断再做
int i = 0; while (i < n && a[i] != x) i++; // 退出时:要么 i == n // 要么 a[i] == x
for:计数循环
for (int i = 0; i < n; i++) { // 循环体执行 n 次 // i 依次取 0,1,…,n-1 }
for (初始化; 条件; 更新) 循环体 的执行顺序是:初始化 → 判断条件 → 为真就执行循环体 → 更新 → 再判断……条件为假时退出。它和下面的 while 完全等价,分析复杂度时常常要把 for 改写成 while 来数次数:
初始化;
while (条件) { 循环体; 更新; }
break 立即跳出所在的这一层循环;continue 跳过本次循环剩下的部分,直接进入下一次(for 循环会先执行“更新”)。
手动跟踪代码:跟踪表
看懂一段代码最可靠的办法是假装自己是计算机:列一张表,每一列是一个变量,每执行完一轮(或一句)就写一行。后面每一篇的“跟我做一遍”都是这种表,选择题“执行后 x 的值是多少”也这样做。
int i = 0, j = n - 1; while (i < j) { int t = a[i]; a[i] = a[j]; a[j] = t; // 交换两端 i++; j--; // 两个指针向中间走 }
| 轮 | i | j | 条件 i < j | 交换后的数组 |
|---|---|---|---|---|
| 1 | 0 | 4 | 真 | 5 2 3 4 1 |
| 2 | 1 | 3 | 真 | 5 4 3 2 1 |
| — | 2 | 2 | 假,退出 | 5 4 3 2 1(中间的 3 不用动) |
表格的三个用处:① 看清循环执行了几轮(复杂度);② 看清退出时变量的值(边界);③ 写完代码自查——用 2 ~ 3 个元素的小例子填一遍表,大部分下标错误都能发现。(本例由程序运行打印核对。)
数组:一排连续的格子
int a[10]; 申请 10 个连续的 int,下标是 0 ~ 9(不是 1 ~ 10)。a[i] 的地址 = 首地址 + i × 4。正因为地址能直接算出来,访问任何一个元素都是一步完成——这叫随机存取,是顺序表、堆、散列表的基础。
二维数组 int b[3][4]; 是 3 行 4 列,在内存里按行一行接一行地放(C 语言是“行优先”):b[i][j] 前面有 i 整行(每行 4 个)再加 j 个,所以地址 = 首地址 + (i × 4 + j) × 4。第 3 篇的特殊矩阵压缩就是在推这类公式。
数组当函数参数时传的是什么:写 void Clear(int a[], int n) 并调用 Clear(b, 5),传过去的不是 5 个数的副本,而是数组 b 的首地址。所以函数里把 a[i] 改成 0,改的就是 b 本身——这和普通变量“传值改不动”正好相反(0.3 节三种传参)。也因为只传了地址,函数里不知道数组有多长,长度 n 必须另外作为参数传进去,这就是大题函数头里总有一个 n 的原因。(例子已编译运行。)
函数:把一件事打包
int max(int x, int y) { // 返回类型 函数名(参数列表) if (x > y) return x; return y; } void swap(int x, int y) { // void 表示没有返回值 int t = x; x = y; y = t; }
注意第二个函数交换不了调用者的变量!C 语言调用函数时是值传递:把实参的值复制一份给形参 x、y,函数里改的是副本。要让函数改到外面的变量,必须传它的地址(0.3 节的指针),或者用 C++ 的引用。数组是例外:写 int a[] 当参数时传进去的其实是首地址,所以函数里改 a[i] 会改到外面的数组。
数学补课:后面会用到的五样高中数学
2¹⁰ = 1024 ≈ 10³,2²⁰ ≈ 10⁶。log₂n 就是“n 能对半分几次”:log₂1000 ≈ 10(⌊ ⌋ 是 9,⌈ ⌉ 是 10),log₂10⁶ ≈ 20。用处:折半查找的比较次数、完全二叉树的高度、i *= 2 的循环次数。
1 + 2 + … + n = n(n + 1)/2,例如 1 ~ 100 的和是 5050;1 + 2 + 4 + … + 2ᵏ = 2ᵏ⁺¹ − 1,例如 2⁰ ~ 2¹⁰ 的和是 2047。用处:数循环次数(1.3)、满二叉树的结点数(5.2)。
⌊x⌋ 向下取整、⌈x⌉ 向上取整:⌊7/2⌋ = 3,⌈7/2⌉ = 4,⌊−3.5⌋ = −4。注意 C 语言整数除法是向零取整:-7 / 2 得 −3。用处:结点 i 的双亲是 ⌊i/2⌋、B 树结点至少 ⌈m/2⌉ 棵子树。
从 5 个里挑 2 个排成一列:5 × 4 = 20 种(排列);只挑不排:20 ÷ 2 = 10 种,记 C(5, 2);C(6, 3) = 20;4! = 24。用处:出栈序列个数(卡特兰数,3.2)、二叉树的形态数(5.5)。
a % m 是 a 除以 m 的余数:17 % 5 = 2。“到头就绕回来”全靠它:(7 + 1) % 8 = 0。求循环队列长度写 (rear − front + M) % M,例 (3 − 5 + 8) % 8 = 6——先加 M 是因为 C 语言里负数取模结果是负的(-7 % 3 得 −1)。用处:循环队列(3.3)、散列函数(7.9)。
- 执行
int i = 0, s = 0; while (i < 10) { s += i; i += 3; }后,s 和 i 各是多少?答案
i 依次取 0、3、6、9 时进入循环,s = 0 + 3 + 6 + 9 = 18;最后 i = 12 时条件不成立退出。s = 18,i = 12。 int a[6];的首地址为 1000,每个 int 4 字节,a[5] 的地址是多少?a[6] 能用吗?答案
1000 + 5 × 4 = 1020。a[6] 越界(合法下标 0~5),C 语言不会报错,但会读写别人的内存,是严重错误。写算法时要时刻检查下标范围。
0.3C 语言(二):指针、结构体、动态内存、typedef 与引用
链表、树、图这些结构全靠指针连起来。指针是零基础同学最大的坎,我们用“门牌号”来理解它。
指针 = 存放“地址”的变量
变量像一间房子,里面住着值;地址是房子的门牌号。指针变量是一张写着门牌号的纸条:它本身也是一间房子,只不过里面放的不是普通的数,而是别人的门牌号。拿着纸条就能找到那间房子,进去看、进去改。
int x = 5; int *p; // p 是“指向 int 的指针”:它里面放一个 int 变量的地址 p = &x; // &x:取 x 的地址(门牌号),放进 p *p = 7; // *p:按 p 里的地址找到那个格子(就是 x),把 7 放进去 printf("%d", x); // 输出 7
| 写法 | 读法 | 记忆 |
|---|---|---|
int *p; | 定义:p 是指向 int 的指针 | 定义时的 * 只是说明“p 是指针” |
&x | 取 x 的地址 | & = “的地址” |
*p | 取 p 所指的那个变量 | 使用时的 * = “顺着箭头走过去” |
p = q; | 让 p 也指向 q 所指的地方(复制门牌号) | 两张纸条写同一个门牌号,房子没有被复制 |
NULL | 空指针,表示“哪也不指” | 链表的最后一个结点的 next 就是 NULL;对 NULL 用 * 会崩溃 |
现在可以写出真正能交换的 swap 了:把两个变量的地址传进去,函数里顺着地址去改。
void swap(int *a, int *b) { // a、b 是地址 int t = *a; *a = *b; *b = t; } // 调用:swap(&x, &y);
结构体:把几样东西捆成一个整体
一个链表结点要同时放“数据”和“下一个结点在哪”,C 语言用 struct 把它们捆在一起:
struct LNode { // 定义一种新类型:struct LNode int data; // 数据域 struct LNode *next; // 指针域:指向下一个同类结点 }; struct LNode a; // 一个结点变量 a.data = 3; // 结构体变量用 . 访问成员 struct LNode *p = &a; // p 指向 a p->data = 5; // 结构体指针用 -> 访问成员,等价于 (*p).data = 5
手里拿的是结构体本身就用点:a.data;手里拿的是指向结构体的指针就用箭头:p->data。链表、树的代码里几乎全是指针,所以几乎全是 ->:p->next->data 读作“p 的下一个结点的数据”。
typedef:给类型起个短名字
typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // LNode 是 struct LNode 的别名;LinkList 是 struct LNode * 的别名 LNode *p; // p 是指向结点的指针 LinkList L; // L 也是指向结点的指针,用这个名字强调“它代表一整条链表”
教材里 LNode * 和 LinkList 完全是一回事,只是习惯上用 LinkList 表示“链表的头指针”,用 LNode * 表示“某个结点”。考试时自己定义清楚、前后一致即可。如果题目已经给出了 typedef(近几年的图、树大题常这样),就直接用题目的类型名和成员名,不要另起一套名字,更不要重新定义一个同名的类型——阅卷老师是对照题目的定义来读你的代码的。
动态内存:malloc 和 free
数组的大小写死在代码里;链表要一个一个结点地“现要现拿”,这就要用动态分配:
#include <stdlib.h> LNode *s = (LNode *)malloc(sizeof(LNode)); // 向系统要一块能放一个 LNode 的内存,返回它的地址 s->data = 10; s->next = NULL; free(s); // 用完还给系统(删除结点时一定要写) int *a = (int *)malloc(sizeof(int) * n); // 要 n 个 int 的连续空间,当数组用:a[0] ~ a[n-1] // C++ 写法:LNode *s = new LNode; int *a = new int[n]; delete s; delete[] a;
sizeof(LNode) 算出一个结点占多少字节;(LNode *) 把 malloc 返回的“无类型地址”转换成“结点指针”。算法题里需要一个大小为 n 的辅助数组时(例如 2018-41 找未出现的最小正整数),就这样申请;用 memset(a, 0, sizeof(int) * n) 或一个 for 循环把它清零。
空指针是值为 NULL 的指针,明确表示“不指向任何东西”,可以拿来判断(if (p == NULL)),但不能顺着它访问(p->data 会让程序崩溃)。野指针是“指向一块不知道是什么的内存”的指针,最常见的三种来源:① 定义了指针却没赋初值;② malloc 出来的新结点忘了把 next 置为 NULL;③ free 之后还继续用它。野指针最可怕的地方是不一定马上出错——它可能“碰巧”能用,过一会儿才莫名其妙地崩溃。所以本书的代码有三个固定习惯:新结点的指针域立刻置 NULL;访问 p->next 之前先确认 p 不空;free 之后不再碰那个结点。第 2 篇链表的大部分易错点,都是这三条没做到。
C++ 的引用 &:为什么教材的函数参数里有 &
王道等教材的代码常写成 bool InitList(LinkList &L)。这里的 & 在参数声明里,是 C++ 的“引用”:L 成了调用者那个变量的别名,函数里改 L 就是改外面的变量。它的效果和传指针一样,但写起来不用到处加 *。
void InitList(LinkList *L) { *L = (LNode *)malloc(sizeof(LNode)); (*L)->next = NULL; } // 调用:InitList(&L);
void InitList(LinkList &L) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; } // 调用:InitList(L);
只有函数要改变指针变量本身(让头指针 L 指向新结点、让 root 从 NULL 变成新结点)时才需要。只是顺着指针去改结点里的内容(p->data = 5、p->next = q),不需要 &。考试写 C++ 引用完全合法,评分不区分 C 和 C++。
一张图看懂三种传参
void f1(int x) { x = 100; } // 值传递 void f2(int *x) { *x = 100; } // 传指针,调用写 f2(&v) void f3(int &x) { x = 100; } // 传引用(C++),调用写 f3(v)
f1(v) 后 v 仍是 5;f2(&v) 后 v 是 100;f3(v) 后 v 是 100。链表的 InitList(LinkList &L) 用引用,正是因为要改的是头指针 L 本身。(三个函数已编译运行核对。)
- 设
LNode *p指向结点 A,A 的下一个是 B,B 的下一个是 C。p->next->next->data是哪个结点的数据?执行p = p->next;后,A 还在内存里吗?答案
是 C 的数据(A → B → C,走两步)。执行后 p 改指 B,A 仍在内存里,只是 p 不再指向它;如果没有别的指针指着 A,A 就“找不到了”(内存泄漏),删除结点时要先用临时指针记住它再 free。 - 下面的函数想把头指针置空,为什么调用后外面的 L 没变?
void clear(LinkList L) { L = NULL; }答案
L 是值传递,函数里的 L 是外面 L 的一份拷贝,改拷贝不影响外面。改成void clear(LinkList &L)(C++)或void clear(LinkList *L) { *L = NULL; }(C)。
0.4递归:函数调用自己
递归是树、图、快排、归并排序的核心写法,一定要真正想通。先看最简单的例子——阶乘 n! = n × (n−1)!:
int fact(int n) { if (n <= 1) return 1; // ① 出口(边界):规模小到可以直接回答 return n * fact(n - 1); // ② 递归:把 n 的问题交给 n-1 的问题,再加一步 }
- 定义清楚函数做什么:fact(n) 返回 n 的阶乘。
- 写出口:最小的情况直接返回(n ≤ 1 时返回 1)。没有出口就会无限调用下去,直到栈溢出。
- 相信它:假设 fact(n−1) 已经能正确返回 (n−1)!,只写“怎么用它得到 fact(n)”这一步。不要在脑子里一层层展开——那是计算机的事。
递归在计算机里怎么执行:调用栈
每调用一次函数,系统就在一个叫调用栈的区域里压入一块“工作记录”(参数、局部变量、执行到哪一句);函数返回时弹出这块记录,回到调用它的地方继续。递归调用就是一层层压进去,到出口后再一层层弹出来:
高中学过数学归纳法:先证明 n = 1 时成立(奠基),再证明“假如 n − 1 时成立,那么 n 时也成立”(递推),于是对所有 n 都成立。写递归函数完全一样:递归出口就是奠基——最小的情况直接给出正确答案;递归体就是递推——先假定 sum(a, n − 1) 已经正确地算出了前 n − 1 个数的和,只要再加上 a[n − 1],就得到前 n 个的和。写的时候不要钻进下一层、下下层去追踪,那样只会越想越乱;只要“出口对、每一步对”,整个递归就一定对。需要检查执行过程时,再用下面的“去 / 回”跟踪表一步步看。
递归最常见的错误是出口写漏或写错:例如求阶乘忘了写 if (n <= 1) return 1;,fact(3) 会调用 fact(2)、fact(1)、fact(0)、fact(−1)……永远停不下来,每一层都在调用栈里压一个栈帧,很快把栈占满,程序崩溃(“栈溢出”)。还有一种隐蔽的错误:出口存在,但递归调用没有朝出口靠近,例如写成 fact(n + 1),同样停不下来。自查方法很简单:拿最小的输入(n = 0、1,空树、空表)代进去走一遍,看它是不是直接返回;再看每次递归调用的参数是不是在变小。
再看两个例子
数组求和(递归版)
// 返回 a[0..n-1] 的和 int sum(int a[], int n) { if (n == 0) return 0; // 空数组和为 0 return sum(a, n - 1) + a[n - 1]; // 前 n-1 个的和 + 最后一个 }
深度 n,时间 O(n),空间 O(n)。循环版空间只要 O(1)——递归不一定更好。
斐波那契(反面教材)
int fib(int n) { // 1,1,2,3,5,8,… if (n <= 2) return 1; return fib(n - 1) + fib(n - 2); }
每次分成两个调用,同一个 fib(k) 被重复算很多次,时间约 O(1.618n),是指数级;改成循环从小到大算只要 O(n)。
树的递归是本书用得最多的:“对一棵树做某事 = 对根做一下 + 对左子树递归 + 对右子树递归”,出口是“空树”。第 5 篇的遍历、求高度、求 WPL,第 9 篇的二叉树算法题,全是这个套路。
跟我做一遍:递归的“去”和“回”
void PrintRev(int a[], int n, int i) { // 逆序打印 a[i..n-1] if (i == n) return; // 出口 PrintRev(a, n, i + 1); // 先把后面的都打印完 printf("%d ", a[i]); // 回来以后再打印自己 }
| 阶段 | 发生了什么 | 已输出 |
|---|---|---|
| 去(压栈) | PrintRev(0) → PrintRev(1) → PrintRev(2) → PrintRev(3) → PrintRev(4) 到出口,一个字都还没打印 | — |
| 回(弹栈) | PrintRev(3) 打印 a[3] = 1 | 1 |
| PrintRev(2) 打印 a[2] = 4 | 1 4 | |
| PrintRev(1) 打印 a[1] = 1 | 1 4 1 | |
| PrintRev(0) 打印 a[0] = 3 | 1 4 1 3 |
写在递归调用后面的语句,要等“回来”时才执行,执行顺序和调用顺序相反——所以输出是逆序。把最后两句交换(先打印、后递归),输出就变成正序 3 1 4 1。树的后序遍历、逆序输出链表,用的都是“写在后面”这一点。(两种写法都已运行核对。)
- 下面函数 f(4) 返回多少?递归调用共发生几次(不含最外层)?
int f(int n) { if (n == 0) return 0; return f(n - 1) + n; }答案
f(4) = f(3) + 4 = … = 0 + 1 + 2 + 3 + 4 = 10。依次调用 f(3)、f(2)、f(1)、f(0),共 4 次。这正是 2015-01 里的函数 S(n)。 - 把上面的 PrintRev 改成“先 printf、再递归调用”,对 a = {2, 7, 5} 输出什么?最多同时有几层 PrintRev 在栈里?
答案
输出 2 7 5(正序);i = 0、1、2、3 共 4 层(i = 3 是出口那一层)。
0.5算法大题怎么答:结构、评分、书写规范
算法设计题的问法几乎一字不变:
……请设计一个在时间和空间两方面都尽可能高效的算法……要求:(1) 给出算法的基本设计思想;(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释;(3) 说明你所设计算法的时间复杂度和空间复杂度。
评分细则长什么样
以 2013-41(找主元素,13 分)的官方评分说明为例,分数按“你的算法有多高效”分档:
| 你写出的算法 | (1) 思想 | (2) 代码 | (3) 复杂度 | 合计上限 |
|---|---|---|---|---|
| 时间 O(n)、空间 O(1)(最优) | 4 | 7 | 2 | 13 |
| 时间 O(n)、空间 O(n)(计数数组) | 4 | 6 | 2 | 12 |
| 时间 O(n log n)(先排序再数) | 3 | 6 | 2 | 11 |
| 时间 O(n²)(两重循环暴力) | 3 | 5 | 2 | 10 |
- 暴力解也能拿 70% 以上的分。想不出最优解时,先把最直接的办法写对写完整,不要空着。
- 复杂度那一问的 2 分只看“和你写的代码是否一致”,写暴力解就老老实实写 O(n²),照样拿满。
- “时间上尽可能高效”的题(如 2018-41)允许用空间换时间:开一个辅助数组,时间 O(n)、空间 O(n) 就是满分答案。
- 思想写清楚很重要:评分说明几乎都有一句“若代码有小错但思想正确,可酌情给分”。
答题卡上的三段式模板
- 基本设计思想(4~5 行):先用一句话说总体策略(“两个指针一前一后”“先逆置后半段再合并”“用计数数组标记出现过的数”),再用 2~3 句说关键步骤,必要时画一个小图或举一个小例子。
- 代码:先写数据类型定义(题目给了就直接用题目的),再写函数。变量名有意义(count、pre、slow/fast),每个循环前面写一行注释说它在干什么,边界处写注释(“p 走到空说明 k 超过表长”)。
- 复杂度:写“时间复杂度 O(n),空间复杂度 O(1)”,并用半句话说明理由(“只扫描一趟数组”“只用了常数个辅助变量”)。
示范答卷:每一段的得分点在哪
(1) 基本设计思想(得分点:说出“一趟扫描”“保留的往前放”“用 k 计数”,约 4 分)
用 k 记录已保留的元素个数。从头到尾扫描一趟:当前元素不等于 x 就把它放到第 k 个位置、k 加 1;等于 x 就跳过。扫描结束后令表长为 k。每个元素只被移动一次。
(2) 代码(得分点:循环与判断正确、k 的维护、表长更新,约 7 分;注释写在关键处)
void DelX(SqList &L, ElemType x) { int k = 0; // k:已经保留的元素个数 for (int i = 0; i < L.length; i++) if (L.data[i] != x) // 不是 x:保留,放到第 k 个位置 L.data[k++] = L.data[i]; L.length = k; // 新表长 }
(3) 复杂度(得分点:与代码一致,2 分)
只扫描一趟,时间复杂度 O(n);只用了 k、i 两个变量,空间复杂度 O(1)。
如果只想到暴力做法(每遇到一个 x 就调用一次 ListDelete,后面元素整体前移):思想写清楚、代码写对、复杂度如实写“时间 O(n²)、空间 O(1)”,按这类题的惯例仍能拿到大部分分。(DelX 已编译,随机对拍 1 万组。)
代码书写规范与常见扣分点
| 要求 | 说明 |
|---|---|
| 函数头与题目一致 | 题目给了原型(如 int IsExistEL(MGraph G))就一字不差地用;没给就自己定义,参数要包含所需的一切(数组要同时传长度 n)。 |
| 判空、越界 | 链表题先考虑空表、只有一个结点;数组题考虑 n = 1;访问 p->next 之前确认 p 不空。 |
| 变量都要定义、初始化 | 计数器从 0 开始、最小值初值取足够大(如 INT_MAX);辅助数组要清零。 |
| 结构体用 ->,数组用 [] | G 是结构体变量时写 G.Edge[i][j],是指针时写 G->Edge[i][j]。 |
| 可以用 C++ 的便利 | bool、引用 &、new、swap 都可以用;但不要调用 STL 的 sort、stack、queue 来代替题目要考的核心过程(除非题目本身不考它,例如“先排序再处理”的思路里写一句“用快速排序”是允许的,但会按 O(n log n) 给分)。 |
| 不要写 main、输入输出 | 只写题目要求的函数;需要输出时用 printf 一句即可。 |
读懂题目给的类型定义
近几年的图、树大题常常先给出一段 typedef,要求“基于这个定义写算法”。拿到它先做三件事:认出每个成员是什么、想好怎么访问、照着它的名字写。以图题常见的邻接矩阵定义为例:
typedef struct { // 题目常给的邻接矩阵定义 int numVertices, numEdges; // 顶点数、边数 char VerticesList[MAXV]; // 顶点表 int Edge[MAXV][MAXV]; // 邻接矩阵,Edge[i][j] == 1 表示有边 } MGraph; int Degree(MGraph G, int v) { // 无向图顶点 v 的度 int d = 0; for (int j = 0; j < G.numVertices; j++) if (G.Edge[v][j] == 1) d++; return d; }
- 认成员:顶点个数是
G.numVertices,不是自己设的 n;“i 到 j 有没有边”是G.Edge[i][j];顶点的名字在G.VerticesList[i],题目要求“输出顶点”时输出它,而不是输出下标 i。 - 想访问方式:参数是结构体变量
MGraph G就用点号G.Edge;若题目写的是MGraph *G,就要用G->Edge。一行里 1 的个数是度(有向图是出度),一列里 1 的个数是入度。 - 照着名字写:成员名、函数名、参数类型都和题目一字不差。自己再定义新的结构体去“改造”题目,阅卷时很难给分。
验证一下:4 个顶点、边 a–b、a–c、b–c、c–d,用 Degree 算出的度依次是 2、2、3、1,加起来 8 = 2 × 边数——握手定理也顺手检查了代码。二叉树题同理:先看结点里有哪些域(data、weight、left、right……),叶结点怎么判断(左右孩子都为 NULL),再动笔。(代码已编译运行核对。)
考场上的顺序与检查
- 先读完整道题,把所有限制条件圈出来:“时间尽可能高效”“空间 O(1)”“不能破坏原链表”“给出的类型定义”。漏看一个条件,后面全白写。
- 先写思想再写代码:思想写清楚了,代码即使没写完,思想分也能拿到;思想里顺手写一个小例子,阅卷老师一眼就能看懂你的方法。
- 代码写完立刻“跑”一遍:拿 2 ~ 3 个元素的小例子,在草稿纸上按跟踪表(0.2 节)走一遍,重点看边界:空表、只有一个元素、第一个和最后一个元素。
- 最后写复杂度,并对照代码检查一致:几重循环、有没有辅助数组、有没有递归。
- 时间不够时的取舍:宁可交一份完整、正确的较慢解法,也不要交一份写了一半的最优解——前者通常能拿到大部分分数,后者往往只有思想分。
- 数组下标从 1 开始用到 n,越界了(C 数组下标 0 ~ n−1)。
- 链表删除结点时先 free 再用它的 next(应该先保存后继)。
- 把
==写成=。 - 循环条件写错一位:
i <= n还是i < n?每次写完用一个 2~3 个元素的小例子走一遍。 - 思想写了一种方法,代码写的是另一种;或者复杂度写的和代码不一致。
- 写出了正确的 O(n²) 暴力代码,复杂度一问却写成 O(n),会怎样?
答案
复杂度那一问的分数拿不到——它看的是“与你的代码是否一致”。如实写 O(n²) 反而能拿满这一问。 - 题目要求“时间上尽可能高效”,用一个大小为 n 的辅助数组可以吗?
答案
可以。只强调时间时允许用空间换时间(如 2018-41);若题目明确要求空间 O(1),才不能开数组。
7 % 3、7 / 3、-7 / 2在 C 语言中分别等于多少?答案
1、2、−3(C99 起整数除法向零取整,−3.5 舍成 −3)。LNode *p = L->next;和LNode p = *(L->next);有什么区别?答案
前者让 p 指向第一个结点(复制地址,不复制结点);后者把第一个结点整个复制一份到局部变量 p 里,改 p.data 不影响链表。写链表算法永远用前者。- 一个递归函数最多同时有 20 层调用在栈里,每层局部变量占常数空间,它的空间复杂度是多少?
答案
和递归深度成正比。若深度是 20 这样的常数,空间 O(1);若深度随 n 增长(如 n 层),空间 O(n);二叉树的递归遍历深度等于树高 h,空间 O(h)。
绪论:数据结构到底研究什么,怎样衡量算法的好坏
这一篇讲两件事:一是数据结构的基本概念——逻辑结构、存储结构、运算这“三要素”,它们是后面每一章的骨架;二是衡量算法好坏的尺子——时间复杂度和空间复杂度。复杂度几乎每年都出一道选择题(通常是第 1 题),并且每道算法大题都要写复杂度,是必须练到“看一眼就会算”的内容。
1.0本篇地图:概念 + 一把尺子
本篇 8 道选择题全是复杂度(几乎年年第 1 题),概念部分虽然没有单独考过,却是后面每一章都要用的“语言”。学法:概念部分理解即可,复杂度部分要练到看一眼代码就能写出执行次数。
为什么要先学绪论:后面每一章的结构都一样——先讲逻辑结构(元素之间什么关系、能做哪些操作),再讲存储结构(顺序存还是链式存),最后讲在这种存法上运算怎么实现、复杂度是多少。绪论就是这套“三段式”的说明书;复杂度则是比较各种方案的统一尺子。这两样一旦熟了,后面每章都只是往这个框架里填内容。
| 小节 | 学完要会的事 | 真题 | 难度 | 学时 |
|---|---|---|---|---|
| 1.1 基本概念 | 术语层级;逻辑结构与存储结构的判定;四种存储 | — | ★ | 1 h |
| 1.2 算法 | 五个特性、四个目标 | — | ★ | 0.5 h |
| 1.3 时间复杂度 | 三类循环逐题数次数;最好 / 最坏 / 平均 | 8 | ★★ | 2 h |
| 1.4 空间与递归 | 原地工作、递归栈深度、递推式展开 | 1 | ★★ | 1 h |
复杂度题不要“凭感觉”。固定三步:① 找最内层的基本语句;② 设它执行了 t 次,写出 t 和 n 的关系(求和或解不等式);③ 只留最高阶。本篇每个例子都给了“n 取几个值时实际执行了几次”的表,可以用来检验自己的推导。
1.1基本概念:数据、数据结构的三要素
数据结构研究什么
同样一万个人的通讯录:按姓名排好,用折半查找最多比较 14 次就能找到一个人;乱放着,最坏要比较一万次。数据怎么组织,决定了处理它要花多少力气——这就是数据结构这门课要研究的。
图书的分类关系(哲学 → 西方哲学 → 古希腊……)是逻辑结构;书在书架上实际怎么摆是存储结构;借书、还书、查书是运算。哪里不像:书架是三维空间,可以按类别分区摆;计算机内存却只是一长串编号连续的格子(一维),树、图这样“一对多”“多对多”的关系,必须想办法“压扁”成一维存进去——要么靠位置(顺序),要么靠指针(链式)。这正是存储结构值得单独研究的原因。
先看一个生活里的例子:学校的学生信息表。
| 学号 | 姓名 | 性别 | 成绩 |
|---|---|---|---|
| 2026001 | 张三 | 男 | 95 |
| 2026002 | 李四 | 女 | 88 |
| 2026003 | 王五 | 男 | 91 |
| 术语 | 定义 | 在上表中是 |
|---|---|---|
| 数据 | 信息的载体,能输入计算机并被程序处理的符号的总称 | 整张表里的所有内容 |
| 数据元素 | 数据的基本单位,通常作为一个整体考虑和处理(也叫元素、结点、记录) | 一行(一个学生的记录) |
| 数据项 | 构成数据元素的不可分割的最小单位 | 学号、姓名、性别、成绩这一格 |
| 数据对象 | 性质相同的数据元素的集合,是数据的一个子集 | 全体学生记录;又如整数集合 {…, −1, 0, 1, …} |
| 数据类型 | 一个值的集合和定义在这个集合上的一组操作的总称 | int:值是整数、操作是加减乘除取余…… |
| 数据结构 | 相互之间存在一种或多种特定关系的数据元素的集合 | “按学号先后排好的一串记录” |
数据类型分三类:原子类型(值不可再分,如 int、char)、结构类型(值可以再分成若干成分,如结构体、数组)、抽象数据类型 ADT(一个数学模型 + 定义在模型上的一组操作,只描述“是什么、能做什么”,不管“怎么存、怎么实现”)。例如 ADT 栈:数据是一列元素,操作有 Push、Pop、GetTop、判空。用数组实现还是用链表实现,是存储层面的事。
ADT 只回答两个问题:数据是什么、能对它做什么,一个字都不提“怎么存、怎么算”。
ADT Complex {
// 数据:一对实数 (re, im),表示 re + im·i
// 操作:
Create(&z, a, b); // 构造复数 z = a + b·i
GetReal(z); // 返回实部
GetImag(z); // 返回虚部
Add(z1, z2); // 返回 z1 + z2
Multiply(z1, z2); // 返回 z1 × z2
}
至于实部、虚部是存成两个 float 还是一个长度为 2 的数组,Multiply 里用哪个公式计算——那是“实现”的事,写 ADT 的时候不管。这种“先说清楚要什么、再决定怎么做”的分工,就是后面每一章的套路:先讲逻辑结构和基本操作(ADT),再讲顺序 / 链式两种实现。
为什么要把逻辑结构和存储结构分开:同一个“线性表”,有时用数组存最合适(经常按位置找),有时用链表存最合适(经常插入删除)。如果把“元素之间是什么关系”和“内存里怎么放”绑在一起,换一种存法就得重新定义整个问题;分开以后,逻辑结构和操作的定义不变,只换实现——用户代码只调用 ListInsert,根本不用知道底下是数组还是链表。这和生活里“开车只管方向盘和油门,不管发动机怎么造”是一个道理。选择题里“逻辑结构独立于存储结构”“运算的定义依赖逻辑结构、实现依赖存储结构”这类说法,考的就是这一层意思。
数据结构的三要素
逻辑结构的四种基本类型
| 类型 | 关系 | 例子 | 分类 |
|---|---|---|---|
| 集合 | 元素之间除了“同属一个集合”没有别的关系 | 一袋弹珠 | 非线性 |
| 线性结构 | 一对一:除第一个和最后一个外,每个元素恰有一个前驱、一个后继 | 排队的人;线性表、栈、队列、串、(一维)数组 | 线性 |
| 树形结构 | 一对多:每个元素最多一个前驱(双亲),可以有多个后继(孩子) | 家谱、文件目录 | 非线性 |
| 图状结构 | 多对多:任意两个元素之间都可能有关系 | 城市间的公路网、社交网络 |
边界上的几个“归属”:栈、队列、串都是线性结构(只是限制了操作或元素类型);一维数组是线性结构,二维、三维数组可以看成“元素本身又是一个线性表”的线性表,教材把它们算作线性表的推广;循环链表是线性表的一种存法,首尾相接并不改变“一对一”的逻辑关系。树也可以看成一种特殊的图(连通、无回路),但在逻辑结构的分类里,树和图分开算。
教材常把数据结构写成二元组:D 是数据元素的集合,R 是元素之间关系的集合,〈x, y〉 表示 x 是 y 的前驱。设 D = {a, b, c, d}:
| R | 画出来 | 结构 |
|---|---|---|
| 空集 | 四个孤立的点 | 集合 |
| {〈a, b〉, 〈b, c〉, 〈c, d〉} | a → b → c → d,每个至多一个前驱、一个后继 | 线性 |
| {〈a, b〉, 〈a, c〉, 〈b, d〉} | a 有两个后继 b、c;每个元素至多一个前驱 | 树 |
| {〈a, b〉, 〈a, c〉, 〈b, d〉, 〈c, d〉} | d 有两个前驱 b、c | 图 |
判断只看两件事:有没有元素有两个以上的前驱(有就是图);没有的话,有没有元素有两个以上的后继(有就是树,没有就是线性)。关系为空就是集合。(四种情形由程序按此规则判定核对。)
四种存储结构的对比
| 存储方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 顺序存储 | 逻辑上相邻的元素存放在物理位置上也相邻的单元里(数组) | 可以随机存取(按下标一步找到);每个元素只存数据,存储密度 = 1 | 插入删除要移动大量元素;要预先分配连续空间,可能产生碎片 |
| 链式存储 | 不要求物理相邻,用指针表示元素之间的逻辑关系 | 插入删除只改指针;不需要连续空间,不会有外部碎片 | 只能顺序存取(从头一个个找);指针占额外空间,存储密度 < 1 |
| 索引存储 | 存数据的同时建一张索引表,索引项一般是(关键字,地址) | 检索速度快 | 索引表占空间;插删时要改索引表 |
| 散列存储 | 根据关键字直接计算存储地址(哈希) | 检索、插入、删除都很快(平均 O(1)) | 散列函数设计不好会有大量冲突,处理冲突要付出时间和空间 |
- 逻辑结构独立于存储结构:同一逻辑结构可以有多种存储结构(线性表可以顺序存、也可以链式存);但存储结构离不开逻辑结构(它存的就是某种逻辑结构)。
- “有序表”是逻辑概念:它只说元素按关键字有序排列,既可以顺序存(有序顺序表)也可以链式存(有序链表)。
- “循环队列”“顺序表”“单链表”“哈希表”是带存储方式的概念:循环队列是用数组实现的队列,说的是存储结构;栈、队列、线性表本身是逻辑结构。
- “数据元素是基本单位、数据项是最小单位”,不要记反。
- 存储结构影响运算的效率:同样是“访问第 i 个元素”,顺序表 O(1),链表 O(n)。
跟我做一遍:是逻辑结构还是存储结构
| 名词 | 属于 | 理由 |
|---|---|---|
| 线性表、栈、队列、串 | 逻辑 | 只规定一对一关系和操作位置,没说怎么存 |
| 有序表 | 逻辑 | 只说按关键字有序,可以顺序存,也可以链式存 |
| 树、二叉树、二叉排序树、图 | 逻辑 | 描述的是一对多、多对多关系 |
| 顺序表、单链表、双链表、静态链表 | 存储 | “顺序”“链”就是存法 |
| 循环队列 | 存储 | 用数组首尾相接实现的队列(3.3) |
| 散列表(哈希表) | 存储 | 按关键字计算地址 |
| 线索二叉树 | 存储 | 利用二叉链表的空指针存前驱 / 后继,是二叉链表的一种存法(5.6) |
| 邻接矩阵、邻接表 | 存储 | 图的两种存法(6.2) |
一句话检验:换一种存法,它还叫这个名字吗?有序表换成链表存仍叫有序表 ⇒ 逻辑结构;循环队列换成链表存就不叫循环队列了 ⇒ 存储结构。
- “数据元素是数据的最小单位”对吗?
答案
不对。数据元素是基本单位,数据项才是最小单位。 - 栈和顺序栈,哪个是逻辑结构?
答案
栈是逻辑结构(只规定后进先出);顺序栈是用数组实现的栈,是存储结构。 - D = {1, 2, 3, 4},R = {〈1, 2〉, 〈1, 3〉, 〈3, 4〉},这是什么逻辑结构?若再加上 〈2, 4〉 呢?
答案
每个元素至多一个前驱、1 有两个后继 ⇒ 树。加上 〈2, 4〉 后,4 有两个前驱 2、3 ⇒ 图。
考纲补充(尚无真题):逻辑结构与存储结构
数据结构的基本概念是考纲点,2009—2026 年没有单独出过选择题,下面一道按考纲自编。
- A. 有序表
- B. 循环队列
- C. 散列表
- D. 线索二叉树
展开解析
A有序表只规定元素按关键字有序,用数组存、用链表存都可以,是逻辑结构。循环队列是用数组首尾相接实现的队列,散列表按关键字计算存储地址,线索二叉树是利用空指针存线索的二叉链表——它们都带着“怎么存”,是存储结构。
变化点:检验方法“换一种存法还叫这个名字吗”。见 1.1 跟做。
1.2算法与算法评价
算法可以怎么写:自然语言(“先比较 a、b,大的再和 c 比……”)、流程图(下面的辗转相除法)、伪代码、真正的程序代码都可以描述同一个算法。考研大题要求“给出算法的基本设计思想”时用自然语言分步写清楚;要求“用 C 或 C++ 描述算法”时才写代码。
算法是对特定问题求解步骤的一种描述,是指令的有限序列,每条指令表示一个或多个操作。程序 = 数据结构 + 算法:数据结构决定“数据怎么放”,算法决定“怎么一步步处理”。
算法的五个特性(缺一不可)
| 特性 | 含义 | 反例 |
|---|---|---|
| 有穷性 | 必须在执行有穷步之后结束,且每一步都在有穷时间内完成 | 操作系统是一直运行的程序,不是算法(程序可以不满足有穷性) |
| 确定性 | 每条指令都有确切含义,相同的输入只能得到相同的输出 | “把 x 加上一个比较大的数” |
| 可行性 | 描述的操作都能通过已实现的基本运算执行有限次来实现 | “计算 π 的全部位数” |
| 输入 | 有零个或多个输入 | —(可以没有输入) |
| 输出 | 有一个或多个输出 | 一个不产生任何结果的过程没有意义 |
“好”算法的四个目标
- 正确性:能正确地解决问题。
- 可读性:便于人理解(注释、有意义的变量名)。
- 健壮性:输入非法数据时能适当地做出反应,而不是莫名其妙地崩溃(例如 k 大于表长时返回 0)。
- 高效率与低存储量需求:执行时间短(时间复杂度低)、占用空间少(空间复杂度低)。
正确性和健壮性别混:正确性管的是“合法输入下答案对不对”,健壮性管的是“非法输入下会不会出乱子”。一个求平均分的程序,输入 3 个成绩算对了,是正确;输入 0 个成绩时不做“除以 0”而是给出提示,是健壮。408 大题写代码时,开头那句 if (i < 1 || i > L.length) return false; 体现的就是健壮性——评分时常常是单独的一个得分点。
为什么不直接“上机测时间”来比较算法?因为运行时间还受机器速度、编译器、输入数据的影响。我们要的是一个只和问题规模 n 有关、能在纸上算出来的指标——这就是复杂度。
| 算法 | 程序 | |
|---|---|---|
| 是什么 | 解决问题的步骤(可以用自然语言、流程图、伪代码描述) | 用某种程序设计语言写出来、能在机器上运行的代码 |
| 必须有穷吗 | 必须,有穷性是五个特性之一 | 不一定(操作系统、服务器一直运行) |
| 关系 | 程序是算法用某种语言的实现;同一个算法可以写成 C、Java、Python 等不同程序 | |
408 大题要求“描述算法思想”时,用文字说清楚步骤就行,那是在写算法;要求“用 C 或 C++ 描述算法”时才是写程序。前者答清楚了,后者写不全也能拿到思想分。
跟我做一遍:在一个算法上看五个特性
| 轮 | m | n | r = m % n | 判断 |
|---|---|---|---|---|
| 1 | 48 | 18 | 12 | r ≠ 0,m = 18,n = 12 |
| 2 | 18 | 12 | 6 | r ≠ 0,m = 12,n = 6 |
| 3 | 12 | 6 | 0 | r = 0,输出 6 |
再看几个“不是算法”的描述各违背了哪一条:
| 描述 | 违背 |
|---|---|
x = 1; while (x > 0) x++;(假设整数不会溢出) | 有穷性:永远不停 |
| “把 x 加上一个比较大的数” | 确定性:“比较大”没有确切含义,不同的人得到不同结果 |
| “写出圆周率的全部数字” | 可行性(也违背有穷性):无法用有限次基本运算完成 |
| 操作系统:开机后一直运行,等待用户的命令 | 有穷性:它是程序,但不是算法 |
- 算法一定要有输入吗?一定要有输出吗?
答案
输入可以有 0 个或多个(例如“输出 1 ~ 100 的和”不需要输入);输出至少 1 个。 - “输入非法数据时给出提示,而不是程序崩溃”体现的是好算法的哪个目标?
答案
健壮性。 - 判断:“程序一定满足有穷性。”“算法一定满足有穷性。”
答案
前一句错:操作系统、服务器程序开机后一直运行,是程序但不是算法。后一句对:有穷性是算法的五个特性之一。
考纲补充(尚无真题):算法的特性
算法的五个特性是考纲点,尚无单独的真题,下面一道按考纲自编。
- A. 算法至少要有一个输入
- B. 算法必须在有限步之后结束,程序则不一定
- C. 算法就是程序
- D. 只要能得到正确结果,算法的每一步可以有多种含义
展开解析
B有穷性是算法的特性,而程序(如操作系统)可以一直运行不结束,所以“算法 ≠ 程序”(C 错)。A 错:输入可以是零个;D 错:确定性要求每条指令有确切含义、相同输入得到相同输出。
变化点:输出至少一个、输入可以没有,这两条最容易记反。见 1.2 跟做。
1.3时间复杂度:算法的“快慢”怎么算
从“语句频度”到大 O
一条语句在算法中被重复执行的次数叫它的频度。算法中所有语句的频度之和记为 T(n),n 是问题规模(数组长度、结点个数……)。例如:
for (i = 0; i < n; i++) // 条件判断执行 n+1 次 for (j = 0; j < n; j++) // 条件判断执行 n(n+1) 次 c[i][j] = 0; // 执行 n² 次 ← 最深层的“基本语句”
T(n) 是一个多项式(下面会具体算出它是 2n² + 2n + 1)。n 很大时,起决定作用的只有增长最快的那一项,而且系数也不重要(换台快一倍的机器,系数就变了)。于是只保留最高阶项、去掉系数,记作 T(n) = O(n²)。
用人话说:T(n) = O(n²) 的意思是“n 足够大以后,T(n) 不会超过 n² 的某个固定倍数”。上面的 T(n) = 2n² + 2n + 1,取 C = 3、n₀ = 3,当 n ≥ 3 时 2n² + 2n + 1 ≤ 3n² 都成立(n = 3 时 25 ≤ 27),所以它是 O(n²)。定义里“存在 C 和 n₀”的意思就是:允许忽略系数,也允许忽略 n 很小时的情况——这正是“只看最高阶、不看系数”的数学依据。
为什么只看最高阶:算一算就明白
上面三行代码,按“条件判断 + 赋值”数一数:外层条件判断 n + 1 次,内层条件判断共 n(n + 1) 次,赋值 n² 次,合计 T(n) = 2n² + 2n + 1。把它和 n² 比一比:
| n | T(n) = 2n² + 2n + 1 | n² | T(n) ÷ n² | 低次项 2n + 1 占 T(n) 的比例 |
|---|---|---|---|---|
| 10 | 221 | 100 | 2.21 | 9.5% |
| 100 | 20 201 | 10 000 | 2.02 | 1.0% |
| 1000 | 2 002 001 | 1 000 000 | 2.002 | 0.1% |
n 越大,低次项越无足轻重,T(n) 越来越像“2 倍的 n²”;而系数 2 又会因机器、编译器不同而变。所以只保留“n²”这个增长的形状,写成 O(n²)。(T(n) 由程序逐条计数核对。)
两条运算规则
加法规则:顺序的几段取最大
T(n) = T₁(n) + T₂(n) = O(max(f(n), g(n)))
先一个 O(n) 的循环,再一个 O(n²) 的双重循环,总的是 O(n²)。
常见的阶,从小到大
| n | log₂n | √n | n | n log₂n | n² | 2ⁿ |
|---|---|---|---|---|---|---|
| 10 | 3.3 | 3.2 | 10 | 33 | 100 | 1024 |
| 100 | 6.6 | 10 | 100 | 664 | 10⁴ | 1.3 × 10³⁰ |
| 10⁶ | 20 | 1000 | 10⁶ | 2 × 10⁷ | 10¹² | 天文数字 |
最好、最坏、平均
同一个算法,输入不同,执行次数可能不同。例如在数组中顺序查找 x:第一个就是 x,比较 1 次(最好);x 在最后或不存在,比较 n 次(最坏);各位置概率相等时平均比较 (n+1)/2 次(平均)。一般说的时间复杂度指最坏情况,它给出了运行时间的上限;平均复杂度是所有输入等概率时的期望。
有时三种情况差得很远,就要分开说:快速排序最坏 O(n²)(输入已经有序时,第 8 篇),平均却是 O(n log n),而且实际中最坏情况很少碰到,所以介绍快排时总是“平均 O(n log n)、最坏 O(n²)”一起说。做题时题目问哪一种就答哪一种;没说明时按最坏情况答,因为最坏情况是一个“无论如何都不会更差”的保证。
设查找成功的概率为 p,成功时 x 在各位置的概率相等;失败时要比较完全部 n 个。则平均比较次数 = p × (n + 1)/2 + (1 − p) × n。例如 n = 10、p = 1/2:(1/2) × 5.5 + (1/2) × 10 = 7.75 次。p = 1 时就是常说的 (n + 1)/2。平均复杂度依赖于“输入的概率分布”,所以一般默认讨论最坏情况。(公式与逐项求和由程序对 n = 1 ~ 29 核对。)
做题套路:六种循环怎么数次数
算时间复杂度就是数“最内层基本语句执行了几次”。下面六种情形覆盖了全部真题:
| 代码特征 | 怎么数 | 结果 | 真题 |
|---|---|---|---|
① 变量每次加常数:i += 1、i += 3 | 从初值走到 n,走了 n / 步长 次 | O(n) | — |
② 变量每次乘常数:i *= 2 | 设执行 t 次,2t ≈ n,t ≈ log₂n | O(log n) | 2011-01 |
| ③ 两层循环互相独立 | 外层次数 × 内层次数 | 如 O(n log n) | 2014-01 |
| ④ 内层次数依赖外层变量 | 把每次外层对应的内层次数加起来(等差 / 等比求和) | O(n²) 或 O(n) | 2022-01、2025-01 |
⑤ 循环条件是个表达式:while (sum < n)、(x+1)² ≤ n | 设执行 t 次,写出 t 次后变量的值,解不等式 | 常见 O(√n) | 2017-01、2019-01 |
| ⑥ 递归 | 写出递推式 T(n) = …,展开求和(见 1.4) | — | 2012-01 |
跟着做一遍:套路 ②③④
for (i = 1; i <= n; i++) for (j = 1; j <= i; j++) x++;
展开解析
外层 i = 1 时内层执行 1 次,i = 2 时 2 次……i = n 时 n 次。x++ 共执行 1 + 2 + … + n = n(n+1)/2 次,最高阶 n²/2,去掉系数得 O(n²)。
如果把内层改成 j <= i*i,次数是 1² + 2² + … + n² = n(n+1)(2n+1)/6,是 O(n³)。
for (i = 1; i < n; i *= 2) for (j = 0; j < i; j++) sum++;
展开解析
外层 i 取 1, 2, 4, …, 2k(2k < n),内层每次执行 i 次。总次数 = 1 + 2 + 4 + … + 2k = 2k+1 − 1 < 2n。所以是 O(n),不是 O(n log n)!
容易错在“外层 log n 次、内层最多 n 次,乘起来 n log n”——乘法规则只适用于内层次数与外层无关的情况;内层依赖外层时必须求和。等比数列的和由最后一项决定。
x = 2;
while (x < n / 2)
x = 2 * x;- A. O(log₂n)
- B. O(n)
- C. O(n log₂n)
- D. O(n²)
展开解析
A循环变量 x 每次翻倍,从 2 增长到 n/2 附近只需要很少几步。
- 设循环体执行了 t 次,此时 x = 2 × 2t = 2t+1。
- 循环在 x ≥ n/2 时结束:2t+1 ≥ n/2,即 t ≥ log₂n − 2。
- 所以执行次数约 log₂n − 2,数量级 O(log₂n)。
- A 对乘法步长的循环都是对数级。
- B 错O(n) 是步长为常数加法(x = x + 2)时的结果。
- C 错只有一层循环,没有乘上 n 的理由。
- D 错两重独立的 n 次循环才是 O(n²)。
i *= 2(或 /= 2)的循环执行 log₂n 次;初值、终值差常数倍只影响常数项。
int func(int n) { int i = 0, sum = 0; while (sum < n) sum += ++i; return i; }
- A. O(log n)
- B. O(n1/2)
- C. O(n)
- D. O(n log n)
展开解析
Bsum += ++i 表示先把 i 加 1,再把新的 i 加到 sum 上:sum 依次加上 1, 2, 3, …
- 循环执行 t 次后,i = t,sum = 1 + 2 + … + t = t(t + 1)/2。
- 循环在 sum ≥ n 时结束:t(t + 1)/2 ≥ n ⇒ t ≈ √(2n)。
- 执行次数是 √n 的常数倍 ⇒ O(n1/2)。
- A 错sum 是按等差数列增长(平方级),不是按等比(指数级)增长。
- B 对√(2n) 次。
- C 错误以为 sum 每次加 1。
- D 错只有一层循环。
累加量逐次增大的循环:写出 t 次后的值,解 “值 ≥ n”。和是 t² 级 ⇒ t 是 √n 级。
- 设循环体执行了 t 次。
- 写出执行 t 次后循环变量的值(用 t 表示)。例:sum = 1 + 2 + … + t = t(t+1)/2。
- 循环结束的条件是“变量达到 n”,列方程:t(t+1)/2 ≥ n。
- 解出 t 的数量级:t ≈ √(2n),所以 O(√n)。
三类循环各两题:每题都有“执行次数表”
下面 6 段代码都在电脑上真的跑过,表里是 x++(或循环体)实际执行的次数,用来检验推导。
| # | 代码 | 推导 | 实际次数(n = …) | 结果 |
|---|---|---|---|---|
| 类型 ①:循环条件里的变量在循环体中变化(解不等式) | ||||
| A1 | i = 1; | 执行 t 次后 i = t + 1,条件 (t + 1)² ≤ n 失败时停 ⇒ t = ⌊√n⌋ | n = 100:10 n = 10⁴:100 | O(√n) |
| A2 | s = 0; k = 0; | t 次后 s = 2 + 4 + … + 2t = t(t + 1),当 t(t + 1) > n 时停 ⇒ t ≈ √n | n = 100:10 n = 10⁴:100 | O(√n) |
| 类型 ②:倍增 / 倍减 | ||||
| B1 | i = n; | 每次减半,t 次后 i ≈ n / 2t ≤ 1 ⇒ t = ⌊log₂n⌋ | n = 8:3 n = 64:6 | O(log n) |
| B2 | for (i = 1; i <= n; i *= 3) | 外层 ⌊log₃n⌋ + 1 次,内层与外层无关、每次 n 次 ⇒ 相乘 | n = 9:27 n = 81:405 | O(n log n) |
| 类型 ③:内层次数依赖外层变量(求和) | ||||
| C1 | for (i = 1; i <= n; i++) | i = 1 时 n 次、i = 2 时 n − 1 次……共 n + (n − 1) + … + 1 = n(n + 1)/2 | n = 8:36 n = 64:2080 | O(n²) |
| C2 | for (i = n; i >= 1; i /= 2) | n + n/2 + n/4 + … < 2n(等比求和,由第一项决定) | n = 8:15 n = 64:127 | O(n) |
for (i = 1; i <= n; i++) for (j = 1; j <= i; j *= 2) x++;
- 固定 i,内层 j = 1, 2, 4, …, 不超过 i,执行 ⌊log₂i⌋ + 1 次。
- 总次数 = Σi=1n (⌊log₂i⌋ + 1)。前一半的 i 都 ≥ n/2,每项至少 log₂n 级;全部项又都不超过 log₂n + 1 ⇒ 总和在 (n/2)·log₂(n/2) 和 n·(log₂n + 1) 之间。
- 结论 O(n log n)。程序实测:n = 8 时 21 次,n = 16 时 54 次,n = 64 时 328 次;n = 2¹⁶ 时总次数与 n·log₂n 之比约 0.94,确实是同一个量级。
倍增实验:把 n 翻倍,看次数怎么变
推出复杂度以后怎么检验?最简单的办法是把 n 翻一倍,看基本语句的次数变成原来的几倍:
| 代码类型 | n = 1024 | n = 2048 | 翻倍后变成 | 特征 |
|---|---|---|---|---|
| i 每次减半(O(log n)) | 10 | 11 | +1 | n 翻倍,次数只多 1 |
| 一重循环(O(n)) | 1024 | 2048 | × 2 | 正好翻倍 |
| 外层倍增 × 内层 n(O(n log n)) | 11264 | 24576 | × 2.18 | 比翻倍略多 |
| 1 + 2 + … + n(O(n²)) | 524800 | 2098176 | × 4.0 | 变成 4 倍 |
规律:O(log n) 加常数、O(n) 乘 2、O(n log n) 乘略大于 2、O(n²) 乘 4、O(n³) 乘 8、O(2ⁿ) 则是平方——这就是 1.3 节各张“实际次数表”的读法。考场上推不出来时,把 n 代成 16 和 32(或 64)手数一下,比一比就能排除大部分选项。(表中数字由程序实际计数。)
规模有两个参数时:O(m + n) 还是 O(mn)
很多问题的规模不止一个数:两个链表长 m 和 n、图有 n 个顶点 e 条边。这时复杂度里要同时出现两个参数,判断方法还是“数基本语句”:
- 两段依次处理:先扫一遍长 m 的表,再扫一遍长 n 的表,是 m + n 次,写 O(m + n)。因为 max(m, n) ≤ m + n ≤ 2·max(m, n),O(m + n) 和 O(max(m, n)) 是同一个阶——选项里出现哪种写法都对。
- 嵌套处理:对长 m 的表里每个元素,都去扫一遍长 n 的表,是 m × n 次,写 O(mn)。例如 i 从 0 到 29、j 从 0 到 49 的双重循环执行 1500 次 = 30 × 50。
- 不能随便“化成一个 n”:只有题目说明 m、n 同阶(或 m ≤ n)时,才可以把 O(m + n) 写成 O(n)。两个有序链表归并,每比较一次就有一个结点被摘下来,最多 m + n − 1 次比较——下面 2013-01 考的正是这一点。
- A. O(n)
- B. O(m × n)
- C. O(min(m, n))
- D. O(max(m, n))
展开解析
D两个升序链表合并成降序:每次比较两个表的当前结点,把较小的那个摘下来用头插法插入结果链表(头插法会把次序倒过来,先插的最小元素最后在表尾)。
- 每比较一次就摘下一个结点,一个表摘完后,另一个表剩下的结点逐个头插即可(不再比较)。
- 最坏情况:两个表交替变小,一直比较到只剩最后一个元素,比较 m + n − 1 次;且无论如何每个结点都要头插一次,共 m + n 次操作。
- O(m + n)。由于 max(m, n) ≤ m + n ≤ 2 max(m, n),所以 O(m + n) = O(max(m, n))。
- A 错只和 n 有关,忽略了 m 可能远大于 n。
- B 错合并不需要让每个元素和另一个表的每个元素都比一次。
- C 错min(m, n) 是合并时比较次数的最好情况(较短的表整体都比另一个表小,比 min(m, n) 次它就走完了)。本题问最坏,而且降序要求每个结点都逐个头插,总时间是 m + n 级别。
- D 对O(m + n) 与 O(max(m, n)) 是同一个量级,选项里只给了后者。
O(m + n) = O(max(m, n)):两个数的和与其中较大者只差不超过 2 倍。
真题怎么考:8 道题,3 种套路
| 真题 | 代码特征 | 套路 |
|---|---|---|
| 2011-01 | x = 2 × x 直到 x ≥ n/2 | ② 倍增:解 2ᵗ ≈ n |
| 2014-01 | 外层 k *= 2,内层 j 从 1 到 n,与 k 无关 | ③ 独立相乘 |
| 2022-01 | 外层 i *= 2,内层 j < i,依赖外层 | ④ 等比求和,不能相乘 |
| 2025-01 | 外层 i·i < n,内层 j < i | ⑤ + ④:外层只到 √n,再对 i 求和 |
| 2017-01、2019-01 | while (sum < n) sum += ++i;while (n ≥ (x + 1)²) x++ | ⑤ 解不等式,得 √n 级 |
| 2012-01 | 递归求阶乘 | ⑥ 递推式 T(n) = T(n − 1) + O(1) |
| 2013-01 | 两个有序链表合并 | 两个规模参数,见上面 |
秒杀法什么时候会失灵:① “外层 log n 次 × 内层 n 次 = n log n”只在内层次数与外层无关时成立,2022-01 就是专门设的陷阱;② “看到 i*i 就是 √n”要小心——2025-01 里外层确实只走 √n 次,但内层还要再跑 i 次,总次数是 1 + 2 + … + √n ≈ n/2,答案是 O(n) 而不是 O(√n);③ 递归不能数代码行,要写递推式。拿不准时就把 n 代成 16、64 这样的数,实际数一数,用两个数的比值猜阶。
i = 1; while (i <= n) i = i * 2 + 1;循环执行几次?复杂度是多少?答案
i 依次是 1、3、7、15……= 2t − 1,约 log₂n 次,O(log n)(程序实测 n = 15 时 4 次)。for (i = 0; i < n; i++) for (j = 0; j < 100; j++) x++;是 O(n²) 吗?答案
不是。内层固定 100 次,与 n 无关,总次数 100n,O(n)。- 下面两段代码的时间复杂度各是多少(m、n 无关)?①
for (i = 0; i < m; i++) for (j = 0; j < n; j++) x++;②for (i = 0; i < m; i++) x++; for (j = 0; j < n; j++) y++;答案
① 嵌套,执行 m × n 次,O(mn)(m = 7、n = 5 时 35 次);② 先后两段,m + n 次,O(m + n)(即 O(max(m, n)),同一组数是 12 次)。 - 某段代码在 n = 1000 时执行 2 万次,n = 2000 时执行 8 万次,它最可能是什么阶?若 n 翻倍时次数从 20 变成 21 呢?
答案
翻倍后变 4 倍,最可能是 O(n²);只多 1,是 O(log n)(见“倍增实验”)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
count = 0; for (k = 1; k <= n; k *= 2) for (j = 1; j <= k; j++) count++;
- A. O(log2n)
- B. O(n)
- C. O(nlog2n)
- D. O(n2)
展开解析
B外层 k 取 1, 2, 4, …, 2t(2t ≤ n),内层执行 k 次。总次数 = 1 + 2 + 4 + … + 2t = 2t+1 − 1 < 2n,所以是 O(n)。
变化点:原题内层上界是 n(与 k 无关),总次数 = 外层 log2n 次 × n = O(nlog2n);改成 k 后,内层次数随外层变化,要把每一轮的次数加起来(等比数列求和),不能直接相乘。
int func(int n) { int i = 0, sum = 0; while (sum < n) { i++; sum += i * i; } return i; }
- A. O(log2n)
- B. O(n1/3)
- C. O(n1/2)
- D. O(n)
展开解析
B循环 t 次后 sum = 12 + 22 + … + t2 = t(t+1)(2t+1)/6 ≈ t3/3。循环在 t3/3 ≥ n 时结束,t ≈ (3n)1/3,即 O(n1/3)。
变化点:原题每次加 i,sum ≈ t2/2,得 O(n1/2);现在加 i2,sum 按 t3 增长,于是开三次方。方法不变:设循环执行 t 次,写出循环变量与 t 的关系,代入结束条件解出 t。
int sum = 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j += i) sum++;
- A. O(n)
- B. O(nlog2n)
- C. O(n3/2)
- D. O(n2)
展开解析
Bi 固定时,j 从 1 开始每次加 i,执行约 n/i 次。总次数 ≈ n/1 + n/2 + … + n/n = n(1 + 1/2 + … + 1/n) ≈ n·ln n,即 O(nlog2n)(对数的底只差常数倍)。
变化点:原题内层次数是 i(越来越多),这里内层步长是 i(次数越来越少)。1 + 1/2 + … + 1/n 叫调和级数,约等于 ln n,记住这个结论。
- A. 1
- B. max(m, n)
- C. m + n − 1
- D. min(m, n)
展开解析
D每比较一次就有一个元素归位。最好情况是较短的那个表的所有元素都比另一个表的第一个元素小:比较 min(m, n) 次后短表取完,长表剩下的部分直接接上,不用再比。
变化点:原题问最坏情况,两表交替取到最后,比较 m + n − 1 次,复杂度 O(max(m, n));最好情况只比 min(m, n) 次。归并的比较次数介于 min(m, n) 和 m + n − 1 之间。
1.4空间复杂度与递归算法的复杂度
空间复杂度
算法的空间复杂度 S(n) 是它所需的存储空间关于 n 的数量级。只算辅助空间:输入数据本身占的空间(题目给的数组)不算,因为无论什么算法都要存它。
| 情形 | 空间复杂度 | 例子 |
|---|---|---|
| 只用了几个变量(i、j、temp、计数器) | O(1),称为“原地工作” | 数组逆置、2010-42 循环左移(三次逆置法) |
| 开了一个和 n 一样大的辅助数组 | O(n) | 2018-41 的标记数组、归并排序的辅助数组 |
| 开了 n × n 的二维数组 | O(n²) | 邻接矩阵 |
| 递归 | O(递归深度) × 每层的空间 | 二叉树递归遍历 O(h),快排平均 O(log n)、最坏 O(n) |
原地工作指辅助空间是常量(和 n 无关),并不是一点空间都不用。另外,“时间和空间都尽可能高效”的题目往往要权衡:用 O(n) 空间换来 O(n) 时间(如计数数组),还是用 O(1) 空间忍受 O(n log n) 时间(如先原地排序)。题目强调时间就用空间换时间;强调“空间复杂度为 O(1)”(如 2019-41)就不能开数组。
| 写法 | 做法 | 辅助空间 |
|---|---|---|
| ① 另开数组 | 开 b[n],b[i] = a[n − 1 − i],再抄回 a | O(n):多了一整个数组 |
| ② 两端交换 | i 从左、j 从右,交换 a[i]、a[j] 后相向而行(2.5 基本功 ①) | O(1):只有 i、j、temp |
| ③ 递归交换 | Rev(a, i, j):交换 a[i]、a[j],再调用 Rev(a, i + 1, j − 1) | O(n):递归深度约 n/2 |
三种写法的时间都是 O(n),空间却差别很大。第 ③ 种看起来没有开数组,但每层递归都在栈里占了一个栈帧,深度 n/2,所以空间仍是 O(n)——“没写 malloc”不等于“空间 O(1)”。题目要求“空间 O(1)”时,只有第 ② 种合格。
递归的空间:工作栈有多深
递归调用时,系统为每一层调用在递归工作栈里压一个栈帧(参数、局部变量、返回地址),这一层返回时弹出(图见 3.6)。所以递归的空间复杂度 = 递归的最大深度 × 每层的空间,与总共调用了多少次无关:
| 递归算法 | 调用总次数 | 最大深度 | 空间复杂度 |
|---|---|---|---|
| 递归求和 sum(n) = n + sum(n − 1) | n + 1 | n + 1 | O(n) |
| 递归折半查找 | ≤ log₂n + 1 | ≤ log₂n + 1 | O(log n) |
| 朴素递归斐波那契 fib(n) | 2F(n + 1) − 1(指数级) | n | O(n) |
| 二叉树递归遍历 | 结点数(加空指针) | 树高 h | O(h):平衡时 O(log n),单支时 O(n) |
斐波那契这一行最能说明问题:调用了指数多次,但任何时刻栈里最多 n 层,所以时间指数级、空间只是 O(n)。
空间复杂度按什么单位数:和时间一样只看数量级,“一个整数”“一个指针”“一个栈帧”都算常数个单位。所以开 3 个变量和开 30 个变量都是 O(1),开一个长 n 的数组是 O(n),开一个 n × n 的矩阵是 O(n²);递归时用“深度 × 每层的常数”算。
递归算法的时间复杂度:写递推式
对递归算法,设规模为 n 时的执行时间为 T(n),根据代码写出 T(n) 和更小规模的关系,再逐层展开:
| 递推式 | 展开 | 结果 | 典型算法 |
|---|---|---|---|
| T(n) = T(n−1) + O(1) | T(n) = T(n−2) + 2 = … = T(1) + (n−1) | O(n) | 递归求阶乘(2012-01)、递归求和、链表递归 |
| T(n) = T(n/2) + O(1) | 每层规模减半,共 log₂n 层,每层常数 | O(log n) | 递归折半查找 |
| T(n) = 2T(n/2) + O(n) | 共 log₂n 层,每层合起来是 n | O(n log n) | 归并排序、快排最好情况 |
| T(n) = T(n−1) + O(n) | n + (n−1) + … + 1 | O(n²) | 快排最坏情况(每次只少一个) |
| T(n) = 2T(n−1) + O(1) | 1 + 2 + 4 + … + 2n−1 | O(2ⁿ) | 汉诺塔、朴素递归斐波那契(上界) |
以汉诺塔为例(3.6 节):挪 n 个盘子 = 挪两次 n − 1 个 + 挪一次最大的,T(n) = 2T(n − 1) + 1,展开得 2ⁿ − 1。看递推式的“形状”就能大致判断阶:规模减 1、只调用一次是线性级,规模减 1、调用两次是指数级,规模减半是对数级(或配合每层工作量得到 n log n)。
递归树法:一层一层加起来
同一个式子用展开法:T(n) = 2T(n/2) + n = 4T(n/4) + 2n = 8T(n/8) + 3n = … = nT(1) + n·log₂n。设 T(1) = 1,得 T(n) = n log₂n + n(程序对 n = 2 ~ 2¹⁷ 逐个核对)。
void g(int n) { if (n <= 1) return; for (int i = 0; i < n; i++) x++; // 本层做 n 次 g(n / 2); // 规模减半,只递归一次 }
- 写递推式:本层工作 n,加上一次规模为 n/2 的调用:T(n) = T(n/2) + n。
- 展开:T(n) = n + n/2 + n/4 + … + 2,是一个公比 1/2 的等比数列,和小于 2n。
- 结论 O(n)(不是 O(n log n):每层的工作在减半,不是每层都是 n)。递归深度 log₂n,空间 O(log n)。
程序实测 x++ 的次数:n = 8 时 14,n = 16 时 30,n = 100 时 196,n = 1024 时 2046,都小于 2n。
拓展 主定理:三种情况一眼看出
对 T(n) = aT(n/b) + f(n)(a ≥ 1,b > 1),把 f(n) 和 nlogba(叶子层的总工作量)比大小:
| 递推式 | nlogba | f(n) | 谁大 | 结果 | 程序算出的精确值(n = 2ᵏ) |
|---|---|---|---|---|---|
| 2T(n/2) + 1 | n | 1 | 叶子多 | O(n) | 2n − 1 |
| 2T(n/2) + n | n | n | 一样大 | O(n log n) | n log₂n + n |
| T(n/2) + n | 1 | n | f 大 | O(n) | 2n − 1 |
| 4T(n/2) + n | n² | n | 叶子多 | O(n²) | 2n² − n |
规律:谁大听谁的;一样大就再乘一个 log n。408 只要求会展开,主定理当作检验工具即可(严格的条件比这里写的多,不展开)。
同一个问题常常两种都能写。递归代码短、思路直接,适合问题本身就是“大问题由同类小问题组成”的情形(树的遍历、分治、回溯),代价是 O(深度) 的栈空间和函数调用开销,深度太大还会栈溢出;循环省空间、快,适合“后一步只依赖前一两步”的递推(阶乘、斐波那契、链表遍历)。408 大题里两种写法都给分,选自己最不容易写错的那种;但题目要求“空间 O(1)”时,就不能用深度随 n 增长的递归。
拓展 本书会遇到的几种算法设计思想:复杂度只是“尺子”,真正决定算法好坏的是设计思想。后面各章的算法大致可以归成几类——分治(把问题切成几个同类的小问题,各自解决再合起来):折半查找、归并排序、快速排序;贪心(每一步都选眼前最好的,并且能证明这样最后也是最好的):哈夫曼树、Prim、Kruskal、Dijkstra;动态规划(把子问题的答案存起来,避免重复计算):Floyd、关键路径的 ve 和 vl、记忆化的斐波那契;回溯(走不通就退回上一步换条路):DFS、求所有简单路径;空间换时间:散列表、计数数组。学到这些算法时回头对照,能看出它们“为什么这样设计”。
int fact(int n) { if (n <= 1) return 1; return n * fact(n - 1); }
- A. O(log₂n)
- B. O(n)
- C. O(n log₂n)
- D. O(n²)
展开解析
B每次调用除了递归只做常数次运算(一次比较、一次乘法),规模每次减 1。
- 写递推式:T(n) = T(n − 1) + O(1),T(1) = O(1)。
- 展开:T(n) = T(n − 2) + 2 = … = T(1) + (n − 1)。
- 共调用 n 次,时间 O(n);递归深度 n,空间也是 O(n)。
- A 错O(log n) 要求规模每次减半(如 fact(n/2)),这里每次只减 1。
- B 对n 层调用,每层常数时间。
- C 错每层并没有 O(log n) 的工作量。
- D 错每层没有 O(n) 的工作(那会是 T(n) = T(n−1) + n)。
递归的时间 = 调用次数 × 每次调用自身的工作量;T(n) = T(n−1) + 常数 ⇒ O(n)。
- T(n) = T(n/2) + 1,T(1) = 1,时间复杂度是多少?
答案
每层规模减半、做常数工作,共 log₂n + 1 层:T(n) = log₂n + 1,O(log n)(递归折半查找)。 - 递归求 1 + 2 + … + n 的算法,时间、空间复杂度各是多少?
答案
调用 n + 1 次,时间 O(n);递归深度 n + 1,空间 O(n)。改成循环后空间 O(1)。 - 用递归把数组逆置(每层交换两端,再递归处理中间),空间复杂度是 O(1) 吗?
答案
不是。每层递归占一个栈帧,深度约 n/2,空间 O(n)。要 O(1) 就用两端交换的循环。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
int f(int n) { if (n <= 1) return 1; return f(n - 1) + f(n - 1); }
- A. O(n)、O(n)
- B. O(2n)、O(n)
- C. O(2n)、O(2n)
- D. O(n2)、O(n)
展开解析
B时间:T(n) = 2T(n−1) + 1,调用树每层翻倍,共约 2n 次调用,O(2n)。空间:两次递归调用是先后执行的,第一次返回、栈帧释放后才做第二次,所以任一时刻栈中最多 n 层,O(n)。
变化点:原题 fact 只调用自己一次,时间和空间都是 O(n)。调用两次后时间爆炸成 O(2n),但空间只看递归的最大深度,仍是 O(n)。C 是典型错误:把“调用总次数”当成了“栈的深度”。
int h(int n) { if (n <= 1) return 1; return h(n / 2) + h(n / 2); }
- A. O(log2n)
- B. O(n)
- C. O(nlog2n)
- D. O(n2)
展开解析
BT(n) = 2T(n/2) + 1。调用树第 k 层有 2k 个调用,共 log2n 层,总调用次数 1 + 2 + 4 + … + n ≈ 2n,所以是 O(n)。递归深度只有 log2n,空间 O(log2n)。
变化点:只调用一次 h(n/2) 时是 O(log2n)(像折半查找);调用两次,每层调用数翻倍,恰好抵消了规模的减半,变成 O(n)。若每层还要做 O(n) 的工作(归并排序),才是 O(nlog2n)。
1.5本篇总结:一页纸 + 必会题型
| # | 题型 | 方法 | 本书位置 |
|---|---|---|---|
| 1 | 术语辨析(基本单位 / 最小单位、ADT) | 元素是基本单位,数据项是最小单位 | 1.1 |
| 2 | 逻辑结构还是存储结构 | “换一种存法还叫这个名字吗” | 1.1 跟做 |
| 3 | 算法特性 | 有穷、确定、可行、输入 ≥ 0、输出 ≥ 1 | 1.2 |
| 4 | 循环的时间复杂度 | 独立相乘;依赖求和;条件含变量解不等式 | 1.3 |
| 5 | 递归的时间复杂度 | 写递推式,展开或画递归树 | 1.4 |
| 6 | 空间复杂度 | 只算辅助空间;递归看最大深度 | 1.4 |
- 常对幂指阶:O(1) < O(log n) < O(√n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)。
- 内层依赖外层时不能直接相乘:1 + 2 + 4 + … + n 是 O(n),不是 O(n log n)。
- 没有特别说明时,时间复杂度指最坏情况。
- 对数的底数不影响阶:log₃n 和 log₂n 只差常数倍,都写 O(log n)。
- 写成 O(2n)、O(n²/2):大 O 里不写系数,统一写 O(n)、O(n²)。
- 以为复杂度就是运行时间:O(n) 的算法在 n 很小时可能比 O(n²) 的慢(系数大);复杂度说的是 n 变大时的增长趋势。
- 把“最好情况”当成复杂度:没有特别说明时,指最坏情况;顺序查找最好 1 次,但它的时间复杂度是 O(n)。
- 递归只看代码行数:几行代码的递归可以是指数级(朴素斐波那契),必须写递推式。
- 空间复杂度把输入也算进去:题目给的数组不算,只算算法自己额外开的;但递归栈要算。
本篇自测第 3 题的内层循环 j += i 执行约 n/i 次,总次数是 n × (1 + 1/2 + … + 1/n)。括号里的和叫调和级数。把它分组看:1;1/2;1/3 + 1/4(两项,每项 ≥ 1/4,和 ≥ 1/2);1/5 ~ 1/8(四项,和 ≥ 1/2)……每一组都在 1/2 和 1 之间,而到 n 一共大约 log₂n 组,所以和是 log n 级。更精确地,它约等于 ln n + 0.577:n = 1000 时和为 7.485,ln 1000 = 6.908。所以那道题是 O(n log n);程序实测 n = 1000 时内层共执行 8053 次,与 n ln n ≈ 6908 同一个量级。
- 下列说法正确的是:① 数据元素是数据的最小单位;② 逻辑结构与存储结构无关;③ 同一逻辑结构只能有一种存储结构;④ 算法可以没有输入。
答案
只有 ④ 对。① 最小单位是数据项;② 严格说是“逻辑结构独立于存储结构”,但存储结构依赖逻辑结构,说“无关”不准确;③ 可以有多种。 for (i = n; i > 1; i /= 3) x++;的时间复杂度?答案
i 每次除以 3,执行 log₃n 次,O(log n)(对数的底数只差一个常数倍,统一写 log n)。for (i = 1; i <= n; i++) for (j = 1; j <= n; j += i) x++;的时间复杂度?答案
内层对每个 i 执行约 n/i 次,总次数 n(1 + 1/2 + … + 1/n) ≈ n ln n,是 O(n log n)(调和级数)。- 一个递归算法满足 T(n) = 2T(n/2) + 1,T(1) = 1,它的时间复杂度是多少?
答案
展开:第 k 层有 2k 个子问题各做常数工作,总共 1 + 2 + 4 + … + n ≈ 2n,O(n)(例如递归求二叉树结点数就是这样)。
1.✎本篇真题闯关(8 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
线性表:顺序表与链表,以及算法大题的基本功
线性表是最简单、最常用的结构:一串排好队的元素。它有两种存法——顺序表(数组,挨着放)和链表(指针串起来)。两种存法各有长短,408 选择题常考“某操作在某种存储下要多少时间”“这几句指针操作对不对”;而算法大题有将近一半就是在数组或链表上做文章。所以本篇除了讲清楚概念和基本操作,最后一节专门练“算法题基本功”:逆置、双指针、快慢指针、头插法、归并、空间换时间。
2.0本篇地图:从“一队元素”到算法大题
选择题每年大约 1 道,考的是“某操作在某种存储下要多少时间”“这几句指针语句对不对”;真正的分量在算法大题——18 年里有 11 道是在数组或链表上做文章。所以本篇的学法是:前四节把每个操作画图、逐句跟踪弄熟,第 5 节练七个基本功和类似题。
| 小节 | 学完要会的事 | 真题 | 难度 | 学时 |
|---|---|---|---|---|
| 2.1 定义 | 线性表的三个要素;逻辑结构 vs 存储结构 | — | ★ | 0.5 h |
| 2.2 顺序表 | 插入、删除、查找代码;平均移动次数推导;扩容代价 | 2 | ★★ | 1.5 h |
| 2.3 单链表 | 头结点的作用;头插 / 尾插;插删代码;两个 O(1) 技巧 | 2 | ★★ | 2 h |
| 2.4 双 / 循环 / 静态链表 | 指针修改的顺序;各种链表各操作的时间;空闲链 | 4 | ★★ | 1.5 h |
| 2.5 基本功 | 七个基本功 + 七道类似题(算法大题的地基) | 大题 11 | ★★★ | 4 h |
链表题一定要画图:每个结点画成“数据格 + 指针格”,每执行一句就改一根箭头。眼睛看代码很容易“想当然”,一画就知道哪根指针断了。本篇所有代码都在电脑上编译、用随机数据和“标准答案”对拍过,可以放心照着写。
2.1线性表的定义和基本操作
为什么先学线性表
生活里大量数据天然“排成一队”:学号表、购物车里的商品、一句话里的字、等着打印的文件……共同点是每个元素前面最多一个、后面最多一个。把这种“一个接一个”的关系抽出来,就是线性表。后面的栈、队列是“只能在端点操作”的线性表,串是“元素都是字符”的线性表,所以它是整门课的地基。
排队时每个人只认“我前面那个人”和“我后面那个人”;队头前面没人,队尾后面没人。这正是线性表的“直接前驱、直接后继”。哪里不像:排队的人在地面上一定是挨着站的;线性表只规定先后次序,元素在内存里是不是挨着放,是存储结构的事——挨着放就是顺序表,散着放、用指针串起来就是链表。
线性表是具有相同数据类型的 n(n ≥ 0)个数据元素的有限序列,记作 L = (a₁, a₂, …, aᵢ, …, aₙ)。n 叫表长,n = 0 时是空表。
- 相同类型:每个元素占同样大小的空间(这是顺序表能用公式算地址的前提)。
- 有限:元素个数有限。“所有整数”不是线性表。
- 序列:有先后次序。a₁ 叫表头元素,aₙ 叫表尾元素;除 a₁ 外每个元素有且仅有一个直接前驱,除 aₙ 外每个元素有且仅有一个直接后继。
- aᵢ 的“i”叫位序,从 1 开始;而 C 数组下标从 0 开始——写代码时第 i 个元素是
data[i-1],这是最常见的错误来源。
线性表是一种逻辑结构;顺序表、链表是它的两种存储结构(实现方式)。
同一张表,两种存法
这一张图就是整篇的提纲:“线性表”说的是逻辑结构——元素排成一队、每个至多一个前驱和一个后继;“顺序表”“链表”说的是存储结构——这一队元素在内存里怎么放。同一个逻辑结构可以有不同的存法,不同存法在“找第 i 个”“插一个”“删一个”上的快慢正好相反:顺序表找得快、改得慢,链表改得快、找得慢。2.2 ~ 2.4 节分别讲两种存法,2.5 节教你按需要选,并把它们用到算法题里。
基本操作(ADT)
| 操作 | 功能 |
|---|---|
InitList(&L) | 初始化:构造一个空表 |
DestroyList(&L) | 销毁:释放 L 占用的内存 |
ListInsert(&L, i, e) | 在第 i 个位置插入元素 e(插入后 e 成为第 i 个) |
ListDelete(&L, i, &e) | 删除第 i 个元素,并用 e 带回它的值 |
LocateElem(L, e) | 按值查找:返回第一个值等于 e 的元素的位序 |
GetElem(L, i) | 按位查找:返回第 i 个元素的值 |
Length(L)、Empty(L)、PrintList(L) | 求表长、判空、输出 |
参数前的 & 是 C++ 引用(见 0.3):凡是要修改表本身的操作都要加。
怎么读这张表:InitList、ListInsert、ListDelete、DestroyList 会改变表(长度、内容或存储空间),所以 L 前有 &;LocateElem、GetElem、Length 只是“看一看”,L 前没有 &。ListDelete 的 e 前也有 &,因为要把删掉的值“带回”给调用者。这组操作是线性表的“接口”:后面不管用顺序表还是链表实现,名字和参数都一样,只是函数里面的写法不同——这就是“逻辑结构定义操作,存储结构决定实现”的意思。
- “序列”只是有先后,不是按值排好;按值排好的叫有序表,是线性表的一种特殊情况。
- 线性表是逻辑结构,顺序表、链表是存储结构。选择题常问“下列属于逻辑结构的是”,把“顺序表”当成逻辑结构就错了。
- “类型相同”指每个元素的结构相同。元素可以是一整条记录(学号、姓名、成绩),不一定是一个数。
- 位序从 1 开始,C 数组下标从 0 开始,第 i 个元素是
data[i-1]。
跟我做一遍:哪些是线性表
| 例子 | 是不是 | 理由 |
|---|---|---|
| 一个班的学生记录,按学号排列 | 是 | 每条记录类型相同,个数有限,有先后 |
| 全体正整数 1, 2, 3, … | 不是 | 无限个,不满足“有限” |
| 字符串 "hello" | 是 | 元素都是字符;串就是特殊的线性表(第 4 篇) |
| 集合 {3, 1, 2} | 不是 | 集合中的元素没有先后次序 |
| (1, "a", 2.5) | 不是 | 元素类型不同 |
| 家谱(一个人可以有好几个孩子) | 不是 | 一个元素有多个“后继”,是树(第 5 篇) |
| 空表 () | 是 | n = 0 的线性表 |
- 线性表中第一个元素有几个直接前驱?最后一个元素有几个直接后继?其他元素呢?
答案
第一个元素 0 个直接前驱,最后一个元素 0 个直接后继;其余元素各恰好 1 个直接前驱和 1 个直接后继。 - 判断:“线性表的元素在内存中一定是连续存放的。”
答案
错。线性表是逻辑结构,只规定先后次序;用链表存时元素可以散在内存各处。
考纲补充(尚无真题):线性表的定义
线性表的定义与逻辑特征是考纲点,2009—2026 年没有单独出过选择题,下面一道按考纲自编。
- A. 线性表中每个元素都有一个直接前驱和一个直接后继
- B. 线性表中的元素必须是同一种数据类型
- C. 线性表中的元素一定按值从小到大排列
- D. 线性表的存储结构就是顺序表
展开解析
B线性表是“相同数据类型的 n 个元素的有限序列”。A 错:第一个元素没有直接前驱,最后一个没有直接后继;C 错:“序列”指有先后次序,不是按值有序(按值有序的叫有序表);D 错:线性表是逻辑结构,顺序表和链表都可以存它。
变化点:把 B 改成“线性表中的元素在内存中必须连续存放”就是错的——那是顺序表的特点,不是线性表的。
2.2顺序表:用数组存线性表
把线性表的元素依次存放在一组地址连续的存储单元里,逻辑上相邻的元素物理上也相邻。第 i 个元素的地址:
两种定义方式
#define MaxSize 50 typedef struct { ElemType data[MaxSize]; // 静态数组 int length; // 当前长度 } SqList;
满了就不能再插入(除非改代码重新编译)。
#define InitSize 100 typedef struct { ElemType *data; // 指向动态数组 int MaxSize, length; } SeqList; // 初始化: L.data = (ElemType *)malloc(sizeof(ElemType) * InitSize);
满了就 malloc 一块更大的,把旧数据全部复制过去,再 free 旧的——扩充代价是 O(n),并不是“在原地长大”。
顺序表的特点:① 随机访问;② 存储密度高(只存数据本身);③ 扩充容量不方便;④ 插入、删除要移动大量元素。
顺序表和数组是一回事吗:不完全是。数组是 C 语言提供的“一块连续内存 + 按下标访问”的机制,长度在定义时就定了;顺序表是“用数组来存线性表”,还要多一个 length 记录当前用了几个格子,并约定元素必须从头开始连续存放、中间不能有空位。所以 MaxSize 是“最多能放几个”,length 是“现在放了几个”——判满看 length == MaxSize,判空看 length == 0。
“随机访问”为什么是 O(1):元素一个挨一个地放,第 i 个元素的地址 = 第 1 个元素的地址 + (i − 1) × 每个元素的大小。例如 int 占 4 字节、表从地址 1000 开始,第 5 个元素就在 1000 + 4 × 4 = 1016,一次乘法加法就算出来了,不管表有多长。这里的“随机”是“想访问哪个就直接访问哪个”的意思,不是“碰运气”。链表做不到,是因为第 i 个结点的地址只记在第 i − 1 个结点的 next 里,只能一个个顺着找。
动态分配:满了怎样“扩容”
void IncreaseSize(SeqList &L, int len) { // 把容量增加 len ElemType *p = L.data; // ① 记住旧数组 L.data = (ElemType *)malloc(sizeof(ElemType) * (L.MaxSize + len)); // ② 申请更大的新数组 for (int i = 0; i < L.length; i++) // ③ 逐个复制:O(n) L.data[i] = p[i]; L.MaxSize += len; free(p); // ④ 释放旧数组 }
易错:“动态分配”只是说容量可以在运行时改,它仍然是顺序存储、仍然支持随机存取;扩容时不能“在原地变长”,因为旧数组后面的内存可能已经被别的变量占用。拓展 如果每次扩成原来的 2 倍,连续在表尾插入 n 个元素,复制的总次数不超过 2n,平均到每次插入只是 O(1)。(代码与“不超过 2n”均由程序验证。)
插入:把后面的元素往后挪一格
bool ListInsert(SqList &L, int i, ElemType e) { if (i < 1 || i > L.length + 1) return false; // i 的合法范围是 1 ~ length+1 if (L.length >= MaxSize) return false; // 表满 for (int j = L.length; j >= i; j--) // 从最后一个开始,依次后移 L.data[j] = L.data[j - 1]; L.data[i - 1] = e; // 位序 i 对应下标 i-1 L.length++; return true; }
可以插入的位置有 n + 1 个(i = 1 ~ n + 1),在第 i 个位置插入移动 n − i + 1 个元素。设每个位置概率相同,都是 1/(n+1):
平均移动 = Σi=1n+1 (n − i + 1) / (n + 1) = (n + (n−1) + … + 0)/(n + 1) = n / 2
最好 O(1)(插在表尾,不用移),最坏 O(n)(插在表头,全部后移),平均 O(n)。
为什么必须从后往前移:在 a, b, c, d 的第 2 个位置插入 x。若从前往后移,先做 data[2] = data[1],c 就被 b 盖掉了;接着 data[3] = data[2],又把刚复制来的 b 抄过去……最后得到 a, x, b, b, b——c、d 全丢了。从后往前移则是先把 d 挪到空位、再挪 c、再挪 b,每次都往一个“已经腾出来”的格子里写,谁也不会被覆盖。记法:往哪边挪,就先动那一头的元素——后移先动最后一个,前移(删除)先动最前面那个。
i 的合法范围为什么是 1 ~ length + 1:可以插在第 1 个元素之前(i = 1),也可以接在最后一个元素之后(i = length + 1),但不能“隔空”插到 length + 2——顺序表中间不能有空位。删除时 i 只能是 1 ~ length,因为只有这么多元素。代码第一句先判 i,第二句判表满,先判合法再动数据,是写所有修改操作的好习惯。(覆盖的例子由程序模拟。)
删除:把后面的元素往前挪一格
bool ListDelete(SqList &L, int i, ElemType &e) { if (i < 1 || i > L.length) return false; // 合法范围 1 ~ length e = L.data[i - 1]; // 带回被删元素 for (int j = i; j < L.length; j++) // 从被删位置的后一个开始,依次前移 L.data[j - 1] = L.data[j]; L.length--; return true; }
删除第 i 个要前移 n − i 个元素;可删位置 n 个,平均移动 (n − 1)/2 次。最好 O(1)(删表尾),最坏 O(n)(删表头),平均 O(n)。注意删除要从前往后移,方向和插入相反。
平均移动次数:画成阶梯一眼看懂
把这串数正着写一遍、倒着写一遍,上下对齐相加:每一对都是 n + 1,共 n 对,所以两倍的和是 n(n + 1),和就是 n(n + 1)/2(高斯小时候的算法)。插入:台阶是 n, n − 1, …, 1, 0,总和 n(n + 1)/2,除以 n + 1 个位置得 n/2;删除:台阶是 n − 1, …, 1, 0,总和 n(n − 1)/2,除以 n 个位置得 (n − 1)/2。
概率不相等时就做“加权平均”:每个位置的移动次数乘以它的概率再相加。例如插入时有一半的机会插在表尾(不用移),另一半平均分给前 n 个位置,则平均移动 = ½ × 0 + (1/2n) × (n + n − 1 + … + 1) = (n + 1)/4。(各公式由程序对 n = 1 ~ 39 逐一核对。)
查找
ElemType GetElem(SqList L, int i) { return L.data[i - 1]; } // 按位查找:O(1) int LocateElem(SqList L, ElemType e) { // 按值查找:返回位序,失败返回 0 for (int i = 0; i < L.length; i++) if (L.data[i] == e) return i + 1; return 0; }
按值查找最好 1 次、最坏 n 次,平均 (n + 1)/2 次,O(n)。如果顺序表有序,按值查找可以用折半查找做到 O(log n)(第 7 篇),但插入、删除仍然是 O(n)——找到位置很快,挪元素还是慢。
| 操作(顺序表,长度 n) | 最好 | 最坏 | 平均 | 平均移动次数 |
|---|---|---|---|---|
| 按位查找 GetElem | O(1) | — | ||
| 按值查找(无序) | O(1) | O(n) | O(n) | 比较 (n+1)/2 次 |
| 插入第 i 个 | O(1) | O(n) | O(n) | n/2 |
| 删除第 i 个 | O(1) | O(n) | O(n) | (n−1)/2 |
- 插入的合法范围是 1 ~ n + 1(可以插在最后一个元素之后),删除是 1 ~ n。
- “有序顺序表”只能让查找变快(折半 O(log n)),插入、删除仍要移动元素,还是 O(n)。
- 平均移动次数的分母不同:插入有 n + 1 个位置,删除只有 n 个位置,所以是 n/2 和 (n − 1)/2。
- 动态分配的顺序表仍是顺序存储,仍能随机存取;扩容要整体复制,O(n)。
跟我做一遍:插入、删除各一次
- 插入位序 2:合法范围 1 ~ 6 ✓。要后移 n − i + 1 = 4 个元素,从最后一个开始:7 移到 data[5],20 移到 data[4],8 移到 data[3],5 移到 data[2];再 data[1] = 9,length = 6。结果 (12, 9, 5, 8, 20, 7)。
- 删除位序 4:合法范围 1 ~ 6 ✓。e = data[3] = 8;要前移 n − i = 6 − 4 = 2 个元素,从被删位置的后一个开始:20 移到 data[3],7 移到 data[4];length = 5。结果 (12, 9, 5, 20, 7),e = 8。
- 检查方向:插入若从前往后移,5 会先覆盖 8,后面全错;删除若从后往前移,7 会先覆盖 20。插入从后往前,删除从前往后——总是先挪“离空位远的那一头”不会覆盖。
- A. 查找包含指定值元素的算法
- B. 插入包含指定值元素的算法
- C. 删除第 i 个元素的算法
- D. 获取第 i 个值的算法
展开解析
D顺序表最大的本事是随机存取:第 i 个元素的地址 = 首地址 + (i − 1) × 元素大小,一步算出。
- A 错有序顺序表按值查找可以用折半,平均 O(log n);顺序查找 O(n)。都不是 O(1)。
- B 错有序表插入:先找位置(折半 O(log n)),再把后面的元素整体后移,平均移动 n/2 个,O(n)。
- C 错删除第 i 个要把后面 n − i 个元素前移,平均 (n − 1)/2 个,O(n)。
- D 对按位取值
L.data[i-1],O(1)。
顺序表:按位查 O(1);按值查、插入、删除都是 O(n)。“有序”只能加快查找,不能加快插删。
- 长度为 n(n 为偶数)的顺序表,只允许删除前一半位置(1 ≤ i ≤ n/2)的元素且等概率,平均移动几个元素?
答案
删除位置 i 移动 n − i 个。平均 = [(n − 1) + (n − 2) + … + (n − n/2)] ÷ (n/2) = (3n − 2)/4。例如 n = 4:(3 + 2)/2 = 2.5 ✓。 - 在 n 个元素的顺序表中按值查找,查找成功时平均比较几次?
答案
找第 i 个要比较 i 次,等概率时 (1 + 2 + … + n)/n = (n + 1)/2 次。 - 顺序表从地址 2000 开始存放,每个元素占 8 字节,第 10 个元素的地址是多少?在 a, b, c, d 的第 2 个位置插入 x,若把后移写成“从前往后”,会得到什么?
答案
2000 + (10 − 1) × 8 = 2072。从前往后移会得到 a, x, b, b, b:c、d 被依次覆盖丢失。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 在第 i 个位置插入一个元素,其余元素相对次序不变
- B. 删除第 i 个元素,其余元素相对次序不变
- C. 删除第 i 个元素,不要求保持其余元素的次序
- D. 查找值为 x 的元素
展开解析
C不要求保持次序时,用表尾元素覆盖第 i 个元素,再把表长减 1 即可,O(1)。A、B 要移动后面的元素,平均 O(n);D 无序只能顺序查找,O(n)。
变化点:原题“获取第 i 个值”靠的是随机存取;这里靠的是“不要求保持次序”这个条件——算法题里看到“顺序无关”,往往就能用“拿表尾来填坑”把删除变成 O(1)。
- A. n − i、(n − 1)/2
- B. n − i + 1、n/2
- C. n − i、n/2
- D. n − i + 1、(n + 1)/2
展开解析
A删第 i 个,要把第 i+1 ~ n 个前移,共 n − i 个。平均:(1/n)·Σ(n − i)(i 从 1 到 n)= (1/n)·(0 + 1 + … + n−1) = (n − 1)/2。
变化点:插入在第 i 个位置(1 ≤ i ≤ n+1)要后移 n − i + 1 个,平均 n/2(B 是插入的答案)。插入有 n+1 个位置、删除只有 n 个位置,这是两者平均值不同的原因。
- A. O(1)
- B. O(log2n)
- C. O(n2)
- D. O(n)
展开解析
D新空间申请好以后,要把原来的 n 个元素逐个复制过去,再释放旧空间,所以这一次是 O(n)。表尾插入本身不用移动元素,是 O(1),但被扩容拖成了 O(n)。
变化点:若每次扩成原来的 2 倍,扩容很少发生,连续插入 n 次复制的总次数不超过 2n,平均每次 O(1)(拓展)。见 2.2 扩容。
2.3单链表:用指针串起来
顺序表插删要挪一大片,扩容要整体搬家。链表换个思路:每个元素单独找个地方放,再用指针记住“下一个在哪”。
顺序表像电影院里一排连号的座位,中间插一个人,后面所有人都得往后挪一个座。链表像寻宝游戏:每个宝箱里除了宝物,还有一张纸条写着下一个宝箱在哪。要在中间加一个宝箱,只需要改两张纸条;但想直接找第 100 个宝箱,只能从第一个开始一路看纸条找过去。
typedef struct LNode { ElemType data; // 数据域 struct LNode *next; // 指针域:指向后继结点 } LNode, *LinkList;
头指针、头结点、首元结点
| 名词 | 含义 |
|---|---|
| 头指针 | 指向链表第一个结点的指针,是链表的“名字”,链表一定有头指针。 |
| 头结点 | 在首元结点之前额外附设的一个结点,数据域一般不存东西(也可以存表长)。可有可无。 |
| 首元结点 | 存放第一个数据元素 a₁ 的结点。 |
引入头结点的两个好处:① 第一个数据结点也有前驱(头结点),在表头插入、删除和在其他位置的代码完全一样;② 空表和非空表的处理统一(头指针永远非空)。算法大题的链表几乎都带头结点。
带不带头结点,代码差在哪
bool ListInsertNH(LinkList &L, int i, ElemType e) { if (i < 1) return false; if (i == 1) { // 插在表头:要改头指针 L 本身 LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = L; L = s; return true; } LNode *p = L; int j = 1; // p 指向第 j 个结点 while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL) return false; // i 太大 LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; p->next = s; return true; }
bool ListInsertH(LinkList L, int i, ElemType e) { if (i < 1) return false; LNode *p = L; int j = 0; // 头结点算第 0 个 while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL) return false; LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; p->next = s; return true; }
第 1 个位置的前驱就是头结点,不用特判;头指针 L 永远不变,所以参数连 & 都可以不要。
两个版本已编译,对随机表和随机位置(含非法位置)与标准答案逐一对拍。
建立单链表:头插法与尾插法
LinkList HeadInsert(LinkList &L, int a[], int n) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; // 空表 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = a[i]; s->next = L->next; // ① 新结点指向原来的第一个 L->next = s; // ② 头结点指向新结点 } return L; }
输入 1 2 3,得到 L → 3 → 2 → 1:顺序颠倒。这正是“链表原地逆置”的做法。
LinkList TailInsert(LinkList &L, int a[], int n) { L = (LNode *)malloc(sizeof(LNode)); LNode *r = L; // r 始终指向表尾 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = a[i]; r->next = s; // 接到表尾 r = s; // r 移到新的表尾 } r->next = NULL; // 表尾置空,千万别忘 return L; }
得到 L → 1 → 2 → 3:顺序不变。需要一个尾指针 r。
两种方法都是每个元素 O(1),建表 O(n)。
尾插法最后那句 r->next = NULL 为什么不能忘:malloc 出来的结点,next 里是一个不确定的值(“野指针”)。不置空,表尾后面就“接”着一块不知道是哪里的内存,之后遍历时 while (p != NULL) 停不下来,程序可能崩溃。头插法不需要这句,因为第一个插入的结点继承了 L->next 的 NULL,它自然成为表尾。选用哪一种看题目要的次序:要保持输入次序用尾插,要倒过来用头插。
查找:只能从头一个个走
LNode *GetElem(LinkList L, int i) { // 按位查找第 i 个结点(带头结点),i = 0 返回头结点 if (i < 0) return NULL; LNode *p = L; int j = 0; // p 指向第 j 个结点,头结点算第 0 个 while (p != NULL && j < i) { p = p->next; j++; } return p; // i 超过表长时 p 走到 NULL } LNode *LocateElem(LinkList L, ElemType e) { // 按值查找 LNode *p = L->next; while (p != NULL && p->data != e) p = p->next; return p; // 找不到返回 NULL }
按位、按值查找都是 O(n):链表不能随机存取,只能顺序存取。求表长也要从头数到尾,O(n)(除非额外维护一个 length)。
int Length(LinkList L) { // 带头结点:数据结点的个数 int len = 0; for (LNode *p = L->next; p != NULL; p = p->next) len++; return len; }
GetElem 里的 j 怎么理解:j 始终表示“p 指向第几个结点”,头结点算第 0 个。循环条件 j < i 让 p 停在第 i 个;若中途 p 变成 NULL,说明表不够长,返回 NULL。这种“指针 + 计数器同步走”的写法是链表题的基本动作,删除、插入都先用它找到第 i − 1 个(前驱)。数表长时从 L->next 开始数,不要把头结点也算进去——带头结点的空表长度是 0。
插入:顺序很重要
bool ListInsert(LinkList &L, int i, ElemType e) { // 在第 i 个位置插入(带头结点) LNode *p = GetElem(L, i - 1); // 找到第 i-1 个结点:O(n) if (p == NULL) return false; // i 不合法 LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; // ① p->next = s; // ② return true; }
插入本身(① ②)是 O(1),时间花在找前驱上,所以“在第 i 个位置插入”是 O(n),而“在已知结点 p 之后插入”是 O(1)。
① ② 颠倒会怎样:先执行 p->next = s,p 原来的后继就再也找不到了(唯一记着它地址的就是 p->next);接着 s->next = p->next 得到的是 s 自己,s 指向自己形成一个小环,后半个链表全部丢失。所以口诀“先连后面、再连前面”的本质是:任何一个指针被改写之前,它指向的结点必须已经有别的指针记住。双链表的四句、链表逆置的 r,都是这一条原则。
单链表找前驱要从头走,O(n)。偷梁换柱:仍然把 s 插到 p 之后,然后交换 p 和 s 的数据——逻辑上就等于把 e 插到了 p 前面,O(1)。
s->next = p->next; p->next = s; // s 插到 p 之后 ElemType t = p->data; p->data = s->data; s->data = t; // 交换数据
删除
bool ListDelete(LinkList &L, int i, ElemType &e) { // 删除第 i 个结点 LNode *p = GetElem(L, i - 1); // 找前驱 if (p == NULL || p->next == NULL) return false; // i 不合法 LNode *q = p->next; // q 指向被删结点 e = q->data; p->next = q->next; // 前驱“跨过”q free(q); // 释放。先改指针再 free,顺序不能反 return true; }
删除的顺序也有讲究:必须先 p->next = q->next 再 free(q)。若先 free(q),q 指向的内存已经还给系统,再读 q->next 就是读一块“作废”的内存,结果不可预料;而 e = q->data 也要在 free 之前取出。free 之后最好不要再碰 q。
同理,删除已知结点 p 而不知道前驱时,可以把后继的数据复制到 p,再删掉后继(O(1));但如果 p 是最后一个结点就不行,只能从头找前驱。
按值删除:pre、p 两个指针一起走。删除的关键是拿到被删结点的前驱,所以遍历时让 pre 永远跟在 p 后面一步:
bool DeleteFirstX(LinkList L, ElemType x) { // 带头结点:删除第一个值为 x 的结点 LNode *pre = L, *p = L->next; // pre 始终是 p 的前驱 while (p != NULL && p->data != x) { pre = p; p = p->next; } if (p == NULL) return false; // 没找到 pre->next = p->next; free(p); return true; }
带头结点的好处在这里又显出来:要删的若是第一个数据结点,它的前驱就是头结点,pre 从 L 出发正好覆盖这种情况,不用特判。要删除所有值为 x 的结点,只需把 if 改成循环里的分支:找到 x 就 pre->next = p->next; free(p); p = pre->next;(pre 不动),否则 pre、p 一起后移。(DeleteFirstX 与数组模型随机对拍 5 万组。)
两个 O(1) 技巧:偷梁换柱
void InsertBefore(LNode *p, ElemType e) { // 在 p 之前插入 e LNode *s = (LNode *)malloc(sizeof(LNode)); s->next = p->next; p->next = s; // s 先插到 p 之后 s->data = p->data; p->data = e; // 再把数据换一下 } bool DeleteNode(LNode *p) { // 删除 p 自己;p 是尾结点时做不到 LNode *q = p->next; if (q == NULL) return false; // 没有后继可借 p->data = q->data; // 后继的数据搬到 p p->next = q->next; // 再删掉后继 free(q); return true; }
- 插入时先写
p->next = s,p 原来的后继就找不到了。口诀:先连后面,再连前面。 - 删除时先
free(q)再写p->next = q->next,就是在使用已释放的内存。 - 尾插法最后忘了
r->next = NULL,链表尾部是一个随机地址。 - “换数据”的两个技巧:删 p 时 p 不能是尾结点;若题目要求“结点本身移动”(比如别的指针还指着它),也不能用。
跟我做一遍:逐句执行指针语句
q = p->next; p->next = q->next; q->next = p; h->next->next = q; 后链表变成什么?| 语句 | 效果 | 此时从 h 出发能走到的 |
|---|---|---|
q = p->next | q 指向 3(只是多了个名字,链没变) | h → 1 → 2 → 3 → 4 |
p->next = q->next | 2 跨过 3 直接指向 4,3 被“摘下” | h → 1 → 2 → 4 |
q->next = p | 3 指向 2 | h → 1 → 2 → 4(3 还没接回来) |
h->next->next = q | h->next 是 1,于是 1 指向 3 | h → 1 → 3 → 2 → 4 |
功能:交换相邻的结点 2 和 3(交换的是结点本身,不是数据)。方法总结:每执行一句,只改一根箭头;遇到 x->next->next 这种连写,先算出 x->next 此刻是谁。(由程序逐句模拟核对。)
| 地址 | 元素 | 链接地址 |
|---|---|---|
| 1000H | a | 1010H |
| 1004H | b | 100CH |
| 1008H | c | 1000H |
| 100CH | d | NULL |
| 1010H | e | 1004H |
| 1014H |
- A. 1010H,1014H,1004H
- B. 1010H,1004H,1014H
- C. 1014H,1010H,1004H
- D. 1014H,1004H,1010H
展开解析
D先顺着“链接地址”把链表还原出来,再做一次普通的插入。
- 从表头 c(1008H)出发:c → 1000H(a) → 1010H(e) → 1004H(b) → 100CH(d) → NULL。逻辑顺序是 c、a、e、b、d。
- 把 f(1014H)插到 a 和 e 之间:f 的链接地址 = a 原来的链接地址 = 1010H(先连后面);a 的链接地址改为 f 的地址 1014H(再连前面)。
- e 的链接地址不变,仍是 1004H。所以 a、e、f 依次是 1014H、1004H、1010H。
- A 错a 没有改指向 f,却把 e 改成指向 f——链表变成 c、a、e、f、b、d,f 被插到了 e 和 b 之间,不是 a 和 e 之间。
- B 错a 仍指向 e,f 没有被链进来。
- C 错e 的链接地址被改成 1010H(指向自己)。从 c 走:c → a → f → b → d,e 被跳过、丢失了。
- D 对a → f → e → b → d。
插入只改两个指针:新结点的 next = 前驱原来的 next;前驱的 next = 新结点。其他结点一律不动。
q = p->next; p->next = q->next; q->next = h->next; h->next = q; 这段代码的功能是( )。- A. 把 q 指向的结点插入到 p 的后面
- B. 把 p 指向的结点插入到 q 的后面
- C. 把 p 指向的结点插入到 h 的后面
- D. 把 q 指向的结点插入到 h 的后面
展开解析
D画图逐句跟踪。设链表 h → … → p → q → x → …。
q = p->next:q 指向 p 的后继。p->next = q->next:p 跨过 q 直接指向 x,q 被从原位置摘下(没有 free)。q->next = h->next; h->next = q:这两句是标准的头插——q 插到头结点 h 之后,成为第一个数据结点。
- A 错q 本来就在 p 后面;执行后 q 反而离开了 p。
- B 错p 的位置没有变,只是 p->next 改了。
- C 错移动的是 q 不是 p。
- D 对把 p 的后继 q 移到表头(头结点之后)。
“s->next = h->next; h->next = s” 是头插法的两句,看到就知道是插到头结点之后。
- 带头结点的单链表 L 为空的条件是什么?不带头结点的呢?
答案
带头结点:L->next == NULL;不带头结点:L == NULL。 - 用头插法依次读入 a, b, c, d 建表,从头到尾的次序是什么?如果只能用头插法,又想得到 a, b, c, d 的次序,怎么办?
答案
得到 d, c, b, a。可以把输入倒过来读(d, c, b, a),或者建好后再用头插法原地逆置一次(2.5 基本功 ④)。 - 对带头结点的 L → 3 → 5 → 3 → 7 调用 2.3 节的 DeleteFirstX(L, 3),结果是什么?若改成删除所有 3 呢?
答案
只删第一个:L → 5 → 3 → 7;删除所有:L → 5 → 7(找到 3 时 pre 不动、p 前进到 pre->next,再继续判断)。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
q = h->next; h->next = q->next; q->next = p->next; p->next = q; 后,这段代码的功能是( )。- A. 把第一个元素结点移到 p 所指结点的后面
- B. 把 p 所指结点移到表头
- C. 交换第一个元素结点与 p 所指结点
- D. 把 p 的后继结点移到表头
展开解析
A前两句:q 指向第一个元素结点,并把它从表头摘下(h->next 跳过 q)。后两句:把 q 插到 p 后面(先连后继,再改 p->next)。整体就是把第一个结点搬到 p 之后。
变化点:原题是把 p 的后继摘下、插到表头;这里反过来,把表头结点摘下、插到 p 后。读这类代码的方法相同:先找“摘下”的两句,再找“插入”的两句,画出前后指针即可。
- A.
q = p->next; p->data = q->data; p->next = q->next; free(q); - B.
q = p->next; p->next = q->next; free(p); - C.
free(p); - D.
p = p->next; free(p);
展开解析
A找不到前驱就没法改前驱的 next。技巧:把后继 q 的数据复制到 p 中,再删掉 q——效果等同于删除了 p 原来的数据。B 释放了还在链上的 p,链表断裂;C 同样让前驱指向已释放的结点;D 删掉的是后继,p 的数据还在。
变化点:正常删除要 O(n) 找前驱。“复制后继再删后继”只要 O(1),但 p 是尾结点时不能用(没有后继可复制),这正是题目加“不是尾结点”的原因。
2.4双链表、循环链表、静态链表
双链表:多一个指向前驱的指针
单链表只能往后走,找前驱要从头开始。双链表的结点多一个 prior 指针,可以双向走,代价是每个结点多占一个指针的空间。
typedef struct DNode { ElemType data; struct DNode *prior, *next; // 前驱、后继 } DNode, *DLinkList;
// 删除 p 的后继 q DNode *q = p->next; p->next = q->next; if (q->next != NULL) q->next->prior = p; // q 不是最后一个结点时才需要 free(q); // 删除 p 自己(双链表可以直接删,不用找前驱) p->prior->next = p->next; // 前驱的 next 跨过 p p->next->prior = p->prior; // 后继的 prior 跨过 p(p 是尾结点时要判空) free(p);
边界一定要想到:带头结点的双链表里,p 的前驱至少是头结点,所以 p->prior->next 总能写;但 p 若是最后一个结点,p->next 是 NULL,第二句 p->next->prior 就会访问空指针而崩溃,必须先判断 if (p->next != NULL)。循环双链表没有 NULL(尾结点的 next 指回头结点),这两句永远成立,所以考题里常用循环双链表来“省掉边界判断”。
跟我做一遍:四句话的顺序到底哪些能换
s->prior = p; p->next = s; s->next = p->next; p->next->prior = s;)对不对- 先给四句分类:①
s->next = p->next和 ②p->next->prior = s都要用到“p 原来的后继”(记作 b);④p->next = s会把这个信息覆盖;③ 只和 p、s 有关,放哪都行。 - 所以规则只有一条:④ 必须排在 ① 和 ② 的后面。
- 本例 ④ 在 ①② 之前 ⇒ 错。看看错成什么样:④ 之后 p->next 已经是 s,① 让 s->next = s(自己指向自己),② 改的是 s->prior 而不是 b->prior(随后又被 ③ 覆盖)。
程序把四句话的全部 4! = 24 种顺序都执行了一遍,正确的恰好 8 种:① ② ③ ④、① ② ④ ③、① ③ ② ④、② ① ③ ④、② ① ④ ③、② ③ ① ④、③ ① ② ④、③ ② ① ④——全都满足“④ 在 ①② 之后”。2023-02 是另一种情形:题目已经先执行了 ① ④,这时只能经由 s->next 找回 b(b->prior 还没改,恰好就是 p)。
- A. p->next->prev = p->prev; p->prev->next = p->prev; free(p);
- B. p->next->prev = p->next; p->prev->next = p->next; free(p);
- C. p->next->prev = p->next; p->prev->next = p->prev; free(p);
- D. p->next->prev = p->prev; p->prev->next = p->next; free(p);
展开解析
D删除 p,要让 p 的前驱和后继互相指向对方:前驱的 next 改成 p 的后继,后继的 prev 改成 p 的前驱。
p->next->prev = p->prev:后继结点往回指,跳过 p,指向 p 的前驱。p->prev->next = p->next:前驱结点往后指,跳过 p,指向 p 的后继。free(p)。这是循环双链表,p 的前驱和后继一定存在(最坏是头结点),不用判空。
- A 错第二句让前驱的 next 指向前驱自己。
- B 错第一句让后继的 prev 指向后继自己。
- C 错两句都指向了自己,正好把 A、B 的错误都犯了。
- D 对“后继的前驱 = 我的前驱;前驱的后继 = 我的后继”。
删 p:p->prev->next = p->next;p->next->prev = p->prev(两句顺序可以互换,因为都没改 p 自己的指针)。
循环链表:尾巴接回头
int LengthC(LinkList L) { // 带头结点的循环单链表求表长 int len = 0; for (LNode *p = L->next; p != L; p = p->next) len++; // 回到头结点才停 return len; }
把普通单链表的代码照搬过来、仍写 p != NULL,在循环链表上就是死循环——循环链表里根本没有 NULL。判空也跟着变:带头结点的循环单链表为空时 L->next == L(头结点自己指向自己)。凡是“从头走到尾”的代码,换到循环链表上第一件事就是改终止条件。(代码对 0 ~ 49 个结点核对。)
| 链表类型 | 在表头插入 | 在表尾插入 | 删除表头 | 删除表尾 |
|---|---|---|---|---|
| 单链表(只有头指针) | O(1) | O(n) | O(1) | O(n) |
| 单链表(头指针 + 尾指针) | O(1) | O(1) | O(1) | O(n)(要找尾结点的前驱) |
| 循环单链表(只有尾指针 r) | O(1) | O(1) | O(1) | O(n)(同上) |
| 循环双链表(头指针) | O(1) | O(1) | O(1) | O(1) |
判断方法:删除某结点需要它的前驱;插到某位置需要该位置的前驱。问自己:“这个前驱能不能 O(1) 拿到?”
为什么循环单链表常常只设尾指针:有了尾指针 r,表尾就是 r,表头(头结点)就是 r->next,两头都是 O(1);只设头指针的话,找表尾要走一遍。一个典型用法是把两个循环单链表首尾相接,只改几根指针:
LinkList Join(LinkList ra, LinkList rb) { // ra、rb 分别指向两个带头结点的循环单链表的尾结点
LNode *ha = ra->next; // A 的头结点
ra->next = rb->next->next; // A 的尾接到 B 的第一个数据结点
free(rb->next); // 释放 B 的头结点
rb->next = ha; // B 的尾接回 A 的头结点
return rb; // 新表的尾指针
}
四句话的顺序有讲究:第一句先用 ha 记住 A 的头结点,因为第二句会把 ra->next 改掉;第三句 free 的是 B 的头结点,必须在第二句用完 rb->next->next 之后、第四句改写 rb->next 之前。时间 O(1)。同样的事情用只有头指针的单链表做,得先走到 A 的表尾,O(n)。(代码随机对拍 2 万组。)
大横评:哪种链表做哪件事最快
下表 n 为元素个数。判断的诀窍只有一句:要动某个结点,先问它的前驱能不能 O(1) 拿到(删尾结点要尾的前驱,所以只有双链表能 O(1))。
| 链表(指针) | 访问首元 | 访问尾 | 表头插入 | 表尾插入 | 删首元 | 删尾 |
|---|---|---|---|---|---|---|
| 单链表,带头结点,头指针 L | O(1) | O(n) | O(1) | O(n) | O(1) | O(n) |
| 循环单链表,带头结点,头指针 L | O(1) | O(n) | O(1) | O(n) | O(1) | O(n) |
| 循环单链表,带头结点,尾指针 r | O(1) | O(1) | O(1) | O(1) | O(1) | O(n) |
| 循环单链表,不带头结点,头指针 L | O(1) | O(n) | O(n)* | O(n) | O(n)* | O(n) |
| 循环单链表,不带头结点,尾指针 r | O(1) | O(1) | O(1) | O(1) | O(1) | O(n) |
| 循环双链表,带头结点 | O(1) | O(1) | O(1) | O(1) | O(1) | O(1) |
带尾指针 r 时:带头结点的首元是 r->next->next,不带头结点的首元是 r->next;表尾插入就是插在 r 之后再让 r 后移。
* 不带头结点时,第一个结点变了,尾结点的 next 也要跟着改,而只有头指针找不到尾,所以按常规做法是 O(n)(用“交换数据”的技巧可以绕过去,但题目一般按常规做法算)。结论:只要涉及“表头 + 表尾”的插入和删首,选“带尾指针的循环单链表”;还要删尾,就只能用循环双链表。
- A. h->next = h->next->next; q = h->next; free(q);
- B. q = h->next; h->next = h->next->next; free(q);
- C. q = h->next; h->next = q->next; if (p != q) p = h; free(q);
- D. q = h->next; h->next = q->next; if (p == q) p = h; free(q);
展开解析
D删除第一个元素本身很简单(q = h->next; h->next = q->next; free(q)),陷阱在尾指针 p:如果表中只有一个元素,它既是第一个也是最后一个,删掉后 p 会指向已经释放的结点,必须改成指向头结点 h(空的循环链表中,尾就是头)。
- A 错先让 h->next 跨过第一个元素,再取 q = h->next,释放的是第二个元素;第一个元素没有被释放(内存泄漏),第二个被释放后链表断了。
- B 错一般情况下对,但只有一个元素时没有更新尾指针 p,p 成了悬空指针。
- C 错条件写反了:p != q(有多个元素)时反而把 p 改成 h,尾指针乱掉。
- D 对只有当被删的就是尾结点(p == q)时,才把 p 置为 h。
链表题的边界:删除的结点如果恰好是尾指针 / 头指针所指,要同步修改这些指针。
静态链表:用数组模拟链表
有些语言(或场合)没有指针,可以用一个结构体数组来模拟链表:每个元素存数据和“下一个元素的数组下标”(叫游标),用 −1 表示结束。0 号单元充当头结点。
| 下标 | 0(头) | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| data | — | b | a | d | c | 空闲 |
| next | 2 | 4 | 1 | −1 | 3 | … |
从 0 出发:0 → 2(a) → 1(b) → 4(c) → 3(d) → −1,逻辑顺序是 a、b、c、d。插入、删除只改游标,不移动元素;但和顺序表一样要预先分配一块连续空间,容量固定,而且不能随机存取(要按游标一个个走)。操作系统里文件分配表 FAT 就是静态链表。
它像谁(三句话记住静态链表):从“改游标、不挪元素”看,它继承了链表插删方便的优点;从“一次开好一整块数组、容量固定”看,它又带着顺序表空间不灵活的缺点;同时它既不能随机存取(要顺着游标走),每个元素还多存一个游标——所以静态链表只在“没有指针可用”或“想把链表整个存进一块连续空间(比如写进磁盘)”时才用。选择题考它,几乎都在考这几条优缺点;题目给出一张“下标—数据—游标”表让你写出逻辑顺序时,就从 0 号单元的游标出发一格格往下跳,直到 −1。
#define MaxSize 50 typedef struct { ElemType data; int next; // 下一个元素的数组下标,-1 表示表尾 } SLinkList[MaxSize];
静态链表的“空闲链”:数组里的 malloc 和 free
静态链表没有 malloc,空位从哪来?办法是把所有空闲单元也用游标串成一条链(空闲链),插入时从空闲链头上取一个,删除时把单元还回空闲链。于是一个数组里同时有两条链:
#define SMAX 8 typedef struct { ElemType data; int next; } SNode; SNode S[SMAX]; int freeHead; // 空闲链第一个下标,-1 表示没有空位 void InitSList() { S[0].next = -1; // 0 号:数据链的头结点,空表 for (int i = 1; i < SMAX - 1; i++) S[i].next = i + 1; // 1→2→…→SMAX-1 串成空闲链 S[SMAX - 1].next = -1; freeHead = 1; } int AllocNode() { // 从空闲链取一个下标(相当于 malloc) int k = freeHead; if (k != -1) freeHead = S[k].next; return k; } void FreeNode(int k) { // 把下标 k 还给空闲链(相当于 free) S[k].next = freeHead; freeHead = k; } bool SInsert(int i, ElemType e) { // 在第 i 个位置插入 int p = 0, j = 0; while (p != -1 && j < i - 1) { p = S[p].next; j++; } // 找第 i-1 个 if (i < 1 || p == -1) return false; int k = AllocNode(); if (k == -1) return false; // 没有空位 S[k].data = e; S[k].next = S[p].next; S[p].next = k; // 和单链表的插入一模一样 return true; } bool SDelete(int i, ElemType &e) { // 删除第 i 个 int p = 0, j = 0; while (p != -1 && j < i - 1) { p = S[p].next; j++; } if (i < 1 || p == -1 || S[p].next == -1) return false; int q = S[p].next; e = S[q].data; S[p].next = S[q].next; FreeNode(q); // 单元还给空闲链 return true; }
| 操作 | 做了什么 | 数据链(逻辑次序) | freeHead |
|---|---|---|---|
| InitSList | 1 ~ 7 串成空闲链 | 空 | 1 |
| 插入 a、b、c | 依次取到 1、2、3 号单元 | a(1) b(2) c(3) | 4 |
| 在第 2 个位置插入 d | 取 4 号;S[4].next = S[1].next = 2;S[1].next = 4 | a(1) d(4) b(2) c(3) | 5 |
| 删除第 3 个 | 前驱是 4 号:S[4].next = S[2].next = 3;2 号挂回空闲链最前 | a(1) d(4) c(3) | 2(2→5→6→7) |
代码已编译;对随机插入、删除序列与“数组模型”逐步比对,并检查“数据链结点数 + 空闲链结点数 = 7”始终成立。本表就是程序运行的结果。
s->next = p->next; p->next = s; 后,还要执行( )。- A. s->next->prev = p; s->prev = p;
- B. p->next->prev = s; s->prev = p;
- C. s->prev = s->next->prev; s->next->prev = s;
- D. p->next->prev = s->prev; s->next->prev = p;
展开解析
C已经执行了两句之后的状态:p → s → x(x 是 p 原来的后继),但 x->prev 仍指向 p,s->prev 还没赋值。还差两件事:s->prev = p;x->prev = s。关键是此时 p->next 已经是 s,只能通过 s->next 找到 x。
- C 的第一句
s->prev = s->next->prev:s->next 是 x,x->prev 此时还是 p,所以 s->prev = p ✓。 - C 的第二句
s->next->prev = s:x->prev = s ✓。两件事都完成。
- A 错x->prev = p 等于没改(本来就是 p),x 往回指仍然跳过了 s。
- B 错p->next 现在是 s,第一句变成 s->prev = s;第二句又改成 p,结果 s->prev 对了,但 x->prev 始终没改。
- C 对巧妙地利用 x->prev 还没被改这一点拿到了 p。
- D 错第一句 s->prev = s->prev(未赋值的垃圾值);第二句 x->prev = p,都不对。
双链表插入,p->next 一旦被改,就要通过 s->next 去找原后继。标准顺序:s->next = p->next; p->next->prev = s; s->prev = p; p->next = s。
- 循环链表遍历结束的条件是
p == L(回到头结点),不是p == NULL。 - 非循环双链表删除尾结点时
p->next为 NULL,不能再写p->next->prior,要先判空。 - 删除的结点恰好是尾指针所指的结点时,要同步修改尾指针(2021-01)。
- 静态链表的“指针”是数组下标;它和顺序表一样要预先分配空间,但不能随机存取。
2016-02、2023-02 考双链表语句的顺序;2021-01 考循环链表删除时尾指针的维护;2026-02 考遍历到最后一个结点时的空指针。都是“边界 + 顺序”:中间的结点谁都会处理,失分在头、尾和语句次序上。
- 带头结点的循环双链表 L 中,p 指向某个结点。怎样判断 p 是不是最后一个元素结点?是不是第一个元素结点?
答案
最后一个:p->next == L;第一个:p->prior == L。 - 静态链表删除一个元素后,被删的数组单元如果不挂回空闲链,会怎样?
答案
这个单元既不在数据链上也不在空闲链上,再也用不到了——相当于内存“泄漏”。删得多了,数组明明有空位却插不进去。 - 两个带头结点的循环单链表 A = (1, 2)、B = (3, 4, 5),只给尾指针 ra、rb,用 2.4 节的 Join 连接后,从新表的头结点出发依次是哪些数据?返回的尾指针指向谁?
答案
A 的头结点之后依次是 1, 2, 3, 4, 5,再回到头结点;返回 rb,指向数据为 5 的结点。B 的头结点被释放。全程 O(1)。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A.
s->prev = p->prev; s->next = p; p->prev->next = s; p->prev = s; - B.
s->prev = p->prev; s->next = p; p->prev = s; p->prev->next = s; - C.
s->next = p; p->prev = s; s->prev = p->prev; p->prev->next = s; - D.
p->prev = s; s->next = p; s->prev = p->prev; p->prev->next = s;
展开解析
A设 p 的前驱为 r。A:s 的两个指针先连好(s->prev = r,s->next = p),再让 r->next = s(此时 p->prev 还是 r),最后 p->prev = s,正确。B:先改了 p->prev = s,下一句 p->prev->next 变成了 s->next = s,r 的 next 没改。C、D:p->prev 先被改成 s,再执行 s->prev = p->prev 得到 s->prev = s,前驱丢失。
变化点:原题是删除,这里是插入。口诀:先连新结点的两个指针,再改前驱的 next,最后改 p->prev——凡是还要通过 p->prev 找前驱的语句,都必须放在修改 p->prev 之前。
- A.
s->next = h; p->next = s; - B.
s->next = h; p->next = s; p = s; - C.
p->next = s; p = s; s->next = h->next; - D.
s->next = p; p->next = s; p = s;
展开解析
B循环链表的尾结点指回头结点。插到表尾:新结点指向头结点(s->next = h),原尾结点指向新结点(p->next = s),再把尾指针移到 s。A 忘了更新 p;C 让新尾结点指向第一个元素结点,跳过了头结点;D 让 s 指回原尾结点,成了一个小环。
变化点:原题删除第一个元素时要特判“表中只有一个元素”(删完要把 p 改回 h);表尾插入不用特判,但别忘了维护尾指针,这是有尾指针的链表最常见的漏洞。
| 下标 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| data | (头) | b | a | c | (空闲) |
| next | 2 | 3 | 1 | −1 |
- A. 4、1
- B. 1、4
- C. 4、3
- D. 3、1
展开解析
A表的逻辑次序:0 → 2(a) → 1(b) → 3(c)。在 a 与 b 之间插入 d:d.next = a 原来的 next = 1,a.next = 4。
变化点:静态链表里“指针”就是数组下标,插入步骤与单链表完全相同(先 d.next = a.next,再 a.next = d 的下标),只是不需要移动元素。
2.5顺序表 vs 链表,以及算法题的七个基本功
怎么选存储结构
| 比较项 | 顺序表 | 链表 |
|---|---|---|
| 存取方式 | 随机存取:第 i 个 O(1) | 顺序存取:第 i 个 O(n) |
| 逻辑与物理 | 逻辑相邻 ⇒ 物理相邻 | 逻辑相邻不一定物理相邻,靠指针 |
| 插入、删除 | 平均移动一半元素,O(n) | 找到位置后只改指针,O(1);找位置 O(n) |
| 空间分配 | 预先分配;小了会溢出,大了浪费;动态扩充要整体搬移 | 用多少申请多少,灵活 |
| 存储密度 | = 1 | < 1(要存指针) |
| 适用场合 | 表长变化不大、经常按位访问、很少插删 | 表长变化大、频繁插删 |
- 最常做的操作是什么?频繁“按位置取元素”(比如二分查找、随机抽一个)⇒ 顺序表;频繁在中间插入、删除 ⇒ 链表。
- 元素个数能不能事先估计?能估计、变化不大 ⇒ 顺序表,空间利用率高;完全不知道会有多少 ⇒ 链表,用多少申请多少。
- 插删发生在哪里?只在表尾插删 ⇒ 顺序表也是 O(1);只在表头插删 ⇒ 单链表 O(1);两头都要 ⇒ 双链表或循环链表(见 2.4 节的大横评)。
选择题问“最节省时间的存储方式”时,几乎总是在考第 3 问:把题目要的几种操作逐个对照,看哪种结构能让所有操作都 O(1)。
基本功 ①:原地逆置(两端交换)
void Reverse(int a[], int left, int right) { // 逆置 a[left..right] while (left < right) { int t = a[left]; a[left] = a[right]; a[right] = t; left++; right--; } }
时间 O(n),空间 O(1)。2010-42 循环左移 p 位就是连做三次:逆置前 p 个、逆置后 n − p 个、逆置全部(见第 9 篇 t1)。
| 步骤 | 结果 |
|---|---|
| ① 逆置前 3 个 | 3 2 1 4 5 6 7 |
| ② 逆置后 4 个 | 3 2 1 7 6 5 4 |
| ③ 逆置全部 | 4 5 6 7 1 2 3 |
为什么三次逆置就是循环移位:把序列看成两段 X = 1 2 3、Y = 4 5 6 7,要的是 YX。先把两段各自倒过来得 X′Y′(′ 表示倒序),再整体倒一次:整体倒序会让两段交换位置、每段内部再倒回来,于是 (X′Y′)′ = YX。就像把两摞书各自翻个面,再把整排书翻个面,两摞书就换了位置、而且都正面朝上。
两个细节:① Reverse 的参数是下标区间 [left, right],前 p 个是 [0, p − 1],后面是 [p, n − 1],边界最容易差 1;② p = 0 或 p = n 时三次逆置后仍是原序列,不必特判。(对 n ≤ 29 的所有 p 由程序核对。)
类似题 ①:数组循环右移 k 位
题目:把 a[0..n−1] 循环右移 k 位(例如 1 2 3 4 5 6 7 右移 3 位得 5 6 7 1 2 3 4),要求空间 O(1)。思路:右移 k 位 = 后 k 个整体搬到前面。先整体逆置(7 6 5 4 3 2 1),再分别逆置前 k 个(5 6 7)和后 n − k 个(1 2 3 4)。
void RotateRight(int a[], int n, int k) { if (n == 0) return; k = k % n; // 右移 n 位等于没动 Reverse(a, 0, n - 1); // 整体逆置 Reverse(a, 0, k - 1); // 前 k 个再逆置 Reverse(a, k, n - 1); // 后 n-k 个再逆置 }
时间 O(n)、空间 O(1)。与 2010-42 循环左移对比:左移 p 位是“先分别逆置两段,再整体逆置”,两者只是三次逆置的分段位置不同。
基本功 ②:一趟扫描删除所有值为 x 的元素(“保留下来的往前放”)
void DelX(SqList &L, ElemType x) { int k = 0; // k:已经保留下来的元素个数 for (int i = 0; i < L.length; i++) if (L.data[i] != x) L.data[k++] = L.data[i]; // 不是 x 就放到第 k 个位置 L.length = k; }
如果每遇到一个 x 就调用一次 ListDelete,每次都要移动后面所有元素,是 O(n²);上面的写法每个元素最多移动一次,O(n)、O(1)。“把要保留的元素按顺序写回前面”这个思路也用于:删除有序表中的重复元素、删除值在 [s, t] 之间的元素、把数组按条件划分。
| i | data[i] | 保留吗 | 动作 | k | data[0..k−1] |
|---|---|---|---|---|---|
| 0 | 3 | 保留 | data[0] = 3 | 1 | 3 |
| 1、2 | 2、2 | 跳过 | — | 1 | 3 |
| 3 | 5 | 保留 | data[1] = 5 | 2 | 3 5 |
| 4 | 2 | 跳过 | — | 2 | 3 5 |
| 5 | 7 | 保留 | data[2] = 7 | 3 | 3 5 7,length = 3 |
k 始终 ≤ i,所以“往前放”永远不会覆盖还没看过的元素;每个元素最多被写一次,O(n)、O(1)。(由程序跟踪核对。)
基本功 ② 的应用:删除重复元素
有序表:相同的元素一定挨在一起,只要和“已保留的最后一个”比较。
void DelDupSorted(SqList &L) { if (L.length == 0) return; int k = 1; // data[0..k-1] 是已保留的结果 for (int i = 1; i < L.length; i++) if (L.data[i] != L.data[k - 1]) // 与已保留的最后一个不同才保留 L.data[k++] = L.data[i]; L.length = k; }
void DelDupSortedList(LinkList L) { LNode *p = L->next, *q; if (p == NULL) return; while (p->next != NULL) { q = p->next; if (q->data == p->data) { // 与 p 相同:删掉,p 不动 p->next = q->next; free(q); } else p = q; // 不同:p 往后走 } }
| i | data[i] | 与 data[k−1] 比较 | k | 已保留 |
|---|---|---|---|---|
| 1 | 1 | = 1,跳过 | 1 | 1 |
| 2 | 2 | ≠ 1,保留 | 2 | 1 2 |
| 3、4 | 2、2 | = 2,跳过 | 2 | 1 2 |
| 5 | 3 | ≠ 2,保留 | 3 | 1 2 3 |
| 6 | 5 | ≠ 3,保留 | 4 | 1 2 3 5 |
| 7 | 5 | = 5,跳过 | 4 | 1 2 3 5,length = 4 |
上表是对 1 1 2 2 2 3 5 5 的跟踪。两种写法都是 O(n)、O(1)。
无序表(保留第一次出现的):相同元素不一定挨着,有两种做法——
void DelDupUnsorted(SqList &L) { int k = 0; for (int i = 0; i < L.length; i++) { int j = 0; while (j < k && L.data[j] != L.data[i]) j++; // 在已保留的里找 if (j == k) L.data[k++] = L.data[i]; // 没找到:保留 } L.length = k; }
void DelDupFlag(SqList &L, int m) { int *flag = (int *)calloc(m + 1, sizeof(int)); // 全部置 0 int k = 0; for (int i = 0; i < L.length; i++) if (!flag[L.data[i]]) { // 第一次见到 flag[L.data[i]] = 1; L.data[k++] = L.data[i]; } L.length = k; free(flag); }
四段代码均已编译,与“集合去重”的标准答案随机对拍 2 万组。右边就是基本功 ⑦“空间换时间”,2015-41 链表按绝对值去重是同一个思路。
类似题 ②:把所有 0 移到末尾
题目:数组中所有 0 移到末尾,非 0 元素保持原来的相对次序,空间 O(1)。例:0 3 0 5 7 0 2 → 3 5 7 2 0 0 0。思路:和 DelX 一模一样——“非 0 的往前放”,最后把剩下的位置补成 0。
void MoveZeros(int a[], int n) { int k = 0; for (int i = 0; i < n; i++) if (a[i] != 0) a[k++] = a[i]; // 非 0 的往前放 while (k < n) a[k++] = 0; // 剩下的位置补 0 }
O(n)、O(1)。若改成“奇数放前面、偶数放后面,且不要求保持次序”,用第 8 篇快排的“两端向中间交换”更省(见 8.5 划分的用法)。
基本功 ③:两个有序表的归并(双指针)
// 把有序数组 A[0..m-1]、B[0..n-1] 合并成有序数组 C void Merge(int A[], int m, int B[], int n, int C[]) { int i = 0, j = 0, k = 0; while (i < m && j < n) // 两个都没取完:取较小者 C[k++] = (A[i] <= B[j]) ? A[i++] : B[j++]; while (i < m) C[k++] = A[i++]; // 剩下的直接照抄 while (j < n) C[k++] = B[j++]; }
时间 O(m + n),最多比较 m + n − 1 次。归并排序(第 8 篇)、2011-42 两序列中位数、2020-41 三元组最小距离,都是“多个有序序列,每次移动当前最小的那个指针”的变形。
| 比较 | A[i] | B[j] | 取谁 | C |
|---|---|---|---|---|
| 1 | 1 | 2 | A 的 1,i 后移 | 1 |
| 2 | 4 | 2 | B 的 2,j 后移 | 1 2 |
| 3 | 4 | 3 | B 的 3 | 1 2 3 |
| 4 | 4 | 8 | A 的 4 | 1 2 3 4 |
| 5 | 7 | 8 | A 的 7,A 取完 | 1 2 3 4 7 |
| — | B 剩下 8、9,直接照抄 | 1 2 3 4 7 8 9 | ||
共比较 5 次。“剩下的直接照抄”之所以对,是因为两个表都有序:一个表取完时,另一个表剩下的元素都不小于已放进 C 的元素。相等时取 A(用 <=),A 中的元素排在前面——要求稳定时这个等号不能丢。(程序核对。)
基本功 ③ 的链表版:两个有序链表合并
LinkList MergeList(LinkList La, LinkList Lb) { // 两个递增链表合并为递增链表,用原结点
LNode *pa = La->next, *pb = Lb->next, *r = La; // r 指向结果表的表尾
while (pa != NULL && pb != NULL) {
if (pa->data <= pb->data) { r->next = pa; r = pa; pa = pa->next; }
else { r->next = pb; r = pb; pb = pb->next; }
}
r->next = (pa != NULL) ? pa : pb; // 剩下的整段接上,不用逐个处理
free(Lb); // Lb 的头结点不再需要
return La;
}
| 比较 | pa vs pb | 接到 r 后面 | 结果表 |
|---|---|---|---|
| 1 | 1 vs 2 | 1(La) | 1 |
| 2 | 4 vs 2 | 2(Lb) | 1 2 |
| 3 | 4 vs 3 | 3(Lb) | 1 2 3 |
| 4 | 4 vs 8 | 4(La) | 1 2 3 4 |
| 5 | 7 vs 8 | 7(La);La 走完 | 1 2 3 4 7 |
| — | pa 为空 | 整段接上 8 → 9 | 1 2 3 4 7 8 9 |
La = 1 4 7、Lb = 2 3 8 9。和数组版相比,链表版不需要新空间:只是重新“穿针引线”,剩下的一段整体接上,O(1)。时间 O(m + n)。变式:要求合并成递减,就把“接到 r 后面”改成“头插到 La 之后”,最后剩下的那段也要逐个头插。
类似题 ③:两个有序数组的交集
题目:A、B 都严格递增,求公共元素。例:A = 1 3 4 6 9,B = 2 3 6 7 9 10 → 3 6 9。思路:两个指针比大小,小的那个不可能在另一边出现(另一边后面的只会更大),直接跳过;相等就是公共元素,两边一起走。
int Intersect(int A[], int m, int B[], int n, int C[]) { // 返回交集元素个数 int i = 0, j = 0, k = 0; while (i < m && j < n) { if (A[i] < B[j]) i++; else if (A[i] > B[j]) j++; else { C[k++] = A[i]; i++; j++; } } return k; }
O(m + n)。暴力做法(对 A 的每个元素在 B 里找一遍)是 O(mn)——考场上写出暴力也能拿大部分分,但“有序”这个条件就是提示你用双指针。
基本功 ④:链表原地逆置(头插法)
void ReverseList(LinkList L) { // L 带头结点 LNode *p = L->next, *r; L->next = NULL; // 先把头结点摘下来,当成一个空表 while (p != NULL) { r = p->next; // r 记住 p 的后继,防止断链 p->next = L->next; // 把 p 头插到 L 之后 L->next = p; p = r; } }
另一种写法:三指针原地逆置
void Reverse3(LinkList L) { // L 带头结点 LNode *pre = NULL, *p = L->next, *r; while (p != NULL) { r = p->next; // ① r 记住后继,防止断链 p->next = pre; // ② p 掉头指向前驱 pre = p; p = r; // ③ 三个指针一起右移 } L->next = pre; // ④ 头结点指向原来的尾结点 }
| 头插法(基本功 ④) | 三指针 | |
|---|---|---|
| 想法 | 把结点一个个摘下,插到头结点后面 | 原地把每根箭头“掉个头” |
| 头结点 | 一开始就断开,最后自然指向新首元 | 最后一步才改 L->next |
| 不带头结点时 | 要另外维护一个新链表的头指针 | 直接用:最后的 pre 就是新头指针 |
| 复杂度 | 都是 O(n) 时间、O(1) 空间,都必须先用 r 保存后继 | |
两种写法都已编译,与“数组倒过来”的结果随机对拍。
类似题 ④:只逆置第 m 到第 n 个结点
题目:带头结点的单链表,逆置第 m ~ n 个结点(1 ≤ m ≤ n ≤ 表长),其余不动。例:1 2 3 4 5 6 7,m = 2,n = 5 → 1 5 4 3 2 6 7。思路:找到第 m − 1 个结点 pre,把它当成“局部头结点”,对后面 n − m 个结点做头插。
void ReverseMN(LinkList L, int m, int n) { LNode *pre = L; for (int i = 1; i < m; i++) pre = pre->next; // pre 指向第 m-1 个 LNode *p = pre->next, *q; // p 是第 m 个,逆置后它会成为这一段的最后一个 for (int i = m; i < n; i++) { q = p->next; // 把 p 后面的结点摘下 p->next = q->next; q->next = pre->next; // 头插到 pre 之后 pre->next = q; } }
跟踪:pre = 1,p = 2。第 1 次把 3 摘下插到 1 后:1 3 2 4 5 6 7;第 2 次摘 4:1 4 3 2 5 6 7;第 3 次摘 5:1 5 4 3 2 6 7。p 始终是 2,它一路被“挤”到这一段的末尾。O(n)、O(1)。
基本功 ⑤:快慢指针
找中间结点:慢的走 1 步,快的走 2 步
LNode *slow = L, *fast = L; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } // 快指针到尾时,slow 在中间
带头结点、n 个数据结点时,结束后 slow 是第 ⌈n/2⌉ 个结点(前半段的最后一个)。
找倒数第 k 个:先让快的走 k 步
LNode *p = L->next, *q = L->next; int cnt = 0; while (p != NULL) { if (cnt < k) cnt++; // p 先走 k 步 else q = q->next; // 之后 q 跟着走 p = p->next; } // cnt < k:表长不足 k;否则 q 是倒数第 k 个
两个指针始终相距 k,p 走到空时 q 恰好在倒数第 k 个。2009-42 原题。
为什么快慢指针能找中点:fast 的速度是 slow 的 2 倍,同时出发,fast 走完全程时 slow 恰好走了一半。具体停在哪个结点要看循环条件:本书的写法(都从头结点出发,fast 能再走两步才继续)在 n = 6 时停在第 3 个、n = 7 时停在第 4 个,即第 ⌈n/2⌉ 个。2019-41 要把链表从中间断开,正好用“前半段最后一个”。
| p 指向 | 10 | 20 | 30 | 40 | 50 | 60 |
|---|---|---|---|---|---|---|
| 处理后 q 指向 | 10(cnt = 1) | 10(cnt = 2) | 20 | 30 | 40 | 50 |
前两步 p 先走、q 不动;之后两人一起走,始终相距 2。p 走完时 q 停在 50,正是倒数第 2 个。只扫一遍,不用先数表长再走 n − k + 1 步——这就是 2009-42 要的“尽可能高效”。(程序核对。)
类似题 ⑤:判断链表有没有环,并找出环的入口
LNode *CycleEntry(LinkList L) { // 有环返回入口,无环返回 NULL
LNode *slow = L, *fast = L;
while (fast != NULL && fast->next != NULL) {
slow = slow->next; fast = fast->next->next;
if (slow == fast) { // 相遇:一定有环
LNode *p = L;
while (p != slow) { p = p->next; slow = slow->next; } // 同速前进
return p; // 再次相遇处就是入口
}
}
return NULL;
}
为什么第二阶段能在入口相遇:设 L 到入口 a 步,入口到相遇点 b 步,环长 c。相遇时 slow 走了 a + b,fast 走了 2(a + b),多走的 a + b 恰是整数圈 kc,所以 a = kc − b:从相遇点再走 a 步,相当于绕整数圈再退回 b 步,正好停在入口。O(n)、O(1)。(对随机长度、随机入口的链表共 2 万组验证。)
基本功 ⑥:长度差对齐(找两链表的第一个公共结点)
两个单链表从某个结点开始合为一条(像字母 Y),后面部分完全共享。先各自求长度 m、n,让长的先走 |m − n| 步,然后两个指针同步前进,第一次指向同一个结点(比较的是指针,不是数据)时就是公共部分的起点。时间 O(m + n),空间 O(1)。2012-42 共同后缀原题。
LNode *SearchCommon(LinkList A, LinkList B) { // 带头结点,返回第一个公共结点(没有则 NULL)
int m = Length(A), n = Length(B); // Length 见 2.3 节
LNode *p = A->next, *q = B->next;
for (; m > n; m--) p = p->next; // 长的先走 |m − n| 步
for (; n > m; n--) q = q->next;
while (p != q) { p = p->next; q = q->next; } // 同步前进,比较的是地址
return p;
}
为什么先对齐:公共部分在两条链的尾部,长度相同。把长的那条多出来的头部跳过以后,两个指针离公共起点的距离就一样了,同步走必然同时到达。例:A = a1 a2 a3 c1 c2(长 5)、B = b1 c1 c2(长 3),p 先走 2 步到 a3,之后 p、q 同步:a3 与 b1 不同,c1 与 c1 相同,返回 c1。没有公共部分时两人同时走到 NULL,循环同样结束,返回 NULL。(代码与“构造好的公共部分”随机对拍 5 万组。)
类似题 ⑥:不求长度,找两链表的第一个公共结点
思路:设 A 独有部分长 a、B 独有部分长 b、公共部分长 c。指针 p 走完 A 再去走 B,q 走完 B 再去走 A:两人都走了 a + c + b 步后,正好同时到达公共部分的起点;没有公共部分时两人同时走到 NULL。
LNode *FirstCommon(LinkList A, LinkList B) { // 带头结点
LNode *p = A->next, *q = B->next;
while (p != q) { // 比较的是地址,不是数据
p = (p != NULL) ? p->next : B->next; // 走完 A 就去走 B
q = (q != NULL) ? q->next : A->next; // 走完 B 就去走 A
}
return p; // 公共结点,或者 NULL
}
例:A 独有 a1 a2,B 独有 b1 b2 b3,公共 c1 c2 c3。p 依次经过 a1 a2 c1 c2 c3 (空) b1 b2 b3 c1,q 依次经过 b1 b2 b3 c1 c2 c3 (空) a1 a2 c1,第 10 个位置同时到 c1。和基本功 ⑥ 一样 O(m + n)、O(1),只是不用先数长度。
基本功 ⑦:空间换时间(辅助数组做标记 / 计数)
当元素的取值范围已知且不大(例如 |data| ≤ n、值在 0 ~ n−1 之间),开一个大小为 n + 1 的数组 flag[],用值当下标:flag[x] = 1 表示 x 出现过。这样“判断某个值是否出现过”只要 O(1),整个算法一趟扫描 O(n),空间 O(n)。2015-41 链表绝对值去重、2018-41 未出现的最小正整数、2013-41 主元素的计数解法都是它。
int FindDup(int a[], int n) { // 返回一个重复的数 int *flag = (int *)calloc(n, sizeof(int)); // flag[x] = 1 表示 x 出现过(calloc 自动清 0) int dup = -1; for (int i = 0; i < n; i++) { if (flag[a[i]]) { dup = a[i]; break; } // 第二次见到 a[i] flag[a[i]] = 1; } free(flag); return dup; }
对 3, 1, 4, 2, 5, 4:依次把 flag[3]、flag[1]、flag[4]、flag[2]、flag[5] 置 1,读到第二个 4 时 flag[4] 已经是 1,返回 4。时间 O(n)、空间 O(n)。若不许开辅助数组,就只能两两比较 O(n²),或先排序再找相邻相等 O(n log n)——这正是“空间换时间”的取舍:值域小、能当下标是这个技巧的前提,值域很大(比如任意 int)时数组开不下,就不能用。(代码与暴力法随机对拍 5 万组。)
类似题 ⑦:找出没出现的数
题目:n 个整数,每个都在 1 ~ n 之间(可能重复),找出 1 ~ n 中没出现的。例:n = 8,4 3 2 7 8 2 3 1 → 5 6。思路:值的范围已知且不大,开 flag[1..n] 做标记。
int Missing(int a[], int n, int out[]) { // 返回没出现的个数,结果放在 out int *flag = (int *)calloc(n + 1, sizeof(int)); for (int i = 0; i < n; i++) flag[a[i]] = 1; // 出现过就做标记 int k = 0; for (int v = 1; v <= n; v++) if (!flag[v]) out[k++] = v; free(flag); return k; }
O(n) 时间、O(n) 空间。2018-41“未出现的最小正整数”就是它的变形:只要输出第一个没被标记的数,值超出 1 ~ n 的直接忽略。
- 先想暴力解(两重循环、先排序),确保至少能拿 60%~70% 的分。
- 题目说“有序”:想双指针、折半、归并。
- 题目说值的范围有限:想辅助数组计数 / 标记(空间换时间)。
- 题目要求空间 O(1):想原地逆置、原地交换、双指针;链表想头插逆置、快慢指针。
- 题目涉及“后半段”“倒数”:想快慢指针或长度差。
真题怎么考:线性表大题用到哪个基本功
| 真题 | 题目大意 | 用到的基本功 |
|---|---|---|
| 2009-42 | 链表倒数第 k 个结点 | ⑤ 快慢指针(先走 k 步) |
| 2010-42 | 数组循环左移 p 位 | ① 三次逆置 |
| 2011-42 | 两个等长升序序列的中位数 | ③ 双指针(最优解是两边同时折半,见 7.2) |
| 2012-42 | 两个单词链表的共同后缀起点 | ⑥ 长度差对齐 |
| 2013-41 | 数组的主元素 | ⑦ 计数(或“抵消”法) |
| 2015-41 | 链表中绝对值相同的只留第一个 | ⑦ 标记数组 + 删除结点 |
| 2016-43 | 集合分成两半,和之差最大 | 快排划分(8.5) |
| 2018-41 | 未出现的最小正整数 | ⑦ 标记数组 |
| 2019-41 | 链表重排成 a₁ aₙ a₂ aₙ₋₁ … | ⑤ 找中点 + ④ 逆置后半 + ③ 交替合并 |
| 2020-41 | 三个升序数组的最小三元组距离 | ③ 多指针,每次移动最小的那个 |
| 2025-41 | 每个 A[i] 与其后元素乘积的最大值 | 从右往左一趟扫描,记住后缀最大值和最小值(负数乘最小值可能最大) |
完整解答、评分细则和“只会暴力解时怎么拿分”见第 9 篇 9.1、9.2。
链表大题千变万化,代码却几乎都由下面三个骨架拼成。写题时先想“这一步用哪个骨架”,再往里填条件:
- 遍历 + 删除(pre 跟着 p 走):
pre = L; p = L->next; while (p) { if (要删) { pre->next = p->next; free(p); p = pre->next; } else { pre = p; p = p->next; } }——去重、按条件删除、删除区间内的值都是它。 - 尾插建表(r 指表尾):
r = L; …; r->next = s; r = s; …; r->next = NULL;——拆分链表、合并成新表(保持原序)用它。 - 头插建表(插在 L 之后):
s->next = L->next; L->next = s;——逆置、要求结果“倒过来”的合并用它。动手前先用 r 记住原来的后继,防止断链。
再配上快慢指针(找中点、倒数第 k 个、判环)和长度对齐(公共结点),2009 ~ 2025 年的链表大题全部能拆成这几块。
“时间复杂度 O(n)”光写结论不够稳,要用一句话说明为什么。几种常用的说法:
- “只对数组(链表)扫描一遍 / 常数遍,每个元素访问常数次,时间 O(n)。”——一趟扫描、快慢指针、长度对齐。
- “i、j 两个指针只增不减,各自最多走 n 步,总共 O(n)。”——双指针、归并、划分。
- “开了一个长度为 n 的辅助数组,空间 O(n);其余只用了几个变量。”——标记数组、计数。
- “只用了 i、j、temp 等常数个变量,空间 O(1)。”——原地逆置、原地删除。
注意“O(1) 空间”不包括题目给的输入数组本身;递归算法的空间要算递归栈的深度,不能写 O(1)。
- 改 next 之前没有先保存后继,链表从这里断开。
- 快慢指针的循环条件要写成
fast != NULL && fast->next != NULL,先判 fast 再判 fast->next,顺序不能反。 - 找公共结点比较的是地址
p == q,不是p->data == q->data。 - 题目要求空间 O(1) 时不能用标记数组;没要求时,用 O(n) 空间换 O(n) 时间往往是最优解。
- 写完用 0 个、1 个、2 个元素的小例子手动跑一遍,边界错误大多能发现。
- 64 位系统中,ElemType 为 int(4 字节)、指针 8 字节,单链表结点的存储密度是多少?顺序表呢?
答案
单链表 4 / (4 + 8) = 1/3;顺序表 1(不考虑预留的空位)。 - 要把两个递增链表合并成一个递减链表(用原结点),合并时应该用尾插还是头插?
答案
头插:每次取较小的结点头插,最先取出的最小值最后会在表尾,正好递减;其中一个表取完后,另一个表剩下的结点也要逐个头插。 - 用三次逆置把 1, 2, 3, 4, 5, 6 循环左移 2 位,写出每一步的结果。
答案
逆置前 2 个:2 1 3 4 5 6;逆置后 4 个:2 1 6 5 4 3;整体逆置:3 4 5 6 1 2。(程序核对。) - 用基本功 ③ 归并 A = 2, 5, 8 和 B = 1, 5, 9,一共比较几次?两个 5 谁在前?
答案
2 vs 1 取 1;2 vs 5 取 2;5 vs 5 相等取 A 的 5;8 vs 5 取 B 的 5;8 vs 9 取 8,A 取完,9 照抄。共比较 5 次,A 的 5 在前(<= 保证)。(程序核对。)
2.6本篇总结:一页纸 + 必会题型
表 1:各操作的时间
| 操作 | 顺序表 | 单链表(带头结点) | 说明 |
|---|---|---|---|
| 按位查找第 i 个 | O(1) | O(n) | 随机存取 vs 顺序存取 |
| 按值查找 | O(n)(有序可折半 O(log n)) | O(n) | 链表有序也不能折半 |
| 在第 i 个位置插入 / 删除 | O(n),平均移动 n/2、(n − 1)/2 | O(n)(找前驱) | 链表的时间花在“找” |
| 已知结点 p,在其后插入 / 删其后继 | — | O(1) | 前插、删 p 自己用“换数据”技巧 |
| 求表长 | O(1) | O(n) | 除非另存 length |
表 2:判空条件
| 结构 | 空表条件 |
|---|---|
| 单链表(带头结点 / 不带) | L->next == NULL / L == NULL |
| 循环单链表(带头结点) | L->next == L |
| 循环双链表(带头结点) | L->next == L(同时 L->prior == L) |
| 静态链表(0 号为头结点) | S[0].next == -1 |
表 3:必会题型
| # | 题型 | 方法 | 本书位置 |
|---|---|---|---|
| 1 | 某操作在某种存储下的时间 | 问“前驱能否 O(1) 拿到” | 2.4 大横评 |
| 2 | 平均移动次数 | 阶梯求和 ÷ 位置数;概率不等就加权 | 2.2 |
| 3 | 判断指针语句的功能 / 对错 | 画图,逐句只改一根箭头 | 2.3 跟做 |
| 4 | 双链表插入删除的语句顺序 | 覆盖 p->next 的那句放最后 | 2.4 跟做 |
| 5 | 静态链表的游标 | 先按游标还原逻辑次序,再改游标 | 2.4 |
| 6 | 数组算法题 | 逆置、保留的往前放、双指针、标记数组 | 2.5 |
| 7 | 链表算法题 | 头插逆置、快慢指针、长度差、尾插合并 | 2.5、第 9 篇 |
- 位序从 1 开始,下标从 0 开始:第 i 个是
data[i-1]。插入合法范围 1 ~ n + 1,删除 1 ~ n。 - 顺序表插入从后往前挪,删除从前往后挪。
- 单链表插入先连后面、再连前面;删除先改指针、再 free;遍历时要改 next 之前先用 r 记住后继。
- 双链表插入:改 p->next 的那句放最后;删除:两句可以互换,注意尾结点判空。
- 删除的结点恰好是尾指针 / 头指针所指时,记得同步修改这些指针(2021-01)。
- 长度为 n 的顺序表,删除第 i 个元素需要移动几个元素?在第 i 个位置插入呢?
答案
删除移动 n − i 个;插入移动 n − i + 1 个。 - 在一个带头结点的单链表中,要删除 p 所指结点的后继,写出语句。
答案
q = p->next; p->next = q->next; free(q); - 某线性表最常用的操作是“在最后一个元素之后插入”和“删除第一个元素”,用哪种存储最省时?(A. 单链表 B. 仅有头指针的循环单链表 C. 仅有尾指针的循环单链表 D. 双链表)
答案
C。尾指针 r:表尾插入在 r 之后插、再移动 r,O(1);删除第一个元素就是删 r->next->next(头结点之后),O(1)。B 要找表尾 O(n)。 - 静态链表中指针表示的是什么?它需要移动元素吗?能随机存取吗?
答案
游标是下一个元素在数组中的下标;插删不需要移动元素;不能随机存取。
2.✎本篇真题闯关(17 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
栈、队列和数组:两种“受限的线性表”与数组的压缩存储
栈和队列都是线性表,只是限制了插入和删除的位置:栈只能在一端进出(后进先出),队列一端进、另一端出(先进先出)。限制看似让它们变弱了,实际上正是这种限制让它们成为解决“括号匹配、表达式求值、递归、层次遍历、缓冲”等问题的利器。本篇最后讲数组的地址计算和特殊矩阵的压缩存储——选择题几乎年年有一道“求下标”。这一篇选择题 26 道,题型非常固定:出栈序列、循环队列、中缀转后缀、特殊矩阵下标,每一类都有标准做法。
3.0本篇地图:四类固定题型
本篇 26 道选择题,几乎每年 2 道,而且题型极其固定:出栈序列、循环队列公式、中缀转后缀、特殊矩阵下标。每类都有“标准做法”,练熟了就是送分题。下表的真题数按“挂在哪一节”统计(一题可能同时挂两节)。
| 小节 | 学完要会的事 | 真题 | 难度 | 学时 |
|---|---|---|---|---|
| 3.1 栈 | 两种 top 约定、共享栈、链栈 | 1 | ★ | 1 h |
| 3.2 出栈序列 | 模拟法、312 规则、卡特兰数、五类题型 | 7 | ★★ | 1.5 h |
| 3.3 队列 | 循环队列四种约定下的公式、链队列 | 3 | ★★ | 1.5 h |
| 3.4 双端队列 | 受限双端队列能得到哪些序列 | 6 | ★★ | 1 h |
| 3.5 表达式 | 括号匹配、中缀转后缀 / 前缀、三种求值 | 5 | ★★ | 2 h |
| 3.6 递归 | 调用栈、递归改非递归 | 1 | ★ | 0.5 h |
| 3.7 数组 | 多维数组地址、特殊矩阵下标、稀疏矩阵 | 6 | ★★ | 1.5 h |
这一篇的题先看约定再套公式:top 从 −1 还是 0 起?rear 指队尾元素还是它的下一个?矩阵下标从 0 还是 1 起?约定一换,公式就差 1。每节都给了“约定对照表”,做题前先在草稿纸上写明题目用的是哪一种。
3.1栈:只能在一端进出的线性表
为什么要“限制”:栈在解决什么问题
先看几个生活里的例子。写文档时按 Ctrl + Z 撤销,撤销的总是最近一次的修改;浏览器点“后退”,回到的总是刚刚离开的那个页面;做数学题时遇到嵌套的括号,总是先算最里面那一层。它们有一个共同点:最后发生的事最先被处理。
如果用普通的线性表来记这些事,每次都要找到“最后一个”,还可能误删中间的;而栈干脆规定只能在一端操作,“最后一个”永远就在栈顶,O(1) 就能拿到,也不可能弄乱中间的次序。限制越少,能做的事越多,出错的机会也越多;栈用“只能在一端进出”换来了简单、安全、高效。本篇后面的括号匹配、表达式求值、函数调用,都是“后来的先处理”这一规律的应用。
一摞盘子:洗好的盘子只能放在最上面,拿盘子也只能从最上面拿。最后放上去的最先被拿走——后进先出(Last In First Out,LIFO)。哪里不像:一摞盘子实在要从中间抽一个也能抽出来,栈绝对不行——除了栈顶,其他元素既不能读也不能删;而且顺序栈的高度有上限 MaxSize,盘子堆没有。
栈是只允许在一端进行插入和删除的线性表。允许操作的一端叫栈顶,另一端叫栈底;插入叫进栈(入栈、压栈,Push),删除叫出栈(弹栈,Pop)。
| 基本操作 | 功能 |
|---|---|
InitStack(&S) | 初始化空栈 |
StackEmpty(S) | 判空 |
Push(&S, x) | 进栈:x 成为新的栈顶(栈满则失败——“上溢”) |
Pop(&S, &x) | 出栈:删除栈顶并用 x 带回(栈空则失败——“下溢”) |
GetTop(S, &x) | 读栈顶元素,不删除 |
顺序栈
#define MaxSize 50 typedef struct { ElemType data[MaxSize]; int top; // 栈顶指针:栈顶元素的下标 } SqStack; void InitStack(SqStack &S) { S.top = -1; } // 空栈 top = -1 bool StackEmpty(SqStack S) { return S.top == -1; } bool Push(SqStack &S, ElemType x) { if (S.top == MaxSize - 1) return false; // 栈满 S.data[++S.top] = x; // 先加 1,再放入 return true; } bool Pop(SqStack &S, ElemType &x) { if (S.top == -1) return false; // 栈空 x = S.data[S.top--]; // 先取出,再减 1 return true; }
++S.top 和 S.top++ 的区别(C 语言基础):S.data[++S.top] = x 是“先把 top 加 1,再用加过的 top 当下标”;x = S.data[S.top--] 是“先用当前的 top 当下标取数,取完再减 1”。前置 ++/−− 先改后用,后置 ++/−− 先用后改。把这两句拆开写就是 S.top = S.top + 1; S.data[S.top] = x; 和 x = S.data[S.top]; S.top = S.top - 1;,考场上拆开写更不容易错。
| top 指向栈顶元素(初值 −1) | top 指向栈顶元素的下一个位置(初值 0) | |
|---|---|---|
| 进栈 | S.data[++S.top] = x | S.data[S.top++] = x |
| 出栈 | x = S.data[S.top--] | x = S.data[--S.top] |
| 栈空 / 栈满 | top == −1 / top == MaxSize − 1 | top == 0 / top == MaxSize |
| 元素个数 | top + 1 | top |
做题时先看题目的约定,再套相应的写法。
跟我做一遍:同一串操作,两种约定下 top 怎么变
| 操作 | 约定一:top 指栈顶元素 | 约定二:top 指下一个空位 | 栈(左为底) |
|---|---|---|---|
| 初始化 | −1 | 0 | 空 |
| Push(a) | 0(先加 1 再放:data[0] = a) | 1(先放再加 1:data[0] = a) | a |
| Push(b) | 1 | 2 | a b |
| Push(c) | 2 | 3 | a b c |
| Pop | 1(先取 data[2] 再减 1) | 2(先减 1 再取 data[2]) | a b |
| Push(d) | 2 | 3 | a b d |
两种约定的 top 始终差 1,数组里的内容完全一样。判满:约定一 top == 4(MaxSize − 1),约定二 top == 5(MaxSize)。口诀:top 指元素,进栈先加后放、出栈先取后减;top 指空位,进栈先放后加、出栈先减后取。(两种写法与链栈一起随机对拍 2 万组。)
跟我做一遍:用栈做进制转换
“除 8 取余”:每次除以 8,余数是一位八进制数。麻烦在于先得到的是最低位,而书写时要从最高位写起——先得到的后输出,正好是栈。
| 步 | n | n / 8 | 余数 n % 8(进栈) | 栈(左为底) |
|---|---|---|---|---|
| 1 | 2026 | 253 | 2 | 2 |
| 2 | 253 | 31 | 5 | 2 5 |
| 3 | 31 | 3 | 7 | 2 5 7 |
| 4 | 3 | 0 | 3 | 2 5 7 3 |
| n = 0,依次出栈输出 | 3 7 5 2 ⇒ (3752)₈ | |||
void Convert(int n, int b) { // 把十进制正整数 n 转成 b 进制输出(2 ≤ b ≤ 10) int st[32], top = -1; // 顺序栈 while (n > 0) { st[++top] = n % b; // 余数进栈:先得到的是最低位 n /= b; } while (top >= 0) printf("%d", st[top--]); // 出栈顺序 = 从最高位到最低位 }
验算:3 × 8³ + 7 × 8² + 5 × 8 + 2 = 1536 + 448 + 40 + 2 = 2026 ✓。同一段代码 b = 2 时得 2026 = (11111101010)₂。n = 0 时循环一次也不执行、什么也不输出,真要用时得单独处理。(代码对 1 ~ 100000、b = 2 ~ 10 全部回代核对。)
共享栈:两个栈共用一个数组
省在哪里:两个栈各开 5 格,0 号栈要放 7 个、1 号栈只放 2 个——0 号栈第 6 个就上溢了,而 1 号栈还空着 3 格。共用 10 格的数组,7 + 2 = 9 个都放得下。两个栈的用量往往此消彼长(一个忙时另一个闲),共享就能“互相借地方”。代价是只适合两个栈:三个栈就没有第三个“端点”可以往里长了。
typedef struct { ElemType data[MaxSize]; int top0, top1; } ShStack; // 初始 top0 = -1,top1 = MaxSize bool Push(ShStack &S, int i, ElemType x) { // i = 0 或 1:压入哪个栈 if (S.top1 - S.top0 == 1) return false; // 两个栈顶相邻:满 if (i == 0) S.data[++S.top0] = x; // 0 号栈往右长 else S.data[--S.top1] = x; // 1 号栈往左长 return true; } bool Pop(ShStack &S, int i, ElemType &x) { if (i == 0) { if (S.top0 == -1) return false; x = S.data[S.top0--]; } else { if (S.top1 == MaxSize) return false; x = S.data[S.top1++]; } return true; }
链栈
用单链表实现栈,表头作栈顶(在表头插入、删除都是 O(1)),通常不带头结点。进栈就是头插,出栈就是删除第一个结点。链栈的好处是不存在栈满上溢(只要内存够),便于多个栈共享存储空间。
typedef struct LinkNode { ElemType data; struct LinkNode *next; } LinkNode, *LiStack; void InitStack(LiStack &S) { S = NULL; } // 不带头结点:空栈就是 NULL bool StackEmpty(LiStack S) { return S == NULL; } void Push(LiStack &S, ElemType x) { // 头插:新结点成为栈顶 LinkNode *p = (LinkNode *)malloc(sizeof(LinkNode)); p->data = x; p->next = S; S = p; } bool Pop(LiStack &S, ElemType &x) { // 删除第一个结点 if (S == NULL) return false; LinkNode *p = S; x = p->data; S = S->next; free(p); return true; }
为什么栈顶放在表头而不放表尾:出栈要删除栈顶,删表尾结点得先找它的前驱,O(n);删表头 O(1)。所以链栈的进出都在表头做。
| 顺序栈 | 链栈 | |
|---|---|---|
| 空间 | 事先开好 MaxSize,可能浪费,也可能不够(上溢) | 用多少申请多少,只要内存够就不会满 |
| 每个元素的额外开销 | 没有 | 多一个指针域 |
| 进栈、出栈 | O(1),改 top | O(1),在表头插入、删除 |
| 读第 i 个元素(不出栈) | 可以按下标直接看(虽然栈的定义不允许) | 只能从栈顶往下数 |
| 适合 | 元素个数大致可以预估 | 个数变化大、或多个栈共享内存 |
链栈若带头结点,栈顶是头结点后面的第一个结点,空栈条件变成 S->next == NULL,代码多一层“->next”。408 默认不带头结点,题目说明了就按题目来。
I. 采用非递归方式重写递归程序时必须使用栈
II. 函数调用时,系统要用栈保存必要信息
III. 只要确定了入栈次序,即可确定出栈次序
IV. 栈是一种受限的线性表,允许在其两端进行操作
- A. 仅 I
- B. 仅 I、II、III
- C. 仅 I、III、IV
- D. 仅 II、III、IV
- 顺序栈 MaxSize = 10,top 指向栈顶元素的下一个位置。栈中有 4 个元素时 top 等于几?下一个进栈的元素放在哪?
答案
top = 4;下一个元素放在 data[4],放完 top 变成 5。 - 共享栈 MaxSize = 10,top0 = 3,top1 = 5(都指向各自的栈顶元素)。还能再放几个元素?
答案
空位只有下标 4,1 个(top1 − top0 − 1)。 - 用 3.1 节的 Convert 把 13 转成二进制:依次进栈的余数是什么?最后输出什么?
答案
13 → 6 余 1,6 → 3 余 0,3 → 1 余 1,1 → 0 余 1:依次进栈 1, 0, 1, 1;出栈输出 1101(8 + 4 + 1 = 13 ✓)。先算出的最低位压在栈底,最后才输出。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
I. 栈是只允许在一端进行插入和删除的线性表
II. 链栈一般以链表的表头作为栈顶,入栈、出栈都是 O(1)
III. 共享栈栈满的条件是 top1 − top0 == 1
IV. 把递归程序改写成非递归程序时一定要用栈
- A. 仅 I、II
- B. 仅 I、III、IV
- C. I、II、III、IV
- D. 仅 I、II、III
展开解析
DI 是栈的定义。II:在表头插入、删除都是 O(1),所以链栈把表头当栈顶。III:两个栈顶相邻(中间没有空位)时满,top0 + 1 == top1。IV 错:尾递归等简单递归可以直接改成循环,不需要栈(原题 I 也考这一点)。
变化点:共享栈的栈满条件依赖指针约定。若改为“top0 初值 0、top1 初值 n−1,先存再移”,栈满条件就变成 top0 == top1 + 1。做题时先看清初值。
- A.
top++; S[top] = x; - B.
S[top] = x; top++; - C.
top--; S[top] = x; - D.
S[top] = x; top--;
展开解析
C向低下标方向增长,进栈时 top 要减小;top 指向栈顶元素、初值 n(S[n] 不存在),所以必须先减再存:第一个元素存在 S[n−1]。
变化点:两个约定决定写法:增长方向决定是 ++ 还是 −−;top 指向栈顶元素还是栈顶的下一个位置决定先移后存还是先存后移。初值往往就暗示了第二个约定(初值 n、−1 表示“指向栈顶元素”)。
3.2出栈序列:判断合法性与计数(卡特兰数)
元素按固定顺序进栈,但进栈和出栈可以交替进行,于是出栈序列有很多种可能。这类题每年几乎必出一道,有三种问法:判断某个序列是否可能、求栈的最小容量、数有多少种可能。
方法一:模拟(万能)
对要检验的出栈序列,从左到右依次处理每个要输出的元素 x:
- 如果 x 还没进栈:把输入序列中 x 之前(含 x)还没进栈的元素全部依次进栈,然后弹出 x。
- 如果 x 已经在栈里:它必须正好在栈顶,弹出;如果不在栈顶,这个序列就不可能。
同时记录栈中元素个数的最大值,就是栈的最小容量。
展开解析
| 要输出 | 操作 | 操作后栈(左为栈底) |
|---|---|---|
| 3 | 1、2、3 进栈,3 出栈 | 1 2 |
| 2 | 2 在栈顶,出栈 | 1 |
| 5 | 4、5 进栈,5 出栈 | 1 4 |
| 4 | 4 在栈顶,出栈 | 1 |
| 1 | 1 在栈顶,出栈 | 空 ✓ 可能 |
第二个序列:输出 3 后栈里是 1 2(2 在顶),下一个要输出 1,但 1 不在栈顶 ⇒ 不可能。
方法二:看“三元组”规律(秒杀)
进栈序列为 1, 2, …, n 时,出栈序列 p₁ p₂ … pₙ 不可能 ⟺ 存在 i < j < k,使得 pj < pk < pi(俗称“312 模式”:先出一个大的,后面出现“小、中”的顺序)。
原因:pi 出栈时,比它小的 pj、pk 都已进栈还没出栈,并且 pj 在 pk 下面(先进栈),所以只能先出 pk 再出 pj。
等价的实用说法:对于出栈序列中的任一元素 x,排在 x 后面、且比 x 小的元素,必须是降序排列的。
用它检验 3, 1, 2, 5, 4:3 后面比 3 小的是 1, 2,是升序 ⇒ 不可能。2026-42 第 (2) 问考的就是这个结论。
跟我做一遍:栈至少要多大
| 要输出 | 需要先进栈的 | 输出后栈中(左为底) | 输出前栈中个数 |
|---|---|---|---|
| 2 | 1、2 | 1 | 2 |
| 4 | 3、4 | 1 3 | 3 |
| 3 | —(3 在栈顶) | 1 | 2 |
| 6 | 5、6 | 1 5 | 3 |
| 5 | — | 1 | 2 |
| 1 | — | 空 | 1 |
“输出前栈中个数”的最大值是 3,所以容量至少 3。关键是数“输出那一刻之前”的个数——要输出的元素自己也还在栈里。最常见的错误是数“输出后”的个数,结果少 1。(逐步表由程序模拟。)
已知前几个出栈元素,下一个可能是谁
原则只有一句:下一个出栈的,要么是当前栈顶,要么是还没进栈的元素(先把它前面的都压进去,再弹出它)。栈顶以下的元素被压着,一个也出不来。
- 4 第一个出栈,说明 1、2、3、4 都进过栈,4 出去后栈里是 1 2 3(3 在顶),5、6 还没进栈。
- p₂ 可以是栈顶 3,或没进栈的 5、6;1、2 被 3 压着,不可能。共 3 种。
- 一般地,进栈序列 1 ~ n、p₁ = k(k ≥ 2)时,p₂ 有 1 + (n − k) 种可能:栈顶 k − 1 加上 k + 1 ~ n。k = 1 时栈空,只能是 2 ~ n,n − 1 种。
程序对 n = 3 ~ 8 枚举所有出栈序列核对了这个计数。2013-02 问的是“已知 p₂,求 p₃ 有几种可能”,也是同样的思路:先弄清栈里还剩谁、谁在栈顶、谁还没进栈。
计数:n 个元素有多少种出栈序列
递推式是怎么来的:按“元素 1 是第几个出栈的”分类。若 1 是第 k 个出栈的,那么在它之前出栈的恰好是 2, 3, …, k 这 k − 1 个元素(它们在 1 上面进栈,必须在 1 出栈前全部出完),它们之间的出栈方式有 Ck−1 种;1 出栈后栈空了,剩下的 k + 1, …, n 这 n − k 个元素重新开始,有 Cn−k 种。两边独立,相乘再按 k 加起来。n = 3:1 第 1 个出栈 C₀C₂ = 2 种,第 2 个 C₁C₁ = 1 种,第 3 个 C₂C₀ = 2 种,共 5 种;n = 4:5 + 2 + 2 + 5 = 14。(程序对 n = 3、4、5 按 1 的位置分类计数核对。)
为什么是卡特兰数:每种出栈序列对应一个由 n 次 Push、n 次 Pop 组成的操作串,要求任何前缀里 Pop 的次数都不超过 Push 的次数。这样的串的个数就是 Cₙ。同样的数也出现在“n 个结点的不同二叉树的个数”(5.5 节,2015-02)、“先序序列固定的二叉树个数”等问题里。
为什么这些问题的答案都一样:它们都能和“n 对括号的合法排列”一一对应。进栈记成“(”、出栈记成“)”,合法的操作串就是一串配得上的括号;给定先序序列的二叉树,用“进入一个结点 / 离开一个结点”也能写成一串括号。只要两个问题之间能建立一一对应,答案的个数就一定相同——这是数学里证明计数问题最常用的办法,上面的翻折法也是这个思路。
为什么是 C(2n, n)/(n + 1):翻折法
C(m, k) 表示从 m 个位置里挑 k 个的方法数,C(m, k) = m! / (k!(m − k)!)。例如 6 次操作里挑 3 次作“进栈”:C(6, 3) = 720 / (6 × 6) = 20。
把一次进栈画成“往右上走一步”(U),一次出栈画成“往右下走一步”(D)。n 个元素全部进出一次,就是一条从高度 0 出发、走 2n 步、回到高度 0 的折线。合法的要求是:任何时刻出栈次数不超过进栈次数,即折线从不低于 0。
- 全部折线:2n 步里挑 n 步向上,共 C(2n, n) 条。
- 不合法的折线一定在某一刻第一次碰到 −1。把这一刻之后的每一步上下颠倒,终点从 0 变成 −2;反过来,任何一条终点为 −2 的折线都必然碰过 −1,再翻回去就得到一条不合法折线。所以“不合法折线”和“终点为 −2 的折线”一一对应。
- 终点为 −2 的折线有 n − 1 步向上、n + 1 步向下,共 C(2n, n + 1) 条。
- 合法的 = C(2n, n) − C(2n, n + 1)。因为 C(2n, n + 1) = C(2n, n) × n/(n + 1),所以结果是 C(2n, n)/(n + 1)。
n = 3:全部 20 条,不合法 C(6, 4) = 15 条,合法 5 条,正是 C₃ = 5。(程序对 n = 1 ~ 8 穷举全部折线,确认“不合法数 = C(2n, n + 1)”且翻折是一一对应。)
进栈序列 1 ~ n,以 1 开头:1 进栈立刻出栈,剩下 2 ~ n 随意,共 Cn−1 种。以 2 开头:1、2 进栈后 2 出栈,1 压在栈底,接下来相当于对 1, 3, 4, …, n 这 n − 1 个元素(1 已在栈中,但任何合法操作串本来就以“1 进栈”开头)做任意出栈,也是 Cn−1 种。以 n 开头:只有 1 种(n, n−1, …, 1)。2026-42 第 (3)(4) 问就是这个。
五类题型一张表
| # | 问法 | 方法 | 例(进栈序列 1, 2, …, n) |
|---|---|---|---|
| 1 | 某序列能否得到 | 模拟;或找“312 模式” | 3 1 2 5 4:3 后面比 3 小的 1、2 是升序 ⇒ 不能 |
| 2 | 栈的最小容量 | 模拟,记栈中元素个数的最大值 | 出栈 2 4 3 6 5 1:最多同时有 1 3 4(或 1 5 6)三个 ⇒ 容量 3 |
| 3 | 已知前几个出栈元素,下一个可能是谁 | 栈顶元素,或任何还没进栈的元素 | n = 5,p₁ = 2:p₂ 可以是栈顶 1,或未进栈的 3、4、5,共 4 种 |
| 4 | 以 k 开头的序列有几种 | k = 1、2 时都是 Cn−1;k = n 时只有 1 种 | 见下表 |
| 5 | 一共有几种 | 卡特兰数 C(2n, n)/(n + 1) | n = 4:14 种;n = 5:42 种 |
| 以 k 开头 | k = 1 | k = 2 | k = 3 | k = 4 | k = 5 | 合计 |
|---|---|---|---|---|---|---|
| n = 4 | 5 | 5 | 3 | 1 | — | 14 |
| n = 5 | 14 | 14 | 9 | 4 | 1 | 42 |
以上数字全部由程序枚举所有出栈序列统计;并验证了“312 规则”与模拟法对 n ≤ 7 的全部排列判断一致。
真题怎么考
- 某个序列能不能得到(2010-01 附加了“不允许连续三次退栈”、2022-02 问输入输出排列的一般性质):模拟法最稳,312 规则最快。但附加了操作限制时只能模拟——312 规则只回答“栈能不能做到”,管不了“连续出栈几次”这类限制。
- 栈的最小容量(2009-02,栈和队列配合):先看清“出队顺序 = 出栈顺序”(每个元素出栈后立即进队),再像上面那样数“输出前”栈里有几个。
- 计数(2011-02 以某元素开头、2013-02 已知 p₂ 求 p₃ 的可能个数):画出“此刻栈里有谁、谁在栈顶、谁还没进栈”,用“栈顶或未进栈”的原则数;问总数时用卡特兰数。
- 给出操作串求输出(2020-02 给出 Push / Pop 的次序):照着操作逐个执行,Pop 弹出的是栈顶,不是最早进栈的那个。
- 栈与队列组合(2018-02:队头元素可以直接输出,也可以先进栈):相当于“一个栈 + 一条可以直接输出的通道”,逐个选项模拟。
- 大题(2026-42):出栈序列合法的条件与计数,第 9 篇 t8 有完整解答。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
C元素出栈后立即入队,队列不改变次序,所以出队顺序就是出栈顺序 b, d, c, f, e, a, g。模拟并记录栈中元素个数的最大值:
| 要输出 | 操作 | 栈(左为底) | 个数 |
|---|---|---|---|
| b | a、b 进栈,b 出 | a | 峰值 2 |
| d | c、d 进栈,d 出 | a c | 峰值 3 |
| c | c 在栈顶,出 | a | 1 |
| f | e、f 进栈,f 出 | a e | 峰值 3 |
| e、a | 依次出 | 空 | 0 |
| g | g 进栈,g 出 | 空 | 峰值 1 |
- A 错容量 1 只能得到与进栈顺序完全相同的序列。
- B 错d 出栈前栈里有 a、c、d 三个。
- C 对最大同时 3 个。
- D 错4 够用,但不是“至少”。
求栈的最小容量 = 模拟时栈中元素个数的最大值(注意是“要弹出某元素之前”那一刻的个数)。
- A. n − 3
- B. n − 2
- C. n − 1
- D. 无法确定
展开解析
C按 p₁ 的取值分类,看 p₂ = 3 时 p₃ 能取什么。
- p₁ = 1:1 进出;2、3 进栈,3 出。栈中剩 2,p₃ 可以是 2(弹栈),也可以是 4 ~ n 中任意一个(继续进栈到它再弹出)。
- p₁ = 2:1、2 进栈,2 出;3 进出。栈中剩 1,p₃ 可以是 1 或 4 ~ n。
- p₁ = 4:1~4 进栈,4 出,此时栈顶正好是 3,p₂ = 3 ✓。栈中剩 1、2(2 在顶),p₃ 可以是 2 或 5 ~ n。
- p₁ ≥ 5:p₁ 出栈后栈顶是 p₁ − 1 ≥ 4,p₂ 不可能是 3。
- 合并:p₃ 能取 1、2、4、5、…、n,即除 3 以外的所有值,共 n − 1 个。
- A 错漏掉了 1 和 2 两种。
- B 错漏掉了 1 或 2 中的一种(只考虑了 p₁ = 1 的情况会漏掉 1)。
- C 对除 3 以外都可以。
- D 错可以确定。
“某个位置可能取几个值”:按前面几个出栈元素分类,各类求并集。
- 进栈序列 1, 2, 3, 4, 5,以 3 开头、并且 5 最后出栈的出栈序列有几种?
答案
3 出栈后栈里是 1 2,2 必须在 1 前;4 可以插在 2 前、2 与 1 之间、1 后;5 最后:3 4 2 1 5、3 2 4 1 5、3 2 1 4 5,共 3 种。 - 6 个元素依次进栈,共有多少种出栈序列?以 6 开头的有几种?
答案
C(12, 6)/7 = 924/7 = 132 种;以 6 开头时 1 ~ 6 全部进栈后才出,只能是 6 5 4 3 2 1,1 种。 - 进栈序列 1 ~ 5,若第一个出栈的是 3,第二个出栈的可能是哪些元素?
答案
3 出栈后栈里是 1 2(2 在顶),4、5 未进栈。第二个可以是栈顶 2,或未进栈的 4、5;1 被 2 压着,不可能。共 3 种 = 1 + (5 − 3)。(程序枚举核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2
- B. 3
- C. 4
- D. 5
展开解析
C出队顺序就是出栈顺序。模拟:a、b、c 进栈(3 个),c 出;d、e 进栈,栈中 a b d e(4 个),e、d、b 出;f 进(a f)f 出;g 进 g 出;a 出。最多同时 4 个元素。
变化点:原题最大深度出现在 a、d、e 同时在栈里(3 个);这里 c 出栈后 a、b 还压在底下,再进 d、e 就到了 4。方法一样:严格模拟,每次进栈后记录栈中元素个数。
- A. 4
- B. 5
- C. 9
- D. 14
展开解析
Cc 先出,说明 a、b 已进栈(b 在上),d、e 还没进。按第二个出栈的元素分类:
① 第二个是 b:栈中只剩 a,d、e 未进,相当于“a 已在栈中”的 3 元素问题,有 5 种(a d e、a e d、d a e、d e a、e d a);
② 第二个是 d:栈中 a b,e 未进,有 b a e、b e a、e b a 共 3 种;
③ 第二个是 e:d 必须已进栈并压在 e 下面,之后只能依次弹出 d b a,1 种。
合计 5 + 3 + 1 = 9。
变化点:原题以 d 开头时 a、b、c 都在栈中且只剩 e 未进,e 只能插在 c、b、a 之间的 4 个空位里,共 4 种。以 c 开头时未进栈的元素变多,要按第二个元素分类再数。
- A. 2
- B. n − 3
- C. n − 1
- D. n − 2
展开解析
D3 先出,栈中剩 1、2(2 在栈顶),4 ~ n 未进栈。p2 可以是栈顶 2,也可以是 4 ~ n 中任意一个(进到它为止再出栈);不能是 1(被 2 压着)。共 1 + (n − 3) = n − 2 个。
变化点:原题给 p2 = 3 问 p3,1 和 2 的情况要分开讨论(得 n − 1);这里给的是 p1,已进栈的元素中只有栈顶能出,未进栈的都能出。
- A. a, b, c, d
- B. b, c, a, d
- C. a, c, d, b
- D. a, c, b, d
展开解析
DPush a, Pop → a;Push b, Push c, Pop → c,Pop → b;Push d, Pop → d。出栈序列 a, c, b, d,e 还没进栈。
变化点:只换了操作次序。这种题一步一步模拟即可,写下每步之后栈的内容最不容易错。
3.3队列与循环队列
食堂排队:新来的人排到队尾,打到饭的人从队头离开。先来的先走——先进先出(First In First Out,FIFO)。
为什么需要队列:栈处理的是“最近的先办”,可很多事情讲究公平——先来的先办。打印店接了一串打印任务,总该按交来的先后打;网卡收到的数据包,要按到达的先后交给程序;操作系统里排队等 CPU 的进程,最朴素的规则也是先来先服务。这些场景只需要“在一头加、在另一头取”,队列就是为它们量身定做的。哪里不像:食堂排队可以有人插队、中途离开,队列不行——除了队头,谁也不能出来。
队列是只允许在一端插入(队尾 rear,入队 EnQueue)、在另一端删除(队头 front,出队 DeQueue)的线性表。操作系统中打印机的缓冲区、进程的就绪队列、树的层次遍历、图的广度优先搜索都用队列。
顺序队列的“假溢出”
用数组存队列,front 指向队头元素,rear 指向队尾元素的下一个位置。入队 Q.data[Q.rear++] = x,出队 x = Q.data[Q.front++]。问题是 front 和 rear 只增不减:出队后前面空出来的位置再也用不上,rear 到了数组末尾就无法入队,尽管数组前面还有空位——这叫假溢出。
循环队列:把数组首尾接起来
bool EnQueue(SqQueue &Q, ElemType x) { if ((Q.rear + 1) % MaxSize == Q.front) return false; // 队满 Q.data[Q.rear] = x; Q.rear = (Q.rear + 1) % MaxSize; // 队尾指针后移,到末尾就绕回 0 return true; } bool DeQueue(SqQueue &Q, ElemType &x) { if (Q.front == Q.rear) return false; // 队空 x = Q.data[Q.front]; Q.front = (Q.front + 1) % MaxSize; return true; }
补课:取模怎样让下标“绕回来”
a % M 是 a 除以 M 的余数,结果总在 0 ~ M − 1 之间(a ≥ 0 时)。所以 (rear + 1) % M 就像钟表:M = 6 时,rear 依次为 0, 1, 2, 3, 4, 5,加 1 再取模得 1, 2, 3, 4, 5, 0——走到 5 之后自动回到 0,数组首尾就“接”起来了。
元素个数公式为什么要“+ M”:队列绕了一圈以后,可能出现 rear < front。例如 M = 6、front = 4、rear = 1,队中的元素在 4、5、0 号单元,共 3 个。直接算 rear − front = −3,不对;加上一圈的长度 M:−3 + 6 = 3 ✓。而 rear ≥ front 时,加了 M 会多出一圈,再 % M 去掉。所以 (rear − front + M) % M 两种情况都对。
还有一个只在 C 语言里才有的坑:C 的 % 对负数的结果带负号,(1 − 4) % 6 在 C 里得 −3,不是数学上的 3。所以代码里必须写成 (rear − front + M) % M,先把它变成非负数再取模;省掉 + M 的写法在 rear < front 时会算出负数。(两种写法的差别由程序按 C 的取模规则核对。)
- 增设 size 域:队空 size == 0,队满 size == MaxSize,此时 front == rear 两种情况都可能出现,可以用满全部单元。
- 增设 tag 域:最近一次操作是删除则 tag = 0、插入则 tag = 1。front == rear 且 tag == 0 为空,且 tag == 1 为满。
- rear 指向队尾元素本身(不是下一个位置):入队要先 rear = (rear+1)%M 再放入。若要求第一个元素放在 A[0],初始 rear 就应是 M − 1、front = 0(2011-03)。此时队空条件变成 (rear + 1) % M == front。
四种约定的公式对照表(M = MaxSize)
| ① rear 指队尾下一个,牺牲一格(默认) | ② 同①,增设 size | ③ 同①,增设 tag | ④ rear 指队尾元素,牺牲一格 | |
|---|---|---|---|---|
| 初始 | front = rear = 0 | front = rear = 0,size = 0 | front = rear = 0,tag = 0 | front = 0,rear = M − 1 |
| 队空 | front == rear | size == 0 | front == rear 且 tag == 0 | (rear + 1) % M == front |
| 队满 | (rear + 1) % M == front | size == M | front == rear 且 tag == 1 | (rear + 2) % M == front |
| 元素个数 | (rear − front + M) % M | size | 同①(满时为 M) | (rear − front + 1 + M) % M |
| 入队 | data[rear] = x; rear = (rear+1)%M | 同①,size++ | 同①,tag = 1 | rear = (rear+1)%M; data[rear] = x |
| 最多存 | M − 1 个 | M 个 | M 个 | M − 1 个 |
记法:约定④只是把①的 rear 整体往回挪了一格,所以①里出现 rear 的地方都换成 rear + 1。(四种写法都已编成代码,与一个“标准队列”模型随机对拍 2 万组,含队空、队满边界。)
跟我做一遍:M = 6 的循环队列
| 操作 | front | rear | 判断 | 队中(从队头起) |
|---|---|---|---|---|
| 初始 | 0 | 0 | front == rear:空 | — |
| 入队 a ~ e | 0 | 5 | (5 + 1) % 6 = 0 == front:满 | a b c d e |
| 出队两次 | 2 | 5 | 取出 a、b | c d e |
| 入队 f | 2 | 0 | f 放在 data[5],rear 绕回 0 | c d e f |
| 入队 g | 2 | 1 | g 放在 data[0];(1 + 1) % 6 = 2 == front:又满了 | c d e f g |
最后元素个数 (1 − 2 + 6) % 6 = 5 = M − 1。注意第二步:数组 6 格只放了 5 个就满了,空着的那一格就是被“牺牲”的。
换成约定④(rear 指队尾元素本身)再做一遍:初始 front = 0、rear = M − 1 = 5(这样第一个元素才会放进 data[0])。
| 操作 | front | rear | 判断 |
|---|---|---|---|
| 初始 | 0 | 5 | (5 + 1) % 6 = 0 == front:空 |
| 入队 a ~ e | 0 | 4 | 每次先 rear 加 1 再放;(4 + 2) % 6 = 0 == front:满 |
| 出队两次 | 2 | 4 | 取出 a、b |
| 入队 f | 2 | 5 | f 放在 data[5] |
| 入队 g | 2 | 0 | g 放在 data[0];(0 + 2) % 6 = 2 == front:满,个数 (0 − 2 + 1 + 6) % 6 = 5 |
数组里的元素和约定①一模一样,只是 rear 的值处处比约定①小 1——这正是对照表里“把 rear 换成 rear + 1”的意思。(两种约定的逐步结果由程序模拟。)
- A. 0,0
- B. 0,n − 1
- C. n − 1,0
- D. n − 1,n − 1
展开解析
B本题的约定和常规不同:rear 指向队尾元素本身(不是下一个位置)。于是入队时要先让 rear 后移,再存放:rear = (rear + 1) % n; A[rear] = x。
- 第一个元素要放在 A[0],入队后 rear = 0,所以入队前 rear = n − 1((n − 1 + 1) % n = 0)。
- 第一个元素入队后它就是队头,front 应指向 A[0],而入队操作不改变 front,所以初始 front = 0。
- 初值:front = 0,rear = n − 1。
- A 错若 rear 初值为 0,先后移再存放,第一个元素会放在 A[1]。
- B 对0,n − 1。
- C 错front 初值为 n − 1 时,第一个元素入队后 front 不指向它。
- D 错front 初值 n − 1 的问题同 C(第一个元素入队后 front 不指向它);rear = n − 1 倒是对的。
rear 指向队尾元素时:入队“先移后放”;想让第一个元素放在 A[0],rear 初值就是 n − 1。
链队列
用带头结点的单链表,front 指向头结点,rear 指向最后一个结点。入队在 rear 后面插入;出队删除头结点后的第一个结点。
typedef struct LinkNode { ElemType data; struct LinkNode *next; } LinkNode; typedef struct { LinkNode *front, *rear; } LinkQueue; void EnQueue(LinkQueue &Q, ElemType x) { LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode)); s->data = x; s->next = NULL; Q.rear->next = s; Q.rear = s; } bool DeQueue(LinkQueue &Q, ElemType &x) { if (Q.front == Q.rear) return false; // 队空 LinkNode *p = Q.front->next; x = p->data; Q.front->next = p->next; if (Q.rear == p) Q.rear = Q.front; // 删的是最后一个元素:rear 要退回头结点 free(p); return true; }
不带头结点时,front、rear 都直接指向数据结点,空队列时两者都是 NULL。入队、出队都要多处理一种情况:
void EnQueue(LinkQueue &Q, ElemType x) { // 不带头结点 LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode)); s->data = x; s->next = NULL; if (Q.front == NULL) { Q.front = Q.rear = s; } // 空队列:新结点既是队头也是队尾 else { Q.rear->next = s; Q.rear = s; } } bool DeQueue(LinkQueue &Q, ElemType &x) { if (Q.front == NULL) return false; LinkNode *p = Q.front; x = p->data; Q.front = p->next; if (Q.rear == p) Q.rear = NULL; // 删的是最后一个:队列变空 free(p); return true; }
两种写法有一个共同的坑:删除最后一个元素时必须改 rear,否则 rear 指着已经释放的结点(2021-01 链表题同理)。
算法题常客:用两个栈实现一个队列
栈是“后进先出”,队列是“先进先出”。两个栈怎么凑出一个队列?把元素在两个栈之间倒一次,次序就反过来了,反两次正好回到原来的次序。
#define MaxSize 100 typedef struct { int data[MaxSize]; int top; } SqStack; // 顺序栈,top 指栈顶元素,空栈 -1 SqStack S1 = {{0}, -1}, S2 = {{0}, -1}; // S1 只管入队,S2 只管出队 void EnQueue(int x) { S1.data[++S1.top] = x; } // 入队:压进 S1 bool DeQueue(int *x) { if (S2.top == -1) { // S2 空了,才把 S1 整个倒进 S2 if (S1.top == -1) return false; // 两个都空:队空 while (S1.top != -1) S2.data[++S2.top] = S1.data[S1.top--]; } *x = S2.data[S2.top--]; // S2 的栈顶就是最早入队的元素 return true; }
| 操作 | S1(左为底) | S2(左为底) | 出队得到 |
|---|---|---|---|
| 入队 1、2、3 | 1 2 3 | 空 | |
| 出队 | 空 | 3 2(倒过来是 3 2 1,弹出 1) | 1 |
| 入队 4 | 4 | 3 2 | |
| 出队、出队 | 4 | 空 | 2、3 |
| 出队 | 空 | 空(倒入 4 后弹出) | 4 |
出队顺序 1, 2, 3, 4,正是入队顺序。关键规则是只有 S2 空了才倒:若 S2 里还有元素就把 S1 倒进去,新来的会压在旧元素上面,次序就乱了(入队 4 后若立刻倒,4 会先于 2、3 出队)。每个元素一辈子最多被压进 S1、倒进 S2、从 S2 弹出各一次,所以 n 次操作总共 O(n),平均每次 O(1)。某一次出队可能要倒很多个元素,但平均下来很快——这种“偶尔一次慢、平均很快”的算法分析叫均摊分析拓展。
考题若规定两个栈都有容量上限,还要处理“S1 满了”:S2 为空时可以先把 S1 倒过去再入队;S2 不空时就是队满。反过来“用两个队列实现一个栈”也常考:每次出栈时,把一个队列里除最后一个以外的元素都转到另一个队列,最后那个就是栈顶。(代码与队列模型随机对拍 20 万次操作;跟踪表由程序打印。)
链队列不会队满,适合元素个数变化大的场合。2019-42 要求“空间只增不减、出队的结点可重复使用”,答案是循环单链表实现的队列(用链式结构才能增加空间,首尾相接才能复用出队的结点),见第 9 篇 t8。
- 循环队列 Q[0..7],rear 指向队尾元素(约定④),front = 3,rear = 1。队中有几个元素?
答案
(1 − 3 + 1 + 8) % 8 = 7(下标 3、4、5、6、7、0、1)。 - 循环队列用 M 个单元,采用“增设 size”的办法,最多能存几个元素?牺牲一格的办法呢?
答案
增设 size:M 个;牺牲一格:M − 1 个。 - 用 3.3 节“两个栈实现队列”的代码:入队 1、2、3,出队 1 次,入队 4、5,再出队 3 次。四次出队依次得到什么?最后两个栈里各剩什么?
答案
第 1 次出队时 S2 空,把 1 2 3 倒进去,得 1;入队 4、5 进 S1;接着出队得 2、3(S2 里还有,不倒);第 4 次 S2 空了,才把 4 5 倒进去,得 4。最后 S1 空,S2 剩 5。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 0,0
- B. 0,n−1
- C. n−1,0
- D. n−1,n−1
展开解析
D入队时 rear 先加 1 再存:第一个元素存在 A[0],所以 rear 初值是 (0 − 1 + n) % n = n − 1。入队后队头元素在 A[0],front 应指向它的前一个位置 n − 1,而入队不改 front,所以 front 初值也是 n − 1。
变化点:原题 front 指向队头元素本身,入队后 front 应为 0,所以初值 0、n−1。只改了 front 的含义,答案就从 B 变成 D。方法:假设进一个元素,倒推初值。
- A. 8
- B. 12
- C. 11
- D. 9
展开解析
A元素个数 = (rear − front + M) % M = (5 − 17 + 20) % 20 = 8,即 A[17]、A[18]、A[19]、A[0] ~ A[4]。
变化点:原题问判空判满(牺牲一个单元:空 front == rear,满 (rear+1)%M == front),这里问长度,公式同源。12 是直接算 17 − 5 的错误答案。
3.4双端队列、受限队列与队列的应用
双端队列:两端都可以入队和出队。再加一些限制,得到两种常考的“受限双端队列”:
全部元素进完后才输出,那么输出序列就是元素在队列里从“输出端”数起的排列。按进入的逆序把元素一个个“拿掉”:最后进入的元素一定在两端之一;拿掉它之后,倒数第二个进入的元素也必须在两端之一……有一个元素不在两端就不可能。
进出交替时(2021-02),用模拟:要输出 x 时,x 之前的元素都已进入,看能否安排每个元素从哪端进,使 x 恰好在输出端。
跟我做一遍:输入受限和输出受限各能得到什么
程序穷举的结果:栈能得到 14 种;输入受限、输出受限双端队列各能得到 22 种,但两者不完全一样。最有代表性的是以 4 开头的三个序列——4 第一个出来,说明 1 ~ 4 都已经进去了,只要看“全部进完后的排列”。
| 序列 | 输入受限(一端进,两端出) | 输出受限(两端进,一端出) |
|---|---|---|
| 4 1 3 2 | 能:都从左端进,队中是 4 3 2 1;左出 4,右出 1,左出 3,再出 2 | 不能:从输出端读应是 4 1 3 2;倒着拿掉 4 之后,下一个进入的 3 不在两端 |
| 4 2 1 3 | 不能:队中只能是 4 3 2 1;出 4 后两端是 3 和 1,出不了 2 | 能:排成 4 2 1 3(1 进;2 放左;3 放右;4 放左),依次输出 |
| 4 2 3 1 | 不能(同上,出 4 后两端是 3 和 1) | 不能:拿掉 4 后剩 2 3 1,下一个进入的 3 在中间 |
判定输出受限的技巧(全部进完再出时):按进入的逆序把元素一个个拿掉,每次拿掉的都必须在当前的两端之一。判定输入受限:进入的次序决定了队中排列(只从一端进就是逆序排列),再看能否每次从两端之一取出要输出的元素。
进出交替时怎么判断(2021-02 题型)
- 3, 1, 2, 5, 4:要先出 3,1、2、3 都得进去,而且出 3 之后紧接着要出 1、2,所以队中从左到右必须是 3 1 2。安排:1 进(哪端都行);2 从右端进 ⇒ 1 2;3 从左端进 ⇒ 3 1 2。依次出 3、1、2,队空。再要出 5:4 进,5 从左端进 ⇒ 5 4,出 5、4。能。
- 4, 1, 3, 2, 5:先出 4,1 ~ 4 都已进入;之后要依次出 1、3、2,所以那一刻队中从左到右必须是 4 1 3 2。倒着检查:4 是最后进入的,在左端 ✓;拿掉 4 后剩 1 3 2,下一个“最后进入”的是 3,可它在中间——任何一次进入都只能放在两端 ⇒ 不能。
- 方法小结:每要输出一个元素,就确定“到这一刻为止,队中必须是什么排列”;能否排成,用“按进入的逆序逐个拿掉、每次都在端点”来检查。
程序穷举输出受限双端队列对 1 ~ 5 能得到的全部序列核对:3 1 2 5 4 在内,4 1 3 2 5 不在。
- A. b, a, c, d, e
- B. d, b, a, c, e
- C. d, b, c, a, e
- D. e, c, b, a, d
展开解析
C两端都能入队、只有一端(设为左端)出队,而且是全部入队之后才出队,所以出队序列就是队列从左到右的排列。判断方法:按入队的逆序(e, d, c, b, a)逐个“拿掉”,每个被拿掉的元素此时必须在当前排列的两端之一。
- A 可能b a c d e:拿掉 e(右端)、d(右)、c(右),剩 b a,拿掉 b(左)、a。
- B 可能d b a c e:e(右)、d(左)、c(右)、b(左)、a。
- C 不可能d b c a e:拿掉 e(右)、d(左)后剩 b c a,下一个要拿 c,它在中间 ⇒ 不可能。
- D 可能e c b a d:e(左)、d(右)、c(左)、b(左)、a。
输出受限、先全进后全出:倒着拆,最后进的必在两端。
队列的应用
| 应用 | 为什么用队列 |
|---|---|
| 缓冲区(2009-01) | 主机输出快、打印机慢,数据要按写入的先后次序被取走:先进先出 |
| 树的层次遍历、图的 BFS | 先访问到的结点,它的孩子(邻居)也要先被访问:一层一层往外扩 |
| 操作系统的 FCFS 调度、就绪队列 | 先来先服务 |
| 多条并行轨道调度(2016-03) | 每条轨道就是一个队列,车只能从一头进、另一头出 |
| 页面置换 FIFO(操作系统) | 最先调入内存的页面最先被换出:用一个队列记录调入的先后次序 |
这些应用里,缓冲区几乎都用循环队列实现:大小固定、首尾相接反复使用,写的一方(生产者)在 rear 放、读的一方(消费者)在 front 取,满了写方等、空了读方等——这正是操作系统里“生产者—消费者问题”的数据结构原型。
真题怎么考(3.3、3.4)
- 循环队列的判空、判满、个数、初值(2011-03、2014-03):第一步永远是认清约定——front、rear 指元素还是指“下一个”,牺牲一格还是另设标志。2014-03 的 end1、end2 只是把 front、rear 换了个名字。
- 缓冲区、调度用什么结构(2009-01):“先写入的先取走”就是队列。
- 受限双端队列(2010-02 全部进完再出、2021-02 进出交替):前者“按进入的逆序逐个拿掉”,后者按 3.4 的逐步模拟。
- 多条轨道的调度(2016-03):每条轨道是一个队列,车只能按进入的先后驶出,所以同一条轨道里的编号必须从前到后递增,否则后面的车出不去。“至少几条轨道”就是把驶入序列分成尽量少的递增子序列——逐个把车放进“末尾比它小”的第一条轨道,放不下就新开一条。
- 设计题(2019-42):要“空间可增长 + 出队的结点能复用”,用循环单链表做队列,第 9 篇 t8 有完整解答。
- 输入 1, 2, 3, 4,写出一个栈得不到、但输出受限双端队列能得到的输出序列。
答案
例如 4 1 2 3:输出受限时排成 4 1 2 3(1 进;2 放右;3 放右;4 放左)即可;栈若先出 4,后面只能是 3 2 1。 - 为什么栈能得到的输出序列,两种受限双端队列也一定能得到?
答案
只用双端队列的同一端进出,它就是一个栈;受限双端队列都至少有一端既能进又能出,所以栈能做的它都能做(程序穷举 n = 4 也确认了这一点)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 4, 1, 3, 2
- B. 4, 2, 1, 3
- C. 4, 1, 2, 3
- D. 3, 1, 2, 4
展开解析
B4 最先出,说明 1、2、3、4 都已入队,且只能从同一端入,队中次序固定为 1 2 3 4,只能从两端取:4 之后下一个只能是 3 或 1。B 在 4 后面出 2,不可能。A:4(右)1(左)3(右)2,可以;C:4、1、2、3,可以;D:1 2 3 入队,出 3(右)、1(左)、2,再入 4 出 4,可以。
变化点:原题是输出受限(一端只能入队),这里是输入受限(一端只能出队)。对 1 2 3 4:输入受限不能得到 4213、4231;输出受限不能得到 4132、4231。4231 两种都不能得到,常考。
- A. 2
- B. 3
- C. 4
- D. 5
展开解析
B每条轨道先进先出,同一条轨道上的列车编号必须从前到后递增。贪心:来一列车,放到“末尾编号小于它且最大”的轨道,没有就开新轨道:3 → 轨 1;6 → 轨 1(3 6);1 → 轨 2;4 → 轨 2(1 4);7 → 轨 1;2 → 轨 3;5 → 轨 2;8、9 → 轨 1。共 3 条。也可以看“最长递减子序列”6, 4, 2 长度为 3,这三列车两两不能同轨。
变化点:原题序列中最长递减子序列 8, 4, 2, 1 长度为 4。结论:所需轨道数 = 驶入序列的最长递减子序列长度。
- A. 打印机的数据缓冲区
- B. 操作系统按先来先服务调度作业
- C. 函数调用时保存返回地址和局部变量
- D. 图的广度优先遍历
展开解析
C最后调用的函数最先返回,“后进先出”,用栈。A、B、D 都要求先来先处理,用队列。
变化点:记住分工:栈——函数调用/递归、括号匹配、表达式求值、DFS、进制转换;队列——缓冲区、FCFS 调度、BFS、层序遍历。
3.5栈的应用:括号匹配与表达式求值
括号匹配
表达式里的括号 ( ) [ ] { } 必须正确配对、正确嵌套。规律:最后出现的左括号最先被匹配——正好是后进先出。
- 从左到右扫描;遇到左括号就进栈。
- 遇到右括号:栈空则失败(右括号多了);否则弹出栈顶,看是否与之同类,不同类则失败。
- 扫描结束时栈必须为空,否则失败(左括号多了)。
bool BracketCheck(char str[], int n) { char st[100]; int top = -1; // 用数组当栈 for (int i = 0; i < n; i++) { char c = str[i]; if (c == '(' || c == '[' || c == '{') st[++top] = c; else if (c == ')' || c == ']' || c == '}') { if (top == -1) return false; // 右括号多余 char t = st[top--]; if ((c == ')' && t != '(') || (c == ']' && t != '[') || (c == '}' && t != '{')) return false; // 类型不配 } } return top == -1; // 左括号多余则失败 }
栈里同时存在的元素个数 = 括号的最大嵌套深度。2025-02 问“栈容量为 3 时哪个表达式不能处理”,就是找嵌套超过 3 层的那个。
| 读到 | 动作 | 栈(左为底) |
|---|---|---|
| { | 进栈 | { |
| [ | 进栈 | { [ |
| ( | 进栈 | { [ ( |
| ) | 弹出 (,配对 | { [ |
| ( | 进栈 | { [ ( |
| ) | 弹出 (,配对 | { [ |
| ] | 弹出 [,配对 | { |
| } | 弹出 {,配对 | 空 ⇒ 匹配 |
| 读到 | 动作 | 栈(左为底) |
|---|---|---|
| ( | 进栈 | ( |
| [ | 进栈 | ( [ |
| ) | 弹出的是 [,与 ) 不配 | 失败 |
([)] 里每种括号的个数都对,但嵌套错了:“) ”想关的是最近的那个左括号,而最近的是“[”。只数个数的方法(左右括号数相等就算对)判断不了这种错误,栈可以。
第一例栈中最多同时有 3 个括号({ [ (),即最大嵌套深度 3。两例均由程序逐步模拟。
三种表达式
| 名称 | 运算符位置 | 例:(a + b) × c − d | 特点 |
|---|---|---|---|
| 中缀表达式 | 在两个操作数中间 | (a + b) × c − d | 人习惯的写法,需要括号和优先级 |
| 后缀表达式 (逆波兰式) | 在两个操作数之后 | a b + c × d − | 没有括号,从左到右扫一遍就能算 |
| 前缀表达式 (波兰式) | 在两个操作数之前 | − × + a b c d | 没有括号,从右到左扫一遍就能算 |
三种表达式 = 表达式树的三种遍历
- 按运算顺序,给每一个运算都加上一对括号:((a + b) × c) − d。
- 转后缀:把每个运算符移到它所在括号的右括号后面,再去掉括号:((a b)+ c)× d)− ⇒ a b + c × d −。
- 转前缀:把每个运算符移到左括号前面:−(×(+(a b) c) d) ⇒ − × + a b c d。
操作数的相对次序在三种表达式中完全相同,只有运算符的位置在变——可以先用这点排除选项。
- 加括号:× 和 / 同级,从左往右算,先算 ×:(((a + b) × (c − d)) / e)。
- 后缀:每个运算符移到自己那对括号的右括号后:(((a b)+ (c d)−)× e)/ ⇒ a b + c d − × e /。
- 前缀:每个运算符移到自己那对括号的左括号前:/(×(+(a b) −(c d)) e) ⇒ / × + a b − c d e。
- 检查:操作数次序 a b c d e 三处都一样;运算符个数 4 个;后缀从左往右、前缀从右往左各算一遍,结果都应是 ((a + b) × (c − d)) / e。
两个结果由程序(栈法转后缀、表达式树先序得前缀)核对。最容易错的是第 1 步:同级运算一定从左往右加括号,写成 (a + b) × ((c − d) / e) 虽然数值相等,转出来的后缀却是 a b + c d − e / ×,和标准答案不一样。
后缀表达式求值:一个操作数栈
- 从左到右扫描。遇到操作数就进栈。
- 遇到运算符:弹出两个操作数,先弹出的是右操作数,后弹出的是左操作数,计算“左 op 右”,结果进栈。
- 扫描完,栈中唯一的元素就是结果。
例:a b + c × d −,设 a=1, b=2, c=3, d=4:1 进、2 进、遇 + 弹出 2 和 1 得 3 进栈、3(c)进、遇 × 弹出 3 和 3 得 9、4 进、遇 − 弹出 4 和 9 得 9 − 4 = 5。
| 扫描到 | 动作 | 操作数栈(左为底) |
|---|---|---|
| 6 2 3 | 依次进栈 | 6 2 3 |
| + | 弹出 3(右)、2(左),2 + 3 = 5 | 6 5 |
| × | 弹出 5、6,6 × 5 = 30 | 30 |
| 8 4 | 进栈 | 30 8 4 |
| / | 弹出 4(右)、8(左),8 / 4 = 2 | 30 2 |
| − | 弹出 2(右)、30(左),30 − 2 = 28 | 28 |
上表是后缀 6 2 3 + × 8 4 / − 的求值(对应中缀 6 × (2 + 3) − 8 / 4)。减法、除法一定要分清左右:后缀从左往右扫,先弹出的是右操作数。
操作数栈最多几个数也是常见问法:逐个记录栈的大小即可。例如 2 3 4 × + 5 −,读完 2、3、4 时栈里有 3 个数,之后 × 把它变成 2 个、+ 变成 1 个,5 进栈又是 2 个,最大是 3(结果为 9)。一般规律:连续出现的操作数越多,栈越高;一个合法的后缀表达式算完时,栈里恰好剩 1 个数,剩多了或中途不够弹,就说明表达式写错了。(程序核对。)
前缀表达式求值:从右往左扫
前缀表达式的运算符在操作数前面,所以要从右往左扫描:遇到操作数进栈,遇到运算符弹出两个——这时先弹出的是左操作数(和后缀正好相反)。
| 从右往左扫到 | 动作 | 栈(左为底) |
|---|---|---|
| 4、8 | 进栈 | 4 8 |
| / | 先弹出 8(左),再弹出 4(右),8 / 4 = 2 | 2 |
| 3、2 | 进栈 | 2 3 2 |
| + | 弹出 2(左)、3(右),2 + 3 = 5 | 2 5 |
| 6 | 进栈 | 2 5 6 |
| × | 弹出 6(左)、5(右),6 × 5 = 30 | 2 30 |
| − | 弹出 30(左)、2(右),30 − 2 = 28 | 28 |
和后缀的例子是同一个式子,结果一样是 28。拓展 用栈把中缀转成前缀:从右往左扫描,右括号进栈、遇左括号弹到右括号为止,运算符只弹出栈顶中优先级高于它的(等于不弹),最后把输出整体倒过来。(这条规则与前缀求值都已编成代码,对 2 万个随机表达式与独立的递归求值程序对拍。)
后缀还原成中缀:栈里放“式子”
和后缀求值一模一样,只是栈里放的不是数,而是一段式子:遇到运算符就弹出两段,拼成“(左 op 右)”再压回去。
| 扫描到 | 栈中的式子(左为底) |
|---|---|
| 6 2 3 | 6 | 2 | 3 |
| + | 6 | (2+3) |
| × | (6×(2+3)) |
| 8 4 / | (6×(2+3)) | (8/4) |
| − | ((6×(2+3))−(8/4)) |
最后去掉多余的括号:最外层不要;乘除的结果作为加减的操作数时不要括号;只有“低优先级的式子做高优先级运算的操作数”(如 2 + 3 做 × 的右操作数)才必须保留。得 6 × (2 + 3) − 8 / 4。
中缀转后缀:一个运算符栈(机算,要会模拟每一步)
从左到右扫描中缀表达式:
| 遇到 | 处理 |
|---|---|
| 操作数 | 直接输出(加到后缀表达式里) |
| 左括号 ( | 直接进栈 |
| 右括号 ) | 依次弹出栈顶运算符并输出,直到弹出 ((括号本身不输出) |
| 运算符 op | 依次弹出栈中优先级高于或等于 op 的运算符并输出,遇到 ( 或栈空为止;然后把 op 进栈 |
| 扫描结束 | 把栈中剩余运算符依次弹出并输出 |
为什么“等于”也要弹出:同级运算从左到右算,左边的先进栈,应先输出。优先级:× / 高于 + −;( 在栈内时优先级最低(除了被 ) 弹出,谁也弹不出它)。
展开解析
| 扫描到 | 动作 | 栈(左为底) | 已输出 |
|---|---|---|---|
| a | 输出 | a | |
| + | 栈空,进栈 | + | a |
| b | 输出 | + | a b |
| × | 栈顶 + 优先级低,不弹;× 进栈 | + × | a b |
| ( | 进栈 | + × ( | a b |
| c | 输出 | + × ( | a b c |
| − | 栈顶是 (,不弹;− 进栈 | + × ( − | a b c |
| d | 输出 | + × ( − | a b c d |
| ) | 弹出 − 输出,弹出 ( | + × | a b c d − |
| − | 弹出 ×(高)、+(同级),− 进栈 | − | a b c d − × + |
| e | 输出 | − | a b c d − × + e |
| / | 栈顶 − 低,/ 进栈 | − / | a b c d − × + e |
| f | 输出 | − / | a b c d − × + e f |
| 结束 | 弹出 /、− | a b c d − × + e f / − |
栈中同时最多有 4 个运算符(+ × ( −,括号也算)。
“栈中最多同时有几个运算符”怎么做(2012-02 题型)
| 读到 | − | × | ( | + | / | ) | − |
|---|---|---|---|---|---|---|---|
| 处理后的栈 | − | − × | − × ( | − × ( + | − × ( + / | − × | − |
| 个数 | 1 | 2 | 3 | 4 | 5 | 2 | 1 |
- 操作数直接输出,不影响栈,所以表里只列运算符和括号。左括号也占栈的位置,要算进去(题目说“操作符”时括号也算,2012-02 就是这样)。
- 读到 / 时:栈顶是 +,/ 的优先级更高,不弹,直接进栈——栈达到最多的 5 个:− × ( + /。
- 读到 ) 时把 /、+ 弹出输出,再弹出 (;读到最后的 − 时,栈中的 ×、− 优先级都不低于它,全部弹出后它再进栈。最终后缀为 a b c d e / + × − f −。
快速估计:栈最高的时刻,一般出现在“一串优先级逐个升高的运算符”或“括号里又套括号”的地方——每个新来的都比栈顶高,谁也不弹。但最稳妥的还是像上表这样逐个读、逐个记。(转换结果与每一步的栈由程序模拟。)
中缀表达式直接求值:两个栈
把上面两个算法合起来:一个运算符栈、一个操作数栈。扫描到操作数就压入操作数栈;每当按中缀转后缀的规则要“输出一个运算符”时,改为从操作数栈弹出两个数,计算后把结果压回操作数栈。2018-01 考的就是这种两栈配合的计算。
int Prior(char op) { // ( 在栈内优先级最低 return (op == '+' || op == '-') ? 1 : (op == '*' || op == '/') ? 2 : 0; } double Calc(double a, char op, double b) { // 算 a op b return op == '+' ? a + b : op == '-' ? a - b : op == '*' ? a * b : a / b; } double EvalInfix(const char *s) { // 操作数为一位数字 double num[100]; int tn = -1; // 操作数栈 char op[100]; int to = -1; // 运算符栈 for (int i = 0; s[i]; i++) { char c = s[i]; if (c >= '0' && c <= '9') num[++tn] = c - '0'; else if (c == '(') op[++to] = c; else if (c == ')') { // 算到左括号为止 while (op[to] != '(') { double b = num[tn--], a = num[tn--]; num[++tn] = Calc(a, op[to--], b); } to--; // 弹出 ( } else { while (to >= 0 && Prior(op[to]) >= Prior(c)) { // 栈顶优先级不低于当前:先算栈顶 double b = num[tn--], a = num[tn--]; // 先弹出的是右操作数 num[++tn] = Calc(a, op[to--], b); } op[++to] = c; } } while (to >= 0) { double b = num[tn--], a = num[tn--]; num[++tn] = Calc(a, op[to--], b); } return num[tn]; }
| 扫描到 | 动作 | 运算符栈 | 操作数栈 |
|---|---|---|---|
| 3 | 进操作数栈 | 3 | |
| + | 栈空,进栈 | + | 3 |
| 4 | 进栈 | + | 3 4 |
| × | 栈顶 + 比 × 低,不算;× 进栈 | + × | 3 4 |
| ( 5 | ( 进栈,5 进栈 | + × ( | 3 4 5 |
| − 2 | 栈顶是 (,− 直接进栈;2 进栈 | + × ( − | 3 4 5 2 |
| ) | 算 5 − 2 = 3,弹出 ( | + × | 3 4 3 |
| − | 先算 4 × 3 = 12,再算 3 + 12 = 15(同级也要先算);− 进栈 | − | 15 |
| 8 / 4 | 8 进栈;/ 比 − 高,进栈;4 进栈 | − / | 15 8 4 |
| 结束 | 算 8 / 4 = 2,再算 15 − 2 = 13 | 13 |
代码与本表由程序核对;对 2 万个随机表达式,“双栈直接求值”“转后缀再求值”“转前缀再求值”“后缀还原中缀再求值”四种结果与递归求值完全一致。
真题怎么考
- 中缀 ↔ 后缀 / 前缀(2024-02):加括号法最快;先用“操作数次序不变”排除选项,再核对运算符的位置。
- 转换过程中某一时刻栈的内容(2014-02)、栈中最多同时几个运算符(2012-02):必须用运算符栈逐步模拟——加括号法只给结果,给不出中间状态。
- 两个栈配合计算(2018-01):题目会规定弹出的两个数按什么次序运算(如“b op a”),照题目的定义算,不要套用“先弹出的是右操作数”的习惯。
- 括号匹配与栈容量(2025-02):栈中最多元素个数 = 最大嵌套深度,逐个表达式数一数最深嵌了几层。
- 方法什么时候不灵:① 出现单目负号、乘方(从右往左结合)时,“同级先弹”的规则要改——408 至今没考过;② 题目自己定义了优先级或运算次序时,一律按题目的规定。
- A. 5
- B. 7
- C. 8
- D. 11
展开解析
A按 3.5 节规则逐步模拟 a + b − a × ((c + d)/e − f) + g,只记录运算符栈:
| 扫描到 | 动作 | 栈(左为底) | 个数 |
|---|---|---|---|
| + | 进栈 | + | 1 |
| − | 弹出 +(同级),− 进栈 | − | 1 |
| × | 比 − 高,进栈 | − × | 2 |
| ( ( | 两个左括号进栈 | − × ( ( | 4 |
| + | 栈顶是 (,进栈 | − × ( ( + | 5 |
| ) | 弹出 +,弹出 ( | − × ( | 3 |
| / | 进栈 | − × ( / | 4 |
| − | 弹出 /,− 进栈 | − × ( − | 4 |
| ) | 弹出 −,弹出 ( | − × | 2 |
| + | 弹出 ×、−,+ 进栈 | + | 1 |
- A 对最多 5 个(括号也算操作符)。
- B 错7 恰好是表达式中 +、−、×、/ 的总个数(不含括号)——那是“一共进过栈几个运算符”,不是“同时在栈里最多几个”。
- C 错就算完全不按优先级弹出、只在遇到 ) 时才弹,栈里最多也只有 + − × ( ( + 这 6 个,8 不可能出现。
- D 错11 是表达式中操作符的总数。
运算符栈:遇到运算符先把栈里“同级或更高”的全弹出(遇 ( 停),再进栈;( 直接进,) 弹到 ( 为止。
- A. + ( × −
- B. + ( − ×
- C. / + ( × − ×
- D. / + − ×
展开解析
B模拟 a / b + (c × d − e × f) / g,扫描到 f 时停下:
- / 进栈 → [/];遇 +,弹出 /,+ 进栈 → [+]。
- ( 进栈 → [+ (];× 进栈(栈顶是 ()→ [+ ( ×]。
- 遇 −:弹出 ×(更高),栈顶变成 (,停;− 进栈 → [+ ( −]。
- 遇 ×:比 − 高,直接进栈 → [+ ( − ×];接着扫描到 f(操作数,直接输出),此时栈为 + ( − ×。
- A 错第一个 × 在遇到 − 时已经被弹出。
- B 对+ ( − ×。
- C 错/ 在遇到 + 时就被弹出了;第一个 × 也已弹出。
- D 错/ 早已弹出,且漏了 (。
每遇到一个运算符,先想“栈里谁比它高或和它一样”,这些全部出栈。
- 求后缀表达式 2 3 4 × + 5 − 的值。
答案
3 × 4 = 12,2 + 12 = 14,14 − 5 = 9。 - 求前缀表达式 + 3 × 2 − 7 4 的值。
答案
从右往左:7 − 4 = 3(先弹出 7 是左操作数),2 × 3 = 6,3 + 6 = 9。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 3
- B. 4
- C. 5
- D. 6
展开解析
C关键几步:扫到 d 时栈为 + * ( −(4 个);遇 ) 弹出 −、(;遇 / 时栈顶 * 优先级相同先弹出,/ 进栈,栈为 + /;再遇 ( + * 依次进栈,扫到 g 时栈为 + / ( + *,共 5 个,为最大值。后缀表达式为 abcd−*efg*+/+h−。
变化点:栈最深处往往出现在“括号里还有高优先级运算”的地方。本节的 表达式转换小工具可以输入任意表达式核对每一步。
- A. * ( + −
- B. / ( −
- C. * ( +
- D. * ( −
展开解析
Da 输出;* 进栈;( 进栈;b 输出;+ 进栈(栈顶是 ();c 输出;遇 −:栈顶 + 与 − 优先级相同,先弹出 +,再 − 进栈,栈为 * ( −;然后扫描到 d。
变化点:A 是忘了“同级先弹出”的错误。规则:新运算符进栈前,要把栈顶优先级大于或等于它的都弹出(遇到 ( 为止)——同级也弹,这保证了同级运算从左到右结合。
- A. ab+c*def−/−
- B. ab+c*de−f/−
- C. abc+*def−/−
- D. ab+c*−def−/
展开解析
A按运算次序加括号:((a+b)*c) − (d/(e−f))。左边后缀 ab+c*,右边后缀 def−/,最后是 −:ab+c*def−/−。
变化点:快速方法:给每个运算加满括号,把运算符移到对应右括号的位置,再去掉括号。B 把 d/e 先算了,C 把 b+c 先算了,D 把减号提前了。
- A. 7
- B. 21
- C. 49
- D. 5
展开解析
A用操作数栈:6 2 3 进栈;遇 + 弹出 3、2,得 5;遇 − 弹出 5、6,算 6 − 5 = 1;3 8 2 进栈;遇 / 算 8 / 2 = 4;遇 + 算 3 + 4 = 7;遇 * 算 1 × 7 = 7。
变化点:原题用两个栈模拟,同样要注意:先弹出的是右操作数(6 − 5 而不是 5 − 6)。
- A. {a+[b*(c−d)]}+(e+f)
- B. ((a+b)*[c−(d+e)])
- C. [a*(b+(c−(d+e)))]
- D. (a+[b−c])*{d/(e+f)}
展开解析
C栈中元素个数的最大值 = 括号的最大嵌套层数。A、B 最深 3 层,D 最深 2 层;C 在 d 之前有 [ ( ( ( 共 4 层,超过容量。
变化点:只要数嵌套深度,不必真的模拟。数的时候从左往右,遇左括号 +1、右括号 −1,记下最大值。
- A. 11
- B. 5
- C. 13
- D. −11
展开解析
A从右往左扫描,遇到运算符时先弹出的是左操作数:6 / 2 = 3;2 × 3 = 6;8 + 6 = 14;最后 14 − 3 = 11。也可以先还原成中缀:(8 + 2 × 3) − 6 / 2 = 11。
变化点:若把弹出次序弄反,最后一步会算成 3 − 14 = −11(D)。前缀“右→左、先弹左”,后缀“左→右、先弹右”,见 3.5 前缀求值。
3.6栈与递归
0.4 节已经看到:递归调用的背后是系统的调用栈。每进入一层递归,就把这一层的参数、局部变量、返回地址压栈;返回时弹栈。所以:
- 递归的空间复杂度 = O(递归深度);递归太深会导致栈溢出。
- 递归的缺点:可能有大量重复计算(如朴素的斐波那契),函数调用本身也有时间开销。
- 递归改非递归:一般可以用栈模拟调用过程(如二叉树遍历的非递归写法);但像阶乘、斐波那契这种简单的递推,直接用循环就行,不一定要用栈(2017-02 I 错)。
- 想清楚函数的含义:用一句话说出“这个函数接收什么、返回什么”,例如“Fact(n) 返回 n!”“Depth(T) 返回树 T 的高度”。之后写代码时,相信这句话,不要钻进下一层去想。
- 找递归出口:最小的情形直接给答案,例如 n ≤ 1 返回 1、T 为空返回 0。没有出口或出口写错,递归会无限下去,直到栈溢出。
- 写递归体:把规模 n 的问题用规模更小的同类问题表示,例如 Fact(n) = n × Fact(n − 1)、Depth(T) = 1 + max(左子树高度, 右子树高度)。每次调用都必须向出口靠近。
第 5 篇的二叉树算法、第 6 篇的 DFS 几乎都是这个模式;大题里递归写法往往比非递归短得多,也更不容易错。
看得见的调用栈
调用次数 = 调用树的结点数:fib(4) 是 9 次,fib(5) 是 15 次,一般是 2·F(n + 1) − 1,随 n 指数增长;而调用栈的最大深度只有 n(沿最左边的路径 f4 → f3 → f2 → f1)。所以朴素递归的斐波那契时间 O(2ⁿ) 级、空间 O(n)。(次数与公式由程序对 n = 0 ~ 20 核对。)
递归改非递归的两条路
long long Fib(int n) { // 只记前两项 if (n <= 1) return n; long long a = 0, b = 1; for (int i = 2; i <= n; i++) { long long c = a + b; a = b; b = c; } return b; }
时间 O(n)、空间 O(1)。阶乘、斐波那契这类“后一项只依赖前几项”的递归都能这样改,用不着栈(2017-02 Ⅰ 说“必须使用栈”是错的)。
long long Fact(int n) { int st[64], top = -1; while (n > 1) st[++top] = n--; // 相当于一层层“往下调用” long long r = 1; while (top >= 0) r *= st[top--]; // 相当于一层层“返回时计算” return r; }
一般的递归(例如二叉树的先序 / 中序 / 后序遍历,第 5 篇 5.4)改写时就要自己开栈,存下“回来以后还要做的事”。
拓展 尾递归:递归调用是函数的最后一步、回来后什么都不用做(如 f(n, acc) { return n <= 1 ? acc : f(n - 1, n * acc); })。这种递归不需要保留当前栈帧,编译器可以把它直接变成循环。(两段代码与递归版本对 n = 0 ~ 20 逐一对拍。)
汉诺塔:递归最好的“教具”
三根柱子 A、B、C,A 上从大到小叠着 n 个盘子,要全部挪到 C;每次只能挪一个,大盘不能压在小盘上。直接想很难,递归地想只有三步:先把上面 n − 1 个挪到 B,再把最大的挪到 C,最后把 B 上的 n − 1 个挪到 C。“挪 n − 1 个”是同一个问题、规模小一点,交给递归去做。
void Hanoi(int n, char a, char b, char c) { // 把 n 个盘子从 a 借助 b 移到 c if (n == 0) return; // 没有盘子:什么都不做(递归出口) Hanoi(n - 1, a, c, b); // ① 上面 n−1 个先从 a 移到 b(借助 c) printf("%d: %c -> %c\n", n, a, c); // ② 最大的盘子 n 从 a 移到 c Hanoi(n - 1, b, a, c); // ③ 再把 n−1 个从 b 移到 c(借助 a) }
程序输出:1: A→C,2: A→B,1: C→B,3: A→C,1: B→A,2: B→C,1: A→C。前 3 步就是“把上面 2 个挪到 B”,第 4 步挪最大的,后 3 步“把 2 个从 B 挪到 C”——和三步思路一一对应。
- 移动次数:设为 T(n),则 T(n) = 2T(n − 1) + 1,T(0) = 0 ⇒ T(n) = 2ⁿ − 1。n = 3 是 7 次,n = 64 要 1.8 × 10¹⁹ 次——指数时间。
- 递归深度:每层把 n 减 1,最深 n 层(不算 n = 0 的空调用),空间 O(n)。时间指数级、空间却只是线性的,再次说明“递归的空间看深度,时间看调用总数”。
移动次数与递归深度由程序对 n = 0 ~ 20 核对。
拓展 记忆化:让朴素递归不再重复算
朴素递归的 fib 慢,是因为同一个 fib(k) 被算了很多遍。办法是开一个数组把算过的结果记下来,下次直接用:
long long memo[100]; // 全部初始化为 -1,表示“还没算过” long long FibMemo(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; // 算过了就直接用 return memo[n] = FibMemo(n - 1) + FibMemo(n - 2); }
每个 fib(k) 只真正计算一次,函数总共只被调用 2n − 1 次(fib(30) 只要 59 次;朴素递归光是 fib(25) 就要调用 242785 次),时间降到 O(n),空间是 O(n) 的数组加 O(n) 的递归栈。这和上面“改成循环”的思路是同一回事:都是把子问题的答案存起来。(调用次数由程序对 n = 1 ~ 90 核对。)
int S(int n) { return (n <= 0) ? 0 : S(n - 1) + n; } void main() { cout << S(1); }程序运行时使用栈来保存调用过程的信息,自栈底到栈顶保存的信息依次对应的是( )。
- A. main() → S(1) → S(0)
- B. S(0) → S(1) → main()
- C. main() → S(0) → S(1)
- D. S(1) → S(0) → main()
展开解析
A程序从 main 开始,main 调用 S(1),S(1) 又调用 S(0)。调用时压栈、返回时弹栈,所以栈底是最早的 main,栈顶是最后被调用的 S(0)。
- A 对自底向上:main → S(1) → S(0)。
- B 错把栈底、栈顶弄反了。
- C 错S(0) 是被 S(1) 调用的,不可能在 S(1) 下面。
- D 错main 最先被调用,在栈底。
调用栈自底向上 = 调用发生的先后顺序。
- 用朴素递归计算 fib(5),函数一共被调用几次?递归栈最深几层(不算 main)?
答案
调用 15 次(2 × F(6) − 1 = 2 × 8 − 1);最深 5 层(f5 → f4 → f3 → f2 → f1)。 - 递归计算 n! 的空间复杂度是多少?改成循环呢?
答案
递归:调用栈有 n 层,O(n);循环:O(1)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
int F(int n) { return (n <= 1) ? 1 : F(n - 1) + F(n - 2); } int main() { printf("%d", F(4)); }程序运行时用栈保存调用信息,则栈中同时保存的调用信息(含 main)最多有( )层。
- A. 4
- B. 5
- C. 6
- D. 9
展开解析
B最深的一条调用链是 main → F(4) → F(3) → F(2) → F(1),共 5 层。整个过程中 F 一共被调用 9 次(F(4)、F(3) 各 1 次,F(2) 2 次,F(1) 3 次,F(0) 2 次),但它们不是同时在栈中。
变化点:原题问栈底到栈顶的次序(main 在栈底);这里问最大深度。深度看最长的调用链,不看调用总次数(D 是陷阱)。
- A. 3
- B. 4
- C. 5
- D. 6
展开解析
BS(3) → S(2) → S(1) → S(0),S(0) 直接返回,共 4 层。
变化点:容易漏掉“递归出口那一层”:S(0) 虽然不再递归,但它本身也是一次调用,也要占一个栈帧。
3.7数组与特殊矩阵的压缩存储
数组的地址计算
数组是由 n 个相同类型元素构成的有限序列,每个元素受 n 个线性关系的约束(n 维数组),元素在各个线性关系中的序号叫下标,下标的取值范围叫维界(如 A[0..m−1][0..n−1] 的两个维界是 0 ~ m − 1、0 ~ n − 1)。数组是线性表的推广:一维数组就是一个定长的线性表;二维数组可以看成“每个元素本身又是一个定长线性表”的线性表。数组一旦定义,维数和维界就不再改变,所以除了初始化和销毁,只有存取元素和修改元素两种操作,不做插入删除,适合顺序存储。多维数组要映射到一维的内存:
为什么能“随机存取”:地址公式里只有乘法和加法,算任何一个元素的地址都只要常数次运算,所以访问 a[i][j] 是 O(1),与它排在第几个无关。代价是数组定义后维数和大小固定,中间插入一个元素就要整体挪动、还可能越界——这也是数组一般只“存取、修改”而不“插入、删除”的原因。
三维数组:一样是“数前面有几个”
例:int A[3][4][5](每个 4 字节)行优先,A[0][0][0] 地址 1000,则 A[2][1][3] 前面有 2 × 20 + 1 × 5 + 3 = 48 个元素,地址 1000 + 48 × 4 = 1192。(两个通式由程序对 1 ~ 4 的各种维长穷举核对。)
跟我做一遍:由两个元素的地址反推每行多长
- 设每行 n 个元素。A[2][5] 前面有 2 整行(2n 个)再加本行前 5 个:100 + 2n + 5 = 131 ⇒ n = 13。
- A[4][4] 前面有 4 × 13 + 4 = 56 个元素,地址 100 + 56 = 156。
- 检查陷阱:① 若题目说“列优先”,同样的两个地址解出的是“每列多长”;② 若元素占 L 个单元,所有“个数”都要乘 L 再加到起始地址上;③ 下标从 1 开始时,把 i、j 先减 1 再数。
2021-03 就是这个套路(先由两个已知地址解出行长,再求第三个)。本例由程序核对。
- A. 295
- B. 300
- C. 301
- D. 306
展开解析
B行数没给出,列数也没给出,但可以从两个已知地址反推每行有几个元素(设为 n)。
- 行优先:LOC(A[3][3]) = 100 + (3n + 3) × 1 = 220 ⇒ 3n = 117 ⇒ n = 39。
- LOC(A[5][5]) = 100 + (5 × 39 + 5) = 100 + 200 = 300。
- A 错295 = 100 + 5 × 39,漏加了列下标 j = 5。
- B 对300。
- C 错多算了 1,常见于把下标当成从 1 开始。
- D 错306 − 100 = 206,要等于 5n + 5 就得 n = 40.2,不是整数;把反推出的 n = 39 代进去不可能得到 306。
行优先:地址 = 基址 + (i × 列数 + j) × 元素大小;已知两个元素的地址就能反推列数。
特殊矩阵:只存必要的元素
矩阵中有许多值相同的元素或零元素,并且分布有规律,就可以只存一部分,节省空间——这叫压缩存储。所有公式都是一个套路:数一数 ai,j 前面存了多少个元素,这个数就是它在(下标从 0 开始的)一维数组里的下标。
| 矩阵(下标 1 ~ n) | ai,j 前面有几个元素 = 在 B[0..] 中的下标 k |
|---|---|
| 对称矩阵,存下三角,行优先 | i ≥ j 时:前 i − 1 行共 1 + 2 + … + (i−1) = i(i−1)/2 个,本行前面 j − 1 个 ⇒ k = i(i−1)/2 + j − 1;i < j 时用 aj,i 代替(交换 i、j) |
| 对称矩阵,存上三角,行优先 | i ≤ j 时:第 1 行 n 个、第 2 行 n − 1 个……前 i − 1 行共 (i−1)(2n−i+2)/2 个,本行从 ai,i 开始,前面 j − i 个 ⇒ k = (i−1)(2n−i+2)/2 + j − i |
| 对称矩阵,存上三角,列优先 | i ≤ j 时:第 j 列有 j 个(a1,j ~ aj,j),前 j − 1 列共 j(j−1)/2 个 ⇒ k = j(j−1)/2 + i − 1(与“下三角行优先”对称) |
| 下三角矩阵,行优先 | i ≥ j 时同对称矩阵;i < j 的常数 c 存在最后:k = n(n+1)/2 |
| 三对角矩阵,行优先 | 第 1 行 2 个,其余每行 3 个。前 i − 1 行共 3(i−1) − 1 个,本行第一个元素是 ai,i−1,ai,j 前面还有 j − i + 1 个 ⇒ k = 2i + j − 3 |
- 矩阵下标从 0 还是 1 开始?数组下标从 0 还是 1 开始?(C 语言数组从 0 开始)
- 存的是上三角还是下三角?如果要找的元素在不存的那半边,先利用对称性交换 i、j(2020-01 的 m7,2 要换成 m2,7)。
- 行优先还是列优先?
- 公式记不住没关系:老老实实数“前面有几个”,最后用一个最简单的元素(如 a1,1 应在下标 0)验算一下。
对称矩阵:四种组合一张表
以 n 阶对称矩阵存下三角(i ≥ j)、一维数组下标从 0 开始为例,“行 / 列优先”和“矩阵下标从 1 / 0 开始”组合出四个公式;数组下标改为从 1 开始时,每个结果再加 1。
| 矩阵下标从 1 开始 | 矩阵下标从 0 开始 | |
|---|---|---|
| 行优先 | k = i(i − 1)/2 + j − 1 | k = i(i + 1)/2 + j |
| 列优先 | k = (j − 1)(2n − j + 2)/2 + i − j | k = j(2n − j + 1)/2 + i − j |
- 行优先:前 4 行有 1 + 2 + 3 + 4 = 10 个;第 5 行里 a5,1 在它前面,1 个。前面共 11 个 ⇒ B[11]。
- 列优先:第 1 列(a1,1 ~ a5,1)有 5 个;第 2 列从 a2,2 开始,a2,2、a3,2、a4,2 在它前面,3 个。前面共 8 个 ⇒ B[8]。
- 验算:用公式 (2 − 1)(10 − 2 + 2)/2 + 5 − 2 = 5 + 3 = 8 ✓。同一个元素,行、列优先的位置不同,所以做题第一件事是看清是哪一种。
三对角矩阵,以及“已知位置反求下标”
- 求 a3,4 的位置:第 1 行 2 个(a1,1、a1,2),第 2 行 3 个,前 2 行共 5 个;第 3 行从 a3,2 开始,a3,2、a3,3 在它前面,2 个。共 7 个 ⇒ B[7]。公式 2i + j − 3 = 6 + 4 − 3 = 7 ✓。
- 反过来:B[k] 存的是哪个元素?三对角矩阵除第 1 行外每行 3 个,所以行号 i = ⌊(k + 1)/3⌋ + 1,再由 k = 2i + j − 3 得 j = k − 2i + 3。例:k = 7 ⇒ i = ⌊8/3⌋ + 1 = 3,j = 7 − 6 + 3 = 4,正是 a3,4。
- 对称矩阵的反求(存下三角、行优先、矩阵下标从 1、数组从 0):前 i − 1 行共 i(i − 1)/2 个,所以 i 是满足 i(i − 1)/2 ≤ k 的最大整数,j = k − i(i − 1)/2 + 1。例:k = 12,1 + 2 + 3 + 4 = 10 ≤ 12 < 15 ⇒ i = 5,j = 12 − 10 + 1 = 3,即 a5,3(验算:5 × 4/2 + 3 − 1 = 12 ✓)。
反求公式由程序逐个核对(三对角 n ≤ 29、对称矩阵 n ≤ 39 的全部元素)。考试偶尔反着问(“B[k] 对应矩阵中哪个元素”),核心仍是“数前面有几个”,只是倒过来数。
- A. 86
- B. 87
- C. 88
- D. 89
展开解析
B数 m30,30 前面有几个元素:
- 第 1 行存 2 个(m1,1、m1,2),第 2 ~ 29 行每行 3 个:2 + 28 × 3 = 86 个。
- 第 30 行依次是 m30,29、m30,30、m30,31,m30,30 前面还有 1 个。
- 共 87 个在它前面,数组下标从 0 开始 ⇒ 下标 87。验算公式 k = 2i + j − 3 = 60 + 30 − 3 = 87。
- A 错漏数了本行的 m30,29。
- B 对87。
- C 错犯了一个错:按数组下标从 1 开始算,或者把第 1 行也按 3 个算,都会得到 88。
- D 错上面两个错同时犯:第 1 行按 3 个算、下标又从 1 开始,得 89。
三对角矩阵:第一行和最后一行 2 个,其他行 3 个;下标(从 0)k = 2i + j − 3。
稀疏矩阵
非零元素很少、而且分布没有规律的矩阵叫稀疏矩阵,没法用公式压缩,只能把每个非零元素连同它的位置一起存下来:
三元组表(顺序存储)
每个非零元素存(行号, 列号, 值),按行优先排成一个数组。为了能还原出矩阵,还必须另外保存矩阵的行数、列数(以及非零元素的个数)(2023-03)。压缩后失去了随机存取特性。
| i | j | v |
|---|---|---|
| 1 | 3 | 5 |
| 2 | 1 | −2 |
| 4 | 4 | 7 |
十字链表(链式存储)
每个非零元素一个结点:(行号, 列号, 值, 向右指针 right, 向下指针 down)。同一行的非零元素用 right 链成一个链表,同一列的用 down 链成一个链表,再用行头指针数组、列头指针数组分别指向各行、各列的第一个结点。适合非零元素个数经常变化的情形。
十字链表长什么样
“适用于压缩存储稀疏矩阵的两种结构”就是三元组表和十字链表(2017-03)。邻接矩阵、二叉链表都不是。
三元组表到底省多少:100 × 100 的整数矩阵有 50 个非零元。直接存要 10000 个整数;三元组表每个非零元存 3 个整数,50 × 3 = 150 个,再加行数、列数、非零元个数 3 个,共 153 个。但它不是总划算:非零元个数 t 满足 3t + 3 < 10000,即 t ≤ 3332(约占三分之一)时才省空间——非零元一多,“记位置”的开销就比省下的还大。另外三元组表是按行优先排好的顺序表,要找 ai,j 只能查找,不能再像数组那样按下标直接算地址。
真题怎么考
- 多维数组地址(2021-03):先由两个已知地址解出行长,再算第三个。
- 对称矩阵(2018-03 存上三角、行优先;2020-01 存上三角、列优先,且要找的 m7,2 在下三角,要先换成 m2,7):四个检查点逐个确认,最后用最简单的元素验算。题目说“存入 C 语言的一维数组”,就是下标从 0 开始。
- 三对角矩阵(2016-04):前 i − 1 行共 3(i − 1) − 1 个,再数本行前面几个。
- 稀疏矩阵(2017-03 两种存储结构、2023-03 三元组表还要存什么):三元组表 + 行数 + 列数;十字链表。
- 方法什么时候不灵:背下来的公式只对推导时的那种约定成立。约定一换(上 / 下三角、行 / 列优先、从 0 / 1 开始),与其换公式,不如现场“数前面有几个”,三十秒就能数清。
- int A[2][3][4] 行优先存储,A[0][0][0] 的地址为 0,每个 int 4 字节。A[1][2][3] 的地址是多少?
答案
前面有 1 × 12 + 2 × 4 + 3 = 23 个元素,地址 23 × 4 = 92。 - n 阶对称矩阵下标从 0 开始,按行优先存下三角于 B[0..],a[4][2] 在哪里?
答案
k = 4 × 5/2 + 2 = 12(前 4 行 1 + 2 + 3 + 4 = 10 个,本行前面 2 个)。 - 6 阶三对角矩阵(下标从 1 起)按行优先存于 B[0..15],B[10] 存的是哪个元素?
答案
i = ⌊(10 + 1)/3⌋ + 1 = 4,j = 10 − 2 × 4 + 3 = 5,即 a4,5。验算:2 × 4 + 5 − 3 = 10 ✓。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 224
- B. 225
- C. 245
- D. 250
展开解析
B列优先:A[i][j] 前面有 j 整列(每列 m 个)再加 i 个,地址 = 100 + j·m + i。由 100 + 3m + 2 = 192 得 m = 30(行数)。A[5][4] = 100 + 4 × 30 + 5 = 225。
变化点:行优先时用已知地址反推列数,列优先时反推行数。公式记法:“前面有几整行(列),再加本行(列)前面几个”。
- A. 87
- B. 88
- C. 89
- D. 90
展开解析
C第 1 行 2 个元素,其余每行 3 个。前 30 行共 2 + 29 × 3 = 89 个;第 31 行的非零元素是 m31,30、m31,31、m31,32,m31,30 是本行第 1 个,前面 0 个。下标 = 89。也可用公式 k = 2i + j − 3 = 62 + 30 − 3 = 89。
变化点:原题 m30,30 在第 30 行中间,下标 87。三对角每行的第一个非零元素在 j = i − 1 处,别和对称矩阵弄混。
- A. 50
- B. 51
- C. 52
- D. 53
展开解析
C下三角按列存,第 k 列有 12 − k + 1 个元素。前 5 列共 12 + 11 + 10 + 9 + 8 = 50 个;第 6 列从 m6,6 开始,m8,6 前面有 m6,6、m7,6 两个。下标 = 50 + 2 = 52。
变化点:“下三角按列优先”和“上三角按行优先”存储次序完全一样(把矩阵转置一下就看出来了),所以原题 m6,6 的下标 50 也可以直接拿来用:m8,6 对应上三角的 m6,8,50 + 2 = 52。
- A. 15
- B. 16
- C. 22
- D. 23
展开解析
Am7,2 在下三角,没存,按对称性找 m2,7。上三角行优先:第 1 行有 10 个;第 2 行从 m2,2 开始,m2,7 前面有 m2,2 ~ m2,6 共 5 个。下标 = 10 + 5 = 15。
变化点:原题按列优先,m2,7 前面有 1 + 2 + … + 6 = 21 个再加 1,下标 22。只改“行/列优先”,答案完全不同。先把下标换到存储的那一半,再数前面有多少个。本节的矩阵下标小工具可以核对。
- A. 用三元组表存储后,仍能按下标随机存取任一元素
- B. 用三元组表存储时,矩阵转置只需交换每个三元组的行号与列号
- C. 用三元组表存储时,还需保存矩阵的行数、列数(通常还有非零元个数)
- D. 十字链表不适合非零元位置经常变化的矩阵
展开解析
C三元组只记录非零元,不记录矩阵的规模,所以要另存行数和列数(原题考的就是这一点)。A 错:要按行列号查找,失去随机存取;B 错:交换后还要按新行号重新排序;D 错:十字链表正是为了方便插入删除而设计的。
变化点:三元组表(顺序存储,省空间,不便插删)与十字链表(链式存储,便于插删)是稀疏矩阵的两种存储方式,也就是 2017-03 的答案。
- A. 246
- B. 288
- C. 252
- D. 264
展开解析
D三维长度为 4 × 5 × 6。行优先时 A[2][3][4] 前面有 2 个完整的“面”(每面 5 × 6 = 30 个)、本面前 3 行(每行 6 个)、本行前 4 个:60 + 18 + 4 = 82 个,地址 100 + 82 × 2 = 264。
变化点:若改为列优先(最左边的下标变得最快),前面有 4 × (4 × 5) + 3 × 4 + 2 = 94 个,地址 288(B)。见 3.7 三维数组。
3.8本篇总结:一页纸 + 必会题型
表 1:最常用的公式
| 对象 | 公式(默认约定) | 换约定时 |
|---|---|---|
| 顺序栈 | 空 top == −1;满 top == MaxSize − 1;个数 top + 1 | top 初值 0:各加 1 |
| 共享栈 | 满 top1 − top0 == 1 | — |
| 出栈序列总数 | C(2n, n)/(n + 1):1, 2, 5, 14, 42, 132 | — |
| 循环队列 | 空 front == rear;满 (rear + 1) % M == front;个数 (rear − front + M) % M | rear 指队尾元素:rear 换成 rear + 1 |
| 二维数组 | 行优先 (i × n + j) × L | 列优先 (j × m + i) × L |
| 对称矩阵(下三角,从 1 起) | k = i(i − 1)/2 + j − 1 | 见 3.7 四种组合 |
| 三对角矩阵(从 1 起) | k = 2i + j − 3 | 数组从 1 起再加 1 |
表 2:必会题型
| # | 题型 | 方法 | 本书位置 |
|---|---|---|---|
| 1 | 出栈序列判定、计数、最小容量 | 模拟 / 312 规则 / 卡特兰数 | 3.2 |
| 2 | 循环队列判空判满、求个数、求初值 | 先认约定,再套表 | 3.3 对照表 |
| 3 | 受限双端队列能否得到某序列 | 按进入逆序拿掉 / 模拟 | 3.4 |
| 4 | 中缀转后缀,某时刻栈中内容、最大运算符数 | 运算符栈逐步表 | 3.5 |
| 5 | 后缀 / 前缀求值 | 后缀左→右先弹右;前缀右→左先弹左 | 3.5 |
| 6 | 递归与调用栈 | 栈底是 main,越晚调用越靠栈顶 | 3.6 |
| 7 | 数组与特殊矩阵求下标 | 数“前面有几个”,用 a₁₁ 验算 | 3.7 |
- 题目的约定先写在草稿纸上:top 初值、rear 指向、矩阵与数组下标起点、行 / 列优先。
- 出栈序列:某元素后面比它小的必须降序。
- 中缀转后缀:栈中“优先级不低于”当前的都要弹出;( 只被 ) 弹出。
- 链队列 / 链表删除到只剩空表时,记得改尾指针。
- 压缩存储后的稀疏矩阵失去随机存取;三元组表要另存行数、列数。
- 进栈序列 1, 2, 3, 4,以 3 开头的出栈序列有几种?
答案
3 出栈后栈中是 1 2(2 在顶),4 未进栈。剩下的序列:2 必须在 1 前面,4 可以插在 2 前、2 与 1 之间、1 后:3 4 2 1、3 2 4 1、3 2 1 4,共 3 种。 - 循环队列 Q[0..9],front = 7,rear = 2(rear 指向队尾的下一个位置),队中有几个元素?
答案
(2 − 7 + 10) % 10 = 5(下标 7、8、9、0、1)。 - 把 a × (b + c) − d 转成前缀表达式。
答案
加括号:((a × (b + c)) − d),运算符移到左括号前:− × a + b c d。 - 10 阶对称矩阵按行优先存下三角于 B[1..55](数组下标从 1 开始),a8,5 在哪里?
答案
前 7 行 28 个,本行前面 4 个,它是第 28 + 4 + 1 = 33 个,数组下标从 1 开始,所以在 B[33]。
3.✎本篇真题闯关(28 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
串:字符的线性表,与模式匹配的 KMP 算法
串就是字符串,是元素限定为字符的线性表。它的存储和基本操作都很简单,408 只考一件事:模式匹配——在主串里找子串出现的位置。朴素算法会“回头重来”,最坏 O(mn);KMP 算法利用“已经匹配过的信息”让主串指针永不回退,做到 O(m + n)。选择题会让你手算 next / nextval 数组、数比较次数、给出失配后 i、j 的值。这一篇把 KMP 从“为什么”讲到“怎么手算”,再给出两套下标约定的对照,保证你看到哪种题目都不慌。
4.0本篇地图:只考模式匹配
串这一章在 408 里只有 3 道真题,全部考 KMP:失配后 i、j 的值(2015-08)、比较次数(2019-09)、nextval 与右移距离(2024-06)。分值不高,但它是“一学就会、一放就忘”的典型,所以本篇用逐趟图把每一次比较都画出来,再把 next、nextval 的手算步骤固定成口诀。
| 小节 | 学完要会的事 | 真题 | 难度 | 学时 |
|---|---|---|---|---|
| 4.1 定义与存储 | 子串个数、空串与空格串、三种存储 | — | ★ | 0.5 h |
| 4.2 朴素匹配 | 逐趟比较、i 的回退公式、最坏 O(mn) | — | ★ | 0.5 h |
| 4.3 KMP | 为什么不回退;PM / next 手算;数比较次数 | 2 | ★★★ | 2 h |
| 4.4 nextval | nextval 手算口诀;右移距离 | 1 | ★★★ | 1 h |
学这一篇抓住两条线:一条是“为什么”——朴素算法浪费在哪、KMP 凭什么可以不回退、nextval 又省了什么,这条线决定你能不能在题目稍一变形时还做得出来;另一条是“怎么算”——next、nextval 的手算口诀和逐趟表的画法,这条线决定你在考场上算得又快又准。两条线缺一条,KMP 都会变成“背了又忘”的东西。
先看懂 4.3 的两张图(“为什么不漏”和逐趟图),再背手算方法。做题时一律画逐趟表:每一趟写出模式串对准主串的哪个位置、从哪个字符比到哪个字符、在哪失配——比较次数、i 和 j 的值都能直接读出来。
4.1串的定义、基本操作与存储
为什么要专门研究“串”
文本编辑器里的“查找”、搜索引擎找关键词、病毒扫描找特征码、生物信息学在 DNA 序列里找基因片段,核心动作都是在一个长串里找一个短串。串的元素只是字符,存储没什么新东西,真正值得研究的是这个“找”的效率。
朴素的做法是把这个词逐个位置对一遍,对不上就往后挪一格从头再对。KMP 像一个记性好的人:对到一半发现不对时,他记得刚刚读过的字,不用回头重读,直接把词挪到“可能对得上”的地方。哪里不像:人会凭经验跳着找,KMP 只能利用“模式串自己的结构”(相等前后缀)决定挪多远,而且这个信息在开始找之前就算好了(next 数组)。
串是由零个或多个字符组成的有限序列,记为 S = 'a₁a₂…aₙ'。n 是串长,n = 0 时为空串(记作 ∅ 或 '')。
| 术语 | 含义 | 例:S = 'data structure' |
|---|---|---|
| 子串 | 串中任意连续的字符组成的子序列 | 'struct' 是 S 的子串;'dt' 不是(不连续) |
| 主串 | 包含子串的串 | S |
| 字符在串中的位置 | 该字符在串中的序号(从 1 开始) | 's' 的位置是 6 |
| 子串在主串中的位置 | 子串第一个字符在主串中的位置 | 'struct' 的位置是 6 |
| 空格串 | 由一个或多个空格组成的串,不是空串 | '␣' 长度为 1 |
| 串相等 | 两个串长度相等且每个对应位置的字符都相等,才称两串相等;比较大小见下表 StrCompare(字典序) | 'data' ≠ 'data␣'(长度 4 ≠ 5) |
一个长度为 n 的串,非空子串有 n(n+1)/2 个(长度为 k 的有 n − k + 1 个,k = 1 ~ n 求和,前提是字符互不相同),加上空串共 n(n+1)/2 + 1 个;非空真子串(不含自身)有 n(n+1)/2 − 1 个。
串与线性表的区别:线性表的操作通常以单个元素为对象;串的操作通常以子串为对象(查找、插入、删除一个子串)。
| 基本操作 | 功能 |
|---|---|
StrAssign(&T, chars) | 赋值 |
StrCompare(S, T) | 比较:从第一个字符起逐个比较编码大小,第一个不同的字符决定大小;一个是另一个的前缀时,长的大。S > T 返回正、= 返回 0、< 返回负 |
StrLength(S)、Concat(&T, S1, S2) | 求串长、连接 |
SubString(&Sub, S, pos, len) | 取从第 pos 个字符起、长度为 len 的子串 |
Index(S, T) | 定位(模式匹配):返回 T 在 S 中第一次出现的位置,不存在返回 0 |
StrAssign、StrCompare、StrLength、Concat、SubString 构成串操作的最小操作子集,其余操作都能用它们实现。
串比较怎么写(字符从 ch[1] 开始存):
int StrCompare(SString S, SString T) { // S > T 返回正,相等返回 0,S < T 返回负 for (int i = 1; i <= S.length && i <= T.length; i++) if (S.ch[i] != T.ch[i]) return S.ch[i] - T.ch[i]; // 第一个不同的字符决定大小 return S.length - T.length; // 一个是另一个的前缀:长的大 }
例:'data' 与 'date' 前三个字符相同,第 4 个 'a' < 'e',返回 'a' − 'e' = −4,所以 'data' < 'date';'ab' 与 'abc' 比完 2 个字符都相同,返回长度差 −1。这和查字典的规则一样:先比第一个不同的字母,前面全一样就短的在前。
“最小操作子集”是什么意思:有了 SubString 和 StrCompare,定位操作 Index 就能直接写出来——把主串里每个长度为 m 的子串取出来,和 T 比一比:
int Index2(SString S, SString T) { // 用基本操作实现定位 int n = StrLength(S), m = StrLength(T); SString sub; for (int i = 1; i <= n - m + 1; i++) { SubString(sub, S, i, m); // 取出从第 i 个字符起、长为 m 的子串 if (StrCompare(sub, T) == 0) return i; } return 0; }
同样地,“在第 pos 个字符前插入串 T”可以拼成:先用 SubString 取出前 pos − 1 个字符和剩下的部分,再用 Concat 把“前半 + T + 后半”接起来;“删除从 pos 起的 len 个字符”就是把前后两段 Concat 起来。基本操作像积木,复杂操作都是搭出来的。
它本质上就是 4.2 节的朴素匹配,只是“逐个字符比”被藏进了 StrCompare 里。这说明 Index 不必算进最小子集——能用别的操作拼出来的,就不是“最小”的一部分。(两段代码与 C 标准库 strcmp、strstr 随机对拍 20 万组。)
- 子串必须连续:'dt' 不是 'data' 的子串(它是“子序列”,408 的串一章不讨论子序列)。
- 字符、子串的位置从 1 开始数;子串的位置指它第一个字符的位置。
- 空串长度 0,空格串长度 = 空格个数;空串是任何串的子串。
- 串比较按字符编码逐个比,不是先比长度:'abc' < 'abd','ab' < 'abc'(前者是后者的前缀),'b' > 'abc'。
跟我做一遍:字符有重复时,子串有几个
- 先按公式数“位置不同的”子串:长度 1 ~ 5 各有 5、4、3、2、1 个,共 15 个。
- 再去掉重复的:长度 1 的 a、b、c、a、b 中 a、b 各重复一次,只剩 3 个;长度 2 的 ab、bc、ca、ab 中 ab 重复,剩 3 个;长度 3、4、5 没有重复,分别是 3、2、1 个。
- 不同的非空子串共 3 + 3 + 3 + 2 + 1 = 12 个。
所以 n(n + 1)/2 只在字符互不相同时才是“子串个数”。选择题若给的串有重复字符(如 'aab' 只有 5 个不同的非空子串),必须逐个长度去重。顺便对比一下:如果不要求连续(子序列),n 个互不相同的字符能挑出 2ⁿ 个(含空的),n = 5 时是 32 个,比 15 个非空子串多得多——题目说“子串”就一定是连续的,别把两种数法混了。(由程序枚举核对。)
三种存储方式
定长顺序存储
#define MAXLEN 255 typedef struct { char ch[MAXLEN]; int length; } SString;
超过 MAXLEN 的部分被截断。教材常让 ch[0] 空着不用,字符从 ch[1] 开始,位序与下标一致。
拓展 C 语言自己的字符串(char s[])不存 length,而是在末尾放一个 '\0' 表示结束,strlen 要从头数到 '\0',是 O(n)。教材的 SString 多存一个 length,求串长 O(1),代价是多占一个整数。两种做法各有取舍,考题里的 S.length 都是指教材这种。
堆分配存储
typedef struct { char *ch; // malloc 分配 int length; } HString;
串长可变,按需申请。
块链存储
链表的每个结点存多个字符(一个“块”),最后一个结点不满时用 # 等补齐。结点大小 = 1 时存储密度很低;块越大密度越高,但插删越不方便。
存储密度 = 串值所占空间 ÷ 实际分配的空间。
三种存储怎么选:串长大致固定、主要做查找比较 ⇒ 定长顺序存储,简单直接;串长变化大、经常拼接截取 ⇒ 堆分配,按需申请;文本特别长、又要频繁在中间插删(例如编辑器里的大文档)⇒ 块链,插删只动一个块。408 的算法题和 KMP 一律按定长顺序存储(字符从 ch[1] 开始)来写。
块链存储长什么样
- 空串和空格串有什么区别?长度各是多少?
答案
空串不含任何字符,长度 0;空格串由空格字符组成,长度是空格的个数(如 '␣' 长度 1)。空格串不是空串。 - 串 'aab' 有几个不同的非空子串?
答案
a、b、aa、ab、aab,共 5 个(a 出现两次只算一个)。 - 用基本操作写 Index 时,循环为什么只到 i = n − m + 1?
答案
从第 i 个字符起要取出长度为 m 的子串,必须 i + m − 1 ≤ n,即 i ≤ n − m + 1;再往后剩下的字符不够 m 个,不可能匹配。
考纲补充(尚无真题):子串个数
串的基本概念是考纲点,2009—2026 年没有单独出过选择题,下面一道按考纲自编。
- A. 35
- B. 36
- C. 37
- D. 72
展开解析
B'software' 的 8 个字符互不相同,长度为 k 的子串有 9 − k 个,非空子串共 8 + 7 + … + 1 = 8 × 9 / 2 = 36 个。
变化点:加上空串是 37(C);“非空真子串”不含自身,是 35(A)。若串里有重复字符(如 'abcab'),要逐个长度去重,见 4.1 跟做。
4.2朴素模式匹配(BF 算法)
在主串 S(长 n)中找模式串 T(长 m)。最直接的做法:把 T 依次对准 S 的第 1、2、3……个位置,逐个字符比较;一旦不相等,就把 T 右移一格,从头再比。
int Index(SString S, SString T) { // 字符从下标 1 开始存 int i = 1, j = 1; while (i <= S.length && j <= T.length) { if (S.ch[i] == T.ch[j]) { i++; j++; } // 相等:都往后比 else { i = i - j + 2; j = 1; } // 失配:i 回到本趟起点的下一个,j 从头开始 } if (j > T.length) return i - T.length; // 匹配成功,返回起始位置 return 0; }
i − j + 2 的来历:本趟匹配从主串的第 i − j + 1 个字符开始(已经比过 j − 1 个),下一趟从它的下一个即 i − j + 2 开始。
朴素算法也有它的好处:代码短、不需要额外数组、不需要预处理。在字符种类很多、文本比较“随机”的情况下(比如在一篇中文文章里找一个词),绝大多数位置第一个字符就对不上,每趟只比一两次,实际速度和 KMP 差不多。它真正吃亏的是“大量部分匹配”的情形,例如在一长串 a 里找 aaab——这时 KMP 的优势才体现出来。408 对朴素算法的考法基本只有两种:失配后 i、j 的新值(i − j + 2 和 1),以及一共比较了几次。
复杂度:最坏情况下每一趟都比到模式串最后一个字符才失配,例如 S = 'aaaa…aab'、T = 'aaab',要比较约 (n − m + 1) × m 次,O(nm);一般情况下常常第一两个字符就失配,实际接近 O(n + m)。
| 情况 | 例子(讲义代码实际计数) | 比较次数 |
|---|---|---|
| 最好(成功):第一个位置就匹配上 | S = abcdefgh,T = abc | m = 3 |
| 失败,且每趟第一个字符就不等 | S = aaaaaaaa,T = bcd | 8 = n(代码不会在“剩下不足 m 个字符”时提前停) |
| 最坏:每趟都比到最后一个字符 | S = aaaaaaaaab,T = aaab | 28 = (n − m + 1) × m |
第二行提醒一个细节:教材的 Index 循环条件只看 i ≤ n、j ≤ m,主串剩下的字符明明已经不够 m 个了,它还会接着比下去,所以失败时可能比 n 次而不是 n − m + 1 次。选择题问“最多 / 最少比较几次”时,先确认题目按哪种写法算。(三个例子由程序按讲义代码计数。)
跟我做一遍:朴素匹配逐趟数比较次数
第 1 趟在 T 的第 5 个字符失配时 i = 5、j = 5,按代码 i = i − j + 2 = 2,回到本趟起点(第 1 个字符)的下一个。四趟比较次数 5 + 1 + 2 + 5 = 13,在第 4 个位置匹配成功。同样的 S、T 用 KMP 只要 9 次(第 1 趟 5 次后 j 跳到 2,接着比 4 次就成功)。(图中每个格子都来自程序记录的比较过程。)
- 朴素匹配中,某趟在 i = 9、j = 4 时失配,下一趟 i、j 从几开始?
答案
本趟从主串第 i − j + 1 = 6 个字符开始,下一趟从第 7 个开始,即 i = i − j + 2 = 7,j = 1。 - 主串 'aaaaaaaaab'(10 个字符),模式串 'aaab',朴素匹配共比较几次?
答案
前 6 个位置每趟比 4 次(第 4 个字符 b 与 a 失配),第 7 个位置比 4 次成功:7 × 4 = 28 次 = (n − m + 1) × m,是最坏情况。 - 讲义的朴素匹配代码在匹配失败时,至少要比较几次?为什么?
答案
至少 n 次。循环只有在 i > n(或匹配成功)时才结束,而每比较一次,i 最多前进 1;要从 1 走到 n + 1,至少比较 n 次。(程序随机验证。)
考纲补充(尚无真题):朴素匹配
朴素模式匹配是考纲点,尚无单独的真题,下面一道按考纲自编。
- A. n + m
- B. nm
- C. (n − m)m
- D. (n − m + 1)m
展开解析
D模式串可以对准主串的第 1 ~ n − m + 1 个位置,最坏情况下每个位置都比到模式串最后一个字符才知道结果(例如主串 'aa…ab'、模式串 'a…ab'),每趟 m 次,共 (n − m + 1)m 次,即 O(nm)。
变化点:KMP 的比较次数不超过约 2n,加上求 next 的 O(m),总共 O(n + m)(A 是 KMP 的量级,不是朴素算法的)。见 4.3 对比表。
4.3KMP 算法与 next 数组
朴素算法浪费在哪里
看一个例子:S = 'abaabaabc…',T = 'abaabc'。第一趟比到 T 的第 6 个字符 c 时失配,此时已经知道 S 的前 5 个字符就是 'abaab'。朴素算法把 i 退回第 2 个字符重新比,但这些字符我们已经知道是什么了!
- 前缀:除最后一个字符外,串的所有头部子串。'abab' 的前缀:a、ab、aba。
- 后缀:除第一个字符外,串的所有尾部子串。'abab' 的后缀:b、ab、bab。
- 部分匹配值(PM):前缀集合与后缀集合中最长相等元素的长度。'abab' 的 PM = 2(ab)。
KMP 的规则:主串指针 i 永不回退;模式串在第 j 个字符失配时,j 跳到 next[j] 继续与 S[i] 比较。next[j] 只和模式串有关,可以提前算好。
假设你在背一串口令 'abaabc',背到第 6 个字时卡住了,已经背出的是 'abaab'。最省事的补救不是从头再来,而是看看刚背出的结尾里,有没有一段正好是口令的开头——'abaab' 的结尾 'ab' 就是口令开头的 'ab',于是可以假装刚才那个 'ab' 就是新一轮的开头,接着往下背第 3 个字。“最长相等前后缀”就是“结尾里能当作新开头的最长那一段”;next[j] 告诉你卡在第 j 个字时,应该从第几个字接着背。
哪里不像:人背口令卡住了还可以去翻书;KMP 只能用模式串自己的信息,而且要选最长的那段——选短了会跳过头,错过可能的匹配(图 ★ 的反证说的就是这件事)。
为什么主串指针不用回退:一张反证图
这就证明了两件事:① 模式串右移 k − PM 位不会错过任何可能的匹配;② 右移后前 PM 个字符已经保证相等,主串指针 i 停在原处,接着拿 T 的第 PM + 1 个字符和 S[i] 比——这正是 next[k + 1] = PM + 1 的意思。
手算 next 数组(教材约定:位序从 1 开始)
展开解析
| j | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| T[j] | a | b | a | a | b | c |
| 前 j−1 个字符 | — | a | ab | aba | abaa | abaab |
| 最长相等前后缀 | — | 0 | 0 | 1(a) | 1(a) | 2(ab) |
| next[j] | 0 | 1 | 1 | 2 | 2 | 3 |
对照图 4-3:在 j = 6 失配,next[6] = 3,下一次用 T[3] 与主串当前字符比较——和图上的分析一致。
另一种手算法:先列 PM 表,再“右移一格 + 1”
先算模式串每个前缀的部分匹配值 PM(最长相等前后缀长度),排成一行;把这一行整体右移一格(第 1 格填 −1),就得到 0 起下标的 next;再整体加 1,就是教材的 next。以 T = 'abcababc' 为例:
| j(位序) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| T[j] | a | b | c | a | b | a | b | c |
| 前 j 个字符的 PM | 0 | 0 | 0 | 1 | 2 | 1 | 2 | 3 |
| 右移一格(0 起下标的 next) | −1 | 0 | 0 | 0 | 1 | 2 | 1 | 2 |
| 再加 1(教材 next) | 0 | 1 | 1 | 1 | 2 | 3 | 2 | 3 |
检查第 6 列:前 5 个字符 abcab 的最长相等前后缀是 ab(长 2),所以第 6 个字符失配时用第 3 个字符接着比,next[6] = 3 ✓。右移位数 = 已匹配个数 − 已匹配部分的 PM,在两种下标约定下都一样。(PM 与两套 next 由程序按定义暴力计算,并与 get_next 的结果对拍。)
- 数错了范围:next[j] 看的是“第 j 个字符前面的 j − 1 个字符”,不包括第 j 个本身。把 'abaab' 写成 'abaabc' 去找前后缀,整行都会右移一格。
- 前后缀取到了整个串:前缀不含最后一个字符、后缀不含第一个字符。'aaa' 的最长相等前后缀是 'aa'(长 2),不是 'aaa'。
- 忘了 + 1:教材的 next = 最长相等前后缀长度 + 1;0 起下标的 next 才不加。两种约定混着用是最常见的丢分点。
- 前后缀只比了“看着像”的:'abcab' 的前缀 'ab' 和后缀 'ab' 相等,但前缀 'abc' 与后缀 'cab' 不相等——必须逐个长度老老实实对,或用 PM 表法从短到长算。
- 不自查:算完用两条性质检查——next[1] = 0、next[2] = 1 是固定的;相邻两项最多加 1。
KMP 匹配过程与代码
int Index_KMP(SString S, SString T, int next[]) { int i = 1, j = 1; while (i <= S.length && j <= T.length) { if (j == 0 || S.ch[i] == T.ch[j]) { i++; j++; } // j == 0:模式串从头开始,主串后移 else j = next[j]; // 失配:i 不动,j 回退 } if (j > T.length) return i - T.length; return 0; } void get_next(SString T, int next[]) { // 求 next:模式串“自己和自己匹配” int i = 1, j = 0; next[1] = 0; while (i < T.length) { if (j == 0 || T.ch[i] == T.ch[j]) { i++; j++; next[i] = j; } else j = next[j]; } }
get_next 的递推为什么是那样写的
| 步 | i | j | 比较 T[i] 与 T[j] | 动作 |
|---|---|---|---|---|
| 1 | 1 | 0 | j = 0,不比 | i、j 各加 1 ⇒ next[2] = 1 |
| 2 | 2 | 1 | T[2] = b ≠ T[1] = a | j = next[1] = 0 |
| 3 | 2 | 0 | j = 0,不比 | next[3] = 1 |
| 4 | 3 | 1 | T[3] = a = T[1] = a | next[4] = 2 |
| 5 | 4 | 2 | T[4] = a ≠ T[2] = b | j = next[2] = 1 |
| 6 | 4 | 1 | T[4] = a = T[1] = a | next[5] = 2 |
| 7 | 5 | 2 | T[5] = b = T[2] = b | next[6] = 3,i = 6 = 串长,结束 |
结果 0 1 1 2 2 3,和手算一致。读表的要点:i 只增不减,每增一次就填一个 next[i];j 表示“当前已经匹配上的前缀长度”,配上了就一起前进,配不上就按已经算好的 next 回退——和 KMP 匹配主串时的动作一模一样。(每一步由程序按代码记录。)
- next[j] < j(j ≥ 1):最长相等前后缀一定比已匹配的部分短,失配后 j 只会往回跳,不可能原地不动或往前跳。
- next[j + 1] ≤ next[j] + 1:相等前后缀每往后一位最多加长 1(只有新字符也配上时才 +1)。所以 next 从左到右要么加 1,要么往下掉,不会突然往上跳两格。
手算完拿这两条扫一眼:例如算出 0 1 1 3 …,第 4 个比第 3 个大了 2,必错。(两条性质对 3000 个随机模式串由程序验证。)
复杂度:求 next 是 O(m),匹配是 O(n),总共 O(m + n)。直观理由:i 只增不减,最多增 n 次;每次 j 回退都至少退 1,而 j 的增加次数不超过 i 的增加次数,所以回退总次数也不超过 n。
KMP 的优势在于主串不回溯,适合主串很长、需要从外存边读边匹配的场合;一般情况下朴素算法的实际表现也接近 O(m + n),所以它仍然被广泛使用。
拓展 KMP 真正厉害的地方不只是“快”,而是“主串只从左往右读一遍、读过的字符再也不用回头看”。这意味着主串可以是一个流:网络上不断到来的数据包、从磁盘一块块读进来的日志、键盘逐个敲进来的字符,都可以边到边匹配,不需要先全部存下来。实际系统里还有更快的单模式算法(如 BM 算法,从模式串的末尾往前比,经常一次跳过一大段),以及一次同时找很多个关键词的 AC 自动机(它的“失配指针”就是把 next 推广到多个模式串上)。这些都超出 408,但思路都源于 KMP 的“利用已知信息,避免重复比较”。
跟我做一遍:KMP 逐趟图与比较次数(含 next = 0 的情形)
- 第 1 趟:i = 1 ~ 5 与 j = 1 ~ 5 比较,前 4 次相等,第 5 次 S[5] = b ≠ c 失配。i 不动,j = next[5] = 2。
- 第 2 趟:S[5] 与 T[2] 比、S[6] 与 T[3] 比,都相等;S[7] = x 与 T[4] = a 比,失配。j = next[4] = 2。
- 第 3 趟:S[7] 与 T[2] = b 比,失配,j = next[2] = 1;第 4 趟:S[7] 与 T[1] = a 比,失配,j = next[1] = 0。
- j = 0:不比较,i、j 同时加 1,即 i = 8、j = 1。第 5 趟比 5 次全部相等,此时 i = 13、j = 6 > 5,返回 i − 5 = 8(匹配位置)。
- 比较次数 5 + 3 + 1 + 1 + 5 = 15;同一个例子朴素匹配要 21 次。
KMP 什么时候比朴素算法好得多
朴素算法的浪费在于主串指针反复回退,在主串和模式串有大量“部分匹配”时最严重。下表都由程序实际计数:
| 主串 S | 模式串 T | 朴素比较次数 | KMP 比较次数 |
|---|---|---|---|
| abaabaac | abaac | 13 | 9 |
| abaabaxabaac | abaac | 21 | 15 |
| a × 20 再接 b(21 个字符) | aaaab | 85 | 37 |
KMP 的总比较次数不超过 2n 左右:每次比较要么相等(i 前进一步,最多 n 次),要么不等(j 至少退一步,而 j 退的总量不超过它进的总量,也不超过 n)。再加上求 next 的 O(m),总共 O(m + n)。若字符集很大、文本很“随机”,朴素算法通常比一两个字符就失配,两者差别不大。
另一套下标约定(下标从 0 开始,2015-08 用的就是它)
很多题目(和 C 语言习惯)让字符从下标 0 开始。这时常用的 next 定义是:next[j] = T[0..j−1] 的最长相等前后缀长度(next[0] = −1)。失配时 j = next[j],恰好就是“下一次和 S[i] 比较的模式串下标”。两套约定的数值只差 1:
为什么正好差 1:两套约定描述的是同一个动作——“让模式串的第 PM + 1 个字符对准主串当前字符”。位序从 1 起时,第 PM + 1 个字符的位序就是 PM + 1;下标从 0 起时,它的下标是 PM。同一个字符,两种编号差 1,next 的值自然差 1。所以记住一套、另一套整体减(加)1 即可,不用背两套口诀。
| T | a | b | a | a | b | c |
|---|---|---|---|---|---|---|
| 教材 next(位序 1~6) | 0 | 1 | 1 | 2 | 2 | 3 |
| 0 起下标的 next(下标 0~5) | −1 | 0 | 0 | 1 | 1 | 2 |
- 先求 0 起下标的 next:t 的前缀 a、aa、aab、aaba、aabaa 的 PM 依次是 0、1、0、1、2,右移一格、首位填 −1:next = −1, 0, 1, 0, 1, 2。
- 在 j = 5 失配:i 不动(仍是 5),j = next[5] = 2——t[0..4] = 'aabaa' 的最长相等前后缀是 'aa',长 2,下一次拿 t[2] 和 s[5] 比。
- 继续:s[5] = b 与 t[2] = b 相等,s[6..8] 依次与 t[3..5] 相等,匹配成功,起始下标 9 − 6 = 3。共比较 6 + 4 = 10 次。
如果用教材约定(位序从 1 起)描述同一件事:在第 6 个字符失配,next[6] = 3,下一次用第 3 个字符比较——数值都差 1,模式串右移的距离都是 3。(由程序逐步模拟核对。)
题目说“i = j = 5 时失配”,并且用 s[i]、t[j] 这种 C 数组写法,就是 0 起下标:失配时 j 变成 T[0..4] 的最长相等前后缀长度,i 不变。题目说“第 j 个字符”或给出 next[1] = 0,就是教材约定。不管哪种约定,模式串右移的位数都一样:已匹配长度 − 最长相等前后缀长度。
- 两套下标约定混着用:题目用 next[1] = 0 的约定,答案却按 0 起下标写,差 1。
- 数比较次数时漏了“失配的那一次”,或者把 j = 0 时的 i++、j++ 也算成一次比较。
- 以为 next 和主串有关——next 只由模式串决定,换主串不用重算。
- 以为失配时 i 也要动——KMP 的 i 只在“相等”或“j 退到 0”时前进。
2015-08 用 0 起下标,问第一次失配后 i、j 的值;2019-09 问到匹配成功时一共比较了几次。两题的共同解法:先求 next,再画逐趟表。
三种问法、三个坑:① 问“失配后 i、j 的值”——先认下标约定,0 起下标时 j 直接等于 PM,教材约定时等于 PM + 1,i 都不变;② 问“比较了几次”——相等、不等都算,j = 0 那一步不算,最后一次成功的比较也要算上;③ 问“修正后的 next”或“nextval”——一定要先算 next 再推 nextval,题目没说“修正”就用普通 next。三种问法都离不开同一张逐趟表,画对了表,答案只是把表里的数读出来。
- A. i = 1,j = 0
- B. i = 5,j = 0
- C. i = 5,j = 2
- D. i = 6,j = 2
展开解析
C题目用 s[i]、t[j] 且 i = j = 5,是下标从 0 开始的写法:已经匹配了 t[0..4] = 'abaab',在 t[5] = 'c' 处失配。
- KMP 中主串指针 i 不回退,仍是 5。
- 'abaab' 的最长相等前后缀是 'ab',长度 2。下一次用 t[2] 与 s[5] 比较,j = 2。
- (用教材约定核对:next[6] = 3,即第 3 个字符,对应 0 起下标 2,一致。)
- A 错i 回到 1、j 回到 0 是朴素算法的做法。
- B 错j = 0 表示从头比较,浪费了 'ab' 这段已知相等的信息。
- C 对i = 5,j = 2。
- D 错失配时 i 不前进;i 只在 j 退到“头之前”(j = −1 或教材的 0)时才加 1。
KMP 失配:i 不变,j = 已匹配部分的最长相等前后缀长度(0 起下标)。
- A. 9
- B. 10
- C. 12
- D. 15
展开解析
B模式串 S = 'abaabc' 的 next(教材约定)= 0 1 1 2 2 3。逐趟数比较次数(相等、不等都算):
- 第 1 趟:T[1..5] = 'abaab' 与 S[1..5] 逐个相等(5 次),T[6] = 'a' 与 S[6] = 'c' 不等(1 次)——共 6 次。
- j = next[6] = 3,i 仍是 6:T[6] = 'a' 与 S[3] = 'a' 相等,T[7] = 'a' 与 S[4] 相等,T[8] = 'b' 与 S[5] 相等,T[9] = 'c' 与 S[6] = 'c' 相等——4 次,匹配成功(起始位置 4)。
- 合计 6 + 4 = 10 次(已用程序模拟核对)。
- A 错漏数了失配的那 1 次比较。
- B 对10 次。
- C 错把第二趟当成从 S[1] 重新开始比较(多比了前两个字符)。
- D 错15 正是朴素算法的比较次数:主串起点 1、2、3、4 依次比 6、1、2、6 次(程序核对)。
数比较次数:每趟 = 相等的个数 + 1 次失配;KMP 下一趟从 next[j] 开始,前面的字符不再比。
每一次“S[i] 与 T[j] 的比较”算一次,相等和不相等都算;j = 0 时不比较(直接 i++、j++)。画一张表:每一趟写出从哪里比到哪里、在哪里失配,失配那次也要计入。
- 求 T = 'abcababc' 中第 7 个字符失配时,模式串应右移几位?
答案
已匹配 6 个字符 abcaba,其 PM = 1,右移 6 − 1 = 5 位(next[7] = 2,即让 T[2] 对准主串当前字符)。 - KMP 匹配中,什么情况下主串指针 i 会前进?
答案
只有两种:S[i] 与 T[j] 比较相等时;或 j 退到 0 时(i、j 同时加 1)。失配时 i 不动。 - T = 'abab'(教材约定)在第 4 个字符失配,用 next 和用 nextval,下一步分别拿第几个字符和主串当前字符比较?
答案
next = 0 1 1 2,nextval = 0 1 0 1。用 next:第 2 个(b);用 nextval:第 1 个(a)——因为 T[2] = b 和失配的 T[4] = b 一样,再比也是白比。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 9
- B. 12
- C. 15
- D. 10
展开解析
BT 的 next = 0 1 2 3 4。第 1 趟:S[1..3] 与 T[1..3] 相等(3 次),S[4] = b 与 T[4] = a 失配(第 4 次);j = next[4] = 3,S[4] 与 T[3] 比(第 5 次)失配;j = 2(第 6 次)失配;j = 1(第 7 次)失配;j = next[1] = 0,i、j 各加 1。第 2 趟:S[5..9] 与 T[1..5] 全部相等(5 次)。共 7 + 5 = 12 次,匹配位置 5。
变化点:用 nextval(0 0 0 0 4)时,在 j = 4 失配后直接 j = 0,省掉 3 次无用比较,共 9 次(A);朴素 BF 算法需要 15 次(C)。这道题正好说明了 nextval 为什么能优化:T[4] = T[3] = T[2] = T[1] = a,拿 a 去比刚刚失配的 b 注定还会失配。
- A. 0 1 1 2 3 4 2 2
- B. 0 1 1 2 3 4 1 2
- C. 0 1 1 2 3 1 2 2
- D. 0 1 0 1 0 4 2 1
展开解析
Anext[j] = T[1..j−1] 的最长相等前后缀长度 + 1:
j = 3:“ab”无 → 1;j = 4:“aba”有 a → 2;j = 5:“abab”有 ab → 3;j = 6:“ababa”有 aba → 4;j = 7:“ababaa”只有 a → 2;j = 8:“ababaaa”只有 a → 2。结果 0 1 1 2 3 4 2 2。
变化点:D 是 nextval 数组(修正后的),注意区分题目问的是哪一个。若题目用 0 起下标(next[0] = −1),则整体减 1:−1 0 0 1 2 3 1 1。可用 KMP 小工具核对。
4.4nextval:KMP 的进一步优化
next 浪费在哪:“注定又失配”
一句话理解 nextval:next 告诉你“失配后该跳到哪”,可跳过去的那个字符如果和刚刚失配的字符长得一样,跳过去也是白跳——它和主串的那个字符照样不相等。nextval 就是提前把这种“白跳”合并掉:顺着 next 一路往回找,直到找到第一个和当前字符不一样的位置(或者跳到 0)。所以 nextval 并不是新的思想,只是把“比了也白比”的那几次比较在预处理阶段省掉了。
为什么这样跳不会漏掉匹配:在第 j 个字符失配,说明主串当前字符 S[i] ≠ T[j]。若 T[next[j]] 和 T[j] 是同一个字符,那么 S[i] ≠ T[next[j]] 也一定成立——这次比较的结果早就知道了,跳过它不会错过任何东西;接着要比的 T[next[next[j]]] 同理。一直跳到第一个“和 T[j] 不一样”的字符,那个字符才有可能和 S[i] 相等,才值得比。nextval 记下的正是这个位置。
next 数组还有浪费。设模式串 T = 'aaaab',主串 'aaabaaaab'。在 j = 4 失配(主串字符是 b、T[4] = a),next[4] = 3,于是拿 T[3] = a 再比——但 T[3] 和 T[4] 相同,也是 a,注定又失配;接着 T[2]、T[1] 都是 a,白白比了三次。
展开解析
| j | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| T[j] | a | a | a | a | b |
| next[j] | 0 | 1 | 2 | 3 | 4 |
| T[next[j]] 与 T[j] 相同? | — | 同 | 同 | 同 | 不同 |
| nextval[j] | 0 | 0 | 0 | 0 | 4 |
| j | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| T[j] | a | b | a | a | b | c |
| next[j] | 0 | 1 | 1 | 2 | 2 | 3 |
| T[next[j]] | — | a(≠b) | a(=a) | b(≠a) | b(=b) | a(≠c) |
| nextval[j] | 0 | 1 | 0 | 2 | 1 | 3 |
j = 3:T[3] = T[next[3]] = T[1] = a,所以 nextval[3] = nextval[1] = 0。j = 5:T[5] = T[2] = b,nextval[5] = nextval[2] = 1。
跟我做一遍:nextval 手算(三步口诀)
口诀:先算 next;从左往右看 T[j] 和 T[next[j]];相同抄 nextval[next[j]],不同抄 next[j]。
| j | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| T[j] | a | b | c | a | b | c | d |
| next[j] | 0 | 1 | 1 | 1 | 2 | 3 | 4 |
| T[next[j]] | — | a(≠ b) | a(≠ c) | a(= a) | b(= b) | c(= c) | a(≠ d) |
| nextval[j] | 0 | 1 | 1 | 0 | 1 | 1 | 4 |
j = 4:T[4] = a 与 T[1] 相同,抄 nextval[1] = 0;j = 5:T[5] = b 与 T[2] 相同,抄 nextval[2] = 1;j = 6:抄 nextval[3] = 1。由于是从左往右算,要抄的那一格一定已经算好了。另外注意,nextval 里可以出现好几个 0:凡是和第 1 个字符相同、一路抄回 nextval[1] 的位置都是 0(如 'aaab' 的 nextval = 0 0 0 3);而 next 里只有 next[1] 是 0。看到一个 nextval 里有多个 0 不要以为算错了。右移距离:在 j = 7 失配时 nextval[7] = 4,右移 7 − 4 = 3 位;在 j = 4 失配时 nextval[4] = 0,右移 4 位(T 的第 1 个字符对准主串的下一个字符)。(next、nextval 由程序核对,并验证了“nextval 就是沿 next 链跳到第一个不同字符”的定义。)
什么时候 nextval 和 next 一样
只要对每个 j ≥ 2 都有 T[j] ≠ T[next[j]],两者就完全相同,例如 'abcd'(都是 0 1 1 1)。反过来,模式串里重复出现的字符越多(尤其是 'aaaa…' 这种),nextval 省下的比较就越多。注意:nextval 只减少比较次数,不改变 KMP 的时间复杂度 O(m + n)。
void get_nextval(SString T, int nextval[]) { int i = 1, j = 0; nextval[1] = 0; while (i < T.length) { if (j == 0 || T.ch[i] == T.ch[j]) { i++; j++; if (T.ch[i] != T.ch[j]) nextval[i] = j; else nextval[i] = nextval[j]; // 相同就继续跳 } else j = nextval[j]; } }
代码和口诀怎么对应:get_nextval 与 get_next 几乎一样,只是在“i++、j++ 之后要填 nextval[i]”这一步多问了一句:新的 T[i] 和它要跳去的 T[j] 一样吗?不一样就填 j(即 next[i]);一样就填 nextval[j](顺着跳到底)。失配回退时也改用 nextval——它本身已经是“跳到底”的结果,回退得更快。手算时不必模拟这段代码,用“先算 next、再从左往右抄”的口诀就行;代码只在要求写程序时用到。
在第 j 个字符失配、j 变成 j' 时:若 j' ≥ 1,模式串右移 j − j' 位(让第 j' 个字符对准主串当前字符);若 j' = 0,模式串的第 1 个字符要对准主串的下一个字符,右移 j 位。
- 相同时抄的是 nextval[next[j]],不是 next[next[j]]。
- 必须从左往右算:要抄的那一格在左边,必须先算好。
- 右移距离:nextval[j] = 0 时右移 j 位(不是 j − 0 再减 1),其他情况右移 j − nextval[j] 位。
2024-06 问用修正后的 next(nextval)时模式串“最多能滑动多远”:把每个 j 的右移距离都算出来取最大值。
| j | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| T[j] | a | b | c | a | b | d |
| next[j] | 0 | 1 | 1 | 1 | 2 | 3 |
| nextval[j] | 0 | 1 | 1 | 0 | 1 | 3 |
| 右移距离 | 1 | 1 | 2 | 4 | 4 | 3 |
- nextval:j = 4 时 T[4] = a 与 T[next[4]] = T[1] 相同,抄 nextval[1] = 0;j = 5 时 b 与 T[2] 相同,抄 nextval[2] = 1;j = 6 时 d 与 T[3] = c 不同,抄 next[6] = 3。
- 右移距离:nextval[j] ≥ 1 时是 j − nextval[j];nextval[j] = 0 时是 j(第 1 个字符要对准主串的下一个字符)。j = 4 得 4、j = 5 得 5 − 1 = 4。
- 最大右移 4 位。注意 j = 1 时 nextval = 0,右移 1 位,不是 0 位。
nextval 与右移距离由程序按定义计算。
- A. 5
- B. 4
- C. 3
- D. 2
展开解析
A先求 S = 'aabaab' 的 next 和 nextval(教材约定),再算每个位置失配时模式串右移几位。
| j | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| S[j] | a | a | b | a | a | b |
| next | 0 | 1 | 2 | 1 | 2 | 3 |
| nextval | 0 | 0 | 2 | 0 | 0 | 2 |
| 右移位数 | 1 | 2 | 1 | 4 | 5 | 4 |
- nextval[j] = j' ≥ 1 时右移 j − j' 位;nextval[j] = 0 时第 1 个字符要对准主串的下一个字符,右移 j 位。
- j = 5 时 nextval = 0,右移 5 位,是最大值。
- A 对5。
- B 错4 是 j = 4 或 j = 6 失配时的右移距离。
- C 错若用未修正的 next,j = 4、5、6 失配时都只右移 3 位,最大就是 3。
- D 错2 是 j = 2 失配时的右移距离(nextval[2] = 0,右移 2 位),不是最大值。
右移位数 = j − nextval[j](nextval 为 0 时是 j)。nextval 越小、滑得越远。
- 求 T = 'aabaaf' 的 next 和 nextval(教材约定)。
答案
next = 0 1 2 1 2 3;nextval = 0 0 2 0 0 3。(j = 2:T[2] = a = T[1],抄 0;j = 3:b ≠ T[2] = a,抄 2;j = 4:a = T[1],抄 0;j = 5:a = T[2],抄 nextval[2] = 0;j = 6:f ≠ T[3] = b,抄 3。) - 判断:“使用 nextval 后,KMP 算法的时间复杂度降低了。”
答案
错。仍是 O(m + n),只是常数变小、比较次数可能减少。 - 改用 nextval 以后,KMP 的比较次数一定比用 next 少吗?
答案
不一定,只会少或者一样。例如主串 'ababcabcacbab'、模式串 'abcac',两次失配时 T[j] 与 T[next[j]] 都不相同,nextval 与 next 在这两处取值一样,两种做法都比较 12 次。nextval 只在“跳过去的字符和失配字符相同”时才省。(程序计数。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 3
- B. 4
- C. 5
- D. 6
展开解析
Bnext = 0 1 1 2 2 3,nextval = 0 1 0 2 1 3。在第 j 位失配时,模式串右移 j − nextval[j] 位(nextval[j] = 0 时右移 j 位,下一次用 S[1] 对主串的下一个字符):j = 1 ~ 6 依次滑动 1、1、3、2、4、3。最大为 4(在第 5 位失配时)。
变化点:原题 “aabaab” 的 nextval = 0 0 2 0 0 2,在第 5 位失配时 nextval = 0,滑动 5 位。计算方法相同:先求 next,再逐位修正成 nextval,最后算 j − nextval[j]。
- A. 0 1 2 3 4
- B. 0 0 0 0 0
- C. 0 0 0 0 4
- D. 0 1 1 1 4
展开解析
Cnext = 0 1 2 3 4。修正:j = 2,T[2] = T[next[2]] = T[1],取 nextval[1] = 0;j = 3、4 同理都为 0;j = 5,T[5] = b ≠ T[4] = a,保留 4。
变化点:修正规则:若 T[j] == T[next[j]],nextval[j] = nextval[next[j]],否则 nextval[j] = next[j]。模式串前部字符越重复,修正得越多。
4.5本篇总结:一页纸 + 必会题型
| 要算的东西 | 方法 | 本书位置 |
|---|---|---|
| next(教材约定) | next[1] = 0;next[j] = 前 j − 1 个字符的 PM + 1;或“PM 表右移一格再加 1” | 4.3 |
| next(0 起下标) | 教材 next 全部减 1,next[0] = −1 | 4.3 |
| nextval | T[j] 与 T[next[j]] 相同抄 nextval[next[j]],不同抄 next[j] | 4.4 |
| 失配后 i、j | i 不变,j = next[j];j = 0 时 i、j 都加 1 | 4.3 跟做 |
| 比较次数 | 画逐趟表,相等、不等都算,j = 0 那一步不算 | 4.3 跟做 |
| 右移距离 | j − next[j](next[j] = 0 时右移 j 位);也等于“已匹配 − PM” | 4.4 |
| 朴素匹配 | 失配后 i = i − j + 2、j = 1;最坏 (n − m + 1) × m 次 | 4.2 |
综合跟做:同一道题,三种做法比一比
- 求 next 和 nextval:T 的前缀 a、aa、aaa 的最长相等前后缀长度依次是 0、1、2,所以 next = 0 1 2 3;T[2]、T[3] 都和 next 指向的字符一样是 a,一路抄到 0,T[4] = b 与 T[3] = a 不同,nextval = 0 0 0 3。
- 第一次失配:S[1..2] = aa 与 T[1..2] 相等,S[3] = b 与 T[3] = a 不等,此时 i = 3、j = 3。
- 用 next:j 依次退到 2、1,S[3] = b 和 T[2] = a、T[1] = a 又各比一次,都不等;j = 0 后 i = 4、j = 1,接下来 aaab 比 4 次全部相等。共 3 + 2 + 4 = 9 次,匹配位置 4。
- 用 nextval:第一次失配后 j = nextval[3] = 0,直接 i = 4、j = 1,省掉了两次“注定失配”的比较。共 3 + 4 = 7 次。
- 用朴素算法:从位置 1、2、3、4 各起一趟,分别比 3、2、1、4 次,共 10 次。
| 方法 | 比较次数 | 省在哪里 |
|---|---|---|
| 朴素 | 10 | —(主串指针要回退) |
| KMP(next) | 9 | 主串指针不回退 |
| KMP(nextval) | 7 | 再省掉“和刚失配的字符一样”的比较 |
这一题把本篇的五个考点串了起来:next、nextval、第一次失配的 i 与 j、两种数组下的比较次数、朴素算法的比较次数。考场上按这个顺序写,每一步都有分。(全部数字由程序按讲义代码计数。)
这一篇留下的思想:KMP 最值得带走的不是 next 数组本身,而是“预处理一次,反复受益”和“不重复做已经知道答案的比较”——前者在散列表(先算好地址)、并查集(路径压缩)里都能看到,后者在 nextval、折半查找(每次比较都排除一半)里也是同一个味道。
- 先看题目用哪种下标:给 next[1] = 0 或说“第 j 个字符”是教材约定;写 s[i]、t[j] 且从 0 数是 0 起下标。
- KMP 中 i 永不回退;next 只和模式串有关。
- 朴素:失配后 i = i − j + 2、j = 1;KMP:失配后 i 不动、j = next[j](或 nextval[j])——两者最根本的区别,就在主串指针 i 会不会往回退(这也是 KMP 能边读边匹配数据流的原因)。
- 子串个数公式 n(n + 1)/2 + 1 只适用于字符互不相同的串。
- 求 T = 'ababaa' 的 next 和 nextval(教材约定)。
答案
next = 0 1 1 2 3 4;nextval = 0 1 0 1 0 4。(j = 3:T[3] = a = T[1],取 nextval[1] = 0;j = 4:T[4] = b = T[2],取 nextval[2] = 1;j = 5:T[5] = a = T[3],取 nextval[3] = 0;j = 6:T[6] = a ≠ T[4] = b,取 next[6] = 4。) - 朴素匹配最坏情况下比较多少次?给出一个例子。
答案
约 (n − m + 1) × m 次,O(nm)。例:S = 'aaaaaaab'(n = 8),T = 'aab'(m = 3):前 5 趟每趟比较 3 次后在第 3 个字符失配,第 6 趟 3 次匹配成功,共 18 次 = (8 − 3 + 1) × 3。 - KMP 算法中主串指针 i 会回退吗?模式串指针 j 呢?
答案
i 从不回退;j 会根据 next(nextval)回退。
4.✎本篇真题闯关(3 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
树与二叉树:一对多的层次结构
树是数据结构的核心,选择题考得最多(18 年 41 道),大题也常以二叉树为对象(求 WPL、表达式树、判定二叉搜索树……)。这一篇的内容很多,但主线清楚:先学树的术语和性质(结点数、高度的计算)→ 专门研究最规整的二叉树(性质、存储、四种遍历、由遍历序列还原、线索化)→ 把一般的树和森林转换成二叉树来处理 → 两个重要应用:哈夫曼树(最优编码)和并查集(集合合并)。其中“遍历”是一切二叉树算法的基础,一定要把递归写法练到闭着眼睛也能写出来。
5.0本篇地图:先看全貌,再逐节攻克
第 5 篇是数据结构里选择题最多的一篇(2009—2026 年共 41 道,平均每年 2 ~ 3 道),大题也常拿二叉树当“材料”。内容多,但只有一条主线:先学一般的树 → 重点研究二叉树 → 把一般的树和森林变成二叉树 → 两个应用。下面这张图左边是知识的层次,右边的条形是每一节挂的选择题真题数,越长越要花时间。
| 小节 | 学完要会的事(一句话) | 相关大题 | 难度 | 学时 |
|---|---|---|---|---|
| 5.1 概念与性质 | 用“结点数 = 边数 + 1”算叶子数、算 m 叉树的最小高度 | 2016-42 | ★★ | 1 h |
| 5.2 二叉树性质 | n₀ = n₂ + 1;完全二叉树由 n 求叶子数、由某层叶子数求结点数 | — | ★★ | 1.5 h |
| 5.3 存储 | 顺序存储的下标公式(从 1 / 从 0 两套);二叉链表 n + 1 个空指针 | 2022-41 | ★ | 0.5 h |
| 5.4 遍历 | 手写四种序列;递归代码默写;非递归能画出栈的变化 | 2017-41、2014-41 | ★★★ | 3 h |
| 5.5 构造 | 先 / 后 / 层序 + 中序还原二叉树;知道先 + 后为什么不行 | — | ★★ | 1.5 h |
| 5.6 线索二叉树 | 给定树画出某种线索;知道哪种线索树找不到前驱 / 后继 | — | ★★★ | 2 h |
| 5.7 树的存储 | 三种表示法各自“找双亲 / 找孩子”快不快 | — | ★ | 0.5 h |
| 5.8 树和森林 | “左孩子右兄弟”来回转换;遍历对应表;左空 / 右空计数 | — | ★★ | 1.5 h |
| 5.9 哈夫曼 | 构造、WPL(两种算法)、编码、前缀码判断、m 叉补虚段 | 2014-41、2020-42、2012-41 | ★★ | 2 h |
| 5.10 并查集 | 数组表示、Find / Union 代码、两种优化与复杂度 | 预测 | ★★ | 1.5 h |
| 5.11 堆及其应用 | 判堆、位置题(第 k 大在哪)、两种建堆的区别、删中间元素、k 路归并与 Top-k | 2022-42 | ★★ | 1.5 h |
- 第一遍按 5.1 → 5.10 顺序读,每节先做“跟我做一遍”,再做“随堂小测”,最后点小节标题下的真题。
- 5.4 遍历是全篇地基:5.5 构造、5.6 线索、5.8 转换、第 9 篇的二叉树大题全部建立在“会写遍历序列 + 会写递归遍历”之上,这一节不熟不要往后走。
- 5.9 哈夫曼和第 8 篇的“最佳归并树”是同一个东西;5.10 并查集会在第 6 篇 Kruskal 算法里再用一次。
- 第二遍只看每节的“易错”框和篇末 5.12 的一页总结,然后做本篇真题闯关。
5.1树的基本概念与性质
为什么需要“树”:一对多的关系
前四篇的线性表、栈、队列、串都是“排成一队”:每个元素前面最多一个、后面最多一个。可现实里很多关系不是一队,而是一个管好几个:电脑里一个文件夹装着几个子文件夹,子文件夹里又装着文件;学校分学院、学院分专业、专业分班级;一场淘汰赛的对阵图一层层合并到冠军。用线性表硬存这种关系,就丢掉了“谁属于谁”的信息。树就是专门存“一对多、有层次”关系的结构。
把树想成一本只记“父亲”的族谱:最上面的老祖宗是根;每个人只有一个父亲(双亲,这里“双亲”是教材的固定译名,指的就是一个上级结点);可以有好几个孩子;没有孩子的人是叶子。从老祖宗往下,每一支又是一本小族谱——这就是“树由根和若干棵子树组成”的递归味道。
哪里不像:真实族谱里一个人有父母两人,还可能出现近亲联姻让两支“合流”;树里每个结点只能有一个双亲,两棵子树绝不相交。一旦出现“一个结点有两个上级”或“绕一圈回来”,那就不是树而是图(第 6 篇)。
树的定义与术语
树是 n(n ≥ 0)个结点的有限集。n = 0 时为空树;非空树满足:① 有且仅有一个特定的结点叫根;② 其余结点可分为 m 个互不相交的有限集,每个集合本身又是一棵树,叫根的子树。这是一个递归定义——树的算法大多也是递归的。
树的特点:根没有前驱,其余每个结点有且只有一个前驱(双亲),可以有零个或多个后继(孩子)。n 个结点的树有且只有 n − 1 条边(除根外每个结点向上连一条)。
| 术语 | 含义 | 图 5-2 中 |
|---|---|---|
| 结点的度 | 结点拥有的孩子个数 | D 的度为 3,H 的度为 1 |
| 树的度 | 树中各结点度的最大值 | 3 |
| 叶结点(终端结点) | 度为 0 的结点 | C、F、G、I、K、L、M |
| 分支结点(非终端结点) | 度大于 0 的结点 | A、B、D、E、H |
| 双亲、孩子、兄弟 | 同一双亲的孩子互为兄弟 | E、F 是兄弟;G、H、I 是兄弟 |
| 堂兄弟 | 双亲在同一层的结点 | E 与 G、H、I |
| 祖先、子孙 | 从根到该结点路径上的所有结点都是它的祖先 | K 的祖先:A、B、E |
| 层次 | 根为第 1 层,它的孩子为第 2 层…… | M 在第 4 层 |
| 深度、高度 | 结点的深度从根往下数(根深度 1),结点的高度从叶往上数;树的高度(深度)= 最大层数 | E 的深度 3、高度 2;树高 4 |
| 路径、路径长度 | 两结点之间经过的结点序列;路径长度 = 经过的边数。树的路径只能从上往下(兄弟之间没有路径) | A 到 M 的路径长度 3 |
| 树的路径长度 | 从根到每个结点的路径长度之和。别和 5.9 节的带权路径长度 WPL 混:WPL 只算叶子,还要乘权 | 0 + 1 × 3 + 2 × 5 + 3 × 3 = 22 |
| 有序树、无序树 | 子树从左到右有次序、不能互换的是有序树 | 二叉树是有序树 |
| 森林 | m(m ≥ 0)棵互不相交的树的集合;把树的根删掉就得到子树森林 | 删掉 A 得到 3 棵树的森林 |
注意:有的教材让根的层次(深度)从 0 开始,408 真题一般会在题目中说明,默认从 1 开始。
树的性质:全部来自“数结点、数边”
408 的树性质题看起来五花八门(求叶子数、求结点数、求高度、求森林有几棵树),其实只用到两件事:数边和每层最多放几个。先补两样高中没学过的数学工具。
① 等比数列求和 S = 1 + m + m² + … + mh−1。两边乘 m:mS = m + m² + … + mh。相减,中间全部抵消:mS − S = mh − 1,所以 S = (mh − 1)/(m − 1)。m = 2 时就是 1 + 2 + 4 + … + 2h−1 = 2h − 1。
② 对数 logmx 回答的是“m 自乘几次能到 x”。log₂8 = 3(2 × 2 × 2 = 8),log₃81 = 4。不是整数也没关系:log₂10 ≈ 3.32,表示 2³ = 8 < 10 < 16 = 2⁴。考试里一般不用算小数,只要夹在两个相邻的幂之间就行。
③ 取整 ⌈x⌉ 向上取整(天花板):⌈3.32⌉ = 4,⌈3⌉ = 3;⌊x⌋ 向下取整(地板):⌊3.32⌋ = 3,⌊7/2⌋ = 3。C 语言里正整数相除 7/2 得 3,就是 ⌊7/2⌋。
- 结点数 = 所有结点的度数之和 + 1,即 n = n₁ + 2n₂ + … + m·nₘ + 1。推导见上图。
- 叶子数公式:n₀ = 1 + n₂ + 2n₃ + … + (m − 1)nₘ。把“n = n₀ + n₁ + … + nₘ”代入性质 1,两边的 n₁ 抵消即得。记法:度为 i 的结点给叶子数贡献 i − 1,度为 1 的结点贡献 0(它只是“传话”,不分叉)。
- 度为 m 的树(或 m 叉树)第 i 层至多 mi−1 个结点:第 1 层 1 个,每往下一层,每个结点最多带 m 个孩子,个数最多乘 m。
- 高度为 h 的 m 叉树至多 1 + m + … + mh−1 = (mh − 1)/(m − 1) 个结点(每层都放满,用上面的求和公式)。
- n 个结点的 m 叉树最小高度 = ⌈logm(n(m − 1) + 1)⌉:高度最小就要每层尽量放满,找最小的 h 使 (mh − 1)/(m − 1) ≥ n,移项得 mh ≥ n(m − 1) + 1。考场上不用背公式,直接列“h → 最多结点数”的表,找第一个够用的 h 更不容易错。
- 最大高度:n 个结点的 m 叉树最高是一条链,高度 n;而度为 m 的树必须有一个结点挂满 m 个孩子,最高为 n − m + 1(一条链 + 链上某处多挂 m − 1 个叶子)。
- 森林:k 棵树组成的森林,每棵树结点比边多 1,所以 结点数 = 边数 + k,叶子数 n₀ = k + n₂ + 2n₃ + …(把性质 2 里的“1”换成 k)。
“度为 m 的树”与“m 叉树”不是一回事
| 比较项 | 度为 m 的树 | m 叉树 |
|---|---|---|
| 含义 | 结点的最大度恰好是 m | 每个结点最多 m 个孩子(可以一个度为 m 的都没有) |
| 能否为空 | 不能,至少 m + 1 个结点 | 可以是空树 |
| 孩子的次序 | 一般的树不讲位置(无序树) | 有第 1 ~ m 个位置,即使只有一个孩子也要说明在哪个位置 |
| 高度为 h 时最少结点 | h + m − 1(链 + 多挂 m − 1 个) | h(一条链) |
| 高度为 h 时最多结点 | 两者相同:(mh − 1)/(m − 1) | |
二叉树就是 m = 2 的 m 叉树:可以为空、孩子分左右。所以“度为 2 的有序树”≠ 二叉树——前者至少 3 个结点,而且只有一个孩子时不分左右(5.2 节详讲)。
跟我做一遍:三类计算题
一棵度为 4 的树中,度为 4、3、2、1 的结点分别有 5、6、8、10 个,求叶结点个数。
- 先数边(依据:边数 = 度数之和):4 × 5 + 3 × 6 + 2 × 8 + 1 × 10 = 20 + 18 + 16 + 10 = 64 条。
- 再求总结点数(依据:结点数 = 边数 + 1):n = 65。
- 减去非叶结点:非叶结点 5 + 6 + 8 + 10 = 29 个,叶子 n₀ = 65 − 29 = 36。
- 用公式验算:n₀ = 1 + (4 − 1) × 5 + (3 − 1) × 6 + (2 − 1) × 8 + 0 × 10 = 1 + 15 + 12 + 8 = 36 ✓。两种方法结果一致才写答案。
一棵 5 叉树有 200 个结点,它的高度至少是多少?
- 高度最小 ⇒ 每层尽量放满。列出“高度 h 时最多能放几个结点”:
| h | 第 h 层最多 | 前 h 层合计最多 | 够放 200 个吗 |
|---|---|---|---|
| 1 | 1 | 1 | 不够 |
| 2 | 5 | 6 | 不够 |
| 3 | 25 | 31 | 不够 |
| 4 | 125 | 156 | 不够(还差 44 个) |
| 5 | 625 | 781 | 够了 ⇒ 最小高度 5 |
用公式核对:⌈log₅(200 × 4 + 1)⌉ = ⌈log₅801⌉,5⁴ = 625 < 801 ≤ 3125 = 5⁵,所以是 5。注意公式里是 n(m − 1) + 1,不是 n——直接算 ⌈log₅200⌉ = 4 就错了。
度为 3 的树有 10 个结点,它的高度最大是多少?最小是多少?
- 最高:要尽量排成链,但“度为 3”要求至少一个结点有 3 个孩子。让链上某个结点多挂 2 个叶子:链上 8 个结点 + 2 个叶子 = 10 个,高度 8 = n − m + 1。
- 最矮:每层尽量放满。高度 2 最多 1 + 3 = 4 个,不够;高度 3 最多 1 + 3 + 9 = 13 ≥ 10,够了 ⇒ 最小高度 3。
- 检查最矮的树是否真的“度为 3”:第 2 层放 3 个、第 3 层放 6 个,根的度就是 3 ✓。
真题怎么考
- 给各种度的结点数,求叶子数(2010-05):数边法或叶子公式,一分钟内解决。失效条件:题目说的是森林时,“+1”要换成“+ 棵数”。
- 森林中结点、边、棵数三者知二求一(2016-05):棵数 = 结点数 − 边数。
- m 叉树的最小高度(2022-04 三叉树 244 个结点):用列表法最稳。2022-04 题干说“叶结点的高度为 1”,只是规定高度从下往上数,树的高度仍等于层数。
- 大题推广(2016-42 正则 k 叉树):非叶结点都有 k 个孩子,n₀ = (k − 1)m + 1;高度 h 时最多 (kh − 1)/(k − 1) 个、最少 h + (h − 1)(k − 1) 个结点。全部是本节性质的直接应用,第 9 篇 t8 有完整解答。
- A. 41
- B. 82
- C. 113
- D. 122
展开解析
B两种算法都行。
- 方法一(数边):度数之和 = 20 × 4 + 10 × 3 + 1 × 2 + 10 × 1 = 122,所以结点总数 n = 122 + 1 = 123。已知非叶结点 20 + 10 + 1 + 10 = 41 个,叶子 n₀ = 123 − 41 = 82。
- 方法二(公式):n₀ = 1 + n₂ + 2n₃ + 3n₄ = 1 + 1 + 20 + 60 = 82。
- A 错41 是非叶结点数。
- B 对82。
- C 错把度数和、结点总数、叶子数混在一起算的结果。
- D 错122 是度数之和(= 边数),不是叶子数。
树:n = 边数 + 1 = Σ(度 × 该度结点数) + 1;n₀ = 1 + Σ(i − 1)nᵢ。
- “度”是孩子个数,不是“连了几条边”:非根结点连着 1 条向上的边 + 度条向下的边,图论里的“度”和树里的“度”不同。
- 树的度 = 所有结点度的最大值,不是总和。
- 求最小高度时用 n 代替 n(m − 1) + 1,会少 1 层(例 2)。
- 层次默认根为第 1 层;若题目说根在第 0 层,所有“高度”公式都要减 1。
- 森林 F 有 3 棵树,其中度为 2 的结点 4 个、度为 3 的结点 2 个,其余都是叶子或度为 1 的结点。F 有几个叶结点?
答案
森林的叶子公式把“1”换成棵数:n₀ = 3 + (2 − 1) × 4 + (3 − 1) × 2 = 3 + 4 + 4 = 11(度为 1 的结点多少都不影响)。 - 高度为 4 的 3 叉树最多有几个结点?高度为 4、度为 3 的树最少有几个结点?
答案
最多 (3⁴ − 1)/2 = 40;最少:一条 4 个结点的链,再在某个非最底层结点上多挂 2 个孩子,共 4 + 2 = 6 个(h + m − 1)。 - 判断:“度为 2 的有序树就是二叉树。”
答案
错。二叉树可以为空,可以没有度为 2 的结点;而且二叉树中只有一个孩子时也要分左右,度为 2 的有序树只有一个孩子时无所谓左右。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 21
- B. 25
- C. 26
- D. 36
展开解析
C结点数 = 分支数 + 1:n0 + 10 + 5 + 20 = (3×10 + 2×5 + 1×20) + 1 = 61,得 n0 = 61 − 35 = 26。也可直接用 n0 = 1 + Σ(i − 1)ni = 1 + 2×10 + 1×5 = 26。
变化点:度为 1 的结点不影响叶子数((1−1)×n1 = 0)。这个等式对任何树都成立,是这类题的唯一工具。
- A. 30
- B. 37
- C. 38
- D. 39
展开解析
C结点的度之和 = 分支(边)数 = 30。每棵树结点数 = 边数 + 1,森林结点数 = 边数 + 树的棵数 = 30 + 8 = 38。
变化点:原题已知边数和结点数求棵数(25 − 15 = 10)。三者关系:结点数 = 边数 + 棵数,知二求一。
- A. 4
- B. 5
- C. 6
- D. 7
展开解析
B高度为 h 的四叉树最多有 1 + 4 + 16 + … + 4h−1 = (4h − 1)/3 个结点。h = 4 时最多 85 < 100,h = 5 时最多 341 ≥ 100,所以至少 5 层。
变化点:m 叉树高度至少为 ⌈logm(n(m−1) + 1)⌉。原题三叉树 244 个结点:(35−1)/2 = 121 < 244 ≤ 364,得 6。
5.2二叉树的定义、性质与满/完全二叉树
为什么要专门研究二叉树
一般的树每个结点的孩子个数不定,存储时不知道该给每个结点留几个指针;写算法时也要处理“第 1 个孩子、第 2 个孩子……第 k 个孩子”。如果规定每个结点最多两个孩子,事情一下子变简单:每个结点固定两个指针,递归时只要处理“左边一半、右边一半”。更妙的是,5.8 节会讲到任何树、任何森林都能一一对应地变成一棵二叉树,所以把二叉树研究透,等于把所有的树都研究透了。
8 名选手打单淘汰赛:每场比赛两人,赢的晋级。把对阵图倒过来看就是一棵二叉树——叶子是选手,每个分叉点是一场比赛,根是决赛。每场比赛恰好淘汰 1 个人,要从 8 个人里淘汰到只剩冠军,必须淘汰 7 人,所以一共 7 场比赛。这正是下面要证的 n₀ = n₂ + 1:叶子(选手)比度为 2 的结点(比赛)多 1。
哪里不像:对阵图里每个分叉都恰好两条线;一般的二叉树还可以有“只有一个孩子”的结点(度为 1),它们像“不用比赛的过道”,既不增加也不减少选手,所以不影响 n₀ = n₂ + 1。
定义与五种基本形态
二叉树是 n(n ≥ 0)个结点的有限集:或者为空,或者由一个根和两棵互不相交的、分别叫左子树和右子树的二叉树组成。它有两个特点:每个结点最多两个孩子;子树有左右之分,次序不能颠倒——即使只有一个孩子,也要说明是左孩子还是右孩子。
几种特殊的二叉树
| 名称 | 定义 | 特点 |
|---|---|---|
| 满二叉树 | 高度为 h、含 2h − 1 个结点,每层都是满的 | 叶子全在最下一层;除叶子外每个结点度都是 2。按层序从 1 编号,结点 i 的双亲是 ⌊i/2⌋,左孩子 2i、右孩子 2i + 1 |
| 完全二叉树 | 高度为 h、n 个结点,每个结点都与同高度满二叉树中编号 1 ~ n 的结点一一对应 | 即“除最后一层外都满,最后一层的结点集中在最左边”。见下面的性质表 |
| 二叉排序树 | 左子树上所有关键字 < 根 < 右子树上所有关键字,左右子树也分别是二叉排序树 | 第 7 篇详讲 |
| 平衡二叉树 | 任一结点左右子树的高度差不超过 1 | 第 7 篇详讲 |
| 正则二叉树 | 树中每个分支结点都有 2 个孩子(没有度为 1 的结点) | 哈夫曼树就是;2016-42 推广到“正则 k 叉树” |
二叉树的性质(每年都会用到,务必会推导)
| # | 性质 | 推导 / 说明 |
|---|---|---|
| 1 | n₀ = n₂ + 1(叶子数 = 度为 2 的结点数 + 1) | 结点数 n = n₀ + n₁ + n₂;边数 n − 1 = n₁ + 2n₂。两式相减。 |
| 2 | 第 i 层至多 2i−1 个结点 | 每层最多是上一层的 2 倍 |
| 3 | 高度为 h 的二叉树至多 2h − 1 个结点 | 1 + 2 + … + 2h−1 |
| 4 | n 个结点的完全二叉树(及二叉树的最小)高度为 ⌈log₂(n + 1)⌉ 或 ⌊log₂n⌋ + 1 | 2h−1 − 1 < n ≤ 2h − 1 |
| 5 | n 个结点的二叉链表有 n + 1 个空指针 | 共 2n 个指针,n − 1 个被边用掉 |
n₀ = n₂ + 1:同一批边数两遍
- 按结点数:n = n₀ + n₁ + n₂ = 3 + 2 + 2 = 7。
- 从下往上数边:除根外每个结点向上一条边,边数 = n − 1 = 6。
- 从上往下数边:度为 1 的结点伸出 1 条、度为 2 的伸出 2 条,边数 = n₁ + 2n₂ = 2 + 4 = 6。
- 令两种数法相等:n₀ + n₁ + n₂ − 1 = n₁ + 2n₂,n₁ 消掉,得 n₀ = n₂ + 1。
这条结论对任何非空二叉树都成立,与形状无关;它就是 5.1 叶子公式 n₀ = 1 + n₂ + 2n₃ + … 在 m = 2 时的样子。由它衍生出三种题型:
| 已知 | 做法 | 例 |
|---|---|---|
| n₂(或 n₀) | 直接 n₀ = n₂ + 1 | 度为 2 的结点 49 个 ⇒ 叶子 50 个 |
| 总数 n 和 n₁ | n = n₀ + n₁ + (n₀ − 1) ⇒ n₀ = (n − n₁ + 1)/2 | n = 200、n₁ = 15 ⇒ n₀ = 93、n₂ = 92 |
| 判断“这样的树存在吗” | (n − n₁ + 1) 必须是偶数 | n = 200、n₁ = 16 ⇒ 185/2 不是整数,不存在 |
| 没有度为 1 的结点(哈夫曼树、满二叉树、2018-04) | n = 2n₀ − 1 | k 个叶子 ⇒ 2k − 1 个结点 |
完全二叉树:由结点数推出一切
完全二叉树的形状完全由结点数 n 决定(编号 1 ~ n 一个不缺),所以“求叶子数、度为 1 的结点数、高度”都只需要 n。
- 结点 i 的双亲是 ⌊i/2⌋;左孩子 2i(若 2i ≤ n),右孩子 2i + 1(若 2i + 1 ≤ n)。编号从 0 开始时:双亲 ⌊(i−1)/2⌋,孩子 2i + 1、2i + 2(2022-41、2025-03 的顺序存储用的就是它)。
- i ≤ ⌊n/2⌋ 为分支结点,i > ⌊n/2⌋ 为叶结点,所以叶子数 n₀ = n − ⌊n/2⌋ = ⌈n/2⌉。
- 度为 1 的结点最多一个:n 为偶数时 n₁ = 1(编号 n/2 的结点只有左孩子),n 为奇数时 n₁ = 0。
- 叶子只可能出现在最下面两层;最下一层的叶子都靠左排列。
- 若结点 i 是叶子或只有左孩子,则编号大于 i 的结点全是叶子。
为什么“编号 > ⌊n/2⌋ 的都是叶子”:结点 i 的左孩子编号是 2i,有左孩子 ⟺ 2i ≤ n ⟺ i ≤ n/2。所以从编号就能直接读出谁是叶子,不用画树。
展开解析
n = 1001 为奇数 ⇒ n₁ = 0;叶子 n₀ = ⌈1001/2⌉ = 501;n₂ = n₀ − 1 = 500(验算 501 + 0 + 500 = 1001)。高度 ⌊log₂1001⌋ + 1 = 9 + 1 = 10(2⁹ = 512 ≤ 1001 < 1024)。
一棵完全二叉树的第 4 层(根为第 1 层)有 3 个叶结点,这棵树最少有几个结点?最多有几个?
- 先定位(依据:完全二叉树的叶子只出现在最下面两层):有叶子的第 4 层,要么是最后一层,要么是倒数第二层。两种情况分别对应“最少”和“最多”。
- 最少:第 4 层就是最后一层,而且只有这 3 个结点。前 3 层满:1 + 2 + 4 = 7,再加 3 ⇒ 10。这正是上面图里的树(8、9、10 号在第 4 层)。
- 最多:第 4 层是倒数第二层,必须是满的:2³ = 8 个结点,其中 3 个叶子(排在最右边,因为完全二叉树最后一层的结点靠左),左边 5 个都是分支结点。要结点最多,这 5 个每个都带 2 个孩子:第 5 层 10 个。总数 = (2⁴ − 1) + 10 = 15 + 10 = 25。
- 检验:n = 25 时 ⌊25/2⌋ = 12,第 4 层的编号是 8 ~ 15,其中 13、14、15 > 12 是叶子——恰好 3 个 ✓。
2009-05 是同一个题型:第 6 层有 8 个叶子,最多 = 63 + (32 − 8) × 2 = 111,最少 = 31 + 8 = 39。
- A. 39
- B. 52
- C. 111
- D. 119
展开解析
C第 6 层有叶子,完全二叉树的叶子只能在最下面两层,所以树高是 6 或 7。要结点最多,取 7 层。
- 前 6 层是满的:2⁶ − 1 = 63 个结点,其中第 6 层 2⁵ = 32 个。
- 第 6 层有 8 个是叶子,另外 24 个是分支结点。要结点最多,这 24 个都有两个孩子:第 7 层 48 个。
- 总数 63 + 48 = 111。(第 7 层的结点都在最左边,第 6 层的 8 个叶子在最右边,符合完全二叉树。)
- A 错39 = 31 + 8,是按“树高 6、第 6 层只有 8 个结点”算的最少情况。
- B 错“第 6 层恰有 8 个叶子”的完全二叉树,结点数只可能是 39(第 6 层是最后一层)或 110、111(第 7 层有 47 或 48 个),52 不在其中。
- C 对111。
- D 错119 = 63 + 56,误以为第 6 层 28 个非叶结点。
完全二叉树“某层有 k 个叶子”:最多的情形是它不是最后一层,下一层由该层其余结点各生两个孩子。
编号公式的两套写法:下标从 1 还是从 0
教材按层序从 1 编号,公式最漂亮;可 C 语言数组下标从 0 开始,真题两种都考过(2022-41、2025-03 用的是从 0 开始)。不要死背两套,记住一套再“整体平移”就行。
编号从 1:i 的孩子 2i、2i + 1,双亲 ⌊i/2⌋
编号从 0:i 的孩子 2i + 1、2i + 2,双亲 ⌊(i − 1)/2⌋
| 要求的量 | 编号从 1(T[0] 空着) | 编号从 0 |
|---|---|---|
| 左孩子 | 2i(存在条件 2i ≤ n) | 2i + 1(存在条件 2i + 1 < n) |
| 右孩子 | 2i + 1(2i + 1 ≤ n) | 2i + 2(2i + 2 < n) |
| 双亲 | ⌊i/2⌋(i > 1) | ⌊(i − 1)/2⌋(i > 0) |
| 是否叶子 | i > ⌊n/2⌋ | i ≥ ⌊n/2⌋ |
| 所在层次 | ⌊log₂i⌋ + 1 | ⌊log₂(i + 1)⌋ + 1 |
平移的道理:从 0 编号的 i,就是从 1 编号的 i + 1。它的左孩子在“从 1”体系里是 2(i + 1),换回“从 0”要再减 1:2(i + 1) − 1 = 2i + 1。双亲同理:⌊(i + 1)/2⌋ − 1 = ⌊(i − 1)/2⌋。考场上忘了公式,就用这个方法现推。
为什么孩子是 2i(从 1 编号):第 k 层第一个结点编号是 2k−1。设 i 是第 k 层的第 j 个,i = 2k−1 + j − 1。它前面的 j − 1 个结点各有 2 个孩子,所以 i 的左孩子是第 k + 1 层的第 2j − 1 个:编号 2k + 2j − 2 = 2i。
高度公式的两种写法
高度为 h 的完全二叉树,前 h − 1 层是满的,第 h 层至少 1 个、至多 2h−1 个,所以结点数满足 2h−1 − 1 < n ≤ 2h − 1(也可写成 2h−1 ≤ n < 2h)。对这两种写法分别取对数:
- 2h−1 < n + 1 ≤ 2h ⇒ h − 1 < log₂(n + 1) ≤ h ⇒ h = ⌈log₂(n + 1)⌉;
- 2h−1 ≤ n < 2h ⇒ h − 1 ≤ log₂n < h ⇒ h = ⌊log₂n⌋ + 1。
| n | 1 | 2 | 3 | 4 | 7 | 8 | 15 | 16 | 100 | 1001 |
|---|---|---|---|---|---|---|---|---|---|---|
| ⌈log₂(n + 1)⌉ | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 7 | 10 |
| ⌊log₂n⌋ + 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 7 | 10 |
两式恒等(程序对 n = 1 ~ 5000 逐个验证)。易错点在“边界”:n = 7 时 log₂8 = 3,⌈log₂(n + 1)⌉ = 3;如果误写成 ⌈log₂n⌉ = ⌈2.81⌉ = 3 碰巧对,但 n = 8 时 ⌈log₂8⌉ = 3 就错了(应为 4)。同理,这也是 n 个结点的任意二叉树的最小高度。
完全二叉树按层序从 1 编号存放在数组 T 中,求编号 i = 12 和 j = 7 的两个结点的最近公共祖先。
思路:一直往上走(除以 2)就是走向双亲。编号大的结点所在层次不会比编号小的浅,所以每次让编号大的那个往上走一步,两者相遇的地方就是最近公共祖先。
int CommonAncestor(ElemType T[], int i, int j) { // T[1..n] 按层序存放 while (i != j) { if (i > j) i = i / 2; // 编号大的往上走一层 else j = j / 2; } return i; // 返回公共祖先的编号 }
| 轮次 | i | j | 比较后的动作 |
|---|---|---|---|
| 开始 | 12 | 7 | 12 > 7,i = 12/2 = 6 |
| 1 | 6 | 7 | 6 < 7,j = 7/2 = 3 |
| 2 | 6 | 3 | 6 > 3,i = 6/2 = 3 |
| 3 | 3 | 3 | 相等,结束:最近公共祖先是 3 号 |
复杂度:每轮有一个编号减半,O(log₂n)。如果是下标从 0 的存储,把 i / 2 改成 (i − 1) / 2 即可。
n 个结点能组成多少种二叉树:卡特兰数
C(a, b) 表示“从 a 个不同的东西里选 b 个(不讲顺序)的方法数”,C(a, b) = a! / (b!·(a − b)!)。例如 C(6, 3) = 720 / (6 × 6) = 20。
以 n = 3 为例。先把结点按先序起名 a、b、c(根总是 a),一共 5 种形态:
中序 c b a
中序 b c a
中序 b a c
中序 a c b
中序 a b c
递推:设 n 个结点的二叉树有 Cₙ 种。根占一个结点,左子树放 k 个(k = 0 ~ n − 1),右子树放 n − 1 − k 个,左右独立选形态:
| n | 递推计算 | C(2n, n)/(n + 1) | Cₙ |
|---|---|---|---|
| 0 | 规定 | 1/1 | 1 |
| 1 | C₀C₀ | 2/2 | 1 |
| 2 | C₀C₁ + C₁C₀ = 1 + 1 | 6/3 | 2 |
| 3 | C₀C₂ + C₁C₁ + C₂C₀ = 2 + 1 + 2 | 20/4 | 5 |
| 4 | 5 + 2 + 2 + 5 | 70/5 | 14 |
| 5 | 14 + 5 + 4 + 5 + 14 | 252/6 | 42 |
| 6 | 42 + 14 + 10 + 10 + 14 + 42 | 924/7 | 132 |
记住前几项 1, 1, 2, 5, 14, 42, 132。同一个数出现在三处:n 个结点的二叉树形态数;先序序列固定的二叉树个数(2015-02,5.5 节);n 个元素的出栈序列数(3.2 节)。顺带两个小结论:n 个结点、高度为 n 的二叉树(每层一个结点)有 2n−1 种(每条边选左或右);拓展 n 个结点的一般(有序)树有 Cn−1 种(树转成二叉树后根没有右孩子,左子树是 n − 1 个结点的任意二叉树)。
真题怎么考
- 完全二叉树求叶子数(2011-04:768 个结点 ⇒ 384):n₀ = ⌈n/2⌉ 一步到位;n 是偶数时别忘了 n₁ = 1。
- 某层有 k 个叶子求结点数(2009-05):最多 ⇒ 该层是倒数第二层;最少 ⇒ 该层是最后一层。
- 没有度为 1 的结点(2018-04 满二叉树、5.9 哈夫曼树):n = 2n₀ − 1。
- 概念辨析(2025-04):完全二叉树度为 1 的结点“最多 1 个”而不是“没有”;n₀ = n₂ + 1 不能推出“叶子比分支结点多”。
- 满二叉树一定是完全二叉树,反过来不成立。完全二叉树的叶子只在最下两层,但“最下两层都有叶子”不一定(满二叉树的叶子全在最后一层)。
- 高度为 h 的完全二叉树结点数在 [2h−1, 2h − 1] 之间,最少的情形是最后一层只有 1 个结点。
- “n 个结点的二叉树高度”问的若是任意二叉树,范围是 ⌈log₂(n + 1)⌉ ~ n;只有说完全二叉树时高度才唯一。
- 下标从 0 时孩子是 2i + 1、2i + 2,不是 2i、2i + 1;判断孩子存在用“< n”而不是“≤ n”。
- 一棵二叉树有 200 个结点,其中度为 1 的结点 15 个,叶子有几个?如果度为 1 的结点是 16 个呢?
答案
n₀ = (n − n₁ + 1)/2 = (200 − 15 + 1)/2 = 93(n₂ = 92,93 + 15 + 92 = 200 ✓)。n₁ = 16 时 (200 − 16 + 1)/2 = 92.5 不是整数——这样的二叉树不存在。 - 一棵完全二叉树有 50 个叶结点,它最少、最多各有几个结点?
答案
完全二叉树 n₀ = ⌈n/2⌉ = 50 ⇒ n = 99 或 100。最少 99(没有度为 1 的结点),最多 100(有一个度为 1 的结点)。 - 27 个结点的完全二叉树按层序存在数组 R[0..26] 中。R[13] 的双亲是哪个下标?R[13] 有没有孩子?另一棵有 100 个结点的完全二叉树高度是多少?
答案
双亲 ⌊(13 − 1)/2⌋ = 6;左孩子下标应为 2 × 13 + 1 = 27,但 27 < 27 不成立 ⇒ 没有孩子(也可用 13 ≥ ⌊27/2⌋ = 13 判定为叶子)。100 个结点:⌊log₂100⌋ + 1 = 6 + 1 = 7(2⁶ = 64 ≤ 100 < 128)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 21
- B. 37
- C. 51
- D. 57
展开解析
C结点最多时,第 5 层不是最后一层:第 5 层满(16 个),其中 6 个是叶子、10 个有孩子。完全二叉树中这 10 个结点都必须有 2 个孩子(除最后一个外,前面的分支结点都是度 2,这里取最多),第 6 层有 20 个结点。总数 = (25 − 1) + 20 = 31 + 20 = 51。
变化点:若问“最少”,第 5 层就是最后一层,只有 6 个结点:15 + 6 = 21。“有 k 个叶结点的那一层”可能是最后一层,也可能是倒数第二层,最多、最少分别对应这两种情况。
- A. 500
- B. 501
- C. 502
- D. 250
展开解析
Bn 为奇数时完全二叉树没有度为 1 的结点:n = n0 + n2 = 2n2 + 1,n2 = 500,n0 = 501。也可以直接用 n0 = ⌈n/2⌉。
变化点:原题 n = 768 为偶数,有一个度为 1 的结点,n0 = n/2 = 384。奇数 n:n1 = 0;偶数 n:n1 = 1,两种情况都有 n0 = ⌈n/2⌉。
- A. 31
- B. 40
- C. 41
- D. 42
展开解析
Cn0 = n2 + 1 = 16,总数 = 16 + 10 + 15 = 41。
变化点:原题是满二叉树,没有度 1 结点,k 个叶子则共 2k − 1 个结点。一般二叉树要把 n1 加上。
5.3二叉树的存储结构
把一棵树放进内存的两种思路
内存是一长排编了号的格子,没有“上下左右”。要把二叉树放进去,必须用某种办法记住“谁是谁的孩子”。办法只有两类:用位置记(顺序存储:编号为 i 的结点放在第 i 格,孩子在哪一格靠公式算出来)和用指针记(链式存储:每个结点自带两个指针,直接指向左右孩子)。
顺序存储像电影院按票上的座位号入座:你的孩子一定坐在 2i 号和 2i + 1 号,找人极快;但没来的人座位也得空着,来的人少、座位号又大时,空座一大片。链式存储像每人手里只记两个孩子的电话:来几个人占几个位置,不浪费座位;可想找“我的双亲”时,手里没有他的电话,只能从根开始一路问下来。
顺序存储
把二叉树的结点按完全二叉树的编号放进数组:编号为 i 的结点放在 T[i](下标从 1 开始,T[0] 空着)或 T[i−1](从 0 开始)。对完全二叉树来说非常紧凑,还能用下标公式直接找双亲和孩子。
一般二叉树也可以这样存,但缺的结点也要占位(用 0、−1 或 # 表示“空”)。最坏情况是只有右孩子的“单支树”:高度为 h、只有 h 个结点,却要占 2h − 1 个单元。所以顺序存储适合完全二叉树和满二叉树,一般二叉树用链式存储。
链式存储:二叉链表
typedef struct BiTNode { ElemType data; struct BiTNode *lchild, *rchild; // 左、右孩子指针 } BiTNode, *BiTree;
n 个结点的二叉链表有 2n 个指针域,其中 n − 1 个指向孩子,n + 1 个为空——线索二叉树就是利用这些空指针。如果经常要找双亲,可以再加一个 parent 指针,叫三叉链表。
跟我做一遍:顺序存储读图、转链表、判合法
二叉树按层序编号(从 1 开始)存放在 T[1..10] 中:A B C # D E # # # F(# 表示空)。① 画出这棵树;② 求 F 的双亲,F 是左孩子还是右孩子;③ 若改为从 0 开始存放,F 在哪个下标?
- 逐个找双亲(依据:i 的双亲是 ⌊i/2⌋):B(2)、C(3) 的双亲是 1 号 A;4 号空;D(5) 的双亲 ⌊5/2⌋ = 2 号 B,5 是奇数 ⇒ D 是 B 的右孩子;E(6) 的双亲 3 号 C,6 是偶数 ⇒ 左孩子;F(10) 的双亲 5 号 D,10 是偶数 ⇒ 左孩子。
- 画树:A(B(, D(F, )), C(E, )),先序 A B D F C E。
- 换成从 0 存放:每个元素下标减 1,F 在 9 号;验算双亲 ⌊(9 − 1)/2⌋ = 4,正是 D(原来的 5 号)✓。
写成程序:从 1 号开始递归,i 号结点的左子树就是“以 2i 为根的子树”。
#define NIL '#' BiTree Build(ElemType T[], int n, int i) { // 由 T[1..n] 建立以 i 为根的二叉链表 if (i > n || T[i] == NIL) return NULL; // 越界或空位:空树 BiTNode *p = (BiTNode *)malloc(sizeof(BiTNode)); p->data = T[i]; p->lchild = Build(T, n, 2 * i); // 左孩子在 2i p->rchild = Build(T, n, 2 * i + 1); // 右孩子在 2i + 1 return p; } // 调用:BiTree root = Build(T, n, 1);
| 调用顺序 | i | T[i] | 结果 |
|---|---|---|---|
| 1 | 1 | A | 建 A,先去建左子树 Build(2) |
| 2 | 2 | B | 建 B,Build(4) |
| 3 | 4 | # | 空位,返回 NULL ⇒ B 无左孩子 |
| 4 | 5 | D | 建 D,Build(10) |
| 5 | 10 | F | 建 F;Build(20)、Build(21) 越界 ⇒ F 是叶子 |
| 6 | 11 | — | 11 > 10 越界 ⇒ D 无右孩子 |
| 7 | 3 | C | 回到 A 的右子树:建 C,Build(6) |
| 8 | 6 | E | 建 E;Build(12)、Build(13) 越界 |
| 9 | 7 | # | 空位 ⇒ C 无右孩子,结束 |
复杂度:每个非空结点建一次、每次最多再试 2 个位置,O(n)。
判断一个数组能不能表示二叉树(2025-03 的考法):每个存在的结点,它的双亲也必须存在(根除外)。下标从 0、用 −1 表示空:
bool IsValid(int R[], int n) { // R[0..n-1] for (int i = 1; i < n; i++) if (R[i] != -1 && R[(i - 1) / 2] == -1) // 有结点却没有双亲 return false; return true; }
两段代码都已抽出编译,与“从根出发能走到的结点数 = 非空结点数”的独立判定随机对拍 20 万次一致。
真题怎么考
- 顺序存储要占多少单元(2020-03):“任意”高度为 h 的二叉树都要存下 ⇒ 按最坏的单支树准备 2h − 1 个单元,和结点数无关。
- 数组能否表示二叉树(2025-03):逐个检查非空结点的双亲位置是否非空。
- 大题(2022-41):顺序存储的二叉树判断是否为二叉搜索树——用下标公式做中序遍历,或递归传上下界。下标公式写错(从 0 存却用 2i)是最常见的失分点。
- 二叉链表空指针 n + 1,三叉链表空指针 n + 2(多出的 1 个是根的 parent)。
- 顺序存储的“结点不存在”必须占位;“数组长度”与“结点数”是两回事。
- 顺序存储找双亲、孩子都是 O(1);二叉链表找孩子 O(1),找双亲要从根遍历 O(n)。
- 一棵 30 个结点的二叉树用二叉链表存储,有几个空指针?改用三叉链表呢?
答案
二叉链表 30 + 1 = 31;三叉链表 3 × 30 − 2 × 29 = 32。 - 二叉树按层序从 1 编号顺序存储,结点存在 T[19]。它的双亲在哪个下标?它是左孩子还是右孩子?它在第几层?
答案
双亲 ⌊19/2⌋ = 9;19 是奇数 ⇒ 右孩子;层次 ⌊log₂19⌋ + 1 = 4 + 1 = 5(16 ≤ 19 < 32)。 - 下标从 0 的数组 R = {8, 3, −1, 1, 6, −1, −1, −1, −1, 4}(−1 为空)能否表示一棵二叉树?能的话写出先序序列。
答案
能:R[1]、R[3]、R[4]、R[9] 的双亲 R[0]、R[1]、R[1]、R[4] 都存在。树为 8(3(1, 6(4, )), ),R[9] = 4 是 R[4] = 6 的左孩子(9 = 2 × 4 + 1)。先序 8 3 1 6 4。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 8
- B. 15
- C. 16
- D. 31
展开解析
B顺序存储按完全二叉树的编号放结点,高度为 4 的二叉树中结点编号最大可达 24 − 1 = 15(例如最后一层的最右结点)。“任意一棵”都要存下,就要按最坏情况准备 15 个单元。
变化点:只和高度有关,与结点数无关:高度 h 需要 2h − 1 个单元(若下标从 1 开始、数组 0 号不用,则数组长度为 2h)。
- A. R[] = {10, 5, 15, −1, 7}
- B. R[] = {10, −1, 15, −1, −1, 12, 20}
- C. R[] = {10, 5, 15, −1, 7, 12, −1, 3}
- D. R[] = {10, 5, −1, 3, 7}
展开解析
C检查每个存在的结点,其双亲 R[(i−1)/2] 必须存在。C 中 R[7] = 3 的双亲是 R[3] = −1,结点 3 没有双亲,不是一棵树。A:R[4] 的双亲 R[1] = 5 ✓;B:R[5]、R[6] 的双亲 R[2] = 15 ✓;D:R[3]、R[4] 的双亲 R[1] = 5 ✓。
变化点:原题 D 同样是“某个非空结点的双亲为空”。方法:从后往前,对每个非 −1 的位置 i 查 R[(i−1)/2](下标从 1 开始时查 R[i/2])。
5.4二叉树的遍历
遍历是什么:把一棵树“压扁”成一行
线性表要访问每个元素,从头走到尾就行;树有分叉,“挨个访问”之前必须先规定走哪条路线、在什么时候“看一眼”结点。遍历就是这条规定好的路线——它把非线性的树变成一个线性序列,后面所有二叉树算法(求高度、数叶子、找路径、判断性质)都是“沿遍历路线走一遍,路上顺便做点事”。
规定路线:每到一个岔口先进左廊、逛完回来再进右廊,走到底就原路返回。同一条路线上,每个岔口(结点)你会经过三次:刚到时、从左廊回来时、从右廊回来要离开时。在第一次经过时拍照得到先序,第二次得到中序,第三次得到后序——路线完全一样,只是“拍照”的时机不同。
哪里不像:层序遍历不是这条路线,它是“一层一层地逛”,相当于每层楼按从左到右的顺序走完再上楼,需要队列来记“下一层要去哪些房间”。
四种遍历的定义与递归代码
遍历是按某条搜索路径访问树中每个结点,使每个结点恰好被访问一次。二叉树由根(N)、左子树(L)、右子树(R)三部分组成,规定“先左后右”,按根被访问的时机分为三种:
| 遍历 | 顺序 | 递归代码的核心 |
|---|---|---|
| 先序(前序)NLR | 根 → 左子树 → 右子树 | visit(T); PreOrder(T->lchild); PreOrder(T->rchild); |
| 中序 LNR | 左子树 → 根 → 右子树 | InOrder(T->lchild); visit(T); InOrder(T->rchild); |
| 后序 LRN | 左子树 → 右子树 → 根 | PostOrder(T->lchild); PostOrder(T->rchild); visit(T); |
| 层序 | 从上到下、每层从左到右 | 用队列(见下) |
void PreOrder(BiTree T) { if (T != NULL) { // 出口:空树什么也不做 visit(T); // 访问根 PreOrder(T->lchild); // 递归遍历左子树 PreOrder(T->rchild); // 递归遍历右子树 } } // 中序、后序只是把 visit(T) 移到中间或最后
写先序时,把树看成“根 + 左子树 + 右子树”三块:先写根 A,左子树整体写成 [B 的先序],右子树写成 [C 的先序],再把每块递归展开:A [B D [E G]] [C F] ⇒ A B D E G C F。一次只处理一层,就不会乱。
另一个技巧:从根出发绕树的外轮廓逆时针走一圈,每个结点会经过三次(左边、下边、右边)。第一次经过时记下得到先序,第二次(从下面)得到中序,第三次(从右边)得到后序。
递归是怎么跑起来的:每个结点被经过三次
把这条路线和递归调用对上号:每次“经过”就是递归函数执行到某一行。下表跟踪 f(A)(f 可以是先序、中序或后序函数),“递归栈”是当时还没结束的调用:
| # | 发生了什么 | 递归栈(底 → 顶) | 这一刻若 visit,得到 |
|---|---|---|---|
| 1 | 调用 f(A):第 ① 次到 A | A | 先序访问 A |
| 2 | 调用 f(B):第 ① 次到 B | A B | 先序访问 B |
| 3 | 调用 f(D):第 ① 次到 D | A B D | 先序访问 D |
| 4 | f(D->lchild) 是空树,立刻返回:第 ② 次到 D | A B D | 中序访问 D |
| 5 | f(D->rchild) 也立刻返回:第 ③ 次到 D,f(D) 结束出栈 | A B D | 后序访问 D |
| 6 | 回到 f(B),左子树完成:第 ② 次到 B | A B | 中序访问 B |
| 7 | B 的右孩子为空:第 ③ 次到 B,f(B) 结束 | A B | 后序访问 B |
| 8 | 回到 f(A):第 ② 次到 A | A | 中序访问 A |
| 9 | 调用 f(C):第 ① 次到 C | A C | 先序访问 C |
| 10 | C 的左孩子为空:第 ② 次到 C | A C | 中序访问 C |
| 11 | C 的右孩子为空:第 ③ 次到 C,f(C) 结束 | A C | 后序访问 C |
| 12 | 回到 f(A):第 ③ 次到 A,全部结束 | A | 后序访问 A |
表里递归栈最深时有 3 层(A B D),正好等于树高——这就是“递归遍历的空间复杂度 O(h)”的来历:栈里存的是从根到当前结点的路径。
层序遍历:队列
void LevelOrder(BiTree T) { BiTNode *Q[MaxSize]; int front = 0, rear = 0; // 用数组当队列 if (T != NULL) Q[rear++] = T; // 根入队 while (front < rear) { // 队不空 BiTNode *p = Q[front++]; // 出队 visit(p); if (p->lchild != NULL) Q[rear++] = p->lchild; // 左孩子入队 if (p->rchild != NULL) Q[rear++] = p->rchild; // 右孩子入队 } }
为什么是队列:先出队的结点,它的孩子也要先被访问——先进先出。层序遍历的变形可以求树的宽度(结点最多的一层有几个)、每层的结点、判断是否是完全二叉树(层序遍历中一旦遇到空指针,后面就不能再出现非空结点)。
跟踪层序遍历(图 5-15 的树):队列里永远是“已经发现、还没访问”的结点,先发现的先访问。
| 步 | 出队并访问 | 入队 | 队列(队头 → 队尾) | 已输出 |
|---|---|---|---|---|
| 0 | — | A | A | |
| 1 | A | B、C | B C | A |
| 2 | B | D、E | C D E | A B |
| 3 | C | F(C 无左孩子) | D E F | A B C |
| 4 | D | — | E F | A B C D |
| 5 | E | G | F G | A B C D E |
| 6 | F | — | G | A B C D E F |
| 7 | G | — | 空,结束 | A B C D E F G |
非递归遍历:用栈模拟递归
void InOrder2(BiTree T) { BiTNode *S[MaxSize]; int top = -1; BiTNode *p = T; while (p != NULL || top != -1) { if (p != NULL) { // 一路向左,沿途入栈 S[++top] = p; p = p->lchild; } else { // 左边走到头了 p = S[top--]; // 出栈并访问 visit(p); p = p->rchild; // 转向右子树 } } }
void PreOrder2(BiTree T) { BiTNode *S[MaxSize]; int top = -1; BiTNode *p = T; while (p != NULL || top != -1) { if (p != NULL) { visit(p); // 入栈前先访问 S[++top] = p; p = p->lchild; } else { p = S[top--]; p = p->rchild; } } }
跟踪:先序和中序的非递归,其实是同一套进出栈
对比上面两段代码:它们的入栈、出栈动作完全相同(一路向左压栈,走不动了弹一个、转向右孩子),区别只是 visit 放在哪里——先序在入栈时访问(第 ① 次经过),中序在出栈时访问(第 ② 次经过)。用图 5-15 的树 A(B(D, E(G, )), C(, F)) 跟踪一遍,一张表同时得到两个序列:
| 步 | p | 动作 | 栈(底 → 顶) | 先序输出(入栈时) | 中序输出(出栈时) | 之后 p |
|---|---|---|---|---|---|---|
| 1 | A | A 入栈,向左 | A | A | B | |
| 2 | B | B 入栈,向左 | A B | A B | D | |
| 3 | D | D 入栈,向左 | A B D | A B D | ∧ | |
| 4 | ∧ | 弹出 D,转向 D 的右孩子 | A B | A B D | D | ∧ |
| 5 | ∧ | 弹出 B,转向 B 的右孩子 | A | A B D | D B | E |
| 6 | E | E 入栈,向左 | A E | A B D E | D B | G |
| 7 | G | G 入栈,向左 | A E G | A B D E G | D B | ∧ |
| 8 | ∧ | 弹出 G,转向右 | A E | A B D E G | D B G | ∧ |
| 9 | ∧ | 弹出 E,转向右 | A | A B D E G | D B G E | ∧ |
| 10 | ∧ | 弹出 A,转向右 | 空 | A B D E G | D B G E A | C |
| 11 | C | C 入栈,向左 | C | A B D E G C | D B G E A | ∧ |
| 12 | ∧ | 弹出 C,转向右 | 空 | A B D E G C | D B G E A C | F |
| 13 | F | F 入栈,向左 | F | A B D E G C F | D B G E A C | ∧ |
| 14 | ∧ | 弹出 F;p 空且栈空,结束 | 空 | A B D E G C F | D B G E A C F | ∧ |
每个结点恰好入栈一次、出栈一次:循环 2n = 14 轮,时间 O(n)。栈里存的是“还没转向右边的祖先”,本例最深 3 层,不超过树高 4;最坏情况(全是左孩子的单支树)栈深为 n,所以空间 O(h),最坏 O(n)。第 10 步栈空了但 p = C 不空,所以循环条件必须是 p != NULL || top != -1,两者都空才结束。
这一点也解释了 5.5 节的结论:先序序列固定时,中序序列就是先序依次进栈、按某种方式出栈得到的出栈序列——上表的“入栈顺序”就是先序,“出栈顺序”就是中序。
后序非递归最麻烦:结点要等右子树也访问完才能访问,所以从栈顶取结点时要判断“它的右子树是否已访问过”,用一个指针 r 记录最近访问的结点:栈顶结点的右孩子为空或等于 r 时才出栈访问,否则转向右子树。后序非递归时,访问某结点时栈中恰好是它的全部祖先(从根到它的路径),这个性质常用来求“根到某结点的路径”“两个结点的最近公共祖先”。
void PostOrder2(BiTree T) { BiTNode *S[MaxSize]; int top = -1; BiTNode *p = T, *r = NULL; // r:最近访问过的结点 while (p != NULL || top != -1) { if (p != NULL) { S[++top] = p; p = p->lchild; } // 一路向左 else { p = S[top]; // 读栈顶,先不出栈 if (p->rchild != NULL && p->rchild != r) // 右子树存在且未访问 p = p->rchild; // 转向右 else { top--; visit(p); // 出栈访问 r = p; p = NULL; // 记录,并让下一轮继续弹栈 } } } }
跟踪:后序非递归(r 记录“刚访问过谁”)
后序要等右子树访问完才能访问根。困难在于:从右子树“回到”栈顶结点时,和从左子树回来时看到的情况一样(p 都是空),必须靠 r 区分——栈顶结点的右孩子恰好是 r,说明右子树刚访问完,可以访问它了。
| 步 | p | 动作 | 栈(底 → 顶) | r | 后序输出 |
|---|---|---|---|---|---|
| 1–3 | A、B、D | 一路向左:A、B、D 依次入栈 | A B D | — | |
| 4 | ∧ | 栈顶 D 无右孩子 ⇒ 弹出并访问 D,r = D | A B | D | D |
| 5 | ∧ | 栈顶 B 的右孩子 E ≠ r ⇒ 不弹出,p 转向 E | A B | D | D |
| 6–7 | E、G | E、G 入栈 | A B E G | D | D |
| 8 | ∧ | 栈顶 G 无右孩子 ⇒ 访问 G,r = G | A B E | G | D G |
| 9 | ∧ | 栈顶 E 无右孩子 ⇒ 访问 E,r = E | A B | E | D G E |
| 10 | ∧ | 栈顶 B 的右孩子 E = r ⇒ 右子树已访问,访问 B | A | B | D G E B |
| 11 | ∧ | 栈顶 A 的右孩子 C ≠ r ⇒ p 转向 C | A | B | D G E B |
| 12 | C | C 入栈(无左孩子) | A C | B | D G E B |
| 13 | ∧ | 栈顶 C 的右孩子 F ≠ r ⇒ p 转向 F | A C | B | D G E B |
| 14 | F | F 入栈 | A C F | B | D G E B |
| 15 | ∧ | 访问 F,r = F | A C | F | D G E B F |
| 16 | ∧ | C 的右孩子 F = r ⇒ 访问 C | A | C | D G E B F C |
| 17 | ∧ | A 的右孩子 C = r ⇒ 访问 A,结束 | 空 | A | D G E B F C A |
注意第 8 步访问 G 时,栈里是 A B E——恰好是 G 的全部祖先。这就是“后序非递归时栈中是当前结点到根的路径”的直观证据,求“根到某结点的路径”“两个结点的最近公共祖先”都靠它。
复杂度:三种遍历每个结点都访问一次,时间 O(n);递归(或栈)的深度等于树高,空间 O(h),最坏(单支树)O(n)。
层序遍历的两个变形:求宽度、判断完全二叉树
层序遍历的队列天然按层排好,稍加改造就能做“按层”的统计。两段代码都在层序遍历的骨架上只改了几行(已编译,与暴力方法随机对拍 2 万棵树一致):
int Width(BiTree T) { if (T == NULL) return 0; BiTNode *Q[MaxSize]; int front = 0, rear = 0; int last = 1, width = 0, cnt = 0; Q[rear++] = T; // last:本层最后一个结点的下一个位置 while (front < rear) { BiTNode *p = Q[front++]; cnt++; if (p->lchild) Q[rear++] = p->lchild; if (p->rchild) Q[rear++] = p->rchild; if (front == last) { // 本层出队完毕 if (cnt > width) width = cnt; cnt = 0; last = rear; // 此刻队中正好是下一层 } } return width; }
bool IsComplete(BiTree T) { if (T == NULL) return true; BiTNode *Q[MaxSize]; int front = 0, rear = 0; Q[rear++] = T; while (front < rear) { BiTNode *p = Q[front++]; if (p != NULL) { // 空指针也入队 Q[rear++] = p->lchild; Q[rear++] = p->rchild; } else { // 第一个空:后面必须全空 while (front < rear) if (Q[front++] != NULL) return false; } } return true; }
为什么判完全二叉树要让空指针也入队:完全二叉树按层序编号 1 ~ n 一个不缺,等价于“层序序列中所有结点都排在所有空位之前”。把空指针也排进队列,一旦出现第一个空,后面再出现任何非空结点,就说明中间缺了编号,不是完全二叉树。图 5-15 的树:层序 A B C D E ∧ F……C 的左孩子为空,后面又出现了 F ⇒ 不是完全二叉树。
复杂度都是 O(n) 时间;队列最多同时存一层(判完全时还有空指针,最多 n + 1 个),空间 O(n)。宽度的另一种写法是“每轮先记下当前队列长度 = 本层结点数,再把这么多个结点出队”,思路相同。
遍历的应用:用递归解决二叉树问题
几乎所有二叉树算法都是“遍历 + 在访问时做点事”。写递归时只想三件事:空树返回什么?左右子树的结果怎么得到(递归调用)?怎么用左右子树的结果和根算出整棵树的结果?
求高度(后序)
int Height(BiTree T) { if (T == NULL) return 0; int hl = Height(T->lchild); int hr = Height(T->rchild); return (hl > hr ? hl : hr) + 1; }
数叶子
int Leaves(BiTree T) { if (T == NULL) return 0; if (T->lchild == NULL && T->rchild == NULL) return 1; return Leaves(T->lchild) + Leaves(T->rchild); }
交换所有结点的左右子树
void Swap(BiTree T) { if (T == NULL) return; BiTNode *t = T->lchild; T->lchild = T->rchild; T->rchild = t; Swap(T->lchild); Swap(T->rchild); }
带“深度”参数的先序(WPL 的写法)
int WPL(BiTree T, int depth) { // 根的 depth 传 0 if (T == NULL) return 0; if (T->lchild == NULL && T->rchild == NULL) return T->weight * depth; // 叶子:权 × 路径长度 return WPL(T->lchild, depth + 1) + WPL(T->rchild, depth + 1); }
2014-41 原题,第 9 篇 t3 详讲。
从遍历序列看树的结构(选择题常考的结论)
| 条件 | 结论 |
|---|---|
| 先序序列 = 中序序列 | 每个结点都没有左孩子(所有非叶结点只有右子树,2017-04) |
| 中序序列 = 后序序列 | 每个结点都没有右孩子 |
| 先序序列 = 后序序列 | 只有一个结点(或空树) |
| 先序与后序正好相反 | 每层只有一个结点(高度 = 结点数),即每个结点至多一个孩子(2011-05) |
| 先序与中序相反 | 每个结点都没有右孩子 |
| 中序与后序相反 | 每个结点都没有左孩子 |
| 中序遍历中,v 有两个孩子 | v 的中序前驱是左子树中最右的结点(它没有右孩子);v 的中序后继是右子树中最左的结点(它没有左孩子)(2024-03) |
| 在先序序列中,x 在 y 之前;在后序序列中 x 在 y 之后 | x 是 y 的祖先 |
跟我做一遍:表达式树与三种遍历
画出表达式 (a + b) * c − d / e 的表达式树,写出它的先序、中序、后序序列,并说明各对应什么表达式。
- 找最后算的运算符当根(依据:表达式树中越靠下越先算,根是最后一步):按优先级,最后做的是中间那个“−”。左边 (a + b) * c 是左子树,右边 d / e 是右子树。
- 递归:左子树最后算的是 *,它的左子树是 a + b、右子树是 c;右子树的根是 /,孩子 d、e。
- 写序列:先序 − * + a b c / d e(前缀表达式 / 波兰式);后序 a b + c * d e / −(后缀表达式,3.5 节用栈求值的就是它);中序 a + b * c − d / e——丢掉了括号,和原式意思不同了。
- 补括号:中序遍历时,遇到“非根的分支结点”就在它的子表达式两边加括号,得到 ((a+b)*c)-(d/e),这正是 2017-41 的做法(第 9 篇 t3 有完整代码)。
验算:令 a = 2, b = 3, c = 4, d = 9, e = 3,原式 = 5 × 4 − 3 = 17;按后缀式 2 3 + 4 * 9 3 / − 用栈求值也得 17。
- A. LRN
- B. NRL
- C. RLN
- D. RNL
展开解析
考点5.4 遍历方式
D序列第一个是 3(右子树),第二个是 1(根),后面是左子树的结点。所以顺序是“右子树 → 根 → 左子树”,即 RNL。再检查左子树:以 2 为根,RNL 应为 [5 子树] 2 4 = 7 5 6 2 4 ✓(5 子树 RNL:7 5 6)。
- A 错LRN 的第一个结点应是左子树中的 4 或 6。
- B 错NRL 应以根 1 开头。
- C 错RLN 应以根 1 结尾。
- D 对RNL,相当于“镜像的中序”,得到的正好是中序序列的逆序。
看根在序列中的位置定 N 的次序,再看第一个元素属于哪棵子树定 L、R 的次序。
I. q 是 p 的双亲 II. q 是 p 的右孩子 III. q 是 p 的右兄弟 IV. q 是 p 的双亲的双亲
- A. 仅 I
- B. 仅 III
- C. 仅 II、III
- D. 仅 II、IV
展开解析
Bp 紧挨在 q 前面,即 q 是 p 的中序后继。p 的中序后继只有两种情况:p 有右子树 ⇒ 后继是右子树中最左下的结点;p 没有右子树 ⇒ 后继是“第一个把 p 放在左子树里的祖先”。
- I 可能p 是 q 的左孩子且 p 没有右子树:中序 … p q …。
- II 可能q 是 p 的右孩子且 q 没有左子树:p 的右子树最左下的就是 q。
- III 不可能p、q 是兄弟(p 左、q 右),中序为 [p 的子树] 双亲 [q 的子树],双亲一定夹在两者之间,p 和 q 不可能相邻。
- IV 可能p 是 q 的左孩子的右孩子,且 p 没有右子树:中序中 p 之后就回到 q。
只有 III 不可能,选 B。
中序后继:有右子树取右子树最左下;无右子树往上找,第一个“从左边上来”的祖先。
真题怎么考
| 题型 | 真题 | 秒杀 / 判别方法 | 什么时候失效 |
|---|---|---|---|
| 判断遍历方式 | 2009-03 | 看根在序列中的位置定 N,看第一个元素属于哪棵子树定 L、R 先后 | 根只有一棵子树时,L、R 的先后看不出来,要看更深一层 |
| 由序列推形态 | 2011-05、2017-04 | 先序 = 中序 ⇔ 无左孩子;先序与后序相反 ⇔ 每个结点至多一个孩子 | 这些是“对每个结点都成立”的结论,部分结点满足不能套 |
| 树形已知,填值 | 2017-05、2023-05 | 按给定遍历的顺序“空走”一遍树形,依次填入 | — |
| 序列中相邻结点的关系 | 2022-03、2024-03 | 画出 L、N、R 三块:中序前驱 = 左子树最右,后继 = 右子树最左 | 结点没有左 / 右子树时,前驱 / 后继在祖先里找 |
| 先序 + 后序定孩子 | 2012-03 | 先序中根后第一个 = 后序中根前最后一个 ⇒ 根只有一个孩子 | 只能定“孩子是谁”,定不了左右 |
| 大题:遍历 + 访问时做事 | 2017-41、2014-41 | 表达式树转中缀(中序 + 括号)、求 WPL(带深度参数的先序) | — |
- 非递归中序 / 先序的循环条件是
p != NULL || 栈不空,只写其中一个会提前结束(看上表第 10 步)。 - 后序非递归从栈顶取结点时先不弹出,确认右子树已访问(右孩子为空或等于 r)才弹出;访问后要把 p 置空,否则会把刚访问的结点又压回去。
- 层序遍历用队列,不是栈;“先序 = 层序”并不意味着树是单支的(只有根和它的左右孩子时先序也等于层序)。
- 遍历的时间复杂度都是 O(n);递归和非递归的栈空间都是 O(h),最坏 O(n),不要说成 O(log n)。
- 二叉树 1(2(4, 5(7, )), 3(, 6)) 的先序、中序、后序、层序序列各是什么?
答案
先序 1 2 4 5 7 3 6;中序 4 2 7 5 1 3 6;后序 4 7 5 2 6 3 1;层序 1 2 3 4 5 6 7。可以用本节的小工具输入“1 2 3 4 5 # 6 # # 7”核对。 - 用非递归后序遍历上题的树,访问结点 7 时栈中(底 → 顶)有哪些结点?
答案
1 2 5——正是 7 的全部祖先(根到 7 的路径去掉 7 本身)。 - 对上题的树用 IsComplete 判断,队列里出现第一个空指针时,后面还有非空结点吗?结论是什么?
答案
层序入队:1;2、3;4、5、∧(3 的左)……第一个空是 3 的左孩子,之后还会出现 6 ⇒ 不是完全二叉树。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 1
- B. 2
- C. 4
- D. 5
展开解析
C前序 NLR 与后序 LRN 正好相反,说明每个结点只有一个孩子(左右子树不能同时非空),树是一条长度为 3 的链。1 → 2 → 3 两条边,每条边都可以是左孩子或右孩子,共 2 × 2 = 4 棵。它们的中序分别是 3 2 1、2 3 1、1 3 2、1 2 3。
变化点:原题 4 个结点的链有 23 = 8 棵,中序不能是 3, 2, 4, 1。结论:先序和后序相反 ⇔ 每个结点至多一个孩子(高度 = 结点数);先序和后序相同 ⇔ 只有一个结点(或空树)。
- A. v 的左孩子
- B. v 右子树中最左下的结点
- C. v 左子树中最右下的结点
- D. v 的右孩子
展开解析
D后序是 LRN:v 之前紧挨着的是 v 的右子树后序序列的最后一个——右子树的根,也就是 v 的右孩子。
变化点:原题是中序:v 的前驱是左子树的最右下结点(它没有右孩子),后继是右子树的最左下结点(它没有左孩子)。先序中 v 的后继是 v 的左孩子。三种遍历的“邻居”各不相同,按遍历顺序画出 L、N、R 三块就能看出来。
I. q 是 p 的双亲 II. q 是 p 的右孩子 III. q 是 p 的右兄弟 IV. q 是 p 的左孩子
- A. 仅 I
- B. 仅 I、III
- C. 仅 II
- D. 仅 II、IV
展开解析
A先序中双亲一定在孩子前面,I 不可能。II:p 没有左孩子而有右孩子时,p 后面紧跟右孩子。III:p 是叶子且是左孩子,p 之后就是它的右兄弟。IV:p 有左孩子时,p 后面紧跟左孩子。
变化点:原题是中序(不可能的是 III:p 与其右兄弟之间一定隔着双亲)。换一种遍历,就换一种“谁挨着谁”。
- A. 只有一个结点
- B. 所有非叶结点只有左子树
- C. 所有非叶结点只有右子树
- D. 所有结点的度均为 1
展开解析
A先序第一个是根,后序最后一个是根。两个序列相同,则根既是第一个又是最后一个,只能有一个结点。
变化点:原题“先序 = 中序”要求所有非叶结点只有右子树(NLR = LNR ⇔ L 为空)。同理“中序 = 后序”要求只有左子树(LNR = LRN ⇔ R 为空)。
5.5由遍历序列构造二叉树
为什么要“由序列还原树”
一棵树画在纸上很直观,可要把它存进文件、通过网络发给别人,只能写成一行一行的序列。对方收到序列后能不能把树原样画回来?这就是本节的问题。答案的关键只有一句:中序负责分左右,另一个序列负责找根。
先序(或后序、层序)像“点名顺序”:告诉你谁是这一组的组长(根);中序像“合影里从左到右的站位”:组长站在中间,他左边的人是左组、右边的人是右组。知道组长、再看站位,就能把人分成两组;每组再找组长、再分……直到每组只剩一个人。
哪里不像:如果只给“点名顺序”(先序 + 后序),没有站位,就不知道谁站左边谁站右边——这正是先序 + 后序不能唯一确定二叉树的原因。
- 先序 + 中序、后序 + 中序、层序 + 中序:可以唯一确定。
- 先序 + 后序、先序 + 层序、后序 + 层序:不能唯一确定(它们都只能确定根,分不出左右子树的范围)。
- 中序是关键:它能把左、右子树分开。
先序 + 中序:递归切分
- 先序的第一个元素是根。
- 在中序里找到根:它左边的是左子树的中序序列,右边的是右子树的中序序列,由此知道左子树有几个结点(设 k 个)。
- 先序中根后面紧跟的 k 个元素是左子树的先序序列,再后面的是右子树的先序序列。
- 对左、右子树递归做同样的事。(后序 + 中序:根是后序的最后一个;层序 + 中序:层序中最先出现的属于该子树的元素是子树的根。)
展开解析
- 根 A;中序 [B C] A [E D G H F I]:左子树 2 个结点,右子树 6 个。
- 左子树:先序 B C,中序 B C ⇒ 根 B,中序中 B 左边没有元素 ⇒ C 是 B 的右孩子。
- 右子树:先序 D E F G H I,中序 E D G H F I ⇒ 根 D,左子树 [E],右子树中序 [G H F I]、先序 F G H I ⇒ 根 F,左 [G H](先序 G H ⇒ G 为根,H 是 G 的右孩子),右 [I]。
写成代码:用下标区间递归
手算时我们“切出”子序列;程序里不必真的复制数组,只要记住每棵子树的先序在 A[l1..h1]、中序在 B[l2..h2]。找到根在中序中的位置 i 后,左子树结点数 llen = i − l2、右子树结点数 rlen = h2 − i,区间就全定了。
BiTree PreInCreate(ElemType A[], ElemType B[], int l1, int h1, int l2, int h2) { // A[l1..h1] 是先序序列,B[l2..h2] 是中序序列(同一棵子树) if (l1 > h1) return NULL; // 空子树 BiTNode *root = (BiTNode *)malloc(sizeof(BiTNode)); root->data = A[l1]; // 先序第一个是根 int i = l2; while (B[i] != root->data) i++; // 在中序中找根的位置 int llen = i - l2, rlen = h2 - i; // 左、右子树的结点数 root->lchild = PreInCreate(A, B, l1 + 1, l1 + llen, l2, l2 + llen - 1); root->rchild = PreInCreate(A, B, h1 - rlen + 1, h1, i + 1, h2); return root; } // 调用:PreInCreate(A, B, 0, n - 1, 0, n - 1);
用上面的例题(先序 A B C D E F G H I,中序 B C A E D G H F I,下标从 0)跟踪非空的调用:
| 先序区间 | 中序区间 | 根 | 根在中序的 i | llen | rlen | 接着递归 |
|---|---|---|---|---|---|---|
| [0, 8] | [0, 8] | A | 2 | 2 | 6 | 左:先序 [1, 2]、中序 [0, 1];右:先序 [3, 8]、中序 [3, 8] |
| [1, 2] | [0, 1] | B | 0 | 0 | 1 | 左空;右:先序 [2, 2]、中序 [1, 1] ⇒ C |
| [3, 8] | [3, 8] | D | 4 | 1 | 4 | 左:[4, 4] ⇒ E;右:先序 [5, 8]、中序 [5, 8] |
| [5, 8] | [5, 8] | F | 7 | 2 | 1 | 左:先序 [6, 7]、中序 [5, 6];右:[8, 8] ⇒ I |
| [6, 7] | [5, 6] | G | 5 | 0 | 1 | 左空;右:[7, 7]、[6, 6] ⇒ H |
复杂度:每个结点建一次,但每次要在中序区间里线性查找根。树很平衡时总代价 O(n log n);退化成单支树时 1 + 2 + … + n = O(n²)。拓展 先用一个数组记下每个值在中序中的下标,查找变成 O(1),总时间 O(n)。代码已抽出编译,随机生成 5 万棵树“遍历 → 重建 → 比较”全部一致。
跟我做一遍:后序 + 中序
画出这棵二叉树,并写出先序序列。
- 找根(依据:后序最后一个是根):A。在中序里找 A:[D H B E] A [F C I G],左子树 4 个、右子树 4 个。
- 切后序:后序 = 左子树后序 + 右子树后序 + 根,所以前 4 个 H D E B 是左子树,接着 4 个 F I G C 是右子树。
- 左子树:后序 H D E B 的根是 B;中序 [D H] B [E] ⇒ 左 {D, H}、右 {E}。{D, H} 的后序 H D ⇒ 根 D,中序 D H 中 H 在 D 右边 ⇒ H 是 D 的右孩子。
- 右子树:后序 F I G C 的根是 C;中序 [F] C [I G] ⇒ F 是左孩子;{I, G} 的后序 I G ⇒ 根 G,中序 I G 中 I 在 G 左边 ⇒ I 是 G 的左孩子。
- 验算:对画出的树重新写后序和中序,与题目一致 ✓。先序 A B D H E C F G I。
第 1 步:定根 A
第 2 步:定 B、C
第 3 步:定 D、G,完成
跟我做一遍:层序 + 中序
层序 + 中序和前两种不同:层序里左右子树的结点是交错的(第 2 层左边一个、右边一个……),没法像先序那样“切一段”。办法是:对每一棵子树(由中序的一段决定),在层序中最先出现的那个结点就是它的根。
| 当前子树(中序的一段) | 这些结点在层序中谁最先出现 | 根 | 左子树 | 右子树 |
|---|---|---|---|---|
| 2 6 4 7 1 5 3 | 1(层序第 1 个) | 1 | 2 6 4 7 | 5 3 |
| 2 6 4 7 | 2(层序第 2 个) | 2 | 空 | 6 4 7 |
| 5 3 | 3(层序第 3 个) | 3 | 5 | 空 |
| 6 4 7 | 4(层序第 4 个) | 4 | 6 | 7 |
验算:层序 1;2 3;4 5;6 7 ✓,中序 2 6 4 7 1 5 3 ✓。2026-03 考的就是这种组合。
先序 + 后序为什么不唯一,什么时候唯一
先序 A B、后序 B A:B 可以是 A 的左孩子,也可以是右孩子。只要某个结点只有一个孩子,就分不出它是左还是右。但先序和后序能确定祖先关系和“每个结点的孩子是谁”(2012-03 就利用了这一点)。
那先序 + 后序到底能确定多少东西?看根的下一层:先序中根后面第一个是“左子树的根”(若左子树空,就是右子树的根),后序中根前面最后一个是“右子树的根”(若右子树空,就是左子树的根)。
- 这两个结点不同 ⇒ 根有两个孩子,左孩子、右孩子都确定了,而且能按它们把先序、后序各切成左右两段,继续往下推;
- 这两个结点相同 ⇒ 根只有一个孩子,孩子是谁确定,但是左是右确定不了。
所以:当且仅当每个分支结点都有两个孩子时,先序 + 后序能唯一确定二叉树;一般地,与给定先序、后序相容的二叉树有 2k 棵,k 是只有一个孩子的结点个数(程序对 7 个结点以内的所有二叉树逐一验证)。
- 根 A。先序中 A 后第一个是 B,后序中 A 前最后一个是 C,不同 ⇒ A 有两个孩子:左 B、右 C。按 B 在后序中的位置切开:左子树后序 D E B、先序 B D E;右子树后序 F C、先序 C F。
- 左子树:先序 B 后是 D,后序 B 前是 E,不同 ⇒ B 的左孩子 D、右孩子 E。
- 右子树:先序 C 后是 F,后序 C 前也是 F ⇒ C 只有一个孩子 F,左右不定。
- 结论:只有 C 是单孩子结点,k = 1,共 2¹ = 2 棵:
先序序列固定为 1, 2, …, n 时,每棵二叉树对应唯一的中序序列,而这个中序序列恰好是“1, 2, …, n 依次进栈得到的某个出栈序列”(非递归中序遍历就是这样进出栈的)。所以个数 = 卡特兰数 Cₙ = C(2n, n)/(n + 1)。n 个结点的不同形态的二叉树个数也是 Cₙ。n = 4 时是 14(2015-02)。
真题怎么考
- 直接还原再求别的序列(2026-03 层序 + 中序求后序;2020-04 森林遍历 → 二叉树先序 + 中序):还原后一定要验算,再写所求序列。
- 还原后数结构(2021-04:先序 + 中序还原后数根的右链,得森林的棵数)。
- 先序固定的二叉树个数(2015-02):中序 = 先序依次进栈的出栈序列 ⇒ 卡特兰数 C₄ = 14。
- 先序 + 后序(2012-03):问“根的孩子是谁”——能确定;问“左还是右”——不能确定。
- 后序 + 中序切后序时,右子树的后序在根的前面(后序最后一个是根),别把根算进右子树。
- 层序 + 中序不能“切一段层序”,必须对每棵子树重新在整个层序里找最先出现的结点。
- “先序 + 后序不能唯一确定”说的是一般情况;若题目说明每个分支结点度为 2(如哈夫曼树、满二叉树),它是能唯一确定的。
- 后序 4 2 6 7 5 3 1、中序 2 4 1 6 5 7 3,求先序。
答案
根 1;中序 [2 4] 1 [6 5 7 3]。左:后序 4 2 ⇒ 根 2,4 在中序中位于 2 右边 ⇒ 右孩子。右:后序 6 7 5 3 ⇒ 根 3,中序 [6 5 7] 3 ⇒ 只有左子树;{6, 5, 7} 后序 6 7 5 ⇒ 根 5,左 6、右 7。树 1(2(, 4), 3(5(6, 7), )),先序 1 2 4 3 5 6 7。 - 先序 a b c d、后序 b d c a,a 的孩子有哪些?满足条件的二叉树有几棵?
答案
先序 a 后是 b、后序 a 前是 c,不同 ⇒ a 的左孩子 b、右孩子 c。右子树先序 c d、后序 d c ⇒ c 只有一个孩子 d,左右不定。共 2 棵。 - 层序 A B C D E、中序 D B A E C,画出树并写先序。
答案
根 A;中序 [D B] A [E C]。{D, B} 中层序先出现 B ⇒ 根 B,D 在 B 左边 ⇒ 左孩子;{E, C} 中先出现 C ⇒ 根 C,E 在 C 左边 ⇒ 左孩子。树 A(B(D, ), C(E, )),先序 A B D C E。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 14
- B. 32
- C. 42
- D. 120
展开解析
C先序序列固定时,不同二叉树的个数等于中序序列的个数;而先序序列看成进栈序列、中序序列看成出栈序列,个数是卡特兰数 C5 = C(10, 5)/6 = 252/6 = 42。也等于 5 个结点的二叉树形态数。
变化点:原题 4 个结点,C4 = 14。卡特兰数前几项 1, 1, 2, 5, 14, 42, 132 要背熟:n 个结点的二叉树形态数、n 个元素的出栈序列数都是它。
- A. a, b, f, c, d, g, e
- B. a, b, f, c, d, e, g
- C. a, b, c, d, e, f, g
- D. a, f, b, g, d, c, e
展开解析
A后序最后一个 a 是根;中序中 a 左边 c b e d 是左子树,右边 f g 是右子树。左子树后序 c e d b,根 b,中序 c | b | e d:左孩子 c,右子树 {e, d} 后序 e d,根 d,e 是 d 的左孩子。右子树后序 g f,根 f,g 在中序中位于 f 右边,是 f 的右孩子。
按层:a;b, f;c, d, g;e。
变化点:原题给的是层序 + 中序。无论哪种组合,中序都用来划分左右子树,另一个序列用来找根:先序找最前面的、后序找最后面的、层序找最先出现的。本书 构造二叉树小工具可核对。
5.6线索二叉树
为什么要线索化:让空指针“指路”
遍历二叉树得到一个线性序列,每个结点在序列里都有前驱和后继。但二叉链表只记录了孩子,想找某结点在中序序列里的前驱,只能从头遍历一遍。而 n 个结点的二叉链表恰好有 n + 1 个空指针闲着——可以让它们指向前驱和后继,这些指针叫线索。
二叉链表像一排停车位,每个结点有“左车位”“右车位”,可总有 n + 1 个车位空着。线索化就是在空车位上立块牌子:左边的空位写“上一站是谁”(前驱),右边的空位写“下一站是谁”(后继)。以后想知道“下一站”,看到牌子就直接走过去,不用从头再遍历。
哪里不像:只有空车位才能立牌子;车位上停着车(有孩子)时就没有牌子,这时“下一站”要自己推算(例如中序后继是右子树里最左下的结点)。所以线索不是让每个结点都 O(1) 找到后继,而是“空指针处免费给出答案”。ltag / rtag 就是告诉你“这个车位上是车还是牌子”。
typedef struct ThreadNode { ElemType data; struct ThreadNode *lchild, *rchild; int ltag, rtag; // 0:指向孩子;1:是线索 } ThreadNode, *ThreadTree;
| 标志 | = 0 | = 1 |
|---|---|---|
| ltag | lchild 指向左孩子 | lchild 指向前驱 |
| rtag | rchild 指向右孩子 | rchild 指向后继 |
规则:左指针空就让它指向前驱,右指针空就让它指向后继,前驱、后继按哪种遍历序列算,就是哪种线索树(先序 / 中序 / 后序线索二叉树)。序列里第一个结点没有前驱,最后一个没有后继,对应的线索为 NULL(或指向头结点)。
中序线索化的代码
ThreadNode *pre = NULL; // 全局变量:刚刚访问过的结点 void InThread(ThreadTree p) { if (p != NULL) { InThread(p->lchild); // 线索化左子树 if (p->lchild == NULL) { // 左指针空:指向前驱 p->lchild = pre; p->ltag = 1; } if (pre != NULL && pre->rchild == NULL) { // 前驱的右指针空:指向我(它的后继) pre->rchild = p; pre->rtag = 1; } pre = p; InThread(p->rchild); // 线索化右子树 } } // 调用:pre = NULL; InThread(T); 结束后还要处理最后一个结点:pre->rchild = NULL; pre->rtag = 1;
就是在中序遍历的 visit 处,把“当前结点”和“上一个访问的结点 pre”互相连起来。
跟踪:中序线索化(图 5-18 的树)
InThread 就是一次中序遍历,在“访问”的位置做两件事:我的左指针空 ⇒ 指向 pre;pre 的右指针空 ⇒ 指向我。下表从 pre = NULL 开始:
| 访问到 | 此时 pre | p 的左指针空? | pre 的右指针空? | 之后 pre |
|---|---|---|---|---|
| d | NULL | 空 ⇒ d.lchild = NULL,ltag = 1 | pre 为 NULL,跳过 | d |
| e | d | 空 ⇒ e.lchild = d,ltag = 1 | d 的右指针指向孩子 e,不改 | e |
| b | e | 有左孩子 d,不改 | e 的右指针空 ⇒ e.rchild = b,rtag = 1 | b |
| x | b | 空 ⇒ x.lchild = b | b 的右指针指向孩子 x | x |
| a | x | 有左孩子 b | x 的右指针空 ⇒ x.rchild = a | a |
| c | a | 空 ⇒ c.lchild = a | a 的右指针指向孩子 c | c |
| 结束后 | pre = c 是最后一个结点:c.rchild = NULL,rtag = 1(这一步在函数外补,最容易漏) | |||
每个结点“自己的左线索”在访问它时设好,“自己的右线索”要等下一个结点被访问时才设——因为那时才知道后继是谁。所以最后一个结点的右线索只能在遍历结束后单独处理。
利用线索遍历:不用栈、不用递归
ThreadNode *Firstnode(ThreadNode *p) { // 以 p 为根的子树中,中序第一个结点
while (p->ltag == 0) p = p->lchild; // 沿左孩子一直走到底
return p;
}
ThreadNode *Nextnode(ThreadNode *p) { // p 的中序后继
if (p->rtag == 0) return Firstnode(p->rchild); // 有右子树:右子树中最左下的结点
else return p->rchild; // 右指针是线索:直接就是后继
}
void Inorder(ThreadNode *T) { // 中序线索树的中序遍历
for (ThreadNode *p = Firstnode(T); p != NULL; p = Nextnode(p))
visit(p);
}
// 对称地:Lastnode 沿 rchild 一直走到 rtag == 1;
// Prenode:ltag == 0 时取左子树的 Lastnode,否则直接取 lchild。二者配合可以逆序遍历。
| 当前 p | rtag | Nextnode 怎么算 | 后继 |
|---|---|---|---|
| d(起点 = Firstnode(a)) | 0 | 有右孩子 e ⇒ Firstnode(e) = e | e |
| e | 1 | 右线索直接给出 | b |
| b | 0 | Firstnode(x) = x(x 的 ltag = 1) | x |
| x | 1 | 右线索 | a |
| a | 0 | Firstnode(c) = c | c |
| c | 1 | 右线索为 NULL ⇒ 循环结束 | NULL |
复杂度:整个遍历中每条边最多走两次,时间 O(n);只用了几个指针变量,额外空间 O(1)——这是线索树相对“递归 / 栈遍历要 O(h) 空间”的优势。但注意单次 Nextnode 在 rtag = 0 时要一路向左走,最坏 O(h),不是 O(1)。
三种线索树放在一起比:谁能找前驱、谁能找后继
同一棵树(图 5-18)按三种遍历线索化,线索指向完全不同:
先序 a b d e x c
中序 d e b x a c
后序 e d x b c a
找前驱、后继的规则表
| 线索树 | 找后继 | 找前驱 |
|---|---|---|
| 中序 | rtag = 1 直接得;否则为右子树中最左下的结点 | ltag = 1 直接得;否则为左子树中最右下的结点 |
| 先序 | 有左孩子则是左孩子;否则是右孩子(或右线索) | ltag = 0 时找不到:前驱可能是双亲或双亲左子树中的结点,需要三叉链表 |
| 后序 | rtag = 0 时找不到:后继可能是双亲或双亲右子树中的结点,需要三叉链表 | 有右孩子则是右孩子;否则是左孩子(或左线索) |
为什么“先序找前驱”“后序找后继”会卡住——用上图各举一个结点:
一句话规律:先序“根左右”,前驱可能在上面(双亲);后序“左右根”,后继可能在上面(双亲);中序的前驱、后继要么由线索直接给出,要么在自己的子树里,所以中序线索树最好用。
| 线索树 | 指向 NULL 的线索个数 | 理由 |
|---|---|---|
| 中序 | 恰好 2 个 | 第一个结点(最左下)没有左孩子、最后一个结点(最右下)没有右孩子,二者分别没有前驱、后继 |
| 先序 | 1 个;根没有左孩子时 2 个 | 最后一个结点一定是叶子(右指针空且无后继);第一个结点是根,只有根缺左孩子时它的左线索才是 NULL |
| 后序 | 1 个;根没有右孩子时 2 个 | 第一个结点一定是叶子(左指针空且无前驱);最后一个是根,只有根缺右孩子时右线索才是 NULL |
有的资料直接写“先序、后序线索树各有 1 个空链域”,那只是根有两个孩子时的情况;程序对 3 万棵随机树逐一核对过上表。
结论:中序线索树找前驱、后继都方便,可以不用栈完成遍历;先序线索树找前驱、后序线索树找后继仍需要知道双亲。所以说“后序线索树的遍历仍需要栈的支持”。
先序、后序线索化的代码,以及“转圈”陷阱
先序线索化只需把 visit 那几行挪到递归之前。但有一个坑:访问 p 时如果 p 的左指针为空,它立刻被改成线索(指向前驱);接下来若不检查就“递归遍历左子树”,会顺着这条线索回到前驱,前驱又走回 p……无限转圈。
void PreThread(ThreadTree p) { if (p != NULL) { if (p->lchild == NULL) { p->lchild = pre; p->ltag = 1; } if (pre != NULL && pre->rchild == NULL) { pre->rchild = p; pre->rtag = 1; } pre = p; if (p->ltag == 0) PreThread(p->lchild); // ★ 左指针是线索就不能往下走 if (p->rtag == 0) PreThread(p->rchild); // ★ 右指针同理(见下) } } // 调用:pre = NULL; PreThread(T); 结束后 pre->rtag = 1(最后一个结点的右线索为 NULL)
为什么右边也要检查:设 p 只有左孩子 q。访问 q 时,q 的前驱是 p,而 p 的右指针是空的,于是 p.rchild 被改成线索指向 q。等左子树处理完回到 p,如果不看 rtag 直接 PreThread(p->rchild),就会把 q 的子树再线索化一遍,把已经设好的线索改乱甚至死循环。程序随机测试:只检查 ltag 的写法在 3 万棵随机树中有 25783 棵出错,出错的树恰好都含有“只有左孩子”的结点;两边都检查的写法全部正确。
后序线索化没有这个问题:visit 在两个递归调用之后,左右子树都处理完才改指针,不会再顺着线索往下走:
void PostThread(ThreadTree p) { if (p != NULL) { PostThread(p->lchild); PostThread(p->rchild); if (p->lchild == NULL) { p->lchild = pre; p->ltag = 1; } if (pre != NULL && pre->rchild == NULL) { pre->rchild = p; pre->rtag = 1; } pre = p; } }
带头结点的线索二叉树:像双向循环链表一样首尾相接
中序线索树的首、尾两个线索是 NULL,遍历时要单独判断。仿照带头结点的双向循环链表,可以加一个头结点把首尾接起来:
好处:从头结点出发,沿后继可以从第一个结点正向遍历到最后,沿前驱可以从最后一个结点逆向遍历到第一个;两个方向的结束条件都统一成“p 回到头结点”,不用再特判 NULL。
叶结点 X 的右线索指向它的后序后继。若 X 是右孩子(有左兄弟 Y),后序中 X 之后紧接着就是双亲;若 X 是左孩子而双亲还有右子树,后继是右子树中后序的第一个结点(最左下的叶子)。
展开解析
D树形:a 的左孩子 b、右孩子 c;b 只有右孩子 d。后序序列 d, b, c, a。空指针有:d 的左、右,b 的左,c 的左、右,共 5 个。
- d:左线索 → 前驱(d 是第一个,无前驱)= NULL;右线索 → 后继 b。
- b:左线索 → 前驱 d(b 的右指针指向孩子 d,不是线索)。
- c:左线索 → 前驱 b;右线索 → 后继 a。
- A 错d 的右线索指向 c、c 的线索指向 d,是其他遍历顺序下的结果。
- B 错d 的右线索指向 a、c 的右线索为 NULL:这是中序(b d a c)线索树的样子。
- C 错d 的线索指向 a 与 c,不对应后序。
- D 对d:NULL、b;b 的左线索:d;c:b、a。
做线索题:先写出对应的遍历序列,再给每个空指针找前驱(左)或后继(右)。
真题怎么考
- 给树,问某结点的线索指向谁(2014-04 中序、2013-05 后序):先写出对应的遍历序列,左线索 = 序列中的前一个,右线索 = 后一个。
- 给几棵画好线索的树,选符合某种线索定义的(2010-03 后序):逐个空指针检查,先看序列首尾的 NULL 线索最快排除。
- 概念:哪种线索树找不到前驱 / 后继;“后序线索树遍历仍需栈”;线索化后空链域个数。
- 线索只占用原来为空的指针;有孩子的指针不改。叶子两边都是线索,度为 1 的结点只有一边是线索。
- 中序线索树找后继不一定 O(1):rtag = 0 时要在右子树里一路向左走。“线索树的好处”是遍历不需要栈,不是每步都 O(1)。
- 中序线索化结束后要补“最后一个结点的右线索”;先序线索化递归左右子树前都要检查 tag。
- 线索是在二叉链表上加的,所以“线索二叉树”属于存储结构(物理结构)层面的概念;同一棵逻辑上的树可以有三种不同的线索化结果。
- 二叉树 1(2(4, 5), 3(, 6)) 中序线索化后,结点 5 的左、右线索各指向谁?结点 3 的左线索呢?
答案
中序 4 2 5 1 3 6。5 的左线索 → 2,右线索 → 1;3 没有左孩子,左线索 → 中序前驱 1(3 有右孩子 6,右指针不是线索)。 - 同一棵树先序线索化,结点 5 的左、右线索各指向谁?整棵树有几个指向 NULL 的线索?
答案
先序 1 2 4 5 3 6。5 的左线索 → 4,右线索 → 3。NULL 线索只有 6 的右线索 1 个(根 1 有左孩子,所以第一个结点的左指针不是线索)。 - 判断:“在中序线索二叉树中,求任一结点的中序后继都只需 O(1) 时间。”
答案
错。rtag = 1 时 O(1);rtag = 0 时要找右子树中最左下的结点,最坏 O(h)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. P
- B. P 的双亲
- C. 以 Y 为根的子树中最左下的结点
- D. Y
展开解析
D叶结点的右线索指向它的先序后继。先序 NLR:P 的左子树(就是叶子 X)访问完,紧接着访问 P 的右子树的根 Y。
变化点:原题是后序线索树:叶子 X 有左兄弟 Y 时,X 之后访问的是双亲(后序 LRN,X 是右子树的最后一个),右线索指向父结点。同一个位置关系,换一种遍历,线索就指向不同结点。
- A. p 的右子树中最左下的结点
- B. p 的右孩子
- C. p 的右子树中最右下的结点
- D. p 的双亲
展开解析
A中序 LNR:p 之后访问右子树,右子树中第一个被访问的是它的最左下结点(从右孩子出发一直沿左指针走到 ltag = 1 的结点)。只有右孩子没有左孩子时,最左下结点就是右孩子本身,所以 B 不总是对。
变化点:原题问的是 rtag = 1 时(右指针就是线索)的指向。rtag = 0 时要“找”:这正是中序线索树求后继的算法。
5.7树的存储结构
难点:孩子个数不固定
二叉树每个结点最多两个孩子,固定两个指针就够了。一般的树呢?如果按“树的度 m”给每个结点都留 m 个孩子指针(叫多重链表),浪费惊人:n 个结点共 n·m 个指针,真正用到的只有 n − 1 个(边数),空着 n(m − 1) + 1 个。度为 3、10 个结点的树就要空 21 个指针,比用到的 9 个还多。所以树要换思路来存,常用三种:
① 双亲表示法像户口本:每个人只登记“我父亲是几号”——查父亲一眼就知道,查“谁是我的孩子”得翻遍整本。② 孩子表示法像家长手里的“孩子名单”:每个人身上挂一张自己孩子的名单——查孩子方便,查父亲得翻所有人的名单。③ 孩子兄弟表示法像排队:每个人只记两件事,“我的大孩子是谁”和“我后面的弟弟是谁”——两个指针就够了,这正是一棵二叉树。
哪里不像:现实中可以同时登记父亲和孩子;数据结构里也可以组合(双亲 + 孩子链表,见下面的“拓展”),代价是多占空间。
下面三种方法都以同一棵树为例。
① 双亲表示法:一个数组,每格记双亲下标
#define MAX_TREE_SIZE 100 typedef struct { // 结点 ElemType data; int parent; // 双亲在数组中的下标,根为 -1 } PTNode; typedef struct { // 整棵树 PTNode nodes[MAX_TREE_SIZE]; int n; // 结点数 } PTree;
双亲表示法也是 5.10 节并查集的存储方式——并查集只需要“往上找根”,正好发挥它找双亲快的长处。
② 孩子表示法:每个结点挂一条孩子链表
typedef struct CTNode { // 孩子链表的结点 int child; // 孩子在表头数组中的下标 struct CTNode *next; // 下一个孩子 } CTNode; typedef struct { // 表头数组的元素 ElemType data; CTNode *firstchild; // 孩子链表的头指针 } CTBox; typedef struct { CTBox nodes[MAX_TREE_SIZE]; int n, r; // 结点数、根的下标 } CTree;
眼熟吗?这就是第 6 篇图的邻接表:把“孩子链表”换成“邻接点链表”即可。n 个结点的树共有 n − 1 个孩子链表结点。
③ 孩子兄弟表示法:两个指针,把树变成二叉树
typedef struct CSNode { ElemType data; struct CSNode *firstchild, *nextsibling; // 第一个孩子、右边的兄弟 } CSNode, *CSTree;
三种方法怎么选
| 表示法 | 存储 | 找双亲 | 找孩子 | 典型用途 |
|---|---|---|---|---|
| 双亲表示法 | 顺序(数组) | O(1) | O(n),扫描整个数组 | 只需往上找的场合:并查集 |
| 孩子表示法 | 顺序 + 链式 | O(n),遍历所有孩子链表 | O(孩子数) | 常要找孩子的场合;思想同图的邻接表 |
| 孩子兄弟表示法 | 链式(二叉链表) | O(n)(除非加 parent 指针) | 第一个孩子 O(1),其余沿兄弟链 | 最常用:树 ↔ 二叉树转换,直接套用二叉树算法 |
拓展 双亲孩子表示法:在孩子表示法的表头数组里再加一个 parent 域,找双亲、找孩子都快,代价是多一个域。
跟我做一遍:在双亲数组和孩子兄弟链表上做计算
树的双亲表示:下标 0 ~ 9 依次为 R A B C D E F G H K,parent = −1 0 0 0 1 1 3 6 6 6。① F 有哪些孩子?② 有几个叶子?③ 树高是多少?
- 找孩子(依据:孩子的 parent 等于我的下标):F 是 6 号,扫描 parent 行找等于 6 的:7、8、9 号 ⇒ G、H、K。必须扫完整个数组,O(n)。
- 数叶子(依据:叶子不是任何结点的双亲):parent 行出现过的下标是 0、1、3、6,其余 2、4、5、7、8、9 号都是叶子 ⇒ B、D、E、G、H、K,共 6 个。
- 求高度:从每个结点沿 parent 往上走到根,数经过几层,取最大。K(9 号):9 → 6 → 3 → 0,共 4 层,是最深的 ⇒ 树高 4。
int PHeight(PTree T) { // 双亲表示法求树高 int h = 0; for (int i = 0; i < T.n; i++) { // 从每个结点往上走到根,数层数 int d = 0; for (int k = i; k != -1; k = T.nodes[k].parent) d++; if (d > h) h = d; } return h; }
复杂度 O(n·h)。如果结点按层序存放(双亲一定排在孩子前面),可以从前往后算 depth[i] = depth[parent[i]] + 1,降到 O(n)。
在孩子兄弟链表里:firstchild 为空 ⟺ 原树中是叶子;一个结点的“家族高度”是 1 + 它孩子们中最高的,而它的孩子们就是“firstchild 以及 firstchild 的兄弟链”。
int Leaves(CSTree T) { // 树的叶子数 if (T == NULL) return 0; if (T->firstchild == NULL) // 没有孩子:叶子 return 1 + Leaves(T->nextsibling); return Leaves(T->firstchild) + Leaves(T->nextsibling); } int Height(CSTree T) { // 树的高度 if (T == NULL) return 0; int hc = Height(T->firstchild) + 1; // 我这一支:孩子们的最大高度 + 1 int hs = Height(T->nextsibling); // 我右边各兄弟子树中最高的 return hc > hs ? hc : hs; }
Height(T) 返回的是“以 T 为首的这一串兄弟中,最高的那棵子树的高度”。按递归返回的顺序(从下往上)跟踪上图的树:
| 结点 | hc = Height(firstchild) + 1 | hs = Height(nextsibling) | 返回 max |
|---|---|---|---|
| K | 0 + 1 = 1 | 0 | 1 |
| H | 1 | Height(K) = 1 | 1 |
| G | 1 | Height(H) = 1 | 1 |
| F | Height(G) + 1 = 2 | 0 | 2 |
| C | Height(F) + 1 = 3 | 0 | 3 |
| B | 1 | Height(C) = 3 | 3 |
| E | 1 | 0 | 1 |
| D | 1 | Height(E) = 1 | 1 |
| A | Height(D) + 1 = 2 | Height(B) = 3 | 3 |
| R | Height(A) + 1 = 4 | 0 | 4 |
叶子数 Leaves(R) = 6(D、E、B、G、H、K)。两个函数各访问每个结点一次,O(n)。三段代码都已编译,并与暴力方法在 5 万棵随机树上对拍一致。
真题怎么考
本节没有直接的真题,但它是 5.8 节的基础:2011-06、2014-05 考的“二叉树中无右孩子 / 左指针为空的结点”,用的就是孩子兄弟表示法的含义(左 = 第一个孩子,右 = 右兄弟)。大题可能出“孩子兄弟链表上求叶子数 / 高度 / 度”这类递归题,写法和二叉树完全一样。
- 孩子兄弟表示法中,结点的右指针不是它的孩子,而是它的兄弟;求树高时不能写成“左右子树高度的最大值 + 1”(那是二叉树高度,会把兄弟算成下一层)。
- 双亲表示法找孩子、孩子表示法找双亲都要 O(n);考“哪种操作快”时别记反。
- 多重链表的空指针数 n(m − 1) + 1:m 是树的度,不是某个结点的度。
- 一棵度为 4、有 20 个结点的树用多重链表存储(每个结点 4 个孩子指针),有多少个空指针?
答案
共 20 × 4 = 80 个指针,用掉 19 个(边数),空 61 个 = 20 × 3 + 1。 - 双亲数组 parent = {−1, 0, 0, 1, 1, 2, 4}(下标 0 ~ 6),这棵树有几个叶子?高度是多少?
答案
出现在 parent 中的下标是 0、1、2、4,叶子是 3、5、6,共 3 个。最深的是 6:6 → 4 → 1 → 0,高度 4。 - 孩子兄弟表示的二叉链表中,结点 x 的 firstchild 为空、nextsibling 非空,x 在原树中是什么样的结点?
答案
x 是叶子(没有孩子),而且不是它双亲的最后一个孩子(右边还有兄弟)。
考纲补充(尚无真题):树的存储
这一节 2009—2026 年还没有直接考过,下面两道是按考纲自编的选择题,帮你检查是否真正理解了三种表示法。
- A. n + 1
- B. n(m − 1)
- C. nm − n − 1
- D. n(m − 1) + 1
展开解析
D指针共 nm 个,每条边用掉一个,树有 n − 1 条边,空指针 = nm − (n − 1) = n(m − 1) + 1。
变化点:m = 2 时就是二叉链表的 n + 1(A 只对二叉树成立)。这个式子说明 m 越大浪费越多,所以一般的树不用多重链表,而改用孩子兄弟表示法(每个结点只要 2 个指针)。
- A. 是根结点
- B. 是叶结点
- C. 没有兄弟
- D. 是其双亲的最后一个孩子
5.8树、森林与二叉树的转换及遍历
为什么要转换:把“树的问题”翻译成“二叉树的问题”
一般的树孩子个数不定,算法写起来麻烦;二叉树的存储、遍历、线索化我们已经全会了。5.7 节的孩子兄弟表示法告诉我们:任何一棵树都能用二叉链表存,而二叉链表本身就是一棵二叉树。所以只要掌握“树 ↔ 二叉树”“森林 ↔ 二叉树”的转换规则,树和森林的问题就都能翻译成二叉树的问题来做,而且这种对应是一一对应的(2025-04 考过这个结论)。
每个人只伸两只手:左手牵自己的大孩子,右手牵右边的弟弟。全家人就这样手拉手连成一串——这就是对应的二叉树。往左走一步 = 下一代;往右走一步 = 同一代往右挪一个。
哪里不像:照片里兄弟是并排站的,在二叉树里却变成了“右孩子的右孩子……”,层次被拉长了——所以二叉树的高度通常比原树高,二叉树的层次和原树的层次不是一回事。
树 → 二叉树:“左孩子,右兄弟”
孩子兄弟表示法本身就是一棵二叉树:每个结点的左指针指向它的第一个孩子,右指针指向它的下一个兄弟。手工转换三步:
- 加线:在每一层的兄弟之间加一条连线。
- 去线:每个结点只保留和第一个孩子的连线,去掉和其他孩子的连线。
- 旋转:以根为轴顺时针转 45°:原来的“第一个孩子”成为左孩子,兄弟连线成为右孩子。
树的根没有兄弟,所以转换后的二叉树根没有右子树。
森林 → 二叉树
先把每棵树转成二叉树,再把第二棵树的根作为第一棵树根的右孩子,第三棵作为第二棵根的右孩子……(把各棵树的根看成兄弟)。于是二叉树中从根沿右指针一路往下的结点个数 = 森林中树的棵数。反过来,二叉树 → 森林:把根及其右链上的结点断开,每个都成为一棵树的根,再递归还原。
反过来:二叉树 → 森林
规则倒着用:根和沿右指针一路下去的结点,各是一棵树的根;对每个结点,它的左孩子是第一个孩子,左孩子沿右指针的一串都是它的孩子。
树和森林的遍历,以及与二叉树遍历的对应
| 树 | 森林 | 对应的二叉树 | 说明 |
|---|---|---|---|
| 先根遍历 | 先序遍历 | 先序遍历 | 先访问根,再依次先根遍历各子树 |
| 后根遍历 | 中序遍历(也叫后序遍历) | 中序遍历 | 先依次后根遍历各子树,再访问根。森林的“中序”就是对每棵树依次做后根遍历 |
| 层次遍历 | — | — | 与二叉树的层序无直接对应 |
为什么后根对应中序:二叉树中 x 的左子树 = x 在原树里的所有子孙,右子树 = x 的右兄弟们及其子孙。中序“左、根、右”= 先 x 的子孙、再 x、再 x 的兄弟——正是后根遍历的顺序。
- 二叉树中左指针为空的结点 ⟺ 原森林中的叶结点(没有第一个孩子)。所以森林的叶子数 = 二叉树中左指针为空的结点数(2014-05)。
- 二叉树中右指针为空的结点 ⟺ 原森林中“最右边的孩子”或最后一棵树的根。一棵树中每个分支结点恰有一个最右孩子,再加上根:无右孩子的结点数 = 分支结点数 + 1(2011-06:2011 − 116 + 1 = 1896)。森林有 m 棵树时是“分支结点数 + 1”(只有最后一棵树的根没有右兄弟,其余树的根都有)。
- 二叉树中结点 u 是 v 的“祖父”(v 的双亲的双亲)时,在森林中 u、v 可能是父子(v 是 u 的第二个孩子)或兄弟(v 是 u 右边隔一个的兄弟),也可能是祖孙(2009-06 只问了前三种关系)。
- 二叉树根沿右链的结点数 = 森林中树的棵数(2021-04)。
跟我做一遍:森林、二叉树、遍历和计数一次做完
① 写出 F 的先序、中序遍历;② 画出对应的二叉树并验证遍历的对应关系;③ 数一数二叉树中左指针为空、右指针为空的结点各有几个,与森林有什么关系?
- 森林的遍历:先序 = 依次对每棵树先根遍历:A B E F C | D G H I J K;中序 = 依次对每棵树后根遍历:E F B C A | H G J K I D。
- 转二叉树:第一棵:A 的左孩子 B;B 的左孩子 E,E 的右孩子 F(F 是 E 的弟弟);B 的右孩子 C。第二棵树的根 D 挂在 A 的右边;D 的左孩子 G,G 的左孩子 H,G 的右孩子 I;I 的左孩子 J,J 的右孩子 K。
- 验证:二叉树先序 A B E F C D G H I J K = 森林先序 ✓;二叉树中序 E F B C A H G J K I D = 森林中序 ✓。二叉树的后序 F E C B H K J I G D A 在森林里没有对应的遍历。
- 左指针为空的结点:E、F、C、H、J、K 共 6 个 = 森林的叶子数(没有第一个孩子 ⟺ 叶子)。
- 右指针为空的结点:F、C、I、K(各分支结点的最后一个孩子)、H(G 唯一的孩子)、D(最后一棵树的根),共 6 个 = 森林分支结点数 5(A、B、D、G、I)+ 1。
红色 A、D 是两棵树的根,在二叉树中沿右链排列。数据已用程序转换验证;“无右孩子 = 分支结点数 + 1”对任意树和森林随机检验 2000 次成立。
- A. T 中叶结点的个数
- B. T 中度为 1 的结点个数
- C. T 中左孩子指针为空的结点个数
- D. T 中右孩子指针为空的结点个数
展开解析
C二叉树的左指针指向“第一个孩子”。森林中的叶子没有孩子 ⟺ 在二叉树中左指针为空。
- A 错二叉树中的叶子要求左右都空:只对应森林中既无孩子、又无右兄弟的结点,只是森林叶子中的一部分(有右兄弟的叶子不算)。
- B 错度为 1 的结点可能是“有孩子无兄弟”,不一定是森林的叶子。
- C 对左孩子指针为空的结点数。
- D 错右指针为空对应“没有右兄弟”。
左空 ⟺ 原来是叶子;右空 ⟺ 原来没有右兄弟。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
C先由先序 a b d c e g f、中序 b d a e g c f 建出 T,再数根的右链。
- 根 a;中序 b d | a | e g c f:左 {b, d},右 {e, g, c, f}。
- 右子树先序 c e g f ⇒ 根 c;中序 e g | c | f ⇒ c 的右孩子是 f。
- 根的右链:a → c → f,共 3 个结点 ⇒ 森林有 3 棵树(根分别是 a、c、f)。
- A 错根有右子树,森林不止一棵树。
- B 错漏了 f。
- C 对3 棵。
- D 错4 是把不在右链上的结点也算成了根(如 e 是 c 的左孩子,对应 c 的第一个孩子;d 是 b 的右孩子,对应 b 的兄弟)。只有从根出发一路向右的 a、c、f 才是各棵树的根。
森林中各树的根在二叉树中沿“根 → 右 → 右 …”排成一串。
真题怎么考
| 考法 | 真题 | 抓手 |
|---|---|---|
| 左空 / 右空计数 | 2014-05、2011-06 | 左空 ⟺ 叶子;右空 ⟺ 没有右兄弟(每个分支结点的最后一个孩子 + 最后一棵树的根) |
| 遍历对应 | 2019-02、2020-04 | 树 / 森林先根 = 二叉树先序;树后根、森林中序 = 二叉树中序 |
| 森林有几棵树 | 2021-04 | 先建出二叉树,再数根的右链结点数 |
| 结点关系 | 2009-06 | 二叉树中往左一步 = 下一代,往右一步 = 同代右移 |
| 转换后的高度 | 2026-04 | 排在第 k 位的树,它的根在二叉树第 k 层;每棵树内部再看左子树的高度 |
| 概念辨析 | 2025-04 | 森林与二叉树一一对应,任何森林都能唯一地转成二叉树 |
- 森林的“中序遍历”就是依次对每棵树做后根遍历(有的书叫后序遍历),对应二叉树的中序,不是后序。
- 只有一棵树时,二叉树的根没有右子树;根有右子树 ⟺ 原来是不止一棵树的森林。
- 转换后的二叉树里,结点的层次 ≠ 原树中的层次:同一层的兄弟在二叉树里一个比一个深。
- 一棵有 15 个结点的树,其中叶子 8 个。转成二叉树后,左指针为空、右指针为空的结点各有几个?
答案
左空 = 叶子数 = 8;右空 = 分支结点数 + 1 = (15 − 8) + 1 = 8。 - 二叉树 A(B(, C), D(E, F)) 对应的森林有几棵树?分别是什么?
答案
根的右链 A → D → F,共 3 棵:A 的左孩子 B、B 的右孩子 C ⇒ 第一棵 A(B, C);D 的左孩子 E ⇒ 第二棵 D(E);第三棵只有 F。 - 树 A(B(D, E), C(F)) 的后根遍历序列是什么?对应二叉树的哪种遍历序列与它相同?
答案
后根:D E B F C A。与对应二叉树 A(B(D(, E), C(F, )), ) 的中序序列相同。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 799
- B. 800
- C. 1220
- D. 1221
展开解析
B孩子兄弟表示法中,左指针指向第一个孩子。树中没有孩子的结点(叶结点)在二叉树中就没有左孩子,共 800 个。
变化点:原题问无右孩子的结点:右指针指向下一个兄弟,每个非叶结点的孩子中最后一个没有右兄弟,再加上根,共 (2020 − 800) + 1 = 1221 个(D)。一左一右,别混。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
D先构造 T:根 a,左子树 {b},右子树 {d, c, e, f}(先序 c d e f);它的根 c,左 d,右 {e, f}(先序 e f):根 e,f 是 e 的右孩子。T 的根沿右指针走:a → c → e → f,共 4 个结点,每个都是森林中一棵树的根,所以 F 有 4 棵树。
变化点:原题右链是 a → c → f,3 棵。方法:森林中树的棵数 = 二叉树根结点的右链长度(根及其右孩子、右孩子的右孩子……)。
- A. c, b, f, e, d, a
- B. b, c, e, f, d, a
- C. c, b, e, f, d, a
- D. f, e, d, c, b, a
展开解析
A森林的先根 = T 的先序,森林的中根 = T 的中序。由先序 a b c d e f、中序 b c a e f d 构造 T:根 a,左子树 {b, c}:b 为根、c 是 b 的右孩子;右子树 {e, f, d}:先序 d e f,根 d,e f 在 d 左边,e 为根、f 是 e 的右孩子。T 的后序:c b f e d a。
变化点:对应关系要记牢:森林先根 ↔ 二叉树先序;森林中根(后根)↔ 二叉树中序;二叉树的后序在森林中没有直接对应的遍历,必须先把 T 画出来。
5.9哈夫曼树与哈夫曼编码
问题:怎样用最少的 0/1 发完一篇电报
一段电文共 42 个字符,只由 A ~ F 六种字母组成,出现次数分别是 A 12、B 4、C 9、D 3、E 8、F 6。用定长编码,6 种字母至少要 ⌈log₂6⌉ = 3 位,全文 42 × 3 = 126 位。能不能更省?办法是常用的字母用短码、少见的字母用长码。哈夫曼树就是专门回答“怎么分配码长才最省”的工具,它算出来的方案只需要 104 位。
摩尔斯电码里最常用的 E 只有一个“·”,罕见的 Q 是“− − · −”——频率高的码短,这正是哈夫曼编码的思想。
哪里不像:摩尔斯电码中 E(·)是 I(··)的开头,发报时必须在字母之间停顿才能分清;哈夫曼编码是前缀码,任何一个码都不是另一个码的开头,所以 0/1 串可以连着发,接收方不需要任何分隔符也能唯一地切开。
带权路径长度 WPL
给树中结点赋一个有意义的数叫权(比如字符出现的频率)。从根到某结点的路径长度(边数)乘以该结点的权,叫该结点的带权路径长度;所有叶结点的带权路径长度之和叫树的带权路径长度 WPL:
直觉:权大的叶子应该离根近,权小的可以离根远。
构造算法(每次合并最小的两个)
- 把 n 个权值看成 n 棵只有根的树,组成森林 F。
- 从 F 中选出根的权值最小的两棵,作为一个新结点的左、右子树,新结点的权 = 两者之和。
- 从 F 中删去这两棵,加入新树。
- 重复 2、3,直到 F 中只剩一棵树。
展开解析
合并 2 + 4 = 6 → {7, 5, 6};合并 5 + 6 = 11 → {7, 11};合并 7 + 11 = 18。
WPL = 7 × 1 + 5 × 2 + 2 × 3 + 4 × 3 = 35。
快速算法:WPL = 所有非叶结点(新建结点)的权之和 = 6 + 11 + 18 = 35。因为每个叶子的权被它上面的每一个祖先各算了一次,恰好是路径长度次。
跟我做一遍:6 个字母的哈夫曼树与编码
构造哈夫曼树,写出各字母的哈夫曼编码(左 0 右 1,合并时小的作左孩子),求 WPL、平均码长,并与定长编码比较。
| 步 | 当前森林中各树根的权(从小到大) | 选出最小的两个 | 新结点 |
|---|---|---|---|
| 0 | 3(D) 4(B) 6(F) 8(E) 9(C) 12(A) | — | — |
| ① | 3 4 6 8 9 12 | 3、4 | 7 |
| ② | 6 7 8 9 12 | 6、7 | 13 |
| ③ | 8 9 12 13 | 8、9 | 17 |
| ④ | 12 13 17 | 12、13 | 25 |
| ⑤ | 17 25 | 17、25 | 42(根) |
注意第 ③ 步:上一步新生成的 13 不是最小的,最小的是 8 和 9——每一步都要在当前所有树根里重新挑,不是总拿新结点去合并。
| 字母 | A | B | C | D | E | F | 合计 |
|---|---|---|---|---|---|---|---|
| 次数(权) | 12 | 4 | 9 | 3 | 8 | 6 | 42 |
| 哈夫曼编码 | 10 | 1111 | 01 | 1110 | 00 | 110 | |
| 码长 × 次数 | 24 | 16 | 18 | 12 | 16 | 18 | WPL = 104 位 |
| 定长编码 | 每个 3 位 | 42 × 3 = 126 位 | |||||
平均码长 = WPL ÷ 总次数 = 104 ÷ 42 ≈ 2.48 位;比定长编码少 126 − 104 = 22 位,约省 17.5%。也可以用“新结点权值之和”直接算 WPL:7 + 13 + 17 + 25 + 42 = 104,两种算法一致。
可以在下面的小工具里输入“12 4 9 3 8 6”核对每一步。全部数字已用 C 程序(下面的 CreateHuffman)和 Python 堆实现分别算出并比对。
为什么 WPL = 所有新结点的权之和
每个新结点的权 = 它下面所有叶子的权之和。把所有新结点的权加起来,某个叶子的权被加了几次?它有几个祖先就加几次——而祖先个数正是它的路径长度。所以“新结点权值之和”恰好等于“各叶子权 × 路径长度之和”。
| 叶子 | 它的祖先(新结点) | 祖先个数 = 路径长度 | 对 WPL 的贡献 |
|---|---|---|---|
| D:3 | 7、13、25、42 | 4 | 3 × 4 = 12 |
| B:4 | 7、13、25、42 | 4 | 4 × 4 = 16 |
| F:6 | 13、25、42 | 3 | 6 × 3 = 18 |
| A:12 | 25、42 | 2 | 12 × 2 = 24 |
| E:8、C:9 | 17、42 | 2 | (8 + 9) × 2 = 34 |
| 合计 | 104 | ||
这个等式对任何“父结点权 = 孩子权之和”的树都成立(不限于哈夫曼树,也不限于二叉),所以第 8 篇最佳归并树算 WPL 时同样可以这样“只加内部结点”。
拓展 为什么贪心是对的:权最小的两个叶子在某棵最优树里一定可以放在最深层且互为兄弟(若不在,把它们和最深的两个叶子交换,WPL 不会变大)。把这两个叶子合成一个权为 w₁ + w₂ 的叶子,原问题的 WPL = 新问题的 WPL + (w₁ + w₂),问题规模减 1,于是一步步合并最小的两个就得到最优解。
写成代码:静态数组存 2n − 1 个结点
哈夫曼树没有度为 1 的结点,n 个叶子正好 2n − 1 个结点,事先就能开好数组:前 n 个放叶子,第 n + 1 ~ 2n − 1 个依次放每次合并产生的新结点。parent = 0 表示“还是一棵树的根,可以被选”。
typedef struct { int weight; int parent, lchild, rchild; } HTNode; // 下标 1..2n-1,0 表示“无” void Select(HTNode HT[], int k, int *s1, int *s2) { // 在 HT[1..k] 中选 parent == 0 且权最小的两个 *s1 = *s2 = 0; for (int i = 1; i <= k; i++) { if (HT[i].parent != 0) continue; // 已经合并过的跳过 if (*s1 == 0 || HT[i].weight < HT[*s1].weight) { *s2 = *s1; *s1 = i; } else if (*s2 == 0 || HT[i].weight < HT[*s2].weight) *s2 = i; } } void CreateHuffman(HTNode HT[], int w[], int n) { // w[1..n] 是 n 个叶子的权 for (int i = 1; i <= 2 * n - 1; i++) { // 初始化:前 n 个是叶子 HT[i].weight = (i <= n) ? w[i] : 0; HT[i].parent = HT[i].lchild = HT[i].rchild = 0; } for (int i = n + 1; i <= 2 * n - 1; i++) { // 共合并 n - 1 次 int s1, s2; Select(HT, i - 1, &s1, &s2); // 在已有结点中选两个最小的根 HT[s1].parent = HT[s2].parent = i; HT[i].lchild = s1; HT[i].rchild = s2; // 小的作左孩子 HT[i].weight = HT[s1].weight + HT[s2].weight; } } void Encode(HTNode HT[], int i, char code[]) { // 求第 i 个叶子的编码 char tmp[64]; int len = 0; for (int c = i, p = HT[i].parent; p != 0; c = p, p = HT[p].parent) tmp[len++] = (HT[p].lchild == c) ? '0' : '1'; // 我是左孩子记 0,右孩子记 1 for (int k = 0; k < len; k++) code[k] = tmp[len - 1 - k]; // 倒过来 = 从根到叶子 code[len] = '\0'; }
对上面的例子(下标 1 ~ 6 依次是 A B C D E F)运行后,数组内容如下——这张表就是“跟踪表”,每一行新结点对应一次合并:
| 下标 | 1 (A) | 2 (B) | 3 (C) | 4 (D) | 5 (E) | 6 (F) | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| weight | 12 | 4 | 9 | 3 | 8 | 6 | 7 | 13 | 17 | 25 | 42 |
| parent | 10 | 7 | 9 | 7 | 9 | 8 | 8 | 10 | 11 | 11 | 0 |
| lchild | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 6 | 5 | 1 | 9 |
| rchild | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 7 | 3 | 8 | 10 |
以 D(下标 4)为例跑 Encode:4 的双亲 7,4 是 7 的左孩子 → 0;7 的双亲 8,7 是右孩子 → 1;8 → 10 记 1;10 → 11 记 1。倒过来读得 1110。WPL 也可以直接对下标 7 ~ 11 的 weight 求和。
复杂度:每次 Select 扫描 O(n),共 n − 1 次,O(n²)。拓展 用小根堆(8.6 节)存树根,每次取最小两个 O(log n),总共 O(n log n)。代码已编译,随机 2 万组权值与“排序取最小两个”的参照实现对拍,WPL 两种算法一致,且所有编码互不为前缀。
哈夫曼编码与前缀码
用二进制串给字符编码:固定长度编码每个字符的码长相同(如 ASCII);可变长度编码让频率高的字符码短、频率低的码长,平均码长更短。可变长编码必须是前缀编码:没有一个编码是另一个编码的前缀,否则解码时会有歧义(比如 A = 0、B = 01,收到 01 不知道是 AB 的开头还是 B)。
- 以字符的频率为权构造哈夫曼树。
- 左分支记 0、右分支记 1(反过来也行),从根到某个叶子路径上的 0/1 串就是这个字符的编码。
- 因为字符都在叶子上,没有一条根到叶子的路径是另一条的前缀,所以一定是前缀编码。
- WPL 就是编码后的总位数(频率用次数时);平均码长 = WPL ÷ 总次数(2023-04)。
判断一组编码是否是前缀编码:把它们画成一棵二叉树(0 往左、1 往右),看是否有编码落在另一个编码的路径中间(非叶子位置)。2020-42 就是让你描述这个过程。
译码:顺着树走,走到叶子就输出
收到 0/1 串后从根出发,读到 0 往左、读到 1 往右,一到叶子就输出这个字母并回到根。前缀性保证了“走到叶子”这件事不会提前或推迟发生,所以切分是唯一的。以上面的编码为例,译 11100011110110:
| 读入的位 | 在树中走的路线 | 到达 | 输出 |
|---|---|---|---|
| 1 1 1 0 | 42 → 25 → 13 → 7 → 左 | 叶子 D | D |
| 0 0 | 42 → 17 → 左 | 叶子 E | E |
| 1 1 1 1 | 42 → 25 → 13 → 7 → 右 | 叶子 B | B |
| 0 1 | 42 → 17 → 右 | 叶子 C | C |
| 1 0 | 42 → 25 → 左 | 叶子 A | A |
结果 DEBCA。这正是 2020-42 第 (2) 问要你描述的“译码过程”,2017-06 也考过;第 (3) 问“判定前缀特性”就是把所有编码插入一棵 0/1 树,看是否有编码走到了别的编码的叶子上,或在中途结束于已有路径的内部结点。
| 比较 | 定长编码 | 哈夫曼编码 |
|---|---|---|
| 码长 | 都相同,⌈log₂(字符种数)⌉ | 按频率分配,频率高的短 |
| 总长度 | 次数 × 定长(本例 126) | WPL,所有前缀码中最小(本例 104) |
| 译码 | 每 k 位一切,最简单 | 沿树走,需要哈夫曼树(或码表) |
| 什么时候没优势 | 各字符频率都相同且种数是 2 的幂时,哈夫曼树就是满二叉树,码长与定长编码完全一样。2022-05 比较的正是这两棵编码树:定长编码的叶子全在同一层,哈夫曼树的叶子不一定 | |
每次合并最小的 m 棵。若 (n − 1) 不能被 (m − 1) 整除,要先补上若干个权为 0 的虚叶子,使 (n + 补的个数 − 1) % (m − 1) == 0,否则最后一次合并凑不满 m 棵,树就不是最优的。外部排序的最佳归并树(8.10 节)就是 m 叉哈夫曼树。
m 叉哈夫曼树:为什么要补虚叶子
推导:每次把 m 棵树合并成 1 棵,树的棵数减少 m − 1。从 n 棵减到 1 棵,一共要减少 n − 1,所以必须 (n − 1) 能被 (m − 1) 整除,每次才能恰好凑满 m 棵。若余数 u = (n − 1) mod (m − 1) ≠ 0,就先补 (m − 1) − u 个权为 0 的虚叶子。虚叶子权为 0,不增加 WPL,却让最后一次合并不会“凑不满”——凑不满的那次本该发生在最底层(权最小的地方),而不是根上。
程序用穷举(每次任选 2 ~ m 棵合并,枚举所有方式)验证:补虚叶子后的贪心结果 34 就是所有三叉树里的最小值。外部排序的最佳归并树(8.10 节)就是这个模型:归并段长度是权,k 路归并 = k 叉,I/O 次数 = 2 × WPL。
- A. 89
- B. 200
- C. 208
- D. 289
展开解析
B最小 WPL 就是哈夫曼树的 WPL。
- 合并 10 + 12 = 22;16 + 21 = 37;22 + 30 = 52;37 + 52 = 89。
- WPL = 非叶结点权之和 = 22 + 37 + 52 + 89 = 200。
- 验算:30 深度 2,16、21 深度 2,10、12 深度 3:(30 + 16 + 21) × 2 + (10 + 12) × 3 = 134 + 66 = 200。
- A 错89 只是根的权(全部叶子权之和)。
- B 对200。
- C 错208 是“每次都把新结点和下一个叶子合并”的结果:10 + 12 = 22,22 + 16 = 38,38 + 21 = 59,59 + 30 = 89,22 + 38 + 59 + 89 = 208。第二步应合并当前最小的 16 和 21。
- D 错289 = 200 + 89:WPL 已经包含了根的权 89,又多加了一次。
WPL = 所有新建(非叶)结点的权之和。
真题怎么考
| 考法 | 真题 | 抓手 |
|---|---|---|
| 求最小 WPL | 2021-05 | 构造后把新结点权加起来 |
| 结点数、度 | 2019-03、2010-06 | 总数 2n − 1;没有度为 1 的结点;不一定是完全二叉树 |
| 权值与层次关系 | 2015-03、2026-05 | 权越小越深;上层结点的权不小于下层任一结点 |
| 编码、码长 | 2018-05、2025-05、2023-04 | 码长 = 叶子深度;平均码长 = WPL ÷ 总频次 |
| 前缀码判断与译码 | 2014-06、2017-06、2020-42 | 画 0/1 树,看是否有码落在别的码的路径上 |
| 与定长编码比较 | 2022-05 | 定长编码树的叶子都在同一层;哈夫曼总长 ≤ 定长总长,频率均匀时可能相等 |
| m 叉 + 补虚段 | 2013-04、2012-41 | (n − 1) mod (m − 1) 判断补几个 0 |
| 大题:求 WPL 的算法 | 2014-41 | 带深度参数的先序遍历(5.4 节代码),叶子处累加权 × 深度 |
- WPL 只算叶子;路径长度是边数(根的深度算 0),不是层数。
- 平均码长除以总频次,不是除以字符种数(2023-04 的陷阱)。
- 哈夫曼树不唯一,各字符的码也不唯一(左右可交换、等权时可换),但 WPL 唯一;所以“某字符的码长”在等权时可能有两种答案,题目一般会规定合并规则。
- m 叉补虚段的个数是 (m − 1) − u,不是 u;u = 0 时不补。
- 权值 1, 3, 4, 6, 10 构造哈夫曼树,WPL 是多少?
答案
1 + 3 = 4;4 + 4 = 8;6 + 8 = 14;10 + 14 = 24。WPL = 4 + 8 + 14 + 24 = 50。 - 一棵哈夫曼树共 199 个结点,它有几个叶子?有没有可能共 200 个结点?
答案
2n − 1 = 199 ⇒ n = 100。总数 2n − 1 一定是奇数,不可能是 200。 - 编码 a = 0、b = 10、c = 110、d = 111,把 0110100111 译成字符串。
答案
0 | 110 | 10 | 0 | 111 ⇒ a c b a d。 - 8 个叶子构造 4 叉哈夫曼树(每次合并 4 棵),要补几个权为 0 的虚叶子?
答案
(8 − 1) mod (4 − 1) = 1 ≠ 0,补 (4 − 1) − 1 = 2 个,凑成 10 个叶子:(10 − 1) mod 3 = 0。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 115
- B. 125
- C. 133
- D. 145
展开解析
C构造哈夫曼树:5 + 8 = 13;12 + 13 = 25;15 + 20 = 35;25 + 35 = 60。WPL = 所有新结点的权之和 = 13 + 25 + 35 + 60 = 133。
变化点:第三步最小的两个是 15 和 20(不是 25),合并时每次都要重新在当前所有树中选最小的两个。WPL 用“新结点权值之和”算最快。
- A. 62
- B. 64
- C. 66
- D. 71
展开解析
A三叉哈夫曼树每次合并 3 棵。先检查 (n − 1) % (3 − 1) = 6 % 2 = 0,不用补虚叶子。2 + 3 + 4 = 9;剩 5, 6, 7, 8, 9,取 5 + 6 + 7 = 18;剩 8, 9, 18,合并得 35。WPL = 9 + 18 + 35 = 62。
变化点:原题 6 个叶子,(6 − 1) % 2 = 1 ≠ 0,要先补一个权为 0 的虚叶子才能让每次都合并 3 个。叶子数改成 7 恰好不用补。补 0 的个数 = (k − 1) − (n − 1) % (k − 1)(余数不为 0 时)。
- A. 1.8
- B. 2.0
- C. 2.2
- D. 2.4
展开解析
C1 + 2 = 3;3 + 3 = 6;4 + 5 = 9;6 + 9 = 15。WPL = 3 + 6 + 9 + 15 = 33,平均码长 = WPL ÷ 总频次 = 33 ÷ 15 = 2.2。
变化点:“加权平均长度”要除以总频次,不是除以字符个数。定长编码需要 ⌈log25⌉ = 3 位,哈夫曼编码平均省了 0.8 位。
- A. 11
- B. 20
- C. 21
- D. 30
展开解析
C只有度为 0 和度为 3 的结点:n0 = 1 + (3 − 1)·n3 = 1 + 2 × 10 = 21。
变化点:二叉哈夫曼树 n0 = n2 + 1,总结点 2n0 − 1(原题 115 个结点 → 58 个叶子)。k 叉时 n0 = (k − 1)nk + 1,是树的性质“结点数 = 分支数 + 1”的特例。
- A. 0, 10, 110, 111
- B. 00, 01, 10, 110
- C. 1, 01, 001, 000
- D. 0, 01, 11, 10
展开解析
D前缀编码要求任一编码都不是另一编码的前缀。D 中 0 是 01 的前缀,收到 01 时无法确定是“0 + 1…”还是“01”。
变化点:B 虽然不是哈夫曼编码(编码树中 111 的位置空着,有一个分支结点只有一个孩子),但仍是前缀编码。前缀编码 ⇔ 所有编码都在二叉树的叶子上,把编码画成树最直观。
5.10并查集
问题:不停地“合并”,随时问“是不是一伙的”
班里 40 个人,老师不断告诉你“张三和李四是朋友”“李四和王五是朋友”……朋友的朋友也算同一个圈子。中途随时会问:“张三和王五在不在同一个圈子?”如果每次都把圈子重新算一遍,太慢。并查集就是专门处理这类问题的结构:只支持两件事——合并两个集合、查询某个元素属于哪个集合,但这两件事都做得极快。
每个人只记住一个“上级联系人”,顺着上级一路问上去,最后问到的那个人就是圈主(树根)。问“两人是不是一伙”= 比较两人各自的圈主是不是同一个人;合并两个圈子 = 让一个圈主认另一个圈主当上级,一步完成。
哪里不像:现实的朋友会绝交,可并查集不支持拆分;“上级”也只是为了找圈主而设的指针,不代表真实的辈分关系——路径压缩时会随意改上级。
定义、存储与基本代码
并查集是一种简单的集合表示,支持三种操作:
Initial(S):把 S 中每个元素初始化为只含自己的集合。Find(S, x):查找 x 所在集合的“代表”(根结点)。Union(S, root1, root2):把两个不相交的集合合并成一个。
它用树的双亲表示法存储:每个集合是一棵树,根就是集合的代表;数组 S[x] 存 x 的双亲下标,根的 S[root] 为负数。判断两个元素是否在同一集合:看它们的根是否相同。
#define SIZE 100 int S[SIZE]; void Initial(int S[]) { for (int i = 0; i < SIZE; i++) S[i] = -1; } int Find(int S[], int x) { // 找 x 所在集合的根 while (S[x] >= 0) x = S[x]; return x; } void Union(int S[], int root1, int root2) { // 要求 root1、root2 是两个不同集合的根 if (root1 == root2) return; S[root2] = root1; // 让 root2 指向 root1 }
为什么要优化:朴素合并会退化成链
Find 的时间取决于树高。朴素合并最坏时树退化成长度 n 的链,一次 Find 就是 O(n)。改进的思路很朴素:别让树长高。
0 ~ 7 初始各成一个集合。依次执行 Union(Find(0), Find(1))、(2, 3)、(4, 5)、(6, 7),再 Union(Find(0), Find(2))、Union(Find(4), Find(6)),最后 Union(Find(0), Find(4))。合并用上面“按大小”的代码(S 中根存 −结点数),写出每一阶段的 S 数组。
- 前四次:每次两个根的大小都是 1,代码走 else 分支(S[r2] > S[r1] 不成立),r1 挂到 r2 下:S[0] = 1、S[2] = 3、S[4] = 5、S[6] = 7,根 1、3、5、7 的 S 都变成 −2。
- Union(Find(0), Find(2)):Find(0) = 1,Find(2) = 3,两棵都有 2 个结点 ⇒ 1 挂到 3 下,S[1] = 3,S[3] = −4。同理 Union(Find(4), Find(6)):5 挂到 7 下,S[5] = 7,S[7] = −4。
- Union(Find(0), Find(4)):Find(0) 沿 0 → 1 → 3 找到根 3,Find(4) 沿 4 → 5 → 7 找到根 7,两棵都是 4 个结点 ⇒ 3 挂到 7 下,S[3] = 7,S[7] = −8。
- 检查树高:最深的 0 在第 4 层(0 → 1 → 3 → 7),⌊log₂8⌋ + 1 = 4,正好达到上界。
以上数字由 C 程序逐步执行讲义代码打印得到。
两个优化(王道代码)
① 按大小合并(小树并到大树)
void Union(int S[], int r1, int r2) { if (r1 == r2) return; if (S[r2] > S[r1]) { // r2 结点更少(负得少) S[r1] += S[r2]; S[r2] = r1; } else { S[r2] += S[r1]; S[r1] = r2; } }
根里存 −(结点个数)。这样树高不超过 ⌊log₂n⌋ + 1,Find 最坏 O(log n)。
② 路径压缩
int Find(int S[], int x) { int root = x; while (S[root] >= 0) root = S[root]; while (x != root) { // 把路径上的结点都直接挂到根下 int t = S[x]; S[x] = root; x = t; } return root; }
查一次之后,路径上的结点都直接挂到根下,以后再查它们只要一步。
两种“小树并大树”:按大小、按高度
上面左边的代码是按大小(王道主线,根存 −结点数)。另一种常见写法是按高度(也叫按秩):根存 −树高,矮树挂到高树下;只有两棵一样高时,合并后高度才加 1。
void UnionByHeight(int S[], int r1, int r2) { // 根中存 -(树高) if (r1 == r2) return; if (S[r1] < S[r2]) S[r2] = r1; // r1 更高:r2 挂到 r1 下,高度不变 else if (S[r1] > S[r2]) S[r1] = r2; // r2 更高 else { S[r2] = r1; S[r1]--; } // 一样高:r1 的高度 + 1 }
为什么树高不超过 ⌊log₂n⌋ + 1(按大小):一个结点的深度只在“它所在的树被挂到另一棵树下”时才加 1,而按大小合并时对方至少和它一样大——所以它每被压低一层,所在集合的大小至少翻一倍。集合最大是 n,最多翻倍 log₂n 次,深度最多 1 + ⌊log₂n⌋。按高度合并同理:两棵一样高的树合并,高度才加 1,而要长到高度 h 至少要 2h−1 个结点。程序对 3000 组随机操作检查过这个上界。
路径压缩:查一次,路上的人都直接认圈主
除了讲义上方的非递归写法(先找根,再沿路把每个结点的 S 改成根),递归写法更短,考场上写哪个都行:
int Find(int S[], int x) { // 返回根,并把路径上的结点都直接挂到根下 if (S[x] < 0) return x; // x 就是根 return S[x] = Find(S, S[x]); // 先找到根,回来时顺手把 S[x] 改成根 }
拓展 路径压缩会把树压矮,但不会去更新根里存的“高度”,所以按高度合并 + 路径压缩时,根里的数只是高度的上界,这就是教材把它叫“秩(rank)”而不叫“高度”的原因。两种优化一起用时,m 次操作总时间约 O(m·α(n)),α 是增长极慢的函数,n 不超过宇宙原子数时 α(n) ≤ 4,可看成常数。
| 实现 | Find 最坏 | Union | n 个元素做 n 次操作 |
|---|---|---|---|
| 朴素 | O(n)(树退化成链) | O(1) | O(n²) |
| 按大小(秩)合并 | O(log n) | O(1) | O(n log n) |
| 按秩合并 + 路径压缩 | 接近 O(1)(O(α(n)),α 是增长极慢的函数,实际 ≤ 4) | O(1) | 约 O(n α(n)) |
应用:连通分量计数、判断无向图有没有环(Kruskal 算法的核心一步)、等价类划分。代码见下。
应用:连通分量个数、判断无向图有没有环
把图的每个顶点看成一个元素,每读到一条边 (u, v) 就把 u、v 所在的集合合并。读完所有边后,集合的个数 = 连通分量的个数;而如果读到某条边时 u、v 已经在同一个集合,说明它俩之前已经连通,再加这条边就形成了环。
int Components(int n, int edge[][2], int m) { // n 个顶点、m 条边 int S[MAXN]; Initial(S, n); // 全部置 -1 for (int i = 0; i < m; i++) { int r1 = Find(S, edge[i][0]), r2 = Find(S, edge[i][1]); if (r1 != r2) Union(S, r1, r2); } int cnt = 0; for (int i = 0; i < n; i++) if (S[i] < 0) cnt++; // 每个根代表一个连通分量 return cnt; } bool HasCycle(int n, int edge[][2], int m) { // 无向图(无重边、无自环) int S[MAXN]; Initial(S, n); for (int i = 0; i < m; i++) { int r1 = Find(S, edge[i][0]), r2 = Find(S, edge[i][1]); if (r1 == r2) return true; // 两端早已连通,再加这条边就成环 Union(S, r1, r2); } return false; }
复杂度:带两种优化时约 O(n + m·α(n)),几乎是线性的。两个函数已与“深度优先搜索数分量”以及“无环 ⟺ 边数 = 顶点数 − 分量数”的判据随机对拍 3000 个图,全部一致。第 6 篇 Kruskal 算法里“这条边加进来会不会成环”用的就是 HasCycle 里的那一句判断。
预测大题:并查集的代码题怎么拿满分
2009—2026 年还没有直接考过并查集,但它是考纲点、代码短、又能和图结合,很适合出成 41 题。下面按 408 大题的格式给一道模拟题和评分思路。
无向图 G 有 n 个顶点(编号 0 ~ n − 1)、m 条边,边存放在数组 edge[m][2] 中。设计一个尽可能高效的算法,求 G 的连通分量个数。(1)给出算法的基本设计思想;(2)用 C 语言描述算法,关键之处给出注释;(3)说明时间复杂度和空间复杂度。
| 评分点 | 满分写法 | 参考分 |
|---|---|---|
| (1) 设计思想 | 用并查集:初始每个顶点一个集合;逐条边合并两端所在集合;最后数根的个数。点出“Find 带路径压缩、Union 按大小” | 3 ~ 4 |
| (2) 代码 | Initial、Find、Union 三个函数 + 主函数(就是上面的 Components);Find 必须对根返回、对非根沿双亲走;合并前判 r1 != r2 | 7 ~ 8 |
| (3) 复杂度 | 时间 O(n + m·α(n))(不优化时最坏 O(m·n)),空间 O(n) | 2 |
只会暴力解时怎么拿分:① 不写优化的朴素 Find / Union 也是正确算法,一般只扣“效率”部分的 1 ~ 2 分,复杂度写 O(m·n) 即可;② 也可以用第 6 篇的 DFS / BFS:建邻接表,对每个未访问顶点做一次遍历、计数加 1,时间 O(n + m),同样是正确的满分思路之一。最怕的是“思路写对、代码只写一半”——先保证三个函数完整,再考虑优化。
- Union 的两个参数必须是根:先 Find 再 Union。直接 Union(S, a, b) 会把 b 原来的双亲关系覆盖掉,集合就乱了。
- 根的 S 值是负数(−1 或 −结点数 / −高度),判断“是不是根”用
S[x] < 0,不要写成S[x] == -1(按大小合并后根不一定是 −1)。 - 按大小合并时,比较的是负数:S[r2] > S[r1] 表示 r2 的结点更少。
- 并查集只能合并、查询,不能拆分,也不能高效地列出某个集合的全部元素。
- S = {1, 3, 3, 7, 5, 7, 7, −8}(按大小合并,根存 −结点数)。不做路径压缩时 Find(0) 要访问几个结点?做一次带压缩的 Find(0) 后 S 变成什么?
答案
路径 0 → 1 → 3 → 7,访问 4 个结点。压缩后 0、1 都直接指向 7:S = {7, 7, 3, 7, 5, 7, 7, −8}(3 本来就指向 7,不变)。 - 5 个顶点的无向图依次读入边 (0, 1)、(1, 2)、(3, 4)、(2, 0)。用并查集判断:读到哪条边时发现有环?最后有几个连通分量?
答案
前三条边都连接两个不同集合;读到 (2, 0) 时 Find(2) = Find(0),已经连通 ⇒ 有环。最后集合 {0, 1, 2}、{3, 4},共 2 个连通分量。 - n = 1000 个元素,只用“按大小合并”(不压缩),任何时刻树高最多是多少?
答案
⌊log₂1000⌋ + 1 = 9 + 1 = 10(2⁹ = 512 ≤ 1000 < 1024)。
考纲补充(尚无真题):并查集
并查集是考纲点,2009—2026 年尚未直接考过。下面两道按考纲自编,覆盖“树高上界”和“数组状态”两种最可能的考法。
- A. ⌊log₂n⌋ + 1
- B. n
- C. ⌈√n⌉
- D. ⌈n/2⌉
展开解析
A某个结点的深度每增加 1,都是因为它所在的树被挂到了一棵不比它小的树下,于是它所在集合的大小至少翻倍。集合大小最多 n,最多翻倍 ⌊log₂n⌋ 次,深度至多 ⌊log₂n⌋ + 1。
变化点:若是朴素合并(不比较大小),最坏会退化成链,高度 n(B)。加上路径压缩后单次 Find 均摊接近常数,但“最坏高度”的问法仍按 ⌊log₂n⌋ + 1 回答。
- A. {−6, 0, 1, 2, 0, 4}
- B. {4, 0, 0, 0, −6, 4}
- C. {−6, 0, 0, 0, 0, 4}
- D. {−4, 0, 0, 0, −2, 4}
展开解析
C① Find(3):路径 3 → 2 → 1 → 0,根 0;压缩后 S[3] = S[2] = 0(S[1] 本来就是 0),S = {−4, 0, 0, 0, −2, 4}。② Find(3) = 0(4 个结点),Find(5) = 4(2 个结点),小树 4 并到 0 下:S[0] = −6,S[4] = 0。结果 {−6, 0, 0, 0, 0, 4}。
变化点:A 忘了路径压缩;B 把大树并到了小树下;D 只做了 Find 没做 Union。注意 5 没有被压缩——它不在 Find(3) 的路径上,Find(5) 时它的双亲 4 本来就是根。
5.11堆及其应用 2025 起考纲单列
2025 年起,408 大纲在“树与二叉树的应用”下,把“堆及其应用”和哈夫曼树、并查集并列写了出来。堆的操作(建堆、向上调整、向下调整、堆排序)在 8.6 节已经结合排序讲透了;这一节换一个角度——把堆当成一种数据结构来用:它到底擅长什么、比别的结构好在哪里、命题人会从哪几个地方下手。读之前最好先看过 8.6 的 建堆、插入、删除堆顶 三段。
问题:反复“取出最紧急的那一个”
医院急诊室:病人随时会来,每来一个就按病情打个分;医生一空下来,总是先叫分数最高的那个。这里只有两种操作反复交替:放进一个、取出最大的一个。操作系统按优先级挑下一个运行的进程、打印机按优先级排队,都是同一个模型,叫优先队列。用学过的结构来实现,代价各不相同:
| 用什么存 | 放进一个 | 取出最大的 | 问题出在哪 |
|---|---|---|---|
| 无序顺序表 | O(1)(放末尾) | O(n)(要扫一遍) | 每次取都要找 |
| 有序顺序表 | O(n)(要挪元素) | O(1)(在末尾) | 每次放都要挪 |
| 有序链表 | O(n)(要找位置) | O(1)(在表头) | 每次放都要找 |
| 二叉排序树 | 平均 O(log n),最坏 O(n) | 平均 O(log n),最坏 O(n) | 会退化成链 |
| AVL / 红黑树 | O(log n) | O(log n) | 能做到,但每个结点两个指针 + 平衡信息,杀鸡用牛刀 |
| 堆 | O(log n) | 看一眼 O(1),取走 O(log n) | 只用一个数组,n 个元素一次建好只要 O(n) |
堆的思路是:不追求全体有序,只追求“上级比下级大”。这一点点秩序,刚好够让最大的那个永远待在最上面。
规定每个主管都比他手下的人更“紧急”(大根堆),那么老板一定是全公司最紧急的——想找最紧急的,看老板就行。但同级之间从来不比:两个部门经理谁更紧急不知道,一个部门的基层员工和另一个部门的经理谁更紧急也不知道。所以找“最不紧急的”只能把所有基层员工(叶子)挨个看一遍。
哪里不像:真实公司里新来的员工不会几分钟就升成老板;堆里新放进来的元素只要够大,就能一层层“换”上去,一直换到根。
教材定义:n 个关键字的序列 L[1..n],若对 1 ≤ i ≤ ⌊n/2⌋ 都有 L(i) ≥ L(2i) 且 L(i) ≥ L(2i + 1),称为大根堆(大顶堆);把 ≥ 换成 ≤ 就是小根堆。拆开看是三件事:形状是完全二叉树,存法是顺序存储(编号公式 2i、2i + 1、⌊i/2⌋),规则是双亲不小于孩子(堆序)。
跟我做一遍:判断一个序列是不是堆
① 96, 83, 72, 61, 80, 27, 50, 45, 9, 12 ② 96, 83, 72, 80, 61, 50, 27, 9, 45, 12 ③ 96, 72, 83, 45, 61, 50, 27, 9, 12, 80 ④ 96, 80, 83, 45, 61, 72, 27, 9, 12, 50
- 定检查范围(依据:只有分支结点才有孩子):n = 10,⌊10/2⌋ = 5,只要检查 1 ~ 5 号;6 ~ 10 号是叶子,不用管。
- 逐个比孩子:i 号与 2i、2i + 1 号比。①:96 ≥ 83、72;83 ≥ 61、80;72 ≥ 27、50;61 ≥ 45、9;80 ≥ 12 ✓。②④ 同样逐个比,全部满足。
- 找到违规:③ 的 5 号是 61,它的左孩子是 10 号 80,61 < 80 ✗。一处违规就不是堆,答案 ③。
- 顺手检验:② 里 4 号 80 比 5 号 61 大、而 ① 里正好相反——兄弟谁大都可以,这就是“堆不唯一”。
四个序列由程序逐个检查,只有 ③ 违规。
写成代码就是把上面的检查原样搬过去。注意 i = ⌊n/2⌋ 且 n 为偶数时,它只有左孩子:
bool IsMaxHeap(int A[], int n) { // A[1..n] 是不是大根堆 for (int i = 1; i <= n / 2; i++) { // 只查分支结点 1 ~ ⌊n/2⌋ if (A[i] < A[2 * i]) return false; // 左孩子一定存在 if (2 * i + 1 <= n && A[i] < A[2 * i + 1]) return false; // 右孩子可能不存在 } return true; }
时间 O(n),空间 O(1)。判断小根堆把两个 < 换成 >。选择题里还有一个快速排除法:先看 A[1] 和 A[2]——A[1] > A[2] 就只可能是大根堆,A[1] < A[2] 就只可能是小根堆,不用两种都查。
第 k 大在哪里:命题人最爱的“位置题”
“大根堆中第 3 大的元素可能在哪些位置?”“最小的元素在哪?”这类题不用背,靠两条道理就能推出来:
- 道理一:祖先都比我大。第 k 大的元素,比它大的只有 k − 1 个,所以它的祖先最多 k − 1 个,深度 ≤ k。第 1 大只能在根,第 2 大只能在第 2 层,第 3 大在第 2 或第 3 层……
- 道理二:子孙都比我小。放在某个位置的元素要比它子树里的其他元素都大。子树有 s 个结点的位置,放的元素至少比 s − 1 个元素大。最小的元素子树里不能有别人 ⇒ 只能在叶子;第 2 小的子树里最多再有 1 个结点。
① 最小元素可能在哪些下标?② 第 2 小的元素呢?③ 第 3 大的元素可能在第几层?
- 最小(道理二):子树里不能有别人 ⇒ 一定是叶子。⌊100/2⌋ = 50,叶子是 51 ~ 100 号。
- 第 2 小(道理二):子树里最多再有 1 个结点(就是最小的那个)。叶子 51 ~ 100 都可以;还有一个容易漏的位置——50 号!n = 100 是偶数,50 号只有一个左孩子 100 号,子树恰好 2 个结点。只要最小值放在 100 号、第 2 小放在 50 号,堆就成立。答案 50 ~ 100。
- 第 3 大(道理一):深度 ≤ 3,又不可能是根 ⇒ 第 2 层或第 3 层(下标 2 ~ 7)。
② 的“50 号”由程序构造出一个合法的堆实际验证(A[100] = 1、A[50] = 2);全部结论在随机生成的 2 万个堆上检查过。
失效条件:两条道理都要求关键字互不相同。有相等关键字时,“第 k 大”本身就有歧义,题目一般会说“各不相同”。
同一组数,“依次插入”和“建堆”结果不一样
把一组数变成堆有两种做法,真题两种都考过,而且结果往往不同:
- 筛选法建堆(2018-11 “将序列建成大根堆”):先整体放进数组,再从 ⌊n/2⌋ 号往前,逐个向下调整。O(n)。
- 逐个插入(2021-11 “依次插入到初始为空的大根堆”):每来一个放到末尾,再向上调整。最坏 O(n log n)。
筛选法:调整 3、2、1 号
| 调整 | 之后的序列 |
|---|---|
| 初始 | 3 8 1 5 9 2 7 |
| 3 号的 1 | 3 8 7 5 9 2 1 |
| 2 号的 8 | 3 9 7 5 8 2 1 |
| 1 号的 3 | 9 8 7 5 3 2 1 |
比较 8 次(每下沉一层:两个孩子比 1 次,再与较大者比 1 次)。
逐个插入:放末尾、再上浮
| 插入 | 之后的堆 |
|---|---|
| 3、8、1 | 8 3 1 |
| 5 | 8 5 1 3 |
| 9 | 9 8 1 3 5 |
| 2 | 9 8 2 3 5 1 |
| 7 | 9 8 7 3 5 1 2 |
比较 10 次(每上浮一层与双亲比 1 次)。
筛选法:9 8 7 5 3 2 1
逐个插入:9 8 7 3 5 1 2
两种方法的每一步与比较次数由程序按讲义代码(HeadAdjust、HeapInsert)模拟得到。
为什么筛选法更快:筛选法里大多数结点在底层,几乎不用动(8.6 节证明了比较次数 < 4n);逐个插入时后插进来的元素恰恰在底层,可能要一路上浮到根。最坏的输入是递增序列:每个新元素都比全部旧元素大,都要浮到根。n = 1000 时,逐个插入比较 7987 次(= Σ⌊log₂i⌋),筛选法只要 1982 次。
审题:“将序列建成堆”“初始建堆”⇒ 筛选法;“依次插入到初始为空的堆中”⇒ 逐个插入。两种结果对应的选项命题人一定都会放进去。
删除任意一个元素:不一定是“下沉”
优先队列有时要删掉中间的某个元素(进程被撤销),或者改变某个元素的优先级。做法和删除堆顶一样:用末尾元素补到被删的位置,堆长减 1。但接下来要先问一句:补上来的元素比它的新双亲大吗?
- 比双亲大 ⇒ 规则在“上面”被破坏,要向上调整;
- 不比双亲大 ⇒ 只可能比孩子小,向下调整。两种情况只会发生一种。
删除堆顶时没有双亲,所以 8.6 节的代码只有下沉——那是这里的特例。末尾元素来自堆的最底层,但它和被删位置不在同一棵子树里,二者没有大小关系,所以完全可能比新双亲大:
void SiftUp(int A[], int k) { // 大根堆:k 处的元素向上调整 A[0] = A[k]; while (k > 1 && A[k / 2] < A[0]) { // 比双亲大就让双亲下来 A[k] = A[k / 2]; k = k / 2; } A[k] = A[0]; } void HeapDelete(int A[], int *n, int k) { // 删除 A[k](1 ≤ k ≤ *n) A[k] = A[(*n)--]; // 末尾元素补到 k if (k > *n) return; // 删的恰好是末尾,结束 if (k > 1 && A[k] > A[k / 2]) SiftUp(A, k); // 比双亲大:上浮 else SiftDown(A, k, *n); // 否则:下沉(SiftDown 就是 8.6 节的 HeadAdjust) }
时间 O(log n)。代码已编译,在 20 万个随机堆上“删任意位置”后检查堆性质与元素集合,全部正确。拓展 修改优先级同理:大根堆里把某个关键字改大就上浮,改小就下沉。
应用一:k 路归并——k 个有序表合成一个
k 个各自有序的表,共 n 个元素,要合并成一个有序表。二路归并的办法是“比较两个表头,取小的”;k 路时最笨的办法是每次把 k 个表头都比一遍,取一个元素要 k − 1 次比较,总共 O(nk)。用堆:只把每个表当前的第一个元素放进一个大小为 k 的小根堆,堆顶就是全体最小;取走它以后,从同一个表补入下一个元素,再向下调整。
| 步 | 堆中的元素(值 / 来自哪个表) | 取出 | 从同一个表补入 |
|---|---|---|---|
| 1 | 1/L3、2/L1、5/L2 | 1 | 8(L3) |
| 2 | 2/L1、5/L2、8/L3 | 2 | 9(L1) |
| 3 | 5/L2、8/L3、9/L1 | 5 | 6(L2) |
| 4 | 6/L2、8/L3、9/L1 | 6 | 20(L2) |
| 5 | 8/L3、9/L1、20/L2 | 8 | 12(L3) |
| 6 | 9/L1、12/L3、20/L2 | 9 | 15(L1) |
| 7 | 12/L3、15/L1、20/L2 | 12 | L3 空了,堆缩小 |
| 8 | 15/L1、20/L2 | 15 | L1 空了 |
| 9 | 20/L2 | 20 | 全部结束 |
输出 1, 2, 5, 6, 8, 9, 12, 15, 20。注意第 4 步:补进来的不一定是“剩下最小的”,而是“刚被取走的那个表的下一个”——别的表的表头本来就在堆里。
typedef struct { int val, list; } HNode; // 堆里放“值 + 它来自哪个表” void HSift(HNode H[], int k, int n) { // 小根堆向下调整(H[1..n]) HNode t = H[k]; for (int i = 2 * k; i <= n; i *= 2) { if (i < n && H[i + 1].val < H[i].val) i++; // 取较小的孩子 if (t.val <= H[i].val) break; H[k] = H[i]; k = i; } H[k] = t; } // k 个升序数组 L[0..k-1],长度 len[];结果写入 out,返回元素个数 int MergeK(int *L[], int len[], int k, int out[]) { HNode *H = (HNode *)malloc((k + 1) * sizeof(HNode)); int *pos = (int *)calloc(k, sizeof(int)); // pos[j]:表 j 下一个要读的位置 int n = 0, cnt = 0; for (int j = 0; j < k; j++) // 每个非空表的第一个元素入堆 if (len[j] > 0) { H[++n].val = L[j][0]; H[n].list = j; pos[j] = 1; } for (int i = n / 2; i >= 1; i--) HSift(H, i, n); // 建小根堆 O(k) while (n > 0) { int j = H[1].list; out[cnt++] = H[1].val; // 堆顶是当前最小 if (pos[j] < len[j]) H[1].val = L[j][pos[j]++]; // 同一个表补下一个 else H[1] = H[n--]; // 这个表空了:末尾补到堆顶 HSift(H, 1, n); // 向下调整 O(log k) } free(H); free(pos); return cnt; }
复杂度:建堆 O(k);n 个元素每个引起一次 O(log₂k) 的调整,总时间 O(n log₂k),额外空间 O(k)。代码已编译,与“全部倒在一起再排序”的结果在 5 万组随机数据上对拍一致。
8.10 节外部排序的 k 路归并,做的正是这件事。教材在那里用的是败者树而不是堆:堆每下沉一层要比较 2 次(两个孩子先比,再和较大者比),败者树重赛每层只比 1 次,固定 ⌈log₂k⌉ 次。8 路归并时,每输出一个元素:朴素扫描 7 次,堆最多 5 次,败者树 3 次(程序实测)。所以“k 路归并用什么结构”的选择题,内部排序语境答堆或败者树都对;问外部排序,答败者树。
应用二:凡是“反复取最小”的算法,都能用堆加速
回头看全书,有一批算法的骨架完全一样:每一步从一堆候选里挑出最小(最大)的那个,处理后候选集合又有变化。凡是这个骨架,都可以把候选放进堆里:
| 算法 | 每一步挑什么 | 朴素实现 | 用堆实现 | 什么时候值得 |
|---|---|---|---|---|
| 哈夫曼(5.9) | 根的权最小的两棵树 | 每次扫描,O(n²) | 小根堆,O(n log n) | 叶子多时 |
| Prim(6.6) | 离生成树最近的顶点 | lowcost 数组,O(n²) | O(e log n) 拓展 | 稀疏图 |
| Dijkstra(6.7) | dist 最小的未确定顶点 | O(n²) | O((n + e) log n) 拓展 | 稀疏图 |
| Kruskal(6.6) | 权最小的未选边 | 先全排序 O(e log e) | 建堆 O(e),每取一条 O(log e) | 选够 n − 1 条边就能提前停 |
| Top-k(8.6) | 候选里最大的(守门员) | 全排序 O(n log n) | 大小为 k 的堆,O(n log k) | k 远小于 n |
| k 路归并(上一段) | k 个表头里最小的 | O(nk) | O(n log k) | k 较大 |
| 置换-选择(8.10) | 工作区中 ≥ MINIMAX 的最小记录 | 扫描工作区 O(w) | 堆或败者树 O(log w) | 工作区大 |
“稀疏图用堆,稠密图用数组”:拿 Dijkstra 算一笔账。n = 10⁴ 个顶点、e = 3 × 10⁴ 条边的稀疏图:数组版 n² = 1.0 × 10⁸,堆版 (n + e)log₂n ≈ 5.3 × 10⁵,快了约 200 倍;可若是完全图,e ≈ 5 × 10⁷,堆版 ≈ 6.6 × 10⁸,反而比数组版慢。所以教材的 O(n²) 写法并不过时——稠密图上它就是最好的。(数字由程序计算。)
应用三:Top-k 与“第 k 小”——三种做法怎么选
2022-42 问的就是“从 n(n > 100000)个数中找最小的 10 个”。8.6 节给了标准答案(大小为 k 的大根堆),这里把几种做法放在一起比,答题时能说清“为什么选它”:
| 做法 | 时间 | 额外空间 | n = 10⁶、k = 10 随机数据的比较次数 | 特点 |
|---|---|---|---|---|
| 全部排序后取前 k 个 | O(n log n) | 视排序而定 | 约 2 × 10⁷(n log₂n 量级) | 做了大量无用功 |
| 大小为 k 的大根堆(守门员) | O(n log k) | O(k) | 实测 1,000,464 ≈ 1.0 n;最坏(输入递减)不超过 7n | 可以边读边做,数据放不进内存也行 |
| 整体建小根堆,再删 k 次堆顶 | O(n + k log n) | O(1)(原地) | 实测 ≈ 1.88 n(几乎全花在建堆上) | 要求全部数据都在数组里 |
| 快速选择(快排划分,8.5) | 平均 O(n),最坏 O(n²) | O(1) ~ O(log n) | 实测 ≈ 1.86 n | 要改动原数组;最坏会退化 |
记法:找最小的 k 个用大根堆,找最大的 k 个用小根堆——堆顶守着“候选里最容易被淘汰的那个”。2022-42 特意问“平均情况下比较次数尽可能少”,满分答法就是第二行:随机数据里越往后,新数比堆顶还小的概率越低(上例 100 万个数只换了 98 次堆顶),绝大多数元素只和堆顶比 1 次就被淘汰,平均比较次数约等于 n。(三行实测由程序 p5_heap_topk.py 按讲义的计数约定统计。)
拓展 应用四:动态中位数——两个堆背靠背
数据一个个读进来,每读一个都要报告“当前的中位数”。每次排序太慢。办法:用一个大根堆放较小的一半,一个小根堆放较大的一半,并保持两堆个数相差不超过 1(约定大根堆多 1 个)。两个堆顶一左一右夹住中间位置:总数为奇数时中位数是大根堆的堆顶,偶数时是两个堆顶的平均值。
| 读入 | 放进哪个堆(≤ 大根堆顶就进大根堆) | 需要挪一个吗 | 较小的一半 | 较大的一半 | 中位数 |
|---|---|---|---|---|---|
| 5 | 大根堆(空) | — | 5 | — | 5 |
| 15 | 15 > 5,进小根堆 | — | 5 | 15 | 10 |
| 1 | 大根堆 | — | 1 5 | 15 | 5 |
| 3 | 大根堆 | 大根堆多 2 个:挪堆顶 5 到小根堆 | 1 3 | 5 15 | 4 |
| 8 | 8 > 3,进小根堆 | 小根堆多 1 个:挪堆顶 5 回来 | 1 3 5 | 8 15 | 5 |
| 7 | 7 > 5,进小根堆 | — | 1 3 5 | 7 8 15 | 6 |
| 9 | 小根堆 | 挪堆顶 7 到大根堆 | 1 3 5 7 | 8 9 15 | 7 |
| 10 | 小根堆 | — | 1 3 5 7 | 8 9 10 15 | 7.5 |
每读一个数最多做一次插入、一次删除堆顶、一次插入,O(log n)。这是“堆 + 堆”的组合,408 还没考过,但它把大、小根堆的用法都练了一遍,作为 41 题的变形完全可能出现。(逐步结果由程序模拟。)
堆、二叉排序树、平衡树、有序表:谁擅长什么
| 操作 | 堆 | 二叉排序树(平均 / 最坏) | AVL / 红黑树 | 有序顺序表 |
|---|---|---|---|---|
| 取最大(看一眼) | O(1) | O(log n) / O(n) | O(log n) | O(1) |
| 删除最大 | O(log n) | O(log n) / O(n) | O(log n) | O(1)(在表尾) |
| 插入 | O(log n) | O(log n) / O(n) | O(log n) | O(n) |
| 按关键字查找任意元素 | O(n) | O(log n) / O(n) | O(log n) | O(log n)(折半) |
| 按序输出全部 | O(n log n)(即堆排序) | O(n)(中序) | O(n) | O(n) |
| 一次建好 | O(n) | O(n log n) / O(n²) | O(n log n) | O(n log n)(排序) |
| 存储 | 一个数组,没有指针 | 每结点 2 个指针 | 2 个指针 + 平衡因子 / 颜色 | 一个数组 |
一句话:只关心“最值”⇒ 堆;还要按关键字查找、按序输出 ⇒ 平衡树。堆用“只保持上下有序”换来了 O(n) 建立和没有指针的紧凑存储,代价是查找任意元素退化为 O(n)。
真题怎么考,以及 2027 可能怎么考
| 考法 | 真题 | 抓手 | 失效 / 易错 |
|---|---|---|---|
| 插入后的堆、比较次数 | 2009-09、2011-11 | 放末尾,沿到根的路径上浮,每层比 1 次 | 小根堆、大根堆别看反 |
| 删除堆顶后的堆、比较次数 | 2015-10、2024-09 | 末尾补堆顶,下沉每层比 2 次(只有一个孩子时 1 次) | 删两次要在第一次的结果上继续 |
| 建堆过程 | 2018-11 | 从 ⌊n/2⌋ 往前逐个下沉 | 与“依次插入”区分 |
| 依次插入得到的堆 | 2021-11 | 每个都放末尾再上浮 | 与“建堆”区分 |
| 性质辨析 | 2020-09 | 完全二叉树、可顺序存储、次大在第 2 层;不是 BST | — |
| Top-k 设计题 | 2022-42 | 大小为 k 的大根堆,O(n log k) | “最小的 k 个”用大根堆 |
2027 值得准备的新角度(考纲单列后,命题人会更多地考“应用”而不只是“操作”):
- 位置题:第 k 大 / 最小 / 第 2 小可能在哪些下标 → 两条道理。
- 删除中间元素、修改优先级:先和双亲比,决定上浮还是下沉。
- 选结构:哪种结构最适合实现优先队列?哪些算法能用堆优化?k 路归并、Top-k 的时间复杂度?
- Ⅰ/Ⅱ/Ⅲ 组合判断:把性质速查里的 6 条混进几条错误说法(“堆的中序序列有序”“最小值在最后一个位置”“n 个元素逐个插入建堆是 O(n)”)。
- 41 题:判断数组是否为堆、用堆实现优先队列的插入与删除、合并 k 个有序表——本节和 8.6 节的代码都是现成的答案骨架。
- 堆只规定“双亲 ≥ 孩子”,不规定“左 < 右”:堆的中序遍历不有序,也不能像 BST 那样按关键字查找。
- 大根堆里找最小值是 O(n)(扫描全部叶子),不是 O(1),也不是 O(log n)。
- “依次插入建堆”与“筛选法建堆”结果可能不同,复杂度也不同(O(n log n) vs O(n))。
- 删除中间元素时,补上来的末尾元素可能比新双亲大,要上浮,不能只会下沉。
- 下标从 0 开始时:孩子 2i + 1、2i + 2,双亲 ⌊(i − 1)/2⌋,最后一个分支结点 ⌊n/2⌋ − 1(8.6)。
- 小根堆 1, 10, 2, 12, 15, 3, 4, 20, 25, 30, 18, 5 中删除 5 号位置的 15,写出结果,并说明做的是上浮还是下沉、比较了几次。
答案
末尾的 5 补到 5 号;它的双亲 2 号是 10,5 < 10,对小根堆来说规则在上面被破坏 ⇒ 上浮:与 10 交换(第 1 次比较),再与根 1 比(第 2 次),1 < 5 停。结果 1, 5, 2, 12, 10, 3, 4, 20, 25, 30, 18,比较 2 次。(程序验证。) - 30 个互不相同的元素组成大根堆。第 4 大的元素可能在第几层?最小的元素可能在哪些下标?
答案
第 4 大深度 ≤ 4 且不是根 ⇒ 第 2 ~ 4 层。最小的在叶子:⌊30/2⌋ = 15,下标 16 ~ 30。 - 8 个有序表共 8000 个元素,用大小为 8 的小根堆做 8 路归并。时间复杂度是多少?和每次扫描 8 个表头的做法比,比较次数大约差多少?
答案
O(n log₂k) = O(8000 × 3)。8 个元素的堆一次下沉最多比较 5 次(1 号、2 号各两次,4 号只有一个孩子一次),总共不超过 40000 次;每次扫描表头要比 7 次,共 56000 次。若用败者树,每次只要 ⌈log₂8⌉ = 3 次,共 24000 次——这就是外部排序选败者树的原因。(程序实测。)
考纲补充(2025 新单列):堆及其应用
“堆及其应用”2025 年起在大纲中单列。下面两道按新考纲自编,考的是“位置”和“删除中间元素”这两个以前真题没碰过的角度。
Ⅰ. 堆的中序遍历序列是递减的 Ⅱ. 最小元素一定在下标 ⌊n/2⌋ + 1 ~ n 之中
Ⅲ. 第 3 大的元素一定在第 2 层 Ⅳ. 同一组关键字“依次插入初始为空的堆”与“用筛选法建堆”得到的堆一定相同
- A. 仅 Ⅱ、Ⅲ
- B. 仅 Ⅰ、Ⅱ
- C. 仅 Ⅱ、Ⅳ
- D. 仅 Ⅱ
展开解析
DⅠ 错:堆只规定双亲 ≥ 孩子,左右孩子没有大小关系,中序遍历不有序(这是堆和 BST 的区别)。Ⅱ 对:最小元素的子树里不能有别的元素,只能是叶子,叶子的下标正是 ⌊n/2⌋ + 1 ~ n。Ⅲ 错:第 3 大的祖先只能是第 1、2 大,所以它在第 2 层或第 3 层——它可以是第 2 大的孩子,例如 7, 3, 6, 1, 2, 4, 5 中第 3 大的 5 在 7 号位置(第 3 层)。Ⅳ 错:3, 8, 1, 5, 9, 2, 7 用筛选法得 9, 8, 7, 5, 3, 2, 1,依次插入得 9, 8, 7, 3, 5, 1, 2。
变化点:2020-09 考的是“次大一定在第 2 层”(对);换成“第 3 大”就不对了。见 5.11 位置题、两种建堆。(反例由程序枚举。)
- A. 98, 80, 90, 66, 79, 70, 65, 21, 60, 50, 12
- B. 98, 80, 90, 60, 79, 70, 65, 21, 66, 50, 12
- C. 98, 80, 90, 66, 79, 70, 65, 60, 21, 50, 12
- D. 98, 80, 90, 60, 79, 66, 65, 21, 50, 12, 70
展开解析
A末尾的 66 补到 9 号,它的双亲是 4 号 60,66 > 60 ⇒ 规则在上面被破坏,要上浮:66 与 60 交换(第 1 次比较),再与 2 号 80 比(第 2 次),66 < 80,停。结果 A。B 只补位不调整,60 < 66 不是堆;C、D 虽然也是合法的大根堆,但不是这个算法会得到的结果(C 把 60 挪到了 8 号,D 改动了 6 号以后的位置)。
变化点:删除堆顶只会下沉;删除中间元素要先和双亲比,可能上浮。见 5.11 删除任意元素。(程序模拟验证。)
5.12本篇一页总结与必会题型
考前一天只看这一页:左列是板块,中间是必须背下来的结论,右列是“遇到题怎么下手”。每一行都能点回对应小节复习。
| 板块 | 必背结论 | 下手方法 |
|---|---|---|
| 树的性质 | n = 度数和 + 1;n₀ = 1 + n₂ + 2n₃ + …(森林把 1 换成棵数);m 叉树第 i 层 ≤ mi−1,高 h 最多 (mh − 1)/(m − 1) 个;最小高度 ⌈logm(n(m − 1) + 1)⌉ | 数边;高度题列“h → 最多结点数”表 |
| 二叉树 | n₀ = n₂ + 1;完全二叉树 n₀ = ⌈n/2⌉、n₁ ∈ {0, 1}(n 偶数时为 1);高度 ⌈log₂(n + 1)⌉ = ⌊log₂n⌋ + 1;n 个结点的形态数 Cₙ = C(2n, n)/(n + 1):1, 1, 2, 5, 14, 42 | “某层 k 个叶子”:最多 ⇒ 倒数第二层,最少 ⇒ 最后一层 |
| 存储 | 从 1:孩子 2i、2i + 1,双亲 ⌊i/2⌋;从 0:2i + 1、2i + 2、⌊(i − 1)/2⌋;二叉链表空指针 n + 1,三叉链表 n + 2 | 忘了公式就“整体平移 1”现推 |
| 遍历 | 先 / 中 / 后序 = 第 1 / 2 / 3 次经过;非递归先序、中序进出栈相同;后序非递归访问 x 时栈中恰是 x 的祖先;层序用队列;时间 O(n)、空间 O(h) | 分解法写序列;先序 = 中序 ⇔ 无左孩子;先序、后序相反 ⇔ 单支 |
| 构造 | 中序 + 任一种(先 / 后 / 层)可唯一确定;先 + 后不行,相容的树有 2单孩子结点数 棵 | 先序找最前、后序找最后、层序找最先出现的当根,中序分左右 |
| 线索 | 左空指前驱、右空指后继;中序线索树前驱后继都能找,先序找前驱、后序找后继要双亲;NULL 线索:中序 2 个,先序 / 后序 1 个(根缺左 / 右孩子时 2 个) | 先写出对应遍历序列,再逐个空指针填前后邻居 |
| 树的存储 | 双亲(找双亲 O(1),并查集用);孩子(找孩子快,同邻接表);孩子兄弟(二叉链表,转换用) | 问“哪种操作快”看指针方向 |
| 转换 | 左孩子右兄弟;树先根 = 二叉先序,树后根 / 森林中序 = 二叉中序;左空 ⟺ 叶子,右空数 = 分支结点数 + 1,根的右链长 = 森林棵数 | 能画就画出二叉树再数 |
| 哈夫曼 | 结点数 2n − 1、无度 1;WPL = 非叶结点权之和;平均码长 = WPL ÷ 总频次;m 叉补 (m − 1) − (n − 1) mod (m − 1) 个 0(余数为 0 不补) | 每步重新挑当前最小的;WPL 用加法快算 |
| 并查集 | S[x] 存双亲,根存负数;Union 前先 Find;按大小 / 高度合并 ⇒ 树高 ≤ ⌊log₂n⌋ + 1;再加路径压缩 ⇒ 每次近似 O(1) | 连通分量 = 根的个数;边两端同根 ⇒ 成环 |
| 堆及其应用 | 完全二叉树 + 顺序存储 + 双亲 ≥ 孩子;次大在第 2 层、最小在叶子、第 k 大深度 ≤ k;筛选法建堆 O(n) ≠ 依次插入 O(n log n);删中间元素先比双亲;k 路归并 / Top-k O(n log k) | 判堆只查 1 ~ ⌊n/2⌋;位置题用“祖先都比我大、子孙都比我小” |
本篇必会题型清单(做完打勾)
| # | 题型 | 代表真题 | 小节 |
|---|---|---|---|
| 1 | 已知各种度的结点数求叶子数;森林的结点、边、棵数 | 2010-05、2016-05 | 5.1 |
| 2 | m 叉树的最小高度;正则 k 叉树的结点数(大题) | 2022-04、2016-42 | 5.1 |
| 3 | 完全二叉树由 n 求叶子数;某层 k 个叶子求结点数 | 2011-04、2009-05 | 5.2 |
| 4 | 顺序存储需要的单元数、数组能否表示二叉树 | 2020-03、2025-03 | 5.3 |
| 5 | 判断遍历方式、由序列推树的形态 | 2009-03、2011-05、2017-04 | 5.4 |
| 6 | 树形已知按序列填值;序列中相邻结点的关系 | 2017-05、2023-05、2022-03、2024-03 | 5.4 |
| 7 | 由两种序列构造二叉树、再求第三种序列 | 2026-03、2020-04 | 5.5 |
| 8 | 先序固定的二叉树个数(卡特兰数) | 2015-02 | 5.2 |
| 9 | 线索指向谁、哪种线索树找不到前驱 / 后继 | 2010-03、2013-05、2014-04 | 5.6 |
| 10 | 森林 ↔ 二叉树的计数(左空、右空、右链、高度) | 2011-06、2014-05、2021-04、2026-04 | 5.8 |
| 11 | 哈夫曼 WPL、码长、平均码长、前缀码判断与译码 | 2021-05、2023-04、2014-06、2020-42 | 5.9 |
| 12 | m 叉哈夫曼树补虚叶子 | 2013-04 | 5.9 |
| 13 | 大题:二叉树递归算法(WPL、表达式树、顺序存储判 BST) | 2014-41、2017-41、2022-41 | 9.3 |
| 14 | 大题(预测):并查集求连通分量 / 判环 | — | 5.10 |
| 15 | 堆:插入 / 删除 / 建堆过程与比较次数;判堆、位置题;k 路归并与 Top-k | 2009-09、2015-10、2018-11、2021-11、2024-09、2022-42 | 5.11 |
- 一棵二叉树有 50 个叶结点、30 个度为 1 的结点,共有多少个结点?
答案
n₂ = n₀ − 1 = 49,n = 50 + 30 + 49 = 129。 - 已知后序序列 D A B C、中序序列 D A C B,画出这棵二叉树。
答案
根是后序最后一个 C;中序 D A | C | B:左子树 {D, A},右子树 {B}。左子树后序 D A ⇒ 根 A,中序 D A ⇒ D 是 A 的左孩子。二叉树为 C(A(D, ), B)。 - 一棵有 n 个结点的树,转换成二叉树后,根结点有没有右子树?
答案
没有。树的根没有兄弟。只有森林(多于一棵树)转换得到的二叉树,根才有右子树。 - 6 个字符的频率为 2, 3, 5, 7, 11, 13,求哈夫曼编码的 WPL 与平均码长。
答案
合并:2 + 3 = 5;5 + 5 = 10;7 + 10 = 17;11 + 13 = 24;17 + 24 = 41。WPL = 5 + 10 + 17 + 24 + 41 = 97,平均码长 97 / 41 ≈ 2.37。 - 用并查集判断下列边加入后是否出现环:(1,2)、(2,3)、(4,5)、(3,1)。
答案
(1,2)、(2,3) 合并后 1、2、3 同一集合;(4,5) 另成一集合;(3,1) 两端 Find 得到同一个根 ⇒ 出现环。
5.✎本篇真题闯关(45 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
图:多对多的关系,以及遍历、最小生成树、最短路径、拓扑排序、关键路径
线性表是一对一,树是一对多,图是多对多——任意两个顶点之间都可能有边。地图上的城市和公路、网络里的路由器和链路、课程之间的先修关系、工程里的工序,都是图。这一篇的内容按“存得下 → 走得遍 → 算得出”展开:先学概念和存储(邻接矩阵、邻接表等),再学两种遍历(BFS、DFS),最后是五个经典应用:最小生成树、最短路径、有向无环图表示表达式、拓扑排序、关键路径。选择题 33 道,大多是“给一张图,手工执行某个算法”;大题常考手算 Prim / Dijkstra / 关键路径,以及在邻接矩阵上写算法(2021-41、2023-41、2024-41)。
6.0本篇地图:存得下 → 走得遍 → 算得出
图是数据结构里大题最多的一篇:2009—2026 年 33 道选择题、10 道大题(手算 Prim / Dijkstra / 关键路径,或在邻接矩阵上写算法)。主线分三段:先学概念和存储,再学两种遍历,最后是五个应用。下图右边的条形是每一节挂的选择题数,★ 表示还有大题。
| 小节 | 学完要会的事 | 相关大题 | 难度 | 学时 |
|---|---|---|---|---|
| 6.1 概念 | 完全图、连通、强连通、度的计算;生成树与连通分量的“极大 / 极小” | — | ★★ | 1.5 h |
| 6.2 矩阵 / 邻接表 | 两种存储的空间与各操作代价;在邻接矩阵上写算法 | 2021-41、2023-41、2015-42 | ★★ | 1.5 h |
| 6.3 十字链表等 | 读懂结点结构,沿链数度;FirstNeighbor / NextNeighbor | — | ★ | 1 h |
| 6.4 BFS | 队列逐步执行、判断 BFS 序列、无权图最短路 | — | ★★ | 1.5 h |
| 6.5 DFS | 递归 / 非递归、判断 DFS 序列、判连通与判环 | — | ★★ | 1.5 h |
| 6.6 最小生成树 | Prim、Kruskal 手算;唯一性判断 | 2017-42、2018-42 | ★★ | 1.5 h |
| 6.7 最短路径 | Dijkstra 表格法、Floyd 矩阵法;各算法适用条件 | 2009-41、2014-42 | ★★★ | 2 h |
| 6.8 DAG 表达式 | 最少顶点数 | — | ★ | 0.5 h |
| 6.9 拓扑排序 | 手算序列与个数、唯一性、入度法代码 | 2024-41 | ★★ | 1.5 h |
| 6.10 关键路径 | ve / vl / e / l 表格;关键活动;缩短工期判断 | 2011-41、2025-42 | ★★★ | 2 h |
- 6.1 ~ 6.3 一口气读完,重点是 6.2 两种存储的代价表——后面每个算法的复杂度都从它推出来。
- 6.4 BFS、6.5 DFS 必须能不看书写出代码,并能对着一张图手工写出序列(会用栈 / 队列模拟)。
- 应用部分每个算法都按“看懂逐步表 → 自己在纸上重做一遍 → 用小工具核对”的顺序练;大题主要考手算过程,表格要写规范。
- 第二遍看篇末 6.11 的一页总结,再做本篇真题闯关和第 9 篇 t4、t5 大题。
6.1图的基本概念
为什么需要“图”:多对多的关系
树里每个结点只有一个双亲;可现实中很多关系是多对多的:一个城市和好几个城市之间都有公路,一门课可能有多门先修课,一个网页被很多网页链接也链接了很多网页。这种“谁和谁有关系”没有层次、也可能绕圈,就要用图来描述。
车站是顶点,两站之间的轨道是边;某站连出几条轨道就是它的度;从一站坐到另一站经过的站点序列是路径;绕环线一圈回到原站是回路;若每段轨道上标了行车时间,就是带权图(网);单行线就是有向边(弧)。
哪里不像:地铁图通常是连通的、而且同一对站之间可能有两条线路(多重边);数据结构讨论的图可以不连通,但默认是简单图——没有重复的边,也没有自己连自己的边。
| 线性表(第 2 篇) | 树(第 5 篇) | 图(本篇) | |
|---|---|---|---|
| 元素之间的关系 | 一对一:每个元素至多一个前驱、一个后继 | 一对多:至多一个双亲,可以有多个孩子 | 多对多:任意两个顶点之间都可能有边 |
| 有没有“第一个” | 有(表头) | 有(根) | 没有,任何顶点都可以当起点 |
| 能不能绕圈 | 不能(循环链表是存储上的技巧) | 不能(有回路就不是树) | 可以有回路 |
| 遍历 | 从头走到尾 | 先序 / 中序 / 后序 / 层序 | DFS / BFS,要 visited[] 防止重复 |
从左到右,关系越来越自由,能描述的东西越来越多,算法也越来越难——图的遍历比树多出来的那个 visited[],正是为了对付“绕圈”。树其实是“连通、没有回路”的特殊的图(n 个顶点、n − 1 条边)。
定义与术语
图 G 由顶点集 V 和边集 E 组成,记为 G = (V, E)。|V| 是顶点个数(也叫图的阶),|E| 是边数。线性表可以是空表、树可以是空树,但图不能是空图:V 一定非空,E 可以为空(只有顶点没有边)。
| 概念 | 定义与要点 |
|---|---|
| 简单图、多重图 | 没有重复边、没有自己到自己的边(自环)的图叫简单图。数据结构只讨论简单图。 |
| 完全图 | 任意两个顶点之间都有边:无向完全图有 n(n−1)/2 条边;有向完全图任意两顶点间有方向相反的两条弧,共 n(n−1) 条。 |
| 子图、生成子图 | V′ ⊆ V、E′ ⊆ E 且 E′ 的边的端点都在 V′ 中,G′ 是 G 的子图;V′ = V 的子图叫生成子图。 |
| 连通、连通分量 | 无向图中 v 到 w 有路径,则 v、w 连通;任意两顶点都连通的叫连通图;无向图的极大连通子图叫连通分量。 |
| 强连通、强连通分量 | 有向图中 v 到 w、w 到 v 都有路径,称强连通;任意一对顶点都强连通的叫强连通图。 |
| 生成树、生成森林 | 连通图的生成树是包含全部顶点的一个极小连通子图,有 n − 1 条边:砍掉一条就不连通,加一条就出现回路。非连通图各连通分量的生成树组成生成森林。 |
| 度 | 无向图中顶点的度 TD(v) = 依附于它的边数,全部顶点度之和 = 2|E|(每条边贡献 2)。有向图中度 = 入度 ID + 出度 OD,全部入度之和 = 全部出度之和 = |E|。 |
| 边的权、网 | 边上带数值(距离、费用)叫权,带权的图叫网。 |
| 稠密图、稀疏图 | 边很少(如 |E| < |V| log|V|)为稀疏图,反之为稠密图。只是相对的说法。 |
| 路径、路径长度、回路 | 顶点序列 v, …, w,相邻两个之间有边;路径长度 = 边的条数。第一个和最后一个顶点相同的路径叫回路(环)。 |
| 简单路径、简单回路 | 顶点不重复出现的路径叫简单路径;除首尾外其余顶点不重复的回路叫简单回路。回路不是简单路径(首尾重复了,2011-08)。 |
| 距离 | u 到 v 的最短路径长度;不存在路径时记为 ∞。 |
| 有向树 | 一个顶点入度为 0、其余顶点入度都为 1 的有向图。 |
有向图的强连通分量:互相能到才算一伙
判别方法:两个顶点在同一个强连通分量 ⟺ 它们互相有路径。手算时先找出所有回路,把有公共顶点的回路合并成一块,剩下不在任何回路上的顶点各自单独成一个分量。强连通分量把有向图“缩”成一个 DAG(上图缩成 {a,b,c} → {d,e} → {f}),这也是 6.9 节拓扑排序只对 DAG 有意义的原因。
边数、连通性的计算:每条结论都来自一个“极端图”
这类题看起来要背很多公式,其实只有两个工具:握手定理(每条边贡献 2 个度)和构造极端情况(问“至少 / 至多 / 保证”时,想想最坏的图长什么样)。
| 问题(n 个顶点) | 答案 | 为什么(极端图) |
|---|---|---|
| 无向完全图的边数 | n(n − 1)/2 | 每个顶点连 n − 1 条,每条边被两端各数一次 |
| 有向完全图的弧数 | n(n − 1) | 每对顶点之间两个方向各一条 |
| 无向图连通,至少几条边 | n − 1 | 树:少一条就断开 |
| 无向图不连通,最多几条边 | (n − 1)(n − 2)/2 | n − 1 个顶点两两相连(完全图),剩一个顶点孤立 |
| 保证任何情况下都连通,至少几条边 | (n − 1)(n − 2)/2 + 1 | 比“不连通的最多边数”多 1 条,就再也摆不出不连通的图 |
| 有向图强连通,至少几条弧 | n | 所有顶点围成一个有向环 |
| 拓展 保证强连通,至少几条弧 | (n − 1)² + 1 | 不强连通的最多:n − 1 个顶点的有向完全图 + 新顶点只向它们发出 n − 1 条弧 |
| 度与边 | Σ度 = 2e(有向:Σ入度 = Σ出度 = e) | 每条边给两端各加 1 个度 |
n = 5:前 4 个顶点用掉 C(4, 2) = 6 条边,顶点 5 仍孤立
第 7 条边无论怎么放,都只能连到顶点 5 ⇒ 连通
概念辨析题的“反例库”:四张小图打天下
“下列说法正确的是”这类题(2009-07、2025-06、2026-07……),最快的办法不是去证明,而是拿几张典型的小图去试,一个反例就能否定一个选项。下面四张图最常用:
① 环(每个顶点度 2)
② 星形(一棵树)
③ 两个三角形(不连通)
④ 完全图 K₄
| 说法 | 对 / 错 | 用哪张图判断 |
|---|---|---|
| 连通图中一定有度为 1 的顶点 | 错 | ① 环:每个顶点度都是 2 |
| 每个顶点的度都 ≥ 2 的无向图一定连通 | 错 | ③ 两个三角形 |
| 每个顶点的度都 ≥ 2 的无向图一定有回路 | 对 | 沿边一直走,走不出去也停不下来,必然重复顶点(2025-06) |
| n 个顶点、n − 1 条边的无向图一定是树 | 错 | 一个三角形 + 一个孤立点:4 个顶点 3 条边,不连通 |
| n 个顶点、n − 1 条边的连通图一定是树 | 对 | ② 星形就是例子;连通 + n − 1 条边 ⇒ 没有回路 |
| 边数 ≥ n 的无向图一定有回路 | 对 | 森林最多 n − 1 条边 |
| 无向完全图一定是连通图,连通图不一定是完全图 | 对 | ④ 与 ① |
各图的度、连通分量数已用程序核对。
连通分量与生成树:一个“极大”,一个“极小”
| 概念 | “极大”还是“极小” | 含义 | 边数 |
|---|---|---|---|
| 连通分量 | 极大连通子图 | 再加入任何一个顶点(和它的边)就不连通了:顶点、边都尽量多 | 子图里原有的边全保留 |
| 强连通分量 | 极大强连通子图 | 有向图里的“连通分量” | — |
| 生成树 | 极小连通子图(含全部顶点) | 再删掉任何一条边就不连通了,再加上任何一条边就形成回路:边恰好够用 | n − 1 |
| 生成森林 | 非连通图各连通分量的生成树合在一起 | 每个分量各一棵 | n − 分量数 |
由此可推出一个好用的结论:n 个顶点、k 个连通分量的无向图至少有 n − k 条边;若边数恰好是 n − k,它就是一个森林(没有回路)。
- n 个顶点的无向图要连通至少 n − 1 条边(恰为 n − 1 条且连通就是一棵树)。所以 |E| < |V| − 1 时一定不连通(2022-06)。
- n 个顶点的无向图边数 > (n − 1)(n − 2)/2 时一定连通:前 n − 1 个顶点两两相连最多用掉 (n − 1)(n − 2)/2 条边而第 n 个顶点仍孤立;再多一条就必须连到第 n 个顶点。所以“保证在任何情况下都连通”至少需要 (n − 1)(n − 2)/2 + 1 条(2010-07:n = 7 时是 16)。
- n 个顶点的有向图要强连通至少 n 条弧(围成一个环)。
- n 个顶点、边数 > n − 1 的无向图一定有回路;n 个顶点的无向图若每个顶点的度都 ≥ 2,一定有回路(2025-06)。
- 无向连通图的度之和为偶数(= 2|E|),但不一定有度为 1 的顶点(例如环),边数也可以恰好是 n − 1(2009-07)。
跟我做一遍:由度推顶点数
无向图 G 有 20 条边,其中度为 4 的顶点 4 个、度为 3 的顶点 6 个,其余顶点的度都小于 3。G 至少有多少个顶点?
- 握手定理:所有顶点的度之和 = 2 × 20 = 40。
- 已知部分:4 × 4 + 6 × 3 = 16 + 18 = 34,还剩 40 − 34 = 6 个度要由“其余顶点”提供。
- 求至少:其余顶点每个度 ≤ 2,要让顶点最少,就让每个都取 2 ⇒ 至少 6 ÷ 2 = 3 个。
- 合计至少 4 + 6 + 3 = 13 个顶点。(程序用 Havel–Hakimi 方法验证了度序列 4,4,4,4,3,3,3,3,3,3,2,2,2 确实能画成简单图,所以 13 能取到。)
2017-07 同型:16 条边、3 个度 4、4 个度 3 ⇒ 32 − 24 = 8 ⇒ 至少 4 个度 2 的顶点,共 11 个。
- A. 10
- B. 11
- C. 13
- D. 15
展开解析
B
- 度之和 = 2 × 16 = 32。
- 度为 4 的 3 个、度为 3 的 4 个,共贡献 12 + 12 = 24,还差 8 度。
- 其余顶点度都 < 3,即最多 2。要顶点数最少,每个都取 2:至少 8 / 2 = 4 个。
- 总数至少 3 + 4 + 4 = 11。
- A 错10 个顶点时剩下 3 个顶点最多贡献 6 度,凑不够 8。
- B 对11。
- C 错13 个顶点可行(剩下的 8 度分给 6 个顶点,如 2、2、1、1、1、1),但不是最少。
- D 错15 = 3 + 4 + 8:剩下 8 度让每个顶点只分 1 度,顶点就多了;求“至少”要让每个顶点取允许的最大度 2。
“顶点数至少 / 至多”:用度之和 = 2|E| 列式,剩余的度让其他顶点取最大 / 最小允许值。
真题怎么考
- 保证连通的最少边数(2010-07)、边数与连通性(2022-06):画极端图。
- 度与顶点数(2017-07):握手定理。
- 概念辨析(2009-07 连通图的性质、2011-08 回路不是简单路径、2025-06 每个顶点度 ≥ 2 必有回路、2026-07):举反例最快——环、星形图、两个三角形是常用的反例图。
- 图不能是空图(顶点集非空),但边集可以为空。
- “连通分量”是极大连通子图,“生成树”是极小连通子图——说反了就错。
- 强连通是有向图的概念;有向图中“连通”通常指弱连通(忽略方向后连通),题目里要看清。
- 路径长度是边数(或带权图的权值和),不是顶点数。
- 10 个顶点的无向图,要保证它在任何情况下都连通,至少需要几条边?
答案
不连通时最多 C(9, 2) = 36 条(9 个顶点完全图 + 1 个孤立点),所以至少 37 条。 - 有 12 个顶点、3 个连通分量的无向图至少有几条边?边数恰好是这个值时,图有什么特点?
答案
至少 12 − 3 = 9 条;恰好 9 条时每个分量都是树,整个图是一个森林(没有回路)。 - 6 个顶点的有向图要成为强连通图,至少需要几条弧?
答案
6 条:6 个顶点围成一个有向环。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 7
- B. 21
- C. 22
- D. 28
展开解析
C最坏情况:7 个顶点构成完全图(C(7, 2) = 21 条边),第 8 个顶点孤立,此时仍不连通。再加 1 条边,无论怎么加都会连上孤立点,所以需要 21 + 1 = 22 条。
变化点:原题 7 个顶点:C(6, 2) + 1 = 16。注意与“n 个顶点的连通图至少有几条边”(n − 1,A)区分:一个问“可能连通”,一个问“保证连通”。
- A. 10
- B. 12
- C. 13
- D. 14
展开解析
D度之和 = 2 × 20 = 40。已知顶点贡献 4×4 + 3×4 = 28,还差 12。其余顶点度 ≤ 2,要顶点最少就让每个都取 2:12 ÷ 2 = 6 个。总数 4 + 4 + 6 = 14。
变化点:原题 16 条边:32 − 24 = 8,8 ÷ 2 = 4,共 11 个。方法永远是握手定理:度数之和 = 2 × 边数。
- A. n − 1
- B. n
- C. n + 1
- D. 2(n − 1)
展开解析
Bn 个顶点连成一个有向环(v1→v2→…→vn→v1),任意两点互相可达,共 n 条弧;少于 n 条时必有某个顶点入度或出度为 0,不可能强连通。
变化点:对比记:无向连通图至少 n − 1 条边(一棵树);有向强连通图至少 n 条弧(一个环);无向完全图 n(n−1)/2 条边;有向完全图 n(n−1) 条弧。
6.2图的存储:邻接矩阵与邻接表
存图的核心问题:“谁和谁之间有边”
顶点好存,放一个数组就行;难的是边——每个顶点的邻居个数不定,可能一个也没有,也可能和所有顶点都相连。两种主流存法对应两种思路:把所有“顶点对”列成一张表,逐格写有没有边(邻接矩阵);只给每个顶点记一张“邻居名单”(邻接表)。
邻接矩阵像地图册后面的城市里程表:n 个城市就画 n × n 个格子,查“甲到乙多远”一眼可得(O(1)),但没有直达公路的格子也得占着位置。邻接表像每个城市一本直达城市通讯录:有几条直达就记几行,省纸;可要问“甲和乙之间有没有直达”,得把甲的通讯录翻一遍。
哪里不像:里程表通常是对称的(无向图),而有向图的邻接矩阵不对称;有向图的“通讯录”只记从自己出发的弧,查“谁能到我”(入度)就得翻所有人的通讯录。
邻接矩阵
用一个一维数组存顶点,一个 n × n 的二维数组存边:A[i][j] = 1 表示有边 (vᵢ, vⱼ)(或弧 <vᵢ, vⱼ>),0 表示没有。带权图(网)中 A[i][j] 存权值,没有边存 ∞(主对角线一般存 0)。
#define MAXV 100 typedef struct { int numVertices, numEdges; // 顶点数、边数 char VerticesList[MAXV]; // 顶点表 int Edge[MAXV][MAXV]; // 邻接矩阵 } MGraph; // 2021-41、2023-41、2024-41 用的就是这个定义
无向图和网的邻接矩阵长什么样(同一组顶点 A、B、C、D,边 A–B、A–C、B–C、C–D;网的边权依次为 4、7、2、5):
| A | B | C | D | 度 | |
|---|---|---|---|---|---|
| A | 0 | 1 | 1 | 0 | 2 |
| B | 1 | 0 | 1 | 0 | 2 |
| C | 1 | 1 | 0 | 1 | 3 |
| D | 0 | 0 | 1 | 0 | 1 |
无向图:对称;第 i 行 1 的个数 = 度;全部 1 的个数 = 2e = 8
| A | B | C | D | |
|---|---|---|---|---|
| A | 0 | 4 | 7 | ∞ |
| B | 4 | 0 | 2 | ∞ |
| C | 7 | 2 | 0 | 5 |
| D | ∞ | ∞ | 5 | 0 |
网:存权值,没有边存 ∞,对角线存 0(有的教材存 ∞,看题目约定)
- 空间 O(n²),与边数无关,适合稠密图。无向图的矩阵对称,可以只存上(下)三角(3.7 节)。
- 数一共有多少条边:要扫描整个矩阵,O(n²)。
- Aᵐ[i][j] 等于从 vᵢ 到 vⱼ 长度为 m 的路径条数(A 为 0/1 矩阵;2015-42 原题)。原因:A²[i][j] = Σ A[i][k]·A[k][j],每个“i → k → j”的走法贡献 1。
在邻接矩阵上统计度(2021-41、2023-41 的核心步骤):第 i 行求和是出度、第 i 列求和是入度。例如 2023-41 要数“出度大于入度”的顶点:
int CountK(MGraph G) { // 出度 > 入度的顶点个数 int count = 0, n = G.numVertices; for (int i = 0; i < n; i++) { int out = 0, in = 0; for (int j = 0; j < n; j++) { out += G.Edge[i][j]; // 第 i 行:i 的出弧 in += G.Edge[j][i]; // 第 i 列:i 的入弧 } if (out > in) count++; } return count; // 时间 O(n²),空间 O(1) }
(2023-41 原题还要求输出这些顶点,第 9 篇 t4 有完整满分写法。)
邻接矩阵的幂:Aᵐ[i][j] 数的是什么
对 0/1 邻接矩阵,A²[i][j] = Σₖ A[i][k]·A[k][j]:只有“i 到 k 有边并且 k 到 j 有边”时这一项才是 1,所以 A²[i][j] 就是“i 经过一个中间顶点走到 j”的走法数,即长度为 2 的路径条数。同理 Aᵐ[i][j] 是长度为 m 的路径条数(这里的“路径”允许重复经过顶点,2015-42 原题)。
| A² | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 0 | 0 | 1 | 0 |
| 2 | 1 | 0 | 0 | 1 |
| 3 | 0 | 1 | 0 | 0 |
| 4 | 0 | 0 | 0 | 0 |
| A³ | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 1 | 0 | 0 | 1 |
| 2 | 0 | 1 | 0 | 0 |
| 3 | 0 | 0 | 1 | 0 |
| 4 | 0 | 0 | 0 | 0 |
- 对照图验证 A²:A²[1][3] = 1 对应 1→2→3;A²[2][1] = 1 对应 2→3→1;A²[2][4] = 1 对应 2→3→4;A²[3][2] = 1 对应 3→1→2。没有别的长度为 2 的走法 ✓。
- A³ 的对角线:A³[1][1] = A³[2][2] = A³[3][3] = 1——每个都对应环 1→2→3→1 从不同起点出发。对角线上出现非零,说明有长度为 3 的回路。
- 第 4 行全为 0:顶点 4 出度为 0,从它出发走不出任何路径。
矩阵乘法已用程序计算核对。
邻接表
对每个顶点建一个单链表,存所有和它相邻的顶点(有向图存它指出去的弧)。顶点本身放在一个数组(顶点表)里,每个元素带一个指向边链表的指针。
typedef struct ArcNode { // 边表结点 int adjvex; // 这条弧指向的顶点下标 struct ArcNode *next; // 下一条弧 // int weight; // 网还要存权值 } ArcNode; typedef struct VNode { // 顶点表结点 char data; ArcNode *first; // 第一条依附于它的弧 } VNode, AdjList[MAXV]; typedef struct { AdjList vertices; int vexnum, arcnum; } ALGraph;
跟我做一遍:两种存储各要多少空间
- 邻接矩阵:不管有几条边,都要 1000 × 1000 = 1,000,000 个格子,其中只有 6000 个是 1(每条边在矩阵里出现两次),99% 以上的格子存的是 0。
- 邻接表:顶点表 1000 个结点 + 边表 2 × 3000 = 6000 个结点,共 7000 个结点。每个结点比一个格子大(多一个指针),但总量仍差了一百多倍。
- 反过来:若是完全图(每两个顶点之间都有边,e = n(n − 1)/2),邻接表要 1000 + 999000 = 1,000,000 个结点,还个个带指针,比矩阵更费空间。
- 折中:无向图的邻接矩阵是对称的,可以像 3.7 节的对称矩阵那样只存上三角(不含对角线),n(n − 1)/2 = 499,500 个格子——但这样就不能直接按 A[i][j] 下标访问了,要先换算位置。
结论就是那句老话:稀疏图(e 远小于 n²)用邻接表,稠密图用邻接矩阵。“稀疏 / 稠密”没有严格的分界,做题时把 n² 和 n + 2e 算出来比一比就清楚了。除了空间还要看常做什么操作:经常要问“i、j 之间有没有边”,矩阵 O(1) 一步到位,邻接表要把 i 的链表走一遍;经常要“列出 i 的所有邻居”(遍历就是这样),邻接表只走度数那么长,矩阵却要扫一整行 n 个格子。(数字由程序计算。)
同一个图,邻接表不唯一:建表的次序会改变遍历序列
邻接表通常按输入的边逐条建立,新结点用头插法插到链表最前面最省事——结果链表次序和输入次序正好相反。而 BFS、DFS 都按链表次序找邻居,所以同一个图、不同的邻接表,遍历序列可能不同;邻接矩阵唯一,序列也就唯一(下标小的邻居先找到)。
| 顶点 | 头插法建立的链表 | 尾插法建立的链表 |
|---|---|---|
| a | c → b | b → c |
| b | d → a | a → d |
| c | d → a | a → d |
| d | e → c → b | b → c → e |
| e | d | d |
| 从 a 出发 DFS | a c d e b | a b d c e |
| 从 a 出发 BFS | a c b d e | a b c d e |
每条无向边在两端的链表里各插一次,所以边表结点共 2 × 5 = 10 个。四个序列已用程序模拟核对。
邻接矩阵 → 邻接表(常见小算法题):逐行扫描,遇到 1 就建一个边结点挂上去。下面倒着扫描 + 头插,使链表中的邻接点按下标从小到大排列:
void MatToList(MGraph G, ALGraph *L) { // 邻接矩阵 → 邻接表(0/1 矩阵) L->vexnum = G.numVertices; L->arcnum = G.numEdges; for (int i = 0; i < G.numVertices; i++) { L->vertices[i].data = G.VerticesList[i]; L->vertices[i].first = NULL; for (int j = G.numVertices - 1; j >= 0; j--) // 倒着扫 + 头插 ⇒ 链表里下标从小到大 if (G.Edge[i][j] == 1) { ArcNode *p = (ArcNode *)malloc(sizeof(ArcNode)); p->adjvex = j; p->next = L->vertices[i].first; L->vertices[i].first = p; } } }
时间 O(n²)(必须扫完整个矩阵)。反过来邻接表 → 邻接矩阵:先把矩阵清零 O(n²),再沿每个链表把对应格子置 1,O(n² + e)。代码已编译,并与直接在矩阵上做 DFS 的结果随机对拍 5000 个图一致。
| 邻接矩阵 | 邻接表 | |
|---|---|---|
| 空间 | O(n²) | 无向 O(n + 2e),有向 O(n + e) |
| 适合 | 稠密图 | 稀疏图 |
| 判断 (vᵢ, vⱼ) 是否有边 | O(1) | O(vᵢ 的度),最坏 O(n) |
| 找 v 的所有邻接点 | O(n) | O(v 的度) |
| 求有向图某顶点的出度 / 入度 | O(n) / O(n) | O(出度) / O(n + e)(2026-06) |
| 表示是否唯一 | 唯一 | 不唯一 |
| BFS、DFS 的时间 | O(n²) | O(n + e) |
| 0 | 1 | 0 | 1 |
| 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 0 |
| 1 | 0 | 0 | 0 |
- A. 1, 2, 1, 2
- B. 2, 2, 1, 1
- C. 3, 4, 2, 3
- D. 4, 4, 2, 2
展开解析
C矩阵不对称,是有向图。有向图顶点的度 = 出度(行中 1 的个数)+ 入度(列中 1 的个数)。
| 顶点 | 出度(行) | 入度(列) | 度 |
|---|---|---|---|
| v1 | 2 | 1 | 3 |
| v2 | 2 | 2 | 4 |
| v3 | 1 | 1 | 2 |
| v4 | 1 | 2 | 3 |
- A 错只算了入度。
- B 错只算了出度。
- C 对3, 4, 2, 3。
- D 错4, 4, 2, 2 = 出度 × 2:把行和算了两遍,像无向图那样以为行和等于列和。
先看矩阵是否对称:不对称就是有向图,度 = 行和 + 列和。
真题怎么考
- 由矩阵数度(2013-07):有向图行和 = 出度、列和 = 入度,度 = 两者之和。
- 邻接表求入度(2026-06):要扫描所有边表,O(n + e)。
- Aᵐ 的含义(2015-42):长度为 m 的路径条数。
- 邻接矩阵上的算法大题(2021-41 数奇度顶点判断 EL 路径、2023-41 统计出度大于入度的 K 顶点):本质都是“按行求和 = 出度(无向图 = 度)、按列求和 = 入度”,双重循环 O(n²)。第 9 篇 t4 有完整解答。
- 无向图的邻接表中边表结点是 2e 个,有向图是 e 个。
- 邻接矩阵存储时,图的 BFS / DFS 是 O(n²),与边数无关;邻接表才是 O(n + e)。
- 网的邻接矩阵里“没有边”是 ∞ 不是 0;写代码判断“有边”时要和题目约定一致。
- 有 n 个顶点、e 条边的无向图分别用邻接矩阵和邻接表存储,判断顶点 i、j 之间是否有边各需要多少时间?求顶点 i 的度呢?
答案
判边:矩阵 O(1),邻接表 O(i 的度)(最坏 O(n))。求度:矩阵扫一行 O(n),邻接表数链表长度 O(i 的度)。 - 有向图 G 的邻接矩阵中第 3 列有 4 个 1、第 3 行有 2 个 1,顶点 3 的入度、出度、度各是多少?
答案
列 = 入度 4,行 = 出度 2,度 = 6。 - 0/1 邻接矩阵 A 满足 A³ 的对角线上有非零元素,说明什么?
答案
存在长度为 3 的、从某顶点出发又回到它的路径,即图中有长度为 3 的回路(有向图中是有向回路;无向图中就是三角形)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
| 1 | 2 | 3 | 4 | |
|---|---|---|---|---|
| 1 | 0 | 1 | 1 | 0 |
| 2 | 0 | 0 | 1 | 1 |
| 3 | 1 | 0 | 0 | 0 |
| 4 | 0 | 0 | 1 | 0 |
- A. 2, 2, 1, 1
- B. 1, 1, 3, 1
- C. 3, 3, 4, 2
- D. 1, 2, 1, 2
展开解析
B有向图:第 i 行之和是出度,第 j 列之和是入度。各列之和:第 1 列 1,第 2 列 1,第 3 列 3,第 4 列 1。
变化点:A 是出度(行和),C 是度(入度 + 出度)。原题的矩阵是无向图(对称),度 = 行和 = 列和。
- A. O(1)
- B. O(n)
- C. O(e)
- D. O(n + e)
展开解析
B入度 = 该顶点所在列中 1 的个数,扫描一列要 O(n)。
变化点:原题是邻接表:出度只看自己的边表,入度要扫描所有边表,O(n + e)。邻接矩阵求入度、出度都是 O(n)。
6.3十字链表、邻接多重表与图的基本操作
为什么还要两种“十字形”的链表
邻接表有两个短处:有向图只串了出弧,求入度要扫全表;无向图每条边存两次,删一条边要改两个链表。十字链表(有向图)让每条弧同时挂在“同弧尾”和“同弧头”两条链上;邻接多重表(无向图)让每条边只存一个结点、同时挂在两个端点的链上。
十字链表(有向图)
每条弧一个结点:(tailvex, headvex, hlink, tlink, info)。tlink 把弧尾相同的弧串起来(出弧链),hlink 把弧头相同的弧串起来(入弧链)。每个顶点结点有 firstin、firstout 两个指针。于是求出度、入度都方便。
邻接多重表(无向图)
每条边只用一个结点:(ivex, ilink, jvex, jlink, info)。ilink 指向下一条依附于 ivex 的边,jlink 指向下一条依附于 jvex 的边。邻接表中每条边存两次,删边要改两处;邻接多重表每条边只存一次。数某顶点的度:从顶点表出发沿着链走,看有几条边结点包含这个顶点。
邻接多重表示例:无向图有边 e1 = (0, 1)、e2 = (0, 3)、e3 = (1, 2)、e4 = (1, 3)、e5 = (2, 3)。每条边一个结点(ivex, ilink, jvex, jlink),ilink 指向“下一条依附于 ivex 的边”,jlink 指向“下一条依附于 jvex 的边”:
| 边 | ivex | ilink | jvex | jlink |
|---|---|---|---|---|
| e1 | 0 | e2 | 1 | e3 |
| e2 | 0 | ∧ | 3 | e4 |
| e3 | 1 | e4 | 2 | e5 |
| e4 | 1 | ∧ | 3 | e5 |
| e5 | 2 | ∧ | 3 | ∧ |
顶点表 firstedge:V0 → e1,V1 → e1,V2 → e3,V3 → e2
沿链数度:V1 从 firstedge = e1 出发,在 e1 中 V1 是 jvex,走 jlink → e3;在 e3 中 V1 是 ivex,走 ilink → e4;在 e4 中 V1 是 ivex,ilink = ∧ 结束。经过 e1、e3、e4 三条边 ⇒ 度为 3。规则只有一句:当前顶点在这个结点里是 ivex 就走 ilink,是 jvex 就走 jlink(2024-04 就考这个)。
| 邻接矩阵 | 邻接表 | 十字链表 | 邻接多重表 | |
|---|---|---|---|---|
| 适用 | 有向、无向 | 有向、无向 | 只用于有向图 | 只用于无向图 |
| 空间 | O(n²) | O(n + e) / O(n + 2e) | O(n + e) | O(n + e) |
| 每条边存几次 | — | 无向图 2 次 | 1 次 | 1 次 |
图的基本操作(后面的算法都用它们表述):Adjacent(G, x, y) 判断有无边;Neighbors(G, x) 列出邻接点;InsertVertex、DeleteVertex、AddEdge、RemoveEdge;FirstNeighbor(G, x) 求 x 的第一个邻接点(没有返回 −1);NextNeighbor(G, x, y) 求 x 的邻接点中 y 之后的下一个。
- 出度:从 V2 的 firstout 出发,沿 tlink 横着走:<V2, V0> → <V2, V3> → ∧,共 2 条出弧。
- 入度:从 V2 的 firstin 出发,沿 hlink 竖着走:<V0, V2> → <V1, V2> → ∧,共 2 条入弧。
- 对比邻接表:出度同样只需走 V2 自己的链表;但入度要扫描所有顶点的链表,O(n + e)。十字链表把入弧也串起来了,这正是它的价值。
基本操作的实现:FirstNeighbor 与 NextNeighbor
遍历算法不关心图怎么存,只需要“给我 x 的第一个邻居”“给我 x 在 y 之后的下一个邻居”。把这两个操作在两种存储上各写一遍,就能看出复杂度差在哪里:
int FirstNeighbor(MGraph G, int x) { for (int j = 0; j < G.numVertices; j++) if (G.Edge[x][j] == 1) return j; return -1; // 没有邻接点 } int NextNeighbor(MGraph G, int x, int y) { for (int j = y + 1; j < G.numVertices; j++) if (G.Edge[x][j] == 1) return j; return -1; }
int FirstNeighbor(ALGraph G, int x) { ArcNode *p = G.vertices[x].first; return p != NULL ? p->adjvex : -1; } int NextNeighbor(ALGraph G, int x, int y) { ArcNode *p = G.vertices[x].first; while (p != NULL && p->adjvex != y) p = p->next; // 先找到 y return (p != NULL && p->next != NULL) ? p->next->adjvex : -1; }
| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| FirstNeighbor(x) | O(n)(最坏扫一整行) | O(1) |
| NextNeighbor(x, y) | O(n) | O(x 的度)(要先找到 y) |
| Adjacent(x, y) 判边 | O(1) | O(x 的度) |
| AddEdge / RemoveEdge | O(1) | 插入 O(1)(头插);删除 O(度),无向图要删两处 |
有了这两个函数,DFS 可以写成与存储无关的通用形式:for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) if (!visited[w]) DFS(G, w);。(四个函数已编译,用它们写的 DFS 与直接扫描矩阵的 DFS 对拍一致。)
- A. 0, 2
- B. 2, 4
- C. 3, 2
- D. 2, 3
展开解析
B邻接多重表中每个边结点代表一条无向边,边结点里的 ivex、jvex 是这条边的两个端点(顶点下标:a = 0,b = 1,c = 2,d = 3,e = 4)。一个顶点的度 = 包含它的边结点个数。
- 7 个边结点:(0,1)、(0,3)、(1,3)、(2,0)、(3,2)、(4,2)、(4,3),即边 a−b、a−d、b−d、c−a、d−c、e−c、e−d。
- b(1)出现在 (0,1)、(1,3):度 2。
- d(3)出现在 (0,3)、(1,3)、(3,2)、(4,3):度 4。
- A 错度为 0 不可能,b 在两个边结点里。
- B 对2, 4。
- C 错把 a 的度 3 当成了 b。
- D 错漏数了 d 的一条边。
邻接多重表:一条边一个结点,结点里存两个端点。数度就数“有几个边结点含这个顶点”。
- 十字链表只用于有向图,邻接多重表只用于无向图,别配反。
- 十字链表和邻接多重表都不唯一(链中结点次序可以不同),邻接矩阵唯一。
- 邻接多重表里一条边只有一个结点,数度时一个结点只算一次。
- 在上面的十字链表图中,沿 V2 的 firstin 链能找到哪些弧?V2 的入度是多少?
答案
V2 那一列:<V0, V2> → <V1, V2>,入度 2。 - 在上面的邻接多重表中,沿链求 V3 的度。
答案
V3 的 firstedge = e2,V3 是 jvex ⇒ 走 jlink → e4;V3 是 jvex ⇒ jlink → e5;V3 是 jvex ⇒ jlink = ∧。经过 e2、e4、e5,度为 3。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2e、e
- B. e、e
- C. 2e、2e
- D. e、2e
展开解析
A邻接表中每条无向边 (u, v) 在 u 和 v 的边表里各出现一次,共 2e 个结点;邻接多重表中每条边只用一个边结点,同时挂在两个顶点的链上,共 e 个。
变化点:原题在邻接多重表上数度:一个顶点的度 = 它那条链上的边结点个数。十字链表(有向图)中每条弧也只有一个结点,同时挂在弧尾的出弧链和弧头的入弧链上。
- A. 十字链表适用于存储无向图
- B. 邻接多重表中,每条边只用一个边结点表示
- C. 图的邻接表表示是唯一的
- D. 用邻接矩阵存储稀疏图比邻接表更节省空间
展开解析
BA:十字链表是有向图的存储结构;C:边表中结点的次序可以不同,邻接表不唯一(邻接矩阵是唯一的);D:邻接矩阵固定 n2 个单元,稀疏图应该用邻接表。
变化点:搭配记:有向图—十字链表,无向图—邻接多重表;二者都是为了让一条边只存一次。
- A. O(1)
- B. O(x 的度)
- C. O(n + e)
- D. O(n²)
展开解析
B要沿 x 的边表先找到 y,再取它的下一个结点,最多走完 x 的整条边表,O(x 的度)(最坏 O(n))。
变化点:若用邻接矩阵,NextNeighbor 要从 y + 1 扫到行末,O(n);FirstNeighbor 在邻接表上是 O(1)。遍历时每个顶点都要把邻居找一遍,所以 DFS / BFS 在邻接表上总共 O(n + e)、在矩阵上 O(n²)。
6.4广度优先搜索 BFS
BFS:像水波一样一圈圈往外扩
波纹先到离落点最近的一圈,再到第二圈、第三圈……BFS 从起点出发,先访问所有距离为 1 的顶点,再访问距离为 2 的,依此类推。队列就是“排队等着继续往外扩的那一圈”。
哪里不像:水波是连续扩散的,BFS 是一个一个顶点处理的;同一圈里谁先谁后,取决于它们被发现的先后(也就是邻接表 / 矩阵里邻居的次序)。
代码与复杂度
BFS 类似树的层序遍历:从起点 v 出发,先访问 v 的所有邻接点,再访问这些邻接点的所有未访问过的邻接点……一圈一圈向外扩。为了记住“下一个该从谁往外扩”,用队列;为了不重复访问,用 visited[] 数组。
bool visited[MAXV]; void BFS(ALGraph G, int v) { // 从 v 出发广度优先遍历 int Q[MAXV], front = 0, rear = 0; visit(v); visited[v] = true; Q[rear++] = v; while (front < rear) { int u = Q[front++]; // 出队 for (ArcNode *p = G.vertices[u].first; p != NULL; p = p->next) { int w = p->adjvex; if (!visited[w]) { // 未访问的邻接点:访问、标记、入队 visit(w); visited[w] = true; Q[rear++] = w; } } } } void BFSTraverse(ALGraph G) { // 图可能不连通:每个没访问过的顶点都要作为起点 for (int i = 0; i < G.vexnum; i++) visited[i] = false; for (int i = 0; i < G.vexnum; i++) if (!visited[i]) BFS(G, i); // 调用 BFS 的次数 = 连通分量数 }
| 存储 | 时间 | 空间 | 说明 |
|---|---|---|---|
| 邻接表 | O(n + e) | O(n)(队列、visited) | 每个顶点入队一次,每条边(弧)被检查一次(无向图两次) |
| 邻接矩阵 | O(n²) | O(n) | 找每个顶点的邻接点都要扫一整行 |
- BFS 求单源最短路径(无权图):第一次访问到某个顶点时经过的边数就是最短距离,因为 BFS 按距离由近到远访问。带权图不行(2023-06、2025-06 D)。
- 广度优先生成树:遍历过程中“从 u 第一次访问到 w”的边 (u, w) 组成一棵树。邻接矩阵唯一 ⇒ BFS 序列和生成树唯一;邻接表不唯一 ⇒ 不唯一。
O(n + e) 是怎么来的:每个顶点入队、出队各一次,共 O(n);每个顶点出队时把自己的边表扫一遍,所有边表加起来有 e 个(无向图 2e 个)结点,共 O(e)。两部分相加就是 O(n + e)。用邻接矩阵时,“扫自己的邻居”变成扫一整行 n 个格子,n 个顶点共 n² 次,所以是 O(n²)——这个推法对 DFS 完全一样。
visited 为什么要在入队时就标记
| 写法 | 过程 | 进过队列的元素 |
|---|---|---|
| ✓ 入队时标记(本书代码) | A 出队,B、C 入队并标记;B 出队,D 入队并标记;C 出队,看到 D 已标记,跳过;D 出队 | A B C D,每个 1 次 |
| ✗ 出队时才标记 | A 出队并标记,B、C 入队;B 出队并标记,D 未标记 ⇒ 入队;C 出队并标记,D 仍未标记(它还在队里没出来)⇒ 又入队 | A B C D D,D 被访问两次 |
道理:一个顶点从“被发现”到“出队”之间要等一段时间,这段时间里它的其他邻居也会发现它。只有在发现的那一刻就打上标记,才能保证每个顶点只入队一次。图越稠密,错写法重复得越厉害:5 个顶点的完全图,错误写法会让顶点进队 11 次(正确是 5 次),时间也不再是 O(n + e)。
两种写法的队列内容由程序模拟。DFS 的递归写法没有这个问题:递归进入 w 时第一件事就是标记 w。顺带一提,树的层序遍历(第 5 篇)根本不需要 visited:树里没有回路,每个结点只会被它唯一的双亲发现一次——图比树多出来的麻烦,全都来自“一个顶点可能被好几个邻居同时发现”。
跟我做一遍:BFS 逐步表与广度优先生成树
原图(本节和 6.5 节共用)
橙色粗线 = 广度优先生成树,虚线 = 没用上的边
| 步 | 出队 | 检查它的邻接点(字母序) | 新访问并入队 | 队列(头 → 尾) | 访问序列 |
|---|---|---|---|---|---|
| 0 | — | 访问起点 A 并入队 | A | A | A |
| 1 | A | B、C、D 都未访问 | B C D | B C D | A B C D |
| 2 | B | A ✓、E | E | C D E | … E |
| 3 | C | A ✓、E ✓、F | F | D E F | … F |
| 4 | D | A ✓、G | G | E F G | … G |
| 5 | E | B ✓、C ✓、H | H | F G H | … H |
| 6 ~ 8 | F、G、H | 邻接点全部访问过 | — | 依次出队直到空 | A B C D E F G H |
注意第 2 步:E 是在处理 B 时被发现的,所以生成树中 E 挂在 B 下(边 B–E),虽然 C–E 也是一条边。谁先发现它,它就挂在谁下面——这就是广度优先生成树的构造规则。
生成树 / 序列唯一吗?邻接矩阵唯一,邻居总是按下标从小到大被检查,所以从同一个起点出发,BFS 序列和广度优先生成树都唯一;邻接表不唯一(6.2 节的例子),序列和生成树也就可能不同。非连通图:一次 BFS 只能走遍起点所在的连通分量,所以 BFSTraverse 要对每个没访问过的顶点再调一次 BFS,调用次数 = 连通分量数,得到的是广度优先生成森林。
BFS 求无权图的单源最短路径
BFS 按“距离由近到远”的次序第一次发现每个顶点,所以第一次发现 w 时,d[w] = d[v] + 1 就是最终答案,以后不会更短。把 visited[] 换成距离数组 d[](INF 表示没发现过),再用 path[] 记下“从谁发现的”:
void BFS_MinDist(ALGraph G, int u, int d[], int path[]) { // 无权图:u 到各顶点的最短路径 int Q[MAXV], front = 0, rear = 0; for (int i = 0; i < G.vexnum; i++) { d[i] = INF; path[i] = -1; } d[u] = 0; Q[rear++] = u; while (front < rear) { int v = Q[front++]; for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) { int w = p->adjvex; if (d[w] == INF) { // 第一次发现 w:它的最短距离就是 d[v] + 1 d[w] = d[v] + 1; path[w] = v; Q[rear++] = w; } } } }
对上面的图从 A 出发:d = A 0、B 1、C 1、D 1、E 2、F 2、G 2、H 3;path[H] = E、path[E] = B、path[B] = A,倒推得最短路径 A → B → E → H。(代码与 Floyd 求出的最短边数在 4000 个随机图上对拍一致。)
失效条件:边有不同的权时不能用 BFS(边数最少的路径不一定权值和最小),要用 Dijkstra(6.7 节)。2023-06 正是在考“无权图用 BFS 就够了”。
一个顺手的推论:BFS 最后出队的顶点,离起点最远。因为出队次序就是按距离从近到远排的,最后一个出队的顶点距离最大(若有好几个一样远,它是其中之一)。上面的例子从 A 出发,出队次序 A B C D E F G H,最后的 H 距离 3,正是最远的。所以“求距离顶点 v 最远的一个顶点”只要做一遍 BFS,返回最后出队的那个即可,O(n + e);“求与 v 距离恰好为 k 的所有顶点”,就在 d[] 里挑 d[w] == k 的(本例 k = 2 时是 E、F、G)。(出队次序与距离由程序核对。)
模拟队列:第一个顶点入队;每出队一个顶点 u,它所有未访问的邻接点必须紧接着出现在序列中(彼此顺序任意),然后再轮到下一个出队的顶点。一旦某个位置出现了一个不是“当前应扩展的顶点”的邻居,就不是 BFS 序列。
对 6.4 节例图,判断从 A 出发的两个序列是否可能是 BFS 序列:① A D C B G F E H ② A B C D E G F H。
| 出队 | 它的未访问邻接点(集合) | 序列①接下来的几个 | 序列②接下来的几个 |
|---|---|---|---|
| A | {B, C, D} | D C B ✓ | B C D ✓ |
| ①:D / ②:B | ①:D 的 {G};②:B 的 {E} | G ✓ | E ✓ |
| ①:C / ②:C | ①:C 的 {E, F};②:C 的 {F}(E 已发现) | F E ✓ | 应是 F,却是 G ✗ |
| ①:B、G、F、E | 只剩 E 的 {H} | H ✓ | — |
① 是 BFS 序列,② 不是。方法:用队列记“下一个该轮到谁扩展”,每个顶点扩展出的新顶点必须成批地紧跟着出现。(程序枚举了所有邻接次序下的 BFS 序列(共 9 种)核对。)
- A. h, c, a, b, d, e, g, f
- B. e, a, f, g, b, h, c, d
- C. d, b, c, a, h, e, f, g
- D. a, b, c, d, h, e, f, g
展开解析
DBFS 访问完起点后,必须先把起点的全部邻接点访问完,才能访问更远的顶点。
- A 是h 的邻接点 c、a;c 的未访问邻接点 b、d;a 的未访问邻接点 e;b、d 没有新的;e 的 g、f。
- B 是e 的邻接点 a、f、g;a 的 b、h;f、g 无新邻点;b 的 c、d。
- C 是d 的邻接点 b、c;b 的 a;c 的 h;a 的 e;e 的 f、g。
- D 不是a 的邻接点是 b、e、h,访问 a、b 之后应该先访问 e 和 h,D 却接着访问了 c(b 的邻接点)。
BFS 序列:一个顶点的所有未访问邻居必须连在一起,而且整体排在“下一层”之前。
- BFS 在入队时就标记 visited,而不是出队时,否则同一个顶点可能被多个邻居重复入队。
- 邻接矩阵上 BFS 是 O(n²),邻接表上是 O(n + e)——复杂度由存储决定,不由算法决定(2012-05)。
- BFS 序列中同一层的顶点必须按它们父亲的出队次序分组出现,不能交错。
- 对本节的例图,从 H 出发(邻接点按字母序)BFS 的序列是什么?
答案
H 的邻接点 E、F、G;E 带出 B、C;F 的邻居 C 已发现;G 带出 D;最后 B 带出 A。序列 H E F G B C D A。 - 序列 A, C, B, E, F, D, G, H 可能是本节例图从 A 出发的 BFS 序列吗?
答案
不可能。A 出队后它的邻接点 B、C、D 必须紧接着出现(顺序可变),而序列中 C、B 之后出现的是 E,D 还没出现——D 被挤到了第二层后面。 - 对本节例图从 D 出发 BFS(邻接点按字母序),最后出队的是哪个顶点?离 D 最远的顶点有哪些?
答案
出队次序 D A G B C H E F,最后出队的是 F;离 D 最远(距离 3)的有 E、F 两个。最后出队的一定是最远的“之一”,但不一定是唯一的那个。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. O(n)
- B. O(n + e)
- C. O(n2)
- D. O(n × e)
展开解析
C每个顶点出队后,要扫描邻接矩阵的一整行(n 个元素)找邻接点,n 个顶点共 O(n2)。
变化点:原题用邻接表,每条边只看一次(有向图 e 次、无向图 2e 次),O(n + e)。BFS 和 DFS 的复杂度只取决于存储结构:邻接矩阵 O(n2),邻接表 O(n + e)。
- A. a, b, c, d, e, f
- B. c, a, d, e, b, f
- C. a, b, c, e, d, f
- D. f, d, e, b, c, a
展开解析
CBFS 的关键:先出队的顶点,它的邻接点先入队。C 中 a 之后 b、c 入队,b 先出队,b 的未访问邻接点 d 应当排在 c 的邻接点 e 之前,而 C 先访问了 e,所以不是 BFS 序列。A:a 的邻接点 b、c,b 引出 d,c 引出 e,d 引出 f ✓。B:c 的邻接点 a、d、e,a 引出 b,d 引出 f ✓。D:f 的邻接点 d、e,d 引出 b、c,b 引出 a ✓。
变化点:判断方法:把序列按“层”切开(起点—起点的邻接点—下一层……),检查每个顶点是由它前面最早的邻接点引出的,且引出的次序与这些“父亲”的出队次序一致。
6.5深度优先搜索 DFS
DFS:一条路走到黑,走不通再回头
每到一个路口,挑一条没走过的岔路一直往前走;走到死胡同就原路退回上一个路口,换另一条没走过的岔路。退回时要知道“上一个路口是哪个”,所以要记住来时的路——这就是栈(递归时由系统的递归栈自动记住)。
哪里不像:真实迷宫里走过的路会忘,DFS 用 visited[] 记住所有到过的顶点,所以不会在环里打转。
递归代码与复杂度
DFS 类似树的先序遍历:从 v 出发,访问 v 的一个未访问邻接点 w,再从 w 出发继续往深处走……走不下去了(所有邻接点都访问过)就回退到上一个顶点,看它还有没有别的未访问邻接点。用递归实现最简洁(递归栈记住了回退的路)。
void DFS(ALGraph G, int v) { visit(v); visited[v] = true; for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) if (!visited[p->adjvex]) DFS(G, p->adjvex); // 往深处走;返回时就是“回退” } void DFSTraverse(ALGraph G) { for (int i = 0; i < G.vexnum; i++) visited[i] = false; for (int i = 0; i < G.vexnum; i++) if (!visited[i]) DFS(G, i); }
复杂度与 BFS 相同:邻接表 O(n + e),邻接矩阵 O(n²);空间是递归栈 O(n)。
跟我做一遍:DFS 逐步表与深度优先生成树
| 步 | 当前顶点 | 它的邻接点(字母序) | 动作 | 递归栈(底 → 顶) | 访问序列 |
|---|---|---|---|---|---|
| 1 | A | B C D | 访问 A,去第一个未访问的 B | A | A |
| 2 | B | A ✓、E | 访问 B,去 E | A B | A B |
| 3 | E | B ✓、C、H | 访问 E,去 C | A B E | A B E |
| 4 | C | A ✓、E ✓、F | 访问 C,去 F | A B E C | … C |
| 5 | F | C ✓、H | 访问 F,去 H | A B E C F | … F |
| 6 | H | E ✓、F ✓、G | 访问 H,去 G | A B E C F H | … H |
| 7 | G | D、H ✓ | 访问 G,去 D | … H G | … G |
| 8 | D | A ✓、G ✓ | 访问 D;没路了,回退 | … G D | … D |
| 9 | — | G、H、F、C、E、B、A 依次检查剩下的邻接点 | 都访问过,逐层回退到 A,结束 | 空 | A B E C F H G D |
非递归 DFS:自己管理“来时的路”
把递归改成显式的栈:栈里存的就是当前这条路径。每次看栈顶,若它还有未访问的邻接点,就访问并入栈(往深处走);否则出栈(回退)。为了不每次都从头扫边表,用 cur[u] 记住 u 的边表已经看到哪了:
void DFS2(ALGraph G, int v) { // 与递归 DFS 的访问次序完全相同 int S[MAXV], top = -1; ArcNode *cur[MAXV]; // cur[u]:u 的边表下一次从哪条弧接着看 for (int i = 0; i < G.vexnum; i++) cur[i] = G.vertices[i].first; visit(v); visited[v] = true; S[++top] = v; while (top != -1) { int u = S[top]; while (cur[u] != NULL && visited[cur[u]->adjvex]) cur[u] = cur[u]->next; // 跳过已访问的 if (cur[u] != NULL) { // 有未访问的邻接点 w:访问它并入栈(往深处走) int w = cur[u]->adjvex; visit(w); visited[w] = true; S[++top] = w; } else top--; // u 的邻接点都访问过了:回退 } }
每条弧被 cur 指针扫过一次,时间 O(n + e);栈深最多 n。(多次调用时 cur 的初始化放到 DFSTraverse 里只做一次。)程序在 4000 个随机图上验证它与递归 DFS 的序列完全一致。
有的写法是“弹出一个顶点就访问,并把它所有未访问的邻接点一次性入栈(入栈时就标记)”。它能遍历所有顶点,但得到的不一定是 DFS 序列。对上图从 A 出发,它输出 A B E H F G C D:访问 F 时 F 还有未访问的邻接点 C,真正的 DFS 应当接着去 C,它却跳到了 G(C 早被标记“已入栈”)。判断 DFS 序列的题目要以“栈顶优先往深处走”为准。
模拟一个栈(当前路径):每访问下一个顶点 x,它必须是栈顶顶点的未访问邻接点;如果栈顶没有未访问的邻接点,就弹栈回退,再看新的栈顶。如果 x 不是栈顶的邻接点,而栈顶还有未访问的邻接点,就不是 DFS 序列(2016-06 D 就错在这里)。
对 6.4 节例图,判断从 A 出发的两个序列是否可能是 DFS 序列:① A D G H F C E B ② A C F H E G D B。
| 序列① | 检查(栈顶 → 下一个) | 序列② | 检查 |
|---|---|---|---|
| A → D | D 是 A 的邻接点 ✓ | A → C | ✓ |
| D → G | ✓ | C → F | ✓(C 还有 E 也行) |
| G → H | ✓ | F → H | ✓ |
| H → F | ✓(H 未访问的有 E、F) | H → E | ✓(H 未访问的有 E、G) |
| F → C | ✓ | E → G? | 栈顶 E 还有未访问的 B,必须先走 B;G 不是 E 的邻接点 ✗ |
| C → E → B | ✓ | — | — |
① 是 DFS 序列,② 不是。DFS 的规则只有一条:下一个顶点必须是栈顶的未访问邻接点;只有栈顶没有未访问邻接点时才允许回退。(程序枚举了全部 12 种 DFS 序列核对。)
DFS 的应用一览
- 连通性:DFSTraverse(或 BFSTraverse)中外层循环调用 DFS 的次数 = 无向图的连通分量个数。
- 有向图不能这么数:从一个顶点出发能走到哪些顶点取决于边的方向,所以外层循环调用 DFS 的次数既不等于强连通分量数,还和起点的次序有关。最小的例子:只有一条弧 A → B。按 A、B 的次序,从 A 出发一次就访问完,调用 1 次;按 B、A 的次序,从 B 出发只访问到 B,还要再从 A 出发,调用 2 次。而强连通分量始终是 {A}、{B} 两个。判断有向图是否强连通,要从某个顶点出发正着走一遍、再把所有弧反向走一遍,两次都能到达全部顶点才算。
- 判断有向图是否有环:DFS 中遇到一条指向“还在递归栈里”(正在访问、尚未退出)的顶点的弧,就有环。
- 逆拓扑序列:在有向无环图上做 DFS,把 visit 放在退出递归之前(所有后继都处理完再输出自己),输出的就是逆拓扑有序序列(2020-06);把它倒过来就是拓扑序列。
- 深度优先生成树 / 森林。
用遍历判连通、数连通分量、判环
| 问题 | 做法 | 时间(邻接表) |
|---|---|---|
| 无向图是否连通 | 从任一顶点 DFS / BFS 一次,看是否访问到全部 n 个顶点 | O(n + e) |
| 连通分量个数 | DFSTraverse 中外层循环调用 DFS 的次数 | O(n + e) |
| 无向图是否有环 | DFS 时遇到“已访问、但不是自己父亲”的邻接点 ⇒ 有环;或者看 e > n − 连通分量数 | O(n + e) |
| 有向图是否有环 | DFS 时遇到一条指向“还在递归栈中”的顶点的弧(回到祖先)⇒ 有环;或用拓扑排序(6.9 节) | O(n + e) |
| u 到 v 是否有路径 | 从 u 出发 DFS / BFS,看能否访问到 v | O(n + e) |
bool UCycle(ALGraph G, int v, int parent) { visited[v] = true; for (ArcNode *p = G.vertices[v].first; p; p = p->next) { int w = p->adjvex; if (!visited[w]) { if (UCycle(G, w, v)) return true; } else if (w != parent) // 访问过又不是父亲 return true; // ⇒ 绕回来了,有环 } return false; }
int color[MAXV]; // 0 未访问,1 在递归栈中,2 已结束 bool DCycle(ALGraph G, int v) { color[v] = 1; for (ArcNode *p = G.vertices[v].first; p; p = p->next) { int w = p->adjvex; if (color[w] == 1) return true; // 回到祖先 if (color[w] == 0 && DCycle(G, w)) return true; } color[v] = 2; // v 的子孙全部处理完 return false; }
有向图不能用无向图那条规则:指向一个“已结束”(颜色 2)的顶点的弧不构成环,例如 a→b、a→c、c→b,从 a 出发先访问完 b,再从 c 指向 b,并没有环。两段代码都与“边数 > n − 分量数”“拓扑排序能否排完”两种判据在随机图上对拍一致。
- A. V1, V5, V4, V3, V2
- B. V1, V3, V2, V5, V4
- C. V1, V2, V5, V4, V3
- D. V1, V2, V3, V4, V5
展开解析
D弧:V1→V2、V1→V3、V1→V5、V3→V2、V2→V5、V5→V4、V4→V3。模拟时维护“当前路径”(栈),下一个顶点必须是栈顶的未访问邻接点。
- A 是V1 → V5 → V4 → V3 → V2,一路向深处。
- B 是V1 → V3 → V2 → V5 → V4。
- C 是V1 → V2 → V5 → V4 → V3。
- D 不是访问 V2 后,V2 还有未访问的邻接点 V5,必须先走 V5;而 V2 根本没有到 V3 的弧,V3 不可能紧跟在 V2 之后。
DFS 序列:下一个顶点必须是“最近的、还有未访问邻居的”那个顶点的邻居。
算法题常客:输出两个顶点之间的所有简单路径
普通 DFS 每个顶点只访问一次,只能找到一条路。要找出 u 到 v 的所有简单路径(顶点不重复),关键改动只有一处:从一个顶点退出来时,把它的 visited 恢复成 false——它在这条路上用过了,但别的路径还可以经过它。这种“走进去做标记、退出来撤销标记”的写法叫回溯。
int path[MAXV], len = 0; // 当前走过的路径 void AllPath(ALGraph G, int u, int v) { // 调用前 visited[] 全为 false path[len++] = u; visited[u] = true; // 走进 u if (u == v) { // 到了:输出这一条 for (int i = 0; i < len; i++) printf("%d ", path[i]); printf("\n"); } else { for (ArcNode *p = G.vertices[u].first; p != NULL; p = p->next) if (!visited[p->adjvex]) AllPath(G, p->adjvex, v); } visited[u] = false; len--; // 回溯:退出 u,让别的路径还能经过它 }
- 0 → 1 → 2 → 3 → 4:到了,输出 0 1 2 3 4。退回 3、2(它们没有别的未走邻居),回到 1。
- 1 的下一个邻居 3:0 → 1 → 3,先试 3 的邻居 2——2 的邻居全在路上,死路,退回;再试 4,输出 0 1 3 4。
- 退回 0,走 2:0 → 2 → 1 → 3 → 4,输出 0 2 1 3 4;再从 2 走 3:0 → 2 → 3 → 4,输出 0 2 3 4(中途 0 → 2 → 3 → 1 是死路)。
- 共 4 条。若忘了最后那句“visited[u] = false”,第一条路用过的 1、2、3 就再也不能用了,只能输出第一条。
时间:路径条数可能随顶点数指数增长,最坏是指数级;空间 O(n)(path 数组 + 递归栈)。同样的回溯框架还能解“是否存在长度为 k 的路径”“经过所有顶点的路径”等题。(代码与“枚举所有顶点排列”的暴力法对 3000 个随机小图对拍;本例输出由程序打印。)
BFS 和 DFS 放在一起比
| BFS | DFS | |
|---|---|---|
| 辅助结构 | 队列(先发现的先扩展) | 栈(递归栈;最近发现的先深入) |
| 像树的哪种遍历 | 层序遍历 | 先序遍历 |
| 生成树的样子 | 又矮又宽:根到每个顶点都是最短路(边数最少) | 又高又窄:常常是一条长链 |
| 时间 / 空间 | 邻接表 O(n + e),矩阵 O(n²);队列 O(n) | 同左;递归栈 O(n) |
| 擅长的事 | 无权图最短路径、按层处理、离起点最近的某个点 | 判环、拓扑排序(逆后序)、找所有路径、连通分量、回溯类问题 |
| 序列唯一吗 | 邻接矩阵存储:起点定了就唯一;邻接表存储:随边表次序变化 | |
选哪个的口诀:问“最近、最少几步”用 BFS;问“能不能到、有没有环、所有方案”用 DFS。两者都能判连通、数连通分量(调用次数 = 连通分量数)。
- DFS 序列、深度优先生成树在邻接表存储下都不唯一,邻接矩阵存储下唯一(起点相同时)。
- DFS 的空间是递归栈 O(n)(最坏一条长链),不是 O(1)。
- 有向图从一个顶点出发 DFS 一次,未必能访问到所有顶点,即使图是“弱连通”的。
- 对 6.4 节的例图从 H 出发(邻接点按字母序)DFS,序列是什么?
答案
H → E → B → A → C → F(F 的邻居 C、H 已访问,回退到 C、A)→ D → G。序列 H E B A C F D G。 - 序列 A, B, E, H, C, F, G, D 是否可能是 6.4 节例图从 A 出发的 DFS 序列(邻接点次序任意)?
答案
不可能。A → B → E → H 都合法(每个都是上一个的未访问邻接点)。此时栈顶是 H,它还有未访问的邻接点 F、G,DFS 必须走到 F 或 G;序列却出现了 C,而 C 不是 H 的邻接点。判法:每个新顶点都必须是栈顶的邻接点,除非栈顶已经无路可走、需要回退。 - 用 6.5 节的 AllPath 在同一张图(0–1、0–2、1–2、1–3、2–3、3–4)里求 0 到 3 的所有简单路径,按输出次序写出来。
答案
0 1 2 3、0 1 3、0 2 1 3、0 2 3,共 4 条。每找到一条就回溯:退出某个顶点时把它的 visited 恢复,它才能出现在后面的路径里(例如 1 在第一条和第三条里都出现了)。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
B从 v0 先走 v1:v0 v1 v3,回溯到 v0 再走 v2:v0 v1 v3 v2。先走 v2:v0 v2 v3 v1。共 2 个。
变化点:原题边是 v0 指向 v1、v2、v3 且 v1→v3,v0 有 3 个出边,序列有 5 个。DFS 一旦进入一个顶点,就要把从它能到达的未访问顶点走完才回来,所以 v3 一定紧跟在先访问的那个中间顶点后面。
- A. 1, 2, 4, 3, 5
- B. 1, 3, 4, 5, 2
- C. 1, 2, 4, 5, 3
- D. 1, 3, 2, 4, 5
展开解析
DDFS 中,下一个访问的顶点必须是当前顶点的未访问邻接点;当前顶点没有未访问邻接点时,才能回退到上一个顶点继续找。D 中访问 3 后,3 的邻接点 4 尚未访问,却跳到了与 3 不相邻的 2,不合法。B:1→3→4→5,5 无路回退到 4,4→2 ✓。
变化点:判断 DFS 序列:维护一个“当前路径”栈,每个新顶点必须与栈顶相邻;不相邻时,只有在栈顶的邻接点都已访问的前提下才能弹栈。
- A. 拓扑有序序列
- B. 逆拓扑有序序列
- C. 广度优先搜索序列
- D. 深度优先搜索序列
展开解析
A对无环图,若有弧 <u, v>,则 v 一定比 u 先退出递归(v 要么在 u 的递归里访问完,要么早已访问完)。所以退出次序是逆拓扑序列,逆置后就是拓扑序列。
变化点:原题问的就是退出次序本身(逆拓扑序列,B)。多了“逆置”一步,答案翻转。这是用 DFS 做拓扑排序的原理。
- A. 遍历中遇到一个已访问过的顶点
- B. 遍历中遇到一个已访问过、且不是当前顶点双亲的顶点
- C. 存在出度为 0 的顶点
- D. 遍历中遇到一条指向“仍在递归栈中”(尚未访问结束)的顶点的弧
展开解析
D指向递归栈中的顶点 = 指向自己的祖先,沿树边再加上这条弧就构成有向回路。A 不对:a→b、a→c、c→b 中从 c 指向已访问完的 b 并没有环。B 是无向图的判据。C 与有无环无关。
变化点:无向图用“已访问且不是父亲”;有向图用“三色法”(未访问 / 在栈中 / 已结束),或者用拓扑排序看能否输出全部顶点。
6.6最小生成树:Prim 与 Kruskal
问题从哪里来
要在 n 个城市之间铺光缆,使任意两个城市都能连通,每条候选线路有不同的造价,怎样总造价最低?连通 n 个点至少要 n − 1 条边,而且不能有回路(有回路就说明有一条边是多余的)——所以答案是一棵生成树,要求的是边权之和最小的那一棵,叫最小生成树(MST)。2018-42 就是这个场景。
Prim 像修路扩张的村子:从一个村子出发,每次只修一条“从已通路的村子到未通路的村子”里最便宜的路,路网始终连成一片。Kruskal 像按价格挑工程:把所有候选路按造价从低到高排,一条条看,只要它连接的两个村子还没被路网连通就修,否则跳过(修了就白花钱,会形成环)。
哪里不像:现实修路可能有“必须经过某地”的约束;MST 只要求总造价最小、全部连通,不保证任意两点之间走的是最短路——这是 MST 和最短路径最容易混的地方(2023-06)。
Prim 算法:从一个顶点出发,让树一点点长大(加点)
- 任选一个顶点放入集合 U(已在树中的顶点)。
- 在所有“一端在 U 中、另一端不在 U 中”的边里选权值最小的一条,把它和它的另一端加入树。
- 重复第 2 步,直到所有顶点都进入 U。
展开解析
用 lowcost[] 记录每个不在树中的顶点“到树的最短边”,每步选最小的:
| 步 | U | lowcost:2 | 3 | 4 | 5 | 6 | 选中的边 |
|---|---|---|---|---|---|---|---|
| 0 | {1} | 6 | 1 | 5 | ∞ | ∞ | (1, 3) 权 1 |
| 1 | {1, 3} | 5 | — | 5 | 6 | 4 | (3, 6) 权 4 |
| 2 | {1, 3, 6} | 5 | — | 2 | 6 | — | (6, 4) 权 2 |
| 3 | {1, 3, 6, 4} | 5 | — | — | 6 | — | (3, 2) 权 5 |
| 4 | {1, 3, 6, 4, 2} | — | — | — | 3 | — | (2, 5) 权 3 |
总权 1 + 4 + 2 + 5 + 3 = 15。注意 Prim 加入的边权不一定递增(第 4 步选了 3,而前一步是 5)。
Prim 逐步图:树一步步长大
初始 U = {1}
① 选 (1, 3) 权 1
② 选 (3, 6) 权 4
③ 选 (6, 4) 权 2
④ 选 (3, 2) 权 5
⑤ 选 (2, 5) 权 3,完成
void Prim(MGraph G, int v0) { // Edge[i][j] 为权值,无边为 INF int lowcost[MAXV], adjvex[MAXV]; // lowcost[i]:i 到树的最短边权 // adjvex[i]:那条边在树中的端点 bool inTree[MAXV] = {false}; int n = G.numVertices; inTree[v0] = true; for (int i = 0; i < n; i++) { lowcost[i] = G.Edge[v0][i]; adjvex[i] = v0; } for (int k = 1; k < n; k++) { // 还要选 n-1 条边 int u = -1, min = INF; for (int i = 0; i < n; i++) // 找离树最近的顶点 if (!inTree[i] && lowcost[i] < min) { min = lowcost[i]; u = i; } printf("(%d, %d) %d\n", adjvex[u], u, min); inTree[u] = true; for (int i = 0; i < n; i++) // u 进树后,更新其他顶点到树的距离 if (!inTree[i] && G.Edge[u][i] < lowcost[i]) { lowcost[i] = G.Edge[u][i]; adjvex[i] = u; } } }
Prim 的时间 O(n²),与边数无关,适合稠密图。
Kruskal 算法:从最短的边开始挑(加边)
- 开始时每个顶点自成一棵树(n 棵树的森林)。
- 按权值从小到大考察每条边:如果它的两个端点在不同的树里,就选它(两棵树合成一棵);在同一棵树里就跳过(选了会成环)。
- 选够 n − 1 条边为止。
对图 6-12:(1,3)1 选 → (4,6)2 选 → (2,5)3 选 → (3,6)4 选 → 权 5 的边有 (1,4)、(2,3)、(3,4):(1,4) 两端已连通跳过,(2,3) 选(或按另一顺序先看 (3,4) 跳过)。结果和 Prim 相同,总权 15。
“两端是否在同一棵树”用并查集判断(5.10 节)。排序 O(e log e),并查集操作近似 O(1),总时间 O(e log e),适合稀疏图。
Kruskal 逐步表:排序 + 并查集判环
| 次序 | 考察的边 | 权 | 两端各在哪个集合(Find) | 选 / 弃 | 选完后的集合 |
|---|---|---|---|---|---|
| 1 | (1, 3) | 1 | {1}、{3} 不同 | 选 | {1, 3} {2} {4} {5} {6} |
| 2 | (4, 6) | 2 | {4}、{6} 不同 | 选 | {1, 3} {4, 6} {2} {5} |
| 3 | (2, 5) | 3 | {2}、{5} 不同 | 选 | {1, 3} {4, 6} {2, 5} |
| 4 | (3, 6) | 4 | {1, 3}、{4, 6} 不同 | 选 | {1, 3, 4, 6} {2, 5} |
| 5 | (1, 4) | 5 | 都在 {1, 3, 4, 6} | 弃(成环 1–3–6–4–1) | 不变 |
| 6 | (2, 3) | 5 | {2, 5}、{1, 3, 4, 6} 不同 | 选 | 全部连通,已选 5 = n − 1 条,结束 |
选出 (1,3)、(4,6)、(2,5)、(3,6)、(2,3),总权 1 + 2 + 3 + 4 + 5 = 15,与 Prim 结果相同。注意 Kruskal 选边的权值非递减,Prim 则可能先选 5 再选 3。(两种算法的每一步已用程序模拟核对。)
Kruskal 的代码:排序 + 并查集
typedef struct { int u, v, w; } Edge; int Kruskal(Edge E[], int n, int m) { // 顶点 0..n-1,边 E[0..m-1];返回 MST 总权,不连通返回 -1 int S[MAXV], cnt = 0, sum = 0; for (int i = 0; i < n; i++) S[i] = -1; // 并查集初始化:每个顶点自成一棵树 qsort(E, m, sizeof(Edge), cmp); // 按权值从小到大排序(cmp 比较 w) for (int i = 0; i < m && cnt < n - 1; i++) { int r1 = Find(S, E[i].u), r2 = Find(S, E[i].v); if (r1 != r2) { // 两端不在同一棵树:选它,两棵树合并 Union(S, r1, r2); sum += E[i].w; cnt++; } // 否则选了会成环,跳过 } return cnt == n - 1 ? sum : -1; }
Find、Union 用 5.10 节带优化的版本。排序 O(e log e) 是瓶颈,后面的循环约 O(e·α(n))。代码与 Prim 在 5000 个随机图上比较总权全部一致(不连通时两者都返回 −1)。
为什么贪心是对的(MST 性质):把顶点任意分成两堆 U 和 V − U,跨两堆的边里最便宜的那条 (u, v) 一定在某棵 MST 里——假如某棵 MST 不含它,把它加进去会形成一个回路,回路上必有另一条跨两堆的边,删掉那条(不比它便宜)得到的树不会更贵。Prim 每次的“U = 已在树中的顶点”、Kruskal 每次的“U = 这条边一端所在的那棵树”,都是在用这条性质。
| Prim | Kruskal | |
|---|---|---|
| 思路 | 从一个顶点出发,每次加一个顶点(连着它的最短边) | 按边权从小到大,每次加一条不成环的边 |
| 时间 | O(n²) | O(e log e) |
| 适合 | 稠密图 | 稀疏图 |
| 过程中的状态 | 始终是一棵树 | 是森林,最后合成一棵树 |
| 选边顺序 | 边权不一定递增 | 边权递增(非递减) |
Prim 和 Kruskal 到底差多少:算一笔账。1000 个顶点、3000 条边的稀疏图:Prim(邻接矩阵版)约 n² = 1,000,000 次基本操作;Kruskal 主要花在给边排序上,约 e·log₂e ≈ 3000 × 11.6 ≈ 35,000 次,快了近 30 倍。换成 1000 个顶点的完全图(e = 499,500):Kruskal 约 499,500 × 18.9 ≈ 9,500,000 次,Prim 仍是 1,000,000 次,反过来 Prim 快了近 10 倍。这就是“稀疏图用 Kruskal、稠密图用 Prim”的来历——复杂度里含 n 的看点数,含 e 的看边数。(估算由程序计算。)
Prim 和 Dijkstra 长得很像,别混
两个算法的骨架几乎一样:一个数组记“每个不在集合里的顶点的某个值”,每轮挑最小的加入集合,再用它更新别人。差别只在数组的含义和更新公式:
| Prim(最小生成树) | Dijkstra(最短路径) | |
|---|---|---|
| 数组含义 | lowcost[i]:i 到整棵树的最短一条边 | dist[i]:源点到 i 的最短路径长度 |
| 更新公式 | lowcost[i] = min(lowcost[i], w(u, i)) | dist[i] = min(dist[i], dist[u] + w(u, i)) |
| 得到的树 | 总权最小的生成树 | 最短路径树:源点到每个顶点的路径都最短 |
| 适用 | 无向连通网 | 有向 / 无向网,边权非负 |
对图 6-12 从顶点 1 跑 Dijkstra:dist = 2:6、3:1、4:5、5:7、6:5,最短路径树由 1–2、1–3、1–4、3–5、3–6 组成,总权 22(2 也可以挂在 3 下,总权 21),都比 MST 的 15 大;反过来,MST 中 1 到 5 的路径 1–3–2–5 长 9,也不是最短的 7。两棵树一般不同,这正是 2023-06 的考点。(数据由程序计算。)
MST 什么时候唯一
“各边权值互不相同 ⇒ MST 唯一”是充分条件,不是必要条件:图 6-12 有三条权为 5 的边,可程序穷举它全部的生成树后发现,权值为 15 的只有一棵——MST 依然唯一。真正好用的判据是:
先求出一棵 MST。对每一条没被选中的边 (u, v):看它和树上 u、v 之间的那条路径组成的回路。若这条边的权严格大于路径上所有边的权,它就不可能替换进来;所有非树边都满足这一点 ⇔ MST 唯一。只要有一条非树边的权等于路径上某条边的权,把两者互换就得到另一棵同样代价的 MST。
图 6-12(唯一):非树边 (1,4) 权 5,树上路径 1–3–6–4 的边权 1、4、2,最大 4 < 5;(3,4) 权 5 对路径 3–6–4(4、2);(1,2)、(3,5)、(5,6) 权 6 对应路径上的最大边权都是 5。全部“严格大于” ⇒ 唯一。
正方形四边权 2、对角线权 3:非树边 d–a 权 2 = 路径 d–c–b–a 上的边权 ⇒ 不唯一(共 4 棵 MST,总权都是 6)
判据已在 300 个随机小图上与“穷举全部生成树、数一数最小的有几棵”对拍一致。2017-42 第 (2)(3) 问考的就是唯一性的判断与理由。
- A. (V1, V3)
- B. (V1, V4)
- C. (V2, V3)
- D. (V3, V4)
展开解析
C
- Kruskal:第 1 次选最小的 (V1, V4) 5;第 2 次在三条权 8 的边 (V1, V3)、(V2, V3)、(V3, V4) 中任选一条都不成环,三条都可能。
- Prim 从 V4 开始:第 1 次选 (V4, V1) 5;此时树 = {V4, V1},连出去的边有 V1−V2 10、V1−V3 8、V4−V2 11、V4−V3 8,第 2 次只能选 (V1, V3) 或 (V3, V4)。
- Kruskal 可能第 2 次选、Prim 不可能第 2 次选的是 (V2, V3):它的两个端点都不在 Prim 当前的树里。
- A 错(V1, V3) 两种算法第 2 次都可能选。
- B 错(V1, V4) 是两者的第 1 次。
- C 对(V2, V3)。
- D 错(V3, V4) 两种算法第 2 次都可能选。
Prim 每次选的边必有一端在当前树里;Kruskal 只看边权和是否成环。
真题怎么考
- MST 性质辨析(2012-08):树形可能不唯一,代价唯一;最小边不一定在每棵 MST 中。
- Prim 与 Kruskal 选边次序对比(2015-06):Kruskal 按权全局排序,Prim 只看与当前树相连的边——列两张表各自模拟最稳。
- MST 与最短路径的区别(2023-06)。
- 大题:2017-42 手算 Prim 并判断唯一性;2018-42 光缆网络(求所有可能的 MST、TTL 问题)。第 9 篇 t5 有完整解答。
- Prim 每次比较的是“到整棵树的最短边”(lowcost),不是“到最后加入的那个顶点”的最短边。
- Kruskal 判环要看两端是否在同一个连通分量,不是看两端是否“都已经被选过”——两端都出现过但分属两棵树时仍然要选。
- Prim O(n²) 与边数无关(稠密图好),Kruskal O(e log e)(稀疏图好);说反了是常见失分点。
- 对图 6-12 从顶点 5 开始执行 Prim,依次选中的边是什么?
答案
(5, 2) 3 → (2, 3) 5 → (3, 1) 1 → (3, 6) 4 → (6, 4) 2,总权 15。(每步在“已入树顶点连出的边”里取最小:U = {5} 时最小是 (5,2) 3;U = {5,2} 时最小是 (2,3) 5……) - 判断:“带权连通图中权值最小的边一定在每棵最小生成树中。”
答案
错。例如上面正方形的四条边权值都是 2(都是最小边),任何一棵 MST 只选其中 3 条。若最小边唯一,它才一定在每棵 MST 中。 - 某省要在 1000 个乡镇之间铺光缆,可选线路只有 3000 条(各有造价)。求最小生成树,Prim(邻接矩阵版)和 Kruskal 选哪个?
答案
边很少(稀疏图),选 Kruskal:约 e·log₂e ≈ 3.5 万次操作,Prim 约 n² = 100 万次。若 1000 个乡镇两两之间都有候选线路(稠密),就反过来选 Prim。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
I. 若图中各边权值互不相同,则最小生成树唯一
II. 最小生成树中一定包含图中某一条权值最小的边
III. 最小生成树中一定不包含图中权值最大的边
IV. n 个顶点的连通图,其最小生成树有 n − 1 条边
- A. 仅 I、IV
- B. 仅 II、III
- C. I、II、III、IV
- D. 仅 I、II、IV
展开解析
DI 对:权值各不相同时最小生成树唯一。II 对:Kruskal 第一条选中的就是一条权值最小的边(它不可能构成回路)。III 错:若权值最大的边是桥(删掉就不连通),它必须在生成树中。IV 对:生成树都有 n − 1 条边。
变化点:原题 II 说“所有权值最小的边一定出现在所有最小生成树中”是错的(几条等权最小边可能构成回路)。这里改成“某一条”就对了。注意量词。
- A. (a, c)
- B. (b, e)
- C. (b, c)
- D. (d, f)
展开解析
DKruskal 按权从小到大:(a,c)1、(d,f)2、(b,e)3、(b,c)4、(a,d)5,第 2 次选 (d, f)。Prim 从 a 出发:(a,c)1;此时与 {a, c} 相连的最小边是 (b,c)4;再是 (b,e)3、(a,d)5、(d,f)2。第 2 次选 (b, c)。所以答案是 (d, f)。
变化点:Kruskal 看全图最小的边(只要不成环),可以同时长出几棵小树;Prim 只看与当前树相连的边,始终是一棵树。两者得到的树一样(权值各不相同),但选边次序不同。
6.7最短路径:BFS、Dijkstra 与 Floyd
两种问题、两种思路
Dijkstra 像在源点点一把火,火沿着边以匀速蔓延:最先烧到的顶点离源点最近,一旦被烧到,它的最短距离就定下来了;然后火再从它继续往外烧。Floyd 像航空公司逐个开放中转机场:先只允许直飞,再允许在 0 号机场转机,再允许在 0、1 号转机……每开放一个中转站,就检查所有航线能不能因此变短。
哪里不像:火不会“倒着烧回来变得更快”,所以 Dijkstra 遇到负权边就失灵(见下面的反例);Floyd 允许负权边,但不允许“绕一圈总长为负”的回路——那样可以无限绕圈,最短路不存在。
带权有向图中,从一个顶点到另一个顶点的路径上边权之和最小的那条叫最短路径。两类问题:单源最短路径(从一个源点到其余各顶点)用 Dijkstra;每对顶点之间的最短路径用 Floyd(或对每个顶点跑一次 Dijkstra)。
Dijkstra 算法
思想:按路径长度递增的次序逐个确定最短路径。设集合 S 存放已经求出最短路径的顶点,dist[i] 表示“目前找到的”源点到 i 的最短路径长度,path[i] 记录这条路径上 i 的前驱。
- 初始:S = {v₀};dist[i] = 边 <v₀, i> 的权(没有边为 ∞);有边的 path[i] = v₀,否则 −1。
- 从不在 S 中的顶点里选 dist 最小的 u,把 u 加入 S——此时 dist[u] 就是 v₀ 到 u 的最短路径长度(最终确定)。
- 用 u 去“松弛”其余不在 S 中的顶点 j:若 dist[u] + w(u, j) < dist[j],则 dist[j] = dist[u] + w(u, j),path[j] = u。
- 重复 2、3,共 n − 1 次。
展开解析
| 轮 | dist[1] | dist[2] | dist[3] | dist[4] | 选中 |
|---|---|---|---|---|---|
| 初始 | 10 | ∞ | ∞ | 5 | 4(5) |
| 用 4 松弛 | 8(0→4→1) | 14(0→4→2) | 7(0→4→3) | ✓ | 3(7) |
| 用 3 松弛 | 8 | 13(0→4→3→2) | ✓ | ✓ | 1(8) |
| 用 1 松弛 | ✓ | 9(0→4→1→2) | ✓ | ✓ | 2(9) |
依次得到的最短路径:0→4(5)、0→4→3(7)、0→4→1(8)、0→4→1→2(9)。path 数组:path[1] = 4、path[2] = 1、path[3] = 4、path[4] = 0,从终点沿 path 倒推就能得到路径。
王道式的完整表格(每格写“dist / path”,✓ 表示已确定加入 S)。考场上按这个格式写,既不会漏松弛,也方便最后倒推路径:
| 顶点 | 初始 | 第 1 轮 | 第 2 轮 | 第 3 轮 | 第 4 轮 |
|---|---|---|---|---|---|
| 1 | 10 / 0 | 8 / 4 | 8 / 4 | ✓ 8 / 4 | ✓ |
| 2 | ∞ / −1 | 14 / 4 | 13 / 3 | 9 / 1 | ✓ 9 / 1 |
| 3 | ∞ / −1 | 7 / 4 | ✓ 7 / 4 | ✓ | ✓ |
| 4 | 5 / 0 ← 最小 | ✓ | ✓ | ✓ | ✓ |
| 本轮选中 | 4(5) | 3(7) | 1(8) | 2(9) | — |
| S | {0, 4} | {0, 4, 3} | {0, 4, 3, 1} | {0, 4, 3, 1, 2} |
读表:第 1 列“初始”是 0 的直接出边;每一列先在未确定的格子里挑最小的(标 ✓),再用它松弛其余格子,结果写进下一列。倒推路径:2 的 path 是 1,1 的 path 是 4,4 的 path 是 0 ⇒ 0 → 4 → 1 → 2,长度 9。(每一列都用程序跑讲义代码核对过。)
void Dijkstra(MGraph G, int v0, int dist[], int path[]) { bool final[MAXV]; int n = G.numVertices; for (int i = 0; i < n; i++) { final[i] = false; dist[i] = G.Edge[v0][i]; path[i] = (G.Edge[v0][i] < INF) ? v0 : -1; } final[v0] = true; dist[v0] = 0; path[v0] = -1; for (int k = 1; k < n; k++) { int u = -1, min = INF; for (int i = 0; i < n; i++) // 选当前最近的顶点 if (!final[i] && dist[i] < min) { min = dist[i]; u = i; } if (u == -1) break; // 其余顶点不可达 final[u] = true; for (int j = 0; j < n; j++) // 松弛 if (!final[j] && G.Edge[u][j] < INF && dist[u] + G.Edge[u][j] < dist[j]) { dist[j] = dist[u] + G.Edge[u][j]; path[j] = u; } } }
时间 O(n²)(邻接矩阵或邻接表都是,因为每轮选最小要扫一遍)。
代码逐段读:① 初始化时 dist 先填“直接走一条边”的长度,有边的 path 记 v0;② 外层循环 n − 1 次,每次定下一个顶点;③ 选最小时跳过已确定的,u == −1 说明剩下的顶点 dist 全是 INF、从源点根本到不了,提前结束;④ 松弛的三个条件缺一不可:j 还没确定、u 到 j 有边、新路更短。第二个条件防的是“无穷大相加溢出”——INF 若取 int 的最大值 2147483647,dist[u] + INF 会溢出成负数,反而显得“更短”,把 dist[j] 改坏。所以要么先判 G.Edge[u][j] < INF,要么把 INF 取成一个不会溢出的大数(如 0x3f3f3f3f)。
为什么每轮选出的 dist 最小的顶点 u 就“定下来了”:如果还有一条更短的路到 u,它必须先离开 S 到某个不在 S 的顶点 x,而 dist[x] ≥ dist[u],后面的边又都非负,所以这条路不可能比 dist[u] 短。这个论证用到了“边权非负”——负权边会让它失效(见下面的反例)。Floyd 为什么 k 在最外层:A⁽ᵏ⁾ 要用到 A⁽ᵏ⁻¹⁾ 的整个矩阵,必须等“只经过 v₀ ~ vₖ₋₁”这一层全部算完,才能开放下一个中转点。
- 不能有负权边:它假设“加入 S 的顶点距离不会再变小”,负权边会打破这个假设。
- 它和 2009-41 那种“每次走到离当前顶点最近的点”的贪心不同:Dijkstra 每次选的是离源点(经过 S 中顶点中转)最近的顶点。
反例:Dijkstra 为什么不能有负权边
- 初始 dist[B] = 2、dist[C] = 3,选最小的 B,宣布 B 的最短距离是 2,B 加入 S。
- 再选 C(3),用 C 松弛:3 + (−2) = 1 < 2,可 B 已经在 S 里,代码不再更新它。
- 结果 dist[B] = 2,而真正的最短路 A → C → B 长度是 1。
原因:Dijkstra 的正确性依赖“路径越走越长”——已经确定的顶点,绕别的顶点走回来只会更长。负权边打破了这一点。(程序实测:讲义 Dijkstra 给出 2,Floyd 给出 1。)
Floyd 算法:每对顶点
思想:依次允许顶点 v₀、v₁、…、vₙ₋₁ 作为中转站。A⁽ᵏ⁾[i][j] 表示“中间只经过 v₀ ~ vₖ”时 i 到 j 的最短路径长度:
for (int k = 0; k < n; k++) // 中转站 for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) if (A[i][k] + A[k][j] < A[i][j]) { A[i][j] = A[i][k] + A[k][j]; path[i][j] = k; // 记录中转点 }
展开解析
| 矩阵 | [0][1] | [0][2] | [1][0] | [1][2] | [2][0] | [2][1] | 变化 |
|---|---|---|---|---|---|---|---|
| A⁽⁻¹⁾ | 6 | 13 | 10 | 4 | 5 | ∞ | 初始 |
| A⁽⁰⁾(经 0) | 6 | 13 | 10 | 4 | 5 | 11 | 2→0→1 = 5 + 6 |
| A⁽¹⁾(经 1) | 6 | 10 | 10 | 4 | 5 | 11 | 0→1→2 = 6 + 4 |
| A⁽²⁾(经 2) | 6 | 10 | 9 | 4 | 5 | 11 | 1→2→0 = 4 + 5 |
把四个矩阵都写出来(括号里是 path,即最后一次让它变短的中转顶点,−1 表示直达):
| A⁽⁻¹⁾ | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 0 | 6 | 13 |
| 1 | 10 | 0 | 4 |
| 2 | 5 | ∞ | 0 |
| A⁽⁰⁾ 经 0 | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 0 | 6 | 13 |
| 1 | 10 | 0 | 4 |
| 2 | 5 | 11 (0) | 0 |
| A⁽¹⁾ 经 1 | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 0 | 6 | 10 (1) |
| 1 | 10 | 0 | 4 |
| 2 | 5 | 11 (0) | 0 |
| A⁽²⁾ 经 2 | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 0 | 6 | 10 (1) |
| 1 | 9 (2) | 0 | 4 |
| 2 | 5 | 11 (0) | 0 |
由 path 还原路径:1 到 0 的 path 是 2 ⇒ 路径 = [1 到 2] + [2 到 0];path[1][2] = −1、path[2][0] = −1,都是直达 ⇒ 1 → 2 → 0,长度 4 + 5 = 9。规则:path[i][j] = k 就把问题拆成 i → k 和 k → j 两段,递归拆到 −1 为止。(四个矩阵与 path 由程序逐轮打印核对。)
负回路检测:Floyd 结束后若某个 A[i][i] < 0,说明顶点 i 在一个负权回路上,此时“最短路径”没有意义。
拓展 Floyd 的“近亲”:判断任意两点能不能到达。把 0/1 邻接矩阵当作 A,把“A[i][k] + A[k][j] < A[i][j] 就更新”换成“A[i][k] 且 A[k][j] 为 1 就把 A[i][j] 置 1”,三重循环跑完,A[i][j] = 1 就表示 i 能走到 j(这叫求传递闭包,Warshall 算法)。例如只有 A→B、B→C 两条弧,开放中转点 B 后就补上了 A→C。它和 Floyd 一样是 O(n³),k 也必须在最外层。有向图“是否强连通”就可以用它判断:闭包矩阵全为 1 即强连通。
先检查回路 0→1→2→3→0 的总权 4 − 3 + 1 + 5 = 7 ≥ 0,另一条回路 0→2→3→0 总权 8,没有负回路,可以用 Floyd。逐个开放中转点,只记“变小了”的格子:
| 开放中转点 | 变小的格子(新路径) |
|---|---|
| k = 0 | A[3][1] = 9(3→0→1),A[3][2] = 7(3→0→2) |
| k = 1 | A[0][2] = 1(0→1→2,比直达的 2 短,靠的就是负权边),A[3][2] = 6(3→0→1→2) |
| k = 2 | A[0][3] = 2(0→1→2→3),A[1][3] = −2(1→2→3) |
| k = 3 | A[1][0] = 3(1→2→3→0),A[2][0] = 6(2→3→0),A[2][1] = 10(2→3→0→1) |
| 最终 A(path) | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 4 | 1 (1) | 2 (2) |
| 1 | 3 (3) | 0 | −3 | −2 (2) |
| 2 | 6 (3) | 10 (3) | 0 | 1 |
| 3 | 5 | 9 (0) | 6 (1) | 0 |
对角线全为 0,确认无负回路。同一个图用 Dijkstra 从 0 出发:第一轮选 dist 最小的 2(值 2)并定下来,之后再从 1 绕过来的 1 + … 已无法更新它,最终给出 dist[2] = 2、dist[3] = 3,而正确答案是 1 和 2——负权边让 Dijkstra 出错,Floyd 不受影响。(每轮变化与最终矩阵由程序打印核对。)
时间 O(n³),空间 O(n²)。Floyd 允许负权边,但不允许有“权值之和为负的回路”(否则可以无限绕圈变小)。
适用性矩阵:遇到题先看条件再选算法
| BFS | Dijkstra | Floyd | |
|---|---|---|---|
| 解决的问题 | 单源,无权(或权都相同) | 单源,带权 | 所有顶点对之间 |
| 思想 | 按层扩展 | 贪心:每次确定离源点最近的顶点 | 动态规划:逐个开放中转点 |
| 负权边 | — | 不行 | 可以 |
| 负权回路 | — | 不行 | 不行(但能检测出来) |
| 时间 | O(n + e)(邻接表) | O(n²);拓展 堆优化 O((n + e) log n) | O(n³) |
| 求所有顶点对 | 对每个顶点做一次 BFS | 对每个顶点做一次,O(n³) | 直接 O(n³),代码更短 |
预测大题:在邻接表上写 Dijkstra,并输出路径
void Dijkstra_AL(ALGraph G, int v0, int dist[], int path[]) { // 弧结点带 weight 域 bool final[MAXV]; for (int i = 0; i < G.vexnum; i++) { final[i] = false; dist[i] = INF; path[i] = -1; } dist[v0] = 0; for (int k = 0; k < G.vexnum; k++) { int u = -1; for (int i = 0; i < G.vexnum; i++) // 选未确定顶点中 dist 最小的 if (!final[i] && dist[i] < INF && (u == -1 || dist[i] < dist[u])) u = i; if (u == -1) break; // 剩下的都不可达 final[u] = true; for (ArcNode *p = G.vertices[u].first; p != NULL; p = p->next) { // 只看 u 的出弧 int j = p->adjvex; if (!final[j] && dist[u] + p->weight < dist[j]) { dist[j] = dist[u] + p->weight; path[j] = u; } } } } void PrintPath(int path[], int v) { // 递归输出源点到 v 的路径 if (path[v] != -1) PrintPath(path, path[v]); printf("%d ", v); }
和矩阵版的区别只在松弛:邻接表只遍历 u 的出弧,总共 O(e);选最小仍要 O(n) 一次、共 n 次,所以总时间 O(n² + e) = O(n²)。评分要点:初始化(dist、path、final)、选最小、松弛三段齐全;不可达顶点要能跳出。代码已与 Floyd 在 5000 个随机有向网上对拍,路径回溯长度也逐一核对。
- A. 5, 2, 3, 4, 6
- B. 5, 2, 3, 6, 4
- C. 5, 2, 4, 3, 6
- D. 5, 2, 6, 3, 4
展开解析
B
| 步骤 | 2 | 3 | 4 | 5 | 6 | 选中 |
|---|---|---|---|---|---|---|
| 初始 | 5 | ∞ | ∞ | 4 | ∞ | 5(4) |
| 用 5 松弛 | 5 | ∞ | 11 | ✓ | 9 | 2(5) |
| 用 2 松弛 | ✓ | 7 | 11 | ✓ | 9 | 3(7) |
| 用 3 松弛 | ✓ | ✓ | 11 | ✓ | 9 | 6(9) |
| 用 6 松弛 | ✓ | ✓ | 11 | ✓ | ✓ | 4(11) |
- A 错4 的最短距离 11 > 6 的 9,4 最后。
- B 对5, 2, 3, 6, 4。
- C 错4 排在第 3 个;可 4 的最短距离 11 是所有顶点中最大的,应最后确定(理由同 A)。
- D 错3(7)比 6(9)先确定。
每一轮都在全部未确定的顶点里挑 dist 最小的,不要只盯着刚加入顶点的邻居。
真题怎么考
- Dijkstra 依次求得的顶点次序(2016-08)、某一轮后的 dist(2012-07、2021-08):按上面的表格法一列一列写。
- 无权图最短路径用什么(2023-06):BFS。
- 大题:2009-41 让你判断“每次走到离当前顶点最近的点”这种贪心能不能求最短路(不能,举反例);2014-42 借 OSPF 外壳考邻接表设计 + Dijkstra。第 9 篇 t5 有完整解答。
- Dijkstra 每轮选的是“离源点最近”(dist 最小)的顶点,不是离上一个选中顶点最近的。
- 松弛只对未确定的顶点做;已确定的 dist 不会再变(非负权时)。
- Floyd 三重循环的最外层必须是中转点 k;把 k 放在内层就错了。
- Floyd 的 A⁽ᵏ⁾ 中,第 k 行、第 k 列在这一轮不会变化(经 k 中转到 k 自己没有意义),可以用来快速检查手算结果。
- 对图 6-14 以顶点 1 为源点执行 Dijkstra,依次确定的顶点及其最短路径长度是什么?
答案
初始 dist[2] = 1、dist[4] = 2。依次确定 2(1)→ 4(2)→ 3(4,路径 1→4→3)→ 0(11,路径 1→4→3→0)。 - 一个带负权边但没有负权回路的有向图,求所有顶点对的最短路径,能用 Dijkstra 吗?用什么?
答案
不能用 Dijkstra;用 Floyd(允许负权边,只要没有负回路)。 - Floyd 算法中,A⁽ᵏ⁾ 第 k 行的元素与 A⁽ᵏ⁻¹⁾ 相比会变吗?
答案
不会:A[k][j] 经 k 中转 = A[k][k] + A[k][j] = 0 + A[k][j],不会更小(无负回路时 A[k][k] = 0)。第 k 列同理。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 10, 50, 30, 90
- B. 10, 60, 30, 100
- C. 10, 50, 30, 60
- D. 10, 60, 30, 90
展开解析
A初始 dist = 10, ∞, 30, 100。第一条:顶点 2(10),用它松弛:dist[3] = 10 + 50 = 60。第二条:顶点 4(30),松弛:dist[3] = min(60, 30 + 20) = 50,dist[5] = min(100, 30 + 60) = 90。此时 dist = 10, 50, 30, 90。之后再选 3(50),dist[5] 更新为 60。
变化点:C 是算法结束时的最终结果,B 是只求出第一条路径后的状态。“求出第 k 条最短路径后”指选出第 k 个顶点并用它松弛之后。
I. Dijkstra 算法(对每个顶点各执行一次)
II. Floyd 算法
III. 广度优先搜索
- A. 仅 I
- B. 仅 I、II
- C. I、II、III
- D. 仅 II
展开解析
DDijkstra 假设“已确定的最短路径不会再变短”,有负权边时不成立。BFS 只适用于各边权值相同的图。Floyd 允许负权边(只要没有负权回路)。
变化点:原题各边权值均为 1,BFS 就能求最短路径。三种算法的适用范围:BFS—无权(等权)图;Dijkstra—非负权;Floyd—可有负权边、不能有负权回路。
6.8有向无环图描述表达式
没有回路的有向图叫有向无环图(DAG)。用二叉树表示表达式时,相同的子表达式要重复画;用 DAG 可以让相同的子表达式共享一个结点,节省空间。
- 把所有不同的操作数各画一次(最底层)。
- 按运算的先后顺序逐个画运算符,每画一个之前看看:有没有已经画过的、左右操作数完全相同的同一运算,有就直接复用。
- 数总顶点数。注意交换律一般不考虑(a+b 与 b+a 按不同处理,除非题目说明)。
展开解析
操作数 a、b、c、d、e:5 个。运算:① a + b;② c + d(出现 3 次,共享);③ b × ②;④ ① × ③;⑤ ② × e(出现 2 次,共享);⑥ ④ + ⑤;⑦ ⑥ × ⑤。共 7 个运算符。总计 12 个顶点。
跟我做一遍:逐个运算查“能不能复用”
思路和电子表格一样:一个中间结果算出来放在一个格子里,后面再用到它时引用那个格子,而不是重新算一遍——DAG 里“引用”就是多画一条指向同一个顶点的弧。
用有向无环图表示该表达式,最少需要几个顶点?若用二叉树表示需要几个结点?
| 按计算次序 | 运算 | 之前有没有完全相同的? | 新增顶点 |
|---|---|---|---|
| 0 | 操作数 a、b、c、d | 各画一次 | 4 |
| 1 | ① a + b | 没有 | 1 |
| 2 | ② ① + c | 没有 | 1 |
| 3 | ③ ① × ② | 没有(① 和 ② 都直接复用) | 1 |
| 4 | ④ ② × d | 没有(② 复用) | 1 |
| 5 | ⑤ ③ + ④ | 没有 | 1 |
| 合计 | 9 | ||
用二叉树表示时 a + b 要画 3 次、(a + b) + c 要画 2 次:叶子 9 个、运算符 8 个,共 17 个结点。DAG 只要 9 个。(两种计数都由程序“对相同子表达式去重”得到。)
- 只有运算符和两个操作数都完全相同的子表达式才能共享;(a + b) 与 (b + a) 默认不合并(除非题目说明可用交换律)。
- 操作数(字母)本身也是顶点,每个只画一次。
- 必须按计算次序从里到外一层层查:先确认 a + b 能共享,才能发现 (a + b) + c 也能共享。若跳着看,容易漏掉“建立在共享结点上的共享”,多画顶点。
- DAG 里一个顶点可以有多个“双亲”(被多处引用),这正是它不是树的地方;但它绝不能有回路——表达式里不会出现“自己依赖自己”。
- 表达式 (x + y) × (x + y) × z 用 DAG 表示至少几个顶点?(按从左到右结合:((x + y) × (x + y)) × z)
答案
操作数 x、y、z 共 3 个;运算 ① x + y;② ① × ①(两个操作数都是同一个顶点 ①);③ ② × z。共 3 + 3 = 6 个顶点。 - DAG 表示表达式时,哪些顶点的出度为 0?
答案
操作数(叶子):它们不再指向任何子表达式。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 6
- B. 7
- C. 8
- D. 13
展开解析
C操作数 a、b、c 各一个(3 个);运算 a+b 出现两次,只保留一个;b+c 一个;(a+b)*(b+c) 一个;(a+b)*c 一个;最外层 + 一个。共 3 + 1 + 1 + 1 + 1 + 1 = 8。
变化点:D 是用二叉树(不共享子表达式)表示时的结点数:7 个操作数 + 6 个运算符 = 13。方法:先合并相同的操作数,再自底向上合并相同的子表达式,每种只留一个顶点。
6.9拓扑排序
拓扑排序在解决什么问题
大学选课有先修关系:学数据结构前要学 C 语言和离散数学。把课程排成一个学习顺序,使每门课都排在它的所有先修课之后——这就是拓扑排序。穿衣服也是:袜子在鞋前、衬衫在领带前,但袜子和衬衫谁先都行,所以拓扑序列往往不唯一。
哪里不像:现实中可以同时做几件事;拓扑序列要求排成一条线。另外现实里如果出现“A 要先于 B、B 又要先于 A”,人会去改规则;图里出现这种回路就不存在拓扑序列。
AOV 网与入度法
用顶点表示活动、有向边 <vᵢ, vⱼ> 表示“vᵢ 必须先于 vⱼ 进行”的有向图叫 AOV 网(Activity On Vertex)。例如课程和先修关系。拓扑排序就是把所有顶点排成一个序列,使得每条边的起点都排在终点前面。
- 从图中选一个入度为 0(没有前驱)的顶点输出。
- 删去它和从它出发的所有边(后继的入度都减 1)。
- 重复,直到图空(成功)或者剩下的顶点入度都不为 0(说明有回路,不存在拓扑序列)。
bool TopologicalSort(ALGraph G, int topo[]) { int indegree[MAXV] = {0}, S[MAXV], top = -1, count = 0; for (int i = 0; i < G.vexnum; i++) // 统计入度 for (ArcNode *p = G.vertices[i].first; p != NULL; p = p->next) indegree[p->adjvex]++; for (int i = 0; i < G.vexnum; i++) if (indegree[i] == 0) S[++top] = i; // 入度为 0 的进栈 while (top != -1) { int v = S[top--]; topo[count++] = v; // 输出 for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) if (--indegree[p->adjvex] == 0) // 后继入度减 1,变为 0 就进栈 S[++top] = p->adjvex; } return count == G.vexnum; // 没输出完 ⇒ 有回路 }
邻接表 O(n + e)(2016-07),邻接矩阵 O(n²)。用栈或队列存入度为 0 的顶点都可以(结果可能不同,都是合法的拓扑序列)。
跟我做一遍:用栈和用队列,得到的序列不同
初始入度:1、2 为 0;3、6 为 1;4、5 为 2。初始把入度为 0 的按编号依次放入容器(1 先、2 后):
| 输出 | 后继入度减 1 | 队列 |
|---|---|---|
| — | 初始 | 1 2 |
| 1 | 3→0 入队,4→1 | 2 3 |
| 2 | 4→0 入队 | 3 4 |
| 3 | 5→1 | 4 |
| 4 | 5→0、6→0 入队 | 5 6 |
| 5、6 | 无后继 | 空 |
序列 1 2 3 4 5 6
| 输出 | 后继入度减 1 | 栈(底→顶) |
|---|---|---|
| — | 初始 | 1 2 |
| 2 | 4→1 | 1 |
| 1 | 3→0、4→0 入栈 | 3 4 |
| 4 | 5→1、6→0 入栈 | 3 6 |
| 6 | 无后继 | 3 |
| 3、5 | 5→0 入栈,再弹出 | 空 |
序列 2 1 4 6 3 5
两个都是合法的拓扑序列。这个图的拓扑序列一共有多少个?按“每步有几个入度为 0 的顶点就分几支”画树来数:先 1 再 2 的有 5 个,先 1 再 3 的有 2 个,先 2 的有 5 个,共 12 个。(两种代码的输出与 12 这个总数都由程序验证:后者对 6! 个排列逐一检查。)
另一种方法:DFS 的逆后序
在 DAG 上做 DFS,一个顶点退出递归时,它能到达的所有顶点(后继、后继的后继……)都已经退出了。所以按“退出的先后”排列,就是逆拓扑序列;倒过来就是拓扑序列。代码只要把输出放在递归调用之后,并从数组尾部往前填:
int topo[MAXV], idx; // idx 从 n 开始往前填 void DFS_Topo(ALGraph G, int v) { visited[v] = true; for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) if (!visited[p->adjvex]) DFS_Topo(G, p->adjvex); topo[--idx] = v; // 后继都已排好,把自己放到它们前面 } void TopoByDFS(ALGraph G) { // 前提:G 是 DAG(有环时不报错,结果无意义) idx = G.vexnum; for (int i = 0; i < G.vexnum; i++) visited[i] = false; for (int i = 0; i < G.vexnum; i++) if (!visited[i]) DFS_Topo(G, i); }
对上图:从 1 出发,1 → 3 → 5,5 先退出,然后 3;再 1 → 4 → 6,6 退出、4 退出、1 退出;最后从 2 出发(4 已访问),2 退出。退出次序 5 3 6 4 1 2 是逆拓扑序列,倒过来 2 1 4 6 3 5。时间 O(n + e)。这种写法不能顺便判环(要判环得用 6.5 节的三色法)。两份代码已在 5000 个随机有向图上检验:输出总满足“每条弧起点在前”,入度法在有环时正确返回 false。
- 有向图有拓扑序列 ⟺ 无回路(2011-08 Ⅲ)。拓扑排序可以用来判断有向图是否有环。
- 拓扑序列唯一 ⟺ 每一步都恰好只有一个入度为 0 的顶点(2024-41 大题)。
- 若邻接矩阵是三角矩阵(主对角线以下全为 0),则所有弧都是从小编号指向大编号,1, 2, …, n 就是一个拓扑序列,存在;但可能不唯一(2012-06)。反过来,存在拓扑序列的图,适当编号后邻接矩阵可以成为三角矩阵。
- 逆拓扑序列:每次删出度为 0 的顶点;或者 DFS 中在退出递归时输出顶点(2020-06)。
- 求所有拓扑序列的个数:每一步有几个入度为 0 的顶点就分几支,画成一棵树来数(2010-08、2021-07)。
遇到回路时卡在哪里:AOV 网 0→1、1→2、2→3、3→1。初始只有 0 的入度为 0,输出 0 后 1 的入度从 2 减到 1;此时 1、2、3 的入度都是 1,再也找不到入度为 0 的顶点,循环结束,只输出了 1 个顶点(< 4)⇒ 有回路 1→2→3→1。卡住的那些顶点,正好是回路上的顶点和被回路“堵住”的下游顶点。
| 问题 | 结论 / 做法 |
|---|---|
| 拓扑序列唯一的条件 | 入度法的每一步都恰好只有一个入度为 0 的顶点(2024-41 让你在邻接矩阵上判断)。等价地:存在一条经过所有顶点的有向路径。 |
| 判断有向图有没有环 | 入度法结束时输出的顶点数 < n ⟺ 有环;剩下没输出的顶点都在环上或在环的下游。 |
| 逆拓扑序列 | 每次删出度为 0 的顶点;或 DFS 退出递归时输出(2020-06)。 |
| 拓扑序列的个数 | 画“分支树”逐步数(2010-08、2021-07);顶点多时注意哪些顶点之间没有约束,可以任意交错。 |
| 邻接矩阵是三角矩阵 | 一定有拓扑序列(顶点编号顺序就是一个),但不一定唯一(2012-06)。 |
跟着判一下:对本节的 AOV 网,第一步入度为 0 的就有 1、2 两个 ⇒ 拓扑序列不唯一(前面已数出 12 个)。若换成 1→2、2→3、1→3 三条弧:第一步只有 1,删掉后只有 2,再只有 3 ⇒ 唯一(1, 2, 3)。2024-41 要在邻接矩阵上写这个判定:入度 = 列和,每轮扫一遍找入度为 0 的顶点并计数,O(n²),第 9 篇 t4 有满分代码。
- A. 4
- B. 3
- C. 2
- D. 1
展开解析
B弧:a→e、a→b、a→c、b→c、e→d、c→d。
- 开始只有 a 入度为 0,a 第一。
- 删去 a 后,b、e 入度为 0(c 还有来自 b 的弧)。
- 约束:b 在 c 前,c、e 都在 d 前。b、c、e 的排列中 b 必须在 c 前:b c e、b e c、e b c,三种;d 最后。
- 序列:a b c e d、a b e c d、a e b c d,共 3 个。
- A 错4 是漏看了弧 e → d:那样 e 可以排到 d 后面,多出 a b c d e 这一个。
- B 对3。
- C 错2 漏了一种:b、c、e 之间只有“b 在 c 前”一条约束,e 可以在 b 前、b 与 c 之间、c 后,共 3 种。
- D 错每一步不止一个入度为 0 的顶点,不唯一。
数拓扑序列:每一步列出所有入度为 0 的顶点,分支计数。
- 拓扑排序中途卡住(剩下的顶点入度都不为 0)说明有环,但剩下的顶点不一定都在环上:例如 A→B、B→C、C→B、C→D,输出 A 后就卡住,剩下 B、C、D,其中 D 只是被环“挡住”的下游顶点,本身不在环上。要找出环上的顶点,可以再对剩下的部分反向删去出度为 0 的顶点,或用 DFS 三色法。(程序核对。)
- 入度为 0 的顶点要在入度减到 0 的那一刻入栈 / 入队,不要每轮重新扫描全部顶点(那样是 O(n²))。
- 用栈和用队列得到的序列不同,但都对;题目问“可能的序列”时要能识别两者。
- 拓扑排序只对有向图有意义;无向图没有“先后”。
- 对本节的 AOV 网,序列 1, 2, 4, 6, 3, 5 是拓扑序列吗?2, 4, 1, 3, 5, 6 呢?
答案
第一个是(每条弧的起点都在终点前面)。第二个不是:4 的前驱 1 排在了 4 后面(弧 1→4)。 - 入度法执行完,只输出了 7 个顶点中的 4 个,说明什么?
答案
图中有回路,不存在拓扑序列。没输出的 3 个顶点入度始终不为 0(在环上或依赖环上的顶点)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 3
- B. 4
- C. 5
- D. 6
展开解析
C1 一定第一个,6 一定最后一个,中间是 2、3、4、5 的排列,约束:3 在 4、5 之前,2 在 4 之前。
第二个是 2:之后只能是 3,再排 4、5(2 种):1 2 3 4 5 6、1 2 3 5 4 6;
第二个是 3:剩 2、4、5,约束 2 在 4 前:2 4 5、2 5 4、5 2 4(3 种)。
共 2 + 3 = 5 个。
变化点:数拓扑序列要按“当前入度为 0 的顶点”分支,逐层展开。本书 图算法小工具选“拓扑排序”会直接给出个数。
- A. 存在,且唯一
- B. 存在,且不唯一
- C. 存在,可能不唯一
- D. 无法确定是否存在
展开解析
A只有 i < j 的弧 <i, j>,一定无环,拓扑序列存在。又因为对任意 i < j 都有弧,每一步入度为 0 的顶点只有一个(编号最小的那个),序列唯一:1, 2, …, n。
变化点:原题只说对角线以下为 0,上面有 0 有 1,存在但“可能不唯一”(C)。上三角全为 1 时,任意两个顶点之间都有先后关系,拓扑序列就唯一了。
- A. O(n)
- B. O(n + e)
- C. O(n2)
- D. O(n × e)
展开解析
C每输出一个顶点,要扫描它所在的一行来减少后继的入度,每行 n 个元素,共 O(n2)。
变化点:与 BFS、DFS 一样,拓扑排序的复杂度取决于存储方式:邻接表 O(n + e),邻接矩阵 O(n2)。
6.10关键路径
关键路径在解决什么问题
用边表示活动、边上的权表示活动持续时间、顶点表示事件的带权有向无环图叫 AOE 网(Activity On Edge)。事件 vₖ 表示“以 vₖ 为终点的活动都已完成,以 vₖ 为起点的活动可以开始”。
- 只有一个入度为 0 的顶点(源点,工程开始)和一个出度为 0 的顶点(汇点,工程结束)。
- 从源点到汇点路径长度最长的路径叫关键路径,它的长度就是完成整个工程的最短时间(2020-08)。关键路径上的活动叫关键活动。
做一顿饭:煮饭 30 分钟,同时洗菜 10 分钟再炒菜 15 分钟。两条线并行,饭什么时候能吃?取决于较慢的那条(30 分钟)。工程也一样,并行的若干条路线中最长的一条决定了总工期——它上面的每个活动都一点也耽误不得。
| AOV 网(6.9) | AOE 网(6.10) | |
|---|---|---|
| 顶点表示 | 活动 | 事件(某些活动完成、某些活动可以开始的时刻) |
| 边表示 | 活动之间的先后关系,无权 | 活动本身,权 = 持续时间 |
| 研究的问题 | 能否安排、按什么次序做(拓扑排序) | 最少要多久、哪些活动不能拖(关键路径) |
| 要求 | 无回路 | 无回路,且只有一个源点、一个汇点 |
| 联系 | 求关键路径时,ve 要按拓扑序算、vl 要按逆拓扑序算——AOE 网的计算建立在拓扑排序之上 | |
四个量
| 量 | 含义 | 怎么算 |
|---|---|---|
| 事件最早发生时间 ve(k) | 从源点到 vₖ 的最长路径长度 | ve(源点) = 0;按拓扑序从前往后:ve(k) = max{ ve(j) + w(j, k) }(j 是 k 的前驱) |
| 事件最迟发生时间 vl(k) | 不推迟整个工程的前提下,vₖ 最迟必须发生的时间 | vl(汇点) = ve(汇点);按逆拓扑序从后往前:vl(k) = min{ vl(j) − w(k, j) }(j 是 k 的后继) |
| 活动最早开始时间 e(i) | 活动 aᵢ = <vₖ, vⱼ> 最早能开始的时间 | e(i) = ve(k)(起点事件的最早时间) |
| 活动最迟开始时间 l(i) | 不推迟工程的前提下 aᵢ 最迟开始时间 | l(i) = vl(j) − w(k, j)(终点事件的最迟时间减持续时间) |
| 时间余量 d(i) | 活动可以拖延的时间 | d(i) = l(i) − e(i);d(i) = 0 的就是关键活动 |
展开解析
| 事件 | v1 | v2 | v3 | v4 | v5 | v6 |
|---|---|---|---|---|---|---|
| ve(从前往后取 max) | 0 | 3 | 2 | max(3+2, 2+4) = 6 | 6 | max(6+2, 6+1, 2+3) = 8 |
| vl(从后往前取 min) | 0 | min(6−2, 7−3) = 4 | min(6−4, 8−3) = 2 | 8 − 2 = 6 | 8 − 1 = 7 | 8 |
| 活动 | a1 | a2 | a3 | a4 | a5 | a6 | a7 | a8 |
|---|---|---|---|---|---|---|---|---|
| e = ve(起点) | 0 | 0 | 3 | 3 | 2 | 2 | 6 | 6 |
| l = vl(终点) − w | 1 | 0 | 4 | 4 | 2 | 5 | 6 | 7 |
| d = l − e | 1 | 0 | 1 | 1 | 0 | 3 | 0 | 1 |
关键活动 a2、a5、a7,关键路径 v1 → v3 → v4 → v6,长度 8(= ve(汇点))。关键路径上的事件满足 ve = vl。
把 ve、vl 标在图上:一眼看出关键路径
多条关键路径时,缩短哪个活动才有用
- ve:v1 0、v2 3、v3 3、v4 max(3 + 2, 3 + 2) = 5、v5 9;vl 倒推全部等于 ve ⇒ 所有 5 个活动都是关键活动,关键路径有两条:v1→v2→v4→v5 和 v1→v3→v4→v5,工期 9。
- 只把 a1 缩短到 1:上面那条变成 7,下面那条仍是 9 ⇒ 工期还是 9,白缩了。
- 把 a5 缩短到 2:a5 是两条路径共有的 ⇒ 工期变成 7。
- a1、a2 同时各缩短 1(两条路径上各缩一个)⇒ 工期 8;a3、a4 同时缩短 1 也是 8。
再看图 6-15:把关键活动 a5 从 4 缩到 1,工期并不会变成 8 − 3 = 5,而是 7——缩到一定程度后,v1→v2→v4→v6、v1→v2→v5→v6 这两条长度为 7 的路径成了新的关键路径,a5 已经不再关键。(以上各种缩短后的工期都由程序重算 ve、vl 得到。)
写成代码:拓扑序求 ve,逆拓扑序求 vl
bool CriticalPath(ALGraph G, int ve[], int vl[]) { // 弧结点带 weight(活动持续时间) int topo[MAXV], n = G.vexnum; if (!TopoQueue(G, topo)) return false; // 有回路,不是 AOE 网 for (int i = 0; i < n; i++) ve[i] = 0; for (int k = 0; k < n; k++) { // 按拓扑序求 ve:用 v 去更新它的后继 int v = topo[k]; for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) if (ve[v] + p->weight > ve[p->adjvex]) ve[p->adjvex] = ve[v] + p->weight; } for (int i = 0; i < n; i++) vl[i] = ve[topo[n - 1]]; // 汇点的 vl = ve for (int k = n - 1; k >= 0; k--) { // 按逆拓扑序求 vl:用后继更新 v int v = topo[k]; for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) if (vl[p->adjvex] - p->weight < vl[v]) vl[v] = vl[p->adjvex] - p->weight; } for (int v = 0; v < n; v++) // 逐条弧求 e、l,相等即关键活动 for (ArcNode *p = G.vertices[v].first; p != NULL; p = p->next) { int e = ve[v], l = vl[p->adjvex] - p->weight; if (e == l) printf("<%c,%c> ", G.vertices[v].data, G.vertices[p->adjvex].data); } return true; }
TopoQueue 是上一节入度法的队列版(把拓扑序列存进 topo[])。三遍扫描都是 O(n + e)。对图 6-15 运行,输出关键活动 <1,3> <3,4> <4,6>,ve = 0 3 2 6 6 8、vl = 0 4 2 6 7 8,与手算一致。
- A. 3 和 7
- B. 12 和 12
- C. 12 和 14
- D. 15 和 15
展开解析
C活动 d = <2, 4>,持续 7。e(d) = ve(2),l(d) = vl(4) − 7。
- ve:1 = 0;3 = 8;2 = max(0 + 3, 8 + 4) = 12;4 = 12 + 7 = 19;5 = max(12 + 6, 8 + 10) = 18;6 = max(19 + 6, 18 + 9) = 27。
- vl:6 = 27;5 = 27 − 9 = 18;4 = 27 − 6 = 21;2 = min(21 − 7, 18 − 6) = 12;3 = min(12 − 4, 18 − 10) = 8;1 = 0。
- e(d) = ve(2) = 12;l(d) = vl(4) − 7 = 21 − 7 = 14。d 的时间余量为 2,不是关键活动。
- A 错ve(2) 要取 max,不能只看 a 这一条(3)。
- B 错最迟开始时间要用 vl(4) − 7,不是 vl(2)。
- C 对12 和 14。
- D 错15 和 15 意味着 d 是关键活动;但关键路径是 1 → 3 → 5 → 6 和 1 → 3 → 2 → 5 → 6(长 27),都不经过 d。而且 e(d) = ve(2) = 12,不可能是 15。
活动 <j, k>:e = ve(j),l = vl(k) − 持续时间。ve 从前往后取 max,vl 从后往前取 min。
- 写出拓扑序列(说明 ve 按这个次序求)。
- 列 ve 表:源点 0,每个事件写出“取 max 的式子”,例如 ve(v4) = max{3 + 2, 2 + 4} = 6。
- 列 vl 表:汇点 = ve(汇点),逆序写出“取 min 的式子”。
- 列 活动表:每条弧一行,e、l、d = l − e 三列。
- 结论:d = 0 的活动是关键活动;画出 / 写出关键路径;工期 = ve(汇点)。若问“缩短哪个活动能缩短工期”,先看有几条关键路径。
评分时每张表都有分,式子写出来比只写结果稳——算错一个数也能拿到过程分。2011-41 的 AOE 网用上三角矩阵存储,先把矩阵还原成图再按模板做。
关键路径为什么能“按拓扑序一遍算出”
关键路径就是 AOE 网中从源点到汇点的最长路径。在一般的图里求最长路径非常难(有回路时绕圈子可以无限长,求最长简单路径至今没有高效算法),可 AOE 网是有向无环图:按拓扑序处理到 v 时,v 的所有前驱都已经算完,ve(v) = max{ve(前驱) + 权} 一步就能定下来,再也不会被改——这其实就是“动态规划”:大问题的答案由已经算好的小问题拼出来。
有向无环图 A→B 权 1、A→C 权 2、B→D 权 10、C→D 权 1。“选最大”的 Dijkstra:A 定下后 B = 1、C = 2;选最大的 C 定下,C 把 D 更新为 3;接着选最大的 D(3)就定下了——可 B 还没来得及处理,真正的最长路 A→B→D = 11 被漏掉。
Dijkstra“选最小就能定下”的前提是:后面只会越走越长、不可能绕回来更短;求最长路时没有这个保证(还没处理的 B 后面可能接一条很长的边)。所以求关键路径必须按拓扑序,保证一个顶点的所有前驱都处理完了才算它。(反例由程序模拟;图 6-15 的 ve(汇点) = 8 也由穷举所有路径核对为最长路径长度。)
| AOV 网(6.9) | AOE 网(6.10) | |
|---|---|---|
| 谁表示活动 | 顶点 | 边(弧),顶点表示“事件” |
| 边上有没有权 | 没有,只表示先后关系 | 有,表示活动持续的时间 |
| 要解决的问题 | 排出一个可行的先后次序(拓扑排序) | 求最短工期和关键活动(关键路径) |
| 对图的要求 | 无回路 | 无回路,且只有一个源点、一个汇点 |
| 关系 | 求关键路径的第一步就是在 AOE 网上做拓扑排序 | |
真题怎么考
- 求某活动的最早 / 最迟开始时间(2019-05):先算 ve(取 max)、vl(取 min),e = 起点的 ve,l = 终点的 vl − 持续时间。
- 概念与缩短工期(2013-09、2020-08、2022-07):多条关键路径时缩短单个活动不一定有用。
- 大题:2011-41(三角矩阵存储的 AOE 网求关键路径)、2025-42。第 9 篇 t5 有完整解答。
- ve 取最大(要等所有前驱活动都完成),vl 取最小(不能耽误任何一个后继);记反了全错。
- 关键事件满足 ve = vl,关键活动满足 e = l。两端都是关键事件的活动不一定是关键活动(例如两个关键事件之间还有一条更短的弧)。
- vl 的初值是汇点的 ve(不是 0、也不是 ∞)。
- 在图 6-15 中,活动 a6 = <v3, v6> 的时间余量是多少?它推迟 2 天开工,工期会变吗?
答案
e = ve(v3) = 2,l = vl(v6) − 3 = 5,余量 3。推迟 2 天不超过余量,工期不变。 - 在本节两条关键路径的例子中,只缩短 a3 一天,工期是多少?
答案
仍是 9:下面那条 v1→v3→v4→v5 没有缩短。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
下面三题共用这个 AOE 网(边上标的是活动名和持续时间):
- A. 3 和 6
- B. 3 和 3
- C. 7 和 10
- D. 6 和 6
展开解析
A事件最早发生时间 ve:v1 = 0,v2 = 3,v3 = 4,v4 = max(3+4, 4+1) = 7,v5 = max(3+2, 4+5) = 9,v6 = max(7+3, 9+4) = 13。最迟发生时间 vl(从 v6 = 13 倒推):v5 = 9,v4 = 13 − 3 = 10,v3 = min(10−1, 9−5) = 4,v2 = min(10−4, 9−2) = 6,v1 = 0。a3 = <v2, v4>:e = ve(v2) = 3,l = vl(v4) − 4 = 6。
变化点:活动的 e 看弧尾事件的 ve;活动的 l 看弧头事件的 vl 再减去持续时间。
- A. a1
- B. a4
- C. a5
- D. a7
展开解析
C余量 = l − e:a1 = 3 − 0 = 3;a4 = (9 − 2) − 3 = 4;a5 = (10 − 1) − 4 = 5;a7 = (13 − 3) − 7 = 3。a5 最大。
变化点:余量大的活动“闲”,拖延一些也不影响工期;余量为 0 的是关键活动(这里是 a2、a6、a8)。
- A. a1
- B. a3
- C. a6
- D. a7
展开解析
C关键路径只有一条:v1 → v3 → v5 → v6(a2、a6、a8,长 13),其他路径最长为 10(v1→v2→v4→v6)。a6 在唯一的关键路径上,缩短它就能缩短工期(缩短不超过 3 时效果完全体现)。a1、a3、a7 都有余量,加快它们没用。
变化点:原题有三条关键路径,必须让每一条关键路径都被缩短(加快它们共有的活动,或在每条上各加快一个)。关键路径唯一时,加快任一关键活动都行。
6.11本篇一页总结与必会题型
| 板块 | 必背结论 | 下手方法 |
|---|---|---|
| 概念 | 无向完全图 n(n − 1)/2、有向 n(n − 1);连通至少 n − 1;保证连通 C(n − 1, 2) + 1;强连通至少 n;Σ度 = 2e;连通分量极大、生成树极小 | “至少 / 保证”题画极端图 |
| 存储 | 矩阵 O(n²)、唯一、判边 O(1);邻接表 O(n + e)(无向 2e 个边结点)、不唯一、有向图求入度 O(n + e);Aᵐ[i][j] = 长度 m 的路径数;十字链表(有向)、邻接多重表(无向)每条边一个结点 | 复杂度从“找邻居要多久”推 |
| BFS | 队列;入队时标记;O(n + e) / O(n²);无权图单源最短路;调用次数 = 连通分量数 | 模拟队列判序列 |
| DFS | 栈(递归);O(n + e) / O(n²);无向判环看“访问过且非父亲”,有向判环看“指向递归栈中的顶点”;退出次序的逆序 = 拓扑序 | 模拟栈判序列:新顶点必须是栈顶的邻居 |
| MST | Prim 加点 O(n²) 稠密;Kruskal 加边 O(e log e) 稀疏、并查集判环;代价唯一,树形可能不唯一;每条非树边都严格大于其树上路径各边 ⇔ 唯一 | 列 lowcost 表 / 排序表 |
| 最短路径 | BFS 无权;Dijkstra 单源、不能负权、O(n²);Floyd 所有对、可负权不可负回路、O(n³)、k 在最外层 | dist / path 表格逐列写;Floyd 写 A⁽ᵏ⁾ |
| DAG 表达式 | 相同子表达式共享一个顶点 | 按计算次序逐个查能否复用 |
| 拓扑排序 | 入度法 O(n + e);输出不足 n 个 ⇒ 有环;唯一 ⇔ 每步恰一个入度为 0;栈 / 队列结果不同都合法 | 数序列个数画分支树 |
| 关键路径 | ve 拓扑序取 max,vl 逆拓扑序取 min;e = ve(起点),l = vl(终点) − w;e = l 为关键活动;多条关键路径时只缩一条无效 | 先 ve 表,再 vl 表,再 e / l 表 |
本篇必会题型清单
| # | 题型 | 代表真题 | 小节 |
|---|---|---|---|
| 1 | 边数、连通性、度的计算与概念辨析 | 2010-07、2017-07、2022-06 | 6.1 |
| 2 | 由邻接矩阵 / 邻接表求度、各操作的复杂度 | 2013-07、2026-06 | 6.2 |
| 3 | 读邻接多重表 / 十字链表 | 2024-04 | 6.3 |
| 4 | 判断 BFS / DFS 序列、遍历的复杂度 | 2013-08、2016-06、2012-05、2015-05 | 6.4 / 6.5 |
| 5 | Prim、Kruskal 选边次序;MST 性质与唯一性 | 2015-06、2012-08、2017-42、2018-42 | 6.6 |
| 6 | Dijkstra 逐轮结果、Floyd 矩阵、算法适用条件 | 2016-08、2021-08、2023-06、2014-42 | 6.7 |
| 7 | DAG 表示表达式的最少顶点数 | 2019-06 | 6.8 |
| 8 | 拓扑序列个数、唯一性、存在性 | 2010-08、2012-06、2021-07、2024-41 | 6.9 |
| 9 | ve / vl / e / l 与关键路径、缩短工期 | 2019-05、2020-08、2013-09、2011-41 | 6.10 |
| 10 | 大题:在邻接矩阵上统计度 / 判断性质 | 2021-41、2023-41 | 9.4 |
大题第一步:把文字题“翻译”成图
图的大题很少直接给你一张图,常常是城市、线路、课程、工序、网络路由(2014-42 用的就是 OSPF 路由的外壳)。动笔前先回答三个问题:顶点是什么?边是什么?边有没有方向、有没有权?然后按题目的问法对号入座:
| 题目里的说法 | 翻译成图的问题 | 用什么 |
|---|---|---|
| “用最少的总长度 / 总造价把所有地点连起来” | 带权无向图的最小生成树 | Prim(稠密)/ Kruskal(稀疏) |
| “从 A 出发到各地的最短路线 / 最少花费” | 单源最短路径(权非负) | Dijkstra |
| “任意两地之间的最短路线” | 每对顶点的最短路径 | Floyd |
| “换乘次数最少”“最少经过几站” | 无权图(每条边算 1)的最短路径 | BFS |
| “先修课程”“哪些任务必须先做”,问能否排出顺序 | AOV 网的拓扑排序(有环则不能) | 入度法 / DFS |
| “工程最短工期”“哪些工序不能拖” | AOE 网的关键路径 | 拓扑序求 ve、逆拓扑序求 vl |
| “网络是否连通”“分成几块” | 连通性、连通分量 | BFS / DFS 调用次数 |
“某地铁线网有若干车站,相邻两站之间都有轨道。乘客从甲站去乙站,想让换乘次数最少。”
- 若把车站当顶点、相邻站当边,用 BFS 求出的是“经过的站数最少”,不是“换乘最少”——问法和图不匹配。
- 换个建模:把每条线路当顶点,两条线路有换乘站就连一条边。“换乘最少”就变成在这张线路图上,从“经过甲站的线路”到“经过乙站的线路”的最少边数,用 BFS。
- 结论:同一个实际问题,顶点和边选得不同,算法也跟着变。先想清楚“题目要最小化的东西,在图里对应什么”,再选算法。
最容易混的一对:“把所有城市连起来、总造价最低”是 MST;“从 A 到 B 走得最近”是最短路径。MST 上 A 到 B 的那条路不一定是最短路径(2023-06)——MST 管的是“全体加起来最省”,最短路管的是“这一对最近”,两个目标不同,答案自然不同。
- n 个顶点的无向连通图用邻接矩阵存储,矩阵中至少有几个非零元素?
答案
连通至少 n − 1 条边,每条边在对称矩阵中占 2 个位置:至少 2(n − 1) 个。 - 一个有 n 个顶点、e 条边的无向图用邻接表存储,边表结点共几个?求所有顶点的度要多少时间?
答案
2e 个;把每个链表的长度数一遍,O(n + e)。 - 用 Dijkstra 求最短路径时,某顶点被加入 S 之后,它的 dist 还会变吗?
答案
不会(边权非负时)。这正是“按长度递增次序产生最短路径”的含义。 - 对一个 DAG,“按拓扑序求 ve,按逆拓扑序求 vl”能不能反过来?
答案
不能。ve(k) 依赖所有前驱的 ve,必须前驱先算完——所以按拓扑序;vl(k) 依赖所有后继的 vl——按逆拓扑序。
6.✎本篇真题闯关(43 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
查找:从顺序查找到 B+ 树与散列表
查找是计算机里最频繁的操作:数据库按学号找记录、编译器找变量、操作系统找页表项。这一篇按“数据怎么组织”分三类:线性结构上的查找(顺序、折半、分块),树形结构上的查找(二叉排序树、平衡二叉树、红黑树、B 树、B+ 树),以及不用比较、直接“算出地址”的散列表。衡量查找效率的统一尺子是平均查找长度 ASL。选择题 39 道,最常考:折半查找判定树、AVL 的旋转、B 树的结点与关键字个数、散列表的 ASL。
7.0本篇地图:三种组织数据的方式
查找的快慢取决于数据怎么组织:排成一列(线性表)只能顺序找或折半找;组织成树,就能沿一条路径往下找;用散列函数直接算地址,理想情况下一步到位。本篇 39 道选择题,其中 B 树 10 道、AVL 8 道、散列 7 道、折半 6 道,这四块占了八成。
| 小节 | 学完要会的事 | 相关大题 | 难度 | 学时 |
|---|---|---|---|---|
| 7.1 概念 | ASL 成功 / 失败分别按什么平均 | — | ★ | 0.5 h |
| 7.2 顺序、折半 | 判定树的画法、ASL、最多比较次数、比较序列的合法性 | 2013-42 | ★★ | 2 h |
| 7.3 分块 | 最优块长 √n | — | ★ | 0.5 h |
| 7.4 BST | 构造、三种删除、ASL;判断查找路径 | 2013-42 | ★★ | 1.5 h |
| 7.5 AVL | 四种旋转手算、最少结点递推 | — | ★★★ | 2 h |
| 7.6 红黑树 | 五条性质、黑高、插入时看叔叔 | — | ★★ | 1.5 h |
| 7.7 B 树 | 关键字个数范围、高度、插入分裂、删除借 / 并 | — | ★★★ | 2.5 h |
| 7.8 B+ 树 | 与 B 树的区别、为什么适合数据库 | — | ★ | 0.5 h |
| 7.9 散列 | 建表、ASL 成功 / 失败、各种冲突处理的特点 | 2010-41、2024-42 | ★★★ | 2 h |
7.1查找的基本概念与平均查找长度
从“图书馆找书”说起
书随便堆在地上,只能一本本翻(顺序查找);书按编号排好,可以先看中间那本、再决定往左还是往右(折半查找);按“楼层 → 书架 → 层”分级(树形索引,B 树就是这样);或者给每本书算一个固定的柜号,按号直接去拿(散列)。比较一次就是“看一本书的书脊”,看得越少越快——这就是 ASL 要数的东西。
哪里不像:图书馆找书还要走路;算法里一般只数“关键字比较”的次数,不计其它开销。但 B 树那一节会特别关心“读了几个磁盘块”,那时数的是读盘次数。
| 术语 | 含义 |
|---|---|
| 查找 | 在数据集合中寻找满足某种条件的数据元素的过程。结果只有两种:成功、失败。 |
| 查找表 | 用于查找的数据集合。只做查找的叫静态查找表(顺序查找、折半查找、散列);还要插入、删除的叫动态查找表(二叉排序树、平衡树、B 树、散列)。 |
| 关键字 | 能唯一标识一个数据元素的数据项(如学号)。 |
| 平均查找长度 ASL | 查找过程中关键字比较次数的平均值,是衡量查找算法效率的主要指标。 |
一条主线:用什么换来更少的比较
这一篇的方法看起来很多,其实是同一条主线上的几次“交换”:
- 顺序查找什么都不要求,代价是 O(n) 次比较。
- 折半查找要求“有序 + 顺序存储”,换来 O(log n);付出的是插入删除要挪元素,O(n)。
- 分块查找只要求“块间有序”,比较次数介于两者之间(约 √n),插入方便一些。
- 二叉排序树把有序性存进指针,插删只改指针;付出的是形状受插入次序摆布,最坏 O(n)。
- AVL / 红黑树用旋转、变色的额外工作,保住 O(log n) 的树高。
- B / B+ 树为磁盘而生:一个结点放很多关键字,用“结点内多比较几次”换“少读几次盘”。
- 散列表干脆不比大小,用额外空间(表要开大、α 不能太高)和冲突处理换来平均 O(1);付出的是不支持“按大小”的查询——找最小值、找某个范围内的关键字,都得把整张表扫一遍。
记住每种方法要求什么、换来什么、付出什么,选择题里的“适用条件”“优缺点”大多能当场推出来,不用死背。
ASL 成功与 ASL 失败:分母不一样
表 elem[1..5] = 3, 8, 2, 9, 5,elem[0] 放哨兵。求等概率下的 ASL 成功和 ASL 失败。
- 成功:从后往前比,找 5 比 1 次,找 9 比 2 次,……,找 3 比 5 次。ASL成功 = (1 + 2 + 3 + 4 + 5)/5 = 3 = (n + 1)/2。分母是元素个数。
- 失败:查一个不在表里的数,要和 5 个元素都比一遍,最后还和哨兵 elem[0] 比 1 次才停下,共 6 次。所有失败的情形都一样,ASL失败 = 6 = n + 1。
- 一般规律:ASL成功 按“查找每个元素”平均;ASL失败 按“每一种失败的情形”平均——顺序查找无序表只有 1 种失败情形,有序表、折半查找有 n + 1 种,散列表按初始地址分。
- ASL 数的是关键字比较次数,不是循环次数,也不是移动次数。
- 动态查找表可以插入删除(BST、AVL、B 树、散列表都行),静态查找表只查不改(顺序、折半、分块);散列表两种都可以当。
- 10 个元素的无序顺序表,带哨兵顺序查找,ASL 成功和失败各是多少?
答案
成功 (10 + 1)/2 = 5.5;失败 10 + 1 = 11。 - 查找概率不相等时(例如 p₁ = 0.5、p₂ = 0.3、p₃ = 0.2),从前往后顺序查找,应该把哪个元素放在最前面?ASL 是多少?
答案
概率大的放前面:顺序 p₁、p₂、p₃,ASL = 0.5 × 1 + 0.3 × 2 + 0.2 × 3 = 1.7;反过来放是 0.2 + 0.6 + 1.5 = 2.3。
考纲补充(尚无真题):ASL 的含义
本节概念目前没有单独出过选择题,但每道 ASL 计算题都以它为前提。
- A. n + 1
- B. n/2 + n/(n + 1)
- C. (n + 1)/2
- D. n
展开解析
B失败的情形有 n + 1 种(落在 n + 1 个区间里),分别比较 1、2、…、n、n 次,平均 = (n(n + 1)/2 + n)/(n + 1) = n/2 + n/(n + 1)。
变化点:A 是无序表带哨兵的失败 ASL(必须比到哨兵);C 是成功 ASL。“有序”让失败可以提前结束——ASL 失败要先弄清楚失败有几种情形、每种比几次。
7.2顺序查找与折半查找
顺序查找(线性查找)
从表的一端开始逐个比较,适用于顺序表和链表,对元素是否有序没有要求。
int Search_Seq(SSTable ST, ElemType key) { // 元素存在 ST.elem[1..n],elem[0] 放“哨兵” ST.elem[0] = key; // 哨兵:保证循环一定会停,不用每次判断越界 int i; for (i = ST.TableLen; ST.elem[i] != key; i--) ; // 从后往前找 return i; // 找到返回位置,找不到返回 0 }
| 情形 | ASL 成功 | ASL 失败 |
|---|---|---|
| 一般线性表(等概率) | (n + 1)/2 | n + 1(带哨兵,比到 elem[0]) |
| 有序表(比到比 key 大的元素就知道失败) | (n + 1)/2 | n/2 + n/(n + 1)(有 n + 1 个失败区间) |
若各元素查找概率不等,把概率大的放在前面,ASL 更小(2013-42)。顺序查找的时间 O(n),优点是简单、对存储和有序都没有要求;缺点是 n 大时效率低。
为什么要哨兵:没有哨兵时,循环每轮要判断两件事(“i 越界了吗”“elem[i] 等于 key 吗”);把 key 先放进 elem[0],循环一定会在 0 处停下,每轮只判断一件事。n 很大时能省近一半的判断,但比较关键字的次数不变,所以 ASL 公式里失败时多出来的那 1 次正是“和哨兵比”。
有序表失败 ASL 的公式是怎么来的:把上图的 n + 1 个方框从左往右数。key 落在第 i 个失败区间(比 aᵢ₋₁ 大、比 aᵢ 小)时,要一直比到 aᵢ 才发现“aᵢ 已经比 key 大了”,共比较 i 次(i = 1 ~ n);最右边那个区间(key 比 aₙ 还大)把表比完就停,也是 n 次。所以
折半查找(二分查找)
要求:有序的顺序表(需要随机存取,按下标直接找到中间元素;链表、静态链表都不行,2024-05)。每次和中间元素比较,相等就成功;小于就去左半边找,大于就去右半边找,每比一次范围缩小一半。
int Binary_Search(int a[], int n, int key) { // a[0..n-1] 升序 int low = 0, high = n - 1, mid; while (low <= high) { mid = (low + high) / 2; // 向下取整 if (a[mid] == key) return mid; else if (a[mid] > key) high = mid - 1; // 到左半部分找 else low = mid + 1; // 到右半部分找 } return -1; // low > high:查找失败 }
折半代码的三个边界:差一个字符就错
折半的思想一句话就能说清,代码的边界却极容易写错。《编程珠玑》的作者让专业程序员当场写二分查找,大约九成的人写出的程序有错误。写之前先想清楚一句话:[low, high] 是“还没被排除的候选区间”,两端都包含在内。三个边界都由这句话推出来(反例都用上面的 a[0..10] = 7, 10, 13, 16, 19, 29, 32, 33, 37, 41, 43):
| 写错成 | 后果 | 反例 |
|---|---|---|
while (low < high) | low = high 时区间里还剩 1 个元素,没比就退出了 | 查 43:区间 [0, 10] → [6, 10] → [9, 10] → [10, 10],此时循环条件不成立,返回 −1,可 43 明明在 a[10]。这张表里 10、19、33、43 四个元素都查不到 |
high = mid | a[mid] 已经比过、确定不是了,却还留在区间里;当 low = high = mid 且 a[mid] > key 时,区间再也不缩小 | 查 5:……low = high = 0,mid = 0,7 > 5,high = mid = 0,原地打转,死循环 |
mid = (low + high) / 2 本身拓展 | low、high 都接近 int 上限(约 21 亿)时,low + high 会溢出成负数 | 稳妥写法 mid = low + (high - low) / 2。考试中数组都很小,两种写法都给分 |
失败时 low 停在哪里:循环过程中始终保持一个事实——a[low] 左边的元素都 < key,a[high] 右边的元素都 > key。失败退出时 low = high + 1,于是 a[0..low − 1] 都 < key、a[low..n − 1] 都 > key,low 正是 key 应该插入的位置。把“相等就返回”去掉,就得到一个很有用的变体:
int LowerBound(int a[], int n, int key) { // a[0..n-1] 升序;返回第一个 ≥ key 的下标,都小于 key 时返回 n int low = 0, high = n - 1, mid; while (low <= high) { mid = (low + high) / 2; if (a[mid] >= key) high = mid - 1; // a[mid] 可能就是答案,但先到左边找更靠前的 else low = mid + 1; // a[mid] < key:答案一定在右边 } return low; // 循环结束时 low = high + 1,正是答案 }
| 次 | low | high | mid | a[mid] | LowerBound(a, 11, 20) 的动作 |
|---|---|---|---|---|---|
| 1 | 0 | 10 | 5 | 29 | 29 ≥ 20,high = 4(29 可能是答案,先往左看) |
| 2 | 0 | 4 | 2 | 13 | 13 < 20,low = 3 |
| 3 | 3 | 4 | 3 | 16 | 16 < 20,low = 4 |
| 4 | 4 | 4 | 4 | 19 | 19 < 20,low = 5 |
| — | 5 | 4 | low > high,返回 5:a[5] = 29 是第一个 ≥ 20 的元素 | ||
它有三个用处:① 8.2 节折半插入排序找插入位置;② 表中有重复关键字时找第一个等于 key 的(返回 low 后再看 a[low] 是否等于 key;原版 Binary_Search 遇到哪个相等的就返回哪个);③ “统计表中小于 key 的元素有几个”——答案就是 low。LowerBound 已与标准库 lower_bound 随机对拍 20 万组;两种错误写法的反例由程序找出。
跟我做一遍:折半查找的 low / high / mid
查 33(成功)
| 次 | low | high | mid | a[mid] | 动作 |
|---|---|---|---|---|---|
| 1 | 0 | 10 | 5 | 29 | 29 < 33,low = 6 |
| 2 | 6 | 10 | 8 | 37 | 37 > 33,high = 7 |
| 3 | 6 | 7 | 6 | 32 | 32 < 33,low = 7 |
| 4 | 7 | 7 | 7 | 33 | 相等,返回 7 |
查 20(失败)
| 次 | low | high | mid | a[mid] | 动作 |
|---|---|---|---|---|---|
| 1 | 0 | 10 | 5 | 29 | 29 > 20,high = 4 |
| 2 | 0 | 4 | 2 | 13 | 13 < 20,low = 3 |
| 3 | 3 | 4 | 3 | 16 | 16 < 20,low = 4 |
| 4 | 4 | 4 | 4 | 19 | 19 < 20,low = 5 |
| — | 5 | 4 | low > high,失败,返回 −1 | ||
两次都比较了 4 次 = 判定树的高度 ⌈log₂12⌉ = 4(这张表就是图 7-5 那棵 n = 11 的判定树:29 在根,33 在第 4 层)。失败时 low 停在 5:low 正好是 20 应该插入的位置,这个性质常被用来写“有序表中插入元素”的算法。(两次跟踪由程序输出核对。)
折半查找判定树
把每次比较的元素画成结点:第一次比较的 mid 为根,左半部分的判定树为左子树,右半部分的为右子树。再在每个空指针处补一个方形的“失败结点”,就得到判定树。
- 查找某元素的比较次数 = 它在判定树中的层数;查找失败的比较次数 = 失败结点的父结点的层数。
- n 个元素的判定树有 n 个圆形结点、n + 1 个失败结点。
- 判定树是平衡的,高度 h = ⌈log₂(n + 1)⌉(不含失败结点)。所以查找成功、失败的比较次数最多都是 ⌈log₂(n + 1)⌉(2010-09、2023-08)。
- mid 向下取整时,每个结点的右子树结点数 − 左子树结点数 = 0 或 1;向上取整时反过来。一棵树要能成为判定树,所有结点必须满足同一个规则(2017-08)。
- 图 7-5:ASL成功 = (1×1 + 2×2 + 3×4 + 4×4)/11 = 33/11 = 3;ASL失败 = (4×3 + 8×4)/12 = 44/12 ≈ 3.67。
- 时间复杂度 O(log n)。
判定树怎么画:mid 向下取整与向上取整
手画判定树不用真去跑程序,按区间递归:区间 [low, high] 的根是 mid,左子树是 [low, mid − 1] 的判定树,右子树是 [mid + 1, high] 的。n = 10(位序 1 ~ 10)两种取整的结果:
向下取整:每个结点右子树比左子树多 0 或 1 个结点
向上取整:左子树比右子树多 0 或 1 个结点
程序对 n = 1 ~ 299 逐个验证:判定树高度都等于 ⌈log₂(n + 1)⌉;n = 10、11 的两种 ASL 也由程序计算。2017-08 就是给几棵树问“哪棵可能是折半查找判定树”:检查是否所有结点都满足同一种“右多”或“左多”的规则。
每比较一次,就确定了 key 的一个范围(大于谁、小于谁);后面比较的元素必须落在当前范围内。例:500, 200, 450, 180:比 500 后知 key < 500;比 200 后走右边,知 200 < key < 500;比 450 后走左边,知 200 < key < 450;下一个 180 不在 (200, 450) 内,不可能(2015-07 A)。这和“二叉排序树的查找路径”(2011-07)是同一个判断方法。
为什么判定树的高度是 ⌈log₂(n + 1)⌉
⌈x⌉ 是“向上取整”。⌈log₂(n + 1)⌉ = h 等价于 2ʰ⁻¹ < n + 1 ≤ 2ʰ,即 2ʰ⁻¹ ≤ n ≤ 2ʰ − 1。口算法:找最小的 h,使 2ʰ − 1 ≥ n。n = 11:2³ − 1 = 7 不够,2⁴ − 1 = 15 够了 ⇒ h = 4;n = 16:15 不够 ⇒ h = 5;n = 1000:2¹⁰ − 1 = 1023 ≥ 1000 ⇒ h = 10。也就是说,1000 个元素折半查找最多比较 10 次。
- 二叉树最矮能多矮:第 i 层最多 2ⁱ⁻¹ 个结点,高为 h 的二叉树最多 1 + 2 + … + 2ʰ⁻¹ = 2ʰ − 1 个结点。所以 n 个结点的二叉树,高度至少是 ⌈log₂(n + 1)⌉。
- 判定树恰好就是最矮的:每次 mid 都把区间分成个数相差不超过 1 的两半,于是任何结点的左右子树结点数至多差 1。这样一层层分下去,除最后一层外,每层都是满的(和完全二叉树一样矮,只是最后一层的结点不一定靠左)。程序对 n = 1 ~ 1199 逐个检查了这一点。
- 前 h − 1 层满,共 2ʰ⁻¹ − 1 个结点;第 h 层至少 1 个、至多 2ʰ⁻¹ 个 ⇒ 2ʰ⁻¹ − 1 < n ≤ 2ʰ − 1 ⇒ h = ⌈log₂(n + 1)⌉。
- “最多比较几次”就是树高:成功时找最深的那个结点要比 h 次;失败时落在最深的失败结点,它的父结点在第 h 层,也比 h 次。
满判定树的 ASL 公式:当 n = 2ʰ − 1 时判定树是满二叉树,第 i 层 2ⁱ⁻¹ 个结点各比较 i 次:
- A. 4
- B. 5
- C. 6
- D. 7
展开解析
B折半查找失败时,比较次数 = 判定树中失败结点的父结点所在层数,最多等于判定树的高度 ⌈log₂(n + 1)⌉。
- n = 16:⌈log₂17⌉ = 5(2⁴ = 16 < 17 ≤ 32)。
- 16 个元素的判定树前 4 层是满的(15 个),第 5 层有 1 个结点;落在它下面的失败结点要比较 5 次。
- A 错4 次只能保证查找 15 个元素的表。
- B 对5 次。
- C 错6 次超过了判定树的高度 5:折半查找每比较一次下降一层,最多 5 层就到失败结点。
- D 错7 次同样超过树高 5。
折半查找无论成功还是失败,比较次数最多都是 ⌈log₂(n + 1)⌉。
查找概率不等时:顺序、折半、BST 谁更快(2013-42 题型)
| 组织方式 | 各关键字的比较次数 | ASL |
|---|---|---|
| ① 按关键字顺序排,顺序查找 | A 1、B 2、C 3、D 4 | 0.1 + 0.4 + 0.9 + 1.6 = 3.0 |
| ② 按概率从大到小排,顺序查找 | D 1、C 2、B 3、A 4 | 0.4 + 0.6 + 0.6 + 0.4 = 2.0 |
| ③ 有序表折半查找(判定树 B(A, C(, D))) | B 1、A 2、C 2、D 3 | 0.2 + 0.2 + 0.6 + 1.2 = 2.2 |
| ④ 最好的 BST:C(B(A, ), D) | C 1、B 2、D 2、A 3 | 0.3 + 0.4 + 0.8 + 0.3 = 1.8 |
结论出人意料:概率很不均匀时,“按概率排好的顺序查找”(2.0)竟然比折半(2.2)还快;而把概率大的放在靠近根的位置的 BST 最快(1.8,程序枚举所有 BST 后确认是最小值)。折半查找只在等概率时才是最优的。2013-42 正是让你在给定概率下比较几种组织方式并给出 ASL。
真题怎么考
- 最多比较次数(2010-09 失败最多 5 次、2023-08):⌈log₂(n + 1)⌉。
- 比较序列是否合法(2015-07):逐步缩小区间。
- 判定树的形状(2017-08)、折半的存储要求(2024-05 只能顺序存储)、ASL 计算(2016-09)。
- 大题(2013-42):比较顺序查找、折半查找、BST 在不同概率下的 ASL,第 9 篇 t6 有解答。
- “最多比较几次”直接写 ⌈log₂(n + 1)⌉。前提是“和 mid 比一次”算 1 次比较(408 的约定)。若题目问的是“查找失败最少比较几次”,答案是 ⌈log₂(n + 1)⌉ − 1 或 ⌈log₂(n + 1)⌉——要看最后一层满不满:n = 2ʰ − 1 时最后一层是满的,失败一律比 h 次。
- 判断一棵树能不能是判定树:先看每个结点“右子树结点数 − 左子树结点数”是不是全为 0 或 1(或全为 0 或 −1),只要有一个结点方向不同就排除。拿不准时,把 1 ~ n 按中序填进去,逐个检查每个结点是否是它所管区间的 mid。
- 比较序列合法性:这类题只给关键字、不给表,所以只能用“区间法”:每个后来的关键字都必须落在前面比较留下的区间里。若题目同时给出了表,还要检查每一步比较的是不是当前区间正中的那个元素。
- 算 ASL 别用公式硬套:((n + 1)/n)·log₂(n + 1) − 1 只对 n = 2ʰ − 1 精确;其他 n 一律画判定树、按层数加。
- 失败的比较次数 = 失败结点的父结点的层数(不是失败结点自己的层数)。
- 判定树是“平衡的”,但不一定是完全二叉树(n = 10 的两棵都不是)。
- 链表不能折半查找,有序也不行——不能随机存取到中间元素。
- 有序表 a[0..10](同上),用折半查找 41,依次和哪些元素比较?
答案
mid = 5(29)→ low = 6;mid = 8(37)→ low = 9;mid = 9(41)找到。依次比较 29、37、41,共 3 次。 - n = 30 的有序表折半查找,成功时最多比较几次?失败时最少几次、最多几次?
答案
判定树高 ⌈log₂31⌉ = 5。成功最多 5 次;失败结点挂在第 4、5 层结点下(n = 30 时第 5 层有 15 个结点),失败最少 4 次、最多 5 次。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 4
- B. 5
- C. 6
- D. 20
展开解析
B折半查找判定树的高度为 ⌈log2(n + 1)⌉ = ⌈log221⌉ = 5。查找失败时比较次数不超过树高,最多 5 次。
变化点:原题 n = 16,⌈log217⌉ = 5;这里 n = 20 仍是 5。n 在 16 ~ 31 之间,树高都是 5。失败时比较次数为树高或树高 − 1。
- A. 2.9
- B. 3.0
- C. 3.4
- D. 2.8
展开解析
A判定树各层结点数为 1、2、4、3(前三层满,第 4 层 3 个)。ASL = (1×1 + 2×2 + 3×4 + 4×3) ÷ 10 = 29 ÷ 10 = 2.9。
变化点:折半查找判定树是一棵平衡的二叉排序树,前 h − 1 层是满的,只有最后一层不满。算 ASL 时只要数最后一层有几个:10 − 7 = 3。
- A. 100, 50, 80, 60, 70
- B. 100, 50, 80, 90, 70
- C. 10, 90, 20, 80, 30
- D. 90, 10, 80, 20, 70
展开解析
B每比较一次就确定下一次的范围。B:100 之后比 50(往左,范围 < 100),50 之后比 80(往右,范围 (50, 100)),80 之后比 90(往右,范围 (80, 100)),接着比 70——70 不在 (80, 100) 内,矛盾。其余选项每个数都落在前面确定的范围内。
变化点:方法同二叉排序树的查找路径:维护一个区间 (下界, 上界),每个新数必须落在区间内,然后根据它与前一个数的大小关系收缩区间。
7.3分块查找(索引顺序查找)
折半查找要求整体有序,插入删除不方便;顺序查找不要求有序但太慢。分块查找取两者之长:把表分成若干块,块内可以无序,块间有序(前一块的最大关键字 < 后一块的所有关键字);再建一个索引表,存每块的最大关键字和块的起始地址,索引表按关键字有序。
查找分两步:先在索引表中确定所在的块(顺序或折半),再在块内顺序查找。ASL = LI(查索引)+ LS(查块内)。
2025-07:n = 400,两步都顺序查找,每块 √400 = 20 个元素时效率最高,ASL = 21。
为什么最优块长是 √n
若 x·y 是定值,x + y 在 x = y 时最小(均值不等式 x + y ≥ 2√(xy))。例如面积为 16 的长方形,周长最小的是 4 × 4 的正方形。
n 个元素均分成 b 块、每块 s 个(b = n/s),两步都顺序查找:ASL = (b + 1)/2 + (s + 1)/2 = (n/s + s)/2 + 1。其中 n/s 与 s 的乘积是定值 n,所以当 n/s = s,即 s = √n 时和最小,ASL最小 = √n + 1。
- 两步都顺序查找:s = √10000 = 100,分 100 块,ASL = (100 + 1)/2 + (100 + 1)/2 = 101。对比整表顺序查找 (10000 + 1)/2 ≈ 5000,快了约 50 倍。
- 索引表改用折半:ASL ≈ ⌈log₂(b + 1)⌉ + (s + 1)/2。此时 s 越小越好(极端情况每块 1 个,就是整表折半);实际中块长还要照顾“块内插入删除方便”,所以按需要取。
2025-07(n = 400 ⇒ 每块 20 个)就是这个模型;程序也对 s = 1 ~ 400 逐个计算确认 s = 20 最优。
跟我做一遍:在图 7-7 里查 38、41、90
| 查 | 第一步:在索引表中定块 | 第二步:块内顺序查找 | 共比较 |
|---|---|---|---|
| 38 | 22(38 > 22,往后)、48(38 ≤ 48,就是第 2 块),2 次 | 33、42、44、38,第 4 次找到 | 6 次,成功 |
| 41 | 同上,2 次,定在第 2 块 | 第 2 块 6 个元素比完都不等 | 8 次,失败 |
| 90 | 22、48、86 都比 90 小,3 次 | 不用进块 | 3 次,失败 |
- 定块的规则:在索引表里找第一个 ≥ key 的最大关键字,它对应的块就是 key 唯一可能在的块。理由是块间有序:第 2 块的元素都 > 22(第 1 块的最大值),且都 ≤ 48。
- 索引表也可以折半,用的正是 7.2 节的 LowerBound。查 38:low = 0、high = 2,mid = 1(48 ≥ 38,high = 0);mid = 0(22 < 38,low = 1);low > high 停下,low = 1 ⇒ 第 2 块,也是 2 次。查 90 时 low 停在 3(等于索引表长度),说明 key 比所有块都大,直接判失败。
- 等概率 ASL:把 18 个元素逐个这样数一遍再平均,结果 5.5,正好等于公式 (b + 1)/2 + (s + 1)/2 = (3 + 1)/2 + (6 + 1)/2(程序逐个计算核对)。注意 ASL 公式只算成功的查找。
块内为什么允许无序?插入新元素时只要放到对应块的末尾(块太满再拆块、改索引),不用像有序表那样挪动一大片。所以分块查找适合“数据经常增加、但能按范围大致分组”的场合,例如按首字母分卷的通讯录;B 树可以看成把这个想法一层层推广下去。
- n = 900,索引表和块内都顺序查找,每块多少个元素最好?此时 ASL 是多少?
答案
s = √900 = 30,ASL = 30 + 1 = 31。 - 分块查找要求块内有序吗?块与块之间呢?
答案
块内不要求有序;块间必须有序(前一块的最大关键字小于后一块的所有关键字),否则无法由索引表确定块。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 30,31
- B. 30,30
- C. 45,31
- D. 30,15.5
展开解析
A设每块 s 个,共 b = n/s 块。ASL = (b + 1)/2 + (s + 1)/2,在 s = √n 时最小:s = √900 = 30,b = 30。ASL = 15.5 + 15.5 = 31。
变化点:原题 400 个元素,每块 √400 = 20。若块间改用折半查找,ASL ≈ ⌈log2(b + 1)⌉ + (s + 1)/2,这时块应该分得大一些,结论就不再是 √n。
7.4二叉排序树(BST)
为什么要把有序表“种”成一棵树
折半查找快,但有序表插一个数要挪动一大串元素。BST 把“中间那个元素”变成一个路口的指路牌:比它小往左走、比它大往右走,每个路口都是一块新的指路牌。插入新元素只需要沿路走到尽头,挂一块新牌子,谁也不用挪。
哪里不像:真实的路网不会因为“修路顺序”而变形;BST 的形状却完全由插入顺序决定——按递增顺序插入,指路牌排成一条直线,查找就退化成顺序查找。这正是 7.5 节要解决的问题。
定义与性质
静态的有序表插删不方便。二叉排序树(二叉查找树、二叉搜索树)把有序性“存在树形里”,查找、插入、删除都只要沿一条路径走。定义:或者是空树,或者满足:
- 若左子树非空,左子树上所有结点的关键字都小于根的关键字;
- 若右子树非空,右子树上所有结点的关键字都大于根的关键字;
- 左、右子树也分别是二叉排序树。
由定义可得最重要的性质:对二叉排序树做中序遍历,得到递增有序序列(2022-41 判定 BST 就是检查中序是否递增)。
查找与插入
BSTNode *BST_Search(BSTree T, int key) { // 非递归查找 while (T != NULL && key != T->key) T = (key < T->key) ? T->lchild : T->rchild; return T; } bool BST_Insert(BSTree &T, int k) { // 插入:新结点一定成为叶子 if (T == NULL) { T = (BSTNode *)malloc(sizeof(BSTNode)); T->key = k; T->lchild = T->rchild = NULL; return true; } if (k == T->key) return false; // 已存在 if (k < T->key) return BST_Insert(T->lchild, k); return BST_Insert(T->rchild, k); }
代码怎么读:递归版的参数是 BSTree &T(C++ 引用,见 0.3 节)。走到空指针时执行 T = 新结点:此时 T 是“双亲结点的那根孩子指针”的别名,这一句就把新结点挂到了双亲上。如果参数不用引用,赋值只改了局部变量,新结点根本没挂上树。不想用引用,就要像下面的非递归版那样,用指针 f 一路记住双亲,最后再决定挂左边还是右边:
bool BST_Insert2(BSTree &T, int k) { // 非递归插入:f 始终是 p 的双亲 BSTNode *p = T, *f = NULL; while (p != NULL) { if (k == p->key) return false; // 已存在,不插 f = p; p = (k < p->key) ? p->lchild : p->rchild; } BSTNode *s = (BSTNode *)malloc(sizeof(BSTNode)); s->key = k; s->lchild = s->rchild = NULL; if (f == NULL) T = s; // 原来是空树,新结点就是根 else if (k < f->key) f->lchild = s; else f->rchild = s; return true; }
两版时间都是 O(h)(h 为树高);非递归版额外空间 O(1),递归版要 O(h) 的递归栈。大题里两种都给满分。非递归版的关键是循环结束时 p 已经是 NULL,只能靠 f 找回双亲——忘了 f 是最常见的错误。代码已随机插入 2 万棵树、与标准库集合对拍。
按一个关键字序列依次插入,就构造出一棵 BST。同一组关键字,插入顺序不同,树的形状可能不同(2020-05):第一个关键字一定是根;每个结点都必须在它的祖先之后插入。
跟我做一遍:构造一棵 BST 并算 ASL
| 插入 | 比较路径 | 挂在 |
|---|---|---|
| 45 | 空树 | 根 |
| 24 | 24 < 45 → 左 | 45 的左孩子 |
| 53 | 53 > 45 → 右 | 45 的右孩子 |
| 12 | < 45 左 → < 24 左 | 24 的左孩子 |
| 37 | < 45 左 → > 24 右 | 24 的右孩子 |
| 93 | > 45 右 → > 53 右 | 53 的右孩子 |
| 28 | < 45 → > 24 → < 37 | 37 的左孩子 |
- ASL 成功:第 1 层 45;第 2 层 24、53;第 3 层 12、37、93;第 4 层 28。(1 + 2 × 2 + 3 × 3 + 4)/7 = 18/7 ≈ 2.57。
- ASL 失败:在每个空指针处补失败结点,共 7 + 1 = 8 个。12 下面两个、37 右边一个、93 下面两个,父结点都在第 3 层,各比 3 次;53 左边一个,比 2 次;28 下面两个,比 4 次。(3 × 5 + 2 + 4 × 2)/8 = 25/8 = 3.125。
建树过程、两种 ASL 由参照程序计算核对。
删除(三种情况)
- 被删结点 z 是叶子:直接删掉。
- z 只有左子树或只有右子树:让它的子树代替它的位置。
- z 有左、右两棵子树:用 z 的中序直接后继(右子树中最左下的结点,没有左孩子)或直接前驱(左子树中最右下的结点,没有右孩子)代替 z 的值,再删去那个后继(前驱)——转化成情况 1 或 2。
三种删除放在一起看
原树(红色是要删的)
删 12(叶子):直接删
删 53(只有右子树):93 顶上来
删 24(两个孩子):中序后继 28 顶替
void BST_Delete(BSTree &T, int key) { if (T == NULL) return; // 没找到 if (key < T->key) BST_Delete(T->lchild, key); else if (key > T->key) BST_Delete(T->rchild, key); else if (T->lchild != NULL && T->rchild != NULL) { // 情况 3:两个孩子 BSTNode *s = T->rchild; while (s->lchild != NULL) s = s->lchild; // 中序后继:右子树最左下 T->key = s->key; // 用后继的值顶替 BST_Delete(T->rchild, s->key); // 再到右子树里删掉后继(情况 1 或 2) } else { // 情况 1、2:至多一个孩子 BSTNode *q = T; T = (T->lchild != NULL) ? T->lchild : T->rchild; // 孩子顶上来(叶子则为 NULL) free(q); } }
参数用 C++ 引用 BSTree &T,“孩子顶上来”只要一句赋值,不必另找双亲。时间 O(h)。代码已用 g++ 编译,随机插入删除 60 万次,每次都与标准库有序集合比较中序序列,全部一致。
查找效率
查找长度取决于树高。n 个结点的 BST,最好情况(接近平衡)高度约 log₂n,ASL = O(log n);最坏情况(按有序序列插入,退化成单支树)高度 n,ASL = (n + 1)/2,和顺序查找一样。为避免退化,引出平衡二叉树。
| 二叉排序树 | 折半查找 | |
|---|---|---|
| 判定树 | 不唯一,取决于插入顺序 | 唯一 |
| 查找 | 平均 O(log n),最坏 O(n) | O(log n) |
| 插入、删除 | O(log n)(只改指针) | O(n)(要移动元素) |
| 适合 | 动态查找表 | 静态查找表 |
“平均”到底有多好:把 n 个关键字的 n! 种插入次序看成等可能,先看 n = 3:
1 2 3
1 3 2
2 1 3 或 2 3 1
3 1 2
3 2 1
- n 个结点的 BST 有多少种形态?是卡特兰数 1, 2, 5, 14, 42, …(和 3.2 节出栈序列个数是同一个数列):选定根以后,左右子树的形态可以各自独立地数,递推式完全一样。程序对 n = 3、4、5 穷举所有插入次序核对。
- 拓展 一般地,随机次序下的平均 ASL = 2(1 + 1/n)Hₙ − 3(Hₙ = 1 + 1/2 + … + 1/n),n 很大时约为 1.39 log₂n。n = 1000 时约 12 次(程序随机建 200 棵树实测 12.0);完全平衡时约 9 次;有序插入退化成链时 500.5 次。
- 所以 BST 的毛病不是“平均差”,而是最坏太差——偏偏实际数据经常是有序或接近有序的(按学号、按时间顺序录入)。这正是 7.5 节平衡二叉树要解决的问题。
判断一个序列能不能是查找路径
① 90, 30, 80, 40, 70, 60 ② 90, 30, 80, 85, 60
- 维护一个区间 (下界, 上界),初始 (−∞, +∞)。每比较一个结点:60 比它小就往左,上界改成它;比它大就往右,下界改成它。下一个结点必须落在当前区间里。
- ①:90 → 往左,(−∞, 90);30 在区间内,往右,(30, 90);80 在内,往左,(30, 80);40 在内,往右,(40, 80);70 在内,往左,(40, 70);60 在内且等于 key ⇒ 可能。
- ②:…到 80 时区间是 (30, 80),往左;下一个 85 不在 (30, 80) 内 ⇒ 不可能。
- A. X < K1
- B. X > K2
- C. K1 < X < K3
- D. K3 < X < K2
展开解析
D从根走到 T:K1 → 右 → K2 → 左 → K3 → 右 → T。
- 在 K1 的右子树中:X > K1。
- 在 K2 的左子树中:X < K2。
- 在 K3 的右子树中:X > K3。
- 又因为 K3 在 K1 的右子树里,K3 > K1。合起来:K3 < X < K2(最紧的上下界)。
- A 错X 在 K1 的右子树,X > K1。
- B 错X 在 K2 的左子树,X < K2。
- C 错X 在 K3 的右子树,X > K3。
- D 对K3 < X < K2。
子树的取值范围 = 从根到它的路径上:往右走时的结点给下界,往左走时的结点给上界,取最紧的一对。
判断一棵二叉树是不是 BST(大题常用的小模块)
最常见的错误是只比较“每个结点和它的两个孩子”。正确做法利用 BST 的核心性质——中序序列严格递增:中序遍历时记住前一个访问的值 pre,只要当前值不大于 pre 就不是 BST。
int pre = INT_MIN; // 中序遍历中前一个结点的值 bool IsBST(BSTree T) { if (T == NULL) return true; if (!IsBST(T->lchild)) return false; // 左子树必须是 BST if (T->key <= pre) return false; // 中序序列必须严格递增 pre = T->key; return IsBST(T->rchild); }
时间 O(n),空间 O(h)。反例:5(3(1, 6), 8)——每个结点都比左孩子大、比右孩子小,但 6 在 5 的左子树里却大于 5,中序 1 3 6 5 8 不递增,函数返回 false。2022-41(顺序存储的二叉树判 BST)与 2026-41 都建立在这个性质上。代码已与“中序序列是否递增”的暴力判断随机对拍 2 万棵树。
同一棵 BST 可以由多少种插入次序得到?规则是“每个结点都要在它的所有祖先之后插入”,左右两棵子树的插入可以任意交错。本节的树 45(24(12, 37(28, )), 53(, 93)):根 45 必须第一个;左子树 4 个结点、右子树 2 个结点在后 6 个位置里交错,有 C(6, 4) = 15 种排法;左子树内部 24 第一个,其余 3 个中 12 与 {37, 28}(37 要在 28 前)交错,C(3, 1) = 3 种;右子树 53 必在 93 前,1 种。共 15 × 3 × 1 = 45 种(程序穷举 7! = 5040 个排列核对)。2020-05 考的“哪个序列能构造出与它相同的 BST”就用这条规则逐个检查。
BST 上的三个常用小算法:最值、≥ k 降序输出、范围输出
算法大题里 BST 常常只是“背景”,真正考的是会不会利用有序性剪枝——不必把整棵树都走一遍。
BSTNode *MinNode(BSTree T) { // 最小:一路向左(T 非空)
while (T->lchild != NULL) T = T->lchild;
return T;
}
BSTNode *MaxNode(BSTree T) { // 最大:一路向右
while (T->rchild != NULL) T = T->rchild;
return T;
}
void OutputGE(BSTree T, int k) { // 从大到小输出所有 ≥ k 的关键字
if (T == NULL) return;
OutputGE(T->rchild, k); // ① 先右子树:那里的关键字都比根大
if (T->key >= k) {
printf("%d ", T->key); // ② 再根
OutputGE(T->lchild, k); // ③ 根 ≥ k 时,左子树里才可能还有 ≥ k 的
} // 根 < k 时左子树全 < k,整棵剪掉
}
void OutputRange(BSTree T, int a, int b) { // 从小到大输出 a ≤ key ≤ b 的关键字
if (T == NULL) return;
if (T->key > a) OutputRange(T->lchild, a, b); // 根 > a,左子树里才可能有 ≥ a 的
if (T->key >= a && T->key <= b) printf("%d ", T->key);
if (T->key < b) OutputRange(T->rchild, a, b); // 根 < b,右子树里才可能有 ≤ b 的
}
- 最小、最大:从根一路向左 45 → 24 → 12,最小是 12;一路向右 45 → 53 → 93,最大是 93。它们分别是中序序列的第一个和最后一个。
- 从大到小输出 ≥ 30 的:“右—根—左”的反中序就是降序。先走右子树:93、53 输出;回到根 45 输出;45 ≥ 30,进左子树 24——先看 24 的右子树:37 输出,37 的左孩子 28 < 30 不输出;再看 24 本身:24 < 30,于是 24 的左子树 12 整棵剪掉。输出 93 53 45 37。
- 输出 [25, 50] 内的:45 > 25 ⇒ 进左子树;24 < 25,它的左子树(12)不可能 ≥ 25,跳过;24 不在区间、24 < 50 ⇒ 进它的右子树 37 → 28,依次输出 28、37;回到 45 输出 45;45 < 50 ⇒ 进右子树 53,53 > 50,它的右子树(93)跳过。输出 28 37 45,只访问了 45、24、37、28、53 五个结点,12 和 93 都没碰。
剪枝的依据只有一句话:左子树都比根小,右子树都比根大。范围很窄时访问的结点数约为“两条查找路径 + 输出个数”,即 O(h + k),比遍历整棵树的 O(n) 好得多。四个函数已与“中序遍历后再筛选”的暴力写法随机对拍 2 万棵树。
- 依次插入 50, 30, 70, 20, 40, 60, 80 得到的 BST 中,ASL 成功是多少?若改成按 20, 30, 40, 50, 60, 70, 80 的次序插入呢?
答案
第一种是满二叉树:(1 + 2 × 2 + 3 × 4)/7 = 17/7 ≈ 2.43。第二种退化成一条右斜链:(1 + 2 + … + 7)/7 = 4,与顺序查找一样。 - 在本节构造的 BST 中删除 45(根),用中序后继顶替,结果是什么?
答案
后继是右子树 53(, 93) 的最左下结点 53。把 53 搬到根,再删原来的 53(只有右子树,93 顶上):53(24(12, 37(28, )), 93)。 - 用 OutputRange 在本节的树 45(24(12, 37(28, )), 53(, 93)) 中输出 [30, 60] 内的关键字,输出什么?哪个结点没有被访问?
答案
输出 37 45 53。访问顺序 45、24、37、28、53、93;没被访问的只有 12(24 < 30,24 的左子树整棵跳过)。93 被访问了:53 < 60,所以还得进 53 的右子树看一眼,93 > 60 不输出、它没有左子树。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 50, 20, 40, 30, 35
- B. 50, 80, 60, 70, 65
- C. 50, 20, 45, 25, 48
- D. 10, 90, 20, 80, 30
展开解析
CC:50 → 20(往左,范围 < 50)→ 45(往右,范围 (20, 50))→ 25(往左,范围 (20, 45))→ 48,48 > 45 不在范围内,不可能。其余都满足区间逐步收缩。
变化点:与折半查找比较序列的判断方法完全一样。查找路径上,每个结点都要与路径上它之前的所有结点保持“正确的大小关系”,不只是和前一个比。
- A. 5, 8, 3, 9, 7, 4, 2
- B. 5, 3, 2, 4, 8, 9, 7
- C. 5, 3, 8, 9, 4, 7, 2
- D. 5, 4, 3, 8, 2, 7, 9
展开解析
D原序列生成的树:根 5,左孩子 3(3 的孩子 2、4),右孩子 8(8 的孩子 7、9)。D 中 4 先于 3 插入,4 成了 5 的左孩子,3 成了 4 的左孩子,树不同。
变化点:两个序列生成同一棵 BST 的充要条件:每个结点都在它所有祖先之后插入。只要这个条件满足,兄弟之间、不同子树之间的次序可以随便换。
7.5平衡二叉树(AVL 树)
为什么要“平衡”
BST 的查找时间等于树高,而树高完全由插入顺序决定:最好 log₂n,最坏 n。平衡二叉树(AVL)的思路是:每插入或删除一个结点就检查一下,一旦某处“一边比另一边高出 2 层”,立刻局部调整,让树高始终保持在 O(log n)。
扁担两头的重量(左右子树的高度)只允许差一点点;一旦一头明显更重,就把挑担的支点(根)往重的那边挪一挪——这就是“旋转”。哪里不像:挑担只看两头,AVL 要求每一个结点都平衡,调整也只在“离新结点最近的那个失衡点”做一次就够了(插入时)。
平衡因子
平衡二叉树:任一结点的左、右子树的高度差的绝对值不超过 1,且左右子树也是平衡二叉树(本书默认它同时是二叉排序树)。结点的平衡因子 = 左子树高度 − 右子树高度,只能是 −1、0、1。
- 标平衡因子:自底向上算每个结点的高度(叶子高 1),再用“左高 − 右高”。50 的左子树 30(20(10, ), 40) 高 3、右子树 70(, 80) 高 2 ⇒ +1;30:左 2、右 1 ⇒ +1;20:左 1、右 0 ⇒ +1;70:左 0、右 1 ⇒ −1;叶子都是 0。全部在 −1 ~ +1 之间,是 AVL 树。
- 插入 5:沿 50 → 30 → 20 → 10 走到 10 的左边。只有插入路径上的祖先,平衡因子才可能改变:10 变成 +1,20、30、50 都变成 +2——三个结点同时失衡!
- 只调离新结点最近的那个:20 是最近的失衡结点,以它为根的子树就是“最小不平衡子树”。从 20 出发朝 5 的方向走两步:左、左 ⇒ LL 型,以 20 右单旋,10 升上来。
- 调整后子树 10(5, 20) 的高度是 2,和插入前 20(10, ) 的高度一样,所以 30、50 的平衡因子自动回到 +1。插入时只调一处就够,原因就在这里:调整让子树恢复了插入前的高度,祖先们“感觉不到”这次插入。
各步平衡因子与旋转结果由参照程序输出核对。考试中常把“根失衡”和“某个中间结点失衡”混在一起考:一定要从新结点往上找第一个失衡的。
插入与四种旋转
插入一个结点后,从它往上找到第一个平衡因子绝对值变成 2 的结点 A,以 A 为根的子树叫最小不平衡子树,只需要调整这棵子树,整棵树就恢复平衡。按新结点插在 A 的哪个方向,分四种情况:
展开解析
- 15、3、7:7 插在 15 的左孩子 3 的右边,15 失衡(LR):7 升为根,3、15 为左右孩子。
- 10:插到 15 的左边,平衡。9:插到 10 的左边,15 的平衡因子变 2,最小不平衡子树是 15(LL):10 升上来,9、15 为其左右孩子。树:7(3, 10(9, 15))。
- 8:插到 9 的左边,此时根 7 的左高 1、右高 3,失衡;8 在 7 的右孩子 10 的左子树里(RL):9 升为根,7 在左、10 在右;9 原来的左子树 8 给 7 做右孩子,右子树(空)给 10 做左子树。结果 9(7(3, 8), 10(, 15))。
四种类型的最小例子(三个关键字就能看清):
| 类型 | 插入次序 | 失衡时的样子 | 怎么转 | 结果 |
|---|---|---|---|---|
| LL | 3, 2, 1 | 3 的左孩子 2 的左孩子 1 | 以 3 右旋 | 2(1, 3) |
| RR | 1, 2, 3 | 1 的右孩子 2 的右孩子 3 | 以 1 左旋 | 2(1, 3) |
| LR | 3, 1, 2 | 3 的左孩子 1 的右孩子 2 | 先以 1 左旋,再以 3 右旋 | 2(1, 3) |
| RL | 1, 3, 2 | 1 的右孩子 3 的左孩子 2 | 先以 3 右旋,再以 1 左旋 | 2(1, 3) |
四种情况的结果都是“三个结点中中间大小的那个当根”——这就是图下口诀“看中序”的含义:旋转前后中序序列不变,只是换了一个根。
旋转在代码里是什么:只改三根指针。以 LL 型的右单旋为例(结点里多存一个高度域 height,空树高 0):
typedef struct AVLNode { int key, height; struct AVLNode *lchild, *rchild; } AVLNode, *AVLTree; int H(AVLTree T) { return T == NULL ? 0 : T->height; } void Update(AVLTree T) { // 由两个孩子的高度算出自己的高度 int hl = H(T->lchild), hr = H(T->rchild); T->height = 1 + (hl > hr ? hl : hr); } AVLTree RotateRight(AVLTree A) { // LL:右单旋,A 的左孩子 B 上来 AVLTree B = A->lchild; A->lchild = B->rchild; // ① B 的右子树 BR 改挂为 A 的左子树 B->rchild = A; // ② A 下去,成为 B 的右孩子 Update(A); Update(B); // ③ 先更新下面的 A,再更新上面的 B return B; // B 是新的子树根,由调用者接回原双亲 } AVLTree RotateLeft(AVLTree A) { // RR:左单旋,与右单旋对称 AVLTree B = A->rchild; A->rchild = B->lchild; B->lchild = A; Update(A); Update(B); return B; }
- 为什么中序不变:BR 里的关键字原本夹在 B 和 A 之间(比 B 大、比 A 小);旋转后 BR 成了 A 的左子树,仍然夹在 B 和 A 之间。BL 始终在 B 左边,AR 始终在 A 右边。
- 为什么 ③ 的顺序不能反:旋转后 A 在 B 下面,B 的高度要用到 A 的新高度,所以先算 A。
- LR 怎么写:
A->lchild = RotateLeft(A->lchild); return RotateRight(A);——先把左孩子那一段左旋成“LL 的样子”,再对 A 右旋。RL 对称。
拓展 把旋转嵌进递归插入,就得到完整的 AVL 插入:
AVLTree AVL_Insert(AVLTree T, int k) { // 返回插入并调整后的子树根 if (T == NULL) { T = (AVLTree)malloc(sizeof(AVLNode)); T->key = k; T->height = 1; T->lchild = T->rchild = NULL; return T; } if (k < T->key) T->lchild = AVL_Insert(T->lchild, k); else if (k > T->key) T->rchild = AVL_Insert(T->rchild, k); else return T; // 已存在 Update(T); // 递归返回途中自下而上更新高度 int bf = H(T->lchild) - H(T->rchild); if (bf == 2) { // 第一个遇到的失衡结点 = 最小不平衡子树的根 if (k < T->lchild->key) return RotateRight(T); // LL T->lchild = RotateLeft(T->lchild); return RotateRight(T); // LR } if (bf == -2) { if (k > T->rchild->key) return RotateLeft(T); // RR T->rchild = RotateRight(T->rchild); return RotateLeft(T); // RL } return T; }
读这段代码能把三件事想通:① 递归在返回途中自下而上检查,最先发现 |bf| = 2 的正是离新结点最近的失衡结点;② 判 LL 还是 LR 只要比较 k 和 A 的左孩子的关键字——k 更小说明新结点在左孩子的左边;③ 旋转后子树恢复原高度,再往上的祖先算出的 bf 都正常,不会再旋转。408 不要求默写 AVL 代码,但这三点是选择题的理解基础。代码已随机插入 3000 棵树(每棵至多 300 个关键字),检查平衡、高度域与中序序列,并复现了上面“插入 5”的例子。
逐步图:15, 3, 7, 10, 9, 8 的插入与旋转
① 插 7:15 失衡(LR)
② 先左旋再右旋:7 升为根
③ 插 10、9:15 失衡(LL)
④ 以 15 右旋:10 上来
⑤ 插 8:根 7 失衡(RL)
⑥ 先右旋再左旋:9 升为根
AVL 的高度与结点数
最少结点的 AVL 树:为什么 Nₕ = Nₕ₋₁ + Nₕ₋₂ + 1
要让高度为 h 的 AVL 树结点最少:根的一棵子树高 h − 1(必须有一棵这么高,树才有 h 层),另一棵在平衡允许的范围内尽量矮,取 h − 2;两棵子树本身也要“最省”。所以 Nₕ = Nₕ₋₁ + Nₕ₋₂ + 1(加 1 是根)。
h = 1:1 个
h = 2:2 个
h = 3:4 个
h = 4:7 个
| h | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Nₕ(最少结点) | 1 | 2 | 4 | 7 | 12 | 20 | 33 | 54 |
| 2ʰ − 1(最多结点) | 1 | 3 | 7 | 15 | 31 | 63 | 127 | 255 |
反过来问“n 个结点的 AVL 树最高多高”:找最大的 h 使 Nₕ ≤ n。例如 n = 20:N₆ = 20 ≤ 20 < 33 = N₇,最高 6;n = 50:N₇ = 33 ≤ 50 < 54,最高 7。最矮则是 ⌈log₂(n + 1)⌉(像完全二叉树那样排满)。(Nₕ 与最少结点树形由程序构造核对。)
删除
先按 BST 的方法删除,再从被删结点往上检查平衡因子,遇到失衡就按四种情况旋转(此时按“较高的子树”方向判断类型)。与插入不同,删除时一次旋转后子树可能变矮,失衡会一直向上传递,最坏要调整到根,O(log n)。所以“删除再插回”得到的 AVL 树可能和原来不同(2019-04)。
删除时怎么判型:插入时看“新结点在哪边”,删除时没有新结点,改看失衡结点 A 较高的那棵子树:设较高子树的根为 B——
- B 的平衡因子与 A 同号或为 0 ⇒ 单旋(A 左高就 LL 右旋,右高就 RR 左旋);
- B 的平衡因子与 A 异号 ⇒ 双旋(LR 或 RL),B 那一侧的孙子升上来。
- 删叶子 1 后,2 的左子树空(高 0)、右子树 4(3, 5) 高 2 ⇒ 2 的平衡因子 −2,失衡。
- 较高的是右子树,根 B = 4,它的平衡因子是 0(3、5 一样高)——与 −2 “同号或为 0” ⇒ RR 型,以 2 左旋:4 升上来,2 下去当 4 的左孩子,4 原来的左子树 3 改挂为 2 的右孩子。结果 4(2(, 3), 5)。
- 调整后子树高度仍是 3,和删除前一样,所以失衡不会再往上传。只有当 B 的平衡因子不为 0 时,旋转后子树会比删除前矮 1,祖先才可能接着失衡(上面“连续旋转”的例子就是这种情况)。
B 的平衡因子为 0 只可能在删除时出现(插入后 B 一定偏向新结点那边),所以插入时只有四种情况,删除时多了这一种要留心。结果与参照程序一致。
删除可能要连续旋转(预测点)
原树,删 12
① 11 失衡(LR)→ 10(9, 11);子树变矮,根 8 又失衡
② 8 处 LL 右旋,5 成为新根
- 删叶子 12 后,11 的左子树 9(, 10) 高 2、右子树空 ⇒ 11 失衡。看 11 的左孩子 9:它“右边高”⇒ LR,10 升上来,子树变成 10(9, 11),高度从 3 变成 2。
- 这一变矮让根 8 的右子树只剩高度 2,而左子树 5 高 4 ⇒ 8 失衡;5 的左边更高 ⇒ LL,以 8 右旋,5 成为新根。
- 结论:插入时一次调整就能让子树恢复原高度,所以最多调整一处;删除时调整后子树可能变矮,失衡会向上传,最坏一直调到根(王道新版明确讲了这一点)。
这个例子是用参照程序搜索出来的:12 个结点的 AVL 树中,删除 12 恰好触发 LR + LL 两次旋转;每一步的树形都由程序打印核对。
- A. 13, 48
- B. 24, 48
- C. 24, 53
- D. 24, 90
展开解析
C
- 48 > 24 → 往右;48 < 53 → 往左;48 > 37 → 成为 37 的右孩子。
- 往上检查:37 平衡因子 −1,53 为 1,24:左子树高 1、右子树高 3,平衡因子 −2,最小不平衡子树的根是 24。
- 48 插在 24 的右孩子(53)的左子树(37)里 ⇒ RL 型:37 升为根,24 为左孩子、53 为右孩子;37 原来的右子树 48 给 53 当左孩子,原来的左子树(空)给 24 当右子树。
- 新树:37(24(13, ), 53(48, 90))。37 的左、右孩子是 24、53。
- A 错13 始终是 24 的左孩子,跟着 24 一起走,不会直接挂到 37 下面;48 则交给了 53 当左孩子。
- B 错48 应挂在 53 下面。
- C 对24, 53。
- D 错90 仍是 53 的右孩子。
LR / RL:最小不平衡子树中“孙子”那个结点升为根,另外两个分到它左右。
真题怎么考
- 插入后某结点的孩子是谁 / 根是谁(2010-04、2013-03、2015-04、2021-06、2026-08):画出插入位置 → 找最小不平衡子树 → 判型 → 旋转。
- 最少结点数(2012-04:高 6 最少 20 个)、平衡因子的概念(2009-04)。
- 删除再插入(2019-04):删除后的调整可能和插入时不同,树不一定复原。
- 先按 BST 规则把新关键字放到位,画出来,不要只在脑子里转。
- 从新结点往上逐个算平衡因子,停在第一个 ±2 处——这就是 A。
- 从 A 朝新结点走两步定类型;用“三个结点按中序排,中间的当根”直接写出结果,再把四棵子树按中序挂回去。
- 最后检查:中序是否仍递增、每个结点的平衡因子是否都在 −1 ~ 1。选项里常有“转对了但子树挂错”的干扰项,这一步专门防它。
- 判型看的是从最小不平衡结点 A 往下走的两步,而不是从根、也不是从新结点往上数。
- LR 是“先对 A 的左孩子左旋,再对 A 右旋”,名字里的 L、R 指新结点相对 A 的位置,不是旋转方向。
- 平衡因子 = 左子树高度 − 右子树高度(王道口径),取值只能是 −1、0、1。
- 依次插入 1, 2, 3, 4, 5, 6, 7 构造 AVL 树,一共旋转几次?最后的树是什么样?
答案
插 3、5、6、7 时各做一次 RR 左旋,共 4 次;最后是满二叉树 4(2(1, 3), 6(5, 7))。 - 高度为 5 的 AVL 树最少有几个结点?33 个结点的 AVL 树最高是多少?
答案
N₅ = N₄ + N₃ + 1 = 7 + 4 + 1 = 12。N₇ = 33 ≤ 33 < 54 = N₈,最高 7。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
B插 3 时在 1 处 RR 左旋得 2(1, 3);插 5 时在 3 处 RR 左旋得 2(1, 4(3, 5));插 6 时根 2 失衡(RR),左旋得 4(2(1, 3), 5(, 6))。
分支结点 4、2、5 的平衡因子分别为 0、0、−1,平衡因子为 0 的有 2 个。
变化点:原题插到 7 恰好得到满二叉树,3 个分支结点的平衡因子都是 0。少插一个,5 缺了左孩子。可用 AVL 小工具核对每一步旋转。
- A. 20
- B. 31
- C. 32
- D. 33
展开解析
D所有非叶结点平衡因子都为 1,说明每个结点的左子树都比右子树高 1,这正是高度为 h 的“最瘦”AVL 树:Nh = Nh−1 + Nh−2 + 1。N1 = 1,N2 = 2,N3 = 4,N4 = 7,N5 = 12,N6 = 20,N7 = 33。
变化点:原题高度 6 得 20。这个数列要会递推:1, 2, 4, 7, 12, 20, 33, 54……它也是“高度为 h 的 AVL 树最少有几个结点”的答案。
- A. 9;7、10
- B. 7;3、10
- C. 9;3、15
- D. 10;7、15
展开解析
A插 7:15 处 LR 失衡,得 7(3, 15)。插 10、9:15 处 LL 失衡,右旋得 7(3, 10(9, 15))。插 8:根 7 平衡因子 −2,8 插在 7 的右孩子 10 的左子树中,RL 型:先在 10 处右旋、再在 7 处左旋,得 9(7(3, 8), 10(, 15))。
变化点:判断旋转类型只看从失衡结点往下走的前两步:右、左 ⇒ RL。最终新根是失衡路径上的第三个结点(这里是 9)。
- A. 7
- B. 8
- C. 10
- D. 11
展开解析
D根的两棵子树高度差至多 1。要差最大:一棵高 4 且是满二叉树(15 个结点),另一棵高 3 且结点最少(N3 = 4 个)。差 = 15 − 4 = 11。
变化点:原题高度 4:满的高 3 子树 7 个,最瘦的高 2 子树 2 个,差 5。套路:一边取最多(满二叉树 2h − 1),一边取最少(Nh)。
7.6红黑树
AVL 树要求很严格(高度差 ≤ 1),插入、删除时经常要旋转,而且删除时旋转可能一路传到根。实际系统中数据变动频繁,于是有了红黑树:放宽平衡要求,保证“大致平衡”,换来插入、删除时只需要常数次旋转。C++ 的 map / set、Java 的 TreeMap、Linux 的进程调度器都用它。
把黑结点想成地铁线路上的正式站,红结点是临时加的小站。规则只有两条:从总站(根)出发到任何终点,经过的正式站数都一样;两个小站不能挨着。于是最短的线路全是正式站,最长的线路正式站、小站交替——最长也不超过最短的 2 倍。红黑树不追求每个路口都平衡,只保证“没有哪条线路长得离谱”。
哪里不像:真实线路的站点不会改颜色;红黑树插入删除时会频繁“变色”,变色比旋转便宜得多,这正是它比 AVL 调整代价小的原因。
定义(五条性质)
红黑树是一棵二叉排序树,每个结点多一个颜色域,并且满足:
- 每个结点是红色或黑色的。
- 根是黑色的。
- 叶结点(虚构的外部结点 NIL,也就是空指针处)都是黑色的。
- 不存在两个相邻的红结点(红结点的双亲和孩子都是黑色)。
- 对每个结点,从它到任一叶结点的简单路径上,所含黑结点的数量相同。
从某结点 x 出发(不含 x)到叶结点路径上的黑结点数,叫 x 的黑高 bh(x);根的黑高就是树的黑高。
- 从根到叶结点的最长路径不超过最短路径的 2 倍:最短路径全是黑结点,最长路径红黑相间,而两条路径上黑结点数相同。
- 黑高为 bh 的子树至少有 2bh − 1 个内部结点;而根的黑高 ≥ h/2(性质 4:路径上红结点不超过一半)。所以 n 个内部结点的红黑树高度 h ≤ 2log₂(n + 1),查找、插入、删除都是 O(log n)。
- 红黑树不一定是 AVL 树(它可以“更不平衡”);但 AVL 树一定可以染成红黑树。
- 新插入的结点一律染成红色:染黑会改变所在路径的黑结点数,破坏性质 5,很难修;染红最多破坏性质 4(红红相邻),好修。
插入:先按 BST 插入(红色),再看叔叔的颜色
设新结点为 z,父结点 p,祖父 g,叔叔(g 的另一个孩子)y。
| 情况 | 处理 |
|---|---|
| z 是根 | 染黑,结束。 |
| p 是黑色 | 什么都不用做(没有违反任何性质)。 |
| p 红,叔叔 y 黑(NIL 也算黑) | 旋转 + 染色:按 z、p、g 的位置像 AVL 一样分 LL / RR / LR / RL,旋转后新的子树根染黑,它的两个孩子染红。例如 LL:以 g 右单旋,p 变黑、g 变红。结束。 |
| p 红,叔叔 y 红 | 只染色:p、y 染黑,g 染红;把 g 当作新的 z,向上继续检查(g 可能和它的父亲红红相邻)。若 g 是根,最后染回黑色。 |
为什么这两种处理都不会破坏“各路径黑结点数相同”:
- 叔叔红 ⇒ 只变色:变色前 g 黑、p 和 y 红;变色后 g 红、p 和 y 黑。从 g 往下的任何一条路径,都恰好经过 p、y 中的一个——少了 g 这个黑,多了 p 或 y 这个黑,黑结点数不变。唯一的风险是 g 变红后可能和它的父亲红红相邻,所以要把 g 当成新的 z 继续往上查。
- 叔叔黑 ⇒ 旋转 + 变色:调整前,任何一条穿过这一小块的路径,在 z、p、g 三个结点里恰好经过 1 个黑结点(g);旋转后新的子树根染黑、它的两个孩子染红,每条路径在这三个结点里仍恰好经过 1 个黑结点(新根)。而且子树根仍是黑色,上面不可能再红红相邻,所以到此结束,最多 2 次旋转。
- 新结点为什么染红:染黑会让它所在的那些路径凭空多出一个黑结点,几乎处处违反性质 5;染红最多只造成“红红相邻”这一处问题,而上面两招正好专治它。
逐步图:依次插入 20, 10, 5, 30, 40, 57
① 20、10:父黑,不用调
② 插 5:父 10 红、叔叔 NIL 黑 ⇒ LL
③ 以 20 右旋,10 黑、5 和 20 红
④ 插 30:叔叔 5 红 ⇒ 只变色,根染回黑
⑤ 插 40:父 30 红、叔叔 NIL ⇒ RR
⑥ 以 20 左旋,30 黑、20 和 40 红
⑦ 插 57:叔叔 20 红 ⇒ 变色,30 变红;30 的父亲 10 黑,结束
- 最少:全黑、最矮。根下面的每条路径(不含根、含 NIL)只有 2 个黑结点:根的两个孩子是黑色,再下面就是 NIL ⇒ 3 个内部结点 = 2² − 1。
- 最多:尽量多塞红结点。路径上黑、红交替:根(黑)→ 红 → 黑 → 红 → NIL,每层都排满,是高 4 的满二叉树 ⇒ 15 个 = 4² − 1。
- 一般地,黑高 bh 的红黑树内部结点数在 [2bh − 1, 4bh − 1] 之间。由“至少 2bh − 1 个”和“bh ≥ h/2”就推出 h ≤ 2log₂(n + 1)。
本书黑高的口径:从 x 出发、不含 x、含 NIL 叶;有的教材不数 NIL,此时公式里的 bh 要相应加 1,做题先看题目定义。两个界由程序递推验证(bh = 1 ~ 5)。
跟我做一遍:同一个形状,哪几种染色是红黑树
① 合法
② 全黑:不合法
③ 30、40 都红:不合法
④ 合法
- 检查顺序:先看根是不是黑(性质 2);再看每个红结点的孩子是不是都黑(性质 4);最后数黑结点(性质 5)。性质 5 要从每个结点出发检查,不只从根出发。② 从根数:20 → 10 → 5 → NIL 经过 10、5、NIL 共 3 个黑,20 → 30 → NIL 只经过 30、NIL 共 2 个,不等。从 30 数更直观:往左直接是 NIL(1 个黑),往右经过 40 再到 NIL(2 个黑)。
- ③ 的 30、40 是相邻的两个红结点,违反性质 4(顺带也让 20 左右的黑结点数不等)。
- ① 和 ④ 都合法:同一个形状可以有不止一种合法染色。④ 正是依次插入 20, 10, 30, 5, 15, 40 得到的结果(插 5 时叔叔 30 是红的,只变色);① 则把红色放在了第二层。
- 反过来想:30 只有一个孩子 40,那么 40 必须是红的(否则 30 两边黑结点数不同),而且 40 下面不能再有孩子。推广成一条好用的结论:红黑树中只有一个孩子的结点一定是黑的,它唯一的孩子一定是红色的叶子。
四种染色由程序逐条检查五条性质;插入结果由参照程序输出。
展开解析
- 20:根,黑。10:插在 20 左边,红,父亲黑,OK。
- 5:插在 10 左边,红;父亲 10 红,叔叔(20 的右孩子)是 NIL 黑 ⇒ LL:以 20 右旋,10 成为根并染黑,5、20 染红。
- 30:插在 20 右边,红;父亲 20 红,叔叔 5 红 ⇒ 只染色:5、20 黑,10 红;10 是根,染回黑。
- 40:插在 30 右边,红;父亲 30 红,叔叔(20 的左孩子)NIL 黑 ⇒ RR:以 20 左旋,30 上来染黑,20、40 染红。树:10(5, 30(20, 40)),10、5、30 黑。
- 57:插在 40 右边;父亲 40 红,叔叔 20 红 ⇒ 染色:20、40 黑,30 红;30 的父亲 10 是黑的,结束。得到图 7-15。
插入最多旋转 2 次(LR、RL),染色可能向上传播 O(log n) 层。删除更复杂(要处理“双黑”),最多旋转 3 次,拓展 408 大纲只要求了解。
| AVL 树 | 红黑树 | |
|---|---|---|
| 平衡要求 | 严格:任一结点左右高度差 ≤ 1 | 宽松:最长路径 ≤ 2 × 最短路径 |
| 树高 | 约 1.44 log₂n | ≤ 2 log₂(n + 1) |
| 查找 | 略快 | 略慢,仍是 O(log n) |
| 插入 / 删除的旋转次数 | 插入最多 2 次,删除可能 O(log n) 次 | 插入最多 2 次,删除最多 3 次 |
| 适合 | 查找多、修改少 | 频繁插入删除 |
拓展 红黑树其实是一棵“拆开的”4 阶 B 树。把每个黑结点和它的红孩子“捏”成一个大结点,图 7-15 的 10(5, 30(20, 40(, 57))) 就变成 [10 30]{[5] [20] [40 57]}:每个大结点有 1 ~ 3 个关键字、所有叶子在同一层——正是 7.7 节的 4 阶 B 树(又叫 2-3-4 树)。五条性质都能翻译过去:“各路径黑结点数相同”就是“B 树所有叶子在同一层”;“红结点不相邻”保证一个大结点最多 3 个关键字;插入时“叔叔红 ⇒ 变色并往上传”,对应 B 树“结点满了就分裂、中间关键字上移”。理解了这层对应,红黑树的规则就不再是死记的口诀。(捏合结果由程序核对。)
拓展 删除的思路:“双黑”
先按 BST 的方法删除。若真正被摘掉的结点是红色,什么性质都没坏,结束;若是黑色,它所在路径上少了一个黑结点,就让顶替它的结点“背上一个额外的黑”,叫双黑。之后按兄弟的颜色分情况:兄弟红 ⇒ 旋转把兄弟变黑;兄弟黑且它的孩子都黑 ⇒ 兄弟染红,把“多出来的黑”上交给父亲;兄弟黑且有红孩子 ⇒ 旋转 + 变色,从兄弟那边“借”一个黑,结束。整个删除最多 3 次旋转。408 考纲只要求了解,真题未考过删除细节。
- 新结点一律染红;插入只会破坏“红红不相邻”,所以只看叔叔:叔叔红只变色(可能向上传),叔叔黑(含 NIL)才旋转。
- 红黑树不一定是 AVL 树;AVL 树都能染成红黑树。
- “根到叶的最长路径 ≤ 2 × 最短路径”是推论,不是定义里的条件。
- 在本节最终的红黑树 10(5, 30(20, 40(, 57))) 中再插入 25,需要旋转吗?
答案
25 插在 20 的右边,染红;父亲 20 是黑色 ⇒ 什么都不用做。 - 判断:“红黑树中,从根到任一叶结点的路径上,红结点数不超过黑结点数。”
答案
对(数 NIL 时):红结点不能相邻,每个红结点的孩子都是黑的,所以红的个数不超过黑的个数。这正是“最长 ≤ 2 × 最短”的来源。
考纲补充(尚无真题):红黑树
红黑树是考纲点,2009—2026 年尚未直接考过,下面两道按考纲自编。
- A. 根结点和叶结点(NIL)都是黑色
- B. 从根到叶结点的最长路径不超过最短路径的 2 倍
- C. 新插入的结点染成红色
- D. 红黑树一定是平衡二叉树(AVL 树)
展开解析
D红黑树只保证“大致平衡”(最长 ≤ 2 × 最短),某个结点的左右子树高度差可以超过 1,所以不一定是 AVL 树;反过来 AVL 树都可以染成红黑树。A、B、C 都对。
变化点:新结点染红是为了只可能破坏“红红不相邻”这一条,修起来容易;若染黑会改变一条路径上的黑结点数。
- A. 以祖父为中心做一次单旋转
- B. 父亲和叔叔染黑、祖父染红,再以祖父为当前结点继续向上检查
- C. 先双旋转再染色
- D. 删除 z 后重新插入
展开解析
B叔叔红:只需变色——父、叔变黑,祖父变红(每条路径的黑结点数不变),但祖父可能和它的父亲红红相邻,所以要以祖父为新的 z 继续检查;若祖父是根,最后把根染回黑。
变化点:叔叔黑(包括 NIL)时才旋转:LL / RR 单旋,LR / RL 双旋,旋转后新子树根染黑、两个孩子染红,然后结束。
- A. x 是红色,它的孩子是黑色
- B. x 是黑色,它的孩子是红色的叶结点
- C. x 和它的孩子都是黑色
- D. x 的孩子下面还可以有一个黑色的孩子
展开解析
B从 x 往 NIL 那一侧走,只经过 1 个黑结点(NIL 本身);往孩子那一侧走,黑结点数必须也是 1,所以孩子不能是黑色(C 错),只能是红色,而且孩子下面只能直接是 NIL——若还有孩子,那个孩子必须是黑的(红不相邻),黑结点数就变成 2 了(D 错)。孩子是红色,x 就不能是红色(A 错)。
变化点:这是“各路径黑结点数相同”的直接推论,见 7.6 的染色判断跟做。同理,AVL 树中只有一个孩子的结点,那个孩子也一定是叶子(否则高度差 ≥ 2)。
7.7B 树
为什么要“又矮又胖”:数一数读盘次数
内存访问约 100 纳秒,读一次磁盘约 10 毫秒,相差约 10 万倍。100 万条记录用平衡二叉树做索引,树高约 20,最坏要读 20 次盘;若每个结点做成一个磁盘块、放 200 个关键字(201 路分叉),3 层最多能放 201³ − 1 ≈ 812 万个关键字,查找最多只读 3 次盘。B 树就是“一个结点 = 一个磁盘块”的多路平衡查找树:结点内的比较在内存里做,几乎不花时间;真正贵的是走了几层。
数据量大到内存放不下时,数据在磁盘上。读一次磁盘比内存慢十万倍,所以要让树尽量矮——每个结点放很多关键字、有很多孩子,一个结点正好是一个磁盘块。这就是多路平衡查找树:B 树。
m 阶 B 树的定义
- 每个结点最多 m 棵子树,即最多 m − 1 个关键字。
- 若根不是叶结点,根至少有 2 棵子树(至少 1 个关键字)。
- 除根以外的所有非叶结点至少有 ⌈m/2⌉ 棵子树,即至少 ⌈m/2⌉ − 1 个关键字。
- 结点内关键字有序:K₁ < K₂ < … < Kₙ,子树指针 P₀、P₁、…、Pₙ 夹在它们之间,Pᵢ₋₁ 所指子树中的关键字都 < Kᵢ,Pᵢ 所指子树中的都 > Kᵢ。
- 所有叶结点都出现在同一层,叶结点不带信息(可以看作查找失败结点,实际是空指针)。
| m | 每个结点子树数 | 关键字数(根) | 关键字数(其他结点) |
|---|---|---|---|
| 3(2-3 树) | 2 ~ 3 | 1 ~ 2 | 1 ~ 2 |
| 4 | 2 ~ 4 | 1 ~ 3 | 1 ~ 3 |
| 5 | 3 ~ 5 | 1 ~ 4 | 2 ~ 4 |
定义第 5 条说的“叶结点”是失败结点:最底层含关键字的结点下面那些空指针,不含任何信息,画图时常常省略。最底层含关键字的结点叫终端结点。可真题里也常用“叶结点”指终端结点:2012-09 问“最右叶结点中的关键字”,2023-07 说“删除操作一定会导致叶结点的变化”——失败结点里没有关键字,这些“叶结点”显然指终端结点。判断规则:说到“叶结点中的关键字”“叶结点的变化”,指终端结点;说“B 树的高度”,一般不含失败结点那一层(2013-10:高度为 2 的 5 阶 B 树最少 5 个关键字,就是按“根 + 一层终端结点”算的)。
设高度为 h(不含叶结点那一层),关键字总数为 n:
- 关键字最多:每个结点 m − 1 个关键字、m 棵子树:nmax = (m − 1)(1 + m + … + mh−1) = mh − 1。
- 关键字最少:根 1 个关键字(2 棵子树),其余结点 ⌈m/2⌉ − 1 个关键字(⌈m/2⌉ 棵子树)。第 1 层 1 个结点、第 2 层 2 个、第 3 层 2⌈m/2⌉ 个……第 h 层 2⌈m/2⌉h−2 个:nmin = 1 + 2(⌈m/2⌉h−1 − 1) = 2⌈m/2⌉h−1 − 1(2013-10、2018-08)。
- 反过来,n 个关键字的 B 树高度:logm(n + 1) ≤ h ≤ log⌈m/2⌉((n + 1)/2) + 1。
- n 个关键字的 B 树,叶结点(失败结点)有 n + 1 个。
- 结点数最多:每个结点关键字尽量少;结点数最少:每个结点关键字尽量多(2014-09、2021-09)。
高度范围的推导:最胖和最瘦的两种 B 树
| 5 阶 B 树的高 h | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 关键字最少 2⌈m/2⌉h−1 − 1 | 1 | 5 | 17 | 53 |
| 关键字最多 mh − 1 | 4 | 24 | 124 | 624 |
- 最矮(每个结点都装满):mh − 1 ≥ 100 ⇒ 5h ≥ 101 ⇒ h ≥ 3(5² = 25 < 101 ≤ 125)。
- 最高(每个结点都最瘦):2⌈5/2⌉h−1 − 1 ≤ 100 ⇒ 3h−1 ≤ 50.5 ⇒ h − 1 ≤ 3(3³ = 27 ≤ 50.5 < 81)⇒ h ≤ 4。
- 所以 3 ≤ h ≤ 4。对照上表:h = 3 时关键字可在 17 ~ 124 之间,h = 4 时在 53 ~ 624 之间,100 都落在里面 ✓。
表格与范围由程序枚举核对。“高度”不含叶结点(失败结点)那一层,这是 408 的约定。
- 结点最多:每个结点放的关键字越少越好(非根至少 2 个,根至少 1 个)。高度 2 放不下(根 1 个 + 2 个叶子最多 8 个),只能是高度 3:根 1 个关键字 → 第 2 层 2 个结点,设共 k 个关键字(k ≥ 4)→ 第 3 层有 k + 2 个结点,每个至少 2 个关键字。总数 1 + k + 2(k + 2) ≤ 20 ⇒ k ≤ 5,取 k = 5:第 3 层 7 个结点恰好 14 个关键字。结点数 1 + 2 + 7 = 10。
- 结点最少:每个结点尽量装满(最多 4 个)。⌈20/4⌉ = 5 个结点不行(5 个结点只能是根 + 4 个孩子,根最多 3 个关键字 + 16 = 19);根 4 个关键字带 5 个叶子,叶子共 16 个关键字,每个叶子 3 ~ 4 个都合法 ⇒ 最少 6 个。
两个答案由程序枚举所有合法的 5 阶 B 树结构(高 1 ~ 4)求得。2014-09(15 个关键字的 4 阶 B 树,结点最多 15 个)、2021-09 是同类题:求最多结点就让每个结点最瘦,求最少结点就让每个结点最胖,再检查结构是否合法。
查找
从根开始,在结点内(有序表)顺序或折半查找;找到就成功,没找到就根据范围走到对应的子树,直到走到叶结点(空指针)为失败。查找过程 = 在树上“找结点”(读磁盘)+ 在结点内“找关键字”(内存中)。
这棵树就是下面“插入”例题最后得到的树:根 [75],第二层 [49]、[139],第三层 [36]、[53]、[101]、[145]。
- 查 101:读根 [75](第 1 次读盘),101 > 75 ⇒ 走 75 右边的指针;读 [139](第 2 次),101 < 139 ⇒ 走左边的指针;读 [101](第 3 次),相等,成功。读盘 3 次,关键字比较 3 次。
- 查 60:[75] 中 60 < 75 ⇒ 左;[49] 中 60 > 49 ⇒ 右;[53] 中 60 > 53 ⇒ 走 53 右边的指针,是空指针(叶结点)⇒ 失败。同样读了 3 个结点。
- 为什么叶结点(失败结点)有 n + 1 个:n 个关键字把数轴切成 n + 1 段——比最小的还小、相邻两个关键字之间、比最大的还大。每一段对应恰好一个失败结点。这棵树 7 个关键字,最底层 4 个结点共有 8 个空指针位置,正好 7 + 1。
衡量 B 树查找的代价,真正要数的是读盘次数 = 走过的层数 ≤ 树高 h。结点内的比较在内存中进行,结点很大时可以改用折半,但对总时间影响很小——这和二叉树“数比较次数”的眼光不同,做题时看清题目问的是哪一个。
插入:插在最底层的非叶结点,满了就分裂
- 先查找,确定插入位置:一定是最底层的某个非叶结点(终端结点)。
- 插入后若该结点关键字数 ≤ m − 1,结束。
- 若关键字数变成 m(上溢):从中间位置 ⌈m/2⌉ 分裂——左边 ⌈m/2⌉ − 1 个关键字留在原结点,右边的放到新结点,第 ⌈m/2⌉ 个关键字上移到父结点。父结点也可能因此上溢,继续分裂;根分裂会让树长高一层(B 树只会从根长高)。
- m 为偶数时怎么分:上溢的结点有 m 个关键字,第 ⌈m/2⌉ 个上移,左边留 ⌈m/2⌉ − 1 个,右边分到 m − ⌈m/2⌉ 个(m 为偶数时右边比左边多 1 个)。例:4 阶 B 树依次插入 10, 20, 30, 40,插 40 后 [10 20 30 40] 有 4 个关键字(上限 3),第 ⌈4/2⌉ = 2 个即 20 上移,得根 [20]、孩子 [10] 和 [30 40]。两边都不少于下限 ⌈4/2⌉ − 1 = 1,分裂出来的结点一定合法。
- 结点数和树高怎么变:非根结点分裂一次,结点数 +1;根分裂一次,结点数 +2(多一个兄弟、多一个新根),树高 +1。接着上例再依次插入 50, 60, …, 100:插 60、80 时叶子分裂,插 100 时叶子分裂又引起根分裂,10 个关键字插完一共分裂 5 次,结点数 1 → 3 → 4 → 5 → 8,树高 1 → 2 → 3,最后是 [40]{[20]{[10] [30]} [60 80]{[50] [70] [90 100]}}。
4 阶的每一步由参照程序执行核对。顺带可以看到:按递增顺序插入时,每次分裂后左边的结点都只剩最少的关键字,B 树会比较“空”——这和 BST 怕有序输入是同一类现象,只是 B 树仍然保持平衡。
展开解析
- 53、75:[53, 75]。139:[53, 75, 139] 上溢,中间 75 上移:根 [75],孩子 [53]、[139]。
- 49:[49, 53]。145:[139, 145]。
- 36:[36, 49, 53] 上溢,49 上移:根 [49, 75],孩子 [36]、[53]、[139, 145]。
- 101:[101, 139, 145] 上溢,139 上移到根:根 [49, 75, 139] 也上溢,75 上移成为新根。
删除
- 被删关键字 k 在非终端结点:用它的直接前驱(左子树中最大的)或直接后继(右子树中最小的)替代它,转化为删除终端结点中的关键字。所以 B 树的删除最终一定落在终端结点上(2023-07 Ⅱ)。
- k 在终端结点,删除后关键字数仍 ≥ ⌈m/2⌉ − 1:直接删。
- 删除后不足(下溢),且相邻兄弟有多余的关键字(> ⌈m/2⌉ − 1):向兄弟“借”——父结点中夹在两者之间的关键字下移到本结点,兄弟中最靠近的关键字上移到父结点(2012-09)。
- 下溢且兄弟也没有多余:和兄弟合并,连同父结点中夹在两者之间的关键字一起并成一个结点。父结点少了一个关键字,可能继续下溢,向上处理;根的关键字减到 0 时根被删除,树高减 1。
- 要删的关键字在终端结点(最底层非叶结点)里吗?不在 ⇒ 先用前驱(或后继)顶替,问题转到终端结点。
- 删掉后这个结点还剩 ≥ ⌈m/2⌉ − 1 个关键字吗?够 ⇒ 结束。
- 不够:相邻兄弟有多余的关键字吗?有 ⇒ 借(父结点里夹在中间的关键字下来,兄弟最靠近的那个关键字上去),结束。“相邻”指同一个父结点下紧挨着的兄弟,隔着一个的兄弟和堂兄弟都不算。
- 兄弟也没有多余 ⇒ 与一个相邻兄弟合并(连同父结点中夹在两者之间的那个关键字),然后回到第 2 步检查父结点。合并可能一路传到根;根被并空时删掉根,树高 −1。
合并是插入时“分裂”的逆过程:分裂让树从根长高,合并让树从根变矮,所以 B 树的所有叶结点始终在同一层。左右兄弟都能借时,题目一般会说明借哪边;408 的选择题通常把数据设计成只有一种可能。
- A. 60
- B. 60, 62
- C. 62, 65
- D. 65
展开解析
D3 阶 B 树每个非根结点至少 ⌈3/2⌉ − 1 = 1 个关键字。
- 78 所在结点只有 78 一个关键字,删除后为空,下溢。
- 左兄弟 [60, 62] 有 2 个关键字,可以借:父结点中夹在两者之间的 65 下移到右边结点,左兄弟中最大的 62 上移到父结点。
- 结果:父结点 [55, 62],孩子 [47]、[60]、[65]。最右叶结点中是 65。
- A 错60 留在中间结点。
- B 错左兄弟 [60, 62] 有富余,应当借出一个关键字,不会原样成为最右叶结点;若把 65 并进去会有 3 个关键字,超过 3 阶 B 树每个结点最多 2 个的上限。
- C 错62 上移到了父结点。
- D 对65。
B 树删除下溢:兄弟富余就“借”(父下移、兄上移),兄弟也不富余才“合并”(连同父中的分隔关键字)。
- m 阶 B 树的 m 指子树数上限,关键字上限是 m − 1;非根结点关键字下限是 ⌈m/2⌉ − 1,不是 ⌈m/2⌉。
- B 树的高度一般不含失败结点那一层;失败结点(叶结点)个数 = 关键字总数 + 1。
- 插入一定在最底层的非叶结点进行;删除最终也一定落在最底层的非叶结点上。
- “借”时是父结点的关键字下来、兄弟的关键字上去,不是兄弟的关键字直接过来(否则破坏有序性)。
- 高度为 3 的 5 阶 B 树最多、最少各有多少个关键字?关键字最多时有几个结点?
答案
最多 5³ − 1 = 124;最少 2 × 3² − 1 = 17。关键字最多时每个结点都装满 4 个、有 5 棵子树:1 + 5 + 25 = 31 个结点(31 × 4 = 124 ✓)。 - 3 阶 B 树 [75]{[49]{[36] [53]} [139]{[101] [145]}} 中插入 60,树会长高吗?
答案
60 落到叶子 [53],变成 [53 60],3 阶允许 2 个关键字 ⇒ 不分裂,不长高。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 9
- B. 11
- C. 15
- D. 17
展开解析
D5 阶 B 树:根至少 1 个关键字(2 棵子树),其余结点至少 ⌈5/2⌉ − 1 = 2 个关键字(3 棵子树)。第 1 层 1 个结点 1 个关键字;第 2 层 2 个结点共 4 个;第 3 层 2 × 3 = 6 个结点共 12 个。合计 1 + 4 + 12 = 17。公式:2⌈m/2⌉h−1 − 1 = 2 × 32 − 1 = 17。
变化点:原题 3 阶、高度 5:每个非根结点至少 1 个关键字,25 − 1 = 31。根特殊:只要求至少 1 个关键字,这是“最少”问题的关键。
- A. 40
- B. 30, 60
- C. 20, 50
- D. 20, 40, 60
展开解析
D4 阶 B 树每个结点最多 3 个关键字,插入后达到 4 个就分裂,第 ⌈4/2⌉ = 2 个关键字上移。插 40:[10 20 30 40] 分裂,20 上移,根 [20],叶 [10] [30 40]。插 60:[30 40 50 60] 分裂,40 上移,根 [20 40]。插 80:[50 60 70 80] 分裂,60 上移,根 [20 40 60],叶 [10] [30] [50] [70 80]。
变化点:升序插入时每次都在最右叶结点分裂,左边留下“半空”的结点,空间利用率低。本书 B 树小工具可以把阶数改成 4 核对。
- A. 3
- B. 4
- C. 5
- D. 6
展开解析
D5 阶 B 树非根结点至少 2 个关键字。高度 3 至少要 17 个关键字(见本组第 1 题),15 个关键字只能是高度 2(高度 1 最多 4 个)。高度 2 时根有 r 个关键字、r + 1 个孩子,孩子共 15 − r 个关键字且每个至少 2 个:r + 1 ≤ (15 − r)/2,得 r ≤ 4。取 r = 4:5 个孩子共 11 个关键字(2, 2, 2, 2, 3),结点数 1 + 5 = 6。
变化点:原题 4 阶 B 树非根结点只要 1 个关键字,每个结点放 1 个就有 15 个结点。阶数变大,下限变大,结点数上限就小了。求“最多结点”就让每个结点的关键字尽量少,但要检查树形是否合法。
- A. 10
- B. 11
- C. 12
- D. 13
展开解析
C3 阶 B 树每个结点 1 ~ 2 个关键字。根最多 2 个关键字,所以第 2 层最多 3 个结点;5 个关键字放进 3 个结点:2, 2, 1。第 3 层的结点数 = 第 2 层的子树数 = 3 + 3 + 2 = 8。总数 1 + 3 + 8 = 12。
变化点:原题第 2 层 4 个关键字:分成 2, 1, 1,第 3 层 3 + 2 + 2 = 7,共 11。关键:第 2 层结点数受根的关键字数限制;每个结点有 k 个关键字就有 k + 1 个孩子。
- A. 70
- B. 80
- C. 80, 90
- D. 70, 80
展开解析
D删除 90 后 [90] 所在结点为空,低于下限(1 个)。它的左兄弟 [70] 只有 1 个关键字,借不出来,只能合并:把 [70]、父结点中的分隔关键字 80 合成 [70, 80],父结点变为 [60](仍有 1 个关键字,满足要求)。最右叶结点为 [70, 80]。
变化点:删除时先看兄弟能不能借(兄弟关键字数 > 下限):能借就“父下来、兄上去”;不能借就与兄弟和父结点的分隔关键字合并,父结点少一个关键字,可能继续向上调整。
7.8B+ 树
B+ 树是 B 树的变形,是数据库索引、文件系统中最常用的结构。m 阶 B+ 树满足:
- 每个分支结点最多 m 棵子树;非根分支结点至少 ⌈m/2⌉ 棵子树;根至少 2 棵子树(除非它同时是叶子)。
- 结点的子树个数与关键字个数相等(B 树是关键字数 + 1)。
- 所有关键字(和指向记录的指针)都出现在叶结点中;叶结点按关键字大小顺序链接起来。
- 分支结点只是索引:每个索引项是某个子树中的最大(或最小)关键字和指向该子树的指针,不含指向记录的指针。
范围查询为什么快
- 先像查找 20 一样从根往下走:根 [50 96] 中 20 ≤ 50 ⇒ 第一棵子树;[15 50] 中 15 < 20 ≤ 50 ⇒ 第二个叶子 [20 50]。读了 3 个结点(3 次读盘)。
- 从这个叶子开始沿叶子链表往右走:20、50;下一个叶子 56、62;再下一个叶子的 71 > 62,停。
- 结果 20, 50, 56, 62,一共读了 3 + 1 = 4 个结点。若用 B 树,同样的范围要在多个分支结点和叶子之间上上下下做中序遍历。
为什么数据库几乎都用 B+ 树:① 分支结点只放“关键字 + 子树指针”,不放记录指针,同样 4 KB 的磁盘块能放更多索引项,树更矮;② 所有查找都走到叶子,每次查找读盘次数相同,性能稳定;③ 叶子链表让范围查询、排序输出(ORDER BY、BETWEEN)只需顺着链表读。
| m 阶 B 树 | m 阶 B+ 树 | |
|---|---|---|
| n 个关键字的结点有几棵子树 | n + 1 | n |
| 非根结点关键字数范围 | ⌈m/2⌉ − 1 ≤ n ≤ m − 1 | ⌈m/2⌉ ≤ n ≤ m |
| 关键字出现的位置 | 每个关键字只出现一次,可以在任何结点(分支结点也含记录信息) | 全部出现在叶结点;分支结点中的关键字在叶结点中重复出现 |
| 查找 | 可能在分支结点就成功 | 无论成功与否都要走到叶结点(从根开始的随机查找) |
| 顺序查找 | 不支持(要中序遍历) | 支持:从叶结点链表头依次访问(2016-10) |
| 头指针 | 一个(根) | 两个:根、最左叶结点(sqt) |
| 应用 | 文件索引 | 数据库索引(2017-09)、文件系统 |
拓展 B+ 树的插入和删除与 B 树思路相同,都只在叶结点动手:插入后叶结点关键字超过 m 个就一分为二,父结点里相应地多出一个索引项(父结点满了再往上分裂);删除后叶结点不够 ⌈m/2⌉ 个,就向相邻兄弟借或与它合并,并修改父结点中对应的索引关键字。与 B 树最大的不同在分裂:B 树是把中间的关键字“搬”上去(叶子里不再有它),B+ 树是把索引关键字“复制”上去(叶子里仍保留一份,因为所有关键字都必须出现在叶子里)。408 目前只考 B+ 树的概念和它与 B 树的区别。
跟我做一遍:3 层 B+ 树能索引多少条记录
- B+ 树的分支结点只放“关键字 + 子树指针”,每项 16 字节,一块能放 4096/16 = 256 项,即 256 路分叉。叶结点放“关键字 + 记录指针”,还要留 8 字节给指向右兄弟的链表指针:(4096 − 8)/16 取整 = 255 项。3 层最多 256 × 256 × 255 ≈ 1671 万条记录。
- B 树的每个结点都要放“关键字 + 记录指针 + 子树指针”:k 个关键字需要 8k + 8k + 8(k + 1) ≤ 4096 字节,k 最多 170,也就是 171 路分叉。3 层最多 171³ − 1 ≈ 500 万个关键字。
- 同样读 3 次盘,B+ 树能管的记录是 B 树的 3 倍多。这就是“分支结点不存记录指针,树更矮”的定量含义;再加上叶子链表让范围查询只需顺着链表读,数据库索引(如 MySQL 的 InnoDB)几乎都选 B+ 树。
数字按题设计算并由程序核对;真实数据库的结点里还有页头等管理信息,容量略小,但量级不变。估算题的套路:每块能放几项 = 块大小 ÷ 每项大小,h 层最多 ≈ 分叉数的 h 次方。
- m 阶 B+ 树和 m 阶 B 树中,一个有 k 个关键字的分支结点各有几棵子树?
答案
B+ 树 k 棵;B 树 k + 1 棵。 - B+ 树中查找成功时是否可能在分支结点就结束?
答案
不会(随机查找时):分支结点里的关键字只是索引,记录指针都在叶子中,必须走到叶子才算找到。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 叶结点包含全部关键字
- B. 非叶结点中的关键字也会在叶结点中出现
- C. 查找任何关键字(无论成功与否)都要走到叶结点
- D. 结点中的关键字个数比子树个数少 1
展开解析
DB+ 树中结点的关键字个数等于子树个数(每个关键字对应一棵子树,是该子树中的最大关键字);“关键字个数 = 子树个数 − 1”是 B 树的特点。A、B、C 都是 B+ 树的特点。
变化点:原题问“不同于 B 树的特点”(支持顺序查找)。B 树与 B+ 树的四个区别:关键字与子树个数的关系、信息存放位置、叶结点是否包含全部关键字并链接、查找是否必须到叶子。
- A. 范围查询(如查找关键字在 [100, 200] 之间的所有记录)
- B. 查找某一个关键字时一定比 B 树快
- C. 需要存储的关键字总数更少
- D. 插入时不会发生结点分裂
展开解析
AB+ 树的叶结点按关键字顺序链接成链表,找到 100 后沿叶链顺序扫描到 200 即可。B:B 树有可能在非叶结点就查到,不一定更慢;C:B+ 树非叶结点的关键字在叶中重复出现,存得更多;D:B+ 树插入同样会分裂。
变化点:这正是数据库索引、文件系统多用 B+ 树的原因(原题答案):既支持随机查找,又支持顺序(范围)查找。
- A. 128
- B. 85
- C. 64
- D. 256
展开解析
AB+ 树分支结点中关键字个数等于子树个数,每项“关键字 + 子树指针”共 16 字节:2048 ÷ 16 = 128。
变化点:B:若是 B 树,每个结点还要放记录指针,k 个关键字占 8k + 8k + 8(k + 1) ≤ 2048,k 最多 85——这正是 B+ 树“更矮”的原因。D:忘了每项有两部分。估算方法见 7.8 的跟做。
7.9散列表(哈希表)
前面所有查找都靠“比较”,比较次数至少是 log 级。散列换了思路:用一个函数直接由关键字算出它的存储地址 Addr = H(key),理想情况下查找只要 O(1)。
| 术语 | 含义 |
|---|---|
| 散列函数 H | 把关键字映射成地址的函数 |
| 冲突 | 不同的关键字被映射到同一地址:key₁ ≠ key₂ 但 H(key₁) = H(key₂),这些关键字互称同义词 |
| 散列表 | 根据关键字直接访问的数据结构,建立了关键字和存储地址之间的直接映射 |
| 装填因子 α | α = 表中记录数 n ÷ 散列表长度 m。α 越大,表越满,越容易冲突 |
冲突能不能完全避免?一般不能。学号有 10 位,可能的关键字有上百亿个,而表只有几百个单元——“鸽子比笼子多”,必然有两只鸽子挤进同一个笼子(鸽巢原理)。所以用散列要做两件事:一是选一个让地址尽量均匀的散列函数,让冲突少;二是定好冲突以后怎么办(冲突处理方法)。只有关键字集合事先完全已知、而且表足够大时,才可能设计出完全不冲突的函数,直接定址法就是这种情形。好的散列函数还要计算简单——算地址本身太慢,就失去了散列 O(1) 的意义。
散列函数的构造
| 方法 | 做法 | 特点 |
|---|---|---|
| 直接定址法 | H(key) = a·key + b | 不会冲突;关键字分布不连续时浪费空间 |
| 除留余数法 | H(key) = key % p,p 取不大于表长 m 且最接近 m 的质数 | 最常用,考试几乎都用它;取质数是为了让地址分布更均匀 |
| 数字分析法 | 选取关键字中分布均匀的若干位作为地址 | 适用于已知的关键字集合(如手机号后几位) |
| 平方取中法 | 取关键字平方值的中间几位 | 中间几位与每一位都有关,分布较均匀 |
四种构造方法各举一例
| 方法 | 例子 |
|---|---|
| 直接定址 | 统计 1950—2025 年出生人口,H(年份) = 年份 − 1950,地址 0 ~ 75,一个年份一个格,绝不冲突。 |
| 除留余数 | H(key) = key % 13:28 → 2,41 → 2(冲突,同义词)。 |
| 数字分析 | 某班学号 2026040101 ~ 2026040160,前 8 位都一样,只有最后 2 位分布均匀 ⇒ 取最后 2 位当地址。 |
| 平方取中 | key = 1234,1234² = 1522756,取中间 3 位 227 作地址;中间几位受每一位的影响,分布较均匀。 |
p 为什么取质数:若关键字有某种规律(比如都是 10 的倍数),取模的 p 与这个规律有公因数时,地址会挤在少数几个位置上。关键字 10, 20, 30, 40, 50, 60:模 10 全部落在 0;模质数 7 依次是 3, 6, 2, 5, 1, 4,一个都不冲突。(程序计算。)
处理冲突
开放定址法:冲突时在表中找下一个空位置。第 i 次冲突时探测的地址 Hᵢ = (H(key) + dᵢ) % m,按增量序列 dᵢ 分为:
| 方法 | 增量 dᵢ | 特点 |
|---|---|---|
| 线性探测法 | 1, 2, 3, …, m − 1 | 冲突了就看下一个单元。只要表不满,一定能找到空位(2025-09 A)。缺点:容易造成堆积(聚集)——本不是同义词的关键字也挤在一起争抢地址,大大增加 ASL(2014-08) |
| 平方探测法 | 1², −1², 2², −2², …(有的题目只用 +k²,如 2024-42) | 能减少堆积,但不能探测到表中所有单元(至少能探测一半;表长为 4k + 3 形式的质数时能探到全部)(2025-09 B 错) |
| 双散列法 | dᵢ = i × H₂(key) | 用第二个散列函数决定步长 |
| 伪随机序列法 | dᵢ = 伪随机数 | — |
拉链法(链地址法):把所有同义词存在一个单链表中,散列地址为 i 的同义词链表的头指针存在表的第 i 个单元。不会堆积(只有同义词才会在一条链上),适合经常插入删除的情形。
若直接把元素删掉(置空),后面经过它才找到的同义词就会被“截断”——查找时遇到空位就判定失败。所以只能做删除标记(逻辑删除):查找时把它当作“有元素但不匹配”继续探测,插入时可以重新利用它(2023-09)。多次删除后表里会有很多“假占用”,需要定期整理。
平方探测为什么要求表长是 4k + 3 形式的质数
线性探测的毛病是“挤成一团”。平方探测让探测点越跳越远(+1、−1、+4、−4、+9……),同义词不会在初始地址旁边排成长队。但跳着走有个代价:有些单元可能永远跳不到。
- 表长 13 出了什么问题:5² = 25 = 2 × 13 − 1,所以模 13 时 −1 ≡ 5²,于是 −k² ≡ (5k)²——“减 k²”落到的位置,“加某个数的平方”早就落到过了。+k² 只能落在 6 个位置(加上 0 共 7 个),−k² 也是这 7 个。若这 7 个都满了,表里明明还有 6 个空位,插入也会失败。
- 表长 11 为什么行:模 11 时找不到哪个数的平方等于 −1(即 10),+k² 落到的 {1, 3, 4, 5, 9} 和 −k² 落到的 {10, 8, 7, 6, 2} 完全不重叠,加上 0 正好 11 个。凡是 4k + 3 形式的质数(7、11、19、23、31、43……)都这样;4k + 1 形式的质数(5、13、17、29、37……)都只能覆盖 (m + 1)/2 个。两类各取 5 ~ 6 个,程序逐个验证过。
- 结论记两句:平方探测不能保证探测到表中所有单元(2025-09 B 错),但至少能探测到一半;表长是 4k + 3 的质数时能探测到全部。只用 +k² 的版本(2024-42)覆盖得更少,题目这样规定时就照题目算。
同一组关键字,四种冲突处理放在一起比
先算初始地址:22、33、44 → 0;13、24 → 2;7、18 → 7。三组同义词。
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ASL 成功 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 线性探测 | 22 | 33 | 44 | 13 | 24 | 7 | 18 | (1+2+3+2+3+1+2)/7 = 2 | ||||
| 平方探测 ±k² | 22 | 33 | 13 | 24 | 7 | 18 | 44 | (1+2+3+1+2+1+2)/7 ≈ 1.71 |
- 线性探测:44 要试 0、1、2 才落位;接着 13 的初始地址 2 已被 44 占了——13 和 44 根本不是同义词,却也冲突了,这就是堆积(一次聚集):一团连续占用的地址会越滚越大。
- 平方探测:44 试 0、0 + 1² = 1、0 − 1² = −1 ≡ 10,跳到了远处,没有压住 2 号单元,于是 13 一次就放下。代价是:探测序列跳着走,不一定能探测到表中所有单元(表长为 4k + 3 形式的质数时可以,11 = 4 × 2 + 3 正好满足)。
- 双散列:步长由第二个函数决定,如 H₂(key) = 1 + key % 10:33 第一次冲突后跳 4 格到 4 号。不同关键字步长不同,进一步减少聚集。
- 拉链法:0 号链 22 → 33 → 44,2 号链 13 → 24,7 号链 7 → 18。ASL 成功 = (1 + 2 + 3 + 1 + 2 + 1 + 2)/7 ≈ 1.71;非同义词永远不会互相干扰。
线性探测的 ASL 失败(分母为 p = 11):从 0 ~ 10 号出发直到遇到空位的比较次数依次为 6、5、4、3、2、1、1、3、2、1、1,和 29,ASL失败 = 29/11 ≈ 2.64。(四种方法的放置结果与 ASL 都由程序模拟核对。)
用上面“关键字 % 13、表长 16”的例子:27 的初始地址是 1,当时 1、2、3 都被占,它被放在 4 号。现在删掉 2 号的 01:
- 真删除(2 号置空):再查 27,从 1 号开始比,1 号是 14 不等,到 2 号发现空位 ⇒ 判定“失败”,可 27 明明在 4 号。
- 做删除标记:2 号标成“已删除”,查找时把它当成“有东西但不相等”,继续比 3 号、4 号,第 4 次比较找到 27 ✓;插入新元素时可以把它当空位用。
写成代码:线性探测散列表(含删除标记)
#define M 13 #define EMPTY -1 // 从未用过 #define DELETED -2 // 删除标记 int HT[M]; // 初始化时全部置 EMPTY int H(int key) { return key % M; } int Search(int key) { // 找到返回下标,失败返回 -1 for (int i = 0; i < M; i++) { int a = (H(key) + i) % M; if (HT[a] == EMPTY) return -1; // 遇到“从未用过”的空位:失败 if (HT[a] == key) return a; // DELETED 不等于任何关键字,会继续往后探测 } return -1; // 转了一圈也没有 } bool Insert(int key) { if (Search(key) >= 0) return false; // 已存在 for (int i = 0; i < M; i++) { int a = (H(key) + i) % M; if (HT[a] == EMPTY || HT[a] == DELETED) { // 空位或删除标记都可以放 HT[a] = key; return true; } } return false; // 表满 } bool Delete(int key) { int a = Search(key); if (a < 0) return false; HT[a] = DELETED; // 只做标记,不能置成 EMPTY return true; }
三个细节:① 查找遇到 EMPTY 才停,遇到 DELETED 要继续;② 插入前先查重,否则同一个关键字可能在“删除标记”处被插第二次;③ 删除只改标记。代码已编译,随机执行 120 万次插入 / 删除 / 查找,与集合模型比较全部一致。
散列表的查找与 ASL
查找时按与插入相同的探测序列逐个比较:遇到关键字相等则成功;遇到空位置则失败。
失败 ASL 的分母:两种口径与真题用法
| 口径 | 分母 | 上面 key % 13、表长 16 的例子 |
|---|---|---|
| 王道 / 408 真题 | 散列函数可能取到的初始地址个数(除留余数法就是 p) | 91/13 = 7 |
| 部分教材 | 表长 m(从每个单元出发都算一次) | (91 + 1 + 1 + 1)/16 = 94/16 ≈ 5.88 |
道理:查找失败只可能从 H(key) 算出的地址开始,13 ~ 15 号单元永远不会是初始地址,算进去没有意义。2010-41(H(key) = key × 3 mod 7,表长 10)官方答案的分母是 7,就是第一种口径。拉链法的失败 ASL,408 一般只数“与链中关键字的比较次数”(空链比较 0 次),分母同样是 p。
拓展 装填因子 α 与 ASL 的近似关系(表长足够大时):
| α | 线性探测 成功 ½(1 + 1/(1 − α)) | 线性探测 失败 ½(1 + 1/(1 − α)²) | 拉链法 成功 1 + α/2 |
|---|---|---|---|
| 0.5 | 1.5 | 2.5 | 1.25 |
| 0.75 | 2.5 | 8.5 | 1.375 |
| 0.9 | 5.5 | 50.5 | 1.45 |
可见:ASL 只和 α 有关、与 n 无关;线性探测在 α 接近 1 时急剧变差,所以开放定址法通常把 α 控制在 0.75 以下(2011-09、2022-09 的结论)。
为什么失败比成功涨得快:查找失败要一直走到空位才能停下,α 越大,空位越少、越稀疏,堆积形成的“长队”会让一次失败查找把整团走完;成功查找只需走到目标为止,平均只走半团。程序在 2 万个单元的表中实测:α = 0.5 时线性探测成功 ASL ≈ 1.51、α = 0.75 时 ≈ 2.46,与公式的 1.5、2.5 吻合。拉链法不怕表满,α 甚至可以超过 1(链表可以任意长),这也是它适合“插入删除频繁、个数难预估”的原因。
展开解析
① 依次插入(括号内为探测次数):19→6(1),14→1(1),23→10(1),01→1 冲突→2(2),68→3(1),20→7(1),84→6、7 冲突→8(3),27→1、2、3 冲突→4(4),55→3、4 冲突→5(3),11→11(1),10→10、11 冲突→12(3),79→1 ~ 8 冲突→9(9)。
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 14 | 01 | 68 | 27 | 55 | 19 | 20 | 84 | 79 | 23 | 11 | 10 | ||||
| 比较次数 | 1 | 2 | 1 | 4 | 3 | 1 | 1 | 3 | 9 | 1 | 1 | 3 |
ASL成功 = (1 + 2 + 1 + 4 + 3 + 1 + 1 + 3 + 9 + 1 + 1 + 3)/12 = 30/12 = 2.5。
ASL失败:初始地址只可能是 0 ~ 12(13 种)。从地址 0 出发:0 就是空,比较 1 次;从 1 出发要比到 13 号空位:13 次;从 2 出发 12 次……从 12 出发 2 次。合计 1 + 13 + 12 + … + 2 = 91,ASL失败 = 91/13 = 7。
② 拉链法(新结点接在链尾):地址 1:14 → 01 → 27 → 79;3:68 → 55;6:19 → 84;7:20;10:23 → 10;11:11。ASL成功 = (1×6 + 2×4 + 3×1 + 4×1)/12 = 21/12 = 1.75。拉链法明显更好——没有堆积。
拉链法的失败 ASL:两种数法
各地址的链长:1 号 4 个(14 → 01 → 27 → 79),3、6、10 号各 2 个,7、11 号各 1 个,其余 7 个地址是空链。查一个不存在的关键字时,从它的地址出发把那条链比完才知道失败。
| 数法 | 从地址 i 出发,失败要比几次 | ASL 失败 |
|---|---|---|
| 只数与关键字的比较(王道口径,常用) | = 链长:空链 0 次,1 号链 4 次…… | (4 + 2 × 3 + 1 × 2)/13 = 12/13 ≈ 0.92 |
| 把“发现指针为空”也算 1 次 | = 链长 + 1 | (12 + 13)/13 = 25/13 ≈ 1.92 |
两种数法的分母都是 13(散列函数可能取到的地址个数),差别只在分子要不要给每条链加 1。做题先看题干有没有说明“空指针是否计入比较”,没说明时按第一种。成功 ASL 不受影响:(1 × 6 + 2 × 4 + 3 + 4)/12 = 1.75。
链长与两种 ASL 由程序统计核对。顺便对比开放定址法:线性探测时失败 ASL 是 7(上面例题),拉链法不到 1——拉链法的失败查找只和同义词比较,不会被别的关键字“堵路”。
散列表的 ASL 取决于三件事:散列函数(是否均匀)、处理冲突的方法(是否会堆积)、装填因子 α。ASL 不直接依赖于 n——只要 α 不变,表多大 ASL 都差不多,这正是散列表 O(1) 的原因。提高效率:减小 α(表开大一点)、设计冲突少的散列函数、避免堆积。
跟我做一遍:从“装填因子”出发设计一张散列表
- 定表长:α = n/m ≤ 0.75 ⇒ m ≥ 8/0.75 ≈ 10.67,表长只能是整数,取 m = 11。
- 选 p:取不大于表长的最大质数,11 本身是质数 ⇒ H(key) = key % 11。
- 建表(括号里是放进去时比较了几次):15 → 4(1);28 → 6(1);4 → 4 冲突 → 5(2);39 → 6 冲突 → 7(2);20 → 9(1);11 → 0(1);33 → 0 冲突 → 1(2);6 → 6、7 冲突 → 8(3)。
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 11 | 33 | 15 | 4 | 28 | 39 | 6 | 20 | |||
| 查找成功比较次数 | 1 | 2 | 1 | 2 | 1 | 2 | 3 | 1 | |||
| 从此地址出发查找失败 | 3 | 2 | 1 | 1 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
- ASL 成功 = (1 + 1 + 2 + 2 + 1 + 1 + 2 + 3)/8 = 13/8 ≈ 1.63。
- ASL 失败:从 0 ~ 10 每个初始地址出发,比到空位为止(与空位的那 1 次也算),合计 35;分母取 p = 11 ⇒ 35/11 ≈ 3.18。这里 p = m = 11,两种分母口径恰好一致。从 4 号出发要比 4、5、6、7、8、9、10 共 7 次——4 ~ 9 号已经连成一团。
- 对照:若图省事把表长定为 10、p 取 7(α = 0.8),同一组关键字的成功 ASL 变成 18/8 = 2.25。表开得小一点,平均每次查找就要多比 0.6 次。
建表、两种 ASL 与对照数据由程序模拟核对。2010-41 就是这个流程(由装填因子 0.7 定表长,再建表、算两种 ASL);2024-42 反过来,给出表长让你算装填因子。
真题怎么考
- 建表 + ASL(2010-41、2024-42、2019-08、2023-09、2018-09):画出表格、写出每个关键字的探测次数,失败 ASL 的分母用 p。
- 概念:堆积(2014-08)、影响 ASL 的因素(2011-09、2022-09)、平方探测能否探测全表(2025-09)、删除要做标记(2023-09)。
- 表长 m 和模数 p:题目给装填因子时 m = ⌈n/α⌉;p 没给时取不大于 m 的最大质数。m 比 p 大时,地址 p ~ m − 1 只能靠探测到达,它们不会是任何关键字的初始地址。
- 探测公式:线性探测按 (H(key) + i) % m 往后找;平方探测是 ±k² 还是只有 +k²,题目怎么写就怎么算(2024-42 只用 +k²)。
- 失败 ASL 的数法:分母取 p(初始地址个数),与空位比较的那 1 次要算;拉链法默认不数空指针。答卷时把“每个地址出发比较几次”列成一行表,阅卷可以按表给步骤分,算错一个数也只扣一点。
- 线性探测造成的是堆积(一次聚集);有的资料把它叫“二次聚集”是错的,“二次聚集”一般指平方探测下同义词的聚集。
- 失败 ASL 要把“和空位置比较的那一次”也算上(408 官方答案如此)。
- 平方探测的增量是 1², −1², 2², −2², …;若题目只给 +k²(2024-42),按题目来。
- 关键字 5, 16, 27, 38 用 H(key) = key % 11、线性探测放入表长 11 的散列表,ASL 成功是多少?
答案
四个都是 5 的同义词:5 → 5(1),16 → 5、6 → 6(2),27 → 7(3),38 → 8(4)。ASL = (1 + 2 + 3 + 4)/4 = 2.5。 - 为什么说散列表的 ASL “与表中元素个数 n 无关”?
答案
ASL 由装填因子 α = n/m、散列函数和冲突处理方法决定;n 增大时只要把表长 m 按比例增大、保持 α 不变,ASL 基本不变。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2
- B. 2.5
- C. 3
- D. 1.75
展开解析
B四个关键字的散列地址都是 1。15 → 1(1 次);22 → 1 冲突 → 2(2 次);8 → 1、2 冲突 → 3(3 次);29 → 4(4 次)。ASL = (1 + 2 + 3 + 4) ÷ 4 = 2.5。
变化点:同义词越多,堆积越严重,ASL 越大。原题 3 个同义词:(1 + 2 + 3) ÷ 3 = 2。
- A. 2.8
- B. 3
- C. 4
- D. 4.5
展开解析
C建表:23 → 2;14 → 0;9 → 2 冲突 → 3;6 → 6;30 → 2、3 冲突 → 4;12 → 5;18 → 4、5、6 冲突 → 7。表:0:14,1:空,2:23,3:9,4:30,5:12,6:6,7:18,8、9:空。
查找失败时,从 H(key) 开始比较,直到遇到空位(空位也算一次比较)。H 只可能取 0 ~ 6:地址 0 比 2 次(0、1),地址 1 比 1 次,地址 2 比 7 次(2 ~ 8),地址 3 比 6 次,4 比 5 次,5 比 4 次,6 比 3 次。ASL失败 = (2 + 1 + 7 + 6 + 5 + 4 + 3) ÷ 7 = 28 ÷ 7 = 4。
变化点:分母是散列函数可能取到的地址个数 7(不是表长 10),A 就是错用了表长。本书 散列表小工具按同样的约定计算。
- A. 1
- B. 2
- C. 3
- D. 查找失败
展开解析
C三个关键字的散列地址都是 3,依次存在 3、4、5 号单元。删除 10 只是在 4 号做标记。查 17:比较 3 号(3 ≠ 17)、4 号(已删除,继续往后)、5 号(命中),共 3 次。
变化点:若删除时把 4 号直接置空,查 17 到 4 号就会以为“遇到空位、查找失败”(D)——这就是开放定址法不能真正删除、只能做标记的原因。
7.10本篇一页总结与必会题型
| 板块 | 必背结论 | 下手方法 |
|---|---|---|
| ASL | 成功按元素平均,失败按“失败情形”平均;只数关键字比较次数 | 先数清有几种失败情形 |
| 顺序 / 折半 | 顺序:成功 (n + 1)/2,失败 n + 1(有序表 n/2 + n/(n + 1));折半:只能用于有序顺序表,判定树高 ⌈log₂(n + 1)⌉ = 最多比较次数,n + 1 个失败结点 | 按区间递归画判定树;序列合法性看区间 |
| 分块 | 块内无序、块间有序;两步顺序查找时 s = √n 最优,ASL = √n + 1 | (b + 1)/2 + (s + 1)/2 |
| BST | 中序递增;插入成叶子;删除三情况(两个孩子用前驱 / 后继顶替);最坏退化为 O(n) | 查找路径判定用区间 |
| AVL | |平衡因子| ≤ 1;LL / RR 单旋,LR / RL 双旋(孙子上位);Nₕ = Nₕ₋₁ + Nₕ₋₂ + 1:1, 2, 4, 7, 12, 20, 33;删除可能连续旋转 | 找最小不平衡结点,沿新结点方向走两步判型 |
| 红黑树 | 根黑、NIL 黑、红不相邻、各路径黑结点数相同;最长 ≤ 2 × 最短,h ≤ 2log₂(n + 1);新结点红,叔红变色、叔黑旋转 | 只看叔叔的颜色 |
| B 树 | m 阶:子树 ⌈m/2⌉ ~ m(根 2 ~ m);关键字最多 mʰ − 1、最少 2⌈m/2⌉ʰ⁻¹ − 1;叶结点数 = 关键字数 + 1;插入上溢分裂(第 ⌈m/2⌉ 个上移),删除下溢先借后并 | 画结点、数关键字,逐层推 |
| B+ 树 | k 个关键字 k 棵子树;所有关键字在叶子、叶子成链;随机查找必到叶;支持顺序与范围查询 | 与 B 树对比表 |
| 散列 | 除留余数 p 取 ≤ m 的最大质数;线性探测会堆积;平方探测表长 4k + 3 质数时可探全表;开放定址删除要做标记;ASL 只与 α、函数、冲突方法有关;失败 ASL 分母取 p,含与空位的一次比较 | 画表、逐个写探测次数 |
本篇必会题型清单
| # | 题型 | 代表真题 | 小节 |
|---|---|---|---|
| 1 | 折半查找最多比较次数、判定树形状、ASL | 2010-09、2017-08、2016-09、2023-08 | 7.2 |
| 2 | 比较序列 / 查找路径是否合法 | 2015-07、2011-07 | 7.4 |
| 3 | 分块查找的最优块长 | 2025-07 | 7.3 |
| 4 | BST 构造、删除、关键字范围 | 2013-06、2018-06、2020-05、2024-07 | 7.4 |
| 5 | AVL 插入旋转、最少结点 | 2010-04、2012-04、2015-04、2021-06、2026-08 | 7.5 |
| 6 | B 树关键字 / 结点 / 高度计算,插入分裂、删除借并 | 2013-10、2014-09、2018-08、2012-09、2021-09、2025-08 | 7.7 |
| 7 | B+ 树与 B 树的区别、应用 | 2016-10、2017-09 | 7.8 |
| 8 | 散列建表与 ASL(成功 / 失败) | 2010-41、2024-42、2019-08、2023-09 | 7.9 |
| 9 | 散列概念:堆积、装填因子、冲突处理特点 | 2014-08、2011-09、2022-09、2025-09 | 7.9 |
| 10 | 大题:几种查找方法的 ASL 比较 | 2013-42 | 9.6 |
- 长度为 100 的有序表折半查找,查找成功最多比较几次?
答案
⌈log₂101⌉ = 7。 - 高度为 3 的 4 阶 B 树最多、最少各有多少个关键字?
答案
最多 4³ − 1 = 63;最少 2 × ⌈4/2⌉² − 1 = 2 × 4 − 1 = 7。 - AVL 树中插入一个结点后发生 LR 旋转,旋转后新的子树根是原来的哪个结点?
答案
是 A 的左孩子 B 的右孩子 C(“孙子”上位)。 - 散列表长 11,H(key) = key % 11,线性探测,依次插入 22, 41, 53, 46, 30, 13, 01, 67,求 ASL 成功。
答案
22→0(1),41→8(1),53→9(1),46→2(1),30→8、9 冲突→10(3),13→2 冲突→3(2),01→1(1),67→1、2、3 冲突→4(4)。ASL = (1+1+1+1+3+2+1+4)/8 = 14/8 = 1.75。
7.✎本篇真题闯关(45 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
排序:九种内部排序、外部排序,以及它们的比较
排序是 408 选择题的“头号考点”:18 年 39 道,几乎每年 2~3 道。题型非常固定:给出某一趟排序后的结果,判断是哪种算法、增量是多少、枢轴是谁;问稳定性、时间空间复杂度、比较和移动次数;问“某种情况下应该选哪种排序”。所以这一篇对每种算法都讲清楚四件事:怎么做(手工模拟每一趟)、代码、性能(时间、空间、稳定性、适用存储)、每一趟结束后序列有什么特征——最后一条是判断“某趟结果”类题目的钥匙。全篇用同一个例子贯穿:49, 38, 65, 97, 76, 13, 27, 49*(49* 表示与 49 相等、但原来排在后面的元素,用来观察稳定性)。
8.0本篇地图:九种内部排序 + 外部排序
排序是 408 选择题的头号考点(39 道),大题也考过 5 次。题型非常固定,主要是四问:某一趟后的结果是什么 / 是哪种算法、稳不稳定、时间空间多少、和初始序列有没有关系、某种场景该选哪种。下表把每种算法的考频和要会的东西列出来。
| 小节 | 学完要会的事 | 真题 | 难度 | 学时 |
|---|---|---|---|---|
| 8.1 概念 | 稳定性怎么判断;内部 / 外部;比较排序的下界 | — | ★ | 0.5 h |
| 8.2 插入 | 逐趟结果;比较、移动次数;折半插入只少比较 | 4 | ★★ | 1 h |
| 8.3 希尔 | 由一趟结果反推增量;不稳定的原因 | 4 | ★★ | 1 h |
| 8.4 冒泡 | 每趟一个元素到位;flag 提前结束 | 1 | ★ | 0.5 h |
| 8.5 快速 | 手工划分;“第二趟后”判定;最坏情况 | 6 | ★★★ | 2 h |
| 8.6 选择、堆 | 建堆、插入、删除、堆排序逐步;Top-k | 7 | ★★★ | 2 h |
| 8.7 归并 | Merge 代码、趟数、空间 O(n) | 3 | ★★ | 1 h |
| 8.8 基数 | 分配收集逐趟、O(d(n + r)) | 4 | ★★ | 1 h |
| 8.9 比较 | 总表、口诀、与初态的关系、选型 | 6 | ★★★ | 1.5 h |
| 8.10 外部排序 | I/O 次数、败者树、置换—选择、最佳归并树 | 4 + 大题 | ★★★ | 2 h |
每种算法都用同一个例子 49, 38, 65, 97, 76, 13, 27, 49* 走一遍,自己在纸上写出每一趟,再和书上的表对照;然后记住它的“每趟特征”(第 i 趟后序列长什么样),这是判断“某趟结果是哪种排序”的唯一钥匙。最后用 8.9 节的总表做横向对比。
8.1排序的基本概念
为什么要排序
排好序的数据才能折半查找、才能用归并合并、才能一眼看出最大最小和重复项;数据库的 ORDER BY、成绩排名、搜索结果排序都是排序。所以排序是“为别的操作做准备”的基础动作,做得快不快直接影响整个系统。
按分数整理一摞试卷,有人一张张插到已整理好的那一叠里(插入),有人每次从剩下的里面挑最高分(选择),有人分成几小摞各自整理再合起来(归并),有人按分数段先分到几个筐里(基数)。哪里不像:人能一眼看出整摞的大概情况,算法每次只能比较两个关键字——所以要数比较次数。
- 排序:把一组元素按关键字重新排成递增(或递减)序列。
- 稳定性:若待排序表中有两个关键字相等的元素 Rᵢ、Rⱼ,排序前 Rᵢ 在 Rⱼ 前面,排序后 Rᵢ 仍在 Rⱼ 前面,则该算法是稳定的,否则不稳定。稳定性只和“相等元素的相对次序”有关,与算法好坏无关;但在多关键字排序(先按次要关键字排、再按主要关键字排,2026-10)中必须用稳定的算法。
- 内部排序:排序期间元素全部在内存中;外部排序:元素太多,排序过程中要在内、外存之间来回移动(8.10 节)。
- 内部排序一般要做两种基本操作:比较两个关键字,移动元素。评价一个算法要看比较次数、移动次数(时间)以及辅助空间。
- 基于比较的排序,最坏情况下至少要比较 ⌈log₂(n!)⌉ 次:n 个元素有 n! 种排列,每次比较最多把可能性减半,所以基于比较的排序算法时间下界是 O(n log n)。基数排序不靠比较,不受这个限制。
| 类别 | 算法 | 核心动作 |
|---|---|---|
| 插入类 | 直接插入、折半插入、希尔 | 把一个元素插到已经有序的部分里 |
| 交换类 | 冒泡、快速 | 比较两个元素,逆序就交换 |
| 选择类 | 简单选择、堆 | 从未排序部分中选出最小(大)的放到最终位置 |
| 归并类 | 二路归并 | 把有序表两两合并 |
| 分配类 | 基数 | 按关键字各位的值分配到桶中再收集,不比较 |
怎么判断稳不稳定:看有没有“跳跃式交换”
考场判定法:先看算法的交换 / 移动是不是“远距离跳跃”的;是就构造一个形如 2, 2*, 1 的小例子试一下,三个数就能看出来。口诀“快些选堆”不稳定(快速、希尔、选择、堆),其余(插入、冒泡、归并、基数)稳定。
“插入、冒泡、归并、基数稳定”指的是按标准写法写出的代码。比较符号写错一个,稳定的算法也会变得不稳定:
| 算法 | 稳定的写法 | 改成这样就不稳定 | 反例 |
|---|---|---|---|
| 直接插入 | A[j] > temp 才后移 | A[j] >= temp 也后移 | 2, 2*, 1 排成 1, 2*, 2 |
| 冒泡 | A[j − 1] > A[j] 才交换 | >= 也交换 | 2, 2* 被换成 2*, 2 |
| 归并 | B[i] <= B[j] 时先取左段 | B[i] < B[j](相等先取右段) | [2] 与 [2*] 合并成 2*, 2 |
反过来,“快些选堆”的不稳定来自方法本身(远距离交换或分组),怎么改等号都救不回来。所以大题里写插入、冒泡、归并的代码时,比较符号要和“稳定”对得上。(三个反例由程序验证。)
逆序对:衡量“乱”的程度
序列中前面的数比后面的数大,这一对就叫一个逆序对。例如 4, 2, 5, 1, 3 中有 (4,2)、(4,1)、(4,3)、(2,1)、(5,1)、(5,3) 共 6 个。有序序列有 0 个,逆序序列有 n(n − 1)/2 个(最多)。
直接插入排序、冒泡排序每做一次“相邻元素后移 / 交换”,正好消灭一个逆序对。所以它们的移动(交换)次数 = 逆序对个数:初始越接近有序,逆序对越少,排得越快——这就是“基本有序时用插入排序”的道理(程序在 2000 个随机序列上验证了插入排序的后移次数恰等于逆序对数)。
- 逐个看每个数“后面有几个比它小”:3 后面有 1、1*、2(3 个);1 后面没有(1* 与它相等,不算);4 后面有 1*、2(2 个);1* 后面没有;5 后面有 2(1 个)。
- 逆序对共 3 + 2 + 1 = 6 个。
- 用直接插入排序检验:每次后移消灭一个逆序对,程序数出的后移次数正好是 6。相等的 1 和 1* 不构成逆序对,插入时 1* 停在 1 后面——这也是直接插入排序稳定的原因。
拓展 比较排序的下界:判定树
为什么比较排序至少要 O(n log n):把排序过程画成“比较判定树”,每个内部结点是一次比较,每个叶子是一种可能的结果(一种排列)。n 个元素有 n! 种排列,所以至少 n! 个叶子;二叉树要有 n! 个叶子,高度至少 ⌈log₂(n!)⌉ ≈ n log₂n。下图是 n = 3 的情形,6 个叶子,最坏要比 3 次:
- 序列 3, 1, 4, 2 有几个逆序对?用直接插入排序要做几次“后移”?
答案
(3,1)、(3,2)、(4,2) 共 3 个,后移也是 3 次。 - 判断:“稳定的排序算法一定比不稳定的好。”
答案
错。稳定性只说明相等元素的相对次序是否保持,与快慢无关;需要多关键字排序(2026-10)时才必须用稳定的方法。
8.2直接插入排序与折半插入排序
打扑克时理牌:左手里的牌已经排好了,每摸一张新牌,就从右往左找到它该在的位置插进去,比它大的牌都往右挪一格。
把序列分成“前面已经有序”和“后面待排序”两部分,开始时有序部分只有第一个元素。每一趟把待排序部分的第一个元素插到有序部分的合适位置。
void InsertSort(int A[], int n) { for (int i = 1; i < n; i++) { // A[0..i-1] 已有序,把 A[i] 插进去 if (A[i] < A[i - 1]) { // 比有序部分的最后一个还大就不用动 int temp = A[i], j; for (j = i - 1; j >= 0 && A[j] > temp; j--) A[j + 1] = A[j]; // 比 temp 大的依次后移 A[j + 1] = temp; // 插入 } } }
代码逐行读:四个容易写错的地方
if (A[i] < A[i - 1]):A[i] 不比有序部分的最后一个小,说明它已经在正确位置,这一趟什么都不用做。去掉这个 if 结果也对,只是每趟都要白白存取一次 temp。j >= 0 && A[j] > temp的顺序不能反:&& 是“短路”的——左边为假就不再算右边。j 减到 −1 时,先判 j ≥ 0 不成立,循环就停了;若写成A[j] > temp && j >= 0,会先访问 A[−1],数组越界。下面带哨兵的写法,正是为了省掉 j ≥ 0 这个判断。- 用
>而不是>=:遇到与 temp 相等的元素就停下,temp 插在它后面,相等元素的先后次序不变——直接插入排序稳定就靠这一个符号。改成 ≥,49* 就会跑到 49 前面。 - 为什么插在
j + 1:循环停下时,A[j] 是从右往左第一个“不大于 temp”的元素(或 j = −1),temp 应该紧挨在它右边;而 A[j + 1] 原来的元素已经后移到 A[j + 2],这个位置正好空着。
四条都可以拿 49, 38, 65, 97, 76, 13, 27, 49* 在纸上跑一遍验证。程序按这段代码计数:全程比较 25 次、移动 25 次(后移 15 次 = 逆序对数,另 10 次是 5 趟的 temp 存取)。
另一种写法:带哨兵(王道版)
王道教材的写法(元素放在 A[1..n],A[0] 既是哨兵又是暂存单元,内层循环不用再判断 j ≥ 0):
void InsertSort2(int A[], int n) { int i, j; for (i = 2; i <= n; i++) if (A[i] < A[i - 1]) { A[0] = A[i]; // A[0] 作哨兵,兼作暂存 for (j = i - 1; A[0] < A[j]; j--) // 不用判 j >= 0:比到 A[0] 一定停 A[j + 1] = A[j]; A[j + 1] = A[0]; } }
A[0] 一身二用:它先当 temp 暂存要插入的元素;又因为 A[0] 就等于这个元素,内层循环比到 j = 0 时 A[0] < A[0] 一定不成立,循环必然停下——不需要再判断 j ≥ 0。代价是元素只能放在 A[1..n]。数比较次数时要注意:带哨兵的写法里“和 A[0] 的那一次比较”也算一次,所以王道的最坏比较次数公式 (n + 2)(n − 1)/2 比“不带哨兵、只数和有序元素的比较”多出一些。题目给出哪段代码,就按哪段代码数。
| 趟 | 结果(竖线左边为有序部分) | 说明 |
|---|---|---|
| 初始 | [49] 38 65 97 76 13 27 49* | |
| 1 | [38 49] 65 97 76 13 27 49* | 38 插到 49 前 |
| 2 | [38 49 65] 97 76 13 27 49* | 65 不动 |
| 3 | [38 49 65 97] 76 13 27 49* | 97 不动 |
| 4 | [38 49 65 76 97] 13 27 49* | 76 插到 97 前 |
| 5 | [13 38 49 65 76 97] 27 49* | 13 插到最前,5 个元素后移 |
| 6 | [13 27 38 49 65 76 97] 49* | |
| 7 | [13 27 38 49 49* 65 76 97] | 49* 停在 49 后面(遇到相等不再前移)⇒ 稳定 |
跟我做一遍:数比较次数和移动次数
| i | 要插入的 | 比较 | 移动(temp 存取算 2 次 + 每次后移 1 次) | 结果 |
|---|---|---|---|---|
| 1 | 2 | 2 < 4?是;4 > 2?是 ⇒ 2 次 | 2 + 1 = 3 | 2 4 5 1 3 |
| 2 | 5 | 5 < 4?否 ⇒ 1 次 | 0 | 2 4 5 1 3 |
| 3 | 1 | 1 < 5 是;5、4、2 都 > 1 ⇒ 4 次 | 2 + 3 = 5 | 1 2 4 5 3 |
| 4 | 3 | 3 < 5 是;5 > 3、4 > 3 是,2 > 3 否 ⇒ 4 次 | 2 + 2 = 4 | 1 2 3 4 5 |
| 合计 | 11 次 | 12 次(其中后移 6 次 = 逆序对数) | ||
一般公式(王道口径,n 个元素):最好(已有序)比较 n − 1 次、移动 0 次;最坏(逆序)比较 Σᵢ₌₂ⁿ i = (n + 2)(n − 1)/2 次、移动 Σᵢ₌₂ⁿ (i + 1) = (n + 4)(n − 1)/2 次。n = 8 时最坏比较 35 次、移动 42 次。(本例与公式都由程序按讲义代码计数核对。)
折半插入对同一个序列:比较次数降为 7 次(每次在有序部分里折半找位置),后移次数仍是 6 次——只改变比较次数,不改变移动次数,所以时间仍是 O(n²)。
折半插入排序
在有序部分中找插入位置时用折半查找,比较次数减少为 O(n log n);但找到位置后仍要把元素一个个后移,移动次数不变,总时间仍是 O(n²)。它同样稳定(查找时遇到相等要继续往右找,保证插在相等元素之后),但只适用于顺序表。与直接插入排序相比,可能不同的只有比较次数(2012-11)。
void BInsertSort(int A[], int n) { for (int i = 1; i < n; i++) { int temp = A[i], low = 0, high = i - 1; while (low <= high) { // 在 A[0..i-1] 中折半查找插入位置 int mid = (low + high) / 2; if (A[mid] > temp) high = mid - 1; else low = mid + 1; // 相等时往右找,保证稳定 } for (int j = i - 1; j >= low; j--) A[j + 1] = A[j]; // 移动 A[low] = temp; } }
| 次 | low | high | mid | A[mid] | 动作 |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 49 | 49 > 49*?否(相等)⇒ low = 4,继续往右找 |
| 2 | 4 | 6 | 5 | 76 | 76 > 49* ⇒ high = 4 |
| 3 | 4 | 4 | 4 | 65 | 65 > 49* ⇒ high = 3 |
| — | 4 | 3 | low > high,插在 A[4]:65、76、97 后移,49* 落在 49 后面 ⇒ 稳定 | ||
找位置只比了 3 次;直接插入要和 97、76、65、49 比 4 次(再加开头那 1 次判断)。整个例子折半插入共比较 16 次,直接插入 25 次;两者的后移次数都是 15 次——这就是“折半插入只减少比较、不减少移动”的具体样子。注意第 1 次比较遇到相等时要往右走(low = mid + 1);若改成往左,49* 会插到 49 前面,就不稳定了。
有序部分有 i 个元素时,折半找位置总是比较 ⌈log₂(i + 1)⌉ 次或少 1 次,几乎不随数据变化,所以折半插入的比较总数约为 n log₂n 级(程序对 i = 1 ~ 199 的各种插入位置逐个核对)。
链表上的直接插入排序
顺序表是“从后往前边比边移”;单链表不能往回走,就改成从头往后找插入位置,找到后改两个指针,不用移动任何元素。做法:把链表拆成“已排好的部分”和“剩下的结点”,每次摘下一个结点插进去。
void LinkInsertSort(LinkList L) { // L 带头结点,排成递增 LNode *p = L->next, *r, *pre; if (p == NULL) return; r = p->next; p->next = NULL; // 第一个结点单独组成有序链表 p = r; while (p != NULL) { r = p->next; // 先记住后继,防止断链 pre = L; while (pre->next != NULL && pre->next->data <= p->data) pre = pre->next; // 找第一个比 p 大的结点的前驱;用 <= 保证稳定 p->next = pre->next; pre->next = p; // 把 p 插到 pre 后面 p = r; } }
| 摘下的结点 | 从头找位置 | 有序链表 |
|---|---|---|
| (初始) | 第一个结点 3 单独成链 | 3 |
| 1 | 3 > 1,插在头结点后 | 1 → 3 |
| 4 | 1、3 都 ≤ 4,走到表尾 | 1 → 3 → 4 |
| 1* | 1 ≤ 1* 继续往后(这就是 <= 的作用),3 > 1* | 1 → 1* → 3 → 4 |
| 2 | 1、1* ≤ 2,3 > 2 | 1 → 1* → 2 → 3 → 4 |
时间仍是 O(n²)(找位置要一个个比),空间 O(1),稳定。有个和顺序表相反的细节:链表版从前往后找,所以输入已经有序时最慢(每个结点都要走到表尾,比较 n(n − 1)/2 次),逆序时最快(每次第一个就停,只比 n − 1 次)。(代码已编译,随机对拍并检查稳定性;比较次数由程序统计。)
- 直接插入排序对 n 个元素,初始“基本有序”(只有 k 个逆序对,k 远小于 n)时的时间复杂度是多少?
答案
后移恰好 k 次,比较不超过 2(n − 1) + k 次,所以是 O(n + k),接近 O(n)。 - 直接插入排序和折半插入排序,对同一序列,哪个量一定相同?
答案
移动次数(元素后移的次数都等于逆序对数),比较次数可能不同(2012-11)。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 起泡排序
- B. 简单选择排序
- C. 二路归并排序
- D. 直接插入排序
展开解析
D起泡、简单选择排序每趟都把一个最小(或最大)元素放到最终位置,两趟后前两个应是 2、3(或后两个是 15、20),不符。二路归并两趟后每 4 个一段有序,而 3, 8, 12, 5 无序,不符。直接插入排序两趟后前 3 个有序:3, 8, 12 ✓。
变化点:识别方法:插入排序——前面一段有序但不一定是最终位置;选择/起泡——两端有元素已在最终位置;归并——按 2、4、8 分段有序。
- A. 6, 5, 4, 3, 2, 1
- B. 1, 2, 6, 5, 4, 3
- C. 3, 1, 2, 6, 4, 5
- D. 1, 3, 2, 5, 4, 6
展开解析
D插入第 i 个元素时,每与一个比它大的元素比较一次就后移一位,最后再与一个不比它大的元素比较一次停下(到最前面时不用这一次)。所以比较次数 ≈ 逆序对数 + (n − 1)。A:逆序 15,比较 15 次;B:逆序 6,比较 11 次;C:逆序 4,比较 8 次;D:逆序 2,比较 7 次。
变化点:直接插入排序的工作量与逆序对个数成正比:越接近有序越快,完全有序时只比较 n − 1 次。
- A. 关键字的比较次数仍为 O(n2)
- B. 它是不稳定的排序
- C. 需要 O(n) 的辅助空间
- D. 元素的移动次数仍为 O(n2)
展开解析
D折半查找插入位置,比较次数降为 O(nlog2n);但找到位置后仍要逐个后移元素,移动次数与直接插入排序相同,最坏 O(n2)。
变化点:原题问两者可能的不同之处——比较次数不同,移动次数相同、趟数相同、辅助空间相同。
8.3希尔排序
直接插入排序在“基本有序”时很快。希尔排序先让序列变得基本有序:取一个增量 d,把相距 d 的元素分为一组(共 d 组),各组内做直接插入排序;然后缩小增量再做;最后 d = 1,对整体做一次直接插入排序。
void ShellSort(int A[], int n) { for (int d = n / 2; d >= 1; d = d / 2) // 增量序列 n/2, n/4, …, 1 for (int i = d; i < n; i++) // 各组轮流做插入:A[i] 插入它所在的组 if (A[i] < A[i - d]) { int temp = A[i], j; for (j = i - d; j >= 0 && A[j] > temp; j -= d) A[j + d] = A[j]; A[j + d] = temp; } }
第二趟、第三趟发生了什么
| 趟 | 增量 | 分组(按下标) | 结果 | 后移 |
|---|---|---|---|---|
| 1 | 4 | {0, 4} {1, 5} {2, 6} {3, 7},每组 2 个 | 49 13 27 49* 76 38 65 97 | 3 |
| 2 | 2 | {0, 2, 4, 6} {1, 3, 5, 7},每组 4 个 | 27 13 49 38 65 49* 76 97 | 3 |
| 3 | 1 | 整个序列一组 | 13 27 38 49 49* 65 76 97 | 3 |
读代码时注意:for (i = d; i < n; i++) 并不是“先排完第 1 组再排第 2 组”,而是 i 从 d 开始依次往后,每个元素插入它自己所在的那一组——几组轮流推进。这和“一组一组地排”结果完全一样,代码却少一层循环。49 和 49* 在第 1 趟分在不同的组,本例最后次序没变只是巧合;上面 3, 2, 2*, 1 的例子就颠倒了。
为什么分组排反而更快
直接插入排序每后移一次,元素只挪 1 格;一个元素要从末尾挪到开头,就得后移很多次。希尔排序在增量大的时候,一次后移让元素一步跨 d 格,很快就消灭了“离得特别远的错位”;等到最后 d = 1 时,序列已经基本有序,正是直接插入最擅长的情况。
| 数据 | 直接插入的后移次数 | 希尔(d = n/2, n/4, …, 1)的后移次数 |
|---|---|---|
| 本篇例子(n = 8) | 15 | 9(3 + 3 + 3) |
| 1000 个随机整数 | 242,571 | 7,330 |
n = 8 时希尔的比较次数反而更多(30 次对 25 次)——每趟都要把序列扫一遍;n 越大,后移省下的远比多出的比较多,优势就越明显。也正因为效果强烈依赖增量序列,希尔排序的复杂度说不准,教材只给“约 O(n1.3)”。(表中数字由程序按讲义代码计数,随机数据固定种子。)
跟我做一遍:由一趟结果反推增量
- 方法:增量为 d 时,结果中每隔 d 取出的子序列必须各自有序。从小到大试。
- d = 2:取位置 0, 2, 4, 6, 8 得 50, 30, 20, 26, 80,不有序 ✗。
- d = 3:位置 0, 3, 6, 9 得 50, 40, 26, 70 ✗。d = 4:位置 0, 4, 8 得 50, 20, 80 ✗。
- d = 5:五组 (50, 90)、(8, 26)、(30, 38)、(40, 80)、(20, 70) 全部有序 ✓,且与原序列对应位置的组 {50, 90}、{26, 8}、{38, 30}、{80, 40}、{70, 20} 元素相同 ✓ ⇒ d = 5。
原序列分别用 d = 5、3、2 做一趟的结果由程序计算:只有 d = 5 得到题目给的序列。
增量序列:希尔本人用 d₁ = ⌊n/2⌋、dᵢ₊₁ = ⌊dᵢ/2⌋,最后一个必须是 1;增量之间最好互质(如 5, 3, 1),否则同一批元素反复在一起比较、效率变差。增量序列不同,时间复杂度也不同,所以教材只给“约 O(n¹·³)”。拓展 例如 n/2 折半的序列在最坏情况下仍是 O(n²);改用 1, 3, 7, 15, …(2ᵏ − 1,相邻两个互质)最坏可降到 O(n1.5)。408 不考具体是哪种序列更好,只考“与增量序列有关、最后一趟增量必为 1、不稳定、只适用于顺序表”。
- 希尔排序的最后一趟增量一定是多少?为什么?
答案
1。只有 d = 1 时才对整个序列做直接插入排序,前面的趟只是让序列“基本有序”,不能保证全部有序。 - 希尔排序适用于链表吗?
答案
不适用:要按下标 i − d 跳着访问,链表不能随机存取。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2
- B. 3
- C. 4
- D. 5
展开解析
C一趟增量为 d 的排序后,相距 d 的元素应当有序。检查 d = 4:(19, 24, 28)、(2, 3)、(14, 17)、(7, 23) 都是递增 ✓。d = 2:19 > 14 ✗;d = 3:19 > 7 ✗;d = 5:19 > 3 ✗。
变化点:方法与原题相同:逐个试增量,看每组是否有序。只要找到一组逆序就能排除,所以先看第一个元素和相距 d 的元素即可快速排除。
- A. 13, 27, 49, 55, 04, 49, 38, 65, 97, 76
- B. 13, 27, 49, 55, 04, 38, 49, 65, 97, 76
- C. 04, 13, 27, 38, 49, 49, 55, 65, 76, 97
- D. 13, 04, 49, 38, 27, 49, 55, 65, 97, 76
展开解析
A增量 5 分为 5 组(位置 1 与 6、2 与 7……):(49, 13)、(38, 27)、(65, 49)、(97, 55)、(76, 04),每组内排序后放回原位置:13, 27, 49, 55, 04, 49, 38, 65, 97, 76。
变化点:D 是再做一趟增量 3 之后的结果。B 把第 6、7 位的 49、38 放反了:49 属于第 1 组(位置 1、6),38 属于第 2 组(位置 2、7)。分组按位置,不按数值。
8.4冒泡排序
从后往前(或从前往后)两两比较相邻元素,逆序就交换。每一趟把剩余元素中最小的“冒”到最前面(或把最大的“沉”到最后),这个元素就到了最终位置。若某一趟没有发生交换,说明已经有序,可以提前结束。
void BubbleSort(int A[], int n) { for (int i = 0; i < n - 1; i++) { bool flag = false; // 本趟是否发生交换 for (int j = n - 1; j > i; j--) // 从后往前,把最小的冒到 A[i] if (A[j - 1] > A[j]) { int t = A[j - 1]; A[j - 1] = A[j]; A[j] = t; flag = true; } if (!flag) return; // 没有交换:已经有序 } }
| 趟 | 结果(从后往前冒泡) |
|---|---|
| 初始 | 49 38 65 97 76 13 27 49* |
| 1 | 13 49 38 65 97 76 27 49* |
| 2 | 13 27 49 38 65 97 76 49* |
| 3 | 13 27 38 49 49* 65 97 76 |
| 4 | 13 27 38 49 49* 65 76 97 |
| 5 | 没有交换,结束 |
| 趟 | 比较(j 从 7 到 i + 1) | 交换 | 说明 |
|---|---|---|---|
| 1 | 7 | 5 | 13 冒到最前 |
| 2 | 6 | 5 | 27 冒到第 2 位 |
| 3 | 5 | 4 | 38 到位,49 与 49* 相等不交换 |
| 4 | 4 | 1 | 只有 97 与 76 交换,此时已全部有序 |
| 5 | 3 | 0 | 没有交换,flag 为假,提前结束 |
| 合计 | 25 | 15 | 交换次数 15 = 初始序列的逆序对数 |
第 i 趟比较 n − i 次与数据无关;交换次数等于逆序对数,与数据有关。表中数字由程序按讲义代码计数。
- 每趟结束后,有一个元素(最小或最大)到达最终位置;第 i 趟后,一端的 i 个元素都是最终位置、全局有序(这与插入排序“前 i+1 个有序但不在最终位置”不同)。
- 最好(已有序)只比较 n − 1 次、不交换,O(n);最坏(逆序)比较、交换约 n²/2 次,O(n²);平均 O(n²)。
- 空间 O(1);稳定(相等时不交换);顺序表、链表都适用。
flag 怎样让冒泡提前结束
对 1, 2, 4, 3, 5, 6 从后往前冒泡,第 1 趟把 3 和 4 换了(序列已经有序),第 2 趟一次交换都没有,flag 仍为 false ⇒ 直接结束,只用了 2 趟。没有 flag 的写法要老老实实做满 n − 1 = 5 趟。
拓展 flag 还可以再“聪明”一点:不只记“有没有交换”,而是记最后一次交换发生的位置。从后往前冒泡时,最后一次交换的位置之前(靠前的那一段)在本趟里没有再动过,说明它们已经是最终次序,下一趟只需扫到那个位置为止。这样对“前半段早已有序”的数据能省掉很多比较;但最坏情况(逆序)仍是 O(n²),选择题按标准写法回答即可。
| 冒泡方向 | 第 i 趟后的特征 |
|---|---|
| 从后往前(本书代码) | 最前面 i 个元素是最小的 i 个,且已在最终位置 |
| 从前往后 | 最后面 i 个元素是最大的 i 个,且已在最终位置(2010-11 就是这种) |
冒泡和直接插入:谁移动得多
两种算法都只让相邻元素换位置,每交换(后移)一次正好消灭一个逆序对,所以冒泡的交换次数、直接插入的后移次数都等于逆序对数(例子里都是 15)。区别在于:一次交换要 3 次赋值(t = a; a = b; b = t),一次后移只要 1 次赋值。
| 直接插入 | 冒泡(从后往前,带 flag) | |
|---|---|---|
| 比较次数(例子) | 25 | 25 |
| 赋值次数(例子) | 25(后移 15 + temp 存取 10) | 45(交换 15 次 × 3) |
| 第 i 趟后 | 前 i + 1 个相对有序,不一定在最终位置 | 一端 i 个已在最终位置 |
| 基本有序时 | O(n) | O(n)(靠 flag 提前结束) |
所以同是 O(n²) 的稳定排序,实际中直接插入通常比冒泡快;冒泡的长处是“每趟定一个最终位置”。选择题问“哪些排序每趟能确定一个元素的最终位置”时,冒泡在内、直接插入不在。(计数由程序按两段讲义代码统计。)
拓展 双向冒泡(鸡尾酒排序):奇数趟从前往后把最大的沉到后面,偶数趟从后往前把最小的冒到前面,两头同时收缩。它专治“个别小元素落在末尾”:2, 3, 4, 5, 1 用单向的“从前往后”冒泡,1 每趟只往前挪一格,要 4 趟;双向的第 2 趟(从后往前)一次就把 1 送到了最前面。遇到这类“写出某趟结果”的题,按题目规定的方向模拟即可。(程序核对。)
- A. 起泡排序
- B. 希尔排序
- C. 归并排序
- D. 基数排序
展开解析
考点8.4 冒泡排序
A三趟结果的共同特征:第 1 趟后最大的 88 在最后;第 2 趟后次大的 16 在倒数第 2;第 3 趟后 12 在倒数第 3——每趟把剩余最大的“沉”到最后,而且前面部分的相对次序基本保持(相邻交换),是冒泡排序。
- A 对起泡排序(从前往后冒泡)。验证第 1 趟:2 12 16 88 5 10 → 88 依次与 5、10 交换 → 2 12 16 5 10 88 ✓。
- B 错88 从第 4 位移到第 6 位,若是希尔排序,增量只能是 2;可 d = 2 时第 2、4、6 位的 12, 88, 10 排好后是 10, 12, 88,第 2 位应变成 10,与结果不符(d = 1 则一趟就全部有序)。
- C 错二路归并第一趟:2 12 | 16 88 | 5 10,与第一趟结果不同。
- D 错基数排序第一趟按个位:10, 2, 12, 5, 16, 88,不同。
冒泡每趟:一个最值归位,其他元素只做相邻交换,整体变化很小。
- 对 n 个元素冒泡排序,最少几趟、最多几趟?
答案
已有序时 1 趟(没有交换即结束);最多 n − 1 趟。 - 序列 5, 1, 2, 3, 4 从后往前冒泡(每趟把最小的冒到前面),需要几趟?若从前往后(每趟把最大的沉到后面)呢?
答案
从后往前:第 1 趟 1 冒到最前得 1, 5, 2, 3, 4;第 2 趟得 1, 2, 5, 3, 4;第 3 趟得 1, 2, 3, 5, 4;第 4 趟得 1, 2, 3, 4, 5。5 每趟只被越过、往后挪一格,4 趟都有交换,做满 n − 1 = 4 趟。从前往后:第 1 趟 5 一路沉到最后得 1, 2, 3, 4, 5,第 2 趟无交换结束,共 2 趟。方向不同,趟数可以差很多。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 1, 2, 4, 3, 5, 8
- B. 1, 4, 2, 5, 3, 8
- C. 1, 2, 3, 4, 5, 8
- D. 1, 2, 4, 5, 3, 8
展开解析
A第一趟:5 一路后移,遇 8 停下,8 再移到末尾:1, 4, 2, 5, 3, 8。第二趟:4 与 2 交换,4 < 5 不换,5 与 3 交换:1, 2, 4, 3, 5, 8。
变化点:B 是第一趟的结果,C 是第三趟的结果(第四趟无交换,排序结束)。每趟结束后末尾多一个元素到达最终位置。
- A. 0、0
- B. n − 1、0
- C. n − 1、n − 1
- D. n(n − 1)/2、0
展开解析
B初始已有序时,第一趟比较 n − 1 次,没有交换,算法结束。
变化点:没有交换标志时,即使有序也要比较 n(n − 1)/2 次(D)。最坏(逆序)时比较 n(n − 1)/2 次、交换同样多次,每次交换移动 3 次。
8.5快速排序
快速排序是平均性能最好的内部排序。思想:任取一个元素作枢轴(pivot,一般取第一个),通过一趟划分,把比枢轴小的都放到它左边、大的都放到右边,枢轴就落在了最终位置;再对左右两部分递归地做同样的事。
int Partition(int A[], int low, int high) { // 一次划分,返回枢轴的最终位置 int pivot = A[low]; // 第一个元素作枢轴,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 p = Partition(A, low, high); QuickSort(A, low, p - 1); // 递归处理左子表 QuickSort(A, p + 1, high); // 递归处理右子表 } }
| 趟 | 结果(加粗为本趟确定位置的枢轴) |
|---|---|
| 初始 | 49 38 65 97 76 13 27 49* |
| 1 | 27 38 13 49 76 97 65 49* |
| 2 | 13 27 38 49 49* 65 76 97 (左子表以 27、右子表以 76 为枢轴) |
| 3 | 13 27 38 49 49* 65 76 97 |
这里 49* 最后排在 49 后面只是巧合;快速排序是不稳定的。例如对 2, 2*, 1 以 2 为枢轴划分:high 找到 1 填到最前面,low 往右走过 2*,与 high 相遇,枢轴 2 放在最后,得到 1, 2*, 2——两个 2 的相对次序变了。
- 每趟(对每个子表的一次划分)使枢轴到达最终位置。按 408 的说法“对尚未确定最终位置的所有元素进行一遍处理称为一趟”:第一趟后至少 1 个元素在最终位置;第二趟后,若第一趟的枢轴在一端,至少 2 个;若在中间,两边各有一个新枢轴,至少 3 个(2014-11、2019-10)。
- 一次划分后,枢轴左边的元素全部 ≤ 它,右边全部 ≥ 它,即左右两块“块间有序”,但块内无序(2023-11、2024-08)。
- 时间:每次划分都把表均分时最好,递归深度 log₂n,O(n log n);表已经有序或逆序时每次只分出一个元素,最坏 O(n²);平均 O(n log n),是所有内部排序中平均最快的。
- 空间:递归栈,最好 O(log n),最坏 O(n)。
- 不稳定;只适用于顺序表(2011-10)。
- 改进:枢轴取“头、中、尾三者的中间值”或随机选取,避免最坏情况;子表很小时改用插入排序。
- 递归次数与“先处理哪个子表”无关(2010-10);但先递归处理较短的子表,可以把栈深度(空间)控制在 O(log n)。
Partition 逐行读:一个“坑”、两个等号
- 挖坑填数:
pivot = A[low]之后,A[low] 就成了一个“坑”。high 往左找比枢轴小的,填进左边的坑,于是 A[high] 成了新坑;low 往右找比枢轴大的,填进右边的坑……最后 low 与 high 相遇,那就是最后一个坑,把枢轴放进去。全程没有一次“交换”,每搬一个元素只赋值 1 次。 - 内层循环为什么还要判
low < high:外层条件只在每轮开头检查一次;内层 high 一路左移时可能越过 low,不判就会把已经处理过的元素再搬一遍,甚至越界。 - 两个等号
>=、<=不能去掉:它们让与枢轴相等的元素“原地不动”。若改成 >、<,两个指针遇到相等的元素都会停住,一个元素在两个坑之间来回搬、指针却不前进——数组里有与枢轴相等的元素时可能死循环(程序用 2, 2*, 1 和 8 个 5 都试出了死循环)。保留等号的代价是:全部相等时 high 一路走到 low,枢轴落在最左端,退化成 O(n²)(见下面“最坏情况”)。 - 数一数比较次数:例子的第一次划分共比较 11 次。7 个非枢轴元素每个至少被比 1 次;27、65、13、97 这 4 个被搬进坑以后,又被另一个指针比了 1 次——11 = 7 + 4。一般地,一次划分的比较次数 = (n − 1) + 被搬动的元素个数,是 O(n)。
比较次数与两个死循环反例都由程序按讲义代码逐步模拟得到。
递归树:快排的“趟”和枢轴
例子:4 次划分,3 趟就排好
输入已有序:每次只分出 1 个,退化成链
最坏情况:什么样的输入让快排变慢
看右图:输入 1, 2, 3, 4, 5, 6 已经有序,枢轴 1 是最小的,划分后左边空、右边 5 个;下一次枢轴 2 又是最小的……每次只“剥掉”一个元素,要划分 n − 1 次,比较 (n − 1) + (n − 2) + … + 1 = n(n − 1)/2 次。三种输入都会这样:
- 已经有序(枢轴总是最小)、完全逆序(枢轴总是最大);
- 所有元素都相等:本书的 Partition 中 high 遇到“≥ 枢轴”就左移,一路移到 low,枢轴总落在最左端,同样退化(程序已验证 n 个相同元素要划分 n − 1 次)。
补救方法就是让枢轴“不那么偏”:取头、中、尾三个元素的中间值,或者随机选一个元素与 A[low] 交换后再划分。这也解释了为什么快排在“基本有序”时反而最慢,而插入排序在“基本有序”时最快——两者正好相反,是选择题的高频陷阱。
三数取中的效果:有序输入 1, 2, …, 9,取头、中、尾 1、5、9 的中间值 5 作枢轴(先把 5 和 A[low] 交换),一次划分后 5 正好落在正中,左右各 4 个——最坏情况一下子变成了最好情况。随机选枢轴的思路则是:让“每次都恰好选到最偏的元素”这件事的概率变得极小。(划分结果由程序按讲义 Partition 核对。)
递归深度与空间:先递归短的一段
快速排序的辅助空间就是递归工作栈,栈的深度等于递归树的高度:例子是 3 层;有序输入退化成链,n 个元素要递归 n 层,空间 O(n)。改进办法:每次划分后,只对较短的一段递归,较长的一段用循环接着处理。
void QuickSort2(int A[], int low, int high) { // 拓展:递归深度不超过 log₂n + 1 while (low < high) { int p = Partition(A, low, high); if (p - low < high - p) { // 左段较短:递归左段 QuickSort2(A, low, p - 1); low = p + 1; // 右段(较长)留给下一轮循环 } else { // 右段较短:递归右段 QuickSort2(A, p + 1, high); high = p - 1; } } }
每递归一层,要处理的段长至少减半,所以深度不超过 log₂n + 1。程序实测:2000 个有序元素,原版要递归 2000 层,这一版只有 2 层。注意两点:① 它只改善空间,时间仍可能 O(n²);② 对原版 QuickSort 来说,先递归左段还是右段,递归调用的总次数都一样(每个长度 ≥ 2 的子表都要划分一次,与次序无关),变的只是栈的最大深度——这正是 2010-10 的结论。(QuickSort2 已与库函数排序随机对拍 3 万组,并检查递归深度。)
归并排序“分”得很随便(从中间一刀切开,O(1)),力气全花在“合”上(Merge 要比较、要辅助数组);快速排序反过来,“分”很费力(Partition 要扫一遍,把小的、大的分到两边),分好以后两段各自排完直接就是答案,不用再合。所以归并每层都稳稳当当 O(n)、总是 O(n log n),代价是 O(n) 的辅助空间;快排的好坏全看“分”得匀不匀——分得好比归并还快(不用来回复制数组),分得差就退化到 O(n²)。
跟我做一遍:判断能不能是快排“第二趟”的结果
- 找出所有“已在最终位置”的元素:它左边全比它小、右边全比它大(与排好序的序列比对位置最省事)。
- 第二趟后,这样的元素:若第一趟枢轴落在端点,至少 2 个(端点那个 + 另一侧子表的新枢轴);若落在中间,至少 3 个,而且其中一个把序列分成两段,另两个分别在两段里。
- 例:5, 9, 7, 20, 26, 22, 31, 40。排好序是 5, 7, 9, 20, 22, 26, 31, 40,逐位对照,位置对得上的是 5、20、31、40(还要检查左边都比它小、右边都比它大,这几个都满足)。
- 试 20 当第一趟枢轴:左段 {5, 9, 7} 里有 5 到位,右段 {26, 22, 31, 40} 里有 31(或 40)到位 ⇒ 可能(第一趟枢轴 20,第二趟左右枢轴分别是 5 和 31 或 40)。
- 不可能的情形:只有 1 个元素到位;或者每个到位元素当枢轴时,总有一侧非空却没有到位元素。2019-10 就是用这个方法排除选项。
程序验证:把这 8 个数的全部 40320 种初始排列都做两趟快排,所得的 2015 种结果恰好就是满足上述判定法的那些序列——判定法既不漏也不多。
- A. 5, 2, 16, 12, 28, 60, 32, 72
- B. 2, 16, 5, 28, 12, 60, 32, 72
- C. 2, 12, 16, 5, 28, 32, 72, 60
- D. 5, 2, 12, 28, 16, 32, 72, 60
展开解析
D最终序列 2, 5, 12, 16, 28, 32, 60, 72。
- A 可能5, 2, 16, 12, 28, 60, 32, 72:28、72 在最终位置。第一趟枢轴 72 在最右端,第二趟对前 7 个划分,枢轴 28 到位。
- B 可能2, 16, 5, 28, 12, 60, 32, 72:2、72 在最终位置,都在两端,可能。
- C 可能2, 12, 16, 5, 28, 32, 72, 60:2、28、32 在最终位置,可能(例如第一趟枢轴 2 在左端,第二趟对剩下的划分)。
- D 不可能5, 2, 12, 28, 16, 32, 72, 60:在最终位置的只有 12、32,都在中间。若第一趟枢轴是 12,第二趟它左边的 5, 2 中应有一个到位(没有);若第一趟是 32,右边的 72, 60 中应有一个到位(没有)。
第一趟枢轴在中间 ⇒ 第二趟两侧子表各有一个元素到位;缺一侧就不可能。
跟我做一遍:由一次划分的结果反推枢轴(2023-11 题型)
- 判据:枢轴左边的元素都不比它大、右边都不比它小。逐个检查:1 左边没有、右边都 ≥ 1 ✓;3 右边有 2 ✗;2 左边有 3 ✗;4 左边 1, 3, 2 都 ≤ 4、右边 6, 5 都 ≥ 4 ✓;6 右边有 5 ✗;5 左边有 6 ✗。
- 所以枢轴可能是 1 或 4。题目一般只问“可能是哪个”,选项里出现哪个就选哪个;若两个都在选项里,再看题目的其他条件。
- 快速检查的窍门:从左往右记“到目前为止的最大值”,从右往左记“到目前为止的最小值”;一个元素同时满足“≥ 它左边的最大值”和“≤ 它右边的最小值”,就是候选。这样每个元素只看两次,O(n)。
void Pivots(int A[], int n) { // 输出所有“左边都不比它大、右边都不比它小”的元素 int *sufmin = (int *)malloc(n * sizeof(int)); sufmin[n - 1] = A[n - 1]; for (int i = n - 2; i >= 0; i--) // sufmin[i] = A[i..n-1] 中的最小值 sufmin[i] = (A[i] < sufmin[i + 1]) ? A[i] : sufmin[i + 1]; int premax = INT_MIN; // premax = A[0..i-1] 中的最大值 for (int i = 0; i < n; i++) { if (A[i] >= premax && (i == n - 1 || A[i] <= sufmin[i + 1])) printf("%d ", A[i]); if (A[i] > premax) premax = A[i]; } free(sufmin); }
这段代码本身也是一道好算法题:“找出数组中所有满足‘左边都不大于它、右边都不小于它’的元素”,暴力 O(n²),用前缀最大、后缀最小两个数组降到 O(n)。它判断的正是“这个元素已经在最终位置”——所以 8.5 “第二趟”判定的第一步也可以用它。(代码与暴力判断随机对拍 3 万组;例子由程序核对。)
划分的三种用法(算法大题常客)
Partition 的本事是:一趟 O(n),把一个元素放到最终位置,同时把其余元素分成“小的一堆”和“大的一堆”。很多算法题不需要完整排序,只需要借用这个“分堆”的动作。
用法 1:找第 k 小的元素。划分一次后,若枢轴正好落在第 k 个位置就找到了;否则只需要到一边继续找,另一边直接扔掉。
int KthSmallest(int A[], int n, int k) { // 返回第 k 小的元素(1 <= k <= n),A 会被打乱 int low = 0, high = n - 1; while (1) { int p = Partition(A, low, high); // 枢轴落在最终位置 p if (p == k - 1) return A[p]; // 正好是第 k 个位置 else if (p > k - 1) high = p - 1; // 答案在左边 else low = p + 1; // 答案在右边 } }
| 划分区间 | 划分后数组 | 枢轴位置 p | 与 k − 1 = 4 比较 |
|---|---|---|---|
| A[0..7] | 27 38 13 49 76 97 65 49* | 3 | 3 < 4,到右边找 |
| A[4..7] | 27 38 13 49 49* 65 76 97 | 6 | 6 > 4,到左边找 |
| A[4..5] | 27 38 13 49 49* 65 76 97 | 4 | 相等,第 5 小是 49* |
每次大约扔掉一半,平均比较 n + n/2 + n/4 + … < 2n 次,平均 O(n),比先排序再取(O(n log n))快;最坏(每次只扔掉 1 个,例如对有序序列找第 n 小,每次划分只剥掉最左边一个)O(n²),和快排怕有序输入是同一个原因,同样可以用随机选枢轴来防。空间 O(1)。2016-43 “把集合分成元素个数尽量相等、和之差尽量大的两部分”就是找第 ⌊n/2⌋ 小的划分点。
用法 2:奇数放前、偶数放后(或负数放前、正数放后)。左右两个指针相向而行:左边找“不该在左边的”(偶数),右边找“不该在右边的”(奇数),找到就交换。
void OddFirst(int A[], int n) { // 奇数都移到偶数前面 int i = 0, j = n - 1; while (i < j) { while (i < j && A[i] % 2 != 0) i++; // 从左往右找偶数 while (i < j && A[j] % 2 == 0) j--; // 从右往左找奇数 if (i < j) { int t = A[i]; A[i] = A[j]; A[j] = t; i++; j--; } } }
例:4, 7, 2, 9, 6, 1, 3, 8。i 停在 4(下标 0)、j 停在 3(下标 6),交换得 3, 7, 2, 9, 6, 1, 4, 8;i 停在 2(下标 2)、j 停在 1(下标 5),交换得 3, 7, 1, 9, 6, 2, 4, 8;之后 i、j 相遇,结束。每个元素最多被看一次,时间 O(n),空间 O(1),但不稳定。
用法 3:三色划分(荷兰国旗问题)。元素只有红、白、蓝三种,要排成“红…白…蓝”。用三个指针把数组分成四个区:
| a[0..i−1] | a[i..j−1] | a[j..k] | a[k+1..n−1] |
|---|---|---|---|
| 已确定的红 | 已确定的白 | 还没看的 | 已确定的蓝 |
typedef enum { RED, WHITE, BLUE } color; // 红 0、白 1、蓝 2 void Flag(color a[], int n) { int i = 0, j = 0, k = n - 1; color t; while (j <= k) { // 还有没看的 switch (a[j]) { case RED: t = a[i]; a[i] = a[j]; a[j] = t; i++; j++; break; // 换到红区末尾 case WHITE: j++; break; // 白的留在原地 case BLUE: t = a[j]; a[j] = a[k]; a[k] = t; k--; break; // 换到蓝区前面;换来的 a[j] 还没看,j 不动 } } }
| i | j | k | 数组(W 白 R 红 B 蓝) | a[j] 是 | 动作 |
|---|---|---|---|---|---|
| 0 | 0 | 6 | W R B W R B R | W | j++ |
| 0 | 1 | 6 | W R B W R B R | R | a[0]↔a[1],i++,j++ |
| 1 | 2 | 6 | R W B W R B R | B | a[2]↔a[6],k−− |
| 1 | 2 | 5 | R W R W R B B | R | a[1]↔a[2],i++,j++ |
| 2 | 3 | 5 | R R W W R B B | W | j++ |
| 2 | 4 | 5 | R R W W R B B | R | a[2]↔a[4],i++,j++ |
| 3 | 5 | 5 | R R R W W B B | B | a[5]↔a[5],k−−;j > k 结束 |
j 每步前进或 k 每步后退,二者之间的“未看区”每步缩小 1,所以恰好 n 步、时间 O(n)、空间 O(1)。注意遇到蓝色时 j 不动:从 k 换过来的元素还没检查过。(三段代码均已编译,随机 2 万组对拍;两个跟做表由程序打印。)
- 对 1, 2, 3, 4, 5, 6 快速排序(取第一个元素作枢轴),要做几次划分?比较次数是多少级别?
答案
每次划分只确定最左一个元素,右边剩下 n − 1 个,共 5 次划分(最后剩下的 6 单独一个,不用划分);比较 5 + 4 + 3 + 2 + 1 = 15 次,约 n²/2,O(n²)——快排最怕有序输入。 - 序列 11, 5, 18, 26, 40, 33, 52, 47 可能是快排第二趟的结果吗?
答案
有序为 5, 11, 18, 26, 33, 40, 47, 52,到位的只有 18 和 26。若第一趟枢轴是 18,左段 {11, 5} 非空却没有到位元素 ✗;若是 26,左段有 18 ✓,但右段 {40, 33, 52, 47} 没有到位元素 ✗。所以不可能(程序穷举全部初始排列也得不到它)。 - 2, 1, 3, 5, 4, 6 是某次划分后的序列,枢轴可能是哪些元素?
答案
左边都不大于它、右边都不小于它的元素:3 和 6。2 右边有 1 ✗;5 右边有 4 ✗。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2, 3, 4, 5, 8, 7, 9, 6
- B. 4, 2, 3, 5, 7, 6, 8, 9
- C. 3, 2, 5, 4, 7, 6, 9, 8
- D. 2, 3, 5, 4, 6, 9, 7, 8
展开解析
C找“已在最终位置”的元素(左边都比它小、右边都比它大):A 中 2, 3, 4, 5;B 中 5, 8, 9;C 中一个也没有;D 中 2, 3, 6。两趟后至少有 2 个元素到位(若第一趟的枢轴在一端,第二趟只处理一个子表,至少 2 个;否则至少 3 个)。C 一个都没有,不可能。
变化点:原题的四个选项都有到位元素,要进一步判断到位元素的位置是否合理(第一趟枢轴不在两端时,左右两个子表中都必须各有一个到位元素)。B 中 5 在中间,但左边子表 4, 2, 3 里没有到位元素——B 之所以仍然可能,是因为第一趟的枢轴可以是 8(在倒数第二个位置),第二趟再让 5 到位。
- A. 41
- B. 52
- C. 60
- D. 88
展开解析
B枢轴左边的元素都不大于它、右边的都不小于它。52:左边 35, 20, 41, 18 都小于 52,右边 60, 57, 88, 70 都大于 52 ✓。41 右边有 18;60 右边有 57;88 右边有 70,都不满足。
变化点:方法同原题,逐个检查。更快的办法:从左到右记“前缀最大值”,从右到左记“后缀最小值”,某元素同时等于二者时就可能是枢轴。
- A. 初始序列越接近有序,快速排序越快
- B. 每一趟划分后枢轴左边的子表一定比右边的短
- C. 快速排序是稳定的排序方法
- D. 最坏情况下递归深度为 O(n),平均情况下为 O(log2n)
展开解析
D以第一个元素为枢轴时,初始有序反而是最坏情况:每次只分出一个元素,递归深度 n,时间 O(n2);平均每次大致对半分,深度 O(log2n),所以空间复杂度平均 O(log2n)、最坏 O(n)。快速排序不稳定;子表长短取决于枢轴的值。
变化点:原题问递归次数与处理次序的关系(无关)。快速排序的“好坏”只取决于每次划分是否均匀。
- A. 已经递增有序
- B. 随机排列
- C. 已经递减有序
- D. n 个关键字全部相等
展开解析
BA:枢轴总是最小的,右边剩 n − 1 个;C:枢轴总是最大的,左边剩 n − 1 个;D:high 遇到“≥ 枢轴”一路左移到 low,枢轴总落在最左端,也是每次只分出 1 个。三者都要划分 n − 1 次,O(n2)。随机排列平均 O(nlog2n)。
变化点:“三数取中”能救 A、C,却救不了 D(三个数一样大);要让全相等也不退化,可以让两个指针遇到与枢轴相等的元素都停下来交换,把相等元素平均分到两边。见 8.5 最坏情况。
8.6选择排序:简单选择排序与堆排序
简单选择排序
第 i 趟在 A[i..n−1] 中选出最小的,和 A[i] 交换。每趟确定一个元素的最终位置。
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 t = A[i]; A[i] = A[min]; A[min] = t; } } }
| 趟 | 结果 | 说明 |
|---|---|---|
| 初始 | 49 38 65 97 76 13 27 49* | |
| 1 | 13 38 65 97 76 49 27 49* | 最小的 13 与 A[0] 的 49 交换 |
| 2 | 13 27 65 97 76 49 38 49* | |
| 3 | 13 27 38 97 76 49 65 49* | |
| 4 | 13 27 38 49 76 97 65 49* | |
| 5 | 13 27 38 49 49* 97 65 76 | 49 与 49* 次序没变只是巧合 |
| 6 | 13 27 38 49 49* 65 97 76 | |
| 7 | 13 27 38 49 49* 65 76 97 | 最后一个自然到位 |
代码怎么读:① min 记的是下标不是值——找完以后要靠下标去交换;② if (min != i) 省掉“自己和自己交换”;③ 交换是“远距离”的:A[i] 被直接换到 min 的位置,可能越过与它相等的元素,这就是不稳定的根源。链表也能做简单选择排序:找最小结点后交换两个结点的数据域,或把它摘下来接到有序部分的尾巴上。
比较次数怎么数:第 i 趟(i 从 1 数)要在剩下的 n − i + 1 个元素里找最小,比较 n − i 次,与元素的值无关。例子 n = 8:7 + 6 + 5 + 4 + 3 + 2 + 1 = 28 次。交换次数则与初态有关:已经有序时一次也不换,最多换 n − 1 次。
- 每趟一个元素到达最终位置。
- 比较次数与初始序列无关,恒为 n(n − 1)/2,所以时间始终 O(n²);移动次数很少,最多 n − 1 次交换即 3(n − 1) 次移动,是最坏情况下移动最少的(2025-10)。
- 空间 O(1);不稳定:交换可能把一个元素换到与它相等的元素后面(例:2, 2*, 1 第一趟 2 与 1 交换得 1, 2*, 2);顺序表、链表都适用。
堆
堆:n 个元素的序列 L[1..n],若满足 L(i) ≥ L(2i) 且 L(i) ≥ L(2i + 1),叫大根堆;若 L(i) ≤ L(2i) 且 L(i) ≤ L(2i + 1),叫小根堆(1 ≤ i ≤ ⌊n/2⌋)。把它看成一棵完全二叉树(按顺序存储),大根堆就是“每个结点都不小于它的孩子”,所以根是最大值。
堆只规定了双亲与孩子之间的大小,左右孩子之间、不同子树之间没有大小关系。所以堆可以看成完全二叉树、可以顺序存储;不能看成二叉排序树。大根堆中次大的元素一定是根的某个孩子(在第 2 层);但最小元素只知道一定在叶子中,不知道在哪个位置。
建堆:从最后一个分支结点开始,依次向下调整
- 编号 ⌊n/2⌋ 的结点是最后一个分支结点。从它开始,依次对 ⌊n/2⌋、⌊n/2⌋ − 1、…、1 号结点做“向下调整”。
- 向下调整结点 k:比较它的两个孩子,取较大的(大根堆);若较大的孩子比 k 大,就交换,然后继续向下调整被换下去的位置,直到不需要交换或到达叶子。
void HeadAdjust(int A[], int k, int len) { // 下标 1..len;把以 k 为根的子树调整为大根堆 A[0] = A[k]; // A[0] 暂存根 for (int i = 2 * k; i <= len; i *= 2) { // 沿较大的孩子往下 if (i < len && A[i] < A[i + 1]) i++; // 取两个孩子中较大的 if (A[0] >= A[i]) break; // 根比孩子都大,调整结束 A[k] = A[i]; k = i; // 孩子上移,继续往下 } A[k] = A[0]; } void BuildMaxHeap(int A[], int len) { for (int i = len / 2; i > 0; i--) HeadAdjust(A, i, len); } void HeapSort(int A[], int len) { BuildMaxHeap(A, len); for (int i = len; i > 1; i--) { int t = A[i]; A[i] = A[1]; A[1] = t; // 堆顶(最大)与堆底交换,放到最终位置 HeadAdjust(A, 1, i - 1); // 剩下的 i-1 个重新调整成堆 } }
HeadAdjust 逐行读:也是“挖坑”
A[0] = A[k]:把要下沉的元素先拿出来,k 处成了一个坑。i = 2 * k是左孩子;if (i < len && A[i] < A[i + 1]) i++选出较大的孩子。其中i < len是在问“有没有右孩子”:i = len 时 A[i + 1] 已经不在堆里,只能选左孩子。if (A[0] >= A[i]) break:拿出来的元素不比较大的孩子小,坑的位置就是它的家,停。- 否则
A[k] = A[i]; k = i:较大的孩子上移填坑,坑下沉到孩子的位置,继续往下比;i *= 2走到新位置的左孩子。 - 循环结束后
A[k] = A[0]:把拿出来的元素放进最后的坑。
和快排的 Partition 一样,用“坑”代替“交换”:每下沉一层只赋值 1 次,交换则要 3 次。手算画图时按“交换”来想,结果完全相同。
数组下标从 0 开始时怎么算
本书(和王道)的堆从 A[1] 开始存,公式最简单。有的题目和代码从 A[0] 开始存,所有公式都要跟着改:
例如 0 起时最后一个分支结点是 ⌊n/2⌋ − 1:n = 7 时是 A[2](值 12),建堆就从 A[2] 往前调到 A[0]。做题先看数组从几开始,别把两套公式混用——这是堆的选择题里最常见的失分点。(两套公式对 n = 1 ~ 39 逐个核对。)
建堆为什么只要 O(n)
直觉上 n/2 个结点各调整一次、每次最多 log₂n 层,应该是 O(n log n);但实际上大多数结点都在底下几层,根本沉不了几层:
| 结点在哪一层(树高 h) | 这一层最多几个结点 | 最多下沉几层 | 比较次数上限 |
|---|---|---|---|
| 倒数第 1 层(叶子) | 约 n/2 | 0(不用调整) | 0 |
| 倒数第 2 层 | 约 n/4 | 1 | (n/4) × 2 × 1 |
| 倒数第 3 层 | 约 n/8 | 2 | (n/8) × 2 × 2 |
| 倒数第 j + 1 层 | 约 n/2j+1 | j | (n/2j+1) × 2j = n · j/2j |
每下沉一层最多比较 2 次。全部加起来 ≤ n × (1/2 + 2/4 + 3/8 + 4/16 + …) < 2n,再算上各层结点数取整的误差,教材的结论是建堆比较次数不超过 4n,时间 O(n)。例子 49, 38, 65, 97, 76, 13, 27, 49* 建堆实际只比较了 10 次(≤ 4 × 8 = 32)。
设 S = 1/2 + 2/4 + 3/8 + 4/16 + …,两边同乘 1/2 得 S/2 = 1/4 + 2/8 + 3/16 + …。上下相减(对齐分母):S − S/2 = 1/2 + 1/4 + 1/8 + … = 1,所以 S = 2。前几项的部分和是 0.5、1、1.375、1.625、1.781……越来越接近 2 但永远不超过。这正是高中数列里的“错位相减法”。
跟我做一遍:建堆时逐步数比较次数
n = 7,最后一个分支结点是 ⌊7/2⌋ = 3 号,依次调整 3、2、1 号:
| 调整 | 过程(按 HeadAdjust 的比较约定) | 比较 | 之后的序列 |
|---|---|---|---|
| 3 号(15) | 两个孩子 1、12 比,取 12(1 次);15 ≥ 12,停(1 次) | 2 | 3 8 15 2 9 1 12(不变) |
| 2 号(8) | 两个孩子 2、9 比,取 9(1 次);8 < 9,9 上移(1 次);8 落到 5 号,已是叶子,停 | 2 | 3 9 15 2 8 1 12 |
| 1 号(3) | 孩子 9、15 比,取 15(1 次);3 < 15,15 上移(1 次);到 3 号后,孩子 1、12 比,取 12(1 次);3 < 12,12 上移(1 次);3 落到 7 号 | 4 | 15 9 12 2 8 1 3 |
共比较 2 + 2 + 4 = 8 次,远小于上限 4n = 28。“有两个孩子时,先两个孩子比、再和较大的孩子比”——每下沉一层 2 次,这是数建堆比较次数的标准约定;2018-11 那样问“序列的变化过程”,就是把这张表每一行的结果依次写出来。(程序按讲义 HeadAdjust 计数核对。)
堆排序
- 堆排序:建好大根堆后,把堆顶(最大)与最后一个元素交换,最大值就到了最终位置;再把前 n − 1 个元素从堆顶向下调整成堆……重复 n − 1 次,得到升序序列。例子中依次输出 97、76、65、49*、49、38、27、13(放在数组末尾)。
- 删除堆顶:用最后一个元素代替堆顶,再向下调整(2015-10、2024-09)。
- 插入:新元素放在堆的末尾,再向上调整——与双亲比较,比双亲大(大根堆)就交换,一直上浮到不需要交换或到达根(2009-09、2011-11、2021-11)。
跟我做一遍:堆排序逐趟
| 趟 | 堆顶与堆底交换后,再把前面调整成堆(竖线右边已在最终位置) |
|---|---|
| 建堆 | 97 76 65 49* 49 13 27 38 |
| 1 | 76 49* 65 38 49 13 27 | 97 |
| 2 | 65 49* 27 38 49 13 | 76 97 |
| 3 | 49* 49 27 38 13 | 65 76 97 |
| 4 | 49 38 27 13 | 49* 65 76 97 |
| 5 | 38 13 27 | 49 49* 65 76 97 |
| 6 | 27 13 | 38 49 49* 65 76 97 |
| 7 | 13 | 27 38 49 49* 65 76 97 |
第 i 趟后,末尾 i 个是最大的 i 个且已在最终位置,前面 n − i 个仍是一个大根堆——这就是判断“某趟结果是不是堆排序”的特征。本例 49 和 49* 最后次序没变只是巧合:对 2, 2*, 1 建堆得 2, 2*, 1,第 1 趟 2 与 1 交换并调整得 2*, 1 | 2,第 2 趟得 1 | 2*, 2,结果 1, 2*, 2,不稳定。(两例均由程序按讲义代码模拟。)
向下调整:每下降一层,先比较两个孩子谁大(1 次,只有一个孩子时不比),再用较大的孩子与当前元素比较(1 次)。向上调整:每上升一层,与双亲比较 1 次。
插入:放到末尾,再向上调整
void HeapInsert(int A[], int *len, int x) { // 大根堆 A[1..*len],插入 x int k = ++(*len); A[k] = x; // 先放到末尾 while (k > 1 && A[k / 2] < A[k]) { // 比双亲大就上浮 int t = A[k]; A[k] = A[k / 2]; A[k / 2] = t; k = k / 2; } }
① 80 放在第 9 个位置,双亲是 49*
② 80 > 49*,交换;双亲是 76
③ 80 > 76,交换;80 < 97,停
删除堆顶:末尾补上来,再向下调整
int HeapDeleteTop(int A[], int *len) { int top = A[1]; A[1] = A[(*len)--]; // 末尾元素补到堆顶,堆长减 1 HeadAdjust(A, 1, *len); // 再向下调整(就是建堆用的那个函数) return top; }
① 97 取走,末尾 38 补到堆顶
② 76 vs 65 取 76;38 < 76,交换
③ 49* vs 49 取左边的 49*;交换,到叶子
跟我做一遍:用堆找最小的 k 个(Top-k)
想法:手里只留 3 个“候选”,并且随时知道候选中最大的那个(它最容易被淘汰),所以用大小为 3 的大根堆。新来的数比堆顶小,就把堆顶换掉再向下调整;否则直接跳过。
| 读入 | 与堆顶比较 | 操作 | 堆(层序) |
|---|---|---|---|
| 25, 7, 40 | — | 前 3 个建大根堆 | 40 7 25 |
| 12 | 12 < 40 | 替换堆顶、下沉 | 25 7 12 |
| 3 | 3 < 25 | 替换堆顶、下沉 | 12 7 3 |
| 30 | 30 > 12 | 跳过 | 12 7 3 |
| 9 | 9 < 12 | 替换堆顶,9 比两个孩子都大,不用动 | 9 7 3 |
| 18 | 18 > 9 | 跳过 | 9 7 3 ⇒ 最小的 3 个是 3, 7, 9 |
复杂度:每个数最多引起一次 O(log k) 的调整,总时间 O(n log k),只占 O(k) 的内存——数据多到放不进内存时(2022-42 那样的海量数据)也能边读边做。口诀:找最小的 k 个用大根堆,找最大的 k 个用小根堆(堆顶是“守门员”,守的是最容易被淘汰的那个)。(本例与随机数据上的正确性都由程序验证。)
拓展 堆不只是用来排序:优先队列。很多场合要反复做“放进一个元素”“取出当前最小(或最大)的元素”这两件事:操作系统按优先级挑下一个要运行的进程、医院急诊按病情轻重叫号、第 6 篇 Dijkstra 每次取“当前最近的顶点”。用无序数组,取最小要扫一遍 O(n);用有序数组,插入要挪元素 O(n);用堆,两件事都只要 O(log n)——插入就是“放末尾再上浮”,取出就是“删堆顶再下沉”,也就是上面两段代码。第 9 篇预测题里的小根堆(9.10)就是按这个思路出的。
- A. 6,1,7,9,8,4,5 → 6,9,7,1,8,4,5 → 9,6,7,1,8,4,5 → 9,8,7,1,6,4,5
- B. 6,9,5,1,8,4,7 → 6,9,7,1,8,4,5 → 9,6,7,1,8,4,5 → 9,8,7,1,6,4,5
- C. 6,9,5,1,8,4,7 → 9,6,5,1,8,4,7 → 9,6,7,1,8,4,5 → 9,8,7,1,6,4,5
- D. 6,1,7,9,8,4,5 → 7,1,6,9,8,4,5 → 7,9,6,1,8,4,5 → 9,7,6,1,8,4,5 → 9,8,6,1,7,4,5
展开解析
考点8.6 建堆过程
An = 7,从第 ⌊7/2⌋ = 3 个结点开始,依次对 3、2、1 号结点向下调整(用程序逐步模拟验证)。
- 3 号(5):孩子 4、7,较大的 7 比 5 大,交换 → 6, 1, 7, 9, 8, 4, 5。
- 2 号(1):孩子 9、8,较大的 9,交换 → 6, 9, 7, 1, 8, 4, 5(1 到了叶子)。
- 1 号(6):孩子 9、7,较大的 9,交换 → 9, 6, 7, 1, 8, 4, 5;6 继续与孩子 1、8 中较大的 8 比,交换 → 9, 8, 7, 1, 6, 4, 5。
- A 对过程完全一致(第 3 个状态是 6 向下调整的中间状态)。
- B 错第一步应该先调整 3 号结点,而不是 2 号。
- C 错第一步同样先调整了 2 号结点(1 与 9 交换),第二步又直接调整根,跳过了 3 号结点;顺序应是 3 号 → 2 号 → 1 号。
- D 错第二步就调整了根,顺序错;也不是按“从下往上”的方法建堆。
建堆:从 ⌊n/2⌋ 号结点倒着到 1 号,逐个向下调整。
- 向小根堆 3, 8, 5, 12, 9, 7 中插入 4,结果是什么?比较几次?
答案
4 放在第 7 个位置,双亲是第 3 个(5):4 < 5,交换;双亲变为第 1 个(3):4 > 3,停。结果 3, 8, 4, 12, 9, 7, 5,比较 2 次。 - 要从 100 万个数中找出最大的 10 个,用什么堆?时间复杂度?
答案
大小为 10 的小根堆(堆顶是候选中最小的“守门员”),新数比堆顶大才替换。O(n log 10) = O(n)。 - 堆存放在 A[0..n−1](下标从 0 开始)中,A[5] 的双亲和两个孩子分别是哪个下标?若从 1 开始存,A[5] 的呢?
答案
0 起:双亲 ⌊(5 − 1)/2⌋ = 2,孩子 2 × 5 + 1 = 11、12。1 起:双亲 ⌊5/2⌋ = 2,孩子 10、11。(两套公式不能混用。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 2, 3, 8, 5, 9, 10, 15, 12
- B. 2, 3, 5, 8, 9, 10, 15, 12
- C. 2, 5, 3, 12, 9, 10, 15, 8
- D. 3, 2, 8, 5, 9, 10, 15, 12
展开解析
A2 放在末尾第 8 个位置,双亲是第 4 个(12):2 < 12 交换;双亲变为第 2 个(5):交换;双亲第 1 个(3):交换。一路上浮到堆顶,路径上的 12、5、3 各下移一层:2, 3, 8, 5, 9, 10, 15, 12。
变化点:插入只沿“新结点 → 根”这一条路径调整,与路径外的元素(8, 9, 10, 15)无关,比较次数 ≤ 树高。
- A. 2
- B. 3
- C. 4
- D. 5
展开解析
B末尾的 20 移到堆顶:20, 9, 7, 12, 15, 10。第 1 层:两个孩子 9 与 7 比较(1 次),较小的 7 与 20 比较(2 次),交换。20 到了第 3 个位置,只有一个孩子 10(第 6 个):10 与 20 比较(3 次),交换。结果 7, 9, 10, 12, 15, 20,共 3 次。
变化点:向下调整时,有两个孩子要比 2 次,只有一个孩子只比 1 次。原题也是 3 次(12 下沉到第 3 个位置后只剩一个孩子 16)。每下沉一层都要先看清孩子的个数。
- A. 3, 6, 5, 1, 2, 4 → 6, 3, 5, 1, 2, 4
- B. 3, 1, 5, 6, 2, 4 → 6, 1, 5, 3, 2, 4 → 6, 3, 5, 1, 2, 4
- C. 5, 1, 3, 6, 2, 4 → 5, 6, 3, 1, 2, 4 → 6, 5, 3, 1, 2, 4
- D. 3, 6, 5, 1, 2, 4 → 6, 3, 5, 1, 2, 4 → 6, 3, 5, 4, 2, 1
展开解析
A从最后一个分支结点(第 ⌊6/2⌋ = 3 个)开始,依次向前调整:第 3 个 5,孩子 4,不动;第 2 个 1,孩子 6、2,与 6 交换:3, 6, 5, 1, 2, 4;第 1 个 3,孩子 6、5,与 6 交换,3 下沉后孩子 1、2 都比它小,停止:6, 3, 5, 1, 2, 4。
变化点:建堆是自下而上对每个分支结点做向下调整,不是从根开始;每个结点的调整可能要一路下沉多层。B 先调整了根,C 把 3 和 5 直接交换了(不是建堆的操作)。
- A. 9, 7, 6, 2, 3, 5
- B. 9, 7, 6, 3, 2, 5
- C. 9, 6, 7, 2, 3, 5
- D. 9, 7, 5, 2, 3, 6
展开解析
A5;5, 2;插 7:7 > 5 上浮,7, 2, 5;插 3:双亲 2,上浮,7, 3, 5, 2;插 9:双亲 3,上浮,再与 7 交换,9, 7, 5, 2, 3;插 6:双亲 5,上浮,9, 7, 6, 2, 3, 5。
变化点:逐个插入(向上调整)和一次性建堆(向下调整)得到的堆可能不同:对同一序列 5, 2, 7, 3, 9, 6 一次性建堆得到的是 9, 5, 7, 3, 2, 6。考试要看清是哪种方式。
8.7归并排序
归并是把两个(或多个)有序表合并成一个有序表(2022-10)。二路归并排序:把 n 个元素看成 n 个长度为 1 的有序子表,两两归并得到 ⌈n/2⌉ 个长度为 2 的有序子表,再两两归并……直到合成一个长度为 n 的有序表。
int B[MAXN]; // 辅助数组 void Merge(int A[], int low, int mid, int high) { // 合并 A[low..mid] 与 A[mid+1..high] int i, j, k; for (k = low; k <= high; k++) B[k] = A[k]; // 先复制到 B for (i = low, j = mid + 1, k = low; i <= mid && j <= high; k++) A[k] = (B[i] <= B[j]) ? B[i++] : B[j++]; // 取较小者;相等取前一段的,保证稳定 while (i <= mid) A[k++] = B[i++]; while (j <= high) A[k++] = B[j++]; } 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); // 合并 } }
| 趟 | 结果(方括号为有序子表) |
|---|---|
| 初始 | [49] [38] [65] [97] [76] [13] [27] [49*] |
| 1 | [38 49] [65 97] [13 76] [27 49*] |
| 2 | [38 49 65 97] [13 27 49* 76] |
| 3 | [13 27 38 49 49* 65 76 97] |
Merge 逐行读
- 第一个 for 先把 A[low..high] 复制到辅助数组 B,然后从 B 往 A 里写。如果直接在 A 上边比边写,会把还没比较的元素覆盖掉。
- 第二个 for 里有三个指针:i 指左段、j 指右段、k 指写回的位置。每比较一次,较小的写回 A[k],它所在那一段的指针前进一步。
B[i] <= B[j]的等号让相等时先取左段的——左段的元素原来就在前面。归并排序稳定靠的就是这个等号。- 两个 while 把没取完的那一段原样抄到末尾,不再比较。两个 while 实际只会执行其中一个:另一段已经取完了。
- B 开成全局数组(或一次性 malloc),免得每次递归都申请空间;它的大小是 n,这就是归并排序 O(n) 辅助空间的来源。
分治图:先对半分,再两两合
跟我做一遍:最后一次归并,数比较次数
| 比较 | B[i] vs B[j] | 取谁 | A 已得到 |
|---|---|---|---|
| 1 | 38 vs 13 | 13(右) | 13 |
| 2 | 38 vs 27 | 27(右) | 13 27 |
| 3 | 38 vs 49* | 38(左) | 13 27 38 |
| 4 | 49 vs 49* | 相等取左边的 49 ⇒ 稳定 | 13 27 38 49 |
| 5 | 65 vs 49* | 49*(右) | … 49 49* |
| 6 | 65 vs 76 | 65(左) | … 49* 65 |
| 7 | 97 vs 76 | 76(右);右表取完 | … 65 76 |
| — | 左表剩 97 | 直接抄过去,不再比较 | 13 27 38 49 49* 65 76 97 |
共 7 次 = 4 + 4 − 1,是最多的情形(两个表交替取,直到最后才有一个表取完)。若左表是 [1 2 3]、右表是 [4 5 6],只比 3 次左表就取完了;左表 [1 3 5]、右表 [2 4 6] 则要比 5 次。
有的资料说“归并排序的比较次数与初始序列无关”,这是错的:上面的例子就说明,每次 Merge 比几次取决于两段的元素怎样交错。程序穷举 8 个元素的全部 40320 种排列:比较次数最少 12 次(已有序时,每次 Merge 都是左表先取完)、最多 17 次。与初始序列无关的是时间复杂度 O(n log n)、趟数 ⌈log₂n⌉ 和移动次数(本书的 Merge 每次都把整段复制到 B 再写回,n = 8 时恒为 48 次)。
12 和 17 是怎么来的(n = 2ᵏ):两段各 m 个元素归并,最少比 m 次(一段先全部取完),最多比 2m − 1 次(交替着取,直到最后一个)。第 j 趟有 n/2ʲ 次归并,每次两段各 2ʲ⁻¹ 个元素:
“第 i 趟”按自底向上算
递归的 MergeSort 是“先分到底、再一层层合”,合并的先后是深度优先的;而 408 题目说“二路归并排序第 i 趟后”,指的是自底向上:第 1 趟把相邻的、长度为 1 的段两两合并,第 2 趟合并长度为 2 的段……n 是 2 的幂时,两种看法每一层的结果相同(本篇的例子就是);n 不是 2 的幂时就不同了:
拓展 按趟做的归并可以直接写成两层循环,不用递归:
void MergeSortBU(int A[], int n) { // 自底向上(非递归)的二路归并 for (int len = 1; len < n; len *= 2) // 本趟开始时,有序段长 len for (int low = 0; low + len < n; low += 2 * len) { // 还有“右段”才需要合并 int mid = low + len - 1; // 左段 A[low..mid] int high = low + 2 * len - 1; // 右段 A[mid+1..high] if (high > n - 1) high = n - 1; // 最后一段可能不满 Merge(A, low, mid, high); } }
外层循环每转一次就是一“趟”,共 ⌈log₂n⌉ 趟;末尾凑不成对的那一段(例如 n = 5 时第 1 趟的第 5 个元素)原样留到下一趟。它不用递归栈,但仍要 O(n) 的数组 B。代码已与库函数排序随机对拍 3 万组。
拓展 链表上的归并排序不需要辅助数组:合并两条有序链表只改 next 指针(第 2 篇的链表合并),找中点用快慢指针,所以它是给链表排序的首选——O(n log n)、稳定,额外空间只有递归栈。
LNode *MergeList(LNode *a, LNode *b) { // 合并两个递增的无头结点链表
LNode head, *r = &head; // 临时头结点,r 指向结果链表的表尾
while (a != NULL && b != NULL) {
if (a->data <= b->data) { r->next = a; a = a->next; } // 相等先取 a:稳定
else { r->next = b; b = b->next; }
r = r->next;
}
r->next = (a != NULL) ? a : b; // 剩下的一段整条接上,不用逐个搬
return head.next;
}
LNode *ListMergeSort(LNode *L) { // L 无头结点;返回排好序的链表
if (L == NULL || L->next == NULL) return L;
LNode *slow = L, *fast = L->next; // 快慢指针:fast 走两步,slow 走一步
while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; }
LNode *right = slow->next; slow->next = NULL; // 从中点断成两条
return MergeList(ListMergeSort(L), ListMergeSort(right));
}
两个细节:① fast 从 L->next 出发,保证只有 2 个结点时 slow 停在第 1 个,能断成 1 + 1;若从 L 出发,2 个结点时 slow 会停在第 2 个,断成 2 + 0,递归永远停不下来。② 合并的“剩下一段整条接上”,在顺序表里要逐个复制,在链表里一句赋值就够了。(代码已随机对拍 2 万组。)
- 对 20 个元素做二路归并排序,需要几趟?
答案
有序段长度 1 → 2 → 4 → 8 → 16 → 20,共 ⌈log₂20⌉ = 5 趟。 - 用本书的 Merge 合并 [2, 5, 8] 和 [1, 9],一共比较几次?
答案
1 vs 2 取 1;2 vs 9 取 2;5 vs 9 取 5;8 vs 9 取 8,左表取完,9 直接抄过去。共 4 次。 - 对 5, 2, 4, 6, 1 做二路归并排序,第 2 趟后的序列是什么?
答案
按自底向上算:第 1 趟 [2 5] [4 6] [1];第 2 趟 [2 4 5 6] [1],即 2, 4, 5, 6, 1;第 3 趟才全部有序(⌈log₂5⌉ = 3 趟)。(程序核对。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 4
- B. 5
- C. 6
- D. 7
展开解析
C先归并 (1, 4) 和 (2, 8):1:2 取 1;4:2 取 2;4:8 取 4;剩 8 直接放入,3 次,得 (1, 2, 4, 8)。再与 (3) 归并:1:3、2:3、4:3,取出 3 后 (3) 已空,4、8 直接放入,3 次。共 6 次。
变化点:归并两个表的比较次数取决于哪个表先取完:一个表取完后,另一个表剩下的元素不用再比。原题 (3, 5)、(7, 9)、(6):第一次 2 次、第二次 3 次,共 5 次。
- A. 4
- B. 5
- C. 6
- D. 7
展开解析
D每趟归并后有序段长度翻倍:1, 2, 4, 8, 16, 32, 64, 128。长度达到 ≥ 100 需要 ⌈log2100⌉ = 7 趟。
变化点:k 路归并的趟数为 ⌈logkn⌉。二路归并每趟 O(n),共 O(nlog2n),且与初始序列无关;辅助空间 O(n),稳定。
- A. 元素的移动次数与初始序列无关
- B. 关键字的比较次数与初始序列无关
- C. 每一趟结束后至少有一个元素到达最终位置
- D. 空间复杂度为 O(1)
8.8基数排序
基数排序不比较关键字,而是借助“多关键字排序”的思想:把关键字的每一位看成一个关键字。例如三位十进制数,个位、十位、百位分别是三个关键字,百位最主要。
- 最低位优先(LSD):先按最低位(个位)排,再按十位,最后按最高位。每一趟包含分配(按当前位的值 0 ~ 9 放进 10 个队列)和收集(按 0 ~ 9 的顺序把队列首尾相接)。
- 最高位优先(MSD):先按最高位分成若干子序列,再对每个子序列按次高位递归地排。
| 趟 | 按哪一位 | 收集后的序列 |
|---|---|---|
| 初始 | 278 109 063 930 589 184 505 269 008 083 | |
| 1 | 个位 | 930 063 083 184 505 278 008 109 589 269 |
| 2 | 十位 | 505 008 109 930 063 269 278 083 184 589 |
| 3 | 百位 | 008 063 083 109 184 269 278 505 589 930 |
第 1 趟:分配到 10 个队列,再收集
另一种做法:最高位优先(MSD)
MSD 像整理扑克牌:先按花色分成 4 堆,再在每一堆里按点数排。对同样 10 个数:
| 步骤 | 结果 |
|---|---|
| 按百位分堆 | 0:063 008 083 1:109 184 2:278 269 5:589 505 9:930 |
| 每堆内部再按十位分 | 0 堆 → 008 | 063 | 083;1 堆 → 109 | 184;2 堆 → 269 | 278;5 堆 → 505 | 589;9 堆只有 1 个,不用再分 |
| 各堆都只剩 1 个元素,按堆的次序连起来 | 008 063 083 109 184 269 278 505 589 930 |
| LSD(最低位优先,408 默认) | MSD(最高位优先) | |
|---|---|---|
| 每趟处理 | 整个序列 | 每一堆各自递归 |
| 对每趟的要求 | 分配必须稳定,否则前几趟白排 | 堆与堆之间已分好,不依赖稳定性 |
| 能否提前结束 | 不能,必须做满 d 趟 | 某堆只剩 1 个元素就不用再分 |
为什么先排最低位,最后反而全对了
直觉上应该先按最重要的百位排。LSD 反其道而行,靠的是每一趟都稳定。看两位数 23, 21, 13, 11:
- 第 1 趟按个位:21, 11, 23, 13——个位为 1 的在前,为 3 的在后。
- 第 2 趟按十位:十位为 1 的 11、13 进同一个队列,按第 1 趟的次序进队(11 先、13 后),出队时仍是 11、13;同理 21、23。结果 11, 13, 21, 23。
- 道理:最后一趟决定了“十位不同”的元素谁前谁后;十位相同的元素之间的次序,由前一趟(个位)决定,而稳定性保证这个次序不被破坏。d 趟以后,元素先按最高位排、高位相同再看次高位……正好是从小到大。
- 反例:若第 2 趟把同一个队列里的元素倒过来收集(不稳定),得到 13, 11, 23, 21——十位对了,个位全乱。
拓展 基数 r 怎么选:同样的关键字可以按不同的“位”拆。10⁶ 个 0 ~ 999999 的整数:按十进制每位一趟,r = 10、d = 6,约 6 × (10⁶ + 10) 次操作;每两位当一“位”,r = 100、d = 3,约 3 × (10⁶ + 100) 次;每三位当一“位”,r = 1000、d = 2,约 2 × (10⁶ + 1000) 次。r 变大,趟数 d 变少,但要准备的队列变多——只要 r 远小于 n,取大一点的 r 更划算。O(d(n + r)) 这个式子把这笔账算得清清楚楚。
拓展 位数不同的关键字(如 8 和 930)在前面补 0,看成 008、930;有负数时可以先给所有数加上同一个偏移量变成非负数,排完再减回去。这些处理都不影响 O(d(n + r)) 的结论。(两个例子由程序核对。)
计数排序 拓展
2021-42 考了一种“比较计数排序”:对每个元素,数出比它小的元素个数 cnt,它就应该放在 b[cnt] 处。它要把所有元素两两比较,比较次数 n(n − 1)/2,时间 O(n²),空间 O(n);两个元素相等时计数的规则决定了是否稳定(见第 9 篇 t7)。下面是另一种不比较的计数排序。
不比较的计数排序(值域为 0 ~ k):先数每个值出现几次,再求“前缀和”得到每个值在结果中的最后位置,最后从后往前把元素放进去。
void CountSort(int A[], int out[], int n, int k) { // A 的值都在 0..k 之间 int C[MAXN] = {0}; // C[v]:值 v 的计数(MAXN > k) for (int i = 0; i < n; i++) C[A[i]]++; // ① 统计每个值出现几次 for (int v = 1; v <= k; v++) // ② 前缀和:C[v] = 不超过 v 的元素个数 C[v] += C[v - 1]; for (int i = n - 1; i >= 0; i--) // ③ 从后往前放,相等元素保持原次序 out[--C[A[i]]] = A[i]; }
- 计数:C = [1, 2, 1, 2, 1](0 出现 1 次,1 出现 2 次……)。
- 前缀和:C = [1, 3, 4, 6, 7],意思是“值 ≤ 1 的有 3 个”,所以最后一个 1 应放在下标 2。
- 从后往前放:2 → C[2] 减为 3,放 out[3];3* → out[5];0 → out[0];1* → out[2];3 → out[4];1 → out[1];4 → out[6]。
- 结果 0, 1, 1*, 2, 3, 3*, 4:1 在 1* 前、3 在 3* 前,稳定。时间 O(n + k),空间 O(n + k)。
如果第 ③ 步改成从前往后放,相等元素的次序会反过来(不稳定)。基数排序的每一趟其实就是“按一位做一次计数排序”(或用 r 个队列),所以它要求每趟稳定。代码与本例都已编译运行核对。
拓展 桶排序和它们是什么关系:把值域分成若干个区间(“桶”),每个元素按值放进对应的桶,各桶内部再排好序,最后按桶的次序依次连接。三者是一家人——计数排序是“每个值一个桶”的极端情形,桶里不用再排;基数排序的每一趟是“按某一位的值分成 r 个桶”的一次桶排序(桶内保持进入次序,不用再排);一般的桶排序在数据大致均匀分布时平均 O(n),但若大部分元素挤进同一个桶,就退化成桶内排序的复杂度。它们都不靠比较大小决定次序,所以不受 O(n log n) 下界的限制。
- 对 36, 15, 70, 22, 41, 8 做 LSD 基数排序,第 1 趟(按个位)后的序列是什么?
答案
个位:0 号 70;1 号 41;2 号 22;5 号 15;6 号 36;8 号 08。收集得 70, 41, 22, 15, 36, 8。 - 为什么 LSD 基数排序要求每一趟的分配是稳定的?
答案
后一趟按更高位排,高位相同的元素必须保持前一趟按低位排好的次序,否则前面几趟的结果就被打乱了。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 505, 008, 109, 930, 063, 269, 278, 083, 184, 589
- B. 930, 063, 083, 184, 505, 278, 008, 109, 589, 269
- C. 008, 063, 083, 109, 184, 269, 278, 505, 589, 930
- D. 505, 008, 109, 930, 269, 063, 278, 083, 184, 589
展开解析
A第 1 趟按个位:930 | 063, 083 | 184 | 505 | 278, 008 | 109, 589, 269(即 B)。第 2 趟按十位,在第 1 趟结果的基础上按顺序分配:0 位 505, 008, 109;3 位 930;6 位 063, 269;7 位 278;8 位 083, 184, 589。收集:505, 008, 109, 930, 063, 269, 278, 083, 184, 589。
变化点:D 把十位同为 6 的 063 与 269 次序弄反了——同一个桶里要保持上一趟的相对次序(063 在 269 之前),这正是 LSD 基数排序依赖稳定分配的原因。
- A. 202, 15
- B. 81, 15
- C. 202, 46
- D. 46, 67
展开解析
A按个位分配:0:330;1:121, 81;2:202;4:54;5:15;6:46;7:67;8:38;9:9。收集:330, 121, 81, 202, 54, 15, 46, 67, 38, 9。54 前是 202,后是 15。
变化点:位数不同的数,高位补 0 看待(9 看成 009)。第 1 趟只看个位,桶 3 为空就跳过。
8.9各种内部排序的比较与应用
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定 | 每趟定一个最终位置? | 链表可用? |
|---|---|---|---|---|---|---|---|
| 直接插入 | O(n) | O(n²) | O(n²) | O(1) | ✓ | 否 | ✓ |
| 折半插入 | O(n log n)* | O(n²) | O(n²) | O(1) | ✓ | 否 | ✗ |
| 希尔 | 约 O(n1.3),最坏 O(n²) | O(1) | ✗ | 否 | ✗ | ||
| 冒泡 | O(n) | O(n²) | O(n²) | O(1) | ✓ | 是 | ✓ |
| 快速 | O(n log n) | O(n log n) | O(n²) | O(log n) ~ O(n) | ✗ | 是(枢轴) | ✗ |
| 简单选择 | O(n²) | O(n²) | O(n²) | O(1) | ✗ | 是 | ✓ |
| 堆 | O(n log n) | O(n log n) | O(n log n) | O(1) | ✗ | 是 | ✗ |
| 二路归并 | O(n log n) | O(n log n) | O(n log n) | O(n) | ✓ | 否 | ✓ |
| 基数 | O(d(n + r)) | O(r) | ✓ | 否 | ✓ | ||
* 折半插入的“最好”指比较次数 O(n log n);移动次数仍与直接插入相同。
- 不稳定的:希尔、快速、简单选择、堆——“快些选堆”(快速、希尔、选择、堆)(2023-10)。其余(插入、冒泡、归并、基数)稳定。
- 每趟至少确定一个元素最终位置的:冒泡、快速、简单选择、堆(2012-10)。插入、希尔、归并、基数不能。
- 比较次数与初始序列无关的:简单选择(总是 n(n−1)/2)、基数(不比较)。趟数与初始序列无关的:直接插入、折半插入(都是 n − 1 趟)、希尔、简单选择、堆、归并、基数。移动次数与初始序列无关的:基数(2015-09)、二路归并(每次整段复制再写回)。详见下面的对照表。
- 最坏情况下移动次数最少:简单选择,最多 3(n − 1) 次(2025-10)。
- 平均最快:快速;最坏也是 O(n log n):堆、归并;O(n log n) 且稳定:只有归并。
- 空间 O(1) 且 O(n log n):堆。
- 只能用于顺序表的:折半插入、希尔、快速、堆(换成链表后效率降低,2017-11)。
三句口诀别混:“快些选堆”问的是稳不稳定(远距离交换、分组的不稳定);“冒快选堆”问的是每趟是否定下一个最终位置(每趟“选出”或“放好”一个元素的才行);“选堆归基”问的是时间是否与初态无关(不管数据什么样都按固定步骤走完的)。三句里都有“选、堆”,是因为简单选择和堆排序这两种“选择类”排序恰好三条都占:不稳定、每趟定一个、时间与初态无关。
与初始序列的关系:比较、移动、趟数分开看
选择题常问“某某与初始序列无关”。必须把“比较次数”“移动次数”“趟数”“时间复杂度”分开记。下表由程序对 6 个、7 个元素的全部排列逐一运行本书代码统计得出(“无关”表示所有排列下这个数都一样):
| 算法 | 比较次数 | 移动次数 | 趟数 | 时间复杂度 |
|---|---|---|---|---|
| 直接插入 | 有关 | 有关 | 无关(n − 1) | 有关 |
| 折半插入 | 有关(变化很小) | 有关 | 无关(n − 1) | 有关 |
| 希尔 | 有关 | 有关 | 无关(由增量序列定) | 有关 |
| 冒泡(带 flag) | 有关 | 有关 | 有关 | 有关 |
| 快速 | 有关 | 有关 | 有关(递归深度) | 有关 |
| 简单选择 | 无关(n(n − 1)/2) | 有关 | 无关(n − 1) | 无关 O(n²) |
| 堆 | 有关 | 有关 | 无关(n − 1) | 无关 O(n log n) |
| 二路归并 | 有关 | 无关 | 无关(⌈log₂n⌉) | 无关 O(n log n) |
| 基数 | 不比较 | 无关 | 无关(d) | 无关 O(d(n + r)) |
记法:时间与初态无关的只有“选、堆、归、基”(简单选择、堆、归并、基数);其中简单选择连比较次数都固定,归并和基数连移动次数都固定。2015-09 问“移动次数与初始排列无关”,选项里只有基数排序。
三种“特殊输入”下实测比较次数(n = 8,按本书各段代码计数):
| 输入 | 直接插入 | 折半插入 | 希尔 | 冒泡 | 快速 | 简单选择 | 堆 | 归并 |
|---|---|---|---|---|---|---|---|---|
| 已有序 1 ~ 8 | 7 | 17 | 17 | 7 | 28 | 28 | 27 | 12 |
| 逆序 8 ~ 1 | 35 | 13 | 34 | 28 | 32 | 28 | 24 | 12 |
| 全部相等 | 7 | 17 | 17 | 7 | 28 | 28 | 18 | 12 |
- 有序、全相等:直接插入和冒泡只比 n − 1 = 7 次,是最好情况;快速排序却退化到 n(n − 1)/2 = 28 次——“基本有序别用快排”就是这张表的第一行。
- 逆序:直接插入最惨(35 = (n + 2)(n − 1)/2);折半插入反而比有序时少(13 对 17):相等时往右走,有序输入每次都要一路走到最右边。
- 简单选择三行都是 28,归并三行都是 12(这三种输入恰好让每次归并都是“一段先取完”),堆的变化不大——呼应“选、堆、归的时间与初态无关”。
表中 24 个数字由程序按讲义代码实际运行计数。
怎样选排序方法(2019-07:要考虑规模、存储方式、稳定性、初始状态)
- n 较小(≤ 50 左右):直接插入或简单选择;元素本身信息量大(移动代价高)时用简单选择。
- 初始基本有序:直接插入或冒泡(接近 O(n));此时快速排序反而最慢。
- n 较大:快速、堆、归并。要求稳定用归并;要求 O(1) 空间用堆;平均最快用快速。
- n 很大、关键字位数少:基数排序。
- 数据量大到内存放不下:外部排序(归并),2016-11 对 10TB 数据排序。
- 只需要前 k 个最大 / 最小:堆(或快速选择)。
跟我做一遍:按场景选算法
| 场景 | 关键条件 | 选 |
|---|---|---|
| 已排好的成绩表后面又追加了 10 个新成绩 | 基本有序 | 直接插入(接近 O(n)) |
| 单片机上排 10 万个整数,额外内存只有几十字节,最坏也不能太慢 | O(1) 空间 + 最坏 O(n log n) | 堆排序 |
| 10 万名考生按总分排名,同分按报名先后 | 要稳定;分数是 0 ~ 750 的整数 | 基数(或计数)排序;只许用比较排序时选归并 |
| 50 条记录,每条 1 KB,移动代价很大 | n 小、移动贵 | 简单选择(最多 n − 1 次交换);或只排“下标 / 指针” |
| 从 1 亿条日志中找响应时间最长的 100 条 | 只要前 k 个 | 大小为 100 的小根堆,O(n log 100) |
| 对 10 TB 数据排序,内存 64 GB | 放不进内存 | 外部排序:置换—选择生成初始段 + 败者树多路归并 |
| 一般的大量随机数据,不要求稳定,平均越快越好 | 平均性能 | 快速排序(三数取中防退化) |
思路顺序:① 能不能放进内存 → ② 规模大小 → ③ 是否基本有序 → ④ 是否要稳定 → ⑤ 空间限制 → ⑥ 关键字特点(值域小、位数少就考虑基数)。
真题怎么考:“给出某趟结果,判断是哪种算法”的四步
- 先排好序,逐位对照:把最终有序序列写在旁边,标出“已在最终位置”的元素。简单选择、冒泡、堆每趟至少确定 1 个,快排每个子表确定 1 个;一个都没有的,基本可以排除这四种。
- 看前缀、后缀:前 i + 1 个相对有序但不在最终位置 ⇒ 插入(2009-10);前 i 个是最小的 i 个 ⇒ 选择或冒泡;后 i 个是最大的 i 个、前面仍是堆 ⇒ 堆排序。
- 看间隔:每隔 d 个取出的子序列都有序 ⇒ 希尔(2014-10、2018-10、2025-11);每 2ⁱ 个一组各自有序 ⇒ 二路归并。
- 看位:按个位(或最低几位)有序 ⇒ 基数排序(2013-11、2021-10)。
方法什么时候会失灵:① 题目问“可能是”时,同一个序列可能同时符合两种算法的特征,要把选项逐个验证,不能看到一个特征就停;② 冒泡有“从前往后”“从后往前”两个方向,快排有 408 对“趟”的特殊定义(对所有未定位元素处理一遍),都按题目的约定来;③ 希尔的增量不一定是 n/2, n/4…,要从小到大逐个试;④ 归并的“趟”按自底向上算(8.7);⑤ 序列很短时,“已在最终位置”的元素可能纯属巧合,必须结合算法过程验证。
- 10 万条记录已经基本按关键字有序,要求排序稳定,选哪种方法?
答案
直接插入排序:基本有序时接近 O(n),而且稳定。快速排序此时反而最慢。 - 要求辅助空间 O(1)、最坏情况 O(n log n),选哪种?若还要求稳定呢?
答案
只要求前两条选堆排序;再加上“稳定”,基于比较的排序里没有同时满足三条的——归并稳定且最坏 O(n log n),但要 O(n) 辅助空间;直接插入稳定、O(1),但最坏 O(n²)。(基数排序不比较关键字,只适用于能按位拆分的关键字,另当别论。)
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
Ⅰ.直接插入排序 Ⅱ.冒泡排序 Ⅲ.希尔排序 Ⅳ.二路归并排序
- A. 仅 Ⅰ、Ⅲ
- B. 仅 Ⅱ、Ⅳ
- C. 仅 Ⅲ、Ⅳ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
展开解析
D冒泡排序每趟把一个最大(小)元素送到最终位置。直接插入排序前面有序的部分可能被后面更小的元素插进来而整体后移;希尔排序、归并排序每趟只是局部有序。所以Ⅰ、Ⅲ、Ⅳ都不一定。
变化点:每趟能确定一个元素最终位置的:简单选择、冒泡、快速(枢轴)、堆排序(原题答案Ⅰ、Ⅲ、Ⅳ 即选择、快速、堆)。
Ⅰ.折半插入排序 Ⅱ.简单选择排序 Ⅲ.冒泡排序 Ⅳ.基数排序 Ⅴ.堆排序
- A. 仅 Ⅰ、Ⅲ
- B. 仅 Ⅱ、Ⅳ、Ⅴ
- C. 仅 Ⅲ、Ⅳ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
展开解析
D折半插入排序在找插入位置时遇到相等元素继续向右找,插在相等元素之后,稳定。冒泡只交换相邻的逆序对,稳定。基数排序依赖稳定分配,稳定。简单选择排序的交换可能把前面的元素换到相等元素之后(如 2, 2′, 1),不稳定;堆排序不稳定。
变化点:口诀:“快(快速)些(希尔)选(选择)一堆(堆)”不稳定,其余(插入、折半插入、冒泡、归并、基数)稳定。
- A. 快速排序
- B. 堆排序
- C. 归并排序
- D. 希尔排序
展开解析
C快速排序最坏 O(n2) 且不稳定;堆排序最坏 O(nlog2n) 但不稳定;希尔排序不稳定。归并排序最好、最坏都是 O(nlog2n) 且稳定,代价是 O(n) 的辅助空间。
变化点:若要求改成“空间 O(1) 且最坏 O(nlog2n)”,答案就是堆排序。选算法时逐条对照时间(平均/最坏)、空间、稳定性三项。
8.10外部排序
文件太大,内存放不下,只能把数据分批读进内存处理。外存(磁盘)以块为单位读写,一次读写的时间远大于内存中排序的时间,所以外部排序的目标是减少读写磁盘的次数。外部排序通常采用归并排序:
- 生成初始归并段:每次读入内存能容纳的一部分数据,在内存中排好序(内部排序),写回外存,得到一个个有序的子文件,叫初始归并段(顺串)。
- 多路归并:对这些归并段逐趟进行 k 路归并,每趟把 k 个段合成一个,直到整个文件有序。归并时内存中为每个段设一个输入缓冲区,另设一个输出缓冲区。
为什么外部排序几乎都用归并
磁盘按块读写,而且顺序读写比跳来跳去快得多。归并恰好只需要顺序访问:每个归并段从头读到尾,输出也从头写到尾;内存里只要给每个段留一块缓冲区,文件再大也能做。快速排序、堆排序要在整个数组里来回跳着访问,数据在磁盘上时每跳一次都可能多读一块,代价极高。所以外部排序 = 先用内部排序做出若干有序段,再用多路归并把它们合起来。优化的目标始终是“少读写几遍文件”:每多一趟归并,整个文件就要多读一遍、多写一遍。
跟我做一遍:数读写磁盘的次数
- 块数 = 6000 / 100 = 60 块。每次读入 600 个记录排好写回,得到 r = 6000 / 600 = 10 个初始归并段。
- 归并趟数 S = ⌈log₄10⌉ = 2(10 段 → 3 段 → 1 段)。
- 生成初始段:读 60 块、写 60 块;每趟归并:又读 60、写 60。总计 2 × 60 × (2 + 1) = 360 次。
- 对比 3 路:⌈log₃10⌉ = 3 趟,2 × 60 × 4 = 480 次——k 越大,趟数越少,读写越少。
两种问法要分清:问“整个外部排序”的读写次数要算生成初始段那一遍(本例 360);问“归并过程中”的读写次数只算 S 趟(本例 2 × 60 × 2 = 240)。缓冲区:k 路归并至少要 k 个输入缓冲区 + 1 个输出缓冲区;若要让读写与内部归并并行,则要 2k 个输入、2 个输出缓冲区(拓展)。数字由程序复核。
减少趟数有两个办法:增大 k(多路归并)、减少 r(让初始归并段更长)。分别对应下面的败者树和置换—选择排序。
- k 路归并至少要 k 个输入缓冲区 + 1 个输出缓冲区:k + 1 ≤ 16 ⇒ k ≤ 15,趟数 ⌈log₁₅100⌉ = 2(15² = 225 ≥ 100)。
- 若要让读写磁盘和内存归并同时进行,每个缓冲区都要配一个“备用”的(一个在装数据、一个在被使用):2k + 2 ≤ 16 ⇒ k ≤ 7,趟数 ⌈log₇100⌉ = 3(7² = 49 < 100 ≤ 343)。
- 对比 2 路归并要 ⌈log₂100⌉ = 7 趟。可见缓冲区个数直接限制了 k,k 又决定了趟数。拓展 第 2 步的“双缓冲”是选择题偶尔会问的细节,知道 2k 个输入、2 个输出即可。
趟数由程序计算核对。
多路平衡归并与败者树
k 路归并时,每输出一个元素,都要从 k 个段的当前元素中选出最小的。直接比较要 k − 1 次(k = 16 时 15 次),k 越大内部比较越多,会抵消减少趟数带来的好处。败者树让每次选最小只需 ⌈log₂k⌉ 次比较:
- 它是一棵完全二叉树,k 个叶子分别存放 k 个归并段当前参加比较的元素;
- 内部结点记录左右子树比较的失败者(大的那个)所在的段号,让胜者(小的)继续向上比较;
- 根的上面再加一个结点 ls[0],记录冠军——当前最小关键字所在的归并段号(2024-11)。
- 输出冠军后,从该段补进下一个元素,只需沿这个叶子到根的路径重新比较,⌈log₂k⌉ 次。
逐个结点看:ls[4] 处 b3(6) 与 b4(12) 比,败者段号 4 留下,胜者 3 往上;ls[3] 处 b1(9) 与 b2(20) 比,留下败者 2,胜者 1 往上;ls[2] 处段 3(6)与 b0(10) 比,留下败者 0,胜者 3 往上;ls[1] 处段 3(6)与段 1(9)比,留下败者 1;最终胜者 3 记入 ls[0]。输出 6 后,段 3 补进下一个元素,只需重新比较 ls[4]、ls[2]、ls[1] 三处。
败者树:重赛只走一条路径
输出冠军(段 3 的 6)后,段 3 补进下一个元素,假设是 15。只有它所在叶子到根的路径需要重赛,路径上每个结点存的“败者”正好就是它该遇到的对手:
| 结点 | 原来存的败者 | 比赛 | 谁留下(新败者) | 谁往上走 |
|---|---|---|---|---|
| ls[4] | 段 4(12) | 15 vs 12 | 段 3(15 输了) | 段 4(12) |
| ls[2] | 段 0(10) | 12 vs 10 | 段 4 | 段 0(10) |
| ls[1] | 段 1(9) | 10 vs 9 | 段 0 | 段 1(9) |
| ls[0] | 冠军 = 段 1,输出 9;共比较 3 次 | |||
接着段 1 补进 18:它的叶子在较浅的一层,只经过 ls[3](段 2,20:18 胜)和 ls[1](段 0,10:10 胜),比较 2 次,冠军变为段 0,输出 10。所以准确地说,每次重赛最多比较 ⌈log₂k⌉ 次(k 不是 2 的幂时,有的叶子更浅)。
int b[K + 1]; // b[0..K-1]:各段当前参加比较的元素;b[K] 放一个比所有关键字都小的 MINKEY int ls[K]; // ls[0]:冠军的段号;ls[1..K-1]:各内部结点记录的败者段号 void Adjust(int s) { // 段 s 的当前元素刚换过,沿它到根的路径重赛 int t = (s + K) / 2; // 把叶子 s 看成编号 K + s 的结点,t 是它的双亲 while (t > 0) { if (b[s] > b[ls[t]]) { // s 输了:s 留下当败者,原来存的那一方继续往上 int tmp = s; s = ls[t]; ls[t] = tmp; } t = t / 2; } ls[0] = s; // 最后剩下的胜者就是冠军 } void CreateLoserTree(void) { b[K] = MINKEY; // 虚拟的“最小段” for (int i = 0; i < K; i++) ls[i] = K; // 所有内部结点先都记成虚拟段 for (int i = K - 1; i >= 0; i--) Adjust(i); // 让每个叶子依次参赛 }
建树时先让所有内部结点都存“虚拟段 K”(它最小,谁碰到它都算输、被留下),再从 b[K−1] 到 b[0] 依次调用 Adjust:对图中的 10, 9, 20, 6, 12,最后得到 ls = [3, 1, 0, 2, 4],与上图一致。某段取完时,在它的位置放一个“无穷大”,冠军变成无穷大就说明全部归并完了。代码已编译,对随机 2 ~ 16 路归并共 2 万组与排序结果对拍。
先把 ls[0..4] 全设成 5(虚拟段,关键字 MINKEY 比谁都小)。叶子 i 的双亲是 ⌊(i + 5)/2⌋:叶子 4、3 挂在 ls[4] 下,叶子 2、1 挂在 ls[3] 下,叶子 0 挂在 ls[2] 下。
| 调用 | 沿途的比赛 | 之后 ls[0..4] |
|---|---|---|
| Adjust(4),12 | ls[4]:12 对虚拟段,12 输,留下段 4;虚拟段继续往上,一路都是“虚拟段对虚拟段” | 5 5 5 5 4 |
| Adjust(3),6 | ls[4]:6 对段 4(12),6 胜;ls[2]:6 对虚拟段,6 输,留下段 3 | 5 5 3 5 4 |
| Adjust(2),20 | ls[3]:20 对虚拟段,20 输,留下段 2 | 5 5 3 2 4 |
| Adjust(1),9 | ls[3]:9 对段 2(20),9 胜;ls[1]:9 对虚拟段,9 输,留下段 1 | 5 1 3 2 4 |
| Adjust(0),10 | ls[2]:10 对段 3(6),10 输,留下段 0、段 3 往上;ls[1]:6 对段 1(9),6 胜;冠军段 3 | 3 1 0 2 4 |
虚拟段的作用:在真正的对手到来之前“占位”。它比谁都小,所以先到的叶子一碰到它就输、被留下当败者,等后来的叶子来和它比;最后一个叶子参赛完,所有虚拟段都被挤出了树。结果与图 8-17 一致。(每一步由程序按讲义代码模拟。)
胜者树的内部结点存的是胜者。冠军换了新元素后重赛,每一层都要去找兄弟结点的胜者来比;而败者树在路径上的每个结点里存的恰好就是“当初打败过谁 / 被谁打败”的那个对手,只需和双亲结点里存的比,不用访问兄弟。两者比较次数相同,但败者树访问的结点少、代码更简单。
两者都能“每次选出 k 个里最小的”,都是完全二叉树。区别在换掉最小元素以后:小根堆要从堆顶向下调整,每下一层先比两个孩子选出较小的,再拿它和下沉的元素比,每层 2 次;败者树从叶子向上重赛,每层只和结点里存的那个败者比 1 次。所以 k 路归并用败者树,比较次数大约是用堆的一半;而且败者树的叶子就是 k 个段,内部结点只存段号,结构固定不变,正合外部排序的需要。
总比较次数 = (n − 1)·⌈log₂k⌉·⌈logkr⌉ ≈ (n − 1)⌈log₂r⌉,与 k 无关。所以用败者树后,增大 k 只减少读写外存的次数,内部比较不会增加。但 k 也不能无限大:内存要为 k 个段各开一个输入缓冲区,k 越大每个缓冲区越小,读写块的次数反而变多。
置换—选择排序(置换-选择):生成更长的初始归并段
内存工作区 WA 能容纳 w 个记录。普通方法每个初始段长度最多 w;置换—选择边输出边读入,得到的初始段平均长度约 2w,而且长度可以不等。
- 从输入文件读 w 个记录到 WA。
- 从 WA 中选出关键字最小、且不小于当前段最后输出的关键字 MINIMAX 的记录,输出到当前段,它成为新的 MINIMAX。
- 从输入读下一个记录补进 WA。
- 重复 2、3,直到 WA 中所有记录都比 MINIMAX 小(选不出来),当前段结束;以 WA 中的记录开始下一个段。
展开解析
| WA | 输出(MINIMAX) | 说明 |
|---|---|---|
| 17 21 05 | 05 | 段 1 开始,读入 44 |
| 17 21 44 | 17 | 读入 10(10 < 17,只能留给下一段) |
| 10 21 44 | 21 | 可选的(≥ 17)有 21、44,选 21;读入 12 |
| 10 12 44 | 44 | ≥ 21 的只有 44;读入 56 |
| 10 12 56 | 56 | 读入 32 |
| 10 12 32 | — | 都 < 56,段 1 结束:05 17 21 44 56 |
| 10 12 32 | 10、12、29、32 | 段 2:输出 10,读入 29;再依次输出 12、29、32 |
初始归并段:05, 17, 21, 44, 56 和 10, 12, 29, 32。段 1 长 5 > w = 3。
2023-42 结论:第一个初始归并段的长度最小为 w(输入序列是递减的,WA 中的记录输出后读进来的全都更小),最大为 n(输入已经递增,所有记录都能进同一段)。
段长翻倍能省多少:10000 个记录、工作区 w = 500。普通方法每段 500 个,得 20 段;置换—选择平均每段约 1000 个,约 10 段。做 4 路归并:20 段要 ⌈log₄20⌉ = 3 趟,10 段只要 ⌈log₄10⌉ = 2 趟——少一趟,整个文件就少读一遍、少写一遍。段数减半听起来不多,但趟数是按对数变化的,刚好跨过 4 的幂时就能省下一整趟。
为什么平均是 2w(直观理解):当前段在输出过程中,新读进来的记录大约有一半比 MINIMAX 大,可以“搭上”当前这一段;所以一段除了开始时工作区里的 w 个,还能再吸收大约 w 个后来者。像下雪天的扫雪车:车一边扫、雪一边下,车道上能扫到的雪大约是车厢容量的 2 倍。程序对随机输入模拟,平均段长约为 1.99w。
工作区 WA 很大时,“选出 ≥ MINIMAX 的最小记录”这一步也用败者树来做(王道教材的实现):每次选择从逐个比较的 O(w) 降到 O(log w)。所以败者树在外部排序里出现两次——生成初始段时选记录、多路归并时选冠军。
最佳归并树
置换—选择得到的初始段长度不等。归并时每个记录被读写的次数等于它所在的段在归并树中的深度,所以读写总量 = 2 × 归并树的 WPL(以段长为权)。要让它最小,就按哈夫曼树的方法构造 k 叉归并树:每次选最短的 k 个段归并——这叫最佳归并树。
展开解析
(9 − 1) % (3 − 1) = 0,不用补虚段。按 3 叉哈夫曼树合并:2 + 3 + 6 = 11;9 + 11 + 12 = 32;17 + 18 + 24 = 59;30 + 32 + 59 = 121。WPL = 11 + 32 + 59 + 121 = 223,读写次数 = 2 × 223 = 446。
省了多少:同样这 9 个段,如果不挑长短、按给出的次序每 3 个一组做平衡归并:(9, 30, 12) → 51、(18, 3, 17) → 38、(2, 6, 24) → 32,再 (51, 38, 32) → 121,WPL = 51 + 38 + 32 + 121 = 242,读写 484 次;最佳归并树只要 446 次。差别来自长段 30、24:平衡归并把它们放在了和短段一样深的位置,每个块都要多读写一遍。(两种 WPL 由程序计算。)
跟我做一遍:段数不够时补虚段
- 检查:(8 − 1) % (3 − 1) = 1 ≠ 0,需补 (3 − 1) − 1 = 1 个长度为 0 的虚段。
- 每次取最小的 3 个合并:(0, 2, 3) → 5;(5, 6, 9) → 20;(12, 17, 18) → 47;(20, 30, 47) → 97。
- WPL = 5 + 20 + 47 + 97 = 169,读写次数 = 2 × 169 = 338。
- 如果不补虚段,照样每次取 3 个,最后一次只剩 2 段:(2, 3, 6) → 11、(9, 11, 12) → 32、(17, 18, 30) → 65、(32, 65) → 97,WPL = 205,更差。虚段的作用是让“只有 2 段的那次合并”发生在最底层,而不是最顶层(权小的放下层才省)。
两种做法的 WPL 都由程序计算核对。
真题怎么考
- 读写次数:先求块数与初始段数 r,再求趟数 ⌈logkr⌉,最后 2 × 块数 × (趟数 + 1);看清问的是“整个外部排序”还是“归并过程”。
- 败者树(2024-11):ls[0] 存的是冠军的段号,不是关键字;内部结点存败者;每次重赛最多比较 ⌈log₂k⌉ 次。
- 置换—选择(2023-42):逐步写出工作区内容和 MINIMAX;当工作区里的记录全都比 MINIMAX 小时,当前段结束;第一段长度最小 w、最大 n。
- 最佳归并树(2019-11):先算要补几个虚段,再像哈夫曼树那样每次取最小的 k 个;读写次数 = 2 × WPL。
- k 与趟数(2016-11、2026-11):文件放不进内存就用归并;k 越大、初始段数越少,趟数越少;k 太大会让每个缓冲区变小,所以不是越大越好。
- 文件 200 块,生成 25 个初始归并段,做 5 路平衡归并。整个外部排序读写磁盘多少次?
答案
S = ⌈log₅25⌉ = 2,总次数 2 × 200 × (2 + 1) = 1200 次(只算归并过程则为 800 次)。 - 12 个初始归并段做 4 路最佳归并,要补几个虚段?
答案
(12 − 1) % (4 − 1) = 2 ≠ 0,补 (4 − 1) − 2 = 1 个。验证:(13 − 1) % 3 = 0 ✓。
选择题变式:真题换个条件再做一遍
每道题都由一道真题改动一两个条件而来。先做本节的真题,再做这里的变式,最后看“变化点”:改了什么、答案为什么跟着变。
- A. 1
- B. 5
- C. 6
- D. 7
展开解析
C严格 8 叉树要求 (n − 1) % (8 − 1) = 0。(100 − 1) % 7 = 99 % 7 = 1 ≠ 0,需补 7 − 1 = 6 个虚段(长度为 0)。验证:(106 − 1) % 7 = 0 ✓。
变化点:原题 120 段、12 路:119 % 11 = 9,补 11 − 9 = 2 个。公式:余数 u ≠ 0 时补 (k − 1) − u 个,与 k 叉哈夫曼树补 0 权叶子完全相同。
- A. 2、3
- B. 2、7
- C. 3、3
- D. 6、7
展开解析
A趟数 = ⌈log864⌉ = 2。败者树是有 8 个叶子的完全二叉树,新元素从叶子沿路径升到根,每层比较 1 次,共 ⌈log28⌉ = 3 次。
变化点:不用败者树时,每次要在 8 个中选最小,比较 7 次(B)。败者树让比较次数与 k 无关地增长为 log2k,于是增大 k 减少趟数时,总比较次数不会随之暴涨。原题考的是败者树根结点(冠军)存的是段号。
- A. 4, 15, 97
- B. 4, 15, 64, 97, 108
- C. 4, 15, 17, 32, 44, 64, 76, 97, 108
- D. 4, 15, 17, 32, 64, 97, 108
展开解析
D工作区 {15, 4, 97}:输出 4,读入 64;输出 15,读入 17;输出 17,读入 32;输出 32,读入 108;输出 64,读入 44(小于刚输出的 64,本段不能用,冻结);输出 97,读入 76(冻结);输出 108,读入 9(冻结)。此时工作区三个都被冻结,第一段结束:4, 15, 17, 32, 64, 97, 108(7 个记录,比工作区大得多)。第二段从 9, 44, 76 开始。
变化点:原题考的是“内存大小限制初始归并段长度”这类说法:置换-选择排序得到的段长可以超过工作区大小(随机输入时平均约为工作区的 2 倍),段越长、段数越少,归并趟数就越少。
8.11本篇总结:一页纸 + 必会题型
排序题看起来五花八门,其实只考四件事。把下面两张表和最后的口诀默写出来,排序的选择题基本就稳了。
表 1:“第 i 趟后”长什么样(判断是哪种算法)
| 算法 | 第 i 趟结束后的特征 | 有元素到最终位置? |
|---|---|---|
| 直接 / 折半插入 | 前 i + 1 个元素相对有序,后面原封不动 | 不一定 |
| 希尔 | 相距 d 的元素各自有序 | 不一定 |
| 冒泡 | 一端的 i 个元素是最小(或最大)的 i 个,且已排好 | 是,每趟 1 个 |
| 快速 | 枢轴左边都 ≤ 它,右边都 ≥ 它 | 是,每个子表 1 个枢轴 |
| 简单选择 | 前 i 个是最小的 i 个,且已排好 | 是,每趟 1 个 |
| 堆排序 | 末尾 i 个是最大的 i 个且已排好,前面仍是一个堆 | 是,每趟 1 个 |
| 二路归并 | 每 2i 个一组各自有序 | 不一定 |
| 基数(LSD) | 按最低的 i 位看是有序的 | 不一定 |
表 2:必会题型清单
| # | 题型 | 方法 | 本书位置 |
|---|---|---|---|
| 1 | 给某趟结果,问是哪种算法 | 对照表 1;先找“已到最终位置”的元素 | 8.2 变式 |
| 2 | 希尔:由一趟结果反推增量 | 从小到大试 d,每组都有序才行 | 8.3 |
| 3 | 快排:第二趟结果的判定、划分结果 | 数到位元素,看分布 | 8.5 |
| 4 | 堆:建堆、插入、删除及比较次数 | 建堆自下而上;插入上浮;删除下沉;两个孩子比 2 次 | 8.6 |
| 5 | 稳定性、复杂度、与初态的关系 | “快些选堆”不稳定;“选堆归基”时间与初态无关 | 8.9 |
| 6 | 场景选算法 | 按规模、初态、稳定性、空间四项排除 | 8.9 |
| 7 | 归并 / 插入的比较次数 | 逐次模拟,表取完就不再比 | 8.7、8.2 |
| 8 | 基数排序某趟结果 | 分配时同一队列保持原次序 | 8.8 |
| 9 | 外部排序:I/O 次数、败者树、置换—选择、最佳归并树 | 2 × 块数 × (S + 1);补虚段 (k − 1) − u(u = (r − 1) % (k − 1),u = 0 时不补) | 8.10 |
| 10 | 算法大题:划分思想、Top-k、计数排序 | 快排 Partition 改写(2016-43);大小为 k 的堆(2022-42) | 8.6、第 9 篇 |
- 不稳定:快些选堆。时间与初态无关:选堆归基。每趟定一个最终位置:冒快选堆。
- 平均最快:快速;最坏 O(n log n):堆、归并;稳定又 O(n log n):只有归并;O(1) 空间又 O(n log n):只有堆。
- 基本有序:插入、冒泡最快,快速最慢。
- 外部排序减少 I/O:增大 k(败者树撑腰)、减少 r(置换—选择,平均 2w)、合理安排归并次序(最佳归并树)。
- 排序类算法大题想不到最优解时:先写出正确的“先排序再处理”O(n log n) 或暴力 O(n²) 解法,并说清瓶颈在哪——按第 9 篇的评分细则,这样仍能拿到大部分分数。
- 对 10 个元素做简单选择排序,比较多少次?
答案
n(n − 1)/2 = 45 次,与初始顺序无关。 - 序列 (15, 9, 7, 8, 20, −1, 4) 经一趟排序后变为 (4, 9, −1, 8, 20, 7, 15),可能是哪种排序?
答案
增量为 3 的希尔排序。相距 3 的三组:位置 0、3、6 是 {15, 8, 4} → 排成 4, 8, 15;位置 1、4 是 {9, 20} 不变;位置 2、5 是 {7, −1} → −1, 7。结果恰为 (4, 9, −1, 8, 20, 7, 15)。检验其他方法:简单选择第一趟应把最小的 −1 换到最前;冒泡第一趟应把 −1 冒到最前或 20 沉到最后;快速排序以 15 为枢轴时,15 应落在第 6 个位置(前面有 5 个比它小)——都不符。 - 用败者树做 8 路归并,每选出一个最小元素需要比较几次?
答案
⌈log₂8⌉ = 3 次(沿叶子到根的路径)。 - 对 1000 个元素用堆排序求最小的 10 个,用大根堆还是小根堆?
答案
用大小为 10 的大根堆保存“当前最小的 10 个”,堆顶是其中最大的;新元素比堆顶小就替换并向下调整。O(n log 10)。(也可以把 1000 个元素建小根堆再删 10 次,O(n + 10 log n)。)
8.✎本篇真题闯关(44 题)
本篇各小节挂的真题全部在这里,按小节排好。可以展开后一道道做,也可以用弹窗模式连续做。做题记录(对/错)只保存在你这台电脑的浏览器里,小节标题下的题号会随之变绿或变红。
大题满分训练营:36 道真题分 8 类,每类都有模板、示范、模拟题和变式
数据结构的两道综合题共 23 分,占这门课的一半。它们看起来五花八门,其实只有 8 种“套路”。这一篇按题型组织:每一类先告诉你这类题长什么样(历年怎么问),再列出必须会的几件事和解题模板,然后用一道真题做完整示范,接着给出自编模拟大题(附满分解答)和举一反三变式表(条件一变,答案怎么变),最后是丢分点。所有代码都在电脑上编译并用大量随机数据对拍过。
9.0训练营导读:大题的分布与答题总策略
下表把 2009—2026 年的 36 道大题按题型和年份排开(红框题号可直接点开做题)。可以看到:第 41 题(或 42 题)几乎年年是算法设计,对象在数组、链表、二叉树、图之间轮换;另一道是应用题,图、查找、排序轮流出。
| 题型 | 典型问法 | 分值 | 建议用时 |
|---|---|---|---|
| ① ~ ④ 算法设计 | “设计一个时间和空间都尽可能高效的算法……(1) 思想 (2) 代码 (3) 复杂度” | 13 ~ 15 | 25 ~ 30 分钟 |
| ⑤ ~ ⑧ 应用题 | 画出存储结构 / 手工执行算法 / 求 ASL、关键路径、最小生成树 / 说明理由、推导公式 | 8 ~ 10 | 15 ~ 20 分钟 |
- 读题翻译:用自己的话写出输入、输出、约束(有序?值域?能否修改原数据?空间要求?)。约束往往就是提示:“有序”→ 双指针 / 折半;“值域 1 ~ n”→ 计数数组;“空间 O(1)”→ 原地逆置、交换、指针技巧。
- 先想暴力解:两重循环或先排序。确认自己至少能拿 60% ~ 70% 的分。
- 再优化一步:想想暴力解哪里重复计算了(逐个删除 → 一趟挪动;每次找最后一个 → 先逆置;两两比较 → 标记数组)。
- 写代码前先在草稿纸上用 3 ~ 5 个元素的小例子走一遍,特别是边界:空表、一个元素、全部相同、k 等于表长。
怎么认出是哪一类:先看题目给的数据是什么——给数组、顺序表 ⇒ ①;给链表结点定义 ⇒ ②;给二叉链表结点 ⇒ ③;给 MGraph / 邻接表定义并要求写代码 ⇒ ④;给一张图让你“求、画、写过程” ⇒ ⑤;给关键字序列和散列函数、查找概率 ⇒ ⑥;给待排序序列、归并段、一段排序代码 ⇒ ⑦;要你“设计结构”“推导公式”“说明理由” ⇒ ⑧。认出类型后翻到对应小节,先读“思路是怎么想出来的”,再看示范和模拟题——每一类的思考套路都不一样,但都遵循上面的四步法。
- 过程比答案值钱:ASL 写出每个关键字的比较次数再求和;关键路径写出 ve、vl、e、l 表;Prim / Dijkstra 写出每一步的表格。只有答案没有过程会被扣分。
- 图要画清楚:散列表画成带地址的表格;树、图画成带标号的图;链式存储画出指针。
- “说明理由”题用一两句话点出原理:如“哈夫曼树的 WPL 最小”“局部最优不等于全局最优”。
9.1题型 ①:数组 / 顺序表上的算法设计
这类题长什么样
| 年份 | 题目 | 最优解的关键 | 最优复杂度 |
|---|---|---|---|
| 2010-42 | 数组循环左移 p 位 | 三次逆置 | O(n)、O(1) |
| 2011-42 | 两个等长升序序列的中位数 | 比较两边中位数,各舍一半 | O(log n)、O(1) |
| 2013-41 | 找主元素(出现次数 > n/2) | 摩尔投票 + 二次确认 | O(n)、O(1) |
| 2016-43 | 划分为元素个数最接近、和相差最大的两个子集 | 快排划分只递归一边(快速选择) | 平均 O(n)、O(1) |
| 2018-41 | 未出现的最小正整数 | 标记数组(值域 1 ~ n) | O(n)、O(n) |
| 2020-41 | 三个升序数组的三元组最小距离 | D = 2(max − min),每次移最小值的指针 | O(n)、O(1) |
| 2025-41 | 每个元素与其后元素乘积的最大值 | 从右往左维护后缀最大、最小值 | O(n)、O(1) |
必须会的几件事
① 原地逆置 / 交换
两个下标从两端向中间走(2.5 基本功①)。循环移位、单词倒序、划分(负数在前)都靠它。
② 双指针扫描有序序列
归并、找公共元素、找最接近的一对:每次移动“较小”的那个指针(基本功③)。
③ 计数 / 标记数组
值域已知(0 ~ n 或 1 ~ n)时,用值当下标,O(1) 判断是否出现、出现几次(基本功⑦)。
④ 快速排序的划分
一次划分 O(n) 把数组按枢轴分成两块;“第 k 小 / 前 k 小”只需递归一边(8.5)。
解题模板
- 判断能不能“一趟扫描”:大多数数组题的最优解都是 O(n)。想清楚扫描时需要维护哪些量(计数器、候选值、最大 / 最小值、两个指针)。
- 判断要不要辅助数组:题目只说“时间尽可能高效”→ 可以开 O(n) 数组;说“时间和空间都尽可能高效”→ 优先 O(1)。
- 写思想:一句话总策略 + 两三句关键步骤 + 一个小例子。
- 写代码:函数头(数组 + 长度)、循环、边界处理、返回值。
- 写复杂度:与代码一致,附半句理由。
思路是怎么想出来的:从暴力到最优
- 先写暴力:两重循环枚举所有的 (i, j),O(n²),6 个数要试 15 对。这一步不是白写——考场上想不出更好的,它就是保底分(按本节末尾的评分细则,正确的两重循环解大约能拿到 13 分里的 10 分)。
- 找浪费:暴力法完全没用上“升序”这个条件。题目给的每个条件都有用,没用上的条件就是突破口。
- 换零件:让 i 从最左、j 从最右出发。和太小,说明 A[i] 跟剩下的任何数配都不够大(A[j] 已经是剩下最大的了)⇒ 扔掉 A[i],i++;和太大,说明 A[j] 跟谁配都太大 ⇒ 扔掉 A[j],j−−。每比较一次就扔掉一个元素,O(n)、O(1)——这正是 2.5 节的“双指针”。
- 走一遍小例子:A = 1, 3, 4, 6, 9, 11,k = 13:1 + 11 = 12 小,i++;3 + 11 = 14 大,j−−;3 + 9 = 12 小,i++;4 + 9 = 13,找到。
- 查边界:i < j 保证同一个元素不被用两次;只有 1 个元素或找不到时返回 false。
bool TwoSum(int A[], int n, int k, int &x, int &y) { // A 升序 int i = 0, j = n - 1; while (i < j) { int s = A[i] + A[j]; if (s == k) { x = A[i]; y = A[j]; return true; } if (s < k) i++; // 和太小:A[i] 和谁配都不够大,扔掉 A[i] else j--; // 和太大:A[j] 和谁配都太大,扔掉 A[j] } return false; }
四步走的套路对所有数组题都适用:暴力 → 找没用上的条件 → 想哪个基本功能利用它 → 小例子 + 边界。(代码与暴力法随机对拍 10 万组;例子由程序跟踪。)
完整示范
已知一个整数序列 A=(a0,a1,…,an−1) ,其中 0≤ai<n(0≤i<n) 。
若存在 ap1=ap2=⋯=apm=x 且 m>n/2(0≤pk<n,1≤k≤m) ,则称 x 为 A 的主元素。
例如 A=(0,5,5,3,5,7,5,5),则 5 为主元素;又如 A=(0,5,5,3,5,1,5,7) ,则 A 中没有主元素。
假设 A 中的 n 个元素保存在一个一维数组中,请设计一个尽可能高效的算法,找出 A 的主元素。若存在主元素,则输出该元素;否则输出 −1 。要求:
(1) 给出算法的基本设计思想。
(2) 根据设计思想,采用 C、C++ 或 Java 语言描述算法,关键之处给出注释。
(3) 说明你所设计算法的时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
- 主元素 = 出现次数超过一半(> n/2)的元素,最多只有一个。
- 0 ≤ aᵢ < n:值的范围已知,可以开计数数组(O(n) 空间);但最优解只要 O(1) 空间。
“抵消”思想(摩尔投票):把不同的两个数成对抵消,主元素比其他所有数加起来还多,抵消到最后剩下的一定是它。但剩下的不一定是主元素(可能本来就没有),所以还要再数一遍确认。
分两步。① 选候选元素:从前往后扫描,用 c 保存候选元素、count 记录它的“净票数”。遇到与 c 相同的数 count 加 1,不同的数 count 减 1;count 减到 0 时,把下一个数设为新的候选、count 置 1。② 确认:再扫描一遍,统计 c 的出现次数,若大于 n/2 则 c 是主元素,否则不存在主元素,输出 −1。
int Majority(int A[], int n) { int c = A[0], count = 1; // c:候选主元素;count:净票数 for (int i = 1; i < n; i++) { if (count == 0) { // 前面的全部抵消完了,换新的候选 c = A[i]; count = 1; } else if (A[i] == c) count++; // 支持候选 else count--; // 与候选抵消 } count = 0; // 第二趟:确认 c 是否真的过半 for (int i = 0; i < n; i++) if (A[i] == c) count++; if (count > n / 2) return c; return -1; }
扫描两遍数组,时间复杂度 O(n);只用了常数个变量,空间复杂度 O(1)。
第二趟的确认不能省:例如 (1, 2, 3),第一趟最后候选是 3,但 3 只出现 1 次。另一种满分附近的做法:因为 0 ≤ aᵢ < n,开计数数组 cnt[n],一趟统计后找 cnt > n/2 的值,时间 O(n)、空间 O(n)(评分表中比最优解少 1 分)。先排序再数是 O(n log n)。
- 找“超过一半”的元素:摩尔投票(抵消)+ 再数一遍确认,O(n)、O(1)。
- 值域已知且不大时,“计数数组”是万能的保底方法。
自编模拟大题
给定含 n(n ≥ 1)个整数的数组 A(可能有负数)。连续子序列 A[i..j] 的和为 A[i] + A[i+1] + … + A[j]。请设计一个时间和空间都尽可能高效的算法,求所有连续子序列中和的最大值(若全为负数,结果是最大的那个元素)。例如 A = {−2, 11, −4, 13, −5, −2},结果为 20(11 − 4 + 13)。要求:(1) 给出算法的基本设计思想;(2) 用 C 或 C++ 写出算法,关键之处给出注释;(3) 说明时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
- 暴力:枚举所有 i、j 求和,O(n²)(用前缀和)或 O(n³)。
- 关键观察:以 A[i] 结尾的最大子段和 cur(i),要么是 A[i] 自己,要么是 cur(i − 1) + A[i]——前面那段和为负数就不要了,“负数只会拖后腿”。
从左到右扫描一遍,用 cur 记录“以当前元素结尾的最大子序列和”,best 记录目前为止的最大值。对 A[i]:若 cur > 0,则 cur = cur + A[i](前面的和有贡献,接上);否则 cur = A[i](前面的和不大于 0,丢弃,从 A[i] 重新开始)。每步用 cur 更新 best。
int MaxSubSum(int A[], int n) { int best = A[0], cur = A[0]; // cur:以当前元素结尾的最大和 for (int i = 1; i < n; i++) { if (cur > 0) cur = cur + A[i]; // 前面的和为正,接上 else cur = A[i]; // 前面的和不为正,从 A[i] 重新开始 if (cur > best) best = cur; // 更新全局最大值 } return best; }
只扫描一遍,时间 O(n);只用两个变量,空间 O(1)。
best 的初值不能取 0:全为负数时答案是负数。
| 评分点 | 分 |
|---|---|
| 思想:说清“以当前元素结尾的最大和”及取舍规则 | 4 |
| 代码正确(O(n) 满分 7;O(n²) 最高 5;O(n³) 最高 4) | 7 |
| 复杂度与代码一致 | 2 |
长度为 n 的整型数组 A,请设计一个时间和空间都尽可能高效的算法,把所有负数移到所有非负数的前面(相对次序可以改变)。要求写出思想、代码和复杂度。
先在纸上自己做一遍,再点这里展开满分解答
借鉴快速排序的划分:i 从左往右找第一个非负数,j 从右往左找第一个负数,若 i < j 则交换两者,继续,直到 i ≥ j。这样 i 左边都是负数,j 右边都是非负数。
void NegFirst(int A[], int n) { int i = 0, j = n - 1; while (i < j) { while (i < j && A[i] < 0) i++; // 从左找非负数 while (i < j && A[j] >= 0) j--; // 从右找负数 if (i < j) { // 交换 int t = A[i]; A[i] = A[j]; A[j] = t; i++; j--; } } }
时间 O(n)(每个元素最多被 i 或 j 扫过一次),空间 O(1)。若要求保持负数之间、非负数之间的相对次序,数组上做不到 O(n)、O(1),可以用 O(n) 辅助数组(先放负数再放非负数)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2010-42 循环左移 → 改成循环右移 p 位 | 思路与代码右移 p 位 = 左移 n − p 位。也可以直接:先整体逆置,再逆置前 p 个、逆置后 n − p 个。 void ConverseRight(int R[], int n, int p) { // 循环右移 p 位 Reverse(R, 0, n - 1); // 整体逆置 Reverse(R, 0, p - 1); // 前 p 个逆置 Reverse(R, p, n - 1); // 后 n-p 个逆置 } |
| 2018-41 未出现的最小正整数 → 要求空间 O(1)(可以修改 A) | 思路与代码“原地归位”:把每个在 1 ~ n 范围内的值 v 交换到下标 v − 1 处;之后第一个 A[i] ≠ i + 1 的位置给出答案。每次交换都让一个值归位,总交换次数 ≤ n,时间 O(n)、空间 O(1)。 int firstMissing(int A[], int n) { for (int i = 0; i < n; i++) while (A[i] > 0 && A[i] <= n && A[A[i] - 1] != A[i]) { // A[i] 应放在下标 A[i]-1 int t = A[A[i] - 1]; A[A[i] - 1] = A[i]; A[i] = t; } for (int i = 0; i < n; i++) if (A[i] != i + 1) return i + 1; return n + 1; } |
| 2011-42 两个等长序列的中位数 → 两个不等长升序序列(长度 m、n),中位数取第 ⌈(m + n)/2⌉ 小 | 思路与代码归并式计数:两个指针,每次取较小者,数到第 k = ⌈(m + n)/2⌉ 个即是。O(m + n)、O(1)。 int Median2(int A[], int m, int B[], int n) { int k = (m + n + 1) / 2, i = 0, j = 0, x = 0; while (k--) { if (j >= n || (i < m && A[i] <= B[j])) x = A[i++]; // B 取完或 A 当前更小 else x = B[j++]; } return x; } |
| 2020-41 三元组 → 两个升序数组中各取一个,求差的绝对值的最小值 | 思路与代码同样“每次移动较小者的指针”。O(m + n)、O(1)。 int MinDiff(int A[], int m, int B[], int n) { int i = 0, j = 0, best = 0x7fffffff; while (i < m && j < n) { int d = A[i] > B[j] ? A[i] - B[j] : B[j] - A[i]; if (d < best) best = d; if (A[i] < B[j]) i++; else j++; // 移动较小者 } return best; } |
| 2025-41 乘 A[i..n−1] 中的数 → 改为乘 A[0..i] 中的数(j ≤ i) | 思路与代码改成从左往右扫描,维护前缀最大、最小值。 void calMulMaxPrefix(int A[], int res[], int n) { int maxv = A[0], minv = A[0]; for (int i = 0; i < n; i++) { if (A[i] > maxv) maxv = A[i]; if (A[i] < minv) minv = A[i]; res[i] = (A[i] >= 0) ? A[i] * maxv : A[i] * minv; } } |
| 2013-41 主元素 → 题目保证主元素一定存在 | 思路第一趟投票选出的候选就是答案,第二趟确认可以省去,只扫描一遍。 |
| 2016-43 划分 → 改为“求数组中第 k 小的元素” | 思路完全相同的快速选择:反复划分,枢轴位置 i = k − 1 时 A[i] 就是答案;i < k − 1 去右边找,否则去左边找。平均 O(n)。 |
- 数组下标越界:访问 B[A[i] − 1] 前没检查 A[i] 的范围;i + 1 越过 n − 1。
- 最小值 / 最大值的初值取错(取 0 而不是第一个元素或 INT_MAX)。
- 思想里写的方法和代码不一致;复杂度写成最好情况。
- “时间和空间都尽可能高效”时开了 O(n) 数组,没有说明理由(其实可以写,只是会少一两分)。
评分细则与保底策略
数组题按历年评分说明的惯例整理:算法题通常“思想 4 分 + 代码 7 分 + 复杂度 2 分”(总分 15 分时代码多 2 分),代码分按算法效率封顶。
| 你写出的算法 | 大致得分上限(13 分制) |
|---|---|
| 一趟扫描,时间 O(n)、空间 O(1)(如三次逆置、摩尔投票、后缀最值) | 13(满分) |
| 时间 O(n)、空间 O(n)(计数 / 标记数组) | 12 左右;若题目只要求时间高效则满分 |
| 先排序再处理,O(n log n) | 11 左右 |
| 两重循环,O(n²) | 10 左右 |
| 思想正确、代码有小错 | 思想分全给,代码酌情扣 1 ~ 3 分 |
- 先写两重循环或“先排序”的版本,保证正确;复杂度如实写 O(n²) / O(n log n)。
- 思想里写清楚“对每个元素做什么”,再写一句“可以用 xx 优化到 O(n)”——即使没写出优化版,也可能多给思路分。
- 值域已知时,标记数组几乎总能把暴力降到 O(n),是性价比最高的优化。
9.2题型 ②:链表上的算法设计
这类题长什么样
| 年份 | 题目 | 最优解的关键 | 最优复杂度 |
|---|---|---|---|
| 2009-42 | 查找倒数第 k 个结点 | 两个指针相距 k 同步走 | O(n)、O(1) |
| 2012-42 | 两个单词链表的公共后缀起点 | 求长度差,长的先走,再同步走 | O(m + n)、O(1) |
| 2015-41 | 删除绝对值重复的结点 | 标记数组 + 保持前驱指针 | O(m)、O(n) |
| 2019-41 | 重排为 a₁, aₙ, a₂, aₙ₋₁, … | 快慢指针找中点 + 后半逆置 + 交替合并 | O(n)、O(1) |
必须会的几件事
① 带头结点的遍历与删除
工作指针停在“待检查结点的前驱”上,删除时 r = p->next; p->next = r->next; free(r);,删完 p 不动。
② 头插法逆置、尾插法建表
逆置前先用 r 记住后继(基本功④)。
③ 快慢指针
中间结点、倒数第 k 个、判断有环(基本功⑤)。
④ 两链表对齐
长度差、同步前进;有序链表的归并与交集。
解题模板
- 写出结点类型定义(题目给了就照抄,没给要自己写,常有 1 ~ 2 分)。
- 确定有没有头结点,决定指针从 L 还是 L->next 出发。
- 画图:在草稿纸上画 4 ~ 5 个结点,把每一条指针语句在图上执行一遍,特别注意“先保存后继,再改指针”。
- 检查三个边界:空表、一个结点、处理到表尾(p->next 为空)。
思路是怎么想出来的:拆成几个基本功
- 先写暴力:把数据抄进数组,再用两端指针比较——O(n) 时间、O(n) 空间。不满足“空间 O(1)”,但先写出来就有分。
- 找困难:单链表只能从前往后走,没法从尾巴往回比。能不能让后半段“倒过来”?
- 换零件:① 快慢指针找到中点(第 ⌈n/2⌉ 个结点);② 把后半段原地逆置(头插法);③ 前半段和逆置后的后半段同时往后走,逐个比较。三步都是 2.5 节的基本功,各 O(n)、O(1)。
- 走一遍小例子:1 2 3 2 1,前半 1 2 3,后半 2 1 逆置成 1 2,逐个比 1 = 1、2 = 2,后半比完,是回文;1 2 2 1,前半 1 2,后半逆置成 1 2,是回文;1 2 3 1,前半 1 2,后半逆置成 1 3,2 ≠ 3,不是。
- 查边界与收尾:结点数为奇数时,中间那个在前半段末尾,后半段比完就停,它不用比;空表、只有 1 个结点都是回文。题目若要求“不破坏原链表”,比较完再把后半段逆置回去接好。
这是 2019-41 链表重排的“近亲”:同样是“找中点 + 逆置后半 + 两个指针同步走”。链表大题的难点很少在单个技巧上,而在于看出它能拆成哪几个基本功。(三个例子由程序模拟。)
完整示范
设线性表 L = (a₁, a₂, a₃, …, aₙ₋₂, aₙ₋₁, aₙ) 采用带头结点的单链表保存,链表中的结点定义如下:
typedef struct node { int data; struct node *next; } NODE;
请设计一个空间复杂度为 O(1) 且时间上尽可能高效的算法,重新排列 L 中的各结点,得到线性表 L′ = (a₁, aₙ, a₂, aₙ₋₁, a₃, aₙ₋₂, …)。要求:(1) 给出算法的基本设计思想。(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释。(3) 说明你所设计算法的时间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
- L′ 是“从前面取一个、从后面取一个”交替排列。单链表从后面取很难(每次都要从头找),所以先把后半段逆置,就变成“两条链表交替合并”。
- 空间 O(1):不能开数组、不能用栈,只能原地改指针。
① 找中间结点:设指针 p、q,p 每次走一步、q 每次走两步,q 到达链尾时 p 正好在中间结点(前半段的最后一个)。② 将 L 的后半段结点原地逆置(用头插法插到 p 之后)。③ 从单链表前、后两段中依次各取一个结点,按要求重排:把后半段的结点依次插到前半段各结点之后。
例:L = (1, 2, 3, 4, 5) → 前半段 1, 2, 3,后半段 4, 5 → 后半段逆置为 5, 4 → 交替合并得 1, 5, 2, 4, 3。
void change_list(NODE *h) { NODE *p, *q, *r, *s; p = q = h; while (q->next != NULL) { // ① 找中间结点:p 走一步,q 走两步 p = p->next; q = q->next; if (q->next != NULL) q = q->next; } q = p->next; // q 指向后半段的第一个结点 p->next = NULL; while (q != NULL) { // ② 后半段原地逆置:逐个头插到 p 之后 r = q->next; q->next = p->next; p->next = q; q = r; } s = h->next; // s 指向前半段的第一个结点 q = p->next; // q 指向逆置后后半段的第一个结点 p->next = NULL; // 前半段以 p 结尾 while (q != NULL) { // ③ 将后半段结点依次插入前半段结点之后 r = q->next; q->next = s->next; s->next = q; s = q->next; // s 指向前半段的下一个插入点 q = r; } }
找中间结点 O(n),逆置 O(n),合并 O(n),时间复杂度 O(n)(空间复杂度 O(1))。
常见错误:每次从头找当前的最后一个结点,时间 O(n²)(按评分标准扣分);逆置时忘记先用 r 保存后继导致断链;前半段的最后一个结点 p 的 next 没有置空,产生环。n 为奇数时前半段多一个,中间那个元素留在最后。
- “首尾交替”“判断回文链表”:快慢指针找中点 + 后半段逆置。
- 链表题三件套:快慢指针、头插法逆置、边走边插。熟练到能直接默写。
自编模拟大题
带头结点的单链表 L 中保存 n 个整数,结点结构为 data | next。请设计一个空间复杂度为 O(1) 且时间上尽可能高效的算法,判断 L 中的整数序列是否“回文”(正读、反读都一样,如 1, 2, 3, 2, 1)。是返回 true,否则返回 false。要求写出思想、代码(含结点定义)和复杂度。
先在纸上自己做一遍,再点这里展开满分解答
① 用快慢指针找到链表的中间结点 slow(前半段的最后一个结点);② 将 slow 之后的后半段原地逆置;③ 用两个指针分别从前半段的第一个结点和逆置后的后半段第一个结点出发,逐个比较,全部相等则是回文。(若要求不破坏链表,比较完后再把后半段逆置回去。)
typedef struct LNode { int data; struct LNode *next; } LNode; bool IsPalindrome(LNode *L) { LNode *slow = L, *fast = L; while (fast != NULL && fast->next != NULL) { // 找中间:slow 走一步,fast 走两步 slow = slow->next; fast = fast->next->next; } LNode *p = slow->next, *r; // 后半段 slow->next = NULL; while (p != NULL) { // 后半段头插到 slow 之后,完成逆置 r = p->next; p->next = slow->next; slow->next = p; p = r; } LNode *a = L->next, *b = slow->next; // a:前半段,b:逆置后的后半段 while (b != NULL) { if (a->data != b->data) return false; a = a->next; b = b->next; } return true; }
找中点、逆置、比较各 O(n),时间 O(n);空间 O(1)。
用栈或数组存下前半段再比较,时间 O(n) 但空间 O(n),不满足题目要求(约扣 3 ~ 4 分)。n 为奇数时中间元素不参与比较。
两个带头结点的单链表 A、B 分别存放两个集合,元素值严格递增。设计算法求 A 与 B 的交集,结果存放在 A 链表中,并释放 A 中不属于交集的结点。要求时间和空间都尽可能高效。
先在纸上自己做一遍,再点这里展开满分解答
类似有序表的归并:pa、pb 分别扫描 A、B,r 指向结果链表(A 的头结点开始)的尾部。若 pa->data == pb->data,把 pa 结点接到结果链表尾部,两个指针都后移;若 pa->data 较小,它不可能在交集中,释放它,pa 后移;若 pb->data 较小,pb 后移。最后释放 A 中剩下的结点,结果链表尾置空。
void Intersect(LNode *A, LNode *B) { LNode *pa = A->next, *pb = B->next, *r = A, *u; // r:结果链表的尾 while (pa != NULL && pb != NULL) { if (pa->data == pb->data) { // 公共元素,留在 A 中 r->next = pa; r = pa; pa = pa->next; pb = pb->next; } else if (pa->data < pb->data) { // A 中较小者不在交集中,释放 u = pa; pa = pa->next; free(u); } else pb = pb->next; // B 中较小者跳过 } while (pa != NULL) { u = pa; pa = pa->next; free(u); } // A 剩余的都不在交集中 r->next = NULL; }
时间 O(m + n),空间 O(1)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2009-42 倒数第 k 个 → 找中间结点 | 思路快慢指针:slow 每次走一步,fast 走两步,fast 到表尾时 slow 在中间。一趟扫描。 |
| 2012-42 公共后缀的起点 → 只问两个链表是否相交 | 思路与代码相交的两个链表最后一个结点一定是同一个:各自走到尾结点,比较两个尾指针是否相等。O(m + n)。 bool IsCross(LNode *h1, LNode *h2) { // h1、h2 为两个链表的头结点 LNode *p = h1->next, *q = h2->next; if (p == NULL || q == NULL) return false; // 有空表则不相交 while (p->next != NULL) p = p->next; // 各自走到尾结点 while (q->next != NULL) q = q->next; return p == q; // 尾结点是同一个结点即相交 } |
| 2015-41 按绝对值去重 → 链表递增有序,删除值重复的结点(不用辅助数组) | 思路与代码有序时相等的元素相邻:p 从第一个结点开始,若 p->next 的值等于 p 的值就删除 p->next,否则 p 后移。O(n)、O(1)。 void DelDupSorted(LNode *L) { // L 带头结点,递增有序 LNode *p = L->next, *q; if (p == NULL) return; while (p->next != NULL) { q = p->next; if (q->data == p->data) { p->next = q->next; free(q); } // 删除重复 else p = q; } } |
| 2019-41 首尾交替重排 → 把链表拆成奇数位置和偶数位置两个链表 | 思路一趟扫描,用计数器区分奇偶位置,分别尾插到两个链表(保持原次序);若要求偶数位置的逆序,就对偶数链表用头插法。 |
| 2009-42 → 要求删除倒数第 k 个结点 | 思路让后面的指针停在倒数第 k + 1 个结点(被删结点的前驱):从头结点出发、先走 k 步即可(p 从 L 出发走 k 步后,q 从 L 出发同步走,p 到最后一个结点时 q 是前驱)。 |
- 先修改指针后使用旧的后继,导致断链;free 后又访问。
- 对空表或只有一个结点的情况访问了 NULL->next。
- 比较“结点”时比较了 data(2012-42 应比较指针)。
- 结构体用错了 . 和 ->。
评分细则与保底策略
链表题按历年评分说明的惯例整理:算法题通常“思想 4 分 + 代码 7 分 + 复杂度 2 分”(总分 15 分时代码多 2 分),代码分按算法效率封顶。
| 你写出的算法 | 大致得分上限(13 分制) |
|---|---|
| 一趟或常数趟扫描,O(n)、O(1)(快慢指针、头插逆置、长度差) | 13(满分) |
| 把结点值 / 指针存进辅助数组再处理,O(n) 空间 | 11 ~ 12 |
| 对每个结点从头找一次,O(n²) | 10 左右 |
| 没写结点类型定义 | 代码分扣 1 分左右 |
- 先求表长,再按位置处理:“先数一遍、再走到第 k 个”几乎总能写对。
- 实在不会原地改指针,就把值复制到数组里处理,再写回链表(注明空间 O(n))。
- 结点定义
typedef struct LNode {…} LNode, *LinkList;一定要写。
9.3题型 ③:二叉树上的算法设计
这类题长什么样
| 年份 | 题目 | 最优解的关键 | 复杂度 |
|---|---|---|---|
| 2014-41 | 求二叉树的 WPL | 先序递归,深度作参数;或层序记层数 | O(n) |
| 2017-41 | 表达式树转中缀表达式(带括号) | 中序递归,非根分支结点加括号 | O(n) |
| 2022-41 | 判定顺序存储的二叉树是否为 BST | 下标 2k+1、2k+2 上的中序遍历,检查递增 | O(n) |
| 2026-41 | BST 中与 K 差的绝对值最小的所有结点 | 沿查找路径(O(h))或中序遍历(O(n)) | O(h) / O(n) |
必须会的几件事
① 递归三问
空树返回什么?左右子树的结果怎么用?当前结点做什么?(5.4 遍历的应用)
② 参数向下传、返回值向上传
深度、路径、取值范围作为参数传给孩子;高度、结点数、是否满足条件作为返回值交给双亲。
③ 层序遍历按层处理
每轮先记下当前队列长度,一次出队一整层——求宽度、每层结点、按层号求 WPL。
④ BST ⟺ 中序递增
判定、第 k 小、最接近某值、范围查询都用中序或沿查找路径走。
解题模板
- 写结点定义(二叉链表:data、lchild、rchild;题目给了就照抄字段名)。
- 选遍历方式:需要先处理根再处理孩子(传深度、路径)→ 先序;按大小顺序(BST)→ 中序;需要孩子的结果(高度、结点数、是否平衡)→ 后序;按层 → 层序。
- 写递归函数:出口(T == NULL)→ 处理当前结点 → 递归左右。必要时用全局变量或指针参数带回结果。
- 复杂度:每个结点访问一次 O(n);递归栈 O(h)(最坏 O(n))。
思路是怎么想出来的:先问“信息往哪个方向传”
- 先问需要什么信息:要比较各层的结点数,就得知道每个结点在第几层。二叉树题几乎都从这个问题开始:答案需要的信息,是从上往下传(层数、路径)还是从下往上收(高度、结点数)?
- 两种做法:① 层序遍历,每次把一整层出队、数一数个数;② 递归先序遍历,带一个参数 depth,每到一个结点就让 count[depth]++,最后取最大值——“从上往下传”的信息用参数传。
- 走一遍小例子:A(B(D, E), C(, F(G, ))):第 1 层 A,第 2 层 B、C,第 3 层 D、E、F,第 4 层 G。count = 1, 2, 3, 1,宽度 3。
- 查边界:空树宽度 0;count 数组的大小要按最坏的树高开(一条链时高度 n)。时间 O(n),空间 O(n)。
判断方法:信息要“从根往下传”的(层数、从根到当前结点的路径、祖先的值)用先序 + 参数;要“从孩子往上收”的(高度、子树结点数、子树是否平衡)用后序 + 返回值。2014-41 求带权路径长度 WPL,就是“深度往下传、到叶子再累加”的例子。(各层结点数由程序统计。)
完整示范
二叉树的带权路径长度(WPL)是二叉树中所有叶结点的带权路径长度之和。给定一棵二叉树 T,采用二叉链表存储,结点结构为:
其中叶结点的 weight 域保存该结点的非负权值。设 root 为指向 T 的根结点的指针,请设计求 T 的 WPL 的算法,要求:
(1) 给出算法的基本设计思想;
(2) 使用 C 或 C++ 语言,给出二叉树结点的数据类型定义;
(3) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释。
先在纸上自己做一遍,再点这里展开满分解答
- WPL = Σ(叶子的权 × 叶子的深度),深度按边数算(根为 0)。
- 遍历一遍树,走到叶子时把“权 × 当前深度”加起来即可。关键是遍历时要知道每个结点的深度。
基于先序递归遍历,把结点的深度 deep 作为递归函数的参数:
① 若当前结点为空,返回 0;② 若当前结点是叶子,返回“权值 × deep”;③ 否则返回对左子树、右子树递归调用(深度参数都为 deep + 1)的结果之和。对根调用时 deep = 0。
(也可以用层序遍历:用队列逐层处理并记录当前层数,遇到叶子累加“权值 × 层数”。)
typedef struct BiTNode { int weight; struct BiTNode *left, *right; } BiTNode, *BiTree;
int wpl_PreOrder(BiTree root, int deep) { // 返回以 root 为根的子树中叶子的“权 × 深度”之和 if (root == NULL) return 0; if (root->left == NULL && root->right == NULL) return root->weight * deep; // 叶结点 return wpl_PreOrder(root->left, deep + 1) // 左右子树的深度都加 1 + wpl_PreOrder(root->right, deep + 1); } int WPL(BiTree root) { return wpl_PreOrder(root, 0); // 根的深度为 0 }
层序遍历写法(同样满分):
#define MaxSize 100 int WPL_LevelOrder(BiTree root) { BiTree q[MaxSize]; // 队列 int front = 0, rear = 0, wpl = 0, deep = 0; int lastNode = 0; // 当前层最后一个结点在队列中的位置 if (root == NULL) return 0; q[rear++] = root; lastNode = rear; while (front < rear) { BiTree t = q[front++]; if (t->left == NULL && t->right == NULL) wpl += deep * t->weight; if (t->left != NULL) q[rear++] = t->left; if (t->right != NULL) q[rear++] = t->right; if (front == lastNode) { // 当前层处理完 deep++; lastNode = rear; // 下一层的最后一个结点 } } return wpl; }
① 深度从 0 算起(路径长度 = 边数),若从 1 算起结果会多出所有叶子权值之和;② 用 static 或全局变量累加时,多次调用不清零会出错,写成“返回值相加”最稳;③ 只有叶子才累加,分支结点不算。
- 需要“深度”的递归:把深度作为参数传下去;需要“子树的结果”的递归:用返回值带上来。
- 层序遍历按层处理:记录每层最后一个结点的位置(或用每层的元素个数)。
自编模拟大题
二叉树采用二叉链表存储。二叉树的宽度定义为结点数最多的那一层的结点数。请设计算法求一棵二叉树的宽度。要求:(1) 给出算法的基本设计思想;(2) 给出结点类型定义,用 C 或 C++ 写出算法,关键之处给出注释;(3) 说明复杂度。
先在纸上自己做一遍,再点这里展开满分解答
采用层序遍历,按层处理:每次在处理一层之前,队列中恰好是这一层的全部结点,队列长度就是该层的结点数,用它更新最大值;然后把这一层的结点全部出队,同时把它们的孩子(下一层)入队。所有层处理完后得到最大值。
#define MaxSize 1000 typedef struct BiTNode { int data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; int Width(BiTree T) { if (T == NULL) return 0; BiTNode *Q[MaxSize]; int front = 0, rear = 0, maxw = 0; Q[rear++] = T; while (front < rear) { int cnt = rear - front; // 当前层的结点数 if (cnt > maxw) maxw = cnt; for (int i = 0; i < cnt; i++) { // 当前层全部出队,下一层全部入队 BiTNode *p = Q[front++]; if (p->lchild != NULL) Q[rear++] = p->lchild; if (p->rchild != NULL) Q[rear++] = p->rchild; } } return maxw; }
每个结点入队、出队各一次,时间 O(n);队列最多同时存放 O(n) 个结点,空间 O(n)。
也可以先序递归,用参数传层号、用数组 cnt[层号] 计数,最后取最大值,同样满分。
二叉树采用二叉链表存储,设计算法判断它是否为完全二叉树。
先在纸上自己做一遍,再点这里展开满分解答
完全二叉树按层序编号时结点是连续的,中间不会有“空位”。做层序遍历,并且空孩子也入队:一旦出队遇到空结点,队列中剩下的必须全是空结点,否则不是完全二叉树。
bool IsComplete(BiTree T) { if (T == NULL) return true; // 空树也是完全二叉树 BiTNode *Q[MaxSize]; int front = 0, rear = 0; Q[rear++] = T; while (front < rear) { BiTNode *p = Q[front++]; if (p != NULL) { // 非空:两个孩子(可能为空)都入队 Q[rear++] = p->lchild; Q[rear++] = p->rchild; } else { // 遇到第一个空结点 while (front < rear) // 其后必须全是空结点 if (Q[front++] != NULL) return false; } } return true; }
时间 O(n),空间 O(n)(队列中最多放 2n + 1 个指针,MaxSize 要够大)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2017-41 转中缀 → 输出表达式树的后缀表达式(或前缀) | 思路与代码后序遍历直接输出即可,不需要括号(后缀式本身没有括号);前缀式用先序遍历。 void PostExp(BTree *root) { if (root == NULL) return; PostExp(root->left); PostExp(root->right); printf("%s", root->data); } |
| 2022-41 顺序存储判定 BST → 二叉链表存储 | 思路与代码方法一:中序遍历 + 记录前一个值(与原题相同)。方法二:递归传入每个结点允许的取值范围 (low, high):左子树的上界变成当前值,右子树的下界变成当前值。 bool IsBSTRange(BiTree T, int low, int high) { // 结点值必须在 (low, high) 内 if (T == NULL) return true; if (T->data <= low || T->data >= high) return false; return IsBSTRange(T->lchild, low, T->data) && IsBSTRange(T->rchild, T->data, high); } // 调用:IsBSTRange(T, INT_MIN, INT_MAX)(结点值不取这两个极端值) |
| 2026-41 最接近 K → 求 BST 中第 k 小的结点 | 思路与代码中序遍历计数,数到第 k 个即是;找到后立即停止递归。O(h + k)。 int cntK; BSTNode *ansK; void KthSmall(BSTNode *T, int k) { // 调用前 cntK = 0, ansK = NULL if (T == NULL || ansK != NULL) return; KthSmall(T->left, k); if (ansK != NULL) return; if (++cntK == k) { ansK = T; return; } KthSmall(T->right, k); } |
| 2014-41 WPL → 求所有叶子的深度之和、叶子数、或二叉树的高度 | 思路同一个递归框架:叶子深度之和 = 把 weight 换成 1;叶子数 = 叶子返回 1;高度 = 后序,返回 max(左高, 右高) + 1。 |
| 判断两棵二叉树是否相似(形状相同,不管值) | 思路都空 → 相似;一个空一个不空 → 不相似;否则左子树相似且右子树相似。O(n)。 |
- 递归没有出口或出口判断放在访问结点之后(先访问 T->data 再判 T == NULL)。
- 用 static 或全局变量累加,但没有在调用前清零。
- 深度从 0 还是 1 开始与题目定义不符(WPL 的路径长度按边数,根为 0)。
- 顺序存储的下标公式用错(从 0 开始:2k + 1、2k + 2;从 1 开始:2k、2k + 1)。
评分细则与保底策略
二叉树题按历年评分说明的惯例整理:算法题通常“思想 4 分 + 代码 7 分 + 复杂度 2 分”(总分 15 分时代码多 2 分),代码分按算法效率封顶。
| 你写出的算法 | 大致得分上限(13 分制) |
|---|---|
| 一次遍历完成(递归或层次),O(n) | 13 ~ 15(满分) |
| 对每个结点再做一次遍历(如反复求高度),O(n²) | 低 2 ~ 3 分 |
| 先把遍历序列存进数组再处理 | 视题目而定,通常低 1 ~ 2 分 |
| 思想说清了遍历顺序,代码细节错 | 思想分全给 |
- 先确定用哪种遍历:要“先知道孩子的结果”用后序,要“按层”用层次,要“带着深度往下传”用先序加参数。
- 递归写不顺时,先写“空树返回什么”和“叶子返回什么”,再补一般情况。
- 写出二叉链表的结点定义和函数原型,哪怕代码不完整也能拿到部分分。
9.4题型 ④:图上的算法设计(邻接矩阵 / 邻接表)
这类题长什么样
| 年份 | 题目 | 关键 | 复杂度 |
|---|---|---|---|
| 2021-41 | 判断无向连通图是否存在 EL 路径(奇度顶点 0 或 2 个) | 数每一行的非零元素(度) | O(n²) |
| 2023-41 | 输出有向图中出度大于入度的顶点(K 顶点) | 行 = 出度,列 = 入度 | O(n²) |
| 2024-41 | 判断有向图的拓扑序列是否唯一 | 每一步入度为 0 的顶点恰好一个 | O(n²) |
这三道题的共同点:题目给出 MGraph 类型定义,考的是“邻接矩阵上的基本操作 + 一个简单的图论判定”。代码都不长,难点在于把行、列含义写对。接下来的年份很可能换成邻接表、或者要求 DFS / BFS。
必须会的几件事
① 邻接矩阵上的度
无向图:第 i 行非零个数 = 度。有向图:第 i 行 = 出度,第 i 列 = 入度(6.2)。
② 邻接表的遍历
for (p = G.vertices[i].first; p; p = p->next) … p->adjvex …
④ 拓扑排序
入度数组 + 反复找入度为 0 的顶点(6.9);能否输出全部顶点 ⟺ 是否无环。
解题模板
- 照抄题目给的类型定义,确认顶点数字段名(numVertices / vexnum)。
- 把判定条件翻译成“数数”:度、入度、出度、入度为 0 的个数、访问到的顶点数……
- 双重循环扫描矩阵(或遍历邻接表),注意 Edge[i][j] 与 Edge[j][i] 的含义。
- 复杂度:邻接矩阵 O(n²),邻接表 O(n + e)。
思路是怎么想出来的:把图的话翻译成“行和列”
- 先把术语翻译成矩阵:M[v][u] = 1 表示 v、u 相邻;“距离为 2”就是“不是 v 自己、不和 v 相邻,但和 v 的某个邻居相邻”。
- 最直接的写法有坑:扫第 v 行找出所有邻居 u,再扫第 u 行找出 u 的邻居 w——这样会把 v 自己、以及本来就和 v 相邻的顶点也收进来。例:边 0–1、0–2、1–3、2–3、3–4、4–5,v = 0 时“邻居的邻居”是 {0, 3},0 必须去掉。
- 更稳的写法:从 v 做一次 BFS,记下每个顶点的距离 d[],最后输出 d[w] == 2 的顶点。本例只有顶点 3。BFS 在邻接矩阵上是 O(n²)。
- 查边界:图不连通时,到不了的顶点 d 保持 −1,不会被误判;同一个顶点被两个邻居同时发现(3 既是 1 的邻居也是 2 的邻居)时,BFS 的 visited 保证只算一次。
图的算法题第一步永远是“翻译”:无向图的度 = 一行里 1 的个数,有向图的出度 / 入度 = 行 / 列里 1 的个数,边数 = 矩阵中 1 的总数(无向图除以 2)。翻译清楚了,代码就是几重循环——2021-41(数奇度顶点)、2023-41(比较出度和入度)都是这样。(两种写法的结果由程序核对。)
完整示范
载人航天工程是包含众多子工程的复杂系统工程,为了保证工程的有序开展,需要明确各子工程的前导工程,以协调各子工程的实施。该问题可以简化、抽象为有向图的拓扑序列问题。已知有向图 G 采用邻接矩阵存储,类型定义如下:
typedef struct { // 图的类型定义 int numVertices, numEdges; // 图的顶点数和有向边数 char VerticesList[MAXV]; // 顶点表,MAXV 为已定义常量 int Edge[MAXV][MAXV]; // 邻接矩阵 } MGraph;
请设计算法 int uniquely(MGraph G),判定 G 是否存在唯一的拓扑序列,若是则返回 1,否则返回 0。要求:(1) 给出算法的基本设计思想(4 分);(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释(9 分)。
先在纸上自己做一遍,再点这里展开满分解答
- 拓扑排序:每次输出一个入度为 0 的顶点,删去它的出边。
- 拓扑序列唯一 ⟺ 每一步入度为 0 的顶点恰好只有一个。若某一步一个都没有,说明有回路,拓扑序列不存在,也返回 0。
先由邻接矩阵求出各顶点的入度(第 j 列非零元素个数为顶点 j 的入度)。然后进行 n 轮拓扑排序:每轮统计入度为 0 的顶点个数,若个数不等于 1(为 0 说明有回路、大于 1 说明序列不唯一),返回 0;否则输出这个顶点,将其入度置为 −1 表示已删除,并把它的所有后继的入度减 1。n 轮都只有一个入度为 0 的顶点,则拓扑序列唯一,返回 1。
int uniquely(MGraph G) { int n = G.numVertices, indegree[MAXV]; for (int j = 0; j < n; j++) { // 求各顶点的入度:第 j 列非零元素个数 indegree[j] = 0; for (int i = 0; i < n; i++) if (G.Edge[i][j] != 0) indegree[j]++; } for (int k = 0; k < n; k++) { // 共要输出 n 个顶点 int count = 0, v = -1; for (int i = 0; i < n; i++) // 找入度为 0 的顶点 if (indegree[i] == 0) { count++; v = i; } if (count != 1) return 0; // 0 个:有回路;多于 1 个:不唯一 indegree[v] = -1; // “删除”顶点 v for (int j = 0; j < n; j++) // v 的后继入度减 1 if (G.Edge[v][j] != 0) indegree[j]--; } return 1; }
① 输出过的顶点入度要改成 −1(或另设 visited 数组),否则下一轮它的入度仍是 0,会被重复计数;② 忘记处理“入度为 0 的顶点个数为 0”(有环)的情况;③ 时间复杂度 O(n²):求入度 O(n²),n 轮每轮 O(n)。
- 拓扑排序的邻接矩阵实现:入度数组 + 每轮扫描一遍找入度为 0 的顶点。
- “唯一”类判定:在算法每一步检查“选择是否唯一”。
自编模拟大题
无向图 G 采用邻接矩阵存储(类型定义同 2021-41 的 MGraph,Edge[i][j] = 1 表示有边)。一个无向图是一棵树,当且仅当它连通并且边数等于顶点数减 1。请设计算法 bool IsTree(MGraph G) 判断 G 是否是一棵树。要求写出思想、代码与复杂度。
先在纸上自己做一遍,再点这里展开满分解答
从顶点 0 出发做一次深度优先遍历,统计访问到的顶点数 vn 和经过的边数 en(每访问一个顶点,就把它所在行中非零元素的个数加到 en 上,这样每条边会被两端各数一次)。遍历结束后,若 vn 等于顶点数(连通)且 en = 2(n − 1)(边数为 n − 1),则 G 是树。
int visited[MAXV]; void DFS(MGraph G, int v, int *vn, int *en) { visited[v] = 1; (*vn)++; // 访问顶点 v for (int w = 0; w < G.numVertices; w++) if (G.Edge[v][w] != 0) { (*en)++; // 每条边从两端各数一次 if (!visited[w]) DFS(G, w, vn, en); } } bool IsTree(MGraph G) { for (int i = 0; i < G.numVertices; i++) visited[i] = 0; int vn = 0, en = 0; DFS(G, 0, &vn, &en); return vn == G.numVertices && en == 2 * (G.numVertices - 1); }
每个顶点访问一次,每次扫描一整行,时间 O(n²);visited 数组和递归栈 O(n)。
不能只数边数 = n − 1:两个连通分量(一个带环)也可能恰好 n − 1 条边。连通与边数两个条件缺一不可。
有向图 G 采用邻接表存储(类型定义见 6.2 节的 ALGraph)。设计算法判断从顶点 vᵢ 到 vⱼ(i ≠ j)是否存在路径。
先在纸上自己做一遍,再点这里展开满分解答
从 vᵢ 出发做深度优先遍历,若在遍历过程中访问到 vⱼ,则存在路径;遍历结束仍未访问到,则不存在。
int visited2[MAXV]; int DFSPath(ALGraph G, int i, int j) { if (i == j) return 1; // 到达终点 visited2[i] = 1; for (ArcNode *p = G.vertices[i].first; p != NULL; p = p->next) if (!visited2[p->adjvex] && DFSPath(G, p->adjvex, j)) return 1; return 0; } int ExistPath(ALGraph G, int i, int j) { for (int k = 0; k < G.vexnum; k++) visited2[k] = 0; return DFSPath(G, i, j); }
时间 O(n + e),空间 O(n)。用 BFS 同样正确。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2021-41 EL 路径 → 判断是否存在欧拉回路(从起点出发经过每条边一次回到起点) | 思路连通无向图存在欧拉回路 ⟺ 所有顶点的度都是偶数。代码只需把判定改成 count == 0。 |
| 2021-41 → 图改用邻接表存储 | 思路与代码无向图的邻接表中,顶点 i 的边表长度就是它的度。O(n + e)。 int IsExistEL_AL(ALGraph G) { int count = 0; for (int i = 0; i < G.vexnum; i++) { int degree = 0; for (ArcNode *p = G.vertices[i].first; p != NULL; p = p->next) degree++; if (degree % 2 != 0) count++; } return (count == 0 || count == 2) ? 1 : 0; } |
| 2024-41 拓扑唯一 → 判断有向图是否有环 | 思路与代码做拓扑排序,若输出的顶点数小于 n(中途找不到入度为 0 的顶点),则有环。 int HasCycle(MGraph G) { int n = G.numVertices, indegree[MAXV], output = 0; for (int j = 0; j < n; j++) { indegree[j] = 0; for (int i = 0; i < n; i++) if (G.Edge[i][j] != 0) indegree[j]++; } for (int k = 0; k < n; k++) { int v = -1; for (int i = 0; i < n; i++) if (indegree[i] == 0) { v = i; break; } if (v == -1) break; // 找不到入度为 0 的顶点 indegree[v] = -1; output++; for (int j = 0; j < n; j++) if (G.Edge[v][j] != 0) indegree[j]--; } return output < n; // 没能输出全部顶点 ⇒ 有环 } |
| 2023-41 K 顶点 → 输出所有入度为 0的顶点(源点)或出度最大的顶点 | 思路仍是逐列数入度、逐行数出度,只改判定条件;出度最大时用一个变量记录当前最大值和对应顶点。 |
| 求无向图的连通分量个数 | 思路外层循环遍历所有顶点,每遇到一个未访问的顶点就调用一次 DFS(或 BFS),计数器加 1。邻接矩阵 O(n²),邻接表 O(n + e)。 |
- 入度、出度的行列弄反;无向图的边数 = 非零元素个数 / 2。
- 循环上界写成 numEdges;访问顶点名写成下标。
- DFS 前没有初始化 visited;多次调用之间没有清零。
- 拓扑排序中已输出的顶点没有标记,被重复选中。
评分细则与保底策略
图的算法题按历年评分说明的惯例整理:算法题通常“思想 4 分 + 代码 7 分 + 复杂度 2 分”(总分 15 分时代码多 2 分),代码分按算法效率封顶。
| 你写出的算法 | 大致得分上限(13 分制) |
|---|---|
| 邻接矩阵 O(n²) / 邻接表 O(n + e),一遍扫描或一次遍历 | 13 ~ 15(满分) |
| 对每个顶点做一次完整遍历(多一个 n 倍) | 低 2 ~ 3 分 |
| 存储结构定义缺失或与题目不符 | 代码分扣 1 ~ 2 分 |
- 按定义直接数:度、边、路径条件,大多数图算法题“照定义两重循环”就是可接受的答案。
- 题目给了图的结构定义(如 MGraph)就照抄,变量名一字不改。
- 判连通、判环、判路径都可以先写一个 DFS / BFS 模板,再在“访问”处加题目要做的事。
9.5题型 ⑤:图的应用题(最短路径、最小生成树、关键路径、存储)
这类题长什么样
| 年份 | 题目 | 考什么 |
|---|---|---|
| 2009-41 | “每次走到离当前顶点最近的点”能否求最短路径 | 举反例;理解 Dijkstra 的贪心依据 |
| 2011-41 | 上三角矩阵压缩存储 → 还原邻接矩阵 → 画图 → 关键路径 | 特殊矩阵 + 关键路径 |
| 2014-42 | OSPF 链路状态表 → 抽象成图、设计链式存储、Dijkstra | 邻接表设计 + Dijkstra |
| 2015-42 | 邻接矩阵、A² 的含义、Bᵐ 的含义 | 矩阵乘法的路径意义 |
| 2017-42 | Prim 选边顺序、MST 是否唯一、唯一的条件 | Prim + MST 唯一性 |
| 2018-42 | 光缆铺设(所有 MST)、存储结构与算法、TTL | Kruskal + 多解 |
| 2025-42 | AOE 网:工期、关键活动、同时进行的活动、余量、推迟与压缩 | 关键路径的全部概念 |
必须会的几件事
① 手工执行 Dijkstra
画 dist 表:每轮选最小、再用它松弛,写出每一轮的 dist(6.7)。
② 手工执行 Prim / Kruskal
Prim 写出每步的“树中顶点、候选边、选中边”;Kruskal 按边权排序逐条判断成环(6.6)。
③ 关键路径四张表
ve(拓扑序取 max)、vl(逆拓扑序取 min)、e、l 与 l − e(6.10)。
④ 存储结构的画法
邻接矩阵写成带行列号的表,邻接表画出顶点表和边结点链(6.2)。
解题模板
- 把场景翻译成图:谁是顶点、谁是边、权是什么、有向还是无向。
- 先画图:哪怕题目给了图,也在草稿纸上重画一遍,标好权值。
- 按算法写表格:每一步一行,最后写结论。
- “说明理由 / 是否唯一”:一两句话点出原理,必要时给反例。
思路是怎么想出来的:先确认“问的是哪一种最优”
- 读题三问:图有没有方向、有没有权?问的是“连通所有点、总和最小”(最小生成树)还是“某两点之间最近”(最短路径)?要不要写出过程(应用题大多要)?
- 最小生成树:Kruskal 按权从小到大挑:C–D 1、A–B 2、B–C 2,4 个顶点已连通,总代价 5。
- 最短路径:Dijkstra 从 A 出发:B 2、C 3(直达)、D 4(A → C → D)。
- 对比:在最小生成树上从 A 到 C 要走 A–B–C,长 4,比最短路 3 长。最小生成树保证“全体加起来最省”,不保证任意两点最近——题目问哪个就用哪个,2023-06 专门考过这个区别。
应用题的分数一大半在“过程”:Prim / Kruskal 要写出每一步加入的边,Dijkstra 要写出每一轮的 dist 表,关键路径要列 ve、vl、e、l 表。结果算错一个数,过程对了仍能拿到大部分分。(两个结果由程序计算。)
完整示范
下图所示的 AOE 网描述了 12 个工程活动及其持续时间。
(1) 完成该工程的最短时间是多少?哪些是关键活动?(2) 若以最短时间完成工程,则与活动 e 同时进行的活动可能有哪些?(3) 时间余量最大的活动是哪个?其时间余量是多少?(4) 假设工程从时刻 0 启动,因某种原因,活动 b 在时刻 6 开始,为保证工程不延期,在其他活动持续时间保持不变的情况下,b 的持续时间最多是多少?若不改变 b 的持续时间,则压缩哪个活动的持续时间也能保证工程不延期?
先在纸上自己做一遍,再点这里展开满分解答
- 源点 1,汇点 5。先求所有事件的 ve、vl,再求每个活动的 e、l 和余量 l − e。
- (2) 的“同时进行”:两个活动可能进行的时间段有重叠。(4) 是在关键路径的约束下倒推允许的最大时长。
| 事件 | 1 | 3 | 4 | 6 | 7 | 2 | 5 |
|---|---|---|---|---|---|---|---|
| ve | 0 | 2 | max(3, 2+3) = 5 | max(2+1, 5+1) = 6 | max(5+4, 6+1) = 9 | max(5, 5+4) = 9 | max(9+2, 5+1, 9+3) = 12 |
| vl | 0 | min(5−3, 8−1) = 2 | min(10−4, 12−1, 8−1, 9−4) = 5 | 9 − 1 = 8 | 12 − 3 = 9 | 12 − 2 = 10 | 12 |
| 活动 | a | b | c | d | e | f | g | h | j | k | m | n |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| e(最早) | 0 | 0 | 2 | 0 | 2 | 5 | 5 | 6 | 5 | 9 | 5 | 9 |
| l(最迟) | 0 | 5 | 7 | 2 | 2 | 6 | 7 | 8 | 11 | 10 | 5 | 9 |
| 余量 | 0 | 5 | 5 | 2 | 0 | 1 | 2 | 2 | 6 | 1 | 0 | 0 |
完成工程的最短时间为 12,关键活动为 a、e、m、n(关键路径 1 → 3 → 4 → 7 → 5)。
e 是关键活动,在时间段 [2, 5] 进行。可能与它时间重叠的活动:b(可在 [0, 5] 到 [5, 10] 之间进行)、d([0, 3] 到 [2, 5])、c(最早 [2, 3])。其余活动最早都在时刻 5 以后才开始。所以可能同时进行的活动有 b、c、d。
由上表,时间余量最大的是活动 j,余量 = vl(5) − ve(4) − 1 = 12 − 5 − 1 = 6。
b 在时刻 6 开始,设持续时间为 x,则 b 在 6 + x 完成;事件 2 的最迟发生时间 vl(2) = 10,要求 6 + x ≤ 10,所以 b 的持续时间最多为 4。
若 b 仍为 5,则事件 2 在时刻 11 发生,k 需在 12 前完成,k 的持续时间应压缩到 12 − 11 = 1,即压缩活动 k(由 2 压缩到 1)。
(4) 中事件 2 还受 f 影响(5 + 4 = 9),但 6 + x 比它大,决定了事件 2 的发生时间。“压缩哪个活动”:b 之后通往汇点的只有 k,只能压缩 k。
- 活动 <i, j> 可能进行的时间范围:从 [e, e + 持续时间] 到 [l, l + 持续时间]。
- 活动推迟、改时长的问题:用“事件 j 的 vl 不能被突破”列不等式。
自编模拟大题
某地 6 个村庄 A ~ F 之间可修建道路的费用(万元)如下图所示(无向边)。
(1) 要修建道路使所有村庄连通且总费用最低,用 Kruskal 算法依次给出选中的边,并求总费用。(2) 从 A 出发用 Prim 算法,依次给出选中的边。(3) 该最低费用方案是否唯一?说明理由。(4) 以 A 为源点,用 Dijkstra 算法依次求出 A 到其余各村庄的最短路径(写出每一步的 dist)。
先在纸上自己做一遍,再点这里展开满分解答
边按权排序:(B,C)1、(A,C)2、(D,E)2、(E,F)3、(A,B)4、(B,D)5、(D,F)6、(C,D)8、(C,E)10。依次选:(B,C)1 ✓、(A,C)2 ✓、(D,E)2 ✓、(E,F)3 ✓、(A,B)4 ✗(A、B 已连通)、(B,D)5 ✓,已选 5 条边,结束。
选中的边:(B,C)、(A,C)、(D,E)、(E,F)、(B,D),总费用 1 + 2 + 2 + 3 + 5 = 13。
(A,C)2 → (C,B)1 → (B,D)5 → (D,E)2 → (E,F)3。(每一步都是一端在树中的最短边。)
唯一。虽然有两条权为 2 的边,但它们都在最小生成树中;每条不在树中的边加入后形成的回路里,它都是唯一的最大边:(A,B)4 所在回路 A−B−C 中其余边为 1、2;(D,F)6 所在回路 D−E−F 中其余为 2、3;(C,D)8、(C,E)10 所在回路中其余边都更小。所以没有可以等价替换的边。
| 轮 | B | C | D | E | F | 选中 |
|---|---|---|---|---|---|---|
| 初始 | 4 | 2 | ∞ | ∞ | ∞ | C(2) |
| 用 C 松弛 | 3 | ✓ | 10 | 12 | ∞ | B(3) |
| 用 B 松弛 | ✓ | ✓ | 8 | 12 | ∞ | D(8) |
| 用 D 松弛 | ✓ | ✓ | ✓ | 10 | 14 | E(10) |
| 用 E 松弛 | ✓ | ✓ | ✓ | ✓ | 13 | F(13) |
最短路径:A→C(2);A→C→B(3);A→C→B→D(8);A→C→B→D→E(10);A→C→B→D→E→F(13)。
注意最小生成树中 A 到 F 的路径长度也是 13,但这只是巧合:一般情况下 MST 上的路径不是最短路径(见 2023-06)。
| 评分点 | 分 |
|---|---|
| (1) 边的顺序与总费用 | 3 |
| (2) Prim 顺序 | 2 |
| (3) 结论 + 理由 | 2 |
| (4) 每一步 dist 与路径 | 3 |
某工程的 AOE 网如下,边上为活动名与持续天数。
(1) 求各事件的最早、最迟发生时间和各活动的时间余量。(2) 求工期与关键路径。(3) 若把 a7 缩短 2 天,工期能缩短吗?若把 a1 缩短 2 天呢?(4) 若 a9 推迟 3 天开始,工期是否受影响?
先在纸上自己做一遍,再点这里展开满分解答
| 事件 | v1 | v2 | v3 | v4 | v5 | v6 | v7 | v8 | v9 |
|---|---|---|---|---|---|---|---|---|---|
| ve | 0 | 6 | 4 | 5 | 7 | 7 | 16 | 14 | 18 |
| vl | 0 | 6 | 6 | 8 | 7 | 10 | 16 | 14 | 18 |
| 活动 | a1 | a2 | a3 | a4 | a5 | a6 | a7 | a8 | a9 | a10 | a11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| e | 0 | 0 | 0 | 6 | 4 | 5 | 7 | 7 | 7 | 16 | 14 |
| l | 0 | 2 | 3 | 6 | 6 | 8 | 7 | 7 | 10 | 16 | 14 |
| 余量 | 0 | 2 | 3 | 0 | 2 | 3 | 0 | 0 | 3 | 0 | 0 |
工期 18 天。关键活动 a1、a4、a7、a8、a10、a11,有两条关键路径:v1→v2→v5→v7→v9 与 v1→v2→v5→v8→v9。
a7 只在第一条关键路径上,缩短后第二条关键路径仍为 18 天,工期不能缩短。a1 是两条关键路径的公共活动,缩短 2 天后两条都变为 16 天,其余路径(经 a2 的 4 + 1 + 9 + 2 = 16、经 a3 的 5 + 2 + 4 + 4 = 15)也不超过 16,工期缩短为 16 天。
a9 的时间余量为 3,推迟 3 天开始恰好用完余量,工期不受影响(推迟 4 天就会延期 1 天)。
缩短关键活动时要检查:缩短后别的路径会不会变成最长的(本题 a1 缩短 2 天后仍然没有超过 16 的其他路径,所以恰好 16)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2009-41 → 问“Dijkstra 能否用于负权边”,并举例 | 答案不能。例:A→B 5、A→C 2、B→C −4。Dijkstra 先确定 C(2),再确定 B(5),但 A→B→C = 1 < 2,C 的“最终值”错了。 |
| 2017-42 → 改为从 B 开始 Prim,或用 Kruskal | 答案从 B:(B,C)4 → (C,E)5 → (E,D)4 → (D,A)4。Kruskal:三条权 4 的边 (A,D)、(D,E)、(B,C) 全选(任意次序),再选 (C,E)5((A,E)5 会与 A−D−E 成环)。结果是同一棵 MST。 |
| 2018-42 → 把 BJ−TL 的费用改为 4 | 答案连接两大块的权 3 边只剩 WH−QD,MST 唯一,总费用仍为 16;TL 到 BJ 要经过 TL→JN→QD→WH→XA→BJ,TTL = 5 的分组在 XA 处被丢弃,H2 收不到。 |
| 2025-42 → 活动 j 推迟 5 天开始 / 推迟 7 天 | 答案j 的余量为 6:推迟 5 天工期不变;推迟 7 天,j 在 12 + 1 = 13 才完成,工期延长 1 天,为 13。 |
| 2014-42 → 把 R2−R4 的费用改为 6 | 答案R1 到 R4:经 R3 为 2 + 6 = 8,经 R2 为 3 + 6 = 9,取经 R3 的 8;到 192.1.7.0/24 的费用变为 9,路径 R1→R3→R4。其余三条不变。 |
| 2011-41 → 邻接矩阵是下三角、按列优先存储 | 思路下三角列优先:第 0 列存 A[1][0] ~ A[5][0](5 个),第 1 列 4 个……按列依次还原,再转置理解方向(A[i][j] 仍表示 i→j 的弧)。数元素个数(15 个)确认不含对角线。 |
- 只写结果不写过程(dist 表、ve / vl 表)。
- Dijkstra 选中一个顶点后忘了用它更新其他顶点。
- “所有方案”只画了一种;“是否唯一”只答“是 / 否”不给理由。
- 关键路径只找了一条,漏了并列的另一条。
评分细则与保底策略
图的应用题按“过程 + 结果”给分:每一问的分数大多落在过程(表格、图、每一步)上,只写结论往往只能拿到一半。
| 得分点 | 说明 |
|---|---|
| 画出存储结构 / 图 | 邻接矩阵写全、邻接表画出指针顺序 |
| 算法每一步 | Prim / Kruskal 每次选哪条边;Dijkstra 每轮的 dist 表;拓扑每次输出谁 |
| 关键路径 | ve、vl、e、l 四张表都写出来,再标关键活动 |
| 结论 | 最小代价、最短路径长度、关键路径(可能不止一条) |
- 即使最后答案算错,表格和每一步也能拿到大部分分——一定要把过程写出来。
- 按题目要求的顶点次序和算法规则走,平局时注明自己的取法。
9.6题型 ⑥:查找的应用题(散列表、ASL、查找树)
这类题长什么样
| 年份 | 题目 | 考什么 |
|---|---|---|
| 2010-41 | 由装填因子定表长,线性探测建表,求成功 / 失败 ASL | 散列表全流程 |
| 2013-42 | 概率不等的 4 个元素:顺序存储、链式存储怎样排、ASL 多少 | ASL 的定义、顺序查找、BST |
| 2024-42 | 平方探测(+k²)建表、装填因子、查找的比较序列、失败地址 | 散列表的探测过程 |
今后也可能考 BST / AVL 的构造与 ASL、B 树的插入删除(选择题已考过很多次,搬到大题上就是“画出每一步”)。
必须会的几件事
① 散列建表与两种 ASL
逐个写出关键字的探测序列和比较次数;成功 ASL 按关键字平均,失败 ASL 按初始地址平均,分母取 p,与空位那一次也要算(7.9 跟做)。
② 折半查找判定树
按区间递归画出判定树,结点的层数就是比较次数;失败结点挂在空指针处,比较次数等于它双亲的层数(7.2)。
③ 不等概率下比较组织方式
ASL = Σ 概率 × 比较次数;概率大的放前面、放近根处,最优的不一定是平衡的(7.2 跟做)。
解题模板
- 散列表:定表长(由 α 反推)→ 列出每个关键字的 H(key) → 按顺序插入,记录每个关键字的探测次数 → 画表 → ASL 成功(按关键字平均)、ASL 失败(按 H 的取值个数平均,含与空位的比较)。
- 查找树:逐个插入画出每一步(AVL 要标出失衡结点和旋转类型)→ 每层结点数 × 层号求 ASL。
- 概率不等:ASL = Σ pᵢ × cᵢ,概率大的放前面 / 上面。
思路是怎么想出来的:把“比较次数”画成树
- 把比较次数和层数对应:在 BST 里找一个关键字,比较次数等于它所在的层数,所以 ASL = Σ(概率 × 层数)。
- 凭直觉:概率大的放得离根近。3 个关键字只有 5 种 BST(卡特兰数 C₃ = 5),可以全部列出来算。
- 算一算:最“平衡”的 b(a, c) 是 2 × 0.6 + 1 × 0.3 + 2 × 0.1 = 1.7;而“歪”的 a(, b(, c)) 是 1 × 0.6 + 2 × 0.3 + 3 × 0.1 = 1.5,是 5 种里最小的。
- 结论:概率不均匀时,最优 BST 不一定平衡。2013-42 正是让你在给定概率下比较几种组织方式;等概率时才是平衡的最好。
查找应用题的通用思路:先画出“比较的过程”(顺序表、判定树、BST、散列表的探测序列),再按“每个关键字比较几次 × 概率”求和;失败的 ASL 则按“每种失败情形比较几次”求和。(5 种 BST 的 ASL 由程序枚举。)
完整示范
将关键字序列 ⟨7,8,30,11,18,9,14⟩ 散列存储到散列表中。散列表的存储空间是一个下标从 0 开始的一维数组,散列函数为 H(key)=(key×3)mod7 ,处理冲突采用线性探测再散列法,要求装填(载)因子为 0.7 。
(1) 请画出所构造的散列表。
(2) 分别计算等概率情况下查找成功和查找不成功的平均查找长度。
先在纸上自己做一遍,再点这里展开满分解答
- 装填因子 α = 关键字个数 / 表长 = 0.7,7 个关键字 ⇒ 表长 7 / 0.7 = 10,下标 0 ~ 9。
- H(key) = (key × 3) mod 7,只能算出 0 ~ 6:查找失败的初始地址只有 7 种。
| key | 7 | 8 | 30 | 11 | 18 | 9 | 14 |
|---|---|---|---|---|---|---|---|
| H(key) | 0 | 3 | 6 | 5 | 5 | 6 | 0 |
依次插入:7→0;8→3;30→6;11→5;18:5 冲突→6 冲突→7;9:6 冲突→7 冲突→8;14:0 冲突→1。
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 7 | 14 | 8 | 11 | 30 | 18 | 9 |
查找成功的比较次数:7、8、30、11 各 1 次,14 为 2 次,18、9 各 3 次。ASL成功 = (1 + 1 + 1 + 1 + 3 + 3 + 2)/7 = 12/7。
查找失败:初始地址 0 ~ 6,从各地址出发比较到空单元为止(含与空单元的比较):
| H(key) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 比较次数 | 3 | 2 | 1 | 2 | 1 | 5 | 4 |
ASL不成功 = (3 + 2 + 1 + 2 + 1 + 5 + 4)/7 = 18/7。
两个最常见的错误:① 失败 ASL 的分母写成表长 10(应为散列函数值的个数 7);② 没有把“与空单元的比较”算进去。另外表长是由装填因子反推出来的,不是 7。
- 散列表题的四步:定表长 → 算 H → 逐个插入并记探测次数 → 算成功 / 失败 ASL。
自编模拟大题
关键字序列 {32, 13, 49, 24, 38, 21, 4, 12},散列函数 H(key) = key % 7,散列表长度为 10(下标 0 ~ 9)。(1) 用线性探测法处理冲突,画出散列表,求等概率下查找成功和查找失败的 ASL。(2) 改用链地址法(新结点插在链尾),求查找成功的 ASL 和查找失败的 ASL(与空指针的比较不计)。(3) 比较两种方法,说明原因。
先在纸上自己做一遍,再点这里展开满分解答
H:32→4、13→6、49→0、24→3、38→3(冲突→4 冲突→5)、21→0(冲突→1)、4→4(4、5、6 冲突→7)、12→5(5、6、7 冲突→8)。
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 49 | 21 | 24 | 32 | 38 | 13 | 4 | 12 | ||
| 比较次数 | 1 | 2 | 1 | 1 | 3 | 1 | 4 | 4 |
ASL成功 = (1 + 2 + 1 + 1 + 3 + 1 + 4 + 4)/8 = 17/8 ≈ 2.13。
ASL失败:初始地址 0 ~ 6 出发比较到空位:0→3 次(0、1、2),1→2,2→1,3→7(3 ~ 9),4→6,5→5,6→4。(3 + 2 + 1 + 7 + 6 + 5 + 4)/7 = 28/7 = 4。
0:49 → 21;3:24 → 38;4:32 → 4;5:12;6:13。
ASL成功 = (1 × 5 + 2 × 3)/8 = 11/8 ≈ 1.38。ASL失败 = (2 + 0 + 0 + 2 + 2 + 1 + 1)/7 = 8/7 ≈ 1.14。
链地址法的 ASL 明显更小。线性探测会产生堆积:4 和 12 与 32、38、13 并不是同义词,却因为地址被占而被迫一路往后探测;链地址法中只有同义词才在同一条链上,不会堆积。
依次输入关键字 16, 3, 7, 11, 9, 26, 18, 14, 15。(1) 构造二叉排序树,求等概率下查找成功的 ASL。(2) 构造平衡二叉树,写出每次旋转的类型,画出最终的树,求查找成功的 ASL。
先在纸上自己做一遍,再点这里展开满分解答
各层结点:第 1 层 16;第 2 层 3、26;第 3 层 7、18;第 4 层 11;第 5 层 9、14;第 6 层 15。ASL = (1 + 2×2 + 3×2 + 4 + 5×2 + 6)/9 = 31/9 ≈ 3.44。
插入 7 时 16 失衡(LR 型)→ 7(3, 16);插入 9 时 16 失衡(LL 型)→ 7(3, 11(9, 16));插入 26 时 7 失衡(RR 型)→ 11(7(3, 9), 16(, 26));插入 18 时 16 失衡(RL 型)→ 11(7(3, 9), 18(16, 26));插入 15 时 16 失衡(LR 型),得到最终的树:
ASL = (1 + 2×2 + 3×4 + 4×2)/9 = 25/9 ≈ 2.78。
每次插入后都要从新结点往上找第一个 |平衡因子| = 2 的结点,按新结点相对它的位置判断类型(已用程序逐步模拟验证)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2010-41 线性探测 → 改用链地址法 | 答案H 同原题:0:7 → 14;3:8;5:11 → 18;6:30 → 9。ASL成功 = (1×4 + 2×3)/7 = 10/7;ASL失败(不计空指针)= (2 + 0 + 0 + 1 + 0 + 2 + 2)/7 = 1。 |
| 2024-42 平方探测 → 改用线性探测 | 答案20→5、3→9、11→0、18→10、9→5 冲突→6、14→9、10、0 冲突→1(4 次)、7→10、0、1 冲突→2(4 次)。表:0:11、1:14、2:7、5:20、6:9、9:3、10:18。ASL成功 = (1+1+1+1+2+4+4)/7 = 2。 |
| 2013-42 → 四个元素的查找概率相等 | 答案折半查找 ASL = (1 + 2×2 + 3)/4 = 2,顺序查找 (1+2+3+4)/4 = 2.5,此时折半更好——折半查找的优势只在等概率时成立。 |
| 散列表“删除”一个关键字后再求 ASL 失败(2023-09 的大题版) | 思路开放定址法做逻辑删除,被删单元在查找时仍当作“有元素、不匹配”继续探测,所以失败 ASL 不变(与空单元的比较照常计入)。 |
- 表长没有由装填因子推出;失败 ASL 的分母用了表长。
- 探测公式没按题目给的来(2024-42 只有 +k²)。
- “比较序列”写成了地址序列。
- AVL 的旋转类型判断错(要看新结点相对于最小不平衡子树的根的位置)。
评分细则与保底策略
查找的应用题按“过程 + 结果”给分:每一问的分数大多落在过程(表格、图、每一步)上,只写结论往往只能拿到一半。
| 得分点 | 说明 |
|---|---|
| 散列表 | 画出带地址的表,每个关键字写出探测过程(冲突几次) |
| ASL 成功 | 列出每个关键字的比较次数再求和、除以关键字个数 |
| ASL 失败 | 写清分母(散列函数可能取到的地址个数)和每个地址的比较次数 |
| 查找树 | 画出每次插入 / 旋转后的树 |
- ASL 题先把每个关键字的比较次数列成表,即使最后除错也能拿到表的分。
- 失败 ASL 的口径不确定时,写一句自己采用的口径(“空位置算 1 次比较”)。
9.7题型 ⑦:排序与外部排序的应用题
这类题长什么样
| 年份 | 题目 | 考什么 |
|---|---|---|
| 2012-41 | 6 个有序表两两合并,使最坏比较次数最少 | 哈夫曼思想(最佳归并) |
| 2021-42 | 读懂比较计数排序:结果、比较次数、稳定性及修改 | 读代码 + 稳定性 |
| 2022-42 | 从 10 万个数中找最小的 10 个 | 堆的 Top-k 应用 |
| 2023-42 | 置换—选择生成初始归并段;第一段长度的最大最小值 | 外部排序 |
必须会的几件事
① 外部排序读写次数
先求块数、初始段数 r 和趟数 ⌈logkr⌉,再算 2 × 块数 × (趟数 + 1);看清问的是整个排序还是只算归并(8.10)。
② 置换—选择
逐步写出工作区内容和 MINIMAX,工作区里的记录都比 MINIMAX 小时当前段结束(8.10)。
③ 最佳归并树
先算要补几个虚段,再像哈夫曼树那样每次合并最小的 k 个,读写次数 = 2 × WPL(8.10 跟做)。
解题模板
- “给序列写某一趟”:严格按 8 篇的算法手工模拟,每趟写一行;快排写清枢轴,堆写清建堆起点 ⌊n/2⌋。
- “读代码”:用题目给的小数据逐行跑一遍,记下关键数组的值;稳定性只看“相等时走哪个分支”。
- “选算法 / 设计策略”:说出算法名 + 为什么(时间、空间、稳定性、数据规模)+ 复杂度。
- 外部排序:置换—选择画三列表;归并树用 k 叉哈夫曼,先算要补几个虚段;读写次数 = 2 × WPL(以块数为权)。
思路是怎么想出来的:要的是“全排好”还是“一部分”
- 先写暴力:整个排好序,取第 k 个,O(n log n)。最稳,几乎不会写错。
- 找浪费:题目只要第 k 个,前 k − 1 个之间、后 n − k 个之间的次序根本不需要知道——全排序做了大量多余的事。
- 零件一:堆。维护一个大小为 k 的大根堆,保存当前最小的 k 个,扫完后堆顶就是答案,O(n log k)。k 很小、数据又多到装不下内存(只能边读边处理)时最合适。
- 零件二:快排的划分。划分一次就知道枢轴排第几,只往包含第 k 个的那一边继续,平均 O(n)、最坏 O(n²)(8.5 节)。数据能全部放进内存、追求平均最快时选它。
- 选择:答题时把几种方法和复杂度都写出来,再说明在题目条件下选哪个、为什么。2016-43 用的是划分,2022-42 用的是堆,两题的区别正在于“数据能不能全放进内存、k 是不是很小”。
排序类应用题先问一句:要不要全排好?不要的话,堆和划分几乎总比完整排序快;要的话,再按 8.9 节的选型规则(规模、初态、稳定、空间)挑排序方法。
完整示范
设有 6 个有序表 A、B、C、D、E、F,分别含有 10、35、40、50、60 和 200 个数据元素,各表中元素按升序排列。要求通过 5 次两两合并,将 5 个表最终合并成 1 个升序表,并在最坏情况下比较的总次数达到最小。请回答下列问题。
(1) 给出完整的合并过程,并求出最坏情况下比较的总次数。
(2) 根据你的合并过程,描述 N(N≥2) 个不等长升序表的合并策略,并说明理由。
先在纸上自己做一遍,再点这里展开满分解答
- 两个长度为 m、n 的有序表合并,最坏比较 m + n − 1 次。合并得到的表还要参加后面的合并,所以短表应该先合并——和构造哈夫曼树一模一样。
- 题中“将 5 个表最终合并成 1 个”应为 6 个表(A ~ F 共 6 个,合并 5 次)。
按哈夫曼树的思想,每次选最短的两个表合并:
| 次 | 合并 | 新表长 | 最坏比较次数 |
|---|---|---|---|
| 1 | A(10) + B(35) | 45 | 44 |
| 2 | AB(45) + C(40) | 85 | 84 |
| 3 | D(50) + E(60) | 110 | 109 |
| 4 | ABC(85) + DE(110) | 195 | 194 |
| 5 | ABCDE(195) + F(200) | 395 | 394 |
最坏情况下比较的总次数为 44 + 84 + 109 + 194 + 394 = 825。
各表的合并策略:在对 N 个表两两合并时,若表长不同,最坏情况下总的比较次数依赖于合并次序。借用哈夫曼树的构造思想,每次选择最短的两个表合并,可以使最坏情况下的总比较次数最少。
理由:每个元素参与比较的次数与它所在的表在合并树中的深度成正比,总比较次数近似于以表长为权的合并树的 WPL,哈夫曼树的 WPL 最小。
用其他顺序也能合并完,评分标准给部分分(过程 3 分);比较次数每次是 m + n − 1,不是 m + n。
- “合并代价与规模成正比、合并结果还要继续合并”⇒ 哈夫曼策略(外部排序的最佳归并树同理)。
自编模拟大题
待排序序列 (46, 79, 56, 38, 40, 84, 25, 61),按升序排序。写出:(1) 以第一个元素为枢轴的快速排序第一趟结果;(2) 建立的初始大根堆,以及输出第一个元素并调整后的堆;(3) 增量为 4 的希尔排序第一趟结果;(4) 二路归并排序第二趟结果;(5) 以上四种排序中哪些是稳定的?
先在纸上自己做一遍,再点这里展开满分解答
(1) 快排第一趟(枢轴 46):25, 40, 38, 46, 56, 84, 79, 61。过程:high 找到 25 填到最前,low 找到 79 填到 25 原来的位置,high 找到 40,low 找到 56,high 找到 38,low 与 high 相遇,46 放入下标 3。
(2) 从第 ⌊8/2⌋ = 4 个元素(38)开始向下调整:38 与孩子 61 交换;56 与 84 交换;79 不动;46 与 84 交换后再与 56 交换。初始大根堆:84, 79, 56, 61, 40, 46, 25, 38。输出 84(与末尾 38 交换)后调整:79, 61, 56, 38, 40, 46, 25 | 84。
(3) 希尔 d = 4:组 {46, 40}、{79, 84}、{56, 25}、{38, 61} 组内排序:40, 79, 25, 38, 46, 84, 56, 61。
(4) 二路归并:第一趟 [46 79] [38 56] [40 84] [25 61];第二趟 [38 46 56 79] [25 40 61 84]。
(5) 只有二路归并排序是稳定的;快速、堆、希尔排序不稳定。
建堆时向下调整要一直调到底(46 换下来后还要继续与孩子比较);输出堆顶时是“与最后一个元素交换再调整”,调整范围不包括已输出的元素。
(1) 输入文件的关键字依次为 15, 19, 04, 83, 12, 27, 11, 25, 16, 34, 26, 07, 10, 90, 06,内存工作区可容纳 3 个记录,用置换—选择排序生成初始归并段。(2) 另有一个文件经置换—选择得到 10 个初始归并段,长度(块数)依次为 12, 9, 30, 18, 3, 17, 2, 6, 24, 21,采用 3 路归并。画出最佳归并树,并求归并过程中读写外存的总块数(不含生成初始归并段)。
先在纸上自己做一遍,再点这里展开满分解答
| 工作区 | 输出 | 读入 |
|---|---|---|
| 15 19 04 | 04 | 83 |
| 15 19 83 | 15 | 12(冻结) |
| 12 19 83 | 19 | 27 |
| 12 27 83 | 27 | 11(冻结) |
| 12 11 83 | 83 | 25(冻结)→ 段 1 结束 |
| 12 11 25 | 11, 12, 16, 25, 26, 34, 90 | 依次读入 16, 34, 26, 07(冻结), 10(冻结), 90, 06(冻结) |
| 07 10 06 | 06, 07, 10 | 输入结束 |
三个初始归并段:04, 15, 19, 27, 83;11, 12, 16, 25, 26, 34, 90;06, 07, 10。
(10 − 1) % (3 − 1) = 1 ≠ 0,需补 3 − 1 − 1 = 1 个长度为 0 的虚段。按 3 叉哈夫曼树合并:0 + 2 + 3 = 5;5 + 6 + 9 = 20;12 + 17 + 18 = 47;20 + 21 + 24 = 65;30 + 47 + 65 = 142。
WPL = 5 + 20 + 47 + 65 + 142 = 279(每个非叶结点的权之和),归并过程读写外存 2 × 279 = 558 块。
虚段要最先参与合并(它长度为 0,放在最深处);读写次数 = 2 × WPL,因为每个块在每一趟中读一次、写一次。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2012-41 → 4 个有序表,长度 20、30、40、50 | 答案20 + 30 → 50(比较 49);40 + 50 → 90(89);50 + 90 → 140(139)。最坏总比较次数 49 + 89 + 139 = 277。 |
| 2021-42 → 要求排成降序且稳定 | 答案把判断改为 |
| 2022-42 最小的 10 个 → 找最大的 10 个 | 答案用 10 个元素的小根堆,堆顶是当前 10 个中最小的;新数比堆顶大才替换堆顶并调整。O(n)、O(1)。 |
| 2023-42 → 工作区改为 m = 3 或 m = 5 | 答案m = 3:4 个段:37, 51, 92, 94 / 14, 15, 48, 56, 63, 99 / 23, 31, 43, 60, 90, 166 / 8, 17, 100。m = 5:3 个段:14, 37, 51, 63, 92, 94, 99 / 15, 23, 31, 43, 48, 56, 60, 90, 100, 166 / 8, 17。工作区越大,段越长、段越少。 |
| 问 k 路平衡归并、r 个初始段需要几趟 | 答案⌈logk r⌉ 趟;用败者树选最小时每次比较 ⌈log₂k⌉ 次。 |
- 归并的比较次数写成 m + n(应为 m + n − 1)。
- 置换—选择输出了“工作区中最小的”,而不是“不小于 MINIMAX 的最小者”。
- 最佳归并树忘了补虚段;读写次数忘了乘 2。
- Top-k 问题堆的类型选反。
评分细则与保底策略
排序的应用题按“过程 + 结果”给分:每一问的分数大多落在过程(表格、图、每一步)上,只写结论往往只能拿到一半。
| 得分点 | 说明 |
|---|---|
| 每趟结果 | 按题目要求的算法写出每一趟后的序列 |
| 比较 / 移动次数 | 逐趟数,写出加和式 |
| 外部排序 | 写出归并趟数、读写次数的公式和代入过程;最佳归并树要画树并注明虚段 |
| 选型理由 | 一两句话点出稳定性、时间、空间中的关键因素 |
- 不会整体算的,就把每一趟写出来——每趟都有分。
- 外部排序先写公式(2 × 块数 × (趟数 + 1))再代数,公式本身就有分。
9.8题型 ⑧:结构设计与性质推导
这类题长什么样
| 年份 | 题目 | 考什么 |
|---|---|---|
| 2016-42 | 正则 k 叉树:叶子数、高度为 h 时结点数的最大最小值 | 树的性质推导 |
| 2019-42 | 设计一个空间只增不减、可复用、O(1) 进出的队列 | 循环链表队列的设计 |
| 2020-42 | 前缀编码用什么结构保存、如何译码、如何判定前缀特性 | 编码树(二叉树) |
| 2026-42 | 出栈序列能否得到、不可能的特征、计数 | 栈与卡特兰数 |
必须会的几件事
② 树的结点数关系
结点数 = 分支数 + 1;度为 0、度为 k 的结点个数之间的关系由这条式子推出(5.1)。
③ 前缀编码与哈夫曼树
前缀编码 ⟺ 字符都在二叉树的叶子上;译码就是从根沿 0 / 1 走到叶子(5.9)。
④ 出栈序列的判定与计数
模拟法、312 模式、卡特兰数,以及“以某元素开头”的计数(3.2)。
解题模板
- 推导题:设未知数(各类结点个数),列“结点数 = 各类之和”“边数 = 结点数 − 1 = 各结点度数之和”两个方程;极值问题画出“最满”和“最瘦”的形态。
- 设计题:逐条翻译要求 → 每条要求对应一个设计决定(顺序 / 链式、有无头结点、循环与否、几个指针)→ 画初始状态和操作后的状态 → 给出判空判满条件和操作步骤。
- “用什么结构”题:说出结构名 + 怎样对应(编码 ↔ 根到叶的路径)+ 操作过程。
思路是怎么想出来的:列出要求,逐条对答案
- 列出要求:① 入栈 O(1);② 出栈 O(1);③ 取最小值 O(1)。前两条普通顺序栈就满足,难点只在第 ③ 条。
- 试第一个方案:用一个变量 min 记住最小值。入栈时更新很容易;可一旦把最小值出栈,新的最小值是谁?只能把栈扫一遍,O(n)——不满足 ③。
- 找出缺的信息:问题出在“出栈后忘了以前的最小值”。那就把每个时刻的最小值都记下来:给每个位置再存一个 min[i] = 栈底到第 i 个元素中的最小值。入栈时 min[top] = min(x, min[top − 1]);出栈时它跟着一起丢掉,下面剩下的 min 正好就是“回到从前”的最小值。
- 走一遍:依次入栈 5、3、7、2,min 依次是 5、3、3、2;出栈 2 后最小值回到 3,再出栈 7 仍是 3。
typedef struct { int data[MaxSize], min[MaxSize]; int top; } MinStack; // min[i]:栈底到第 i 个元素中的最小值 void Push(MinStack &S, int x) { S.top++; S.data[S.top] = x; S.min[S.top] = (S.top == 0 || x < S.min[S.top - 1]) ? x : S.min[S.top - 1]; } void Pop(MinStack &S, int &x) { x = S.data[S.top--]; } // min 随之一起“弹出” int GetMin(MinStack S) { return S.min[S.top]; }
代价是空间多一倍——用空间换时间。结构设计题(2019-42 的队列、2020-42 的前缀编码)都是这个流程:把要求逐条列出来,拿候选结构一条条去对,不满足就看缺了什么信息,再想办法把这个信息存下来。(代码与暴力求最小值随机对拍 20 万次操作。)
完整示范
请设计一个队列,要求满足:
① 初始时队列为空;
② 入队时,允许增加队列占用空间;
③ 出队后,出队元素所占用的空间可重复使用,即整个队列所占用的空间只增不减;
④ 入队操作和出队操作的时间复杂度始终保持为 O(1) 。
请回答下列问题:
(1) 该队列是应选择链式存储结构,还是应选择顺序存储结构?
(2) 画出队列的初始状态,并给出判断队空和队满的条件。
(3) 画出第一个元素入队后的队列状态。
(4) 给出入队操作和出队操作的基本过程。
先在纸上自己做一遍,再点这里展开满分解答
- ② 空间能增加 ⇒ 链式(顺序存储要预先分配固定大小)。
- ③ 出队的空间可重复使用、只增不减 ⇒ 出队的结点不释放,留着给以后入队用——把链表首尾接起来,像循环队列一样循环使用。
- ④ 入队、出队 O(1) ⇒ 要有队头、队尾两个指针。
采用链式存储结构(两段式单向循环链表),设队头指针 front、队尾指针 rear。顺序存储无法满足“入队时允许增加空间”。
仿照循环队列“牺牲一个单元”的做法:rear 始终指向一个空闲结点(下一个元素将存入的位置)。初始时创建只有一个空闲结点的循环单链表,front 和 rear 都指向它。
队空条件:front == rear。队满条件:front == rear->next(空闲结点只剩 rear 这一个)。
见上图右半部分:初始时队满(front == rear->next),先在 rear 后插入一个新的空闲结点,再把元素存入 rear 所指结点,rear 后移指向新的空闲结点。此时 front 指向存有 e₁ 的结点,rear 指向空闲结点,两个结点构成循环链表。
入队:若 front == rear->next(队满),则在 rear 之后插入一个新的空闲结点;把入队元素存入 rear 所指结点;rear = rear->next。
出队:若 front == rear(队空),出队失败;否则取出 front 所指结点中的元素 e,front = front->next,返回 e(结点不释放,留待复用)。
typedef struct QNode { int data; struct QNode *next; } QNode; QNode *front, *rear; void InitQueue() { front = rear = (QNode *)malloc(sizeof(QNode)); rear->next = rear; // 一个空闲结点,自成循环 } void EnQueue(int x) { if (front == rear->next) { // 队满:在 rear 后增加一个空闲结点 QNode *s = (QNode *)malloc(sizeof(QNode)); s->next = rear->next; rear->next = s; } rear->data = x; // 存入 rear 所指的空闲结点 rear = rear->next; } int DeQueue(int *x) { if (front == rear) return 0; // 队空 *x = front->data; front = front->next; // 出队结点不释放,可重复使用 return 1; }
用普通链队列(出队时 free 结点)不满足“空间只增不减、可复用”;用顺序循环队列不满足“允许增加空间”。
- 设计题先逐条翻译要求,每条要求对应一个设计决定。
自编模拟大题
设计一个栈,除 Push、Pop 外还支持 GetMin(返回栈中当前最小元素),要求三个操作的时间复杂度都是 O(1)。(1) 应采用什么存储结构?需要保存哪些信息?(2) 用 C 语言写出类型定义和三个操作。
先在纸上自己做一遍,再点这里展开满分解答
用顺序栈,除数据数组 data 外,再用一个同样大小的数组 min,min[i] 保存 data[0..i] 中的最小值。入栈时 min[top] = min(x, min[top − 1]);出栈时两个数组一起退栈;GetMin 直接返回 min[top]。以空间换时间,三个操作都是 O(1)。
#define MaxSize 100 typedef struct { int data[MaxSize]; int min[MaxSize]; // min[i]:data[0..i] 中的最小值 int top; // 栈顶下标,空栈为 -1 } MinStack; void InitStack(MinStack *S) { S->top = -1; } bool Push(MinStack *S, int x) { if (S->top == MaxSize - 1) return false; // 栈满 S->top++; S->data[S->top] = x; if (S->top == 0 || x < S->min[S->top - 1]) S->min[S->top] = x; else S->min[S->top] = S->min[S->top - 1]; return true; } bool Pop(MinStack *S, int *x) { if (S->top == -1) return false; // 栈空 *x = S->data[S->top--]; // min 数组随 top 一起“退栈” return true; } bool GetMin(MinStack *S, int *x) { if (S->top == -1) return false; *x = S->min[S->top]; return true; }
利用两个顺序栈 S1、S2(容量都为 MaxSize)实现一个队列。(1) 说明入队、出队的方法;(2) 队空、队满的条件是什么?(3) 写出入队、出队的代码;(4) 分析一次出队操作的最坏时间和 n 次操作的总时间。
先在纸上自己做一遍,再点这里展开满分解答
S1 负责入队,S2 负责出队。入队:直接压入 S1。出队:若 S2 为空,把 S1 的元素全部依次弹出并压入 S2(顺序被翻转,最早入队的到了 S2 栈顶),然后从 S2 弹出;若 S2 不空,直接从 S2 弹出。
队空:S1、S2 都空。队满:S1 满且 S2 不空(S1 满但 S2 空时,可以先把 S1 倒入 S2 再入队)。
typedef struct { int data[MaxSize]; int top; } Stack; // top 初值 -1 void Move(Stack *S1, Stack *S2) { // S1 全部倒入 S2 while (S1->top != -1) S2->data[++S2->top] = S1->data[S1->top--]; } bool EnQueue(Stack *S1, Stack *S2, int x) { if (S1->top == MaxSize - 1) { // S1 满 if (S2->top != -1) return false; // S2 也不空:队满 Move(S1, S2); // S2 空:先倒过去腾出 S1 } S1->data[++S1->top] = x; return true; } bool DeQueue(Stack *S1, Stack *S2, int *x) { if (S2->top == -1) Move(S1, S2); // S2 空:把 S1 倒过来 if (S2->top == -1) return false; // 仍然空:队空 *x = S2->data[S2->top--]; return true; }
单次出队最坏要把 S1 的全部元素倒入 S2,O(n);但每个元素一生中最多入 S1 一次、出 S1 入 S2 一次、出 S2 一次,n 次操作总时间 O(n),平均每次 O(1)(均摊分析)。
举一反三:条件一变,答案怎么变
| 原题 → 改了什么 | 怎么做(点开看) |
|---|---|
| 2016-42 → 正则 k 叉树有 m 个分支结点,总结点数是多少 | 答案n = n₀ + m = (k − 1)m + 1 + m = km + 1(也可直接由“边数 = km = n − 1”得到)。 |
| n 个结点的 m 叉树用“m 个孩子指针”的链表存储,有多少个空指针 | 答案共 nm 个指针,非空的 n − 1 个,空指针 nm − (n − 1) = n(m − 1) + 1。二叉树(m = 2)时为 n + 1。 |
| 2019-42 → 若只允许用顺序存储,能否满足 4 条要求 | 答案不能完全满足:顺序存储的空间在初始化时就分配好,入队时无法“增加”空间;若采用动态数组扩容,扩容时要整体复制,入队操作不能始终 O(1)。 |
| 2026-42 → n = 5,以 3 开头的出栈序列有多少个 | 答案3 出栈后 1、2 在栈中(2 在顶),4、5 未入栈。枚举(或程序验证)共 9 个;n = 5 时以 1、2、3、4、5 开头的个数分别是 14、14、9、4、1,合计 42 = C₅。 |
| 2020-42 → 给出一组编码,判断是否是前缀编码并画出编码树 | 思路按 2020-42 第 (3) 问的过程逐个插入编码;最后检查:所有编码都落在叶子上即是前缀编码。例如 {0, 10, 110, 111} 是,{0, 01, 11} 不是(0 是 01 的前缀)。 |
- 推导题只给结果、不写过程(评分上限只有一半左右)。
- 设计题没有给出“判空、判满”的明确条件,没有画出要求画的状态图。
- “最少结点”的树形没有保证满足定义(正则树每个分支结点必须恰有 k 个孩子)。
评分细则与保底策略
结构设计与推导题按“过程 + 结果”给分:每一问的分数大多落在过程(表格、图、每一步)上,只写结论往往只能拿到一半。
| 得分点 | 说明 |
|---|---|
| 结构设计 | 画出存储结构,写出类型定义(结构体) |
| 操作说明 | 每个操作怎么做、为什么正确 |
| 复杂度 | 每个操作的时间复杂度 |
| 推导题 | 写出关系式和推导步骤,用小例子验证 |
- 先给出能用的方案(即使效率一般),再说明可以如何改进。
- 推导题写不出通式时,先算 n = 1、2、3 的值找规律,再写出结论,过程分可以拿到。
9.9整卷模拟:三套 41 + 42
每套按真题的分值和难度编排:41 题算法设计 13 分、42 题应用题 10 分。建议限时 40 分钟一套,写在纸上,写完再看解答和评分细则,按细则给自己打分。代码均已上机测试。
模拟卷一
已知一个带头结点的单链表 L,结点结构为 data | next,data 为整数。请设计一个时间上尽可能高效、空间复杂度为 O(1) 的算法,将 L 中所有 data 为奇数的结点移到所有 data 为偶数的结点之前,并且奇数结点之间、偶数结点之间保持原来的相对次序。例如 L = (4, 7, 1, 8, 6, 3) 变为 (7, 1, 3, 4, 8, 6)。要求:(1) 给出算法的基本设计思想;(2) 用 C 或 C++ 描述算法,关键之处给出注释;(3) 说明时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
扫描一遍链表,把结点分成两条链:奇数结点用尾插法接到以头结点 L 开始的“奇数链”后面,偶数结点用尾插法接到另设的“偶数链”后面(尾插法保持原来的相对次序)。扫描结束后,把偶数链接到奇数链的尾部,偶数链尾置空。
typedef struct LNode { int data; struct LNode *next; } LNode; void OddFirst(LNode *L) { LNode *oddTail = L; // 奇数链的尾(从头结点开始) LNode *evenHead = NULL, *evenTail = NULL; // 偶数链的头、尾 LNode *p = L->next, *nx; while (p != NULL) { nx = p->next; // 先保存后继 if (p->data % 2 != 0) { // 奇数:尾插到奇数链 oddTail->next = p; oddTail = p; } else { // 偶数:尾插到偶数链 if (evenHead == NULL) evenHead = p; else evenTail->next = p; evenTail = p; } p = nx; } if (evenTail != NULL) evenTail->next = NULL; // 偶数链尾置空 oddTail->next = evenHead; // 奇数链后接偶数链 }
只扫描一遍,时间 O(n);只用了几个指针,空间 O(1)。
负奇数 −3 % 2 等于 −1,所以判断奇数要写 “% 2 != 0” 而不是 “== 1”;没有偶数结点时 evenTail 为空,不能访问 evenTail->next;最后奇数链的尾必须接上偶数链(或置空)。
| 评分点 | 分 |
|---|---|
| 思想:拆成两条链、尾插保持次序、最后连接 | 4 |
| 代码:一趟 O(n)、O(1) 且正确 7 分;逐个查找插入 O(n²) 最高 5 分;用辅助数组最高 5 分 | 7 |
| 复杂度与代码一致 | 2 |
有向带权图 G 的邻接矩阵如下(∞ 表示无弧):
| v0 | v1 | v2 | v3 | v4 | |
|---|---|---|---|---|---|
| v0 | 0 | 10 | ∞ | 30 | 100 |
| v1 | ∞ | 0 | 50 | ∞ | ∞ |
| v2 | ∞ | ∞ | 0 | ∞ | 10 |
| v3 | ∞ | ∞ | 20 | 0 | 60 |
| v4 | ∞ | ∞ | ∞ | ∞ | 0 |
(1) 画出 G 的邻接表(边表中按顶点编号递增排列)。(2) 基于该邻接表,写出从 v0 出发的深度优先和广度优先遍历序列。(3) 用 Dijkstra 算法求 v0 到其余各顶点的最短路径,写出每一步的 dist。
先在纸上自己做一遍,再点这里展开满分解答
v0 → [1 | 10] → [3 | 30] → [4 | 100] ∧
v1 → [2 | 50] ∧
v2 → [4 | 10] ∧
v3 → [2 | 20] → [4 | 60] ∧
v4 ∧
每个边结点:邻接点下标 | 权值 | next。
DFS:v0 → v1 → v2 → v4,回溯到 v0 → v3:v0, v1, v2, v4, v3。
BFS:v0 出队后 v1、v3、v4 入队;v1 出队后 v2 入队:v0, v1, v3, v4, v2。
| 步骤 | v1 | v2 | v3 | v4 | 选中 |
|---|---|---|---|---|---|
| 初始 | 10 | ∞ | 30 | 100 | v1(10) |
| 用 v1 松弛 | ✓ | 60 | 30 | 100 | v3(30) |
| 用 v3 松弛 | ✓ | 50 | ✓ | 90 | v2(50) |
| 用 v2 松弛 | ✓ | ✓ | ✓ | 60 | v4(60) |
最短路径:v0→v1(10);v0→v3(30);v0→v3→v2(50);v0→v3→v2→v4(60)。
| 评分点 | 分 |
|---|---|
| (1) 邻接表正确 | 3 |
| (2) DFS、BFS 序列各 1 分 | 2 |
| (3) 每一步 dist 与最终路径 | 5 |
模拟卷二
二叉树采用二叉链表存储,各结点的值互不相同。请设计算法,输出从根结点到值为 x 的结点的路径(依次输出路径上各结点的值);若 x 不在树中,输出 “not found”。要求:(1) 给出算法的基本设计思想;(2) 给出结点定义,用 C 或 C++ 描述算法,关键之处给出注释;(3) 说明时间、空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
用数组 path 保存“从根到当前结点”的路径,先序递归:访问一个结点时先把它加入 path;若它就是 x,找到;否则在左子树中找,找不到再到右子树中找;左右子树都找不到,就把它从 path 中删去(回溯),返回“未找到”。
(另一种方法:后序非递归遍历,访问到 x 时栈中恰好是它的全部祖先。)
#define MaxSize 100 typedef struct BiTNode { int data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; int path[MaxSize], len = 0; // 当前路径及长度 bool FindPath(BiTree T, int x) { // 找到返回 true,path[0..len-1] 为根到 x 的路径 if (T == NULL) return false; path[len++] = T->data; // 当前结点入路径 if (T->data == x) return true; if (FindPath(T->lchild, x) || FindPath(T->rchild, x)) return true; len--; // 左右子树都没有 x:回溯 return false; } void PrintPath(BiTree T, int x) { len = 0; if (FindPath(T, x)) for (int i = 0; i < len; i++) printf("%d ", path[i]); else printf("not found"); }
每个结点最多访问一次,时间 O(n);path 数组与递归栈都与树高成正比,空间 O(h)(最坏 O(n))。
回溯时忘了 len−−;用全局 len 但多次调用前没有清零;找到后没有立即返回,导致路径被后续递归破坏。
| 评分点 | 分 |
|---|---|
| 思想:先序 + 路径数组 + 回溯(或后序非递归栈) | 4 |
| 代码正确(含结点定义) | 7 |
| 复杂度 | 2 |
(1) 在初始为空的 3 阶 B 树中依次插入关键字 20, 30, 50, 52, 60, 68, 70,画出每次分裂后的 B 树和最终的 B 树。(2) 在最终的 B 树中删除关键字 50,画出删除后的 B 树。(3) 高度为 3 的 3 阶 B 树(不计叶结点层)最多、最少含多少个关键字?
先在纸上自己做一遍,再点这里展开满分解答
3 阶 B 树每个结点最多 2 个关键字,出现 3 个就从中间(第 2 个)分裂上移。
- 20、30 → [20, 30];50 → [20, 30, 50] 分裂,30 上移:根 [30],孩子 [20]、[50]。
- 52 → [50, 52];60 → [50, 52, 60] 分裂,52 上移:根 [30, 52],孩子 [20]、[50]、[60]。
- 68 → [60, 68];70 → [60, 68, 70] 分裂,68 上移:根 [30, 52, 68] 也分裂,52 上移成为新根。
最终:根 [52];第 2 层 [30]、[68];第 3 层 [20]、[50]、[60]、[70]。
[50] 删除后为空(下溢);兄弟 [20] 只有 1 个关键字,不能借,合并:[20] + 双亲中的 30 → [20, 30],双亲 [30] 变空(下溢)。它的兄弟 [68] 也只有 1 个关键字,继续合并:空结点 + 根中的 52 + [68] → [52, 68],原来的根变空被删除,树高减 1。
结果:根 [52, 68];孩子 [20, 30]、[60]、[70]。
最多:每个结点 2 个关键字、3 个孩子:3³ − 1 = 26。最少:每个结点 1 个关键字(根 1 个,其余结点至少 ⌈3/2⌉ − 1 = 1 个):1 + 2 + 4 = 7,即 2⌈3/2⌉3−1 − 1 = 7。
| 评分点 | 分 |
|---|---|
| (1) 三次分裂过程与最终树 | 4 |
| (2) 两次合并、树高减 1 | 4 |
| (3) 最多 26、最少 7 | 2 |
模拟卷三
已知两个升序数组 A[0..m−1] 和 B[0..n−1](m、n ≥ 0,m + n ≥ 1),以及整数 k(1 ≤ k ≤ m + n)。请设计一个时间和空间上尽可能高效的算法,求两个数组合在一起后的第 k 小元素。例如 A = {2, 5, 9},B = {1, 6, 7, 12},k = 4 时答案为 6。(1) 给出算法的基本设计思想;(2) 用 C 或 C++ 描述算法,关键之处给出注释;(3) 说明时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
- 暴力:把两个数组合并成一个新数组(归并),取第 k 个,时间 O(m + n)、空间 O(m + n)。
- 只要第 k 个,不必真的合并:两个指针像归并一样往前走,每次取较小的那个,数到第 k 个就停,空间 O(1)。
- 再进一步(拓展):每次比较两个数组中“第 k/2 个”元素,较小一侧的前 k/2 个不可能是答案,一次扔掉一半,O(log k)。
仿照两个有序表的归并:i、j 分别指向 A、B 当前最小的未取元素,每次取 A[i] 与 B[j] 中较小的一个(某个数组取完就只取另一个),并计数;取到第 k 个时它就是答案。不需要真正合并,只用几个变量。
int KthSmallest(int A[], int m, int B[], int n, int k) { // 1 <= k <= m + n int i = 0, j = 0, cnt = 0, x = 0; while (cnt < k) { if (j >= n || (i < m && A[i] <= B[j])) x = A[i++]; // B 取完了,或 A[i] 较小:取 A[i] else x = B[j++]; // 否则取 B[j] cnt++; // 已经取出 cnt 个 } return x; // 第 k 个取出的就是第 k 小 }
拓展 O(log k) 的写法:
int KthLog(int A[], int m, int B[], int n, int k) { // 每次排除约 k/2 个 int i = 0, j = 0; // A[i..]、B[j..] 是还没排除的部分 while (1) { if (i == m) return B[j + k - 1]; // A 已排除完 if (j == n) return A[i + k - 1]; // B 已排除完 if (k == 1) return A[i] < B[j] ? A[i] : B[j]; int h = k / 2; int ni = (i + h < m ? i + h : m) - 1; // A 中第 h 个(不够就取最后一个) int nj = (j + h < n ? j + h : n) - 1; if (A[ni] <= B[nj]) { k -= ni - i + 1; i = ni + 1; } // A 的这一段不可能是第 k 小 else { k -= nj - j + 1; j = nj + 1; } } }
最多取 k 次,时间 O(k)(≤ O(m + n));只用几个变量,空间 O(1)。(拓展写法时间 O(log k)。)
某个数组取完后还去读它(越界);k 从 0 数还是从 1 数没想清楚;把“第 k 小”理解成下标 k。
| 评分点 | 分 |
|---|---|
| 思想:双指针按归并顺序数到第 k 个(O(log k) 写法同样满分) | 4 |
| 代码正确:O(k) 或 O(log k)、O(1) 空间满分 7;先合并到新数组 O(m + n) 空间最高 6;合并后排序最高 5 | 7 |
| 复杂度与代码一致 | 2 |
(1) 将序列 35, 18, 42, 7, 26, 11, 53, 30 按“自底向上”的方法建成小根堆,写出每次调整后的序列和最终的堆,并统计关键字比较次数。(2) 对建好的堆连续执行 3 次“删除堆顶”,写出每次删除后的堆。(3) 要从 1 亿个整数中找出最大的 100 个,内存只能放下几千个整数,应该怎样用堆?时间复杂度是多少?
先在纸上自己做一遍,再点这里展开满分解答
n = 8,从第 ⌊8/2⌋ = 4 个元素开始,依次对 4、3、2、1 号向下调整(较小的孩子上移):
| 调整 | 过程 | 比较 | 调整后 |
|---|---|---|---|
| 4 号(7) | 只有一个孩子 30,7 ≤ 30,不动 | 1 | 35 18 42 7 26 11 53 30 |
| 3 号(42) | 孩子 11、53 取 11;42 > 11,交换 | 2 | 35 18 11 7 26 42 53 30 |
| 2 号(18) | 孩子 7、26 取 7,下沉;再与孩子 30 比,停 | 3 | 35 7 11 18 26 42 53 30 |
| 1 号(35) | 孩子 7、11 取 7,下沉;孩子 18、26 取 18,下沉;孩子 30,下沉 | 5 | 7 18 11 30 26 42 53 35 |
共比较 1 + 2 + 3 + 5 = 11 次。
每次用最后一个元素替换堆顶,再向下调整:
- 删 7:35 补到堆顶,与 11 交换,再与 42、53 中的 42 比较,停:11 18 35 30 26 42 53。
- 删 11:53 补到堆顶,与 18 交换,再与 30、26 中的 26 交换:18 26 35 30 53 42。
- 删 18:42 补到堆顶,与 26 交换,再与 30、53 中的 30 交换:26 30 35 42 53。
用前 100 个数建一个小根堆(堆顶是这 100 个里最小的)。之后每读入一个数:比堆顶大就替换堆顶并向下调整,否则跳过。全部读完后堆中就是最大的 100 个。时间 O(n log 100) = O(n),n = 10⁸;空间只需 100 个单元。
找最大的 k 个却用大根堆;删除堆顶后忘了把最后一个元素补上来;有两个孩子时少比一次。
| 评分点 | 分 |
|---|---|
| (1) 建堆过程与结果、比较次数 | 4 |
| (2) 三次删除后的堆 | 3 |
| (3) 选小根堆、方法与复杂度 | 3 |
(1)(2) 的每一步由程序按讲义的向下调整代码模拟核对。
- 按评分细则逐项给自己打分,扣分的地方回到对应的篇章重读。
- 算法题把自己的代码敲进电脑,用题目中的例子和两三个边界例子(空、一个元素、全相同)跑一遍。
- 把错因写进自己的“错题本”:是思路没想到、边界漏了,还是表达不清。
9.10预测题:考纲里有、真题还没正面考过的六段代码
下面六道题对应的代码在前面各篇都讲过,但 2009—2026 年的大题还没有直接要求写出它们。它们的共同点是代码短、模板固定、和已考过的题“只差一步”,是近几年最可能出现的新面孔。每道都按大题格式给出满分解答和评分点,代码均已编译并用随机数据对拍。
无向图有 n 个顶点(编号 0 ~ n − 1),m 条边存放在数组 edge[m][2] 中。请用并查集设计算法,判断图中是否存在回路。要求写出思想、代码(含并查集的查找与合并)和复杂度。
先在纸上自己做一遍,再点这里展开满分解答
开始时每个顶点自成一个集合。逐条加入边 (u, v):若 u、v 已在同一集合(根相同),说明它们早已连通,再加这条边就构成回路;否则合并两个集合。查找时做路径压缩,合并时把小树挂到大树下。
#define MAXV 100 int parent[MAXV]; // parent[i] < 0:i 是根,-parent[i] 是集合大小 int Find(int x) { // 找根,并做路径压缩 int root = x; while (parent[root] >= 0) root = parent[root]; while (x != root) { int t = parent[x]; parent[x] = root; x = t; } // 沿途结点直接挂到根上 return root; } bool HasCycle(int n, int edge[][2], int m) { for (int i = 0; i < n; i++) parent[i] = -1; // 每个顶点自成一个集合 for (int k = 0; k < m; k++) { int a = Find(edge[k][0]), b = Find(edge[k][1]); if (a == b) return true; // 两端已连通,这条边构成回路 if (parent[a] > parent[b]) { int t = a; a = b; b = t; } // 让 a 是较大的集合 parent[a] += parent[b]; parent[b] = a; // 小树并到大树下 } return false; }
每条边做两次查找、一次合并,按秩合并 + 路径压缩后每次几乎是常数,时间约 O(n + m)(严格是 O(n + m·α(n)),α 增长极慢);空间 O(n)。
只会暴力时:每加一条边就从 u 做一次 DFS 看能否到 v,O(m(n + m)),思想写清楚也能拿到大部分分。
| 评分点 | 分 |
|---|---|
| 思想:同一集合再加边即成环 | 4 |
| 代码:Find、合并、判断(有路径压缩或按秩合并满分,都没有扣 1) | 7 |
| 复杂度 | 2 |
用顺序存储的小根堆实现一个整数优先队列,写出类型定义和插入 Insert、删除最小元素 DeleteMin 两个操作,并说明它们的时间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
堆存在数组 data[1..size] 中,结点 k 的双亲是 k/2、孩子是 2k 和 2k + 1。插入:放在末尾,比双亲小就上浮。删除最小:取走 data[1],用最后一个元素补上,比较小的孩子大就下沉。
#define MAXH 1000 typedef struct { int data[MAXH + 1]; int size; } MinHeap; // data[1..size] bool Insert(MinHeap &H, int x) { if (H.size == MAXH) return false; int k = ++H.size; while (k > 1 && H.data[k / 2] > x) { // 双亲比 x 大:双亲下移一层 H.data[k] = H.data[k / 2]; k /= 2; } H.data[k] = x; // 找到位置再放 return true; } bool DeleteMin(MinHeap &H, int &x) { if (H.size == 0) return false; x = H.data[1]; int last = H.data[H.size--], k = 1, c; while ((c = 2 * k) <= H.size) { // k 有左孩子 if (c < H.size && H.data[c + 1] < H.data[c]) c++; // 取较小的孩子 if (last <= H.data[c]) break; // last 不比孩子大:放在 k H.data[k] = H.data[c]; k = c; // 孩子上移 } H.data[k] = last; return true; }
两个操作都只沿一条根到叶的路径移动,时间 O(log n);空间 O(1)(不算堆本身)。
只会暴力时:用无序数组,插入 O(1)、删除最小 O(n)(扫描找最小);或有序数组,插入 O(n)、删除 O(1)。写对也能拿到一半以上。
| 评分点 | 分 |
|---|---|
| 类型定义与下标关系 | 2 |
| Insert 上浮 | 3 |
| DeleteMin 补位 + 下沉(选较小孩子) | 3 |
| 复杂度 | 2 |
二叉树采用二叉链表存储。请不用递归,设计算法求中序遍历序列中的第 k 个结点的值(不存在时返回 −1)。
先在纸上自己做一遍,再点这里展开满分解答
用栈模拟中序遍历:从根开始一路向左,沿途结点进栈;走到空时出栈一个结点访问它(计数加 1,若等于 k 就返回),再转向它的右子树。栈空且当前指针为空时遍历结束。
typedef struct BiTNode { int data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; int KthInorder(BiTree T, int k) { // 中序第 k 个结点的值,不存在返回 -1 BiTNode *st[100]; int top = -1; // 栈(树高不超过 100) BiTNode *p = T; int cnt = 0; while (p != NULL || top >= 0) { if (p != NULL) { st[++top] = p; p = p->lchild; } // 一路向左,沿途进栈 else { p = st[top--]; // 出栈:它的左子树已访问完 if (++cnt == k) return p->data; // 访问它 p = p->rchild; // 转向右子树 } } return -1; }
每个结点进栈、出栈各一次,时间 O(n)(找到第 k 个就提前结束);栈的深度不超过树高 h,空间 O(h)。
只会递归时:写出递归中序 + 全局计数器,注明“题目要求非递归,此为递归版”,一般只扣代码分的一部分。
| 评分点 | 分 |
|---|---|
| 思想:栈模拟中序(一路向左、出栈访问、转右) | 4 |
| 代码正确(含结点定义) | 7 |
| 复杂度 | 2 |
有向图用邻接表存储(顶点 0 ~ n − 1)。设计算法输出一个拓扑序列;若图中有回路,返回 false。
先在纸上自己做一遍,再点这里展开满分解答
先遍历所有边表算出每个顶点的入度,入度为 0 的顶点进栈。反复出栈一个顶点 v 输出,并把 v 的每个邻接点入度减 1,减到 0 的进栈。最后若输出的顶点数少于 n,剩下的顶点都在回路上(它们的入度永远减不到 0)。
#define MAXV 100 typedef struct ArcNode { int adjvex; struct ArcNode *next; } ArcNode; typedef struct { ArcNode *first; } VNode; typedef struct { VNode adj[MAXV]; int n; } ALGraph; bool TopoSort(ALGraph *G, int topo[]) { // 有回路返回 false int indeg[MAXV] = {0}, st[MAXV], top = -1, cnt = 0; for (int v = 0; v < G->n; v++) // 统计入度 for (ArcNode *p = G->adj[v].first; p; p = p->next) indeg[p->adjvex]++; for (int v = 0; v < G->n; v++) if (indeg[v] == 0) st[++top] = v; while (top >= 0) { int v = st[top--]; topo[cnt++] = v; // 输出 v for (ArcNode *p = G->adj[v].first; p; p = p->next) if (--indeg[p->adjvex] == 0) st[++top] = p->adjvex; // 删去 v 的出边 } return cnt == G->n; // 没能全部输出 ⇒ 有回路 }
统计入度 O(n + e),每个顶点进出栈一次、每条边处理一次,总时间 O(n + e);空间 O(n)。
只会暴力时:每轮扫描全部顶点找一个“入度为 0 且未输出”的,O(n²)(邻接矩阵上就是这样),同样正确,只是慢。
| 评分点 | 分 |
|---|---|
| 思想:入度为 0 的先输出,删边,最后计数判环 | 4 |
| 代码正确(O(n + e) 满分;O(n²) 扣 1 ~ 2) | 7 |
| 复杂度 | 2 |
带权有向图用邻接矩阵存储(无边记 INF,权值都为正)。写出求顶点 v0 到其余各顶点最短路径长度的算法。
先在纸上自己做一遍,再点这里展开满分解答
dist[i] 记录目前已知的 v0 到 i 的最短长度,final[i] 标记是否已确定。每轮从未确定的顶点中选 dist 最小的 u 确定下来,再用 u 作中转更新其他未确定顶点的 dist;共 n − 1 轮。
#define INF 0x3f3f3f3f #define MAXV 100 typedef struct { int edge[MAXV][MAXV]; int n; } MGraph; // 无边记 INF void Dijkstra(MGraph &G, int v0, int dist[]) { bool final[MAXV]; for (int i = 0; i < G.n; i++) { dist[i] = G.edge[v0][i]; final[i] = false; } dist[v0] = 0; final[v0] = true; for (int r = 1; r < G.n; r++) { // 每轮确定一个顶点 int u = -1, min = INF; for (int i = 0; i < G.n; i++) if (!final[i] && dist[i] < min) { min = dist[i]; u = i; } // 未确定中最近的 if (u == -1) break; // 剩下的都不可达 final[u] = true; for (int i = 0; i < G.n; i++) // 以 u 为中转更新 if (!final[i] && G.edge[u][i] != INF && dist[u] + G.edge[u][i] < dist[i]) dist[i] = dist[u] + G.edge[u][i]; } }
n − 1 轮,每轮选最小 O(n)、更新 O(n),时间 O(n²);空间 O(n)。
INF 相加溢出(要先判断 edge[u][i] != INF);把已确定的顶点又更新了;不可达时还继续选顶点。
| 评分点 | 分 |
|---|---|
| 思想:贪心选最近未确定顶点 + 松弛 | 4 |
| 代码正确 | 7 |
| 复杂度 | 2 |
数组 a[0..n−1] 中只有 0、1、2 三种值(分别代表红、白、蓝)。请设计时间 O(n)、空间 O(1) 的算法,把它排成“所有 0 在前、1 居中、2 在后”。
先在纸上自己做一遍,再点这里展开满分解答
用 i、j、k 把数组分成四段:a[0..i−1] 全是 0,a[i..j−1] 全是 1,a[j..k] 未处理,a[k+1..n−1] 全是 2。看 a[j]:是 0 就和 a[i] 交换,i、j 都加 1;是 1 就 j 加 1;是 2 就和 a[k] 交换,k 减 1(换来的 a[j] 还没看过,j 不动)。j > k 时结束。
void SortColors(int a[], int n) { // a 中只有 0、1、2 int i = 0, j = 0, k = n - 1; while (j <= k) { if (a[j] == 0) { int t = a[i]; a[i] = a[j]; a[j] = t; i++; j++; } // 换到 0 区末尾 else if (a[j] == 1) j++; // 1 留在原地 else { int t = a[j]; a[j] = a[k]; a[k] = t; k--; } // 换到 2 区前面 } }
每步 j 加 1 或 k 减 1,未处理区每步缩小 1,共 n 步,时间 O(n);空间 O(1)。
只会简单做法时:先数出 0、1、2 各有几个,再按个数重写数组——也是 O(n)、O(1),完全正确,只是要扫两遍(若题目要求“一趟扫描”才不满分)。
| 评分点 | 分 |
|---|---|
| 思想:三指针分区 | 4 |
| 代码正确 | 7 |
| 复杂度 | 2 |
考场手册:考前一周用的速查表
这一篇不讲新东西,只把全书最常用的代码模板、公式、性质、易错点压缩成几张表,并提供按年份刷题的入口。考前一周每天过一遍:公式能默写、模板能默写、易错清单能说出原因,就可以上考场了。
10.0全书一页纸:每篇一行
考前最后一天,只看这一张表:每一行都能说出“这是什么、怎么做、在哪一节”,就说明整本书已经串起来了。
| 篇 | 核心结构 / 算法 | 必背结论 | 必会题型(链接) |
|---|---|---|---|
| 1 绪论 | 逻辑结构 / 存储结构;时间、空间复杂度 | 常对幂指阶;内层依赖外层要求和;递归空间 = 最大深度 | 1.3 三类循环 |
| 2 线性表 | 顺序表、单 / 双 / 循环 / 静态链表 | 插入移动 n − i + 1、平均 n/2;先连后面再连前面;前驱能否 O(1) 决定操作代价 | 2.4 大横评、2.5 基本功 |
| 3 栈队列数组 | 栈、循环队列、表达式求值、特殊矩阵 | 312 模式不可能;卡特兰数;(rear + 1) % M == front;数“前面有几个” | 3.8 必会题型 |
| 4 串 | KMP、next、nextval | i 不回退;next[j] = PM + 1;nextval 相同抄 nextval[next[j]] | 4.5 总结 |
| 5 树 | 二叉树性质、遍历、线索、树与森林、哈夫曼、并查集、堆 | n₀ = n₂ + 1;遍历序列还原(必须有中序);WPL = 非叶结点权之和 | 5.12 总结 |
| 6 图 | 存储、BFS / DFS、MST、最短路、拓扑、关键路径 | Dijkstra 不能有负权;拓扑唯一 ⟺ 每步一个入度 0;关键路径是最长路径 | 6.11 总结 |
| 7 查找 | 折半、BST、AVL、红黑树、B / B+ 树、散列 | 折半最多 ⌈log₂(n + 1)⌉ 次;AVL 最少结点 1, 2, 4, 7, 12, 20;散列失败 ASL 的分母 | 7.10 总结 |
| 8 排序 | 九种内部排序、外部排序 | 快些选堆不稳定;选堆归基时间与初态无关;2 × 块数 × (趟数 + 1) | 8.11 总结 |
| 9 训练营 | 八类大题 + 预测题 + 三套整卷 | 思想 4 + 代码 7 + 复杂度 2;暴力解也能拿七成 | 9.10 预测题 |
| 11 满分冲刺 | 命题规律、新考点专项、Ⅰ/Ⅱ/Ⅲ 辨析、三套 45 分模拟卷、细目表 | 组合题每条都要判;冷点、新点(红黑树、并查集、堆应用、Floyd)都要练过;限时 60 分钟做一套 | 11.6 细目表 |
考前一周怎么用这本书
| 时间 | 做什么 | 用到本书哪里 |
|---|---|---|
| 第 1 天 | 把上面“一页纸”逐行默说一遍,说不出来的那一行回到对应篇的“一页总结”补 | 10.0、各篇总结 |
| 第 2 天 | 默写 10.1 的代码模板:逆置、归并、头插、快慢指针、二叉树递归、BFS / DFS、Partition、堆调整 | 10.1、第 9 篇“三个骨架” |
| 第 3 天 | 按年份刷近 5 年选择题,错题按题号跳回小节,只看“易错”和“真题怎么考” | 10.5、各节易错框 |
| 第 4 天 | 做一套整卷模拟(41 + 42 限时 40 分钟),按评分细则自己打分 | 9.9、各类“评分细则” |
| 第 5 天 | 把 10.4 的易错清单从头到尾过一遍,每条遮住后半句自问自答 | 10.4 |
| 第 6 天 | 再做一套整卷;手算题(KMP、AVL、B 树、散列、关键路径、堆)各做一道限时练 | 9.9、各篇跟做 |
| 第 7 天 | 只看公式速查和一页纸,不再做新题,保持手感、早睡 | 10.0、10.3 |
原则:最后一周不学新东西,只把会的变成“又快又不错”。选择题的分大多丢在约定和边界上(下标从 0 还是 1、rear 指哪里、分母是 p 还是 m),所以 10.4 的易错清单比再刷十道新题更值。大题则靠“暴力解保底 + 一步优化”,模板默写熟了,考场上就不会卡在语法上。考前最后一晚只需翻一遍各篇的“一页总结”,按时睡觉——清醒的头脑在考场上值好几分,祝你考出好成绩。
10.1算法大题代码模板索引
下面每个模板都在正文里有完整代码(点链接跳转),这里只列“什么时候用 + 核心一句”。考前把每个模板在纸上默写一遍。
| 模板 | 什么时候用 | 核心一句 | 位置 |
|---|---|---|---|
| 原地逆置 | 循环移位、两段交换、逆序 | while (i < j) swap(a[i++], a[j--]); | 2.5 |
| 保留式删除 | 删除所有满足条件的元素 | if (保留) a[k++] = a[i]; 最后 length = k | 2.5 |
| 有序表归并 / 双指针 | 合并、交集、最近的一对、中位数 | 每次移动较小者的指针 | 2.5 |
| 计数 / 标记数组 | 值域已知,判出现、计次数 | flag[x] = 1;(注意范围检查) | 9.1 |
| 快排划分 / 快速选择 | 排序、第 k 小、按枢轴分两块 | high 往左找小、low 往右找大,交替填坑 | 8.5 |
| 头插法逆置链表 | 逆置、后半段逆置、回文判断 | r = p->next; p->next = L->next; L->next = p; p = r; | 2.5 |
| 快慢指针 | 中间结点、倒数第 k 个 | 快的走 2 步 / 先走 k 步 | 2.5 |
| 链表删除 | 按条件删结点 | 指针停在前驱:q = p->next; p->next = q->next; free(q); | 2.3 |
| 二叉树递归 | 高度、结点数、叶子、WPL、判定 | 空树出口 → 递归左右 → 合并结果 | 5.4 |
| 层序遍历(按层) | 宽度、每层处理、完全二叉树判定 | cnt = rear - front; 一次处理一层 | 9.3 |
| 非递归中序 / 后序 | 要求非递归、求祖先路径 | 一路向左压栈;后序用 r 记录最近访问 | 5.4 |
| BST 查找路径 | 查找、插入、最接近、前驱后继 | T = key < T->key ? T->lchild : T->rchild; | 7.4 |
| 邻接矩阵数度 | 度、入度、出度、K 顶点、欧拉路径 | 行 = 出度,列 = 入度 | 9.4 |
| DFS / BFS | 连通性、路径存在、遍历序列 | visited + 递归 / 队列 | 6.4 |
| 拓扑排序 | 判环、判唯一、排序 | 入度数组,反复取入度为 0 的顶点 | 6.9 |
| 堆调整 | 堆排序、Top-k | 向下调整:与较大的孩子比,大就交换继续 | 8.6 |
| 折半查找 | 有序顺序表 | while (low <= high),mid = (low + high)/2 | 7.2 |
| KMP 求 next | 模式匹配 | 模式串自己与自己匹配 | 4.3 |
| 三指针原地逆置 | 不带头结点的链表逆置 | r = p->next; p->next = pre; pre = p; p = r; | 2.5 |
| 有序链表合并 | 两个有序链表合成一个(用原结点) | r 指结果表尾,较小者接到 r 后,剩下的整段接上 | 2.5 |
| 环的入口 | 判断有环、找入口 | 快慢指针相遇后,一个回到表头,同速再走 | 2.5 |
| 双栈表达式求值 | 中缀表达式直接求值 | 栈顶优先级 ≥ 当前就先算栈顶;先弹出的是右操作数 | 3.5 |
| 三色划分 / 第 k 小 | 0、1、2 排序;第 k 小(快速选择) | i、j、k 三区;划分后只到一边继续 | 8.5 |
| 堆的插入 / 删除 | 优先队列、Top-k | 插入放末尾上浮;删除末尾补顶下沉 | 8.6、9.10 |
| 并查集 | 判环、连通分量、Kruskal | Find 路径压缩;小树并到大树 | 5.10、9.10 |
| Dijkstra | 单源最短路(非负权) | 选未确定的最近顶点,再松弛 | 6.7、9.10 |
| 败者树重赛 | k 路归并选最小 | 沿叶子到根,输了留下、赢的往上 | 8.10 |
10.2复杂度与性质总表
| 结构 | 按位 / 按值查找 | 插入 | 删除 | 备注 |
|---|---|---|---|---|
| 顺序表 | O(1) / O(n) | O(n),平均移动 n/2 | O(n),平均移动 (n−1)/2 | 随机存取 |
| 单链表 | O(n) / O(n) | 已知前驱 O(1) | 已知前驱 O(1) | 只能顺序存取;找前驱 O(n) |
| 栈、队列 | — | O(1) | O(1) | 循环队列满:(rear+1)%M == front |
| 有序顺序表 | 折半 O(log n) | O(n) | O(n) | 静态查找 |
| BST | 平均 O(log n),最坏 O(n) | 同左 | 同左 | 中序有序 |
| AVL / 红黑树 | O(log n) | O(log n) | O(log n) | AVL 删除可能旋转到根;红黑树插删常数次旋转 |
| B / B+ 树 | O(log n)(读盘次数 = 树高) | 分裂 | 借 / 合并 | 外存索引 |
| 散列表 | 平均 O(1) | O(1) | 开放定址只能逻辑删除 | ASL 取决于 α、H、冲突处理 |
| 图算法 | 邻接矩阵 | 邻接表 | 备注 |
|---|---|---|---|
| BFS / DFS | O(n²) | O(n + e) | BFS 求无权图最短路径 |
| 拓扑排序 | O(n²) | O(n + e) | 唯一 ⟺ 每步一个入度 0 |
| Prim | O(n²) | 稠密图 | |
| Kruskal | O(e log e) | 稀疏图,并查集 | |
| Dijkstra | O(n²) | 不允许负权边 | |
| Floyd | O(n³) | 允许负权边,不允许负权回路 | |
| 关键路径 | O(n + e)(邻接表) | ve 拓扑序取 max,vl 逆拓扑序取 min | |
| 结构 | 主要操作 | 时间 | 备注 |
|---|---|---|---|
| 堆(n 个元素) | 建堆 / 插入 / 删除堆顶 | O(n) / O(log n) / O(log n) | 建堆比较次数 ≤ 4n(8.6) |
| 并查集 | Find / Union | 按秩合并 + 路径压缩后近似 O(1) | 只按秩合并时树高 ≤ ⌊log₂n⌋ + 1 |
| KMP | 求 next / 匹配 | O(m) / O(n) | 朴素匹配最坏 (n − m + 1)·m 次比较 |
| 递归 | — | 看递推式(展开 / 递归树) | 空间 = 最大深度 × 每层空间(1.4) |
排序算法“比较 / 移动 / 趟数 / 时间”与初始序列的关系见 8.9 对照表;各种链表各操作的时间见 2.4 大横评。
排序算法的完整比较见 8.9 节的总表。
10.3公式速查
| 内容 | 公式 |
|---|---|
| 树 | n = 边数 + 1 = Σ(i·nᵢ) + 1;n₀ = 1 + Σ(i − 1)nᵢ;m 叉树第 i 层 ≤ mi−1;高 h 至多 (mh − 1)/(m − 1) 个结点;最小高度 ⌈logm(n(m−1)+1)⌉ |
| 二叉树 | n₀ = n₂ + 1;第 i 层 ≤ 2i−1;高 h 至多 2h − 1;n 个结点的二叉链表有 n + 1 个空指针 |
| 完全二叉树 | 高 ⌈log₂(n+1)⌉ = ⌊log₂n⌋ + 1;叶子 ⌈n/2⌉;n 偶数时 n₁ = 1;结点 i 的双亲 ⌊i/2⌋、孩子 2i、2i + 1(从 0 起:⌊(i−1)/2⌋、2i + 1、2i + 2) |
| n 个结点的二叉树个数 | 卡特兰数 Cₙ = C(2n, n)/(n + 1):1, 1, 2, 5, 14, 42, 132, 429;也是 n 个元素出栈序列数 |
| 哈夫曼树 | n 个叶子共 2n − 1 个结点;WPL = 非叶结点权之和;m 叉:(n − 1) % (m − 1) ≠ 0 时补 0 权虚结点 |
| 森林 ↔ 二叉树 | 森林的树数 = 结点数 − 边数;二叉树左指针空 ⟺ 森林叶子;无右孩子的结点数 = 分支结点数 + 1 |
| 图 | 度之和 = 2|E|;无向完全图 n(n−1)/2 条边;有向完全图 n(n−1);连通至少 n − 1 条;保证连通 (n−1)(n−2)/2 + 1 条 |
| AVL | 高 h 最少结点 Nₕ = Nₕ₋₁ + Nₕ₋₂ + 1:1, 2, 4, 7, 12, 20, 33 |
| B 树(m 阶,高 h) | 关键字最多 mh − 1;最少 2⌈m/2⌉h−1 − 1;非根结点关键字 ⌈m/2⌉ − 1 ~ m − 1;叶子(失败结点)n + 1 个 |
| 折半查找 | 最多比较 ⌈log₂(n+1)⌉ 次(成功、失败都是);判定树 n + 1 个失败结点 |
| 分块查找 | 两级顺序:ASL = (b+1)/2 + (s+1)/2,s = √n 时最小为 √n + 1 |
| 散列表 | α = n/m;ASL 失败分母 = H 的值域个数;与空位的比较计入 |
| 顺序查找 | ASL 成功 (n + 1)/2;失败 n + 1(带哨兵) |
| KMP | next[1] = 0,next[j] = 最长相等前后缀 + 1;右移位数 = j − next[j](next 为 0 时右移 j) |
| 特殊矩阵(矩阵从 1 编号、数组从 0 存) | 对称下三角 k = i(i−1)/2 + j − 1;三对角 k = 2i + j − 3;二维数组行优先 LOC = base + (i·n + j)·L |
| 外部排序 | 归并趟数 ⌈logk r⌉;读写次数 = 2 × 块数 × (趟数 + 1);最佳归并树读写 = 2 × WPL;补虚段个数:u = (r − 1) % (k − 1),u ≠ 0 时补 k − 1 − u 个,u = 0 时不补 |
| 归并两表 | 最坏比较 m + n − 1,最好 min(m, n) |
| 循环队列四种约定 | rear 指队尾下一个:满 (rear + 1) % M == front,个数 (rear − front + M) % M;rear 指队尾元素:空 (rear + 1) % M == front,满 (rear + 2) % M == front(3.3 表) |
| 顺序表平均移动 | 插入 n/2、删除 (n − 1)/2;概率不等就加权求和 |
| 卡特兰数(翻折法) | C(2n, n) − C(2n, n + 1) = C(2n, n)/(n + 1) |
| 三维数组 | 行优先 (i·n·p + j·p + k)·L;列优先 (k·m·n + j·m + i)·L |
| 对称矩阵四种组合 | 行优先:从 1 起 i(i − 1)/2 + j − 1,从 0 起 i(i + 1)/2 + j;列优先:从 1 起 (j − 1)(2n − j + 2)/2 + i − j,从 0 起 j(2n − j + 1)/2 + i − j(3.7) |
| 子串个数 | 字符互不相同时:非空子串 n(n + 1)/2,加空串 + 1,非空真子串 − 1 |
| 朴素递归斐波那契 | 调用 2F(n + 1) − 1 次,最大深度 n |
| 主定理(拓展) | T(n) = aT(n/b) + f(n):比 f(n) 与 nlogba,谁大听谁的,一样大多乘 log n |
10.4易错清单(90 条)
- 数据元素是基本单位,数据项是最小单位。
- 逻辑结构独立于存储结构;“有序表”是逻辑概念,“循环队列”是存储概念。
- 算法有穷性:程序可以无穷运行,算法不行。
- 内层循环次数依赖外层时要求和,不能直接相乘(2022-01 是 O(n))。
- O(m + n) = O(max(m, n))。
- 顺序表位序从 1 开始、下标从 0 开始。
- 插入移动 n − i + 1 个、删除移动 n − i 个。
- 单链表插入:先连后面再连前面。
- 循环单链表只有尾指针时删表尾仍是 O(n)。
- 静态链表不能随机存取。
- 出栈序列不可能 ⟺ 存在“大、小、中”(312)模式。
- 以 1 开头与以 2 开头的出栈序列个数相同,都是 Cₙ₋₁。
- 循环队列 rear 指向队尾元素时,初值 rear = n − 1 才能让第一个元素放在 A[0]。
- 输出受限双端队列:倒着拆,最后进的在两端。
- 中缀转后缀:遇到运算符要弹出“同级或更高”的。
- 栈上求值:先弹出的是右操作数。
- 对称矩阵存上三角,要找的元素在下三角时先交换 i、j。
- 三对角矩阵 k = 2i + j − 3(从 0 存、矩阵从 1 编号)。
- 稀疏矩阵三元组表还要存行数、列数。
- KMP:主串指针不回退;比较次数包括失配那次。
- 度为 m 的树至少 m + 1 个结点;m 叉树可以为空。
- 完全二叉树 n 为偶数时有一个度为 1 的结点。
- 先序 = 中序 ⟺ 无左子树;先序与后序相反 ⟺ 每层一个结点。
- 先序 + 后序不能唯一确定二叉树,但能确定父子关系。
- 中序线索树找前驱后继都方便;后序线索树找后继仍需双亲。
- 树的后根遍历 = 对应二叉树的中序遍历。
- 森林叶子数 = 二叉树中左指针为空的结点数。
- 哈夫曼树不一定是完全二叉树,不唯一,但 WPL 唯一。
- 哈夫曼 WPL = 非叶结点权之和;平均码长 = WPL / 总频次。
- m 叉哈夫曼树要补 0 权虚结点。
- 并查集按秩合并后树高 ≤ ⌊log₂n⌋ + 1。
- 图不能是空图(顶点集非空)。
- 回路不是简单路径。
- 邻接表求入度要 O(n + e)。
- BFS 序列:一个顶点的全部未访问邻居要连在一起。
- DFS 序列:下一个必须是栈顶顶点的邻居,除非栈顶已无未访问邻居。
- MST 代价唯一、树形不一定唯一;Kruskal 选出的边权递增。
- MST 上的路径不是最短路径;BFS 只能求无权图最短路径。
- Dijkstra 选“离源点最近”,并且每步都要松弛;不能处理负权边。
- 拓扑序列存在 ⟺ 无环;唯一 ⟺ 每步一个入度为 0 的顶点。
- DFS 退出时输出 = 逆拓扑序列。
- 关键路径是最长路径;缩短关键活动不一定缩短工期(多条关键路径)。
- 活动 <j, k> 的余量 = vl(k) − ve(j) − 持续时间。
- 折半查找只适用于有序顺序表(静态链表也不行)。
- 判定树中结点左右子树结点数之差方向必须一致。
- BST 删除非叶结点再插回,树一定变;删叶子再插回,树不变。
- AVL 旋转类型看新结点相对于最小不平衡子树根的位置;LR / RL 是“孙子上位”。
- AVL 删除后再插回可能相同也可能不同。
- 红黑树新结点染红;叔叔红只染色,叔叔黑要旋转。
- B 树的根可以只有 1 个关键字;非根至少 ⌈m/2⌉ − 1 个。
- B 树插入在终端结点,分裂上移第 ⌈m/2⌉ 个;树只从根长高。
- B 树删除:兄弟富余就借,否则合并(连同双亲的分隔关键字)。
- B+ 树支持顺序查找,关键字全在叶子,查找必到叶子。
- 散列 ASL 失败分母是 H 的值域个数(key % p 为 p),与空位的比较计入。
- 开放定址法只能逻辑删除;平方探测不一定能探测到所有单元。
- 不稳定排序:快、希、选、堆。
- 每趟定一个最终位置:冒泡、快速、简单选择、堆。
- 简单选择比较次数固定 n(n−1)/2,移动最多 3(n−1)。
- 快排第 k 趟后至少 k 个元素到位;枢轴在中间时两边各要到位一个。
- 败者树 ls[0] 存冠军的段号;置换—选择输出“≥ MINIMAX 的最小者”。
- 链表版直接插入排序从前往后找位置,有序输入最慢,与顺序表相反。
- 二路归并的比较次数与初态有关([1,2]+[3,4] 比 2 次,[1,3]+[2,4] 比 3 次);移动次数无关。
- 快排在全部元素相等时同样退化为 O(n²)。
- 双链表插入:改 p->next 的那句必须排在两句用到“原后继”的语句之后(24 种顺序只有 8 种对)。
- “复制后继数据再删后继”的技巧不能删尾结点。
- 循环队列 rear 指向队尾元素时:空 (rear + 1) % M == front,满 (rear + 2) % M == front。
- 前缀表达式从右往左扫,先弹出的是左操作数。
- 递归的空间看最大深度而不是调用次数:朴素 fib(n) 调用指数次,空间只有 O(n)。
- n(n + 1)/2 个子串只在字符互不相同时成立。
- 求 nextval:相同时抄 nextval[next[j]],不是 next[next[j]]。
- 堆的向下调整:两个孩子时每层比 2 次,一个孩子比 1 次。
- 败者树重赛“最多”比较 ⌈log₂k⌉ 次,k 不是 2 的幂时有的叶子更浅。
- 最佳归并树补虚段,是为了让“不满的那次合并”发生在最底层。
- 先序线索化时,递归右子树前也要检查 rtag:只有左孩子的结点,右指针在处理左子树时已被改成线索(5.6)。
- 快排“第二趟”判定:第一趟枢轴在端点至少 2 个到位,在中间至少 3 个且左右各一。
- 时间与初态无关的排序只有“选、堆、归、基”。
- Top-k:找最大的 k 个用小根堆,找最小的 k 个用大根堆。
- “依次插入初始为空的堆”与“将序列建成堆(筛选法)”结果可能不同;前者最坏 O(n log n),后者 O(n)(5.11)。
- 删除堆中间的元素:末尾补位后先和双亲比,比双亲大要上浮,不一定是下沉(5.11)。
- 大根堆中第 k 大元素的深度 ≤ k;最小元素一定在叶子(下标 ⌊n/2⌋ + 1 ~ n),找它要 O(n)(5.11)。
- k 路归并:堆每下沉一层比 2 次,败者树每层比 1 次;外部排序用败者树(5.11、8.10)。
- Top-k 用大小为 k 的堆时,随机数据平均比较次数约等于 n——绝大多数元素只和堆顶比 1 次(2022-42)。
- 有向图 DFS / BFS 外层调用次数既不等于强连通分量数,还与起点次序有关(6.5)。
- MST 唯一的判据:每条非树边都是它所在回路上唯一的最大边;有等权边时 MST 也可能唯一(6.6)。
- 关键活动缩短到一定程度后不再关键,工期只能降到“次长路径”的长度(6.10)。
- Floyd 以 vk 为中间顶点的那一轮,第 k 行、第 k 列不变,所以可以原地更新(6.7)。
- 红黑树黑高为 bh(不含该结点、含 NIL)时,内部结点数在 2bh − 1 ~ 4bh − 1 之间;插入至多旋转 2 次(7.6)。
- 并查集 Union 的参数必须是根:不先 Find 会把集合关系弄乱,甚至出现互指的环(5.10、11.1)。
- 折半查找的判定树是平衡二叉树,但不一定是完全二叉树(n = 5 就不是)(7.2)。
- 多维数组行优先时最右边的下标变得最快,列优先时最左边的变得最快。
10.5按年份刷题
2009—2026 年全部 233 道数据结构真题。点题号弹出题目,做完自动记录对错;勾选“只显示错题”可以集中复习错题。建议最后一个月每两天做一年(11 道选择 + 2 道大题,限时 50 分钟)。
满分冲刺:命题规律、新考点专项、概念辨析与全真模拟
前十篇解决“会不会”,这一篇解决“稳不稳”。数据结构从 40 分到 45 分,差的不是新知识,而是三件事:字眼和口径不出错、Ⅰ/Ⅱ/Ⅲ 组合题每一条都判对、考纲有而真题少的点也练过。本篇先用 18 年真题的统计说清命题规律,再给出新考点专项、概念辨析专练和三套 45 分的全真模拟卷,最后附一张可以逐条打勾的全书知识点细目表。
11.0从 40 分到 45 分:满分差在哪里
能考 40 分的同学,知识点基本都学过。剩下的 5 分,按我改卷和带学生的经验,几乎全丢在下面四个地方:
| 丢分的地方 | 典型表现 | 本书对策 |
|---|---|---|
| ① 字眼与口径 | “一定 / 可能 / 至少 / 至多”看漏;下标从 0 还是从 1;B 树高度含不含失败结点;散列失败 ASL 的分母取 p 还是 m;“依次插入”和“建堆”;数比较次数还是移动次数;“趟”怎么算 | 各节“易错”框;10.4 易错清单;本篇 11.2 |
| ② 组合题漏判一条 | Ⅰ/Ⅱ/Ⅲ/Ⅳ 四个说法判对三个,照样 0 分。18 年里 22 道,近六年占比明显上升(下图) | 本篇 11.2 概念辨析专练 |
| ③ 冷点、新点没准备 | 红黑树、并查集、Floyd 从没出过选择题;堆的应用 2025 年才单列;线索二叉树、循环队列、冒泡排序已经十年以上没考,随时可能回来 | 本篇 11.1 专项;5.11 堆及其应用 |
| ④ 大题的细节分 | 空表、只有一个元素等边界没处理;复杂度和代码对不上;没写注释;思想写成了代码的复述 | 第 9 篇各类“评分细则与保底策略”;本篇三套模拟卷的评分表 |
18 年的选择题落在哪里
从这张图能读出四条规律,备考时间要据此分配:
- 树、查找、排序是基本盘:每年合计 5 ~ 7 道选择题,一道都不能丢。其中树(5.4 遍历、5.8 森林、5.9 哈夫曼)和查找(7.5 AVL、7.7 B 树、7.9 散列)几乎年年有。
- 图稳定在 1 ~ 4 道:选择题考概念和手算过程(拓扑排序、关键路径、最小生成树),大题考应用或代码。
- 线性表、栈和队列在选择题里“小”,在大题里“大”:选择题合计 34 道,但 41 题的数组 / 顺序表(7 次)、链表(4 次)占了 18 道算法题的一大半。
- 组合题在变多:2009—2014 年 7 道(占 11%),2015—2020 年 5 道(8%),2021—2026 年 10 道(15%),其中 2022、2023 两年就有 7 道。组合题的特点是“每一条都要会”,碰运气的空间最小。
统计口径:每道选择题按本书讲解标注的主归属小节归到章;组合题指题干中出现 Ⅰ/Ⅱ/Ⅲ 等多个说法、选项是说法组合的题。统计程序 verify/10/p11/stats.py。
大题的轮换规律
| 题型 | 考过的年份 | 最近一次 | 2027 的看法 |
|---|---|---|---|
| 算法 · 数组 / 顺序表 | 2010、2011、2013、2016、2018、2020、2025 | 2025 | 出现最多的题型,任何一年都可能 |
| 算法 · 链表 | 2009、2012、2015、2019 | 2019 | 已经 7 年没考,回归概率最高:快慢指针、逆置、归并、去重要练熟 |
| 算法 · 二叉树 | 2014、2017、2022、2026 | 2026 | 刚考过,概率相对低,但递归模板必须会 |
| 算法 · 图(邻接矩阵 / 邻接表) | 2021、2023、2024 | 2024 | 近几年的新热点,度、路径、遍历类小算法要会写 |
| 应用 · 图(最短路、MST、关键路径、存储) | 2009、2011、2014、2015、2017、2018、2025 | 2025 | 42 题的常客 |
| 应用 · 查找(散列、ASL) | 2010、2013、2024 | 2024 | 散列建表 + 两种 ASL 是稳拿分 |
| 应用 · 排序与外部排序 | 2012、2021、2022、2023 | 2023 | 外排(置换-选择、败者树、最佳归并树)可能成为 42 题 |
| 结构设计与性质推导 | 2016、2019、2020、2026 | 2026 | 考“理解”:给场景选结构、推公式 |
新考纲还给了两个“现成的 41 题”:并查集(求连通分量、判环,5.10)和堆(判堆、优先队列插入删除、k 路归并,5.11)。它们代码短、能和图或排序结合,命题人很容易拿来出题。
2027 年值得重点准备的点
| 考点 | 真题情况 | 为什么值得准备 | 本书位置 |
|---|---|---|---|
| 堆及其应用 | 堆的操作考过 7 次;“应用”未考 | 2025 年大纲单列,命题人会从“操作”转向“应用” | 5.11、11.1 |
| 红黑树 | 从未考过 | 2022 年进入大纲,已经五年没有出题 | 7.6、11.1 |
| 并查集 | 从未考过 | 同上;还能出成 41 题 | 5.10、11.1 |
| Floyd 算法 | 选择题从未考过 | 考纲点,与 Dijkstra 对比是天然的辨析题 | 6.7、11.1 |
| B+ 树 | 2016、2017 后未考 | 与 B 树的区别是固定考法,近十年没出 | 7.8、11.1 |
| 十字链表、邻接多重表 | 2024 考过一次 | 存储结构题的新角度 | 6.3、11.1 |
| 外部排序三件套 | 2023—2026 年考了 3 次(2023-42、2024-11、2026-11) | 置换-选择、败者树、最佳归并树三选一,几乎必考 | 8.10、11.1 |
| 线索二叉树 | 2014 年后未考 | “冷了很久”的经典点 | 5.6、11.1 |
| 循环队列、冒泡排序、递归的空间 | 分别在 2014、2010、2012 年后没考 | 基础点,回归时往往换新的约定或问法 | 3.3、8.4、1.4,11.1 |
- 一轮学完以后做 11.1、11.2:做错的题点链接回到对应小节,把那一节的“易错”框重读一遍。
- 怎么作答:本篇(以及全书各节)的练习题都可以直接点选项,点完立刻判对错并展开解析;三套模拟卷则先只记下你的选择,点“交卷”后统一判分、列出错题(选择记录保存在本机浏览器)。
- 考前四周做三套模拟卷,每套限时 60 分钟:11 道选择题 20 分钟、41 题 25 分钟、42 题 15 分钟。严格计时,做完再对答案。选择题错 1 道以内、大题得分率 85% 以上,才算达到满分水平。
- 考前一周用 11.6 细目表逐条自查:每一条都能说出结论和理由,就勾上;勾不上的回原文。
11.1考纲有、真题少:新考点与冷考点专项
下面每个考点先说“为什么可能考、会怎么考”,再给 2 ~ 3 道按 408 风格自编的选择题。题目全部是原创数据,答案和每个干扰项都由程序模拟或枚举核对过(verify/10/p11/new_topics.py)。建议先盖住解析,限时每题 2 分钟。
① 红黑树:插入后的颜色、黑高与结点数、性质辨析
为什么可能考:2022 年进入大纲,至今没出过题;它和 AVL 树、B 树都能对比。会怎么考:给插入序列问最后的形状或红结点;给黑高问结点数范围;Ⅰ/Ⅱ/Ⅲ 判断性质与推论。复习:7.6 插入、逐步图。
- A. 10、30
- B. 15、25
- C. 5、15、25
- D. 5、15、25、30
展开解析
C逐步:10 为根(黑)。20 红,父黑,不调。30 红,父 20 红、叔叔 NIL 黑,RR 型 ⇒ 以 10 左旋,20 成为根并染黑,10、30 染红。15 插在 10 的右边,父 10 红、叔叔 30 红 ⇒ 只变色:10、30 变黑,20 变红,20 是根再染回黑。25 插在 30 左边,父 30 黑,不调。5 插在 10 左边,父 10 黑,不调。最终 20(10(5, 15), 30(25, )),黑:20、10、30;红:5、15、25。整个过程只旋转了 1 次。
干扰项:A 是“插 15 之前”的红结点;B 漏了最后插入的 5(新结点一律红,父黑就不再变)。见 7.6。
- A. 7,63
- B. 8,64
- C. 7,31
- D. 15,63
展开解析
A最少:没有红结点、每条路径都只有 3 个黑结点(含 NIL)⇒ 内部结点是高 3 的满二叉树,2³ − 1 = 7。最多:红黑交替插满,黑、红、黑、红、黑、红共 6 层 ⇒ 2⁶ − 1 = 63。一般地,黑高为 bh 的红黑树内部结点数在 [2bh − 1, 4bh − 1] 之间。
口径提醒:有的书黑高不数 NIL,此时同一棵树黑高少 1,公式也跟着平移。做题先看题目怎么定义。见 7.6 跟做。
Ⅰ. 红结点的两个孩子(含 NIL)一定都是黑色 Ⅱ. 红黑树一定是平衡二叉树(AVL 树)
Ⅲ. 插入一个关键字后,至多需要旋转 2 次 Ⅳ. 新插入的结点一律染成黑色,以免出现两个相邻的红结点
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅲ、Ⅳ
- C. 仅 Ⅰ、Ⅳ
- D. 仅 Ⅰ、Ⅲ
展开解析
DⅠ 对:不能有两个相邻的红结点,NIL 规定为黑。Ⅱ 错:红黑树只保证最长路径不超过最短路径的 2 倍,左右子树高度差可以大于 1;反过来 AVL 树都能染成红黑树。Ⅲ 对:叔叔黑时旋转(LR / RL 是 2 次)后就结束,叔叔红只变色。Ⅳ 错:新结点染红——染黑会让它所在路径多一个黑结点,破坏“各路径黑结点数相同”,比红红相邻难修得多。
见 7.6 定义与结论。
② 并查集:数组状态、路径压缩、树高上界
为什么可能考:2022 年进入大纲,从未出题;Kruskal 算法里本来就要用它。会怎么考:给一串 Union / Find 问最后的 S 数组;给 S 数组问压缩以后的样子;问按秩合并后树高的上界。复习:5.10。
- A. −4, 0, 0, 2, −3, 4, 4, −1
- B. 1, 3, 3, −4, 5, −3, 5, −1
- C. 1, 3, 3, −4, 6, −2, 4, −1
- D. 1, 3, 3, −4, 5, −3, 4, −1
展开解析
B前三次两边都只有 1 个结点,走 else 分支,r1 挂到 r2 下:S[0] = 1、S[2] = 3、S[4] = 5,根 1、3、5 的 S 变成 −2。第四次 Find(1) = 1、Find(3) = 3,一样大 ⇒ 1 挂到 3 下:S[1] = 3,S[3] = −4。第五次 Find(6) = 6、Find(4) = 5(4 已经不是根!),5 的树更大 ⇒ 6 挂到 5 下:S[6] = 5,S[5] = −3。
干扰项:A 是“一样大时把 r2 挂到 r1 下”的另一种写法;D 把 6 挂到了元素 4 下(忘了先 Find);C 是直接调用 Union(S, 6, 4) 不先 Find 的结果——此时 S[4] = 6、S[6] = 4,两个元素互指成环,集合彻底乱掉。这正是“Union 的参数必须是根”的原因。(四个结果均由程序执行得到。)
- A. −5, 0, 1, 2, 0, −2, 5
- B. −5, 0, 0, 0, 0, −2, 0
- C. −5, 0, 0, 0, 0, −2, 5
- D. −5, 0, 1, 0, 0, −2, 5
展开解析
C{0, 1, 2, 3, 4} 是一条链 4 → 3 → 2 → 1 → 0,Find(4) 先走到根 0(经过 5 个结点),再把路径上的 4、3、2、1 都直接改成指向 0(1 本来就指向 0)。5、6 在另一个集合里,与这次查找无关。
干扰项:A 只改了起点 4;D 只改了一半;B 把别的集合也压到了 0 下。见 5.10 路径压缩。
- A. 7
- B. 8
- C. 50
- D. 100
展开解析
A一个结点每被压低一层,它所在集合的大小至少翻倍;集合最多 100 个元素,最多翻倍 ⌊log₂100⌋ = 6 次,所以深度至多 6 + 1 = 7。不加任何优化时最坏是一条链,高度 100(D)。
见 5.10 两种小树并大树。
③ 堆的应用:Top-k 选什么堆
堆的位置题、两种建堆、删除中间元素已在 5.11 节末出了两道,这里补一道“应用”题。
- A. 维护一个大小为 k 的大根堆,新数比堆顶大就替换堆顶
- B. 把全部数读入后建大根堆,再删除 k 次堆顶
- C. 把全部数读入后用快速排序,取最后 k 个
- D. 维护一个大小为 k 的小根堆,新数比堆顶大就替换堆顶并向下调整
展开解析
D要找最大的 k 个,候选里“最容易被淘汰”的是最小的那个,所以用小根堆,堆顶当守门员:新数比堆顶大才有资格进来,替换堆顶后向下调整,O(n log k),内存 O(k)。A 用大根堆,堆顶是候选中最大的,无法判断新数该不该进;B、C 都要把全部数据读进内存,不满足 O(k) 的限制。
口诀:找最大的 k 个用小根堆,找最小的 k 个用大根堆(2022-42)。见 5.11 Top-k。
④ Floyd:一轮改了哪些元素、与 Dijkstra 辨析
为什么可能考:选择题从未出过 Floyd,而它是考纲点。会怎么考:给邻接矩阵问加入某个中间顶点后矩阵怎么变;负权、复杂度、适用范围的辨析。复习:6.7 Floyd。
- A. 1
- B. 2
- C. 3
- D. 4
展开解析
C先算前两轮。以 v0 为中间顶点:只有 A[2][1] 从 ∞ 变成 2 + 4 = 6(v2 → v0 → v1)。以 v1 为中间顶点:A[0][2] = 4 + 3 = 7,A[3][2] = 1 + 3 = 4。以 v2 为中间顶点:A[1][0] = 3 + 2 = 5(v1 → v2 → v0),A[1][3] = 3 + 5 = 8,A[3][0] = 4 + 2 = 6(v3 → v1 → v2 → v0),共 3 个。其余如 A[0][3]:经 v2 是 7 + 5 = 12,不比 10 小,不改。最后一轮以 v3 为中间顶点,没有元素再变小。
技巧:以 vk 为中间顶点那一轮,只需看“第 k 列有限值 × 第 k 行有限值”的组合,第 k 行和第 k 列本身这一轮不会变。(逐轮矩阵由程序计算。)
Ⅰ. 允许图中有负权边,但不允许有负权回路 Ⅱ. 时间复杂度为 O(n³);对每个顶点各执行一次 Dijkstra 算法(邻接矩阵存储)总时间也是 O(n³)
Ⅲ. 只适用于有向图,不适用于无向图 Ⅳ. 以 vk 为中间顶点的那一轮中,矩阵的第 k 行和第 k 列不会被修改
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅰ、Ⅱ、Ⅳ
- C. 仅 Ⅱ、Ⅲ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
展开解析
BⅠ 对(有负权回路时“最短路径”不存在)。Ⅱ 对:Dijkstra 一次 O(n²),做 n 次 O(n³),但 Dijkstra 不允许负权边。Ⅲ 错:无向图把每条边看成两条方向相反的弧即可。Ⅳ 对:更新第 k 行用的是 A[k][k] + A[k][j],而 A[k][k] = 0,结果等于原值;第 k 列同理——所以 Floyd 可以在同一个矩阵上原地更新。
见 6.7 适用性矩阵。
⑤ B+ 树:关键字数的范围与 B 树的区别
为什么可能考:2017 年以后没有出过;“B 树和 B+ 树的区别”是固定考法。复习:7.8。
- A. 8,64
- B. 4,64
- C. 8,27
- D. 2,48
展开解析
A最少:根 2 棵子树;第 2 层两个结点各至少 ⌈4/2⌉ = 2 棵子树 ⇒ 4 个叶结点;每个叶结点至少 2 个关键字 ⇒ 8。最多:每个结点 4 棵子树、叶结点 4 个关键字 ⇒ 4 × 4 × 4 = 64。所有关键字都在叶结点里,所以这也就是整棵树能索引的记录数范围。
对比:4 阶 B 树(m − 1 = 3 个关键字)高 3 时关键字最多 4³ − 1 = 63。见 7.8 容量估算。
Ⅰ. 所有关键字都在叶结点中出现 Ⅱ. 随机查找时可能在分支结点就查找成功
Ⅲ. 可以从最小关键字开始沿叶结点链表顺序查找 Ⅳ. 有 k 个关键字的分支结点有 k + 1 棵子树
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅲ、Ⅳ
- C. 仅 Ⅰ、Ⅳ
- D. 仅 Ⅰ、Ⅲ
⑥ 十字链表与邻接多重表
为什么可能考:2024-04 首次考邻接多重表,存储结构题开始往这两种“十字形”结构延伸。复习:6.3。
- A. e,e
- B. 2e,e
- C. 2e,2e
- D. e,2e
展开解析
B邻接表中每条边 (u, v) 在 u、v 的链表里各出现一次,共 2e 个边结点;邻接多重表每条边只有一个边结点(同时挂在 u 和 v 两条链上),共 e 个——这正是它“省一半空间、删边只改一处”的好处。有向图的十字链表同理:每条弧一个结点,共 e 个。
- A. 从 v 的 firstin 出发,沿 hlink 链计数
- B. 从 v 的 firstout 出发,沿 tlink 链计数
- C. 从 v 的 firstin 出发,沿 tlink 链计数
- D. 扫描全部顶点的 firstout 链,统计 headvex 为 v 的弧
展开解析
Ahlink 把弧头相同的弧串起来,firstin 指向第一条以 v 为弧头的弧,沿 hlink 走完就是 v 的全部入弧。B 求的是出度;C 混用了两条链;D 能得到正确结果,但要扫描全部弧,是邻接表求入度的笨办法,没有用上十字链表的优势。
见 6.3 十字链表图。
⑦ 外部排序三件套:置换-选择、败者树、最佳归并树
为什么可能考:2023-42(置换-选择)、2024-11(败者树)、2026-11(k 与归并趟数),四年考了三次,而三件套的计算题还远没考遍。复习:8.10。
- A. 3;8, 15, 21, 47, 55
- B. 4;8, 21, 47, 55
- C. 5;8, 15, 21
- D. 3;8, 15, 21, 47, 55, 70
展开解析
D第 1 段:工作区 {40, 12, 75} 输出 12,读 33;输出 33,读 8(< 33,冻结);输出 40,读 60;输出 60,读 21(冻结);输出 75,读 90;输出 90,读 15(冻结),工作区全冻结 ⇒ 第 1 段 12, 33, 40, 60, 75, 90。第 2 段:{8, 21, 15} 输出 8,读 55;输出 15,读 47;输出 21,读 3(冻结);输出 47,读 70;输出 55,读 28(冻结);输出 70,读 66(冻结)⇒ 第 2 段 8, 15, 21, 47, 55, 70。第 3 段 3, 28, 66。共 3 段,平均长度 5,大于工作区的 3。
易错:读进来的数比刚输出的 MINIMAX 小时要冻结,不能输出到本段;第 2 段末尾的 70 容易漏(A)。(由程序模拟。)
- A. 6,3
- B. 5,3
- C. 5,6
- D. 6,5
展开解析
B败者树是 k 个叶结点(各归并段当前记录)的完全二叉树,非叶结点 k − 1 = 5 个,各存一个败者,另加一个结点存冠军。输出冠军后,只需让新补入的记录沿它到根的路径重赛,路径上每个结点比较 1 次,至多 ⌈log₂6⌉ = 3 次。若不用败者树、每次扫描 6 个段首,要比较 5 次。
见 8.10 败者树。
- A. 246
- B. 632
- C. 492
- D. 584
展开解析
C(11 − 1) mod (4 − 1) = 1 ≠ 0,要补 (4 − 1) − 1 = 2 个长度为 0 的虚段。按 4 叉哈夫曼树合并:0 + 0 + 4 + 5 = 9;7 + 8 + 9 + 9 = 33(原有的 9 和新合成的 9 先用哪个都一样);11 + 12 + 15 + 20 = 58;25 + 30 + 33 + 58 = 146(根)。WPL = 9 + 33 + 58 + 146 = 246(块),每块读、写各一次,总 I/O = 2 × 246 = 492。
干扰项:A 忘了乘 2;B 是不补虚段、每次直接取最小的 4 个合并的结果(最后一次只剩 2 段,“凑不满”留在了根上,WPL 316);D 是不按长短、做 4 路平衡归并(⌈log₄11⌉ = 2 趟,每趟全文件 146 块读写一遍:2 × 146 × 2)。三种做法都由程序计算,最佳归并树最省。
⑧ KMP:nextval
2024-06 考过一次 nextval,之后很可能换个模式串再考。复习:4.4 三步口诀。
- A. 0, 0, 2, 0, 0, 3, 2
- B. 0, 1, 2, 1, 2, 3, 3
- C. 0, 0, 2, 0, 0, 3, 3
- D. 0, 1, 0, 1, 1, 3, 2
展开解析
A先求 next:0, 1, 2, 1, 2, 3, 3。再逐位比:j = 2,T₂ = a 与 T₁ = a 相同 ⇒ 抄 nextval[1] = 0;j = 3,T₃ = b ≠ T₂ = a ⇒ 保留 2;j = 4,T₄ = a 与 T₁ 相同 ⇒ 0;j = 5,T₅ = a 与 T₂ 相同 ⇒ 抄 nextval[2] = 0;j = 6,T₆ = a ≠ T₃ = b ⇒ 保留 3;j = 7,T₇ = b 与 T₃ = b 相同 ⇒ 抄 nextval[3] = 2。
干扰项:B 是 next;C 在 j = 7 忘了“相同就抄”。(由程序计算。)
⑨ 受限双端队列与稀疏矩阵
- A. 5, 2, 1, 3, 4
- B. 3, 1, 5, 2, 4
- C. 2, 5, 1, 3, 4
- D. 5, 1, 2, 4, 3
展开解析
DD 以 5 开头 ⇒ 5 出队时 1 ~ 4 都已入队、还都在队里(5 从出队端进来后马上出去)。此后再没有元素进来,剩下的出队顺序就是队列从出队端看过去的排列 1, 2, 4, 3。可是 4 是 1 ~ 4 里最后入队的,它只能从两端之一进去,进去时一定在队列的某一端,之后也没有别的元素再进来把它“夹”到中间——而在 1, 2, 4, 3 里它偏偏在中间,矛盾。A、B、C 都能安排出来,例如 A:1 进、2 从出队端进、3 和 4 从另一端进,5 从出队端进马上出,接着依次出 2, 1, 3, 4。
做题方法:选项少时逐个模拟;n = 5 时输出受限能得到 90 种、栈只能得到 42 种(程序枚举)。见 3.4 跟做。
Ⅰ. 用三元组表存储后,失去了随机存取的特性 Ⅱ. 三元组表中除三元组外,还需保存矩阵的行数和列数
Ⅲ. 非零元的个数和位置在运算中经常变化时,宜用十字链表 Ⅳ. 与对称矩阵一样,可以用下标公式直接算出任一元素在压缩数组中的位置
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅱ、Ⅲ、Ⅳ
- C. 仅 Ⅰ、Ⅱ、Ⅲ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
⑩ 冷了很久的考点:线索、循环队列、冒泡、递归、BFS
这几个点十年左右没出选择题,但都是教材的核心内容。命题人让它们“回归”时,通常会换一个约定或问法,正好考你是不是真懂。
- A. A
- B. C
- C. E
- D. G
展开解析
B先序序列 A B D E G C F。F 没有左孩子,左指针改为线索,指向先序前驱 C。(F 也没有右孩子,它是先序最后一个结点,右线索为 NULL。)
失效条件:只有空指针才变成线索;若问的是有左孩子的结点,它的左指针仍指向孩子。见 5.6 三种线索树。
- A. (rear + 2) % m == front
- B. (rear + 1) % m == front
- C. rear == front
- D. (front + 1) % m == rear
展开解析
A这种约定下队空是 (rear + 1) % m == front(初始 rear = m − 1,(m − 1 + 1) % m = 0 = front)。队里元素个数 = (rear − front + 1 + m) % m,牺牲一格时最多 m − 1 个,此时 rear 再往后两格就回到 front:(rear + 2) % m == front。B 是这种约定下的队空条件;C 表示队中恰有 1 个元素。
见 3.3 四种约定的公式对照表。(随机入队出队 1 万次验证公式。)
- A. 12, 7, 26, 35, 19, 3, 48, 51
- B. 3, 7, 48, 12, 26, 51, 19, 35
- C. 3, 7, 35, 12, 48, 19, 26, 51
- D. 12, 35, 48, 7, 26, 51, 19, 3
展开解析
C第 1 趟把 3 一路换到最前:3, 35, 12, 48, 7, 26, 51, 19;第 2 趟从后往前:19 先后越过 51、26,7 先后越过 48、12、35,得 3, 7, 35, 12, 48, 19, 26, 51。特征:前 2 个是全体最小的 2 个,且已在最终位置。
干扰项:A 是“从前往后、把最大的沉到最后”的第 2 趟;B 是简单选择排序的第 2 趟;D 是直接插入排序的第 2 趟。四种算法的第 2 趟放在一起,正好练“每趟特征”。(均由程序模拟。)
int f(int n) { if (n <= 1) return 1; return f(n / 2) + f(n / 2); }
- A. O(log₂n),O(log₂n)
- B. O(nlog₂n),O(n)
- C. O(n),O(n)
- D. O(n),O(log₂n)
展开解析
D时间:T(n) = 2T(n/2) + O(1),递归树每层的调用数 1, 2, 4, …, n,共 2n − 1 次调用(n = 1024 时 2047 次)⇒ O(n)。空间看递归工作栈的最大深度:每次规模减半,深度 log₂n + 1(n = 1024 时 11 层)⇒ O(log₂n)。两个 f(n/2) 是先后执行的,第一个返回后栈帧就释放了,不会同时占用。
变化点:写成 2 * f(n/2) 只调用一次,时间就变成 O(log₂n)。见 1.4 递归的空间。(调用次数与深度由程序计数。)
- A. T 中 v 到 u 的路径长度可能大于 G 中 v 到 u 的最短路径长度
- B. T 中 v 到 u 的路径长度一定等于 G 中 v 到 u 的最短路径长度
- C. T 的形状与邻接表中边结点的次序无关
- D. T 中 v 到 u 的路径与深度优先生成树中 v 到 u 的路径相同
展开解析
BBFS 按距离一圈一圈向外访问,u 第一次被发现时就在第 d(v, u) 层,所以树上路径长度等于最短路径长度(这就是“BFS 求无权图单源最短路径”的原理)。C 错:邻接表次序不同,同一层里谁先被发现会变,生成树的形状可能不同(但每个顶点所在的层不变)。D 错:DFS 走的是“一条路走到黑”,路径一般更长。
见 6.4 BFS 求最短路径(2023-06 同源)。
11.2概念辨析专练:Ⅰ / Ⅱ / Ⅲ 组合题
组合题是选择题里最“不讲情面”的一类:四个说法判对三个,照样 0 分。下面 16 道按章节排列,每章两道,说法都取自各章“易错”框里最容易混淆的结论。做法建议:
- 先判最有把握的一条,用它一次划掉两个选项;
- 再判剩下的选项之间有差别的那几条,所有选项都包含(或都不包含)的说法可以不判;
- 每条都要能说出理由——对完答案后逐条看解析,说不出理由的那条就是你的薄弱点,点链接回原文。
解析逐条给出 ✓ / ✗ 和理由;涉及计算或反例的说法都由程序核对(verify/10/p11/judge.py)。
(1)绪论
Ⅰ. 数据元素是数据的基本单位,数据项是数据的最小单位
Ⅱ. 逻辑结构相同时,采用不同的存储结构,基本运算的效率可能不同
Ⅲ. 算法的时间复杂度只取决于问题规模 n,与待处理数据的初始状态无关
Ⅳ. 算法可以没有输入,但至少要有一个输出
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅰ、Ⅱ、Ⅳ
- C. 仅 Ⅱ、Ⅲ、Ⅳ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
展开解析
B正确的是 Ⅰ、Ⅱ、Ⅳ。
- Ⅰ ✓教材原话。一个学生记录是一个数据元素,其中的学号、姓名是数据项。
- Ⅱ ✓同是线性表,顺序表按位查找 O(1)、链表 O(n);插入则反过来。
- Ⅲ ✗直接插入排序对有序表 O(n)、逆序表 O(n²),所以才要分最好、最坏、平均。
- Ⅳ ✓五个特性中的“输入:零个或多个”“输出:一个或多个”。
复习:回到原文。
Ⅰ. 若 T(n) = 3n² + 100n·log₂n + 5,则 T(n) = O(n²)
Ⅱ. O(log₂n) 与 O(log₁₀n) 是同一个阶
Ⅲ. 递归算法的空间复杂度只与递归调用的总次数有关
Ⅳ. 最坏时间复杂度为 O(n²) 的算法,在任何输入下都比 O(n·log₂n) 的算法慢
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅱ、Ⅳ
- C. 仅 Ⅰ、Ⅲ
- D. 仅 Ⅰ、Ⅱ
展开解析
D正确的是 Ⅰ、Ⅱ。
- Ⅰ ✓只保留最高阶,n² 的增长比 n·log₂n 快。
- Ⅱ ✓换底公式 log₂n = log₁₀n / log₁₀2,只差一个常数倍,所以写 O(log n) 时可以不写底数。
- Ⅲ ✗决定空间的是递归工作栈的最大深度,不是总次数:f(n) = f(n/2) + f(n/2) 调用 2n − 1 次,深度只有 log₂n + 1。
- Ⅳ ✗复杂度描述的是 n 很大时的增长趋势;n 很小或输入特殊(如基本有序时的直接插入)时,O(n²) 的算法完全可能更快。
复习:回到原文。
(2)线性表
Ⅰ. 顺序表可以随机存取,链表只能顺序存取
Ⅱ. 在带头结点的单链表中,删除第一个元素结点与删除其他元素结点的操作可以统一
Ⅲ. 静态链表需要预先分配一块连续的存储空间,插入删除时不需要移动元素
Ⅳ. 在双链表中已知结点 p,在 p 之前插入一个新结点的时间复杂度为 O(n)
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅰ、Ⅲ、Ⅳ
- C. 仅 Ⅱ、Ⅲ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
展开解析
A正确的是 Ⅰ、Ⅱ、Ⅲ。
- Ⅰ ✓“存取方式”问的就是这个:顺序表由下标直接算地址,链表要从头一个个走。
- Ⅱ ✓头结点让第一个元素结点也有前驱,不必单独修改头指针——这是设头结点的两大好处之一。
- Ⅲ ✓静态链表用数组的游标模拟指针,空间一次分配,插删只改游标。
- Ⅳ ✗双链表有 prior 指针,前驱直接可得,O(1);单链表才需要找前驱(或用“后插 + 交换数据”的技巧)。
复习:回到原文。
Ⅰ. 用顺序表存储时,在第 i 个位置插入元素所需的时间与 i 无关
Ⅱ. 用单链表存储时,若已知指针 p 指向某个非尾结点,则删除 p 所指结点可在 O(1) 时间完成
Ⅲ. 用只设尾指针的单循环链表存储时,删除表尾元素的时间为 O(1)
Ⅳ. 两个长度分别为 m、n 的有序单链表合并成一个有序单链表,最坏情况下比较 m + n − 1 次
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅱ、Ⅲ、Ⅳ
- C. 仅 Ⅱ、Ⅳ
- D. 仅 Ⅰ、Ⅳ
展开解析
C正确的是 Ⅱ、Ⅳ。
- Ⅰ ✗要把第 i ~ n 个元素后移,移动 n − i + 1 次,与 i 有关。
- Ⅱ ✓把后继的数据复制到 p 中,再删掉后继——“偷梁换柱”,尾结点不能这样做。
- Ⅲ ✗删表尾要修改它的前驱的 next,而前驱只能从头走一圈去找,O(n)。只设尾指针时 O(1) 的是:表头插入、表尾插入、删表头。
- Ⅳ ✓每比较一次输出一个元素,最坏时一直比到某个表只剩最后一个元素(程序枚举 m = 3、n = 4 得最坏 6 次)。
复习:回到原文。
(3)栈、队列和数组
Ⅰ. 栈和队列都是操作受限的线性表
Ⅱ. 用单链表实现队列时,若只设一个指向表头的指针(队头在表头),则入队操作的时间为 O(n)
Ⅲ. 循环队列中元素个数为 (rear − front + MaxSize) % MaxSize,这个公式不论采用“牺牲一个单元”还是“设 tag 标志”都成立
Ⅳ. 共享栈的两个栈底分别设在数组两端(top0 初值 −1,top1 初值 MaxSize),栈满的条件是 top0 + 1 == top1
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅱ、Ⅲ、Ⅳ
- C. 仅 Ⅰ、Ⅱ、Ⅳ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
展开解析
C正确的是 Ⅰ、Ⅱ、Ⅳ。
- Ⅰ ✓栈只在一端插删,队列一端插一端删。
- Ⅱ ✓入队在队尾,要从表头走到表尾才能挂上去;所以链队列要同时设 front 和 rear。
- Ⅲ ✗设 tag 时队满也有 rear == front,公式算出 0,而实际是 MaxSize 个;必须结合 tag 判断。
- Ⅳ ✓两个栈顶相邻时数组用满。
复习:回到原文。
Ⅰ. 中缀表达式转后缀表达式时,栈中存放的是运算符;后缀表达式求值时,栈中存放的是操作数
Ⅱ. n 个不同元素依次进栈,若第一个出栈的是第 n 个元素,则出栈序列唯一
Ⅲ. 二维数组 a[m][n] 按行优先存储和按列优先存储时,a[0][0] 的地址相同,a[m−1][n−1] 的地址也相同
Ⅳ. 稀疏矩阵用三元组表存储后,仍可以按行、列下标在 O(1) 时间存取任一元素
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅰ、Ⅱ、Ⅲ
- C. 仅 Ⅱ、Ⅲ、Ⅳ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
展开解析
B正确的是 Ⅰ、Ⅱ、Ⅲ。
- Ⅰ ✓转换时操作数直接输出,只有运算符和括号进栈;求值时操作数进栈,遇到运算符弹两个算。
- Ⅱ ✓第 n 个先出说明全部已进栈,之后只能依次弹出:n, n − 1, …, 1。
- Ⅲ ✓两者都是第一个和最后一个元素:首地址、首地址 + (mn − 1)·L。中间元素的地址才不同。
- Ⅳ ✗非零元分布没有规律,只能在三元组表里查找,失去了随机存取特性。
复习:回到原文。
(4)串
Ⅰ. 空串是任意串的子串
Ⅱ. 串 'abc' 的子串个数(含空串)是 7
Ⅲ. KMP 算法匹配过程中,主串指针 i 不回退
Ⅳ. 模式串的 next 数组只由模式串本身决定,与主串无关
- A. 仅 Ⅰ、Ⅲ
- B. 仅 Ⅰ、Ⅱ、Ⅲ
- C. 仅 Ⅱ、Ⅲ、Ⅳ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
展开解析
D正确的是 Ⅰ、Ⅱ、Ⅲ、Ⅳ。
- Ⅰ ✓子串的定义允许取 0 个连续字符。
- Ⅱ ✓非空子串 3 + 2 + 1 = 6 个,加空串共 7 个(字符互不相同时 n(n + 1)/2 + 1)。
- Ⅲ ✓失配时只移动模式串(j = next[j]),这是 KMP 的核心。
- Ⅳ ✓next 描述的是模式串“自己和自己”的最长相等前后缀,所以可以先算好再去匹配任何主串。
复习:回到原文。
Ⅰ. 朴素模式匹配算法的最坏时间复杂度为 O(n + m)
Ⅱ. 用 nextval 代替 next,可以减少字符比较次数,但 KMP 的时间复杂度仍是 O(n + m)
Ⅲ. 若模式串中各字符互不相同,则按教材约定,next 数组除 next[1] = 0 外全为 1
Ⅳ. 任何情况下,KMP 算法的字符比较次数都比朴素算法少
- A. 仅 Ⅱ、Ⅲ
- B. 仅 Ⅰ、Ⅱ、Ⅲ
- C. 仅 Ⅲ、Ⅳ
- D. 仅 Ⅱ、Ⅲ、Ⅳ
展开解析
A正确的是 Ⅱ、Ⅲ。
- Ⅰ ✗最坏是 O(nm):例如主串 aaa…ab、模式串 aa…ab,每趟都要比到模式串最后一个字符才失配。
- Ⅱ ✓nextval 只是跳过“注定又失配”的比较,阶不变。
- Ⅲ ✓没有任何相等的前后缀,PM 值全为 0,next = PM + 1 = 1(程序对长度 2 ~ 6 的全部排列核对)。
- Ⅳ ✗模式串和主串部分匹配很少时(如字符都不相同),两者比较次数差不多,KMP 还要先花 O(m) 求 next。
复习:回到原文。
(5)树
Ⅰ. 非空二叉树中,度为 2 的结点比叶结点少 1 个
Ⅱ. 完全二叉树中,若某结点没有左孩子,则它一定是叶结点
Ⅲ. 由二叉树的先序序列和后序序列可以唯一确定这棵二叉树
Ⅳ. 哈夫曼树中不存在度为 1 的结点
- A. 仅 Ⅰ、Ⅱ、Ⅳ
- B. 仅 Ⅱ、Ⅲ、Ⅳ
- C. 仅 Ⅰ、Ⅲ
- D. Ⅰ、Ⅱ、Ⅲ、Ⅳ
展开解析
A正确的是 Ⅰ、Ⅱ、Ⅳ。
- Ⅰ ✓n₀ = n₂ + 1。
- Ⅱ ✓完全二叉树中只有一个孩子时必定是左孩子,没有左孩子就不可能有右孩子。
- Ⅲ ✗只有一个孩子的结点分不出左右,例如先序 AB、后序 BA 有两棵树。
- Ⅳ ✓每次合并都产生一个度为 2 的新结点,叶子都是原始结点。
复习:回到原文。
Ⅰ. 一棵树转换成二叉树后,根结点没有右子树
Ⅱ. 森林 F 转换成二叉树 T,F 的中序(后根)遍历序列等于 T 的后序遍历序列
Ⅲ. 在后序线索二叉树中求某结点的后继,有时仍需要知道它的双亲
Ⅳ. 并查集采用按大小合并并加上路径压缩后,一次 Find 的均摊时间可以看作常数
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅰ、Ⅱ、Ⅲ
- C. 仅 Ⅱ、Ⅳ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
展开解析
D正确的是 Ⅰ、Ⅲ、Ⅳ。
- Ⅰ ✓树的根没有兄弟。
- Ⅱ ✗等于 T 的中序遍历序列;T 的后序在森林里没有对应的遍历。
- Ⅲ ✓后序“左右根”,有右孩子的结点,后继在双亲那边,往下走找不到。
- Ⅳ ✓O(α(n)),α(n) 在实际规模下不超过 4。
复习:回到原文。
(6)图
Ⅰ. n(n > 1)个顶点的无向连通图至少有 n − 1 条边,n 个顶点的强连通有向图至少有 n 条弧
Ⅱ. 图的邻接矩阵表示是唯一的,邻接表表示不唯一
Ⅲ. 若有向图存在拓扑序列,则它的邻接矩阵一定是三角矩阵
Ⅳ. 有向图中所有顶点的入度之和等于出度之和,也等于弧数的 2 倍
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅰ、Ⅱ
- C. 仅 Ⅱ、Ⅳ
- D. 仅 Ⅰ、Ⅱ、Ⅳ
展开解析
B正确的是 Ⅰ、Ⅱ。
- Ⅰ ✓前者是一棵树,后者是一个环。
- Ⅱ ✓边结点在链表中的次序可以不同。
- Ⅲ ✗要先按拓扑序给顶点重新编号,邻接矩阵才是上三角;原编号下不一定。反过来,邻接矩阵是三角矩阵则一定存在拓扑序列。
- Ⅳ ✗每条弧给一个顶点贡献 1 个出度、给另一个顶点贡献 1 个入度:入度和 = 出度和 = 弧数(不是 2 倍)。无向图才是度之和 = 2e。
复习:回到原文。
Ⅰ. Dijkstra 算法不能用于含负权边的图
Ⅱ. 最小生成树的权值之和是唯一的,但最小生成树本身不一定唯一
Ⅲ. 在 AOE 网中缩短任意一个关键活动的持续时间,一定能缩短整个工程的工期
Ⅳ. 用 DFS 判断有向图是否有环时,只要遇到一条指向“已访问过”的顶点的弧,就说明有环
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅰ、Ⅳ
- C. 仅 Ⅰ、Ⅱ
- D. 仅 Ⅱ、Ⅲ、Ⅳ
展开解析
C正确的是 Ⅰ、Ⅱ。
- Ⅰ ✓一个顶点一旦确定就不再修改,负权边可能让已确定的距离变得更短。
- Ⅱ ✓有等权边时可能有多棵 MST,权值和相同。
- Ⅲ ✗有多条关键路径时,只缩短其中一条上的活动,另一条仍决定工期;而且缩短过多,关键路径会改变。
- Ⅳ ✗要指向“还在递归栈中”的顶点(祖先)才有环。反例:A → B、A → C、C → B 无环,可 DFS 访问 C 时,B 已经访问过了。
复习:回到原文。
(7)查找
Ⅰ. 折半查找只适用于有序的顺序表
Ⅱ. 折半查找的判定树一定是完全二叉树
Ⅲ. 在二叉排序树中删除一个结点后再把它插入,得到的树一定与原树相同
Ⅳ. 对同一组关键字,按不同次序插入建成的 AVL 树形态可能不同
- A. 仅 Ⅰ、Ⅱ、Ⅳ
- B. 仅 Ⅰ、Ⅳ
- C. 仅 Ⅱ、Ⅲ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
Ⅰ. 散列表的平均查找长度取决于装填因子,与表中元素个数 n 没有直接关系
Ⅱ. 用线性探测法处理冲突的散列表,删除元素时只能做删除标记
Ⅲ. m 阶 B 树中,除根以外的非叶结点至少有 ⌈m/2⌉ 个关键字
Ⅳ. 平方探测法一定能探测到散列表中的所有单元
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅰ、Ⅱ、Ⅲ
- C. 仅 Ⅱ、Ⅳ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
(8)排序
Ⅰ. 直接插入、冒泡、二路归并、基数排序都是稳定的
Ⅱ. 简单选择排序的关键字比较次数与初始序列无关
Ⅲ. 快速排序在待排序数据基本有序时效率最高
Ⅳ. 堆排序和二路归并排序的最坏时间复杂度都是 O(n·log₂n)
- A. 仅 Ⅰ、Ⅱ、Ⅲ
- B. 仅 Ⅱ、Ⅳ
- C. 仅 Ⅰ、Ⅲ、Ⅳ
- D. 仅 Ⅰ、Ⅱ、Ⅳ
展开解析
D正确的是 Ⅰ、Ⅱ、Ⅳ。
- Ⅰ ✓口诀“快些选堆不稳定”,这四个不在其中。
- Ⅱ ✓每趟都要在剩余元素中找最小,比较 n(n − 1)/2 次。
- Ⅲ ✗枢轴取第一个元素时,有序数据会让每次划分极不均匀,退化成 O(n²),反而最慢。
- Ⅳ ✓两者在最好、最坏、平均情况下都是 O(n·log₂n);区别在空间:堆 O(1),归并 O(n)。
复习:回到原文。
Ⅰ. 冒泡、简单选择、快速、堆排序每一趟都至少确定一个元素的最终位置
Ⅱ. 二路归并排序的关键字比较次数与初始序列无关
Ⅲ. 外部排序中,增大归并路数 k 可以减少归并趟数;但若不用败者树,内部归并的比较次数会随 k 增加
Ⅳ. 希尔排序也可以直接用于链表存储的数据
- A. 仅 Ⅰ、Ⅱ
- B. 仅 Ⅱ、Ⅲ
- C. 仅 Ⅰ、Ⅲ
- D. 仅 Ⅰ、Ⅲ、Ⅳ
11.3全真模拟卷 A(数据结构 45 分 · 限时 60 分钟)
本卷按 408 的题位和分值出:选择题 11 道(每题 2 分,共 22 分),41 题 13 分,42 题 10 分。建议用时:选择题 20 分钟,41 题 25 分钟,42 题 15 分钟。先在纸上做完全部题目、记下用时,再展开答案。所有数字和代码都由程序核对(verify/10/p11/mock_a.py、mock_a41.c、mock_a42.py)。
一、单项选择题(第 1 ~ 11 题,每题 2 分)
count = 0; for (i = 1; i * i <= n; i++) for (j = i; j <= n; j += i) count++;
- A. O(√n)
- B. O(n)
- C. O(n·log₂n)
- D. O(n·√n)
展开解析
C外层 i = 1 ~ √n;内层 j 从 i 开始、每次加 i,执行 ⌊n/i⌋ 次。总次数 = n/1 + n/2 + … + n/√n = n × (1 + 1/2 + … + 1/√n) ≈ n × ln√n = (n/2)·ln n,是 O(n·log n)。程序实测 n = 10⁶ 时 count = 7,485,017,约为 n·log₂n 的 0.38 倍,比值随 n 增大缓慢趋于常数(ln2 / 2 ≈ 0.35)。
陷阱:外层只有 √n 次,容易以为是 O(√n · n);但内层次数随 i 变小得很快,要求和(调和级数,见 1.3 补课)而不是相乘。
- A. 3, 1, 5, 6, 4
- B. 3, 1, 5, 4
- C. 3, 5, 1, 4
- D. 3, 1, 6, 5, 4
展开解析
B原表 A(3) → B(1) → C(6) → D(4)。插入 X:S[5].next = S[1].next = 6,S[1].next = 5,表为 3 → 1 → 5 → 6 → 4。删除 C(下标 6):它的前驱现在是 X(下标 5),令 S[5].next = S[6].next = 4。结果 3 → 1 → 5 → 4,即 A, B, X, D。
陷阱:插入 X 之后 C 的前驱已经从 B 变成 X,删除时要改 S[5] 而不是 S[1];A 是忘了删除,D 是插错了位置。静态链表的 next 是数组下标,插删都只改 next、不移动元素。
- A. 3, 2, 1, 6, 5, 4
- B. 2, 4, 3, 1, 6, 5
- C. 1, 4, 3, 2, 6, 5
- D. 4, 3, 5, 2, 6, 1
展开解析
D四个序列在不限容量时都是合法的出栈序列,区别在栈的最大深度。D 第一个出栈的是 4,此前 1、2、3、4 必须同时在栈中,深度为 4 > 3。A、B、C 模拟时栈中最多 3 个元素。
方法:带容量的题先判合法性,再看“第一次出栈 x 时栈里有几个”这类最深时刻(3.2 节“栈至少要多大”)。
- A. j(j − 1)/2 + i
- B. i(i − 1)/2 + j
- C. (i − 1)(2n − i + 2)/2 + j − i + 1
- D. j(j + 1)/2 + i
展开解析
A按列存上三角:第 1 列存 1 个(a1,1),第 2 列存 2 个,……第 j 列存 j 个(行 1 ~ j)。ai,j 前面有第 1 ~ j − 1 列共 1 + 2 + … + (j − 1) = j(j − 1)/2 个元素,再加上本列中它前面的 i − 1 个,它是第 j(j − 1)/2 + i 个,下标从 1 开始就是 j(j − 1)/2 + i。程序对 n = 6 逐个核对。
干扰项:B 是“下三角按行优先”的公式(i、j 互换);C 是“上三角按行优先”的公式。上三角按列存 ⟺ 下三角按行存(转置),所以公式就是把 i、j 对调。见 3.7 四种组合。
- A. 247
- B. 249
- C. 248
- D. 250
展开解析
C完全二叉树 n₀ = ⌈n/2⌉ = 124 ⇒ n = 247 或 248。n = 247 时没有度为 1 的结点;n = 248 时有 1 个。最多 248。
反过来检验:n₂ = n₀ − 1 = 123,n = 124 + 123 + n₁,而完全二叉树 n₁ 只能是 0 或 1。
- A. A, B, C, D, E, F
- B. A, B, D, C, E, F
- C. A, D, B, C, E, F
- D. A, B, D, E, C, F
展开解析
B后序最后一个 A 是根;中序 [D B] A [E C F]。左子树后序 D B ⇒ 根 B,中序 D B ⇒ D 是 B 的左孩子。右子树后序 E F C ⇒ 根 C,中序 E C F ⇒ E 左、F 右。树为 A(B(D, ), C(E, F)),先序 A B D C E F。
A 是层序序列——先序和层序在这里只差 C、D 的次序,最容易看串。
- A. 2.25,5
- B. 2.5,4
- C. 2.25,4
- D. 2.4,5
展开解析
A合并:4 + 6 = 10;10 + 10 = 20;15 + 20 = 35;25 + 35 = 60;40 + 60 = 100。WPL = 10 + 20 + 35 + 60 + 100 = 225(每百个字符 225 位),平均码长 2.25。码长:40 → 1,25 → 2,15 → 3,10 → 4,4 和 6 → 5,最长 5。
注意:第二步有两个 10(原来的 10 和新结点 10),先合并谁都一样,WPL 不变,原来那个 10 的码长都是 4。平均码长 = WPL ÷ 总频次,不是除以字符种数 6。
- A. 13,11
- B. 12,11
- C. 13,13
- D. 13,12
展开解析
Dve:v1 = 0,v2 = 4,v3 = 3,v4 = 5,v5 = max(4 + 6, 3 + 4) = 10,v6 = max(3 + 5, 5 + 3) = 8,v7 = max(10 + 3, 8 + 4) = 13。vl:v7 = 13,v5 = 10,v6 = 9,v2 = 4,v3 = min(10 − 4, 9 − 5) = 4,v4 = 6,v1 = 0。e = l 的活动是 a1、a4、a8,关键路径 v1 → v2 → v5 → v7,工期 13。a4 缩短 2 后这条路径只有 11,但 v1 → v3 → v6 → v7 和 v1 → v4 → v6 → v7 都是 12,工期只能降到 12,而且关键路径变成了这两条。
陷阱:A 以为“关键活动缩短多少,工期就缩短多少”——缩短到一定程度后,别的路径会成为最长的,原来的关键活动就不再关键了(6.10 节“关键活动与工期的关系”)。四条源点到汇点路径的长度 13、12、12、10 由程序枚举。
- A. v2, v1, v3, v4
- B. v2, v1, v4, v3
- C. v1, v2, v3, v4
- D. v2, v3, v1, v4
展开解析
A初始 dist = [0, 6, 2, ∞, ∞]。① 选 v2(2),更新 v1 = min(6, 2 + 3) = 5,v3 = 9,v4 = 11。② 选 v1(5),更新 v3 = min(9, 5 + 2) = 7,v4 = min(11, 5 + 6) = 11。③ 选 v3(7),更新 v4 = min(11, 7 + 1) = 8。④ 选 v4(8)。
陷阱:C 按“初始直接相邻的距离”排序,忘了 v1 被 v2 松弛后才确定;B 漏了第 ③ 步对 v4 的更新。
- A. 40,20
- B. 30,20
- C. 25,20
- D. 30,25
展开解析
B插入 25 前是 40(20(10, 30), 60)。25 插在 30 的左边,从 25 往上看,最小不平衡子树的根是 40(左高 3、右高 1)。25 在 40 的左孩子 20 的右子树中 ⇒ LR 型:30(“孙子”)上位成为根,20、40 分别成为它的左、右孩子,30 原来的左孩子 25 给 20 当右孩子。结果 30(20(10, 25), 40( , 60))。
判型口诀:从最小不平衡结点出发,沿着“新结点所在的方向”走两步,L 再 R 就是 LR。见 7.5 四种旋转。
- A. 13, 27, 38, 46, 52, 64, 75, 91
- B. 46, 27, 13, 38, 52, 75, 64, 91
- C. 46, 27, 38, 13, 52, 64, 75, 91
- D. 27, 13, 38, 46, 52, 64, 75, 91
展开解析
C枢轴 52。high 从 46 开始:46 < 52,填到 0 号;low 前进到 91 > 52,填到 7 号;high 前移越过 75,遇到 38 < 52,填到 2 号;low 前进到 64 > 52,填到 5 号;high 前移遇到 13,填到 3 号;low 前进与 high 相遇于 4 号,放入 52。得 46, 27, 38, 13, 52, 64, 75, 91。
恰好右半部分已经有序,左半部分还没有——别被 A 的“全有序”或 D 的“左边也有序”迷惑。见 8.5 Partition 逐行读。
二、综合应用题
已知带头结点的单链表 L,结点结构为 data | next,表长为 n。请设计一个时间和空间上都尽可能高效的算法:从第一个结点起每 k 个结点分为一组,将每组内的结点逆置;最后不足 k 个的结点保持原顺序。例如 L = (1, 2, 3, 4, 5, 6, 7, 8),k = 3 时变为 (3, 2, 1, 6, 5, 4, 7, 8)。要求:(1) 给出算法的基本设计思想;(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释;(3) 说明所设计算法的时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
先扫描一遍求表长 n,得到完整的组数 n / k(整除)。用指针 pre 指向当前组前面的结点(开始时是头结点)。对每一组:记住本组第一个结点 first(逆置后它会成为本组的最后一个),把本组其余 k − 1 个结点依次从原位置摘下,用头插法插到 pre 之后,这一组就逆置了;然后令 pre = first,处理下一组。完整的组处理完以后,剩下不足 k 个的结点不动。
typedef struct LNode { int data; struct LNode *next; } LNode; void ReverseK(LNode *L, int k) { if (k <= 1) return; // k ≤ 1 时不用动 int n = 0; for (LNode *p = L->next; p != NULL; p = p->next) n++; // 第一遍:求表长 LNode *pre = L; // pre:当前组前面的那个结点 for (int g = 0; g < n / k; g++) { // 只处理 n/k 个完整的组 LNode *first = pre->next; // 本组第一个结点,逆置后变成本组最后一个 LNode *p = first->next; // 从本组第 2 个结点开始 for (int i = 1; i < k; i++) { // 本组其余 k-1 个结点依次头插到 pre 之后 first->next = p->next; // ① 把 p 从原位置摘下 p->next = pre->next; // ② p 插到 pre 的后面 pre->next = p; p = first->next; // ③ 下一个待处理的结点 } pre = first; // 下一组的前驱 = 本组逆置后的尾 } }
| 时刻 | 链表(| 隔开已处理的组) | pre | first |
|---|---|---|---|
| 开始 | H → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | H | — |
| 第 1 组:2 头插 | H → 2 → 1 → 3 → … | H | 1 |
| 第 1 组:3 头插 | H → 3 → 2 → 1 | 4 → … | H → 改为 1 | 1 |
| 第 2 组完成 | H → 3 → 2 → 1 | 6 → 5 → 4 | 7 → 8 | 4 | 4 |
| n / k = 2 组已完成 | 7、8 不足 3 个,保持原样 | 结束 | |
求表长扫描一遍,逆置时每个结点至多被摘下、插入一次,时间 O(n);只用了几个指针变量,空间 O(1)。
① 每组逆置后 pre 要移到 first(本组新的尾),不是移到 pre->next;② 摘下 p 时要通过 first->next 维护“本组剩余部分”和后面的链,漏掉这一句会把链表断开;③ 最后一组不足 k 个时不能逆置——先求表长再算组数,比边走边判断更不容易错。代码已编译,与数组暴力翻转在 30 万组随机数据上对拍一致。
| 评分点 | 分 |
|---|---|
| 思想:分组、每组头插法(或三指针)逆置、组与组正确衔接、末组不足 k 个不动 | 4 |
| 代码:O(n) 时间、O(1) 空间且正确 7 分;用辅助数组存结点指针再重新链接(O(n) 空间)最高 5 分;每组都从头找位置(O(n²))最高 5 分 | 7 |
| 时间、空间复杂度与代码一致 | 2 |
只会暴力解时怎么拿分:把结点地址依次存进数组,在数组上按组翻转后重新串成链表——思路清楚、代码短,正确的话能拿 9 ~ 10 分。逆置一段链表是第 2 篇“基本功 ④”,这道题就是把它重复 n/k 次。
某公司要在 6 个城市 A ~ F 之间铺设光缆,可铺设的线路及其代价(单位:万元)如下图所示。请回答:
(1) 从 A 开始用 Prim 算法求最小生成树,按次序写出依次加入的边及其代价。(2) 最小生成树的总代价是多少?它是否唯一?说明理由。(3) 若线路 B–E 的代价从 6 降为 5,最小生成树的总代价是否改变?此时最小生成树是否唯一?
先在纸上自己做一遍,再点这里展开满分解答
| 步 | 树中顶点 | 候选边中代价最小的 | 加入 |
|---|---|---|---|
| 1 | A | A–C 3(A–B 7,A–D 9) | (A, C) 3 |
| 2 | A, C | C–B 4(C–D 5,C–E 8,A–B 7,A–D 9) | (C, B) 4 |
| 3 | A, C, B | C–D 5(B–E 6,C–E 8,A–D 9) | (C, D) 5 |
| 4 | A, B, C, D | D–F 4(D–E 5,B–E 6,C–E 8) | (D, F) 4 |
| 5 | A, B, C, D, F | D–E 5(B–E 6,E–F 7,C–E 8) | (D, E) 5 |
总代价 3 + 4 + 5 + 4 + 5 = 21 万元。唯一:对每一条不在树中的边,把它加入树中都会形成一个回路,而它都是该回路上唯一的代价最大的边(例如 B–E 6 所在回路 B–E–D–C–B 中其他边是 5、5、4;A–B 7、A–D 9、C–E 8、E–F 7 同理),所以任何一棵生成树只要用到某条非树边,都可以换成更便宜的树边,最小生成树只有这一棵。
总代价不变,仍为 21:B–E 加入原树形成回路 B–E–D–C–B,回路上 B–E、D–E、C–D 都是 5,用 B–E 换掉其中任何一条都不会更便宜。但不再唯一:去掉 C–D、D–E 或 B–E 中的任意一条都得到代价 21 的生成树,共 3 棵。
判断唯一性不能只说“没有相同权值的边”——本图中原来就有两条 4、两条 5,MST 照样唯一。要看的是:每条非树边是否是它所在回路上唯一的最大边。(全部生成树由程序枚举验证:原图最小代价 21 的只有 1 棵,改后有 3 棵。)
| 评分点 | 分 |
|---|---|
| (1) 五条边次序全对 4 分,每错一步扣 1 分;写成 Kruskal 次序最多 2 分 | 4 |
| (2) 总代价 1 分;唯一性结论与理由 2 分 | 3 |
| (3) 代价不变 1 分;不唯一、能指出回路上的等权边 2 分 | 3 |
卷 A 答案速查(做完再看)
| 题 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 答案 | C | B | D | A | C | B | A | D | A | B | C |
| 考点 | 1.3 | 2.4 | 3.2 | 3.7 | 5.2 | 5.5 | 5.9 | 6.10 | 6.7 | 7.5 | 8.5 |
41:链表(基本功 ④ 头插法逆置);42:图的应用(Prim、MST 唯一性)。自评:选择题错 ≤ 1 道、41 ≥ 11 分、42 ≥ 9 分 ⇒ 本卷达到满分水平。
11.4全真模拟卷 B(数据结构 45 分 · 限时 60 分钟)
题位、分值与卷 A 相同。本卷的 41 题考“分治 + 计数”,42 题考查找树与 ASL。数字与代码由 verify/10/p11/mock_b.py、mock_b41.c 核对。
一、单项选择题(第 1 ~ 11 题,每题 2 分)
void f(int n) { if (n <= 1) return; for (int i = 0; i < n; i++) x++; f(n / 2); f(n / 2); }
- A. O(n)
- B. O(n·log₂n)
- C. O(n²)
- D. O(log₂n)
展开解析
BT(n) = 2T(n/2) + n。画递归树:第 1 层 1 次调用循环 n 次;第 2 层 2 次调用各 n/2 次,合计 n;……每层合计都是 n,共 log₂n 层 ⇒ O(n·log₂n)(与归并排序相同)。程序实测 n = 2¹⁶ 时 x++ 恰好执行 n·log₂n = 1,048,576 次。
对比:去掉 for 循环是 O(n)(11.1 专项 23);只调用一次 f(n/2) 是 O(n)(n + n/2 + … < 2n)。见 1.4 递归树。
- A. s->next = p->next; p->next->prior = s; s->prior = p; p->next = s;
- B. p->next = s; s->next = p->next; p->next->prior = s; s->prior = p;
- C. s->prior = p; p->next = s; p->next->prior = s; s->next = p->next;
- D. s->next = p->next; p->next = s; p->next->prior = s; s->prior = p;
展开解析
A原则:p->next 被改写之前,要先用完它(找到原后继)。A 先让 s 指向原后继、原后继的 prior 指向 s,再连 p 和 s,正确。B 第一句就改了 p->next,之后的 p->next 都成了 s,s->next 指向自己。C 同样过早改了 p->next。D 的前向链对了,但第三句执行时 p->next 已经是 s,改的是 s->prior 而不是原后继的 prior——原后继的 prior 仍指向 p,向后看是对的,向前看却断了。
四个选项都由程序逐句执行验证(只有 A 的 next、prior 都一致)。见 2.4 四句话的顺序。
- A. a b c + d * * e f / −
- B. a b c d + * * e f / −
- C. a b * c d * + e f / −
- D. a b c d * + * e f / −
展开解析
D加括号法:((a × (b + (c × d))) − (e / f)),把每个运算符移到它所在括号的右括号后面,再去掉括号:a b c d × + × e f / −。用栈模拟时,运算符栈最多同时有 4 个元素(*、(、+、*,读到 d 时)。
干扰项:A 把 b + c 当成一个整体先算了(忽略了 * 优先于 +);C 把 a * b 先算了(忽略了括号)。见 3.5 中缀转后缀。
- A. 11
- B. 10
- C. 9
- D. 8
展开解析
C(rear − front + M) % M = (6 − 17 + 20) % 20 = 9:元素在 Q[17]、Q[18]、Q[19]、Q[0] ~ Q[5],共 3 + 6 = 9 个。
A 是直接算 17 − 6 = 11(把空的那一段当成了元素)。约定变了公式也要变,见 3.3 四种约定。
- A. 9
- B. 10
- C. 8
- D. 11
展开解析
An₀ = 1 + n₂ + 2n₃ ⇒ 30 = 1 + n₂ + 20 ⇒ n₂ = 9。度为 1 的结点数不影响叶子数。验算:结点数 = 30 + 5 + 9 + 10 = 54,边数 = 5 × 1 + 9 × 2 + 10 × 3 = 53 = 54 − 1 ✓。
- A. 5,16
- B. 16,4
- C. 4,15
- D. 4,16
展开解析
DT 的根是第一棵树的根;它的左子树是第一棵树除根以外的部分(5 − 1 = 4 个结点);右子树是其余两棵树(7 + 9 = 16 个结点)。
记法:“左 = 我的子孙,右 = 我右边的兄弟(这里是另外两棵树)”。见 5.8 森林 → 二叉树。
- A. T[24] 是叶结点
- B. T[24] 只有左孩子 T[49]
- C. T[24] 有左孩子 T[49] 和右孩子 T[50]
- D. T[24] 只有右孩子 T[49]
展开解析
B下标从 0 开始:左孩子 2 × 24 + 1 = 49 < 50,存在;右孩子 50,不存在(数组最大下标 49)。n = 50 是偶数,恰好有一个度为 1 的结点,就是最后一个分支结点 T[⌊50/2⌋ − 1] = T[24]。
陷阱:按从 1 编号的公式算成孩子 48、49 就会选错。见 5.2 两套编号。
- A. 0, 1, 3, 4, 2, 5
- B. 0, 1, 2, 5, 3, 4
- C. 0, 1, 2, 3, 5, 4
- D. 0, 3, 2, 1, 5, 4
展开解析
C访问 0 → 0 的第一个邻接点 1 → 1 的邻接点 0 已访问、下一个 2 → 2 的邻接点 1 已访问、下一个 3 → 3 的邻接点 0、2 已访问、下一个 5 → 5 的邻接点 2、3 已访问、下一个 4 → 4 的邻接点都已访问,依次回退,结束。序列 0, 1, 2, 3, 5, 4。
干扰项:A 是广度优先序列;B 在 2 处跳到了 5(没按边表次序先访问 3)。邻接表不同,序列可能不同——一定按题目给的次序走。
- A. 3
- B. 4
- C. 6
- D. 5
展开解析
D1 一定第一个、6 一定最后一个,只看中间 2、3、4、5 的排法,约束是 2 < 4、3 < 4、3 < 5。分情况:先 2:之后只能 3,再 4、5 任意 ⇒ 1 2 3 4 5 6、1 2 3 5 4 6(2 个);先 3:之后 2、5 都可以——3 2 后 4、5 任意(2 个),3 5 后只能 2 4(1 个)。共 5 个(程序枚举 6! 种排列核对)。
分支画成一棵“选择树”数叶子,不重不漏。见 6.9 拓扑排序。
- A. 1.625,32/13
- B. 1.625,32/11
- C. 1.5,32/11
- D. 1.625,24/11
展开解析
B建表:18 → 7、27 → 5、1 → 1(各 1 次);40 → 7 冲突 → 8(2 次);12 → 1 冲突 → 2(2 次);39 → 6(1 次);56 → 1、2 冲突 → 3(3 次);30 → 8 冲突 → 9(2 次)。成功 ASL = 13/8 = 1.625。失败:初始地址只可能是 0 ~ 10,从各地址出发直到遇到空位的比较次数为 1, 4, 3, 2, 1, 6, 5, 4, 3, 2, 1,和 32,ASL = 32/11 ≈ 2.91。
陷阱:A 的分母用了表长 13;408 口径分母取散列函数能取到的地址个数 p = 11,且与空位比较的那一次要算。见 7.9 两种口径。
- A. 92, 88, 77, 64, 85, 36, 50, 25, 41, 68, 70
- B. 92, 85, 88, 64, 70, 36, 50, 25, 41, 68, 77
- C. 92, 88, 77, 64, 85, 36, 50, 25, 41, 70, 68
- D. 88, 92, 77, 64, 85, 36, 50, 25, 41, 68, 70
展开解析
A88 放在第 11 个位置,双亲是第 5 个(70):88 > 70,交换;双亲变为第 2 个(85):88 > 85,交换;双亲是第 1 个(92):88 < 92,停。共比较 3 次。
B 错在把第 11 个位置的双亲当成了第 3 个;C 交换后把 70 放错了位置;D 越过了根。见 8.6 插入。
二、综合应用题
数组 A[0..n−1] 中存放 n 个整数。若 i < j 且 A[i] > A[j],则称 (A[i], A[j]) 为一个逆序对。例如 A = {4, 8, 2, 6, 1} 中有 7 个逆序对。请设计一个时间上尽可能高效的算法,求 A 中逆序对的个数(允许改变 A 中元素的次序)。要求:(1) 给出算法的基本设计思想;(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释;(3) 说明所设计算法的时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
借助二路归并排序。把数组分成左右两半,逆序对分三类:两个数都在左半、都在右半、一左一右。前两类递归求出;第三类在“合并两个有序段”时顺便数出来:合并时若左段当前元素 A[i] > 右段当前元素 A[j],因为左段有序,A[i..mid] 都比 A[j] 大,它们都与 A[j] 构成逆序对,计数加 mid − i + 1,然后把 A[j] 放入结果。三类之和就是答案。
long long Count(int A[], int T[], int low, int high) { // 返回 A[low..high] 的逆序对数,并把这一段排成升序 if (low >= high) return 0; // 0 或 1 个元素:没有逆序对 int mid = (low + high) / 2; long long c = Count(A, T, low, mid) + Count(A, T, mid + 1, high); // 两半各自内部的逆序对 int i = low, j = mid + 1, k = low; while (i <= mid && j <= high) { // 合并两个有序段,同时数“跨两半”的逆序对 if (A[i] <= A[j]) T[k++] = A[i++]; // 相等不算逆序,所以用 <= else { T[k++] = A[j++]; c += mid - i + 1; // A[i..mid] 都比 A[j] 大,各与 A[j] 构成一个逆序对 } } while (i <= mid) T[k++] = A[i++]; while (j <= high) T[k++] = A[j++]; for (k = low; k <= high; k++) A[k] = T[k]; return c; } long long InversePairs(int A[], int n) { int *T = (int *)malloc(n * sizeof(int)); // 归并用的辅助数组 long long c = Count(A, T, 0, n - 1); free(T); return c; }
| 合并 | 左段 | 右段 | 数出的跨段逆序对 | 累计 |
|---|---|---|---|---|
| [4] 与 [8] | 4 | 8 | 0 | 0 |
| [4 8] 与 [2] | 4 8 | 2 | 2 比 4 小:mid − i + 1 = 2(4、8) | 2 |
| [6] 与 [1] | 6 | 1 | 1(6) | 3 |
| [2 4 8] 与 [1 6] | 2 4 8 | 1 6 | 1 出列时 3 个(2、4、8);6 出列时 1 个(8) | 7 |
与二路归并排序相同:时间 O(n·log₂n);辅助数组 T 占 O(n),递归栈深度 O(log₂n),空间 O(n)。
① 只有在“右段元素先出列”时计数,加的是左段剩余的个数 mid − i + 1,不是 1;② 比较用 <=,相等的元素不构成逆序对,也保证了稳定;③ 逆序对最多 n(n − 1)/2 个,n = 10⁵ 时约 5 × 10⁹,超过 int 的范围,计数器要用 long long(程序实测完全逆序的 10 万个数得到 4,999,950,000)。代码与 O(n²) 暴力在 20 万组随机数据上对拍一致。
| 评分点 | 分 |
|---|---|
| 思想:分治(归并)+ 合并时计数,说清 mid − i + 1 的含义 | 4 |
| 代码:O(n log n) 且正确 7 分;两重循环暴力 O(n²) 正确最高 5 分 | 7 |
| 复杂度与代码一致 | 2 |
只会暴力解时怎么拿分:两重循环数 A[i] > A[j] 的对数,思想 + 代码 + 复杂度 O(n²)、O(1) 都写对,可拿 9 ~ 10 分。想到归并却写不完整时,先把思想段写清楚(4 分在这里),再写出 Merge 部分。
将关键字序列 20, 35, 40, 15, 30, 25, 38 依次插入初始为空的平衡二叉树(AVL 树)。请回答:(1) 在插入过程中,哪几次插入引起了调整?分别是哪种类型的调整?画出最终的平衡二叉树。(2) 求等概率下该平衡二叉树查找成功和查找失败的平均查找长度。(3) 若按同一序列构造普通的二叉排序树(不做平衡调整),查找成功的平均查找长度是多少?
先在纸上自己做一遍,再点这里展开满分解答
| 插入 | 最小不平衡子树的根 | 类型 | 调整后 |
|---|---|---|---|
| 40 | 20(平衡因子 −2) | RR:以 35 为轴左旋 | 35(20, 40) |
| 25 | 35(平衡因子 2) | LR:25 在 35 的左孩子 20 的右子树中,30 上位 | 30(20(15, 25), 35( , 40)) |
| 38 | 35(平衡因子 −2) | RL:38 在 35 的右孩子 40 的左子树中,38 上位 | 30(20(15, 25), 38(35, 40)) |
其余插入(35、15、30)都没有破坏平衡。
成功:第 1 层 1 个、第 2 层 2 个、第 3 层 4 个,ASL成功 = (1 + 2 × 2 + 3 × 4)/7 = 17/7 ≈ 2.43。失败:8 个空指针(失败结点)都挂在第 3 层结点下,每次失败比较 3 次,ASL失败 = 3 × 8/8 = 3。
依次插入得 20(15, 35(30(25, ), 40(38, )))。各结点层数:20 为 1,15、35 为 2,30、40 为 3,25、38 为 4。ASL成功 = (1 + 2 × 2 + 3 × 2 + 4 × 2)/7 = 19/7 ≈ 2.71,比平衡后多 2/7。
① 判型从“最小不平衡结点”出发、沿新结点的方向走两步,插入 25 时最小不平衡结点是 35 而不是根 20(插入 40 后根已经变成 35);② RL 型是让“孙子”38 上位,不是让 40 上位;③ 失败 ASL 数的是失败结点所在“父结点”的层数(比较次数),不是失败结点自己的层数。(构造过程与 ASL 由程序计算。)
| 评分点 | 分 |
|---|---|
| (1) 三次调整的位置与类型各 1 分,最终树形 2 分 | 5 |
| (2) 成功 2 分,失败 1 分 | 3 |
| (3) BST 形态与 ASL | 2 |
卷 B 答案速查(做完再看)
| 题 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 答案 | B | A | D | C | A | D | B | C | D | B | A |
| 考点 | 1.4 | 2.4 | 3.5 | 3.3 | 5.1 | 5.8 | 5.2 | 6.5 | 6.9 | 7.9 | 8.6 |
41:数组(归并排序的应用);42:查找(AVL 插入、ASL)。自评:选择题错 ≤ 1 道、41 ≥ 11 分、42 ≥ 9 分 ⇒ 本卷达到满分水平。
11.5全真模拟卷 C(数据结构 45 分 · 限时 60 分钟)
本卷的 41 题是邻接矩阵上的图算法(近年的新热点),42 题是外部排序的完整应用。数字与代码由 verify/10/p11/mock_c.py、mock_c41.c 核对。
一、单项选择题(第 1 ~ 11 题,每题 2 分)
i = 0; j = n - 1; while (i < j) { if (A[i] + A[j] > x) j--; else i++; }
- A. O(log₂n)
- B. O(n·log₂n)
- C. O(n)
- D. O(n²)
展开解析
C每一轮 i 加 1 或 j 减 1,两者的距离 j − i 每轮恰好减少 1,从 n − 1 减到 0,循环恰好 n − 1 轮 ⇒ O(n)。这就是“双指针”:两个指针一共只走 n 步。
不要被“两个变量”误导成 O(n²):判断循环次数要找“每轮必然变化、且有界的量”(这里是 j − i)。
- A. 单链表
- B. 顺序表
- C. 双链表
- D. 只设尾指针的单循环链表
展开解析
B顺序表按序号存取 O(1),在表尾插入、删除都不需要移动元素,也是 O(1)。三种链表按序号存取都要 O(n);只设尾指针的单循环链表能 O(1) 地在表尾插入,但删除表尾要找前驱,仍是 O(n)。
见 2.4 大横评。
- A. 8,5
- B. 15,6
- C. 25,5
- D. 15,5
展开解析
D调用次数 c(n) = 1 + c(n − 1) + c(n − 2),c(1) = c(2) = 1:c(3) = 3,c(4) = 5,c(5) = 9,c(6) = 15。最深的一条调用链是 f(6) → f(5) → f(4) → f(3) → f(2),5 个栈帧同时存在。
陷阱:深度不是 6——f(3) 往下调用的是 f(2) 和 f(1),它们都是出口,链到 f(2) 就到底了。A 的 8 是 f(6) 的函数值。见 3.6 栈帧图。(程序计数。)
- A. 12
- B. 16
- C. 14
- D. 24
展开解析
CT 的 next = 0, 1, 1, 2, 2, 3。第 1 趟比 S₁ ~ S₆:前 5 个相等,S₆ = a ≠ T₆ = c,共 6 次;j = next[6] = 3。第 2 趟从 S₆ 与 T₃ 比起:S₆ ~ S₈ 与 T₃ ~ T₅ 相等,S₉ = a ≠ c,共 4 次;j = 3。第 3 趟 S₉ ~ S₁₂ 与 T₃ ~ T₆ 全部相等,4 次,匹配成功(位置 7)。合计 6 + 4 + 4 = 14 次。
D 的 24 是朴素算法的比较次数。主串指针从不回退,失配那一位会被重新比较一次——这就是每趟“多出来”的那 1 次。见 4.3 逐趟图。(程序计数。)
- A. 10
- B. 9
- C. 11
- D. 500
展开解析
A高度最小时是完全二叉树:⌈log₂(1000 + 1)⌉ = 10(2⁹ = 512 < 1001 ≤ 1024)。最大高度是 1000(单支树)。
- A. x 的左孩子
- B. x 的双亲
- C. x 的左子树中最右下的结点
- D. x 的左子树中最左下的结点
展开解析
C中序“左、根、右”:x 前面紧挨着的是它左子树中序序列的最后一个结点,也就是从左孩子出发一直沿右指针走到底(rtag = 1)的那个结点。只有当左孩子没有右子树时,前驱才恰好是左孩子本身(A 只是特例)。
- A. O(n)
- B. O(e)
- C. O(n²)
- D. O(n + e)
展开解析
D出边表只记录“从我出去的弧”,要知道有多少弧指向 v,必须扫描所有 n 个顶点的边表,访问 n 个表头和 e 个边结点,O(n + e)。求出度只需扫描 v 自己的边表。若经常求入度,可以另建逆邻接表,或改用十字链表。
- A. (B, C)
- B. (D, F)
- C. (D, E)
- D. (A, B)
展开解析
B按权从小到大:(A, C) 2 选中;(C, E) 3 选中;(A, B) 4 选中;(B, C) 5——B、C 已经通过 A 连通,加入会成环,跳过;(D, F) 6 选中,是第 4 条;之后 (D, E) 7 选中,最小生成树完成(总权 22)。
陷阱:A 是“第 4 小的边”,但它会构成回路,不会被选中。“第 k 条被选中的边”≠“第 k 小的边”。(程序模拟。)
- A. 只能是 5
- B. 5 或 6
- C. 4 或 5
- D. 1 ~ 5 都有可能
展开解析
C判定树高 ⌈log₂21⌉ = 5,它是平衡的:除最后一层外是满的,所以 21 个失败结点只挂在第 4、5 层的结点下面,失败时比较 4 次或 5 次(程序统计:11 种情形比较 4 次,10 种比较 5 次)。
查找成功最多比较 5 次;失败的比较次数等于失败结点的父结点所在层数。见 7.2 判定树。
- A. 3
- B. 5
- C. 6
- D. 4
展开解析
D高度最大时每个结点关键字最少:根 1 个,其余 ⌈5/2⌉ − 1 = 2 个。高为 h 时关键字至少 2 × 3h−1 − 1:h = 4 时为 53,恰好等于 53;h = 5 时为 161 > 53。所以最大高度 4(最小高度 3:5³ − 1 = 124 ≥ 53)。
见 7.7 高度范围的推导(2013-10、2018-08 同类)。
- A. 3,10
- B. 3,50
- C. 4,10
- D. 2,25
展开解析
A趟数 = ⌈logkr⌉:⌈log₅100⌉ = 3(5² = 25 < 100 ≤ 125)。2 趟完成要求 k² ≥ 100,k ≥ 10。
增大 k 能减少趟数(2026-11 Ⅰ),但要更多的输入缓冲区,不用败者树时内部比较次数也会增加。
二、综合应用题
无向图 G 采用邻接矩阵存储,存储结构定义如下:
typedef struct { int numVertices, numEdges; // 顶点数、边数 char VerticesList[MAXV]; // 顶点表 int Edge[MAXV][MAXV]; // 邻接矩阵,1 表示有边,0 表示无边 } MGraph;
若 G 连通且不含回路,则称 G 是一棵树。请设计算法 bool IsTree(MGraph G),判断 G 是否是一棵树(不使用 numEdges 的值,G 至少有 1 个顶点)。要求:(1) 给出算法的基本设计思想;(2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释;(3) 说明所设计算法的时间复杂度和空间复杂度。
先在纸上自己做一遍,再点这里展开满分解答
利用结论:n 个顶点的无向图是一棵树 ⟺ 它连通且恰好有 n − 1 条边。先扫描邻接矩阵的上三角数出边数 e,若 e ≠ n − 1 直接返回 false;否则从 0 号顶点出发做一次广度优先遍历(深度优先也可以),数能访问到的顶点个数,等于 n 就说明连通,返回 true,否则返回 false。
bool IsTree(MGraph G) { int n = G.numVertices, e = 0; for (int i = 0; i < n; i++) // ① 数边:只看上三角,每条边数一次 for (int j = i + 1; j < n; j++) if (G.Edge[i][j] == 1) e++; if (e != n - 1) return false; // 树必须恰好 n-1 条边 bool visited[MAXV] = {false}; int Q[MAXV], front = 0, rear = 0, cnt = 0; // ② 从 0 号顶点 BFS,数能到达几个顶点 visited[0] = true; Q[rear++] = 0; while (front < rear) { int v = Q[front++]; cnt++; for (int w = 0; w < n; w++) if (G.Edge[v][w] == 1 && !visited[w]) { visited[w] = true; Q[rear++] = w; } } return cnt == n; // ③ 连通且 n-1 条边 ⟺ 是树 }
数边扫描上三角 O(n²);邻接矩阵上的 BFS 对每个出队顶点扫描一整行,也是 O(n²)。时间 O(n²),visited 与队列空间 O(n)。
① 只判“连通”或只判“边数 = n − 1”都不够——连通但多一条边有环;n − 1 条边但不连通时必然有环(例如三角形加一个孤立点)。两个条件缺一不可。② 数边时若扫描整个矩阵,每条边会数两次,要除以 2。③ 也可以改成“DFS 时遇到已访问且不是父结点的邻接点 ⇒ 有环”,但比数边更容易写错。代码与“并查集判环 + 边数”的独立判定在 20 万个随机图上对拍一致。
| 评分点 | 分 |
|---|---|
| 思想:树 ⟺ 连通 + n − 1 条边(或 连通 + 无环),说清楚如何判断连通 | 4 |
| 代码:数边正确 2 分、遍历正确 4 分、返回条件 1 分 | 7 |
| 时间 O(n²)、空间 O(n) | 2 |
拓展:若改用邻接表存储,时间降为 O(n + e);若只给边数组,用并查集逐条合并,遇到两端已在同一集合就有环(5.10)。
对一个大文件进行外部排序。请回答:(1) 用置换-选择排序生成初始归并段,内存工作区可容纳 4 个记录,输入关键字依次为 35, 12, 61, 8, 47, 29, 90, 15, 3, 72, 54, 26, 81, 40, 19, 66,写出得到的全部初始归并段。(2) 另一个文件得到了 9 个初始归并段,长度(磁盘块数)依次为 3, 6, 9, 12, 15, 18, 21, 24, 30。若采用 3 路归并,画出最佳归并树(只需写出每次合并的段),并计算归并过程中读、写磁盘的总次数。(3) 对 (2) 中的 9 个段采用 3 路平衡归并(每趟把所有段 3 个一组合并),读写磁盘的总次数是多少?
先在纸上自己做一遍,再点这里展开满分解答
| 输出(MINIMAX) | 读入 | 工作区(* 表示冻结,留给下一段) |
|---|---|---|
| — | 35, 12, 61, 8 | 35 12 61 8 |
| 8 | 47 | 35 12 61 47 |
| 12 | 29 | 35 61 47 29 |
| 29 | 90 | 35 61 47 90 |
| 35 | 15(< 35,冻结) | 61 47 90 15* |
| 47 | 3(冻结) | 61 90 15* 3* |
| 61 | 72 | 90 72 15* 3* |
| 72 | 54(冻结) | 90 15* 3* 54* |
| 90 | 26(冻结) | 全部冻结 ⇒ 第 1 段结束 |
| 3、15、26、40、54、66、81 | 81、40、19(冻结)、66 | 第 2 段(细节同上) |
| 19 | — | 第 3 段 |
初始归并段:① 8, 12, 29, 35, 47, 61, 72, 90;② 3, 15, 26, 40, 54, 66, 81;③ 19。前两段长度 8、7,都约为工作区的 2 倍;最后一段只剩 1 个记录——输入结束时工作区里剩下的冻结记录单独成段。
(9 − 1) mod (3 − 1) = 0,不需要补虚段。按 3 叉哈夫曼树合并:3 + 6 + 9 = 18;12 + 15 + 18 = 45;18 + 21 + 24 = 63;30 + 45 + 63 = 138(根)。WPL = 18 + 45 + 63 + 138 = 264(块),每块读、写各一次,读写总次数 = 2 × 264 = 528。
9 个段 3 路平衡归并需要 ⌈log₃9⌉ = 2 趟,每趟所有记录都读、写一次,共 138 块:读写总次数 = 2 × 138 × 2 = 552,比最佳归并树多 24 次。
① 置换-选择中新读入的记录比刚输出的 MINIMAX 小时必须冻结;② 第 (2) 问两个 18(原有的段和新合并出的段)先选哪个都一样,WPL 不变;③ I/O 次数 = 2 × WPL 时,WPL 的单位是块,不是记录。(全部由程序模拟计算。)
| 评分点 | 分 |
|---|---|
| (1) 三个归并段全对 4 分;冻结规则错误导致段错,酌情给 1 ~ 2 分 | 4 |
| (2) 判断不补虚段 1 分,合并过程 2 分,I/O 1 分 | 4 |
| (3) 趟数与 I/O | 2 |
卷 C 答案速查(做完再看)
| 题 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 答案 | C | B | D | C | A | C | D | B | C | D | A |
| 考点 | 1.3 | 2.5 | 3.6 | 4.3 | 5.2 | 5.6 | 6.2 | 6.6 | 7.2 | 7.7 | 8.10 |
41:图(邻接矩阵 + 遍历);42:外部排序(置换-选择、最佳归并树)。自评:选择题错 ≤ 1 道、41 ≥ 11 分、42 ≥ 9 分 ⇒ 本卷达到满分水平。三套卷都达到,数据结构这一科就稳在 43 分以上。
11.6全书知识点细目表(满分清单)
这张表把全书压成 153 条“能直接拿来做题的结论”,按篇排列。考前一周逐条过:能说出结论、能说出理由、能举一个例子,就勾上;勾不上的点链接回原文。勾选只保存在你这台电脑的浏览器里。右列是直接考过这一条的真题,点题号可以弹出原题和讲解。
总进度:
第 1 篇 绪论 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 数据、数据元素(基本单位)、数据项(最小单位)、数据对象、数据类型(原子 / 结构 / ADT)的区别 | 去看 | — | |
| 数据结构三要素:逻辑结构、存储结构、数据的运算;运算的定义针对逻辑结构,实现针对存储结构 | 去看 | — | |
| 四种逻辑结构(集合、线性、树、图),线性与非线性 | 去看 | — | |
| 四种存储结构(顺序、链式、索引、散列)的特点;存储结构影响运算效率 | 去看 | — | |
| 判断一个名词是逻辑结构还是存储结构:有序表、栈是逻辑;循环队列、散列表、线索二叉树是存储 | 去看 | — | |
| 算法五特性:有穷、确定、可行、输入(≥ 0 个)、输出(≥ 1 个);程序可以不有穷 | 去看 | — | |
| 好算法四目标:正确、可读、健壮、高效(时间少、空间省) | 去看 | — | |
| 语句频度 → T(n) → 只保留最高阶;加法规则、乘法规则 | 去看 | — | |
| 常见阶的大小:O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!) < O(nⁿ) | 去看 | — | |
| 最好、最坏、平均时间复杂度;一般讨论最坏 | 去看 | — | |
| 三类循环数次数:条件与循环体变量相关、倍增倍减、内层依赖外层(要求和) | 去看 | 2011-01 2012-01 2013-01 2014-01 2017-01 2019-01 2022-01 2025-01 | |
| 调和级数 1 + 1/2 + … + 1/n ≈ ln n;等比求和 | 去看 | — | |
| 空间复杂度:原地工作 = 辅助空间 O(1);递归的空间 = 最大递归深度 | 去看 | — | |
| 递归的时间:写递推式,展开法 / 递归树;T(n) = 2T(n/2) + n ⇒ O(n log n) | 去看 | — |
第 2 篇 线性表 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 线性表:相同类型、有限序列;位序从 1 开始;表头无前驱、表尾无后继 | 去看 | — | |
| 顺序表:随机存取、存储密度 1;静态 / 动态分配(动态分配仍是顺序存储) | 去看 | — | |
| 顺序表插入合法位置 1 ~ n + 1,移动 n − i + 1 个,平均 n/2;删除移动 n − i 个,平均 (n − 1)/2;按值查找平均 (n + 1)/2 | 去看 | 2023-01 2026-01 | |
| 头指针、头结点、首元结点;头结点的两大好处(空表非空表统一、首元结点操作统一) | 去看 | — | |
| 头插法建表得逆序,尾插法得正序,都是 O(n) | 去看 | — | |
| 单链表插入、删除的指针语句顺序;前插 = 后插 + 交换数据;删除 *p = 复制后继再删后继(尾结点不行) | 去看 | 2016-01 2024-01 | |
| 双链表插入四句的正确顺序:先连新结点、再改原后继的 prior、最后改 p->next | 去看 | — | |
| 循环单链表判空 L->next == L;循环双链表判空 L->next == L 且 L->prior == L | 去看 | — | |
| 只设尾指针的循环单链表:表头插、表尾插、删表头 O(1),删表尾 O(n);两表合并 O(1) | 去看 | 2016-02 2021-01 | |
| 静态链表:数组 + 游标,next = −1 表示表尾;插删不移动元素,不能随机存取 | 去看 | 2023-02 2026-02 | |
| 顺序表与链表的选择:存取方式、插删、空间分配、存储密度 | 去看 | — | |
| 七个基本功:原地逆置、保留下来的往前放、两表归并、头插法逆置、快慢指针、长度对齐、辅助数组计数 | 去看 | — |
第 3 篇 栈、队列和数组 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 顺序栈 top = −1 与 top = 0 两种约定下的判空、判满、进栈、出栈 | 去看 | — | |
| 共享栈栈满 top0 + 1 == top1;链栈没有栈满,操作都在表头 | 去看 | — | |
| 出栈序列合法性:模拟法、“312 模式”不可能 | 去看 | 2009-02 2010-01 2011-02 2013-02 2018-02 2020-02 2022-02 | |
| n 个元素的出栈序列数 = 卡特兰数 C(2n, n)/(n + 1):1, 1, 2, 5, 14, 42 | 去看 | 2026-42 | |
| 栈的最小容量、已知前几个出栈元素求下一个 | 去看 | — | |
| 顺序队列的假溢出;循环队列牺牲一格:空 front == rear,满 (rear + 1) % M == front,长度 (rear − front + M) % M | 去看 | 2009-01 2011-03 2014-03 | |
| 循环队列四种约定(size / tag / rear 指向队尾元素)下公式怎么变 | 去看 | — | |
| 链队列:front、rear 两个指针;删除最后一个元素时 rear 也要改;没有队满;按需求设计队列结构 | 去看 | 2019-42 | |
| 双端队列、输入受限、输出受限:能得到哪些序列 | 去看 | 2010-02 2016-03 2021-02 | |
| 队列的应用:层序遍历、BFS、缓冲区、CPU 资源排队(FCFS)、页面置换 FIFO | 去看 | — | |
| 括号匹配:左括号进栈、右括号与栈顶配对 | 去看 | — | |
| 中缀转后缀(运算符栈)、后缀求值(操作数栈)、前缀求值(从右往左)、栈中最多几个运算符 | 去看 | 2012-02 2014-02 2018-01 2024-02 2025-02 | |
| 递归工作栈:栈帧数 = 递归深度;递归改非递归(循环或自己用栈) | 去看 | 2015-01 2017-02 | |
| 数组是线性表的推广;维界;只做存取和修改;行优先、列优先地址公式(含三维) | 去看 | 2021-03 | |
| 对称、上 / 下三角矩阵压缩公式(行 / 列优先 × 下标从 0 / 1 四种组合) | 去看 | 2016-04 2018-03 2020-01 | |
| 三对角矩阵 k = 2i + j − 3(下标从 1)及反求 (i, j) | 去看 | 2016-04 | |
| 稀疏矩阵:三元组表(还要存行数、列数)、十字链表;压缩后失去随机存取 | 去看 | 2017-03 2023-03 |
第 4 篇 串 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 空串与空格串;子串、主串、位置;串相等;子串个数 n(n + 1)/2 + 1(字符互不相同) | 去看 | — | |
| 串的三种存储:定长顺序(截断)、堆分配、块链(存储密度) | 去看 | — | |
| 朴素匹配:最坏 O(nm),失配时主串指针回退 | 去看 | — | |
| KMP:主串指针不回退,O(n + m);只有部分匹配多时才明显优于朴素算法 | 去看 | — | |
| 手算 next:PM 表右移一位 + 1(位序从 1:next[1] = 0、next[2] = 1);下标从 0 的另一套约定 | 去看 | 2015-08 2019-09 | |
| KMP 比较次数怎么数:失配的那一位会被再比一次 | 去看 | 2015-08 2019-09 | |
| nextval:T[j] == T[next[j]] 时抄 nextval[next[j]],否则保留 next[j] | 去看 | 2024-06 |
第 5 篇 树与二叉树 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 树的术语:度、层次、深度 / 高度、路径长度、树的路径长度、有序树、森林 | 去看 | — | |
| 结点数 = 度数之和 + 1;n₀ = 1 + n₂ + 2n₃ + …(森林把 1 换成棵数) | 去看 | 2010-05 2016-05 | |
| m 叉树第 i 层 ≤ mⁱ⁻¹ 个,高 h 最多 (mʰ − 1)/(m − 1) 个,最小高度 ⌈logₘ(n(m − 1) + 1)⌉ | 去看 | 2022-04 2016-42 | |
| “度为 m 的树”与“m 叉树”的区别 | 去看 | — | |
| 二叉树五种形态;二叉树 ≠ 度为 2 的有序树 | 去看 | — | |
| n₀ = n₂ + 1;由 n、n₁ 求 n₀ | 去看 | 2018-04 | |
| 完全二叉树:n₀ = ⌈n/2⌉,n₁ ∈ {0, 1};某层 k 个叶子求结点数最多 / 最少 | 去看 | 2009-05 2011-04 | |
| 编号公式两套:从 1(2i、2i + 1、⌊i/2⌋)与从 0(2i + 1、2i + 2、⌊(i − 1)/2⌋) | 去看 | 2025-03 | |
| 高度 ⌈log₂(n + 1)⌉ = ⌊log₂n⌋ + 1 | 去看 | — | |
| n 个结点的二叉树形态数 = 卡特兰数;与出栈序列、先序固定的二叉树个数相同 | 去看 | 2015-02 | |
| 顺序存储适合完全二叉树,一般二叉树最坏要 2ʰ − 1 个单元;二叉链表 n + 1 个空指针,三叉链表 n + 2 个 | 去看 | 2020-03 | |
| 四种遍历的递归代码;每个结点经过三次 | 去看 | 2009-03 | |
| 非递归先序、中序(同一套进出栈)、后序(r 记录最近访问);后序非递归时栈中是祖先 | 去看 | — | |
| 层序遍历用队列;求宽度、判完全二叉树 | 去看 | — | |
| 由序列推树形:先序 = 中序 ⟺ 无左孩子;先序与后序相反 ⟺ 每个结点至多一个孩子 | 去看 | 2011-05 2017-04 2017-05 2022-03 2023-05 2024-03 | |
| 由遍历序列构造二叉树:先 + 中、后 + 中、层 + 中唯一;先 + 后不唯一(2^单孩子结点数 棵) | 去看 | 2026-03 2012-03 | |
| 线索二叉树:ltag / rtag 含义;中序线索化代码(最后补右线索);先序线索化要检查 tag | 去看 | 2014-04 | |
| 三种线索树找前驱 / 后继:先序找前驱、后序找后继需要双亲;NULL 线索个数 | 去看 | 2010-03 2013-05 | |
| 树的三种存储:双亲(找双亲快)、孩子(找孩子快)、孩子兄弟(二叉链表) | 去看 | — | |
| 树、森林 ↔ 二叉树:左孩子右兄弟;树的根没有右子树;根的右链长 = 森林棵数 | 去看 | 2009-06 2021-04 2025-04 2026-04 | |
| 遍历对应:树先根 = 二叉先序;树后根 = 森林中序 = 二叉中序 | 去看 | 2019-02 2020-04 | |
| 转换后的计数:左指针空 ⟺ 叶子;右指针空 = 分支结点数 + 1 | 去看 | 2011-06 2014-05 | |
| 哈夫曼树构造、WPL 两种算法(新结点权之和)、2n − 1 个结点、无度 1、不唯一但 WPL 唯一 | 去看 | 2010-06 2015-03 2019-03 2021-05 2026-05 | |
| 哈夫曼编码:前缀码、平均码长 = WPL ÷ 总频次、与定长编码比较、译码 | 去看 | 2014-06 2017-06 2018-05 2022-05 2023-04 2025-05 2020-42 | |
| m 叉哈夫曼树补 (m − 1) − (n − 1) mod (m − 1) 个虚叶子(余数为 0 时不补) | 去看 | 2013-04 | |
| 并查集:双亲数组、Find / Union 代码;Union 前先 Find | 去看 | — | |
| 并查集两个优化:按大小 / 高度合并(树高 ≤ ⌊log₂n⌋ + 1)、路径压缩(近似 O(1)) | 去看 | — | |
| 并查集的应用:连通分量计数、判环、Kruskal | 去看 | — | |
| 堆 = 完全二叉树 + 顺序存储 + 双亲 ≥ 孩子;次大在第 2 层、最小在叶子;堆不是 BST | 去看 | 2020-09 | |
| 判断序列是不是堆:只查 1 ~ ⌊n/2⌋ | 去看 | — | |
| 第 k 大元素的位置:深度 ≤ k;子树大小限制 | 去看 | — | |
| 筛选法建堆 O(n) 与依次插入建堆 O(n log n) 结果可能不同 | 去看 | 2018-11 2021-11 | |
| 删除任意位置元素:末尾补位后先比双亲,可能上浮 | 去看 | — | |
| 堆的应用:优先队列、k 路归并 O(n log k)、Top-k、加速哈夫曼 / Prim / Dijkstra | 去看 | 2022-42 |
第 6 篇 图 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 图的术语:有向 / 无向、简单图、完全图、子图、连通分量、强连通分量、生成树、生成森林、度、路径、回路、距离 | 去看 | 2009-07 2011-08 2017-07 | |
| 边数计算:完全图 n(n − 1)/2 或 n(n − 1);连通至少 n − 1;保证连通至少 C(n − 1, 2) + 1;强连通至少 n | 去看 | 2010-07 2022-06 2025-06 2026-07 | |
| 无向图度之和 = 2e;有向图入度和 = 出度和 = e | 去看 | — | |
| 极大连通子图(连通分量)与极小连通子图(生成树) | 去看 | — | |
| 邻接矩阵:唯一、空间 O(n²)、无向图对称可压缩;Aᵐ[i][j] = 长度为 m 的路径数 | 去看 | 2013-07 | |
| 邻接表:不唯一、空间 O(n + e)(无向 2e 个边结点);求入度要扫描全表 | 去看 | 2026-06 | |
| 十字链表(有向图,hlink / tlink)与邻接多重表(无向图,每边一个结点) | 去看 | 2024-04 | |
| 基本操作 FirstNeighbor / NextNeighbor 在两种存储上的实现与代价 | 去看 | — | |
| BFS:队列、入队时标记;邻接矩阵 O(n²)、邻接表 O(n + e);广度优先生成树 | 去看 | 2012-05 2013-08 | |
| BFS 求无权图单源最短路径 | 去看 | 2023-06 | |
| DFS:递归 / 非递归;深度优先生成树、生成森林;序列与存储结构有关 | 去看 | 2015-05 2016-06 2020-06 | |
| 用遍历判连通、数连通分量(调用次数,有向图不能这样数)、判环(有向图三色法) | 去看 | — | |
| Prim(加点,O(n²),稠密图)与 Kruskal(加边,O(e log e),稀疏图) | 去看 | 2012-08 2015-06 2020-07 | |
| MST 的唯一性:每条非树边是否是其回路上唯一的最大边;权值和唯一 | 去看 | 2023-06 | |
| Dijkstra:dist / path / final 表格;不能有负权边;O(n²) | 去看 | 2012-07 2016-08 2021-08 | |
| Floyd:A⁽ᵏ⁾ 逐步;允许负权边、不允许负权回路;O(n³) | 去看 | — | |
| BFS / Dijkstra / Floyd 适用性对照 | 去看 | — | |
| 有向无环图描述表达式:共享子式,求最少顶点数 | 去看 | 2019-06 | |
| 拓扑排序:入度法(栈 / 队列)、DFS 逆后序;序列不唯一;判环;O(n + e) | 去看 | 2010-08 2012-06 2014-07 2016-07 2018-07 2021-07 | |
| 关键路径:ve、vl、e、l、时间余量;关键活动 e = l;缩短关键活动不一定缩短工期 | 去看 | 2013-09 2019-05 2020-08 2022-07 |
第 7 篇 查找 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 查找表、静态 / 动态查找、关键字;ASL 成功按元素平均、失败按失败情形平均 | 去看 | — | |
| 顺序查找(哨兵):成功 (n + 1)/2、失败 n + 1;有序表失败 n/2 + n/(n + 1) | 去看 | — | |
| 折半查找:只适用有序顺序表;代码的三个边界 | 去看 | 2024-05 | |
| 折半判定树:向下 / 向上取整两种;平衡但不一定完全;最多比较 ⌈log₂(n + 1)⌉ 次;n + 1 个失败结点 | 去看 | 2010-09 2015-07 2016-09 2017-08 2023-08 | |
| 分块查找:块内无序、块间有序;最优块长 √n | 去看 | 2025-07 | |
| 二叉排序树:查找、插入、构造;删除三种情况;中序递增 | 去看 | 2013-06 2018-06 2020-05 2024-07 | |
| BST 的 ASL;最坏退化为 O(n);判断查找路径是否合法 | 去看 | 2011-07 | |
| AVL:平衡因子;LL / RR / LR / RL 四种旋转;只调最小不平衡子树 | 去看 | 2010-04 2013-03 2015-04 2019-04 2021-06 2026-08 | |
| AVL 最少结点 Nₕ = Nₕ₋₁ + Nₕ₋₂ + 1:1, 2, 4, 7, 12, 20, 33;由 n 求最大高度 | 去看 | 2009-04 2012-04 | |
| AVL 删除可能连续旋转 | 去看 | — | |
| 红黑树五条性质、黑高;最长路径 ≤ 2 × 最短;h ≤ 2log₂(n + 1);新结点染红 | 去看 | — | |
| 红黑树插入:叔叔红只变色(可能上传),叔叔黑旋转 + 变色(至多 2 次旋转) | 去看 | — | |
| m 阶 B 树定义:子树 ⌈m/2⌉ ~ m(根 2 ~ m),关键字 ⌈m/2⌉ − 1 ~ m − 1;叶结点在同一层 | 去看 | 2009-08 2023-07 | |
| B 树高度范围、关键字数极值、结点数极值;失败结点 n + 1 个 | 去看 | 2013-10 2014-09 2018-08 2021-09 2022-08 | |
| B 树插入:上溢从第 ⌈m/2⌉ 个关键字处分裂,中间关键字上移;只从根长高 | 去看 | 2020-10 2025-08 | |
| B 树删除:前驱 / 后继顶替 → 直接删 → 借兄弟 → 合并(可能传到根) | 去看 | 2012-09 | |
| B+ 树:k 个关键字 k 棵子树;关键字全在叶子、叶子成链;随机查找必到叶;支持顺序与范围查询 | 去看 | 2016-10 2017-09 | |
| 散列函数构造:直接定址、除留余数(p 取 ≤ m 的最大质数)、数字分析、平方取中 | 去看 | — | |
| 冲突处理:线性探测(堆积)、平方探测(4k + 3 质数才能探全表)、双散列、伪随机、拉链法 | 去看 | 2014-08 2025-09 | |
| 开放定址不能真删除,要做删除标记 | 去看 | 2023-09 | |
| ASL 成功 / 失败的算法;失败 ASL 分母取 p,与空位比较也算;拉链法只数关键字比较 | 去看 | 2018-09 2019-08 2010-41 2024-42 | |
| ASL 取决于散列函数、冲突处理方法、装填因子 α,与 n 无直接关系 | 去看 | 2011-09 2022-09 |
第 8 篇 排序 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 稳定性的定义与判断(看有没有“跳跃式交换”);内部 / 外部排序 | 去看 | — | |
| 直接插入:最好 O(n)、最坏 O(n²)、稳定;比较次数与移动次数怎么数;可用于链表 | 去看 | 2009-10 2012-11 2020-11 2026-09 | |
| 折半插入:比较 O(n log n),移动仍 O(n²);只能顺序存储 | 去看 | — | |
| 希尔排序:按增量分组插入;不稳定;只能顺序存储;由一趟结果反推增量 | 去看 | 2014-10 2015-11 2018-10 2025-11 | |
| 冒泡排序:每趟一个元素到最终位置;flag 提前结束;最好 O(n) | 去看 | 2010-11 | |
| 快速排序:Partition 逐行;平均 O(n log n)、最坏 O(n²)(有序时);空间 O(log n) ~ O(n);不稳定 | 去看 | 2010-10 2011-10 2014-11 | |
| 快排“第 i 趟”结果的判断;由划分结果反推枢轴 | 去看 | 2019-10 2023-11 2024-08 | |
| 简单选择:比较 n(n − 1)/2 与初态无关,移动至多 3(n − 1),不稳定 | 去看 | 2025-10 | |
| 堆排序:建堆 O(n)、每次调整 O(log n)、总 O(n log n)、空间 O(1)、不稳定;插入上浮、删除下沉 | 去看 | 2009-09 2011-11 2015-10 2024-09 | |
| 二路归并:Merge 代码、趟数 ⌈log₂n⌉、空间 O(n)、稳定;比较次数与初态有关 | 去看 | 2017-10 2022-10 2024-10 | |
| 基数排序:LSD / MSD,分配与收集,O(d(n + r)),稳定;多关键字排序;计数排序〔拓〕 | 去看 | 2013-11 2015-09 2021-10 2026-10 2021-42 | |
| 各种排序的时间、空间、稳定性、适用存储总表;与初态的关系(比较 / 移动 / 趟数) | 去看 | 2012-10 2017-11 2022-11 2023-10 | |
| 选排序方法的四个因素:规模、存储方式、稳定性、初始状态 | 去看 | 2019-07 | |
| 外部排序:时间主要花在 I/O;归并趟数 ⌈logₖr⌉;I/O 次数计算;缓冲区个数 | 去看 | 2016-11 2019-11 2026-11 | |
| 败者树:k − 1 个非叶结点 + 冠军;每次重赛 ⌈log₂k⌉ 次比较 | 去看 | 2024-11 | |
| 置换-选择排序:MINIMAX 与冻结;初始归并段平均长度 2w,长度不等 | 去看 | 2023-42 | |
| 最佳归并树:k 叉哈夫曼树 + 补虚段;I/O = 2 × WPL | 去看 | 2012-41 |
算法大题 ()
| ✓ | 知识点(结论) | 讲解 | 真题 |
|---|---|---|---|
| 答题三段式:设计思想(4 分左右)+ 代码与注释(7 分左右)+ 复杂度(2 分);暴力解也能拿七成 | 去看 | — | |
| 数组 / 顺序表题:双指针、辅助数组计数、排序后扫描、划分 | 去看 | 2010-42 2011-42 2013-41 2016-43 2018-41 2020-41 2025-41 | |
| 链表题:逆置、快慢指针、长度对齐、归并、去重(2019 年后未考,重点准备) | 去看 | 2009-42 2012-42 2015-41 2019-41 | |
| 二叉树题:递归遍历 + 访问时做事(WPL、表达式、判 BST、宽度) | 去看 | 2014-41 2017-41 2022-41 2026-41 | |
| 图算法题:邻接矩阵 / 邻接表上数度、找路径、判性质 | 去看 | 2021-41 2023-41 2024-41 | |
| 图的应用题:最短路、最小生成树、关键路径、存储设计 | 去看 | 2009-41 2011-41 2014-42 2015-42 2017-42 2018-42 2025-42 | |
| 查找应用题:散列建表与两种 ASL、不等概率下选查找结构 | 去看 | 2010-41 2013-42 2024-42 | |
| 排序与外部排序应用题:Top-k、计数、置换-选择、最佳归并树 | 去看 | 2012-41 2021-42 2022-42 2023-42 | |
| 结构设计与性质推导:正则 k 叉树、队列设计、前缀码、出栈序列计数 | 去看 | 2016-42 2019-42 2020-42 2026-42 | |
| 预测代码题:并查集、堆、非递归遍历、拓扑排序、Dijkstra、三色划分 | 去看 | — |