ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

二叉排序树判断:从经典题到工程实践,彻底掌握BST验证

二叉排序树判断:从经典题到工程实践,彻底掌握BST验证

1. 从一道经典题说起:为什么“判断二叉排序树”值得单独拎出来讲

但凡刷过数据结构题的人,大概率都见过这道题:给一棵二叉树,判断它是不是二叉排序树(BST)。很多人第一反应是“这题简单”,然后五分钟写个递归交上去,结果被边界用例打得满地找牙。我自己当年第一次写的时候,就栽在了一个看起来人畜无害的用例上——一棵长得很像BST、但某个深层节点偷偷违反了大小关系的树。

这道题之所以经典,是因为它表面上考的是递归遍历,实际上考的是你对二叉排序树定义的理解深度、对遍历顺序与约束传递的把握,以及对边界条件(重复值、极值、空树)的处理严谨度。它是一道能同时区分“背过模板”和“真正理解”的题。

这篇文章我会把这道题彻底拆开讲透:从BST的定义本质,到三种主流解法的取舍,再到中序遍历为什么是“最优解”,最后附上我踩过的坑和一份可以直接抄的排查清单。不管你是正在准备面试,还是带新人时想找个例子讲清楚“约束传递”这个概念,这篇都能直接用。

先明确一下本文讨论的范围:二叉排序树(Binary Search Tree),也叫二叉查找树、二叉搜索树,指的是这样一棵二叉树——对于任意节点,其左子树中所有节点的值都严格小于它,右子树中所有节点的值都严格大于它。注意这里我用了“所有节点”和“严格”,这两个词是后面所有坑的根源。

2. 二叉排序树的定义到底在说什么

2.1 局部性质与全局性质的混淆陷阱

很多人对BST的理解停留在“左孩子比父节点小,右孩子比父节点大”。这个理解不能说错,但它只描述了局部性质,而BST的定义要求的是全局性质。

举个具体的例子。假设有这样一棵树:根节点是10,左孩子是5,右孩子是15;然后5的右孩子是12。你看局部:10的左孩子5小于10,没问题;5的右孩子12大于5,也没问题。但整棵树是BST吗?不是。因为12在根节点10的左子树里,而12大于10,违反了“左子树所有节点都小于根”的全局约束。

这就是局部判断的致命缺陷。你只比较父子两代,永远发现不了这种“跨层违规”。我见过太多人写的代码就是只比较当前节点和左右孩子,然后自信满满地说“我测过了”。测过不代表测全了,这种用例不专门构造是碰不到的。

2.2 严格与非严格:重复值怎么处理

另一个容易被忽略的点是“严格”二字。标准的BST定义要求左子树严格小于、右子树严格大于,也就是不允许重复值。但实际工程中,很多场景是允许重复的,这时候就有两种常见变体:

  • 变体A:左子树小于等于根,右子树大于根(重复值统一放左边)
  • 变体B:左子树小于根,右子树大于等于根(重复值统一放右边)

这两种变体在判断逻辑上是不同的。如果你面试时遇到这题,一定要先问清楚重复值怎么处理,这本身就是加分项。我见过候选人直接默认不允许重复,结果面试官给的用例里有重复值,当场就卡住了。

提示:判断逻辑里用<还是<=,取决于题目对重复值的约定。写代码前先确认,别想当然。

2.3 空树和单节点:边界也是合法BST

空树是不是BST?按定义,空树没有任何节点违反约束,所以是BST。单节点树同理,也是BST。这两个边界看起来废话,但实际写代码时,递归的终止条件如果处理不当,很容易在这里出问题。比如你用“取左子树最大值和根比较”的思路,空树时取最大值就会抛异常。

3. 三种主流解法逐一拆解

3.1 解法一:中序遍历收集后判断有序

这是最直观、也最不容易出错的解法。原理很简单:BST的中序遍历结果一定是严格递增的序列。所以你把中序遍历的结果存到一个数组里,然后从头到尾检查是否严格递增即可。

def isValidBST_inorder(root): vals = [] def inorder(node): if not node: return inorder(node.left) vals.append(node.val) inorder(node.right) inorder(root) for i in range(1, len(vals)): if vals[i] <= vals[i-1]: return False return True

这个解法的优点是逻辑清晰、几乎不会写错,缺点是空间复杂度O(n),需要额外存整个序列。对于一般规模的树完全够用,但如果树很大或者你追求极致空间,就得考虑下面的解法。

我个人的经验是:面试时如果时间紧,优先写这个。先把正确性拿下,再谈优化。很多面试官看到你能写出中序遍历,基本就认可你理解BST了。

