作者:Oto_G

QQ: 421739728

简介

本文章对Myers差分算法(Myers Diff Algorithm)进行了细致讲解,适合对Myers差分算法完全不了解的小白进行学习。

Myers差分算法或者称为 Myers Diff Algorithm,其中的Myers是指发表该算法的作者;差分是英文Diff的直译,也可以理解为差别、区别;算法即为Algorithm。

该算法解决了LCS的等价问题SES,其在git diff、DNA序列比较等场景中都被广泛使用。

基础

首先Myers差分算法所解决的问题,用不那么严谨的话来说,就是解决了比较两个字符串之间的差异的问题。但就算如此,还是不够直观,我们先来看个例子。

差异的描述

Myers差分算法的理解、实现、可视化-LMLPHP

两个字符串ABC和CBAB,找到两个字符串之间差异的问题可以具体化为:用删减、不变、增加这三个词描述字符串ABC变化到字符串CBAB的过程这样一个具体的问题。如上图,我列举出了两种从字符串ABC变化到字符串CBAB的方法(当然不止两种)。

  • 左边那种的变化过程就可以描述为:增加CB,保持AB不变,删减C

  • 右边那种的变化过程可以描述为:删减AB,保持C不变,增加BAB

注:一定要牢记,不管是目前理解还是在Myers算法中,比较字符串差异的过程都抽象为了字符串A变化到字符串B的过程,即可以理解为字符串A为原字符串,字符串B为新字符串。

比较差异的过程就是描述原字符串是如何变化到新字符串的过程。

为了后文方便描述,在本文中,我们将这些操作附上不同颜色:绿色表示增加、红色表示删减、白色表示不变

好的差异比较

当然,通过刚刚的例子,我们也能发现,比较两个字符串的差异的结果有非常多种,那么接下来我们就需要定义什么是好的差异比较结果,也就是我们应该遵循什么标准来比较差异。

Myers差分算法的理解、实现、可视化-LMLPHP

我们这次使用Myers在论文中所使用的字符串来进行演示,我在图中列举了对于从字符串A变化到字符串B的三种方式(当然还有许多方法),其中“比较1”是最佳的差异比较结果,即删减AB,保持C不变,增加B,保持AB不变,删减B保持A不变,增加C。

可以对照着图中这三个结果在草稿纸上过一遍变化过程,我们这里先看”比较1“和”比较2“,它们俩得出的结果非常相似,都删减了三个字符,增加了两个字符,同时有4个字符不变,但为什么“比较1”更好呢,可以仔细观察在第二个字符上,“比较1”选择了和前一个字符同样的操作即删减,而“比较2”则选择了和前一个字符不同的操作即“增加”。在直观感受上呢,“比较1”也比“比较2”更加清晰。再看“比较1”和“比较3”,虽然感官上“比较3”比“比较1”更加直观,但是完全失去了比较意义,所以我们依据这些优劣,确定好的差异比较的规则

  • 差异应该表现为更连续的增加或者删减

  • 相同内容应该尽可能多,即差异尽可能少

  • 同时约定:当删减和增加都可选时,优先选择删减

注:对于第三条的约定,可以根据具体的应用场景进行修改,默认为优先删减

算法介绍

在了解完这些后,我们终于可以进入Myers差分算法的学习了。

首先来看看Myers在论文中是怎么描述自己设计的算法的,这段话截自《An O(ND) Difference Algorithm and Its Variations》

翻译为:在论文中提出了一个时间复杂度为O(ND)的算法,我们的算法很简单,它基于一个直观的编辑图形式主义。

也就是Myers差分算法是通过构造一个名为编辑图的东西来解决差异比较问题,同时这个解决方法的速度也很快。

名词解释

下面先了解下在Myers的论文中出现的一些词的含义。图中有介绍的名词就不再介绍了。

Myers差分算法的理解、实现、可视化-LMLPHP

Myers差分算法的理解、实现、可视化-LMLPHP

  • LCS

  • SES

  • N

  • D

  • k:

    • 可以理解为截距,它是通过x - y算出来的,看右边折线图中x轴(上方横轴)被原字符串覆盖,y轴(左侧纵轴)被新字符串覆盖,k = x - y,比如(3, 1)点所在的k = 3 - 1,即k = 2
  • snake

    • 指代两黄色端点间的线段,如(1, 0)到(2, 2)的线段就称为一个snake
  • D-path

    • 从(0, 0)出发,到达D的线路,如2-path,就表示从(0, 0)出发到(2, 4) (2, 2) (3, 1)的三条路线
  • edit graph

    • 编辑图,就是Myers算法的核心,其形式就是图右的折线图

对于k, snake, edit graph可能了解了是什么,但仍然无法对应到Myers差分算法本身,那么下面我们就来将这些名词联系起来,首先是论文中Myers推导出的两个定理。

