news 2026/4/29 4:17:38

第 16 课:动态规划专题(二)—— 子序列与子数组问题:面试最高频的 DP 题型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第 16 课:动态规划专题(二)—— 子序列与子数组问题:面试最高频的 DP 题型

子序列和子数组问题是面试中出现频率最高的动态规划题型,没有之一。它们看似变化多端,但实际上都有固定的解题套路。这一课我们会彻底搞懂这两类问题的本质,掌握它们的通用模板,以后遇到任何子序列 / 子数组问题都能快速找到思路。


一、先搞清楚:子序列 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,找到其中最长严格递增子序列的长度。

用四步走拆解
  1. 定义 dp 数组dp[i]表示以nums[i]结尾的最长递增子序列的长度
    • ✅ 这是单个数组子序列问题最常用的 dp 定义方式
  2. 状态转移方程:对于每个i,遍历所有j < i
    • 如果nums[j] < nums[i],那么dp[i] = max(dp[i], dp[j] + 1)
    • 意思是:如果nums[i]可以接在nums[j]后面,那么以nums[i]结尾的最长递增子序列长度就是dp[j] + 1
  3. 初始化:所有dp[i] = 1(每个元素本身就是一个长度为 1 的子序列)
  4. 遍历顺序:从前往后遍历i,对于每个i,从前往后遍历j0 <= j < i
代码

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)

题目:给定两个字符串text1text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列,返回 0。

用四步走拆解
  1. 定义 dp 数组dp[i][j]表示text1的前i个字符和text2的前j个字符的最长公共子序列长度
    • ✅ 这是两个数组 / 字符串子序列问题最常用的 dp 定义方式
  2. 状态转移方程
    • 如果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 个字符的最大值)
  3. 初始化dp[0][j] = 0dp[i][0] = 0(空字符串的公共子序列长度为 0)
  4. 遍历顺序:从前往后遍历ij
代码

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. 编辑距离

题目:给你两个单词word1word2,请你计算出将word1转换成word2所使用的最少操作数。你可以对一个单词进行如下三种操作:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符
  • 解法:dp 定义和 LCS 完全一样,只是状态转移方程不同。
衍生题 2:LeetCode 115. 不同的子序列

题目:给定一个字符串s和一个字符串t,计算在s的子序列中t出现的个数。

  • 解法:同样是二维 dp 数组,状态转移方程略有不同。

三、子数组问题:动态规划 vs 滑动窗口

子数组问题因为元素是连续的,所以有两种常用解法:

  1. 动态规划:适用于求最值、求和等问题
  2. 滑动窗口:适用于求满足条件的最长 / 最短子数组长度、子数组个数等问题

题型 1:动态规划解法

母题:LeetCode 53. 最大子数组和

题目:给你一个整数数组nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

用四步走拆解
  1. 定义 dp 数组dp[i]表示以nums[i]结尾的最大子数组和
  2. 状态转移方程dp[i] = max(nums[i], dp[i-1] + nums[i])
    • 意思是:以nums[i]结尾的最大子数组和,要么是nums[i]本身,要么是dp[i-1] + nums[i](接在前面的子数组后面)
  3. 初始化dp[0] = nums[0]
  4. 遍历顺序:从前往后遍历
代码

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

运行

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}")

四、解题技巧总结

子序列问题解题模板

  1. 单个数组
    • dp 定义:dp[i]表示以nums[i]结尾的 xxx
    • 状态转移:遍历j < i,根据nums[j]nums[i]的关系更新dp[i]
  2. 两个数组 / 字符串
    • dp 定义:dp[i][j]表示arr1i个元素和arr2j个元素的 xxx
    • 状态转移:根据arr1[i-1]arr2[j-1]的关系更新dp[i][j]

子数组问题解题模板

  1. 求最值、求和:用动态规划,dp[i]表示以nums[i]结尾的 xxx
  2. 求满足条件的长度、个数:用滑动窗口,时间复杂度 O (n)
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/29 4:16:54

LED驱动电路设计与NCL30051控制器应用详解

1. LED驱动电路设计基础与NCL30051概述在商业照明领域&#xff0c;LED驱动电路的设计直接关系到整个照明系统的性能和可靠性。与传统照明相比&#xff0c;LED照明具有更高的能效和更长的使用寿命&#xff0c;但其驱动电路的设计也更为复杂。LED作为电流驱动型器件&#xff0c;需…

作者头像 李华
网站建设 2026/4/29 4:12:16

NVIDIA NIM微服务:RTX AI PC上的生成式AI开发新范式

1. NVIDIA NIM微服务&#xff1a;RTX AI PC上的生成式AI开发新范式生成式AI正在重塑我们与PC交互的方式。从数字人到智能代理&#xff0c;从播客生成到视频创作&#xff0c;这些新兴应用场景对开发者提出了全新挑战。NVIDIA最新推出的NIM&#xff08;NVIDIA Inference Microser…

作者头像 李华
网站建设 2026/4/29 4:11:13

BLIKVM开源KVM over IP方案解析与部署指南

1. BLIKVM开源KVM over IP方案解析作为一名长期从事远程运维管理的工程师&#xff0c;我一直在寻找低成本、高可靠性的带外管理方案。传统IPMI方案价格昂贵&#xff0c;而基于树莓派的KVM over IP方案正好填补了这一空白。BLIKVM作为PiKVM项目的分支&#xff0c;提供了更加灵活…

作者头像 李华
网站建设 2026/4/29 4:09:47

五分钟带你认识并安装使用OpenSpec

随着AI 的野蛮发展&#xff0c;随之孵化出来各种新概念、新技能、新模式也是层出不穷前有vibecoding&#xff0c;后有claude &#xff0c;前有cursor3 后有小龙虾&#xff0c;前有SKILL 后有dify&#xff0c;前后MCP 后有langgraph/langchain……&#xff08;名词不分先后&…

作者头像 李华