☰
树的直径详解:两次DFS与树形DP算法及扩展应用
2026/10/13 3:46:37 网站建设 项目流程

1. 树的直径到底在求什么

树的直径,字面上听着像是要量一棵树的“腰围”,但在数据结构里,它指的是树中任意两个节点之间最长的那条路径的长度。更严谨一点:在一棵无根树中,找到两个相距最远的节点,它们之间的距离(经过的边数,或者带权图中边权和)就是这棵树的直径。

我第一次接触这个概念时,第一反应是“这有什么用”。后来刷题和实际写业务代码相继踩到相关场景,才发现它几乎是树形结构问题的“地基级”工具。比如:

  • 在一张的网络拓扑里,找相距最远的两个交换节点,评估全网最坏延迟;
  • 在一棵组织架构树里,找两个层级差异最大的节点,衡量结构“扁平度”;
  • 在文本解析或依赖分析中,把依赖链建模成树,直径就是最长的关键路径。

这篇文章围绕“树的直径”这个经典问题展开,我会从算法原理推导、完整代码实现、变式与扩展、常见坑点排查四个角度把整个问题讲透。无论你是刚学竞赛入门的高中生,还是准备面试刷数据结构的中级工程师,下面这套从“为什么两次DFS能行”到“带负权边该怎么处理”的解释,都能帮你把这个问题彻底吃进脑子里。

2. 先把模型和使用场景定义清楚

2.1 树的基本约定与直径定义

我们讨论的树是有n个节点、n-1条边的连通无环图。无环和连通这两个条件决定了树中任意两个节点间有且仅有一条简单路径。树的直径就是所有节点对之间简单路径长度的最大值。

这里有两个容易混淆的概念:

  • 路径长度:路径上边的数量(不加权)。
  • 路径权值和:把路径上所有边权相加(加权)。

如果题目不特别说明“边权为1”,默认是前者。后面我会把加权情况单独拎出来讲,因为两遍DFS在带负权边时会失效,而树形DP可以处理任意边权(包括负数),这个坑很隐蔽。

2.2 直径经典的四个使用场景

我先从工程和算法题两个角度列举几个高频场景,让读者先建立“这东西什么时候能用”的感知:

  1. 网络节点最远距离:在无向无环的网络拓扑中求最长通信链路长度,常用于评估最坏延迟上界。
  2. 树上最远的两个叶子:这种变式在树与树之间找最长公共路径、树的合并操作里很常见。
  3. 与树的重心结合:有些题目要求先求重心再求直径,比如“切掉一条边后让两部分最大直径最小”,解决方案会遍历每条边,反复计算子树直径,核心还是直径算法。
  4. 动态维护树的直径:当树中动态添加叶子节点时,需要O(1)或O(log n)更新直径,此时需要掌握直径的合并性质(即两颗树合并后新直径只可能出现在四种端点组合中)。

2.3 一个必须理解的直观比喻

想象你在一个环形的操场上跑最远距离,但操场是个“树形的迷宫”,路径只有唯一通道,没有回路。树的直径就是迷宫中最远的两点之间的通道长度。这个问题的算法意义在于:不用暴力枚举所有节点对(O(n^2)),而是通过两轮搜索或一轮DP,在O(n)时间内找到答案。

暴力思路大多数人都想得到:枚举所有点对,对每对计算路径长度,取最大。一旦节点数达到20万,O(n^2)基本算不出来。所有的优化都围绕一个目标:利用树的特殊结构,把枚举量降下来。

3. 核心算法一:两次DFS/BFS

3.1 算法的直观理由和完整推导

两次搜索法是最经典的求解方式,步骤极其简洁:

  1. 从任意一个节点u出发,DFS/BFS找到距离u最远的节点v;
  2. 从v出发,DFS/BFS找到距离v最远的节点w;
  3. 路径v -> w的长度就是树的直径。

很多人在第一次看到这个算法时都会问一句:“凭什么?” 我一开始也觉得反直觉,凭什么随便一个点出发能到“某个最远点”,再从那个点出发就能得到全局最长路径?

