「演算法筆記」狀壓DP
一、關於狀壓 dp
為了規避不確定性,我們將需要列舉的東西放入狀態。當不確定性太多的時候,我們就需要將它們壓進較少的維數內。
常見的狀態:
- 天生二進位制(開關、選與不選、是否出現……)
- 爆搜出狀態,給它們編號
1.狀態跟某一個資訊集合內的每一條都有關。(如 dp 套 dp)
2.若干條精簡而相互獨立的資訊壓在一起處理。 (如每個數字是否出現)
在使用狀壓dp的題目當中,往往能一眼看到一些小資料範圍的量,切人點明確。而有些題,這樣的量並不明顯,需要更深人地分析題目性質才能找到。
二、預備知識
1.位運算
二進位制數 S 從低(0-based)到高第 i 位的值:(S>>i)&1
二進位制數 S 從低(0-based)到高第 i 位的值變為 1:S|(1<<i)
二進位制數 S 從低(0-based)到高第 i 位的值變為 0:S&(~(1<<i)) 或 ~((~S)|(1<<i))
二進位制數 S 從低(0-based)到高第 i 位的值取反:S^(1<<i)
2.列舉子集
for(int i=S;i!=0;i=(i-1)&S)
複雜度:
(2n-1) 的子集:2n
(2n-1) 的子集的子集和:3n
(2n-1) 的子集的子集的子集和:4n
……
三、例題
1. TSP(旅行商問題)
題目大意:一個 n 個點的帶權的有向圖,求一條路徑,使得這條路徑經過每個點恰好一次,並且路徑上邊的權值和最小。n≤16。
Solution:
設 dp[i][S] 表示目前走到節點 i,已經經過節點的集合為 S 的最小邊權。
列舉所有的邊 (i,j)∈E,令 w(i,j) 表示它的邊權。
if j∉S(因為要滿足經過每個點恰好一次,即不能重複經過,所以要滿足 j 沒有被經過),dp[i][S]+w(i,j)→dp[j][S∪{j}]
若 S&(1<<j) 的值為 0,則 j 不在 S 中;若 S&(1<<j) 的值不為 0,則 j 在 S 中。也可以判斷 (S>>j)&1 是否為 1。
初始化一個節點的路徑 dp[i][1<<i]=0(節點編號為 0~n-1)或 dp[i][1<<(i-1)]=0(節點編號為 1~n),其他的設為 ∞。
把 j 加入到 S:S +/|/^ (1<<j)。因為已經確認了 S 的這一位是 0,則 +,|,^ 都是可以的。
時間複雜度:O(2n·n2)
2. [USACO06NOV] 玉米田 Corn Fields
題目大意:有一個 N*M 的網格圖,已知若干個格子不能選、不能有相鄰的格子被選。在不限制所選格子總數的前提下,求方案數。N,M≤12。
Solution:
由於限制在相鄰的格子上,若按行或按列 dp,就不需要維護之前已經決策過的格子的狀態,而是維護一行或一列的格子的狀態。
令 dp[i][j] 表示目前 dp 到第 i 行,這一行的狀態是 j,所有 1~i 行內的格子被選的方案數。
列舉 i+1 行的狀態 k,判斷 k 與 j 是否有衝突(是否出現某個格子的上面一個格子被選了的情況,即是否有相鄰的格子被選),只需判斷 j&k 是否為 1(j 的二進位制數與 k 的二進位制數是否有在相同的位置同時等於 1)。
dp[i][j]→dp[i+1][k]
預處理出沒有相鄰的兩列同時被選的狀態。在一行 12 個格子的網格圖中,沒有相鄰的兩列同時被選的合法的狀態大概有 377 個。
int ans=0; for(int i=0;i<(1<<12);i++){ bool flag=1; for(int j=1;j<=11;j++) if(((i>>(j-1))&1)&&((i>>j)&1)) flag=0; if(flag) ++ans; } printf("%lld\n",ans); //Output:377
所以通過預處理,可以通過此題。
code:
#include<bits/stdc++.h> #define int long long using namespace std; const int N=13,M=(1<<12)+5,mod=1e9; int n,m,a[N][N],p[N],f[N][M],ans; bool v[M]; signed main(){ scanf("%lld%lld",&m,&n),f[0][0]=1; for(int i=1;i<=m;i++) for(int j=1;j<=n;j++){ scanf("%lld",&a[i][j]); p[i]=(p[i]<<1)+a[i][j]; //記錄第 i 行土地的狀態 } for(int i=0;i<(1<<n);i++){ v[i]=1; for(int j=1;j<n;j++) if(((i>>(j-1))&1)&&((i>>j)&1)) v[i]=0; } //記錄狀態 i 是否合法(預處理出沒有相鄰的兩列同時被選的狀態) for(int i=1;i<=m;i++) //列舉行 for(int j=0;j<(1<<n);j++) //列舉這行的狀態 if(v[j]&&(j&p[i])==j) //判斷狀態 j 是否合法以及是否在肥沃的土地上 for(int k=0;k<(1<<n);k++) //列舉上一行的狀態 if(!(k&j)) f[i][j]=(f[i][j]+f[i-1][k])%mod; for(int i=0;i<(1<<n);i++) ans=(ans+f[m][i])%mod; //前 m 行的方案數累加 printf("%lld\n",ans); return 0; }
3.TopCoder RainbowGraph
題目大意:給出一張 N 個點 M 條邊的無向圖。每個點有一個顏色。一條路徑是合法的當且僅當它按順序經過的節點的顏色序列寫下來,同種顏色出現的位置是連續的。計算合法的經過每個節點恰好一次的路徑條數。N≤100,Color≤10(顏色數),對於任意的 i,Cnt(v|Colorv=i)≤10(每種顏色的節點數)。
Solution:
既然同種顏色出現的位置是連續的。我們不妨將問題劃分成兩個小問題考慮:
- 對於顏色 c,從 i 出發到 j 結束,同時遍歷完所有該顏色節點的路徑條數是多少。
- 對於全域性,有多少種安排顏色的方法、及安排顏色之間“介面”的方法。
第一個問題:將同種顏色內的串起來。
要求:(1)每個點恰好出現一次(2)相鄰兩點之間有邊相連
令 dp[i][j][S] 表示從 i 號點出發,在 j 號點結束,經過的節點集合為 S 的路徑條數。
for(int i=0;i<k;i++) //k:當前顏色的節點個數 dp[i][i][1<<i]=1; //考慮單點路徑的情況 for(int S=1;S<(1<<k);S++) //列舉狀態 for(int i=0;i<k;i++) //列舉起點 for(int j=0;j<k;j++) //列舉終點 if(dp[i][j][S]) //判斷當前的 dp 狀態是否合法 for(int p=0;p<k;p++) //列舉下一個點 if(!((S>>p)&1)&&G[j][p]) //p 在狀態中沒有出現過(之前沒有經過這個點)並且之前路徑的終點 j 能夠到達下一個點 p dp[i][p][S|(1<<p)]+=dp[i][j][S];
注:這裡所有的節點編號,為了方便體現,直接寫成了 i 之類的。事實上,需要開一個數組維護,把全域性當中的顏色為當前顏色的節點轉化到 0~k 的範圍內。
第二個問題:將每種顏色串起來。
要求:(1)每種顏色恰好出現一次(則需記錄顏色狀態 2C)(2)相鄰顏色的首尾節點間有邊相連(則需記錄終點 N)
對於一種顏色 c,我們使用 f[c][i][j] 來代表之前的 dp[全域性中編號為 i 的點][全域性中編號為 j 的點][(1<<k)-1]。
令 dp[i][S] 表示以節點 i 結尾,已經經過的顏色集合為 S 的總方案數。
for(int c=0;c<C;c++) //列舉顏色 for(int i=0;i<k[c];i++) //列舉起點 k[c]:顏色為 c 的節點個數 for(int j=0;j<k[c];j++) //列舉終點 dp[id[j]][1<<c]+=f[c][i][j]; for(int S=1;S<(1<<C);S++) for(int i=0;i<N;i++) if(dp[i][S]) for(int c=0;c<C;c++) //列舉下一個顏色 C:顏色數 if(!((S>>c)&1)) //這個顏色在之前的狀態中沒有出現過 for(int j=0;j<k[c];j++) //列舉下一個顏色的起點 if(G[i][id[j]]) //當前的終點與下一個起點有邊可以到達 for(int q=0;q<k[c];q++) //列舉下一個顏色的終點 dp[id[q]][S|(1<<c)]+=dp[i][S]*f[c][j][q];
4.TopCoder CheeseRolling
題目大意:N 個人打淘汰賽。N 是 2 的冪次。給出兩兩單挑的勝負情況。一共有 N! 種賽程安排方案,對於每個人,你需要輸出有多少種方案使他最終獲勝。n≤16。
Solution:
令 dp[i][S] 表示編號為 i 的選手是選手集合 S 中的優勝者的方案數。
那麼最後每個點的答案就是 dp[i][(1<<N)-1]
列舉 i 能夠戰勝的對手 j,然後將集合 S 等分為兩個一個包含 i 一個包含 j 的集合。
for(int i=0;i<N;i++) dp[i][1<<i]=1; for(int S=1;S<(1<<n);S++) //列舉集合 S if(valid(S)) //只處理大小為 2 的冪次的集合 for(int i=0;i<N;i++) //列舉集合中的勝出者 i if((S>>i)&1) //集合 S 包含 i for(int j=0;j<N;j++) //列舉 i 所擊敗的對手 j if(i!=j&&G[i][j]) //i 和 j 不是同一個人並且 i 能夠擊敗 j for(int k=(S-1)&S;k!=0;k=(k-1)&S) //列舉子集 dp[i][S]+=2LL*dp[i][k]*dp[j][S^k];
複雜度為 O(3n·n2),偏高,需要優化。
- 優化 1:valid 這部分有很多冗餘狀態,可以預處理出來有效的部分。
- 優化 2:把子集改成 dfs 精確列舉等分子集。
5. AtCoder 058E Iroha and Haiku
題目大意:
一個有 N 個元素且每個元素的取值都在 1~10 範圍內的序列被稱為好的當且僅當存在下標 x,y,z,w(0≤x<y<z<w≤N)
- ax+ax+1+...+ay-1=X
- ay+ay+1+...+az-1=Y
- az+az+1+...+aw-1=Z
計數。並對 109+7 取模。3≤N≤40,1≤X≤5,1≤Y≤7,1≤Z≤5。
Solution:
做法 1:當你不知道如何優雅地設計狀態/不知道狀態的規模有多大時,不妨暴力一點。
因為 X≤5,Y≤7,Z≤5,所以 X+Y+Z≤5+7+5,即 X+Y+Z≤17,ax+ax+1+...+ay-1+ay+ay+1+...+az-1+az+az+1+...+aw-1≤17。
只需維護取值 1~10,和≤17 的序列。
#include<bits/stdc++.h> #define int long long using namespace std; int cnt; void dfs(int x){ if(x>17) return ; ++cnt; for(int i=1;i<=10;i++) dfs(x+i); } signed main(){ dfs(0),printf("%lld\n",cnt); return 0; } //Output:130624
大約 105個狀態,做法就顯而易見了。
一個 dp 序列當中的元素,每次只保留最靠後的值不超過 17 的元素佇列。
dp[i][S][0/1] 表示當前 dp 到序列中第 i 個元素,佇列的狀態為 S,是否出現過題中所述條件。
一些優化:
- 優化 1:每個狀態是否滿足題中條件可預處理。
- 優化 2:每個狀態遇到了 1~10 後轉移到什麼狀態可預處理。(需要一個作用在 vector上的 map 或是 set、二分等)
- 優化 3:狀態當中的第三維可以通過求補集而去掉。
不難觀察得到,演算法的複雜度瓶頸主要是由狀態的 vector 形態帶來的。
做法 2:思路和前面類似,只不過考慮換個形式表示“和不超過 17”這個狀態。
如果我們將這“X+Y+Z”點貢獻展開看成一條長度為 X+Y+Z 的格子,一個數可以佔領 1~10 個格子。那麼我們關心的即為位置在 X、X+Y、X+Y+Z 的三個格子是否是一個數所佔領格子的末尾。我們考慮將一個數末尾標為 1,其他格子標為 0。
例子:X=Y=Z=2,字尾狀態:
2+2+2 | 010101 |
1+1+2+2 | 110101 |
2+1+1+2 | 011101 |
1+2+2+1 | 101011 |
code:
//開始的格子為 1 的寫法 #include<bits/stdc++.h> #define int long long using namespace std; const int N=50,M=(1<<17)+5,mod=1e9+7; int n,x,y,z,st,dp[N][M],sum=1,k,ans; signed main(){ scanf("%lld%lld%lld%lld",&n,&x,&y,&z); st=((1<<(x+y+z-1)))|(1<<(y+z-1))|(1<<(z-1)); //目標從後往前數的第 X+Y+Z、Y+Z、Z 的位置為 1 dp[0][0]=1; for(int i=1;i<=n;i++){ sum=sum*10%mod; //計算總方案數(n 位數字,就有 10^n 種方案) for(int S=0;S<(1<<(x+y+z));S++) //列舉狀態 if(dp[i-1][S]&&(S&st)!=st) for(int c=1;c<=10;c++){ //列舉第 i 位新增的數字 k=((S<<c)|(1<<(c-1)))&((1<<(x+y+z))-1); //(S<<c)|(1<<(c-1)):比如 4 的二進位制數為 1000,4 後面加上 2 為 4 2,用 100010 表示,即將 4 的二進位制向前兩位再加上 10。 //...&((1<<(x+y+z))-1:防止溢位 dp[i][k]=(dp[i][k]+dp[i-1][S])%mod; } } for(int S=0;S<(1<<(x+y+z));S++) if((S&st)!=st) ans=(ans+dp[n][S])%mod; printf("%lld\n",(sum-ans)%mod); //總方案數減不符合條件的方案 return 0; }
6. AtCoder XOR Tree
題目大意:給出一棵 N 個節點的樹,每條樹邊有一個邊權。.每次操作你可以將一條路徑上的邊權異或上某個值。問最少進行多少次操作可以達到邊權全 0。N≤105,0≤邊權≤15。
Solution:
首先定義一個點的點權為與其相鄰的所有邊的邊權異或和。
容易證明邊權全為 0 等價於點權全為0。
- 邊權為 0→點權為 0:根據定義顯然
- 點權為 0→邊權為 0:來自樹的性質,總是存在葉子結點。葉子結點上的點權為 0 可推出一條邊權為 0。刪去後重複這一過程。
每次操作就可以看成是將任意兩個點異或上同一個數。
- 如果是兩個相同的數,一次操作就可以直接將這倆消去。
- 如果是兩個不同的數 a,b,那麼一次操作可以消去一個,剩下一個 a^b。
思考一下要不要考慮同時異或一個不為 a 也不為 b 的數 x。
因為一共只有 16 種取值,我們先將重複的直接消去。剩下的資訊只有 0~15 每個數字是否出現過,可以壓成一個 216 的狀態。
每次列舉一下要操作哪兩個數。
- x^y∉S:轉移到的狀態就是 S∪x^y,即 S^(1<<(x^y))。
- x^y∈S:此次操作之後就會產生一對相等的 pair,我們直接在此時將它們一併消去,增加一次操作,並將 x^y 從 S 中去掉即可。
7. Codeforces 16E Fish
題目大意:有 n 條魚,剛開始它們都自由地生活在水裡。接下來每一時刻會有兩條魚相遇。所有可能的“魚對”之間都是等概率出現這一事件的。魚 i 和魚 j 相遇後,i 吃掉 j 的概率為 a[i][j]。 反之 j 吃掉 i 的概率為 1-a[i][j]。求每條魚 i 活到最後的概率。N≤19。
Solution:
令 dp[S] 表示剩下集合中的魚狀態為 S 的概率。
初始化:dp[2n-1]=1(剛開始全部的魚都存活)
對於任意 1≤i≤n,dp[2i] 為魚 i 活到最後的概率。
按從大到小列舉狀態 S(按時刻進行),設 c 為 S 中 1 的個數,則有 C(c,2) 個可能的“魚對”。列舉第一條被選的魚 i 以及第二條被選的魚 j,這兩條魚相遇的概率為 1/C(c,2)。
- i 吃掉 j:1/C(c,2)·dp[S]·a[i][j]→dp[S-2j]
- j 吃掉 i:1/C(c,2)·dp[S]·a[j][i]→dp[S-2i]
最後輸出所有的 dp[2i] 即可。
code:C(c,2)=c*(c-1)/2
#include<bits/stdc++.h> #define int long long using namespace std; const int N=20,M=(1<<18)+5; int n,x,cnt; double a[N][N],dp[M]; signed main(){ scanf("%lld",&n),dp[(1<<n)-1]=1; for(int i=0;i<n;i++) for(int j=0;j<n;j++) scanf("%lf",&a[i][j]); for(int S=(1<<n)-1;S>=1;S--){ //列舉狀態 cnt=0,x=S; while(x) cnt+=x&1,x>>=1; //計算 S 中 1 的個數 for(int i=0;i<n;i++) if(S&(1<<i)) //列舉第一條魚 for(int j=i+1;j<n;j++) if(S&(1<<j)){ //列舉第二條魚 dp[S-(1<<j)]+=dp[S]*a[i][j]/(1.0*cnt*(cnt-1)/2); //i 吃掉 j dp[S-(1<<i)]+=dp[S]*a[j][i]/(1.0*cnt*(cnt-1)/2); //j 吃掉 i } } for(int i=0;i<n;i++) printf("%.6lf%c",dp[1<<i],i==n-1?'\n':' '); return 0; }
8. Codeforces 165ECompatible Numbers
題目大意:給出一個長度為 N 的序列,我們稱兩個數 x,y 是相容的,當且僅當 x&y=0。對於序列中的每個數,你需要求出任意一個序列中與它相容的數,或是宣告不存在。N≤106,0<a[i]≤4·106。
9. Codeforces 743E Vladik and cards
題目大意:給出一個長度為 N 個序列,序列中的元素取值為 1~8。求一個最長的子序列滿足:
- 每種取值出現在子序列中的位置連續。
- 每種取值出現的次數相差不超過 1。N≤1000。
10. Codeforces 599ESandy and Nuts
題目大意:有一棵 N 個點的樹, 1 號點為根節點。現在你知道 M 條資訊:
- 某些樹上的邊 (u,v)
- 某兩個點的LCA (a,b,c)
現在你需要數滿足條件的樹形態數。N≤13,M≤100。
後面鴿了,明天補!記得不要提醒我,我不要做鴿子 QAQ
更正:我就是鴿子(已經寫了3天惹