426,什麼是遞歸,經過這篇文章,讓你完全搞懂遞歸

Beauty begins the moment you decide to be yourself.
node

美麗開始於你決定作本身的那一刻。web

啥叫遞歸算法



tips:文章有點長,能夠慢慢看,若是來不及看,也能夠先收藏之後有時間在看。json


聊遞歸以前先看一下什麼叫遞歸。數組

遞歸,就是在運行的過程當中調用本身。微信


構成遞歸需具有的條件:數據結構

1. 子問題須與原始問題爲一樣的事,且更爲簡單;數據結構和算法

2. 不能無限制地調用自己,須有個出口,化簡爲非遞歸情況處理。編輯器


遞歸語言例子ide



咱們用2個故事來闡述一下什麼叫遞歸。


1,從前有座山,山裏有座廟,廟裏有個老和尚,正在給小和尚講故事呢!故事是什麼呢?「從前有座山,山裏有座廟,廟裏有個老和尚,正在給小和尚講故事呢!故事是什麼呢?‘從前有座山,山裏有座廟,廟裏有個老和尚,正在給小和尚講故事呢!故事是什麼呢?……’」


2,大雄在房裏,用時光電視看着從前的狀況。電視畫面中的那個時候,他正在房裏,用時光電視,看着從前的狀況。電視畫面中的電視畫面的那個時候,他正在房裏,用時光電視,看着從前的狀況……


遞歸模板



咱們知道遞歸必須具有兩個條件,一個是調用本身,一個是有終止條件。這兩個條件必須同時具有,且一個都不能少。而且終止條件必須是在遞歸最開始的地方,也就是下面這樣

public void recursion(參數0) { if (終止條件) { return; } recursion(參數1);}

不能把終止條件寫在遞歸結束的位置,下面這種寫法是錯誤的

public void recursion(參數0) { recursion(參數1); if (終止條件) { return; }}

若是這樣的話,遞歸永遠退不出來了,就會出現堆棧溢出異常(StackOverflowError)。


但實際上遞歸可能調用本身不止一次,而且不少遞歸在調用以前或調用以後都會有一些邏輯上的處理,好比下面這樣。

public void recursion(參數0) { if (終止條件) { return; }
可能有一些邏輯運算 recursion(參數1)    可能有一些邏輯運算 recursion(參數2) …… recursion(參數n) 可能有一些邏輯運算}


實例分析



我對遞歸的理解是先往下一層層傳遞,當碰到終止條件的時候會反彈,最終會反彈到調用處。下面咱們就以5個最多見的示例來分析下


1,階乘

咱們先來看一個最簡單的遞歸調用-階乘,代碼以下

1public int recursion(int n) {
2    if (n == 1)
3        return 1;
4    return n * recursion(n - 1);
5}

這個遞歸在熟悉不過了,第2-3行是終止條件,第4行是調用本身。咱們就用n等於5的時候來畫個圖看一下遞歸到底是怎麼調用的

若是看不清,圖片可點擊放大。

這種遞歸仍是很簡單的,咱們求f(5)的時候,只須要求出f(4)便可,若是求f(4)咱們要求出f(3)……,一層一層的調用,當n=1的時候,咱們直接返回1,而後再一層一層的返回,直到返回f(5)爲止。


遞歸的目的是把一個大的問題細分爲更小的子問題,咱們只須要知道遞歸函數的功能便可,不要把遞歸一層一層的拆開來想,若是同時調用屢次的話這樣你極可能會陷入循環而出不來。好比上面的題中要求f(5),咱們只須要計算f(4)便可,即f(5)=5*f(4);至於f(4)是怎麼計算的,咱們就不要管了。由於咱們知道f(n)中的n能夠表明任何正整數,咱們只須要傳入4就能夠計算f(4)。


2,斐波那契數列

咱們再來看另外一道經典的遞歸題,就是斐波那契數列,數列的前幾項以下所示

[1,1,2,3,5,8,13……]

