遞歸關系的基本解法
無論是fabo_2還是fabo_3,在計算時都需要遵守遞歸表達式,即求f(n)的值時必須先求得n之前的所有序列數。這就讓我們有了一個設想,能否將斐波那契數列的遞歸表達轉換成普通的函數映射?這樣就可以在常數時間內求得f(n)。
特征方程和特征根
首先要明確的是,沒有一個通用的方法能夠解所有的遞歸關系,但是一些解法對於某些規則的遞歸相當有效,其中的特征方程法就可以用來求得斐波那契數列的顯示表達。
當一個遞歸關系滿足:
則稱遞歸關系為k階的線性(所有F的次數都是1)常系數(a1,a2,...,ak都是常數)齊次(每一項F次數都相等,沒有常數項)遞歸關系。
我們的目標是尋找遞歸關系的顯示表達,這就相當於尋找一個能夠表達F(n)函數,令這個函數是:
根據遞歸表達式:
這就使問題變成了解方程問題,這個方程稱為遞歸關系的特征方程。方程會產生k個跟x1,x2,…,xk,這些跟稱為特征根或特征解,當特征解互不相同時,則遞歸關系的通解是:
其中c1,c2,…,ck是常數,只要求得這些常系數就得到了通解的固定形態。
注:這種方法僅適用於沒有重跟的常系數線性齊次遞歸關系。
斐波那契的顯示表達式
斐波那契數列正好是常系數線性齊次遞歸關系,因此可以轉換為下面的特征方程:
接下來是求解特征根:
由於已經知道在斐波那契數列中x > 0,所以xn-2≠0,結果只能是x2-x-1=0,這就可以計算出兩個特征根:
兩個特征根互不相同,f(n)的通解滿足:
由於已知f(0)=f(1)=1,因此可以將通解轉換為方程組:
終於可以求得斐波那契數列的顯示表達了:
這是個很神奇的表達,用無理數表示了有理數。這樣斐波那契數列的代碼就更直接了:
1 import math 2 3 # 直接利用顯示公式計算斐波那契數列 4 def fabo_2(n): 5 f = (((math.sqrt(5) + 1) / 2) ** (n + 1) - ((math.sqrt(5) - 1) / 2) ** (n + 1)) / math.sqrt(5) 6 f = round(f, 1) 7 if f > int(f): 8 return int(f) + 1 9 else: 10 return int(f) 11 12 if __name__ == '__main__': 13 for i in range(0, 14): 14 print('f({0}) = {1}'.format(i, fabo_2(i)))
Python直接使用顯示公式將會得到一個浮點數,因此必須額外加上5~9行的處理。
黃金分割
現在對斐波那契數列的f(n)求極限:
這就是著名的黃金分割,一個兔子繁殖的故事最終居然和黃金分割點聯系到一起,是不是有些不可思議?
遞歸算法
我們已經見識過不少遞歸程序,它提供了一種解決復雜問題的直觀途徑,這一節我們將看到更多關於遞歸的算法。
可疑的遞歸
在程序設計中遞歸的簡單定義是一個能調用自身的程序,然而程序不能總是調用自身,否則就沒有辦法停止,所以遞歸還必須有一個明確的終止條件;另一個指導原則是——每次遞歸調用的參數值必須更小。
看看下面的程序是否是合理的遞歸?
1 # 可疑的遞歸 2 def suspicious(n, k): 3 print('\t' * k, 'suspicious({0})'.format(n)) 4 if n == 1: 5 return 1 6 elif n & 1 == 1: 7 return suspicious(n * 3 + 1, k + 1) 8 else: 9 return suspicious(n // 2, k + 1) 10 11 if __name__ == '__main__': 12 suspicious(3, 0)
如果參數n是奇數,suspicious使用3n+1來調用自身;如果n是偶數,使用n/2來調用自身。顯然並不是每次都使用更小的參數調用自身,這不符合遞歸的原則,我們也沒法使用歸納法來證明整個程序是否會終止。
代碼的運行結果:
雖然對於suspicious(3)來說,它最后終止了,但我們無法證明它是否會對某個參數有任意深度的嵌套。為了避免不必要的麻煩,還是讓遞歸程序更明確一點——每次遞歸調用的參數值都更小。
動態編程
斐波那契的遞歸代碼很優美,它能夠讓人以順序的方式思考,然而這段代碼只能作為遞歸程序的演示樣品,並不能應用於實踐。在運行時便會發現,當輸入大於30時速度會明顯變慢,普通家用機甚至無法計算出f(50)。可以通過運行下面的代碼清晰的看到變慢的原因:
1 def fabo_test(n): 2 if n < 2: 3 return 1 4 else: 5 print('\t' * (6 - n), 'f({0})'.format(n)) 6 return fabo_test(n - 1) + fabo_test(n - 2) 7 if __name__ == '__main__': 8 fabo_test(6)
第二次遞歸會忽略上一次所做的所有計算,所以很不給面子的出現了大量重復,這將導致相當大的開銷。這段代碼說明的問題是,寫一個簡單而低效的遞歸方法是相當容易的,我們需要小心這種陷阱。
知道問題的所在就可以對症下葯,只要把計算過的數據存儲起來就好了,這種方法稱為動態編程,它消除了重復計算,適用於任何遞歸計算,能夠把算法的運行時間從指數級改進到線性級,其代價是我們可以負擔得起緩存造成的空間開銷,是典型的空間換時間。
1 # 存儲所有計算過的斐波那契數 2 fabo_list = [1, 1] 3 # 用遞歸計算斐波那契序列 4 def fabo_4(n): 5 if n < len(fabo_list): 6 return fabo_list[n] 7 else: 8 fabo_n = fabo_4(n - 1) + fabo_4(n - 2) 9 fabo_list.append(fabo_n) 10 11 return fabo_n 12 13 if __name__ == '__main__': 14 for i in range(40, 51): 15 print('f({0}) = {1}'.format(i, fabo_4(i)))
運行結果:
小偷的背包
一個小偷撬開了一個保險箱,發現里面有N個大小和價值不同的東西,但自己只有一個容量是M的背包,小偷怎樣選擇才能使偷走的物品總價值最大?
假設有5個物品A,B,C,D,E,它們的體積分別是3,4,7,8,9,價值分別是4,5,10,11,13,可以用矩形表示體積,將矩形旋轉90°后表示價值:
下圖展示一個容量為17的背包的4中填充方式,其中有兩種方式的總價都是24:
背包問題有很多重要的實應用,比如長途運輸時,需要知道卡車裝載物品的最好方式。我們基於這樣的思路去解決背包問題:在取完一個物品后,找到填充背包剩余部分的最佳方法。對於一個容量為M的背包,需要對每一種類型的物品都推測一下,如果把它裝入背包的話總價值是多少,依次遞歸下去就能找到最佳方案。這個方案的原理是,一旦做出了最佳選擇就無需更改,也就是說一旦知道了如何填充較小容量的背包,則無論下一個物品是什么,都無需再次檢驗已經放入背包中的物品(已經放入背包中的物品一定是最佳方案)。
可以根據這種方案編寫代碼:
1 class Goods: 2 ''' 物品的數據結構 ''' 3 def __init__(self, size, value): 4 ''' 5 :param size: 物品的體積 6 :param value: 物品的價值 7 ''' 8 self.size = size 9 self.value = value
1 def fill_into_bag(M, goods_list): 2 ''' 3 填充一個容量是 M 的背包 4 :param M: 背包的容量 5 :param goods_list: 物品清單,包括每種物品的體積和價值,物品互不相同 6 :return: (最大價值,最佳填充方案) 7 ''' 8 space = 0 # 背包的剩余容量 9 max = 0 # 背包中物品的最大價值 10 plan = [] # 最佳填充方案 11 12 for goods in goods_list: 13 space = M - goods.size 14 if space >= 0: 15 # 在取完一個物品(goods)后,填充背包剩余部分的最佳方法 16 space_plan = fill_into_bag(space, goods_list) 17 if space_plan[0] + goods.value > max: 18 max = space_plan[0] + goods.value 19 plan = [goods] + space_plan[1] 20 21 return max, plan 22 23 def paint(plan): 24 print('最大價值:' + str(plan[0])) 25 print('最佳方案:') 26 for goods in plan[1]: 27 print('\t大小:{0}\t價值:{1}'.format(goods.size, goods.value)) 28 29 if __name__ == '__main__': 30 goods_list = [Goods(3, 4), Goods(4, 5), Goods(7, 10), Goods(8, 11), Goods(9, 13)] 31 plan = fill_into_bag(17, goods_list) 32 paint(plan)
運行結果:
遺憾的是,fill_into_bag方法同樣只能作為一個簡單的試驗樣品,它犯了和fabo_test同樣的錯誤,第二次遞歸會忽略上一次所做的所有計算,要花指數級的時間才能計算出結果!為了把時間降為線性,需要使用動態編程技術對其進行改進,把計算過的值都緩存起來,由此得到了背包問題的2.0版,當然,我們並不想把這個算法告訴小偷:
1 # 字典緩存,space:(max,plan) 2 sd = {} 3 def fill_into_bag_2(M, goods_list): 4 ''' 5 填充一個容量是 M 的背包 6 :param M: 背包的容量 7 :param goods_list: 物品清單,包括每種物品的體積和價值,物品互不相同 8 :return: (最大價值,最佳填充方案) 9 ''' 10 space = 0 # 背包的剩余容量 11 max = 0 # 背包中物品的最大價值 12 plan = [] # 最佳填充方案 13 14 if M in sd: 15 return sd[M] 16 17 for goods in goods_list: 18 space = M - goods.size 19 if space >= 0: 20 # 在取完一個物品(goods)后,填充背包剩余部分的最佳方法 21 print(goods.size, space) 22 space_plan = fill_into_bag_2(space, goods_list) 23 if space_plan[0] + goods.value > max: 24 max = space_plan[0] + goods.value 25 plan = [goods] + space_plan[1] 26 # 設置緩存,M空間的最佳方案 27 sd[M] = max, plan 28 29 return max, plan
作者:我是8位的