验证二叉搜索树:三种解法避开局部判断陷阱
2026/9/15 4:35:49 网站建设 项目流程

1. 题目复述与最常见的错误解法:为什么很多人第一版代码过不了

验证二叉搜索树这道题,在LeetCode hot100里序号是98,题号也是98,是二叉树分类下的必刷题。题目本身不长:给定一个二叉树的根节点,判断它是否是一个有效的二叉搜索树。

很多第一次刷到这道题的人,第一反应是“这还不简单吗?递归判断每个节点,左孩子小于根节点,右孩子大于根节点不就行了”。然后信心满满地写了几行代码,一提交,看着红色报错愣在原地的场景我见得太多了。LeetCode的AC率显示这道题通过率大概在四成出头,原因是那些看起来非常自然的解法其实根本不成立。

我们先回到定义本身。一个有效的二叉搜索树要满足三个条件:

  • 节点的左子树只包含小于当前节点的数;
  • 节点的右子树只包含大于当前节点的数;
  • 所有左子树和右子树自身也必须是二叉搜索树。

注意最后一条,“所有左子树和右子树自身也必须是二叉搜索树”,这意味着约束不是父子节点之间的事,而是祖先节点对后代节点的一种全局约束。我第一次做这道题时也踩了同样的坑,写出的错误版本长这样:

def isValidBST(self, root: TreeNode) -> bool: if not root: return True if root.left and root.left.val >= root.val: return False if root.right and root.right.val <= root.val: return False return self.isValidBST(root.left) and self.isValidBST(root.right)

这段代码表面上每个节点都检查了左小右大,实际上只验证了“相邻两层”之间的大小关系。它最大的问题是:完全没有检查某个节点是否同时小于它的所有祖先节点的值。用一个经典的测试用例就足以拆穿它:

输入:[5,4,6,null,null,3,7]

这棵树长这样:根节点是5,左子树是4,右子树是6,6的左孩子是3,右孩子是7。如果按照上面那段错误代码的判断逻辑,节点6满足“左孩子3小于6”,节点5满足“右孩子6大于5”,看起来局部都是对的,所以会返回True。但树里那个值为3的节点,位于整棵树的右子树里,却比根节点5小,这在二叉搜索树中是绝对不允许的。正确答案应该是False。

为什么这个错误解法这么容易被当成“标准答案”呢?我觉得是因为很多人在学习二叉树的中序遍历、层序遍历时,形成了“只要每一步局部正确就整体正确”的思维惯性。但BST的定义恰恰是反过来的:全局区间约束要层层传递下去,每一个左子树节点都要小于所有祖先,每一个右子树节点都要大于所有祖先,这种约束不会因为某个中间节点满足条件就自动成立。

想清楚这一点之后,这道题的解法思路其实有三条主流路线:区间约束递归法、中序遍历法、还有基于前驱节点的前序遍历写法。下面一个一个拆开说。

2. 解法一:区间约束递归法,一条正确的解题主线

既然问题出在“缺少全局约束”,那最容易想到的修复思路,就是在递归的时候把当前节点允许的取值范围传下去。这个方法在很多题解里叫min/max法,或者区间约束法,我个人觉得它是理解这道题最直观的钥匙。

核心逻辑是这样的:从根节点出发时,它对所有后代节点的约束是“任意数都允许”,也就是取值范围是负无穷到正无穷。当我们进入某一个节点的左子树时,这个节点会变成新增强的上界。举个例子,根节点是5,那么它的左子树里所有节点都必须小于5,同时大于负无穷;它的右子树里所有节点都必须大于5,同时小于正无穷。再往下递归一层也一样,每走入一个左孩子,就把上界收紧为当前节点的值;每走入一个右孩子,就把下界收紧为当前节点的值。

这个思路写出代码来非常清爽:

