CHAPTER 21 / 数据结构与算法

递归、树与图:沿着依赖和边访问

有环的图为什么不能像树一样无条件递归下去?

阅读与推演约 60 分钟练习时间另计

这一章要弄清楚

  • 写出递归的缩小量与基础情况
  • 用 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 发现顺序

0d=0 queued1unseen2unseen4unseen3unseen01 / 04 · TREE0d=0 queued1unseen2unseen4unseen3unseen01 / 04 · TREE
起点

节点和边始终留在原位置。箭头表示从起点按层发现的方向;例题邻接表还包含反向边。

1 / 4
阅读完整推演文字
  1. 起点

    0:d=0 queued;1:unseen;2:unseen;4:unseen;3:unseen

    节点和边始终留在原位置。箭头表示从起点按层发现的方向;例题邻接表还包含反向边。

  2. 展开 0

    0:done;1:d=1 queued;2:d=1 queued;4:unseen;3:unseen

    1、2 在首次入队时标记 d=1;孤立点4仍未访问。

  3. 展开 1

    0:done;1:done;2:d=1 queued;4:unseen;3:d=2 queued

    首次发现3,赋 d=2 并入队。0不消失,所有顶点坐标仍固定。

  4. 展开 2 并结束

    0:done;1:done;2:done;4:unreachable;3:done / d=2

    2看到3已经入队,不重复添加;处理3后队列为空。最终距离0、1、1、2、-1。

跟着例子,走完一遍

例题 01C++20 · 本机可运行

树递归计数与高度

根 1 的左孩子 2、右孩子 3;空子树高度 0。

  1. 空节点返回 count=0,height=0。
  2. 两个叶子各 count=1,height=1。
  3. 根合并得到 count=3,height=2。
17-a.cpp
下载
#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';
}

如何编译和运行下载的 .cpp 文件 →

结果与解释

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
例题 02C++20 · 本机可运行

有环图的 BFS 距离

邻接表包含菱形环与孤立顶点 4,从 0 出发。

  1. 0 入队并标记距离 0。
  2. 发现 1、2,距离为 1。
  3. 3 首次由 1 发现,距离 2;4 保持 -1。
17-b.cpp
下载
#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 的最短路径?

给我一点提示
  1. 发现 next 时保存 parent[next]=v。
  2. 从终点反向追到起点再反转。
查看答案与推理

本例按给定邻居顺序会得到 parent[3]=1、parent[1]=0,逆序收集 [3,1,0] 后反转为 [0,1,3]。若终点不可达先返回无路径;另一顺序可能得到 [0,2,3],距离同样最短。

练习 2

一条十万节点链用递归 DFS 有何风险,如何改为显式栈?

给我一点提示
  1. 递归深度等于链长。
  2. 待处理顶点可以保存在 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.

继续查证

公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。

接着看已有的图解

这些资料按主题补充本章内容。