遇事不决,动态规划

1,912 阅读24分钟

遇事不决,动态规划

曾几何时,遇事不决,暴力破解,最小的心智负担快速解决问题,是大多数人优先考虑的解法。

但是作为一名优秀的青年,手撕算法,脚踩数据结构,最低的算法复杂度、最快的运行速度,解决最难的问题,才是优雅得体的。

在这样宏伟的愿景之下,我对部分算法进行了学习,发现相对传统的暴力破解,动态规划也是一种更贴合我们逻辑思维的解法。

接下来,就来了解一下什么是动态规划吧!

全文分为三个部分

  • 理论基础 - 介绍动态规划的基础定义
  • 实例演示 - 通过经典的爬楼梯、背包问题进一步掌握动态规划的思想
  • git diff 算法 - 了解动态规划在实际场景中的运用 - 代码 diff 工具

理论基础

什么是动态规划?

动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。

简单来说,就是将一个大问题分解成若干子问题,通过解决子问题,然后一步一步推导找到问题最终解的一个过程

大致就如下图所示: image.png

怎么样,是不是非常简单(开个玩笑,开个玩笑。

其实大部分动态规划能解决的问题,都可以通过暴力枚举来解决,回溯算法的思想就源于暴力枚举,当满足情况就停止遍历(剪枝)。不过回溯算法的复杂度是呈指数级增长,动态规划却可以有效地降低时间复杂度。

我们常常使用动态规划来解决:最优解问题,比如求最大值、最小值等等

动态规划具有“一个模型三个特征”

image-20220422234525764.png

一个模型

  • 多阶段决策最优解模型

三个特征

  • 最优子结构:问题的最优解包含子问题的最优解

  • 无后效性:

    在推到后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步步推导出来的

    某阶段状态一旦确定,就不受之后阶段的决策影响

  • 重复子问题:不同的决策序列,到达某个相同阶段时,可能会产生重复的状态

啥模型?啥特征?是不是有点难理解,我们可以结合以下的实例来剖析它们的含义。

实例演示

爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

还记得之前说过动态规划可以看做是通过解决子问题,获取递推规律,从而达到解决最终问题的过程吗?

就和做测智商题的解法一致:找规律

首先我们尝试一下正向找规律

  • 当 n = 1 时,我们有 1 种方法可以到达楼顶:

    • 1 阶
  • 当 n = 2 时,我们有 2 种方法可以到达楼顶:

    • 1 阶 + 1 阶
    • 2 阶
  • 当 n = 3 时,我们有 3 种方法可以到达楼顶:

    • 1 阶 + 1 阶 + 1 阶
    • 1 阶 + 2 阶
    • 2阶 + 1阶
  • 。。。。

依次类推可以得到台阶数和方法数的关系:

台阶高度方法总数
11
22
33
45
58
613
721
834
......

聪明的你应该已经发现规律了:从第 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 东西的背包

商店里有如下三件物品(所有物品大小形状都看作一致,仅重量不同)

怎样选择,能让装进背包的物品价值最大?

image-20220419222232455.png

遇事不决,我们就。。把所有情况列出来:

image-20220416231920937.png

可以很清晰的看到,选择平板 + 笔记本电脑是在有限容量(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/15001kg背包装平板2kg背包装平板2kg背包装平板2kg背包装平板
音响/4kg/30001kg背包装平板和音响2kg背包装平板和音响3kg背包装平板和音响4kg背包装平板和音响
笔记本/3kg/20001kg背包装平板、音响和笔记本2kg背包装平板、笔记本和音响和笔记本3kg背包装平板、和音响和笔记本4kg背包装平板、音响和笔记本

这样一张表格就可以看作上文提到的多阶段决策最优解模型

然后我们再依次分析,每一格能装的最大价值物品,比如平板这一行:

  • 【1kg 背包装平板】:平板刚好 1kg 可以装下,所以最大价值为1500
  • 【2kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
  • 【3kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
  • 【4kg 背包装平板】:平板 1kg 可以装下,所以最大价值为1500
1kg背包2kg背包3kg背包4kg背包
平板/1kg/15001500150015001500
音响/4kg/3000
笔记本/3kg/3000

我们目前在【4kg 背包装平板】获得的最大价值是1500 元

那么到了音响这一行:

  • 【1kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
  • 【2kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
  • 【3kg 背包装平板和音响】:只装得下 1kg 的平板,最大价值为1500
  • 【4kg 背包装平板和音响】:只装平板,价值为1500;只装音响,价值为3000;因此最大价值为3000
1kg背包2kg背包3kg背包4kg背包
平板/1kg/15001500150015001500
音响/4kg/30001500150015003000
笔记本/3kg/3000

可以看到,【4kg 背包】中更新了最大价值为3000元

接着往下看笔记本这一行

  • 【1kg 背包装平板、音响和笔记本】:只装得下 1kg 的平板,最大价值为1500
  • 【2kg 背包装平板、音响和笔记本】:只装得下 1kg 的平板,最大价值为1500
  • 【3kg 背包装平板、音响和笔记本】:只装 1kg 的平板,价值为1500;只装 3kg 的笔记本,价值为2000,所以最大价值为2000

这样【3kg】背包的最大价值更新为了2000元:

1kg背包2kg背包3kg背包4kg背包
平板/1kg/15001500150015001500
音响/4kg/30001500150015003000
笔记本/3kg/2000150015002000

最后的【4kg 背包装平板、音响和笔记本】中,就只剩两种情况了:

image-20220419223036097.png

【4kg音响的价值】 vs 【3kg笔记本的价值】 + 【1kg平板的价值】

显然【3kg音响的价值】 + 【1kg平板的价值】略胜一筹,夺得桂冠

最终得出:同时装下平板和笔记本,最大价值为3500,如下表格所示

1kg背包2kg背包3kg背包4kg背包
平板/1kg/15001500(平)1500(平)1500(平)1500(平)
音响/4kg/30001500(平)1500(平)1500(平)3000(音)
笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)

可能有人会表示疑惑:这和我用穷举法不能说完全相似,只能说一模一样?甚至步骤看起来还更多了??

当然不是!

我们可以看到在推导【4kg 背包装平板、音响和笔记本】最大价值的时候,我们只做了一个比较:

【4kg音响的价值】 vs 【3kg笔记本的价值】 + 【1kg平板的价值】

也就是

【当前的最大价值】 vs 【当前商品的价值】 + 【剩余空间的最大价值】

当【当前的最大价值】没有被更新的时候,就对应了我们上面提的"三个特征"中的重复子问题特征:不同的决策序列,到达某个相同阶段时,可能会产生重复的状态

每一步的最大价值的计算都只依赖于这两个数值的比较

1d7b5ade16f5405d8190c86dfda36a5a.jpg

不信的话,我们再增加一个商品试试:

image-20220419224701594.png

表格同样再来一行

1kg背包2kg背包3kg背包4kg背包
平板/1kg/15001500(平)1500(平)1500(平)1500(平)
音响/4kg/30001500(平)1500(平)1500(平)3000(音)
笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)
手机/1kg/2000
  • 【1kg 背包装平板、音响、笔记本和手机】:1500(平) vs 1kg 手机,手机获胜,最大价值更新为2000

    1kg背包2kg背包3kg背包4kg背包
    平板/1kg/15001500(平)1500(平)1500(平)1500(平)
    音响/4kg/30001500(平)1500(平)1500(平)3000(音)
    笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)
    手机/1kg/20002000(手)
  • 【2kg 背包装平板、音响、笔记本和手机】: 当前(2kg背包)最大价值 1500(平) vs 当前物品价值 2000(手) + 1 kg背包最大价值(1500平),后者获胜,为3500元

    image-20220419232336251.png

    1kg背包2kg背包3kg背包4kg背包
    平板/1kg/15001500(平)1500(平)1500(平)1500(平)
    音响/4kg/30001500(平)1500(平)1500(平)3000(音)
    笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)
    手机/1kg/20002000(手)3500(手+平)
  • 【3kg 背包装平板、音响、笔记本和手机】:当前(3kg背包)最大价值2000(笔) vs 当前物品价值 2000(手) + 剩余 2kg背包最大价值1500(平),后者获胜,为3500元

    image-20220419232236986.png

    1kg背包2kg背包3kg背包4kg背包
    平板/1kg/15001500(平)1500(平)1500(平)1500(平)
    音响/4kg/30001500(平)1500(平)1500(平)3000(音)
    笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)
    手机/1kg/20002000(手)3500(手+平)3500(手+平)
  • 【4kg 背包装平板、音响、笔记本和手机】:最后一个想必你也会了,只需比较

    image-20220419232113896.png

    可以得出最大值为 2000(手) + 2000(笔),最大值更新为 4000 元

    1kg背包2kg背包3kg背包4kg背包
    平板/1kg/15001500(平)1500(平)1500(平)1500(平)
    音响/4kg/30001500(平)1500(平)1500(平)3000(音)
    笔记本/3kg/20001500(平)1500(平)2000(笔)3500(平+笔)
    手机/1kg/20002000(手)3500(手+平)3500(手+平)4000(手+笔)

