LCS問題(最長公共子序列)
上一篇文章
所有代碼和markdown在chux0519/algs同步更新辆影。
計算LCS長度
遞歸解法
LCS問題是具有最優(yōu)子結(jié)構(gòu)的。
假設長度為m的字符串X[0..m-1]和長度為n的字符串Y[0..n-1],用L(X[0..m-1], Y[0..n-1]) 來表示它們的LCS長度控嗜。則可以得到以下結(jié)論:
- 如果X[m-1] = Y[n-1], 那么L(X[0..m-1], Y[0..n-1]) = 1 + L(X[0..m-2], Y[0..n-2])
- 如果X[m-1] != Y[n-1], 那么L(X[0..m-1], Y[0..n-1]) = MAX ( L(X[0..m-2], Y[0..n-1]), L(X[0..m-1], Y[0..n-2])
用JS描述如下
function lcs (str1, str2, len1, len2) {
if (len1 === 0 || len2 === 0) return 0
if (str1[len1] === str2[len2]) {
return 1 + lcs(str1, str2, len1 - 1, len2 - 1)
} else {
return Math.max(
lcs(str1, str2, len1 - 1, len2),
lcs(str1, str2, len1, len2 - 1)
)
}
}
進一步分析
假設X="AXYT", Y="AYZX"
呢撞,畫出以上代碼的調(diào)用圖,得到:
lcs("AXYT", "AYZX")
/
lcs("AXY", "AYZX") lcs("AXYT", "AYZ")
/ /
lcs("AX", "AYZX") lcs("AXY", "AYZ") lcs("AXY", "AYZ") lcs("AXYT", "AY")
可以看出lcs("AXY", "AYZ")被重復計算了胡本,隨著層數(shù)的增多,可以分析出畸悬,有許多重復子問題會出現(xiàn)侧甫,于是,可以利用記憶化或者制表來進行優(yōu)化算法。
制表解法
// tab
function lcsWithTab (str1, str2, len1, len2) {
var tab = new Array(len1 + 1).fill([])
for (var i = 0; i <= len1; i++) {
for (var j = 0; j <= len2; j++) {
if (i === 0 || j === 0) {
tab[i][j] = 0
} else if (str1[i - 1] === str2[j - 1]) {
tab[i][j] = tab[i - 1][j - 1] + 1
} else {
tab[i][j] = Math.max(
tab[i - 1][j],
tab[i][j - 1]
)
}
}
}
return tab[len1][len2]
}
制表解法披粟,通過定義好基線條件tab[i][j] = 0 where i ==0 || j == 0
咒锻,自底向上計算出結(jié)果,提升速度的同時消除了遞歸守屉。
回溯LCS
存儲回溯數(shù)組
在上述算法中惑艇,增加一個數(shù)組用于存儲回溯路徑即可,稍作改動如下拇泛。
function LCS (str1, str2, len1, len2) {
var tab = []
var back = []
for (var m = 0; m <= len1; m++) {
tab[m] = []
back[m] = []
for (var n = 0; n <= len2; n++) {
tab[m][n] = null
back[m][n] = null
}
}
for (var i = 0; i <= len1; i++) {
for (var j = 0; j <= len2; j++) {
if (i === 0 || j === 0) {
tab[i][j] = 0
back[i][j] = null
} else if (str1[i - 1] === str2[j - 1]) {
tab[i][j] = tab[i - 1][j - 1] + 1
back[i][j] = '↖'
} else if (tab[i - 1][j] > tab[i][j - 1]) {
tab[i][j] = tab[i - 1][j]
back[i][j] = '↑'
} else if (tab[i - 1][j] === tab[i][j - 1]) {
tab[i][j] = tab[i - 1][j]
back[i][j] = '←/↑'
} else {
tab[i][j] = tab[i][j - 1]
back[i][j] = '←'
}
}
}
return { tab: tab, bt: back }
}
運行
var str1 = 'GAC'
var str2 = 'AGCAT'
var result = LCS(str1, str2, str1.length, str2.length)
console.log(result.tab)
console.log(result.bt)
可以得到如下輸出:
[ [ 0, 0, 0, 0, 0, 0 ],
[ 0, 0, 1, 1, 1, 1 ],
[ 0, 1, 1, 1, 2, 2 ],
[ 0, 1, 1, 2, 2, 2 ] ]
[ [ null, null, null, null, null, null ],
[ null, '←/↑', '↖', '←', '←', '←' ],
[ null, '↖', '←/↑', '←/↑', '↖', '←' ],
[ null, '↑', '←/↑', '↖', '←/↑', '←/↑' ] ]
tab數(shù)組的輸出為LCS的長度敦捧,?bt數(shù)組的內(nèi)容是回溯的方向,例子取自維基百科碰镜。
輸出一個LCS
輸出LCS結(jié)果兢卵,需要利用bt數(shù)組進行回溯,只輸出一個LCS時绪颖,可以用以下回溯函數(shù)秽荤。
function backtrace (bt, str1, str2, i, j) {
if (i === 0 || j === 0) {
return ''
} else if (bt[i][j] === '↖') {
return backtrace(bt, str1, str2, i - 1, j - 1) + str1[i]
} else {
if (bt[i][j] === '←') {
return backtrace(bt, str1, str2, i, j - 1)
} else {
return backtrace(bt, str1, str2, i - 1, j)
}
}
}
console.log(backtrace(result.bt, str1, str2, str1.length, str2.length))
得到輸出AC
實際上不用bt數(shù)組,直接使用保存LCS長度的tab數(shù)組也能直接回溯柠横。稍微修改backtrace的判斷條件即可窃款。如下:
function backtraceByTab (tab, str1, str2, i, j) {
if (i === 0 || j === 0) {
return ''
} else if (str1[i - 1] === str2[j - 1]) {
return backtraceByTab(tab, str1, str2, i - 1, j - 1) + str1[i]
} else {
if (tab[i][j - 1] > tab[i - 1][j]) {
return backtraceByTab(tab, str1, str2, i, j - 1)
} else {
return backtraceByTab(tab, str1, str2, i - 1, j)
}
}
}
console.log(backtraceByTab(result.tab, str1, str2, str1.length, str2.length))
得到輸出仍舊為AC
20170906 update
輸出所有LCS
輸出所有LCS結(jié)果,在這里使用保存LCS長度的tab數(shù)組牍氛。
wiki給出的偽代碼如下
function backtrackAll(C[0..m,0..n], X[1..m], Y[1..n], i, j)
if i = 0 or j = 0
return {""}
else if X[i] = Y[j]
return {Z + X[i] for all Z in backtrackAll(C, X, Y, i-1, j-1)}
else
R := {}
if C[i,j-1] ≥ C[i-1,j]
R := R ∪ backtrackAll(C, X, Y, i, j-1)
if C[i-1,j] ≥ C[i,j-1]
R := R ∪ backtrackAll(C, X, Y, i-1, j)
return R
使用JS實現(xiàn)如下
// backtrace all
function backtraceAllByTab (tab, str1, str2, i, j) {
function _backtraceAllByTab (tab, str1, str2, i, j) {
if (i === 0 || j === 0) {
return ['']
} else if (str1[i - 1] === str2[j - 1]) {
return [].map.call(_backtraceAllByTab(tab, str1, str2, i - 1, j - 1), each => each + str1[i - 1])
} else {
var r = []
if (tab[i][j - 1] >= tab[i - 1][j]) {
r = r.concat(_backtraceAllByTab(tab, str1, str2, i, j - 1)) // 本應該求并集晨继,這里直接連接起來,最后去重一次
}
if (tab[i - 1][j] >= tab[i][j - 1]) {
r = r.concat(_backtraceAllByTab(tab, str1, str2, i - 1, j)) // 本應該求并集搬俊,這里直接連接起來紊扬,最后去重一次
}
return r
}
}
return Array.from(new Set(_backtraceAllByTab(tab, str1, str2, i, j))) // 去重
}
調(diào)用
var str1 = 'GAC'
var str2 = 'AGCAT'
console.log(backtraceAllByTab(result.tab, str1, str2, str1.length, str2.length))
輸出
[ 'AC', 'GC', 'GA' ]
值得注意的是,輸出所有的LCS是不保證時間復雜度為多項式復雜度的唉擂,如果兩個字符串比較接近餐屎,那么可能每一步都會有分枝。
輸出diff
這里JS實現(xiàn)完全參考wiki玩祟,等號的判定條件放在
>=
中腹缩,若將其替換為'>',則diff的輸出可能不同空扎。
function printDiff (tab, str1, str2, i, j) {
if (i > 0 && j > 0 && str1[i - 1] === str2[j - 1]) {
printDiff(tab, str1, str2, i - 1, j - 1)
console.log(` ${str1[i - 1]}`)
} else if (j > 0 && tab[i][j - 1] >= tab[i - 1][j]) {
printDiff(tab, str1, str2, i, j - 1)
console.log(`+ ${str2[j - 1]}`)
} else if (i > 0) {
printDiff(tab, str1, str2, i - 1, j)
console.log(`- ${str1[i - 1]}`)
} else {
console.log('')
}
}
調(diào)用
var str1 = 'GAC'
var str2 = 'AGCAT'
printDiff(result.tab, str1, str2, str1.length, str2.length)
輸出
- G
A
+ G
C
+ A
+ T
思考——算法優(yōu)化
-
使用trim
在字符串長度很長時藏鹊,記錄LCS的長度的表會非常占用空間,因此如果兩個字符串有很多類似的部分转锈,可以對首尾相同的部分進行跳過盘寡,從而縮短要進行比較的部分,達到優(yōu)化的目的黑忱。例如wiki中給出的偽代碼宴抚。
function LCS(X[1..m], Y[1..n]) start := 1 m_end := m n_end := n trim off the matching items at the beginning while start ≤ m_end and start ≤ n_end and X[start] = Y[start] start := start + 1 trim off the matching items at the end while start ≤ m_end and start ≤ n_end and X[m_end] = Y[n_end] m_end := m_end - 1 n_end := n_end - 1 C = array(start-1..m_end, start-1..n_end) only loop over the items that have changed for i := start..m_end for j := start..n_end the algorithm continues as before ...
-
減少比較次數(shù)
在上述的算法中,我們進行的比較是逐字符比較的甫煞,而實際的應用中菇曲,我們通常是采用逐行比較的方式,將每一行看作是一個元素來進行比較抚吠,從而到達減少比較次數(shù)的目的常潮。
-
縮短字符串長度
在使用了上面的方法后,將每一行(字符串)看作元素楷力,例如在比較源碼異同時喊式,通常一行有多余60個的字符,這時利用哈希或是check sum通诚舫可以將長度縮短到8-40個字符岔留。但是,這種做法還是有一些弊端检柬。
- 首先献联,哈希或是check sum的計算會額外的需要一部分時間何址。
- 其次里逆,哈希或是check sum的計算會額外的需要一部分空間用爪。
- 上述兩點雖說有些耗時原押,但是比起逐字符比較,這樣的代價其實很小偎血。
- 最后一點真正弊端是诸衔,字符串的哈希可能導致碰撞(不同的字符串產(chǎn)生相同的哈希)颇玷,一旦發(fā)生碰撞署隘,這會使結(jié)果不正確。但是亚隙,這樣的情況仍是有解決辦法的(例如對哈希進行再加密等等)磁餐。
-
Hirschberg's算法
這里提一下Hirschberg's algorithm,這個算法可以將節(jié)點消耗的內(nèi)存降低到
min(m,n)+1
,但是會相應略微的增加一部分時間復雜度(仍是平方時間復雜度)阿弃。 -
使用更高級的算法
LCS的DP解法復雜度時平方時間诊霹,理論上應該也不能在高了,但是同樣的時間復雜度渣淳,在應用中的實際平均耗時是有區(qū)別的脾还。這里mark一篇論文,后續(xù)可能會繼續(xù)寫相關LCS的文章入愧。