探究簡單遞歸Java代碼實現

  遞歸(Recursion),是一種非常基本而又極其重要的編程思想,也是解決某些面試題的強有力武器。無論是鏈表、二叉樹、圖等數據結構,還是排序、查找等算法,許多問題通常都能通過遞歸解決。

  程序調用自身的編程技巧稱爲遞歸( recursion)。一般來說,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。簡而言之,遞歸的兩個point是:1)它是一段反覆調用自身的程序 ;2)它必須有跳出調用自己的判斷條件。不論是遞歸還是迭代,都是算法中最基礎最簡單的思想,這裏就不做基礎的介紹了。我們可以嘗試反覆從問題的實現、優化中思考,相信一定會有收穫。

  我們從一個簡單的問題開始。

問題一:猴子喫桃

孫悟空第一天摘下若干蟠桃,當即吃了一半,還不過癮,又多吃了一個。第二天早上,他又將剩下的蟠桃喫掉一半,還不過癮,又多吃了一個。之後每天早上他都喫掉前一天剩下桃子的一半零一個。到第10天早上想再喫時,就只剩下一個蟠桃了。求孫悟空第一天共摘了多少個蟠桃?


 這個問題很簡單,設前一天剩下的蟠桃爲an-1,當天所剩的爲an,我們不難得出an-1 =  an-1/2 + 1 + an  整理一下,便是an-1=2an+2.

 按照遞推的思想,我們可以得到如下代碼

/**
	 * 遞推算法
	 */
	public int eat01(int n){
		int a=1;
		//也可以這樣考慮,“第1天開始喫桃子,連續吃了n-1天”
		//寫成for(int i=1;i<=n-1;i++),無所謂,結果一樣
		for(int i=2;i<=n;i++){
			a=2*a+2;
		}
		return a;
	}

同樣的,我們也可以用遞歸的思想去實現

	/**
     * 遞歸算法
     */
    public int eat02(int n){
        System.out.println("f("+n+")壓棧");
        if(n==1){
            System.out.println("此時函數棧達到最大深度!");
            System.out.println("f("+n+")彈棧");
            return 1;
        }else{
            int a=eat02(n-1)*2+2;
            System.out.println("f("+n+")彈棧");
            return a;
        }
    }
    
    /**
     * 遞歸算法
     * 用三元運算符把代碼簡化爲一行
     */
    public int eat03(int n){
        return n==1?1:eat03(n-1)*2+2;
    }


我們來探討一下這個算法的時間、空間複雜度。顯而易見,時間複雜度極爲O(N)

空間複雜度Space(N) = Heap(N)+Stack(N),忽略低次項、係數之後,也記作O(N)。
比如:Space(N) = 3*N^2+16*N+100,那麼O(N) = N^2。
Heap(N)表示額外申請堆內存空間的大小,Stack(N)表示函數棧的最大深度。

開始調用哪個函數,該函數就壓棧;調用完畢,該函數就彈棧。
我們測試eat02可以看到


這裏是沒有申請堆內存的,故Heap(N) =0

Space(N) = Heap(N)+Stack(N)
Heap(N) =0
Stack(N) =N
故而,Space(N) = 0+N = O(N)
當Stack(N)增長率很快(超過NlogN)的時候,慎用遞歸!


問題二:最大公約數與最小公倍數

最大公約數與最小公倍數

最大公約數(Greatest  Common Divisor),簡稱GCD;只考慮正整數
通常做法:

  • 分解質因數:24 = 2*2*2*3,60 = 2*2*3*5
  • 提取所有的公共質因數:2、2、3
  • 求所有公共質因數的乘積,即得最大公約數:2*2*3 = 12


顯然,這個方法並不適合我們編程實現。

輾轉相除法

古希臘數學家歐幾里得(公元前330年—公元前275年)發明了一種巧妙的算法——輾轉相除法,又稱歐幾里得算法:

  • 令較大數爲m,較小數爲n;
  • 當m除以n的餘數不等於0時,把n作爲m,並把餘數作爲n,進行下一次循環;
  • 當餘數等於0時,返回n。


由這個思想我們可以寫出一段代碼:

/**
	 * 最大公約數的遞推算法
	 */
public int gcd01(int m,int n){
	int a=Math.max(m, n);
	int b=Math.min(m, n);
	m=a;
	n=b;
	int r;
	while(m%n!=0){
		r=m%n;
		m=n;
		n=r;
	}
	return n;
}

