在理解動態(tài)規(guī)劃、BFS和DFS一文中,只是初步講解了一下動態(tài)規(guī)劃,理解的并不到位,這里再加深理解一下。
本文主要參考什么是動態(tài)規(guī)劃一文。
一、前言
1.1、算法問題的求解過程
類似于機器學(xué)習(xí)的步驟,對同一個問題,可以用不同的模型建模,然后對于確定的模型,可以用不同的算法求解。
一般的算法問題求解步驟,分為兩步:
- 1、問題建模:
對于同一個問題,可以有不同的模型。 - 2、問題求解:
對于特定的模型,選出一個合適的算法(時間復(fù)雜度和空間復(fù)雜度滿足要求),求解問題。
對應(yīng)到動態(tài)規(guī)劃算法上,具體分為這兩步:
- 1、問題建模:[最優(yōu)子結(jié)構(gòu)],[邊界],[狀態(tài)轉(zhuǎn)移方程]。
- 2、用動態(tài)規(guī)劃算法求解問題。
1.2、動態(tài)規(guī)劃的思想
大事化小,小事化了。把一個復(fù)雜的問題分階段進(jìn)行簡化,逐步化簡成簡單的問題。
1.3、動態(tài)規(guī)劃的步驟
1.3.1 問題建模
- 1、 根據(jù)問題,找到【最優(yōu)子結(jié)構(gòu)】。
把原問題從大化小的第一步,找到比當(dāng)前問題要小一號的最好的結(jié)果,而一般情況下當(dāng)前問題可以由最優(yōu)子結(jié)構(gòu)進(jìn)行表示。 - 2、確定問題的【邊界】。
根據(jù)上述的最優(yōu)子結(jié)構(gòu),一步一步從大化小,最終可以得到最小的,可以一眼看出答案的最優(yōu)子結(jié)構(gòu),也就是邊界。 - 3、通過上述兩步,通過分析最優(yōu)子結(jié)構(gòu)與最終問題之間的關(guān)系,我們可以得到【狀態(tài)轉(zhuǎn)移方程】。
1.3.2 問題求解的各個方法(從暴力枚舉 逐步優(yōu)化到動歸)
暴力枚舉:
下面的樓梯問題,國王與金礦問題,還有最少找零硬幣數(shù)問題,都可以通過多層嵌套循環(huán)遍歷所有的可能,將符合條件的個數(shù)統(tǒng)計起來。只是時間復(fù)雜度是指數(shù)級的,所以一般 不推薦。遞歸:
1、既然是從大到小,不斷調(diào)用狀態(tài)轉(zhuǎn)移方程,那么就可以用遞歸。
2、遞歸的時間復(fù)雜度是由階梯數(shù)和最優(yōu)子結(jié)構(gòu)的個數(shù)決定的。不同的問題,用遞歸的話可能效果會大不相同。
3、在階梯問題,最少找零問題中,遞歸的時間復(fù)雜度和空間復(fù)雜度都比動歸方法的差, 但是在國王與金礦的問題中,遞歸的時間復(fù)雜度和空間復(fù)雜度都比動歸方法好。這是需要注意的。
每一種算法都沒有絕對的好與壞,關(guān)鍵看應(yīng)用場景。、
上面這句話說的很好,不止于遞歸和動歸,一般的算法也是,比如一般的排序算法,在不同的場景中,效果也大不相同。
備忘錄算法:
1、在階梯數(shù)N比較多的時候,遞歸算法的缺點就顯露出來了:時間復(fù)雜度很高。如果畫出遞歸圖(像二叉樹一樣),會發(fā)現(xiàn)有很多很多重復(fù)的節(jié)點。然而傳統(tǒng)的遞歸算法并不能識別節(jié)點是不是重復(fù)的,只要不到終止條件,它就會一直遞歸下去。
2、為了避免上述情況,使遞歸算法能夠不重復(fù)遞歸,就把已經(jīng)得到的節(jié)點都存起來,下次再遇到的時候,直接用存起來的結(jié)果就行了。這就是備忘錄算法。
3、備忘錄算法的時間復(fù)雜度和空間復(fù)雜度都得到了簡化。正經(jīng)的動歸算法:
1、上述的備忘錄算法,盡管已經(jīng)不錯了,但是依然還是從最大的問題,遍歷得到所有的最小子問題,空間復(fù)雜度是O(N)。
2、為了再次縮小空間復(fù)雜度,我們可以自底向上的構(gòu)造遞歸問題,通過分析最優(yōu)子結(jié)構(gòu)與最終問題之間的關(guān)系,我們可以得到【狀態(tài)轉(zhuǎn)移方程】。
然后從最小的問題不斷往上迭代,即使一直到最大的原問題,也是只依賴于前面的幾個最優(yōu)子結(jié)構(gòu)。這樣,空間復(fù)雜度就大大簡化。也就得到了正經(jīng)的動歸算法。
下面通過幾個例題,來具體了解動歸問題。
二、例題
例1:Climbing Stairs
leetcode原題:你正在爬一個有n個臺階的樓梯,每次只能上 1個 或者 2個臺階,那么到達(dá)頂端共有多少種不同的方法?
1.1、 建立模型:
-
最終問題F(N):
假設(shè)從0到達(dá)第N個臺階的方法共有F(N)個。 -
最優(yōu)子結(jié)構(gòu)F(N-1),F(xiàn)(N-2):
到達(dá)N個臺階,有兩種可能,第一種可能是從第 N-1 個臺階上1個臺階到達(dá)終點,第二種可能是從第 N-2 個臺階上2個臺階到達(dá)終點。 -
最優(yōu)子結(jié)構(gòu)與最終問題之間的關(guān)系:
按照上述表達(dá),那么可以歸納出F(N) = F(N-1) + F(N-2) (n>=3)
結(jié)束條件為F(1) = 1,F(2) = 2
1.2、 問題求解:
1.2.1、 解法1:遞歸
先用比較容易理解的遞歸求解(結(jié)束條件已知,遞歸公式已知,可以直接寫代碼了)
class Solution:
def climbStairs(self, n):
"""
:type n: int
:rtype: int
"""
if n == 1:
return 1
elif n == 2:
return 2
else:
return self.climbStairs(n-1) + self.climbStairs(n-2)
回想前面所說,遞歸的時間復(fù)雜度是由階梯數(shù)和最優(yōu)子結(jié)構(gòu)的個數(shù)決定的。這里的階梯數(shù)是 N ,最優(yōu)子結(jié)構(gòu)個數(shù)是 2 。如果想象成一個二叉樹,那么就可以認(rèn)為是一個高度為N-1,節(jié)點個數(shù)接近 2 的 N-1 次方的樹,因此此方法的時間復(fù)雜度可以近似的看作是O(2N) 。
1.2.2、 解法2:備忘錄算法
參考什么是動態(tài)規(guī)劃中遞歸的圖,發(fā)現(xiàn)有很多相同的參數(shù)被重復(fù)計算,重復(fù)的太多了。
所以這里我們想到了把重復(fù)的參數(shù)存儲起來,下次遞歸遇到時就直接返回該參數(shù)的結(jié)果,也就是備忘錄算法了,這里需要用到一個哈希表,解決方法就是對類用init進(jìn)行初始化。
class Solution:
def __init__(self):
self.map = {}
def climbStairs(self, n):
"""
:type n: int
:rtype: int
"""
if n == 1:
return 1
if n == 2:
return 2
if n in self.map:
return self.map[n]
else:
value = self.climbStairs(n-1) + self.climbStairs(n-2)
self.map[n] = value
return value
這里哈希表里存了 N-2 個結(jié)果,時間復(fù)雜度和空間復(fù)雜度都是O(N)。程序性能得到了明顯優(yōu)化。
1.2.3、 解法3:動態(tài)規(guī)劃
之前都是自頂向下的求解,考慮一下自底向上的求解過程。從F(1)和F(2)邊界條件求,可知F(3) = F(1)+F(2)。不斷向上,可知F(N)只依賴于前兩個狀態(tài)F(N-1)和F(N-2)。于是我們只需要保留前兩個狀態(tài),就可以求得F(N)。相比于備忘錄算法,我們再一次簡化了空間復(fù)雜度。
這就是動態(tài)規(guī)劃了。(具體的細(xì)節(jié)看漫畫比較好理解。)
具體代碼實現(xiàn)中,可以令F(N-2)=a,F(xiàn)(N-1)=b,則temp等于a+b,然后把a向前挪一步等于b,b向前挪一步等于temp。那么下一次迭代時,temp就依然等于a+b。
代碼如下:
class Solution:
def climbStairs(self, n):
"""
:type n: int
:rtype: int
"""
if n == 1:
return 1
if n == 2:
return 2
a = 1
b = 2
for i in range(3,n+1):
temp = a + b
a = b
b = temp
return temp
例2: Making change using the fewest coins.
參考Dynamic Programming中,用最少的硬幣數(shù)目找零錢的一個例子。
問題描述:
假設(shè)你是一家自動售貨機制造商的程序員。你的公司正設(shè)法在每一筆交易 找零時都能提供最少數(shù)目的硬幣以便工作能更加簡單。已知硬幣有四種(1美分,5美分,10美分,25美分)。假設(shè)一個顧客投了1美元來購買37美分的物品 ,你用來找零的硬幣的最小數(shù)量是多少?
(這個問題用貪心算法也能解,具體細(xì)節(jié)看參考文獻(xiàn))
2.1、 建立模型:
就以動歸作為解題的算法來建立模型吧。
- 邊界:當(dāng)需要找零的面額正好等于上述的四種整額硬幣時,返回1即可
- 最優(yōu)子結(jié)構(gòu):回想找到最優(yōu)子結(jié)構(gòu)的方法,就是往后退一步,能夠得到的最好的結(jié)果。這里有四個選擇,1 + mincoins(63-1),1 + mincoins(63-5),1 + mincoins(63-10) 或者 1 + mincoins(63-25),這四個選擇可以認(rèn)為是63的最優(yōu)子結(jié)構(gòu)。
- 狀態(tài)轉(zhuǎn)移方程:按照 上述的最優(yōu)子結(jié)構(gòu),mincoins(63)也就等于上述四個最優(yōu)子結(jié)構(gòu)的最小值。于是,方程可以表示為:
2.2、 問題求解:
模型已經(jīng)得到,接下來就運用算法進(jìn)行求解。
這里依然可以按照例1的解法,由模型,很自然的想到用遞歸求解。
2.2.1、解法1,遞歸
邊界條件已知,模型已知,可以直接寫代碼了。
def recMC(coinValueList,change):
minCoins = change
if change in coinValueList:
return 1
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recMC(coinValueList,change-i)
if numCoins < minCoins:
minCoins = numCoins
return minCoins
print(recMC([1,5,10,25],63)
但是,對于每一個大于25的數(shù)目,都有四個最優(yōu)子結(jié)構(gòu),然后對于每個最優(yōu)子結(jié)構(gòu),還有大量相同重復(fù)的參數(shù)(具體細(xì)節(jié)看參考)。所以這個解法并不合適。
2.2.2、解法2,動態(tài)規(guī)劃
首先要有自底向上的思想,從change等于1時,開始往上迭代,參考最優(yōu)子結(jié)構(gòu),記錄下來最少硬幣數(shù)。一直迭代到63。
1==>1
2==>min(2-1) + 1 = 2
3==>min(3-1) + 1 = 3
4==>min(4-1) + 1 = 4
5==>min(min(5-1) + 1 = 5, min(5-5) + 1 = 1)= 1
6==>min(min(6-1) + 1 = 2, min(6-5) + 1 = 2)= 2
7==>min(min(7-1) + 1 = 3, min(7-5) + 1 = 3)= 3
由此可以推下去,每一個change對應(yīng)的最少硬幣數(shù),都可以由前面的若干個最優(yōu)子結(jié)構(gòu)(有幾個最優(yōu)子結(jié)構(gòu),由change是多少決定,change大于5就有兩個子結(jié)構(gòu),大于10就有三個。。)得到。這樣一直迭代到63,那么就可以得到63的最少硬幣數(shù)。
因此,需要一個循環(huán)來從頭到尾遍歷。
需要一定需要一個map來記錄部分結(jié)果。
每一個change,我們可以根據(jù)上面的式子遍歷最優(yōu)子結(jié)構(gòu),并將每個子結(jié)構(gòu)的結(jié)果都添加到一個list中,在遍歷完最有子結(jié)構(gòu)以后,選擇最小的那一個,添加到map中去。
求解一個新的 i 的最優(yōu)解的過程是很方便的,從最優(yōu)子結(jié)構(gòu)中挑選最小的值然后加1即可。
最優(yōu)子結(jié)構(gòu)的值,可以用minCoin[i-j]得到。其中j為有效硬幣面額。
實現(xiàn)代碼:
def dpMakeChange(coinValueList,change):
minCoins = { }
for cents in range(change+1):
#cents小于等于1時,coinCount會為空,沒法執(zhí)行min。
#因此這里先填上
if cents <= 1:
minCoins[cents] = cents
continue
#遍歷cents的每個最優(yōu)子結(jié)構(gòu)并且添加到list中,等待篩選
coinCount = [ ]
for j in coinValueList:
if cents >= j:
coinCount.append(minCoins[cents - j] + 1)
minCoins[cents] = min(coinCount)
return minCoins[change]
result = dpMakeChange([1,5,10,25],63)
print(result)
當(dāng)然這個函數(shù)是有瑕疵的,因為這個函數(shù)只告訴我們最少的硬幣數(shù),并不能告訴我們應(yīng)該找零的面額。所以我們可以擴展一下函數(shù),跟蹤記錄我們使用的硬幣即可。具體細(xì)節(jié)可以看參考。
例3: 國王與金礦問題
只講一下大致的思路。
問題中需要注意的地方:
- 國王與金礦的問題中,因為每個金礦需要的人不同,所含金礦數(shù)量也不同。為了簡化問題,這里第 i 個金礦所含的金礦數(shù)量和所需要的工人都是 特定不變的。
- 在實現(xiàn)自底向上的遞推時,因為問題的參數(shù)有兩個,那么存在兩個輸入維度。為此,可以畫一個表格來做分析。
- 在實現(xiàn)自底向上的遞推時,為了比較快的找到規(guī)律,最好把從邊界不斷地往上迭代,結(jié)合最優(yōu)子結(jié)構(gòu)和存儲的結(jié)果,慢慢的找到規(guī)律。
3.1、問題建模
這里著重講解一下最后一點,也就是動態(tài)規(guī)劃最重要的地方。
最優(yōu)子結(jié)構(gòu):對于5個金礦,10個工人的情況,往后退一步存在兩種情況。(第五個金礦的金礦數(shù)量為350,所需工人為3人)
- 情況1:國王選擇不挖第五個金礦,那么此時最大化的金礦數(shù)量就是在有4個金礦,10個工人的情況下,能夠挖到的最多金礦數(shù)量。
- 情況2:國王選擇挖第五個金礦,那么此時用3個工人挖得350的金礦數(shù)量是已知的,還剩4個金礦與7個工人。
那么最優(yōu)解相當(dāng)于在4個金礦與7個工人的情況下能夠挖得的最多金礦數(shù)量 + 350。
最優(yōu)子結(jié)構(gòu)與最終問題之間的關(guān)系:5個金礦10個工人的最優(yōu)選擇,就是上述兩個最優(yōu)子結(jié)構(gòu)的最大值。
于是我們可以得到狀態(tài)轉(zhuǎn)移方程:
最重要的狀態(tài)轉(zhuǎn)移方程已經(jīng)得到,至于剩下的邊界條件,現(xiàn)實中會遇到的各種特殊情況,這里就不贅述了。細(xì)節(jié)參考漫畫。
3.2、問題求解
3.2.1 解法1、遞歸
程序 :把狀態(tài)轉(zhuǎn)移方程翻譯成遞歸程序,遞歸的結(jié)束條件就是方程式中的邊界即可。
復(fù)雜度:因為每個狀態(tài)有兩個最優(yōu)子結(jié)構(gòu),所以遞歸的執(zhí)行流程類似于一個高度為N的二叉樹。所以方法的時間復(fù)雜度是O(2N)。
3.2.2 解法2、備忘錄算法
程序:在簡單遞歸的基礎(chǔ)上,增加一個HashMap備忘錄,用來存儲中間的結(jié)果,HashMap的Key是一個包含金礦數(shù)N和工人數(shù)W的對象,Value是最優(yōu)選擇獲得的黃金數(shù)。
復(fù)雜度:時間復(fù)雜度和空間復(fù)雜度相同,都等于被網(wǎng)絡(luò)中不同Key的數(shù)量。
3.2.3 解法3、動態(tài)規(guī)劃
為了實現(xiàn)自底向上的迭代,對于參數(shù)有兩個的問題,我們可以先畫要一個表格來做分析。根據(jù)狀態(tài)轉(zhuǎn)移方程,我們可以方便的畫出表格。注意,一定是要根據(jù)狀態(tài)轉(zhuǎn)移方程來求的。
由于我們在求解每個格子的數(shù)值時,結(jié)合狀態(tài)轉(zhuǎn)移方程,發(fā)現(xiàn)除了第一行以外,每一個格子都可以由前一行的格子中的一個或者兩個格子推導(dǎo)而來。
從整體上來說,每一行的值都可以由前一行來求得。
于是,我們在寫代碼的時候,也可以像畫表格一樣,從左至右,從上到下一個一個的推出最終結(jié)果。反映到程序上就是:
for i in range(金礦數(shù)):
for j in range(工人數(shù)目):
狀態(tài)轉(zhuǎn)移方程
另外,由上可知,我們并不需要存儲整個表格,只需要存儲前一行的結(jié)果即可推出新的一行。
代碼這里就不寫了。
注意:
- 這里動態(tài)規(guī)劃的時間復(fù)雜度是O(n*w),空間復(fù)雜度是O(w)。在n=5,w=1000是,顯然要計算5000次,開辟1000單位的空間。
- 但是如果用簡單遞歸算法的話,時間復(fù)雜度是O(2N),需要計算32次 ,開辟5單位(遞歸深度)的空間。
- 這是由于動態(tài)規(guī)劃方法的時間和空間都和w成正比,而簡單遞歸卻和w無關(guān),所以當(dāng)工人數(shù)量很多的時候,動態(tài)規(guī)劃反而不如遞歸。
所以說,每一種算法沒有絕對的好與壞,關(guān)鍵要看應(yīng)用場景。
總結(jié):
個人覺得, 動態(tài)規(guī)劃算法最重要的有兩點
- 建模:一定要找對最優(yōu)子結(jié)構(gòu),然后分析最優(yōu)子結(jié)構(gòu)與最終問題的關(guān)系,從而得到狀態(tài)轉(zhuǎn)移方程。
- 問題求解:先手動的自底向上的,運用狀態(tài)轉(zhuǎn)移方程迭代一下,一直到最終問題,從而確定程序的主體部分。
至于,模型中的邊界問題,特殊情況等,就是需要多敲代碼來慢慢考慮的了。