【算法】子序列问题合集

2023-02-26  本文已影响0人  分布式与微服务

前言

动态规划的核心设计思想是数学归纳法

假如我们想证明一个数学结论:

  1. 那么先假设这个结论在 k < n 时成立
  2. 想办法推导证明出 k = n 的时候此结论也成立。

是需要一个 dp 数组嘛?

  1. 可以假设 dp[0...i - 1] 都已经被算出来了
  2. 然后问自己:怎么通过这些结果算出 dp[i]
  3. 首先要定义清楚的 dp 数组的含义,即 dp[i] 的值到底代表是什么?

Tips:”子序列“ 和 ”子串“ 区别

子序列问题是最常见的算法问题,而且并不好解决。

一旦涉及子序列和最值,那几乎可以肯定,考察的是动态规划技巧,时间复杂度一般都是 O(n ^ 2)

既然用到动态规划,那就要定义 dp 数组,寻找状态转移关系。

两种思路:

  1. 第一种思路模板是一个一维的 dp 数组:
int n = array.length;
int [] dp = new int[n];

for (int i = 1; i < n; ++i) {
    for (int j = 0; j < i; ++j) {
        dp[i] = 最值(dp[i], dp[j] + ...)
    }
}

  1. 第二种思路模板是一个二维的 dp 数组:
int n = arr.length;
int [][] dp = new int[n][n];

for (int i = 0; i < n; ++i) {
    for (int j = 0; j < n; ++j) {
        if (arr[i] == arr[j]) 
            dp[i][j] = dp[i][j] + ...
        else
            dp[i][j] = 最值(...)
    }
}

题目

(1)LeetCode 300 最长递增子序列

题目描述


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

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

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

示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1

思路解法


定义是这样的:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。

可以推出 base case:dp[i] 初始值为 1, 因为以 nums[i] 结尾的最长递增子序列起码要包含它自己。

public class LeetCode_300 {

    // 方法一:
    // Time: o(n^2), Space: o(n), Faster: 27.93%
    public int lengthOfLISDP(int [] nums) {
        if (nums == null || nums.length == 0) return 0;
        int n = nums.length, max = 1;
        int [] d = new int[n];
        d[0] = 1;

        for (int i = 1; i < n; ++i) {
            for (int j = 0; j < i; ++j) {
                int cur = nums[i] > nums[j] ? d[j] + 1 : 1;
                d[i] = Math.max(d[i], cur);
            }
            max = Math.max(max, d[i]);
        }
        return max;
    }

    // 方法二:
    // Time: o(n * log(n)), Space: o(n), Faster:  92.62%
    public int lengthOfLISBinarySearch(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        // 牌堆数初始化为 0
        int len = 0;
        int [] top = new int[nums.length];
        for (int x : nums) {
            int left = binarySearchInsertPosition(top, len, x);
            // 把这张牌放到牌堆顶,牌堆顶的牌是这个堆最小的
            top[left] = x;
            // 没找到合适的牌堆,新建一堆
            if (left == len) ++len;
        }
        return len;
    }

    private int binarySearchInsertPosition(int[] d, int len, int x) {
        int low = 0, high = len - 1;
        while (low <= high) {
            int mid = low + (high - low) / 2;
            if (x < d[mid]) high = mid - 1;
            else if (x > d[mid]) low = mid + 1;
            else return mid;
        }
        return low;
    }

}

(2)LeetCode 53 最大子序和

题目描述


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

示例:

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

思路解法


题目解读:以 nums[i] 为结尾的 “最大子数组和” 为 dp[i]

使用数学归纳法来找状态转移关系:假设已经算出了 dp[i - 1], 如何推导出 dp[i] 呢?

dp[i] 有两种 “选择”:

  1. 要么与前面的相邻子数组连接,形成一个和更大的子树组
  2. 要么不与前面的子数组连接,自成一派,自己作为一个子数组。
// 要么自成一派,要么和前面的子数组合并
dp[i] = Math.max(nums[i], num[i] + dp[i - 1]);

思路:

  1. 方法一:暴力求法,两层 for 循环
  2. 方法二:dp方法。
  3. 方法三:遍历一遍,负数 + 负数没有任何意义 前面累加值为负数,则对后面没有正向的贡献, 所以可以直接舍弃前面这段子序列的和

这里主要讲的是方法三:

public class LeetCode_53 {

    // Time: O(n), Space: O(1), Faster: 95.05%
    public int maxSumOfSubArray(int[] nums) {
        int max = Integer.MIN_VALUE, cur = 0;

        for (int i = 0; i < nums.length; ++i) {

            // 前面累加值为负数,对后面没有正向的贡献,可以直接舍弃前面的子序列的和
            cur = cur <= 0 ? nums[i] : (cur + nums[i]);
            max = Math.max(max, cur);
        }

        return max;
    }
}

