迭代與遞迴--你被遞迴搞暈過嗎?

fuxing.發表於2024-06-07

前言

演算法中會經常遇見重複執行某個任務,那麼如何實現呢,本文將詳細介紹兩種實現方式,迭代與遞迴。


本文基於 Java 語言。

一、迭代

迭代(iteration),就是說程式會在一定條件下重複執行某段程式碼,直到條件不再滿足。

在 Java 語言中,可以理解為就是迴圈遍歷,Java 中有多種遍歷方式,如 for 迴圈、while 迴圈。接下來講解如何進行程式碼實現。

1. for 迴圈

這個是最常見的迭代形式之一,適合在預先知道迭代次數(遍歷次數)時使用

比如,我們透過 for 迴圈計算1 + 2 + ... + n的結果。

public  int forLoop(int n){
    int result = 0;
    for (int i = 0; i <= n; i++) {
        result += i;
    }
    return result;
}

流程圖如下:

圖片源自 Hello 演算法

2. while 迴圈

與 for 迴圈類似,while 迴圈也是一種實現迭代的方法。在 while 迴圈中,程式每輪都會先檢查條件,如果條件為真,則繼續執行,否則就結束迴圈。

比如,我們透過 while 迴圈計算1 + 2 + ... + n的結果。

public int whileLoop(int n) {
    int result = 0;
    int i = 1; // 初始化條件變數

    while (i <= n) {
        result += i;
        i++; // 更新條件變數
    }
    return result;
}

3. 巢狀迴圈

我們可以在一個迴圈結構內巢狀另一個迴圈結構,每一次巢狀都是一次“升維”,將會使時間複雜度提高至“立方關係”、“四次方關係”,以此類推。

下面以氣泡排序為例,原理是從後兩兩對比,更小的往前放。陣列內位置交換,不懂得話看一下我總結的另一篇文字--《位運算詳解》

public class FuXing {
    public static void main (String[] args) {
        int[] arr =  {9,6,1,5,2,3,4,7,8};
        System.out.println("排序後:" + Arrays.toString( bubbleSort(arr)));
    }

    /**
     * 氣泡排序
     */
    public static void bubbleSort (int[] arr){
        // 過濾無序排序的陣列
        if(arr == null || arr.length < 2){
            return;
        }
        // 倒序遍歷所有的數
        for (int i = arr.length - 1; i > 0; i--) {
            //兩兩比較,更小的往前放
            for (int j = 0; j < i; j++) {
                if(arr[j] > arr[j+1]){
                    swap(arr,j,j+1);
                }
            }
        }
    }

    /**
     * 陣列內位置交換
     */
    public static void swap(int[] arr,int i ,int j){
        arr[i] ^= arr[j];
        arr[j] ^= arr[i];
        arr[i] ^= arr[j];
    }
}

二、遞迴

1. 簡介

遞迴(recursion)是一種演算法策略,透過直接或者間接地呼叫自身來解決問題,將大問題分解成更多的子問題,主要解決同一大類問題。

從資料結構角度看,遞迴天然適合處理連結串列、樹和圖的相關問題,因為它們非常適合用分治思想進行分析。

主要包含兩個階段:

  1. 遞: 程式不斷深入地呼叫自身,通常傳入更小或更簡化的引數,直到達到“終止條件”。
  2. 歸: 觸發“終止條件”後,程式從最深層的遞迴函式開始逐層返回,匯聚每一層的結果。

遞迴的三個重要要素(須記住):

  1. 終止條件: 用於決定什麼時候由“遞”轉“歸”。
  2. 遞迴呼叫: 對應“遞”,函式呼叫自身。
  3. 返回結果: 對應“歸”,將當前遞迴層級的結果返回至上一層

2. 如何設計遞迴

在寫一些遞迴演算法時,應該如何去操作呢?這裡分享一點個人經驗,當我們需要寫一個遞迴程式時:

  1. 找重複: 找到的相同的子問題;
  2. 找變化: 聚焦於某一個子問題,檢視變化的量,通常會作為引數,這時可定義函式體;;
  3. 找出口: 也就是找終止條件。

我們需要明確遞迴函式本身是為了做什麼,返回值是什麼,從最終想要的答案出發,逐步尋找上一層的答案,並且用它們構造當前層的答案。

比如,我們透過遞迴計算1 + 2 + ... + n結果。

  1. 找重複:n 的累加 = n + (n - 1)的累加,n - 1 就是原問題的重複,即子問題;
  2. 找變化:聚焦於某一個子問題,這裡的變化就是 n 的自減,遞迴引數就是 n - 1;
  3. 找出口:終止條件就是 n = 1,n 為 1 時無法計算階乘。

程式碼如下:

public int recursion (int n) {
     //終止條件
     if (n == 1) return 1;
     //遞:遞迴呼叫
     int result = recursion(n - 1);
     //歸:返回結果
     return result + n;
 }

流程圖如下:

image.png

圖片源自 Hello 演算法

3. 呼叫棧

在 Java 中,遞迴函式每次呼叫自身時,JVM 都會為新開啟的函式分配記憶體,以儲存區域性變數、呼叫地址和其他資訊等。也就是在 Java 虛擬機器棧中新生成一個棧幀。

因為棧空間是隨著函式結果返回才會釋放,所以遞迴通常比迭代更加消耗記憶體空間。且呼叫遞迴函式會產生額外的開銷,因此時間效率上也會受影響。過深的遞迴操作,可能導致棧記憶體溢位。

