
我见过太多想突破 dp 优化的人第一反应是去搜“单调队列优化模板”“四边形不等式优化模板”背下来就觉得掌握了。结果换一道题数据范围从 1e3 涨到 1e5转移方程形态一变人就懵了。原因其实不复杂dp 优化的本质不是某个数据结构技法的堆叠而是基于状态设计去压缩无效决策的枚举。你状态里放了什么信息决定了你能用什么方式跳过哪些决策。这篇文章我打算换一种“优化尝试”的视角来写。不直接甩模板而是沿着我平时做题的真实路径走一遍先讲清楚状态设计怎么给后续优化让路再拆单调队列优化、四边形不等式优化含常见的分治解法和二分解法最后补上空间复杂度的暗坑以及一套我一直在用的验证与调试方法。适合刚把基础 dp 弄明白、准备进阶优化的读者也适合那些背了一堆模板但遇到新题总翻车的人。1. 状态设计里的“自由度”决定了后续所有优化的上限1.1 一个状态定义错误拖垮整个优化的真实例子之前给一个学弟讲题遇到一道任务调度的变种给定 n 个任务每个任务有处理耗时 w[i]要求塞进 m 台机器里最小化最大完成时间。第一眼很容易写出dp[i][j]表示“前 i 个任务分配到 j 台机器的最小代价”写完才发现没法转移——因为你不知道每台机器上任务的先后顺序顺序直接影响“完成时间”这个目标函数。于是有人硬加一维变成dp[i][j][k]把最后一台机器的工作量也记进去状态空间直接爆炸本来可能是 O(nm) 的题变成了 O(nm*sum)连暴力都跑不动。后来换了个表达先按完成时刻排序用dp[i]表示“处理完前 i 个任务所需的最短时间”转移时就只需要枚举“最后一段连续任务由哪台机器接管”决策集合一下子干净了。这个例子我想放在最前面dp 优化并不是“优化转移代码”而是“优化状态表达”。状态表达得越干净决策面临的可选项越小优化切入点就越清晰。你如果一开始就把“机器占用情况”这种维度过多的信息塞进状态后面无论上单调队列还是四边形不等式都只是局部止血救不回来。1.2 状态设计的三条检查线最优子结构、无后效性、决策信息充分在做任何 dp 优化尝试之前我会先拿三个问题给状态定义做体检。第一最优子结构是否成立。你定义的f[i]能不能由更小的f[0..i-1]直接构造出来如果发现构造f[i]还需要“当前阶段额外信息”那就说明状态漏了东西。最典型的是带修改次数的题目最长上升子序列加一个“最多允许 k 次修改”朴素一维状态根本没法表达“已经用了多少次修改”必须显式把k纳入维度。漏掉这个信息后面任何优化都没法写。第二无后效性是否被破坏。当你在计算f[i]时未来不应该回头修改f[i]的值。这一点听起来顺理成章但滚动数组、倒序更新这类常见优化技巧往往会悄悄破坏它。后面讲空间优化时我会专门用背包举例0/1 背包滚动数组必须逆序就是因为正序会让新值覆盖旧值产生“同一个物品多次使用”的后效性。第三决策信息是否充分。转移方程里的代价函数w(i, j)最好能由i、j两个下标独立算出来不依赖第三个变量。四边形不等式优化恰恰要求代价函数满足某种“四边形的单调性”如果w里面混进了外部环境变量这个前提根本无从谈起。这也是为什么竞赛题里常见的套路是把代价函数设计成“只跟两段下标相关”比如(prefix[j] - prefix[i])^2、区间两两距离和这类形式。这三条检查线做完你基本能判断这个状态设计是“可以优化”的还是“必须推翻重来”的。1.3 “降维”与“升维”的辩证状态维度不是越少越好这里有个反直觉的点。很多人以为优化方向就是“降维”能一维就不开二维。但有时候你得先“升维”才能让无后效性成立从而把转移化简。举个例子。最长上升子序列朴素转移是f[i] max(f[j]) 1其中j i且a[j] a[i]。如果你只想优化时间可以引入值域线段树在“值域”这个维度上做文章这本质上是把一维问题包装成二维的结构来做加速。可如果问题加上“有 k 次修改机会”就必须增设“已用修改次数”这一维。这时升维是解决问题的前提而不是倒退。所以我通常这样判断你希望压掉枚举中的哪个自由度就围绕哪个自由度做优化。单调队列优化压掉的是“起点下标”这个自由度四边形不等式压掉的是“决策点枚举范围”的自由度而状态压缩类优化压掉的是“集合表示”上的自由度。把状态维度和优化目标对应起来你的“优化尝试”才不会像无头苍蝇。除了维度本身还有一个容易被忽略的设计技巧把转移中变化规律相似的项拆出来。比如某个转移里同时出现sum[i]和sum[j]就优先用前缀和把整个代价拆成“只跟 i 有关”和“只跟 j 有关”两块。这一步做了单调队列就有下脚的地方不做后面所有优化都是空中楼阁。2. 从 O(n²) 到 O(n)单调队列优化不是背模板2.1 长成什么形状的转移方程才配得上单调队列判断一个 dp 能不能用单调队列优化我常用的标准很直接转移方程长这样dp[i] min/max( dp[j] cost(i, j) )而且j的取值范围是连续区间比如[i - L, i - R]同时cost(i, j)能拆成“只跟 i 有关的项 只跟 j 有关的项”。如果满足这三点这个题目大概率可以动单调队列。看一个最简单的例子给定数组a[]求每个长度为m的滑动窗口内的最小值。这道题的标准解法就是单调队列直接扫一遍但它本质上也可以写成dp[i] min_{j in [i-m, i-1]} a[j]只不过没有累计状态而已。我故意用这种“半个 DP”开头是为了让你看清单调队列优化 DP 的核心我们维护的是一个候选决策点集合这个集合里的元素按优劣单调排列队首就是当前最优决策。再上一个经典问题烽火台传递。有 n 个烽火台第 i 个点燃的代价是a[i]要求任意连续 m 个烽火台中至少要有一个被点燃求最小代价。朴素转移是dp[i] a[i] min( dp[j] ), j in [i-m, i-1]直接暴力是 O(n*m)n、m 都到 1e5 就彻底不行了。这里的dp[j]恰好是只跟 j 有关的项完美契合上面的判据。2.2 队列里到底存什么下标、值还是“过期时间”很多初学者会把单调队列理解成“维护最小值/最大值的队列”这是错的。队列里存的应该是候选决策下标而单调性依据这些下标对应的“dp[j] cost_part(j)”大小来维护。只有存下标你才能在下标离开窗口范围时把它从队首弹出去。下面是一段完整实现用的是 C 风格数组C 语言和 C 都能直接跑int q[MAXN]; int head 0, tail 0; // 队列区间 [head, tail) for (int i 1; i n; i) { // 1. 淘汰队首过期决策窗口左边界之外的下标全部失效 while (head tail q[head] i - m) head; // 2. 取队首为当前最优决策 dp[i] a[i] (head tail ? dp[q[head]] : 0); // 3. 把当前决策插入队列同时保证队列单调性 // 这里以取最小值为例队尾元素不比当前 dp[i] 优就弹掉 while (head tail dp[q[tail - 1]] dp[i]) --tail; q[tail] i; }这里有几个细节我吃过亏写出来提醒一下。第一第 1 步的弹出条件用还是取决于窗口是可取到i - m还是取不到。严格说如果j能等于i - m那过期条件应该是q[head] i - m也就是小于左边界才弹如果题目要求j i - m 1就得写成。我的习惯是先在草稿纸上写明合法j集合再写代码不然很容易在边界上翻车。第二取队首时要判断队列是否为空。空队列说明窗口内没有合法决策此时要么无解要么需要一个特殊初值。烽火台题里前 m 个烽火台往往没有前置状态这时候我会设置一个虚拟的 0 号决策点dp[0] 0让循环从一开始就把前 m 个位置自然覆盖掉比在循环里写一堆if (i m) dp[i] a[i]干净得多。第三第 3 步比较的是dp[q[tail-1]]和dp[i]不是比较a[q[tail-1]]和a[i]。因为a[i]是当前固定项它不参与候选决策的比较。如果写错比较对象队列单调性会直接崩掉。2.3 单调队列优化的边界与同值决策处理同值的决策也值得单独说。当dp[old] dp[i]时新旧决策谁留在队列里我的经验是如果题目只要求代价最小不要求方案唯一两者都可以但如果后续转移里还依赖决策下标比如要还原路径建议保留新下标。因为新下标在窗口内存活时间更长它过期得更晚能减少后面不必要的弹队操作。上面代码比较时用而不是就是为了让新下标顶掉旧下标。另外处理窗口特别大的情况时有个退化的现象当m n“过期逻辑”永远不触发队列会不断累积算法退化成“前缀最值”的维护。这并不是 bug它说明单调队列天然包含了一部分前缀最值优化。别觉得奇怪也别强行加弹队条件。调试单调队列还有一个很实用的“影子队列”方法开一个辅助数组把每一步的head、tail、队列内容以及当前dp[i]全部打印出来。遇到 WA看影子队列一眼就能确认是“过期下标没弹掉”还是“单调性比较对象选错了”。3. 四边形不等式优化决策单调性驱动的分治解法与二分解法3.1 不是数学证明而是一种“决策点不回头”的直觉四边形不等式的完整表述是对于a b c d有权值w(a, c) w(b, d) w(a, d) w(b, c)。我第一次看到这个式子的时候也很懵后来做题做多了才抓住它背后的直觉随着决策位置 i 往右走最优决策点opt[i]要么不动、要么也往右走绝不会倒退。“决策点单调”才是所有优化的核心。只要某个 DP 满足opt[i] opt[i1]你就不必每一轮都从 0 开始枚举 k可以从上一次的最优决策点附近继续往后扫。四边形不等式正是“决策点单调性”的充分条件之一。判断一个代价函数是否满足四边形不等式最省事的方法不是硬证而是代数拆项。比如经典的区间划分代价w(l, r) (prefix[r] - prefix[l-1])^2展开成平方差形式之后平方函数的凸性直接推出四边形不等式。常见满足条件的代价函数还有两两距离的平方和、区间标准差等。3.2 必须先做的验证步骤跑小数据打印决策表不管你数学上怎么证明工程实现之前都该验证一步。写一个 O(n^2) 的暴力版本把所有i对应的最优决策opt[i]打印出来肉眼确认它是不是单调不减。这一步成本极低但能救回后面几小时的分治/二分调试时间。我见过太多人因为题解区写了“四边形不等式”就直接跳过了验证结果优化完 WA回头查半天才发现是代价函数不满足条件或者状态定义里的opt[i]定义方式跟标准模型不一致。打印决策表永远是性价比最高的手段。判断的标准不光是单调还要看每行、每列是否符合递推关系这一步做扎实了后面分治和二分才有基础。3.3 分治解法把“枚举所有决策”变成“递归划分决策区间”对于形如dp[i][j] min_{k}( dp[i-1][k] w(k1, j) )的分层 DP并且每层的最优决策点单调分治解法非常优雅。思路是定义递归函数solve(l, r, optL, optR)它负责计算第l到第r列的状态而且已知这些状态的最优决策都落在[optL, optR]区间。先取mid (l r) / 2然后暴力扫一遍k in [optL, min(optR, mid-1)]找出让dp[i-1][k] w(k1, mid)取到最小值的 k记为best[mid]。接着向左递归时把决策区间压缩到[optL, best[mid]]向右递归时压缩到[best[mid], optR]。由于每一层每个位置只需要扫描自己的决策区间一次复杂度能从 O(n^2) 降到 O(n log n)。这里有个容易忽略的细节向右递归时k理论上不能等于mid因为状态转移里k通常不能越过当前列。我会在递归参数里把右端点写成min(optR, mid-1)避免在叶子节点反复检查非法转移。这个习惯能省掉很多无意义的边界判断。写分治解法还有一个好处它天然适合并行。如果题目允许每层的左右递归互不依赖可以一边处理左边一边处理右边只是工程上通常没必要但理解这一点有助于体会它的结构优势。3.4 二分解法当决策点在一个固定方向上前进分治解法的前提是每一层的状态可以独立计算但有些 DP 是同一层内也依赖前面的计算结果不能简单地分治。这时候如果决策点依然单调常用的是二分解法也叫决策栈二分法。核心思想是既然opt[i]单调不减那么决策点的“切换边界”也是单调的。维护一个栈栈里每个元素保存一段连续的决策点作用区间[L, R]以及对应的决策值 k。每算出一个新状态就判断它对应的最优决策是继续沿用栈顶的 k还是从更早的某个 k 变过来的。如果是后者就二分查找精确的切换位置。我第一次接触这个做法时觉得它绕后来写一个实际问题才明白它本质上是把“所有状态的转移”打包成一段段连续区间二分只是用来定位“新决策从哪个位置开始接管旧决策”。这种做法在状态量大的时候非常稳复杂度也是 O(n log n)。顺便提一句竞赛里还有一种名字里带“二分”的优化思路外层二分答案内层用带限制的 DP 判定可行性。这种“二分答案 DP 判定”的模型经常被用来处理带权选择问题本质上是把“求最优值”转化为“给定阈值判可行性”再整体乘一个 log。无论哪种二分套路都必须先确认决策单调性否则二分出来的转折点没有任何意义。4. 空间复杂度的暗坑滚动数组之外的降维思路4.1 滚动数组的更新顺序一维数组为什么常常写反先讲一个很常见的翻车点0/1 背包的滚动数组优化。朴素状态是dp[i][j] max(dp[i-1][j], dp[i-1][j-w[i]] v[i])压缩成一维后代码是for (int i 1; i n; i) for (int j V; j w[i]; --j) dp[j] max(dp[j], dp[j - w[i]] v[i]);如果你把内层循环写成从w[i]到V递增语义立刻变成完全背包——同一个物品会被反复取。原因很简单dp[j-w[i]]在逆序时读的是上一轮的值正序时已经被本轮更新覆盖了。完全背包反过来要正序是因为一个物品可以重复选。滚动数组的顺序问题不是“看心情”而是看“状态覆盖的语义”。每次循环开始前最好明确告诉自己这个值来自上一轮还是允许来自本轮。4.2 交换内外层循环的隐藏收益有一类二维 DP状态本身是dp[l][r]但转移只依赖l相近的状态。这时可以考虑交换循环维度让空间较小的维度放在滚动方向空间较大的维度放在深层。比如某些区间 DP 的转移只依赖dp[l1][r]、dp[l][r-1]这类相邻状态那么把l当外层用滚动数组维护r方向的数据就能把空间复杂度从 O(n^2) 降到 O(n)。时间复杂度保持不变但缓存友好度通常会变好实测起来常数也小一些。这类交换看起来不起眼在 n 到 5000 级别的区间 DP 里却能决定你能不能过内存限制。很多题不是时间不够而是空间爆了换一个循环方向就救活了。4.3 从“压缩状态含义”到带权二分把维度从状态里拿掉还有一种更隐蔽的空间优化不压缩存储方式而是压缩“状态含义”。还是回到“允许 k 次修改”的例子。你之所以要开dp[i][k]是因为k是转移中必须计数的维度。但如果状态本身的代价函数关于 k 是凸的就可以用带权二分一般叫 WQS 二分或斜率二分把这一维拿掉。具体思路是给“每一次使用修改次数”附加一个惩罚值lambda然后直接计算不带次数限制的 DP看解出来到底用了多少次修改。如果用了超过 k 次就增大惩罚少于 k 次就减小惩罚。二分这个lambda直到解出来的次数恰好收敛到目标值附近再反推真实答案。这个技巧对空间复杂度和时间复杂度都有帮助但它依赖一个强条件代价函数关于“次数”是凸的。判断凸性同样建议用小数据打表观察不要凭直觉直接上。4.4 写状态复用代码时的常见翻车点滚动数组等于反复在同一块内存上写新值最容易出两个问题上轮残留旧值没清空或者转移用到“历史值”时其实读到了“当前值”。我自己在项目里就踩过这样一个坑dp[i][j] min(dp[i][j], dp[i-1][k] w[k][j])用滚动数组重写成dp[j] min(dp[j], last[k] w[k][j])时忘了last和dp指向同一块内存结果新值覆盖旧值后面的 k 读到的是更新后的状态答案直接错掉一大片。从那以后我定了一条规矩滚动数组至少保留两个显式指针比如cur和last代码里禁止出现“同一个数组既代表本轮又代表上一轮”的语义模糊。哪怕多复制一遍也要保证清清爽爽。空间优化是为了省内存不是为了省这行代码的逻辑清晰度。5. 优化“翻车”现场一套高效的调试与验证方法5.1 暴力对拍优化前后必须做的最小验证不论在项目里做数据还是刷题我都坚持先写一个几十行的暴力版本再写优化版本然后用随机小数据对拍。对拍不是跑一次就结束而是循环几百上千次尽量覆盖到边界情况。有些人觉得暴力版本浪费时间但暴力版本的正确性是给优化版本当“参照系”的。没有这个参照你的单调队列错在边界可能整个晚上都查不出来。而对拍脚本本身很简单生成随机数控制规模分别跑两个函数比较输出一旦遇到不一致把输入和两个输出都打印出来缩小范围。我自己最常用的是固定随机种子方便复现同一组数据。排查的时候先把 n 控制在 10 以内再逐步缩小到最容易出错的小边界比如 n1、nm、m0 这类特殊情况。5.2 打印决策表观察最优决策点的移动规律对于四边形不等式相关的优化除了对拍我强烈建议单独打印一张“决策表”。维护一个数组best[i]把暴力版本里每个状态的最优决策下标打出来看它是不是单调的。这段建议听起来初等但真实价值很大。因为一旦进入分治或二分优化错误信号会被层层叠加你看到的只有一个错误答案实际原因可能藏在递归深处某个决策区间切错了。决策表让你一眼定位到问题出在第几层、哪个区间。如果一个题目理论上应该满足决策单调性但决策表显示并不单调通常只有两种情况w函数不满足四边形不等式或者状态定义本身有问题。此时不是继续调优化代码而是回头改状态设计。5.3 复杂度估算里容易被忽略的常数与数据坑理论上O(n log n)永远比O(n^2)漂亮实战里却不一定。当 n 只有几千O(n^2) 暴力配合良好的常数很可能会碾压一个有大量数组分配和递归调用的 O(n log n) 优化。我在选优化方案前一定会先估算当前数据规模n1e5 时单调队列和四边形不等式优化才有意义n1e3 时直接暴力往往更省事。另一个常见陷阱是“常数隐藏在 w 函数里”。有些题看着 n 只有 5000O(n^2) 似乎可以过但代价函数w(l, r)如果每次都要 O(r-l) 求和实际复杂度就变成 O(n^3) 了。解决办法不是盲目上更高级的优化而是先把 w 的预处理做好比如用前缀和把求和压成 O(1)。这一步经常比换高级优化策略更关键。我还习惯把 DP 优化分成“体能优化”和“结构优化”。体能优化指常数级优化用数组替代 vector、去递归、前缀和预处理。结构优化指压缩复杂度阶数单调队列、四边形不等式、分治二分。上手时先做体能优化如果分析下来复杂度阶数本来就够就不必为了“看起来高级”去套一个复杂结构。最后再分享一点个人体会做 dp 优化最忌讳拿着一道题去套模板。我当初也走过这条路背了一堆板子遇到难题照样卡壳。后来慢慢觉察到与其说模板不够熟不如说没有顺着状态设计和决策枚举的脉络去思考。状态里漏了信息优先补维度转移枚举的决策区间有规律就优先做结构优化。如果你做每道题都能顺着这条脉络走到头dp 优化就不再是碰运气而是一个可以反复复用的思维流程。