动态规划之 “连续” 的艺术:子序列问题的剧情式解析

215 阅读7分钟

"人生就像一场连续剧,有高潮也有低谷。动态规划中的子序列问题,也总在寻找那段最精彩的连续剧情。"

一、前言:连续的美好,算法的追寻

你是否曾在刷题时遇到这样的问题:给你一串数字,问你 "最长的连续递增子序列有多长?"、"最大连续子数组和是多少?"、"两个数组里最长的重复连续片段有多长?"

别慌,这些问题其实都属于 "连续子序列" 类的动态规划问题。今天,我们就来聊聊这类问题的套路和精髓。


二、动态规划基础回顾

动态规划(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 数组的变化过程如下:

索引 inums[i]dp [i] 计算过程dp [i] 值当前最大值 res
0-2初始值-2-2
11max(1, -2+1)=max(1,-1)=111
2-3max(-3, 1+(-3))=max(-3,-2)=-2-21
34max(4, -2+4)=max(4,2)=444
4-1max(-1, 4+(-1))=max(-1,3)=334
52max(2, 3+2)=max(2,5)=555
61max(1, 5+1)=max(1,6)=666
7-5max(-5, 6+(-5))=max(-5,1)=116
84max(4, 1+4)=max(4,5)=556

最终结果为 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 转移过程
空间优化 DPO(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 数组的变化过程如下:

索引 inums[i]与前一个元素比较dp [i] 计算过程dp [i] 值当前最大值 res
01初始值11
133 > 1dp[0] + 122
255 > 3dp[1] + 133
344 < 5重置为 113
477 > 4dp[3] + 123

最终结果为 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 的万能钥匙

  1. 状态设计:以 "结尾" 为核心,dp[i] 或 dp[i][j] 表示以某元素结尾的最优解。
  1. 递推公式:通常是 "要么自成一派,要么和前面连起来"。
  1. 初始化:别忘了边界条件,通常第一个元素单独处理。
  1. 遍历顺序:从前往后,保证前面的状态已知。
  1. 空间优化:能滚动就滚动,能压缩就压缩。

五、面试 Tips & 趣味小结

  • 面试官常问:"为什么要以结尾为状态?"—— 因为连续性要求我们不能跳跃,必须一环扣一环。
  • 代码能简则简,空间优化是加分项。
  • 多做对比,理解 "连续" 与 "不连续"DP 的区别。

🌟 最后,祝大家在算法的连续剧里,永远都是主角,永远在高光时刻!