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

作者:Oto_G

QQ: 421739728

简介

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

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

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

基础

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

差异的描述

image

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

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

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

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

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

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

好的差异比较

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

image

我们这次使用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》

In this paper an O(ND) time algorithm is presented. Our algorithm is simple and based on an intuitive edit graph formalism.

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

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

名词解释

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

  • 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推导出的两个定理。

两个定理

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

绘制编辑图

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

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

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

绘制步骤用流程图给出

整个绘制编辑图的过程就是以内外两层循环为主进行的,外层以差异数量的不断增加来循环,最终得到的差异数量即为两字符串的差异数量;内层循环则是在每一轮差异下对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不仅仅给出了这一版本的算法思路,而且对它进行了优化,在这里就不细说了,大致给一个优化思路:编辑图的起点和终点是已知的,那么从终点向起点绘制编辑图是否也可行呢,那同时从起点和终点绘制编辑图是否也可行呢?

感谢

posted @ 2022-06-08 21:13  Oto_G  阅读(4538)  评论(3编辑  收藏  举报