我的理解:
在字符串或序列中,子序列是指从原始序列中删除零个或多个元素后得到的序列,而且相对顺序保持不变。换句话说,子序列是从原始序列中选择出的元素,它们保持相对顺序,但可以跳过一些元素或者不选择任何元素。
举个例子,考虑序列 "abcd",它的一些子序列包括:"a","b","c","d","ab","ac","ad","bc","bd","cd","abc","abd","acd","bcd","abcd"。注意,这些子序列可能不一定是连续的,可以跳过某些元素。
子序列的长度可以是从0到原始序列长度的任意值。一个长度为 n 的序列,它有 2^n 个不同的子序列,包括空序列(长度为0的子序列)和原始序列本身(长度为 n 的子序列)。
子序列在算法和字符串处理中经常出现,它们可以用于各种问题,如最长公共子序列、最长递增子序列、字符串匹配等。
公共子序列是指在两个或多个序列中都存在的一个序列,它不要求在原序列中是连续的,但要求在每个序列中的相对顺序保持一致。
换句话说,给定两个序列,一个公共子序列是通过从每个序列中选择零个或多个元素形成的序列,而且这些元素在原序列中的相对顺序保持一致。公共子序列中的元素不一定是连续的,可以跳过某些元素。
举个例子,考虑序列 "ABCD" 和 "ACDF",其中一个公共子序列是 "AD"。在第一个序列中,我们选择了第1个和第4个元素;在第二个序列中,我们选择了第1个和第3个元素。这样形成的序列 "AD" 是两个序列的一个公共子序列。
在计算机科学和算法中,最长公共子序列(Longest Common Subsequence,简称 LCS)是公共子序列问题中最经典和常见的形式。在最长公共子序列问题中,我们要找到两个序列中的最长的公共子序列的长度或具体的序列。该问题在字符串处理、比较和匹配等领域中具有广泛应用。
最长公共子序列(Longest Common Subsequence,LCS)是指在两个或多个序列中找到的具有最长长度的公共子序列。
给定两个序列,最长公共子序列是通过从每个序列中选择零个或多个元素形成的一个序列,而且这些元素在原序列中的相对顺序保持一致。最长公共子序列中的元素不一定是连续的,可以跳过某些元素。
举个例子,考虑序列 "ABCD" 和 "ACDF",其中一个最长公共子序列是 "AD"。在第一个序列中,我们选择了第1个和第4个元素;在第二个序列中,我们选择了第1个和第3个元素。这样形成的序列 "AD" 是两个序列的一个最长公共子序列。
最长公共子序列问题是计算机科学中经典的问题之一,它具有广泛的应用,如字符串比较、版本控制、DNA序列比对、文本相似性等。解决最长公共子序列问题的常见方法是使用动态规划算法,通过填充一个二维表格来求解最长公共子序列的长度或找到具体的序列。
① 用反证法。若z!=Xm,则{z1,z2,....,zk,Xm}是X和Y的长度为k+1的公共子序列。这与 Z是X和Y的最长公共子序列矛盾,因此必有zk=Xm=yn。由此可知,Zk-1是Xm-1和Yn-1的长度大于k-1的公共子序列。若Xm-1和Yn-1,有长度大于k-1的公共子序列W,则将 Xm加在其尾部产生X和Y的长度大于k的公共子序列。此为示盾。所以,Zk-1是Xm-1和Yn-1的最长公共子序列。
② 由于Zk!=Xm,Z是Xm-1和Y的公共子序列,若Xm-1和Y有长度大于k的公共子序列w,则w也是X和Y的长度大于k的公共子序列。这与Z是X和丫的最长公共子序列矛盾。由此可知,Z是 X和Y的最长公共子序列。
③ 証明方法与②类似。 由此可见,两个序列的最长公共子序列包含了这两个序列的前级的最长公共子序列。因此,最长公共子序列问题具有最优子结构性质。
这个证明是关于最长公共子序列(LCS)问题的最优子结构性质的证明。最长公共子序列问题是在两个序列中找到一个最长的共同子序列的问题,而最优子结构性质是指问题的最优解可以通过子问题的最优解来构建。
证明中使用了反证法的思想来说明最优子结构性质。我将按照证明的步骤来解释其意义:
1. 第一步是通过反证法证明 Zk-1 是 Xm-1 和 Yn-1 的最长公共子序列。假设 Zk-1 不是 Xm-1 和 Yn-1 的最长公共子序列,即存在 Xm-1 和 Yn-1 的长度大于 k-1 的公共子序列 W。这是一个假设。
2. 接下来,通过将 Xm 加在 W 的尾部,构造出一个长度大于 k 的公共子序列。这是基于假设得出的结论。
3. 但是,我们知道 Z 是 X 和 Y 的最长公共子序列,所以根据这个事实,这个推导出的长度大于 k 的公共子序列与 Z 的长度相矛盾。因此,假设是错误的,可以推出 Zk-1 是 Xm-1 和 Yn-1 的最长公共子序列。
4. 类似地,通过反证法证明 Z 是 X 和 Y 的最长公共子序列。假设 Z 不是 X 和 Y 的最长公共子序列,即存在 X 和 Y 的长度大于 k 的公共子序列 w。然后通过推导,得出 Z 是 X 和 Y 的最长公共子序列,与假设矛盾。
综上所述,通过反证法的推理过程,证明了最长公共子序列问题具有最优子结构性质。也就是说,可以通过子问题的最优解来构建原问题的最优解。
这个证明表明,对于最长公共子序列问题,我们可以使用动态规划算法来求解,通过解决子问题并利用子问题的最优解来构建整体的最优解。
功能代码:
void LCSLength(int m,int n,char *x,char *y,int **c,int **b)
{
int i,j;
for(i=1;i<=m;i++)
{
c[i][0]=0;
}
for(i=1;i<=n;i++)
{
c[0][i]=0;
}
for(i=1;i<=m;i++)
{
for(j=1;j<=n;j++)
{
if(x[i]==y[j])
{
c[i][j]=c[i-1][j-1]+1;
b[i][j]=1;
}
else if(c[i-1][j]>=c[i][j-1])
{
c[i][j]=c[i-1][j];
b[i][j]=2;
}
else
{
c[i][j]=c[i][j-1];
b[i][j]=3;
}
}
}
}
这段代码实现了最长公共子序列(LCS)问题的动态规划解法。
函数`LCSLength`接受以下参数:
- `m`:第一个序列的长度。
- `n`:第二个序列的长度。
- `x`:第一个序列的字符数组。
- `y`:第二个序列的字符数组。
- `c`:二维数组,用于存储最长公共子序列的长度。
- `b`:二维数组,用于指示最长公共子序列的构造方式。
代码中的主要逻辑如下:
1. 初始化边界条件:将`c[i][0]`和`c[0][i]`设置为0,表示一个序列为空时,最长公共子序列的长度为0。
2. 遍历两个序列的字符数组,对于每对字符`x[i]`和`y[j]`:
- 如果`x[i]`等于`y[j]`,说明这两个字符可以作为最长公共子序列的一部分,所以将`c[i][j]`设置为`c[i-1][j-1]+1`,表示在去掉最后一个字符之前的最长公共子序列的基础上加上当前字符。
- 如果`x[i]`不等于`y[j]`,则需要根据之前计算得到的结果来决定最长公共子序列的长度。比较`c[i-1][j]`和`c[i][j-1]`的大小,如果前者大于等于后者,则将`c[i][j]`设置为`c[i-1][j]`,表示最长公共子序列来自于序列`x`去掉最后一个字符的子序列;否则,将`c[i][j]`设置为`c[i][j-1]`,表示最长公共子序列来自于序列`y`去掉最后一个字符的子序列。
- 根据`x[i]`和`y[j]`的关系,更新`b[i][j]`的值,用于指示构造最长公共子序列时的操作方式:1表示选择当前字符,2表示选择`x[i-1]`,3表示选择`y[j-1]`。
3. 最终,`c[m][n]`中存储的就是最长公共子序列的长度,`b`数组可以用于构造最长公共子序列。
这段代码通过填充`c`和`b`数组,使用自底向上的动态规划方法,计算出了最长公共子序列的长度和构造方式。
void LCS(int i, int j, char *x, int **b)
{
if (i == 0 || j == 0) {
return;
}
if (b[i][j] == 1) {
LCS(i - 1, j - 1, x, b);
cout << x[i];
} else if (b[i][j] == 2) {
LCS(i - 1, j, x, b);
} else {
LCS(i, j - 1, x, b);
}
}
我对这段代码的理解:
- 第2行开始的函数定义,接受参数:整数 `i` 和 `j`,字符指针 `x`,以及二维整数指针 `b`。
- 第4行进行递归终止条件的判断。如果 `i` 或 `j` 为0,即其中一个序列为空,说明已经遍历完了一个序列的所有字符,所以终止递归。
- 第7行开始的 `if` 语句处理当 `b[i][j]` 的值为1的情况。这表示当前字符 `x[i]` 是最长公共子序列的一部分。
- 第8行递归调用 `LCS` 函数,并将索引 `i-1` 和 `j-1` 作为参数,向前移动到上一个位置。
- 第9行输出当前字符 `x[i]`,即最长公共子序列中的一个字符。
- 第11行开始的 `else if` 语句处理当 `b[i][j]` 的值为2的情况。这表示最长公共子序列来自于 `x` 的前一个位置。
- 第12行递归调用 `LCS` 函数,并将索引 `i-1` 和 `j` 作为参数,向前移动到上一个位置。
- 第14行开始的 `else` 语句处理当 `b[i][j]` 的值既不是1也不是2的情况。这表示最长公共子序列来自于 `y` 的前一个位置。
- 第15行递归调用 `LCS` 函数,并将索引 `i` 和 `j-1` 作为参数,向前移动到上一个位置。
这段代码通过递归的方式,根据 `b` 数组的值不断向前移动,从而构造出最长公共子序列。递归终止条件是当其中一个序列为空时,说明已经遍历完了所有的字符。在每一步递归中,根据 `b` 数组的值选择相应的操作,递归调用 `LCS` 函数,最终输出构造完成的最长公共子序列。
如果我们省略数组 `b`,可以改进算法以减少空间复杂度。以下是改进后的算法:
void LCSLength(int m, int n, char *x, char *y, int **c)
{
int i, j;
for (i = 1; i <= m; i++) {
c[i][0] = 0;
}
for (j = 0; j <= n; j++) {
c[0][j] = 0;
}
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (x[i] == y[j]) {
c[i][j] = c[i - 1][j - 1] + 1;
} else {
c[i][j] = max(c[i - 1][j], c[i][j - 1]);
}
}
}
}
void LCS(int i, int j, char *x, char *y, int **c)
{
if (i == 0 || j == 0) {
return;
}
if (x[i] == y[j]) {
LCS(i - 1, j - 1, x, y, c);
cout << x[i];
} else {
if (c[i - 1][j] >= c[i][j - 1]) {
LCS(i - 1, j, x, y, c);
} else {
LCS(i, j - 1, x, y, c);
}
}
}
改进后的算法中,我们省略了数组 `b`,直接使用一个二维数组 `c` 来记录最长公共子序列的长度。在 `LCSLength` 函数中,我们计算并填充了数组 `c`,而在 `LCS` 函数中,我们根据 `c` 数组的值进行回溯。
在 `LCS` 函数中,我们首先检查当前位置的字符是否相等。如果相等,则说明当前字符是最长公共子序列的一部分,我们进行递归调用并输出该字符。如果不相等,则根据 `c` 数组的值选择向左移动或向上移动,继续进行递归调用。通过这种方式,我们可以构造出最长公共子序列。这种改进后的算法在空间复杂度上更为高效,但仍能达到相同的结果。
C语言实现
#include
#include
#include
int max(int a, int b) {
return (a > b) ? a : b;
}
void LCS(char *X, char *Y, int m, int n) {
int L[m + 1][n + 1];
int i, j;
// 初始化第一行和第一列为0
for (i = 0; i <= m; i++) {
L[i][0] = 0;
}
for (j = 0; j <= n; j++) {
L[0][j] = 0;
}
// 填充 L 数组
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (X[i - 1] == Y[j - 1]) {
L[i][j] = L[i - 1][j - 1] + 1;
} else {
L[i][j] = max(L[i - 1][j], L[i][j - 1]);
}
}
}
// 打印最长公共子序列
int index = L[m][n];
char lcs[index + 1];
lcs[index] = '\0'; // 设置字符串结尾
i = m;
j = n;
while (i > 0 && j > 0) {
if (X[i - 1] == Y[j - 1]) {
lcs[index - 1] = X[i - 1];
i--;
j--;
index--;
} else if (L[i - 1][j] > L[i][j - 1]) {
i--;
} else {
j--;
}
}
// 打印最长公共子序列
printf("Longest Common Subsequence: %s\n", lcs);
}
int main() {
char X[] = "AGGTAB";
char Y[] = "GXTXAYB";
int m = strlen(X);
int n = strlen(Y);
LCS(X, Y, m, n);
return 0;
}
#include
#include
#include
using namespace std;
string LCS(string X, string Y) {
int m = X.length();
int n = Y.length();
vector> dp(m + 1, vector(n + 1, 0));
// 填充 dp 数组
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i - 1] == Y[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 构造最长公共子序列
string lcs;
int i = m, j = n;
while (i > 0 && j > 0) {
if (X[i - 1] == Y[j - 1]) {
lcs = X[i - 1] + lcs;
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs;
}
int main() {
string X = "AGGTAB";
string Y = "GXTXAYB";
string longestCommonSubsequence = LCS(X, Y);
cout << "Longest Common Subsequence: " << longestCommonSubsequence << endl;
return 0;
}
这个示例代码中,我们使用了一个二维向量 `dp` 来保存最长公共子序列的长度。我们首先初始化向量的大小为 `(m+1) x (n+1)`,并将所有元素初始化为0。然后使用两个嵌套的循环来填充向量 `dp`。在填充过程中,我们比较两个字符是否相等,如果相等则当前位置的值为左上角位置的值加1,否则取左方和上方两个位置的较大值。最后,我们根据 `dp` 向量的结果构造出最长公共子序列,并将其返回。
这个示例代码同样可以作为一个基础的起点,你可以根据具体的需求和问题进行修改和优化。
原理图:
最长公共子序列问题的重点、难点和易错点可以总结如下:
重点:
1. 理解问题:理解最长公共子序列问题的定义和要求,即在给定的两个序列中找到最长的共同子序列。
2. 动态规划思想:掌握动态规划的基本思想,将问题分解为子问题,并通过存储和重复利用子问题的解来提高效率。
3. 最优子结构性质:最长公共子序列问题具有最优子结构性质,即最优解可以由子问题的最优解构成。
4. 递推关系:找到递推关系式,将问题的解表示为子问题的解的组合。
难点:
1. 状态定义:确定问题的状态,即如何定义动态规划的状态。在最长公共子序列问题中,常用的状态是两个序列的前缀,或者是两个序列的某个位置。
2. 状态转移方程:确定状态之间的转移关系,即如何从一个状态转移到下一个状态。在最长公共子序列问题中,状态转移方程通常涉及比较两个序列的元素是否相等,并根据比较结果进行转移。
3. 边界条件:确定边界条件,即初始状态的值或边界状态的处理。在最长公共子序列问题中,通常需要初始化第一行和第一列的状态值。
易错点:
1. 状态索引:注意索引的起始位置和边界条件的处理。索引错误可能导致数组越界或计算错误的状态值。
2. 状态转移方程的正确性:在确定状态转移方程时,要确保它能正确地推导出问题的最优解,包括考虑相等和不相等两种情况。
3. 递归与迭代的区别:最长公共子序列问题可以通过递归或迭代的方式解决。理解它们之间的差异和实现细节是避免出错的关键。
解决最长公共子序列问题需要综合运用动态规划的思想和技巧,同时注意处理好状态定义、状态转移方程和边界条件,以确保算法的正确性和有效性。通过充分理解重点、克服难点并避免易错点,可以更好地解决这类问题。