LoginSignup
40
36

More than 1 year has passed since last update.

Pythonで7種のソートを実装して、性能を比較してみた

Last updated at Posted at 2022-10-29

ソートアルゴリズムの説明を読んで「読んだだけでは覚えられないな、、」と思ったので、
代表的なアルゴリズム7種を実装し、実際に動かして性能を比較してみた
Pythonが書きやすそうな気がしたので、Pythonで書いた

目次

ソースコード

バブルソート

  • 全ての要素に関して、隣接する要素と比較し順序が逆であれば入れ替える。これを要素数-1回繰り返すことでソートを行う
  • 一番シンプルなソート
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n^{2}$ $n^{2}$ $1$ あり
def bubble_sort(lst):
    n = len(lst)
    for i in range(n):
        for j in range(n-1):
            if lst[j] >= lst[j+1]:
                lst[j], lst[j+1] = lst[j+1], lst[j]
    return lst

選択ソート

  • 最も値の小さい要素を探し、それを1番目の要素と交換する(1番目の要素までソート済みとなる)
  • 以降同様に、未ソート部分の最小要素を探し、未ソート部分の先頭要素と交換する
  • すべての要素がソート済みになったら処理を終了する
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n^{2}$ $n^{2}$ $1$ なし
def selection_sort(lst):
    n = len(lst)
    for i in range(0, n-1):
        min = i
        for j in range(i+1, n):
            if lst[j] < lst[min]:
                min = j
        lst[i], lst[min] = lst[min], lst[i] 
    return lst

挿入ソート

  • 0番目と1番目の要素を比較し、順番が逆であれば入れ換える
  • 2番目の要素が1番目までの要素より小さい場合、正しい順に並ぶように「挿入」する(配列の場合、前の要素を後ろに一つずつずらす)
  • 3番目以降の要素についても同様の処理を続け、全ての要素がソートされたら処理を終了する
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n^{2}$ $n^{2}$ $1$ あり
def insertion_sort(lst):
    for i in range(1, len(lst)):
        tmp = lst[i]
        j = i - 1

        while j >= 0 and lst[j] > tmp:
            lst[j+1] = lst[j]
            j -= 1
        lst[j+1] = tmp
    return lst

シェルソート

  • 適当な間隔 h を決める
  • 間隔 h をあけて取り出したデータ列に挿入ソートを適用する
  • 間隔 h を狭めて、再度データ列の抽出と挿入ソートを適用する、という処理をhが1になるまで続ける
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n\log n$ $\geqq n^{1.5}$ $1$ なし
def shell_sort(lst):
    n = len(lst)
    h = 1
    while h < n / 9:
        h = h * 3 + 1
    while h > 0:
        for i in range(h, n):
            j = i
            while j >= h and lst[j-h] > lst[j]:
                lst[j], lst[j-h] = lst[j-h], lst[j]
                j -= h
        h = h // 3
    return lst

クイックソート

  • ピボットという境界値を選択する
  • ピボット未満の要素を配列の先頭側に集め、ピボット未満の要素のみを含む区間とそれ以外に分割する
  • 分割された区間に対し、再びピボットの選択と分割を行う。この処理を分割区間が整列済みになるまで行う
  • 最後に分割区間を統合する
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n\log n$ $n^2$ $n\log n$ なし
def quick_sort(lst):
    if len(lst) <= 1:
        return lst

    pivot = lst[0]
    left = []
    center = []
    right = []

    for v in lst:
        if v < pivot:
            left.append(v)
        elif v > pivot:
            right.append(v)
        else:
            center.append(v)
    
    left = quick_sort(left)
    right = quick_sort(right)

    return left + center + right

