【LeetCode Hot100 刷题日记 (44/100)】230. 二叉搜索树中第 K 小的元素——中序遍历 / 子树计数 / 平衡 BST 优化🌳

44 阅读7分钟

📌 题目链接:230. 二叉搜索树中第 K 小的元素 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:树、深度优先搜索、二叉搜索树、二叉树

⏱️ 目标时间复杂度(基础解法):O(H + k),H 为树高

💾 空间复杂度(基础解法):O(H)


在 LeetCode Hot100 的中等难度题目中,「230. 二叉搜索树中第 K 小的元素」是一道极具代表性的 BST(Binary Search Tree)性质应用题。它不仅考察你对中序遍历的理解,更深入地引出了如何高效支持频繁查询与动态修改的系统级设计思维——这正是大厂面试中高频出现的“进阶追问”模式。

本文将从基础解法 → 进阶优化 → 工程级实现三个层次,全面拆解本题的核心思想,并附上完整可运行的 C++ 与 JavaScript 代码,助你在面试中从容应对各种变体!


🧠 题目分析

给定一棵二叉搜索树(BST)和一个整数 k,要求返回其中第 k 小的节点值(k 从 1 开始计数)。

关键点在于:

  • BST 的定义:左子树所有节点 < 根节点 < 右子树所有节点;
  • 中序遍历(In-order Traversal)的结果是严格递增序列
  • 因此,第 k 小的元素 = 中序遍历的第 k 个元素

但问题不止于此!题干末尾抛出一个经典进阶问题

❓ 如果 BST 经常被修改(插入/删除),且需要频繁查询第 k 小的值,如何优化?

这直接引向了数据结构设计层面的思考——是否可以预处理?是否可以用更高级的 BST(如 AVL、红黑树)?是否能支持 O(log n) 查询?


🔑 核心算法及代码讲解

✅ 方法一:中序遍历(迭代版)——最常用、最简洁

这是面试中最推荐的写法:空间可控、提前终止、逻辑清晰

📌 核心思想

利用 BST 中序遍历的单调递增性,用栈模拟递归过程,每访问一个节点就计数一次,当计数达到 k 时立即返回。

💡 为什么用迭代而不是递归?

  • 递归无法在找到答案后提前终止(除非用全局变量或异常,不优雅);
  • 迭代可自然 break,效率更高;
  • 面试官更看重你对“控制流”的掌控能力。

🧾 代码详解(C++)

class Solution {
public:
    int kthSmallest(TreeNode* root, int k) {
        stack<TreeNode*> stk;
        // 当前节点或栈非空时继续遍历
        while (root != nullptr || !stk.empty()) {
            // 一路向左到底,压入所有左子节点
            while (root != nullptr) {
                stk.push(root);
                root = root->left;
            }
            // 弹出栈顶(当前最小未访问节点)
            root = stk.top();
            stk.pop();
            // 访问该节点,k 减 1
            --k;
            if (k == 0) { // 找到第 k 小
                break;
            }
            // 转向右子树
            root = root->right;
        }
        return root->val; // 返回结果
    }
};

关键细节

  • --k 后判断 k == 0,因为 k 从 1 开始;
  • breakroot 指向目标节点,直接返回其值;
  • 不需要额外数组存储整个中序序列,节省空间。

✅ 方法二:预处理子树节点数 —— 支持 O(H) 查询

适用于多次查询、树结构不变的场景。

📌 核心思想

为每个节点记录其子树的总节点数(包括自己)。这样在查找第 k 小时,可通过比较左子树大小决定往哪边走:

  • leftSize == k - 1 → 当前节点就是答案;
  • leftSize < k - 1 → 答案在右子树,且是右子树中第 (k - leftSize - 1) 小;
  • leftSize > k - 1 → 答案在左子树。

⚠️ 注意:这种方法不适用于频繁修改的树,因为每次插入/删除都要更新 O(H) 个祖先的 size,而重建哈希表成本高。

🧾 辅助类设计(MyBst)

class MyBst {
public:
    MyBst(TreeNode* root) {
        this->root = root;
        countNodeNum(root); // 预处理所有子树大小
    }

    int kthSmallest(int k) {
        TreeNode* node = root;
        while (node != nullptr) {
            int left = getNodeNum(node->left); // 获取左子树节点数
            if (left < k - 1) {
                // 第 k 小在右子树
                node = node->right;
                k -= left + 1; // 跳过左子树 + 根
            } else if (left == k - 1) {
                break; // 当前节点即为答案
            } else {
                // 第 k 小在左子树
                node = node->left;
            }
        }
        return node->val;
    }

private:
    TreeNode* root;
    unordered_map<TreeNode*, int> nodeNum; // 节点 -> 子树大小

