目次

「このコードは遅い」と言われたとき、どれくらい遅いか、どう改善できるかを論理的に説明できるだろうか。O記法(ビッグオー記法)は計算量を抽象的に表現する道具で、アルゴリズムの比較と選択の共通言語だ。

O記法とは何か

O記法は「入力サイズ n が増えたとき、処理時間(または空間)がどのくらいの割合で増えるか」を表す。定数倍や低次の項は無視して、増加の「傾向」だけを捉える。

具体的な実行時間: 3n² + 5n + 100 ms
O記法:           O(n²)
             ↑ 最も影響が大きい項のみ残す

なぜ定数を無視するか: nが大きくなれば主要な項が支配的になり、定数は誤差範囲に収まるから。

主要な計算量クラス

O(1)——定数時間

入力サイズに関係なく、常に一定の時間で完了する。

# 配列の要素へのアクセス
def get_element(arr, index):
    return arr[index]  # O(1): インデックスで直接アクセス

# 辞書の検索
def get_user(users_dict, user_id):
    return users_dict.get(user_id)  # O(1): ハッシュテーブルで直接アクセス

# スタックの push/pop
from collections import deque
stack = deque()
stack.append(1)  # O(1)
stack.pop()      # O(1)

O(log n)——対数時間

入力が2倍になるたびに処理ステップが1増える。非常に効率的。

def binary_search(sorted_arr, target):
    """二分探索: O(log n)"""
    left, right = 0, len(sorted_arr) - 1

    while left <= right:
        mid = (left + right) // 2
        if sorted_arr[mid] == target:
            return mid
        elif sorted_arr[mid] < target:
            left = mid + 1   # 右半分を探索
        else:
            right = mid - 1  # 左半分を探索

    return -1

# n=1000万でも最大23回の比較で見つかる(log₂(10,000,000) ≈ 23)
arr = list(range(10_000_000))
idx = binary_search(arr, 9_999_999)
print(idx)  # 9999999

O(n)——線形時間

入力に比例して処理時間が増える。配列を一度だけ走査するとき。

def find_max(arr):
    """最大値を線形探索: O(n)"""
    if not arr:
        return None
    max_val = arr[0]
    for x in arr:  # n 回のループ
        if x > max_val:
            max_val = x
    return max_val

def has_duplicate(arr):
    """重複チェック: O(n)(ハッシュセット使用)"""
    seen = set()
    for x in arr:
        if x in seen:
            return True
        seen.add(x)
    return False

# ❌ O(n²) の重複チェック(避けるべき)
def has_duplicate_slow(arr):
    for i in range(len(arr)):
        for j in range(i + 1, len(arr)):
            if arr[i] == arr[j]:
                return True
    return False

O(n log n)——線形対数時間

効率的なソートアルゴリズムがここに入る。

# Python の組み込みソート: Timsort = O(n log n)
arr = [5, 2, 8, 1, 9, 3]
arr.sort()

# マージソートで O(n log n) の仕組みを理解する
def merge_sort(arr):
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left = merge_sort(arr[:mid])   # O(log n) 回の分割
    right = merge_sort(arr[mid:])

    return merge(left, right)      # 各レベルで O(n) のマージ

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

O(n²)——二乗時間

ネストしたループが典型例。入力が2倍になると処理時間が4倍になる。

def bubble_sort(arr):
    """バブルソート: O(n²)"""
    n = len(arr)
    for i in range(n):           # n 回
        for j in range(n - i - 1):  # 最大 n 回
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr

# n=10,000 で実行時間を比較
import time

arr = list(range(10000, 0, -1))  # 最悪ケース(逆順)

start = time.time()
sorted(arr)  # O(n log n): Timsort
print(f"Timsort: {time.time() - start:.4f}秒")  # 約0.001秒

arr2 = list(range(1000, 0, -1))  # n=1000 でのみ試す(n=10000は遅すぎる)
start = time.time()
bubble_sort(arr2)
print(f"バブルソート(n=1000): {time.time() - start:.4f}秒")  # 約0.1秒

O(2ⁿ)——指数時間

入力が1増えるたびに処理量が2倍になる。n が30程度でも数十億ステップになる。

