【动态规划】力扣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:动态规划
- 定义状态:
dp[i] :以 nums[i] 结尾 的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素。 - 状态转移方程:
如果一个较大的数接在较小的数后面,就会形成一个更长的子序列。只要 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] - 初始化:
dp[i] = 1,1 个字符显然是长度为 1 的上升子序列。 - 输出:
不能返回最后一个状态值,最后一个状态值只表示以 nums[n - 1] 结尾的「上升子序列」的长度,状态数组 dp 的最大值 max(dp[i]) (0 ≤ i < n) 才是题目要求的结果,可以设置一个变量来储存,也可以直接return max(dp)。 - 空间优化:
遍历到一个新数的时候,之前所有的状态值都得保留,因此无法优化空间。
作者: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。
- 定义新状态(特别重要)
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 的偏差;
状态定义其实也描述了状态转移方程。 - 状态转移方程:
从直觉上看,数组 tail 也是一个严格上升数组。
![image]()
因为只需要维护状态数组 tail 的定义,它的长度就是最长上升子序列的长度。下面说明在遍历中,如何维护状态数组 tail 的定义。
- 如果 tail 中的元素都比它小,将它插到最后
- 否则,用它覆盖掉比它大的元素中最小的那个
说明:
我们再看一下数组 tail[i] 的定义:长度为 i + 1 的 所有 最长上升子序列的结尾的最小值。因此,在遍历的过程中,我们试图让一个大的值变小是合理的;
这一步可以认为是「贪心算法」,总是做出在当前看来最好的选择,当前「最好的选择」是:当前只让第 1 个严格大于 nums[i] 的数变小,变成 nums[i],这一步操作是「无后效性」的。
总之,思想就是让 tail 中存储比较小的元素。
由于是在有序数组中的操作,因此可以使用「二分查找算法」。
- 初始化:
遍历第 1 个数 nums[0],直接放在有序数组 tail 的开头 tail[0] = nums[0]。 - 输出:
有序数组 tail 的长度,就是所求的「最长上升子序列」的长度。未必是真实的最长上升子序列,但长度是对的。 - 空间优化:
无法优化空间。
作者: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
时间少了很多

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


浙公网安备 33010602011771号