题目描述
给定一个二叉搜索树(BST)的根节点 root,和一个整数 k(k 从 1 开始计数),设计算法查找其中第 k 小的元素。
示例 1:
输入:root = [3,1,4,null,2], k = 1
输出:1
解释: BST 结构如下:
3
/ \
1 4
\
2
中序遍历结果:[1, 2, 3, 4],第 1 小的元素是 1
示例 2:
输入:root = [5,3,6,2,4,null,null,1], k = 3
输出:3
解释: BST 结构如下:
5
/ \
3 6
/ \
2 4
/
1
中序遍历结果:[1, 2, 3, 4, 5, 6],第 3 小的元素是 3
提示:
- 树中的节点数为 n
- 1 <= k <= n <= 10^4
- 0 <= Node.val <= 10^4
进阶: 如果二叉搜索树经常被修改(插入/删除操作)并且你需要频繁地查找第 k 小的值,你将如何优化算法?
解题思路总览
| 方法 | 思路 | 时间复杂度(查询) | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 方法一:中序遍历(递归) | BST 中序遍历为升序,递归遍历计数 | O(n) | O(h) | 一次查询,简单直接 |
| 方法二:中序遍历(迭代) | 用栈模拟递归,迭代实现 | O(n) | O(h) | 一次查询,面试备用 |
| 方法三:记录子树节点数 | 每个节点维护左子树节点数,计算定位 | O(h) | O(n) | 频繁查询 |
| 方法四:平衡 BST(AVL/红黑树) | 自动平衡的 BST,查询稳定 O(log n) | O(log n) | O(n) | 频繁修改+频繁查询 |
核心原理: BST 的中序遍历结果是一个升序序列,第 k 小的元素就是中序遍历的第 k 个节点。
方法一:中序遍历(递归)
思路
利用 BST 的性质:左子树所有节点值 < 根节点值 < 右子树所有节点值。中序遍历(左-根-右)的顺序正好是升序的。
遍历过程中用计数器 k 跟踪当前访问到第几个节点,当 k 减到 0 时,当前节点就是第 k 小的元素。
完整代码
cpp
class Solution {
public:
int ans;
void Traversal(TreeNode* head, int& k) {
if (!head || k <= 0) return; // 剪枝:节点为空或已找到则停止
Traversal(head->left, k); // 先遍历左子树
if (--k == 0) { // 访问根节点时计数减1
ans = head->val;
}
Traversal(head->right, k); // 最后遍历右子树
}
int kthSmallest(TreeNode* root, int k) {
Traversal(root, k);
return ans;
}
};
算法流程图
以示例 1 为例,root = [3,1,4,null,2], k = 1:
原始 BST:
3
/ \
1 4
\
2
遍历路径:
1. Traversal(3, k=1)
|
+-- Traversal(1, k=1)
| |
| +-- Traversal(null) 返回
| |
| 访问 1:--k=0 == 0,ans=1
| |
| +-- Traversal(2, k=0) → k<=0 直接返回
|
+-- (根节点 3 不再访问,因为 k<=0 已剪枝)
返回 ans = 1
逐行解析
cpp
int ans; // 全局变量,存储最终答案
- 定义成员变量
ans,保存第 k 小的元素值。
cpp
void Traversal(TreeNode* head, int& k) {
if (!head || k <= 0) return; // 剪枝
int& k使用引用传递,确保递归中的修改影响外层。- 剪枝条件 :
!head(空节点)和k <= 0(已找到),提前终止无效遍历。
cpp
Traversal(head->left, k); // 左
if (--k == 0) { ans = head->val; } // 根
Traversal(head->right, k); // 右
k先自减再比较,每访问一个节点计数减 1。k == 0时说明当前节点是第 k 小的元素。
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 最坏遍历所有节点(k = n 时无法剪枝) |
| 空间 | O(h) | 递归栈深度,h 为树高 |
优点: 代码简洁,容易理解
缺点: 每次查询都要 O(n) 时间,无法利用历史修改信息
方法二:中序遍历(迭代)
思路
用显式栈模拟递归过程,避免递归调用带来的函数开销和栈空间风险。
完整代码
cpp
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
stack<TreeNode*> st;
TreeNode* cur = root;
while (cur || !st.empty()) {
// 1. 一直向左入栈,直到最左叶子
while (cur) {
st.push(cur);
cur = cur->left;
}
// 2. 出栈访问节点
cur = st.top();
st.pop();
if (--k == 0) {
return cur->val;
}
// 3. 转向右子树
cur = cur->right;
}
return -1; // 不应该走到这里
}
};
算法流程图
初始:cur = root,栈为空
Step 1: cur = 3 入栈,cur = 1
Step 2: cur = 1 入栈,cur = null
Step 3: cur = null,循环退出内层 while
Step 4: cur = 1 出栈,--k=0 == 0,返回 cur->val = 1
模拟过程:
栈状态变化:[] → [3] → [3,1] → [3] → []
cur 变化: 3 → 1 → null → 1 → 2/null
逐行解析
cpp
while (cur || !st.empty()) {
- 循环条件:当前节点不为空 或 栈不为空
- 两个条件有一个满足就继续
cpp
while (cur) {
st.push(cur);
cur = cur->left;
}
- 第一阶段:将左子树的路径全部入栈
- 每次循环将当前节点入栈,然后移向左孩子
- 直到
cur == null退出
cpp
cur = st.top();
st.pop();
if (--k == 0) return cur->val;
- 第二阶段:出栈访问节点
- 此时
cur是栈顶元素,即最左叶子(或左子树已访问完的节点) k计数减 1,判断是否找到
cpp
cur = cur->right;
- 第三阶段:转向右子树
- 下一轮循环会遍历右子树的左子树路径
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个节点最多入栈出栈各一次 |
| 空间 | O(h) | 栈最多存储 h 个节点 |
优点: 避免递归栈溢出风险,面试可以展示非递归功底
缺点: 时间复杂度仍是 O(n),无法优化查询效率
方法三:记录子树节点数(进阶优化)
思路
在每个节点上维护一个 leftCount 字段,表示该节点左子树的节点数。通过计算可以快速定位第 k 小的元素,无需遍历整棵树。
完整代码
cpp
class TreeNodeEx {
public:
int val;
int leftCount; // 左子树的节点数
TreeNodeEx* left;
TreeNodeEx* right;
TreeNodeEx(int x) : val(x), leftCount(0), left(nullptr), right(nullptr) {}
};
class Solution {
public:
// 辅助函数:统计以 node 为根的节点数
int count(TreeNodeEx* node) {
if (!node) return 0;
return count(node->left) + count(node->right) + 1;
}
// 构建带 leftCount 的 BST
TreeNodeEx* build(TreeNode* root) {
if (!root) return nullptr;
TreeNodeEx* node = new TreeNodeEx(root->val);
node->left = build(root->left);
node->right = build(root->right);
// leftCount 只统计左子树的节点数
node->leftCount = count(node->left);
return node;
}
// 查询第 k 小的元素
int kthSmallest(TreeNodeEx* root, int k) {
if (!root) return -1;
int leftCount = root->left ? root->leftCount : 0;
if (k <= leftCount) {
// 第 k 小在左子树中
return kthSmallest(root->left, k);
} else if (k == leftCount + 1) {
// 当前节点就是第 k 小
return root->val;
} else {
// 第 k 小在右子树中,需要减去左子树和当前节点的个数
return kthSmallest(root->right, k - leftCount - 1);
}
}
int kthSmallest(TreeNode* root, int k) {
TreeNodeEx* newRoot = build(root);
return kthSmallest(newRoot, k);
}
};
算法流程图
以 BST [3,1,4,null,2] 为例:
原始 BST:
3
/ \
1 4
\
2
构建后的结构(节点上数字为 leftCount):
3(1) ← 左子树有 1 个节点(节点1)
/ \
1(1) 4(0) ← 节点1 的左子树有 1 个节点(节点2)
\
2(0)
查询 k=1:
root=3, leftCount=1, k=1 <= leftCount=1
→ 进入左子树 root=1
root=1, leftCount=1, k=1 <= leftCount=1
→ 进入左子树 root=2
root=2, leftCount=0, k=1 == leftCount+1=1
→ 返回 root->val = 2
但这不对...k=1 应该返回 1
问题出在哪里?
重新理解 leftCount 的含义:
- leftCount 应该表示"以该节点为根的子树中,左子树部分有 x 个节点"
对于 root=3:
- 左子树是节点1,节点1本身又有左子树(节点2)
- 所以 3.leftCount = 1(节点1)+ 1(节点2)?不对...
重新看代码:
node->leftCount = count(node->left);
count(node) 统计的是以 node 为根的子树总节点数
所以:
- 节点2的 count = 1
- 节点1的 count = 1 + 1 = 2(自身 + 左子树节点2)
- 节点3的 count = 1 + 2 = 3(自身 + 左子树节点1及其子树)
而 leftCount = count(node->left)
所以:
- 节点3.leftCount = count(节点1) = 2
- 节点1.leftCount = count(节点2) = 1
- 节点4.leftCount = count(null) = 0
重新建图:
3(2)
/ \
1(1) 4(0)
\
2(0)
查询 k=1:
root=3, leftCount=2, k=1 <= 2
→ 进入左子树 root=1
root=1, leftCount=1, k=1 <= 1
→ 进入左子树 root=2
root=2, leftCount=0, k=1 == 0+1
→ 返回 2.val
这还是不对!k=1 应该返回 1,不是 2
哦我发现了!
leftCount = count(node->left) 表示的是:
node 左子树的"总节点数",而不是"直接左子节点数"
所以对于 root=3:
- leftCount = count(节点1) = 2(节点1 + 节点2)
这意味着在以 3 为根的子树中:
- 有 2 个节点在左子树
- 1 个节点是根(节点3)
- 0 个节点在右子树(节点4)
但这个信息不够!因为我们不知道这 2 个左子树节点是怎么分布的
实际上这个 leftCount 的含义是:
"如果按中序遍历,左子树一共有多少个节点"
对于 BST [3,1,4,null,2]:
中序遍历结果:[1, 2, 3, 4]
- 节点1 的位置是 1(它的左子树有 0 个节点,但它是节点3的左子树的第 2 个)
不对...
我重新梳理:
- 节点3.leftCount = count(节点1) = 2
这表示:按中序遍历,节点3的左子树有 2 个节点
- 节点1.leftCount = count(节点2) = 1
这表示:按中序遍历,节点1的左子树有 1 个节点
所以查询 k=1:
root=3, leftCount=2, k=1 <= leftCount=2
→ 第 1 小在节点3 的左子树中
root=1, leftCount=1, k=1 <= leftCount=1
→ 第 1 小在节点1 的左子树中
root=2, leftCount=0, k=1 == leftCount+1 == 1
→ 返回节点2 的值?
这还是不对...
问题是:
节点1 的 leftCount = 1 表示它的左子树有 1 个节点(节点2)
但节点1 本身的值 1 应该排在节点2 前面!
哦!我终于理解了:
leftCount 记录的应该是"左子树的节点数",不包括自身
所以:
- 节点2.leftCount = 0(没有左子树)
- 节点1.leftCount = 1(它的左子树有 1 个节点:节点2)
- 节点3.leftCount = 1(它的左子树有 1 个直接节点:节点1)
不对,节点1 的子树不只有直接左子节点...
让我重新理解 count(node->left):
- count(节点2) = 1(节点2 自己)
- count(节点1) = 1 + 1 + 0 = 2(自身 + 左子树 + 右子树)= 节点1 + 节点2
所以节点3.leftCount = count(节点1) = 2
这表示节点3 的左子树有 2 个节点
中序遍历 [1, 2, 3, 4]:
- 节点1 排在第 1
- 节点2 排在第 2
- 节点3 排在第 3
k=1 时:
root=3, leftCount=2, k=1 <= leftCount=2
→ 进入左子树 root=1
root=1, leftCount=1, k=1 <= leftCount=1
→ 进入左子树 root=2
root=2, leftCount=0, k=1 > leftCount+1=1
→ 进入右子树?但右子树是 null
这说明我的理解有误!
正确理解应该是:
- leftCount = count(node->left)
- count(node) 返回的是以 node 为根的子树的"总节点数"
所以节点1 的 leftCount = 1 表示:节点1 的左子树有 1 个节点(节点2)
但问题是节点1 本身的值(1)应该先于节点2 被访问!
代码中的逻辑:
if (k <= leftCount) {
// k 小于等于左子树节点数,说明第 k 小在左子树
return kthSmallest(root->left, k);
} else if (k == leftCount + 1) {
// k 等于左子树节点数 + 1,说明当前根节点就是第 k 小
return root->val;
} else {
// k 大于左子树节点数 + 1,说明第 k 小在右子树
return kthSmallest(root->right, k - leftCount - 1);
}
对于节点1:
- leftCount = count(节点2) = 1
- k = 1
k <= leftCount?1 <= 1?是的!
→ 进入左子树
但左子树(节点2)的值是 2,不是第 1 小
哦!我终于明白了!
节点1.leftCount = 1 表示节点1 的左子树有 1 个节点
但这个 1 个节点指的是节点2
中序遍历时:
- 先遍历节点1 的左子树(节点2),得到值 2
- 然后访问节点1,得到值 1
- 最后遍历节点1 的右子树(null)
所以中序遍历顺序是:[节点2的值, 节点1的值] = [2, 1]
但 BST 的性质要求:左子树 < 根 < 右子树
节点2 是节点1 的右子树,不是左子树!
所以 BST 结构应该是:
3
/ \
1 4
\
2
这意味着:
- 节点1 的右子树是节点2
- 节点1 的左子树是 null
那么节点1.leftCount = count(null) = 0?
让我重新建树:
原始输入:[3,1,4,null,2]
3
/ \
1 4
\
2
节点3:
- left = 节点1
- right = 节点4
节点1:
- left = null
- right = 节点2
节点2:
- left = null
- right = null
所以:
- 节点2.leftCount = count(null) = 0
- 节点1.leftCount = count(null) = 0
- 节点3.leftCount = count(节点1) = 1(节点1 自己)
重新建图:
3(1)
/ \
1(0) 4(0)
\
2(0)
查询 k=1:
root=3, leftCount=1, k=1 <= leftCount=1
→ 进入左子树 root=1
root=1, leftCount=0, k=1 == leftCount+1=1
→ 返回 root->val = 1
正确!
所以 leftCount 记录的是:node->left 子树的节点总数(不包括 node 自身)
- 节点2.leftCount = 0(无左子树)
- 节点1.leftCount = 0(节点2 是右子树,不是左子树)
- 节点3.leftCount = 1(节点1 是左子树,只有 1 个节点)
复杂度分析
| 复杂度 | 构建 | 查询 | 说明 |
|---|---|---|---|
| 时间 | O(n) | O(h) | 查询只需沿一条路径向下 |
| 空间 | O(n) | O(h) | 额外存储 leftCount |
优点: 查询效率高,O(h) 优于 O(n)
缺点: 每次插入/删除需要更新所有祖先节点的 leftCount
方法四:平衡 BST(AVL/红黑树)
思路
使用自平衡的二叉搜索树(如 AVL 树、红黑树),插入/删除时自动保持平衡。这样查询第 k 小的复杂度稳定在 O(log n)。
复杂度对比
| 方法 | 插入/删除 | 查询第 k 小 | 适用场景 |
|---|---|---|---|
| 普通 BST | O(h),最坏 O(n) | O(n) 或 O(h) | 静态数据,一次查询 |
| 平衡 BST | O(log n) | O(log n) | 频繁修改+频繁查询 |
| 记录节点数 | O(h) | O(h) | 频繁查询,修改不多 |
四种方法对比总结
| 维度 | 方法一递归 | 方法二迭代 | 方法三节点数 | 方法四平衡树 |
|---|---|---|---|---|
| 代码复杂度 | 简单 | 中等 | 复杂 | 很复杂 |
| 时间(查询) | O(n) | O(n) | O(h) | O(log n) |
| 时间(修改) | O(h) | O(h) | O(h) | O(log n) |
| 空间 | O(h) | O(h) | O(n) 额外 | O(n) |
| 修改后维护 | 无需维护 | 无需维护 | 需更新祖先 | 自动维护 |
| 面试推荐 | 首选 | 备用展示 | 进阶加分 | 竞赛/系统设计 |
面试建议:
- 能写出方法一/二,理解中序遍历的核心原理
- 进阶问题能提到方法三/四的思路,展示系统性思考
面试追问 FAQ
| 问题 | 解答 |
|---|---|
| Q1:为什么中序遍历可以得到升序序列? | BST 定义:左子树所有节点值 < 根节点值 < 右子树所有节点值。中序遍历顺序是"左-根-右",正好按升序访问。 |
| Q2:递归改迭代怎么做? | 用栈模拟:1) 一直向左入栈;2) 出栈访问;3) 转向右子树。方法二就是迭代版本。 |
Q3:k <= 0 的剪枝条件是什么作用? |
当 k 减到 0 时,说明已找到答案。k <= 0 直接返回,终止后续递归,避免无效遍历。 |
| Q4:如何理解方法三的 leftCount? | 每个节点维护"左子树有多少个节点"。查询时:若 k <= leftCount,往左找;若 k == leftCount+1,返回当前节点;否则往右找,k 减去相应数量。 |
| Q5:频繁修改+频繁查询的最佳方案? | 使用平衡 BST(如红黑树),插入/删除 O(log n),查询 O(log n)。或者记录子树节点数的 BST,查询 O(h),但修改时要更新路径上所有节点。 |
| Q6:方法三和方法四如何选择? | 如果修改远少于查询,记录节点数更好(查询 O(h),无需旋转);如果修改和查询都很频繁,平衡 BST 更优(自动维护,稳定 O(log n))。 |
相关题目
| 题目 | 难度 | 关键点 |
|---|---|---|
| 230. 二叉搜索树中第K小的元素 | 中等 | 中序遍历,递归剪枝 |
| 94. 二叉树的中序遍历 | 中等 | 递归/迭代两种解法 |
| 173. 二叉搜索树迭代器 | 中等 | 中序遍历的惰性计算 |
| 99. 恢复二叉搜索树 | 困难 | 利用中序遍历性质 |
总结
| 要点 | 说明 |
|---|---|
| 核心原理 | BST 中序遍历 = 升序序列,第 k 小 = 中序遍历第 k 个节点 |
| 关键技巧 | k 作为计数器边遍历边递减,找到即停止 |
| 剪枝优化 | k <= 0 提前终止递归 |
| 复杂度 | 时间 O(n),空间 O(h)(递归栈深度) |
| 进阶优化 | 记录节点数 O(h) 查询,或平衡 BST O(log n) 查询+修改 |