动态规划刷题总结
动态规划算法描述
动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
- dp[i]表示子问题的解
- 初始状态
- 状态转移方程
- 通过只保留必要的状态来节省空间,这种方法叫做滚动变量,或者滚动数组
经典问题:
- 爬楼梯
- 斐波那契队列
DP的问题特点
- 最优子结构:原问题的最优价,是由子问题的最优解得来的
- 无后效性:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关 。
解题思路:
-
判断是否是DP
- 先观察问题是否适合使用回溯(穷举)解决 。适合用回溯解决的问题通常满足“决策树模型” ,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
- 问题包含最大(小)或最多(少)等最优化描述。
-
求解步骤
- 描述决策
- 定义状态
- 建立DP表
- 推导状态转移方程
- 确定边界条件
-
暴力搜索:从上到下,在从下到上
-
记忆化搜搜:从上到下,再从下到上
-
动态规划:直接从下到上来解题
70. 爬楼梯
一维的动态规划问题,分解为子问题,然后从下向上解决
class Solution {
public int climbStairs(int n) {
if (n <= 2) {
return n;
}
int a = 1;
int b = 2;
for (int i = 3; i < n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return a + b;
}
}
198. 打家劫舍
一维动态规划,特点在于这里的动态转移方程的特点
class Solution {
public int rob(int[] nums) {
if (nums.length == 1) {
return nums[0];
}
if (nums.length == 2) {
return nums[0] > nums[1] ? nums[0] : nums[1];
}
// 这里动态规划
// 描述决策:
// 定义状态:dp[i] = max( dp[i-2] + nums[i], nums[i]
int a = nums[0];
int b = nums[0] > nums[1] ? nums[0] : nums[1];
for (int i = 2; i < nums.length; i++) {
int tmp = b;
b = Math.max(a + nums[i], b);
a = tmp;
}
return b;
}
}
第二次编码,看起来写法更加优雅一些,不过其实本质是相同的,而且,上面的解法使用滚动变量的方式,更加节省内存
class Solution {
public int rob(int[] nums) {
int len = nums.length;
int max = 0;
// dp数组
int[] dp = new int[len + 2];
dp[0] = 0;
dp[1] = 0;
for (int i = 2; i < len + 2; i++) {
int money = nums[i - 2];
dp[i] = Math.max(dp[i - 1], dp[i - 2] + money);
}
return dp[len + 1];
}
}
139. 单词拆分
第二次做出来了,这种题的套路都是一个一维的dp数组,这个题的思路是,dp动态记录是否能够被拼接
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
boolean[] dp = new boolean[s.length() + 1];
Set<String> set = new HashSet<>(wordDict);
dp[0] = true;
for (int i = 1; i < dp.length; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && set.contains(s.substring(j, i))) {
dp[i] = true;
}
}
}
return dp[s.length()];
}
}
322. 零钱兑换
解题思路:
1、维护一个数组,数组大小是amout的大小,然后,遍历每个可能的amount,去和银币比较如果小于银币的大小,那么需要的银币数量就是无穷大
2、如果大于=银币的大小,并且i-coin[j] 里面有值,那么说明可以凑出来
3、那么最小值就重新赋值为memo[i-coin[j] ] + 1
class Solution {
public int coinChange(int[] coins, int amount) {
// 自底向上的动态规划
if(coins.length == 0){
return -1;
}
// memo[n]的值: 表示的凑成总金额为n所需的最少的硬币个数
int[] memo = new int[amount+1];
memo[0] = 0;
for(int i = 1; i <= amount;i++){
int min = Integer.MAX_VALUE;
for(int j = 0;j < coins.length;j++){
if(i - coins[j] >= 0 && memo[i-coins[j]] < min){
min = memo[i-coins[j]] + 1;
}
}
// memo[i] = (min == Integer.MAX_VALUE ? Integer.MAX_VALUE : min);
memo[i] = min;
}
return memo[amount] == Integer.MAX_VALUE ? -1 : memo[amount];
}
}
300. 最长递增子序列
解题思路:
1、使用动态规划
初始化为值为1的数组
遍历元素
遍历从i=0到j的序列,如果i小于j的值,那么dpi = max(dp[i], dp[j] + 1)
// Dynamic programming.
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0)
return 0;
int[] dp = new int[nums.length];
int res = 0;
Arrays.fill(dp, 1);
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
res = Math.max(res, dp[i]);
}
return res;
}
}
2、第二个方法,使用动态规划和二分查找的方式
维护一个数组,这个数组是一个子序列
遍历元素,元素二分插入到这个子序列中
替换比他大的第一个元素
最后这个数组的大小就是子序列的大小
在已经构建的递增序列 d[1..len] 中,找到第一个大于等于 nums[i] 的位置,然后用 nums[i] 替换它。
int l = 1, r = len, pos = 0;
| 变量 | 含义 |
|---|---|
l |
二分查找的左边界(从 1 开始,因为 d[0] 没用到) |
r |
二分查找的右边界 |
pos |
记录最后一个满足 d[mid] < nums[i] 的位置,也就是 nums[i] 应该插入的位置的前一个 |
class Solution {
public int lengthOfLIS(int[] nums) {
int len = 1, n = nums.length;
if (n == 0) {
return 0;
}
int[] d = new int[n + 1];
d[len] = nums[0];
for (int i = 1; i < n; ++i) {
if (nums[i] > d[len]) {
d[++len] = nums[i];
} else {
int l = 1, r = len, pos = 0; // 如果找不到说明所有的数都比 nums[i] 大,此时要更新 d[1],所以这里将 pos 设为 0
while (l <= r) {
int mid = (l + r) >> 1;
if (d[mid] < nums[i]) {
pos = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
d[pos + 1] = nums[i];
}
}
return len;
}
}
120. 三角形最小路径和
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] f = new int[n][n];
f[0][0] = triangle.get(0).get(0);
for (int i = 1; i < n; ++i) {
f[i][0] = f[i - 1][0] + triangle.get(i).get(0);
for (int j = 1; j < i; ++j) {
f[i][j] = Math.min(f[i - 1][j - 1], f[i - 1][j]) + triangle.get(i).get(j);
}
f[i][i] = f[i - 1][i - 1] + triangle.get(i).get(i);
}
int minTotal = f[n - 1][0];
for (int i = 1; i < n; ++i) {
minTotal = Math.min(minTotal, f[n - 1][i]);
}
return minTotal;
}
}
二刷总结:
你的这段代码是用来求解三角形最小路径和的,但存在多处逻辑错误和边界处理问题,导致计算结果错误。下面逐一分析问题,并给出修正后的代码。
核心问题分析
1. 初始化错误:未累加路径和
matric[i][0] = triangle.get(i).get(0);
matric[i][i-1] = triangle.get(i).get(i-1);
这两行只赋值了当前层的数值,没有累加上层的路径和。例如:
- 第
i行第0列的路径只能来自上一行第0列,应该是matric[i-1][0] + triangle.get(i).get(0); - 第
i行最后一列(索引i)的路径只能来自上一行最后一列(索引i-1),但你错误地用了i-1作为列索引,且未累加。
2. 循环范围错误:中间列遍历不完整
for (int j = 1; j < i - 1; j++)
这个循环的终止条件 j < i - 1 会导致中间列完全遍历不到(比如 i=2 时,j < 1,循环直接跳过)。正确的中间列范围应该是 1 <= j < i(因为第 i 行有 i+1 个元素,索引 0~i)。
3. 最后一列处理错误
- 第
i行的最后一列索引是i(不是i-1),你漏处理了这一列,且赋值时未累加上层路径和。
4. 结果遍历范围错误
for (int i = 1; i < n; i++)
第 n-1 行的列索引范围是 0~n-1,但你的代码中 matric[i][i-1] 只处理到 n-2 列,最后一列(n-1)未赋值,遍历会读到默认值 0,导致结果错误。
修正后的代码
import java.util.List;
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
// 二维数组存储到每个位置的最小路径和
int[][] dp = new int[n][n];
// 初始化顶点
dp[0][0] = triangle.get(0).get(0);
// 从第二行开始遍历(i从1到n-1)
for (int i = 1; i < n; i++) {
// 第i行第0列:只能从上一行第0列来
dp[i][0] = dp[i-1][0] + triangle.get(i).get(0);
// 中间列:j从1到i-1,取上一行j-1和j的最小值 + 当前值
for (int j = 1; j < i; j++) {
dp[i][j] = Math.min(dp[i-1][j-1], dp[i-1][j]) + triangle.get(i).get(j);
}
// 第i行最后一列(索引i):只能从上一行最后一列(索引i-1)来
dp[i][i] = dp[i-1][i-1] + triangle.get(i).get(i);
}
// 遍历最后一行,找最小值
int minSum = dp[n-1][0];
for (int j = 1; j < n; j++) {
minSum = Math.min(minSum, dp[n-1][j]);
}
return minSum;
}
}
优化(空间复杂度从O(n²)→O(n))
由于计算第 i 行时只需要第 i-1 行的数据,因此可以用一维数组优化:
import java.util.List;
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[] dp = new int[n];
dp[0] = triangle.get(0).get(0);
for (int i = 1; i < n; i++) {
// 从后往前遍历,避免覆盖未使用的上一行数据
dp[i] = dp[i-1] + triangle.get(i).get(i); // 最后一列
for (int j = i-1; j > 0; j--) { // 中间列
dp[j] = Math.min(dp[j-1], dp[j]) + triangle.get(i).get(j);
}
dp[0] += triangle.get(i).get(0); // 第一列
}
int minSum = dp[0];
for (int j = 1; j < n; j++) {
minSum = Math.min(minSum, dp[j]);
}
return minSum;
}
}
测试用例验证
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
- 修正前代码结果错误,修正后结果为
11(路径:2→3→5→1),符合预期。
核心逻辑总结:
- 每个位置的最小路径和 = 上一行相邻位置的最小路径和 + 当前值;
- 边界列(第一列/最后一列)只有一个来源,中间列有两个来源;
- 最终结果是最后一行的最小值。
64. 最小路径和
自己做出来了,没看题解,这个题目比较简单,思路就是,第一行,一直累加即可,第一列的,直接累加即可,否则,取两种可能的最小值,再加上本单元格的值解决。
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (i == 0 && j == 0) {
continue;
}
if (i == 0) {
dp[i][j] = dp[i][j - 1] + grid[i][j];
continue;
}
if (j == 0) {
dp[i][j] = dp[i - 1][j] + grid[i][j];
continue;
}
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
63. 不同路径 II
动态规划
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] dp = new int[m + 1][n + 1];
dp[1][1] = obstacleGrid[0][0] == 1 ? 0 : 1;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (i == 1 && j == 1) {
continue;
}
if (obstacleGrid[i - 1][j - 1] == 1) {
dp[i][j] = 0;
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m][n];
}
}
5. 最长回文子串
元算法:回文串的判断方法
- 对称位置计算:len - i -1
- len/2 奇数,中间段不用比较,偶数全部比较
public boolean isPalindromic(String s) {
int len = s.length();
for (int i = 0; i < len / 2; i++) {
if (s.charAt(i) != s.charAt(len - i - 1)) {
return false;
}
}
return true;
}
中心指针方法
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) {
return "";
}
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i);
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
public int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
--left;
++right;
}
return right - left - 1;
}
}
作者:力扣官方题解
链接:https://leetcode.cn/problems/longest-palindromic-substring/solutions/255195/zui-chang-hui-wen-zi-chuan-by-leetcode-solution/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
这个算法的精妙之处分析
1. 核心洞察:回文的对称中心
关键理解 :
-
回文有两种形式:
- 奇数长度:
aba,中心是一个字符 - 偶数长度:
abba,中心是两个相同字符之间
- 奇数长度:
-
所以, 每个回文子串都有一个中心 (1个或2个字符)
2. 指针控制的艺术
2.1 中心扩展函数
int expandAroundCenter(String s, int left, int right) {
// 注意:left和right是中心位置
while (left >= 0 && right < s.length()
&& s.charAt(left) == s.charAt(right)) {
--left; // 向左扩展
++right; // 向右扩展
}
return right - left - 1; // 回文长度
}
精妙之处 :
- 统一处理奇偶 :
- 奇数:
expand(s, i, i)→ 从单个字符向两边 - 偶数:
expand(s, i, i+1)→ 从两个字符向两边
- 返回值计算 :
- 循环结束时,
left和right是不匹配的位置 - 回文长度 =
(right-1) - (left+1) + 1 = right - left - 1
2.2 起始位置计算
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
推导过程 :
-
对于奇数长度
len:- 起始位置:
i - (len-1)/2 - 结束位置:
i + (len-1)/2 = i + len/2(整数除法)
- 起始位置:
-
对于偶数长度
len:- 起始位置:
i - (len-1)/2 - 结束位置:
i + 1 + (len/2 - 1) = i + len/2
- 起始位置:
3. 如何锻炼出这种能力
3.1 思维训练步骤
第一步:从暴力法开始
// 最直接的想法:检查所有子串
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
if (isPalindrome(s, i, j)) {
// 记录最长
}
}
}
// 问题:O(n³),太慢
第二步:识别冗余计算
- 观察:回文是对称的
- 如果
s[i..j]是回文,那么s[i+1..j-1]也是回文 - 这引出了动态规划思路
第三步:寻找更优视角
- 动态规划O(n²)空间,还能更好吗?
- 换个角度:以每个位置为中心向外扩展
- 这样只需要O(1)空间
第四步:实现并优化
// 初步实现
for (int center = 0; center < n; center++) {
// 奇数情况
expand(center, center);
// 偶数情况
expand(center, center+1);
}
第五步:边界和索引计算
- 思考:循环结束时的指针是什么状态?
- 思考:如何从
left和right计算长度? - 思考:如何从
center和length计算子串边界?
4. 指针计算的推导练习
练习1:手动推导
字符串: "babad"
中心i=1(字符'a')
奇数扩展过程:
left=1, right=1 → 匹配
left=0, right=2 → s[0]='b', s[2]='b'匹配
left=-1, right=3 → 越界结束
长度 = 3 - (-1) - 1 = 3
练习2:边界计算
len = 5, i = 2
奇数情况:
start = 2 - (5-1)/2 = 2 - 2 = 0
end = 2 + 5/2 = 2 + 2 = 4
子串: s[0..4]
len = 4, i = 1
偶数情况:
start = 1 - (4-1)/2 = 1 - 1 = 0
end = 1 + 4/2 = 1 + 2 = 3
子串: s[0..3]
5. 算法思维的层次
层次1:暴力枚举
- 检查所有子串
- 思维:枚举所有可能
层次2:动态规划
- 利用子问题重叠
- 思维:空间换时间
层次3:中心扩展
- 利用对称性
- 思维:变换问题视角
层次4:Manacher算法
- 利用已知回文信息
- 思维:利用历史信息加速
6. 具体的锻炼方法
方法1:纸上推演
// 选一个例子,手动执行
s = "cbbd"
i=0: len1=1, len2=0, len=1
i=1: len1=1, len2=2, len=2
start=1-(2-1)/2=1, end=1+2/2=2 → "bb"
i=2: len1=1, len2=0, len=1
i=3: len1=1, len2=0, len=1
结果: "bb"
方法2:分步实现
// 第一步:只实现奇数扩展
int expandOdd(int center) {
int l = center, r = center;
while (l >= 0 && r < n && s[l]==s[r]) {
l--; r++;
}
return (r-1) - (l+1) + 1;
}
// 第二步:添加偶数扩展
int expandEven(int left, int right) {
// 类似
}
// 第三步:合并处理
方法3:可视化思考
奇数扩展: a b a
↑ ↑ ↑
left center right
偶数扩展: a b b a
↑ ↑
left right
中心是bb之间的空隙
7. 指针控制的通用模式
模式1:向两端扩展
int l = center, r = center; // 或 center, center+1
while (condition) {
l--; // 左移
r++; // 右移
}
// 结束时l,r是第一个不满足条件的位置
模式2:长度计算
// 方法1:记录开始和结束位置
int start = l+1, end = r-1;
int length = end - start + 1;
// 方法2:直接计算
int length = r - l - 1; // 本算法采用
8. 思维提升要点
8.1 从具体到抽象
具体问题:最长回文子串
↓
抽象特征:对称性
↓
数学建模:以每个点为中心向两侧扩展
↓
算法实现:双指针扩展
8.2 从简单到复杂
- 先解决判断回文
- 再找最长回文
- 从O(n³)优化到O(n²)
- 考虑奇偶性
- 边界计算
8.3 从模仿到创造
- 理解这个算法后
- 尝试自己推导边界计算公式
- 尝试用不同的方式实现
- 思考能否进一步优化
9. 总结
这个算法的精妙在于 :
- 视角转换 :从"找子串"变为"以每个点为中心扩展"
- 统一处理 :用同一逻辑处理奇偶两种情况
- 简洁计算 :
right-left-1巧妙计算长度 - 边界推导 :
i-(len-1)/2和i+len/2完美计算位置
如何锻炼 :
- 理解每个公式的推导过程
- 手动执行多个例子
- 尝试自己重新推导
- 应用到类似问题(如回文子序列)
终极目标 :看到问题时,能自然地想到对称性、中心扩展、双指针这些模式,而不需要死记硬背。这需要通过大量的练习和深度思考来培养算法直觉。
public class Solution {
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i..j] 是否是回文串
boolean[][] dp = new boolean[len][len];
// 初始化:所有长度为 1 的子串都是回文串
for (int i = 0; i < len; i++) {
dp[i][i] = true;
}
char[] charArray = s.toCharArray();
// 递推开始
// 先枚举子串长度
for (int L = 2; L <= len; L++) {
// 枚举左边界,左边界的上限设置可以宽松一些
for (int i = 0; i < len; i++) {
// 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
int j = L + i - 1;
// 如果右边界越界,就可以退出当前循环
if (j >= len) {
break;
}
if (charArray[i] != charArray[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substring(begin, begin + maxLen);
}
}
作者:力扣官方题解
链接:https://leetcode.cn/problems/longest-palindromic-substring/solutions/255195/zui-chang-hui-wen-zi-chuan-by-leetcode-solution/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
动态规划解法的精妙之处分析
1. 核心算法思想
状态转移方程 :
dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1]
边界条件 :
- 长度为1:
dp[i][i] = true - 长度为2:
dp[i][j] = (s[i] == s[j]) - 长度为3及以上:如上公式
2. 算法的精妙之处
2.1 递推顺序的巧妙设计
// 先枚举子串长度,再枚举左边界
for (int L = 2; L <= len; L++) { // 长度从小到大
for (int i = 0; i < len; i++) { // 左边界
int j = L + i - 1; // 右边界
// ...
}
}
精妙性 :
- 计算
dp[i][j]时需要知道dp[i+1][j-1] - 按长度递增的顺序,保证了子问题先被计算
2.2 空间换时间的平衡
- 空间:O(n²)的二维数组
- 时间:O(n²)的双重循环
- 在n≤1000时,这个平衡是可接受的
2.3 边界处理的优雅
if (j - i < 3) { // 长度≤3的情况
dp[i][j] = true; // 只要s[i]==s[j]就一定回文
} else {
dp[i][j] = dp[i + 1][j - 1]; // 看内部
}
- 长度=1:初始化已处理
- 长度=2或3:只要两端相等,就是回文
- 长度≥4:需要检查内部
3. 与中心扩展法的对比
| 维度 | 动态规划 | 中心扩展 |
|---|---|---|
| 思维方式 | 自底向上,递推 | 中心开花,扩展 |
| 计算顺序 | 按长度递增 | 按中心遍历 |
| 空间复杂度 | O(n²) | O(1) |
| 时间复杂度 | O(n²) | O(n²) |
| 代码复杂度 | 较高 | 较低 |
| 适用场景 | 教学/理解 | 实际使用 |
4. 如何锻炼出这种能力
4.1 思维训练路径
第一步:识别问题特征
最长回文子串
↓
回文 = 对称
↓
s[i..j]是回文 等价于 s[i]==s[j] 且 s[i+1..j-1]是回文
↓
存在重叠子问题 → 可用动态规划
第二步:设计状态定义
// 关键定义
dp[i][j]: s[i..j]是否是回文
// 为什么这样定义?因为回文判断是区间问题
第三步:推导状态转移
基础:dp[i][i] = true
递推:dp[i][j] = (s[i]==s[j]) && dp[i+1][j-1]
边界:j-i<3时,只需s[i]==s[j]
第四步:确定计算顺序
需要计算dp[i][j],先要计算dp[i+1][j-1]
→ 子区间更短
→ 按长度递增计算
4.2 具体训练方法
方法1:手动画表推导
字符串: "babad"
dp表(true=T, false=F):
长度L=1: (0,0)T, (1,1)T, (2,2)T, (3,3)T, (4,4)T
长度L=2: (0,1)F, (1,2)T, (2,3)F, (3,4)F
长度L=3: (0,2)T, (1,3)F, (2,4)F
长度L=4: (0,3)F, (1,4)F
长度L=5: (0,4)F
方法2:分步实现练习
// 1. 先写暴力解法
// 2. 发现重复计算
// 3. 设计dp状态
// 4. 写转移方程
// 5. 确定计算顺序
// 6. 优化边界
方法3:类比学习
- 最长回文子串 ←→ 最长回文子序列
- 区间DP ←→ 线性DP
- 二维DP ←→ 一维DP(空间优化)
5. 指针/索引控制的技巧
5.1 长度与边界的关系
int j = L + i - 1; // 核心公式
// 推导:j - i + 1 = L
// 所以 j = L + i - 1
5.2 提前终止优化
if (j >= len) {
break; // 右边界越界,后面i更大,j也更大,直接退出
}
5.3 边界统一处理
if (j - i < 3) { // 巧妙!
// 长度≤3:aba, aa, a
// 只要s[i]==s[j],一定是回文
}
6. 元认知能力提升
6.1 问题分解能力
原始问题:找最长回文子串
↓
子问题:判断每个子串是否回文
↓
更小子问题:判断更短子串是否回文
↓
基础问题:单个字符一定是回文
6.2 状态定义能力
- 思考:什么信息需要记录?
- 思考:如何用简洁的状态表示?
- 思考:状态之间如何转移?
6.3 计算顺序意识
// 错误顺序:先i后j
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
// 计算dp[i][j]时需要dp[i+1][j-1]
// 但dp[i+1][j-1]可能还没计算!
}
}
// 正确顺序:先长度后左边界
for (int L = 2; L <= n; L++) { // 长度递增
for (int i = 0; i < n; i++) { // 左边界
// 这样能保证子问题已计算
}
}
7. 算法思维的层次递进
层次1:暴力枚举(O(n³))
for i in 0..n-1
for j in i..n-1
if isPalindrome(s,i,j) // O(n)
update result
层次2:记忆化搜索(递归+记忆)
memo[i][j] = 是否计算过s[i..j]
function isPalindrome(i, j):
if memo[i][j] 已计算: return
if s[i]!=s[j]: return false
if j-i<3: return true
return isPalindrome(i+1, j-1)
层次3:动态规划(迭代)
dp[i][j] = (s[i]==s[j]) && (j-i<3 || dp[i+1][j-1])
按长度递增计算
层次4:空间优化
// 只存两行或一维数组
// 但顺序要倒着计算
8. 通用的动态规划思考框架
8.1 DP问题识别模式
- 最值问题 (最长/最短/最大/最小)
- 计数问题 (多少种方式)
- 存在性问题 (是否可行)
- 区间问题 (子串/子数组)
8.2 DP解题四步法
// 1. 定义状态
// dp[i][j]表示什么?
// 2. 状态转移
// dp[i][j] = ?
// 3. 初始化
// 基础情况是什么?
// 4. 计算顺序
// 先算哪个,后算哪个?
8.3 区间DP常用技巧
// 技巧1:枚举长度
for (int len = 1; len <= n; len++)
for (int i = 0; i+len-1 < n; i++)
// 技巧2:枚举中点
for (int i = n-1; i >= 0; i--)
for (int j = i+1; j < n; j++)
for (int k = i; k < j; k++) // 分割点
9. 从这道题学到的通用经验
9.1 回文问题的DP思维
判断s[i..j]是否回文
↓
看两端 + 看内部
↓
dp[i][j] = (s[i]==s[j]) && dp[i+1][j-1]
↓
按长度递增计算
9.2 边界处理的哲学
- 单独处理长度1、2、3的情况
- 用
j-i<3统一短长度情况 - 先处理简单情况,再处理复杂情况
9.3 代码实现的细节
// 细节1:char数组加速
char[] charArray = s.toCharArray();
// 比s.charAt(i)更快
// 细节2:及时break
if (j >= len) break;
// 减少无效循环
// 细节3:记录结果
if (dp[i][j] && j-i+1 > maxLen)
// 随时更新最优解
10. 总结锻炼方法
短期训练 :
- 手动推导3-5个例子的DP表
- 自己从头实现这个算法
- 尝试优化空间复杂度
- 对比中心扩展法,理解不同思路
长期提升 :
- 建立DP问题分类体系
- 总结常见状态定义模式
- 练习识别子问题重叠
- 培养计算顺序直觉
元认知要点 :
- 看到"最长/最短" → 想到DP
- 看到区间问题 → 想到二维DP
- 看到递推关系 → 确定计算顺序
- 先想出暴力解,再找优化点
这个DP解法体现了 系统化的算法设计思维 :从问题分析→状态定义→方程推导→实现优化。通过这种训练,你能培养出将复杂问题分解为可管理子问题的能力,这是算法能力的核心。
97. 交错字符串
我完全理解你的感受!交错字符串的指针确实容易搞晕。让我给你一个清晰的 记忆口诀 ,然后一步步解释如何搞对:
📌 记忆口诀
“dp的i是长度,字符串的索引要减一”
🔧 三步搞定指针
1. 理解 dp 数组的含义
dp[i][j] 表示:
- 使用 s1 的前 i 个字符
- 使用 s2 的前 j 个字符
- 能否组成 s3 的前 i+j 个字符
注意:i和 j表示 字符数量 ,从 0 到 len,不是索引!
2. 关键映射关系
s1 的第 i 个字符 → s1.charAt(i-1)
s2 的第 j 个字符 → s2.charAt(j-1)
s3 的第 (i+j) 个字符 → s3.charAt(i+j-1)
3. 可视化表格
索引映射表:
dp[i][j] 中的 i,j → 字符串实际索引
i=1, j=1 → s1[0], s2[0], s3[0+1-1] = s3[0]
i=2, j=3 → s1[1], s2[2], s3[2+3-1] = s3[4]
i=0, j=k → 只用s2,s2[k-1], s3[k-1]
🎯 实用检查清单
每次写代码时问自己:
- 长度检查 :
if (len1 + len2 != len3) return false; - dp定义 :
boolean[][] dp = new boolean[len1+1][len2+1]; - 初始化 :
dp[0][0] = true;- 第一行:
dp[0][j] = dp[0][j-1] && s2[j-1] == s3[j-1]; - 第一列:
dp[i][0] = dp[i-1][0] && s1[i-1] == s3[i-1];
- 状态转移 :
// 两种可能:最后一个字符来自s1或s2
boolean fromS1 = dp[i-1][j] && s1.charAt(i-1) == s3.charAt(i+j-1);
boolean fromS2 = dp[i][j-1] && s2.charAt(j-1) == s3.charAt(i+j-1);
dp[i][j] = fromS1 || fromS2;
💡 快速验证方法
用这个简单的例子测试你的代码:
s1 = "a"
s2 = "b"
s3 = "ab"
步骤:
1. dp[0][0] = true
2. 第一行:dp[0][1] = dp[0][0] && s2[0]='b' == s3[0]='a'? false
3. 第一列:dp[1][0] = dp[0][0] && s1[0]='a' == s3[0]='a'? true
4. dp[1][1]:
- fromS1: dp[0][1] && s1[0]='a' == s3[1]='b'? false
- fromS2: dp[1][0] && s2[0]='b' == s3[1]='b'? true
- 结果:true ✅
🚀 终极简化版代码
class Solution {
public boolean isInterleave(String s1, String s2, String s3) {
int m = s1.length(), n = s2.length();
if (m + n != s3.length()) return false;
boolean[][] dp = new boolean[m+1][n+1];
dp[0][0] = true;
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i > 0) {
dp[i][j] |= dp[i-1][j] && s1.charAt(i-1) == s3.charAt(i+j-1); // 用 |=保证:只要有一条路径能到,就是 true。
}
if (j > 0) {
dp[i][j] |= dp[i][j-1] && s2.charAt(j-1) == s3.charAt(i+j-1);
}
}
}
return dp[m][n];
}
}
📝 最后提醒
最常犯的错误 :
- 忘记
s3.charAt(i+j-1)中的-1 - 把
i直接当作索引,而不是i-1 - 循环边界不对(应该是
<= len而不是< len)
记住: dp的i表示长度,字符串索引要减一 。多写几遍,形成肌肉记忆就好了!
算法思路总结
该算法用于判断字符串 s3 是否是由字符串 s1 和 s2 交错组成的。其核心思想是使用动态规划(Dynamic Programming)来解决这个问题。以下是详细的思路总结:
1. 定义状态
- 使用一个二维布尔数组
dp,其中dp[i][j]表示s1的前i个字符和s2的前j个字符是否能交错组成s3的前i+j个字符。
2. 初始化
dp[0][0]被初始化为true,表示两个空字符串可以交错组成一个空字符串。
3. 状态转移
- 遍历
s1和s2的所有可能的前缀组合,更新dp数组:- 如果
i > 0,检查s1的当前字符是否与s3的对应字符匹配,并且dp[i-1][j]为true,则更新dp[i][j]: dp[i][j] |= (dp[i-1][j] \land (s1.charAt(i-1) == s3.charAt(i+j-1))) - 如果
j > 0,检查s2的当前字符是否与s3的对应字符匹配,并且dp[i][j-1]为true,则更新dp[i][j]: dp[i][j] |= (dp[i][j-1] and (s2.charAt(j-1) == s3.charAt(i+j-1)))
- 如果
4. 返回结果
- 最终,返回
dp[m][n],即s1和s2的全部字符是否能够交错组成s3。
复杂度分析
- 时间复杂度:O(m * n),其中
m和n分别是s1和s2的长度,因为我们需要填充整个dp数组。 - 空间复杂度:O(m * n),需要额外的空间来存储
dp数组。
总结
这个算法通过动态规划有效地解决了字符串交错的问题,利用状态转移方程减少了复杂度,使得问题的解决更加高效。
72. 编辑距离
定义dp
dp[i][j] 代表 word1 中前 i 个字符,变换到 word2 中前 j 个字符,最短需要操作的次数,i代表字符的数量,而不是索引的位置,这个要千万的注意。所以dp[i][0] = i
状态转移
状态转移
- 增,dp[i][j] = dp[i][j - 1] + 1
- 删,dp[i][j] = dp[i - 1][j] + 1
- 改,dp[i][j] = dp[i - 1][j - 1] + 1
按顺序计算,当计算 dp[i][j] 时,dp[i - 1][j] , dp[i][j - 1] , dp[i - 1][j - 1] 均已经确定了
配合增删改这三种操作,需要对应的 dp 把操作次数加一,取三种的最小
如果刚好这两个字母相同 word1[i - 1] = word2[j - 1] ,那么可以直接参考 dp[i - 1][j - 1] ,操作不用加一
作者:Ikaruga
链接:https://leetcode.cn/problems/edit-distance/solutions/189676/edit-distance-by-ikaruga/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
class Solution {
public int minDistance(String word1, String word2) {
int n = word1.length();
int m = word2.length();
// 有一个字符串为空串
if (n * m == 0) {
return n + m;
}
// DP 数组
int[][] D = new int[n + 1][m + 1];
// 边界状态初始化
for (int i = 0; i < n + 1; i++) {
D[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
D[0][j] = j;
}
// 计算所有 DP 值
for (int i = 1; i < n + 1; i++) {
for (int j = 1; j < m + 1; j++) {
int left = D[i - 1][j] + 1;
int down = D[i][j - 1] + 1;
int left_down = D[i - 1][j - 1];
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
left_down += 1;
}
D[i][j] = Math.min(left, Math.min(down, left_down));
}
}
return D[n][m];
}
}
221. 最大正方形

class Solution {
public int maximalSquare(char[][] matrix) {
int m = matrix.length;
int n = matrix[0].length;
int[][] dp = new int[m + 1][n + 1];
int max = 0;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
int min = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]);
if (matrix[i - 1][j - 1] == '1') {
dp[i][j] = min + 1;
}
if (dp[i][j] > max) {
max = dp[i][j];
}
}
}
return max * max;
}
}

浙公网安备 33010602011771号