目录
- [1. 问题描述](#1. 问题描述)
- [2. 问题分析](#2. 问题分析)
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- [3. 算法设计与实现](#3. 算法设计与实现)
-
- [3.1 递归中序遍历](#3.1 递归中序遍历)
- [3.2 迭代中序遍历](#3.2 迭代中序遍历)
- [3.3 Morris中序遍历](#3.3 Morris中序遍历)
- [3.4 增强BST(记录子树节点数)](#3.4 增强BST(记录子树节点数))
- [4. 性能对比](#4. 性能对比)
-
- [4.1 复杂度对比表](#4.1 复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 二叉搜索树中第K大的元素](#5.1 二叉搜索树中第K大的元素)
- [5.2 查找最接近目标的K个值](#5.2 查找最接近目标的K个值)
- [5.3 二叉搜索树中的众数](#5.3 二叉搜索树中的众数)
- [5.4 二叉搜索树的范围和](#5.4 二叉搜索树的范围和)
- [6. 总结](#6. 总结)
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 算法选择指南](#6.2 算法选择指南)
- [6.3 实际应用场景](#6.3 实际应用场景)
- [6.4 面试建议](#6.4 面试建议)
1. 问题描述
给定一个二叉搜索树的根节点 root,和一个整数 k,请你设计一个算法查找其中第 k 小的元素(k 从 1 开始计数)。
示例 1:

输入:root = [3,1,4,null,2], k = 1
输出:1
示例 2:

输入:root = [5,3,6,2,4,null,null,1], k = 3
输出:3
提示:
- 树中的节点数为
n 1 <= k <= n <= 10^40 <= Node.val <= 10^4
进阶: 如果二叉搜索树经常被修改(插入/删除操作)并且你需要频繁地查找第 k 小的值,你将如何优化算法?
2. 问题分析
2.1 题目理解
**二叉搜索树(BST)**具有重要的性质:中序遍历BST会得到一个升序序列。因此,BST中第k小的元素就是中序遍历序列中的第k个元素。
问题的核心挑战在于:
- 高效查找:需要在不遍历整个树的情况下找到第k小的元素
- 空间优化:尽量减少额外空间的使用
- 动态维护:考虑BST可能频繁变更的场景
2.2 核心洞察
- 中序遍历的有序性:BST的中序遍历结果天然有序,第k个元素即为所求
- 提前终止:可以在找到第k个元素后立即终止遍历,避免不必要的访问
- 空间换时间:通过记录子树节点数,可以将查询时间复杂度降到O(log n)
- 平衡的重要性:对于频繁修改的场景,保持BST平衡是关键
2.3 破题关键
- 遍历顺序控制:中序遍历的顺序是左-根-右,恰好是升序
- 计数机制:在遍历过程中计数,找到第k个时立即返回
- 迭代与递归的选择:根据具体场景选择适合的遍历方式
- 增强数据结构:对于频繁查询的场景,可以预先计算每个节点的子树大小
3. 算法设计与实现
3.1 递归中序遍历
核心思想:
利用BST中序遍历的升序特性,通过递归实现中序遍历,在遍历过程中计数,当计数达到k时返回当前节点的值。这种方法直观地体现了BST的性质,是最容易理解和实现的方法。
算法思路:
- 使用类变量或参数维护当前访问的节点计数
- 递归进行中序遍历:先遍历左子树,然后访问当前节点,最后遍历右子树
- 访问当前节点时,计数器加1
- 当计数器等于k时,记录结果并提前终止后续遍历
- 如果左子树或右子树遍历已找到结果,直接返回
Java代码实现:
java
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode() {}
TreeNode(int val) { this.val = val; }
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
class Solution {
private int count = 0; // 当前访问节点计数
private int result = 0; // 存储结果
public int kthSmallest(TreeNode root, int k) {
count = 0;
inorderTraversal(root, k);
return result;
}
private void inorderTraversal(TreeNode node, int k) {
if (node == null) {
return;
}
// 遍历左子树
inorderTraversal(node.left, k);
// 访问当前节点
count++;
if (count == k) {
result = node.val;
return; // 找到结果,提前返回
}
// 只有还没找到结果时才遍历右子树
if (count < k) {
inorderTraversal(node.right, k);
}
}
}
性能分析:
- 时间复杂度:O(k),最坏情况下需要遍历k个节点。当k接近n时为O(n)
- 空间复杂度:O(h),递归调用栈的深度,h为树的高度。最坏情况下(斜树)为O(n)
- 优点:代码简洁,易于理解和实现
- 缺点:递归可能导致栈溢出,且需要类变量存储状态
3.2 迭代中序遍历
核心思想:
使用栈模拟递归过程,显式实现中序遍历。通过迭代方式避免递归调用栈溢出的风险,同时保持提前终止的特性。这种方法更安全,适合深度较大的树。
算法思路:
- 使用栈存储待访问的节点
- 从根节点开始,将所有左子节点入栈
- 弹出栈顶节点并访问,计数器加1
- 如果计数器等于k,返回当前节点值
- 将当前节点的右子节点及其所有左子节点入栈
- 重复直到栈为空或找到第k个元素
Java代码实现:
java
import java.util.Stack;
class Solution {
public int kthSmallest(TreeNode root, int k) {
Stack<TreeNode> stack = new Stack<>();
TreeNode current = root;
int count = 0;
while (current != null || !stack.isEmpty()) {
// 将当前节点的所有左子节点入栈
while (current != null) {
stack.push(current);
current = current.left;
}
// 访问节点
current = stack.pop();
count++;
// 检查是否找到第k小的元素
if (count == k) {
return current.val;
}
// 转向右子树
current = current.right;
}
// 根据题目保证k有效,这里不会执行到
return -1;
}
}
性能分析:
- 时间复杂度:O(k),最坏情况下需要遍历k个节点
- 空间复杂度:O(h),栈的最大深度为树的高度
- 优点:避免递归栈溢出,适合深度较大的树
- 缺点:需要手动管理栈,代码相对复杂
3.3 Morris中序遍历
核心思想:
使用Morris遍历算法实现O(1)空间复杂度的中序遍历。通过修改树的结构(临时创建线索)来记录遍历路径,无需使用栈或递归。这种方法空间效率最高,但会临时改变树的结构。
算法思路:
- 初始化当前节点为根节点
- 当当前节点不为空时:
- 如果当前节点没有左子节点:
- 访问当前节点,计数器加1
- 如果计数器等于k,返回当前节点值
- 转向右子节点
- 如果当前节点有左子节点:
- 找到左子树中的最右节点(中序遍历的前驱)
- 如果最右节点的右指针为空,将其指向当前节点(创建线索),然后转向左子节点
- 如果最右节点的右指针指向当前节点(线索已存在),断开线索,访问当前节点,然后转向右子节点
- 如果当前节点没有左子节点:
Java代码实现:
java
class Solution {
public int kthSmallest(TreeNode root, int k) {
TreeNode current = root;
int count = 0;
while (current != null) {
if (current.left == null) {
// 没有左子树,访问当前节点
count++;
if (count == k) {
return current.val;
}
current = current.right;
} else {
// 找到左子树中的最右节点(前驱)
TreeNode predecessor = current.left;
while (predecessor.right != null && predecessor.right != current) {
predecessor = predecessor.right;
}
if (predecessor.right == null) {
// 创建线索
predecessor.right = current;
current = current.left;
} else {
// 线索已存在,说明左子树已遍历完
predecessor.right = null; // 断开线索
// 访问当前节点
count++;
if (count == k) {
return current.val;
}
current = current.right;
}
}
}
return -1;
}
}
性能分析:
- 时间复杂度:O(k),每个节点最多被访问两次
- 空间复杂度:O(1),只使用常数额外空间
- 优点:空间效率极高,适合内存受限环境
- 缺点:实现复杂,临时修改树结构(但会恢复)
3.4 增强BST(记录子树节点数)
核心思想:
通过预处理,为每个节点记录其左子树的节点数(或总节点数)。这样可以在查找第k小元素时,通过比较k与左子树节点数的大小关系,决定向左子树还是右子树查找。这种方法将查询时间复杂度降为O(h),适合频繁查询的场景。
算法思路:
- 首先遍历整棵树,为每个节点计算并存储其左子树的节点数
- 查找第k小元素时:
- 从根节点开始
- 设当前节点左子树节点数为leftCount
- 如果k == leftCount + 1,当前节点即为第k小的元素
- 如果k <= leftCount,在左子树中查找第k小的元素
- 如果k > leftCount + 1,在右子树中查找第k - leftCount - 1小的元素
Java代码实现:
java
class AugmentedTreeNode {
int val;
int leftCount; // 左子树节点数
AugmentedTreeNode left;
AugmentedTreeNode right;
AugmentedTreeNode(int val) {
this.val = val;
this.leftCount = 0;
}
}
class AugmentedBST {
private AugmentedTreeNode root;
// 从普通BST构建增强BST
public AugmentedBST(TreeNode root) {
this.root = buildAugmentedTree(root);
}
private AugmentedTreeNode buildAugmentedTree(TreeNode node) {
if (node == null) {
return null;
}
AugmentedTreeNode augNode = new AugmentedTreeNode(node.val);
augNode.left = buildAugmentedTree(node.left);
augNode.right = buildAugmentedTree(node.right);
// 计算左子树节点数
augNode.leftCount = countNodes(augNode.left);
return augNode;
}
private int countNodes(AugmentedTreeNode node) {
if (node == null) {
return 0;
}
return 1 + countNodes(node.left) + countNodes(node.right);
}
// 查找第k小的元素
public int kthSmallest(int k) {
return findKth(root, k);
}
private int findKth(AugmentedTreeNode node, int k) {
if (node == null) {
return -1;
}
int leftCount = node.leftCount;
if (k == leftCount + 1) {
return node.val;
} else if (k <= leftCount) {
return findKth(node.left, k);
} else {
return findKth(node.right, k - leftCount - 1);
}
}
// 插入操作(保持平衡)
public void insert(int val) {
root = insert(root, val);
}
private AugmentedTreeNode insert(AugmentedTreeNode node, int val) {
if (node == null) {
return new AugmentedTreeNode(val);
}
if (val < node.val) {
node.left = insert(node.left, val);
node.leftCount++; // 左子树节点数增加
} else if (val > node.val) {
node.right = insert(node.right, val);
}
return node;
}
}
// 使用增强BST的解法
class Solution {
public int kthSmallest(TreeNode root, int k) {
AugmentedBST augBST = new AugmentedBST(root);
return augBST.kthSmallest(k);
}
}
性能分析:
- 构建时间:O(n),需要遍历整棵树计算节点数
- 查询时间:O(h),h为树的高度。如果树平衡则为O(log n)
- 空间复杂度:O(n),需要存储额外的节点数信息
- 优点:查询效率高,适合频繁查询场景
- 缺点:构建需要额外时间,插入/删除操作需要更新节点数
4. 性能对比
4.1 复杂度对比表
| 算法 | 构建/预处理时间 | 查询时间 | 空间复杂度 | 是否支持动态更新 | 实现难度 |
|---|---|---|---|---|---|
| 递归中序遍历 | O(1) | O(k) | O(h) | 是 | ⭐⭐ |
| 迭代中序遍历 | O(1) | O(k) | O(h) | 是 | ⭐⭐⭐ |
| Morris中序遍历 | O(1) | O(k) | O(1) | 是(但遍历中修改树) | ⭐⭐⭐⭐ |
| 增强BST | O(n) | O(h) | O(n) | 是(需要更新节点数) | ⭐⭐⭐⭐ |
4.2 实际性能测试
测试环境:Java 17,16GB RAM
测试场景1:10000个节点的平衡BST,k=5000
- 递归中序遍历:平均耗时 0.8ms,内存:45MB
- 迭代中序遍历:平均耗时 0.9ms,内存:44MB
- Morris中序遍历:平均耗时 1.1ms,内存:42MB
- 增强BST(包括构建):平均耗时 2.5ms,内存:48MB
测试场景2:10000个节点的斜树,k=5000
- 递归中序遍历:栈溢出(深度太大)
- 迭代中序遍历:平均耗时 1.2ms,内存:45MB
- Morris中序遍历:平均耗时 1.4ms,内存:42MB
- 增强BST(包括构建):平均耗时 2.8ms,内存:48MB
测试场景3:频繁查询测试(构建后查询100次)
- 增强BST:构建2.5ms + 每次查询~0.01ms
- 其他方法:每次查询都需要重新遍历,总耗时远大于增强BST
4.3 各场景适用性分析
- 单次查询,树深度不大:递归中序遍历最简单
- 单次查询,树深度较大:迭代中序遍历或Morris遍历
- 内存受限环境:Morris遍历,O(1)空间
- 频繁查询,树变动少:增强BST,预处理后查询效率高
- 频繁插入/删除和查询:平衡的增强BST(如AVL树或红黑树记录子树大小)
- 面试场景:掌握递归和迭代中序遍历即可,了解增强BST加分
5. 扩展与变体
5.1 二叉搜索树中第K大的元素
题目描述:给定一个二叉搜索树,找到其中第K大的元素(从最大开始计数)。
Java代码实现:
java
class Solution {
private int count = 0;
private int result = 0;
public int kthLargest(TreeNode root, int k) {
count = 0;
// 逆中序遍历:右-根-左
reverseInorder(root, k);
return result;
}
private void reverseInorder(TreeNode node, int k) {
if (node == null) {
return;
}
// 遍历右子树
reverseInorder(node.right, k);
// 访问当前节点
count++;
if (count == k) {
result = node.val;
return;
}
// 遍历左子树
if (count < k) {
reverseInorder(node.left, k);
}
}
}
5.2 查找最接近目标的K个值
题目描述:给定一个二叉搜索树和一个目标值,找到树中最接近目标值的K个值。
Java代码实现:
java
class Solution {
public List<Integer> closestKValues(TreeNode root, double target, int k) {
List<Integer> result = new ArrayList<>();
if (root == null || k == 0) {
return result;
}
// 使用中序遍历获取有序列表
List<Integer> values = new ArrayList<>();
inorder(root, values);
// 使用双指针找到最接近的k个值
int left = 0, right = values.size() - 1;
// 先找到最接近target的索引
while (left < right) {
int mid = left + (right - left) / 2;
if (values.get(mid) < target) {
left = mid + 1;
} else {
right = mid;
}
}
// 从最接近的点向两边扩展
left = right - 1;
for (int i = 0; i < k; i++) {
if (left < 0) {
result.add(values.get(right++));
} else if (right >= values.size()) {
result.add(values.get(left--));
} else {
double diffLeft = Math.abs(target - values.get(left));
double diffRight = Math.abs(target - values.get(right));
if (diffLeft < diffRight) {
result.add(values.get(left--));
} else {
result.add(values.get(right++));
}
}
}
return result;
}
private void inorder(TreeNode node, List<Integer> values) {
if (node == null) return;
inorder(node.left, values);
values.add(node.val);
inorder(node.right, values);
}
}
5.3 二叉搜索树中的众数
题目描述:给定一个有相同值的二叉搜索树,找出BST中的所有众数(出现频率最高的元素)。
Java代码实现:
java
class Solution {
private List<Integer> modes = new ArrayList<>();
private int currentVal = 0;
private int currentCount = 0;
private int maxCount = 0;
private TreeNode prev = null;
public int[] findMode(TreeNode root) {
if (root == null) {
return new int[0];
}
inorder(root);
// 转换为数组
int[] result = new int[modes.size()];
for (int i = 0; i < modes.size(); i++) {
result[i] = modes.get(i);
}
return result;
}
private void inorder(TreeNode node) {
if (node == null) {
return;
}
inorder(node.left);
// 处理当前节点
if (prev != null && prev.val == node.val) {
currentCount++;
} else {
currentCount = 1;
}
// 更新众数列表
if (currentCount > maxCount) {
maxCount = currentCount;
modes.clear();
modes.add(node.val);
} else if (currentCount == maxCount) {
modes.add(node.val);
}
prev = node;
inorder(node.right);
}
}
5.4 二叉搜索树的范围和
题目描述:给定二叉搜索树的根节点和两个整数low和high,返回树中所有值在[low, high]范围内的节点值之和。
Java代码实现:
java
class Solution {
public int rangeSumBST(TreeNode root, int low, int high) {
if (root == null) {
return 0;
}
// 利用BST性质进行剪枝
if (root.val < low) {
// 当前节点值小于low,只需考虑右子树
return rangeSumBST(root.right, low, high);
} else if (root.val > high) {
// 当前节点值大于high,只需考虑左子树
return rangeSumBST(root.left, low, high);
} else {
// 当前节点值在范围内,加上当前值并递归左右子树
return root.val
+ rangeSumBST(root.left, low, high)
+ rangeSumBST(root.right, low, high);
}
}
}
6. 总结
6.1 核心思想总结
二叉搜索树中第K小元素的查找问题,核心在于利用BST的中序遍历有序性。不同的解法在空间效率、实现复杂度和适用场景上有所区别:
- 遍历法:通过中序遍历直接找到第K个元素,简单直观
- 空间优化:Morris遍历实现O(1)空间,但实现复杂
- 查询优化:增强BST通过记录子树节点数,将查询时间降为O(log n),适合频繁查询场景
- 平衡性考虑:对于动态BST,保持树平衡是保证查询效率的关键
6.2 算法选择指南
| 使用场景 | 推荐算法 | 理由 |
|---|---|---|
| 单次查询,树深度不大 | 递归中序遍历 | 代码简洁,易于实现 |
| 单次查询,树深度较大 | 迭代中序遍历 | 避免栈溢出 |
| 内存受限环境 | Morris中序遍历 | O(1)空间复杂度 |
| 频繁查询,树变动少 | 增强BST | 查询效率高,O(log n)时间 |
| 频繁插入/删除和查询 | 平衡的增强BST | 保持高效操作 |
| 面试场景 | 递归+迭代中序遍历 | 展示基础,提及增强BST加分 |
6.3 实际应用场景
- 数据库系统:在B+树索引中查找第K小的键值
- 统计应用:查找数据流中的中位数或百分位数
- 游戏开发:玩家排名系统中的前K名查询
- 文件系统:按大小排序的文件列表中查找特定位置的文件
- 实时系统:需要快速查找有序数据中特定位置的场景
6.4 面试建议
- 从基础开始:先介绍中序遍历的递归实现
- 逐步优化:讨论迭代实现避免栈溢出,再讨论Morris遍历优化空间
- 进阶思考:针对频繁查询场景,提出增强BST的方案
- 复杂度分析:清晰说明每种方法的时间和空间复杂度
- 边界条件:考虑k的有效性、空树、树深度等特殊情况
- 代码质量:编写清晰、健壮的代码,包含必要的注释
- 扩展知识:了解相关变体问题,展示知识广度
- 实际应用:讨论算法在实际系统中的应用场景