1. 程式人生 > >演算法之動態規劃總結

演算法之動態規劃總結

五、動態規劃題集整理


一、動態規劃初探       1、遞推       暫且先不說動態規劃是怎麼樣一個演算法,由最簡單的遞推問題說起應該是最恰當不過得了。因為一來,遞推的思想非常淺顯,從初中開始就已經有涉及,等差數列 f[i] = f[i-1] + d( i > 0, d為公差,f[0]為初項)就是最簡單的遞推公式之一;二來,遞推作為動態規劃的基本方法,對理解動態規劃起著至關重要的作用。理論的開始總是枯燥的,所以讓讀者提前進入思考是最能引起讀者興趣的利器,於是【例題1】應運而生。      【例題1】在一個3 X N的長方形方格中,鋪滿1X2的骨牌(骨牌個數不限制),給定N,求方案數(圖一 -1-1為N=2的所有方案),所以N=2時方案數為3。
圖一 -1-1        這是一個經典的遞推問題,如果覺得無從下手,我們可以來看一個更加簡單的問題,把問題中的“3”變成“2”(即在一個2XN的長方形方格中鋪滿1X2的骨牌的方案)。這樣問題就簡單很多了,我們用f[i]表示2Xi的方格鋪滿骨牌的方案數,那麼考慮第i列,要麼豎著放置一個骨牌;要麼連同i-1列,橫著放置兩個骨牌,如圖2所示。由於骨牌的長度為1X2,所以在第i列放置的骨牌無法影響到第i-2列。很顯然,圖一 -1-2中兩塊黑色的部分分別表示f[i-1]和f[i-2],所以可以得到遞推式f[i] = f[i-1] + f[i-2] (i >= 2),並且邊界條件f[0] = f[1] = 1。
圖一 -1-2       再回頭來看3 X N的情況,首先可以明確當N等於奇數的時候,方案數一定為0。所以如果用f[i] (i 為偶數) 表示3Xi的方格鋪滿骨牌的方案數,f[i]的方案數不可能由f[i-1]遞推而來。那麼我們猜想f[i]和f[i-2]一定是有關係的,如圖一 -1-3所示,我們把第i列和第i-1列用1X2的骨牌填滿後,輕易轉化成了f[i-2]的問題,那是不是代表f[i] = 3*f[i-2]呢?
圖一 -1-3       仔細想想才發現不對,原因是我們少考慮了圖一 -1-4的情況,這些情況用圖一 -1-3的情況無法表示,再填充完黑色區域後,發現和f[i-4]也有關係,但是還是漏掉了一些情況。
圖一 -1-4       上面的問題說明我們在設計狀態(狀態在動態規劃中是個很重要的概念,在本章的第4小節會進行介紹總結)的時候的思維定式,當一維的狀態已經無法滿足我們的需求時,我們可以試著增加一維,用二維來表示狀態,用f[i][j]表示(3 X i) + j個多餘塊的擺放方案數,如圖一 -1-5所示: 圖一 -1-5       轉化成二維後,我們可以輕易寫出三種情況的遞推式,具體推導方法見圖一 -1-6。       f[i][0] = f[i-2][0] + f[i-1][1] + f[i-2][2]         f[i][1] = f[i-1][2]       f[i][2] = f[i][0] + f[i-1][1]       邊界條件     f[0][0] = f[1][1] = f[0][2] = 1 圖一 -1-6        如果N不是很大的情況,到這一步,我們的問題已經完美解決了,其實並不需要求它的通項公式,因為我們是程式猿,一個for迴圈就能搞定了 <*_*>,接下來的求解就全仰仗於計算機來完成了。       【例題2】對一個“01”串進行一次μ變換被定義為:將其中的"0"變成"10","1"變成"01",初始串為"1",求經過N(N <= 1000)次μ變換後的串中有多少對"00"(有沒有人會糾結會不會出現"000"的情況?這個請放心,由於問題的特殊性,不會出現"000"的情況)。圖一 -1-7表示經過小於4次變換時串的情況。 圖一 -1-7        如果純模擬的話,每次μ變換串的長度都會加倍,所以時間和空間複雜度都是O(2^n),對於n為1000的情況,完全不可能計算出來。仔細觀察這個樹形結構,可以發現要出現"00",一定是"10"和"01"相鄰產生的。為了將問題簡化,我們不妨設A = "10", B = "01",構造出的樹形遞推圖如圖一 -1-8所示,如果要出現"00",一定是AB("1001")。        令FA[i]為A經過i次μ變換後"00"的數量,FA[0] = 0;FB[i]為B經過i次μ變換後"00"的數量,FB[0] = 0。        從圖中觀察得出,以A為根的樹,它的左子樹的最右端點一定是B,也就是說無論經過多少次變換,兩棵子樹的交界處都不可能產生AB,所以FA[i] = FB[i-1] + FA[i-1](直接累加兩棵子樹的"00"的數量);而以B為根的樹,它的左子樹的右端點一定是A,而右子樹的左端點呈BABABA...交替排布,所以隔代產生一次AB,於是FB[i] = FA[i-1] + FB[i-1] + (i mod 2) 。最後要求的答案就是FB[N-1],遞推求解。 圖一 -1-8      2、記憶化搜尋       遞推說白了就是在知道前i-1項的值的前提下,計算第i項的值,而記憶化搜尋則是另外一種思路。它是直接計算第i項,需要用到第 j 項的值( j < i)時去查表,如果表裡已經有第 j 項的話,則直接取出來用,否則遞迴計算第 j 項,並且在計算完畢後把值記錄在表中。記憶化搜尋在求解多維的情況下比遞推更加方便,【例題3】是我遇到的第一個記憶化搜尋的問題,記憶猶新。       【例題3】這個問題直接給出了一段求函式w(a, b, c)的虛擬碼:       function w(a, b, c):        if a <=or b <=or c <=0, then returns:1        if a >20or b >20or c >20, then returns: w(20,20,20)        if a < b and b < c, then returns: w(a, b, c-1)+ w(a, b-1, c-1)- w(a, b-1, c)            otherwise it returns: w(a-1, b, c)+ w(a-1, b-1, c)+ w(a-1, b, c-1)       要求給定a, b, c,求w(a, b, c)的值。       乍看下只要將虛擬碼翻譯成實際程式碼,然後直接對於給定的a, b, c,呼叫函式w(a, b, c)就能得到值了。但是隻要稍加分析就能看出這個函式的時間複雜度是指數級的(儘管這個三元組的最大元素只有20,這是個陷阱)。對於任意一個三元組(a, b, c),w(a, b, c)可能被計算多次,而對於固定的(a, b, c),w(a, b, c)其實是個固定的值,沒必要多次計算,所以只要將計算過的值儲存在f[a][b][c]中,整個計算就只有一次了,總的時間複雜度就是O(n^3),這個問題的n只有20。      3、狀態和狀態轉移            在介紹遞推和記憶化搜尋的時候,都會涉及到一個詞---狀態,它表示瞭解決某一問題的中間結果,這是一個比較抽象的概念,例如【例題1】中的f[i][j],【例題2】中的FA[i]、FB[i],【例題3】中的f[a][b][c],無論是遞推還是記憶化搜尋,首先要設計出合適的狀態,然後通過狀態的特徵建立狀態轉移方程(f[i] = f[i-1] + f[i-2] 就是一個簡單的狀態轉移方程)。      4、最優化原理和最優子結構
在介如果問題的最優解包含的子問題的解也是最優的,就稱該問題具有最有子結構,即滿足最優化原理。這裡我盡力減少理論化的概念,而改用一個簡單的例題來加深對這句話的理解。      【例題4】給定一個長度為n(1 <= n <= 1000)的整數序列a[i],求它的一個子序列(子序列即在原序列任意位置刪除0或多個元素後的序列),滿足如下條件:       1、該序列單調遞增;       2、在所有滿足條件1的序列中長度是最長的;       這個問題是經典的動態規劃問題,被稱為最長單調子序列。 我們假設現在沒有任何動態規劃的基礎,那麼看到這個問題首先想到的是什麼? 我想到的是萬金油演算法---列舉(DFS),即列舉a[i]這個元素取或不取,所有取的元素組成一個合法的子序列,列舉的時候需要滿足單調遞增這個限制,那麼對於一個n個元素的序列,最壞時間複雜度自然就是O(2n),n等於30就已經很變態了更別說是1000。但是方向是對的,動態規劃求解之前先試想一下搜尋的正確性,這裡搜尋的正確性是很顯然的,因為已經枚舉了所有情況,總有一種情況是我們要求的解。我們嘗試將搜尋的演算法進行一些改進,假設第i個數取的情況下已經搜尋出的最大長度記錄在陣列d中,即用d[i]表示當前搜尋到的以a[i]結尾的最長單調子序列的長度,那麼如果下次搜尋得到的序列長度小於等於d[i],就不必往下搜尋了(因為即便繼續往後列舉,能夠得到的解必定不會比之前更長);反之,則需要更新d[i]的值。如圖一-4-1,紅色路徑表示第一次搜尋得到的一個最長子序列1、2、3、5,藍色路徑表示第二次搜尋,當列舉第3個元素取的情況時,發現以第3個數結尾的最長長度d[3] = 3,比本次列舉的長度要大(本次列舉的長度為2),所以放棄往下列舉,大大減少了搜尋的狀態空間。 圖一-4-1 這時候,我們其實已經不經意間設計好了狀態,就是上文中提到的那個d[i]陣列,它表示的是以a[i]結尾的最長單調子序列的長度,那麼對於任意的i,d[i] 一定等於 d[j] + 1 ( j < i ),而且還得滿足 a[j] < a[i]。因為這裡的d[i]表示的是最長長度,所以d[i]的表示式可以更加明確,即: d[i] = max{ d[j] | j < i && a[j] < a[i] } + 1 這個表示式很好的闡釋了最優化原理,其中d[j]作為d[i]的子問題,d[i]最長(優)當且僅當d[j]最長(優)。當然,這個方程就是這個問題的狀態轉移方程。狀態總數量O(n), 每次轉移需要用到前i項的結果,平攤下來也是O(n)的,所以該問題的時間複雜度是O(n^2),然而它並不是求解這類問題的最優解,下文會提到最長單調子序列的O(nlogn)的優化演算法。
      5、決策和無後效性
