目录
最长公共子序列(LCS)
我们在写论文或者文章的时候,是否被判定为抄袭,其思想就是使用求最长公共子序列方法查找两篇文章相似度高不高。
子序列是指某个序列中任意地去掉若干个不一定连续的元素后形成的序列。如果一个元素也不去掉,其本身也是它的一个子序列。设序列X,Y:
如果存在X的元素构成的严格递增序列,使得
则Y是X的一个子序列。
例如BDAB、ABCBDAB是ABCBDAB的一个子序列。
如果存在Z同时是X和Y的子序列,则称Z是X和Y的公共子序列。子序列的长度则是指子序列的元素个数。最长公共子序列问题就是在给定的和序列中,求出这两个序列的最长公共子序列。
动态规划分析思路
如果用枚举法暴力求解,首先得列举出X所有的子序列,依次检查X的每个子序列是否在Y序列中出现。设,m,k分别是X和Y序列元素个数。例如,
,子序列有3个<A,B,AB>,子序列个数;
,子序列有7个<A,B,C,AB,AC,BC,ABC>,子序列个数;
,子序列有15个<A,B,C,D,AB,AC,AD,BC,BD,CD,ABC,ABD,ACD,BCD,ABCD>,子序列个数;
依次类推,暴力枚举算法下,得执行获取子序列,再执行次子序列是否在Y中出现,则最终求出所有子序列时间复杂度为,呈现指数级别的时间复杂度,速度那是相当的慢。如果用动态规划法去求最长公共子序列,那将是大大提高效率。
动态规划的一般步骤:
- 刻画出最优解的子结构;
- 确定动态转移方程;
- 自底向上计算出最优解;
- 根据全局最优解输出某一个最优解的值;
构造子结构
设序列X,Y,Z:
;
;
,其中,Z是X和Y的一个最长公共子序列。
(1).如果,那么当时,是和的一个最长公共子序列;
假如,X=ABDC,Y=KBADC,Z=BDC,等式意思就是X、Y、Z最后一个元素“C”相等。如下图所示:
此时=BD、=ABD、=KBAD,是和的一个最长公共子序列。
(2).如果,那么当时,将是和的一个最长公共子序列;
假如,X=ABDCG,Y=KBADC,Z=BDC,意思就是X最后一个元素“G”和Y最后一个元素“C”不相等,Z最后一个元素和X最后一个元素也不相等,如下图所示:
即,这样肯定是和的一个最长公共子序列。
(3).如果,那么当时,将是和的一个最长公共子序列;
举个例子对这句话理解一下,比如X=ABDC,Y=KBADCG,Z=BDC,同样的意思就是X最后一个元素“G”和Y最后一个元素“C”不相等,Z最后一个元素和Y最后一个元素也不相等。如下图所示:
即,这样肯定是和的一个最长公共子序列。
确定转移方程
设表示序列和的最长公共子序列的长度,根据子结构的构造出以下方程:
这里主要是比较难理解,但是结合下图一起思考,就能茅舍顿开了。
比如图中黑圈的是,,,属于的情况,此时=2,=1,=2;看得出此时>,我们取红色矩形中的Y序列BDC和X序列ABC,那刚好有最长公共子序列BC;如果此时我们取蓝色矩形中的Y序列BDCA和X序列AB,那最长公共子序列只有A或者B。所以,我们取红色矩形的X和Y序列才正确。也就是说,在的情况下,=取大的序列长度。
自底向上计算出最优解
根据上面的转移方程,设、为例构造出一个二维表格,很直观的可以计算出最优解出来:
在这里得维护一个标记数组用来存储上面二维表递归路径的方向,输出结果如下:
='↖'左上角方向,标记,此时剩余的最长公共子序列在和序列中;
='←↑'左上两边方向,标记=,此时剩余的最长公共子序列要么在序列中,要么序列中;
='←'向左方向,标记>,也就是说此时最长公共子序列在和序列中。如下图所示,如果红色的框对应的就是和序列。
='↑'向上方向,标记<, 也就是说此时最长公共子序列在和序列中。如下图所示,如果红色的框对应的就是和序列。
JavaScript求出最优解代码如下:
// 最长子序列
function lcsLength(str1, str2) {
var ln1 = str1.length;
var ln2 = str2.length;
var lcs = [];
var dir = [];
for (var i = 0; i <= ln1; i++) {
lcs[i] = [];
lcs[i][0] = 0;
dir[i] = [];
dir[i][0] = -1;
}
for (var j = 0; j <= ln2; j++) {
lcs[0][j] = 0;
// b[0][j] = -1;
}
for (i = 1; i <= ln1; i++) {
for (j = 1; j <= ln2; j++) {
if (str1[i - 1] == str2[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
dir[i][j] = '↖';
} else if (lcs[i - 1][j] > lcs[i][j - 1]) {
lcs[i][j] = lcs[i - 1][j];
dir[i][j] = "↑";
} else if (lcs[i - 1][j] < lcs[i][j - 1]) {
lcs[i][j] = lcs[i][j - 1];
dir[i][j] = "←";
} else {
lcs[i][j] = lcs[i][j - 1];
dir[i][j] = "←↑";
}
}
}
// lcss = lcs;
// console.log(lcs);
return dir;
}
var X = "ABCBDAB";
var Y = "BDCABA";
var lcsdirect = lcsLength(X, Y);
代码计算出的最长子序列长度为4,其实计算长度是没有难度的,最难是如何输出所有最长公共子序列,而且公共子序列不唯一,如何输出所有的公共子序列呢?
输出全局最优解
设置目的就是记忆求最长公共子序列长度过程中的路径方向跟踪,即当='↖'时,表示为LCS的其中一个元素。如下图所示:
从上图中开始递归遍历找到 ='↖'的路径。当递归结束时,可以看出得到三条路径,也就是说, 和 最长公共子序列应该有三条。用JavaScript输出所有最长子序列代码如下:
// tmpstr保存当前序列;
// lcsLen当前剩余的序列长度;
function print_cls(arr, str1, i, j, tmpstr, lcsLen) {
if (i == 0 || j == 0) {
console.log(tmpstr);
return;
} else if (arr[i][j] == '↖') {
lcsLen = lcsLen - 1;
tmpstr = str1[i - 1] + tmpstr;
print_cls(arr, str1, i - 1, j - 1, tmpstr, lcsLen);
} else if (arr[i][j] == "↑") {
print_cls(arr, str1, i - 1, j, tmpstr, lcsLen);
} else if (arr[i][j] == "←") {
print_cls(arr, str1, i, j - 1, tmpstr, lcsLen);
}
else {
print_cls(arr, str1, i, j - 1, tmpstr, lcsLen);
print_cls(arr, str1, i - 1, j, tmpstr, lcsLen);
}
}
print_cls(lcsdirect, X, X.length, Y.length, '', lcsdirect[X.length][Y.length]);
以上方法执行之后,可以输出3个最长子序列:
总结
- 为了理解动态规划求解子序列问题,我花了不少时间去理解和思考。直到今早醒来打开电脑把代码重新调试成功,断断续续用时差不多2天的时间才真正吃透子序列问题,必须好好写出这篇文章记录下来。自己脑瓜不灵光,只能花费更长时间去思考和理解。
- 看了很多动态规划例子,我觉得动态规划思想是一种抽象思维方式。理解动态规划需要跳出常规的技术思维,用一种自底向上的思维方式去思考。比如说求子序列问题,一般性思考可能会立马想到就是暴力枚举法,不断的for循环,一旦跳进这种思维陷阱,思路就没有章法了。当然这种思维需要通过案例不断的训练。