基本的なデータ構造:配列・連結リスト・スタック・キュー
対象読者: 本テーマ第 1 章の計算量と O 記法(O・Θ の定義と増大度の比較)を学んだ人。配列やポインタを使う基本的なプログラミング経験を仮定し、データ構造そのものの知識は仮定しません。
前提となる記事:計算量と O 記法:アルゴリズムの速さを入力サイズの関数で測る
0. この記事の要点
Section titled “0. この記事の要点”たくさんのデータを並べて持つとき、並べ方によって「何番目かを一瞬で見る」ことが得意になったり、「途中に割り込ませる」ことが得意になったりします。この記事は、その得意不得意を操作の回数で数えて比べ、なぜそうなるかを証明します。読み終えると、配列と連結リストのどちらを選ぶべきかを理由つきで説明でき、スタックとキューが何を約束する部品なのかも言えるようになります。
- データ構造の優劣は「何ができるか」ではなく「どの操作がどれだけ速いか」で決まります。同じ列を表す配列と連結リストは、速い操作がちょうど正反対です。
- 配列は添字によるアクセスが 、途中への挿入・削除が 。連結リストは位置を指すノードが手元にあればその直後への挿入・削除が 、 番目へのアクセスが です(命題 3.2、命題 4.2)。
- 容量を 2 倍ずつ増やす動的配列は、1 回の push の最悪計算量が であっても、 回の合計は 未満に収まります。償却計算量(たまに高くつく操作を、操作列全体にならして見た 1 回あたりの計算量)は です(定理 3.3)。
- 「償却 」は「平均的に速い」という意味ではありません。入力の確率分布を一切仮定せず、どんな操作列に対しても合計コストが保証されます。
- スタック(LIFO)とキュー(FIFO)は抽象データ型であり、実装に触れずに公理で規定できます。同じ公理を配列でも連結リストでも満たせます。
- キューはリングバッファで最悪 (命題 7.2)、2 本のスタックで償却 (定理 8.2)に実現できます。後者はポテンシャル法(状態に「たまった仕事」の量を割り当てて償却を見積もる方法)で解析します。
プログラムが扱うデータの多くは「列」です。テキストは文字の列、画像は画素の列、口座の履歴は取引の列です。列を計算機の中でどう並べるかには選択の余地があり、その選択によって処理時間が桁単位で変わります。
素朴な疑問から始めましょう。なぜ配列と連結リストという二つの表現が、どちらも半世紀以上使われ続けているのでしょうか。答えは、両者の得意な操作がちょうど正反対だからです。配列は「 番目を見せてほしい」に強く、「途中に割り込ませてほしい」に弱い。連結リストはその逆です。片方がすべての操作で勝っていれば、もう片方は歴史から消えていたはずです。
歴史的にも、この二つは別々の要求から生まれました。配列は最初期の高級言語 Fortran(1957 年)から言語機能として存在します。連結リストはほぼ同じ時期の 1956 年前後に、Allen Newell、Cliff Shaw、Herbert Simon が定理証明プログラム Logic Theorist のために設計した情報処理言語 IPL で導入され、1958 年の LISP に受け継がれました。彼らが必要としたのは「途中に要素を割り込ませても、後ろ全体をずらさなくてよい表現」でした。
この記事では、前章 計算量と O 記法 の ・ 記法(定義 3.1[計算量と O 記法])を道具として使い、まず「何ができるか」(抽象データ型)と「どう実現するか」(データ構造)を分けます。そのうえで配列・連結リスト・スタック・キューを取り上げ、各操作の計算量を証明付きで求めます。
2. 準備:計算モデルと抽象データ型
Section titled “2. 準備:計算モデルと抽象データ型”計算量を「証明する」には、何を 1 単位時間とみなすかを先に決めなければなりません。この記事ではワード RAM モデルを使います。これは前章の 一様コスト RAM モデル(定義 2.1)[計算量と O 記法] を、記憶領域の番地づけまで具体化したものです。
記憶領域は番地 で添字づけられた語の列 であり、1 語は整数か番地を 1 つ保持できます。番地 を指定した の読み出し・書き込み、および語同士の加減乗除と比較は、いずれも 1 単位時間で実行できるものとします。この「番地を指定した読み書きが定数時間」という仮定が、後で見る「配列の添字アクセスが 」の根拠です。
ワード RAM は理想化です。実際の計算機には階層的なキャッシュがあり、1 語のアクセス時間は一定ではありません。それでもこのモデルを使うのは、アルゴリズムの本質的な差( か か か)がキャッシュの有無に影響されないからです。同じ どうしの実測差はモデルの外にあり、注意 5.1 で扱います。
次に、データ構造を論じるための基本的な区別を導入します。
抽象データ型(abstract data type, ADT)とは、次の 3 つの組であって、値の内部表現を一切含まないものをいう。
- 値の集合。
- 操作の名前と、その引数および返り値の型。
- 操作が満たすべき性質。事前条件と事後条件で書いてもよいし、操作どうしが満たす等式(公理)で書いてもよい。
抽象データ型を、具体的な記憶領域の配置と手続きによって実現したものをデータ構造という。
この区別が効くのは、同じ抽象データ型に複数のデータ構造がありうるからです。どれを選ぶかは「正しさ」では決まらず、どの操作を何回使うかと、その操作の計算量で決まります。
3. 配列と動的配列
Section titled “3. 配列と動的配列”定義に現れるこの 1 本の式が、配列の強さと弱さの両方を決めています。番地が の一次式で書けるので、どの要素にも計算だけで到達できます。一方、要素を 1 つ割り込ませると、それ以降の要素の がすべて 1 ずつずれ、番地もすべてずれます。順に確認します。
長さ の配列 について、ワード RAM モデルの下で次が成り立つ。
- 添字 ()を指定した要素の読み出しと書き込みは 時間で行える。
- 与えられた値 が に現れるかどうかを判定する探索は、要素の並びに順序の仮定がない場合、最悪 時間を要する。先頭から順に比較する線形探索がこの計算量を達成する。
- 位置 ()への挿入は既存要素の移動をちょうど 回、位置 ()の要素の削除は 回必要とし、いずれも 時間で行える。とくに最悪()は であり、挿入位置 が 上一様分布に従うときの平均移動回数は である。
次の証明のねらいは二つです。一つは、添字アクセスが定数時間であることを、定義の式から読み取ること。もう一つは、探索が より速くならないことを示すことです。後者は、どんなアルゴリズムでも「読んでいない位置」があれば、そこだけ入力を書き換えて答えを狂わせられる、という議論(敵対者論法)で示します。
証明(命題 3.2)
1. 定義 3.1 より です。 と は配列ごとに固定なので、この計算は乗算 1 回と加算 1 回。ワード RAM では算術演算と番地指定の読み書きがそれぞれ 1 単位時間なので、合計は定数時間、すなわち です。 にも にも依存しない点が重要です。
2. 上界は線形探索が与えます。 の順に を読んで と比較し、一致すれば真を返し、最後まで一致しなければ偽を返します。各反復は 1 の意味で定数時間なので、全体で です。
下界を敵対者論法で示します。正しいアルゴリズム が、 を含まない入力 に対し、ある位置 を一度も読まずに「含まない」と答えたとします。 の位置 だけを に置き換えた を与えると、 は を読まないので同じ計算をたどり、やはり「含まない」と答えます。しかし は を含むので誤りです。よって 箇所すべてを読む必要があり 、上界と合わせて です。
3. 挿入を示します。挿入後の配列 は ()、、()を満たします。 の要素は例外なく番地が だけ後ろにずれるので、少なくとも 回の書き込みが必要です。逆に、 の順に と後ろから詰めれば、上書きによる損失なく 回の書き込みで済み、最後に を 1 回行えば完了します。1 回の移動は 1 の意味で定数時間なので、合計 です。削除も同様に、 の順に と前へ詰めることで 回の移動、すなわち です。
最悪は で移動回数 、よって です。平均は
となり、これも です。平均を取っても から逃げられません。
配列の弱点はもう一つあります。長さを最初に決めなければならないことです。要素数が実行時に増えていく場合、配列そのままでは扱えません。この問題を解くのが動的配列(可変長配列)です。素朴に「1 つ増えるたびに長さ の配列を確保して全部コピーする」と、 回の追加で 回のコピーが発生します。ところが、増やし方を「」から「 倍」に変えるだけで、合計が線形に落ちます。
次の規則で末尾追加 を実現するデータ構造を考える。容量 の配列 と現在の要素数 ()を保持し、初期状態を 、 とする。 は次のように動作する。
- ならば とし、 とする。
- ならば、まず長さ の配列を新たに確保して既存の 個の要素をそこへ移し、 としてから上の操作を行う。
このとき、空の状態から を 回()行うときの要素の書き込み回数の合計は 未満である。したがって 1 回の あたりの償却計算量は である。ただし、個々の の最悪計算量は である。
証明(定理 3.3)
集約法(総コストを直接見積もり、操作回数で割る方法)で示します。
書き込みを 2 種類に分けます。 という末尾への書き込みは、どの でもちょうど 1 回起きるので合計 回です。
残りは容量拡張に伴うコピーです。拡張が起きるのは の直前に のときで、容量は と 2 の冪をたどるので、これは直前の要素数が ()のときに限られ、コピー回数はちょうど です。 回の の間にこの拡張が起きるのは を満たす に対してだけなので、 をそのような最大の とすると、コピーの総数は
です。最後の不等号で を使いました。
以上より総書き込み回数は 未満です。 回の操作で総コストが 未満なので、1 回あたりの償却コストは 未満、すなわち です。
最悪計算量については、要素数が の状態での が 回のコピーを行うので、命題 3.2 の 1 より 時間かかります。
例 3.4(10 回の push を数え上げる)
空の状態から 10 回 したときの書き込み回数を数えます。拡張が起きるのは直前の要素数が のとき、すなわち 2・3・5・9 回目の です。
| push 番号 | 直前の | 拡張後の容量 | コピー回数 | この回の書き込み合計 |
|---|---|---|---|---|
| 1 | — | 0 | 1 | |
| 2 | 2 | 1 | 2 | |
| 3 | 4 | 2 | 3 | |
| 5 | 8 | 4 | 5 | |
| 9 | 16 | 8 | 9 | |
| 4, 6, 7, 8, 10 | — | — | 0 | 各 1 |
総書き込み回数は で、定理の保証する上界 を下回っています。9 回目の だけで 9 回の書き込みが必要ですが、平らにならせば 1 回あたり 回です。
上の例の各 push の書き込み回数を棒で描くと、高くつく回が容量の倍増に合わせて現れ、その間隔が倍々に開いていく様子が分かります。
注意 3.5(償却と平均は別のもの)
償却計算量は確率をまったく含みません。定理 3.3 が主張しているのは「どんな の並びに対しても、合計が 未満」という決定論的な保証です。ハッシュ表の平均計算量(定理 5.3[探索アルゴリズム])のように「入力が一様分布なら」という仮定を置く議論とは、意味も強さも違います。この区別については 注意 6.5[探索アルゴリズム] も参照してください。
また、この定理は増やし方が定数倍であること(コピー回数が幾何級数になること)に依存しています。「毎回 」にすると 回目の で 個のコピーが発生し、合計 となって償却 は崩れます。逆に、比率が より大きい定数でありさえすれば 倍である必要はなく、 倍でも 倍でも同じ議論が通ります。
4. 連結リスト
Section titled “4. 連結リスト”配列の弱点は「番地が添字で決まってしまう」ことでした。ならば、番地を要素自身に持たせればよい、というのが連結リストの発想です。
ノードとは、値を保持するフィールド と、別のノードへの参照または を保持するフィールド からなる区画をいう。ノード が
を満たすとき、この列を単方向連結リストといい、先頭ノードへの参照 によってリスト全体を表す。空のリストは で表す。各ノードが フィールドも持ち を満たすものを双方向連結リストという。
ノードの番地には何の制約もありません。記憶領域のどこに散らばっていてもよく、つながりは の値だけが表しています。この自由さが、そのまま計算量の特徴になります。
個のノードからなる単方向連結リストについて、ワード RAM モデルの下で次が成り立つ。
- ノード への参照が与えられているとき、 の直後への新しいノードの挿入、および の直後のノードの削除は、いずれも 時間で行える。
- 先頭から数えて 番目( 始まり、)のノードへ到達するには、 の追跡がちょうど 回必要であり、 時間かかる。最悪は である。
- 単方向連結リストでは、ノード への参照が与えられていても、 自身の削除には最悪 時間かかる。双方向連結リストであれば 時間で行える。
証明(命題 4.2)
1. 挿入は、新しいノード を用意して
の 2 回の書き込みで完了します。実行後、 の直後は 、 の直後は元の となり、定義 4.1 の連結条件が保たれます。削除は、 のとき
の 1 回の書き込みで完了します。どちらも読み書きの回数が にも にも依存しないので 時間です。命題 3.2 の 3 では位置 の挿入に 回の移動が必要でしたが、ここでは要素をひとつも動かしていません。
2. 上界は、 から始めて を 回たどる手続きが与えます。各追跡は 1 語の読み出しなので定数時間、合計 です。
下界については、このモデルでノードの番地を知る手段が「 を読む」「既に到達したノードの を読む」の 2 つしかないことを使います。定義 3.1 の配列と違い、 番目のノードの番地を から計算する式は存在しません。したがって到達済みノードの集合は 1 回の追跡で高々 1 個しか増えず、 に至るには少なくとも 回の追跡が必要です。以上より です。
3. 自身の削除には、直前のノード を見つけて とする必要があります。単方向リストでは から をたどれないため、 から順に が に一致するノードを探すしかなく、2 より最悪 です。双方向連結リストなら が定数時間で得られるので、
の 2 回の書き込みで済み、 時間です(両端の の場合分けは 注意 4.4 の番兵で消せます)。
なお、 が末尾でなければ、直後のノードの を に複写してから直後のノードを削除する方法で、単方向リストでも で「 の値を取り除いた」状態にできます。ただしこれはノード という実体を残して中身を入れ替えるだけで、 を指していた他の参照が別の値を指すようになります。また が末尾のときはこの方法が使えず、直前のノードを探すしかないので、最悪の場合の は変わりません。ここでの主張は、ノードの同一性を保ったまま取り除く場合の話です。
例 4.3(挿入と削除を実際に動かす)
Python で 命題 4.2 の 1 を確かめます。
class Node: __slots__ = ("value", "next")
def __init__(self, value, next=None): self.value = value self.next = next
def insert_after(p: Node, x) -> Node: """p の直後に値 x のノードを挿入して、そのノードを返す。""" q = Node(x, p.next) # q.next <- p.next p.next = q # p.next <- q return q
def delete_after(p: Node) -> None: """p の直後のノードを削除する。直後が無ければ何もしない。""" if p.next is not None: p.next = p.next.next
def to_list(head: Node) -> list: out, cur = [], head while cur is not None: out.append(cur.value) cur = cur.next return out
head = Node(3, Node(1, Node(4)))print(to_list(head)) # [3, 1, 4]insert_after(head, 5)print(to_list(head)) # [3, 5, 1, 4]delete_after(head.next)print(to_list(head)) # [3, 5, 4]insert_after の本体は 2 行、delete_after は 1 行で、どちらもリストの長さに依存するループを含みません。これが の中身です。一方 to_list は末尾まで をたどるので かかり、長さを知りたいだけでも全体の走査が要ります(要素数を別に保持すれば です)。
注意 4.4(番兵ノード)
実装では、値を持たないダミーのノード(番兵、sentinel)を先頭に 1 つ置き、 の代わりに番兵への参照を保持する手法がよく使われます。こうすると「先頭への挿入」「先頭の削除」が「番兵の直後への挿入・削除」に統一され、命題 4.2 の 1 がそのまま適用できます。空リストの判定は「番兵の が かどうか」になります。場合分けが減るぶんバグも減るので、実装するときは番兵から始めるとよいでしょう。
5. 二つの表現の対比
Section titled “5. 二つの表現の対比”ここまでの結果を並べます。まず、両者のメモリ上の姿を見比べてください。
命題 3.2 と 命題 4.2、および 定理 3.3 をまとめると、次の表になります。連結リストは末尾ノードへの参照も保持しているものとします。
| 操作 | 動的配列 | 単方向連結リスト |
|---|---|---|
| 番目の読み書き | (最悪 ) | |
| 値による探索(順序の仮定なし) | ||
| 値による探索(整列済み) | (二分探索) | |
| 先頭への挿入・先頭の削除 | ||
| 末尾への追加 | 償却 | |
| 末尾の削除 | (直前ノードが必要) | |
| 添字 で指定した位置への挿入・削除 | ||
| 手元にあるノードの直後への挿入・削除 | ||
| 要素以外に必要な記憶領域 | 未使用の容量(最大で要素数と同程度) | ノードごとに参照 1 個分 |
表の最後から 2 行目が両者の性格をもっとも鋭く表しています。「今いる場所の隣に入れる」は連結リストでは 、配列では 。逆に「 番目に飛ぶ」は配列では 、連結リストでは です。選択は、この 2 種類の操作をそれぞれ何回使うかで決まります。
注意 5.1(定数因子とキャッシュ)
記法は定数因子を隠します。実測では、隠された部分が効くことがあります。現代の CPU は主記憶を語単位ではなくキャッシュライン単位(典型的には 64 バイト)で読み込むため、配列を先頭から走査すると 1 回の読み込みで複数の要素がキャッシュに載ります。連結リストの走査では、次のノードの番地が読んでみるまで分からず、ノードが記憶領域に散らばっているほどキャッシュミスが増えます。
結果として、「途中への挿入が多いから連結リスト」という判断が、要素数が数千程度までは実測で裏切られることがあります。迷ったら、まず動的配列で書いて測ってから考えるのがよいでしょう。
6. スタック
Section titled “6. スタック”ここからは抽象データ型の側に移ります。スタックは、最後に入れたものが最初に出てくる(last in, first out, LIFO)という一点だけを規定した型です。
スタックとは、次の操作をもつ抽象データ型である。値の型を 、スタックの全体を と書く。
- (空のスタック)
- (積む)
- (頂上を取り除く)
- (頂上を読む)
これらは任意の 、 について次の公理を満たす。
と は定義されない(実装ではエラーとする)。
公理 が LIFO そのものです。 を積んでから取り除くと、積む前の状態にぴったり戻る、と言っています。この 4 本の等式のどこにも配列やノードは現れません。実装が何であれ、これらを満たせばスタックです。実装は 2 通りあり、どちらも既に得た結果から計算量が読めます。
動的配列による実装. 要素を と並べ、頂上を とします。 は末尾追加なので償却 (定理 3.3)、 は 、 は の読み出しなので最悪 です(命題 3.2 の 1)。命題 3.2 の 3 が要求する要素の移動は、末尾での操作に限れば となって発生しません。
連結リストによる実装. 頂上を先頭ノードとし、 を先頭への挿入、 を先頭の削除とします。番兵を置けばどちらも 命題 4.2 の 1 に帰着し、最悪 です。償却ではなく最悪で なので、1 回の応答時間に上限が要る場面ではこちらが有利です。代わりにノードごとに参照 1 個分の領域がかかります。
スタックの使い道を、証明のつく形で一つ見ておきます。括弧の対応判定です。
記号の集合を とし、対応の取れた括弧列の集合 を、次の 3 つの規則で生成される最小の集合として定める。
入力 に対する次のアルゴリズム を考える。空のスタック から始め、 を左から 1 文字ずつ読む。
- 読んだ文字が開き括弧なら、それを に する。
- 読んだ文字が閉じ括弧なら、 が空であれば直ちに拒否し、そうでなければ を見て し、取り出した文字がその閉じ括弧と同種の開き括弧でなければ拒否する。
全文字を読み終えた時点で が空なら受理、空でなければ拒否する。このとき、 が を受理することと であることは同値である。また の実行時間は であり、 を受理する場合は である(途中で拒否すれば早く終わる)。
証明のねらいは、「アルゴリズムが受理する」ことと「規則から作れる括弧列である」ことが完全に一致する、と確かめることです。前半は、規則に沿って作った列なら、読み進めてもスタックが元の状態に戻ることを、規則の作り方に沿った帰納法で示します。後半は、受理される列の最初の開き括弧に注目し、それと対応する閉じ括弧で列を「中身」と「残り」に切り分けます。
証明(定理 6.2)
準備(スタックの相対性). はスタックの頂上しか参照せず、空のスタックから しようとした時点で拒否します。したがって、初期スタックを として を処理する実行と、空スタックから同じ を処理する実行は、後者が「空からの 」で拒否する場合を除いて、 より上の変化がまったく同じです。以下この事実を繰り返し使います。
() ならば受理する. 次の、より強い主張を の生成規則に関する構造帰納法で示します。
ならば、任意の初期スタック から を処理したとき は拒否せず、処理後のスタックは に戻る。
- のとき。何も読まないので拒否せず、スタックは のままです。
- ()のとき。最初の を読むとスタックは になります。帰納法の仮定を初期スタック で に適用すると、拒否せず、処理後のスタックは に戻ります。次に を読むと、スタックは空でなく頂上は なので して種類が一致し、拒否しません。処理後のスタックは です。 も同じ議論です。
- ()のとき。帰納法の仮定を初期スタック で に適用すると、拒否せずスタックは に戻ります。続けて同じ仮定を に適用すれば、やはり拒否せずスタックは です。
とくに を空スタックとすれば、 は拒否せず、読み終えたときのスタックは空なので受理します。
() 受理するならば . に関する強い帰納法で示します。 なら生成規則の第 1 条より です。 とします。
が閉じ括弧だとすると、その時点でスタックは空なので は拒否し、仮定に反します。よって は開き括弧です。これを 、対応する閉じ括弧を と書きます。 は 1 文字目で され、受理時にはスタックが空なので、どこかで されます。それが 文字目を読んだときだとします。
はスタックの最下段にあり 文字目で初めて取り除かれるので、位置 の処理中はスタックに が残り続けます。すなわち の処理は より上だけで完結し、 文字目の直前のスタックはちょうど 1 個です。準備の観察より、 を空スタックから処理しても拒否せず、処理後は空になります。 なので、帰納法の仮定より です。
文字目では が され、 が拒否しなかったので です。残りの の処理は空スタックから始まり、全体が受理されるので空スタックで終わります。 より です。以上より で、生成規則の第 2 条から 、第 3 条から を得ます。
計算量. 各文字につき か と が高々 1 回ずつで、連結リスト実装ならこれらは最悪 です(命題 4.2 の 1)。よって全体で です。受理される場合は全文字を読むので です。
例 6.3(判定アルゴリズムを走らせる)
に対する の実行を追います。スタックは左を底として書きます。
| 読んだ文字 | 動作 | 直後のスタック |
|---|---|---|
| push | ||
| push | ||
| pop( と一致) | ||
| pop( と一致) | 空 | |
| push | ||
| pop( と一致) | 空 |
読み終えてスタックが空なので受理します。実際 かつ で、生成規則の第 3 条から です。一方 では、2 文字目で した結果が となり と種類が違うので拒否されます。 では拒否は起きませんが、読み終えたときスタックに が 1 個残るので拒否されます。実装は次のとおりです。
def is_balanced(w: str) -> bool: partner = {")": "(", "]": "["} stack = [] for c in w: if c in "([": stack.append(c) elif c in ")]": if not stack or stack.pop() != partner[c]: return False else: raise ValueError(f"unexpected symbol: {c!r}") return not stack
print(is_balanced("([])()")) # Trueprint(is_balanced("(]")) # Falseprint(is_balanced("(()")) # False最後の return not stack が「読み終えたときスタックが空」という受理条件に対応します。この 1 行を忘れると "(((" を受理してしまいます。
この判定は、深さ優先探索や再帰呼び出しの管理と同じ形をしています。「開いたものを、開いた順序の逆に閉じる」構造が現れる場所には、たいていスタックがあります。
7. キュー
Section titled “7. キュー”キューは、最初に入れたものが最初に出てくる(first in, first out, FIFO)型です。窓口の行列と同じ規律で、待ち行列とも呼ばれます。
キューとは、次の操作をもつ抽象データ型である。値の型を 、キューの全体を と書く。
- (空のキュー)
- (末尾に加える)
- (先頭を取り除く)
- (先頭を読む)
これらは任意の 、 について次の公理を満たす。
と は定義されない。
定義 6.1 と見比べてください。スタックでは と、直前に積んだものがそのまま取れました。キューでは が をすり抜けて奥へ潜っていきます。この「すり抜け」が FIFO の正体で、公理の右辺に再帰が現れる理由です。
素朴な配列実装はうまくいきません. 先頭を常に に置くと、 は末尾追加なので償却 ですが、 は位置 の削除であり 命題 3.2 の 3 より 、 回で です。原因は先頭を に固定したことなので、固定をやめれば解決します。
容量 の配列 、先頭位置 ()、要素数 ()を保持し、次のように操作を定める。
- : のとき 、。
- : のとき戻り値を とし、、。
- : のとき を返す。
このとき、キューの内容を先頭から順に とすると、常に が成り立ち、定義 7.1 の公理が満たされる。また 3 つの操作はいずれも最悪 時間である。
証明のねらいは、要素を一度も動かさなくても「 番目の要素は にある」という関係が操作のたびに保たれることを確かめる点にあります。mod(割り算の余り)を使うのは、配列の右端を越えたら左端へ戻るためです。
証明(命題 7.2)
主張の等式 ()を不変条件として、操作回数に関する帰納法で示します。
初期状態は なので、条件は空虚に成り立ちます。
の場合。新しい内容は で は変わりません。書き込み先 が既存の位置 ()と重ならないことを確かめます。 より と の差は の倍数になり得ないので、 を法として異なります。したがって既存要素は壊れず、新しい末尾要素は番号 の要素として等式を満たします。
の場合。戻り値は でキューの先頭です。新しい内容 を改めて と番号づけ、 とすると
となって不変条件が保たれます。
が を返すことは の場合そのものです。以上より と はつねに先頭要素に作用し、 は末尾にのみ加えるので、定義 7.1 の公理が満たされます。計算量は、各操作が定数回の加算・剰余・比較と 1 回の配列アクセス(命題 3.2 の 1 より )からなることから最悪 です。
例 7.3(容量 4 のリングバッファ)
、初期状態 、 から始めます。 の未使用区画を _ と書きます。
| 操作 | 書き込み先 / 読み出し元 | |||
|---|---|---|---|---|
a _ _ _ | 0 | 1 | ||
a b _ _ | 0 | 2 | ||
a b c _ | 0 | 3 | ||
a b c _ | 1 | 2 | ||
a b c d | 1 | 3 | ||
e b c d | 1 | 4 | ||
e b c d | 2 | 3 |
最後の状態でキューの内容は であり、不変条件どおり 、、 です。 を書き込むときに配列の右端を越えて左端へ回り込む点が「リング」の由来で、要素は一度も移動していません。
注意 7.4(満杯と空の区別、そして容量拡張)
スタックが深さ優先探索を、キューが幅優先探索を駆動します。どちらも グラフアルゴリズム の中核で、幅優先探索が最短距離を正しく求めることはキューの FIFO 規律から従い(定理 3.2[グラフアルゴリズム])、深さ優先探索では処理中の頂点がちょうどスタックをなします(補題 4.1[グラフアルゴリズム])。また、配列を「表」として使い添字アクセスの に全面的に依存するのが 動的計画法 です(定理 3.2[動的計画法])。
8. ポテンシャル法と 2 本のスタックによるキュー
Section titled “8. ポテンシャル法と 2 本のスタックによるキュー”定理 3.3 では総コストを直接数え上げました(集約法)。操作が複数種類あって互いに影響し合うと、この数え上げは難しくなります。そこで使うのがポテンシャル法で、データ構造の「たまった仕事」を実数値の関数で表し、各操作でその増減を見ます。
次の補題は、この方法で「償却コストの合計は実コストの合計以上」と言える条件を述べます。直感的には、貯金()が初めより減らない限り、償却コストで払った額が実際にかかった額を下回ることはない、ということです。証明では、 の増減を足し合わせると両端だけが残る、という計算を使います。
データ構造の状態の列 ( は初期状態、 は 番目の操作の直後の状態)と、 番目の操作の実コスト が与えられているとする。状態の集合上の実数値関数 が
を満たすとする。償却コストを で定めると
が成り立つ。とくに、ある定数 がすべての について を満たすならば、 回の操作の総コストは 以下である。
証明(補題 8.1)
定義から
です。右辺の第 2 項は望遠鏡和で、隣接する項が打ち消し合って になります。よって
であり、移項すれば等式を得ます。仮定より すなわち なので不等式が従います。最後の主張は から明らかです。
は「今の状態が抱えている借金」だと思ってください。安い操作で少しずつ借金を積み、高い操作でまとめて返済します。返済の瞬間は実コストが大きくても が大きく減るので、償却コストは小さくなります。
証明は二段に分かれます。まず、移し替えで順序が 2 回反転して元に戻るため、この実装が FIFO を守ることを確かめます。次に、償却コストを見積もるポテンシャルとして、in の要素数の 2 倍を選びます。係数 2 の理由は、in にある要素 1 個が、後で out へ移されるときにちょうど pop 1 回と push 1 回、つまり 2 回分の仕事を必要とするからです。enqueue のときに先に 2 回分を積み立てておけば、移し替えの高いコストをその貯金で支払えます。
証明(定理 8.2)
正当性. キューの内容を先頭から順に とし、次を不変条件とします。
ある ()が存在して、 を頂上から底へ読むと となり、 を底から頂上へ読むと となる。
初期状態は 、両方のスタックが空で、 として成立します。
では を底から読むと となり、新しいキューの内容は ですから、同じ で不変条件が保たれます。
移送が起きるのは が空、すなわち のときで、このとき を底から読むと です。 から される順序は で、これを順に に するので、 を底から読むと 、すなわち頂上からは です。 は空なので として不変条件が成立します。順序が 2 度反転して元に戻る、というのがこの実装の仕掛けです。
が空でないとき()、その頂上は不変条件より 、すなわちキューの先頭です。よって は を返し、 は を取り除きます。取り除いた後は の頂上から が並ぶので、番号を付け替えれば で不変条件が保たれます。 のときは両方が空で、移送しても は空のままなのでエラーとなり、定義 7.1 が を未定義としていることと整合します。
償却計算量. ポテンシャルを
と定めます。 で、初期状態では なので、補題 8.1 の仮定 が満たされます。コストは 1 回のスタック操作を 1 と数えます(仮定よりこれは最悪 時間です)。
- :実コストは 1 回で 。 の要素数が 1 増えるので 。よって 。
- が空でないときの :実コストは 1 回で 。 は変わらないので 。よって 。
- が空で に 個あるときの :移送で が 回、 が 回、その後の が 1 回なので 。 の要素数が から になるので 。よって 。
- : の代わりに を読むだけなので、上の 2 つと同じ計算で 。
いずれの場合も です。補題 8.1 より、 回の操作の総コストは 以下であり、1 操作あたりの償却計算量は です。
最悪計算量. 個すべてが に積まれ が空の状態での は 回のスタック操作を行うので です。償却が であることと矛盾しません。この高価な が起きる前には、 個の要素を積むために 回の が必要だからです。
例 8.3(2 本のスタックを動かす)
実装と、 の変化を追った実行例を示します。
class TwoStackQueue: def __init__(self): self._in = [] # 末尾が頂上 self._out = [] # 末尾が頂上
def enqueue(self, x): self._in.append(x)
def _transfer(self): while self._in: self._out.append(self._in.pop())
def dequeue(self): if not self._out: self._transfer() if not self._out: raise IndexError("dequeue from empty queue") return self._out.pop()
def front(self): if not self._out: self._transfer() if not self._out: raise IndexError("front of empty queue") return self._out[-1]
def __len__(self): return len(self._in) + len(self._out)
q = TwoStackQueue()for x in "abc": q.enqueue(x)print(q.dequeue(), q.dequeue()) # a bq.enqueue("d")print(q.dequeue(), q.dequeue()) # c d各操作の実コスト 、ポテンシャル 、償却コスト は次のようになります。
| 操作 | 直後の (底から) | 直後の (頂上から) | |||
|---|---|---|---|---|---|
| 初期状態 | — | — | — | 0 | — |
| — | 1 | 2 | 3 | ||
| — | 1 | 4 | 3 | ||
| — | 1 | 6 | 3 | ||
| — | 7 | 0 | 1 | ||
| — | 1 | 0 | 1 | ||
| 1 | 2 | 3 | |||
| — | 1 | 2 | 1 | ||
| — | — | 3 | 0 | 1 |
4 行目の は実コスト ( 3 回、 3 回、 1 回)と飛び抜けていますが、 が から へ落ちるので償却コストは です。総実コストは 、操作回数は で、補題 8.1 の上界 を下回ります。
定義 7.1 の公理だけを使って、次の等式を導いてください。途中でどの公理を使ったかを明記してください。
また、この結果と の公理から、、 の順に入れたキューから 1 つ取り出すと が残ることを確かめてください。
解答
と置きます。公理 を 、 に適用すると です。よって の公理の第 2 の場合が使えて
となります。次に に、同じ公理の第 1 の場合(内側の引数が )を適用すると です。代入して
を得ます。
さらに の公理の第 1 の場合より です。先に入れた が先に出ていったので、確かに FIFO です。定義 6.1 の公理で同じ計算をすると となり、残るのは です。公理の違いがそのまま挙動の違いになっています。
演習 9.2標準
単方向連結リストを、 時間・ 追加領域で反転する手続きを書いてください。新しいノードを作らず、既存のノードの を書き換えるだけで行うこと。ループ不変条件を述べ、それを使って正当性を示してください。
解答
3 つの変数だけを使います。
def reverse(head): prev = None cur = head while cur is not None: nxt = cur.next # 先に控えないと next を上書きした瞬間に迷子になる cur.next = prev # 向きを反転 prev = cur cur = nxt return prev不変条件. 元のリストを とします。ループの各反復の開始時点で、ある ()について次が成り立ちます。
prevは、 をこの順に連ねたリストの先頭( のときは )である。curは ( のときは )である。- この 2 本のリストは互いに素で、合わせて元のノード全体をなす。
初期化. ループに入る直前は prev = None、cur = head なので、 で成立します。
維持. 反復開始時に で成立し、cur とします。nxt に (存在しなければ )を控えます。cur.next = prev により の直後が になり、prev から始まるリストは になります。この代入で失われるのは から への参照だけですが、それは nxt に控えてあります。最後に prev = cur、cur = nxt とすれば、 について不変条件が成り立ちます。
終了. ループは cur が になったとき、すなわち のときに終わります。不変条件より prev は を連ねたリストの先頭で、これは元のリストの反転です。
計算量. ループは各ノードにつきちょうど 1 回、合計 回まわり、1 回の本体は代入 4 個で定数時間です(命題 4.2 の 1 と同じ理由です)。よって 時間。追加で使う記憶領域は変数 3 個だけなので です。
なお nxt の退避を省くと、cur.next = prev の直後に へ到達する手段が失われます。命題 4.2 の 2 の証明のとおり、ノードの番地は参照をたどってしか得られないからです。
定理 3.3 の動的配列に、要素の削除 と、記憶領域を返すための縮小規則を加えます。
- 「 の後、要素数が容量の半分以下になったら容量を半分にする」という規則を採用すると、償却 が成り立たなくなります。 回の操作で総コストが になる操作列を具体的に構成してください。
- 「要素数が容量の を下回ったら容量を半分にする」という規則なら、 と の償却コストが定数に保たれます。連続する 2 回の容量変更の間に少なくとも何回の操作が必要かを評価して、その理由を説明してください。
解答
1. 容量 、要素数 (満杯)の状態から始め、 と を交互に繰り返します。
- :満杯なので容量が に拡張され、 個がコピーされます。実行後は 、。
- :実行後 となり、 なので縮小規則が発動し、容量が に戻って 個がコピーされます。
これで最初の状態に完全に戻るので、以降同じことが繰り返されます。2 回の操作ごとに 回のコピーが発生するので総コストは となり、 と取れば です。原因は、拡張の閾値と縮小の閾値が同じ点にあることです。
2. 閾値をずらすと、容量変更の直後に境界から遠い場所に着地します。容量変更の直後の状態を見ます。
- 拡張の直後:容量 、要素数 。
- 縮小の直後:縮小前の容量を 、そのとき要素数は を下回った直後なので ( 単位の誤差を除く)。容量は 。
どちらの場合も です。次に拡張が起きるには が に達する必要があるので が少なくとも 回、次に縮小が起きるには が を下回る必要があるので が少なくとも 回必要です。したがって、連続する 2 回の容量変更の間には少なくとも 回の操作があります。
容量 の変更にかかるコストは ですから、これを直前の 回以上の操作に割り振ると 1 回あたり定数です。形式的には、補題 8.1 のポテンシャルとして
を取れば、拡張・縮小の有無による場合分けで償却コストが定数に抑えられることを確かめられます。どちらの式も容量変更の直後()で になり、境界へ近づくほど増える点が要点です。
- T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 4th ed., MIT Press, 2022 — 第 10 章(Elementary Data Structures)に配列・連結リスト・スタック・キューが、第 16 章(Amortized Analysis)に集約法・課金法・ポテンシャル法があります。動的配列(table doubling)の解析も第 16 章です。
- D. E. Knuth, The Art of Computer Programming, Volume 1: Fundamental Algorithms, 3rd ed., Addison-Wesley, 1997 — §2.2(Linear Lists)。§2.2.1 がスタック・キュー・デック、§2.2.2 が逐次配置(配列)、§2.2.3 が連結配置(連結リスト)で、この記事の §3 から §7 に対応します。連結リストの歴史的経緯も §2.6 に記述があります。
- A. V. Aho, J. E. Hopcroft, J. D. Ullman, Data Structures and Algorithms, Addison-Wesley, 1983 — 第 2 章(Basic Data Types)。抽象データ型を先に定め、実装を後から与えるという本記事の構成は、この本の流儀に沿っています。
- R. E. Tarjan, “Amortized Computational Complexity”, SIAM Journal on Algebraic and Discrete Methods 6 (1985), 306–318. DOI: 10.1137/0606031 — 償却計算量とポテンシャル法を体系的に定式化した論文です。
- R. Sedgewick, K. Wayne, Algorithms, 4th ed., Addison-Wesley, 2011 — §1.3(Bags, Queues, and Stacks)。可変長配列と連結リストの両方でスタック・キューを実装し、実測値を比較しています。
- 石畑清『アルゴリズムとデータ構造』岩波書店(岩波講座 ソフトウェア科学 3)、1989 — 日本語で読める定評ある教科書です。線形リストの各種表現が詳しく扱われています。
Appendix: 主要言語の標準ライブラリとの対応
Section titled “Appendix: 主要言語の標準ライブラリとの対応”この記事で扱ったデータ構造は、主要な言語の標準ライブラリにそのまま入っています。名前から実装が読み取れないことがあるので、対応を挙げておきます。
動的配列と連結リスト. C++ の std::vector、Java の ArrayList、Python の list が動的配列です。増加率は実装ごとに違い、Java の ArrayList はおよそ 倍、CPython の list はおよそ 倍(Python 3.12 では 1 要素ずつ append したときの容量が と増えます。数列は版によって変わり、3.8 以前は でした)ですが、注意 3.5 のとおり より大きい定数であれば償却 は保たれます。連結リストは C++ の std::list(双方向)と std::forward_list(単方向)、Java の LinkedList(双方向)です。Python の標準ライブラリに純粋な連結リスト型がないのは、必要になる場面が少ないためで、注意 5.1 の事情も背景にあります。
スタックとキュー. C++ の std::stack と std::queue は、既存のコンテナに 定義 6.1・定義 7.1 のインタフェースだけを見せるアダプタ(既定の土台は std::deque)で、抽象データ型と実装の分離がそのまま型に現れています。Java の ArrayDeque はリングバッファ(命題 7.2)で、スタックにもキューにも使えます。Python ではスタックに list(append と pop)、キューに collections.deque(固定長ブロックの双方向連結リストで、両端の追加・削除が最悪 )を使います。list を pop(0) でキューとして使うと、命題 3.2 の 3 により 1 回あたり 、 回で になります。実務で頻繁に見かける速度低下の原因です。
執筆: Claude Code(Sonnet 5.5)・監査 3 回(Claude Code)・最終 2026-10-12
この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金 ・利用条件 ・特定商取引法に基づく表記
© 2026 夢現技研合同会社 ・コード例は MIT ライセンスです。