Java 中的遞歸


遞歸

遞歸 一種通過調用某個方法來描述需要重復進行的操作。該方法的特點就是可以自己調用自己。

案例一

  1. 排隊的問題

    在生活中,我們經常需要排隊。在排隊中,我們怎么才能知道自己所排在第幾位呢?

    我們也許會想到數自己前面有幾個人,這就是典型的迭代思想。就像是一個while循環,只要前面還有沒數過的人,就不會停止。這種方式相對來說是比較直觀的,但是同樣也有局限性。比如在排隊時,遇到了轉彎,我們看不到前面的人怎么辦呢?

    有一個方法,我們可以通過詢問前面一個人他所處的位置。假設有A、B、C、D四個人,D詢問C所在的位置,C詢問B所在位置,B詢問A所在的位置。A知道自己排在第一位(他不需要再問別人了,這一個詢問的過程結束),然后告訴B,那B就知道自己在第二位;然后B告訴C,C也就知道了自己在第三位;最后C告訴D他在第三位時,D也就知道自己所在的位置了。這就是使用遞歸思想來分析問題——每個人都來回答一個問題,從而使這個問題多次重現(recur)。

    這一點也是遞歸思想的精髓所在,遞歸方法涉及多個進行合作的單元,每個單元負責解決問題的一小部分。例如剛剛那個例子,每個人都像前詢問一個問題,最后每個人又向后回答一個問題,而不是由一個人來負責統計所有的人數。

  2. 迭代到遞歸的轉變

    根據傳遞的長度length,打印出length個“ * ”

    /**
     * 迭代
     *
     * @param length
     */
    public void writeStars1(int length) {
        for (int i = 0; i < length; i++) {
            System.out.print("*");
        }
        System.out.println();
    }
    
    /**
     * 遞歸
     *
     * @param length
     */
    public void writeStars2(int length) {
        if (length == 0) {
            System.out.println();
        } else {
            System.out.print("*");
            writeStars2(length - 1);
        }
    }
    

    上面這兩個方法最終結果都是一樣的。不同的是,迭代好比是一個人來統計所有的人數。遞歸則是由每個人回答同一個問題,由於每個人所在的位置不同,所回答的結果也不相同。

遞歸方法的結構

public void writeStars3(int length) {
    System.out.println("*");
    writeStars3(length - 1);
}

我們看一下上面這段代碼,如果執行這段代碼,程序並不會停止,會一直在自己調用自己執行下去。這也就是我們可能會遇到的無窮遞歸(infinite recursion)。

遞歸方法有兩個重要的組成部分:一個基本情況(base case)和一個遞歸情況(recursive case)。

基本情況 一種簡單到不需要遞歸調用就可以解決的情況。

遞歸情況 一種需要把整個問題轉化成一個相同種類的,比較簡單的,而且可以通過遞歸調用來解決的問題的情況。

/**
 * 通過注釋來解釋基本情況和遞歸情況
 *
 * @param length
 */
public void writeStars2(int length) {
    if (length == 0) {
        // 基本情況
        System.out.println();
    } else {
        // 遞歸情況
        System.out.print("*");
        writeStars2(length - 1);
    }
}

案例二

我們有一個LinkedList集合,現在需要按照倒敘把集合的內容打印出來,打印完之后集合里面不要有數據。

  1. 通過迭代實現

    代碼

    LinkedList<String> list = new LinkedList<>();
    list.add("a");
    list.add("b");
    list.add("c");
    list.add("d");
    list.add("e");
    list.add("f");
    
    System.out.println(list);
    
    for (int i = list.size() - 1; i >= 0; i--) {
        System.out.print(list.get(i) + " ");
    }
    list.clear();
    System.out.println();
    System.out.println(list);
    

    輸出結果

    [a, b, c, d, e, f]
    f e d c b a 
    []
    

    看到上面的結果,顯然是符合要求的。那我們如果使用遞歸,該怎么實現呢?

  2. 通過遞歸實現

    代碼

    public static void main(String[] args) {
    
        LinkedList<String> list = new LinkedList<>();
        list.add("a");
        list.add("b");
        list.add("c");
        list.add("d");
        list.add("e");
        list.add("f");
    
        System.out.println(list);
    
        Iterator<String> iterator = list.iterator();
        writeArray(iterator);
    
        System.out.println();
        System.out.println(list);
    }
    
    /**
     * 遞歸實現
     * @param iterator
     */
    private static void writeArray(Iterator<String> iterator) {
        // 如果迭代器中還有數據,才進行遞歸計算
        if (iterator.hasNext()) {
            // 獲得當前指針的參數
            String next = iterator.next();
            // 刪除當前指針
            iterator.remove();
            // 使用遞歸計算
            writeArray(iterator);
            System.out.print(next + " ");
        }
    }
    

    輸出結果

    [a, b, c, d, e, f]
    f e d c b a 
    []
    

