四边形不等式优化DP:
一,介绍
四边形不等式是一种用于优化动态规划(DP)问题的数学工具,主要适用于形如 fi=minj<i{fj+cost(j,i)} 的转移方程。其核心思想是通过证明代价函数满足特定的不等式性质,从而缩小状态转移时的决策变量范围,将时间复杂度从 O(n2) 优化至 O(nlogn) 或更低。
二、核心原理与关键性质
四边形不等式优化的核心依赖于以下两个关键条件:
区间包含单调性 若对于任意 (a <= b <= c <= d),代价函数满足 ({cost}(a, d) >= {cost}(b, c)),则称 ({cost}) 具有区间包含单调性。 直观理解:较大区间的代价不小于较小区间的代价(如区间越长,计算成本越高)。
四边形不等式 对于任意 (a <= b <= c <= d),代价函数满足 ({cost}(a, d) + {cost}(b, c) >= {cost}(a, c) + {cost}(b, d))。 直观理解:交叉区间的代价和不小于相邻区间的代价和(如 “长 + 短” 区间的代价和 ≥ “中 + 中” 区间的代价和)。
四边形不等式(((i,j)) 与 ((i+1,j+1)) 形式)
对于任意整数 (i leq j),代价函数 (text{cost}(i, j)) 满足:(text{cost}(i, j) + {cost}(i+1, j+1) >={cost}(i, j+1) + {cost}(i+1, j))
最重要的点:决策单调性:
三、决策单调性与优化原理
当代价函数满足上述两个条件时,DP 的决策变量 j 具有单调性,即:
设 (k(i)) 为计算 (f_i) 时的最优决策点(即使 (f_j + {cost}(j, i)) 最小的 j),则 (k(i)) 随 i 的增大而单调不减,即 (k(i-1) <= k(i))。
利用这一性质,可将状
四、优化后的算法实现步骤
预处理代价函数:验证 (text{cost}(j, i)) 是否满足四边形不等式和区间包含单调性(通常通过数学归纳法或问题性质证明)。
维护决策点数组:记录每个 i 的最优决策点 (k(i)),初始时 (k(1) = 0)(假设边界条件)。
按决策单调性枚举:计算 (f_i) 时,仅在 ([k(i-1), k(i+1)]) 范围内枚举 j,而非全区间。
更新决策点:每次计算完 (f_i) 后,更新 (k(i+1)) 的可能范围。
五,例题:石子合并:
设有 NN 堆石子排成一排,其编号为 1,2,3,…,N1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 NN 堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有 44 堆石子分别为 1 3 5 2, 我们可以先合并 1、21、2 堆,代价为 44,得到 4 5 2, 又合并 1、21、2 堆,代价为 99,得到 9 2 ,再合并得到 1111,总代价为 4+9+11=244+9+11=24;
如果第二步是先合并 2、32、3 堆,则代价为 77,得到 4 7,最后一次合并代价为 1111,总代价为 4+7+11=224+7+11=22。
问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
输入格式
第一行一个数 NN 表示石子的堆数 NN。
第二行 NN 个数,表示每堆石子的质量(均不超过 10001000)。
输出格式
输出一个整数,表示最小代价。
数据范围
1≤N≤300
这里的cost是sum(i,j),那么一定有sum(i,j+1)+sum(i+1,j)>=sum(i,j)+sum(i+1,j+1);
可以优化,
对于一个区间len,他们的可参考区间是len-1的区间
#include <iostream>
#include <vector>
#include <algorithm>
const int INT_MAX=0x3f3f3f3f;
using namespace std;
int stoneMergeOptimized(vector<int>& stones) {
int n = stones.size();
if (n == 0) return 0;
// 计算前缀和数组
vector<int> prefixSum(n + 1, 0);
for (int i = 1; i <= n; i++) {
prefixSum[i] = prefixSum[i - 1] + stones[i - 1];
}
// 初始化dp数组和决策点数组
vector<vector<int>> dp(n, vector<int>(n, 0));
vector<vector<int>> kTable(n, vector<int>(n, 0));
// 初始化长度为1的区间
for (int i = 0; i < n; i++) {
dp[i][i] = 0;
kTable[i][i] = i;
}
// 枚举区间长度l从2到n
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = i + l - 1;
dp[i][j] = INT_MAX;
int sum = prefixSum[j + 1] - prefixSum[i];
// 决策点范围优化:k∈[kTable[i][j-1], kTable[i+1][j]]
int startK = kTable[i][j - 1];
int endK = kTable[i + 1][j];
if (startK < i) startK = i;
if (endK > j - 1) endK = j - 1;
for (int k = startK; k <= endK; k++) {
int current = dp[i][k] + dp[k + 1][j] + sum;
if (current < dp[i][j]) {
dp[i][j] = current;
kTable[i][j] = k;
}
}
}
}
return dp[0][n - 1];
}
int main() {
vector<int> stones ;
int n;
cin>>n;
for(int i=0;i<n;i++){
int tmp;
cin>>tmp;
stones.push_back(tmp);
}
cout << stoneMergeOptimized(stones) << endl;
return 0;
}
例题2:诗人小G
小 GG 是一个出色的诗人,经常作诗自娱自乐。
但是,他一直被一件事情所困扰,那就是诗的排版问题。
一首诗包含了若干个句子,对于一些连续的短句,可以将它们用空格隔开并放在一行中,注意一行中可以放的句子数目是没有限制的。
小 GG 给每首诗定义了一个行标准长度(行的长度为一行中符号的总个数),他希望排版后每行的长度都和行标准长度相差不远。
显然排版时,不应改变原有的句子顺序,并且小 GG 不允许把一个句子分在两行或者更多的行内。
在满足上面两个条件的情况下,小 GG 对于排版中的每行定义了一个不协调度,为这行的实际长度与行标准长度差值绝对值的 PP 次方,而一个排版的不协调度为所有行不协调度的总和。
小 GG 最近又作了几首诗,现在请你对这几首诗进行排版,使得排版后的诗尽量协调(即不协调度尽量小),并把排版的结果告诉他。
输入格式
第一行包含一个整数 TT,表示诗的数量,接下来是 TT 首诗,每首诗是一组数据。
每组数据的第一行包含三个整数 N,LN,L 和 PP,其中 NN 表示这首诗句子的数目,LL 表示这首诗的行标准长度,PP 的含义参考问题描述。
从第二行开始,每行一个句子,句子由英文字母、数字、标点符号等符号组成(ASCII 码 33∼12733∼127,但不包含 -)。
输出格式
对于每组测试数据,若最小的不协调度不超过 10181018,则第一行为一个数,表示不协调度。接下来若干行,表示你排版之后的诗。注意:在同一行的相邻两个句子之间需要用一个空格分开。
如果有多个可行解,它们的不协调度都是最小值,则输出任意一个解均可。(本题有 special judge)(由于本题数据量大,展示标准答案时,不展示可行解)
若最小的不协调度超过 10181018,则输出 Too hard to arrange。
每组测试数据结束后输出 --------------------,共 2020 个 -,- 的 ASCII 码为 4545,请勿输出多余的空行或者空格。
#include <cstdio>
#include <cstring>
typedef long double ll;
const int N = 100005;
int n, L, P;
char str[N][31];
int s[N], g[N], res[N];
ll f[N];
// 队列
struct Node
{
// 区间 [l, r] 当前的决策都是 x
int l, r, x;
} q[N];
int hh, tt;
ll min(ll a, ll b) {return a < b ? a : b;}
// 返回将第 i 句诗放到第 j 句诗所在行后,前 i 句诗的不协调度和
ll w(int j, int i)
{
ll v = 1, x = s[i] - s[j - 1] - 1 - L;
for (int k = P; k; k >>= 1, x *= x)
if (k & 1) v *= x;
return (v > 0 ? v : -v) + f[j - 1];
}
// 将 i 插入队列
void insert(int i)
{
int p = -1; // 存 i 应该插入的位置
while (hh <= tt)
// 当队列中有元素,并且 i 比队头的 l 的决策更优,则将队头元素删除,取队头的 l 做 i 插入的位置
if (w(i, q[tt].l) <= w(q[tt].x, q[tt].l)) p = q[tt -- ].l;
else break;
// 如果删完之后,如果队头元素的 r 的的决策不比决策 i 更优,则在队头的 [l, r] 中二分查找
if (w(i, q[tt].r) < w(q[tt].x, q[tt].r))
{
int l = q[tt].l, r = q[tt].r, mid;
while (l < r)
{
mid = l + r >> 1;
// 如果 i 做 mid 的决策比队头的 x 做 mid 的决策更优,则应该往前找,让 r = mid
if (w(i, mid) <= w(q[tt].x, mid)) r = mid;
else l = mid + 1; // 否则往后找
}
// 二分之后 r 就是我们要找的位置,即 r 前面的所有决策都比 i 更优,r 及其后面 i 更优
q[tt].r = r - 1, p = r;
}
// 如果 p 不等于 -1,说明 i 比原来队头的某个决策更优,更新了队列,那么就将 p 及以后的决策都改为 i
if (~p) q[ ++ tt] = {p, n, i};
}
int main()
{
int task;
for (scanf("%d", &task); task -- ;)
{
scanf("%d %d %d", &n, &L, &P);
for (int i = 1; i <= n; i ++ )
{
scanf("%s", str[i]);
s[i] = s[i - 1] + strlen(str[i]) + 1;
}
// 将队列清空,并将 1 ~ n 的最优决策都设为 0
hh = tt = 0, *q = {1, n, 0};
for (int i = 1; i <= n; i ++ )
{
insert(i); // 在队列中插入 i
g[i] = q[hh].x, f[i] = w(g[i], i); // f[i] 的最优决策即为队头
while (hh <= tt && q[hh].r <= i) hh ++ ; // 队列中所有 1 ~ i 中的元素直接去掉
q[hh].l = i + 1; // 将队列中最后一个元素中,1 ~ i 中的元素去掉
}
if (f[n] > 1e18) puts("Too hard to arrange");
else
{
printf("%lldn", (long long) f[n]); // 输出答案
// 输出决策。res[i] 表示第 i 句诗所在行中,末尾诗句是第几句
for (int i = n; i; i = g[i] - 1) res[g[i]] = i;
for (int i = 1; i <= n; i = res[i] + 1)
{
for (int j = i; j <= res[i]; j ++ )
{
printf("%s", str[j]);
// 注意不要输出多余空格
if (j != res[i]) putchar(32);
}
putchar(10);
}
}
for (int i = 20; i -- ; putchar(45));
putchar(10);
}
return 0;
}
插头 DP 简介
插头 DP 是一种用于解决棋盘路径、回路等状态压缩动态规划问题的方法。核心思想是通过状态压缩记录棋盘上分界线(通常是从左到右、从上到下扫描棋盘时划分已处理和未处理部分的线)上插头(可以理解为路径通过分界线的连接点)的状态,利用动态规划递推求解。
各情况分析
1-(i, j) 是障碍物
此时不能有路径经过这个格子,所以如果((i, j))是障碍物,那么对于任何插头状态s,(f[i][j][s]=0) 。因为不可能存在合法路径经过障碍格来满足题目要求。
2-(i, j) 两个边都是 0
当左边插头和上边插头都是0(即没有插头连接过来)时,若当前格子不是回路的起点,这种情况是不合法的,(f[i][j][s]=0) 。若当前格子是回路起点(一般在处理第一行第一列等特殊位置时判断),可以新开启一条路径,后续根据状态转移规则继续递推(只能是下和右都有)。
3-左非 0 上 0
左边有插头连接过来(插头状态为1或2 ),上边没有插头。此时可以将左边的插头延伸到当前格子,然后根据延伸后的插头状态更新(f[i][j][s]) 。比如左边插头为1 ,延伸到当前格子后,若当前格子右边没有其他插头连接,那么右边插头变为1 ,同时更新状态对应的方案数。当然可以向下更新
4-上非 0 左 0
与 “左非 0 上 0” 类似,只是插头来源是上边。将上边的插头延伸到当前格子,根据延伸后的插头状态更新(f[i][j][s]) 。例如上边插头为2 ,延伸后若当前格子左边没有其他插头连接,左边插头变为2 ,并更新方案数。当然另外一个方向同理
5-左 1 上 2
只有右下角的格子可能有这个情况
6-左 2 上 1
左边插头为2 ,上边插头为1 ,说明有两条不同的路径从不同方向过来。需要按照路径连接规则将它们合理连接起来,更新插头状态(比如将这两个插头合并成一个新的状态,类似于括号匹配中不同括号的合并),并更新(f[i][j][s]) 。
都是 1
同 情况,两条路径在当前格子相遇,将它们合并成一条路径,更新插头状态并更新(f[i][j][s]) 。这里的合并操作要遵循插头状态的编码规则,比如将两个1编码成一个表示合并后路径的新状态。
都是 2
与 “都是 1” 类似,两条路径在当前格子相遇,按照规则合并路径,更新插头状态,进而更新(f[i][j][s]) 。
整体求解流程
初始化边界条件,比如第一行第一列等特殊位置的状态。
按照从左到右、从上到下的顺序遍历棋盘的每个格子。
对于每个格子,根据上述不同情况进行状态转移,更新(f[i][j][s]) 。
遍历完整个棋盘后,最终的回路数量通常可以从最后一行最后一列对应的合法状态的f值中获取(具体取决于状态编码和问题设定)。
1254

被折叠的 条评论
为什么被折叠?



