ページ構成の際、次の情報源を参考にした。
[Algorithms | Jeff Erickson (inzkyk訳)](https://booth.pm/ja/items/1633486) |
[アルゴリズム | ともめも](https://www.tomotaku.com/category/algorithm/) |
アルゴリズムの実行時間を解析するうえで、私たちは実行時間が入力サイズに対して漸近的に増える効率に興味がある。入力サイズと相対的な実行時間を、おそらく計算量という。
計算量には時間計算量と領域計算量がある。単に計算量といえば、普通時間計算量を指す。領域計算量は空間計算量とも言う。
アルゴリズムの実行時間を表すための、漸近記法をまとめた。
なお、Ο (オミクロン)を変換するのが面倒なので、以降はアルファベットのO (オー)を用いる。
先頭または末尾にデータを追加・削除できるデータ構造を次に述べる。
それぞれのPythonにおける実装とデータ追加・削除の操作をまとめた。(pop()
するのはスタック?キュー?といつも迷うので)
データ構造\実装 | list | collections.deque | queue.Queue |
---|---|---|---|
スタック | 追加: append(), 取り出し: pop() | 追加: append(), 取り出し: pop() | - |
キュー | 追加: append(), 取り出し: pop(0) | 追加: append(), 取り出し: popleft() | 追加: put(), 取り出し: get() |
両端キュー | 追加: append(), 取り出し: pop() / pop(0) | 追加: append(), 取り出し: pop() / popleft() | - |
末尾を取得するのがpop()
と覚えておけば良さそうだ。なお、先頭を取得するのがpull()
の言語もあるようだが、Pythonは違う。
互いに素な集合がある時に、ある2つの要素が同じ集合に属しているか?を調べることをUnion-Findという。
集合を木構造で表したとき、同じ集合に属しているか?を調べるのは根を求めることに等しく、計算量が$O(N)$となりえる。
しかし、union by rank を行うことで$O(log N)$ に、さらに経路圧縮を行うことで$O(α(N))$となる。$α(N)$はアッカーマンの逆関数と呼ばれ、増加がとても遅いことで知られる。1
[!NOTE] Union-Findでfind時に経路圧縮すると、rankと木の高さが一致しないのでは? その通り。rankと木の高さが常に等しいとは限らない。rankの代わりに木のサイズを用いても時間計算量は$O(N)$で変わらないため、説明しやすさのためにそちらを用いても良い。[^saka_pon_2022] [^saka_pon_2022]: Union-Find の実装方法まとめ
アルゴリズム図鑑で紹介されているチェーン法の他に、オープンアドレス法による対応がある。オープンアドレス法には、線形走査法とダブルハッシュ法がある。
[ハッシュ探索②(オープンアドレス法) | Programming Place Plus アルゴリズムとデータ構造編【探索アルゴリズム】 第7章](https://programming-place.net/ppp/contents/algorithm/search/007.html#linear_probing) |
[!NOTE] オープンアドレス法って、ハッシュ値を削除したら、同じハッシュ値で再ハッシュされた値を探せなくならない? なる。したがって、ハッシュ値の削除時には空の番地であることを示す値をいれる。この値をTombstone (墓石)という。2
番兵
for
ループまたはwhile
ループで、探索のための比較以外に終了条件の判定を行うことが面倒であるため、リストの最後に目的のデータをダミーで入れておく。find
などを用いる場合、勝手に最後まで探索してくれるので考慮不要。[計数ソート | アルゴリズムビジュアル大事典](https://yutaka-watanobe.github.io/star-aida/1.0/algorithms/counting_sort/print.html)を参照。 |
シェルソート
パーティション
文字列探索とは、文字列Sが単語Wを含む場合に、それが文字列Sの何文字目から何文字目かを調べるタスクである。例えば、S=”Hello,world!”、W=”world”の場合、6文字目から10文字目が一致する。普通に計算すると、計算量は$O( | S | \cdot | W | )$となる。 |
KMP法は、一度比較した文字は、なるべく2回比較したくない、という気持ちのアルゴリズムである。例えば次の文字列を考える。
この文字列は偶然にも先頭から6文字が一致しているので、先頭から比較を始めたKMP法は7文字目で一致しないことに気づく。ここで単純なアルゴリズムなら、次にSの2文字目”i”とWの1文字目”f”を比較するところから始めるだろう。しかしKMP法は、「もうSの6文字目までは比較したから、次はSの7文字目”r”とWの1文字目”f”を比較したい!」と考える。もちろんこれは失敗する!
失敗する原因は、すでに比較した文字列の中に、先頭が一致する他のパターンが含まれているためである。Sの部分文字列”firefir”の後半のfiは、Wの”fighter”だけでなく”fire”とも比較しなければいけないのだ!そこでKMP法では、Wの各文字に対して、「この文字で比較が失敗したら、Wの直前のX文字分と一致していたSの文字列はWの先頭とも一致するはずだから、Sの現在地をX文字巻き戻した箇所とWの先頭を揃えてから再開してね」という数Xを事前に計算しておく。これを部分マッチテーブルという。
次の4通り。
[動的計画法(DP)をみんながわかるように超ていねいにじっくり説明した | あずぱん動画](動的計画法(DP)をみんながわかるように超ていねいにじっくり説明した) |
次のような問題がある
動的計画法の例として、編集距離の問題を考える。編集距離とは、単語Aを1文字づつ置換・挿入・削除することで、何手で単語Bに変身できるかを指す値である。例えば、MONEY
をFOOD
に変身させるにあたって、次の手順では4手で変身でき、同時に2単語間の最短距離でもある。
次に、MONEY
をLOVE
に変身させる場合で、編集距離の依存関係をフローチャートで示す。動的計画法(編集距離の依存関係の可視化)を参照のこと。
最大部分配列とは、正〜負の整数を要素に持つ配列に対して、連続する部分列で和が最大のものを求める問題である。これを解くKadane[^j_kadane]’s algorithm(カデインのアルゴリズム)について、最適な小構造に注目した説明を考えた。 [^j_kadane]: 2024年現在もCMUに勤めておられる。ご本人の映像があるので、見るとアルゴリズムに親近感が湧くかもしれない。
正〜負の整数がバランスよく含まれる最大部分配列を2つの部分配列に分けると、どちらの部分配列の和も正になる。それを踏まえたうえで、次の手順で最大部分配列を求める。
親、根、木辺などは<#探索木(森)の性質>を参照。
他に次のような定義がある。
グラフ$G=(V,E)$に対して、次のように定義される。
なぜ誘導グラフと呼ぶかについては、「生成部分グラフ」という用語についてを参照。
逆 (reversal): $G$のすべての辺$u→w$を$w→u$に取り換えたグラフ
グラフの探索には、深さ優先や幅優先などいろいろあるが、実は袋(=候補となる辺を入れておくデータ構造)に何を採用するかの違いに過ぎない。
名前 | 袋 | 解決する問題 |
---|---|---|
深さ優先探索 | スタック | |
幅優先探索 | キュー | |
最良優先探索 | 優先度付きキュー | |
最良優先探索 | 優先度付きキュー(辺の重み) | 最小全域木 |
最良優先探索 | 優先度付きキュー(辺の重みの和) | 最短路木 |
最良優先探索 | 優先度付きキュー(路の幅) | 最幅路 |
[Topological Sort Visualized and Explained | Carl the Person](https://www.youtube.com/watch?v=7J3GadLzydI)が短くて分かりやすい。 |
強連結成分を計算するためのアルゴリズムは次の通り。線形時間で計算するための工夫がポイント。
いくつもアルゴリズムがあるが、次に述べる戦略の実装違いといえる。
その戦略とは、入力グラフ$G$に対して、その頂点だけからなる$F$(つまり、$F=(V, ∅)$)をintermediate spanning forest(中間全域森)として定義し、$F$にいい感じの辺を徐々に加えていく戦略である。
プリム法とクラスカル法が有名である。グラフが密な場合に限ってはプリム法が早いが、それ以外は実装が容易なクラスカル法を用いれば良い。
def boruvka(V, E):
F = (V, ∅)
count = count_and_label(F) # 成分数
while count > 1:
add_all_safe_edges(E, F, count)
count ← count_and_label(F)
return F
任意の頂点をスタート地点として、接続する中で重みが最小かつ閉路を作らない辺を付け加えることを繰り返す。グラフが隣接リストで表現されている場合、計算量はO(ElogV)となる。一方で隣接行列を用いた場合の計算量はO(V^2)となるため、辺に比べて頂点が少ない場合に特に高速になる。
[プリム法 (Prim’s Algorithm) | サルでもわかるアルゴリズム](https://www.youtube.com/watch?v=anuJPP3FZ8c)が分かりやすかった。 |
すべての辺の中から、重みが最小かつ付け加えても閉路にならない辺を選ぶ。このようにして選んだ辺の集合が最小全域木となる。
閉路の検出はUnion−Findを用いて高速に行うため、計算量はほぼ辺のソートにかかると考えてよく、計算量はO(ElogE)となる。
重み付きグラフにおいて、2つの頂点を結ぶ経路の中で、重みが最小の経路を求める問題。次の種類がある。
単一始点最短経路を求めるのに用いる。計算量は$O( | V | ^2)$になる。 |
隣接リストと優先度付きキューを用いると、計算量は$O(( | V | + | E | )log | V | )$になる。 |
[Dijkstra’s Algorithm | Depth First](https://www.youtube.com/watch?v=NyrHRNiRpds)が非常に分かりやすい。 |
螺旋本には、隣接リストと二分ヒープを用いると、計算量は$O(( | V | + | E | )log | V | )$になる、とあるが…正直言って優先度付きキューでの実装より複雑だし計算量も変わらないので、私は理解していません…。 |
[!NOTE] 実世界でのダイクストラ法の応用を知りたい
始点から辺の数がn以内でたどり着ける頂点について、n=1,2,3… | V | -1まで順番に最短経路を探す手法。[^sambol_2015]v-1回目のループの後に再度最短経路を探し、距離が更新されれば負の閉路があると判断できる。最短路は閉路を含まないため、その長さは | V | -1となる。負閉路の検出も行うため、計算量はO( | V | * | E | )となる。 |
[^sambol_2015]: [Bellman-Ford in 5 minutes | Michael Sambol](https://www.youtube.com/watch?v=obWXjtg0L64)が分かりやすい。 |
入力サイズ$n$, 定数$k$について、計算量が$O(n^k)$で抑えられるアルゴリズムを多項式時間アルゴリズムという。計算量が$O(n^{k^k})$や$O(k^n)$の場合はいわない。
多項式時間で解ける問題は、複雑性クラスPに属しているという。逆に、多項式時間で解けない問題をNP困難 (NP-Hard)という。また、多項式時間で解けるかどうかに関わらず、多項式時間で検証できる問題をNPという。そして、NP困難かつNP、つまり多項式時間では解けないが、解の検証はできる問題をNP完全 (NP-Complete)という。多くの場合、NP完全の証明では、すでにNP完全であることが証明されている問題に多項式時間で変換できることを示す。
よく言われるP≠NPとは、PがNPの真部分集合である、つまり多項式時間では解けないが解の検証はできる問題が存在する、という命題である。
本節の執筆にあたってヒューリスティック探索入門[^jinnaiyuu_2023]を参考にした。 [^jinnaiyuu_2023]: ヒューリスティック探索入門
ヒューリスティック探索のベンチマークとして、状態空間問題がある。これは初期状態とゴール条件が与えられ、それを満たす経路を探索する問題である。
よく知られたNP困難な問題として巡回セールスパーソン問題 (TSP, Traveling Salesperson Problem)がある。地図上の全ての地点を通りつつ最初の地点に戻る最短経路を求める問題である。最適な経路はその部分経路も最適だから動的計画法で解けるが、その計算量は$O(n\cdot 2^n)$など指数時間になるため、NP困難である。
状態空間問題は完全情報であり、状態遷移が決定的であることを仮定した。状態遷移が確率的である問題はマルコフ決定過程 (MDP, Markov Decision Process)という。
ダイクストラ法では、スタート地点から近い地点を順に探索する。しかし、例えばゴールまでの直線距離が分かったら、もっと効率的な探索ができないか?このように、ノードの有望さを定量化する関数をヒューリスティック関数 (heuristic function)といい、それを用いたアルゴリズムをヒューリスティック探索という。逆に言えば、有望さの見積もりを用いない探索アルゴリズムは、ヒューリスティック探索が常に定数を返す特別な場合とみなせる。
ナップザック問題や巡回セールスパーソン問題などの組合せ最適化問題を考える。例えば、100個ある荷物から、重量が一定以下になるように荷物を選ぶことを考える。$2^{100}$通りから、重量が一定以下かつ価値が最大の組合せを選べばよいが、明らかに計算が終わらない。そこで、この組合せを木に見立てる。木が分岐するとき、その部分問題の全通りを計算するよりも素早く、簡易的な方法で計算を行い、その結果次第では部分問題をスキップして良い場合がある。
例えば0-1ナップザック問題なら、条件を緩めた問題として連続ナップザック問題がある。[^okamotoy_2013]0-1ナップザック問題の解は連続ナップザック問題の解より大きくならない(上界)。そこで、全体の連続ナップザック問題の解と部分問題の解が一致したら、最適値が出たとして計算を打ち切って良いなど、計算対象を限定することができる。[^shioura_2022] [^okamotoy_2013]: 緩和問題とその威力 [^shioura_2022]: 組合せ最適化問題: 分枝限定法
競技プログラミングの基礎的な問題では時間計算量を$O(N^2)$未満に抑えれば良いことが多いが、現実で遺伝子情報などの巨大データを扱うと$O(N)$でも実用的でない。更に空間計算量も問題になる。
そこで、空間計算量を最低限に抑えつつ、時間計算量も$O(N)$未満に抑えよう、というのが簡潔データ構造の動機である。ここで最低限とは、理論値に迫ろう、というくらいのニュアンス。
理論値には「情報理論的下限」という名前がついており、データ本体(簡潔表現 (succinct representation))とインデックス(簡潔索引 (succinct index))に対してだいたい次のように定義されている。正確な定義は教科書を参照してください。3
ちなみに succinct は「サクスィンクト」と発音し、簡潔な、準備ができた、という意味。
word-RAMという架空のアーキテクチャのマシンを想定する。特徴は次の通り。
rankとは、ビットベクトル中の特定の位置までの0または1を数える処理である。シンプルに実装すると時間計算量が$N$になってしまうので、様々な工夫がなされてきた。
まず、全てのビットベクトルに対してその位置までの1の数をあらかじめ数えて表に記録しておくことを考える。時間計算量は表引きに係る分だけになるが、空間計算量が表の行数($N$)×各行の値を表すのに必要なビット数($log_2{N+1}$)となり、簡潔索引の条件を満たさない。
次に、ビットベクトルの$l$ビットごとに累計の1の数をあらかじめ記録することを考える。ここで試しに$l$を$log_2{N}$とすると、簡潔索引の空間計算量は行数($\frac{N}{log_2{N}}$)×値を表すビット数($log_2{N+1}$) = $N$となり、やはり条件を満たさない。もっと広い間隔で記録する必要があるため、$l$を$(log_2{n})^2$とすると、簡潔索引の空間計算量が$\frac{N}{(log_2{N})^2} \cdot log_2{N+1} = \frac{n}{log_2{N}}$となり、これは条件を満たす。この表の1行を教科書では大ブロックという。