N 個のデータのうち小さい方から数えて k 番目の値を求める方法を「選択アルゴリズム (Selection Algorithm)」と呼びます。簡単な方法はクイックソートなどでソートすることですが、データ数 N が多くなるとクイックソートでも N * log2 N に比例する時間が必要になります。ところが、クイックソートを変形することで、データ数 N に比例する程度の時間で k 番目のデータを求めることができます。これを「クイックセレクト (quick select)」と呼びます。
クイックソートはある値(枢軸)を基準にして、要素をそれより大きいものと小さいものの 2 つに分割していくことでソートを行います。2 つに分けた各々のグループを同様に分割して、さらに 2 つのグループに分けます。分割を続けていくと最後は要素がひとつになりソートが完了します。
クイックセレクトも枢軸を基準に二分割していきますが、枢軸の決め方と二分割したグループの扱い方が異なるのです。
3番目のデータを求める
↓
table[95376428] 3番目に格納されている7を
↑ ↑ 枢軸にして二分割する
L R
table[25346798] R>3なので前半部分0-4に対して
↑↑ 同様の操作を行う
RL
↓
table[25346798] 今度は4を枢軸として二分割する
↑ ↑
L R
table[23546798] L<3なので後半部分2-4に対して
↑↑ 同様の操作を行う
RL
図 : クイックセレクトのアルゴリズム
上図を見てください。配列 table の中から 3 番目のデータを求めます。この場合、枢軸は table[3] に格納されている 7 となります。この値を基準に、クイックソートと同様に配列を二分割すると、0 から R の前半部分に枢軸より小さい値が集まり、L から最後までの後半部分には枢軸より大きい値が集まります。
もし、R が 3 より大きいのであれば、求めるデータは前半部分に含まれることになります。逆に L が 3 より小さいのであれば、後半部分に含まれます。上図の場合は、R は 4 なので前半部分に求めるデータがあるのです。この部分に対して、同様の処理を繰り返します。
今度も table[3] に格納されている要素を枢軸にします。この枢軸に対して配列を二分割します。この処理を繰り返していくと、最後にはグループに含まれる要素がひとつになって分割不能になります。このとき、table[3] に格納されたデータが答となるのです。
このように、クイックセレクトは分割したグループの片方に対してのみ操作を行うので、クイックソートよりも高速に処理することが可能になります。
プログラムは次のようになります。
リスト : クイックセレクト
search1 :: Ord a => IOArray Int a -> a -> Int -> IO Int
search1 buff pv i = do
x <- readArray buff i
if pv > x
then search1 buff pv (i + 1)
else return i
search2 :: Ord a => IOArray Int a -> a -> Int -> IO Int
search2 buff pv j = do
x <- readArray buff j
if pv < x
then search2 buff pv (j - 1)
else return j
quickPartition :: Ord a => IOArray Int a -> a -> Int -> Int -> IO (Int, Int)
quickPartition buff pv low high = do
i <- search1 buff pv low
j <- search2 buff pv high
if i < j
then do swapItem buff i j
quickPartition buff pv (i + 1) (j - 1)
else return (i, j)
quickSelect :: Ord a => IOArray Int a -> Int -> IO a
quickSelect buff k = do
(start, end) <- getBounds buff
qselect start end
where
qselect start end
| start >= end = readArray buff k
| otherwise = do
pv <- readArray buff k
(i, j) <- quickPartition buff pv start end
if j < k
then qselect (j + 1) end
else qselect start (i - 1)
引数 k が求める順番を表し、局所関数 qselect の引数 start と end が分割する区間の両端を表します。関数 search1, search2, partition は「配列のソート (2)」で作成したクイックソートと同じです。
最初に、配列 table の k 番目に位置するデータを枢軸 pivot に選び、partition で区間を二分割します。分割が終わって j が k より小さい場合は後半部分を分割します。区間 (j + 1, end) に対して qselect を再帰呼び出しします。そうでなければ前半部分を分割します。区間 (start, i - 1) に対して qselect を再帰呼び出しします。start が end 以上になったならば buff の k 番目の値を返します。
それでは、どの程度効果があるのか拙作のページ「配列のソート (2)」で作成したクイックソート quickSort と比較してみましょう。データ数を 5000, 10000, 20000, 40000 増やしていき、その中央値を求めることにします。テストプログラムは簡単なので説明は割愛します。詳細はプログラムリストをお読みください。
インタプリタ ghci 上で実行したところ、実行結果は次のようになりました。
ghci> test 5000 159050967812799482 0.030705092s 159050967812799482 0.146068966s ghci> test 10000 159755821740510529 0.050423603s 159755821740510529 0.307371815s ghci> test 20000 42943112069563441 0.115262209s 42943112069563441 0.649589248s ghci> test 40000 88201593115835707 0.214998141s 88201593115835707 1.349078594s
select がとても速いことがわかります。
ご参考までに、リストで動作するプログラムも作ってみました。この場合、枢軸はリストの先頭要素とし、分割した前半のリストの長さ a で場合分けします。a が k と等しい場合、枢軸が k 番目の要素になります。a が k よりも大きい場合、k 番目の要素は前半のリストにあります。それ以外の場合、k 番目の要素は後半のリストにあります。興味のある方はプログラムリストをお読みくださいませ。
--
-- quickselect.hs : クイックセレクト
--
-- Copyright (C) 2013-2021 Makoto Hiroi
--
import Data.Array.IO
import Control.Monad
import Data.Time
import System.Random
-- 要素の交換
swapItem :: IOArray Int a -> Int -> Int -> IO ()
swapItem buff i j = do
a <- readArray buff i
b <- readArray buff j
writeArray buff i b
writeArray buff j a
-- ソート
search1 :: Ord a => IOArray Int a -> a -> Int -> IO Int
search1 buff pv i = do
x <- readArray buff i
if pv > x
then search1 buff pv (i + 1)
else return i
search2 :: Ord a => IOArray Int a -> a -> Int -> IO Int
search2 buff pv j = do
x <- readArray buff j
if pv < x
then search2 buff pv (j - 1)
else return j
quickPartition :: Ord a => IOArray Int a -> a -> Int -> Int -> IO (Int, Int)
quickPartition buff pv low high = do
i <- search1 buff pv low
j <- search2 buff pv high
if i < j
then do swapItem buff i j
quickPartition buff pv (i + 1) (j - 1)
else return (i, j)
quickSort :: Ord a => IOArray Int a -> IO ()
quickSort buff = do
(low, high) <- getBounds buff
qsort low high
where
qsort low high = do
pv <- readArray buff ((low + high) `div` 2)
(i, j) <- quickPartition buff pv low high
when (low < i - 1) $ qsort low (i - 1)
when (high > j + 1) $ qsort (j + 1) high
-- 選択
quickSelect :: Ord a => IOArray Int a -> Int -> IO a
quickSelect buff k = do
(start, end) <- getBounds buff
qselect start end
where
qselect start end
| start >= end = readArray buff k
| otherwise = do
pv <- readArray buff k
(i, j) <- quickPartition buff pv start end
if j < k
then qselect (j + 1) end
else qselect start (i - 1)
-- リスト版
quickSort' :: Ord a => [a] -> [a]
quickSort' [] = []
quickSort' (x:xs) = quickSort' [y | y <- xs, y < x]
++ [x]
++ quickSort' [y | y <- xs, y >= x]
quickSelect' :: Ord a => [a] -> Int -> a
quickSelect' xs k =
iter xs k
where iter [x] 0 = x
iter (x:xs) k = if a == k then x
else if a > k then iter ys k
else iter zs (k - a - 1)
where ys = [y | y <- xs, y < x]
a = length ys
zs = [z | z <- xs, z >= x]
test :: Int -> IO ()
test n = do
a <- newListArray (0, n - 1) (take n (randoms (mkStdGen 11) :: [Int])) :: IO (IOArray Int Int)
b <- newListArray (0, n - 1) (take n (randoms (mkStdGen 11) :: [Int])) :: IO (IOArray Int Int)
x1 <- getCurrentTime
y1 <- quickSelect a (n `div` 2)
x2 <- getCurrentTime
print y1
print (diffUTCTime x2 x1)
x3 <- getCurrentTime
quickSort b
y2 <- readArray b (n `div` 2)
x4 <- getCurrentTime
print y2
print (diffUTCTime x4 x3)
test' :: Int -> IO ()
test' n = do
let a = take n (randoms (mkStdGen 11) :: [Int])
b = take n (randoms (mkStdGen 11) :: [Int])
x1 <- getCurrentTime
print (quickSelect' a (n `div` 2))
x2 <- getCurrentTime
print (diffUTCTime x2 x1)
x3 <- getCurrentTime
let c = quickSort' b
print (c !! (n `div` 2))
x4 <- getCurrentTime
print (diffUTCTime x4 x3)