クイックソート

date:2012/10/28

0. 問題

リスト [3, 2, 5, 4, 1] の要素を並べ替えて、[1, 2, 3, 4, 5] にする手順を考えましょう。

できるだけ少ない手順で並び替え(ソート)を行いたいと思っています。 (あまり良く知らないので、配列をコピーする際のコストなどはあまり考えていません。)

1. バブルソート

まず、素朴な方法を考えます。

先頭から順番に隣の要素と比較して、大きい要素を後ろに持っていってみます。

1-1. やってみる

[3, 2, 5, 4, 1] に対して、

a. 先頭 の 3, 2 を比較して、3 の方が大きいので、入れ替える
→ [2, 3, 5, 4, 1]
b. 2番目の 3, 5 を比較して、3 の方が小さいので、そのまま
→ [2, 3, 5, 4, 1]
c. 3番目の 5, 4 を比較して、5 の方が大きいので、入れ替える
→ [2, 3, 4, 5, 1]
d. 4番目の 5, 1 を比較して、5 の方が大きいので、入れ替える
→ [2, 3, 4, 1, 5]

ここまでで、最も大きい要素である、5 が最後尾に来ました。 同じようにして、もう1度同じ操作を行えば、4 が後ろから2番目に来ます。実際、

a. 先頭 の 2, 3 を比較して、2 の方が小さいので、そのまま
→ [2, 3, 4, 1, 5]
b. 2番目の 3, 4 を比較して、3 の方が小さいので、そのまま
→ [2, 3, 4, 1, 5]
c. 3番目の 4, 1 を比較して、4 の方が大きいので、入れ替える
→ [2, 3, 1, 4, 5]
d. 4番目の 4, 5 を比較して、4 の方が小さいので、そのまま
→ [2, 3, 1, 4, 5]

となります。 さらにもう1度同じ操作を行えば、3 が後ろから3番目になります。 このようにして、最終的に、[1, 2, 3, 4, 5] というリストを得ることができます。

このソートの方法をバブルソートというようです。

1-2. 実装してみる

  1. Haskell での実装例
bubble_step :: (Ord a) => [a] -> [a]
bubble_step (x:[]) = x:[]
bubble_step (x:y:xs)
   | x > y = y:bubble_step(x:xs)
   | otherwise = x:bubble_step(y:xs)

bubble :: (Ord a) => [a] -> [a]
bubble xs = iterate bubble_step xs !! length xs
この実装は非常にメモリを食います。
  1. Ruby での実装例
class Bubblesort < Array
  def bubble
    for j in 0...self.length-1
      for i in 0...self.length-1
        self[i], self[i+1] = self[i+1], self[i] if self[i] > self [i+1]
      end
    end
    return self
  end
end

1-3. 考えてみる

この方法で、何回要素を比較しないといけないか考えてみます。 1つの要素を後ろに持っていくのに、上の a-d の4回必要でした。 今、要素は 1~5 の5つあるので、リストを並べ終えるまでに、 \(4\times5=20\) 回も要素を比較する必要があります。

もし、もっと大きなリストを考えていた場合、 サイズ N のリストに対して、 a-d に対応する操作は、 N-1 回必要で、 要素が N 個あるので、 \(N\times(N-1)\) 回も要素を比較しなければなりません。

(実際には、 \(\frac{N\times(N-1)}{2}\) くらいまで減らすことが可能です。)

2. クイックソート

次に、クイックソートと呼ばれるもう少し効率の良いソート方法を実装します。

2-0. クイックソートとは

クイックソートの戦略は次の通りです。

1. 要素を1つ適当に取り出す(ピボット)
2. リストを、その要素より小さいものと、その要素以上のもの、の2つに分割する。
3. 分割された各リストそれぞれに対して、再び1. を行い、以下繰り返し。

図にしてみるとこんな感じ。

../../_images/quicksort.png

2-1. やってみる

ここでは、ピボットとしてリストの先頭要素を取る場合を考えてみます。

[3, 2, 5, 4, 1] に対して、

