左哥算法 - 二叉树(一)

132 阅读21分钟

我来为您详细讲解二叉树的核心概念和解题方法。

1. 二叉树的基本概念

二叉树是一种非常重要的数据结构,每个节点最多有两个子节点:

    A
   / \
  B   C
 / \   \
D   E   F

基本节点结构:

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
}

2. 二叉树的遍历方式

递归遍历

  1. 先序遍历:根 -> 左 -> 右
  2. 中序遍历:左 -> 根 -> 右
  3. 后序遍历:左 -> 右 -> 根

流程图:

graph TD
    A[开始] --> B[判断节点是否为空]
    B -- 是 --> C[返回]
    B -- 否 --> D[处理当前节点]
    D --> E[递归处理左子树]
    E --> F[递归处理右子树]

非递归遍历(使用栈)

// 先序遍历示例
public void preOrder(TreeNode root) {
    Stack<TreeNode> stack = new Stack<>();
    while (root != null || !stack.isEmpty()) {
        if (root != null) {
            System.out.print(root.val + " "); // 处理根节点
            stack.push(root);
            root = root.left;  // 移动到左子树
        } else {
            root = stack.pop().right; // 处理右子树
        }
    }
}

让我详细解释二叉树的深度优先遍历(DFS)和广度优先遍历(BFS)。

1. 首先定义二叉树节点
public class TreeNode {
    int val;           // 节点值
    TreeNode left;     // 左子节点
    TreeNode right;    // 右子节点
    
    public TreeNode(int val) {
        this.val = val;
    }
}
2. 深度优先遍历(DFS)

深度优先有三种遍历方式:

  1. 前序遍历(根->左->右)
  2. 中序遍历(左->根->右)
  3. 后序遍历(左->右->根)
public class TreeTraversal {
    // 前序遍历
    public void preOrder(TreeNode root) {
        if (root == null) return;
        
        System.out.print(root.val + " "); // 先访问根节点
        preOrder(root.left);              // 再遍历左子树
        preOrder(root.right);             // 最后遍历右子树
    }
    
    // 中序遍历
    public void inOrder(TreeNode root) {
        if (root == null) return;
        
        inOrder(root.left);               // 先遍历左子树
        System.out.print(root.val + " "); // 再访问根节点
        inOrder(root.right);              // 最后遍历右子树
    }
    
    // 后序遍历
    public void postOrder(TreeNode root) {
        if (root == null) return;
        
        postOrder(root.left);             // 先遍历左子树
        postOrder(root.right);            // 再遍历右子树
        System.out.print(root.val + " "); // 最后访问根节点
    }
}
3. 广度优先遍历(BFS)

使用队列实现层序遍历:

public class TreeTraversal {
    public void levelOrder(TreeNode root) {
        if (root == null) return;
        
        Queue<TreeNode> queue = new LinkedList<>();
        queue.offer(root);
        
        while (!queue.isEmpty()) {
            // 获取当前层的节点数
            int levelSize = queue.size();
            
            // 遍历当前层的所有节点
            for (int i = 0; i < levelSize; i++) {
                TreeNode node = queue.poll();
                System.out.print(node.val + " ");
                
                // 将下一层的节点加入队列
                if (node.left != null) {
                    queue.offer(node.left);
                }
                if (node.right != null) {
                    queue.offer(node.right);
                }
            }
            System.out.println(); // 换行表示当前层结束
        }
    }
}
4. 完整示例
public class TreeTraversalDemo {
    // 树节点定义
    static class TreeNode {
        int val;
        TreeNode left;
        TreeNode right;
        
        TreeNode(int val) {
            this.val = val;
        }
    }
    
    // DFS实现
    public static class DFS {
        // 前序遍历(递归)
        public static void preOrderRecursive(TreeNode root) {
            if (root == null) return;
            System.out.print(root.val + " ");
            preOrderRecursive(root.left);
            preOrderRecursive(root.right);
        }
        
        // 前序遍历(非递归)
        public static void preOrderIterative(TreeNode root) {
            if (root == null) return;
            Stack<TreeNode> stack = new Stack<>();
            stack.push(root);
            
            while (!stack.isEmpty()) {
                TreeNode node = stack.pop();
                System.out.print(node.val + " ");
                
                // 注意:先压入右子节点,再压入左子节点
                if (node.right != null) stack.push(node.right);
                if (node.left != null) stack.push(node.left);
            }
        }
        
