news 2026/11/4 15:17:40

L2-031深入虎穴:DFS+链式前向星求树的最大深度

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
L2-031深入虎穴:DFS+链式前向星求树的最大深度

昨天有个学弟在群里问 L2-031 深入虎穴,贴了一段 BFS 代码,样例过了但提交就是有两个点过不去。我让他把起点从 1 号门改成入度为 0 的门,马上 AC。这道题很多人第一眼以为是个普通图论题,甚至想套最短路模板,实际上它就是在一棵有向树里找深度最大的节点,题目里绝大多数信息都在为“树”这个结论做铺垫。今天我用 DFS + 链式前向星把这题完整拆一遍,顺便把入口查找、边数组大小、递归爆栈这几个容易翻车的地方一起说清楚,想打天梯赛或者刚学树的同学都可以直接参考。

1. 题目到底在考什么:从“深入虎穴”里拆出树的模型

1.1 每个节点给出的出口列表,其实就是“孩子列表”

先看输入:第一行是节点数 N,接下来第 i 行给出节点 i 的若干出口。也就是说,节点 i 和这些出口之间有一条“从 i 出发、能走到出口”的有向边。

这里最关键的一个隐藏条件是:整张图只有一个入度为 0 的节点作为入口,并且从入口开始可以到达所有节点。这直接等价于:除入口节点外,其余每个节点的入度都为 1。为什么?因为如果某个非入口节点有两个不同节点能到达它,那从入口出发到它就有两条路径;如果某个非入口节点入度为 0,那它又会变成一个“新入口”,这与唯一入口矛盾。所以整个结构是一棵以入口为根的有向树,每个节点给出的出口就是这棵树的“孩子节点列表”。

很多同学看到“有向图”就条件反射想用 Dijkstra 或者多源 BFS,其实完全不需要。题目要求的无非是:从根节点出发,找一条最长的向下路径,输出这条路径终点的编号。这不就是树的深度问题吗?

1.2 入口怎么找:入度为 0 不是 1 号门

样例里碰巧 1 号门是入口,于是不少人直接dfs(1),提交后部分测试点通不过。正确做法是读边的时候顺手统计入度:

int indeg[N]; // 读入时: indeg[v]++;

全部读完后,从 1 到 N 扫一遍,第一个indeg[i] == 0的就是入口:

int root = 1; for (int i = 1; i <= n; ++i) { if (indeg[i] == 0) { root = i; break; } }

这道题保证有唯一入口,所以可以直接 break。如果以后遇到不保证唯一的题目,就不能简单 break 了,得把所有入度为 0 的点都作为起点分别遍历,或者再根据题意特殊处理。

1.3 同深度输出编号最小,更新答案别漏等号

题目还有一个要求:如果有多个节点距离入口一样远,输出编号最小的那个。这导致深度更新的条件不能只写>,还得在深度相等时比较编号:

if (depth > maxDepth || (depth == maxDepth && u < ans)) { maxDepth = depth; ans = u; }

这个==的情况特别容易被忽略,而且一旦漏掉,样例往往还是能过的,因为样例的答案可能只有一个,或者恰好编号顺序碰巧正确。等数据里出现并列时,就是白丢的 3 分。我自己的习惯是,只要题面出现“编号最小/最大”“字典序最小”这类关键词,就会在更新答案的函数里单独写一段相等的判断,绝不让它漏掉。

2. 前向星建图:head、to、nxt的配合与边数陷阱

2.1 为什么竞赛里常用链式前向星

很多人会问:明明用vector<int> child[N]存树不是更简单吗?当然可以。不过链式前向星是竞赛里很基础也很常用的建图方式,它能做到用紧凑的数组存储所有边,遍历子节点时通过数组下标跳转,常数小、缓存友好。更重要的是,很多老题目、老 OJ 的编译环境和内存限制比较奇怪,链式前向星在极端环境下会比 vector 更稳。

我不是说 vector 不行,而是建议你至少掌握前向星,这样才能在需要的时候写出更省内存、更可控的代码。尤其是遇到那种 N 到 10 万、递归深度还可能到 10 万的题,vector 本身没问题,但如果你连前向星都不会,那读别人的题解都会困难。

2.2 加边函数的三个数组到底怎么联动

