Leetcode刷题总结 —— 子序列和子串问题

731 阅读4分钟

求子序列和子串是特别经典的一系列题型,而解题方法也有很多,比如贪心算法。
虽然贪心算法思想比较巧妙,而且代码也相对简洁,但是有时比较依赖灵光乍现,很容易“贪不出来”。
因此,这里只对更为套路化的动态规划法的基本题做一个总结,难度较高的暂且略过。

1. 最长递增子序列

例题: 最长递增子序列

动态规划法主要是要弄清楚四个点:

  • 定义合适的dp数组
  • 寻找递推关系
  • 赋予初始值
  • 确定递推顺序

这里将dp数组定义为:dp[i]表示以nums[i]为结尾的最长递增子序列。

递推关系可以这么考虑:
要求以nums[i]为结尾的最长递增子序列的值,可以遍历i之前的索引j,如果nums[j]小于nums[i],则nums[i]就可以放在以nums[j]为结尾的最长递增子序列的最后,也就是说dp[i]就等于dp[j] + 1。遍历完成后,找到所有dp[j] + 1的最大值就可以得到dp[i]的最终值。最后整个dp数组中的最大值,就是最长递增子序列的值。

初始值比较好理解,初始的dp数组中的每一位肯定都是 1,因为最长递增子序列至少会包含一个数字。

外层循环是从小到大,内层循环由于是求最大值,所以两种遍历方向都是可以的。

/**
 * 求最长递增子序列
 */
public int getLongestIncrementSubsequence(int[] nums) {
    // dp[i]表示以nums[i]为结尾的最长递增子序列
    int[] dp = new int[nums.length];
    // 初始化dp数组和max为1
    Arrays.fill(dp, 1);
    int max = 1;

    for (int i = 0; i < nums.length; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                // 求所有`dp[j] + 1`的最大值
                dp[i] = Math.max(dp[i], dp[j] + 1);
                max = Math.max(dp[i], max);
            }
        }
    }
    return max;
}

动态规划算法的时间复杂度是O(n^2),因为有两层循环;
空间复杂度是O(n),即dp数组。

2. 最长递增子串

例题:最长连续递增序列

最长连续递增序列其实就是最长递增子串。

动态规划法还是那四点:

  • 定义合适的dp数组
  • 寻找递推关系
  • 赋予初始值
  • 确定递推顺序

dp数组定义为:dp[i]表示以nums[i]为结尾的最长递增子串。

很好理解,如果 nums[i] > nums[i - 1],那么以nums[i]为结尾的最长递增子串的值就等于以nums[i-1]为结尾的最长递增子串的值 + 1。

初始的dp数组中的每一位肯定也都是 1,因为最长递增子串至少会包含一个数字。

这里只有一层循环,从小到大即可:

public int findLengthOfLCIS(int[] nums) {
    // dp[i]表示以i为结尾的最长递增子串的长度
    int[] dp = new int[nums.length];
    Arrays.fill(dp, 1);

    int max = 1;
    for (int i = 1; i <= nums.length; i++) {
        if (nums[i] > nums[i - 1]) {
            dp[i] = Math.max(dp[i], dp[i - 1] + 1);
        }
        max = Math.max(max, dp[i]);
    }
    return max;
}

动态规划算法的时间复杂度是O(n),因为有一层循环;
空间复杂度是O(n),即dp数组。

3. 最长公共子序列

例题:最长公共子序列

a. 定义合适的dp数组
这里定义dp[i][j]text1中前i个字符和text2中前j个字符中的最长公共子序列。

为啥i表示前i个数,即[0, i-1],而不是表示[0, i]呢?

其实我们也可以定义为[0, i],但是就需要额外进行初始化操作。这个我们在下边说。

b. 寻找递推关系
我们用char1表示text1中当前字符,char2表示text2中当前字符,容易得到递推关系:
当char1 == char2时,dp[i][j] = dp[i-1][j-1] + 1;
当char1 != char2时,dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])

