10日で覚えるData Structure & AlgorithmDay 5: 再帰と分割統治法

Day 5: 再帰と分割統治法

今日学ぶこと

  • 再帰の基本概念とコールスタック
  • 基底条件と再帰条件
  • 分割統治法の考え方
  • 再帰を使った実践的なアルゴリズム

再帰とは

再帰(Recursion) とは、関数が自分自身を呼び出すことです。大きな問題を同じ構造の小さな問題に分解して解きます。

flowchart TB
    subgraph Recursion["再帰の流れ"]
        F5["factorial(5)"] --> F4["5 × factorial(4)"]
        F4 --> F3["4 × factorial(3)"]
        F3 --> F2["3 × factorial(2)"]
        F2 --> F1["2 × factorial(1)"]
        F1 --> BASE["1(基底条件)"]
    end
    style Recursion fill:#3b82f6,color:#fff
    style BASE fill:#22c55e,color:#fff
function factorial(n) {
  // Base case
  if (n <= 1) return 1;
  // Recursive case
  return n * factorial(n - 1);
}

console.log(factorial(5)); // 120 (5 × 4 × 3 × 2 × 1)

再帰の2つの要素

すべての再帰関数には次の2つが必要です:

1. 基底条件(Base Case)

再帰を停止する条件です。これがないと無限ループになります。

2. 再帰条件(Recursive Case)

問題を小さくして自分自身を呼び出す部分です。

function countdown(n) {
  // Base case
  if (n <= 0) {
    console.log("Done!");
    return;
  }
  // Recursive case
  console.log(n);
  countdown(n - 1);
}

countdown(5); // 5, 4, 3, 2, 1, Done!

コールスタック

再帰呼び出しはコールスタックに積まれます。基底条件に達すると、逆順に戻り値が返されます。

flowchart TB
    subgraph CallStack["コールスタック"]
        direction TB
        S1["factorial(1) → 1"]
        S2["factorial(2) → 2 × 1"]
        S3["factorial(3) → 3 × 2"]
        S4["factorial(4) → 4 × 6"]
        S5["factorial(5) → 5 × 24"]
    end
    S5 --> S4 --> S3 --> S2 --> S1
    S1 -.->|return 1| S2
    S2 -.->|return 2| S3
    S3 -.->|return 6| S4
    S4 -.->|return 24| S5
    style CallStack fill:#8b5cf6,color:#fff

スタックオーバーフロー

再帰が深すぎるとスタックオーバーフローが発生します。

// This will crash!
function infinite() {
  return infinite(); // No base case
}
// RangeError: Maximum call stack size exceeded

再帰の基本パターン

配列の合計

function sum(arr) {
  if (arr.length === 0) return 0;
  return arr[0] + sum(arr.slice(1));
}

console.log(sum([1, 2, 3, 4, 5])); // 15

配列の反転

function reverse(str) {
  if (str.length <= 1) return str;
  return reverse(str.slice(1)) + str[0];
}

console.log(reverse("hello")); // "olleh"

べき乗の計算

// O(n) version
function power(base, exp) {
  if (exp === 0) return 1;
  return base * power(base, exp - 1);
}

// O(log n) version using divide and conquer
function fastPower(base, exp) {
  if (exp === 0) return 1;
  if (exp % 2 === 0) {
    const half = fastPower(base, exp / 2);
    return half * half;
  }
  return base * fastPower(base, exp - 1);
}

console.log(fastPower(2, 10)); // 1024

分割統治法(Divide and Conquer)

分割統治法は、問題を以下の3つのステップで解きます:

flowchart TB
    P["問題"] --> D["1. 分割(Divide)\n問題を小さく分ける"]
    D --> C["2. 統治(Conquer)\n各部分を再帰的に解く"]
    C --> M["3. 結合(Combine)\n結果を合わせる"]
    style P fill:#3b82f6,color:#fff
    style D fill:#22c55e,color:#fff
    style C fill:#f59e0b,color:#fff
    style M fill:#8b5cf6,color:#fff

例:マージソート

function mergeSort(arr) {
  // Base case
  if (arr.length <= 1) return arr;

  // Divide
  const mid = Math.floor(arr.length / 2);
  const left = arr.slice(0, mid);
  const right = arr.slice(mid);

  // Conquer
  const sortedLeft = mergeSort(left);
  const sortedRight = mergeSort(right);

  // Combine
  return merge(sortedLeft, sortedRight);
}

