Chapter III. 漸入佳境
基礎演算法
遞迴
作者nathanlee726
先備知識介紹

定義


遞迴從程式的角度來解釋,指的是一個函式在執行過程中會呼叫自己,使得函式中的程式碼會被反覆執行。直到達成某個條件後,這個函式才會停止呼叫自己,並完成執行後回傳,從而結束遞迴的過程。

簡單舉一個例子,想像一下在走迷宮時,我們會不斷遇到路口,如果我們想要用程式來程式來走迷宮,我們便會寫一個函式來處理遇到路口時該做什麼行動。在這個函式中,我們會分別嘗試左轉、直走、或右轉並遇到下一個路口,然後再做一次遇到路口後的行動。這一系列的行為會一直持續下去,直到我們達成了「走到終點」這個條件才會停止。試著將這段口語敘述轉換成程式碼後,看起來會是這樣:

void intersection() {
    if (arrive_exit) {
        win();
        return;
    }
    if (left_enable) {
        turn_left();
        intersection();
    }
    if (right_enable) {
        turn_right();
        intersection();
    }
    if (forward_enable) {
        move_forward();
        intersection();
    }
    return;
}

遞迴函式通常會在我們需要解決具有多個層次的問題時被使用。在迷宮這個例子中,每個十字路口都是一層問題,當我們選擇往一個方向走後,便會遇到下一個十字路口,也就是下一層問題。由於我們一開始不知道走完迷宮總共需要解決幾層十字路口的問題,這時候就會使用遞迴這個技巧。我們可以使用一個遞迴函式來解決一層十字路口的問題,而這個遞迴函式遇到下一個十字路口,也就是下一層問題時,就會再呼叫一次自己這個函式,來解決新的一層問題。

另外,在這個遞迴函式中我們也可以發現,如果到了一個十字路口時,它的左邊、前方、右邊都沒有路,也就是到了一個死路後,函式便會停止前往新的十字路口,並回到上一個十字路口。這就是遞迴中最重要的概念,「遞迴終止條件」。遞迴終止條件往往是我們在寫遞迴時第一件要考慮的事情,一來它能夠避免遞迴函式不知道要停下來而一直遞迴下去,形成無窮遞迴。二來這個條件也能幫助我們思考遞迴函式解決問題的條件,詳細考慮什麼情況才算是完成遞迴,能夠更好理解題目。

在撰寫遞迴函式時,有一個要點是,只需要考慮在解決這一層問題時需要注意的事就好,不要試圖連下一層的情況也考慮進去,否則你會讓你的腦袋跑進一個無窮遞迴。當我們遇到需要進入下一層問題的情況時,直接把自己這個遞迴函式放上去就行。寫遞迴函式時要一邊假裝這個函式已經寫好,一邊完成這個函式的實作。就像在迷宮的例子中,我們假裝 intersection 已經寫好了,這樣左轉、直走、或右轉後遇到新的十字路口,我們就可以直接用已經「寫好」的 intersection 解決了。

Tips 1
思考遞迴的技巧

要記住遞迴最重要的兩個重點!

  • 遞迴終止條件:記得寫下所有遞迴終止條件,避免出現無窮遞迴,也幫助自己了解題目
  • 撰寫遞迴函式:遇到要往下遞迴的地方時,不要去煩惱遞迴下去會發生什麼事,只要先假裝自己的遞迴函式已經寫好就行了

應用


既然我們提到了遞迴通常是用來處理有多個層次的問題,那麼具體而言會像是怎麼樣的題目呢,就讓我們來看一些例子吧:

例題
Fibonacci Number

費氏數列由一連串的費氏數組成,滿足每一項的數字都由前兩項相加得到,並由 $0$ 和 $1$ 作為數列的起始值。

若寫下費氏數列的遞迴關係式,可以得到:
$
F(0) = 0, F(1) = 1\\
F(n) = F(n - 1) + F(n - 2),\ \text{for }n > 1
$

給定 $n$,請求出 $F(n)$

條件限制
  • $0\leq n\leq 30$

從上述關係式可以知道,要求出費波那契數列的第 $n$ 項 $F(n)$,就必須求出 $F(n-1), F(n-2)$,而要求出 $F(n-1)$,我們就會需要知道 $F(n-2), F(n-3)$,一路求到 $F(0), F(1)$為止。因此,我們可以把費波那契數列的每一項都看成一層問題,再利用遞迴來解決每一層問題。

來考慮一下前面提到的兩個重點:

int fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fib(n - 1) + fib(n - 2);
}
例題
河內塔
Source:TIOJ 1355

傳說在古老的印度,有一座神廟,據說它是宇宙的中心。

在廟宇中放置了一塊上面插有三根長木釘的木板,在其中的一根木釘上,從上至下被放置了 $64$ 片直徑由小至大的圓環形金屬片。

古印度教的天神指示祂的僧侶們將 $64$ 片的金屬片移至三根木釘中的其中一根上。

規定在每次的移動中,只能搬移一片金屬片,並且在過程中必須保持金屬片由上至下是直徑由小至大的次序,也就是說不論在那一根木釘上,圓環形的金屬片都是直徑較小的被放在上層。

直到有一天,僧侶們能將 $64$ 片的金屬片依規則從指定的木釘上全部移動至另一根木釘上,那麼,世界末日即隨之來到,世間的一切終將被毀滅,萬物都將至極樂世界。

僧侶極希望能早點結束這繁重的工作,於是來求助於你,要如何才能有效率的進行這工作呢?