3.2 解法二:中序遍历 + 前驱指针(O(1)空间)

既然中序遍历是递增的,那我其实不需要存整个序列,只需要记住上一个访问的节点,每次和当前节点比较即可。这样空间复杂度降到O(1)(不考虑递归栈)。

def isValidBST_inorder_opt(root): prev = [None] # 用列表模拟可变引用 def inorder(node): if not node: return True if not inorder(node.left): return False if prev[0] is not None and node.val <= prev[0].val: return False prev[0] = node return inorder(node.right) return inorder(root)

这个解法是中序遍历思路的最优形态,也是我最推荐的写法。它既保留了中序遍历的直观性,又把空间压到了最低。唯一需要注意的是prev的更新时机——必须在比较之后、递归右子树之前更新,顺序错了就全乱。

注意:Python里闭包对外部变量的修改需要用可变对象(如列表)或者nonlocal关键字。用列表是个偷懒但有效的写法,面试时如果面试官较真,可以换成nonlocal。

3.3 解法三:上下界约束传递(递归传参)

这个解法是另一种思路:不依赖中序遍历,而是在递归时把当前节点允许的取值范围传下去。根节点的范围是(-∞, +∞),往左走时上界收紧为当前节点值,往右走时下界收紧为当前节点值。

def isValidBST_bounds(root): def check(node, low, high): if not node: return True if node.val <= low or node.val >= high: return False return check(node.left, low, node.val) and \ check(node.right, node.val, high) return check(root, float('-inf'), float('inf'))

这个解法的精髓在于约束的传递。它直接对应了BST的全局定义:每个节点都必须落在它祖先们共同划定的区间内。理解了这个,你就真正理解了BST。

但这个解法有个坑:用float的极值作为初始边界,如果节点值本身就是极值会误判。更稳妥的做法是用None表示无界,或者用语言提供的更大类型。我在实际项目里就遇到过节点值是INT_MIN的情况,用float('-inf')虽然能过,但如果换成C++用INT_MIN做边界就炸了。

3.4 三种解法对比与选型建议

解法时间复杂度空间复杂度优点缺点推荐场景
中序收集O(n)O(n)最直观,不易错额外空间大面试快速作答
中序+前驱O(n)O(1)空间最优,直观需处理闭包/引用生产环境首选
上下界传递O(n)O(h)体现全局约束理解边界值需小心教学/展示理解深度

选型建议很明确:面试先写中序+前驱,如果面试官追问空间优化,你已经答完了;如果面试官想考你对约束传递的理解,再补上下界解法。三种都会,你就是这道题的天花板。

4. 完整实操:从零实现并验证

4.1 构造测试用例的正确姿势

写这道题,测试用例比代码本身更重要。我总结了必须覆盖的几类用例:

  1. 空树:应该返回True
  2. 单节点:应该返回True
  3. 标准BST:应该返回True
  4. 局部合法但全局违规:应该返回False(这是最容易漏的)
  5. 含重复值:根据约定判断
  6. 含极值:验证边界处理
  7. 退化成链表的BST:验证递归深度

第4类用例我强烈建议每个人都手动构造一遍。比如前面说的“根10、左5、5的右孩子12”,这棵树用局部判断会误判为True,用正确解法会返回False。构造这个用例的过程,就是你真正理解BST的过程。

4.2 手把手实现中序+前驱解法

我用Python完整实现一遍,并加上详细注释:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def isValidBST(root): """ 判断二叉树是否为二叉排序树 采用中序遍历 + 前驱指针,空间O(1) """ prev = None # 记录中序遍历的前一个节点 def inorder(node): nonlocal prev if node is None: return True # 先递归左子树 if not inorder(node.left): return False # 处理当前节点:与前驱比较 if prev is not None and node.val <= prev.val: return False prev = node # 再递归右子树 return inorder(node.right) return inorder(root)

这段代码的关键点有三个:nonlocal prev声明、比较时机在左递归之后、更新prev在比较之后。任何一处顺序错了都会导致错误结果。

4.3 验证过程与结果记录

我拿几组用例跑一遍,记录实际结果:

