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

RioTian發表於2020-11-07

動態規劃當中有非常常見的一個分支——狀態壓縮動態規劃,很多人對於狀態壓縮畏懼如虎,但其實並沒有那麼難,希望這文章能帶你們學到這個經典的應用。

二進位制表示狀態

在講解多重揹包問題的時候,我們曾經講過二進位制表示法來解決多重揹包。利用二進位制的性質,將多個物品拆分成少數個物品,轉化成了簡單的零一揹包來解決。今天的狀態壓縮同樣離不開二進位制,不過我個人感覺今天的二進位制應用更加容易理解一些。

二進位制的很多應用離不開集合這個概念,我們都知道在計算機當中,所有資料都是以二進位制的形式儲存的。一般一個 \(int\) 整形是4個位元組,也就是32位bit,我們通過這32位bit上0和1的組合可以表示多大21億個不同的數。如果我們把這32位bit看成是一個集合,那麼每一個數都應該對應集合的一種狀態,並且每個數的狀態都是不同的。

二進位制表示

比如上圖當中,我們列舉了5個二進位制位,我們把其中兩個設定成了1,其餘的設定成了0。我們通過計算,可以得到6這個數字,那麼6也就代表了(00110)這個狀態。數字和狀態是一一對應的,因為每個整數轉化成二進位制都是唯一的。

也就是說一個整數可以轉化成二進位制數,它可以代表某個集合的一個狀態,這兩者一一對應。這一點非常重要,是後面一切推導的基礎。

狀態轉移

整數的二進位制表示可以代表一個二元集合的狀態,既然是狀態就可以轉移。在此基礎上,我們可以得出另一個非常重要的結論——我們可以用整數的加減表示狀態之間的轉移

我們還用剛才的例子來舉例,上面的圖當中我們列舉了5個二進位制位,假設我們用這5個二進位制位表示5個小球,這些小球的編號分別是0到4。這樣一來,剛才的6可以認為表示拿取了1號和2號兩個小球的狀態。

如果這個時候我們又拿取了3號小球,那麼集合的狀態會發生變化,我們用一張圖來表示:

上圖當中粉絲的筆表示決策,比如我們拿取了3號球就是一個決策,在這個決策的影響下,集合的狀態發生了轉移。轉移之後的集合代表的數是14,它是由之前的集合6加上轉移帶來的變化,也就是得到的。剛好就代表拿取3號球這個決策,這樣我們就把整個過程串起來了。

總結一下,我們用二進位制的0和1表示一個二元集合的狀態。可以簡單認為某個物品存在或者不存在的狀態。由於二進位制的0和1可以轉化成一個 \(int\) 整數,也就是說我們用整數代表了一個集合的狀態。這樣一來,我們可以用整數的加減計算來代表集合狀態的變化

這也就是狀態壓縮的精髓,所謂的壓縮,其實就是將一個集合壓縮成了一個整數的意思,因為整數可以作為陣列的下標,這樣操作會方便我們的編碼。

關於位運算還有很多奇技淫巧,原文連結:Here

旅行商問題

明白了狀態壓縮的含義之後,我們來看一道經典的例題,也就是大名鼎鼎的旅行商問題。

旅行商問題的背景很有意思,說是有一個商人想要旅行各地並進行貿易。各地之間有若干條單向的通道相連,商人從一個地方出發,想要用最短的路程把所有地區環遊一遍,請問環遊需要的最短路程是多少?在這題當中,我們假設商人從0位置出發,最後依然回到位置0。

我們來看下面這張圖來直觀地感受一下:

旅行商問題流程圖

假設我們的商人從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問題,即邏輯電路問題。

邏輯電路問題

下圖是一個邏輯電路,假設我們知道它的輸出是 \(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的增大,兩者的差距還會更大。

最後,我們來實現以下演算法:

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作為決策的起點。

如果你看不懂上面的做法也沒有關係,我再附上一種稍稍簡單一些的方法:

    # 我們從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\) 點的出發點,找到距離最近的那條。相比於上面的做法,這種做法更容易理解一些,但是程式碼多寫幾行,但是更容易理解一些。我建議如果直接理解第一段程式碼有困難的話,可以先搞懂第二段,然後再想明白為什麼第一段程式碼也成立。

總結

不知道有多少人成功看到了這裡,動態規劃的確不簡單,第一次學的話會覺得很困難難以理解是正常的。但是它是屬於那種入門之前覺得特別難,但是一旦想明白了之後就特別簡單的問題。而且大家從程式碼量上也看得出來,我用了幾千字描述的演算法,寫出來居然只有十幾行。

動態規劃演算法一直都是如此,程式碼不長,但每一行都是精髓。從這點上來說,它的價效比還真的是蠻高的。

好了,今天的文章就是這些,如果覺得有所收穫,請順手點個推薦吧,你們的舉手之勞對我來說很重要。

參考

狀態壓縮技巧:動態規劃的降維打擊

OI wiki

TechFlow


  1. 什麼是P,NP和NPC問題? ↩︎

相關文章