动态规划之子序列问题

子序列问题是动态规划中一系列常见的问题,有多个种类,这里对动态规划的子序列问题进行汇总。

一、动态规划之子序列问题

根据leetcode题库,这里把动态规划的子序列问题汇总为以下几种:

  1. 子序列(不连续)
  2. 子序列(连续)
  3. 编辑距离
  4. 回文串

二、leetcode例题讲解子序列问题

1. 子序列(不连续)
300. 最长递增子序列

leetcode题目链接:300. 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例一:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4

示例二:

输入:nums = [7,7,7,7,7,7,7]
输出:1

解题思路:

最长递增子序列动规的经典题目,这里dp[i]是可以根据dp[j] (j < i)推导出来的,那么依然用动规五部曲来分析。

1.确定dp数组(dp table)以及下标的含义

dp[i]表示 i 之前包括 i 的最长上升子序列的长度。

2.确定递推公式

位置 i 的最长递增子序列等于 j 从 0 到 i-1 各个位置的最长升序子序列 + 1 的最大值。

所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);

3.dp数组如何初始化

每一个i,对应的dp[i](即最长递增子序列)起始大小至少都是是1。

4.确定遍历顺序

dp[i] 是有0到i-1各个位置的最长递增子序列 推导而来,那么遍历i一定是从前向后遍历。

j其实就是0到i-1,遍历i的循环里外层,遍历j则在内层,

5.举例推导DP数组

省略。

Java实现代码:

class Solution {
    public int lengthOfLIS(int[] nums) {

        // 动态规划
        int[] dp = new int[nums.length];
        Arrays.fill(dp, 1);
        for(int i = 0; i < dp.length; i++){
            for(int j = 0; j < i; j++){
                if(nums[i] - nums[j] > 0){
                    dp[i] = Math.max(dp[i], dp[j]+1);
                }
            }
        }
        int res = 0;
        for(int i = 0; i < dp.length; i++){
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

注:优化的话,可以使用二分查找的方法降低时间复杂度。

class Solution {
    public int lengthOfLIS(int[] nums) {

        int[] dp = new int[nums.length];
        // 动态规划 + 二分查找
        Arrays.fill(dp, Integer.MAX_VALUE);
        int res = 0;
        for(int num : nums){
            int i = 0, j = nums.length-1;
            while(i <= j){
                int mid = (j - i)/2 + i;
                if(dp[mid] < num) i = mid + 1;
                else j = mid - 1;
            }
            dp[i] = num;
            if(dp[res] != Integer.MAX_VALUE) res++;
        }
        return res;

    }
}
1143. 最长公共子序列

leetcode题目链接:1143. 最长公共子序列

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

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

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

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

示例一:

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

示例二:

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

解题思路:

最长公共子序列动规的经典题目,要求要相对顺序,即:“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。

1.确定dp数组(dp table)以及下标的含义

dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]。

2.确定递推公式

主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同text1[i - 1] 与 text2[j - 1]不相同

如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;

如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。

即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);

3.dp数组如何初始化

test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i][0] = 0;

同理dp[0][j]=0

其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。

4.确定遍历顺序

要从前向后,从上到下来遍历这个矩阵。

5.举例推导DP数组

省略。

Java实现代码:

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        // dp[i][j]表示text1[0~i-1]与text2[0~j-1]的最大公共子序列长度
        int[][] dp = new int[text1.length()+1][text2.length()+1];
        // 这个初始化包含在new里面,可以省略
        for (int i = 0; i <= text1.length(); i++) {
           dp[i][0] = 0;
        }
        for (int j = 0; j <= text2.length(); j++) {
           dp[0][j] = 0;
        }
        // 如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;
        // 如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的
        for(int i = 1; i <= text1.length(); i++){
            for(int j = 1; j <= text2.length(); j++){
                if(text1.charAt(i-1) == text2.charAt(j-1)){
                    dp[i][j] = dp[i-1][j-1] + 1;
                }else{
                    dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
                }
            }
        }
        return dp[text1.length()][text2.length()];
    }
}
1035. 不相交的线

leetcode题目链接:1035. 不相交的线

在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。

现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:

  1. nums1[i] == nums2[j]
  2. 且绘制的直线不与任何其他连线(非水平线)相交。

