Home
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

4

Local AI

--

Uedu Code

--

Taipei AQI 57 · Taichung AQI 74 · Tainan AQI 63 · Kaohsiung AQI 64
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 15

遞迴

遞迴函式、終止條件與呼叫堆疊
Interaction實作 20 minute(s)

Learning Objectives

  • 說明遞迴函式的兩個部分:終止條件與遞迴步驟
  • 追蹤遞迴呼叫時呼叫堆疊一層層長出、再一層層收回的過程
  • 寫出階乘、陣列加總、次方等遞迴函式,並和迴圈寫法比較
  • 說明直接照定義寫的費氏數列為什麼會重複計算
  • 說明少了終止條件時會發生堆疊溢位

函式呼叫自己

有些問題可以拆成「一個比較小的同類問題,再加上一點點工作」。例如階乘:

  • 4! = 4 × 3!
  • 3! = 3 × 2!
  • 2! = 2 × 1!
  • 1! = 1(不用再拆,直接知道答案)

把這個想法寫成程式,就是一個呼叫自己的函式,叫做遞迴(recursion):

int factorial(int n) {
    if (n <= 1) {                  // 終止條件(base case)
        return 1;
    }
    return n * factorial(n - 1);   // 遞迴步驟(recursive case)
}

每個遞迴函式都要有兩個部分:

  1. 終止條件:問題小到可以直接回答,不再呼叫自己
  2. 遞迴步驟:把問題變小一點,呼叫自己處理小問題,再用小問題的答案組出原本的答案

每次呼叫都會在呼叫堆疊(call stack)上建立一個新的框架,放這一次呼叫自己的參數和區域變數。 所以 factorial(4) 和 factorial(3) 各有各的 n,彼此不會互相干擾。本課的動畫會讓你看到框架一層層疊上去,又一層層收回來。

階乘:堆疊長出來,再收回去

看右邊的框架:每呼叫一次 factorial 就在下方多一層,最下面那層是目前正在執行的。到 n 是 1 時碰到終止條件,接著每一層依序回傳,把下一層給的答案乘上自己的 n。

呼叫之前與呼叫之後

遞迴函式裡,寫在「呼叫自己」之前的程式,會在往下走的時候執行;寫在之後的程式,要等下面所有層都結束、回到這一層時才執行。 所以同樣是印出 n,放在遞迴呼叫前面或後面,印出來的順序剛好相反。

往下走、再往回走

countdown(3) 往下呼叫到 countdown(0)。注意第 10 行的「下去」在往下的途中印出,第 12 行的「回來」要等到下一層結束才印出,順序是 1、2、3。

想一想:追蹤遞迴

1.

下面的程式會印出什麼?

int mystery(int n) {
    if (n == 0) return 0;
    return n % 10 + mystery(n / 10);
}

int main() {
    cout << mystery(345);
}
2.

下面的程式會印出什麼?

void show(int n) {
    if (n == 0) return;
    show(n - 1);
    cout << n;
}

int main() {
    show(3);
}
3.

int power(int b, int e) { return b * power(b, e - 1); } 這個函式少了什麼?

第 1 次作答

遞迴與迴圈

能用遞迴寫的,也都能用迴圈寫,兩者各有長處:

遞迴 迴圈
程式長相 和數學定義、問題結構很接近,常常比較短 要自己安排變數記錄進度
執行成本 每次呼叫都要建立框架,比較慢、佔堆疊空間 通常比較快、省空間
適合 問題本身是遞迴結構:樹狀資料、資料夾、分治法(二分搜尋、合併排序)、窮舉所有組合 單純的重複、一路往前算

費氏數列的定義是 F(0) = 0、F(1) = 1、F(n) = F(n − 1) + F(n − 2)。照定義直接寫成遞迴非常直覺, 但 fib(n) 會呼叫 fib(n − 1) 和 fib(n − 2),fib(n − 1) 又會再算一次 fib(n − 2)……同樣的值被算了很多次, 呼叫次數大約每多 1 就乘上 1.6 倍,fib(40) 要呼叫超過 3 億次。用迴圈從小往大算,每個值只算一次。

