0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ソートアルゴリズムはなぜ「賢いやり方」だと桁違いに速いのか【O(n²)とO(n log n)の正体】

0
Posted at

配列を昇順に並べ替える。sort() を一度呼ぶだけ。あまりに当たり前で、コストなんて意識しません。

でも、コンピュータは中で何をしているのでしょうか。数字を一目で「小さい順」に並べることは、人間には簡単に見えて、機械にはできません。機械にできるのは、たった2つ。2つを比べること、そして2つを入れ替えることです。

並べ替えとは、この比較と交換を、正しい順序になるまで繰り返すことです。そして優れたアルゴリズムとは、この回数をいかに少なく済ませるかの工夫に他なりません。

▶ アニメーションで見たい方はこちら(約10分)
https://youtu.be/GGCDlEWv8XY

この記事では、比較の回数を常に意識しながら、なぜ並べ替えにコストがかかるのか、なぜ賢いやり方が桁違いに速いのかを、底から組み立てます。

バブルソート — 隣を比べて、交換するだけ

まず、最も素朴なやり方から見ていきます。隣り合う2つを比べて、左が大きければ入れ替える。これを左から右へ繰り返します。

1回左から右まで走り切ると、最大の値が、まるで泡のように右端まで押し上げられます。これがバブルソートの名前の由来です。一番大きいものが右端に確定したら、残りでもう一度。次に大きいものが、その隣に確定します。これを繰り返せば、右から順に整列が完成していきます。

def bubble_sort(arr):
    n = len(arr)
    comparisons = 0
    for i in range(n - 1):
        for j in range(n - 1 - i):
            comparisons += 1
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr, comparisons

data = [5, 3, 8, 1, 9, 2]
sorted_data, cmp = bubble_sort(data)
print(sorted_data, "比較回数:", cmp)

隣どうしを比べて交換する。たったこれだけです。仕組みは驚くほど単純です。問題は、その回数にあります。

なぜO(n²)なのか — 比較回数の正体

数えてみましょう。要素がn個あるとき、1回目のパスで比べる回数は n-1。2回目は右端が確定したので n-2。次は n-3。

これを足し合わせると、1+2+3+…という階段状の和になります。その合計は、およそ n の2乗を2で割った数(n(n-1)/2)です。

ここが本質です。要素が10倍になると、比較回数はおよそ100倍になります。これをオーダーnの2乗、O(n²) と呼びます。データが増えると、コストが爆発的に膨らみます。

なぜ2乗なのか。それは「すべての要素を、他のほぼすべての要素と比べている」からです。この無駄に気づくことが、速いアルゴリズムへの第一歩です。

選択ソートと挿入ソート — 発想を変えても2乗の壁は越えられない

別の発想もあります。未整列の中から最小値を1つ選び、先頭に置く。次は2番目に小さいものを選んで、2番目に置く。これが選択ソートです。

def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]  # 1パスにつき交換は1回だけ
    return arr

バブルソートとの違いは、交換の回数です。バブルは何度も小刻みに入れ替えますが、選択ソートは1パスにつき交換は1回だけ。交換のコストが高い場面では有利です。

しかし、比較の回数は変わりません。最小値を見つけるには、結局すべてを見比べる必要があるからです。比較回数は、やはりn²のままです。発想を変えても、全部と全部を比べる構造が残る限り、2乗の壁は越えられません。

トランプを配られたとき、私たちは無意識に並べ替えています。新しい1枚を、すでに並んだ手札の正しい位置に差し込む。これが挿入ソートです。

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]  # 大きい要素を1つずつ右へずらす
            j -= 1
        arr[j + 1] = key
    return arr

配列の左側を「整列済み」と考え、次の1枚を取り出して、後ろから順に比べながら正しい場所まで滑り込ませます。技術的に言えば、挿入位置が見つかるまで、大きい要素を1つずつ右へずらしていく操作です。

ここで面白い性質があります。もとからほぼ整列している配列なら、ずらす回数がほとんどゼロです。挿入ソートはほぼ整列済みのデータで圧倒的に速くなる性質を持っています。この性質は、後で実用ライブラリの設計に効いてきます。

n²の壁 — 100万件で何が起きるか

ここまでの3つは、すべて比較回数がn²でした。小さなデータなら一瞬です。では、現実の規模ではどうでしょうか。

要素が100万件。n²は1兆です。1秒間に10億回比較できる速いマシンでも、およそ1000秒、17分近くかかる計算です。たった一度の並べ替えに、これだけの時間がかかります。

データは増え続けます。2乗のアルゴリズムは、ある規模を超えた瞬間に、現実的に使えなくなります。

ここで問いです。比較を「全部と全部」ではなく、もっと賢く減らす方法はないのでしょうか。答えは、問題を分割することにあります。

分割統治 — 半分に分けるという発想

大きな問題をそのまま解くのは大変です。なら、半分に割ってみましょう。

半分にした2つの山を、それぞれ並べ替える。そしてその2つを賢く合体させれば、全体が並びます。半分の山も、また半分に割る。これを繰り返すと、最後は要素1個の山になり、1個ならもう並んでいます。

