A
B

【学习笔记】DP优化

观前提示:作者是一只蒟蒻,如果内容有误请立即踢飞这只蒟蒻,本蒟蒻被踢飞后会立即改正。

1、 单调队列优化DP

先上个题体验一下。

样题: P1725 琪露诺

题面描述: 琪露诺从 \(i\) 点转移到 \([i + L,i + R]\) 任意一点,获得转移后格子的价值 \(a_j\) ,求从 \(1\) 节点转移到 \(n + 2\) 节点可获得的最大贡献。

首先我们考虑朴素做法,易得转移方程为: \(dp_i = \max(dp_i,dp_j + a_i),j \in [i - R,i - L]\)

于是我们直接对于每个 \(j\) 暴力枚举可能的 \(i\) ,于是我们可以得到如下代码

#include<bits/stdc++.h>
#define int long long 
#define Blue_Archive return 0
#define con putchar_unlocked(' ')
#define ent putchar_unlocked('\n')
using namespace std;
const int N = 2e5 + 7;

int n;
int L;
int R;
int ans;
int a[N];
int dp[N];

inline int read()
{
	int k = 0,f = 1;
	char c = getchar_unlocked();
	while(c < '0' || c > '9')
	{
		if(c == '-') f = -1;
		c = getchar_unlocked();
	}
	while(c >= '0' && c <= '9') k = (k << 3) + (k << 1) + c - '0',c = getchar_unlocked();
	return k * f;
}

inline void write(int x)
{
	if(x < 0) putchar_unlocked('-'),x = -x;
	if(x > 9) write(x / 10);
	putchar_unlocked(x % 10 + '0');
}

signed main()
{
	n = read();
	L = read();
	R = read();
	for(int i = 1;i <= n + 1;i ++) a[i] = read();
	for(int i = 1;i <= n + 1;i ++)
	{
		for(int j = \min(n + 2,i + L);j <= \min(n + 2,i + R);j ++)
		{
			dp[j] = \max(dp[j],dp[i] + a[j]);
		}
	}
 	write(dp[n + 2]);ent;
	Blue_Archive;
}

考虑优化,我们发现在每一次决策时, \(j\) 只可能在 \([i - R,i - L]\) 这一区间内,所以只需要考虑该集合内的每个点对该点的贡献。

而由于 \(L,R\) 为定值,因此对于之前决策时小于 \(i - l\) 的点,现在也小于 \(i - l\),所以不需要再考虑,因此我们只需要每次动态维护这段区间即可。

