引言
斜率优化(Slope Optimization / Convex Hull Trick)是动态规划的高级优化技巧,适用于状态转移方程形如 dp[i] = min(dp[j] + cost(j, i)) 且 cost 函数满足特定单调性的问题。通过维护一个下凸壳(或上凸壳),将原本 O(n²) 的DP优化到 O(n log n) 甚至 O(n)。本文使用Java实现斜率优化的核心算法,并通过一个经典的运输成本问题展示其应用。
问题引入:运输成本优化
有 n 批货物依次排列,第 i 批货物重量为 w[i],需要将货物分批运输。每批运输的代价为 (这批货物总重量 + 固定启动成本)²。求将所有货物运完的最小总代价。
形式化:设 dp[i] 为运完前 i 批货物的最小代价,sum[i] 为前缀重量和。
dp[i] = min(dp[j] + (sum[i] - sum[j] + C)²) for j < i
展开后:dp[i] = min(dp[j] + sum[j]² - 2*(sum[i]+C)*sum[j]) + (sum[i]+C)²
令 y_j = dp[j] + sum[j]², x_j = sum[j],则对每个 j,在坐标系中是一条斜率为 2*(sum[i]+C) 的直线,求最小截距。这正是一个凸包问题。
算法原理
将每个决策点 j 看作平面上的点 (x_j, y_j),其中:
– x_j = sum[j]
– y_j = dp[j] + sum[j]²
对于当前状态 i,需要找到使 y_j - 2*(sum[i]+C)*x_j 最小的点 j。这等价于用斜率为 2*(sum[i]+C) 的直线从下方去切这些点,切到的第一个点即为最优决策点。
由于斜率 2*(sum[i]+C) 单调递增,我们可以用单调队列维护下凸壳,实现均摊 O(1) 的查询。
Java实现
import java.util.*;
/**
* 斜率优化动态规划:凸包优化实现
* 问题:运输成本最小化
*/
public class SlopeOptimization {
/**
* 基础DP版本:时间复杂度 O(n²)
*/
public static long minCostBasic(long[] w, long C) {
int n = w.length;
long[] sum = new long[n + 1];
for (int i = 1; i <= n; i++) {
sum[i] = sum[i - 1] + w[i - 1];
}
long[] dp = new long[n + 1];
Arrays.fill(dp, Long.MAX_VALUE / 4);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
long cost = dp[j] + (sum[i] - sum[j] + C) * (sum[i] - sum[j] + C);
dp[i] = Math.min(dp[i], cost);
}
}
return dp[n];
}
/**
* 斜率优化版本:时间复杂度 O(n)
* 使用单调队列维护下凸壳
*/
public static long minCostOptimized(long[] w, long C) {
int n = w.length;
long[] sum = new long[n + 1];
for (int i = 1; i <= n; i++) {
sum[i] = sum[i - 1] + w[i - 1];
}
long[] dp = new long[n + 1];
dp[0] = 0;
// 单调队列存储决策点下标 j
// 维护下凸壳:相邻三点的斜率单调递增
ArrayDeque<Integer> deque = new ArrayDeque<>();
deque.offerLast(0);
for (int i = 1; i <= n; i++) {
// 查询:找到最优决策点
// 队首两个点 j1, j2,如果 slope(j1, j2) <= 2*(sum[i]+C),则 j1 不再最优
long currentSlope = 2 * (sum[i] + C);
while (deque.size() >= 2) {
int j1 = deque.peekFirst();
int j2 = deque.peekFirst() == deque.getFirst()
? ((LinkedList<Integer>) deque).get(1) : -1;
// 使用迭代器获取第二个元素
Iterator<Integer> it = deque.iterator();
j1 = it.next();
j2 = it.next();
if (slope(sum, dp, j1, j2) <= currentSlope) {
deque.pollFirst();
} else {
break;
}
}
int bestJ = deque.peekFirst();
dp[i] = dp[bestJ] + (sum[i] - sum[bestJ] + C) * (sum[i] - sum[bestJ] + C);
// 插入新决策点 i,维护下凸壳
// 队尾三个点 j1, j2, i,如果 slope(j1,j2) >= slope(j2,i),则 j2 在凸包内,弹出 j2
while (deque.size() >= 2) {
Iterator<Integer> it = deque.iterator();
int[] last = new int[deque.size()];
int idx = 0;
while (it.hasNext()) last[idx++] = it.next();
int j1 = last[last.length - 2];
int j2 = last[last.length - 1];
if (slope(sum, dp, j1, j2) >= slope(sum, dp, j2, i)) {
// 弹出队尾
deque.pollLast();
} else {
break;
}
}
deque.offerLast(i);
}
return dp[n];
}
/**
* 计算两点之间的斜率:(y2 - y1) / (x2 - x1)
* 使用交叉相乘避免浮点数精度问题
*/
private static double slope(long[] sum, long[] dp, int j1, int j2) {
long x1 = sum[j1];
long y1 = dp[j1] + sum[j1] * sum[j1];
long x2 = sum[j2];
long y2 = dp[j2] + sum[j2] * sum[j2];
if (x1 == x2) return y2 > y1 ? Double.POSITIVE_INFINITY : Double.NEGATIVE_INFINITY;
return (double) (y2 - y1) / (double) (x2 - x1);
}
使用LinkedList的优化实现
/**
* 使用LinkedList的干净实现版本
*/
public static long minCostClean(long[] w, long C) {
int n = w.length;
long[] sum = new long[n + 1];
for (int i = 1; i <= n; i++) {
sum[i] = sum[i - 1] + w[i - 1];
}
long[] dp = new long[n + 1];
dp[0] = 0;
// 使用LinkedList便于访问前两个/后两个元素
LinkedList<Integer> q = new LinkedList<>();
q.addLast(0);
for (int i = 1; i <= n; i++) {
long k = 2 * (sum[i] + C); // 当前查询斜率
// 队首弹出非最优决策
while (q.size() >= 2 &&
getSlope(sum, dp, q.get(0), q.get(1)) <= k) {
q.pollFirst();
}
int j = q.getFirst();
dp[i] = dp[j] + (sum[i] - sum[j] + C) * (sum[i] - sum[j] + C);
// 队尾维护下凸壳
while (q.size() >= 2 &&
getSlope(sum, dp, q.get(q.size() - 2), q.getLast()) >=
getSlope(sum, dp, q.getLast(), i)) {
q.pollLast();
}
q.addLast(i);
}
return dp[n];
}
private static double getSlope(long[] sum, long[] dp, int i, int j) {
long xi = sum[i], xj = sum[j];
long yi = dp[i] + sum[i] * sum[i];
long yj = dp[j] + sum[j] * sum[j];
return (double)(yj - yi) / (double)(xj - xi);
}
一般化模板
/**
* 斜率优化通用模板
* 适用于:dp[i] = min/max(dp[j] + f(j, i)),其中最优决策点具有单调性
*
* @param points 每个决策点的坐标 (x[j], y[j]),其中 y[j] 包含 dp[j]
* @param querySlopes 每次查询的斜率,必须单调
*/
public static long[] convexHullOptimization(long[][] points, double[] querySlopes) {
int n = points.length;
int q = querySlopes.length;
long[] dp = new long[q];
LinkedList<Integer> hull = new LinkedList<>();
hull.addLast(0);
for (int i = 0; i < q; i++) {
double k = querySlopes[i];
// 查询最优决策
while (hull.size() >= 2 &&
slope(points, hull.get(0), hull.get(1)) <= k) {
hull.pollFirst();
}
int best = hull.getFirst();
// dp[i] = points[best][1] - k * points[best][0] + ... 根据具体公式计算
// 插入新点(如果当前查询也产生新决策点)
// while (hull.size() >= 2 && ...)
}
return dp;
}
private static double slope(long[][] points, int i, int j) {
if (points[i][0] == points[j][0]) return Double.POSITIVE_INFINITY;
return (double)(points[j][1] - points[i][1]) / (double)(points[j][0] - points[i][0]);
}
二分查找版本(斜率非单调)
/**
* 当查询斜率不单调时,使用二分查找定位最优决策点
* 时间复杂度 O(n log n)
*/
public static long minCostBinarySearch(long[] w, long C, double[] customSlopes) {
int n = w.length;
long[] sum = new long[n + 1];
for (int i = 1; i <= n; i++) {
sum[i] = sum[i - 1] + w[i - 1];
}
long[] dp = new long[n + 1];
dp[0] = 0;
// 用ArrayList存储凸包上的点,支持二分查找
List<Integer> hull = new ArrayList<>();
hull.add(0);
for (int i = 1; i <= n; i++) {
double k = customSlopes != null ? customSlopes[i] : 2 * (sum[i] + C);
// 二分查找最优决策点
int left = 0, right = hull.size() - 1;
while (left < right) {
int mid = (left + right) / 2;
if (getSlope(sum, dp, hull.get(mid), hull.get(mid + 1)) <= k) {
left = mid + 1;
} else {
right = mid;
}
}
int bestJ = hull.get(left);
dp[i] = dp[bestJ] + (sum[i] - sum[bestJ] + C) * (sum[i] - sum[bestJ] + C);
// 维护凸包(仍然需要保持斜率单调递增)
while (hull.size() >= 2 &&
getSlope(sum, dp, hull.get(hull.size() - 2), hull.get(hull.size() - 1)) >=
getSlope(sum, dp, hull.get(hull.size() - 1), i)) {
hull.remove(hull.size() - 1);
}
hull.add(i);
}
return dp[n];
}
完整测试与对比
public static void main(String[] args) {
long[] w = {3, 5, 2, 7, 4, 6, 1, 8, 3, 5};
long C = 10;
System.out.println("=== 斜率优化动态规划 ===\n");
System.out.println("货物重量: " + Arrays.toString(w));
System.out.println("固定启动成本 C = " + C);
System.out.println();
long t1 = System.currentTimeMillis();
long cost1 = minCostBasic(w, C);
System.out.println("基础DP O(n²) 结果: " + cost1 + ",耗时: " + (System.currentTimeMillis() - t1) + "ms");
t1 = System.currentTimeMillis();
long cost2 = minCostClean(w, C);
System.out.println("斜率优化 O(n) 结果: " + cost2 + ",耗时: " + (System.currentTimeMillis() - t1) + "ms");
// 大规模测试
System.out.println("\n大规模测试 (n=50000):");
long[] largeW = new long[50000];
Random rand = new Random(42);
for (int i = 0; i < 50000; i++) {
largeW[i] = rand.nextInt(100) + 1;
}
t1 = System.currentTimeMillis();
long cost3 = minCostClean(largeW, C);
System.out.println("斜率优化 O(n) 结果: " + cost3 + ",耗时: " + (System.currentTimeMillis() - t1) + "ms");
}
}
复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 适用条件 |
|---|---|---|---|
| 基础DP | O(n²) | O(n) | 通用 |
| 斜率优化(单调) | O(n) | O(n) | 查询斜率单调递增 |
| 斜率优化(二分) | O(n log n) | O(n) | 查询斜率任意 |
总结
本文通过运输成本问题引入斜率优化思想,将动态规划的复杂度从 O(n²) 优化到 O(n)。核心在于将决策点转化为平面上的点集,通过维护下凸壳实现快速查询最优决策。读者可以掌握凸包优化的数学原理与代码实现,并将其应用于具有决策单调性的DP问题中。