数据结构与算法复习手册:从写过,到讲清楚
前段时间把自己练手的一整套 C/C++ 数据结构代码翻出来重读了一遍:顺序表、带头结点单链表、顺序栈、链栈、循环队列、链队列、双向循环链表,再往上到二叉树、BST、AVL、红黑树、B 树、B+ 树、图的遍历,以及后来单独练的 KMP、八大排序和哈希表,一共 35 份源码与笔记。
重读下来最强烈的感受是:写得出,不等于讲得清。代码能跑通,但一被追问第二层就断------为什么这里必须先保存指针?清空之后还能不能 Push?普通 BST 为什么最坏是 O(n)?B+ 树到底比 B 树多在哪?所以这次不是再写一遍代码,而是把每一处"能跑但经不起问"的地方挑出来,对照修正,整理成一份可以反复复习的手册。
适合谁看:准备 C++ 后端方向校招 / 实习,已经会写这些结构,但"名词题第一句说不准""一被追问第二层就没词"的同学。
食用方式 :每个知识点都按「定义讲得准 → 能手算一次 → 能写核心代码 → 能说清复杂度的前提 → 能比较替代方案」来组织。文中 🛠️ 标记的地方是原实现里容易写错、面试却最常追问的细节,建议先盖住答案自己说一遍再往下看。面试里真正决定分数的,不是"你知不知道",而是"你能不能在三个句子里把它说准"。
目录
- 一、开始之前:范围、约定与复习方法
- 二、复习目标:从「会背」到「能解释」
- 三、线性结构:数组、指针、栈与队列
- 四、树:遍历、查找与平衡
- 二叉树:先遍历,再谈搜索
- BST:把有序性变成查找路线
- AVL:让高度差始终受控
- 红黑树:用颜色约束树高
- [B 树:一个结点保存多个键](#B 树:一个结点保存多个键)
- [B+ 树:让范围查询沿叶子前进](#B+ 树:让范围查询沿叶子前进)
- 五、图与复杂度
- 六、原稿疑点清单
- 七、进阶数据结构
- 八、常用算法
- 九、面试整合
- 十、自测
- 附录:源码与笔记索引
一、开始之前:范围、约定与复习方法
1.1 从自己的代码出发
先看约定与范围,再开始复习。
基础部分以一个 C/C++ 数据结构练习工程(数据结构.sln)引用的两个项目为主线,保留了其中31 个 .cpp / .h 文件 的实现与中文注释;另外纳入 KMP、八大排序和哈希表目录下的 4 个文件,共35 份源码与笔记,再按简历里列出的技能补充面试常见追问。
基础项目 · 栈和堆列
顺序表、带头结点单链表、顺序栈、链栈、顺序队列、链队列、循环双端队列、双向循环链表。
关联项目 · 进阶数据结构1
二叉树遍历与构造、BST、AVL、红黑树、B 树、B+ 树笔记,以及图的遍历、最小生成树、拓扑排序。
🧭 复习路线:01 线性表→02 栈与队列→03 搜索树→04 多路树与图
✅ 阅读约定✅ 提示总结规则,🛠️ 提示指出原实现里的问题,⚠️ 提示容易踩的边界;"原稿"指练习代码的原始片段,"修正版示例"是整理后的参考实现,全部文件清单见文末附录。
先统一四个容易混淆的词
| 词 | 本文约定 | 与练习代码的对应 |
|---|---|---|
| 长度 size | 当前有效元素个数 | SeqList.cursize;dequeue.size |
| 容量 capacity | 已经分配、最多能装多少元素 | SeqList.capacity;SeqStack.stacksize;dequeue.cursize |
| 头结点 / 首元结点 | 头结点可作为不存数据的哨兵;首元结点才是第一个有效元素 | List.h 的 head 是哨兵;链栈 top 是首元结点 |
| 队列 / 双端队列 | 队列:一端进、另一端出;双端队列:两端都能进出 | queue 与 dequeue 的操作约束不同 |
怎么用这份文档
- **第一遍:**先读绿色规则,操作演示,再盖住答案手算。
- **第二遍:**读原稿与纠错,特别留意空结构、一个元素、满容量、删除根结点。
- **第三遍:**独立写出入栈、循环入队、中序遍历、BST 删除和 AVL 旋转,再做自测。
源码中大量练习受
#if 0 / #if 1控制,存在占位文件和未完成函数。B+ 树目前是笔记与结构声明,图的最小生成树和拓扑排序主要是注释。新增 KMP、排序和哈希文件属于解决方案之外的补充练习;跳表、可扩展哈希、Trie 与新增算法模板属于本次讲解延伸。
✅ 语言与编译练习工程使用 .cpp,还用到了 bool、函数重载和 std::stack / std::queue,因此本文示例按 C++17 编译,同时保留 C 风格结构体与指针思路。文末给出了可独立编译运行的示例程序;注意不要把不同章节的同名类型与函数直接粘进同一个源文件。
二、复习目标:从「会背」到「能解释」
2.1 简历延伸:从会背到能解释
按简历里列出的技能逐项建立复习目标。
以简历里列出的技能条目为范围,在原有复习内容上补齐以下能力:定义讲得准 → 能手算一次 → 能写核心代码 → 能说明复杂度的前提 → 能比较替代方案。
📌 这次怎么延伸
基础部分保留原项目的源码对照;新纳入单独练习的 KMP、八大排序和哈希表。跳表、可扩展哈希、Trie 以及新增算法模板属于本文的讲解补充,不代表原工程已经实现。
| 简历里的词 | 至少能完成的面试动作 | 跳转 |
|---|---|---|
| 链表 / 队列 / 栈 | 画出指针连接;处理空与单元素;说明 LRU、BFS、括号匹配的选择依据。 | 基础 · 综合场景 |
| 跳表 | 解释随机层数、update\[\]、期望 O(log n) 和最坏 O(n)。 | 跳表 |
| 可扩展哈希 | 区分全局深度与局部深度;手算目录翻倍和桶分裂。 | 可扩展哈希 |
| 布隆过滤器 | 解释误判方向、不能直接删除、参数估算及缓存同步。 | 布隆过滤器 |
| AVL / B / B+ / 红黑树 | 保持树的性质;画出旋转 / 分裂;比较内存有序容器与外存索引。 | AVL · B 树 · B+ 树 · 红黑树 |
| 字典树 | 区别单词终点与前缀路径;解释字符集、空间和前缀查询。 | Trie |
| KMP | 手算 pi;说明失配为什么回退 j 而不回退 i。 | 字符串匹配 |
| 动态规划 / 贪心 | 先定义状态或选择规则,再给正确性理由;能举反例。 | DP · 贪心 |
| 二分 / 双指针 / 滑动窗口 | 说明区间、单调性和窗口不变量;证明每次移动不会漏解。 | 二分 · 双指针与窗口 |
| 分治 / 基本排序 | 写归并或快排;解释稳定性、递归栈和最坏输入。 | 分治与排序 |
✅ 面试讲述模板
先说"适合解决什么问题",再说"维护什么性质、操作怎么做",随后给复杂度及前提,最后补一个边界或取舍。面对"熟悉 / 掌握"的追问,用实际写过和验证过的内容支撑,不必把学习示例说成项目落地。
新增参考文件:BF_ KMP算法/源2.cpp、八大排序算法/源.cpp 与排序.cpp、哈希表/源.cpp。排序重点也呼应了"面试"文件夹中 2026-09-21 复盘报告的 Q15;这里只采用其中的技术复习线索。
三、线性结构:数组、指针、栈与队列
3.1 线性表:数组与指针的两种组织
抓住有效范围、插删方向和指针连接顺序。
📎 对应源码:Seqlist.h · Seqlist.cpp · List.h · 链表改变的链表栈.cpp
顺序表:只处理有效区间 [0, cursize)
📄 原稿摘录 · Seqlist.h · L9--13
cpp
9 typedef struct SeqList {
10 ELEM_TYPE* arr;//有效数组
11 int cursize; //有效个数
12 int capacity;//容量
13 }SeqList;
📐 判据
- 0 ≤ cursize ≤ capacity
- 读取 / 删除位置:0 ≤ index < cursize
- 插入位置:0 ≤ pos ≤ cursize(空表也允许 pos = 0)
在[10, 20, 30]的下标 1 插入 99:先从右向左移动 30、20,再写入 99,结果是[10, 99, 20, 30]。从左向右移动会覆盖尚未搬走的值。删除下标 1 时反过来,从左向右覆盖,最后长度减一。
讲解片段 · C++
cpp
// 已确认容量足够且 pos 合法:插入
for (int i = n; i > pos; --i) arr[i] = arr[i - 1];
arr[pos] = value;
++n;
// 已确认 0 <= pos < n:删除
for (int i = pos; i + 1 < n; ++i) arr[i] = arr[i + 1];
--n;
连续重复值:用读写指针一次压紧
原稿的RemoveElemAll删除后继续i++,会跳过刚挪过来的元素。例如[1, 1, 2]删除全部 1,第一次删除后还剩一个 1 在下标 0。下面这版扫描一次,保留元素的相对顺序不变,时间 O(n)、辅助空间 O(1)。
修正版示例 · C++17(可直接编译运行)
cpp
// 前提:p 指向一个状态有效的顺序表。
bool RemoveElemAll(SeqList* p, int value) {
if (!p || !p->arr) return false;
int write = 0;
const int oldSize = p->cursize;
for (int read = 0; read < oldSize; ++read) {
if (p->arr[read] != value)
p->arr[write++] = p->arr[read];
}
p->cursize = write;
return write != oldSize;
}
带头结点单链表:先保留连接,再改变连接
text
head(哨兵,不计入长度) → 10 → 20 → 30 → NULL
头插 5:head → 5 → 10 → 20 → 30 → NULL
讲解片段 · C++
cpp
// 在已知结点 prev 后插入已分配结点 node
node->next = prev->next;
prev->next = node;
// 删除 prev 后继;先确保 prev != nullptr 且 prev->next != nullptr
ListNode* victim = prev->next;
prev->next = victim->next;
free(victim);
--plist->cursize;
🛠️ 原稿的 Delnext 需要调整顺序
原稿先让 ptr->next 指向下下个结点,再 free(ptr->next),释放的是新的后继,真正要删的结点反而丢失。应先保存 victim。ClearList 释放数据结点后还应令 head->next = nullptr。
链表改变的链表栈.cpp · L136--142链表改变的链表栈.cpp · L219--230
| 操作 | 顺序表 | 单链表 |
|---|---|---|
| 按下标访问 | O(1) | O(n) |
| 按值查找 | O(n) | O(n) |
| 已知位置插删 | 一般 O(n),需要移动元素 | 已知前驱结点时 O(1);只有下标则先 O(n) 定位 |
| 尾插 | 空间足够时 O(1),倍增扩容时摊还 O(1) | 没有尾指针时 O(n);维护尾指针可 O(1) |
| 存储特点 | 连续,局部性好;可能预留空位 | 不要求连续;每个结点需要指针空间 |
✍️ 练一练:为什么空表可以插入,却不能删除?
插入是在两个元素之间选"空隙",n 个元素有 n+1 个位置;空表有位置 0。删除必须选现有元素,空表没有合法下标。原稿的 InsertElem 在第 69 行直接拒绝空表,需要去掉这个限制。
3.2 栈:所有操作都围绕同一端
后进先出 LIFO;top 的含义必须前后一致。
📎 对应源码:stack.h · stack顺序栈.cpp · Linkstack.h · stack链表栈.cpp · 栈和队列.cpp · L1--13
顺序栈把数组尾部作为栈顶 ,top指向下一次入栈的空位。最上面的有效元素在top - 1。入栈写入再前移,出栈先后退再读取。
📐 判据
- 长度 = top - basd 空:top == basd
- 满:top - basd == stacksize 栈顶值:*(top - 1)
把 a、b、c 依次放进去
🧪 交互演示(原文档可点击操作):从左到右是栈底到栈顶。可以输入一个字符,也可以留空,自动使用下一个字母。
为什么扩容后必须重建 top?
原稿注释里已经注意到realloc可能换地址。正确顺序是:先保存长度 → 用临时指针分配 → 成功后更新 basd → 令 top = 新基址 + 旧长度。不能在更新基址后再用旧 top 求差。
修正版示例 · C++17(可直接编译运行)
cpp
// 前提:ps 已成功初始化,且尚未销毁。
Status Push(SeqStack* ps, SElemType value) {
if (!ps || !ps->basd) return ERROR;
const std::ptrdiff_t n = ps->top - ps->basd;
if (n == ps->stacksize) {
if (ps->stacksize > INT_MAX / 2) return ERROR;
const int capacity = ps->stacksize * 2;
auto p = static_cast<SElemType*>(
std::realloc(ps->basd, sizeof(SElemType) * capacity));
if (!p) return ERROR; // 扩容失败时,原内存仍然有效。
ps->basd = p;
ps->top = p + n; // 用扩容前保存的长度重建 top。
ps->stacksize = capacity;
}
*ps->top++ = value;
return OK;
}
Status Pop(SeqStack* ps, SElemType* out) {
if (!ps || !out || !ps->basd || ps->top == ps->basd) return ERROR;
*out = *--ps->top;
return OK; // 状态码与元素值分开返回。
}
void ClearStack(SeqStack* ps) {
if (ps) ps->top = ps->basd; // 清空保留内存与容量。
}
⚠️ 状态码不是元素值
原稿头文件定义 OK = 0、ERROR = -1。修正版 Pop 返回 OK,通过 out 交付元素;调用应写 if (Pop(&s, &value) == OK)。不能直接把返回值当 bool,否则成功的 0 会被判为假。原版返回 *pval 的约定与 Status 类型不一致。
链栈:top 就是第一个有效结点
text
依次 Push(1), Push(2), Push(3)
top → 3 → 2 → 1 → NULL
Pop 后:top → 2 → 1 → NULL,取出 3
讲解片段 · C++
cpp
// 入栈:pp 是新分配、已写好 data 的结点
pp->next = ps->top;
ps->top = pp;
++ps->cursize;
// 出栈:先检查 ps、pval 和非空
StackNode* old = ps->top;
*pval = old->data;
ps->top = old->next;
free(old);
--ps->cursize;
return OK;
🛠️ 原稿里"栈"的两端混用了
当前开启的 Push 是链表头插,但 GetTop / Pop 却遍历到尾端,表现为取最早插入的元素,且单元素时会解引用空指针。三者必须统一使用 top。注释中的"空间复杂度 O(n)/O(1)"这里主要是在讨论遍历的时间复杂度。
stack链表栈.cpp · L63--74stack链表栈.cpp · L90--151
出栈序列不是任意排列
若 a、b、c 全部入栈后再出栈,只能得到 c、b、a。若允许入栈与出栈交替,a、b、c 或 b、a、c 都可实现,而 c、a、b 不可实现:弹出 c 后,b 仍挡在 a 上面。
括号匹配就是栈的典型应用:左括号入栈;右括号必须匹配当前栈顶再弹出;最后栈必须为空。例如([{}])合法,([)]在遇到第一个右圆括号时失败。
🛠️ 把笔记中的这句话改正
"顺序表只有头插以及尾删就是栈"不成立,那是两端操作,呈现队列次序。栈要求同一端插入和删除;数组一般选尾端,单链表一般选头端。
栈和队列.cpp · L11--12
3.3 队列与双端队列:让空间循环利用
FIFO 管顺序;循环下标管空间;双端操作管接口。
📎 对应源码:queue.cpp · listqueue.cpp · Dequeu.h · dequeue双向循环顺序队列.cpp · 双向循环链表之队列doubly circular linked list queue.cpp
普通队列在队尾入队、队头出队,先进先出 FIFO。顺序队列若每次出队都搬动后面的元素,时间是 O(n);循环队列用下标取模,把已经空出的前部空间继续用起来。
循环队列:本文使用 size 计数法
📐 判据
- front:队头元素下标 rear:下次入队下标
- 空:size == 0 满:size == capacity
- 后移:(index + 1) % capacity
- 前移:(index + capacity - 1) % capacity
⚠️ 不要混用两套判满公式本文计数法允许全部 capacity 个格子存数据,空和满时都可能 front == rear,靠 size 区分。另一套"牺牲一个单元法"不存 size:空为 front == rear,满为 (rear + 1) % capacity == front,最多装 capacity - 1 个元素。
5 个格子,走过末尾再回到开头
🧪 交互演示(原文档可点击操作):点击"载入回绕示例":a~e 入队 → a、b 出队 → f、g 入队。物理数组显示 f、g、c、d、e,逻辑队列仍是 c、d、e、f、g。
修正版示例 · C++17(可直接编译运行)
cpp
// 固定容量循环队列:通过 size 区分空与满。
const int CAP = 5;
struct RingQueue {
char arr[CAP]{};
int front = 0, rear = 0, size = 0;
};
bool Enqueue(RingQueue* q, char value) {
if (!q || q->size == CAP) return false;
q->arr[q->rear] = value;
q->rear = (q->rear + 1) % CAP;
++q->size;
return true;
}
bool Dequeue(RingQueue* q, char* out) {
if (!q || !out || q->size == 0) return false;
*out = q->arr[q->front];
q->front = (q->front + 1) % CAP;
--q->size;
return true;
}
| 双端操作 | 步骤(计数法) |
|---|---|
| 队尾入队 | 先判满;arrrear = x;rear 后移;size++ |
| 队头出队 | 先判空;取 arrfront;front 后移;size-- |
| 队头入队 | 先判满;front 前移;arrfront = x;size++ |
| 队尾出队 | 先判空;rear 前移;取 arrrear;size-- |
动态扩容时,按逻辑顺序 复制:newArr[i] = oldArr[(front + i) % oldCapacity],其中0 ≤ i < size。完成后设front = 0、rear = size,再替换容量。原稿的指针版本中部分移动没有回绕,不能只在尾插时处理回绕。
链队列:始终维护 head 和 rear
listqueue.cpp每次从 head 遍历到末尾,入队是 O(n)。下面这版不设哨兵、增加 rear,使入队、出队都为 O(1)。删除最后一个结点后,两个指针都要变为空。
📂 展开:链队列的完整核心操作
修正版示例 · C++17(可直接编译运行)
cpp
// 不带哨兵结点;维护 rear,使入队为 O(1)。
struct ListNode { char data; ListNode* next; };
struct LinkQueue { ListNode* head = nullptr; ListNode* rear = nullptr; int size = 0; };
bool Enqueue(LinkQueue* q, char value) {
if (!q) return false;
auto p = static_cast<ListNode*>(std::malloc(sizeof(ListNode)));
if (!p) return false;
*p = {value, nullptr};
if (q->rear) q->rear->next = p;
else q->head = p;
q->rear = p;
++q->size;
return true;
}
bool Dequeue(LinkQueue* q, char* out) {
if (!q || !out || !q->head) return false;
ListNode* old = q->head;
*out = old->data;
q->head = old->next;
std::free(old);
--q->size;
if (!q->head) q->rear = nullptr;
return true;
}
🛠️ 单元素出队是必测场景
原稿 size == 1 分支把 head 移走就返回,没有 free 原结点,也没有 size--。之后 head 为 NULL 而 size 仍为 1,会破坏判空逻辑。另外,初始化分配的头结点在第一次入队时被覆盖,哨兵与无哨兵两套写法混用了。
listqueue.cpp · L3--13listqueue.cpp · L70--83
双向循环链表:不靠遍历找队尾
沿用不设哨兵的dcllq结构:空表head = NULL;单结点必须满足head->next = head->prev = head;非空表尾结点就是head->prev。
text
┌─────────────────────────┐
head → 10 ⇄ 20 ⇄ 30(head->prev)
└─────────────────────────┘
讲解片段 · C++
cpp
// q 非空,p 为新结点:尾插
listnode* tail = q->head->prev;
p->next = q->head;
p->prev = tail;
tail->next = p;
q->head->prev = p;
++q->size;
删除结点 x 时,用x->prev->next = x->next和x->next->prev = x->prev接好两侧,再释放 x。若只剩一个结点,应释放后设置 head=NULL、size=0 并立即返回。原稿的findend把尾结点 next 设为 NULL,会断开循环;正确做法是直接用 head->prev 找尾。
3.4 指针与内存:把状态维护完整
这几条规则贯穿链表、栈、队列和树。
一次修改,要检查三类状态
连接关系
next / prev、left / right、parent 是否互相对应?换根后,新根 parent 是否为 NULL?
辅助信息
长度是否只增减一次?容量是否保留?AVL 高度、红黑树颜色是否更新?
第三类是所有权与资源:原来分配的每块内存由谁释放?是否还有指针指向已释放的对象?结构体里的指针是地址,不是自动管理内存的容器。
| 容易误解的写法 | 真正含义 / 正确习惯 |
|---|---|
| p = NULL | 只改变 p 这个指针变量;不会释放对象,也不会自动清除其他别名。 |
| free§; p = p->next; | 释放后再访问成员是错误的。先保存 next,再 free§。 |
| Destroy(T* p) 内部 p = NULL | p 是调用者指针的一份拷贝;可修改 *p 的字段,但赋空 p 不会让调用者指针变空。需要 T**或 C++ 引用才能改调用者的指针变量。 |
| p->parent->left != p && p->parent != NULL | && 从左到右短路,应先判断 p->parent != NULL,再访问成员。 |
| assert(p != NULL) | 断言用于检查程序内部约定;Release 可能关闭断言,不能代替对正常空树、查找失败或内存分配失败的处理。 |
| malloc(sizeof(Status) * N) | 分配元素数组应使用 sizeof(元素类型) 或 sizeof *p。这套代码里 SElemType 是 char、Status 是 int,原写法类型不匹配。 |
清空和销毁,语义要一致
**顺序结构清空:**长度归零,保留空间和容量,可直接复用。**顺序结构销毁:**释放数组,指针置空、容量归零,需重新初始化后再使用。**链式结构清空:**释放所有数据结点;有哨兵则保留哨兵并重置其连接。
🛠️ ClearStack 之后再 Push 为什么有风险?
原稿只把 top 归位,却把 stacksize 置为 0;下一次 Push 会认为满了,扩容仍按 0 × 2 申请。清空应保留 stacksize。SeqList 的 ClearElem 实际释放了数组,更接近销毁,后续不能按"只清空"处理。
stack顺序栈.cpp · L29--35Seqlist.cpp · L384--406
每个函数至少想一遍这些输入
空结构;只有一个元素;正好满;连续重复值;不存在的值;删除头 / 尾 / 根;刚清空后再次插入;分配失败。对树,再加上"只有左孩子"和"只有右孩子"。这些场景比只打印一串正常数据更能检验实现。
四、树:遍历、查找与平衡
4.1 二叉树:先遍历,再谈搜索
"前、中、后"说的是根在左、右之间的访问位置。
📎 对应源码:Binary_Tree.h · BInary_Tree.cpp
概念与高度约定
二叉树的每个结点最多有两个孩子,且左右有别。普通二叉树不保证有序、不保证平衡。树中结点的度是孩子数量,不要与图的度混淆。
本文层数从 1 开始;深度按边数计算,根深度为 0;高度按边数计算,叶子高度为 0,空树高度为 -1(与本文 AVL 一节采用的约定一致)。若教材按层数计算树高,只需整体加 1,不能把两套公式混在一起。
| 类型 / 性质 | 正确表述 |
|---|---|
| 第 i 层最多结点数 | 2^(i-1),i 从 1 开始。 |
| 共 k 层时最多结点数 | 2^k - 1;若高度 h 按边数,则 2^(h+1) - 1。 |
| 满二叉树(本文指完美二叉树) | 每一层都满。 |
| 完全二叉树 | 除最底层外都满;最底层从左到右连续排列。叶子只能在最后两层。 |
| 严格 / 正则二叉树 | 每个结点的孩子数只能是 0 或 2,不要求各层都满。 |
| 非空二叉树的叶子数 | n₀ = n₂ + 1。由 n-1 = n₁ + 2n₂ 与 n = n₀+n₁+n₂ 得到。 |
🛠️ 先纠正笔记里的"性质二"
任意二叉树的左右子树结点数之差没有"不超过 1"的限制,链状二叉树就是反例。AVL 限制的是每个结点左右子树的高度差。"完全二叉树的节点只会出现在最后两层"应改为"叶子节点只会出现在最后两层"。
Binary_Tree.h · L17--29
用代码注释里的 a~f 这棵树
🧪 交互演示(原文档可点击操作):每点击一步,只访问一个结点
| 遍历 | 结果 | 实现要点 |
|---|---|---|
| 前序 | a b d e f c | 先访问根;用显式栈时先压右孩子,再压左孩子。 |
| 中序 | d b e f a c | 一直向左入栈,弹出访问,再转向其右子树。 |
| 后序 | d f e b c a | 左右子树先完成,根最后访问;可用递归、双栈、单栈加 lastVisited。 |
| 层序 | a b c d e f | 队列中取队头,依次加入左右孩子。 |
修正版示例 · C++17(可直接编译运行)
cpp
// 把访问结果追加到字符串,便于核对遍历顺序。
void PostOrder(BiT_Node* root, std::string& out) {
if (!root) return;
PostOrder(root->leftchild, out);
PostOrder(root->rightchild, out);
out += root->data;
}
🛠️ 递归调用的名称也决定顺序
原稿 PostOrder_Traverse 内部调用了 InOrder_Traverse,得到的是左右子树各自的中序,再访问根,通常不是后序。两个递归调用都应改为后序函数自身。
BInary_Tree.cpp · L38--44
📂 展开:更容易复写的非递归中序模板
讲解片段 · C++
cpp
#include <stack>
void InOrder(BiT_Node* root) {
std::stack<BiT_Node*> st;
BiT_Node* p = root;
while (p || !st.empty()) {
while (p) {
st.push(p);
p = p->leftchild;
}
p = st.top();
st.pop();
printf("%c ", p->data);
p = p->rightchild;
}
}
这与用 tag 标记的思路目的相同,但无需额外标记。原稿在弹出 tmp 后,访问右子树时用成了 st.top()->rightchild,应使用 tmp->rightchild,否则可能取错结点或访问空栈。
如何由序列还原树?
结点值互不相同且序列合法时,前序 + 中序 或后序 + 中序 可唯一还原。前序取第一个作为根,后序取最后一个作为根;在中序中定位根,分割左右子树,递归构造。单独普通前序不够,但带#空指针标记的前序足够。
text
前序:A B D E C 中序:D B E A C
根 A → 左子树中序 D B E / 右子树中序 C
左子树根 B → 左 D / 右 E
带空标记的同一棵树:ABD##E##C##
原稿的CreateBinTree_single_Pre用静态指针共享递归进度,但第二次调用时不会重新初始化。更清晰的接口是传入下标引用size_t& i,由外层每次从 0 开始,并检查输入是否截断、有没有多余字符。CreateBinTree_double_PreIn末尾还缺少return root;。
遍历时间 O(n);递归栈 / 单栈通常 O(h),最坏 O(n);双栈后序需要 O(n) 额外空间;层序队列最多容纳与树宽同阶的结点。
4.2 BST:把有序性变成查找路线
左子树的所有键更小,右子树的所有键更大。
📎 对应源码:BSTtree.h · BSTtree.cpp
沿用"拒绝重复键"的约定。BST 的性质约束整棵子树,不只是直接孩子。因此中序遍历得到严格递增序列。一个值比当前键小就往左走,比当前键大就往右走,走到空指针代表不存在。
text
讲解示例:依次插入 50, 30, 70, 20, 40, 60, 80
50
/ \
30 70
/ \ / \
20 40 60 80
查找 60:50 → 70 → 60
删除分三类,最终都化为 0 / 1 个孩子
| 情况 | 处理 | 示例 |
|---|---|---|
| 叶子 | 断开父结点指向它的链接,再释放 | 删除 20:令 30 的左孩子为空 |
| 一个孩子 | 用唯一孩子接替它的位置;同步 parent | 删除仅有右孩子 40 的 30:用 40 顶替 |
| 两个孩子 | 用中序前驱或后继的键覆盖,再删除替代结点 | 删除 50:用后继 60 覆盖根键,再删除原来的 60 叶子 |
"狸猫换太子"这个思路可以保留。后继是右子树中最左的结点,必然没有左孩子;前驱是左子树中最右的结点,必然没有右孩子。于是复杂删除变成 0 / 1 孩子删除。
讲解片段 · C++
cpp
// p 已经化为至多一个孩子的待删结点
BSTNode* child = p->leftchild ? p->leftchild : p->rightchild;
BSTNode* parent = p->parent;
if (!parent) tree->root = child;
else if (parent->leftchild == p) parent->leftchild = child;
else parent->rightchild = child;
if (child) child->parent = parent;
free(p);
前驱 / 后继,记住两条路线
**后继:**有右子树,右转后一路向左;没有右子树,就沿 parent 往上,直到第一次"从左孩子上来"。前驱完全对称。有序序列的最小值没有前驱,最大值没有后继。
🛠️ 原稿要补的三项连接工作
BuyNode 成功路径缺少 return p;插入新结点后要令 ppp->parent = pp;删除叶子时 child 为 NULL,不能直接 child->parent = father。换根后也要维护新根的 parent。
BSTtree.cpp · L2--14BSTtree.cpp · L44--53BSTtree.cpp · L81--102
**复杂度是 O(h),不能无条件写 O(log n)。**依次插入 1、2、3、4、5,会得到向右的链,查找、插入、删除最坏 O(n)。接下来的 AVL 和红黑树就是为控制高度而设计。
4.3 AVL:让高度差始终受控
每个结点都要求 |左高 − 右高| ≤ 1。
📎 对应源码:AVLTree.h · AVLTree.cpp
📐 判据
- height(NULL) = -1 height(叶子) = 0
- height§ = 1 + max(height(p->left), height(p->right))
- BF§ = height(p->left) - height(p->right)
递归实现的主线很清楚:先按 BST 插入 / 删除 → 回溯更新高度 → 旋转 → 返回新子树根 。调用方要接住返回值,例如root->left = Insert_Helper(root->left, x),否则旋转后的根会丢失。
| 类型 | 插入示例 | 修复方式 |
|---|---|---|
| LL | 30, 20, 10 | 对失衡点 30 右旋 |
| RR | 10, 20, 30 | 对失衡点 10 左旋 |
| LR | 30, 10, 20 | 先对左孩子 10 左旋,再对 30 右旋 |
| RL | 10, 30, 20 | 先对右孩子 30 右旋,再对 10 左旋 |
🧪 交互演示(原文档可点击操作):观察同一组键怎样恢复平衡
📄 原稿摘录 · AVLTree.cpp · L44--55
cpp
44 AVLNode* Left_Rotate(AVLNode* Node)
45 {
46 //因为递归的原因所以不用加上assert
47 AVLNode* child = Node->right;
48 AVLNode* grandchild = child->left; //不管是不是为空统一将其当作冲突节点处理,因为就算为空,旋转之后,这个被处理节点的左边依旧是为空,(如果不为空,则是冲突结点,所以将空节点也当作孩子冲突节点)
49 child->left = Node;
50 Node->right = grandchild;
51 Update_Height(Node);//注意这个参数的细节,得传递中间变量,不能在传递Node->left等值,因为要重新更新高度的节点它们的子节点已经变了。
52 Update_Height(child);
53 //平衡因子是计算得到的,只需要更新高度即可。
54 //不用更新冲突节点,因为其到最远叶子的边的个数并没有发生改变,只是它的上一个节点发生了改变罢了。
55 return child;
这段左旋代码可直接用来记忆:右孩子 child 上来;child 的左子树 grandchild 改挂到旧根右侧;旧根成为 child 的左孩子;先更新旧根高度,再更新新根高度。旋转保持中序次序不变。
🛠️ 四种类型,失衡点的 |BF| 都是 2
原注释把 LR / RL 说成"高度差为 1",混淆了失衡点与其较高孩子。LR:BF(root)=2 且 BF(left)<0;RL:BF(root)=-2 且 BF(right)>0。删除时较高孩子的 BF 可以是 0,原稿的 Rotate 用 >=0 / <=0 处理单旋,这个细节是对的。
AVLTree.h · L24--30AVLTree.cpp · L75--104
查找必须比较正在移动的 p
修正版示例 · C++17(可直接编译运行)
cpp
AVLNode* AVLTree_Find(AVLNode* root, int x) {
AVLNode* p = root;
while (p) {
if (x < p->key) p = p->left;
else if (x > p->key) p = p->right;
else return p;
}
return nullptr;
}
原稿虽然让 p 向下走,却始终比较root->key。例如根 20、左孩子 10,查找 10 时第二轮仍比较根的 20,会继续向左走而漏掉 10。删除函数也应以if (!root) return nullptr;处理没找到的路径,而不是断言非空。
AVL 查找、插入、删除最坏 O(log n)。插入通常在最低失衡处修复后恢复该子树原高度;删除可能让多个祖先连续失衡,所以应沿路径继续更新与调整。
4.4 红黑树:用颜色约束树高
保留口诀,再把触发条件说清楚。
📎 对应源码:RBtree.h · RBTree.cpp
"根叶黑、不红红、路黑同"这句口诀很适合复习。这里的"叶"指NIL 空指针叶子,不是存有数据、孩子数为 0 的结点。红黑树仍然是 BST,但不要求像 AVL 一样左右高度差不超过 1。
| 必须保持的规则 | 检查方法 |
|---|---|
| BST 有序性 | 中序递增;本文沿用不重复键约定。 |
| 每个结点红或黑,根和 NIL 黑 | 颜色必须明确;空孩子按黑色处理。 |
| 红结点的孩子都是黑色 | 不能出现红父 + 红子。 |
| 从任意结点到其各个 NIL 后代的黑结点数相等 | 比较所有路径的黑高,而不是所有路径的总长度。 |
✅ "最长不超过最短两倍"是结论
它由黑高相同和不能连续红色推出,不能单独替代红黑规则。最短路径不一定实际全黑,最长路径也不一定实际严格红黑交替;它们只是用于推导上界的极端形态。含 n 个数据结点的红黑树高度为 O(log n),标准高度上界为 2 log₂(n+1)。
插入:新结点先红,再看父与叔
| 情况 | 动作 |
|---|---|
| 插入的是根 | 根染黑,结束。 |
| 父黑 | 不需要修复。 |
| 父红、叔红 | 父和叔染黑,祖父染红;把祖父当作新检查点继续向上。 |
| 父红、叔黑(含 NULL) | 折线 LR / RL 先对父旋转变直线;再旋转祖父,让新的局部根黑、旧祖父红;最终保证全树根黑。 |
text
取自原稿 main 的前三次插入:100 → 26 → 3
插入 3 后(父红、叔为 NIL): 右旋 100 并变色:
100 黑 26 黑
/ / \
26 红 3 红 100 红
/
3 红
再插 49:父 100 红、叔 3 红 → 两者染黑,根 26 最终保持黑。
删除:先按 BST 简化,再补偿少掉的黑色
双孩子结点先换成删除前驱 / 后继。实际移除红叶子不影响黑高;黑结点只有一个非空孩子时,用红孩子接替并染黑。真正复杂的是移除黑叶子后留下的黑高缺口,常称"双黑"。双黑是分析状态,不是第三种真实颜色。
| 当前缺口在父的左侧时 | 处理(缺口在右侧时镜像) |
|---|---|
| 兄弟红 | 兄弟染黑、父染红,对父左旋;得到黑兄弟后继续判断。 |
| 兄弟黑,两个孩子都黑 | 兄弟染红;缺口上移至父。若父原为红则染黑结束;到根也结束。 |
| 兄弟黑,近侄红、远侄黑 | 近侄染黑、兄弟染红,对兄弟右旋,转成下一种情况。 |
| 兄弟黑,远侄红 | 兄弟继承父色;父和远侄染黑;对父左旋,缺口消除。 |
"近侄 / 远侄"按相对缺口的位置定义:缺口在左侧,右兄弟的左孩子是近侄、右孩子是远侄。这样比直接套 LL / RR 字母更不容易把方向写反。
🛠️ 读原稿实现时,优先核对这两个问题
根部旋转后必须让新根 parent = NULL;否则可能与旧根形成 parent 环。Delete_Adjust 中 p == NULL 分支设置 root 后仍执行 p->leftchild,删除唯一根结点会访问空指针,应该直接完成释放并返回。原稿还有双旋变色与删除分支的问题,本文的流程表用于重新梳理,不能把原实现当作已验证模板。
RBTree.cpp · L24--47RBTree.cpp · L323--337
AVL 与红黑树都保证 O(log n)。AVL 平衡更严格;红黑树的更新调整有不同的常数开销。"查找多选 AVL、更新多选红黑树"可作为经验起点,实际性能还取决于数据分布、实现和缓存。
4.5 B 树:一个结点保存多个键
把树变矮,减少外存访问;分裂和合并维护占用率。
📎 对应源码:Btree.h · Btree.cpp
B 树又写作 B-tree / B-树,横线不是减号。这里沿用阶数 m = 最大孩子数的定义。结点内键递增;k 个键把范围切成 k+1 段;所有数据叶子在同一层。
| 限制 | m 阶 B 树 | 本文的 5 阶实例 |
|---|---|---|
| 最多孩子 / 键 | m / m−1 | 5 个孩子 / 4 个键 |
| 非根内部结点最少孩子 | ⌈m/2⌉ | 3 |
| 非根结点最少键 | ⌈m/2⌉−1 | 2 |
| 非叶根最少孩子 / 键 | 2 / 1 | 2 / 1 |
| 根本身是叶子 | 非空时 1~m−1 个键;空树另行表示 | 不能硬套"至少 2 个孩子" |
原稿的key_arr从下标 1 存有效键,ptr_arr从 0 开始存区间指针。key_arr[M+1]还预留了暂存溢出键的空间;数组有空间,不代表树允许永久保留 M 个键。
插入:在叶子插入,满了就把中间键向上提
text
原稿 main 开头:依次插入 1, 5, 7, 4, 16
排序后临时结点:[1 4 5 7 16](5 个键,超出上限 4)
↓ 提升中间键 5
[5]
/ \
[1 4] [7 16]
若父结点也超限,就继续分裂;根分裂时创建新根,树高加一。非叶结点分裂时,键与孩子指针必须一起分配,右结点应得到中间分界右侧的全部孩子,并更新这些孩子的 parent。
删除:先借,再合并
删除内部键时,可以用前驱或后继替代,再到叶子删除。非根结点低于下限时:兄弟有富余则通过父分隔键借;两侧都没富余则和一个兄弟、父分隔键合并。合并会使父少一个键,可能继续向上传播。
text
讲解示例 · 借右兄弟(5 阶,下限 2 个键)
删除 5 前: [20] 删除并借后: [25]
/ \ / \
[5 10] [25 30 35] [10 20] [30 35]
父分隔键 20 下来,右兄弟最小键 25 上去。
讲解示例 · 兄弟也只有 2 个键,无法借
[20] 删除 5 → 合并 → [10 20 25 30]
/ \ 旧根清空,树高减一
[5 10] [25 30]
🛠️ 公式与原实现都要分清根的特例
非根最少孩子数要向上取整,不能像部分注释那样向下取整。原实现新建根时先设 key_num = 1,之后又统一 parent->key_num++,会把第一次分裂后的根计为 2 个键;删除根叶子时若直接调用 Delete_Adjust,又会访问不存在的 parent。
Btree.h · L18--28Btree.cpp · L67--108Btree.cpp · L252--260
B 树查找经过 O(logₘ n) 层量级(m 为固定阶数时即 O(log n))。这是层数 / 页访问的量级;结点内逐个比较还需 O(m),若对结点键二分则为 O(log m),不能把页访问次数与总 CPU 比较次数混为一谈。
4.6 B+ 树:让范围查询沿叶子前进
索引结点指路,叶子保存完整的数据项。
📎 对应源码:BPlus.h
这里的定义里,叶子包含全部有效数据,next / PrevNode串起相邻叶子;非叶结点存索引;BPTree.first指向第一个叶子,适合顺序访问。
| 比较点 | B 树 | B+ 树 |
|---|---|---|
| 数据项位置 | 内部结点与叶子都可保存 | 完整数据项 / 记录指针在叶子;内部结点用于索引 |
| 单点查找 | 可能在内部结点结束 | 取完整记录通常走到叶子 |
| 范围查询 | 需要跨结点按序遍历 | 先定位起点叶子,再沿叶子链扫描 |
| 结构调整 | 可能分裂、借、合并、向上传播 | 也可能分裂、借、合并、向上传播,并更新索引与叶链 |
采用"子树最大键"的索引约定
text
索引 [20 | 40 | 60]
↓ 每个键对应一个子树的最大键
叶子层:[5 10 20] ⇄ [25 30 40] ⇄ [45 50 60]
范围 [18, 45]:定位第一片叶子 → 输出 20,25,30,40,45 → 停止
这是笔记里的"几个孩子对应几个索引键"版本。另一种常见实现使用 k 个分隔键和 k+1 个孩子。不同教材对阶数、内部键个数和叶子容量的定义可能不同;做题应先确认约定,不能只背"B+ 树比 B 树多一个键"。
🛠️ 需要修正的绝对说法
B+ 树并非在所有单点查询、插入、删除场景下都更快,也不是只改叶子而完全不向上调整。它的典型优势是内部结点可容纳更多索引、树较矮、范围扫描连续。根若本身就是叶子,也不必有两个分支。
BPlus.h · L20--26
✍️ 练一练:为什么范围查询适合 B+ 树?
定位第一个满足条件的数据后,后续键按顺序分布在同一叶子或相邻叶子中,可以连续扫描;不用为每个值都重新从根查找。代价与定位路径以及实际扫描 / 输出的数据量有关。
五、图与复杂度
5.1 图:从遍历走向约束与最优连接
沿用 Map.cpp 的九宫格示例,把队列和栈串起来。
📎 对应源码:Map.cpp
图的基本概念
图 G=(V,E) 用顶点表示对象、边表示关系。无向图的边不分方向;有向图的边有起点和终点;带权图用权表示距离、费用等。简单图没有自环和重复边。
| 概念 / 性质 | 记忆方式 |
|---|---|
| 无向图度之和 | Σdeg(v) = 2|E|,每条边在两端各贡献一次。 |
| 有向图入度 / 出度 | Σ入度 = Σ出度 = |E|。 |
| 完全图边数 | n 个顶点:无向 n(n−1)/2;有向 n(n−1)。 |
| 连通分量 | 无向图的极大连通子图;连通图恰好有一个连通分量,就是自身。 |
| 强连通 | 有向图任意两顶点互相可达。n>1 时,强连通至少需 n 条边。 |
| 环 / 简单路径 | 简单路径不重复顶点;闭合路线起点等于终点,简单环除首尾外不重复。不能把任意重复顶点的行走都直接叫简单环。 |
邻接矩阵与邻接表
| 项目 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | O(V²) | O(V+E),无向边通常存两次 |
| 判断某条边存在 | O(1) | 通常 O(deg(u)),取决于容器 |
| 枚举某点邻居 | 扫描一行 O(V) | O(deg(u)) |
| 遍历全图的 BFS / 标准 DFS | O(V²) | O(V+E) |
| 适用倾向 | 稠密图、频繁判断边 | 稀疏图、频繁遍历邻居 |
无权矩阵可以用 0/1 表示边。带权图若允许权重为 0,就不能同时用 0 表示"无边",应使用独立标记或约定的无穷大值。矩阵扩展 / 删除顶点的复杂度还取决于是否预留容量和是否需要搬移整个矩阵。
用九宫格手算 BFS 和 DFS
text
0 ── 1 ── 2
│ │ │
3 ── 4 ── 5
│ │ │
6 ── 7 ── 8
从 0 出发,按编号从小到大检查邻居:
| 方式 | 顺序 | 关键状态 |
|---|---|---|
| BFS | 0 1 3 2 4 6 5 7 8 | 出队 0 后队列 1,3;出队 1 后 3,2,4;出队 3 后 2,4,6。 |
| 递归 DFS 的首次访问序 | 0 1 2 5 4 3 6 7 8 | 沿一条未访问路线深入,走不通再回溯。 |
讲解片段 · C++
cpp
// 补充示例:邻接表 BFS,编号范围 0..adj.size()-1
// 需要 <vector>、<queue>,调用时 start 必须合法
std::vector<int> BFS(const std::vector<std::vector<int>>& adj, int start) {
std::vector<bool> visited(adj.size(), false);
std::vector<int> order;
std::queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true; // 入队时标记,避免重复入队
q.push(v);
}
}
}
return order;
}
BFS 采用入队即标记是正确做法,可以保留。从一个起点只能覆盖它可达的部分;遍历非连通图时,外层还要对每个未访问顶点启动一次搜索。DFS 可以递归,也可以用显式栈,"回溯"不会再次把已访问结点当成新结点处理。
⚠️ 双栈 DFS 的输出需要换个名称
原稿在结点完成探索时压入 S2,最后弹出 S2,得到的是"完成顺序的逆序",不一定是第一次发现顶点的顺序。例:仅有 0---1、0---2,按编号探索的首次访问序为 0,1,2,S2 输出为 0,2,1。只有在有向无环图且覆盖全图等条件下,逆完成顺序才可用作拓扑序。
Map.cpp · L146--183
最小生成树:连接所有点,且总权最小
讨论对象是带权无向连通图。生成树包含全部 V 个顶点、V−1 条边且连通无环;最小生成树 MST 让总权最小。非连通图对应最小生成森林。MST 与从某起点到其他点的最短路径树是不同目标。
| 算法 | 选择原则 | 常用复杂度 |
|---|---|---|
| Prim | 从已选点集合向外,选跨越边界的最小权边,把一个新点接进来 | 矩阵朴素版 O(V²);邻接表加堆常见 O((V+E)log V) |
| Kruskal | 边按权排序,若两端不在同一连通分量则选入;并查集判环 | O(E log E) 为主 |
**补充手算:**边 AB=1、BC=2、AC=4、CD=3、BD=5。Kruskal 依次选 AB、BC、CD,总权 6;已经选满 V−1=3 条边,停止。若继续检查 AC,会发现它形成环,应跳过。
拓扑排序:先完成前置任务
只针对 DAG(有向无环图)。Kahn 算法:统计入度 → 所有入度 0 的点入队 → 取出并输出 → 对其每条出边的终点入度减一,减到 0 就入队。若最终输出数量小于 V,则存在环。
**补充示例:**A→C、B→C、C→D。合法顺序有 A,B,C,D 和 B,A,C,D;若再加 D→A,就出现依赖环,无法得到完整拓扑序。队列中的多个入度零点可按不同顺序选择,因此答案可能不唯一。
Prim、Kruskal 与 Kahn 的原稿内容主要是步骤笔记,上面的手算是讲解补充。BFS 中 visited 的含义是"已经发现 / 入队",不是"已经打印";原 BFS / DFS 还应释放 malloc 得到的 visited,或使用自动管理内存的容器。
5.2 复杂度速查:先问前提是什么
把理论结构、原稿实现、单次代价区分开。
| 结构 / 算法 | 查找 / 访问 | 插入 / 删除 | 适用前提 |
|---|---|---|---|
| 顺序表 | 下标 O(1),按值 O(n) | 一般 O(n),尾插摊还 O(1) | 倍增扩容;单次扩容 O(n) |
| 单链表 | 定位 O(n) | 已知前驱 O(1) | 找位置的成本不能省略 |
| 顺序栈 | 栈顶 O(1) | Push 摊还 O(1),Pop O(1) | 单次扩容 O(n) |
| 链栈 | 栈顶 O(1) | O(1) | 统一在表头操作 |
| 循环数组队列 | 队头 / 队尾 O(1) | 固定容量下 O(1) | 全操作正确回绕;动态扩容单次 O(n) |
| 链队列 | 队头 O(1) | 有 rear 时 O(1) | 原稿无 rear 的入队实际 O(n) |
| BST | O(h) | O(h) | 最坏 h=O(n) |
| AVL / 红黑树 | O(log n) | O(log n) | 正确维护平衡性质 |
| 二分查找 | O(log n) | --- | 数组已经有序 |
| 冒泡排序 | --- | O(n²) 时间、O(1) 辅助空间 | 原稿版本没有提前退出,即使已排序也仍 O(n²) |
| BFS / 标准 DFS | O(V+E) 或 O(V²) | --- | 前者邻接表,后者邻接矩阵 |
| Kahn 拓扑排序 | O(V+E) | --- | 邻接表,入度与出边各处理一次 |
先 BubbleSort 再二分的 BinFindValue,为什么整体不是 O(log n)?
📄 原稿摘录 · Seqlist.cpp · L293--306
cpp
293 int BinFindValue(SeqList* plist, ELEM_TYPE val)
294 {
295 assert(plist != NULL);
296 if (IsEmpty(plist))
297 {
298 printf("链表为空!\n");
299 return -1;
300 }
301 BubbleSort(plist);
302 int left = 0;
303 int right = plist->cursize - 1;
304 while (left <= right)
305 {
306 int mid = left + (right - left) / 2;
它每次查询前都调用BubbleSort,所以整体 O(n²)+O(log n)=O(n²)。而且排序改变了原表顺序,返回下标是排序后的下标。若要多次查询,先排序一次,再重复二分;需要保持原位置时应另存索引信息。
二分的区间约定要写在脑子里
这里用的是闭区间[left, right]:循环条件left <= right,中间小于目标就left = mid + 1,大于目标就right = mid - 1。mid = left + (right-left)/2比直接相加更稳妥。
text
[2, 5, 8, 12, 16] 查找 12
left=0 right=4 → mid=2,8<12 → left=3
left=3 right=4 → mid=3,命中 12 → 返回 3
冒泡排序只在相邻元素严格大于时交换,保持相等元素相对顺序,因而稳定。若增加"本轮未交换则退出"的标记,已排序输入的最好时间可降到 O(n)。
六、原稿疑点清单
6.1 把原稿里的疑点变成复习题
以下来自源码静态核对;用于定位重点,并非完整代码审计。
先根据"触发场景"解释问题,再尝试修正,比直接背一份标准代码更容易形成理解。本次整理没有修改原工程。
| 重点 | 原稿位置 | 复习时检查 |
|---|---|---|
| 顺序表空表插入 | Seqlist.cpp · L66--86 |
InsertElem 在空表直接返回 false;空表的 pos=0 应允许插入。分配失败时也不能继续移动 / 写入。 |
| 按值删除长度 | Seqlist.cpp · L214--248 |
RemoveElem 命中后未减长度,未命中反而减长度;RemoveElemAll 会跳过相邻重复值,且固定返回 false。 |
| 顺序栈清空和扩容 | stack顺序栈.cpp · L29--59 |
ClearStack 不应把容量设为 0;Push 在栈满时必须检查扩容是否成功。 |
| 链栈出入两端不一致 | stack链表栈.cpp · L63--151 |
头插配尾删不是栈;单结点时 top->next 为 NULL,不能继续访问 p->next。 |
| 单链表删除目标错误 | 链表改变的链表栈.cpp · L136--142 |
先保存待删结点,再重连,再 free。原稿释放了改写后的 ptr->next。 |
| 未完成的单链表接口 | 链表改变的链表栈.cpp · L108--117 |
InsertPos 未完成并缺少返回;Remove 只查找打印而未删除;ClearList 释放后未重置 head->next。 |
| 顺序队列容量与长度 | queue.cpp · L3--30 |
cursize 初始化为容量,却用于判空并在入队时自增;应分离容量和长度。该实现当前被 #if 0 关闭。 |
| 循环队列边界 | dequeue双向循环顺序队列.cpp · L87--167 |
头删、尾删、遍历和取尾也必须回绕;rear 指下次写入位置,取尾应先向前一步;空队列要先拒绝读取。 |
| 链队列单元素出队 | listqueue.cpp · L70--83 |
先验证取队头成功,再摘链 / 释放 / size--。单元素分支不能提前漏掉这些操作。 |
| 双向循环链表断环 | 双向循环链表之队列doubly circular linked list queue.cpp · L25--44 |
findend 不应修改链接;单结点 next / prev 应指回自身;deletefront 的单结点分支处理完应立即返回。 |
| 二叉树遍历混用 | BInary_Tree.cpp · L38--44 |
后序中应递归调用后序;非递归中序出栈后用保存的结点取右孩子;双栈后序打印的是 st2.top()->data。 |
| 重复定义与构造返回 | BInary_Tree.cpp · L13--118 |
递归与非递归 PreOrder / InOrder 同名同参数,若整体启用会重复定义,须改名或分开编译;PreIn 构造末尾需 return root。 |
| BST 父指针和空孩子 | BSTtree.cpp · L44--102 |
新结点设置 parent;删除叶子不能 child->parent;后继 / 前驱条件先判 parent 非空。 |
| AVL 查找固定比较 root | AVLTree.cpp · L209--223 |
比较 p->key;删除不存在键时允许遇到空子树并返回,不应断言失败。 |
| 红黑树删除唯一根 | RBTree.cpp · L323--337 |
父为 NULL 时单独结束;根部旋转后清除新根 parent;不能假设每个调整入口都有父或兄弟。 |
| B 树新根被重复计数 | Btree.cpp · L67--108 |
创建根时 key_num 已设为 1,不能再统一自增;分裂还应正确迁移中间分界后的所有孩子并设置 parent。 |
| B 树删除根的特例 | Btree.cpp · L363--399 |
非根下限不能用于根叶子;Delete_Adjust 默认有父,调用前应处理根为空或根收缩。头文件 Borrow_leftBro 等声明与实现的大小写 / 返回类型也应统一。 |
| 图的 DFS 输出语义 | Map.cpp · L146--183 |
双栈输出是逆完成序,不一定是首次访问序;visited 分配后应释放。邻接表 O(V+E) 的结论不能直接套在反复从头扫描邻居的实现上。 |
✅ 测试原工程时先选一个练习
工程中有同名类型、同名辅助函数和多个被条件编译关闭的 main。不要同时打开所有 #if 0;先明确要运行的模块,再隔离类型 / 函数(例如用命名空间或独立项目)。文末给出的示例是独立程序。
七、进阶数据结构
7.1 跳表:给有序链表增加多层索引
随机化保持期望效率,最底层保存完整有序序列。
从单链表查找 O(n) 出发:给部分结点增加"快车道",高层跨过一大段,遇到下一步会超过目标就下到低层。每个结点以概率 p 继续晋升一层;查找从最高层最左侧开始。
text
L2 head ────────── 20 ───────────────── 50
L1 head ── 10 ──── 20 ────── 35 ────── 50
L0 head → 10 → 15 → 20 → 25 → 35 → 40 → 50
找 35:高层先到 20 → 50 太大,下层 → 到 35 → 降到底层确认。
补充讲解 · 概念伪代码,并非原项目接口
cpp
// 概念伪代码:每层按键有序;forward[level] 指向该层下一个结点。
p = head;
for (int level = highest; level >= 0; --level) {
while (p->forward[level] && p->forward[level]->key < key)
p = p->forward[level];
update[level] = p; // 插入 / 删除时,本层的前驱结点。
}
candidate = p->forward[0];
// candidate 存在且键相等才算命中。
插入和删除:复用查找得到的 update\[\]
插入 27 时,先找各层最后一个小于 27 的结点;随机生成新结点高度;逐层执行"新结点指向后继、前驱指向新结点"。删除时同样记录前驱,只在前驱的 next 指向目标时摘链,必要时降低最高有效层。
| 项目 | 结论与前提 |
|---|---|
| 查找 / 插入 / 删除 | 合理随机晋升、层数上限足够时,期望 O(log n);最坏仍可 O(n)。 |
| 空间 | 每结点期望层数 1/(1-p),总空间期望 O(n);p=1/2 时约 2 个前向指针 / 结点(不含其他字段)。 |
| 区间查询 | 先找下界,再沿底层扫描,期望 O(log n+k),k 为输出量。 |
| 排名查询 | 基本 next 指针不能直接算排名;还需维护跨越结点数 span 等元数据。 |
💬 面试追问:为什么有些有序集合用跳表而不是红黑树?
两者都支持有序查询。跳表用局部链表更新实现多层索引,范围扫描直观;红黑树提供确定性的 O(log n) 最坏界。选择还取决于常数、空间、实现与并发方案;跳表并不自动无锁。Redis 常见的较大 ZSet 编码结合跳表和哈希表,小集合可能使用紧凑编码,不能说所有 ZSet 都必然是跳表。
💬 面试追问:为什么不是给所有结点都建满层?
这样只会复制整条链表,增加空间而没有形成稀疏索引。几何概率让越高层越稀疏,平衡向右走与向下走的成本。
✅ 手写前说清边界
本文默认键唯一、重复插入不增加结点;真实实现还要确定比较器、随机数生成方式、最大层数、内存释放以及并发访问策略。
7.2 可扩展哈希:目录与桶分开增长
先区别普通哈希扩容、可扩展哈希与一致性哈希。
📎 对应源码:哈希表/源.cpp · L277--382
这份哈希表练习用 12 个链表桶:val % 12定位后头插。装载因子 α=n/桶数;均匀散列时链地址查找期望 O(1+α),极端冲突可退化 O(n)。如果允许负整数,C++ 的负余数不能直接当数组下标,可用((key % M) + M) % M。
| 机制 | 主要解决的问题 | 增长方式 |
|---|---|---|
| 普通哈希表 rehash | 桶太少、装载率升高 | 更换桶数组,并把原元素按新桶数重新分配;单次可能 O(n)。 |
| 可扩展哈希 Extendible Hashing | 桶 / 页局部溢出,希望局部分裂 | 目录用若干哈希位寻址;分裂溢出的桶,必要时翻倍目录。 |
| 一致性哈希 | 分布式机器节点增删时的数据归属 | 环或其他映射机制减少迁移范围;虚拟节点改善均衡,不等同于桶目录。 |
两个深度,记住 ld ≤ gd
本例按哈希值低位 取目录下标。全局深度gd表示用了几位,目录有 2^gd 个指针;每个桶的局部深度ld表示区分这个桶用了几位。一个 ld 小于 gd 的桶,会被 2^(gd-ld) 个目录项共同指向。
📐 判据
- 目录下标 = hash(key) & ((1 << gd) - 1)
- 桶满且 ld < gd:只分裂桶,重定向相关目录指针
- 桶满且 ld == gd:先将目录翻倍、gd++,再分裂桶
每桶最多 2 个键,观察目录何时翻倍
🧪 交互演示(原文档可点击操作):为便于手算,示例 hash(key)=key。真实系统需要分布合适的哈希函数。
| 插入场景 | 结果 |
|---|---|
| 初始 gd=1 | 0→偶数桶 ld=1:0,2;1→奇数桶 ld=1:1。 |
| 插入 4,偶数桶满且 ld=gd | gd 升为 2;00→0,4、10→2,这两桶 ld=2;01 和 11 仍共享 ld=1 的 1。 |
| 插入 3 | 奇数桶变为 1,3,目录不变。 |
| 再插入 5,奇数桶满但 ld<gd | 只分裂该桶;01→1,5、11→3,各 ld=2;gd 保持 2。 |
💬 面试追问:目录翻倍是不是把所有数据重写一遍?
不是。翻倍主要复制目录指针,既有桶仍然可以共享;随后只重新分配发生分裂的桶中的记录。目录本身的复制成本是 O(2^gd),所以也不能把所有插入都说成严格 O(1)。
💬 面试追问:如果分裂之后桶还是满怎么办?
继续检查下一位并重试。若哈希值完全相同或已经达到深度上限,仅增加位数无法分开,必须有溢出页、冲突链或明确的失败策略,不能无限扩目录。
💬 面试追问:删除能否缩容?
可以设计合并:伙伴桶局部深度相同且合并后装得下时合桶、ld--,重定向所有别名目录项。所有桶都不再需要最高目录位时,目录才可缩小。实际实现还要考虑并发和页持久化。
✅ 复杂度的条件
目录可在内存中直接寻址、桶容量有界且散列良好时,点查可为期望常数次桶访问。固定深度上限、溢出链、目录页访存和分裂都会影响实际代价。
7.3 布隆过滤器:快速排除"不可能存在"
用位数组换空间,接受一定概率的假阳性。
📎 对应源码:哈希表/源.cpp · L410--418
准备 m 个 bit 和 k 个哈希位置。插入 x,把这 k 个位置全置 1。查询时,只要有一位为 0,就说明 x 没被加入;若全部为 1,只能说"可能存在",应再查真实集合。不同元素的位可能重叠。
text
m=8,k=2(人为给定映射,专用于手算)
插入 A → 位置 {1,4};插入 B → 位置 {2,6}
位数组下标:0 1 2 3 4 5 6 7
当前 bit :0 1 1 0 1 0 1 0
未插入 C → {1,6}:两位都是 1 → 假阳性
查询 D → {3,6}:位置 3 为 0 → 一定未被加入
公式要能解释变量,而不是只背结果
📐 判据
- 假阳性率 f ≈ (1 - e(-kn/m))k
- 近似最优 k ≈ (m/n) ln 2
- 给定 n 与目标 f:m ≈ -n ln f / (ln 2)^2
公式建立在哈希分布均匀、位置近似独立等假设上。n 是预计加入的不同元素数,m 是位数,不是字节数;k 取接近最优值的整数。插入与查询需 O(k) 次位访问,只有 k 固定时才常简写成 O(1);哈希长字符串还要考虑读入字符的成本。
估算空间预算
🧪 交互演示(原文档可点击操作):只计算理论容量,不创建大型位数组;0.01 表示 1%。
💬 面试追问:为什么不能直接删除,把对应的位改回 0?
这些位也可能服务于其他仍存在的元素。清零会制造假阴性。计数布隆过滤器用计数器支持删除,但需要正确记录插入次数、避免溢出和错误删除;不能仅凭"可能存在"就任意减计数。
💬 面试追问:"没有假阴性"有什么前提?
所有被讨论的元素已成功加入当前过滤器,位未被错误清除,哈希规则一致,更新没有丢失。如果数据库新增记录而过滤器尚未更新,过滤器的"不存在"只是对旧集合成立,不能断言当前数据库中不存在。缓存穿透防护必须设计初始化、更新和重建流程。
💬 面试追问:用于去重时能否直接跳过"可能存在"的对象?
只有业务容忍误丢时才可这样做;精确去重仍需二次核验。否则假阳性会使一个从未处理过的对象被错误跳过。
🛠️ 修正笔记里的空间说法
m 固定时,n 越大,位数组越趋于饱和,假阳性率通常升高;不能把"加入更多数据"理解成性能越来越好。超过容量预期应考虑重建、分层或可扩展布隆过滤器。
7.4 字典树:让字符串共享前缀
路径存在和完整单词存在,是两个不同问题。
Trie 把字符放在边上,从根到某结点的路径表示一个前缀;用 terminal 标记完整单词。插入 app、apple、ape,共享前两个字符 ap,但 app 和 ape 分叉。
text
root → a → p → p* → l → e*
└→ e*
* 表示单词终点;只有 apple 时,app 的路径存在,但 app 不是完整单词。
查找长度 L 的键,逐字符走一条边,固定字符集的数组分支使时间 O(L)。这与哈希字符串通常也需要 O(L) 计算哈希并不矛盾;Trie 的优势是自然支持前缀导航,代价可能是大量空孩子指针。
📂 展开:支持插入、精确查询与前缀查询的 C++ 示例
面试延伸 · 示例代码
cpp
// 字典树:字符集限定为 a..z;本例允许空单词,根的 terminal 表示它。
class Trie {
struct Node {
std::array<std::unique_ptr<Node>, 26> child{};
bool terminal = false;
};
Node root;
static int Index(char c) {
if (c < 'a' || c > 'z') throw std::invalid_argument("lowercase a-z only");
return c - 'a';
}
const Node* Walk(const std::string& s) const {
const Node* p = &root;
for (char c : s) {
const int index = Index(c);
if (!p->child[index]) return nullptr;
p = p->child[index].get();
}
return p;
}
public:
void Insert(const std::string& s) {
for (char c : s) Index(c); // 先验证全部字符,避免非法输入导致部分插入。
Node* p = &root;
for (char c : s) {
const int index = Index(c);
if (!p->child[index]) p->child[index] = std::make_unique<Node>();
p = p->child[index].get();
}
p->terminal = true;
}
bool Search(const std::string& s) const {
const Node* p = Walk(s);
return p && p->terminal;
}
bool StartsWith(const std::string& prefix) const { return Walk(prefix) != nullptr; }
};
| 设计选择 | 取舍 |
|---|---|
| child26 | 小写英文下 O(1) 访问分支,但稀疏结点可能浪费指针。 |
| map / unordered_map 孩子 | 只存实际分支,增加容器和访问成本;不能不加前提地仍按固定数组成本分析。 |
| 压缩 Trie / Radix Tree | 把单分支路径压成字符串边,减少结点,插入时需处理边分裂。 |
| Unicode | 按字节构建与按用户理解的字符构建不同;必须明确编码和规范化策略。 |
💬 面试追问:删除 apple 会不会把 app 也删掉?
不能直接删除整条路径。先去掉 apple 的终点标记,再从叶向上,仅回收"不是其他单词终点、也没有剩余孩子"的结点;遇到 app 的终点必须停止。若支持重复词和前缀计数,需要准确维护 endCount、passCount。
💬 面试追问:前缀查询是不是一定 O(L)?
定位前缀结点可以是 O(L),但列出所有匹配词还要访问相关子树并输出结果。结果本身很多时,不可能只花 O(L) 就全部返回。
7.5 KMP:失配时复用已经匹配的信息
统一使用 pi 前缀函数,避免混用 next 的不同定义。
📎 对应源码:BF_ KMP算法/源2.cpp
"i 打死都不会回退,j 回退到合适的位置"抓住了核心。这里令pi[i]表示模式串 P0...i 的最长相等真前缀与真后缀长度;"真"表示不能取整个字符串自身。pi 只由模式串决定。
text
模式串 P:A B A B C
下标 i :0 1 2 3 4
pi[i] :0 0 1 2 0
例如 ABAB 的最长相等真前后缀是 AB,长度为 2。
当 texti 与 Pj 不同,前 j 个字符已经匹配;可复用的长度是pi[j-1]。令j=pi[j-1]后继续比较同一个 texti,可能需要多次回退;不是每次都简单 j--。
🧪 交互演示(原文档可点击操作):看清 i 不回退,j 如何跳转
面试延伸 · 示例代码
cpp
std::vector<int> Prefix(const std::string& p) {
const int m = static_cast<int>(p.size());
std::vector<int> pi(m, 0);
for (int i = 1, j = 0; i < m; ++i) {
while (j > 0 && p[i] != p[j]) j = pi[j - 1];
if (p[i] == p[j]) ++j;
pi[i] = j;
}
return pi;
}
int KMP(const std::string& text, const std::string& p) {
if (p.empty()) return 0; // 本例约定空模式在下标 0 匹配。
const auto pi = Prefix(p);
const int n = static_cast<int>(text.size()), m = static_cast<int>(p.size());
for (int i = 0, j = 0; i < n; ++i) {
while (j > 0 && text[i] != p[j]) j = pi[j - 1];
if (text[i] == p[j]) ++j;
if (j == m) return i - m + 1;
}
return -1;
}
🛠️ 对照 next = -1 的另一种写法
next数组版本的next[j]描述"在位置 j 失配时跳到哪里",与本文的pi[j]定义不同。原搜索函数在 j=next0=-1 后,下一轮仍访问 str2j,会越界。Get_Next 无条件写 next1,对长度 0 / 1 的模式也有越界风险。采用哨兵 -1 写法时必须显式处理 j==-1;本文的 pi 写法用 j>0 条件避免负下标。BF_ KMP算法/源2.cpp · L18--49BF_ KMP算法/源2.cpp · L54--90
还要把 strlen 的结果先保存,避免在每次循环条件中重复扫描;原搜索失败的分支也应释放 next。示例使用 vector 管理前缀表,空模式约定返回 0。
💬 面试追问:为什么 while 里还能有 while,却是 O(n+m)?
i 只向前走;j 的增加次数随成功比较最多是线性的,所有回退次数受此前增加量约束。前缀表构建 O(m),匹配 O(n),不是把两层循环长度直接相乘。空间 O(m)。
💬 面试追问:如何找全部匹配,含重叠?
每次 j==m 时记录起点,然后令 j=pim-1 继续。比如 aaaaa 中找 aaa,起点是 0、1、2;把 j 无条件清零会漏掉重叠。
八、常用算法
8.1 二分:先定义边界,再写循环
从"找某个值"延伸到"找第一个满足条件的位置"。
原文中的二分使用闭区间。这里补充面试常用的半开区间写法:维护[left,right),找第一个不小于 target 的下标。重复值、空数组、完全不存在的值都能统一处理。
面试延伸 · 示例代码
cpp
// 前提:a 已按非递减顺序排列。返回首个 >= target 的位置,可能等于 a.size()。
std::size_t LowerBound(const std::vector<int>& a, int target) {
std::size_t left = 0, right = a.size(); // 搜索区间 [left, right)
while (left < right) {
const auto mid = left + (right - left) / 2;
if (a[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
📐 判据
- 不变量:left 左边都 < target;right 及右边都 ≥ target
- 循环退出 left == right,即分界点;返回值可等于 n,读取 apos 前必须判 pos < n
| 输入 / 目标 | lower_bound | 含义 |
|---|---|---|
| 1,2,2,2,5 / 2 | 1 | 第一个 2,而不是任意一个 2。 |
| 1,2,2,2,5 / 4 | 4 | 插入 4 不破坏有序性的位置。 |
| 1,2,2,2,5 / 9 | 5 | 所有元素都更小;返回尾后位置,不能解引用。 |
| \[\] / 任意值 | 0 | 没有合法元素,但有一个插入位置。 |
答案二分:数组可以不是搜索对象
例:有工作量 3,6,7,11,以速度 v 每小时处理一个任务的一部分,每项独立向上取整,要在 8 小时内结束。定义check(v)=Σceil(work/v) ≤ 8。速度越大越容易满足,形成 false...false,true...true,可以二分最小可行速度。
text
v=3:1+2+3+4=10 小时,不可行
v=4:1+2+2+3=8 小时,可行
v=5:1+2+2+3=8 小时,可行 → 最小可行速度为 4
只有先证明 check 单调且搜索范围包含解,才能二分。对正整数可用x / v + (x % v != 0)算向上取整,避免 x+v−1 溢出;总和使用足够宽的整数。若时限小于任务数,本模型无解,应提前处理。
💬 面试追问:upper_bound 与 lower_bound 有什么区别?
upper_bound 找第一个 >target 的位置;有序区间内某个值的出现次数可用 upper-lower。直接查值时,lower_bound 返回位置还需确认未到尾后且值确实等于 target。
💬 面试追问:二分查找放在链表上还是 O(log n) 吗?
比较次数可以减少,但链表不能 O(1) 随机访问中间位置。定位中点的遍历成本会主导;不能把数组的时间结论直接套给链表。
💬 面试追问:循环怎么证明不会卡住?
本例 mid 位于 [left,right),每步要么 left=mid+1,要么 right=mid,因此区间长度严格减小。不要把另一套闭区间更新规则拼进来。
8.2 双指针与滑动窗口:移动前先证明安全
相向、同向、快慢指针各有适用条件。
| 形态 | 典型题 | 为什么能这样移动 |
|---|---|---|
| 相向指针 | 有序数组两数之和、回文判断 | 顺序或对称性允许排除一整批候选。 |
| 同向读写指针 | 移除元素、有序数组去重 | read 扫描未处理区,write 构建有效前缀。 |
| 快慢指针 | 链表中点、环检测 | 利用不同速度与相遇性质;必须先判断 fast 及 fast->next。 |
| 滑动窗口 | 连续子串 / 子数组 | 左右端点维护一个连续区间,按窗口性质更新状态。 |
相向指针:有序数组两数之和
面试延伸 · 示例代码
cpp
// 前提:数组有序。返回两个不同位置;未找到返回 {-1,-1}。
std::pair<int,int> TwoSum(const std::vector<int>& a, long long target) {
int left = 0, right = static_cast<int>(a.size()) - 1;
while (left < right) {
const long long sum = static_cast<long long>(a[left]) + a[right];
if (sum == target) return {left, right};
if (sum < target) ++left;
else --right;
}
return {-1, -1};
}
若 aleft+aright 小于目标,固定 left 搭配任何更小的右值都不够,只能 left++;大于目标时理由对称。输入无序就没有这个排除依据,可先排序(注意原下标)或用哈希。时间 O(n),额外空间 O(1)。
最长无重复子串:左边界不能倒退
面试延伸 · 示例代码
cpp
// 最长无重复子串:窗口 [left,right] 内每个字节最多出现一次。
int LongestUnique(const std::string& s) {
std::array<int, 256> last;
last.fill(-1);
int left = 0, best = 0;
for (int right = 0; right < static_cast<int>(s.size()); ++right) {
const auto c = static_cast<unsigned char>(s[right]);
left = std::max(left, last[c] + 1); // 左边界只能前进。
last[c] = right;
best = std::max(best, right - left + 1);
}
return best;
}
| 扫描 abba | left | 当前窗口 | 原因 |
|---|---|---|---|
| a,下标 0 | 0 | a | 首次出现。 |
| b,下标 1 | 0 | ab | 无重复。 |
| b,下标 2 | 2 | b | 上次 b 在 1,left 跳到 2。 |
| a,下标 3 | 2 | ba | 上次 a 在 0 已在窗口外,不能把 left 回退到 1。 |
示例按字节计数,英文输入的答案可以按字符理解;UTF-8 中文通常包含多个字节,需要先明确题目要求。时间 O(n),固定 256 字节字符集的辅助空间 O(1)。
最短和 ≥ target:仅在正数前提下套这个窗口
📂 展开:正整数数组的最短窗口模板
面试延伸 · 示例代码
cpp
// 正整数数组中,和 >= target 的最短连续子数组;不存在返回 0。
int MinLength(const std::vector<int>& a, long long target) {
if (target <= 0 || std::any_of(a.begin(), a.end(), [](int x){ return x <= 0; }))
throw std::invalid_argument("positive values and target required");
int left = 0, best = INT_MAX;
long long sum = 0;
for (int right = 0; right < static_cast<int>(a.size()); ++right) {
sum += a[right];
while (sum >= target) {
best = std::min(best, right - left + 1);
sum -= a[left++];
}
}
return best == INT_MAX ? 0 : best;
}
2,3,1,2,4,3,target=7,最短窗口是 4,3,长度 2。固定右端点后,只要满足条件就不断收缩左边界,记录最短;两个边界各最多走 n 步,所以 O(n)。
⚠️ 含负数时,不能直接照搬
反例 1,-1,5,target=5:窗口到全数组才满足,移走 1 后和为 4,按正数模板会停止收缩,漏掉长度 1 的 5。包含负数的最短和问题可考虑前缀和 + 单调队列;这是进一步延伸,不属于这个正数模板。
💬 面试追问:滑动窗口是不是所有子数组题都能用?
不是。要有能随左右移动维护的状态,并能证明丢弃左端不会漏掉更优解;有些是"保持合法求最长",有些是"满足要求收缩求最短"。"连续"只是线索,不是充分条件。
💬 面试追问:链表环的入口怎么找?
Floyd 快慢指针先相遇,再让一个指针回到头,另一个留在相遇点,两者每次一步,再次相遇即入口。可由头到入口距离 a、入口到首次相遇距离 b 与环长 c 的同余关系 a+b≡0(mod c) 推导。若没有相遇,则无环。
8.3 动态规划:状态必须足以决定后续
先写状态含义、转移、初始化,再决定遍历顺序。
DP 把原问题分解为可复用的子问题,存储子问题答案。常见条件是最优子结构与重叠子问题;设计上最关键的是状态中已经包含做后续决定所需的信息。记忆化搜索和自底向上填表,是两种实现方式。
例一:最少硬币数,为什么不是总拿最大的?
面额 1,3,4 凑 6:贪心取 4+1+1,要 3 枚;最优 3+3,只要 2 枚。定义 dpx 为凑出金额 x 的最少枚数;最后一枚若是 c,前面必须先凑出 x-c。
📐 判据
- dp0=0;其他金额初始为不可达
- dpx = min(dpx-c+1),遍历 c≤x 且 dpx-c 可达的硬币
| x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dpx | 0 | 1 | 2 | 1 | 1 | 2 | 2 |
面试延伸 · 示例代码
cpp
// 每种硬币可无限取;无法凑出返回 -1。
int MinCoins(const std::vector<int>& coins, int amount) {
if (amount < 0 || amount == INT_MAX ||
std::any_of(coins.begin(), coins.end(), [](int c){ return c <= 0; }))
throw std::invalid_argument("invalid coins or amount");
const int inf = amount + 1;
std::vector<int> dp(static_cast<std::size_t>(amount) + 1, inf);
dp[0] = 0;
for (int x = 1; x <= amount; ++x) {
for (int c : coins) {
if (c <= x && dp[x - c] != inf)
dp[x] = std::min(dp[x], dp[x - c] + 1);
}
}
return dp[amount] == inf ? -1 : dp[amount];
}
时间 O(amount × 面额数),空间 O(amount)。不要把不可达状态也初始化为 0;不要让"无穷大 + 1"发生整数溢出。若需要具体方案,再记录每次转移选用的硬币。
例二:0/1 背包,容量为什么倒序?
面试延伸 · 示例代码
cpp
// 0/1 背包:每件最多一次,允许不装满;w>0,v>=0,值总和须可由 long long 表示。
long long Knapsack01(const std::vector<int>& w, const std::vector<int>& v, int cap) {
if (w.size() != v.size() || cap < 0) throw std::invalid_argument("invalid input");
std::vector<long long> dp(static_cast<std::size_t>(cap) + 1, 0);
for (std::size_t i = 0; i < w.size(); ++i) {
if (w[i] <= 0 || v[i] < 0) throw std::invalid_argument("invalid item");
for (int j = cap; j >= w[i]; --j)
dp[j] = std::max(dp[j], dp[j - w[i]] + v[i]);
}
return dp[cap];
}
二维定义:dpij 为前 i 件物品、容量不超过 j 的最大价值。压成一维后,j 倒序使 dpj-w 仍表示"上一件物品处理完"的状态,避免同一件重复使用。重量 1,3,4、价值 15,20,30、容量 4,最优是重量 1+3,价值 35。
| 问题 | 一维容量方向 / 初始化 | 注意 |
|---|---|---|
| 0/1 背包 | 每个物品外层,容量从大到小;不必装满时初始 0 | 每件只能一次;正序会把同一件反复用。 |
| 完全背包 | 每个物品外层,容量从小到大 | 允许用当前物品更新后的状态,再次选它。 |
| 恰好装满求最大值 | dp0=0,其余负无穷;只从可达状态转移 | 不能沿用"不必装满"的全零初始化。 |
| 硬币组合数 / 排列数 | 正面额下,常见组合计数用硬币外层;排列计数用金额外层 | 目标含义不同;不能把所有 DP 的两层循环随便交换。 |
再准备两种经典状态
**LCS:**dpij 表示两个串前 i / j 个字符的最长公共子序列;末字符相同用 dpi-1j-1+1,否则取 max(dpi-1j,dpij-1),时间 O(nm)。子序列不要求连续。
**LIS:**dpi 表示以第 i 个元素结尾的最长严格递增子序列,转移自所有 j<i 且 aj<ai,基线 O(n²)。进一步可用"最小结尾值 + 二分"降至 O(n log n),但 tails 数组本身未必是实际的一条原数组子序列。
💬 面试追问:DP 和递归有什么区别?
递归是控制流程;DP 是组织和复用子问题结果的方法。朴素递归可能重复计算同一状态,加上记忆化才避免重复;迭代 DP 则用依赖顺序来填表。
💬 面试追问:怎么判断一维优化是对的?
检查转移读的是上一行还是当前行;明确哪个状态必须尚未覆盖、哪个需要刚刚更新。空间优化不能先于状态定义和依赖分析。
8.4 贪心:局部选择必须有正确性理由
会给一个反例,也要会给一个交换论证。
贪心每一步做当前看来最好的选择,通常不再回头。它能否得到全局最优,取决于问题结构;"看起来更划算"不是证明。前面硬币 1,3,4 凑 6 就反驳了"每次最大面额"的通用正确性。
例:安排最多不重叠会议
区间按半开 [start,end) 解释,结束时刻等于下场开始时刻可以接上。目标是数量最多,先选择结束最早的会议,为后续留出时间。
面试延伸 · 示例代码
cpp
// 半开区间 [start,end),start<end;求最多互不重叠的区间数量。
int MaxMeetings(std::vector<std::pair<int,int>> intervals) {
for (auto [start,end] : intervals)
if (start >= end) throw std::invalid_argument("start must precede end");
std::sort(intervals.begin(), intervals.end(), [](auto a, auto b){
return a.second != b.second ? a.second < b.second : a.first < b.first;
});
int count = 0;
long long end = std::numeric_limits<long long>::min();
for (auto [start,finish] : intervals) {
if (start >= end) { ++count; end = finish; }
}
return count;
}
text
A[1,3) B[2,4) C[3,5) D[0,6) E[5,7)
按结束时间排序:A, B, C, D, E
选 A → 跳过 B → 选 C → 跳过 D → 选 E
结果 A,C,E,共 3 场
用交换论证说明为什么最早结束正确
取任一最优方案的第一场会议 O。贪心选 G,满足 G 结束时间不晚于 O。用 G 替换 O,不会挤掉后续会议,数量不变,所以存在一个最优方案以 G 开头。对 G 结束后的剩余子问题重复同样论证。
| 常见错误选择 | 为什么不能直接用 |
|---|---|
| 最早开始 | 一个很长的会议可能挡住许多短会议。 |
| 持续时间最短 | 例如 [2,4) 比 [0,3)、[3,6) 短,却同时挡住后二者,只能选 1 场而最优 2 场。 |
| 带收益时仍按最早结束 | 收益目标不同:两个低收益会议未必比一个高收益会议更好。加权区间调度通常考虑 DP。 |
💬 面试追问:最小生成树也是贪心,为什么安全?
Prim 可用割性质解释:跨越当前点集与其余顶点的最小权边,可安全加入某棵最小生成树。Kruskal 选不成环的最小边也有相应安全性论证。不能从"贪心"一词推导任意局部规则都正确。
💬 面试追问:贪心与 DP 怎么选?
先看局部选择能否被证明总可纳入某个最优解。证明不了时尝试找反例;若不同选择要比较多种后续结果且子问题可复用,DP 可能更合适。不是用代码长短判断。
8.5 分治与基本排序:稳定性要落到操作上
对照排序笔记,区分平均、最坏与额外空间。
📎 对应源码:八大排序算法/源.cpp · 八大排序算法/排序.cpp
分治是"划分 → 解决子问题 → 合并"。归并排序先切半,分别排好,再线性合并;快速排序先按基准分区,再递归处理两侧。二者都用分治,但做主要工作的位置不同。DP 的子问题常有重叠,而归并的两半互不重叠。
排序总表(常见实现)
| 算法 | 最好 / 平均 / 最坏时间 | 额外空间 | 稳定性 |
|---|---|---|---|
| 插入 | O(n) / O(n²) / O(n²) | O(1) | 稳定:相等时不越过前面的记录。 |
| 冒泡(提前退出版) | O(n) / O(n²) / O(n²) | O(1) | 稳定:只在严格逆序时交换。 |
| 选择 | O(n²) / O(n²) / O(n²) | O(1) | 通常不稳定,远距离交换可能跨过相等元素。 |
| 希尔 | 依赖增量序列,没有统一的 O(n^1.3);固定 5,3,1 不能保证次二次最坏界 | O(1) | 通常不稳定。 |
| 堆排序 | 通常 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(log n) 调用栈,总 O(n) | 合并相等键先取左侧时稳定。 |
| 快速排序 | O(n log n) / O(n log n) / O(n²) | 普通递归平均 O(log n),最坏 O(n) | 通常不稳定。 |
| 计数排序(补充) | O(n+K),K 为键值范围 | 稳定输出版 O(n+K) | 可稳定;只计数重建值的写法不能描述附带记录的稳定性。 |
| LSD 基数排序 | O(d(n+b)),d 为位数,b 为基数 | O(n+b) | 每一轮按位分配 / 收集稳定时整体稳定。 |
🛠️ 原稿表头把几个稳定性写反了
原稿开头将冒泡列为不稳定,又将选择、希尔列为稳定;后面的具体实现注释有些已写对。按常见原地实现记:快、选、堆、希不稳定;冒泡、插入、归并在正确处理相等元素时稳定。9 月 21 日的面试复盘里 Q15 也涉及这个追问。
八大排序算法/排序.cpp · L6--9
text
稳定性反例:记录只按数值排序,字母标记原身份
原序列 [2a, 2b, 1]
选择排序把最小的 1 与第一个 2a 交换 → [1, 2b, 2a]
相等键 2 的相对次序改变,所以此实现不稳定。
归并:相等时取左边,递归区间严格缩小
📂 展开:半开区间归并排序
面试延伸 · 示例代码
cpp
// 半开区间 [left,right),tmp 与 a 等长。
void MergeRange(std::vector<int>& a, std::vector<int>& tmp, int left, int right) {
if (right - left <= 1) return;
const int mid = left + (right - left) / 2;
MergeRange(a, tmp, left, mid);
MergeRange(a, tmp, mid, right);
int i = left, j = mid, k = left;
while (i < mid && j < right)
tmp[k++] = a[i] <= a[j] ? a[i++] : a[j++]; // 相等时先取左边。
while (i < mid) tmp[k++] = a[i++];
while (j < right) tmp[k++] = a[j++];
for (int p = left; p < right; ++p) a[p] = tmp[p];
}
void MergeSort(std::vector<int>& a) {
std::vector<int> tmp(a.size());
MergeRange(a, tmp, 0, static_cast<int>(a.size()));
}
每层合并总 O(n),有 O(log n) 层,得到 O(n log n)。归并代码用<=优先取左边,符合稳定要求;建议统一中点公式left+(right-left)/2,不要把 right/2+left/2 当作通用等价式。
快速排序:三路分区处理大量重复值
📂 展开:小于 / 等于 / 大于 pivot 的三路快排
面试延伸 · 示例代码
cpp
// 三路快排,闭区间 [left,right]。本例选中间位置的值,不保证最坏 O(n log n)。
void Quick3(std::vector<int>& a, int left, int right) {
if (left >= right) return;
const int pivot = a[left + (right - left) / 2];
int lt = left, i = left, gt = right;
while (i <= gt) {
if (a[i] < pivot) std::swap(a[lt++], a[i++]);
else if (a[i] > pivot) std::swap(a[i], a[gt--]); // 新换来的值尚未检查。
else ++i;
}
Quick3(a, left, lt - 1);
Quick3(a, gt + 1, right);
}
等于区间不再递归,全相等数组一次划分就结束。随机基准可以改善期望表现,仍非最坏保证;工程排序可通过递归深度监测和堆排序回退控制最坏代价。标准库std::sort不保证稳定,常见实现是 introsort;标准规定复杂度和行为,不强制唯一的内部实现。
🛠️ 非递归快排里的两个具体问题
Partition 结束缺少把保存的 pivot 写回 arrleft,可能丢失基准值;Quick_Sort_Non_Recursion 的 if (left < mid - 1); 末尾多了分号,使后面的压栈无条件执行,可能压入无效区间。初始还应使用传入的 start,而不是固定压入 0。
八大排序算法/排序.cpp · L426--485
堆:复习排序时顺便准备 Top K
数组零基下标 i 的孩子为 2i+1、2i+2。自底向上建堆是 O(n),因为大多数结点靠近叶子、下沉距离短;不是把每个结点都按 log n 粗略相乘。取最大的 K 个值可维护容量 K 的小根堆,堆顶是当前入选门槛,时间 O(n log K)、空间 O(K);若还要有序输出,再处理结果顺序。
💬 面试追问:基数排序为什么需要每一轮稳定?
低位到高位排序时,高位相等的记录必须保持上一轮按低位形成的相对次序,才能把已经排好的信息保留下来。用十个 FIFO 队列可以保证同一桶内的顺序;当前数字提取实现还需限定非负整数并处理空数组。
💬 面试追问:比较排序为什么一般不能比 O(n log n) 更快?
不同键的 n! 种排列需要决策树区分,二元比较树高度至少 log₂(n!)=Ω(n log n)。计数与基数排序利用键范围或数字表示,不在纯比较模型内,因此不矛盾。
九、面试整合
9.1 面试整合:把数据结构与算法连到场景
按需求选择,再说明限制,不只报一个名称。
| 场景 | 可选结构 / 算法 | 面试时继续补充 |
|---|---|---|
| LRU 缓存 | 哈希表 + 双向链表 | 哈希快速定位,链表把命中结点移到头、淘汰尾;同一次修改同步两者。容量 0、重复 key、并发要单独考虑。 |
| 排行榜:按分数查询区间 / 按成员更新 | 跳表 + 哈希映射,或其他有序索引 | 跳表处理有序范围,哈希处理成员定位;相同分数需要二级排序键,改分数要更新有序位置。 |
| 数据库范围查询 | B+ 树 | 页内多路索引降低高度,叶子链支持连续扫描;节点分裂、页大小和缓存影响实际 I/O。 |
| 动态磁盘点查桶溢出 | 可扩展哈希 | 局部分桶,目录必要时增长;目录大小、冲突上限与持久化属于后续工程问题。 |
| 缓存不存在的 key 被反复请求 | 布隆过滤器 / 缓存空值 | 过滤器阳性还需查真实数据;新增 key 同步、过期、重建不能忽略。 |
| 搜索建议、词典前缀 | Trie / 压缩 Trie | 定位前缀后还要枚举候选;热度排序需要额外信息。 |
| 内存中按键有序遍历 | AVL / 红黑树 | 权衡高度约束与更新成本;std::map 常见红黑树实现,但标准不指定必须是哪种树。 |
| 单模式长文本匹配 | KMP | O(n+m) 基线、空模式约定、重叠匹配;多模式场景可再了解 Aho--Corasick。 |
| 最小可行处理速度 | 答案二分 | 可行性必须单调,搜索区间含解,注意上取整与整数范围。 |
把"数据库用 B+ 树"再讲深一层
以常见 InnoDB 索引组织为例:聚簇索引叶子保存行数据;二级索引叶子通常保存二级键和主键值。二级索引覆盖所需列时可直接返回,否则可能按主键再定位行,称为回表。不能一概而论为所有查询固定"查两次",也不能把这个具体引擎的实现扩展到所有数据库。
每题给自己 90 秒:定义 → 操作 → 复杂度 → 边界
💬 面试追问:如何回答"你掌握这些结构,举一个组合使用的例子"?
可选择 LRU:我用哈希表把 key 映射到双向链表结点,链表按最近使用顺序维护;get 命中后移到头,put 超容量时删尾并删哈希项。平均每次 O(1),这个结论依赖哈希平均查找和已知结点的链表摘接。若只是学习实现,应明确说是练习案例,再展示边界测试。
💬 面试追问:怎么讲你自己发现的代码问题?
可选 KMP 的 -1 哨兵:旧实现失配后 j 可能为 -1,下一轮仍直接访问 patternj。先给触发例 text="a", pattern="b";说明越界原因;统一改为 pi 前缀函数写法;再测试空串、单字符、重复前缀与未匹配。这比只说"加了判断"更完整。
7 次复习,每次都留下可检验的结果
| 次序 | 重点 | 验收 |
|---|---|---|
| 1 | 链表、栈队列 + LRU 思路 | 画空与单结点删除;独立写反转、括号匹配。 |
| 2 | BST / AVL / 红黑树 / B 与 B+ | 画一次旋转、一次分裂;说清为何选不同树。 |
| 3 | 跳表 / 可扩展哈希 / Bloom / Trie | 手画共享目录项;算空间预算;区分 app 与 apple。 |
| 4 | KMP + 二分 | 手算 ABABC 的 pi;写 lower_bound 并解释每个边界。 |
| 5 | 双指针 + 窗口 | 跑 abba 和 1,-1,5 反例,解释适用前提。 |
| 6 | DP + 贪心 | 硬币最少数、0/1 背包、区间调度各一题,先写状态或证明。 |
| 7 | 排序 + 综合口述 | 现场讲稳定性;用带身份的相等键举反例;完成扩展自测。 |
配套示例代码
配套的独立 C++17 示例程序包括 Trie、KMP、二分、相向双指针、两类窗口、硬币 DP、0/1 背包、区间贪心、归并与三路快排。该程序已通过本机 MSVC 编译与运行验证,包含空输入、重复值和随机对照检查;它是练习模板,没有实现所有新增结构的完整工程版本。
📥 配套示例代码 :
interview_examples.cpp(可独立编译运行,正文中的算法片段均取自该文件)
Visual Studio 开发者命令提示符 · 独立编译
text
cl /std:c++17 /EHsc /W4 /utf-8 interview_examples.cpp
interview_examples.exe
✅ 复习完成的标准
合上文档还能讲清适用条件,手算一次过程,写出核心逻辑,并给出会使错误实现失败的输入。说不清的条目回到定义与例子,优先把已经写在简历里的技能练扎实。
十、自测
10.1 自测与复习安排
先回答,再查看解析;做错的题回到对应原稿。
28 道自测题覆盖基础实现与简历延伸的关键边界。建议先盖住答案自己说一遍,再对照解析;做错的题回到对应小节。
01. 顺序栈的 top 指向下次写入位置,入栈 a、b 后,栈顶表达式是?
- A. *top
- B. *(top - 1)
- C. *basd
✅ 答案:B。top 比最后一个有效元素多走一位,所以用 *(top - 1)。
02. 原稿 OK = 0,修正版 Pop 返回状态码。正确判断成功的方式是?
- A. if (Pop(&s, &v))
- B. if (Pop(&s, &v) == OK)
- C. if (v != 0)
✅ 答案:B。状态码与元素值分离;0 是成功,不应按 bool 判断。
03. 容量 5 的计数式循环队列,front=rear=2,size=5,此时?
- A. 空
- B. 满
- C. 只有 2 个元素
✅ 答案:B。计数法由 size 判空满,同样的 front/rear 可表示空或满。
04. 删除链表 prev 后继,哪一步应最先做?
- A. free(prev->next)
- B. prev->next = prev->next->next
- C. 保存 victim = prev->next
✅ 答案:C。先保存目标,重连后再释放,才能避免丢失指针或释放错对象。
05. 普通二叉树的左右子树必须满足什么数量关系?
- A. 结点数之差不超过 1
- B. 高度差不超过 1
- C. 不要求上述平衡关系
✅ 答案:C。普通二叉树可以退化为链。AVL 才要求每个结点左右高度差不超过 1。
06. 依次向空 BST 插入 1、2、3、4,最坏查找复杂度是?
- A. O(1)
- B. O(log n)
- C. O(n)
✅ 答案:C。树退化为右链。普通 BST 的操作是 O(h)。
07. AVL 插入 30、10、20 后是哪种失衡,如何处理?
- A. LL,只右旋根
- B. LR,先左旋左孩子,再右旋根
- C. RL,先右旋右孩子,再左旋根
✅ 答案:B。从 30 看插入路径先左后右,所以是 LR;失衡点的 BF 为 2。
08. 红黑树中"根叶黑"的叶子是?
- A. 所有存数据的末端结点
- B. NIL 空指针叶子
- C. 高度最小的数据结点
✅ 答案:B。实际的数据叶子可以是红色;NIL 按黑色参与性质定义。
09. 按最大孩子数定义,5 阶 B 树非根结点最少几个键?
- A. 1
- B. 2
- C. 3
✅ 答案:B。ceil(5/2)-1 = 2;非根内部结点最少 3 个孩子。
10. BFS 为避免重复入队,通常什么时候标记 visited?
- A. 入队时
- B. 出队后
- C. 遍历全部结束时
✅ 答案:A。入队即标记,多个已发现顶点不会反复把同一个邻居加入队列。
11. Kahn 算法最后只输出了 V-2 个顶点,说明?
- A. 该图一定无向
- B. 有环,无法得到完整拓扑序
- C. 入度数组不需要更新
✅ 答案:B。完整处理后仍有顶点无法成为入度 0,说明剩余依赖中含环。
12. 一个 BinFindValue 每次先 BubbleSort 再二分,整体时间复杂度是?
- A. O(log n)
- B. O(n)
- C. O(n²)
✅ 答案:C。原稿冒泡排序是 O(n²),它主导总时间;返回值还是排序后的下标。
13. 普通随机跳表的查找复杂度,哪项更准确?
- A. 一定最坏 O(log n)
- B. 合理随机晋升下期望 O(log n),最坏 O(n)
- C. 一定 O(1)
✅ 答案:B。随机层次提供期望效率,不提供确定性的最坏对数界。
14. 可扩展哈希 gd=3,某桶 ld=1,共有多少目录项指向它?
- A. 1
- B. 2
- C. 4
✅ 答案:C。共享项数为 2^(gd-ld)=4。
15. 可扩展哈希桶满,且 ld < gd,应先做什么?
- A. 无条件复制所有记录
- B. 分裂该桶并重定向相关目录项
- C. 改成一致性哈希环
✅ 答案:B。目录已有足够的区分位,无需增加全局深度。
16. 标准布隆过滤器查询为阳性,可以断言什么?
- A. 当前数据库一定存在
- B. 可能属于已加入的集合,必要时再核验
- C. 一定没有重复元素
✅ 答案:B。多元素位重叠会造成假阳性;数据库同步也是另一个问题。
17. 布隆过滤器 m 固定,加入的不同元素越来越多,通常会怎样?
- A. 假阳性率升高
- B. 假阳性率必降为 0
- C. 每个查询变成精确查找
✅ 答案:A。位逐渐饱和,未插入元素的所有查询位同时为 1 的机会升高。
18. Trie 中只插入 apple,查询 app,正确结果是?
- A. 精确存在,前缀也存在
- B. 精确不存在,前缀存在
- C. 两者都不存在
✅ 答案:B。路径与单词结束标记是不同条件。
19. 模式 ABABC 的 pi 前缀函数数组是?
- A. 0,0,1,2,0
- B. -1,0,0,1,2
- C. 0,1,2,3,4
✅ 答案:A。本文 pii 描述 P0...i 的最长相等真前后缀长度;不要混用 next 定义。
20. KMP 已匹配 j>0 个字符发生失配,本文的回退规则是?
- A. i--
- B. j = pij-1,继续比较当前 texti
- C. j 永远置 0
✅ 答案:B。复用已匹配部分的相等前后缀,不回退主串位置。
21. 对 1,2,2,5 求 lower_bound(2),返回?
- A. 1
- B. 2
- C. 3
✅ 答案:A。返回第一个 >=2 的下标,零基下标为 1。
22. 无重复窗口处理 abba 的最后一个 a,left 应当?
- A. 回退到 1
- B. 保持在 2
- C. 回退到 0
✅ 答案:B。上次 a 已在当前窗口外,left 只能前进,用 max 维护。
23. 正数最短和窗口直接用于 1,-1,5、target=5,会有什么风险?
- A. 一定更快
- B. 没有任何风险
- C. 可能漏掉单元素 5
✅ 答案:C。移除正数后和暂时不足,但继续移除负数会增加和,破坏原窗口的收缩依据。
24. 0/1 背包压成一维,为什么容量倒序?
- A. 为了保持物品按价值有序
- B. 防止同一物品在这一轮重复使用
- C. 所有 DP 都必须倒序
✅ 答案:B。倒序保留 dpj-w 为上一轮状态;完全背包则常用正序。
25. 面额 1,3,4 凑 6 的最少枚数是?
- A. 3
- B. 2
- C. 1
✅ 答案:B。3+3 只需 2 枚;每次取最大面额得到 4+1+1 并非最优。
26. 求最多不重叠会议,经典贪心排序依据是?
- A. 最早结束
- B. 最早开始
- C. 时长最短
✅ 答案:A。交换论证可证明,最早结束为剩余会议保留了至少同样多的空间。
27. 常见实现中,哪组都不稳定?
- A. 冒泡、插入、归并
- B. 快速、选择、堆、希尔
- C. 所有排序都不稳定
✅ 答案:B。远距离移动可能改变相等键的相对顺序;是否稳定最终取决于具体实现。
28. 自底向上建立二叉堆的时间是?
- A. O(n)
- B. O(n log n) 必然且不可改善
- C. O(log n)
✅ 答案:A。不同高度结点的数量不同,大部分结点下沉距离很短,总代价 O(n)。
四道动手题
1. 修正 RemoveElem:找到才删除,没找到不改变表
顺序查找,命中时调用 ErasePos(plist, i) 并返回结果;循环结束才返回 false。用 1,2,3 删 2,再删不存在的 9:结果都应是 1,3,后一次返回 false。
2. 反转单链表:画出 prev、cur、next 每轮的变化
使用 prev=NULL、cur=head->next;每轮先保存 next=cur->next,再令 cur->next=prev,最后 prev=cur、cur=next。结束后 head->next=prev。测试空表、单结点和三个结点。时间 O(n),额外空间 O(1)。这是基于单链表的补充练习。
3. 删除 BST 的根 50(两个孩子):应更新哪些指针?
先用右子树最小值 60 覆盖根键,再摘除原 60。若原 60 有右孩子,让其接替,更新该孩子 parent;若无孩子,父指向它的链接变空。实际使用前驱 / 后继替换时,根结构体不一定被释放。
4. 从有环图到完整遍历,visited 如何复用?
对每个未访问顶点启动 BFS / DFS,但共用同一个 visited,才能遍历所有连通分量且不重复。Kahn 的入度是另一种状态,不能用 visited 替代;它需要统计还没被移除的前驱数量。
可运行示例
配套的独立 C++17 示例程序包含:顺序栈扩容与清空复用、连续重复值删除、循环队列回绕与判满、链队列单元素边界、后序遍历和树查找。正文中标为"修正版示例"的代码与之一致。
📥 配套示例代码 :
review_examples.cpp(可独立编译运行,正文中的基础结构片段均取自该文件)
该独立示例已用本机 MSVC / C++17 编译并运行验证通过。Visual Studio:新建独立控制台项目,使用此文件作为唯一带 main 的源文件;或在 x64 Native Tools 命令提示符中执行下方命令。断言用于示例验证,运行时不要定义 NDEBUG。
本地编译命令 · Visual Studio 开发者命令提示符
text
cl /std:c++17 /EHsc /W4 /utf-8 review_examples.cpp
review_examples.exe
三轮复习建议
| 轮次 | 内容 | 完成标准 |
|---|---|---|
| 第 1 轮 · 基础 | 线性表、栈、队列、指针 | 不看答案写出 Push / Pop / Enqueue / Dequeue,并覆盖空和单元素。 |
| 第 2 轮 · 树 | 遍历、BST、AVL、红黑树、B / B+ 树 | 手算遍历;画出四种 AVL 旋转;解释红黑树与 B 树调整的触发条件。 |
| 第 3 轮 · 图与回顾 | BFS / DFS、MST、拓扑排序、错题 | 手算九宫格;说清三类图算法目标;重新完成扩展后的自测题。 |
🔑 答案速查:B、B、B、C、C、C、B、B、B、A、B、C、B、C、B、B、A、B、A、B、A、B、C、B、B、A、B、A
附录:源码与笔记索引
附录 A:复习涉及的源码 / 笔记文件清单
35 个文件快照 · 正文中提到文件名的地方均可与下表对照。下表只保留文件名、行数与所在目录。
| # | 文件 | 行数 | 涉及章节 |
|---|---|---|---|
| 1 | Dequeu.h |
37 | 栈和堆列 |
| 2 | dequeue双向循环顺序队列.cpp |
218 | 栈和堆列 |
| 3 | doubly circular linked list queue.h |
39 | 栈和堆列 |
| 4 | Linkstack.h |
42 | 栈和堆列 |
| 5 | List.h |
41 | 栈和堆列 |
| 6 | listqueue.cpp |
129 | 栈和堆列 |
| 7 | listqueue.h |
26 | 栈和堆列 |
| 8 | nonelinkstack.h |
1 | 栈和堆列 |
| 9 | queue.cpp |
119 | 栈和堆列 |
| 10 | queue.h |
26 | 栈和堆列 |
| 11 | Seqlist.cpp |
411 | 栈和堆列 |
| 12 | Seqlist.h |
61 | 栈和堆列 |
| 13 | stack.h |
43 | 栈和堆列 |
| 14 | stack链表栈.cpp |
167 | 栈和堆列 |
| 15 | stack顺序栈.cpp |
140 | 栈和堆列 |
| 16 | 双向循环链表之队列doubly circular linked list queue.cpp |
245 | 栈和堆列 |
| 17 | 栈和队列.cpp |
271 | 栈和堆列 |
| 18 | 链表改变的链表栈.cpp |
237 | 栈和堆列 |
| 19 | AVLTree.cpp |
277 | 进阶数据结构1 |
| 20 | AVLTree.h |
99 | 进阶数据结构1 |
| 21 | BInary_Tree.cpp |
315 | 进阶数据结构1 |
| 22 | Binary_Tree.h |
107 | 进阶数据结构1 |
| 23 | BPlus.h |
65 | 进阶数据结构1 |
| 24 | BSTtree.cpp |
245 | 进阶数据结构1 |
| 25 | BSTtree.h |
47 | 进阶数据结构1 |
| 26 | Btree.cpp |
489 | 进阶数据结构1 |
| 27 | Btree.h |
133 | 进阶数据结构1 |
| 28 | Map.cpp |
269 | 进阶数据结构1 |
| 29 | Map.h |
0 | 进阶数据结构1 |
| 30 | RBTree.cpp |
640 | 进阶数据结构1 |
| 31 | RBtree.h |
161 | 进阶数据结构1 |
| 32 | BF_ KMP算法/源2.cpp |
179 | BF_ KMP算法 |
| 33 | 八大排序算法/源.cpp |
396 | 八大排序算法 |
| 34 | 八大排序算法/排序.cpp |
588 | 八大排序算法 |
| 35 | 哈希表/源.cpp |
418 | 哈希表 |
写在最后
这份手册的骨架来自一套完整的 C/C++ 数据结构练习工程(35 个源码 / 笔记文件):
- 基础项目:顺序表、带头结点单链表、顺序栈、链栈、顺序队列、链队列、循环双端队列、双向循环链表;
- 进阶项目:二叉树遍历与构造、BST、AVL、红黑树、B 树、B+ 树,以及图的遍历、最小生成树、拓扑排序;
- 补充练习:KMP、八大排序、哈希表;跳表、可扩展哈希、Trie 与部分算法模板属于本文延伸讲解。
复习时先说明前提,再给复杂度。原实现用于对照,修正版用于理解------顺序不要反过来,否则很容易把"背下来的结论"当成"自己会的东西"。