本文讲解几种动态规划的优化方式。

滚动数组一类显然的恒等变形优化就咕了)逃



导论

DP 有两个重要的概念:状态与状态转移。

而 DP 过程的时间复杂度也可以由 \(O(DP)=\) 涉及到的状态总数 \(\times\) 转移决策数量 \(\times\) 处理状态转移的时间。

所以我们就有三个大方向:

  • 减少涉及的状态总数

我们可以试图减少涉及到的状态的总数。可以试图改变状态定义粗暴地达到这一点。

  • 减少寻找决策的时间

我们可以优化寻找最优状决策点的时间,撒大网会浪费很多时间。类似单调队列优化的算法就是让寻找最优决策点的时间大大减少的算法。

当然这个东西有时候与状态本身的定义与组织方式也有关系。

  • 减少计算状态转移的时间

通常来讲,我们的状态转移方程是一个递推式,复杂度很低。

但有时候某些状态转移不能 \(O(1)\) 计算,我们可以在状态计算方面下点文章。



矩阵优化

这本质上是一种优化状态转移计算时间的方法。

首先要了解一点矩阵的基础知识

矩阵

在数学中,矩阵(Matrix)是一个按照长方阵列排列的复数或实数集合 ,最早来自于方程组的系数及常数所构成的方阵。这一概念由19世纪英国数学家凯利首先提出。—来自度娘。

举个例子:

\[\begin{bmatrix} 1 & 0\\ 0 & 1 \end{bmatrix} \]

这就是一个矩阵。

矩阵的运算

矩阵的加法

\(A_{i,j}\) 表示矩阵 \(A\) 的第 \(i\) 行第 \(j\) 列。

那么矩阵加法是:对于两个 \(N\times N\) 的矩阵 \(A,B\),若 \(C=A+B\)
\(C_{i,j}=A_{i,j}+B_{i,j}\)\(C\) 也是一个 \(N\times N\) 的矩阵

加法满足交换律和结合律。

矩阵的乘法

两个矩阵 \(A,B\) 之间的乘法当且仅当 \(A\) 的行数与 \(B\) 的列数相等的时候才有定义。

\(A\) 是一个 \(N\times K\) 的矩阵 ,\(B\) 是一个 \(K\times M\) 的矩阵,令矩阵 \(C=A\times B\) ,则 \(C\) 是一个 \(N\times M\) 的矩阵。并且:

\[C_{i,j}=\sum_{x=1}^NA_{i,k}B_{k,j} \]

矩阵乘法满足结合律与分配律

不满足交换律!!! 特殊情况不作考虑。


矩阵优化

讲了这么多,这玩意能干啥?

我们从最简单的例题看起:

P1349 广义斐波那契

已知:\(a_i=p\cdot a_{i-1}+q\cdot a_{i-2}\ (i>2)\)

给定 \(p,q,a_1,a_2,n,m\),求 \(a_n\bmod m\)

其中 \(p,q,a_1,a_2\in [0,2^{31}-1],\ n,m\in[1,2^{31}]\)

数据范围告诉我们,暴力递推是不行的。

既然这一章是讲矩阵优化的,那 我们来考虑一下矩阵的性质。

我们让矩阵 \(\begin{bmatrix}x&y\end{bmatrix}\)\(\begin{bmatrix}a&b\\ c&d\end{bmatrix}\) 相乘。得到: \(\begin{bmatrix}x^{\prime}=ax+cy&y^{\prime}=bx+dy\end{bmatrix}\)

每一项的形式都是线性齐次式,和题目中的 \(2\) 阶常系线性齐次递推式很像。我们考虑令 \(x=a_n,y=a_{n-1}\) ,要使得 \(x^{\prime}=a_{n+1},y^{\prime}=a_n\)

最显然的办法是:\(a=p,c=q,b=1,d=0\)

所以对于这个题,我们先构造一个答案矩阵,初始为 \(S=\begin{bmatrix}a_2&a_1\end{bmatrix}\)。再构造一个递推矩阵 \(A=\begin{bmatrix}p&1\\ q&0\end{bmatrix}\),然后我们令:\(S^{\prime}=S\times A^{n-2}\) ,则 \(S^{\prime}_{1,1}\) 就是答案。

code:(矩阵为了顺手我转置了,反正等价)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N=2;