A. ピボットの選択 : 先頭要素 3 を取る。
リストの要素とピボットの3を比較する。すると次の2つのリストを得る。
[2, 1] : 3より小さい要素からなるリスト
[5, 4] : 3以上の要素からなるリスト
B. リストを2つに分割
B-1. [2, 1] に対して、先頭要素 2 を取り、リスト要素と2を比較する。次の2つのリストを得る。
[1] : 2より小さい要素からなるリスト
[] : 2より大きい要素からなるリスト
B-2. [5, 4] に対して、先頭要素 5 を取り、リスト要素と2を比較する。次の2つのリストを得る。
[4] : 5より小さい要素からなるリスト
[] : 5より大きい要素からなるリスト
../../_images/quick.png

このようにして、3の左側(3より小さい要素全体)がソートされて、[1, 2]になり、3の右側(3以上の要素全体)がソートされて[4, 5]となります。

結果として、 [1, 2], [3], [4, 5] という3つのリストを合わせた、[1, 2, 3, 4, 5] というリストが得られます。

2-2. 実装してみる

  1. Haskell での実装例
quicksort' :: Ord a => [a] -> [a]
quicksort' [] = []
quicksort' (x:xs) =
  let smaller = filter (<x) xs
      larger = filter (>=x) xs
  in quicksort' smaller ++ [x] ++ quicksort' larger
  1. Ruby での実装例
class Sortable < Array
  public
  def quicksort
    self.subquicksort.flatten.delete_if{|s| s == nil}
  end

  protected
  def subquicksort
    pivot = self.shift
    left, right = Sortable.new([]), Sortable.new([])
    self.each{|s|
      if s < pivot
        left.push(s)
      else
        right.push(s)
      end
    }
    return [left.subquicksort, pivot, right.subquicksort] if left.size > 1 || right.size > 1
    return [left, pivot, right]
  end
end

2-3. 考えてみる

まず、このアルゴリズムの良いところは、アルゴリズムを再帰で書けることです。
“リストをある方法で小さなリストに分割する” という同じ手順を何度も繰り返すことで、ソートが完了するので、実装が非常に簡単です。
次に計算量についてですが、このアルゴリズムの計算量は、ピボットの選択によって大きく異なることがわかります。
上の実装の場合、[5, 4, 3, 2, 1] というリストをソートする際に計算量が大きくなります。
ピボットの選択は、できるなら中央値を取るのが良いでしょう。
詳細は省きますが、平均的には \(O(n \log n)\) 程度の計算量になることが知られています。

3. 比較してみる

上に書いた実装例で実行時間を測ってみました。

具体的には、 長さ10万, 各要素は1~100万の間というリストをランダムに生成して、上に書いた実装例で実行時間を計測しました。 クイックソートはこれを5回行い平均を取りましたが、バブルソートは時間がかかりすぎるので1度だけ行いました。

バブルソート クイックソート
Haskell Ruby Haskell Ruby
1202秒 2052秒 7.50ミリ秒 2.56秒
このように、適当な場合にはクイックソートの方が遥かに効率が良いことがわかります。
次に、 [100000, 99999, 99998, ..., 1] というリストに対して同様のことを行なってみました。
このリストは、上記の実装のクイックソートで、最も計算量の大きなリストです。
バブルソート クイックソート
Haskell Ruby Haskell Ruby
940秒 2043秒 *1 *2
*1 は、メモリを使いすぎてスワップアウトして、いつまで経っても終わらないので計測できませんでした。 少なくとも 8時間はかかっていました。スワップアウトしないならもっと速いと思いますが、実装を変更するほうが現実的だと思います。
*2 は、スタックが深すぎて途中で SystemStackError が投げられてしまいました。再帰を使わずに書いた方が良いかもしれません。

4. まとめ

以上のように、クイックソートは実装が簡単でありながらも、ランダムなデータに対して効率良いソート方法です。
しかし、リストによっては、あまりにも単純な実装だとうまくいかないことがあるようです。
事前に何らかの構造を持ったデータであることがわかっている場合には、ソート方法をうまく選ぶと良いと思います。
Haskell でのより良い実装については [1] を参照してください。

...Haskell でもっとうまく実装できるようになりたい。