論文閱讀 狀態壓縮

hebtu666發表於2019-02-05

狀態壓縮

Abstract

資訊學發展勢頭迅猛,資訊學奧賽的題目來源遍及各行各業,經常有一些在實際應用中很有價值的問題被引入資訊學並得到有效解決。然而有一些問題卻被認為很可能不存在有效的(多項式級的)演算法,本文以對幾個例題的剖析,簡述狀態壓縮思想及其應用。

Keywords

狀態壓縮、集合、Hash、NPC

 

 

Content

Introduction

作為OIers,我們不同程度地知道各式各樣的演算法。這些演算法有的以O(logn)的複雜度執行,如二分查詢、歐幾里德GCD演算法(連續兩次迭代後的餘數至多為原數的一半)、平衡樹,有的以O()執行,例如二級索引、塊狀連結串列,再往上有O(n)、O(nplogqn)……大部分問題的演算法都有一個多項式級別的時間複雜度上界,我們一般稱這類問題為P類(deterministic Polynomial-time)問題,例如在有向圖中求最短路徑。然而存在幾類問題,至今仍未被很好地解決,人們懷疑他們根本沒有多項式時間複雜度的演算法,它們是NPC(NP-Complete)NPH(NP-Hard)類,例如問一個圖是否存在哈密頓圈(NPC)、問一個圖是否存在哈密頓圈(NPH)、求一個完全圖中最短的哈密頓圈(即經典的Traveling Salesman Problem貨郎擔問題,NPH)、在有向圖中求最長(簡單)路徑(NPH),對這些問題尚不知有多項式時間的演算法存在。P和NPC都是NP(Non-deterministic Polynomial-time)的子集,NPC則代表了NP類中最難的一類問題,所有的NP類問題都可以在多項式時間內歸約到NPC問題中去。NPH包含了NPC和其他一些不屬於NP(也更難)的問題(即NPC是NP與NPH的交集), NPC問題的最優化版本一般是NPH的,例如問一個圖是否存在哈密頓圈是NPC的,但求最短的哈密頓圈則是NPH的,原因在於我們可以在多項式時間內驗證一個迴路是否真的是哈密頓迴路,卻無法在多項式時間內驗證其是否是最短的,NP類要求能在多項式時間內驗證問題的一個解是否真的是一個解,所以最優化TSP問題不是NP的,而是NPH的。存在判定性TSP問題,它要求判定給定的完全圖是否存在權和小於某常數v的哈密頓圈,這個問題的解顯然可以在多項式時間內驗證,因此它是NP的,更精確地說是NPC的。

如上所述,對於NPC和NPH問題,至今尚未找到多項式時間複雜度的演算法。然而它們的應用又是如此的廣泛,我們不得不努力尋找好的解決方案。毫無疑問,對於這些問題,使用暴力的搜尋是可以得到正確的答案的,但在資訊學競賽那有限的時間內,很難寫出速度可以忍受的暴力搜尋。例如對於TSP問題,暴力搜尋的複雜度是O(n!),如此高的複雜度使得它對於高於10的資料規模就無能為力了。那麼,有沒有一種演算法,它可以在很短的時間內實現,而其最壞情況下的表現比搜尋好呢?答案是肯定的——狀態壓縮(States Compression,SC)

 

作為對下文的準備,這裡先為使用Pascal的OIers簡要介紹一下C/C++樣式的位運算(bitwise operation)。

 

  • 基本運算子

名稱

C/C++樣式

Pascal樣式

簡記法則

按位與

(bitwise AND)

&

and

全一則一

否則為零

按位或

(bitwise OR)

|

or

有一則一

否則為零

按位取反

(bitwise NOT)

~

not

是零則一

是一則零

按位異或

(bitwise XOR)

^

xor

不同則一

相同則零

以上各運算子的優先順序從高到低依次為:~,&,^,|

 

  • 特殊應用
    1. and:
      1. 用以取出一個數的某些二進位制位
      2. 取出一個數的最後一個1(lowbit) :x&-x
    2. or :用以將一個數的某些位設為1
    3. not:用以間接構造一些數:~0=4294967295=232-1
    4. xor:
      1. 不使用中間變數交換兩個數:a=a^b;b=a^b;a=a^b;
      2. 將一個數的某些位取反

 

 

