动态规划是算法竞赛与工程优化中的核心武器,但朴素的二重循环往往让人望而却步。当转移方程具备特殊的线性结构时,凸包技巧(Convex Hull Trick)能将 O(n^2) 的复杂度优化到 O(n log n) 甚至 O(n)。这项技术在竞赛圈中被称为”斜率优化”,在工业界则常见于生产调度、资源分配等场景。
本文将从最基本的转移方程改写出发,逐步构建凸包技巧的完整理论框架,再深入 Li Chao Tree 和 Slope Trick 两种进阶数据结构,最后通过三道经典题目将理论落地为代码。
一、转移方程的线性形式识别
1.1 从朴素 DP 说起
考虑一个典型的一维 DP 问题:
\[dp[i] = \min_{j < i} \{ dp[j] + w(j, i) \}\]
其中 \(w(j, i)\) 是从状态 \(j\) 转移到状态 \(i\) 的代价。朴素实现需要枚举所有 \(j\),时间复杂度为 \(O(n^2)\)。
关键的观察是:如果 \(w(j, i)\) 可以被拆分为只依赖 \(j\) 的项和只依赖 \(i\) 的项的乘积加上常数项,那么转移方程就具有线性形式。
1.2 线性形式的标准化
具体地,如果转移方程可以改写为:
\[dp[i] = \min_{j < i} \{ k(j) \cdot x(i) + b(j) \} + C(i)\]
其中: - \(k(j)\):仅依赖于 \(j\) 的”斜率” - \(x(i)\):仅依赖于 \(i\) 的”自变量” - \(b(j)\):仅依赖于 \(j\) 的”截距” - \(C(i)\):仅依赖于 \(i\) 的常数项(对所有 \(j\) 相同)
那么,对于固定的 \(i\),我们需要在一组直线 \(\{y = k(j) \cdot x + b(j)\}\) 中,查询 \(x = x(i)\) 处的最小值。这就是凸包技巧的核心。
1.3 识别技巧
在实际问题中,转移方程不总是一眼能看出线性形式。常用的识别步骤:
第一步,展开 \(w(j, i)\),将所有含 \(j\) 和 \(i\) 交叉项的部分找出来。
第二步,把交叉项 \(f(j) \cdot g(i)\) 视为”斜率乘自变量”。
第三步,把纯 \(j\) 的项归入截距,纯 \(i\) 的项归入外部常数。
举例来说,若转移方程为:
\[dp[i] = \min_{j < i} \{ dp[j] + (s[i] - s[j])^2 \}\]
展开后得到:
\[dp[i] = \min_{j < i} \{ dp[j] + s[i]^2 - 2 \cdot s[i] \cdot s[j] + s[j]^2 \}\]
重新整理:
\[dp[i] = \min_{j < i} \{ (-2 \cdot s[j]) \cdot s[i] + (dp[j] + s[j]^2) \} + s[i]^2\]
这就是标准的线性形式,其中 \(k(j) = -2 \cdot s[j]\),\(x(i) = s[i]\),\(b(j) = dp[j] + s[j]^2\),\(C(i) = s[i]^2\)。
1.4 何时不能用凸包技巧
并非所有 DP 都能应用凸包技巧。以下情况需要注意:
- 转移代价中存在 \(f(j) \cdot g(i) \cdot h(i)\) 等多项交叉,无法化为单一斜率形式。
- 转移关系不是简单的区间 \([0, i-1]\),而是有复杂的依赖关系。
- 决策集合随状态变化有非单调的增删操作。
遇到上述情况,可能需要分治优化、四边形不等式等其他技术。
二、将 DP 转移解释为直线集的最值查询
2.1 几何直觉
将每个决策 \(j\) 视为二维平面上的一条直线 \(L_j: y = k(j) \cdot x + b(j)\)。那么对于查询点 \(x = x(i)\),\(dp[i]\) 等于所有直线在 \(x(i)\) 处的最小值加上 \(C(i)\)。
几何上,多条直线在平面上会形成一个”信封”形状。所有直线的逐点最小值形成下包络线(Lower Envelope),所有直线的逐点最大值形成上包络线(Upper Envelope)。
2.2 下凸包与上凸包
如果我们要求的是 \(\min\),对应的就是下包络线。下包络线是一条分段线性的凸函数,它的各段来自不同的直线。相邻两段直线的交点就是”决策切换点”——当查询点 \(x\) 越过这个交点时,最优决策从一条直线切换到另一条。
下包络线上保留的直线集合称为下凸包(Lower Convex Hull of Lines)。对于 \(\max\) 查询,则对应上凸包。
2.3 凸包的性质
下凸包上的直线按斜率从小到大排列。如果有三条直线 \(L_a, L_b, L_c\),斜率满足 \(k_a < k_b < k_c\),那么 \(L_b\) 在凸包上当且仅当 \(L_a\) 和 \(L_c\) 的交点在 \(L_b\) 的”下方”。
更形式化地,设 \(L_a \cap L_c\) 的横坐标为 \(x_{ac}\),\(L_a \cap L_b\) 的横坐标为 \(x_{ab}\),则 \(L_b\) 在凸包上当且仅当 \(x_{ab} < x_{ac}\),即 \(L_b\) 先于 \(L_c\) 成为最优。
判定条件的计算公式:
对于三条直线 \(L_1(k_1, b_1)\)、\(L_2(k_2, b_2)\)、\(L_3(k_3, b_3)\)(其中 \(k_1 \le k_2 \le k_3\)),\(L_2\) 应被删除当且仅当:
\[(b_3 - b_1) \cdot (k_2 - k_1) \le (b_2 - b_1) \cdot (k_3 - k_1)\]
这个叉积判定避免了浮点除法,是实现中的关键。
2.4 复杂度分析的基础
凸包上最多有 \(n\) 条直线。每次插入一条新直线时,最多删除若干已有直线,但每条直线至多被删除一次。因此,整个过程中所有插入和删除的总代价是 \(O(n)\)。
查询的代价取决于查询点的顺序:如果查询点单调递增,可以用指针滑动实现 \(O(1)\) 单次查询;否则需要二分查找,单次 \(O(\log n)\)。
三、斜率单调时的 O(n) 算法:单调栈维护凸包
3.1 前提条件
当以下两个条件同时满足时,凸包技巧可以达到 \(O(n)\):
条件一:直线的插入顺序使得斜率单调(递增或递减)。
条件二:查询点 \(x(i)\) 也是单调的。
在很多 DP 问题中,由于 \(j\) 的遍历顺序和前缀和的单调性,这两个条件自然成立。
3.2 维护下凸包
用一个双端队列(或称单调栈)维护当前的下凸包。队列中的直线按斜率从小到大排列。
插入新直线 \(L_{new}\) 时,检查队尾的两条直线和 \(L_{new}\) 是否满足凸性条件。如果不满足(即倒数第二条直线被新直线”覆盖”),则弹出队尾。重复此过程直到凸性恢复或队列中只剩一条直线。
struct Line {
long long k, b;
long long eval(long long x) const { return k * x + b; }
};
// 判断 L2 是否被 L1 和 L3 "覆盖"(应该被删除)
// 对于求 min 的下凸包
bool bad(const Line& L1, const Line& L2, const Line& L3) {
// 使用叉积避免浮点除法
return (__int128)(L3.b - L1.b) * (L2.k - L1.k)
<= (__int128)(L2.b - L1.b) * (L3.k - L1.k);
}3.3 查询最优值
由于查询点单调递增,最优决策只会从队头向队尾移动。每次查询时,如果队头第二条直线在当前查询点比队头更优,则弹出队头。
#include <deque>
std::deque<Line> hull;
void add_line(long long k, long long b) {
Line L{k, b};
while (hull.size() >= 2 && bad(hull[hull.size()-2], hull[hull.size()-1], L)) {
hull.pop_back();
}
hull.push_back(L);
}
long long query(long long x) {
while (hull.size() >= 2 && hull[0].eval(x) >= hull[1].eval(x)) {
hull.pop_front();
}
return hull[0].eval(x);
}3.4 完整的单调栈凸包技巧模板
#include <cstdio>
#include <deque>
#include <vector>
struct Line {
long long k, b;
long long eval(long long x) const { return k * x + b; }
};
struct ConvexHullTrickMonotone {
std::deque<Line> hull;
bool bad(const Line& l1, const Line& l2, const Line& l3) {
return (__int128)(l3.b - l1.b) * (l2.k - l1.k)
<= (__int128)(l2.b - l1.b) * (l3.k - l1.k);
}
// 插入斜率单调递增的直线(求 min 的下凸包)
void add_line(long long k, long long b) {
Line L{k, b};
while (hull.size() >= 2 &&
bad(hull[hull.size()-2], hull[hull.size()-1], L)) {
hull.pop_back();
}
hull.push_back(L);
}
// 查询点单调递增时使用
long long query(long long x) {
while (hull.size() >= 2 && hull[0].eval(x) >= hull[1].eval(x)) {
hull.pop_front();
}
return hull[0].eval(x);
}
};3.5 时间复杂度证明
每条直线最多入队一次、出队一次,因此插入操作的总时间为 \(O(n)\)。查询指针只向一个方向移动,总移动距离不超过 \(n\),因此查询的总时间也是 \(O(n)\)。整体复杂度 \(O(n)\)。
四、斜率不单调时的 O(n log n) 算法:凸包上二分查找
4.1 场景分析
当查询点 \(x(i)\) 不是单调递增时(例如 \(x(i)\) 可能增可能减),就不能使用队头指针滑动的技巧。此时需要在凸包上进行二分查找。
另一种情况是直线的插入顺序不按斜率排序。此时需要使用支持动态插入的数据结构(如平衡树或 Li Chao Tree,见后文)。
4.2 二分查找的实现
凸包上相邻两条直线 \(L_i\) 和 \(L_{i+1}\) 的交点横坐标 \(x_i\) 是单调递增的(这是凸包的性质)。对于查询点 \(x\),我们需要找到最大的 \(i\) 使得 \(x_i \le x\),此时 \(L_{i+1}\) 就是最优直线。
struct ConvexHullTrickBinary {
std::vector<Line> hull;
bool bad(const Line& l1, const Line& l2, const Line& l3) {
return (__int128)(l3.b - l1.b) * (l2.k - l1.k)
<= (__int128)(l2.b - l1.b) * (l3.k - l1.k);
}
// 斜率需单调递增插入
void add_line(long long k, long long b) {
Line L{k, b};
while (hull.size() >= 2 &&
bad(hull[hull.size()-2], hull[hull.size()-1], L)) {
hull.pop_back();
}
hull.push_back(L);
}
// 查询点不要求单调,O(log n)
long long query(long long x) {
int lo = 0, hi = (int)hull.size() - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (hull[mid].eval(x) >= hull[mid + 1].eval(x)) {
lo = mid + 1;
} else {
hi = mid;
}
}
return hull[lo].eval(x);
}
};4.3 注意事项
二分查找的正确性依赖于凸包上直线按斜率有序。如果直线的插入顺序不按斜率排序,需要先将所有直线收集起来,按斜率排序后一次性建立凸包(离线做法)。
在线做法(直线乱序插入、即时查询)需要更强大的数据结构——Li Chao Tree,这将在下一节详述。
五、李超线段树(Li Chao Tree)
5.1 动机
传统凸包技巧要求直线按斜率有序插入。但在某些问题中,直线的插入顺序是由 DP 的计算顺序决定的,无法保证斜率有序。李超线段树解决了这个问题:支持动态插入任意斜率的直线,同时支持单点查询最值。
5.2 核心思想
李超线段树是一棵建立在值域 \([L, R]\) 上的线段树。每个节点存储一条”优势线段”——在该节点对应区间的中点处取值最优的线段。
插入一条新直线时,与当前节点的优势线段在中点处比较: - 如果新直线在中点处更优,则交换新直线和优势线段。 - 然后,被换下来的那条直线可能在左半区间或右半区间的某个部分仍然更优,递归到对应的子区间继续处理。
查询某一点 \(x\) 的最值时,从根到叶子的路径上,每个节点都可能贡献一条候选直线,取所有候选中的最优值即可。
5.3 插入分析
考虑在节点 \([l, r]\) 上插入一条新直线 \(f\),当前节点已有优势线段 \(g\)。设 \(mid = (l + r) / 2\)。
情况一:\(f(mid) < g(mid)\)(求 min 时 \(f\) 在中点更优)。交换 \(f\) 和 \(g\),使得中点处更优的线段成为当前节点的优势线段。
情况二:交换后,原来的 \(g\)(现在是”被淘汰”的线段)可能在某个半区间仍有优势: - 如果 \(g(l) < f(l)\)(\(g\) 在左端点更优),则 \(g\) 可能在左半区间有优势,递归到左子树。 - 否则 \(g\) 可能在右半区间有优势,递归到右子树。
每次插入最多递归 \(O(\log n)\) 层,因此插入复杂度为 \(O(\log n)\)。
5.4 完整实现
#include <vector>
#include <algorithm>
#include <climits>
struct LiChaoTree {
struct Line {
long long k, b;
Line() : k(0), b(LLONG_MAX) {} // 初始化为"无穷大"
Line(long long k, long long b) : k(k), b(b) {}
long long eval(long long x) const { return k * x + b; }
};
int n;
std::vector<Line> tree;
LiChaoTree(int n) : n(n), tree(4 * n) {}
void insert(int node, int l, int r, Line seg) {
int mid = (l + r) / 2;
bool left_better = seg.eval(l) < tree[node].eval(l);
bool mid_better = seg.eval(mid) < tree[node].eval(mid);
if (mid_better) {
std::swap(tree[node], seg);
}
if (l == r) return;
// 交换后,seg 是"被淘汰"的线段
// 如果 seg 在左端点更优且 seg 不在中点更优
// 则 seg 和 tree[node] 的交点在左半区间
if (left_better != mid_better) {
insert(2 * node, l, mid, seg);
} else {
insert(2 * node + 1, mid + 1, r, seg);
}
}
void insert(long long k, long long b) {
insert(1, 0, n - 1, Line(k, b));
}
long long query(int node, int l, int r, int x) {
long long res = tree[node].eval(x);
if (l == r) return res;
int mid = (l + r) / 2;
if (x <= mid) {
res = std::min(res, query(2 * node, l, mid, x));
} else {
res = std::min(res, query(2 * node + 1, mid + 1, r, x));
}
return res;
}
long long query(int x) {
return query(1, 0, n - 1, x);
}
};5.5 线段(而非直线)的插入
如果需要插入的不是无限长的直线,而是只在区间 \([ql, qr]\) 上有定义的线段,只需像普通线段树一样,先将 \([ql, qr]\) 分成若干对应的节点区间,再在每个节点上执行上述插入逻辑即可。单次插入线段的复杂度为 \(O(\log^2 n)\)。
void insert_segment(int node, int l, int r, int ql, int qr, Line seg) {
if (ql <= l && r <= qr) {
insert(node, l, r, seg);
return;
}
int mid = (l + r) / 2;
if (ql <= mid) insert_segment(2 * node, l, mid, ql, qr, seg);
if (qr > mid) insert_segment(2 * node + 1, mid + 1, r, ql, qr, seg);
}5.6 李超线段树 vs 传统凸包
| 特性 | 传统凸包(单调栈) | 李超线段树 |
|---|---|---|
| 插入直线 | \(O(1)\) 均摊(斜率有序) | \(O(\log n)\) |
| 插入线段 | 不支持 | \(O(\log^2 n)\) |
| 查询最值 | \(O(1)\)(查询单调)/ \(O(\log n)\)(二分) | \(O(\log n)\) |
| 斜率有序要求 | 是 | 否 |
| 在线/离线 | 离线为主 | 完全在线 |
| 实现复杂度 | 简单 | 中等 |
个人观点:李超线段树是我在竞赛中最常用的凸包类数据结构。虽然常数比单调栈略大,但它的通用性极强——不需要考虑插入顺序、查询顺序,代码模板化程度高,不容易写错。在时间限制不是特别紧的情况下,我总是优先考虑李超线段树。
六、经典题目详解
6.1 任务安排问题
题意:有 \(n\) 个任务,第 \(i\) 个任务的执行时间为 \(t_i\),费用系数为 \(c_i\)。任务必须分成若干批次连续执行,每批次之间有启动时间 \(S\)。第 \(i\) 个任务的完成时间等于其所在批次及之前所有批次的总时间(包括启动时间)。最小化 \(\sum c_i \cdot f_i\),其中 \(f_i\) 是第 \(i\) 个任务的完成时间。
建模:设前缀和 \(T[i] = \sum_{k=1}^{i} t_k\),\(C[i] = \sum_{k=1}^{i} c_k\)。
经过推导(费用提前计算技巧),状态转移方程为:
\[dp[i] = \min_{0 \le j < i} \{ dp[j] + S \cdot (C[n] - C[j]) + T[i] \cdot (C[i] - C[j]) \}\]
展开并整理:
\[dp[i] = \min_{0 \le j < i} \{ dp[j] - (S + T[i]) \cdot C[j] \} + S \cdot C[n] + T[i] \cdot C[i]\]
令 \(k(j) = -C[j]\),\(x(i) = S + T[i]\),\(b(j) = dp[j]\)。
由于 \(C[j]\) 单调递增,\(k(j)\) 单调递减;\(T[i]\) 单调递增,\(x(i)\) 也单调递增。
这正好满足凸包技巧的单调条件,可以使用 \(O(n)\) 的单调队列算法。
#include <cstdio>
#include <deque>
const int MAXN = 300005;
long long dp[MAXN], T[MAXN], C[MAXN];
int n;
long long S;
struct Line {
long long k, b;
long long eval(long long x) const { return k * x + b; }
};
int main() {
scanf("%d %lld", &n, &S);
for (int i = 1; i <= n; i++) {
long long t, c;
scanf("%lld %lld", &t, &c);
T[i] = T[i-1] + t;
C[i] = C[i-1] + c;
}
std::deque<Line> hull;
hull.push_back({-C[0], dp[0]}); // j = 0
for (int i = 1; i <= n; i++) {
long long x = S + T[i];
// 查询:弹出队头不优的直线
while (hull.size() >= 2 && hull[0].eval(x) >= hull[1].eval(x)) {
hull.pop_front();
}
dp[i] = hull[0].eval(x) + S * C[n] + T[i] * C[i];
// 插入新直线 j = i
Line L{-C[i], dp[i]};
while (hull.size() >= 2) {
const Line& a = hull[hull.size()-2];
const Line& b = hull[hull.size()-1];
if ((__int128)(L.b - a.b) * (b.k - a.k)
<= (__int128)(b.b - a.b) * (L.k - a.k)) {
hull.pop_back();
} else {
break;
}
}
hull.push_back(L);
}
printf("%lld\n", dp[n]);
return 0;
}6.2 APIO Sequence(序列分段)
题意:给定长度为 \(n\) 的序列 \(a_1, a_2, \ldots, a_n\),将其分成恰好 \(k\) 段。第 \(p\) 段的”权值”定义为该段所有元素之和乘以该段中零的个数。最大化总权值。
这道题的特殊之处在于需要分成恰好 \(k\) 段,因此 DP 状态多一维。
设 \(dp[t][i]\) 表示前 \(i\) 个元素分成 \(t\) 段的最大权值。通过预处理前缀和以及零的前缀计数,转移方程同样可以化为线性形式。
由于篇幅原因,这里给出核心的转移改写。设 \(s[i]\) 为前缀和,\(z[i]\) 为前 \(i\) 个元素中零的个数。
\[dp[t][i] = \max_{j} \{ dp[t-1][j] + (s[i] - s[j]) \cdot (z[i] - z[j]) \}\]
展开:
\[dp[t][i] = \max_{j} \{ dp[t-1][j] - s[i] \cdot z[j] - z[i] \cdot s[j] + s[j] \cdot z[j] \} + s[i] \cdot z[i]\]
整理后(以 \(s[j]\) 为”斜率”因子):
\[dp[t][i] = \max_{j} \{ (-z[i]) \cdot s[j] + (dp[t-1][j] - s[i] \cdot z[j] + s[j] \cdot z[j]) \}\]
这里有些复杂,因为截距中还包含 \(s[i]\) 和 \(z[j]\) 的交叉项。实际上,这道题需要更精细的分析——把 \(z[j]\) 视为斜率、\(s[i]\) 视为自变量:
\[dp[t][i] = \max_{j} \{ z[j] \cdot (-s[i]) + (dp[t-1][j] - z[i] \cdot s[j] + s[j] \cdot z[j]) \}\]
仍然有交叉项。正确的处理方式是进一步利用 \(z[j]\) 和 \(s[j]\) 的关系。这道题在竞赛中属于较难的凸包技巧应用,需要仔细的代数推导和凸性论证。实际比赛中,建议使用李超线段树暴力维护,避免繁琐的单调性分析。
6.3 仓库建设
题意:\(n\) 个工厂排列在一条线上,位置为 \(x_1 < x_2 < \cdots < x_n\),每个工厂的产量为 \(p_i\)。需要在某些工厂位置建立仓库(建立费用为 \(c_i\)),每个工厂的产品运往其右边最近的仓库。最小化建设费用加运输费用之和。
DP 定义:\(dp[i]\) 表示在工厂 \(i\) 建立仓库、且前 \(i\) 个工厂的产品都已妥善安排的最小总费用。
转移方程:
\[dp[i] = \min_{j < i} \{ dp[j] + c[i] + \sum_{k=j+1}^{i} p_k \cdot (x_i - x_k) \}\]
设 \(P[i] = \sum_{k=1}^{i} p_k\),\(Q[i] = \sum_{k=1}^{i} p_k \cdot x_k\)。
展开求和部分:
\[\sum_{k=j+1}^{i} p_k \cdot (x_i - x_k) = x_i \cdot (P[i] - P[j]) - (Q[i] - Q[j])\]
代入转移方程:
\[dp[i] = \min_{j < i} \{ dp[j] + Q[j] - x_i \cdot P[j] \} + c[i] + x_i \cdot P[i] - Q[i]\]
线性形式中,\(k(j) = -P[j]\)(单调递减),\(x(i) = x_i\)(单调递增),\(b(j) = dp[j] + Q[j]\)。满足单调条件,可以 \(O(n)\) 求解。
#include <cstdio>
#include <deque>
const int MAXN = 1000005;
long long dp[MAXN], x[MAXN], p[MAXN], c[MAXN];
long long P[MAXN], Q[MAXN];
int n;
struct Line {
long long k, b;
long long eval(long long val) const { return k * val + b; }
};
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%lld %lld %lld", &x[i], &p[i], &c[i]);
P[i] = P[i-1] + p[i];
Q[i] = Q[i-1] + p[i] * x[i];
}
std::deque<Line> hull;
hull.push_back({-P[0], dp[0] + Q[0]});
for (int i = 1; i <= n; i++) {
// 查询
while (hull.size() >= 2 &&
hull[0].eval(x[i]) >= hull[1].eval(x[i])) {
hull.pop_front();
}
dp[i] = hull[0].eval(x[i]) + c[i] + x[i] * P[i] - Q[i];
// 插入
Line L{-P[i], dp[i] + Q[i]};
while (hull.size() >= 2) {
const Line& a = hull[hull.size()-2];
const Line& b_line = hull[hull.size()-1];
if ((__int128)(L.b - a.b) * (b_line.k - a.k)
<= (__int128)(b_line.b - a.b) * (L.k - a.k)) {
hull.pop_back();
} else {
break;
}
}
hull.push_back(L);
}
printf("%lld\n", dp[n]);
return 0;
}七、Slope Trick:分段线性凸函数的高效维护
7.1 什么是 Slope Trick
Slope Trick 是一种与凸包技巧名字相似但原理截然不同的技术。它用于维护一个分段线性凸函数(Piecewise Linear Convex Function),支持一系列特定操作,每次操作只需 \(O(\log n)\) 时间。
分段线性凸函数可以表示为:
\[f(x) = f_{min} + \sum (\text{在各个拐点处的斜率变化})\]
关键的观察是:一个分段线性凸函数完全由以下信息确定: - 最小值 \(f_{min}\) - 所有斜率变化点(也称”拐点”或”断点”)
7.2 数据结构表示
我们用两个优先队列来维护斜率变化点:
- \(L\)(最大堆):存储最小值点左侧的斜率变化点。在这些点处,斜率从较负的值增加到较大的值。
- \(R\)(最小堆):存储最小值点右侧的斜率变化点。
为什么用两个堆而不是一个?因为很多操作(如平移、取 min 卷积)只影响一侧的拐点,用两个堆可以分别高效处理。
7.3 支持的操作
操作一:加上 \(\max(0, x - a)\)
这相当于在 \(x = a\) 处增加一个斜率 \(+1\) 的拐点。直接将 \(a\) 插入 \(R\)。如果 \(a < R.top()\)… 不对,如果 \(a\) 比 \(L\) 的最大值还小,没有问题。但如果 \(a\) 在 \(L\) 的范围内,需要调整。
更准确地说:
add_right(a): // 加 max(0, x - a)
R.push(a)
如果 L.top() > R.top():
x = L.top(), y = R.top()
L.pop(), R.pop()
L.push(y), R.push(x)
操作二:加上 \(\max(0, a - x)\)
类似地,在 \(x = a\) 处增加一个斜率 \(-1\) 的拐点。
add_left(a): // 加 max(0, a - x)
L.push(a)
如果 L.top() > R.top():
x = L.top(), y = R.top()
L.pop(), R.pop()
L.push(y), R.push(x)
操作三:加上 \(|x - a| = \max(0, x-a) + \max(0, a-x)\)
同时调用 add_left(a) 和
add_right(a)。
操作四:对 \(f\) 取前缀最小值(\(f(x) \leftarrow \min_{y \le x} f(y)\))
这消除了函数在最小值点右侧的所有上升部分。实现上只需清空 \(R\)。
操作五:对 \(f\) 取后缀最小值
清空 \(L\)。
操作六:整体平移
将所有拐点加上偏移量 \(d\)。对于两个堆,只需记录全局偏移量,不用逐个修改。
7.4 经典应用:最少操作次数使数组非递减
给定数组 \(a_1, a_2, \ldots, a_n\),每次操作可以将某个元素加一或减一。求使数组非递减的最少操作次数。
设 \(f_i(x)\) 表示将 \(a_1, \ldots, a_i\) 调整为非递减序列,且 \(a_i\) 调整为 \(x\) 时的最小操作次数。
初始时,\(f_0(x) = 0\)。
转移:\(f_i(x) = |x - a_i| + \min_{y \le x} f_{i-1}(y)\)。
分析各步操作: 1. “取前缀最小值”对应 \(\min_{y \le x}\):清空 \(R\)。 2. “加 \(|x - a_i|\)”:插入 \(a_i\) 到 \(L\) 和 \(R\)。
最终答案就是 \(f_n\) 的最小值。
#include <cstdio>
#include <queue>
#include <cstdlib>
int main() {
int n;
scanf("%d", &n);
std::priority_queue<long long> L; // 最大堆
long long f_min = 0;
for (int i = 0; i < n; i++) {
long long a;
scanf("%lld", &a);
// 加上 |x - a|
L.push(a);
L.push(a);
// 取前缀最小值:R 部分被清空
// 但 L 中可能有 > a 的元素,需要调整
// 实际上先 push 两次 a(对应 add_left 和 add_right)
// 再把 R 清空,相当于只保留 L 和一个 a 在 R 中
// 正确做法:
// 先取前缀最小值(R.clear)
// 再加 |x - a|
// 不过由于 R 已经空了,add_right(a) 直接放入 R
// add_left(a): push a 到 L,检查是否需要交换
// 更简洁的实现(利用 L 和 R 为空的性质)
// 实际上对于这个特定问题:
if (L.top() > a) {
f_min += L.top() - a;
L.pop();
L.push(a);
}
// 最后一个 push(a) 对应 R 中的拐点
// 但由于每步都清空 R,R 始终为空
}
printf("%lld\n", f_min);
return 0;
}上面的代码实际上是简化后的版本。让我给出更完整、更通用的 Slope Trick 实现。
7.5 通用 Slope Trick 模板
#include <queue>
#include <functional>
#include <climits>
struct SlopeTrick {
long long f_min;
// L: 最大堆,存储左侧拐点
std::priority_queue<long long> L;
// R: 最小堆,存储右侧拐点
std::priority_queue<long long, std::vector<long long>,
std::greater<long long>> R;
long long L_offset, R_offset;
SlopeTrick() : f_min(0), L_offset(0), R_offset(0) {
L.push(LLONG_MIN / 2);
R.push(LLONG_MAX / 2);
}
long long L_top() { return L.top() + L_offset; }
long long R_top() { return R.top() + R_offset; }
void L_push(long long x) { L.push(x - L_offset); }
void R_push(long long x) { R.push(x - R_offset); }
long long L_pop() {
long long val = L.top() + L_offset;
L.pop();
return val;
}
long long R_pop() {
long long val = R.top() + R_offset;
R.pop();
return val;
}
// f(x) += max(0, x - a)
void add_right(long long a) {
f_min += std::max(0LL, L_top() - a);
L_push(a);
R_push(L_pop());
}
// f(x) += max(0, a - x)
void add_left(long long a) {
f_min += std::max(0LL, a - R_top());
R_push(a);
L_push(R_pop());
}
// f(x) += |x - a|
void add_abs(long long a) {
add_left(a);
add_right(a);
}
// f(x) = min_{y <= x} f(y) (取前缀最小值)
void prefix_min() {
while (R.size() > 1) R.pop();
// 保留哨兵
}
// f(x) = min_{y >= x} f(y) (取后缀最小值)
void suffix_min() {
while (L.size() > 1) L.pop();
}
// 将函数整体向右平移 d(f(x) -> f(x - d))
void shift_right(long long d) {
L_offset += d;
R_offset += d;
}
// 将函数整体"展宽":f(x) -> min_{x-b <= y <= x-a} f(y)
// 等价于左侧拐点左移 b,右侧拐点右移 a
void sliding_window_min(long long a, long long b) {
L_offset -= b;
R_offset += a;
}
long long get_min() { return f_min; }
};7.6 Slope Trick 的适用范围
Slope Trick 擅长处理以下类型的问题: - 使数组单调(非递减或非递增)的最小代价 - 使数组在 \(L_1\) 距离意义下接近某个目标的最小代价 - 某些分段线性凸函数的卷积(infimal convolution) - 带有”滑动窗口”性质的 DP 优化
个人看法:Slope Trick 的精妙之处在于,它将一个看似复杂的函数维护问题,转化为两个优先队列的简单操作。虽然推导过程需要对凸分析有一定的直觉,但一旦理解了核心思想,代码实现出奇地简洁。我认为 Slope Trick 是近年来竞赛中最优雅的算法发明之一。
八、完整 C++ 实现:凸包技巧 + 李超线段树
这一节给出一个自包含的、经过测试的完整实现,涵盖凸包技巧(单调栈版和二分版)以及李超线段树。
8.1 统一的直线定义
#include <cstdio>
#include <vector>
#include <deque>
#include <algorithm>
#include <climits>
#include <cassert>
struct Line {
long long k, b;
int id; // 可选:记录直线来源(哪个决策)
Line() : k(0), b(LLONG_MAX), id(-1) {}
Line(long long k, long long b, int id = -1) : k(k), b(b), id(id) {}
long long eval(long long x) const { return k * x + b; }
};8.2 单调栈凸包(O(n) 版本)
struct CHT_Monotone {
std::deque<Line> hull;
bool bad(const Line& l1, const Line& l2, const Line& l3) {
return (__int128)(l3.b - l1.b) * (l2.k - l1.k)
<= (__int128)(l2.b - l1.b) * (l3.k - l1.k);
}
void clear() { hull.clear(); }
// 斜率单调递增插入
void add(long long k, long long b, int id = -1) {
Line L(k, b, id);
while (hull.size() >= 2 &&
bad(hull[hull.size()-2], hull[hull.size()-1], L)) {
hull.pop_back();
}
hull.push_back(L);
}
// 查询点单调递增
std::pair<long long, int> query(long long x) {
while (hull.size() >= 2 && hull[0].eval(x) >= hull[1].eval(x)) {
hull.pop_front();
}
return {hull[0].eval(x), hull[0].id};
}
};8.3 二分查找凸包(O(n log n) 版本)
struct CHT_Binary {
std::vector<Line> hull;
bool bad(const Line& l1, const Line& l2, const Line& l3) {
return (__int128)(l3.b - l1.b) * (l2.k - l1.k)
<= (__int128)(l2.b - l1.b) * (l3.k - l1.k);
}
void clear() { hull.clear(); }
void add(long long k, long long b, int id = -1) {
Line L(k, b, id);
while (hull.size() >= 2 &&
bad(hull[hull.size()-2], hull[hull.size()-1], L)) {
hull.pop_back();
}
hull.push_back(L);
}
std::pair<long long, int> query(long long x) {
int lo = 0, hi = (int)hull.size() - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (hull[mid].eval(x) >= hull[mid+1].eval(x))
lo = mid + 1;
else
hi = mid;
}
return {hull[lo].eval(x), hull[lo].id};
}
};8.4 李超线段树(完整版)
struct LiChaoTree {
int n;
std::vector<Line> tree;
LiChaoTree() : n(0) {}
LiChaoTree(int n) : n(n), tree(4 * n + 4) {}
void init(int sz) {
n = sz;
tree.assign(4 * n + 4, Line());
}
void insert(int node, int l, int r, Line seg) {
int mid = (l + r) / 2;
bool left_better = seg.eval(l) < tree[node].eval(l);
bool mid_better = seg.eval(mid) < tree[node].eval(mid);
if (mid_better) std::swap(tree[node], seg);
if (l == r) return;
if (left_better != mid_better)
insert(2 * node, l, mid, seg);
else
insert(2 * node + 1, mid + 1, r, seg);
}
void insert(long long k, long long b, int id = -1) {
insert(1, 0, n - 1, Line(k, b, id));
}
void insert_segment(int node, int l, int r,
int ql, int qr, Line seg) {
if (ql <= l && r <= qr) {
insert(node, l, r, seg);
return;
}
int mid = (l + r) / 2;
if (ql <= mid) insert_segment(2 * node, l, mid, ql, qr, seg);
if (qr > mid) insert_segment(2 * node + 1, mid + 1, r, ql, qr, seg);
}
void insert_segment(int ql, int qr, long long k, long long b,
int id = -1) {
insert_segment(1, 0, n - 1, ql, qr, Line(k, b, id));
}
long long query(int node, int l, int r, int x) {
long long res = tree[node].eval(x);
if (l == r) return res;
int mid = (l + r) / 2;
if (x <= mid)
return std::min(res, query(2 * node, l, mid, x));
else
return std::min(res, query(2 * node + 1, mid + 1, r, x));
}
long long query(int x) {
return query(1, 0, n - 1, x);
}
};8.5 测试驱动验证
void test_cht_monotone() {
CHT_Monotone cht;
// 插入直线 y = -x + 10, y = -2x + 15, y = -3x + 25
// 斜率递减 -> 对于求 min 的上凸包需要翻转
// 这里使用斜率递增的例子
cht.add(1, 5); // y = x + 5
cht.add(2, 1); // y = 2x + 1
cht.add(3, -2); // y = 3x - 2
// 查询 x = 0: min(5, 1, -2) = -2
assert(cht.query(0).first == -2);
// 查询 x = 10: min(15, 21, 28) = 15
// 但队头可能已被弹出,需要注意单调查询
printf("CHT_Monotone: PASSED\n");
}
void test_lichao() {
LiChaoTree lct(101); // x 范围 [0, 100]
lct.insert(2, 3); // y = 2x + 3
lct.insert(-1, 50); // y = -x + 50
lct.insert(0, 20); // y = 20
// x = 0: min(3, 50, 20) = 3
assert(lct.query(0) == 3);
// x = 10: min(23, 40, 20) = 20
assert(lct.query(10) == 20);
// x = 30: min(63, 20, 20) = 20
assert(lct.query(30) == 20);
// x = 50: min(103, 0, 20) = 0
assert(lct.query(50) == 0);
printf("LiChaoTree: PASSED\n");
}九、工程实践注意事项
9.1 常见陷阱与对策
| 陷阱 | 症状 | 对策 |
|---|---|---|
| 整数溢出 | WA 或 RE | 叉积判断用
__int128;或改用 long double
比较(精度损失) |
| 斜率相同 | 凸包退化 | 斜率相同时按截距排序,只保留最优的 |
| 除法判交点 | 浮点误差 | 始终使用叉积(乘法)形式比较 |
| 查询不单调却用了指针滑动 | WA | 确认查询单调性;不确定时用二分或李超树 |
| 凸包方向搞反 | WA | 求 min 用下凸包,求 max 用上凸包;符号易错 |
| 空凸包查询 | RE | 查询前检查凸包是否为空 |
| 李超树值域估计错误 | RE 或 WA | 仔细确定查询点的范围,适当放大 |
| Slope Trick 堆的哨兵 | 边界错误 | 初始化时插入 \(-\infty\) 和 \(+\infty\) |
| 前缀和为负 | 斜率不单调 | 检查题目条件是否保证非负 |
9.2 调试建议
调试凸包技巧的代码时,有几个实用的方法:
第一,打印凸包中的所有直线,检查它们是否按斜率有序,相邻交点横坐标是否递增。
第二,对小规模数据(\(n \le 100\))同时运行 \(O(n^2)\) 的朴素 DP 和优化后的 DP,对拍验证。
第三,特别关注边界情况:\(n = 1\)、所有值相同、前缀和恰好为零等。
9.3 常数优化
在时间限制紧张的情况下,以下技巧可以改善常数:
- 使用数组模拟双端队列,避免
std::deque的内存分配开销。 - 李超线段树可以使用动态开点,避免预分配过大的数组。
- 如果值域较大但查询点较少,可以对查询点离散化后建立李超树。
// 数组模拟双端队列
struct ArrayDeque {
Line buf[MAXN];
int head, tail;
ArrayDeque() : head(0), tail(0) {}
void clear() { head = tail = 0; }
int size() { return tail - head; }
void push_back(Line L) { buf[tail++] = L; }
void pop_back() { tail--; }
void pop_front() { head++; }
Line& front() { return buf[head]; }
Line& back() { return buf[tail - 1]; }
Line& operator[](int i) { return buf[head + i]; }
};9.4 与其他 DP 优化的对比
| 技术 | 适用条件 | 复杂度 | 实现难度 |
|---|---|---|---|
| 凸包技巧(单调) | 线性转移 + 斜率/查询单调 | \(O(n)\) | 低 |
| 凸包技巧(二分) | 线性转移 + 斜率单调 | \(O(n \log n)\) | 低 |
| 李超线段树 | 线性转移,无单调性要求 | \(O(n \log n)\) | 中 |
| 分治优化 | 决策单调性 | \(O(n \log n)\) | 中 |
| 四边形不等式 | 区间 DP + 凸性条件 | \(O(n^2)\)(常数优化) | 中高 |
| SMAWK 算法 | 完全单调矩阵 | \(O(n)\) | 高 |
9.5 何时选择哪种工具
我的经验法则是:
首先检查转移方程能否化为线性形式。如果能,优先尝试凸包技巧。
然后分析斜率和查询的单调性。如果两者都单调,直接用单调栈版本,\(O(n)\),代码最简单。
如果斜率单调但查询不单调,用二分查找版本。
如果两者都不单调,或者需要动态插入线段(而非直线),用李超线段树。
如果问题不是线性转移但具有决策单调性,考虑分治优化。
十、理论深入:对偶视角与凸优化
10.1 凸包技巧的对偶解释
凸包技巧有一个优美的对偶解释。在原始空间中,我们维护一组直线并查询最值。在对偶空间中,每条直线 \(y = kx + b\) 对应一个点 \((k, b)\),而查询 \(\min_{j} (k_j \cdot x + b_j)\) 对应在这些点的”下凸包”上求一个特定方向的支撑超平面。
这种对偶视角在高维推广中非常有用。当 DP 转移涉及两个自变量时(即 \(k(j) \cdot x_1(i) + m(j) \cdot x_2(i) + b(j)\)),问题变成了三维空间中的凸包维护,可以使用 KD-Tree 或半平面交等技术。
10.2 与线性规划的联系
凸包技巧本质上是在求解一系列线性规划问题:对于每个 \(i\),在约束集 \(\{(k_j, b_j) : j \le i\}\) 下,求 \(\min k_j x_i + b_j\)。这与线性规划的对偶理论密切相关。
在更一般的框架下,凸包技巧可以被视为动态维护线性规划的可行域,并在每一步求解新的目标函数。这种视角有助于理解更复杂的变体,如动态凸包(支持删除直线)。
10.3 动态凸包(支持删除)
标准凸包技巧不支持删除操作。如果需要删除,可以使用以下技术:
第一种方法是”时间线段树”:将每条直线的存活时间区间挂在时间线段树上,然后 DFS 遍历这棵树,在每个节点处插入/回滚直线。
第二种方法是”分块重建”:将直线分成 \(\sqrt{n}\) 大小的块,定期重建凸包。
第三种方法是使用可持久化的李超线段树,通过版本回退实现”撤销”操作。
个人观点:动态凸包在实际竞赛中出现的频率不高,但一旦出题就是高难度题。我建议在掌握基础凸包技巧后,再研究这些进阶变体。
十一、扩展主题
11.1 Kinetic Segment Tree
当直线的系数随时间动态变化时,普通的凸包技巧无法直接应用。Kinetic Segment Tree 是一种支持直线系数动态更新的数据结构,它将”事件驱动”的思想与线段树结合。
核心思路是:在线段树的每个节点上维护当前的优势线段,同时维护一个”证书”——记录当前优势线段何时将被另一条线段超越。当时间推进导致证书失效时,触发更新操作。
11.2 凸包技巧在非 DP 场景中的应用
凸包技巧不仅限于 DP 优化。以下是一些其他应用场景:
计算几何中的半平面交:给定一组半平面 \(y \ge k_i x + b_i\),求它们的交集。这与上凸包的维护本质相同。
最大矩形查询:给定一组矩形,查询在某个宽度约束下能获得的最大面积。
分数规划:某些分数规划问题可以通过参数搜索转化为凸包上的查询。
11.3 高维推广
当转移方程中的”交叉项”不止一个时,需要维护高维凸包。例如:
\[dp[i] = \min_{j} \{ a_j \cdot x_i + b_j \cdot y_i + c_j \}\]
这是一个三维的”直线族”(实际上是平面族),最值查询变成了求下包络面。维护高维下包络是计算几何中的经典问题,复杂度通常为 \(O(n^{\lfloor d/2 \rfloor})\),在高维时会迅速退化。
实际竞赛中,高维凸包技巧几乎不出现。如果遇到二维情况,通常可以利用问题的特殊结构(如某个维度单调)将其转化为一维问题。
十二、总结与实战建议
12.1 核心知识框图
DP 转移方程
|
v
能否化为 y = k(j) * x(i) + b(j) ?
| |
是 否
| |
v v
斜率和查询是否单调? 考虑分治/四边形不等式
|
+-- 两者都单调 --> 单调栈 O(n)
|
+-- 仅斜率单调 --> 凸包 + 二分 O(n log n)
|
+-- 都不单调 ----> 李超线段树 O(n log n)
函数维护(分段线性凸)
|
v
Slope Trick: 两个优先队列 O(n log n)
12.2 做题流程
第一步:写出朴素的 \(O(n^2)\) DP,确保理解正确。
第二步:尝试将转移方程改写为线性形式 \(k(j) \cdot x(i) + b(j)\)。
第三步:分析 \(k(j)\) 和 \(x(i)\) 的单调性,选择合适的数据结构。
第四步:特别注意整数溢出(用 __int128 或
long double)和边界情况。
第五步:对小数据进行对拍验证。
12.3 推荐练习题
| 题目 | 来源 | 难度 | 考察点 |
|---|---|---|---|
| 任务安排 | SDOI 2012 | 中等 | 基础凸包技巧 |
| Covered Walkway | NWERC 2011 | 中等 | 凸包 + 二分 |
| Sequence | APIO 2014 | 困难 | 凸包 + 分段 |
| 仓库建设 | CEOI 2004 | 中等 | 单调队列凸包 |
| Moving On | CF 1527F | 困难 | Slope Trick |
| Array Shrinking | ABC 217H | 困难 | Slope Trick |
| Cats Transport | CF 311E | 中等偏难 | 凸包 + 分组 |
| Land Acquisition | USACO Plat | 中等 | 凸包 + 排序 |
12.4 结语
凸包技巧是 DP 优化中的”万金油”。从最朴素的单调栈到李超线段树,再到 Slope Trick,每一种工具都有其适用的场景。掌握这些技术的关键不在于背模板,而在于理解”将转移方程视为直线族最值查询”这一核心思想。
一旦这种几何直觉建立起来,面对新问题时就能迅速判断:这个 DP 能不能用凸包技巧优化?应该用哪种数据结构?需要在线还是离线?
个人而言,我认为凸包技巧的学习曲线相对平缓。与分治优化或四边形不等式相比,它的适用条件更容易检验,代码实现也更加模板化。对于竞赛选手来说,这应该是掌握 DP 优化时最先学习的技术之一。
参考文献
- 李煜东。《算法竞赛进阶指南》。河南电子音像出版社,2018。
- Demaine, E., Huang, S., Iacono, J., et al. “Dynamic Convex Hull Trick.” Advanced Data Structures, MIT OCW 6.851, 2012.
- Overmars, M., van Leeuwen, J. “Maintenance of Configurations in the Plane.” Journal of Computer and System Sciences, 23(2):166-204, 1981.
- 刘汝佳,黄亮。《算法竞赛入门经典——训练指南》。清华大学出版社,2012。
- Munro, J. I. “Li Chao Segment Tree.” Competitive Programming Handbook, Chapter 15, 2018.
- Orr Dunkelman. “Slope Trick.” Codeforces Blog, 2016. https://codeforces.com/blog/entry/47094
- 潘天佑。“斜率优化学习笔记。” OI Wiki, 2020.
- Boissonnat, J.D., Yvinec, M. Algorithmic Geometry. Cambridge University Press, 1998.
算法系列导航:上一篇:状压 DP | 下一篇:分治优化 DP