跳轉至

2.2   迭代與遞迴

在演算法中,重複執行某個任務是很常見的,它與複雜度分析息息相關。因此,在介紹時間複雜度和空間複雜度之前,我們先來了解如何在程式中實現重複執行任務,即兩種基本的程式控制結構:迭代、遞迴。

2.2.1   迭代

迭代(iteration)是一種重複執行某個任務的控制結構。在迭代中,程式會在滿足一定的條件下重複執行某段程式碼,直到這個條件不再滿足。

1.   for 迴圈

for 迴圈是最常見的迭代形式之一,適合在預先知道迭代次數時使用

以下函式基於 for 迴圈實現了求和 \(1 + 2 + \dots + n\) ,求和結果使用變數 res 記錄。需要注意的是,Python 中 range(a, b) 對應的區間是“左閉右開”的,對應的走訪範圍為 \(a, a + 1, \dots, b-1\)

iteration.py
def for_loop(n: int) -> int:
    """for 迴圈"""
    res = 0
    # 迴圈求和 1, 2, ..., n-1, n
    for i in range(1, n + 1):
        res += i
    return res
iteration.cpp
/* for 迴圈 */
int forLoop(int n) {
    int res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (int i = 1; i <= n; ++i) {
        res += i;
    }
    return res;
}
iteration.java
/* for 迴圈 */
int forLoop(int n) {
    int res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}
iteration.cs
/* for 迴圈 */
int ForLoop(int n) {
    int res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}
iteration.go
/* for 迴圈 */
func forLoop(n int) int {
    res := 0
    // 迴圈求和 1, 2, ..., n-1, n
    for i := 1; i <= n; i++ {
        res += i
    }
    return res
}
iteration.swift
/* for 迴圈 */
func forLoop(n: Int) -> Int {
    var res = 0
    // 迴圈求和 1, 2, ..., n-1, n
    for i in 1 ... n {
        res += i
    }
    return res
}
iteration.js
/* for 迴圈 */
function forLoop(n) {
    let res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (let i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}
iteration.ts
/* for 迴圈 */
function forLoop(n: number): number {
    let res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (let i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}
iteration.dart
/* for 迴圈 */
int forLoop(int n) {
  int res = 0;
  // 迴圈求和 1, 2, ..., n-1, n
  for (int i = 1; i <= n; i++) {
    res += i;
  }
  return res;
}
iteration.rs
/* for 迴圈 */
fn for_loop(n: i32) -> i32 {
    let mut res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for i in 1..=n {
        res += i;
    }
    res
}
iteration.c
/* for 迴圈 */
int forLoop(int n) {
    int res = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}
iteration.kt
/* for 迴圈 */
fun forLoop(n: Int): Int {
    var res = 0
    // 迴圈求和 1, 2, ..., n-1, n
    for (i in 1..n) {
        res += i
    }
    return res
}
iteration.rb
### for 迴圈 ###
def for_loop(n)
  res = 0

  # 迴圈求和 1, 2, ..., n-1, n
  for i in 1..n
    res += i
  end

  res
end
iteration.zig
// for 迴圈
fn forLoop(n: usize) i32 {
    var res: i32 = 0;
    // 迴圈求和 1, 2, ..., n-1, n
    for (1..n+1) |i| {
        res = res + @as(i32, @intCast(i));
    }
    return res;
} 
視覺化執行

圖 2-1 是該求和函式的流程框圖。

求和函式的流程框圖

圖 2-1   求和函式的流程框圖

此求和函式的操作數量與輸入資料大小 \(n\) 成正比,或者說成“線性關係”。實際上,時間複雜度描述的就是這個“線性關係”。相關內容將會在下一節中詳細介紹。

2.   while 迴圈

for 迴圈類似,while 迴圈也是一種實現迭代的方法。在 while 迴圈中,程式每輪都會先檢查條件,如果條件為真,則繼續執行,否則就結束迴圈。

下面我們用 while 迴圈來實現求和 \(1 + 2 + \dots + n\)

iteration.py
def while_loop(n: int) -> int:
    """while 迴圈"""
    res = 0
    i = 1  # 初始化條件變數
    # 迴圈求和 1, 2, ..., n-1, n
    while i <= n:
        res += i
        i += 1  # 更新條件變數
    return res
iteration.cpp
/* while 迴圈 */
int whileLoop(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i++; // 更新條件變數
    }
    return res;
}
iteration.java
/* while 迴圈 */
int whileLoop(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i++; // 更新條件變數
    }
    return res;
}
iteration.cs
/* while 迴圈 */
int WhileLoop(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i += 1; // 更新條件變數
    }
    return res;
}
iteration.go
/* while 迴圈 */
func whileLoop(n int) int {
    res := 0
    // 初始化條件變數
    i := 1
    // 迴圈求和 1, 2, ..., n-1, n
    for i <= n {
        res += i
        // 更新條件變數
        i++
    }
    return res
}
iteration.swift
/* while 迴圈 */
func whileLoop(n: Int) -> Int {
    var res = 0
    var i = 1 // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while i <= n {
        res += i
        i += 1 // 更新條件變數
    }
    return res
}
iteration.js
/* while 迴圈 */
function whileLoop(n) {
    let res = 0;
    let i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i++; // 更新條件變數
    }
    return res;
}
iteration.ts
/* while 迴圈 */
function whileLoop(n: number): number {
    let res = 0;
    let i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i++; // 更新條件變數
    }
    return res;
}
iteration.dart
/* while 迴圈 */
int whileLoop(int n) {
  int res = 0;
  int i = 1; // 初始化條件變數
  // 迴圈求和 1, 2, ..., n-1, n
  while (i <= n) {
    res += i;
    i++; // 更新條件變數
  }
  return res;
}
iteration.rs
/* while 迴圈 */
fn while_loop(n: i32) -> i32 {
    let mut res = 0;
    let mut i = 1; // 初始化條件變數

    // 迴圈求和 1, 2, ..., n-1, n
    while i <= n {
        res += i;
        i += 1; // 更新條件變數
    }
    res
}
iteration.c
/* while 迴圈 */
int whileLoop(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i;
        i++; // 更新條件變數
    }
    return res;
}
iteration.kt
/* while 迴圈 */
fun whileLoop(n: Int): Int {
    var res = 0
    var i = 1 // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += i
        i++ // 更新條件變數
    }
    return res
}
iteration.rb
### while 迴圈 ###
def while_loop(n)
  res = 0
  i = 1 # 初始化條件變數

  # 迴圈求和 1, 2, ..., n-1, n
  while i <= n
    res += i
    i += 1 # 更新條件變數
  end

  res
end
iteration.zig
// while 迴圈
fn whileLoop(n: i32) i32 {
    var res: i32 = 0;
    var i: i32 = 1; // 初始化條件變數
    // 迴圈求和 1, 2, ..., n-1, n
    while (i <= n) {
        res += @intCast(i);
        i += 1;
    }
    return res;
}
視覺化執行

while 迴圈比 for 迴圈的自由度更高。在 while 迴圈中,我們可以自由地設計條件變數的初始化和更新步驟。

例如在以下程式碼中,條件變數 \(i\) 每輪進行兩次更新,這種情況就不太方便用 for 迴圈實現:

iteration.py
def while_loop_ii(n: int) -> int:
    """while 迴圈(兩次更新)"""
    res = 0
    i = 1  # 初始化條件變數
    # 迴圈求和 1, 4, 10, ...
    while i <= n:
        res += i
        # 更新條件變數
        i += 1
        i *= 2
    return res
iteration.cpp
/* while 迴圈(兩次更新) */
int whileLoopII(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i++;
        i *= 2;
    }
    return res;
}
iteration.java
/* while 迴圈(兩次更新) */
int whileLoopII(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i++;
        i *= 2;
    }
    return res;
}
iteration.cs
/* while 迴圈(兩次更新) */
int WhileLoopII(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i += 1; 
        i *= 2;
    }
    return res;
}
iteration.go
/* while 迴圈(兩次更新) */
func whileLoopII(n int) int {
    res := 0
    // 初始化條件變數
    i := 1
    // 迴圈求和 1, 4, 10, ...
    for i <= n {
        res += i
        // 更新條件變數
        i++
        i *= 2
    }
    return res
}
iteration.swift
/* while 迴圈(兩次更新) */
func whileLoopII(n: Int) -> Int {
    var res = 0
    var i = 1 // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while i <= n {
        res += i
        // 更新條件變數
        i += 1
        i *= 2
    }
    return res
}
iteration.js
/* while 迴圈(兩次更新) */
function whileLoopII(n) {
    let res = 0;
    let i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i++;
        i *= 2;
    }
    return res;
}
iteration.ts
/* while 迴圈(兩次更新) */
function whileLoopII(n: number): number {
    let res = 0;
    let i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i++;
        i *= 2;
    }
    return res;
}
iteration.dart
/* while 迴圈(兩次更新) */
int whileLoopII(int n) {
  int res = 0;
  int i = 1; // 初始化條件變數
  // 迴圈求和 1, 4, 10, ...
  while (i <= n) {
    res += i;
    // 更新條件變數
    i++;
    i *= 2;
  }
  return res;
}
iteration.rs
/* while 迴圈(兩次更新) */
fn while_loop_ii(n: i32) -> i32 {
    let mut res = 0;
    let mut i = 1; // 初始化條件變數

    // 迴圈求和 1, 4, 10, ...
    while i <= n {
        res += i;
        // 更新條件變數
        i += 1;
        i *= 2;
    }
    res
}
iteration.c
/* while 迴圈(兩次更新) */
int whileLoopII(int n) {
    int res = 0;
    int i = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i;
        // 更新條件變數
        i++;
        i *= 2;
    }
    return res;
}
iteration.kt
/* while 迴圈(兩次更新) */
fun whileLoopII(n: Int): Int {
    var res = 0
    var i = 1 // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += i
        // 更新條件變數
        i++
        i *= 2
    }
    return res
}
iteration.rb
### while 迴圈(兩次更新)###
def while_loop_ii(n)
  res = 0
  i = 1 # 初始化條件變數

  # 迴圈求和 1, 4, 10, ...
  while i <= n
    res += i
    # 更新條件變數
    i += 1
    i *= 2
  end

  res
