Skip to content

第 5 章 再帰とソートアルゴリズム比較

はじめに

再帰は関数型言語の根幹をなす概念であり、ソートアルゴリズムは実装スタイルの違いが最も顕著に現れる題材です。本章では、再帰の表現力とソートの実装アプローチを 14 言語で比較します。

再帰の表現力比較

階乗(factorial)の実装

最もシンプルな再帰の例として、階乗の実装を比較します。

命令型言語でもシンプル:

# Python
def factorial(n):
    if n <= 1: return 1
    return n * factorial(n - 1)
// Java
static int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

パターンマッチで直感的:

// F#
let rec factorial = function
    | 0 | 1 -> 1
    | n -> n * factorial (n - 1)
-- Haskell
factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)
# Elixir
def factorial(0), do: 1
def factorial(n), do: n * factorial(n - 1)
;; Clojure
(defn factorial [n]
  (if (<= n 1) 1
    (* n (factorial (dec n)))))

末尾再帰最適化(TCO)

末尾再帰最適化は、再帰をループと同等の効率で実行する仕組みです。

言語 TCO サポート 備考
Python なし 再帰深度制限あり(デフォルト 1000)
TypeScript なし V8 エンジンが未対応
Java なし JVM が未対応
C# 限定的 .NET JIT が一部最適化
Ruby 手動設定 RubyVM::InstructionSequence で有効化可能
PHP なし
Go なし ゴルーチンで代替
C コンパイラ依存 GCC/Clang が -O2 以上で最適化
Rust なし(保証なし) LLVM が最適化する場合あり
F# あり rec キーワードで明示
Scala あり @tailrec アノテーションで検証
Clojure recur で明示 recur 特殊形式で末尾再帰
Elixir あり BEAM VM がネイティブサポート
Haskell あり 遅延評価と組み合わせて最適化

末尾再帰の書き方比較

Scala(@tailrec で検証):

@tailrec
def factorial(n: Int, acc: Int = 1): Int =
  if (n <= 1) acc
  else factorial(n - 1, n * acc)

Clojure(recur で明示):

(defn factorial [n]
  (loop [i n acc 1]
    (if (<= i 1) acc
      (recur (dec i) (* acc i)))))

Elixir(アキュムレータパターン):

def factorial(n), do: factorial(n, 1)
defp factorial(0, acc), do: acc
defp factorial(n, acc), do: factorial(n - 1, n * acc)

ソートアルゴリズムの実装スタイル

バブルソートの 14 言語比較

破壊的ソート(in-place)

配列を直接変更するアプローチ:

Python:

def bubble_sort(a):
    n = len(a)
    for i in range(n - 1):
        for j in range(n - 1, i, -1):
            if a[j - 1] > a[j]:
                a[j - 1], a[j] = a[j], a[j - 1]

Java:

static void bubbleSort(int[] a) {
    for (int i = 0; i < a.length - 1; i++)
        for (int j = a.length - 1; j > i; j--)
            if (a[j - 1] > a[j]) {
                int tmp = a[j - 1]; a[j - 1] = a[j]; a[j] = tmp;
            }
}

Rust:

pub fn bubble_sort(a: &mut Vec<i32>) {
    let n = a.len();
    for i in 0..n - 1 {
        for j in (i + 1..n).rev() {
            if a[j - 1] > a[j] { a.swap(j - 1, j); }
        }
    }
}

非破壊的ソート(新しいリストを返す)

元のデータを変更せず、ソート済みの新しいリストを返すアプローチ:

Haskell:

bubbleSort :: Ord a => [a] -> [a]
bubbleSort [] = []
bubbleSort xs = let (sorted, rest) = bubble xs
                in last sorted : bubbleSort (init sorted)
  where
    bubble [x] = ([x], [])
    bubble (x:y:xs)
      | x > y    = let (s, r) = bubble (x:xs) in (y:s, r)
      | otherwise = let (s, r) = bubble (y:xs) in (x:s, r)

Clojure:

(defn bubble-sort [coll]
  (let [n (count coll)]
    (loop [a (vec coll) i 0]
      (if (>= i (dec n)) a
        (recur (reduce (fn [v j]
                 (if (> (v (dec j)) (v j))
                   (assoc v (dec j) (v j) j (v (dec j)))
                   v))
               a (range (dec n) i -1))
               (inc i))))))

ソート実装スタイル分類

スタイル 言語 特徴
破壊的(配列直接操作) Python, TS, Java, C#, Ruby, PHP, Go, C 元の配列を変更。メモリ効率が良い
破壊的(&mut) Rust 可変参照を明示。安全性を保証
破壊的(IORef/mutable) F#, Haskell IO 内で可変配列を操作
非破壊的(新リスト) Clojure, Elixir 元のデータは不変。新しいリストを返す

クイックソートの比較

クイックソートは、言語のパラダイムの違いが最も顕著に現れるアルゴリズムです。

Python(命令型):

def quick_sort(a, left, right):
    pl, pr = left, right
    pivot = a[(left + right) // 2]
    while pl <= pr:
        while a[pl] < pivot: pl += 1
        while a[pr] > pivot: pr -= 1
        if pl <= pr:
            a[pl], a[pr] = a[pr], a[pl]
            pl += 1; pr -= 1
    if left < pr: quick_sort(a, left, pr)
    if pl < right: quick_sort(a, pl, right)

Haskell(関数型 — リスト内包表記):

quickSort :: Ord a => [a] -> [a]
quickSort [] = []
quickSort (x:xs) =
  quickSort [y | y <- xs, y <= x] ++ [x] ++ quickSort [y | y <- xs, y > x]

Scala(関数型):

def quickSort(list: List[Int]): List[Int] = list match
  case Nil => Nil
  case pivot :: tail =>
    val (less, greater) = tail.partition(_ <= pivot)
    quickSort(less) ::: pivot :: quickSort(greater)

Elixir(パターンマッチ + パイプ):

def quick_sort([]), do: []
def quick_sort([pivot | rest]) do
  {less, greater} = Enum.split_with(rest, &(&1 <= pivot))
  quick_sort(less) ++ [pivot] ++ quick_sort(greater)
end

クイックソートの表現力スペクトラム

命令型(長い)                                    関数型(短い)
 ←─────────────────────────────────────────────────→
 C    Java   Python   Rust   Scala   Elixir   Haskell
      Go     C#       F#     Clojure

Haskell のクイックソートは 3 行 で表現でき、アルゴリズムの本質(ピボット選択 → 分割 → 再帰)が直接コードに反映されています。

ソートアルゴリズムの計算量

全言語で共通の計算量ですが、定数係数は言語の実行モデルにより異なります。

アルゴリズム 最良 平均 最悪 安定性
バブルソート O(n) O(n^2) O(n^2) 安定
選択ソート O(n^2) O(n^2) O(n^2) 不安定
挿入ソート O(n) O(n^2) O(n^2) 安定
シェルソート O(n log n) O(n^1.3) O(n^2) 不安定
クイックソート O(n log n) O(n log n) O(n^2) 不安定
マージソート O(n log n) O(n log n) O(n log n) 安定
ヒープソート O(n log n) O(n log n) O(n log n) 不安定
度数ソート O(n + k) O(n + k) O(n + k) 安定

まとめ

  • 再帰: 関数型言語はパターンマッチで直感的に書ける。命令型言語でも再帰は使えるが、TCO が保証されないことが多い
  • 末尾再帰: F#, Scala, Elixir, Haskell は TCO を保証。Clojure は recur で明示
  • ソートの破壊的/非破壊的: OOP 言語は in-place ソートが基本。関数型言語は新しいリストを返す
  • クイックソート: 関数型言語のリスト内包表記やパターンマッチにより、アルゴリズムの本質を最も簡潔に表現できる
  • 言語選択の指針: アルゴリズムの可読性を重視するなら関数型、パフォーマンスを重視するなら C/Rust/Go