【資料結構與演算法】之遞迴的基本介紹---第六篇
一、遞迴的基本概念
1、定義
遞迴:指的是一個過程,函式直接或者間接的呼叫自己,此時則發生了遞迴。
遞迴的兩個要素:遞推公式和遞迴邊界
可以看到遞迴的定義非常的簡潔,但是理解起來就沒有這麼容易了。不知道大家是否和我一樣,在遇到遞迴問題的時候,總是試圖去一步一步的分析,然而往往遞迴不了幾次,我就已經迷糊了。這並不是我們的理解能力和邏輯能力有問題,而是遞迴這種思想並不符合我們人類的思維習慣,相對於遞迴,我們更加容易理解迭代,生活中做事情不就是拆成一步一步的去做嘛。但是,在實際的生活場景中,幾乎不會遇見遞迴的思想。所以我們不能夠直觀的理解遞迴這種思想,也是很正常的,而且實際應用中也不用去搞清楚整個遞迴過程,比只需要知道遞迴過程中關鍵的兩個要素就可以了,下文中會詳細講解。
【ps:上面講了這麼多,就是想告訴自己以及看到這篇文章的你,不要試圖去理解中整個遞迴的過程,這是在給自己製造麻煩,或許真正理解遞迴的人也無法用幾句話把遞迴給講清楚,這就是隻可意會不可言傳吧,多看多練,肯定都會弄明白的】
先來看個簡單的案例:1+2+3+ ... +n 求和
我們以往遇到這個問題,有的人直接用for迴圈搞定,有的人通過等差數列公式求出,實際上我們也可用遞迴的方式求和。
// 求和 0 + 1 + 2 + 3 + ... + 9 public class RecursionDemo1 { public static void main(String[] args) { int sum = getSum(9); System.out.println(sum); int sum2 = Sum(10); System.out.println(sum2); } // 等差數列方式 public static int Sum(int n){ if(n < 0){ return 0; }else{ return (n * (n - 1)) / 2; } } // 遞迴的方式求和 public static int getSum(int n){ if(n < 0){ return 0; }else{ return getSum(n-1) + n; } } }
2、什麼時候用遞迴?
遞迴的基本思想就是“自己調自己”,遞迴方法實際上體現了“以此類推”、“用同樣的步驟重複”的思想,它可以用簡單的程式來解決一些較為複雜的問題,但是運算量卻很大。
儘管遞迴程式不是很好理解,運算量也很大,但是遞迴程式的使用還是非常頻繁的。無論是直接遞迴還是間接遞迴,都需要實現當前層呼叫下一層時的引數傳遞,並取得下一層所返回的結果,並向上一層呼叫返回當前層的結果。【遞和歸的兩個過程】。
2.1 使用遞迴需要滿足的三個條件:
(1)、一個問題的解可以分解為幾個子問題的解,所謂的子問題就是資料規模更小的問題;
(2)、這個問題與分解後的子問題,除了資料規模不同,求解思路完全一樣;
(3)、存在遞迴終止條件,不可以無限的進行遞迴迴圈。
2.2 編寫遞迴程式碼的關鍵步驟:
(1)、根據將大問題拆分為小問題過程中的規律,總結出遞推公式;
(2)、確定終止條件;
(3)、將遞推公式和終止條件翻譯成程式碼。
光說定義,還是有點抽象的,那下面就是“王道”時間,上程式碼!
3、遞迴的優缺點
優點:程式碼的表達力很強,寫起來比較簡潔;
缺點:空間複雜度較高,有堆疊溢位的風險、存在重複計算等問題。
對於上面的缺點,下面提出兩個應對策略:
(1)堆疊溢位:可以宣告一個全域性變數來控制遞迴的深度,從而避免堆疊的溢位;
(2)重複計算:通過某種資料結構(比如:連結串列)來儲存已經求解過的值,下次遇到的時候,進行查詢,如果找到了則直接拿出來用,沒有找到再進行計算,並將計算結果放入該資料結構中,這樣就可以避免重複計算。
二、常見的遞迴案例
案例1:遞迴階乘的實現: n! = n * (n - 1) * (n - 2)* ...*1 (n > 0)
// 階乘的遞迴實現
public static int mulity(int n){
if(n == 1){
return 1;
}
return n * mulity(n-1);
}
上面這個程式的遞迴過程如下,我們以mulity(4)為例:
// 計算表示式表示
mulity(4) = 4 * mulity(3)
= 4 * (3 * mulity(2) )
= 4 * (3 * (2 * mulity(1) ) )
= 4 * (3 * (2 * (1 * mulity(0) ) ) )
= 4 * (3 * (2 * (1 * 1) ) )
= 4 * (3 * (2 * 1) )
= 4 * (3 * 2)
= 4 * 6
= 24
// 函式式表示
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
案例2:判斷一串字串中是否有相同的內容
// 判斷一系列字串中是否有相同的內容
public static boolean isContains(String[] arr) {
int n = 0;
boolean b = false;
if (n == arr.length) {
b = true;
} else {
for (int i = n; i < arr.length - 1; i++) {
if(arr[n].equals(arr[i + 1])){
System.out.println("重複位置的下標為為:" + n + "和" + (i + 1));
return true;
}
}
n++;
isContains(arr);
}
return b;
}
// 測試程式碼
public static void main(String[] args) {
String[] str = {"a","b","c","a","d","a","s"};
boolean contains = isContains(str);
System.out.println(contains);
}
案例3:陣列中元素求和
// 陣列元素求和
public static int arraySum(int array[], int n){
if(n == 1){
return array[0];
}else{
return array[n - 1] + arraySum(array, --n);
}
}
// 陣列元素求和,無需引數:元素的個數n
public static int arrSum(int array[]){
int len = array.length;
if(len == 1){
return array[0];
}else{
// 遞推公式,簡單推導下,就可以找出來,每次最後兩個位置的元素值相加,其和放在倒數第二個位置上,再和倒數第三個相加,依次類推
array[len - 2] = array[len - 2] + array[len - 1];
int[] tempArr = new int[len - 1];
System.arraycopy(array, 0, tempArr, 0, len - 1);
return arrSum(tempArr);
}
}
// 測試案例
public static void main(String[] args) {
int[] arr = {1, 2, 3};
int arraySum = arraySum(arr, 3);
System.out.println(arraySum);
int arrSum = arrSum(arr);
System.out.println(arrSum);
}
案例4:河內塔問題
河內之塔(Towers of Hanoi)是法國人M.Claus(Lucas)於1883年從泰國帶至法國的,河內為越戰時北越的首都,即現在的胡志明市;1883年法國數學家 Edouard Lucas曾提及這個故事,據說創世紀時Benares有一座波羅教塔,是由三支鑽石棒(Pag)所支撐,開始時神在第一根棒上放置64個由上至下依由小至大排列的金盤(Disc),並命令僧侶將所有的金盤從第一根石棒移至第三根石棒,且搬運過程中遵守大盤子在小盤子之下的原則,若每日僅搬一個盤子,則當盤子全數搬運完畢之時,此塔將毀損,而也就是世界末日來臨之時。事實上,若有n個盤子,則移動完畢所需之次數為2^n - 1,所以當盤數為64時,則所需次數為:264- 1 = 18446744073709551615 為5.05390248594782e+16年,也就是約5000世紀,如果對這數字沒什麼概念,就假設每秒鐘搬一個盤子好了,也要約5850億年左右。
解題思路:
三支棒子從左到右依次編號為:A、B、C。
當A棒只有1個盤子的時候,只需要將A棒上的一個盤子移到C棒上;
當A棒上有2個盤子的時候,先將A棒上的1號盤子移動到B棒上,再將A棒上的2號盤子移動到C棒上,最後再將B棒上的1號盤子移動到C棒上;
當A棒上有3個盤子的時候,先將A棒上的1號盤子搬移到C棒上,再將A棒上的2號盤子搬移到B棒上,再將C棒上的1號盤子搬移到B棒上,然後將A棒上的3號盤子搬移至C棒上,再將B棒上的1號盤子搬移至A棒上,B棒上的2號盤子搬移到C棒上,最後將A棒上的1號盤子搬移到C棒上。
當A棒上有4個盤子的時候,先將A棒上的1號盤子搬移到B棒上,2號盤子搬移至C棒上,然後將B棒上的一號盤子搬移到C棒上,再將A棒上的3號盤子搬移到B棒上,再將C棒上的一號盤子搬移到A棒上,再將C棒上的2號盤子搬移到B棒上,再將A棒上的1號盤子搬移到B棒上,然後將A棒上的4號盤子搬移到C棒上,然後將B棒上的1號盤子搬移到C棒上,B棒上的2號盤子搬移到A棒上,再將C棒上的1號盤子搬移到A棒上,然後將B棒上的3號盤子搬移到C棒上,再將A棒上的1號盤子搬移到B棒上,將A棒上的2號盤子搬移到C棒上,最後將B棒上的1號盤子搬移到C棒上,結束!
所以,當A棒上有n個盤子的時候,我們可以將整個盤子搬移過程大概分為下面三步:
(1)先將A棒上編號為1~n的盤子(共n-1個)搬移到C棒上(需要藉助C棒);
(2)再將A棒上最大的n號盤子搬移到C棒上;
(3)最後將B棒上的n-1個盤子藉助A棒移動到C棒上
public class HNT {
static int i = 1;
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
char from = 'A', depend_on = 'B', to = 'C';
hanio(n, from, depend_on, to);
}
public static void hanio(int n, char from, char depend_on, char to){
if(n == 1){
move(n, from, to);
}else{
hanio(n - 1, from, to, depend_on); // 藉助C棒,將A棒上的n-1個盤子搬移到B棒上
move(n, from, to); // 將A棒上的n號盤子搬移到C棒上
hanio(n - 1, depend_on, from, to); // 將B棒上的n-1個盤子藉助A棒移動到C棒上
}
}
public static void move(int n, char from, char to){
System.out.println("第" + (i++) + "步:" + n + "號盤子" + from + "移動到" + to);
}
}
【ps:對於上面的過程,你只需要明白劃分出的三個步驟就行,不要試圖去搞清楚一個數量級比較大的盤子搬移過程,只會自找麻煩,要相信計算機可以給你算出準確的結果】
【最後將極客時間《資料結構與演算法之美》中的一段話作為本篇博文的結尾,也是我覺得最重要的!!!】
對待遞程式碼的理解:對於遞迴程式碼,如果你想試圖弄清楚整個遞迴(包括:遞和歸)的過程,實際上是進入了一個思想誤區。當你遇到遞迴程式碼的時候,如果一個問題A可以分解為若干個子問題B、C、D,那麼你可以假設子問題A、B、C已經解決。而且你只需要思考問題A與其子問題B、C、D兩層之間的關係即可,不需要一層層的往下思考子問題與子子問題,子子問題與子子子問題之間的關係。遮蔽掉遞迴的細節,將遞迴的程式碼抽象成一個遞推公式,不用想一層層的呼叫關係,更不要試圖用人腦去分解遞迴的每個步驟。