接觸DP最早的應該就是這道題了吧,翻了翻leetcode submission發現最早的是在一年前... 而且是最基礎的DP解法。在膜拜了大神們用矩陣甚至直接上斐波那契數列公式解法后覺得腦容量有點不太夠用。。。嗯,需要寫點東西捋一捋,所以這是一個學渣(p.s. 話癆)關于爬樓梯類型題目的總結(如果有不對的地方歡迎米娜桑批評指正( ̄. ̄))。
題目:Climbing Stairs
You are climbing a stair case. It takes n steps to reach to the top.Each time you can either climb 1 or 2 steps. In how many distinct ways can you climb to the top?
Input:
Argument:達到最高層的步數(Integer)
Condition: 每次只能爬一步或者兩步
Output:
達到最高層可以用多少種不同的方法(Integer)
1. 暴力遞歸
Intuitive:
想法很簡單,既然下一步只有兩種選擇:1步和2步, 那么下步走的方法也就是2種。總的方法數即走一步的方法 + 走兩步的方法。
Edge cases:
當前走的步數等于或者大于0時結束遞歸
Recursion Tree
例如目標層數是5,那么recursion tree應該是這個樣子滴
可以看到每個節點我們都有兩個選擇,走一步或者兩步。那么一共有 2^n 個節點,時間復雜度 2^n. 至于空間復雜度則是樹的深度,最壞的情況,我們每次只走一步,深度達到n。另外也不難看出在遞歸的過程中存在很多重復的計算,這就是下一個算法優化的點啦。
Code:
TC:O(2^n) SC:O(n)
public int climbStairs(int target){
return helper(0, target);
}
public int helper(int step, int target){
//edge cases
if(step == target){
return 1;
}
if(step > target){
return 0;
}
return helper(step + 1, target) + helper(step + 2, target);
}
2. 記憶化搜索
Intuitive:
所以,怎么避免重復計算呢?很明顯的想法就是把算過的存起來嘛,下次計算前先查一下,有結果就跳過,沒結果就算。就是這樣一個小小的優化, 就把復雜度從2^n縮減到n(p.s. array 查詢的復雜度是O(1)),你敢信!嗯,所以在面試官followup的時候,別老想有多fancy或者tricky的變化,逮著最明顯的那個劣勢優化就好~
Edge cases:
Edge case 是跟暴力遞歸的一樣的。
Code:
TC: O(n) SC: O(n)
public int climbStairs(int target){
int[] memo = new int[target + 1];
return helper(0, target, memo);
}
public int helper(int step, int target, int[] memo){
if (memo[step] != 0){
return memo[step];
}
if(step == target){
return 1;
}
if(step > target){
return 0;
}
memo[i] = helper(step + 1, target, memo) + helper(step + 2, target, memo);
return memo[i];
}
3. 動態規劃
Intuitive:
那么就來到高大上的DP了,曾經有一度我都認為按照我的水平面試如果遇到DP的題就基本可以跪了。雖然現在依然有點怵,但是發現DP他不是玄學,一上來就給你整個高大上的狀態轉移方程,自然會一臉懵逼。但是,如果了解了DP是怎么從暴力遞歸,記憶化搜索一步步進化而來的,那么就可以不再害怕DP這個磨人的小妖精啦。不記得從哪里看到一句話,DP是一種思想而不是算法,嗯,所以要想的多...
DP的本質就是當前結果依賴于之前結果,或者說子問題。那么我們在第i步時候的方法數就依賴于之前走的步數,在這道題就是:
dp[i] = dp[i - 1] + dp[i - 2]
Edge Cases:
那么edge cases 就很明顯了,總不能讓index小于0吧╮(-_-)╭。
- dp[1] = 1
- dp[2] = 2
Code:
TC: O(n) SC:O(n)
public int climbStairs(int target){
if (target == 1 || target == 2){
return target;
}
int[] dp = new int[target + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= target; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[target];
}
其實關于動態規劃和記憶搜索的區別,我并不是分的很清楚,有的資料說一個是top down一個是bottom up。嗯,那么對于倒著填的DP又該怎么解釋呢?在這里mark一下,以后找找資料做個記憶化搜索和動態規劃的比較v( ̄︶ ̄)y
其實從狀態轉移方程不難看出來,結果dp[i]只依賴于前兩個數dp[i - 1]和dp[i - 2]。所以在不要求打印路徑的情況下,我們真的需要一個O(n)的空間么?是不是只要維護兩個變量滾動更新就好了?那么之前的DP解法可以進一步簡化了。
Code:
TC: O(n) SC:O(1)
public int climbStairs(int target){
if (target == 1 || target == 2){
return target;
}
int pre1 = 1;
int pre2 = 2;
for (int i = 3; i <= target; i++){
int temp = pre1 + pre2
pre1 = pre2;
pre2 = temp;
}
return pre2;
}
4. 矩陣解法
Here comes the fun part~~~ (= ̄ω ̄=)
其實這部分才是我寫這篇又臭又長的博文的動機所在,那么就讓我試試用我捉襟見肘的語言表達能力和慘不忍睹的數學功底能不能解釋清楚吧...
從之前的DP解法我們知道遞推公式是這樣滴:
f(n) = f(n - 1) + f(n - 2)
那么對于這樣一個二階的遞推公式我們能不能借用一個二階的矩陣來表示這個遞推關系呢?試一試哈,設我們的謎之矩陣為
我們想促成這樣的一個表達式:
已知的一些關系為:
f(1) = 1
f(2) = 2
f(3) = 3
f(4) = 5
把這些已知的數代入這個遞推關系式中:
四個未知數四個方程,那么可以求出謎之矩陣為
那么通式為:
通式右邊的部分我們可以繼續用公式替換:
例如把:
代入通式,得:
以此類推通式可以化簡為:
那么原問題就可以被轉化成求二階矩陣n - 2次方的問題了。先不考慮矩陣,從最簡單的來哈,如果求一個數的n次方,讓你用O(lgn)的算法求出,最直接的想法是什么?沒錯轉化成二進制計算。
舉個例子, 求10 ^ 35, 可以分解為:
而35轉化為二進制為:
不難看出,只有在二進制位上是1的時候我們才需要乘以該位對應的數值,而遇到為0的情況就直接skip,運算次數減少了很多呀。
那么對于矩陣的乘法也是一樣的道理滴。最多需要你implement一個計算兩個矩陣相乘的helper function 而已。時間復雜度分析也很清楚了,矩陣的n次方可以由矩陣n/2次方的平方得出,以此類推可以有這樣一個recrusion tree:
樹的高度是lg(n), 那么我們需要做的乘法是也就是lg(n)次。
Edge Cases:
與之前差不多
Code:
TC: O(lg(n)) SC:O(1)
public int climStairs(int target){
if(target < 1){
return 0;
}
if (target == 1 || target == 2){
return target;
}
int[][] matrix = {{1, 1},{1, 0}};
int[][] res = matrixPow(matrix, target - 2);
//別忘記乘以最開始的f(2) 與 f(1)
return res[0][0] * 2 + res[0][1] * 1;
}
//計算矩陣 n 次方
public int[][] matrixPow(int[][] matrix, int n){
int r = matrix.length;
int c = matrix[0].length;
int[][] res = new int[r][c];
//單位矩陣
for (int i = 0; i < r; i++){
res[i][i] = 1;
}
int[][] placeHolder = matrix;
for(; n > 0; n >>>= 1){
if ((n & 1) == 1){
res = matrixMulti(res, placeHolder);
}
placeHolder = matrixMulti(placeHolder, placeHolder);
}
return res;
}
//計算兩矩陣相乘
public int[][] matrixMulti(int[][] m1, int[][] m2){
int m1Row = m1.length;
int m1Col = m1[0].length;
int m2Col = m2[0].length;
int[][] res = new int[m1Row][m2Col];
for (int i = 0; i < m1Row; i++){
for (int k = 0; k < m1Col; k++){
if (m1[i][k] != 0){
for (int j = 0; j < m2Col; j++){
if(m2[k][j] != 0){
res[i][j] += m1[i][k] + m2[k][j];
}
}
}
}
}
return res;
}
5. 斐波那契公式解法
最后一種是借助用斐波那契數列的解來解題,更多數學...
為了方便用二元一次方程的通解我們把斐波那契的公司小小改動一下,表達的意思還是一樣的哈:
則原方程轉化為:
兩邊都除以相同項得:
套用二元一次方程通解得:
則f(n) 可以寫為:
代入已知的f(0) = 0, f(1) = 1等... 可得:
代入之前的公式得:
大功告成~ 所以code也就是直接套用這個公式一下就好,偷個懶不寫了。另外從這個公式也不難看出斐波那契數列問題如果用brute force方法解的話復雜度是2^n的。
補充:
嗯,之所以花時間來總結這個題,就是因為很多DP的題其實本質就是個爬樓梯問題,或者說本質就是斐波那契數列問題。那么只要我們了解了這個方法論之后,那么在遇到類似的題目就可以見招拆招,萬變不離其宗啦。
舉個??:
有一對兔子,每個月都生一對兔子,小兔子長到第三個月后每個月又生一對兔子,假如兔子都不死,問每個月的兔子總數為多少?
貢獻于第n個月的兔子數量的是f(n - 1) + f(n - 3)
遞推公式是 f(n) = f(n - 1) + f(n - 3)
那么三階遞推公式就可以試試用三階遞推矩陣表示了,其余的步驟就大同小異啦。
Reference:
https://discuss.leetcode.com/topic/30625/easiest-java-solution
https://leetcode.com/problems/climbing-stairs/solution/