條件限制
  • 金屬片的數量 $\leq 10$

我們先來簡單了解一下河內塔的遊戲策略,首先,遊戲一開始的第一個目標,便是想辦法把最大的盤子從最左邊移動到最右邊。而要能夠完成這個目標,我們必須把最小的盤子到第二大的盤子都搬到中間。同樣的,要達成這個目標,我們要能夠把第二大的盤子放到中間,在那之前,要先把最小的盤子到第三大的盤子移動到右邊。降到這邊,相信大家已經看出來題目所具有的層次性了。我們可以將「把最小的盤子道第 $n$ 個盤子從 $a$ 柱子移動到 $b$ 柱子」看成一層問題,並用遞迴來解決這個問題。在撰寫程式的時候後,我們同樣要注意兩個技巧:

void move(int a, int b) {
    static int steps = 0;
    ++steps;
    cout << "#" << steps << " : move the dish from #" << a << " to #" << b << "\n";
}
void hanoi(int n, int a, int b, int c) { // 將前 n 個盤子從 a 移動到 b,c 是無關的那跟柱子
    if (n == 1) {
        move(a, b);
        return; 
    }
    hanoi(n - 1, a, c, b);
    move(a, b);
    hanoi(n - 1, c, b, a);
}

剪枝


最後,我們要提到遞迴的一個技巧是剪枝。大家還記得遞迴一定要設定終止條件,以免陷入無窮迴圈。而終止條件通常會被設定成問題被解決時的條件,不過,其實我們也能在確定問題不能以目前的方向解決時就結束遞迴。舉例而言,如果我們在走迷宮被工作人員告知「這個方向是死路」,那麼我們便不會在前方的十字路口做選擇,而是直接回頭,以免浪費時間。在一些遞迴的題目中,我們也會利用類似的技巧來減少執行的時間,這便是所謂的剪枝。

接下來,我們再透過一個經典例子來加深理解吧:

例題
列舉八皇后問題所有解

西洋棋的棋盤中你可以放置 $8$ 個皇后而且彼此都不衝突(就是都不能吃到對方)。請你寫一個程式來輸出所有這樣可能的安排。

為了把棋盤標準化,我們定義棋盤最左上角的位置為$(1,1)$。所以下圖黑色方塊的位置為$(4,6)$,代表第 $4$ 列(ROW),第 $6$ 行(COLUMN)。

首先,我們來找出這個問題的遞迴性質。可以發現到我們可以在每一排都選一格放皇后,等八排都放完後再來檢查這樣是不是一種正確的放法。那麼,決定第 $k$ 排的皇后要放哪就會是一個可以被遞迴解決的問題,寫成程式碼後大概會長這樣:

void queens(int table[8][8], int n) {
    for (int i = 0; i < 8; i++) {
        table[n - 1][i] = 1;
        if (n == 8) check(table);
        else queens(table, n + 1);
        table[n - 1][i] = 0;
    }
    return;
}

不過,我們會發現這種方法似乎有點浪費時間,有時候前兩個皇后就已經放在同一直行了,程式卻還是會繼續遞迴。因此,為了避免程式浪費時間遞迴不可能的放法,我們可以使用剪枝這個技巧。我們只需要在放下這一橫列的皇后後,先檢查它跟前面的皇后沒有在同一直行或是同一斜排上,確認沒問題後才繼續遞迴就可以了。

void queens(int table[8][8], int n) {
    for (int i = 0; i < 8; i++) {
        bool legal = true;
        // 檢查有沒有在同一直行
        for (int j = 0; j < n - 1; j++)
            if (table[j][i]) 
                legal = false;
        // 以棋盤左下角為 (0, 0)
        // 檢查左下到右上方向的斜線,table[x][y] 的 (y - x) 數值一致
        for (int j = 0; j < n - 1; j++)
            if (i - (n - 1 - j) >= 0 && table[j][i - (n - 1 - j)]) 
                legal = false;
        // 檢查左上到右下方向的直線,table[x][y] 的 (x + y) 數值一致
        for (int j = 0; j < n - 1; j++)
            if (i + (n - 1 - j) < 8 && table[j][i + (n - 1 - j)])
                legal = false;
        // 合法才會繼續遞迴,不合法的即被「剪枝」 
        if (legal) { 
            table[n - 1][i] = 1;
            if (n == 8) found();
            else queens(table, n + 1);
            table[n - 1][i] = 0;
        }
    }
}

習題


習題
河內塔(續)
Source:TIOJ 1356

繼承先前我們提到的河內塔例題,若 3 號柱子與 1 號柱子的移動過程之間,一定要先經過 2 號柱子,你能給出移動方法嗎?

條件限制
  • 金屬片的數量 $\leq 10$
習題
數獨
Source:NEOJ 62

在數獨遊戲中,遊戲給你一個 $9\times 9$ 的方陣,其中還可以分成 $9$ 個 $3\times 3$ 的子方陣。例如:

數獨的遊戲規則是這樣的,最後完成數獨的時候,每個格子都必須填上 $1\sim 9$ 其中一個數字,並且在每一行、每一列、每一個子方陣中,都不能有重複的數字。

現在給你一個 $9\times 9$ 的方陣,上面有一些格子已經寫上數字了,你的任務是完成這個數獨。

條件限制
  • 每筆測試資料未填上數字的格子數保證不超過 $15$ 個
  • 單一測資檔不超過十筆測試資料