算法学习——DP(二)

LCS问题(最长公共子序列)

上一篇文章
所有代码和markdown在chux0519/algs同步更新。

计算LCS长度

递归解法

LCS问题是具有最优子结构的。
假设长度为m的字符串X[0..m-1]和长度为n的字符串Y[0..n-1],用L(X[0..m-1], Y[0..n-1]) 来表示它们的LCS长度。则可以得到以下结论:

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

用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",画出以上代码的调用图,得到:

                         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,自底向上计算出结果,提升速度的同时消除了递归。

回溯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的文章。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容