0. この記事の要点
Section titled “0. この記事の要点”- バブルソートは隣り合う要素しか交換しません。1 回の交換で解消できる「順序の逆転」はちょうど 1 個なので、逆転が最悪 個ある以上、 から逃げられません。遅さの原因は実装の粗さではなく、要素を遠くへ動かせないという設計そのものにあります。
- マージソートは配列を半分に割り、再帰的にソートしてから 2 本のソート済み列をマージします。比較回数は 回以下、計算時間は です。
- クイックソートはピボットで配列を分割します。分割が均等なら ですが、ピボットを固定位置(例えば末尾)から選ぶと、整列済みの入力で分割が毎回「 と 」に偏り、 に落ちます。
- ピボットを一様ランダムに選べば、期待比較回数は 以下になります。最悪ケースは消えませんが、入力によって決まるのではなく乱数によって決まるようになり、実用上ほぼ起こらなくなります。
- 比較だけを頼りにするソートは、どんなに工夫しても最悪 回の比較を必要とします。マージソートはこの下界を定数倍の意味で達成しており、その意味で「これ以上速くはならない」ところに到達しています。
1. 動機:二乗時間と n log n の間にある壁
Section titled “1. 動機:二乗時間と n log n の間にある壁”ソートは、計算機が最も長い時間を費やしてきた処理のひとつです。Knuth は『The Art of Computer Programming』第 3 巻で、当時の計算機メーカーの見積もりとして、実行時間の 25% 以上がソートに使われていると紹介しています。今日でもデータベースの索引構築、外部結合、上位 件の抽出、重複除去といった処理の中核にソートが入っています。
そして、ソートは「計算量の違いが体感できる」最初の題材でもあります。素朴に書けば になり、少し賢く書けば になる。この差は定数倍の問題ではありません。
例 1.1(100 万件を並べ替えるとき)
1 秒あたり 回の基本操作をこなす計算機で、 件を並べ替えるとします。
のアルゴリズムでは、比較回数はおよそ
回です。1 回の比較と付随処理を 1 操作と数えても 秒、つまり 8 分以上かかります。
のアルゴリズムでは、 なので比較回数はおよそ
回、時間にして約 秒です。定数倍を無視した粗い見積もりですが、比は 倍。「昼休みが終わっても終わらない処理」と「瞬時に終わる処理」の差です。しかもこの比はおよそ なので、 を 10 倍の にすると 、つまり差はさらに 8 倍以上に開きます。
この記事では、素朴な方法がなぜ に縛られるのかを転倒数という量で説明し、その縛りを分割統治がどう外すのかを見ます。最後に、比較に基づく限りは より速くできないことを証明し、マージソートが漸近的に最適であることを確かめます。計算量の記法そのものについては 計算量とO記法(定義 3.1[計算量と O 記法])を、配列・連結リスト・ヒープといった土台については 基本的なデータ構造(定義 3.1[基本的なデータ構造])を前提とします。
2. 準備:問題の設定と評価軸
Section titled “2. 準備:問題の設定と評価軸”定義 2.1(ソート問題)
を全順序集合とする。入力は の元からなる長さ の列 である。出力は 上の置換 であって
を満たすもの(あるいは並べ替えた列そのもの)である。
全順序であることは効いています。任意の 2 元 について か の少なくとも一方が成り立つからこそ、どの 2 つを比べても必ず答えが返ります。半順序(比較不能な対がある)では、そもそも上の意味での出力が存在しないことがあります。
定義 2.2(比較ソートモデル)
アルゴリズムが入力の要素について得られる情報が、2 要素 を取り出して が成り立つかを問う比較の結果だけであるとき、そのアルゴリズムを比較ソートという。要素の値そのものを添字に使う、ビットを取り出すといった操作は許さない。
この記事で扱う 3 つはすべて比較ソートです。比較ソートに限れば 定理 6.1 のとおり という壁があり、計数ソートや基数ソートはこのモデルの外に出ることでその壁を回避します(注意 6.3)。
定義 2.3(転倒(inversion)と転倒数)
列 に対し、添字の対 で
を満たすものを の転倒という。転倒の総数を と書き、転倒数という。
転倒数は「入力がどれだけ乱れているか」の尺度です。 は昇順に整列済みであることと同値で、要素が相異なるとき最大値は降順のときの です。この量が、素朴なソートの遅さを説明する鍵になります。
定義 2.4(安定性とin-place性)
ソートアルゴリズムが安定であるとは、比較の意味で等しい 2 つの要素について、入力での前後関係が出力でも保たれることをいう。また、入力の配列以外に使う作業領域が または で済むとき、そのアルゴリズムを in-place であるという。
安定性は実務で効きます。「売上順に並べた表を、さらに部門順に並べ替える」とき、安定なソートを使えば同じ部門の中では売上順が保たれます。不安定なソートではこれが壊れます。
以下、配列の添字は から始め、 は を表します。計算時間は「比較 1 回、代入 1 回」を単位時間として数えます(一様コスト RAM モデル(定義 2.1)[計算量と O 記法])。
3. バブルソート:隣接交換で払う代償
Section titled “3. バブルソート:隣接交換で払う代償”バブルソートは、隣り合う 2 要素の順序が逆なら入れ替える、という操作を繰り返すだけのアルゴリズムです。
def bubble_sort(a): n = len(a) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if a[j] > a[j + 1]: a[j], a[j + 1] = a[j + 1], a[j] swapped = True if not swapped: # 1 回も交換が起きなければ整列済み break return a内側のループを 1 周すると、走査した範囲の最大値が右端まで「浮かび上がって」きます。これが名前の由来です。
定理 3.1(バブルソートの正当性と操作回数)
長さ の任意の列 に対して、上のアルゴリズムは次を満たす。
- 停止し、出力は を昇順に並べ替えた列である。
- 実行中に行われる交換の回数はちょうど である。
- 早期打ち切り(
swappedによるbreak)を行わない場合、比較回数はちょうど である。早期打ち切りを行う場合でも、比較回数は 以下であり、降順に並んだ入力では等号が成り立つ。
証明(定理 3.1)
(1) 正当性。 まず内側ループについて、次の不変条件を示します。
内側ループが添字 の反復を終えた直後、(この時点での配列の値として)が成り立つ。
のとき、比較と交換の結果 には の大きい方が入るので成立します。 まで成り立つとすると、反復 の開始時点で です。反復 では と を比べて大きい方を に置くので、終了後 となります。
したがって外側ループの 回目()が終わった直後には、 に の最大値が入ります。これを外側の不変条件
外側ループの反復 が終わった直後、末尾の 個 は昇順に並んでおり、かつ配列全体の中で大きい方から 個である
と組み合わせ、 についての帰納法で示します。 は上の内側の結論そのものです。 まで成り立つとすると、反復 は範囲 のみを触り、その最大値を に置きます。この最大値は「上位 個を除いた残りの最大値」なので、全体では大きい方から 番目です。よって主張が従います。 まで進めば末尾 個が正しい位置に入り、残る も自動的に最小値です。
早期打ち切りが起きた場合も正しさは保たれます。ある外側反復で 1 回も交換が起きなかったということは、その時点で がすべての (未確定範囲内)で成り立ち、確定済みの末尾はそれらより大きいので、配列全体が昇順だからです。
(2) 交換回数。 交換は「 であるような隣接対を入れ替える」操作だけです。この操作が、配列に含まれる要素の対の相対順序に与える影響を考えます。入れ替えられた 2 要素 、()の対については、入れ替え前は「大きい方が先」で転倒、入れ替え後は「小さい方が先」で非転倒になります。それ以外のどの 2 要素の対についても、位置が入れ替わるのは隣接する の 2 つだけなので、前後関係は変わりません。したがって 1 回の交換で はちょうど 1 減ります。
(1) より終了時の配列は昇順、すなわち転倒数 です。転倒数は交換のたびに 1 ずつ減り、それ以外では変化しないので、交換回数は初期値 に等しくなります。なお等しい要素は a[j] > a[j+1] が偽なので交換されず、定義 2.3 の狭義不等号と整合します。
(3) 比較回数。 早期打ち切りをしなければ、外側の反復 で内側は の 回比較します。よって合計は
です。早期打ち切りは反復を減らすだけなので比較回数は増えません。降順の入力では最後の反復まで必ず交換が起きるため(各反復で少なくとも 1 つの転倒が残っている)、打ち切りは発生せず 回になります。
例 3.2(6 要素の完全なトレース)
にバブルソートを適用します。各行は内側ループを 1 周した結果です。
| 周 | 比較回数 | 交換回数 | 周の終了時の配列 |
|---|---|---|---|
| 1 | 5 | 4 | |
| 2 | 4 | 2 | |
| 3 | 3 | 2 | |
| 4 | 2 | 1 | |
| 5 | 1 | 0 | (打ち切り) |
比較は合計 回、交換は 回でした。
一方、 の転倒を直接数えます。 より後ろで 未満なのは の 4 個、 より後ろで 未満なのは の 2 個、 より後ろでは の 2 個、 より後ろには 未満はなく 個、 より後ろでは の 1 個。合計 で、確かに交換回数と一致しています(定理 3.1 の (2))。
ここまでで「バブルソートは遅い」は測れましたが、まだ「なぜ遅いか」の説明にはなっていません。次の命題が答えです。
命題 3.3(隣接交換のみを使うソートの下界)
配列の要素を動かす操作として、隣接する 2 要素の交換のみを使うアルゴリズムを考える。このとき、入力 を昇順に整列するために必要な交換回数は少なくとも である。とくに、要素が相異なる長さ の入力に対し、最悪の場合 回、また一様ランダムな順列に対して平均 回の交換が必要であり、どちらも である。
証明(命題 3.3)
つまりバブルソートの遅さは、実装の工夫(早期打ち切り、双方向走査など)でどうにかなるものではありません。隣接交換という道具立て自体が を要求しています。速くするには、1 回の操作で多数の転倒をまとめて解消できるように、要素を遠くへ動かす仕組みが必要です。これが次節以降の共通するアイデアです。
同じ でも、挿入ソートの実行時間は になり(比較回数の数え上げは 例 2.5[計算量と O 記法])、ほとんど整列済みの入力()では線形時間で終わります。バブルソートは早期打ち切りを入れても外側ループが「最も左へ動くべき要素の移動距離」に比例して回るため、この性質を持ちません。小さい配列や「ほぼ整列済み」の配列に対しては、実務では挿入ソートが選ばれます。
4. マージソート:分割統治が壁を破る
Section titled “4. マージソート:分割統治が壁を破る”分割統治法(divide and conquer)は、問題を同種の小さい問題に分割し、再帰的に解き、その解を統合する設計方針です。3 つの段階に名前が付いています。
- 分割(Divide):問題を小さな部分問題に分ける。
- 統治(Conquer):部分問題を再帰的に解く。十分小さければ直接解く。
- 統合(Combine):部分解を合わせて元の問題の解を作る。
ソートに当てはめると、「配列を前半と後半に分け、それぞれをソートし、2 本のソート済み列をマージする」となります。これがマージソートです。
flowchart TB A["8 3 5 1 9 2"] --> B["8 3 5"] A --> C["1 9 2"] B --> D["8"] B --> E["3 5"] C --> F["1"] C --> G["9 2"] E --> H["3"] E --> I["5"] G --> J["9"] G --> K["2"]
def merge(left, right): result = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: # 等号を左に付けるのが安定性の要 result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return result
def merge_sort(a): if len(a) <= 1: return a[:] m = len(a) // 2 return merge(merge_sort(a[:m]), merge_sort(a[m:]))補題 4.1(マージの正当性とコスト)
left が長さ の昇順列、right が長さ の昇順列であるとき、上の merge は次を満たす。
- 出力は
leftとrightの全要素からなる長さ の昇順列である。 - 比較回数は高々 回であり、実行時間は である。
leftとrightの中に比較の意味で等しい要素があるとき、left側の要素が先に出力される。
証明(補題 4.1)
(1) while ループについて、次の不変条件が保たれることを示します。
resultは昇順であり、その各要素はleft[i:]とright[j:]のどの要素以下である。またresultはleft[:i]とright[:j]を合わせたものと一致する。
初期状態では かつ result が空なので、「各要素は〜以下」という条件は要素が 1 つもないため空虚に成り立ち、left[:0] と right[:0] もともに空です。反復の各回で、left[i] <= right[j] のときに left[i] を追加する場合を考えます。left は昇順なので left[i] は left[i:] の最小値、条件より right[j] 以下で、right も昇順だから right[j:] のすべて以下です。すなわち left[i] は残っている全要素の最小値なので、追加後も「result の各要素は残りのどの要素以下」が保たれ、result は昇順のままです。else の場合(left[i] > right[j])も同様に right[j] が残りの最小値です。
ループ終了時、left か right の一方は消費し尽くされています。残った側は昇順で、しかも不変条件より result のどの要素以上なので、そのまま連結すれば全体が昇順になります。要素の個数と多重度も保たれます。ループ内では消費した要素をそのまま 1 個ずつ result に追加し、最後に未消費の残りを順に連結するだけで、削除も複製も行っていないからです。
(2) while ループの 1 反復につき比較は 1 回で、そのたびに result の長さが 1 増えます。ループは left か right の一方が尽きた時点で止まるので、反復回数は高々 回です(最後の 1 要素は必ず連結側に回るため)。残りの連結と合わせて、操作回数は に比例します。
(3) 等しい要素については left[i] <= right[j] が真になり、left 側が先に出力されます。
定理 4.2(マージソートの正当性と計算量)
証明(定理 4.2)
正当性と停止性。 入力長 についての強い帰納法によります。 なら入力はそのまま昇順で、複製を返して停止します。 のとき、 とすると かつ なので、2 つの再帰呼び出しは帰納法の仮定によりそれぞれ停止して昇順列を返します。あとは 補題 4.1 の (1) から、merge の出力が全体の昇順列になります。安定性は 補題 4.1 の (3) と、前半が常に left 側に来ることから、同じく帰納法で従います。
比較回数。 と書きます。補題 4.1 の (2) より
が成り立ちます(merge_sort は で切るので部分の長さは と です)。
まず補助的な不等式を示します。、 とすると かつ です。両辺を 2 で割って天井を取ると なので 、さらに は単調非減少で だから も従います。
これを使って を についての強い帰納法で示します。 では で成立。 とし、 より小さいすべてで成立を仮定します。、 と置くと 、 なので
となり、主張が示されました。
実行時間。 マージ以外の処理(部分列の切り出しと再帰呼び出しの管理)も長さに比例するので、ある定数 が存在して
とできます。 を同じ帰納法で示します。 では 。 では、上と同じ を使って
です(2 行目で 、 を使いました)。 なので を得ます。
この証明の中身を絵にすると、次のようになります。再帰の各段では、部分配列の長さの合計が常に です。マージのコストは長さに比例するので、どの段でも合計コストは 。段数が なので、全体が の程度になります。分割統治の計算量がこの形になる理由は、ほとんどこの一枚に尽きています。
例 4.3(マージの回数を数え切る)
例 3.2 と同じ をマージソートします。分割は上の図のとおりです。葉から順にマージしていきます。
| マージ | 入力 | 出力 | 比較回数 |
|---|---|---|---|
| 1 | , | 1 | |
| 2 | , | 2 | |
| 3 | , | 1 | |
| 4 | , | 1 | |
| 5 | , | 5 |
マージ 2 を細かく見ます。left 、right 。 は偽なので を出力、 も偽なので を出力、ここで right が尽きたので残った を連結して 。比較 2 回です。マージ 5 では と 、 と 、 と 、 と 、 と の 5 回を比較し、left が尽きた時点で残りの を連結します。
合計比較回数は 回。定理 4.2 の上界 を確かに下回っています。同じ入力に対してバブルソートは比較 15 回・交換 9 回でした(例 3.2)。 でも既に差が出ています。
なお、マージ 5 で を出力する直前までに出力された は、 側の 3 要素すべてを一気に追い越しています。1 回の操作で複数の転倒がまとめて解消される、これが 命題 3.3 の縛りを外している場所です。
マージソートの弱点は作業領域です。上の実装は再帰のたびに新しいリストを作るので の追加メモリを使い、定義 2.4 の意味で in-place ではありません。作業配列を 1 本だけ確保して使い回す実装でも です。一方で、連結リストに対しては追加の配列なしにポインタの付け替えだけでマージできるため、外部記憶やリスト構造のソートではマージソートが第一候補になります。データ構造ごとの得失は 基本的なデータ構造 を、連結リストの操作コストは 命題 4.2[基本的なデータ構造] を参照してください。
5. クイックソート:平均は速く、最悪は遅い
Section titled “5. クイックソート:平均は速く、最悪は遅い”マージソートは「分けるのは楽、合わせるのが仕事」でした。クイックソートはこれを逆にします。分けるときに仕事をして、合わせるときは何もしない。C. A. R. Hoare が 1959 年に考案したこのアルゴリズムは、基準値(ピボット)より小さい要素を左に、大きい要素を右に集める操作を再帰的に繰り返します。分割が終わればピボットは最終位置に確定し、左右をそれぞれソートすれば、連結するだけで全体が整列します。
ここでは実装が単純な Lomuto の分割法を使います。
def partition(a, lo, hi): pivot = a[hi] # 末尾をピボットにする i = lo for j in range(lo, hi): if a[j] <= pivot: a[i], a[j] = a[j], a[i] i += 1 a[i], a[hi] = a[hi], a[i] # ピボットを境界へ return i
def quick_sort(a, lo=0, hi=None): if hi is None: hi = len(a) - 1 if lo < hi: p = partition(a, lo, hi) quick_sort(a, lo, p - 1) quick_sort(a, p + 1, hi) return a補題 5.1(Lomuto 分割の正当性)
とし、partition(a, lo, hi) を呼ぶ。返り値を 、呼び出し前の a[hi] の値を とすると、呼び出し後には
が成り立ち、部分配列 の要素の多重集合は変化しない。比較回数はちょうど 回である。
証明(補題 5.1)
for ループについて、反復 の開始時点で次の不変条件が成り立つことを についての帰納法で示します。
のときは で、2 つの範囲がともに空なので成立します。反復 では 2 つの場合があります。 のときは何もしないので、 の範囲に が加わるだけで不変条件は保たれます。 のときは と を交換します。不変条件より交換前の は( なら) より大きく、交換後は位置 に移るので「 より大きい領域」の右端に収まります。一方 は位置 に移り、その後 が 1 増えるので「 以下の領域」に含まれます。 の場合は自分自身との交換で、やはり成立します。
ループ終了時()には ()と ()が成り立ちます。最後に と を交換すると、 が位置 に来て、それまで にあった( より大きい)値は末尾に移ります。よって として主張の形になります。 は が から始まり高々 回増えることから従います。要素の多重集合が変わらないのは、操作が交換だけだからです。比較 a[j] <= pivot は の各回でちょうど 1 回なので 回です。
補題 5.1 により、quick_sort の正当性は入力長についての強い帰納法で従います。 が確定位置なので、 と をそれぞれ整列すれば全体が整列し、部分配列の長さはどちらも元より真に小さいからです。統合の手間はゼロで、マージに相当する処理がありません。
5.1. 最悪計算量
Section titled “5.1. 最悪計算量”定理 5.2(決定的クイックソートの最悪計算量)
相異なる 個の要素からなる任意の入力に対し、上の quick_sort の比較回数は高々 回である。さらに、入力が既に昇順に整列されている場合、比較回数はちょうど 回であり、再帰の深さは に達する。したがって最悪計算量は である。
証明(定理 5.2)
上界。 比較は a[j] <= pivot の形でしか起こらないので、比較される 2 要素の一方は必ずその呼び出しのピボットです。補題 5.1 より、ピボットは確定位置 に置かれ、その後の再帰呼び出し quick_sort(a, lo, p-1) と quick_sort(a, p+1, hi) はどちらも位置 を含みません。つまりピボットは以後の部分配列に二度と現れないので、ある 2 要素の対が比較されるのは高々 1 回です。対の総数は なので、比較回数はこれ以下です。
昇順入力での等号。 長さ の昇順部分配列に対して partition を呼ぶと、ピボット a[hi] はその部分配列の最大値です。よってループ中つねに a[j] <= pivot が成り立ち、 は毎回増えて最終的に 、返り値は です。交換もすべて自分自身との交換なので配列は昇順のまま保たれます。したがって再帰は「長さ 」と「長さ 」に分かれ、比較は 補題 5.1 より 回。これを と繰り返すので、比較回数の合計は
となり、再帰の深さは です。比較 1 回あたりの手間は定数なので、実行時間も です。
例 5.3(最も苦手な入力)
に quick_sort を適用します。
partition(a, 0, 4):ピボット 。 はすべて 以下なので は と進み、最後にa[4]とa[4]を交換して 。比較 4 回。左は 、右は空。partition(a, 0, 3):ピボット 。同様に 。比較 3 回。partition(a, 0, 2):ピボット 、。比較 2 回。partition(a, 0, 1):ピボット 、。比較 1 回。
合計 回。 の整列済みデータなら比較は 回、そのうえ再帰の深さが になるので、多くの処理系ではスタックが先に溢れます。「既にソートされたデータを再度ソートしたら固まった」という現象の正体がこれです。
もうひとつの落とし穴が重複です。全要素が等しい配列に Lomuto 分割を適用すると、a[j] <= pivot が常に真なので となり、やはり「 と 」の分割になって です。この対策には、ピボットと等しい要素をまとめて中央に置く 3 分割(Dutch national flag 型)が使われます。Bentley と McIlroy の “Engineering a Sort Function” は、この種の実装上の罠を体系的に扱った古典です。
5.2. 平均計算量
Section titled “5.2. 平均計算量”最悪ケースが でもクイックソートが実用され続けているのは、平均が速く、しかもその「平均」を乱数で保証できるからです。ピボットを部分配列から一様ランダムに選ぶ版をランダム化クイックソートと呼びます(pivot = a[hi] の代わりに、lo 以上 hi 以下の添字を一様に選んで a[hi] と交換してから同じ処理をします)。
定理 5.5(ランダム化クイックソートの期待比較回数)
相異なる 個の要素からなる任意の入力に対し、各再帰呼び出しでピボットをその部分配列から一様ランダムに(過去の選択と独立に)選ぶクイックソートの期待比較回数 は
を満たす。ここで は調和数である。とくに期待実行時間は である。
証明(定理 5.5)
入力を昇順に並べたものを とし、 に対して と置きます。確率変数
を導入します。定理 5.2 の証明で見たように、どの対も高々 1 回しか比較されないので、比較回数の総数は と書けます。期待値の線形性より です。
主張: と が比較されるのは、 の中で最初にピボットに選ばれた元が または であるとき、かつそのときに限る。
まず、 の元がすべて同じ部分配列に入っている限り、 と は比較されていません。比較は必ずピボットとの間で起こるので、 からまだ 1 つもピボットが選ばれていない以上、 と が比較されることはないからです。またこの間、ピボット が の外から選ばれても は分かれません。実際 なら か のいずれかで( は値の順序で連続した区間だからです)、前者なら 補題 5.1 により の元はすべて右側へ、後者ならすべて左側へ入ります。
そこで、 から初めてピボットに選ばれた元を とします。この時点で は同じ部分配列にあります。 または なら、ピボットは同じ部分配列の全要素と比較されるので、 と は比較されます。 なら、補題 5.1 により は左側、 は右側に入り、以後同じ部分配列に同居しないので二度と比較されません。主張が示されました。
確率の計算。 を含む部分配列からピボットが一様ランダムに選ばれるとき、選ばれた元が に属するという条件の下では、 の各元が等確率です。 の外が選ばれた場合は上で見たとおり はそのまま次の部分配列へ持ち越されるので、同じ議論が繰り返されます。したがって「 の中で最初にピボットになる元」は 上の一様分布に従い、それが か である確率は
です。
和の評価。 と置いて数え上げます。
最後に を使います。これは に対し が成り立つ(区間 上で )ことから
と従います。以上より です。比較以外の処理(交換と添字操作)は比較回数に比例するので、期待実行時間は です。
ですから、ランダム化クイックソートの比較回数は平均でマージソートの上界のおよそ 1.4 倍です。それでも実測でクイックソートが速いことが多いのは、追加メモリを使わず配列を直接書き換えるためキャッシュ効率がよく、内側ループが「比較・条件付き交換・添字加算」だけで済むからです。計算量が同じ でも定数倍が違う、という典型例になっています。
最悪ケースへの対策は、突き詰めると「ピボットの選び方」です。
- ランダム選択:定理 5.5 の設定です。最悪ケースは消えませんが、その発生確率は入力ではなく乱数に依存します。特定の入力を狙って遅くさせることが(乱数列を知らない限り)できなくなる点も実用上重要です。
- median-of-three:先頭・中央・末尾の 3 つの中央値をピボットにします。整列済み入力に対して中央付近が選ばれるため 例 5.3 の破綻を防げます。ただし、この規則を知ったうえで作られた敵対的入力では依然として になります。
- イントロソート:再帰の深さが を超えたらヒープソートに切り替えます。ヒープソートは最悪 なので、全体として最悪 を保証しつつ、通常はクイックソートの速さで動きます。C++ の標準ライブラリの
std::sortが採用している方式です。
6. 比較ソートの限界
Section titled “6. 比較ソートの限界”ここまでで のアルゴリズムを 2 つ得ました。では、もっと速い比較ソートはあるのでしょうか。答えは「ない」です。しかも、個々のアルゴリズムを調べるのではなく、あらゆる比較ソートを一度に扱って証明できます。
考え方はこうです。比較ソートは、入力の要素を直接見ることができず、比較の答え(真か偽か)だけを頼りに動きます。したがって、相異なる 個の要素に対する実行の様子は、内部節点が「 か」という質問、枝が答え、葉が出力する並べ替えに対応する二分木(決定木)で表せます。1 回の比較で得られる情報は 1 ビット、 回の比較で区別できる結果は高々 通りです。一方、正しくソートするには 通りの並べ替えをすべて区別できなければなりません。
定理 6.1(比較ソートの下界)
相異なる 個の要素を正しく整列する任意の決定的比較ソートについて、最悪の場合に必要な比較回数 は
を満たす。とくに である。
証明(定理 6.1)
アルゴリズムを固定し、要素数 の相異なる入力に対する決定木を考えます。入力の並べ替え方は 通りあり、それぞれに対して出力すべき置換は異なります。もし 2 つの異なる並べ替え が決定木の同じ葉に到達するなら、アルゴリズムは両者に同じ出力を返すことになり、少なくとも一方では誤った結果になります。よって決定木の葉は少なくとも 個あります。
高さ の二分木の葉の個数は高々 個です(高さ についての帰納法: なら葉は 1 個。高さ の木は、根の 2 つの部分木がそれぞれ高さ 以下なので、葉は高々 個)。最悪の場合の比較回数は決定木の高さ に等しいので、、すなわち を得ます。
次に を示します。指数関数の級数展開
(右辺は の項だけを残したもので、他の項はすべて非負)から が従います。両辺の を取ると
で、 です。
系 6.2(マージソートの漸近的最適性)
マージソートの最悪比較回数は 以下であり、任意の比較ソートの最悪比較回数の下界 と比べて、比が で に収束する。すなわちマージソートは比較ソートの中で漸近的に最適であり、その最悪計算量は である。
証明(系 6.2)
7. 使い分け
Section titled “7. 使い分け”3 つ(と比較用にヒープソート)をまとめます。 は要素数、追加メモリは入力配列以外に必要な作業領域です。
| アルゴリズム | 最悪時間 | 平均時間 | 追加メモリ | 安定性 | 特徴 |
|---|---|---|---|---|---|
| バブルソート | 安定 | 実用価値はほぼない。転倒数の教材として有用 | |||
| 挿入ソート | 安定 | なので小規模・ほぼ整列済みに強い | |||
| マージソート | 安定 | 最悪保証あり。外部ソート・連結リスト向き | |||
| クイックソート | 不安定 | 定数倍が小さく実測が速い。ピボット対策が必須 | |||
| ヒープソート | 不安定 | 最悪保証があり in-place。定数倍はやや大きい |
クイックソートの追加メモリは再帰スタックの分です。素朴に書くと最悪で深さ になりますが(定理 5.2)、短い側だけを再帰する書き方にすれば必ず に収まります(演習 8.3)。表の値はこの工夫を前提としています。
実務での判断はおおむね次のとおりです。
- 標準ライブラリのソートを使う。 これが第一選択です。CPython の
list.sortと Java のオブジェクト配列向けArrays.sortは Timsort 系の安定マージソートで、入力に既に存在する連続した昇順・降順の並び(run)を検出して活用します。C++ のstd::sortはイントロソート、Java の基本型配列向けArrays.sortは 2 つのピボットを使うクイックソートです。安定性が必要かどうかで選択肢が分かれる点に注意してください。 - 最悪ケースの保証が要るとき(応答時間の上限が契約になっている場合や、入力を敵対的に選ばれうる場合)はマージソートかヒープソート、あるいはイントロソートを選びます。
- メモリが厳しいときはヒープソートかクイックソートです。マージソートの が払えない場面はあります。
- 小さい部分配列には挿入ソート。 実際の高速な実装は、部分配列の長さが十数個を下回ったら再帰をやめて挿入ソートに切り替えます。 でも定数倍が小さいので、小さい では勝ちます。
なお「ソート済みであること」は、それ自体が強力な前処理です。整列済み配列に対する二分探索は で動きます(探索アルゴリズム の 定理 3.3[探索アルゴリズム])。1 回ソートしてから何度も検索する、という設計が成立するのは、 の投資を一度払えば、以後の検索が何回でも で済むからです。また、分割統治は部分問題が重ならないときの手法で、部分問題が重複する場合は結果を再利用する 動的計画法(定義 3.1[動的計画法])が対応します。
演習 8.1標準
相異なる 個の要素を一様ランダムに並べた列 について、転倒数の期待値が
であることを示してください。またこれを使って、バブルソートの平均交換回数を求めてください。
解答
添字の対 ()に対して、 のとき 、そうでないとき を取る指示確率変数を とします。定義より です。
一様ランダムな順列において を示します。全順列の集合上で、位置 と位置 の値を入れ替える写像 を考えます。 を 2 回施すと元に戻る( が恒等写像)ので は全単射であり、したがって一様分布を保ちます。そして は の値を と で入れ替えます。よって であり、要素が相異なることから両者の和は なので、ともに です。
期待値の線形性(独立性は不要です)より
を得ます。
定理 3.1 の (2) よりバブルソートの交換回数は に等しいので、平均交換回数も 、すなわち です。最悪の場合の半分になるだけで、オーダーは改善しません。
配列の転倒数 を 時間で計算するアルゴリズムを、マージソートを改造して構成し、正しさを説明してください。
解答
配列を前半 と後半 に分けると、転倒 は次の 3 種類に分類されます。
- がともに前半にある。
- がともに後半にある。
- が前半、 が後半にある(交差転倒)。
1 と 2 は再帰呼び出しが数えます。3 を、マージの最中に数えます。要点は、マージの時点で と は既にソート済みだという事実です。 の先頭要素 right[j] を出力する場面では、left[i] > right[j] が成り立っています。 はソート済みなので、まだ出力されていない left[i], left[i+1], ..., left[-1] はすべて left[i] 以上、したがってすべて right[j] より大きい。これらはいずれも元の配列で right[j] より前にあるので、交差転倒をちょうど len(left) - i 個作ります。
逆に、任意の交差転倒 (、、)はちょうど 1 回だけ数えられます。マージでは left[i] <= right[j] のとき左を出すので、 である は より後に出力されます。つまり が出力される瞬間に は必ず未出力で、そのときの len(left) - i に数えられます。数えられるのはその 1 回だけです。
def sort_and_count(a): if len(a) <= 1: return a[:], 0 m = len(a) // 2 left, c_left = sort_and_count(a[:m]) right, c_right = sort_and_count(a[m:]) merged, i, j, cross = [], 0, 0, 0 while i < len(left) and j < len(right): if left[i] <= right[j]: merged.append(left[i]) i += 1 else: merged.append(right[j]) j += 1 cross += len(left) - i # 残っている左側は全て right[j] より大きい merged.extend(left[i:]) merged.extend(right[j:]) return merged, c_left + c_right + cross
assert sort_and_count([8, 3, 5, 1, 9, 2]) == ([1, 2, 3, 5, 8, 9], 9)計算量はマージソートと同じ漸化式 に従うので、定理 4.2 の証明と同じ議論で です。最後の assert は 例 3.2 で手計算した転倒数 9 と一致します。二重ループで数えると かかるところを、ソートの副産物として得ているわけです。
演習 8.3標準
quick_sort は最悪の場合に再帰の深さが に達します(定理 5.2)。2 つの再帰呼び出しのうち短い方だけを再帰で処理し、長い方はループで処理するように書き換えると、比較回数を変えずに再帰の深さを に抑えられます。この実装を書き、深さの上界を証明してください。
解答
def quick_sort_bounded(a, lo=0, hi=None): if hi is None: hi = len(a) - 1 while lo < hi: p = partition(a, lo, hi) if p - lo < hi - p: # 左が短い quick_sort_bounded(a, lo, p - 1) lo = p + 1 # 右はループで処理 else: # 右が短い quick_sort_bounded(a, p + 1, hi) hi = p - 1 # 左はループで処理 return a処理する部分配列の集合は元の実装とまったく同じなので、partition の呼び出し回数も比較回数も変わりません。
深さの評価をします。長さ の部分配列を分割すると、2 つの部分の長さ は を満たします(ピボット 1 個が抜けるため)。再帰するのは短い方なので、その長さは です。したがって再帰が 1 段深くなるたびに部分配列の長さは半分未満になります。長さ から始めて長さが 以下になれば再帰は止まるので、深さ では長さが 未満、これが 以下になるのは のときです。よって再帰の深さは高々 で、スタック使用量は です。
これは末尾再帰の除去(tail call elimination)を手で行ったものと見ることができます。例 5.3 の整列済み入力に対しても、短い方(長さ )を再帰し、長い方をループで回すので、深さは で済みます。実行時間が である点は変わりませんが、少なくともスタック溢れによる異常終了は防げます。
(a) merge の比較が left[i] <= right[j] である(< ではない)ことが、マージソートの安定性にとってなぜ必要かを説明してください。
(b) Lomuto 分割によるクイックソートが安定でないことを、3 要素の具体例で示してください。要素は「キーと付随データの組」とし、キーだけで比較するものとします。
解答
(a) キーが等しい 2 要素 (前半 に由来)と (後半 に由来)を考えます。元の配列では の要素はすべて の要素より前にあるので、安定であるためには を より先に出力しなければなりません。比較が left[i] <= right[j] なら、キーが等しいとき条件は真になり左側の が先に出ます。もし left[i] < right[j] と書いていると、等しいときに else 節に入って右側の が先に出てしまい、順序が入れ替わります。等号をどちら側に付けるかという 1 文字が安定性を決めています。同じ半分に由来する 2 要素の順序は、定理 4.2 の証明のとおり帰納法の仮定で保たれます。
(b) キー 2 の要素を 、キー 1 の要素を とし、入力を
とします。partition(a, 0, 2) を追います。ピボットは a[2] すなわち (キー 1)、 です。
- :
a[0]のキーは 2 で は偽。何もしない。 - :
a[1]のキーは 2 で は偽。何もしない。
ループを抜けて a[0] と a[2] を交換すると、配列は 、返り値は です。この後の再帰では、右側 に対してピボット で分割しますが、 が真なので a[1] は自分自身と交換されて が 2 になり、最後に a[2] と a[2] を交換して並びは変わりません。最終出力は
です。入力ではキー 2 の要素が の順だったのに、出力では の順に入れ替わっています。よってこの実装は安定ではありません。原因は、分割の最後にピボットを境界へ移す交換が、遠く離れた 2 要素を入れ替えてしまうことにあります。要素を遠くへ動かせることがクイックソートの速さの源でしたが、その同じ性質が安定性を壊しています。
- T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 4th ed., MIT Press, 2022 — 第 2 章(挿入ソートとマージソート)、第 7 章(クイックソートと期待計算量の解析)、第 8 章(比較ソートの下界、計数ソート、基数ソート)。
- D. E. Knuth, The Art of Computer Programming, Volume 3: Sorting and Searching, 2nd ed., Addison-Wesley, 1998 — 第 5 章(整列)、とくに 5.2(内部整列)。各手法の歴史的経緯と精密な定数の解析。
- C. A. R. Hoare, “Quicksort”, The Computer Journal 5 (1962), 10–16. DOI: 10.1093/comjnl/5.1.10 — 考案者自身による原論文。
- J. L. Bentley, M. D. McIlroy, “Engineering a Sort Function”, Software: Practice and Experience 23 (1993), 1249–1265. DOI: 10.1002/spe.4380231105 — ピボット選択と重複要素の扱いを含む実装上の設計。
- D. R. Musser, “Introspective Sorting and Selection Algorithms”, Software: Practice and Experience 27 (1997), 983–993 — イントロソート(クイックソートとヒープソートの切り替え)の提案論文。
- R. Sedgewick, K. Wayne, Algorithms, 4th ed., Addison-Wesley, 2011 — 第 2 章(ソート)。実装と実測に重心を置いた解説。
Appendix: 分割統治の漸化式をまとめて解く
Section titled “Appendix: 分割統治の漸化式をまとめて解く”マスター定理。 マージソートで現れた のような漸化式は、分割統治法のたびに顔を出します。毎回帰納法で解く代わりに、次の定理(定理 6.1[計算量と O 記法])を使うと機械的に処理できます。、 を定数、 を非負関数とし、
とします( は や と読み替えてよい)。 と置くと、次が成り立ちます。
- ある について ならば 。(葉での仕事が支配的)
- ならば 。(各段が均等に効く)
- ある について で、かつある と十分大きいすべての について が成り立つならば 。(根での仕事が支配的)
マージソートへの適用。 、、 なので 、 で第 2 の場合に当たり、 を得ます。定理 4.2 の帰納法と同じ結論です。
分割の偏りが与える影響。 クイックソートの最悪ケースは で、これは の形をしていないためマスター定理の対象外ですが、直接展開すれば です。一方、分割が常に「」に偏るという(一見すると悪そうな)場合は で、再帰木の深さが 、各段のコストが なので のままです。定数比の偏りは対数の底を変えるだけで、オーダーを壊しません。クイックソートが「そこそこの分割」でも十分速い理由がここにあります。破綻するのは、分割が「 個と残り全部」のように定数比を保てなくなるときだけです。
注意点。 第 3 の場合の正則条件()は飾りではありません。 が振動する場合など、これを満たさない例では結論が成り立たないことがあります。また 3 つの場合の隙間(たとえば )はマスター定理では扱えず、再帰木を直接評価するか、より一般の Akra–Bazzi の方法が必要です。証明と精密な形は Cormen らの第 4 章にあります。
この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金プラン ・利用条件 ・特定商取引法に基づく表記
© 2026 夢現技研合同会社 ・本文の LLM への入力は自由です。コード例は MIT ライセンスです。