五、動態規劃題集整理
一、動態規劃初探
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。
這是一個經典的遞推問題,如果覺得無從下手,我們可以來看一個更加簡單的問題,把問題中的“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]也有關係,但是還是漏掉了一些情況。
上面的問題說明我們在設計狀態(狀態在動態規劃中是個很重要的概念,在本章的第4小節會進行介紹總結)的時候的思維定式,當一維的狀態已經無法滿足我們的需求時,我們可以試著增加一維,用二維來表示狀態,用f[i][j]表示(3 X i) + j個多餘塊的擺放方案數,如圖一 -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
如果N不是很大的情況,到這一步,我們的問題已經完美解決了,其實並不需要求它的通項公式,因為我們是程式猿,一個for迴圈就能搞定了 <*_*>,接下來的求解就全仰仗於計算機來完成了。
【例題2】對一個“01”串進行一次μ變換被定義為:將其中的"0"變成"10","1"變成"01",初始串為"1",求經過N(N <= 1000)次μ變換後的串中有多少對"00"(有沒有人會糾結會不會出現"000"的情況?這個請放心,由於問題的特殊性,不會出現"000"的情況)。圖一 -1-7表示經過小於4次變換時串的情況。
如果純模擬的話,每次μ變換串的長度都會加倍,所以時間和空間複雜度都是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],遞推求解。
2、記憶化搜尋
遞推說白了就是在知道前i-1項的值的前提下,計算第i項的值,而記憶化搜尋則是另外一種思路。它是直接計算第i項,需要用到第 j 項的值( j < i)時去查表,如果表裡已經有第 j 項的話,則直接取出來用,否則遞迴計算第 j 項,並且在計算完畢後把值記錄在表中。記憶化搜尋在求解多維的情況下比遞推更加方便,【例題3】是我遇到的第一個記憶化搜尋的問題,記憶猶新。
【例題3】這個問題直接給出了一段求函式w(a, b, c)的虛擬碼:
function w(a, b, c):
if a <=0 or b <=0 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),所以放棄往下列舉,大大減少了搜尋的狀態空間。
這時候,我們其實已經不經意間設計好了狀態,就是上文中提到的那個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],兩個人過橋的總時間為二者中時間長者。問所有小朋友過橋的總時間最短是多少。
每次過橋的時候最多兩個人,如果橋這邊還有人,那麼還得回來一個人(送手電筒),
也就是說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)的優化演算法。
揹包問題是動態規劃中一個最典型的問題之一。由於網上有非常詳盡的揹包講解
有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} ),空間複雜度在用滾動陣列優化後可以達到
進行優化後(此處省略500字),狀態轉移方程變成:
f[i][v] = max{ f[i-1][v], f[i][v - Ci] +Wi }
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 }
優化:採用二進位制拆分物品,將Mi個物品拆分成容量為1、2、4、8、... 2^k、Mi-( 2^(k+1) - 1 ) 個對應價值為Wi、2Wi、4Wi、8Wi、...、2^kWi、(
這樣做的時間複雜度降為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的最大資金。
狀態壓縮的動態規劃,一般處理的是資料規模較小的問題,將狀態壓縮成k進位制的整數,k取2時最為常見。
對於一條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表示的狀態就是:
明確了狀態表示,那麼我們假設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] 為所有的兩個點的路徑的最大值,即最小的子問題。這個問題的狀態並非線性的,所以用記憶化搜尋來求解狀態的值會事半功倍。
方塊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的方案數,這個放置骨牌的過程就是狀態轉移。
用一個二維陣列DP[i][j] 表示第i行的狀態為j的骨牌放置方案數(其中 1<=i<=m, 0 <= j < 2
n),為了將問題簡化,我們虛擬出一個第0行,則有DP[0][j] = (j ==
2
n
-1) ? 1 : 0;這個就是我們的初始狀態,它的含義是這樣的,因為第0行是我們虛擬出來的,所以第0行只有完全放滿的時候才有意義,也就是第0行全部放滿(狀態的二進位制表示全為1,即十進位制表示的
2n
那麼如何進行狀態轉移呢?假設第3行的某個狀態(101000)2的方案數DP[3][(101000)2 ] =
5,如圖二-4-4所示:
我們需要做的就是通過各種方法將第3行填滿,從而得到一系列第4行可能的狀態集合S,並且對於每一個在S中的狀態s,執行DP[4][s] += DP[3][(101000)2 ](兩個狀態可達,所以方案數是可傳遞的,又因為多個不同的狀態可能到達同一個狀態,所以採用累加的方式)。
根據給定的骨牌,我們可以列舉它的擺放方式,圖二-4-5展示了三種骨牌的擺放方式以及能夠轉移到的狀態,但是這裡的狀態轉移還沒結束,因為第3行尚未放滿,問題求的是將整個棋盤鋪滿的方案數,所以只有當第i行全部放滿後,才能將狀態轉移給i+1行。
列舉擺放的這一步可以採用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行在放置骨牌前的方案數
然後再來看如何將骨牌擺上去,這裡對骨牌進行歸類,旋轉之後得到如下六種情況:
為了方便敘述,分別給每個型別的骨牌強加了一個奇怪的名字,都是按照它自身的形狀來命名的,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所示的情況。
當四個條件都滿足就可以遞迴進入下一層了,遞迴的時候也是採用位運算,實現如下:
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
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】都是這類方程。
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
d[i][j] = w(i, j) + opt{ d[i][k-1] + d[k][j] }, (1 <= i < j <= n)
d[i][j] = opt{ d[i-1][k] + w(i, j, k) | k < j } (1<= i <= n, 1 <= j <= m)
d[i][j] = opt{ d[i'][j'] + w(i', j', i, j) | 0 <= i' < i, 0 <= j' < j}
常見於二維的迷宮問題,由於複雜度比較大,所以一般配合資料結構優化,如線段樹、樹狀陣列等。
對於一個
tD/eD 的動態規劃問題,在不經過任何優化的情況下,可以粗略得到一個時間複雜度是
)
的演算法,大多數情況下空間複雜度是很容易優化的,難點在於時間複雜度,下一章我們將詳細講解各種情況下的動態規劃優化演算法。
四、動態規劃和資料結構結合的常用優化
【例題12】例題7(迴文串那題)的N變成5000,其餘不變。
回憶一下那個問題的狀態轉移方程如下:
d[i+1][j-1] | A[i] == A[j]
min{ d[i+1][j], d[i][j-1] } + 1
}
我們發現將d[i][j]理解成一個二維的矩陣,i表示行,j表示列,那麼第i行的結果只取決於第i+1和第i行的情況,對於第i+2行它表示並不關心,那麼我們只要用一個d[2][N]的陣列就能儲存狀態了,其中d[0][N]為奇數行的狀態值,d[1][N]為偶數行的狀態值,當前需要計算的狀態行數為奇數時,會利用到
d[1][N]整行狀態都沒用了,可以用於下一行狀態的儲存,類似“傳送帶”的滾動來迴圈利用空間資源,美其名曰 - 滾動陣列。
這是個2D/0D問題,理論的空間複雜度是O(n2),利用滾動陣列可以將空間降掉一維,變成O(n)。
揹包問題的幾個狀態轉移方程同樣可以用滾動陣列進行空間優化。
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的最小值。
證明:因為這x堆中每堆元素都是單調不增的,所以原序列的最長遞增子序列的每個元素在分出來的每堆元素中一定只出現最多一個,那麼最長遞增子序列的長度L的最大值為x,所以x >= L。
而我們要求的是x的最小值,於是這個最小值就是 L 了。
【例題15】三個小島,編號1、2、3,老王第0天在1號島上。這些島有一些奇怪的規則,每過1天,1號島上的人必須進入2、3號島;2號島上的人必須進入1號島;3號島上的人可以前往1、2或留在3號島
)天老王在到達1號島的行走方案,由於資料比較大,只需要輸出 ans MOD 100000007的值即可。
圖四-3-1
臨時想的一個問題,首先看問題有幾個維度,島和天數,而且狀態轉移是常數級的,所以這是個2D/0D問題,我們用f[i][j]表示第i天在j號島上的方案數,那麼初始狀態f[0][1] = 1, f[0][2] = f[0][3] = 0。
f[i][1] = f[i-1][2] + f[i-1][3]
f[i][2] = f[i-1][1] + f[i-1][3]
f[i][3] = f[i-1][1] + f[i-1][3]
令這個矩陣為A,Aij表示從i號島到j號島是否連通,連通標1,不連通標0,它還有另外一個含義,就是經過1天,從i島到j島的方案數,利用矩陣的傳遞性,
A2的第i行的第j列則表示經過2天,從i島到j島的方案數,同樣的,
A
n
則表示了經過n天,從i島到j島的方案數,那麼問題就轉化成了求A
n
MOD 100000007的值了。
An就可以在O(logn)的時間內完成了,加法和乘法對MOD操作都是可交換的(即 “先加再模” 和 “先模再加再模” 等價),所以可以在矩陣乘法求解的時候,一旦超出模數,就執行取模操作。
最後求得的矩陣T =
An MOD
100000007,那麼T[1][1]就是我們要求的解了。
【例題16】
n(n <= 500000)個單詞,每個單詞輸出的花費為Ci(1 <= i <= n),將k個連續的單詞輸出在一行的花費為:
其中M為常數,求一個最佳方案,使得輸出所有單詞總的花費最小。
令d[i]為前i個單片語織出的最小花費,s[i] = sum{Ck | 0 <= k <= i },其中s[0] = 0
狀態轉移方程 d[i] = min{ d[j] + (s[i] - s[j])
+ M | 0 <= j < i } (1 <= i <= n)
這是個1D/1D問題,空間複雜度O(n), 時間複雜度為O(n
2
),對於500000的資料來說是不可能在給定時間出解的。
令g[j] = d[j] + (s[i] - s[j])
2+ M,表示由 j 到 i 的決策。
對於兩個決策點 j < k,如果k的決策值小於j (即g[k] < g[j]),則有:
d[k] + (s[i] - s[k])
2
+ M < d[j] + (s[i] - s[j])
2
+ M
,然後將左邊轉化成關於j和k的表示式,右邊轉化成只有i的表示式。
(中間省略t行推導過程...,t >= 5)
(d[j] - d[k] + s[j]
2
- s[k]
2
) / (s[j] - s[k]) < 2*s[i]
令xj = d[j] + s[j]
2
, yj = s[j],則原式轉化成: (xj - xk) / (yj - yk) < 2*s[i]
不等式左邊是個斜率的形式,我們用斜率函式來表示 slope(j, k) = (xj - xk) / (yj - yk)
那麼這裡我們可以得出兩個結論:
1、當兩個決策j、k (j < k)滿足 slope(j, k) < 2*s[i]時,j決策是個無用決策,並且因為s[i]是個單調不降的,所以對於i < i',則有slope(j, k) < 2*s[i] < 2*s[i'],即j決策在隨著i增大的過程中也是一直都用不到的。
2、對於當前需要計算的值f[i],存在三個決策j、k、l,並且有 j < k < l,如果slope(j, k) > slope(k, l),則k這個決策是個無用決策,證明需要分情況討論:
i. slope(k, l) < 2*s[i],則l的決策比k更優;
ii. slope(k, l) >= 2*s[i],則 slope(j, k) > slope(k, l) >= 2*s[i],j的決策比k更優;
綜上所述,當slope(j, k) > slope(k, l)時,k是無用決策;
那麼可以用單調佇列來維護一個決策佇列的單調性,單調佇列存的是決策序列。
一開始佇列裡只有一個決策,就是0這個點(虛擬出的初始決策),根據第一個結論,如果佇列裡面決策數目大於1,則判斷slope( Q[front], Q[front+1] ) < 2*s[i]是否成立,如果成立,Q[front]是個無用決策,front ++,如果不成立那麼Q[front]必定是當前i的最優決策,通過狀態轉移方程計算f[i]的值,然後再根據第二個結論,判斷slope(Q[rear-2], Q[rear-1]) > slope(Q[rear-1], i)是否成立,如果成立,那麼Q[rear-1]必定是個無用決策,rear
--,如果不成立,則將 i 作為當前決策 插入到佇列尾, 即 Q[rear++] = i。
這題需要注意,斜率計算的時候,分母有可能為0的情況。
1、對點更新,即在給你(x, v)的情況下,在下標x的位置累加一個和v(耗時 O(log(n)) )。
函式表示 void add(x, v);
2、成端求和,給定一段區間[x, y],求區間內資料的和(這些資料就是第一個操作累加的資料,耗時
)。
函式表示 int sum(x, y);
用其它資料結構也是可以實現上述操作的,例如線段樹(可以認為它是一種輕量級的線段樹,但是線段樹能解決的問題更加普遍,而樹狀陣列只能處理求和問題),但是樹狀陣列的實現方便、
複雜度低,使得它在解決對點更新成端求和問題上成為首選。這裡並不會講它的具體實現,有興趣請參見
樹狀陣列
。
【例題17】給定一個n
(n <= 100000)個元素的序列a[i] (1 <= a[i] <= INF),定義"和諧序列"為序列的任何兩個相鄰元素相差不超過K,求a[i]的子序列中,"和諧序列"的個數 MOD 10000007。
用d[i]表示以第i個元素結尾的"和諧序列"的個數,
令d[0] = 1, 那麼sum {d[i] | 1 <= i <= n}就是我們要求的解。列出狀態轉移方程:
d[i]
= sum{ d[j] | j==0 || j < i && abs(a[i] - a[j]) <= K }
這是一個1D/1D問題,如果不進行任何優化,時間複雜度是O(n^2
我們首先假設K是個無限大的數,也就是不考慮abs(a[i]
- a[j]) <= K這個限制,那這個問題要怎麼做?很顯然d[1] = 1,d[2] = 1 + d[1],d[3] = d[1] + d[2] + 1(這裡的1其實是狀態轉移方程中j == 0的情況,也就是隻有一個數a[i]的情況),更加一般地:
d[i]
= sum{d[j] | j < i} + 1
對於這一步狀態轉移還是O(n)的,但是我們可以將它稍加變形,如下:
d[i]
= sum(1, INF) + 1; (這裡的sum是樹狀陣列的區間求和函式)
得到d[i]的值後,再將d[i]插入到樹狀陣列中,利用樹狀陣列第1條操作
add(a[i], d[i]);
基於這樣的一種思路,我們發現即使有了限制K,同樣也是可以求解的,只是在求和的時候進行如下操作:
d[i]
= sum(a[i] - K, a[i] + K) + 1;
這樣就把原本O(n)的狀態轉移變成了 O(logn),整個演算法時間複雜度O
(logn)。
線段樹
是一種完全二叉樹,它支援區間求和、區間最值等一系列區間問題,這裡為了將問題簡化,直接給出求值函式而暫時不去討論它的具體實現,有興趣的可以自行尋找資料。線段樹可以擴充套件到二維,二維線段樹是一棵四叉樹,一般用於解決平面統計問題,參見
二維線段樹。
1、insert(x, v) 在下標為x的位置插入一個值v; 耗時 O( log(n) )
2、query(l, r) 求下標在[l, r]上的最值; 耗時 O( log(n) )
d[i] = max{ d[j] | j < i && a[j] < a[i] } + 1
這裡我們首先要保證
1 <= a[i] <= n,如果a[i]是實數域的話,首先要對a[i]進行離散化,排序後從小到大重新編號,最小的a[i]編號為1,最大的a[i]編號為n。
對於狀態轉移執行線段樹的詢問操作: d[i] = query( 1, a[i] - 1 ) + 1
然後再執行插入操作: insert( a[i], d[i] )
狀態轉移同樣耗時O(logn)。
這個思想和樹狀陣列的思想很像,大體思路都是將數本身對映到某個資料結構的下標。
7、其他優化
a.雜湊HASH狀態表示
b.結合數論優化
c.結合計算幾何優化
d.四邊形不等式優化
e.等等
五、動態規劃題集整理
目錄
一、動態規劃初探
1、遞推
2、記憶化搜尋
3、狀態和狀態轉移
4、最優化原理和最優子結構
5、決策和無後效性
二、動態規劃的經典模型
1、線性模型
2、區間模型
3、揹包模型
4、狀態壓縮模型
三、動態規劃的常用狀態轉移方程
1、1D/1D
2、2D/0D
3、2D/1D
4、2D/2D
四、動態規劃和資料結構結合的常用優化
1、滾動陣列
2、最長單調子序列的二分優化
3、矩陣優化
4、斜率優化
5、樹狀陣列優化
6、線段樹優化
7、其他優化
五、動態規劃題集整理
一、動態規劃初探
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。
這是一個經典的遞推問題,如果覺得無從下手,我們可以來看一個更加簡單的問題,把問題中的“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]也有關係,但是還是漏掉了一些情況。
上面的問題說明我們在設計狀態(狀態在動態規劃中是個很重要的概念,在本章的第4小節會進行介紹總結)的時候的思維定式,當一維的狀態已經無法滿足我們的需求時,我們可以試著增加一維,用二維來表示狀態,用f[i][j]表示(3 X i) + j個多餘塊的擺放方案數,如圖一 -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
如果N不是很大的情況,到這一步,我們的問題已經完美解決了,其實並不需要求它的通項公式,因為我們是程式猿,一個for迴圈就能搞定了 <*_*>,接下來的求解就全仰仗於計算機來完成了。
【例題2】對一個“01”串進行一次μ變換被定義為:將其中的"0"變成"10","1"變成"01",初始串為"1",求經過N(N <= 1000)次μ變換後的串中有多少對"00"(有沒有人會糾結會不會出現"000"的情況?這個請放心,由於問題的特殊性,不會出現"000"的情況)。圖一 -1-7表示經過小於4次變換時串的情況。
如果純模擬的話,每次μ變換串的長度都會加倍,所以時間和空間複雜度都是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],遞推求解。
2、記憶化搜尋
遞推說白了就是在知道前i-1項的值的前提下,計算第i項的值,而記憶化搜尋則是另外一種思路。它是直接計算第i項,需要用到第 j 項的值( j < i)時去查表,如果表裡已經有第 j 項的話,則直接取出來用,否則遞迴計算第 j 項,並且在計算完畢後把值記錄在表中。記憶化搜尋在求解多維的情況下比遞推更加方便,【例題3】是我遇到的第一個記憶化搜尋的問題,記憶猶新。
【例題3】這個問題直接給出了一段求函式w(a, b, c)的虛擬碼:
function w(a, b, c):
if a <=0 or b <=0 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),所以放棄往下列舉,大大減少了搜尋的狀態空間。
這時候,我們其實已經不經意間設計好了狀態,就是上文中提到的那個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],兩個人過橋的總時間為二者中時間長者。問所有小朋友過橋的總時間最短是多少。
每次過橋的時候最多兩個人,如果橋這邊還有人,那麼還得回來一個人(送手電筒),
也就是說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)的優化演算法。
揹包問題是動態規劃中一個最典型的問題之一。由於網上有非常詳盡的揹包講解
有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} ),空間複雜度在用滾動陣列優化後可以達到
進行優化後(此處省略500字),狀態轉移方程變成:
f[i][v] = max{ f[i-1][v], f[i][v - Ci] +Wi }
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 }
優化:採用二進位制拆分物品,將Mi個物品拆分成容量為1、2、4、8、... 2^k、Mi-( 2^(k+1) - 1 ) 個對應價值為Wi、2Wi、4Wi、8Wi、...、2^kWi、(
這樣做的時間複雜度降為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的最大資金。
狀態壓縮的動態規劃,一般處理的是資料規模較小的問題,將狀態壓縮成k進位制的整數,k取2時最為常見。
對於一條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表示的狀態就是:
明確了狀態表示,那麼我們假設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] 為所有的兩個點的路徑的最大值,即最小的子問題。這個問題的狀態並非線性的,所以用記憶化搜尋來求解狀態的值會事半功倍。
方塊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的方案數,這個放置骨牌的過程就是狀態轉移。
用一個二維陣列DP[i][j] 表示第i行的狀態為j的骨牌放置方案數(其中 1<=i<=m, 0 <= j < 2
n),為了將問題簡化,我們虛擬出一個第0行,則有DP[0][j] = (j ==
2
n
-1) ? 1 : 0;這個就是我們的初始狀態,它的含義是這樣的,因為第0行是我們虛擬出來的,所以第0行只有完全放滿的時候才有意義,也就是第0行全部放滿(狀態的二進位制表示全為1,即十進位制表示的
2n
那麼如何進行狀態轉移呢?假設第3行的某個狀態(101000)2的方案數DP[3][(101000)2 ] =
5,如圖二-4-4所示:
我們需要做的就是通過各種方法將第3行填滿,從而得到一系列第4行可能的狀態集合S,並且對於每一個在S中的狀態s,執行DP[4][s] += DP[3][(101000)2 ](兩個狀態可達,所以方案數是可傳遞的,又因為多個不同的狀態可能到達同一個狀態,所以採用累加的方式)。
根據給定的骨牌,我們可以列舉它的擺放方式,圖二-4-5展示了三種骨牌的擺放方式以及能夠轉移到的狀態,但是這裡的狀態轉移還沒結束,因為第3行尚未放滿,問題求的是將整個棋盤鋪滿的方案數,所以只有當第i行全部放滿後,才能將狀態轉移給i+1行。
列舉擺放的這一步可以採用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行在放置骨牌前的方案數
然後再來看如何將骨牌擺上去,這裡對骨牌進行歸類,旋轉之後得到如下六種情況:
為了方便敘述,分別給每個型別的骨牌強加了一個奇怪的名字,都是按照它自身的形狀來命名的,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所示的情況。
當四個條件都滿足就可以遞迴進入下一層了,遞迴的時候也是採用位運算,實現如下:
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
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】都是這類方程。
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
d[i][j] = w(i, j) + opt{ d[i][k-1] + d[k][j] }, (1 <= i < j <= n)
d[i][j] = opt{ d[i-1][k] + w(i, j, k) | k < j } (1<= i <= n, 1 <= j <= m)
d[i][j] = opt{ d[i'][j'] + w(i', j', i, j) | 0 <= i' < i, 0 <= j' < j}
常見於二維的迷宮問題,由於複雜度比較大,所以一般配合資料結構優化,如線段樹、樹狀陣列等。
對於一個
tD/eD 的動態規劃問題,在不經過任何優化的情況下,可以粗略得到一個時間複雜度是
)
的演算法,大多數情況下空間複雜度是很容易優化的,難點在於時間複雜度,下一章我們將詳細講解各種情況下的動態規劃優化演算法。
四、動態規劃和資料結構結合的常用優化
【例題12】例題7(迴文串那題)的N變成5000,其餘不變。
回憶一下那個問題的狀態轉移方程如下:
d[i+1][j-1] | A[i] == A[j]
min{ d[i+1][j], d[i][j-1] } + 1
}
我們發現將d[i][j]理解成一個二維的矩陣,i表示行,j表示列,那麼第i行的結果只取決於第i+1和第i行的情況,對於第i+2行它表示並不關心,那麼我們只要用一個d[2][N]的陣列就能儲存狀態了,其中d[0][N]為奇數行的狀態值,d[1][N]為偶數行的狀態值,當前需要計算的狀態行數為奇數時,會利用到
d[1][N]整行狀態都沒用了,可以用於下一行狀態的儲存,類似“傳送帶”的滾動來迴圈利用空間資源,美其名曰 - 滾動陣列。
這是個2D/0D問題,理論的空間複雜度是O(n2),利用滾動陣列可以將空間降掉一維,變成O(n)。
揹包問題的幾個狀態轉移方程同樣可以用滾動陣列進行空間優化。
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的最小值。
證明:因為這x堆中每堆元素都是單調不增的,所以原序列的最長遞增子序列的每個元素在分出來的每堆元素中一定只出現最多一個,那麼最長遞增子序列的長度L的最大值為x,所以x >= L。
而我們要求的是x的最小值,於是這個最小值就是 L 了。
【例題15】三個小島,編號1、2、3,老王第0天在1號島上。這些島有一些奇怪的規則,每過1天,1號島上的人必須進入2、3號島;2號島上的人必須進入1號島;3號島上的人可以前往1、2或留在3號島
)天老王在到達1號島的行走方案,由於資料比較大,只需要輸出 ans MOD 100000007的值即可。
圖四-3-1
臨時想的一個問題,首先看問題有幾個維度,島和天數,而且狀態轉移是常數級的,所以這是個2D/0D問題,我們用f[i][j]表示第i天在j號島上的方案數,那麼初始狀態f[0][1] = 1, f[0][2] = f[0][3] = 0。
f[i][1] = f[i-1][2] + f[i-1][3]
f[i][2] = f[i-1][1] + f[i-1][3]
f[i][3] = f[i-1][1] + f[i-1][3]
令這個矩陣為A,Aij表示從i號島到j號島是否連通,連通標1,不連通標0,它還有另外一個含義,就是經過1天,從i島到j島的方案數,利用矩陣的傳遞性,
A2的第i行的第j列則表示經過2天,從i島到j島的方案數,同樣的,
A
n
則表示了經過n天,從i島到j島的方案數,那麼問題就轉化成了求A
n
MOD 100000007的值了。
An就可以在O(logn)的時間內完成了,加法和乘法對MOD操作都是可交換的(即 “先加再模” 和 “先模再加再模” 等價),所以可以在矩陣乘法求解的時候,一旦超出模數,就執行取模操作。
最後求得的矩陣T =
An MOD
100000007,那麼T[1][1]就是我們要求的解了。
【例題16】
n(n <= 500000)個單詞,每個單詞輸出的花費為Ci(1 <= i <= n),將k個連續的單詞輸出在一行的花費為:
其中M為常數,求一個最佳方案,使得輸出所有單詞總的花費最小。
令d[i]為前i個單片語織出的最小花費,s[i] = sum{Ck | 0 <= k <= i },其中s[0] = 0
狀態轉移方程 d[i] = min{ d[j] + (s[i] - s[j])
+ M | 0 <= j < i } (1 <= i <= n)
這是個1D/1D問題,空間複雜度O(n), 時間複雜度為O(n
2
),對於500000的資料來說是不可能在給定時間出解的。
令g[j] = d[j] + (s[i] - s[j])
2+ M,表示由 j 到 i 的決策。
對於兩個決策點 j < k,如果k的決策值小於j (即g[k] < g[j]),則有:
d[k] + (s[i] - s[k])
2
+ M < d[j] + (s[i] - s[j])
2
+ M
,然後將左邊轉化成關於j和k的表示式,右邊轉化成只有i的表示式。
(中間省略t行推導過程...,t >= 5)
(d[j] - d[k] + s[j]
2
- s[k]
2
) / (s[j] - s[k]) < 2*s[i]
令xj = d[j] + s[j]
2
, yj = s[j],則原式轉化成: (xj - xk) / (yj - yk) < 2*s[i]
不等式左邊是個斜率的形式,我們用斜率函式來表示 slope(j, k) = (xj - xk) / (yj - yk)
那麼這裡我們可以得出兩個結論:
1、當兩個決策j、k (j < k)滿足 slope(j, k) < 2*s[i]時,j決策是個無用決策,並且因為s[i]是個單調不降的,所以對於i < i',則有slope(j, k) < 2*s[i] < 2*s[i'],即j決策在隨著i增大的過程中也是一直都用不到的。
2、對於當前需要計算的值f[i],存在三個決策j、k、l,並且有 j < k < l,如果slope(j, k) > slope(k, l),則k這個決策是個無用決策,證明需要分情況討論:
i. slope(k, l) < 2*s[i],則l的決策比k更優;
ii. slope(k, l) >= 2*s[i],則 slope(j, k) > slope(k, l) >= 2*s[i],j的決策比k更優;
綜上所述,當slope(j, k) > slope(k, l)時,k是無用決策;
那麼可以用單調佇列來維護一個決策佇列的單調性,單調佇列存的是決策序列。
一開始佇列裡只有一個決策,就是0這個點(虛擬出的初始決策),根據第一個結論,如果佇列裡面決策數目大於1,則判斷slope( Q[front], Q[front+1] ) < 2*s[i]是否成立,如果成立,Q[front]是個無用決策,front ++,如果不成立那麼Q[front]必定是當前i的最優決策,通過狀態轉移方程計算f[i]的值,然後再根據第二個結論,判斷slope(Q[rear-2], Q[rear-1]) > slope(Q[rear-1], i)是否成立,如果成立,那麼Q[rear-1]必定是個無用決策,rear
--,如果不成立,則將 i 作為當前決策 插入到佇列尾, 即 Q[rear++] = i。
這題需要注意,斜率計算的時候,分母有可能為0的情況。
1、對點更新,即在給你(x, v)的情況下,在下標x的位置累加一個和v(耗時 O(log(n)) )。
函式表示 void add(x, v);
2、成端求和,給定一段區間[x, y],求區間內資料的和(這些資料就是第一個操作累加的資料,耗時
)。
函式表示 int sum(x, y);
用其它資料結構也是可以實現上述操作的,例如線段樹(可以認為它是一種輕量級的線段樹,但是線段樹能解決的問題更加普遍,而樹狀陣列只能處理求和問題),但是樹狀陣列的實現方便、
複雜度低,使得它在解決對點更新成端求和問題上成為首選。這裡並不會講它的具體實現,有興趣請參見
樹狀陣列
。
【例題17】給定一個n
(n <= 100000)個元素的序列a[i] (1 <= a[i] <= INF),定義"和諧序列"為序列的任何兩個相鄰元素相差不超過K,求a[i]的子序列中,"和諧序列"的個數 MOD 10000007。
用d[i]表示以第i個元素結尾的"和諧序列"的個數,
令d[0] = 1, 那麼sum {d[i] | 1 <= i <= n}就是我們要求的解。列出狀態轉移方程:
d[i]
= sum{ d[j] | j==0 || j < i && abs(a[i] - a[j]) <= K }
這是一個1D/1D問題,如果不進行任何優化,時間複雜度是O(n^2
我們首先假設K是個無限大的數,也就是不考慮abs(a[i]
- a[j]) <= K這個限制,那這個問題要怎麼做?很顯然d[1] = 1,d[2] = 1 + d[1],d[3] = d[1] + d[2] + 1(這裡的1其實是狀態轉移方程中j == 0的情況,也就是隻有一個數a[i]的情況),更加一般地:
d[i]
= sum{d[j] | j < i} + 1
對於這一步狀態轉移還是O(n)的,但是我們可以將它稍加變形,如下:
d[i]
= sum(1, INF) + 1; (這裡的sum是樹狀陣列的區間求和函式)
得到d[i]的值後,再將d[i]插入到樹狀陣列中,利用樹狀陣列第1條操作
add(a[i], d[i]);
基於這樣的一種思路,我們發現即使有了限制K,同樣也是可以求解的,只是在求和的時候進行如下操作:
d[i]
= sum(a[i] - K, a[i] + K) + 1;
這樣就把原本O(n)的狀態轉移變成了 O(logn),整個演算法時間複雜度O
(logn)。
線段樹
是一種完全二叉樹,它支援區間求和、區間最值等一系列區間問題,這裡為了將問題簡化,直接給出求值函式而暫時不去討論它的具體實現,有興趣的可以自行尋找資料。線段樹可以擴充套件到二維,二維線段樹是一棵四叉樹,一般用於解決平面統計問題,參見
二維線段樹。
1、insert(x, v) 在下標為x的位置插入一個值v; 耗時 O( log(n) )
2、query(l, r) 求下標在[l, r]上的最值; 耗時 O( log(n) )
d[i] = max{ d[j] | j < i && a[j] < a[i] } + 1
這裡我們首先要保證
1 <= a[i] <= n,如果a[i]是實數域的話,首先要對a[i]進行離散化,排序後從小到大重新編號,最小的a[i]編號為1,最大的a[i]編號為n。
對於狀態轉移執行線段樹的詢問操作: d[i] = query( 1, a[i] - 1 ) + 1
然後再執行插入操作: insert( a[i], d[i] )
狀態轉移同樣耗時O(logn)。
這個思想和樹狀陣列的思想很像,大體思路都是將數本身對映到某個資料結構的下標。
7、其他優化
a.雜湊HASH狀態表示
b.結合數論優化
c.結合計算幾何優化
d.四邊形不等式優化
e.等等
五、動態規劃題集整理