class Solution: def isValidBST(self, root: TreeNode) -> bool: return self._check(root, None, None) def _check(self, node: TreeNode, lower, upper) -> bool: if not node: return True val = node.val if lower is not None and val <= lower: return False if upper is not None and val >= upper: return False if not self._check(node.right, val, upper): return False if not self._check(node.left, lower, val): return False return True

这里有几个关键细节值得重点展开。第一,为什么我用None而不是负无穷正无穷这样的数值?如果你用Integer.MIN_VALUE和Integer.MAX_VALUE作为初始边界,一旦测试用例里真实节点值就是Integer.MIN_VALUE,判断条件就会出错。LeetCode的测试集非常刁钻,专门准备了边界值用例,比如单节点树[2147483647],或者节点值等于int极值的情况。用None来表示“没有边界约束”就不会掉进这个坑,这是我在实战中被教育过的一次。

第二个细节是递归顺序。很多人习惯先递归左子树再递归右子树,其实在这个方法里先做哪边逻辑上都能跑通,但我个人倾向于先检查当前节点、再检查右子树、再检查左子树,这样一旦违反约束能在最早的时间返回False,省去多余的遍历。虽然大O复杂度不变,但在实际运行中,面对一个快速违反约束的深层树,这种剪枝顺序能让执行时间有明显下降。

第三个细节关于等于。二叉搜索树的定义要求左子树严格小于根节点,右子树严格大于根节点。所以在区间判断里用的是<=和>=而非<和>,如果树里出现了值相等的连续节点,比如[2,2,2],正确结果应该是False。我见过不少人在这里写错成val < lower、val > upper,导致重复值被错误判定为合法。

这个方法正确性也可以从数学归纳法的角度理解:递归的每一步都维护了一个不变式——“当前节点以及它的所有子孙节点,都落在区间(lower, upper)内”。初始时整个树落在(null, null)区间内,等价于没有约束。每进入一层子节点,区间都会收缩,进而保证当前节点一定小于它的所有祖先、或者大于它的所有祖先。区间一旦被破坏,就代表这一条分支上某个节点违反了祖先约束。

复杂度方面,每个节点最多被访问一次,时间复杂度O(n);空间复杂度是递归栈的深度,也就是树的高度。最坏情况下树退化成链表,高度为n,空间复杂度O(n);最好情况下是平衡二叉树,高度为log n,空间复杂度O(log n)。这些面试的时候一定要能脱口而出。

3. 解法二:中序遍历,让二叉树“泄密”的线性序列

如果说区间约束法是这道题的“正面强攻”,那中序遍历法就是一处“四两拨千斤”的侧翼包抄。

二叉搜索树有一个非常优美的等价定义:对一棵二叉搜索树做中序遍历,得到的节点序列一定是严格递增的。反过来,如果一棵二叉树中序遍历的序列是严格递增的,那它一定是一棵二叉搜索树。这是一个充要条件。

为什么?“二叉搜索树”这个名字里的“搜索”二字,来源于这种结构能像二分查找一样快速定位元素。中序遍历的输出顺序是左子树、根节点、右子树,而BST每个节点的左子树都更小、右子树都更大,所以中序遍历拿到的序列天然就是从小到大排列的。如果某个位置的元素没有保持递增,那必然是某个子树违反了大小约束,整个树就直接烧穿了。

所以解题思路可以转化为:做一次中序遍历,在遍历过程中检查当前访问的节点值是不是严格大于前一个被访问的节点值。这个方案有两个常见写法。

先看递归写法。这里最容易踩的坑在于“前一个节点”怎么记录。如果用全局变量记录前一个节点的值,初始值设成Integer.MIN_VALUE,遇到节点的真实值就是Integer.MIN_VALUE时判断就会翻车,和上文里的边界问题一模一样。更干净的做法是用一个TreeNode类的前驱指针,初始为null,第一次访问节点时只更新前驱、不做比较:

class Solution: def isValidBST(self, root: TreeNode) -> bool: self.prev = None return self._inorder(root) def _inorder(self, node: TreeNode) -> bool: if not node: return True if not self._inorder(node.left): return False if self.prev is not None and self.prev.val >= node.val: return False self.prev = node return self._inorder(node.right)

再看迭代写法。如果树的高度很深,递归本身有栈溢出风险,这时候用栈模拟中序遍历是更稳的选择。而且迭代写法几乎不需要多少记忆成本,就是把中序遍历的模板拿过来,在中途插一个比较判断:

class Solution: def isValidBST(self, root: TreeNode) -> bool: stack = [] prev = None cur = root while stack or cur: while cur: stack.append(cur) cur = cur.left cur = stack.pop() if prev is not None and prev.val >= cur.val: return False prev = cur cur = cur.right return True

我实际在LeetCode上提交两种实现的耗时差距微乎其微,真正影响选择的是场景:如果是在IDE里写算法题解,递归写起来更快,代码更短;如果在面试白板环节,迭代写法可以向面试官展示你对递归栈溢出的理解。顺便说一个冷门经验,LeetCode的测试集里有很多退化成长链的树,递归深度动辄上万层,Python的默认递归深度只有1000左右,这时候如果没有给递归函数加sys.setrecursionlimit,会直接报RecursionError而不是返回True/False。别问我是怎么知道的。

中序遍历法和区间约束法还有一个微妙的性能差异。中序遍历需要进入整棵左子树之后才开始第一个比较,如果非法节点恰好藏在右子树的深层位置,中序遍历要先走完大量的左子树节点才发现问题;而区间约束法在每层递归时都会先检查当前节点的值是否在合法区间,能更早触发剪枝。当然这只是常数级别的差别,理论上限仍然是O(n),不过理解这个差异有助于你在面试时面对“为什么用这个方法不用另一个”的追问。

4. 边界情况与测试用例设计:像裁判一样虐自己的代码

刷题时间久了你会发现,大多数人不是不会写解法,而是不会设计测试用例。lc的提示里那句话我一直很认同:提交通过不代表正确,真正的正确性来自对边界的理解。验证二叉搜索树这道题,边界情况极其丰富,我整理了一个自测清单,每一条背后都是一次真实的血泪教训。

先看基础边界:空树返回True,只有一个节点的树返回True。这两个用例能过滤掉一上来就if root.left and root.val <= root.left.val直接访问空节点的低劣错误。

再看半树结构。比如[1,null,2,null,3,null,4],这棵树退化成一条向右延伸的链表,每个右孩子都比父节点大,看起来局部都合法,实际上是合法的BST。同理[4,3,2,1]向左延伸的链也是合法BST。很多第一版错误解法在这两个用例上都能通过,所以它们不足以区分解法好坏。真正有区分度的是“局部合法但全局不合法”的用例。

我重点想推演两个用例。第一个是[10,5,15,null,null,6,20]。根节点10,左子树5合法,右子树15,15的左孩子是6、右孩子是20。右子树内部看,15的左孩子6小于15,右孩子20大于15,局部分检全部通过。但6大于根节点10,放在整棵树里它就处在根节点的右子树位置,右子树要求所有节点必须大于根的值,所以这棵树不是BST。错误解法在这里会返回True,正确解法返回False。

第二个必须提的经典用例是[5,4,6,null,null,3,7],刚才在第一节已经讲过。这个case在LeetCode讨论区被称为“杀手用例”,它精准地打在错误解法的腰眼上:4小于5合法,6大于5合法,但6的左孩子3小于根节点5就不合法。用区间约束法跑一遍会很直观:进入5的右子树时区间变成(5, null),访问节点6,通过;进入6的左子树时区间变成(5, 6),访问节点3,发现3不大于5,直接返回False。中序遍历法也能发现:序列会变成4,5,3,6,7,在中序遍历第2个位置和第3个位置之间出现5大于3的逆序,直接返回False。

