← 返回作品集
📗 第二册 · 衔接《C++ 零基础入门教程》

数据结构考研全攻略
408 全考点 · 图解 + 手算 + 代码

上一册你已经会用 C++ 写程序了,这一册进入考研正题:按照 408 考纲,把数据结构的每一个考点讲透——先看图理解结构,再学考场手算套路,最后落到真题风格的代码上。

📖 10 章 · 408 全考点 🖼️ 12 幅图解 🧮 考场手算模板 💻 真题风格代码 🎯 数据结构 · 45 分
LNode.h
typedef struct LNode {
    int data;              // 数据域
    struct LNode *next;    // 指针域:指向后继
} LNode, *LinkList;
00
PRELUDE · 考情导航

开篇:考情速览与使用指南

数据结构在 408 里值多少分?

科目分值说明
数据结构45 / 150 分单选题约 11~12 道(22~24 分)+ 综合应用题 2 道(约 21~23 分,含一道算法设计题)
计算机组成原理45 分——
操作系统35 分与数据结构联动多(进程队列 = 队列、文件 = B+ 树……)
计算机网络25 分——
📌 与教材的对应关系

本教程按 408 考纲组织,章节顺序与严蔚敏《数据结构(C 语言版)》和王道《数据结构考研复习指导》一致,可以完美搭配使用:本册负责「讲懂 + 图解 + 手算」,王道负责海量刷题。

怎么用这一册效果最好?

  1. 先读结构图:每一章的图解(链表图、树图、图论图)就是考场上你脑中应有的画面,务必能自己在纸上画出来。
  2. 再背手算模板:next 数组、Prim/Kruskal、Dijkstra 表格、散列 ASL……这些是选择题的直接得分点,每章的「🧮 考场手算」框就是为它们准备的。
  3. 然后敲代码:考研代码题要求能写出可运行的算法,本册代码全部用 C++(含引用传参 &,写法见上一册第 9 章),可直接上机验证。
  4. 最后做每章练习:题目按真题风格改编,先自己算,再点开答案对照。
💡 前置知识自查(来自上一册)

本册会用到:struct 结构体(第 13 章)、函数与引用传参(第 9 章)、指针(第 12 章)、queue 等 STL 容器(第 15 章)。哪一块生疏了,回上一册对应章节补 20 分钟即可。

章节考频主要出题方式
01 绪论与复杂度⭐⭐选择题:算时间复杂度
02 线性表⭐⭐⭐选择 + 算法设计大题高频区
03 栈、队列、数组⭐⭐⭐选择题极多:出栈序列、循环队列、矩阵压缩
04 串与 KMP⭐⭐选择题:手算 next / nextval
05 树与二叉树⭐⭐⭐选择题最多的一章 + 大题(遍历、线索、哈夫曼)
06 图⭐⭐⭐大题常客:手算 MST / 最短路 / 拓扑
07 查找⭐⭐⭐选择 + 大题:判定树、AVL、B 树、散列 ASL
08 排序⭐⭐⭐选择题极多:手算排序过程、稳定性、复杂度大表
01
CHAPTER 01 · 基础篇 ⭐⭐

绪论:数据结构三要素与复杂度分析

📌 本章目标:分清逻辑结构与存储结构,掌握大 O 时间复杂度的推导方法——这是每年选择题的「送分题」,必须稳拿。

程序 = 数据结构 + 算法

这是计算机科学家沃斯(Wirth)的著名公式。数据结构决定数据「怎么摆放」,算法决定「按什么步骤处理」。摆放方式不同,处理效率天差地别——这就是这门课要研究的一切。

考点一:数据结构三要素

  1. 逻辑结构:数据元素之间逻辑上的关系,与怎么存无关。四种:集合、线性结构(一对一)、树形结构(一对多)、图状结构(多对多)。
  2. 存储结构(物理结构):逻辑结构在内存中的映像。四种:顺序存储、链式存储、索引存储、散列(哈希)存储。
  3. 数据的运算:运算的定义针对逻辑结构,运算的实现针对存储结构。