一個狀態演變到另一個狀態,往往是通過“決策”來進行的。有了“決策”,就會有狀態轉移。而 無後效性,就是一旦某個狀態確定後,它之前的狀態無法對它之後的狀態產生“效應”(影響)。 【例題5】老王想在未來的n年內每年都持有電腦,m(y, z)表示第y年到第z年的電腦維護費用,其中y的範圍為[1, n],z的範圍為[y, n],c表示買一臺新的電腦的固定費用。 給定矩陣m,固定費用c,求在未來n年都有電腦的最少花費。 考慮第 i 年是否要換電腦,換和不換是不一樣的決策,那麼我們定義一個二元組(a, b),其中 a < b,它表示了第a年和第b年都要換電腦(第a年和第b年之間不再換電腦),如果假設我們到第a年為止換電腦的最優方案已經確定,那麼第a年以前如何換電腦的一些列步驟變得不再重要,因為它並不會影響第b年的情況,這就是無後效性。 更加具體得,令d[i]表示在第i年買了一臺電腦的最小花費(由於這臺電腦能用多久不確定,所以第i年的維護費用暫時不計在這裡面),如果上一次更換電腦的時間在第j年,那麼第j年更換電腦到第i年之前的總開銷就是c + m(j, i-1),於是有狀態轉移方程: d[i] = min{ d[j] + m(j, i-1) |  1 <=  j < i  }  + c 這裡的d[i]並不是最後問題的解,因為它漏算了第i年到第n年的維護費用,所以最後問題的答案: ans  =  min{ d[i] + m(i, n)  | 1 <= i < n } 我們發現兩個方程看起來很類似,其實是可以合併的,我們可以假設第n+1年必須換電腦,並且第n+1年換電腦的費用為0,那麼整個階段的狀態轉移方程就是: d[i] = min{ d[j] + m(j, i-1) | 1 <= j < i } + w(i)    其中w(i) = (i==n+1)?0:c; d[n+1]就是我們需要求的最小費用了。 二、動態規劃的經典模型
      1、線性模型        線性模型的是動態規劃中最常用的模型,上文講到的最長單調子序列就是經典的線性模型,這裡的線性指的是狀態的排布是呈線性的。【例題6】是一個經典的面試題,我們將它作為線性模型的敲門磚。 【例題6】在一個夜黑風高的晚上,有n(n <= 50)個小朋友在橋的這邊,現在他們需要過橋,但是由於橋很窄,每次只允許不大於兩人通過,他們只有一個手電筒,所以每次過橋的兩個人需要把手電筒帶回來,i號小朋友過橋的時間為T[i],兩個人過橋的總時間為二者中時間長者。問所有小朋友過橋的總時間最短是多少。 圖二-1-1 每次過橋的時候最多兩個人,如果橋這邊還有人,那麼還得回來一個人(送手電筒), 也就是說N個人過橋的次數為2*N-3(倒推,當橋這邊只剩兩個人時只需要一次,三個人的情況為來回一次後加上兩個人的情況...)。 有一個人需要來回跑,將手電筒送回來(也許不是同一個人,realy?!) 這個回來的時間是沒辦法省去的,並且回來的次數也是確定的,為N-2,如果是我,我會選擇讓跑的最快的人來幹這件事情,但是我錯了... 如果總是跑得最快的人跑回來的話,那麼他在每次別人過橋的時候一定得跟過去,於是就變成就是很簡單的問題了, 花費的總時間:        T =  minPTime * (N-2) + (totalSum-minPTime) 來看一組資料 四個人過橋花費的時間分別為 1 2 5 10,按照上面的公式答案是19,但是實際答案應該是17。 具體步驟是這樣的: 第一步:1和2過去,花費時間2,然後1回來(花費時間1); 第二歩:3和4過去,花費時間10,然後2回來(花費時間2);       第三部:1和2過去,花費時間2,總耗時17。 所以之前的貪心想法是不對的。        我們先將所有人按花費時間遞增進行排序, 假設前i個人過河花費的最少時間為opt[i], 那麼考慮前i-1個人過河的情況,即河這邊還有1個人,河那邊有i-1個人,並且這時候手電筒肯定在對岸,所以        opt[i] = opt[i-1] + a[1] + a[i]        (讓花費時間最少的人把手電筒送過來,然後和第i個人一起過河)        如果河這邊還有兩個人,一個是第i號,另外一個無所謂,河那邊有i-2個人,並且手電筒肯定在對岸,所以       opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2]    (讓花費時間最少的人把電筒送過來,然後第i個人和另外一個人一起過河,由於花費時間最少的人在這邊,所以下一次送手電筒過來的一定是花費次少的,送過來後花費最少的和花費次少的一起過河,解決問題)        所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }        2、區間模型 區間模型的狀態表示一般為d[i][j],表示區間[i, j]上的最優解,然後通過狀態轉移計算出[i+1, j]或者[i, j+1]上的最優解,逐步擴大區間的範圍,最終求得[1, len]的最優解。 【例題7】給定一個長度為n(n <= 1000)的字串A,求插入最少多少個字元使得它變成一個迴文串。 典型的區間模型,迴文串擁有很明顯的子結構特徵,即當字串X是一個迴文串時,在X兩邊各新增一個字元'a'後,aXa仍然是一個迴文串,我們用d[i][j]來表示A[i...j]這個子串變成迴文串所需要新增的最少的字元數,那麼對於A[i] == A[j]的情況,很明顯有 d[i][j] = d[i+1][j-1] (這裡需要明確一點,當i+1 > j-1時也是有意義的,它代表的是空串,空串也是一個迴文串,所以這種情況下d[i+1][j-1] = 0);當A[i] != A[j]時,我們將它變成更小的子問題求解,我們有兩種決策: 1、在A[j]後面新增一個字元A[i]; 2、在A[i]前面新增一個字元A[j]; 根據兩種決策列出狀態轉移方程為:             d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1;                (每次狀態轉移,區間長度增加1) 空間複雜度O(n^2),時間複雜度O(n^2), 下文會提到將空間複雜度降為O(n)的優化演算法。 3、揹包模型 揹包問題是動態規劃中一個最典型的問題之一。由於網上有非常詳盡的揹包講解 這裡只將常用部分抽出來,具體推導過程詳見 a.0/1揹包 有N種物品(每種物品1件)和一個容量為V的揹包。放入第 i 種物品耗費的空間是Ci,得到 的價值是Wi。求解將哪些物品裝入揹包可使價值總和最大。 f[i][v]表示前i種物品恰好放入一個容量為v的揹包可以獲得的最大價值。 決策為第i個物品在前i-1個物品放置完畢後,是選擇放還是不放,狀態轉移方程為:  f[i][v] = max{ f[i-1][v], f[i-1][v - Ci] +Wi } 時間複雜度O(VN),空間複雜度O(VN) (空間複雜度可利用滾動陣列進行優化達到O(V),下文會介紹滾動陣列優化)。 b.完全揹包 有N種物品(每種物品無限件)和一個容量為V的揹包。放入第 i 種物品耗費的空間是Ci,得到 的價值是Wi。求解將哪些物品裝入揹包可使價值總和最大。 f[i][v]表示前i種物品恰好放入一個容量為v的揹包可以獲得的最大價值。 f[i][v] = max{ f[i-1][v - kCi] + kWi  | 0 <= k <= v/Ci  }        (當k的取值為0,1時,這就是01揹包的狀態轉移方程) 時間複雜度O( VNsum{V/Ci} ),空間複雜度在用滾動陣列優化後可以達到 O( V )。 進行優化後(此處省略500字),狀態轉移方程變成: f[i][v] = max{ f[i-1][v],  f[i][v - Ci] +Wi }    時間複雜度降為 O(VN)。 c.多重揹包                有N種物品(每種物品Mi件)和一個容量為V的揹包。放入第i種物品耗費的空間是Ci,得到 的價值是Wi。求解將哪些物品裝入揹包可使價值總和最大。 f[i][v]表示前i種物品恰好放入一個容量為v的揹包可以獲得的最大價值。 f[i][v] = max{ f[i-1][v - kCi] + kWi  | 0 <= k <= Mi } 時間複雜度O( Vsum(Mi) ), 空間複雜度仍然可以用滾動陣列優化後可以達到 O( V )。                優化:採用二進位制拆分物品,將Mi個物品拆分成容量為1、2、4、8、... 2^k、Mi-( 2^(k+1) - 1 ) 個對應價值為Wi、2Wi、4Wi、8Wi、...、2^kWi、( Mi-( 2^(k+1) - 1 ) )Wi的物品,然後採用01揹包求解。 這樣做的時間複雜度降為O(Vsum(logMi) )。 【例題8】一群強盜想要搶劫銀行,總共N(N <= 100)個銀行,第i個銀行的資金為Bi億,搶劫該銀行被抓概率Pi,問在被抓概率小於p的情況下能夠搶劫的最大資金是多少? p表示的是強盜在搶銀行時至少有一次被抓概率的上限,那麼選擇一些銀行,並且計算搶劫這些銀行都不被抓的的概率pc,則需要滿足1 - pc < p。這裡的pc是所有選出來的銀行的搶劫時不被抓概率(即1 - Pi)的乘積,於是我們用資金作為揹包物品的容量,概率作為揹包物品的價值,求01揹包。狀態轉移方程為: f[j] = max{ f[j], f[j - pack[i].B] * (1-pack[i].p) } 最後得到的f[i]表示的是搶劫到 i 億資金的最大不被抓概率。令所有銀行資金總和為V,那麼從V-0進行列舉,第一個滿足1 - f[i] < p的i就是我們所要求的被抓概率小於p的最大資金。 4、狀態壓縮模型 狀態壓縮的動態規劃,一般處理的是資料規模較小的問題,將狀態壓縮成k進位制的整數,k取2時最為常見。 【例題9】 對於一條n (n <= 11)個點的哈密爾頓路徑C1C2...CN(經過每個點一次的路徑)的值由三部分組成:       1、每個頂點的權值Vi的和       2、對於路徑上相鄰的任意兩個頂點CiCi+1,累加權值乘積 Vi*Vi+1       3、對於相鄰的三個頂點CiCi+1Ci+2,如果Ci和Ci+2之間有邊,那麼累加權值三乘積 Vi*Vi+1*Vi+2       求值最大的哈密爾頓路徑的權值 和 這樣的路徑的個數。 列舉所有路徑,判斷找出值最大的,複雜度為O(n!),取締! 由於點數較少,採用二進位制表示狀態,用d[i][j][k]表示某條哈密爾頓路徑的最大權值,其中i是一個二進位制整數,它的第t位為1表示t這個頂點在這條哈密爾頓路徑上,為0表示不在路徑上。j和k分別為路徑的最後兩個頂點。那麼圖二-4-1表示的狀態就是: d[(11101111)2][7][1] 圖二-4-1       明確了狀態表示,那麼我們假設02356這5個點中和7直接相連的是i,於是就轉化成了子問題...->j -> i -> 7,我們可以列舉i = 0, 2, 3, 5, 6。 直接給出狀態轉移方程: d[i][j][k] = max{ d[i ^ (1<<k)][t][j] + w(t, j, k)   |   (i & (1<<t)) != 0 }  這裡用到了幾個位運算:i ^ (1<<k)表示將i的二進位制的第k位從1變成0,i & (1<<t)則為判斷i的二進位制表示的第t位是否為1,即該路徑中是否存在t這個點。這個狀態轉移的實質就是將原本的 ...->j -> k 轉化成更加小規模的去掉k點後的子問題 ... -> t -> j 求解。而w(t, j, k)則表示 t->j->k這條子路徑上產生的權值和,這個可以由定義在O(1)的時間計算出來。 d[ (1<<j) | (1<<k) ][j][k] 為所有的兩個點的路徑的最大值,即最小的子問題。這個問題的狀態並非線性的,所以用記憶化搜尋來求解狀態的值會事半功倍。       【例題10】 方塊A方塊B
