刷动态规划的时候,很多人会碰见一类题:状态不是一维数组,而是dp[i][j],含义是“从第 i 个位置到第 j 个位置这段连续区间的最优值”。这类题有一个专门的名字,叫区间DP。第一次接触它的时候,我也挺懵,石子合并、括号匹配、最长回文子串,看起来八竿子打不着,实际上底层都是同一套“小区间拼大区间”的思路。这篇文章从状态设计、枚举顺序、转移模板到优化手段,把我踩过的坑和总结的经验一次说清楚。适合刚学完线性DP、准备进阶的选手,也适合刷题时看到区间DP就头大的朋友。只要把“区间”这两个字记住,这类题就成功了一半。
1. 区间DP到底在解决什么问题
1.1 从最长回文子串说起
你八成见过这么一道经典题:给一个字符串,求最长的回文子串长度。很多人第一次看到这题时,第一反应是双指针或者中心扩展,但实际上它也可以用区间DP来解。状态是dp[i][j],表示从第 i 个字符到第 j 个字符这一段子串是否为回文串,或者回文串的最大长度。
回文的性质很特殊:一个长字符串是不是回文,取决于它两端的字符是否相等,以及去掉两端之后剩下的中间段是不是回文。换句话说,s[i] == s[j]并且中间段s[i+1..j-1]是回文,那么s[i..j]就是回文。这不就是“小区间推出大区间”吗?
这就是区间DP最典型的场景:最终答案落在某个连续区间上,并且大区间的性质可以由小区间组合得到。你不需要关心这个区间外面的内容,只需要专注区间内部怎么转移。
从这道题还能看到一个关键点:区间DP的状态通常不关心“我们已经取了哪些元素”,只关心“当前操作发生在哪一段连续区间上”。这是它和背包、状压DP最大的区别。
1.2 一眼识别区间DP的四个特征
刷题刷多了之后,我发现区间DP的题目其实是有“面相”的。看到以下几点,基本可以直接往区间DP方向想:
- 题目问的是“某一段连续区间上的最优解”,比如合并一排石子的最小代价,而不是从一堆无序元素中选几个。
- 状态里有两个下标 i 和 j,且 j 总是大于等于 i,含义是区间左右端点。
- 转移时会枚举一个分割点 k,把区间
[i,j]分成[i,k]和[k+1,j]两段,或者分成[i,k]和[k+1,j]的某种组合。 - 数据范围很微妙:n 通常在 100 到 1000 之间。为什么?因为朴素区间DP复杂度是 O(n^3),n 太大就不现实,n 太小又没必要用区间DP。
举个例子,一堆石子排成一排,只能合并相邻的两堆,每次合并的代价是两堆石子的重量之和,问最终合并成一堆的最小总代价。这里合并的对象必须相邻,所以“区间”是天然约束。你可以把它想成:每次合并都发生在某个子区间内部,最后再把两个子区间拼起来。这种“连续约束”就是区间DP的入场券。
相比之下,如果是一堆糖果随便挑几个,问最大总价值,那是背包;如果是树形依赖关系,那是树形DP。区间DP的关键词永远是“连续”。
2. 状态设计与转移方向的最优解思路
2.1 状态里的两个维度怎么定
区间DP的状态几乎都是dp[i][j]。但你会发现,同样是两个下标,不同题目里语义可能不太一样:
- 最大值/最小值:比如合并石子的最小代价,
dp[i][j]表示合并[i,j]内所有石子需要的最小代价。 - 布尔值:比如判断回文,
dp[i][j]表示s[i..j]是否为回文。 - 计数:比如问一个括号序列有多少种合法加括号方式。
- 方案数:比如问把区间切成 k 段有多少种切法。
状态语义没想清楚就写转移,是最容易翻车的开始。我自己的习惯是:先把一句话写在注释里,“dp[i][j] = 在什么条件下,处理完 [i,j] 区间之后得到什么值”。这句话写不出来,后面全白搭。
另外要提醒一点,如果题目要求的是“整个区间上的最优值”,那答案通常是dp[1][n];如果题目要求的是所有子区间中的最优值,那还得在转移过程中维护一个全局答案。比如最长回文子序列,状态是子序列的最大长度,但是题目问的是整个字符串的最长回文子序列,所以最后取dp[1][n]就行。而一些“最大子段和变体”则需要在每个区间都更新答案。
2.2 三种常见转移方向
区间DP的转移方向,我总结下来无非三种。
第一种是合并模型。把区间从中间某个位置 k 切开,得到左右两个子区间,分别求出最优解,再合并成一个整体的结果。合并模型常见于石子合并、矩阵连乘、哈夫曼树变形。转移方程形如:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + cost(i,j,k))这里的cost是把左右两个已经处理好的部分“拼起来”的额外代价。比如石子合并,cost就是[i,j]区间内的石子总重量,因为每次合并都要把一堆的重量再加一次。
第二种是划分模型。区间[i,j]可以被分成“一个已经算好的小区间”加上“一块新元素”。比如把一个字符串切成若干回文子串的最小次数,dp[i][j]可以是枚举 k,让[i,k]已经是合法的整体,[k+1,j]本身是一个直接可用的块。这种转移的本质是“最后一次操作发生在哪里”。
第三种是匹配模型。两个端点的元素可以直接配对,去掉它们之后,转移到内部的[i+1,j-1]。典型的就是括号匹配和回文问题。匹配模型不一定需要枚举 k,转移往往是 O(1) 的,这样整体复杂度可以降到 O(n^2)。
区分一个题目到底属于哪种模型,有一个判断方法:把大区间变成小区间的时候,是需要保留两个子问题的结果再叠加,还是只需要消掉两端?需要枚举 k 的,多半是合并/划分;直接往里缩的,多半是匹配。
2.3 复杂度心里要有数
朴素区间DP的三重循环是这样的:
- 第一层枚举区间长度 len,从 1 到 n。
- 第二层枚举区间起点 i。
- 第三层枚举分割点 k。
所以复杂度是 O(n^3)。n=500 的时候,大约要做 1.25 亿次运算,C++ 在 1 到 2 秒内勉强可以跑完;n=1000 的时候,就是 10 亿次,裸写基本必超时。这就是为什么很多区间DP题目的 n 都压在一两百:不是为了刁难你,而是确保你能用朴素做法过掉,同时让“优化”成为一种可选亮点。
有经验的选手会在写代码前先做一道算术题:n^3 是否在我的时限内可跑?如果是 500,放心写;如果是 1000,就要考虑四边形不等式优化或者寻找 O(n^2) 的解法;如果 n 是 2000 以上,基本可以确定这不是朴素区间DP的菜,可能是别的算法。
计算方式很简单:总状态数约等于 n^2/2,每个状态枚举 k 平均约 n 次,所以总复杂度量级是 O(n^3)。实际枚举次数大约为 n^3/6,你可以粗略按 n^3 来估算上限。比如 n=1000,1/6 * 10^9 ≈ 1.67 亿次,可能还能卡过去;但如果是 5000,那就别想了。
3. 核心细节解析与实操要点
3.1 枚举顺序为什么必须是“先长度后起点”
区间DP最容易被忽视的细节就是循环顺序。很多新手会把循环写成:
for (int i = 1; i <= n; i++) { for (int j = i; j <= n; j++) { ... } }这种写法在很多时候是错的。原因很简单:当你在算dp[i][j]时,你可能会用到dp[i][k]和dp[k+1][j]。如果先枚举 i 再枚举 j,那么dp[k+1][j]这个区间的长度可能比[i,j]小,也可能比[i,j]大,后者还没算出来,你就拿不到正确值。
正确的顺序是:先枚举区间长度 len,再枚举起点 i,终点 j = i + len - 1。这样保证所有被依赖的子区间长度都小于当前区间,已经提前算好了。你可以把它理解成“搭积木”:1 格的积木先准备好,再搭 2 格,再搭 3 格。要是上来直接搭 5 格,下面几层可能还是空的,自然塌方。
如果你实在记不住顺序,有个折中方案:用记忆化搜索写递归。递归天然只会访问长度更短的子区间,不用担心顺序问题。缺点是递归调用有额外开销,状态特别多时可能栈溢出或变慢,但用来验证状态转移是否正确非常方便。后面我会给一个记忆化搜索的模板。
3.2 区间长度的边界和下标细节
我建议区间用“左闭右闭”来理解:区间[i, j]包含 i 和 j。这样长度为 1 的区间是[i, i],长度为 2 的区间是[i, i+1]。枚举长度时,len 从 1 开始,到 n 结束;起点 i 的最大值是n - len + 1。写成代码就是:
for (int len = 1; len <= n; len++) { for (int i = 1; i + len - 1 <= n; i++) { int j = i + len - 1; ... } }如果你用的是 0 下标,那么 j = i + len - 1,i 的范围到n - len为止。两种下标方式都可以,但建议一个工程内别混用。我最开始刷题时,一会用 1 下标,一会用 0 下标,导致回文判断的i+1 > j-1这类边界反复出 bug。后来统一用 1 下标,代码清晰很多。
还有一种常见写法是左闭右开:[i, j)不包含 j。回文问题里这种写法的好处是天然避开空区间的烦恼,因为[i, i)是空区间,[i, i+1)是单字符。但我个人更习惯闭区间,因为最终答案dp[1][n]的下标不用再减一。
3.3 初始化与无效状态的坑
初始化是区间DP里最容易出问题的地方,而且问题很隐蔽:不是所有状态都从 0 开始。
如果你求的是最小值,dp数组通常要初始化为一个很大的数,比如INF = 1e9;如果求的是最大值,初始化为很小的数,比如-INF。但长度为 1 的区间需要单独给正确初值,比如合并一堆石子不需要代价,所以dp[i][i] = 0;回文题里单个字符是回文,所以dp[i][i] = 1;矩阵连乘里单个矩阵不需要乘法次数,所以dp[i][i] = 0。
如果忘了初始化长度为 1 的区间,或者把整个数组都初始化成 INF 后直接跑转移,结果必然是 INF 到处蔓延,最后输出一堆2147483647。我建议在测试时先打印一个小数据集的 dp 数组,看看长度为 1 和 2 的状态是不是符合直觉,而不是一上来就盯着最终答案看。
还有一个值得注意的地方:长度 2 的转移可能没有经过分割点枚举。比如回文判断中,dp[i][i+1]只需要看s[i] == s[i+1],不需要依赖dp[i+1][i]这种非法状态。在写转移时要小心,不要在长度 2 时访问i+1 > j-1的下标,那会变成空区间。闭区间写法下,dp[i+1][j-1]在 len=2 时下标为dp[i+1][i],是一个左端点大于右端点的非法状态。通常做法是单独判断长度 1 和长度 2 的初始化,或者让空区间返回合法值(比如回文时空区间算 true)。
3.4 分割点枚举范围的细节
在合并模型中,分割点 k 通常从 i 循环到 j-1,因为你要把区间分成[i,k]和[k+1,j],两个部分必须都非空。如果 k 取到 j,右半区间就空了,没有意义;如果 k 从 i-1 开始,左半区间就空了。
有些新手会把 k 写成for (int k = i + 1; k <= j; k++),这时左区间[i,k]至少长度为 2,右区间[k+1,j]可能为空。这会导致漏掉某些合法分割。正确的做法是k从 i 到 j-1,保证左右都至少有一个元素。当然,如果你的转移模型允许空区间,比如某些计数问题,那可以再调整,但默认情况下非空分割更稳妥。
另一个细节是,有的转移方程里你还会用到dp[i][k] + dp[k][j]这种形式,这是左闭右开区间写法,k 的取值范围要相应改成 i 到 j。如果你拿模板直接套,没注意区间定义,很容易发生数组越界或者答案偏大偏小。
4. 经典模型与题目识别指南
4.1 石子合并与环形区间DP
石子合并是区间DP的入门必修课。题目是这样的:有 n 堆石子排成一排,每堆石子有一个重量 a[i],每次只能合并相邻的两堆,合并的代价是这两堆石子的重量之和。问把所有石子合并成一堆的最小代价。
状态定义很自然:dp[i][j]表示合并第 i 堆到第 j 堆所需的最小代价。转移时,枚举最后一次合并的分界点 k,左边[i,k]已经合并成一堆,右边[k+1,j]已经合并成一堆,最后合并这两堆的代价是sum[i][j]。所以:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j])这里sum[i][j]可以用前缀和在 O(1) 时间内算出来,不要在第三层循环里临时累加,否则复杂度会变成 O(n^4)。
接下来有个经典变体:如果石子不是排成一排,而是围成一个环呢?处理方式有一个很成熟的手段:把环拆成链,复制一倍。把原始数组a[1..n]接到自己后面,形成a[1..2n],然后对每个长度为 n 的区间分别求石子合并的代价,最后取最小值。
为什么这个办法是对的?因为在环上,最终的合并过程一定可以看作从某个位置断开,变成一条链。我们不知道最优断点在哪里,所以枚举所有长度为 n 的区间,等价于枚举所有断点。复制一倍之后,每个可能的断开方式都对应一个长度为 n 的连续区间。
复杂度上要注意,复制之后 n 变成了 2n,所以朴素复杂度是 8n^3,常数大了不少。很多环形区间DP题,n 原值只有 100 左右,复制后就是 200,仍然能跑。如果 n 是 500,复制后 1000,就必须考虑优化或换思路。
4.2 括号匹配与回文类问题
括号匹配的经典题:给一个只包含(,),[,]的字符串,问最少添加多少个括号才能让它变成合法的括号序列。这题也可以用区间DP来解。
状态dp[i][j]表示把s[i..j]变成合法括号序列需要的最少添加数。如果s[i]和s[j]能够匹配(比如(和)),那么它们本身可以配成一对,内部只需要处理s[i+1..j-1],所以:
dp[i][j] = dp[i+1][j-1]但这不一定最优,因为字符串里可能有多对括号需要交叉组合,比如()[]。因此还要枚举分割点:
dp[i][j] = min(dp[i][k] + dp[k+1][j])把区间分成两段,各自变成合法括号序列,然后拼接起来。有些写法会先用第一个式子更新,再用第二个式子取 min,两个都要做。
这种“可匹配时先内缩,然后切分取 min”的套路,也出现在很多回文相关题目里。比如求最长回文子序列,dp[i][j]表示s[i..j]的最长回文子序列长度。如果两端字符相等,最长回文子序列长度可以从dp[i+1][j-1] + 2转移;如果不相等,则取max(dp[i+1][j], dp[i][j-1])。和括号匹配不同的地方在于,回文子序列不需要枚举 k,而是直接看两端字符是否相等,因为子序列不要求连续,两端字符可以直接配对。
回文子串和回文子序列最大的区别就是连续性。子串要求连续,所以转移时中间段也要是回文子串;子序列不要求连续,只是“删掉一些字符后形成回文”,所以更容易用匹配模型。做题时先看清楚是 subarray 还是 subsequence,否则转移会完全不同。
4.3 矩阵连乘与双区间划分
矩阵连乘也是合并模型的经典代表。给定一堆矩阵的维度,目标是找一种加括号方式,使乘法运算次数最少。状态dp[i][j]表示计算第 i 到第 j 个矩阵连乘的最小乘法次数。
转移时,最后一步一定是在某个位置 k 把矩阵分成左右两组:左边一组先乘成一个矩阵,右边一组先乘成一个矩阵,然后这两个矩阵再相乘。如果矩阵的维度数组是p[0..n],第 k 个矩阵的维度是p[k-1] * p[k],那么左右合并的乘法代价是p[i-1] * p[k] * p[j]。转移方程:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j])这个式子和石子合并非常像,区别只在cost部分。石子合并的 cost 是区间和,矩阵连乘的 cost 是三个维度相乘。如果你能抓住“合并成本”的本质,很多题只是换了一个 cost 而已。
还有一类划分问题不要求两个子区间都取最优解。比如把字符串切成若干回文子串的最少次数,dp[i][j]可以定义为把s[i..j]切完的最少次数,枚举最后一段回文子串的起点 k,那么[k+1..j]是一个回文子串,而[i..k]继续递归处理。这种转移只需要一个子区间是“已经算好的状态”,另一段是“可直接判定的块”,所以不需要所有分割点都先算好“块内最优”。
区分合并与切分,有一个简单办法:合并模型中,分割点左右两边的信息都必须来自状态数组;切分模型中,分割点右边可能是一个“当前区间尾部的连续块”,可以直接根据原数组判断,不一定是一个需要继续 DP 的完整子区间。
4.4 决策单调性与四边形不等式优化说明
区间DP的朴素复杂度是 O(n^3),当 n 到 1000 以上时,很多时候要靠四边形不等式优化来降一维。这个优化的前提是,转移方程满足下面这个形式:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + cost(i,j))并且cost满足四边形不等式:对于i <= j <= k <= l,有:
cost(i,k) + cost(j,l) <= cost(i,l) + cost(j,k)同时cost还要满足区间单调,即cost(i,j)的值随区间变大而增大或不变。满足这两个条件时,每个区间的最优分割点是单调的,即opt[i][j-1] <= opt[i][j] <= opt[i+1][j],于是枚举 k 时可以把范围缩小到opt[i][j-1]到opt[i+1][j]。
代码上的改动就是在每次更新dp[i][j]时记录opt[i][j],下一个更长的区间只在这个范围内寻找。整体复杂度从 O(n^3) 降到 O(n^2)。
但是要注意,不是所有区间DP都能用四边形不等式优化。如果你不确定 cost 是否满足条件,最稳妥的办法是先用朴素解法跑通,再尝试优化。不要一上来就上优化,否则最后连状态转移是对的还是错的都分不清。
5. 实操过程:三个可以直接复用的模板
5.1 朴素合并模型模板(C++)
以石子合并为例,给你一个可以直接套的完整模板。假设石子的下标从 0 到 n-1,a 是重量数组。
#include <bits/stdc++.h> using namespace std; const int INF = 0x3f3f3f3f; int main() { int n; cin >> n; vector<int> a(n + 1); vector<int> sum(n + 1, 0); for (int i = 1; i <= n; i++) { cin >> a[i]; sum[i] = sum[i - 1] + a[i]; } vector<vector<int>> dp(n + 1, vector<int>(n + 1, INF)); for (int i = 1; i <= n; i++) { dp[i][i] = 0; } for (int len = 2; len <= n; len++) { for (int i = 1; i + len - 1 <= n; i++) { int j = i + len - 1; int cost = sum[j] - sum[i - 1]; for (int k = i; k < j; k++) { dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + cost); } } } cout << dp[1][n] << endl; return 0; }这个模板的注意点是:dp[i][i] = 0是必须的,不设置的话长度为 2 的区间根本无法从长度 1 转移。cost用的是前缀和,不要用循环累加。第三层k从 i 到 j-1,保证左右两段都非空。
如果你第一次写区间DP,建议就在这个模板上面改。先跑通石子合并,再把cost换成别的公式,基本能覆盖一半以上的区间DP题。
5.2 匹配模型模板(Python)
回文判断这种不需要枚举分割点的题,用 Python 写起来很简洁。下面是最长回文子串长度的代码,用区间DP思路实现:
s = input().strip() n = len(s) dp = [[False] * n for _ in range(n)] ans = 1 for i in range(n): dp[i][i] = True for length in range(2, n + 1): for i in range(n - length + 1): j = i + length - 1 if length == 2: dp[i][j] = (s[i] == s[j]) else: dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1] if dp[i][j]: ans = max(ans, length) print(ans)这个版本里有个重要细节:长度为 2 时不能直接访问dp[i+1][j-1],因为下标变成dp[i+1][i],是空区间。所以单独处理。如果你想用更优雅的方式,可以让 dp 数组额外多一维,把空区间也存进去,但可读性会下降。
还要注意 Python 的时间性能。n=1000 时 O(n^2) 没问题,n=2000 时也能接受。但如果是合并模型 O(n^3),n=500 用 Python 就可能超时,建议换 C++ 或者想优化。
5.3 记忆化搜索模板
有些人觉得枚举长度的循环顺序太绕,特别是有多个条件或者需要剪枝的时候。这种情况下,记忆化搜索是一个很好的验证工具。下面是通用框架:
vector<vector<int>> memo(n + 1, vector<int>(n + 1, -1)); int dfs(int i, int j) { if (i == j) return 0; if (memo[i][j] != -1) return memo[i][j]; int res = INF; for (int k = i; k < j; k++) { res = min(res, dfs(i, k) + dfs(k + 1, j) + cost(i, j)); } return memo[i][j] = res; }记忆化搜索的好处是,你不需要关心循环顺序,因为递归只会调用区间长度更短的子问题。坏处是递归栈有开销,状态特别多时可能慢一些。实操时,我习惯先用记忆化搜索把转移方程验证正确,再改成自底向上的循环版本。这个方法应对复杂题目时特别有用。
5.4 四边形不等式优化模板
在石子合并这个场景里,如果 n 是 1000,朴素写法可能很悬,这时可以上优化。先定义一个opt[i][j]数组记录dp[i][j]取最优值时 k 的位置。循环顺序不变,但第三层 k 的范围变成:
for (int k = opt[i][j - 1]; k <= opt[i + 1][j]; k++)注意,如果opt[i][j-1]和opt[i+1][j]还没计算出来,循环顺序就需要合理调整。通常写法是:先枚举长度,再枚举起点,同时保证opt[i][j-1]是同一个长度下的左边邻居,已经算好;而opt[i+1][j]是上一个长度或者之前已经算过的。具体实现时,把opt初始化为 i,即可避免边界问题。
这个优化的代码不复杂,但前提是 cost 满足四边形不等式。我建议你至少手动验证几个小例子。比如石子合并的 cost 是区间和,它是满足四边形不等式的,所以可以放心优化。但如果你把 cost 换成一个随区间变化非常奇怪的函数,优化就可能失效,得到错误的答案。
还有一个更简单的优化:常数优化。比如把sum计算提前,cost在第三层循环里是常量,所以可以先算出来再进入 k 循环;再比如把 vector 换成二维数组,减少越界检查和动态分配开销。在小数据范围下,这些优化有时比四边形不等式更快见效。
6. 常见问题与排查技巧实录
6.1 输出 INF 或者答案明显偏大
遇到这种问题,第一反应是检查长度为 1 的状态初始化。合并类问题dp[i][i]=0,回文类问题dp[i][i]=true。如果初始化遗漏,长度为 2 的状态就可能是 INF 或者 false,然后错误一路传染到最终答案。打印 dp 数组最直接。
第二反应是检查枚举顺序。如果先枚举 i 再枚举 j,可能导致在计算dp[i][j]时访问过尚未计算的dp[k+1][j]。这种情况在调试时很难发现,因为样例可能恰好算对。把循环改成“先长度后起点”即可。
第三反应是看分割点范围。如果 k 从 i 循环到 j,可能导致右区间为空,dp[j+1][j]这种非法状态被访问。C++ 里 vector 越界通常不会立刻报错,但会返回随机值,导致答案诡异。加上assert(k < j)可以把这个问题暴露出来。
6.2 数组下标越界却不报错
很多区间DP用的是dp[i+1][j-1],在 len=2 时变成dp[i+1][i]。如果 dp 数组是n+1大小,这个下标看起来没有越界,因为 i+1 和 i 都在范围内。但逻辑上它表示空区间,值可能是未初始化的垃圾。这时需要在转移前判断长度,或者在初始化阶段把dp[i+1][i]设置成合法的空区间值。
另一个常见越界是前缀和下标。使用sum[j] - sum[i-1]时,如果 i=1,sum[0]必须存在且为 0。所以前缀和数组一定要从下标 0 开始初始化。
6.3 O(n^3) 超时的排查顺序
如果朴素写法超时,先别急着上高深优化。按以下顺序排查:
- 第三层循环里有没有重复计算?比如每次枚举 k 都重新算区间和,复杂度可能变成 O(n^4)。把不变的值提到循环外。
- 有没有用
memset或者动态分配数组导致常数过大?小数据可以忽略,大数据时影响很大。 - 有没有可能剪枝?比如已经知道某个 k 不可能比当前最优值更优,提前 break。
- 最后才考虑四边形不等式优化。优化之前先用小规模数据验证朴素代码的正确性,把两者对拍一下。优化本身也可能写错,尤其是
opt数组边界处理。
我见过不少同学一上来就写四边形优化,结果 cost 不满足条件,答案错得离谱。老老实实先把朴素版跑对,再谈优化,才是正道。
6.4 如何用“打印 dp”快速定位
区间DP的状态特别多,调试时只看最终答案很难定位。我的做法是,在 n 很小的时候(比如 4 或 5),用一组简单数据跑完,在每次循环结束后打印整个二维 dp 数组。重点看长度递增时,状态值是否符合预期。
比如石子合并样例为[1, 2, 3],长度 2 的区间[1,2]合并代价是 3,[2,3]合并代价是 5;长度 3 的区间要比较dp[1][2] + 3 + 3 = 6和dp[2][3] + 1 + 3 = 9,所以最小是 9 吗?这里我要特别说明,cost(1,3)=1+2+3=6,第一种分割是dp[1][1] + dp[2][3] + 6 = 0 + 5 + 6 = 11,不是 6;第二种是dp[1][2] + dp[3][3] + 6 = 3 + 0 + 6 = 9。所以答案是 9。用这种小例子能快速验证转移方程。
如果不打印 dp,只盯着答案看,你很难判断是初始化错了还是转移错了。打印 dp 还有个好处:能看到opt数组的变化,方便确认单调性,为后续优化铺路。
6.5 一个容易被忽略的问题:long long
区间DP里的 cost 不一定都是小整数。矩阵连乘的乘法次数可能非常大,三个维度相乘可能超过 21 亿。这时如果 dp 数组用int,会溢出变成负数,答案奇怪到怀疑人生。稳妥的做法是统一使用long long,尤其涉及乘法时。
石子合并的区间和虽然通常不会特别大,但累积总代价也可能很大。不要因为样例小就掉以轻心,比赛或在线题库里经常有极端数据。用long long又不费太多事,没必要赌。
6.6 什么时候可以跳过某些状态
有些区间DP题允许空区间参与转移,比如字符串插入字符的最少次数,dp[i][i-1]可能表示空串,需要初始化为 0。这种情况下,普通的dp数组维度要开成(n+2) x (n+2),避免访问i-1越界。我建议在代码开头就统一多开一维,省得后面改。
另外,如果题目要求“切成 k 段”,状态会多加一维变成dp[i][j][k],复杂度变成立方以上,但枚举长度和起点的核心顺序仍然不变。不要因为维度多了就慌了,核心思想还是小区间拼大区间。
一点个人经验
区间DP真正难的地方不是模板,而是判断“状态表示什么”和“最后一个操作是什么”。我刷了好几道题之后才意识到,每次卡壳都是因为没想清楚“最后一步”到底发生在哪里:是最后合并两堆,还是最后补一个括号,还是最后切一刀。想清楚这一步,转移方程基本就自己蹦出来了。
如果你现在刚开始学区间DP,别急着背模板,先找一道石子合并题,把 dp 数组手工推一遍,再对照代码理解为什么循环要先枚举长度。记住,n=500 的朴素代码不算慢,n=1000 就要考虑优化,n=2000 以上多半不是纯区间DP的锅。做题时打印 dp 数组,调试效率会高很多。最后再分享一个小技巧:很多区间DP题的评论区讨论都会提到“断环成链”和“四边形不等式”,这两个词听着玄乎,实际用起来并不复杂,一个一个吃透就好。