逻辑结构元素关系典型例子
集合除「同属一个集合」外无其他关系并查集
线性结构一对一,有且仅有一个开始和终端结点线性表、栈、队列、串
树形结构一对多二叉树、B 树
图状结构多对多有向图、无向图、网
⚠️ 选择题陷阱

「循环队列」「顺序表」是线性结构;判断「存储结构」的标志是看它是否关心内存地址(顺序表关心、链表也关心——它们都是存储结构层面的名词,逻辑上都叫线性表)。考题爱用「有序表」设坑:有序表是逻辑结构(描述元素已排序),不是存储结构。

考点二:算法的五个特性与设计目标

五个重要特性:有穷性(有限步内结束——注意「死循环程序」是程序不是算法)、确定性、可行性、输入(0 个或多个)、输出(1 个或多个)。
设计目标:正确性、可读性、健壮性、高效率与低存储量。

考点三:时间复杂度(每年必考)

语句执行次数 T(n) 随问题规模 n 的增长规律,用大 O 记号表示,只保留最高阶且去掉系数。考场三步法:

  1. 找出执行次数最多的那条语句(基本操作)。
  2. 算出它一共执行了多少次 f(n)(累加求和 / 解不等式)。
  3. f(n) 取最高阶、去系数,写 O(f(n))。

常见复杂度从小到大(必背顺序):

O(1) O(log n) O(n) O(n log n) O(n²) 问题规模 n → 执行次数 →
图 1-1 各阶复杂度的增长速度:n² 是灾难级,log n 是优秀级

例题 1:乘法增长 → 对数阶

logn.cpp
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:嵌套累加 → 平方阶

n2.cpp
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)。

02
CHAPTER 02 · 线性结构 ⭐⭐⭐

线性表:顺序表与链表全对比

📌 本章目标:熟练写出顺序表插入/删除、单链表建表与插删代码,记住平均移动次数和判空条件——这是算法设计大题的「母题」。

线性表的定义

具有相同数据类型的 n 个数据元素的有限序列(n=0 时为空表)。逻辑上一对一,有前驱后继。注意:线性表是逻辑结构;顺序表和链表是它的两种存储结构。

一、顺序表:一排连续的储物格

SqList.h — 静态分配(动态分配用指针 + malloc,思想相同)
#define MaxSize 50
typedef struct {
    int data[MaxSize];   // 连续存储区
    int length;          // 当前元素个数
} SqList;
  • 位序 vs 下标:位序 i 从 1 开始,对应下标 i−1,这是选择题和代码题共同的易错点。
  • 特点:随机存取 O(1)(一算地址就到);插入删除要大量移动元素。

插入:第 i 个位置插入 e,后面的元素整体后移

插入前(n=5,在位序 3 处插入 6) 3 5 7 9 11 空 空 从最后一位起依次后移 插入后(n=6) 3 5 6 7 9 11 空 下标 012(位序3) 345
图 2-1 顺序表插入:第 i 个及以后的元素从后往前依次后移,腾出位置
Insert.cpp(考研标准写法,& 引用见上一册第 9 章)
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)。

二、单链表:用指针串起来的珍珠链

LNode.h(408 卷面最常见的定义,务必默写)
typedef struct LNode {
    int data;              // 数据域
    struct LNode *next;    // 指针域:指向后继结点
} LNode, *LinkList;        // LNode* 强调「结点」,LinkList 强调「整个链表」
📌 头指针 / 头结点 / 首元结点

头指针:指向链表第一个结点的指针,链表的「名字」,必须有;头结点:在首元结点前附加的一个结点(不存数据),可有可无,有了它「在表头插入」和「在表中间插入」逻辑就统一了,推荐考研统一带头结点。

插入与删除:只改指针,不搬元素