咱們參照遞歸的模板來寫下,首先終止條件是當n等於1或者2的時候返回1,也就是數列的前兩個值是1,代碼以下

1public int fibonacci(int n) {
2    if (n == 1 || n == 2)
3        return 1;
4    這裏是遞歸調用;
5}

遞歸的兩個條件,一個是終止條件,咱們找到了。還一個是調用本身,咱們知道斐波那契數列當前的值是前兩個值的和,也就是

fibonacci(n) =fibonacci(n - 1) + fibonacci(n - 2)


因此代碼很容易就寫出來了

1//1,1,2,3,5,8,13……
2public int fibonacci(int n) {
3    if (n == 1 || n == 2)
4        return 1;
5    return fibonacci(n - 1) + fibonacci(n - 2);
6}


3,漢諾塔

經過前面兩個示例的分析,咱們對遞歸有一個大概的瞭解,下面咱們再來看另外一個示例-漢諾塔,這個其實前面講過,有興趣的能夠看下362,漢諾塔

漢諾塔的原理這裏再簡單提一下,就是有3根柱子A,B,C。A柱子上由上至下依次由小至大排列的圓盤。把A柱子上的圓盤借B柱子所有移動到C柱子上,而且移動的過程始終是小的圓盤在上,大的在下。咱們仍是用遞歸的方式來解這道題,先來定義一個函數

public void hanoi(int n, char A, char B, char C)

他表示的是把n個圓盤從A藉助B成功的移動到C。


咱們先來回顧一下遞歸的條件,一個是終止條件,一個是調用本身。咱們先來看下遞歸的終止條件就是當n等於1的時候,也就是A柱子上只有一個圓盤的時候,咱們直接把A柱子上的圓盤移動到C柱子上便可。

