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