2 ポイント 投稿者 GN⁺ 2024-01-15 | 1件のコメント | WhatsAppで共有
  • Advent of Code 2023 Day 12 のように特殊ケースが多い問題でも、同じ部分問題を繰り返し解く構造を見つければ 動的計画法 で扱える
  • 核心は、再帰で問題を分割したうえで重複計算を メモ化 で減らし、必要な値を依存順に埋める反復計算へ移すことにある
  • Fibonacci の例では、素朴な再帰は f(1) を繰り返し評価するが、キャッシュを使えば f(0) から f(n) まで n + 1 個の値 だけ評価すればよい
  • Levenshtein 距離と Advent of Code Day 12 では、文字列長や規則インデックスのような 状態インデックス をキャッシュキーにして、再帰呼び出しを配列の充填へ置き換える過程を示している
  • 動的計画法を身につけると、性能改善だけでなく、アルゴリズムの 中間状態と依存関係 が見えるようになり、メモリ最適化の余地も見つけやすくなる

名前は紛らわしいが、アイデアは単純

  • “dynamic programming” という名前は、現代的な「プログラミングスタイル」や「動的型付け」のような意味と直接関係があるわけではない
  • 核心は、問題をより小さな類似問題に分け、その結果を再利用するアルゴリズム設計の方法である
  • 歴史的な意味での “programming” を基準にするとこの表現にも納得がいく、という編集注が添えられている
  • 出発点は通常、再帰関数 のように問題を小問題へ分解する形である
  • 同じ部分問題が何度も現れるなら、計算結果を保存して再利用する キャッシュ が自然に必要になる

Fibonacci で見るキャッシュと反復化

  • Fibonacci 関数は f(n) = f(n - 1) + f(n - 2) と定義され、素朴な再帰実装は同じ値を何度も計算する
  • f(1) は最終結果に実際に加算される値なので、f(n) が大きくなるほど素朴な再帰の評価回数も急速に増える
  • 結果をキャッシュしたりメモ化したりすれば、すでに計算済みの f(4)f(3)f(2) を再計算する必要はない
  • この方式では f(0) から f(6) まで合計 7 個の値だけを評価し、一般には n + 1 回の評価 にまで減る
  • さらに一歩進めて、f(0)f(1) から必要な値を順に埋めていけば、再帰呼び出し自体が不要になる
    • F[2] = F[1] + F[0]
    • F[3] = F[2] + F[1]
    • 同じ方法で F[6] = 8 まで計算する
  • Fibonacci では配列全体すら必要なく、直前の値とその前の値 の 2 つだけを保持すればよい
  • この流れは、数学的定義から始めて反復実装へ移していく体系的な道筋を示している

編集距離の例へ広げる

  • 2 つの文字列の編集距離とは、一方の文字列をもう一方へ変換するのに必要な最小編集回数のこと
  • 許される編集の種類によって問題は変わる
    • 文字の置換だけを許すなら Hamming distance
    • 挿入と削除まで許すなら Levenshtein distance
  • Levenshtein 距離は、2 つの文字列 AB の末尾文字を基準にしてより小さな問題へ分割できる
    • 末尾文字が同じなら、その 2 文字を無視して残りの文字列の距離を使う
    • 末尾文字が異なるなら、置換・削除・挿入のうち最小コストを選ぶ
    • A が空なら B のすべての文字を挿入しなければならないのでコストは b
    • B が空なら A のすべての文字を削除しなければならないのでコストは a
  • この定義をそのまま Python の再帰に移すと、長い文字列や差異の大きい文字列では非常に遅くなる
  • Fibonacci が呼び出し木の各段階でおおむね 2 分岐に増えていたとすれば、この再帰は場合によって 3 分岐 に増える
  • Python の functools.cache を付ければ、同じ部分文字列の組み合わせに対する計算結果を再利用できる
  • よりよい実装では新しい文字列を作り続けず、元の文字列 AB と部分文字列長 ab だけを渡す
  • 最後の段階では 2 次元の cache 配列を自前で作り、cache[a][b] = levenstein(A[:a], B[:b]) となるよう順に埋めていく
  • 反復版は ab を 0 から文字列長まで走査し、すでに埋められた前の行と前の列の値を参照する

