Myers diff#

你是否曾经使用过 Unix 系统中的 ‘diff’ 工具?简单来说,它是一种用于比较两个文本文件之间差异的工具。此外,Unix 系统中还有名为 ‘patch’ 的工具。

现在很少有人手动应用软件包的修补,但 ‘diff’ 在另一个领域中仍然非常有用:版本控制系统。你有看到特定提交后代码文件所做更改(并以不同的颜色突出显示)的函数是非常方便的。例如,最受欢迎的版本控制系统之一,git,就是一个很好的例子。

diff --git a/main/main.mbt b/main/main.mbt
index 99f4c4c..52b1388 100644
--- a/main/main.mbt
+++ b/main/main.mbt
@@ -3,7 +3,7 @@

 fn main {
   let a = lines("A\nB\nC\nA\nB\nB\nA")
-  let b = lines("C\nB\nA\nB\nA\nC")
+  let b = lines("C\nB\nA\nB\nA\nA")
   let r = shortst_edit(a, b)
   println(r)
 }

但我们具体怎么计算两个文本文件之间的差异呢?

Git 的默认比较差异算法由 Eugene W. Myers 在他的论文一个 O(ND) 的差分算法及其变体中提出。这篇论文主要聚焦于证明该算法的正确性。接下来,我们将以一种不那么严谨的方式理解其基本架构,并使用 MoonBit 编写一个简单的实现。

“差异”的定义及其度量标准#

当我们讨论两个文本文件之间的“差异”时,我们实际上是在指能将文本 a 变成文本 b 的一系列编辑步骤。

假设文本 a 的内容是

A
B
C
A
B
B
A

假设文本 b 的内容是

C
B
A
B
A
C

为了把文本 a 转变为文本 b,最简单的编辑序列是删除所有 a 中的字符(用减号表示),然后插入文本 b 中的每个字符(用加号表示)。

- A
- B
- C
- A
- B
- B
- A
+ C
+ B
+ A
+ B
+ A
+ C

但是这样的结果可能对程序员阅读程序来说并没有太大帮助。接下来的编辑序列会好很多,至少它更短。

- A
- B
  C
+ B
  A
  B
- B
  A
+ C

实际上,这是实现文本 a 转换为文本 b 所需的最简编辑序列,仅需要 5 个操作。如果我们只考虑编辑序列的长度,这一结果已经令人满意。但在探索多种编程语言时,我们发现除了编辑序列长度外,还有其他度量指标对用户体验同样重要。让我们来看看以下例子:

// good quality
  struct RHSet[T] {
    set : RHTable[T, Unit]
  }
+
+ fn RHSet::new[T](capacity : Int) -> RHSet[T] {
+  let set : RHTable[T, Unit]= RHTable::new(capacity)
+  { set : set }
+ }


// bad quality
  struct RHSet[T] {
    set : RHTable[T, Unit]
+ }
+
+ fn RHSet::new[T](capacity : Int) -> RHSet[T] {
+  let set : RHTable[T, Unit]= RHTable::new(capacity)
+  { set : set }
  }

当我们在文件末尾插入一个新函数定义时,理想的编辑序列应将所做的更改置于文件末尾。同样,在有删除和插入操作的情况下,不应该将这两个操作交替进行计算。这是另一个例子。

Good:   - one         Bad:    - one
        - two                 + four
        - three               - two
        + four                + five
        + five                + six
        + six                 - three

