☰
LeetCode 230:二叉搜索树第K小元素的JS解法与复杂度分析
2026/10/3 4:43:40 网站建设 项目流程

刷LeetCode刷到第15天,遇到了230这道“二叉搜索树中第K小的元素”。乍一看它只是个普通的二叉树题,真正动手做才发现,这一道题把二叉搜索树的顺序性、递归转迭代、栈模拟、甚至二分查找思想全部串到了一起。今天我用JavaScript把它拆开讲清楚:题目在问什么、有哪几条主流的解法路线、各自的复杂度怎么算,以及我提交过程中踩过的真实坑。这道题很适合刚开始刷二叉树的朋友,也特别适合准备算法面试、想让自己的解法讲解有层次的人。废话不多说,直接开始。

1. 题目到底在问什么:一个“定位”问题

1.1 二叉搜索树的核心性质

做这道题之前,得先把二叉搜索树(BST)的基本性质从记忆里捞出来。对于任意一个节点 root,它的左子树中所有节点的值都小于 root.val,右子树中所有节点的值都大于 root.val,而且这个性质对树里的每一个节点都成立。换句话说,整棵树天然带着“有序”的基因。

这里有个很容易被忽略的点:并不是节点本身从左到右排好序,而是如果你按照“左子树、当前节点、右子树”这个顺序去遍历,拿到的序列一定是严格递增的。这个顺序就是中序遍历。二叉搜索树的中序遍历结果是有序数组,这一点是整道题的钥匙。

1.2 “第K小”该怎么理解

题目给定一个 k,要求返回二叉搜索树中第 k 小的元素。注意这里的 k 是从 1 开始计数的,也就是说 k = 1 时返回的是整棵树的最小值。这个“从1开始”非常容易踩坑,代码里如果直接用数组下标,写成 res[k] 就错了,正确的是 res[k - 1]。

从另一个角度理解,第 k 小其实就是一个定位问题:在有序序列里,我们要找到第 k 个位置的元素。既然中序遍历能拿到有序序列,最简单的做法就是把中序遍历跑完,然后用索引取数。但作为刷题,只做到这一步是不够的,因为面试官大概率会追问:能不能不遍历完整棵树?能不能用迭代代替递归?所以下面几条路线都值得掌握。

2. 解法一:中序遍历,最直观的做法

2.1 递归版:先排序再取数

最先想到的方案一定是递归中序遍历收集所有节点值,然后返回数组中的第 k - 1 项。

const kthSmallest = (root, k) => { const res = []; const inorder = (node) => { if (!node) return; inorder(node.left); res.push(node.val); inorder(node.right); }; inorder(root); return res[k - 1]; };

这段代码没有任何技巧,但胜在稳定、不容易错。它把树完整遍历一遍,收集到长度为 n 的数组,最后取下标。时间 O(n),空间上除了递归栈高度 O(h) 外,还多了一个数组 O(n),所以总空间 O(n)。这里的 h 是树高,最坏情况下树退化成一条链,h = n,递归栈本身也会 O(n)。

我刚写这道题的时候,第一版就是这个。能过,但总感觉差点意思:明明只要第 k 个,前面 k 个之后的数据根本用不上,为什么还要傻乎乎地遍历完?于是有了下面的优化。

2.2 优化递归:数到 K 就停

既然只要数到第 k 个,那就可以在递归过程中维护一个计数器,每当访问一个节点就把计数器加一,计数器等于 k 时记录答案并立刻返回,后面不再继续递归。

const kthSmallest = (root, k) => { let count = 0; let ans; const inorder = (node) => { if (!node || ans !== undefined) return; inorder(node.left); count++; if (count === k) { ans = node.val; return; } inorder(node.right); }; inorder(root); return ans; };

这里用ans !== undefined作为“已经找到了”的开关。因为节点值在本题中都是数值,不会出现值为 undefined 的情况,所以这个判断是安全的。每次递归进入函数时先检查开关,如果已经找到就直接回退,避免多余的调用。

这个优化看起来只是加了一个判断,实际意义不小。当 k 很小的时候,比如 k = 1,只需要走到整棵树最左边的节点就结束了,不需要访问右子树和剩下的所有节点。区间上仍然是 O(n),但平均下来常数会小不少。面试的时候,能主动说出“提前终止”,是一个不扣分的亮点。

2.3 迭代版:把递归栈搬到显式栈

递归版虽然简单,但在 JavaScript 里有一个现实问题:递归深度过大会导致调用栈溢出。尤其当二叉搜索树退化成一个长链时,h 可能等于 n,递归版在 LeetCode 上有时会直接报栈溢出。迭代版用显式栈模拟递归,可控性更强,也更能展示对中序遍历过程的理解。

