【学习笔记】斜率优化 DP
前言
更好的阅读体验?
本文介绍了斜率优化 DP 的推导过程及其基础应用,如果想要有更多了解请自行查阅其它文章。
算法介绍
在平时的 DP 过程中,我们可能会遇到这样的 DP 式子:
单调队列优化 DP 拼劲全力无法战胜,只能请它大哥出场了。
推导一
动态规划当然要考虑最优决策点的位置呀!所以我们假设现在有
如果
如果
这里我们令
将
假设
我们把目光放到
- 如果
k_2<k_1\le a_i ,那么C 是最优点 - 如果
k_2\le a_i<k_1 ,那么C 是最优点 - 如果
a_i<k_2<k_1 ,那么A 是最优点
也就是说,
同样的道理,这里看似有这么多点,但实则只有下凸包上的点会用到:
又因为下凸包的斜率是单调递增的,如果
那不对呀!上凸包怎么能被忽略呢?!
所以如果上面不等式是
推导二
TA 回来了:
先假装看不见
进一步:
令
怎样移向呢?看法则:
-
-
-
b$ 中要有我们要求的 $dp_i
我们把每一个
你看,最优决策点的位置不变,说明最优决策点还是在下凸包的斜率单峰处。
当然,最大化
总结
其实它们本质是相同的,可以相辅相成地来理解。
接下来,明白了最优决策点在什么位置,那该怎么快速地找最优决策点呢?
- 如果状态转移决策具有决策单调性(放在刚才的例子里就是
\forall i>j,a_i>a_j ,即二次项系数a_i 单调递增,此时随着和斜率比较的a_i 的不断增加,由于下凸包上的点单调递增,a_i 卡到下凸包上的点一定越来越靠后),则可以用单调队列维护凸包上的点(单调队列返厂啦!)。 - 如果不具有决策单调性,则根据凸包的单调性二分即可。
例题讲解
P3195 [HNOI2008] 玩具装箱
- 状态设计:定义
dp_i 表示对于前i 个玩具,若i 作为所属分组的最后一个玩具,求总的最小花费。 - 转移方程:
dp_i=\min_{j=1}^{i-1}\{dp_j+(i-(j+1)+(\sum_{k=j}^i C_k)-L)^2\} 设
S_i=\sum_{j=1}^i C_j,M=L+1 ,则转移方程变为:dp_i=\min_{j=1}^{i-1}\{dp_j+(S_i-S_j-M)^2\} 拆开:
dp_i=\min_{j=1}^{i-1}\{dp_j+S_i^2+S_j^2+M^2-2S_iS_j-2S_iM+2S_jM\} 发现了
2S_iS_j ,因此斜率优化必定了。 - 状态初始化:
dp_0=0 。
答案就是
如果当前状态为
令
满足此条件时有
又由于
注意:
- 单调队列中维护凸包上的点对应的
j 。 - 维护的点不应存在三点共线,而应当只维护两端点。
- 单调队列中应保证至少有两个点再求斜率,
deque难以实现且常数大,建议使用手写队列。 - 维护凸包比较斜率时建议不要使用除法,容易被卡精度,交叉相乘是更好的选择。同时,
\dfrac{a}{0} \ge \dfrac{b}{c} 交叉相乘后恒成立,这难道不正是我们一直在找的\dfrac{a}{0} = \infty 的可行实现方案吗?
:::success[Code]
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e4 + 10;
int n, L, c[N];
int dp[N], s[N], a[N], b[N];
int q[N], h = 1, t = 1;
int X(int p) { return b[p]; }
int Y(int p) { return dp[p] + b[p] * b[p]; }
double slope(int a, int b) {
if (X(a) == X(b)) {
if (Y(a) == Y(b)) return 0; // 斜率无法比较
if (Y(a) > Y(b)) return -1e18; // 斜率为负无穷
else return 1e18; // 斜率为正无穷
}
return (Y(a) - Y(b)) * 1.00 / (X(a) - X(b));
}
signed main() {
scanf("%lld%lld", &n, &L);
for (int i = 1; i <= n; i++) scanf("%lld", &c[i]), s[i] = s[i - 1] + c[i];
for (int i = 0; i <= n; i++) a[i] = s[i] + i, b[i] = s[i] + i + L + 1;
q[1] = 0;
for (int i = 1; i <= n; i++) {
// 由于凸包的斜率一定单调递增,于是把队头斜率小于 2 * a[i] 的点删除
while (h < t && slope(q[h], q[h + 1]) <= 2 * a[i]) h++;
int j = q[h];
dp[i] = dp[j] + (a[i] - b[j]) * (a[i] - b[j]); // 状态转移方程
// 把队尾的斜率小于当前点的坐标删除,放入当前点
while (h < t && slope(q[t - 1], q[t]) >= slope(q[t], i)) t--;
q[++t] = i;
}
printf("%lld\n", dp[n]);
return 0;
}
:::
P2365 [IOI 2002] 任务安排
- 状态设计:用
dp_{i,j} 表示前i 个任务分成j 组,i 为第j 组最后一个任务,完成所有任务所需时间的最小值。 - 转移方程:
\begin{aligned} dp_{i,j} &= \min_{j-1\le k<i} dp_{k,j-1} + \sum_{p=k+1}^{i} (\sum_{p=1}^i tim_p+s\times j) \times f_k+s \\ &= \min_{j-1\le k<i} dp_{k,j-1} + (\sum_{p=1}^i tim_p+s\times j) \times \sum_{p=k+1}^{i} f_k+s \end{aligned} 优化一下,假设
T_i=\sum_{j=1}^i tim_j,F_i=\sum_{j=1}^i f_j 。 则上式变为:dp_{i,j} = \min_{j-1\le k<i} dp_{k,j-1} + T_i \times (F_i-F_k)+s - 状态初始化:
dp_{0,0}=0,dp_{0,i}=\infty(i\ge 1)
答案就是
一个完美的
我们发现为了知道当前是第几组,我们耗费了一维空间去存储这个数据,所以可以考虑把这一维扔掉。
但是这样我们就不知道机器启动过几次了!
别慌,冷静分析一下你会发现,如果我们要从
- 状态设计:用
dp_i 表示对于前i 个任务,i 为当前分组最后一组的最后一个任务,完成所有任务所需时间的最小值。 - 转移方程:
dp_i = \min_{0\le j<i} \{dp_{j} + s\times(F_n-F_j) + T_i\times(F_i-F_j)\} - 状态初始化:
dp_{0}=0
答案就是
:::success[Code]
#include <bits/stdc++.h>
#define maxn 5010
#define int long long
using namespace std;
int n, s, sc[maxn], st[maxn], f[maxn];
signed main() {
cin >> n >> s;
for (int i = 1; i <= n; i++) cin >> st[i] >> sc[i];
for (int i = 1; i <= n; i++) st[i] += st[i - 1], sc[i] += sc[i - 1];
memset(f, 0x3f, sizeof(f));
f[0] = 0;
for (int i = 1; i <= n; i++)
for (int j = 0; j < i; j++)
f[i] = min(f[i], f[j] + s * (sc[n] - sc[j]) + st[i] * (sc[i] - sc[j]));
cout << f[n] << endl;
return 0;
}
:::
P10979 任务安排 2
状态转移方程中有含
依旧扔掉
令
如果将
易知最优决策点是下凸包上最后一个斜率小于等于
在这道题中,
:::success[Code]
#include <bits/stdc++.h>
#define N 300010
#define int long long
using namespace std;
int n, s, l = 1, r = 1, q[N];
int c[N], tim[N], f[N];
int dy(int j, int k) { return f[j] - f[k]; }
int dx(int j, int k) { return c[j] - c[k]; }
signed main() {
scanf("%lld%lld", &n, &s);
for (int i = 1; i <= n; i++) {
scanf("%lld%lld", tim + i, c + i);
tim[i] += tim[i - 1];
c[i] += c[i - 1];
}
q[1] = 0;
for (int i = 1; i <= n; i++) {
while (l < r && dy(q[l + 1], q[l]) <= (tim[i] + s) * dx(q[l + 1], q[l])) l++;
int j = q[l];
f[i] = f[j] + tim[i] * (c[i] - c[j]) + s * (c[n] - c[j]);
while (l < r && dy(i, q[r]) * dx(q[r], q[r - 1]) <= dy(q[r], q[r - 1]) * dx(i, q[r])) r--;
q[++r] = i;
}
printf("%lld\n", f[n]);
return 0;
}
:::
P5785 [SDOI2012] 任务安排
在这道题中,
:::success[Code]
#include <bits/stdc++.h>
#define N 300010
#define int long long
using namespace std;
int n, s, h = 1, t = 0, q[N];
int c[N], tim[N], f[N];
int dy(int j, int k) { return f[j] - f[k]; }
int dx(int j, int k) { return c[j] - c[k]; }
int find(int i) {
if (h == t) return q[h];
int l = h, r = t, res = h;
while (l <= r) {
int mid = (l + r) >> 1;
if (dy(q[mid + 1], q[mid]) <= dx(q[mid + 1], q[mid]) * (tim[i] + s)) l = mid + 1;
else r = mid - 1, res = mid;
}
return q[res];
}
signed main() {
scanf("%lld%lld", &n, &s);
for (int i = 1; i <= n; i++) {
scanf("%lld%lld", tim + i, c + i);
tim[i] += tim[i - 1];
c[i] += c[i - 1];
}
for (int i = 1; i <= n; i++) {
while (h < t && dy(i - 1, q[t]) * dx(q[t], q[t - 1]) <= dx(i - 1, q[t]) * dy(q[t], q[t - 1])) t--;
q[++t] = i - 1;
int j = find(i);
f[i] = f[j] + tim[i] * (c[i] - c[j]) + s * (c[n] - c[j]);
}
printf("%lld\n", f[n]);
return 0;
}
:::
参考资料
- 斜率优化 dp