ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

二叉搜索树第K小元素:中序遍历与迭代栈实战解析

二叉搜索树第K小元素:中序遍历与迭代栈实战解析 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 是树的高度。递归调用栈的最大深度等于树高。在极端情况下比如退化成链表的 BSTH 可以等于 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; };每一步的逻辑内层while (current)不断把左节点压栈直到current为空。这代表已经走到当前子树的最左边。stack.pop()弹出最左侧的节点这就是当前最小元素。k--计数判断是否已经数到第 K 个。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 的中位数类似于求第 (N1)/2 小和第 N/21 小元素的组合。如果你在刷题中遇到“有序数组转 BST”“BST 的插入和删除”“BST 求众数”等题目它们内部逻辑里都会涉及类似的中序有序性判断。这道题能写熟练等于把这一整条链路的底层逻辑打通了。我个人在实际操作中的体会是迭代中序遍历值得多默写几遍不只在本题用得上在二叉树的大多数中序相关题目里都通用。我第一次写迭代版时总记不住current current.right这一行该放在哪里后来找到一个记忆锚点弹出节点 → 计数/访问 → 切换右子树。只要把这三件事按顺序连起来后面就顺了。如果你现在刚开始写这道题我建议你按这个顺序训练自己先默写递归解法再把递归解法改写成迭代解法再推演一遍逆中序求第 K 大的变体最后看一眼节点计数优化。完成这四步这一题才算真正消化掉而不是仅仅“看懂了题解”。下次面试碰到 BST 第 K 小这类问题你就不需要从头想自然手到擒来。最后再分享一个小技巧编写中序遍历相关的代码时始终假设“极端情况”——空树、只有右子树的链、只有一个节点的树、满二叉树。在纸上画出这几种形状在人脑中模拟一遍本来程序的走向就能发现很多边界判断上的盲点比直接提交靠报错反推要高效得多。
返回列表