算法學習——DP篇(二)

LCS問題(最長公共子序列)

上一篇文章
全部代碼和markdown在chux0519/algs同步更新。git

計算LCS長度

遞歸解法

LCS問題是具備最優子結構的。
假設長度爲m的字符串X[0..m-1]和長度爲n的字符串Y[0..n-1],用L(X[0..m-1], Y[0..n-1]) 來表示它們的LCS長度。則能夠獲得如下結論:github

  1. 若是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])算法

  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])segmentfault

用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",畫出以上代碼的調用圖,獲得:markdown

lcs("AXYT", "AYZX")
                       /                 
         lcs("AXY", "AYZX")            lcs("AXYT", "AYZ")
         /                              /               
lcs("AX", "AYZX") lcs("AXY", "AYZ")   lcs("AXY", "AYZ") lcs("AXYT", "AY")

能夠看出lcs("AXY", "AYZ")被重複計算了,隨着層數的增多,能夠分析出,有許多重複子問題會出現,因而,能夠利用記憶化或者製表來進行優化算法。函數

製表解法

// 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,自底向上計算出結果,提高速度的同時消除了遞歸。oop

回溯LCS

存儲回溯數組

在上述算法中,增長一個數組用於存儲回溯路徑便可,稍做改動以下。優化

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數組的輸出爲LCS的長度,bt數組的內容是回溯的方向,例子取自維基百科

輸出一個LCS

輸出LCS結果,須要利用bt數組進行回溯,只輸出一個LCS時,能夠用如下回溯函數。

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數組,直接使用保存LCS長度的tab數組也能直接回溯。稍微修改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結果,在這裏使用保存LCS長度的tab數組。
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實現以下

// 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))) // 去重
}

調用

var str1 = 'GAC'
var str2 = 'AGCAT'
console.log(backtraceAllByTab(result.tab, str1, str2, str1.length, str2.length))

輸出

[ 'AC', 'GC', 'GA' ]

值得注意的是,輸出全部的LCS是不保證時間複雜度爲多項式複雜度的,若是兩個字符串比較接近,那麼可能每一步都會有分枝。

輸出diff

這裏JS實現徹底參考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('')
  }
}

調用

var str1 = 'GAC'
var str2 = 'AGCAT'
printDiff(result.tab, str1, str2, str1.length, str2.length)

輸出

- G
  A
+ G
  C
+ A
+ T

思考——算法優化

  • 使用trim

在字符串長度很長時,記錄LCS的長度的表會很是佔用空間,所以若是兩個字符串有不少相似的部分,能夠對首尾相同的部分進行跳過,從而縮短要進行比較的部分,達到優化的目的。例如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 ...
  • 減小比較次數

在上述的算法中,咱們進行的比較是逐字符比較的,而實際的應用中,咱們一般是採用逐行比較的方式,將每一行看做是一個元素來進行比較,從而到達減小比較次數的目的。

  • 縮短字符串長度

在使用了上面的方法後,將每一行(字符串)看做元素,例如在比較源碼異同時,一般一行有多餘60個的字符,這時利用哈希或是check sum一般能夠將長度縮短到8-40個字符。可是,這種作法仍是有一些弊端。

  • 首先,哈希或是check sum的計算會額外的須要一部分時間。

  • 其次,哈希或是check sum的計算會額外的須要一部分空間。

  • 上述兩點雖然說有些耗時,可是比起逐字符比較,這樣的代價其實很小。

  • 最後一點真正弊端是,字符串的哈希可能致使碰撞(不一樣的字符串產生相同的哈希),一旦發生碰撞,這會使結果不正確。可是,這樣的狀況還是有解決辦法的(例如對哈希進行再加密等等)。

  • Hirschberg's算法

這裏提一下Hirschberg's algorithm,這個算法能夠將節點消耗的內存下降到min(m,n)+1,可是會相應略微的增長一部分時間複雜度(還是平方時間複雜度)。

  • 使用更高級的算法

LCS的DP解法複雜度時平方時間,理論上應該也不能在高了,可是一樣的時間複雜度,在應用中的實際平均耗時是有區別的。這裏mark一篇論文,後續可能會繼續寫相關LCS的文章。

相關文章
相關標籤/搜索