动态规划刷题总结

动态规划算法描述

动态规划(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),符合预期。

核心逻辑总结:

  1. 每个位置的最小路径和 = 上一行相邻位置的最小路径和 + 当前值;
  2. 边界列(第一列/最后一列)只有一个来源,中间列有两个来源;
  3. 最终结果是最后一行的最小值。

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. 核心洞察:回文的对称中心

关键理解

  • 回文有两种形式:

    1. 奇数长度:aba,中心是一个字符
    2. 偶数长度: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;  // 回文长度
}

精妙之处

  1. 统一处理奇偶
  • 奇数:expand(s, i, i)→ 从单个字符向两边
  • 偶数:expand(s, i, i+1)→ 从两个字符向两边
  1. 返回值计算
  • 循环结束时,leftright不匹配的位置
  • 回文长度 = (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);
}

第五步:边界和索引计算

  • 思考:循环结束时的指针是什么状态?
  • 思考:如何从 leftright计算长度?
  • 思考:如何从 centerlength计算子串边界?

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 从简单到复杂

  1. 先解决判断回文
  2. 再找最长回文
  3. 从O(n³)优化到O(n²)
  4. 考虑奇偶性
  5. 边界计算

8.3 从模仿到创造

  • 理解这个算法后
  • 尝试自己推导边界计算公式
  • 尝试用不同的方式实现
  • 思考能否进一步优化

9. 总结

这个算法的精妙在于

  1. 视角转换 :从"找子串"变为"以每个点为中心扩展"
  2. 统一处理 :用同一逻辑处理奇偶两种情况
  3. 简洁计算right-left-1巧妙计算长度
  4. 边界推导i-(len-1)/2i+len/2完美计算位置

如何锻炼

  1. 理解每个公式的推导过程
  2. 手动执行多个例子
  3. 尝试自己重新推导
  4. 应用到类似问题(如回文子序列)

终极目标 :看到问题时,能自然地想到对称性、中心扩展、双指针这些模式,而不需要死记硬背。这需要通过大量的练习和深度思考来培养算法直觉。

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问题识别模式

  1. 最值问题 (最长/最短/最大/最小)
  2. 计数问题 (多少种方式)
  3. 存在性问题 (是否可行)
  4. 区间问题 (子串/子数组)

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. 总结锻炼方法

短期训练

  1. 手动推导3-5个例子的DP表
  2. 自己从头实现这个算法
  3. 尝试优化空间复杂度
  4. 对比中心扩展法,理解不同思路

长期提升

  1. 建立DP问题分类体系
  2. 总结常见状态定义模式
  3. 练习识别子问题重叠
  4. 培养计算顺序直觉

元认知要点

  • 看到"最长/最短" → 想到DP
  • 看到区间问题 → 想到二维DP
  • 看到递推关系 → 确定计算顺序
  • 先想出暴力解,再找优化点

这个DP解法体现了 系统化的算法设计思维 :从问题分析→状态定义→方程推导→实现优化。通过这种训练,你能培养出将复杂问题分解为可管理子问题的能力,这是算法能力的核心。

97. 交错字符串

我完全理解你的感受!交错字符串的指针确实容易搞晕。让我给你一个清晰的 记忆口诀 ,然后一步步解释如何搞对:

📌 记忆口诀

“dp的i是长度,字符串的索引要减一”

🔧 三步搞定指针

1. 理解 dp 数组的含义

dp[i][j] 表示:
- 使用 s1 的前 i 个字符
- 使用 s2 的前 j 个字符
- 能否组成 s3 的前 i+j 个字符

注意:ij表示 字符数量 ,从 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]

🎯 实用检查清单

每次写代码时问自己:

  1. 长度检查if (len1 + len2 != len3) return false;
  2. dp定义boolean[][] dp = new boolean[len1+1][len2+1];
  3. 初始化
  • 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];
  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];
    }
}

📝 最后提醒

最常犯的错误

  1. 忘记 s3.charAt(i+j-1)中的 -1
  2. i直接当作索引,而不是 i-1
  3. 循环边界不对(应该是 <= len而不是 < len

记住: dp的i表示长度,字符串索引要减一 。多写几遍,形成肌肉记忆就好了!

算法思路总结

该算法用于判断字符串 s3 是否是由字符串 s1s2 交错组成的。其核心思想是使用动态规划(Dynamic Programming)来解决这个问题。以下是详细的思路总结:

1. 定义状态

  • 使用一个二维布尔数组 dp,其中 dp[i][j] 表示 s1 的前 i 个字符和 s2 的前 j 个字符是否能交错组成 s3 的前 i+j 个字符。

2. 初始化

  • dp[0][0] 被初始化为 true,表示两个空字符串可以交错组成一个空字符串。

3. 状态转移

  • 遍历 s1s2 的所有可能的前缀组合,更新 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],即 s1s2 的全部字符是否能够交错组成 s3

复杂度分析

  • 时间复杂度:O(m * n),其中 mn 分别是 s1s2 的长度,因为我们需要填充整个 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. 最大正方形

img

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;
    }
}
posted @ 2025-12-15 22:51  coder江  阅读(34)  评论(0)    收藏  举报