news 2026/10/10 7:47:05

子序列动态规划四题解析:从最长公共子序列到最大子序和

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
子序列动态规划四题解析:从最长公共子序列到最大子序和

第43天,代码随想录算法营正式进入子序列动态规划的深水区。今天的四道题是1143.最长公共子序列、1035.不相交的线、53.最大子序和、392.判断子序列。前两题是标准的二维DP,第三题是经典的一维DP,第四题则是“最长公共子序列”的退化版本。一天刷下来,最大的感受是:子序列类DP的边界条件才是最考验人的地方,很多思路看起来顺理成章,但真正落到dp数组的递推上,稍不留神就会写出一个能通过样例、却经不起推敲的解。

如果你正在刷代码随想录,或者正处于动态规划专项的中期,这四道题值得放在一起反复咀嚼。它们彼此之间有很强的递进关系:1143是基础模板,1035是模板的换皮,392是模板的特殊情形,53则是另一个维度的一维DP。把这几题的异同点理清楚,比单独刷十道不相关的DP题都有用。

1. 为什么这四道题适合放在同一天消化

我最初看到当天的题单时,其实有点疑惑:1143和1035明显是亲兄弟,392看起来也逃不出1143的框架,但53“最大子序和”混进来,画风不太一样。刷完才明白,这四道题刚好覆盖了子序列DP里最常见的三类状态设计思路:“二维前缀DP”“一维结尾DP”“贪心取最优位置”。

先说1143、1035、392这一组。它们本质上都在做同一件事:判断或计算两个序列之间按顺序匹配能达到的最大效果。区别只在于最终要问的是什么:1143问最长公共子序列的长度,1035问最多能画出多少条不相交的线,392问较短的字符串能不能完整出现在较长的字符串里。这三题的递推公式长得很像,但“为什么这么递推”的解释角度完全不同。1035如果没有意识到它等价于最长公共子序列,很容易陷入几何相交性的泥潭;392如果只会双指针,就体会不到它在DP框架里的独特地位。

再看53。它是一个一维数组上的子序列问题,但“连续”这个限制让它的状态定义和前面的题完全不同。前面三题的状态都是“两个序列各看到前几位”,而53只需要一个“以当前元素结尾”的局部状态。它不复杂,但它能帮你认清一个容易被忽略的事实:DP的状态不一定非得是“从头到当前位置的整体结果”,也可以是“强制包含当前位置的结果”。这个区别,在处理很多后续题目时特别关键。

所以这一天的题单设计得很好:先用二维DP建立“子序列匹配”的直觉,再用一个一维题打破你对状态定义的固有认知,最后用392让你回头重新审视第一道题,发现同一个模板可以被简化到什么程度。

2. 1143. 最长公共子序列:先搞定最标准的二维DP

2.1 状态定义:dp[i][j]到底代表什么

很多人在写1143时一上来就定义dp[i][j]表示“text1的前i个字符和text2的前j个字符之间的最长公共子序列长度”。这个定义本身没问题,但要注意它隐含了一个偏移:i=0或j=0都代表空字符串,所以实际比较字符时用的是text1[i-1]和text2[j-1]。

为什么非要这样偏移?因为动态规划的边界必须覆盖空串的情况。如果不偏移,dp[0][0]会变成“第一个字符和第一个字符的最长公共子序列”,空串的边界无法表达,最终初始化会非常别扭。偏移一位之后,dp[0][j]=0和dp[i][0]=0的意义很清楚:某一方为空,公共子序列长度只能是0。

状态定义想清楚之后,整个递推就顺理成章了。

2.2 转移方程:为什么相等时加一,不相等时取max

当text1[i-1] == text2[j-1]时,说明当前这两个字符可以拼接到已经匹配好的公共子序列尾部,所以:

dp[i][j] = dp[i-1][j-1] + 1

这里有个容易纠结的点:为什么不是max(dp[i-1][j], dp[i][j-1]) + 1?因为如果这两个字符相等,它们可以同时成为公共子序列的最后一个字符,而dp[i-1][j-1]已经天然包含了“前面所有可能的最优匹配”,直接加1就是最优解。如果再去比较dp[i-1][j]和dp[i][j-1],反而可能重复计算字符,甚至破坏子序列的顺序性。

当两个字符不相等时,情况就不同了。text1[i-1]和text2[j-1]不可能同时作为公共子序列的末尾,所以只能选择其中之一跳过:

dp[i][j] = max(dp[i-1][j], dp[i][j-1])

dp[i-1][j]的含义是“text1的前i-1个字符和text2的前j个字符的最长公共子序列”,相当于把text1[i-1]这个字符丢弃;dp[i][j-1]则是丢弃text2[j-1]。取两者的最大值,就能保证在“当前字符不匹配”时,我们的结果不会倒退。