由上述代碼可以看出,使用遞歸算法,也可以同樣得到結果,並且可以不使用for循環來完成任務。

調用棧

調用棧 用來跟蹤所調用方法的順序的內部結構。

了解遞歸的底層機制,對於初學者來說是很有幫助的。我們先來分析下面這段代碼:

public static void main(String[] args) {
     draw();
     draws();
}

private static void draw(){
    System.out.println("在紙上畫了一幅畫");
}
private static void draws(){
    draw();
    draw();
}

我們來人工debug一下這段代碼。

  1. 程序首先進入main方法;
  2. 然后程序會停止執行main方法,轉而去執行draw方法,當程序執行完draw方法時,會回到main方法當中;
  3. 接着需要執行draws方法,draws方法又會去調用兩次draw方法。而此時位於頂端的方法則是我們正在執行的方法draw;
  4. 當我們執行draw時,回到了draws上面,執行完draws后,最終會回到main方法中;

調用一個方法我們就把它放在最上面的位置,這就是Java的調用棧(call stack)

了解了什么是調用棧,則可以利用調用棧來分析剛剛那個遞歸倒敘是如何工作的了。

案例三

整數的密運算。Math.pow(x, y)可以進行x的y次冪運算。但是如果要通過遞歸該如何實現呢?

為求簡單,我們只考慮整數的情況。也就是說我們不考慮負指數,因為它的結果不是整數。

在遞歸情況中,我們已知y > 0。我們至少需要再乘一個x:x的y次冪=x * x的y-1次冪$x^y = x * x^z$)。由此我們可通過以下代碼實現。

public static void main(String[] args) {
    System.out.println(Math.pow(3, 5));
    System.out.println(pow(3, 5));
}

private static int pow(int x, int y) {
    // 按照定義,任何整數的零次冪都是1
    if (y == 0) {
        return 1;
    } else {
        return x * pow(x, y - 1);
    }
}

運算結果

243.0
243

案例四

我們在寫程序時,通常都會使用包含上下級結構的碼表,例如下面這個數據:

SysCode{scId='01', itemName='測試1', sort=1, fScId='null', cDesc='測試測試1'}
SysCode{scId='02', itemName='測試2', sort=1, fScId='01', cDesc='測試測試2'}
SysCode{scId='03', itemName='測試3', sort=3, fScId='01', cDesc='測試測試3'}
SysCode{scId='04', itemName='測試4', sort=2, fScId='01', cDesc='測試測試4'}
SysCode{scId='05', itemName='測試5', sort=1, fScId='03', cDesc='測試測試5'}
SysCode{scId='06', itemName='測試6', sort=2, fScId='03', cDesc='測試測試6'}
SysCode{scId='07', itemName='測試7', sort=2, fScId='null', cDesc='測試測試2'}
SysCode{scId='08', itemName='測試8', sort=1, fScId='07', cDesc='測試測試2'}

分析以上代碼,我們可以根據fScId封裝上下級
關系,首先在SysCode類中增加一個children集合來保存子集。

/**
 * 使用遞歸創建子集
 *
 * @param scId
 * @param list
 * @return
 */
private List<SysCode> getSysCodeChildren(String scId, List<SysCode> list) {
    List<SysCode> sysCodeList = new ArrayList<>();
    list.forEach(e -> {
        if (null != e.getfScId() && e.getfScId().equals(scId)) {
            e.setChildren(getSysCodeChildren(e.getScId(), list));
            sysCodeList.add(e);
        }
    });
    return sysCodeList;
}

遞歸回溯

很多問題都可以通過系統地檢查各種可能性來求解。比如,一個迷宮得游戲,要從入口找到出口,可以檢查每一條線路,直到找到可行的線路。

很多使用窮舉搜索求解得問題都能夠用回溯法(backtracking)求解。回溯法是一種適宜用遞歸方式表示的問題求解方法。因此,這種方法也稱為遞歸回溯(recursive backtracking)。