头结点next pnext ① s(新)next ② qnext … ① s->next = p->next; ② p->next = s;(两句顺序不能颠倒!)
图 2-2 单链表插入:先接新的(①),再改旧的(②);颠倒了会「丢失」后半条链
link.cpp
// 头插法建表:读入的顺序与链表顺序【相反】
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;
}

三、双链表与循环链表

dlink.cpp — 双链表:前后都能走
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 的结点。

👀 查看参考答案
practice2.cpp
// 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;
        }
    }
}
03
CHAPTER 03 · 线性结构 ⭐⭐⭐

栈、队列与数组:受限的线性表

📌 本章目标:掌握栈和队列的存储与代码,会算出栈序列个数与循环队列元素个数,会用栈手算表达式求值,会套特殊矩阵压缩公式。

一、栈(Stack):后进先出 LIFO

只允许在栈顶一端插入(进栈 push)和删除(出栈 pop),像摞盘子。栈是递归、函数调用、表达式求值的底层机制。

stack.cpp — 顺序栈(top 指向栈顶元素,初值 −1)
#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 约定(选择题坑点)

约定一: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)。顺序队列不断后移会造成假溢出(前面已出队的位置浪费)——解决:取模构成循环队列。

MaxSize = 8 的循环队列(物理上仍是一条数组,逻辑上首尾相接) front→ A B C rear→ 空 空 空 rear 继续前进越界后取模回绕:(rear + 1) % 8 当前元素个数 = (rear − front + MaxSize) % MaxSize = (3 − 0 + 8) % 8 = 3
图 3-1 循环队列:下标对 MaxSize 取模,数组变成逻辑上的「环」
queue.cpp — 循环队列(牺牲一格区分队空与队满)
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 == 0size == MaxSize不浪费空间
增设 tag 标志出队后 tag=0 且 front==rear入队后 tag=1 且 front==rear记录最后一次操作
💡 双端队列(选择题常客)

两端都可进可出 = 双端队列;输入受限:只能一端进、两端出;输出受限:两端进、只能一端出。考法:给出某种双端队列,判断哪个输出序列合法——同样用模拟法验证。

三、栈的应用 1:括号匹配

bracket.cpp
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:表达式求值(必考手算)

中缀 → 后缀(逆波兰式)手算规则

  1. 操作数:直接加入输出。
  2. 运算符:先把栈中优先级 ≥ 自己的运算符弹出输出,再把自己入栈(左括号在栈里当「盾牌」挡住弹出)。
  3. 左括号入栈;右括号:连续弹出输出直到遇到左括号(左括号丢弃)。
  4. 结束时把栈中剩余运算符依次弹出。
