深入浅出的二维动态规划算法-最长公共子序列

1,250 阅读6分钟

深入是很深入,出不出的来就不晓得了

正题:二维动态规划算法

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

示例 1:
输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace" ,它的长度为 3 。

示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。

示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。

求最长公共子序列还是比较复杂的,关键点在于子序列未必需要连续,非连续的字符也可以组成子序列。如果求公共子字符串,那么使用暴力枚举很轻松就能解决,可如果是子序列排列组合的情况就太多了,很不适合使用暴力枚举的方法。

任何根据上一步状态来判断下一步执行状态的情况一般优先考虑到动态规划去解决问题,但是两个数组的动态规划要如何去实现,这就涉及到了动态规划的衍生:二维动态规划

首先看一下官方给出的二维动态规划的解析:

image.png

image.png

就很难理解是不是,那么我们可以围绕这样的一个方案进行解析,然后去了解一下二维动态规划问题。

首先开头

假设字符串 text1 和 text2 的长度 为 m,n,创建 m+1 行 n+1 列的二维数组

创建二维数组很好理解,为的是内外循环遍历找到对应的每一种情况,但是为什么要是 m + 1n + 1 行的空间数组呢?这个可以放一边,暂时不去考虑为什么,因为如果我第一步去想的话,一定是创建 m 和 n 行列个数的数组的,或许在后面的计算中遇到了一些问题,才会发现 实际上 m + 1n + 1 行列更为合适。在没有遇到这个问题之前,我们默认 mn 列。

那么可以得出这样的一个矩阵 dpList[]:

image.png

行和列的下标分别为 i 和 j, dpList[i][j] 用来表示 text1 中第 i 个字符 和 text 2 中第 j 个字符,可能组成的最大公共子序列是多长 ,下面考虑如何动态填入这些数字。

当 i = 0 , j = 0 的时候, text1 和 text2 分别拿到的只有当前字符串的首字母,ab,仅仅两个字符能组成最大公共子序列,要么就是1(两个字符相同时),要么就是 0,两个字符不同时, ab不同,那么可以组成 0个最大公共子序列,所以 dpList[0][0] 填写 0. 如图:

image.png

当 i = 0 , j = 1 的时候, text1 第 i 个字符 a , text2 第 j 个字符 d, ad 并不相同,也就是说从根本上他们就不可能组成公共子序列,所以 dpList[0][1]填写0,第一行以此类推, j = 2时, dpList[0][2] 填写 0.

image.png

当 i = 0,j = 3, 分别取出 aa,两个字符相同,那么 text1 和 text2 到目前为止 可以组成的最大公共子序列就是 a,长度为 1

当 i = 0, j = 4, 分别取出 ab,两字符不同,难道就填写 0 吗,很显然不是的,因为你只考虑了当前 ab两个字符,我们所填入的数是考虑 到 i 和 j 节点最大长度,也就是说,必须包含之前的长度,这么表达可能不太容易理解,换一种解释就是 当 i = 0 , j = 4 的时候我们考虑的 不是 ab 单独两个字符是否是公共字符,而是[a][b,d,c,a,b] 组成的最大公共字符串,即包含了 ab 前面所有字符所组成的最长公共字符串。

那么这个应该怎么看,可以发现,[a][b,d,c,a,b] 仅有一个 a 这一个公共字符串,并且这个情况在上一步就已经算进去了,那么如果加入了 b 之之后没有组成新的公共字符串,那么还保持上一步的长度不变!

新的问题又来了,所谓 “上一步” 长度是什么?答案是: i 和 j 都 -1 的那个 dpList对应的数值的最大值!即:

dpList[i-1][j-1],你需要去考虑 i - 1 和 j - 1 两者最长子序列中最大的一项,作为当前最常子序列最大值。 如果你觉得理解很困难,不妨举一个例子去想一下。

所以就得出了 题解中这个方程组:

image.png

但是只要存在 i -1j - 1 这样的下标减法出现,势必会出现边界情况,即 i = 0j = 0的时候,总不能出现下标为 -1 的情况吧。为了避免这样的情况,就出现了题解中 行列各 + 1 的想法了。

image.png

这就是为什么一开始所说的 为什么要是 m + 1 和 n + 1 行的空间数组呢,因为这样dpList真正计算就可以从1开始,最外层单个字符必不会是公共子序列所以都是 0,很巧妙。

下面是动态规划图的补充过程:

ScreenRecorderProject19.gif

后面大家可以以此类推,最终会得到和题解非常相近的动态规划图。

代码实现: 由于我们没有创建 m + 1n + 1 的行列,所以需要考虑边界情况,即 i 和 j 分别等于 1 的情况。

var longestCommonSubsequence = function (text1, text2) {
  const l1 = text1.length
  const l2 = text2.length
  const dpList = []
  for (let index = 0; index < l1; index++) {
    dpList[index] = []
    for (let j = 0; j < l2; j++) {
      dpList[index].push(0)
    }
  }

  for (let i = 0; i < l1; i++) {
    for (let j = 0; j < l2; j++) {
      if (text1[i] === text2[j]) {
        dpList[i][j] = (i > 0 && j > 0) ? dpList[i - 1][j - 1] + 1 : 1
      } else {
        if (i > 0 && j > 0) {
          dpList[i][j] = Math.max(dpList[i - 1][j], dpList[i][j - 1])
        } else if (i > 0) {
          dpList[i][j] = dpList[i - 1][j]
        } else if (j > 0) {
          dpList[i][j] = dpList[i][j - 1]
        } else {
          dpList[i][j] = 0
        }
      }
    }
  }
  return dpList[l1 - 1][l2 - 1]
};

至此一个二维动态规划的案例就解完了,说实话二维动态规划比较抽象,每一个节点的状态比较难以定义,导致了比较难以理解。其实上面说这么多也未必说的到点子上,但能力有限,表达能力更有限。。高战者请指导,类我者请意会!只有多做类似的算法问题才能掌握到里面的关键。

推荐类似使用二维动态规划解决的算法问题: 求解最大正方形矩阵最小路径和

多学多练,准没错!