1. 程式人生 > >回爐篇10—資料結構(9)之遞迴

回爐篇10—資料結構(9)之遞迴

前言:

記得小時候經常講的一個故事:從前有座山,山上有座廟,廟裡有一個老和尚和一個小和尚,一天,老和尚給小和尚講了一個故事,故事內容是“從前有座山,山上有座廟,廟裡有一個老和尚和一個小和尚,一天,老和尚給小和尚講了一個故事,故事內容…”

什麼是遞迴,上面的小故事就是一個明顯的遞迴。以程式設計的角度來看,程式呼叫自身的程式設計技巧稱為遞迴( recursion)。

百度百科中的解釋是這樣的:遞迴做為一種演算法在程式設計語言中廣泛應用。 一個過程或函式在其定義或說明中有直接或間接呼叫自身的一種方法,它通常把一個大型複雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞迴策略只需少量的程式就可描述出解題過程所需要的多次重複計算,大大地減少了程式的程式碼量。遞迴的能力在於用有限的語句來定義物件的無限集合。


1.遞迴的定義

遞迴,就是在執行的過程中呼叫自己。

遞迴必須要有三個要素:

①、邊界條件
②、遞迴前進段
③、遞迴返回段

當邊界條件不滿足時,遞迴前進;當邊界條件滿足時,遞迴返回。

2.求一個數的階乘:n!

n! = n*(n-1)(n-2)

規定:
①、0!=1

②、1!=1

③、負數沒有階乘

上面的表示式我們先用for迴圈改寫:

/**
 * 0!=1  1!=1
 * 負數沒有階乘,如果輸入負數返回-1
 * @param n
 * @return
 */
public static int getFactorialFor
(int n){ int temp = 1; if(n >=0){ for(int i = 1 ; i <= n ; i++){ temp = temp*i; } }else{ return -1; } return temp; }

如果求階乘的表示式是這樣的呢?

n! = n*(n-1)!

我們用遞迴來改寫:

/**
 * 0!=1  1!=1
 * 負數沒有階乘,如果輸入負數返回-1
 * @param n
 * @return
 */
public
static int getFactorial(int n){ if(n >= 0){ if(n==0){ System.out.println(n+"!=1"); return 1; }else{ System.out.println(n); int temp = n*getFactorial(n-1); System.out.println(n+"!="+temp); return temp; } } return -1; }

我們呼叫該方法getFactorial(4);即求4!列印如下:
在這裡插入圖片描述
這段遞迴程式的邊界條件就是n==0時,返回1,具體呼叫過程如下:
在這裡插入圖片描述


3.遞迴的二分查詢

注意:二分查詢的陣列一定是有序的!!!

在有序陣列array[]中,不斷將陣列的中間值(mid)和被查詢的值比較,如果被查詢的值等於array[mid],就返回下標mid; 否則,就將查詢範圍縮小一半。如果被查詢的值小於array[mid], 就繼續在左半邊查詢;如果被查詢的值大於array[mid], 就繼續在右半邊查詢。 直到查詢到該值或者查詢範圍為空時, 查詢結束。
在這裡插入圖片描述
不用遞迴的二分查詢如下:

/**
 * 找到目標值返回陣列下標,找不到返回-1
 * @param array
 * @param key
 * @return
 */
public static int findTwoPoint(int[] array,int key){
    int start = 0;
    int last = array.length-1;
    while(start <= last){
        int mid = (last-start)/2+start;//防止直接相加造成int範圍溢位
        if(key == array[mid]){//查詢值等於當前值,返回陣列下標
            return mid;
        }
        if(key > array[mid]){//查詢值比當前值大
            start = mid+1;
        }
        if(key < array[mid]){//查詢值比當前值小
            last = mid-1;
        }
    }
    return -1;
}

二分查詢用遞迴來改寫,相信也很簡單。邊界條件是找到當前值,或者查詢範圍為空。否則每一次查詢都將範圍縮小一半。

public static int search(int[] array,int key,int low,int high){
    int mid = (high-low)/2+low;
    if(key == array[mid]){//查詢值等於當前值,返回陣列下標
        return mid;
    }else if(low > high){//找不到查詢值,返回-1
        return -1;
    }else{
        if(key < array[mid]){//查詢值比當前值小
            return search(array,key,low,mid-1);
        }
        if(key > array[mid]){//查詢值比當前值大
            return search(array,key,mid+1,high);
        }
    }
    return -1;
}

遞迴的二分查詢和非遞迴的二分查詢效率都為O(logN),遞迴的二分查詢更加簡潔,便於理解,但是速度會比非遞迴的慢。


4.分治演算法

當我們求解某些問題時,由於這些問題要處理的資料相當多,或求解過程相當複雜,使得直接求解法在時間上相當長,或者根本無法直接求出。對於這類問題,我們往往先把它分解成幾個子問題,找到求出這幾個子問題的解法後,再找到合適的方法,把它們組合成求整個問題的解法。如果這些子問題還較大,難以解決,可以再把它們分成幾個更小的子問題,以此類推,直至可以直接求出解為止。這就是分治策略的基本思想。

