斐波那契數列4種解法(暴力遞迴+動態規劃)
Reference
LeetCode 509. 斐波那契數列
labuladong的演算法小抄
Markdown語法
Labuladong的演算法小抄(紙質書籍 2021年1月第1版,2022年1月第七次印刷 第2章,第1節)
此問題解法和下一個湊零錢問題解法,我都會詳細介紹解法原理,再後續動態規劃演算法原理和此相同,我只會解釋題目解決方案竅門(即找到“狀態”、“選擇”和狀態轉移方程),不會再詳細解釋其他相關知識。
動態規劃一般解法
暴力窮舉 -> 帶備忘錄的遞迴解法 -> dp 陣列的迭代解法。
找到“狀態”和“選擇”->明確dp陣列/函式的定義->尋找狀態之間的關係。
難點
- dp陣列的含義
- 尋找正確的狀態轉移方程(數學歸納法)
程式碼解釋詳見 Labuladong的演算法小抄 書箱(2022年1月第七次印刷) pp.31-37
方法1:暴力遞迴 (存在大量的重疊子問題)
遞迴問題最好都畫出遞迴樹,方法理解演算法和計算時間空間複雜度
遞迴演算法的時間複雜度:用子問題個數乘以解決一個子問題需要的時間。
def func(N): # arr = list(map(int,input().strip().split())) # N = int(input()) def fib(N): if N == 0: return 0 if N == 1 or N == 2: return 1 else: return fib(N-1) + fib(N-2) return fib(N) if __name__ == '__main__': N = int(input()) c = func(N) print(c) for i in range(N+1): print(func(i), end=" ")
⼦問題個數,即遞迴樹中節點的總數。顯然⼆叉樹節點總數為指數級別,所以⼦問題個數為 O(2^n)。
解決⼀個⼦問題的時間,在本演算法中,沒有迴圈,只有 f(n - 1) + f(n - 2) ⼀個加法操作,時間為 O(1)。所以,這個演算法的時間複雜度為 O(2^n),指數級別,爆炸。
觀察遞迴樹,很明顯發現了演算法低效的原因:存在⼤量重複計算,⽐如 f(18) 被計算了兩次,⽽且你可以看到,以 f(18) 為根的這個遞迴樹體量巨⼤,多算⼀遍,會耗費巨⼤的時間。更何況,還不⽌ f(18) 這⼀個節點被重複計算,所以這個演算法及其低效。
這就是動態規劃問題的第⼀個性質:重疊⼦問題。下⾯,我們想辦法解決這個問題
方法2:帶備忘錄的遞迴解法 (使用memo陣列或者雜湊表充當備忘錄)
觀察方法1的遞迴樹,此方法相當於存在巨量冗餘的遞迴二叉樹,備忘錄相當於提供了一套“剪枝”操作。使遞迴樹改造成了一幅不存在冗餘的遞迴樹。極大地減少了子問題
def func(N):
# arr = list(map(int,input().strip().split()))
# N = int(input())
def fib(N):
if N == 0:
return 0
memo = [0] * (N+1)
return helper(memo,N)
def helper(memo,N):
if N == 1 or N == 2:
memo[N] = 1
return memo[N]
if memo[N] != 0:
return memo[N]
memo[N] = helper(memo,N-1) + helper(memo,N-2)
return memo[N]
return fib(N)
if __name__ == '__main__':
N = int(input())
c = func(N)
print(c)
for i in range(N+1):
print(func(i), end=" ")
⼦問題個數,即圖中節點的總數,由於本演算法不存在冗餘計算,⼦問題就是f(1),f(2),f(3)...f(20),數量和輸⼊規模n=20成正⽐,所以⼦問題個數為O(n)。
解決⼀個⼦問題的時間,同上,沒有什麼迴圈,時間為O(1)。所以,本演算法的時間複雜度是O(n)。⽐起暴⼒演算法,是降維打擊。
⾄此,帶備忘錄的遞迴解法的效率已經和迭代的動態規劃解法⼀樣了。實際上,這種解法和迭代的動態規劃已經差不多了,只不過這種⽅法叫做「⾃頂向下」,動態規劃叫做「⾃底向上」。
啥叫「⾃頂向下」?注意我們剛才畫的遞迴樹(或者說圖),是從上向下延伸,都是從⼀個規模較⼤的原問題⽐如說f(20),向下逐漸分解規模,直到f(1)和f(2)觸底,然後逐層返回答案,這就叫「⾃頂向下」。
啥叫「⾃底向上」?反過來,我們直接從最底下,最簡單,問題規模最⼩的f(1)和f(2)開始往上推,直到推到我們想要的答案f(20),這就是動態規劃的思路,這也是為什麼動態規劃⼀般都脫離了遞迴,⽽是由迴圈迭代完成計算
方法3:dp陣列的迭代解法 (DP table 自底向上解法)
有了上⼀步「備忘錄」的啟發,我們可以把這個「備忘錄」獨⽴出來成為⼀張表,就叫做 DP table 吧,在這張表上完成「⾃底向上」的推算豈不美哉!
def func(N):
# arr = list(map(int,input().strip().split()))
# N = int(input())
def fib(N):
if N == 0:
return 0
if N == 1 or N == 2:
return 1
dp = [0] * (N + 1) # 加1是把N=0時返回0考慮進去了。 #float('inf')
dp[1],dp[2] = 1,1
for i in range(3,N+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[N]
return fib(N)
if __name__ == '__main__':
N = int(input())
c = func(N)
print(c)
for i in range(N+1):
print(func(i), end=" ")
畫個圖就很好理解了,⽽且你發現這個DPtable特別像之前那個「剪枝」後的結果,只是反過來算⽽已。實際上,帶備忘錄的遞迴解法中的「備忘錄」,最終完成後就是這個DPtable,所以說這兩種解法其實是差不多的,⼤部分情況下,效率也基本相同。
這⾥,引出「狀態轉移⽅程」這個名詞,實際上就是描述問題結構的數學形式:
為啥叫「狀態轉移⽅程」?為了聽起來⾼端。你把f(n)想做⼀個狀態n,這個狀態n是由狀態n-1和狀態n-2相加轉移⽽來,這就叫狀態轉移,僅此⽽已。
你會發現,上⾯的⼏種解法中的所有操作,例如returnf(n-1)+f(n-2),dp[i]=dp[i-1]+dp[i-2],以及對備忘錄或DPtable的初始化操作,都是圍繞這個⽅程式的不同表現形式。可⻅列出「狀態轉移⽅程」的重要性,它是解決問題的核⼼。很容易發現,其實狀態轉移⽅程直接代表著暴⼒解法。
千萬不要看不起暴⼒解,動態規劃問題最困難的就是寫出狀態轉移⽅程,即這個暴⼒解。優化⽅法⽆⾮是⽤備忘錄或者 DP table,再⽆奧妙可⾔!
方法4:dp陣列的迭代解法+狀態壓縮
這個例⼦的最後,講⼀個細節優化。細⼼的讀者會發現,根據斐波那契數列的狀態轉移⽅程,當前狀態只和之前的兩個狀態有關,其實並不需要那麼⻓的⼀個DPtable來儲存所有的狀態,只要想辦法儲存之前的兩個狀態就⾏了。所以,可以進⼀步優化,把空間複雜度降為O(1):
def func(N):
# arr = list(map(int,input().strip().split()))
# N = int(input())
def fib(N):
if N == 0:
return 0
if N == 1 or N == 2:
return 1
prev = 1 # N-2
curr = 1 # N-1
for i in range(3,N+1):
summ = prev + curr
prev = curr
curr = summ
return summ
return fib(N)
if __name__ == '__main__':
N = int(input())
c = func(N)
print(c)
for i in range(N+1):
print(func(i), end=" ")