一.基本的从暴力递归到动态规划过程
1. 解题流程图
graph TD
A[问题] --> B[暴力递归]
B --> C{是否有重复计算?}
C -->|是| D[记忆化搜索]
C -->|否| E[保持暴力递归]
D --> F{是否有严格的计算顺序?}
F -->|是| G[动态规划]
F -->|否| H[保持记忆化搜索]
2. 以经典的"爬楼梯"问题为例
问题:一次可以爬1或2个台阶,爬到N级台阶共有多少种方法?
2.1 暴力递归版本
public class ClimbStairs {
// 暴力递归
public static int climb1(int n) {
if (n <= 1) return 1;
return climb1(n-1) + climb1(n-2);
}
public static void main(String[] args) {
System.out.println("爬5层楼梯的方法数:" + climb1(5));
}
}
画个图来看递归过程:
f(5)
/ \
f(4) f(3)
/ \ / \
f(3) f(2) f(2) f(1)
/ \
f(2) f(1)
可以看到,f(3)、f(2)被重复计算了!
问题1.为什么 要climb1(n-1) + climb1(n-2); 相加呢?
让我用通俗的方式解释为什么要相加。
1. 直观解释
想象你正站在第5级台阶前:
5
↗ ↑
4 |
↗ |
3 |
要到达第5级台阶,你只有两种选择:
- 从第4级台阶走1步上来(n-1)
- 从第3级台阶走2步上来(n-2)
所以:
- 到达第5级台阶的总方法数 = 到达第4级的方法数 + 到达第3级的方法数
2. 具体示例
让我们计算到达第3级台阶的方法:
第3级台阶:
方法1:1步 + 1步 + 1步
方法2:1步 + 2步
方法3:2步 + 1步
所以f(3) = 3种方法
这3种方法是怎么来的?
从第2级台阶上来的方法(走1步):
- 1步 + 1步 + 1步
- 2步 + 1步
从第1级台阶上来的方法(走2步):
- 1步 + 2步
所以f(3) = f(2) + f(1)
3. 完整演示代码
public class ClimbStairsDemo {
public static void explainClimb(int n) {
System.out.println("计算到达第" + n + "级台阶的方法数:");
System.out.println("可以从第" + (n-1) + "级台阶走1步上来");
System.out.println("可以从第" + (n-2) + "级台阶走2步上来");
int result = climb1(n);
System.out.println("总方法数: " + result);
System.out.println("-------------");
}
public static int climb1(int n) {
// 基础情况
if (n <= 1) {
System.out.println("到达第" + n + "级台阶只有1种方法");
return 1;
}
System.out.println("计算到达第" + n + "级台阶:");
int ways1 = climb1(n-1); // 走1步上来的方法数
int ways2 = climb1(n-2); // 走2步上来的方法数
System.out.println("从第" + (n-1) + "级台阶上来的方法数: " + ways1);
System.out.println("从第" + (n-2) + "级台阶上来的方法数: " + ways2);
return ways1 + ways2;
}
public static void main(String[] args) {
explainClimb(3);
}
}
4. 图解递归过程
f(3) 的所有可能方法:
1. 从f(2)走1步上来:
1.1 [1,1] + 1
1.2 [2] + 1
2. 从f(1)走2步上来:
2.1 [1] + 2
所以:f(3) = f(2) + f(1)
= 2 + 1
= 3种方法
5. 为什么要相加?
-
互斥事件:
- 从n-1级走1步上来
- 从n-2级走2步上来
- 这是两个完全不同的选择
-
概率论知识:
- 互斥事件的概率相加
- 这里是互斥的方法数相加
-
完备性:
- 这两种方式包含了所有可能的走法
- 不会遗漏任何一种方法
- 也不会重复计算任何方法
所以,climb1(n-1) + climb1(n-2) 代表:
- 从n-1级台阶走1步上来的所有方法数
- 加上
- 从n-2级台阶走2步上来的所有方法数
- 等于到达第n级台阶的总方法数
这就像是:
- 要到达目的地有两条路
- 一条路有A种走法
- 另一条路有B种走法
- 总共就有A+B种走法
2.2 记忆化搜索版本
public class ClimbStairs {
// 记忆化搜索
public static int climb2(int n) {
int[] memo = new int[n + 1];
return process(n, memo);
}
private static int process(int n, int[] memo) {
if (n <= 1) return 1;
if (memo[n] != 0) return memo[n];
memo[n] = process(n-1, memo) + process(n-2, memo);
return memo[n];
}
}
现在的递归树变成了:
f(5)
/ \
f(4) f(3)*
/ \
f(3) f(2)
/ \
f(2) f(1)
注:带*的表示直接从memo中获取,不需要重复计算
2.3 动态规划版本
public class ClimbStairs {
// 动态规划
public static int climb3(int n) {
if (n <= 1) return 1;
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
3. 转化流程详解
第一步:找到暴力递归中的重复状态
public class Example {
public static void printState(int n, String path) {
System.out.println("计算f(" + n + "), 路径: " + path);
}
public static int recursion(int n) {
printState(n, "");
if (n <= 1) return 1;
return recursion(n-1) + recursion(n-2);
}
}
第二步:使用数组记录已经计算过的状态
public class Example {
public static int memoization(int n, int[] memo, String path) {
printState(n, path);
if (n <= 1) return 1;
if (memo[n] != 0) {
System.out.println("直接返回记忆化的结果: " + memo[n]);
return memo[n];
}
memo[n] = memoization(n-1, memo, path + "->left") +
memoization(n-2, memo, path + "->right");
return memo[n];
}
}
第三步:发现状态转移方程
dp[i] = dp[i-1] + dp[i-2]
第四步:确定计算顺序
public class Example {
public static void showDPProcess(int n) {
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
System.out.println("DP数组初始状态:");
printArray(dp);
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
System.out.println("计算dp[" + i + "]后:");
printArray(dp);
}
}
private static void printArray(int[] arr) {
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}
}
4. 总结:从递归到动态规划的转化步骤
-
写出暴力递归
- 找到问题的终止条件
- 找到问题的递归部分
- 确定递归函数的参数和返回值
-
分析是否有重复计算
- 画出递归树
- 找到重复计算的节点
- 如果有重复计算,进行记忆化搜索改造
-
改写为记忆化搜索
- 增加记忆化数组
- 每次计算前查表
- 每次计算后存表
-
改写为动态规划
- 找到状态转移方程
- 确定计算顺序
- 优化空间(如果需要)
5. 练习示例
public class DPExamples {
// 示例1:斐波那契数列
// 示例2:爬楼梯
// 示例3:最小路径和
// ...(更多示例)
这样的转化过程是不是更容易理解了?关键是要:
- 先写出正确的暴力递归
- 发现重复计算
- 用数组记录重复计算的结果
- 找到计算顺序
- 最后转成动态规划
二.Leetcode题目
1.LeetCode 198 题「打家劫舍」
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
1. 暴力递归版本
public class HouseRobber {
// 暴力递归
public static int rob1(int[] nums) {
return process(nums, 0);
}
// 从index位置开始抢劫,能获得的最大金额
private static int process(int[] nums, int index) {
// 基础情况
if (index >= nums.length) {
return 0;
}
// 两种选择:
// 1. 抢当前房子,然后去index+2位置
int rob = nums[index] + process(nums, index + 2);
// 2. 不抢当前房子,去index+1位置
int notRob = process(nums, index + 1);
return Math.max(rob, notRob);
}
}
2. 记忆化搜索版本
public class HouseRobber {
// 记忆化搜索
public static int rob2(int[] nums) {
int[] memo = new int[nums.length];
Arrays.fill(memo, -1); // 初始化为-1,表示未计算
return process2(nums, 0, memo);
}
private static int process2(int[] nums, int index, int[] memo) {
// 基础情况
if (index >= nums.length) {
return 0;
}
// 如果已经计算过,直接返回
if (memo[index] != -1) {
return memo[index];
}
// 计算并记录结果
int rob = nums[index] + process2(nums, index + 2, memo);
int notRob = process2(nums, index + 1, memo);
memo[index] = Math.max(rob, notRob);
return memo[index];
}
}
3. 动态规划版本
public class HouseRobber {
// 动态规划
public static int rob3(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
// dp[i]表示从第i个房子开始抢劫能获得的最大金额
int[] dp = new int[nums.length + 2]; // +2是为了处理边界情况
// 从后向前填表
for (int i = nums.length - 1; i >= 0; i--) {
dp[i] = Math.max(
nums[i] + dp[i + 2], // 抢当前房子
dp[i + 1] // 不抢当前房子
);
}
return dp[0];
}
// 空间优化版本
public static int rob4(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
// 只需要记录三个状态
int next = 0; // dp[i+2]
int current = 0; // dp[i+1]
int result = 0; // dp[i]
// 从后向前遍历
for (int i = nums.length - 1; i >= 0; i--) {
result = Math.max(nums[i] + next, current);
next = current;
current = result;
}
return result;
}
// 测试代码
public static void main(String[] args) {
int[] nums = {2, 7, 9, 3, 1};
System.out.println("示例房屋金额:" + Arrays.toString(nums));
System.out.println("\n使用不同方法计算最大抢劫金额:");
System.out.println("暴力递归结果:" + rob1(nums));
System.out.println("记忆化搜索结果:" + rob2(nums));
System.out.println("动态规划结果:" + rob3(nums));
System.out.println("空间优化结果:" + rob4(nums));
// 详细展示动态规划过程
showDPProcess(nums);
}
// 展示动态规划的详细过程
public static void showDPProcess(int[] nums) {
System.out.println("\n动态规划详细过程:");
int[] dp = new int[nums.length + 2];
System.out.println("初始状态:");
System.out.println("dp数组:" + Arrays.toString(dp));
for (int i = nums.length - 1; i >= 0; i--) {
dp[i] = Math.max(nums[i] + dp[i + 2], dp[i + 1]);
System.out.println("\n处理第" + i + "个房子(金额:" + nums[i] + "):");
System.out.println("抢劫该房子:" + nums[i] + " + dp[" + (i+2) + "] = " + (nums[i] + dp[i + 2]));
System.out.println("不抢该房子:dp[" + (i+1) + "] = " + dp[i + 1]);
System.out.println("取较大值:dp[" + i + "] = " + dp[i]);
System.out.println("当前dp数组:" + Arrays.toString(dp));
}
System.out.println("\n最终结果:" + dp[0]);
}
}
运行结果
示例房屋金额:[2, 7, 9, 3, 1]
使用不同方法计算最大抢劫金额:
暴力递归结果:12
记忆化搜索结果:12
动态规划结果:12
空间优化结果:12
动态规划详细过程:
初始状态:
dp数组:[0, 0, 0, 0, 0, 0, 0]
处理第4个房子(金额:1):
抢劫该房子:1 + dp[6] = 1
不抢该房子:dp[5] = 0
取较大值:dp[4] = 1
当前dp数组:[0, 0, 0, 0, 1, 0, 0]
处理第3个房子(金额:3):
抢劫该房子:3 + dp[5] = 3
不抢该房子:dp[4] = 1
取较大值:dp[3] = 3
当前dp数组:[0, 0, 0, 3, 1, 0, 0]
处理第2个房子(金额:9):
抢劫该房子:9 + dp[4] = 10
不抢该房子:dp[3] = 3
取较大值:dp[2] = 10
当前dp数组:[0, 0, 10, 3, 1, 0, 0]
处理第1个房子(金额:7):
抢劫该房子:7 + dp[3] = 10
不抢该房子:dp[2] = 10
取较大值:dp[1] = 10
当前dp数组:[0, 10, 10, 3, 1, 0, 0]
处理第0个房子(金额:2):
抢劫该房子:2 + dp[2] = 12
不抢该房子:dp[1] = 10
取较大值:dp[0] = 12
当前dp数组:[12, 10, 10, 3, 1, 0, 0]
最终结果:12
关键点解释
-
状态定义:
dp[i]表示从第i个房子开始抢劫能获得的最大金额
-
状态转移方程:
dp[i] = max(nums[i] + dp[i+2], dp[i+1])- 抢当前房子:
nums[i] + dp[i+2] - 不抢当前房子:
dp[i+1]
-
填表顺序:
- 从后向前填写
- 因为当前状态依赖后面的状态
-
空间优化:
- 只需要记录三个状态值
- 不断更新这三个值
这个例子很好地展示了动态规划的核心思想:
- 将大问题分解为小问题
- 找到状态转移方程
- 利用已计算的结果
- 按照正确的顺序计算
思路模板
用流程图总结动态规划的解题步骤。
动态规划解题流程图
graph TD
A[开始分析问题] --> B[第一步: 暴力递归]
B --> B1[找到终止条件]
B1 --> B2[找到可变参数]
B2 --> B3[找到每个状态的选择]
B3 --> C[第二步: 分析重复计算]
C --> D[第三步: 画出状态表]
D --> D1[确定表的维度]
D1 --> D2[确定表的大小]
D2 --> D3[找出状态转移方程]
D3 --> E[第四步: 确定填表顺序]
E --> E1[基础值填充]
E1 --> E2[按依赖关系填表]
E2 --> F[第五步: 代码实现]
F --> F1[初始化dp数组]
F1 --> F2[实现状态转移]
F2 --> F3[返回最终结果]
F3 --> G[第六步: 空间优化]
G --> G1[分析必要的状态]
G1 --> G2[使用变量替代数组]
详细步骤说明
- 第一步:暴力递归
// 示例:打家劫舍问题的暴力递归
public int rob(int[] nums, int index) {
if (index >= nums.length) return 0;
// 选择1:抢当前房子
int rob = nums[index] + rob(nums, index + 2);
// 选择2:不抢当前房子
int notRob = rob(nums, index + 1);
return Math.max(rob, notRob);
}
- 第二步:分析重复计算
// 记忆化搜索
public int rob(int[] nums, int index, int[] memo) {
if (index >= nums.length) return 0;
if (memo[index] != -1) return memo[index];
int rob = nums[index] + rob(nums, index + 2, memo);
int notRob = rob(nums, index + 1, memo);
memo[index] = Math.max(rob, notRob);
return memo[index];
}
- 第三步:画出状态表
状态表示例(打家劫舍):
索引 i: 0 1 2 3 4
房屋值: 2 7 9 3 1
dp[i]: 12 10 10 3 1
- 第四步:确定填表顺序
// 从后向前填表
for (int i = nums.length - 1; i >= 0; i--) {
dp[i] = Math.max(nums[i] + dp[i + 2], dp[i + 1]);
}
- 第五步:代码实现
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int[] dp = new int[nums.length + 2];
for (int i = nums.length - 1; i >= 0; i--) {
dp[i] = Math.max(nums[i] + dp[i + 2], dp[i + 1]);
}
return dp[0];
}
- 第六步:空间优化
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int next = 0; // dp[i+2]
int current = 0; // dp[i+1]
int result = 0; // dp[i]
for (int i = nums.length - 1; i >= 0; i--) {
result = Math.max(nums[i] + next, current);
next = current;
current = result;
}
return result;
}
用状态表的方式来展示空间优化的过程。以打家劫舍问题为例:
1. 原始DP表
假设输入数组 nums = [2, 7, 9, 3, 1]
索引 i: 0 1 2 3 4 5 6
nums: 2 7 9 3 1
dp: 12 10 10 3 1 0 0
2. 观察依赖关系
graph LR
A["dp[i]"] --> B["dp[i+1]"]
A --> C["dp[i+2]"]
对于每个位置i,我们只需要知道:
- dp[i+1] 的值
- dp[i+2] 的值
3. 状态转移表
让我们看看值是如何转移的:
第一轮 (i = 4):
next = dp[6] = 0
current = dp[5] = 0
result = max(1 + next, current) = 1
更新后:
next = 0 → current = 0 → result = 1
第二轮 (i = 3):
next = dp[5] = 0
current = dp[4] = 1
result = max(3 + next, current) = 3
更新后:
next = current = 1 → current = result = 3
第三轮 (i = 2):
next = dp[4] = 1
current = dp[3] = 3
result = max(9 + next, current) = 10
更新后:
next = current = 3 → current = result = 10
4. 完整状态转移表
初始状态:
next = 0, current = 0, result = 0
位置 nums[i] next current 计算过程 result 更新后
4 1 0 0 max(1+0, 0) 1 next=0, current=1
3 3 0 1 max(3+0, 1) 3 next=1, current=3
2 9 1 3 max(9+1, 3) 10 next=3, current=10
1 7 3 10 max(7+3, 10) 10 next=10, current=10
0 2 10 10 max(2+10, 10) 12 next=10, current=12
5. 代码实现
public class SpaceOptimization {
public static int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int next = 0; // dp[i+2]
int current = 0; // dp[i+1]
int result = 0; // dp[i]
System.out.println("初始状态:");
System.out.println("next=" + next + ", current=" + current + ", result=" + result);
for (int i = nums.length - 1; i >= 0; i--) {
result = Math.max(nums[i] + next, current);
System.out.println("\n处理位置" + i + ":");
System.out.println("nums[" + i + "]=" + nums[i]);
System.out.println("选择抢劫:" + nums[i] + " + " + next + " = " + (nums[i] + next));
System.out.println("选择不抢:" + current);
System.out.println("result = " + result);
next = current;
current = result;
System.out.println("更新后:next=" + next + ", current=" + current);
}
return result;
}
public static void main(String[] args) {
int[] nums = {2, 7, 9, 3, 1};
System.out.println("输入数组:" + Arrays.toString(nums));
System.out.println("最终结果:" + rob(nums));
}
}
6. 运行结果
输入数组:[2, 7, 9, 3, 1]
初始状态:
next=0, current=0, result=0
处理位置4:
nums[4]=1
选择抢劫:1 + 0 = 1
选择不抢:0
result = 1
更新后:next=0, current=1
处理位置3:
nums[3]=3
选择抢劫:3 + 0 = 3
选择不抢:1
result = 3
更新后:next=1, current=3
...(以此类推)
最终结果:12
7. 空间优化总结
- 原始状态:需要一个长度为 n+2 的数组
- 观察依赖:每个位置只依赖后面两个位置
- 优化方案:使用三个变量替代数组
- next:表示dp[i+2]
- current:表示dp[i+1]
- result:表示dp[i]
- 状态转移:
- result = max(nums[i] + next, current)
- next = current
- current = result
这样就把O(n)的空间复杂度优化到了O(1)!
解题核心要点
-
状态定义
- 明确dp数组每个元素的含义
- 确定需要几维数组
-
转移方程
- 找出状态之间的关系
- 写出数学表达式
-
边界条件
- 确定初始值
- 处理特殊情况
-
填表顺序
- 确保计算时依赖的值已经得到
- 通常从小到大或从大到小
-
空间优化
- 分析必要的状态数量
- 使用变量替代数组
这个流程图和步骤可以作为解决动态规划问题的通用模板!
2.LeetCode 1143 「最长公共子序列」(LCS)
这个题难就难在dp表是二维数组
1. 题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。
2. 分析过程
graph TD
A[分析问题] --> B[1.定义状态]
B --> C[2.找出选择]
C --> D[3.写出状态转移方程]
D --> E[4.确定边界条件]
E --> F[5.实现代码]
3. 详细代码实现
public class LongestCommonSubsequence {
public static int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
// dp[i][j]表示text1的前i个字符与text2的前j个字符的LCS长度
int[][] dp = new int[m + 1][n + 1];
// 填充dp表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i-1) == text2.charAt(j-1)) {
// 当前字符相同,LCS长度+1
dp[i][j] = dp[i-1][j-1] + 1;
} else {
// 当前字符不同,取较大值
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[m][n];
}
// 详细解释版本
public static void explainLCS(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1];
System.out.println("text1: " + text1);
System.out.println("text2: " + text2);
System.out.println("\n初始dp表:");
printDP(dp, text1, text2);
// 填表过程
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
System.out.println("\n比较 text1[" + (i-1) + "]=" +
text1.charAt(i-1) + " 和 text2[" +
(j-1) + "]=" + text2.charAt(j-1));
if (text1.charAt(i-1) == text2.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + 1;
System.out.println("字符相同,dp[" + i + "][" + j +
"] = dp[" + (i-1) + "][" + (j-1) +
"] + 1 = " + dp[i][j]);
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
System.out.println("字符不同,取左边和上边的较大值:");
System.out.println("dp[" + (i-1) + "][" + j +
"] = " + dp[i-1][j]);
System.out.println("dp[" + i + "][" + (j-1) +
"] = " + dp[i][j-1]);
System.out.println("dp[" + i + "][" + j +
"] = " + dp[i][j]);
}
System.out.println("\n当前dp表:");
printDP(dp, text1, text2);
}
}
// 打印最长公共子序列
printLCS(dp, text1, text2, m, n);
}
// 打印dp表
private static void printDP(int[][] dp, String text1, String text2) {
System.out.print(" # ");
for (char c : text2.toCharArray()) {
System.out.print(" " + c + " ");
}
System.out.println();
for (int i = 0; i <= text1.length(); i++) {
if (i == 0) System.out.print("# ");
else System.out.print(text1.charAt(i-1) + " ");
for (int j = 0; j <= text2.length(); j++) {
System.out.printf("%2d ", dp[i][j]);
}
System.out.println();
}
}
// 打印最长公共子序列
private static void printLCS(int[][] dp, String text1, String text2,
int i, int j) {
StringBuilder lcs = new StringBuilder();
while (i > 0 && j > 0) {
if (text1.charAt(i-1) == text2.charAt(j-1)) {
lcs.insert(0, text1.charAt(i-1));
i--;
j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
i--;
} else {
j--;
}
}
System.out.println("\n最长公共子序列是:" + lcs.toString());
}
public static void main(String[] args) {
String text1 = "abcde";
String text2 = "ace";
explainLCS(text1, text2);
}
}
4. 示例运行
以 text1 = "abcde", text2 = "ace" 为例:
步骤1: 初始状态
# a c e
0 # 0 0 0 0
1 a 0 0 0 0
2 b 0 0 0 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤2: 处理 dp[1][1] (a vs a)
比较 text1[0]=a 和 text2[0]=a
字符相同!
dp[1][1] = dp[0][0] + 1 = 1
# a c e
0 # 0 0 0 0
1 a 0 1 0 0
2 b 0 0 0 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤3: 处理 dp[1][2] (a vs c)
比较 text1[0]=a 和 text2[1]=c
字符不同!
取上方值(0)和左方值(1)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 0
2 b 0 0 0 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤4: 处理 dp[1][3] (a vs e)
比较 text1[0]=a 和 text2[2]=e
字符不同!
取上方值(0)和左方值(1)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 0 0 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤5: 处理 dp[2][1] (b vs a)
比较 text1[1]=b 和 text2[0]=a
字符不同!
取上方值(1)和左方值(0)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 0 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤6: 处理 dp[2][2] (b vs c)
比较 text1[1]=b 和 text2[1]=c
字符不同!
取上方值(1)和左方值(1)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 0
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤7: 处理 dp[2][3] (b vs e)
比较 text1[1]=b 和 text2[2]=e
字符不同!
取上方值(1)和左方值(1)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 0 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤8: 处理 dp[3][1] (c vs a)
比较 text1[2]=c 和 text2[0]=a
字符不同!
取上方值(1)和左方值(0)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 0 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤9: 处理 dp[3][2] (c vs c)
比较 text1[2]=c 和 text2[1]=c
字符相同!
dp[3][2] = dp[2][1] + 1 = 2
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 0
4 d 0 0 0 0
5 e 0 0 0 0
步骤10: 处理 dp[3][3] (c vs e)
比较 text1[2]=c 和 text2[2]=e
字符不同!
取上方值(1)和左方值(2)的较大值:2
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 0 0 0
5 e 0 0 0 0
步骤11: 处理 dp[4][1] (d vs a)
比较 text1[3]=d 和 text2[0]=a
字符不同!
取上方值(1)和左方值(0)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 0 0
5 e 0 0 0 0
步骤12: 处理 dp[4][2] (d vs c)
比较 text1[3]=d 和 text2[1]=c
字符不同!
取上方值(2)和左方值(1)的较大值:2
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 2 0
5 e 0 0 0 0
步骤13: 处理 dp[4][3] (d vs e)
比较 text1[3]=d 和 text2[2]=e
字符不同!
取上方值(2)和左方值(2)的较大值:2
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 2 2
5 e 0 0 0 0
步骤14: 处理 dp[5][1] (e vs a)
比较 text1[4]=e 和 text2[0]=a
字符不同!
取上方值(1)和左方值(0)的较大值:1
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 2 2
5 e 0 1 0 0
步骤15: 处理 dp[5][2] (e vs c)
比较 text1[4]=e 和 text2[1]=c
字符不同!
取上方值(2)和左方值(1)的较大值:2
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 2 2
5 e 0 1 2 0
步骤16: 处理 dp[5][3] (e vs e)
比较 text1[4]=e 和 text2[2]=e
字符相同!
dp[5][3] = dp[4][2] + 1 = 3
最终状态:
# a c e
0 # 0 0 0 0
1 a 0 1 1 1
2 b 0 1 1 1
3 c 0 1 2 2
4 d 0 1 2 2
5 e 0 1 2 3
5. 状态转移方程
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
让我用生动的方式来解释这两种情况。
1. 字符相同的情况
比如: text1="abc", text2="adc"
当比较第一个字符 'a' vs 'a'
想象成这样:
当两个人都喜欢吃苹果🍎:
- 太好了!我们都喜欢吃苹果
- 那就把这个苹果加入我们的共同爱好清单
- 然后继续看下一个爱好
所以:
dp[i][j] = dp[i-1][j-1] + 1
└── 之前的共同爱好数量 ──┘ └─ 加上这个新的共同爱好 ─┘
2. 字符不同的情况
比如比较: 'b' vs 'd'
想象成这样:
当一个人喜欢篮球🏀,另一个人喜欢足球⚽:
- 这两个不一样,没法都加入共同清单
- 那我们要保持之前找到的最多共同爱好数量
- 要么保留前一个人的爱好清单数量
- 要么保留另一个人的爱好清单数量
- 选择数量多的那个!
所以:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
└── 上方的数量 ──┘ └── 左方的数量 ──┘
3. 具体例子
text1 = "abcde"
text2 = "ace"
当处理到 'a' vs 'a':
👥 都喜欢 'a'
✅ 加入共同列表
结果:共同序列 "a",长度 1
当处理到 'b' vs 'c':
🤔 不一样...
👈 看左边:保留 "a"
👆 看上边:保留 "a"
✅ 保持原有的共同序列 "a"
结果:共同序列还是 "a",长度 1
4. 形象类比
想象你在玩一个卡牌游戏:
字符相同的情况:
🎯 找到一张配对的卡牌
└── 太好了!得分+1
字符不同的情况:
🤔 两张卡牌不配对
└── 保持现有的最高分数
或者类比找共同好友:
字符相同:
👥 找到一个共同好友
└── 加入共同好友列表
字符不同:
🔄 保持当前最大的共同好友数量
└── 不增加新的共同好友
5. 总结公式
graph TD
A[比较两个字符] --> B{是否相同?}
B -->|相同| C[加入共同序列<br>dp[i][j] = dp[i-1][j-1] + 1]
B -->|不同| D[保持最大值<br>dp[i][j] = max<br>dp[i-1][j]<br>dp[i][j-1]]
C -->|例如| E[都喜欢苹果🍎<br>共同爱好+1]
D -->|例如| F[一个喜欢篮球🏀<br>一个喜欢足球⚽<br>保持原有共同爱好数]
这样理解起来是不是更容易了?每一步都是在寻找和维护最长的共同序列,就像在找朋友之间的共同爱好一样!
7.dp表填完如何查找子串
最后一个数字 dp[m][n](即右下角的值)就是最长公共子序列的长度。让我解释为什么:
1. 为什么是右下角的值?
# a c e
# 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3 ← 最终答案
因为:
dp[i][j]表示:text1的前i个字符与text2的前j个字符的最长公共子序列长度dp[m][n]就表示:整个text1和整个text2的最长公共子序列长度
2. 如何找到具体的子序列?
public static String findLCS(String text1, String text2, int[][] dp) {
StringBuilder lcs = new StringBuilder();
int i = text1.length();
int j = text2.length();
while (i > 0 && j > 0) {
if (text1.charAt(i-1) == text2.charAt(j-1)) {
// 找到一个公共字符
lcs.insert(0, text1.charAt(i-1));
i--;
j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
// 来自上方
i--;
} else {
// 来自左方
j--;
}
}
return lcs.toString();
}
3. 回溯过程示例
以 text1 = "abcde", text2 = "ace" 为例:
步骤1: 从dp[5][3]开始 (值为3)
比较 'e' vs 'e'
相同!记录 'e'
↖️ 移动到dp[4][2]
步骤2: 在dp[4][2] (值为2)
比较 'd' vs 'c'
不同!
⬆️ 移动到dp[3][2]
步骤3: 在dp[3][2] (值为2)
比较 'c' vs 'c'
相同!记录 'c'
↖️ 移动到dp[2][1]
步骤4: 在dp[2][1] (值为1)
比较 'b' vs 'a'
不同!
⬆️ 移动到dp[1][1]
步骤5: 在dp[1][1] (值为1)
比较 'a' vs 'a'
相同!记录 'a'
↖️ 移动到dp[0][0]
最终结果: "ace"
4. 可视化回溯路径
# a c e
# 0 0 0 0
a 0 1←1 1
b 0 1 1 1
c 0 1 2←2
d 0 1 2 2
e 0 1 2 3 ← 开始
回溯路径: 3 → 2 → 1 → 0
收集字符: e → c → a
最终结果: ace
5. 完整代码示例
public class LCSWithPath {
public static void printLCSDetails(String text1, String text2) {
int[][] dp = new int[text1.length() + 1][text2.length() + 1];
// 填充dp表
for (int i = 1; i <= text1.length(); i++) {
for (int j = 1; j <= text2.length(); j++) {
if (text1.charAt(i-1) == text2.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
}
// 打印最终dp表
System.out.println("最终dp表:");
printDP(dp, text1, text2);
// 找出具体的LCS
String lcs = findLCS(text1, text2, dp);
System.out.println("\n最长公共子序列: " + lcs);
System.out.println("长度: " + dp[text1.length()][text2.length()]);
// 打印回溯路径
System.out.println("\n回溯路径:");
printPath(text1, text2, dp);
}
private static void printPath(String text1, String text2, int[][] dp) {
int i = text1.length();
int j = text2.length();
while (i > 0 && j > 0) {
System.out.printf("在位置[%d][%d], 值=%d\n", i, j, dp[i][j]);
if (text1.charAt(i-1) == text2.charAt(j-1)) {
System.out.println("找到匹配字符: " + text1.charAt(i-1));
i--; j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
System.out.println("向上移动");
i--;
} else {
System.out.println("向左移动");
j--;
}
}
}
}
所以:
- dp表的右下角值给出了最长公共子序列的长度
- 通过回溯dp表可以找出具体的子序列
- 回溯时根据字符是否相同来决定移动方向
- 相同字符就是我们要找的公共子序列中的字符
这就是为什么最后一个数字能告诉我们最长公共子序列的长度,而且我们还能通过回溯找出具体的序列!
6. 空间优化版本
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
// 只需要两行
int[] prev = new int[n + 1];
int[] curr = new int[n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i-1) == text2.charAt(j-1)) {
curr[j] = prev[j-1] + 1;
} else {
curr[j] = Math.max(prev[j], curr[j-1]);
}
}
// 更新prev数组
int[] temp = prev;
prev = curr;
curr = temp;
}
return prev[n];
}
7. 关键点总结
-
状态定义:
- dp[i][j] 表示 text1 前i个字符和 text2 前j个字符的LCS长度
-
选择:
- 当前字符相同:LCS长度+1
- 当前字符不同:取左边或上边的较大值
-
边界条件:
- dp[0][j] = 0:空字符串与任何字符串的LCS长度为0
- dp[i][0] = 0:同上
-
填表顺序:
- 从左到右,从上到下
- 每个位置依赖左边、上边和左上角的值
-
空间优化:
- 只需要保存两行数据
- 交替使用这两行
8. 应用场景
- 生物信息学中的DNA序列比对
- 文件差异比较
- 版本控制系统
- 自然语言处理
这道题是动态规划中的经典问题,理解了这个问题的解法,对于理解其他序列相关的动态规划问题会很有帮助!