function merge(left, right) {
  const result = [];
  let i = 0, j = 0;

  while (i < left.length && j < right.length) {
    if (left[i] <= right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }

  return [...result, ...left.slice(i), ...right.slice(j)];
}

console.log(mergeSort([38, 27, 43, 3, 9, 82, 10]));
// [3, 9, 10, 27, 38, 43, 82]
flowchart TB
    A["[38, 27, 43, 3, 9, 82, 10]"] --> B["[38, 27, 43]"]
    A --> C["[3, 9, 82, 10]"]
    B --> D["[38]"]
    B --> E["[27, 43]"]
    E --> F["[27]"]
    E --> G["[43]"]
    C --> H["[3, 9]"]
    C --> I["[82, 10]"]
    H --> J["[3]"]
    H --> K["[9]"]
    I --> L["[82]"]
    I --> M["[10]"]
    F -.-> N["[27, 43]"]
    G -.-> N
    D -.-> O["[27, 38, 43]"]
    N -.-> O
    J -.-> P["[3, 9]"]
    K -.-> P
    L -.-> Q["[10, 82]"]
    M -.-> Q
    P -.-> R["[3, 9, 10, 82]"]
    Q -.-> R
    O -.-> S["[3, 9, 10, 27, 38, 43, 82]"]
    R -.-> S
    style A fill:#3b82f6,color:#fff
    style S fill:#22c55e,color:#fff

二分探索(分割統治法の例)

function binarySearch(arr, target, left = 0, right = arr.length - 1) {
  // Base case
  if (left > right) return -1;

  // Divide
  const mid = Math.floor((left + right) / 2);

  // Conquer
  if (arr[mid] === target) return mid;
  if (arr[mid] < target) {
    return binarySearch(arr, target, mid + 1, right);
  }
  return binarySearch(arr, target, left, mid - 1);
}

const sorted = [1, 3, 5, 7, 9, 11, 13, 15];
console.log(binarySearch(sorted, 7));  // 3
console.log(binarySearch(sorted, 6));  // -1

再帰 vs 反復

すべての再帰は反復(ループ)に書き換えることができます。

// Recursive fibonacci: O(2ⁿ)
function fibRecursive(n) {
  if (n <= 1) return n;
  return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// Iterative fibonacci: O(n)
function fibIterative(n) {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    [prev, curr] = [curr, prev + curr];
  }
  return curr;
}

// Memoized fibonacci: O(n)
function fibMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  return memo[n];
}
方法 時間計算量 空間計算量
素朴な再帰 O(2ⁿ) O(n)
メモ化再帰 O(n) O(n)
反復 O(n) O(1)

末尾再帰

末尾再帰は、再帰呼び出しが関数の最後の操作である場合です。一部の言語ではコンパイラが最適化できます。

// Not tail recursive
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1); // Multiplication after recursive call
}

// Tail recursive
function factorialTail(n, acc = 1) {
  if (n <= 1) return acc;
  return factorialTail(n - 1, n * acc); // Recursive call is the last operation
}

まとめ

概念 説明
再帰 関数が自分自身を呼び出すテクニック
基底条件 再帰を停止する条件
コールスタック 再帰呼び出しが積まれるスタック
分割統治法 分割→統治→結合の3ステップ
メモ化 計算結果をキャッシュして重複計算を回避

重要ポイント

  1. すべての再帰には基底条件と再帰条件が必要
  2. 分割統治法は問題を分割→統治→結合の3ステップで解く
  3. メモ化により指数時間の再帰を線形時間に改善できる
  4. 再帰が深すぎるとスタックオーバーフローが発生する

練習問題

問題1: 基本

再帰を使って、配列の中の最大値を見つける関数を実装してください。

問題2: 応用

再帰を使って、文字列が回文(前から読んでも後ろから読んでも同じ)かどうかを判定する関数を実装してください。

チャレンジ問題

再帰を使って、N個の要素から全ての組み合わせ(部分集合)を生成する関数を実装してください。


参考リンク


次回予告: Day 6では「ソートアルゴリズム」について学びます。主要なソートアルゴリズムの仕組みと性能を比較しましょう。