跳到主要内容

如何用 MoonBit 实现 diff? (3)

本篇文章为diff系列的第三篇。在上一篇中,我们了解了完整的myers算法及其不足之处。在本文中,我们将了解如何实现线性空间复杂度的myers算法变种。

分而治之

Git所使用的Myers diff线性变种采用一种叫做Snake(有时也叫Middle Snake)的概念分解整个搜索过程,一条Snake在编辑图中意味着一步左移/下移后跟0~N步对角线移动。 Myers diff的线性变种会在一条最优的编辑路径上寻找居于中间位置中间的Snake, 并通过它将整个编辑图分割为两个部分。接下来的步骤则如法炮制,分别对分割出的两块子图运用相同的技术进行分割,最终得到一条完整的编辑路径。

    0   1   2   3   4   5   6   7   8   9  10  11  12  13  14
 0  o---o---o---o---o---o---o
    |   |   |   |   |   |   |
 1  o---o---o---o---o---o---o
    |   | \ |   |   |   |   |
 2  o---o---o---o---o---o---o
    |   |   |   |   |   |   |
 3  o---o---o---o---o---o---o
    |   |   |   |   | \ |   |
 4  o---o---o---o---o---o---o
    |   |   |   |   |   |   |
 5  o---o---o---o---o---o---o
                              \
 6                              @
                                  \
 7                                  @---o---o---o---o---o---o
                                        |   |   |   |   |   |
 8                                      o---o---o---o---o---o
                                        | \ |   |   |   |   |
 9                                      o---o---o---o---o---o
                                        |   |   |   |   |   |
10                                      o---o---o---o---o---o
                                        |   |   |   |   |   |
11                                      o---o---o---o---o---o
                                        |   |   | \ |   |   |
12                                      o---o---o---o---o---o
                                        |   |   |   |   |   |
13                                      o---o---o---o---o---o
                                        |   |   |   |   | \ |
14                                      o---o---o---o---o---o

稍微回顾一下前文,最优编辑路径指的是到终点距离最短(对角线距离为零),这样的编辑路径不止一条。

细心的读者想必已经发现了,以上论述存在先有鸡还是先有蛋的问题:要得到一条Snake, 必须先有一条最优的编辑路径,但是要得到一条最优的编辑路径,目前看来唯一的办法是跑一遍原版myers算法。

实际上,线性myers算法的思路基本就是这样,但它采取了一种不大寻常的思路:同时从左上角和右下角使用原版myers算法交替进行搜索,但是不保存历史记录,只检查两边的搜索过程是否重叠,一旦重叠,就将重叠部分作为Middle Snake返回。

听起来思路很清晰,但还有些细节需要搞清楚。

从后往前搜索时,对角线坐标就不能再用k了,我们需要定义一个新的对角线坐标c = k - delta。它和k是互为镜像的,这样正好满足从反方向向起点搜索的需求。

        x                       k
                                  0     1     2     3
        0     1     2     3         \     \     \     \
  y  0  o-----o-----o-----o           o-----o-----o-----o
        |     |     |     |      -1   |     |     |     | \
        |     |     |     |         \ |     |     |     |   2
     1  o-----o-----o-----o           o-----o-----o-----o
        |     | \   |     |      -2   |     | \   |     | \
        |     |   \ |     |         \ |     |   \ |     |   1
     2  o-----o-----o-----o           o-----o-----o-----o
                                        \     \     \     \
                                        -3    -2    -1      0
                                                              c

如何判断搜索过程是否重叠?只要发现正向搜索在某一条对角线上的位置其x正好比反向的位置要大就行,但是由于同一条对角线的k和c坐标不同,换算会稍微有点麻烦。

代码实现

我们首先定义SnakeBox类型,分别对应middle snake以及被分割出的子编辑图(因为是方形的,所以直接以Box称呼了)

struct Box {
  left : Int
  right : Int
  top : Int
  bottom : Int
} derive(Debug, Show)

struct Snake {
  start : (Int, Int)
  end : (Int, Int)
} derive(Debug, Show)

fn width(self : Box) -> Int {
  self.right - self.left
}

fn height(self : Box) -> Int {
  self.bottom - self.top
}

fn size(self : Box) -> Int {
  self.width() + self.height()
}

fn delta(self : Box) -> Int {
  self.width() - self.height()
}