有了這些基礎,就可以開始了。

 

 

Getting Started

我們暫時避開狀態壓縮的定義,先來看一個小小的例題。

 

【引例】

在n*n(n≤20)的方格棋盤上放置n個車(可以攻擊所在行、列),求使它們不能互相攻擊的方案總數。

【分析】

這個題目之所以是作為引例而不是例題,是因為它實在是個非常簡單的組合學問題:我們一行一行放置,則第一行有n種選擇,第二行n-1,……,最後一行只有1種選擇,根據乘法原理,答案就是n!。這裡既然以它作為狀態壓縮的引例,當然不會是為了介紹組合數學。我們下面來看另外一種解法:狀態壓縮遞推(States Compressing Recursion,SCR)

我們仍然一行一行放置。取棋子的放置情況作為狀態,某一列如果已經放置棋子則為1,否則為0。這樣,一個狀態就可以用一個最多20位的二進位制數表示。例如n=5,第1、3、4列已經放置,則這個狀態可以表示為01101(從右到左)。設f[s]為達到狀態s的方案數,則可以嘗試建立f的遞推關係。

考慮n=5,s=01101。這個狀態是怎麼得到的呢?因為我們是一行一行放置的,所以當達到s時已經放到了第三行。又因為一行能且僅能放置一個車,所以我們知道狀態s一定來自:

①前兩行在第3、4列放置了棋子(不考慮順序,下同),第三行在第1列放置;②前兩行在第1、4列放置了棋子,第三行在第3列放置;

③前兩行在第1、3列放置了棋子,第三行在第4列放置。

這三種情況互不相交,且只可能有這三種情況。根據加法原理,f[s]應該等於這三種情況的和。寫成遞推式就是:

f[01101]=f[01100]+f[01001]+f[00101]

根據上面的討論思路推廣之,得到引例的解決辦法:

f[0]=1

f[s]=∑f[s^2i]

其中s∈[0…01,1…11],s的右起第i+1位為1。

 

反思這個演算法,其正確性毋庸置疑(可以和n!對比驗證)。但是演算法的時間複雜度為O(n2n),空間複雜度O(2n),是個指數級的演算法,比迴圈計算n!差了好多,它有什麼優勢?較大的推廣空間。

 

Sample Problems

【例1】

在n*n(n≤20)的方格棋盤上放置n個車,某些格子不能放,求使它們不能互相攻擊的方案總數。

【分析】

對於這個題目,如果組合數學學得不夠紮實,你是否還能一眼看出解法?應該很難。對於這個題目,確實存在數學方法(容斥原理),但因為和引例同樣的理由,這裡不再贅述。

聯絡引例的思路,發現我們並不需要對演算法進行太大的改變。引例的演算法是在列舉當前行(即s中1的個數,設為r)的放置位置(即列舉每個1),而對於例1,第r行可能存在無法放置的格子,怎麼解決這個問題呢?列舉1的時候判斷一下嘛!事實的確是這樣,列舉1的時候判斷一下是否是不允許放置的格子即可。

但是對於n=20,O(n2n)的複雜度已經不允許我們再進行多餘的判斷。所以實現這個演算法時應該應用一些技巧。對於第r行,我們用a[r]表示不允許放置的情況,如果某一位不允許放置則為1,否則為0,這可以在讀入資料階段完成。運算時,對於狀態s,用tmps=s^a[r]來代替s進行列舉,即不列舉s中的1轉而列舉tmps中的1。因為tmps保證了無法放置的位為0,這樣就可以不用多餘的判斷來實現演算法,程式碼中只增加了計算a陣列和r的部分,而時間複雜度沒有太大變化。

 

這樣,我們直接套用引例的演算法就使得看上去更難的例1得到了解決。你可能會說,這題用容斥原理更快。沒錯,的確是這樣。但是,容斥原理在這題上只有當棋盤為正方形、放入的棋子個數為n、且棋盤上禁止放置的格子較少時才有簡單的形式和較快的速度。如果再對例1進行推廣,要在m*n的棋盤上放置k個車,那麼容斥原理是無能為力的,而SCR演算法只要進行很少的改變就可以解決問題。這也體現出了引例中給出的演算法具有很大的擴充套件潛力。

 

