ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

二叉树直径全攻略:路径输出、负权、多叉树与动态维护

二叉树直径全攻略:路径输出、负权、多叉树与动态维护 上一篇写完二叉树直径的常规解法之后后台和评论区收到不少留言问得最集中的几类包括能算出长度但面试官追问“具体是哪条路径”就卡壳树里带负数权重时公式还能不能套以及“又是空指针又是栈溢出到底哪里写错了”。这些问题其实指向同一件事——教科书上那个O(n)的经典解只是起点真正到了面试或者工程现场考验的是你对着各种变体和边界的应变能力。这篇就把直径问题周边的坑一次讲透先补上“输出完整路径”的两种写法再说负权值下公式怎么改然后把问题推广到多叉树和多次查询场景最后聊聊初学者最常见的运行时错误以及线索二叉树这个容易被忽略的考点。读的时候建议打开编辑器跟着敲只看不写记不住。1. 从“最长路径长度”到“完整路径”两步走是面试标配1.1 经典长度解法回顾它到底丢掉了什么先复习一下上一篇的经典解法后面所有变体都从这段代码出发def diameter_of_binary_tree(root): ans 0 def dfs(node): nonlocal ans if not node: return 0 left dfs(node.left) right dfs(node.right) ans max(ans, left right) return max(left, right) 1 dfs(root) return ans逻辑本身很干净每个节点返回自己的高度同时把“左子树高度 右子树高度”作为“经过该节点的最长路径”去更新全局答案。整个过程只产生一个整数所以代码非常短。但代价是——你只知道最长路径有多长完全不知道它长在哪。工程里这种信息往往很要命。我之前在一个内部工具里做过一次目录树分析需要定位“哪两个最深层目录之间的文件链路最长”最后不只是要数字还要把路径打印出来让人去排查。面试时更是这样如果你能把长度算出来却写不出路径复原的代码面试官基本会认为你是背的模板。1.2 方法一拐点记录法在递归里多存一个方向最直接的补充思路既然直径一定会以某个节点为“拐点”路径就是它左边一条最长链加上右边一条最长链那我只要记住每个节点的高度来自哪个孩子然后在全局答案最大的那个节点上顺着方向往下走到叶子即可。实现分两步第一步DFS时同时记录决策def find_diameter_path(root): height {} # 节点 - 高度 direction {} # 节点 - 高度来自哪个孩子: L / R / None global_best [0, None] # (最大直径长度, 拐点节点) def dfs(node): if not node: return 0 left dfs(node.left) right dfs(node.right) if left right: height[node] left 1 direction[node] L if left 0 else None else: height[node] right 1 direction[node] R if right 0 else None cur left right if cur global_best[0]: global_best[0] cur global_best[1] node return height[node] dfs(root) # 第二步从拐点向左/右分别找最远叶子 def walk(node, side): path [] while node: path.append(node.val) d direction.get(node) if d side: node node.left if side L else node.right else: break return path pivot global_best[1] left_path walk(pivot, L) right_path walk(pivot, R) return global_best[0], left_path[::-1] right_path注意一个边界如果拐点某一侧为空高度为0direction被记成Nonewalk循环直接退出最终路径就只是另一侧的一条链。这个写法单树遍历两遍时间O(n)空间O(n)面试时够用了。缺点是引入了两个字典代码显得“重”。1.3 方法二两次遍历加父指针最通用也最好讲如果目标不只是二叉树你甚至不需要“拐点”这种二叉树概念。树上有一个经典结论对一棵边权非负的树从任意节点出发走到离它最远的节点U再从U出发走到最远的节点VU到V的路径就是一条直径。这个性质成立的核心原因是树上任意最远点必然落在某条直径的端点处。你可以这样直观理解如果从P出发的最远点X不在直径端点那么X与直径两端点的距离矛盾会迫使X“代替”其中一个端点成为更远的点最终最远点一定长在直径端点上。所以实现更简单适合边权非负的普通树def farthest_node_and_path(root): parent {root: None} def dfs_to_far(node, fa, dist): parent[node] fa far, far_dist node, dist for child, w in [(node.left, 1), (node.right, 1)]: if child and child ! fa: cand, cand_dist dfs_to_far(child, node, dist w) if cand_dist far_dist: far, far_dist cand, cand_dist return far, far_dist u, _ dfs_to_far(root, None, 0) v, _ dfs_to_far(u, None, 0) path [] cur v while cur is not None: path.append(cur.val) cur parent[cur] return len(path) - 1, path[::-1]这份代码里的parent只在第二次遍历时有效因为第一次是从root搜的第二次才是从u搜第二次的路径才对应真正的直径端点链。实际写的时候要小心不要在第二次复用第一次的parent字典否则回溯链是反的。我第一次实现就踩过这个坑打印出来路径变成了从root到v再拼一段v到u明显发生了回溯断链。对比一下两种方法拐点记录法更贴合二叉树的递归结构负权场景也能本地改造两次遍历法更通用适合推广到多叉树和带权树但不能在有负权边的时候直接用。面试先问清楚题目约定再决定上哪种。2. 权值出现负数直径变体题的“最大路径和”陷阱2.1 负权为什么会直接废掉经典公式大树底下的经典公式ans max(ans, left right)能成立前提是这两条子树高度链的价值只随长度增长。一旦边权或点权出现负数“两条链相加”这个动作本身就可能是亏本的——你完全可能宁可只走一条很短但全正的链也不愿意凑上一条负得离谱的侧链。面试题里最典型的就是LeetCode 124“二叉树中的最大路径和”。注意它的价值定义在节点上节点值可以是负数路径可以只含一个节点。这跟“直径”的区别可以概括成一句话直径求的是长度最长路径和求的是数值最大。同样一棵树这两个问题的答案甚至不在同一个地方。2.2 正确公式把负贡献截断为0核心变化在于递归返回的不再是“高度”而是“从当前节点向下能获得的最大贡献值”而且这个贡献值允许被截断def max_path_sum(root): max_sum float(-inf) def gain(node): nonlocal max_sum if not node: return 0 left_gain max(gain(node.left), 0) right_gain max(gain(node.right), 0) # 经过当前节点的最大路径和 cur node.val left_gain right_gain max_sum max(max_sum, cur) # 作为父节点的一条支链只能挑一边 return node.val max(left_gain, right_gain) gain(root) return max_sum关键就是那两次max(gain(...), 0)。向左或右延伸时如果子树贡献是负数就不如不要相当于那条链被截断为0。这也是和经典直径最大的公式差异所在。2.3 和经典直径的全面对比维度经典二叉树直径二叉树最大路径和价值定义边数 / 路径长度节点数值之和权值约束默认无负权节点值可为负递归返回值子树高度单侧最大贡献可截断为0全局更新式left rightnode.val left_gain right_gain路径能否为空不能至少两端点为叶子或两个节点可以一个点因为负贡献可剪掉如果题目换成了“边权可为负的直径”处理逻辑类似递归返回“从当前节点出发能获得的最大加和路径”更新答案时用两条子链加上当前边权但每侧子链的返回值先和0比较再使用。这样改完代码框架几乎不变变的只是对“负数”的态度。这个题目我建议所有准备算法面试的人都手写一遍因为它是测试“你到底是理解了递归还是记住了模板”的最好题目之一。能把max(gain(child), 0)这一步解释清楚面试官基本不会再怀疑你是背的。3. 从二叉树推广到多叉树top2不是唯一难点3.1 多叉树里公式要从“左右”改成“最大和次大”二叉树里的每个节点最多有两个孩子所以“经过当前节点的最长路径”就是left_height right_height。多叉树里孩子数量不定这个式子要改成取所有子树高度中的最大值和次大值两者相加。为什么一定是最大和次大因为一条路径拐在这个节点时能且只能从两个不同的子树各带一条链上来。你如果想带三条链那路径就出现三叉分岔不是一条路径了。这是理解这一步的关键好多初学者在这个地方绕不出来。3.2 通用树形DP的迭代写法def diameter_of_tree(root): ans 0 def dfs(node): nonlocal ans best1 best2 0 for child in node.children: h dfs(child) if h best1: best1, best2 h, best1 elif h best2: best2 h ans max(ans, best1 best2) return best1 1 dfs(root) return ans顺着代码走一遍每个子树的内部直径已经在它自己的递归帧里更新过ans所以每个节点只需要关注“经过自己”的路径。最终任何一个内部直径都会被它对应的拐点节点覆盖到不需要额外传参。3.3 工程场景目录树和层级结构分析这种多叉树直径不是纯刷题日常生活中其实很常见。比如分析一个文件目录树想知道“哪两个外层目录之间的层级跨度最大”。每个目录可以有很多子目录问题本质就是多叉树直径。另一个我会主动提的场景是权限系统的组织架构。每个部门下面挂着若干子部门部门之间最长的汇报链就是树的直径当你需要评估一次全员消息广播从根节点发出去多久能覆盖最远的下属层级算的就是这棵树的“最大深度”而跨部门最长链路则是直径。这些场景的共同点是树结构是天然存在的问题转化也不复杂但如果你只背过二叉树的解遇到多叉树时当场改写top2这段逻辑容易在边界和返回值上卡壳。4. 多次查询与动态更新直径问题不想每次O(n)遍历怎么办4.1 静态树的多次距离查询欧拉序LCA如果业务里需要反复询问“树上任意两节点距离多远”每次都重新DFS整棵树复杂度就是O(qn)规模一大立刻撑不住。标准做法是预处理出每个节点的欧拉序和深度再用RMQST表支持O(1)查询LCA。# 预处理欧拉序 深度 ST表 first {} euler [] depth_list [] # 对应欧拉序中每个节点的深度 def dfs(u, fa, dep): first[u] len(euler) euler.append(u) depth_list.append(dep) for v in children[u]: if v ! fa: dfs(v, u, dep 1) euler.append(u) depth_list.append(dep) dfs(root, -1, 0) # 用ST表在euler的[l, r]区间里找深度最小节点即为lca # 距离 depth[u] depth[v] - 2 * depth[lca]距离公式本质是u到根的距离加上v到根的距离减掉两倍最近公共祖先到根的距离。这样每条查询就变成O(1)预处理复杂度O(n log n)空间O(n log n)。在大量“两点距离”类查询的评测和业务里这是标配。4.2 动态加叶子时的在线直径维护另一类常见变体是动态插入叶子。每次插入一个新节点x后问题变成当前的树直径是多少这时候有一个很有用的性质两个集合合并后的直径端点一定来自原两个集合各自直径的四个端点中。具体到往树上加一个叶子新集合可以看成“旧树 ∪ {x}”旧树的直径端点是d1、d2那么新直径只需要比较旧直径的长度dist(x, d1)dist(x, d2)取最大即可。为什么是这三个候选因为如果存在另一条更长的路径经过x那它一定是从x出发到某个旧节点的路径而这个旧节点要尽量远。旧树里离x最远的节点必然出现在旧直径的某个端点上——这一步是直径端点合并性质在“一个点和一棵树合并”时的特例。配合4.1的LCA距离查询每次插入后的维护成本几乎就是O(1)次距离计算总复杂度大幅下降。4.3 代价与边界动态维护的前提这个方法的前提是“只往叶子加”。如果允许在树中间插节点会把树结构破坏掉直径端点的维护性质就不再成立。实际面试或设计中遇到动态树先确认清楚插入形式再决定算法路线。我见过有人把动态加叶子直接推广到任意形态的树插入结果答案次次错检查半天才发现性质要求没满足。5. “写二叉树程序时总是报运行时错误”六个反复出现的坑后台热搜里有个词条是“写二叉树程序时为什么总是报运行时错误”这个问题太典型了值得单独开一章。5.1 空指针访问99%的初学报错源头没有做if not node: return 0判断直接node.left裸奔空节点一访问就崩。放在递归里的隐蔽之处在于空指针往往不是第一次调用就出事而是递归深入到某个叶子时才踩到。调试时不要只盯着报错行往上看是不是漏了基线条件。5.2 深度失控导致栈溢出二叉树面试题几乎都是递归写递归调用深度等于树高。正常平衡树深度log n没事但如果输入是一棵退化成链的树深度就是n几万层递归一来就栈溢出。解决办法按场景选面试或本地确认树会不会退化。链表型数据在OJ里很常见。刷题平台适当调大递归限制比如Python设置sys.setrecursionlimit(1000000)。工程或重度递归场景改写成显式栈的迭代后序遍历空间可控。迭代后序遍历配一个“后序栈”其实就是在模拟递归但栈是自己malloc的可以做得比较大不至于爆。5.3 全局变量与捕获方式的错位在直径这类题里全局答案通常在递归中不断更新。常见错误有两个Python里忘记写nonlocal ans这样递归函数里对ans的赋值只会创建一个局部变量外层答案永远是0。C里lambda写[]捕获却试图修改外部int编译直接不过。调试技巧也很直接在更新行打日志看它到底有没有被走进来、值是多少。5.4 高度定义不一致边数还节点数直径题有个经典歧义叶子节点的高度到底是1还是0LeetCode 543是边数路径空节点return 0、叶子return 1ans max(ans, left right)—— 注意这里left、right是子树高度不是深度所以不用加1。如果你把叶子return 0那么最后答案会少2反过来则多2。每次拿到题目先确认单位再动手免得整段重写。5.5 多组数据全局变量忘记重置OJ上多组测试共享同一个全局变量。直径题目特别容易中招上一组算完ans留着旧值下一组直接比较出错。解法是每组数据开始处重新赋0或者干脆把ans包在递归函数外层作为局部变量。5.6 点权题目误用边权模板同样是“最长路径”有的题是节点值累加有的是边权累加。公式高度相似点权的全局更新是left right node.val边权是left right。一混用所有结果全错。这个坑我见过不下五次每次都是代码看起来“没什么不对”其实就是单位错了。调试建议拿到样例先手画树把每个节点对应的递归返回值标出来跟代码输出对一遍。手算和机器输出不一致时单位问题立刻暴露。6. 线索二叉树让遍历像推货架一样往前走6.1 线索二叉树省掉了什么一般的递归遍历要记录一个栈来保证回溯到父节点。线索二叉树的做法是在空指针里存“前驱/后继”信息让遍历过程可以一路顺指针走完不需要栈也不需要系统递归栈空间O(1)。中序线索化里每个节点的left/right空指针分别指向中序前驱/后继同时用ltag/rtag标记它到底是指向孩子还是线索。遍历时你从最左节点开始不断通过右线索或“右子树的最左节点”找后继就像在超市货架上推着车从一头走到另一头——所有节点被线性排成一条货架路线顺着走就行。6.2 线索化能不能直接算直径别急着跳转你可能会想既然遍历能O(1)空间完成直径是不是也能用线索树省空间答案是否定的。直径计算真正的瓶颈不在“能不能遍历所有节点”而在于每个节点都需要拿到左右子树的统计值。线索化优化的是遍历顺序的存储开销它并不会额外记录子树高度也不会给你从后继跳回父节点的能力。所以如果题目考察的是直径线索化帮不上核心忙如果考察的是“线性遍历”线索化才是主角。判断依据很简单看看题目是要求“访问节点”还是要求“聚合子树信息”。前者线索二叉树有价值后者老老实实走后序遍历或树形DP。6.3 线索二叉树的实际适用场景现实里和货架线性遍历最像的需求是把一棵树状分类体系转成一条物理顺序去处理。举个具体例子仓库货架分类是树形的——“食品”下面分“零食”“饮料”“零食”下面再分“膨化”“糖果”。系统要生成一张进货清单按顺序把每个叶子分类扫一遍而且要尽量少走冤枉路。把树中序线索化之后每个分类的后继都提前存好了程序按指针一路走下去不用反复回查父级分类。这类需求用普通递归遍历也写得出来但线索树把整个“线性遍历”变成了纯指针操作在嵌入式设备、递归栈受限的环境里有实打实的优势。面试问到线索二叉树时能说出“它省的是空间不是时间”这句话通常就能得分。我个人带新人时总强调一件事直径题的核心不在于背下那几行经典代码而在于你能对着任意变体快速回答“公式为什么不成立了”。从找路径的两种手法到负权截断再到多叉树和动态维护最后到线索遍历的边界这些内容串起来就是一张完整的二叉树算法地图。刷题时不妨把我上面这些变体每道都手写一遍然后自己把树的形态改成退化链、全负权、多叉树再跑一遍跑通之后你再面对 관련 题目会踏实很多。
返回列表