dp套dp 隨寫

Hanghang007發表於2024-06-13

我不很理解為什麼將這個東西拿出來單獨講。

這種題大概的模型就是內層來個相較於同等題目簡單的內層 dp,再在外面套個殼子, 比如說每個元素給個取值範圍而非定值。一種不恰當的類比是函式複合。

整體思路就是你需要先設計出一個內層 dp,然後把內層地轉移看成一個類似於自動機的圖,外層 dp 就是把內層 dp 的結果拿出來當狀態再做一次轉移,往往這種時候狀態數是爆炸的,就需要在外層去最佳化/剪枝不必要的轉移/狀態。

所以難點就在兩個方向上,一個是設計出內層的 dp,一個是最佳化外層的狀態和轉移。

這種套娃題往往是可以出的極難無比的,出題人可以不斷地將內層dp 的難度提高,隨之而來的就是外層的設計越來越複雜,你去化簡外層dp 的難度(尤其是程式碼難度)會飛速提升。

值得注意的是,外層 dp 的最佳化是有萬能的演算法去解決的,即 Hopcroft DFA 最小化演算法 - yyyyxh ,但是由於演算法難度/程式碼難度較高,在 OI 中並不常用(dp 套 dp 本身出的也不多),有興趣可以自行了解。

P4590 [TJOI2018] 遊園會

首先你先忽略到存在 \(NOI\) 字串的限制,不妨設給定的串為 \(T\),你用的串為 \(S\)

考慮確定了 \(S\),就是典中典。設 \(f_{i,j}\) 表示考慮 \(S\) 中前 \(i\) 個 ,\(T\) 中前 \(j\) 個字元的最大公共子序列。

那麼有轉移方程:

\[f_{i,j}=\max(f_{i-1,j},f_{i,j-1},f_{i-1,j-1}+(S_i=T_j)) \]

內層 dp 做完了,考慮外層。

注意到 \(|T|\) 很小,且 \(f_{i,x}\) 的轉移只和 \(f_{i-1,y}\) 以及 \(f_{i,z}\) 相關。

考慮一層一層轉移,把所有的可能的 \(f_{i-1,x}\) 一維陣列看成一個狀態(自動機上的一個節點),把剛才的方程當作自動機轉移條件,那麼每次都將列舉所有狀態,根據轉移條件去暴力列舉。

具體而言,假設已經知道了所有 \(f_{i-1}\) 的狀態,考慮去列舉第 \(S_i\) 是什麼字元,然後去更新所有 \(f_i\) 的狀態。

外層 dp 設計完了,但是狀態數太多了,毛估估下大概有 \(O(n\times k^k)\) 個狀態,每次轉移是 \(O(k)\) 的,爆了。

定睛一看,你發現很多狀態是無用的啊,具體而言,你注意到 \(f_{i,j}\le f_{i,j+1}\le f_{i,j}+1\)

差分一手後陣列每一個值只能是 \(0/1\),那麼對於一個 \(f_i\) ,那麼有用的狀態為 \(O(n\times 2^k)\)。能接受。

還剩個問題就是不能出現 \(NOI\),考慮再加維度 \(0/1/2\) 表示已經匹配到了 \(NOI\)\(0/1/2\) 個值了,這一步不困難,可結合程式碼理解。

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