这里的关键引理是:从任意节点出发,到达的最远节点一定是直径某个端点。

证明思路可以用反证法简述:设直径端点为s, t,任意起点为u,u能到达的最远点设为v。如果v不是s或t,那么通过讨论u在直径上的投影位置,可以推出存在比s-t更长的路径,与直径的假设矛盾。具体推导在许多算法教材上都有,我不贴大段证明,但你要理解核心点:树中没有环,所有路径都必须经过直径上的某段公共路径,所以“离一个点最远的点”会天然被“拽”到直径两端之一上。

这个性质依赖于边权的非负性。一旦出现负权边,上述反证过程会失效,因为绕路可能比直接走更“短”(负权拉低总长),所以“最远”的含义就变了。这也是我强调“带负权不能用两遍DFS”的原因。

3.2 为什么用BFS而不用DFS

严格来说,对无权的树,BFS和DFS都能求最远距离,因为树中任意两点路径唯一,不存在“多条路径取最短”的歧义,DFS走到目标点的路径也就是唯一那条路径,所以DFS一样能算距离。

但在实际工程和代码实现中,我更多推荐对无权树用BFS,理由很简单:BFS天然分层,不需要递归栈,不会因为树形是一条链导致递归深度过大而爆栈。对节点数10^5级别的树,递归DFS一旦遇到链式结构,默认平衡栈很容易挂掉。虽然后面我会给出用栈模拟递归的写法,但如果能用队列,何必跟递归栈过不去。

对带权的树,BFS就不能用了,因为不同边权导致同一层的节点距离不再一致,必须用统一的最短路算法(树退化成无负权图时常用Dijkstra),或者直接用DFS。但在树的两点间唯一路径这个前提下,DFS用来计算“唯一路径长度”是没问题的。

3.3 无权树的BFS代码(C++实现)

我直接给一份能用、实测过、注释清晰的代码。这里用邻接表存图,所以需要处理边的存储结构。

#include <bits/stdc++.h> using namespace std; const int N = 100005; vector<int> adj[N]; // 邻接表 int dist[N]; // 距离数组 int n; pair<int, int> bfs(int s) { // 返回 {最远距离, 最远点编号} memset(dist, -1, sizeof(dist)); queue<int> q; q.push(s); dist[s] = 0; int farNode = s; int maxDist = 0; while (!q.empty()) { int u = q.front(); q.pop(); for (int v : adj[u]) { if (dist[v] == -1) { dist[v] = dist[u] + 1; if (dist[v] > maxDist) { maxDist = dist[v]; farNode = v; } q.push(v); } } } return {maxDist, farNode}; } int main() { cin >> n; for (int i = 0; i < n - 1; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); } auto first = bfs(1); auto second = bfs(first.second); cout << "树的直径长度: " << second.first << endl; cout << "直径端点分别是: " << first.second << " 和 " << second.second << endl; return 0; }

代码逻辑没有任何花哨的地方,第一遍bfs(1)找离节点1最远的点,第二遍从那个点出发搜最远距离。memset每次会把整个距离数组重置为-1,O(n)的代价,所以总体复杂度是O(2n),可以接受。

3.4 带权树的两遍DFS代码(权重非负)

如果边带权,BFS用不了了,但DFS仍然可用。以下用链式前向星存带权边:

