dp可能简单算法中的难度天花板了,但dp并非完全摸不头脑,下面我们从简单题入手来讲解dp
开头便抛出dp的终极奥义:除了初始化之外,dp的每一步递推都是一个一般性的问题,只需要考虑——如果这一步就要得到结果,我需要做什么?
线性dp:从一口气头推到尾,各个子问题相对独立
线性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的思想可以解题,并为贪心的思路提供基础。
稍复杂是指: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
// 对于任意的 i > j
if (nums[i] > nums[j]) { // 产生了递增
if (length[j] + 1 == length[i]) { // 若此递增与当前维护的"以i结尾的最长递增子序列"的长度相等
count[i] += count[j];
} else if (length[j] + 1 > length[i]) { // 若此递增使"以i结尾的最长递增子序列"的长度增加
length[i] = length[j] + 1; // 则更新以i结尾的最大长度
count[i] = count[j]; // 由于以i结尾的最大长度改变了,计数也需要改变
}
}
但由于要找到全局的最大长度,需要维护全局的最大长度maxlen
,同时维护ans
来记录长度为maxlen
的递增子序列有多少个
if (length[i] > maxlen) { // 如果此次"以i结尾的最长递增子序列"的长度 > 最大长度
maxlen = length[i];
ans = count[i]; // 最大长度发生改变,计数也要改变
} else if (length[i] == maxlen) { // 如果此次"以i结尾的最长递增子序列"的长度 == 最大长度
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++) // 对j的for循环来修改i的相关变量
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) { // 对i的for循环来修改全局变量
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 最大矩形
更复杂是指:dp的定义模糊不清,子问题之间的转化更加难找
实战题目:
LeetCode 剑指Offer60 n个骰子的点数
字符串的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)例题: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++) // 对应的是s的下标
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 单词拆分
一旦字符串类问题复杂了起来,我们常常忘记的是空串的力量,如果在递推时想到借用空串,难度会降低
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++) // 让p与空串进行匹配
if (p.charAt(i) == '*')
dp[0][i + 1] = dp[0][i - 1];
2)实战题目:
LeetCode 97 交错字符串
LeetCode 44 通配符匹配
对于复杂的dp,最理想的方法是画表
1)例题:LeetCode 72 编辑距离
LeetCode 72 编辑距离 官方题解
官方题解的表的动画做得很好,这里不重复画了
dp:
dp[i][j]
表示s
从0 ~ i
,t
从0 ~ j
的匹配情况这里解释一下递推公式
dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1])
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 不同的子序列