詳解狀態壓縮動態規劃算法

本文始發於我的公衆號:TechFlow,原創不易,求個關注web


今天是算法與數據結構專題的第16篇,也是動態規劃系列的第5篇。算法

今天文章的內容是動態規劃當中很是常見的一個分支——狀態壓縮動態規劃,不少人對於狀態壓縮畏懼如虎,但其實並無那麼難,但願我今天的文章能帶大家學到這個經典的應用。數組

二進制表示狀態

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

二進制的不少應用離不開集合這個概念,咱們都知道在計算機當中,全部數據都是以二進制的形式存儲的。通常一個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號兩個小球的狀態。spa

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

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

總結一下,咱們用二進制的0和1表示一個二元集合的狀態。能夠簡單認爲某個物品存在或者不存在的狀態。因爲二進制的0和1能夠轉化成一個int整數,也就是說咱們用整數表明了一個集合的狀態。這樣一來,咱們能夠用整數的加減計算來表明集合狀態的變化

這也就是狀態壓縮的精髓,所謂的壓縮,其實就是將一個集合壓縮成了一個整數的意思,由於整數能夠做爲數組的下標,這樣操做會方便咱們的編碼。

旅行商問題

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

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

咱們來看下面這張圖來直觀地感覺一下:

假設咱們的商人從0位置出發,想要環遊一週以後再次回到0,那麼它所須要經歷的最短距離是多少呢?

這個圖仍是比較簡單的,若是在極端狀況下也就是全部點之間都有連線的時候,對於每個點來講,它能夠選擇的下一個位置一共有n-1種。那麼一共能夠選擇的路線總共有n!種,這是一個很是大的值,顯然是咱們不能接受的。這也是爲何咱們說旅行商問題是一個NP-Hard問題。

NP問題

既然說到了NP問題,簡單和你們聊聊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 = [[013], [125], [235], [343], [407], [416], [034], [204]]
    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 - ,決策帶來的開銷是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點的出發點,找到距離最近的那條。相比於上面的作法,這種作法更容易理解一些,可是代碼多寫幾行,可是更容易理解一些。我建議若是直接理解第一段代碼有困難的話,能夠先搞懂第二段,而後再想明白爲何第一段代碼也成立。

總結

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

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

好了,今天的文章就是這些,若是以爲有所收穫,請順手點個關注或者轉發吧,大家的舉手之勞對我來講很重要。

相關文章
相關標籤/搜索