上面講的遞迴的二分查詢法就是一個分治演算法的典型例子,分治演算法常常是一個方法,在這個方法中含有兩個對自身的遞迴呼叫,分別對應於問題的兩個部分。

二分查詢中,將查詢範圍分成比查詢值大的一部分和比查詢值小的一部分,每次遞迴呼叫只會有一個部分執行。


5、漢諾塔問題

漢諾塔問題是由很多放置在三個塔座上的盤子組成的一個古老的難題。如下圖所示,所有盤子的直徑是不同的,並且盤子中央都有一個洞使得它們剛好可以放在塔座上。所有的盤子剛開始都放置在A 塔座上。這個難題的目標是將所有的盤子都從塔座A移動到塔座C上,每次只可以移動一個盤子,並且任何一個盤子都不可以放置在比自己小的盤子之上。
在這裡插入圖片描述
 試想一下,如果只有兩個盤子,盤子從小到大我們以數字命名(也可以想象為直徑),兩個盤子上面就是盤子1,下面是盤子2,那麼我們只需要將盤子1先移動到B塔座上,然後將盤子2移動到C塔座,最後將盤子1移動到C塔座上。即完成2個盤子從A到C的移動。

如果有三個盤子,那麼我們將盤子1放到C塔座,盤子2放到B塔座,在將C塔座的盤子1放到B塔座上,然後將A塔座的盤子3放到C塔座上,然後將B塔座的盤子1放到A塔座,將B塔座的盤子2放到C塔座,最後將A塔座的盤子1放到C塔座上。

如果有四個,五個,N個盤子,那麼我們應該怎麼去做?這時候遞迴的思想就很好解決這樣的問題了,當只有兩個盤子的時候,我們只需要將B塔座作為中介,將盤子1先放到中介塔座B上,然後將盤子2放到目標塔座C上,最後將中介塔座B上的盤子放到目標塔座C上即可。

所以無論有多少個盤子,我們都將其看做只有兩個盤子。假設有 N 個盤子在塔座A上,我們將其看為兩個盤子,其中(N-1)~1個盤子看成是一個盤子,最下面第N個盤子看成是一個盤子,那麼解決辦法為:
  ①、先將A塔座的第(N-1)~1個盤子看成是一個盤子,放到中介塔座B上,然後將第N個盤子放到目標塔座C上。
  ②、然後A塔座為空,看成是中介塔座,B塔座這時候有N-1個盤子,將第(N-2)~1個盤子看成是一個盤子,放到中介塔座A上,然後將B塔座的第(N-1)號盤子放到目標塔座C上。
  ③、這時候A塔座上有(N-2)個盤子,B塔座為空,又將B塔座視為中介塔座,重複①,②步驟,直到所有盤子都放到目標塔座C上結束。

簡單來說,跟把大象放進冰箱的步驟一樣,遞迴演算法為:

①、從初始塔座A上移動包含n-1個盤子到中介塔座B上。

②、將初始塔座A上剩餘的一個盤子(最大的一個盤子)放到目標塔座C上。

③、將中介塔座B上n-1個盤子移動到目標塔座C上

/**
 * 漢諾塔問題
 * @param dish 盤子個數(也表示名稱)
 * @param from 初始塔座
 * @param temp 中介塔座
 * @param to   目標塔座
 */
public static void move(int dish,String from,String temp,String to){
    if(dish == 1){
        System.out.println("將盤子"+dish+"從塔座"+from+"移動到目標塔座"+to);
    }else{
        move(dish-1,from,to,temp);//A為初始塔座,B為目標塔座,C為中介塔座
        System.out.println("將盤子"+dish+"從塔座"+from+"移動到目標塔座"+to);
        move(dish-1,temp,from,to);//B為初始塔座,C為目標塔座,A為中介塔座
    }
}

測試:

move(3,"A","B","C");

列印結果為:
在這裡插入圖片描述


5.歸併排序

歸併演算法的中心是歸併兩個已經有序的陣列。歸併兩個有序陣列A和B,就生成了第三個有序陣列C。陣列C包含陣列A和B的所有資料項。
在這裡插入圖片描述
非遞迴演算法為:

/**
 * 傳入兩個有序陣列a和b,返回一個排好序的合併陣列
 * @param a
 * @param b
 * @return
 */
