拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

二叉树递归深搜:剪枝与验证BST的两种核心设计

二叉树递归深搜:剪枝与验证BST的两种核心设计

递归、深搜、回溯、二叉树——这几个词放在一起,刷题的人基本都能脑补出一整套套路:先画递归树,再想出口,再决定把当前节点放到哪一步处理。我自己在带新人和写博客时发现,很多人卡在“递归函数到底要不要返回值、返回什么值”这一步,这恰恰是二叉树剪枝和验证二叉搜索树这两道题最值得练的地方。第8题二叉树剪枝,考的是后序位置:先探明左子树和右子树有没有1,再决定当前节点能不能留;第9题验证二叉搜索树,考的是约束传递或中序序列:只要让每个节点知道自己允许的取值范围,或者借助中序遍历检查是否严格递增,就能揪出那些“局部正确、全局非法”的树。这两题一删一验,正好覆盖了递归设计里两种最核心的形态:返回值向上汇总信息、参数向下传递约束。适合正在集中刷二叉树专题、或者已经刷过遍历但一写递归就报错的人。顺便说一句,写二叉树递归题最容易撞上的“运行时错误”,大部分都能归到空指针、栈溢出、边界溢出这几类,后面我会集中排查。

1. 整体设计与思路拆解:为什么是深搜,为什么先剪枝再验证

1.1 从遍历顺序开始:前中后序到底在干什么

二叉树递归里最容易被忽略的一点:每个节点在递归过程中其实被“经过”三次。第一次是刚进入函数,称为前序位置;第二次是处理完左子树回来,称为中序位置;第三次是处理完右子树准备返回,称为后序位置。如果把递归比作在一栋楼里逐层巡查,前序位置是“进门先说”,中序位置是“查完左房间回来汇报”,后序位置是“整层检查完才做总结”。很多算法题的区别,本质上就是选择在哪个位置做文章。

前序位置适合从上往下传递信息,比如验证BST要传区间;后序位置适合从下往上汇总信息,比如剪枝要判断子树里有没有1;中序位置在二叉搜索树里有天然优势,因为中序遍历结果就是有序的。第8题和第9题刚好一个用后序,一个用中序,正好把这三个位置的差别讲清楚。另外“二叉树的遍历”这个热搜词背后的前中后序、层序,其实只解决了“怎么走”的问题;真正的算法题还多问一层:“走到某个节点后,你要在这个节点做什么”。

深搜这里也顺便说一句:递归只是深搜的一种实现方式,它的好处是系统栈帮你把“当前节点是谁、下一步回到哪”都记住了。你也可以用显式栈自己模拟,但递归写起来更贴近人脑。题目里反复出现的“回溯”也不是什么神秘操作,递归调用返回的那一下,天然就是回溯——控制权交回上一帧。你只需要决定“回家路上做不做清理”。

1.2 为什么把剪枝和验证BST放在一起讲

两道题放在一起讲,不是因为题号相邻,而是因为它们是递归函数设计的两个极佳样本。剪枝题的核心是“返回值”:你需要知道左右子树是否包含1,这个信息只有递归函数走到后序位置才能拿齐;验证BST的核心是“参数约束”:你需要在递归过程中不断告诉子树“你最大只能到多少、最小只能到多少”,一旦越界就提前终止。一个信息从下往上汇总,一个限制从上往下传递,这两者基本穷尽了“递归函数怎么携带上下文”的主流做法。

还有一个原因:这两题都带“剪枝”字样,但剪的含义不同。剪枝题剪的是真的节点引用,把不包含1的子树置空,体现了“回溯回家路上动手改结构”;验证BST剪的是遍历分支,访问到非法节点立刻返回false,不再往下搜索,体现了算法竞赛里常说的“剪枝”——提前阻断不可能的解。这两层含义同时出现,很多同学第一次接触时会懵。

另外提一下热搜词里的“不同的二叉搜索树”。很多读者分不清它和今天这类题的区别:如果只问“给定n个节点能构造多少种BST”,那是卡特兰数的动态规划;如果问“把所有BST一棵棵列出来”,那才需要回溯+深搜。动态规划关注的是数量和最优值,回溯关注的是具体路径和解集合。今天这两道题虽然也用递归,但本质属于“在树上做条件判断/结构修改”,不是DP。把题型边界划清楚,刷题才不容易混乱。

2. 二叉树剪枝:递归后序位置的“删子树”实战

2.1 题目拆解:到底剪掉什么

