【算法-LeetCode】1143. 最长公共子序列(动态规划;滚动数组;通用的空间优化)

1143. 最长公共子序列 - 力扣(LeetCode)

发布:2021年9月25日23:15:29

问题描述及示例

给定两个字符串 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 。

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-common-subsequence
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

提示:
1 <= text1.length, text2.length <= 1000
text1 和 text2 仅由小写英文字符组成。

我的题解(动态规划)

有关动态规划的思路总结,之前写过一个相关的题解博客:

参考:【算法-LeetCode】53. 最大子序和(动态规划初体验)_赖念安的博客-CSDN博客

里面也有我写的其他动态规划题解,一些通用的步骤都是一样的,也可以作为参考,进行对照思考。

而本题的思路和我之前做的一个动态规划题目非常像:

参考:【算法-LeetCode】718. 最长重复子数组(动态规划)_赖念安的博客-CSDN博客

这两者唯一不同的地方就在于上面那题是要求的公共子数组要求必须是连续的,而本题中的公共子序列则不要求必须连续。而这一点不同则体现在两者状态转移方程是否需要顾及 text1[i]text2[j] 不相等的情况

根据分析,结论就是本题的状态转移方程需要考虑上面所说的不相等的情况,而【LeetCode718.最长重复子数组】则不需要考虑。

我的题解1(二维dp数组)

①先确定 dp[i][j] 的含义。根据之前总结的动态规划的总体思路,本题的 dp[i][j] 的含义和【LeetCode718.最长重复子数组】中 dp[i][j] 的含义基本一样:dp[i][j] 代表 text1[0]~text1[i]text2[0]~text2[j] 的最长公共子序列的长度。

②然后状态转移方程。本题的状态转移方程和【LeetCode718.最长重复子数组】的基本一样,但是需要加一种情况:

// 【LeetCode718.最长重复子数组】的状态转移方程
if(nums1[i-1] === nums2[j-1]) {
  dp[i][j] = dp[i-1][j-1] + 1;
  // 每次dp数组更新之后都要同时更新result的值
  result = Math.max(result, dp[i][j]);
}

// 本题的状态转移方程
if(text1[i-1] === text2[j-1]) {
  dp[i][j] = dp[i-1][j-1] + 1;
} else {
  // 如果text1[i-1]不等于text2[j-1],那么就去两者中较大的那个值作为dp[i][j]的值
  dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
}

可以看到,本题的状态转移方程需要考虑 text1[i-1] !== text2[j-1] 的情况。除此之外,两者的状态转移方程的基本推导过程都是大差不差的,这里就不再赘述了。

③之后就是确定如何初始化 dp 数组了,根据状态转移方程,我们可以很容易地知道需要初始化二维 dp 数组的第一行和第一列(因为用到了 i-1j-1 的下标)。但是和【LeetCode718.最长重复子数组】同理,我们可以人为地在 dp 数组的第一行前增加一行并在第一列前增加一列,同时将这增加的一行一列的元素都填充为 0

因为填充为 0 恰好可以满足所有的情况,这样就不同再特意对 dp 数组的某一行和某一列特意做初始化了,如果想研究其中的原理,可以自己根据状态转移方程手动填充一下 dp 数组,这样应该会有更深的理解。

④遍历顺序就很好理解了。对 text1text2 都是由前到后的顺序(如果体现在 dp 数组的填充过程的话,就是由前到后且由上到下地填充)。

下图是我手动填充的一个 dp 数组:

在这里插入图片描述

我手动填充的一个dp数组,注意输入的参数和示例1是有顺序差别的

