dp可能简单算法中的难度天花板了,但dp并非完全摸不头脑,下面我们从简单题入手来讲解dp 开头便抛出dp的终极奥义:除了初始化之外,dp的每一步递推都是一个一般性的问题,只需要考虑——如果这一步就要得到结果,我需要做什么?
一、常规线性dp
线性dp:从一口气头推到尾,各个子问题相对独立
线性dp一般比较明显的看出递推公式,并且比较容易优化
1、简单的线性dp
简单是指:dp的定义简单,子问题容易找,但实现起来并不简单,有些细节需要注意 1)例题:LeetCode 322 零钱兑换 dp定义比较清晰
- 维护数组
dp ,dp[i] 表示以i 为金额时有最少的硬币个数
想法有些类似于贪心,对coins[] 中的任意零钱面额coin ,只要i - coin > 0 就说明有新的组合,我们只要尝试让dp[i] 的硬币数最少即可
dp[i] = Math.min(dp[i], dp[i - coin] + 1)
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, (int)1e9);
dp[0] = 0;
for (int coin : coins)
for (int i = coin; i <= amount; i++)
dp[i] = Math.min(dp[i - coin] + 1, dp[i]);
return dp[amount] == (int)1e9 ? -1 : dp[amount];
}
2)实战题目 还有非常多类似的题目可以自己去刷,这里只选出典型的题目 LeetCode 63 不同路径Ⅱ LeetCode 120 三角形最小路径和 LeetCode 53 最大子序和 LeetCode 413 等差数列划分 LeetCode 300 最长递增子序列 LeetCode 剑指Offer46 把数字翻译成字符串 LeetCode 45 跳跃游戏Ⅱ LeetCode 983 最低价票 其中一些题,dp并非是最优解,但dp的思想可以解题,并为贪心的思路提供基础。
2、稍复杂的线性dp
稍复杂是指:dp定义稍许复杂,子问题并非十分清晰
1)题目一:LeetCode 152 乘积最大子数组 若只维护一个dp便得不到正确答案,当前的max不一定能推出下一个max 本题要求乘积最大,我们知道,两个较大的正数相乘得到大的正数,两个较小负数相乘也会的到大的正数。
所以定义两个dp数组:
- 维护数组
max ,max[i] 表示以i 结尾乘积最大的子数组 - 维护数组
min ,min[i] 表示以i 结尾乘积最小的子数组
当max[i] 遇到较大的负数,便可能转化为min[i] ,同理当min[i] 遇到较小的负数,便可能转化位max[i]
递推公式:
max[i] = Math.max(nums[i], Math.max(max[i - 1] * nums[i], min[i - 1] * nums[i])) min[i] = Math.min(nums[i], Math.min(max[i - 1] * nums[i], min[i - 1] * nums[i]))
public int maxProduct(int[] nums) {
int[] max = new int[nums.length];
int[] min = new int[nums.length];
int ans = 0;
ans = max[0] = min[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
max[i] = Math.max(nums[i], Math.max(max[i - 1] * nums[i], min[i - 1] * nums[i]));
min[i] = Math.min(nums[i], Math.min(max[i - 1] * nums[i], min[i - 1] * nums[i]));
ans = Math.max(ans, max[i]);
}
return ans;
}
2)题目二:LeetCode 673 最长递增子序列的个数 利用dp可以找到最长递增子序列,只是加上了个数的需求,所以我们便多维护一个dp数组
- 维护数组
length ,length[i] 表示以i 结尾的最长递增子序列长度 - 维护数组
count ,count[i] 表示以i 结尾的醉成递增子序列的个数
数组定义出来之后便很好的找到递推方程:长度等于当前维护的最大长度则使count 增加,长度大于当前维护的最大长度,则修改count
if (nums[i] > nums[j]) {
if (length[j] + 1 == length[i]) {
count[i] += count[j];
} else if (length[j] + 1 > length[i]) {
length[i] = length[j] + 1;
count[i] = count[j];
}
}
但由于要找到全局的最大长度,需要维护全局的最大长度maxlen ,同时维护ans 来记录长度为maxlen 的递增子序列有多少个
if (length[i] > maxlen) {
maxlen = length[i];
ans = count[i];
} else if (length[i] == maxlen) {
ans += count[i];
}
换句话说,对j 的for循环来修改i 的相关变量,对i 的for循环来修改全局变量
public int findNumberOfLIS(int[] nums) {
int len = nums.length;
int ans = 1;
int maxlen = 1;
int[] count = new int[len];
int[] length = new int[len];
Arrays.fill(length, 1);
Arrays.fill(count, 1);
for (int i = 1; i < len; i++) {
for (int j = 0; j < i; j++)
if (nums[i] > nums[j]) {
if (length[i] < length[j] + 1) {
length[i] = length[j] + 1;
count[i] = count[j];
} else if (length[i] == length[j] + 1) {
count[i] += count[j];
}
}
if (length[i] > maxlen) {
maxlen = length[i];
ans = count[i];
} else if (length[i] == maxlen) {
ans += count[i];
}
}
return ans;
}
3)实战题目 LeetCode 376 摆动序列 LeetCode 1567 乘积为正数的最长子数组长度 LeetCode 221 最大正方形 LeetCode 96 不同的二叉搜索树 LeetCode 85 最大矩形
3、复杂的线性dp
更复杂是指:dp的定义模糊不清,子问题之间的转化更加难找 实战题目: LeetCode 剑指Offer60 n个骰子的点数
二、字符串类线性dp专讲
字符串的dp一般是颇为头疼的,但其实本质仍是dp,而且其dp的定义可能比其他类型更为简单,一般字符串dp分成两种:
若给定一个字符串s : dp[i] 表示从0 ~ i 的题目所求变量 或 表示从i ~ s.len 的题目所求变量 dp[i][j] 表示从i ~ j 的题目所求变量 若给定两个字符串s 和t : dp[i][j] 表示s 从0 ~ i ,t 从0 ~ j 的匹配情况 或 表示s 从i ~ s.len ,t 从j ~ t.len 的匹配情况
然后去寻找递推方程,找到两个子问题之间的连接方式即可
1、简单的字符串线性dp
1)例题:LeetCode 32 最长有效括号 dp的定义比较清晰:
- 维护数组
dp ,dp[i] 表示以i 结尾的子串最长有效括号的长度
我们知道,一段有效括号,必然是以) 结尾的 所以可以找到递推方程:
- 若
) 的前面是( ,则左右括号刚好凑成一对,dp[i] = dp[i - 2] + 2 ,也就是当前这一对括号的长度 + 这一对括号之前已经配对了的长度 - 若
) 的前面是) ,则当前的) 需要绕过其前方的一段有效括号去寻找( ,而这一段有效括号的长度为dp[i - 1] ,绕过这一段长度后,我们希望在i - dp[i - 1] - 1 的位置找到( ,如果找到了:dp[i] = i - ((i - dp[i - 1] - 1) + 1) + dp[i - dp[i - 1] - 2]
下面的代码中,dp的长度多开了一位,所以s.charAt(i) 对应的是dp[i + 1]
public int longestValidParentheses(String s) {
int len = s.length();
int[] dp = new int[len + 1];
int ans = 0;
for (int i = 1; i < len; i++)
if (s.charAt(i) == ')') {
if (s.charAt(i - 1) == '(')
dp[i + 1] = dp[i - 1] + 2;
else if (i - dp[i] - 1 >= 0 && s.charAt(i - dp[i] - 1) == '(')
dp[i + 1] = dp[i] + 2 + dp[i - dp[i] - 1];
ans = Math.max(ans, dp[i + 1]);
}
return ans;
}
2)实战题目: LeetCode 5 最长回文子串 LeetCode 139 单词拆分
2、稍复杂的字符串线性dp
一旦字符串类问题复杂了起来,我们常常忘记的是空串的力量,如果在递推时想到借用空串,难度会降低 1)例题:LeetCode 10 正则表达式匹配 先定义dp,由于有两个字符串,则采用二维数组
- 维护数组
dp ,dp[i][j] 表示s 从0 ~ i ,t 从0 ~ j 的匹配情况
子问题其实很好找,如果当前的i 与j 匹配上了,则寻找dp[i - 1][j - 1] 的匹配情况 i 表示s的下标,j 表示p的下标
①若j 与i 字符相同,或j 为'.' ,则匹配成功 if (charAt(j) == '.' or charAt(i)) dp[i][j] = dp[i - 1][j - 1] 这种情况很好解释,在没有'*' 时,要么是p中的字符匹配上了,要么是'.' 匹配上了 且本次的dp[i][j] 与dp[i - 1][j - 1] 之间挂钩
②若j 为'*' ,则要么'*' 使得其前面的字符消失,要么'*' 复制前面的字符 if (charAt(j) == '*') dp[i][j] = dp[i][j - 2] || (charAt(j - 1) == '.' or charAt(i) && dp[i - 1][j]) dp[i][j - 2] 表示跳过当前的'*' 及其前面的字符 charAt(j - 1) == '.' or charAt(i) && dp[i - 1][j] 表示若'*' 前的字符与i 对应的字符匹配,则复制。'*' 对前面的字符复制可以视作i 对应的字符被删去
递推的方法体,dp的长度多开了一位,所以s.charAt(i) 对应的是dp[i + 1]
boolean[][] dp = new boolean[slen + 1][plen + 1];
char pre = '0';
for (int i = 0; i < slen; i++) {
char c = s.charAt(i);
for (int j = 0; j < plen; j++) {
char cur = p.charAt(j);
if (cur == '.' || cur == c)
dp[i + 1][j + 1] = dp[i][j];
else if (cur == '*')
dp[i + 1][j + 1] = dp[i + 1][j - 1] || ((pre == '.' || pre == c) && dp[i][j + 1]);
pre = cur;
}
}
还有一步重要的:需要让字符串p与空串进行匹配来保证'*' 的使用有效性,因为'*' 无论是删去其前方的字符,还是复制其前方的字符,在dp时都是在削减匹配长度,为防止匹配时s或p被削减至空,需额外的初始化
dp[0][0] = true;
for (int i = 0; i < plen; i++)
if (p.charAt(i) == '*')
dp[0][i + 1] = dp[0][i - 1];
2)实战题目: LeetCode 97 交错字符串 LeetCode 44 通配符匹配
3、复杂的字符串线性dp
对于复杂的dp,最理想的方法是画表 1)例题:LeetCode 72 编辑距离
LeetCode 72 编辑距离 官方题解 官方题解的表的动画做得很好,这里不重复画了
dp:
dp[i][j] 表示s 从0 ~ i ,t 从0 ~ j 的匹配情况
这里解释一下递推公式
- 若 A 和 B 的最后一个字母相同:
dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1]) - 若 A 和 B 的最后一个字母不同:
dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1)
增和删时相对的,对word1的删除就等于对word2的增加
dp[i][j] 总是可以由dp[i - 1][j] 在i 处增一位得到,也总是可以由dp[i][j - 1] 在j 处增一位得到- 如果A 和 B 的最后一个字母相同:那么相当于只需要考虑
dp[i - 1][j - 1] - 如果A 和 B 的最后一个字母不同:相当于在
dp[i - 1][j - 1] 之上多了一步,将他们最后一位换成相同的字母
public int minDistance(String word1, String word2) {
int len1 = word1.length();
int len2 = word2.length();
if (len1 * len2 == 0)
return len1 + len2;
int[][] dp = new int[len1 + 1][len2 + 1];
for (int i = 1; i <= len1; i++)
dp[i][0] = i;
for (int i = 1; i <= len2; i++)
dp[0][i] = i;
for (int i = 1; i <= len1; i++) {
char c1 = word1.charAt(i - 1);
for (int j = 1; j <= len2; j++) {
char c2 = word2.charAt(j - 1);
if (c1 == c2)
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1] - 1, Math.min(dp[i - 1][j], dp[i][j - 1]));
else
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1]));
}
}
return dp[len1][len2];
}
2)实战题目: LeetCode 115 不同的子序列
|