foundations · Stage 1

アルゴリズム選択を計算量と測定で検証する

漸近的な予測と再現可能な実測を分け、入力分布に合うアルゴリズムを選ぶ。

学習時間
240分
難易度
foundation
更新日
2026-07-30
到達証拠
成果物・説明・判断根拠・転用

到達目標

  1. 入力サイズを2倍にしたときの操作回数の増え方を、三つの候補アルゴリズムについて予測できる

    • 仮説、入力生成、実行条件、生データ、要約統計、差の説明を含む報告
    • 実測の逆転を複数仮説と追加測定で診断する回答
  2. ウォームアップ、反復、中央値、入力生成の分離を含む再現可能なベンチマークを実行できる

    • 仮説、入力生成、実行条件、生データ、要約統計、差の説明を含む報告
    • Big-Oと絶対時間の違いをグラフなしでも説明できる5分説明
  3. 予測と実測の差を入力分布、定数項、処理系の観測から説明し、別分布でアルゴリズムを再選択できる

    • 実測の逆転を複数仮説と追加測定で診断する回答
    • 偏りと重複率が変化した未知データに対する再選択

能力の進行

  1. recognize

    入力サイズ、データ分布、比較対象、測定環境をベンチマークの独立した条件として列挙できる

    証拠: 仮説、入力生成、実行条件、生データ、要約統計、差の説明を含む報告

  2. explain

    漸近的上界が成長率を表し、特定マシンの秒数を表さない理由を具体例で説明できる

    証拠: Big-Oと絶対時間の違いをグラフなしでも説明できる5分説明

  3. apply

    同一入力を使った反復測定から中央値とばらつきを報告し、計算量予測と照合できる

    証拠: 仮説、入力生成、実行条件、生データ、要約統計、差の説明を含む報告

  4. diagnose

    予測と実測が逆転したとき、入力生成、キャッシュ、GC、外れ値を追加測定で切り分けられる

    証拠: 実測の逆転を複数仮説と追加測定で診断する回答

  5. lead

    本番データの分布変化を監視条件へ変換し、アルゴリズム再選択のレビューを主導できる

    証拠: 偏りと重複率が変化した未知データに対する再選択

なぜ重要か

アルゴリズムの選択は、教科書の計算量だけでも、手元の一回の速さだけでも決められない。Big-Oは漸近上界であり、入力が十分大きい範囲で関数が定数倍の上限を超えないことを表す。同じ次数で上下から抑える厳密な成長率はΘで表し、特定のCPUで何秒かかるかを表すものではない。一方、実測値はそのコード、入力分布、処理系、同時負荷に拘束される。

誤った選択は、小さいテストでは見えず、本番データの偏りや増加で急に顕在化する。シニアは、操作回数の予測、再現可能な測定、差を説明する仮説を一つの報告に接続し、分布が変わったら選び直せるようにする。

メンタルモデル

予測と測定を二本の独立した鎖として作り、最後に照合する。予測を見て測定値を捨てたり、測定値を見て都合のよい計算量を後付けしたりしない。

集合を使う探索は、一回だけなら通常のhashを仮定しても期待Θ(n)の構築と参照の両方を負担する。同じ集合へ多数回問い合わせるなら構築費用を償却できる。この「何回使うか」はnと別の独立変数である。

アルゴリズム再選択の検証経路

注記

図を読む際の補足情報です。

  1. 操作モデル: 比較、hash計算、割り当てなど、支配的な操作を決める。
  2. 成長率予測: 支配操作がΘ(n)ならnを2倍にしたとき約2倍、Θ(n²)なら約4倍と予測する。
  3. 入力モデル: サイズだけでなく、順序、重複率、問い合わせ位置を固定する。
  4. 測定設計: 準備と対象区間を分け、ウォームアップ後に複数回測る。
  5. 統計要約: 全値、中央値、範囲を残し、除外規則を先に決める。
  6. 差の診断: 定数項、キャッシュ、GC、処理系、外れ値を追加測定で反証する。
  7. 再選択条件: 本番のn、分布、呼出回数の変化を監視する。

同じquery列への構築済みlookupで、入力特性・size・setup・space・query回数が選択をどう変えるか。

