动态规划(子序列问题)
子序列(不连续)
<span id = “最长上升子序列">最长上升子序列
LIS(longest increasing subsequence)是最经典的动态规划题目之一,因为它的递推公式很有条理,可以很清楚的告诉我们什么状态可以递推出当前的状态。
首先搞清楚几个概念:
上升:本题中的上升指的是严格的上升,即{1, 2, 3}
而不是{1, 2, 2}
。
子序列和子串:子序列是数组中可以不连续的子段序列,而子串是数组中一个连续的一段序列。
(暴力回溯)
可以将所有的数组中的所有子集(所有组合)求出来,然后判断是否为严格上升的序列,最后取最长的序列的长度。
有时间复杂度太高,所以就不写了。
(动规)
1.dp数组的含义
要求出最长上升子序列,只有一个限制条件,即要满足是上升的序列,所以只需要一个一维数组来控制即可。
定义dp[i]
表示:以第i个数字结尾的最长上升子序列的长度为dp[i]
。
注意:答案不一定是dp[nums.size() - 1]
,因为不一定是以最后一个数字结尾就是最长的上升子序列,例如nums = {1, 2, 3, 1, 1}
,所以最后遍历一遍以nums[i]
结尾的所有数dp[i]
,取一个最大值。(也可以在算出dp[i]的时候就更新ans,见下面的代码)
2.递推公式
一般递推公式都是推出最后一个最具有一般性的dp数组,即dp[i]
。所以要考虑什么状态可以推出dp[i]
,即dp[i]
前一个满足要求的dp数组,也就是说哪一个dp数组满足nums[i]
是它的最长上升子序列中的数。
可以发现想要推出nums[i]
是否为最长上升子序列中的数,就要看nums[j] j = 1,2 ... i - 1
是否小于nums[i]
,if (nums[i] > nums[j])
这时dp[i] = dp[j] + 1
,但是j = 1,2,...i - 1
,所以dp[i]
是其中最大的一个,所以dp[i] = max(dp[i], dp[j] + 1)
。
3.初始化
因为每一个子序列本身都是一个上升的子序列,所以dp
数组中的所有数初始化为1。
根据递推公式中dp[i] = max(dp[i], dp[j] + 1)
,可知dp[i]
有dp[j]
也就是dp[0 ... i - 1]
推出,所以不用特判初始化。
4.遍历顺序
有递推公式可以知道,前面状态推出后面的状态,所以从前往后遍历即可。
5.举例
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
vector<int> dp(nums.size(), 1);
int ans = 0;
for (int i = 1; i < nums.size(); i ++) {
for (int j = 0; j < i; j ++) {
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
}
if (ans < dp[i]) ans = dp[i];
}
return ans;
}
};
(动规 + 二分 + 贪心)
tail[i]
表示:长度为i + 1的所上升子序列的结尾的最小值。所以tail
数组的长度就是上升子序列的长度。
如何更新tail
数组:
1.如果从前往后遍历数组,nums[i] > tail[end]
的话,就将nums[i]
插入到tail
数组的最后。
2.如果从前往后遍历数组,nums[i] <= tail[end]
的话,就将tail
数组中>=nums[i]
的第一个元素,替换成nums[i]
,这个过程可以用二分来解决。
为什么要用nums[i]
替换tail
数组中>= nums[i]
的第一个元素?
数组是从前往后遍历的去求出上升子序列的最后一个最小元素的。
如果nums[i]>tail[end]
的话就不说明了,因为要满足上升子序列的要求,所以只能将nums[i]
插入到tail
的后面。
但是如果nums[i]<=tail[end]
,就说明tail
数组中一定存在一个数>=nums[i]
,而>=nums[i]
的第一个数,可能是tail[end]
,也可能是tail
数组中前面的数。这里就要用到贪心的思想了,就是不论如何,要想求出最长的上升子序列,就要使得if(nums[i] <= tail[end])
中的nums[i]
一定是tail
数组中相应位置上最小的数,因为小数字后面接的大数字的个数一定要比小数字大的数后面接的大数字的个数要多。
这样说太抽象了,举个栗子:
tail = {1, 3, 6}
,nums[i] = 5
,nums[i + 1] = 6
如果不替换nums[i]
的话,因为nums[i]
不大于tail[end] == 6
nums[i + 1]
也不大于tail[end] == 6
,所以tail
的长度就是3.
但是因为nums[i] < tail[end]
,所有nums[i]
替换>= nums[i]
的第一个元素tail[2] == 6
,所以tail = {1, 3, 5}
, 因为nums[i + 1] > tail[end]
,所以nums[i + 1]
到tail
数组后面,所以tail = {1, 3, 5, 6}
,这时tail
数组的长度为4,所以最长上升子序列的长度为4。这显然比上面一种情况的长度要大。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
vector<int> tail;
int end = 0;
tail.push_back(nums[0]);
for (int i = 1; i < nums.size(); i ++) {
if (nums[i] > tail[end]) {
tail.push_back(nums[i]);
end ++;
} else {
int l = 0, r = end;
while (l < r) {
int mid = l + r >> 1;
if (tail[mid] >= nums[i]) r = mid;
else l = mid + 1;
}
tail[l] = nums[i];
}
}
return end + 1;
}
};
(动规 + 二分 + 贪心)化简版
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
vector<int> tail(nums.size(), 0);
int end = 0;
tail[0] = nums[0];
for (int i = 1; i < nums.size(); i ++) {
int l = 0, r = end + 1;
while (l < r) {
int mid = l + r >> 1;
if (tail[mid] >= nums[i]) r = mid;
else l = mid + 1;
}
tail[l] = nums[i];
if (l == end + 1) end ++;// 如果添加的元素是最后一个,说明数组扩张了
}
return end + 1;
}
};
<span id = “最长公共子序列">最长公共子序列
本题可以类比最长公共子数组
(动规)
这里的公共子序列指的是可以在两个字符串中不连续的公共字符串序列。
1.dp数组的含义
因为要同时遍历两个数组,而dp
数组需要用两维来限定。dp[i][j]
表示A数组中以i - 1结尾的数字和B数组中以j - 1结尾的数字中最长公共子数组的长度。(这里最好是用以i - 1和j - 1,也就是给dp数组多加一个第一行和第一列,这样可以少一些特判,下面的递推公式和举例画图中可以看出来优势)
最终的答案就是dp[text1.size()][text2.size()]
,这里和最长公共子数组不同的是:不需要用一个ans
来更新最大值。因为这里是可以存储不连续的公共子串的,所以dp[text1.size()][text2.size()]
包含了前面序列的公共部分。但是公共子数组要求是存储连续的数字,最后一个dp[nums1.size()][nums2.size()]
不一定和前面的数字是连续公共的,所以要求出最长的公共部分还需要遍历一个dp数组。
2.递推公式
判断是否为公共的字符,只需要判断if (text1[i - 1] == text2[j - 1])
是否成立。
如果成立,则说明有公共的字符,所以dp[i][j] = dp[i - 1][j - 1] + 1
。
如果不成立,注意这里是和最长公共子数组(即连续的序列)的不同之处。这时有两种状态可以推出dp[i][j]
,一种是dp[i - 1][j]
,即text1中的[0 ... i - 1]
字符和text2中的[0 ... j]
字符比较,另一种是dp[i][j - 1]
,即text1中的[0 ... i]
字符和text2中的[0 ... j - 1]
字符比较。所以dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
。
3.初始化
dp[0][j]
和dp[i][0]
没有实际的意义,但是递推公式中需要在数组中累加长度,所以所有的dp数组中的数初始化为0即可。
4.遍历顺序
有递推公式可知:前一个状态推出后一个状态,所以数组从前往后遍历
5.举例子
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int n = text1.size(), m = text2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= m; j ++) {
if (text1[i - 1] == text2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[n][m];
}
};
不相交的线
(动规)
连接直线有两个要求:
1.数字相同nums1[i] == nums2[j]
2.连接的线不可以相交
数字相同其实直接添加一个判断即可,但是如何让线与线之间不相交呢?
只要保持相对顺序不变,然后连接相同的数字即可。也就是在遍历一个数组的过程中在另一个数组中找是否有相同的序列。这个说法好像和最长公共子序列的做法是一样的鸭!没错,这题就可以转换为求出两个数组的最长公共子序列的长度。
代码可参考上一道题目:
class Solution {
public:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
int n = nums1.size(), m = nums2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= m; j ++) {
if (nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[n][m];
}
};
子序列(连续)
最长连续上升子序列
本题和最长上升子序列问题的区别就在于这里统计的是连续的子序列,也就是最长的子串的长度,所以这样其实就更加简单了。
区别只体现在递推公式上,最长上升子序列中dp[i]
这个状态可以由nums[0...i - 1]
这i - 1个状态推出,但是由于子串必须是连续的,所以先要求出nums[i]
是否在上升子串中,只需要判断nums[i] >= nums[i - 1]
,如果成立dp[i] = dp[i - 1] + 1
,如果不成立就可以直接跳过。
总结:最长上升子序列和0 ~ i - 1
的状态有关,而最长上升子串只和i - 1
这一个状态有关。
这里描述的比较简单,想要详细的理解可以参考最长上升子序列
(动规)
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
vector<int> dp(nums.size(), 0);
int ans = 1;
for (int i = 1; i < nums.size(); i ++) {
dp[i] = 1;
if (nums[i] > nums[i - 1]) dp[i] = dp[i - 1] + 1;
ans = max(ans, dp[i]);
}
return ans;
}
};
(双指针)
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int ans = 1;
for (int i = 0; i < nums.size(); i ++) {
int j = i;
while (j + 1 < nums.size() && nums[j] < nums[j + 1]) j ++;
ans = max(ans, j - i + 1);
i = j;
}
return ans;
}
};
<span id = “最长公共子数组">最长公共子数组
本题可以类比最长公共子序列
(动规)
这里的公共子数组指的是一段连续的重复的数字。
1.dp数组的含义
因为是求出两个数组中一段连续的重复的部分,所以要同时遍历两个数组,而dp
数组需要用两维来限定。dp[i][j]
表示A数组中以i - 1结尾的数字和B数组中以j - 1结尾的数字中最长公共子数组的长度。(这里最好是用以i - 1和j - 1,也就是给dp数组多加一个第一行和第一列,这样可以少一些特判,下面的递推公式和举例画图中可以看出来优势)
2.递推公式
因为要求出连续的子数组,判断**if (nums1[i - 1] == nums2[j - 1])
**,如果两个数字相等,说明出现了一个重复的数字,所以在dp[i - 1][j - 1]
上+1
,但是如果不想等,因为需要求出连续的子数组,如果两个数字不相等的话,该位置也就不可能构成连续的子数组了,所以dp[i][j] = 0
,因为原数组全部初始化为0,所以可以省略不写。因为子数组是连续的,所以只有这一个状态可以推出dp[i][j]
。也因为所有的dp[i][j]
只能由dp[i - 1][j - 1] + 1
推出,所以从1开始循环就可以不用特判下标为负数的情况了。
3.初始化
dp[0][j]
和dp[i][0]
没有实际的意义,但是递推公式中需要在数组中累加长度,所以所有的dp数组中的数初始化为0即可。
4.遍历顺序
有递推公式可知:前一个状态推出后一个状态,所以数组从前往后遍历
5.举例
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>> dp(nums1.size() + 1, vector<int>(nums2.size() + 1, 0));
int ans = 0;
for (int i = 1; i <= nums1.size(); i ++) {
for (int j = 1; j <= nums2.size(); j ++) {
if (nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
ans = max(ans, dp[i][j]);
}
}
return ans;
}
};
(动规 一维数组优化)
由于每一次数组只用到了上一层数组,所以可以减少一维空间,而只用一个一维数组代替。
优化成一维数组需要注意两点:
1.定义数组的大小为nums2.size() + 1
,所以为了使dp[i - 1][j - 1] == dp[j - 1]
,第二层遍历nums2
数组的时候,需要倒着枚举。
如果不知道为什么倒着枚举,可以看看背包问题的一维数组优化空间。
2.注意要在二维数组的代码上加上else dp[j] = 0;
这样才可以覆盖点上一层的已经计算过的数。
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
vector<int> dp(nums2.size() + 1, 0);
int ans = 0;
for (int i = 1; i <= nums1.size(); i ++) {
for (int j = nums2.size(); j >= 1; j --) {
if (nums1[i - 1] == nums2[j - 1]) dp[j] = dp[j - 1] + 1;
else dp[j] = 0;// 注意:如果没有重复的数字,需要将dp[i]置零,否则就会遗留下上一层的数字
ans = max(ans, dp[j]);
}
}
return ans;
}
};
(滑动窗口 待更新)
最大子序和
(暴力)
暴力方法可以用循环枚举所有的子序列的和或者递归回溯该数组中所有的子集,然后对于每一个子集求和。由于时间复杂度较高,所以就不详细讲了。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int ans = INT_MIN;
for (int i = 0; i < nums.size(); i ++) {
int sum = 0;
for (int j = i; j < nums.size(); j ++) {
sum += nums[j];
if (sum > ans) ans = sum;
}
}
return ans;
}
};
(动规)
求解连续的序列问题是可以用动态规划的。
1.dp数组的含义
本题是求解一个数组中的最大连续子序列和的问题,所以限制条件就是当前的数组,所以dp数组为一维数组。dp[i]
表示以i下标为结尾的最大连续子序列的和。
2.递推公式
求解连续的子序列的分析方法:有两个状态可以推出当前状态,1;当前数单独成一段连续子序列的开头 2.当前数加入到上一段连续子序列中(这个方法对于连续的子序列的求解都适用)判断是否要加入上一个连续子序列中要看具体的题目要求
本题是否要加入上一段连续的子序列中的判断依据是:if (nums[i] + dp[i - 1] > nums[i])
1.如果nums[i]
单独成一段,,则dp[i] = nums[i]
2.如果nums[i]
加入上一段连续的子序列,则dp[i] = dp[i - 1] + nums[i]
两者之间取一个最大值就是递推公式dp[i] = max(dp[i - 1] + nums[i], nums[i])
3.初始化
以0为下标的连续子序列的和就是nums[0]
本身,所以dp[0] = nums[0]
,其余的部分都初始化为0即可。
4.遍历顺序
根据递推公式可以上一个状态推出下一个状态,所以从左往右遍历数组。
5.举例子
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
vector<int> dp(nums.size(), 0);
int ans = nums[0];
dp[0] = nums[0];
for (int i = 1; i < nums.size(); i ++) {
dp[i] = max(nums[i], dp[i - 1] + nums[i]);
ans = max(ans, dp[i]);
}
return ans;
}
};s
(动规 空间优化)
由于每一个状态都是仅由上一个状态推出,所以可以不用一个数组来保存,而是由一个变量dp
保存dp[i - 1]
即可。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
int ans = nums[0];
int dp = nums[0];
for (int i = 1; i < nums.size(); i ++) {
dp = max(nums[i], dp + nums[i]);
ans = max(ans, dp);
}
return ans;
}
};
(贪心)
本题中贪心的思想,是用一个变量sum
保存,前面一段连续子序列的和,然后观察前面序列的和是否对于当前数加入该连续子序列有增益性。
如果sum > 0
,则当前数组加入上一个连续的子序列是有增益性的,即nums[i] + sum > nums[i]
,所以最大值为sum += nums[i]
如果sum <= 0
,则当前数加入上一个连续的子序列没有当前数自己本身单独成一段子序列所得的总和大,也就是前面的非整数只会让当前数变得更小而已,所以就不用加入其中,所以当前的最大值为sum = num[i]
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int ans = nums[0];
int sum = 0;
for (int i = 0; i < nums.size(); i ++) {
if (sum >= 0) {
sum += nums[i];
} else {
sum = nums[i];
}
ans = max(ans, sum);
}
return ans;
}
};
总结(连续和不连续问题)
连续子序列分析方法:
有两个状态可以推出dp[i]..[j]
,
1.当前数单独成一段
2.满足连续子序列要求,可以加入上一段连续子序列中
(复盘连续子序列)
在最长连续上升子序列中:
1.如果单独成一段,则dp[i] = 1
2.如果满足上升子序列的要求,即nums[i] > nums[i - 1]
,就可以加入上一段,而且加入上一段一定要比自成一段的长度要长,所以dp[i] = dp[i - ] +1
。
在最长公共子序列中:
1.如果单独成一段,就要看是否两个数组中的两个数字是否相同,如果不同dp[i][j] = 0
,如果相同dp[i][j] = 1
2.如果满足重复的子数组的要求,即nums1[i] == nums2[j]
,就可以加入上一段连续的公共子序列的,而且长度一定会比单独成一段要长,所以dp[i][j] = dp[i - 1][j - 1] + 1
在最大子序和中:
1.如果单独成一段,总和为nums[i]
自己本身,所以dp[i] = nums[i]
2.因为是求以i结尾的最大子序列的和,所以需要前面一段的子序列的和大于0,才可以让以i结尾的子序列的和变大,所以当nums[i] + dp[i - 1] > 0
,dp[i] = dp[i - 1] + num[i]
最终求出的是和的大小而不是和上面一样求的是长度,最后还需要比较两种和的大小,dp[i] = max(nums[i], nums[i] + dp[i - 1])
不连续子序列分析方法:
连续的子序列考虑的是一个数和前后数的关系的问题,但是不连续子序列更多的考虑的是当前数和前面所有数字的关系。
1.满足连续子序列的要求,可以加入上一段连续的子序列
2.不满足连续子序列的要求,然后和前面一步可以推出当前状态的数字建立联系。
(复盘不连续子序列)
在最长上升子序列中:
1.满足nums[i] > nums[i - 1]
,dp[i] = dp[i - 1] + 1
2.如果不满足的话,就遍历前面i - 1个数,if (nums[i] > nums[j]) dp[i] = dp[j] + 1
因为前一个和前面i - 1个数字的位置的等价的,所以可以放在一起写
在最长公共子序列中:
1.满足nums1[i - 1] == nums2[j - 1]
,说明有一个重复的数字,即dp[i][j] = dp[i - 1][j - 1] + 1
1.如果不满足这两个字符相同,就倒退一步(记住只倒退一步,因为这样就可以找到可以推出dp[i][j]
的状态了),让[0...i - 1]和[0...j]
比,和[0...i]和[0...j - 1]
比,然后在这两种情况下取一个最大值。即dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
编辑距离
判断子序列
(双指针)
双指针分别指向两个不同的字符串,如果对应上一个相同的字符,两个指针就同时往后移动,否则只有t
串的指针在移动,最后判断s
串的指针是否移动在最后即可。
class Solution {
public:
bool isSubsequence(string s, string t) {
int si = 0;
for (int i = 0; i < t.size(); i ++) {
if (t[i] == s[si]) si ++;
}
return si == s.size();
}
};
(动规)
1.dp数组的含义
有两个字符串,所以还有要用两维空间来限定。dp[i][j]
表示以i - 1结尾的字符串和以j - 1结尾的字符串,公共子序列的长度为dp[i][j]
。(这个dp
数组的定义和最长公共子序列的dp
数组的定义是一样的,但是后面的递推公式不一样)
2.递推公式
有公共的字符的条件是s[i - 1] == t[i - 1]
;
1.如果s[i - 1] == t[j - 1]
,则说明有一个相同的字符,所以dp[i][j] = dp[i - 1][j - 1] + 1
2.如果s[i - 1] != t[j - 1]
,这个时候往回倒退一步,想什么状态可以推出dp[i][j]
。
有两种情况:
1.在[0 ... i - 2]
中和[0 ... j - 1]
中继续找是否有相同的字符,即dp[i][j] = dp[i - 1][j]
2.在[0 ... i - 1]
中和[0 ... j - 2]
中继续找是否有相同的字符,即dp[i][j] = dp[i][j - 1]
但是由于s
串是目标串,即要在t
串中寻找是否有s
串,所以不用在s
串中找t
串中的公共字符序列,所以情况1舍去。
所以综上:if(s[i - 1] != t[j - 1]) dp[i][j] = dp[i][j - 1]
3.初始化
根据递推公式是需要初始化dp[0][j]
和dp[i][0]
的,但是以-1为下标的字符串没有意义,所以直接初始化为0即可。
4.遍历顺序
根据递推公式,前一个状态推出后一个状态,所以从前往后遍历。
class Solution {
public:
bool isSubsequence(string s, string t) {
vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
for (int i = 1; i <= s.size(); i ++) {
for (int j = 1; j <= t.size(); j ++) {
if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = dp[i][j - 1];
}
}
return dp[s.size()][t.size()] == s.size();
}
};
(*贪心 + 动规)
class Solution {
public:
bool isSubsequence(string s, string t) {
int n = s.size(), m = t.size();
vector<vector<int>> dp(m + 1, vector<int>(26, -1));
for (int i = m - 1; i >= 0; i --) {
for (int j = 0; j < 26; j ++) {
if (t[i] == j + 'a') dp[i][j] = i;
else dp[i][j] = dp[i + 1][j];
}
}
int depth = 0;
for (int i = 0; i < n; i ++) {
if (dp[depth][s[i] - 'a'] == -1) return false;
depth = dp[depth][s[i] - 'a'] + 1;
}
return true;
}
};
不同的子序列
如果这题不是不同子序列而是不同子串就可以用KMP来做了。
(动规)
1.dp数组的含义
因为是匹配两个字符串,所以dp
数组是用两维来限制的,dp[i][j]
表示以i - 1的为结尾的s串中以j - 1结尾的t串的个数为dp[i][j]
(以i - 1和j - 1结尾是因为在二维数组中多加了一行和一列。这样可以方便后面的计算递推)。
2.递推公式
既然是要在s串中找t串,所以就需要一个字母一个字母的去匹配。这样就自然的要对s[i - 1] == t[j - 1]
来讨论。
1.如果s[i - 1] == t[i - 1]
的话,这就还要分成两种情况:
1.1.如果是用s[i - 1]
去匹配就要在前i - 1个字母中找前j - 1个字符,即dp[i - 1][j - 1]
1.2.如果是不用s[i - 1]
去匹配,就要在前i - 1个字母中找前j个字母,即dp[i - 1][j]
2.如果s[i - 1] != t[j - 1]
的话,就只能在前i - 1个字母中找前j个字母。即dp[i - 1][j]
3.初始化
因为递推公式需要初始化,dp[0][j]
和dp[i][0]
,dp[i][0]
表示前i个字符中空字符串的个数,因为每一个字符串都有空字符串,所以dp[i][0]
都是等于1的。dp[0][j]
表示前0个字符串中有前j个字符串的个数,因为空字符串中不包括其他任何的非空字符串,所以dp[0][j]
都等于0(dp[0][0]
是等于1的)。
4.遍历顺序
根据递推公式可知:前一个状态推出后一个状态,所以从前往后遍历数组。
5.举例子
class Solution {
public:
int numDistinct(string s, string t) {
typedef unsigned long long ULL;
vector<vector<ULL>> dp(s.size() + 1, vector<ULL>(t.size() + 1, 0));
for (int i = 0; i <= s.size(); i ++) dp[i][0] = 1;
// for (int i = 1; i <= t.size(); i ++) dp[0][i] = 0;
// 这一句可以不用写,因为在数组初始化的时候就已经初始化为0了
for (int i = 1; i <= s.size(); i ++) {
for (int j = 1; j <= t.size(); j ++) {
if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
else dp[i][j] = dp[i - 1][j];
}
}
return dp[s.size()][t.size()];
}
};
(动规 空间优化)
因为递推公式只用到了左上角和上方的数组,所以可以将二维数组降维成一维数组。
注意:
1.将所有的第一维都去掉
2.内循环到逆向枚举。(又不懂为什么的同学可以康康背包问题的一维空间优化。
class Solution {
public:
int numDistinct(string s, string t) {
typedef unsigned long long ULL;
vector<ULL> dp(t.size() + 1, 0);
for (int i = 0; i <= s.size(); i ++) dp[0] = 1;
for (int i = 1; i <= s.size(); i ++) {
for (int j = t.size(); j >= 1; j --) {
if (s[i - 1] == t[j - 1]) dp[j] = dp[j - 1] + dp[j];
}
}
return dp[t.size()];
}
};
两个字符串的删除操作
(动规 LIS)
第一种方法是利用最长上升子序列(LIS)的结果,因为求出两个字符串中最少要删除几个字符才可以相同,也就是在问两个字符串的最长相同的子序列,最后只要用整个字符串减去最长的公共子序列的长度就是要删除的字符的个数。
class Solution {
public:
int minDistance(string word1, string word2) {
int n = word1.size(), m = word2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= m; j ++) {
if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return n + m - 2 * dp[n][m];
}
};
(动规2)
这是不利用LIS的动态规划
1.dp数组的含义
因为是两个字符串,所以还是要用两维来限制,dp[i][j]
表示以i - 1为结尾的字符串和以j - 1结尾的字符串需要达到相等至少要删除dp[i][j]
个字符。(dp数组的下标是从1开始的,这样方便后面的计算和递推)
2.递推公式
要使得字符串达到相同,肯定和word1[i - 1]
和word2[j - 1]
是否相同有关,所以就拿它作依据。
情况1:如果word1[i - 1] == word2[j - 1]
,也就是word1第i个字符和word2的第2个字符相同,也就是要比较前i - 1个字符和前j - 1个字符,即dp[i][j] = dp[i - 1][j - 1]
。
情况2:如果word1[i - 1] != word2[j - 1]
,那如果倒退一步来说不同的字符就多了一个,也就是要删除的字符要多一个。如果word1[i - 1]
是多出来的字符的话,dp[i][j] = dp[i - 1][j] + 1
,如果word2[j - 1]
是多出来的字符的话,dp[i][j] = dp[i][j - 1] + 1
。如果word1[i - 1]
和word2[j - 1]
都是多出来的字符的话,dp[i][j] = dp[i - 1][j - 1] + 2
。最后在这三者中去最小值即可。
3.初始化
根据递推公式,需要初始化第一行dp[0][i]
和第一列dp[i][0]
。
当word1
是空字符串的时候,word2
需要删除i个字符才可以和word1
相同。
当word12
是空字符串的时候,word2
也需要删除i个字符才可以和word1
相同。
即for (int i = 0; i <= word1.size(); i ++) dp[i][0] = i;
for (int i = 0; i <= word2.size(); i ++) dp[0][i] = i;
4.遍历顺序
根据递推公式可知:前一个状态推出后一个状态,所以需要从前往后遍历
5.举例子
class Solution {
public:
int minDistance(string word1, string word2) {
int n = word1.size(), m = word2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 0; i <= n; i ++) dp[i][0] = i;
for (int i = 0; i <= m; i ++) dp[0][i] = i;
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= m; j ++) {
if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = min(dp[i - 1][j - 1] + 2,
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
return dp[n][m];
}
};
编辑距离
(动规)
1.dp数组的含义
两个字符串的增、删、换,所以需要两维来限制。dp[i][j]
表示以i - 1为结尾的字符串和以j - 1为结尾的字符串,最短的编辑距离为dp[i][j]
。(这里dp数组的下标是从1开始的)
2.递推公式
最终需要使得两个字符串相同,就要判断word1[i - 1]
和word2[j - 1]
的差异:
情况1:如果word1[i - 1] == word2[j - 1]
,就要看word1
的前i - 1个字符合word2
前j - 1个字符的最短编辑距离,即dp[i][j] = dp[i - 1][j - 1]
。
情况2:如果word1[i - 1] != word2[j - 1]
,对于某一个字符串可以进行增、删、换这三种操作,比如说对word1
进行操作:
情况2.1.如果删掉word1[i - 1]
就可以使得两个字符串相同,那word1
的前i - 1个字符和word2
的前j个字符在编辑过后已经是相同的了,即dp[i][j] = dp[i - 1][j] + 1
情况2.2.如果增加word1[i - 1]
就可以使得两个字符串相同,那word1
的前i个字符和word2
的前j - 1个字符在编辑过后已经是相同的了,即dp[i][j] = dp[i][j - 1] + 1
情况2.3.如果替换word1[i - 1]
为word2[j - 1]
就可以使得两个字符串相同,那word1
的前i - 1个字符和word2
的前j - 1个字符在编辑过后就已经是相同的了,即dp[i][j] = dp[i - 1][j - 1] + 1
最后在情况2的3种情况下需要选出最小的步数
3.初始化
根据递推公式,是要初始化dp[0][j]
和dp[i][0]
的,这里还是对于word1
进行增、删、换。当word1
为空字符串的时候,word2
有j个字符word1
就要增加j个字符,这样才可以使word1
成为word2
,所以dp[0][j] = j
,当word2
为空字符串的时候,word1
有i个字符就要删掉i个字符,这样才可以编辑为空字符串,所以dp[i][0] = i
4.遍历顺序
根据递推公式可知,前面的状态推出后面的状态,所以需要从前往后遍历
5.举例子
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
for (int i = 0; i <= word1.size(); i ++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j ++) dp[0][j] = j;
for (int i = 1; i <= word1.size(); i ++) {
for (int j = 1; j <= word2.size(); j ++) {
if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1});
}
}
return dp[word1.size()][word2.size()];
}
};
回文串
回文子串
(暴力)
遍历字符串中所有的子串的集合,并判断是否为回文串。
class Solution {
public:
bool isPali(string& s, int start, int end) {// 判断是否为回文子串
while (start < end) {
if (s[start] != s[end]) return false;
start ++;
end --;
}
return true;
}
int countSubstrings(string s) {
int ans = 0;
// 枚举字符串的所有的字符串集合
for (int i = 0; i < s.size(); i ++) {
for (int j = i; j < s.size(); j ++) {
if (isPali(s, i, j)) ans ++;
}
}
return ans;
}
};
(动规)
1.dp数组的含义
因为需要枚举字符串中所有的子串的集合,这需要在字符串中限定范围,所以dp
数组需要两维来控制字符串的分割的范围。dp[i][j]
表示字符串在[i, j]
的范围中字符串是否为回文串。
2.递推公式
判断是否为回文串有两种方式,一种是从一个中心点往外扩展到两端的字符不同,另一种是限定好一个字符串然后从外往里检查是否对应位置上的字符是否相同。显然这里使用的是第二中方式,所以要判断s[i] == s[j]
。
情况1:如果s[i] != s[j]
,在字符串中只要有一对字符不同,就一定不可能是回文的,所以dp[i][j] = false;
情况2:如果s[i] == s[j]
,因为字符串的长度影响着是否要进一步判断回文串,但是不知道字符串的长度,所以有需要分成三种情况:
情况2.1.如果i == j
,也就是只有子一个字符的时候,该字符串使回文的
情况2.2.如果i == j + 1
,也就说有两个相同的字符,所以该字符串也是回文的
情况2.3.如果i > j + 1
,这时候就需要进一步缩小判断的范围,下面就可以判断[i + 1, j - 1]
中的字符串是否为回文串,即dp[i][j] = dp[i + 1][j - 1]
。
3.初始化
根据递推公式,可知后一个状态需要前一个状态,所以没有必要多开一行或者一列表格,所以直接一开始假设所有的字符串都不是回文的,即初始化为false
。
4.遍历顺序
根据递推公式可知,后一个状态需要前一个状态,所以字符串的起点需要逆序枚举。
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int ans = 0;
for (int i = s.size() - 1; i >= 0; i --) {// 一定要倒序枚举
for (int j = i; j < s.size(); j ++) {
if (s[i] == s[j]) {
if (j - i <= 1) {// 如果字符串的长度<=1
dp[i][j] = true;
ans ++;
} else if (dp[i - 1][j + 1]) {// 如果s[i]==s[j]并且[i-1, j+1]是回文串,则[i,j]是回文串
dp[i][j] = true;
ans ++;
}
}
}
}
return ans;
}
};
(中心扩展法)
上面动态规划中是先限定了字符串的范围,然后从外往里判断是否为回文字符串。也可以寻找一个中心点,然后从中心点往外扩展判断是否为回文串。
那中心点有几个呢?什么字符可以成为中心点?
最小的中心点就是每一个字符,但是因为每一次扩展都是两端一起判断,所以单个字符只能扩展字符串数量为奇数的字符串。所以还需要连着两个字符在一起的字符串作为中心点。如果一个字符串的长度为len
,则有len
个字符,两个字符炼连起来的字符串有len - 1
个,所以中心点有2 * len - 1
个。
class Solution {
public:
int countSubstrings(string s) {
int ans = 0;
for (int i = 0; i < 2 * s.size() - 1; i ++) {// 又2*s.size()-1个中心
int l = i / 2;// 中心的左端点
int r = l + i % 2;// 中心的右端点
while (l >= 0 && r < s.size()) {// 扩展中心,判断回文串
if (s[l] == s[r]) {
ans ++;
l --;
r ++;
} else break;
}
}
return ans;
}
};
最长回文子串
(暴力)
class Solution {
public:
bool isPali(string& s, int start, int end) {
while (start < end) {
if (s[start] != s[end]) return false;
start ++;
end --;
}
return true;
}
string longestPalindrome(string s) {
string str;
int len = 0;
for (int i = 0; i < s.size(); i ++) {
for (int j = i; j < s.size(); j ++) {
if (isPali(s, i, j) && len < j - i + 1) {
len = j - i + 1;
str = s.substr(i, j - i + 1);
}
}
}
return str;
}
};
(动规)
class Solution {
public:
string longestPalindrome(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int ans = 0;
string str;
for (int i = s.size() - 1; i >= 0; i --) {
for (int j = i; j < s.size(); j ++) {
if (s[i] == s[j]) {
if (j - i <= 1) {
dp[i][j] = true;
if (ans < j - i + 1) {
ans = j - i + 1;
str = s.substr(i, j - i + 1);
}
}
else if (dp[i + 1][j - 1]) {
dp[i][j] = true;
if (ans < j - i + 1) {
ans = j - i + 1;
str = s.substr(i, j - i + 1);
}
}
}
}
}
return str;
}
};
(中心扩展法)
class Solution {
public:
string longestPalindrome(string s) {
string str;
int ans = 0;
for (int i = 0; i < 2 * s.size() - 1; i ++) {// 有2*size()-1次中心扩展
int left = i / 2;
int right = left + i % 2;
while (left >= 0 && right < s.size()) {
if (s[left] == s[right]) {
if (ans < right - left + 1) {
ans = right - left + 1;
str = s.substr(left, right - left + 1);
}
left --;
right ++;
} else break;
}
}
return str;
}
};
最长回文子序列
(动规)
1.dp数组的含义
因为需要在字符串中寻找所有的子序列,所以需要限定字符串序列的范围来得出所有的子序列。所以dp
数组需要两维,dp[i][j]
表示在[i, j]
范围内的字符序列,最长回味子序列的长度为dp[i][j]
。
2.递推公式
要判断一个字符序列是否为回文序列,而且dp[i][j]
已经限定了字符序列的范围了,所以就需要字符序列从外往内依次判断字符序列两端的字符是否相等,即s[i] == s[j]
。
情况1.如果s[i] == s[j]
,就要进一步缩小字符序列的范围来判断字符序列是否为回文序列。所以又有三种情况:
情况1.1.如果i == j
,即字符序列中只有一个字符,那么一个字符序列一定是回文的,所以dp[i][j] = 1
情况1.2.如果j = i + 1
,即字符序列中只有两个字符,并且这两个字符相等,所以这个字符序列也是回文的,即dp[i][j] = 2
情况1.3.如果j > i + 1
,即字符序列超过两个字符,这时候就没有办法准确的判断回文序列的长度了,因为这时dp[i][j]
就依赖与前一个状态dp[i + 1][j - 1]
,即字符序列在[i + 1, j - 1]
范围中的最长子序列长度加上s[i]
和s[j]
这两个相等的字符,就是[i, j]
范围内的最长回文子序列的长度。
情况2.如果s[i] != s[j]
,则说明这个字符序列的两端不能构成回文序列,所以就要往上倒退一个状态来递推当前的状态,范围在[i, j]
的字符序列倒退一步就是范围在[i + 1, j]
和[i, j - 1]
的子序列。所以在其中取最大值,即dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
。
3.初始化
一开始可以将所有的子序列默认是不回文的,即dp[i][j] = 0
。当然像单个字符这样的子序列一定是回文的,可以先初始化一遍即dp[i][i] = 1
,这样就可以不用在s[i] == s[j]
的时候判断字符序列的长度了。如果在循环递推的时候判断了,就可以不用提前初始化dp[i][i]
了。这二者选其一就可。
4.循环顺序
根据递推公式
可知后面的状态推出前面的状态,所以第一维循环需要逆序枚举。
5.举例子
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = s.size() - 1; i >= 0; i --) {
for (int j = i; j < s.size(); j ++) {
if (s[i] != s[j]) {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
} else {
if (j - i <= 1) dp[i][j] = j - i + 1;
else dp[i][j] = dp[i + 1][j - 1] + 2;
}
}
}
return dp[0][s.size() - 1];
}
};