总结:

(3)LeetCode 152 连续子序列的最大乘积

题目描述


这个题目说的是,给你一个整数数组,你要找到乘积最大的连续子序列,然后返回它的乘积。

比如说,给你的数组是:

8, 1, -2, 4, -1

这个数组中连续子序列的最大乘积是 64,连续子序列就是数组本身。

思路解法


解法有两种:

  1. 暴力法:两层 for 循环
  2. 动态规划:在遍历数组时候,要保留当前最大乘积、当前最小乘积
    • 当前最大乘积
    • 当前最小乘积

图解如下:


AC 代码:

public class LeetCode_152 {

    // 方式一:暴力法
    // Time: O(n ^ 2), Space: O(1), Faster: 5.01%
    public int maxProduct2(int[] nums) {
        int max = Integer.MIN_VALUE;
        for (int i = 0; i < nums.length; ++i) {
            int sum = nums[i];
            max = Math.max(max, sum);
            for (int j = i + 1; j < nums.length; ++j) {
                sum *= nums[j];
                max = Math.max(sum, max);
            }
        }
        return max;
    }

    // 方式二:动态规划
    // Time: o(n), Space: o(1), Faster: 66.15%
    public int maxProduct(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        int max = nums[0], currMax = nums[0], currMin = nums[0];

        for (int i = 1; i < nums.length; ++i) {
            int a = currMax * nums[i], b = currMin * nums[i], c = nums[i];
            currMax = max(a, b, c);
            currMin = min(a, b, c);
            max = Math.max(max, currMax);
        }

        return max;
    }

    private int max(int a, int b, int c) {
        return Math.max(Math.max(a, b), c);
    }

    private int min(int a, int b, int c) {
        return Math.min(Math.min(a, b), c);
    }
}

(4)LeetCode 354 俄罗斯套娃信封问题

题目描述


给定一些标记了宽度和高度的信封,宽度和高度以整数对形式 (w, h) 出现。当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。

请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。

说明: 不允许旋转信封。

示例:

输入: envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出: 3 
解释: 最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]。

信封嵌套问题实际上是最长递增子序列问题上升到二维,其解法就需要先按照特定的规则排序,之后转换为一个一维的最长递增子序列问题,最后用二分搜索框架中的技巧来解决。

