很多人一聽到「遞迴」這個詞就覺得很難,好像是數學家或資工系學生才會碰到的東西。
但其實,遞迴是一個很生活化的概念,你早就不知不覺用過它了。
簡單一句話:
遞迴就是「做同樣的事,但每次都往更小、更簡單的方向做下去,最後停在一個最簡單的情況」。
還是覺得有點抽象?別急,下面用幾個生活小例子帶你秒懂。
生活中的遞迴例子
俄羅斯套娃——一層層打開的遊戲
想像你正在玩俄羅斯套娃:
- 你拿起最大的一個,打開它;
- 裡面還有一個更小的套娃;
- 你再打開那個小一點的,發現裡面還有更小的;
- 你重複「打開」這個動作,一層一層往下,直到最裡面那個最小的娃娃,再也打不開為止。
整個過程中,你做的事情始終一樣:「打開套娃 → 看裡面有沒有更小的 → 如果有就繼續打開」。
而「最小的娃娃」就是一個結束點,因為它不需要再打開。
找東西的方法——抽屜一層層翻找
假設你要在一個有很多層抽屜的大衣櫃裡找外套:
- 先打開最上層抽屜,看看是不是你要的外套;
- 如果不是,就打開下一層抽屜,重複一樣的檢查動作;
- 你一層層打開,一直找下去,直到:
- 找到了外套 → 你停止動作;
- 所有抽屜都打開完卻沒找到 → 你也停止,因為再也沒有抽屜可以查了。
這個例子告訴我們:只要每次都用同樣的方法去檢查,最終一定會停在一個「不用再找」的狀態。
小朋友問「為什麼」——一層層追問原因
你一定遇過小朋友不斷問「為什麼」的情況:
- 小孩:「為什麼天會黑?」
- 你:「因為太陽下山了。」
- 小孩:「為什麼太陽要下山?」
- 你:「因為地球在轉啊。」
- 小孩:「那為什麼地球會轉?」
- 你:「因為就是這樣啊!」(終於回答不下去了)
這個「問為什麼」的過程,就是典型的遞迴:
每次都重複「問為什麼」這件事,每次問的內容都更深入一層,直到一個「不能再追問」的原因為止。
打電話轉接客服——一個人接著一個人
你打客服電話,想解決某個問題:
- 總機先接聽,檢查自己能不能幫你;
- 如果不能,總機就把電話轉給技術部門;
- 技術部門接起來後,也做同樣的檢查:「能處理嗎?不能就再轉給另一位負責人」;
- 一直轉,直到電話接到真正能處理的人,才停止。
在這個過程中,每個人做的事都一模一樣——「檢查 → 能處理就結束,不能就轉給下一個」,而最後那個「能處理的人」就是終點。
家族傳話——一輩輩往上告知
想像你想通知家族裡所有人一件事,但你只能告訴爸爸:
- 你先跟爸爸說;
- 爸爸再用同樣的方式告訴爺爺;
- 爺爺再告訴曾祖父……
- 一直傳到家族裡最年長的那一輩就結束,因為再也沒有人可以告訴了。
每個人做的動作都是相同的:「收到 → 再往上傳」,只是傳到的人一輩輩變得更年長,直到「最高一輩」停下來。
剝洋蔥——一層層往內
剝洋蔥的時候,你每次做的事情都是「剝掉一層皮」。
剝完一層後,你拿起裡面那層洋蔥,再做一樣的動作——「剝掉一層皮」。
重複這個動作,一層層往內,直到只剩下最裡面的那一層小洋蔥。
這也是遞迴的典型結構:每次做的事完全相同,但每次處理的是更「內層」的部分,最後停在最小的那一層。
遞迴的精神
看完上面那些生活例子,你應該已經感受到一種「共同的模式」:
不管是打開套娃、剝洋蔥,還是小朋友問為什麼,它們都遵守同一種思考方式。這就是「遞迴精神」。
我們可以用三個簡單的重點來拆解:
重複同樣的事情
遞迴的第一個精神是:
不管到哪一層,你做的動作都一樣。
- 打開套娃時,每一層都是「打開 → 看裡面有沒有更小的」。
- 翻抽屜時,每一層都是「打開 → 檢查是不是外套」。
- 小朋友問為什麼時,每一層都是「再問一次為什麼」。
換句話說,每一層的規則是固定的,只有處理的對象變得更小或更內層。
一步步往更簡單的情況推進
遞迴不是原地打轉,它一定會每次都往更接近終點的地方前進。
- 剝洋蔥時,每剝一次,洋蔥就變得更小一點。
- 找抽屜時,每次都往更下層的抽屜走,終於會到最底層。
- 客服轉接時,每轉一次,就更接近那個真正能處理問題的人。
如果每次做完動作還是回到原點,那就不是遞迴,而是「死循環」。
一定有一個「停止點」
遞迴最重要的精神是:它一定會在某個最簡單的情況下結束。
- 最小的套娃不能再打開 → 結束。
- 最後一層抽屜找完 → 結束。
- 小朋友問到「因為就是這樣啊!」 → 結束。
- 剝到最裡層的小洋蔥 → 結束。
如果沒有這個「停止點」,整件事情就會沒完沒了。
一句話記住遞迴精神:「重複同樣的動作 → 每次往更簡單的情況前進 → 最終在一個最簡單的情況停下來。」
程式中遞迴的基本概念
程式裡的遞迴也是這樣:
✅ 你每次都在做同樣的事(打開、剝一層、問為什麼)
✅ 每次都更接近終點(套娃越來越小、洋蔥越剝越少、原因越問越深入)
✅ 最後一定會在一個最簡單的情況停下來
唯一的不同是:我們用函式來一層一層幫自己完成這件事。
因此在開始學遞迴之前,我們先要搞清楚一個最基本的概念:什麼是「呼叫函式」。
為什麼要先學這個?
因為遞迴的核心,就是「同一個函式重複執行自己,直到完成工作」。
如果你還不熟悉「函式是怎麼被執行的、怎麼把資料交給函式處理」,遞迴看起來就會像一種很神秘的魔法。
✅ 遞迴就像重複做同樣的事
你可以把遞迴想成 「每次做同樣的動作,並且一步步接近結束」。
它沒有什麼特別神奇的地方,跟我們在生活中做的事很像。
以打開套娃為例:
- 你先打開最大的套娃;
- 如果裡面還有更小的,就再打開下一個;
- 一直重複這個動作,直到最裡面那個最小的套娃,才停下來。
程式中的遞迴也一樣:
函式每次處理一個小步驟,如果還沒完成,就再執行一次自己,直到「不需要再繼續」為止。
什麼是呼叫函式?
在 JavaScript 中,函式(function)就像一個小工具,可以幫你完成某件事。
如果你只是寫下函式名稱,程式只會知道「有這個工具」,但它不會自動執行。
要執行這個工具,你需要在函式名稱後加上一對小括號 (),這個動作就叫做「呼叫函式」。
舉個簡單的例子:
function sayHello() {
console.log("Hello!");
}
sayHello(); // 呼叫函式 → 會印出 Hello!當你寫 sayHello(); 時,程式就會跑進這個函式,執行裡面的動作。
✅ 傳遞「參數」給函式
有些時候,函式需要額外的資訊才能完成任務,這些資訊會放在括號裡,我們稱為參數(parameter)。
你可以把參數想成「交給函式的材料」。
例如,我們要寫一個可以對不同人打招呼的函式:
function sayHelloTo(name) {
console.log("Hello, " + name + "!");
}
sayHelloTo("Tom"); // 會印出 Hello, Tom!
sayHelloTo("Amy"); // 會印出 Hello, Amy!當我們呼叫 sayHelloTo("Tom") 時,就是把「Tom」這個名字交給函式,函式再用它完成工作。
✅ 遞迴也用同樣的方式呼叫自己
理解了「函式可以接收參數」之後,你就能明白:
遞迴不過是「函式自己呼叫自己,並且把一個新的參數交給自己」而已。
接下來,我們就用一個最簡單的例子來示範這種思路。
用最簡單的例子:從 1 數到 5
我們的目標是:
從 1 開始,一直往上數,最後數到 5,並且印出每一個數字。
程式碼
function countUp(number) {
console.log(number); // 印出當前的數字
if (number < 5) { // 如果還沒數到 5,就繼續
countUp(number + 1); // 呼叫自己,並把「下一個數字」交給自己
}
}
countUp(1); // 從 1 開始數執行過程一步步拆解
你可以把這個過程想像成「一個人一階一階往上爬樓梯,每走一階都會報一次數字」:
第一次呼叫 countUp(1)
- 程式跑進函式,看到
console.log(number)→ 印出 1。 - 接著判斷:
1 < 5成立 → 還沒到目標,準備繼續往上爬。 - 於是執行
countUp(2)→ 呼叫自己,帶著下一個數字 2 再次執行。
第二次呼叫 countUp(2)
- 這是「新的執行」,就好像你到了下一階樓梯,重新開始做同樣的動作。
- 印出 2。
- 判斷:
2 < 5成立 → 繼續往上。 - 呼叫
countUp(3)。
第三次呼叫 countUp(3)
- 再做一次一模一樣的事情:
- 印出 3;
- 判斷:
3 < 5→ 還沒結束 → 呼叫countUp(4)。
第四次呼叫 countUp(4)
- 一樣重複:
- 印出 4;
- 判斷:
4 < 5→ 繼續 → 呼叫countUp(5)。
第五次呼叫 countUp(5)
- 印出 5。
- 判斷:
5 < 5→ 條件不成立 → 不再呼叫自己,程式在這一階停止。
為什麼程式會結束?
- 因為每次呼叫時,
number都會比上一次大 1(number + 1)。 - 最終當數字變成 5 時,
if (number < 5)這個條件不成立 → 程式就不會再執行新的呼叫,所有執行結束。
最終印出的結果
每次呼叫都先印出一個數字,所以完整輸出為:
1
2
3
4
5為什麼這就是「遞迴」?
你會發現,這段程式其實跟我們剛剛提到的「打開套娃」或「剝洋蔥」沒什麼不同:
✔ 每次做同樣的事 → 印出數字並決定要不要繼續
✔ 一步步往更接近終點的方向 → 每次數字加 1,更接近 5
✔ 有停止點 → 當數字等於 5,就不再呼叫自己
所以,遞迴就是讓函式一層層幫自己完成任務,直到最簡單的情況為止。
遞迴的基本結構
所有遞迴程式,無論多複雜,都只有兩個最重要的部分:
- 基底條件(Base Case) → 告訴程式「什麼時候該停下來」。
- 遞迴呼叫(Recursive Case) → 告訴程式「如果還沒結束,接下來要怎麼繼續做」。
只要搞懂這兩件事,就能理解幾乎所有遞迴程式。
接下來,我們直接用「數到 5」的例子幫你完整拆解。
基底條件:什麼時候要停?
基底條件就像是設定一個「終點」。
當這個條件達成時,程式就會乖乖停下來,不再繼續呼叫自己。
在「從 1 數到 5」這個例子裡,我們的終點很清楚:
→ 當數字已經等於 5,就不需要再繼續數。
程式裡的判斷方式是:
if (number < 5) {
// 還沒到 5 → 繼續
}換句話說:
- 當
number還小於 5 → 代表還沒結束,要繼續; - 當
number不再小於 5(也就是等於 5) → 不再呼叫自己,整個任務結束。
這個條件非常重要。如果沒有基底條件,程式會一直呼叫自己,永遠停不下來。
遞迴呼叫:如果還沒結束,接下來要做什麼?
當條件判斷「還沒到終點」時,就要告訴程式:下一步該怎麼繼續做。
在這個例子裡,我們的目標是「一個數字一個數字往上數到 5」。
所以每次都要做兩件事:
- 數字加 1 → 因為我們要慢慢接近 5;
- 再執行一次同樣的函式 → 讓程式繼續往下數。
在程式裡就是這一行:
countUp(number + 1);你可以把它理解成:「把下一個數字交給同一個函式,請它繼續幫我數」。
把基底條件和遞迴呼叫合起來
完整的遞迴結構,看起來就是這樣:
function countUp(number) {
console.log(number); // 先做當前這一步:印出現在的數字
if (number < 5) { // 還沒到終點嗎?
countUp(number + 1); // 還沒 → 數字加 1,繼續呼叫自己
}
}這段程式的運作邏輯可以用一句超簡單的話來概括:
「先做現在的事,如果還沒做完,就繼續做下一步。」
為什麼這樣寫就能結束?
很多初學者會擔心:「函式不是一直呼叫自己嗎?那不會跑不完嗎?」
不會的,因為這裡有兩個保證:
- 每次呼叫都更接近終點 → 每呼叫一次,
number就會加 1,最終一定會變成 5。 - 有明確的停止條件 → 當數字等於 5,
if (number < 5)這個條件就不成立,程式自然停下來。
所以你可以放心,這個遞迴永遠不會陷入無限循環。
呼叫堆疊與返回順序
呼叫堆疊是什麼?
想像你桌上放著一疊便利貼,這疊便利貼是你正在處理的所有工作清單,而且有一個簡單但嚴格的規則:
- 只能把新的便利貼貼在最上面
→ 新工作一定要壓在最上層,不能插隊到中間或底下。 - 只能從最上面撕下便利貼
→ 你必須把「最新的工作」做完才能繼續做下面的事。
這種只能「從上面加、從上面拿」的規則,就是堆疊(Stack)的特性,電腦世界也遵守同樣的原則。
✅ 每張便利貼代表什麼?
每當程式呼叫一個函式,就好像你拿起一張便利貼,寫下:
我要執行:某個函式(還帶著目前的參數)然後把它貼到最上面。
這代表:「我現在要專心做這件事,等它完全做完再說。」
當這個函式的工作做完時,程式就會把最上面那張撕掉,然後回到下一張便利貼上,繼續處理那張寫的工作。
✅ 為什麼只能從最上面撕?
這是因為電腦必須「後進先出」來保持工作有序:
- 如果你中途硬把中間的便利貼抽掉,下面所有工作會亂成一團,因為它們可能還在等上面那件事的結果。
- 所以電腦規定:一定要把最新加的工作先完成(先撕掉),才能回去做更早的事。
呼叫階段──便利貼一張張往上貼
執行 countUp(1),程式一路呼叫到 countUp(5),便利貼堆疊長這樣(最上面是最新的工作):
| 時間點 | 新貼了什麼? | 便利貼堆疊(下 → 上) |
|---|---|---|
| A | countUp(1) | 1 |
| B | countUp(2) | 21 |
| C | countUp(3) | 321 |
| D | countUp(4) | 4321 |
| E | countUp(5) | 5 ← 最新貼在最上面4321 |
此刻最頂那張便利貼是 countUp(5),它正印出數字 5。
返回階段──便利貼一張張往下撕
countUp(5) 做完後,不再貼新便利貼,程式開始從最上面往下撕:
| 時間點 | 撕掉了什麼? | 便利貼堆疊(下 → 上) |
|---|---|---|
| F | 撕掉 countUp(5) | 4321 |
| G | 撕掉 countUp(4) | 321 |
| H | 撕掉 countUp(3) | 21 |
| I | 撕掉 countUp(2) | 1 |
| J | 撕掉 countUp(1) | (空) ← 堆疊清空,程式結束 |
🚀 一張對照表秒懂
呼叫(往上貼) 返回(往下撕)
countUp(5) 撕 countUp(5)
countUp(4) 撕 countUp(4)
countUp(3) 撕 countUp(3)
countUp(2) 撕 countUp(2)
countUp(1) 撕 countUp(1) ← 全部完成📝 白話重點
- 先把便利貼一張張往上貼
每呼叫一次自己(countUp(number + 1)),就多貼一張「下一步要做的事」在最上層。 - 最上面那張先做好先撕掉
countUp(5)完成 → 撕掉;接著輪到countUp(4),依序往下。 - 全部撕光就收工
當最後撕掉countUp(1),堆疊清空,程式完全結束。
把它想成「先把所有待辦便利貼貼滿,最後一張先做完並撕掉,再一路往回撕」,就能直覺理解呼叫堆疊在遞迴中的工作方式。
遞迴 vs. 迴圈:該如何選擇?
很多初學者學完遞迴後都會疑惑:
「這不是用迴圈也能做到嗎?那我什麼時候該用遞迴?」
事實上,遞迴和迴圈只是解決問題的兩種不同思路:
- 迴圈像是在原地「一直重複做事」。
- 遞迴則像「把問題拆成一層層的小任務,慢慢往下處理」。
我們先看優缺點,再用一個簡單例子對比兩種寫法。
遞迴的優點
✅ 1. 思路更直觀、貼近人類的邏輯
例如找資料夾裡的所有檔案:
- 每打開一個資料夾,就再檢查它裡面有沒有檔案或其他資料夾。
這種「一層層深入」的思維,用遞迴寫起來最自然。
✅ 2. 程式碼簡潔
對於樹狀結構、迷宮尋路這種「分層、分支」的問題,遞迴通常只要幾行就能完成。
遞迴的缺點
❌ 1. 執行效率較慢
每次呼叫函式都要額外耗費記憶體,對於簡單重複的問題,迴圈更快。
❌ 2. 呼叫太深可能「堆疊溢位」
每呼叫一次自己,呼叫堆疊就多貼一張「待辦便利貼」。
如果條件寫錯或呼叫次數太多,程式可能直接報 Stack Overflow。
❌ 3. 容易因為基底條件錯誤陷入無限呼叫
初學者常忘了設定「什麼時候該停下來」,導致函式一直呼叫自己。
用代碼比較:同樣是「數到 5」
我們用剛剛的「從 1 數到 5」這個簡單任務,分別用 迴圈和遞迴來寫,觀察差異。
✅ 迴圈寫法
function countUpLoop() {
for (let i = 1; i <= 5; i++) {
console.log(i);
}
}
countUpLoop();程式怎麼跑?
- 一開始就知道要跑幾次(1 到 5),直接在同一個函式裡重複印出數字。
- 適合這種「明確知道要重複幾次」的任務。
✅ 遞迴寫法
function countUpRecursion(number) {
console.log(number); // 印出當前的數字
if (number < 5) { // 還沒到 5 → 繼續
countUpRecursion(number + 1); // 呼叫自己,讓數字加 1
}
}
countUpRecursion(1);程式怎麼跑?
- 每次只負責處理「當下的這個數字」,然後把「下一個數字」交給同一個函式處理。
- 最終會先一路呼叫到
countUp(5),再從最上層一層層返回。
哪種寫法比較好?
| 情境 | 適合迴圈 | 適合遞迴 |
|---|---|---|
| 簡單、固定次數的重複 | ✅(如從 1 數到 5、計算總和) | ❌ |
| 分層結構、一層層往下探 | ❌ | ✅(如檔案搜尋、迷宮尋路) |
| 效率考量 | ✅(迴圈通常更快) | ❌ |
| 邏輯是否接近人類思考 | ❌(語法偏「機械式」) | ✅(「做這一步→還沒結束就再做下一步」的思維很直觀) |
延伸比較:走資料夾結構
假設你要做一個功能:
印出某個資料夾裡的所有檔案名稱。
但這個資料夾裡可能還有子資料夾,子資料夾裡又可能再有更多資料夾……
你必須一層層往下找,直到最裡層都找完。
這時候可以用兩種方法來解決:遞迴或迴圈。
遞迴寫法(簡單又直觀)
先看資料結構
假設我們有一個像這樣的資料夾結構:
const myFolder = [
{ type: "file", name: "a.txt" },
{ type: "folder", name: "sub1", children: [
{ type: "file", name: "b.txt" },
{ type: "folder", name: "sub2", children: [
{ type: "file", name: "c.txt" }
]}
]}
];你可以把它想成這樣的資料夾圖:
myFolder
├── a.txt
└── sub1
├── b.txt
└── sub2
└── c.txt遞迴程式碼
function printFiles(folder) {
for (let item of folder) {
if (item.type === "file") {
console.log("檔案:", item.name);
} else if (item.type === "folder") {
// 如果是資料夾 → 再呼叫自己
printFiles(item.children);
}
}
}
printFiles(myFolder);執行流程一步步解釋
假設你是這個程式,它其實就像一個很專心的整理員,每次只做兩件事:
- 如果是檔案 → 印出名稱
- 如果是資料夾 → 再進去這個資料夾,做同樣的事
以這個例子來看,流程是這樣:
- 進入
myFolder→ 看到a.txt是檔案 → 印出「檔案:a.txt」 - 看到
sub1是資料夾 → 再呼叫printFiles(sub1.children) - 進入
sub1→ 印出 b.txt - 看到 sub2 是資料夾 → 再呼叫
printFiles(sub2.children) - 進入
sub2→ 印出 c.txt - 最後沒有更多資料夾,程式自動返回上一層,一層層結束。
最終輸出結果:
檔案: a.txt
檔案: b.txt
檔案: c.txt為什麼說「簡單又直觀」?
因為它的程式碼就跟人類做事的邏輯一模一樣:
「遇到檔案就印出名字;遇到資料夾?那就打開資料夾,裡面也做一樣的事。」
不需要管資料夾有幾層,函式自己會一層層往下探到底。
迴圈寫法(比較繁瑣)
迴圈程式碼
function printFilesLoop(folder) {
let stack = [...folder]; // 自己準備一個「小型堆疊」
while (stack.length > 0) {
const item = stack.pop(); // 從最上面拿出一個
if (item.type === "file") {
console.log("檔案:", item.name);
} else if (item.type === "folder") {
// 如果是資料夾 → 手動把裡面的東西放進堆疊
stack.push(...item.children);
}
}
}
printFilesLoop(myFolder);執行流程簡單描述
因為迴圈沒有自帶「呼叫自己」的功能,所以你必須自己準備一個小堆疊(stack),模擬遞迴的行為:
- 把第一層的檔案、資料夾先放進
stack - 每次從最上面拿一個出來處理
- 如果是資料夾,就再把裡面的檔案放到
stack最上面 - 一直重複這個過程,直到
stack空了。
結果輸出跟遞迴一樣,但寫法繞了一大圈。
為什麼說「繁瑣」?
- 你必須自己準備額外的變數(
stack),而遞迴會自動幫你用「呼叫堆疊」做到這件事。 - 程式邏輯比較「機械化」:初學者很難一眼看出這段程式碼到底在做什麼。
總結:該選哪一個?
| 情境 | 遞迴 | 迴圈 |
|---|---|---|
| 可讀性 | ✅ 就像「遇到資料夾就打開」的直觀邏輯 | ❌ 需要自己管理一個「模擬堆疊」,看起來繞 |
| 程式碼長度 | ✅ 短而簡潔 | ❌ 額外變數、邏輯比較多 |
| 執行效率 | ❌ 多次函式呼叫略慢 | ✅ 省略函式呼叫,速度更快 |
| 適用情境 | 探索巢狀結構(檔案、樹狀資料) | 大量簡單、固定次數的重複 |
✅ 最簡單的選擇口訣
「要一層層往下探,就用遞迴; 只是簡單重複,乖乖用迴圈。」
總結
遞迴其實就是一種「拆問題」的思維:
「重複做同樣的事 → 每次處理更小的情況 → 最後停在最簡單的情況」。
當你用這種角度去看程式,遞迴就不再神秘。
所以,下次當你遇到一個「可以一層層往下拆解」的問題時,不妨想想——也許遞迴就是最直觀的解法。