題外話:
好久沒(méi)寫(xiě)博客了,主要覺(jué)得自己寫(xiě)的不怎么樣,肚子沒(méi)墨水了。今天又出來(lái)榨干肚子里的最后一滴墨水。
進(jìn)入正題:
前面寫(xiě)過(guò)兩篇?jiǎng)討B(tài)規(guī)劃的題目,今天又遇到動(dòng)態(tài)規(guī)劃了,想到了一個(gè)更容易理解的方法,我們用湊硬幣這道題目來(lái)加深理解。網(wǎng)絡(luò)上這個(gè)題目已經(jīng)被寫(xiě)爛了,大多數(shù)文章都是復(fù)制粘貼的。本文原創(chuàng)。開(kāi)始講解之前讀者需要了解遞歸。本文從遞歸解法講起直到推出動(dòng)態(tài)規(guī)劃方法。
問(wèn)題描述:
(湊硬幣問(wèn)題)給你 k 種面值的硬幣,面值分別為 c1, c2 ... ck ,每種硬幣的數(shù)量無(wú)限,
再給一個(gè)總金額 amount ,問(wèn)你最少需要幾枚硬幣湊出這個(gè)金額,如果不可能湊出,算法返回-1
這里我們k = 3 , c1 = 1、c2 = 3、c3 = 5.
1. 遞歸解法:
形象的理解遞歸:
比如f是一個(gè)八卦的人,有一天a和b談戀愛(ài)了,只告訴了c。 f想知道a和b的關(guān)系, 就去問(wèn)e, e說(shuō)他自己不知道可以幫忙去問(wèn)d,d說(shuō)他也不知道可以幫忙去問(wèn)c(遞歸調(diào)用過(guò)程),c呢就告訴了d,d又告訴了e,e告訴了f(回溯過(guò)程,回溯過(guò)程可以看作答案一步步傳到f耳朵里的過(guò)程)。
用遞歸的思想來(lái)思考該問(wèn)題大致如下:
我們知道amount=0和amount<0基本情況(c知道a和b的關(guān)系),求amount=11的答案(f想知道a和b的關(guān)系),ans(11)=ans(11-5)+1 or ans(11)= ans(11-3)+1 or ans(11)= ans(11-1)+1 (f去問(wèn)了他認(rèn)識(shí)的人e。如果f認(rèn)識(shí)d或者直接認(rèn)識(shí)c他可以更快的知道答案)。遞歸的回溯過(guò)程程序會(huì)自動(dòng)完成。
def colCoins_rec(coins, amount):
"""
遞歸解法
:param coins: 硬幣面值
:param amount: 總金額
:return: 湊齊總金額的最少硬幣數(shù)量
"""
# 基本情況(c知道的情況)
if amount == 0:
return 0
if amount < 0:
return -1
res = float("inf")
# 有三種面值的硬幣可以選擇(f認(rèn)識(shí)的人,因?yàn)閒只認(rèn)識(shí)e所以只有一個(gè)選擇)
for coin in coins:
#選擇(f選擇問(wèn)誰(shuí),交給誰(shuí)來(lái)獲取答案)
subproblem = colCoins_rec(coins, amount-coin)
# 子問(wèn)題無(wú)解,(f問(wèn)到了錯(cuò)誤地人g,g是個(gè)孤兒誰(shuí)都不認(rèn)識(shí),走到了死胡同,f要問(wèn)下一個(gè)人)
if subproblem < 0: continue
# 選擇最優(yōu)的答案(如果f認(rèn)識(shí)c,那么他很快可以獲取答案,就可以選擇這條信息鏈)
res = min(res, 1+colCoins_rec(coins, amount-coin))
return res
遞歸方法存在什么問(wèn)題呢?很明顯的一個(gè)是使用了函數(shù)調(diào)用棧,棧在計(jì)算機(jī)中是一個(gè)相對(duì)很有限的資源。還有一個(gè)是存在重疊子問(wèn)題,導(dǎo)致了時(shí)間復(fù)雜度大大增加。LeetCode上這個(gè)方法一般是無(wú)法通過(guò)的。重疊子問(wèn)題是什么呢?
h 存在直接連線的表示互相認(rèn)識(shí)
/ \ 如果h想從c那里獲得c知道的答案那么他要問(wèn)認(rèn)識(shí)的f,g同樣
f g f,g要問(wèn)他們認(rèn)識(shí)的人。
/ \ / \ 觀察發(fā)現(xiàn)f,e,g被問(wèn)了多次!這是導(dǎo)致算法復(fù)雜度高的主要原因
g e e f
/ \ ........
e f
/ \
c d
如何解決上述存在的重疊子問(wèn)題呢?答案是使用備忘錄方法。
2備忘錄遞歸方法
備忘錄方法是這些人共用一個(gè)列表的,如果誰(shuí)已經(jīng)知道答案就填在列表對(duì)應(yīng)的元素里里,每個(gè)人在問(wèn)之前先訪問(wèn)列表,如果得到大難就不用再繼續(xù)問(wèn)下去了,避免重復(fù)的被問(wèn)。
mem = [-1]*15
def colCoins_rec_mem(coins, amount, mem):
"""
在遞歸的解法的基礎(chǔ)上加上備忘錄解決重疊子問(wèn)題。
:param coins: 硬幣面值
:param amount: 總金額
:param mem: 備忘錄
:return: 最少硬幣數(shù)量
"""
# 基本情況(c知道的答案)
if amount < 0:
return -1
if amount == 0:
return 0
# 查看備忘錄,看看是否計(jì)算過(guò)(是否被問(wèn)過(guò))
if mem[amount] != -1:
return mem[amount]
# 選擇(詢問(wèn)的過(guò)程)
res = float("inf")
for coin in coins:
subproblem = colCoins_rec_mem(coins, amount-coin, mem)
if subproblem < 0: continue
res = min(res, 1+colCoins_rec_mem(coins, amount-coin, mem))
# 得到答案后寫(xiě)進(jìn)備忘錄
mem[amount] = res
return res
我們可以看到基于備忘錄的遞歸算法,會(huì)在計(jì)算(詢問(wèn))前去查看備忘錄,避免重復(fù)計(jì)算。而且代碼與純遞歸方法也非常的相似。只多了查看備忘錄的過(guò)程和獲得答案后寫(xiě)進(jìn)備忘錄的操作。這個(gè)備忘錄解決了重疊子問(wèn)題,實(shí)際上他還是遞歸方法。我們可以看到其實(shí)備忘錄記錄的就是子問(wèn)題和原問(wèn)題的答案,遞歸方法是自頂向下的以“詢問(wèn)”的方法求得答案,那么可不可以讓c主動(dòng)的向他認(rèn)識(shí)的人擴(kuò)散答案直到讓f知道答案。這個(gè)自底向上的“傳播”就是動(dòng)態(tài)規(guī)劃?。ê脽ヽ這種人啊,就像我很煩動(dòng)態(tài)規(guī)劃題目)話不多說(shuō)看代碼
def colCoins_mem(coins, amount):
"""
自底向上遞推出答案
:param coins: 硬幣面值
:param amount: 總金額
:return:
"""
# 構(gòu)造一個(gè)備忘錄
mem = [0]*(amount+1) # mem[i]表示湊出金額i需要的最少硬幣的數(shù)量
# 基本情況(這句代碼可以不要)
mem[0] = 0
# 開(kāi)始推導(dǎo)(c開(kāi)始傳播八卦了)
for i in range(1, amount+1):
# 從c開(kāi)始向他認(rèn)識(shí)的人傳播消息
for coin in coins:
res = i - coin
if res >= 0:
mem[i] = mem[res] + 1
else:
continue
return mem[amount]
總結(jié):
我們看看動(dòng)態(tài)規(guī)劃的要素就是:1、基本情況(知道答案的那個(gè)人)2、明確mem里面存儲(chǔ)的是什么我們也可以稱(chēng)作狀態(tài)。3、選擇(每個(gè)狀態(tài)有多少轉(zhuǎn)移方方式)4、明確狀態(tài)轉(zhuǎn)移的細(xì)節(jié)(狀態(tài)轉(zhuǎn)移方程)。該文章一氣呵成有什么不懂的留言,有空改一下。動(dòng)態(tài)規(guī)劃就到這里了。