棋盤模型是狀態壓縮最好的展示舞臺之一。下面再看幾個和棋盤有關的題目。

 

【例2】

給出一個n*m的棋盤(n、m≤80,n*m≤80),要在棋盤上放k(k≤20)個棋子,使得任意兩個棋子不相鄰。每次試驗隨機分配一種方案,求第一次出現合法方案時試驗的期望次數,答案用既約分數表示。

 

【分析】

顯然,本題中的期望次數應該為出現合法方案的概率的倒數,則問題轉化為求出現合法方案的概率。而概率=,方案總數顯然為C(n*m,k),則問題轉化為求合法方案數。整理一下,現在的問題是:在n*m的棋盤上放k個棋子,求使得任意兩個棋子不相鄰的放置方案數。

 

這個題目的狀態壓縮模型是比較隱蔽的。觀察題目給出的規模,n、m≤80,這個規模要想用SC是困難的,若同樣用上例的狀態表示方法(放則為1,不放為0),280無論在時間還是在空間上都無法承受。然而我們還看到n*m≤80,這種給出資料規模的方法是不多見的,有什麼玄機呢?能把狀態數控制在可以承受的範圍嗎?稍微一思考,我們可以發現:9*9=81>80,即如果n,m都大於等於9,將不再滿足n*m≤80這一條件。所以,我們有n或m小於等於8,而28是可以承受的。我們假設m≤n(否則交換,由對稱性知結果不變)n是行數m是列數,則每行的狀態可以用m位的二進位制數表示。但是本題和例1又有不同:例1每行每列都只能放置一個棋子,而本題卻只限制每行每列的棋子不相鄰。但是,上例中列舉當前行的放置方案的做法依然可行。我們用陣列s[1..num]儲存一行中所有的num個放置方案,則s陣列可以在預處理過程中用DFS求出,同時用c[i]儲存第i個狀態中1的個數以避免重複計算。開始設計狀態。如註釋一所說,維數需要增加,原因在於並不是每一行只放一個棋子,也不是每一行都要求有棋子,原先的表示方法已經無法完整表達一個狀態。我們用f[i][j][k]表示第i行的狀態為s[j]且前i行已經放置了k個棋子的方案數。沿用列舉當前行方案的做法,只要當前行的方案和上一行的方案不衝突即可,“微觀”地講,即s[snum[i]]和s[snum[i-1]]沒有同為1的位,其中snum[x]表示第x行的狀態的編號。然而,雖然我們列舉了第i行的放置方案,但卻不知道其上一行(i-1)的方案。為了解決這個問題,我們不得不連第i-1的狀態一起列舉,則可以寫出遞推式:

f[0][1][0]=1;

f[i][j][k]=∑f[i-1][p][k-c[j]]

其中s[1]=0,即在當前行不放置棋子;j和p是需要列舉的兩個狀態編號,且要求s[j]與s[p]不衝突,即s[j]&s[p]=0。

當然,實現上仍有少許優化空間,例如第i行只和第i-1行有關,可以用滾動陣列節省空間。

有了合法方案數,剩下的問題就不是很困難了,需要注意的就只有C(n*m,k)可能超出64位整數範圍的問題,這可以通過邊計算邊用GCD約分來解決,具體可以參考附件中的程式碼。這個演算法時間複雜度O(n*pn*num2),空間複雜度(滾動陣列)O(pn*num),對於題目給定的規模是可以很快出解的。

 

 

 

通過上文的例題,讀者應該已經對狀態壓縮有了一些感性的認識。下面這個題目可以作為練習。

 

 

【例3】

在n*n(n≤10)的棋盤上放k個國王(可攻擊相鄰的8個格子),求使它們無法互相攻擊的方案數。

 

【分析】

其實有了前面幾個例子的分析,這個題目應該是可以獨立解決的。不過既然確實有疑問,那我們就來分析一下。

 

首先,你應該能想到將一行的狀態DFS出來(如果不能,請返回重新閱讀,謝謝),仍然設為s[1..num],同時仍然設有陣列c[1..num]記錄狀態對應的1的個數。和例2相同,仍然以f[i][j][k]表示第i行狀態為s[j],且前i行已經放置了k個棋子的方案數。遞推式仍然可以寫作:

f[0][1][0]=1;

