01

遞迴的本質與三大定律

遞迴就是把問題拆成更小的同類問題,直到小到可以直接解決。用 list 加總的例子帶出函式呼叫自己的觀念。

先讀原文開場,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 遞迴 Recursion 1 Objectives The goals for this chapter are as follows: • To understand that complex problems that may otherwise be difficult to solve may have a simple recursive solution. • To learn how to formulate programs recursively. • To understand and apply the three laws of recursion. • To understand recursion as a form of iteration.
白話導讀

遞迴就是把問題拆成更小的同類問題,直到小到可以直接解決。用 list 加總的例子帶出函式呼叫自己的觀念。

什麼是遞迴?從加總一串數字說起

遞迴就是把問題拆成更小的同類問題,直到小到可以直接解決。用 list 加總的例子帶出函式呼叫自己的觀念。

STEP 1

深度探秘

遞迴到底在說什麼

遞迴的核心一句話

遞迴 (recursion) 是一種解決問題的方法:把一個問題拆成越來越小的同類子問題,直到小到可以一眼看出答案(這個「夠小的問題」叫做 base case)。

通常做法是——讓一個函式呼叫它自己。聽起來像繞口令,但它能讓很多原本超難寫的程式變得超優雅。

用「加總一串數字」當例子

假裝你忽然不能用 forwhile 迴圈了,要怎麼算 [1, 3, 5, 7, 9] 的總和?

數學家會說:加法本來就是「兩個數字相加」。那我們就把整串想成一層層的括號:

(1 + (3 + (5 + (7 + 9))))

最內層 (7 + 9) 不用迴圈就能算。算完往外收:

$$ \begin{aligned} total &= (1 + (3 + (5 + (7 + 9)))) \ &= (1 + (3 + (5 + 16))) \ &= (1 + (3 + 21)) \ &= (1 + 24) = 25 \end{aligned} $$

寫成 Python

用文字描述:一串數字的總和 = 第一個數字 + 「剩下那串」的總和。

def list_sum(num_list):
    if len(num_list) == 1:      # base case:只剩一個就是答案
        return num_list[0]
    else:
        return num_list[0] + list_sum(num_list[1:])  # 呼叫自己

第 2 行是逃生門:list 長度為 1 時答案就是它本身。第 5 行函式呼叫自己,每次都丟一個更短的 list 進去。

💡
關鍵

遞迴 = 把問題拆成更小的同類問題,並讓函式呼叫自己,直到問題小到可以直接解決。

STEP 2

生活妙喻

俄羅斯娃娃與傳便當

比喻一:俄羅斯娃娃

打開一個俄羅斯娃娃,裡面是一個更小但長得一樣的娃娃;再打開,又是更小的同款娃娃……直到打開最小那顆——裡面是實心的,打不開了。那顆實心娃娃就是 base case

處理大娃娃的方法,就是「打開它,然後用同樣方法處理裡面那顆」。這正是遞迴。

比喻二:辦公室傳便當數量

你想知道一整排同事手上總共有幾個便當,但你只看得到前面那一位。於是你問前面的人:「你的便當數,加上你前面所有人的便當總數是多少?

他不知道前面的總數,於是他又問他前面的人同樣的問題……一路傳到最前面那位(base case)——他前面沒人了,所以他只回報自己手上的便當數。

接著答案像接力一樣一路往後加回來,最後傳回你手上。每個人做的事一模一樣,這就是遞迴的精神。

flowchart TD
    A[加總 1 3 5 7 9] --> B[1 加上 加總 3 5 7 9]
    B --> C[3 加上 加總 5 7 9]
    C --> D[5 加上 加總 7 9]
    D --> E[7 加上 加總 9]
    E --> F[只剩 9 直接回傳 9]
💡
關鍵

每一層都做同一件事,把更小的同款問題交給下一層,碰到最小的那顆就停。

STEP 3

實用超能力

什麼時候該想到遞迴

看到這些徵兆,就想想遞迴

  • 問題可以用「它自己的縮小版」來定義(總和、階乘、走訪樹狀資料……)。
  • 有一個明顯最簡單、可以直接回答的小狀況(空 list、單一元素、數字 0)。

動手追一遍

list_sum([1, 3, 5, 7, 9]) 想像呼叫展開:

list_sum([1,3,5,7,9])
= 1 + list_sum([3,5,7,9])
= 1 + (3 + list_sum([5,7,9]))
= 1 + (3 + (5 + list_sum([7,9])))
= 1 + (3 + (5 + (7 + list_sum([9]))))   ← 碰到 base case,回傳 9

碰到 base case 後,答案再一層層收回來,最後得到 25。

自我檢測

計算 [2, 4, 6, 8, 10] 的總和會做幾次「呼叫自己」(遞迴呼叫)?

提示:5 個元素,最外層那次不算遞迴呼叫,從第 2 層算起到 base case 共 4 次

💡
關鍵

當問題能用自己的縮小版描述、又存在一個可直接解的最小狀況時,就是遞迴登場的時機。

🔆
生活妙喻:遞迴 ≈ 俄羅斯娃娃

每打開一顆,裡面是更小的同款娃娃,直到最小那顆打不開——那就是 base case,停止點。

🔆
生活妙喻:遞迴呼叫接力回傳 ≈ 一排同事互相轉問便當數

問題一路往前傳,到最前面的人回報後,答案再一層層往後加回來。

本節字彙

recursion(遞迴)
把問題拆成越來越小的同類子問題、並讓函式呼叫自己來解決的方法。
🧠 「自己呼叫自己」——像照鏡子裡又有一面鏡子。
base case(基本情況)
小到可以直接給出答案、不需要再呼叫自己的情況,是遞迴的停止點。
🧠 base = 地基/底,是遞迴往下挖到的最底層。
accumulator(累加器)
迴圈版本中用來邊跑邊累積結果的變數,例如 the_sum。
🧠 accumulate = 累積,邊走邊撿錢包。
下面哪一句話最準確描述「遞迴」的本質?
在 list_sum 的遞迴版本中,`if len(num_list) == 1: return num_list[0]` 這段扮演什麼角色?
計算 `[2, 4, 6, 8, 10]`(5 個元素)的總和時,list_sum 會做幾次遞迴呼叫(呼叫自己)?

遞迴三大定律

