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

部分問題を定義し、依存の順に表を埋める — 動的計画法

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

第11期でメモ化(同じ部分問題を2回解かない)と依存グラフを見ました。今回は、部分問題を2次元の表のマスとして定義し、その表をどの順で埋めればよいかを扱います。

まず結論

動的計画法の仕事は「部分問題を何と定義するか」でほぼ決まります。定義できれば、答えは小さい部分問題の答えから組み立てる式になり、あとは表を埋めるだけです。

埋める順序には1つだけ条件があります。各マスが読む先(依存先)が、読む前に埋まっていること。kitten→sitting の編集距離は、行ごとに埋めても列ごとに埋めても 3 ですが、右下から逆に埋めると 1 という誤った値がエラーなしで返ります。

? 読む前に予測

kitten を sitting に変える最小手数(編集距離、正解は 3)を表で求めます。各マスは左・上・左上のマスを読んで決まります。この表を右下のマスから逆順に埋めると、何が起きるでしょうか。

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

抽象原理

1マスを何と定義するかで、式が書けるかが決まる

編集距離は、1文字の挿入・削除・置換をそれぞれ1手と数えて、文字列 s を t に変える最小手数です。kitten→sitting は、k を s に置換、e を i に置換、末尾に g を挿入して 3 手です。

動的計画法で最初にやるのは、部分問題の定義です。ここでは次のように置きます。

D[i][j] = s の先頭 i 文字を、t の先頭 j 文字に変える最小手数

添字が2つあるのが要点です。「s の先頭 i 文字」だけでは、相手のどこまでと対応させるかが決まりません。両方の長さを持たせると、最後の1手で3通りに場合分けできます。

  • s の i 文字目を削除する → 残りは D[i-1][j] の問題(上のマス)
  • t の j 文字目を挿入する → 残りは D[i][j-1] の問題(左のマス)
  • 置換する(同じ文字なら0手) → 残りは D[i-1][j-1] の問題(左上のマス)

その中の最小が D[i][j] です。これが成り立つのは、最小手数の手順から最後の1手を除いた残りも、小さい問題の最小手数の手順になっているからです。残りにもっと短い手順があれば、それに置き換えて全体も短くできてしまいます。全体の最適が部分の最適から組める、この性質を最適部分構造と呼びます。

マス (i,j) が読むのは3つのマスだけ
左上 D[i-1][j-1]置換(一致なら0手)
上 D[i-1][j]削除 +1
3つの最小を取る
左 D[i][j-1]挿入 +1
D[i][j]先頭 i 文字 → 先頭 j 文字
  • ・読む先はどれも i か j が小さいマス。依存が循環することはない
  • ・最初の行は D[0][j] = j(j 文字を挿入)、最初の列は D[i][0] = i(i 文字を削除)
具体実物

kitten→sitting の表は 7 行 8 列

上の式で表を埋めて印字したものです(Python 3.14.6 / macOS 26.5.2 / arm64、2026-10-07 実行。Docker の python:3.14-slim でも同じ値)。行が kitten の先頭 i 文字、列が sitting の先頭 j 文字です。

編集距離の表(行を外側、列を外側、逆順の3通りで右下を比べる)コマンド出力
s i t t i n g
0 1 2 3 4 5 6 7
k 1 1 2 3 4 5 6 7
i 2 2 1 2 3 4 5 6
t 3 3 2 1 2 3 4 5
t 4 4 3 2 1 2 3 4
e 5 5 4 3 2 2 3 4
n 6 6 5 4 3 3 2 3
distance (rows outer): 3
distance (cols outer): 3
distance (reverse order, reading unfilled cells): 1

右下の 3 が答えです。表は 7 × 8 = 56 マスで、初期化済みの行と列を除く 42 マスを1回ずつ、3つのマスを読んで埋めます。

行を外側にしても列を外側にしても 3 です。逆順に埋めると 1 になります。 右下を最初に計算する時点で、上・左・左上はまだ初期値の 0 なので、min(0+1, 0+1, 0+1) = 1 です。

✓ ここまでの確認

編集距離の表で、行を外側のループにしても列を外側にしても同じ 3 が出ます。その理由として最も適切なものはどれですか。

抽象原理

表を埋める順序は、依存の順

マスを頂点、「このマスはあのマスを読む」を矢印と見ると、表は依存のグラフです。第11期『依存はグラフ — import が解決される形』では、root の本文が走る前に leaf・mid1・mid2 の評価が終わっていました。依存先を先に片付けるという同じ条件が、表を埋める順序にもそのまま掛かります。

編集距離の矢印はいつも i か j が小さいほうを向くので、循環しません。循環しないグラフには依存先が先に来る並べ方が必ずあり、行ごとも列ごともその1つです。逆順はそうではありません。

動的計画法が使っている構造は2つです。

  1. 最適部分構造 — 全体の最適が、部分問題の最適から組める
  2. 部分問題の重なり — 同じマスが複数のマスから読まれる。第11期『同じ計算を2回しない — 覚えておくという交換』で fib を例に見た性質です