f[i][j][k]=∑f[i-1][p][k-c[j]]

其中仍要求s[j]和s[p]不衝突。

可是問題出來了:這題不但要求不能行、列相鄰,甚至不能對角線相鄰!s[j]、s[p]不衝突怎麼“微觀地”表示呢?其實,稍微思考便可以得出方法:用s[p]分別和s[j]、s[j]*2、s[j]/2進行衝突判斷即可,原理很顯然。解決掉這唯一的問題,接下來的工作就沒有什麼難度了。演算法複雜度同例2。

 

下一個例題是狀態壓縮棋盤模型的經典題目,希望解決這個經典的題目能夠增長你的自信。

 

【例4】

給出一個n*m(n≤100,m≤10)的棋盤,一些格子不能放置棋子。求最多能在棋盤上放置多少個棋子,使得每一行每一列的任兩個棋子間至少有兩個空格。

【分析】

顯然,你應該已經有DFS搜出一行可能狀態的意識了(否則請重新閱讀之前的內容3遍,謝謝),依然設為s[1..num],依舊有c[1..num]儲存s中1的個數,依照例1的預處理搞定不能放置棋子的格子。

問題是,這個題目的狀態怎麼選?繼續像例2、3那樣似乎不行,原因在於棋子的攻擊範圍加大了。但是我們照葫蘆畫瓢:例2、3的攻擊範圍只有一格,所以我們的狀態中只需要有當前行的狀態即可進行遞推,而本題攻擊範圍是兩格,因此增加一維來表示上一行的狀態。用f[i][j][k]表示第i行狀態為s[j]、第i-1行狀態為s[k]時前i行至多能放置的棋子數,則狀態轉移方程很容易寫出:

f[i][j][k]=max{f[i-1][k][l]}+c[j]

其中要求s[j],s[k],s[l]互不衝突。

因為棋子攻擊範圍為兩格,可以直觀地想象到num不會很大。的確,由例2中得到的num的計算式並代入d=2、m=10,得到num=60。顯然演算法時間複雜度為O(n*num3),空間複雜度(滾動陣列)O(num2)。此演算法還有優化空間。我們分別列舉了三行的狀態,還需要對這三個狀態進行是否衝突的判斷,這勢必會重複列舉到一些衝突的狀態組合。我們可以在計算出s[1..num]後算出哪些狀態可以分別作為兩行的狀態,這樣在DP時就不需要進行盲目的列舉。這樣修改後的演算法理論上比上述演算法更優,但因為num本身很小,所以這樣修改沒有顯著地減少執行時間。值得一提的是,本題筆者的演算法雖然在理論上並不是最優,但由於位運算的使用,截至2月9日,筆者的程式在PKU OJ上長度最短,速度第二快。

這個題目是國內比賽中較早出現的狀態壓縮題。它告訴我們狀態壓縮不僅可以像前幾個例題那樣求方案數,而且可以求最優方案,即狀態壓縮思想既可以應用到遞推上(SCR),又可以應用到DP上(SCDP),更說明其有廣泛的應用空間。

 

 

看了這麼多棋盤模型應用狀態壓縮的例項,你可能會有疑問,難道狀態壓縮只在棋盤上放棋子的題目中有用?不是的。我們暫時轉移視線,來看看狀態壓縮在其他地方的應用——覆蓋模型

 

【例5】

給出n*m (1≤n、m≤11)的方格棋盤,用1*2的長方形骨牌不重疊地覆蓋這個棋盤,求覆蓋滿的方案數。

 

【分析】

這也是個經典的組合數學問題:多米諾骨牌完美覆蓋問題(或所謂二聚物問題)。有很多關於這個問題的結論,甚至還有個專門的公式:如果m、n中至少有一個是偶數,則結果=。這個公式形式比較簡單,且計算的複雜度是O()的,很高效。但是這個公式內還有三角函式,且中學生幾乎不可能理解,所以對我們能力的提高沒有任何幫助。用SCR演算法能較好地解決這個問題。

顯然,如果n、m都是奇數則無解(由棋盤面積的奇偶性知),否則必然有至少一個解(很容易構造出),所以假設n、m至少有一個偶數,且m≤n(否則交換)。我們依然像前面的例題一樣把每行的放置方案DFS出來,逐行計算。用f[i][s]表示把前i-1行覆蓋滿、第i行覆蓋狀態為s的覆蓋方案數。因為在第i行上放置的骨牌最多也只能影響到第i-1行,則容易得遞推式:

f[0][1…11]=1

f[i][s1]=∑f[i-1][s2]

其中(s1,s2)整體作為一個放置方案,可以把所有方案DFS預處理出來。下面討論一下本題的一些細節。

首先討論DFS的一些細節。對於當前行每一個位置,我們有3种放置方法:①豎直覆蓋,佔據當前格和上一行同一列的格;②水平覆蓋,佔據當前格和該行下一格;③不放置骨牌,直接空格。如何根據這些列舉出每個(s1,s2)呢?下面介紹兩種方法:

第一種:

DFS共5個引數,分別為:p(當前列號),s1、s2(當前行和上一行的覆蓋情況),b1、b2(上一列的放置對當前列上下兩行的影響,影響為1否則為0)。初始時s1=s2=b1=b2=0。①p=p+1,s1=s1*2+1,s2=s2*2,b1=b2=0;②p=p+1,s1=s1*2+1,s2=s2*2+1,b1=1,b2=0;③p=p+1,s1=s1*2,s2=s2*2+1,b1=b2=0。當p移出邊界且b1=b2=0時記錄此方案。

 

第二種:

觀察第一種方法,發現b2始終為0,知這種方法有一定的冗餘。換個更自然的方法,去掉引數b1、b2。①p=p+1,s1=s1*2+1,s2=s2*2;②p=p+2,s1=s1*4+3,s2=s2*4+3;③p=p+1,s1=s1*2,s2=s2*2+1。當p移出邊界時記錄此方案。這樣,我們通過改變p的移動距離成功簡化了DFS過程,而且這種方法更加自然。

 

DFS過程有了,實現方法卻還有值得討論的地方。前面的例題中,我們為什麼總是把放置方案DFS預處理儲存起來?是因為不合法的狀態太多,每次都重新DFS太浪費時間。然而回到這個題目,特別是當採用第二種時,我們的DFS過程中甚至只有一個判斷(遞迴邊界),說明根本沒有多少不合法的方案,也就沒有必要把所有方案儲存下來,對於每行都重新DFS即可,這不會增加執行時間卻可以節省一些記憶體。

 

這個演算法時間複雜度為多少呢?因為DFS時以兩行為物件,每行2m,共進行n次DFS,所以是O(n*4m)?根據“O”的上界意義來看並沒有錯,但這個界並不十分精確,也可能會使人誤以為本演算法無法通過1≤n、m≤11的測試資料,而實際上本演算法可以瞬間給出m=10,n=11時的解。為了計算精確的複雜度,必須先算出DFS得到的方案數。

考慮當前行的放置情況。如果每格只有①③兩個選擇,則應該有2m种放置方案;如果每格有①②③這3個選擇,且②中p只移動一格,則應該有3m种放置方案。然而現在的事實是:每格有①②③這3個選擇,但②中p移動2格,所以可以知道方案數應該在2m和3m之間。考慮第i列,則其必然是:第i-1列採用①③達到;第i-2列採用②達到。設h[i]表示前i列的方案數,則得到h[i]的遞推式:

h[0]=1,h[1]=2

h[i]=2*h[i-1]+h[i-2]

應用組合數學方法求得其通項公式h[m]=。注意到式子的第二項是多個絕對值小於1的數的乘積,其對整個h[m]的影響甚小,故略去,得到方案數h[m]≈0.85*2.414m,符合2m<h[m]<3m的預想。

 

因為總共進行了n次DFS,每次複雜度為O(h[m]),所以演算法總時間複雜度為O(n*h[m])=O(n*0.85*2.414m),對m=10,n=11不超時也就不足為奇了。應用滾動陣列,空間複雜度為O(2m)。

 

 

對於本題,我們已經有了公式和SCR兩種演算法。公式對於m*n不是很大的情況有效,SCR演算法在競賽中記不住公式時對小的m、n有效。如果棋盤規模為n*m(m≤10,n≤231-1),則公式和SCR都會嚴重超時。有沒有一個演算法能在1分鐘內解決問題呢?答案是肯定的,它仍然用到SC思想。

 

此演算法中應用到一個結論:給出一個圖的鄰接矩陣G(允許有自環,兩點間允許有多條路徑,此時G[i][j]表示i到j的邊的條數),則從某點a走k步到某點b的路徑數為Gk[a][b]。本結論實際上是通過遞推得到的,簡單證明如下:從i走k步到j,必然是從i走k-1步到t,然後從t走1步到j,根據加法原理,即G[k][i][j]=∑G[k-1][i][t]*G[t][j]。是否感到這個式子很眼熟?沒錯,它和矩陣乘法一模一樣,即:G[k]=G[k-1]*G。因為矩陣乘法滿足結合律,又由G[1]=G,所以我們得到結果:G[k]=Gk

下面介紹這個演算法。考慮一個有2m個頂點的圖,每個頂點表示一行的覆蓋狀態,即SCR演算法中的s1或s2。如果(s1,s2)為一個放置方案,則在s2和s1之間連一條(有向)邊,則我們通過DFS一次可以得到一個鄰接矩陣G。仍然按照逐行放置的思想來考慮,則要求我們每行選擇一個覆蓋狀態,且相鄰兩行的覆蓋狀態(s1,s2)應為一個放置方案,一共有n行,則要求選擇n個狀態,在圖中考慮,則要求我們從初始(第0行)頂點(1...111)n步走到(1…111),因為圖的鄰接矩陣是DFS出來的,每條邊都對應一個放置方案,所以可以保證走的每條邊都合法。因此,我們要求的就是頂點(1…111)走n步到達(1…111)的路徑條數。由上面的結論知,本題的答案就是Gn[1…111][1…111]。

現在的問題是,如何計算G的n次冪?連續O(n)次矩陣乘法嗎?不可取。矩陣的規模是2m*2m,一次普通矩陣乘法要O((2m)3)=O(8m),O(n)次就是O(n*8m),比SCR演算法還要差得多。其實我們可以借用二分的思想。如果要計算38的值,你會怎麼算呢?直接累乘將需要進行7次乘法。一種較簡單的方法是:3*3=32,32*32=34,34*34=38,只進行了3次乘法,效率高了許多。因為矩陣乘法滿足結合律,所以可以用同樣的思路進行優化。這種思路用遞迴來實現是非常自然的,然而,本題的矩陣中可能有210*210=220=1048576個元素,如果用(未經優化的)遞迴來實現,將可能出現堆疊溢位。不過慶幸的是我們可以非遞迴實現。用bin[]儲存n的二進位制的每一位,從最高位、矩陣G開始,如果bin[當前位]為0,則把上一位得到的矩陣平方;如果為1,則平方後再乘以G。這種方法的時間複雜度容易算出:O(logn) 次矩陣乘法,每次O(8m),共O(8m*logn)。

這樣對於m≤7就可以很快出解了。但對於m=n=8,上述演算法都需要1s才能出解,無法令人滿意。此演算法還有優化空間。

我們的矩陣規模高達2m*2m=4m,但是其中有用的(非0的)有多少個呢?根據介紹SCR演算法時得到的h[m]計算式,G中有4m-h[m]=4m-0.85*2.414m個0,對於m=8,可以算出G中98.5%的元素都是0,這是一個非常非常稀疏的矩陣,使用三次方的矩陣乘法有點大材小用。我們改變矩陣的儲存結構,即第p行第q列的值為value的元素可以用一個三元組(p,q,value)來表示,採用一個線性表依行列順序來儲存這些非0元素。怎樣對這樣的矩陣進行乘法呢?觀察矩陣乘法的計算式,當a[i][k]或者b[k][j]為0時,結果為0,對結果沒有影響,完全可以略去這種沒有意義的運算。則得到計算稀疏矩陣乘法的演算法:列舉a中的非0元素,設為(p,q,v1),在b中尋找所有行號為q的非0元素(q,r,v2),並把v1*v2的值累加到c[p][r]中。這個演算法多次用到一個操作:找出所有行號為q的元素,則可以給矩陣附加一個陣列hp[q],表示線性表中第一個行號為q的元素的位置,若不存在則hp[q]=0。算出二維陣列c之後再對其進行壓縮儲存即可。此矩陣乘法的時間複雜度為O(),在最壞情況下,a.not0=b.not0=4m,演算法的複雜度為O(8m),和經典演算法相同。因為矩陣非常稀疏,演算法複雜度近似為O(4m) 。考慮整個演算法的時間複雜度:O(logn)次矩陣乘法,每次O(4m),則總時間複雜度O(logn*4m),對於m≤9也可以很快出解了,對於m=10,n=2147483647,此演算法在筆者機器上(Pm 1.6G,512M)執行時間少於20s。雖然仍然不夠理想,但已經不再超時數小時。此演算法空間複雜度為O(max_not0+4m),對於m=10,max_not0小於190000。

 

 