请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。

以这种方法绘制线条,并返回可以绘制的最大连线数。

示例1:

在这里插入图片描述

输入:nums1 = [1,4,2], nums2 = [1,2,4]
输出:2
解释:可以画出两条不交叉的线,如上图所示。 
但无法画出第三条不相交的直线,因为从 nums1[1]=4 到 nums2[2]=4 的直线将与从 nums1[2]=2 到 nums2[1]=2 的直线相交。

示例二:

输入:nums1 = [2,5,1,2,5], nums2 = [10,5,2,1,5,2]
输出:3

解题思路

绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且直线不能相交

直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。

在这里插入图片描述
其实也就是说A和B的最长公共子序列是[1,4],长度为2。 这个公共子序列指的是相对顺序不变(即数字4在字符串A中数字1的后面,那么数字4也应该在字符串B数字1的后面)

本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度

1143. 最长公共子序列一模一样。

Java代码实现:

class Solution {
    public int maxUncrossedLines(int[] nums1, int[] nums2) {
        int[][] dp = new int[nums1.length+1][nums2.length+1];
        for(int i = 1; i <= nums1.length; i++){
            for(int j = 1; j <= nums2.length; j++){
                if(nums1[i-1] == nums2[j-1]){
                    dp[i][j] = dp[i-1][j-1] + 1;
                }else{
                    dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
                }
            }
        }
        return dp[nums1.length][nums2.length];
    }
}
2. 子序列(连续)
674. 最长连续递增序列

leetcode题目链接:674. 最长连续递增序列

给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。

连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], …, nums[r - 1], nums[r]] 就是连续递增子序列。

示例一:

输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 57 在原数组里被 4 隔开。 

示例二:

输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1

解题思路

本题相对于300. 最长递增子序列,最大的区别在于“连续”。

1.确定dp数组(dp table)以及下标的含义

dp[i]:以下标i为结尾的数组的连续递增的子序列长度为dp[i]。

2.确定递推公式

如果 nums[i ] > nums[i - 1],那么以 i 为结尾的数组的连续递增的子序列长度 一定等于 以 i-1 为结尾的数组的连续递增的子序列长度 + 1 。

即:dp[i] = dp[i-1] + 1;

因为本题要求连续递增子序列,所以就必要比较nums[i]与nums[i-1],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)。

既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i]与nums[i-1]。

3.dp数组如何初始化

以下标i为结尾的数组的连续递增的子序列长度最少也应该是1,即就是nums[i]这一个元素。

所以dp[i]应该初始1。

4.确定遍历顺序

从递推公式上可以看出, dp[i]依赖dp[i-1],所以一定是从前向后遍历

5.举例推导DP数组

省略。

Java实现代码:

class Solution {
    public int findLengthOfLCIS(int[] nums) {
        int[] dp = new int[nums.length];
        Arrays.fill(dp, 1);
        int res = 1;
        for(int i = 1; i < nums.length; i++){
            if(nums[i] > nums[i-1]){
                dp[i] = dp[i-1] + 1;
            }
            if(dp[i] > res) res = dp[i];
        }
        return res;
    }
}
718. 最长重复子数组

leetcode题目链接:718. 最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

示例一:

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1]

解题思路

注意题目中说的子数组,其实就是连续子序列。

1.确定dp数组(dp table)以及下标的含义

dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。

2.确定递推公式

根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。

即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1;

根据递推公式可以看出,遍历i 和 j 要从1开始!

3.dp数组如何初始化

根据dp[i][j]的定义,dp[i][0] 和dp[0][j]其实都是没有意义的!

但dp[i][0] 和dp[0][j]要初始值,因为 为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1;

所以dp[i][0] 和dp[0][j]初始化为0。

4.确定遍历顺序

外层for循环遍历A,内层for循环遍历B。

反之也可。

5.举例推导DP数组

省略。

Java实现代码:

class Solution {
    public int findLength(int[] nums1, int[] nums2) {
        int[][] dp = new int[nums1.length + 1][nums2.length + 1];
        int res = 0;
        for(int i = 1; i <= nums1.length; i++){
            for(int j = 1; j <= nums2.length; j++){
                if(nums1[i-1] == nums2[j-1]){
                    dp[i][j] = dp[i-1][j-1] + 1;
                    res = Math.max(res, dp[i][j]);
                }
            }
        }
        return res;
    }
}