#include <bits/stdc++.h> using namespace std; const int N = 100005; struct Edge { int to; long long w; int next; } edges[N * 2]; // 双向边,开2倍 int head[N], tot = 0; long long dist[N]; void addEdge(int u, int v, long long w) { edges[tot] = {v, w, head[u]}; head[u] = tot++; } void dfs(int u, int parent) { for (int i = head[u]; i != -1; i = edges[i].next) { int v = edges[i].to; long long w = edges[i].w; if (v == parent) continue; dist[v] = dist[u] + w; dfs(v, u); } } int main() { memset(head, -1, sizeof(head)); int n; cin >> n; for (int i = 0; i < n - 1; i++) { int u, v; long long w; cin >> u >> v >> w; addEdge(u, v, w); addEdge(v, u, w); } // 第一次DFS dfs(1, -1); int farNodeA = 1; for (int i = 1; i <= n; i++) { if (dist[i] > dist[farNodeA]) farNodeA = i; } // 第二次DFS dfs(farNodeA, -1); long long diameter = 0; int farNodeB = farNodeA; for (int i = 1; i <= n; i++) { if (dist[i] > diameter) { diameter = dist[i]; farNodeB = i; } } cout << "直径长度: " << diameter << endl; cout << "直径端点: " << farNodeA << " " << farNodeB << endl; return 0; }

这里有个明显的隐患:DFS递归深度。如果树的形态是1-2-3-4-...-n一条链,递归层数达到n很大时会爆栈。在竞赛中应对这种情况,要么用#pragma comment(linker, "/STACK:102400000,102400000")增大栈空间(仅Windows MSVC编译器支持,OJ上常见于 GNU 系,这时可用ulimit调栈),要么改成栈模拟DFS。我个人更推荐写栈模拟,后面在“常见问题与坑点”里给出具体写法。

4. 核心算法二:树形DP

4.1 状态设计思路

树形DP是另一种求直径的经典方案,而且它最大的优势是不需要找到端点,也能直接算长度。也就是说,如果题目只问直径长度,DP方案不需要第一遍搜索确定起点。

状态设计很好理解:

设dp[u]表示以u为根的子树中,从u出发向下走到子树内任意节点的最远距离。那么对于节点u,它的任意两个子节点v1和v2,经过u的“最长路径候选值”就是dp[v1] + weight(u,v1) + dp[v2] + weight(u,v2)——也就是从v1子树中某个最深点,走到v2子树中某个最深点,路径必须经过u。

但一个节点可能有多个孩子,直径也不一定要经过u,所以我们维护两个东西:

  • dp[u]:从u往下走的最长链长度;
  • ans:全局最大直径,在DFS回溯过程中不断更新为max(ans, 最长链dp值 + 次长链dp值)。

这里有个容易想不明白的点:为什么“最长链dp值 + 次长链dp值”就是经过u的最长路径?

因为从u出发,向两个不同方向走出的两条链,合并起来恰好是路径在u处“拐弯”。而路径的端点位于两个子树内,那么路径经过u时,它在u处连接了两条往下延伸的链,这两条链必然分别是u某个子树方向的最长链和另一个子树方向的次长链。只要我们枚举每个节点并对所有子树方向维护“第一大”和“第二大”往下走的链长度,全局答案就能覆盖所有跨根路径。

4.2 树形DP的推荐写法

不需要找到直径端点,只需要跑一遍DFS:

#include <bits/stdc++.h> using namespace std; const int N = 100005; struct Edge { int to; long long w; int next; } edges[N * 2]; int head[N], tot = 0; long long dp[N]; long long ans = 0; void addEdge(int u, int v, long long w) { edges[tot] = {v, w, head[u]}; head[u] = tot++; } void dfsDP(int u, int parent) { dp[u] = 0; long long maxDown = 0; // 第一长链 long long secondDown = 0; // 第二长链 for (int i = head[u]; i != -1; i = edges[i].next) { int v = edges[i].to; long long w = edges[i].w; if (v == parent) continue; dfsDP(v, u); long long candidate = dp[v] + w; if (candidate > maxDown) { secondDown = maxDown; maxDown = candidate; } else if (candidate > secondDown) { secondDown = candidate; } dp[u] = max(dp[u], candidate); } ans = max(ans, maxDown + secondDown); } int main() { memset(head, -1, sizeof(head)); int n; cin >> n; for (int i = 0; i < n - 1; i++) { int u, v; long long w; cin >> u >> v >> w; addEdge(u, v, w); addEdge(v, u, w); } dfsDP(1, -1); cout << "直径长度: " << ans << endl; return 0; }