image.png

4. 尾部遞迴

如果函式在返回前的最後一步才進行遞迴呼叫,則該函式可以被編譯器或直譯器最佳化,使其在空間效率上與迭代相當。這種情況被稱為尾遞迴(tail recursion)。

遞迴方式 說明
普通遞迴 當函式返回到上一層級的函式後,需要繼續執行程式碼,因此係統需要儲存上一層呼叫的上下文。
尾遞迴 遞迴呼叫是函式返回前的最後一個操作,這意味著函式返回到上一層級後,無須繼續執行其他操作,因此係統無須儲存上一層函式的上下文。

比如,還是透過遞迴計算1 + 2 + ... + n結果。尾部遞迴的求和操作是在“遞”的過程中執行的,“歸”的過程只需層層返回。而普通遞迴每層返回後都要再執行一次求和操作。

程式碼如下:

public int tailRecursion (int n, int result) {
    // 終止條件
    if (n == 0) return res;
    
    // 尾遞迴呼叫
    return tailRecursion(n - 1, res + n);
 }

流程圖如下:

image.png

圖片源自 Hello 演算法

5. 遞迴樹

當處理與“分治”相關的演算法問題時,遞迴往往比迭代的思路更加直觀、程式碼更加易讀。以“斐波那契數列”為例。

斐波那契數列是指這樣一個數列:0,1,1,2,3,5,8,13,21,34… 這個數列從第3項開始 ,每一項都等於前兩項之和,求該數列的第 n 個數字。

回顧一下上面說的方法,進行設計遞迴函式:

1. 找重複: 原問題的重複,即子問題。我們設斐波那契數列的第 n 個數字為 f(n),可得:

  • f(3) = f(2) + f(1) = 0
  • f(4) = f(3) + f(2) = 2
  • f(n) = f(n - 1) + f(n - 2)

這裡每一項都等於前兩項之和

2. 找變化: 聚焦於某一個子問題,檢視變化的量,會作為引數,這時可定義函式體。

如,f(n) = f(n - 1) + f(n - 2),這裡的變化量有兩個,n -1 和 n - 2,即分別做為入參。

函式體如下:

int fib(int n) {
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    
    // 返回結果 f(n)
    return res;
}

**3. 找出口:**終止條件就是 n = 1 或 n = 2,n 為 1 或 2 時無法拆分為子問題,則完整函式如下。

int fib(int n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2) return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}

觀察以上程式碼,我們在函式內遞迴呼叫了兩個函式,這意味著從一個呼叫產生了兩個呼叫分支。如下圖所示,這樣不斷遞迴呼叫下去,最終將產生一棵層數為 n 的遞迴樹(recursion tree)。

image.png

圖片源自 Hello 演算法

6. 如何避免陷入遞迴

不知道你有沒有被遞迴邏輯搞暈的經歷,遞迴呼叫步驟中,經常會有很多深層操作,所以我們可能會想看看每一層的返回值,我們就會一層層深入的去思考下一步的邏輯,隨著思考層數加深,就會覺得遞迴越來越困難,心態就崩了。

遞迴不像迭代是正向思維,是一個逆向思維的過程,很多情況其實並不好理解,也不清楚什麼時候該用,很容易被層層巢狀搞暈。

image.png

那麼如何解決這種問題呢?我們可以這麼理解,迭代是正向思維,從已知去推未知,也就是從最開始的已知的基礎步驟,不斷的重複去累計,直到任務完成。

遞迴是未知推已知,是將原問題分解成多個子問題,我們並不知道子問題的解,所以需要不斷地透過遞迴分解,直到分解成基本的已知的解,最後在統一起來。

我們被搞暈的主要因素還是忍不住的跟隨者遞迴函式去不斷的深入思考,要想避免這種情況。只要我們不再探究它內部的深層操作,將所有的遞迴呼叫的操作當成一個整體,相信它自己就能完成使命

比如,我們需要把大象裝進冰箱,一共需要幾步?是不是隻要開啟冰箱門,把大象放進去,然後關掉冰箱門。我們把大象放進冰箱當作終止條件,而遞迴呼叫過程當作把大象,我們並不需要知道大象怎麼放進冰箱的,即我們不需要知道遞迴是怎麼執行的。

image.png

這樣我們在看一些遞迴演算法時,可以避免陷入迴圈的遞迴邏輯中。

三、兩者對比

對比維度 迭代 遞迴
實現方式 迴圈結構 函式呼叫自身
時間效率 效率通常較高,無函式呼叫開銷 每次函式呼叫都會產生開銷
記憶體使用 通常使用固定大小的記憶體空間 累積函式呼叫可能使用大量的棧空間
適用問題 適用於簡單迴圈任務,程式碼直觀、可讀性好 適用於子問題分解,如樹、圖、分治、回溯等,程式碼結構簡潔、清晰

儘管迭代和遞迴在很多情況下可以互相轉化,但不一定值得這樣做,有以下兩點原因:

  1. 轉化後的程式碼可能更加難以理解,可讀性更差。
  2. 對於某些複雜問題,模擬系統呼叫棧的行為可能非常困難。

總之,選擇迭代還是遞迴取決於特定問題的性質。在程式設計實踐中,權衡兩者的優劣並根據情境選擇合適的方法至關重要。


參考:

[1] 靳宇棟. Hello 演算法.

相關文章