1. 程式人生 > >演算法一看就懂之「 遞迴 」

演算法一看就懂之「 遞迴 」

之前的文章咱們已經聊過了「 陣列和連結串列 」、「 堆疊 」和「 佇列 」,今天咱們來看看「 遞迴 」,當然「 遞迴 」並不是一種資料結構,它是很多演算法都使用的一種程式設計方法。它太普遍了,並且用它來解決問題非常的優雅,但它又不是那麼容易弄懂,所以我特意用一篇文章來介紹它。

一、「 遞迴 」是什麼?

遞迴 就是指函式直接或間接的呼叫自己,遞迴是基於棧來實現的。遞迴的經典例子就是 斐波拉契數列(Fibonacci)。一般如果能用遞迴來實現的程式,那它也能用迴圈來實現。用遞迴來實現的話,程式碼看起來更清晰一些,但遞迴的效能並不佔優勢,時間複雜度甚至也會更大一些。


上圖為 斐波拉契數列 圖例。

要實現遞迴,必須滿足2個條件:

  1. 可呼叫自己

    就是我們要解決的這個問題,可以通過函式呼叫自己的方式來解決,即可以通過將大問題分解為子問題,然後子問題再可以分解為子子問題,這樣不停的分解。並且大問題與子問題/子子問題的解決思路是完全一樣的,只不過資料不一樣。因此這些問題都是通過某一個函式去解決的,最終我們看到的就是不停得函式呼叫自己,然後就把問題化解了。

    如果這個問題不能分解為子問題,或子問題的解決方法與大問題不一樣,那就無法通過遞迴呼叫來解決。

  2. 可停止呼叫自己

    停止呼叫的條件非常關鍵,就是大問題不停的一層層分解為小問題後,最終必須有一個條件是來終止這種分解動作的(也就是停止呼叫自己),做遞迴運算一定要有這個終止條件,否則就會陷入無限迴圈。

下面還是以 斐波拉契數列(Fibonacci)為例,我們來理解一下遞迴:

斐波拉契數列就是由數字 1,1,2,3,5,8,13…… 組成的這麼一組序列,特點是每位數字都是前面相鄰兩項之和。如果我們希望得出第N位的數字是多少?

  1. 可以使用迴圈的方式求解:

    這裡就不列程式碼了,思路是:我們知道最基本的情況是 f(0)=0,f(1)=1,因此我們可以設定一個一個迴圈,迴圈從i=2開始,迴圈N-1次,在迴圈體內 f(i)=f(i-1)+f(i-2),直到i=N-1,這樣迴圈結束的時候就求出了f(N)的值了。

  2. 更優雅的方式是使用遞迴的方式求解:

    我們知道斐波拉契數列的邏輯就是:


    可以看出,這個邏輯是滿足上面2個基本條件,假如求解 f(3),那 f(3)=f(2)+f(1),因此我們得繼續去求解f(2),而 f(2)=f(1)+f(0),因此整個求解過程其實就在不斷的分解問題的過程,將大問題f(3),分解為f(2)和f(1)的問題,以此類推。既然可以分解成子問題,並且子問題的解決方法與大問題一致,因此這個問題是滿足“可呼叫自己”的遞迴要求。

    同時,我們也知道應該在何時停止呼叫自己,即當子問題變成了f(0)和f(1)時,就不再需要往下分解了,因此也滿足遞迴中“可停止呼叫自己”的這個要求。

    所以,斐波拉契數列問題可以採用遞迴的方式去編寫程式碼,先看圖:


    我們將程式碼寫出來:

    int Fb(int n){
       if(n<=1) return n==0?0:1;
       return Fb(n-1)+Fb(n-2); //這裡就是函式自己呼叫自己
    }

    從上面的例子可以看出,我們寫遞迴程式碼最重要的就是寫2點:

  3. 遞推公式

    上面程式碼中,遞推公式就是 Fb(n)=Fb(n-1)+Fb(n-2),正是這個公式,才可以一步步遞推下去,這也是函式自己呼叫自己的關鍵點。因此我們在寫遞迴程式碼的時候最首先要做的就是思考整個邏輯中的遞推公式。

  4. 遞迴停止條件

    上面程式碼中的停止條件很明顯就是:if(n<=1) return n==0?0:1;這就是遞迴的出口,想出了遞推公司之後,就要考慮遞迴停止條件是啥,沒有停止條件就會無限迴圈了,通常遞迴的停止條件是程式的邊界值。

    我們對比實現斐波拉契數列問題的2種方式,可以看出遞迴的方式比迴圈的方式在程式結構上更簡潔清晰,程式碼也更易讀。但遞迴呼叫的過程中會建立函式副本,建立大量的呼叫棧,如果遞迴的資料量很大,呼叫層次很多,就會導致消耗大量的時間和空間,不僅效能較低,甚至會出現堆疊溢位的情況。

    我們在寫遞迴的時候,一定要注意遞迴深度的問題,隨時做好判斷,防止出現堆疊溢位。

    另外,我們在思考遞迴邏輯的時候,沒必要在大腦中將整個遞推邏輯一層層的想透徹,一般人都會繞暈的。大腦很辛苦的,我們應該對它好一點。我們只需要關注當前這一層是否成立即可,至於下一層不用去關注,當前這一層邏輯成立了,下一層肯定也會成立的,最後只需要拿張紙和筆,模擬一些簡單資料代入到公式中去校驗一下遞推公式對不對即可。