    int countNodeNum(TreeNode* node) {
        if (!node) return 0;
        // 后序遍历:先算左右子树,再算当前
        nodeNum[node] = 1 + countNodeNum(node->left) + countNodeNum(node->right);
        return nodeNum[node];
    }

    int getNodeNum(TreeNode* node) {
        if (!node) return 0;
        return nodeNum.count(node) ? nodeNum[node] : 0;
    }
};

优势:单次查询仅需 O(H),无需遍历前 k 个节点。
劣势:预处理 O(N),且不支持动态更新(除非配合懒更新或持久化结构)。


✅ 方法三:平衡 BST(AVL 树)——工程级解决方案

针对频繁插入/删除 + 频繁查询第 k 小的场景。

📌 核心思想

构建一棵带 size 和 height 字段的 AVL 树,每个节点维护:

  • size:以该节点为根的子树的总节点数;
  • height:用于判断是否失衡。

通过旋转操作维持树的平衡,保证所有操作(插入、删除、kthSmallest)均为 O(log N)

🌟 这是 Google/Facebook 等公司系统设计题的常见模型,比如实现一个支持 rank 查询的有序集合。

虽然 LeetCode 不要求手写 AVL,但理解其思想对面试极有帮助!


🧩 解题思路(分步拆解)

步骤 1:识别 BST 性质

  • 明确 BST 的中序遍历 = 升序序列;
  • 第 k 小 ⇨ 中序第 k 个元素。

步骤 2:选择遍历方式

  • 递归中序:简单但无法提前退出;
  • 迭代中序:可控、高效、面试首选。

步骤 3:考虑扩展场景

  • 多次查询?→ 预处理子树 size;
  • 动态修改?→ 使用平衡 BST(如 AVL、Treap、Splay);
  • 极端情况?→ k=1(最小值)、k=n(最大值)。

步骤 4:边界处理

  • k 保证合法(1 ≤ k ≤ n),无需额外 check;
  • 树非空(n ≥ 1)。

📊 算法分析

方法时间复杂度(单次查询)空间复杂度是否支持动态修改适用场景
中序遍历(迭代)O(H + k)O(H)一次性查询、简单场景
子树计数(预处理)O(H)O(N)多次查询、静态树
平衡 BST(AVL)O(log N)O(N)高频查询 + 高频修改

📌 面试建议

  • 先写方法一(保底);
  • 被问“如果多次查询怎么办?” → 提出方法二;
  • 被问“如果还要支持插入删除呢?” → 提出方法三,简述 AVL 或引用 std::set + order statistic tree(C++ 扩展)。

💻 完整代码

✅ C++(保留原模板风格)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 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:
    int kthSmallest(TreeNode* root, int k) {
        stack<TreeNode*> stk;
        while (root != nullptr || !stk.empty()) {
            while (root != nullptr) {
                stk.push(root);
                root = root->left;
            }
            root = stk.top();
            stk.pop();
            --k;
            if (k == 0) {
                break;
            }
            root = root->right;
        }
        return root->val;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 示例 1: [3,1,4,null,2], k=1 → 1
    TreeNode* root1 = new TreeNode(3);
    root1->left = new TreeNode(1);
    root1->right = new TreeNode(4);
    root1->left->right = new TreeNode(2);
    Solution sol;
    cout << sol.kthSmallest(root1, 1) << "\n"; // 输出: 1

    // 示例 2: [5,3,6,2,4,null,null,1], k=3 → 3
    TreeNode* root2 = new TreeNode(5);
    root2->left = new TreeNode(3);
    root2->right = new TreeNode(6);
    root2->left->left = new TreeNode(2);
    root2->left->right = new TreeNode(4);
    root2->left->left->left = new TreeNode(1);
    cout << sol.kthSmallest(root2, 3) << "\n"; // 输出: 3

    return 0;
}

✅ JavaScript

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root
 * @param {number} k
 * @return {number}
 */
var kthSmallest = function(root, k) {
    const stack = [];
    while (root !== null || stack.length > 0) {
        while (root !== null) {
            stack.push(root);
            root = root.left;
        }
        root = stack.pop();
        k--;
        if (k === 0) {
            break;
        }
        root = root.right;
    }
    return root.val;
};

// 测试用例(需自行构造树)
// 由于 JS 无内置 TreeNode,通常在 LeetCode 环境中直接运行即可

🌟 结语

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!