【力扣100题】34.二叉搜索树中第K小的元素

题目描述

给定一个二叉搜索树(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) 查询+修改

相关推荐
_深海凉_1 小时前
LeetCode热题100-翻转二叉树
算法·leetcode·职场和发展
许长安1 小时前
gRPC Keepalive 机制
c++·经验分享·笔记·rpc
吃好睡好便好2 小时前
在Matlab中绘制抛物三维曲面图
开发语言·人工智能·学习·算法·matlab·信息可视化
伯远医学2 小时前
Nat. Methods | 邻近标记技术:活细胞中捕捉分子互作的新利器
java·开发语言·前端·javascript·人工智能·算法·eclipse
wangjialelele2 小时前
Linux SystemV 消息队列 + 责任链模式:实现客户端消息处理流水线
linux·服务器·c语言·网络·c++·责任链模式
刘永鑫Adam2 小时前
Nature Microbiology | 基于TRACS算法的跨多界宏基因组数据菌株水平溯源推演
算法
小O的算法实验室2 小时前
2026年SEVC,面向无人机辅助边缘计算的自适应群体智能算法,深度解析+性能实测
算法·边缘计算·智能算法·智能算法改进
高锰酸钾_2 小时前
计算机网络-网络层-路由算法与路由协议
计算机网络·算法·智能路由器
智者知已应修善业2 小时前
51单片机4按键控制共阳LED霓虹灯切换1整体闪烁2流水下3流水上4间隔闪烁】2023-10-27
c++·经验分享·笔记·算法·51单片机