Dev Study
← アルゴリズムの手法 — CLRS を読むための地図
レッスン 1 / 13·約 9 分·第29期 アルゴリズムの手法 — CLRS を読むための地図

正しさは「毎周守られる約束」で言う — ループ不変式

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / DB今ここ

第11期では、増え方で測る・配列とハッシュ・log・ソートの下限などを見ました。第29期はその続きで、手法ごとに「問題のどんな構造を使っているか」を13本で読みます。CLRS を読むための地図です。初回は、後の回で何度も使う道具「ループ不変式」です。

まず結論

テストが全部通っても、分かるのは「試した入力では動いた」までです。ループが正しいと言うには、各周の始めに成り立つ約束(ループ不変式)を1つ決め、①最初に成り立つ ②1周回っても保たれる ③終わったときにそれが答えを意味する、の3つで言います。

約束は assert で毎周確かめられます。範囲を1つずらすと、どの周で約束が破れるかがそのまま見えます。

? 読む前に予測

挿入ソートのループの開始を 1 から 2 に変えました(本文のコードなら range(1, n + 1) の 1 を 2 に。2個目の要素を差し込む周を飛ばす)。値が 0〜50・長さ 2〜30 の乱数列 1,000 本を入れると、毎周の約束を確かめる assert で落ちるのは何本くらいでしょう。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

テストが言えるのは、試した入力のことだけ

ループを含む関数にテストを20個書き、全部通ったとします。分かるのはその20個の入力では正しい答えが出たことです。21個目は分かりません。

そこで、入力の数ではなくループの形で正しさを言います。道具は、ループの各周の始めに成り立つと決めた約束(ループ不変式)です。確かめるのは次の3つです。

  1. 最初に成り立つ(1周目に入る前)
  2. 1周回っても保たれる(周の始めに成り立てば、次の周の始めにも成り立つ)
  3. 終わったとき、それが答えを意味する

1と2がそろえば、何周回っても約束は破れません。3があれば、終わった時点の約束がそのまま「正しい答え」になります。1は再帰の基底条件と同じ役回りです。

挿入ソートの約束「先頭 i 個は、元の先頭 i 個を並べたもの」
① i = 1先頭1個は並んでいる(自明)
1周: a[i] を先頭 i 個の正しい位置へ差し込む
② i → i+1先頭 i+1 個が並んでいる
i = n でループが終わる
③ i = n先頭 n 個 = 全体が並んでいる
  • ・①と②で「何周でも保たれる」、③で「それが答え」
  • ・テストの本数は、この3つのどれも示さない
具体実物

約束を assert にすると、破れる周が見える

約束は Python の assert でそのまま書けます。各周の始めに置き、最後の確認は終了時(i = n)に行います。

def insertion_sort(a):
    orig = list(a)
    n = len(a)
    for i in range(1, n + 1):
        assert a[:i] == sorted(orig[:i]), f"i={i}"   # 約束
        if i == n:
            break                                    # ③ 終了時の確認
        x = a[i]
        j = i - 1
        while j >= 0 and a[j] > x:
            a[j + 1] = a[j]
            j -= 1
        a[j + 1] = x
    return a

このままの版と、わざと1つずらした版を走らせた結果です(Python 3.14.7 / Linux aarch64、Docker の python:3.14-slim、2026-10-07)。

$ python3 exp1_invariant.py(抜粋)コマンド出力
assert passed on 1000 random lists (len 0..30, values 0..50)
input [5, 3, 8, 1] -> fails at i=1: a=[5, 3, 8, 1] a[:i+1]=[5, 3] sorted=[3, 5]
input [1, 2, 3, 0] -> fails at i=3: a=[1, 2, 3, 0] a[:i+1]=[1, 2, 3, 0] sorted=[0, 1, 2, 3]
input [1, 2, 3, 4] -> no failure (already sorted prefix)
range start 2 -> fails at first check i=2: a[:i]=[5, 3] sorted=[3, 5]
range(2, n) start: failed 499 of 1000 random lists

正しい約束は、長さ 0〜30 の乱数列 1,000 本ですべて通りました。 確かめる範囲を a[:i+1] に広げる(まだ差し込んでいない要素まで並んでいると主張する)と、[5, 3, 8, 1] は最初の周 i=1 で落ちます。ところが [1, 2, 3, 0] は i=3 まで通り、並び済みの [1, 2, 3, 4] では一度も落ちません。

ループの開始を 1 から 2 にずらす(上のコードなら range(1, n + 1) の 1 を 2 に)と、[5, 3, 8, 1] は最初の確認 i=2 で落ちます。落ちるのは先頭2個が逆順のときだけで、長さ 2〜30 の別の 1,000 本(上の 1,000 本とは別の乱数列)では 499 本が落ちました(値が 0〜50 の一様乱数なら理論上の割合は 25/51 ≈ 0.49。499 はこの1回の乱数での値です)。壊れたコードが約半分のテストを通るということです。

✓ ここまでの確認

約束を「先頭 i+1 個が並んでいる」に変えた assert は、[5, 3, 8, 1] では i=1 で落ち、[1, 2, 3, 0] では i=3 まで通りました。ここから読み取れることはどれですか。

