【动态规划】力扣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 = [7,7,7,7,7,7,7]
输出:1

首先考虑题目问什么,就把什么定义成状态。题目问最长上升子序列的长度,其实可以把「子序列的长度」定义成状态,但是发现「状态转移」不好做。
基于「动态规划」的状态设计需要满足「无后效性」的设计思想,可以将状态定义为「以 nums[i] 结尾 的「上升子序列」的长度」。

无后效性」的设计思想:让不确定的因素确定下来,以保证求解的过程形成一个逻辑上的有向无环图。这题不确定的因素是某个元素是否被选中,而我们设计状态的时候,让 nums[i] 必需被选中,这一点是「让不确定的因素确定下来」,也是我们这样设计状态的原因。

方法1:动态规划

  1. 定义状态:
    dp[i] :以 nums[i] 结尾 的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素。
  2. 状态转移方程:
    如果一个较大的数接在较小的数后面,就会形成一个更长的子序列。只要 nums[i] 严格大于在它位置之前的某个数nums[j],那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列,即 dp[i] = max(dp[j]) + 1。因此状态转移方程为
    dp[i] = max(dp[i], dp[j] + 1) ,其中0 ≤ i < n, 0 ≤ j < i且 num[j] < num[i]
  3. 初始化:
    dp[i] = 1,1 个字符显然是长度为 1 的上升子序列。
  4. 输出:
    不能返回最后一个状态值,最后一个状态值只表示以 nums[n - 1] 结尾的「上升子序列」的长度,状态数组 dp 的最大值 max(dp[i]) (0 ≤ i < n) 才是题目要求的结果,可以设置一个变量来储存,也可以直接return max(dp)。
  5. 空间优化:
    遍历到一个新数的时候,之前所有的状态值都得保留,因此无法优化空间。
    作者:liweiwei1419
    链接:https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/dong-tai-gui-hua-er-fen-cha-zhao-tan-xin-suan-fa-p/
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 0:
            return 0
        dp = [] # 用append()来决定dp数组的最终长度
        for i in range(n):
            dp.append(1) # 每个dp[i]都初始化为1,易知dp长度为n
            for j in range(0, i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)
        return max(dp)

时间复杂度:O(n^2),其中 n 为数组 nums 的长度。动态规划的状态数为 n,计算状态 dp[i] 时,需要 O(n) 的时间遍历 dp[0…i−1] 的所有状态,所以总时间复杂度为 O(n^2)。
空间复杂度:O(n),需要额外使用长度为 n 的 dp 数组。

「动态规划」的方法在计算一个新的状态的时候,需要考虑到之前所有小于 nums[i] 的那些位置的状态。事实上还有改进的空间:首先修改「状态」的定义。
方法2:动态规划 + 二分查找 + 贪心
依然着眼于某个上升子序列结尾的元素,如果已经得到的上升子序列的结尾的数越小,那么遍历的时候后面接上一个数,会有更大的可能构成一个长度更长的上升子序列。既然结尾越小越好,我们可以记录在长度固定的情况下,结尾最小的那个元素的数值,这样定义后容易得到「状态转移方程」。
为了与「方法1」的状态定义区分,将状态数组命名为 tail。

  1. 定义新状态(特别重要)
    tail[i] :长度为 i + 1 的所有上升子序列的结尾的最小值。
    说明:
    数组 tail 不是问题中的「最长上升子序列」(下文还会强调),不能命名为 LIS。数组 tail 只是用于求解 LIS 问题的状态数组;
    tail[0] 表示长度为 1 的所有上升子序列中,结尾最小的元素的数值。以题目中的示例为例 [10, 9, 2, 5, 3, 7, 101, 18] 中,容易发现长度为 2 的所有上升子序列中,结尾最小的是子序列 [2, 3] ,因此 tail[1] = 3;
    下标和长度有数值为 1 的偏差;
    状态定义其实也描述了状态转移方程。
  2. 状态转移方程:
    从直觉上看,数组 tail 也是一个严格上升数组。
    image
    因为只需要维护状态数组 tail 的定义,它的长度就是最长上升子序列的长度。下面说明在遍历中,如何维护状态数组 tail 的定义。
  • 如果 tail 中的元素都比它小,将它插到最后
  • 否则,用它覆盖掉比它大的元素中最小的那个
    说明:
    我们再看一下数组 tail[i] 的定义:长度为 i + 1 的 所有 最长上升子序列的结尾的最小值。因此,在遍历的过程中,我们试图让一个大的值变小是合理的;
    这一步可以认为是「贪心算法」,总是做出在当前看来最好的选择,当前「最好的选择」是:当前只让第 1 个严格大于 nums[i] 的数变小,变成 nums[i],这一步操作是「无后效性」的。
    总之,思想就是让 tail 中存储比较小的元素。
    由于是在有序数组中的操作,因此可以使用「二分查找算法」。
  1. 初始化:
    遍历第 1 个数 nums[0],直接放在有序数组 tail 的开头 tail[0] = nums[0]。
  2. 输出:
    有序数组 tail 的长度,就是所求的「最长上升子序列」的长度。未必是真实的最长上升子序列,但长度是对的。
  3. 空间优化:
    无法优化空间。
    作者:liweiwei1419
    链接:https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/dong-tai-gui-hua-er-fen-cha-zhao-tan-xin-suan-fa-p/

整个算法流程:设当前已求出的最长上升子序列的长度为 len(初始时为 1),从前往后遍历数组 nums,在遍历到 nums[i] 时:

  • 如果 nums[i] > tail[len] ,则直接加入到 tail 数组末尾,并更新 len = len + 1;
  • 否则,在 tail 数组中二分查找,找到第一个比 nums[i] 小的数 tail[k] ,并更新 tail[k + 1] = nms[i]。

以输入序列 [0, 8, 4, 12, 2] 为例:
第一步插入 0,tail = [0];
第二步插入 8,tail = [0, 8];
第三步插入 4,tail = [0, 4];
第四步插入 12,tail = [0, 4, 12];
第五步插入 2,tail = [0, 2, 12]。
最终得到最大递增子序列长度为 3。

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tail = []
        for n in nums:
            if not tail or n > tail[-1]:
                tail.append(n)
            else:
                l, r = 0, len(tail) - 1
                loc = r
                while l <= r:
                    mid = (l + r) // 2
                    if tail[mid] >= n:
                        loc = mid
                        r = mid - 1
                    else:
                        l = mid + 1
                tail[loc] = n
        return len(tail)

作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/longest-increasing-subsequence/solution/zui-chang-shang-sheng-zi-xu-lie-by-leetcode-soluti/

简单一点,初始化tail列表长度为n,用ans表示tail有效列表的长度。

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 0:
            return 0
        tail = [1] * n
        ans = 0
        for num in nums:
            i, j = 0, ans
            while i < j:
                mid = (i + j) // 2 # 整除
                if tail[mid] < num:
                    i = mid + 1
                else:
                    j = mid
            tail[i] = num
            if j == ans:
                ans += 1
        return ans

时间少了很多
image
时间复杂度 O(N logN): 遍历 nums 列表需 O(N),在每个 nums[i] 二分法需 O(logN)。
空间复杂度 O(N) : tail 列表占用线性大小额外空间。

posted @ 2022-04-24 11:16  Vonos  阅读(209)  评论(0)    收藏  举报