1143. 最长公共子序列 - 力扣(LeetCode)
其实第一反应是双指针分别移动比较,但是稍微细想就发现行不通,找不到清晰的移动策略:当i, j所指向字符不同的时候,可以移动 i ,也可以移动 j ,也可以都移动。
所以就会想到回溯法枚举,回溯法本质上可行,但是字符串太长的话复杂度太高,那能不能用动态规划呢?那就得先从回溯理一下思路:
回溯的思路,从 a[0]和 b[0]开始,依次考察两个字符串中的字符是否匹配:
反过来也就是说,如果要求 a[0…i] 和 b[0…j] 的最长公共长度 max_lcs(i, j),只可能通过下面三个状态转移过来:
把这个转移过程,翻译为状态转移方程:
如果:a[i]==b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1));
如果:a[i]!=b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1));
其中max表示求三数中的最大值。
翻译成代码,特殊处理第一行和第一列(访问不到i-1/j-1):
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
//f[i][j]表示text1的前i个字符与text2的前j个字符的最长公共子序列长度
vector> f(m, vector(n, 0));
if(text1[0] == text2[0]) f[0][0] = 1;
for(int i = 1; i < n; ++i){//第0行,text1的第0个字符与text2的前i个字符的最长公共子序列长度
if(text1[0] == text2[i]) f[0][i] = 1;
else f[0][i] = f[0][i-1];
}
for(int i = 1; i < m; ++i){//第0列,text2的第0个字符与text1的前i个字符的最长公共子序列长度
if(text2[0] == text1[i]) f[i][0] = 1;
else f[i][0] = f[i-1][0];
}
for(int i = 1; i< m; ++i){
for(int j = 1; j < n; ++j){
if(text1[i] == text2[j]) f[i][j] = max(max(f[i-1][j-1]+1, f[i-1][j]), f[i][j-1]);
else f[i][j] = max(max(f[i-1][j-1], f[i-1][j]), f[i][j-1]);
}
}
return f[m-1][n-1];
}
};
如果要进行空间优化倒是很简单,因为只涉及到本行和前一行的数据,所以使用两行的滚动数组就可以,或者使用一维数组(这题的一维数组有点难度,因为很容易出错,不能轻易覆盖掉之前的值)也是可以。
注释有点多不好懂,空间占用不是很多的话其实不是那么必要。
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector f(n, 0);
if(text1[0] == text2[0]) f[0] = 1;
for(int i = 1; i < n; ++i){//第0行,text1的第0个字符与text2的前i个字符的最长公共子序列长度
if(text1[0] == text2[i]) f[i] = 1;
else f[i] = f[i-1];
}
//for(auto c : f) cout << c << " ";
//cout << endl;
for(int i = 1; i< m; ++i){
int pre = text1[i] == text2[0] ? 1 : f[0];//第一个值
int cur = 0;
for(int j = 1; j < n; ++j){
if(text1[i] == text2[j]) cur = max(max(f[j-1]+1, f[j]), pre);
else cur = max(max(f[j-1], f[j]), pre);
f[j-1] = pre;//上次的值可以填充了,因为下一轮循环上次就变为上上次,状态转移不需要上上次的值
pre = cur;//当前值需要记录下来,不能进行填充,因为下一轮循环会用到这个位置的值,填充的话就给覆盖掉了
}
//填充最后一个值
f[n-1] = pre;//这儿要用pre,不能用cur,因为可能循环没有进去。
}
return f[n-1];
}
};
为了让代码更加简洁,我们可以对之前二维数组的代码进行优化:
之前我们需要特殊处理第一行和第一列,但是如果使用哨兵
处理,就可以将第一行/列的处理并入到后续的通用代码块。哨兵类似链表里面常用的哑巴节点,添加哨兵的目的是为了使边界条件更加容易处理:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
//f[i][j]表示text1的前i-1个字符与text2的前j-1个字符的最长公共子序列长度
vector> f(m+1, vector(n+1, 0));//行列均多申请一个位置
for(int i = 1; i< m+1; ++i){
for(int j = 1; j < n+1; ++j){
//注意这儿比较的是i-1和j-1的位置
if(text1[i-1] == text2[j-1]) f[i][j] = max(max(f[i-1][j-1]+1, f[i-1][j]), f[i][j-1]);
else f[i][j] = max(max(f[i-1][j-1], f[i-1][j]), f[i][j-1]);
}
}
return f[m][n];
}
};
其实上面的那个转移方程:
如果:a[i]==b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1));
如果:a[i]!=b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1));
虽然是对的,但是不够简单,更直接的转移方程应该是这样的:
如果:a[i]==b[j],那么:max_lcs(i, j)就等于:
max_lcs(i-1,j-1)+1;
如果:a[i]!=b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1, j), max_lcs(i, j-1));
其实仔细想想就明白了,可以看动态规划之最长公共子序列(LCS) - 最长公共子序列 - 力扣(LeetCode)这位大佬的解释。
所以上面的代码这样写就可以:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
//f[i][j]表示text1的前i-1个字符与text2的前j-1个字符的最长公共子序列长度
vector> f(m+1, vector(n+1, 0));//行列均多申请一个位置
for(int i = 1; i< m+1; ++i){
for(int j = 1; j < n+1; ++j){
if(text1[i-1] == text2[j-1]) f[i][j] = f[i-1][j-1]+1;
else f[i][j] = max(f[i-1][j], f[i][j-1]);
}
}
return f[m][n];
}
};
当然使用一维数组优化空间也是一样的思路:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector f(n+1, 0);
for(int i = 1; i< m+1; ++i){
int pre = 0, cur = 0;
for(int j = 1; j < n+1; ++j){
if(text1[i-1] == text2[j-1]) cur = f[j-1]+1;
else cur = max(f[j], pre);
f[j-1] = pre;//下轮循环上次变为上上次,状态转移不需要上上次的值(就代表可以进行更新了)
pre = cur;//当前值需要记录,不能更新,因为下轮循环会用到
}
f[n] = pre;//更新每一行的最后一个值
}
return f[n];
}
};