多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

二叉树算法题核心套路:遍历框架、递归思维与运行时错误排查

二叉树算法题核心套路:遍历框架、递归思维与运行时错误排查 很多人学数据结构学到二叉树这一章教材能看懂实验报告能凑出来但一碰算法题就卡壳甚至偶尔照着书抄代码都报运行时错误。这种感觉我太熟了刷了几百道树相关的题之后回头看发现二叉树算法题的核心其实特别集中遍历框架怎么搭、递归为什么是解题灵魂、深度和搜索二叉树这类高频题型有哪些固定套路以及那个让无数人头疼的“运行时错误”到底该怎么排查。这篇文章把这些问题一次说透。示例代码用Python写但思路换成C、Java、C完全无障碍因为数据结构考察的是思维不是语言。适合三类人正在准备数据结构期末考试或考研的同学、刚开始刷LeetCode找工作的求职者、总觉得二叉树“会看但不会做”的自学者。1. 二叉树的遍历框架所有算法题的“母题”二叉树题目看起来千变万化有的让你求最大深度有的让你判断是不是搜索二叉树有的让你把整棵树序列化成字符串再反序列化回来。但只要你把题目拆到不能再拆最后一定落在同一件事上把树完整地走一遍。遍历就是二叉树所有题目的母题遍历不熟后面所有题都是空中楼阁。树的遍历分成两大类深度优先遍历DFS和广度优先遍历BFS。深度优先又按根节点的访问时机分出先序、中序、后序三种BFS对应层序遍历一层一层往下扫。我刚开始学的时候被“先序中序后序”绕晕过一阵后来发现直观的记忆方式这个“序”说的不是别的就是根节点相对于左右子树的位置。左子树永远比右子树先访问变的只是根节点插在哪而已。1.1 为什么说遍历是二叉树题目的地基先看一个现实中的比喻。你去超市盘点货架想看货架上有多少种饮料、最重的那箱牛奶在第几排第一步肯定要把货架从头到尾走一遍。电脑处理二叉树也一样它不能像人一样“一眼”看到整棵树只能一个一个节点去访问。怎么保证不重复、不遗漏地访问所有节点这就是遍历算法要解决的事。几乎所有二叉树题目都是在“已知遍历方式”的前提下追加额外计算。比如统计叶子节点数量本质是先序遍历加一个if判断当前节点没有左孩子也没有右孩子计数加一。比如求树的最大深度本质是后序遍历先知道左子树有多深、右子树有多深再比较取大。再比如判断二叉树是否对称本质是同时做两个方向的遍历逐个比较对应位置的节点值。所以如果你拿到一道题完全没头绪先别急着找技巧先问自己用哪种遍历方式能把所有信息过一遍这个问题想明白题目通常已经走了一半。1.2 递归遍历的三要素与万能模板为什么二叉树题目首选递归因为二叉树本身就有递归结构一棵树由一个根节点和两棵更小的子树组成子树又由根节点和更小的子树组成直到空节点。递归函数正好描述了这种“自己包含自己”的结构。写递归遍历抓住三要素确定终止条件当前节点为空就返回。确定当前节点要做什么访问、累加、交换按题目来。确定递归调用顺序先左还是先右处理当前节点放在调用前还是调用后。先序遍历的万能模板def preorder(root): if root is None: return # 前序位置先访问根 visit(root) preorder(root.left) preorder(root.right)后序只需要把visit(root)挪到两次递归调用之后。很多初学者以为前中后序是三种完全不同的算法其实只是同一段代码里“处理根节点”这一行摆放的位置不同。想明白这一点遍历这块就算真正入门了。这里有个常见低级错误忘了判断空节点或者在判断空节点后没有立即return结果函数继续往下执行访问到None的属性运行时错误。判断空节点和立刻返回必须连在一起写中间不要夹别的逻辑。1.3 迭代遍历模拟系统栈的思路有些场景要求不用递归比如实验报告题、考研笔试题以及面试官刻意考察栈和队列的功底。递归代码之所以简洁是因为系统在背后帮你维护了调用栈手动迭代则需要自己模拟这个栈。先序迭代经典写法def preorder_iter(root): stack [] cur root while stack or cur: while cur: visit(cur) stack.append(cur) cur cur.left cur stack.pop() cur cur.right思路是一路向左走到底每经过一个节点先访问再入栈走到空节点后弹出栈顶节点转向右子树继续“向左走到底”。中序迭代几乎一样唯一变化是把访问从入栈前移到弹出后——因为中序要求“先左子树、再根、再右子树”而栈的弹出顺序天然从最左节点开始。后序迭代稍麻烦最省脑子的技巧是先按“根-右-左”顺序遍历最后反转结果def postorder_iter(root): result [] stack [root] while stack: node stack.pop() if node: result.append(node.val) stack.append(node.left) stack.append(node.right) return result[::-1]层序遍历则是BFS的标准应用队列实现且按层分组时要先记录当前队列长度再一次性弹出这么多节点否则会出现混层。层平均值、二叉树的右视图、锯齿形层序遍历全是在这个模板上改的。我强调迭代遍历还有一个重要原因很多“总是报运行时错误”的案例本质就是递归深度太大导致栈溢出。改写成显式栈的迭代版本后问题直接从根上消失。第3节会专门展开这个坑。2. 高频题型与套路拆解2.1 二叉树的深度最大深度、最小深度与直径求“二叉树的深度”是数据结构课上最经典的题也是实验报告和期末卷里的常客。最大深度是从根节点到最远叶子节点的最长路径上的节点数。递归代码简洁到可以背def max_depth(root): if root is None: return 0 return 1 max(max_depth(root.left), max_depth(root.right))这段代码正确的前提是理解递归返回值的含义max_depth(root)返回以root为根的子树的层数。空树是0非空树等于左右子树较大值再加1为什么加1因为要算上根节点自己这一层。最小深度就比看上去狡猾。题目定义是从根节点到最近叶子节点的最短路径上的节点数。如果直接模仿最大深度写1 min(...)在只有左子树的树上必错。因为空右子树返回0min会把0也算进去答案变成1但真正的最近叶子节点在左子树深处。叶子节点的定义是没有孩子空节点根本不是叶子。正确做法是分类讨论左子树为空就只走右子树右子树为空就只走左子树两边都不为空才取较小值。这道题建议每个人都亲手踩一次踩过之后对“递归出口要和定义严格对齐”这句话会有刻骨铭心的理解。二叉树的直径是另一个高频变体任意两个节点间路径上的节点数最大值。这条最长路径可能不经过根节点但它一定有一个“拐点”也就是路径上离公共祖先最近的那个节点。做法是遍历所有节点对每个节点求“左子树深度右子树深度”维护全局最大值。这里递归函数返回的是“单边深度”全局变量记录的是“以当前节点为拐点的最大路径长度”。这两个概念分离是直径题的核心难点请务必记牢。2.2 搜索二叉树BST判定与性质利用搜索二叉树也叫二叉搜索树、二叉排序树是考研数据结构必考项也是LeetCode高频题。定义很简洁左子树所有节点值小于根右子树所有节点值大于根左右子树各自也是BST。拿到定义最容易踩的坑是写一个只看父子节点的判断左孩子小于根、右孩子大于根然后递归。这个写法对付小树没问题但会放过一个经典反例根节点是5左孩子是3而3的右孩子是6。按“左孩子根”看3小于5没问题按“右孩子父节点”看6大于3没问题。但整棵树不是BST因为6作为根节点5的左子树成员必须小于5结果它比5还大。问题就出在只比较了相邻节点没有约束整棵子树的取值范围。正确的解法是给递归函数传上下界左子树节点的值必须在负无穷到根节点值之间右子树节点的值必须在根节点值到正无穷之间每往下一层区间收窄一次。def is_valid_bst(root, lowfloat(-inf), highfloat(inf)): if root is None: return True if not (low root.val high): return False return is_valid_bst(root.left, low, root.val) and is_valid_bst(root.right, root.val, high)另一个思路是利用BST和中序遍历的关系中序遍历一棵BST得到严格递增序列。于是可以中序遍历并检查当前值是否大于上一个值。这个方法代码同样很短而且完全等价。建议两种都掌握因为“BST中第K小的元素”这类题天然适合利用中序递增的性质。2.3 线索二叉树让遍历不走回头路的经典优化线索二叉树在考研数据结构里地位特殊。普通二叉树中单个节点只知道自己的左孩子和右孩子想知道中序前驱或中序后继只能重新从根遍历一趟代价很高。线索二叉树的思路是把那些空闲的指针利用起来。节点没有左孩子就让左指针指向中序前驱没有右孩子就让右指针指向中序后继。线索化最关键的问题是区分“这个指针是真的孩子指针还是线索指针”。解决方案是给每个节点增加两个标志位ltag和rtag0表示指向孩子1表示指向前驱或后继。线索化一般走中序遍历处理当前节点时如果左孩子为空就把左指针指向前驱节点置ltag为1如果前驱节点的右孩子为空就让前驱节点的右指针指向当前节点置前驱的rtag为1。这个“前驱节点的右线索由后继节点来补”的操作第一次写很容易漏。线索二叉树现在工程上用得少但它考察的是“在不改变数据结构基本形态的前提下用标志位榨干存储空间”的思维。这种思维在后面的B树、B树、数据库索引结构里还会反复出现学的时候别嫌它绕。2.4 二叉树的应用场景表达式树与哈夫曼树刷题刷多了容易产生“二叉树只是刷题工具”的错觉其实它应用极广。第一个典型是表达式树。表达式(a b) * c - d可以表示成一棵二叉树内部节点放运算符叶子节点放操作数。对这棵树做后序遍历得到后缀表达式做中序遍历得到中缀表达式。很多“基于栈的算术表达式求值”实验题往深一层看和表达式树共享同一套“先处理子树再处理根”的顺序逻辑。第二个是哈夫曼树也就是最优二叉树。根据字符出现频率构造哈夫曼树得到哈夫曼编码这是数据压缩的基础。构造过程不停地合并权值最小的两棵树。期末卷子爱考构造流程思想上其实就是不断“挑选、合并、调整”的贪心但载体是二叉树。第三个是堆。堆在逻辑上是一棵完全二叉树物理上存在数组里但完全符合二叉树的性质。优先队列、TopK问题、堆排序都建立在堆这个“数组版二叉树”上。二叉树概念吃透了学堆的“上浮”“下沉”会非常顺。此外文件系统的目录结构、公司组织架构、语法分析树都是树结构的直接应用。理解这些真实场景再回头看“超市货架遍历二叉树”之类的生活化比喻就会明白树的本质就是“有层级关系的数据结构”遍历就是去盘点这张网上的每一个点。2.5 二叉树的重建从遍历序列还原树重建二叉树是另一类常考题已知中序序列 先序或后序、层序序列要求还原整棵树。LeetCode第105题是经典代表。核心思路在中序序列的特点根节点左边是整棵左子树右边是整棵右子树。所以只要在先序序列里确定根节点再去中序序列里定位根节点位置就能把序列切成左右两半然后递归处理。def build_tree(preorder, inorder): if not inorder: return None root_val preorder.pop(0) root TreeNode(root_val) idx inorder.index(root_val) root.left build_tree(preorder, inorder[:idx]) root.right build_tree(preorder, inorder[idx1:]) return root这个写法很直观但pop(0)每次是O(n)操作优化时可以改用一个索引变量或者先把preorder反转成栈再pop()。面试里如果被问“为什么只有先序和后序不能唯一重建”答案是先序只能确定根无法切分左右子树的边界遇到单分支树会出现多种合法结构。和重建相关的还有序列化与反序列化问题基本做法是用前序遍历把树编码成字符串空节点用特殊符号代替反序列化时按同样顺序恢复。序列化的本质其实就是“带着空补全信息的遍历记录”。3. 写二叉树程序为什么总是报运行时错误根因诊断与修复“写二叉树程序时为什么总是报运行时错误”这个问题我自己也曾经被折磨过很久。排查下来这类错误通常集中在几个非常可预测的原因上。3.1 空节点取属性最高频的运行时错误最常见的一类是空指针取属性。Python报AttributeError: NoneType object has no attribute valJava报NullPointerExceptionC/C大概率直接段错误。原因只有一个递归进行到空节点没有先判断就直接访问了root.val、root.left。一个小例子def faulty(root): return faulty(root.left) 1 # 没有判断空只要树不是完全平衡总会走到空节点这一行必炸。修法是递归函数开头无条件加判断def fixed(root): if root is None: return 0 return fixed(root.left) 1注意“无条件”三个字。因为很多人只在某个分支里判断了空另一个分支漏了报错就成了“玄学随机出现”。干脆所有递归函数首行一律写空判断不给错误留机会。3.2 构建树时埋下的雷循环引用与结构错误第二类错误隐蔽且恶心报错不在算法逻辑里而在于手写测试数据时把树构建错了。n1 TreeNode(1) n2 TreeNode(2) n1.left n2 n2.left n1 # 手滑把n1设为n2的左孩子成环了树里出现环递归遍历进入环后永远退不出来栈空间耗尽Python表现为RecursionErrorC系表现为栈溢出。这时你的第一反应一定是“算法写错了”但算法没有任何问题是测试数据本身就是错的。排查手法有两个。第一遍历前先打印节点值或者给节点加一个visited标记遍历时发现重复访问就基本断定成环。第二构造小规模测试用例逐步加节点把出错规模缩到最小复现。多数情况下问题会在构建代码里自己跳出来。3.3 递归深度过大与Python栈限制第三类是递归深度超过默认限制。Python默认递归上限大约1000层二叉树一旦退化成一条链比如每个节点只有一个右孩子递归深度就可能破千然后抛RecursionError。代码看起来没有任何错误却崩溃特别迷惑。临时救急可以用sys.setrecursionlimit(10000)但这治标不治本。更稳定的做法是把递归改成显式栈的迭代写法。前面特意花篇幅讲迭代遍历除了应付“不让用递归”的考试要求核心原因就是它能根治深层递归崩溃问题。3.4 从实验报告看递归返回值设计最后一种“报错”不是运行时错误而是答案错误。程序跑得动结果不对。这种问题多半出在递归返回值设计上。典型例子是统计叶子节点数量def leaf_count(root): if root is None: return 0 if root.left is None and root.right is None: return 1 return leaf_count(root.left) leaf_count(root.right)这段代码是对的但前提是清楚“返回值是以root为根的子树中的叶子个数”。返回值定义一旦含糊就会出现结果差1、结果是0这种玄学。写任何递归函数之前先问自己一句这个函数到底返回什么最好把它写成注释放在函数上方比如# 返回以root为根的子树中的叶子节点数量。别小看这一行注释它能避免大量返工。4. 实战拆解三道必刷二叉树算法题前面讲了很多套路但纸上得来终觉浅这里挑三道很有代表性的LeetCode题从审题到最后落地完整走一遍。4.1 翻转二叉树LeetCode 226题目很直白把一棵二叉树的所有左右子树交换返回翻转后的树。这道题因为一个创业者的段子而广为人知也恰恰说明它是基础中的基础。递归解法def invert_tree(root): if root is None: return None root.left, root.right root.right, root.left invert_tree(root.left) invert_tree(root.right) return root核心是理解“前序位置”的代码效果先交换当前节点的左右孩子再递归处理已经交换过的左右子树。你可能会问先递归再交换行不行也可以因为后序位置同样能完成左右子树交换。真正的问题是“中序位置行不行”建议你亲手写一下你会发现交换左子树之后原来的左子树变成了右子树再递归右子树等于处理了已经被改过的子树结果乱套。这个细节经常被拿来考察对遍历位置的理解。复杂度也简单每个节点访问一次时间O(n)递归栈深度和树高相关空间O(h)最坏情况退化链表是O(n)。4.2 路径总和LeetCode 112题目给一棵二叉树和一个整数targetSum判断是否存在一条从根到叶子的路径路径上节点值之和等于targetSum。很多人的第一反应是“累加路径和到叶子再比较”。这个思路没错但代码要传累加值还要处理回溯。更简洁的思路是“剩余值递减”每递归一层目标值减去当前节点值到叶子时只要比较一次。def has_path_sum(root, target): if root is None: return False if root.left is None and root.right is None: return target root.val return has_path_sum(root.left, target - root.val) or has_path_sum(root.right, target - root.val)有个考点要特别注意为什么不能写成if root is None and target 0: return True因为题目要求根到叶子的路径遍历到空节点不构成合法路径只有到达真正的叶子节点才算数。这一条看似细枝末节却是很多人调试半天过不了测试用例的原因。延伸一下二叉树的所有路径LeetCode 257就是遍历时把路径记录进数组自己维护一个path列表并注意回溯路径总和IILeetCode 113要收集所有满足条件的具体路径需要在递归结束后弹出当前节点是标准的“递归回溯”模板。模板练熟了这系列题几乎全通。4.3 二叉树的最大路径和LeetCode 124这是二叉树题目里少有的“劝退题”也是“递归返回值与全局答案分离”的最佳教材。题目要求找一条任意节点到任意节点的路径使节点值之和最大。这个路径可以只走一条边也可以穿过某个节点拐弯甚至可以只包含一个节点。核心观察是任何一条路径都有且只有一个“最高节点”也就是路径上离根最近的那个节点。以最高节点为界路径等于“从最高节点向左下延伸的一条链” “最高节点本身” “从最高节点向右下延伸的一条链”。于是设计两个角色递归函数dfs(node)返回“以node为端点的单边最大贡献”即从node出发向下只走一条链能得到的最大值。如果分支贡献为负干脆不走等价于和0取max。全局变量ans记录“穿过当前节点的最大路径和”每访问一个节点就用“左贡献 节点值 右贡献”去更新它。def max_path_sum(root): ans float(-inf) def dfs(node): nonlocal ans if node is None: return 0 left max(dfs(node.left), 0) right max(dfs(node.right), 0) ans max(ans, left node.val right) return max(left, right) node.val dfs(root) return ans最难理解的是最后一行return max(left, right) node.val。它回答的问题是我作为父节点的子节点向上能提供的最大贡献是多少只能选左链或右链中的一条不能两条都带上因为路径向上走时只能是一个方向。而ans max(ans, left node.val right)是在当前节点这里假设路径拐弯把两边都接上。这两个概念一旦分离这题就不再吓人。LeetCode上不少“树上动态规划”题目比如最长同值路径、打家劫舍III底子都是这个“递归返回单边 全局变量记录答案”的模型。5. 常见问题速查与避坑清单5.1 高频问题排查速查表把我常遇到的问题整理成一张表遇到状况先按表逐条对照能解决绝大多数二叉树程序的“疑难杂症”。症状可能原因排查方向递归运行到某一步突然报空指针递归出口缺少空节点判断检查递归函数首行是否有if root is None且立刻return程序死循环或RecursionError构建的测试树中存在环打印节点值或加visited检查是否重复访问Python报maximum recursion depth exceeded树过深或递归层数超限改用迭代写法或临时sys.setrecursionlimit计算结果总是差1递归返回值的含义没想清楚确认函数返回的是层数、节点数还是叶子数验证BST输出错误只比较了父子节点没传上下界使用带low/high参数的递归版本层序遍历结果混层队列中混入了不同层的节点先记录len(q)再一次性弹出该层所有节点交换左右子树后结果乱套在中序位置做了交换操作回顾遍历位置语义改为前序或后序5.2 避坑清单来自实战的血泪经验第一条递归函数首行统一判断空节点不要只判断自己“看得顺眼”的分支。养成这个习惯至少消灭一半运行时错误。第二条手写测试树之前先画示意图。树结构一旦不对程序跑出来的错误会误导你往算法逻辑里找问题最后绕一大圈才发现是测试数据错了。画图只要几秒钟节省的是半小时起步的排查时间。第三条递归里涉及共享状态时Python记得用nonlocal或列表传引用C系语言要考虑传指针。忘记这一点最常见的表现是“全局变量没更新”或者“上一层的值被下一层覆盖”。第四条遇到怎么改都不对的题先停下来重新审视递归返回值定义。很多死磕到深夜无解的题最后都是因为“函数到底返回什么”没想清楚。想清楚再写代码永远比写了再调试快。第五条测边界条件。二叉树题最容易在空树、只有一个节点、退化成链的树上翻车。提交代码之前心里默默过一遍这几种边界用例。5.3 资源推荐从教材到在线刷题二叉树部分不需要囤太多资源把经典吃透就够。教材方面国内数据结构教材的二叉树章节普遍扎实尤其是遍历推导、线索化、哈夫曼树这些内容。手上有“数据结构c语言版”教材的话建议把它当案头书看待用C语言把基本函数写一遍对内存和指针的理解会深一个层次。课程方面搜索“python数据结构与算法北大”可以找到配套公开课适合Python入门者系统跟学。其他高校的同类课程质量也不错关键是挑一个风格顺眼的完整跟完比东看一个视频西看一个视频强得多。刷题方面LeetCode的二叉树标签就是必刷题库。建议先做“hot 100”里的树题再按专题刷深度类、遍历类、搜索二叉树类。顺序可以这样排先遍历迭代和递归写法再深度类再路径类再BST类最后挑战序列化、最大路径和这类进阶题。每天两三道坚持一个月树题手感自然上来。工具层面除了在线评测不少高校的“数据结构习题集”和期末试卷里的算法设计题也值得做它们更偏推导和手写代码正好弥补在线刷题“量大但基础不牢”的短板。另外不管用的是C、Java、Python还是VBA这种相对冷门的语言只要语言支持类和引用都能模拟二叉树结构。算法思维是语言无关的别被语言限制住。最后再说一个我个人的习惯。每做完一道二叉树题我都会在纸上把一个只有三五个节点的例子完整走一遍递归过程把每一层递归的入参、返回值、当前栈状态画出来。这方法看着笨但特别管用——它能让“递归”从一个抽象概念变成看得见的“栈帧展开图”。等你能在脑子里自动浮现这张图二叉树算法题就不再是一座山了。它考来考去考的其实就是三件事会不会遍历懂不懂递归返回值的含义以及能不能把“当前节点要做什么”想清楚。这三件事做扎实了后面的搜索二叉树、线索二叉树、树上动态规划都只是同一个内核上套的不同马甲。
返回列表