动态规划第一篇详见此博客,介绍了理论和股票买卖系列题目
此篇介绍其他主题的动态规划题目:如 最大递增序列,回文串等等。
题目示例
leetcode300. 最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组
[0,3,1,6,2,2,7] 的子序列。
法1.动态规划
思路分析
我们用dp[i]表示数组的前i个元素构成的最长上升子序列,如果要求dp[i],我们需要用num[i]和前面的数字一个个比较,如果比前面的任何一个数字大,说明加入到他的后面可以构成一个上升子序列,就更新dp[i]。
- 数组dp[i]记录的就是 对应以nums[i]中每一个数结尾时 的最长子序列长度
我们就以[8,2,3,1,4]为例来画个图看一下
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);//DP数组记录 以nums中每个位置 为结尾时 的 最长子序列长度
for(int i = 0; i < nums.size(); i++){
for(int j = 0; j < i; j++){
if(nums[j] < nums[i]){
dp[i] = max(dp[i], dp[j]+1);
}
}
}
return *max_element(dp.begin(), dp.end());
}
};
法2.贪心+二分查找
暂不记录,可参考官方解答
leetcode354. 俄罗斯套娃信封问题
给定一些标记了宽度和高度的信封,宽度和高度以整数对形式 (w, h)出现。当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。
请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。
说明: 不允许旋转信封。
法1.动态规划
思路分析
今天的题目要求信封套信封最多套多少层,并且套的过程中信封长与宽不能旋转;长或者宽相等的时候,两个信封不能套在一起。
可以抽象成:题意:找出二维数组的一个排列,使得其中有最长的单调递增子序列(两个维度都递增)。
我在之前的题解里面讲过:「遇事不决先排序」,排序能让数据变成有序的,降低了混乱程度,往往就能帮助我们理清思路。本题也是如此。
1. 两个维度都递增的排序
第一感觉肯定是各种语言默认的排序方法:两个维度都递增的顺序。
对于题目给出的[[5,4],[6,4],[6,7],[2,3]]示例,如果按照两个维度都递增的排序方法,会得到:[[2, 3], [5, 4], [6, 4], [6, 7]]
然后我们利用最长递增子序列的方法,即使用动态规划,定义dp[i] 表示以 i 结尾的最长递增子序列的长度。对每个 i 的位置,遍历 [0,i),对两个维度同时判断是否是严格递增(不可相等)的,如果是的话,dp[i] = max(dp[i], dp[j] +1)。
这个方法对应的代码是:
class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
if not envelopes:
return 0
N = len(envelopes)
envelopes.sort()
res = 0
dp = [1] * N
for i in range(N):
for j in range(i):
if envelopes[j][0] < envelopes[i][0] and envelopes[j][1] < envelopes[i][1]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
2. 第一维递增,第二维递减的排序
上面的方法,我们在循环中对两个维度都进行判断是否严格递增的。其实有个技巧,可以减少第一个维度的判断。
先看个例子,假如排序的结果是下面这样:[[2, 3], [5, 4], [6, 5], [6, 7]]
如果我们只看第二个维度 [3, 4, 5,7],会得出最长递增子序列的长度是 4 的结论。实际上,由于第 3 和第 4 个信封的第一个维度都是6,导致他们不能套娃。所以,利用第一个维度递增,第二个维度递减的顺序排序,会得到下面的结果:
[[2, 3], [5, 4], [6, 7], [6, 5]]
这个时候,只看第二个维度 [3, 4, 7,5],就会得到最长递增子序列的长度是 3 的正确结果。
该方法对应的代码为:
class Solution {
public:
//要对两个维度排序,且多个信封有同一个维度一样大时只能选一个
int maxEnvelopes(vector<vector<int>>& envelopes) {
vector<int> ans(envelopes.size(), 1);
//按w升序,w相同时h降序 对原数组排序,排完后直接遍历h,找到h的最长升序子序列长度即使答案
sort(envelopes.begin(), envelopes.end(), [](const auto& e1, const auto& e2){
return e1[0] < e2[0] || (e1[0] == e2[0] && e1[1] > e2[1]);
});
//定义状态 f[i] 为考虑前 i 个物品,并以第 i 个物品为结尾的最大值。
//为什么要DP查找,因为每个数结尾和前面数组成的长度都有很多种情况,所以要动态查找并记录。确定每一个位置结尾时的所有情况最大值,最终再在这些
//最大值里找到 (最大值)答案
for(int i = 0; i < envelopes.size(); i++){
for(int j = 0; j < i; j++){
if(envelopes[j][1] < envelopes[i][1]){
ans[i] = max(ans[i], ans[j]+1);
}
}
}
return *max_element(ans.begin(), ans.end());
}
};
法2.二分法+动态规划 / 树状数组+动态规划
前面的方法: 时间复杂度:O(n^2), 空间复杂度:O(n)
时间复杂度可以优化到O(nlogn),此处暂不记录,可参考此篇讲解
leetcode647. 回文子串
给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
思路
1. 动态规划
如何快速判断连续一段 [i, j] 是否为回文串? 我们不可能每次都使用双指针去线性扫描一遍 [i, j] 判断是否回文。
一个直观的做法是,我们先预处理除所有的 f[i][j](动态规划数组,动规表),f[i][j] 代表 [i, j]这一段是否为回文串。
预处理 f[i][j] 的过程可以用递推去做。
要想 f[i][j] == true ,必须满足以下两个条件:
- f[i + 1][j - 1] == true //中间子串必须回文 - s[i] == s[j] //两端字符一样
由于状态 f[i][j] 依赖于状态 f[i + 1][j - 1],因此需要我们左端点 i 是从大到小进行遍历;而右端点 j 是从小到大进行遍历。
我们的遍历过程可以整理为:右端点 j 一直往右移动(从小到大),在 j 固定情况下,左端点 i 在 j 在左边开始,一直往左移动(从大到小)
class Solution {
public:
int countSubstrings(string s) {
int n = s.size(), cnt = 0;
vector<vector<int>> dp(n, vector<int>(n));
for(int j = 0; j < n; j++){
for(int i = j; i >= 0; i--){
if(i == j){//当[i, j]只有一个字符时,必然是回文串
dp[i][j] = 1;
cnt++;
}else if(i + 1 == j){//当[i, j]长度为2时,满足 cs[i] == cs[j] 即回文串
if(s[i] == s[j]){
dp[i][j] = 1;
cnt++;
}
}else{//当[i, j]长度大于2时,满足[i + 1]到[j - 1]回文且两端一样, 即回文串
if(dp[i+1][j-1] && s[i] ==s[j]){
dp[i][j] = 1;
cnt++;
}
}
}
}
return cnt;
}
};
2. 中心扩散等其他解法
详见 其他解答
leetcode5. 最长回文子串
给你一个字符串 s,找到 s 中最长的回文子串。
思路
思路同上题,额外比较 更新一下最长字串
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n));//记录i-j是否为回文子串
string ans;//回文子串长度即为ans.size()
for(int j = 0; j < n; j++){
for(int i = j; i >= 0; i--){
if(i == j){//只有一个字符(对应动规表的对角线)必是回文
dp[i][j] = 1;
}else if(i + 1 == j){//两个字符时
dp[i][j] = s[i] == s[j];
}else{//长度>2,由里面子串和两端决定,对应动规表中最下角和自身
dp[i][j] = dp[i+1][j-1] && s[i] == s[j];
}
//判断是否最长并记录
if(dp[i][j] && j - i + 1 > ans.size()){
ans = s.substr(i, j - i + 1);
}
}
}
return ans;
}
};
leetcode132. 分割回文串 II
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文。
返回符合要求的 最少分割次数 。
思路
如果 考察回溯算法的力扣 131题分割回文串,你有使用动态规划做预处理的话,或上面两题做过的话,可以很快想到如何求得任意段[i,j]是否为回文子串。
- 快速判断「任意一段子串是否回文」思路
剩下的问题是,我们如何快速判断连续一段 [i, j] 是否为回文串,做法和上面的题 一模一样。
- PS. 131题,数据范围只有 16,因此我们可以不使用 DP 进行预处理,而是使用双指针来判断是否回文也能过。但是该题数据范围为2000(数量级为 103),使用朴素做法判断是否回文的话,复杂度会去到 O(n3)(计算量为 109),必然超时。
因此我们不可能每次都使用双指针去线性扫描一遍 [i, j] 判断是否回文。 一个直观的做法是,我们先预处理除所有的
f[i][j],f[i][j] 代表 [i, j] 这一段是否为回文串。具体思路见上题。
递推「最小分割次数」思路
我们定义 f[i] 为以下标为 i 的字符作为结尾的最小分割次数,那么最终答案为 f[n - 1]。
不失一般性的考虑第 j 字符的分割方案:
- 从起点字符到第 j 个字符能形成回文串,那么最小分割次数为 0。此时有 f[j] = 0
- 从起点字符到第 j 个字符不能形成回文串:
2.1 该字符独立消耗一次分割次数。此时有 f[j] = f[j - 1] + 1
2.2 该字符不独立消耗一次分割次数,而是与前面的某个位置 i 形成回文串,[i, j] 作为整体消耗一次分割次数。此时有 f[j] = f[i - 1] + 1
在 2.2 中满足回文要求的位置 i 可能有很多,我们在所有方案中取一个 min 即可。第一个动态规划是快速判断所有回文子串,第二个动态规划是快速推出以某个字符结尾的最小分割次数。
class Solution {
public:
int minCut(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n));
//动态规划找到所有子串是否为回文子串
for(int j = 0; j < n; j++){
for(int i = j; i >= 0; i--){
if(i == j)
dp[i][j] = 1;
else if(i + 1 == j)
dp[i][j] = s[i] == s[j];
else
dp[i][j] = dp[i + 1][j - 1] && s[i] == s[j];
}
}
//根据dp表找最少分割次数,同样使用动态规划:f[i]表示以i结尾的子串最少分割次数
vector<int> ans(n);
for(int i = 0; i < n; i++) ans[i] = i;//初始化最大值,每位都需要分割一次
for(int j = 1; j < n; j++){
if(dp[0][j]){// 如果 [0,j] 这一段直接构成回文,则无须分割
ans[j] = 0;
}
else{//如果无法直接构成回文:那么对于第 j 个字符,有使用分割次数,或者不使用分割次数两种选择
for(int i = 0; i < j; i++){
if(dp[i+1][j]){//i+1和j之间构成回文子串;其中,当i=j-1时,j自己独立占了一个分割次数
ans[j] = min(ans[j], ans[i] + 1);
}
}
}
}
return ans[n-1];
}
};
leetcode115. 不同的子序列
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,“ACE” 是 “ABCDE” 的一个子序列,而 “AEC” 不是)
题目数据保证答案符合 32 位带符号整数范围。
思路分析
dp[i][j] 代表 T 前 i 字符串可以由 S j 字符串组成最多个数.
所以动态方程:
当 S[j] == T[i] , dp[i][j] = dp[i-1][j-1] + dp[i][j-1];
当 S[j] != T[i] , dp[i][j] = dp[i][j-1]
-
对于第一行, T 为空,因为空集是所有字符串子集, 所以我们第一行都是 1
-
对于第一列, S 为空,这样组成 T 个数当然为 0` 了
-
对于某个 dp[i][j] 而言,若s[j] == t[i],包含两类决策:
- 不选择:不让 s[j] 参与匹配,也就是需要让 s 中 [0,j-1]个字符去匹配 t 中的 [0,i]字符。此时匹配值为 dp[i][j-1]
- 选择:让 s[j] 参与匹配,这时候只需要让 s 中 [0,j-1]个字符去匹配 t 中的 [0,i-1][0,i−1] 字符即可,同时满足 s[i]=t[j]。此时匹配值为 dp[i-1][j-1]
最终 dp[i][j] 就是两者之和。
- 若s[j] != t[i] ,dp[i][j]的值 只和 dp[i][j-1] 一样,也就是当前的无法匹配只能和前一个匹配的数量相等。
详见代码和注释~
class Solution {
public:
int numDistinct(string s, string t) {
// 技巧:往原字符头部插入空格,这样得到 string 字符串是从 1 开始
// 同时由于往头部插入相同的(不存在的)字符,不会对结果造成影响,而且可以使得 dp[0][j] = 1,可以将 1 这个结果滚动下去
int n = s.size(), m = t.size();
s = " " + s;
t = " " + t;
// dp(i,j) 代表考虑「s 中的下标为 0~j 字符」和「t 中下标为 0~i 字符」是否匹配
vector<vector<long>> dp(m+1, vector<long>(n+1));
for(int j = 0; j < n+1; j++) dp[0][j] = 1;
for(int i = 1; i < m+1; i++){
for(int j = 1; j < n+1; j++){
if(s[j] == t[i])// 使用 s[j] 进行匹配,则要求 s[j] == t[i],然后有选和不选两种 dp[i][j] = dp[i - 1][j - 1] + dp[i][j-1]
dp[i][j] = dp[i-1][j-1] dp[i][j-1];
else//两个不相等,则数量和 s[j-1]匹配的一样
dp[i][j] = dp[i][j-1];
}
}
return dp[m][n];
}
};
分享一个较好的解答
leetcode10.
leetcode53.最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
int dp = 0, max_sum = INT_MIN;
for(int i = 0; i < n; i++){
dp = max(nums[i], dp + nums[i]);//要么加入前面子串,要么不加入自己开始
max_sum = max(dp, max_sum);//重新开始子串,也要和前面已找过的子串和比较
}
return max_sum;
}
};
多解法可参考此讲解