# 用例1:空树 print(isValidBST(None)) # True # 用例2:单节点 print(isValidBST(TreeNode(5))) # True # 用例3:标准BST # 10 # / \ # 5 15 # / \ / \ # 3 7 12 20 root = TreeNode(10) root.left = TreeNode(5, TreeNode(3), TreeNode(7)) root.right = TreeNode(15, TreeNode(12), TreeNode(20)) print(isValidBST(root)) # True # 用例4:局部合法但全局违规 # 10 # / \ # 5 15 # \ # 12 <- 12在10的左子树里,但大于10 root = TreeNode(10) root.left = TreeNode(5) root.left.right = TreeNode(12) root.right = TreeNode(15) print(isValidBST(root)) # False,正确识别 # 用例5:含重复值(约定不允许重复) # 10 # / \ # 5 15 # / # 5 <- 重复 root = TreeNode(10) root.left = TreeNode(5, TreeNode(5)) root.right = TreeNode(15) print(isValidBST(root)) # False,因为5 <= 5

实测下来,这五组用例全部通过。特别是用例4,如果你用局部判断的写法,这里会返回True,直接暴露问题。

5. 踩坑实录与排查清单

5.1 我踩过的三个真实坑

坑一:只比较父子两代。这是我第一次写这题犯的错,代码大概长这样:

def wrong(node): if not node: return True if node.left and node.left.val >= node.val: return False if node.right and node.right.val <= node.val: return False return wrong(node.left) and wrong(node.right)

这段代码对用例4会返回True,因为它只看10和5、10和15、5和12这三对父子关系,而5和12确实满足“右孩子大于父节点”。但它忽略了12和10的关系。修正方法就是改用中序遍历或上下界传递。

坑二:中序遍历时比较时机错误。我一度写成先更新prev再比较,结果永远返回True。因为prev更新成当前节点后,再拿当前节点和prev比,就是自己和自己比,永远相等,永远不触发<=。记住:先比较,后更新。

坑三:用float极值做边界遇到极值节点。用上下界解法时,如果节点值恰好是float('-inf')(虽然实际很少见),初始边界判断会出问题。稳妥做法是用None表示无界,或者用对象包装。

5.2 常见问题速查表

问题现象可能原因排查方法解决方案
局部违规能查出,全局违规查不出只比较父子两代构造“左子树的右孩子大于根”的用例改用中序遍历或上下界
所有用例都返回Trueprev更新时机错误打印每次比较的prev和当前值先比较后更新
含重复值时结果不符预期比较用了<而非<=确认题目对重复值的约定按约定调整比较符
极值节点误判边界用了具体极值用极值节点测试用None表示无界
递归栈溢出树退化成链表构造单链BST测试改用迭代中序遍历

5.3 独家避坑技巧

第一个技巧:写完代码先别急着提交,手动构造用例4。这个用例是区分“会写”和“写对”的分水岭。我面试别人的时候,如果候选人主动提到这个用例,基本就稳了。

第二个技巧:中序遍历的迭代写法也准备一份。递归写法在树很深时会栈溢出,迭代写法用显式栈,虽然空间还是O(h),但不会爆栈。面试时如果面试官问“树很深怎么办”,你能答上来就是加分。

def isValidBST_iterative(root): stack = [] prev = None curr = root while curr or stack: while curr: stack.append(curr) curr = curr.left curr = stack.pop() if prev is not None and curr.val <= prev.val: return False prev = curr curr = curr.right return True

第三个技巧:如果题目允许修改树结构,Morris遍历可以把空间降到O(1)且不用递归栈。但Morris遍历会临时改变树结构,判断完要恢复,写起来复杂,面试时除非面试官明确要求,否则不建议主动写,容易出错。

6. 这道题背后的思维模型

6.1 约束传递:递归问题的一般套路

这道题真正有价值的地方,是它展示了一个通用的递归思维模型:约束传递。当一个问题要求“整棵树满足某性质”,而这个性质可以分解为“每个节点在某个区间内”时,你就可以在递归时把区间传下去。

这个模型可以迁移到很多问题上。比如判断一棵树是不是平衡树,你传下去的是“当前子树的高度范围”;比如在树里找一条满足某条件的路径,你传下去的是“当前路径的累积状态”。掌握了约束传递,你就掌握了一大类树形递归问题的解法。

6.2 中序遍历:BST问题的万能钥匙

另一个思维模型是:BST的很多问题都可以用中序遍历解决。因为中序遍历把BST“压平”成了一个有序数组,很多在数组上容易解决的问题,搬到BST上就可以用中序遍历。

比如“找BST中第k小的元素”,中序遍历到第k个就停;“找BST中两个节点的最近公共祖先”,利用中序序列的有序性可以快速定位;“把BST转换成有序双向链表”,直接中序遍历边遍历边改指针。遇到BST问题,先想中序遍历,这是条件反射。

