1. 程式人生 > >動態規劃——用二進位制表示集合的狀態壓縮DP

動態規劃——用二進位制表示集合的狀態壓縮DP

動態規劃當中有非常常見的一個分支——**狀態壓縮動態規劃**,很多人對於狀態壓縮畏懼如虎,但其實並沒有那麼難,希望這文章能帶你們學到這個經典的應用。 ## 二進位制表示狀態 在講解多重揹包問題的時候,我們曾經講過二進位制表示法來解決多重揹包。利用二進位制的性質,將多個物品拆分成少數個物品,轉化成了簡單的零一揹包來解決。今天的狀態壓縮同樣離不開二進位制,不過我個人感覺今天的二進位制應用更加容易理解一些。 二進位制的很多應用離不開**集合**這個概念,我們都知道在計算機當中,所有資料都是以二進位制的形式儲存的。一般一個 $int$ 整形是4個位元組,也就是32位bit,我們通過這32位bit上0和1的組合可以表示多大21億個不同的數。如果我們把這32位bit看成是一個集合,那麼**每一個數都應該對應集合的一種狀態**,並且每個數的狀態都是不同的。 ![二進位制表示](https://gitee.com//riotian/blogimage/raw/master/img/20201106203717.webp) 比如上圖當中,我們列舉了5個二進位制位,我們把其中兩個設定成了1,其餘的設定成了0。我們通過計算,可以得到6這個數字,那麼6也就代表了(00110)這個狀態。**數字和狀態是一一對應的**,因為每個整數轉化成二進位制都是唯一的。 也就是說一個整數可以轉化成二進位制數,它可以代表某個集合的一個狀態,這兩者一一對應。這一點非常重要,是後面一切推導的基礎。 ## 狀態轉移 整數的二進位制表示可以代表一個二元集合的狀態,既然是狀態就可以轉移。在此基礎上,我們可以得出另一個非常重要的結論——**我們可以用整數的加減表示狀態之間的轉移**。 我們還用剛才的例子來舉例,上面的圖當中我們列舉了5個二進位制位,假設我們用這5個二進位制位表示5個小球,這些小球的編號分別是0到4。這樣一來,剛才的6可以認為表示拿取了1號和2號兩個小球的狀態。 如果這個時候我們又拿取了3號小球,那麼集合的狀態會發生變化,我們用一張圖來表示: ![](https://gitee.com//riotian/blogimage/raw/master/img/20201106203737.webp) 上圖當中粉絲的筆表示決策,比如我們拿取了3號球就是一個決策,在這個決策的影響下,集合的狀態發生了轉移。轉移之後的集合代表的數是14,它是由之前的集合6加上轉移帶來的變化,也就是得到的。剛好就代表拿取3號球這個決策,這樣我們就把整個過程串起來了。 總結一下,我們用二進位制的0和1表示一個二元集合的狀態。可以簡單認為某個物品存在或者不存在的狀態。由於二進位制的0和1可以轉化成一個 $int$ 整數,也就是說我們用整數代表了一個集合的狀態。這樣一來,我們可以**用整數的加減計算來代表集合狀態的變化**。 這也就是狀態壓縮的精髓,所謂的壓縮,其實就是將一個集合壓縮成了一個整數的意思,因為整數可以作為陣列的下標,這樣操作會方便我們的編碼。 > 關於位運算還有很多奇技淫巧,原文連結:[Here](https://www.cnblogs.com/RioTian/p/13598747.html) ## 旅行商問題 明白了狀態壓縮的含義之後,我們來看一道經典的例題,也就是大名鼎鼎的旅行商問題。 旅行商問題的背景很有意思,說是有一個商人想要**旅行各地**並進行貿易。各地之間有若干條**單向的通道**相連,商人從一個地方出發,想要用最短的路程把所有地區環遊一遍,請問環遊需要的最短路程是多少?在這題當中,我們假設商人從0位置出發,最後依然回到位置0。 我們來看下面這張圖來直觀地感受一下: ![旅行商問題流程圖](https://gitee.com//riotian/blogimage/raw/master/img/20201106203742.webp) 假設我們的商人從0位置出發,想要**環遊一週之後再次回到0**,那麼它所需要經歷的最短距離是多少呢? 這個圖還是比較簡單的,如果在**極端情況下也就是所有點之間都有連線**的時候,對於每一個點來說,它可以選擇的下一個位置一共有 $n-1$ 種。那麼一共可以選擇的路線總共有 $n!$ 種,這是一個非常大的值,顯然是我們不能接受的。這也是為什麼我們說旅行商問題是一個 $NP-Hard$ 問題。 ## NP問題 既然說到了NP問題[^1],簡單和大家聊聊NP問題的定義。 很多演算法的初學者對於這些概念非常迷糊,也的確,這些概念聽起來都差不多,的確很容易搞暈。我們先從最簡單的開始介紹,首先是P問題。 P問題可以認為是已經解決的問題,這個解決的定義是可以做**多項式的時間複雜度內**解決。所謂的多項式,也就是,這裡的k是一個常數。與多項式相反的函式有很多,比如指數函式、階乘等等。 $NP$ 問題並不是P問題的反義,這裡的N不能理解成No,就好像 $noSQL$ 不是非 $SQL$ 的意思一樣。$NP$ 問題指的是可以**在多項式內驗證解的問題**。 比如給定一個排序的序列讓我們判斷它是不是有序的,這很簡單,我們只需要遍歷一下就好了。再比如大整數的因式分解,我們來做因式分解會很難,但是讓我們判斷一個因式分解的解法是不是正確則要簡單得多,我們直接把它們乘起來和原式比較就可以了。 顯然**所有P問題都是NP問題**,既然我們可以多項式內找到解,那麼必然我們也可以在多項式內驗證解是否正確。但是反過來是否成立呢,是否多項式時間內可以驗證解的問題,也可以通過某種演算法可以在多項式時間內被解開呢?**究竟是我們暫時還沒有想到演算法,還是解法一開始就不存在呢?** 上面的這個問題就是著名的NP=P是否成立的問題,這個問題目前仍然是一個謎,有些人相信成立,有些人不相信,這也被認為是二十一世紀的最大難題之一。 為了證明這個問題,科學家們又想出了一個辦法,就是給問題做規約。舉個例子,比如解方程,我們解一元一次方程非常簡單,而解二元一次方程則要困難一些。如果我們想出瞭解二元一次方程的辦法,那麼必然也可以用來解一元一次方程,因為我們只需要令另一個未知數等於0就是一元一次方程了。 同理,我們也可以把NP問題做轉化,將它的難度增大,**增大到極限成為一個終極問題**。由於這個終極問題是所有NP問題轉化得到的,只要我們想出演算法來解決了終極問題,那麼,所有的NP問題全部都迎刃而解。就比如如果我們想出瞭解N元方程的演算法,那麼這一類解方程的問題就都搞定了。這種轉化之後得到的問題稱為**NP完全問題,也叫做NPC問題**。 下面我們來看一個經典的NPC問題,即邏輯電路問題。 ![邏輯電路問題](https://gitee.com//riotian/blogimage/raw/master/img/20201106203841.webp) 下圖是一個邏輯電路,假設我們知道它的輸出是 $True$ ,我們也知道了電路的結構,那麼請問我們能否確定一定可以找到一個輸入的組合,使得最後的輸出是 $True$ 嗎? 它顯然是一個 $NP$ 問題,因為我們可以直接把解法代入電路去計算一下,就可以驗證這個解是否正確,但是想要得到答案卻很難。經過嚴謹的證明,所有NP問題都可以經過轉化得到它,也就是說如果我們找到一種解法可以在多項式內解決這個問題,那麼我們就解決了所有的 $NP$ 問題。 最後,還有一個 $NP-Hard$ 問題,$NP-Hard$ 問題是說所有 $NP$ 問題可以經過轉化得到它,但是**它本身並不是NP問題**,也就是說我們無法在多項式時間內判斷它的解是否正確。 比如剛才提到的旅行商問題就是一個 $NP-Hard$ 問題,因為即使我們給定了一個解,我們也**沒有辦法快速判斷給定的解是否正確**,必須要遍歷完所有的情況才可以。我們驗證的複雜度就已經超出了多項式的範疇,所以它不屬於 $NP$ 問題,比 $NP$ 問題更加困難,所以是一個 $NP-Hard$ 問題。 ## 狀態壓縮解法 說完了 $NP$ 問題,我們回到演算法本身。 既然我們要用動態規劃的思路來解決這個問題,就**不能脫離狀態和決策**。前文說了我們利用二進位制可以用一個整數來表示一個集合的狀態,我們很容易會把這個狀態當成是動態規劃當中的狀態,但其實這是不對的。 單純集合之間的轉移沒有限制條件,比如之前的例子當中我們已經拿了1號球和2號球,後面只要是剩下的球都可以拿,但是旅行商問題不一樣,假設我們去過了0和1兩個地方,我們當前在位置1,我們是無法用2和5兩地之間的連線來更新這個狀態的,因為我們當前只能從1號位置出發。也就是說我們**能採取的決策是有限制的**。 所以我們不能只單純地拿集合的狀態來當做狀態,為了保證地點之間的移動順序正確,我們還需要加上一維,也就是當前所處的位置。所以**真正的狀態是我們之前遍歷過的位置的狀態,加上當前所處的地點,這兩者的結合**。 狀態確定了,決策就很簡單了,凡是當前地點能去的之前沒有去過的位置,都可以構成決策。 我們之前說過,在動態規劃問題當中,**複雜度等於狀態數乘上決策數**,狀態數是,決策數就是n,所以總體的複雜度是。雖然這個數字看起來仍然大得誇張,但是仍然要比n!小很多。 我們舉個例子來看下,如果 $n=10,n!=3628800$,,**兩者相差了三十多倍**。隨著n的增大,兩者的差距還會更大。 最後,我們來實現以下演算法: ```python import math if __name__ == "__main__": inf = 1 << 31 # 鄰接矩陣儲存邊權重 d = [[inf for _ in range(10)] for _ in range(10)] # 測試資料 edges = [[0, 1, 3], [1, 2, 5], [2, 3, 5], [3, 4, 3], [4, 0, 7], [4, 1, 6], [0, 3, 4], [2, 0, 4]] for u, v, l in edges: d[u][v] = l # 初始化成近似無窮大的值 dp = [[inf for _ in range(5)] for _ in range((1 << 5))] dp[0][0] = 0 # 遍歷狀態 for s in range(1, (1 << 5)): for u in range(5): # 遍歷決策 for v in range(5): # 必須要求這個點沒有去過 if (s >> v) & 1 == 0: continue dp[s][v] = min(dp[s][v], dp[s - (1 << v)][u] + d[u][v]) print(dp[(1 << 5) - 1][0]) ``` 在ACM競賽的程式碼風格當中,我們通常用u表示邊的起點,v表示邊的終點。所以上面的三重迴圈第一種是遍歷了所有的狀態,後面兩重迴圈是枚舉了起點和終點,也就是所有的邊。我們遍歷的是當前這個狀態之前的最後一次移動的邊,也就是說當前的點是v,之前的點是u,所以之前的狀態是$s-2^v$ ,決策帶來的開銷是 $d[u][v]$,也就是從u到v的距離。 如果讀過之前文章的小夥伴,會發現這是一個逆推的動態規劃。我們列舉當前的狀態和當前狀態的所有來源,從而找到當前狀態的最優解。如果對這個概念不熟悉的同學,可以檢視一下之前動態規劃下的其他文章。 這段程式碼當中有兩個細節,第一個細節是**我們沒有做u的合法判斷**,有可能我們u是不合法的,比如我們的集合當中只有2和3兩個點,但是我們卻枚舉了從4到5的策略。這樣是沒問題的,因為我們開始的時候把所有的狀態都設定成了無窮大,**只有合法的狀態才不是無窮**,由於我們希望最後得到的結果越小越好,不合法的狀態是不會被用來更新的。 第二個細節稍微隱蔽一些,就是我們在初始化的時候設定了 $dp[0][0] = 0$ 。這表示我們是從空集開始的,而不是從0點開始的。因為0點已經遍歷過的狀態對應的數字是1,當然我們也可以設定成0已經訪問過了,從0點開始,這樣的話由於每個點不能重複訪問,所以最後我們是無法回到0點的,要得到正確結果我們還需要加上回到0點需要的消耗。 分析一下會發現第一點是第二點的基礎,如果我們在列舉策略的時候都判斷一下u點是否也合法,那麼這個演算法就沒有辦法執行,因為**對於空集而言,所有點都是未訪問過的**,也都是非法狀態,我們就找不到一個訪問過的u作為決策的起點。 如果你看不懂上面的做法也沒有關係,我再附上一種稍稍簡單一些的方法: ```python # 我們從0點已經遍歷開始 dp[1][0] = 0 for s in range(2, (1 << 5)): for u in range(5): # 嚴格限制u必須已經遍歷過 if (s >> u) & 1 == 0: continue for v in range(5): if (s >> v) & 1 == 0: continue dp[s][v] = min(dp[s][v], dp[s - (1 << v)][u] + d[u][v]) ans = inf # 最後加上回到0點的距離 for i in range(5): ans = min(ans, dp[(1 << 5) - 1][i] + d[i][0]) print(ans) ``` 在這一種做法當中,我們**從狀態1開始**,也就是說我們把0號位置看成當前所在的點,並且已經遍歷過了,所以標記成了1。這樣的問題是我們沒有辦法再回到0了,因為一個點只能走一次,所以最後的時候需要再尋找回到0點的最優路徑。 $(1 << n) - 1$的值是從 $0$ 到 $n-1$ 個二進位制位都是 $1$ 的值,表示這 $n$ 個位置全部已經遍歷過了。然後我們遍歷所有回到 $0$ 點的出發點,找到距離最近的那條。相比於上面的做法,這種做法更容易理解一些,但是程式碼多寫幾行,但是更容易理解一些。我建議如果直接理解第一段程式碼有困難的話,可以先搞懂第二段,然後再想明白為什麼第一段程式碼也成立。 ## 總結 不知道有多少人成功看到了這裡,動態規劃的確不簡單,第一次學的話會覺得很困難難以理解是正常的。但是它是屬於那種入門之前覺得特別難,但是**一旦想明白了之後就特別簡單的問題**。而且大家從程式碼量上也看得出來,我用了幾千字描述的演算法,寫出來居然只有十幾行。 動態規劃演算法一直都是如此,程式碼不長,但每一行都是精髓。從這點上來說,它的價效比還真的是蠻高的。 好了,今天的文章就是這些,如果覺得有所收穫,請順手點個**推薦**吧,你們的舉手之勞對我來說很重要。 [^1]: [什麼是P,NP和NPC問題?](https://www.cnblogs.com/RioTian/p/13502495.html) ## 參考 **[狀態壓縮技巧:動態規劃的降維打擊](https://www.cnblogs.com/RioTian/p/13385018.html)** [OI wiki](https://oi-wiki.org/dp/state/) [Tec