        // 中序遍历(非递归)
        public static void inOrderIterative(TreeNode root) {
            if (root == null) return;
            Stack<TreeNode> stack = new Stack<>();
            TreeNode curr = root;
            
            while (curr != null || !stack.isEmpty()) {
                // 一直遍历到左子树最底层
                while (curr != null) {
                    stack.push(curr);
                    curr = curr.left;
                }
                
                curr = stack.pop();
                System.out.print(curr.val + " ");
                curr = curr.right;
            }
        }
    }
    
    // BFS实现
    public static class BFS {
        // 层序遍历(返回层级信息)
        public static List<List<Integer>> levelOrder(TreeNode root) {
            List<List<Integer>> result = new ArrayList<>();
            if (root == null) return result;
            
            Queue<TreeNode> queue = new LinkedList<>();
            queue.offer(root);
            
            while (!queue.isEmpty()) {
                int levelSize = queue.size();
                List<Integer> currentLevel = new ArrayList<>();
                
                for (int i = 0; i < levelSize; i++) {
                    TreeNode node = queue.poll();
                    currentLevel.add(node.val);
                    
                    if (node.left != null) queue.offer(node.left);
                    if (node.right != null) queue.offer(node.right);
                }
                
                result.add(currentLevel);
            }
            
            return result;
        }
    }
    
    // 测试代码
    public static void main(String[] args) {
        // 创建测试树
        //       1
        //      / \
        //     2   3
        //    / \   \
        //   4   5   6
        TreeNode root = new TreeNode(1);
        root.left = new TreeNode(2);
        root.right = new TreeNode(3);
        root.left.left = new TreeNode(4);
        root.left.right = new TreeNode(5);
        root.right.right = new TreeNode(6);
        
        System.out.println("DFS遍历:");
        System.out.print("前序遍历(递归): ");
        DFS.preOrderRecursive(root);
        System.out.println();
        
        System.out.print("前序遍历(非递归): ");
        DFS.preOrderIterative(root);
        System.out.println();
        
        System.out.print("中序遍历(非递归): ");
        DFS.inOrderIterative(root);
        System.out.println();
        
        System.out.println("\nBFS遍历:");
        List<List<Integer>> levels = BFS.levelOrder(root);
        for (int i = 0; i < levels.size(); i++) {
            System.out.println("第" + (i+1) + "层: " + levels.get(i));
        }
    }
}
5. 输出示例
DFS遍历:
前序遍历(递归): 1 2 4 5 3 6
前序遍历(非递归): 1 2 4 5 3 6
中序遍历(非递归): 4 2 5 1 3 6

BFS遍历:
第1层: [1]
第2层: [2, 3]
第3层: [4, 5, 6]
6. 应用场景
  1. DFS适用场景

    • 查找树的深度
    • 检查树是否平衡
    • 路径总和问题
    • 树的序列化
  2. BFS适用场景

    • 查找最短路径
    • 层次遍历
    • 查找特定深度的节点
    • 树的序列化

这些遍历方法是解决树相关问题的基础,掌握它们对于理解更复杂的树算法非常重要!

让我们通过具体例子来深入理解二叉树的递归实现。

1. 基本遍历方式详解

假设我们有这样一棵树:

     1
   /   \
  2     3
 / \   / \
4   5 6   7
1.1 先序遍历 (根->左->右)
public void preOrder(TreeNode root) {
    if (root == null) return;  // 基本情况:空节点直接返回
    
    System.out.print(root.val + " ");  // 先访问根节点
    preOrder(root.left);               // 递归访问左子树
    preOrder(root.right);              // 递归访问右子树
}

访问顺序:1 -> 2 -> 4 -> 5 -> 3 -> 6 -> 7

流程图:

graph TD
    A[开始] --> B{节点是否为空?}
    B -- 是 --> C[返回]
    B -- 否 --> D[访问当前节点]
    D --> E[递归访问左子树]
    E --> F[递归访问右子树]
1.2 递归执行栈的可视化

以节点1为例:

1层:访问12层:访问23层:访问44层:null返回
        返回到3层:访问54层:null返回
    返回到2层:访问33层:访问64层:null返回
        返回到3层:访问74层:null返回
1.3 三种基本递归遍历
class Solution {
    // 先序遍历(根->左->右)
    public void preOrder(TreeNode root) {
        if (root == null) return;
        
        System.out.print(root.val + " ");  // 处理根节点
        preOrder(root.left);               // 递归左子树
        preOrder(root.right);              // 递归右子树
    }
    
