本文已参与「新人创作礼」活动,一起开启掘金创作之路。
学习动态规划问题要格外注意这⼏个词:状态」,「选择」,「dp 数组的定义。你把这⼏个词理解到位了,就理解了动态规划的核⼼。
1.最长递增子序列问题M300
题目描述
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
🌸「示例:」
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
解答
最⻓递增⼦序列(Longest Increasing Subsequence,简写 LIS)是⾮常经典的⼀个算法问题,⽐较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅⼊深讲解如何找状态转移⽅程,如何写出动态规划解法。
解法一:动态规划解法
动态规划的核⼼设计思想是数学归纳法。
⽐如我们想证明⼀个数学结论,那么我们先假设这个结论在 k<n 时成⽴,然后根据这个假设,想办法推导证明出 k=n 的时候此结论也成⽴。如果能够证明出来,那么就说明这个结论对于 k 等于任何数都成⽴。
类似的,我们设计动态规划算法,不是需要⼀个 dp 数组吗?我们可以假设 dp[0...i-1] 都已经被算出来了,然后问⾃⼰:怎么通过这些结果算出 dp[i]?
我们定义:dp[i] 表示以 nums[i] 这个数结尾的最⻓递增⼦序列的⻓度。
dp 数组的定义⽅法也就那⼏种。
假设我们已经知道了 dp[0..4] 的所有结果,我们如何通过这些已知结果推出 dp[5] 呢?
nums[5] = 3,既然是递增⼦序列,我们只要找到前⾯那些结尾⽐ 3 ⼩的⼦序列,然后把 3 接到最后,就可以形成⼀个新的递增⼦序列。
class Solution300 {
public int lengthOfLIS(int[] nums) {
// (1)定义dp数组:dp[i] 表示以 nums[i] 这个数结尾的最⻓递增⼦序列的⻓度。
int length = nums.length;
int[] dp = new int[length];
Arrays.fill(dp, 1);
// (3)数学归纳法
for (int i = 0; i < length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]){
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
// (2)最后返回的是
//int count = 1;
//for (int i : dp) {
// if (i > count){
// count = i;
// }
//}
Arrays.sort(dp);
return dp[dp.length -1];
}
}
时间复杂度 O(N^2)。
解法二:二分查找解法
⽐如说上述的扑克牌最终会被分成这样 5 堆(我们认为纸牌 A 的牌⾯是最⼤的,纸牌 2 的牌⾯是最⼩的)。
2.最大子数组和问题 E53
题目描述
给你一个整数数组
nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
🌸「示例:」
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:
输入:nums = [1]
输出:1
示例 3:
输入:nums = [5,4,-1,7,8]
输出:23
解答
解法一:动态规划解法
-
dp 数组的定义
-
返回最大子数组和
-
使用数学归纳法来找状态转移关系
class Solution53 {
public int maxSubArray(int[] nums) {
//(1)定义dp数组:dp[i]表示以nums[i]结尾的最大和连续子数组
int[] dp = new int[nums.length];
Arrays.fill(dp, Integer.MIN_VALUE);
// base case
dp[0] = nums[0];
//(3)状态转移
for (int i = 1; i < nums.length; i++) {
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
}
//(2)返回
System.out.println("dp[]: " + Arrays.toString(dp));
Arrays.sort(dp);
return dp[dp.length - 1];
}
}
时间复杂度是 O(N),空间复杂度也是 O(N)。
较暴⼒解法已经很优秀了,不过注意到 dp[i] 仅仅和 dp[i-1] 的状态有关,那么我们可以进⾏「状态压缩」,将空间复杂度降低:
总结一下
「最⼤⼦数组和」就和「最⻓递增⼦序列」⾮常类似,dp 数组的定义是「以 nums[i] 为结尾的最⼤⼦数组和/最⻓递增⼦序列为 dp[i]」。因为只有这样定义才能将 dp[i+1] 和 dp[i] 建⽴起联系,利⽤数学归纳法写出状态转移⽅程。
3.最长公共子序列问题M1143
题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。 两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
🌸「示例:」
示例 1:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace" ,它的长度为 3 。
示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。
示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。
解答
正确的思路是不要考虑整个字符串,⽽是细化到 s1 和 s2 的每个字符。
对于两个字符串求子序列的问题,都是用两个指针i和j分别在两个字符串上移动,大概率是动态规划思路。
最长公共子序列的问题也可以遵循这个规律,我们可以先写一个dp函数:
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j)
这个dp函数的定义是:dp(s1, i, s2, j)计算s1[i..]和s2[j..]的最长公共子序列长度。
根据这个定义,那么我们想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)或j == len(s2)时,因为这时候s1[i..]或s2[j..]就相当于空串了,最长公共子序列的长度显然是 0:
int longestCommonSubsequence(String s1, String s2) {\
return dp(s1, 0, s2, 0);\
}\
\
/* 主函数 */\
int dp(String s1, int i, String s2, int j) {\
// base case\
if (i == s1.length() || j == s2.length()) {\
return 0;\
}\
// ...
接下来,咱不要看s1和s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么。
我们只看s1[i]和s2[j],如果s1[i] == s2[j],说明这个字符一定在lcs中:
这样,就找到了一个lcs中的字符,根据dp函数的定义,我们可以完善一下代码:
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中,
// 加上 s1[i+1..] 和 s2[j+1..] 中的 lcs 长度,就是答案
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// ...
}
}
刚才说的s1[i] == s2[j]的情况,但如果s1[i] != s2[j],应该怎么办呢?
s1[i] != s2[j]意味着,s1[i]和s2[j]中至少有一个字符不在lcs中:
如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?
其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
if (s1.charAt(i) == s2.charAt(j)) {
return 1 + dp(s1, i + 1, s2, j + 1)
} else {
// s1[i] 和 s2[j] 中至少有一个字符不在 lcs 中,
// 穷举三种情况的结果,取其中的最大结果
return max(
// 情况一、s1[i] 不在 lcs 中
dp(s1, i + 1, s2, j),
// 情况二、s2[j] 不在 lcs 中
dp(s1, i, s2, j + 1),
// 情况三、都不在 lcs 中
dp(s1, i + 1, s2, j + 1)
);
}
}
这里就已经非常接近我们的最终答案了,还有一个小的优化,情况三「s1[i]和s2[j]都不在 lcs 中」其实可以直接忽略。
因为我们在求最大值嘛,情况三在计算
s1[i+1..]和s2[j+1..]的lcs长度,这个长度肯定是小于等于情况二s1[i..]和s2[j+1..]中的lcs长度的,因为s1[i+1..]比s1[i..]短嘛,那从这里面算出的lcs当然也不可能更长嘛。
同理,情况三的结果肯定也小于等于情况一。说白了,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三,完整代码如下:
// 备忘录,消除重叠子问题
int[][] memo;
/* 主函数 */
int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 备忘录值为 -1 代表未曾计算
memo = new int[m][n];
for (int[] row : memo)
Arrays.fill(row, -1);
// 计算 s1[0..] 和 s2[0..] 的 lcs 长度
return dp(s1, 0, s2, 0);
}
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
// base case
if (i == s1.length() || j == s2.length()) {
return 0;
}
// 如果之前计算过,则直接返回备忘录中的答案
if (memo[i][j] != -1) {
return memo[i][j];
}
// 根据 s1[i] 和 s2[j] 的情况做选择
if (s1.charAt(i) == s2.charAt(j)) {
// s1[i] 和 s2[j] 必然在 lcs 中
memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1);
} else {
// s1[i] 和 s2[j] 至少有一个不在 lcs 中
memo[i][j] = Math.max(
dp(s1, i + 1, s2, j),
dp(s1, i, s2, j + 1)
);
}
return memo[i][j];
}
解法一:动态规划解法
4.编辑距离问题
题目描述
🌸「示例:」
解答
解法一:动态规划解法
5.正则表达式问题
题目描述
🌸「示例:」