在动态规划优化领域,四边形不等式(Quadrangle Inequality)是一个强大的数学工具,它能够帮助我们识别和利用决策单调性(Decision Monotonicity)来优化算法的时间复杂度。这个看似简单的数学不等式,在实际应用中却能带来惊人的效率提升。
四边形不等式的形式化定义为:
w(a,c) + w(b,d) ≤ w(a,d) + w(b,c),其中a < b < c < d
这个不等式得名于其几何解释:如果将a、b、c、d看作四边形的四个顶点,那么不等式表示"对角线之和小于等于对边之和"。虽然在欧几里得几何中这个性质并不成立,但在组合优化中,满足这一性质的代价函数却相当常见。
更直观的理解方式是将其重写为差分形式:
w(b,d) - w(b,c) ≤ w(a,d) - w(a,c)
这表示对于固定右端点d和c,当左端点从a变为b时,函数值的增量减小。换句话说,随着区间长度的增加,代价函数w的增长速度会变快,这种性质在数学上称为"凸性"。
在实践中,许多常见的代价函数都满足四边形不等式:
而不满足四边形不等式的函数包括:
这些函数的特点是增长逐渐放缓,呈现"凹性"而非"凸性"。
决策单调性是指:在动态规划转移f[i] = min(f[j] + w(j+1,i))中,如果随着i的增加,最优决策点j也是非减的,那么我们称这个DP具有决策单调性。
这种性质的重要性在于,它允许我们限制决策点的搜索范围,从而大幅减少计算量。例如,在朴素实现中可能需要O(n²)时间的DP,利用决策单调性可以优化到O(nlogn)甚至O(n)。
石子合并问题是一个经典的区间DP应用场景:有n堆石子排成一列,每次可以合并相邻的两堆,合并代价为这两堆石子的重量之和,问将所有石子合并成一堆的最小总代价。
不考虑优化时,我们可以用标准的区间DP方法:
cpp复制int dp[N][N];
for(int len=1; len<=n; len++) {
for(int i=1; i+len-1<=n; i++) {
int j = i+len-1;
if(len == 1) dp[i][j] = 0;
else {
dp[i][j] = INF;
for(int k=i; k<j; k++) {
dp[i][j] = min(dp[i][j], dp[i][k]+dp[k+1][j]+sum(i,j));
}
}
}
}
这种方法的时间复杂度是O(n³),对于n=1000左右的数据规模就难以承受。
观察到sum(i,j)满足四边形不等式,我们可以记录每个状态的最优决策点s[i][j],并利用决策单调性性质:
s[i][j-1] ≤ s[i][j] ≤ s[i+1][j]
优化后的DP实现:
cpp复制int dp[N][N], s[N][N];
for(int i=1; i<=n; i++) {
dp[i][i] = 0;
s[i][i] = i;
}
for(int len=2; len<=n; len++) {
for(int i=1; i+len-1<=n; i++) {
int j = i+len-1;
dp[i][j] = INF;
for(int k=s[i][j-1]; k<=s[i+1][j] && k<j; k++) {
if(dp[i][j] > dp[i][k]+dp[k+1][j]+sum(i,j)) {
dp[i][j] = dp[i][k]+dp[k+1][j]+sum(i,j);
s[i][j] = k;
}
}
}
}
看似三重循环,但通过决策单调性的限制,实际复杂度降到了O(n²)。这是因为对于每个长度len,所有i的s[i][j]变化总量是O(n)的。
当石子排成环形时,我们可以在原数组后面复制一份,然后对2n长度的数组进行区间DP,最后取所有长度为n的区间的最小值。
实现要点:
邮局问题是决策单调性优化的经典案例:在一条直线上有n个房子,需要建立m个邮局,使得每个房子到最近邮局的距离之和最小。
定义f[i][j]表示前i个邮局覆盖前j个房子的最小总距离。转移方程为:
f[i][j] = min(f[i-1][k] + w(k+1,j)),其中k < j
这里w(l,r)表示在[l,r]区间内建立一个邮局的最小总距离,显然邮局应该建在中位数位置。
w(l,r)可以通过前缀和在O(1)时间内计算:
cpp复制int w(int l, int r) {
int mid = (l + r) / 2;
return (a[mid]*(mid-l) - (s[mid-1]-s[l-1])) +
((s[r]-s[mid]) - a[mid]*(r-mid));
}
这个函数满足四边形不等式,因此DP具有决策单调性。
类似于区间DP的优化,我们可以记录s[i][j]表示f[i][j]的最优决策点,并利用性质:
s[i-1][j] ≤ s[i][j] ≤ s[i][j+1]
实现时需要倒序枚举j:
cpp复制for(int i=1; i<=m; i++) {
s[i][n+1] = n; // 边界条件
for(int j=n; j>=1; j--) {
for(int k=s[i-1][j]; k<=s[i][j+1] && k<j; k++) {
if(f[i][j] > f[i-1][k] + w(k+1,j)) {
f[i][j] = f[i-1][k] + w(k+1,j);
s[i][j] = k;
}
}
}
}
时间复杂度为O(n² + nm),其中O(n²)来自预处理w数组。
分治法利用了决策单调性的分治特性,对于每一层i,我们递归地计算f[i][j]:
实现代码:
cpp复制void solve(int i, int l, int r, int optL, int optR) {
if(l > r) return;
int mid = (l + r) / 2;
f[i][mid] = INF;
int opt = optL;
for(int k=optL; k<=min(optR,mid-1); k++) {
int val = f[i-1][k] + w(k+1,mid);
if(val < f[i][mid]) {
f[i][mid] = val;
opt = k;
}
}
solve(i, l, mid-1, optL, opt);
solve(i, mid+1, r, opt, optR);
}
每层时间复杂度O(nlogn),总复杂度O(mnlogn)。
二分队列是最通用的决策单调性优化方法,适用于单层DP。它维护一个决策点队列,每个决策点负责一个区间。
关键步骤:
实现代码:
cpp复制struct Node { int k, l, r; } q[N];
int head, tail;
for(int i=1; i<=m; i++) {
head = tail = 1;
q[1] = {i-1, i, n}; // 初始决策点
for(int j=i; j<=n; j++) {
// 弹出过期的决策区间
while(head < tail && q[head].r < j) head++;
// 计算f[i][j]
int k = q[head].k;
f[i][j] = f[i-1][k] + w(k+1,j);
// 弹出被完全覆盖的决策区间
while(head <= tail) {
int pos = q[tail].l;
if(calc(i,j,pos) <= calc(i,q[tail].k,pos)) {
tail--;
} else break;
}
// 二分查找分割点
int L = q[tail].l, R = q[tail].r, pos = R+1;
while(L <= R) {
int mid = (L+R)/2;
if(calc(i,j,mid) <= calc(i,q[tail].k,mid)) {
pos = mid;
R = mid-1;
} else L = mid+1;
}
if(pos <= n) {
if(pos > q[tail].l) {
q[tail].r = pos-1;
q[++tail] = {j, pos, n};
} else {
q[tail] = {j, pos, n};
}
}
}
}
时间复杂度O(mnlogn),适用于更一般的决策单调性DP。
问题描述:有n个玩具需要装箱,每个玩具长度为c_i。要求将玩具分成若干组,每组的总长度加上组内玩具数量减1不能超过L。每组代价为(t-L)^2,其中t是实际长度。求最小总代价。
定义f[i]表示前i个玩具的最小总代价:
f[i] = min(f[j] + (sum(j+1,i)+i-j-1-L)^2)
令w(j+1,i) = (sum(j+1,i)+i-j-1-L)^2,可以证明w满足四边形不等式。
cpp复制int calc(int j, int i) {
int t = sum[i]-sum[j]+i-j-1-L;
return f[j] + t*t;
}
int find_pos(int x, int y, int l, int r) {
while(l <= r) {
int mid = (l+r)/2;
if(calc(x,mid) <= calc(y,mid)) r = mid-1;
else l = mid+1;
}
return l;
}
void solve() {
head = tail = 1;
q[1] = {0, 1, n};
for(int i=1; i<=n; i++) {
while(head < tail && q[head].r < i) head++;
f[i] = calc(q[head].k, i);
while(head <= tail && calc(i,q[tail].l) <= calc(q[tail].k,q[tail].l)) {
tail--;
}
if(head > tail) {
q[++tail] = {i, i+1, n};
} else {
int pos = find_pos(i, q[tail].k, q[tail].l, q[tail].r);
if(pos <= n) {
q[tail].r = pos-1;
q[++tail] = {i, pos, n};
}
}
}
}
边界条件处理:决策单调性优化中,边界条件的处理尤为重要。例如在分治法中,递归终止条件必须正确设置;在二分队列中,初始决策点的设置要合理。
代价函数预处理:对于可以预处理的代价函数(如石子合并中的区间和),预处理可以大幅提高效率。而对于需要动态计算的代价函数(如邮局问题中的中位数距离和),要确保计算复杂度尽可能低。
决策点初始化:在分治法和记录决策点法中,初始决策点的设置会影响正确性。通常需要根据问题特性仔细设置初始值。
循环顺序:在记录决策点法中,循环顺序(正序或倒序)对正确性至关重要,必须根据决策单调性的具体性质来确定。
二分查找实现:在二分队列法中,二分查找的实现要特别注意边界条件,避免死循环或错误分割。
三种主要优化方法的比较:
| 方法 | 适用场景 | 时间复杂度 | 实现难度 | 通用性 |
|---|---|---|---|---|
| 记录决策点 | 区间DP或分层DP | O(n²) | 中等 | 较低 |
| 分治法 | 分层DP | O(nlogn)每层 | 较低 | 中等 |
| 二分队列 | 单层或分层DP | O(nlogn) | 较高 | 最高 |
选择建议:
在实际实现决策单调性优化时,容易遇到以下问题:
验证方法:
调试技巧:
检查方法:
建议:
决策单调性优化不仅适用于经典的DP问题,还可以应用于:
在实际应用中,识别决策单调性往往比实现优化本身更具挑战性。经验丰富的选手通常会:
掌握决策单调性优化可以大幅提升解决复杂DP问题的能力,是算法竞赛选手和算法工程师的重要技能之一。通过大量练习和总结,开发者可以培养出快速识别和应用这类优化的直觉。