1. 程式人生 > >算法系列-動態規劃(1):初識動態規劃

算法系列-動態規劃(1):初識動態規劃

昨天,羅拉去面試回來,垂頭喪氣。顯然是面試不順利,我趕忙過去安慰。 經過詢問才知道,羅拉麵試掛在了動態規劃。 說到動態規劃,八哥可就來精神了,於是就結合勞拉的面試題簡單的和她介紹了動態規劃。 事情是這樣的,勞拉的面試官給了她一道題,題目如下: ``` 有一個數列,規律如下:1、1、2、3、5、8、13.... 如果要求第N個數值,用程式碼如何實現。 ``` 羅拉一看這題,心裡一喜,“這題目,不簡單嗎?”。 於是和麵試官賣弄道:“這不是斐波那契數列嗎?這個數列從第3項開始,每一項都等於前兩項之和”。 面試官笑笑,“沒錯,那麼如何實現求第n個數呢?” “這簡單,稍後”,羅拉毫不含糊,在紙上啪啪寫下幾行程式碼,很快哈,兩分鐘不到,她就寫出來了,只用了兩行程式碼。 ``` public class Fibonacci { public int rec_fib(int n) { if (n == 1 || n == 2) return 1; else return rec_fib(rec_fib(n - 1) + rec_fib(n - 2)); } } ``` 八哥仔細一看,好傢伙,年輕人不講碼德啊,直接遞迴。 在羅拉仔細準備迎接面試官得誇獎的時候。 面試官問:“遞迴,不錯,還有更好的方法嗎?” 羅拉懵了,她覺得自己的程式碼夠簡單,應該沒啥問題吧。 仔細想了一會兒,也沒想出其他的辦法。最後只能和麵試官互道珍重回家等通知了。 --- 那麼,大家發現這個寫法的問題了嗎? 下面八哥就和大家嘮嗑嘮嗑。 首先,寫法肯定是沒問題的,但是問題出在遞迴上面。 下面,我們分別計算一下``n=10`` 和 ``n=45`` 的時候,看看這個程式耗費的時間 ``` public class Fibonacci { public static void main(String[] args) { long star = System.currentTimeMillis(); System.out.println(rec_fib(10)); long end = System.currentTimeMillis(); System.out.println("計算n=10 耗時:"+(end - star)/1000 + "s"); star = System.currentTimeMillis(); System.out.println(rec_fib(45)); end = System.currentTimeMillis(); System.out.println("計算n=45 耗時:"+(end - star)/1000 + "s"); } public static long rec_fib(int n) { if (n == 1 || n == 2) return 1; else return rec_fib(n - 1) + rec_fib(n - 2); } } ``` 輸出結果如下: ``` 55 計算n=10 耗時:0s 1134903170 計算n=45 耗時:3s ``` 發現沒?計算``fn(45)``的居然花了三秒多,如果我們計算``100,1000``那豈不是原地螺旋爆炸? 那為啥會計算``fn(45)``會花這麼多時間呢?接下來我們就分析分析。 首先我們根據這個數列的特點,很容易寫出下面的推導公式。 ![推導公式](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201171816922-1779313610.png) 然後,我們可以畫一下遞迴圖 ![遞迴圖](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172023174-2117922396.png) 發現問題沒有?是不是發現有些資料被多次計算?比如``f(48)``被算了兩次,``f(47)``會被算3次,越往下算的越多。 ![重複計算](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172042323-76290299.png) 仔細想想,按照這樣重複計算,``n = 50``那得重複多少次啊。 我們再來分析一下羅拉寫的這個演算法的時間複雜度。 按照我們這麼拆分下去,很容易發現,這玩意就基本等於一顆完全二叉樹了。自然時間複雜度就是: ![](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172055752-2013439685.png) 指數級別的時間複雜度,不爆炸都對不起遞迴了好吧。 --- 出了問題,我們就要解決問題。 打蛇打七寸,既然知道痛點是重複計算,那我們從重複計算的地方著手就好了。 我們很容易想到把計算過的值存起來,用的時候直接用就好了。 比如我們可以用資料記錄計算過的值。 羅拉聽完,若有所思,隨後啪啪一份程式碼就出來了。 ``` public class Fibonacci { public static long men_fib(int n) { if (n < 0) return 0; if (n <= 2) return 1; long[] men = new long[n + 1]; men[1] = 1; men[2] = 1; menHelper(men, n); return men[n]; } public static long menHelper(long[] men, int n) { if (n == 1 || n == 2) return 1; if (men[n] != 0) return men[n]; men[n] = menHelper(men, n - 1) + menHelper(men, n - 2); return men[n]; } } ``` 使用一個``men[n]``陣列記錄計算過的值,這樣避免了重複計算。 這個時候羅拉又重新執行``f(10)和fn(45)``,檢視執行時間. ``` public class Fibonacci { public static void main(String[] args) { long star = System.currentTimeMillis(); System.out.println(men_fib(10)); long end = System.currentTimeMillis(); System.out.println("計算n=10 耗時:" + (end - star) / 1000 + "s"); star = System.currentTimeMillis(); System.out.println(men_fib(45)); end = System.currentTimeMillis(); System.out.println("計算n=45 耗時:" + (end - star) / 1000 + "s"); } public static long men_fib(int n) { if (n < 0) return 0; if (n <= 2) return 1; long[] men = new long[n + 1]; men[1] = 1; men[2] = 1; menHelper(men, n); return men[n]; } public static long menHelper(long[] men, int n) { if (n == 1 || n == 2) return 1; if (men[n] != 0) return men[n]; men[n] = menHelper(men, n - 1) + menHelper(men, n - 2); return men[n]; } } ``` 執行結果 ``` 55 計算n=10 耗時:0s 1134903170 計算n=45 耗時:0s ``` 看,基本都是瞬間執行完。 即使計算``f(100)``,也很快。 ``` 3736710778780434371 計算n=100 耗時:0s ``` 效率提升可觀吧,如果羅拉當時這麼做了,至少還能再蹭一杯茶。然後再相忘江湖吧。 我們使用一個數據記錄計算過的值,相當於整了一個備忘錄,這是遞迴常見的優化方式。這個其實已經有了一點動態規劃的味道。 不過呢,這個帶備忘錄的遞歸屬於自頂向下的方法。那怎麼理解自頂向下呢?廢話不多說,上圖 ![自頂向下](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172115377-1150208759.png) 看這個圖,我們執行的時候是按照這個順序``f(50),f(49)...f(1),f(1)``執行的吧,從上往下計算,可以粗略的認為這就是自頂向下。 我們還可以採用自底向上的方式,也就是按照下面的形式 ![自底向上](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172128755-259046342.png) 我們還是用一個數組``dp``記錄計算過值,因為我們已經知道了,第1個和第2個數。所以我們可以通過第1個和第2個數。從1開始,遞推出50,這個就是自底向上。 按照這個思路,羅拉很快,一分鐘不到哈,就寫出了程式碼,年輕人就是雷厲風行。 ``` public static long fib(int n) { if (n == 1 || n == 2) return 1; int[] dp = new int[n + 1]; dp[1] = 1; dp[2] = 1; for (int i = 3; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 1]; return dp[n]; } ``` 同樣執行了執行``f(10)和fn(45)`` ``` public class Fibonacci { public static void main(String[] args) { long star = System.currentTimeMillis(); System.out.println(fib(10)); long end = System.currentTimeMillis(); System.out.println("計算n=10 耗時:" + (end - star) / 1000 + "s"); star = System.currentTimeMillis(); System.out.println(fib(45)); end = System.currentTimeMillis(); System.out.println("計算n=100 耗時:" + (end - star) / 1000 + "s"); } public static long fib(int n) { if (n == 1 || n == 2) return 1; int[] dp = new int[n + 1]; dp[1] = 1; dp[2] = 1; for (int i = 3; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 1]; return dp[n]; } } ``` 檢視執行時間。 ``` 55 計算n=10 耗時:0s 1134903170 計算n=45 耗時:0s ``` 答案顯而易見,效果與備忘錄一樣,這個時候我們再分析一下時間複雜度。 這種自底向上方式就是動態規劃。(ps:自頂向上不等於動態規劃) 整個過程,我們就用了一個額外陣列``dp``,和一個``for``迴圈,那麼很容易得到時間複雜度為 ![](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172225967-2086402287.png) 這對指數級別的時間複雜度,在N比較大的情況下,就是降維打擊啊。 可能有人有疑問了,我如果對遞迴用了備忘錄優化,不是可以達到一樣的效果嗎?這樣的話動態規劃有什麼優勢呢? 年輕人別急嘛,動態規劃沒那麼簡單,當然掌握核心思想也不難。 我這只是舉個例子,其實斐波那契數列沒必要用動態規劃,只是這個例子比較簡單而已,剛好可以用來入門。 動態規劃也不是用於解決這類問題的。 **動態規劃通常用來求解最優化問題,一般此類問題有很多的解,我們希望找到一個最優的解(比如最大值、最小值)**。 > 注意我說的是我們找的解是一個最優解,而不是最優解,因為一個問題可能有多個解都是最優解。 是不是有點難以理解?那我舉個例子: >比如,我有100米的鋼材,可以切成不同的長度出售,不同長度價格不同。 >就像圖中劃分那樣,如果我們要賺最多錢,怎麼賣比較好呢? 這個時候你用備忘錄就很難做了吧。 ![](https://img2020.cnblogs.com/blog/1293390/202012/1293390-20201201172148302-1275955220.png) 怎樣,沒頭緒了吧,別急用動態規劃就很容易做這類題目,至於怎麼做,且聽下回分解。 歡迎關注八哥:兔八哥雜談,會持續更新一些文章。 此文為原創文章,轉自啊請註明出處!!!