🧮 考场手算:A + B * (C − D) − E / F
读入动作栈内已输出
A输出—A
+入栈+A
B输出+A B
*优先级高于 +,入栈+ *A B
(入栈+ * (A B
C − DC 输出;− 入栈;D 输出+ * ( −A B C D
)弹到 ( 为止:− 输出+ *A B C D −
−弹 * 和 +(≥自己)再入栈−A B C D − * +
E / FE 输出;/ 入栈;F 输出− /A B C D − * + E F
结束全部弹空—A B C D − * + E F / −

后缀式求值

从左到右扫:数字入栈;遇运算符弹出两个数计算再压回——注意先弹出的是右操作数(减法除法别减反了)。

五、栈的应用 3:递归的本质

函数调用靠函数调用栈实现:每调用一次,栈里压入一帧(参数、局部变量、返回地址);返回时弹出一帧。递归 = 自己调用自己 → 栈深 = 递归深度。递归优点是思路清晰,缺点是开销大、可能栈溢出,可用「尾递归改循环」优化。

六、数组:特殊矩阵的压缩存储(真题高频)

对称矩阵只需存下三角(含对角线)共 n(n+1)/2 个元素,按行优先存入一维数组 a[0..],aij(i ≥ j)的下标公式:

🧮 考场手算:下标公式(1 起始行列,0 起始存储)
矩阵类型公式(下标从 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。

04
CHAPTER 04 · 线性结构 ⭐⭐

串与 KMP:主串指针从不回头

📌 本章目标:理解朴素匹配为什么慢,熟练手算 next 与 nextval 数组——这是本章选择题的唯一高频题型,模板固定,稳拿分。

基本概念

串(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:

j12345
T[j]ababa
前面的串—aababaabab
最长相等前后缀—无(0)无(0)a(1)ab(2)
next[j]01123

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
2T[2]=b ≠ T[next[2]=1]=anextval = 1
3T[3]=a = T[1]=anextval = nextval[1] = 0
4T[4]=b = T[2]=bnextval = nextval[2] = 1
5T[5]=a = T[3]=anextval = nextval[3] = 0

代码实现(下标从 1 开始,T[0]、S[0] 空置)

kmp.cpp
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 次。

05
CHAPTER 05 · 非线性结构 ⭐⭐⭐

树与二叉树:选择题最大的「粮仓」

📌 本章目标:背熟二叉树五大性质,会写四种遍历,掌握线索二叉树、树森林转换、哈夫曼树与并查集——本章选择题密度全书第一。

一、树的性质(先背结论再做题)

  • 结点数 = 总度数 + 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⌋ + 1n 个结点
完全二叉树编号结点 i 的孩子是 2i、2i+1;双亲是 ⌊i/2⌋1 起始编号;i ≤ ⌊n/2⌋ 时为分支结点
📌 满二叉树 vs 完全二叉树

满二叉树:每层都满。完全二叉树:只允许最后一层缺右边若干连续结点。完全二叉树可以用数组顺序存储而不浪费空间——这正是「堆」的地基(第 8 章)。

三、存储结构

BiTree.h — 二叉链表(408 默认写法)
typedef struct BiTNode {
    int data;
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;

n 个结点的二叉链表共有 2n 个指针域,用了 n−1 个(n−1 条边),空指针 = n+1 个——这 n+1 个空位就是「线索化」的原材料。

四、遍历:所有大题的出发点

A B C D E F G 先序(根左右):A B D E C F G | 中序(左根右):D B E A F C G | 后序(左右根):D E B F G C A
图 5-1 同一棵树的三种遍历:区别只在「根」被访问的时机
order.cpp — 三种递归遍历只差一行顺序
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 区分「孩子指针」和「线索」:

ThreadTree.h
typedef struct ThreadNode {
    int data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;    // 0 = 指向孩子;1 = 指向前驱/后继的线索
} ThreadNode, *ThreadTree;
A B C B 的右线索 → 后继 A C 的左线索 → 前驱 A 中序序列:B A C —— 空指针按中序接到前驱/后继上
图 5-2 中序线索二叉树:虚线为线索,Tag=1 表示这是线索而不是孩子
💡 线索树选择题要点

中序线索树上找某结点的后继:若 rtag=1 直接走右线索;若有右孩子,后继 = 右子树中「最左下的结点」。n 个结点的线索二叉树含 n+1 个线索(空指针数)。

六、树、森林与二叉树的互相转换

规则一句话:左孩子、右兄弟——树中结点的第一个孩子变成它在二叉树中的左孩子,下一个兄弟变成右孩子。转换后树的根没有右子树;森林同理,各棵树的根互为右兄弟。

树 / 森林的遍历对应二叉树的遍历备注
树的先根遍历对应二叉树的先序遍历选择题原题级考点
树的后根遍历对应二叉树的中序遍历注意:树没有「中根」的说法
森林的先序 / 中序遍历对应二叉树的先序 / 中序遍历森林的「中序」= 依次后根遍历每棵树

树的三种存储:①双亲表示法(数组存 data + parent 下标,找双亲 O(1)、找孩子难);②孩子表示法(顺序 + 单链表);③孩子兄弟表示法(就是转二叉树用的二叉链表)。

七、哈夫曼树与哈夫曼编码

带权路径长度 WPL = Σ(叶权值 × 到根的路径长度)。WPL 最小的二叉树叫哈夫曼树(最优二叉树)。构造:每次取出权值最小的两棵树合并,新根权值为其和,重复 n−1 次。

15 8 7 4 3 1 2 权值 {1, 2, 4, 8} 合并次序:1+2=3 → 3+4=7 → 7+8=15 左 0 右 1: 8 → 0 4 → 10 2 → 111 1 → 110
图 5-3 哈夫曼树:权值越小的叶子离根越远;WPL = 1×3 + 2×3 + 4×2 + 8×1 = 25
🧮 哈夫曼树必背结论

① n 个叶子(初始权值)的哈夫曼树共 2n − 1 个结点,且不存在度为 1 的结点;② 哈夫曼编码是前缀码(任何编码都不是另一个的前缀),解码不会歧义;③ 同一组权值可能构造出形态不同但 WPL 相同的哈夫曼树(左右子树交换不影响)。

八、并查集(2020 年起已纳入考纲,要求不高)

用「双亲表示法」的森林维护若干不相交集合,支持两个操作:Find(找所属集合代表)、Union(合并两集合)。典型应用:判断图的连通性、Kruskal 判环。

ufs.cpp — 最简实现
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。

06
CHAPTER 06 · 非线性结构 ⭐⭐⭐

图:从存储到四大算法

📌 本章目标:掌握两种存储结构,会手推 BFS/DFS 序列,能在考场上用表格法算 Prim、Kruskal、Dijkstra、拓扑排序与关键路径——图的大题套路极其固定。

一、基本概念与术语

  • 无向图:边 (v,w) 无方向;有向图:弧 <v,w> 从 v 指向 w。
  • 完全图:任意两顶点间都有边。无向完全图 n(n−1)/2 条边;有向完全图 n(n−1) 条弧。
  • 连通(无向:任意两点有路径)/强连通(有向:双向都有路径)。生成树:连通图含全部 n 个顶点的极小连通子图,恰有 n−1 条边。
  • 无向图所有顶点度数之和 = 2×边数;有向图入度之和 = 出度之和 = 弧数。

二、存储结构

graph.h — 邻接矩阵(适合稠密图)与邻接表(适合稀疏图)
#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];
0 1 2 3 无向图 G 邻接表: 0 1 2 ∧ 0 的边表:邻接 1、2 1 0 3 ∧ 无向图中每条边存两次(对称)
图 6-1 图与它的邻接表:顶点表 + 每个顶点一条边链
存储空间找邻接点适合备注
邻接矩阵O(n²)O(n) 扫一行稠密图无向图矩阵对称;便于判断两点的边是否存在
邻接表O(n+e)(无向 2e 个边结点)沿链找稀疏图有向图邻接表求出度易、求入度难(逆邻接表反之)
十字链表O(n+e)——有向图入边出边都串起来,出度入度都好求
邻接多重表O(n+e)——无向图每条边只存一次,方便对边做标记

三、BFS 与 DFS(序列 + 代码 + 复杂度)

bfs-dfs.cpp — 邻接表版本
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 唯一;否则可能不唯一,但总权值唯一。

mst.cpp — 两套思想:加点 vs 加边
// 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²)。

🧮 考场手算:以 0 为源点

画一张「轮次 × 顶点」的 dist 表格,每轮:① 从未确定行里选最小 → 划掉(确定);② 用它更新它的邻点。到第 n−1 轮全部确定。真题就考某轮结束后 dist 数组长什么样,逐轮填表即可,不要跳步。

Floyd:所有点对最短路,动态规划

floyd.cpp — 三重循环,背下来也就五行
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 网(边=活动、点=事件)算关键路径。

  1. 拓扑排序:选一个入度为 0 的顶点输出并删除(含其所有出边),重复直到全部输出。若中途没有入度 0 的点 → 图中有环,无拓扑序列。实现:入度表 + 队列,O(n+e)。
  2. 逆拓扑排序:反过来删「出度为 0」的点(或对拓扑排序结果取反),DFS 按出栈序也得到逆拓扑序。
  3. 关键路径(AOE):源点 → 汇点最长路径,其长度 = 工程最短完工时间。关键活动满足:最迟开始时间 = 最早开始时间(l == e,时间余量为 0)。
🧮 关键路径四步手算法
步骤求什么怎么算
① 顺推事件最早发生 veve(j) = max( ve(i) + 权 ),按拓扑序从源点 0 开始
② 逆推事件最迟 vlvl(i) = min( vl(j) − 权 ),按逆拓扑序从汇点 = ve 开始
③ 换算活动活动最早 e、最迟 le = 弧尾 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 两种。

07
CHAPTER 07 · 查找与排序 ⭐⭐⭐

查找:折半、B 树、散列三巨头

📌 本章目标:会画折半查找判定树,掌握 B 树的核心性质与分裂过程,会算散列表两种冲突处理下的 ASL——本章手算题型非常固定,多练即满分。

一、顺序查找

从前往后扫,有序无序都行。成功 ASL = (n+1)/2,不成功 ASL = n+1(带哨兵时从后往前找)。优化:有序表不成功查找可与折半结合提前终止。

二、折半查找(二分)

仅适用于有序的顺序表。mid = ⌊(low+high)/2⌋,比中点、砍一半。

binsearch.cpp
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;
}
7 4 10 2 6 9 12 有序表 (2,4,6,7,9,10,12) 的判定树:树上结点 = 比较次数
图 7-1 折半查找判定树:查找成功最多比较 ⌈log₂(n+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 阶)

[ 40 | 70 ]根(2 关键字 → 3 棵子树) [ 12 | 25 ]叶 [ 55 ]叶 [ 85 | 92 ]叶
图 7-2 一棵 3 阶 B 树片段:关键字把区间分开,左小右大,全部数据在内外结点都有
🧮 m 阶 B 树核心性质(选择题大仓库)
  • 根结点至少 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 树 vs B+ 树

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 = 所有元素比较次数之和 ÷ 元素个数;③ 失败 ASL = 从每个可能哈希地址找到空位的比较次数之和 ÷ 哈希函数模数 p(分母是 p 不是表长!开放定址失败查找必须走到「空」才停)。

例:H(key)=key%7,表长 7,线性探测,依次插入 15, 22, 29(三者都映射到地址 1,连环冲突):

地址0123456
值—152229———
比较次数123

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」逐层算下限即可。

08
CHAPTER 08 · 查找与排序 ⭐⭐⭐

排序:一张大表 + 两道手算套路

📌 本章目标:把「八大排序」的复杂度、稳定性、手算过程压缩进一张总表;重点吃透快速排序与堆排序——选择题与代码大题双料常客。

总表:先背下这张(选择题 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。

二、冒泡与快速排序

quick.cpp — 快排: partition 是必默写代码
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?不是:教材的「一趟」指对每个未处理子表各做一次划分(严版约定),答题按题目给的过程示例对齐即可。

三、选择排序:简单选择与堆排序

简单选择:每趟从无序区选最小放前面。堆排序重点在「手算建堆与调整」:

9 7 8 3 5 4 6 大根堆:每个结点 ≥ 其孩子(数组表示:i 的孩子是 2i 与 2i+1,1 起始)
图 8-1 大根堆:堆顶永远是最大值,逻辑是树、存储是数组
🧮 考场手算:建堆与输出

建堆(自底向上):从最后一个分支结点 ⌊n/2⌋ 起倒序逐个「下沉」调整;输出:堆顶与堆尾交换 → 堆规模减 1 → 新堆顶下沉。「下沉一次 = 一层」:把孩子中较大者与父比较,父小则交换继续往下。大根堆→升序,小根堆→降序。

heap.cpp — 下沉调整(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}。

09
CHAPTER 09 · 冲刺篇 🎯

代码大题专项:答题模板与高频母题

📌 本章目标:掌握算法设计题的标准答题格式,吃透 2 道真题风格完整解答,扫一遍 408 十余年常考母题——大题其实是「套路题」。

一、标准答题格式:三段式,缺一扣分

  1. ① 算法思想(文字描述):用 3~5 句话说清「扫描什么、维护什么、怎么处理」。阅卷先看这段定档。
  2. ② 算法实现:C/C++ 函数,签名要带引用 &(修改链表/表长时),关键行加注释。能写伪代码也行,但代码更稳。
  3. ③ 复杂度分析:一句「时间 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)。

majority.cpp — 摩尔投票法
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)。

思想:① 快慢指针找中点,断成两半;② 后半段原地逆置;③ 两段交替归并。三步全是链表基本操作——大题 = 基本操作的组合。

① 快慢指针找中点,断链 a₁ a₂ a₃ a₄ a₅ a₆ a₇ 前半 a₁~a₄ | 后半 a₅~a₇ ② 后半段逆置 → a₇→a₆→a₅  ③ 交替插入 a₁ a₇ a₂ a₆ a₃ a₅ a₄
图 9-1 重排三步走:断链 → 逆置后半段 → 交替归并
rearrange.cpp
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 大题的「新题」几乎都是这些母题换皮:换存储结构、加一个限制条件、换问法。写完对答案时,重点对「思想段」:看自己漏了哪个边界、哪句复杂度理由。

10
CHAPTER 10 · 冲刺篇 🎯

复习规划与得分策略

📌 本章目标:把 10 章内容装进一个可执行的复习节奏里,明确选择题和大题各自的得分打法。

三轮复习法

轮次时间参考任务目标
第一轮 · 懂约 6 周(每天 1.5~2h)跟本册逐章过概念 + 图解,每章结束做配套练习并「合上书手算一遍」;配合王道课后选择题能自己画出每章结构图、推出每个公式
第二轮 · 熟约 4 周按章刷真题与王道错题;每天手算 1 个 KMP / 堆 / Dijkstra / 散列 ASL 维持手感;开始写第 9 章母题代码选择题正确率 ≥ 85%,大题三段式成型
第三轮 · 稳考前 3~4 周整卷限时模拟;只看错题本;背「第 8 章总表 + 各章手算模板」;模拟大题答题卡书写稳定输出 38+ 分

选择题 vs 大题:两套打法

🎯选择题(约 22~24 分)拼的是「结论准确」:复杂度大表、判空条件、B 树性质、稳定性口诀。错题按章归类,反复看结论页而不是重做题。
✍️算法大题(约 15 分)拼的是「三段式完整」:思想 3 句 + 代码带注释 + 复杂度一句。先在草稿定思路再下笔,卷面分也是分。
🧮手算大题(约 8~10 分)拼的是「步骤不跳」:KMP、堆、Dijkstra、关键路径、散列 ASL 都按本册模板逐行画表,跳步就是丢分。

推荐资料

资料用法
严蔚敏《数据结构(C 语言版)》教材本体,定义以它为准;配套习题别错过
王道《数据结构考研复习指导》主刷题资料:课后题 → 真题归类 → 错题本
天勤《数据结构高分笔记》语言更口语,适合第一轮替换阅读
408 历年真题(2009 至今)第二轮核心;每套限时做,只统计数据结构部分得分
本册《C++ 零基础入门教程》代码大题的语法参考:指针、引用、STL 三章
💡 最后的建议

数据结构是 408 四门里「回报率」最高的一门:套路最固定、图解最直观、和操作系统/组成原理互相成全。别在偏难怪知识点上钻牛角尖——把每章的「🧮 考场手算」练到条件反射,你的 45 分就稳了。

🎉 数据结构篇完!

从线性表到 B+ 树,从 Dijkstra 到快排手算——你已经把 408 数据结构的考点地图完整走了一遍。

接下来:回到第 9 章把母题代码全部亲手写一遍,然后带着本册的手算模板去刷王道与真题。祝上岸!🎓

↺ 回到考情速览