ここで鍵になるのが、何回半分にできるかです。100万件は、半分にし続けると、およそ20回で1個になります。1024件なら10回です。これがlog n、対数の正体です。

全部と全部を比べるn²ではなく、n個の要素をlog n段で処理する。これがn × log n の速さを生みます。

マージソート — 分けて、併合する

分割統治を最も素直に実装したのが、マージソートです。

まず配列を、要素1個になるまで半分に割り続けます。ここでは比較も交換も起きません。ただ分けるだけです。

本番はここからの 併合(マージ) です。2つの整列済みの列を1つにまとめるとき、それぞれの先頭だけを比べて、小さい方を取り出す。これを繰り返せば、全体を1回なめるだけで合体できます。

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

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

なぜ速いのか。各段の併合は、合わせてn個を見るだけです。そして段の数はlog n。だから全体でn × log n。100万件でも、わずか2000万回ほどの比較で済みます。2乗の1兆とは、桁が違います。

クイックソート — 基準で2つに振り分ける

もう一つの代表が、クイックソートです。発想は逆向きの分割統治です。

まず基準となる1つの要素、ピボットを選びます。そして残りを、ピボットより小さいグループと大きいグループに振り分ける。これで、ピボットは最終的な定位置に収まります。

あとは、小さいグループと大きいグループを、それぞれ同じやり方で並べ替えるだけです。再帰的に振り分けを繰り返すと、全体が並びます。

def quick_sort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    less = [x for x in arr if x < pivot]
    equal = [x for x in arr if x == pivot]
    greater = [x for x in arr if x > pivot]
    return quick_sort(less) + equal + quick_sort(greater)

マージソートが「分けてから併合」なら、クイックソートは「振り分けながら確定」です。追加のメモリがほとんど要らず、実測が非常に速いため、多くの言語の標準で長く使われてきました。

クイックの最悪ケース — なぜn²に戻るのか

クイックソートには弱点があります。ピボットの選び方が悪いと、速さが崩れるのです。

例えば、すでに整列済みの配列で、常に先頭をピボットに選んだらどうなるでしょうか。ピボットより小さいグループは空、大きいグループは残り全部になります。分割が、たった1つずつしか進みません。

これは、半分に割れていない、ということです。段の数がlog nではなくnになり、計算量はn²に逆戻りします。せっかくの分割統治が台無しです。

だから実用のクイックソートは、ピボットをランダムに選んだり、3つの候補の中央値を選んだりして、偏った分割を避ける工夫をしています。

計算量を一望する

ここで一度、全体を見渡しましょう。

アルゴリズム 平均計算量 最悪計算量 追加メモリ 安定性
バブルソート O(n²) O(n²) O(1) 安定
選択ソート O(n²) O(n²) O(1) 不安定
挿入ソート O(n²) O(n²) O(1) 安定
マージソート O(n log n) O(n log n) O(n) 安定
クイックソート O(n log n) O(n²) O(log n) 不安定

バブル、選択、挿入はn²。マージとクイックはn × log n。最初は大差なく見えても、データが大きいほど、2乗組はみるみる引き離されていきます。

万能の最強アルゴリズムは存在しません。あるのは、状況ごとの最適だけです。

実用ライブラリの正体 — なぜTimsortなのか

では、PythonやJavaの標準ソートは、何を使っているのでしょうか。答えは、単一のアルゴリズムではありません。Timsortという、組み合わせ戦略です。

Timsortの発想はこうです。現実のデータは、完全にランダムなことは少なく、部分的に整列済みなことが多い。そこで、すでに並んでいる連続部分(ラン)を見つけ、それを土台にします。

短い部分には、ほぼ整列済みで高速な挿入ソートを使い、それらをマージソートの併合で賢くまとめます。挿入ソートの「ほぼ整列で速い」性質と、マージソートの「最悪でもn log n」の保証を、いいとこ取りしているのです。

実際、PythonとJavaはどちらもTimsortを標準ソートに採用しています(Pythonのsorted()/list.sort()、JavaのCollections.sort()が対象オブジェクト配列に対して使用)。理論の最速だけでなく、現実のデータの形に最適化する。これが実用アルゴリズムの設計思想です。

# Python の sorted() は Timsort。挙動を確認するだけなら実装は不要
data = [5, 3, 8, 1, 9, 2, 3, 3]
print(sorted(data))  # [1, 2, 3, 3, 3, 5, 8, 9]

まとめ

ソートアルゴリズムは、たくさんあるように見えて、根っこは1つでした。比較と交換を、いかに少なく済ませるか、という一点です。

全部と全部を比べる素朴なやり方はn²。問題を半分に割り続ける分割統治はn × log n。そして実用は、データの性質を突いて、理論と現実のいいとこ取りをします。

sort() という一行の裏には、こうした工夫の積み重ねがあります。次にソートを呼ぶとき、中で比較と交換ができるだけ少なく繰り返されている様子を、思い浮かべられるはずです。


チャンネル: https://www.youtube.com/@base-technology (根本解説シリーズ)

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?