一维滚动数组:

dp[i][j]都是由dp[i - 1][j - 1]推出。那么压缩为一维数组,也就是dp[j]都是由dp[j - 1]推出。

也就是相当于可以把上一层dp[i - 1][j]拷贝到下一层dp[i][j]来继续用。

此时遍历B数组的时候,就要从后向前遍历,这样避免重复覆盖。

class Solution {
    public int findLength(int[] nums1, int[] nums2) {

        // 一维滚动数组
        int[] dp = new int[nums2.length + 1];
        int res = 0;

        for (int i = 1; i <= nums1.length; i++) {
            for (int j = nums2.length; j > 0; j--) {
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[j] = dp[j - 1] + 1;
                } else {
                    dp[j] = 0;
                }
                res = Math.max(res, dp[j]);
            }
        }
        return res;
    }
}
53. 最大子序和

leetcode题目链接:53. 最大子序和

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例一:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6

示例二:

输入:nums = [1]
输出:1

解题思路:

1.确定dp数组(dp table)以及下标的含义

dp[i]:包括下标i之前的最大连续子序列和为dp[i]。

2.确定递推公式

dp[i]只有两个方向可以推出来:

  1. dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
  2. nums[i],即:从头开始计算当前连续子序列和

一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);

3.dp数组如何初始化

从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。

dp[0]因为为nums[0]即dp[0] = nums[0]

4.确定遍历顺序

递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int maxSubArray(int[] nums) {
        // dp[i] 表示以i结尾的连续子数组的最大和
        // dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); 继续加入nums[i]还是从头计算
        int n = nums.length;
        int res = nums[0];
        int[] dp = new int[n + 1];
        dp[0] = nums[0];
        for (int i = 1; i < n; i++) {
            dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); 
            res = Math.max(dp[i], res);
        }
        return res;
    }
}

存储空间优化,在原数组上修改:

class Solution {
    public int maxSubArray(int[] nums) {
        int res = nums[0]; //存储和的最大值
        for(int i = 1; i < nums.length; i++){
            nums[i] += Math.max(nums[i-1], 0);
            res = Math.max(res, nums[i]);
        }
        return res;
    }
}
3. 编辑距离
392. 判断子序列

leetcode题目链接:392. 判断子序列

给定字符串 s 和 t ,判断 s 是否为 t 的子序列。

字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。

进阶:

如果有大量输入的 S,称作 S1, S2, … , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?

示例一:

输入:s = "abc", t = "ahbgdc"
输出:true

示例二:

输入:s = "axc", t = "ahbgdc"
输出:false

解题思路:

1.确定dp数组(dp table)以及下标的含义

dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。

注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。

2.确定递推公式

在确定递推公式的时候,首先要考虑如下两种操作,整理如下:

  1. if (s[i - 1] == t[j - 1])
    t中找到了一个字符在s中也出现了
  2. if (s[i - 1] != t[j - 1])
    相当于t要删除元素,继续匹配

if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1

if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];

3.dp数组如何初始化

dp[0][0]和dp[i][0]是一定要初始化的。

这里dp[i][0]和dp[0][j]是没有含义的,仅仅是为了给递推公式做前期铺垫,所以初始化为0。

4.确定遍历顺序

从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public boolean isSubsequence(String s, String t) {
        // 动态规划实现
        int length1 = s.length(); int length2 = t.length();
        int[][] dp = new int[length1+1][length2+1];
        for(int i = 1; i <= length1; i++){
            for(int j = 1; j <= length2; j++){
                if(s.charAt(i-1) == t.charAt(j-1)){
                    dp[i][j] = dp[i-1][j-1] + 1;
                }else{
                    dp[i][j] = dp[i][j-1];
                }
            }
        }
        if(dp[length1][length2] == length1){
            return true;
        }else{
            return false;
        }
    }
}

下面是其它方法的实现:

双指针实现:

class Solution {
    public boolean isSubsequence(String s, String t) {
        // 双指针,从前往后找,找到就i++,
        int n = s.length(), m = t.length();
        int i = 0, j = 0;
        while (i < n && j < m) {
            if (s.charAt(i) == t.charAt(j)) {
                i++;
            }
            j++;
        }
        return i == n;
    }
}

