CHAPTER 21 / 数据结构与算法
递归、树与图:沿着依赖和边访问
有环的图为什么不能像树一样无条件递归下去?
这一章要弄清楚
- 写出递归的缩小量与基础情况
- 用 visited 控制图遍历
- 用 BFS 求无权最短路径并解释复杂度
先备知识:指针:地址、空值与有效访问 / 真实资源与RAII:独占拥有 / 测试与调试:独立定位失败 / 数组、哈希与双指针:重复工作从哪里删掉
ISO C++20;macOS 或 Linux;仅标准库;示例是独立教学程序,非学习者验收。
递归是把问题交给更小的同类问题
计算一棵树的节点数量,可以写成当前节点一个,加左子树数量,再加右子树数量。空树返回零,这是基础情况。递归正确性需要两件事:假设更小子树的答案正确,组合能得到当前树答案;每次递归确实进入更小结构,最终碰到空树。只有第一条而没有第二条,会得到看似合理却永远结束不了的程序。
函数调用会保存返回后还需做的工作,例如左子树算完还要算右子树。这个待处理过程可以由调用栈维护,也可以显式写成栈结构。递归不是免费,深度很大的链状树可能耗尽线程栈;尾递归优化也不是 C++ 必须提供的保证。数据深度不受控时,显式栈通常更便于限制内存和处理错误。
树的唯一父路径在一般图里不存在
图可用邻接表表示:每个顶点保存邻居列表。无向边通常在两个方向各记录一次,所以“0 连 1”会让 1 又看到 0。直接沿每条边递归而没有 visited,会在环或回边上重复访问。标记应在进入或入队时设置,保证后续看到同一顶点时知道它已经被发现。
深度优先搜索先沿一条路径深入,再回到尚未探索分支,适合可达性、连通分量和许多依赖分析。广度优先搜索用队列按距离一层层扩展,适合所有边等权的最短边数。两者都能遍历图,但不能仅因为 DFS 先到达目标,就声称那条路径最短。
BFS 的最短性来自层次顺序
起点距离零,邻居距离一,再下一层距离二。队列保证距离较小的已发现节点先出队。首次发现某节点时,它的前驱来自最早可到达的层,因此当前距离是最少边数。把距离设置和入队放在一起,也防止多个父节点在它出队前重复将它加入队列。
以边 0–1、0–2、1–3、2–3 的无向图为例,先发现 1 和 2,它们距离一;处理 1 时首次发现 3,距离二;处理 2 时发现 3 已访问,不再入队。无论邻居迭代顺序如何,最短距离相同,但选中的具体父路径可能不同。测试应区分固定答案距离和可有多个合法答案的路径。
边界和表示决定真实成本
邻接表遍历在合理的 visited 和队列操作下,时间 O(V+E),空间包含图本身及额外 O(V) 状态。无向图存两次边,只改变常数。邻接矩阵则可能为了寻找邻居扫描整行,使整体到平方级。复杂度需要与表示一起解释,不能只背一个公式。
空图、孤立顶点、断开分量、自环和重复边都值得测试。如果需要整个图的所有连通分量,应对每个尚未访问顶点启动一次搜索,而不仅从顶点零跑一次。外部输入必须检查顶点编号;示例用 at 访问并使用固定合法邻接表,让非法编号不会默默成为裸越界。
把图模型接到系统问题
构建依赖、任务调度和设备通信都可出现图结构,但边的意义不同。拓扑排序要求有向无环图,Dijkstra 需要适用的非负权重条件,简单 BFS 只保证等权边的最少步数。先把真实系统里的节点、边和权重定义准确,再选择算法。面试中能指出模型前提,比把所有路径题都套 BFS 更重要。
固定拓扑上的 BFS 发现顺序
节点和边始终留在原位置。箭头表示从起点按层发现的方向;例题邻接表还包含反向边。
1、2 在首次入队时标记 d=1;孤立点4仍未访问。
首次发现3,赋 d=2 并入队。0不消失,所有顶点坐标仍固定。
2看到3已经入队,不重复添加;处理3后队列为空。最终距离0、1、1、2、-1。
阅读完整推演文字
- 起点
0:d=0 queued;1:unseen;2:unseen;4:unseen;3:unseen
节点和边始终留在原位置。箭头表示从起点按层发现的方向;例题邻接表还包含反向边。
- 展开 0
0:done;1:d=1 queued;2:d=1 queued;4:unseen;3:unseen
1、2 在首次入队时标记 d=1;孤立点4仍未访问。
- 展开 1
0:done;1:done;2:d=1 queued;4:unseen;3:d=2 queued
首次发现3,赋 d=2 并入队。0不消失,所有顶点坐标仍固定。
- 展开 2 并结束
0:done;1:done;2:done;4:unreachable;3:done / d=2
2看到3已经入队,不重复添加;处理3后队列为空。最终距离0、1、1、2、-1。
跟着例子,走完一遍
树递归计数与高度
根 1 的左孩子 2、右孩子 3;空子树高度 0。
- 空节点返回 count=0,height=0。
- 两个叶子各 count=1,height=1。
- 根合并得到 count=3,height=2。
#include <algorithm>
#include <iostream>
#include <memory>
struct Node {
int value;
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
explicit Node(int v) : value(v) {}
};
int count(const Node* node) { return node ? 1 + count(node->left.get()) + count(node->right.get()) : 0; }
int height(const Node* node) {
return node ? 1 + std::max(height(node->left.get()), height(node->right.get())) : 0;
}
int main() {
auto root = std::make_unique<Node>(1);
root->left = std::make_unique<Node>(2);
root->right = std::make_unique<Node>(3);
if (count(nullptr) != 0 || height(nullptr) != 0) return 1;
std::cout << "count=" << count(root.get()) << " height=" << height(root.get()) << '\n';
}count=3 height=2
此例高度按节点层数,若按边数定义会不同,必须先约定。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 17-a.cpp -o example && ./example预期标准输出:
count=3 height=2
有环图的 BFS 距离
邻接表包含菱形环与孤立顶点 4,从 0 出发。
- 0 入队并标记距离 0。
- 发现 1、2,距离为 1。
- 3 首次由 1 发现,距离 2;4 保持 -1。
#include <iostream>
#include <queue>
#include <vector>
std::vector<int> bfs(const std::vector<std::vector<std::size_t>>& graph, std::size_t start) {
std::vector<int> distance(graph.size(), -1);
distance.at(start) = 0;
std::queue<std::size_t> pending;
pending.push(start);
while (!pending.empty()) {
const auto v = pending.front(); pending.pop();
for (const auto next : graph.at(v)) {
if (distance.at(next) != -1) continue;
distance[next] = distance[v] + 1;
pending.push(next);
}
}
return distance;
}
int main() {
const std::vector<std::vector<std::size_t>> graph{{1,2},{0,3},{0,3},{1,2},{}};
const auto d = bfs(graph, 0);
if (d != std::vector<int>{0,1,1,2,-1}) return 1;
for (std::size_t i = 0; i < d.size(); ++i) std::cout << (i == 0 ? "" : " ") << d[i];
std::cout << '\n';
}0 1 1 2 -1
入队时标记防重复;-1 明确表示不可达,不与起点零距离混淆。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 17-b.cpp -o example && ./example预期标准输出:
0 1 1 2 -1
图中沿边递归却不标记
无向边让两个节点相互回访,环上无法结束;只记录父节点还不能解决任意图全部重复访问问题。
修正思路:使用 visited 或距离数组,在发现节点时就标记;对整个图按未访问起点逐分量遍历。
轮到你动手
先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。
练习 1
给 BFS 增加 parent 数组,如何还原 0 到 3 的最短路径?
给我一点提示
- 发现 next 时保存 parent[next]=v。
- 从终点反向追到起点再反转。
查看答案与推理
本例按给定邻居顺序会得到 parent[3]=1、parent[1]=0,逆序收集 [3,1,0] 后反转为 [0,1,3]。若终点不可达先返回无路径;另一顺序可能得到 [0,2,3],距离同样最短。
练习 2
一条十万节点链用递归 DFS 有何风险,如何改为显式栈?
给我一点提示
- 递归深度等于链长。
- 待处理顶点可以保存在 vector 或 stack。
查看答案与推理
可能耗尽调用栈。用显式栈保存待访问节点,压栈时或取出时按明确规则标记;若需模拟递归后序,还需记录节点的处理阶段,而非只存顶点编号。
把理解说出来
先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。
递归正确性需要哪两个要素?
参考回答 / English answer
基础情况和严格缩小的问题保证终止;对子问题正确性的归纳说明组合结果正确。
The recursion needs a base case and a decreasing measure for termination. An inductive argument explains why correct subproblem results combine into the correct answer.菱形图中 3 有两个父候选,会入队两次吗?
参考回答 / English answer
正确实现入队时设置距离,因此第二次发现看到已标记,不再入队。
Marking a vertex when it is enqueued prevents duplicate discovery. The second incoming edge sees that the vertex already has a distance.DFS 第一次找到目标就一定是最短路吗?
参考回答 / English answer
不是,DFS 优先深入,与路径边数顺序无关;无权最短边数用 BFS。
Depth-first traversal does not explore paths in increasing length. BFS provides the shortest number of edges when the edges have equal weight.BFS 为什么不能直接解决任意带权最短路?
参考回答 / English answer
层数表示边数而非权重总和,不同边权会破坏首次发现最优;需适合权重条件的算法。
BFS orders paths by edge count, not total weight. Unequal weights require an algorithm whose ordering respects accumulated cost.邻接表 DFS/BFS 为什么 O(V+E)?
参考回答 / English answer
每顶点最多发现处理一次,每条邻接记录检查一次;额外 visited/队列栈为 O(V)。
Each vertex is discovered at most once, and each adjacency entry is examined once. The extra traversal state is linear in the number of vertices.遍历整个不连通图要补什么?
参考回答 / English answer
外层循环所有顶点,对未访问者启动搜索,得到各分量;单一起点只覆盖可达部分。
Loop over all vertices and start a search from each unvisited vertex. A single starting point only reaches its own connected region.继续查证
- MIT 6.006 · Breadth-First Search ↗
Lecture 9:BFS、最短边数与父指针
- Princeton Algorithms · Undirected Graphs ↗
Depth-first search、Breadth-first search、Connected components
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。
接着看已有的图解
- 原有 C/C++ 编程题库 ↗
按当前缺口选择一题;基础练习仍使用标准 C++20,不按旧站点完成标记解锁 G0。
这些资料按主题补充本章内容。