
1. 这道题到底在考什么读懂“二叉树深度”四个字洛谷P4913题目名字写得很直白——【深基16.例3】二叉树深度。深基全称是《深入浅出程序设计竞赛——基础篇》配套题单第16个专题正好是树和二叉树。很多第一次接触树的人就是被这道题按住强行开了个门你不需要会建一棵多漂亮的树只需要能把输入读进来然后用正确的方式数清楚这棵树一共有几层。题目本身不复杂第一行给一个正整数n表示树上一共有n个节点节点编号从1到n。接下来n行每行两个整数分别表示第i个节点的左孩子编号和右孩子编号如果孩子不存在就用0来表示。根节点固定是1号节点。最后要求输出这棵二叉树的深度——也就是从根节点到最远叶子的节点层数。这里的深度按节点数计算根节点单独一层所以只有一个根节点的树深度就是1根节点下面再接一层就变成2。这道题真正想练的东西不是“深度”这个结论而是两件事第一是怎么用数组把一棵树存下来第二是怎么用递归去树上“走一圈”。很多人在学二叉树时先接触的是指针、结构体、new节点那一套到了题目里反而懵了——因为竞赛题的输入不是让你慢慢建树而是给出了一种非常紧凑的静态表示法每个节点就是个编号左右孩子是两个数。你需要打破“树必须是链表样子”的思维惯性学会用编号和数组去模拟这种关系。我为什么推荐新手把这题吃透因为树的静态存储和递归遍历是所有后续树形题的地基。后面做LCA、树的直径、树形DP、以及洛谷动态规划题单里的大多数树上题目第一步都离不开“存树”和“算深度”这两个基本功。P4913就像是一块最干净的练习场把所有干扰项都去掉只留这两件事给你。2. 存储结构的选择为什么竞赛里很少用指针建树2.1 结构体数组和两个平行数组的对比既然要把树存下来最简单直观的做法当然是定义一棵“节点”struct Node { int l, r; // 左孩子编号、右孩子编号 } tree[1000005];然后在读入时直接写入对应字段。tree[i].l就表示i号节点的左孩子编号tree[i].r表示右孩子编号。节点编号和数组下标完全一一对应存取都是O(1)的随机访问非常舒服。另一种常见写法是把左右孩子分别单独存放用两个平行数组const int N 1000005; int lc[N], rc[N];lc[i]和rc[i]分别表示i号节点的左右孩子。相比结构体版本这种写法少了一层访问成员的操作代码写起来也更紧凑。两种方案本质上一模一样选哪个完全是个人习惯。从内存布局上看两个平行数组在处理某些批量操作时理论上缓存更友好但在题目这种规模下差别可以忽略不计。我个人更倾向于直接用【struct Node结构体数组】来写因为它的语义更清晰后面如果节点还要挂别的信息比如权值、深度直接在结构体里加字段就行两个平行数组到时候反而要跟着再加一个数组。2.2 为什么不用指针动态建树入门教材里最常见的二叉树定义是这种:struct TreeNode { int val; TreeNode *left, *right; };但竞赛题里很少有人真的用指针去建树原因有三个。第一个原因是内存操作效率。每来一个节点就new一次申请的是一小块一小块的堆内存分配耗时比静态数组的预分配高不少而且极度依赖动态内存分配器的表现。题目数据一大new的开销会被放大得很明显。第二个原因是释放问题。就算你把整棵树递归delete掉依然可能出现内存碎片更麻烦的是递归析构本身就是一个递归过程在树的深度较大时同样有爆栈风险。很多人new完就再也不管提交过样例没事但仔细查内存就处处都是坑。第三个原因是调试体验。指针一堆gdb里看一棵树要一层层展开非常痛苦而数组存树每个节点的左右孩子一目了然打表调试极其方便。你甚至可以输出整棵树的lc/rc数组扫描一遍就知道建树有没有建错。所以做题归做题什么时候用指针节点、什么时候用数组下标本质上是“工程实践”和“竞赛效率”之间的取舍。刷洛谷这类题静态数组就是最稳的答案。3. 递归求深度三行代码背后的原理3.1 深度的递推公式当前层加子树深度求一棵子树的最大深度有一个非常漂亮的递归定义以u为根的子树的深度等于max(左子树深度, 右子树深度) 1。为什么加1因为要把u这个根节点所在的一层算进去。为什么取max因为深度要走到“最远的叶子”哪边更深就从哪边走。把这个公式写成函数int dfs(int u) { if (u 0) return 0; return max(dfs(lc[u]), dfs(rc[u])) 1; }就三行但每一行都藏着答案。很多初学者理解不了递归主要是卡在“没有return语句层层回传”这个过程上。这里我把递归里的关键路径走一遍。假设树只有两个节点根节点1的右孩子是2节点2没有孩子调用dfs(1)发现lc[1]是0rc[1]是2。想先算出dfs(lc[1])也就是dfs(0)遇到u0条件直接返回0。这是递归的“底盘”空节点深度计为0。接着算dfs(rc[1])也就是dfs(2)。进入dfs(2)后lc[2]和rc[2]都是0所以它的返回值是max(dfs(0), dfs(0)) 1 max(0, 0) 1 1。dfs(2)的结果回到了dfs(1)那一层此时dfs(1)拿到的是max(dfs(0), dfs(2)) 1 max(0, 1) 1 2。最终输出2正好是两层。整个链路是从顶向下层层调用从底向上一层接一层把结果传回来。这正是递归在树上工作的本质。3.2 终止条件为什么必须写成 u 0这个终止条件很容易被忽略但写错就是两类问题。如果去掉if (u 0) return 0那么当访问到一个不存在的节点时你会在lc[u]上拿到一个未初始化的值然后继续往下走不知道会访问到哪块内存轻则答案乱掉重则数组越界直接Runtime Error。还有人是这样写的int dfs(int u) { if (lc[u] 0 rc[u] 0) return 1; return max(dfs(lc[u]), dfs(rc[u])) 1; }这种写法对“叶子节点一律判定为深度1”的树是可以的但它其实把空节点的处理混进了叶子判断里。如果你在调用前已经保证永远不会dfs到0节点那没问题但一旦一个节点的左孩子为0代码里还是会在这一行max(dfs(lc[u]), ...)中调用dfs(0)因为你的函数内部没有拦住它而是直接去访问lc[0]和rc[0]。在多测数据下lc[0]、rc[0]的值不确定容易翻车。最稳的写法就是单独把“空节点”拎出来返回0把“叶子节点”和“普通节点”统一交给max表达式去处理。这样逻辑边界清晰空节点深度是0非空节点深度是孩子深度的最大值加1没有例外情况。代码短、大脑负担小就是最好的。4. 代码实现全过程从读入到输出4.1 C版本的递归写法完整代码可以长这样#include bits/stdc.h using namespace std; const int MAXN 1000005; int lc[MAXN], rc[MAXN]; int dfs(int u) { if (u 0) return 0; return max(dfs(lc[u]), dfs(rc[u])) 1; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; for (int i 1; i n; i) { cin lc[i] rc[i]; } cout dfs(1) \n; return 0; }数组大小为什么要开成MAXN而不是n1严格来说如果你在main里看到n之后用vector来开长度为n1的数组也完全可行。但竞赛里图省事一般都直接定一个大一点的常量比如1000005因为题目给的n上限通常就是1000000多开5个纯粹是防越界的习惯。直接开常量数组还省了一个动态分配的过程代码也更短。输入的时候要注意数据的顺序是节点编号从1到n一行一行给的第i行代表节点i的左右孩子。根节点是固定的1号节点这一点题目给的非常明确不需要你去寻找“谁没有被任何节点指向”——虽然那也是一种判断根的办法但在这道题里画蛇添足。4.2 当递归不再是好选择BFS迭代求深度上面递归写法优雅归优雅但有一个隐藏风险如果题目把n开到很大的范围并且树形长得非常歪比如每个节点只有右孩子、左孩子全是0那这棵树就成了一根长度为n的链子。这时候dfs的递归深度也达到了n而绝大多数评测环境的栈空间撑不住一百万层嵌套调用结果就是栈溢出Runtime Error。怎么解决最直接的方案是抛弃递归改用迭代。求树的深度可以按“层”来从根节点出发一层一层往下走每处理完完整的一层就把深度加1这就是广度优先搜索的思路。#include bits/stdc.h using namespace std; const int MAXN 1000005; int lc[MAXN], rc[MAXN]; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; for (int i 1; i n; i) { cin lc[i] rc[i]; } queueint q; q.push(1); int depth 0; while (!q.empty()) { int sz q.size(); depth; while (sz--) { int u q.front(); q.pop(); if (lc[u]) q.push(lc[u]); if (rc[u]) q.push(rc[u]); } } cout depth \n; return 0; }这里的核心技巧是在处理每一层之前先用int sz q.size()记录当前队列里有多少个节点这些节点就是同一层的节点。然后把这sz个节点全部弹出来并把它们的左右孩子加入队列。等这层处理完深度加1队列里剩下的就是下一层的节点。整个过程不需要递归队列最多同时存一层的节点空间也能控制住。我建议你在提交之前先掂量一下题目数据范围和你的递归消耗。稳妥的竞赛习惯是能递归就递归但一旦感觉树的形状可能退化成长链就果断换成BFS写法。不是递归不好是评测环境对栈有硬性约束。4.3 Python版本显式栈和队列哪个更顺手不少人在洛谷上用Python刷题。递归在Python里的可用性更差因为默认递归深度只有1000左右就算用sys.setrecursionlimit(2000000)去调大遇到链式树依然可能不稳。所以Python版本更推荐用队列或者栈模拟。from collections import deque n int(input()) # 下标从0开始不好读这里让数组长度设为n2下标和节点编号对应 lc [0] * (n 2) rc [0] * (n 2) for i in range(1, n 1): l, r map(int, input().split()) lc[i] l rc[i] r q deque() q.append(1) depth 0 while q: depth 1 sz len(q) for _ in range(sz): u q.popleft() if lc[u]: q.append(lc[u]) if rc[u]: q.append(rc[u]) print(depth)这段代码几乎是把C版本翻译了一遍。唯一的细节是数组长度开成n2而不是n因为节点编号最大到n下标n必须是有效的多开一个位置防止lc[n]或rc[n]访问时越界。Python在列表越界时会直接抛IndexError虽然不影响正确性但总是被这种低级错误拦住很影响刷题心情。如果你坚持用递归模拟栈也可以用显式栈存二元组(节点编号, 当前深度)但队列版本在理解成本上低得多代码也更短个人更推荐。5. 实战中最容易踩的四个坑5.1 数组下标理解错把第i行当成i号节点看起来是废话但真的有人栽在这。输入给定的是n行每行是“第i个节点的左右孩子”。数组下标应该跟节点编号一一对应。可有人写循环时习惯for(int i 0; i n; i)读取后塞进lc[i]结果节点编号全部错位最后根节点1的孩子数据变成了0号位置的内容。碰到这种问题输出一下整个数组看看就知道了——打印lc[i]和rc[i]对照输入样例逐行验证哪里错了一目了然。竞赛里还有一类失误是数组大小开成n然后用下标n。C里数组下标合法范围是0到长度减1访问下标n属于越界。虽然有些时候越界访问没立刻崩但结果就是玄学。养成“开数组时多开一位、下标按1开始”的习惯能从根上避免这个问题。5.2 空节点漏处理把0当真节点往下递归我见过最经典的错误是递归函数这么写int dfs(int u) { int res 1; if (lc[u] ! 0) res max(res, dfs(lc[u]) 1); if (rc[u] ! 0) res max(res, dfs(rc[u]) 1); return res; }逻辑上没啥问题但代码量比三行版本长而且多出了很多分支。更隐蔽的问题是在分支条件写漏了的情况比如只判断了左孩子右孩子就直接dfs(rc[u])如果右孩子为0就访问了野数据。我的建议是不要试图把所有边界情况都塞进函数的细节里直接在最前面用if (u 0) return 0把这堵墙砌好后面就随便折腾了。5.3 读写缓冲区不解除同步C里如果你用cin/cout在大输入量下不关同步可能会超时。虽然这道题n在一百万左右理论上来得及但竞赛环境性能波动大稳妥起见还是写上ios::sync_with_stdio(false); cin.tie(nullptr);这两行几乎是所有洛谷C代码的第一行。可以这么记sync_with_stdio(false)是断开cin和stdio的同步cin.tie(nullptr)是取消cin与cout的强制绑定解绑之后输入输出就不会频繁刷新缓冲区速度能快一个量级。5.4 根节点不固定万一输入顺序乱排怎么办P4913明确固定根节点是1这是它作为入门题的仁慈。但如果你以后遇到“没告诉你根是谁”的题比如CF上常见的树结构输入你就得额外统计每个节点的入度在读入边的时候标记每个被指向的节点最后那个没被指向的节点就是根。这是一个非常常用的辅助技巧建议自己动手写一遍就当提前为后面的数据结构题攒经验。6. 从P4913延伸出去一道简单题能带出多少知识6.1 深度和层序遍历是一体两面BFS求深度的过程其实就是层序遍历。你可以顺手在这个框架里干很多事统计每一层的节点数、输出每一层的节点编号、判断是不是完全二叉树。P4913只是把这个过程简化为“只求个数”但你心里要有数——深度的本质就是层序遍历的层数这个模型在很多题里都会复用。比如后面遇到那种“从根到某个叶子输出路径”的题本质就是DFS回溯记录路径遇到“求最宽的一层”的题本质就是用BFS的sz变量统计每层节点数。树的套路翻来覆去就那么几个而P4913帮你把其中一个练熟了。6.2 深度是很多树上算法的前置信息做最近公共祖先LCA问题的时候第一步通常要先跑一遍DFS把每个节点的depth数组算出来然后才能用倍增或树上差分的方法求LCA。树形DP里“子树大小”和“子树深度”经常是转移需要用到的信息。哪怕只是做洛谷动态规划题单里的简单树形DP也往往要先递归一次拿到子树的某些值再在回溯的过程中做状态转移。你会在后面无数题目里看到dfs返回值的经典组合int dfs(int u, int fa) { int sz 1; for (int v : child[u]) { if (v fa) continue; sz dfs(v, u); } return sz; }这段代码和P4913里dfs求深度一脉相承都是“递归去子节点拿信息汇总后返回父节点”。把P4913的递归过程彻底理解了后面写子树大小、写树形DP的状态转移会顺畅得多。6.3 为什么深基题单要把“树的存储”作为独立专题很多人觉得“存树”算什么专题但如果你去翻深基题单会发现二叉树深度、二叉树遍历、二叉树子树这些题其实都在反复练同一件事用数组下标来表示节点之间的父子关系。这个思维一旦建立后面你看到图的前向星存储、链式邻接表就不会再觉得它们是什么天书了无非是从“每个点最多两个邻居”扩展到“每个点一堆邻居”存储结构从数组换成了链表而已。基础题目最大的价值就是把最核心的思维模式用最小成本刻进你的直觉里。P4913就是这道板子题。结尾聊聊我当年做这题的一些体会老实说我第一次做P4913的时候是那一整个深基题单里最不上心的一题觉得三行递归完事结果第一次还因为数组开小了挂在越界上。后来在另一道树上题目里我顺手用了同样的递归写法结果链状数据直接爆栈才回头认真研究BFS求深度。从那之后我养成了一个习惯遇到树题先想清楚这棵树会不会退化成长链再决定用递归还是用迭代。这题虽然简单但非常值得你多花几分钟做一点自我检验能不能把三行递归说得头头是道能不能不用草稿写出BFS版本如果都能那你的树基础算真正落地了。后面不管你是去挑战洛谷动态规划题单里的树形DP还是去啃LCA、直径、树上差分这些硬骨头你兜里已经有“存树”和“算深度”这两张底牌心里是有底的。