6.3 从这道题看面试考察点

面试官出这道题,想考察的其实是三层能力:

第一层,基础编码能力:你能不能写出正确的递归遍历。这一层刷过题的人都能过。

第二层,边界处理能力:你能不能想到空树、重复值、极值、全局违规这些边界。这一层能筛掉一半人。

第三层,思维深度:你能不能说出为什么中序遍历有效、约束传递的本质是什么、不同解法的取舍。这一层是区分优秀和合格的关键。

所以如果你在准备面试,这道题不要只满足于“写出来”,要能讲清楚每种解法的原理和取舍,最好能主动说出用例4这种反例。这才是真正的加分项。

7. 工程实践中的延伸思考

7.1 实际项目中什么时候需要判断BST

你可能会问,实际工程里真的会用到“判断一棵树是不是BST”吗?直接用的场景确实不多,但间接相关的场景很常见。

比如你实现了一个自平衡二叉搜索树(AVL树、红黑树),在插入删除后需要验证树的性质是否被破坏,这时候就需要一个判断BST的函数作为断言(assertion)。再比如你做数据校验,从外部导入了一棵声称是BST的树,需要验证其合法性。还有在测试自平衡树的旋转逻辑时,判断BST是验证正确性的基础手段。

所以这道题不是纯理论,它是树结构正确性验证的基础工具。理解了它,你在实现更复杂的树结构时就有了一个可靠的“体检工具”。

7.2 性能考量:什么时候O(n)空间不可接受

前面说中序收集解法空间O(n),一般够用。但如果你的树有百万级节点,O(n)的额外数组可能就不可接受了。这时候中序+前驱的O(1)空间解法就体现出价值。

再极端一点,如果树深到递归栈会溢出(比如退化成百万节点的链表),那就得用迭代中序遍历。迭代写法虽然空间还是O(h),但h在最坏情况下也是n,不过它用的是堆上的显式栈,不会像递归那样受系统栈大小限制。

所以完整的选型逻辑是:小树随便写,大树用中序+前驱,超深树用迭代中序,追求极致用Morris。这个决策链条能覆盖从面试到生产的各种场景。

7.3 与其他树性质判断的对比

判断BST只是“树性质判断”这个大家族的一员。类似的还有:

  • 判断平衡二叉树:递归返回高度,同时检查左右高度差
  • 判断完全二叉树:层序遍历,遇到空节点后不能再有非空节点
  • 判断满二叉树:递归检查每个节点要么是叶子要么有两个孩子
  • 判断对称二叉树:递归比较左右子树是否镜像

这些题的共同套路都是递归 + 传递某种约束或状态。你把BST这道题吃透,其他几道基本是触类旁通。我建议把这几个判断放在一起刷,体会它们的共性和差异,效果比单独刷好得多。

8. 一份可以直接抄的完整代码

最后给一份我平时用的完整实现,包含递归和迭代两个版本,带详细注释,可以直接复制到你的项目里当工具函数:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def is_bst_recursive(root): """ 递归版:中序遍历 + 前驱指针 时间O(n),空间O(h),h为树高 不允许重复值 """ prev = None def inorder(node): nonlocal prev if node is None: return True if not inorder(node.left): return False if prev is not None and node.val <= prev.val: return False prev = node return inorder(node.right) return inorder(root) def is_bst_iterative(root): """ 迭代版:显式栈中序遍历 时间O(n),空间O(h),适合深树 不允许重复值 """ stack = [] prev = None curr = root while curr or stack: while curr: stack.append(curr) curr = curr.left curr = stack.pop() if prev is not None and curr.val <= prev.val: return False prev = curr curr = curr.right return True def is_bst_with_duplicates_left(root): """ 允许重复值,重复值放左子树 左子树 <= 根 < 右子树 """ prev = None def inorder(node): nonlocal prev if node is None: return True if not inorder(node.left): return False if prev is not None and node.val < prev.val: return False prev = node return inorder(node.right) return inorder(root)

这三个函数覆盖了绝大多数场景。is_bst_recursive用于一般情况,is_bst_iterative用于深树,is_bst_with_duplicates_left用于允许重复值的场景。你可以根据实际需求选用。

我个人在实际项目里的体会是,判断BST这件事,写对一次容易,写对一百次难。因为每次写的时候,prev的更新时机、比较符的方向、边界的处理,都是容易手滑的地方。所以我建议你把这几个函数放进自己的代码片段库,用的时候直接抄,别每次重新写。省下来的时间,用来思考更值得思考的问题。

RELATED READING

延伸阅读

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