費氏數列:重複計算

calls 是全域變數,記錄 fib 一共被呼叫幾次。播放時注意同一個參數(例如 n 是 2)的框架出現了好幾次。最後和迴圈版本比較:fibLoop 只跑 5 圈。這一段步數比較多,可以拖拉進度條快轉。

遞迴的應用:二分搜尋

在排好序的陣列裡找一個數,不必從頭一個一個看:先看中間那一個,目標比較小就只找左半邊,比較大就只找右半邊。 每次都把範圍砍掉一半,8 個元素最多看 4 次,100 萬個元素也只要看 20 次左右。

「在左半邊找」和「在右半邊找」都是同一個問題、只是範圍變小,所以很適合寫成遞迴:參數 low、high 代表目前的搜尋範圍, 範圍變成空的(low > high)就是找不到的終止條件。

遞迴二分搜尋

每一層框架的 low、high 代表這一層負責的範圍,範圍每次少一半。找到之後,答案一層層原封不動往上傳回 main。第二次找 10,範圍縮到空的,回傳 -1。

沒有終止條件:堆疊溢位

每一層遞迴呼叫都在呼叫堆疊上佔一塊空間,要等到函式回傳才會釋放。如果終止條件寫錯或忘了寫,呼叫會一直疊下去, 直到用光堆疊空間(Linux 上預設通常是 8 MB),程式當掉,這叫做堆疊溢位(stack overflow)。在 Linux 上通常會看到 Segmentation fault。

一個框架通常只有幾十個位元組,真正的程式要疊到幾十萬層才會溢位。為了讓你在幾步之內就看到結果,下面的範例在每一層放了一個約 1.2 MB 的陣列。 同樣的道理,遞迴層數很深時(例如對 10 萬個元素做逐一遞迴),即使終止條件正確也可能溢位,這時就改用迴圈。

堆疊溢位

dig 每一層都呼叫下一層,沒有任何條件會讓它停下來。右邊的框架一直變多,到第 8 層時堆疊空間不夠了。第 14 行永遠不會執行。

想一想:效率與應用

1.

下面的程式會印出什麼?

int calls = 0;

int fib(int n) {
    calls++;
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    fib(4);
    cout << calls;
}
2.

下面的程式會印出什麼?

int power(int b, int e) {
    if (e == 0) return 1;
    return b * power(b, e - 1);
}

int main() {
    cout << power(3, 4);
}
3.

關於遞迴與迴圈,下列哪一個說法正確?

第 1 次作答

動手寫:遞迴加總與次方

用遞迴完成兩個函式,不要用迴圈:

  • sumArray(arr, n):回傳陣列前 n 個元素的和。終止條件是 n 為 0;遞迴步驟是「前 n - 1 個的和」加上第 n 個元素 arr[n - 1]
  • power(b, e):回傳 b 的 e 次方,e 是 0 時回傳 1
你的輸出
預期輸出

重點回顧

  • 遞迴函式呼叫自己,一定要有終止條件(直接回答)和遞迴步驟(把問題變小,呼叫自己)
  • 每次呼叫都在呼叫堆疊上建立新的框架,各有自己的參數與區域變數;碰到終止條件後,各層依序回傳
  • 寫在遞迴呼叫之前的程式在往下時執行,寫在之後的程式在往回時執行,順序相反
  • 照定義寫的費氏數列會重複計算同樣的值,呼叫次數快速增加;改用迴圈從小往大算,每個值只算一次
  • 二分搜尋每次把範圍砍半,範圍變空就是找不到的終止條件
  • 少了終止條件,或層數太深,會用光堆疊空間,發生堆疊溢位
這一課完成了!
Next lesson:命名空間
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。