選択肢の比較
項目best case最も有利な入力配置での支配操作。average case明示した入力分布の期待操作回数。worst case最も不利な入力と衝突条件の上界。setup / build costworked exampleで分離測定するset構築または整列の費用。space cost追加indexやcopyに必要なmemory。query count / crossoversetup費用をlookup短縮で償却できる環境固有のquery回数。
線形走査順序なし配列を先頭から探索する候補。Θ(1): 先頭で一致Θ(n): 平均で約n/2比較Θ(n): 末尾または不在追加構築なしΘ(1)追加space Θ(1)少数queryではbuild費用がないため候補
二分探索整列済み配列を半分ずつ絞る候補。Θ(1): 中央で一致Θ(log n): 整列済み入力Θ(log n): 不在でも半減未整列ならsort Θ(n log n)sort実装とcopy方針に依存sort費用を複数queryで償却
hash lookuphash tableを構築してkeyを探索する候補。Θ(1): 衝突なし期待Θ(1): hash分布を仮定Θ(n): 全keyが衝突set構築Θ(n)をlookupと分離測定set tableの追加space Θ(n)set_build中央値 ÷ 1 query当たり短縮; query回数8と比較

best・average・worst caseを区別し、予測と反復実測の差から再選択条件を説明できる。

パラメータと選択肢
パラメータ選択肢既定値
入力sizen=1000、n=10000、n=100000small
algorithm family線形走査、二分探索、hash lookuplinear
  1. n=1000・線形走査: 理論操作数 n=1000: 線形1000・二分10・hash期待1。測定sampleは7反復のmedian_ns/range_nsを別欄へ記録し、普遍的な時間にしない。; 条件 algorithm-family=linearinput-size=small; node linear-scan; edge なし
  2. n=1000・二分探索: 理論操作数 n=1000: 線形1000・二分10・hash期待1。未整列ならsort費用を測定sampleと分離する。; 条件 algorithm-family=binaryinput-size=small; node binary-search; edge なし
  3. n=1000・hash: 理論操作数 n=1000: 線形1000・二分10・hash期待1。buildとlookupのmedian_ns/range_nsを分ける。; 条件 algorithm-family=hashinput-size=small; node hash-lookup; edge なし
  4. n=10000・線形走査: 理論操作数 n=10000: 線形10000・二分14・hash期待1。測定sampleの増加率を理論値へ後付けしない。; 条件 algorithm-family=linearinput-size=crossover-size; node linear-scan; edge なし
  5. n=10000・二分探索: 理論操作数 n=10000: 線形10000・二分14・hash期待1。sort費用をquery回数で償却できるか測る。; 条件 algorithm-family=binaryinput-size=crossover-size; node binary-search; edge なし
  6. n=10000・hash: 理論操作数 n=10000: 線形10000・二分14・hash期待1。build中央値とlookup短縮の交差条件を算出する。; 条件 algorithm-family=hashinput-size=crossover-size; node hash-lookup; edge なし
  7. n=100000・線形走査: 理論操作数 n=100000: 線形100000・二分17・hash期待1。median_ns/range_nsとの差を追加測定で診断する。; 条件 algorithm-family=linearinput-size=large; node linear-scan; edge なし
  8. n=100000・二分探索: 理論操作数 n=100000: 線形100000・二分17・hash期待1。整列条件とsetupの測定sampleを別々に残す。; 条件 algorithm-family=binaryinput-size=large; node binary-search; edge なし
  9. n=100000・hash: 理論操作数 n=100000: 線形100000・二分17・hash期待1。衝突とmemoryを測り、環境依存時間を普遍化しない。; 条件 algorithm-family=hashinput-size=large; node hash-lookup; edge なし
完全な遷移
イベント開始終了条件
観測結果
結果状態
小入力ではsetup不要という候補を実測で確認する。small-input
小入力でも整列条件とsetupを分離する。small-binary
小入力でもbuildと追加spaceを比較する。small-hash
線形走査側の交差点測定を残す。crossover-linear
sort費用を含む二分探索の交差点を残す。crossover
hash buildを償却するquery回数を残す。crossover-hash
線形成長と測定環境の差を診断する。large-linear
対数成長と整列費用を別々に報告する。large-binary
期待計算量とworst caseを区別して報告する。large-input

現在の状態: n=1000・線形走査 — 理論操作数 n=1000: 線形1000・二分10・hash期待1。測定sampleは7反復のmedian_ns/range_nsを別欄へ記録し、普遍的な時間にしない。

このモデルは例示的かつ決定的であり、実システムの完全な再現ではありません。

動く例で考える