像艾西莫夫的機器人三定律一樣,所有遞迴都必須有 base case、要朝 base case 改變狀態、且要呼叫自己。

STEP 1

深度探秘

所有遞迴都要遵守的三條鐵律

像機器人三定律一樣

作者打趣說:就像艾西莫夫筆下的機器人有三大定律,所有遞迴演算法也必須遵守三條鐵律

  1. 必須有 base case(基本情況) —— 一個可以直接解決、不再呼叫自己的最小狀況。
  2. 必須改變狀態並朝 base case 前進 —— 每次呼叫,資料都要往「更接近 base case」的方向變。
  3. 必須呼叫自己 —— 這正是遞迴的定義。

對照 list_sum

def list_sum(num_list):
    if len(num_list) == 1:                 # 定律 1:base case
        return num_list[0]
    else:
        return num_list[0] + list_sum(num_list[1:])  # 定律 2 + 3
  • 定律 1:list 長度為 1 是 base case。
  • 定律 2num_list[1:] 把 list 變短了一格,朝「長度為 1」邁進。
  • 定律 3:函式呼叫了自己。

少了哪一條會出事?

少了 base case 或忘了朝它前進,遞迴就會無限往下呼叫,最終把呼叫堆疊撐爆(Python 會丟出 RecursionError)。

💡
關鍵

遞迴三定律:要有 base case、每次要朝它前進、且要呼叫自己——三者缺一不可。

STEP 2

生活妙喻

下樓梯找出口

把遞迴想成下樓梯

你被放在一棟樓的某層,要往下走到一樓出口

  • base case:到了一樓,看到出口——停!(沒有 base case,就像樓梯沒有盡頭,你會永遠往下走。)
  • 朝 base case 前進:每次都往下走一層,而不是上下亂跳。如果你一下下樓一下上樓,永遠到不了一樓。
  • 呼叫自己:在每一層,你做的事情都一樣——「再往下走一層,重複同樣流程」。

三定律的關係圖

flowchart TD
    A[進入遞迴函式] --> B{是否到達 base case}
    B -- 是 --> C[直接回傳答案 停止]
    B -- 否 --> D[改變狀態 讓資料更接近 base case]
    D --> E[呼叫自己處理更小的問題]
    E --> B

少了「往下走」這個動作(定律 2),就算有一樓出口(定律 1)你也永遠到不了。

💡
關鍵

有終點(base case)還不夠,每一步都要真的朝終點靠近,否則永遠走不到。

STEP 3

實用超能力

用三定律設計階乘

練習:寫一個階乘 fact(n)

階乘定義:$n! = n \times (n-1) \times (n-2) \times \dots \times 1$,而且 $0! = 1$。

按三定律一步步來:

  1. base case:題目說 $0! = 1$,所以 n == 0 時直接回傳 1。
  2. 朝 base case 前進:每次把 n 變成 n-1,越來越接近 0。
  3. 呼叫自己fact(n) = n * fact(n-1)
def fact(n):
    if n == 0:        # 定律 1
        return 1
    return n * fact(n - 1)   # 定律 2 + 3

為什麼 base case 是 n == 0 最恰當?

因為定義裡明確給了 $0! = 1$。若寫成 n == 1,那 fact(0) 會錯過 base case 一路往負數遞迴,停不下來。選 n == 0 才能涵蓋所有非負整數。

設計任何遞迴前,先問自己這三句:最小狀況是什麼?每次怎麼縮小?縮小後怎麼呼叫自己?

💡
關鍵

設計遞迴的固定流程:先定 base case,再決定如何朝它縮小,最後寫出呼叫自己的式子。

🔆
生活妙喻:三大定律 ≈ 下樓梯找一樓出口

一樓出口是 base case,每次往下一層是朝它前進,每層重複同樣動作是呼叫自己。

🔆
生活妙喻:缺少朝 base case 前進 ≈ 在樓梯間上下亂跳

就算有出口,若不固定往下走,永遠到不了,對應無限遞迴。

本節字彙

base case(基本情況)
遞迴的停止條件,小到可以直接給答案而不再呼叫自己。
🧠 沒有它遞迴就像沒有盡頭的樓梯。
change of state(狀態改變)
每次遞迴呼叫時,讓代表問題的資料朝 base case 變化(通常變小)。
🧠 每一步都要『真的往前一格』。
RecursionError
Python 在遞迴呼叫過深、堆疊被撐爆時丟出的錯誤,常因缺 base case 或沒朝它前進。
🧠 停不下來,電腦先喊累。
下列哪一項「不是」遞迴三大定律之一?
某人寫的遞迴函式有 base case,也會呼叫自己,但每次都用「同一份」沒變小的資料去呼叫。會發生什麼事?
要寫 `fact(n)` 計算階乘且定義 $0! = 1$,最恰當的 base case 是哪一個?

整數轉任意進位字串

用整數除法與餘數,把一個數字遞迴拆成一位一位的數字,再組成字串,體會遞迴呼叫順序如何決定結果順序。

STEP 1

深度探秘

把數字一位一位拆出來

目標

把一個整數轉成某個進位(2 到 16 進位)的字串。例如 10 轉十進位是 "10"、轉二進位是 "1010"

三個組成部分

  1. 把原本的數字拆成一連串個位數
  2. 用查表把單一位數轉成字元。
  3. 把這些字元串接成最終字串。

查表很簡單:準備 convert_string = "0123456789ABCDEF",要第 9 位就取 convert_string[9],得到 "9"小於 base 的數字就是我們的 base case。

用整數除法與餘數來縮小

以 769 轉十進位為例,用整數除法除以 base:

  • $769 \div 10 = 76$ 餘 $9$ → 餘數 9 就是最低位
  • $76 \div 10 = 7$ 餘 $6$
  • $7 < 10$ → 碰到 base case

餘數給我們要記住的位數,則是更小的數字,朝 base case 前進。

