我来为您详细讲解二叉树的核心概念和解题方法。
1. 二叉树的基本概念
二叉树是一种非常重要的数据结构,每个节点最多有两个子节点:
A
/ \
B C
/ \ \
D E F
基本节点结构:
class TreeNode {
int val;
TreeNode left;
TreeNode right;
}
2. 二叉树的遍历方式
递归遍历
- 先序遍历:根 -> 左 -> 右
- 中序遍历:左 -> 根 -> 右
- 后序遍历:左 -> 右 -> 根
流程图:
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)
深度优先有三种遍历方式:
- 前序遍历(根->左->右)
- 中序遍历(左->根->右)
- 后序遍历(左->右->根)
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. 应用场景
-
DFS适用场景:
- 查找树的深度
- 检查树是否平衡
- 路径总和问题
- 树的序列化
-
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层:访问1
第2层:访问2
第3层:访问4
第4层:null返回
返回到3层:访问5
第4层:null返回
返回到2层:访问3
第3层:访问6
第4层:null返回
返回到3层:访问7
第4层: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的例子来说明:
- 第一步:从根节点5开始
初始目标:22
当前节点:5
新目标:22 - 5 = 17(还需要找17)
- 选择左路径,到达节点4
当前目标:17
当前节点:4
新目标:17 - 4 = 13(还需要找13)
- 继续左路径,到达节点11
当前目标:13
当前节点:11
新目标:13 - 11 = 2(还需要找2)
- 最后到达节点2
当前目标:2
当前节点:2
新目标:2 - 2 = 0(刚好找到了!)
形象化解释
想象你在玩一个寻宝游戏:
- 你从入口(根节点)开始,身上带着22块金币
- 每经过一个节点,就要支付该节点标示的金币数
- 你的目标是找到一条路径,使得到达终点时刚好用完所有金币
初始金币: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[继续其他路径...]
代码执行的三种情况
- 空节点情况
if (root == null) return false;
就像走到死胡同,这条路不通,返回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);
- 尝试左边的路 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 ❌
记忆要点
- 把问题想象成"花钱"游戏#
- 每走一步,就从目标金额中减去当前节点值
- 到达叶子节点时,如果刚好花完所有钱,就找到了答案
- 一条路不通可以回溯尝试其他路径
这样理解的话,代码就变得很直观了:我们只是在不断地减少目标值,直到找到一条刚好用完所有金币的路径!
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(来自步骤1)
2. 右子树贡献 = 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. 关键概念解释
-
贡献值vs路径和
- 贡献值:向上返回给父节点的值(只能选择一条路)
- 路径和:经过当前节点的最大路径和(可以包含左右两条路)
-
为什么要取max(x, 0)
- 如果子树贡献为负数,选择不要这条路(返回0)
- 这就像爬山时,不会选择会让总海拔降低的路径
-
全局变量maxSum的作用
- 记录所有可能路径中的最大值
- 因为最大路径可能出现在树的任何位置
6. 形象比喻
想象你是一个登山者:
- 你要找出山地中海拔和最高的一条路径
- 在每个位置,你都要决定:
- 是否要包含左边的山峰
- 是否要包含右边的山峰
- 如果某个方向是山谷(负数),就不选这个方向
- 当你向上返回信息时,只能选择一条路(因为不能分叉)
- 但在计算当前位置的最大路径时,可以同时选择左右两条路
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的最近公共祖先。
递归执行过程:
- 从根节点(3)开始:
lowestCommonAncestor(3, 7, 4)
├── 检查:3不是null,也不是7或4
├── 去左子树找:lowestCommonAncestor(5, 7, 4)
└── 去右子树找:lowestCommonAncestor(1, 7, 4)
- 节点5的处理:
lowestCommonAncestor(5, 7, 4)
├── 检查:5不是null,也不是7或4
├── 去左子树找:lowestCommonAncestor(6, 7, 4) -> 返回null
└── 去右子树找:lowestCommonAncestor(2, 7, 4)
- 节点2的处理:
lowestCommonAncestor(2, 7, 4)
├── 检查:2不是null,也不是7或4
├── 去左子树找:lowestCommonAncestor(7, 7, 4) -> 返回7(找到一个目标)
└── 去右子树找:lowestCommonAncestor(4, 7, 4) -> 返回4(找到另一个目标)
结果:左右都找到了,返回节点2
- 结果往上传递:
节点2 是最终答案
因为:
- 它的左子树找到了7
- 它的右子树找到了4
- 所以节点2就是7和4的最近公共祖先
最终结果:节点2
这意味着节点2(妹妹)是节点7(外甥)和节点4(外甥女)的最近公共祖先。
用家族关系来说就是:
- 7和4都是2的孩子
- 2是7和4的直接父节点
- 所以2是他们最近的共同祖先
验证正确性:
- 确实没有其他更近的节点同时能到达7和4
- 节点2是通向7和4的必经节点
- 如果往上找(比如节点5或3),虽然也是公共祖先,但不是"最近"的
这就像在家族中:
- 虽然爷爷(3)和爸爸(5)也是7和4的共同祖先
- 但妹妹(2)是最近的那个共同祖先
- 因为7和4都是妹妹的孩子
这就是为什么节点2是最终答案!
解题要点总结:
-
画图理解:
- 先在纸上画出具体例子
- 手动模拟递归过程
-
思考方式:
- 自顶向下:像分派任务
- 自底向上:像收集信息
-
代码模板:
ReturnType process(TreeNode node) {
// 1. 基本情况
if (特殊情况) return 特殊值;
// 2. 分解子问题
ReturnType leftResult = process(node.left);
ReturnType rightResult = process(node.right);
// 3. 合并结果
return 根据左右结果计算当前结果;
}
记住:解决二叉树问题就像探索一个家族树或者玩一个寻宝游戏,关键是要明确在每个节点上要做什么,要收集什么信息,以及如何利用这些信息得到最终答案。
3. 递归解题的关键点总结:
-
明确递归函数的定义:
- 参数是什么
- 返回值是什么
- 函数代表什么含义
-
确定终止条件:
- 通常是 null 节点的处理
- 叶子节点的特殊处理
-
明确递归逻辑:
- 当前节点需要做什么
- 需要子树返回什么信息
- 如何处理子树返回的信息
-
全局变量使用:
- 需要维护全局最优解时
- 需要收集所有路径时
这些递归实现都遵循了相同的模式:先处理基本情况(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是在树结构上进行的动态规划。它的特点是:
- 在树上进行状态转移
- 通常从叶子节点开始,逐步向上递推
- 每个节点的状态依赖于其子节点的状态
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的特点
- 状态定义:
- 通常需要定义一个类/结构体存储多个状态
- 状态之间相互关联
class State {
int state1; // 状态1
int state2; // 状态2
// 可能有更多状态...
}
- 状态转移:
- 当前节点的状态依赖于子节点的状态
- 需要考虑多种可能的选择
// 状态转移示例
currentState = combine(leftChildState, rightChildState, currentNode);
3.5 常见树形DP问题类型
-
选择类问题:
- 打家劫舍 III
- 二叉树的最大独立集
-
路径类问题:
- 二叉树中的最大路径和
- 最长路径问题
-
统计类问题:
- 统计优美子树的数量
- 统计满足条件的节点对
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. 解题技巧
-
状态设计:
- 仔细分析需要哪些状态
- 状态要能完整描述问题
- 状态数量要尽可能少
-
递归设计:
- 明确递归函数的含义
- 设计合适的返回值类型
- 处理好边界情况
-
状态转移:
- 考虑所有可能的选择
- 正确处理子节点状态
- 注意状态更新的顺序
通过理解和掌握树形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 解题技巧总结
-
画图分析:
- 在解题前先画出具体的树结构
- 在纸上模拟递归过程
-
考虑边界情况:
- 空树
- 只有一个节点的树
- 所有节点值都相同的树
- 极度不平衡的树
-
自顶向下 vs 自底向上:
- 自顶向下:先处理当前节点,再递归处理子节点
- 自底向上:先递归处理子节点,再处理当前节点
-
全局变量使用原则:
- 需要在递归过程中维护全局状态时使用
- 注意多组测试数据时的重置
通过这些详细的解析和可视化的流程图,希望能帮助您更好地理解二叉树的递归实现。关键是要理解递归的本质是把大问题分解成相同的小问题,而每个小问题都遵循相同的解决模式。
3. 递归解题的关键点总结:
-
明确递归函数的定义:
- 参数是什么
- 返回值是什么
- 函数代表什么含义
-
确定终止条件:
- 通常是 null 节点的处理
- 叶子节点的特殊处理
-
明确递归逻辑:
- 当前节点需要做什么
- 需要子树返回什么信息
- 如何处理子树返回的信息
-
全局变量使用:
- 需要维护全局最优解时
- 需要收集所有路径时
这些递归实现都遵循了相同的模式:先处理基本情况(Base Case),然后递归处理子问题,最后合并子问题的解得到最终解。理解这个模式对解决二叉树的问题非常重要。
4. 常见题型分类
-
树型DP问题
- 二叉树的最大路径和
- 打家劫舍III
-
树的构造问题
- 从前序与中序遍历序列构造二叉树
- 从中序与后序遍历序列构造二叉树
-
树的性质问题
- 判断是否是平衡二叉树
- 判断是否是对称二叉树
5. 解题技巧总结
-
递归三部曲:
- Base case(基础情况)
- 递归调用(分解问题)
- 合并结果(解决问题)
-
树形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. 易错点提醒
- 递归时忘记考虑空节点情况
- 遍历时栈的使用不当
- 没有正确维护全局变量
- 递归返回值的处理错误
实战建议
- 先画图理解题目
- 先用递归方式实现
- 考虑是否需要优化空间复杂度
- 考虑特殊用例(空树、单节点树等)
记住:解决二叉树问题的关键是要明确每个节点需要做什么,需要返回什么信息给它的父节点。大多数二叉树的题目都可以用递归来解决,关键是设计好递归函数的返回值和递归过程中的信息传递。