前言
狀態壓縮是什么呢?
如果你還不知道,那么請看下面的例子。
路旁有一排100個路燈,他們其中有亮的,也有滅的,請問你該如何記錄他們的狀態呢?
有人會說,拿數組記錄不就行了嗎?
但是如果不只有100個路燈呢?有100000000個路燈該怎么記錄呢?
這時,用數組就會超內存,怎么辦呢?
其實,用一個二進制數就可以表示了。表示方法如下:
第i位為1表示第i個路燈是亮的,0表示是滅的,這樣就能輕松記錄了。
但得到了這個二進制數后,我們怎么快速知道每個燈的狀態呢?
出來吧,位運算!
下面列舉了一些常見的二進制位的變換操作。
功能 | 示例 | 位運算
去掉最后一位 | (101101->10110) | x shr 1
在最后加一個0 | (101101->1011010) | x shl 1
在最后加一個1 | (101101->1011011) | x shl 1+1
把最后一位變成1 | (101100->101101) | x or 1
把最后一位變成0 | (101101->101100) | x or 1-1
最后一位取反 | (101101->101100) | x xor 1
把右數第k位變成1 | (101001->101101,k=3) | x or (1 shl (k-1))
把右數第k位變成0 | (101101->101001,k=3) | x and not(1 shl (k-1))
右數第k位取反 | (101001->101101,k=3) | x xor (1 shl (k-1))
取末三位 | (1101101->101) | x and 7
取末k位 | (1101101->1101,k=5) | x and (1 shl k-1)
取右數第k位 | (1101101->1,k=4) | x shr (k-1) and 1
把末k位變成1 | (101001->101111,k=4) | x or (1 shl k-1)
末k位取反 | (101001->100110,k=4) | x xor (1 shl k-1)
把右邊連續的1變成0 | (100101111->100100000) | x and (x+1)
把右起第一個0變成1 | (100101111->100111111) | x or (x+1)
把右邊連續的0變成1 | (11011000->11011111) | x or (x-1)
取右邊連續的1 | (100101111->1111) | (x xor (x+1)) shr 1
所以大家可以發現,狀態壓縮是一種在計算機里很重要的思想。
不只是dp,很多搜索,暴力用上它也會簡單很多。
下面我找到了三道狀態壓縮dp入門題,掌握了他們,狀態壓縮就能很輕松理解了。
互不侵犯
題意:https://www.luogu.org/problemnew/show/P1896
概述:
在N×N的棋盤里面放K個國王,使他們互不攻擊,共有多少種擺放方案。國王能攻擊到它上下左右,以及左上左下右上右下八個方向上附近的各一個格子,共8個格子。
題解:
首先,看到這一題,就知道如果不是搜索,就是DP。當然搜索是過不了的,所以就應該嘗試想出一個DP的解法。
DP的前提之一當然是要找出一個可以互相遞推的狀態。顯然,目前已使用的國王個數當然必須是狀態中的一個部分,因為這是一個限制條件。那么除此之外另外的部分是什么呢?
我們考慮到每行每列之間都有互相的約束關系。因此,我們可以用行和列作為另一個狀態的部分(矩陣狀壓DP常用行作為狀態,一下的論述中也用行作為狀態)。
又看到數據范圍: 1 <=N <=9。這里我們就可以用一個新的方法表示行和列的狀態:數字。考慮任何一個十進制數都可以轉化成一個二進制數,而一行的狀態就可以表示成這樣——例如:
10101010(2)
就表示:這一行的第一個格子沒有國王,第二個格子放了國王,第三個格子沒有放國王,第四個格子放了國王。而這個二進制下的數就可以轉化成十進制:
1010(10)
於是,我們的三個狀態就有了:第幾行(用i表示)、此行放什么狀態(用j表示)、包括這一行已經使用了的國王數(用s表示)。
考慮狀態轉移方程。我們預先處理出每一個狀態(sit[x])其中包含二進制下1的個數,及此狀態下這一行放的國王個數(gs[x]),於是就有:
f[i][j][s]=sum(f[i-1][k][s-gs[j]])f[i][j][s]=sum(f[i−1][k][s−gs[j]]),f[i][j][s]f[i][j][s]就表示在只考慮前i行時,在前i行(包括第i行)有且僅有s個國王,且第i行國王的情況是編號為j的狀態時情況的總數。而k就代表第i-1行的國王情況的狀態編號
其中k在1到n之間,j與k都表示狀態的編號,且k與j必須滿足兩行之間國王要滿足的關系。(對於這一點的處理我們待會兒再說)
這個狀態轉移方程也十分好理解。其實就是上一行所有能夠與這一行要使用的狀態切合的狀態都計入狀態統計的加和當中。其中i、j、s、k都要枚舉。
再考慮國王之間的關系該如何處理呢?在同一行國王之間的關系我們可以直接在預處理狀態時舍去那些不符合題意的狀態,而相鄰行之間的關系我們就可以用到一個高端的東西:位運算。由於狀態已經用數字表示了,因此我們可以用與(∧)運算來判斷兩個狀態在同一個或者相鄰位置是否都有國王——如果:
sit[j]sit[j]&sit[k]sit[k](及上下有重復的king)
(sit[j]<<1)(sit[j]<<1)&sit[k]sit[k](及左上右下有重復king)
sit[j]sit[j]&(sit[k]<<1)(sit[k]<<1)(及右上左下有重復king)
這樣就可以處理掉那些不符合題意的狀態了。
總結一下。其實狀壓DP不過就是將一個狀態轉化成一個數,然后用位運算進行狀態的處理。理解了這一點,其實就跟普通的DP沒有什么兩樣了。
代碼:
#include<iostream> #include<cstdio> using namespace std; int n,k; long long dp[10][15000][80]; long long state[1000005],king[1000005] ; long long ans,sum; inline void init() { int tot=(1<<n)-1; for(int i=0;i<=tot;i++) if(!((i<<1)&i)) { state[++ans]=i; int t=i; while(t) { king[ans]+=t%2; t>>=1; } } } int main() { scanf("%d%d",&n,&k); init(); for(int i=1;i<=ans;i++) if(king[i]<=k) dp[1][i][king[i]]=1; for(int i=2;i<=n;i++) for(int j=1;j<=ans;j++) for(int p=1;p<=ans;p++) { if(state[j] & state[p]) continue; if(state[j] & (state[p]<<1)) continue; if((state[j]<<1) & state[p]) continue; for(int s=1;s<=k;s++) { if(king[j]+s>k) continue; dp[i][j][king[j]+s]+=dp[i-1][p][s]; } } for(int i=1;i<=n;i++) for(int j=1;j<=ans;j++) sum+=dp[i][j][k]; cout<<sum; return 0; }
玉米田Corn Fields
題意:
https://www.luogu.org/problemnew/show/P1879
題解:
若二進制下第i位有賦值1,則一行的第i列有放牛
那么f[i][j]表示在前i行中(包括i)在j個狀態下的最大方案數
易得f[i][j]=(f[i][j]+f[i-1][k])mod p(p=10^9,j是第i行的狀態,k是第i-1行的狀態)
所以我們還要再預處理一下,g[i]表示第i個狀態是否存在,判斷條件是
g[i]= !(i!(i&(i>>1))!(i(i>>1))
目標狀態:f[n][i]全部相加
代碼:
#include<cstdio> #include<iostream> #include<cstring> #include<cmath> #include<string> #include<algorithm> using namespace std; const int mod=100000000; int n,m; int a[15][15]; int q[15]; int f[15][1<<20]; bool pd[1<<20]; void init() { scanf("%d%d",&m,&n); for(int i=1;i<=m;i++) for(int j=1;j<=n;j++) { scanf("%d",&a[i][j]); q[i]=(q[i]<<1)+a[i][j]; } for(int i=0;i<(1<<n);i++) pd[i]=(!(i&(i<<1)))&&(!(i&(i>>1))); } void dp() { f[0][0]=1; for(int i=1;i<=m;i++) { for(int j=0;j<(1<<n);j++) { if(pd[j]&&((j&q[i])==j)) { for(int k=0;k<(1<<n);k++) { if((k&j)==0) f[i][j]=(f[i][j]+f[i-1][k])%mod; } } } } } int main() { init(); dp(); int ans=0; for(int i=0;i<(1<<n);i++) ans=(ans+f[m][i])%mod; printf("%d\n",ans); return 0; }
炮兵陣地
題意:
https://www.luogu.org/problemnew/show/P2704
題解:(轉自https://sshoj.blog.luogu.org/solution-p2704)
這道題是一道狀壓 dp 的特別毒瘤的基礎題(雖然我打了整整一個早上),但是因為每一個炮兵都會影響到之后的兩行的放置,所以用狀壓去壓兩行,按行處理每一行的情況即可。每一行放置的時候也很簡單,只需考慮這個位置前兩行有沒有放置炮兵以及這個位置是不是山丘即可。
那么首先,dp 方程可以很快推出來,dp[L][S][i]表示當前狀態是 S,上一行的狀態是 L,當前考慮到了第 i 行:
dp[L][S][i]=max(dp[L][S][i],dp[FL][L][i-1]+Sum[S]); 這里 FL 表示上上行的狀態,Sum[S] 表示當前狀態 S 里面包含幾個 1。
那么有了這個 dp 方程后,就可以愉快的遞推了,不過這道題有幾個細節需要注意一下:
1.判斷每個位置是不是山丘
這個很好解決,只要把每一行的輸入都轉成一個二進制數(平原是 0,山丘是1),然后直接跟待判斷的狀態做一次位運算即可,就是 S&a[i],如果位運算結果不是零,說明有些位置放在了山丘上,也就是說當前狀態不合法。
2.判斷每個狀態有沒有兩個炮兵左右距離在兩格之內
這個需要動腦想一下,我們發現一個神奇的結論,如果把表示當前狀態的二進制數位運算左移一位,那么用這個結果與原狀態做一次位運算與操作,如果結果不是 0,那么就一定存在兩個炮兵左右距離在一格之內。同理,左移兩位就可以判斷左右距離在兩格之內。這個過程也就是 S&(S<<1),S&(S<<2)。
3.判斷每一列之前兩行有沒有炮兵
這個就直接用當前狀態分別與之前的兩行即可,就是 S&L,S&FL,如果與操作結果不為零,說明有若干列前兩行有炮兵,也就是說當前狀態不合法。
最后說一句,一定要用滾動數組(因為只用到每一行和前兩行,所以只用滾動三行),否則會 MLE 0 (我當初就是這么慘)。。。
代碼:
#include<iostream> #include<cstring> #include<cstdio> #include<cmath> #include<algorithm> using namespace std; int n,m,ans; char iin; int f[(1<<10)][(1<<10)][3],a[150],sum[(1<<10)]; int get(int s) { int cnt=0; while(s) { if(s&1) cnt++; s>>=1; } return cnt; } int Max(int a,int b) { if(a>=b) return a; return b; } int main() { scanf("%d%d",&n,&m); for(int i=0;i<n;i++) for(int j=0;j<m;j++) { cin>>iin;; a[i]<<=1; a[i]+=(iin=='H'?1:0); } for(int i=0;i<(1<<m);i++) sum[i]=get(i); for(int i=0;i<(1<<m);i++) for(int j=0;j<(1<<m);j++) if(!(i&j || i&(i<<1) || i&(i<<2) || j&(j<<1) || j&(j<<2) || i&a[0] || j&a[1])) f[i][j][1]=sum[i]+sum[j]; for(int k=2;k<n;k++) for(int i=0;i<(1<<m);i++) { if(i&a[k-1] || i&(i<<1) || i&(i<<2)) continue; for(int j=0;j<(1<<m);j++) { if(i&j || j&a[k] || j&(j<<1) || j&(j<<2)) continue; for(int l=0;l<(1<<m);l++) { if(l&i || l&j || l&a[k-2] || l&(l<<1) || l&(l<<2)) continue; { f[i][j][k%3]=Max(f[i][j][k%3],f[l][i][(k-1)%3]+sum[j]); } } } } for(int i=0;i<(1<<m);i++) for(int j=0;j<(1<<m);j++) ans=Max(ans,f[i][j][(n-1)%3]); printf("%d",ans); return 0; }
總結
狀態壓縮dp其實就是把一個狀態弄成二進制數,放到dp轉移里面,運用位運算操作暴力枚舉。
所以狀態壓縮dp真的不難,只要弄懂了核心,剩下的就是熟練運用位運算操作了。
這次題解希望對跟我一樣在沖noip提高組的童鞋們有幫助,謝謝大家!