From b70304676853930df4d015e3e0a5a57913196c87 Mon Sep 17 00:00:00 2001 From: Yuto729 Date: Sun, 2 Aug 2026 17:03:51 +0900 Subject: [PATCH 1/2] add docs --- ...60\343\203\242\343\203\207\343\203\253.md" | 1 + ...64\343\203\252\343\202\272\343\203\240.md" | 885 ++++++++++++++++++ ...66\343\201\256\350\250\255\350\250\210.md" | 389 ++++++++ ...21\343\202\277\343\203\274\343\203\263.md" | 1 + 4 files changed, 1276 insertions(+) create mode 100644 "notes/\343\202\275\343\203\274\343\203\210\343\202\242\343\203\253\343\202\264\343\203\252\343\202\272\343\203\240.md" create mode 100644 "notes/\344\270\215\345\244\211\346\235\241\344\273\266\343\201\250\345\242\203\347\225\214\346\235\241\344\273\266\343\201\256\350\250\255\350\250\210.md" diff --git "a/notes/Python\343\201\256\345\244\211\346\225\260\343\203\242\343\203\207\343\203\253.md" "b/notes/Python\343\201\256\345\244\211\346\225\260\343\203\242\343\203\207\343\203\253.md" index 952beeb..2be3c92 100644 --- "a/notes/Python\343\201\256\345\244\211\346\225\260\343\203\242\343\203\207\343\203\253.md" +++ "b/notes/Python\343\201\256\345\244\211\346\225\260\343\203\242\343\203\207\343\203\253.md" @@ -5,6 +5,7 @@ tags: - 言語仕様 - 名前束縛 - メモリモデル + - 復習 --- Pythonの変数モデルについて整理する。 diff --git "a/notes/\343\202\275\343\203\274\343\203\210\343\202\242\343\203\253\343\202\264\343\203\252\343\202\272\343\203\240.md" "b/notes/\343\202\275\343\203\274\343\203\210\343\202\242\343\203\253\343\202\264\343\203\252\343\202\272\343\203\240.md" new file mode 100644 index 0000000..e182f17 --- /dev/null +++ "b/notes/\343\202\275\343\203\274\343\203\210\343\202\242\343\203\253\343\202\264\343\203\252\343\202\272\343\203\240.md" @@ -0,0 +1,885 @@ +--- +tags: + - Leetcode + - ソート + - アルゴリズム + - 計算量 + - Python + - 復習 +--- + +# ソートアルゴリズム + +各アルゴリズムについて「手順」「不変条件」を日本語で述べてからコードを書く。 +境界の取り方の一般論は [[不変条件と境界条件の設計]] にまとめた。 + +--- + +## 0. 前提:ソートを評価する5つの軸 + +アルゴリズムを比べるときは、計算量だけでなく次の軸で見る。面接でトレードオフを聞かれたときに答えるのはこの5つ。 + +| 軸 | 意味 | +| --- | --- | +| 時間計算量 | 最良 / 平均 / 最悪。**最悪が効くかはユースケース次第**(レイテンシ SLO があるなら最悪が支配的) | +| 空間計算量 | 追加で使うメモリ。再帰スタックも数える | +| 安定性 (stable) | 等しいキーの元の相対順序が保たれるか | +| in-place | 追加領域が O(1) または O(log n) か | +| adaptive | すでにほぼ整列済みの入力で速くなるか | + +### 比較ソートの下界 + +比較のみで並べ替えるアルゴリズムは、**必ず Ω(n log n)** の比較を要する。 + +証明の骨子:入力の並べ替えは n! 通りあり、比較1回の結果は2通りなので、アルゴリズムは深さ d の二分決定木とみなせる。すべての並べ替えを区別するには葉が n! 個以上必要なので 2^d ≥ n!、すなわち d ≥ log₂(n!)。スターリングの近似 log₂(n!) = Θ(n log n) より従う。 + +したがって、後述のカウンティングソートやバケットソートが O(n) を達成できるのは、**比較をしていない**(値そのものを添字に使っている)からであり、下界に矛盾しない。 + +--- + +## 1. バブルソート (Bubble Sort) + +### 手順 + +隣り合う2要素を先頭から順に比較し、逆順なら交換する。これを配列の端まで行うと、最大の要素が末尾まで「泡のように」浮き上がって最終位置に確定する。確定した末尾を除いて同じことを繰り返す。 + +**1周まわして一度も交換が起きなかったら、すでにソート済みなので打ち切ってよい。** この早期終了があるかないかで、ほぼ整列済みの入力に対する挙動が O(n) と O(n²) に分かれる(adaptive かどうかの分岐点)。 + +### 不変条件 + +外側ループの各時点で、`end` を未確定領域の排他的終端として: + +- `nums[end:]` はソート済みで、かつ**全体の最大の (n - end) 個**が最終位置に入っている +- `nums[:end]` は未確定 + +内側ループの各時点で、`i` まで走査したとき: + +- `nums[i]` は `nums[:i + 1]` の最大値(最大値を右へ押し出しながら運んでいる) + +### コード + +```python +def bubble_sort(nums: list[int]) -> None: + for end in range(len(nums), 1, -1): + # nums[end:] は確定済み + swapped = False + for i in range(end - 1): + if nums[i] > nums[i + 1]: + nums[i], nums[i + 1] = nums[i + 1], nums[i] + swapped = True + if not swapped: + return # 1周して交換がなければ整列済み +``` + +### 計算量 + +| | | +| --- | --- | +| 時間(最良) | **O(n)** — 整列済み入力。早期終了により1周で抜ける | +| 時間(平均・最悪) | **O(n²)** — 比較回数は n(n-1)/2 | +| 交換回数 | 最悪 n(n-1)/2。**転倒数(inversion 数)に等しい** | +| 空間 | O(1) | +| 安定 | ✅ 狭義の `>` で比較する限り、等しい要素を交換しないため | +| in-place | ✅ | +| adaptive | ✅(早期終了ありの場合) | + +交換回数が転倒数に等しいのは重要な性質で、「隣接交換のみで整列するには転倒数回の交換が必要」という下界にちょうど一致する。つまり**隣接交換しか許されないなら最適**。逆に言えば、遠くへ一気に飛ばせないことが O(n²) の原因。 + +### 実務での位置づけ + +**使わない。** 教育用途と、「配列がほぼ整列していることが保証され、n が極小(10未満程度)」という限定状況のみ。同じ O(n²) なら後述の挿入ソートが全面的に優れている。 + +--- + +## 2. 選択ソート (Selection Sort) + +### 手順 + +未確定領域の中から最小値を探し、未確定領域の先頭と交換する。これで先頭1個が最終位置に確定する。確定領域を1つ伸ばして繰り返す。 + +バブルソートが「最大を右へ運ぶ」のに対し、選択ソートは「最小を探して1回だけ交換する」。 + +### 不変条件 + +`start` を未確定領域の先頭(=確定領域の排他的終端)として: + +- `nums[:start]` はソート済みで、かつ**全体の最小の start 個**が最終位置に入っている +- `nums[start:]` は未確定で、そのすべての要素は `nums[:start]` のどの要素以上 + +2つめの条件が効いていて、これがあるので確定領域を後から触る必要がない。 + +### コード + +```python +def selection_sort(nums: list[int]) -> None: + for start in range(len(nums) - 1): + # nums[:start] は確定済み + min_index = start + for i in range(start + 1, len(nums)): + if nums[i] < nums[min_index]: + min_index = i + nums[start], nums[min_index] = nums[min_index], nums[start] +``` + +### よくある変種と、その落とし穴 + +「最小値の位置を覚えず、見つけるたびに交換する」版は選択ソートの亜種(exchange sort)になる。 + +```python +# 亜種:交換回数が増え、安定性も失う +for i in range(len(nums)): + for j in range(i + 1, len(nums)): + if nums[j] < nums[i]: + nums[i], nums[j] = nums[j], nums[i] +``` + +正しく動くが、交換回数が O(1) 回/ループ から O(n) 回/ループ に増える。**「バブルソートで書きました」と言ってこれを書くと、名前と実装が一致していない**ので注意(バブルソートは隣接要素しか交換しない)。 + +### 計算量 + +| | | +| --- | --- | +| 時間(最良・平均・最悪すべて) | **O(n²)** — 入力によらず必ず n(n-1)/2 回の比較 | +| 交換回数 | **O(n)** — 高々 n-1 回。全ソート中でも最小クラス | +| 空間 | O(1) | +| 安定 | ❌ 離れた位置と交換するため。`[2a, 2b, 1]` → `[1, 2b, 2a]` | +| in-place | ✅ | +| adaptive | ❌ 整列済みでも O(n²) | + +### 実務での位置づけ + +**交換コストが比較コストより桁違いに高いとき**にのみ意味がある。たとえば要素が巨大な構造体で、比較はキーだけ見れば済むが交換は全体のコピーになる場合。それ以外は挿入ソートに劣る。 + +--- + +## 3. 挿入ソート (Insertion Sort) + +O(n²) 族の中では実用上ダントツで、**クイックソートやマージソートの小配列用サブルーチンとして実際に組み込まれている**(Timsort、introsort の両方)。ここは覚える価値がある。 + +### 手順 + +トランプを手札に並べる操作と同じ。左から `i` 番目の要素を取り出し、すでに整列済みの左側の中で正しい位置まで**右にずらしながら**戻していく。 + +交換(swap)ではなく**シフト**で書くのが定石。swap は3回の代入だが、シフトなら1回で済むため定数倍が小さくなる。 + +### 不変条件 + +- `nums[:i]` はソート済みで、**元の `nums[:i]` の要素の並べ替えになっている**(選択ソートと違い、「全体の最小 i 個」ではない) +- 内側ループでは `nums[j + 1:i + 1]` が `current` より真に大きい要素で埋まっており、`nums[j]` は `current` を入れる候補位置 + +「元の i 個の並べ替え」という弱い不変条件で済むのが、この後の要素を先読みしなくてよい理由。 + +### コード + +```python +def insertion_sort(nums: list[int]) -> None: + for i in range(1, len(nums)): + # nums[:i] はソート済み + current = nums[i] + j = i + while j > 0 and nums[j - 1] > current: + nums[j] = nums[j - 1] # 交換ではなくシフト + j -= 1 + nums[j] = current +``` + +`nums[j - 1] > current` が**狭義**であることが安定性の要。`>=` にすると等しい要素を追い越してしまい安定でなくなる。 + +### 計算量 + +| | | +| --- | --- | +| 時間(最良) | **O(n)** — 整列済み入力では内側 while が毎回0回 | +| 時間(平均・最悪) | **O(n²)** | +| 時間(詳細) | **O(n + d)**(d は転倒数)。ほぼ整列済みなら d が小さく、ほぼ線形 | +| 空間 | O(1) | +| 安定 | ✅ | +| in-place | ✅ | +| adaptive | ✅ **強く adaptive**。これが最大の武器 | + +### なぜ小配列で挿入ソートが選ばれるか + +漸近的には O(n²) で負けているのに、n が小さい領域(およそ 10〜60)で O(n log n) 系より速い。理由は3つ。 + +1. **定数倍が極端に小さい** — 再帰呼び出し、pivot 選択、境界計算などのオーバーヘッドが一切ない +2. **メモリアクセスが完全に連続** — キャッシュラインに乗り、プリフェッチが効く +3. **分岐予測が当たりやすい** — 内側 while はほとんどの場合すぐ抜ける + +Timsort の minrun(32〜64要素の走(run)を挿入ソートで作る)も、introsort の閾値も、この性質を使っている([[Timsort]])。 + +--- + +## 4. クイックソート (Quicksort) + +**平均 O(n log n) で、定数倍が全ソート中で最小級**。in-place かつキャッシュ効率が良い。代償は最悪 O(n²) と不安定性。 + +### 4-1. 骨格(分割統治) + +pivot を1つ選び、配列を「pivot 以下の群」と「pivot 以上の群」に分ける。分けた両側を再帰的にソートすると、**併合作業なしで**全体がソートされる。 + +マージソートとの対比が本質: + +- マージソート … 分けるのは簡単(真ん中で切る)、**併合が仕事** +- クイックソート … **分けるのが仕事**、併合は不要 + +### 4-2. 素朴な再帰版(in-place でない) + +まずこれを書けるようにする。可読性が高く、面接で「まず素直に書きます」と言って出すのに適する。 + +**手順**:pivot を1つ選び、リスト内包表記で3つに分ける。`less` と `greater` を再帰的にソートし、`equal` を挟んで連結する。 + +**不変条件**:`quicksort(xs)` は「`xs` の要素をすべて含み、昇順に並んだ**新しい**リスト」を返す。呼び出し前後で `xs` は変化しない。 + +```python +def quicksort(nums: list[int]) -> list[int]: + if len(nums) <= 1: + return nums + pivot = nums[len(nums) // 2] + less = [x for x in nums if x < pivot] + equal = [x for x in nums if x == pivot] + greater = [x for x in nums if x > pivot] + return quicksort(less) + equal + quicksort(greater) +``` + +- `equal` を独立させているので、**全要素が等しい配列でも1回で終わる**(`less` と `greater` が空になる) +- 空間計算量は **O(n log n)**(各段でリストを作り直す)。厳密には in-place でないので「クイックソート」を名乗るには不十分だが、考え方の骨は同じ +- 3回走査しているので `pivot` との比較が3n回。1回の走査で3分割すれば n 回で済む + +### 4-3. in-place 版と分割 (partition) + +以降、区間は原則 **半開区間 `[lo, hi)`** で書く(理由は [[不変条件と境界条件の設計]])。 + +`partition` が満たすべき**事後条件**はただ一つ: + +> `nums[lo:m]` のどの要素も `nums[m:hi]` のどの要素以下である + +これさえ成り立てば左右を独立にソートしてよい。加えて、停止性のために **`lo < m < hi`(両側が真に短くなる)** が必要。ここを外すと無限再帰する。**partition 系のバグの大半はここ**。 + +```python +def quicksort(nums: list[int], lo: int, hi: int) -> None: + """nums[lo:hi] をソートする""" + if hi - lo <= 1: + return + m = partition(nums, lo, hi) + quicksort(nums, lo, m) + quicksort(nums, m, hi) +``` + +### 4-4. Lomuto 分割 + +**手順**:末尾を pivot とする。左から順に走査し、pivot より小さい要素を見つけたら「小さい群」の直後の位置と交換して、群を1つ伸ばす。走査が終わったら pivot を群の直後に置く。これで pivot が最終位置に確定する。 + +**不変条件**(半開区間版): + +- `nums[lo:less_end]` … すべて `< pivot` +- `nums[less_end:i]` … すべて `>= pivot` +- `nums[i:hi - 1]` … 未確定 +- `nums[hi - 1]` … pivot 本体(動かさず置いてある) + +```python +def partition(nums: list[int], lo: int, hi: int) -> int: + pivot = nums[hi - 1] # インデックスでなく「値」を持つ。要素が動くため + less_end = lo + for i in range(lo, hi - 1): + if nums[i] < pivot: + nums[i], nums[less_end] = nums[less_end], nums[i] + less_end += 1 + nums[less_end], nums[hi - 1] = nums[hi - 1], nums[less_end] + return less_end # pivot の最終位置 +``` + +Lomuto は **pivot の最終位置が確定する**ので、再帰は `[lo, m)` と `[m + 1, hi)` になる(`m` を飛ばす)。骨格と1箇所違う点に注意。 + +**閉区間版**(CLRS の形。Lomuto は末尾 pivot なので閉区間とも相性が良い): + +```python +def quicksort(nums, lo, hi): + """nums[lo:hi + 1] をソート""" + if hi <= lo: + return + pivot_index = partition(nums, lo, hi) + quicksort(nums, lo, pivot_index - 1) + quicksort(nums, pivot_index + 1, hi) + + +def partition(nums, lo, hi): + pivot = nums[hi] + less_end = lo - 1 # 閉区間ゆえの番兵。空集合を表す + # nums[lo:less_end + 1] <= pivot + # nums[less_end + 1:i] > pivot + for i in range(lo, hi): + if nums[i] <= pivot: + less_end += 1 + nums[i], nums[less_end] = nums[less_end], nums[i] + nums[less_end + 1], nums[hi] = nums[hi], nums[less_end + 1] + return less_end + 1 +``` + +閉区間にすると `lo - 1` という**配列外を指す番兵**が要り、`less_end + 1` が3箇所に現れる。半開区間版と見比べると差がわかる。 + +なお `<` と `<=` はどちらでも正しく動くが、**不変条件のコメントと必ず一致させる**こと。性能は変わらない(等しい要素が左に全部寄るか右に全部寄るかの違いで、偏り方は同じ)。 + +### 4-5. Hoare 分割 + +Hoare のオリジナル。**実用実装はほぼこちら**。 + +**手順**:左端と右端から中央に向かって歩く。左ポインタは pivot 以上の要素で止まり、右ポインタは pivot 以下の要素で止まる。両方が止まったら、それは「互いに相手側にいるべき2要素」なので交換する。すれ違ったら終了。 + +**不変条件**: + +- `nums[lo:i + 1]` … すべて `<= pivot` +- `nums[j:hi]` … すべて `>= pivot` +- `nums[i + 1:j]` … 未確定 + +```python +def partition(nums: list[int], lo: int, hi: int) -> int: + pivot = nums[(lo + hi - 1) // 2] # 中央付近。値をコピーする + i, j = lo - 1, hi + while True: + i += 1 + while nums[i] < pivot: + i += 1 + j -= 1 + while nums[j] > pivot: + j -= 1 + if i >= j: + return j + 1 # 半開区間の分割点 + nums[i], nums[j] = nums[j], nums[i] +``` + +- 内側の while に範囲チェックがないが、**pivot が配列内に実在する値なので、それ自体が番兵として働き**必ず止まる +- pivot の最終位置は確定しないので、再帰は `[lo, m)` と `[m, hi)`。**Lomuto の癖で `m + 1` にすると要素が消える** +- pivot に中央付近を選ぶことが停止性に効く。末尾を選ぶと `lo < m < hi` が崩れて無限再帰する入力が作れる + +**Lomuto より速い理由**は交換回数。Lomuto は `< pivot` の要素をすべて必ず交換するが、Hoare は「左右で場所が入れ替わるべきペア」だけを交換するので、平均で約 1/3 になる。また等しい要素で両ポインタが止まって交換するため、**重複の多い入力でも分割が均等に保たれる**。 + +### 4-6. 3-way 分割(Dutch National Flag / Bentley–McIlroy) + +`<` / `==` / `>` の3群に分ける。**`== pivot` の群は最終位置が確定しているので再帰に渡さない。** + +これは [[sort-colors/main|Sort Colors]] の解法そのもの(pivot を 1 に固定した1段目)。 + +**不変条件**: + +- `nums[lo:less_end]` … `< pivot` +- `nums[less_end:i]` … `== pivot` +- `nums[i:greater_start]` … 未確定 +- `nums[greater_start:hi]` … `> pivot` + +**手順の要点**:`nums[i]` を pivot と比べて3分岐する。`>` のときは右端から未確定要素を受け取ることになるので **`i` を進めてはいけない**。`<` のときの交換相手 `nums[less_end]` は「`== pivot` 領域の先頭」=判定済みの値なので、進めてよい。 + +```python +import random + +def quicksort_3way(nums: list[int], lo: int, hi: int) -> None: + if hi - lo <= 1: + return + pivot = nums[random.randrange(lo, hi)] + less_end = i = lo + greater_start = hi + while i < greater_start: + if nums[i] > pivot: + greater_start -= 1 + nums[i], nums[greater_start] = nums[greater_start], nums[i] + continue # 受け取った値は未判定なので i を進めない + if nums[i] < pivot: + nums[i], nums[less_end] = nums[less_end], nums[i] + less_end += 1 + i += 1 + quicksort_3way(nums, lo, less_end) + quicksort_3way(nums, greater_start, hi) +``` + +**全要素が等しい配列で O(n)**。2分割版だと O(n²) に落ちるので、重複が多いデータでは決定的な差になる。 + +### 4-7. pivot の選び方(最重要) + +固定位置(先頭・末尾)を pivot にすると、**ソート済み入力・逆順入力**で毎回 1 対 n-1 に割れて O(n²)、しかも再帰が n 段積まれてスタックが溢れる。実データはソート済みに近いことが多いので、これは「まれな最悪ケース」ではなく**日常的に踏む**。 + +対策: + +- **乱択** … 期待計算量 O(n log n) を保証。アルゴリズムを知っている攻撃者が最悪ケース入力を送りつける **algorithmic complexity attack** への対策にもなる +- **median-of-three** … 先頭・中央・末尾の中央値。乱数不要で決定的、実データに強い +- **median-of-medians** … 最悪 O(n) で近似中央値を求める。理論上は最悪 O(n log n) を達成できるが定数倍が重く実用されない + +### 4-8. 再帰の深さを O(log n) に抑える + +素直に書くと再帰の深さは最悪 O(n)。**短いほうを再帰、長いほうをループ**にすると必ず O(log n) に収まる(各再帰で区間が半分以下になるため)。 + +```python +def quicksort(nums, lo, hi): + while hi - lo > 1: + m = partition(nums, lo, hi) + if m - lo < hi - m: + quicksort(nums, lo, m) # 短いほうだけ再帰 + lo = m + else: + quicksort(nums, m, hi) + hi = m +``` + +これは末尾再帰の除去([[再帰]])の応用。 + +### 4-9. introsort + +再帰の深さが `2 log n` を超えたらヒープソートに切り替え、小配列は挿入ソートに切り替えるハイブリッド。**平均の速さを保ちつつ最悪 O(n log n) を保証**する。C++ の `std::sort` がこれ。 + +### 4-10. quickselect(k 番目を求める) + +partition を使うと、ソートせずに k 番目の要素だけを **平均 O(n)** で取り出せる。分割後、k が入っている側だけを再帰(=ループ)すればよい。 + +等比級数 n + n/2 + n/4 + … = 2n から O(n)。最悪は O(n²)(乱択なら期待 O(n))。 + +```python +def quickselect(nums: list[int], k: int) -> int: + """k 番目に小さい要素(0-indexed)。nums は破壊される""" + lo, hi = 0, len(nums) + while True: + m = partition(nums, lo, hi) # Lomuto(pivot 位置が確定する版) + if m == k: + return nums[m] + if m < k: + lo = m + 1 + else: + hi = m +``` + +「上位 k 個」を求める問題で、ヒープ O(n log k) との比較として出てくる。 + +### クイックソートの計算量まとめ + +| | | +| --- | --- | +| 時間(最良・平均) | **O(n log n)** — 分割が均等なら深さ log n × 各段 O(n) | +| 時間(最悪) | **O(n²)** — 毎回 1 対 n-1 に割れる場合 | +| 空間 | **O(log n)**(再帰スタック。短いほう再帰の工夫が前提)。素朴だと最悪 O(n) | +| 安定 | ❌ 離れた位置と交換するため | +| in-place | ✅ | +| adaptive | ❌(3-way 分割は重複に対しては適応的) | + +--- + +## 5. マージソート (Merge Sort) + +**最悪でも O(n log n) を保証**し、**安定**。代償は O(n) の追加領域。 + +### 5-1. トップダウン再帰版 + +**手順**:真ん中で2つに割り、それぞれを再帰的にソートし、2つのソート済み列を併合する。 + +**併合の不変条件**:`merged` は `left[:i] + right[:j]` の要素をすべて含み昇順に並んでいる。かつ `left[i:]` と `right[j:]` のどの要素も `merged` の全要素以上。 + +**安定性の要**:`left[i] <= right[j]` のときに **left を先に取る**こと。ここを `<` にして else 側で left を取ると、等しい要素で right が先に出て安定でなくなる。 + +```python +def merge_sort(nums: list[int]) -> list[int]: + if len(nums) <= 1: + return nums + mid = len(nums) // 2 + return merge(merge_sort(nums[:mid]), merge_sort(nums[mid:])) + + +def merge(left: list[int], right: list[int]) -> list[int]: + merged = [] + i = j = 0 + while i < len(left) and j < len(right): + if right[j] < left[i]: # 狭義。等しいときは left を優先=安定 + merged.append(right[j]) + j += 1 + else: + merged.append(left[i]) + i += 1 + merged.extend(left[i:]) # 残りはどちらか一方のみ + merged.extend(right[j:]) + return merged +``` + +この `merge` は [[merge-two-sorted-list/main|Merge Two Sorted Lists]] の中身とまったく同じ。 + +### 5-2. バッファを使い回す in-place 風の版 + +上の版は再帰の各段で `nums[:mid]` のスライスコピーが走り、**合計 O(n log n) の確保**が発生する。作業用配列を1本だけ確保して使い回すと **O(n)** に落ちる。 + +**不変条件**:`merge_sort(lo, hi)` の呼び出し後、`nums[lo:hi]` はソート済み。`buffer` の内容は呼び出しをまたいで保持されない(毎回上書きしてよい)。 + +```python +def merge_sort(nums: list[int]) -> None: + buffer = [0] * len(nums) + + def sort(lo: int, hi: int) -> None: + if hi - lo <= 1: + return + mid = (lo + hi) // 2 + sort(lo, mid) + sort(mid, hi) + if nums[mid - 1] <= nums[mid]: + return # すでに整列済みなら併合不要(adaptive にする工夫) + merge(lo, mid, hi) + + def merge(lo: int, mid: int, hi: int) -> None: + buffer[lo:hi] = nums[lo:hi] + i, j = lo, mid + for k in range(lo, hi): + if i >= mid: + nums[k] = buffer[j]; j += 1 + elif j >= hi: + nums[k] = buffer[i]; i += 1 + elif buffer[j] < buffer[i]: + nums[k] = buffer[j]; j += 1 + else: + nums[k] = buffer[i]; i += 1 + + sort(0, len(nums)) +``` + +`nums[mid - 1] <= nums[mid]` の早期リターンは1行で adaptive 性を足す定番の工夫。 + +### 5-3. ボトムアップ版(非再帰) + +**手順**:幅1の区間を隣どうし併合して幅2にする。幅2を併合して幅4。これを幅が n 以上になるまで繰り返す。 + +**不変条件**:`width` 回目のループ開始時点で、`nums` は長さ `width` のソート済みブロックに分割されている(末尾のブロックのみ短いことがある)。 + +再帰を使わないのでスタックを消費せず、**連結リストのソートで O(1) 追加領域**を達成できる(`sortList` 系の問題の想定解)。 + +```python +def merge_sort_bottom_up(nums: list[int]) -> None: + n = len(nums) + width = 1 + while width < n: + for lo in range(0, n, 2 * width): + mid = min(lo + width, n) + hi = min(lo + 2 * width, n) + nums[lo:hi] = merge(nums[lo:mid], nums[mid:hi]) + width *= 2 +``` + +### 5-4. 真の in-place マージソート + +追加領域 O(1) でのマージは可能だが、ブロック交換や回転を使うため定数倍が非常に重く、実用されない。**「マージソートは O(n) 領域が要る」と覚えてよい**が、面接で「O(1) にできるか」と聞かれたら「理論上は可能だが定数倍が悪化するので通常は使わない。連結リストならポインタ付け替えで自然に O(1) になる」と答えられるとよい。 + +### 計算量 + +| | | +| --- | --- | +| 時間(最良・平均・最悪すべて) | **O(n log n)** — 入力によらない | +| 比較回数 | 最悪 n log n - n + 1。理論下界 log₂(n!) にかなり近い | +| 空間 | **O(n)**(配列)/ **O(log n)**(連結リスト、再帰スタックのみ) | +| 安定 | ✅ | +| in-place | ❌ | +| adaptive | ❌(素朴版)/ ✅(run 検出を入れた Timsort) | + +### クイックソートとの使い分け + +| | クイックソート | マージソート | +| --- | --- | --- | +| 平均速度 | **速い**(定数倍最小級) | やや遅い | +| 最悪 | O(n²) | **O(n log n) 保証** | +| 追加領域 | O(log n) | O(n) | +| 安定 | ❌ | ✅ | +| キャッシュ | **良い**(局所的に走査) | 良い(順次アクセス) | +| 並列化 | 分割が不均等 | **しやすい** | +| 外部ソート(データがメモリに載らない) | 不向き | **これ一択**(順次読みだけで済む) | + +C++ `std::sort` が introsort(クイック系)、`std::stable_sort` がマージ系、Python `sorted` が Timsort(マージ系)なのは、**安定性を言語の仕様として約束するかどうか**の違いが大きい。 + +--- + +## 6. ヒープソート (Heapsort) + +**最悪 O(n log n) かつ O(1) 追加領域**という、比較ソートの中で唯一この2つを両立するアルゴリズム。 + +### 手順 + +1. 配列全体を**最大ヒープ**にする(heapify) +2. 根(最大値)と末尾を交換する。末尾は最終位置に確定 +3. ヒープのサイズを1減らし、根を sift down してヒープ条件を回復する +4. 2〜3 を繰り返す + +配列を暗黙の完全二分木として見る。ノード `i` の子は `2i + 1`, `2i + 2`。 + +### 不変条件 + +- `nums[end:]` … ソート済みで、**全体の最大の (n - end) 個**が最終位置にある +- `nums[:end]` … 最大ヒープ条件を満たす(どの親も子以上) +- `nums[:end]` のどの要素も `nums[end:]` のどの要素以下 + +`sift_down` の不変条件:`root` を根とする部分木は、`root` 自身を除いてヒープ条件を満たしている。 + +### コード + +```python +def heap_sort(nums: list[int]) -> None: + n = len(nums) + for start in range(n // 2 - 1, -1, -1): # 葉は sift down 不要 + sift_down(nums, start, n) + for end in range(n - 1, 0, -1): + nums[0], nums[end] = nums[end], nums[0] + sift_down(nums, 0, end) + + +def sift_down(nums: list[int], root: int, end: int) -> None: + while True: + child = 2 * root + 1 + if child >= end: + return + if child + 1 < end and nums[child + 1] > nums[child]: + child += 1 # 大きいほうの子を選ぶ + if nums[root] >= nums[child]: + return + nums[root], nums[child] = nums[child], nums[root] + root = child +``` + +### heapify が O(n) である理由 + +各ノードの sift down コストは「そのノードの高さ」に比例する。高さ h のノードは高々 n / 2^(h+1) 個なので、総和は + +Σ_{h=0}^{log n} (n / 2^(h+1)) · h = n · Σ h/2^(h+1) ≤ n · 1 = **O(n)** + +(Σ_{h≥0} h/2^h = 2 に収束する)。「1個ずつ push すると O(n log n)、まとめて heapify すると O(n)」は面接でよく聞かれる。 + +### 計算量 + +| | | +| --- | --- | +| 時間(すべて) | **O(n log n)** — heapify O(n) + 取り出し n 回 × O(log n) | +| 空間 | **O(1)** | +| 安定 | ❌ | +| in-place | ✅ | +| adaptive | ❌ 整列済みでも O(n log n) | + +### なぜ最強に見えるのに使われないか + +最悪保証も領域も優れているのに実測ではクイックソートに負ける。理由は**メモリアクセスがキャッシュに極めて不親切**なこと。`i` → `2i + 1` と飛ぶので、配列が大きいと毎回キャッシュミスする。クイックソートやマージソートが連続アクセス中心なのと対照的([[主なLatency]])。 + +その代わり **introsort の最後の砦**として、クイックソートが劣化したときのフォールバックに使われる。また、ソートではなく**優先度付きキュー**としての用途が本命で、こちらは [[kth-largest-element-in-a-stream/main|Kth Largest Element in a Stream]]、[[find-k-pairs-with-smallest-sums/main|Find K Pairs with Smallest Sums]]、[[meeting-rooms-ii/main|Meeting Rooms II]] などで多用する。 + +--- + +## 7. カウンティングソート (Counting Sort) + +**比較しない**ので O(n) を達成できる。値域 k が小さいことが条件。 + +### 手順(安定版) + +1. 各値の出現回数を数える +2. **累積和を取って、各値の出力開始位置に変換する** +3. 入力を先頭から順に走査し、その値の開始位置に書き込んで開始位置を1進める + +3で「先頭から順に」走査するのが安定性の要。累積和を作らず単に値を並べ直すだけの簡易版(下記)は、値そのもの以外の情報を持たないときにしか使えない。 + +### 不変条件 + +- ステップ2の後、`starts[v]` は「値 v の要素を書き込むべき次の位置」 +- ステップ3のループ中、`output[:starts[v]]` のうち値 v の部分は、入力での出現順に埋まっている + +### コード(安定版・キー付きレコードに対応) + +```python +def counting_sort(records: list, key, k: int) -> list: + """key(record) が 0 以上 k 未満の整数であるレコードを安定ソート""" + counts = [0] * k + for record in records: + counts[key(record)] += 1 + + starts = [0] * k + total = 0 + for value in range(k): + starts[value] = total + total += counts[value] + + output = [None] * len(records) + for record in records: # 先頭から順に=安定 + v = key(record) + output[starts[v]] = record + starts[v] += 1 + return output +``` + +### 簡易版(値そのものしか持たない場合) + +値以外の情報がないなら、安定性を考える必要すらない(等しい値は区別できない)ので、数えて書き戻すだけでよい。[[sort-colors/main|Sort Colors]] の2パス解法がこれ。 + +```python +def counting_sort_in_place(nums: list[int], k: int) -> None: + counts = [0] * k + for num in nums: + counts[num] += 1 + index = 0 + for value, count in enumerate(counts): + for _ in range(count): + nums[index] = value + index += 1 +``` + +### 計算量 + +| | | +| --- | --- | +| 時間 | **O(n + k)**(k は値域の大きさ) | +| 空間 | **O(n + k)**(安定版)/ **O(k)**(簡易版) | +| 安定 | ✅(累積和版)。簡易版は概念上 N/A | +| in-place | ❌(安定版)/ ✅(簡易版) | + +**k が n に比べて大きいと破綻する。** 例えば 32bit 整数を素朴にカウンティングソートすると k = 2³² でメモリが死ぬ。「値域が n の定数倍程度」が使用条件。 + +--- + +## 8. バケットソート (Bucket Sort) + +### 手順 + +値域を等幅の区間(バケット)に分け、各要素を対応するバケットに投げ込む。各バケットを個別にソート(通常は挿入ソート)し、バケット順に連結する。 + +### 不変条件 + +- バケット `i` のどの要素も、バケット `i + 1` のどの要素以下(**バケット間の順序が保証されている**ことが、連結するだけでよい理由) + +```python +def bucket_sort(values: list[float], bucket_count: int) -> list[float]: + if not values: + return [] + lo, hi = min(values), max(values) + if lo == hi: + return list(values) + buckets = [[] for _ in range(bucket_count)] + for value in values: + index = int((value - lo) / (hi - lo) * (bucket_count - 1)) + buckets[index].append(value) + result = [] + for bucket in buckets: + result.extend(sorted(bucket)) # 各バケットは小さいので挿入ソート相当 + return result +``` + +### 計算量 + +| | | +| --- | --- | +| 時間(平均) | **O(n + k)** — 値が**一様分布**しており各バケットが O(1) 個のとき | +| 時間(最悪) | **O(n²)** — 全部が1つのバケットに落ちるとき(内部が挿入ソートの場合) | +| 空間 | O(n + k) | +| 安定 | ✅(バケット内のソートが安定なら) | + +**平均 O(n) は「一様分布」という強い仮定に依存する**。分布が偏るユースケースでは効かない。ここは面接で必ず確認される前提条件。 + +### LeetCode での「バケット」の使われ方 + +厳密なバケットソートより、「**添字を値そのものとして使う**」テクニックとして出ることが多い。[[top-k-frequent-elements/main|Top K Frequent Elements]] は「出現回数(1〜n)を添字にしたバケットに単語を入れ、後ろから走査する」ことで、ヒープの O(n log k) を **O(n)** にできる典型例。 + +--- + +## 9. 基数ソート (Radix Sort) + +### 手順(LSD: 下位桁から) + +最下位の桁でカウンティングソートし、次の桁でカウンティングソートし、…を最上位桁まで繰り返す。 + +### 不変条件 + +- d 桁目まで処理した時点で、**下位 d 桁をキーとしてソート済み** + +これが成り立つのは、各桁のソートが**安定**だから。下位桁で付いた順序が上位桁のソートで壊されない。**安定性が正しさの前提になっている珍しい例**で、ここが試験によく出る。 + +```python +def radix_sort(nums: list[int]) -> list[int]: + """非負整数のみ""" + if not nums: + return [] + output = list(nums) + exp = 1 + while max(nums) // exp > 0: + buckets = [[] for _ in range(10)] + for num in output: + buckets[(num // exp) % 10].append(num) + output = [num for bucket in buckets for num in bucket] + exp *= 10 + return output +``` + +### 計算量 + +| | | +| --- | --- | +| 時間 | **O(d · (n + b))**(d は桁数、b は基数)。固定長整数なら d が定数で **O(n)** | +| 空間 | O(n + b) | +| 安定 | ✅(必須) | + +負数・浮動小数点数はビット表現の工夫が要る。基数 b を大きくすると d が減るがメモリが増えるトレードオフがあり、実装では b = 256(1バイト単位)がよく使われる。 + +--- + +## 10. まとめ表 + +| アルゴリズム | 最良 | 平均 | 最悪 | 空間 | 安定 | in-place | adaptive | +| --- | --- | --- | --- | --- | --- | --- | --- | +| バブル | O(n) | O(n²) | O(n²) | O(1) | ✅ | ✅ | ✅ | +| 選択 | O(n²) | O(n²) | O(n²) | O(1) | ❌ | ✅ | ❌ | +| 挿入 | O(n) | O(n²) | O(n²) | O(1) | ✅ | ✅ | ✅ | +| クイック | O(n log n) | O(n log n) | O(n²) | O(log n) | ❌ | ✅ | ❌ | +| マージ | O(n log n) | O(n log n) | O(n log n) | O(n) | ✅ | ❌ | ❌ | +| ヒープ | O(n log n) | O(n log n) | O(n log n) | O(1) | ❌ | ✅ | ❌ | +| Timsort | O(n) | O(n log n) | O(n log n) | O(n) | ✅ | ❌ | ✅ | +| カウンティング | O(n + k) | O(n + k) | O(n + k) | O(n + k) | ✅ | ❌ | ❌ | +| バケット | O(n + k) | O(n + k) | O(n²) | O(n + k) | ✅ | ❌ | ❌ | +| 基数 | O(d(n + b)) | O(d(n + b)) | O(d(n + b)) | O(n + b) | ✅ | ❌ | ❌ | + +--- + +## 11. Python での実務的な話 + +- `list.sort()` は **in-place**、`sorted(iterable)` は**新しいリストを返す**。どちらも [[Timsort]] +- どちらも**安定**であることが言語仕様として保証されている。だから**多段ソートは「優先度の低いキーから順に複数回ソートする」だけで書ける**(基数ソートと同じ原理) + + ```python + records.sort(key=lambda r: r.name) # 副キー + records.sort(key=lambda r: r.score) # 主キー。name の順序は保たれる + ``` + +- `key=` は各要素につき1回だけ呼ばれる(Schwartzian transform 相当)ので、`cmp` より速い。比較関数しか書けない場合は `functools.cmp_to_key` +- 降順は `reverse=True` を使う。`key=lambda x: -x` は数値でしか使えないうえ、**`reverse=True` は安定性を保つ**(等しい要素を逆順にしない)点が違う +- `heapq` は**最小ヒープのみ**。最大ヒープが欲しいときは値を負にするか、タプルの第1要素を負にする + +--- + +## 関連する問題 + +### このリポジトリ + +- [[sort-colors/main|Sort Colors]] — 3-way 分割(Dutch National Flag)。本ノートの出発点 +- [[merge-two-sorted-list/main|Merge Two Sorted Lists]] — マージソートの merge 部分そのもの +- [[top-k-frequent-elements/main|Top K Frequent Elements]] — バケットソートで O(n)。ヒープ O(n log k) との比較 +- [[kth-largest-element-in-a-stream/main|Kth Largest Element in a Stream]] — ヒープの本来の用途 +- [[find-k-pairs-with-smallest-sums/main|Find K Pairs with Smallest Sums]] — ヒープによる k-way 的な取り出し +- [[median-of-two-sorted-arrays/main|Median of Two Sorted Arrays]] — merge を書かずに二分探索で解く +- [[meeting-rooms/main|Meeting Rooms]] / [[meeting-rooms-ii/main|Meeting Rooms II]] — 開始時刻でソートしてから走査する定番 +- [[group-anagrams/main|Group Anagrams]] — ソートをハッシュキー生成に使う。文字数え(カウンティング)でも書ける +- [[move-zeroes/main|Move Zeroes]] — 安定な2群分割。Lomuto 分割と同じ形 +- [[next-permutation/main|Next Permutation]] — 末尾の降順部分を反転する。ソートの部分操作 +- [[intersection-of-two-arrays/main|Intersection of Two Arrays]] — ソート+2ポインタ vs ハッシュ集合 +- [[first-unique-character-in-a-string/main|First Unique Character in a String]] — カウンティングの発想 + +### リモートの open PR + +- [3sum #80](https://github.com/Yuto729/leetcode/pull/80) — ソートしてから2ポインタ。重複スキップの境界処理が肝 +- [Merge Intervals #77](https://github.com/Yuto729/leetcode/pull/77) — 開始時刻ソート後の走査 +- [Insert Interval #76](https://github.com/Yuto729/leetcode/pull/76) — ソート済み前提の走査 +- [K Closest Points to Origin #81](https://github.com/Yuto729/leetcode/pull/81) — **quickselect が本命**。ヒープ O(n log k)、ソート O(n log n) との比較が問われる +- [Find Median from Data Stream #96](https://github.com/Yuto729/leetcode/pull/96) — 2つのヒープで中央値を維持 +- [Majority Element #79](https://github.com/Yuto729/leetcode/pull/79) — ソートして中央を取る O(n log n) vs Boyer–Moore O(n) +- [Longest Consecutive Sequence #70](https://github.com/Yuto729/leetcode/pull/70) — ソートすれば自明 O(n log n)、集合を使うと O(n) +- [Valid Anagram #68](https://github.com/Yuto729/leetcode/pull/68) — ソート比較 O(n log n) vs カウンティング O(n) +- [Longest Palindrome #89](https://github.com/Yuto729/leetcode/pull/89) — 文字のカウンティング +- [Min Stack #90](https://github.com/Yuto729/leetcode/pull/90) — 最小値を O(1) で保つ。ヒープとの対比 + +--- + +## 関連ノート + +- [[不変条件と境界条件の設計]] +- [[Timsort]] +- [[二分探索]] +- [[再帰]] +- [[主なLatency]] +- [[部分配列系問題のパターン]] diff --git "a/notes/\344\270\215\345\244\211\346\235\241\344\273\266\343\201\250\345\242\203\347\225\214\346\235\241\344\273\266\343\201\256\350\250\255\350\250\210.md" "b/notes/\344\270\215\345\244\211\346\235\241\344\273\266\343\201\250\345\242\203\347\225\214\346\235\241\344\273\266\343\201\256\350\250\255\350\250\210.md" new file mode 100644 index 0000000..b8ee67b --- /dev/null +++ "b/notes/\344\270\215\345\244\211\346\235\241\344\273\266\343\201\250\345\242\203\347\225\214\346\235\241\344\273\266\343\201\256\350\250\255\350\250\210.md" @@ -0,0 +1,389 @@ +--- +tags: + - Leetcode + - アルゴリズム + - 不変条件 + - 境界条件 + - 設計 + - 面接 + - 復習 +--- + +# 不変条件と境界条件の設計 + +off-by-one を「気をつける」で潰そうとすると必ず取りこぼす。**区間の取り方と命名の規約を先に決め、不変条件を文章で書いてからコードを書く**と、境界の判断がその場の勘ではなく機械的な導出になる。 + +このノートは [[sort-colors/main|Sort Colors]] の Dutch National Flag(以下DNFとして例に用いる) +を詰めたときに得たものを軸に、他の問題へ横展開する形でまとめる。 + +--- + +## 1. ループ不変条件とは + +ループの各周回の**開始時点**で必ず成り立っている命題のこと。正しさの証明は数学的帰納法そのもので、次の3点を確認する(CLRS の定式化)。 + +1. **初期化 (initialization)** — ループに入る直前に成り立つ +2. **維持 (maintenance)** — ある周回の開始時に成り立てば、次の周回の開始時にも成り立つ +3. **終了 (termination)** — ループを抜けたとき、不変条件と終了条件を合わせると**求めたい結論が出る** + +3つめが一番大事で、ここが「なぜこのアルゴリズムで答えが出るのか」の説明になる。逆に言うと、**終了時に何も嬉しいことが言えない不変条件は、設計を間違えている**。 + +### 例:Dutch National Flag + +``` +nums[:red_end] すべて 0 +nums[red_end:i] すべて 1 +nums[i:blue_start] 未確定 +nums[blue_start:] すべて 2 +``` + +- 初期化:`red_end = i = 0`, `blue_start = len(nums)` で、未確定が全体・他は空 +- 維持:`nums[i]` を見て3分岐し、どの分岐でも未確定区間が必ず1つ縮む +- 終了:`i == blue_start` で未確定が空。残った3つの領域を並べると `0…0 1…1 2…2` になる → **ソート済み** + +--- + +## 2. 境界の取り方:半開区間を既定にする + +境界変数は「区間の**端**」を指す。「最後の要素」を指さない。 + +| | 包含的 (inclusive) な終端 | 排他的 (exclusive) な終端 | +| --- | --- | --- | +| 意味 | 最後の要素の位置 | 最後の要素の**次**の位置 | +| 例(赤が `[0,0]`) | `last_red_index = 1` | `red_end = 2` | +| 空の表現 | `lo - 1` という**配列外**の番兵が要る | `begin == end` で自然に表せる | +| 長さ | `end - begin + 1` | `end - begin` | +| slice | `nums[:last + 1]` | `nums[:end]` | + +### 半開区間を既定にする理由 + +1. **Python の `slice` と `range` が半開**なので、不変条件をそのまま `nums[a:b]` と書ける +2. **空を `begin == end` で表せる**ので、`-1` のような範囲外を指す番兵が不要 +3. **隣接する2領域の境界を1本の変数で兼ねられる** + +3つめが最も効く。 + +``` +nums[:red_end] 赤 +nums[red_end:i] 白 +``` + +`red_end` は「赤の終わり」であると同時に「白の始まり」。排他的だから同じ値で両方を表せる。包含的だと「赤の最後は 1、白の最初は 2」と別の値になり、あいだに `+1` が挟まる。**`+1` / `-1` が式に現れたら、区間の流儀がずれているサイン**。 + +Dijkstra の EWD831 "Why numbering should start at zero" がこの議論の原典。 + +### ただし「常に半開」ではない + +**そのアルゴリズムの自然な境界に合わせるのが正解**で、決めたら1つの関数の中では絶対に混ぜない。 + +- Lomuto 分割は末尾を pivot にする都合で**閉区間と相性が良い**(CLRS も閉区間で書いている) +- Hoare 分割・3-way 分割・二分探索・スライディングウィンドウは**半開区間が圧倒的に楽** +- チームの既存コードが片方に寄っているなら**一貫性が個々の優劣に勝つ** + +--- + +## 3. 命名の規約 + +名前に境界の意味を埋め込む。名前が悪いと、その時点で番兵や `+1` が確定してしまう。 + +| 接尾辞 | 意味 | 例 | +| --- | --- | --- | +| `_start` | 包含的な始端 | `blue_start`(`nums[blue_start:]` が青) | +| `_end` | **排他的**な終端 | `red_end`(`nums[:red_end]` が赤) | +| `_count`, `_size` | 個数 | `red_end - 0` | + +避けるべき名前: + +- `last_xxx_index` … 「最後の要素」という**閉区間の意味を名前に埋め込んでいる**ため、空を表すのに `-1` が必要になる +- `l`, `r`, `i`, `j` の乱用 … 二分探索や2ポインタで何を指しているか分からなくなる。`left` / `right` より `lo` / `hi` / `search_start` / `search_end` のように役割を書く +- 同じ名前を別の意味で使い回す … `pivot` が「pivot の値」と「pivot の位置」の両方に使われるのは典型的な事故のもと。`pivot` / `pivot_index` で分ける + +さらに、**「次に書き込む位置」として読める名前**にできると、操作が「書いてから境界を進める」という素直な形になる。 + +```python +nums[i], nums[red_end] = nums[red_end], nums[i] +red_end += 1 # 書いてから進める +``` + +閉区間だと「先に進めてから書く」になり、初回の `-1 + 1 = 0` という番兵の後始末が要る。 + +--- + +## 4. 番兵 (sentinel) + +「先頭・末尾だけ特別扱い」という場合分けを消すための道具。2種類ある。 + +### 4-1. 仮想的な番兵(思考の補助輪) + +配列の外側に「確定済みの領域が0個ある」と想像する。半開区間なら `nums[0:0] = []` として自然に表現されるので、**コードには現れない**。空の領域が気持ち悪いうちは、この読み替えをすると納得しやすい。 + +### 4-2. 実体としての番兵 + +本当にデータ構造に置いてしまう。 + +- **dummy head** — 連結リストで「先頭ノードを削除・挿入する場合」を特別扱いしないで済ませる。[[remove-duplicates-from-sorted-list-ii/main|Remove Duplicates from Sorted List II]]、[[merge-two-sorted-list/main|Merge Two Sorted Lists]]、[[reverse-linked-list/main|Reverse Linked List]] の反復版 +- **Hoare 分割の pivot** — pivot が配列内に実在する値なので、内側 while の範囲チェックが不要になる +- **番兵行・番兵列** — グリッド DP で `dp[0][*]`, `dp[*][0]` を余分に取り、範囲外判定を消す。[[unique-paths/main|Unique Paths]]、[[unique-paths-ii/main|Unique Paths II]] +- **`prefix_sum[0] = 0`** — 「空の prefix」を1個置くことで、`prefix_sum[j] - prefix_sum[i]` が `i == 0` でも例外なく成り立つ。[[subarray-sum-equals-k/main|Subarray Sum Equals K]] + +「先頭だけ処理が違う」と気付いたら、番兵で消せないか考える価値がある。 + +--- + +## 5. 停止性:区間が必ず縮むか + +無限ループの原因はほぼ常に「**ある分岐で区間が縮まない**」こと。ループを書いたら、全分岐について「未確定領域が真に縮むか」を確認する。 + +### 二分探索 + +`mid` を計算した後、`lo = mid` としてしまうと `hi - lo == 1` のとき縮まない。**`mid` を必ず次の探索範囲から除外する**(`lo = mid + 1` か `hi = mid`)。詳細は [[二分探索]]。 + +### 分割 (partition) + +`lo < m < hi` を満たすこと。Hoare 分割で pivot に末尾を選ぶとこれが崩れて無限再帰する。 + +### DNF・2ポインタ + +「swap した後に走査ポインタを進めるかどうか」を分岐ごとに決める。DNF で `nums[i] > pivot` のとき `i` を進めてはいけないのは、**交換相手が未確定領域から来ている**から。逆に `<` の側は交換相手が判定済み領域から来るので進めてよい。 + +> **一般則:交換相手が「未判定の領域」から来るなら走査ポインタを進めてはいけない。「判定済みの領域」から来るなら進めてよい。** + +この一般則を持っていると、3分岐のどこで `continue` するかを暗記せずに導ける。 + +--- + +## 6. パターン別の不変条件の設計 + +### パターンA:二分探索(探索区間を狭める) + +**不変条件**:答えは必ず `[lo, hi)` の中にある。かつ `nums[:lo]` は述語が False、`nums[hi:]` は述語が True。 + +**設計手順**: + +1. **単調な述語**を1つ決める(`nums[k] >= x` のように、False…False True…True になるもの) +2. 求めるものを「最初に True になる位置」に言い換える +3. `lo = 0, hi = len(nums)` で始め、`while lo < hi` +4. 終了時 `lo == hi` が答え + +「値を探す」ではなく「**述語の境界を探す**」と捉え直すと、`lo <= hi` にするか `lo < hi` にするかで迷わなくなる。 + +**関連問題** + +- [[二分探索]](ノート。`bisect_left` / `bisect_right` の内部実装) +- [[search-insert-position/main|Search Insert Position]] — `bisect_left` そのもの +- [[search-in-rotated-sorted-array/main|Search in Rotated Sorted Array]] — 「どちら側がソート済みか」を毎周判定する不変条件が追加される +- [[find-minimum-in-rotated-sorted-array/main|Find Minimum in Rotated Sorted Array]] — `nums[mid] > nums[hi - 1]` で回転点の左右を判定 +- [[capacity-to-ship-packages-within-d-days/main|Capacity to Ship Packages Within D Days]] — **答えに対する二分探索**。述語は「容量 c で d 日以内に運べるか」 +- [[median-of-two-sorted-arrays/main|Median of Two Sorted Arrays]] — 分割位置に対する二分探索。境界条件が最難関 +- [First Bad Version #84](https://github.com/Yuto729/leetcode/pull/84) — 述語がそのまま与えられている最小形 +- [Time Based Key Value Store #93](https://github.com/Yuto729/leetcode/pull/93) — `bisect_right - 1` で「timestamp 以下の最大」を取る + +### パターンB:両端からの2ポインタ + +**不変条件**:`nums[:left]` と `nums[right:]` は**答えの候補から除外済み**。答えは必ず `[left, right)` の中にある。 + +**設計手順**:除外してよい理由(=どちらのポインタを動かすかの根拠)を明文化する。ここが証明の本体で、「動かしたほうが損をしない」ことを言えないといけない。 + +**関連問題** + +- [[valid-parentheses/main|Valid Parentheses]] は別系統だが、[Valid Palindrome #66](https://github.com/Yuto729/leetcode/pull/66) — 両端から寄せる最小形 +- [Container With Most Water #73](https://github.com/Yuto729/leetcode/pull/73) — **短いほうを動かす**。長いほうを動かしても面積は増えないので除外してよい、という論証が不変条件 +- [Trapping Rain Water #69](https://github.com/Yuto729/leetcode/pull/69) — `left_max` / `right_max` を持ち回る。「小さいほうの max は確定している」が不変条件 +- [3sum #80](https://github.com/Yuto729/leetcode/pull/80) — ソート後に両端2ポインタ。**重複スキップの境界**(`while left < right and nums[left] == nums[left-1]`)が事故りやすい +- [[is-subsequence/main|Is Subsequence]] — 2列を別々に走査するタイプ + +### パターンC:スライディングウィンドウ + +**不変条件**:`nums[left:right]` が「条件を満たす(または満たさない)」ウィンドウであり、その統計量(和・出現回数など)を増分で保持している。 + +**設計手順**: + +1. ウィンドウを**半開区間 `[left, right)`** で持つ。長さが `right - left` になり `+1` が消える +2. `right` を1つ伸ばして統計量を更新する +3. 条件を満たす(または破る)間、`left` を縮めて統計量を戻す +4. **`left` の縮めと `right` の伸ばしがそれぞれ高々 n 回**なので全体 O(n) + +「縮める操作が伸ばす操作の巻き戻しになっている」ことが増分更新の正しさで、ここが対称に書けていないとバグる。 + +**関連問題** + +- [[部分配列系問題のパターン]](ノート) +- [[minimum-size-subarray-sum/main|Minimum Size Subarray Sum]] — 和が k 以上になったら縮める +- [[longest_substring_without_repeating_characters/main|Longest Substring Without Repeating Characters]] — 重複が出たら縮める +- [Minimum Window Substring #94](https://github.com/Yuto729/leetcode/pull/94) — 「必要な文字を何種類満たしたか」をカウンタで持つ。**縮める条件の設計が最難関** +- [[maximum-subarray/main|Maximum Subarray]] — Kadane。ウィンドウというより後述パターンF + +### パターンD:分割(partition)/群を伸ばす + +**不変条件**:配列を「確定した群1」「確定した群2」「未確定」に分け、各群の条件を slice で書く。 + +**設計手順**: + +1. 群の数を決める(2群なら Lomuto 型、3群なら DNF 型) +2. 各群の境界に排他的終端の名前を付ける +3. 走査ポインタ `i` の分岐ごとに「交換相手がどの領域から来るか」を確認し、`i` を進めるかを決める + +**関連問題** + +- [[sort-colors/main|Sort Colors]] — 3群(DNF)。本ノートの原点 +- [[move-zeroes/main|Move Zeroes]] — 2群。`nums[:write_index]` が非ゼロ、というだけの Lomuto 型 +- [[ソートアルゴリズム]] — quicksort の各種 partition +- [K Closest Points to Origin #81](https://github.com/Yuto729/leetcode/pull/81) — quickselect。partition の事後条件をそのまま使う +- [[remove-duplicates-from-sorted-list/main|Remove Duplicates from Sorted List]] — 連結リスト版の「書き込み位置を進める」 + +### パターンE:単調スタック/補助構造 + +**不変条件**:スタックの中身が常に単調(増加または減少)である。かつスタックの各要素が「まだ答えが確定していない候補」を表す。 + +**設計手順**:pop する条件を「この要素の答えが今まさに確定した」と読めるようにする。 + +**関連問題** + +- [[sum-of-subarray-minimums/main|Sum of Subarray Minimums]] — 各要素が最小値となる区間の幅を、単調スタックで確定させる。**左右の境界を「以上」「より大きい」のどちらで取るかで重複カウントが起きる**典型 +- [Min Stack #90](https://github.com/Yuto729/leetcode/pull/90) — 補助スタックの不変条件「補助スタックの top は常に現在の最小値」 +- [Trapping Rain Water #69](https://github.com/Yuto729/leetcode/pull/69) — 単調スタック解法もある +- [Evaluate Reverse Polish Notation #86](https://github.com/Yuto729/leetcode/pull/86) — スタックの中身が「まだ演算子が来ていないオペランド」 + +### パターンF:走査しながら答えを持ち回る + +**不変条件**:`i` 番目まで見た時点で、変数が「`nums[:i + 1]` に対する答え」を保持している。 + +これは動的計画法の1次元版でもある。**「i まで見たときの答え」を厳密に言語化できるかが全て**で、それができれば漸化式は自動的に決まる。 + +**設計手順**: + +1. 「`nums[:i+1]` に対する答え」の定義を1文で書く +2. `i` を1つ伸ばしたときの更新式を書く +3. 初期値が定義と矛盾しないか確認する(ここが境界条件) + +**関連問題** + +- [[maximum-subarray/main|Maximum Subarray]] — 「i で終わる部分配列の最大和」と「全体の最大和」の2つを持つ。**この2つを混同するのが典型的バグ** +- [[best-time-to-buy-and-sell-stock/main|Best Time to Buy and Sell Stock]] — 「i までの最小値」を持ち回る +- [[best-time-to-buy-and-sell-stock-ii/main|Best Time to Buy and Sell Stock II]] — 貪欲の正当性が不変条件 +- [[house-robber/main|House Robber]] / [[house-robber-ii/main|House Robber II]] — 「i 番目を取る/取らない」の2状態 +- [[climbing-stairs/main|Climbing Stairs]] — 初期値 `dp[0] = 1` の意味づけ(空の登り方が1通り)が境界条件 +- [[subarray-sum-equals-k/main|Subarray Sum Equals K]] — 累積和の辞書。**`{0: 1}` で初期化する**のが番兵 +- [Majority Element #79](https://github.com/Yuto729/leetcode/pull/79) — Boyer–Moore。「候補とカウンタ」の不変条件の言語化が難しい良問 +- [Product of Array Except Self #88](https://github.com/Yuto729/leetcode/pull/88) — 左からの積と右からの積。**両端の初期値を 1 にする**のが番兵 + +### パターンG:再帰の事前条件・事後条件 + +ループ不変条件の再帰版。**「この関数は何を仮定し、何を保証して返すか」を1文で書く**。 + +**設計手順**: + +1. 引数が満たすべき**事前条件**を書く(`lo <= hi`、`node is not None` など) +2. 返り値の**事後条件**を書く +3. 基底ケースが事後条件を満たすか確認する +4. 再帰呼び出しの引数が事前条件を満たし、かつ**真に小さくなる**か確認する + +**関連問題** + +- [[再帰]](ノート) +- [[validate-binary-search-tree/main|Validate Binary Search Tree]] — 「部分木の値が `(lo, hi)` の開区間に収まるか」を引数で伝播させる。**境界を閉区間にすると重複値で誤判定する** +- [[maximum-depth-of-binary-tree/main|Maximum Depth of Binary Tree]] / [[minimum-depth-of-binary-tree/main|Minimum Depth of Binary Tree]] — min のほうは「片方が空のとき」の扱いが境界条件。素直に書くと 0 を返してしまう +- [[construct-binary-tree-from-preorder-and-inorder-traversal/main|Construct Binary Tree from Preorder and Inorder]] — **区間の分割が正しいかが全て**。半開区間で書くと明確になる +- [[convert-sorted-array-to-binary-search-tree/main|Convert Sorted Array to BST]] — 区間の中点の取り方 +- [[split-bst/main|Split BST]] — 事後条件(2つの木を返す)を先に決める +- [Balanced Binary Tree #82](https://github.com/Yuto729/leetcode/pull/82) — 「高さを返しつつ不均衡を検出する」二重の事後条件 +- [Lowest Common Ancestor of a Binary Tree #92](https://github.com/Yuto729/leetcode/pull/92) — 返り値の意味を「部分木内に見つかったノード、なければ None」と定義できるかが鍵 +- [Diameter of Binary Tree #98](https://github.com/Yuto729/leetcode/pull/98) — 「返す値(高さ)」と「更新する値(直径)」が別物 + +### パターンH:BFS の層と visited の境界 + +**不変条件**:キューに入っているのは「距離 d または d+1 のノード」であり、`visited` に入っているノードは**二度とキューに入らない**。 + +**設計上の分岐**:`visited` に追加するタイミングを「キューに入れるとき」にするか「キューから出すとき」にするか。**入れるときが正しい**(出すときにすると同じノードが複数回キューに入る)。詳細は [[DFSのvisitedを追加するタイミング]]。 + +**関連問題** + +- [[DFSのvisitedを追加するタイミング]](ノート) +- [[binary-tree-level-order-traversal/main|Binary Tree Level Order Traversal]] — 層の切れ目を `len(queue)` のスナップショットで取る +- [[word-ladder/main|Word Ladder]] — 層=距離 +- [[number-of-islands/main|Number of Islands]] / [[max-area-of-island/main|Max Area of Island]] +- [Rotting Oranges #91](https://github.com/Yuto729/leetcode/pull/91) — **多始点 BFS**。初期キューに全腐敗オレンジを入れるのが不変条件の初期化 +- [01 Matrix #78](https://github.com/Yuto729/leetcode/pull/78) — 同じく多始点 BFS +- [Flood Fill #74](https://github.com/Yuto729/leetcode/pull/74) +- [Clone Graph #85](https://github.com/Yuto729/leetcode/pull/85) — `visited` が「コピー済みノードの対応表」を兼ねる + +### パターンI:区間・イベントの走査 + +**不変条件**:ソート済みの区間を左から処理し、「現在マージ中の区間」または「現在開いている区間の数」を保持する。 + +**境界の罠**:区間が**閉区間か半開区間か**(`[1,2]` と `[2,3]` は重なるのか)を必ず問題文で確認する。ここは仕様の問題であって、勘で決めてはいけない。 + +**関連問題** + +- [[meeting-rooms/main|Meeting Rooms]] / [[meeting-rooms-ii/main|Meeting Rooms II]] +- [Merge Intervals #77](https://github.com/Yuto729/leetcode/pull/77) — `current_end >= next_start` の等号を含めるか +- [Insert Interval #76](https://github.com/Yuto729/leetcode/pull/76) — 挿入位置の前後で3段階に分ける + +### パターンJ:2つのポインタが違う速度で動く + +**不変条件**:`fast` が `slow` の2倍進んでいる。ループがあれば必ず追いつく。 + +**関連問題** + +- [[linked-list-cycle/main|Linked List Cycle]] — `fast and fast.next` の**両方**をチェックしないと落ちる境界 +- [[linked-list-cycle-ii/main|Linked List Cycle II]] — 出会った後に head から歩き直す理由(数式で示せる) +- [Find Median from Data Stream #96](https://github.com/Yuto729/leetcode/pull/96) — 2ヒープのサイズ不変条件「`len(low) == len(high)` または `len(low) == len(high) + 1`」 + +--- + +## 7. 実装時のチェックリスト + +コードを書く前: + +- [ ] 区間は半開か閉か。**1つの関数の中で混ぜていないか** +- [ ] 境界変数の名前は「区間の端」を表しているか。`last_xxx` になっていないか +- [ ] 不変条件を **slice 記法で書けるか**。書けないなら区間の取り方がずれている +- [ ] 終了条件と不変条件を合わせると、求めたい結論が出るか + +コードを書いた後: + +- [ ] **初期化**で不変条件が成り立つか(空の入力を代入して確かめる) +- [ ] **全分岐**で不変条件が維持されるか +- [ ] **全分岐**で未確定領域が真に縮むか(停止性) +- [ ] 交換相手が未判定領域から来る分岐で、走査ポインタを進めていないか + +ドライラン: + +- [ ] 空、1要素、2要素 +- [ ] 全要素が同じ +- [ ] すでに整列済み/完全に逆順 +- [ ] 答えが**先頭にある場合と末尾にある場合**(境界のバグはここで出る) + +--- + +## 8. 不変条件をコードに残す + +不変条件はコメントに書くだけでなく、**assert として実行可能にできる**と強い。半開区間で書いていれば slice がそのまま使える。 + +```python +def sort_colors(nums: list[int]) -> None: + RED, WHITE, BLUE = 0, 1, 2 + red_end = i = 0 + blue_start = len(nums) + while i < blue_start: + assert all(x == RED for x in nums[:red_end]) + assert all(x == WHITE for x in nums[red_end:i]) + assert all(x == BLUE for x in nums[blue_start:]) + ... +``` + +本番では `python -O` で無効化されるので、デバッグ時だけ有効にできる。O(n) の assert を O(n) のループの中に入れると全体が O(n²) になるので、**入れるのは開発中だけ**。 + +レビューに出すときは assert を消し、**不変条件を4行のコメントとして残す**のが落とし所。このアルゴリズムは不変条件を知らずにコードだけ読んでも何をしているか分からないので、コメントの費用対効果が非常に高い。 + +--- + +## 関連ノート + +- [[ソートアルゴリズム]] +- [[二分探索]] +- [[部分配列系問題のパターン]] +- [[再帰]] +- [[DFSのvisitedを追加するタイミング]] +- [[グラフ]] +- [[意識]] diff --git "a/notes/\351\203\250\345\210\206\351\205\215\345\210\227\347\263\273\345\225\217\351\241\214\343\201\256\343\203\221\343\202\277\343\203\274\343\203\263.md" "b/notes/\351\203\250\345\210\206\351\205\215\345\210\227\347\263\273\345\225\217\351\241\214\343\201\256\343\203\221\343\202\277\343\203\274\343\203\263.md" index f0fe645..aa7d235 100644 --- "a/notes/\351\203\250\345\210\206\351\205\215\345\210\227\347\263\273\345\225\217\351\241\214\343\201\256\343\203\221\343\202\277\343\203\274\343\203\263.md" +++ "b/notes/\351\203\250\345\210\206\351\205\215\345\210\227\347\263\273\345\225\217\351\241\214\343\201\256\343\203\221\343\202\277\343\203\274\343\203\263.md" @@ -114,6 +114,7 @@ return ans ### 例: Minimum Size Subarray Sum (#209, 和 ≥ target の最短部分配列) 「縮められる条件」= 今の窓の和がすでに target 以上 → 縮めても条件を満たせる可能性がある。 +要するに尺取り法(どこかの問題でやった) ```python left = 0 From 112f09bc62140469a707048e5dcf4abcffeb8964 Mon Sep 17 00:00:00 2001 From: Yuto729 Date: Sun, 2 Aug 2026 17:04:01 +0900 Subject: [PATCH 2/2] update docs --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1a94c2f..7aef700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,7 @@ - 例1, 2個でドライランする - エッジケースでpassするか考える +### ノートの記述について + +- 関連するノートは`[[]]`(Obsidianリンク)で紐づけること +