ll p,q,a1,a2,n,m;
void mul(ll q[][N],ll a[][N],ll b[][N],ll mod)
{
	ll tmp[N][N];
	for(int i=0;i<N;i++)
	{
		for(int j=0;j<N;j++)
		{
			tmp[i][j]=0;
		}
	}

	for(int i=0;i<N;i++)
	{
		for(int j=0;j<N;j++)
		{
			for(int k=0;k<N;k++)
			{
				tmp[i][j]+=(ll)a[i][k]*b[k][j]%mod;
				tmp[i][j]%=mod;
			}
		}
	}
	memcpy(a,tmp,sizeof tmp);
}

int main()
{
	cin>>p>>q>>a1>>a2>>n>>m;
	ll CM[N][N]={{p,1},{q,0}};
	ll PM[N][N]={{a2,a1},{0,0}};
	if(n==1)
	{
		cout<<a1%m;
		return 0;
	}
	else if(n==2)
	{
		cout<<a2%m;
		return 0;
	}
	n-=2;

	while(n)
	{
		if(n&1) mul(PM,PM,CM,m);
		mul(CM,CM,CM,m);
		n>>=1;
	}
	cout<<PM[0][0]%m;
	return 0;
}

以上的分析方式,能够推广到 \(n\) 阶常系线性齐次递推方程。

下面来看几个拓展情况:

  • 方程带常数项 \(k\)

    我们用上面的二阶递推式来举例子

    现在我们的式子是 \(f(n)=pf(n-1)+qf(n-2)+k\)

    常数项显然是不能忽略的,并且常数项不会变,所以我们必须加一维来计算常数项的贡献,按如下方法构造:

    答案矩阵初始为 \(\begin{bmatrix}f(2)&f(1)&k\end{bmatrix}\),递推矩阵是 \(\begin{bmatrix}p&1&0\\ q&0&0\\1&0&1\end{bmatrix}\)

  • 方程带未知项 \(n\)

    举最简单的例子

    已知递推方程为 \(f(n)=pf(n-1)+qf(n-2)+n\)

    我们考虑 \(n\) 这一项的递推式:\(n=n-1+1\)

    我们无法直接在矩阵乘法中直接加上这个 \(1\),参照上面的方法,我们再加一维辅助 \(n\) 的递推。

    所以答案矩阵初始为 \(\begin{bmatrix}f(2)&f(1)&2&1\end{bmatrix}\),递推的矩阵是 \(\begin{bmatrix}p&1&0&0\\ q&0&0&0\\ 1&0&1&0\\ 1&0&1&1\end{bmatrix}\)

  • 求和

    现在我们要求的是 \(Sum(n)=\sum\limits_{i=1}^nf(n)\)

    关心一下递推式子:\(sum(n)=Sum(n-1)+f(n)\)

    我们递推的时候把 \(Sum(n)\) 放进去一起递推就好了。

    计算

    \[\begin{bmatrix}f(2)&f(1)&Sum(1)\end{bmatrix}\times \begin{bmatrix}p&1&1\\ q&0&0\\ 0&0&1\end{bmatrix}^{n-1} \]

    即可。



单调队列优化 DP

前置芝士:单调队列

概述

单调队列优化其实只在解决一类问题:滑动窗口求最值的问题。

当然,这里的窗口大小不一定固定,只需要保证递增即可。

这个模型非常的固定,我们只要出现 \(f(i)=\max\limits_{r-k\le i\le r}\{f(i)\}\) 类似的形式我们就可以套用单调队列优化。

这样的优化方法一般会节省一个循环,大约 \(O(n)\) 的时间。


琪露诺

题目传送门

题意简述

琪露诺在一列格子里面从第 \(0\) 格子开始向第 \(N\) 个格子跳。当她在第 \(i\) 位的时候能够跳到区间 \([i+L,i+R]\) 。求跳到 \(N\) 时经过的格子权值和的最大值。

解析

\(f(i)\) 为跳到第 \(i\) 位的最大权值和。考虑第 \(i\) 位状态来源,很容易可以推得:

\[f(i)=\max_{i-R\le k\le i-L}\{f(k)\}+w_i \]

其中 \(w_i\) 是第 \(i\) 格的权值。(真的很容易)

到此为止,我们可以写出 \(O(n^2)\) 的算法了。但是在原题中,\(n\) 的大小能够达到 \(2\times 10^5\),显然 \(O(n^2)\) 是不够的。所以我们继续优化。

观察提议,我们发现每一次转移来源 \(f(k)\) 都在已知区间 \([i-R,i-L]\) 中,\(L,R\) 由题目给定,所以是一个定区间,我们可以使用单调队列直接该维护长度为 \(R-L+1\) 的区间内的 \(f\) 最大值。总时间复杂度降到了 \(O(n)\)