题目原意是:给定一棵二叉树的根节点,树中每个节点的值要么是0要么是1,请你删除所有“不包含1的子树”。注意这里说的是子树:只要一棵子树里存在任何一个节点值为1,这整棵子树就不能被删。很多初学者第一反应是“把值为0的节点删掉”,这个理解是错的。举个例子,一棵树根为0,右子树里有一个值为1的深孙节点,根节点因为右子树包含1,就必须保留;哪怕树根自己等于0,它也要继续留在树上。真正要被删除的是那种“整棵子树全是0”的区块,比如根为0、左右孩子都为0、也没有更深节点,这时候整棵树删完变成null。

为什么这个问题必须靠后序位置?因为在先序位置或者中序位置,你只看到了当前节点和部分子树,还没探明另一侧的情况。假设你在进入根节点时就发现根为0,直接把它剪了,那万一右子树深处藏着一个1呢?这棵1会被连根剪掉,答案就错了。所以递归函数必须先处理左右子树,等两边都返回后,再判断“左子树是不是空了、右子树是不是空了、当前值是不是0”。这就是后序位置的权力:只有完整的子结果回来了,你才敢做最终决策。生活化的类比是:你不可能还没检查完一栋楼的所有房间,就提前宣布这栋楼可以拆除。

2.2 代码实现与递归出口设计

剪枝题最简洁的写法是直接让递归函数返回TreeNode,用null表示“这棵子树已被剪掉”。每次先递归左、右,然后检查“当前节点值为0且左右孩子都为空”,满足就把这个节点剪掉,向上返回null,否则返回当前节点。

