树与图搜索
树与图搜索从一个节点或状态出发,沿连接关系访问可达的邻居。深度优先搜索(DFS)沿一条路径深入后回退,广度优先搜索(BFS)则逐层扩展。本文先说明怎样建模和遍历,再讲访问标记、路径恢复、多源搜索与判环,最后介绍树的子问题合并。
选择搜索方法时,要结合状态身份和访问标记判断是否会重复扩展。本文也会解释 BFS 得到最短距离的条件,以及搜索深度和每层宽度对空间的影响。
1. 面试核心
先记住这几条,再读正文- 遍历成本。 图用邻接表存储时,DFS 和 BFS 完整遍历的时间复杂度均为 O(V+E),但访问顺序与峰值空间不同。
- 右视图。 对二叉树按先左后右的顺序做分层 BFS 时,每层最后访问的节点组成右视图。卡 · BFS
- 多源 BFS。 边权相同且为正时,多源 BFS 第一次到达某个节点的距离,就是它到最近源点的距离。卡 · 多源 BFS
- 有向图判环。 对有向图做 DFS 时,若遇到当前路径中标为灰色的节点,就能确认图中存在环。卡 · 有向图三色判环
- 后序状态合并。 判断二叉树是否平衡时,后序遍历可合并左右子树的高度与平衡状态,让每个节点只计算一次。卡 · 后序合并与树形结果
2. 图的建模
搜索前,需要先说明节点代表什么、怎样从一个节点走到另一个节点。显式图直接保存邻接关系,隐式图则在搜索时按规则生成邻居。
可以用以下例子理解节点与边:
- 二叉树节点的左右孩子是邻居。
- 网格中可通行的上下左右位置是邻居。
- 单词经过一次合法编辑,得到相邻状态。
- 任务之间的先后依赖形成有向边。
建模时,需要明确以下条件:
- 一个状态包含哪些字段。
- 怎样生成合法邻居。
- 两个状态在什么条件下视为相同。
- 边是否有方向与权重。
- 搜索从一个起点还是多个起点开始。
状态身份需要覆盖影响后续选择的字段。比如,带钥匙的寻路状态应同时包含位置与钥匙集合。同一位置拿到不同钥匙后,可以通过的门不同,只记录坐标就会把这些状态混在一起。
边方向决定可达关系,边权决定最短路算法的选择,具体见图。
3. DFS
DFS 先沿一个分支走到底,再返回处理其他分支。它适合以下任务:
- 按前序、中序或后序遍历树。
- 搜索连通分量,或判断路径是否存在。
- 配合状态恢复,回溯枚举方案。
- 按节点完成的顺序实现拓扑排序。
下面用 record 表示无环、无共享子节点的二叉树,并递归输出前序结果:
record TreeNode(int value, TreeNode left, TreeNode right) {}
static void preorder(TreeNode node, java.util.List<Integer> result) {
if (node == null) return;
result.add(node.value());
preorder(node.left(), result);
preorder(node.right(), result);
}
根 1、左孩子 2、右孩子 3,结果为 [1,2,3]。代码先记录当前节点,再访问左、右子树,因此得到前序顺序。record 是 Java 16 正式提供的记录类语法,用于声明一组数据字段;本文使用 Java 21。
邻接表的完整 DFS 中,每个顶点和每条边只需访问常数次,时间为 O(V+E)。递归空间取决于最大活动深度。若树退化成很深的链,方法调用会不断占用栈帧,最终触发 StackOverflowError。
3.1 显式栈
为了控制深层搜索占用的调用栈,可以改用显式栈保存待处理节点。下面仍按前序访问同一类二叉树:
static java.util.List<Integer> preorderIterative(TreeNode root) {
java.util.List<Integer> result = new java.util.ArrayList<>();
java.util.Deque<TreeNode> stack = new java.util.ArrayDeque<>();
if (root != null) stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
result.add(node.value());
if (node.right() != null) stack.push(node.right());
if (node.left() != null) stack.push(node.left());
}
return result;
}
空根返回空列表,代码只把非空节点压栈,因为 ArrayDeque 拒绝 null。栈按后进先出的顺序弹出,所以先压右孩子,再压左孩子,就能先访问左侧。
此二叉树实现的辅助空间为 O(h),h 表示树高;返回结果另占 O(n)。显式栈把待处理节点保存在堆上的容器中,程序可以直接控制保存与恢复。它仍需要存储这些节点,只是省去了逐层递归调用。一般图还需要访问集合,空间上界为 O(V)。
4. BFS
BFS 用队列按层处理节点,先处理离起点近的一层,再扩展下一层。它适合以下任务:
- 按层遍历树。
- 求无权图中的最少边数路径。
- 找出距离起点 k 层的节点。
- 求每个位置到最近源点的距离。
下面按从左到右的顺序遍历每层,取最后一个节点作为右视图:
static java.util.List<Integer> rightView(TreeNode root) {
java.util.List<Integer> result = new java.util.ArrayList<>();
java.util.Queue<TreeNode> queue = new java.util.ArrayDeque<>();
if (root != null) queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
if (i == levelSize - 1) result.add(node.value());
if (node.left() != null) queue.offer(node.left());
if (node.right() != null) queue.offer(node.right());
}
}
return result;
}
根 1、左孩子 2、右孩子 3,右视图为 [1,3]。每层开始时固定 levelSize,就只会处理当时队列中的这一层节点。遍历时新增的孩子留给下一层,层边界因而保持清楚。
辅助队列空间为 O(w),w 表示最大层宽。队列保存的是已经发现、等待处理的节点,也称为搜索前沿。很宽的树会有 O(V) 个节点同时等待,所以宽度也是选择算法时的内存约束。
5. visited 标记时机
图中有环或多条路径时,同一状态会被不同前驱发现。BFS 在入队时就标记已访问,才能阻止这些前驱重复加入它。若等到出队才标记,重复项会先堆进队列,增加时间和空间成本。
下面使用整数顶点编号,用 distance 同时记录距离和访问状态。代码只展示扩展邻居的部分:
for (int next : graph[current]) {
if (distance[next] == -1) {
distance[next] = distance[current] + 1;
parent[next] = current;
queue.offer(next);
}
}
distance 初始全为 −1,源点设为 0 后入队。普通 BFS 把每条边视为单位成本,按距离从小到大处理节点。因此,首次发现邻居时就得到它的最短边数,同时用 parent 记录从哪个节点走到它。
只沿左右孩子访问、且无共享子节点的树,可以省略 visited。若把树转成无向图,父节点也成为邻居,就需要访问标记或排除父边,避免沿原边返回。
5.1 最短路径恢复
距离只能说明需要走多少步。若要输出具体路线,就从目标沿 parent 回走到源点,再反转序列。源点 parent 可设为 −1,作为停止标记;目标 distance 为 −1 时,则表示无可达路径。
每个节点只记录一个 parent,就只会恢复一条最短路径。若需要全部最短路径,应保留所有距离比当前节点少 1 的合法前驱。随后枚举前驱组合,输出数量也要计入总成本。
6. 多源 BFS
多个起点都可以作为出发位置时,需要求的是到最近起点的距离。把所有不同源点设为距离 0 并同时入队,就能在一次搜索中让它们共同向外扩展:
for (int source : sources) {
if (distance[source] == -1) {
distance[source] = 0;
queue.offer(source);
}
}
重复源点只加入一次,随后按普通 BFS 扩展。单位边权下,队列按距离层次递增,所以某个格子首次被发现时,得到的就是最近源点距离。这省去了为每个目标分别运行 BFS 的重复工作。
在网格中,搜索跳过阻塞格和越界位置,无法到达的格保留 −1。每个格子至多入队一次,上下左右的邻居数量固定,所以总时间和空间为 O(行数×列数)。
边权相同且为正时,可将最少边数乘以边权得到距离;权重不同时,按条件选择 0-1 BFS、Dijkstra 或 Bellman-Ford,见图。
7. 回溯
枚举路径时,要区分“当前路径用过”和“全局已经处理过”。当前路径标记在递归返回时撤销,让其他分支还能选择该节点;全局 visited 则保留,表示该状态后续可跳过。
把路径标记永久保留会漏掉合法路径,过早撤销全局标记则会造成重复扩展。加入、递归和撤销的完整机制见回溯。
8. 拓扑排序
拓扑排序为有向无环图安排一个顺序,让每条边的起点都排在终点之前。它适合构建顺序、课程依赖和任务调度。
入度是指向一个节点的边数。Kahn 算法把入度为 0 的节点视为前置依赖已经满足,按以下步骤处理:
- 统计每个节点的入度。
- 把所有入度为 0 的节点加入队列。
- 取出一个节点加入结果,并将每条出边终点的入度减一,相当于删除这些出边。
- 邻居入度降到 0 时,将它加入队列。
- 队列清空后,比较处理的节点数与 V,判断是否存在环。
若处理数小于 V,剩余子图就含有环。因为剩余每个节点都有前驱,沿前驱反复回走,在有限节点中必然遇到重复节点。若所有点都处理完,输出顺序就满足每条边的依赖。
多种零入度选择可产生不同拓扑序。要求字典序最小时,用最小堆管理零入度候选。普通邻接表队列实现为 O(V+E)。
8.1 有向图三色判环
有向图还可以在 DFS 中判环。这里要区分当前路径与已经完成的分支,所以使用三种颜色:
- 白色表示尚未访问。
- 灰色表示仍在当前递归路径中。
- 黑色表示该节点的所有出边已经处理完成。
遇到灰色邻居时,当前路径已经从它走到当前节点,新边又返回它,因此构成环。遇到黑色邻居只表示到达已完成分支,应继续检查其他边。比如,0→1、0→2、2→1 无环,但会重复遇到 1。
无向简单图需要排除返回父节点的边,因为同一条边会从两端各看到一次。若允许两个节点间有多条边,也就是平行边,就要按边 ID 排除父边。否则,把所有通向父节点的边都跳过,会漏掉环。
9. 后序合并与树形结果
树的问题可以先求左右子树的结果,再由当前节点合并,这就是后序处理。比如,检查高度平衡时,每个节点都要求左右子树高度差至多为 1。
让空子树返回高度 0,其他子树返回高度或失败标记 −1。任一子树失败,或左右高度差大于 1,就向上返回 −1。否则返回最大子树高度加 1。这样,高度计算和平衡检查就在一次遍历中完成。
每个节点只合并一次,时间为 O(n),递归空间为 O(h)。若在每个节点重新计算两侧高度,链状树会累计 O(n²) 工作。
9.1 最近公共祖先
最近公共祖先是两个目标的公共祖先中,深度最大的节点。这里允许一个节点作为自己的祖先。下面先假设两个不同目标都存在,并按节点身份区分,而不只比较节点值。
递归合并时,按以下情况返回结果:
- 当前节点为空时,返回空。
- 当前节点等于任一目标时,直接返回它作为候选。
- 否则递归左右子树。若两侧都有结果,说明两个目标分别位于两侧,当前节点就是最近公共祖先。
- 若只有一侧有结果,就将该侧结果向上传递;两侧都为空时返回空。
若一个目标正是另一个的祖先,遇到它时直接返回,就得到应选的祖先。若允许目标缺失,还需额外核验两个目标是否都存在,否则只找到一个目标也会向上返回候选。完整遍历时间为 O(n),递归空间为 O(h)。
10. 常见问题
下面集中说明遍历成本、递归深度和输入修改的边界,以及边权变化后的选择。
10.1 DFS 与 BFS 的成本
邻接表完整遍历的 DFS 与 BFS 都为 O(V+E)。BFS 按距离扩展,DFS 先沿一条路径深入,所以访问顺序和峰值空间不同。估算空间时,分别计算等待处理的节点、活动路径和访问集合。树上若每层很宽,BFS 队列会很大;若路径很深,DFS 调用栈会很大。
10.2 递归深度
每层递归调用都需要栈帧,保存参数和返回位置等信息。深链的活动调用数接近节点数,超过线程栈容量时就会溢出。可以根据输入上限限制深度,或改用显式 Deque,由程序保存待处理节点。
10.3 原地访问标记
允许修改输入,且标记值与合法数据区分时,可以直接在原网格上标记。接口需要说明它会修改参数。若调用方还需使用原网格,就用独立 boolean 数组或另存访问状态。
11 · 面试题卡片
11.1 二叉树右视图如何用 BFS 或 DFS 实现?
出现公司:字节跳动
考察重点:解释 BFS 的层边界与 DFS 的右侧优先顺序,并比较两种方法的时间和峰值空间。
参考回答
BFS 可以按左先右后的顺序取每层最后一项,DFS 则可先走右侧,记录每个深度首次访问的节点。
BFS 在每层开始时保存队列大小,处理完这批节点后再进入下一层,层边界就不会混淆。DFS 从深度 0 开始,当当前深度等于结果长度时,说明首次到达这一层,就记录节点。
两者各访问每个节点一次,时间为 O(n)。BFS 辅助空间为 O(w),w 是最大层宽;DFS 递归空间为 O(h),h 是树高。深树使用递归时还要考虑线程栈容量。
11.2 如何求网格各位置到最近起点的距离?
出现公司:字节跳动
考察重点:解释多个源点同时入队为何只需遍历一次,并说明单位边权和入队标记的作用。
相关内容:多源 BFS。
参考回答
将全部不同源点设为距离 0 并同时入队,再运行一次 BFS,就能求出单位边权下的最近起点距离。
每次向上下左右扩展,首次发现邻居时写入当前距离加 1,并立即标记。所有源点共同按距离层次扩展,所以更短的路线会更早到达。阻塞格与越界位置直接跳过,未到达的格保留 −1。
每个格子至多入队一次,总时间与空间为 O(行数×列数)。移动成本改变时,需要按边权范围改用 0-1 BFS、Dijkstra 等方法。
11.3 有向图遇到已访问节点就是环吗,Kahn 如何判环?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分当前活动路径与已完成分支,并解释 Kahn 剩余节点的入度为何能用于判环。
参考回答
有向 DFS 遇到当前路径中的灰色节点,才能由这条边确认环;Kahn 则用处理节点数是否小于 V 判环。
黑色节点已经完成,重复到达它本身无法说明有环。比如 0→1、0→2、2→1 会重复遇到 1,图却仍然无环。
Kahn 每次移除入度为 0 的节点。若队列清空后仍有节点,剩余每个节点都有前驱。在有限节点中沿前驱不断回走,必然重复,因此剩余子图存在环。
11.4 如何在线性时间判断二叉树是否高度平衡?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明子树怎样同时返回高度与平衡结果,以及为什么能省去重复的高度计算。
相关内容:后序合并与树形结果。
参考回答
后序遍历让每棵子树返回高度或失败标记,就能在线性时间完成判断。
空子树返回 0。若任一子树返回 −1,或左右高度差大于 1,当前节点也返回 −1。否则,返回较大高度加 1,供父节点继续判断。
每个节点只合并一次,所以时间为 O(n),递归空间为 O(h)。若对每个节点都重新计算两侧高度,链状树就会累计 O(n²) 的重复工作。
11.5 BFS 如何恢复一条最短路径?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释首次发现时记录的前驱为何属于最短路径,并说明路径反转和不可达情况的处理。
相关内容:最短路径恢复。
参考回答
BFS 首次发现节点时记录前驱,就能在搜索结束后恢复一条最短路径。
单位边权下,首次发现来自最短距离层。目标可达时,沿 parent 从目标回走到源点,再反转顺序,就得到源点到目标的路线。源点 parent 可设为 −1,用作停止标记。目标 distance 仍为 −1 时,则返回无解。
11.6 二叉树最近公共祖先如何合并结果,目标缺失时怎么办?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:按节点身份合并左右子树的搜索结果,并说明目标缺失为何需要额外验证。
相关内容:最近公共祖先。
参考回答
两个目标都存在时,可以合并左右子树的结果寻找最近公共祖先;目标允许缺失时,还需额外确认存在性。
空节点返回空,当前节点本身为目标时,直接作为候选返回。否则搜索两侧:若两侧各找到一个目标,当前节点就是最近公共祖先。只在一侧找到时,向上传递该侧结果。
若一个目标是另一个的祖先,上述过程会返回这个祖先。若目标可以缺失,只找到一个目标也会返回候选,所以还要验证两个目标都存在。判断时比较节点身份,避免把值相同的不同节点混在一起。