【算法思維訓練-劍指Offer聯名 二】遞歸與循環篇

本篇是聯名訓練的第二篇,主題爲遞歸與循環,如果我們需要重複地多次計算相同的問題,通常可以選擇用遞歸或者循環兩種不同的方法。遞歸是在一個函數的內部調用這個函數自身。而循環則是通過設置計算的初始值及終止條件,在一個範圍內重複運算

遞歸與循環

循環很常見,就是for、while、do while這樣子,我們平時使用的也較爲頻繁,我們重點關注下遞歸的優缺點:

  • 優點:代碼簡潔。基於遞歸實現的代碼比基於循環實現的代碼要簡潔很多,更加容易實現。
  • 缺點一:效率較低。遞歸由於是函數調用自身,而函數調用是有時間和空間的消耗的:每一次函數調用,都需要在內存棧中分配空間以保存參數、返回地址及臨時變量,而且往棧裏壓入數據和彈出數據都需要時間。這就不難理解上述的例子中遞歸實現的效率不如循環**【時間】。另外,遞歸中有可能很多計算都是重複的,從而對性能帶來很大的負面影響。遞歸的本質是把一個問題分解成兩個或者多個小問題。如果多個小問題存在相互重疊的部分,那麼就存在重複的計算【空間】**。
  • 缺點二:調用棧溢出。遞歸還有可能引起更嚴重的問題:調用棧溢出。前面分析中提到需要爲每一次函數調用在內存棧中分配空間,而每個進程的棧的容量是有限的。當遞歸調用的層級太多時,就會超出棧的容量,從而導致調用棧溢出

所以在對性能和時間要求不嚴格,以及限制遞歸次數的前提下,使用遞歸比較好,比較代碼簡潔清爽。反之,如果有強要求的時候,可以考慮使用循環。從遞歸的優缺點也可以歸納出遞歸的三大要素:

  • 第一要素:明確你這個函數想要幹什麼。先不管函數裏面的代碼什麼,而是要先明白,你這個函數的功能是什麼,要完成什麼樣的一件事。
  • 第二要素:尋找遞歸結束條件。我們需要找出當參數爲啥時,遞歸結束,之後直接把結果返回,請注意,這個時候我們必須能根據這個參數的值,能夠直接知道函數的結果是什麼。
  • 第三要素:找出函數的等價關係式。我們要不斷縮小參數的範圍,縮小之後,我們可以通過一些輔助的變量或者操作,使原函數的結果不變。

在這裏插入圖片描述

算法訓練

《劍指offer》關於遞歸與循環的算法訓練共有4題:斐波那契數列【難度3】、跳臺階【難度3】、變態跳臺階【難度2】,矩形覆蓋【難度3】。

斐波那契數列

大家都知道斐波那契數列,現在要求輸入一個整數n,請你輸出斐波那契數列的第n項(從0開始,第0項爲0,第1項是1)。n<=39

分析

按照第一篇學習過程中的方法來分析,分爲如下幾個要點:

  • 數列爲斐波那契數列,其特點是,某一項的值等於其前兩項的和
  • 邊界條件:n<=39

公式如下:
在這裏插入圖片描述

解法

當然了,最直觀的解法當然是直接用遞歸公式去做:

public class Solution {
    public int Fibonacci(int n) {
       //設置邊界條件
       if(n>39||n<0) 
          return -1;
       if(n==0) 
           return 0;
       if(n==1) 
           return 1;
       if(n>1&&n<=39)
         return Fibonacci(n-1)+Fibonacci(n-2);
        
       return -1;
 
    }
}

但是提交結果是:
在這裏插入圖片描述
那麼該怎麼優化呢?不難看出,其實這裏做了很多重複的計算,例如f(5)實際上是f(3)+f(4)…一直到f(1)+f(0)。如果能寄存中間值,就不用浪費這些性能了。**如果你使用遞歸的時候不進行優化,是有非常非常非常多的子問題被重複計算的。因此,使用遞歸的時候,必要須要考慮有沒有重複計算,如果重複計算了,一定要把計算過的狀態保存起來

public class Solution {
    public int Fibonacci(int n) {
       if(n>39||n<0) 
          return -1;
       if(n==0) 
           return 0;
       if(n==1) 
           return 1;
        
       int result=0;
       int first=0;
       int second=1;
       for(int i=2;i<=n;i++){
           result = first+second;
           first=second;
           second=result;
       }

       return result;
 
    }
}

使用循環可以解決重複計算、入棧出棧的時間和空間浪費。
在這裏插入圖片描述

跳臺階

一隻青蛙一次可以跳上1級臺階,也可以跳上2級。求該青蛙跳上一個n級的臺階總共有多少種跳法(先後次序不同算不同的結果)。

分析

按照第一篇學習過程中的方法來分析,分爲如下幾個要點:

  • 實際Demo舉例:如果只有 1 級臺階,那顯然只有一種跳法。如果有2級臺階,那就有兩種跳的方法了:一種是分兩次跳,每次跳1級;另外一種就是一次跳2級。
  • 邊界條件:共有n個臺階,n爲1到n之間的正整數

我們把n級臺階時的跳法看成是n的函數,記爲f(n)。當n>2時,第一次跳的時候就有兩種不同的選擇:一是第一次只跳1級,此時跳法數目等於後面剩下的n-1級臺階的跳法數目,即爲f(n-1);另外一種選擇是第一次跳2級,此時跳法數目等於後面剩下的n-2級臺階的跳法數目,即爲f(n-2)。因此n級臺階的不同跳法的總數f(n)=f(n-1)+f(n-2)。分析到這裏,我們不難看出這實際上就是斐波那契數列了。