(遞歸)回溯法 一種搜索問題解的通用算法,它先找出可能得候選解,一旦確定某個候選解不適合,就立刻放棄進一步嘗試(回溯)。

案例分析

移動線路問題

考慮一個標准的平面直角坐標系(x, y),假設從原點(0, 0)出發,可以進行以下三種移動方式:
線路移動說明

  • 向北移動(用“ N ”表示),每次移動縱坐標 +1;
  • 向東移動(用“ E ”表示),每次移動橫坐標 +1;
  • 向東北移動(用“ NE ”表示),每次縱坐標,橫坐標各 +1;

如上圖所示,從原點出發,會有三種不同的移動方式。現在定義一種移動路線問題:通過一系列移動,從原點到坐標(x, y)。例如,通過移動序列:N -> NE -> N 可以到達(1, 3)。

每個適用於回溯法解決的問題都具有包含問題所有可能解的解空間(solution space)。可以把解空間想象成一顆決策樹(decision tree)。對於我們要解決的移動線路問題來說,每次移動方案都是選擇的結果。

決策樹

考慮從原點(0, 0)到(1, 2),所有可能的移動序號是:

  • N -> N -> E
  • N -> E -> N
  • N -> NE
  • E -> N -> N
  • NE -> N

用程序該如何找出這些解呢?對於大多數回溯問題,最終都會編寫兩個方法。一般會定義一個含有反應問題特性的參數的公有方法。另外會定義一個包含一些額外參數,執行實際回溯處理過程的私有方法。

因為要使用遞歸,所以需要確定基本情況和遞歸情況這兩個部分。回溯法通常包含兩種不通的基本情況。找到解時,停止回溯,這也是遞歸過程的一個基本情況。發現遇到死路的時候,停止繼續搜索。

在回溯搜索過程中,如果既沒有找到問題的解,也沒有進入死路,就需要繼續探索每一種可能的選擇。根據上述的分析,我們可以編寫以下偽代碼來解釋該回溯過程:

private static void explore(current(x, y), target(x, y)) {
    if(一個解) {
        打印出解
    } else if(不是死路) {
        explore(向N移動);
        explore(向E移動);
        explore(向NE移動);
    }
}

在這個問題中,我們需要當前位置的x, y坐標和目標x, y的坐標。還要記錄每次移動的結果,用來形成最終的路徑報告。

我們可以通過對比當前移動的起點和終點是否匹配,來驗證是否找到了解。對於是否進入死路這個問題,我們知道在移動的過程中,橫坐標和縱坐標只會遞增,所以一旦當前位置的x坐標值大於目標位置的x坐標值,或者當前位置的y坐標值大於目標位置的y坐標值,就能知道在相應方向上移動距離超過了目標值,所以永遠不可能找到解,這時需要停止繼續搜索。整合這些分析,我們可以得到以下代碼:

public static void main(String[] args) {

    // 使用回溯法找到(1, 2)的所有線路
    travel(1, 2);

}

/**
 * 反應問題特性的參數的公有方法
 *
 * @param targetX :x目標值
 * @param targetY :y目標值
 */
public static void travel(int targetX, int targetY) {
    explore(targetX, targetY, 0, 0, "path: ");
}

/**
 * 執行實際回溯處理過程的私有方法
 *
 * @param targetX :x目標值
 * @param targetY :y目標值
 * @param currX   :當前x目標值
 * @param currY   :當前y目標值
 * @param path    :移動的路徑
 */
private static void explore(int targetX, int targetY, int currX, int currY, String path) {
    if (currX == targetX && currY == targetY) {
        System.out.println("找到解:" + path);
    } else if (currX <= targetX && currY <= targetY) {
        explore(targetX, targetY, currX, currY + 1, path + " N");
        explore(targetX, targetY, currX + 1, currY, path + " E");
        explore(targetX, targetY, currX + 1, currY + 1, path + " NE");
    }
}

輸出結果

找到解:path:  N N E
找到解:path:  N E N
找到解:path:  N NE
找到解:path:  E N N
找到解:path:  NE N

下圖則是上述搜索對應的決策樹,其中五個深色的部分就是找到的解。
代碼對應的決策樹

這種返回到上一次選擇並繼續搜索其他可能性的特性,是講該類方法成為回溯法的原因。找到解或者死路的時候,就返回上一次進行選擇的情況,並嘗試其他可能,直到嘗試完所有可能的選擇。


參考文章:

來源:https://muycode.com/article/backtracking200409.html

《Java程序設計教程 第三版》


免責聲明!

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



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