以上給出了公式、SCR、矩陣乘方這3個演算法,分別適用於不同的情況,本題基本解決。

 

 

 

讀者應該已經注意到,覆蓋模型和棋盤模型有很多共同點,譬如都是在矩形某些位置放入棋子(或某種形狀的骨牌)來求方案數(如上例)或最優解(下面將會給出幾個例題)。但不難看出,覆蓋模型和棋盤模型又有著很大的不同:棋盤模型中,只要棋子所在的位置不被別的棋子攻擊到即可,而覆蓋模型中,棋子的攻擊範圍也不可以重疊。所以簡單來說,覆蓋模型就是攻擊範圍也不能重疊的棋盤模型。下面再給出一個與上例類似的覆蓋模型的例題以加深印象。

 

 

 

【例6】

給出n*m (1≤n、m≤9)的方格棋盤,用1*2的矩形的骨牌和L形的(2*2的去掉一個角)骨牌不重疊地覆蓋,求覆蓋滿的方案數。

 

【分析】

觀察題目條件,只不過是比例5多了一種L形的骨牌,因此很自然地順著例5的思路走。本題中兩種骨牌的最大長度和例5一樣,所以仍然用f[i][s]表示把前i-1行覆蓋滿、第i行覆蓋狀態為s的覆蓋方案數,得到的遞推式和例5完全一樣:

f[0][1…11]=1

f[i][s1]=∑f[i-1][s2]

其中(s1,s2)整體作為一個放置方案。例5中有兩種DFS方案,其中第二種實現起來較第一種簡單。但在本題中,新增的L形骨牌讓第二種DFS難以實現,在例5中看起來有些笨拙的第一種DFS方案在本題卻可以派上用場。回顧第一種DFS,我們有5個引數,分別為:p(當前列號),s1、s2(當前行和對應的上一行的覆蓋情況),b1、b2(上一列的放置對當前列兩行的影響,影響為1否則為0)。本題中,可選擇的方案增多,故列表給出:

 

 

覆蓋情況

條件

引數s變化

引數b變化

1

0 0

0 0

s1=s1*2+b1

s2=s2*2+1-b2

b1=0

b2=0

2

0 0

1 1

b1=0

s1=s1*2+1

s2=s2*2+1-b2

b1=1

b2=0

3

1 0

1 0

b1=0

b2=0

s1=s1*2+1

s2=s2*2

b1=0

b2=0

4

1 0

1 1

b1=0

b2=0

s1=s1*2+1

s2=s2*2

b1=1

b2=0

5

0 1

1 1

b1=0

s1=s1*2+1

s2=s2*2+1-b2

b1=1

b2=1

6

1 1

0 1

b2=0

s1=s1*2+b1

s2=s2*2

b1=1

b2=1

7

1 1

1 0

b1=0

b2=0

s1=s1*2+1

s2=s2*2

b1=0

b2=1

 

容易看出,在本題中此種DFS方式實現很簡單。考慮其複雜度,因為L形骨牌不太規則,筆者沒能找到一維的方案數的遞推公式,因此無法給出複雜度的解析式。但當m=9時,演算法共生成放置方案79248個,則對於n=m=9,演算法的複雜度為O(9*79248),可以瞬間出解。和上例一樣,本題也沒有必要儲存所有放置方案,也避免MLE。

 

那麼,對於本題是否可以應用上題的矩陣演算法呢?答案是肯定的,方法也類似,複雜度為O(8m*logn)。然而,對於本題卻不能通過上題的稀疏矩陣演算法加速,原因在於剛開始時矩陣中只有1-79248/49=70%的0,而運算結束後整個矩陣中只有2個0,根本無法達到加速效果。

 