还有一个很容易被忽略的边界是节点值等于Integer.MAX_VALUE或Integer.MIN_VALUE的场景。比如[2147483647][2147483647,2147483647],如果初始边界用的是int的极值,前者可能因为边界判断写成val <= lower而被误杀;后者正确的答案应该是False,因为左右值相等不满足严格小于。LeetCode专门为这个收集了大量用例,我建议自己写解题代码时直接用null作为初始边界,彻底绕开这个问题。

最后还有一个让我印象深刻的用例:一棵树的所有节点值相同,比如[1,1,1],三节点完全相同的树。中序遍历序列是1,1,1,不严格递增,结果False。看起来很简单,但这种用例能帮你检验代码有没有把<<=写混。我自己最开始做这道题时就因为在比较运算符上少了严格性,导致这种用例错误返回True,花了不少时间才定位到问题。

5. 把hot100里的二叉树题串起来:从验证二叉树到高频同类题

hot100不仅仅是一份题单,更是一张算法模型的关系图。很多人刷题的方式是把100道题一道一道刷完、刷过就忘,本质原因是没有把题和题之间的共性抽象出来。验证二叉搜索树这道题,正好可以当作理解二叉树系列的一条主线。

先看难度梯度。hot100里二叉树相关的题目分布大概是这样的:入门级的二叉树中序遍历、二叉树的最大深度,主要考察遍历模板;进阶一些的对称二叉树、翻转二叉树,考察的是对递归结构的理解;到了验证二叉搜索树、二叉搜索树中第K小的元素、把二叉搜索树转换为累加树这几道,核心考点就是二叉搜索树的特性;再往上,二叉树的最近公共祖先、二叉树的最大路径和则更偏向综合设计。

可以串成一条线来看:验证BST用的中序遍历法,直接就能迁移到第230题找第K小元素。找第K小元素的朴素思路是中序遍历整个树拿到有序数组再取第K个,优化思路是在中序遍历时计数,数到K就返回。同样的遍历框架,换一个剪枝条件就是一道新题。再比如把BST转换成一个累加树的题,本质是中序遍历的逆序版本,遍历顺序改成右子树-根-左子树,同时维护一个累加值。这三道题放在一起刷,等于练了三遍中序遍历,能力增长远超孤立刷三题。

再说说区间约束法的迁移价值。判断一棵树是不是合法的BST,在很多实际系统中不是独立需求,而是“构造一棵平衡搜索树”的检验步骤。比如有序数组转二叉搜索树这题,要求构建结果本身就必须是一棵BST,如果你用“每次取中间元素作为根节点”的递归方案生成树,可以在最后加一个验证函数来检验输出是否正确。验证函数就是本题的区间约束法。我自己在系统设计相关的面试里,就被问到过类似场景:给你一个内存中的对象树,怎么快速判断它能否作为有序索引结构使用?本质上就是这道题。

回到刷题方法本身,我的建议是hot100不要按顺序平铺着刷。二叉树分类下的题目,最好的顺序是先做中序遍历模板题,再做验证BST,然后马上做第230题和把BST转换为累加树的题,让同一个思路连续重复三到四遍形成肌肉记忆。之后再跳去做最近公共祖先和路径和这类需要现场设计递归状态的题,那时候你对“递归返回值、全局状态、子树分配”的理解会比直接冲难题扎实得多。验证BST看着是一道验证题,实际它训练的是:如何在递归过程中维护全局约束、如何用遍历顺序化繁为简、如何处理极值边界。这三板斧在hot100后续的许多难题里都能反复用到。

用区间约束法还有一个好处:它天然给出了“在哪里维护不变量”的范式。以后你再遇到判断平衡二叉树、判断完全二叉树、验证对称二叉树,思考逻辑都是一模一样的——明确不变量、递归传递条件、边界早停。所以这道题刷完别急着删,隔一周拿出来写一遍迭代中序遍历,你会发现自己手速和思路清晰度都明显上来了。

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

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

立即咨询