Myer差分算法是一个时间复杂度为O(ND)的diff算法,就以diff两个字符串为例,其中N为两个字符串长度之和,D为两个字符串的差异部分的总长度。这个算法首先发表在An O(ND) Difference Algorithm and Its Variations。
Myer差分算法直接解决的问题是最长公共子序列(LCS)的等价问题——最小编辑脚本(SES)问题。当然了,这是论文中的表述,在我看来就是解决了最小编辑距离问题。Myer使用了图来表述这个编辑过程,就以“ABC”和“CBA”这两个字符串的编辑过程为例:
源字符串排列在x轴上侧,目标字符串排列在y轴左侧。图中的红线是编辑过程,蓝线和黄线暂时忽略。红线共5条线段,以左上角为原点,向右为x轴正方向,向下为y轴正方向,一个格子的长度为1,那么这5条线段的6个端点依次为(0, 0)->(1,0)->(2,0)->(3,1)->(3,2)->(3,3)。水平向右的线段代表删除线段终点x坐标上的字符,竖直向下的线段代表插入线段终点y坐标上的字符,斜向右下的线段代表不做编辑,保留线段终点坐标上的字符,只有终点xy坐标处的字符相等时才能这样操作。那么前面的5个线段分别代表了:-A、-B、C、+B、+A。我们把它列成一列:
-A
-B
C
+B
+A
有没有感到很熟悉呢?这和git的diff输出格式是相似的:
$ git diff 1.txt 2.txt
diff --git a/1.txt b/2.txt
index b1e6722..da662e1 100644
--- a/1.txt
+++ b/2.txt
@@ -1,3 +1,3 @@
-A
-B
C
+B
+A
要找一组连起来能从左上角抵达到右下角的线段还是很容易的:最基本的有两组,先直抵达右上角,再直抵达右下角和先直抵达左下角,再直抵达右下角。反映在diff得结果上,前一组是把源字符串全部删除再把目标字符串整个插入,后一组是把目标字符串整个插入再把源字符串全部删除。但这样的diff结果是没有意义的,理想的diff能够最大程度得保留两个字符串相同的部分(LCS),最小化删除和插入操作。表现在图上的话,就是找一组从左上角抵达到右下角的线段,使得水平线段和竖直线段尽可能少,斜线线段尽可能多。怎么做?
Myer使用了贪心算法来实现,下面我来描述一下算法的过程。描述算法的样例字符串就用Myer论文中使用的样例字符串,“ABCABBA”和“CBABAC”。
我们一步一步的来把源字符串编辑成目标字符串,每一步可以是删除操作——添加一条水平线段,也可以是插入操作——添加一条竖直线段,添加斜线不算作编辑操作,只要有可能就可以按照规则尽可能地添加。这样的话,每一步都会添加一个水平线段或者竖直线段,那么问题就转化为了如何用最小的步数在图上从左上角到达右下角。
若当前终点为P(x,y),记k=x-y,我们可以发现,下一步如果插入水平线段,终点会变成P’(x+1,y),k’=x+1-y=k+1;下一步如果插入竖直线段,终点会变成P’’(x,y+1),k’’=x-(y+1)=k-1;而插入斜线不会影响终点的k值。总结下来就是:如果当前终点的k值为k,那么下一步终点的k值为k+1或k-1。逆向使用这条规律:如果当前终点的k值为k,上一步终点的k值为k-1或k+1。
若当前的步数为d,根据前面的推导,我们可以发现d和k之间是有联系的。d=0时,可能的终点k值只可能取0;d=1时,可能的终点k值可能取-1、1;d=2时,k可能取-2、0、2,依次类推,我们可以将对当前d值得可能k值使用下面这段程序输出:
for (int k = -d; k <= d; k += 2)
{
std::cout << k << std::endl;
}
我们把图中所有k值相等的点,k=0,1,2,3…用线段连接起来,这些线段会是一组左上到右下方向的平行线。如下图中的绿线:
有一条非常明显的结论:每组k值相等的点(即在同一条绿线上的点)中,若某个x值大的点到达终点需要的步数为d1,某个x值较小的点到达终点需要的步数为d2,那么d1<=d2一定成立。
那么我们的贪心选择就是:在当前步数d的当前k值线上的点,选择能到达的那个x值极大的点,这个点能够更快的抵达终点。这里为什么说选择呢?因为为了在步数为d时到达k值为k的线的话,在步数为d-1时可能在k值为k+1或k-1的线上,有两个可能,所以需要我们选择从哪条线上走到k值为k的线上。
用代码和注释展示的话,基本流程就是这样的:
(若源字符串长度为M,目标字符串长度为N,很显然,最优的步数一定不会比M+N更差,所以d的上限是M+N。)
for (int d = 0; d <= M + N; d++)
{
for (int k = -d; k <= d; k += 2)
{
// find a max-x point in current k line
// it depends on max-x point in k-1 line & k+1 line
}
}
我们用一个std::vecotr<std::map<int,int>> v来保存每一步d的每一个k值所能抵达的x值极大的那个点(这里用std::map只是为了方便说明原理,是因为k值可能为负,实际代码千万别用map,否则数据量大了以后速度之慢和内存消耗都非常惊人),当走到下一步时我们会用得到上一步所有k值能到达的x值最大的点。
如何选择呢?如果v[d-1][k-1]=x1,v[d-1][k+1]=x2,从k-1线到达k线的话,是添加了一条水平线段,终点x’=x1+1,而从k+1线到达k线的话,是添加了一条竖直线段,终点x’’=x2。如果x1+1<x2,我们选择从k+1线向下走一步,如果x1+1>x2,我们选择从k-1线向右走一步,如果x1+1=x2呢,我们选择从k+1线向下走一步,这是为了能让diff结果看起来更直观一些(先删除后插入更直观)。因此,如果x1<x2(由于x值只能取整,所以等价于x1+1<=x2),从k+1线向下走到达k线,否则从k-1线向右走到达k线。要记得到达k线后,要尽可能的添加斜线,以便得到更大的x值。
还有一点需要注意的是,在当前步数d的k值为d时,d-1步并没有到达过k+1线(到达过k值最大的线为d-1=k-1线),无法从k+1线到达k线。在当前步数d的k值为-d时,d-1步并没有到达过k-1线(到达过k值最小的线为-(d-1)=-d+1=k+1线,无法从k-1线到达k线。
以上就是Myer差分算法大致原理和一些需要注意的点,演示程序见myers_diff仓库。