1. 程式人生 > >【資料結構與演算法】之遞迴的基本介紹---第六篇

【資料結構與演算法】之遞迴的基本介紹---第六篇

一、遞迴的基本概念

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兩層之間的關係即可,不需要一層層的往下思考子問題與子子問題,子子問題與子子子問題之間的關係。遮蔽掉遞迴的細節,將遞迴的程式碼抽象成一個遞推公式,不用想一層層的呼叫關係,更不要試圖用人腦去分解遞迴的每個步驟。

參考及推薦: