<style> .reveal .slides { text-align: left; font-size:32px } </style> ## Bitmask DP 2025/5/8 --- - Bitmask DP - SOS DP --- ## Bitmask DP 又稱位元壓縮DP,泛指狀態是由二進制表示的DP。 通常可以表示集合,$0$ 就是不取,$1$ 就是取。 假設我有 $n$ 個東西要考慮,就能用一個長度為 $n$ 的bitstring表示,所以狀態通常會是 $O(2^n)$,看到題目範圍是 $\leq 20$ 這種就可以往 bitmask dp 想想看。 ---- 這邊先幫大家複習一下位元運算: ```cpp= x>>=1 //右移最左邊補零 x<<=1 //左移最右邊補零 //註 : k 0-base x&(1<<k) //取出 x 的第 k 位 if(x&(1<<k)) x = x ^ (1<<k) //把第 k 位歸零 x = x | (1<<k) //把第 k 位變成 1 x = ((1<<n) - 1) - x; //取 x 的補集合 //宇集 U = (1<<n) - 1 __builtin_popcount(x) //數 x 有幾個 1 ``` 使用位元運算要注意括號,位元運算比布林運算、算數運算優先度低,所以沒括號很容易爆掉。 其實 bitmask dp 就這樣,所以直接進例題。 ---- ### [搭電梯](https://cses.fi/problemset/task/1653) $n$ 個人排隊,每個人重 $w_i$ ,要搭一台負重 $x$ 的電梯,問你最少要搭幾趟?。 - $1 \leq n \leq 20$ - $1 \leq x \leq 10^9$ - $1 \leq w \leq x$ 很明顯的 $x$ 跟 $w$ 都不太能當狀態,畢竟到 $10^9$。 ---- 既然需要最少搭幾趟,不妨我們直接窮舉在一趟電梯中有哪些人上電梯。 假設 $n = 4,x=10$ $\ w=[4,8,6,1]$ ```cpp= 0 1 2 3 sum 0 0 0 0 -> 0 0 0 1 1 -> 7 0 1 0 1 -> 9 ``` 此時第一班電梯肯定就是這些合法的集合 (和$\leq x$) 的其中一個。 那下一班呢? ---- 對於 $(0011)_2$ 如果下一班 $1$ 號上電梯了,就變成 $(0111)_2$ 此時狀態的意義為,哪些人已經上了電梯,此時我們想知道的 $dp[(0111)_2]$ 就是,當這些人都搭上電梯後,的最小班次數。 ---- 觀察一下,對於 $(0011)_2$、$(0101)_2$ 分別多 $1$、$2$ 號搭,都會把狀態送到 $(0111)_2$ 並且這兩個狀態他們都會使班次 + 1 ($1$、$2$ 都分別擠不上第一班)。 那此時對後面的其他狀態而言 (ex. $(1111)_2$),這個 $(0111)_2$ 當然是保留當下最少搭乘電梯次數的越好,那當次數一樣時,電梯內剩越多空間越好,依照上面的例子,在第二班中 $1$ 號上電梯空間剩 2, $2$ 號上第二班電梯空間剩 4 ,所以留 $(0101)_2 \rightarrow (0111)_2$ 這個轉移。 ---- 這樣就能轉移了,對於每個已上電梯的集合,我們都留最小電梯次數和目前最小電梯負重。 ```cpp= #define pp pair<int,int> #define ff first #define ss second const int MXN = 2e5+5,MOD = 1e9+7; pp dp[(1<<20)]; void solve(){ int n,x; cin >> n >> x; vector<int> w(n); cin >> w; //初始化: 最多搭 n 次,所以開 n+1 當 inf for(int s = 0; s < (1<<n);++s) dp[s].ff = n+1; dp[0].ss = x, dp[0].ff = 0; //沒人上電梯 讓 0 號電梯目前負重是 x 這樣下一個人上電梯一定會搭到第一班 for(int s = 1; s < (1<<n);++s){ //窮舉全部狀態 for(int i = 0; i < n;++i){ //搭第 i 個人上去 if(!(s & (1 << i))) continue; //如果第 i 個沒搭過,跳過 int pv = s ^ (1 << i); // i 沒搭的狀態 auto [a,b] = dp[pv]; if(b + w[i] > x) b = w[i],++a; // i 搭不上去前一班,多一班 else b += w[i]; //搭的上去 if(a < dp[s].ff) dp[s] = pp{a,b}; //次數少先取 else if(a == dp[s].ff) dp[s].ss = min(dp[s].ss,b); //次數一樣,取最小的負重 } } cout << dp[(1<<n)-1].ff << '\n'; } ``` for 迴圈從小到大,是因為當 $s \subseteq S$ 時, $s \leq S$ ,所以對任意集合它的子集都能夠先被窮舉到。 ---- 一個特別的觀察,你會發現這題用排隊來看時,我們只要窮舉 $n$ 個人的排列 (也就是 $n!$ 種),然後再每次都載盡量多的人上去 (Greedy),也能找到解,只不過複雜度會是 $O(n*n!)$,但透過 bitmask dp 能壓到 $O(n*2^n)$ ,就我自己遇到的,很多排列會爆開的題目,都能 bitmask 壓掉。 ---- 繼特別的觀察,再一個例題 ### Hamiltonian Path 一張圖,問你有沒有辦法走過所有點,但每個點都只經過剛好一次 ![image](https://hackmd.io/_uploads/ryHOzOYpT.png =500x) $n$ 個點,$n\leq 20$ ---- 暴力: $n!$ 窮舉所有排列 (點走的順序),然後去檢查這個順序能不能剛好都只經過所有點一次 $O(n)$, 全部 $O(n*n!)$ ,這複雜度真眼熟。 ---- bitmask: bitmask $S$ 紀錄當前走過哪些點了,由於我們需要知道誰走到誰,所以還要多記 走完 $S$ 這些點後的最後一個點。 $dp[S][i] = 0 \lor 1$ 走過,$S$ 內所有點後以 $i$ 點為最後一個點,是否有合法的路。 那這樣其實就能直接轉移了 ---- 你會發現,如果有 $i \rightarrow j$ 這條邊,那 $dp[S \ \cup \ \{j\}][j] \ or= \ dp[S][i]$ 就這樣。 ---- Code: ```cpp= for(int i = 0; i < n;++i) dp[(1<<i)][i] = true; // 初始化 for(int s = 1; s < (1<<n);++s){ for(int j = 0; j < n;++j){ if(s&(1<<j)) continue; // s 還不能經過 j for(int i = 0; i < n;++i){ if(!(s&(1<<i))) continue; // s 必須得經過 i if(edge[i][j]){ //有 i -> j 的邊 dp[s | (1<<j)][j] |= dp[s][i]; } } } } bool ans = false; for(int i = 0; i < n;++i) ans |= dp[(1<<n)-1][i]; ``` 複雜度 $O(n^2*2^n)$ ---- TSP: 旅行推銷商問題,其實就是帶權重的漢米爾頓路徑問題,小改code就好。 --- ## SOS DP ### [模板](https://github.com/asd7766zxc/Miaotomata_CodeBook/blob/main/dp/sos_dp.cpp) Sum over subset (SOS) 目標就是在解決以下問題,其中 $A$ 是一個對於所有集合給定的函數。 $F[S]=\sum_{x\subseteq S}{A[x]}$ ---- 舉個例子 ```cpp= A[001] = 10 A[010] = 3 A[101] = 4 F[101] = A[101] + A[001] = 10 + 4 = 14 ``` ---- 暴力做一下 假設我們能夠窮舉對於給定的集合 $S$ 的所有子集 $x$ , 那我們就能在 $O(3^n)$ 時間下搞定, 其中 $n$ 是集合大小,或稱 bitmask 的長度。 好所以這 $O(3^n)$ 怎麼來的? ---- 一樣,在給定一個 $S$ 的情況下,我們知道它的 bitmask 表示會有一些地方是 $0$ 一些地方是 $1$。 像這樣 $S=(10110)_2$ 那他的子集 $x$ 其實就是 $S$ 是 $0$ 的地方一定為 $0$ 但剩下的隨便(可為 $0$ 或 $1$)。 所以 $(10010)_2 \in S$ 但 $(11000)_2$ 不是 $S$ 的子集。 ---- 所以對於一個 $S$ 而言,它的子集個數就是 $2^{( \ S \ 的 \ 1 \ 的個數)}$。 接下來考慮全部的 $S$ ,對於 $\ 1 \ 的個數是 \ k \ 的 \ S \ 而言會 \ {{n}\choose{k}} \ 這麼多$ $然後每個再窮舉,所以 {{n}\choose{k}}2^k$ 根據二項式定理 ${{n}\choose{0}}2^0 + {{n}\choose{1}}2^1 + \cdots + {{n}\choose{n}}2^n = (1+2)^n = 3^n$ ---- 說這麼多,所以 how to 窮舉? 對於 $S=(001011)_2$ 我們可以只考慮 $1$ 的地方對吧, 所以把他看成這樣 $S=(XX1X11)_2$ 然後開始往下數, $(XX1X11)_2$ $(XX0X11)_2$ $(XX1X01)_2$ $(XX0X01)_2$ $(XX1X10)_2$ $(XX0X10)_2$ $(XX1X00)_2$ $(XX0X00)_2$ 這樣就能窮舉完 $S$ 的子集。 ---- 所以我們只要每次都把 左起第一個 $1$ 變成 $0$ 然後把這個 $1$ 往左所有的 $0$ 都變成 $1$ 你會發現 $S-1 = (110011)_2$ , 所以對原 mask 減 $1$ 就能辦到上述效果,再來把不該是 $1$ 的 bit 全部再翻成 $0$ 就搞定了。 ---- 窮舉子集: ```cpp= for(int mask = S; mask; mask = (mask-1) & S){ // S 包含 mask } ``` 注意空集合也就是 $mask = 0$ 要另外做,不然會壞 : 因為 (-1 & S) 會跑回去一個正數 ---- 所以暴力做的 SOS 就會長 ```cpp= for(int S = 0; S < (1LL<<S); ++S){ for(int mask = S; mask; mask = (mask-1) & S){ F[S] += A[mask]; } F[S] += A[0]; } ``` 複雜度 $O(3^n)$ ---- 但通常題目會給到 $n\leq20$ 左右,此時 $3^n=3.4e9$ 老慢了。 這時,我們就要優化一下,接下來的內容會有點抽象。 ---- 我們先放code: ```cpp= // 求子集和 或超集和 -> !(mask & (1 << i)) for(int i = 0; i<(1<<N); ++i) F[i] = A[i]; //預處理 狀態權重 for(int i = 0;i < N; ++i){ for (int s = 0; s < (1<<N) ; ++s){ if (s & (1 << i)){ F[s] += F[s ^ (1 << i)]; } } } ``` 這就是 SOS dp 的全貌 複雜度 $O(n * 2^n)$ ---- ### SOS dp = 前綴和 接下來的觀點是由這裡來的 [SOS dp insights](https://codeforces.com/blog/entry/105247) 有興趣可以翻翻。 ---- Recall: 如果 $x$ 是 $S$ 的子集,則$S$ 是 $0$ 的地方一定為 $0$ 但剩下的隨便(可為 $0$ 或 $1$)。 換句話說,對於每一維(每一個同位置的bit)而言,都要 $\leq$ $S$ 的每一維。 也就是說,假設我們把 $S$ 跟原點做一個每個邊都跟軸平行的矩形 (假設在二維平面上),那落在這個平面內的點都會是 $S$ 的子集。 ---- ex: 為了方便演示,我把base拉成10進位 ![image](https://hackmd.io/_uploads/Syspsz9eel.png) $S=(23)_{10}$ $D=(12)_{10}$ $E=(01)_{10}$ $I=(10)_{10}$ 照定義,$S$ 包含了 $D、E、I$,且根據這張圖,它們也確實都落在這個矩形內。 此時如果我想求 $F[S] = A[D] + A[E] + A[I]$ 那 $F$ 就會是一個二維前綴和。 ---- 在了解(相信),他是前綴和後,我們接下來要解決的問題變成,如何做這麼高維($dim=n\leq20$)的前綴和? ---- 一維前綴和都會吧 ```cpp= // 初始讓每個單點都是一個值 for(int x = 0; x < n;++x) F[x] = A[x]; for(int x = 1; x < n;++x) F[x] += F[x-1]; //這樣等價我們把點給累加起來 ``` ---- 那二維呢? ![image](https://hackmd.io/_uploads/rkhZnM9leg.png) 我們會一維那我們先做一維的 (固定 $y$) ```cpp= #define rep(i,j,n) for(int i = j; i < n;++i) // 初始讓每個單點都是一個值 rep(x,0,n) rep(y,0,n) F[x][y] = A[x][y]; rep(y,0,n){ for(int x = 1; x < n;++x) F[x][y] += F[x-1][y]; } //把每個 y 分別和成一條前綴和 ``` ---- 加完就長這樣每一條都是對 $x$ 的前綴和 ![image](https://hackmd.io/_uploads/B1YznGqgeg.png) 這時候求二維前綴和就很好求了,你會發現就是把這一條條的前綴和(線),疊成一個矩型(面)。 ---- ```cpp= #define rep(i,j,n) for(int i = j; i < n;++i) // 初始讓每個單點都是一個值 rep(x,0,n) rep(y,0,n) F[x][y] = A[x][y]; for(int y = 0; y < n;++y){ //把每個 y 分別和成一條前綴和 for(int x = 1; x < n;++x){ F[x][y] += F[x-1][y]; } } for(int y = 1; y < n;++y){ //把每條都疊起來 for(int x = 0; x < n;++x){ F[x][y] += F[x][y-1]; } } ``` ---- 你會發現 第一輪是 $F[x][y] += F[x-1][y]$ 第二輪是 $F[x][y] += F[x][y-1]$ 也就是說對應的每一維他會由這維的 $-1$ 來。 如果不信,我現在畫一個三維的 ---- $F[x][y][z] += F[x-1][y][z]$ <img src="https://hackmd.io/_uploads/S1ssW8Ilgg.png" width="70%"/> 先對 $x$ 做前綴和就會變成垂直 $x=0$ 這個平面的那堆線(如圖黑色的直線)。 依此類推再來就是面 (對 $y$ 做),跟 6 面體 (對 $z$ 做)。 ---- 好,所以總得來說只要我們分別對每一個照某個順序做前綴和,就能做完全部的前綴和。 由於mask的每一維只有兩個數字 ($0 \lor 1$),所以只有$1$的那些人才需要做 $[x] = [x-1]$ 這種事。 ```cpp= for(int i = 0; i<(1<<N); ++i) F[i] = A[i]; //預處理 狀態權重 //也就是前面前綴和的單點值 for(int i = 0;i < N; ++i){ //窮舉目前在哪一維 (x,y,z ...) //同前面,窮舉所有點 for (int s = 0; s < (1<<N) ; ++s){ //對於 x 包含於 S -> x <= S (值的部分) 所以數值小的先做 if (s & (1 << i)){ // 這個就是該維是 1 的意思 F[s] += F[s ^ (1 << i)]; // [x] = [x-1] } } } ``` ps: 其實你只要會用就好 ---- [例題](https://vjudge.net/contest/715073#problem/G): 這題是 virtual 題(gym 105633K)。 $n$ 個時間點, $m$ 個人, $a_{ij}$ = $Y \ or \ N$ 表示編號為 $j$ 的人能不能在第 $i$ 個時間點開會。 你要找兩個時間點,使得這兩個時間點聯集起來這 $m$ 個人,每個人至少都有開到一次會。 如果有多個這樣的時間點對,輸出兩個時間點交集最大的,如果還有重複,輸出兩個時間點越早越好。 - $2 \leq n \leq 2*10^5$ - $2 \leq m \leq 20$ ---- 暴力一下: 先 $O(n^2)$ 枚舉這樣的 pair,那你會發現就是要找任兩行 $or$ 起來是 $(1<<m)-1$ 的,和 $and$ 起來 $1$的個數是最多的 (假設 $Y=1,N=0$)。 ```cpp= for(int i = 0;...){ for(int j = 0;...) } ``` 那有沒有辦法把 $j$ 那圈壓掉? ---- SOS: 你會發現 SOS dp 的 $F[S]$ 其中包含了所有 $S$ 包含的和,那對於 $S$ 的子集而言,$S$ 是 $0$ 的地方子集也一定是 $0$。 那當我們今天在窮舉的時候,假設窮舉到 $(011001)_2$ ,那另一個與他配對的,就必須在這個時間點 $0$ 的地方是 $1$ ,也就是長 $(1XX11X)_2$ 這樣他們 $or$ 起來必定是 $(1<<m)-1$, 所以這樣第二維就能壓掉了,只要我們把 $Y = 0,N = 1$ 然後塞進 SOS 裡面,就能找到這些人。 ---- 但這樣要怎麼找交集最大的? 其實就是留 $1$ 的個數最多的在 SOS 裡,因為外面那圈再查找的時候,自己的 bitmask 上的 $0$ 找到的都會是 $1$ 此時交集的 $1$ 個數就會是 F[x].(1's) - 自己的0's。 對於SOS而言,自己的0's就是定值,故F[x].(1's) 越大越好。 剩下時間也是同理,變一下就好。 ---- Code: ```cpp= vector<pp> F((1LL<<m)); for(int i = 0; i < (1<<m);++i) F[i] = {A[i],B[i]}; auto work = [&](pp &p,pp c){ if(c.ss == n+5) return; if(p.ff < c.ff) p = c; if(p.ff == c.ff) chmin(p.ss,c.ss); }; for(int i = 0; i < m;++i){ for(int s = 0; s < (1<<m);++s){ if(s & (1<<i)){ work(F[s],F[s^(1<<i)]); } } } pp ans(n+5,n+5); int m1 = -1; for(int i = 0; i < n;++i){ int x = ((1<<m)-1) - P[i]; int cx = m - __builtin_popcount(x); auto [p1,p2] = F[x]; if(p2 == i || p2 == n+5) continue; debug(p2); int a = i; int b = p2; if(a > b) swap(a,b); int dx = p1-cx; debug(a,b,dx); if(dx > m1){ ans = pp{a,b}; m1 = dx; }else if(dx == m1){ if(ans.ff > a) ans = pp{a,b}; if(ans.ff == a) chmin(ans.ss,b); } } if(m1 >= 0){ cout << ans.ff +1 << ' ' << ans.ss + 1 << '\n'; }else{ cout << "No\n"; } ``` 所以 SOS dp 通常要變一下。 --- [題單](https://vjudge.net/contest/715073)
{"title":"Bitmask DP","description":"2025/5/8","contributors":"[{\"id\":\"08326fa4-ca9b-4ca9-873e-239ebe76662c\",\"add\":9797,\"del\":164}]"}
    510 views
   owned this note