Advent of Code 2023 Day 12 への適用

  • Advent of Code 2023年12月12日の問題 は、1 次元の nonogram を解く問題である
  • 例の入力は .??..??...?##. 1,1,3 という形式で、?. または # になりうる
  • ブルートフォースなアプローチは backtracking を使うが、疑問符が n 個あると 2^n 個の候補 を評価しなければならず、指数的に大きくなる
  • 同じ部分問題が繰り返される構造が現れる
    • ..#..??...?##. (1),1,3
    • .#...??...?##. (1),1,3
    • すでに処理した先頭部分を捨てると、それぞれ .??...?##. 1,3..??...?##. 1,3 のようにほぼ同じ問題になる
  • 基本的なバックトラッキング関数は conditionsrules を受け取り、可能な配置数を計算する
    • 規則が残っていなければ、残りの条件に # があるかを確認する
    • 条件が残っていなければ、規則が残っているかを確認する
    • 現在の文字が . または ? なら 1 文字進めて計算する
    • 現在の文字が # または ? なら、次の規則サイズと区切り条件を確認したうえで次の状態へ進む
  • Python では @cache を付けるだけでメモ化を適用できる
  • 動的計画法に変えるには、文字列や規則を切り出して渡すのではなく、文字列オフセット i と規則オフセット j を状態として使う
  • その後 cache[i][j] を自前で作り、インデックスを 逆順に埋める方式 で再帰を反復計算に置き換える
  • Rust 実装の例は記事中の Rust implementation リンクで示されている

キャッシュを自分で埋めると見えてくること

  • Advent of Code Day 12 の動的計画法版は、メモ化版より遅く見えることがある
  • この差は最適化されていない Python 実装による可能性がある
  • キャッシュを自分で構築すると、実際にどの値が必要なのかがより明確に見えてくる
  • Day 12 の問題では、動的計画法版を通じて 前の列だけが必要 であることがわかる
  • したがって、2 次元配列を「前の列」と「現在の列」を表す 2 本の 1 次元配列に置き換えられる

練習に向いた問題と結論

  • 動的計画法は簡単ではないが、ほとんどのプログラマにとって手の届かない技法というわけではない
  • 問題を小問題へ分ける方法を理解すれば、多くの状況でメモ化だけでも素朴な実装より大きく改善できる
  • さらに習熟すれば、アルゴリズムの一系統を理解し、トレードオフをよりよく把握し、追加の最適化も見つけやすくなる
  • 練習対象として次の問題が挙げられている
  • 実装後は ベンチマークとプロファイリング を忘れないこと

