例題描述:
設有一個三角形的數塔,頂點爲根結點,每個結點有一個整數值。從頂點出發,可以向左走或向右走,求出從頂到底連起來的最短路徑,如圖所示:(對於節點中的數據可以自己定義)
解題思路:
一、先思考是否可以用遞歸解決,這道題明顯可以使用dfs遞歸算出所有可能性,然後求出最短路徑。
對於上圖的遞歸而言,應從上往下走,2->3或2->4,3->6或者3->5等(即向左右子節點走)
- 本題最經典的做法就是將其當作一個二維數組,如a[0][0] = 2,a[1][0] = 3,a[1][1] = 4
- 接下來可以定義一個函數來指明節點a[i][j]的下一步,此處將其設爲traverse(i,j)
- 對於每一個節點,在大方向上做兩個操作,一是記錄本節點的值,二是進入遞歸函數遍歷他的左右子節點。
- 在不斷向下遞歸的過程中,問題的規模在不斷的縮小,最終到達最後一條邊停止,也就是最下面一行。
遞歸的思路較爲簡單明瞭,直接看代碼分析複雜度:
public class 遞歸三角形 {
//將二維數組後面的空缺補0,方便計算
static int [][] triangle = {
{2, 0, 0, 0},
{3, 4, 0, 0},
{6, 5, 7, 0},
{4, 1, 8, 3}
};
public static int traverse(int i, int j) {
int row = 4;//共有4行
if(i >= row-1)//此處因爲traverse函數從0開始
{
return 0;
}
int left = traverse(i+1,j)+triangle[i+1][j];//遞歸左子樹節點
int right = traverse(i+1,j+1)+triangle[i+1][j+1];//遞歸右子樹節點
return Math.min(left, right);//計算出兩個子樹的最短路徑
}
public static void main(String[] args) throws Throwable{
int sum = traverse(0,0)+triangle[0][0];
System.out.println(sum);
}
}
遞歸一共就兩個重難點:
1.注意遞歸函數的參數傳遞問題:很多人對於遞歸函數的參數不知道應該寫幾個,應該寫些什麼參數,不清楚自己需要哪些參數完成任務,個人的思路是:先寫函數內容,再確定參數。首先明確,這個函數中我是否需要數組中的元素,是否要傳遞數組;然後是我的遞歸出口應該是什麼,是否需要傳遞過去,最後考慮,遞歸過程中,我使用到了哪些東西,這樣思考較爲全面。例如本題中,使用到了二維數組的行與列,但對於二維數組本身,並未有要求,所以triangle數組可傳可不傳。
2.注意遞歸的出口問題:首先不要急於寫代碼,先思考清楚這個遞歸到哪裏就會停止,是否會有多個出口條件,出口的參數類型是否唯一。本題的出口條件十分簡單,就是到了最後一行直接返回。
traverse(i, j) = {
traverse(i+1, j); 向節點i,j 下面的左節點走一步
traverse(i+1, j+1); 向節點i,j 下面的右節點走一步
}
而其複雜度是非常高的,因爲他要不斷地重複計算左右子節點,有重複的計算(本題中,對於節點 3 和 4 來說,如果節點 3 往右遍歷, 節點 4 往左遍歷,都到了節點 5,節點 5 往下遍歷的話就會遍歷兩次,所以此時就會出現重複子問題),所以複雜度爲O(2^n)。
二、思考是否可以用優化遞歸解決,基本上所有的遞歸問題都是可被優化的,優化又叫做"剪枝",也叫做"備忘錄法"
顯然可以使用最"強"數據結構HashMap來解決這個問題,不太瞭解HashMap的同學可以去百度搜一下,簡單來說就是一個鍵值對的集合,特點就是存取速度奇快無比,我親測,一道算法題用for循環跑下來需要98ms,39.12M,而用HashMap跑只需要3ms,42.05M。
import java.util.HashMap;
public class 遞歸三角形hashmap優化 {
static int [][] triangle = {
{2, 0, 0, 0},
{3, 4, 0, 0},
{6, 5, 7, 0},
{4, 1, 8, 3}
};
static HashMap<String, Integer> map = new HashMap<String, Integer>();
public static int traverse(int i, int j) {
String key = i+""+j;
if (map.get(key)!=null) {
return map.get(key);
}
int line = 4;
if (i >= 3) {
return 0;
}
int left = traverse(i+1, j)+triangle[i+1][j];
int right = traverse(i+1, j+1)+triangle[i+1][j+1];
int res = Math.min(left, right);
map.put(key, res);
return res;
}
public static void main(String[] args) {
int sum = traverse(0,0)+triangle[0][0];
System.out.println(sum);
}
}
代碼與上面的重複度很高,只是加入了map的一些用法,將其存入了集合中。由於用hash表存儲了節點的狀態,所以其時間和空間的複雜度都是O(n)。
二、思考是否可以用動態規劃解決,也就是自底向上的方法,或者叫"遞推"
入手:明確自底向上的含義
1.題目中要求2到底層的最短路徑,那就應該先求3和4到底層的路徑互相比較出最短的,再加上2,就得出了最短路徑
2.遞推一次,先求3到底層的最短路徑,應該先求出其左右子節點到底層的最短路徑,即6和5到底層的路徑互相比較出最短的,加上3,得出3到底層最短路徑;再求4到底層的最短路徑,應該先求出其左右子節點到底層的最短路徑,即5和7到底層的路徑互相比較出最短的,加上4,得出4到底層最短路徑.
3.依次遞推,注意結束位置是在倒數第二行,因爲最後一行已經固定不動,且各自就已經是最短路徑了。
以此來看,問題已經轉換成先求倒數第二行的各自的最小路徑,然後再求倒數第三行等等。
這裏我們明確以下最優子結構,每一層節點到底部的最短路徑依賴於它下層的左右節點的最短路徑,求得的下層兩個節點的最短路徑對於依賴於它們的節點來說就是最優子結構,最優子結構對於子問題來說屬於全局最優解,這樣我們不必去求節點到最底層的所有路徑了,只需要依賴於它的最優子結構即可推導出我們所要求的最優解,所以最優子結構有兩層含義,一是它是子問題的全局最優解,依賴於它的上層問題只要根據已求得的最優子結構推導求解即可得全局最優解,二是它有緩存的含義,消除重疊子問題。
動態規劃解題的重中之重:找到DP的狀態和狀態轉移方程
dp[i,j] = Math.min(dp[i+1,j],dp[i+1,j+1]) + triangle[i,j]
一般來說,dp的狀態轉移方基本都是一維數組主要用來存儲和緩衝的,下面來分析具體的代碼和思路要點:
public class 動態規劃 {
//可以注意到這裏的數組構成不太一樣,後文會說
static int [][] triangle= {
{2},
{3, 4},
{6, 5, 7},
{4, 1, 8, 3}
};
private static int f() {
int line = 4;
int dp[] = triangle[line-1];//line-1代表的是行數編號
//兩重循環後文會說
for (int i = line-2; i >= 0; i--) {
for (int j = 0; j < triangle[i].length; j++) {
dp[j] = triangle[i][j]+Math.min(dp[j],dp[j+1]);
}
}
return dp[0];
}
public static void main(String[] args) {
int result = f();
System.out.println(result);
}
}
- 雙重循環的思路:首先要知道行數的編號是如何編的,由上到下依次爲0,1,2,3,共計4行數字。因爲本題的dp方法是自底向上,而最後一行不需要參與dp,所以dp的數組只有line-1個位置,即只有0,1,2這3個位置。我們是從下往上一行行遞推,所以外循環是指行數的變換,由line-2 -> 0;內循環是指一行中的兩兩互相比較(因爲只有左右兩個子節點),代碼的第一個重點就是 j < triangle[i].length 如何理解,其中的triangle[i]指的是這一行的長度,就是行內比較,可以理解爲i爲x軸,j爲y軸,這就引出了第二點。
- 數組的構建爲何後面不補0了:還是因爲 j < triangle[i].length。如果補0,則他會在每一行裏多出若干個0,再兩兩比較得出0這個值,會導致程序錯誤