const kthSmallest = (root, k) => { const stack = []; let cur = root; while (cur !== null || stack.length > 0) { while (cur !== null) { stack.push(cur); cur = cur.left; } cur = stack.pop(); k--; if (k === 0) return cur.val; cur = cur.right; } return -1; };

迭代版的执行过程可以拆成三步:先一路向左走到最底,把路径上的节点全部压栈;然后弹出一个节点,计数器减一;接着把指针移到它的右子树,继续重复。这个流程正好对应递归中“处理左子树、访问节点、处理右子树”的顺序。

用一个生活化的类比:想象你进了一个只允许向右拐的迷宫,每到岔路口就先把当前路口记在一张便签上,然后一直往左走。走到了尽头,就按便签倒序往回退,每退到一个路口就数一个数,数到 k 就收工。退到一个路口后,再从这个路口的右手边出发继续同样的流程。这张便签就是栈。

迭代版的空间是 O(h),因为栈里最多同时存放一条从根到叶子的路径。时间同样是 O(n)。相比递归版,它不依赖系统递归栈,在极端情况下更稳。面试时如果时间允许,我会优先写迭代版。

3. 解法二:基于子树节点数的二分定位

3.1 有点像“跳着找”:子树计数思路

中序遍历能解决问题,但总觉得不够“聪明”。有没有可能不用完整中序遍历,而是像在有序数组里二分查找那样,在树上跳着找?

可以。二叉搜索树是分治结构的天然载体。对于当前节点 root,一共有三种情况:

  • 如果左子树的节点数 leftCount 大于等于 k,说明第 k 小的元素一定在左子树里,去左子树递归查找。
  • 如果 leftCount 正好等于 k - 1,说明当前节点就是要找的第 k 小元素。
  • 否则,第 k 小的元素在右子树里,并且它相当于右子树的第 k - leftCount - 1 小元素。

这就是把“数组二分”的思路移植到二叉树上。每一步都可以排除掉一整棵子树,看起来非常高效。

3.2 实现子树的节点计数

要实现这个思路,第一步得能快速知道一棵子树有多少个节点。最简单的方式就是写一个递归统计函数:

const countNodes = (node) => { if (!node) return 0; return 1 + countNodes(node.left) + countNodes(node.right); };

这个函数没什么难度,但要注意空节点返回 0,否则后面计算左子树节点数时会出错。

3.3 主体递归函数

有了 countNodes,主函数就可以这样写:

const kthSmallest = (root, k) => { const leftCount = countNodes(root.left); if (k <= leftCount) { return kthSmallest(root.left, k); } if (k === leftCount + 1) { return root.val; } return kthSmallest(root.right, k - leftCount - 1); };

核心逻辑只有三行,非常清晰。要注意的是 k 在向右子树递归时需要做减法,不能把原来的 k 原封不动传下去。比如根节点左子树有 5 个节点,当前 k 是 7,那么右子树的第 2 小才是整棵树的第 7 小,所以要传 k - 5 - 1 = 1?不对,这里是 7 - 5 - 1 = 1,正好正确。因为右子树里最小的一个就是整棵树的第 7 个,所以 k 要更新为 1。

3.4 这个解法真的比中序更快吗?聊聊复杂度

很多题解会把这种解法的时间复杂度写成 O(n),理由是中序遍历是 O(n),而这个解法每递归一层能排除一半节点。但这是不准确的,至少在最坏情况下并不是 O(n)。

问题的关键在于 countNodes 每次都要真正遍历左子树的所有节点。如果树是平衡的,每次递归进入一侧后,下一层统计的对象是上一层的半个子树,整体统计量大约是 n + n/2 + n/4 + ...,也就是 O(n)。但如果树退化成链状,并且 k 一直落在左子树方向,countNodes 会反复遍历同一批节点,最坏情况会到 O(n^2)。

所以这类“跳着找”的解法,真正的价值不是把复杂度降到 log 级别,而是锻炼一种思考方式:能不能把有序数组上的二分段应用到树上。它并不是 230 题的标准最优解,但却是很多后续题目(比如树上的范围查询)的思维基础。面试时如果能主动说出这个解的时间复杂度最坏是 O(n^2),面试官会认为你真的在思考,而不只是背答案。

4. 复杂度对比与进阶优化

4.1 各解法横向对比

为了便于对比,把几种解法的复杂度整理一下:

解法时间复杂度空间复杂度适合场景
中序递归收集数组O(n)O(n)快速写出来,代码最简洁
中序递归提前终止O(n)O(h)常规刷题,k 通常较小时友好
中序迭代显式栈O(n)O(h)面试优先,避免递归栈溢出
子树节点计数二分平均 O(n),最坏 O(n^2)O(h)思维训练,不适合作为主写法
Morris 中序遍历O(n)O(1)面试加分,追求常数空间
预处理子树大小后二分预处理 O(n),单次查询 O(h)O(n)多次查询同一个树的场景