如果将表格看作是一个二维数组packagei表示行,j表示列,package[i][j]表示当前格的最大价值的话,我们可以得出:

image-20220419233637501.png

此时,增加一个商品,我们只增加了 4 个步骤就求出了最大价值,也就是说,如果我们使用这种方法求解最大价值,只需要【背包重量 * 物品数】步就可以得到结果,复杂度为O(n ^ 2),而使用穷举法,每增加一件物品,步骤数却会呈指数增长(2^4, 2^5, 2^6.......),即复杂度为O(2 ^ n)。

计算机对所有结果的处理过程其实就是一一穷举,而我们通过算法实现的代码,可以大大减少这个穷举的过程,因此在实际表现上就是“运行速度更快了,结果出现的更快了”。复杂度的降低意味着运行效率的提升。动态规划较之盲目的穷举带来的好处就在于此。

总结

由以上的示例,我们可以得出

  • 动态规划可以帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,获取价值总和最高的物品
  • 在问题可分解为彼此独立且离散的子问题时,就可使用动态规划来解决。在背包问题中,我们是有一个大前提的:所有物品的形状等其他因素视为一致。如果不一致,就不适合使用动态规划了。

学习算法不光是为了能够刷题,更重要的是培养我们对复杂场景的多维思考能力。

git diff 算法

