算法入門 - 遞歸的原理及應用


遞歸是一種比較繞的算法,這是因為它通常在我們肉眼所見的范圍內無法完成調用。迄今為止,我們學習的數組、鏈表等,實現的代碼都是從上至下依次執行的,即便會有循環,但也是在可控范圍內進行的操作。而遞歸卻有一種無法掌控的感覺,跑着跑着就不知道去哪了。初學這種算法的同學,經常會陷入一層層的調用中,搞得頭腦發暈,時而明白,時而糊塗,真的是讓人頭疼!🤣
實際上,是我們把遞歸想得過於復雜了,從本質上來說,它就是函數調用,只不過調用的是它自己。

1.從分而治之的角度看遞歸

遞歸實際上是一種分而治之的思想,一個問題,如果一下子搞不定,能否只搞定一部分呢?如此進行拆解下去,便將一個復雜的問題分解成了多個較為簡單的問題,直到碰到一個問題是顯而易見可解決的。我們再拿這個解決了的問題,去求解它上一層的問題,再這么反向求解下去,就可以得到最初問題的解了!

下面我們以計算階乘為例,來詳細看一下遞歸的過程。
我們都知道,一個數的階乘就是從這個數開始做乘法,每次減一后繼續乘,直到這個數為1(1!)。假設要求10的階乘,就是10*9*8*7*6*5*4*3*2*1。如果用遞歸的思維來看,我們一下子無法求出10!,但我們知道,10! = 10 * 9!,所以問題就拆解為求9!,9!也無法直接求出,所以再拆解為 9 * 8!,一直這么拆解下去,直到 2! = 2 * 1!,此時我們知道,1! = 1,這就是我們顯而易見可解決的問題,再這么反向求解過去,就可以得到最終的結果。

public class FactNum{
    public static int factorial(int n){
        // 如果n為1,就直接返回1
        if (n == 1) return 1;
        // 否則就簡化為求n-1的階乘
        return n * factorial(n-1);
    }

    public static void main(String[] args) {
        int result = factorial(5);
        System.out.println("5的階乘為: " + result);
    }
}
/*
輸出結果:
5的階乘為: 120
*/

代碼很簡短,只是重復做了兩件事:
1.每次都看一下當前的數 n 是不是1,如果是1,就直接返回1;
2.否則就返回 n 乘以 n-1 的階乘。
實際上,我們可以把這個顯而易見可解決的問題當做是退出條件,然后思考當前問題怎么拆解比較合適。只要分兩步,就可以搞定遞歸算法。

2.從語義的角度看遞歸

還有一點需要注意,我們要時刻記得遞歸函數(方法)的功能,以及我們每一步要做的事情,嘗試使用語義,會讓整個過程更加自然。
例如,我們要求 n 的階乘 factorial(n),如果 n 等於 1,就直接返回 1;如果不是 1,我們就講問題轉化為求 n*(n-1) 的階乘,而 (n-1) 的階乘正好可以通過函數 factorial(n-1) 求得:

    // 一種更加直觀的理解方式
    public static int factorial(int num){
        if (num == 1) {
            return num;
        }
        // 不看做遞歸調用,僅作為普通函數調用,目的就是求 num-1 的階乘
        int fact = factorial(num - 1);
        int result = num * fact;    // 求出 num-1 的階乘,就求出了n的階乘
        return result;
    }

我們僅從功能的角度調用函數,而不去鑽遞歸的角尖,同樣可以寫出遞歸的代碼!

3.從棧的角度看遞歸

還可以把遞歸想象成是模擬棧的壓入,調用一次就相當於將函數壓入棧,然后從最后一層開始出棧:

下面我們就再看一個鏈表的例子,再次強化下對遞歸的理解。

實例:根據元素值刪除鏈表節點

繼續我們之前鏈表的例子,當時我們只實現了按照索引值刪除節點的方法:

    // 移除節點
    public E remove(int index) {
        if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
        if (getSize() == 0) throw new IllegalArgumentException("Empty Queue, please enqueue first!");
        // prev節點的初始值為dummyHead
        Node prev = dummyHead;
        // 通過遍歷找到prev節點
        for (int i = 0; i < index; i++) prev = prev.next;
        // 儲存待刪除節點
        Node delNode = prev.next;
        // 跳過delNode
        prev.next = delNode.next;
        // 待刪除節點后接null
        delNode.next = null;
        size--;
        return delNode.element;
    }
 
    public E removeFirst() {
        return remove(0);
    }
 
    public E removeLast() {
        return remove(getSize() - 1);
    }

實際上我們還可以根據元素值來刪除節點。這里會有一個問題,鏈表是不限制重復元素的,那么是只刪除符合條件的第一個節點,還是刪除所有符合條件的節點呢?這里我們選擇刪除所有符合條件的節點,所以遞歸就能派上用場了!我們先來看下不使用遞歸的實現方式:

    // 非遞歸方式實現
    public void removeElementAll(E element){
        if(isEmpty()) throw new IllegalArgumentException("Empty list, add first!");
        Node prev = dummyHead;
        while (prev.next != null){
            if (prev.next.element == element){
                Node delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
                size--;
            }
            else prev = prev.next;
        }
    }

實現方式很簡單,使用 while 循環遍歷鏈表,只要 prev.next 節點的元素值與待刪除元素值相等,就刪除該節點。
如果采用遞歸的方式,該如何拆解問題呢?我們無法一下子就找出所有符合條件的節點,但是可以把鏈表拆解成兩部分,一個頭節點和一個子鏈表。這樣就把原鏈表轉化成了一個單獨的節點和另一個較短的鏈表。如果節點為空,說明已到鏈表尾部(顯而易見的答案,即退出條件),返回空值即可。例如刪除一個鏈表中所有包含元素6的節點:

public void removeElementAll(E element){
    // 從head開始刪除元素,並更新鏈表
    dummyHead.next = removeElementAll(dummyHead.next, element);
}

private Node removeElementAll(Node head, E element){
    // 如果當前頭節點為空,說明鏈表結束,返回null
    if (head == null) return null;
    // 處理頭節點后的子鏈表並更新
    head.next = removeElementAll(head.next, element);
    // 如果當前頭節點為待刪除節點,則將頭節點的next作為頭節點返回
    if (head.element == element) return head.next;
    // 否則返回當前頭節點
    return head;
}

這里使用了兩個方法,第一個是對外調用的方法,只需要傳入要刪除的元素值即可。第二個是內部的 private 遞歸方法,我們結合動圖來看,每次子鏈表往下掉一層,就代表一層遞歸。可以發現,因為我們的代碼是先處理子鏈表,所以在正向遞歸的過程中並沒有判斷當前頭節點是否該刪除。直到頭節點為空時,我們開始向上返回,並開始判斷頭節點,如果當前頭節點為待刪除節點,則只返回當前頭節點的子鏈表,否則就連同當前節點一並返回到上一層。通過這樣比較直觀的過程,應該能夠更好地理解遞歸了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM