「笔记」斜率优化 DP
写在前面
个人认为有能力来学斜率优化的已经不需要过多对简单实现的解释了(
下文的重点是斜率优化的原理及推导过程。

图文无关
引入
一个经典题:「HNOI2008」玩具装箱 TOY。
给定一列有序的 个物品,第 个物品的价值为 。
给定参数 ,要求将一列物品分成若干段,对于一段 的代价为 。求分段的最小代价和。
,。
1S,128MB。
首先考虑暴力 DP,设 表示将前 个物品分配完的最小代价,转移时枚举上一段的结尾 ,将 构成单独的一段,则有:
暴力直接做的复杂度是 级别的,显然过不了。
发现方程包含了关于 的函数的乘积项,可以考虑使用斜率优化 DP。
分析
设当前枚举到 ,简记 ,,上式等于:
改变一下形式:
设函数:
对于任意一个决策 ,有:
显然为最小化 应最小化 。考虑把所有决策一一对应到二维坐标系上的点上,决策 对应 。又对于确定的 , 是一个定值,找到最优决策的过程可以看做找到一条斜率为 的,经过点 中任意一个的直线,使其截距 最小的过程。
一种显然的想法是一条斜率为 的直线自下向上移动,移动过程中第一个出现在直线上的点即为最优的决策,如图所示。
图片来自:OI-Wiki
观察图像可以发现,该过程中最优决策点一定在点 的下凸包上,证明见下。下凸包上相邻两点构成直线的斜率单调不降,结合直线自下向上移动的过程,最优的决策点 一定满足:
- 的斜率小于 。
- 的斜率大于 。
上述 与 分别表示下凸包上 前后的两个决策点。
又此题中 随 的增大而增大,根据上述结论可以考虑使用单调队列维护最优决策点,队首即为指向最优决策点的指针。相邻两点构成直线的斜率单调不降,指针在下凸包上只会单调右移。每次根据指针指向的元素转移,再将新的决策点加入凸包即可。
考虑如何快速维护下凸包,可以使用单调队列顺便维护。记新决策点、队列尾的点、队列尾的前一个点分别为 ,,, 表示过点 的直线的斜率。为保证下凸包上线段的单调性,每次加入新决策点时,检查斜率 是否成立。若等式成立则从队尾弹出 ,直到等式不成立时将 入队。
此过程实际上与求凸包的 Andrew 算法 是一致的,感兴趣的读者可以略作了解。
每个决策点最多进出队各一次,上述过程总复杂度为 级别。
推导
简单推导最优决策点一定在下凸包上。
对于两个决策点 与 (),若决策 优于 ,则有:
考虑上式的实际意义。若过点 与 的直线的斜率小于 ,则决策 优于 。
对于三个如下图所示的不下凸的决策点 、 与 (),显然有 。对它们与 的关系分类讨论:

- :直线上移时一定先接触到 , 不劣于 。
- :原理同上,、 均不劣于 。
- :原理同上, 不劣于 。
则 不可能成为最优决策,故有贡献的决策点不可能呈现不下凸形状。
代码
代码下面罗列出了一些需要注意的细节,建议结合代码阅读。
复制复制//知识点:斜率优化 /* By:Luckyblock 按照上述分析直接做。 */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 5e4 + 10; //============================================================= int n, h = 1, t, q[kN]; LL L, s[kN], f[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } LD X(int x_) { return s[x_]; } LD Y(int x_) { return f[x_] + s[x_] * s[x_]; } LD K(int x_, int y_) { if (X(x_) == X(y_)) return (Y(y_) > Y(x_) ? 1e18 : -1e18); return (LD) ((Y(y_) - Y(x_)) / (X(y_) - X(x_))); } int Query(int now_) { LD know = -2 * (L - s[now_]); while (h < t && K(q[h], q[h + 1]) <= know) ++ h; return q[h]; } void Insert(int now_) { while (h < t && K(q[t - 1], q[t]) >= K(q[t - 1], now_)) -- t; q[++ t] = now_; } //============================================================= int main() { n = read(), L = read() + 1; Insert(0); for (int i = 1, j = 0; i <= n; ++ i) { s[i] = s[i - 1] + read() + 1; j = Query(i); f[i] = f[j] + 1ll * (s[i] - s[j] - L) * (s[i] - s[j] - L); Insert(i); } printf("%lld\n", f[n]); return 0; }
实现上的细节
- 大部分情况下需要将决策点 入队。
- 保证队列中至少有两个元素(构成直线),单调队列的判断条件要写成
h<t
。 - 最优决策点在上凸包还是下凸包上根据具体方程具体分析。分析的思路参考上面代数推导部分。
long double
。- 可能会出现仅满足 单调不降 的情况,此时会造成两直线的斜率相同。可以考虑在求斜率时写成
if (X(x_) == X(y_)) return (Y(y_) > Y(x_) ? 1e18 : -1e18);
以及判断决策优劣时写成<=
和>=
来解决。
总结
转移方程呈现 的形式,即方程中出现乘积项 时,可以按照上述套路,略作变化得到 ,将 看做斜率 ,将 看做自变量 , 看做函数值 。
此时最小/最大化 的过程与最小/最大化过众多决策点之一的、斜率确定的函数截距的过程一致。可以看做一条斜率为 的直线自下向上移动,移动过程中第一个出现在直线上的点即为最优的决策。
可以考虑先维护包含最优决策点的凸包,再找到凸包上的最优决策点。当 均如例题一样单调时,可使用单调队列维护凸包并找到最优决策点。否则要考虑使用其他手段维护。 不单调会影响最优决策点的位置, 不单调会影响对凸包的维护,详见下方例题部分。
例题
P2900 [USACO08MAR]Land Acquisition G
、 均单调,单调队列。
给定无序的 个物品,第 个物品有两个属性,可以表示为 。
要求将这一列物品分成若干段集合,对于分出的一个集合 ,其代价为 ,求划分的最小代价之和。
,。
1S,128MB。
纯暴力对正解并没有什么启发性,这里直接快进到如何斜率优化。
物品无序,考虑钦定一种物品的排列顺序,使得按此顺序划分时转移方程中出现乘积项。考虑将物品按 降序为第一优先级, 升序为第二优先级排序,此时对于连续的一段 ,其中 一定为 ,但 不确定。此时考虑通过削除无贡献元素使得第二维也存在单调性,使得 一定为 。代码如下所示:
void Init() { n = read(); for (int i = 1; i <= n; ++ i) a[i] = (Data) {read(), read()}; std::sort(a + 1, a + n + 1, CMP); int tmp = 0; for (int i = 1; i <= n; ++ i) { //去除无贡献元素 if (a[i].b > a[tmp].b) a[++ tmp] = a[i]; } n = tmp; }
此时对于划分出的一段 ,其代价为 。考虑朴素 DP,设 表示按此顺序划分前 个物品的最小代价和,转移时枚举上一段的结尾,则有:
这是一个斜率优化的经典形式,设:
代入方程,对于任意一个决策 ,有:
对于上式,显然应最小化截距 的值,可以使用与上面类似的代数推导法证明最优决策点一定位于下凸包上。又 、 均随 的增加不降,按照例题的套路用单调队列维护下凸包即可。
总时间复杂度 ,瓶颈是排序。
//知识点:斜率优化 /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 5e4 + 10; //============================================================= int n, h = 1, t, q[kN]; LL f[kN]; struct Data { int a, b; } a[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } bool CMP(Data fir_, Data sec_) { if (fir_.a != sec_.a) return fir_.a > sec_.a; return fir_.b < sec_.b; } LD X(int x_) { return -a[x_ + 1].a; } LD Y(int x_) { return f[x_]; } LD K(int x_, int y_) { if (X(x_) == X(y_)) return (Y(y_) > Y(x_) ? 1e18 : -1e18); return (LD) ((Y(y_) - Y(x_)) / (X(y_) - X(x_))); } int Query(int now_) { LD know = a[now_].b; while (h < t && K(q[h], q[h + 1]) <= know) ++ h; return q[h]; } void Insert(int now_) { while (h < t && K(q[t - 1], q[t]) >= K(q[t - 1], now_)) -- t; q[++ t] = now_; } void Init() { n = read(); for (int i = 1; i <= n; ++ i) a[i] = (Data) {read(), read()}; std::sort(a + 1, a + n + 1, CMP); int tmp = 0; for (int i = 1; i <= n; ++ i) { //去除无贡献元素 if (a[i].b > a[tmp].b) a[++ tmp] = a[i]; } n = tmp; } //============================================================= int main() { Init(); Insert(0); for (int i = 1, j = 0; i <= n; ++ i) { j = Query(i); f[i] = f[j] + 1ll * a[j + 1].a * a[i].b; Insert(i); } printf("%lld\n", f[n]); return 0; }
「SDOI2016」征途
、 均单调,单调队列。
王 道 征 途
「そうですね…やっぱり僕は、王道を征く、ソープ系ですか」
给定一列有序的 个物品,第 个物品的价值为 。
给定参数 ,要求将一列物品分成 段,最小化每段长度之和的方差 ,输出 。
,。
1S,256MB。
记划分出的 段的和分别为 ,将 展开一下:
发现最后的式子中 等于 ,是一个定值,仅需最小化第一项即可。
按传统先写个暴力,设 表示将前 个数划分为 段时 的最小值,转移时枚举段数 和上一段的结尾 ,则有:
预处理前缀和后暴力转移复杂度 ,考虑优化。
发现后一项是一个区间和的平方的形式,它同时与 有关。考虑对 做一个前缀和,设 ,将其代入原式:
考虑先通过枚举固定 ,对于每一个决策 ,都有:
这是一个显然的斜率优化的形式,套路地设:
对于上式,显然应最小化截距 的值,易证最优决策点一定位于下凸包上。又 、 均随 的增加而增加,单调队列维护下凸包即可。注意一些初始化的小问题,详见代码。
总复杂度 级别。
//知识点:斜率优化 /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 3000 + 10; //============================================================= int n, m, h = 1, t, q[kN]; LL s[kN], f[kN][kN]; //代码中 f[j][i] 表示将前 i 个数划分为 j 段时 sum b_i^2 的最小值,与上述分析中相反。 //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } LD X(int x_) { return s[x_]; } LD Y(int id_, int x_) { return f[id_][x_] + s[x_] * s[x_]; } LD K(int id_, int x_, int y_) { if (X(x_) == X(y_)) return (Y(id_, y_) > Y(id_, x_) ? 1e18 : -1e18); return (LD) ((Y(id_, y_) - Y(id_, x_)) / (X(y_) - X(x_))); } int Query(int id_, int now_) { LD know = 2 * s[now_]; while (h < t && K(id_, q[h], q[h + 1]) <= know) ++ h; return q[h]; } void Insert(int id_, int now_) { while (h < t && K(id_, q[t - 1], q[t]) >= K(id_, q[t - 1], now_)) -- t; q[++ t] = now_; } //============================================================= int main() { n = read(), m = read(); for (int i = 1; i <= n; ++ i) s[i] = s[i - 1] + read(); memset(f, 63, sizeof(f)); f[0][0] = 0; for (int k = 1; k <= m; ++ k) { h = 1, t = 0; Insert(k - 1, 0); for (int i = 1; i <= n; ++ i) { if (i >= k) { int j = Query(k - 1, i); f[k][i] = f[k - 1][j] + (s[i] - s[j]) * (s[i] - s[j]); } Insert(k - 1, i); } } printf("%lld\n", 1ll * m * f[m][n] - 1ll * s[n] * s[n]); return 0; }
CF311B Cats Transport
、 均单调,单调队列。
一条数轴上有 个点,第 个点与第 个点的距离为 ,有 个人在第 个点上。有 只 neko,第 只 neko 位于第 个点上,neko 会在 时间开始等待人的到来。
没个人可以从任意时间从第 个点出发,按编号顺序依次经过各点,速度为 1 个单位长度 1 秒,中间不停下。每经过一个点,就会将该点上处于等待状态的 neko 接上。
每只 neko 的等待时间为其被接上时间与开始等待时间的差。要求安排每个人出发的时间,最小化 neko 等待的时间之和。
,,,,。
2S,256MB。
可以简单地 YY 出一个与 相关的 DP 状态,但 过大,猜测 DP 状态与 无关,考虑找下结论。可以发现每个人接上的 neko 中,至少有一只恰好刚开始等待人的到来。正确性显然,若所有 neko 都是等待了一段时间后再被接上,不如提前人的出发时间,直到有一只刚开始等待,在不影响接上了哪些 neko 的前提下减少等待时间之和。
前缀和预处理点 到各点的距离 。对于每只 neko,容易求得一个人从何时出发能够恰好接上它,显然该时间为 。
考虑将所有 neko 按照 排序。显然在满足一开始的结论的前提下,每个人接到的 neko 都对应排序后的一段区间 ,刚开始等的 neko 一定是第 只 neko(即 最大的),则这个人的出发时间为 ,区间内 neko 的等待时间之和为 。
问题可以抽象为给定一长度为 的数列 ,要求将 分为 段,每段的代价为该段最右的数减去该段每个值的和,最小化代价和。
对于抽象后的问题,设 ,设 表示将前 个数分为 段的最小代价和,转移时先枚举段数 ,再枚举最后一段,则有:
方程中出现了乘积项,先通过枚举固定 后,这显然是一个可以斜率优化的形式,套路地设:
显然应最小化 ,又 与 都随 的增加单调不降,单调队列维护下凸包即可。
总复杂度 级别。
//知识点:斜率优化 /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 1e5 + 10; //============================================================= int n, m, p, head = 1, tail, q[kN]; LL sumd[kN], t[kN], s[kN], f[kN][101]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } LD X(int x_) { return x_; } LD Y(int id_, int x_) { return f[x_][id_] + s[x_]; } LD K(int id_, int x_, int y_) { if (X(x_) == X(y_)) return (Y(id_, y_) > Y(id_, x_) ? 1e18 : -1e18); return (LD) ((Y(id_, y_) - Y(id_, x_)) / (X(y_) - X(x_))); } int Query(int id_, int now_) { LD know = t[now_]; while (head < tail && K(id_, q[head], q[head + 1]) <= know) ++ head; return q[head]; } void Insert(int id_, int now_) { while (head < tail && K(id_, q[tail - 1], q[tail]) >= K(id_, q[tail - 1], now_)) -- tail; q[++ tail] = now_; } void Init() { n = read(), m = read(), p = read(); for (int i = 2; i <= n; ++ i) sumd[i] = sumd[i - 1] + read(); for (int i = 1; i <= m; ++ i) { int h = read(), nowt = read(); t[i] = nowt - sumd[h]; } std::sort(t + 1, t + m + 1); for (int i = 1; i <= m; ++ i) s[i] = s[i - 1] + t[i]; } //============================================================= int main() { Init(); memset(f, 63, sizeof(f)); f[0][0] = 0; for (int k = 1; k <= p; ++ k) { head = 1, tail = 0; Insert(k - 1, 0); for (int i = 1; i <= m; ++ i) { if (i >= k) { int j = Query(k - 1, i); f[i][k] = f[j][k - 1] + 1ll * (i - j) * t[i] - s[i] + s[j]; } Insert(k - 1, i); } } printf("%lld\n", f[m][p]); return 0; }
P2365 任务安排
、 均单调,单调队列。
三 步 走 战 略
给定一列 个有序的物品,每个物品有两个属性 ,给定参数 。
要求将物品分为任意段,第 段 的代价为 ,要求最小化分段的代价之和。
,,。
1S,128MB。
发现分到第几段对答案有影响,设 表示将前 个任务分为 段的最小费用和,转移时枚举段数 和最后一段,则有:
预处理前缀和,暴力转移时间复杂度 ,空间复杂度 。空间和时间都菜爆了。
发现在上述算法中必须枚举分到第几段,考虑能否优化掉状态的这一维,并优化转移。
这里用到了一种叫做「费用提前计算」的思想。发现每次转移将 这段分出后,后续元素的代价里都会加上 ,考虑在状态转移中加上这部分的影响。具体地,将状态删去一维,方程改写为如下所示:
状态转移方程很容易理解。此时已经无法准确定义 的含义了,但 一定表示将所有物品划分为某几段的最小代价和,且这样转移一定可以保证 的正确性。
预处理前缀和后暴力转移即可,时间复杂度 ,空间复杂度 。
上述 算法已经可以通过原题了,但这还不够。
记 ,,代入转移方程并略作变换:
这是一个显然的斜率优化的形式,设:
由于 , 与 均单调递增,套路地单调队列维护下凸包即可。
总时空复杂度均为 。
//知识点:斜率优化,费用提前计算 /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 5e3 + 10; const LL kInf = 9e18 + 2077; //============================================================= int n, h = 1, t, q[kN]; LL s, ans = kInf, f[kN], sumt[kN], sumg[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } LD X(int x_) { return sumg[x_]; } LD Y(int x_) { return f[x_] - s * sumg[x_]; } LD K(int x_, int y_) { if (X(x_) == X(y_)) return (Y(y_) > Y(x_) ? 1e18 : -1e18); return (LD) ((Y(y_) - Y(x_)) / (X(y_) - X(x_))); } int Query(int now_) { LD know = sumt[now_]; while (h < t && K(q[h], q[h + 1]) <= know) ++ h; return q[h]; } void Insert(int now_) { while (h < t && K(q[t - 1], q[t]) >= K(q[t - 1], now_)) -- t; q[++ t] = now_; } //============================================================= int main() { n = read(), s = read(); for (int i = 1; i <= n; ++ i) { sumt[i] = sumt[i - 1] + read(); sumg[i] = sumg[i - 1] + read(); } Insert(0); for (int i = 1; i <= n; ++ i) { int j = Query(i); f[i] = f[j] + sumt[i] * (sumg[i] - sumg[j]) + s * (sumg[n] - sumg[j]); Insert(i); } printf("%lld\n", f[n]); return 0; }
「SDOI2012」任务安排 3
单调, 不单调,单调栈,二分。
与上题仅有数据范围不同
给定一列 个有序的物品,每个物品有两个属性 ,给定参数 。
要求将物品分为任意段,第 段 的代价为 ,要求最小化分段的代价之和。
,,,。
1S,512MB。
考虑上题最后得到的斜率优化的式子。与上题不同的是,本题中可能出现 , 是不单调的,这影响了最优决策点的选择,无法使用单调队列选择最优决策点。但 单调,使用单调队列维护下凸包的做法是正确的。
仍考虑使用单调队列维护下凸包,每次查询最优决策点时在凸包上二分,找到第一个使得左侧斜率小于 ,右侧斜率不小于 的位置即为最优决策点,不从队首弹出元素。可以发现此时的“单调队列”实际上是一个单调栈。我们实际上是在用 Andrew 算法 维护凸包。
总时间复杂度变为 。
//知识点:斜率优化 /* By:Luckyblock */ #include <algorithm> #include <cctype> #include <cstdio> #include <cstring> #define LL long long #define LD long double const int kN = 3e5 + 10; const LL kInf = 9e18 + 2077; //============================================================= int n, h = 1, t, q[kN]; LL s, ans = kInf, f[kN], sumt[kN], sumg[kN]; //============================================================= inline int read() { int f = 1, w = 0; char ch = getchar(); for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1; for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0'); return f * w; } void Chkmin(LL &fir, LL sec) { if (sec < fir) fir = sec; } LD X(int x_) { return sumg[x_]; } LD Y(int x_) { return f[x_] - s * sumg[x_]; } LD K(int x_, int y_) { if (X(x_) == X(y_)) return (Y(y_) > Y(x_) ? 1e18 : -1e18); return (LD) ((Y(y_) - Y(x_)) / (X(y_) - X(x_))); } bool Check(LD know_, int mid_) { return know_ <= K(q[mid_], q[mid_ + 1]); } int Query(int now_) { LD know = sumt[now_]; int ret = t; for (int l = h, r = t - 1; l <= r; ) { int mid = (l + r) >> 1; if (Check(know, mid)) { ret = mid; //q[ret] 是最靠右的使得 check 为 true 的位置,即 q[ret] 左侧斜率小于 k,右侧斜率不小于 k。 r = mid - 1; } else { l = mid + 1; } } return q[ret]; } void Insert(int now_) { while (h < t && K(q[t - 1], q[t]) >= K(q[t - 1], now_)) -- t; q[++ t] = now_; } //============================================================= int main() { n = read(), s = read(); for (int i = 1; i <= n; ++ i) { sumt[i] = sumt[i - 1] + read(); sumg[i] = sumg[i - 1] + read(); } Insert(0); for (int i = 1; i <= n; ++ i) { int j = Query(i); f[i] = f[j] + sumt[i] * (sumg[i] - sumg[j]) + s * (sumg[n] - sumg[j]); Insert(i); } printf("%lld\n", f[n]); return 0; }
写在最后
鸣谢:
【学习笔记】动态规划—斜率优化DP(超详细) - 辰星凌
斜率优化 - OI Wiki
P3195 [HNOI2008]玩具装箱TOY(斜率优化入门) - hhz6830975 的博客
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!