字符串函数操作实现:

class Solution {
    public boolean isSubsequence(String s, String t) {
        // indexOf(c) 返回第一次出现的指定子字符串在此字符串中的索引
        // indexOf(c, index) 从指定的索引处开始,返回第一次出现的指定子字符串在此字符串中的索引
        // 若找不到,返回-1
        int index = -1;
        for (char c : s.toCharArray()){
            index = t.indexOf(c, index+1); // 从下标index+1开始在t中找到该字符,然后记录该位置,下次从下一个位置开始找
            if (index == -1) return false;
        }
        return true;
    }
}
115. 不同的子序列

leetcode题目链接:115. 不同的子序列

给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。

字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,“ACE” 是 “ABCDE” 的一个子序列,而 “AEC” 不是)

示例一:

输入:s = "rabbbit", t = "rabbit"
输出:3
解释:
如下图所示,3 种可以从 s 中得到 "rabbit" 的方案。

在这里插入图片描述

示例二:

输入:s = "babgbag", t = "bag"
输出:5
解释:
如下图所示,5 种可以从 s 中得到 "bag" 的方案。

在这里插入图片描述

解题思路:

1.确定dp数组(dp table)以及下标的含义

dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。

2.确定递推公式

这一类问题,基本是要分析两种情况

s[i - 1] 与 t[j - 1]相等
s[i - 1] 与 t[j - 1] 不相等

当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。

一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]

一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]

为什么还要考虑 不用s[i - 1]来匹配,都相同了指定要匹配啊。

例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];

当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配,即:dp[i - 1][j]

所以递推公式为:dp[i][j] = dp[i - 1][j];

3.dp数组如何初始化

从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][0] 和dp[0][j]是一定要初始化的。

dp[i][0]表示什么呢?

dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。

那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。

再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。

那么dp[0][j]一定都是0,s如论如何也变成不了t。

最后就要看一个特殊位置了,即:dp[0][0] 应该是多少。

dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

4.确定遍历顺序

从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j]都是根据左上方和正上方推出来的。

所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int numDistinct(String s, String t) {
        int[][] dp = new int[s.length() + 1][t.length() + 1];
        for (int i = 0; i < s.length() + 1; i++) {
            dp[i][0] = 1;
        }    
        for (int i = 1; i < s.length() + 1; i++) {
            for (int j = 1; j < t.length() + 1; j++) {
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                }else{
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }      
        return dp[s.length()][t.length()];
    }
}
583. 两个字符串的删除操作

leetcode题目链接:583. 两个字符串的删除操作

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。

示例一:

输入: "sea", "eat"
输出: 2
解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"

解题思路:

1.确定dp数组(dp table)以及下标的含义

dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。

2.确定递推公式

在确定递推公式的时候,首先要考虑如下两种操作,整理如下:

当word1[i - 1] 与 word2[j - 1]相同的时候
当word1[i - 1] 与 word2[j - 1]不相同的时候

当word1[i - 1] 与 word2[j - 1]相同的时候,dp[i][j] = dp[i - 1][j - 1];

当word1[i - 1] 与 word2[j - 1]不相同的时候,有三种情况:

情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1

情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1

情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2

那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1});

3.dp数组如何初始化

从递推公式中,可以看出来,dp[i][0] 和 dp[0][j]是一定要初始化的。

dp[i][0]:word2为空字符串,以i-1为结尾的字符串word1要删除多少个元素,才能和word2相同呢,很明显dp[i][0] = i

dp[0][j]的话同理dp[0][j] = j

4.确定遍历顺序

从递推公式 dp[i][j] = min(dp[i - 1][j - 1] + 2, min(dp[i - 1][j], dp[i][j - 1]) + 1); 和dp[i][j] = dp[i - 1][j - 1]可以看出dp[i][j]都是根据左上方、正上方、正左方推出来的。

