深入浅出Java算法 动态规划
动态规划(DP)是算法面试中最常考也最难掌握的部分,今天我就用最通俗的大白话,帮你彻底搞懂动态规划的套路。
一、动态规划是啥?
动态规划就像你打游戏时的存档点:
- 把大问题拆成小问题(关卡分解)
- 记住已经解决过的小问题(存档)
- 遇到相同小问题直接读取结果(读档)
- 避免重复计算(省时间)
二、动态规划三大特征
- 重叠子问题:问题可以分解,且子问题会重复出现
- 最优子结构:大问题的最优解可以由小问题的最优解推出
- 状态转移方程:明确怎么从小问题推导出大问题
三、动态规划解题四步走
1. 定义状态(开什么存档位)
// 通常用数组表示状态
int[] dp = new int[n+1];
2. 初始化状态(初始存档)
dp[0] = 0; // 根据问题设置初始值
dp[1] = 1;
3. 状态转移方程(关卡攻略)
// 斐波那契数列的例子
dp[i] = dp[i-1] + dp[i-2];
4. 返回结果(最终BOSS掉落)
return dp[n];
四、高频DP问题套路
1. 爬楼梯问题
问题:每次爬1或2阶,到n阶有几种方法?
public int climbStairs(int n) {
if (n <= 2) return n;
int[] dp = new int[n+1];
dp[1] = 1; // 1阶1种方法
dp[2] = 2; // 2阶2种方法
for (int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 从i-1爬1阶或从i-2爬2阶
}
return dp[n];
}
// 空间优化版(只用3个变量)
public int climbStairsOpt(int n) {
if (n <= 2) return n;
int a = 1, b = 2, c = 0;
for (int i = 3; i <= n; i++) {
c = a + b;
a = b;
b = c;
}
return c;
}
白话解释:
- 到第n阶的方法数 = 到n-1阶的方法数(再爬1阶) + 到n-2阶的方法数(再爬2阶)
2. 打家劫舍问题
问题:不能偷相邻房子,求最大收益
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
// 选择:不偷当前(取前一个最大值) 或 偷当前(前前个最大值+当前值)
}
return dp[nums.length - 1];
}
// 空间优化版
public int robOpt(int[] nums) {
int prev = 0, curr = 0;
for (int num : nums) {
int temp = curr;
curr = Math.max(curr, prev + num);
prev = temp;
}
return curr;
}
白话解释:
- 每到一家店,选择:偷这家(加上前前家的最大值)或者不偷(保持前家的最大值)
3. 零钱兑换问题
问题:用最少的硬币凑出金额
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1); // 初始化为不可能的大值
dp[0] = 0; // 0元需要0个硬币
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
白话解释:
- 对于每个金额,尝试每种硬币,看用这个硬币后剩下的金额需要多少硬币
4. 最长递增子序列(LIS)
问题:找出数组中最长的递增子序列长度
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] dp = new int[nums.length];
Arrays.fill(dp, 1); // 每个元素本身就是一个子序列
int maxLen = 1;
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
白话解释:
- 对于每个数,看前面比它小的数能组成多长的序列,然后+1
5. 编辑距离问题
问题:把一个单词变成另一个单词的最少操作次数(增删改)
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m+1][n+1];
// 初始化:空串变空串0次,变i个字符需要i次操作
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i-1) == word2.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1]; // 字符相同,不需要操作
} else {
dp[i][j] = 1 + Math.min(
dp[i-1][j], // 删除word1的字符
Math.min(
dp[i][j-1], // 插入word2的字符
dp[i-1][j-1] // 替换字符
)
);
}
}
}
return dp[m][n];
}
白话解释:
- 两个字符相同:继承左上角的值
- 不同:取左(插入)、上(删除)、左上(替换)中的最小值+1
五、动态规划优化技巧
- 空间压缩:如果当前状态只依赖前几个状态,可以用滚动数组
// 二维DP优化为一维
int[] dp = new int[n];
// 替代
int[][] dp = new int[m][n];
- 备忘录法:自顶向下递归+记忆化,适合不容易找到迭代顺序的问题
int[] memo = new int[n+1];
Arrays.fill(memo, -1);
int helper(int n) {
if (memo[n] != -1) return memo[n];
// 计算并存储
memo[n] = helper(n-1) + helper(n-2);
return memo[n];
}
- 打印路径:有时候需要记录选择路径而不仅是结果
// 额外数组记录选择
int[] choice = new int[n];
六、必刷DP题目清单
-
简单难度:
- LeetCode 70:爬楼梯
- LeetCode 198:打家劫舍
- LeetCode 746:使用最小花费爬楼梯
-
中等难度:
- LeetCode 322:零钱兑换
- LeetCode 300:最长递增子序列
- LeetCode 1143:最长公共子序列
- LeetCode 416:分割等和子集
-
困难难度:
- LeetCode 72:编辑距离
- LeetCode 312:戳气球
- LeetCode 10:正则表达式匹配
记住:动态规划就是"聪明的递归",先想清楚暴力解法,再找重复子问题,最后用记忆化优化。多练习各种状态转移方程,面试时就能游刃有余!