之后,我们考虑答案的单调性,对于一个 \(j\) ,若存在一个 \(j'\) 使得 \(j < j'\)\(dp[j] \le dp[j']\) ,那么 \(dp[j]\) 对于之后的答案将不会再有贡献,因为 \(j'\) 可以完美替代 \(j\) 的功能。(在合法区间待的更久,而且价值更高)。

所以我们可以用一个单调队列维护这些信息。

代码:

// (ᗜ^ᗜ) // 做这种题的真实表情
#include<bits/stdc++.h>
#define int long long 
#define Blue_Archive return 0
#define con putchar_unlocked(' ')
#define ent putchar_unlocked('\n')
using namespace std;
const int N = 2e5 + 7;
const int INF = 1e18;

int n;
int L;
int R;
int ans = -INF; // !存在负数
int a[N];
int q[N];
int dp[N];

inline int read()
{
	int k = 0,f = 1;
	char c = getchar_unlocked();
	while(c < '0' || c > '9')
	{
		if(c == '-') f = -1;
		c = getchar_unlocked();
	}
	while(c >= '0' && c <= '9') k = (k << 3) + (k << 1) + c - '0',c = getchar_unlocked();
	return k * f;
}

inline void write(int x)
{
	if(x < 0) putchar_unlocked('-'),x = -x;
	if(x > 9) write(x / 10);
	putchar_unlocked(x % 10 + '0');
}

signed main()
{
	// freopen("1.in","r",stdin);
	memset(dp,128,sizeof(dp));
	n = read();
	L = read();
	R = read();
	for(int i = 0;i <= n;i ++) a[i] = read();
	int l = 1,r = 1;
	dp[0] = 0;
	q[++ r] = 0;// DP 方程为 \max ,因此单调队列内部为递减(l 最大,r 最小)
	for(int i = L;i <= n;i ++)
	{	
		while(l <= r && i - q[l] > R) l ++;// 满足 队列内元素均在[i - R,i - L] 范围内
		dp[i] = dp[q[l]] + a[i];
		while(l <= r && dp[i - L + 1] >= dp[q[r]]) r --;// 放弃不优决策点,将新节点与 r 节点比较,决定是否更新
		q[++ r] = i - L + 1;//插入新节点
	}
	for(int i = n;i > n - R;i --) ans = \max(ans,dp[i]);
	write(ans);ent;
	Blue_Archive;
}

所以我们可以发现:单调队列可以通过维护具有决策单调性(即 \(i\)\(j\) 转移过来,满足 \(i\le j\)),且单调队列内部是单调的,多用于维护两端指针单调不减的区间最值情况(摘自OI.wiki)。

TIPS: 维护最大值,队列单减。维护最小值,队列单增

2、单调栈优化DP

与单调队列类似,只是维护的是前/后第一个大于/小于当前值的数

也是直接上例题: QOJ5500

非常简单,我们可以得到转移方程 \(dp_i = \max(dp_i,dp_j + (i - j) * (a_i + a_j)),j \in [1,i]\)

然后一看数据范围 : 5e5,好嘛, \(n^2\) 死得非常顺滑,赛时因为优化错误(直接想成斜率优化导致挂了50),没做出来,现在考虑如何优化。

先放正解代码:

#include<bits/stdc++.h>
#define int long long
#define Blue_Archive return 0
#define ent putchar_unlocked('\n')
#define con putchar_unlocked(' ')
using namespace std;
const int N = 5e5 + 7;
const int INF = 1e18;

int n;
int top;
int ans;
int a[N];
int f[N];
int q[N];
int sum[N];

inline int read()
{
	int k = 0,f = 1;
	char c = getchar_unlocked();
	while(c < '0' || c > '9')
	{
		if(c == '-') f = -1;
		c = getchar_unlocked();
	}
	while(c >= '0' && c <= '9') k = (k << 3) + (k << 1) + c - '0',c = getchar_unlocked();
	return k * f;
}

inline void write(int x)
{
	if(x < 0) putchar_unlocked('-'),x = -x;
	if(x > 9) write(x / 10);
	putchar_unlocked(x % 10 + '0');
}

signed main()
{
	freopen("bar.in","r",stdin);
	freopen("bar.out","w",stdout); 
	n = read();
	for(int i = 1;i <= n;i ++) a[i] = read();
	f[1] = (n - 1) * a[1];
	q[top = 1] = 1;
	for(int i = 2;i <= n;i ++)
	{
		while(1 < top && a[i] * (n - q[top]) - a[q[top]] * (n - i) + f[q[top]] < a[i] * (n - q[top - 1]) - a[q[top - 1]] * (n - i) + f[q[top - 1]]) top --;// 弹出所有不优决策维护栈顶最优(用 i 与 top 的结果和 i 与 top - 1 的结果决定栈顶是否最优)
		f[i] = (i - q[top]) * a[i] + (n - i) * (a[i] - a[q[top]]) + f[q[top]];//栈顶更新答案
		q[++ top] = i;//新节点入栈 
		ans = \max(ans,f[i]);
	}
	write(ans),ent;
	Blue_Archive;
}

从本质上来说,单调队列和单调栈是一样的,都是维护可获取的决策点与更优的决策点,从而获得正确答案。

应用场景:单调队列和单调栈用于具有决策单调性的场景。

那什么是决策单调性呢?

举个不严谨的栗子,现在有一个非常可爱的点 \(i\), 她的最优转移为 \([l,r]\) ,对于 \(i\) 点之后的小可爱们,她们的最优转移 \([L,R]\) ,一定满足 \(l \le L\) ,则决策具有单调性。

换句话说,就是对于一个点的最优决策,在该点之后的点中的最优决策没有早于该点的最优决策的。

我们也可以从实现方式上理解: 对于单调栈和单调队列,所有的元素都是只进入一次的(这也可以证明该优化时间复杂度为 \(O(n)\) ,多用于将 \(O(n^2)\) 优化为 \(O(n)\) ),所以可转移区间都是单调的。

3、斜率优化DP

关于DP优化,最有名的当然是斜率优化了,斜率优化顾名思义就是利用斜率相关性质进行DP优化,这简直就是废话。然而这其实非常重要,这启示我们去寻找斜率优化的本质。

首先,让我们从斜率优化的条件入手:

\[\large 形如 dp_i = \min/\max(a_i * b_j + c_j + d_i)的柿子。我们可以使用斜率优化 \]

tips:若是没有 \(a_i * b_j\) 这种东西,那么直接上单调队列/单调栈即可。

我们可以翻译一下,根据大蛇们以及自己手搓,上边这坨可以翻译成一个小清新的结论: 若是一个柿子可以化简为形如 \(y = k*x + b\) 的东西,其中 \(y\) 为与 \(j\) 的一次项,\(k*x\)\(i\)\(j\) 各种数组或本身的乘积,\(b\) 代表 \(i\)\(k * x\) 的与 \(i\) 有关的东西和常数。

我们可以用数形结合的思想来理解一下这个东西。

现在,想象一个二维坐标系,上面有很多的散点。然后用一条线把它们连成一个山峰或一个山谷。

翻译一下:

  1. 这个坐标系上的每个点都代表这一个决策点。横轴代表点的坐标纵轴代表点的最优决策 (\(i,dp_i\))。
  2. 每两个连线的点代表右边的点从左边的点转移过来
  3. 没有线的点曾经都有线跟它相连作为它的转移,之后通过不断地取舍无法作为更优的决策点转移到之后的点,因此被舍弃了。
  4. 山峰跟山谷是上凸包下凸包的形象化(?)。tips: 图在任何时候都是一个凸包的形式。

也就是说,如果存在两个决策点 \(j1\)​,\(j2\)​ 满足 \((0 \leq j1 < j2 < i)\),使得不等式 $ Y(j2)−Y(j1) \leq 2S_i * (X(j2​)−X(j1​)) (Y(x) 为纵坐标,X(x) 为横坐标) $
成立,或者说 使得 \(P(j2),P(j1)\) 两点所形成直线的斜率小于等于 \(2S_i\) ,那么决策点 \(j2\)​ 优于 \(j1\)​。

举个例子: 「HNOI2008」玩具装箱

貌似显然可得: $$dp_i = \min(dp_i,dp_j + (i - (j + 1) + sum_i - sum_j - L) ^ 2),j \in [1,i)$$

\[dp_i = \min(dp_i,dp_j + (sum_i - sum_j + i - j - 1 - L) ^ 2),j \in [1,i) \]

其中 \(sum_i\) 代表 \(c\) 数组的前缀和。

接下来,我们把式子化简一下(为了简便将 \(\min\) 删去):

\[dp_i = dp_j + (sum_i - sum_j + i - j - 1 - L) ^ 2 \]

\[dp_i = dp_j + ((sum_i + i) - (sum_j + j) - (1 + L)) ^ 2 \]

\[设 s_i = (sum_i + i) , s_j = (sum_j + j) , L' = (1 + L) \]

\[dp_i = dp_j + [(s_i - L') - s_j] ^ 2 \]

\[dp_i = dp_j + (s_i - L') ^2 + s_j ^ 2 - 2 * s_j *(s_i - L') \]

\[移项可得 \]

\[dp_j + s_j ^ 2 = dp_i - (s_i - L') ^ 2 + 2 * s_j * (s_i - L') \]

\[dp_j + s_j ^ 2 = dp_i - (s_i - L') ^ 2 - 2 * s_j * (L' - s_i) \]

\[观察可得: 我们可以设 \]

\[x = s_j \]

\[y = dp_j + s_j ^2 \]

\[k = -2(L' - s_i) \]

\[b = dp_i - (s_i - L') ^ 2 \]

这样,我们就得到了形如 \(y = k * x + b\) 形式的式子。

那么,得到这样式子之后,我们应该怎么做呢?

我们可以将转移方程看做 $b_i = \min(b_i,y_j - k_i * x_j),j \in [1,i) $ 。

我们可以把 \((x_i,y_i)\) 看成之前提到的那个二维坐标系上的点,\(k\) 则表示直线的斜率。\(b_i\) 表示该直线的截距。现在问题转化成了选择合适的 \(j\) ,使直线的截距最小。

感性理解:考虑有一条直线从下往上平移,能达到的第一个点即作为决策转移点。(该直线从下往上平移,斜率不变,截距递增,遇到的第一个点,即截距最小的点,因此该点最优)。

然后我们可以发现一条性质:可以让 \(b_i\) 取到最小值的点一定在凸包上(该题在下凸包上),因此,我们要维护一个凸包(推荐选用单调队列(因为所维护的斜率具有单调性),因为本蒻驥只会单调队列维护)。

所以总结就是:通过维护一个凸包,来保证斜率的单调性,进而保证答案的最优性

最后,代码贴上:

// P3195 [HNOI2008] 玩具装箱 
#include<bits/stdc++.h>
#define int long long
#define Blue_Archive return 0;
#define op DZC
#define lid (id << 1)
#define rid (id << 1 | 1) 
#define iny int
#define itn int
#define ull unsigned long long
#define rint register int
using namespace std;
const int N = 1e5 + 5e2;
const int mod = 1e9 + 7;
const int INF = 0x7f7f7f7f7f7f7f7f;

// // ⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

int n;
int l;
int c[N];
int s[N];
int a[N];
int b[N];
int q[N];
int dp[N];

// // ⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

inline int \max(int a,int b)
{
    if(a > b) return a;
    else return b;
}

inline int \min(int a,int b)
{
    if(a < b) return a;
    else return b;
}

inline int youka(int L)//转移方程(求结果用)
{
    return (L - l) * (L - l); 
}

inline double Shiroko(int k)//纵轴(Y)
{
    return dp[k] + b[k] * b[k];
}

inline double Hoshino(int m,int n)//斜率(K)
{
    return (Shiroko(m) - Shiroko(n)) / (double)(b[m] - b[n]);
}

// //⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

signed main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    cin >> n;
    cin >> l;
    b[0] = l + 1;
    for(int i = 1;i <= n;i ++)
    {
        cin >> c[i];
        s[i] = s[i - 1] + c[i];//预处理
        a[i] = s[i] + i;//预处理
        b[i] = a[i] + l + 1;//预处理
    }
    int l = 1;
    int r = 1;
    for(int i = 1;i <= n;i ++)
    {
        while(l < r && Hoshino(q[l + 1],q[l]) <= 2 * a[i]) l ++;//对于相切的点。其两边的连线必然一个斜率大于直线的斜率,一个斜率小于直线的斜率。将小于该直线斜率的点移除,队尾的元素即为相切点,进而求得最优转移点。
        int j = q[l];
        dp[i] = dp[j] + youka(i - j + s[i] - s[j] - 1);//统计答案
        while(l < r && Hoshino(q[r],q[r - 1]) >= Hoshino(i,q[r])) r --;//将最后两个点与要插入到点进行比较,保证斜率的单调性。
        q[++ r] = i;
    }
    cout << dp[n] << "\n";
    Blue_Archive;
}
////////////////////////////////////////////////////////
//⭐⭐⭐⭐⭐⭐⭐⭐⭐Shiroko⭐⭐⭐⭐⭐⭐⭐⭐⭐//
////////////////////////////////////////////////////////
////////////////////////////////////////////////////////
// ♪你指尖悦动的音符,是我此生不变的信仰,唯我Miku公主永存♪ //
////////////////////////////////////////////////////////

tips:关于斜率优化,概括一下就是

1、将初始状态入队。

2、每次使用一条和 \(i\) 相关的直线 \(f(i)\) 去切维护的凸包,找到最优决策,更新\(dp_i\)

3、加入状态\(dp_i\) 。如果一个状态(即凸包上的一个点)在 \(dp_i\) 加入后不再是凸包上的点,需要在 \(dp_i\) 加入前将其删除。(摘自OI.wiki

4、四边形不等式优化DP

本质:利用状态转移方程中的决策单调性

四边形不等式

若函数 \(w\) 满足以下关系,则其满足四边形不等式。
$$w(a,c) + w(b,d) \leq w(a,d) + w(b,c), \forall a \leq b \leq c \leq d$$

那为什么叫四边形不等式呢?可以想象一个四边形,四个顶点都是有序的,相对的两个顶点加和小于相邻的两个顶点加和。这就是四边形不等式。(不要忽略四个顶点都是有序的...

性质1:若 \(w\) 满足四边形不等式,则该问题满足决策单调性。

性质2:若 \(w\) 满足对于任意 \(i < i + 1 \leq j < j + 1\) ,都有 \(w{i,j} + w_{i + 1,j + 1} \leq w_{i,j + 1} + w_{i + 1,j}\),则其满足四边形不等式。

性质3:若有 \(x < j\) 使得对于某个 \(i > j\) ,选取 \(j\) 的转移比 \(x\) 要优,则对于 \(i′ > i\) ,取 \(j\) 也比 \(x\) 优。容易发现 \(j\)\(i\) 的决策点时与性质 1 等价,即性质 1 是性质 2 的特殊情况。

适用情形:形如 \(dp_i = \min(dp_i,dp_j + v_{j,i}),j \in [0,i)\) ,这样的式子。其中及时 \(v_{j,i}\),能在 \(O(1)\) 的情况下求出来也会 TLE ,发现 \(v\) 具有特殊的性质,此时我们可以考虑四边形不等式优化。

那么我们已经知道什么是四边形不等式和什么情况下用四边形不等式了,现在我们考虑如何使用四边形不等式优化DP。

考虑一类DP,其转移方程为 \(dp_i = \min(dp_i,dp_j + v_{j,i}),j \in [0,i)\),多半题意可简化为:将 \(n\) 个数分成若干个连续段,给定每一段的价值,要求最小化每段价值之和。

我们设 \(dp_i\) 为前 \(i\) 个数分段的最小价值和。每局 \(j\) 用来转移。

这样,我们就获得了 \(O(n ^ 2)\) 做法。显然无法通过。

因为该式子满足四边形不等式。我们不妨从她的性质考虑。

考虑有一个 \(i\) ,她可以更新她之后的 \(x\) 点,由于性质3, \(i\) 可以更新 \(x + 1\)\(n\) 的每个点。

假设 \(i\) 能更新的点的范围为 \([k,n]\),我们的目的就是找到这个 \(k\)

很容易发现 \(k\) 具有单调性,即在 \(k\) 之前的点都不满足条件,二之后的点均满足条件。因此,我们考虑二分。

关于具体实现,我们可以维护一个存储三元组 \((l,r,x)\) 的队列,表示 \(l ~ r\) 每个点的最优决策都为 \(x\) ,这个队列为对于 \(x\) 的单调队列。

我们首先枚举 \(i\) , 然后对于枚举的 \(i\),先从后往前找 \(k\) 所在的 \(l,r,x\),然后在 \([l,r]\) 中二分 \(k\) 的位置,之后再更新队列。时间复杂度 \(O(n log{n})\)

posted @ 2025-08-01 17:12  MyShiroko  阅读(38)  评论(7)    收藏  举报