这里要强调一下 h 的概念。二叉搜索树的高度在最坏情况下是 n,所以空间复杂度不能简单写成 O(log n)。只有明确说了“假设是平衡二叉搜索树”,才可以写 O(log n)。很多人在面试时习惯性写 O(log n),这是一个容易被追问的点。

4.2 Morris 遍历:把空间压到 O(1)

提到中序遍历,就绕不开 Morris 遍历。它利用树中空闲的指针,把前驱节点的右指针临时指向当前节点,实现“边走边还原”的效果。

const kthSmallest = (root, k) => { let cur = root; while (cur !== null) { if (cur.left === null) { k--; if (k === 0) return cur.val; cur = cur.right; } else { let pre = cur.left; while (pre.right !== null && pre.right !== cur) { pre = pre.right; } if (pre.right === null) { pre.right = cur; cur = cur.left; } else { pre.right = null; k--; if (k === 0) return cur.val; cur = cur.right; } } } return -1; };

代码看起来弯弯绕绕,核心逻辑其实只有两点:

  • 如果当前节点没有左子树,直接访问它,然后跳到右子树。
  • 如果当前节点有左子树,先找到左子树里最右边的前驱节点。如果前驱的右指针为空,把右指针指向当前节点,然后继续往左走;如果前驱的右指针已经指向当前节点,说明左子树已经访问完毕,把这条临时线索还原为 null,访问当前节点,然后跳向右子树。

这样做的好处是空间 O(1),因为只用了几个指针变量。坏处是改变了树的结构临时加线索,虽然最后会还原,但代码的可读性不高,也更容易写错。我一般不建议日常刷题时优先写 Morris,但如果你在面试中已经写完了常规解法,可以主动提一句“有一个 O(1) 空间的 Morris 版本”,能拉开和普通候选人的差距。

4.3 多次查询场景:预处理子树大小

如果同一个二叉搜索树要被查询很多次,每次都跑中序遍历显然不划算。这时候可以一次性预处理出每个节点的子树大小,存到一个 Map 里,后续每次查询都按照二分定位的方式往下走,单次查询时间就是树高 O(h)。

const buildSizeMap = (node, map) => { if (!node) return 0; const leftSize = buildSizeMap(node.left, map); const rightSize = buildSizeMap(node.right, map); const size = leftSize + rightSize + 1; map.set(node, size); return size; }; const kthSmallestWithSize = (root, k, sizeMap) => { let cur = root; while (cur !== null) { const leftSize = sizeMap.get(cur.left) || 0; if (k <= leftSize) { cur = cur.left; } else if (k === leftSize + 1) { return cur.val; } else { k = k - leftSize - 1; cur = cur.right; } } return -1; };

这里用sizeMap.get(cur.left) || 0有个小细节:当 cur.left 为 null 时,Map 里没有 null 这个 key,get 会返回 undefined,用|| 0兜底。如果你在 LeetCode 上刷过类似的题,应该遇到过这种“null 节点没有记录”的问题。

在实际工程里,如果树的插入和删除很频繁,Map 里的 size 还得动态更新,这就变成了维护一棵带 size 字段的树。面试官如果追问到这一步,其实已经在考察你对动态数据结构的设计能力了。

5. 实操中容易踩的坑和调试技巧

5.1 最容易错的“K从几开始”

这题的一个经典错误是把 k 当成数组下标。递归收集数组的解法里,返回res[k]而不是res[k - 1],当 k = 1 时,返回的是第二小的元素,直接逻辑错误。

我自己的习惯是写代码前先在注释里写清楚边界:

// k 是 1-based,不是 0-based

尤其是从其他语言转过来的人,比如从 Python 或 C++ 刷题时习惯了 0-based 下标,很容易在这个地方栽跟头。

5.2 递归提前返回的开关忘记写

不少人在写“提前终止”的递归版时,只在 count === k 时设置 ans,却没有在函数入口判断是否已经找到,结果答案虽然正确,但后面的递归还在跑。极端情况下,如果树非常大,性能差距会很明显。

正确写法必须在每一次递归一进来就检查开关:

if (!node || ans !== undefined) return;

这个提前返回对左子树递归和右子树递归都生效。如果再配一句“右子树不再需要遍历”,代码就更好懂了。

5.3 迭代版右子树丢失

迭代版里的关键一步是每次弹出栈顶后,要记得把 cur 指向cur.right。有些初学者会写:

cur = stack.pop(); k--; if (k === 0) return cur.val; // 忘了写 cur = cur.right,直接进入下一轮循环

这样会导致下一轮循环里 cur 仍然是刚才弹出的节点,整个循环会卡死或者无限扫描同一个节点。我的记忆方法是:在中序遍历迭代模板里,每一轮循环的结束动作一定是cur = cur.right,不管下一步要做什么,先把指针移动到右子树。