ヒープソート

  • 未整列のデータ列から要素を取り出し、順にヒープに追加する。すべての要素を追加するまで繰り返し
  • ルート(最大値または最小値)を取り出し、整列済みリストに追加する。すべての要素を取り出すまで繰り返し
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n\log n$ $n\log n$ $1$ なし
def heap_sort(lst):
    n = len(lst)
    for i in range(n//2-1, -1, -1):
        heapify(lst, i, n)

    for i in range(n-1, 0, -1):
        lst[0], lst[i] = lst[i], lst[0]
        heapify(lst, 0, i)
        
    return lst

def heapify(lst, i, heap_size):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2

    if left < heap_size and lst[left] > lst[largest]:
        largest = left

    if right < heap_size and lst[right] > lst[largest]:
        largest = right

    if largest != i:
        lst[largest], lst[i] = lst[i], lst[largest]
        heapify(lst, largest, heap_size)

マージソート

  • データ列を分割する(通常、二等分する)
  • 分割されたデータ列で、含まれるデータが1個ならそれを返し、2個以上なら、さらにデータ列の分割を行う
  • 上記の再帰処理から返されたデータ列に対して、要素の比較による単純なソートを行う
  • 二つのソートされたデータ列(1個であればそれ自身)をマージする
平均計算時間 最悪計算時間 メモリ使用量 安定性
$n\log n$ $n\log n$ $n$ あり
def merge_sort(lst):
    if len(lst) <= 1:
        return lst

    mid = len(lst) // 2
    left = lst[:mid]
    right = lst[mid:]

    left = merge_sort(left)
    right = merge_sort(right)

    return merge(left, right)

def merge(left, right):
    merged = []
    left_i, right_i = 0, 0

    while left_i < len(left) and right_i < len(right):
        if left[left_i] <= right[right_i]:
            merged.append(left[left_i])
            left_i += 1
        else:
            merged.append(right[right_i])
            right_i += 1

    if left_i < len(left):
        merged.extend(left[left_i:])
    if right_i < len(right):
        merged.extend(right[right_i:])
    
    return merged

実行時間を比較してみた

-100,000から100,000までの範囲から、ランダムに選ばれた数字100,000個を各アルゴリズムでソートしてみた
3つのデータセットに対してソートを行い、各回の所要時間と、平均所要時間を調べてみた

アルゴリズム 所要時間(秒) 所要時間(秒) 所要時間(秒) 平均所要時間(秒)
バブルソート 706.051 722.234 727.029 718.438
選択ソート 200.039 204.399 200.981 201.806
挿入ソート 203.928 202.049 203.315 203.097
シェルソート 0.516 0.517 0.557 0.530
クイックソート 0.146 0.132 0.139 0.139
ヒープソート 0.481 0.433 0.473 0.462
マージソート 0.287 0.271 0.279 0.279

やはり、基本的なソートである、バブル・選択・挿入と、高速化したソートであるシェル・クイック・ヒープ・マージとの間にはかなりの性能差がみられた

高速化したソートの中だと、クイックソートが一番速かった
(ただ、今回は割と安定したデータセットを使ったので、最悪計算量ベースでみたときに遅くなる可能性はある)
次点で、マージソートもそれなりに速かった

ちなみにソートする対象の個数ごとの所要時間は、以下の表のようになった

アルゴリズム 1,000 10,000 100,000
バブルソート 0.073 6.984 718.438
選択ソート 0.019 1.976 201.806
挿入ソート 0.020 2.046 203.097
シェルソート 0.002 0.030 0.530
クイックソート 0.001 0.012 0.139
ヒープソート 0.002 0.034 0.462
マージソート 0.002 0.023 0.279

やはり計算量が $O(n^{2})$ であるアルゴリズムは量が多くなるにつれて、かなり処理秒数が伸びているのに対し、
計算量が $O(nlogn)$ であるアルゴリズムは処理秒数の伸びを一定に留められている

おまけ

上記の実装から派生して、pythonが標準で提供しているsort関数が、
どのような実装になっているか気になったので、軽く調べてみた

どうやら、pythonのsort関数はティムソートというアルゴリズムを採用しているらしい

性能は以下とのこと

平均計算時間 最悪計算時間 メモリ使用量 安定性
$n\log n$ $n\log n$ $n$ あり

実際に動かしてみたら以下の実行時間となった

アルゴリズム 1,000 10,000 100,000
ティムソート(python標準) 0.000 0.001 0.013

かなり速い!(今回実装したクイックソートの10分の1ほどの速さでソートできるとは...)
さすが標準実装だな、という感じですね

理論上の性能からしても、最悪計算時間がクイックソートよりかなり速い(クイックソート:$O(n^{2})$, ティムソート:$O(nlogn)$ )ので、メモリ使用量を気にしないのであれば、結構優秀なアルゴリズムな気がする

実装箇所はおそらく以下

頑張ればわかりそうだけど、(経験のない)C言語で書かれていてパッと読めるわけではなかったので、今回は断念...
(いつか、ティムソートの実装も理解したいが...)

ということで、この記事ではこれ以上深くは追わないので、
ティムソートについてより詳しく知りたい場合は、以下のページを読んでいただければ、と思います!

参考にしたページ

40
36
1

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
40
36