/**
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
var longestCommonSubsequence = function(text1, text2) {
  // 创建一个二维dp数组,注意它的长宽也可以反过来,但是相应地,下面遍历的时候也要调换位置
  // 注意,我给dp数组多加了一行和一列,这是为了方便后面动态转移方程的计算,
  // 给每个dp元素都填充为0,其中第一行和第一列填充为0的作用是初始化dp数组,
  // 而其他的地方则是顺便被填充为0了,其实这些地方被填充为其他值也可以,因为最终都会被覆盖
  let dp = Array.from({length: text1.length+1}).map(
    () => Array.from({length: text2.length+1}).fill(0)
  );
  // 开始遍历两个传入的字符参数,注意下面的text1和text2的位置是和上面创建dp数组时对应的
  for(let i = 1; i < text1.length+1; i++) {
    for(let j = 1; j < text2.length+1; j++) {
      // 下面这部分是和最长子数组那题唯一不同的地方,注意对照思考
      if(text1[i-1] !== text2[j-1]) {
        dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
      } else {
        dp[i][j] = dp[i-1][j-1] + 1;
      }
    }
  }
  // 最终,dp数组最后的元素(右下角的元素)即为我们想要的结果
  return dp[text1.length][text2.length];
};


提交记录
执行结果:通过
44 / 44 个通过测试用例
执行用时:148 ms, 在所有 JavaScript 提交中击败了10.23%的用户
内存消耗:50.6 MB, 在所有 JavaScript 提交中击败了92.69%的用户
时间:2021/09/26 00:49

关键是要想明白 dp[i][j] 的含义,再注意和重复子数组那题区分开来。

或者说,我们得先想明白怎样定义 dp[i][j] ,才能用上动态规划的思路,我觉得这才是所有动态规划类型的题目的关键所在。

我的题解2(空间优化)

可以看到,上面的二维 dp 数组的解法的时间表现不是很好(其实我比较纳闷,按理来说不是应该空间表现不好的吗?毕竟用了一个额外的二维数组做辅助),但还是有优化空间的,不过是针对空间方面的优化。

按理来说,二维 dp 数组都可以通过“滚动数组”的方式化为一维数组,那样就可以大大节省空间消耗。但是本题中似乎不能像之前那样简单地利用“滚动数组”的思想来进行空间优化,因为在状态转移方程中用到了 dp[i-1][j-1],而如果利用“滚动数组”的话,这个值往往会被前一次 dp 元素的计算结果所覆盖,所以不大方便。

在这里插入图片描述

dp[i][j]的两种由来

于是我就想着用一个变量 leftTop 来存储当前的 dp[i][j] 的左上角的元素(也就是上图中的那个蓝色单元格的 dp[i-1][j-1])。于是就写出了下面的程序:

注意,我们每次更新这个 leftTop 变量的值其实都是为了服务于下一个 dp[i][j] 的计算过程。而为什么要用 temp 变量?其实是因为其中一个状态转移方程中用到了 leftTop,所以不能简单地在状态转移方程改变 dp[j] 前直接进行 leftTop = dp[j] 的赋值操作。

成功前的尝试
/**
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
var longestCommonSubsequence = function(text1, text2) {
  // 创建dp数组,其长度可以和text1相同,也能和text2相同,只要和下面双层for循环中内部的
  // 那个for循环的结束条件相对应即可。当然,所有dp元素还是初始化为0
  let dp = Array.from({length: text2.length+1}).fill(0);
  // leftTop用于相当于是二维dp数组中,dp[i][j]的左上角的元素,其初始值为dp[0]
  let leftTop = dp[0];
  // temp是一个用于辅助中转的临时变量
  let temp = 0;
  // 遍历参数字符串的操作还是不变
  for(let i = 1; i < text1.length+1; i++) {
    for(let j = 1; j < text2.length+1; j++) {
      // 在修改dp[j]前,先把dp[j]的值存到临时变量中
      temp = dp[j];
      // 状态转移方程的思路还是一样,只不过由操作二维dp数组变为了操作一维dp数组
      if(text1[i-1] !== text2[j-1]) {
        dp[j] = Math.max(dp[j], dp[j-1]);
      } else {
        dp[j] = leftTop + 1;
      }
      // dp[j]修改完成后,再将temp暂存的值赋给leftTop
      leftTop = temp;
    }
  }
  // 最后返回dp数组的最后一个元素即可
  return dp[text2.length];
};


提交记录
执行结果:解答错误
通过测试用例:25 / 44
输入:"mhunuzqrkzsnidwbun","szulspmhwpazoxijwbq"
输出:8
预期结果:6
时间:2021/09/26 01:57

我以为上面的思路是没问题的,但是现实是无法通过所有用例……

具体原因我暂时还没有找到,可能得观察调试过程之后才能分析出来吧。这里就不再深究了,因为后面我又想到一种优化空间的方案。

通用的空间优化

因为上面的方案失败了,所以我想到了这种方案。总体的原理其实也是“滚动数组”的应用。而且这种方案也是用到了二维的 dp 数组,只不过这种思路中,dp 数组的宽度始终为 2,而其长度则是任选 text1text2 其中一个的长度即可,只要注意之后的 for 循环遍历顺序的对应关系即可。

那么这种方案是怎么利用滚动数组的呢?其实就是让 dp 数组的第二行来完成上面那种一维 dp 数组方案中的所有计算,每次计算完第二行 dp 后,就将第二行的所有数据复制到 dp 数组的第一行,然后再重复利用 dp 数组的第二行来进行下一轮的遍历。所以说, dp 数组的第二行就是“被滚动”的那一行。

/**
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
var longestCommonSubsequence = function(text1, text2) {
  // 创建一个dp数组,其宽度始终为2,长度则是在text1和text2的长度中任意选一个,
  // 还是和之前样注意和下面for循环的对应关系即可,当然,dp数组的每一个元素还是初始化为0
  let dp = Array.from({length: 2}).map(
    () => Array.from({length: text2.length+1}).fill(0)
  );
  // 还是像之前那样遍历两个字符串参数
  for(let i = 1; i < text1.length+1; i++) {
    for(let j = 1; j < text2.length+1; j++) {
      // 动态转移方程的逻辑也没变
      if(text1[i-1] !== text2[j-1]) {
        // 下面dp[1][j]和dp[1][j-1]中的1相当于是
        // 最开始没有空间优化时的二维dp中的dp[i][j]和dp[i][j-1]
        dp[1][j] = Math.max(dp[0][j], dp[1][j-1]);
      } else {
        // 而下面的dp[0][j-1]则可以看做是dp[i-1][j-1]
        dp[1][j] = dp[0][j-1] + 1;
      }
    }
    // 计算完本行的所有dp元素后,进行最关键的滚动操作:把第二行的元素复制给第一行
    // 注意这里用的是扩展运算符合解构赋值的方式进行深拷贝,而不能简单地写作
    // dp[0] = dp[1],因为dp[0]和dp[1]都是数组对象(引用数据类型),而非基本数据类型
    dp[0] = [...dp[1]];
  }
  // 最后返回dp数组第二行的最后一个元素
  return dp[1][text2.length];
};


提交记录
执行结果:通过
44 / 44 个通过测试用例
执行用时:92 ms, 在所有 JavaScript 提交中击败了92.69%的用户
内存消耗:43.4 MB, 在所有 JavaScript 提交中击败了95.48%的用户
时间:2021/09/26 02:11

可以看到这种方案的性能有了比较大的提升,虽然我比较奇怪为什么提升的是时间性能而不是预期中的空间性能……

而这种利用两行 dp 数组的方法进行空间优化的思路其实可以适用于所有需要由前到后且由上到下填充 dp 数组的动态规划题目

在这里插入图片描述

什么情况下可以用两行dp数组的空间优化方案

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

更新:2021年9月25日23:23:29

参考:最长公共子序列 - 最长公共子序列 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年9月22日14:08:16
参考:【算法-LeetCode】53. 最大子序和(动态规划初体验)_赖念安的博客-CSDN博客
更新:2021年9月25日23:21:46
参考:【算法-LeetCode】718. 最长重复子数组(动态规划)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】322. 零钱兑换(动态规划;博采众长的完全背包问题详解;二维数组;滚动数组)_赖念安的博客-CSDN博客

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值