コンテンツにスキップ

第5回:再帰と分割統治法

前回コールスタックを学んだ。今回は「関数が自分自身を呼ぶ」という発想と、それを使った代表的アルゴリズムを見る。

実験1:関数が自分自身を呼ぶコード

何が出力されるか、走らせる前に予想してから実行してみよう。

def fact(n):
    if n == 0:
        return 1
    return n * fact(n - 1)

print(fact(5))

実行すると 120 が出力される。関数 fact の中で、また fact を呼んでいる。これを再帰という。

再帰関数

再帰関数: 関数の中で自分自身を呼ぶ関数。2つの部品が必要。

  • 基底ケース(base case): もう自分を呼ばずに答えを返す条件
  • 再帰ケース(recursive case): 自分自身を呼ぶ部分

どちらが欠けても壊れる

  • 基底ケースがない → 永遠に止まらない(無限再帰
  • 再帰ケースがない → ただの普通の関数

階乗(factorial)の例

def fact(n):
    if n == 0:           # 基底ケース
        return 1
    return n * fact(n - 1)   # 再帰ケース

動作:

fact(3) = 3 * fact(2)
        = 3 * 2 * fact(1)
        = 3 * 2 * 1 * fact(0)
        = 3 * 2 * 1 * 1
        = 6

再帰はコールスタックで動いている

前回学んだコールスタックそのもの。「最後に呼ばれた関数が、最初に終わる」 = LIFO。

fact(3) を呼ぶと:

push fact(3)
  push fact(2)
    push fact(1)
      push fact(0) → 1 を返す → pop
    1 を返す → pop
  2 を返す → pop
6 を返す → pop

演習5-1

fact(4) の値はいくつになるか? 計算過程を、コールスタックの動きとして書け(fact(4) = 4 * fact(3) = ... の形)。


整列されたデータから目的の値を探すアルゴリズム。

手順

  1. 真ん中の要素を見る
  2. 目的の値と比較
  3. 目的の値が小さい → 左半分だけ調べる
  4. 目的の値が大きい → 右半分だけ調べる
  5. 1.に戻る

毎回、調べる範囲が半分になる。

100 → 50 → 25 → 13 → 7 → 4 → 2 → 1

何回半分にできるか? → \(\log_2(n)\) 回。 これが「最悪ケースでもこの回数で済む」 → O(log n)

二分探索の計算量

n 線形探索 O(n) 二分探索 O(log n)
1,000 最大 1,000 最大 10
100,000 最大 100,000 最大 17
10億 最大 10億 最大 30

データが大きくなるほど差が圧倒的に開く。ただし整列されていないと使えない

補足:再帰を使った二分探索の実装

二分探索は「半分にして同じ操作を繰り返す」アルゴリズムなので、再帰で素直に書ける

def binary_search(arr, target, left, right):
    # 基底ケース:範囲が無効なら「見つからなかった」を返す
    if left > right:
        return -1

    # 真ん中のインデックスを計算
    mid = (left + right) // 2

    if arr[mid] == target:
        return mid                                    # 見つかった
    elif arr[mid] < target:
        return binary_search(arr, target, mid + 1, right)   # 右半分だけ再帰
    else:
        return binary_search(arr, target, left, mid - 1)    # 左半分だけ再帰


arr = [1, 4, 7, 10, 13, 16, 19, 22, 25]
print(binary_search(arr, 19, 0, len(arr) - 1))   # → 6(インデックス)
print(binary_search(arr, 100, 0, len(arr) - 1))  # → -1(見つからない)

// ってなに?

//切り捨て除算(floor division)。割り算の結果の小数点以下を切り捨てて整数にする。

7 / 2    # → 3.5    (普通の割り算、float になる)
7 // 2   # → 3      (切り捨て除算、int のまま)
8 // 2   # → 4
9 // 2   # → 4      (4.5 → 4 に切り捨て)

インデックスは整数でなければいけないので、mid = (left + right) // 2// を使う。/ だと mid が float になって arr[mid] がエラーになる。

再帰と分割統治の対応関係

上のコードはまさに分割統治の3ステップそのもの:

  • Divide: mid で半分に分ける
  • Conquer: 片方を再帰呼び出しで解く
  • Combine: 不要(片方しか見ないから、結果をそのまま return する)

演習5-2

配列 [1, 4, 7, 10, 13, 16, 19, 22, 25] から 19 の場所を二分探索で探す。比較する要素を順に並べよ(何回目に何を見るか)。


分割統治法(Divide and Conquer)

大きな問題を小さな部分問題に分割 → それぞれ解く統合する。

3ステップ:

  • Divide: 問題を小さく分ける
  • Conquer: 小さな問題を解く(多くの場合、再帰で)
  • Combine: 結果を組み合わせる

二分探索は分割統治の特殊例

二分探索を3ステップで見ると:

  • Divide: 真ん中で半分に分ける
  • Conquer: 片方だけ調べる(再帰的に)
  • Combine: 必要ない(片方しか見ないから)

二分探索は「片方を捨てる」シンプルな分割統治。 一般的な分割統治は「両方解いて統合する」で、これは第7回のソートで本格的に出てくる。

分割統治の威力

  • O(n) のループで解いていた問題を O(log n) や O(n log n) に圧縮できる
  • 「問題を半分にする」だけで、計算量のスケールが変わる
  • データが大きくなるほど、効いてくる

演習5-3

以下のコードは「リストの合計を求める」目的で生成AIが出力したものです。問題点として最も適切なものを選べ。

def sum_list(lst):
    return lst[0] + sum_list(lst[1:])

print(sum_list([1, 2, 3, 4, 5]))
  • A. 正しく動作するが効率が悪い
  • B. 空リストを渡すとエラーになる
  • C. 結果が間違っている
  • D. 問題ない

再帰コードを読むときの第一チェックポイント

基底ケースの有無を最初に確認する。基底ケースがないと、いつまでも自分を呼び続けてしまう。