1//表示的是把n個圓盤藉助柱子B成功的從A移動到C
2public static void hanoi(int n, char A, char B, char C{
3    if (n == 1) {
4        //若是隻有一個,直接從A移動到C便可
5        System.out.println("從" + A + "移動到" + C);
6        return;
7    }
8    這裏是遞歸調用
9}

再來看一下遞歸調用,若是n不等於1,咱們要分3步,

1,先把n-1個圓盤從A藉助C成功的移動到B

2,而後再把第n個圓盤從A移動到C

3,最後再把n-1個圓盤從B藉助A成功的移動到C。


那代碼該怎麼寫呢,咱們知道函數

hanoi(n, 'A', 'B', 'C')表示的是把n個圓盤從A藉助B成功的移動到C

因此hanoi(n-1, 'A', 'C', 'B')就表示的是把n-1個圓盤從A藉助C成功的移動到B

hanoi(n-1, 'B', 'A', 'C')就表示的是把n-1個圓盤從B藉助A成功的移動到C


因此上面3步若是用代碼就能夠這樣來表示

1,hanoi(n-1, 'A', 'C', 'B')

2,System.out.println("從" + A + "移動到" + C);

3,hanoi(n-1, 'B', 'A', 'C')


因此最終完整代碼以下

 1//表示的是把n個圓盤藉助柱子B成功的從A移動到C
2public static void hanoi(int n, char A, char B, char C{
3    if (n == 1) {
4        //若是隻有一個,直接從A移動到C便可
5        System.out.println("從" + A + "移動到" + C);
6        return;
7    }
8    //表示先把n-1個圓盤成功從A移動到B
9    hanoi(n - 1, A, C, B);
10    //把第n個圓盤從A移動到C
11    System.out.println("從" + A + "移動到" + C);
12    //表示把n-1個圓盤再成功從B移動到C
13    hanoi(n - 1, B, A, C);
14}

經過上面的分析,是否是感受遞歸很簡單。因此咱們寫遞歸的時候徹底能夠套用上面的模板,先寫出終止條件,而後在寫遞歸的邏輯調用。還有一點很是重要,就是必定要明白遞歸函數中每一個參數的含義,這樣在邏輯處理和函數調用的時候才能駕輕就熟,函數的調用咱們必定不要去一步步拆開去想,這樣頗有可能你會奔潰的。


4,二叉樹的遍歷

再來看最後一個常見的示例就是二叉樹的遍歷,在前面也講過,若是有興趣的話能夠看下373,數據結構-6,樹,咱們主要來看一下二叉樹的前中後3種遍歷方式,


1,先看一下前序遍歷(根節點最開始),他的順序是

根節點→左子樹→右子樹

咱們來套用模板看一下

1public void preOrder(TreeNode node) {
2    if (終止條件)// (必需要有)
3        return;
4    邏輯處理//(不是必須的)
5    遞歸調用//(必需要有)
6}

終止條件是node等於空,邏輯處理這塊直接打印當前節點的值便可,遞歸調用是先打印左子樹在打印右子樹,咱們來看下

1public static void preOrder(TreeNode node{
2    if (node == null)
3        return;
4    System.out.printf(node.val + "");
5    preOrder(node.left);
6    preOrder(node.right);
7}


中序遍歷和後續遍歷直接看下

2,中序遍歷(根節點在中間)

左子樹→根節點→右子樹

1public static void inOrder(TreeNode node{
2    if (node == null)
3        return;
4    inOrder(node.left);
5    System.out.println(node.val);
6    inOrder(node.right);
7}


3,後序遍歷(根節點在最後)

左子樹→右子樹→根節點

1public static void postOrder(TreeNode tree{
2    if (tree == null)
3        return;
4    postOrder(tree.left);
5    postOrder(tree.right);
6    System.out.println(tree.val);
7}


5,鏈表的逆序打印

這個就不在說了,直接看下

1public void printRevers(ListNode root{
2    //(終止條件)
3    if (root == null)
4        return;
5    //(遞歸調用)先打印下一個
6    printRevers(root.next);
7    //(邏輯處理)把後面的都打印完了在打印當前節點
8    System.out.println(root.val);
9}


分支污染問題



經過上面的分析,咱們對遞歸有了更深一層的認識。但總以爲還少了點什麼,其實遞歸咱們還能夠經過另外一種方式來認識他,就是n叉樹。在遞歸中若是隻調用本身一次,咱們能夠把它想象爲是一棵一叉樹(這是我本身想的,咱們能夠認爲只有一個子節點的樹),若是調用本身2次,咱們能夠把它想象爲一棵二叉樹,若是調用本身n次,咱們能夠把它想象爲一棵n叉樹……。就像下面這樣,當到達葉子節點的時候開始往回反彈。

遞歸的時候若是處理不當可能會出現分支污染致使結果錯誤。爲何會出現這種狀況,我先來解釋一下,由於除了基本類型是值傳遞之外,其餘類型基本上不少都是引用傳遞。看一下上面的圖,好比我開始調用的時候傳入一個list對象,在調用第一個分支以後list中的數據修改了,那麼後面的全部分支都能感知到,實際上也就是對後面的分支形成了污染。


咱們先來看一個例子吧

給定一個數組nums=[2,3,5]和一個固定的值target=8。找出數組sums中全部可使數字和爲target的組合。先來畫個圖看一下

圖中紅色的表示的是選擇成功的組合,這裏只畫了選擇2的分支,因爲圖太大,因此選擇3和選擇5的分支沒畫。在仔細一看這不就是一棵3叉樹嗎,OK,咱們來使用遞歸的方式,先來看一下函數的定義

1private void combinationSum(List<Integer> cur, int sums[], int target) {
2
3}

在把遞歸的模板拿出來

 1private void combinationSum(List<Integer> cur, int sums[], int target) {
2    if (終止條件) {
3        return;
4    }
5    //邏輯處理
6
7    //由於是3叉樹,因此這裏要調用3次
8    //遞歸調用
9    //遞歸調用
10    //遞歸調用
11
12    //邏輯處理
13}

這種解法靈活性不是很高,若是nums的長度是3,咱們3次遞歸調用,若是nums的長度是n,那麼咱們就要n次調用……。因此咱們能夠直接寫成for循環的形式,也就是下面這樣

 1private void combinationSum(List<Integer> cur, int sums[], int target) {
2    //終止條件必需要有
3    if (終止條件) {
4        return;
5    }
6    //邏輯處理(無關緊要,是狀況而定)
7    for (int i = 0; i < sums.length; i++) {
8        //邏輯處理(無關緊要,是狀況而定)
9        //遞歸調用(遞歸調用必需要有)
10        //邏輯處理(無關緊要,是狀況而定)
11    }
12    //邏輯處理(無關緊要,是狀況而定)
13}

下面咱們再來一步一步看

1,終止條件是什麼?

當target等於0的時候,說明咱們找到了一組組合,咱們就把他打印出來,因此終止條件很容易寫,代碼以下

1    if (target == 0) {
2        System.out.println(Arrays.toString(cur.toArray()));
3        return;
4    }


2,邏輯處理和遞歸調用

咱們一個個往下選的時候若是要選的值比target大,咱們就不要選了,若是不比target大,就把他加入到list中,表示咱們選了他,若是選了他以後在遞歸調用的時候target值就要減去選擇的值,代碼以下

1        //邏輯處理
2        //若是當前值大於target咱們就不要選了
3        if (target < sums[i])
4            continue;
5        //不然咱們就把他加入到集合中
6        cur.add(sums[i]);
7        //遞歸調用
8        combinationSum(cur, sums, target - sums[i]);


終止條件和遞歸調用都已經寫出來了,感受代碼是否是很簡單,咱們再來把它組合起來看下完整代碼

 1private void combinationSum(List<Integer> cur, int sums[], int target{
2    //終止條件必需要有
3    if (target == 0) {
4        System.out.println(Arrays.toString(cur.toArray()));
5        return;
6    }
7    for (int i = 0; i < sums.length; i++) {
8        //邏輯處理
9        //若是當前值大於target咱們就不要選了
10        if (target < sums[i])
11            continue;
12        //不然咱們就把他加入到集合中
13        cur.add(sums[i]);
14        //遞歸調用
15        combinationSum(cur, sums, target - sums[i]);
16    }

咱們還用上面的數據打印測試一下

1public static void main(String[] args) {
2    new Recursion().combinationSum(new ArrayList<>(), new int[]{235}, 8);
3}

運行結果以下

是否是很意外,咱們思路並無出錯,結果爲何不對呢,其實這就是典型的分支污染,咱們再來看一下圖

當咱們選擇2的時候是一個分支,當咱們選擇3的時候又是另一個分支,這兩個分支的數據應該是互不干涉的,但實際上當咱們沿着選擇2的分支走下去的時候list中會攜帶選擇2的那個分支的數據,當咱們再選擇3的那個分支的時候這些數據還依然存在list中,因此對選擇3的那個分支形成了污染。有一種解決方式就是每一個分支都建立一個新的list,也就是下面這樣,這樣任何一個分支的修改都不會影響到其餘分支。

再來看下代碼

 1private void combinationSum(List<Integer> cur, int sums[], int target) {
2    //終止條件必需要有
3    if (target == 0) {
4        System.out.println(Arrays.toString(cur.toArray()));
5        return;
6    }
7    for (int i = 0; i < sums.length; i++) {
8        //邏輯處理
9        //若是當前值大於target咱們就不要選了
10        if (target < sums[i])
11            continue;
12        //因爲List是引用傳遞,因此這裏要從新建立一個
13        List<Integer> list = new ArrayList<>(cur);
14        //把數據加入到集合中
15        list.add(sums[i]);
16        //遞歸調用
17        combinationSum(list, sums, target - sums[i]);
18    }
19}

咱們看到第13行是從新建立了一個list。再來打印一下看下結果,結果徹底正確,每一組數據的和都是8

上面咱們每個分支都建立了一個新的list,因此任何分支修改都只會對當前分支有影響,不會影響到其餘分支,也算是一種解決方式。但每次都從新建立數據,運行效率不好。咱們知道當執行完分支1的時候,list中會攜帶分支1的數據,當執行分支2的時候,實際上咱們是不須要分支1的數據的,因此有一種方式就是從分支1執行到分支2的時候要把分支1的數據給刪除,這就是你們常常提到的回溯算法,咱們來看下

 1private void combinationSum(List<Integer> cur, int sums[], int target{
2    //終止條件必需要有
3    if (target == 0) {
4        System.out.println(Arrays.toString(cur.toArray()));
5        return;
6    }
7    for (int i = 0; i < sums.length; i++) {
8        //邏輯處理
9        //若是當前值大於target咱們就不要選了
10        if (target < sums[i])
11            continue;
12        //把數據sums[i]加入到集合中,而後參與下一輪的遞歸
13        cur.add(sums[i]);
14        //遞歸調用
15        combinationSum(cur, sums, target - sums[i]);
16        //sums[i]這個數據你用完了吧,我要把它刪了
17        cur.remove(cur.size() - 1);
18    }
19}

咱們再來看一下打印結果,徹底正確

遞歸分支污染對結果的影響



分支污染通常會對結果形成致命錯誤,但也不是絕對的,咱們再來看個例子。生成一個2^n長的數組,數組的值從0到(2^n)-1,好比n是3,那麼要生成

[0, 0, 0][0, 0, 1][0, 1, 0][0, 1, 1][1, 0, 0][1, 0, 1][1, 1, 0][1, 1, 1]

咱們先來畫個圖看一下

這不就是個二叉樹嗎,對於遞歸前面已經講的不少了,咱們來直接看代碼

 1private void binary(int[] arrayint index) {
2    if (index == array.length) {
3        System.out.println(Arrays.toString(array));
4    } else {
5        int temp = array[index];
6        array[index] = 0;
7        binary(array, index + 1);
8        array[index] = 1;
9        binary(array, index + 1);
10        array[index] = temp;
11    }
12}

上面代碼很好理解,首先是終止條件,而後是遞歸調用,在調用以前會把array[index]的值保存下來,最後再還原。咱們來測試一下

new Recursion().binary(new int[]{0, 0, 0}, 0);

看下打印結果

結果徹底正確,咱們再來改一下代碼

 1private void binary(int[] arrayint index) {
2    if (index == array.length) {
3        System.out.println(Arrays.toString(array));
4    } else {
5        array[index] = 0;
6        binary(array, index + 1);
7        array[index] = 1;
8        binary(array, index + 1);
9    }
10}

再來看一下打印結果

和上面結果如出一轍,開始的時候咱們沒有把array[index]的值保存下來,最後也沒有對他進行復原,但結果絲絕不差。緣由就在上面代碼第5行array[index]=0,這是由於,上一分支執行的時候即便對array[index]形成了污染,在下一分支又會對他進行從新修改。即便你把它改成任何數字也都不會影響到最終結果,好比咱們在上一分支執行完了時候咱們把它改成100,你在試試

 1private void binary(int[] arrayint index) {
2    if (index == array.length) {
3        System.out.println(Arrays.toString(array));
4    } else {
5        array[index] = 0;
6        binary(array, index + 1);
7        array[index] = 1;
8        binary(array, index + 1);
9        //注意,這裏改爲100了
10        array[index] = 100;
11    }
12}

咱們看到第10行,把array[index]改成100了,最終打印結果也是不會變的,因此這種分支污染並不會形成最終的結果錯誤。


總結



對遞歸的理解,看完這篇文章應該沒有什麼疑問了,記住上面模板,其實代碼很好寫的,後面也會再寫一些關於遞歸的算法題的,讓你完全搞懂遞歸。



411,動態規劃和遞歸求不一樣路徑 II

394,經典的八皇后問題和N皇后問題

391,回溯算法求組合問題

371,揹包問題系列之-基礎揹包問題


長按上圖,識別圖中二維碼以後便可關注。


若是以爲有用就點個"贊"和「在看」吧

本文分享自微信公衆號 - 數據結構和算法(sjjghsf)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。