由於有上題的鋪墊,基本相同的本題也很快得到了解決。

 

 

【例7】

給出n*m(n,m≤10)的方格棋盤,用1*r的長方形骨牌不重疊地覆蓋這個棋盤,求覆蓋滿的方案數。

【分析】

本題是例5的直接擴充套件。如果說例5中公式比SCR好,本題可以指出當公式未知時SCR依然是可行的演算法。直接思考不容易發現方法,我們先考慮r=3時的情況。首先,此問題有解當且僅當m或n能被3整除。更一般的結論是:用1*r的骨牌覆蓋滿m*n的棋盤,則問題有解當且僅當m或n能被r整除。當r=2時,則對應於例5中m、n至少有一個是偶數的條件。此結論的組合學證明從略。

不同於例5,1*3骨牌的“攻擊範圍”已經達到了3行,可以想象例5中的表示方法已經無法正確表示所有狀態,但其思路依然可以沿用。例5中用f[i][s]表示把前i-1行覆蓋滿、第i行覆蓋狀態為s的覆蓋方案數,是因為當前行的放置方案至多能影響到上一行,狀態中只要包含一行的覆蓋狀態即可消除後效性。本題中當前行的放置方案可以影響到上兩行,故可以想到應儲存兩行的覆蓋狀態以消除後效性,即增加一維,用f[i][s1][s2]表示把前i-2行覆蓋滿、第i-1行覆蓋狀態為s1、第i行覆蓋狀態為s2的覆蓋方案數。先不論上述表示方法是否可行(答案是肯定的),r=2時狀態有2維,r=3時有3維,推廣後狀態變數居然有r維,這樣的方法不具有推廣價值,而且空間複雜度也太高。

仔細分析上述方案,可以發現其失敗之處。s1的第p位s1p為1(覆蓋)時,s2p是不可能為0的(要求覆蓋滿),則這兩位(s1p, s2p)的(0,0),(0,1),(1,0),(1,1)四種組合中有一種不合法,而上述狀態表示方法卻冗餘地儲存了這個組合,造成空間複雜度過高,也進行了多餘的計算。通過上面的討論可以知道,每一位只有3種狀態,引導我們使用三進位制。我們用f[i][s]表示把前i-2行覆蓋滿、第i-1和第i行覆蓋狀態為s的覆蓋方案數,但這裡狀態s不再是二進位制,而是三進位制:sp=0表示s1p=s2p=0;sp=1表示s1p=0,s2p=1;sp=2表示s1p=s2p=1。這樣,我們就只保留了必要的狀態,空間和時間上都有了改進。當r=4時,可以類推,用四進製表示三行的狀態,r=5時用五進位制……分別寫出r=2,3,4,5的程式,進行歸納,統一DFS的形式,可以把DFS(p,s1,s2)分為兩部分:①for i=0 to r-1 do DFS(p+1,s1*r+i,s2*r+(i+1)mod r);②DFS(p+r,s1*rr+rr-1,s2*rr+rr-1) 問題解決。但DFS的這種分部方法是我們歸納猜想得到的,並沒有什麼道理,其正確性無法保證,我們能否通過某種途徑證明它的正確性呢?仍以r=3為例。根據上面的討論,sp取值0到2,表示兩行第p位的狀態,但sp並沒有明確的定義。我們定義sp為這兩行的第p位從上面一行開始向下連續的1的個數,這樣的定義可以很容易地遞推,遞推式同上兩例沒有任何改變,卻使得上述DFS方法變得很自然。

分析演算法的時間複雜度,同例5一樣需要用到DFS出的方案個數h[m],並且仿照例5中h[m]的遞推式,我們可以得到:

h[i]=ri (i=0~r-1)

h[j]=r*h[j-1]+h[j-r] (j=r~m)

理論上我們可以根據遞推式得到例5中那樣的精確的通項公式,但需要解高於三次的方程,且根多數為複數,無法得到例5那樣簡單優美的表示式,這裡僅給出r=2..5,m=10時h[m]的值依次為:5741,77772,1077334,2609585,9784376。對於推廣後的問題,例5的矩陣演算法依然可行,但此時空間將是一個瓶頸。

 

相關文章