1. 程式人生 > 其它 >斐波那契數列4種解法(暴力遞迴+動態規劃)

斐波那契數列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=" ")