两个定理

Myers差分算法的理解、实现、可视化-LMLPHP

图中给出了定理的理解,论文中用数学归纳法对这两个定理进行了证明,有兴趣可以阅读原论文An O(ND) Difference Algorithm and Its Variations,这两个定理对下文理解编辑图绘制步骤非常重要,请理解后再向下阅读

绘制编辑图

联系这些名词就要通过绘制编辑图的方式来进行。下面给出绘制编辑图的方法。

请仔细阅读绘制方法,可以结合下方给出的编辑图配合理解,绘制方法并没有给出绘制步骤,绘制步骤在两图片后。

Myers差分算法的理解、实现、可视化-LMLPHP

Myers差分算法的理解、实现、可视化-LMLPHP

了解完绘制方法,接下来就是最核心的绘制步骤,首先我们明确我们绘制的终止情况是到达右下角那个点,图中即为(3, 4)点,选取它作为终点的原因在了解完绘制步骤后就能体会到。

绘制步骤用流程图给出

Myers差分算法的理解、实现、可视化-LMLPHP

整个绘制编辑图的过程就是以内外两层循环为主进行的,外层以差异数量的不断增加来循环,最终得到的差异数量即为两字符串的差异数量;内层循环则是在每一轮差异下对k ∈ [-d, d]这样一个区域进行搜索(结合定理1就实现步长为2的跳跃搜索)。目的就是走到终点,走的策略则被设计为严格符合好的差异比较的标准。可以通过我设计的Myers可视化工具Myers View (myer-view.vercel.app)来帮助理解。

理解了编辑图的绘制过程,我想,你已经对Myers算法了解了大半了,可以进行一些“大跨步”了,我们直接来看代码,这里借用简析Myers - 小雨心情给出的JavaScript版代码,非常感谢,我在其代码之上加入了详细的注释,同时对代码进行了细微的调整,可以在TODO中看到。