所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int minDistance(String word1, String word2) {
        int len1 = word1.length(), len2 = word2.length();
        int[][] dp = new int[len1+1][len2+1];
        for(int i = 0; i <= len1; i++){
            dp[i][0] = i;
        }
        for(int j = 0; j <= len2; j++){
            dp[0][j] = j;
        }
        for(int i = 1; i <= len1; i++ ){
            for(int j = 1; j <= len2; j++){
                if(word1.charAt(i-1) == word2.charAt(j-1)){
                    dp[i][j] = dp[i-1][j-1];
                }else{
                    dp[i][j] = Math.min(dp[i][j-1]+1, Math.min(dp[i-1][j]+1, dp[i-1][j-1]+2));
                }
            }
        }
        return dp[len1][len2];
    }
}
72. 编辑距离

leetcode题目链接:72. 编辑距离

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

示例一:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse ('h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例二:

输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention ('i' 替换为 'e')
enention -> exention ('n' 替换为 'x')
exention -> exection ('n' 替换为 'c')
exection -> execution (插入 'u')

解题思路:

1.确定dp数组(dp table)以及下标的含义

dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。

2.确定递推公式

在确定递推公式的时候,首先要考虑如下四种操作,整理如下:

if (word1[i - 1] == word2[j - 1])
    不操作
if (word1[i - 1] != word2[j - 1])
    增
    删
    换

第一种:不操作:

if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];

第二种,操作:

if (word1[i - 1] != word2[j - 1]),此时就需要编辑了。

操作一:word1删除一个元素,那么就是以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上一个操作。

dp[i][j] = dp[i - 1][j] + 1;

操作二:word2删除一个元素,那么就是以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上一个操作。

dp[i][j] = dp[i][j - 1] + 1;

word2添加一个元素,相当于word1删除一个元素,例如 word1 = “ad” ,word2 = “a”,word1删除元素’d’ 和 word2添加一个元素’d’,变成word1=“a”, word2=“ad”, 最终的操作数是一样!

操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作。

dp[i][j] = dp[i - 1][j - 1] + 1;

综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;

3.dp数组如何初始化

dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。

那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;

同理dp[0][j] = j;

4.确定遍历顺序

在dp矩阵中一定是从左到右从上到下去遍历。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int minDistance(String word1, String word2) {
        // dp[i][j]表示word1的前i个字母转换成word2的前j个字母所使用的最少操作
        // i指向word1,j指向word2, 若当前字母相同,则dp[i][j] = dp[i - 1][j - 1];
        // 否则取增删替三个操作的最小值 + 1, 即: dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1。
        int len1 = word1.length(), len2 = word2.length();
        int[][] dp = new int[len1+1][len2+1];
        for(int i = 0; i <= len1; i++){
            dp[i][0] = i;
        }
        for(int j = 0; j <= len2; j++){
            dp[0][j] = j;
        }
        for(int i = 1; i <= len1; i++ ){
            for(int j = 1; j <= len2; j++){
                if(word1.charAt(i-1) == word2.charAt(j-1)){
                    dp[i][j] = dp[i-1][j-1];
                }else{
                    dp[i][j] = Math.min(dp[i][j-1], Math.min(dp[i-1][j], dp[i-1][j-1])) + 1;
                }
            }
        }
        return dp[len1][len2];
    }
}
4. 回文串
647. 回文子串

leetcode题目链接:647. 回文子串

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。

回文字符串 是正着读和倒过来读一样的字符串。

子字符串 是字符串中的由连续字符组成的一个序列。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

示例一:

输入:s = "abc"
输出:3
解释:三个回文子串: "a", "b", "c"

示例二:

输入:s = "aaa"
输出:6
解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"

解题思路:

1.确定dp数组(dp table)以及下标的含义

布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

2.确定递推公式

在确定递推公式时,就要分析如下几种情况。

整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。

当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。

当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
情况二:下标i 与 j相差为1,例如aa,也是文子串
情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

3.dp数组如何初始化

dp[i][j]初始化为false。

4.确定遍历顺序

首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。

dp[i + 1][j - 1] 在 dp[i][j]的左下角。

如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。

所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的。