这里有个值得注意的细节:循环里维护maxDown和secondDown,同时更新dp[u]。dp[u]不是所有候选的最大值,而是“最大的那一条链长度”,用于让父节点在计算自己的最远倾向时使用。

4.3 树形DP与两遍DFS的对比

我整理了一个对比表,方便直观理解。

对比维度两遍DFS/BFS树形DP
时间复杂度O(2n)O(n)
是否需要找到端点必须明确端点不需要端点
负权边不可用可用
需要几次遍历两轮独立遍历一轮DFS回溯
实现难度简单直观中等,需要理解状态转移
便于扩展动态增叶子每次全量重算也可以维护,但不如特殊性质快
适用场景无权或非负权、带端点输出任意权重、只求长度、合并子树场景

看到负权边,你们现在应该明白了:如果题目边权可能为负数,两遍DFS/DFS的正确定性依赖非负边权,这时候树形DP是唯一稳定的选择。如果想用两遍DFS求解负权图,需要先证明最长路径的候选端点不会出现“绕远路反而更长”的情况,而这是证明不了的,所以别走弯路。

5. 延伸进阶:与重心结合、多棵树的合并

5.1 直径与树的重心的关系

有一个常见问题:“树的重心一定在直径的中点上吗?”答案是不一定。这句话是很多刚学的人容易记错的,实际上重心是使得删掉它之后剩下最大子树最小的节点,和直径中点没有必然联系。比如一条链的情形,重心是中间节点,直径中点也是它,但换成星型树(一个中心带一堆叶子),重心是中心,直径是两个叶子之间的路径,中点在中心,所以恰好又重合了。这给人一种“好像有关”的错觉。

但遇到非对称树,情况就不同了。直径中点和重心可以偏离很远。在刷题时如果题目要求你切割一条边,使得两部分直径的最大值最小,通常策略是:先求出直径两端,枚举直径上的每条边,分别计算两侧的子树内直径。这里如果直接用重心代替端点会出错,必须回到直径定义本身,先找到直径端点,再做两侧的子树直径。

5.2 动态加叶子时维护直径

如果我们需要往树上加叶子节点,并且实时查询最新直径,最简单的做法是每次加完叶子重新求一次直径,复杂度O(n)一次更新,代价太贵。更优的方式是维护直径的两个端点a和b。

当一个新节点x以父节点p的叶子的形式插入时,新的直径只会出现在以下三种情况中:

  • 原直径不变,仍为a-b;
  • 新路径a-x;
  • 新路径b-x。

只要新节点到a或b的最短距离大于原直径,就更新直径端点。求解距离时,因为树在动态增长,我们可以预处理LCA,每次O(log n)查询距离。整体时间复杂度为O(n log n),比整棵树重算好得多。

这个思路在“动态维护树的直径”类题目里很常见,实际工程中类似“逐步加入新设备到拓扑里、观察端到端延迟上界”的问题也可以套用。

5.3 两棵树合并的新直径

两棵树合并,把两棵树中任意一条边连接起来,合并后的树直径端点只会来自合并前两棵树的四个原有端点中的两个。这是直径的一个漂亮性质,也是先前我们在动态更新那一节用到的基础。

假设第一棵树的直径为a1-b1,第二棵树的直径为a2-b2,两棵树用任意边连接后,新直径的长度最大可能是:

  • 就在第一棵树内部:dist(a1, b1);
  • 就在第二棵树内部:dist(a2, b2);
  • 跨两棵树:dist(a1, a2)、dist(a1, b2)、dist(b1, a2)、dist(b1, b2)。

实现时,只需要在合并后LCA等预处理的支持下,O(log n)求一次距离,比较所有候选,就能O(1)~O(log n)维护合并后的直径。这个思路在竞赛中可以把“维护若干连通块直径”的复杂度压得很低。

6. 实战步骤:从零解题完整流程

6.1 读题确认边界条件

