300. 最长递增子序列
本题问的是子序列,定义为“由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序”。
作为 dp 的序列第一题,难度不高,最大的难点在于找到正确的 dp 数组定义。这似乎也是序列 dp 的难点,和之前(背包问题,遍历顺序)、(股票问题、dp 状态)以及一些递推公式的难点都不一样。
-
dp 数组的下标含义:
dp[i]
代表了nums[0, i+1]
中以nums[i]
结尾的最长递增子序列的长度- 这个定义中“以
nums[i]
结尾”似乎有些奇怪,类似于股票问题里“指定的当前天的状态下,最优解是什么”,只不过这里指定的是最长递增子序列的结尾。 - 原因在于,想要通过
nums[i]
与之前的值nums[j]
( j < k ) (j < k) (j<k) 进行比较从而得到新的最长递增子序列的长度,就必须有所限定。否则,在不知道之前所得到的最长递增子序列的最后一个元素的情况下,无法判断当前元素是否能跟上之前的序列。
- 这个定义中“以
-
dp 递推公式:
if nums[i] > nums[j]: dp[i] = max(dp[i], dp[j] + 1)
- 这个递推公式也非常特殊,因为要求遍历之前所有的下标
j < i
,从而找到以nums[i]
结尾的最长递增子序列,而非以前的递推公式通常只依赖之前的某个状态一次
- 这个递推公式也非常特殊,因为要求遍历之前所有的下标
-
dp 数组的初始化:仍然非常特殊,初始化都为 1 而不是 0,因为根据题目定义,就算是当前值
nums[i] = min(nums[:i])
,也至少能得到一个长度为 1 的递增子序列 -
遍历顺序:从前向后遍历,根据递推公式不难得到内部的比较范围是 [0, i-1]
-
举例推导:
nums = [10,9,2,5,3,7,101,18]
10 9 2 5 3 7 101 18 1 1 1 2 2 3 4 4
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
# dp[i] represents the max length of subsequence in nums[:i+1] and including nums[i]
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i-1, -1, -1):
if nums[i] > nums[j] and dp[j] >= dp[i]:
dp[i] = dp[j] + 1
return max(dp)
值得注意的是,其实可以通过保持最大结果的方式避免最后的 max 操作,但不会对复杂度产生优化,所以可以偷懒这么写方便一点。
dp 数组的定义终于成为了一大梦魇!
674. 最长连续递增序列
本题和上一题唯一的区别在于连续子序列,这个区别导致的变化是递推公式变得简单了。由于要求子序列必须连续,所以在比较 nums[i]
时只需要和 nums[i-1]
比较即可,否则直接重置当前连续子序列的长度。
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
# dp[i] represents the max length of continuous subarray ending in nums[i]
dp = [1] * len(nums)
for i in range(1, len(nums)):
if nums[i] > nums[i-1]:
dp[i] = dp[i-1] + 1
return max(dp)
贪心解法
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
result = 1
curr_max = 1
for i in range(1, len(nums)):
if nums[i] > nums[i-1]:
curr_max += 1
else:
curr_max = 1
result = max(result, curr_max)
return result
718. 最长重复子数组
本题最难的地方在于找到正确的 dp 数组定义。一些思路的小提示:之前两题的定义都是需要以当前元素结尾的最大子序列长度,而本题有两个输入数组,所以使用二维数组来定义 dp 似乎也很合理。
-
dp 数组的下标含义:
dp[i][j]
代表着nums1[: i+1]
和nums2[: j+1]
所具有的以nums1[i]
和nums2[j]
结尾的最长重复子序列的长度- “以当前元素结尾”这个条件反复在之前的定义中出现,因为只有这个条件才能让人充分利用当前元素
nums1[i]
和nums2[j]
- 但这也意味着最后要得到结果,还需要遍历整个二维 dp 数组,而不能像以前的题型一样直接取
dp[-1][-1]
- “以当前元素结尾”这个条件反复在之前的定义中出现,因为只有这个条件才能让人充分利用当前元素
-
dp 递推公式:
if nums1[i] == nums2[j]: dp[i][j] = dp[i-1][j-1] + 1
- 根据定义,只有当两个连续子数组的最后一个元素相等时,才能从子状态
dp[i-1][j-1]
推导出当前dp[i][j]
- 如果两个连续子数组的最后一个元素都不相等,根据定义,显然
dp[i][j]=0
- 根据定义,只有当两个连续子数组的最后一个元素相等时,才能从子状态
-
dp 数组的初始化:由于递推公式中用到了
i-1
,我们显然需要初始化第一行dp[i][0]
和第一列dp[0][j]
- 根据定义,
dp[i][0]
记录了在以nums1[i]
为结尾的连续子数组和nums2[:1]
的最长重复子序列,由于nums2[:1]
的长度为 1,如果nums1[i] == nums2[0]
,则dp[i][0]=1
,否则初始化为 0 dp[0][j]
同理- 其他值全部初始化为 0 即可
- 根据定义,
-
dp 的遍历顺序:从小到大即可,由于只需要左上角的元素进行过初始化,两层循环的嵌套也就无所谓内外顺序
-
举例推导:
nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
1 2 3 2 1 3 0 0 1 0 0 2 0 1 0 2 0 1 1 0 0 0 3 4 0 0 0 0 0 7 0 0 0 0 0
class Solution:
def findLength(self, nums1: List[int], nums2: List[int]) -> int:
result = 0
dp = [[0] * len(nums2) for _ in range(len(nums1))]
for j in range(len(nums2)):
if nums1[0] == nums2[j]:
dp[0][j] = 1
result = 1
for i in range(len(nums1)):
if nums1[i] == nums2[0]:
dp[i][0] = 1
result = 1
for i in range(1, len(nums1)):
for j in range(1, len(nums2)):
if nums1[i] == nums2[j]:
dp[i][j] = dp[i-1][j-1] + 1
if dp[i][j] > result:
result = dp[i][j]
return result
代码随想录上提供了略有差异的 dp 数组定义,在解题的书写上的确更加简洁,不需要进行特殊的初始化。但我还是觉得以上的解法更加符合直觉。
另外,注意到递归中实际上只依赖于前一层的状态,所以就像背包问题一样,也可以把二维数组压成滚动数组来求解。由于需要保持之前的状态,滚动数组的解需要内层遍历从后向前进行。