c. 赋予初始值
按照当前的dp定义,即i表示[0, i-1],dp[0][0]、dp[0][1]以及dp[1][0]的初始值都为0即可;
如果把dp定义为i表示[0, i],那么初始化时就需要额外判断。例如对于dp[0][0],如果char1 == char2,dp[0][0]就等于 1,如果char1 != char2,dp[0][0]就等于 0。

d. 确定递推顺序
两层循环都按照从小到大即可,值得注意的是,dp中的索引和text中的索引相差1,判断字符时应当注意-1。

public int longestCommonSubsequence(String text1, String text2) {
    // dp[i][j]表示text1中的前i个数和text2中的前j个数中的最长子序列
    int[][] dp = new int[text1.length() + 1][text2.length() + 1];

    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()];
}

时间复杂度为O(n^2),空间复杂度为O(n^2).

4. 最长公共子串

例题:最长重复子数组

a. 定义合适的dp数组
这里定义dp[i][j]nums1中以i-1为结尾的数组和nums2中以j-1为结尾的数组中的最长公共子串。

这里-1的理由和上边求最长公共子序列是一样的。但是定义不一样了,这里必须是以i-1和以j-1为结尾的。
其实也很好理解,因为子串是必须连续的,如果dp[i][j]中不是以i-1和以j-1为结尾的值,那递推出来的结果肯定是错的。

b. 寻找递推关系
我们用num1表示nums1中当前数字,num2表示nums2中当前数字,容易得到递推关系:
当nums1 == nums2时,dp[i][j] = dp[i-1][j-1] + 1;
当nums1 != nums2时,dp[i][j] = 0。(注意这里和公共子序列中的不同)

c. 赋予初始值
按照当前的dp定义,dp[0][0]为0即可。

d. 确定递推顺序
两层循环都按照从小到大即可,同样,dp中的索引和nums中的索引相差1,判断数字时应当注意-1。

public int findLength(int[] nums1, int[] nums2) {
    // dp[i][j]表示以i-1为结尾的nums1和以j-1为结尾的nums2
    int[][] dp = new int[nums1.length + 1][nums2.length + 1];

    int max = 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;
                max = Math.max(max, dp[i][j]);
            }
        }
    }

    return max;
}

时间复杂度为O(n^2),空间复杂度为O(n^2).

5. 最大子序列之和

这种题根本不需要使用动态规划(写到这里只是为了对称),直接把正数加起来即可。如果是限定了子序列的长度,那就用滑动窗口。

6. 最大子串之和

例题:最大子数组和

a. 定义合适的dp数组
这里定义dp[i]nums中以i为结尾的数组中的最大子串之和。

b. 寻找递推关系
dp[i]要么是加到前边的子串中,要么是从 i 开始重新计算子串和,所以:
dp[i][j] = Math.max(dp[i-1] + nums[i], nums[i]);

c. 赋予初始值
按照当前的dp定义,dp[0]为nums[0]即可。

d. 确定递推顺序
从前向后即可。

public int maxSubArray(int[] nums) {
    // dp[i]表示以i为结尾的最大连续子数组和
    int[] dp = new int[nums.length];

    dp[0] = nums[0];
    int max = dp[0];

    for (int i = 1; i < nums.length; i++) {
        dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
        max = Math.max(max, dp[i]);
    }

    return max;
}

时间复杂度为O(n),空间复杂度为O(n).

7. 统计回文子序列个数

这类题难度较高,本文暂且略过。

8. 统计回文子串个数

例题:回文子串

a. 定义合适的dp数组
这里dp数组的定义与之前题目区别较大:dp[i][j]表示区间[i, j]的子串是否为回文串。

b. 寻找递推关系
根据dp数组的定义,需要分为以下情况:
s[i]s[j]不相同时,肯定不是回文串,则dp[i, j]false;
s[i]s[j]相同时,就需要分情况讨论了:

  • 若i == j,则肯定是回文串;
  • 若i + 1 == j,则也是回文串;
  • 若i + 1 < j,则dp[i][j]就需要从dp[i+1, j-1]来进行推断了。