在解决实际题目时,第一步永远不是写代码,而是确认三个关键点:

  1. 树是静态还是动态:静态可直接用两次DFS或树形DP;动态类型需要预处理LCA或倍增。
  2. 边权属性:单权?非负?带负?带浮点?这会决定算法选型的根本方向。
  3. 是否需要输出路径/端点:输出端点推荐两次搜索;只长度则DP更简洁。

6.2 模拟一次完整推导

假设有一棵6个节点的树,边集如下(无向):

  • 1-2 权值1
  • 2-3 权值2
  • 3-4 权值3
  • 2-5 权值4
  • 5-6 权值5

用两遍DFS(非负权)跑一遍:

第一遍从1出发:

  • dist[2]=1,dist[3]=3,dist[4]=6,dist[5]=5,dist[6]=10
  • 最远点是6,距离10

第二遍从6出发:

  • 假设6->5距离5,5->2距离4,2->1距离1,得到 dist[1]=10,dist[3] = 11,dist[4]=14
  • 最远点是4,距离14

枚举验证:6到4的路径为 6-5(5)-2(4)-3(2)-4(3) = 14;1到4是1+2+3=6,不到14;5到4=4+2+3=9。显然直径是14,端点6和4。这个手算过程可以帮助你理解两次搜索的每一步在干什么。

如果是树形DP:自底向上回溯时,节点2的四个方向往下的链分别是1(到1),2+3=5(到4),4+5=9(到6),其中最长链9,次长链5,两者之和14更新答案。肉眼可见地直觉,DP只需要看到节点2这一个点,就把经过它的最远路径抓出来了。

6.3 代码落地顺序建议

我习惯按“三步走”方式落地所有树形题:

  1. 先把图的邻接表建对,确认是无向边且数组容量足够(正向+反向,开2倍);
  2. 选算法:按边权类型选{两次DFS / 树形DP};
  3. 用一个简单的小数据树手测,再用链式结构测递归深度,最后提交。

这三个步骤看起来简单,却能避开绝大部分低级失误。

7. 常见问题与排查技巧实录

7.1 “为什么我用两遍DFS,在带负权边时答案错了?”

负权边导致“从任意点出发的最远点不一定是直径端点”。举一个最小反例:

节点1-2权值1,节点2-3权值-5,节点3-4权值1。整棵树呈一条链,真实的“最长路径”(按路径长度最大定义)是1到4,长度为1-5+1=-3?不对,如果题目要求的是“权值和最大路径”,那这个例子里路径长度为负,很多题目根本不会出这种数据,所以它们才默认边权非负。如果题目真出现了负权且要求最长路径,就得用树形DP,因为树形DP枚举每个节点的最长+次长链,不会因为“选不出最远起点”而失效。

这类陷阱在刷题时十有八九会踩一次,我希望你能在踩之前就看到这段。

7.2 “递归DFS爆栈了怎么办?”

链式树是递归DFS的天然克星。假设n=200000,一条链,每次递归一层,系统栈在C++中默认1MB左右,通常只能支持大约几万层递归。解决办法如下:

  • 方法一:使用栈模拟DFS。对一个链式结构,栈模拟的空间由堆管理,不再受系统栈限制。
  • 方法二:在代码开头增加ios::sync_with_stdio(false); cin.tie(nullptr);提升输入效率,但这对栈没有帮助。
  • 方法三:采用BFS代替DFS(无权图时),天然避免递归深度问题。
  • 方法四:由于树中两点路径唯一,也可以用“带权的BFS退化为DFS思路”的特殊技巧——但实际没有特别靠谱的实现,所以最稳妥还是栈模拟。

这里给出栈模拟DFS的示例代码片段,用于重置dist数组并搜索最远点:

