演算法-07 | 動態規劃
1. 分治 + 回溯 + 遞迴 + 動態規劃
它的本質即將一個複雜的問題,分解成各種子問題,尋找它的重複性。動態規劃和分治、回溯、遞歸併沒有很大的本質區別,只是小細節上的不同。
遞迴
程式碼模板
public void recur(int level, int param) { // 1.terminator 遞迴終止條件 if (level > MAX_LEVEL) { // process result return; } // 2.process current logic 處理當前層的邏輯process(level, param); // 3.drill down 遞迴到下一層去 recur(level:level + 1, newParam); // 4.restore current status 有時候需要的話即恢復當前層的狀態,如果改變的狀態都是在引數上,因為遞迴呼叫時這個引數是會被複制的,如果是簡單變數就不需要恢復這個過程。 }
分治Divide & Conquer
很多大的複雜的問題,它其實都是有所謂的自相似性即重複性,計算機可以飛快地迴圈運算。
把大的問題分解為幾個子問題,同時每個子問題也類似地分解成其他的相同的小的子問題,然後分別運算,再把結果返回,同時把結果聚合在一起
def divide_conquer(problem, param1, param2, ...): # 1.recursion terminator 遞迴終止條件 if problem is None: print_result return # 2. prepare data 拆分子問題, data = prepare_data(problem) subproblems = split_problem(problem, data) # conquer subproblems 調子問題的遞迴函式 drill down subresult1 = self.divide_conquer(subproblems[0], p1, ...) subresult2= self.divide_conquer(subproblems[1], p1, ...) subresult3 = self.divide_conquer(subproblems[2], p1, ...) … # 3. process and generate the final result 最後將這些結果合併在一塊 result = process_result(subresult1, subresult2, subresult3, …) # 4. revert the current level states 最後有可能當前層的狀態需要進行恢復
1. 人肉遞迴低效、很累
2. 找到最近最簡方法,將其拆解成可重複解決的問題
3. 養成數學歸納法的思維習慣(抵制人肉遞迴的誘惑)(先把基礎的條件,n=1,n=2時想明白,n成立時,如何推到n+1,比如炮竹的爆炸)
本質:尋找重複性 —> 計算機指令集(if...else,for, 遞迴)
對遞迴不熟時,可以人肉遞迴,畫出遞迴狀態樹:
如斐波拉契數列遞迴狀態樹:
計算第6個數,但它的狀態樹是2n,它結點(狀態樹)的擴散是指數級的,所以它的計算複雜度也是指數級
2. 動態規劃Dynamic programming
1. Wiki 定義:
https://en.wikipedia.org/wiki/Dynamic_programming
Dynamic programming,中文翻譯叫動態規劃,它本質要解決的就是一個遞迴或分治的問題,它們的不同處在於動態規劃它有所謂的最優子結構
2.“Simplifying a complicated problem by breaking it down into
simpler sub-problems”
(in a recursive manner)
用遞迴的方式,將一個複雜的問題分解為子問題。
3.Divide & Conquer + Optimal substructure
分治 + 最優子結構
一般動態規劃的問題是求一個最優解,或者求一個最大值,或者求一個最少的方式,它有一個最優子結構的存在,每一步不需要把所有的狀態都儲存下來,只需存最優的狀態即可。還需要證明:如果每一步都存一個最優值,最後可以推匯出一個全域性的最優值。
1)快取(狀態的儲存陣列)
2)在每一步把次優的狀態給淘汰掉,只保留在這一步裡面最優或者較優的狀態來推匯出最後的全域性最優。
關鍵點:
動態規劃DP 和 遞迴或者分治 沒有根本上的區別;(DP它的本質就是動態遞迴,關鍵看有無最優的子結構)
如果沒有最優的子結構,說明所有的子問題都需要計算一遍,同時把最後的結果給合併在一起,就叫分治(每次的最優解就是當前解,它沒有所謂的每次比較和淘汰的一個過程)。
共性:找到重複子問題
計算機只會for else loop
差異性:最優子結構、中途可以淘汰次優解
動態規劃,有最優子結構,淘汰次優解,這時它的複雜度是更低更有效的,傻遞迴,傻分治經常是指數級的時間複雜度,如果進行了淘汰次優解,會變成O(n2)或者O(n) 時間複雜度。把複雜度從指數級降到了多項式的級別。
斐波拉契數列
斐波拉契數列,傻遞迴,它的時間複雜度是指數級的,可以遞迴但是它是指數級的; ---->> 簡化(速度上、表達方式上)
它的狀態樹為什麼是指數級的,它每一層都是指數級的結點:
第一層1個結點,fib(6)
第二層2個,fib(5)、fib(4)
第三次4個,fib(4)、fib(3)、 fib(3)、 fib(2)
...
每一層乘 2,加一起就是2n,所以它是指數級的。
簡化:
int fib (int n) { return n <= 1 ? n : fib (n - 1) + fib (n - 2); }
簡化程式碼,並沒有改變它的時間複雜度,但是程式碼清爽一點。
如何改變時間複雜度,加一個快取,可以存在一個數組裡邊,這種方法叫記憶化搜尋 Memoization。
比如fib(3),第一次計算出來就把fib(3)存在memo裡面的3的位置,後邊fib(3)就不用計算了直接複用,不然又要從fib(1)和fib(2)算起;
繼續將上邊程式碼(邏輯不是特別清洗)優化:
fib (int n, int[] memo) { if (n <= 1) { return n; } if (memo[n] == 0) { //沒有被計算過,就從頭開始計算並存在陣列中,如果 != 0就直接return,把重複的結點就會砍掉,時間複雜度從指數級降為O(n)的時間複雜度。 memo[n] = fib (n - 1) + fib (n - 2); } return memo[n]; }
優化後的:
Bottom Up 自底向上
記憶化遞迴不如直接寫一個for迴圈,
• F[n] = F[n-1] + F[n-2] • a[0] = 0, a[1] = 1; for (int i = 2; i <= n; ++i) { a[i] = a[i-1] + a[i-2]; } • a[n] • 0, 1, 1, 2, 3, 5, 8, 13,
遞迴,一開始從最上面這個問題開始一步步向下探,最後探到它的葉子結點,葉子結點的值是確定的,<= n 時 return n;
這種方法叫自頂(自頂向下的順序), 遞迴 + 記憶化搜尋, 也比較符合人腦的思維習慣(要解決fib(6),就算fib(5)和fib(4)...中間結果算過了就直接複用);
從計算機的思維,初始值已經有了,0和1,寫fib(6),0,1,1,2,3,5,8,13 0和1相加1,1+1即2, 2+2即4, 2+3即5,...遞推...可直接用迴圈,即自底向上(直接從最下面,迴圈累加上去即可)。
PS:對於初學者或面試,可以先遞迴分治然後進行記憶化搜尋, 再轉化為自底向上的迴圈;
但對於一個熟練的選手,或者追求DP的功力深厚,尤其在計算機競賽時,只要開始寫遞迴,所有競賽選手都可以寫for迴圈,即全部都是自底向上的迴圈,開始遞推。
DP最好的翻譯是動態遞推,動態規劃最終極的一個形態就是自底向上的迴圈。
路徑計數
複雜一點的DP:
1)它的維度變化了,它的狀態有時候是二維空間或者三維
2)它中間會有所謂的取捨最優子結構。
這個人他只能向右或者向下走(不能向左或者向上走),棋盤實心表障礙物不能走,求他從start走到end,有多少種走法?
用分治思想,或者找重複性的思想:
假設棋盤沒有障礙物,棋盤大小 1*1, 2*2,....
他只有2中走法,向右到B,向下到A (轉化為了2個子問題,從B到End有多少種走法,從A到End有多少種走法, 這兩個子問題加起來就是綠人這個大問題的解)
C( Start ---> End) = C(A --> End) + C( B --> End)
有點像斐波拉契數列,只不過它是二維的。
用遞推的思想:
自底向上推:
把靠近End的格子全推一遍,向右走,那一排都是1,不能往下走,往下走就出去了;同理可得上邊一排也全都是1
看任何一個空格子,它的走法 = 右邊格子走法 + 下邊格子的走法
如果格子是石頭,那麼它的走法即是0,得到一個遞推公式:
狀態轉移方程(DP方程) opt[i , j] = opt[i + 1, j] + opt[i, j + 1] 完整邏輯: if a[i, j] = ‘空地’: opt[i , j] = opt[i + 1, j] + opt[i, j + 1] else: opt[i , j] = 0
綠人從Start 到End 走法 = 17 + 10 = 27
通過遞推只要保證初始值是對的,邏輯就是 右 + 下; 這就是數學歸納法的思維。
動態規劃關鍵點:
1. 最優子結構 opt[n] = best_of(opt[n-1], opt[n-2], …) (推匯出的第n步它的值是前面幾個值的最佳值,這個最佳值有時候就是簡單累加,有時取最大值或最小值)
2. 儲存中間狀態:opt[i] (必須定義和儲存這個中間態,這個跟分治是有區別的,分治一般把這步放遞迴裡邊了,)
3. 遞推公式(美其名曰:狀態轉移方程或者 DP 方程)
Fib: opt[i] = opt[n-1] + opt[n-2]
二維路徑:opt[i,j] = opt[i+1][j] + opt[i][j+1] (且判斷a[i,j]是否空地)
DP,它有一個篩選過程,上例是累加,有可能是最大值或最小值,