子序列和子数组问题是面试中出现频率最高的动态规划题型,没有之一。它们看似变化多端,但实际上都有固定的解题套路。这一课我们会彻底搞懂这两类问题的本质,掌握它们的通用模板,以后遇到任何子序列 / 子数组问题都能快速找到思路。
一、先搞清楚:子序列 vs 子数组
这是最容易混淆的两个概念,必须先分清楚:
- 子数组(Subarray) :数组中连续 的一段元素
- 例:数组
[1,2,3,4]的子数组有[1,2]、[2,3,4],但[1,3]不是子数组
- 例:数组
- 子序列(Subsequence) :数组中不连续但保持相对顺序 的一段元素
- 例:数组
[1,2,3,4]的子序列有[1,3]、[2,4]、[1,3,4]
- 例:数组
✅ 记住:子数组一定是子序列,但子序列不一定是子数组。
二、子序列问题:动态规划的主战场
子序列问题是动态规划最擅长解决的问题,因为它天然满足重叠子问题 和最优子结构。
题型 1:单个数组的子序列问题
母题:LeetCode 300. 最长递增子序列(LIS)
题目 :给你一个整数数组nums,找到其中最长严格递增子序列的长度。
用四步走拆解
- 定义 dp 数组 :
dp[i]表示以nums[i]结尾的最长递增子序列的长度- ✅ 这是单个数组子序列问题最常用的 dp 定义方式
- 状态转移方程 :对于每个
i,遍历所有j < i:- 如果
nums[j] < nums[i],那么dp[i] = max(dp[i], dp[j] + 1) - 意思是:如果
nums[i]可以接在nums[j]后面,那么以nums[i]结尾的最长递增子序列长度就是dp[j] + 1
- 如果
- 初始化 :所有
dp[i] = 1(每个元素本身就是一个长度为 1 的子序列) - 遍历顺序 :从前往后遍历
i,对于每个i,从前往后遍历j(0 <= j < i)
代码
python
运行
python
def lengthOfLIS(nums):
"""
计算最长递增子序列的长度
核心思路:动态规划 (Dynamic Programming)
"""
# 边界情况处理:如果数组为空,长度为0
if not nums:
return 0
n = len(nums)
# 1. 定义并初始化 dp 数组
# dp[i] 的定义:以 nums[i] 这个数结尾的最长递增子序列的长度
# 初始化:每个元素本身至少可以构成一个长度为 1 的子序列
dp = [1] * n
print(f"初始数组: {nums}")
print(f"初始DP表: {dp} (每个位置初始长度为1)\n")
# 2. 开始填表
# 外层循环:遍历每一个元素,把它当作"结尾"
for i in range(n):
# 内层循环:在 i 之前查找所有比 nums[i] 小的元素 nums[j]
for j in range(i):
# 只有当 nums[i] > nums[j] 时,nums[i] 才能接在 nums[j] 后面形成更长的递增序列
if nums[i] > nums[j]:
# 状态转移方程:
# 更新 dp[i],看是保持原来的长度大,还是接在 dp[j] 后面(长度+1)更大
dp[i] = max(dp[i], dp[j] + 1)
# 打印每一步 i 结束后的 dp 状态(方便理解过程)
print(f"处理到下标 i={i} (值={nums[i]}) 后的DP表: {dp}")
# 3. 返回结果
# 注意:最长递增子序列不一定以最后一个元素 nums[n-1] 结尾
# 所以我们要返回整个 dp 数组中的最大值
return max(dp)
# --- 测试代码 ---
if __name__ == "__main__":
# 示例输入
nums_input = [10, 9, 2, 5, 3, 7, 101, 18]
# 调用函数
result = lengthOfLIS(nums_input)
print("-" * 30)
print(f"最终结果: 最长递增子序列的长度为 {result}")
# 预期输出逻辑解释:
# 其中一个最长递增子序列是 [2, 3, 7, 18] 或者 [2, 3, 7, 101],长度为 4。
优化:二分法(时间复杂度 O (nlogn))
上面的解法时间复杂度是 O (n²),对于大数据量会超时。有一个更优的二分法解法,时间复杂度 O (nlogn),面试中如果能写出来会非常加分。
衍生题 1:LeetCode 674. 最长连续递增序列
题目 :给定一个未经排序的整数数组,找到最长且连续递增的子序列的长度。
- 这其实是一个子数组问题,解法比 LIS 简单得多,只需要一次遍历即可。
衍生题 2:LeetCode 354. 俄罗斯套娃信封问题
题目 :给你一个二维整数数组envelopes,其中envelopes[i] = [w_i, h_i]表示第 i 个信封的宽度和高度。如果另一个信封的宽度和高度都比这个信封大,那么这个信封就可以放进另一个信封里。请问最多能有多少个信封组成一组 "俄罗斯套娃" 信封?
- 解法:先按宽度升序排序,宽度相同则按高度降序排序,然后问题转化为求高度数组的最长递增子序列长度。
题型 2:两个数组的子序列问题
母题:LeetCode 1143. 最长公共子序列(LCS)
题目 :给定两个字符串text1和text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列,返回 0。
用四步走拆解
- 定义 dp 数组 :
dp[i][j]表示text1的前i个字符和text2的前j个字符的最长公共子序列长度- ✅ 这是两个数组 / 字符串子序列问题最常用的 dp 定义方式
- 状态转移方程 :
- 如果
text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1(两个字符相同,公共子序列长度加 1) - 如果
text1[i-1] != text2[j-1]:dp[i][j] = max(dp[i-1][j], dp[i][j-1](取去掉 text1 第 i 个字符或去掉 text2 第 j 个字符的最大值)
- 如果
- 初始化 :
dp[0][j] = 0,dp[i][0] = 0(空字符串的公共子序列长度为 0) - 遍历顺序 :从前往后遍历
i和j
代码
python
运行
python
def longestCommonSubsequence(text1, text2):
"""
计算两个字符串的最长公共子序列长度
"""
m, n = len(text1), len(text2)
# 1. 初始化 DP 表格
# 创建一个 (m+1) x (n+1) 的二维数组,初始值为 0
# 多出来的一行一列(第0行和第0列)代表空字符串的情况,方便处理边界
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 2. 填表过程
# i 代表 text1 的前 i 个字符,j 代表 text2 的前 j 个字符
for i in range(1, m + 1):
for j in range(1, n + 1):
# 注意:dp[i][j] 对应的是 text1[i-1] 和 text2[j-1](因为 dp 多了一行一列)
if text1[i - 1] == text2[j - 1]:
# 情况 A:字符相同
# 当前长度 = 左上角的值 + 1
dp[i][j] = dp[i - 1][j - 1] + 1
else:
# 情况 B:字符不同
# 当前长度 = max(上方的值, 左方的值)
# 意味着:要么忽略 text1 的这个字符,要么忽略 text2 的这个字符,取最大值
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 返回右下角的值,即两个完整字符串的最长公共子序列长度
return dp[m][n]
# --- 示例测试与运行 ---
if __name__ == "__main__":
# 示例 1
s1 = "abcde"
s2 = "ace"
# 预期输出:3 ("ace" 是公共子序列)
result1 = longestCommonSubsequence(s1, s2)
print(f"字符串 1: '{s1}'")
print(f"字符串 2: '{s2}'")
print(f"最长公共子序列长度: {result1}")
print("-" * 30)
# 示例 2
s3 = "abc"
s4 = "def"
# 预期输出:0 (没有公共子序列)
result2 = longestCommonSubsequence(s3, s4)
print(f"字符串 1: '{s3}'")
print(f"字符串 2: '{s4}'")
print(f"最长公共子序列长度: {result2}")
✅ 这是所有两个字符串子序列问题的通用模板,几乎所有这类问题都是在这个模板上修改的。
衍生题 1:LeetCode 72. 编辑距离
题目 :给你两个单词word1和word2,请你计算出将word1转换成word2所使用的最少操作数。你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
- 解法:dp 定义和 LCS 完全一样,只是状态转移方程不同。
衍生题 2:LeetCode 115. 不同的子序列
题目 :给定一个字符串s和一个字符串t,计算在s的子序列中t出现的个数。
- 解法:同样是二维 dp 数组,状态转移方程略有不同。
三、子数组问题:动态规划 vs 滑动窗口
子数组问题因为元素是连续的,所以有两种常用解法:
- 动态规划:适用于求最值、求和等问题
- 滑动窗口:适用于求满足条件的最长 / 最短子数组长度、子数组个数等问题
题型 1:动态规划解法
母题:LeetCode 53. 最大子数组和
题目 :给你一个整数数组nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
用四步走拆解
- 定义 dp 数组 :
dp[i]表示以nums[i]结尾的最大子数组和 - 状态转移方程 :
dp[i] = max(nums[i], dp[i-1] + nums[i])- 意思是:以
nums[i]结尾的最大子数组和,要么是nums[i]本身,要么是dp[i-1] + nums[i](接在前面的子数组后面)
- 意思是:以
- 初始化 :
dp[0] = nums[0] - 遍历顺序:从前往后遍历
代码
python
运行
python
def maxSubArray(nums):
"""
使用动态规划解决最大子数组和问题
"""
n = len(nums)
if n == 0:
return 0
# 1. 定义 dp 数组
# dp[i] 表示:以 nums[i] 这个数结尾的连续子数组的最大和
dp = [0] * n
# 2. 初始化
# 第一个元素结尾的子数组只能是它自己
dp[0] = nums[0]
print(f"数组: {nums}")
print(f"dp[0] = {nums[0]} (初始化)")
# 3. 状态转移(从第二个元素开始遍历)
for i in range(1, n):
# 核心逻辑:
# 如果 dp[i-1] 是正数,说明前面的累积对我们有帮助,那就加上它。
# 如果 dp[i-1] 是负数,说明前面的累积是累赘,不如直接从 nums[i] 重新开始。
dp[i] = max(nums[i], dp[i - 1] + nums[i])
print(f"i={i} (值={nums[i]}): max({nums[i]}, {dp[i-1]} + {nums[i]}) = {dp[i]}")
# 4. 返回结果
# 注意:最大子数组和不一定以最后一个元素结尾,所以要在整个 dp 数组中找最大值
result = max(dp)
print(f"DP表状态: {dp}")
print(f"最大子数组和: {result}")
return result
# --- 测试运行 ---
if __name__ == "__main__":
# 示例输入:经典的负数陷阱案例
# 预期结果:6 (对应的子数组是 [4, -1, 2, 1])
input_data = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
print("--- 开始计算 ---")
maxSubArray(input_data)
空间优化
因为dp[i]只依赖于dp[i-1],所以可以用一个变量代替整个 dp 数组,空间复杂度优化到 O (1)。
衍生题:LeetCode 152. 乘积最大子数组
题目 :给你一个整数数组nums,请你找出数组中乘积最大的非空连续子数组,并返回该子数组所对应的乘积。
- 解法:和最大子数组和类似,但需要同时维护最大乘积和最小乘积,因为负数乘以负数会变成正数。
题型 2:滑动窗口解法
滑动窗口解法的时间复杂度是 O (n),比动态规划更高效,适用于所有满足单调性的子数组问题。
适用场景
- 求满足条件的最长子数组长度
- 求满足条件的最短子数组长度
- 求满足条件的子数组个数
经典例题:LeetCode 209. 长度最小的子数组
题目 :给定一个含有n个正整数的数组和一个正整数target。找出该数组中满足其和>= target的长度最小的连续子数组,并返回其长度。如果不存在满足条件的子数组,返回 0。
代码
python
运行
python
def minSubArrayLen(target, nums):
"""
使用滑动窗口法寻找和 >= target 的最短子数组长度
"""
n = len(nums)
if n == 0:
return 0
left = 0 # 滑动窗口左边界
current_sum = 0 # 当前窗口内的元素和
min_len = float('inf') # 记录最小长度,初始化为无穷大
print(f"目标值 (target): {target}")
print(f"数组 (nums): {nums}")
print("-" * 30)
# 1. 右指针向右移动,扩大窗口
for right in range(n):
current_sum += nums[right]
# 打印当前窗口状态
# print(f"窗口扩大: 右边界={right}, 当前和={current_sum}")
# 2. 当窗口内的和满足条件 (>= target) 时,尝试收缩左边界
while current_sum >= target:
# 更新最小长度:当前窗口大小为 (right - left + 1)
min_len = min(min_len, right - left + 1)
# 打印找到的符合条件窗口
print(f"找到解: 区间 [{left}, {right}], 和={current_sum}, 长度={right-left+1}")
# 准备移动左边界:先减去左边界的值
current_sum -= nums[left]
# 左边界右移
left += 1
# 3. 返回结果
# 如果 min_len 没变过(仍为无穷大),说明没有找到,返回 0
return min_len if min_len != float('inf') else 0
# --- 测试运行 ---
if __name__ == "__main__":
# 示例 1
t1 = 7
n1 = [2, 3, 1, 2, 4, 3]
print("=== 测试用例 1 ===")
res1 = minSubArrayLen(t1, n1)
print(f"最终结果: {res1}")
print("\n")
# 示例 2
t2 = 11
n2 = [1, 1, 1, 1, 1, 1, 1, 1]
print("=== 测试用例 2 ===")
res2 = minSubArrayLen(t2, n2)
print(f"最终结果: {res2}")
四、解题技巧总结
子序列问题解题模板
- 单个数组 :
- dp 定义:
dp[i]表示以nums[i]结尾的 xxx - 状态转移:遍历
j < i,根据nums[j]和nums[i]的关系更新dp[i]
- dp 定义:
- 两个数组 / 字符串 :
- dp 定义:
dp[i][j]表示arr1前i个元素和arr2前j个元素的 xxx - 状态转移:根据
arr1[i-1]和arr2[j-1]的关系更新dp[i][j]
- dp 定义:
子数组问题解题模板
- 求最值、求和 :用动态规划,
dp[i]表示以nums[i]结尾的 xxx - 求满足条件的长度、个数:用滑动窗口,时间复杂度 O (n)