Myers 的 diff 算法可以满足所有这些需求。它是一种贪婪算法,每次都尽可能地跳过匹配行(避免在出现’{‘之前插入文本),同时也会尝试将删除操作置于插入操作之前,避免后者发生。

算法概述#

Myers 的论文中的基本思路是构建一个编辑操作的网格图,并在该网格上搜索最短路径。以之前的例子 ‘a = ABCABBA’ 和 ‘b = CBABAC’ 为例,我们创建一个 ‘(x, y)’ 坐标网格。

    0     1     2     3     4     5     6     7

0   o-----o-----o-----o-----o-----o-----o-----o
    |     |     | \   |     |     |     |     |
    |     |     |  \  |     |     |     |     |   C
    |     |     |   \ |     |     |     |     |
1   o-----o-----o-----o-----o-----o-----o-----o
    |     | \   |     |     | \   | \   |     |
    |     |  \  |     |     |  \  |  \  |     |   B
    |     |   \ |     |     |   \ |   \ |     |
2   o-----o-----o-----o-----o-----o-----o-----o
    | \   |     |     | \   |     |     | \   |
    |  \  |     |     |  \  |     |     |  \  |   A
    |   \ |     |     |   \ |     |     |   \ |
3   o-----o-----o-----o-----o-----o-----o-----o
    |     | \   |     |     | \   | \   |     |
    |     |  \  |     |     |  \  |  \  |     |   B
    |     |   \ |     |     |   \ |   \ |     |
4   o-----o-----o-----o-----o-----o-----o-----o
    | \   |     |     | \   |     |     | \   |
    |  \  |     |     |  \  |     |     |  \  |   A
    |   \ |     |     |   \ |     |     |   \ |
5   o-----o-----o-----o-----o-----o-----o-----o
    |     |     | \   |     |     |     |     |
    |     |     |  \  |     |     |     |     |   C
    |     |     |   \ |     |     |     |     |
6   o-----o-----o-----o-----o-----o-----o-----o


       A     B     C     A     B     B     A

该网格的左上角是起始点 ‘(0, 0)’,右下角是终点 ‘(7, 6)’。沿着 x 轴移动一个单位右侧删除 a 中的对应字符,沿 y 轴向下移动一个单位插入 b 中的对应字符。斜线表示匹配字符,可跳过而不触发编辑。

在编写实际搜索代码之前,我们手动执行两轮搜索。

  • 第一轮从 ‘(0, 0)’ 开始,移动一步到达 ‘(0,1)’ 和 ‘(1,0)’。

  • 第二轮从 ‘(0,1)’ 和 ‘(1, 0)’ 开始。从 ‘(0,1)’ 向下移动到 ‘(0,2)’,但有一条斜线指向 ‘(1,3)’,因此最终点是 ‘(1,3)’。

Myers 算法的整个原理基于这种广度优先搜索。

实现#

我们已经概述了基本思想,现在是详细考虑设计的时候了。算法的输入是两个字符串,但是搜索需要在一个图上进行。而构造图然后再去搜索它是浪费内存和时间成本的。

Myers 算法的实现采用了一种聪明的方法,定义了一个新的坐标 k = x - y

  • 向右移动会使 k 增加 1。

  • 向左移动会使 k 减少 1

  • 沿对角线向左下方移动保持 k 不变

让我们定义另一个坐标 d 来表示搜索的深度。以 d 为横轴,k 为纵轴,我们可以画出搜索过程的树状图。

    |      0     1     2     3     4     5
----+--------------------------------------
    |
 4  |                             7,3
    |                           /
 3  |                       5,2
    |                     /
 2  |                 3,1         7,5
    |               /     \     /     \
 1  |           1,0         5,4         7,6
    |         /     \           \
 0  |     0,0         2,2         5,5
    |         \                       \
-1  |           0,1         4,5         5,6
    |               \     /     \
-2  |                 2,4         4,6
    |                     \
-3  |                       3,6

你可以看到,在每一轮搜索中,k 严格地在 [-d, d] 范围内(因为在一次移动中,它最多可以比前一轮增加或减少 1),并且点之间的 k 值的间隔为 2。Myers 算法的基本思想来自于这样一个思想:通过遍历 dk 来进行搜索。当然,它还需要保存每一轮的 x 坐标,以便在下一轮搜索中使用。

让我们首先定义 Line 结构,它代表文本中的一行。

///|
struct Line {
  number : Int // Line number
  text : String // Does not include newline
} derive(Show)

///|
fn Line::new(number : Int, text : String) -> Line {
  Line::{ number, text }
}

然后,定义一个辅助函数,根据换行符将字符串拆分为 Array[Line]。注意,行号从 1 开始。

fn lines(str : String) -> Array[Line] {
  let lines = Array::new(capacity=50)
  let mut line_number = 0
  for line in str.split("\n") {
    line_number = line_number + 1
    lines.push(Line::new(line_number, line))
  } else {
    return lines
  }
}

接下来,我们需要包装数组,以便它支持负索引,因为我们将使用 k 的值作为索引。

type BPArray[T] Array[T] // BiPolar Array 

fn BPArray::make[T](capacity : Int, default : T) -> BPArray[T] {
  let arr = Array::make(capacity, default)
  BPArray(arr)
}

fn copy(self : BPArray[Int]) -> BPArray[Int] {
  let BPArray(arr) = self
  let newarr = Array::make(arr.length(), 0)
  for i = 0; i < arr.length(); i = i + 1 {
    newarr[i] = arr[i]
  } else {
    BPArray(newarr)
  }
}

fn op_get[T](self : BPArray[T], idx : Int) -> T {
  let BPArray(arr) = self
  if idx < 0 {
    arr[arr.length() + idx]
  } else {
    arr[idx]
  }
}

fn op_set[T](self : BPArray[T], idx : Int, elem : T) -> Unit {
  let BPArray(arr) = self
  if idx < 0 {
    arr[arr.length() + idx] = elem
  } else {
    arr[idx] = elem
  }
}

现在我们可以开始编写搜索函数了。在搜索完整路径之前,让我们从第一个目标开始,即找到最短路径的长度(等于搜索深度)。以下是基本框架:

fn shortest_edit(old~ : Array[Line], new~ : Array[Line]) -> Int {
  let n = old.length()
  let m = new.length()
  let max = n + m
  let v = BPArray::make(2 * max + 1, 0)
  for d = 0; d < max + 1; d = d + 1 {
    for k = -d; k < d + 1; k = k + 2 {
    ......
    }
  }
}

在最极端的情况下(两个文本没有匹配的行),可以推断所需的最大步数为 n + m,最小步数为 0。因此,设置变量 max = n + m。数组 v 使用 k 作为索引来存储 x 值的历史记录。因为 k 的范围是从 [-d, d],所以这个数组的大小被设置为 2 * max + 1

但即使在这一点上,仍然很难弄清楚下一步该怎么做,所以我们只考虑 d = 0; k = 0 的情况,在这一点,它一定在 (0, 0) 处。此外,如果两篇文本的开头相同,可以直接跳过。我们将这一轮的最终坐标写入数组 v

if d == 0 { // When d equals 0, k must also equal 0
  x = 0
  y = x - k
  while x < n && y < m && old[x].text == new[y].text {
    // Skip all matching lines
    x = x + 1
    y = y + 1
  }
  v[k] = x
}

d > 0 时,需要前一轮存储的坐标信息。当我们知道一个点的 k 值和前一轮搜索中点的坐标时,就很容易推导出 v[k] 的值。因为每一步 k 只能增加或减少 1,所以搜索树中的 v[k] 必须从 v[k - 1]v[k + 1] 扩展。而下一个问题是:如何在以 v[k - 1]v[k + 1] 结尾的两条路径之间做出选择?

有两种边界情况:k == -dk == d

  • k == -d 时,只能选择 v[k + 1]

  • k == d 时,只能选择 v[k - 1]

而回顾前面提到的要求:尽可能在插入之前删除,这本质上意味着从前一个位置选择具有最大 x 值的位置。

if k == -d {
  x = v[k + 1]
} else if k == d {
  x = v[k - 1] + 1 // add 1 to move horizontally
} else if v[k - 1] < v[k + 1] {
  x = v[k + 1]
} else {
  x = v[k - 1] + 1
}

合并这四个分支,我们得到以下代码:

if k == -d || (k != d && v[k - 1] < v[k + 1]) {
  x = v[k + 1]
} else {
  x = v[k - 1] + 1
}

结合上述所有步骤,我们得到以下代码:

fn shortest_edit(old~ : Array[Line], new~ : Array[Line]) -> Int {
  let n = old.length()
  let m = new.length()
  let max = n + m
  let v = BPArray::make(2 * max + 1, 0)
  // v[1] = 0
  for d = 0; d < max + 1; d = d + 1 {
    for k = -d; k < d + 1; k = k + 2 {
      let mut x = 0
      let mut y = 0
      // if d == 0 {
      //   x = 0
      // }
      if k == -d || (k != d && v[k - 1] < v[k + 1]) {
        x = v[k + 1]
      } else {
        x = v[k - 1] + 1
      }
      y = x - k
      while x < n && y < m && old[x].text == new[y].text {
        // Skip all matching lines
        x = x + 1
        y = y + 1
      }
      v[k] = x
      if x >= n && y >= m {
        return d
      }
    }
  } else {
    abort("impossible")
  }
}

由于数组的初始值为 0,我们可以省略 d == 0 的分支。

结语#

我们已经实现了 Myers 算法的一个不完整版本,它完成了正向路径搜索。在下一篇文章中,我们将实现回溯以恢复完整的编辑路径,并编写一个函数来输出彩色差值。

本文引用:

感谢作者 James Coglan。