end
iteration.zig
//  while 迴圈(兩次更新)
fn whileLoopII(n: i32) i32 {
    var res: i32 = 0;
    var i: i32 = 1; // 初始化條件變數
    // 迴圈求和 1, 4, 10, ...
    while (i <= n) {
        res += @intCast(i);
        // 更新條件變數
        i += 1;
        i *= 2;
    }
    return res;
}
視覺化執行

總的來說,for 迴圈的程式碼更加緊湊,while 迴圈更加靈活,兩者都可以實現迭代結構。選擇使用哪一個應該根據特定問題的需求來決定。

3.   巢狀迴圈

我們可以在一個迴圈結構內巢狀另一個迴圈結構,下面以 for 迴圈為例:

iteration.py
def nested_for_loop(n: int) -> str:
    """雙層 for 迴圈"""
    res = ""
    # 迴圈 i = 1, 2, ..., n-1, n
    for i in range(1, n + 1):
        # 迴圈 j = 1, 2, ..., n-1, n
        for j in range(1, n + 1):
            res += f"({i}, {j}), "
    return res
iteration.cpp
/* 雙層 for 迴圈 */
string nestedForLoop(int n) {
    ostringstream res;
    // 迴圈 i = 1, 2, ..., n-1, n
    for (int i = 1; i <= n; ++i) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (int j = 1; j <= n; ++j) {
            res << "(" << i << ", " << j << "), ";
        }
    }
    return res.str();
}
iteration.java
/* 雙層 for 迴圈 */
String nestedForLoop(int n) {
    StringBuilder res = new StringBuilder();
    // 迴圈 i = 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (int j = 1; j <= n; j++) {
            res.append("(" + i + ", " + j + "), ");
        }
    }
    return res.toString();
}
iteration.cs
/* 雙層 for 迴圈 */
string NestedForLoop(int n) {
    StringBuilder res = new();
    // 迴圈 i = 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (int j = 1; j <= n; j++) {
            res.Append($"({i}, {j}), ");
        }
    }
    return res.ToString();
}
iteration.go
/* 雙層 for 迴圈 */
func nestedForLoop(n int) string {
    res := ""
    // 迴圈 i = 1, 2, ..., n-1, n
    for i := 1; i <= n; i++ {
        for j := 1; j <= n; j++ {
            // 迴圈 j = 1, 2, ..., n-1, n
            res += fmt.Sprintf("(%d, %d), ", i, j)
        }
    }
    return res
}
iteration.swift
/* 雙層 for 迴圈 */
func nestedForLoop(n: Int) -> String {
    var res = ""
    // 迴圈 i = 1, 2, ..., n-1, n
    for i in 1 ... n {
        // 迴圈 j = 1, 2, ..., n-1, n
        for j in 1 ... n {
            res.append("(\(i), \(j)), ")
        }
    }
    return res
}
iteration.js
/* 雙層 for 迴圈 */
function nestedForLoop(n) {
    let res = '';
    // 迴圈 i = 1, 2, ..., n-1, n
    for (let i = 1; i <= n; i++) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (let j = 1; j <= n; j++) {
            res += `(${i}, ${j}), `;
        }
    }
    return res;
}
iteration.ts
/* 雙層 for 迴圈 */
function nestedForLoop(n: number): string {
    let res = '';
    // 迴圈 i = 1, 2, ..., n-1, n
    for (let i = 1; i <= n; i++) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (let j = 1; j <= n; j++) {
            res += `(${i}, ${j}), `;
        }
    }
    return res;
}
iteration.dart
/* 雙層 for 迴圈 */
String nestedForLoop(int n) {
  String res = "";
  // 迴圈 i = 1, 2, ..., n-1, n
  for (int i = 1; i <= n; i++) {
    // 迴圈 j = 1, 2, ..., n-1, n
    for (int j = 1; j <= n; j++) {
      res += "($i, $j), ";
    }
  }
  return res;
}
iteration.rs
/* 雙層 for 迴圈 */
fn nested_for_loop(n: i32) -> String {
    let mut res = vec![];
    // 迴圈 i = 1, 2, ..., n-1, n
    for i in 1..=n {
        // 迴圈 j = 1, 2, ..., n-1, n
        for j in 1..=n {
            res.push(format!("({}, {}), ", i, j));
        }
    }
    res.join("")
}
iteration.c
/* 雙層 for 迴圈 */
char *nestedForLoop(int n) {
    // n * n 為對應點數量,"(i, j), " 對應字串長最大為 6+10*2,加上最後一個空字元 \0 的額外空間
    int size = n * n * 26 + 1;
    char *res = malloc(size * sizeof(char));
    // 迴圈 i = 1, 2, ..., n-1, n
    for (int i = 1; i <= n; i++) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (int j = 1; j <= n; j++) {
            char tmp[26];
            snprintf(tmp, sizeof(tmp), "(%d, %d), ", i, j);
            strncat(res, tmp, size - strlen(res) - 1);
        }
    }
    return res;
}
iteration.kt
/* 雙層 for 迴圈 */
fun nestedForLoop(n: Int): String {
    val res = StringBuilder()
    // 迴圈 i = 1, 2, ..., n-1, n
    for (i in 1..n) {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (j in 1..n) {
            res.append(" ($i, $j), ")
        }
    }
    return res.toString()
}
iteration.rb
### 雙層 for 迴圈 ###
def nested_for_loop(n)
  res = ""

  # 迴圈 i = 1, 2, ..., n-1, n
  for i in 1..n
    # 迴圈 j = 1, 2, ..., n-1, n
    for j in 1..n
      res += "(#{i}, #{j}), "
    end
  end

  res
