☰
二叉搜索树第K小元素:中序遍历与迭代栈实战解析
2026/10/2 17:44:57 网站建设 项目流程

1. 题目解读与核心思路

Leetcode 230这道题,基本上每个刷二叉树专题的人都会遇到。题目本身很简洁:给定一棵二叉搜索树(BST),返回其中第 K 小的元素。Day 15 这个进度,一般是已经把基础遍历、二叉树递归、迭代栈都过了一遍,这道题正好把几个知识点串在一起。

先说结论:BST 的中序遍历天然就是升序序列,所以“第 K 小”这个需求,本质上就是“在中序遍历序列里取第 K 个节点”。不管你用递归、迭代还是其他花哨写法,核心都绕不开这个性质。这个知识点值得你嚼透,因为后续很多 BST 题目都是它的变体,比如求第 K 大、求中位数、验证 BST 合法性,都会用到这个有序性。

在动手写代码之前,我还想强调一下审题的几个细节:

  • 题目中的 K 从 1 开始计数,不是 0。很多人在递归里把计数器初始化为 0,最后发现答案总差一位,就是这个原因。
  • 题目假设输入的树一定满足 BST 性质,并且 K 合法(1 ≤ K ≤ 节点总数),所以可以不处理空值、K 越界这些异常情况,但这不代表你在工程代码里就可以放松校验。
  • 返回值是节点的值,不是节点本身,所以直接返回root.val就行。

这道题涉及的 JS 知识点包括递归、闭包修改外部变量、迭代栈模拟递归、提前终止遍历等,很适合用来检验自己对“遍历过程可控性”的理解。

2. 解法一:递归中序遍历——最直观的暴力版

2.1 完整递归实现

递归应该是大多数人接触的第一种写法。思路朴素:先走左子树,再处理根节点,最后走右子树,同时维护一个计数器,数到第 K 个节点时把值记下来。

