1. 程式人生 > 實用技巧 >「演算法筆記」狀壓DP

「演算法筆記」狀壓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天惹