end
iteration.zig
// 雙層 for 迴圈
fn nestedForLoop(allocator: Allocator, n: usize) ![]const u8 {
    var res = std.ArrayList(u8).init(allocator);
    defer res.deinit();
    var buffer: [20]u8 = undefined;
    // 迴圈 i = 1, 2, ..., n-1, n
    for (1..n+1) |i| {
        // 迴圈 j = 1, 2, ..., n-1, n
        for (1..n+1) |j| {
            var _str = try std.fmt.bufPrint(&buffer, "({d}, {d}), ", .{i, j});
            try res.appendSlice(_str);
        }
    }
    return res.toOwnedSlice();
}
視覺化執行

圖 2-2 是該巢狀迴圈的流程框圖。

巢狀迴圈的流程框圖

圖 2-2   巢狀迴圈的流程框圖

在這種情況下,函式的操作數量與 \(n^2\) 成正比,或者說演算法執行時間和輸入資料大小 \(n\) 成“平方關係”。

我們可以繼續新增巢狀迴圈,每一次巢狀都是一次“升維”,將會使時間複雜度提高至“立方關係”“四次方關係”,以此類推。

2.2.2   遞迴

遞迴(recursion)是一種演算法策略,透過函式呼叫自身來解決問題。它主要包含兩個階段。

  1. :程式不斷深入地呼叫自身,通常傳入更小或更簡化的參數,直到達到“終止條件”。
  2. :觸發“終止條件”後,程式從最深層的遞迴函式開始逐層返回,匯聚每一層的結果。

而從實現的角度看,遞迴程式碼主要包含三個要素。

  1. 終止條件:用於決定什麼時候由“遞”轉“迴”。
  2. 遞迴呼叫:對應“遞”,函式呼叫自身,通常輸入更小或更簡化的參數。
  3. 返回結果:對應“迴”,將當前遞迴層級的結果返回至上一層。

觀察以下程式碼,我們只需呼叫函式 recur(n) ,就可以完成 \(1 + 2 + \dots + n\) 的計算:

recursion.py
def recur(n: int) -> int:
    """遞迴"""
    # 終止條件
    if n == 1:
        return 1
    # 遞:遞迴呼叫
    res = recur(n - 1)
    # 迴:返回結果
    return n + res