var kthSmallest = function(root, k) { let count = 0; let result = null; function inorder(node) { if (!node || result !== null) return; // 先遍历左子树 inorder(node.left); // 访问根节点 count++; if (count === k) { result = node.val; return; } // 再遍历右子树 inorder(node.right); } inorder(root); return result; };

这段代码的核心就是inorder这个递归函数。result !== null这个判断是提前终止标识,一旦找到答案就不再往下递归。这里有一个细节值得说:递归外层的count和result都是闭包变量,如果在函数内部直接count = 0重新声明,就会变成一个局部变量,每次递归都从 0 开始数,永远找不到第 K 个节点。

2.2 为什么中序遍历能保证升序

我拿一个具体例子来说明。假设 BST 是[3, 1, 4, null, 2],结构如下:

3 / \ 1 4 \ 2

中序遍历的访问顺序是:左子树 → 根节点 → 右子树。先跑到最左下角的 1,再访问它的右子节点 2,然后回到根 3,最后访问 4。整个序列是[1, 2, 3, 4],正好就是升序。

这个性质的关键在于 BST 的左右子树定义:左子树所有节点值小于根节点,右子树所有节点值大于根节点。递归地在每一层都按这个顺序访问,最终得到的序列必然是全局升序。这也是为什么中序、BST、第 K 小这三个概念在这道题里强绑定在一起。

2.3 递归版的时间复杂度与空间复杂度

  • 时间复杂度:O(N),最坏情况要遍历整棵树。就算你提前终止了,平均也要遍历 K 个节点,但在很多递归实现中,由于终止条件不阻断外层递归的调用,实际执行次数还是接近全量遍历。
  • 空间复杂度:O(H),H 是树的高度。递归调用栈的最大深度等于树高。在极端情况下(比如退化成链表的 BST),H 可以等于 N,递归深度过大会触发调用栈溢出。

如果你刷题时用的是本地 Node.js 环境,且树形结构特别深,第一个解法有可能会直接报Maximum call stack size exceeded。这时候你就需要用到下面要讲的迭代解法了。

3. 解法二:迭代中序遍历——面试官更欣赏的写法

3.1 用栈手动模拟递归过程

迭代中序遍历的经典写法是:维护一个栈,先把左子树一路压栈,再从栈中弹出节点并访问,然后把指针切到右子树。这其实就是递归的“人工翻译”,但好处是遍历过程完全可控,可以随时停下来。

var kthSmallest = function(root, k) { let stack = []; let current = root; while (current || stack.length > 0) { // 一路向左,把左子节点全部压入栈 while (current) { stack.push(current); current = current.left; } // 弹出栈顶节点,访问它 current = stack.pop(); k--; // 找到第 K 小的节点 if (k === 0) { return current.val; } // 切换到右子树,继续中序遍历 current = current.right; } return null; };

每一步的逻辑:

  1. 内层while (current)不断把左节点压栈,直到current为空。这代表已经走到当前子树的最左边。
  2. stack.pop()弹出最左侧的节点,这就是当前最小元素。
  3. k--计数,判断是否已经数到第 K 个。
  4. current = current.right把遍历指针切到右子树。因为中序的顺序是“左根右”,左子树和根节点都处理完了,接下来该处理右子树。

这个解法在 LeetCode 上跑的性能通常优于递归版,因为不需要为每个节点创建新的函数调用帧,而且可以在找到答案时立即退出循环,不需要继续处理剩余的栈内容。

3.2 迭代版的时间复杂度与空间复杂度

  • 时间复杂度:O(H + K),H 是树高。先从根一路走到最左下角(消耗 H 步),然后每弹出一个节点就计数一次,数到第 K 个时结束。
  • 空间复杂度:O(H),栈最多存储树高个节点。在平衡二叉树中,H 约为 logN,内存消耗比递归版的调用栈更可控。

3.3 对比递归版与迭代版

维度递归版迭代版
代码可读性高,逻辑贴近中序遍历定义中,需要理解栈的压入弹出时机
风险点递归深度过大可能爆栈需注意栈清空与指针移动的边界
提前终止能力受语言机制限制,终止不彻底循环内可直接 return,终止彻底
空间消耗调用栈 O(H)显式栈 O(H)
面试推荐度适合先口头说思路更适合写代码展示工程能力

我在实际面试中见过不少候选人能流畅写出递归版,但一到迭代版就犹豫。主要原因是对“什么时候压栈、什么时候弹栈”有点含糊。这里你可以用一个比方理解:递归中序遍历就像你手上有一串待办事项,你会先处理当前节点左侧所有积压事项,处理完了再回来做当前项,再做右侧事项;迭代栈只是帮你把待办事项暂存在一个纸条堆里,所以任何时候都可以停下来数数。

4. 解法三:进阶优化与变体思路

4.1 利用节点计数剪枝——适合多次查询的场景

题目里只查一次,但实际工作中“频繁查第 K 小”的场景并不少见。如果对同一棵树反复查询,每次都做 O(K) 的遍历就不太划算了。

改进做法是给每个节点增加一个count字段,表示以该节点为根的子树有多少个节点。然后在查找时比较当前节点左子树的节点数和 K 的大小关系:

  • 如果左子树节点数leftCount >= K,说明第 K 小元素在左子树中,继续在左子树里找。
  • 如果左子树节点数等于K - 1,当前节点就是答案。
  • 如果左子树节点数小于K - 1,说明答案在右子树,并且要查找的位置变为K - leftCount - 1。

这种类似“二分查找”的思路,可以把单次查询的时间降到 O(H)。代价是插入、删除节点时都要更新count,适合数据相对静态、查询频繁的场景。LeetCode 上有时候你在讨论区看到的“Follow-up”优化就是这条路线。

4.2 如果题目改成“第 K 大”

这是一道非常常见的追问变体。最朴素的思路是把中序遍历反过来,变成“右子树 → 根节点 → 左子树”,也就是逆中序,这样遍历序列就是一个降序序列,第 K 个元素就是第 K 大。代码改动很轻微,还是迭代栈那套结构,只是一开始不断压入右子节点,弹出后切换到左子树。

再进一步,如果你已经实现过“求 BST 中每个子树节点数量”的代码,可以直接用节点计数法求第 K 大:只需要把比较逻辑改成先看右子树的数量。这个变体在面试中遇到的概率很高,建议提前把两版代码都写一遍。

4.3 关于重复值的处理

题目默认 BST 中没有重复元素,但真实项目中几乎不可能没有。如果树中存在重复值,中序遍历依然给出升序排列的序列,所以“第 K 小”这个含义不变,只是在插入、删除时如何处理相同值会让树本身变得复杂——比如到底是把等值节点放左子树还是右子树?严格 BST 定义通常不允许等值节点,但工程实现里常见的是约定“等值节点放右子树”,这样二叉树依然保持有序性,查找时current.val === target即为命中。

就本题而言,不需要额外处理,但值得在思路整理阶段想清楚:如果面试官突然说“我这棵树里有重复值”,你的遍历逻辑需不需要改?答案是不需要,因为中序遍历的结果仍然是排序后的完整序列。

5. 实操心得与常见坑

5.1 坑一:计数器的闭包陷阱

这是我第一次用 JS 写这道题时踩过的坑。我在递归函数里写的是:

function inorder(node) { if (!node) return; inorder(node.left); let count = 0; // 错!这里重置了计数 count++; if (count === k) { /* ... */ } inorder(node.right); }

每次进入inorder都重新声明count,等于每次都在数第一个节点,最后返回的结果永远是整棵树上第一个被遍历到的值,也就是最小值。正确做法是把计数器定义在递归函数外部,或者用对象包装一下。如果你习惯用闭包,注意不要在内部函数里重新声明同名变量。我更推荐用{ count: 0 }这样的对象来传引用,这样在函数参数传递时不会因为基础类型的值拷贝问题翻车。

5.2 坑二:递归提前终止没有真正停下

很多人会这样写递归版:

var kthSmallest = function(root, k) { let count = 0; let result = null; function dfs(node) { if (!node) return; dfs(node.left); count++; if (count === k) { result = node.val; return; } dfs(node.right); } dfs(root); return result; };

这个写法能通过大部分用例,但注意:当count === k时,你只是在当前递归层级return了,外层的调用还会继续执行剩余的dfs(node.right)。虽然代码里用result !== null做了部分阻断,但如果树很大,这个递归依然会把很多无用分支走完。真正干净的终止方式是:

function dfs(node) { if (!node || result !== null) return; dfs(node.left); count++; if (count === k) { result = node.val; return; } dfs(node.right); }

提前在函数入口判断result !== null,这样找到答案后,所有上层后续递归都会立即被剪掉。这个细节在力扣上可能看不出性能差异,但放在大树上体感差距很明显。

5.3 坑三:对空节点与左子树的边界判断

在迭代解法中,内层循环while (current)会在叶子节点的左侧自然停止,不需要额外判断current.left是否存在。很多人写到这里会忍不住加一个while (current && current.left),这其实是错的。加了这个约束,当前节点的左子树为空时,内层循环不会把当前节点压栈,导致根节点永远不会被访问。

正确理解是:内层循环只负责“走到当前子树最左边”。它在第一次进入时把从根到最左叶子路径上的所有节点压栈;后续每弹出节点并切到右子树后,再让内层循环把右子树的最左路径压栈。这是迭代中序遍历的标准节奏,不要人为改动。

5.4 常见问题排查速查表

问题现象大概率原因解决思路
返回的结果是整棵树的最小值计数器在递归中被重置将计数器放到闭包外层,或用对象包装
结果总是第 K-1 个元素K 的初始值从 0 开始数确认 K 的初始值为 1
极端深链树导致栈溢出递归深度过大改用迭代栈解法
返回 null未走到第 K 个节点,或树本身为空检查 K 是否合法,检查递归终止条件
结果与预期偏差一位中序遍历访问时机写错,在递归前就计数确保计数发生在访问根节点时,而不是进入函数时

5.5 关于 JavaScript 现场编码的额外建议

力扣的 JavaScript 环境里var和let的行为差异不容易暴露,但在浏览器控制台里做单文件调试时,var声明的变量会挂到全局对象上,多个测试用例连续执行可能会互相污染。写题解时我一般统一用let或const,避免隐式全局变量。

另外,如果你在本地 Node.js 里测这个函数,需要自己构造树结构。一个很省事的小技巧是用一个insert辅助函数逐层插入节点构造 BST,或者直接写一个buildTree函数把数组转换成二叉树——但注意数组转 BST 这种操作并不总是能简单套用普通的层序建树函数,因为 BST 的数组表示有时会省略空节点导致结构变化,最好自己明确哪一个才是合法 BST 结构,再动手写测试用例。

6. 扩展:从这一题延伸到同类问题

二叉搜索树第 K 小的元素不是孤立知识点,它属于“BST 有序性应用”这个大专题。把这道题吃透之后,以下几类问题都容易顺下来:

  • Leetcode 230 变体:求第 K 大的元素,直接改成逆中序。
  • 验证 BST:如果一棵树中序遍历结果严格升序,它就是合法 BST。递归判断也可以,但中序验证实现更简洁。
  • BST 转累加树:可以利用逆中序累加节点值。
  • 寻找 BST 中两个节点的最近公共祖先:利用 BST 有序性判断方向,比普通二叉树更简单。
  • 求 BST 的中位数:类似于求第 (N+1)/2 小和第 N/2+1 小元素的组合。

如果你在刷题中遇到“有序数组转 BST”“BST 的插入和删除”“BST 求众数”等题目,它们内部逻辑里都会涉及类似的中序有序性判断。这道题能写熟练,等于把这一整条链路的底层逻辑打通了。

我个人在实际操作中的体会是:迭代中序遍历值得多默写几遍,不只在本题用得上,在二叉树的大多数中序相关题目里都通用。我第一次写迭代版时总记不住current = current.right这一行该放在哪里,后来找到一个记忆锚点:弹出节点 → 计数/访问 → 切换右子树。只要把这三件事按顺序连起来,后面就顺了。

如果你现在刚开始写这道题,我建议你按这个顺序训练自己:先默写递归解法,再把递归解法改写成迭代解法,再推演一遍逆中序求第 K 大的变体,最后看一眼节点计数优化。完成这四步,这一题才算真正消化掉,而不是仅仅“看懂了题解”。下次面试碰到 BST 第 K 小这类问题,你就不需要从头想,自然手到擒来。

最后再分享一个小技巧:编写中序遍历相关的代码时,始终假设“极端情况”——空树、只有右子树的链、只有一个节点的树、满二叉树。在纸上画出这几种形状,在人脑中模拟一遍本来程序的走向,就能发现很多边界判断上的盲点,比直接提交靠报错反推要高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询