#include <bits/stdc++.h>
using namespace std;

const int N=4e5+10;

int n,L,R;
int poi[N],dp[N];
int q[N],hh=1,tt=0;

int main()
{
	scanf("%d%d%d",&n,&L,&R);
	memset(dp,-0x3f,sizeof dp);
	int ans=dp[0];
	dp[0]=0;
	for(int i=0;i<=n;i++) scanf("%d",&poi[i]);
	for(int i=L;i<=n;i++)
	{
		while(hh<=tt&&i-q[hh]>R) hh++;
		while(hh<=tt&&dp[q[tt]]<=dp[i-L]) tt--;
		q[++tt]=i-L;
		dp[i]=dp[q[hh]]+poi[i];
	}
	for(int i=n-R+1;i<=n;i++)
		ans=max(ans,dp[i]);
	printf("%d",ans);
	return 0;
}

『POI2004』旅行

题目传送门(LOJ 10178)

题意简述

公路上总共有 \(n\) 个车站,第 \(i\) 站都有 \(d_i\) 汽油(有的站可能油量为零),每升油可以让汽车行驶一千米。第 \(i\) 站距离下一站的距离是 \(s_i\)

John 必须从某个车站出发,一直按顺时针(或逆时针)方向走遍所有的车站,并回到起点。

在一开始的时候,汽车内油量为零,John 每到一个车站就把该站所有的油都带上(起点站亦是如此),行驶过程中不能出现没有油的情况。

任务:已知每个加油站的油量与相邻加油站间的距离,判断以每个车站为起点能否按条件成功周游一周。

解析

我们先只考虑顺时针走的做法,逆时针的做法反过来做一遍即可。

第一件事肯定是破环为链,复制一遍接在后面。

考虑成功周游一圈的条件,就是在路线中的任意一个车站,走过的距离加上到下一个车站的距离恒小于等于已经经过的所有车站的总油量。总距离和总油量都可以前缀和得到。

也就是说,我们可以对前缀和求一个长度为 \(n\) 的区间最小值,然后每次判断是否满足条件即可。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N=2e6+20;

int n;
int o[N],d[N];
ll s[N];
int q[N];
bool ans[N];

int main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d%d",&o[i],&d[i]);
	for(int i=1;i<=n;i++) s[i]=s[i+n]=o[i]-d[i];
	for(int i=1;i<=n*2;i++) s[i]+=s[i-1];

	int head=0,tail=-1;
	for(int i=n*2;i;i--)
	{
		if(head<=tail&&q[head]>=i+n) head++;
		while(head<=tail&&s[q[tail]]>=s[i]) tail--;
		q[++tail]=i;
		if(i<=n)
		{
			if(s[q[head]]>=s[i-1]) ans[i]=1;
		}
	}

	d[0]=d[n];
	for(int i=1;i<=n;i++) s[i]=s[i+n]=o[i]-d[i-1];
	for(int i=1;i<=2*n;i++) s[i]+=s[i-1];

	head=0,tail=-1;
	for(int i=1;i<=2*n;i++)
	{
		if(head<=tail&&q[head]<i-n) head++;
		if(i>n)
		{
			if(s[q[head]]<=s[i]) ans[i-n]=1;
		}
		while(head<=tail&&s[q[tail]]<=s[i]) tail--;
		q[++tail]=i;
	}

	for(int i=1;i<=n;i++)
	{
		if(ans[i]) printf("TAK\n");
		else printf("NIE\n");
	}

	return 0;
}



斜率优化 DP

从字面意思可以推断出,这是一种利用斜率有关的性质的优化方法。

通过一道题目来引入斜率优化。

例:任务安排

\(n\) 个任务排成一个序列在一台机器上等待执行,它们的顺序不得改变。

机器会把这 \(n\) 个任务分成若干批,每一批包含连续的若干个任务。

从时刻 \(0\) 开始,任务被分批加工,执行第 \(i\) 个任务所需的时间是 \(T_i\)

另外,在每批任务开始前,机器需要 \(S\) 的启动时间,故执行一批任务所需的时间是启动时间 \(S\) 加上每个任务所需时间之和。

一个任务执行后,将在机器中稍作等待,直至该批任务全部执行完毕。

也就是说,同一批任务将在同一时刻完成。

每个任务的费用是它的完成时刻乘以一个费用系数 \(C_i\)

请为机器规划一个分组方案,使得总费用最小。

输入格式

第一行包含两个整数 \(n\)\(S\)