def fib_naive(n):
    """素朴なフィボナッチ: O(2ⁿ)"""
    if n <= 1:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

# fib_naive(30): 約270万回の呼び出し
# fib_naive(50): 約1000億回(現実的でない)

# メモ化でO(n)に改善
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n):
    """メモ化フィボナッチ: O(n)"""
    if n <= 1:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

# または動的プログラミング
def fib_dp(n):
    """動的プログラミング: O(n) 時間・O(1) 空間"""
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

計算量の比較

n=1,000,000 での大まかな処理数:

O記法処理数実感
O(1)1即時
O(log n)20即時
O(n)1,000,000数ms
O(n log n)20,000,000数十ms
O(n²)1,000,000,000,000事実上不可能
O(2ⁿ)10^300,000宇宙年齢でも終わらない

空間計算量

時間だけでなく、使用メモリ量(空間計算量)も重要だ。

# O(1) 空間: 追加メモリを使わない
def sum_array(arr):
    total = 0  # 定数個の変数のみ
    for x in arr:
        total += x
    return total

# O(n) 空間: 入力と同サイズのメモリを使う
def copy_array(arr):
    return list(arr)  # n 個の要素をコピー

# 時間と空間のトレードオフ
def fib_dp_time_optimized(n):
    # O(n) 時間・O(1) 空間(前の2値のみ保持)
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

def fib_dp_precomputed(max_n):
    # O(n) 時間・O(n) 空間(全値をキャッシュ)
    dp = [0] * (max_n + 1)
    dp[1] = 1
    for i in range(2, max_n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp  # 全値に O(1) でアクセス可能

実践:N+1問題

データベースアクセスでよく起きる計算量の問題がN+1問題だ。

# ❌ N+1問題: O(n) のDBクエリ
def get_users_with_orders_slow(user_ids):
    users = db.query("SELECT * FROM users WHERE id IN ?", user_ids)  # 1クエリ

    for user in users:
        # ユーザーごとに1クエリ → N ユーザーで N+1 クエリ
        user.orders = db.query("SELECT * FROM orders WHERE user_id = ?", user.id)

    return users

# ✅ 一括取得: O(1) クエリ(データ量はO(n)だが)
def get_users_with_orders_fast(user_ids):
    users = db.query("SELECT * FROM users WHERE id IN ?", user_ids)
    # 全ユーザーのIDを使って注文を一括取得
    orders = db.query(
        "SELECT * FROM orders WHERE user_id IN ?",
        [u.id for u in users]
    )

    # Pythonでインメモリ結合
    orders_by_user = {}
    for order in orders:
        orders_by_user.setdefault(order.user_id, []).append(order)

    for user in users:
        user.orders = orders_by_user.get(user.id, [])

    return users

計算量改善のチェックリスト

□ ネストしたループを発見したら: O(n²) → O(n) に改善できないか?
  → ハッシュマップで外側ループの内側を O(1) に
  → ソート + 二分探索で O(n²) → O(n log n) に

□ ソートが必要か確認:
  → 「最小/最大」だけなら O(n) の線形探索で十分
  → ソートは O(n log n) のコストがかかる

□ 繰り返し同じ計算をしていないか:
  → メモ化(lru_cache)やDP表で O(n) に削減

□ 適切なデータ構造を選べているか:
  → 検索多い: dict/set(O(1))vs list(O(n))
  → ソート済みを前提: heapq(O(log n))

まとめ

O記法はコードのパフォーマンスを語る共通言語だ。「このコードはO(n²)だから、n=10万で問題になる」という判断ができるようになることが目標だ。

  • O(1): 定数時間——ハッシュマップ・配列インデックス
  • O(log n): 対数時間——二分探索・平衡二分木
  • O(n): 線形時間——線形探索・ハッシュセットによる重複確認
  • O(n log n): 線形対数時間——効率的なソート(Timsort・マージソート)
  • O(n²): 二乗時間——ネストループ・バブルソート(n > 10万で問題)
  • O(2ⁿ): 指数時間——ほぼ使えない。DP・メモ化で改善必須
  • N+1問題: DBアクセスパターンで頻発するO(n)クエリ問題

「計算量を把握してからコードを書く」習慣が身につくと、後からリファクタリングが必要になる場面を大幅に減らせる。

免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。