1.定义
给定一个序列,在删去若干个元素后得到的序列即为子序列。
如图1所示,序列{A, B, C, B, D, A, B}的其中一个子序列就是{B, C, B, A}。
如图2,序列{A, B, C, B, D, A, B}和序列{B, D, C, A, B, A}的其中一个子序列就是{B, C, B, A},并且这个公共子序列是最长公共子序列(不唯一)。
2.动态规划
能使用动态规划解决的问题需要具有 最优子结构和重叠子问题的性质。
假设X = {, , ..., } 和Y={, , ..., } 的最长公共子序列为Z = {, , ..., },那么:
- 如果 = ,显然有 = = ,即是和的最长公共子序列;
- 如果 != , 且 != , 则是和的最长公共子序列;
- 如果 != , 且 != , 则是和的最长公共子序列。
从上面的分析可知,对于最长公共子序列,显然具有最优子结构问题,即原问题的最优解包含着子问题的最优解。
要找出X和Y的最长公共子序列,可以递归进行:
- 当 = 时,找出和的最长公共子序列,然后再加上zk即可得到X和Y的最长公共子序列;
- 当 != 时,则需要找出和的最长公共子序列以及和的最长公共子序列,找出两个的最长公共子序列即为所求。
从递归结构来看,显然也具有重叠子问题的性质。由此,我们可以得到以下的式子:
3.求解
3.1 递归求解
可以直接根据上面的推导公式直接得到对应的代码:
int LCSLength(const char* x, const char* y, int i, int j){
if (i == 0 || j == 0)
return 0;
else if (x[i] == y[j])
return LCSLength(x, y, i - 1, j - 1) + 1;
else
return max(LCSLength(x, y, i, j - 1), LCSLength(x, y, i - 1, j));
}
上述代码完全是按照图3的推导公式得到的,递归看上去很是简单,但是里面包含了大量的重复计算。
3.2 动态规划
动态规划是自底向上进行计算的,相较于递归,它可以显著提高算法的效率。
/**
* 动态规划自底向上求解
* @param m: 字符串x的长度
* @param n: 字符串y的长度
* @param x: 字符串x
* @param y: 字符串y
* @param c: c为二维数组c[i][j]记录的是Xi和Yj的最长公共子序列的长度
* @param b: b为二维数组,在这里表现为路径,可以较为方便地获取到最长公共子序列
*/
void LCSLength(int m, int n,const char* x,const char* y, int*** c, int*** b) {
//第0行和第0列默认为0 因此共 m + 1行 n + 1列
int** answer = new int* [m + 1];
int** path = new int* [m + 1];
for (int i = 0; i <= m; i++) {
answer[i] = new int[n + 1];
memset(answer[i], 0, sizeof(int) * (n + 1));
path[i] = new int[n + 1];
memset(path[i], 0, sizeof(int) * (n + 1));
}
//重点在于二重循环
int i = 0, j = 0;
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (x[i - 1] == y[j - 1]) {
answer[i][j] = answer[i - 1][j - 1] + 1;
path[i][j] = 1;
}
else if (answer[i - 1][j] >= answer[i][j - 1]) {
answer[i][j] = answer[i - 1][j];
path[i][j] = 2;
}
else {
answer[i][j] = answer[i][j - 1];
path[i][j] = 3;
}
}
}
if (c != nullptr)
* c = answer;
if (b != nullptr)
* b = path;
}
LCSLength函数存在着两个输出,分别是c和b这两个二维数组,当传入时,只需要传入一个二维数组的地址即可,对应的内存会在LCSLength函数内部进行申请。
可以看到,c对应的变量名称为answer,b对应的则是path;它们明显都比字符串的长度多了一个单位,这样做的目的是为了避免边界条件的判断。
在初始状态下 c[0][] 和 c[][0] 的元素值都为0(其他元素没必要设为0,因为之后的双重循环中会对每一个元素都进行赋值,这里只是用了memset函数全部设为了默认值0)
接下来,进行简单地推导一下:
令x = "ABCBDAB" y = "BDCABA";
一开始i = 1, j = 1。然后x[0] != y[0],此时有从c[1][1] = c[i-1][j]=0;
i = 1, j = 2, x[0] != y[1],此时有c[1][2] = 0;
i = 1, j = 3, x[0] != y[2],此时c[1][3] = 0;
i = 1, j =4, x[0] == y[3],此时c[1][4] = c[0][3] + 1 = 1;
以此类推。。。
如上图所示,之所以加上了额外的一行以及一列的值为0的元素,是为了简化循环中的判断条件,它起到的作用类似于单链表中的表头(避免了边界的判断)。
3.2.1 最长公共子序列的获取
在代码中,还存在着一个名为b的二维数组,它的作用是较为便利地获取最长公共子序列:
/**
* 输出最长公共子序列的其中一个
* @param i: 字符串x的长度
* @param j: 字符串y的长度
* @param x: 字符串
* @param path: 路径
*/
void LCS(int i, int j,const char* x, int** path) {
stack<char> stack;
while (i != 0 && j != 0) {
if (path[i][j] == 1) {
stack.push(x[i - 1]);
i -= 1;
j -= 1;
}
else if (path[i][j] == 2)
i -= 1;
else if (path[i][j] == 3)
j -= 1;
}
while ( !stack.empty())
{
cout << stack.top();
stack.pop();
}
}
LCS函数根据b获取到X和Y的最长公共子序列之一,其内部使用了栈来代替递归。在LCSLength中,只有x[i - 1] == y[j - 1]时,才会设置path[i][j] = 1,否则获取max(c[ i ][j - 1], c[i - 1][ j ]),并赋予path[i][j]2或者是3。在LCS函数中则根据LCSLength中设置的值来进行逆推。
其实b这个数组是可以被c代替的。数组c[i][j]的值仅由c[i - 1][j - 1]、c[ i ][j - 1]以及c[i - 1][ j ]这三个元素的值所确定。
void LCS(const char* x, const char* y, int i, int j, int** c) {
stack<char> stack;
while (i > 0 && j > 0) {
if (c[i][j] == c[i][j - 1] + 1) {
stack.push(x[i - 1]);
j = j - 1;
i = i - 1;
}
else if (c[i][j] == c[i - 1][j] + 1) {
stack.push(x[i - 1]);
i = i - 1;
j = j - 1;
}
else {
//j = j - 1;
i = i - 1;
}
}
while (!stack.empty())
{
cout << stack.top();
stack.pop();
}
cout << endl;
}
无论有没有用到b数组,上面的两个LCS函数目前都是只能获取最长公共子序列之一。这种情况在第二个LCS函数中可以简单地修改else的语句块中是让i --还是j--或者是同时进行,以此来决定当前c[ i ][ j ]选择哪一个方向。
3.2.2 最长公共子序列的长度
由于在LCSLength函数中,c[ i ][ j ]表示的就是Xi Yj的最长公共子序列的长度,这里可以简单地查数组就可以知道。不过,如果只是获取最长公共子序列的长度的话,可以只需要两行的数组空间来交替使用。从LCSLength的代码中可以了解到,数组c[i][j]的值仅由c[i - 1][j - 1]、c[ i ][j - 1]以及c[i - 1][ j ]这三个元素的值所确定。c[i - 1][j - 1] c[i - 1][j]是前一行,c[ i ][j - 1]则是当前行中之前已经计算得到。这两行数组交替使用则可以确定最长公共子序列的长度。
4.参考
- 《计算机算法设计与分析》 王晓东
- 动态规划——最长公共子序列LCS及模板