昨天,羅拉去面試回來,垂頭喪氣。顯然是面試不順利,我趕忙過去安慰。
經過詢問才知道,羅拉面試掛在了動態規划。
說到動態規划,八哥可就來精神了,於是就結合勞拉的面試題簡單的和她介紹了動態規划。
事情是這樣的,勞拉的面試官給了她一道題,題目如下:
有一個數列,規律如下: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)
會花這么多時間呢?接下來我們就分析分析。
首先我們根據這個數列的特點,很容易寫出下面的推導公式。
然后,我們可以畫一下遞歸圖
發現問題沒有?是不是發現有些數據被多次計算?比如f(48)
被算了兩次,f(47)
會被算3次,越往下算的越多。
仔細想想,按照這樣重復計算,n = 50
那得重復多少次啊。
我們再來分析一下羅拉寫的這個算法的時間復雜度。
按照我們這么拆分下去,很容易發現,這玩意就基本等於一顆完全二叉樹了。自然時間復雜度就是:
指數級別的時間復雜度,不爆炸都對不起遞歸了好吧。
出了問題,我們就要解決問題。
打蛇打七寸,既然知道痛點是重復計算,那我們從重復計算的地方着手就好了。
我們很容易想到把計算過的值存起來,用的時候直接用就好了。
比如我們可以用數據記錄計算過的值。
羅拉聽完,若有所思,隨后啪啪一份代碼就出來了。
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
效率提升可觀吧,如果羅拉當時這么做了,至少還能再蹭一杯茶。然后再相忘江湖吧。
我們使用一個數據記錄計算過的值,相當於整了一個備忘錄,這是遞歸常見的優化方式。這個其實已經有了一點動態規划的味道。
不過呢,這個帶備忘錄的遞歸屬於自頂向下的方法。那怎么理解自頂向下呢?廢話不多說,上圖
看這個圖,我們執行的時候是按照這個順序f(50),f(49)...f(1),f(1)
執行的吧,從上往下計算,可以粗略的認為這就是自頂向下。
我們還可以采用自底向上的方式,也就是按照下面的形式
我們還是用一個數組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 - 2];
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 - 2];
return dp[n];
}
}
查看執行時間。
55
計算n=10 耗時:0s
1134903170
計算n=45 耗時:0s
答案顯而易見,效果與備忘錄一樣,這個時候我們再分析一下時間復雜度。
這種自底向上方式就是動態規划。(ps:自頂向上不等於動態規划)
整個過程,我們就用了一個額外數組dp
,和一個for
循環,那么很容易得到時間復雜度為
這對指數級別的時間復雜度,在N比較大的情況下,就是降維打擊啊。
可能有人有疑問了,我如果對遞歸用了備忘錄優化,不是可以達到一樣的效果嗎?這樣的話動態規划有什么優勢呢?
年輕人別急嘛,動態規划沒那么簡單,當然掌握核心思想也不難。
我這只是舉個例子,其實斐波那契數列沒必要用動態規划,只是這個例子比較簡單而已,剛好可以用來入門。
動態規划也不是用於解決這類問題的。
動態規划通常用來求解最優化問題,一般此類問題有很多的解,我們希望找到一個最優的解(比如最大值、最小值)。
注意我說的是我們找的解是一個最優解,而不是最優解,因為一個問題可能有多個解都是最優解。
是不是有點難以理解?那我舉個例子:
比如,我有100米的鋼材,可以切成不同的長度出售,不同長度價格不同。
就像圖中划分那樣,如果我們要賺最多錢,怎么賣比較好呢?
這個時候你用備忘錄就很難做了吧。(ps:也是能做的)
怎樣,沒頭緒了吧,別急用動態規划就很容易做這類題目,至於怎么做,且聽下回分解。
歡迎關注八哥:兔八哥雜談,會持續更新一些文章。
此文為原創文章,轉自請注明出處!!!