1件のコメント

 
GN⁺ 2024-01-15
Hacker Newsの意見
  • 記事で、動的計画法のアルゴリズムは再帰をキャッシュする賢い方法にすぎない、と指摘している点がよかった。自分の経験では、まず再帰的な解法を見つけることが動的計画法の解法を見つける最良の出発点で、いったん見つかればメモ化は簡単で、大きな高速化をもたらし得る。
    ボトムアップの動的計画法より速いこともある。実際に必要な解だけを計算するからだ。要点は、呼び出し木に部分問題が多くても構わないが、異なる部分問題の数は比較的少なくなければならないということ。一度しか必要にならない結果をキャッシュする理由はないし、元の問題を十分に少ない数の異なる部分問題へ分割するのが難しい。

    • 異なる部分問題が比較的少なくなければならない、という部分が肝心。アルゴリズム全体が再帰型か反復型かは二次的で、動的計画法はたいてい再帰アルゴリズムでよく現れる。
    • 「動的計画法は再帰をキャッシュする方法」という説明が、自分には腑に落ちるきっかけだった。大学時代は当時手続き型プログラミングが主流だったせいか、教科書のボトムアップで表を埋める例が魔法のように見えた。
      実務上は末尾呼び出しの除去が常に適用されるわけではないので、そのやり方が正しいのだろうが、先により直感的な、トップダウンの再帰キャッシュという観点で学びたかったと思う。
    • 初めて学んだときは、こんなに派手な機能なら配列メモ化とか呼び出しスタックメモ化と呼ぶべきではないかと思った。「動的計画法」という名前は、もっと優れた何かのために残しておくべきだったと思う。
    • 動的計画法を単にメモ化された再帰と見るのは、広く流布している誤解だと思う。そう学ぶと、2次元配列を埋めるタイプの動的計画法問題を理解するのが非常に難しくなる。
      例えばLeetCodeの「株を売買するのに最適なタイミング」シリーズを見ると、https://leetcode.com/problems/best-time-to-buy-and-sell-stoc... のような問題は、配列を埋める方法のほうがずっと自然ではないかと思う。再帰で解いたことはないし、自然な再帰解法があるのかもよく分からない。
      上のリンクはIIIだが、初めて取り組む人には最初の問題 https://leetcode.com/problems/best-time-to-buy-and-sell-stoc... から始めると、動的計画法の入門としてよい。
    • 「動的計画法は単なるキャッシュ/メモ化だ」と言うのは、「投資は単に物を買って後で売ることだ」と言うのに似ている。技術的にはある程度正しいかもしれないが、テーマの複雑さと難しさをあまりにも大きく取りこぼしていて、洞察というより滑稽に見えることがある。
  • 「動的計画法」という名前の由来は、発明者であるRichard Bellmanにある。1950年にRANDで多段階意思決定プロセスに付ける名前を探していたが、当時の国防長官Wilsonは「研究」という言葉を病的に嫌っており、「数学」という言葉はなおさら避けるべきだったという。
    BellmanはRAND内で、実際には数学をしているという事実をWilsonと空軍から隠すための名前を必要としていた。そこで、計画・意思決定・思考を扱うが、「planning」はさまざまな理由で適切ではなかったため「programming」を選び、多段階・時間変化の概念を含めるため、古典物理学で正確な意味を持つ「dynamic」を付けた。
    「dynamic」は形容詞として否定的な意味で使いにくい点も気に入り、議員も反対しにくい名前だったため、dynamic programmingを自分の活動全体を表す名前として使ったという。
    出典: https://alliance.seas.upenn.edu/~cis520/dynamic/2021/wiki/in...

  • この記事が、まず問題を再帰的に明らかにしたうえで、段階的にキャッシュを追加し、最後には必要な分だけキャッシュサイズを小さくしていくやり方が気に入った。
    自分はよく、いきなり動的計画法の解法へ進もうとして行き詰まったり、動かすために無理な努力をしたりしてきた。今後は順番に段階を踏むよう、自分に強制しようと思う。

    • 自分の経験では、動的計画法をいきなり教えるとパズルのように感じられる。段階を追ってなぜ表を使うのかを説明し、その概念をキャッシュと結び付けると、はるかによく理解される。
  • 動的計画法の見事な応用の一つは、ヌクレオチド/タンパク質配列のペアワイズアラインメントだ。
    https://en.wikipedia.org/wiki/Sequence_alignment
    https://en.wikipedia.org/wiki/Needleman%E2%80%93Wunsch_algor...
    https://en.wikipedia.org/wiki/Smith%E2%80%93Waterman_algorit...

    • これらのアルゴリズムは、バイオインフォマティクス/生物学で最も重要なアルゴリズムの一部だと思う。適用範囲は非常に広い。
  • とても優秀なアルゴリズムの教授がいて、UCLAで学んだ人だった。動的計画法の授業が素晴らしく、まず単純な解法が指数時間計算量になる問題から始め、問題をより小さな問題に分割して計算量を多項式レベルまで下げ、さらにメモ化を適用して線形まで落とした。
    そのとき使っていた問題が何だったのか思い出せるといいのだが。

    • 候補としてはフィボナッチ数列、コインのお釣り問題、0/1 ナップサック問題、行列連鎖積、最長共通部分列、最長増加部分列、Floyd-Warshall のような最短経路問題、編集距離(Levenshtein 距離)がある。
      いずれも素朴な解法は非効率で、動的計画法によって大きく改善される代表的な例だ。
    • 記事の中にもいくつか書いてあり、講義や演習でよく見かける問題だ。たとえば最長共通部分列、最長共通部分文字列、line warp、部分集合和、分割、ナップサック問題がある。
      さらに多くの例は https://en.wikipedia.org/wiki/Dynamic_programming#Algorithms... を見るとよい。
    • 他の人が挙げた問題以外では、スケジューリング問題だった可能性もある。たとえば時間的に重なる N 個のイベント、履修時間割や CPU プロセスのようなものを、スループットのような基準で最適化する問題だ。
      「この2つの科目は一緒に履修しなければならない」のような特殊な制約を追加すると、普通の動的計画法よりずっと複雑で扱いにくくなると理解している。
    • UCLAで Kang の下で学んだ人だったのだろうか?
  • 元サイトがトラフィックに耐えられていないようなので、アーカイブリンクを残しておく。
    https://web.archive.org/web/20240114111200/https://qsantos.f...

  • 動的計画法のおかげで合法な囲碁局面数を計算でき、その値は171桁の数字だった。
    素朴な方法では n×n の碁盤上のすべての可能な局面を見るため 3^(n^2) の時間がかかるが、動的計画法は実質的に1次元を取り除き、時間計算量を O(n^5 * 5.4^n)、空間計算量を O(n * 5.4^n) に減らす。
    https://tromp.github.io/go/legal.html
    https://tromp.github.io/go/gostate.pdf

  • 「Dynamic Programming」という名前が不自然に見えることがあるのは、ここでの programming がプログラミング分野を意味していないからだ。この場合は線形計画法と同じように、最適化に近い意味である。
    動的計画法は、離散時間の意思決定問題、つまり制約下で \sum_t u_t(a_t) を最大化する最適な順序 {a_t} を選ぶ問題を解く方法だと見なせる。価値関数 V* を V*(t) = max_{a_t}{ u_t(a_t) + V*(t-1) } と定義し、最適化問題の次元を大きく削減する方式である。

    • 実のところ、この名前の公式な由来 https://en.wikipedia.org/wiki/Dynamic_programming#History はかなりおかしい。Bellman は「dynamic」が否定的な意味では使いようのない形容詞なのでよいと考え、議員でさえ反対できない名前だと見ていたという。
    • 他の人が「動的計画法」という言葉を使うとき、しばしば賢く見せようとする誇示のように感じられることがある。実際には、問題をだんだん小さな部分問題に分けられると気づいた自然で直感的なアプローチを使っただけなのに、何か特別な手法を「使った」と言うようなものだ。
    • 昔は、最適化問題のように何かを計算することが、コンピュータで何をするかについての考え方の中ではるかに支配的だった、という点が興味深い。最近はほとんどがデータの保存・検索とネットワーキングで、その中に計算が含まれていても、たいていはうまくカプセル化されている感じがする。
    • 「最適化」という言葉も同じように誤解を招く。以前「optimization」というコンピュータサイエンスの授業を取ったとき、まったく別のものを期待していたことがある。
    • さらにさかのぼれば、「programming」はこの概念を正確に説明している。今日われわれが「プログラミング」と呼んでいるものは実際にはコードを書くことであり、関数型・宣言型・手続き型といった複数のプログラミングの一分野に分けられる。その傘の下には、はるかに多くのものが含まれている。
  • 「動的計画法」と聞いたとき、単にメモ化だと考えるのは間違いなのだろうか? 欠けている部分は、メモ化を使うために問題を賢く分割することかもしれない

    • メモ化はより一般的な手法。すでに計算した結果を、後でまた必要になった場合に備えてキャッシュしておく程度の場合が多い
      動的計画法は体系的なメモ化に近い。だんだん大きな部分問題を解いて、全体問題の解に到達する。「帰納アルゴリズム」という言い方もある程度しっくりくる。典型的な動的計画法のアルゴリズムは、実質的に数学的帰納法の証明に似ているからだ。残念ながら、その用語はすでに別の意味で使われている
    • 私は動的計画法をまさにそのように教えている。まず再帰的に解き、その次にメモ化を追加する。これをトップダウンと呼ぶ
      その後、再帰とメモ化にはオーバーヘッドがあることを見て、表を下から上へ構築し、再帰呼び出しを取り除けば動的計画法になる
    • 私のアプローチでは、メモ化は動的計画法の3段階のうち第2段階。第1段階は再帰アルゴリズムを見つけること、第2段階はメモ化、第3段階は反復型/ボトムアップにすること、可能なら3bとして空間最適化を行うこと
      第3段階が動的計画法の最も特徴的な部分だが、第2段階で止まっても動的計画法と呼ぶことはできると思う。ただし、可能な限り効率的というわけではない。別の言い方をすれば、メモ化はキャッシュであり、そのキャッシュを事前に埋める方法があるかを問うのが第3段階だ
    • メモ化ベースではない動的計画法の解法もある。たとえば2つの文字列の最長共通部分文字列を探す問題では、表の左と上のマスを一度だけ必要とするので、メモ化はあまり役に立たない
      一般に、部分問題が多く重なり、最適な部分問題が全体の最適解の一部にならなければならないなら、動的計画法の機会がある。メモ化だけが動的計画法だと言うのは、ハッシュテーブルだけが抽象データ型だと言うのに似ている
    • 私の基準では、そう考えるのは間違い。まず、メモ化は動的計画法の外でも使えるという明白な反例がある。逆に、ほとんどの動的計画法アルゴリズムは、結果を表に保存し、後でその表から最良の答えを見つける方式で実装できる
      メモ化は基本的に、アルゴリズムを高速化するための戦略だ
  • 今年のAdvent of Codeを終えるのは楽しかった。1日目、とくにパート2が例年よりはるかに難しかったのは明らかで、それについてはhttps://blog.singleton.io/posts/2024-01-02-advent-of-code-20...にも書いたが、現在の2022年の統計と現在の2023年の統計を比較するだけでは明確ではない。2022年のパズルは、人々が解く時間がさらに1年あったからだ
    2023年1月14日時点の2022年の統計 https://web.archive.org/web/20230114172513/https://adventofc...を取ってみると、差はかなり大きかった。パート2完了の統計 https://blog.singleton.io/static/imgs-aoc23/completion.pngを描いてみると、1日目開始時の集団規模は似ていたが、2023年は15日目まで2022年より明らかに難しそうに見える
    パート1を解いてパート2を解けなかった人の割合 https://blog.singleton.io/static/imgs-aoc23/ratios.pngも、2023年は多くの日でかなり高く、とくに5日目、10日目、12日目、そして22日目パート2が難しかったことを示唆している

    • 初期のAdvent of Codeは面白く、終盤までは大げさな手法がなくても乗り切れた。その後、より難しくなって面白くなくなったので諦め、もう手を出さなくなった
    • 今年のAdvent of Codeは時間が足りず、あまり進められなかったが、後でまたやってみるかもしれない
      ただし、5日目パート2がどれほど難しかったかには驚いた。諦めずに解きはしたが、何か明らかなことを見落として過度に複雑に解いたのではないかと思っていたので、もともと少し挑戦的な問題だったと分かって安心した
    • 個人的な経験にすぎず、普段使う言語ではない別の言語で試した影響もあるだろうが、1日目パート2は難しいというより問題説明が不適切だったと思う
      例として two1nineeightwothreeabcone2threexyzxtwone3four4nineeightseven2zoneight2347pqrstsixteen が与えられていたが、oneight のような重要な例が欠けていた。このような例がないと、値をどう置換すべきかを正確に見極めるのは難しい
    • この議論に付け加えると、日ごとの進捗を見るスクリプトを持っている。最後の2列を見ると、2023年が2022年に比べてどれほど過酷だったかが分かり、とくに序盤がひどかった
      2022年には序盤の数日間、ほとんどの人が参加し続け、多くの日で継続率が80%を超え、ほぼ全員が2つのパートを解いた。一方、2023年の1日目は、パート1を解いた人のうちパート2まで解いた割合が76%にとどまり、3日目と5日目で多くの人が諦めた
      興味深いことに、終盤の数日はそこまで低くないが、これは2023年のAdvent of Codeが2022年より新しいという点で説明できる。私の解釈では、この集団は難易度に関係なく、ある程度まではすべての挑戦を乗り越える人たちであり、他の多くの人たちは時間がかかりすぎると感じるとやめてしまうのだ