public TreeNode pruneTree(TreeNode root) { if (root == null) { return null; } // 先处理左右子树 root.left = pruneTree(root.left); root.right = pruneTree(root.right); // 后序位置决定当前节点去留 if (root.val == 0 && root.left == null && root.right == null) { return null; } return root; }

这里递归出口有两层含义。第一层是入口处的if (root == null),对应“空子树天然不包含1,直接返回null”;第二层是后序位置的条件判断,对应“叶子且值为0的节点要被剪”。很多新手会把第二层当成出口写提前了,比如一进函数看到root.val == 0就想返回,这就犯了先序决策的错误,结果总会差一个右子树深处的1。写完代码可以用题目自带的小样例走一遍:输入 [1,null,0,0,1],期望输出 [1,null,0,null,1]。手动走一下就会发现,值为0的叶子节点被剪掉,但那个含1的0节点会保留,整棵树的剪枝逻辑才顺。

当然也可以用boolean返回值来写:函数返回“当前子树是否包含1”,然后根据返回值在当前节点的调用方去置空引用。这种写法的好处是语义更清晰,适合解题思路讲解;缺点是代码会多几行。我个人推荐在正式比赛或面试时用返回TreeNode的版本,因为简洁、不容易出错。

2.3 几个容易写错的细节

第一个坑是“把节点值为0和节点不存在混为一谈”。剪枝操作后返回null,但null只是代表“被剪掉了”,不表示“这是一棵树的合法输出”,所以后续所有对子树引用做判断的代码都要先判空。第二个坑是递归顺序:必须先改左再改右,最后判断当前节点的“空孩子”状态。如果你在递归之前就判断当前节点是否为空,拿到的左右孩子信息是过期的,会导致漏剪。第三,这个题看起来像回溯,但并没有“撤销选择”的动作,它只是在回家路上修改了树结构。真正体现“回溯”的是递归调用返回后控制流回到上一层的那一刻,这时候你手上多了两个孩子是否为空的信息,才能做剪枝决策。理解这一点后面做N皇后类回溯题会顺畅很多,因为在那些题里,“递归返回后撤销选择”是显式的,而在树的题目里,撤销动作往往隐藏在对子树的引用重新赋值中。

3. 验证二叉搜索树:区间约束与中序遍历两种深搜方案

3.1 为什么不能只比较父子节点

验证二叉搜索树这道题,最常见的错误解法是从根开始,每到一个节点比较root.val是否大于左孩子、小于右孩子,一路递归下去。乍看没毛病,实际上会在一种经典的“局部合法、全局非法”树上崩掉。比如根节点是10,右孩子是20,右孩子的左孩子是5。按父子比较法,10 < 20、20 > 5,全是合法,但这棵树不是二叉搜索树——因为左子树是20的左子树,里面所有节点都必须小于20且同时大于根10,5却小于10。正是这种“跨层约束”让BST判断题必须把祖先的约束一路带下去。

所以二叉搜索树的定义要抠字眼:左子树的所有节点都小于根节点,右子树的所有节点都大于根节点,而且这个规则要对每一棵子树都成立。注意不是“左孩子小于根”,是“左子树所有节点小于根”。翻译成递归参数就是每个节点都要知道自己当前被允许的取值范围:来自左边界和右边界。左子树里的节点会被收紧为上界(不能超过根),右子树里的节点会被收紧为下界(不能小于根)。这个“约束逐渐收紧”的过程,正是深度优先搜索里传递上下文的标准模板。

中序序列也可以用来验证。中序遍历一棵二叉搜索树,输出的序列必然是严格递增的;反过来,如果中序序列严格递增,这棵树必然是BST。这就给了第二种等价的深搜方案:按中序遍历访问节点,每次对比当前节点值与前一个节点值,一旦出现“当前值 <= 前一个值”就立刻判定非法。两种方案没有优劣之分,一个靠前序位置传参数,一个靠中序位置记录状态,都能AC,但细节各有讲究。

3.2 方案A:区间约束法(前序传上下界)

区间约束法的核心是构造一个辅助函数,参数里带着当前节点允许的取值范围。范围用两个long,初始是Long.MIN_VALUE和Long.MAX_VALUE。为什么不用Integer的边界?因为题目测试数据里会出现Integer.MIN_VALUE(也就是-2147483648),如果你把初始下界写成Integer.MIN_VALUE,某个节点值真的等于它时,你没法区分“这个值合法因为它就是下界”还是“它越界了应该被判false”。换成long就能从容覆盖int的完整取值区间。这个细节是很多老手都掉过坑的地方,建议直接养成习惯。

public boolean isValidBST(TreeNode root) { return isValid(root, Long.MIN_VALUE, Long.MAX_VALUE); } private boolean isValid(TreeNode node, long low, long high) { if (node == null) { return true; } if (node.val <= low || node.val >= high) { return false; } // 递归时收紧区间 return isValid(node.left, low, node.val) && isValid(node.right, node.val, high); }

递归时,左子树的取值范围被上界收紧为当前节点值,右子树的下界被收紧为当前节点值。你可能会问:为什么判断条件是“= =”都算非法?因为BST定义严格:不允许重复值,等于也不行。注意这里使用了短路特性:一旦左子树返回false,右子树压根不会被访问,这就是深搜里的“分支剪枝”,也是和剪枝题里“改节点引用”完全不同的剪枝语义。

3.3 方案B:中序遍历递增校验

中序方案需要维护一个“前一个节点值”的变量。这里有个很多教程没细讲的坑:如果直接写递归函数里的局部变量或参数,它在递归栈的每一帧之间是互相独立的,不可能跨节点保存状态;如果写成普通全局字段,又能被多个测试样例污染。比较稳妥的做法是用一个long字段prev,每访问一个节点就把prev更新为当前值。代码长这样:

private long prev = Long.MIN_VALUE; private boolean valid = true; public boolean isValidBST(TreeNode root) { inOrder(root); return valid; } private void inOrder(TreeNode node) { if (node == null || !valid) { return; } inOrder(node.left); if (node.val <= prev) { valid = false; return; } prev = node.val; inOrder(node.right); }

这里的剪枝有两层:入口处判断!valid,一旦发现非法就不再继续递归;中序位置的比较则相当于把“是否严格递增”这件事变成了一个线性序列的逐个扫描。两种方案在思路上其实殊途同归:区间约束法可以看作把一个节点允许的上下界“压扁”到一维,中序法则是直接把二叉树“压扁”成有序数组。时间和空间复杂度也一样,都是O(n)时间、O(h)空间。

3.4 两题交叉对比小结

到这里,两道题的核心逻辑已经清楚了。剪枝题是“返回值向上汇总信息”,验证BST的区间法则是“参数向下传递约束”,中序法则把递归过程中的状态记录在外部字段里。这三条路线基本覆盖了二叉树深搜里绝大多数递归函数的形态:无返回值的全局状态型、带返回值的汇总型、带参数的约束型。如果再遇到一个二叉树递归题,先去想你要在哪个位置做动作:是先序传东西下去,中序记录历史,还是后序等结果回来。想清楚这一点,递归函数十有八九就能写对。另外提醒一句,“二叉树的深度”这类题就是标准的后序汇总型,和剪枝题同源;而“快速排序非递归”这类题目里反复出现的栈模拟,又和显式栈深搜是同一种套路,都能互相印证。

4. 常见问题与排查技巧:二叉树递归题总是报运行时错误怎么办?

4.1 运行时错误的四种典型来源

网络上总有人问“写二叉树程序时为什么总是报运行时错误”,其实绝大多数运行时错误就四类:空指针异常、栈溢出、逻辑错误伪装成崩溃、整数边界溢出。

第一类是空指针,最常见的写法是root.left.val却没先判断root.left是否为null,或者递归出口写了“if (root == null) return”却忘了在调用方处理返回值。第二类是栈溢出,多半是递归出口缺失或终止条件永远无法满足。比如把递归调用写在判断条件外,或者对一个可能有环的图结构用了树的递归方法。二叉树本身不可能有环,但如果你修改了right指针又去递归left,还是可能死循环。第三类是逻辑错误伪装成崩溃,典型如比较时把“节点为null”当作“节点值为0”,在断言里爆NPE。第四类边界溢出较少见,但验证BST这题就是完美例子:用Integer.MIN_VALUE当下界,测试数据一出现恰好等于这个值的节点,判题器就给你一个Wrong Answer。这种问题不会真崩溃,但特别难查。

4.2 定位问题的三板斧

第一板斧是手推小样例。不要一上来就跑大用例,画一棵三层左右的小树,把递归过程写在纸上,标出每次调用的节点值和返回值。剪枝题的手推核心是看“子树包含1”怎么层层上传;验证BST的手推核心是看“上下界怎么在每一层发生改变”。画完纸上对比期望输出,基本能定位是在前序、中序还是后序位置出了错。

第二板斧是打印调试。给递归函数加一个depth参数,每进入一层打印两遍:入参和出参。打印时用两个空格的缩进表示深度,一眼就能看出递归路径。比如在验证BST的递归里打印“depth=2, node.val=5, range=(1,3)”,就能立刻发现这个节点被传入了错误的上下界。打印调试的关键点是把关键信息拼到一行,方便比对。

第三板斧是二分注释。如果递归函数里有多处操作,把不确定的后半部分注释掉,只保留一个分支跑,看跑出来的结果是不是更接近正确。如果跑左子树时崩溃,说明问题出在左分支的递归调用或返回值处理上;如果两侧都正常,问题大概率在根节点自己的处理逻辑。这个方法类似二分查找bug,效率很高。

还有一个容易被忽略的方法:把递归改成显式栈。很多“栈溢出”其实是系统栈太浅,改成Stack 自己模拟,不但可以避开栈溢出,还能在每次push/pop前后打印栈内容,排查起来比递归直观得多。改造成本不高,中序遍历的非递归写法本身就值得练习。如果只是刷题,直接用隐式栈就能过;但如果目标是工程场景,显式栈往往才是最终答案。

4.3 问题速查表

错误现象可能原因排查方法修复示例
NullPointerException递归中对null节点访问子属性打印node.val前先判空入口加if (node == null) return
StackOverflowError递归出口缺失或递归分支未收敛检查递归调用是否每次都缩小规模明确终止条件并每层处理后进入子问题
Wrong Answer(父子比较却失败)只比较相邻父子节点,未传递全局约束构造跨层非法树测试改用区间约束法传low/high
Wrong Answer(边界值判错)用Integer.MIN_VALUE做初始下界设计节点值等于INT_MIN的用例初值改用Long.MIN_VALUE
只运行一半就返回递归函数提前return,分支被短路打印每层入参/出参检查isValid里逻辑与的顺序
修改原树后结果不对剪枝顺序错误,先序或中序就删了节点手动走一个小树保证左右子树处理完再判断去留

这个表可以当作面试前复习的一页速查,很多坑不真正踩一次很难记住。

4.4 一些额外的心法和工程经验

再强调一个观点:递归不是二叉树的唯一答案。日常开发里,如果二叉树深度可能超过几千层,递归会直接击穿线程栈。面对这种场景,我往往先用栈把递归改成迭代,或者干脆用BFS逐层处理。如果必须保持中序且不想用栈,“线索二叉树”就是经典解法,它用叶子节点的空指针建立中序线索,把遍历做到O(1)额外空间。热词里那个“线索二叉树”就是干这个用的。刷题阶段不用急着优化到那一步,但至少要知道方向。

写递归题还有个心法:固定“递归三要素”,即终止条件、本层逻辑、递归参数。落笔前先花30秒想清楚这三个东西,比直接敲代码快得多。终止条件决定“什么时候停”,本层逻辑决定“在这个节点我要做什么”,递归参数决定“向下传递哪些上下文”。剪枝题的三个要素是:遇到null停、后序判断去留、返回值是剪过的子树;验证BST区间法的三个要素是:遇到null停、判断是否越界、把当前值作为新的上下界。想清楚再写,几行就能过。

我个人真实的体会是,这两道题我一开始都没写对。剪枝题我傻乎乎地先判断“节点值为0”就想删,结果右子树深处藏着1却让我删错;验证BST题我连续踩了两次Integer.MIN_VALUE的坑,才老老实实换成long。后来我总结出一个习惯:递归题写之前先问自己“函数返回什么、参数带什么、处理位置在前中后序的哪个地方”。这个习惯帮我省了大量调试时间。最后再分享一个小技巧,调试二叉树递归题最痛苦的是不知道当前递归到哪一层,建议在递归函数入口加depth参数,用缩进打印节点信息,路径瞬间清晰。今天的两个题,剪枝负责教会你“后序汇总信息”,验证BST负责教会你“先序传递约束”,练完这两刀,二叉树深搜的基本功就算立住了。

返回列表