5.4 本地快速造一棵 BST 来调试

虽然 LeetCode 上已经给了树的结构,但本地需要自己构造输入。我一般写一个辅助插入函数:

function TreeNode(val) { this.val = val; this.left = null; this.right = null; } function insert(root, val) { if (root === null) return new TreeNode(val); if (val < root.val) root.left = insert(root.left, val); else root.right = insert(root.right, val); return root; } function buildBST(arr) { let root = null; for (const val of arr) root = insert(root, val); return root; }

比如要构建一棵包含 5、3、6、2、4、1 这六个节点的二叉搜索树,只需要:

const root = buildBST([5, 3, 6, 2, 4, 1]);

注意这个插入函数默认重复值插入右边,对于本题来说,树里没有重复值,所以没问题。构造好之后可以自己写一个中序遍历打印函数,先确认树结构和你脑子里想的一致,再去跑 kthSmallest,能省掉大量调试时间。

5.5 用边界 case 验证正确性

我每次写完解法,至少会测这几个 case:

  • 单节点树:root = [1],k = 1,应该返回 1。
  • k 等于节点总数:此时应该返回整棵树的最大值,在最右侧节点。
  • k = 1:应该返回最左侧节点的值。
  • 左子树为空但右子树很大的情况:验证迭代版的右子树迁移。
  • 树是一条左链:验证递归版是否会栈溢出,以及提前终止是否有效。

把这些 case 跑完,基本就能确定代码没有边界漏洞。LeetCode 提交里最常见的问题,恰恰就是这些边界条件没考虑清楚。

6. 从这道题延伸:同类题和面试话术

6.1 同类型题目串一串

230 题不是孤立存在的。二叉搜索树的顺序性可以延伸到很多题目:

  • 求第 K 大的元素:把中序遍历的顺序反过来(右、根、左),做法完全对称。
  • 验证二叉搜索树:利用中序遍历结果是否严格递增来判断。
  • 二叉搜索树迭代器:本质上就是要求实现一个能返回下一个最小值的迭代器,正好是迭代中序遍历的封装版。
  • 不同的二叉搜索树:关心的是结构数量,而不是节点值,需要动态规划。
  • 区间和查询:例如求 BST 中所有在 [L, R] 范围内的节点值之和,可以用中序剪枝。

刷题时如果能把同类型的题目放在一起做,会比单纯按题号刷效率高很多。我最近的习惯是:每做完一道题,就在笔记里记下“它和哪几道题共享同一种模型”。230 这道题的模型就是“中序遍历有序性”。

6.2 面试怎么把这道题答出层次

如果面试官抛出这道题,我不会直接扔代码,而是按这个顺序讲:

先说结论:因为二叉搜索树的中序遍历是递增的,所以第 k 小就是中序遍历第 k 个节点。

再说方案一:递归中序遍历,把节点收集到数组,取第 k - 1 个。这个方案时间复杂度 O(n),空间 O(n),胜在直观。

然后主动优化:可以用计数替代数组,遍历到第 k 个就提前终止,避免收集整个数组。

面试官如果点头,再补一句:如果要避免递归栈溢出,可以用显式栈写迭代版,时间和空间都是 O(h)。

最后可以提一个 O(1) 空间的 Morris 版本,说明自己知道最极限的优化。这样一环扣一环地把复杂度说清楚,而不是一口气背出三四个解法,效果会好很多。

前期接到这个问题时,我曾经尝试把子树计数二分当作“最优解”来讲,结果被面试官追问后才发现自己的复杂度分析站不住脚。现在我的策略很明确:讲中序遍历为主,Morris 作为加分项,子树计数作为思维拓展提一句即可。

6.3 Day15 的刷题小结与我的习惯

连续刷题第 15 天,最大的感受是:懂一道题和能讲清一道题是两回事。230 题光看题解可能十分钟就“会了”,但真正动手写一版、错了、再改,才能把细节刻进脑子里。今天我记录的几个点,比如 k 从 1 开始计数、迭代版容易丢右子树、Morris 线索的还原,都是真实踩过的坑。

如果你也正在按照 Day 计划刷题,我的建议是不要只追求 AC。每道题至少写两种解法,并在提交记录旁边写下时间和空间复杂度,这样坚持半个月,面试时你会发现自己的表达流畅度完全不一样。

最后分享一个小经验:像我上面那样,用辅助函数在本地构造二叉搜索树,然后写一个最小测试集,绝对值得。测试过的代码,比“感觉对了提交试试”的代码要稳得多。这道 230 题本身不难,但它把二叉搜索树最核心的性质、三种遍历方式的选择、以及复杂度分析都串在了一起,作为 Day 15 的题目,含金量其实比表面看起来高不少。

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

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

立即咨询