"人生就像一场连续剧,有高潮也有低谷。动态规划中的子序列问题,也总在寻找那段最精彩的连续剧情。"
一、前言:连续的美好,算法的追寻
你是否曾在刷题时遇到这样的问题:给你一串数字,问你 "最长的连续递增子序列有多长?"、"最大连续子数组和是多少?"、"两个数组里最长的重复连续片段有多长?"
别慌,这些问题其实都属于 "连续子序列" 类的动态规划问题。今天,我们就来聊聊这类问题的套路和精髓。
二、动态规划基础回顾
动态规划(Dynamic Programming,简称 DP)其实就是 "拆分大问题,记住小问题的答案,避免重复劳动"。
在子序列类 DP 问题中,我们常常关心 "以某个元素结尾的最优解",因为连续性要求我们不能随意跳跃。
类比:就像追剧不能跳集,否则剧情就断了,主角的成长线也就没法连贯下去。
三、经典题目逐一拆解
1. 53. 最大子数组和
题意简述
给定一个整数数组,找出和最大的连续子数组,并返回其和。
(LeetCode 经典热题,面试高频!)
思路分析
- 状态定义:dp[i] 表示以 nums[i] 结尾的最大连续子数组和。
- 递推公式:dp[i] = max(nums[i], dp[i-1] + nums[i])
-
- 要么自成一派(只要自己),要么和前面的 "剧情" 连起来(和前面的子数组合并)。
- 初始化:dp[0] = nums[0]
- 遍历顺序:从前往后。
代码实现
// 动态规划解法
var maxSubArray = function(nums) {
let dp = new Array(nums.length).fill(0)
dp[0] = nums[0]
let res = nums[0]
for (let i = 1; i < nums.length; i++) {
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i])
res = Math.max(res, dp[i])
}
return res
};
DP 数组变化过程
以数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例,DP 数组的变化过程如下:
| 索引 i | nums[i] | dp [i] 计算过程 | dp [i] 值 | 当前最大值 res |
|---|---|---|---|---|
| 0 | -2 | 初始值 | -2 | -2 |
| 1 | 1 | max(1, -2+1)=max(1,-1)=1 | 1 | 1 |
| 2 | -3 | max(-3, 1+(-3))=max(-3,-2)=-2 | -2 | 1 |
| 3 | 4 | max(4, -2+4)=max(4,2)=4 | 4 | 4 |
| 4 | -1 | max(-1, 4+(-1))=max(-1,3)=3 | 3 | 4 |
| 5 | 2 | max(2, 3+2)=max(2,5)=5 | 5 | 5 |
| 6 | 1 | max(1, 5+1)=max(1,6)=6 | 6 | 6 |
| 7 | -5 | max(-5, 6+(-5))=max(-5,1)=1 | 1 | 6 |
| 8 | 4 | max(4, 1+4)=max(4,5)=5 | 5 | 6 |
最终结果为 6,对应子数组 [4,-1,2,1]
空间优化
其实我们只关心前一个状态,可以把 dp 数组压缩成一个变量:
var maxSubArray = function(nums) {
let pre = nums[0], res = nums[0]
for (let i = 1; i < nums.length; i++) {
pre = Math.max(nums[i], pre + nums[i])
res = Math.max(res, pre)
}
return res
};
贪心解法
如果当前累加和小于 0,直接 "砍掉重练":
var maxSubArray = function(nums) {
let res = -Infinity, count = 0
for (let i = 0; i < nums.length; i++) {
count += nums[i]
if (count > res) res = count
if (count < 0) count = 0
}
return res
};
解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 动态规划 | O(n) | O(n) | 理解 DP 转移过程 |
| 空间优化 DP | O(n) | O(1) | 追求极致效率 |
| 贪心 | O(n) | O(1) | 代码最简洁 |
🏆 小结:本题是 "以 i 结尾的最优解" 典范,连续性要求让 DP 和贪心都能大显身手。
2. 674. 最长连续递增子序列
题意简述
给定一个无序整数数组,找出最长的连续递增子序列的长度。
思路分析
- 状态定义:dp[i] 表示以 nums[i] 结尾的最长连续递增子序列长度。
- 递推公式:如果 nums[i] > nums[i-1],dp[i] = dp[i-1] + 1,否则 dp[i] = 1。
- 初始化:所有 dp[i] 初始为 1。
代码实现
var findLengthOfLCIS = function(nums) {
let res = 1
let dp = new Array(nums.length).fill(1)
for (let i = 1; i < nums.length; i++) {
if (nums[i] > nums[i - 1]) {
dp[i] = dp[i - 1] + 1
res = Math.max(res, dp[i])
}
}
return res
};
DP 数组变化过程
以数组 [1,3,5,4,7] 为例,DP 数组的变化过程如下:
| 索引 i | nums[i] | 与前一个元素比较 | dp [i] 计算过程 | dp [i] 值 | 当前最大值 res |
|---|---|---|---|---|---|
| 0 | 1 | 无 | 初始值 | 1 | 1 |
| 1 | 3 | 3 > 1 | dp[0] + 1 | 2 | 2 |
| 2 | 5 | 5 > 3 | dp[1] + 1 | 3 | 3 |
| 3 | 4 | 4 < 5 | 重置为 1 | 1 | 3 |
| 4 | 7 | 7 > 4 | dp[3] + 1 | 2 | 3 |
最终结果为 3,对应最长连续递增子序列 [1,3,5]
极简指针法
其实只需一个计数器:
var findLengthOfLCIS = function(nums) {
let left = 1, max = 1
for (let i = 1; i < nums.length; i++) {
if (nums[i] > nums[i - 1]) {
left++
} else {
left = 1
}
max = Math.max(max, left)
}
return max
};
🎬 类比:每次剧情递增就 "连播",一旦断档就 "重置计数"。
3. 718. 最长重复子数组
题意简述
给定两个整数数组,找出它们中最长的公共连续子数组的长度。
思路分析
- 状态定义:dp[i][j] 表示以 nums1[i-1] 和 nums2[j-1] 结尾的最长公共子数组长度。
- 递推公式:如果 nums1[i-1] === nums2[j-1],dp[i][j] = dp[i-1][j-1] + 1,否则为 0。
- 初始化:dp 数组全为 0。
代码实现
var findLength = function(nums1, nums2) {
let res = 0
let dp = new Array(nums1.length + 1).fill().map(() => new Array(nums2.length + 1).fill(0))
for (let i = 1; i <= nums1.length; i++) {
for (let j = 1; j <= nums2.length; j++) {
if (nums1[i - 1] === nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
}
res = Math.max(res, dp[i][j])
}
}
return res
};
DP 数组变化过程
以数组 nums1 = [1,2,3,2,1] 和 nums2 = [3,2,1,4,7] 为例,二维 DP 数组的变化过程如下:
初始状态(全为 0):
[
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0]
]
执行过程后(最终状态):
[
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0], // nums1[0]=1 与 nums2各元素比较
[0, 0, 1, 0, 0, 0], // nums1[1]=2 与 nums2各元素比较
[0, 1, 0, 0, 0, 0], // nums1[2]=3 与 nums2各元素比较
[0, 0, 2, 0, 0, 0], // nums1[3]=2 与 nums2各元素比较
[0, 0, 0, 3, 0, 0] // nums1[4]=1 与 nums2各元素比较
]
逐行解析:
- 第 1 行(i=1):nums1 [0]=1 与 nums2 比较,仅 nums2 [2]=1 匹配,所以 dp [1][3]=1
- 第 2 行(i=2):nums1 [1]=2 与 nums2 比较,仅 nums2 [1]=2 匹配,所以 dp [2][2]=1
- 第 3 行(i=3):nums1 [2]=3 与 nums2 比较,仅 nums2 [0]=3 匹配,所以 dp [3][1]=1
- 第 4 行(i=4):nums1 [3]=2 与 nums2 比较,nums2 [1]=2 匹配,且 dp [3][1]=1,所以 dp [4][2]=2
- 第 5 行(i=5):nums1 [4]=1 与 nums2 比较,nums2 [2]=1 匹配,且 dp [4][2]=2,所以 dp [5][3]=3
最终结果为 3,对应最长公共子数组 [3,2,1]
空间优化
只用一维数组也能搞定:
var findLength = function(nums1, nums2) {
let res = 0
let dp = new Array(nums2.length + 1).fill(0)
for (let i = 1; i <= nums1.length; i++) {
for (let j = nums2.length; j > 0; j--) {
if (nums1[i - 1] === nums2[j - 1]) {
dp[j] = dp[j - 1] + 1
} else {
dp[j] = 0
}
res = Math.max(res, dp[j])
}
}
return res
};
🤝 类比:两部剧集同步播放,只有剧情完全一致时才能 "连播",否则就得重新开始。
四、套路总结:连续子序列 DP 的万能钥匙
- 状态设计:以 "结尾" 为核心,dp[i] 或 dp[i][j] 表示以某元素结尾的最优解。
- 递推公式:通常是 "要么自成一派,要么和前面连起来"。
- 初始化:别忘了边界条件,通常第一个元素单独处理。
- 遍历顺序:从前往后,保证前面的状态已知。
- 空间优化:能滚动就滚动,能压缩就压缩。
五、面试 Tips & 趣味小结
- 面试官常问:"为什么要以结尾为状态?"—— 因为连续性要求我们不能跳跃,必须一环扣一环。
- 代码能简则简,空间优化是加分项。
- 多做对比,理解 "连续" 与 "不连续"DP 的区别。
🌟 最后,祝大家在算法的连续剧里,永远都是主角,永远在高光时刻!