二、「 遞迴 」的演算法實踐?

我們看看經常涉及到 遞迴 的 演算法題(來源leetcode):

演算法題:實現 pow(x, n) ,即計算 x 的 n 次冪函式。

說明:
    -100.0 < x < 100.0
    n 是 32 位有符號整數,其數值範圍是 [−2^31, 2^31 − 1]

示例:
輸入: 2.00000, 10
輸出: 1024.00000

解題思路:

方法一:
暴力解法,直接寫一個迴圈讓n個x相乘嘛,當然了這種方式就沒啥技術含量了,時間複雜度O(1),程式碼省略了。

方法二:
基於遞迴原理,很容易就找出遞推公式 f(n)=x*f(n-1),再找出遞迴停止條件即n==0或1的情況就可以了。不過稍微需要注意的是,因為n的取值可以是負數,所以當n小於0的時候,就要取倒數計算。程式碼如下:
class Solution {
    public double myPow(double x, int n) {
        if(n==0) return 1;
        if(n==1) return x;
        if(n<0) return 1/(x*myPow(x,Math.abs(n)-1));
        return x*myPow(x,n-1);
    }
}
這個方法其實也有問題,當n的數值過大時,會堆疊溢位的,看來也是不最佳解,繼續往下看。

方法三:
利用分治的思路,將n個x先分成左右兩組,分別求每一組的值,然後再將兩組的值相乘就是總值了。即 x的n次方 等於 x的n/2次方 乘以 x的n/2次方。以此類推,左右兩組其實還可以分別各自繼續往下分組,就是一個遞推思想了。但是這裡需要考慮一下當n是奇數的情況,做一個特殊處理即可,程式碼如下:
class Solution {
    public double myPow(double x, int n) {
        //如果n是負數,則改為正數,但把x取倒數
        if(n<0) {
            n = -n;
            x = 1/x;
        }
        return pow(x,n);

    }

    private double pow(double x, int n) {
        if(n==0) return 1;
        if(n==1) return x;
        double half = pow(x,n/2);
        //偶數個
        if(n%2==0) {
            return half*half;
        }
        //奇數個
        return half*half*x;
    }
}
這種方法的時間複雜度就是O(logN)了。

以上,就是對資料結構中「 遞迴 」的一些思考。

碼字不易啊,喜歡的話不妨轉發朋友,或點選文章右下角的“在看”吧。