ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

序列DP从入门到进阶:状态设计、转移优化与实战踩坑指南

序列DP从入门到进阶:状态设计、转移优化与实战踩坑指南 序列DP到底难在哪先抛个观点动态规划本身不难难的是你把它用对地方。而序列DP恰恰是动态规划里最基础、同时也是最能看出功力的那类问题。它要求你把一个多维的决策过程拆成一串一维的决策点用状态把走到哪一步、选择了什么、积累了什么完整描述出来。理解了序列DP你再看背包、区间DP、树形DP、状压DP思路是通用的只是状态维度不同而已。这篇文章我会用实际例题带你过一遍序列DP的建模思路、状态定义、转移推导、滚动数组和查错方法尤其是那些光看题解根本学不到的这道题为什么状态要这么设计背后真正的思考过程。适合刚学完基础DP、想进阶的竞赛选手也适合面试前想系统梳理动态规划套路的朋友。1. 序列DP的设计思路与核心模型1.1 序列DP的本质把线性结构变成决策链序列问题有个特点数据的顺序天然限制了状态转移的方向。无论是最长上升子序列、编辑距离、还是股票买卖输入都是一条线性的数据流而我们要做的就是在每个位置上做取或舍、匹配或不匹配的决策。理解序列DP第一步不是背模板而是掌握背后的建模逻辑用一个数组或二维表的下标去映射原序列的位置关系用下标组合去描述所有可能的决策历史。这里有个生活化类比你把一年中每天的气温记录下来想知道最长连续升温天数是几天你不会去问气温的绝对高低只需要关心相邻两天的变化趋势。序列DP也一样它关心的不是整个序列的全局特征而是从起点到当前位置、由状态定义出来的局部最优历史。前面怎么走的不重要重要的是当前这一步以及这一步对后续的影响都能用状态值概括出来这就是动态规划里的无后效性。1.2 五类高频序列DP模型序列DP看起来花花绿绿实际遇到的题目绝大多数可以归入这五类模型线性递推型如斐波那契、走楼梯、买卖股票——状态按位置直接推进转移只依赖常数个前驱。子序列型如最长上升子序列 LIS——在每个位置考虑选当前元素接在哪个子序列后面。双序列匹配型如最长公共子序列 LCS、编辑距离——用二维状态表同时扫描两条序列。区间型如石子合并、括号匹配——状态定义在某个连续区间上转移按区间长度递推。状态机型如股票买卖I/II/III——把决策状态抽象为有限个模式状态在模式间转移。你可能会问为什么要分这么细因为不同模型的状态定义方式和转移方向完全不同。你要是理解了这五类的区别看到新题自动会去匹配属于哪一类而不是硬套模板。洛谷动态规划题单上大量的中等题其实都是在这些模型上加了一些条件限制比如限定开关次数、限定连续段长度、带权值——万变不离其宗。1.3 为什么状态设计比转移方程更重要很多人学DP喜欢直接背转移方程这是本末倒置。方程是从状态定义推出来的状态定义错了方程再漂亮也是错的而且很难查出来——因为样例可能恰好能过。判断状态定义是否合理有一个非常实用的标准写下状态含义之后你能不能自然地说出从任意前驱状态推到这个状态需要什么额外信息如果这个额外信息不在状态里你的定义就是残缺的。举个例子求最长上升子序列长度时你可能会想状态dp[i]表示前i个数中能选出的最长上升子序列长度。这个定义能推出答案吗不能因为你不知道当前这个子序列的结尾元素是谁就无法判断下一个元素能不能接上去。所以正确做法是把状态定义为以nums[i]结尾的最长上升子序列长度这样转移时只需要去看dp[j]j i对应子序列的结尾就能直接比较大小决定要不要转移。这个定义完状态后用转移需求去检验的方法是排查DP状态设计错误最有效的手段你可以在做任何一道题时都主动用它。2. 核心模型深度拆解从LIS到LCS再到编辑距离2.1 最长上升子序列LIS的标准解法LIS是最典型的入门序列DP但它包含的坑一点不少。基础解法很直白dp[i]表示以nums[i]结尾的最长上升子序列长度转移时遍历所有j i如果nums[j] nums[i]就用dp[j] 1去更新dp[i]。// 经典 O(n^2) LIS 解法 int lengthOfLIS(vectorint nums) { int n nums.size(); vectorint dp(n, 1); // 边界每个元素自己构成一个长度为1的子序列 for (int i 0; i n; i) { for (int j 0; j i; j) { if (nums[j] nums[i]) { dp[i] max(dp[i], dp[j] 1); } } } return *max_element(dp.begin(), dp.end()); // 注意不是dp[n-1] }这里有两个最容易被新手忽略的点答案是max(dp)而不是dp[n-1]。因为最长上升子序列不一定以最后一个元素结尾可能在中间的某个位置结尾。这个错误几乎所有初学LIS的人都会犯一次。时间复杂度是O(n²)当n 5000左右就要考虑优化了。而优化方法贪心二分严格来说已经不是纯DP模型而是利用了单调性做的决策优化我后面会细说。2.2 最长公共子序列LCS的二维状态表LCS是序列DP里状态维度扩展的最好范例。这里我们遇到的是两条序列所以状态至少要两个维度dp[i][j]表示字符串A的前i个字符与字符串B的前j个字符的最长公共子序列长度。转移逻辑是这样的如果A[i] B[j]这个位置的字符可以拿来做公共子序列的结尾所以dp[i][j] dp[i-1][j-1] 1如果不相等那当前这两个字符至少浪费一个取max(dp[i-1][j], dp[i][j-1])。我理解这个二维表的转移过程时习惯把它想成在网格上走迷宫从左上角出发往右走表示跳过B的一个字符往下走表示跳过A的一个字符向右下走表示匹配A[i]和B[j]。三种走法的最终位置是右下角路径长度就是公共子序列长度。这样一想转移方程的来源就非常直观了。边界条件是dp[0][j] 0和dp[i][0] 0因为空字符串和任何字符串的公共子序列都是空。这个边界条件看似简单实际上保证了一眼就能想清楚递推基础。LCS序列本身的输出也是一个高频考察点思路是从dp[m][n]开始反向回溯如果当前两个字符相等说明匹配过输出字符同时, i--, j--如果不相等则比较dp[i-1][j]和dp[i][j-1]往较大的方向移动。回溯源代码实现时要注意输出结果是逆序的需要存到数组里再反转或者在递归回溯时先递归再输出。2.3 编辑距离序列DP的模板天花板编辑距离Levenshtein Distance是双序列DP里的进阶考点也是我见过的把状态设计 分类讨论结合得最经典的题目。问题描述把字符串A变成字符串B允许三种操作——插入一个字符、删除一个字符、替换一个字符求最少操作次数。状态定义依然是二维dp[i][j]表示把A的前i个字符变成B的前j个字符所需的最少操作次数。转移时针对A[i]和B[j]的关系分四种情况讨论A[i] B[j]不需要额外操作直接继承dp[i-1][j-1]替换把A[i]替换成B[j]操作次数加1对应dp[i-1][j-1] 1删除删掉A[i]让A的前i-1个去匹配B的前j个对应dp[i-1][j] 1插入在A的末尾插入B[j]让A的前i个去匹配B的前j-1个对应dp[i][j-1] 1。取四种情况的最小值即可。边界条件要特别注意dp[0][j] j空串变成长度为j的串只能插j次dp[i][0] i长度为i的串变成空串只能删i次。这里想强调一个细节很多人写编辑距离时漏掉相等时继承dp[i-1][j-1]这种情况只拿三种操作去比结果就出错了。相等情况必须单独处理因为如果两个字符本来就一样这个位置不应该消耗任何一次操作。你把两种情况替换和不替换都算一遍也行取min也不会错但单独分支效率更高且更直观。我还见过一道恶心题要求打印出完整的编辑步骤比如在第2个位置插入x、将第3个字符替换为y。这种题的解法是在DP的过程中额外开一个pre数组记录每个状态是由哪个方向转移来的最后从dp[m][n]回溯到dp[0][0]每走一步就按方向往下解读操作类型。这个过程本质上就是路径还原也是所有格子型DP输出方案的通法。3. 进阶优化从O(n²)到O(nlogn)的跃迁3.1 贪心二分优化LISLIS的O(n²)解法在n达到10^5级别时完全不能用这时候必须换成贪心二分的解法。这个方法本质不是DP但它是理解决策单调性的一个绝佳例子。核心思路是维护一个数组tails其中tails[k]表示长度为k1的上升子序列中最小可能的末尾元素。遍历原数组时对每个数x在tails中做二分查找找到第一个大于等于x的位置把它替换成x如果x比tails所有元素都大就追加到末尾。这样做是为什么关键在于末尾元素越小这个子序列未来能接上更大元素的概率就越高。所以对每个长度我们只保留最小的那个末尾元素即可。这本质上是在用贪心思想保证每个长度下保存最优潜力而不是保存具体某个子序列。代码大概是下面这样// 贪心二分优化 LISO(nlogn) int lengthOfLIS(vectorint nums) { vectorint tails; for (int x : nums) { auto it lower_bound(tails.begin(), tails.end(), x); if (it tails.end()) tails.push_back(x); else *it x; } return tails.size(); // tails的长度就是LIS长度 }有一个非常容易踩的坑tails数组里的元素并不是真正的最长上升子序列用这个方法虽然能求出长度但无法还原出具体序列。如果你既要长度又要方案需要额外记录每个元素在转移时位于tails中的哪个位置然后倒推回去比较麻烦。所以做基建题时可以先判断题目要的是长度还是方案。3.2 滚动数组把二维压成一维双序列DPLCS、编辑距离的时间复杂度是O(nm)空间也是O(nm)。当两条字符串长度都达到10^5级别时内存直接爆炸。这时候就要用滚动数组优化。以编辑距离为例观察转移方程dp[i][j]只依赖dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]。也就是说当前行的更新只依赖上一行的值和当前行的前一个值。因此我们不需要保留整个二维表只需要保留两行一行是上一行的结果一行是当前行正在计算的结果。甚至可以只开一个一维数组加一个额外变量来保存dp[i-1][j-1]。一维滚动数组实现编辑距离的细节非常微妙当你更新dp[j]表示dp[i][j]时原来的dp[j]其实已经变成了dp[i-1][j]的覆盖值所以必须在覆盖之前先存下来。还有一个更隐蔽的坑是dp[i-1][j-1]的值它在你更新dp[j]之前已经被更新dp[j-1]的操作覆盖掉了。所以每个循环里至少要保存两个临时变量。实际写出来// 编辑距离滚动数组一维 两个临时变量 int minDistance(string word1, string word2) { int m word1.size(), n word2.size(); vectorint dp(n 1); for (int j 0; j n; j) dp[j] j; // 初始行 dp[0][j] j for (int i 1; i m; i) { int prev dp[0]; // prev 保存 dp[i-1][j-1]初始为 dp[i-1][0] dp[0] i; // dp[i][0] i for (int j 1; j n; j) { int temp dp[j]; // 保存 dp[i-1][j]因为马上要被覆盖 if (word1[i-1] word2[j-1]) dp[j] prev; // 相等直接继承 else dp[j] min({prev, dp[j], dp[j-1]}) 1; // 替换、删除、插入 prev temp; // 下一轮的 dp[i-1][j-1] 就是这一轮的 dp[i-1][j] } } return dp[n]; }这个写法初次看会有点绕我建议你拿一组小数据手推一遍把每一轮循环前后dp数组的状态变化写下来。推完你就会发现滚动数组的每一行覆盖本质上就是在模拟二维表的逐行计算过程只是把暂时用不到的行腾出去了。数组下标没对齐的问题97%都出在用滚动数组时更新顺序写错。3.3 状态机DP把限制条件纳入状态股票买卖系列LeetCode 121/122/123/188/309是状态机DP的经典题组它揭示了一个非常重要的序列DP扩展方向当题目额外要求最多交易K次冷冻期手续费时单靠dp[i]无法处理因为你还得记录当前是持仓还是空仓、已经交易了几次。解决方法是把状态定义成多维元组dp[i][k][0/1]表示第i天已经完成k次交易或者交易过k次当前持有1或不持有0股票时的最大利润。这里的第三维就是一个状态机维度枚举了持仓/空仓两种模式。转移思路不复杂空仓状态可以从昨天的空仓保持也可以从昨天持仓今天卖掉算一次卖出持仓状态可以从昨天持仓保持也可以从昨天空仓今天买入注意对买入还是卖出计数的不同约定。写好之后你会发现这种模式之间的转移本质上就是图上的边状态机DP就是在DP的框架内做图上最长路。这套思路延伸出来很多带限制的线性DP都可以通过加大状态维度解决比如任务调度里休息x天后才能再干活、词法分析里的多个自动机状态、路由规划里经过几条收费路段等。状态维度的扩展是序列DP进阶里性价比最高的一项技能。4. 实战演练从输出方案到高频题目思路点拨4.1 带方案输出的LIS记录前驱与回溯我先分享一道综合练习题不仅要求输出LIS长度还要求输出一组满足条件的LIS序列字典序最小。这题对理解回溯机制很有帮助。题目本身还是先做O(n²)DP但在转移时记录每个位置的前驱下标。核心是定义pre[i] j表示在最优转移中dp[i]是由dp[j] 1得到的。输出时只需找到最终答案对应的那个索引然后不断向前回溯到pre值为-1为止。最终把得到的序列逆序输出。// 记录前驱的 LIS 输出O(n^2) void reconstructLIS(vectorint nums) { int n nums.size(); vectorint dp(n, 1), pre(n, -1); int bestEnd 0, bestLen 1; for (int i 0; i n; i) { for (int j 0; j i; j) { if (nums[j] nums[i] dp[j] 1 dp[i]) { dp[i] dp[j] 1; pre[i] j; } } if (dp[i] bestLen) { bestLen dp[i]; bestEnd i; } } vectorint seq; for (int cur bestEnd; cur ! -1; cur pre[cur]) seq.push_back(nums[cur]); reverse(seq.begin(), seq.end()); for (int x : seq) cout x ; }这道题你手推一遍会非常直观地理解DP的每个状态不仅是一个数字它还隐含一条最优路径pre数组就是把这条隐藏路径显式化。面试里如果被问到这个方案为什么不唯一或者如何保证字典序最小答案本质上都是围绕前驱选择顺序来展开的。最终方案唯一性的前提是转移时严格使用而不是求字典序最小需要在转移时加额外的比较规则。4.2 环形序列DP破环成链有一类题特别喜欢拐弯序列不是线性的而是环形的比如环形石子合并、环形房屋偷窃。破环成链是处理这类问题的标准策略。核心思路简单粗暴把环从任意位置断开复制一遍序列形成长度为2n的线序列。然后在这个线序列上做区间DP最终枚举所有长度为n的区间取其中最优值。复杂度从O(n^3)升到O((2n)^3)但常数可控n在100~200的题基本都能过。环形石子合并的转移方程我记得特别清楚// 环形石子合并dp[i][j] 表示合并区间 [i,j] 的最优值 for (int len 2; len n; len) { for (int i 0; i len - 1 2 * n; i) { int j i len - 1; dp[i][j] INF; for (int k i; k j; k) { dp[i][j] min(dp[i][j], dp[i][k] dp[k1][j] sum[i][j]); } } } // 最后枚举 len n 的区间取最小值这里有一个初学者常犯的错sum[i][j]必须用前缀和数组快速求出区间和而不是每次累加内层循环否则三重循环叠加内层扫描直接变成O(n^4)大一点的样例就超时。只要把区间和计算优化成前缀和O(1)查询整体就是标准O(n^3)稳妥得很。从线性到环形本质上是在增强DP状态的覆盖范围原来只考虑一段序列现在要考虑所有可能的一段完整环的起点。理解了破环成链环形变种题基本都能啃下来。4.3 高频面试题合集与思路对照刷题过程中我把常见的序列DP题目按模型整理了一张表做新题时先对号入座效率提升很明显题目类型状态定义转移要点复杂度最长上升子序列dp[i]以i结尾的LIS长度dp[i] max(dp[j]1), ji 且 n[j]n[i]O(n²) → O(nlogn)最长公共子序列dp[i][j]A前i、B前j的LCS长度相等则 1不等取maxO(nm)编辑距离dp[i][j]A前i变B前j的最小代价相等继承否则替换/删/插取min1O(nm)乘积最大子数组dp[i][0/1]以i结尾当前最大/最小乘积乘负数会翻转最大最小O(n)打家劫舍III树上版dp[i][0/1]当前节点偷/不偷的最大值左右子树取max累加O(n)股票买卖IIIdp[i][k][0/1]天/交易次数/持仓状态间两两转移O(nk)最长递增子序列个数dp[i] cnt[i]同时维护长度和方案数O(n²)字符串交织dp[i][j]s1前i和s2前j能否组成s3前ij分别比较s3尾字符O(nm)这张表不是让你背的而是让你看状态设计如何随题目要求变化。你注意观察凡是复杂度为O(nm)的都是双序列问题凡是带偷/不偷持仓/空仓的都是状态机凡是求方案数的都额外开一个cnt数组同步维护——这些规律积累多了做题时反应速度会快很多。5. 实战踩坑记录常见问题与保姆级排查5.1 六个最容易翻车的错误序列DP写错往往不是思路问题而是细节问题。我把在洛谷刷DP题单和自己调试代码时踩过的坑总结成下面这张排查表错误类型典型症状排查思路边界初始化错误输出比答案小/大固定值检查dp[0][j]、dp[i][0]、dp[0]的初始值是否按题意设对状态含义不清答案查不对但样例能过用转移需要什么额外信息标准重新审视状态定义转移顺序错误结果依赖遍历方向LIS是i从小到大区间DP是len从小到大状态机按天推进滚动数组覆盖顺序答案随机出现偏差手推两轮循环确认prev/temp的保存时机答案取错位置总是差一点检查答案是dp[n]还是max(dp)特别小心前i项和以i结尾未考虑相等情况编辑距离多算操作次数双序列匹配类题目优先检查相等分支5.2 手推样例为什么样例过了还会错我调试序列DP的固定流程是第一件事不打开IDE先拿笔和纸把样例按DP表格手推一遍。举个例子编辑距离horse变ros标准答案是3。如果你手推时发现表格里的值一直到不了3说明转移方程某一步写错了。这时候再回头读题检查是不是把哪个操作的方向搞反了。手推样例还有一个好处能帮你发现自己对状态定义的理解和代码实现是否一致。我见过太多人写出来的代码和脑中的思路根本不匹配比如心里想的是dp[i][j]是前i个和前j个写代码时下标却从0开始差值没对齐。这种错在大型输入上几乎无法发现但从小样例手推表里就一目了然。5.3 边界值的极度舒适区如何一次设对边界设置是序列DP最大的心理负担来源。我个人的经验是拿到题目后先找出所有空序列的情况再看所有单字符的情况把这两个极端场景单独算一遍自然就能推出dp数组应该怎么初始化。以LCS为例A为空或B为空公共子序列必然为空于是dp[0][j]0、dp[i][0]0。以编辑距离为例把空串变成字符串B需要插入操作共j次所以dp[0][j]j把字符串A变成空串需要删除操作共i次所以dp[i][0]i。你去看每道题的边界设置背后都对应一个极端场景的直接答案。不要背边界值要背极端场景推边界这个技巧。5.4 真实debug场景一次编辑距离的错误排查分享一个我最近的调试例子。一位同学写编辑距离老是ans比答案多1我看代码发现他把dp[0][j] j写成了dp[0][j] 1。这就是一个典型的边界初始化错误空串变成长度为j的字符串显然需要插入j个字符却写成了1。这样每行递推都会差1但最终结果又是只差一点点让你以为只是心理问题。真实debug过程是拿一个长度为3的例子手算发现表里每行末尾差1追溯到头就是初始化问题。另一个常见debug场景是输出LCS具体序列时发现结果少了最后一个字符。原因往往是回溯循环终止条件写成了while (i 0 j 0)但实际上下标等于0时也可能有还没处理的匹配。把终止条件扩大成while (i 0 j 0) || (i 0 j 0) || (i 0 j 0)又过于复杂通常我会在循环里先处理单边剩余情况逻辑会更清晰。这类问题用一句话总结回溯的边界条件和DP正推的边界条件是对称的只查一边必踩坑。6. 我还想唠叨两句我个人在实际调试动态规划时的一个体会是不要一上来就写代码先写注释。把所有状态的维度、每个维度的含义、转移中前驱状态的范围用中文写清楚再开始动手。注释写不顺的地方就是你的思路还没理通的地方。二十分钟的注释时间能给你省下两个小时的无谓debug。最后再分享一个小技巧序列DP做多了之后你会慢慢形成一种条件反射。看到最多最少方案数是否可行脑子里先问三个问题——问题是一维还是二维序列状态是按位置推进还是按区间推进边界条件里有没有空串/空集这三个问题的答案基本决定了题目的模型归属。把这个习惯养成之后你再去看洛谷动态规划题单里的中等题会发现它们的思维框架都是相通的。序列DP是动态规划体系里最耐嚼的一块。它不像背包那样模式固定也不像树形DP那样结构特殊但它恰恰是帮助你建立状态设计直觉最好的一类训练。把这个基础打牢后续学区间DP、状压DP、数位DP都会顺畅很多。希望你也能在一次次的推表、debug和AC中找到那种原来如此的快乐。
返回列表