开篇:考情速览与使用指南
数据结构在 408 里值多少分?
| 科目 | 分值 | 说明 |
|---|---|---|
| 数据结构 | 45 / 150 分 | 单选题约 11~12 道(22~24 分)+ 综合应用题 2 道(约 21~23 分,含一道算法设计题) |
| 计算机组成原理 | 45 分 | —— |
| 操作系统 | 35 分 | 与数据结构联动多(进程队列 = 队列、文件 = B+ 树……) |
| 计算机网络 | 25 分 | —— |
本教程按 408 考纲组织,章节顺序与严蔚敏《数据结构(C 语言版)》和王道《数据结构考研复习指导》一致,可以完美搭配使用:本册负责「讲懂 + 图解 + 手算」,王道负责海量刷题。
怎么用这一册效果最好?
- 先读结构图:每一章的图解(链表图、树图、图论图)就是考场上你脑中应有的画面,务必能自己在纸上画出来。
- 再背手算模板:next 数组、Prim/Kruskal、Dijkstra 表格、散列 ASL……这些是选择题的直接得分点,每章的「🧮 考场手算」框就是为它们准备的。
- 然后敲代码:考研代码题要求能写出可运行的算法,本册代码全部用 C++(含引用传参
&,写法见上一册第 9 章),可直接上机验证。 - 最后做每章练习:题目按真题风格改编,先自己算,再点开答案对照。
本册会用到:struct 结构体(第 13 章)、函数与引用传参(第 9 章)、指针(第 12 章)、queue 等 STL 容器(第 15 章)。哪一块生疏了,回上一册对应章节补 20 分钟即可。
| 章节 | 考频 | 主要出题方式 |
|---|---|---|
| 01 绪论与复杂度 | ⭐⭐ | 选择题:算时间复杂度 |
| 02 线性表 | ⭐⭐⭐ | 选择 + 算法设计大题高频区 |
| 03 栈、队列、数组 | ⭐⭐⭐ | 选择题极多:出栈序列、循环队列、矩阵压缩 |
| 04 串与 KMP | ⭐⭐ | 选择题:手算 next / nextval |
| 05 树与二叉树 | ⭐⭐⭐ | 选择题最多的一章 + 大题(遍历、线索、哈夫曼) |
| 06 图 | ⭐⭐⭐ | 大题常客:手算 MST / 最短路 / 拓扑 |
| 07 查找 | ⭐⭐⭐ | 选择 + 大题:判定树、AVL、B 树、散列 ASL |
| 08 排序 | ⭐⭐⭐ | 选择题极多:手算排序过程、稳定性、复杂度大表 |
绪论:数据结构三要素与复杂度分析
程序 = 数据结构 + 算法
这是计算机科学家沃斯(Wirth)的著名公式。数据结构决定数据「怎么摆放」,算法决定「按什么步骤处理」。摆放方式不同,处理效率天差地别——这就是这门课要研究的一切。
考点一:数据结构三要素
- 逻辑结构:数据元素之间逻辑上的关系,与怎么存无关。四种:集合、线性结构(一对一)、树形结构(一对多)、图状结构(多对多)。
- 存储结构(物理结构):逻辑结构在内存中的映像。四种:顺序存储、链式存储、索引存储、散列(哈希)存储。
- 数据的运算:运算的定义针对逻辑结构,运算的实现针对存储结构。
| 逻辑结构 | 元素关系 | 典型例子 |
|---|---|---|
| 集合 | 除「同属一个集合」外无其他关系 | 并查集 |
| 线性结构 | 一对一,有且仅有一个开始和终端结点 | 线性表、栈、队列、串 |
| 树形结构 | 一对多 | 二叉树、B 树 |
| 图状结构 | 多对多 | 有向图、无向图、网 |
「循环队列」「顺序表」是线性结构;判断「存储结构」的标志是看它是否关心内存地址(顺序表关心、链表也关心——它们都是存储结构层面的名词,逻辑上都叫线性表)。考题爱用「有序表」设坑:有序表是逻辑结构(描述元素已排序),不是存储结构。
考点二:算法的五个特性与设计目标
五个重要特性:有穷性(有限步内结束——注意「死循环程序」是程序不是算法)、确定性、可行性、输入(0 个或多个)、输出(1 个或多个)。
设计目标:正确性、可读性、健壮性、高效率与低存储量。
考点三:时间复杂度(每年必考)
语句执行次数 T(n) 随问题规模 n 的增长规律,用大 O 记号表示,只保留最高阶且去掉系数。考场三步法:
- 找出执行次数最多的那条语句(基本操作)。
- 算出它一共执行了多少次 f(n)(累加求和 / 解不等式)。
- f(n) 取最高阶、去系数,写 O(f(n))。
常见复杂度从小到大(必背顺序):
例题 1:乘法增长 → 对数阶
int x = 1;
while (x < n)
x = x * 2; // 基本语句:每次翻倍,执行 ⌈log₂n⌉ 次 → O(log₂n)凡是循环变量乘 2(或乘 3、除 2)增长到 n 的,一律 O(log₂n)。设执行 t 次后 2ᵗ ≥ n,取对数即得。
例题 2:嵌套累加 → 平方阶
int sum = 0;
for (int i = 1; i <= n; i++)
for (int j = i; j <= n; j++) // 内层执行 n-i+1 次
sum++; // 共 n+(n-1)+…+1 = n(n+1)/2 → O(n²)例题 3:递归式 → 套速查表
| 递归式 | 典型算法 | 结果 |
|---|---|---|
| T(n) = T(n−1) + O(1) | 递归遍历链表 / 先序递归(单子链) | O(n) |
| T(n) = 2T(n/2) + O(n) | 归并排序、快排(最好情况) | O(n log₂n) |
| T(n) = T(n/2) + O(1) | 折半查找 | O(log₂n) |
| T(n) = 2T(n−1) + O(1) | 汉诺塔 | O(2ⁿ) |
考点四:最好 / 最坏 / 平均时间复杂度
同一算法输入不同,代价不同。最坏情况给出上界(通常说某算法是 O(f(n)) 指最坏),平均情况按概率加权。典型:直接插入排序最好 O(n)(基本有序)、最坏 O(n²)(基本逆序)——第 8 章会反复用到。
考点五:空间复杂度
只算辅助空间。原地工作(只用常数个辅助变量)是 O(1);递归的函数栈要计入(递归深度 d 就是 O(d))。例:归并排序需要长度 n 的辅助数组 → O(n);快排递归栈平均 O(log₂n)、最坏 O(n)。
1-1:for (i=1; i<=n; i++) for (j=1; j<=i*2; j++) x++; 的时间复杂度?
1-2:i=1; while(i<=n) i=i*3; 的时间复杂度?
1-3(真题风格):以下程序段的时间复杂度?for(i=1;i<=n;i++) for(j=1;j<=n;j*=2) x++;
👀 查看参考答案
1-1:内层次数 2+4+…+2n = n(n+1) → O(n²)。
1-2:3ᵗ ≥ n → O(log₃n) = O(log₂n)(对数底数不影响数量级)。
1-3:外层 n 次,内层每次 log₂n 次 → O(n log₂n)。
线性表:顺序表与链表全对比
线性表的定义
具有相同数据类型的 n 个数据元素的有限序列(n=0 时为空表)。逻辑上一对一,有前驱后继。注意:线性表是逻辑结构;顺序表和链表是它的两种存储结构。
一、顺序表:一排连续的储物格
#define MaxSize 50
typedef struct {
int data[MaxSize]; // 连续存储区
int length; // 当前元素个数
} SqList;- 位序 vs 下标:位序 i 从 1 开始,对应下标 i−1,这是选择题和代码题共同的易错点。
- 特点:随机存取 O(1)(一算地址就到);插入删除要大量移动元素。
插入:第 i 个位置插入 e,后面的元素整体后移
bool ListInsert(SqList &L, int i, int e) {
if (i < 1 || i > L.length + 1) return false; // 位序非法(允许插到表尾后一位)
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;
}
bool ListDelete(SqList &L, int i, int &e) {
if (i < 1 || i > L.length) return false;
e = L.data[i - 1];
for (int j = i; j < L.length; j++) // 前移:从前往后
L.data[j - 1] = L.data[j];
L.length--;
return true;
}插入:等概率下平均移动 n/2 次;删除:平均移动 (n−1)/2 次 → 插删时间复杂度都是 O(n),按值查找(顺序比较)也是 O(n),按位查找 O(1)。
二、单链表:用指针串起来的珍珠链
typedef struct LNode {
int data; // 数据域
struct LNode *next; // 指针域:指向后继结点
} LNode, *LinkList; // LNode* 强调「结点」,LinkList 强调「整个链表」头指针:指向链表第一个结点的指针,链表的「名字」,必须有;头结点:在首元结点前附加的一个结点(不存数据),可有可无,有了它「在表头插入」和「在表中间插入」逻辑就统一了,推荐考研统一带头结点。
插入与删除:只改指针,不搬元素
// 头插法建表:读入的顺序与链表顺序【相反】
LinkList CreateByHead(int a[], int n) {
LinkList L = new LNode; // 头结点
L->next = NULL;
for (int i = 0; i < n; i++) {
LNode *s = new LNode;
s->data = a[i];
s->next = L->next; // 新结点插到头结点之后
L->next = s;
}
return L;
}
// 尾插法建表:顺序一致,需要一个尾指针 r
LinkList CreateByTail(int a[], int n) {
LinkList L = new LNode;
LNode *r = L; // r 永远指向尾结点
for (int i = 0; i < n; i++) {
LNode *s = new LNode;
s->data = a[i]; r->next = s; r = s;
}
r->next = NULL;
return L;
}
// 删除 p 的后继结点
void DeleteAfter(LNode *p) {
LNode *q = p->next;
p->next = q->next;
delete q;
}三、双链表与循环链表
typedef struct DNode {
int data;
struct DNode *prior, *next;
} DNode, *DLinkList;
// 在 p 之后插入 s —— 四句口诀:先接新,再改旧
s->next = p->next;
s->prior = p;
p->next->prior = s; // 注意:必须在 p->next = s 之前做
p->next = s;| 结构 | 判空条件(带头结点) | 特点 |
|---|---|---|
| 循环单链表 | L->next == L | 尾结点 next 指回头结点;从任一结点可遍历全表,常只设尾指针 |
| 循环双链表 | L->next == L 且 L->prior == L | 头尾互指,双向都可回到头 |
| 静态链表 | next == -1 | 用数组模拟链表(游标),无指针也能「链」,容量固定 |
四、顺序表 vs 链表:一张表定选择
| 对比项 | 顺序表 | 链表 |
|---|---|---|
| 存取方式 | 随机存取 O(1) | 顺序存取 O(n) |
| 插入 / 删除 | O(n),大量搬移 | 已知位置 O(1) 改指针(查找仍 O(n)) |
| 空间 | 静态分配可能溢出/闲置 | 按需分配,但每个结点多一个指针域 |
| 缓存友好 | ✔ 连续内存命中率极高 | ✘ 结点散落各处 |
| 适用场景 | 查多改少、表长稳定、按位访问 | 频繁插删、表长未知 |
2-1:设计 O(n) 时间、O(1) 空间的算法,将顺序表原地逆置。
2-2:在带头结点的单链表中删除所有值为 x 的结点。
👀 查看参考答案
// 2-1 双下标对撞交换
void Reverse(SqList &L) {
for (int i = 0, j = L.length - 1; i < j; i++, j--)
swap(L.data[i], L.data[j]);
}
// 2-2 前驱指针法:p 扫描,pre 记录前驱
void DelX(LinkList &L, int x) {
LNode *pre = L, *p = L->next;
while (p != NULL) {
if (p->data == x) {
pre->next = p->next;
delete p;
p = pre->next;
} else {
pre = p; p = p->next;
}
}
}栈、队列与数组:受限的线性表
一、栈(Stack):后进先出 LIFO
只允许在栈顶一端插入(进栈 push)和删除(出栈 pop),像摞盘子。栈是递归、函数调用、表达式求值的底层机制。
#define MaxSize 50
typedef struct {
int data[MaxSize];
int top; // 栈顶指针
} SqStack;
void InitStack(SqStack &S) { S.top = -1; }
bool StackEmpty(SqStack S) { return S.top == -1; }
bool Push(SqStack &S, int x) {
if (S.top == MaxSize - 1) return false; // 栈满
S.data[++S.top] = x; // 先移指针再存
return true;
}
bool Pop(SqStack &S, int &x) {
if (S.top == -1) return false; // 栈空
x = S.data[S.top--]; // 先取再移
return true;
}约定一:top = -1 表示空,元素存 data[++top](先加后存),栈顶元素是 data[top];约定二:top = 0 表示空、指向栈顶下一格,元素存 data[top++],栈顶元素是 data[top-1],栈满条件变 top == MaxSize。看清题目用哪种约定!
① 共享栈:两个栈共享一个数组,栈底分设两端、向中间推进,栈满条件 top1 + 1 == top2——空间利用率更高。② 出栈序列个数:n 个不同元素依次进栈,出栈序列共有卡特兰数 C(2n,n)/(n+1) 种(n=3 时 5 种,n=4 时 14 种)。判断某序列「可不可能」:模拟进出栈即可。
二、队列(Queue):先进先出 FIFO
队尾入队(rear)、队头出队(front)。顺序队列不断后移会造成假溢出(前面已出队的位置浪费)——解决:取模构成循环队列。
typedef struct {
int data[MaxSize];
int front, rear;
} SqQueue;
bool EnQueue(SqQueue &Q, int x) {
if ((Q.rear + 1) % MaxSize == Q.front) return false; // 队满(牺牲一格)
Q.data[Q.rear] = x;
Q.rear = (Q.rear + 1) % MaxSize; // 取模回绕
return true;
}
bool DeQueue(SqQueue &Q, int &x) {
if (Q.rear == Q.front) return false; // 队空
x = Q.data[Q.front];
Q.front = (Q.front + 1) % MaxSize;
return true;
}| 判空 / 判满方案 | 队空 | 队满 | 备注 |
|---|---|---|---|
| 牺牲一格(最常用) | front == rear | (rear+1)%MaxSize == front | 最多存 MaxSize−1 个 |
| 增设 size 变量 | size == 0 | size == MaxSize | 不浪费空间 |
| 增设 tag 标志 | 出队后 tag=0 且 front==rear | 入队后 tag=1 且 front==rear | 记录最后一次操作 |
两端都可进可出 = 双端队列;输入受限:只能一端进、两端出;输出受限:两端进、只能一端出。考法:给出某种双端队列,判断哪个输出序列合法——同样用模拟法验证。
三、栈的应用 1:括号匹配
bool bracketCheck(char str[], int len) {
SqStack S; InitStack(S);
for (int i = 0; i < len; i++) {
char c = str[i];
if (c == '(' || c == '[' || c == '{') {
Push(S, c); // 左括号进栈
} else if (c == ')' || c == ']' || c == '}') {
if (StackEmpty(S)) return false; // 右括号多了
char t; Pop(S, t);
if ((c == ')' && t != '(') ||
(c == ']' && t != '[') ||
(c == '}' && t != '{')) return false;
}
}
return StackEmpty(S); // 栈空 → 全部匹配;不空 → 左括号多了
}四、栈的应用 2:表达式求值(必考手算)
中缀 → 后缀(逆波兰式)手算规则
- 操作数:直接加入输出。
- 运算符:先把栈中优先级 ≥ 自己的运算符弹出输出,再把自己入栈(左括号在栈里当「盾牌」挡住弹出)。
- 左括号入栈;右括号:连续弹出输出直到遇到左括号(左括号丢弃)。
- 结束时把栈中剩余运算符依次弹出。
| 读入 | 动作 | 栈内 | 已输出 |
|---|---|---|---|
| A | 输出 | — | A |
| + | 入栈 | + | A |
| B | 输出 | + | A B |
| * | 优先级高于 +,入栈 | + * | A B |
| ( | 入栈 | + * ( | A B |
| C − D | C 输出;− 入栈;D 输出 | + * ( − | A B C D |
| ) | 弹到 ( 为止:− 输出 | + * | A B C D − |
| − | 弹 * 和 +(≥自己)再入栈 | − | A B C D − * + |
| E / F | E 输出;/ 入栈;F 输出 | − / | A B C D − * + E F |
| 结束 | 全部弹空 | — | A B C D − * + E F / − |
后缀式求值
从左到右扫:数字入栈;遇运算符弹出两个数计算再压回——注意先弹出的是右操作数(减法除法别减反了)。
五、栈的应用 3:递归的本质
函数调用靠函数调用栈实现:每调用一次,栈里压入一帧(参数、局部变量、返回地址);返回时弹出一帧。递归 = 自己调用自己 → 栈深 = 递归深度。递归优点是思路清晰,缺点是开销大、可能栈溢出,可用「尾递归改循环」优化。
六、数组:特殊矩阵的压缩存储(真题高频)
对称矩阵只需存下三角(含对角线)共 n(n+1)/2 个元素,按行优先存入一维数组 a[0..],aij(i ≥ j)的下标公式:
| 矩阵类型 | 公式(下标从 0 的数组) |
|---|---|
| 对称矩阵(存下三角,i ≥ j) | k = i(i−1)/2 + j − 1;若 i < j 按对称取 aji |
| 下三角矩阵 | 同上(多存一个常数 c,放末尾) |
| 三对角矩阵(|i−j| ≤ 1) | k = 2i + j − 3 |
| 稀疏矩阵 | 三元组表 (行, 列, 值) 或十字链表 |
验证:a₁₁ → 1×0/2+1−1=0 ✔;a₃₂ → 3×2/2+2−1=4 ✔(a₃₁ 是 3,a₃₂ 是 4,对得上)。
3-1:元素 a、b、c、d、e 依次进栈(可随时出栈),下列哪个出栈序列不可能:① abcde ② edcba ③ a c b e d ④ d b c a e?
3-2:循环队列 MaxSize=10,front=3,rear=1,队中元素个数是多少?
3-3:10 阶对称矩阵压缩存下三角,a₈₅ 存在数组下标多少的位置?
👀 查看参考答案
3-1:④ 不可能。d 最先出栈说明 a、b、c 都还在栈里且 c 上、b 中、a 下,d 出栈后下一个必须先出 c,不可能直接出 b(①②③均可模拟成功)。元素总数 5 → 出栈序列共 42 种(卡特兰数)。
3-2:(1 − 3 + 10) % 10 = 8 个。
3-3:k = 8×7/2 + 5 − 1 = 32。
串与 KMP:主串指针从不回头
基本概念
串(string)是内容受限的线性表:元素只能是字符。子串在主串中的位置用位序表示,且从 1 开始数(教材约定)。模式匹配:在主串 S 中找模式串 T 第一次出现的位置。
朴素模式匹配(BF 算法)为什么慢?
每次失配,主串指针 i 都要回溯到本次起点+1、模式串指针 j 回到 1 重新比,最坏 O(nm)。例:S = "aaaaab",T = "aaab",每次都要比到最后一个字符才发现失配。
KMP 的核心思想
失配时,主串指针 i 绝不回溯,只把模式串指针 j 退回到 next[j] 继续比较。凭什么?模式串已经匹配过的部分里,前缀和后缀有相等的部分,这部分不必重比。整体复杂度 O(n+m)。
手算 next 数组(必考!)
规则:next[j] = 模式串第 1 ~ j−1 个字符构成的子串中,最长相等前后缀的长度 + 1。约定 next[1] = 0(第 1 位失配时 j 归 0、i 前进),next[2] = 1(前面只剩 1 个字符)。
逐位操作:对每一位 j,只看它前面那一段:找「开头能对上的最长小段」,其长度 + 1 写入 next[j]。
例:T = a b a b a:
| j | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| T[j] | a | b | a | b | a |
| 前面的串 | — | a | ab | aba | abab |
| 最长相等前后缀 | — | 无(0) | 无(0) | a(1) | ab(2) |
| next[j] | 0 | 1 | 1 | 2 | 3 |
nextval:在 next 基础上再优化
求出 next 后逐位检查:若 T[j] == T[next[j]],则 nextval[j] = nextval[next[j]](继承更小的);否则 nextval[j] = next[j]。从左往右算,后面要引用前面刚算好的 nextval。
例:T = a b a b a(沿用上表 next = 0 1 1 2 3):
| j | 比较 | 结论 |
|---|---|---|
| 1 | — | nextval = 0 |
| 2 | T[2]=b ≠ T[next[2]=1]=a | nextval = 1 |
| 3 | T[3]=a = T[1]=a | nextval = nextval[1] = 0 |
| 4 | T[4]=b = T[2]=b | nextval = nextval[2] = 1 |
| 5 | T[5]=a = T[3]=a | nextval = nextval[3] = 0 |
代码实现(下标从 1 开始,T[0]、S[0] 空置)
void get_next(char T[], int next[], int m) {
int i = 1, j = 0;
next[1] = 0;
while (i < m) {
if (j == 0 || T[i] == T[j]) { // 字符相等(或从头开始)
++i; ++j;
next[i] = j; // next[i+1] 由 next[i] 推出
} else {
j = next[j]; // 失配:j 退到 next[j]
}
}
}
int KMP(char S[], char T[], int n, int m, int next[]) {
int i = 1, j = 1;
while (i <= n && j <= m) {
if (j == 0 || S[i] == T[j]) { // 匹配成功或 j 归零:双指针一起走
++i; ++j;
} else {
j = next[j]; // 失配:i 不动,j 回退
}
}
if (j > m) return i - m; // 匹配成功,返回起始位序
return 0; // 失败
}① 求 next 是「看前面」算自己,别把 T[j] 自己算进去;② KMP 过程中主串指针 i 永不回退(选择题原题);③ next 数组的计算与主串无关,只由模式串自己决定;④ 有些教材 next 定义从 −1 或右移一位起,看清约定再套。
4-1:手算 T = "abaabc" 的 next 与 nextval。
4-2:模式串 "aaab" 在主串 "aaaab" 中匹配,用 KMP 一共比较了几次字符?
👀 查看参考答案
4-1:next = 0 1 1 2 3 4;nextval:j=1→0;j=2 b≠a→1;j=3 a=a(T[1])→0;j=4 a=T[?]: next[4]=2, T[4]=a≠T[2]=b → nextval=2;j=5 b: next[5]=3, T[5]=b≠T[3]=a → nextval=3;j=6 c: next[6]=4, T[6]=c≠T[4]=a → nextval=4。即 0 1 0 2 3 4。
4-2:比较 8 次。j 在第 4 位失配后 next[4]=2,接着 next[2]=1,再 next[1]=0 → i 前进,总比 4+2+2 次。
树与二叉树:选择题最大的「粮仓」
一、树的性质(先背结论再做题)
- 结点数 = 总度数 + 1(每个结点被一条边指着,除了根)。
- 度为 m 的树第 i 层至多 mi−1 个结点;h 层 m 叉树至多 (mh−1)/(m−1) 个结点。
- 度为 m、有 n 个结点的树:至少有 (m−1)n + 1 − mn…(化简后:n₀ 至少 n − (mn−1)/(m−1) 取整相关,做题直接用 n₀ = n₂+1 推)。
二、二叉树与它的两大「贵族」
二叉树:每个结点至多两棵子树,且子树有左右之分(有序)。
| 性质 | 结论 | 备注 |
|---|---|---|
| n₀ = n₂ + 1 | 叶子数 = 度 2 结点数 + 1 | 由结点数 = 度数 + 1 推出,选择题第一高频 |
| 第 i 层至多 | 2i−1 个结点(i ≥ 1) | 满则该层全满 |
| h 层至多 | 2h − 1 个结点 | 此时称满二叉树 |
| 完全二叉树高度 | ⌈log₂(n+1)⌉ = ⌊log₂n⌋ + 1 | n 个结点 |
| 完全二叉树编号 | 结点 i 的孩子是 2i、2i+1;双亲是 ⌊i/2⌋ | 1 起始编号;i ≤ ⌊n/2⌋ 时为分支结点 |
满二叉树:每层都满。完全二叉树:只允许最后一层缺右边若干连续结点。完全二叉树可以用数组顺序存储而不浪费空间——这正是「堆」的地基(第 8 章)。
三、存储结构
typedef struct BiTNode {
int data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;n 个结点的二叉链表共有 2n 个指针域,用了 n−1 个(n−1 条边),空指针 = n+1 个——这 n+1 个空位就是「线索化」的原材料。
四、遍历:所有大题的出发点
void PreOrder(BiTree T) { // 先序
if (T != NULL) {
visit(T); // 根
PreOrder(T->lchild); // 左
PreOrder(T->rchild); // 右
}
}
void InOrder(BiTree T) { // 中序:visit 移到中间
if (T != NULL) {
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
void PostOrder(BiTree T) { // 后序:visit 移到最后
if (T != NULL) {
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
// 层序遍历:借助队列(C++ STL,见上一册第 15 章)
void LevelOrder(BiTree T) {
queue<BiTree> Q;
if (T != NULL) Q.push(T);
while (!Q.empty()) {
BiTree p = Q.front(); Q.pop();
visit(p); // 出队即访问
if (p->lchild) Q.push(p->lchild); // 先左后右依次入队
if (p->rchild) Q.push(p->rchild);
}
}中序 + 任意一种其他遍历(先序/后序/层序)可以唯一确定二叉树;先序+后序不能(无法区分只有左孩子还是只有右孩子)。大题套路:先序找根 → 中序分左右 → 递归还原。
五、线索二叉树:把 n+1 个空指针用起来
若结点的左/右指针为空,就让它分别指向该结点的前驱/后继(按某种遍历),并用 tag 区分「孩子指针」和「线索」:
typedef struct ThreadNode {
int data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; // 0 = 指向孩子;1 = 指向前驱/后继的线索
} ThreadNode, *ThreadTree;中序线索树上找某结点的后继:若 rtag=1 直接走右线索;若有右孩子,后继 = 右子树中「最左下的结点」。n 个结点的线索二叉树含 n+1 个线索(空指针数)。
六、树、森林与二叉树的互相转换
规则一句话:左孩子、右兄弟——树中结点的第一个孩子变成它在二叉树中的左孩子,下一个兄弟变成右孩子。转换后树的根没有右子树;森林同理,各棵树的根互为右兄弟。
| 树 / 森林的遍历 | 对应二叉树的遍历 | 备注 |
|---|---|---|
| 树的先根遍历 | 对应二叉树的先序遍历 | 选择题原题级考点 |
| 树的后根遍历 | 对应二叉树的中序遍历 | 注意:树没有「中根」的说法 |
| 森林的先序 / 中序遍历 | 对应二叉树的先序 / 中序遍历 | 森林的「中序」= 依次后根遍历每棵树 |
树的三种存储:①双亲表示法(数组存 data + parent 下标,找双亲 O(1)、找孩子难);②孩子表示法(顺序 + 单链表);③孩子兄弟表示法(就是转二叉树用的二叉链表)。
七、哈夫曼树与哈夫曼编码
带权路径长度 WPL = Σ(叶权值 × 到根的路径长度)。WPL 最小的二叉树叫哈夫曼树(最优二叉树)。构造:每次取出权值最小的两棵树合并,新根权值为其和,重复 n−1 次。
① n 个叶子(初始权值)的哈夫曼树共 2n − 1 个结点,且不存在度为 1 的结点;② 哈夫曼编码是前缀码(任何编码都不是另一个的前缀),解码不会歧义;③ 同一组权值可能构造出形态不同但 WPL 相同的哈夫曼树(左右子树交换不影响)。
八、并查集(2020 年起已纳入考纲,要求不高)
用「双亲表示法」的森林维护若干不相交集合,支持两个操作:Find(找所属集合代表)、Union(合并两集合)。典型应用:判断图的连通性、Kruskal 判环。
int fa[N];
void init() { for (int i = 0; i < N; i++) fa[i] = i; } // 各自为营
int find(int x) { // 一直向上找根
return fa[x] == x ? x : find(fa[x]);
}
void merge(int x, int y) { // 一棵树挂到另一棵下
fa[find(x)] = find(y);
}5-1:一棵二叉树有 20 个叶子结点、30 个度为 1 的结点,总结点数是多少?
5-2:n 个结点的二叉链表中空指针有多少个?中序线索二叉树中有多少条线索?
5-3:已知先序 ABDEC、中序 DBEAC,画出二叉树并写后序。
👀 查看参考答案
5-1:n₂ = n₀ − 1 = 19,总数 = 20 + 30 + 19 = 69。
5-2:均为 n+1。
5-3:先序首字符 A 是根,中序 DBE | A | C 定位左右子树 → 根 A,左子树(先序 BDE / 中序 DBE)继续分解……结果后序为 DEBCA。
图:从存储到四大算法
一、基本概念与术语
- 无向图:边 (v,w) 无方向;有向图:弧 <v,w> 从 v 指向 w。
- 完全图:任意两顶点间都有边。无向完全图 n(n−1)/2 条边;有向完全图 n(n−1) 条弧。
- 连通(无向:任意两点有路径)/强连通(有向:双向都有路径)。生成树:连通图含全部 n 个顶点的极小连通子图,恰有 n−1 条边。
- 无向图所有顶点度数之和 = 2×边数;有向图入度之和 = 出度之和 = 弧数。
二、存储结构
#define MaxVertexNum 100
// 邻接矩阵:判断有没有边 O(1)
int G[MaxVertexNum][MaxVertexNum]; // 0/1 或权值 INFINITY 表示不邻接
// 邻接表:边表用链串起来
typedef struct ArcNode { // 边表结点
int adjvex; // 邻接点下标
struct ArcNode *next;
} ArcNode;
typedef struct VNode { // 顶点表结点
int data;
ArcNode *first;
} VNode, AdjList[MaxVertexNum];| 存储 | 空间 | 找邻接点 | 适合 | 备注 |
|---|---|---|---|---|
| 邻接矩阵 | O(n²) | O(n) 扫一行 | 稠密图 | 无向图矩阵对称;便于判断两点的边是否存在 |
| 邻接表 | O(n+e)(无向 2e 个边结点) | 沿链找 | 稀疏图 | 有向图邻接表求出度易、求入度难(逆邻接表反之) |
| 十字链表 | O(n+e) | —— | 有向图 | 入边出边都串起来,出度入度都好求 |
| 邻接多重表 | O(n+e) | —— | 无向图 | 每条边只存一次,方便对边做标记 |
三、BFS 与 DFS(序列 + 代码 + 复杂度)
bool visited[MaxVertexNum];
// 广度优先:类似树的层序,靠「队列」
void BFS(AdjList G, int v) {
queue<int> Q;
visited[v] = true;
cout << v << " ";
Q.push(v);
while (!Q.empty()) {
int u = Q.front(); Q.pop();
for (int w = FirstNeighbor(G, u); w >= 0; w = NextNeighbor(G, u, w))
if (!visited[w]) {
visited[w] = true;
cout << w << " ";
Q.push(w);
}
}
}
// 深度优先:类似树的先序,靠「递归」(本质是栈)
void DFS(AdjList G, int v) {
visited[v] = true;
cout << v << " ";
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w))
if (!visited[w]) DFS(G, w);
}| 遍历 | 辅助结构 | 邻接矩阵复杂度 | 邻接表复杂度 | 序列唯一性 |
|---|---|---|---|---|
| BFS | 队列 | O(n²) | O(n+e) | 邻接矩阵唯一;邻接表看建表顺序 |
| DFS | 栈 / 递归 | O(n²) | O(n+e) | 同上 |
① 非连通图遍历一次只能访问一个连通分量,调用 BFS/DFS 的次数 = 连通分量个数(有向图则不一定);② BFS 求得的单源最短路径在无权图上是正确的(逐层扩散,先到的一定最短)。
四、最小生成树 MST:Prim 与 Kruskal
带权连通图边权和最小的生成树。当各边权互不相同时 MST 唯一;否则可能不唯一,但总权值唯一。
// Prim:「一棵树长大」——每次挑【树内 → 树外】的最短边,加入新点
// 适合稠密图,O(n²),与边数无关
void Prim(int G[][N], int n) {
int lowCost[N]; // 树外各点到树的最短距离
bool inTree[N] = {false};
for (int i = 1; i < n; i++) lowCost[i] = G[0][i];
inTree[0] = true;
for (int k = 1; k < n; k++) {
int minV = -1;
for (int j = 0; j < n; j++) // 扩展点循环 n-1 轮
if (!inTree[j] && (minV == -1 || lowCost[j] < lowCost[minV]))
minV = j;
inTree[minV] = true;
for (int j = 0; j < n; j++) // 用新点更新距离
if (!inTree[j] && G[minV][j] < lowCost[j])
lowCost[j] = G[minV][j];
}
}
// Kruskal:「森林变森林」——边按权从小到大试,不成环就要
// 适合稀疏图 O(e log e),判环用并查集
sort(edges, edges + e); // 按 权值 从小到大排序
int cnt = 0; // 已选边数,选满 n-1 条结束
for (int i = 0; i < e && cnt < n - 1; i++) {
if (find(u) != find(v)) { // 两端不在同一集合 → 不成环
merge(u, v); // (并查集见第 5 章)
cnt++;
}
}Prim:画两列「树内集合 | 候选最短边」,每轮把新点圈进去,更新树外点到树的距离。口诀「加点不回退」。
Kruskal:把所有边按权排成一列,从头到尾打勾(不成环)打叉(成环),选够 n−1 条为止。口诀「按边排队,成环跳过」。
五、最短路径:Dijkstra 与 Floyd
Dijkstra:单源最短路,贪心 + 手算表格
思想:每轮从「未确定」顶点中挑 dist 最小的 U 确定下来,再用 U 松弛它的邻边。不能处理负权边(选择题考点)。时间复杂度 O(n²)。
画一张「轮次 × 顶点」的 dist 表格,每轮:① 从未确定行里选最小 → 划掉(确定);② 用它更新它的邻点。到第 n−1 轮全部确定。真题就考某轮结束后 dist 数组长什么样,逐轮填表即可,不要跳步。
Floyd:所有点对最短路,动态规划
for (int k = 0; k < n; k++) // 中间点 k 必须在最外层!
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (D[i][k] + D[k][j] < D[i][j])
D[i][j] = D[i][k] + D[k][j];
// 时间 O(n³),空间 O(n²);允许负权边,但不允许负权回路六、有向无环图:拓扑排序与关键路径
AOV 网(顶点=活动)做拓扑排序:AOE 网(边=活动、点=事件)算关键路径。
- 拓扑排序:选一个入度为 0 的顶点输出并删除(含其所有出边),重复直到全部输出。若中途没有入度 0 的点 → 图中有环,无拓扑序列。实现:入度表 + 队列,O(n+e)。
- 逆拓扑排序:反过来删「出度为 0」的点(或对拓扑排序结果取反),DFS 按出栈序也得到逆拓扑序。
- 关键路径(AOE):源点 → 汇点最长路径,其长度 = 工程最短完工时间。关键活动满足:最迟开始时间 = 最早开始时间(l == e,时间余量为 0)。
| 步骤 | 求什么 | 怎么算 |
|---|---|---|
| ① 顺推 | 事件最早发生 ve | ve(j) = max( ve(i) + 权 ),按拓扑序从源点 0 开始 |
| ② 逆推 | 事件最迟 vl | vl(i) = min( vl(j) − 权 ),按逆拓扑序从汇点 = ve 开始 |
| ③ 换算活动 | 活动最早 e、最迟 l | e = 弧尾 ve;l = 弧头 vl − 权 |
| ④ 挑活动 | 关键活动 | e == l 的活动,串起来即关键路径 |
结论记忆:缩短关键活动不一定缩短工期(可能关键路径转移);关键路径可能有多条,所有关键路径共同的活动加速才有效。
6-1:n 个顶点的强连通图至少有多少条弧?
6-2:对有向图顶点 {1,2,3,4},弧 1→2、1→3、2→4、3→4,写出所有拓扑序列。
👀 查看参考答案
6-1:n 条(所有顶点连成一个有向环,每个点一进一出)。
6-2:1 必须最先、4 必须最后,2 和 3 任意 → 1 2 3 4 和 1 3 2 4 两种。
查找:折半、B 树、散列三巨头
一、顺序查找
从前往后扫,有序无序都行。成功 ASL = (n+1)/2,不成功 ASL = n+1(带哨兵时从后往前找)。优化:有序表不成功查找可与折半结合提前终止。
二、折半查找(二分)
仅适用于有序的顺序表。mid = ⌊(low+high)/2⌋,比中点、砍一半。
int BinarySearch(int a[], int n, int key) { // 长度 n,下标 0..n-1
int low = 0, high = n - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (a[mid] == key) return mid;
else if (a[mid] > key) high = mid - 1;
else low = mid + 1;
}
return -1;
}① 判定树是平衡二叉树,同层左右必满一半;② n 个元素的成功 ASL = (Σ 各深度结点数 × 深度)/n,失败结点 n+1 个;③ 最多比较次数 = ⌈log₂(n+1)⌉ = ⌊log₂n⌋ + 1;④ 手算技巧:mid 序列由区间的 ⌊(low+high)/2⌋ 决定,逐次写区间即可。
三、分块查找(索引顺序查找)
块间有序、块内无序:先在索引表(各块最大值)折半/顺序定块,再块内顺序找。设 n 元素分 b 块、每块 s 个:顺序查索引时 ASL = (b+1)/2 + (s+1)/2,最优 s = √n,此时 ASL = √n + 1。
四、B 树与 B+ 树(m 阶)
- 根结点至少 2 棵子树、1 个关键字;其他非叶结点至少 ⌈m/2⌉ 棵子树(即 ⌈m/2⌉−1 个关键字);至多 m 棵子树。
- 所有叶子结点(失败结点)出现在同一层,且不带信息。
- 含 n 个关键字、m 阶 B 树的叶子层数(高度下限思路):n ≤ (m−1)(1+m+m²+…+m^(l−1)) = m^l − 1。
- 插入溢出:结点关键字超过 m−1 → 从中间 ⌈m/2⌉ 处分裂,中间关键字升到父结点;删除不足则借 / 合并。
- 含 n 个关键字的 B 树叶结点(失败结点)数 = n + 1。
B+ 树:n 棵子树的结点含 n 个关键字(B 树是 n+1 棵子树 n 个关键字);数据全部在叶子层且叶子用链表串成有序链表 → 便于范围查找和顺序遍历(这正是数据库索引用 B+ 树的原因);非叶结点只是索引。m 阶 B+ 树结点最少子树数同 B 树(⌈m/2⌉),根为 2。
五、散列表(哈希表)
散列函数 H(key) 直接把关键字映射到地址;两个不同 key 映射到同一地址叫冲突(同义词),非同义词挤到同一地址叫堆积。
- 常用哈希:除留余数法 H(key) = key % p(p 取不超过表长的最大质数)。
- 冲突处理——开放定址法:线性探测 Hi = (H + dᵢ) % m,dᵢ = 1,2,…(易堆积);二次探测 dᵢ = 1²,−1²,2²,−2²…;伪随机序列。
- 冲突处理——拉链法(链接法):同义词挂成单链表,无堆积,删除方便。
- 装填因子 α = 表中元素数 / 表长——ASL 直接依赖 α,与表长本身无关(选择题原话)。
步骤:① 逐个元素按哈希函数 + 冲突策略插入,标出每个元素的比较次数;② 成功 ASL = 所有元素比较次数之和 ÷ 元素个数;③ 失败 ASL = 从每个可能哈希地址找到空位的比较次数之和 ÷ 哈希函数模数 p(分母是 p 不是表长!开放定址失败查找必须走到「空」才停)。
例:H(key)=key%7,表长 7,线性探测,依次插入 15, 22, 29(三者都映射到地址 1,连环冲突):
| 地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 值 | — | 15 | 22 | 29 | — | — | — |
| 比较次数 | 1 | 2 | 3 |
15 先来 → H=1 空着,直接放入,比较 1 次;22 → H=1 被占,探到 2 放入,比较 2 次;29 → 1、2 都被占,落到 3,比较 3 次 → 成功 ASL = (1+2+3)/3 = 2。失败 ASL:地址 0 探 1 次遇空,地址 1 探到 4 空 = 4 次,地址 2 → 3 次,地址 3 → 2 次,4、5、6 各 1 次 → (1+4+3+2+1+1+1)/7 = 13/7(分母 7 = p)。
① 散列查找与有序无关,平均 O(1) 但最坏 O(n);② 拉链法成功/失败 ASL 分母分别是元素个数与地址数 p;③ 删除在开放定址法下不能物理删除,只能打「已删」标记,否则断链。
7-1:长度 15 的有序表折半查找,查找成功最多比较几次?长度 100 呢?
7-2:3 阶 B 树中每个结点最少 / 最多几个关键字?含 9 个关键字的 3 阶 B 树最少几层(含叶失败层怎么算)?
👀 查看参考答案
7-1:⌈log₂16⌉ = 4 次;⌈log₂101⌉ = 7 次。
7-2:3 阶 B 树非根结点子树数 2~3 → 关键字 1~2 个。n=9 时失败结点 10 个,3 层可容纳最多 m³−1 = 26 个关键字,故 3 层(含失败层为 4 层结点行)足够——考试按「叶失败结点数 = n+1」逐层算下限即可。
排序:一张大表 + 两道手算套路
总表:先背下这张(选择题 70% 出自它)
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定 | 备注 |
|---|---|---|---|---|---|---|
| 直接插入 | O(n) | O(n²) | 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(1) | ✘ | 比较次数固定 n(n−1)/2 |
| 堆排序 | O(n log₂n) | O(n log₂n) | O(n log₂n) | O(1) | ✘ | 适合大文件 TopK |
| 归并排序 | O(n log₂n) | O(n log₂n) | O(n log₂n) | O(n) | ✔ | 「2 路归并」趟数 = ⌈log₂n⌉ |
| 希尔排序 | — | — | — | O(1) | ✘ | 仅适用于顺序存储;复杂度与增量序列有关 |
| 基数排序 | O(d(n+r)) | 同左 | 同左 | O(r) | ✔ | d 位、基数 r;不比较关键字 |
① 稳定的算法:插冒归基(直接插入、冒泡、归并、基数);快选堆希不稳定——「快些选一堆(不稳定)」。
② 比较次数与初始序列无关:简单选择、折半插入(移动次数有关);趟数与初始序列有关:冒泡、快排。
③ 排序趟数与「序列是否有序」强相关的还有:直接插入(趟数固定但比较次数变化)。归并、堆的趟数固定。
一、插入排序:直接插入与折半插入、希尔
直接插入:把第 i 个元素插到前面有序段中合适位置。折半插入:找位置用二分(比较次数降为 O(n log n),移动次数不变)。希尔:按增量分组做插入排序,增量逐趟缩小到 1。
二、冒泡与快速排序
int Partition(int a[], int low, int high) {
int pivot = a[low]; // 第一个元素作枢轴
while (low < high) {
while (low < high && a[high] >= pivot) high--; // 右边找小
a[low] = a[high];
while (low < high && a[low] <= pivot) low++; // 左边找大
a[high] = a[low];
}
a[low] = pivot; // 枢轴归位
return low; // 返回枢轴最终位置
}
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);
}
}「第 1 趟」= 枢轴一次归位后的数组状态;手算时盯住枢轴(通常首元素),挖坑填坑直到 low==high。考题问「第 k 趟后序列」或「枢轴位置」:每趟确定一个元素的最终位置,共 n−1 趟(每趟一个)——注意「一趟」在考研语境 = 对当前处理的整个子区间做一次 Partition?不是:教材的「一趟」指对每个未处理子表各做一次划分(严版约定),答题按题目给的过程示例对齐即可。
三、选择排序:简单选择与堆排序
简单选择:每趟从无序区选最小放前面。堆排序重点在「手算建堆与调整」:
建堆(自底向上):从最后一个分支结点 ⌊n/2⌋ 起倒序逐个「下沉」调整;输出:堆顶与堆尾交换 → 堆规模减 1 → 新堆顶下沉。「下沉一次 = 一层」:把孩子中较大者与父比较,父小则交换继续往下。大根堆→升序,小根堆→降序。
void SiftDown(int a[], int k, int n) { // a[k] 下沉,堆规模 n
a[0] = a[k]; // 暂存
for (int i = 2 * k; i <= n; i *= 2) { // 沿较大的孩子下沉
if (i < n && a[i] < a[i + 1]) i++;
if (a[0] >= a[i]) break;
a[k] = a[i]; // 孩子上移
k = i;
}
a[k] = a[0]; // 放到最终位置
}
void HeapSort(int a[], int n) {
for (int i = n / 2; i >= 1; i--) // ① 建堆:从最后分支结点倒序
SiftDown(a, i, n);
for (int i = n; i > 1; i--) {
swap(a[1], a[i]); // ② 堆顶换到末尾
SiftDown(a, 1, i - 1); // ③ 规模减一后重新下沉
}
}四、归并、基数与外部排序一页纸
- 归并:2 路归并第 k 趟后得到长度 2ᵏ 的有序段;n 个元素共 ⌈log₂n⌉ 趟;空间 O(n)(它的硬伤)。
- 基数:按「最低位优先 LSD」逐位「分配 + 收集」d 遍;每遍 O(n+r);队列实现,天然稳定。
- 外部排序:败者树(多路归并选最小)、置换-选择(生成更长初始归并段)、最佳归并树(哈夫曼思想,k 叉)。
8-1:序列 {49, 38, 65, 97, 76, 13, 27} 以 49 为枢轴,写出一趟快排后的结果。
8-2:把 {45, 78, 12, 90, 33} 调整为大根堆(1 起始数组),写出建堆后的数组。
👀 查看参考答案
8-1:{27, 38, 13, 49, 76, 97, 65}——49 归位于下标 4(1 起始),左段全小于它,右段全大于它。
8-2:数组 {45, 78, 12, 90, 33}(a[1]~a[5])。从 i=⌊5/2⌋=2 倒序调整:① i=2 结点 78,孩子中较大者 90 > 78 → 交换,得 {45, 90, 12, 78, 33};② i=1 结点 45,较大孩子 90 > 45 → 交换后 45 落到 a[2],再与它的较大孩子 78 比较,78 > 45 → 再交换。最终 {90, 78, 12, 45, 33}。
代码大题专项:答题模板与高频母题
一、标准答题格式:三段式,缺一扣分
- ① 算法思想(文字描述):用 3~5 句话说清「扫描什么、维护什么、怎么处理」。阅卷先看这段定档。
- ② 算法实现:C/C++ 函数,签名要带引用 &(修改链表/表长时),关键行加注释。能写伪代码也行,但代码更稳。
- ③ 复杂度分析:一句「时间 O(f(n)),空间 O(g(n))」并说明理由(扫描了几遍、用了几个辅助变量)。
① 除非题目要求「时间最优」,O(n) 双指针解法与 O(n²) 朴素解法分差通常在 2~3 分,写不出最优解时把朴素解写完整同样值钱;② 边界(空表、单结点、全相同)写进思想描述里是加分项;③ 变量命名清晰(pre/cur/slow/fast),涂改太多会影响观感。
二、真题风格解答一:主元素(2013 真题改编)
题目:已知长度 n 的整数数组 A,其中若有元素出现次数严格超过 n/2,称为主元素;找出主元素,不存在则返回 −1。要求时间 O(n)、空间 O(1)。
int Majority(int A[], int n) {
// 第 1 趟:候选抵消
int candidate = A[0], count = 1;
for (int i = 1; i < n; i++) {
if (A[i] == candidate) count++;
else if (count > 0) count--;
else { candidate = A[i]; count = 1; } // 票数清零,换候选人
}
// 第 2 趟:验证(候选可能不存在,必须数一遍!)
count = 0;
for (int i = 0; i < n; i++)
if (A[i] == candidate) count++;
if (count > n / 2) return candidate;
return -1;
}- 思想:主元素与非主元素「两两抵消」,抵消殆尽后剩下的候选只可能是主元素;因候选未必真存在,再扫一遍验证。
- 复杂度:两趟扫描,时间 O(n);仅常数个变量,空间 O(1)。
三、真题风格解答二:链表重排(2019 真题改编)
题目:带头结点单链表 L=(a₁, a₂, …, aₙ),就地重排为 (a₁, aₙ, a₂, aₙ₋₁, …)。要求时间尽量高效、空间 O(1)。
思想:① 快慢指针找中点,断成两半;② 后半段原地逆置;③ 两段交替归并。三步全是链表基本操作——大题 = 基本操作的组合。
void Rearrange(LinkList L) {
// ① 快慢指针:slow 到中点,fast 扫尾
LNode *slow = L, *fast = L;
while (fast->next != NULL) {
slow = slow->next;
fast = fast->next;
if (fast->next != NULL) fast = fast->next;
}
// ② 断链:p1 = 前半, p2 = 后半
LNode *p2 = slow->next;
slow->next = NULL;
// ③ 后半段原地逆置(头插法)
LNode *p = p2, *q;
p2 = NULL;
while (p != NULL) {
q = p->next;
p->next = p2;
p2 = p;
p = q;
}
// ④ 交替归并:p1 取一个、p2 插一个
LNode *p1 = L->next, *t = L;
while (p1 != NULL && p2 != NULL) {
LNode *n1 = p1->next, *n2 = p2->next;
t->next = p1; t = p1; p1 = n1;
t->next = p2; t = p2; p2 = n2;
}
t->next = (p1 != NULL) ? p1 : p2; // 接上剩余结点
}- 复杂度:三个线性阶段各扫一遍,时间 O(n);只用指针变量,空间 O(1)。
四、高频母题清单(考前必扫)
| 出处 | 母题 | 关键手法 |
|---|---|---|
| 顺序表 | 删除重复元素(有序 / 无序) | 有序:双下标 k 记新长度 |
| 顺序表 | 两个有序表合并 / 原地循环左移 p 位 | 合并 O(m+n);左移 = 三次逆置(2021 真题风格) |
| 顺序表 | 找缺失的最小正整数(2018) | 原地哈希:值 v 放下标 v−1 |
| 顺序表 / 数组 | 三元组最小距离(2021) | 三指针,每次后移最小者 |
| 链表 | 倒数第 k 个结点(2009) | 双指针:fast 先走 k 步 |
| 链表 | 两链表公共结点 | 先对齐长度再同步走 |
| 链表 | 奇偶位拆分 / 逆置每 k 个 | 拆表 + 头插 / 组内逆置 |
| 栈 / 队列 | 两个栈模拟队列(2009 / 2022 风格) | S1 进 S2 出,S2 空才把 S1 全部倒入 |
| 树 | 求 WPL(2014) | 层序遍历,depth × 权值累加 |
| 树 | 判断是否 BST / 完全二叉树 / 平衡 | BST:中序递增;完全:层序遇空后不许再有结点 |
| 图 / 队列 | 用队列排 vase 火车调度等(2009) | 看清「栈 or 队列」的性质再模拟 |
母题表里每一行都亲手写一遍代码 + 默写思想两句话。408 大题的「新题」几乎都是这些母题换皮:换存储结构、加一个限制条件、换问法。写完对答案时,重点对「思想段」:看自己漏了哪个边界、哪句复杂度理由。
复习规划与得分策略
三轮复习法
| 轮次 | 时间参考 | 任务 | 目标 |
|---|---|---|---|
| 第一轮 · 懂 | 约 6 周(每天 1.5~2h) | 跟本册逐章过概念 + 图解,每章结束做配套练习并「合上书手算一遍」;配合王道课后选择题 | 能自己画出每章结构图、推出每个公式 |
| 第二轮 · 熟 | 约 4 周 | 按章刷真题与王道错题;每天手算 1 个 KMP / 堆 / Dijkstra / 散列 ASL 维持手感;开始写第 9 章母题代码 | 选择题正确率 ≥ 85%,大题三段式成型 |
| 第三轮 · 稳 | 考前 3~4 周 | 整卷限时模拟;只看错题本;背「第 8 章总表 + 各章手算模板」;模拟大题答题卡书写 | 稳定输出 38+ 分 |
选择题 vs 大题:两套打法
推荐资料
| 资料 | 用法 |
|---|---|
| 严蔚敏《数据结构(C 语言版)》 | 教材本体,定义以它为准;配套习题别错过 |
| 王道《数据结构考研复习指导》 | 主刷题资料:课后题 → 真题归类 → 错题本 |
| 天勤《数据结构高分笔记》 | 语言更口语,适合第一轮替换阅读 |
| 408 历年真题(2009 至今) | 第二轮核心;每套限时做,只统计数据结构部分得分 |
| 本册《C++ 零基础入门教程》 | 代码大题的语法参考:指针、引用、STL 三章 |
数据结构是 408 四门里「回报率」最高的一门:套路最固定、图解最直观、和操作系统/组成原理互相成全。别在偏难怪知识点上钻牛角尖——把每章的「🧮 考场手算」练到条件反射,你的 45 分就稳了。
🎉 数据结构篇完!
从线性表到 B+ 树,从 Dijkstra 到快排手算——你已经把 408 数据结构的考点地图完整走了一遍。
接下来:回到第 9 章把母题代码全部亲手写一遍,然后带着本册的手算模板去刷王道与真题。祝上岸!🎓