接下来 \(n\) 行每行有一对整数,分别为 \(T_i\)\(C_i\),表示第 \(i\) 个任务单独完成所需的时间 \(T_i\) 及其费用系数 \(C_i\)

输出格式

输出一个整数,表示最小总费用。

\(1≤n≤3×10^5,\\ 0<S,C_i≤512,\\ 1≤T_i≤512\)


解析

首先要做的是考虑本题 DP 的做法。

我们设 \(f(i)\) 是表示将前 \(i\) 个任务处理完的所有方案中能得到的最小费用。

考虑最后一个不同点,我们应当去考察倒数第二批的最后一个任务的位置,设其为 \(j\),我们将 \(f(i)\) 代表的方案集合按照 \(j\) 分类:

动态规划的优化_斜率

这个 \(j\) 最小可以从 \(0\) 开始(即一次性处理完所有的任务),也可以顶到 \(i-1\) ( 当前最后一个任务批次只有 \(i\) 一个任务)。

考虑状态如何转移。由于每一个批次都需要一个开机时间 \(S\),这会使得之后的状态费用发生变化。按这样讲的话我们的 DP 状态定义理应还需要一位变量,将已有的批次数记录下来。

但是,我们先不急着调整状态,先考虑一下这个开机时间 \(S\) 造成的影响。每一次开机,都会造成之后所有的任务结束时刻推迟 \(S\)。所以,从总的答案上来看,假设在第 \(j\) 个任务结束后重新开机,就会多 \(S\times \sum_{i=j+1}^nC_i\) 的费用。

把变化的费用转化成不变的费用,这样的花费转化方式在 DP 中很常用,不少时候能让一些很好的优化能够被应用于 DP 中。

所以状态转移方程为:

\[f(i)=\min_{0\le j\le i-1}\{f[j]+\sum_{x=1}^iT_x\sum_{x=j+1}^iC_x+S\sum_{x=j+1}^nC_x\} \]

到此,只需要预处理几个前缀和,问题就可以被 \(O(n^2)\) 解决了。

但是我们还想更快,怎么办呢?

斜率优化

我们接着上面说。

\(sumC(i),sumT(i)\) 分别是 \(\{C_i\},\{T_i\}\) 的前缀和。然后整理一下 DP 方程:

\[f(i)=\min_{0\le j\le i-1}\{f(i)+sumT(i)\times(sumC(i)-sumC(j))+S\times(sumC(n)-sumC(j))\} \]

观察等式左边的式子我们发现,在枚举到一个 \(i\) 试图计算 \(f(i)\) 时,只有 \(j\) 是变量。有两个量跟着 \(j\) 变化:\(f(j)\)\(sumC(j)\)

我们把 \(\min\) 去掉,然后以与 \(j\) 有关的项为主元化简一下:

\[f(i)=f(j)-(sumT(i)+S)\cdot sumC(j)+sumT(i)\cdot sumC(i)+S\cdot sumC(n) \]

然后再移下项:

\[f(j)=(sumT(i)+S)\cdot sumC(j)-sumT(i)\cdot sumC(i)-S\cdot sumC(n)+f(i) \]

不妨设 \(y=f(j),x=sumC(j)\) ,上面那个式子变成了:

\[y=(sumT(i)+S)\cdot x-sumT(i)\cdot sumC(i)-S\cdot sumC(n)+f(i)\ \]

是不是就是一个直线方程?其中 \(k=sumT(i)+S,\ b=f(i)-sumT(i)\cdot sumC(i)-S\cdot sumC(n)\)

现在来看我们的目标:最小化 \(f(i)\)

根据上面的推论,已知一条直线的斜率,我们要正确找到一个 \(j\) ,得到一个点 \((x,y)\) 代入直线使得截距最小。

\(j\) 的取值范围是 \([0,i-1]\),也就是说我们要 \((sumC(0),f(0)),(sumC(1),f(1)),(sumC(2),f(2))\dots (sumC(i-1),f(i-1))\) 中找到一个点,使得固定斜率的直线经过这个点的时候最小化截距。

那么什么时候截距最小呢?我们先在一个坐标系中随机撒上一些点,来探究点的位置与截距的关系

动态规划的优化_DP_02

我们首先可以推得,所有的点都应该这条直线的上方或者就在这条直线上,否则就存在一个点能使得直线的截距更小。

那么什么样的点可能满足这个条件呢?当然是这些点最外围的点,也就是这些点组成的最大凸壳上的点。具体的,一个点是最优解当且仅当它是第一条斜率大于这条直线斜率的线段的左端点。