c. 赋予初始值
dp数组按照默认值false,因为如果是回文串,在递推公式中会进行赋值,不需要做额外的初始化。

d. 确定递推顺序
这里的递推顺序就需要注意了。
因为dp[i][j]需要从dp[i+1, j-1]来进行推断,从dp数组中看,也就是从左下推断右上
因此,i 的遍历需要从下往上,j 的遍历需要从左往右

public int countSubstrings(String s) {
    int n = s.length();
    // dp[i][j]表示[i, j]的子串是否是回文串
    boolean[][] dp = new boolean[n][n];

    int ans = 0;

    for (int i = n - 1; i >= 0; i--) {
        for (int j = i; j < n; j++) {
            if (s.charAt(i) == s.charAt(j)) {
                if (i == j || i + 1 == j) {
                    dp[i][j] = true;
                    ans++;
                } else if (dp[i + 1][j - 1]) {
                    dp[i][j] = true;
                    ans++;
                }
            }
        }
    }

    return ans;
}

时间复杂度为O(n^2),空间复杂度为O(n^2).

9. 最长回文子序列

例题:最长回文子序列

a. 定义合适的dp数组
dp数组的定义为:dp[i][j]表示区间[i, j]的子串中的最长回文子序列。

b. 寻找递推关系
根据dp数组的定义,需要分为以下情况:
s[i]s[j]相同时,那么dp[i, j] = dp[i+1, j-1] + 2;
s[i]s[j]不相同时,那就把s[i]s[j]分别加入进行讨论,即dp[i, j] = Math.max(dp[i, j-1], dp[i+1, j])

c. 赋予初始值
dp数组可以不进行额外的初始化,可以在递推公式中进行赋值。

d. 确定递推顺序
这里的递推顺序也需要注意。
因为dp[i][j]需要从dp[i+1, j-1]dp[i, j-1]以及dp[i+1, j]来进行推断,从dp数组中看,也就是从左下、左以及下推断右上
因此,i 的遍历需要从下往上,j 的遍历需要从左往右

public int longestPalindromeSubseq(String s) {
    // dp[i][j]表示[i, j]中最长回文子序列
    int n = s.length();

    int[][] dp = new int[n][n];

    for (int i = n - 1; i >= 0; i--) {
        for (int j = i; j < n; j++) {
            if (s.charAt(i) == s.charAt(j)) {
                if (i == j) {
                    dp[i][j] = 1;
                } else {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                }
            } else {
                dp[i][j] = Math.max(dp[i][j - 1], dp[i + 1][j]);
            }
        }
    }

    return dp[0][n - 1];
}

时间复杂度为O(n^2),空间复杂度为O(n^2).

10. 最长回文子串

例题:最长回文子串

这类题的动态规划法较为复杂,但是有个经典解法 —— 中心扩散法

回文串的长度可以为奇数或者偶数:
对于奇数来说,是以一个字符为中心向两边扩散所得,比如对于a来说,可以扩散为aaa或者bab;
对于偶数来说,是以两个字符为中心向两边扩散所得,比如对于ab来说,可以扩散为aabb;

所以叫中心扩散法。

因此,可以依次将自字符串中的每一个或两个字符作为中心进行扩散,获取最长的回文子串。

public String longestPalindrome(String s) {
    String ans = "";
    // 依次作为中心点
    for (int i = 0; i < s.length(); i++) {
        // 以i为中心扩散
        String palindrome1 = palindrome(s, i, i);
        // 以i和i+1为中心扩散
        String palindrome2 = palindrome(s, i, i + 1);
        // 获取最大值
        ans = palindrome1.length() > ans.length() ? palindrome1 : ans;
        ans = palindrome2.length() > ans.length() ? palindrome2 : ans;
    }

    return ans;
}

private String palindrome(String s, int i, int j) {
    // 移动过两端相同的字符,获取最长回文串
    while (i >= 0 && j < s.length() && s.charAt(i) == s.charAt(j)) {
        i--;
        j++;
    }

    // 注意这里的参数是i+1和j
    return s.substring(i + 1, j);
}