解法

public class Solution {
    public int JumpFloor(int target) {
       if(target<1) return -1;
       if(target==1) return 1;
       else if(target==2) return 2;
       else return JumpFloor(target-1)+JumpFloor(target-2);
    }
}

以及低性能損耗的循環版本。

public class Solution {
    public int JumpFloor(int target) {
       if(target<1) return -1;
       if(target==1) return 1;
       else if(target==2) return 2;
       else{
         int result=0;
         int first=1;
         int second=2;
         for(int i=3;i<=target;i++){
           result = first+second;
           first=second;
           second=result;
          }
          return result;
       }
    }
}

變態跳臺階

一隻青蛙一次可以跳上1級臺階,也可以跳上2級……它也可以跳上n級。求該青蛙跳上一個n級的臺階總共有多少種跳法。

分析

確實有夠變態的,不過按照第一篇學習過程中的方法來分析,分爲如下幾個要點:

  • 實際Demo舉例:如果只有 1 級臺階,那顯然只有一種跳法。如果有2級臺階,那就有兩種跳的方法了:一種是分兩次跳,每次跳1級;另外一種就是一次跳2級。如果有3級臺階,那就有f(1)+f(2)+一次跳3級共有4種,如果有4級臺階,那就是
  • 邊界條件:共有n個臺階,n爲1到n之間的正整數

我們把n級臺階時的跳法看成是n的函數,記爲f(n)。當n>2時,第一次跳的時候就有兩種不同的選擇:一是第一次只跳1級,此時跳法數目等於後面剩下的n-1級臺階的跳法數目,即爲f(n-1);另外一種選擇是第一次跳2級,此時跳法數目等於後面剩下的n-2級臺階的跳法數目,即爲f(n-2),最後一種選擇是第一次跳3級,此時跳法數目等於後面剩下的n-3。因此n級臺階的不同跳法的總數歸納爲f(n)=f(n-1)+f(n-2)+f(n-3)…+f(n-n)=f(0)+f(1)+…f(n-1)=f(n-1)+f(n-1)=2*f(n-1)

解法

這個時候

public class Solution {
    public int JumpFloorII(int target) {
          if(target<1) return -1;
          if(target==1) return 1;
          else {
              return 2*JumpFloorII(target-1);
          }
    }
}

以及低性能損耗的循環版本。

public class Solution {
    public int JumpFloorII(int target) {
          if(target<1) return -1;
          if(target==1) return 1;
          else {
              int result=0;
              int last=1;
              for(int i=2;i<=target;i++){
                  result=2*last;
                  last=result;
              }
              return result;
          }
    }
}

矩形覆蓋

我們可以用2X1的小矩形橫着或者豎着去覆蓋更大的矩形。請問用n個2X1的小矩形無重疊地覆蓋一個2Xn的大矩形,總共有多少種方法?比如n=3時,2*3的矩形塊有3種覆蓋方法:

在這裏插入圖片描述

分析

同樣按照之前的方式去分析,不過按照第一篇學習過程中的方法來分析,分爲如下幾個要點:

  • 實際Demo舉例:如果只有 1 個矩形,那顯然只有1種方法。如果有2個矩形,那就有2種方法了:一種是兩個都豎着,另外一種就是兩個都橫着。如果有3 個矩形,那就有f(1)+f(2)共3種方法。
  • 邊界條件:共有n個矩形,n爲1到n之間的正整數
  • 擺放方式:只有兩種,豎着放或者橫着放。

用第一個1×2小矩形去覆蓋大矩形的最左邊時有兩個選擇,豎着放或者橫着放。當豎着放的時候,右邊還剩下2×2的區域,這種情形下的覆蓋方法記爲f(2)。接下來考慮橫着放的情況。當1×2的小矩形橫着放在左上角的時候,左下角必須也橫着放一個1×2的小矩形,而在右邊還還剩下2×1的區域,這種情形下的覆蓋方法記爲f(1)

解法

public class Solution {
    public int RectCover(int target) {
          if(target<1) return 0;
          if(target==1) return 1;
          else if(target==2) return 2;
          else {
              return RectCover(target-1)+RectCover(target-2);
          }
    }
}

以及低性能損耗的循環版本。

public class Solution {
    public int RectCover(int target) {
          if(target<1) return 0;
          if(target==1) return 1;
          else if(target==2) return 2;
          else {
              int result=0;
              int first=1;
              int second=2;
              for(int i=3;i<=target;i++){
                  result=first+second;
                  first=second;
                  second=result;
              }
              return result;
 
          }
    }
}

思考

說實話遞歸雖然牛逼但還是蠻燒腦的,主要考慮想象能力。估計遞歸在二叉樹上用的比較多吧,不過遞歸這裏也有一些收穫:

  • 增強如上一篇所說的理論:構建一個簡單的Demo,因爲有些問題是顯而易見的遞歸,但有些問題不一定看得出來是遞歸,這個時候就需要去歸納,歸納出規律自然而然就能寫出代碼
  • 代碼簡潔和性能損耗在遞歸這裏不可兼得啊,一定要依照需求出發,看看目的是什麼,場景是什麼,什麼時候該用遞歸,什麼時候該用循環
  • 遞歸感覺有點兒分治的感覺,結果迴歸條件。

多練練抽象思維,還是有好處。

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