利用以上兩種積木(任意數量,可進行旋轉和翻轉),拼出一個m*n( 1<= m <= 9, 1 <= n <= 9 )的矩形,問這樣的方式有多少種。如m = 2, n = 3的情況,有以下5種拼接方式:
圖二-4-2 經典問題,2進位制狀態壓縮。有固定套路,就不糾結是怎麼想出來的了, 反正第一次看到這種問題我是想不出來,你呢?但是照例還是得引導一下。       如果問題不是求放滿的方案數,而是求前M-1行放滿,並且第M行的奇數格放上骨牌而偶數格不放 或者 第M行有一個格子留空 或者 第M行的首尾兩個格子留空,求方案數(這是三個問題,分別對應圖二-4-3的三個圖)。這樣的問題可以出一籮筐了,因為第M行的情況總共有2^n,按照這個思路下去,我們發現第i (1 <= i <= m)行的狀態頂多也就2^n 種,這裡的狀態可以用一個二進位制整數來表示,對於第i行,如果這一行的第j個格子被骨牌填充則這個二進位制整數的第j位為1,否則為0。 圖二-4-3中的三個圖的第M行狀態可以分別表示為(101010) 2、(110111) 2、(011110) 2,那麼如果我們已知第i行的狀態k對應的方案數,並且狀態k放置幾個骨牌後能夠將i+1行的狀態變成k',那麼第i+1行的k'這個狀態的方案數必然包含了第i行的狀態k的方案數,這個放置骨牌的過程就是狀態轉移。 圖二-4-3 用一個二維陣列DP[i][j] 表示第i行的狀態為j的骨牌放置方案數(其中 1<=i<=m, 0 <= j < 2n),為了將問題簡化,我們虛擬出一個第0行,則有DP[0][j] = (j ==  2n -1) ? 1 : 0;這個就是我們的初始狀態,它的含義是這樣的,因為第0行是我們虛擬出來的,所以第0行只有完全放滿的時候才有意義,也就是第0行全部放滿(狀態的二進位制表示全為1,即十進位制表示的 2n -1  )的方案數為1,其它情況均為0。 那麼如何進行狀態轉移呢?假設第3行的某個狀態(101000)2的方案數DP[3][(101000)2 ] = 5,如圖二-4-4所示: 圖二-4-4 我們需要做的就是通過各種方法將第3行填滿,從而得到一系列第4行可能的狀態集合S,並且對於每一個在S中的狀態s,執行DP[4][s] += DP[3][(101000)2 ](兩個狀態可達,所以方案數是可傳遞的,又因為多個不同的狀態可能到達同一個狀態,所以採用累加的方式)。 根據給定的骨牌,我們可以列舉它的擺放方式,圖二-4-5展示了三種骨牌的擺放方式以及能夠轉移到的狀態,但是這裡的狀態轉移還沒結束,因為第3行尚未放滿,問題求的是將整個棋盤鋪滿的方案數,所以只有當第i行全部放滿後,才能將狀態轉移給i+1行。 圖二-4-5 列舉擺放的這一步可以採用dfs遞迴列舉列,遞迴出口為列數col == N時。dfs函式的原型可以寫成如下的形式: void dfs( int col, int nextrow, int nowstate, int nextstate, LL cnt); // col       表示當前列舉到的列編號 // nextrow   表示下一行的行編號 // nowstate  表示當前列舉骨牌擺放時第i  行的狀態(隨著放置骨後會更新) // nextstate 表示當前列舉骨牌擺放時第i+1行的狀態(隨著放置骨後會更新) // cnt       狀態轉移前的方案數,即第i行在放置骨牌前的方案數 然後再來看如何將骨牌擺上去,這裡對骨牌進行歸類,旋轉之後得到如下六種情況: 圖二-4-6 圖二-4-7 為了方便敘述,分別給每個型別的骨牌強加了一個奇怪的名字,都是按照它自身的形狀來命名的,o(╯□╰)o。然後我們發現它們都被圈定在一個2X2的格子裡,所以每個骨牌都可以用2個2位的2進位制整數來表示,編碼方式類似上面的狀態表示法(參照圖6,如果骨牌對應格子為藍色則累加格子上的權值),定義如下: int blockMask[6][2] = {      {1, 1},    // 豎向2X1      {3, 0},    // 橫向1X2      {3, 1},    // 槍      {3, 2},    // 7      {1, 3},    // L      {2, 3},    // J }; blockMask[k][0]表示骨牌第一行的狀態,blockMask[k][1]表示骨牌第二行的狀態。這樣一來就可以通過簡單的位運算來判斷第k塊骨牌是否可以放在(i, col)這個格子上,這裡的i表示行編號,col則表示列編號。接下來需要用到位運算進行狀態轉移,所以先介紹幾種常用的位運算: a. x & (1<<i)   值如果非0,表示x這個數字的二進位制表示的第i(i >= 0)位為1,否則為0; b. x & (y<<i)   值如果非0,表示存在一個p(i <= p < i+k),使得x這個數字的二進位制表示的第p位和y的p-i位均為1(k為y的二進位制表示的位數); c. x | (1<<i)   將x的第i位變成1(當然,有可能原本就為1,這個不影響); d. x | (y<<i)   將x的第i~i+k-1位和y進行位或運算(k為y的二進位制表示的位數),這一步就是模擬了骨牌擺放; 那麼這個格子可以放置第k個骨牌的條件有如下幾個: 1、當前骨牌橫向長度記為w,那麼w必須滿足 col + w <= N,否則就超出右邊界了。 2、 nowstate (blockMask[k][0]<<col)  == 0,即第i行,骨牌放入前對應的格子為空(對應的格子表示骨牌佔據的格子,下同) 3、nextstate (blockMask[k][1]<<col)  == 0,即第i+1行,骨牌放入前對應的格子為空 4、最容易忽略的一點是,“J”骨牌放置時,它的缺口部分之前必須要被骨牌鋪滿,否則就無法滿足第i行全鋪滿這個條件了,如圖二-4-8所示的情況。 圖二-4-8 當四個條件都滿足就可以遞迴進入下一層了,遞迴的時候也是採用位運算,實現如下: dfs( col+1, nextrow, nowstate|(blockMask[k][0]<<col), nextstate|(blockMask[k][1]<<col), cnt ); 這裡的位或運算(|)就是模擬將一個骨牌擺放到指定位置的操作(參見位運算d)。 當然,在列舉到第col列的時候,有可能(i, col)這個格子已經被上一行的骨牌給“佔據”了(是否被佔據可以通過 (1<<col) & nowstate 得到),這時候我們只需要繼續遞迴下一層,只遞增col,其它量都不變即可,這表示了這個格子什麼都不放的情況。       5、樹狀模型 樹形動態規劃(樹形DP),是指狀態圖是一棵樹,狀態轉移也發生在樹上,父結點的值通過所有子結點計算完畢後得出。

    【例題11】給定一顆樹,和樹上每個結點的權值,求一顆非空子樹,使得權和最大。

   

