今天拆解一道二叉搜索树(BST)的经典应用题——LeetCode 230. 二叉搜索树中第 K 小的元素。这道题核心考察 BST 的特性,难度中等,却是面试中高频出现的基础题,掌握两种核心解法(迭代+递归),能帮我们更灵活应对同类问题。
先看题目要求:给定一个二叉搜索树的根节点 root 和整数 k(k 从 1 开始计数),设计算法查找其中第 k 小的元素。
在动手写代码前,我们必须先抓住一个关键前提——二叉搜索树的中序遍历特性:BST 的中序遍历(左 → 根 → 右)结果是升序排列的。这意味着,中序遍历序列的第 k-1 个元素(索引从 0 开始),就是题目要求的第 k 小的元素。
基于这个核心特性,我们可以衍生出两种解法:迭代式中序遍历(适合处理大数据量,避免递归栈溢出)和递归式中序遍历(代码简洁,易于理解)。下面分别拆解两种解法的思路和代码实现。
一、题目前置知识(快速回顾)
首先明确 TreeNode 的定义(题目已给出,这里统一梳理,方便后续理解代码):
class TreeNode {
val: number
left: TreeNode | null
right: TreeNode | null
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = (val === undefined ? 0 : val)
this.left = (left === undefined ? null : left)
this.right = (right === undefined ? null : right)
}
}
核心提醒:BST 的左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值,中序遍历升序是解题的核心突破口。
二、解法一:迭代式中序遍历(推荐,无栈溢出风险)
1. 解题思路
迭代式中序遍历通过「栈」模拟递归过程,避免了递归可能出现的栈溢出问题(当树的深度极大时,递归会触发栈溢出,迭代则不会)。步骤如下:
-
初始化一个空栈,以及当前节点 curr 指向根节点 root;
-
先将 curr 及其所有左子节点依次压入栈(对应中序遍历的「左」);
-
弹出栈顶节点(此时是当前最小的节点),对其进行计数(k--);
-
如果 k 减至 0,说明当前弹出的节点就是第 k 小的元素,直接返回其值;
-
否则,将当前节点的右子节点作为新的 curr,重复步骤 2-4(处理右子树的「左」)。
简单说:就是通过栈「左到底」,再依次弹出计数,找到第 k 个弹出的节点即可。
2. 代码实现
function kthSmallest_1(root: TreeNode | null, k: number): number {
if (!root) return 0; // 边界条件:树为空,返回0(题目默认k有效,实际可抛异常)
let res = 0;
const stack: TreeNode[] = [];
let curr: TreeNode | null = root;
// 第一步:将当前节点及其所有左子节点压入栈
while (curr) {
stack.push(curr);
curr = curr.left;
}
// 第二步:弹出栈顶,计数,处理右子树
while (stack.length) {
const node = stack.pop()!; // 弹出当前最小节点(栈顶)
// 将当前节点的右子节点及其所有左子节点压入栈
let right = node.right;
while (right) {
stack.push(right);
right = right.left;
}
k--; // 计数减1
if (k === 0) { // 找到第k小的元素
res = node.val;
break;
}
}
return res;
};
3. 复杂度分析
-
时间复杂度:O(h + k),h 是树的高度。最坏情况下,树是斜树(高度为 n),需要遍历 k 个节点,此时复杂度为 O(n);最好情况下,树是完全二叉树(h = logn),复杂度为 O(logn + k)。
-
空间复杂度:O(h),栈的大小最多为树的高度(斜树时为 n,完全二叉树时为 logn)。
三、解法二:递归式中序遍历(简洁,易于理解)
1. 解题思路
递归式中序遍历更符合我们对「左→根→右」的直观理解,核心是通过递归遍历左子树,在遍历到根节点时进行计数,一旦计数达到 k,就记录结果并提前终止递归(避免不必要的遍历)。步骤如下:
-
定义一个递归辅助函数 helper,接收当前节点;
-
递归遍历当前节点的左子树(优先处理左,保证升序);
-
遍历到根节点时,k 减 1,若 k 为 0,记录当前节点值为结果,直接返回(提前终止);
-
递归遍历当前节点的右子树;
-
从根节点开始调用 helper,最终返回记录的结果。
关键优化:当 k 减至 0 时,直接终止递归(包括父递归),避免遍历整个树,提升效率。
2. 代码实现
function kthSmallest_2(root: TreeNode | null, k: number): number {
if (!root) return 0; // 边界条件
let res = 0;
// 递归中序遍历辅助函数
const helper = (node: TreeNode | null) => {
if (!node || k === 0) return; // 节点为空或已找到结果,提前终止
helper(node.left); // 遍历左子树(左)
// 处理当前节点(根)
k--;
if (k === 0) {
res = node.val;
return;
}
helper(node.right); // 遍历右子树(右)
};
helper(root); // 启动递归
return res;
};
3. 复杂度分析
-
时间复杂度:O(h + k),与迭代法一致。最坏情况遍历整个树(k = n),复杂度 O(n);最好情况遍历到第 k 个节点就终止,复杂度 O(logn + k)。
-
空间复杂度:O(h),递归栈的深度等于树的高度(斜树时为 n,完全二叉树时为 logn),存在栈溢出风险(树深度极大时)。
四、两种解法对比与选择
| 解法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 迭代式(kthSmallest_1) | 无栈溢出风险,可控性强 | 代码稍长,需要手动维护栈 | 树深度较大、大数据量场景 |
| 递归式(kthSmallest_2) | 代码简洁,易于理解和编写 | 树深度极大时可能栈溢出 | 算法题解题、树深度较小时 |
五、常见易错点提醒
-
k 从 1 开始计数:注意计数时是 k-- 后判断是否为 0,而非先判断再减(否则会漏判第 1 个元素)。
-
边界条件处理:当 root 为 null 时,题目默认返回 0(实际场景可根据需求抛异常,比如 k 无效时)。
-
递归提前终止:必须在 helper 函数中判断 k === 0 时直接返回,否则会继续遍历右子树,浪费性能。
-
栈的维护:迭代法中,弹出节点后,必须将其右子节点及其所有左子节点压入栈,否则会遗漏右子树的元素。
六、总结
LeetCode 230 题的核心是利用 BST 中序遍历升序的特性,两种解法本质都是中序遍历的不同实现,只是遍历方式(迭代/递归)不同。
实际解题时,优先选择迭代法(避免栈溢出);如果是面试手写题,递归法更简洁,能快速写出正确代码,但需注意说明其栈溢出的局限性。
掌握这道题后,能举一反三解决同类 BST 问题,比如「找第 k 大的元素」(中序遍历逆序,右→根→左)、「验证 BST 的有效性」等,建议大家动手敲一遍代码,感受两种遍历方式的差异。