Codeforces 斜率优化与 Slope Trick 题解两则:CF311B 与 CF713C
以 Codeforces 311B Cats Transport 与 713C Sonya and Problem Wihtout a Legend 为例,推导前缀代价化简、下凸壳单调队列维护与分段线性凸函数的 Slope Trick 堆维护实现。
凸性与动态规划转移降维
一维与二维动态规划中,当状态转移方程包含与决策变量和状态变量同时耦合的交叉项,或者状态本身关于自变量呈分段线性凸性时,直接枚举决策往往会带来 甚至更高的时间复杂度。利用代价函数的凸性,两类最常见的降维手段分别是:
- 斜率优化(Convex Hull Trick, CHT):将转移改写为直线簇 的极值查询或点积查询,通过单调队列或李超线段树维护下(上)凸壳。
- Slope Trick:当整张 DP 表 关于连续或离散自变量 为分段线性凸函数(Piecewise Linear Convex Function)时,不显式离散化 ,而是直接用优先队列维护斜率变化拐点集合。
下文分别以 Codeforces 311B(Div. 1 B, 2400)与 Codeforces 713C(Div. 1 C, 2300)为例给出完整推导与实现。
CF311B Cats Transport:前缀转化与单调队列斜率优化
题目抽象
山脚为 号山丘,共有 座山丘,相邻山丘间距给定,可预处理出 号山丘到第 座山丘的距离 ()。共有 只猫,第 只猫在山丘 玩耍至时刻 。
现有 名饲养员从 号山丘出发,各自选择一个出发时刻 ,以单位速度向编号增大的方向匀速行走(经过山丘 的时刻为 ),沿途接走所有满足 且尚未被接走的猫。求所有猫等待时间之和的最小值。
数据范围:,,。
独立化每只猫的出发下界
若饲养员在时刻 从 号山丘出发,他到达山丘 的时刻为 。能接走第 只猫当且仅当:
定义每只猫的特征阈值:
此时该猫的等待时间恰为:
至此,山丘坐标 已完全消去。问题等价于:数轴上有 个点 ,选定 个锚点 ,每个点 归属某个满足 的锚点,代价为 ,求最小总代价。
将 升序排序后,任意一名饲养员负责的猫必然构成排序后的一段连续区间 ,且最优出发时刻必取区间右端点 。设前缀和 ,则区间 的等待代价为:
转移方程与凸壳展开
设 表示前 名饲养员接走前 只猫的最小等待时间,转移为:
固定层数 ,将只与 相关的项移出 ,并将 相关项整理为一次函数形式:
对每个决策 ,将其视为平面上的点 :
若两个决策 中 优于或等于 ,则:
由于横坐标 严格递增,且排序后查询斜率 单调不降,可以用双端队列维护点集 的下凸壳:
- 队首弹出:若队首前两个点 满足斜率 ,因后续 , 永远不可能再成为最优决策,弹出 。
- 转移取值:此时队首 即为状态 的最优决策。
- 队尾维护凸性:加入新决策点 前,若队尾两点 与 满足 ,说明 位于上凸折点,弹出 。
为避免浮点精度损失,斜率比较统一改写为交叉相乘(使用 __int128 防止溢出):
C++ 实现
每个状态在每层入队、出队各一次,单层复杂度 ,总时间复杂度 ,空间复杂度可滚动压缩至 。
#include <algorithm>
#include <iostream>
#include <vector>
using i64 = long long;
using i128 = __int128_t;
constexpr i64 INF = 1LL << 60;
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n, m, p;
if (!(std::cin >> n >> m >> p)) return 0;
std::vector<i64> d(n + 1, 0);
for (int i = 2; i <= n; ++i) {
i64 dist;
std::cin >> dist;
d[i] = d[i - 1] + dist;
}
std::vector<i64> a(m + 1, 0);
for (int i = 1; i <= m; ++i) {
int h;
i64 t;
std::cin >> h >> t;
a[i] = t - d[h];
}
std::sort(a.begin() + 1, a.end());
std::vector<i64> s(m + 1, 0);
for (int i = 1; i <= m; ++i) {
s[i] = s[i - 1] + a[i];
}
p = std::min(p, m);
std::vector<i64> dp_prev(m + 1, INF), dp_cur(m + 1, INF);
dp_prev[0] = 0;
std::vector<int> q(m + 1);
for (int k = 1; k <= p; ++k) {
auto y = [&](int j) -> i64 { return dp_prev[j] + s[j]; };
int head = 0, tail = 0;
q[tail++] = 0;
dp_cur[0] = 0;
for (int i = 1; i <= m; ++i) {
while (tail - head >= 2) {
int j1 = q[head], j2 = q[head + 1];
if (y(j2) - y(j1) <= a[i] * (j2 - j1)) {
++head;
} else {
break;
}
}
int best = q[head];
dp_cur[i] = dp_prev[best] + (i - best) * a[i] - (s[i] - s[best]);
if (dp_prev[i] >= INF / 2) continue;
while (tail - head >= 2) {
int j1 = q[tail - 2], j2 = q[tail - 1];
i128 lhs = static_cast<i128>(y(j2) - y(j1)) * (i - j2);
i128 rhs = static_cast<i128>(y(i) - y(j2)) * (j2 - j1);
if (lhs >= rhs) {
--tail;
} else {
break;
}
}
q[tail++] = i;
}
dp_prev.swap(dp_cur);
}
std::cout << dp_prev[m] << '\n';
return 0;
}
CF713C Sonya and Problem Wihtout a Legend:Slope Trick
题目抽象与非严格化变换
给定长度为 (,加强版可达 )的整数序列 。每次操作可以将任意元素加 或减 ,求将序列变为严格递增整数序列 所需的最小操作次数:
因为 ,严格不等式 等价于 ,进而等价于:
令 ,,原问题转化为求非严格递增序列 ,使得:
下文为书写简洁,将变换后的 仍记作 。
分段线性凸函数的递推性质
定义 为考虑前 个位置且 时的最小代价,并定义前缀最小值函数 。递推关系为:
可以通过归纳法证明: 与 均为关于 的分段线性凸函数,且每段的斜率均为整数。
- 取前缀最小值算子 :设 在 处取得全局最小值(即左侧斜率 ,右侧斜率 )。对于 ,有 ,即将 右侧所有大于 的斜率全部截断为 ,左侧保持不变。
- 叠加绝对值函数 :函数 在 处斜率为 ,在 处斜率为 。与 相加后:
- 区间 上的斜率全部减 ;
- 区间 上的斜率全部加 ;
- 这等价于在拐点多重集(斜率每变化 记入一个拐点)中插入两个 。
用大根堆维护左侧拐点集
设 在 上的左端渐近斜率为 ,右端斜率为 ,因此它恰好由 个斜率变化拐点 唯一确定(每经过一个拐点 ,斜率增加 ),其中最大值 即为斜率变为 的左端点。
在第 步加入 并过渡到 时:
- 向拐点多重集 中加入两个 。此时 的最右端斜率由 变为 。
- 为得到 ,需要抹平最右侧斜率为 的射线,即弹出多重集 中的最大元素 。
- 若刚才加入的 ,则最小值函数值增加了 ;若 ,则加入两个 再弹出一个 ,最小值不变。
整个过程只需一个大根堆 std::priority_queue<long long>,每次压入两次 、弹出一次堆顶并累加代价差 q.top() - a[i],时间复杂度从 降至 。
#include <iostream>
#include <queue>
using i64 = long long;
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n;
if (!(std::cin >> n)) return 0;
std::priority_queue<i64> pq;
i64 ans = 0;
for (int i = 1; i <= n; ++i) {
i64 a;
std::cin >> a;
a -= i;
pq.push(a);
pq.push(a);
ans += pq.top() - a;
pq.pop();
}
std::cout << ans << '\n';
return 0;
}
构造最优序列
若题目同时要求输出最优序列(如洛谷 P4331),只需记录每一步求得 时最右侧斜率为 的左端点 ,最后从后向前做一次后缀截断:
这是因为 在 上导数为 ,而在 左侧严格递减,因此在满足 的约束下取 必然同时达到每一步的下界。