実行可能な線形探索と集合参照の比較

python3.13 - <<'PY' > benchmark-results.json
import hashlib
import json
import platform
import random
import statistics
import time

HARNESS = "algorithm_lab_v2"
SEED = 20260731
SIZES = [1000, 10000, 100000]
DISTRIBUTIONS = ["unique", "duplicate_90", "front_biased"]
REPEAT = 7
QUERY_COUNT = 8

def make_data(distribution, size):
    if distribution == "duplicate_90":
        duplicate_count = size * 9 // 10
        tail = list(range(size, size + size - duplicate_count))
        data = [7] * duplicate_count + tail
        random.Random(SEED + size).shuffle(data)
        return data
    return list(range(size))

def make_queries(distribution, size):
    if distribution == "front_biased":
        return [index % max(1, size // 100) for index in range(QUERY_COUNT)]
    if distribution == "duplicate_90":
        return [7, size // 20, 7, size // 30, 7, size // 40, 7, -1]
    generator = random.Random(SEED + size)
    return [
        generator.randrange(size)
        for _ in range(QUERY_COUNT - 1)
    ] + [-1]

def timed(operation):
    start = time.perf_counter_ns()
    value = operation()
    return time.perf_counter_ns() - start, value

def summarize(samples):
    return {
        "median": statistics.median(samples),
        "minimum": min(samples),
        "maximum": max(samples),
    }

conditions = []
for distribution in DISTRIBUTIONS:
    for size in SIZES:
        data = make_data(distribution, size)
        queries = make_queries(distribution, size)
        lookup_set = set(data)
        samples = {
            "generation": [],
            "set_build": [],
            "linear_lookup": [],
            "set_lookup": [],
        }
        expected = [query in data for query in queries]
        for repeat in range(7):
            elapsed, generated = timed(
                lambda d=distribution, n=size: make_data(d, n)
            )
            assert len(generated) == size
            samples["generation"].append(elapsed)
            elapsed, built = timed(lambda values=data: set(values))
            assert built == lookup_set
            samples["set_build"].append(elapsed)
            elapsed, observed = timed(
                lambda values=data, qs=queries: [
                    query in values for query in qs
                ]
            )
            assert observed == expected
            samples["linear_lookup"].append(elapsed)
            elapsed, observed = timed(
                lambda values=lookup_set, qs=queries: [
                    query in values for query in qs
                ]
            )
            assert observed == expected
            samples["set_lookup"].append(elapsed)
        summaries = {
            name: summarize(values)
            for name, values in samples.items()
        }
        linear_per_query = (
            summaries["linear_lookup"]["median"] / len(queries)
        )
        set_per_query = summaries["set_lookup"]["median"] / len(queries)
        advantage = linear_per_query - set_per_query
        crossover = (
            summaries["set_build"]["median"] / advantage
            if advantage > 0
            else 0
        )
        conditions.append({
            "distribution": distribution,
            "size": size,
            "repeat": REPEAT,
            "query_count": len(queries),
            "query_digest": hashlib.sha256(
                json.dumps(queries).encode("utf-8")
            ).hexdigest(),
            "samples_ns": samples,
            "median_ns": {
                name: values["median"]
                for name, values in summaries.items()
            },
            "range_ns": {
                name: [values["minimum"], values["maximum"]]
                for name, values in summaries.items()
            },
            "crossover_queries": max(0, round(crossover, 2)),
            "distribution_facts": (
                {
                    "dominant_value_fraction": (
                        data.count(7) / len(data)
                    ),
                }
                if distribution == "duplicate_90"
                else {
                    "front_query_fraction": sum(
                        query in range(max(1, size // 100))
                        for query in queries
                    ) / len(queries),
                }
                if distribution == "front_biased"
                else {
                    "unique_fraction": len(set(data)) / len(data),
                }
            ),
        })

growth_rates = []
for distribution in DISTRIBUTIONS:
    selected = [
        item for item in conditions
        if item["distribution"] == distribution
    ]
    for previous, current in zip(selected, selected[1:]):
        growth_rates.append({
            "distribution": distribution,
            "from_size": previous["size"],
            "to_size": current["size"],
            "linear_lookup_ratio": round(
                current["median_ns"]["linear_lookup"]
                / max(1, previous["median_ns"]["linear_lookup"]),
                3,
            ),
            "predicted_linear_ratio": (
                current["size"] / previous["size"]
            ),
        })

probe_data = make_data("unique", 10000)
probe_queries = make_queries("unique", 10000)
prebuilt = set(probe_data)
prebuilt_samples = []
rebuild_samples = []
for repeat in range(7):
    elapsed, _ = timed(
        lambda: [query in prebuilt for query in probe_queries]
    )
    prebuilt_samples.append(elapsed)
    elapsed, _ = timed(
        lambda: [
            query in set(probe_data)
            for query in probe_queries
        ]
    )
    rebuild_samples.append(elapsed)
additional_measurement = {
    "hypothesis": "集合参照そのものが構築込み費用を支配する",
    "prebuilt_lookup_samples_ns": prebuilt_samples,
    "rebuild_per_query_samples_ns": rebuild_samples,
    "falsified": (
        statistics.median(rebuild_samples)
        > statistics.median(prebuilt_samples) * 2
    ),
}

print(json.dumps({
    "harness": HARNESS,
    "seed": SEED,
    "sizes": SIZES,
    "distributions": DISTRIBUTIONS,
    "query_contract": "各condition内で両方式へ同じquery列を使用",
    "conditions": conditions,
    "growth_rates": growth_rates,
    "hypotheses": [
        "入力増加時の非線形差はcache階層の境界で説明できる",
        "少数queryではset構築費が参照短縮を上回る",
    ],
    "additional_measurement": additional_measurement,
    "python": platform.python_version(),
    "platform": platform.platform(),
}, ensure_ascii=False, indent=2))
PY
python3.13 -m json.tool benchmark-results.json
前提
同じPython 3.13実行系、固定seed 20260731、他負荷を抑えた環境で各条件をrepeat=7に固定する。
入力
重複なし、90%同値、探索対象が先頭1%寄りの三分布を、1000、10000、100000要素で生成する。各condition内では同じquery列をlistとsetへ渡す。
操作
入力生成、set構築、list参照、構築済みset参照を分離し、各区間の全7反復値、中央値、範囲をJSONへ保存する。
観測
実行結果には入力を10倍にしたときの線形参照の実測増加率、理論上の予測率10、set構築を償却できるquery回数の交差点が全九条件について出る。
結論
二仮説を宣言し、追加測定で「集合参照そのものが構築込み費用を支配する」を反証する。交差点と増加率は環境固有なので、自分の全値から再計算する。

nを1000、10000、100000へ10倍ずつ増やす。線形探索の時間比が概ね10倍なら予測と整合する。比が急に大きくなっても、直ちに計算量が変わったとは言えない。キャッシュ階層やGCの変化を追加測定する。

ラボ成果物: 「計算量予測と実測値の差を説明するベンチマーク報告」には、実行コマンド、全反復値、Python版、OS、CPU、入力生成、除外規則を残す。

トレードオフと失敗モード

問い合わせ処理のdecision table
条件 候補 支払う費用 再評価信号
小さい列へ一回だけ問い合わせ 線形探索 問い合わせごとの比較 nまたは問い合わせ回数が交差点を超える
同じ集合へ多数回問い合わせ hash集合 構築、追加メモリ、hash品質 更新頻度またはメモリ圧力が上がる
順序付き範囲検索も必要 整列列と二分探索 整列または更新時の維持費用 更新が参照より支配的になる

もっともらしい誤診と反証

  1. 誤診: 「支配操作がΘ(n log n)の実装がΘ(n²)の実装より遅いので、解析が誤り」。反証: nを段階的に増やして時間比と交差点を測る。小さいnの定数項による逆転なら、増加率は解析と整合する。
  2. 誤診: 「一回だけ20倍遅いのでアルゴリズムが不安定」。反証: 同じ入力でGC、他プロセス負荷、ページフォールト、CPU周波数を記録する。外れ値を消す前に原因と事前規則を確認する。

平均だけでは裾の遅さを隠し、最小値だけでは実運用の競合を隠す。ベンチマーク専用環境の値は機構比較に役立つが、本番容量計画には同時負荷を含む別測定が要る。

知識チェック

  1. nを2倍にして時間が4倍なら支配操作がΘ(n²)と確定できるか。できない。一範囲の比は仮説を支持するだけで、入力生成や階層変化も反証する必要がある。
  2. timeitの最小値だけを採用すべきか。最小値は干渉の少ない実行を示し得るが、本番の代表値とは限らない。目的に応じて全値と中央値も報告する。
  3. 平均O(1)のhash参照なら分布を無視できるか。できない。hash衝突、構築、メモリ局所性、更新頻度が選択を変える。

5分teach-back

一枚の表で、漸近上界Big-O、厳密な次数Θ、絶対時間、構築費用、問い合わせ回数を区別する。n=10000の実測が逆転した理由を二仮説で説明し、次に変える独立変数を一つ宣言する。

未知へのtransfer

データ分布が変わる場合のアルゴリズム再選択を行う。90%が同じkeyになるログ、ほぼ整列済みの入力、更新が参照より多い入力を与え、元の勝者を自動採用せず、交差点を再測定する。

出典と次の学習

NIST DADSはBig-OとΘの区別、SPEC CPU 2017のrulesは比較可能な実行と報告の規律、Python timeitは短いコード片の反復測定、Princeton COS226の資料はQuicksortの分布依存性を確認するために使う。本文では一次資料を要約し、URLはlesson metadataのsourcesだけに置く。

次はCPU・メモリ経路を学び、同じ計算量でも実測が変わる機構を掘り下げる。復習では1日後に予測式、7日後に別分布、30日後に本番相当入力、90日後に再選択条件を再検証する。

実践ラボ

線形探索と集合参照の交差点を測る

提出成果物: 計算量予測と実測値の差を説明するベンチマーク報告

  1. 重複なし、90パーセント同一値、探索対象が先頭寄りの三分布を固定seedで生成する
  2. 入力サイズ1000、10000、100000について線形探索と集合構築後の参照を同じ問い合わせ列で測る
  3. 入力生成と集合構築を参照時間から分離し、各条件を7回以上反復して中央値と範囲を残す
  4. 計算量から予測した増加率と実測の比を並べ、差を説明する仮説を二つ立てる
  5. 追加測定で仮説を一つ反証し、どの問い合わせ回数で選択が逆転するか報告する

説明して理解を確かめる

5分で、Big-Oが絶対時間ではないこと、入力分布と定数項が選択を変えること、再現に必要な実行条件を説明する。

アセスメント

  1. 問い: 支配操作がΘ(n log n)の実装がΘ(n²)の実装よりn=200で遅かった。直ちに後者を採用できない理由と追加測定を示す。

    期待する証拠: 交差点、入力分布、定数項、ウォームアップ、処理系、反復統計を分けた仮説と測定計画

  2. 問い: 7回の結果のうち1回だけ20倍遅い。削除してよいかを判断する前に何を観測するか。

    期待する証拠: GC、他プロセス負荷、ページフォールト、入力差を確認し、除外規則を事前定義する説明

別問題へ転用する

データ分布が変わる場合のアルゴリズム再選択

復習スケジュール

  1. 1日後

    Big-Oから予測できることと予測できないことを一例ずつ示す

  2. 7日後

    入力生成を測定区間へ含めると、どの結論が変わり得るか

  3. 30日後

    本番分布のどの変化を再選択のトリガーにするか

  4. 90日後

    Big-Oから予測できることと予測できないことを一例ずつ示す

評価ルーブリック

4段階の評価基準
観点未達発展途上熟達卓越
technical-correctnessBig-Oを実行秒数として扱うか、構築費用を参照費用から落としている成長率は正しいが、平均時と最悪時または償却計算量を区別できない操作モデル、成長率、構築費用、問い合わせ回数を整合する式で説明する分布依存の期待値と病的入力を分け、実装上の定数項まで予測に反映する
judgment最速だった一条件だけで本番採用を決める複数サイズを比較するが、本番分布と保守費用を判断に含めない本番のサイズ、分布、呼出回数、メモリ制約から交差点を示して選ぶ分布変化の監視閾値とロールバック可能な再選択手順まで設計する
evidence単発の所要時間だけで、コード、入力、環境を再現できない反復値はあるが、入力生成やウォームアップが測定に混ざる固定入力、分離した区間、7回以上の値、中央値と範囲を提示する外れ値の扱いを事前定義し、複数サイズの比と環境差でも結論を検証する
communication速い遅いの結論だけで、比較条件と単位が分からない表は読めるが、仮説と結論の対応または測定限界が曖昧である予測、手順、生データ、要約、差の説明、推奨を追跡できる非専門家にも成長率と絶対時間を混同させず、再測定条件を伝えられる

出典

以下の外部資料は利用者が選択したときだけ開きます。