最后,就到了我写这篇文章的最初的打算了。恭喜还能读到这里的朋友,你将会增加一些其他人不知道的知识。

20200917110440_58017.gif

算法思路

我们经常使用的 git 或者 IDE 中都有代码对比的能力,如下所示:

image-20220416160143851.png

代码差异化对比是被版本控制系统用来确认代码做了哪些更改。那么要如何实现对代码的差异性对比的功能呢?

比如说,有一个字符串

a = 'ABCABBA'

当对它做出了部分更改,并提交:

a = 'CBABAC'

如何判断这次更改的差异呢?

首先最简单的方法就是直接做全量替换

image-20220423120320178.png

全删了重新来过,最低的心智负担创造最大的价值,好!

image-20220430144651699.png

显而易见,如果你在 git 上查看提交记录返回这样的结果,怕不是会直接删库跑路。那么如何让 diff 结果更加直观详细呢?

一般思路就是,相同的元素保留,只增/删不同的元素

image-20220423134446785.png

这种对比方式就很直观地体现出做出了哪些更改,当然,这种列举方式不止一种,我们还可以有多种的实现方式:

image-20220423141841319.png

可以看到,这四种方式的操作步骤都是只有五步,都属于步骤数最优解的范畴。

差异算法的目的是提供一种产生差异的策略,其中差异具有某些理想的性质。我们通常希望差异尽可能小,但也需要其他考虑。

比如是先删除后增加,还是先增加后删除,还是一边加一边减?

所以 diff 算法不仅仅是需要找出两份代码的不同之处,还需要考虑增减的顺序带来的可阅读性

一般而言,先统一进行删除操作,再进行新增操作是比较好的方式:

image-20220423151530209.png

你们也可以打开 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 算法需要满足:

  1. 先删除,后增加
  2. 整块删除后新增,而不是删除新增交叉
  3. 新增和删除的逻辑结构一致

图搜索

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 过程。

比如我选择最外层的路线:

image-20220427221831837.png 这条就是人脑思维最快解:

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)出发,也有向下、向右两种情况:

  1. 向下移动到达(0,2),但是(0,2)到达(1,3),(1,3)到达(2,4)都是对角线,对角线的移动意味着保留元素不变,既不需要删除,也不需要新增元素,所以我们可以将(0,1)到达(2,4)标记为一步完成。 image-20220430153555386.png

  2. 向右移动到达(1,1),这里也有一条对角线可以快速从(1,1)到达(2,2) image-20220430153827281.png

我们标记如下:

0,0 --- 1,0
 |
 |
0,1 --- 2,2
 |
 |
2,4

接着再判断另一个分支(0,0) -> (1,0)之后的移动步骤:

  1. 从(1,0)向右移动到(2,0)后,通过对角线直达(3,1) image-20220430190556339.png

  2. 从(1,0)向下移动到(1,1)后,可以通过对角线直达(2,2) image-20220430183005196.png

细心的小伙伴应该发现了,之前通过(0,1)也是两步直达(2,2),那么哪种路径是我们需要的呢?

image-20220430185122467.png

还记得一个优秀的直观的 diff 算法需要满足的条件吗?先删除后增加(向右是删除,向下是增加)

而我们通过(1,0)到达(2,2)正是先向右后向下即先删除后增加,比通过(0,1)的先增加后删除是一种更好的解。

image-20220430190413198.png

因此,目前移动的步骤标记变更如下,记住,我们只保留每一步的最佳结果:

0,0 --- 1,0 --- 3,1
 |       |
 |       |
0,1     2,2
 |
 |
2,4
第三步

接下来,就到了比较关键的第三步了。第三步的起始位置有三个,分别是(2,4),(2,2)和(3,1)

image-20220501190904183.png

  • 从(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)

image-20220502135807695.png

同样思路,先进行两次插入,然后再进行一次删除(向下两次,然后向右),得到(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 的最佳路径,也就是

image-20220423134446785.png

核心原理

我们已经了解了图搜索是如何进行的,那么 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度还原回去再看看就明白了

到目前为止,是不是感觉每一个节点的最优坐标判定还是很复杂

image-20220502154005340.png

所以我们需要把整个过程按照如下步骤再简化一下:

  1. 我们存了 k 的值和 x 的值,就不需要存储 y 的值了,因为可以通过 k = x -y 计算出来
  2. 我们不需要存储每一步的移动方向,只需要保存每一步的最佳 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的,点个赞再走吧!祝大家升职加薪!

参考

理论基础部分

数据结构与算法之美 - 王争 - 动态规划理论:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题

实例演示部分

70. 爬楼梯 - 力扣(LeetCode)

算法图解-巴尔加瓦-动态规划-背包问题

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

myers-diff/main.go at master · cj1128/myers-diff

jsdiff/base.js at master · kpdecker/jsdiff