基于有向图的diff算法
简介
假设有两段文本 a 和 b,怎样将 a 改写为 b 呢,我是说,使用最少的步骤,应当是怎样的一种实现?更具体一点,有字符串 以及字符串 ,我们容易得出来,只要将中的后三个字符换成中后两个字符就可以了。
在这整个过程中,对字符串的操作主要有三种:
- 跳过,比如第一个字符
- 删除,如中的后三个字符都会先删除
- 插入,中的第2和第3个字符在删除原有字符后都会再次插入中对应位置的字符
改写字符串的过程就可以视为上述3中操作的组合排序,由于对同一个位置,可能会既进行删除,又进行插入,因此可以使用二维坐标来表示操作的分布。
如上图所示:字符串 ABCABBA 修改为 CBABAC 的过程可以表示为从顶点(0,0)到顶点(7,6)的过程。
- 横向的箭头表示删除x坐标对应的字符
- 纵向的箭头表示插入y坐标对应的字符
- 对角线箭头表示跳过
path
坐标系中,由箭头首尾相连的到path,代表了字符串完整的转化过程
trace
在坐标系中,取字符相同的坐标点,按顺序排列得到 trace。显然,trace上每个点都是一条对角线箭头的终点。
common subsequence
一个字符串的子序列可以通过删除该字符串中的零个或者多个来的到。而两个字符串的公共子序列,表示这两个字符串中相同的部分。每一个trace对应着一条公共子序列,反之亦然。
edit script
trace在坐标系中并不是连续的点,而是离散的,不需要做任何操作的字符点。这些点之间,需要使用非对角线进行连接,连接后的结果是path。而根据path的到的一组删除和插入命令,就是编辑脚本。其中删除命令记为,指从字符串A删除。插入命令则表示在后插入字符序列。
脚本命令访问的位置参数,指的是原始字符串中的位置。而不是经过操作修改过后的。同时,所有的操作被认为是同时执行的,而非按照某种顺序。
脚本的长度被定义为插入和删除字符的总数量。
trace 和 edit script 的对应关系
每一个 trace 都与一个 edit script 一一对应,这是因为,trace 确定了两个字符串的公共子序列,因此也就确定了,在对字符串进行修改时,哪些字符需要插入,哪些字符需要删除,也就是确定了所有的插入和删除操作(在修改方向确定的情况下)。
公共子序列、编辑脚本、traces和path都是同性异构体,比如公共子序列于trace其实是对应和相互定义的,而每一个path的边也能被其公共子序列和编辑脚本确定。因此,path中横向箭头和纵向箭头的数量总和就是这个path对应的编辑脚本的长度。
path中边的总数为 N + M - L
- N 字符串 A 的长度
- M 字符串 B 的长度
- L 对角线边的数量
LCS 和 SES
- LCS:寻找最长公共子序列
- SES:寻找最短编辑脚本 易得 ,D为非对角线边的数量,L为对角线边的数量。因此,对角线边越多,非对角线边就越少,因此编辑脚本的长度就越小。
考虑为每一条边设置权重,对角线边为0,非对角线边为1,则LCS和SES这俩问题都可以等价为在有权有向图中找到权重和最小的路径。
理论
这一部分使用数学归纳法证明了求解这个问题的理论基础,即 Dpath 必然以编号范围在 {-D, -D+2,...,D-2,D} 内的对角线边为终点,以及 Dpath 可以根据 D-1path 连接一个非对角线边来得到。
snake
多个对角线边连接起来的一段连续路径。
k 对角线
k 对角线是指以满足 x-y=k 的坐标点为终点的对角线边的集合。例如,在3x3的网格中,0对角线包含(0,0),(1,1),(2,2)这三个点。
引理1: D path 必以 k 对角线为终点,
证明:
- 当 d = 0 时, 以 0 对角线为终点,成立
- 假设当 d=n 时,以 k 对角线为终点,
- 那么,当 d=n+1 时,其终点所在的对角线为
根据数学归纳法,引理1成立
推论
根据定义,k 对角线中有很多条对角线边,因此每个k对角线边都可能会对应着一条D-path,定义最远的D-path为,其对角线边所指向的顶点,距离原点(0,0)最远的那条D-path。
引理2: D path 可以分解为 D-1 path 跟着一条非对角线边以及一段延伸到头的snake。
从定义上理解,其实也可以分解为以一个非对角线边间隔的两段snake吧。【延伸到头】的意思就是,这个snake过后,要么到达了网格的边界,要么就是遇到了非对角线边。
当然不能这么理解😂,因为每段 path 最末尾的一段snake被定义为尽可能地长,所以D-1path后面一定紧跟着一个非对角线边
实现
本文介绍一种贪心算法用于寻找最远的D-path,贪心算法,顾名思义就是尽可能多地尝试,直到找到结果。但贪心也并不是盲目的乱撞,而是基于前面的推论对寻找的途径进行合理规划。具体如下图所示
- 咱们的目的是以尽可能小的D值获得一条通往(N,M)的path,所以D要从0开始逐渐增加
- 对于每个D,我们找到这一组D-path所能到达的所有k对角线,并找出其中距离原点最远的
- 如果(N,M)是这个k对角线的终点,那么以这个最远k对角线为终点的D-path就是最优解
优化
下图算法对上面的算法进行了一些优化
这个优化的思路是将次找到的最远k对角线的端点缓存到数组中。
- 第三行
- 当k=-D时,说明这个Dpath的终点在x - y = -D 的对角线边上,而x - y=-D说明整个path经历的全都是插入操作(也就是竖直边)
- k!=D说明至少有一个插入操作,同时v[k+1]缓存是被计算过了的
- V[k - 1] < V[k + 1] 说明k-1对角线能达到的最远点的x坐标小于k+1对角线能达到的最远点的x坐标
- 在以上两种情况下,取 x = v[k+1]
- 否则 x = v[k-1] + 1 这意味着对字符串执行一次水平方向上的删除操作
- 然后根据k对角线的定义计算出y值,然后沿着k对角线前进尽可能长的距离
- 缓存当前k对角线能到达的最远x坐标
- 判断是否以经得到最优解
一直没明白数组v是如何缓存到下一次要使用的数据的。其实,由于k值在每一次D循环中,会更新以2为步长的k对角线最远值,因此当下一次D循环时,正好能够以交错的方式获得v[k-1]和v[k+1]。例如,D = 1的循环结束后,v[-1],v[1]会有值,下一次循环时,D=2,这次循环中,需要使用到v中的值只有v[-1],v[1](即k=0时需要利用缓存来比较),因此,这个缓存机制能够减少计算k对角线最远点的成本
复杂度分析
可以看到这个算法中共有三处循环,分别是第2、3和第9行。假设最终结果为D,那么第2、3行计算的次数为:,也就是复杂度为 。
而第9行的while循环,通过对比两个字符串在每个位置的字符是否相同,来找到snake的终点。对于每个D循环,k从-D到D变化的过程中,最多能够遍历(M+N)个对角线,因此,算法的总体复杂度为
生成最小编辑脚本
上述算法只能计算出最短编辑距离,却无法给出与之对应的最小编辑脚本,容易想到的方式是在计算最短编辑距离的过程中,记录下每次迭代得到的数组V,这里面存放着每一个D对应的snake结尾的端点坐标,只要从终点开始,不断回溯得到前一个状态,最终回到原点,就可以获得最小编辑脚本。
具体获取坐标点的方法如下
- 获取终点参数
- D 就是计算最短距离的结果
- k 根据对角线边的定义,终点 k = len(A) - len(B)
- 回溯上一个k值
- 从缓存的 v 数组中,对比v[k-1]和v[k+1]的大小,从而得出上一个k
- 根据v数组,得出上一个点的坐标
- 沿着对角线回退到非对角线边,并记录下沿途经过的点
- 比较对角线起点和上一个非对角线边终点,从而得到非对角线边的编辑方向
- 重复上一个步骤,直至到达起点
总结
这篇文章难度比较高,所以读了很久,也只领悟到一点皮毛,即使在chatGPT的耐心解释下,仍然云里雾里没搞太明白。为了更好地理解类似的文章,需要掌握的知识有:
- 算法与数据结构
- 复杂度分析
- 组合数学
- 概率与统计
- 图论
另外,将论文实际动手复现也可以加深对这个算法的理解。