二叉树算法中篇:展开·右视图·第K小 —— 递归解题的三种武器

文章目录

  • [二叉树算法中篇:展开·右视图·第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)                         ← 右

最后的话

三道题都不难,但我写递归时每次都会问自己:

  1. null 判了没有?
  2. 空的判了没有?(leftTail 为 null、队列为空、栈为空)
  3. 到头了没有?(k 减到 0、depth 超出范围)

判了 null,不等于万事大吉。 真正让你调试半小时的,往往是 leftTail != null 这种"看起来没事"的判断。

下篇预告:二叉树算法下篇------最近公共祖先、路径和、序列化与反序列化。敬请期待。


本文是二叉树算法系列的中篇,上篇讲解了二叉树的四种遍历方式及其递归/迭代实现。