有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。下面采用先遍历列后遍历行的做法。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int countSubstrings(String s) {
        // 动态规划,dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串
        // 递推公式,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种,不相等dp[i][j]是false
        // 当s[i]与s[j]相等时:1.下标i 与 j相同,同一个字符例如a 2.下标i 与 j相差为1,例如aa
        // 3. i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,
        // 那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true
        int n = s.length();
        int ans = 0;
        if(s == null || n < 1) return 0;
        //dp[i][j]:s字符串下标i到下标j的字串是否是一个回文串,即s[i, j]
        boolean[][] dp = new boolean[n][n];
        for(int j = 0; j < n; j++){
            for(int i = 0; i <= j; i++){
                //当两端字母一样时,才可以两端收缩进一步判断
                if(s.charAt(i) == s.charAt(j)){
                    if(j - i < 3){ // a, aa, aba
                        dp[i][j] = true;
                    }else{
                        dp[i][j] = dp[i+1][j-1];
                    }
                }else{
                    dp[i][j] = false;
                }
            }
        }
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                if(dp[i][j]) ans++;
            }
        }
        return ans;
    }
}

双指针法(中心扩展法):

首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。

在遍历中心点的时候,要注意中心点有两种情况。

一个元素可以作为中心点,两个元素也可以作为中心点。

那么有人同学问了,三个元素还可以做中心点呢。其实三个元素就可以由一个元素左右添加元素得到,四个元素则可以由两个元素左右添加元素得到。

所以我们在计算的时候,要注意一个元素为中心点和两个元素为中心点的情况。

class Solution {
    public int countSubstrings(String s) {

        // 双指针法,中心扩展法
        // 一个元素作中心点,两个元素也可以作为中心点
        // 长度为 n 的字符串会生成2n−1组回文中心, 从0到 2n−2遍历i,就可以得到所有可能的回文中心,把奇数长度和偶数长度两种情况统一
        int n = s.length();
        int ans = 0;
        if(s == null || n < 1) return 0;
        for (int i = 0; i < 2 * n - 1; ++i) {
            int l = i / 2, r = i / 2 + i % 2;
            while (l >= 0 && r < n && s.charAt(l) == s.charAt(r)) {
                --l;
                ++r;
                ++ans;
            }
        }
        return ans;
    }
}
516. 最长回文子序列

leetcode题目链接:516. 最长回文子序列

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例一:

输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb"

示例二:

输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb"

解题思路:

上一题求的是回文子串,而本题要求的是回文子序列, 要搞清楚这两者之间的区别。

回文子串是要连续的,回文子序列可不是连续的! 回文子串,回文子序列都是动态规划经典题目。

1.确定dp数组(dp table)以及下标的含义

dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]。

2.确定递推公式

在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。

如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;

如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

加入s[j]的回文子序列长度为dp[i + 1][j]

加入s[i]的回文子序列长度为dp[i][j - 1]

那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);

3.dp数组如何初始化

当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。

其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。

4.确定遍历顺序

从递推公式dp[i][j] = dp[i + 1][j - 1] + 2 和 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 可以看出,dp[i][j]是依赖于dp[i + 1][j - 1] 和 dp[i + 1][j],

也就是从矩阵的角度来说,dp[i][j] 下一行的数据。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的。

5.举例推导DP数组

省略。

Java代码实现:

class Solution {
    public int longestPalindromeSubseq(String s) {
        // 动态规划
        // dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度
        // 如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;
        // 如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]
        // dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        int len = s.length();
        int[][] dp = new int[len + 1][len + 1];
        for (int i = len - 1; i >= 0; i--) { // 从后往前遍历 保证情况不漏,因为i是基于i+1的
            dp[i][i] = 1; // 初始化
            for (int j = i + 1; j < len; j++) {
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j],  dp[i][j - 1]);
                }
            }
        }
        return dp[0][len - 1];
    }
}

三、其它算法分析

1. 动态规划之背包问题——01背包

动态规划之背包问题——01背包

2. 动态规划之背包问题——完全背包

动态规划之背包问题——完全背包

3. 动态规划之子序列问题

动态规划之子序列问题

4. 算法分析之数组问题

算法分析之数组问题

5. 算法分析之链表问题

算法分析之链表问题

6. 算法分析之哈希表

算法分析之哈希表

7. 算法分析之字符串

算法分析之字符串

动态规划其它题型总结:

动态规划之背包问题——01背包

动态规划之背包问题——完全背包

动态规划之打家劫舍系列问题

动态规划之股票买卖系列问题

  • 6
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值