def longestCommonSubsequence(text1: str, text2: str) -> int: m, n = len(text1), len(text2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if text1[i - 1] == text2[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return dp[m][n]

这个代码结构就是整个子序列二维DP的基础模板,后面两道题基本上是在这个模板上做减法或换说法。

2.3 和“最长公共子串”的区别要单独拎出来

很多人会把1143和“最长公共子串”混在一起,两者虽然看起来只差“连续”两个字,但转移方程完全不同。公共子串要求字符在原始字符串里连续出现,所以一旦不匹配,长度就要清零;而公共子序列允许中间跳过字符,不匹配时只需要取前面的最大值继续传递。

放在生活场景里理解:子序列像“保留顺序的交友匹配”,你可以跳过不喜欢的人;子串像“必须站在一起的朋友”,一旦中间有人走开,组合就断了。1143这种“可以跳”的性质,是后面1035“不相交的线”能够转化的关键前提。

3. 1035. 不相交的线:一个让你重新认识LCS的改编题

3.1 题目描述里的几何,只是伪装

1035的题目给了一个很形象的场景:两个数组上下排列,把相等的数字连成线,求最多能连多少条线,且线不能交叉。

第一次看到这个题时,我的第一反应是考虑线段的几何性质,甚至想用区间重叠来判断。真正动手之后才发现,这题根本没有所谓的几何逻辑,它彻头彻尾就是1143最长公共子序列。

为什么?因为“连线不相交”等价于“两条线对应的下标在两个数组里都保持递增顺序”。你在nums1中选了某个下标的数字,又在nums2中选了对应数字,两条线如果相交,就意味着至少有一对匹配的先后顺序发生了反转。换句话说,一组不相交的线,恰好就是两个数组的一个公共子序列。那要求“最多不相交的线”,自然就是求最长公共子序列的长度。

想通这一点之后,连代码都几乎不用改。把1143里的两个字符串换成两个数组即可:

def maxUncrossedLines(nums1, nums2) -> int: m, n = len(nums1), len(nums2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if nums1[i - 1] == nums2[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return dp[m][n]

3.2 为什么相等时加一,不相等时取max

如果只停留在“抄代码”的层面,这道题就浪费了。它真正的价值是帮你建立一种“问题转化”的直觉:凡是让你匹配两个序列、且限制条件与顺序相关,不妨先想想能不能归约成公共子系列。

1035里的“不相交”实际上是一个非常强的顺序约束。任何交点,本质上是在说:原本在前面的匹配,在另一个数组里反而落到了后面。这不就是子序列顺序被打破了吗?所以只要把“线不相交”翻译成“选出的下标序列在两边都单调递增”,答案就浮出水面了。

3.3 用一个状态图辅助理解递推

如果递推导不明白,可以画一个m+1行n+1列的表格。从左到右、从上到下填数,规则只有两条:当前数值相同时,取左上角加一;不相同时,取上方和左方的较大值。

我刷这道题时特意对照1143在表格上多填了两遍。最后总结出一个记忆点:二维子序列DP里,dp[i][j]永远是“已考虑的前缀”层面的结果,而不是“恰好以某个字符结尾”的结果。所以即使最后两个字符不匹配,前面的最优结果也能完全保留下来,不会像公共子串那样断掉。

如果你能把这个点彻底想清楚,后面再做编辑距离、正则匹配之类更复杂的字符串DP,会轻松不少。

4. 53. 最大子序和:一维DP也值得讲透

4.1 状态定义:以nums[i]结尾,而不是前i个元素

53题太经典了,以至于很多人直接背Kadane算法:遍历数组,维护一个当前和,如果当前和变成负数就重置为0,过程中记录最大值。这个贪心做法是对的,但在算法营里,我更推荐先写DP版本,因为只有理解了状态定义,才能真正明白“为什么负数要重置”。

DP版本的状态定义很特别:

dp[i]表示“以nums[i]为结尾的连续子数组的最大和”。

注意,这里不是“前i个元素的最大子数组和”。如果定义成后者,递推会比较别扭,因为你不知道前面那个最大子数组到底在哪儿结束,也就不知道能不能接上nums[i]。而定义成“以当前元素结尾”之后,转移变得非常清晰:

dp[i] = max(nums[i], dp[i-1] + nums[i])

这个式子的含义是:要么从nums[i]重新开始一个新的子数组,要么把nums[i]接到前面的最优子数组后面。二者取较大值。

4.2 为什么“从nums[i]重新开始”的条件是nums[i]本身

很多教材把转移写成dp[i] = max(dp[i-1] + nums[i], nums[i]),二者完全等价。但真正理解它,需要想清楚一个反直觉的点:为什么不是max(dp[i-1], dp[i-1] + nums[i])?

因为dp[i]必须包含nums[i]。题目要求连续子数组,而且状态定义强制以nums[i]结尾。如果取dp[i-1],得到的子数组根本不含nums[i],就没有意义。这也是为什么dp[i]并不是最终答案,最终答案是max(dp)—— 因为最优子数组可能结束在任意位置。

def maxSubArray(nums) -> int: dp = [0] * len(nums) dp[0] = nums[0] result = nums[0] for i in range(1, len(nums)): dp[i] = max(nums[i], dp[i - 1] + nums[i]) result = max(result, dp[i]) return result

更进一步,因为dp[i]只依赖dp[i-1],所以可以只用一个变量滚动更新:

def maxSubArray(nums) -> int: cur = nums[0] result = nums[0] for i in range(1, len(nums)): cur = max(nums[i], cur + nums[i]) result = max(result, cur) return result

这个滚动版本就是大家常说的Kadane算法,只不过很多人只记住了“和为负就重置”,没有意识到它背后的状态含义。

4.3 一个很容易踩的坑:把dp[i]当成全局最优

我当初做这题时卡了很久,因为我始终试图把dp[i]定义成“前i个元素的最大子序和”,然后递推时发现nums[i]不知道该不该加进去。如果前一个最优子数组的结束位置不在i-1,那强行把nums[i]加上去就破坏了连续性。

这个纠结恰恰说明,“以当前元素结尾”这个强制条件不是可有可无的。它牺牲了一点“全局感”,换来了递推时的无后效性。动态规划里的“无后效性”这个词,在53这道题上体现得最直观——你根本不需要关心之前的子数组是从哪里开始的,只需要知道以i-1结尾的最优和是多少。

5. 392. 判断子序列:双指针解法与“简化版LCS”的DP写法

5.1 双指针解法为什么有效

392问的是,字符串s是不是字符串t的子序列。最朴素的想法是用双指针:一个指针遍历s,另一个遍历t,每当在t中找到s当前字符,就移动s的指针,然后将t的指针继续向后。

def isSubsequence(s: str, t: str) -> bool: i, j = 0, 0 while i < len(s) and j < len(t): if s[i] == t[j]: i += 1 j += 1 return i == len(s)

这个做法之所以正确,是因为一个贪心性质:匹配s的字符时,能匹配则匹配,且匹配位置越靠前越好。因为在t中越早匹配,后续可用的字符范围就越大,永远不会让结果变差。如果这种“尽早匹配”的策略都找不到完整子序列,那换用任何延迟匹配的策略也不可能成功。

5.2 从1143的视角看:判断子序列其实是LCS的特例

如果只教双指针,这题几分钟就结束了。但放在“最长公共子序列”这一天的题单里,你必须学会从DP视角再解一次。

回忆1143的递推:

  • 字符相等时dp[i][j] = dp[i-1][j-1] + 1
  • 字符不等时dp[i][j] = max(dp[i-1][j], dp[i][j-1])

对于判断子序列来说,因为我们要判断s能不能完整出现在t中,所以s的每个字符都必须被匹配。这意味着,当s[i-1] != t[j-1]时,我们只能选择“跳过t中的当前字符”,而不能选择“跳过s中的当前字符”。于是转移简化为:

dp[i][j] = dp[i][j-1]

最终,如果dp[m][n] == len(s),说明s被完整匹配了。写成代码:

def isSubsequence(s: str, t: str) -> bool: m, n = len(s), len(t) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if s[i - 1] == t[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = dp[i][j - 1] return dp[m][n] == m

当然,这个DP版本的复杂度是O(mn),远不如双指针的O(n)。但它的意义在于让你看到,1143的模板稍作改动,就能覆盖一类“少了一个自由度”的问题。以后遇到更复杂的变体时,你不会因为没接触过而手足无措。

5.3 进阶思路:预处理t的每个字符位置

LeetCode的392还有一道进阶题:如果输入中有大量s,每个s都要判断是不是t的子序列,怎么优化到 O(len(s) * 某种常数) 级别。

这就是经典的“记忆化后续位置”问题。你可以预处理t,建立一个二维数组next_pos[j][26],表示从t[j]这个位置开始,每个字母下一次出现的位置。之后对于任意一个s,直接在预处理表里跳着找字符即可。

虽然这道题的进阶版本不一定在算法营里出现,但如果你已经吃透了392的DP写法,理解这个预处理思路会变得非常自然:它本质上还是“顺序匹配”,只是把查找效率从线性扫描变成了O(1)跳转。

6. 四题连做之后的三个关键复盘

6.1 复盘一:二维子序列DP的“跳过”是怎么在转移里实现的

很多人会把1143的不相等转移公式记成“取max”,但不知道取max到底在干什么。其实dp[i-1][j]代表“跳过text1的当前字符”,dp[i][j-1]代表“跳过text2的当前字符”。

如果你能把这个“跳过”的动作具象化,1035和392都不再需要背公式。1035的不等转移和1143一模一样,因为它可以从两个数组里任意跳;392的不等转移只剩下“跳过t”,因为s的字符不能被跳过,否则s就不是完整匹配的子序列了。

6.2 复盘二:“不相交”类问题的识别技巧

1035的价值在于问题转化。以后看到“两个序列连线”“配对但不能交叉”“保持相对顺序”这类描述时,第一反应应该是公共子序列。

这个识别过程完全可以靠平时训练积累。我在做完1035之后,特意把LCS、编辑距离、最长递增子序列这几个题的题干放在一起对比。它们都有一个共同特征:要求在保持原有顺序的前提下做匹配。不同点只是“匹配的代价和奖励规则”。掌握这层共性之后,再遇到新的字符串DP,你的第一反应就不再是背模板,而是思考“这题比标准LCS多了什么限制或少了什么选择”。

6.3 复盘三:53的“结尾状态”是很多数组DP的基础

最后再回到53。很多人觉得它简单,随手写出Kadane算法,然后就过了。但如果我把这题改成“返回最大子数组的起始和结束下标”,你能立刻改出来吗?

如果理解了dp[i]是“以 i 结尾”的最优子数组,而最终答案是max(dp),那么只要在更新最大值的同时记录当前子数组的起止位置,就能轻松扩展。这个“结尾状态”的建模方式,在后面股票买卖类问题、打家劫舍类问题里还会反复出现。它和1143的“前缀状态”是两种完全不同的建模视角,但都极其常用。

今天这四道题刷下来,最值得做的收尾工作不是看题解,而是合上代码,在纸上默写每个题的转移方程,并写出每个方程对应“跳过了哪个字符、保留了哪个前缀”。能无卡顿地写出来,才算把这一天的内容真正消化了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 7:46:35

基于移动互联网的检测实验室广告云服务平台设计与落地实践

做检测实验室相关的系统&#xff0c;最头疼的往往不是技术本身&#xff0c;而是业务逻辑的梳理。尤其是涉及广告服务这种面向市场端的场景&#xff0c;客户线索、订单排期、素材审核、数据回传&#xff0c;每一环都牵扯到不同角色的协作。我自己做过几个类似的信息化项目&#…

作者头像 李华
网站建设 2026/10/10 7:46:15

【学习记录】电子电路基础七定律:电压、电流、电阻、电容、功率、欧姆定律与分压定律

【学习记录】电子电路基础七定律&#xff1a;电压、电流、电阻、电容、功率、欧姆定律与分压定律 在嵌入式硬件设计中&#xff0c;电压、电流、电阻、电容、功率、欧姆定律和分压定律是最基础的七个概念。它们看似简单&#xff0c;但很多工程师在排查电路问题时&#xff0c;往往…

作者头像 李华
网站建设 2026/10/10 7:45:50

SpringBoot2+Vue3前后端分离宠物店系统:架构设计与实战解析

搞Java后端的同学应该都有这种体验&#xff1a;项目源码网上能找到不少&#xff0c;但能完整跑通的不多&#xff0c;带文档的更少&#xff0c;带文档还能做到前后端分离、技术栈不过时的就少之又少了。这套网上宠物店系统属于少数能让我本地几分钟内就启动起来的项目。SpringBo…

作者头像 李华
网站建设 2026/10/10 7:45:31

SpringBoot智慧乡村治理平台开发全解析:源码部署与核心技术实践

1. 项目到底在做什么&#xff1a;智慧乡村治理平台的完整定义1.1 从一个毕业设计标题里读出什么"基于SpringBoot的智慧乡村治理平台系统&#xff08;源码lw部署文档讲解等&#xff09;"&#xff0c;这种标题在高校毕业设计、课程设计或者程序员接私活的场景里非常常见…

作者头像 李华
网站建设 2026/10/10 7:45:24

EmbeddedWB在Delphi 12.3中的编译安装与实战指南

简介&#xff1a;面向Delphi开发者的EmbeddedWB控件完整源代码包&#xff0c;覆盖D5至XE12版本&#xff0c;基于WebBrowser技术实现嵌入式网页浏览与交互&#xff0c;适合需要在桌面应用中内嵌页面、抓取网页数据或自定义浏览器行为的开发场景。压缩包共226个文件&#xff0c;大…

作者头像 李华
网站建设 2026/10/10 7:45:05

数据结构课设高分攻略:从选题、设计到答辩的完整路线

简介&#xff1a;湖南科技大学计算机科学与工程学院数据结构课程设计报告&#xff0c;完整覆盖第二学期课设的核心项目。内容依次涉及复杂度分析、Josephus问题、单词检查&#xff08;顺序表/二叉排序树/Hash表&#xff09;、后缀表达式求值、中缀转后缀、二叉树的创建与文本显…

作者头像 李华