function myers(stra, strb) {
    // 字符串 a 的长度为 n
    let n = stra.length
    // 字符串 b 的长度为 m
    let m = strb.length

    /*
    动态规划回溯前轮计算结果用,结构为 k: x ,
    存储的是该截距(k)目前能到达的最远端 x ,
    且 k 满足公式 k = x - y
    */
    let v = {
      '1': 0
    }
    /*
    存储的是每一步差异(d)中的所有截距(k)
    能到达的最远端 x 值,用于计算差异路径(d-path)
    结构为 d: {k : x}
    */
    let vs = {
      '0': { '1': 0 }
    }
    // 声明差异 d ,该值记录两字符串差异的大小
    let d

    loop:
    // 差异d,最坏情况 n+m 即两字符串完全不同
    for (d = 0; d <= n + m; d++) {
      let tmp = {}
      /*
      斜线不计入循环,只有两个方向 → || ↓
      这里使用剪枝思想,使k不用遍历全表
      */
      for (let k = -d; k <= d; k += 2) {
        /*
        判断是否是通过 + 到达的待测点,+ 的情况为:
        当前截距等于负差异(首次循环,也就是左边界)或者
        当前截距不等于正差异(末次循环,也就是上边界)且
        上一截距的x大于下一截距的x(体现优先删除)
        */
        let down = ((k == -d) || ((k != d) && v[k + 1] > v[k - 1]))
        /*
        如果是 + 方式到的该截距,
        则说明该截距的前一步是从上截距过来的,否则是下截距下来的
        */
        let kPrev = down ? k + 1 : k - 1
        // 获取前一步的坐标 xStart yStart
        let xStart = v[kPrev]
        let yStart = xStart - kPrev
        // 获取可能的当前点坐标,如果是 + 方式则x轴坐标不变,否则横坐标加一
        let xMid = down ? xStart : xStart + 1
        // y轴通过 k = x - y 计算得出
        let yMid = xMid - k
        // 声明当前可能的坐标(还未考虑走斜线)
        let xEnd = xMid
        let yEnd = yMid

        /*
        考虑走斜线(对字符串a、b进行比较,
        如果当前x、y所在字符串相同则走斜线)
        */
        while(xEnd < n && yEnd < m && stra[xEnd] === strb[yEnd]) {
          xEnd++
          yEnd++
        }

        // 更新截距k所能到的最远端xEnd,yEnd不必记录可以计算得到
        // 动态规划回溯子问题的实现
        v[k] = xEnd
        // 记录当前截距的最新端点
        tmp[k] = xEnd

        /*
        如果 xEnd 和 yEnd 到达了各自字符串的末端,
        说明路径寻找到了终点,可以结束寻找
        */
        if (xEnd == n && yEnd == m) {
          // 形成完整 d - k 端点表
          vs[d] = tmp
          // 生成 diff 路径
          let snakes = solution(vs, n, m, d)
          // 打印两字符串 diff
          printRes(snakes, stra, strb)
          // 完成 Myers diff
          break loop
        }
      }

      // 刷新当前差异下能到达的最远端
      vs[d] = tmp
    }
  }

  // 由后向前回溯
  function solution(vs, n, m, d) {
    // snakes存 + - 步骤
    let snakes = []
    // 存放当前搜索的位置
    let p = { x: n, y: m }

    // 两文本的差异数量已知,往前倒推步骤
    for (; d > 0; d--) {
      // 取出最后一步的差异所有能到达的点 v[k], k∈[-d, d]
      let v = vs[d]
      // 取出前一步的差异所有能到达的点
      let vPrev = vs[d-1]
      // 计算当前位置的截距,首次循环是终点所在截距k
      let k = p.x - p.y

      // 获取当前截距的坐标
      let xEnd = v[k]
      let yEnd = xEnd - k

      /*
      判断该步是通过 + 还是 - 操作得到的,分两类:
      1、当前截距与负差异相同
        1.1 这种情况说明当前差异除了走斜线以外,其余都是走 + 完成的(TODO: 可优化)
      2、当前截距不等于正差异 且 前一步差异所到达的点中,
      当前截距的上侧截距能到达的最远点的x值比下策截距能到达的最远点的x值大
        2.1 该判断的后半部分保证了删除先于增加的设计要求
      */
      let down = ((k == -d) || ((k != d) && (vPrev[k + 1] > vPrev[k - 1])))
      // 如果是通过 + 到达的该点,则前一步的截距在上侧,即 k + 1 ,反之则 k - 1
      let kPrev = down ? k + 1 : k - 1
      // 获得真正的前驱点(已包含走斜线情况)
      let xStart = vPrev[kPrev]
      let yStart = xStart - kPrev
      // 获得走斜线的开始点,形象的称为mid,(对于没有走斜线的情况,得到的就是当前点)
      let xMid = down ? xStart : xStart + 1
      let yMid = xMid - k

      // 将当前前驱点、斜线开始点(LCS)、当前点的 x 值压栈入 snakes
      snakes.unshift([xStart, xMid, xEnd])

      // 更新当前计算的位置
      p.x = xStart
      p.y = yStart
    }

    return snakes
  }

  function printRes(snakes, stra, strb) {
    let grayColor = '^'
    let redColor = '-'
    let greenColor = '+'
    let consoleStr = ''
    let args = []
    let yOffset = 0

    snakes.forEach((snake, index) => {
      // 获取步骤的前驱(开始) x
      let s = snake[0]
      // 获取步骤的LCS开始x
      let m = snake[1]
      // 获取步骤的终点 x
      let e = snake[2]
      // LCS的起点(TODO: 可以不新增large变量,snake中记录的m已经记录了LCS的开始位置)
      // let large = s

      // 如果是第一个差异,并且差异的开始点不是字符串头(即两字符串在开始部分有相同子字符串)
      // 只会在snakes的forEach中的一个出现
      if (index === 0 && s !== 0) {
        // 用灰色打印所有相同字符,直到s
        for (let j = 0; j < s; j++) {
          consoleStr += `%c${grayColor+stra[j]}`
          args.push(grayColor)
          // 记录b字符串的当前位置(yOffset类似游标)
          yOffset++
        }
      }

      // 如果该子串的差异是 - 操作
      // 删除
      if (m - s == 1) {
        // 用红色打印删除的字符
        consoleStr += `%c${stra[s]}`
        args.push(redColor)
        // TODO: 此处large可以省略
        // large = m
      // 如果该子串的差异是 + 操作
      // 添加
      } else {
        consoleStr += `%c${strb[yOffset]}`
        args.push(greenColor)
        // b字符串当前位置继续右移
        yOffset++
      }

      // LCS部分,当前终点位置 e 减去 LCS的开始位置,即为相同字串的长度
      // 不变
      // for (let i = 0; i < e - large; i++) {
      for (let i = 0; i < e - m; i++) {
        // TODO: 此处large可以使用m代替
        consoleStr += `%c${stra[m+i]}`
        args.push(grayColor)
        // b字符串当前位置继续右移
        yOffset++
      }
    })

    conole.log(consoleStr, ...args)
  }

  // test部分
  let s1 = 'ABCABBA'
  let s2 = 'CBABAC'
  myers(s1, s2)

读完代码后应该能够对Myers差分算法的实现方法有了一个系统的认识,当然其实Myers不仅仅给出了这一版本的算法思路,而且对它进行了优化,在这里就不细说了,大致给一个优化思路:编辑图的起点和终点是已知的,那么从终点向起点绘制编辑图是否也可行呢,那同时从起点和终点绘制编辑图是否也可行呢?

感谢

06-08 21:18