void dfsIterative(int start) { // 使用pair存储(当前节点, 父节点)来模拟递归 stack<pair<int, int>> st; st.push({start, -1}); dist[start] = 0; while (!st.empty()) { auto [u, parent] = st.top(); st.pop(); for (int i = head[u]; i != -1; i = edges[i].next) { int v = edges[i].to; long long w = edges[i].w; if (v == parent) continue; dist[v] = dist[u] + w; st.push({v, u}); } } }

需要留意的是:这只是“深度优先遍历”的迭代实现,并非“完全模拟递归的回溯顺序”,但对求距离来说,遍历顺序不改变结果,因为距离只基于从起点到当前点的唯一路径计算。

7.3 “为什么我在无向树里多加了一条父边判断,反而死循环?”

很典型的误区在于:有些情况下你认为当前节点的父节点只有一个,但其实邻接表里存的是双向边,如果不做父节点判断,DFS会立即在父子两个节点间来回跳。反过来,如果做了if (v == parent) continue;就不会回跳,但要注意,在无向树中这足以避免重复访问,因为树中不存在其他环。

如果不小心把图建成了有环图,那单靠parent判断就不够,必须加visited数组。这里顺带提一句:题目明确说是树,就不需要visited;如果题目说“无向图求最长简单路径”,那就是NP困难的,不能用这套算法。

7.4 边权很大,要不要用 long long?

非常需要。我曾经被一个“边权最大10^9,节点数10^5”的题目卡了半小时,原因只是sum超过了int范围却忘了开long long。树的直径路径上有最多n-1条边,极端情况路径权值和达到10^14,int肯定爆。记住:只要边权是10^9量级或者节点数超过5万,直接long long,别犹豫。

7.5 “要么输出端点,要么只输出长度,代码该怎么调整?”

  • 只求长度:树形DP写法最简洁。
  • 求端点坐标:两遍DFS在第一轮时记录最远点v,第二轮记录最远点w,输出v和w。
  • 求具体路径:在第二次DFS时记录每个节点的前驱节点,最后从终点回溯到起点,翻转输出路径。

后者我贴一段回溯路径用的小片段(配合前面带权DFS代码):

int prevNode[N]; void dfsWithPath(int u, int parent, int pre) { prevNode[u] = pre; // 记录当前节点的前驱 for (int i = head[u]; i != -1; i = edges[i].next) { int v = edges[i].to; long long w = edges[i].w; if (v == parent) continue; dist[v] = dist[u] + w; dfsWithPath(v, u, u); } } // 从终点回溯并逆序输出 vector<int> path; int cur = farNodeB; while (cur != -1) { path.push_back(cur); cur = prevNode[cur]; } reverse(path.begin(), path.end());

顺带说个细节:第一次DFS后要重新把dist数组清零,再跑第二次DFS,否则距离累加会错误。很多初学者在第一次DFS后忘了重置,第二遍搜索得到的结果完全不对,这是排查时最常见的低级错误。

8. 我的一点经验与建议

树的直径这个知识点,我在不同阶段学了至少三遍,每遍都有新感悟。第一遍只知道背模板,能过模板题,但稍微变个形就恍恍惚惚;第二遍认真推导“为什么任意点出发深度最深就是直径端点”,才开始真正理解两次DFS不可替代的前提;第三遍是在工程里维护依赖树动态直径时,我才意识到它和树的合并、LCA、动态规划有那么自然的结合。

我的实操建议很简单:先把两遍DFS跑熟,再把树形DP想透。前者是用直觉打底,后者是用逻辑补全直觉的盲区,两套算法本质上是在解决同一个问题,但角度不同,属于典型的“两条腿走路”。

如果你正在准备面试或竞赛,建议除了会写模板,还要能在白板上讲清证明思路。面试官很喜欢在树的直径后面加一个问题:“如果带负边权怎么办?” 这时候如果你能自然切换到树形DP并解释为什么两次DFS失效,整场表现会明显不一样。

如果你只是工作里偶尔遇到树结构,那么会两遍搜索基本就够用了,至少遇到“网络拓扑最长转发路径”之类需求时,可以直接下手,不用再临时翻资料。树的直径从难度上说,属于“会者不难,但不会的人很容易绕弯”的典型问题,花30分钟彻底掌握,性价比非常高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询