def to_str(n, base):
    convert_string = "0123456789ABCDEF"
    if n < base:                      # base case
        return convert_string[n]
    else:
        return to_str(n // base, base) + convert_string[n % base]

print(to_str(1453, 16))   # 5AD
💡
關鍵

用「除以 base 取餘數」可把整數逐位拆開;小於 base 的數就是可直接查表的 base case。

STEP 2

生活妙喻

找零錢與排隊順序

比喻:兌換零錢

想像你要把一大筆錢換成某種面額。每次你「除以面額」,餘數就是這一輪剛好換不滿一枚的零頭(要記下來),是還沒換的部分(拿去下一輪繼續換)。一直換到剩下不滿一枚為止——那就是 base case。

關鍵:順序為什麼是對的?

餘數其實是從最低位先算出來的。如果你算出餘數就馬上接到字串前面,順序會顛倒!

程式聰明的地方在於:它先呼叫自己(處理更高位),等遞迴回傳後才接上這一位的餘數。這樣高位的字串會先排好,低位才接在後面,順序自然正確。

這正像疊盤子:先放進去的最後才拿出來。它呼應了堆疊的後進先出。

flowchart TD
    A[轉換 10 到 二進位] --> B[先遞迴 轉換 5]
    B --> C[先遞迴 轉換 2]
    C --> D[先遞迴 轉換 1]
    D --> E[1 小於 2 回傳 字元 1]
    E --> F[回到 2 接上餘數 0 得 10]
    F --> G[回到 5 接上餘數 1 得 101]
    G --> H[回到 10 接上餘數 0 得 1010]
💡
關鍵

先遞迴處理高位、回傳後才接上低位餘數,字串順序才會正確——這就是堆疊後進先出的影子。

STEP 3

實用超能力

親手追 10 轉二進位

一步步追 to_str(10, 2)

to_str(10, 2)
  10 >= 2 → to_str(5, 2) + convert_string[10 % 2]   = ... + "0"
    to_str(5, 2)
      5 >= 2 → to_str(2, 2) + convert_string[5 % 2]   = ... + "1"
        to_str(2, 2)
          2 >= 2 → to_str(1, 2) + convert_string[2 % 2] = ... + "0"
            to_str(1, 2)
              1 < 2 → 回傳 "1"           ← base case

收回來:"1""1"+"0"="10""10"+"1"="101""101"+"0"="1010"

答案 "1010",順序完全正確!

為什麼一定要用整數除法 // 而不是 /

在 Python 3,/ 會得到浮點數(如 10/2 = 5.0),拿去當索引或繼續除會出問題。整數除法 // 才能乾淨地得到「商」。

小結守則

凡是「逐位處理」又怕「順序顛倒」的問題,記得:把要接到後面的東西,延後到遞迴回傳之後再接

💡
關鍵

用 // 取商、% 取餘,並把餘數延後到遞迴回傳後才串接,就能逐位轉換又保持正確順序。

🔆
生活妙喻:除以 base 取餘數 ≈ 兌換零錢

餘數是換不滿一枚的零頭(要記下的位數),商是剩下待換的部分(下一輪繼續處理)。

🔆
生活妙喻:延後串接以保持順序 ≈ 疊盤子後進先出

先算出的低位若馬上接會顛倒,先遞迴處理高位、回傳後才接低位,順序才對。

本節字彙

integer division(整數除法 //)
只取商、捨去小數的除法,例如 769 // 10 = 76。
🧠 兩條斜線 // = 只要整數的商。
modulo(取餘數 %)
回傳除法的餘數,例如 769 % 10 = 9,常用來取得最低位。
🧠 % 像被切下來的零頭。
lookup table(查表)
用索引從一個序列直接取出對應字元,如 convert_string[n]。
🧠 對號入座,索引一查就到。
在 `to_str(n, base)` 中,base case 的判斷是哪一個?
把 `to_str(n // base, base) + convert_string[n % base]` 改成 `convert_string[n % base] + to_str(n // base, base)`,結果會怎樣?
用 `to_str(769, 10)` 追蹤,第一次遞迴會把哪個數字傳下去、記住哪個餘數?
02

遞迴在電腦裡是怎麼跑的

把進位轉換改成用一個自己建立的堆疊,看到遞迴與堆疊之間的對應關係。

用顯式堆疊取代遞迴

把進位轉換改成用一個自己建立的堆疊,看到遞迴與堆疊之間的對應關係。

STEP 1

深度探秘

把遞迴改寫成迴圈加堆疊

想法

上一節的進位轉換靠遞迴「先呼叫自己、回傳後才接字元」來保持順序。如果我們不用遞迴,而是自己準備一個堆疊呢?

做法:每算出一個位數的字元,就 push 進堆疊;全部算完後,再一個個 pop 出來串接。因為堆疊是後進先出,最後 push 的高位會最先 pop 出來,順序剛好正確。

from pythonds.basic import Stack

r_stack = Stack()

def to_str(n, base):
    convert_string = "0123456789ABCDEF"
    while n > 0:
        if n < base:
            r_stack.push(convert_string[n])
        else:
            r_stack.push(convert_string[n % base])
        n = n // base
    res = ""
    while not r_stack.is_empty():
        res = res + str(r_stack.pop())
    return res

print(to_str(1453, 16))   # 5AD

觀念重點

這段程式用 while 迴圈取代了遞迴呼叫,用顯式(自己建立)的堆疊取代了遞迴自動使用的呼叫堆疊。結果完全一樣,這透露了一件大事:遞迴和堆疊是一體兩面

💡
關鍵

遞迴中「延後處理、回傳後才用」的順序,本質上就是堆疊的後進先出;許多遞迴都能改寫成迴圈加顯式堆疊。

STEP 2

生活妙喻

洗碗疊盤子

比喻:疊盤子

你邊洗碗邊把洗好的盤子往上疊(push)。要收進櫃子時,你只能從最上面那個拿起(pop),所以最後洗好的最先被收

進位轉換時,最低位最先算出來(最先 push 到堆疊底),最高位最後算出來(push 在最上面)。收的時候從上往下拿,於是高位先出、低位後出——剛好排成正確的閱讀順序。

遞迴版 vs 顯式堆疊版

比較項目 遞迴版 顯式堆疊版
誰在記住中間結果 系統的呼叫堆疊(自動) 你自己建的 Stack(手動)
控制流程 函式呼叫自己 while 迴圈
順序怎麼對的 延後到回傳後才串接 pop 的後進先出
結果 一樣 一樣
flowchart TD
    A[逐位算出餘數字元] --> B[每個字元 push 進堆疊]
    B --> C{數字是否還大於 0}
    C -- 是 --> A
    C -- 否 --> D[依序 pop 出來串接]
    D --> E[得到正確順序的字串]
💡
關鍵

顯式堆疊就是把系統偷偷幫你做的事攤在陽光下:用 push/pop 親手管理那些被延後的中間結果。

STEP 3

實用超能力

什麼時候改用顯式堆疊

為什麼要知道這件事?

  1. 理解遞迴:當你卡在「遞迴怎麼會記得回來要做什麼」時,想像背後有個堆疊在幫你存進度,一切就通了。
  2. 避免遞迴過深:有些語言(或很深的遞迴)容易把呼叫堆疊撐爆。改成顯式堆疊用迴圈跑,就能繞過深度限制。
  3. 更好掌控:顯式堆疊讓你能隨時檢查、印出堆疊內容,除錯更直覺。

動手對照 to_str(10, 2)

顯式堆疊版逐步 push:

n=10 → push 0,n=5
n=5  → push 1,n=2
n=2  → push 0,n=1
n=1  → push 1,n=0  (迴圈結束)
堆疊(由底到頂):0, 1, 0, 1
pop 串接:1, 0, 1, 0 → "1010"

和遞迴版的答案 "1010" 完全一致。

記住這句話:遞迴用的是系統的堆疊;顯式堆疊只是把它搬到你自己手上。

💡
關鍵

把遞迴改寫成顯式堆疊,能避開遞迴深度限制、更易除錯,也讓你真正看懂遞迴背後的堆疊機制。

🔆
生活妙喻:顯式堆疊取代遞迴 ≈ 洗碗疊盤子再從頂端收

最後洗好的盤子疊在最上、最先被收;對應最高位最後 push、最先 pop,順序就正確。

🔆
生活妙喻:系統呼叫堆疊 ≈ 餐廳廚房自動幫你記住的待出菜單

遞迴時系統自動用堆疊記住每層進度,顯式堆疊只是把這份『記事本』拿到自己手上。

本節字彙

explicit stack(顯式堆疊)
由程式設計師自己建立並用 push/pop 管理的堆疊,相對於系統自動的呼叫堆疊。
🧠 explicit = 攤在明面上,自己動手。
push / pop
堆疊的兩個基本操作:push 把元素放到頂端,pop 從頂端取出。
🧠 推上去、彈出來。
LIFO(後進先出)
Last In First Out,最後放入的元素最先被取出,是堆疊的核心性質。
🧠 最後進場的人最先離開。
把進位轉換從遞迴改寫成顯式堆疊版時,迴圈裡每算出一個位數字元就做什麼?
顯式堆疊版能得到正確順序,靠的是堆疊的哪個性質?
這一節最重要的觀念啟示是什麼?

堆疊框架:呼叫堆疊的真相

每次呼叫函式,系統就配置一個 stack frame 存區域變數;回傳值留在堆疊頂給呼叫者用。這就是遞迴能運作的底層機制。

STEP 1

深度探秘

每次呼叫函式都配一個 stack frame

函式呼叫時電腦做了什麼

當 Python 呼叫一個函式時,系統會配置一個 stack frame(堆疊框架) 來存放這次呼叫的區域變數。當函式回傳時,回傳值會被留在堆疊頂端,供呼叫它的那一方取用。

這就是遞迴運作的祕密

to_str(10, 2) 為例,回傳前的呼叫堆疊大致長這樣(最上面是最新的呼叫):

[ to_str(1, 2)   區域變數 n=1, base=2 ]   ← 最內層,回傳 "1"
[ to_str(2, 2)   區域變數 n=2, base=2 ]
[ to_str(5, 2)   區域變數 n=5, base=2 ]
[ to_str(10, 2)  區域變數 n=10, base=2 ]  ← 最外層

to_str(1, 2) 回傳 "1",這個回傳值就頂替了 to_str(1, 2) 這個呼叫的位置,被用在 "1" + convert_string[2 % 2] 算出 "10",再往外傳……

回傳值取代了累加器

在 list 加總的例子裡,你可以把「留在堆疊上的回傳值」想成迴圈版本裡那個累加器變數——只是這次是系統的呼叫堆疊在幫你累積。

💡
關鍵

每次函式呼叫都配置一個 stack frame 存區域變數;回傳值留在堆疊頂供上層使用,這正是遞迴能記住進度的底層機制。

STEP 2

生活妙喻

一疊待辦便利貼

比喻:暫停手邊工作去處理新任務

你正在算 to_str(10, 2),算到一半需要先知道 to_str(5, 2) 的答案。於是你拿一張便利貼寫下「我做到哪、手上的 n 是多少」,貼到桌上那疊的最上面,然後先去處理 5 的問題。

處理 5 時又需要 2 的答案,再貼一張……便利貼越疊越高。直到碰到 base case(1),你終於有了一個確定的答案,於是撕掉最上面那張便利貼,回到上一個被打斷的工作,把剛拿到的答案填進去繼續算。

這疊便利貼就是呼叫堆疊,每張便利貼就是一個 stack frame

每層有自己的變數範圍

重要的是:雖然一直呼叫同一個函式,但每次呼叫都有自己獨立的便利貼,上面的 n 互不干擾。所以 to_str(10,2)n=10to_str(5,2)n=5 不會打架。

flowchart TD
    A[呼叫 to_str 10] --> B[暫停 push frame n=10]
    B --> C[呼叫 to_str 5 push frame n=5]
    C --> D[呼叫 to_str 2 push frame n=2]
    D --> E[呼叫 to_str 1 push frame n=1]
    E --> F[base case 回傳 字串 1 pop frame]
    F --> G[回到 n=2 用回傳值繼續 pop frame]
    G --> H[回到 n=5 再回到 n=10 完成]
💡
關鍵

呼叫堆疊像一疊便利貼,每層呼叫一張,記住自己的進度與獨立變數;碰到 base case 才開始一張張撕回去。

STEP 3

實用超能力

看懂堆疊就能寫好遞迴

為什麼這個畫面這麼重要

書中說:只要把這個堆疊的畫面記在腦中,你就會發現寫正確的遞迴函式容易多了。

當你寫遞迴卡住時,問自己:

  1. 每次呼叫,系統會幫我把目前的變數存到一個新 frame——所以不必擔心變數被覆蓋
  2. 回傳值會自動回到上一層——所以我**只要寫好「這一層該做什麼、要回傳什麼」**即可。
  3. base case 一回傳,撕便利貼的過程就會自動接力把答案組合回去。

與 RecursionError 的關聯

便利貼疊太高,桌子(記憶體)會垮——這就是遞迴過深時的 RecursionError。每一層 frame 都要佔空間,所以遞迴的「深度」會耗用記憶體。

一個實用心法

寫遞迴時,只專注在單一一層:假設「處理更小問題的那次呼叫已經給了我正確答案」,我只要用它把這一層做完。其餘的交給呼叫堆疊。

💡
關鍵

寫遞迴時只要顧好單一層該回傳什麼,剩下的進度保存與接力組合都交給呼叫堆疊自動完成。

🔆
生活妙喻:stack frame ≈ 暫停工作時貼的一張便利貼

每次呼叫貼一張,記下這層的變數與進度,疊成一整疊就是呼叫堆疊。

🔆
生活妙喻:獨立的變數範圍 ≈ 每張便利貼各寫各的 n

同一函式被呼叫多次,但每層的區域變數寫在自己的便利貼上,互不干擾。

本節字彙

stack frame(堆疊框架)
函式被呼叫時系統配置的一塊空間,存放該次呼叫的區域變數與回傳資訊。
🧠 一次呼叫 = 一張便利貼 = 一個 frame。
call stack(呼叫堆疊)
系統用來追蹤所有進行中函式呼叫的堆疊,遞迴就是靠它記住每一層。
🧠 一疊便利貼,後貼的先撕。
scope(變數範圍)
變數有效的範圍;每個 stack frame 為其區域變數提供獨立的 scope。
🧠 各層各掃各的門前雪。
當 Python 呼叫一個函式時,系統配置 stack frame 主要用來做什麼?
為什麼同一個遞迴函式被呼叫很多次,各層的區域變數 `n` 不會互相覆蓋?
在 list 加總的遞迴中,「留在呼叫堆疊上的回傳值」可以類比成迭代版本裡的什麼?
03

看得見的遞迴:碎形與自相似

用海龜繪圖畫遞迴螺旋,再把「樹是樹幹加上左右兩棵小樹」的想法寫成碎形樹。

海龜畫螺旋與碎形樹

用海龜繪圖畫遞迴螺旋,再把「樹是樹幹加上左右兩棵小樹」的想法寫成碎形樹。

STEP 1

深度探秘

用畫圖看見遞迴的展開

為什麼要用畫圖學遞迴

遞迴最難的不是寫,而是在腦中想像它怎麼跑。Python 內建的 turtle(海龜繪圖) 模組可以讓遞迴「畫出來」,親眼看著圖形成形,遞迴的過程就突然變得直覺。

海龜很單純:可以前進、後退、左轉、右轉;尾巴放下時移動會畫線。

遞迴畫螺旋

import turtle

my_turtle = turtle.Turtle()
my_win = turtle.Screen()

def draw_spiral(my_turtle, line_len):
    if line_len > 0:                 # base case:長度 <= 0 就停
        my_turtle.forward(line_len)
        my_turtle.right(90)
        draw_spiral(my_turtle, line_len - 5)   # 用更短的長度呼叫自己

draw_spiral(my_turtle, 100)
my_win.exitonclick()
  • base caseline_len <= 0 就停止。
  • 朝 base case 前進:每次長度減 5。
  • 呼叫自己:用縮短後的長度再畫一段。

碎形 fractal 是什麼

碎形指的是:不管放大多少倍,它都長得一樣。海岸線、雪花、山脈、樹枝都有這種「自相似」特性,天生適合用遞迴描述。

💡
關鍵

海龜繪圖讓遞迴看得見;碎形的自相似(放大後長得一樣)正好對應遞迴的自我呼叫結構。

STEP 2

生活妙喻

一棵樹是樹幹加兩棵小樹

用碎形語言描述一棵樹

如果碎形是「各種放大倍率下都一樣」,那對樹來說:一根小樹枝,長得就像一整棵樹的縮小版

於是我們可以這樣定義樹:

一棵樹 = 一段樹幹,加上往右長的一棵更小的樹,再加上往左長的一棵更小的樹

這個定義裡又出現了「樹」——典型的遞迴定義!

def tree(branch_len, t):
    if branch_len > 5:          # base case:枝太短就停
        t.forward(branch_len)
        t.right(20)
        tree(branch_len - 15, t)   # 右邊的小樹
        t.left(40)
        tree(branch_len - 15, t)   # 左邊的小樹
        t.right(20)
        t.backward(branch_len)

為什麼右轉 20 又左轉 40

畫完右邊小樹後,海龜需要先抵銷剛剛右轉的 20 度,再額外左轉 20 度去畫左樹——加起來剛好左轉 40 度。最後再 right(20)、backward 退回原點,把海龜還原,好讓上一層繼續。

flowchart TD
    A[畫樹幹 前進 branch_len] --> B[右轉 20 度]
    B --> C[遞迴畫右邊更小的樹]
    C --> D[左轉 40 度]
    D --> E[遞迴畫左邊更小的樹]
    E --> F[右轉 20 度 退回原點]
💡
關鍵

把樹定義成「樹幹加左右兩棵更小的樹」,就是一個遞迴定義;畫完一棵小樹要記得把海龜還原給上一層用。

STEP 3

實用超能力

預測畫圖順序與動手改造

樹是怎麼長出來的?

很多人以為樹會左右對稱地同時長出來,其實不是。因為右邊的遞迴呼叫寫在前面,程式會一路往右邊最深的小枝畫到底,再沿著樹幹往回,把整個右半邊畫完,之後才開始畫左半邊

這正是遞迴「深度優先」的展開順序——先衝到最深,再一層層回來。

動手改造(書中練習)

試著修改 tree 讓它更像真樹:

  • 枝越短,線畫越細(依 branch_len 調 pensize)。
  • 枝很短時把顏色改成葉子的綠色
  • 每個分岔的轉角隨機取 15 到 45 度之間,自然界沒那麼對稱。
  • 每次縮短的量也隨機,而非固定減 15。

帶走的觀念

凡是「整體由縮小版的自己組成」的圖形或結構(樹、海岸線、雪花),用遞迴描述往往最自然、最簡潔。

💡
關鍵

遞迴呼叫的書寫順序決定繪製順序(先右畫到底再畫左);自相似的圖形用遞迴描述最自然,還能靠隨機化讓它更逼真。

🔆
生活妙喻:碎形自相似 ≈ 一根小樹枝長得像整棵樹

放大任何一小部分都跟整體相似,這種結構天生對應遞迴的自我呼叫。

🔆
生活妙喻:遞迴的深度優先繪製 ≈ 先沿著右邊一路爬到最高的小枝再下來

因為右遞迴寫在前面,程式先衝到右側最深處,回來才畫左側。

本節字彙

turtle graphics(海龜繪圖)
Python 內建的繪圖模組,用一隻會前進轉彎的海龜畫線,常用來視覺化演算法。
🧠 一隻海龜拖著筆走,走到哪畫到哪。
fractal(碎形)
在任何放大倍率下都呈現相同基本形狀的圖形,具自相似性。
🧠 放大再放大,還是同一個樣子。
self-similarity(自相似)
圖形的局部與整體形狀相似的性質,是碎形的核心,也是遞迴的精神。
🧠 部分像整體,整體像部分。
在 `draw_spiral(my_turtle, line_len)` 中,base case 是什麼?
為什麼說「樹」很適合用遞迴來描述?
在碎形樹程式中,畫完右樹後為何要 `t.left(40)`?

謝爾賓斯基三角形:三路遞迴

一個三岔遞迴的經典範例,用 degree 當作 base case,理解多重遞迴呼叫的繪製順序。

STEP 1

深度探秘

一個三岔的遞迴

手繪規則

謝爾賓斯基三角形 (Sierpinski triangle) 是另一個經典碎形。手畫規則很簡單:

  1. 先畫一個大三角形。
  2. 連接三邊的中點,把它切成四個小三角形。
  3. 忽略中間那個,對其餘三個角落的三角形,重複同樣的程序

你可以一直分下去——只要鉛筆夠尖。

base case:用 degree 控制

既然可以無限分下去,那 base case 是什麼?答案是:我們人為設定要分幾層,這個數字叫做碎形的 degree(階數)。每次遞迴把 degree 減 1,減到 0 就停止

三路遞迴

def sierpinski(points, degree, my_turtle):
    color_map = ['blue', 'red', 'green', 'white',
                 'yellow', 'violet', 'orange']
    draw_triangle(points, color_map[degree], my_turtle)
    if degree > 0:
        sierpinski([points[0], get_mid(points[0], points[1]),
                    get_mid(points[0], points[2])], degree - 1, my_turtle)
        sierpinski([points[1], get_mid(points[0], points[1]),
                    get_mid(points[1], points[2])], degree - 1, my_turtle)
        sierpinski([points[2], get_mid(points[2], points[1]),
                    get_mid(points[0], points[2])], degree - 1, my_turtle)

注意這裡有三個遞迴呼叫,分別處理三個角落的小三角形。get_mid 算兩端點的中點。

💡
關鍵

謝爾賓斯基三角形把一個三角形分成三個角落子三角形各自遞迴,用 degree 遞減到 0 當作 base case。

STEP 2

生活妙喻

分形印章與『畫幾層就停』

比喻:會自我複製的印章

想像有個神奇印章,蓋一下會在原三角形的三個角各印出一個縮小一半的同款三角形(中間留白)。如果讓這些小三角形也各自蓋章……會無窮無盡。

所以我們事先約定:「只蓋 degree 層就收手」。degree=0 時印章失效,這就是 base case。

三路遞迴的繪製順序

假設角落順序是左下、上、右下。因為三個遞迴呼叫依序執行,程式會:

  1. 先一路鑽到左下角最小的三角形,再回來補滿左下區。
  2. 接著處理上方,鑽到最頂端最小三角形再補滿。
  3. 最後處理右下

這又是深度優先:總是先往第一個呼叫鑽到底。

flowchart TD
    A[畫目前三角形 用 degree 對應顏色] --> B{degree 是否大於 0}
    B -- 否 --> C[停止 此層結束]
    B -- 是 --> D[遞迴左下角子三角形 degree 減 1]
    D --> E[遞迴上方子三角形 degree 減 1]
    E --> F[遞迴右下角子三角形 degree 減 1]

為什麼每層顏色不同

程式用 color_map[degree] 取色,所以每一個 degree(每一層)會用不同顏色填滿,讓你一眼看出遞迴的層次。

💡
關鍵

三路遞迴依序處理三個角落、深度優先鑽到底再回補;用 degree 當索引取色讓每一層的遞迴深度都看得見。

STEP 3

實用超能力

多重遞迴的思考方式

一個函式裡有多個遞迴呼叫,怎麼想?

別試著在腦中同時追三條線!沿用上一節的心法:假設每個子呼叫都會正確畫好它那塊,你只要確保:

  1. 先畫好這一層的大三角形。
  2. 把三個角落的座標算對,分別丟給三次遞迴。
  3. degree 有遞減(朝 base case 前進)、degree=0 有停(base case)。

其餘交給呼叫堆疊。

為什麼忽略中間那塊

中間的三角形不再遞迴,正是謝爾賓斯基三角形「中空」外觀的來源——遞迴只發生在三個角落

get_mid 的角色

def get_mid(p1, p2):
    return ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)

它回傳兩端點的中點座標,是把大三角形切成小三角形的關鍵小工具。

帶走的觀念

多重遞迴(一層裡呼叫自己很多次)會讓圖形或計算量呈爆炸式成長。每加一層 degree,三角形數量大約變三倍。

💡
關鍵

面對多重遞迴,只要顧好這一層並相信每個子呼叫會做對;忽略中間三角形造就中空外觀,而每加一層數量約成三倍成長。

🔆
生活妙喻:謝爾賓斯基三角形 ≈ 會在三個角自我複製的印章

每蓋一次就在三個角各印一個縮小的同款三角形,約定只蓋 degree 層就停。

🔆
生活妙喻:degree 當 base case ≈ 事先說好『只畫幾層就收手』

因為可以無限細分,所以人為設定階數,遞減到 0 即停止。

本節字彙

Sierpinski triangle(謝爾賓斯基三角形)
把三角形不斷分成三個角落子三角形(忽略中間)所形成的中空碎形。
🧠 三角裡有三角,中間總是空的。
degree(階數)
人為設定的遞迴層數,每次遞迴減 1,到 0 即為 base case。
🧠 約好分幾層,數到 0 收工。
three-way recursion(三路遞迴)
在一次呼叫中對自己進行三次遞迴呼叫,分別處理三個子問題。
🧠 一變三,三變九,數量爆炸。
謝爾賓斯基三角形演算法的 base case 是什麼?
為什麼謝爾賓斯基三角形被稱為「三路遞迴」?
為什麼最終圖形中間會是「空的」?
04

遞迴的真本事:困難問題與迷宮

把搬 n 個盤子的難題,遞迴拆成搬 n-1 個盤子的子問題,用幾行程式碼解開古老傳說。

河內塔 Tower of Hanoi

把搬 n 個盤子的難題,遞迴拆成搬 n-1 個盤子的子問題,用幾行程式碼解開古老傳說。

STEP 1

深度探秘

古老傳說裡的遞迴難題

謎題規則

河內塔由法國數學家 Lucas 在 1883 年發明。有三根柱子和一疊由大到小的金盤。目標:把所有盤子從一根柱子搬到另一根,但有兩個限制:

  1. 一次只能搬一個盤子
  2. 大盤子絕不能疊在小盤子上面

傳說有 64 個盤子,僧侶以每秒一步的速度搬完世界就會毀滅。別擔心——搬完 64 個盤子需要 $2^{64} - 1 = 18{,}446{,}744{,}073{,}709{,}551{,}615$ 步,約 5849 億年!

從上往下想

假設要搬 5 個盤子。如果我已經會把上面 4 個搬到中繼柱,那我只要:把最底下那個大盤搬到目標柱,再把那疊 4 個搬過去就好。

但我不會搬 4 個怎麼辦?——那就假設我會搬 3 個……一直問下去,直到搬 1 個盤子——這太簡單了!這就是 base case。

高層次步驟

把高度為 height 的塔,從起點柱搬到目標柱,借助中繼柱:

  1. 把上面 height-1 個盤子搬到中繼柱(借助目標柱)。
  2. 把剩下那一個(最大)盤子搬到目標柱
  3. 把中繼柱上的 height-1 個盤子搬到目標柱(借助起點柱)。
💡
關鍵

搬 n 個盤子 = 先搬 n-1 個到中繼柱、搬最大盤到目標、再把 n-1 個搬到目標;base case 是搬 1 個盤子。

STEP 2

生活妙喻

搬家時的『先騰出空間』

比喻:搬一疊書

你想把書架最下層那本超大的書搬走,但上面壓著一疊書。你不會一本本煩惱——你會:先把上面那疊書整疊暫放到旁邊的椅子,搬走大書,再把那疊書放回原位

「把上面那疊搬到別處」這件事本身,又是同一個問題的縮小版(少一本)。這就是河內塔的遞迴。

程式碼

def move_tower(height, from_pole, to_pole, with_pole):
    if height >= 1:
        move_tower(height - 1, from_pole, with_pole, to_pole)  # 步驟 1
        move_disk(from_pole, to_pole)                          # 步驟 2
        move_tower(height - 1, with_pole, to_pole, from_pole)  # 步驟 3

def move_disk(fp, tp):
    print("moving disk from", fp, "to", tp)

注意程式碼幾乎跟英文描述一字不差!關鍵在兩個遞迴呼叫:第一個把上層搬去中繼柱,中間搬最大盤,第二個把上層從中繼柱搬到目標柱。

flowchart TD
    A[搬高度 height 從 起點 到 目標] --> B[遞迴 搬 height 減 1 到 中繼柱]
    B --> C[搬最大盤 從 起點 到 目標]
    C --> D[遞迴 搬 height 減 1 從 中繼柱 到 目標]

base case 的巧妙

當 height 為 0 時,if height >= 1 不成立,函式直接返回什麼都不做。正是這個『什麼都不做就返回』,才讓上一層的 move_disk 得以被執行

💡
關鍵

搬最大盤前,先把上面那疊整疊挪走(同款的更小問題),搬完再挪回來;height 為 0 直接返回就是停止點。

STEP 3

實用超能力

為何不用資料結構追蹤盤子

一個讓人驚訝的問題

看完程式你可能會問:為什麼沒有任何資料結構記錄哪根柱子上有哪些盤子?

如果要手動追蹤,你大概會用三個 Stack(每根柱子一個)。但答案是:Python 透過呼叫堆疊隱式地幫我們提供了所需的堆疊

每一層遞迴的參數(from、to、with 是哪根柱子)都被存在各自的 stack frame 裡,呼叫堆疊自動幫我們記住「現在這層該從哪搬到哪」。我們不必自己管理盤子位置——遞迴的堆疊本質替我們搞定了。

追蹤三個盤子

move_tower(3, "A", "B", "C") 會印出(A 起點、B 目標、C 中繼):

moving disk from A to B
moving disk from A to C
moving disk from B to C
moving disk from A to B
moving disk from C to A
moving disk from C to B
moving disk from A to B

剛好 $2^3 - 1 = 7$ 步,最少步數!

帶走的觀念

河內塔展示了遞迴最迷人的一面:程式碼幾乎就是問題本身的直接翻譯,而難以想像的複雜行為,竟由短短幾行優雅地展開。

💡
關鍵

河內塔不需額外資料結構追蹤盤子,因為呼叫堆疊隱式幫我們記住每層的柱子角色;n 個盤子最少需 $2^n - 1$ 步。

🔆
生活妙喻:河內塔遞迴 ≈ 搬走壓在最下面的大書前先把上面那疊整疊挪到椅子

挪走上面那疊本身是同一問題的縮小版,搬完大書再挪回來。

🔆
生活妙喻:呼叫堆疊隱式追蹤 ≈ 不必另外記筆記,腦中暫停點自動幫你記住搬到哪

每層遞迴的柱子角色存在 stack frame,呼叫堆疊取代了顯式的三個 Stack。

本節字彙

Tower of Hanoi(河內塔)
把一疊大小不一的盤子在三根柱間搬移、且大盤不可疊小盤的經典遞迴謎題。
🧠 三柱搬盤,大不壓小。
intermediate pole(中繼柱)
搬移過程中暫時擺放盤子的柱子,角色會隨遞迴層次而互換。
🧠 暫放區,過個水。
implicit stack(隱式堆疊)
遞迴時系統呼叫堆疊自動提供的堆疊,取代了我們手動建立的資料結構。
🧠 系統默默幫你記,免你動手。
把高度為 n 的塔從起點柱搬到目標柱的遞迴策略,正確順序是?
河內塔遞迴的 base case 是什麼?
為什麼河內塔程式不需要額外的資料結構來記錄每根柱子上的盤子?

走出迷宮:遞迴搜尋與回溯

讓海龜用「往四個方向試、撒麵包屑避免繞圈」的方式走出迷宮,理解四個 base case 與回溯的概念。

STEP 1

深度探秘

往四個方向試,撒麵包屑記路

問題

一隻海龜被丟在迷宮中間,要找到出口。迷宮由「方格」組成,每格不是通道就是。海龜只能走通道,撞牆就得換方向。

搜尋程序

從目前位置出發,依序嘗試:

  1. 先往走一格,從那裡遞迴重複同樣程序。
  2. 北邊不行就往
  3. 南邊不行就往西
  4. 西邊不行就往
  5. 四個方向都失敗,代表此路不通,回報失敗。

為什麼需要撒麵包屑

假設往北走,下一步又往北;若北邊是牆,程序會改試往南——但往南剛好走回原來的位置!再從那裡開始又往北……陷入無限迴圈

所以要記住走過的地方:想像有一袋麵包屑,每到一格就撒一顆。若踏進一格發現已經有麵包屑,就立刻退回去換下一個方向。

四個 base case

  1. 撞到——此格不能走。
  2. 踩到已探索過的格子(有麵包屑)——避免繞圈。
  3. 走到迷宮外緣且不是牆——找到出口,成功!
  4. 四個方向都試過仍失敗——死路。
💡
關鍵

從每格依北南西東順序遞迴嘗試,並撒麵包屑標記走過的格子;四個 base case 涵蓋牆、已走過、找到出口、四方皆敗。

STEP 2

生活妙喻

希臘神話的線團與探路

比喻:忒修斯的線團

希臘神話裡,忒修斯走進迷宮殺怪獸,靠一團記住來路才得以走出。我們的麵包屑就是那團線——標記「這裡來過了」,避免鬼打牆。

回溯:返回就是退一步

最妙的是:回溯(backtracking)只不過是從遞迴函式返回而已。當某個方向走到死路(回傳 False),程式自然返回上一層,換下一個方向再試。你不必寫複雜的「往回走」邏輯——遞迴的返回就幫你做到了。

程式核心

def search_from(maze, row, col):
    maze.update_position(row, col)
    if maze[row][col] == OBSTACLE:   # base case 1 撞牆
        return False
    if maze[row][col] == TRIED:      # base case 2 已走過
        return False
    if maze.is_exit(row, col):       # base case 3 找到出口
        maze.update_position(row, col, PART_OF_PATH)
        return True
    maze.update_position(row, col, TRIED)   # 撒麵包屑
    found = search_from(maze, row-1, col) or \
            search_from(maze, row+1, col) or \
            search_from(maze, row, col-1) or \
            search_from(maze, row, col+1)
    ...
    return found
flowchart TD
    A[到達某格 撒麵包屑] --> B{是牆嗎}
    B -- 是 --> C[回傳 失敗]
    B -- 否 --> D{已走過嗎}
    D -- 是 --> C
    D -- 否 --> E{是出口嗎}
    E -- 是 --> F[回傳 成功]
    E -- 否 --> G[依序試 北 南 西 東]
    G --> A
💡
關鍵

麵包屑像忒修斯的線團避免鬼打牆;而回溯其實就是從遞迴函式返回,自動換下一個方向嘗試。

STEP 3

實用超能力

or 短路與順序的影響

四個方向用 or 串起來的巧思

found = search_from(maze, row-1, col) or \
        search_from(maze, row+1, col) or \
        search_from(maze, row, col-1) or \
        search_from(maze, row, col+1)

這裡用了邏輯短路(short circuit)or 只要前面有一個是 True 就立刻停止,後面的就不執行。

意思是:如果往就能走出迷宮(回傳 True),南、西、東這三個遞迴呼叫根本不會被嘗試。只有北失敗了才試南,南失敗才試西……。

改變順序會怎樣

書中建議你動手把四個方向的呼叫對調順序,再看海龜怎麼走。你會發現:路徑變了!因為短路求值會優先深入第一個方向。雖然最後找到的出口可能相同,但探索的過程與路徑取決於嘗試順序。

is_exit 怎麼判斷出口

def is_exit(self, row, col):
    return (row == 0 or row == self.rows_in_maze - 1 or
            col == 0 or col == self.columns_in_maze - 1)

只要走到迷宮的最外緣(第 0 列、最後一列、第 0 行或最後一行)且不是牆,就算找到出口。

帶走的觀念

這個迷宮問題濃縮了遞迴的精華:多方向嘗試 + base case 判斷 + 用返回實現回溯,是日後學「深度優先搜尋」的最佳起點。

💡
關鍵

四方向用 or 短路依序嘗試,一旦成功就不再試其餘方向;嘗試順序會改變探索路徑,這正是深度優先搜尋的雛形。

🔆
生活妙喻:撒麵包屑標記 ≈ 忒修斯的線團

標記走過的格子,避免一往北一往南地鬼打牆陷入無限迴圈。

🔆
生活妙喻:回溯 backtracking ≈ 走到死巷自然轉身退回路口換條路

回溯不需特別寫,遞迴函式回傳 False 返回上層,就會換下一個方向嘗試。

本節字彙

backtracking(回溯)
走進死路後退回上一個決策點改試其他選項;在遞迴中就是函式返回。
🧠 此路不通就退回去換一條。
short circuit(短路求值)
用 or 連接時只要前面為真就停止後續判斷,讓多個方向依序嘗試。
🧠 一個成功,其餘免問。
is_exit(出口判斷)
檢查目前位置是否在迷宮最外緣(且非牆),是找到出口的 base case。
🧠 走到邊邊就出去了。
為什麼迷宮搜尋一定要「撒麵包屑」標記走過的格子?
在這個迷宮演算法中,「回溯(backtracking)」實際上是如何實現的?
四個方向用 `or` 串接 `北 or 南 or 西 or 東`,若往北就能成功走出,會發生什麼?