用d[1][i] 表示i這個結點選中的情況下,以i為根的子樹的權和最大值;

用d[0][i]表示i這個結點不選中的情況下,以i為根的子樹的權和最大值;

d[1][i] = v[i] + sum{ d[1][v] | v是i的直接子結點 && d[1][v] > 0 }

d[0][i] = max( 0, max{ max( d[0][v], d[1][v] ) | v是i的直接子結點 } )

這樣,構造一個以1為根結點的樹,然後就可以通過dfs求解了。

這題題目要求求出的樹為非空樹,所以當所有權值都為負數的情況下需要特殊處理,選擇所有權值中最大的那個作為答案。

三、動態規劃的常用狀態轉移方程 動態規劃演算法三要素(摘自黑書,總結的很好,很有概括性):               ①所有不同的子問題組成的表               ②解決問題的依賴關係可以看成是一個圖               ③填充子問題的順序(即對 ②的圖進行 拓撲排序,填充的過程稱為狀態轉移);       則如果子問題的數目為O(nt ),每個子問題需要用到 O(ne )個子問題的結果,那麼我們稱它為 tD/eD的問題,於是可以總結出四類常用的動態規劃方程: (下面會把opt作為取最優值的函式(一般取min或max), w(j, i)為一個實函式,其它變數都可以在常數時間計算出來)。)        1、1D/1D              d[i] = opt{ d[j] + w(j, i) | 0 <= i < j } (1 <= i <= n)              【例題4】和【例題5】都是這類方程。        2 、2D/0D              d[i][j] = opt{ d[i-1][j] + xi, d[i][j-1] + yj, d[i-1][j-1] + zij }     (1<= i, j <= n)              【例題7】是這類方程的變形,最典型的見最長公共子序列問題。        3 、2D/1D
              d[i][j] = w(i, j) + opt{ d[i][k-1] + d[k][j] }, (1 <= i < j <= n)                區間模型常用方程。                另外一種常用的2D/1D的方程為:               d[i][j] = opt{ d[i-1][k] + w(i, j, k) | k < j }    (1<= i <= n, 1 <= j <= m)        4 、2D/2D d[i][j] = opt{ d[i'][j'] + w(i', j', i, j) |  0 <= i' < i, 0 <= j' < j}              常見於二維的迷宮問題,由於複雜度比較大,所以一般配合資料結構優化,如線段樹、樹狀陣列等。        對於一個 tD/eD 的動態規劃問題,在不經過任何優化的情況下,可以粗略得到一個時間複雜度是 O(nt+e ) ,空間複雜度是O(nt ) 的演算法,大多數情況下空間複雜度是很容易優化的,難點在於時間複雜度,下一章我們將詳細講解各種情況下的動態規劃優化演算法。 四、動態規劃和資料結構結合的常用優化
       1、滾動陣列       【例題12】例題7(迴文串那題)的N變成5000,其餘不變。        回憶一下那個問題的狀態轉移方程如下:         d[i][j] =   {                                  d[i+1][j-1]                           | A[i] == A[j]  min{ d[i+1][j], d[i][j-1] } + 1   |   A[i]  != A[j]                       }        我們發現將d[i][j]理解成一個二維的矩陣,i表示行,j表示列,那麼第i行的結果只取決於第i+1和第i行的情況,對於第i+2行它表示並不關心,那麼我們只要用一個d[2][N]的陣列就能儲存狀態了,其中d[0][N]為奇數行的狀態值,d[1][N]為偶數行的狀態值,當前需要計算的狀態行數為奇數時,會利用到 d[1][N]的部分狀態,奇數行計算完畢, d[1][N]整行狀態都沒用了,可以用於下一行狀態的儲存,類似“傳送帶”的滾動來迴圈利用空間資源,美其名曰 - 滾動陣列。        這是個2D/0D問題,理論的空間複雜度是O(n2),利用滾動陣列可以將空間降掉一維,變成O(n)。        揹包問題的幾個狀態轉移方程同樣可以用滾動陣列進行空間優化。 2、最長單調子序列 d[i] = max{ d[j] | j < i && a[j] < a[i] } + 1; 那個問題的狀態轉移方程如下: 【例題13】例題4(最長遞增子序列那題)的N變成100000,其餘不變。        首先明確決策的概念,我們認為 j 和 k (j < i, k < i)都是在計算d[i]時的兩個決策。那麼假設他們滿足a[j] < a[k ](它們的狀態對應就是d[j] 和 d[k]),如果a[i] > a[k],則必然有a[i] > a[j],能夠選k做決策的也必然能夠選 j 做決策,那麼如若此時d[j] >= d[k],顯然k不可能是最優決策(j的決策始終比它優,以j做決策,a[ j ]的值小但狀態值卻更大),所以d[k]是不需要儲存的。 基於以上理論,我們可以採用二分列舉,維護一個值 (這裡的值指的是a[i]) 遞增的決策序列,不斷擴大決策序列,最後決策的數目就是最長遞增子序列的長度。具體做法是: 列舉i,如果a[i]比決策序列中最大的元素的值還大,則將i插入到決策序列的尾部;否則二分列舉決策序列,找出其中值最小的一個決策k,並且滿足a[k] > a[i],然後用決策i替換決策k。 這是個1 D/1D問題,理論的時間複雜度是O(n2),利用單調性優化後可以將複雜度將至O(nlogn)。       【例題14】 給定n個元素(n <= 100000)的序列,將序列的所有數分成x堆,每堆都是單調不增的,求x的最小值。        結論:可以轉化成求原序列的最長遞增