遞歸
遞歸 一種通過調用某個方法來描述需要重復進行的操作。該方法的特點就是可以自己調用自己。
案例一
-
排隊的問題
在生活中,我們經常需要排隊。在排隊中,我們怎么才能知道自己所排在第幾位呢?
我們也許會想到數自己前面有幾個人,這就是典型的迭代思想。就像是一個while循環,只要前面還有沒數過的人,就不會停止。這種方式相對來說是比較直觀的,但是同樣也有局限性。比如在排隊時,遇到了轉彎,我們看不到前面的人怎么辦呢?
有一個方法,我們可以通過詢問前面一個人他所處的位置。假設有A、B、C、D四個人,D詢問C所在的位置,C詢問B所在位置,B詢問A所在的位置。A知道自己排在第一位(他不需要再問別人了,這一個詢問的過程結束),然后告訴B,那B就知道自己在第二位;然后B告訴C,C也就知道了自己在第三位;最后C告訴D他在第三位時,D也就知道自己所在的位置了。這就是使用遞歸思想來分析問題——每個人都來回答一個問題,從而使這個問題多次重現(recur)。
這一點也是遞歸思想的精髓所在,遞歸方法涉及多個進行合作的單元,每個單元負責解決問題的一小部分。例如剛剛那個例子,每個人都像前詢問一個問題,最后每個人又向后回答一個問題,而不是由一個人來負責統計所有的人數。
-
迭代到遞歸的轉變
根據傳遞的長度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集合,現在需要按照倒敘把集合的內容打印出來,打印完之后集合里面不要有數據。
-
通過迭代實現
代碼
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 []
看到上面的結果,顯然是符合要求的。那我們如果使用遞歸,該怎么實現呢?
-
通過遞歸實現
代碼
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一下這段代碼。
- 程序首先進入main方法;
- 然后程序會停止執行main方法,轉而去執行draw方法,當程序執行完draw方法時,會回到main方法當中;
- 接着需要執行draws方法,draws方法又會去調用兩次draw方法。而此時位於頂端的方法則是我們正在執行的方法draw;
- 當我們執行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程序設計教程 第三版》