    // 中序遍历(左->根->右)
    public void inOrder(TreeNode root) {
        if (root == null) return;
        
        inOrder(root.left);                // 递归左子树
        System.out.print(root.val + " ");  // 处理根节点
        inOrder(root.right);               // 递归右子树
    }
    
    // 后序遍历(左->右->根)
    public void postOrder(TreeNode root) {
        if (root == null) return;
        
        postOrder(root.left);              // 递归左子树
        postOrder(root.right);             // 递归右子树
        System.out.print(root.val + " ");  // 处理根节点
    }
}

2. 实际问题解析

2.1 求二叉树最大深度
public int maxDepth(TreeNode root) {
    // 基本情况:空节点深度为0
    if (root == null) return 0;
    
    // 递归计算左右子树的深度
    int leftDepth = maxDepth(root.left);
    int rightDepth = maxDepth(root.right);
    
    // 当前节点的深度 = max(左子树深度, 右子树深度) + 1
    return Math.max(leftDepth, rightDepth) + 1;
}

思路流程图:

flowchart TD
    A["开始"] --> B{"节点是否为空?"}
    B -->|是| C["返回0"]
    B -->|否| D["计算左子树深度"]
    D --> E["计算右子树深度"]
    E --> F["返回max(左深度,右深度)+1"]

例如对于树:

   1
  / \
 2   3
/
4

递归过程:

maxDepth(1)
├── leftDepth = maxDepth(2)
│   ├── leftDepth = maxDepth(4)
│   │   ├── leftDepth = maxDepth(null) = 0
│   │   └── rightDepth = maxDepth(null) = 0
│   │   返回 1
│   └── rightDepth = maxDepth(null) = 0
│   返回 2
└── rightDepth = maxDepth(3)
    ├── leftDepth = maxDepth(null) = 0
    └── rightDepth = maxDepth(null) = 0
    返回 1
最终返回 3

让我们通过生动的例子和比喻来讲解这五个经典二叉树问题。

2.2 对称二叉树

想象照镜子,左右两边要完全对称。

     1
   /   \
  2     2   <- 这两个要相同
 / \   / \
3   4 4   3  <- 这些也要相同
public boolean isSymmetric(TreeNode root) {
    if (root == null) return true;
    return check(root.left, root.right);
}

private boolean check(TreeNode left, TreeNode right) {
    // 两边都没有,也是对称的
    if (left == null && right == null) return true;
    // 一边有一边没有,不对称
    if (left == null || right == null) return false;
    
    // 检查:
    // 1. 当前两个节点值相同
    // 2. 左边的左子树和右边的右子树对称
    // 3. 左边的右子树和右边的左子树对称
    return left.val == right.val && 
           check(left.left, right.right) && 
           check(left.right, right.left);
}

就像照镜子时,你举左手,镜子里的人举右手,必须完全对应。

2.3 路径总和

想象在公园里寻宝,从入口到某个终点,路上的数字加起来要等于目标值。

     5
    / \
   4   8
  /   / \
11  13   4
/  \      \
7    2      1

目标和:22
路径:5->4->11->2 = 22
public boolean hasPathSum(TreeNode root, int targetSum) {
    if (root == null) return false;
    
    // 到达叶子节点,检查是否找到目标和
    if (root.left == null && root.right == null) {
        return root.val == targetSum;
    }
    
    // 继续在左右子树中寻找剩余的和
    return hasPathSum(root.left, targetSum - root.val) || 
           hasPathSum(root.right, targetSum - root.val);
}

就像寻宝游戏,每走一步都减去当前位置的值,看最后是否能刚好找到宝藏。

让我用一个寻宝游戏的方式来解释路径总和问题。

问题描述

给定一个目标数字,判断是否存在一条从根节点到叶子节点的路径,路径上所有数字之和等于目标数字。

具体例子
     5
    / \
   4   8
  /   / \
 11  13  4
/  \      \
7   2      1

目标和:22
代码详解
public boolean hasPathSum(TreeNode root, int targetSum) {
    // 1. 空节点情况
    if (root == null) return false;
    
    // 2. 叶子节点情况
    if (root.left == null && root.right == null) {
        return root.val == targetSum;
    }
    
    // 3. 非叶子节点情况
    return hasPathSum(root.left, targetSum - root.val) || 
           hasPathSum(root.right, targetSum - root.val);
}
详细执行过程

让我们以目标和为22的例子来说明:

  1. 第一步:从根节点5开始
初始目标:22
当前节点:5
新目标:22 - 5 = 17(还需要找17
  1. 选择左路径,到达节点4
当前目标:17
当前节点:4
新目标:17 - 4 = 13(还需要找13
  1. 继续左路径,到达节点11
当前目标:13
当前节点:11
新目标:13 - 11 = 2(还需要找2
  1. 最后到达节点2
当前目标:2
当前节点:2
新目标:2 - 2 = 0(刚好找到了!)
形象化解释

想象你在玩一个寻宝游戏:

  1. 你从入口(根节点)开始,身上带着22块金币
  2. 每经过一个节点,就要支付该节点标示的金币数
  3. 你的目标是找到一条路径,使得到达终点时刚好用完所有金币
初始金币:22块
↓
支付5块 (还剩17块)
↓
支付4块 (还剩13块)
↓
支付11块 (还剩2块)
↓
支付2块 (刚好用完!)
流程图
graph TD
    A[开始: 目标和=22] --> B[节点5: 还需17]
    B --> C[节点4: 还需13]
    C --> D[节点11: 还需2]
    D --> E[节点2: 还需0]
    E --> F[找到答案!]
    
    B --> G[节点8: 还需12]
    G --> H[继续其他路径...]
代码执行的三种情况
  1. 空节点情况
if (root == null) return false;

就像走到死胡同,这条路不通,返回false。

  1. 叶子节点情况
if (root.left == null && root.right == null) {
    return root.val == targetSum;
}

到达终点时:

  • 如果剩余金币数正好等于当前节点值 → 成功!
  • 否则 → 这条路径不符合要求
  1. 非叶子节点情况
return hasPathSum(root.left, targetSum - root.val) || 
       hasPathSum(root.right, targetSum - root.val);
  • 尝试左边的路 OR 尝试右边的路
  • 只要有一条路能成功就返回true
实际例子演示

目标和:22

     5 (还需17)
    / \
   4   8
  /   / \
 11  13  4
/  \      \
7   2      1

路径1: 5->4->11->7  = 27 ❌
路径2: 5->4->11->2  = 22 ✅ (找到了!)
路径3: 5->8->13     = 26 ❌
路径4: 5->8->4->1   = 18
记忆要点
  1. 把问题想象成"花钱"游戏#
  2. 每走一步,就从目标金额中减去当前节点值
  3. 到达叶子节点时,如果刚好花完所有钱,就找到了答案
  4. 一条路不通可以回溯尝试其他路径

这样理解的话,代码就变得很直观了:我们只是在不断地减少目标值,直到找到一条刚好用完所有金币的路径!

2.4 二叉树的最大路径和

想象一条山路,要找出一条经过山峰的路径,使得海拔总和最大。

     1
    / \
   2   3
  /     \
 4       5
class Solution {
    private int maxSum = Integer.MIN_VALUE; // 记录发现的最大和
    
    public int maxPathSum(TreeNode root) {
        dfs(root);
        return maxSum;
    }
    
    private int dfs(TreeNode node) {
        if (node == null) return 0;
        
        // 计算左右子路径的最大贡献值(如果是负数就不要)
        int leftGain = Math.max(dfs(node.left), 0);
        int rightGain = Math.max(dfs(node.right), 0);
        
        // 当前节点能形成的最大路径和
        int pathSum = node.val + leftGain + rightGain;
        maxSum = Math.max(maxSum, pathSum);
        
        // 返回对父节点的最大贡献
        return node.val + Math.max(leftGain, rightGain);
    }
}

就像爬山,每个山峰都可能是路径的最高点,需要考虑经过这个山峰的所有可能路径。

让我用一个爬山的比喻来详细讲解二叉树的最大路径和问题。

1. 问题形象化

想象二叉树是一个山地地图:

  • 每个节点的值代表这个位置的海拔高度(可以是负数,表示是山谷)
  • 我们要找出一条路径,使得经过的所有点的海拔和最大
  • 路径可以从任意点开始,到任意点结束,但必须是连续的
2. 代码详解
class Solution {
    // 用来记录发现的最大路径和
    private int maxSum = Integer.MIN_VALUE;
    
    public int maxPathSum(TreeNode root) {
        dfs(root);
        return maxSum;
    }
    
    // 返回以当前节点为起点向下延伸的最大路径和
    private int dfs(TreeNode node) {
        if (node == null) return 0;
        
        // 计算左右子树能提供的最大贡献值
        // 如果子树贡献为负,则不选取该路径(取0)
        int leftGain = Math.max(dfs(node.left), 0);
        int rightGain = Math.max(dfs(node.right), 0);
        
        // 计算经过当前节点的最大路径和
        int pathSum = node.val + leftGain + rightGain;
        maxSum = Math.max(maxSum, pathSum);
        
        // 返回当前节点能提供的最大贡献
        return node.val + Math.max(leftGain, rightGain);
    }
}
3. 具体例子分析

假设我们有这样一个树:

     1
    / \
   2   3
  /     \
-4       5

让我们一步步分析:

步骤1: 处理节点-4(最左叶子)
1. 左右子树贡献都是0(叶子节点)
2. 当前路径和 = -4 + 0 + 0 = -4
3. 更新maxSum = max(maxSum, -4)
4. 返回给父节点的贡献 = -4 + max(0, 0) = -4
   实际返回0(因为负数贡献会被舍弃)
步骤2: 处理节点2
1. 左子树贡献 = max(0, 0) = 0(来自步骤12. 右子树贡献 = 0(空节点)
3. 当前路径和 = 2 + 0 + 0 = 2
4. 更新maxSum = max(maxSum, 2)
5. 返回给父节点的贡献 = 2 + max(0, 0) = 2
步骤3: 处理节点5(右叶子)
1. 左右子树贡献都是0
2. 当前路径和 = 5 + 0 + 0 = 5
3. 更新maxSum = max(maxSum, 5)
4. 返回给父节点的贡献 = 5
步骤4: 处理节点3
1. 左子树贡献 = 0(空节点)
2. 右子树贡献 = 5(来自步骤3)
3. 当前路径和 = 3 + 0 + 5 = 8
4. 更新maxSum = max(maxSum, 8)
5. 返回给父节点的贡献 = 3 + 5 = 8
步骤5: 处理根节点1
1. 左子树贡献 = 2(来自步骤2)
2. 右子树贡献 = 8(来自步骤4)
3. 当前路径和 = 1 + 2 + 8 = 11
4. 更新maxSum = max(maxSum, 11)
5. 返回1 + max(2, 8) = 9
4. 流程图
graph TD
    A[开始: 节点1] --> B[左子树: 节点2]
    A --> C[右子树: 节点3]
    B --> D[节点-4]
    C --> E[节点5]
    
    F[计算贡献值] --> G[更新全局最大和]
    G --> H[返回对父节点的贡献]
5. 关键概念解释
  1. 贡献值vs路径和

    • 贡献值:向上返回给父节点的值(只能选择一条路)
    • 路径和:经过当前节点的最大路径和(可以包含左右两条路)
  2. 为什么要取max(x, 0)

    • 如果子树贡献为负数,选择不要这条路(返回0)
    • 这就像爬山时,不会选择会让总海拔降低的路径
  3. 全局变量maxSum的作用

    • 记录所有可能路径中的最大值
    • 因为最大路径可能出现在树的任何位置
6. 形象比喻

想象你是一个登山者:

  1. 你要找出山地中海拔和最高的一条路径
  2. 在每个位置,你都要决定:
    • 是否要包含左边的山峰
    • 是否要包含右边的山峰
    • 如果某个方向是山谷(负数),就不选这个方向
  3. 当你向上返回信息时,只能选择一条路(因为不能分叉)
  4. 但在计算当前位置的最大路径时,可以同时选择左右两条路
7. 最终结果

在这个例子中:

  • 最大路径和是11(2 -> 1 -> 3 -> 5)
  • 这条路径就像一个山脊线,经过了最多的高海拔点
  • 虽然有些点是负数(比如-4),但我们可以选择不经过这些点

记住:解决这类问题的关键是分清楚"向上的贡献"和"当前的最大路径和"这两个概念!

2.5 最近公共祖先

想象一个家族树,要找到两个人最近的共同祖先。

     爷爷
    /    \
  爸爸   叔叔
 /   \
我   妹妹
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    // 如果到达null,或找到p或q,就返回当前节点
    if (root == null || root == p || root == q) return root;
    
    // 在左右子树中寻找p和q
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    
    // 如果左边没找到,返回右边的结果
    if (left == null) return right;
    // 如果右边没找到,返回左边的结果
    if (right == null) return left;
    // 如果两边都找到了,说明当前节点就是最近公共祖先
    return root;
}

流程图:

graph TD
    A[开始寻找] --> B{当前节点是p或q吗?}
    B -- 是 --> C[返回当前节点]
    B -- 否 --> D[在左子树中寻找]
    D --> E[在右子树中寻找]
    E --> F{左右子树的结果如何?}
    F -- 都找到了 --> G[当前节点是答案]
    F -- 只在左边找到 --> H[返回左边结果]
    F -- 只在右边找到 --> I[返回右边结果]

就像在家谱中找两个人的共同祖先,从下往上找,直到找到第一个同时能通向这两个人的祖先。

让我用具体的例子一步一步演示最终结果。还是用刚才那个树:

       3(爷爷)
      /        \
  5(爸爸)      1(叔叔)
   /    \      /    \
6(我)  2(妹妹) 0     8
      /    \
   7(外甥) 4(外甥女)

假设我们要找节点7和节点4的最近公共祖先。

递归执行过程:
  1. 从根节点(3)开始
lowestCommonAncestor(3, 7, 4)
├── 检查:3不是null,也不是74
├── 去左子树找:lowestCommonAncestor(5, 7, 4)
└── 去右子树找:lowestCommonAncestor(1, 7, 4)
  1. 节点5的处理
lowestCommonAncestor(5, 7, 4)
├── 检查:5不是null,也不是74
├── 去左子树找:lowestCommonAncestor(6, 7, 4) -> 返回null
└── 去右子树找:lowestCommonAncestor(2, 7, 4)
  1. 节点2的处理
lowestCommonAncestor(2, 7, 4)
├── 检查:2不是null,也不是74
├── 去左子树找:lowestCommonAncestor(7, 7, 4) -> 返回7(找到一个目标)
└── 去右子树找:lowestCommonAncestor(4, 7, 4) -> 返回4(找到另一个目标)
结果:左右都找到了,返回节点2
  1. 结果往上传递
节点2 是最终答案
因为:
- 它的左子树找到了7
- 它的右子树找到了4
- 所以节点2就是7和4的最近公共祖先
最终结果:节点2

这意味着节点2(妹妹)是节点7(外甥)和节点4(外甥女)的最近公共祖先。

用家族关系来说就是:

  • 7和4都是2的孩子
  • 2是7和4的直接父节点
  • 所以2是他们最近的共同祖先
验证正确性:
  1. 确实没有其他更近的节点同时能到达7和4
  2. 节点2是通向7和4的必经节点
  3. 如果往上找(比如节点5或3),虽然也是公共祖先,但不是"最近"的

这就像在家族中:

  • 虽然爷爷(3)和爸爸(5)也是7和4的共同祖先
  • 但妹妹(2)是最近的那个共同祖先
  • 因为7和4都是妹妹的孩子

这就是为什么节点2是最终答案!

解题要点总结:
  1. 画图理解

    • 先在纸上画出具体例子
    • 手动模拟递归过程
  2. 思考方式

    • 自顶向下:像分派任务
    • 自底向上:像收集信息
  3. 代码模板

ReturnType process(TreeNode node) {
    // 1. 基本情况
    if (特殊情况) return 特殊值;
    
    // 2. 分解子问题
    ReturnType leftResult = process(node.left);
    ReturnType rightResult = process(node.right);
    
    // 3. 合并结果
    return 根据左右结果计算当前结果;
}

记住:解决二叉树问题就像探索一个家族树或者玩一个寻宝游戏,关键是要明确在每个节点上要做什么,要收集什么信息,以及如何利用这些信息得到最终答案。

3. 递归解题的关键点总结:

  1. 明确递归函数的定义

    • 参数是什么
    • 返回值是什么
    • 函数代表什么含义
  2. 确定终止条件

    • 通常是 null 节点的处理
    • 叶子节点的特殊处理
  3. 明确递归逻辑

    • 当前节点需要做什么
    • 需要子树返回什么信息
    • 如何处理子树返回的信息
  4. 全局变量使用

    • 需要维护全局最优解时
    • 需要收集所有路径时

这些递归实现都遵循了相同的模式:先处理基本情况(Base Case),然后递归处理子问题,最后合并子问题的解得到最终解。理解这个模式对解决二叉树的问题非常重要。

3. 树形DP问题

3.1 最大路径和
class Solution {
    private int maxSum = Integer.MIN_VALUE;
    
    public int maxPathSum(TreeNode root) {
        dfs(root);
        return maxSum;
    }
    
    private int dfs(TreeNode node) {
        if (node == null) return 0;
        
        // 计算左右子树的最大贡献值
        // 如果子树贡献为负,则取0(相当于不选取该路径)
        int leftGain = Math.max(dfs(node.left), 0);
        int rightGain = Math.max(dfs(node.right), 0);
        
        // 更新全局最大路径和
        // 当前节点的最大路径和 = 当前节点值 + 左子树贡献 + 右子树贡献
        int priceNewPath = node.val + leftGain + rightGain;
        maxSum = Math.max(maxSum, priceNewPath);
        
        // 返回节点的最大贡献值(只能选择一条路径)
        return node.val + Math.max(leftGain, rightGain);
    }
}

思路流程图:

graph TD
    A[开始] --> B{节点是否为空?}
    B -- 是 --> C[返回0]
    B -- 否 --> D[计算左子树最大贡献]
    D --> E[计算右子树最大贡献]
    E --> F[计算当前节点的最大路径和]
    F --> G[更新全局最大值]
    G --> H[返回节点的最大贡献值]

让我详细解释下树形DP(树形动态规划)。

3.2. 树形DP的概念

树形DP是在树结构上进行的动态规划。它的特点是:

  1. 在树上进行状态转移
  2. 通常从叶子节点开始,逐步向上递推
  3. 每个节点的状态依赖于其子节点的状态
3.3. 经典例题:打家劫舍 III

给定一棵二叉树,每个节点都有一个金额。不能同时偷取直接相连的节点。求能偷取的最大金额。

     3
    / \
   2   3
  /   / \
 3   1   1
class Solution {
    class Info {
        int selected;    // 选择当前节点的最大值
        int notSelected; // 不选择当前节点的最大值
        
        Info(int selected, int notSelected) {
            this.selected = selected;
            this.notSelected = notSelected;
        }
    }
    
    public int rob(TreeNode root) {
        Info result = dfs(root);
        return Math.max(result.selected, result.notSelected);
    }
    
    private Info dfs(TreeNode node) {
        if (node == null) {
            return new Info(0, 0);
        }
        
        // 递归获取左右子树的信息
        Info leftInfo = dfs(node.left);
        Info rightInfo = dfs(node.right);
        
        // 选择当前节点
        int selected = node.val + leftInfo.notSelected + rightInfo.notSelected;
        
        // 不选择当前节点
        int notSelected = Math.max(leftInfo.selected, leftInfo.notSelected) + 
                         Math.max(rightInfo.selected, rightInfo.notSelected);
        
        return new Info(selected, notSelected);
    }
}

流程图:

flowchart TD
    A["开始"] --> B{"节点是否为空?"}
    B -->|是| C["返回(0,0)"]
    B -->|否| D["递归左子树"]
    D --> E["递归右子树"]
    E --> F["计算选择当前节点的值"]
    F --> G["计算不选择当前节点的值"]
    G --> H["返回两种情况的结果"]
3.4 树形DP的特点
  1. 状态定义
    • 通常需要定义一个类/结构体存储多个状态
    • 状态之间相互关联
class State {
    int state1;  // 状态1
    int state2;  // 状态2
    // 可能有更多状态...
}
  1. 状态转移
    • 当前节点的状态依赖于子节点的状态
    • 需要考虑多种可能的选择
// 状态转移示例
currentState = combine(leftChildState, rightChildState, currentNode);
3.5 常见树形DP问题类型
  1. 选择类问题

    • 打家劫舍 III
    • 二叉树的最大独立集
  2. 路径类问题

    • 二叉树中的最大路径和
    • 最长路径问题
  3. 统计类问题

    • 统计优美子树的数量
    • 统计满足条件的节点对
3.6 解题模板
class Solution {
    class Info {
        // 定义需要的状态
        int state1;
        int state2;
        
        Info(int s1, int s2) {
            state1 = s1;
            state2 = s2;
        }
    }
    
    public int solve(TreeNode root) {
        Info result = process(root);
        return getFinalResult(result);
    }
    
    private Info process(TreeNode node) {
        // 1. 基础情况
        if (node == null) {
            return new Info(/*基础值*/);
        }
        
        // 2. 递归获取子树信息
        Info leftInfo = process(node.left);
        Info rightInfo = process(node.right);
        
        // 3. 计算当前节点的各种状态
        int newState1 = /*状态1的计算*/;
        int newState2 = /*状态2的计算*/;
        
        // 4. 返回当前节点的状态
        return new Info(newState1, newState2);
    }
}
3.7. 解题技巧
  1. 状态设计

    • 仔细分析需要哪些状态
    • 状态要能完整描述问题
    • 状态数量要尽可能少
  2. 递归设计

    • 明确递归函数的含义
    • 设计合适的返回值类型
    • 处理好边界情况
  3. 状态转移

    • 考虑所有可能的选择
    • 正确处理子节点状态
    • 注意状态更新的顺序

通过理解和掌握树形DP,我们可以解决很多复杂的树结构问题。关键是要设计好状态,并正确处理状态之间的转移关系。

3.8 递归解题的通用模板
public ReturnType process(TreeNode node, OtherParams... params) {
    // 1. 基本情况判断
    if (node == null) {
        return baseCase;
    }
    
    // 2. 递归获取左右子树信息
    ReturnType leftInfo = process(node.left, ...);
    ReturnType rightInfo = process(node.right, ...);
    
    // 3. 整合信息
    ReturnType result = mergeInfo(leftInfo, rightInfo, node);
    
    // 4. 返回结果
    return result;
}
3.9 解题技巧总结
  1. 画图分析

    • 在解题前先画出具体的树结构
    • 在纸上模拟递归过程
  2. 考虑边界情况

    • 空树
    • 只有一个节点的树
    • 所有节点值都相同的树
    • 极度不平衡的树
  3. 自顶向下 vs 自底向上

    • 自顶向下:先处理当前节点,再递归处理子节点
    • 自底向上:先递归处理子节点,再处理当前节点
  4. 全局变量使用原则

    • 需要在递归过程中维护全局状态时使用
    • 注意多组测试数据时的重置

通过这些详细的解析和可视化的流程图,希望能帮助您更好地理解二叉树的递归实现。关键是要理解递归的本质是把大问题分解成相同的小问题,而每个小问题都遵循相同的解决模式。

3. 递归解题的关键点总结:

  1. 明确递归函数的定义

    • 参数是什么
    • 返回值是什么
    • 函数代表什么含义
  2. 确定终止条件

    • 通常是 null 节点的处理
    • 叶子节点的特殊处理
  3. 明确递归逻辑

    • 当前节点需要做什么
    • 需要子树返回什么信息
    • 如何处理子树返回的信息
  4. 全局变量使用

    • 需要维护全局最优解时
    • 需要收集所有路径时

这些递归实现都遵循了相同的模式:先处理基本情况(Base Case),然后递归处理子问题,最后合并子问题的解得到最终解。理解这个模式对解决二叉树的问题非常重要。

4. 常见题型分类

  1. 树型DP问题

    • 二叉树的最大路径和
    • 打家劫舍III
  2. 树的构造问题

    • 从前序与中序遍历序列构造二叉树
    • 从中序与后序遍历序列构造二叉树
  3. 树的性质问题

    • 判断是否是平衡二叉树
    • 判断是否是对称二叉树

5. 解题技巧总结

  1. 递归三部曲

    • Base case(基础情况)
    • 递归调用(分解问题)
    • 合并结果(解决问题)
  2. 树形DP模板

class Info {
    // 需要的信息
    int value;
    boolean status;
    Info(int v, boolean s) {
        value = v;
        status = s;
    }
}

public Info process(TreeNode node) {
    if (node == null) {
        return new Info(0, true);
    }
    // 收集信息
    Info leftInfo = process(node.left);
    Info rightInfo = process(node.right);
    // 整合信息
    return new Info(...);
}

6. 易错点提醒

  1. 递归时忘记考虑空节点情况
  2. 遍历时栈的使用不当
  3. 没有正确维护全局变量
  4. 递归返回值的处理错误

实战建议

  1. 先画图理解题目
  2. 先用递归方式实现
  3. 考虑是否需要优化空间复杂度
  4. 考虑特殊用例(空树、单节点树等)

记住:解决二叉树问题的关键是要明确每个节点需要做什么,需要返回什么信息给它的父节点。大多数二叉树的题目都可以用递归来解决,关键是设计好递归函数的返回值和递归过程中的信息传递。