
这个题我当年在某个在线判题平台上第一次提交时自信满满地写了三行递归结果被一个测试用例打得怀疑人生。后来和周围的同学聊了一圈才发现这道验证二叉搜索树表面上是让你判断树长得好不好看实际上考的是你有没有真正理解二叉搜索树的定义边界。很多标着中等难度的题其实只是代码量中等这道题真正的中等之处在于定义陷阱——你以为是局部比较它考的是全局约束。这篇就围绕第98题验证二叉搜索树把整个解题链条讲透从最直觉的错误写法开始到递归区间法、中序遍历法再到各种边界条件和面试延伸。无论你是刚开始刷题的新手还是已经刷过几百题准备面试的进阶选手这篇文章都能让你对BST的合法性验证理解上一个台阶。1. 这道题真正在问什么一个看似简单实则暗藏深意的定义先看题目要求给定一个二叉树的根节点判断它是否是一棵合法的二叉搜索树BST。所谓合法的二叉搜索树按教科书定义是三条节点的左子树只包含小于当前节点的数。节点的右子树只包含大于当前节点的数。所有左子树和右子树自身也必须是二叉搜索树。听完这三条大部分人的第一反应是这不就是比较左孩子小于根、右孩子大于根吗然后递归地让每个子树也满足这个条件就行。于是写出这样的代码public boolean isValidBST(TreeNode root) { if (root null) { return true; } if (root.left ! null root.left.val root.val) { return false; } if (root.right ! null root.right.val root.val) { return false; } return isValidBST(root.left) isValidBST(root.right); }这段代码看起来逻辑完全自洽左孩子不小于根就返回false右孩子不大于根就返回false然后递归检查左右子树。我用这个版本第一次提交时平台返回的结果是解答错误给出的反例是一棵这样的树5 / \ 4 6 / \ 3 7逐个节点看根节点5左孩子4小于5右孩子6大于5局部都没问题。递归检查右子树节点6的左孩子3小于6右孩子7大于6局部也没问题。于是整棵树被判定为合法。但稍加思考就会发现不对——3明明在整棵树的右子树里却比根节点5还小这直接违反了右子树只包含大于当前节点的数。这就是这道题最核心的陷阱二叉搜索树要求的是全局有序不是局部有序。所谓全局有序是指树中任意节点的值都必须大于其左子树中所有节点的值小于其右子树中所有节点的值。也就是说每个节点在整个树中都有一个值域范围它只能落在某个区间里。当你只比较直接父子关系时你默认了每个节点的约束只需要看它的直接父节点但事实上一个节点还会受到所有祖先节点的约束。用生活里的例子来类比想象一个公司的组织架构每个部门经理的薪水要高于直属下属低于直属上司。如果只看直接上下级每个人好像都满足条件但可能出现一个基层员工他的薪水比某个跨部门高管还高——虽然他的直属链条里每个人看起来都合规但放进整个公司体系就乱套了。BST的合法性和这个完全一样它要求的是全局等级秩序。理解了这一点再回头看题目定义里那句所有左子树和右子树自身也必须是二叉搜索树你会发现它真正想表达的是当递归进入某个子树时这个子树里的所有节点不仅受子树内部约束还受外部祖先传递下来的边界约束。这个边界约束正是所有正确解法的核心。2. 递归区间法把BST的约束条件放进函数签名里顺着上面的思路正确的做法很自然就出来了在递归遍历每个节点时除了传入节点本身还要传入它当前允许的取值范围。这个范围是一个开区间(lower, upper)节点的值必须严格落在其中。然后递归左子树时把上界更新为当前节点值递归右子树时把下界更新为当前节点值。Java实现如下public boolean isValidBST(TreeNode root) { return validate(root, null, null); } private boolean validate(TreeNode node, Integer lower, Integer upper) { if (node null) { return true; } if (lower ! null node.val lower) { return false; } if (upper ! null node.val upper) { return false; } return validate(node.left, lower, node.val) validate(node.right, node.val, upper); }这段代码只有十几行但信息量很大。我先解释几个关键设计再逐步跟踪执行过程。第一为什么用Integer而不是int因为int不能表示没有边界这个状态。根节点没有任何祖先约束它的下界和上界都是无穷我们需要一种方式表示无限小和无限大。用Integer包装类型的好处是null可以表示没有约束而int做不到这一点。这是一个看似细节、实则影响巨大的实现选择。有些教科书会教你用Long.MIN_VALUE和Long.MAX_VALUE来初始化边界但能想到树中节点值刚好等于Integer.MIN_VALUE这个边界情况的话你就知道用包装类型加null是更稳妥的方案。第二为什么判断条件是和而不是和因为BST要求严格小于和严格大于左子树里的节点不能等于根节点。一旦允许等于就会出现重复值而重复值在标准的BST定义中是不合法的。这里的比较符号是题目定义的直接体现写反了或者漏掉等号边界测试用例立刻挂掉。第三为什么递归传参是validate(node.left, lower, node.val)和validate(node.right, node.val, upper)因为当进入左子树时当前节点成了左子树的上界进入右子树时当前节点成了右子树的下界。每次递归相当于把取值范围收窄一半也就是说一棵树越往下走每个节点的允许区间越小这就是BST全局约束的递归表达。拿刚才的反例来跟踪一遍5 / \ 4 6 / \ 3 7调用validate(5, null, null)。5在(null, null)区间内合法。递归左子树validate(4, null, 5)4大于null、小于5合法。递归右子树validate(6, 5, null)6大于5、小于null合法。继续进入6的右子树应该看7validate(7, 6, null)7大于6合法。但进入6的左子树时会调用validate(3, 5, 6)此时3不满足node.val lower即3 5为false返回false。整棵树判定非法。看到这里你应该明白了3这个节点被卡住不是因为它在子树内不合法而是因为根节点5给了它一个最小不能小于5的约束它越界了。这个解法的时间复杂度是O(n)每个节点最多访问一次空间复杂度是O(h)其中h是树高最坏情况是链状树递归栈深度为n空间O(n)。在面试中这是几乎所有人都会首先写出的标准解法也是我认为最稳妥的解法——它直接对应BST定义的数学表达不容易出逻辑漏洞。顺带说一句如果面试官问能不能不用递归实现你心里要有第二种解法也就是下面的中序遍历验证法。这两种解法不是互斥的而是从两个不同侧面理解BST。3. 中序遍历验证法BST的单调性是天然验收标准二叉搜索树还有一个非常重要的性质这个性质教科书里讲得不算多但做题非常有用对BST进行中序遍历得到的节点值序列是严格递增的。反过来如果一棵二叉树的中序遍历序列是严格递增的那么它一定是合法的BST。这两个方向都成立。为什么因为中序遍历的顺序是左子树 - 根节点 - 右子树而BST的定义恰好保证任意节点的左子树全部小于它、右子树全部大于它。于是左子树的所有值先输出接着是根然后是右子树的所有值整体当然严格递增。反过来说如果一棵树的中序序列严格递增说明任意节点的左子树值都小于它、右子树值都大于它这不就是BST的全局定义吗这个性质让验证BST等价于验证中序遍历序列是否严格递增。基于这个性质我们可以把验证BST转换成遍历时检查每个数和它前一个数的关系。用递归实现中序遍历的代码如下private Integer prev null; public boolean isValidBST(TreeNode root) { prev null; return inorder(root); } private boolean inorder(TreeNode node) { if (node null) { return true; } if (!inorder(node.left)) { return false; } if (prev ! null node.val prev) { return false; } prev node.val; return inorder(node.right); }这段代码的思路很清晰先递归遍历左子树遍历过程中如果已经发现非法就直接返回false提前结束然后处理当前节点把它和上一个被访问的节点值比较最后递归遍历右子树。prev记录的是中序遍历中上一个被访问的节点值。注意这里又有一个经典的坑prev为什么初始化为null而不是Integer.MIN_VALUE因为题目完全没有限制节点值的范围树中某个节点的值完全有可能是Integer.MIN_VALUE本身。如果把prev初始化为Integer.MIN_VALUE那么当第一个遍历到的节点值恰好也是Integer.MIN_VALUE时node.val prev成立会被误判为非法。这个坑我当初踩过一次后来在讨论区看到不少人也是同样的原因挂了边界用例。用Integer包装类型加null来当尚未有前驱节点的标记可以完美避开这个问题。那能不能不用递归、只用迭代实现呢完全可以而且面试官问你能不用递归做吗的概率很高。迭代版中序遍历用显式栈模拟代码如下public boolean isValidBST(TreeNode root) { DequeTreeNode stack new ArrayDeque(); TreeNode cur root; Integer prev null; while (cur ! null || !stack.isEmpty()) { while (cur ! null) { stack.push(cur); cur cur.left; } cur stack.pop(); if (prev ! null cur.val prev) { return false; } prev cur.val; cur cur.right; } return true; }迭代版的逻辑和递归版完全一致区别只是用栈保存了待回溯的节点。每次从栈里弹出节点时它都是当前中序遍历顺序中的下一个节点和上一个值比较即可。这个版本的好处是没有递归深度限制即使遇到高度为好几万的链状树也不会栈溢出。中序遍历法和递归区间法各有优劣我做了个对比维度递归区间法中序遍历验证法核心思路维护节点值域范围利用中序序列递增性质代码量较短递归版较短迭代版略长理解难度需要理解全局约束传递需要记住中序性质空间复杂度O(h)栈深度随树高递归版O(h)迭代版O(h)边界处理依赖null边界依赖prev的null初始化面试表现标准答案最稳妥展示知识广度有亮点从面试策略来说我建议你先写递归区间法因为最不容易出错然后主动补充一句其实这道题还有一个中序遍历的思路可以把它转化成检查单调递增序列再给出中序版本。这样既展现了底层理解又展示了知识储备。4. 真正容易栽跟头的边界条件与工程细节代码写对了不代表能通过所有测试。我最近统计了一下周围同学在这道题上踩过的坑发现几乎每个人都是在边界条件上翻车而不是在主逻辑上出错。这些坑非常典型值得单开一章说清楚。第一个是空树和单节点的问题。按照BST的定义一棵空树是合法的BST没有节点也就没有违反任何规则单节点树也合法。这两个case你的递归代码通常能自动处理validate(null, null, null)返回truevalidate(单节点, null, null)也返回true但有些考生会额外写if (root null) return false这就不对了。如果你去查BST的正式定义会发现很多标准教材明确说了空树也是二叉搜索树这和空集是任何集合的子集是同一个道理。做题时要有这个约定。第二个是重复元素问题。题目定义里说的是严格小于和严格大于这意味着节点值相等是非法情况。比如一棵只有两个节点的树根节点5左孩子5虽然按照广义BST允许左子树包含等于根的节点它可能说得过去但在LeetCode这个题的语境下它必须判为false。你的比较符号用和就对了。我在面试中见过有同学为了支持重复值把它写成node.val lower就返回false结果把等号漏掉重复值全部判成合法这就和题目定义直接冲突了。第三个是递归深度问题。如果树是链状的比如每个节点只有左孩子深度等于节点数递归解法会栈溢出。在在线判题平台上测试用例一般不会变态到上万层的链状树但面试官如果追问如果节点数很多怎么办你就要能给出迭代版中序解法或者指出递归的空间开销风险。这也是我建议大家都把迭代版中序写在脑子里的原因——它不仅是备选更是一道防线。第四个是语言相关的数值边界问题。Java里Integer有取值范围[-2147483648, 2147483647]前面提过prev初始化的坑Python则因为整数没有固定上限基本没有这个顾虑但用Python写递归解法时要注意解释器默认递归深度限制大约1000层太深的树会直接抛递归错误这时候迭代版更稳。C里通常用long long类型做边界用LONG_MIN和LONG_MAX来初始化上下界但同样要小心节点的值可以是LONG_MIN本身——所以最通用的做法还是用null表示无界。第五个是验证BST和判断平衡二叉树不要混为一谈。BST的合法性只关心值的大小关系不关心树的形状是否平衡。一棵合法的BST完全可以是严重倾斜的链状结构它依然是BST只是查找效率退化成了线性。很多初学者会把这两件事绑定在一起以为树歪了就不合法这是概念混淆。判断平衡二叉树是另一道题判断左右子树高度差和值域验证完全无关。面试时如果被问到这两者的区别能清晰回答出来会是一个不错的加分项。第六个容易被忽略的是利用短路求值优化。在递归区间法里return validate(node.left, lower, node.val) validate(node.right, node.val, upper);这一行用了如果左子树已经非法右子树根本不会被递归调用。这个优化看起来微不足道但在一棵很大的树上它能省掉大量无意义遍历。写递归条件时尽量把最可能导致false的分支放在前面能提前返回就提前返回。5. 从验证BST延伸出去面试官真正想考察的能力一道题的价值往往不只在题本身而在于它能延伸出多少变体和讨论。刷题刷到后期验证BST对我来说已经不是一个孤立的题目而是一组相关考点的地基。这里把常见的延伸方向总结一下方便你面试时举一反三。第一个方向是改动比较规则。如果把严格大于/小于改成大于等于/小于等于允许重复值递归区间法的判断条件要从node.val lower改成node.val lower中序方法的node.val prev也要同步改成node.val prev。这个改动很小但能测试你对比较符号背后含义的敏感度。面试官可能故意问如果允许重复值你怎么改能快速给出改动方案说明你真正理解了代码每一行的含义而不是背模板。第二个方向是同时返回多个信息。有些变形题不仅要求判断是否BST还要求你在验证过程中返回这棵树的最小值和最大值比如验证BST并计算树中所有节点的值域范围或者返回子树的大小。这时候单独写一个boolean函数就不够用了你需要定义一个结果对象把isValid、min、max三个字段一起返回。这种返回值和额外信息的设计模式在解决树的很多问题时都会用到属于一劳永逸的技巧。第三个方向是从验证一棵树变成验证多个条件组合。比如先判断是否BST再判断是否平衡或者再判断是否是完全二叉树。这种组合题的思路是把多个递归过程合并到一次遍历里完成避免一棵树遍历好几遍。如果你已经掌握用区间法验证BST再叠加一个记录每层节点所在位置的逻辑就可以一次遍历同时完成多种性质判断效率上会好看很多。第四个方向是和删除/插入操作联动。BST的合法性验证往往是对某个曾经被操作过的树做体检。比如你写了一棵支持插入、删除的BST怎么保证删除之后的树依然合法标准做法就是删除后调用一次isValidBST来做回归验证。在这种工程场景下你需要的不只是一个能跑的函数而是一个能集成到数据结构实现里的方法。递归区间法这时有一个天然优势它可以作为TreeNode类的一个isValid方法内部通过null边界来表示无界的根节点非常贴合对象思维护性。第五个方向是深入理解递归的本质是传递约束。很多人学递归时只记住了递归调用自己却忽略了一个更重要的视角递归调用是为了把约束条件逐层传递下去。在BST验证中约束是值域在求二叉树深度中约束是层级在归并排序中约束是每一半必须有序。传递约束这个视角一旦建立再看很多树的题目都会豁然开朗。面试官看中的往往也是这一点——你不是在背题而是在用统一的思维模型解决问题。就我个人刷题的经验来说第98题这个验证二叉搜索树非常适合作为递归进阶的分水岭。前期刷二叉树入门题时你可能只需要处理节点本身但这道题逼着你开始思考祖先和子孙之间的跨层关系。我当时把这道题做了三遍第一遍用错误的相邻比较法被测试教做人第二遍写出递归区间法并通过第三遍尝试中序迭代版发现自己对栈的运用还不够熟练。三遍下来BST相关的题目我基本都能举一反三。建议你也按这个思路先写对再写好最后再尝试不同的表达方式。另外想提醒一句网上有很多讨论区上面会有各种奇技淫巧的解法比如用全局变量记录中序序列再检查排序或者用前序遍历加Long.MIN_VALUE/Long.MAX_VALUE的版本。看看开阔思路可以但面试时最好还是选择自己信手拈来、边界条件完全清楚的解法。我见过不止一个同学在现场临时切换成看起来更高级的写法结果卡在边界条件上反而不如老老实实写区间法。能把简单解法写干净本身也是一种能力。