遇事不决,动态规划
曾几何时,遇事不决,暴力破解,最小的心智负担快速解决问题,是大多数人优先考虑的解法。
但是作为一名优秀的青年,手撕算法,脚踩数据结构,最低的算法复杂度、最快的运行速度,解决最难的问题,才是优雅得体的。
在这样宏伟的愿景之下,我对部分算法进行了学习,发现相对传统的暴力破解,动态规划也是一种更贴合我们逻辑思维的解法。
接下来,就来了解一下什么是动态规划吧!
全文分为三个部分
- 理论基础 - 介绍动态规划的基础定义
- 实例演示 - 通过经典的爬楼梯、背包问题进一步掌握动态规划的思想
- git diff 算法 - 了解动态规划在实际场景中的运用 - 代码 diff 工具
理论基础
什么是动态规划?
动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。
简单来说,就是将一个大问题分解成若干子问题,通过解决子问题,然后一步一步推导找到问题最终解的一个过程。
大致就如下图所示:
怎么样,是不是非常简单(开个玩笑,开个玩笑。
其实大部分动态规划能解决的问题,都可以通过暴力枚举来解决,回溯算法的思想就源于暴力枚举,当满足情况就停止遍历(剪枝)。不过回溯算法的复杂度是呈指数级增长,动态规划却可以有效地降低时间复杂度。
我们常常使用动态规划来解决:最优解问题,比如求最大值、最小值等等
动态规划具有“一个模型三个特征”
一个模型
- 多阶段决策最优解模型
三个特征
-
最优子结构:问题的最优解包含子问题的最优解
-
无后效性:
在推到后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步步推导出来的
某阶段状态一旦确定,就不受之后阶段的决策影响
-
重复子问题:不同的决策序列,到达某个相同阶段时,可能会产生重复的状态
啥模型?啥特征?是不是有点难理解,我们可以结合以下的实例来剖析它们的含义。
实例演示
爬楼梯
假设你正在爬楼梯。需要
n
阶你才能到达楼顶。每次你可以爬
1
或2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
还记得之前说过动态规划可以看做是通过解决子问题,获取递推规律,从而达到解决最终问题的过程吗?
就和做测智商题的解法一致:找规律
首先我们尝试一下正向找规律:
-
当 n = 1 时,我们有 1 种方法可以到达楼顶:
- 1 阶
-
当 n = 2 时,我们有 2 种方法可以到达楼顶:
- 1 阶 + 1 阶
- 2 阶
-
当 n = 3 时,我们有 3 种方法可以到达楼顶:
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2阶 + 1阶
-
。。。。
依次类推可以得到台阶数和方法数的关系:
台阶高度 | 方法总数 |
---|---|
1 | 1 |
2 | 2 |
3 | 3 |
4 | 5 |
5 | 8 |
6 | 13 |
7 | 21 |
8 | 34 |
... | ... |
聪明的你应该已经发现规律了:从第 3 阶开始,当前台阶方法总数的值不就等于前两个值的和嘛。
3 = 2 + 1,5 = 3 + 2,8 = 5 + 3 ...
将f(n)
当做上第n
个台阶的方法总数,那么就是
f(n) = f(n - 1) + f(n - 2)
如果是根据反向找规律的思维来推导的话就是:
每次可以爬 1 或 2 个台阶,就是说爬上第 n 个台阶,一定是从第 n - 1 或者 n - 2个台阶上去的,那么爬上第 n 个台阶的方法总数就是爬到第 n - 1 个台阶的方法总数与爬到第 n - 2 个台阶的方法总数之和。
使用代码的方式来解决就是这样:
const climbStairs = (n) => {
// f[i] 为第 i 阶楼梯有多少种方法爬到楼顶
const f = new Array(n + 1).fill()
// 记录初始值,0 阶台阶 0 种, 1 阶台阶 1 种,2 阶台阶 2 种
f[0] = 0
f[1] = 1
f[2] = 2
// 从第 3 阶开始,当前台阶方法总数的值等于前两个值的和
for(let i = 3; i <= n; i++) {
f[i] = f[i - 1] + f[i - 2]
}
// 返回抵达当前台阶的方法数
return f[n]
};
到这里可以发现两个关键点:
f(n)
的获取完全依赖于f(n - 1)
和f(n - 2)
,也就是最终结果的获取依赖于子结果,对应最优子结构特征f(n)
的获取只关心f(n - 1)
和f(n - 2)
的值,而并不关心f(n - 1)
和f(n - 2)
值是如何推导出来的,对应无后效性特征
接下来,我们通过经典的背包问题来深入了解下这种算法
背包问题
假设你有一个可以装 4 kg 东西的背包
商店里有如下三件物品(所有物品大小形状都看作一致,仅重量不同)
怎样选择,能让装进背包的物品价值最大?
遇事不决,我们就。。把所有情况列出来:
可以很清晰的看到,选择平板 + 笔记本电脑是在有限容量(4kg)下价值最大的(3500元)选择。
这样虽然可行,但是不优雅,在有 3 个商品的时候,我么需要计算 8 种组合,在有 4 件商品的时候,我们要计算 16 种组合,当有 n 件商品就需要就计算 2 ^ n 种组合,众所周知,指数级复杂度的算法,优秀青年不到万不得已绝对不会使用。
这时候,就轮到我们的标题登场了:遇事不决,动态规划
按照之前介绍过的动态规划的理念(通过解决小问题来推导出大问题的答案),来具体分析一下:
-
4 kg 的背包,装 3 种不同价值和重量的物品,如何使装的物品价值总和最大
-
可以将大的数值(4kg、3种)拆解成小数值:
- 1kg 的背包装 1 种物品能获取最大价值的方法;
- 1kg 的背包装 2 种物品能获取最大价值的方法;
- 1kg 的背包装 3 种物品能获取最大价值的方法;
- 2kg 的背包装 1 种物品能获取最大价值的方法;
- 。。。
文字描述不太直观,转换成表格的形式:
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1kg背包装平板 | 2kg背包装平板 | 2kg背包装平板 | 2kg背包装平板 |
音响/4kg/3000 | 1kg背包装平板和音响 | 2kg背包装平板和音响 | 3kg背包装平板和音响 | 4kg背包装平板和音响 |
笔记本/3kg/2000 | 1kg背包装平板、音响和笔记本 | 2kg背包装平板、笔记本和音响和笔记本 | 3kg背包装平板、和音响和笔记本 | 4kg背包装平板、音响和笔记本 |
这样一张表格就可以看作上文提到的多阶段决策最优解模型。
然后我们再依次分析,每一格能装的最大价值物品,比如平板这一行:
- 【1kg 背包装平板】:平板刚好 1kg 可以装下,所以最大价值为1500
- 【2kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
- 【3kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
- 【4kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1500 | 1500 | 1500 | 1500 |
音响/4kg/3000 | ||||
笔记本/3kg/3000 |
我们目前在【4kg 背包装平板】获得的最大价值是1500 元
那么到了音响这一行:
- 【1kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
- 【2kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
- 【3kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
- 【4kg 背包装平板和音响】:只装平板,价值为1500;只装音响,价值为3000;因此最大价值为3000
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1500 | 1500 | 1500 | 1500 |
音响/4kg/3000 | 1500 | 1500 | 1500 | 3000 |
笔记本/3kg/3000 |
可以看到,【4kg 背包】中更新了最大价值为3000元
接着往下看笔记本这一行
- 【1kg 背包装平板、音响和笔记本】:只装得下 1kg 的平板,最大价值为1500
- 【2kg 背包装平板、音响和笔记本】:只装得下 1kg 的平板,最大价值为1500
- 【3kg 背包装平板、音响和笔记本】:只装 1kg 的平板,价值为1500;只装 3kg 的笔记本,价值为2000,所以最大价值为2000
这样【3kg】背包的最大价值更新为了2000元:
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1500 | 1500 | 1500 | 1500 |
音响/4kg/3000 | 1500 | 1500 | 1500 | 3000 |
笔记本/3kg/2000 | 1500 | 1500 | 2000 |
最后的【4kg 背包装平板、音响和笔记本】中,就只剩两种情况了:
【4kg音响的价值】 vs 【3kg笔记本的价值】 + 【1kg平板的价值】
显然【3kg音响的价值】 + 【1kg平板的价值】略胜一筹,夺得桂冠
最终得出:同时装下平板和笔记本,最大价值为3500,如下表格所示
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1500(平) | 1500(平) | 1500(平) | 1500(平) |
音响/4kg/3000 | 1500(平) | 1500(平) | 1500(平) | 3000(音) |
笔记本/3kg/2000 | 1500(平) | 1500(平) | 2000(笔) | 3500(平+笔) |
可能有人会表示疑惑:这和我用穷举法不能说完全相似,只能说一模一样?甚至步骤看起来还更多了??
当然不是!
我们可以看到在推导【4kg 背包装平板、音响和笔记本】最大价值的时候,我们只做了一个比较:
【4kg音响的价值】 vs 【3kg笔记本的价值】 + 【1kg平板的价值】
也就是
【当前的最大价值】 vs 【当前商品的价值】 + 【剩余空间的最大价值】
当【当前的最大价值】没有被更新的时候,就对应了我们上面提的"三个特征"中的重复子问题特征:不同的决策序列,到达某个相同阶段时,可能会产生重复的状态
每一步的最大价值的计算都只依赖于这两个数值的比较
不信的话,我们再增加一个商品试试:
表格同样再来一行
1kg背包 | 2kg背包 | 3kg背包 | 4kg背包 | |
---|---|---|---|---|
平板/1kg/1500 | 1500(平) | 1500(平) | 1500(平) | 1500(平) |
音响/4kg/3000 | 1500(平) | 1500(平) | 1500(平) | 3000(音) |
笔记本/3kg/2000 | 1500(平) | 1500(平) | 2000(笔) | 3500(平+笔) |
手机/1kg/2000 |
-
【1kg 背包装平板、音响、笔记本和手机】:1500(平) vs 1kg 手机,手机获胜,最大价值更新为2000
1kg背包 2kg背包 3kg背包 4kg背包 平板/1kg/1500 1500(平) 1500(平) 1500(平) 1500(平) 音响/4kg/3000 1500(平) 1500(平) 1500(平) 3000(音) 笔记本/3kg/2000 1500(平) 1500(平) 2000(笔) 3500(平+笔) 手机/1kg/2000 2000(手) -
【2kg 背包装平板、音响、笔记本和手机】: 当前(2kg背包)最大价值 1500(平) vs 当前物品价值 2000(手) + 1 kg背包最大价值(1500平),后者获胜,为3500元
1kg背包 2kg背包 3kg背包 4kg背包 平板/1kg/1500 1500(平) 1500(平) 1500(平) 1500(平) 音响/4kg/3000 1500(平) 1500(平) 1500(平) 3000(音) 笔记本/3kg/2000 1500(平) 1500(平) 2000(笔) 3500(平+笔) 手机/1kg/2000 2000(手) 3500(手+平) -
【3kg 背包装平板、音响、笔记本和手机】:当前(3kg背包)最大价值2000(笔) vs 当前物品价值 2000(手) + 剩余 2kg背包最大价值1500(平),后者获胜,为3500元
1kg背包 2kg背包 3kg背包 4kg背包 平板/1kg/1500 1500(平) 1500(平) 1500(平) 1500(平) 音响/4kg/3000 1500(平) 1500(平) 1500(平) 3000(音) 笔记本/3kg/2000 1500(平) 1500(平) 2000(笔) 3500(平+笔) 手机/1kg/2000 2000(手) 3500(手+平) 3500(手+平) -
【4kg 背包装平板、音响、笔记本和手机】:最后一个想必你也会了,只需比较
可以得出最大值为 2000(手) + 2000(笔),最大值更新为 4000 元
1kg背包 2kg背包 3kg背包 4kg背包 平板/1kg/1500 1500(平) 1500(平) 1500(平) 1500(平) 音响/4kg/3000 1500(平) 1500(平) 1500(平) 3000(音) 笔记本/3kg/2000 1500(平) 1500(平) 2000(笔) 3500(平+笔) 手机/1kg/2000 2000(手) 3500(手+平) 3500(手+平) 4000(手+笔)
如果将表格看作是一个二维数组package
,i
表示行,j
表示列,package[i][j]
表示当前格的最大价值的话,我们可以得出:
此时,增加一个商品,我们只增加了 4 个步骤就求出了最大价值,也就是说,如果我们使用这种方法求解最大价值,只需要【背包重量 * 物品数】步就可以得到结果,复杂度为O(n ^ 2),而使用穷举法,每增加一件物品,步骤数却会呈指数增长(2^4, 2^5, 2^6.......),即复杂度为O(2 ^ n)。
计算机对所有结果的处理过程其实就是一一穷举,而我们通过算法实现的代码,可以大大减少这个穷举的过程,因此在实际表现上就是“运行速度更快了,结果出现的更快了”。复杂度的降低意味着运行效率的提升。动态规划较之盲目的穷举带来的好处就在于此。
总结
由以上的示例,我们可以得出
- 动态规划可以帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,获取价值总和最高的物品
- 在问题可分解为彼此独立且离散的子问题时,就可使用动态规划来解决。在背包问题中,我们是有一个大前提的:所有物品的形状等其他因素视为一致。如果不一致,就不适合使用动态规划了。
学习算法不光是为了能够刷题,更重要的是培养我们对复杂场景的多维思考能力。
git diff 算法
最后,就到了我写这篇文章的最初的打算了。恭喜还能读到这里的朋友,你将会增加一些其他人不知道的知识。
算法思路
我们经常使用的 git 或者 IDE 中都有代码对比的能力,如下所示:
代码差异化对比是被版本控制系统用来确认代码做了哪些更改。那么要如何实现对代码的差异性对比的功能呢?
比如说,有一个字符串
a = 'ABCABBA'
当对它做出了部分更改,并提交:
a = 'CBABAC'
如何判断这次更改的差异呢?
首先最简单的方法就是直接做全量替换:
全删了重新来过,最低的心智负担创造最大的价值,好!
显而易见,如果你在 git 上查看提交记录返回这样的结果,怕不是会直接删库跑路。那么如何让 diff 结果更加直观详细呢?
一般思路就是,相同的元素保留,只增/删不同的元素:
这种对比方式就很直观地体现出做出了哪些更改,当然,这种列举方式不止一种,我们还可以有多种的实现方式:
可以看到,这四种方式的操作步骤都是只有五步,都属于步骤数最优解的范畴。
差异算法的目的是提供一种产生差异的策略,其中差异具有某些理想的性质。我们通常希望差异尽可能小,但也需要其他考虑。
比如是先删除后增加,还是先增加后删除,还是一边加一边减?
所以 diff 算法不仅仅是需要找出两份代码的不同之处,还需要考虑增减的顺序带来的可阅读性。
一般而言,先统一进行删除操作,再进行新增操作是比较好的方式:
你们也可以打开 gitlab / github / gitee 观察代码对比部分,都是使用的【先删除差异部分,再新增】的策略,这样样能保证最佳的阅读感。
不止如此,还有对代码结构特征的考量。
例如,如果想要插入一个方法,该方法的结束应该被认为是新的,而不是保留前面方法的结束:
好: class Smart 差: class Smart
def initName(name) def initName(name)
name = 'jason' name = 'jason'
return + return
+ +
+ def initage(age) + def initage(age)
+ age = 18 + age = 18
+ return return
注:此处运用了贪心算法
综上,一个优秀的直观的 diff 算法需要满足:
- 先删除,后增加
- 整块删除后新增,而不是删除新增交叉
- 新增和删除的逻辑结构一致
图搜索
Myers 算法就是这样,不但能快速定位到差异化的内容,还考虑了最直观的阅读性。当然,Myers 算法核心就是本文的主角:动态规划。
Myers 算法将寻找 diff 的过程抽象成了图搜索。
A B C A B B A
o-----o-----o-----o-----o-----o-----o-----o 0
| | | \ | | | | |
C | | | \ | | | | |
| | | \ | | | | |
o-----o-----o-----o-----o-----o-----o-----o 1 向右:增加
| | \ | | | \ | \ | |
B | | \ | | | \ | \ | | 向下:删除
| | \ | | | \ | \ | |
o-----o-----o-----o-----o-----o-----o-----o 2 对角线:保留原内容不变
| \ | | | \ | | | \ |
A | \ | | | \ | | | \ |
| \ | | | \ | | | \ |
o-----o-----o-----o-----o-----o-----o-----o 3
| | \ | | | \ | \ | |
B | | \ | | | \ | \ | |
| | \ | | | \ | \ | |
o-----o-----o-----o-----o-----o-----o-----o 4
| \ | | | \ | | | \ |
A | \ | | | \ | | | \ |
| \ | | | \ | | | \ |
o-----o-----o-----o-----o-----o-----o-----o 5
| | | \ | | | | |
C | | | \ | | | | |
| | | \ | | | | |
o-----o-----o-----o-----o-----o-----o-----o 6
0 1 2 3 4 5 6 7
向右表示“删除”,向下表示“新增”,对角线表示“保留原内容不变”。
图中每一条从左上角(0, 0)到右下角的(7, 6)的路径都可以视为一次 diff 过程。
比如我选择最外层的路线:
这条就是人脑思维最快解:
from
ABCABBA
DO
一路向右:-A -B -C -A -B -B -A
一路向下:+C +B +A +B +A +C
RESULT
CBABAC
那么 Myers 算法如何做到在万千条道路中找到最快最优雅的那条呢?容我慢慢道来
第一步
首先,我们从(0, 0)开始出发
0,0
此时有两个选择,可以向右到达(1,0)和向下到达(0,1),也就是
0,0 --- 1,0
|
|
0,1
第二步
接着分析第二步,先考虑从(0,1)出发,也有向下、向右两种情况:
-
向下移动到达(0,2),但是(0,2)到达(1,3),(1,3)到达(2,4)都是对角线,对角线的移动意味着保留元素不变,既不需要删除,也不需要新增元素,所以我们可以将(0,1)到达(2,4)标记为一步完成。
-
向右移动到达(1,1),这里也有一条对角线可以快速从(1,1)到达(2,2)
我们标记如下:
0,0 --- 1,0
|
|
0,1 --- 2,2
|
|
2,4
接着再判断另一个分支(0,0) -> (1,0)之后的移动步骤:
-
从(1,0)向右移动到(2,0)后,通过对角线直达(3,1)
-
从(1,0)向下移动到(1,1)后,可以通过对角线直达(2,2)
细心的小伙伴应该发现了,之前通过(0,1)也是两步直达(2,2),那么哪种路径是我们需要的呢?
还记得一个优秀的直观的 diff 算法需要满足的条件吗?先删除后增加(向右是删除,向下是增加)
而我们通过(1,0)到达(2,2)正是先向右后向下即先删除后增加,比通过(0,1)的先增加后删除是一种更好的解。
因此,目前移动的步骤标记变更如下,记住,我们只保留每一步的最佳结果:
0,0 --- 1,0 --- 3,1
| |
| |
0,1 2,2
|
|
2,4
第三步
接下来,就到了比较关键的第三步了。第三步的起始位置有三个,分别是(2,4),(2,2)和(3,1)
- 从(2,4)可以到达(3,6)和(4,5)两个位置
- 从(2,2)可以到达(2,3)和(5,4)两个位置
- 从(3,1)可以到达(5,2)和(5,4)两个位置
可以看到,从(2,2)和(3,1)都能一步到达(5,4),根据我们在上面定义的结论:整块删除后新增,而不是删除新增交叉。通过(2,2)到达(5,4)经历了删增删,通过(3,1)到达(5,4)经历了删删增,所以(3,1)要优于(2,2)
同样思路,先进行两次插入,然后再进行一次删除(向下两次,然后向右),得到(4,5);而先进行删除,然后再进行两次插入,得到(2,3)。因此,我们将保留(4,5)结果,并丢弃(2,3),表明(4,5)是在一次删除和两次插入之后以任何顺序可到达的最佳位置。
第三步完整记录如下:
0,0 --- 1,0 --- 3,1 --- 5,2
| | |
| | |
0,1 2,2 5,4
|
|
2,4 --- 4,5
|
|
3,6
到现在为止,你应该发现窍门了,我们就不再一步步推导了,最终可以得到如下最佳移动轨迹:
0,0 --- 1,0 --- 3,1 --- 5,2 --- 7,3
| | |
| | |
0,1 2,2 5,4 --- 7,5
| | |
| | |
2,4 --- 4,5 5,5 7,6
| | |
| | |
3,6 4,6 5,6
因此(0,0) => (1,0) => (3,1) => (5,4) => (7.5) => (7,6)就是两个字符串之间 diff 的最佳路径,也就是
核心原理
我们已经了解了图搜索是如何进行的,那么 Myers 算法是如何实现的呢?
首先,将上面的移动轨迹旋转45度后渲染:
k\d| 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
横轴上的数字 d 表示我们在图中所到达的深度,即走了多少步;
纵轴上的数字 k 表示每一步坐标(x,y)的差值 x - y
向右移动一步 x 加 1,所以 k 也增加 1;向下移动一步 y 加 1,同样的 k 就会减少 1;沿对角线移动,x 和 y 都是增加相同的值,因此它俩的差值 k 保持不变。
我们记录的是每一步的 k 值能到达的最远的距离。
从图中我们可以发现,除了原始坐标(0,0)外,所有节点要么是从左上角的节点过来(向下移动),要么是从左下角的节点过来(向右移动)。
也就是每个(d,k)的取值都依赖于(d - 1, k - 1)和(d - 1, k + 1)两个值的更优解。
怎样判断(d - 1, k - 1)和(d - 1, k + 1)谁更优秀呢?上文也提到过了,要保证删除先于新增,向右走的多就表示删除的多,x 就越大。
在(d,k) = (2,0)这个坐标中,左上角的 x 值 1 要大于左下角 x值 0,所以自然而然的,从(x,y) = (1,0)走到(x,y) = (2,2)是最优解
k\d| 0 1 2
----+----------------------
|
1 | 1,0
| / \
0 | 0,0 ( 2,2 )
| \
-1 | 0,1
如果 x 的值一样大呢?比如(d, k) = (3,-1)的时候,有(x,y) = (2,2)和(x,y) = (2,4)两个选择
k\d| 0 1 2 3
----+----------------------------
|
2 | 3,1
| /
1 | 1,0
| / \
0 | 0,0 2,2
| \
-1 | 0,1 ( 4,5 )
| \ /
-2 | 2,4
上面提到过,左上角的节点向下移动到达目标节点,左下角的节点向右到达目标节点,所以我们选择向右(删除有限)到达目标节点的节点,也就是(2,4)
可能有人对这句话不太理解,其实把坐标轴旋转45度还原回去再看看就明白了
到目前为止,是不是感觉每一个节点的最优坐标判定还是很复杂
所以我们需要把整个过程按照如下步骤再简化一下:
- 我们存了 k 的值和 x 的值,就不需要存储 y 的值了,因为可以通过 k = x -y 计算出来
- 我们不需要存储每一步的移动方向,只需要保存每一步的最佳 x 值就行了,因为路径可以通过找到最小的 d 来还原
简化后可以得到:
k\d| 0 1 2 3 4 5
----+--------------------------------------
|
4 | 7
|
3 | 5
|
2 | 3 7
|
1 | 1 5 7
|
0 | 0 2 5
|
-1 | 0 4 5
|
-2 | 2 4
|
-3 | 3
最后,观察上图不难发现,在每一步 d 中的 x 值都取决于(d -1) 步中的 x 值,并且每一步都在交替修改奇数位或者偶数位 k 的位置,所以对其依赖的值不会产生影响。因此,我们可以将 x 值存储在单个以 k 为索引的扁平化数组当中:
k | -3 -2 -1 0 1 2 3 4
--------+-----------------------------------------------
|
d = 0 | 0
|
d = 1 | 0 0 1
|
d = 2 | 2 0 2 1 3
|
d = 3 | 3 2 4 2 5 3 5
|
d = 4 | 3 4 4 5 5 7 5 7
|
d = 5 | 3 4 5 5 7 7 5 7
当在数组 (d, k) = (5,1)到达终点 (x, y) = (7,6)时终止遍历。
代码的具体实现可以参考myers-diff/main.go at master · cj1128/myers-diff 以及 jsdiff/base.js at master · kpdecker/jsdiff
这就是很纯粹的动态规划的思想:通过找到到达每一个节点的最优解,以推断出到达终点的最优解。
最后
创造不易,图片都是自己P的,点个赞再走吧!祝大家升职加薪!
参考
理论基础部分
数据结构与算法之美 - 王争 - 动态规划理论:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题
实例演示部分
git diff 算法部分
Git 是怎样生成 diff 的:Myers 算法 - CJ Ting's Blog
The Myers diff algorithm: part 1 – The If Works
The Myers diff algorithm: part 2 – The If Works