为了避免太早陷入细节,我们先假设已经有了能找到middle snake的函数midpoint : (Box, Array[Line], Array[Line]) -> Snake?, 然后在此基础上编写能搜索出完整path的函数find_path

fn find_path(box : Box, a : Array[Line], b : Array[Line]) -> Iter[(Int, Int)]? {
  let snake = midpoint(box, a, b)?
  let start = snake.start
  let end = snake.end
  let headbox = Box::{ left : box.left, top : box.top, right : start.0, bottom : start.1 }
  let tailbox = Box::{ left : end.0, top : end.1, right : box.right, bottom : box.bottom }
  // println("snake = \{snake}")
  // println("headbox = \{headbox}")
  // println("tailbox = \{tailbox}")
  let head = find_path(headbox, a, b).or(Iter::singleton(start))
  let tail = find_path(tailbox, a, b).or(Iter::singleton(end))
  Some(head.concat(tail))
}

find_path的实现非常简单直接,而midpoint就要复杂一些

  • 对于大小为0的Box, 直接返回None

  • 计算搜索范围的边界,由于前向和后向搜索各搜一半故除以二,但在Box大小为奇数时因为前向搜索的范围要大一点,所以结果加一。

  • 前向和后向搜索的记录分两个数组保存

  • 正反交替搜索,若没找到结果便返回None

fn midpoint(self : Box, a : Array[Line], b : Array[Line]) -> Snake? {
  if self.size() == 0 {
    return None
  }
  let max = {
    let half = self.size() / 2
    if is_odd(self.size()) {
      half + 1
    } else {
      half
    }
  }
  let vf = BPArray::make(2 * max + 1, 0)
  vf[1] = self.left
  let vb = BPArray::make(2 * max + 1, 0)
  vb[1] = self.bottom
  for d = 0; d < max + 1; d = d + 1 {
    match forward(self, vf, vb, d, a, b) {
      None =>
      match backward(self, vf, vb, d, a, b) {
        None => continue
        res => return res
      }
      res => return res
    }
  } else {
    None
  }
}

前向/后向搜索的过程相比原本的myers算法做出了一些需要略作解释的改动

  • 由于需要返回snake,搜索过程需要算出上一个坐标(px在这里指previous x)

  • 由于现在的搜索过程在一个Box中工作(不是全局的编辑图),从x计算y(或者反过来)要考虑换算

  • 后向搜索过程选择最小化y只是一种启发策略,改成x也行

fn forward(self : Box, vf : BPArray[Int], vb : BPArray[Int], d : Int, a : Array[Line], b : Array[Line]) -> Snake? {
  for k = d; k >= -d; k = k - 2 {
    let c = k - self.delta()
    let mut x = 0
    let mut px = 0
    if k == -d || (k != d && vf[k - 1] < vf[k + 1]) {
      x = vf[k + 1]
      px = x
    } else {
      px = vf[k - 1]
      x = px + 1
    }
    let mut y = self.top + (x - self.left) - k
    let py = if (d == 0 || x != px) { y } else { y - 1 }
    while x < self.right && y < self.bottom && a[x].text == b[y].text {
      x = x + 1
      y = y + 1
    }
    vf[k] = x
    if is_odd(self.delta()) && (c >= -(d - 1) && c <= d - 1) && y >= vb[c] {
      return Some(Snake::{ start : (px, py), end : (x, y) })
    }
  }
  return None
}

fn backward(self : Box, vf : BPArray[Int], vb : BPArray[Int], d : Int, a : Array[Line], b : Array[Line]) -> Snake? {
  for c = d; c >= -d; c = c - 2 {
    let k = c + self.delta()
    let mut y = 0
    let mut py = 0
    if c == -d || (c != d && vb[c - 1] > vb[c + 1]) {
      y = vb[c + 1]
      py = y
    } else {
      py = vb[c - 1]
      y = py - 1
    }
    let mut x = self.left + (y - self.top) + k
    let px = if (d == 0 || y != py) { x } else { x + 1 }
    while x > self.left && y > self.top && a[x - 1].text == b[y - 1].text {
      x = x - 1
      y = y - 1
    }
    vb[c] = y
    if is_even(self.delta()) && (k >= -d && k <= d) && x <= vf[k] {
      return Some(Snake::{ start : (x, y), end : (px, py) })
    }
  }
  return None
}

尾声

实际上,Git在默认的diff算法之外还提供了另一种diff算法可以选用:patience diff,它和myers diff的思路截然不同,有时能产出可读性更高的diff结果。