链式前向星的核心是三个数组:head、to、nxt,外加一个边计数tot。

  • head[u]:节点 u 的第一条出边在to数组里的下标。
  • to[idx]:第 idx 条边指向的节点编号。
  • nxt[idx]:第 idx 条边的“兄弟边”下标,也就是同一个起点的上一条边存在哪。

加边函数写成:

void addEdge(int u, int v) { to[++tot] = v; nxt[tot] = head[u]; head[u] = tot; }

用的时候遍历节点 u 的所有出边:

for (int e = head[u]; e; e = nxt[e]) { int v = to[e]; // v 是 u 的一个孩子 }

这里用的是头插法,也就是每次都把新边插到链表头部。所以遍历顺序和输入顺序是反的。但对求深度来说,顺序不重要;如果遇到需要按输入顺序遍历的题,可以考虑尾插法或者在 dfs 之前做一次排序,但本题完全不需要。

2.3 边数组只需 N+5:树的总边数是 N-1

前向星的to和nxt数组开多大?这是本题特别容易踩的坑。如果你没有意识到它是树,很容易想:每个节点都可能连到其他 N-1 个节点,那最多有 N*(N-1) 条边,于是写出const int MAXM = 100005 * 100005,不是编译失败就是内存超限。

但一旦确认了“唯一入口 + 每个非根节点入度为 1”,整棵树的边数就是固定的:

除根节点外,每个节点有且只有一条入边,所以总边数 = N - 1。

因此to和nxt开到N + 5就足够。这也是为什么这题的边数组可以用const int MAXN = 100005复用,不需要额外开一个巨大的MAXM。

如果你实在担心题目数据不按套路出牌,也可以把to和nxt声明成vector<int>,动态 push_back。不过以天梯赛的数据习惯,前向星开N+5是绝对安全的。

3. DFS求最深节点:递归版、手写栈与爆栈自救

3.1 递归版DFS:深度参数和答案更新

用递归写 DFS 是最直观的。入口节点深度定为 1(也可以定为 0,只要全程统一就行),每向下走一层深度加 1。进入节点时先更新答案,再遍历所有子节点。

int maxDepth = 0, ans = 1; void dfs(int u, int depth) { if (depth > maxDepth || (depth == maxDepth && u < ans)) { maxDepth = depth; ans = u; } for (int e = head[u]; e; e = nxt[e]) { int v = to[e]; dfs(v, depth + 1); } }

这里特别注意:因为是树,路径唯一,不需要vis数组,不会走回头路。我在初学阶段总是习惯性地加一个标记数组,后来发现对这种输入结构,加了反而画蛇添足,还容易出错。树本身就是天然的无环图,递归天然不会重复访问。

3.2 显式栈模拟DFS:不会爆栈的写法

递归最大的隐患是爆栈。当树退化成一条链,比如 1 -> 2 -> 3 -> ... -> N,递归深度最大就到 N。当 N 是 10 万时,在很多 OJ 的默认栈空间下,直接段错误。

我自己就遇到过:本地跑好好的,提交到 OJ 变成运行错误,人都懵了。后来加了一行编译指令才过,但如果当时直接用手写栈,根本不用跟环境折腾。

显式栈写法也简单,用一个栈存(节点编号, 当前深度)的二元组,弹出时更新答案,然后把它所有子节点压入栈:

stack<pair<int, int>> st; st.push({root, 1}); while (!st.empty()) { auto [u, depth] = st.top(); st.pop(); if (depth > maxDepth || (depth == maxDepth && u < ans)) { maxDepth = depth; ans = u; } for (int e = head[u]; e; e = nxt[e]) { st.push({to[e], depth + 1}); } }

注意这里压栈顺序不影响答案,因为我们对每个节点都会单独更新一次深度,不会因为先处理哪个孩子而改变最终的最大值。

如果你想让代码更简洁,也可以用 BFS 的队列写法,层数天然等于深度,完全没有递归烦恼。不过既然题目技术点写着 DFS,我还是更推荐掌握手写栈这种“迭代 DFS”的姿势。

3.3 深度初值、N=1和递归边界

有些同学喜欢把maxDepth初始化为INT_MIN,其实没必要。入口节点深度为 1,只要maxDepth初始化为 0,入口节点第一次更新时就能正确覆盖。如果初始化成INT_MIN当然也能过,但没必要增加心理负担。

还要注意 N = 1 的边界:只有一个节点,它自己就是入口,深度为 1,答案就是 1。使用上面的代码,dfs(1, 1)会先把答案设为 1,然后发现没有子节点,结束。输出 1,没问题。

递归边界其实不用刻意写if (depth > ans)之类,因为进入节点就更新,叶子节点更新完没有子边,自然返回。

4. 完整AC代码与测试用例验证

4.1 可直接提交的完整代码

把前面所有内容拼起来,就是一份完整的可提交代码:

#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; int n; int head[MAXN], to[MAXN], nxt[MAXN], tot; int indeg[MAXN]; int maxDepth = 0, ans = 1; void addEdge(int u, int v) { to[++tot] = v; nxt[tot] = head[u]; head[u] = tot; } void dfs(int u, int depth) { if (depth > maxDepth || (depth == maxDepth && u < ans)) { maxDepth = depth; ans = u; } for (int e = head[u]; e; e = nxt[e]) { int v = to[e]; dfs(v, depth + 1); } } int main() { scanf("%d", &n); for (int i = 1; i <= n; ++i) { int k; scanf("%d", &k); while (k--) { int v; scanf("%d", &v); addEdge(i, v); indeg[v]++; } } int root = 1; for (int i = 1; i <= n; ++i) { if (indeg[i] == 0) { root = i; break; } } dfs(root, 1); printf("%d\n", ans); return 0; }

如果担心递归爆栈,把dfs(root, 1)换成前面手写栈版本即可,其他部分完全不用动。

4.2 用一个小型图验证代码逻辑

为了不依赖原题样例,我自己构造一个非常小的输入来验证:

6 2 2 3 1 4 1 5 1 6 0 0

这个数据的结构是:

  • 1 号门通向 2、3
  • 2 号门通向 4
  • 3 号门通向 5
  • 4 号门通向 6
  • 5、6 没有出口

入度统计后,只有 1 号门的入度为 0,所以根是 1。DFS 从 1 出发,深度为 1;节点 2、3 深度为 2;节点 4、5 深度为 3;节点 6 深度为 4。最大深度是 4,对应节点 6,代码输出 6。

你可以把这份输入替换进去试,应该一秒出结果。如果输出不是 6,说明入口查找或深度更新哪里出了问题。

4.3 为什么这题不需要visited数组

我前面反复说不需要vis,这里再解释一下为什么。

在一般的有向图里,如果没有访问标记,DFS 可能无限循环。但本题的数据结构是树:每个节点只有一个父亲,从根出发的路径是唯一的,不可能走回已经访问过的节点。所以就算你不开vis,程序也不会死循环。

这也反过来提醒我们:判断一个题能不能用简单的 DFS 求深度,先确认它是不是树(或森林)。如果图中可能有环,那就必须加访问标记,甚至要考虑更复杂的做法。

5. 三个让大佬都翻车的细节与调试建议

5.1 入口找错:样例过了不代表起点正确

这题最阴险的地方是,样例的入口恰好就是 1 号门,于是很多人理所当然把起点写成 1。一旦测试数据里入口不是 1,你从 1 号门出发最多只能遍历 1 号门所在的子树,而真正的入口可能是另一棵子树,这就导致答案完全不对。

我的排查经验是:如果 DFS 输出结果的深度明显偏小,或者总有几个点过不去,第一件事就是检查起点是不是入度为 0 的节点。你可以在调试时打印所有入度为 0 的节点,看看是不是只有一个、是不是你选的根。这比一点点查遍历逻辑要快得多。

5.2 边数组开爆:以为是普通图结果MLE

前向星数组开太大会直接内存超限,开太小会数组越界或段错误。如果你还没有意识到这是树,极可能写出to[MAXN * MAXN],这种代码连编译都不一定过,更别说提交了。

我的建议是,读题时先把“唯一入口”“从入口可达所有节点”这样的条件圈出来,马上反应出“这是一棵树,边数是 N-1”。这样边数组就敢放心开MAXN + 5。如果你实在不确定,就用vector<int> child[MAXN],动态扩容,至少不会 MLE,遍历也没问题。

5.3 递归爆栈后的排查思路:段错误不等于数组越界

当你看到一个运行错误,不要只往“数组越界”方向想。如果数组大小开得没问题,那就要考虑递归深度。

N = 10 万、树退化成一条链时,递归调用的深度就是 10 万层。默认递归栈一般不够。我遇到过一次,本地 Visual Studio 能跑,提交到某个 OJ 就崩,排查了半天才发现是系统栈太浅。

解决方式有三种:

  1. 用显式栈模拟 DFS(推荐,代码不复杂);
  2. 用 BFS 队列替代递归;
  3. 在代码开头加栈扩容指令(不同环境写法不同,不通用)。

我最推荐第二种思路的变种:其实用队列就是按层遍历,天然避开了递归深度问题。但为了训练 DFS 能力,手写栈也值得练一遍。考试时优先选择你最有把握、最不依赖环境的写法。

这题整体难度不大,但入口查找、深度更新、边数组容量、递归深度这几个点组合在一起,足够把基本功不扎实的选手筛掉。我第二次做这道题时就吃了递归爆栈的亏,后来把所有树形 DFS 题都默认用手写栈兜底,再没因为这个原因丢过分。希望这篇题解能帮你把这几个坑一次踩平,下次遇到这类“看似图论、实则树”的题,能直接抓住本质。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 3:09:51

CC2530烧录避坑指南:SmartRF Flash Programmer 1.12.8安装与使用详解

简介&#xff1a;SmartRF Flash Programmer 1.12.8是德州仪器官方发布的闪存烧录工具&#xff0c;面向底层开发与硬件调试人员&#xff0c;专注于基于8051内核的低功耗射频MCU&#xff0c;如CC2530、CC2540等&#xff0c;适用于Zigbee、BLE、Sub-1GHz等TI芯片的底层开发与量产烧…

作者头像 李华
网站建设 2026/10/31 6:42:59

大模型推理优化实战:从900ms到380ms的延迟压缩与GPU利用率提升

1. 推理优化到底在优化什么九月底那几天我一直在折腾推理链路的性能问题&#xff0c;起因很简单&#xff1a;线上一个日均调用量七位数级别的服务&#xff0c;单次推理耗时从年初的320ms一路涨到了接近900ms&#xff0c;GPU利用率却只有40%出头。这个数字组合一摆出来&#xff…

作者头像 李华
网站建设 2026/10/30 23:04:43

Agent架构瘦身实战:删掉冗余模块,成功率反升

1. 从"堆模块"到"砍模块"&#xff1a;一次反直觉的架构瘦身做Agent这一块&#xff0c;我前后折腾了差不多一年半。最开始那几个月&#xff0c;我和大多数人一样&#xff0c;觉得能力越强越好&#xff0c;于是往系统里塞了一堆东西&#xff1a;规划器、反思…

作者头像 李华
网站建设 2026/11/2 2:25:26

Fabric自动化部署实战:用Python编排SSH远程运维任务

1. 部署中的重复劳动&#xff0c;是时候换种方式了如果你做过一段时间运维或者后端开发&#xff0c;大概率经历过这样一套流程&#xff1a;本地测试通过&#xff0c;手动打包&#xff0c;上传到服务器&#xff0c;解压覆盖&#xff0c;重启服务&#xff0c;看日志确认是否启动成…

作者头像 李华
网站建设 2026/11/2 22:35:00

Hadoop集群WordCount运行故障排查实战指南

简介&#xff1a;本资源是面向1X大数据平台运维职业技能等级证书备考者与Hadoop初学者的实操型学习材料&#xff0c;聚焦Hadoop集群运行核心运维能力培养。内容覆盖NameNode/DataNode格式化、Java进程与HDFS状态查看&#xff08;jps/hdfs dfsadmin -report&#xff09;、浏览器…

作者头像 李华
网站建设 2026/11/3 13:17:09

大模型推理加速实战:KV Cache、PagedAttention与连续批处理优化

1. 大模型推理加速的整体设计思路1.1 为什么推理速度是大模型落地的第一道门槛做过大模型应用的人都有一个共同体会&#xff1a;训练一个模型可能要几天甚至几周&#xff0c;但推理是每天、每时、每刻都在发生的事情。用户问一句话&#xff0c;模型要在一两秒内给出回答&#x…

作者头像 李华