typedef long long ll;
const int N=17,M=(1<<15)+3,H=1e9+7;
int n,m,a[N],ans[N],to[3][3]={{1,0,0},{1,2,0},{1,0,3}},h[2][N],w[M][3],f[M][3],g[M][3];
char ch[N];
void Init()
{
	for(int S=0;S<(1<<m);S++)
	{
		for(int i=1;i<=m;i++)h[0][i]=h[0][i-1]+(S>>(i-1)&1);
		for(int k=0;k<=2;k++)
		{
			for(int i=1;i<=m;i++)
			{
				h[1][i]=max(h[1][i-1],h[0][i]);
				if(a[i]==k)h[1][i]=max(h[1][i],h[0][i-1]+1);
			}
			for(int i=1;i<=m;i++)if(h[1][i]>h[1][i-1])w[S][k]|=1<<(i-1);
		}
	}
}
void Add(int &x,int y){x=x+y>=H?x+y-H:x+y;}
int main()
{
	cin>>n>>m>>(ch+1);
	for(int i=1;i<=m;i++)a[i]=ch[i]=='N'?0:ch[i]=='O'?1:2;
	Init();f[0][0]=1;
	for(int i=1;i<=n;i++)
	{
		swap(f,g);memset(f,0,sizeof(f));
		for(int S=0;S<(1<<m);S++)for(int j=0;j<=2;j++)for(int k=0;k<=2;k++)
		    if(j<2||k<2)Add(f[w[S][k]][to[j][k]],g[S][j]);
	}
	for(int S=0;S<(1<<m);S++)for(int j=0;j<=2;j++)Add(ans[__builtin_popcount(S)],f[S][j]);
	for(int i=0;i<=m;i++)cout<<ans[i]<<endl; 
}

P8352 [SDOI/SXOI2022] 小 N 的獨立集

還是先設計內層狀態,設 \(g_{i,0/1}\) 表示子樹 \(i\) 內是不選/選子樹根的最大值。

外層的話直接設 \(f_{x,i,j}\) 表示子樹 \(x\) 內不選/選子樹根,最大值分別為 \(i,j\) 的方案數。

考慮樹形 \(dp\) 合併子樹 \(y\),有轉移:

\[f'_{x,i+max(k,l),j+k}\leftarrow f'_{x,i+max(k,l),j+k}+f_{x,i,j}\times f_{y,k,l} \]

複雜度 \(O(n^4 k^4)\),爆了,去掉零狀態依舊爆爆爆。

可是外層看著沒啥能最佳化了,於是乎回到內層重新考慮,設 \(g_{i,0/1}\) 表示在 \(i\) 子樹內不(0)/要(1)強制不選根節點的最大值,轉移方程和原本的類似,不細說。

這種 dp 的方式好在哪裡呢?\(0\le g_{i,0}-g_{i,1}\le k\),最多就是差個 \(i\) 的權值。

重新設狀態 \(f_{x,i,j}\) 表示子樹中 \(g_{u,0/1}\) 分別為 \(i+j,i\) 的方案數。合併子樹 \(y\) 有轉移:

\[f'_{x,i+k+l,max(j,l)-l}\leftarrow f'_{i+k+l,max(j,l)-l}+f_{x,i,j}\times f_{y,k,l} \]

複雜度 \(O(n^2k^4)\),把零狀態去掉跑得飛快。

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

typedef long long ll;
const ll N=1e3+3,H=1e9+7;
ll n,k,sz[N],f[N][N*5][6],tmp[N*5][6];
vector<ll>ve[N];
void Add(ll &x,ll y){x=(x+y)%H;}
void Dfs(int x,int fa)
{
	sz[x]=1;
	for(int i=1;i<=k;i++)f[x][0][i]=1;
	for(int y:ve[x])if(y!=fa)
	{
		Dfs(y,x);memset(tmp,0,sizeof(tmp));
		for(int i=0;i<=k*sz[x];i++)for(int j=0;j<=k;j++)if(f[x][i][j])
		for(int p=0;p<=k*sz[y];p++)for(int q=0;q<=k;q++)if(f[y][p][q])
		    Add(tmp[i+p+q][max(j,q)-q],f[x][i][j]*f[y][p][q]);
		sz[x]+=sz[y];memcpy(f[x],tmp,sizeof(f[x]));
	}
}
int main()
{
	cin>>n>>k;
	for(int i=1,x,y;i<n;i++)
	    cin>>x>>y,ve[x].push_back(y),ve[y].push_back(x);
	Dfs(1,0);
	for(ll i=1;i<=n*k;i++)
	{
		ll ans=0;
		for(int j=0;j<=min(i,k);j++)Add(ans,f[1][i-j][j]);
		cout<<ans<<endl;
	}
}