public static int[] sort(int[] a,int[] b){
    int[] c = new int[a.length+b.length];
    int aNum = 0,bNum = 0,cNum=0;
    while(aNum<a.length && bNum < b.length){
        if(a[aNum] >= b[bNum]){//比較a陣列和b陣列的元素,誰更小將誰賦值到c陣列
            c[cNum++] = b[bNum++];
        }else{
            c[cNum++] = a[aNum++];
        }
    }
    //如果a陣列全部賦值到c陣列了,但是b陣列還有元素,則將b陣列剩餘元素按順序全部複製到c陣列
    while(aNum == a.length && bNum < b.length){
        c[cNum++] = b[bNum++];
    }
    //如果b陣列全部賦值到c陣列了,但是a陣列還有元素,則將a陣列剩餘元素按順序全部複製到c陣列
    while(bNum == b.length && aNum < a.length){
        c[cNum++] = a[aNum++];
    }
    return c;
}

該方法有三個while迴圈,第一個while比較陣列a和陣列b的元素,並將較小的賦值到陣列c;第二個while迴圈當a陣列所有元素都已經賦值到c陣列之後,而b陣列還有元素,那麼直接把b陣列剩餘的元素賦值到c陣列;第三個while迴圈則是b陣列所有元素都已經賦值到c陣列了,而a陣列還有剩餘元素,那麼直接把a陣列剩餘的元素全部賦值到c陣列。

歸併排序的思想是把一個數組分成兩半,排序每一半,然後用上面的sort()方法將陣列的兩半歸併成為一個有序的陣列。如何來為每一部分排序呢?這裡我們利用遞迴的思想:

把每一半都分為四分之一,對每個四分之一進行排序,然後把它們歸併成一個有序的一半。類似的,如何給每個四分之一陣列排序呢?把每個四分之一分成八分之一,對每個八分之一進行排序,以此類推,反覆的分割陣列,直到得到的子陣列是一個數據項,那這就是這個遞迴演算法的邊界值,也就是假定一個數據項的元素是有序的。
  在這裡插入圖片描述

public static int[] mergeSort(int[] c,int start,int last){
    if(last > start){
        //也可以是(start+last)/2,這樣寫是為了防止陣列長度很大造成兩者相加超過int範圍,導致溢位
        int mid = start + (last - start)/2;
        mergeSort(c,start,mid);//左邊陣列排序
        mergeSort(c,mid+1,last);//右邊陣列排序
        merge(c,start,mid,last);//合併左右陣列
    }
    return c;
}
 
public static void merge(int[] c,int start,int mid,int last){
    int[] temp = new int[last-start+1];//定義臨時陣列
    int i = start;//定義左邊陣列的下標
    int j = mid + 1;//定義右邊陣列的下標
    int k = 0;
    while(i <= mid && j <= last){
        if(c[i] < c[j]){
            temp[k++] = c[i++];
        }else{
            temp[k++] = c[j++];
        }
    }
    //把左邊剩餘陣列元素移入新陣列中
    while(i <= mid){
        temp[k++] = c[i++];
    }
    //把右邊剩餘陣列元素移入到新陣列中
    while(j <= last){
        temp[k++] = c[j++];
    }
     
    //把新陣列中的數覆蓋到c陣列中
    for(int k2 = 0 ; k2 < temp.length ; k2++){
        c[k2+start] = temp[k2];
    }
}

測試

int[] c = {2,7,8,3,1,6,9,0,5,4};
c = mergeSort(c,0,c.length-1);
System.out.println(Arrays.toString(c));

結果為:
在這裡插入圖片描述


6.消除遞迴

一個演算法作為一個遞迴的方法通常通概念上很容易理解,但是遞迴的使用在方法的呼叫和返回都會有額外的開銷,通常情況下,用遞迴能實現的,用迴圈都可以實現,而且迴圈的效率會更高,所以在實際應用中,把遞迴的演算法轉換為非遞迴的演算法是非常有用的。這種轉換通常會使用到棧。

遞迴和棧

遞迴和棧有這緊密的聯絡,而且大多數編譯器都是用棧來實現遞迴的,當呼叫一個方法時,編譯器會把這個方法的所有引數和返回地址都壓入棧中,然後把控制轉移給這個方法。當這個方法返回時,這些值退棧。引數消失了,並且控制權重新回到返回地址處。

呼叫一個方法時所發生的事:

一、當一個方法被呼叫時,它的引數和返回地址被壓入一個棧中;

二、這個方法可以通過獲取棧頂元素的值來訪問它的引數;

三、當這個方法要返回時,它檢視棧以獲得返回地址,然後這個地址以及方法的所有引數退棧,並且銷燬。


7.總結

一個遞迴方法每次都是用不同的引數值反覆呼叫自己,當某種引數值使得遞迴的方法返回,而不再呼叫自身,這種情況稱為邊界值,也叫基值。當遞迴方法返回時,遞迴過程通過逐漸完成各層方法例項的未執行部分,而從最內層返回到最外層的原始呼叫處。

階乘、漢諾塔、歸併排序等都可以用遞迴來實現,但是要注意任何可以用遞迴完成的演算法用棧都能實現。當我們發現遞迴的方法效率比較低時,可以考慮用迴圈或者棧來代替它。


轉載:

作者:YSOcean
出處:http://www.cnblogs.com/ysocean/