动态规划的优化_i++_03

同时,由于直线的斜率在本题是单调递增的,且新加入点的横坐标也单调递增,查询时斜率小于直线的线段都可以直接删去。

我们可用类似于单调队列的操作来完成维护下凸壳的过程。

大体来说:查询时将队头所有小于当前斜率的线段全部删掉,插入最新点之后将不符合凸壳条件的点删去。

我们将这个过程具体化:

  • 对于在队头的操作

    我们首先假设队头的点编号为 \(hh\),队头后面那个点为 \(hh+1\),当前的斜率为 \(sumT(i)+S\)。那么当 \(\frac{f(hh+1)-f(hh)}{sumC(hh+1)-sumC(hh)}\le sumT(i)+S\) 的时候就应该把队头元素 \(x\) 删去。

  • 对于在队尾的操作

    此时我们应该已经将 \(f(i)\) 做出来了,接下来需要将这个点插入。
    首先我们算队尾两个点之间的线段的斜率,然后算与队尾和插入点的斜率,再作比较。设队尾为 \(tt\),则我们计算 \(\frac{f(tt)-f(tt-1)}{sumC(tt)-sumC(tt-1)}\ge \frac{f(i)-f(tt)}{sumC(i)-sumC(tt)}\) 满足,则将队尾元素删去。

(这其实就是 Graham 算法,需要按水平顺序扫描点)

先给出这种情况的代码实现(省略了除DP外的其他部分)

hh=tt=0;//队列初始化
q[0]=0;//一开始是有一个点 (sumC(0),f(0)) 的
for(int i=1;i<=n;i++)
{
    while(hh<tt && f[q[hh+1]]-f[q[hh]]<=(sumt[i]+s)*(sumc[q[hh+1]]-sumc[q[hh]]))
        hh++;//转化一下,把除法搞成乘法
    int j=q[hh];
    f[i]=f[j]-(sumt[i]+s)*sumc[j]+sumt[i]*sumc[i]+s*sumc[n];
    while(hh<tt && (f[q[tt]]-f[q[tt-1]])*(sumc[i]-sumc[q[tt]])>=(f[i]-f[q[tt]])*(sumc[q[tt]]-sumc[q[tt-1]]))
        tt--;
    q[++tt]=i;
}
printf("%lld",f[n]);

此时的每个点进队出队一次,复杂度 \(O(n)\)

单调性的拓展

更改条件: \(-512\le T_i\le 512\),任务完成的时间可以是负数 (这河狸吗)

此时我们直线的斜率不再是递增的了。但是横坐标仍然单调递增

对于查询我们只能二分解决了,并且我们不能删除队头的元素,只能删除队尾的不在凸壳上的元素。

所以时间复杂度 \(O(n)\to O(n\log n)\)

给出代码实现:

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N=3e5+10;

int n,s;
ll sumc[N],sumt[N],f[N];
int q[N],hh,tt;

int main()
{
	scanf("%d%d",&n,&s);
	for(int i=1;i<=n;i++)
	{
		scanf("%lld%lld",&sumt[i],&sumc[i]);
		sumc[i]+=sumc[i-1]; sumt[i]+=sumt[i-1];
	}
	hh=tt=0;//队列初始化
	q[0]=0;//一开始是有一个点 (sumC(0),f(0)) 的
	for(int i=1;i<=n;i++)
	{
		int l=hh,r=tt;
		while(l<r)
		{
			int mid=(l+r)>>1;
			if(f[q[mid+1]]-f[q[mid]]>(__int128)(sumt[i]+s)*(sumc[q[mid+1]]-sumc[q[mid]])) r=mid;
			else l=mid+1;
		}
		int j=q[r];
		f[i]=f[j]-(sumt[i]+s)*sumc[j]+sumt[i]*sumc[i]+s*sumc[n];
		while(hh<tt && (__int128)(f[q[tt]]-f[q[tt-1]])*(sumc[i]-sumc[q[tt]])>=(__int128)(f[i]-f[q[tt]])*(sumc[q[tt]]-sumc[q[tt-1]]))
			tt--;
		q[++tt]=i;
	}
	printf("%lld",f[n]);
	return 0;
}


但是,有些时候横坐标不是单调的,这时该怎么办?

由于我们可能在任何一个地方插入点,我们不能使用上面的扫描法做凸包了。需要动态维护一个凸包。

我真写不出来这种东西,告辞

决策的单调性

(其实就是四边形不等式优化,先咕着)