1. 写在前面
在前三篇博客中,分别介绍了钢条切割问题,矩阵链乘法问题,以及对动态规划问题做了一个小结。在这篇博客中,将继续介绍一个动态规划问题:最长公共子序列问题。
2. 提出问题
我们有时候会遇到比较两个字符串“相似度”的问题,例如:比较给定字符串X和Y的相似度,其中X = ABCBDAB,Y = BDCABA。根据需要的不同,我们可以给出不同的“相似度”的定义。比如,我们可以定义:若一个字符串是另一个字符串的子串,则它们相似;再比如,若我们只关心字符串中字符的出现的频率,我们可定义:若一个字符串中各字符出现的频率与另一字符串中各字符出现的频率相同,则它们相似。
最长子序列(longest-common-subsequence,LCS)便可看成是一种相似度的描述:若存在第三个字符串满足,它的所有字符在被比较的两个字符串中都以相同的顺序出现(但不必连续出现),则被比较的两个字符串相似,且第三个字符串越长,则认为被比较的两个字符串越相似,该字符串被称为被比较的两个字符串的最长子序列。
以前面的问题为例,比较X = ABCBDAB和Y = BDCABA的相似度。我们可以找到第三个字符串Z1 = BCA,它的每个字符在X和Y中都以下标递增的顺序出现过(在X中下标分别是<1, 2, 5>,在Y中是<0, 2, 3>),但Z1不是X和Y的最长公共子序列,因为存在更长的公共子序列Z2 = BCBA或Z3 = BDAB。
下面给出最长子序列问题的形式化描述:
对于给定的子序列(X=x_1x_2...x_n)和(Y = y_1y_2...y_n),找出一个最长的严格递增的X的下标序列(i_1i_2...i_n),对所有的(j=1, 2, 3, ..., k),满足(X)ij=(Y_j)。
3. 解决问题
3.1 暴力求解
最简单算法是,直接考察字符串X的所有下标组合的可能性。对于长度为n的字符串,共需考察(2^n)种情况。因此暴力求解 的运行时间是指数阶,对于较长的序列是不适用的。
3.2 动态规划
对于该问题,直觉告诉我们,可能能用动态规划来求解。
正如小结中给出的那样,一个问题能够使用动态规划方法来求解,必须满足两个必要条件:1. 最优子结构;2. 子问题折叠
3.2.1 最优子结构
令(X=x_1x_2...x_m, Y = y_1y_2...y_n)为两序列,(Z=z_1z_2...z_k)为X和Y的任意LCS,我们可得到如下结论:
- 如果(x_m = y_n),则(z_k = x_m = y_n)且Zk-1是Xm-1和Yn-1的一个LCS;
- 如果(x_m eq y_n),则(z_k eq x_m)意味着Z是Xm-1和Y的一个LCS;
- 如果(x_m eq y_n),则(z_k eq y_n)意味着Z是X和Yn-1的一个LCS;
下面对如上结论做一下说明:
结论1是很明显的,若(x_m = y_n),则(x_m (y_n))必然会出现在序列Z中,且会出现在最末位置,即(z_k = x_m = y_n);同时除去X,Y,Z的最末尾元素,Zk-1仍然是Xm-1和Yn-1的LCS。
对于结论2,若(x_m eq y_n)且(z_k eq x_m),说明(x_m)没有出现在序列Z中,除去(x_m),Z仍然是Xm-1和Y的一个LCS;
结论3同理于结论2。并且,事实上,若有(z_k eq x_m)或(z_k eq y_n),即说明(x_m eq y_n)。
以上结论立即告诉我们,两个序列的LCS包含两个序列的前缀的LCS,因此LCS问题具有最优子结构性质。
3.2.2 子问题折叠
我们用(c[i, j])表示(x_i)和(y_i)的LCS的长度,对于如上结论,我们可用一个简单的递归式加以描述:
从递归中不难看出,LCS问题的许多子问题是被另外一些子问题共享的,即满足子问题折叠性质。
3.2.3 解决方案
有了3.2.1与3.2.2的分析,根据递归式,我们可以很容易的设计出一个动态规划算法来求解LCS问题。如下给出的是一个Java的实现方案(自底向上):
/**
* 计算c[i][j]
* @param x
* @param y
* @return
*/
public static int[][] step1(String x, String y) {
if (x == null || x.isEmpty() || y == null || y.isEmpty()) {
return null;
}
int[][] c = new int[x.length()+1][y.length()+1];
// case1:i=0或j=0,c[i][j] = 0
// 对于java,可以不做以下步骤,因为默认就已初始化为0
// for (int i = 0; i < x.length(); i++) {
// c[i][0] = 0;
// }
// for (int i = 0; i < y.length(); i++) {
// c[0][i] = 0;
// }
// 自底向上求解
for (int i = 1; i < x.length() + 1; i++) {
for (int j = 1; j < y.length() + 1; j++) {
// case2:若i, j > 0且x_i = y_i,则c[i, j] = c[i-1][j-1]+1
if (x.charAt(i - 1) == y.charAt(j - 1)) {
c[i][j] = c[i - 1][j - 1] + 1;
} else {// case3:若i, j > 0且x_i = y_i,则c[i, j] = c[i-1][j-1]
c[i][j] = Math.max(c[i][j - 1], c[i - 1][j]);
}
}
}
return c;
}
/**
* 根据c[i][j]构造出LCS
* @param x
* @param c
* @return
*/
public static String step2(String x, int[][] c){
if(c == null || c.length == 0 || c[0].length == 0){
return null;
}
StringBuilder result = new StringBuilder();
for (int i = c.length - 1, j = c[0].length - 1; i > 0 && j > 0;) {
if(c[i][j] == c[i - 1][j]){
i--;
}else if(c[i][j] == c[i][j - 1]){
j--;
}else{
result.append(x.charAt(i-1));
i--;
j--;
}
}
return result.reverse().toString();
}
做如下测试:
public static void main(String[] args) {
int[][] c = step1("ABCBDAB", "BDCABA");
String result = step2("ABCBDAB", c);
System.out.println(result); // BCBA
}
3.2.4 时间效率
可以很容易看出,以上动态规划算法的时间效率为Θ(mn)。