这道题目其实是 最长递增子序列(LOngest Incresing Subsequence, LIS 的一个变种。

每次合法的嵌套是大的套小的,相当于找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。

一个直接的想法就是,通过 w * h 计算面积,然后对面积进行标准的 LIS 算法。但是稍加思考就会发现这样不行,比如 1 * 10 大于 3 * 3, 但是显然这样的两个信封是无法互相嵌套的。

思路解法


思路步骤:

  1. 先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序。
  2. 之后把所有的 h 作为一个数组,在这个数组上计算出的 LIS 的长度就是答案。

步骤1,如图:


步骤2,如图:


public class LeetCode_354 {

    // Time: O(n * log(n)), Space: O(n), Faster: 87.03%
    public int maxEnvelopes(int[][] envelopes) {

        int n = envelopes.length;
        // 按照宽度升序排列,如果宽度一样,则按高度降序排列
        Arrays.sort(envelopes, (a, b) -> a[0] == b[0] ? b[1] - a[1] : a[0] - b[0]);

        // 对高度数组寻找 LIS
        int [] height = new int[n];
        for (int i = 0; i < n; ++i) {
            height[i] = envelopes[i][1];
        }

        return lengthOfLISBinarySearch(height);
    }

    // Time: o(n * log(n)), Space: o(n), Faster:  92.62%
    private int lengthOfLISBinarySearch(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        // 牌堆数初始化为 0
        int len = 0;
        int [] top = new int[nums.length];
        for (int x : nums) {
            int left = binarySearchInsertPosition(top, len, x);
            // 把这张牌放到牌堆顶
            top[left] = x;
            // 没找到合适的牌堆,新建一堆
            if (left == len) ++len;
        }
        return len;
    }

    private int binarySearchInsertPosition(int[] d, int len, int x) {
        int low = 0, high = len - 1;
        while (low <= high) {
            int mid = low + (high - low) / 2;
            if (x < d[mid]) high = mid - 1;
            else if (x > d[mid]) low = mid + 1;
            else return mid;
        }
        return low;
    }
}

总结:

其实这种问题还可以扩展到三维,比如过说嵌套箱子,每个箱子有长、宽、高三个维度,请你计算最多能嵌套几个箱子?

按之前思路,可能会想:先把前两个维度(长和宽)按信封嵌套的思路求一个嵌套序列,最后在这个序列的第三个维度(高度)找一下 LIS,就能算出答案。

实际上,这个思路是错误的。这类问题叫做 “偏序问题”,上升到三维会使难度巨幅提升,需要借助一种高级数据结构 “树状数组”。

(5)LeetCode 1312 让字符串成为回文串的最少插入次数

题目描述


给你一个字符串 s ,每一次操作你都可以在字符串的任意位置插入任意字符。

请你返回让 s 成为回文串的 最少操作次数 。

「回文串」是正读和反读都相同的字符串。

示例 1:

输入:s = "zzazz"
输出:0
解释:字符串 "zzazz" 已经是回文串了,所以不需要做任何插入操作。
示例 2:

输入:s = "mbadm"
输出:2
解释:字符串可变为 "mbdadbm" 或者 "mdbabdm" 。
示例 3:

输入:s = "leetcode"
输出:5
解释:插入 5 个字符后字符串变为 "leetcodocteel" 。
示例 4:

输入:s = "g"
输出:0
示例 5:

输入:s = "no"
输出:1

提示:

1 <= s.length <= 500 s 中所有字符都是小写字母。

题干分析


要找最少的插入次数,那肯定要穷举喽。

每次都可以在两个字符的中间插入任意一个字符,外加判断字符串是否为回文字符串,它的时间复杂度肯定暴增,而且是指数级的。

动态规划标准套路:

  1. 第一步要明确:状态选择
  2. 第二步要明确:dp 数组的定义
  3. 第三步:根据 “选择”,思考状态转移的逻辑

1. 第一步要明确:状态 和 选择

  1. 状态:“指针 i” 和 “指针 j
  2. 选择:“插入” 或者 “不插入”

2. 第二步要明确:dp 数组的定义

  1. dp数组的定义dp[i][j] , 对字符串 s[i..j],最少需要进行 dp[i][j] 次插入才能变成回文串。
  1. 最终答案dp[0][n - 1] 的大小(ns 的长度)
  1. base casei == j 时,dp[i][j] = 0, 因为当 i == js[i..j] 就是一个字符,本身就是回文串,所以不需要进行任何插入操作;

3. 第三步:根据 “选择”,思考状态转移的逻辑

  1. 如果 s[i] == s[j],不需要进行任何插入,只要知道如何把 s[i+1..j-1] 变成回文串即可
  1. 如果 s[i] != s[j],分情况讨论,看下文。

分析第三步:状态转移方程

想要计算 dp[i][j] 的值,能从子问题 dp[i + 1][j - 1] 推出 dp[i][j] 值嘛?

也就认为 s[i+1 .. j-1] 已经是一个回文串了,所以通过 dp[i+1][j-1] 推导 dp[i][j] 的关键在于 s[i]s[j]

  1. 如果 s[i] == s[j],不需要进行任何插入,只要知道如何把 s[i+1..j-1] 变成回文串即可。

如图:


// 翻译成代码
if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1];
}

  1. 如果 s[i] != s[j],分情况讨论:

s[i] != s[j] 时,插入两次肯定可以让 s[i..j] 变成回文串,但是不一定是插入次数最少的。

如图:

第一步:将 s[i] 插到 s[j] 的左边

第二步:将 s[j] 插到 s[i] 的左边

特殊情况:

只需要插入一个字符即可使得 s[i..j] 变成回文,如下图:

这种情况,s[i+1..j]s[i..j - 1] 原本就是回文串,那么就不需要插入,只需要插入另一个即可。

if (s[i] != s[j]) {
    // 步骤一选择代价较小的
    // 步骤二必然要进行一次插入
    dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1;
}

思路解法


状态转移方程中 dp[i][j]dp[i + 1][j]dp[i][j - 1]dp[i + 1][j - 1] 三个状态有关,为了保证每次计算 dp[i][j] 时,这三个状态都已经被计算,一般选择从下向上,从左到右遍历 dp 数组:

public class LeetCode_1312 {

    // Time: O(n ^ 2), Space: O(n ^ 2), Faster: 60.65%
    public int minInsertions(String s) {
        int n = s.length();

        // 定义: 对 s[i..j], 最少需要插入 dp[i][j] 次才能变成回文串
        // base case: dp 数组全部初始化为 0
        int [][] dp = new int[n][n];

        // 从下向上遍历
        for (int i = n - 2; i >= 0; --i) {
            // 从左向右遍历
            for (int j = i + 1; j < n; ++j) {
                // 根据 s[i] 和 s[j] 进行状态转移
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j -1];
                } else {
                    dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1;
                }
            }
        }
        // 根据 dp 数组的定义,题目要求的答案是 dp[0][n - 1]
        return dp[0][n - 1];
    }
}

优化:

dp 数组的状态只和和它相邻的状态有关,所以 dp 数组是可以压缩成一维的。

public class LeetCode_1312 {

    // Time: O(n ^ 2), Space: O(n), Faster: 77.81%
    public int minInsertions(String s) {
        int n = s.length();

        int [] dp = new int[n];

        int temp = 0;
        for (int i = n - 2; i >= 0; --i) {
            // 记录 dp[i+1][j-1]
            int pre = 0;
            for (int j = i + 1; j < n; ++j) {
                temp = dp[j];

                if (s.charAt(i) == s.charAt(j)) {
                    // dp[i][j] = dp[i+1][j-1];
                    dp[j] = pre;
                } else {
                    // dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1;
                    dp[j] = Math.min(dp[j], dp[j - 1]) + 1;
                }
                pre = temp;
            }
        }

        return dp[n - 1];
    }
}

(6)LeetCode 1143 最长公共子序列

题目描述


给定两个字符串 text1text2,返回这两个字符串的最长公共子序列的长度。

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

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

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

示例 2:

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

示例 3:

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

题干分析


最长公共子序列(Longest Common Subsequence, LCS是一道非常经典的面试题目,因为它的解法是典型的二维动态规划

这个算法稍微加改造就可以用于解决其他问题,所以说 LCS 算法是值得掌握的。

动态规划思路:

  1. 第一步:一定要明确 dp 数组的含义
  2. 定义 base case
  3. 找状态转移方程

1. 第一步:一定要明确 dp 数组的含义。

对于两个字符串的动态规划问题,套路是通用的,一般都需要一个二维 dp 数组。

比如对于字符串 s1s2,一般来说要构造一个这样的 DP table

dp[i][j] 含义是:对于 s1[0..i - 1]s2[0..j - 1],它们的 LCS 长度是 dp[i][j]

比如上图这个例子,dp[2][4] = 2 含义就是:对于 "ac""babc",它们的 LCS 长度是2。根据这个定义,最终想得到的答案应该是 dp[3][6]

2. 定义 base case

专门让索引为 0 的行和列表示空串,dp[0][..]dp[..][0] 都应该初始化为 0,这就是 base case

比如按照刚才 dp 数组的定义,dp[0][3] = 0 的含义是:对于空字符串 """bab",其 LCS 的长度为 0。

因为有一个字符串是空串,它们的最长公共子序列的长度显然应该是 0

3. 找状态转移方程

做 “选择”。

s1s2 的最长公共子序列,设:子序列为 lcs

那么对于 s1s2 中的每个字符,有什么选择?

很简单,两种选择,要么在 lcs 中,要么不在。

如图:


对于 s1[i]s2[j],怎么知道它们到底在不在 lcs 中?

可以很容易想到的是,如果某个字符在 lcs 中,那么这个字符肯定同时存在与 s1s2 中,因为 lcs 是最长公共子序列。

dp(i, j) 表示 s1[0...i]s2[0...j] 中最长公共子序列的长度,这样就可以找到状态转移关系:

  1. 如果 s1[i] == s2[j]

说明这个公共字符一定在 lcs 中,如果知道了 s1[0..i-1]s2[0..j-1] 中的 lcs 长度,再加 1 就是 s1[0..i]s2[0..j]lcs 的长度。 根据 dp 函数的定义,如下:

if (str1[i] == str2[j]) dp[i][j] = dp(i - 1, j - 1) + 1;
复制代码
  1. 如果 s1[i] != s2[j]

说明 s1[i]s2[j] 这两个字符至少有一个不在 lcs 中,那到底是哪个字符不在 lcs 中呢? 根据 dp 函数的定义,如下:

if (str1[i] != str2[j]) dp[i][j] = Math.max(dp(i - 1, j), dp(i, j - 1));

明白了状态转移方程,可以直接写出解法。

思路解法


思路:

  1. 二维数组 dp
  2. dp 压缩
public class LeetCode_1143 {
    // Time: O(n ^ 2), Space: O(m * n), Faster: 72.28%
    public int longestCommonSubsequence(String text1, String text2) {

        int m = text1.length(), n = text2.length();
        // 定义: 对于 s1[0..i-1] 和 s2[0..j-1], 它们的 lcs 长度是 dp[i][j]
        // base case: dp[0][..] = dp[..][0] = 0 已初始化
        int [][] dp = new int[m + 1][n + 1];

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++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][j - 1], dp[i - 1][j]);
                }
            }
        }
        return dp[m][n];
    }
}
上一篇下一篇

猜你喜欢

热点阅读