刷 LeetCode 的时候,我几乎每刷完一道二叉树的题就会回头看看 230 这道“二叉搜索树中第 K 小的元素”。说实话,它名气不小——二叉搜索树(BST)相关的题目里,它是那种面试官特别爱考的“基础中的基础”,同时也是很多进阶题目的隐藏前置。题目本身很短:给定一棵二叉搜索树的根节点和一个整数 k,返回第 k 小的元素。但就这一句话,能延伸出递归、迭代、剪枝、复杂度优化、甚至改造树结构等一系列讨论。这篇文章我把这道题彻底讲透,包括三种主流解法、工程上的选型建议、面试时的提问点,以及我实际调试中踩过的几个坑。
先记住一个核心结论:二叉搜索树的中序遍历结果就是有序序列,所以“第 K 小”本质上是“中序遍历走到第 K 个访问的节点”。这个联系一旦建立,题目就从“找元素”变成了“控制遍历节奏”。下面我会从最直观的解法一路讲到适合频繁查询场景的进阶改造,每一步都会说清楚为什么这么写、什么时候用哪种。
1. 题目解读与核心思路拆解
1.1 先看懂二叉搜索树的“有序”密码
二叉搜索树的定义很多人背得滚瓜烂熟:左子树所有节点的值小于根节点,右子树所有节点的值大于根节点,且左右子树本身也都是二叉搜索树。但真正能把这条性质转化成解题优势的人,不多。
关键在于“中序遍历”这种访问顺序。中序遍历是左子树 → 根节点 → 右子树。你仔细想一下:在 BST 中,左子树全部比根小,右子树全部比根大,那么中序遍历先访问完所有左子树,再访问根,最后访问右子树,得到的序列自然是严格递增的。这不是巧合,而是 BST 结构定义的必然结果。所以题目问“第 K 小”,几乎等于在问“中序遍历序列里的第 K 个元素是谁”。
理解这一点是整道题的钥匙。很多人一上来就想着怎么快速比较大小、怎么维护一个堆,反而把最简单的路走歪了。
1.2 中序遍历与“第 K 小”的直觉关系
用生活化的例子来说:想象你有一摞按身高排好队的小朋友,你想找第 3 矮的那个,最快的方式不是每次都比来比去,而是让他们按从矮到高的顺序报数,报到 3 的人就是答案。BST 的中序遍历就是这个“自动排好队”的过程,你只需要在遍历时计数到 k 就行。
所以这道题至少有三个层次的解法:
| 解法 | 核心思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 数组法 | 完整中序遍历,把结果存进数组 | O(n) | O(n) | 只查一次,代码最简单 |
| 计数提前终止 | 遍历时计数,到达第 k 个节点直接返回 | O(H + k) | O(H) | 面试推荐写法,兼顾简单与效率 |
| 子树计数法 | 每个节点维护子树大小,比较 k 与左子树大小 | O(H) | O(H) | 频繁查询第 k 小元素的场景 |
接下来我逐个展开,代码直接给可运行的版本。
2. 解法一:数组法——最直观,但空间开销大
2.1 递归中序收集的实现
这个思路最简单:中序遍历整棵树,把所有节点值按访问顺序放进一个数组,最后返回数组索引 k - 1 的元素。因为中序遍历结果天然有序,第 k 小就是下标 k - 1。
C++ 实现:
class Solution { public: int kthSmallest(TreeNode* root, int k) { vector<int> nums; inorder(root, nums); return nums[k - 1]; } void inorder(TreeNode* node, vector<int>& nums) { if (!node) return; inorder(node->left, nums); nums.push_back(node->val); inorder(node->right, nums); } };Python 写法更简洁:
class Solution: def kthSmallest(self, root: TreeNode, k: int) -> int: nums = [] def inorder(node): if not node: return inorder(node.left) nums.append(node.val) inorder(node.right) inorder(root) return nums[k - 1]这段代码面试时写出来没问题,但如果你紧接着说出它的复杂度,面试官大概率会追问:“能不能不用数组,直接在遍历过程中返回?”
2.2 为什么数组法不是最优解
你可能觉得 O(n) 时间、O(n) 空间对一棵树的遍历来说无所谓。但你要是想一下大数据量或频繁调用场景,问题就来了:遍历整棵树完全是一种浪费。
假设这棵 BST 有 100 万个节点,而 k 是 5。按照中序遍历的顺序,第 5 个节点很快就会出现,但数组法仍然会坚持走完整个左子树、根、右子树,把所有节点都收集完才返回。时间复杂度永远固定在 O(n),不随 k 的变化而改变。空间上,需要额外存储 n 个值的数组,对内存也不友好。
不过,数组法的优势在于它把问题“降维”了——拿到有序数组后,你不仅可以查第 k 小,还可以查第 k 大、找中位数、做区间统计。所以它适合“一次遍历,多次查询”的场景。因为 LeetCode 上这道题只能查一次,所以它有优化的空间。
3. 解法二:计数提前终止——面试首选实现
3.1 递归写法与剪枝细节
优化思路很自然:既然中序遍历是有序的,那就在遍历的过程中记录“已经访问了多少个节点”,当计数等于 k 时,立刻记录答案并返回,后续的节点不用再看。
class Solution { public: int kthSmallest(TreeNode* root, int k) { int count = 0; int result = 0; bool found = false; inorder(root, k, count, result, found); return result; } void inorder(TreeNode* node, int k, int& count, int& result, bool& found) { if (!node || found) return; // 终止条件 inorder(node->left, k, count, result, found); if (found) return; count++; if (count == k) { result = node->val; found = true; return; } inorder(node->right, k, count, result, found); } };这里有两个细节值得注意。
第一,为什么用found这个布尔变量?很多初版代码会用result != 0来判断是否已经找到,这在节点值不可能为 0 时似乎可行,但一旦节点值可以是任意整数(包括 0、负数),这种判断就不可靠。用布尔变量是一种“防御性编程”习惯,宁可代码多两行,也不要留逻辑隐患。
第二,递归左子树返回后需要再检查一次found。因为左子树里可能已经找到了目标节点,如果此时不检查,函数会继续执行count++和右子树的递归,造成重复计算。第一次写这道题的人很容易漏掉这个if (found) return;,结果答案虽然可能碰巧正确,但实际上白白遍历了大量节点。
这个解法的均摊时间大约是 O(H + k),其中 H 是树高。为什么会多一个 H?因为你至少要走到中序遍历的第一个节点,这个节点位于最左下的位置,需要 O(H) 的时间到达。然后每访问一个节点是 O(1),直到访问完 k 个。最坏情况下如果树退化成链表,H 会变成 n,但平均情况下 BST 的 H 是 O(log n),所以整体效率远好于数组法。
3.2 迭代写法:生产环境更推荐的版本
递归写法虽然清晰,但在工程落地时有个实际痛点:递归深度受限于系统栈。一棵极度不平衡的树(比如按递增顺序插入节点形成的右斜树)高度可能达到 10 万甚至更高,递归会触发栈溢出。LeetCode 上树节点的数量上限是 10^4,大多数语言还能扛住,但如果你在做实际项目,还是推荐用迭代方式模拟中序遍历。
迭代写法用显式的栈来模拟递归过程,核心逻辑是“一直往左走,走到底再回头访问根,然后转向右子树”:
class Solution { public: int kthSmallest(TreeNode* root, int k) { stack<TreeNode*> stk; TreeNode* cur = root; while (cur || !stk.empty()) { while (cur) { stk.push(cur); cur = cur->left; } cur = stk.top(); stk.pop(); k--; if (k == 0) return cur->val; cur = cur->right; } return -1; // 实际题目保证不会走到这里 } };为什么这样写是对的?while (cur)循环把当前节点的所有左侧祖先压栈,相当于“先处理左子树”;出栈一个节点时,它的左子树已经被完整处理过了,此时访问它,相当于“根节点”;然后把 cur 移到右子树,继续用同样的逻辑处理右子树。整个顺序严格满足左 → 根 → 右。
这道题用迭代解法,在 LeetCode 上的运行时间通常和递归差不多,但胜在不会爆栈。如果你在面试中写了迭代版,建议顺带说一句“用栈模拟递归,目的是避免极端输入下递归栈溢出”,这会让面试官觉得你考虑过工程健壮性。
3.3 递归 vs 迭代:面试时怎么选
我个人的建议是:优先写递归,因为它代码短、逻辑直观,面试时沟通成本低,15 分钟内写出正确的可能性更大。写完之后主动补充一句“如果树高度可能很大,我会用迭代栈来避免递归溢出,核心逻辑完全一样”。然后等面试官接话,如果他要你写在白板/编辑器上,你再写迭代版。
但要注意:如果你已经意识到这是一棵退化成链表的树(比如题目说了节点按递增顺序插入),那就别头铁用递归了,直接上迭代版本。判断依据很简单——递归解法的时间复杂度会降到 O(n^2)?不对,准确说是 O(n + k),看起来还能接受,但空间复杂度会变成 O(n)(递归栈深度等于 n),而且有栈溢出风险,这是致命的。
4. 解法三:子树计数法——把查询压到 O(H)
4.1 核心思路:用“左子树节点个数”来定位
前面两种解法的本质都是“从头开始按顺序数”。但 BST 有一个更强大的信息可以利用:每个节点的左子树里包含多少个节点。如果我能知道根节点左子树的大小,就能立刻判断第 k 小的元素是否在左子树中。
具体规则分三种情况,设当前节点为 node,左子树大小为 leftSize:
- 如果 k <= leftSize:说明第 k 小的节点在左子树里,直接往左走。
- 如果 k == leftSize + 1:说明当前节点 node 正好是第 k 小的节点,直接返回 node.val。
- 如果 k > leftSize + 1:说明第 k 小的节点在右子树里,同时因为已经跳过了左子树(leftSize 个节点)和当前根节点(1 个节点),新目标在右子树里的次序是 k - leftSize - 1。
这个查找过程每层只做一次比较,所以时间复杂度就是树高 O(H)。在平衡 BST 中,单次查询 O(log n) 就完成了,比中序遍历快得多。
这种思路需要每个节点额外维护一个 size 字段(以该节点为根的子树节点总数)。常见的做法是在树节点结构体里加一个int size,然后在插入节点或构建树时维护。
C++ 节点定义示例:
struct TreeNodeWithSize { int val; int size; // 以当前节点为根的子树节点总数 TreeNodeWithSize* left; TreeNodeWithSize* right; TreeNodeWithSize(int v) : val(v), size(1), left(nullptr), right(nullptr) {} };查询函数如下:
int kthSmallest(TreeNodeWithSize* root, int k) { TreeNodeWithSize* cur = root; while (cur) { int leftSize = (cur->left ? cur->left->size : 0); if (k <= leftSize) { cur = cur->left; } else if (k == leftSize + 1) { return cur->val; } else { k -= (leftSize + 1); cur = cur->right; } } return -1; }注意leftSize的取值要先判断左子树是否为空,避免空指针访问。这个写法在递归和迭代中都成立,本质上是在树上做“二分查找”。
4.2 为什么这才是“频繁查询”场景的正解
LeetCode 230 的原题只问你查一次,所以用中序遍历完全够用,没必要改造树结构。但现实中的系统往往不是“查一次”就完事,比如一个排行榜接口需要反复查询“当前排名第 K 的用户”,或者一个存储引擎需要频繁做“取第 K 小键值”的操作。
在这种高频场景下,解法二的 O(H+K) 就会变成瓶颈。设想一个用户数百万的产品,K 如果接近总用户数,每次查询都要遍历近乎全树,接口容易直接超时。而子树计数法每次查询稳定在 O(log n),差别是指数级的。
当然,引入 size 字段也不是没有代价:每次插入或删除节点时,需要沿着路径更新所有祖先节点的 size,这会增加写操作的常数时间。这是一种典型的“读多写少选读优化,写多读少选遍历优化”的工程权衡。面试中如果能把这个权衡讲清楚,会很加分。
LeetCode 上有一道著名的“二叉搜索树迭代器”(173 题),本质上就是要求你实现一个next()返回中序遍历的下一个元素。如果你掌握了 230 的迭代写法,那道题就是在迭代模板里加了一个“记录当前状态”的步骤。反过来,如果你想实现一个支持“快速返回第 k 大/小元素”的平衡 BST,那子树计数法就是基础知识。这两道题非常适合连着刷。
5. 变种问题与面试延伸
5.1 求第 K 大元素的镜像思路
如果题目改成“返回二叉搜索树中第 K 大的元素”,你有三种改法:
第一种最简单:先遍历一遍求出节点总数 n,那么第 k 大等价于第 n - k + 1 小,直接用前文的解法即可。代价是需要额外一次 O(n) 的遍历求总数,但代码改动最小。
第二种更优雅:把中序遍历的顺序镜像过来——先访问右子树,再访问根,最后访问左子树。这种“反向中序遍历”得到的是递减序列,计数到 k 即可。
第三种就是子树计数法的镜像版本:比较右子树大小,而不是左子树。
int kthLargest(TreeNodeWithSize* root, int k) { TreeNodeWithSize* cur = root; while (cur) { int rightSize = (cur->right ? cur->right->size : 0); if (k <= rightSize) { cur = cur->right; } else if (k == rightSize + 1) { return cur->val; } else { k -= (rightSize + 1); cur = cur->left; } } return -1; }面试官很喜欢在“第 k 小”后面追加一句“那第 k 大呢”,就是为了看你是否真的理解了对称性,而不是背模板。
5.2 与“树中第 K 层节点”“统计节点个数”等题目的联动
二叉树的技能树是一张网。做完 230,紧接着值得做两道关联题:
- LeetCode 98 验证二叉搜索树:核心也是中序遍历,检查遍历序列是否严格递增。
- LeetCode 173 二叉搜索树迭代器:把中序遍历拆成
hasNext()和next(),就是 230 迭代解法的状态持久化版本。 - LeetCode 96 不同的二叉搜索树:让你用动态规划计算 n 个不同值能组成多少种不同结构的 BST。它和 230 共享同一个知识底盘——BST 的结构与有序序列的一一对应关系。热搜词里专门提到“不同的二叉搜索树”,说明很多人在刷二叉树系列时会自然地走到这一连串题目上。
- 如果你的技术栈偏 C 方向,还可能会遇到“最优二叉搜索树”这类更工程向的题目,它把查找频率纳入建树成本,本质上是对 BST 结构做动态规划优化。理解 BST 中序遍历有序,是理解那类 DP 的前提。
所以我一直建议:不要孤立地刷 230,应该以它为核心节点,把二叉搜索树系列串成一条线。
5.3 如果树节点值允许重复,会怎样?
标准的 BST 定义不允许重复值,但有些变种题会放宽这个限制(比如值等于根节点时放在左子树或右子树)。一旦出现重复,情况会复杂:中序遍历仍然有序,但“第 k 小”的“第”字就有了歧义——相同值算多个还是一个?
LeetCode 原题的约束是节点值互不相等,所以不用特殊处理。但如果你在工作中自己实现带重复键的 BST,发现“第 k 小”行为不对,多半是这一步没定义清楚。我个人建议:遇到重复键时,把插入规则明确写在文档里,并且把“第 k 小”定义为“第 k 个不同的值”或“第 k 个节点”二选一,否则各种统计结果都会对不上。
6. 常见坑点与调试实录
6.1 递归爆栈的偶发场景
某次我在本地跑一个由 10 万个递增节点构成的右斜树,用递归中序遍历时程序直接报错,具体表现是Segmentation fault。当时我还以为是数据问题,后来用栈把递归换成迭代,问题立刻消失。
所以给你一个硬性建议:凡是二叉树题目,最好先在脑海中估算树高上限。LeetCode 的测试数据一般不会卡递归栈,但真实业务数据完全可能。养成写迭代版的习惯,不是过度设计,而是保命技能。
6.2 k 的边界条件判断错误
这道题题目明确说了1 <= k <= 节点总数,所以理论上不需要做越界判断。但你如果写的是通用函数,最好还是加上防御逻辑:
if (root == nullptr || k <= 0) return -1;否则一旦外部传入 k=0,迭代法中k--后直接变成 -1,永远不等于 0,最终会循环到空栈然后退出,返回一个半路随机值。这种 bug 很难查,因为不是每次都崩溃,而是输出一个错误结果。我调试过类似的问题,最后是加日志打印每个出栈节点的值和当时的 k 才发现计数错位。
6.3 剪枝写法的隐蔽性能问题
递归解法里,如果不加if (found) return;这条剪枝,代码在功能上是正确的,但性能会退化。比如一棵极度不平衡的树,k 很小,但函数仍然会一路递归到最右下方的节点,白白浪费时间。
你可以做一个简单的实验:造一棵 10 万节点的完全二叉树,k=1,不加剪枝的递归版本耗时比加剪枝的慢好几倍。原因在于递归调用栈无法在找到目标后自动退出,所有跟目标无关的分支都被“惯性”地访问了一遍。
6.4 调试技巧:先打印中序遍历序列
不管用哪种解法,遇到输出不对时,我建议先打印整棵树的中序遍历序列,确认序列本身是否递增。如果序列不对,问题大概率不在查询逻辑,而在于树的构建或节点定义。这个技巧在处理自定义输入数据(比如从数组构建 BST)时尤其好用。
举个例子,有人从数组[3,1,4,2]构建 BST 时用了一个错误的插入函数,导致构建出来的树并不是严格递增的中序序列,于是查第 k 小的结果自然不对。打印序列能立刻看出问题。
# 用打印中序序列的方式快速验证 # 期望输出: 1 2 3 4 # 如果输出: 1 3 2 4,说明树结构有问题,先别急着改查询逻辑6.5 小结笔试写代码的一些操作心得
最后分享一个我的操作习惯:拿到“第 k 小元素”这类题,不急着写代码,先在草稿纸上画一棵三层小树,左边画中序遍历的步骤,右边写 k 的取值变化。这个手动画图的过程能帮我锁定“k 是在入栈时减,还是出栈时减”这种细节。
迭代解法有一个特别容易写错的地方:k--到底放在哪里。正确做法是“访问到节点时才 k--”,也就是出栈后立刻减,而不是入栈时减。很多初学者把k--放在while (cur)的入栈循环里,结果计数完全错乱。实际上你手动模拟一次就明白了——入栈只是准备路径,访问才算数。
另外,在真实面试时,我一般会先跟面试官确认一个信息:“树是平衡的吗?”这个信息直接决定了我是先写中序遍历还是先写子树计数法。如果面试官说“是平衡的”,我会优先写数组法或计数法,说清楚复杂度是 O(log n + k);如果说“不保证平衡”,我就直接写迭代版中序遍历,避免递归爆栈的风险;如果说“这个接口会被调用多次”,那可以考虑现场设计带 size 字段的树节点版本。这几种问法对应的答案完全不同,但只要你把核心逻辑吃透,都能从容应对。