尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

二叉搜索树第K小元素:中序遍历、递归与迭代全解析

二叉搜索树第K小元素:中序遍历、递归与迭代全解析 如果你刷过一阵 leetcode_hot100大概率会在这道“二叉搜索树中第 K 小的元素”上卡出过一种错觉思路一看就会代码一写就飘。我第一次做的时候三分钟写出递归中序遍历版本提交直接通过结果跟朋友复盘时被问了三个问题——为什么用成员变量存 k为什么不能把 k 当普通参数传进去找到答案之后递归真的停下来了吗我居然一个都答不利索。力扣 230 这道题表面上是“在 BST 里找第 K 小”本质上考的是三件事中序遍历的有序性、递归状态如何在调用栈中传递、以及手写迭代遍历的边界控制。这三个点放在面试里比很多难题更能筛出基础是否扎实。这篇就把这道题的完整解法链、每个方案背后的原理、以及面试追问对应的答案一次讲透适合正在刷 Hot 100 的朋友也适合准备面试前想快速把二叉树遍历基础夯实的同学。1. 审题第 K 小到底在考什么先别急着写代码1.1 题面与 BST 性质一句话看懂题目描述很简洁给定一棵二叉搜索树找出其中第 k 小的元素。输入是根节点root和整数k保证1 k 节点总数返回对应节点的值。比如一棵树里有 4 个节点k 2那么中序遍历后第二个被访问到的节点值就是答案。要理解这道题得先抓住二叉搜索树最核心的性质左子树所有节点的值都小于根节点右子树所有节点的值都大于根节点且左右子树本身也都是二叉搜索树。这个定义直接决定了 BST 的中序遍历结果一定是严格递增的。举个例子一棵这样的 BST5 / \ 3 6 / \ \ 2 4 7中序遍历顺序是2, 3, 4, 5, 6, 7。如果 k 3那么答案就是 4。你根本不需要额外排序也不需要知道整棵树长什么样只要按中序遍历依次访问节点数到第 k 个就是答案。这就是这道题和“二叉树遍历”产生强绑定的根本原因。1.2 为什么 Hot 100 把它放在二叉搜索树第一题Hot 100 里二叉树相关的题目不少但很多 BST 题其实都在 230 的基础上做加法。比如“验证二叉搜索树”考的是中序遍历是否递增“二叉搜索树迭代器”考的是用栈模拟中序遍历“不同的二叉搜索树”考的是动态规划计数。230 把“BST 性质”和“中序遍历”两个最基础的知识点粘在一起难度适中代码量不大却能引出无数面试题变体所以它几乎是面试前必须彻底吃透的一题。这道题还有一个很微妙的地方中序遍历的递归实现代码短到离谱但它对“状态传递”的理解要求并不低。我第一次写就是用成员变量保存计数器虽然过了但并没有真正理解为什么。后来才发现这个看似“绕了一下”的写法恰恰是递归面试的核心考点之一。1.3 常见错误一上来就想“把树转数组再取下标”有些人的第一反应是中序遍历把所有节点的值存进一个ListInteger然后返回list.get(k-1)。这个方案当然能过但思路有缺陷。首先它引入了 O(n) 的额外空间其次它必须完整遍历整棵树哪怕 k 1 也要把全部节点走完最后面试官如果追问“如果不允许用额外 O(n) 空间呢”你会立刻陷入被动。正确的思路应该是中序遍历过程中直接计数数到第 k 个节点就马上返回。也就是说遍历和查找是同时进行的而不是先遍历完再查。接下来所有解法都会围绕这个“边遍历边计数”的思想展开。2. 递归中序遍历三行能跑但你能扛住面试官这三个追问吗2.1 标准实现与 k 的状态传递先给一个最常规的递归写法class Solution { private int answer 0; private int count 0; public int kthSmallest(TreeNode root, int k) { count k; inorder(root); return answer; } private void inorder(TreeNode node) { if (node null || count 0) { return; } inorder(node.left); if (count 0) { return; } count--; if (count 0) { answer node.val; return; } inorder(node.right); } }核心逻辑就是先递归左子树然后访问当前节点时把count减一减到 0 说明当前节点就是第 k 小记录下来并返回。注意两个细节。第一count是成员变量不是方法参数。这一点很关键后面会专门讲。第二每次递归进入前都要检查count 0。我见过不少人写成这样找到答案后没有及时返回而是继续递归右子树导致count继续递减变成负数虽然最后答案可能还是对的但逻辑上已经不严谨了。2.2 追问一Java 值传递为什么局部变量 k 不行这是面试官最常追问的一个点也是很多 Java 初学者栽跟头的地方。如果你这样写// 错误示范 private void dfs(TreeNode node, int k) { if (node null || k 0) return; dfs(node.left, k); k--; if (k 0) { answer node.val; return; } dfs(node.right, k); }你会发现answer永远是 0或者根本不会在期望的位置停下来。原因在于 Java 的参数传递机制基本类型参数是值传递k--只改变了当前方法栈帧里那个局部副本的值不会影响调用方传入的k更不会影响其他递归分支里的k。每次递归调用dfs(node.left, k)时传递的是一个拷贝。就算在某个深层递归里把 k 减到了 0回到上一层时上一层的 k 仍然是原来的值。那有人会问“我用Integer包装类型不就行了吗”抱歉Integer是不可变类你执行k--本质上是创建了一个新的Integer对象并重新赋值给局部引用对外层同样不生效。所以递归里要跨层级维护一个“共享计数器”通常有三种做法成员变量/全局变量保存到长度为 1 的数组int[] count new int[1]递归函数返回递减后的剩余步数成员变量最直观但要注意每次kthSmallest调用前重置。这也是我在代码里写count k的原因防止多个测试用例之间相互污染。2.3 追问二找到之后如何提前终止避免无意义遍历很多人觉得“找到答案后继续遍历也无所谓反正复杂度量级一样”。如果只是应付判题确实无所谓但在面试中这是一个体现代码素养的细节。中序遍历的天然顺序是“左 → 根 → 右”。假设 k 1也就是要找整棵树最小的节点。递归会一路向左走到最左下角访问到最小值后如果没有终止机制它会继续回溯再访问右子树甚至把整棵树走完。虽然答案已经拿到了但做了大量无效操作。所以在实现里我用count 0作为全局剪枝条件每次进入函数体先判断访问完当前节点后再次判断。这样当计数器归零的那一刻后续所有入栈的递归调用都会立刻返回不再继续探索。这个优化对 k 很小或很大的情况尤其明显最坏情况下能把运行时间从 O(n) 降到 O(k h)其中 h 是树的深度。2.4 追问三递归栈会不会爆这里要区分两个复杂度时间复杂度 O(n)空间复杂度 O(h)。h 是树的高度。在平衡二叉树中 h ≈ log n空间占用很小但 BST 并不保证平衡如果输入是一棵退化成链状的树h n递归深度就会变成 n。实际生产环境中一棵树有几千几万个节点并不罕见。递归深度上万时JVM 默认栈大小可能直接抛出StackOverflowError。所以如果面试官问“这个解法在极端情况下有什么问题”你要能答出“递归深度受树高限制退化时会爆栈”。这也是接下来迭代版存在的意义之一。3. 迭代中序遍历显式栈方案的完整推导与一稿过技巧3.1 从“自己维护系统栈”说起递归的本质是 JVM 帮你维护一个方法调用栈每进入一层递归就压一个栈帧返回时弹栈。迭代版要做的事情就是把这个过程搬到明面上用显式的栈数据结构模拟。中序遍历的迭代写法是二叉树遍历三个版本里最容易写错的因为它的循环条件不只是一句简单的“栈非空”还牵扯到指针cur的状态。我第一次手写时经常在while (!stack.isEmpty())循环里漏掉cur指针的更新导致死循环或者重复访问。一个稳妥的推导方式是分两个阶段理解左侧一路到底只要当前节点不为空就把它压栈然后cur cur.left。弹出并转向右侧从栈里弹出一个节点这个节点就是中序顺序中当前要访问的节点然后cur cur.right。整个 while 循环的继续条件是cur ! null || !stack.isEmpty()缺一不可“当前节点非空”表示还有新分支要探索“栈非空”表示还有祖先节点等待回溯。3.2 完整可跑的迭代代码import java.util.ArrayDeque; import java.util.Deque; class Solution { public int kthSmallest(TreeNode root, int k) { DequeTreeNode stack new ArrayDeque(); TreeNode cur root; while (cur ! null || !stack.isEmpty()) { 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 合法这里只是编译兜底 } }这个版本的写法在面试中非常讨喜因为它同时做到了三点不递归不会有栈溢出风险边遍历边计数提前终止手写栈展现了你对递归底层机制的理解。3.3 手写时最容易踩的三个坑第一个坑while (cur ! null || !stack.isEmpty())写成while (!stack.isEmpty())。当树只有一个根节点且你刚把 root 压栈再弹出后如果忘记处理右子树循环条件可能提前失效。实际上只要初始cur rootcur ! null这个条件在第一次迭代时是必要的。第二个坑弹出节点后忘了把指针移到右子树。没有cur cur.right这一步第二个 while 会反复压入同一批左子树节点造成死循环。这是迭代中序最常见的 bug。第三个坑用LinkedList或Stack实现栈时注意插入删除接口。Deque的push/pop是在队头操作add/remove是在队尾操作混用会导致访问顺序完全错误。建议统一用DequeTreeNode stack new ArrayDeque();并且只用push和pop。关于剪枝收益实测下来的情况是k 1 时迭代版只需访问最左路径上的 h 个节点k n 时需要访问全部节点。平均情况虽然没有量级上的变化但这个版本真正优势在于稳定、可控不会因为递归深度崩掉。4. 左子树计数法不完整遍历也能定位第 K 小以及它的增强树版本4.1 核心思想root 在整体序列中的位置由左子树大小决定递归和迭代两个中序遍历方案本质都是“老老实实按中序顺序走过去”。但 BST 有一个更强的性质可以让我们跳过一整棵子树因为左子树所有节点都小于根节点所以如果我知道左子树一共有多少个节点我就能直接算出根节点在整棵树的升序序列中排第几位。假设左子树节点数为leftSize如果k leftSize说明第 k 小的元素一定在左子树里直接在左子树里找第 k 小。如果k leftSize 1说明根节点恰好就是第 k 小直接返回根节点值。如果k leftSize 1说明第 k 小在右子树里等价于在右子树中找第k - leftSize - 1小。写成代码class Solution { public int kthSmallest(TreeNode root, int k) { int leftSize countNodes(root.left); if (k leftSize) { return kthSmallest(root.left, k); } else if (k leftSize 1) { return root.val; } else { return kthSmallest(root.right, k - leftSize - 1); } } private int countNodes(TreeNode node) { if (node null) { return 0; } return 1 countNodes(node.left) countNodes(node.right); } }这个解法的思路非常“计算机科学”它利用的是 BST 的序关系而不是遍历顺序。如果面试官要求“不遍历完整棵树就找到答案”这个方案就是标准答案之一。4.2 普通分治的复杂度真相很多人误以为这个方案是 O(log n)其实不对。因为每次递归到一层都要调用countNodes去数左子树的节点个数而countNodes本身会遍历它负责的那棵子树。在平衡树中每层递归统计的子树规模大约是上一层的一半总耗时 T(n) n/2 n/4 ... O(n)并没有比中序遍历更好。但在极端不平衡的树上会退化。比如一棵左斜树第一次统计根节点的左子树要访问 n-1 个节点如果 k 还在左子树里第二次又要统计根左子树的左子树访问 n-2 个节点累计会达到 O(n²)。所以这个版本在实际面试中适合作为“思路拓展”但不适合作为主打方案搬出来。4.3 增强 BST当“查询第 K 小”变成频繁操作如果这道题更进一步不是只查一次而是同一棵 BST 会被反复查询第 k 小甚至中间还有插入删除操作该怎么办这个问题一出来普通中序遍历和普通分治都不太行了因为每次查询都要 O(n)。正解是给每个节点维护一个额外字段size表示以该节点为根的子树的节点总数。这种树在算法里有个名字叫顺序统计树Order Statistic Tree。节点定义大概长这样class TreeNode { int val; int size; // 以当前节点为根的子树节点总数 TreeNode left; TreeNode right; TreeNode(int val) { this.val val; this.size 1; } }插入或删除时在递归回溯路径上更新每个节点的size 1 left.size right.size代价是 O(h)。这样之后找第 k 小就变成了从根开始逐层定位int kthSmallest(TreeNode root, int k) { int leftSize root.left null ? 0 : root.left.size; if (k leftSize) { return kthSmallest(root.left, k); } else if (k leftSize 1) { return root.val; } else { return kthSmallest(root.right, k - leftSize - 1); } }因为每次都能直接拿到左子树大小不需要再次遍历数数单次查询复杂度降到 O(h)。如果配合 AVL 树或红黑树保持平衡就能稳定做到 O(log n)。这类结构在业务里也能找到影子排行榜、区间排名统计、在线数据流的动态分位数求值。你要是有机会维护一个需求“每秒可能有几千个查询要求快速知道当前数据流里的第 95 百分位”普通排序根本扛不住顺序统计树就是真正的工程解。5. 复杂度、边界与面试现场这道题背后的通用解题模型5.1 三种解法横向对比到这一步三种主流方案都聊完了用一张表把它们放在一起对比解法时间复杂度空间复杂度频繁查询支持面试推荐度递归中序遍历O(n)O(h)退化时 O(n)不友好必须会且能解释状态传递迭代中序遍历O(n)可提前终止O(h)不友好手写首选体现基本功普通左子树分治平衡 O(n)退化 O(n²)O(h)不友好思路补充别当主线增强 BSTsize 字段O(log n) 平均O(n)非常友好进阶加分项面试的时候我建议的表达顺序是先讲递归版它最直观也最能体现你对中序遍历的理解然后主动说“递归有栈溢出风险如果树很深我会改用显式栈的迭代版”顺手写出来最后如果面试官追问“如果查询频率很高呢”再抛出带 size 的增强 BST。这样层层递进比单纯甩一个最优解出来要有说服力得多。5.2 边界条件与隐藏坑第一个隐藏坑是返回值哨兵问题。代码里的return -1只是为了让编译器通过题目保证 k 一定合法所以不会真正走到。但注意节点值可能本身就是负数所以千万不要在业务代码里用node.val -1这种判断来标记“没找到”。第二个坑是成员变量复用。力扣判题时同一个Solution实例可能会被用于多个测试用例如果count和answer不在kthSmallest方法入口重置第二个用例就会带着上一个用例的残留状态运行。我在代码里写count k就是防止这种情况。如果你用static变量风险更大强烈不建议。第三个坑是空树和 k 越界。题目允许节点数至少为 1但实际工程里还是要防御一下。如果root null应该直接返回 -1 或抛异常而不是让代码继续往下走。第四个坑是关于“第 K 小”和“第 K 大”的转换。很多衍生题会直接问第 K 大不需要重新想思路——要么把中序遍历顺序改成“右 → 根 → 左”第 k 次访问到的就是第 k 大要么先求出节点总数 n再找第n - k 1小。面试时能立刻说出这两种方案会显得你对 BST 序关系理解得很透。5.3 衍生题Morris 遍历和流式 Top K如果面试官继续加深难度要求把空间复杂度压到 O(1)那就要用 Morris 中序遍历了。它的核心思想是利用叶子节点空闲的左右指针做线索临时把中序后继挂到当前节点的 right 指向上遍历完再恢复从而不使用额外栈空间。这个写法在面试中算是加分项但实现细节多容易出现烂尾我建议只在明确要求 O(1) 空间时再去写。另一个方向的衍生题是“不建树直接在一堆数里找第 K 小”那就完全走另一套思路了比如用大小为 K 的大顶堆维护前 K 小元素遍历完整个数据流后堆顶就是答案。这个解法的复杂度是 O(n log K)空间 O(K)在 Hot 100 里也有对应题目。能把 230 和第 K 大堆解法联系起来说明你对“Top K 问题”的整体框架已经有概念了。最后再分享一个我自己的习惯刷这道题不要只满足于提交通过。把递归版、迭代版、分治版各写一遍再自己给自己当面试官追问一遍为什么 k 不能直接传参、为什么递归要检查 count 0、普通分治在链状树上复杂度是多少。能在不看答案的情况下把这些解释清楚比多刷十道简单题都管用。面试时遇到这类基础题靠的就是这层真正理解带来的底气。
返回列表