recursion.cpp
/* 遞迴 */
int recur(int n) {
    // 終止條件
    if (n == 1)
        return 1;
    // 遞:遞迴呼叫
    int res = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.java
/* 遞迴 */
int recur(int n) {
    // 終止條件
    if (n == 1)
        return 1;
    // 遞:遞迴呼叫
    int res = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.cs
/* 遞迴 */
int Recur(int n) {
    // 終止條件
    if (n == 1)
        return 1;
    // 遞:遞迴呼叫
    int res = Recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.go
/* 遞迴 */
func recur(n int) int {
    // 終止條件
    if n == 1 {
        return 1
    }
    // 遞:遞迴呼叫
    res := recur(n - 1)
    // 迴:返回結果
    return n + res
}
recursion.swift
/* 遞迴 */
func recur(n: Int) -> Int {
    // 終止條件
    if n == 1 {
        return 1
    }
    // 遞:遞迴呼叫
    let res = recur(n: n - 1)
    // 迴:返回結果
    return n + res
}
recursion.js
/* 遞迴 */
function recur(n) {
    // 終止條件
    if (n === 1) return 1;
    // 遞:遞迴呼叫
    const res = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.ts
/* 遞迴 */
function recur(n: number): number {
    // 終止條件
    if (n === 1) return 1;
    // 遞:遞迴呼叫
    const res = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.dart
/* 遞迴 */
int recur(int n) {
  // 終止條件
  if (n == 1) return 1;
  // 遞:遞迴呼叫
  int res = recur(n - 1);
  // 迴:返回結果
  return n + res;
}
recursion.rs
/* 遞迴 */
fn recur(n: i32) -> i32 {
    // 終止條件
    if n == 1 {
        return 1;
    }
    // 遞:遞迴呼叫
    let res = recur(n - 1);
    // 迴:返回結果
    n + res
}
recursion.c
/* 遞迴 */
int recur(int n) {
    // 終止條件
    if (n == 1)
        return 1;
    // 遞:遞迴呼叫
    int res = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
recursion.kt
/* 遞迴 */
fun recur(n: Int): Int {
    // 終止條件
    if (n == 1)
        return 1
    // 遞: 遞迴呼叫
    val res = recur(n - 1)
    // 迴: 返回結果
    return n + res
}
recursion.rb
### 遞迴 ###
def recur(n)
  # 終止條件
  return 1 if n == 1
  # 遞:遞迴呼叫
  res = recur(n - 1)
  # 迴:返回結果
  n + res
end
recursion.zig
// 遞迴函式
fn recur(n: i32) i32 {
    // 終止條件
    if (n == 1) {
        return 1;
    }
    // 遞:遞迴呼叫
    var res: i32 = recur(n - 1);
    // 迴:返回結果
    return n + res;
}
視覺化執行

圖 2-3 展示了該函式的遞迴過程。

求和函式的遞迴過程

圖 2-3   求和函式的遞迴過程

雖然從計算角度看,迭代與遞迴可以得到相同的結果,但它們代表了兩種完全不同的思考和解決問題的範式

  • 迭代:“自下而上”地解決問題。從最基礎的步驟開始,然後不斷重複或累加這些步驟,直到任務完成。
  • 遞迴:“自上而下”地解決問題。將原問題分解為更小的子問題,這些子問題和原問題具有相同的形式。接下來將子問題繼續分解為更小的子問題,直到基本情況時停止(基本情況的解是已知的)。

以上述求和函式為例,設問題 \(f(n) = 1 + 2 + \dots + n\)

  • 迭代:在迴圈中模擬求和過程,從 \(1\) 走訪到 \(n\) ,每輪執行求和操作,即可求得 \(f(n)\)
  • 遞迴:將問題分解為子問題 \(f(n) = n + f(n-1)\) ,不斷(遞迴地)分解下去,直至基本情況 \(f(1) = 1\) 時終止。

1.   呼叫堆疊

遞迴函式每次呼叫自身時,系統都會為新開啟的函式分配記憶體,以儲存區域性變數、呼叫位址和其他資訊等。這將導致兩方面的結果。

  • 函式的上下文資料都儲存在稱為“堆疊幀空間”的記憶體區域中,直至函式返回後才會被釋放。因此,遞迴通常比迭代更加耗費記憶體空間
  • 遞迴呼叫函式會產生額外的開銷。因此遞迴通常比迴圈的時間效率更低

如圖 2-4 所示,在觸發終止條件前,同時存在 \(n\) 個未返回的遞迴函式,遞迴深度為 \(n\)

遞迴呼叫深度

圖 2-4   遞迴呼叫深度

在實際中,程式語言允許的遞迴深度通常是有限的,過深的遞迴可能導致堆疊溢位錯誤。

2.   尾遞迴

有趣的是,如果函式在返回前的最後一步才進行遞迴呼叫,則該函式可以被編譯器或直譯器最佳化,使其在空間效率上與迭代相當。這種情況被稱為尾遞迴(tail recursion)

  • 普通遞迴:當函式返回到上一層級的函式後,需要繼續執行程式碼,因此系統需要儲存上一層呼叫的上下文。
  • 尾遞迴:遞迴呼叫是函式返回前的最後一個操作,這意味著函式返回到上一層級後,無須繼續執行其他操作,因此系統無須儲存上一層函式的上下文。

以計算 \(1 + 2 + \dots + n\) 為例,我們可以將結果變數 res 設為函式參數,從而實現尾遞迴:

recursion.py
def tail_recur(n, res):
    """尾遞迴"""
    # 終止條件
    if n == 0:
        return res
    # 尾遞迴呼叫
    return tail_recur(n - 1, res + n)
recursion.cpp
/* 尾遞迴 */
int tailRecur(int n, int res) {
    // 終止條件
    if (n == 0)
        return res;
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
recursion.java
/* 尾遞迴 */
int tailRecur(int n, int res) {
    // 終止條件
    if (n == 0)
        return res;
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
recursion.cs
/* 尾遞迴 */
int TailRecur(int n, int res) {
    // 終止條件
    if (n == 0)
        return res;
    // 尾遞迴呼叫
    return TailRecur(n - 1, res + n);
}
recursion.go
/* 尾遞迴 */
func tailRecur(n int, res int) int {
    // 終止條件
    if n == 0 {
        return res
    }
    // 尾遞迴呼叫
    return tailRecur(n-1, res+n)
}
recursion.swift
/* 尾遞迴 */
func tailRecur(n: Int, res: Int) -> Int {
    // 終止條件
    if n == 0 {
        return res
    }
    // 尾遞迴呼叫
    return tailRecur(n: n - 1, res: res + n)
}
recursion.js
/* 尾遞迴 */
function tailRecur(n, res) {
    // 終止條件
    if (n === 0) return res;
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
recursion.ts
/* 尾遞迴 */
function tailRecur(n: number, res: number): number {
    // 終止條件
    if (n === 0) return res;
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
recursion.dart
/* 尾遞迴 */
int tailRecur(int n, int res) {
  // 終止條件
  if (n == 0) return res;
  // 尾遞迴呼叫
  return tailRecur(n - 1, res + n);
}
recursion.rs
/* 尾遞迴 */
fn tail_recur(n: i32, res: i32) -> i32 {
    // 終止條件
    if n == 0 {
        return res;
    }
    // 尾遞迴呼叫
    tail_recur(n - 1, res + n)
}
recursion.c
/* 尾遞迴 */
int tailRecur(int n, int res) {
    // 終止條件
    if (n == 0)
        return res;
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
recursion.kt
/* 尾遞迴 */
tailrec fun tailRecur(n: Int, res: Int): Int {
    // 新增 tailrec 關鍵詞,以開啟尾遞迴最佳化
    // 終止條件
    if (n == 0)
        return res
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n)
}
recursion.rb
### 尾遞迴 ###
def tail_recur(n, res)
  # 終止條件
  return res if n == 0
  # 尾遞迴呼叫
  tail_recur(n - 1, res + n)
end
recursion.zig
// 尾遞迴函式
fn tailRecur(n: i32, res: i32) i32 {
    // 終止條件
    if (n == 0) {
        return res;
    }
    // 尾遞迴呼叫
    return tailRecur(n - 1, res + n);
}
視覺化執行

尾遞迴的執行過程如圖 2-5 所示。對比普通遞迴和尾遞迴,兩者的求和操作的執行點是不同的。

  • 普通遞迴:求和操作是在“迴”的過程中執行的,每層返回後都要再執行一次求和操作。
  • 尾遞迴:求和操作是在“遞”的過程中執行的,“迴”的過程只需層層返回。

尾遞迴過程

圖 2-5   尾遞迴過程

Tip

請注意,許多編譯器或直譯器並不支持尾遞迴最佳化。例如,Python 預設不支持尾遞迴最佳化,因此即使函式是尾遞迴形式,仍然可能會遇到堆疊溢位問題。

3.   遞迴樹

當處理與“分治”相關的演算法問題時,遞迴往往比迭代的思路更加直觀、程式碼更加易讀。以“費波那契數列”為例。

Question

給定一個費波那契數列 \(0, 1, 1, 2, 3, 5, 8, 13, \dots\) ,求該數列的第 \(n\) 個數字。

設費波那契數列的第 \(n\) 個數字為 \(f(n)\) ,易得兩個結論。

  • 數列的前兩個數字為 \(f(1) = 0\)\(f(2) = 1\)
  • 數列中的每個數字是前兩個數字的和,即 \(f(n) = f(n - 1) + f(n - 2)\)

按照遞推關係進行遞迴呼叫,將前兩個數字作為終止條件,便可寫出遞迴程式碼。呼叫 fib(n) 即可得到費波那契數列的第 \(n\) 個數字:

recursion.py
def fib(n: int) -> int:
    """費波那契數列:遞迴"""
    # 終止條件 f(1) = 0, f(2) = 1
    if n == 1 or n == 2:
        return n - 1
    # 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    res = fib(n - 1) + fib(n - 2)
    # 返回結果 f(n)
    return res
recursion.cpp
/* 費波那契數列:遞迴 */
int fib(int n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2)
        return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.java
/* 費波那契數列:遞迴 */
int fib(int n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2)
        return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.cs
/* 費波那契數列:遞迴 */
int Fib(int n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2)
        return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = Fib(n - 1) + Fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.go
/* 費波那契數列:遞迴 */
func fib(n int) int {
    // 終止條件 f(1) = 0, f(2) = 1
    if n == 1 || n == 2 {
        return n - 1
    }
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    res := fib(n-1) + fib(n-2)
    // 返回結果 f(n)
    return res
}
recursion.swift
/* 費波那契數列:遞迴 */
func fib(n: Int) -> Int {
    // 終止條件 f(1) = 0, f(2) = 1
    if n == 1 || n == 2 {
        return n - 1
    }
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    let res = fib(n: n - 1) + fib(n: n - 2)
    // 返回結果 f(n)
    return res
}
recursion.js
/* 費波那契數列:遞迴 */
function fib(n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n === 1 || n === 2) return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    const res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.ts
/* 費波那契數列:遞迴 */
function fib(n: number): number {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n === 1 || n === 2) return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    const res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.dart
/* 費波那契數列:遞迴 */
int fib(int n) {
  // 終止條件 f(1) = 0, f(2) = 1
  if (n == 1 || n == 2) return n - 1;
  // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
  int res = fib(n - 1) + fib(n - 2);
  // 返回結果 f(n)
  return res;
}
recursion.rs
/* 費波那契數列:遞迴 */
fn fib(n: i32) -> i32 {
    // 終止條件 f(1) = 0, f(2) = 1
    if n == 1 || n == 2 {
        return n - 1;
    }
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    let res = fib(n - 1) + fib(n - 2);
    // 返回結果
    res
}
recursion.c
/* 費波那契數列:遞迴 */
int fib(int n) {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2)
        return n - 1;
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
recursion.kt
/* 費波那契數列:遞迴 */
fun fib(n: Int): Int {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2)
        return n - 1
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    val res = fib(n - 1) + fib(n - 2)
    // 返回結果 f(n)
    return res
}
recursion.rb
### 費波那契數列:遞迴 ###
def fib(n)
  # 終止條件 f(1) = 0, f(2) = 1
  return n - 1 if n == 1 || n == 2
  # 遞迴呼叫 f(n) = f(n-1) + f(n-2)
  res = fib(n - 1) + fib(n - 2)
  # 返回結果 f(n)
  res
end
recursion.zig
// 費波那契數列
fn fib(n: i32) i32 {
    // 終止條件 f(1) = 0, f(2) = 1
    if (n == 1 or n == 2) {
        return n - 1;
    }
    // 遞迴呼叫 f(n) = f(n-1) + f(n-2)
    var res: i32 = fib(n - 1) + fib(n - 2);
    // 返回結果 f(n)
    return res;
}
視覺化執行

觀察以上程式碼,我們在函式內遞迴呼叫了兩個函式,這意味著從一個呼叫產生了兩個呼叫分支。如圖 2-6 所示,這樣不斷遞迴呼叫下去,最終將產生一棵層數為 \(n\)遞迴樹(recursion tree)

費波那契數列的遞迴樹

圖 2-6   費波那契數列的遞迴樹

從本質上看,遞迴體現了“將問題分解為更小子問題”的思維範式,這種分治策略至關重要。

  • 從演算法角度看,搜尋、排序、回溯、分治、動態規劃等許多重要演算法策略直接或間接地應用了這種思維方式。
  • 從資料結構角度看,遞迴天然適合處理鏈結串列、樹和圖的相關問題,因為它們非常適合用分治思想進行分析。

2.2.3   兩者對比

總結以上內容,如表 2-1 所示,迭代和遞迴在實現、效能和適用性上有所不同。

表 2-1   迭代與遞迴特點對比

迭代 遞迴
實現方式 迴圈結構 函式呼叫自身
時間效率 效率通常較高,無函式呼叫開銷 每次函式呼叫都會產生開銷
記憶體使用 通常使用固定大小的記憶體空間 累積函式呼叫可能使用大量的堆疊幀空間
適用問題 適用於簡單迴圈任務,程式碼直觀、可讀性好 適用於子問題分解,如樹、圖、分治、回溯等,程式碼結構簡潔、清晰

Tip

如果感覺以下內容理解困難,可以在讀完“堆疊”章節後再來複習。

那麼,迭代和遞迴具有什麼內在關聯呢?以上述遞迴函式為例,求和操作在遞迴的“迴”階段進行。這意味著最初被呼叫的函式實際上是最後完成其求和操作的,這種工作機制與堆疊的“先入後出”原則異曲同工

事實上,“呼叫堆疊”和“堆疊幀空間”這類遞迴術語已經暗示了遞迴與堆疊之間的密切關係。

  1. :當函式被呼叫時,系統會在“呼叫堆疊”上為該函式分配新的堆疊幀,用於儲存函式的區域性變數、參數、返回位址等資料。
  2. :當函式完成執行並返回時,對應的堆疊幀會被從“呼叫堆疊”上移除,恢復之前函式的執行環境。

因此,我們可以使用一個顯式的堆疊來模擬呼叫堆疊的行為,從而將遞迴轉化為迭代形式:

recursion.py
def for_loop_recur(n: int) -> int:
    """使用迭代模擬遞迴"""
    # 使用一個顯式的堆疊來模擬系統呼叫堆疊
    stack = []
    res = 0
    # 遞:遞迴呼叫
    for i in range(n, 0, -1):
        # 透過“入堆疊操作”模擬“遞”
        stack.append(i)
    # 迴:返回結果
    while stack:
        # 透過“出堆疊操作”模擬“迴”
        res += stack.pop()
    # res = 1+2+3+...+n
    return res
recursion.cpp
/* 使用迭代模擬遞迴 */
int forLoopRecur(int n) {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    stack<int> stack;
    int res = 0;
    // 遞:遞迴呼叫
    for (int i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i);
    }
    // 迴:返回結果
    while (!stack.empty()) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.top();
        stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.java
/* 使用迭代模擬遞迴 */
int forLoopRecur(int n) {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    Stack<Integer> stack = new Stack<>();
    int res = 0;
    // 遞:遞迴呼叫
    for (int i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i);
    }
    // 迴:返回結果
    while (!stack.isEmpty()) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.cs
/* 使用迭代模擬遞迴 */
int ForLoopRecur(int n) {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    Stack<int> stack = new();
    int res = 0;
    // 遞:遞迴呼叫
    for (int i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack.Push(i);
    }
    // 迴:返回結果
    while (stack.Count > 0) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.Pop();
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.go
/* 使用迭代模擬遞迴 */
func forLoopRecur(n int) int {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    stack := list.New()
    res := 0
    // 遞:遞迴呼叫
    for i := n; i > 0; i-- {
        // 透過“入堆疊操作”模擬“遞”
        stack.PushBack(i)
    }
    // 迴:返回結果
    for stack.Len() != 0 {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.Back().Value.(int)
        stack.Remove(stack.Back())
    }
    // res = 1+2+3+...+n
    return res
}
recursion.swift
/* 使用迭代模擬遞迴 */
func forLoopRecur(n: Int) -> Int {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    var stack: [Int] = []
    var res = 0
    // 遞:遞迴呼叫
    for i in (1 ... n).reversed() {
        // 透過“入堆疊操作”模擬“遞”
        stack.append(i)
    }
    // 迴:返回結果
    while !stack.isEmpty {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.removeLast()
    }
    // res = 1+2+3+...+n
    return res
}
recursion.js
/* 使用迭代模擬遞迴 */
function forLoopRecur(n) {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    const stack = [];
    let res = 0;
    // 遞:遞迴呼叫
    for (let i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i);
    }
    // 迴:返回結果
    while (stack.length) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.ts
/* 使用迭代模擬遞迴 */
function forLoopRecur(n: number): number {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊 
    const stack: number[] = [];
    let res: number = 0;
    // 遞:遞迴呼叫
    for (let i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i);
    }
    // 迴:返回結果
    while (stack.length) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.dart
/* 使用迭代模擬遞迴 */
int forLoopRecur(int n) {
  // 使用一個顯式的堆疊來模擬系統呼叫堆疊
  List<int> stack = [];
  int res = 0;
  // 遞:遞迴呼叫
  for (int i = n; i > 0; i--) {
    // 透過“入堆疊操作”模擬“遞”
    stack.add(i);
  }
  // 迴:返回結果
  while (!stack.isEmpty) {
    // 透過“出堆疊操作”模擬“迴”
    res += stack.removeLast();
  }
  // res = 1+2+3+...+n
  return res;
}
recursion.rs
/* 使用迭代模擬遞迴 */
fn for_loop_recur(n: i32) -> i32 {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    let mut stack = Vec::new();
    let mut res = 0;
    // 遞:遞迴呼叫
    for i in (1..=n).rev() {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i);
    }
    // 迴:返回結果
    while !stack.is_empty() {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.pop().unwrap();
    }
    // res = 1+2+3+...+n
    res
}
recursion.c
/* 使用迭代模擬遞迴 */
int forLoopRecur(int n) {
    int stack[1000]; // 藉助一個大陣列來模擬堆疊
    int top = -1;    // 堆疊頂索引
    int res = 0;
    // 遞:遞迴呼叫
    for (int i = n; i > 0; i--) {
        // 透過“入堆疊操作”模擬“遞”
        stack[1 + top++] = i;
    }
    // 迴:返回結果
    while (top >= 0) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack[top--];
    }
    // res = 1+2+3+...+n
    return res;
}
recursion.kt
/* 使用迭代模擬遞迴 */
fun forLoopRecur(n: Int): Int {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    val stack = Stack<Int>()
    var res = 0
    // 遞: 遞迴呼叫
    for (i in n downTo 0) {
        // 透過“入堆疊操作”模擬“遞”
        stack.push(i)
    }
    // 迴: 返回結果
    while (stack.isNotEmpty()) {
        // 透過“出堆疊操作”模擬“迴”
        res += stack.pop()
    }
    // res = 1+2+3+...+n
    return res
}
recursion.rb
### 使用迭代模擬遞迴 ###
def for_loop_recur(n)
  # 使用一個顯式的堆疊來模擬系統呼叫堆疊
  stack = []
  res = 0

  # 遞:遞迴呼叫
  for i in n.downto(0)
    # 透過“入堆疊操作”模擬“遞”
    stack << i
  end
  # 迴:返回結果
  while !stack.empty?
    res += stack.pop
  end

  # res = 1+2+3+...+n
  res
end
recursion.zig
// 使用迭代模擬遞迴
fn forLoopRecur(comptime n: i32) i32 {
    // 使用一個顯式的堆疊來模擬系統呼叫堆疊
    var stack: [n]i32 = undefined;
    var res: i32 = 0;
    // 遞:遞迴呼叫
    var i: usize = n;
    while (i > 0) {
        stack[i - 1] = @intCast(i);
        i -= 1;
    }
    // 迴:返回結果
    var index: usize = n;
    while (index > 0) {
        index -= 1;
        res += stack[index];
    }
    // res = 1+2+3+...+n
    return res;
}
視覺化執行

觀察以上程式碼,當遞迴轉化為迭代後,程式碼變得更加複雜了。儘管迭代和遞迴在很多情況下可以互相轉化,但不一定值得這樣做,有以下兩點原因。

  • 轉化後的程式碼可能更加難以理解,可讀性更差。
  • 對於某些複雜問題,模擬系統呼叫堆疊的行為可能非常困難。

總之,選擇迭代還是遞迴取決於特定問題的性質。在程式設計實踐中,權衡兩者的優劣並根據情境選擇合適的方法至關重要。