数据结构与算法复习手册:从写过,到讲清楚

数据结构与算法复习手册:从写过,到讲清楚

前段时间把自己练手的一整套 C/C++ 数据结构代码翻出来重读了一遍:顺序表、带头结点单链表、顺序栈、链栈、循环队列、链队列、双向循环链表,再往上到二叉树、BST、AVL、红黑树、B 树、B+ 树、图的遍历,以及后来单独练的 KMP、八大排序和哈希表,一共 35 份源码与笔记。

重读下来最强烈的感受是:写得出,不等于讲得清。代码能跑通,但一被追问第二层就断------为什么这里必须先保存指针?清空之后还能不能 Push?普通 BST 为什么最坏是 O(n)?B+ 树到底比 B 树多在哪?所以这次不是再写一遍代码,而是把每一处"能跑但经不起问"的地方挑出来,对照修正,整理成一份可以反复复习的手册。

适合谁看:准备 C++ 后端方向校招 / 实习,已经会写这些结构,但"名词题第一句说不准""一被追问第二层就没词"的同学。

食用方式 :每个知识点都按「定义讲得准 → 能手算一次 → 能写核心代码 → 能说清复杂度的前提 → 能比较替代方案」来组织。文中 🛠️ 标记的地方是原实现里容易写错、面试却最常追问的细节,建议先盖住答案自己说一遍再往下看。面试里真正决定分数的,不是"你知不知道",而是"你能不能在三个句子里把它说准"。


目录


一、开始之前:范围、约定与复习方法

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 的操作约束不同
怎么用这份文档
  1. **第一遍:**先读绿色规则,操作演示,再盖住答案手算。
  2. **第二遍:**读原稿与纠错,特别留意空结构、一个元素、满容量、删除根结点。
  3. **第三遍:**独立写出入栈、循环入队、中序遍历、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--74 stack链表栈.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--13 listqueue.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--35 Seqlist.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--14 BSTtree.cpp · L44--53 BSTtree.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--30 AVLTree.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--47 RBTree.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--28 Btree.cpp · L67--108 Btree.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--49 BF_ 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 与部分算法模板属于本文延伸讲解。

复习时先说明前提,再给复杂度。原实现用于对照,修正版用于理解------顺序不要反过来,否则很容易把"背下来的结论"当成"自己会的东西"。

相关推荐
神仙别闹几秒前
基于C++实现的贪吃蛇游戏平台
java·c++·游戏
ACP广源盛139246256732 分钟前
Type‑C 扩展坞方案选型笔记|国产 DP1.4 转 HDMI2.0 桥接芯片 GSV2201S @ACP评估
c语言·开发语言·笔记·硬件架构·硬件工程·国产芯片
CQU_JIAKE5 分钟前
10.9【A】
数据结构
第25小时记录13 分钟前
数据结构与算法 -第 3 章 常用算法-动态规划
数据结构·python
niucloud-admin18 分钟前
JAVA V6 多商户商城 开发文档——JAVA服务端
java·开发语言
轻舟技术A33 分钟前
Pi 的 Rust 实现来了:rpi,不止一个终端 Agent
开发语言·后端·rust
王老师青少年编程36 分钟前
2026年6月GESP真题及题解(C++三级): 选择题和判断题(题解)
c++·真题·gesp·答案·csp·三级·信奥赛
C++ 老炮儿的技术栈37 分钟前
static的作用
c语言·c++·人工智能·单片机·c