ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二叉树刷题核心:从遍历到递归,打通Hot 100所有题型

二叉树刷题核心:从遍历到递归,打通Hot 100所有题型 1. 开头Hot 100刷题计划里的“硬骨头”为什么我单独拎出二叉树最近在整理力扣 Hot 100 题单时我发现一个很有意思的现象很多刷题的人数组、链表、哈希表这部分刷得飞快一到二叉树就开始卡壳。不是不会递归而是拿到一道二叉树题目后根本不知道从哪个方向下手——是先序中序后序还是层序是 DFS 还是 BFS是递归还是迭代题目稍微变个形状比如求两个节点的最近公共祖先、把二叉搜索树转成累加树、从前序和中序遍历序列重建二叉树整个人就懵了。这篇博文就是一次针对性极强的梳理。我会基于 Hot 100 里所有二叉树相关题目把这一类题的底层规律拆开从遍历体系讲起再到构建、路径、深度、祖先类问题最后单独抽出一个章节讲二叉搜索树。你把这篇吃透再回头刷 Hot 100 里的二叉树基本上属于“开卷考试”。谁适合来看这篇有两类人一类是刚开始刷 Hot 100、正被二叉树劝退的初学者另一类是刷了不少题但总是“见一道忘一道”的进阶者。前者可以把这篇文章当成一个系统索引后者可以用它来做题型归类和复盘。二叉树在面试中的出现频率我猜经常刷题的朋友都有体会——不是那种“偶尔考一道”的存在而是“几乎每三场技术面必有一道”的常青树。另外提醒一点刷二叉树我的建议是不要追求题目数量而要把结构思维打通。二叉树题目的代码量通常不大难点全在思维模型的建立和递归边界的把控。这也是我写这篇的最终目的——帮你把二叉树背后的模型彻底搞清楚。2. Hot 100 中的二叉树到底在考什么2.1 题量与分布这几道题就是全部的主干我把 Hot 100 里的二叉树题目做了个归类全部列出来其实是能数得清的二叉树的最大深度104、翻转二叉树226、对称二叉树101、二叉树的直径543、层序遍历102、二叉树的最近公共祖先236、二叉树的序列化与反序列化297、从前序与中序遍历序列构造二叉树105、二叉树展开为链表114、验证二叉搜索树98、二叉搜索树中第 K 小的元素230、把二叉搜索树转换为累加树538、路径总和系列112、113、437等等。看上去题目不少但如果你把这些题放到一起看会发现它们重复出现的底层能力只有三类遍历能力、递归拆分能力、对二叉搜索树性质的利用能力。没有跳出这三个圈子的题目。我推荐一个刷题策略先把所有“遍历”类题目刷完再刷“路径与深度”类然后把“构建与序列化”当成本阶段的压轴最后集中处理 BST 专项。因为遍历是基础路径题大多是基于遍历过程中携带变量来做的构建和序列化题是遍历的逆向应用BST 题目则是利用遍历结果的有序性。这个顺序是层层递进的跳步容易卡住。2.2 为什么二叉树在面试里是“兵家必争之地”二叉树之所以被高频考察我个人的理解是它是一种“天然适合递归”的数据结构而递归能力是衡量程序员抽象思维的重要标尺。数组和链表也可以用递归做但它们的迭代写法太自然了递归反而是绕路二叉树不一样它的父子节点层层嵌套递归描述几乎就是数学归纳法本身。还有一个原因二叉树的题目变体极多但核心模型极固定。一道简单的“最大深度”可以延伸出“平衡二叉树”“直径”“路径总和”等问题一道“中序遍历”能延伸出“二叉搜索树迭代器”“第 K 小元素”“累加树”等变形。面试官只需要在基础题上做一个小的条件变化就能把候选人的理解深度试出来。这一点和实际工程里的“需求变化”很像——需求变来变去核心架构不变。另外一个现实的因素是二叉树问题便于面试官在有限时间内考察候选人的代码简洁度。二叉树的递归解法通常十几行就能写完但每行都可能藏一个边界条件或逻辑陷阱。对面试官来说这是一个很好“深挖”的考点你写完了他能连环追问三四个问题。2.3 我的四层拆解法把二叉树题目当成一张地图刷过一段时间之后我把所有二叉树题总结成了一个四层拆解模型每次拿到新题就往这个模型里套第一层结构层。先看题目给的二叉树是普通二叉树、完全二叉树、满二叉树还是二叉搜索树。结构决定了你能用什么性质。比如题目是 BST那么中序有序这个性质大概率会被用上。第二层遍历层。题目需要在树上“走一遍”才能出结果吗如果要是前序、中序、后序还是层序怎么选看的是“处理父节点与子节点的顺序关系”。第三层状态传递层。遍历过程中需不需要往下传参数比如路径总和需要传“当前累加值”最大深度需要传“当前层数”。这个参数应该放在函数入参里还是放在全局变量里第四层返回值设计层。每个递归函数应该向上返回什么是高度、是布尔值、是子树结果还是什么都不返回只做累计这个模型看起来简单但真的能解决大多数二叉树的题目。我后面几节都会反复用到这四层思维建议你先把它记下来。3. 遍历体系所有二叉树题目的“底层操作系统”3.1 递归序里的直觉前中后序到底在表达什么很多刚学二叉树的朋友会死记硬背“前序遍历是根左右中序遍历是左根右后序遍历是左右根”。我建议你换一个方式理解前中后序本质上是递归函数中处理当前节点那行代码放在两次递归调用中间的哪个位置。用递归来想一个节点的处理包括三件事访问当前节点、递归访问左子树、递归访问右子树。你把“访问当前节点”这个动作放在左子树调用之前那就是前序放在左子树和右子树调用之间就是中序放在两个调用之后就是后序。def traverse(root): if not root: return # 前序遍历的位置在这里访问 root traverse(root.left) # 中序遍历的位置在这里访问 root traverse(root.right) # 后序遍历的位置在这里访问 root这个框架看起来简单但它解释了为什么很多复杂题目都在“递归的缝隙”里做文章。比如“二叉树展开为链表”这道题典型的解法就是在后序遍历的位置把左右子树重新连接。你在那个位置处理就能保证左右子树都已经展开完毕你放到前序位置处理子节点还没展开结果必然出错。另外说句实话我刷题早期也走过弯路试图把所有递归都改写成迭代。后来发现没必要。递归的栈开销在刷题场景下完全可接受而且代码可读性远高于迭代。真正需要掌握迭代写法的场景就两个一是面试官明确要求不用递归二是你担心 Python 的递归深度限制。除此之外递归优先。3.2 栈模拟非递归遍历统一写法和注意点如果要手写非递归最常见的做法是用栈来模拟系统栈。这里有一个很实用的“统一法”能让你用一套逻辑同时写前序、中序和后序空节点用 None 做标记第一次遇到节点时把它和左右子节点按逆序重新入栈并在节点前插入一个 None 标记遇到 None 标记时说明该节点已经处理完毕弹栈并访问。但个人实测下来统一法代码虽然对称理解成本却偏高。我更喜欢分别记忆前序、中序、后序的专用迭代写法前序栈先压右子树再压左子树弹出即访问。中序一路向左压栈弹出访问后转向右子树。后序最麻烦需要记录“上一个访问的节点”或者用“前序遍历反转左右顺序”的巧办法。# 后序遍历的巧妙写法前序是“根左右”改一下变成“根右左”再反转结果 def postorderTraversal(root): res [] stack [root] while stack: node stack.pop() if node: res.append(node.val) stack.append(node.left) stack.append(node.right) return res[::-1]这段代码的巧思在于前序是“中左右”我们把压栈顺序调一下让根先弹出然后先压左子树再压右子树弹出顺序就变成了“中右左”最终反转一下就是“左右中”——后序。这个写法我非常推荐因为代码量最小且不容易错。3.3 层序遍历 BFS一个模板吃透一大片层序遍历102 题是 BFS 在二叉树上的标准应用。核心就是用队列维护“当前层节点”每次循环处理整层。def levelOrder(root): res [] if not root: return res q [root] while q: level [] for _ in range(len(q)): node q.pop(0) level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level) return res这里最核心的细节是for _ in range(len(q))在进入循环时先把当前层的节点数记下来这样pop只弹出本层节点而新入队的下一层节点不会被本层消费。很多人写层序遍历出错都是栽在这个地方——用while q而不是for _ in range(len(q))结果一个循环把所有层混在一起了。层序模板可以做大量变形二叉树的右视图199只需要每层取最后一个元素二叉树的锯齿形层序遍历103只需要根据层号决定是否反转该层列表填充每个节点的下一个右侧节点指针116只需要在层内把前一个节点指向后一个节点。这些题的本质都是同一套 BFS换的只是每层处理逻辑。3.4 线索二叉树与 Morris 遍历进阶但不建议优先掌握在热搜词里我也看到了“线索二叉树”这里简单提一下。线索二叉树的思路是把二叉树中大量空余的左右指针利用起来指向遍历序列中的前驱和后继节点。它的工程意义在于不用栈、不用递归也能 O(1) 空间完成中序遍历。对应的现代写法就是 Morris 遍历核心是找到当前节点左子树的最右节点并把它右指针暂时指向当前节点这样遍历完左子树后可以自动回到当前节点。def inorderMorris(root): res [] cur root while cur: if not cur.left: res.append(cur.val) cur cur.right else: pre cur.left while pre.right and pre.right ! cur: pre pre.right if not pre.right: pre.right cur cur cur.left else: pre.right None res.append(cur.val) cur cur.right return res我的建议是Morris 遍历了解思想就好刷 Hot 100 不要求手写。面试中极少要求 O(1) 空间遍历二叉树而且 Morris 遍历的临时改树操作很容易让面试官担心你破坏了原树结构——即便你最后恢复原状了解释成本也很高。优先级放最后。4. 构建、序列化与形态判断类题目从“知道先序和中序确定树的样子”说起4.1 重建二叉树先序分根、中序分左右热搜词里有“知道二叉树先序和中序 确定树的样子”这个知识点对应的就是 Hot 100 第 105 题从前序与中序遍历序列构造二叉树。核心逻辑其实一句话就能说清楚前序的第一个节点一定是整棵树的根然后拿着这个根的值去中序里找位置中序中该位置左边的所有节点属于左子树右边的所有节点属于右子树。然后递归处理左右子树。但真正写代码时有几个细节决定成败。第一个是根节点在中序序列中的索引怎么找得高效。如果每次递归都用index()查找总时间复杂度会到 O(n^2)。我建议先遍历一次中序序列把每个值对应的下标存进哈希表之后每次查找就是 O(1)。第二个是递归区间边界的确定这是最容易写错的。def buildTree(self, preorder, inorder): index_map {v: i for i, v in enumerate(inorder)} def build(p_left, p_right, i_left, i_right): if p_left p_right: return None root_val preorder[p_left] root TreeNode(root_val) i_root index_map[root_val] left_size i_root - i_left root.left build(p_left 1, p_left left_size, i_left, i_root - 1) root.right build(p_left left_size 1, p_right, i_root 1, i_right) return root return build(0, len(preorder) - 1, 0, len(inorder) - 1)这里我强烈建议在草稿纸上画一个“区间对应图”上面是前序数组下面是中序数组每次递归时你都能很清楚地看到当前层的根在前序的哪个位置、子树节点在中序的哪个范围。我见过很多同学背模板写这道题连续写错几次后才发现——根本不是没理解而是区间边界没有对着图去算。画图十秒钟省半小时调 bug 的时间。反过来中序 后序重建二叉树106 题思路一模一样只是根节点要从后序区间的最右侧取。先序 后序则不能唯一确定一棵二叉树因为无法区分左右子树——这属于进阶知识Hot 100 基本不考。4.2 序列化与反序列化把一棵树变成字符串再变回来第 297 题“二叉树的序列化与反序列化”是 Hot 100 里少有的“工程实用性”很强的题。序列化的本质是用遍历结果保存树的结构再通过遍历结果还原树。最常用的编码方式是前序遍历 用特殊字符#表示空节点。序列化时按前序遍历顺序把每个节点的值加入列表遇到空节点加入#。反序列化时按同样的顺序用一个指针逐位读取列表遇到#就返回 None否则创建根节点并递归构建左右子树。def serialize(self, root): def dfs(node): if not node: return [#] return [str(node.val)] dfs(node.left) dfs(node.right) return ,.join(dfs(root)) def deserialize(self, data): vals data.split(,) self.i 0 def dfs(): if vals[self.i] #: self.i 1 return None node TreeNode(int(vals[self.i])) self.i 1 node.left dfs() node.right dfs() return node return dfs()写这道题有一个容易踩的坑Python 里递归函数如果内部要修改索引变量一定要用self.i或者nonlocal声明否则外层递归修改内层不生效。我第一次写这题就在这儿卡了二十分钟后来干脆把索引设计成类的成员变量一劳永逸。这道题用层序遍历也可以做但前序遍历的编码更直观。面试时你只要说清楚“空节点也用占位符记录下来”面试官基本就能确认你真的懂了反序列化的原理。4.3 对称、翻转、子树判断形态类题目的递归通式翻转二叉树226是 Homebrew 作者 Max Howell 去 Google 面试被拒的那道题知名度极高。它其实就是一个后序遍历先递归翻转左右子树然后交换左右孩子。对称二叉树101则是另一个方向比较的是两棵子树而不是一棵树的左右节点。这里说一个规律凡是需要比较“树的两部分是否对称”的题目递归函数都要设计成接收两个节点参数的函数。看起来很简单但很多人会在 101 题里写成传一个根节点进去最后不知道怎么比较左子树的右孩子和右子树的左孩子。def isSymmetric(self, root): def dfs(left, right): if not left and not right: return True if not left or not right: return False return (left.val right.val and dfs(left.left, right.right) and dfs(left.right, right.left)) return dfs(root.left, root.right)子树判断572 题另一个树的子树本质上就是“树 A 的某个节点和树 B 完全一致”。做法是遍历树 A 的所有节点每到一个节点判断以它为根的子树是否等于树 B。判断相等的函数、判断子树的函数两个递归各司其职。这种“双层递归”也是二叉树里很常见的一种形态比如“路径总和 III”也是先序遍历每个节点、再以每个节点为起点做一次路径搜索。5. 深度、路径与祖先二叉树里的“距离感”与“累计感”5.1 深度相关最大深度、最小深度、平衡二叉树最大深度104是二叉树里最经典的递归题也是二叉树递归学习的第一道“Hello World”。解法思路是一棵树的高度等于左子树高度和右子树高度两者之间的较大值再加一。def maxDepth(self, root): if not root: return 0 return max(self.maxDepth(root.left), self.maxDepth(root.right)) 1最大深度的关键是把“子树高度”作为递归函数的返回值。这个思维非常重要因为它在二叉树里是“自底向上”收集信息的经典模式。你不需要显式地维护深度变量只需要信任递归函数能够正确返回子树高度。最小深度111和最大深度有一些微妙差别最小深度是从根节点到最近叶子节点的最短路径上的节点数量。这里最容易犯的错误是直接min(left, right) 1——当一棵子树为空时空子树的高度是 0但“空子树”不是叶子节点你不能拿 0 去和另一棵子树的高度比。正确做法是如果某个子树为空则只能从另一边走。平衡二叉树110是最大深度的延伸定义是任意节点的左右子树高度差不超过 1。最简单的思路是每到一个节点都算一下左右子树高度判断后递归处理左右子树。这个版本代码只有十几行但时间复杂度是 O(n^2)因为每个节点都被重复计算高度。优化方案是“自底向上的剪枝”子树不平衡时直接返回 -1不再向上计算。def isBalanced(self, root): def height(node): if not node: return 0 left_h height(node.left) if left_h -1: return -1 right_h height(node.right) if right_h -1: return -1 if abs(left_h - right_h) 1: return -1 return max(left_h, right_h) 1 return height(root) ! -1这个优化版本我在面试中用过一次面试官明显眼神一亮——他以为我会写最朴素的版本结果我给他演了一遍“如何从 O(n^2) 优化到 O(n)”。5.2 路径总和系列从“到叶子”到“任意路径”路径总和 I112判断是否存在一条从根节点到叶子节点的路径使节点之和等于目标和。这是最简单的递归决策题只要在叶子节点判断当前累计值是否等于目标即可。路径总和 II113不仅判断是否存在还要把所有满足条件的路径打印出来。这里需要在递归过程中携带一个path列表并把满足条件的列表拷贝一份到结果里。这里有一个经典坑直接把path加入res后面递归回溯修改path时已加入res的列表也会被改掉。解决办法是res.append(path[:])或者用copy.copy(path)深拷贝一层。我个人一开始觉得很别扭为什么 Python 里res.append(path)后面path.pop()res里的内容也会变后来才彻底理解Python 的列表存的是引用res里的元素只是指向了同一个列表对象。你修改它它就跟着变。在递归回溯类题目里凡是往全局结果中加入列表的都要考虑是否需要拷贝一份再存。路径总和 III437难度上了一个台阶因为不要求路径的起点是根节点也不要求终点是叶子节点。最常见的解法是“双层递归”外层递归遍历每个节点把它当作路径起点内层递归从该起点出发统计有多少条向下的子路径和为 targetSum。这个方案时间复杂度 O(n^2)但胜在思路清晰面试时先讲清楚这个再提前缀和优化到 O(n) 的方案会更有说服力。前缀和做法的核心是维护一个“从根节点到当前节点的路径前缀和”如果某一条路径的前缀和等于另一条更早的前缀和加 targetSum就说明这一段路径的和正好是 targetSum。用哈希表记录前缀和出现的次数能在遍历过程中 O(1) 查出来。这个优化我在实际刷题中花了不少时间才真正搞懂强烈建议画一棵树手动模拟一遍。5.3 最近公共祖先 LCA后序遍历的经典应用二叉树的最近公共祖先236是 Hot 100 中难度较高的一道题很多同学第一次看到完全没有思路。它的核心思路是从底向上找用后序遍历自然实现“先看子树里有没有 p 和 q再决定当前节点是不是 LCA”。def lowestCommonAncestor(self, root, p, q): if not root or root p or root q: return root left self.lowestCommonAncestor(root.left, p, q) right self.lowestCommonAncestor(root.right, p, q) if left and right: return root return left if left else right这段代码极其精简但每行都有含义。if not root or root p or root q处理了三种情况遇到空节点返回空当前节点就是 p 或 q直接返回当前节点。然后分别向左右找如果左右各返回了一个非空结果说明 p 和 q 分别位于当前节点的左右子树那当前节点就是公共祖先。如果只有一侧有非空结果说明 p 和 q 都在同一侧直接返回那一侧的结果。这个“返回非空结果的逻辑”其实是整道题的精髓它把一个看似复杂的“寻找公共祖先”问题简化成了“判断左右子树搜索结果的合并规则”。你如果只是背代码遇到变体题比如二叉搜索树的最近公共祖先 235会愣住如果理解了返回逻辑235 就是你顺手加一个判断的问题。5.4 最大路径和与直径全局变量在递归里怎么用二叉树的直径543是“最长路径长度”的问题这个路径不一定经过根节点。核心思路是直径一定经过某个节点并且等于左子树深度加右子树深度。你只需要在计算深度的递归过程中顺便用一个全局变量记录“每个节点的左深度 右深度”的最大值。二叉树中的最大路径和124是 543 的加强版路径和不一定等于左深度加右深度因为节点的值可能有负数。这时候你对子树能“贡献”的东西要做一个取舍如果某个子树往下走的最大路径和是负数那就干脆不选它相当于取 0。def maxPathSum(self, root): self.ans float(-inf) def one_side_max(node): if not node: return 0 left max(0, one_side_max(node.left)) right max(0, one_side_max(node.right)) self.ans max(self.ans, left right node.val) return max(left, right) node.val one_side_max(root) return self.ans这里体现了一个重要的模式有些信息既要向上返回又要参与全局比较。比如这里递归函数向上返回的是“包含当前节点的单边最大路径和”因为父节点只能使用你的一条分支而全局变量记录的是“以任意节点为拐点的最大路径和”因为路径允许在这里拐弯。很多二叉树难题都长这样——你向上的返回值和全局统计值是两个不同的量。想清楚这个区别二叉树的难题至少解决一半。6. 二叉搜索树专项升序性质的“隐形排序数组”6.1 BST 与中序遍历为什么它们天生一对二叉搜索树的定义左子树所有节点小于根节点右子树所有节点大于根节点左右子树各自也是 BST。这个定义带来的一个核心推论是BST 的中序遍历结果是严格递增的。这个性质太重要了。它意味着你在做 BST 题目时可以把“树”的问题降维成“有序数组”的问题。比如求第 K 小元素230你只需要中序遍历数到第 K 个节点就行了。把 BST 转成累加树538你只需要变一下遍历顺序右 - 根 - 左也就是“逆中序”这样遍历过程中你能一直维护一个累加值每个节点更新为累加值加自身值。我第一次做 538 的时候第一反应是“先中序遍历存数组再倒序累加再重新赋值”。这个思路能 AC但不是最优。最优思路是直接在逆中序遍历的过程中用一个变量累计。面试时如果能直接讲出逆中序遍历的解法会显得你对 BST 性质理解更透。6.2 验证 BST最容易犯傻的一道“基础题”验证二叉搜索树98看起来简单递归判断每个节点是否满足“左小于根、右大于根”。但如果你只判断当前节点和左右孩子的大小关系就会漏掉一种情况右子树里可能出现一个比根节点小、但比右子树根节点大的节点。正确的做法是在递归过程中维护当前节点值的合法范围。进入左子树时上限更新为当前节点值进入右子树时下限更新为当前节点值。如果某个节点值越过了当前范围直接返回 False。def isValidBST(self, root): def valid(node, low, high): if not node: return True if node.val low or node.val high: return False return valid(node.left, low, node.val) and valid(node.right, node.val, high) return valid(root, float(-inf), float(inf))这里还要注意一个语言特性如果用 Python初始上下界用float(-inf)和float(inf)是最稳妥的。如果节点值正好等于边界值也应该返回 False因为 BST 要求严格大于、严格小于。6.3 BST 中的插入、删除与搜索普通的递归加一点形状处理二叉搜索树的搜索700和插入701都是模板题。搜索的递归写法很直白当前节点为空则找不到当前值等于目标则返回目标小于当前值则去左子树找否则去右子树找。插入其实就是在空位放一个新节点。删除 BST 节点450就复杂一些根据删除节点的孩子数量分为三种情况无孩子直接删除返回 None。一个孩子返回那个孩子即可。两个孩子这是最经典的情况。标准做法是找右子树中的最小节点或者左子树中的最大节点来填补被删节点的位置然后递归删除那个最小节点。def deleteNode(self, root, key): if not root: return None if key root.val: root.left self.deleteNode(root.left, key) elif key root.val: root.right self.deleteNode(root.right, key) else: if not root.left: return root.right if not root.right: return root.left min_node root.right while min_node.left: min_node min_node.left root.val min_node.val root.right self.deleteNode(root.right, min_node.val) return root注意在“两个孩子”的分支里我们把右子树的最小节点值赋给当前节点然后递归去右子树删除那个最小节点。这是一个典型的“替换删除”法思路是保持 BST 性质不变。很多人在面试时卡在这一步其实就是没有想清楚“右子树最小节点一定没有左孩子”这个性质——它只会有一个右孩子或者没有孩子所以删除它的时候不会退化成“有两个孩子”的复杂情况。6.4 BST 的最近公共祖先普通 LCA 的“降维打击”二叉搜索树的最近公共祖先235比普通二叉树的 LCA 简单太多。因为你完全不需要遍历整棵树只需要利用 BST 的值大小性质做方向判断p 和 q 都小于当前节点往左子树找。p 和 q 都大于当前节点往右子树找。否则当前节点就是分岔点即 LCA。这段逻辑的代码只有几行而且可以写成迭代形式。这个题和 236 放在一起对比特别有意思236 需要从下往上搜索235 只需要从上往下判断。一道题考的是“状态合并”一道题考的是“规模缩小路径选择”。如果你能把这两题的差异讲清楚面试官对你在 BST 方向上的理解基本就放心了。7. 高频坑点与调试心得这些坑我都替你踩过了7.1 递归函数返回值语义混乱二叉树递归题最常见的错误就是递归函数的返回值语义没有想清楚。比如求直径的递归函数它向上返回的是“单边最大深度”但更新全局答案时用的是“左深度 右深度”。如果你把返回值设计成“整棵子树的最大路径”父节点一拿到的就是错误信息结果必然错误。我的经验是写递归之前先在心里默念三遍这个函数要返回什么。返回子树高度返回布尔值返回根节点还是返回子树的某种状态每一层递归都依据这个定义来写剩下的只是机械翻译。7.2 空节点和叶子节点的边界处理我看到很多初学者在递归里对None的判断不够重视。最大深度可以if not root: return 0但最小深度就不能这么粗暴因为空子树的高度是 0但空子树不能当作“叶子”。路径总和的递归终止条件一定要同时判断“当前节点是叶子”和“左右孩子都是空”不能只判断值相等就返回 True因为中间节点也可能值恰好等于目标但路径还没走到头。最好的办法是每一道二叉树题都先想清楚“空节点返回什么”“叶子节点返回什么”。这两个边界想清楚了整道题就成功了一半。7.3 Python 递归深度限制和可变对象引用问题在 LeetCode 上刷二叉树Python 默认递归深度是 1000大多数测试用例不会爆。但如果你遇到一条链状的极端输入比如退化成一个链表递归深度可能接近节点数这时候有概率触发RecursionError。Hot 100 的二叉树题目基本不会出现这种情况但我在刷某些极端测试用力的题目时确实碰到过。另一个更常见的 Python 坑是可变对象作为默认参数或全局结果收集器。比如路径总和 II 时你往结果列表里追加 path 但又忘了拷贝最终结果全是同一个被回溯清空的列表。遇到这种情况建议统一用res.append(path[:])这种浅拷贝方式。如果嵌套更深一层比如二维列表就得用copy.deepcopy。7.4 调试技巧别靠“盯着代码看”用最笨的方法找问题二叉树类题目调试起来比数组题困难因为你很难在脑海里模拟递归过程。我的调试习惯是先在代码关键位置插入print输出当前节点值和递归参数再构造一个最简单的测试用例跑一遍。比如验证 BST 那道题我调试的时候会打印每次进入valid函数的节点值、low 和 high立刻就能看出是哪个节点越界。还有一个很实用的技巧写一个“打印二叉树”的小工具函数把树结构以比较容易看清的缩进格式输出。调试“构建类”题目如重建二叉树、反序列化时这个工具能让你一眼看出构造出来的树长什么样比看 submit 结果的报错信息高效太多。我至今保留着一个打印二叉树的脚本在我的刷题配置文件里每次换电脑都要重新复制过去。7.5 题目之间的联系比题目本身重要最后分享一个刷题理念不要孤立地去记忆每道题的解法。我个人在复盘时会花时间把相关题目横向关联起来。比如最大深度104、平衡二叉树110、直径543、最大路径和124是一条线核心都是从子树收集高度信息。层序遍历102、右视图199、锯齿形遍历103是一条线核心都是 BFS 逐层处理。重建二叉树105、序列化与反序列化297是一条线核心都是遍历结果的逆向应用。验证 BST98、第 K 小元素230、累加树538是一条线核心都是 BST 中序有序。你把每一条线串起来就会发现二叉树的题目远远没有你以为的那么多。Hot 100 里那些看起来花样百出的题本质上只是少数几个核心思维模式的变化。把模式吃透比盲刷一百道新题有用得多。8. 写在最后的一点题外话我之前刷二叉树的时候最大的一个感触是这一类题特别吃“手感”。不是说你理解了递归思想、看懂了题解就能举一反三。你得真的在编译器里一遍一遍地调试看着那些None和边界条件把自己绕晕再一点一点把逻辑捋顺才能真正建立起直觉。所以如果你现在正被某道二叉树题卡住别急着看题解。先翻回去看我上面讲的那几个基础递归函数返回值定义清楚了吗空节点的边界处理对了吗遍历顺序是前序、中序还是后序如果这些都答不上来说明不是这道题的问题是底层体系还没稳固。回去把遍历代码多敲几遍比硬啃难题有效率得多。我自己的经验是把 Hot 100 里二叉树这十几道题按我前面说的四层拆解法全部过一遍之后再遇到新的二叉树题目基本十分钟内能定下解法的方向——不是因为我聪明而是因为二叉树题目的套路就那么多见过的多了自然就熟了。希望这篇梳理能帮你把这个过程缩短一点。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进