文章目录
- [二叉树算法中篇:展开·右视图·第K小 ------ 递归解题的三种武器 带你看五道leetcode题](#二叉树算法中篇:展开·右视图·第K小 —— 递归解题的三种武器 带你看五道leetcode题)
-
- 前言
- 一、递归心法:三道必问
- [二、LC 114 二叉树展开为链表](#二、LC 114 二叉树展开为链表)
-
- [2.1 题目回顾](#2.1 题目回顾)
- [2.2 你的递归解法:保存右子树,拼接](#2.2 你的递归解法:保存右子树,拼接)
- [2.3 性能分析:while 循环的代价](#2.3 性能分析:while 循环的代价)
- [2.4 优化:让递归返回尾节点](#2.4 优化:让递归返回尾节点)
- [2.5 另一种思路:反向先序遍历](#2.5 另一种思路:反向先序遍历)
- [2.6 小节的空值总结](#2.6 小节的空值总结)
- [三、LC 199 二叉树的右视图](#三、LC 199 二叉树的右视图)
-
- [3.1 题目回顾](#3.1 题目回顾)
- [3.2 解法一:BFS 层序遍历](#3.2 解法一:BFS 层序遍历)
- [3.3 解法二:DFS 根→右→左](#3.3 解法二:DFS 根→右→左)
- [3.4 两种方法对比](#3.4 两种方法对比)
- [3.5 常见误区](#3.5 常见误区)
- [四、LC 230 二叉搜索树中第K小的元素](#四、LC 230 二叉搜索树中第K小的元素)
-
- [4.1 前置知识:BST 中序递增](#4.1 前置知识:BST 中序递增)
- [4.2 解法一:递归中序 + 计数器](#4.2 解法一:递归中序 + 计数器)
- [4.3 解法二:迭代栈(完全提前终止)](#4.3 解法二:迭代栈(完全提前终止))
- [4.4 进阶:节点计数法(适合频繁查询)](#4.4 进阶:节点计数法(适合频繁查询))
- [4.5 方法对比](#4.5 方法对比)
- 五、总结:三道题三种递归模式
二叉树算法中篇:展开·右视图·第K小 ------ 递归解题的三种武器 带你看五道leetcode题
前言
在上篇我们搞定了二叉树的四种遍历(前序、中序、后序、层序),它们是所有树算法题的地基。中篇我们拿三道高频题来练手,每道题都代表一种经典的递归思维模式:
| 题目 | 难度 | 核心思维 |
|---|---|---|
| LC 114 二叉树展开为链表 | 🟡中等 | 递归 + 嫁接:把子树处理好再拼起来 |
| LC 199 二叉树的右视图 | 🟡中等 | DFS 变序遍历 / BFS 层序取末 |
| LC 230 BST第K小元素 | 🟡中等 | BST中序递增:遍历到第K个停下 |
三道题都不难,但每道题的递归写法里都藏着空值陷阱 ------判了 null 不等于万事大吉。
一、递归心法:三道必问
写递归之前,我给自己定了三问(来自我的空值陷阱笔记):
判了 null,就要判空容器;用了下标/切片,就要判边界越界。
对二叉树来说,这三问是:
第一问:root == null ? ← 所有人都会写
第二问:root.left == null ? ← 你需要 left 做什么?它为空能行吗?
第三问:root.right == null ? ← 你需要 right 做什么?它为空能行吗?
每道题我都会标注最容易被忽略的那一问。下面进入正题。
二、LC 114 二叉树展开为链表
2.1 题目回顾
给你根节点
root,将二叉树原地展开为一个单链表:
- 展开后的顺序为先序遍历(根→左→右)
- 用
right指针作为链表next- 所有
left指针置为null
输入: 输出:
1 1
/ \ \
2 5 2
/ \ \ \
3 4 6 3
\
4
\
5
\
6
2.2 你的递归解法:保存右子树,拼接
这是我最初写的版本:
java
class Solution {
public void flatten(TreeNode root) {
if (root == null) return; // ①
TreeNode right = root.right; // ② 保存右子树
flatten(root.left); // ③ 展平左子树
root.right = root.left; // ④ 左链 → 右链
root.left = null; // ⑤ 清空 left
flatten(right); // ⑥ 展平保存的右子树
while (root.right != null) { // ⑦ 走到当前链的末端
root = root.right;
}
root.right = right; // ⑧ 接上展平后的右子树
}
}
思路拆解(用例子走一遍):
初始:
1
/ \
2 5
/ \ \
3 4 6
Step 1: root=1, right=5 (保存), flatten(2)
Step 2: root=2, right=4 (保存), flatten(3)
Step 3: root=3, 左右皆空 → return
root.right=3, root.left=null, flatten(4) → return
while: 2→3 走到末端(root=3), root.right=4
结果: 2→3→4
Step 1 继续: root.right=2, root.left=null, flatten(5)
Step 5: root=5, right=6, flatten(null), root.right=null, flatten(6)→return
while: 5.right=null, root.right=6
结果: 5→6
Step 1 继续: while: 1→2→3→4 走到末端(root=4), root.right=5
最终: 1→2→3→4→5→6 ✅
逻辑完全正确,但这种写法有一个隐藏的性能问题。
2.3 性能分析:while 循环的代价
java
while (root.right != null) { // ← 这行是 O(n)
root = root.right;
}
每层递归都要从头走到尾。考虑一棵极度左倾的树:
1
/
2
/
3
- 处理节点 3:走 0 步
- 处理节点 2:走 1 步(2→3)
- 处理节点 1:走 2 步(1→2→3)
- 总步数:0 + 1 + 2 = 3,即 O(n²) 最坏情况
对于一棵退化成链表的树,这个 while 循环会让整体复杂度退化到 O(n²)。
2.4 优化:让递归返回尾节点
思路 :每次 flatten 不仅展开子树,还返回展开后的尾节点 。这样拼接时不需要 while 遍历,直接 tail.right = right。
java
class Solution {
public void flatten(TreeNode root) {
flattenAndReturnTail(root);
}
// 展开以 root 为根的树,返回展开后链表的尾节点
private TreeNode flattenAndReturnTail(TreeNode root) {
if (root == null) return null; // ① null 判空
TreeNode leftTail = flattenAndReturnTail(root.left); // ② 展平左子树,得到左尾
TreeNode rightTail = flattenAndReturnTail(root.right); // ③ 展平右子树,得到右尾
if (leftTail != null) { // ④ 判左是否为空!
leftTail.right = root.right; // ⑤ 左尾 → 原右子树的头
root.right = root.left; // ⑥ 左链 → 右链
root.left = null; // ⑦ 清空 left
}
// ⑧ 返回当前链的尾节点:右尾 > 左尾 > root 本身
if (rightTail != null) return rightTail;
if (leftTail != null) return leftTail;
return root;
}
}
🔴 空值陷阱 :注意第 ④ 行!只判 root == null 不够,必须判 leftTail != null。因为:
1
\
2
root=1, leftTail=null, rightTail=2
如果直接 leftTail.right = root.right → NPE!💥
这就是三问中第二问 的典型场景:你需要 leftTail 做事,但它可能是 null。
复杂度:时间 O(n),空间 O(h)。每次递归 O(1) 操作,不再有 while 遍历。
2.5 另一种思路:反向先序遍历
更简洁的写法------从右往左处理,维护一个 prev 指针:
java
class Solution {
private TreeNode prev = null;
public void flatten(TreeNode root) {
if (root == null) return;
// 反向前序:右 → 左 → 根
flatten(root.right);
flatten(root.left);
root.right = prev;
root.left = null;
prev = root;
}
}
这个写法的妙处在于:从链尾往回搭 ,每次处理时 prev 已经是处理好的"后面那段",直接接上就行。时间 O(n),空间 O(h)。
2.6 小节的空值总结
| 写法 | 最容易漏的判空 | 后果 |
|---|---|---|
| 你的版本 | root.right != null 的 while 条件 |
还好,while 条件天然防了 |
| 返回尾节点版 | leftTail != null |
NPE |
| 反向前序版 | prev 初始为 null,第一次 root.right = null ✅ |
无问题 |
三、LC 199 二叉树的右视图
3.1 题目回顾
给定二叉树根节点,返回从右侧能看到的所有节点值(从上到下)。
输入: [1,2,3,null,5,null,4]
1 ← 看到 1
/ \
2 3 ← 看到 3(2 被 3 挡住)
\ \
5 4 ← 看到 4(5 被 4 挡住?不对...)
输出: [1, 3, 4]
等等,再仔细看:5 在深度 2,4 在深度 2。从右边看,4 在 3 的右子,5 在 2 的右子。每层取最右的节点,所以深度 2 能看到的是 4(因为 4 更靠右)。
3.2 解法一:BFS 层序遍历
最直观的想法------层序遍历,取每层最后一个节点:
java
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res; // ① 判 null
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size(); // ② 当前层节点数
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (i == size - 1) { // ③ 最后一个 = 最右
res.add(node.val);
}
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return res;
}
}
变体写法------先入右子,再入左子,这样每层第一个出队的就是最右节点:
java
// 先右后左入队
if (node.right != null) queue.offer(node.right);
if (node.left != null) queue.offer(node.left);
if (i == 0) res.add(node.val); // 第一个即最右
复杂度:时间 O(n),空间 O(w),w 为树的最大宽度。
3.3 解法二:DFS 根→右→左
更优雅的写法------变种先序遍历:根 → 右 → 左。
核心 trick:depth == res.size() 意味着当前层第一次被访问,由于我们先走右边,第一次访问到的就是最右节点。
java
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> res = new ArrayList<>();
dfs(root, res, 0);
return res;
}
private void dfs(TreeNode node, List<Integer> res, int depth) {
if (node == null) return; // ① 判 null
if (depth == res.size()) { // ② 关键判断!
res.add(node.val); // 该层首次访问 → 加入结果
}
dfs(node.right, res, depth + 1); // ③ 先右!
dfs(node.left, res, depth + 1); // ④ 后左
}
}
图解这个 trick:
1 depth=0, res.size()=0 → 加入 1, res=[1]
/ \
2 3 depth=1, res.size()=1 → 加入 3(先走右边!), res=[1,3]
\ \
5 4 depth=2, res.size()=2 → 加入 4(先走右边!), res=[1,3,4]
/
6 depth=3, res.size()=3 → 加入 6, res=[1,3,4,6]
如果改成先左后右 ,就变成了左视图:
java
dfs(node.left, res, depth + 1); // 先左
dfs(node.right, res, depth + 1); // 后右
// → 左视图
3.4 两种方法对比
| BFS 层序 | DFS 递归 | |
|---|---|---|
| 时间 | O(n) | O(n) |
| 空间 | O(w) 队列 | O(h) 递归栈 |
| 优势 | 直观,按层思考 | 代码简洁,可改左右顺序变左视图 |
| 劣势 | 宽树空间大 | 深树可能栈溢出 |
3.5 常见误区
❌ 错误想法:只走右子树
java
// 错误!右子树比左子树短时,深层的左子节点也从右侧可见
public void dfs(TreeNode root) {
if (root == null) return;
res.add(root.val);
dfs(root.right); // 只走右边 → 漏掉深层左子节点
}
比如这棵树,只走右边会漏掉节点 5:
1
/
2
/
5 ← 深度 2 只有左子节点,从右边能看到它
四、LC 230 二叉搜索树中第K小的元素
4.1 前置知识:BST 中序递增
BST 的定义:左子树所有节点 < 根 < 右子树所有节点。
中序遍历 (左→根→右)遍历 BST 得到的是严格递增序列。这是解 BST 题目的核心武器。
验证:上一篇文章讲过,这里不再展开。如果你需要复习 BST 中序遍历的完整推导,见中篇开头提到的验证 BST 的笔记。
4.2 解法一:递归中序 + 计数器
最直接的思路:中序遍历,数到第 K 个就停下。
java
class Solution {
private int count = 0;
private int result = 0;
public int kthSmallest(TreeNode root, int k) {
inorder(root, k);
return result;
}
private void inorder(TreeNode node, int k) {
if (node == null) return; // ① 判 null
inorder(node.left, k); // ② 左
count++; // ③ 根:计数
if (count == k) { // ④ 找到第 K 个
result = node.val;
return; // 提前终止
}
inorder(node.right, k); // ⑤ 右
}
}
注意 :return 只是跳出当前层,不会终止已经在递归栈中的上层调用。实际上这版代码即使找到后还会继续遍历一些节点。要真正做到找到即停,可以加一个判断:
java
private void inorder(TreeNode node, int k) {
if (node == null) return;
inorder(node.left, k);
count++;
if (count == k) { result = node.val; return; }
if (count < k) { // ← 加这行
inorder(node.right, k); // 只有还没找到时才继续
}
}
复杂度:时间 O(H + k),空间 O(H)。H 是树高。
4.3 解法二:迭代栈(完全提前终止)
递归版的 return 不能彻底跳出------递归栈里还有父节点等着执行。用显式栈可以做到真正的提前终止:
java
class Solution {
public int kthSmallest(TreeNode root, int k) {
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode curr = root;
while (curr != null || !stack.isEmpty()) {
// 一路向左,全部压栈
while (curr != null) {
stack.push(curr);
curr = curr.left;
}
// 弹出栈顶 = 当前中序节点
curr = stack.pop();
k--;
if (k == 0) return curr.val; // 找到!
// 转向右子树
curr = curr.right;
}
return -1; // 不会执行到这里(题目保证 k 有效)
}
}
🔴 空值陷阱 :curr != null || !stack.isEmpty() 这个条件------当 curr 走到 null 且栈为空时才结束。只判 curr != null 会漏掉叶子节点的右子树处理。
4.4 进阶:节点计数法(适合频繁查询)
如果题目追问:"树经常被修改(插入/删除),且需要频繁查询第 K 小,怎么办?"
思路:给每个节点增加一个 leftCount 字段,记录左子树的节点数。
如果 k == leftCount + 1 → 当前节点就是答案
如果 k <= leftCount → 答案在左子树,递归
如果 k > leftCount + 1 → 答案在右子树,k = k - leftCount - 1
java
class Solution {
public int kthSmallest(TreeNode root, int k) {
int leftCount = countNodes(root.left); // 左子树节点数
if (k == leftCount + 1) {
return root.val; // 根就是答案
} else if (k <= leftCount) {
return kthSmallest(root.left, k); // 在左子树
} else {
return kthSmallest(root.right, k - leftCount - 1); // 在右子树
}
}
private int countNodes(TreeNode node) {
if (node == null) return 0;
return 1 + countNodes(node.left) + countNodes(node.right);
}
}
平衡树时每次查询 O(log n),但如果树退化成链表且无缓存,每次 countNodes 都是 O(n),总复杂度 O(n²)。生产环境中会给节点增加 size 字段并在插入/删除时维护。
4.5 方法对比
| 方法 | 时间 | 空间 | 适合场景 |
|---|---|---|---|
| 递归中序 + 计数 | O(H+k) | O(H) | 单次查询,代码最简洁 |
| 迭代栈 | O(H+k) | O(H) | 单次查询,真正提前终止 |
| 节点计数法 | O(H) 有缓存, O(N²) 无缓存 | O(H) | 频繁查询 + 树不常变 |
五、总结:三道题三种递归模式
| 题目 | 递归模式 | 关键操作 | 最容易漏的判空 |
|---|---|---|---|
| LC 114 展开链表 | 后序拼接:先处理好左右,再拼接 | 获取尾节点 / 维护 prev | leftTail != null 才拼接 |
| LC 199 右视图 | 变序DFS:根→右→左,depth 判断 | depth == res.size() 首次访问 |
root == null 返回空列表 |
| LC 230 第K小 | 中序 + 计数:利用 BST 递增性质 | 计数器到 K 提前停 | 迭代版 `curr != null |
递归模板对比
LC 114(后序拼接):
dfs(root):
if root == null: return
处理左子树 → 得到结果A
处理右子树 → 得到结果B
用 A 和 B 拼接当前层结果
return 当前层结果
LC 199(变序DFS):
dfs(root, depth):
if root == null: return
if depth == res.size(): 加入结果 ← 先序位置
dfs(right, depth+1) ← 先右
dfs(left, depth+1) ← 后左
LC 230(中序 + 计数):
dfs(root):
if root == null: return
dfs(left) ← 左
处理当前节点(计数/判断) ← 中序位置
dfs(right) ← 右
最后的话
三道题都不难,但我写递归时每次都会问自己:
- null 判了没有?
- 空的判了没有?(leftTail 为 null、队列为空、栈为空)
- 到头了没有?(k 减到 0、depth 超出范围)
判了 null,不等于万事大吉。 真正让你调试半小时的,往往是 leftTail != null 这种"看起来没事"的判断。
下篇预告:二叉树算法下篇------最近公共祖先、路径和、序列化与反序列化。敬请期待。
本文是二叉树算法系列的中篇,上篇讲解了二叉树的四种遍历方式及其递归/迭代实现。