每執行一次循環,m或者n至少有一個縮小了2倍,故時間複雜度上限爲log2M。
對於大量的隨機測試樣例,每次循環平均能使m與n的值縮小一個10進位,所以平均複雜度爲O(lgM)。空間複雜度爲O(1)。

非遞歸的實現了,我們來嘗試寫一下遞歸的實現

/**
 * 最大公約數的遞歸算法
 */
public int gcd02(int m,int n){
	/*int a=Math.max(m, n);
	int b=Math.min(m, n);
	if(a%b==0){
		return b;
	}else{
		return gcd02(b, a%b);
	}*/
	return m>=n?m%n==0?n:gcd02(n, m%n):n%m==0?m:gcd02(m, n%m);
}

這段程序的時間、空間複雜度都爲O(lgM)。

求出最大公約數之後,最小公倍數(Least Common Multiple,簡稱LCM),就能迎刃而解了。
LCM(m,n) = m*n/GCD(m,n)
比如,60與24的最大公約數爲12,那麼最小公倍數爲:60*24/12 = 120。

/**
 * 最小公倍數
 */
public int lcm(int m,int n){
	return m*n/gcd01(m, n);
}


問題三:1到100累加的“非主流算法”

題目:求1+2+3+…+n
用遞歸以及非遞歸算法求解,要求時間複雜度爲O(N)。


顯然,這很簡單:

/**
 *遞推算法
 */
public int commonMethod01(int n){
	int sum=0;
	for(int i=1;i<=n;i++){
		sum+=i;
	}
	return sum;
}
/**
 * 遞歸算法
 */
public int commonMethod02(int n){
	if(n==1){
		return 1;
	}else{
		return commonMethod02(n-1)+n;
	}
}


這兩個方法的時間複雜度,都爲O(N),空間複雜度,前者爲O(1),後者爲O(N)

或者,更簡單的方式,站在數學的肩膀上飛,我們用等差數列的求和公式

/**
 * 等差數列求和公式
 */
public int commonMethod03(int n){
	return n*(1+n)/2;
}

這個方法的時間、空間複雜度都爲O(1)


但是正如題中所描述的,“非主流”。

如果加上以下限制,該如何求解?

  • 不允許使用循環語句
  • 不允許使用選擇語句
  • 不允許使用乘法、除法
如果不能人爲地終止循環或者遞歸,那就讓程序意外終止。
自然而然就聯想到:拋異常!


通過異常的話,我們可以換個思路,設計遞歸算法,使用數組存儲數據;當發生數組越界異常時,捕獲異常並結束遞歸。



這麼玩,通過遞歸調用普通函數

/**
 * 遞歸調用普通函數,並捕獲異常
 */
public class SumExceptionMethod {
private int n;
private int[] array;

public SumExceptionMethod() {
	super();
}
public SumExceptionMethod(int n) {
	super();
	this.n = n;
	array=new int[n+1];
}
public int sumMethod(int i){
	try {
		array[i]=array[i-1]+i;
		int k=sumMethod(i+1);
		return k;
	} catch (ArrayIndexOutOfBoundsException e) {
		return array[n];
	}
}
}

我們分析一下:

Heap(N) = N,Stack(N) = N
Space(N) = Heap(N)+Stack(N) = 2N = O(N)

我們還可以試着改造一下,通過遞歸調用構造函數

/**
 * 遞歸調用構造函數,並捕獲異常
 */
public class SumExceptionConstructor {
public static int n;
public static int[] array;

public SumExceptionConstructor(int i){
	try {
		array[i]=array[i-1]+i;
		new SumExceptionConstructor(i+1);
	} catch (ArrayIndexOutOfBoundsException e) {
		System.out.println(array[n]);
		return;
	}
}
}

一樣,Heap(N) = 2N,Stack(N) = N
Space(N) = Heap(N)+Stack(N) = 3N = O(N)


貼上測試方法

public class TestSum {
	@Test
	public void testMethod(){
		int n=100;
		SumExceptionMethod sem=new SumExceptionMethod(n);
		int sum=sem.sumMethod(1);
		System.out.println(sum);
	}
	@Test
	public void testConstructor(){
		int n=100;
		SumExceptionConstructor.n=n;
		SumExceptionConstructor.array=new int[n+1];
		new SumExceptionConstructor(1);
	}
}


問題四:爬樓梯問題

leetCode70:Climbing Stairs
樓梯一共有n級,每次你只能爬1級或者2級。問:從底部爬到頂部一共有多少種不同的路徑?


最後一步:要麼從5往上走2級,要麼從6往上走1級;故f(7) = f(5)+f(6)
到達1,向上爬一級;f(1)=1
到達2,從1向上爬一級;直接向上爬兩級;f(2)=2



