LeetCode 第 300 题:最长上升子序列(动态规划、贪心算法)

地址:https://leetcode-cn.com/problems/longest-increasing-subsequence/

我写的题解地址:https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/dong-tai-gui-hua-er-fen-cha-zhao-tan-xin-suan-fa-p/

首先,需要对子序列和子串进行区分;

  • 子序列(subsequence):并不要求连续,序列 [4, 6, 5][1, 2, 4, 3, 7, 6, 5] 的一个子序列。
  • 子串(substring、subarray):一定是连续的,例如:「力扣」第 3 题:“无重复字符的最长子串”,「力扣」第 53 题:“最大子序和”

其次,题目中的“上升”要求严格“上升”,即不能有重复元素;

第三,子序列中元素的相对顺序很重要,它们必须保持在原始数组中的相对顺序不变。否则这道题去重以后,元素的个数即为所求。

首先思考暴力解法,使用“回溯搜索算法”或者“位运算”的技巧,得到原始数组的所有可能的子序列(时间复杂度 O ( 2 N ) O(2^N) ),对这些子串再依次判定是否为“严格”上升,(时间复杂度 O ( N ) O(N) ),总的时间复杂度为: O ( N 2 N ) O(N2^N)

这道题是经典的使用动态规划解决的问题,在这里我尝试解释使用动态规划解决这个问题的思路。

方法一:动态规划

  • 第 1 步:定义状态。首先考虑是否可以“题目问什么,就把什么定义成状态”,发现无从下手。不过可以基于下面这个考虑定义状态:

从一个较短的上升子序列得到一个较长的上升子序列,我们主要关心这个较短的上升子序列的结尾元素。

为了保证子序列的相对顺序性,在程序读到一个新的数的时候,如果比已经得到的子序列的最后一个数还大,那么就可以放在这个子序列的最后,形成一个更长的子序列。

一个子序列一定会以一个数结尾,于是将状态定义成:dp[i] 表示以 nums[i] 结尾的“最长上升子序列”的长度,注意这个定义中 nums[i] 必须被选取,且必须被放在最后一个元素。

  • 第 2 步:考虑状态转移方程;

遍历到 nums[i] 时,考虑把索引 i 之前的所有的数都看一遍,只要当前的数 nums[i] 严格大于之前的某个数,那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列。因此,dp[i] 就等于索引 i 之前严格小于 nums[i] 的状态最大者 + 1 +1

语言描述:在索引 i 之前严格小于 nums[i] 的所有状态中的最大者 + 1 + 1

符号描述:

d p [ i ] = max 0 j < i , n u m s [ j ] < n u m s [ i ] d p [ j ] + 1 dp[i] = \max_{0 \le j < i, nums[j] < nums[i]} {dp[j] + 1}

  • 第 3 步:考虑初始化:dp[0] = 1,1 个字符当然也是长度为 1 的上升子序列;
  • 第 4 步:考虑输出:所有 dp[i] 中的最大值(dp[i] 考虑了所有以 nums[i] 结尾的上升子序列);

max 1 i N d p [ i ] \max_{1 \le i \le N} dp[i]

  • 第 5 步:考虑状态压缩:之前所有的状态都得保留,因此无法压缩。

Java 代码:

import java.util.Arrays;

public class Solution {

    public int lengthOfLIS(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return len;
        }

        int[] dp = new int[len];
        Arrays.fill(dp, 1);
        for (int i = 1; i < len; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
        }

        int res = 0;
        for (int i = 0; i < len; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

复杂度分析:

  • 时间复杂度: O ( N 2 ) O(N^2) ,因为有两个 for 循环,每个 for 循环的时间复杂度都是线性的。
  • 空间复杂度: O ( N ) O(N) ,要开和原始数组等长的状态数组,因此空间复杂度是 O ( N ) O(N)

这个动态规划的方法在计算一个新的状态的时候,需要考虑到之前所有小于 nums[i] 的那些位置的状态。事实上,这个算法还有改进的空间。

方法二:修改状态定义、贪心算法、二分查找

依然是着眼于一个上升子序列的结尾元素。思路是这样的:

如果已经得到的上升子序列的结尾的数越小,遍历的时候后面接上一个数,就会有更大的可能性构成一个更长的上升子序列。

说明:

1、在最开始,我们强调了子序列的定义,必须保持子序列中的元素在原始数组中的相对顺序。因此,通过从左向右遍历得到一个上升子序列,这个方法是合理的;

2、既然结尾越小越好,可以如下定义状态。为了与之前的状态区分,这里将状态数组命名为 tail

  • 第 1 步:定义新的状态。

tail[i] 表示长度为 i + 1所有最长上升子序列的结尾的最小值。

说明: 1、状态定义其实也描述了状态转移方程;

2、以题目中的示例为例 [10, 9, 2, 5, 3, 7, 101, 18] 中,容易发现长度为 2 的所有上升子序列中结尾最小的是 [2, 3] ,因此 tail[1] = 3

  • 第 2 步:思考状态转移方程。状态转移方程包含在状态定义中。

从直觉上看,数组 tail 也是一个严格上升数组。

下面证明:即对于任意的索引 i < j ,都有 tail[i] < tail[j]

使用反证法

假设对于任意的索引 i < j ,存在某个 tail[i] >= tail[j]

对于此处的 tail[i] 而言,对应一个上升子序列 [ a 0 , a 1 , . . . , a i ] [a_0, a_1, ..., a_i] ,依据定义 t a i l [ i ] = a i tail[i] = a_i
对于此处的 tail[j] 而言,对应一个上升子序列 [ b 0 , b 1 , . . . , b i , . . . , b j ] [b_0, b_1, ..., b_i, ... , b_j] ,依据定义 t a i l [ j ] = b j tail[j] = b_j

由于 tail[i] >= tail[j] ,等价于 a i b j a_i \ge b_j ,而在上升子序列 [ b 0 , b 1 , . . . , b i , . . . , b j ] [b_0, b_1, ..., b_i, ... , b_j] 中, b i b_i 严格小于 b j b_j ,故有 a i b j > b i a_i \ge b_j > b_i ,则上升子序列 [ b 0 , b 1 , . . . , b i ] [b_0, b_1, ..., b_i] 是一个长度也为 i + 1 但是结尾更小的数组,与 a i a_i 的最小性矛盾。因此原命题成立。

因此,我们只需要维护有序数组 tail ,它的长度就是最长上升子序列的长度。下面说明如何在遍历中维护有序数组 tail 的定义:

遍历到新的数 nums[i] 时,首先看 nums[i] 是否严格大于有序数组 tail 的末尾元素。

如果 tail[len - 1] < nums[i] ,则 tail[len] = nums[i],得到一个更长的上升子序列;

如果数组 tail 中有元素等于 nums[i] 什么都不操作,新遍历到的数不会使得已有的子序列的长度更长;

剩下的情况是,有序数组 tail 中一定有一个元素大于 nums[i] ,将第 1 个大于等于 nums[i] 位置的元素变小,这一步操作正是在维护数组 tail[i] 的定义。

发布了442 篇原创文章 · 获赞 330 · 访问量 123万+

猜你喜欢

转载自blog.csdn.net/lw_power/article/details/103816347