P8497 [NOI2022] 移除石子

足夠困難的題目,考慮內層dp 設計即對於固定狀態的設計。

為了不變數混用,將題面中的 \(k\) 設成 \(m\),接下來的 \(k\) 都是變數。

最特殊的情況,\(m=0\)

注意到一操作肯定是留來收尾的,主要的重點在於二操作。

\(a_i\) 表示在當前固定狀態下第 \(i\) 堆的棋子數量。

考慮類似掃描線一樣進行 dp,設 \(f_{i,x,y}\) 表示當前考慮了所有二操作左端點小於 \(i\) 的操作,欽定了有 \(j\) 個二操作已經延展到了 \(i\),可以選擇繼續向右延展或在 \(i\) 不動了,有 \(k\) 個二操作一定要延展到 \(i+1\) 是否可行。

轉移考慮列舉有多少個二操作左端點在 \(i\),設其有 \(st\) 個。

那麼能繼續轉移當且僅當 \(a_i-j-k-st\) 大於 \(0\) 且不為 \(1\),然後再去列舉 \(nj\) 表示在第 \(i+1\) 堆為右端點的操作二數量,那麼需滿足 \(k\le nj\le k+j\),那麼 \(f_{i+1,nj,st}\) 也可行。

最後只需判斷 \(f_{n+1,0,0}\) 是否可行即可。

注意到二操作操作的長度不會超過 \(5\),長度超過 \(5\) 的操作可以劃分成若干短的操作拼接。

那麼 \(j\le 6,k\le 3\),也就保證了我們列舉量的上界。

考慮加上 \(m\) 的限制,這個恰好放 \(m\) 個石頭是煩的,大膽猜測新增小於 \(m\) 個球有解,那麼新增 \(m\) 個球也有解,隨便手玩一下發現只有兩個反例,一個是全零,一個是 \(n=3\) 且全域性為 \(0\)

把這兩種情況先判一手。

那麼設 \(g_{i,j,k}\) 表示使得 \(f_{i,j,k}=1\) 至少需要加的石子數,還是去列舉 \(st,nj\),設 \(v=a_i-j-k-st\),如果 \(v<0\) 則需加 \(x=-v\) 個,否則需要新增 \(x=[v=1]\) 個,用 \(g_{i,j,k}+x\) 去更新 \(g_{i+1,nj,st}\) 即可。

最後檢查 \(g_{n+1,0,0}\le k\) 即可。

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

typedef long long ll;
const int N=1e3+3,H=1e9+7;
void Min(int &x,int y){x=x<y?x:y;}
int n,m,ans,a[N],l[N],r[N],f[N][3][3];
bool Chk()
{
    if(m==1&&(count(a+1,a+n+1,0)==n||(n==3&&a[1]==1&&a[2]==1&&a[3]==1)))return 0;
	memset(f,0x3f,sizeof(f));f[1][0][0]=0;
	for(int i=1;i<=n;i++)for(int j=0;j<=2;j++)for(int k=0;k<=2;k++)if(f[i][j][k]<=m)
	{
        for(int st=0;st<=2;st++)
		{
            int v=a[i]-j-k-st,x=v<0?-v:v==1;
            for(int nj=k;nj<=min(2,j+k);nj++)Min(f[i+1][nj][st],f[i][j][k]+x);
        }
    }
	return f[n+1][0][0]<=m;
}
void Dfs(int x)
{
	if(x==n+1){ans+=Chk();return;}
	for(int i=l[x];i<=r[x];i++)a[x]=i,Dfs(x+1);
}
void Solve()
{
	cin>>n>>m;ans=0;
	for(int i=1;i<=n;i++)cin>>l[i]>>r[i];
	Dfs(1);cout<<ans<<endl;
}
int main()
{
	int T;cin>>T;
	while(T--)Solve();
}

此時可以獲得 \(40\) 分的高分了,在當年你已經吊打了至少百分之九十的選手了。

等等,為什麼你的 \(j,k\) 列舉量只有 \(2\) 呢?

注意到這題是 dp 套 dp 的模型,這種常數的列舉變化往往在外層的最佳化是顯著的,所以在設計出內層 dp 後一定要多看看有沒有什麼最佳化的空間?

怎麼去最佳化的呢?

考慮拿著一開始未最佳化的程式碼一點一點的去減少這種列舉的上界然後去對拍,拍個很多組沒鍋大概就是對的了。

其實你也可以去手動大力分討去減少列舉上界,可是隻要出現一步的粗心或者漏算你就爆了。

然後考慮設計外層 dp。

首先你能感受到 \(a_i\ge 8\) 之後它的轉移大概是本質相同的,你考慮你預處理一下 \(a_i\) 在不同取值能走到的狀態,實際情況下 \(a_i\ge 6\) 之後轉移就是一樣的了。

注意到 \(f_i\) 只有九種不同的狀態,考慮把他們壓一手,看似 \(g_{i,j,k}\)\(0\sim 101\)\(102\) 種取值,所以看上去要爆了,但是實際上去寫個搜尋預處理狀態之後,你會發現只有 \(8765\) 個本質不同的狀態,非常的牛牛啊。然後你隨便寫寫就透過本題了。

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

typedef long long ll;
const int N=1e3+3,M=8765,H=1e9+7;
ll n,m,tot,l[N],r[N],tr[M][7],f[M],g[M];
map<vector<ll>,int>mp;
void Add(ll &x,ll y){x=x+y>=H?x+y-H:x+y;}
void Min(ll &x,ll y){x=x<y?x:y;}
int Dfs(vector<ll> cur)
{
	if(mp.count(cur))return mp[cur];
	int id=mp[cur]=tot++;
	for(int t=0;t<=6;t++)
	{
        vector<ll>now(9,101);
        for(int j=0;j<=2;j++)for(int k=0;k<=2;k++)if(cur[j*3+k]!=101)
		{
            for(int st=0;st<=2;st++)
			{
                int v=t-j-k-st,x=v<0?-v:v==1;
                for(int nj=k;nj<=min(2,j+k);nj++)Min(now[nj*3+st],cur[j*3+k]+x);
            }
        }
        tr[id][t]=Dfs(now);
    }
    return id;
}
ll Fix()
{
	if(m!=1)return 0;
	return H-(count(l+1,l+n+1,0)==n)-(n==3&&l[1]<=1&&1<=r[1]&&l[2]<=1&&1<=r[2]&&l[3]<=1&&1<=r[3]);
}
void Solve()
{
	cin>>n>>m;memset(f,0,sizeof(f));f[0]=1;
	for(int i=1;i<=n;i++)cin>>l[i]>>r[i];
	for(int i=1;i<=n;i++)
	{
		memcpy(g,f,sizeof(g));memset(f,0,sizeof(f));
        for(int j=0;j<tot;j++)if(g[j])for(int t=0;t<=6;t++)
		{
			ll x=0;
            if(t<6)x=l[i]<=t&&t<=r[i];
            else if(r[i]>=6)x=r[i]-max(6ll,l[i])+1;
			Add(f[tr[j][t]],g[j]*x%H);
        }
    }
	ll ans=Fix();
	for(auto it:mp)if(it.first[0]<=m)Add(ans,f[it.second]);
	cout<<ans<<endl;
}
int main()
{
	vector<ll>ve(9,101);ve[0]=0;Dfs(ve);
	int T;cin>>T;
	while(T--)Solve();
}

P5279 [ZJOI2019] 麻將

你知道的,我一直都是吉老師的粉絲,至於九條可憐,我祝他一切安好。

逆天題,有興趣自行了解。

完結撒花