遞歸方程(遞推公式)爲:

這其實也就是斐波那契數列的思想,我們可以輕易的寫出以下代碼:

/**
 *遞歸算法
 */
public int fib01(int n){
	count++;
	if(n==1||n==2){
		//System.out.println(n);
		return n;
	}else{
		int k=fib01(n-1)+fib01(n-2);
		//System.out.println(n);
		return k;
	}
}
/**
 * 遞歸算法 一行
 */
public int fib02(int n){
	return n==1||n==2?n:fib02(n-1)+fib02(n-2);
}

@Test
public void test(){
	int n=15;
    int result=fib01(n);
    System.out.println(result);
    System.out.println(count);
    //估計上限與下限
    Assert.assertTrue(count<=Math.pow(2, n)&&count>=Math.pow(2, n/2));
}


這麼寫的話時間、空間複雜度爲O(2^N),O(N)。顯然這樣的思路很簡單,但是效率貌似不咋地。所以我們或許得考慮以下別的方法。


遞歸樹(Recursive Tree)
彈棧序列,爲二叉樹的後序遍歷序列(Post Order Traversal Sequence)
2 1 3 2 4 2 1 3 5


一個重要定理:樹的高度 = 棧的最大深度



備忘錄法

新開闢一個數組;

如果array[i]不爲0,則直接返回;

如果array[i]爲0,array[i]=f(i-1)+f(i-2),並返回array[i]



代碼如下:

public int dfs(int n,int[] array){
	if(array[n]!=0){
		return array[n];
	}else{
		array[n]=dfs(n-1, array)+dfs(n-2, array);
		return array[n];
	}
}
/**
 * 備忘錄法
 */
public int fib03(int n){
	if(n==1||n==2){
		return n;
	}else{
		int[] array=new int[n+1];
		array[1]=1;
		array[2]=2;
		return dfs(n, array);
	}
}

這種方法的時間、空間複雜度都爲O(N),是不是相比較上一個方法已經有了小小的進步呢?我們還可以繼續嘗試探索、優化。


動態規劃法

動態規劃法(Dynamic programming),簡稱DP。

有兩個要素:1)最優子結構 (在這個問題中的體現爲:fib(n-1)+fib(n-2)=fib(n))

       2)重疊子問題由遞歸樹可知)


藉助數組,從左往右依次求解

/**
 * 動態規劃法
 */
public int fib04(int n){
	if(n==1||n==2){
		return n;
	}else{
		int[] array=new int[n+1];
		array[1]=1;
		array[2]=2;
		for(int i=3;i<=n;i++){
			array[i]=array[i-1]+array[i-2];
		}
		return array[n];
	}
}

這種方法的時間、空間複雜度都爲O(N)好像沒什麼改進,沒事我們繼續堅持探索。


狀態壓縮法

狀態壓縮法,又稱滾動數組、滑動窗口(Sliding Window),用於優化動態規劃法的空間複雜度。

/**
 * 滾動數組
 */
public int fib05(int n){
	if(n==1||n==2){
		return n;
	}else{
		int a=1;
		int b=2;
		int t;
		for(int i=3;i<=n;i++){
			t=a+b;
			a=b;
			b=t;
		}
		return b;
	}
}

這種方法的時間複雜度爲O(N),空間複雜度已經成功降低爲O(1)了!但是我們還不滿意,還想再優化一下,那隻好再次站在數學的肩膀上飛翔了~


斐波那契數列的通項公式



開平方:Math.sqrt()
冪函數:Math.pow()
四捨五入:Math.floor()


/**
 * 通項公式法
 */
public int fib06(int n){
	if(n==1||n==2){
		return n;
	}else{
		double sqrtFive=Math.sqrt(5);
		n++;
		double a=Math.pow((1+sqrtFive)/2, n);
		double b=Math.pow((1-sqrtFive)/2, n);
		double result=1/sqrtFive*(a-b);
		return (int) Math.floor(result);
	}
}

這種方法的時間複雜度爲O(log2N),空間複雜度爲O(1).我們從一開始的O(2^N),O(N)逐漸優化到O(log2N),O(1)。感慨數學的偉大、算法的神奇。

編程很簡單,寫代碼也不難,而計算機科學的魅力是源於算法的,就像“編程之美”之中所描述的那樣,當你對生活感到厭倦、當你疲憊於現在的編碼生活,請打開這本書看看。願從這一刻起,你我都能走上算法的這條“不歸路”。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章