1 が崩れると、表を正しい順で埋めても式そのものが誤りになります。2 が無ければ、表に貯める利点がありません。

具体実物

PostgreSQL は結合の順序を、項目数の段ごとに埋める

複数の表を結合するクエリでは、どの順に結合するかで実行の費用が変わります。PostgreSQL の optimizer README(master、2026-10-07 取得)は、標準の計画器について次のように書きます。

The join tree is constructed using a "dynamic programming" algorithm: in the first pass (already described) we consider ways to create join rels representing exactly two list items. The second pass considers ways to make join rels that represent exactly three list items; the next pass, four items, etc.

ここでの部分問題は「どの項目の組を結合したか」です。k 項目の結合はそれより少ない項目の結合から作るので、2項目、3項目、4項目と段を上がれば、依存先は前の段で済んでいます。埋める順序が依存の順になっているのが、README の段の並びにそのまま出ています。

ただし表は大きくなります。項目の組は、n 項目なら最大で 2^n − 1 通りです(本教材の算数。12 項目で 4,095)。PostgreSQL 18 の文書は、ある大きさからは網羅的な探索をやめると書いています。

Use genetic query optimization to plan queries with at least this many FROM items involved. (Note that a FULL OUTER JOIN construct counts as only one FROM item.) The default is 12.

for queries with many tables the exhaustive search takes too long, often longer than the penalty of executing a suboptimal plan.

既定では FROM 項目が 12 以上で、GEQO(遺伝的探索)に切り替わります。GEQO について 61.3 節は non-exhaustive search と書きます。文書の言い方では、網羅的な探索にかかる時間のほうが、最適でない計画を実行する不利益より長くなることが多いから、という交換です。

README の引用部分は、各段で候補のうち何を残すかまでは書いていません。本教材もそこには踏み込みません。

✓ ここまでの確認

PostgreSQL の optimizer README は、結合の木を「2項目ちょうど、次に3項目、次に4項目……」と段ごとに作ると書いています。この順序が成り立つ理由として最も適切なものはどれですか。

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

編集距離(kitten→sitting)

マスは「s の先頭 i 文字を t の先頭 j 文字に変える最小手数」。左・上・左上だけを読むので、行ごとでも列ごとでも 3。逆順に埋めると 1 に壊れる。

具体例 B

PostgreSQL の結合順序

部分問題は「どの項目の組を結合したか」。2項目、3項目、4項目と段ごとに作る。FROM 項目が 12 以上(既定)では表を全部は埋めず、GEQO に切り替える。

文字列の比較とクエリの計画。扱うものは違いますが、どちらにも「部分問題の定義」と「埋める順序」があります。それぞれ何で、順序はなぜそれでよいのでしょうか。

それぞれ「1マスが何を表すか」と「そのマスを作るときに、どのマスを読むか」を書き出してみてください。

この章のまとめ

  • 動的計画法の仕事は、部分問題の定義でほぼ決まる。 編集距離では「s の先頭 i 文字を t の先頭 j 文字に変える最小手数」という2次元の定義にすると、最後の1手で3通りに場合分けする式が書ける。
  • 使っている構造は2つ。最適部分構造(全体の最適が部分の最適から組める)と、部分問題の重なり(第11期『同じ計算を2回しない』)。
  • 表を埋める順序は依存の順。 読む先が先に埋まっていれば、行ごとでも列ごとでも答えは同じ 3。逆順では例外なしに 1 が返る。
  • PostgreSQL の標準の計画器は、結合を項目数の段ごとに作る README どおりの動的計画法。PostgreSQL 18 では FROM 項目が 12 以上(既定)で GEQO の非網羅探索に切り替わる。
  • 既存クイズでは、「アルゴリズムとデータ構造」の algorithm-13・algorithm-20、「II(応用編)」の algorithm-2-8・algorithm-2-18、「III(発展編)」の algorithm-3-16 がこの章の結論の一行版です。
  • CLRS(『アルゴリズムイントロダクション』)の目次や索引で dynamic programming(動的計画法) を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

kitten→sitting の編集距離を表で解くとき、マス D[i][j] が表す部分問題の定義はどれですか。

用語集(4語)
部分問題
元の問題を小さくした同じ形の問題。動的計画法では、表の1マスが1つの部分問題に対応する。編集距離なら「s の先頭 i 文字を t の先頭 j 文字に変える最小手数」。
最適部分構造
全体の最適解が、部分問題の最適解から組み立てられる性質。編集距離では、最小手数の手順から最後の1手を除いた残りも、小さい部分問題の最小手数になっている。
編集距離
挿入・削除・置換を各1手と数えたとき、ある文字列を別の文字列に変える最小手数。kitten→sitting は 3。
依存の順(トポロジカル順)
どの要素も、それが読む先より後に来る並べ方。依存に循環が無ければ必ず作れる。表を埋める順序はこれに従う。