抽象原理

約束は、ループの外へ出たとき事後条件になる

3の「終わったときに答えを意味する」は、関数の事後条件(終了時に成り立つと約束する条件)につながります。ループ不変式は、事後条件をループの途中まで引き延ばしたものと読めます。

この考え方には2本の古典があります。Floyd 1967 は冒頭で、流れ図の各接続に命題を結びつけ、その接続を通るたびに成り立つと主張する、という枠組みを示しています。

an association of a proposition with each connection in the flow of control through a program, where the proposition is asserted to hold whenever that connection is taken.

Hoare 1969 はこれをプログラムの文面に置き直し、繰り返しを Rule of Iteration として扱います。

Suppose P to be an assertion which is always true on completion of S, provided that it is also true on initiation. Then obviously P will still be true after any number of iterations of the statement S (even no iterations).

誰が最初かは言い切れません。 どちらも先行する考えに由来すると書いています。Hoare は「The treatment given below is essentially due to Floyd [8] but is applied to texts rather than flowcharts.」と書き、Floyd は自分の証明の型について「not original; they are based on ideas of Perlis and Gorn」と書いています。Hoare 1969 の6ページを目視した範囲では invariant という語は見当たりません。

具体実物

bisect は事後条件を docstring に書いている

第11期『半分にすると、なぜ20回で済むのか』では、二分探索を「値ではなく境目を探すもの」として見ました。その境目を、CPython は事後条件として書いています。Lib/bisect.py(3.14 ブランチ)の bisect_left の docstring です。

The return value i is such that all e in a[:i] have e < x, and all e in a[i:] have e >= x. So if x already appears in the list, a.insert(i, x) will insert just before the leftmost x already there.

HTML の公式文書は同じことを別の書き方で載せています。

all(elem < x for elem in a[lo : ip]) is true for the left slice and all(elem >= x for elem in a[ip : hi]) is true for the right slice.

同じファイルの Python 版のループです(実行時は、ファイル末尾の from _bisect import * で C 実装に置き換わります)。

CPython 3.14 Lib/bisect.py — bisect_left(key 無しの枝)設定ファイル
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < x:
lo = mid + 1
else:
hi = mid
return lo

ここでの約束は、本教材の読みとして「a[:lo] はすべて x 未満、a[hi:] はすべて x 以上」です。最初は両側が空なので成り立ちます。どちらの枝も、確かめた側へだけ lo か hi を動かすので保たれます。終了時は lo == hi で、docstring の事後条件そのものになります。

✓ ここまでの確認

CPython の bisect_left のループ(lo と hi を縮める)が毎周守っていて、終了時に docstring の事後条件そのものになる約束はどれですか。

⇄ 2つを見比べる
具体例 A

挿入ソート

約束は「先頭 i 個は、元の先頭 i 個を並べたもの」。i=1 では自明で、1周で1個ずつ差し込んで保ち、i=n で全体が並ぶ。assert で毎周確かめられ、range を1つずらすと落ちる周が見える。

具体例 B

bisect_left

約束は「a[:lo] はすべて x 未満、a[hi:] はすべて x 以上」。最初は両側が空で、1周で lo か hi を確かめた側へ動かして保ち、lo == hi で docstring の事後条件になる。

並べ替えと探索。していることは違いますが、正しさの言い方として共通している形は何でしょうか。

それぞれの約束について「i や lo・hi が最初の値のとき」「1周後」「ループを抜けたとき」に何が言えるかを並べてみてください。

この章のまとめ

  • テストが示すのは試した入力のことだけ。 range を1つずらした挿入ソートは、乱数列 1,000 本のうち 501 本を通った。
  • ループの正しさは不変式で言う。 ①最初に成り立つ ②1周で保たれる ③終了時に答えを意味する。
  • 約束は assert で毎周確かめられる。 ずらすと破れる周が見え、その周は入力によって変わる。
  • 不変式は事後条件をループの途中まで引き延ばしたもの。 CPython の bisect_left は事後条件を docstring に書いている。
  • 誰が最初かは言い切らない。 Hoare 1969 は Floyd 由来と書き、Floyd 1967 は Perlis と Gorn の考えに基づくと書いている。
  • 既存クイズでは、「アルゴリズムとデータ構造 III(発展編)」の algorithm-3-20(ループ不変条件)、「アルゴリズムとデータ構造」の algorithm-7(①に当たる再帰の基底条件)がこの章の結論の一行版です。
  • CLRS(『アルゴリズムイントロダクション』)の目次や索引で loop invariant(ループ不変式)を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

ループ不変式でループの正しさを言うとき、確かめる3つの組はどれですか。

用語集(3語)
ループ不変式(loop invariant)
ループの各周の始めに成り立つと決めた約束。最初に成り立ち、1周で保たれ、終了時に答えを意味することを示すと、ループの正しさが言える。
事後条件
処理が終わったときに成り立つと約束する条件。bisect_left の docstring の all e in a[:i] have e < x, and all e in a[i:] have e >= x がその例。
assert
条件が偽なら AssertionError で止める Python の文。不変式を毎周確かめる道具になる。証明ではなく、実行した入力についての検査。