二叉搜索树(Binary Search Tree, BST)是数据结构中的"秩序守护者"。它的核心定义非常简单:对于任意节点,其左子树所有节点的值 < 当前节点 < 右子树所有节点的值。
这个看似简单的定义,在实际算法落地时衍生出了两个最经典的方向:
-
约束(Constraint):如何验证一棵树是否严格遵守了规则?
-
顺序(Order):如何利用这个规则快速找到特定的元素?
本文将通过两道经典题目,结合代码实现,深入剖析 BST 的递归哲学。
一、 验证的艺术:自顶向下的区间约束
验证一棵二叉搜索树,最直观的错误想法是:只判断"当前节点"和"左右孩子"的大小关系。这是不够的,因为 BST 要求的是整个 左子树都要小于根节点,整个右子树都要大于根节点。
因此,我们需要一种**自顶向下(Top-Down)**的思维:父节点要把自己的"数值范围约束"传递给子节点。
1. 核心代码解析
我们使用递归函数,为每个节点维护一个开区间 (left, right)。
-
根节点的范围是
(-∞, +∞)。 -
向左走时:最大值被更新为当前节点的值(不能超过爸爸)。
-
向右走时:最小值被更新为当前节点的值(不能小于爸爸)。
C++代码实现:
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
// 思路: 递归判断左右子树是不是符合我们二叉搜索树的要求
public:
// 使用 long long 是为了防止节点值为 INT_MIN 或 INT_MAX 时造成的边界判断错误
bool isValidBST(TreeNode* root, long long left = LLONG_MIN, long long right = LLONG_MAX) {
if (root == nullptr) return true;
// 核心逻辑:
// 1. 当前值必须在 (left, right) 区间内
// 2. 递归左子树:上界变为 root->val
// 3. 递归右子树:下界变为 root->val
return root->val > left && root->val < right && isValidBST(root->left, left, root->val) && isValidBST(root->right, root->val, right);
}
};
2. 深度思考:为什么要用 long long?
代码中 left 和 right 的默认值使用了 LLONG_MIN 和 LLONG_MAX。这是因为 LeetCode 的测试用例中,树节点的值可能正好是 int 类型的最小值或最大值。 如果使用 INT_MIN 作为初始下界,当根节点就是 INT_MIN 时,判断条件 root->val > left 会变成 INT_MIN > INT_MIN (False),导致误判。提升数据类型范围是解决此类边界问题的最佳手段。
3. 时空复杂度分析
-
时间复杂度:O(N)
- 我们需要遍历树中的每一个节点来确认其有效性。每个节点只会被访问一次。
-
空间复杂度:O(H)
-
H 为树的高度。主要消耗在于递归调用栈。
-
最坏情况(链状树)为 O(N),平均情况(平衡树)为 O(log N)。
-
二、 顺序的艺术:二叉搜索树中第 K 小的元素
如果说上一题是验证规则,这一题就是利用规则。 BST 的最大特性在于:如果对其进行中序遍历(Inorder Traversal),得到的序列是严格单调递增的。
正如代码思路中所写:"想象把这一棵二叉搜索树拍扁",它就是一个有序数组 [1, 2, 3, 4...]。我们要找第 K 小的数,其实就是中序遍历过程中的第 K 个节点。
1. 核心代码解析
这里不需要构造整个数组,那样太浪费空间。我们利用 DFS(深度优先搜索) 配合 引用传递 的计数器 k,实现"边走边数,数到即停"。
C++代码实现:
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left),
* right(right) {}
* };
*/
class Solution {
// 思路: 想象把这一棵二叉搜索树拍扁, 它刚好会是1234的顺序呈现
// 因此我们优先考虑左边也就是dfs
// left,left找完然后找right,这里用&k来维护全局第k小
int ans;
// 注意这里的 k 是引用传递 (int& k),这是实现"全局计数"的关键
void dfs(TreeNode* root, int& k) {
if (root == nullptr) return;
// 1. 先去最左边(最小的地方)
dfs(root->left, k);
// 2. 中序位置:处理当前节点
// 每经过一个节点,k 减 1,表示这就"划掉"了一个比目标小的数
if (--k == 0) {
ans = root->val;
return; // 找到了,直接返回,不再继续深搜
}
// 3. 左边和自己都不是,那就去右边找
dfs(root->right, k);
}
public:
int kthSmallest(TreeNode* root, int k) {
dfs(root, k);
return ans;
}
};
2. 深度思考:引用传递 int& k 的妙用
很多初学者容易在这里写成值传递 int k。
-
值传递 :每层递归里的
k都是副本,左子树里把k减到了 0,回到父节点时k还是原来的值,导致计数失效。 -
引用传递 :
int& k使得所有递归层级共享同一个变量。它就像一个倒计时器,无论递归走到哪里,只要遍历了一个节点,这个全局计数器就减 1。
此外,if (--k == 0) 后的 return 虽然不能直接跳出整个递归栈(C++没有直接中断机制),但它能有效阻止当前分支继续向右递归,起到一定的剪枝优化作用。
3. 时空复杂度分析
-
时间复杂度:O(H + k)
-
我们需要先深入到最左下角(高度 H),然后开始回溯并计数 k 次。
-
一旦找到第 k 个数,我们就不再处理剩余的节点了。
-
在最坏情况下(k = N),复杂度为 O(N)。
-
-
空间复杂度:O(H)
-
主要消耗为递归栈的空间。
-
最坏情况 O(N),平均情况 O(log N)。
-
三、 总结
这两道题目完美诠释了 BST 的两面性:
-
IsValidBST:侧重于**"区间"**。利用递归参数携带状态(最大最小值),自顶向下进行"封锁"检查。
-
KthSmallest:侧重于**"顺序"**。利用中序遍历的特性,配合全局计数器,自底向上进行"枚举"查找。