LoginSignup
0
0

More than 3 years have passed since last update.

乱数でピボットを選択した qsort の最悪計算時間 について

Last updated at Posted at 2021-04-23

開発に至る経緯

javaのsortとしてTimSortが採用されたと聞いた。TimSortはマージソートが元に
なっているので速度的に問題があると思った。そこで、クイックソートを元にした
  Sqsort   https://qiita.com/t-kawa/items/4be381efb74d44fc991c を開発した。
初代のSqsortは、TimSortより速く、安定だが、最悪計算時間で負けていた。
そこで「乱数を用いてピボットを選択」を導入して、最悪計算時間の改善を図った。
これにより、Sqsort の最悪計算時間が O(n^2) になる確率は
実用上ほぼ0であると考える。これを解説するのが本稿の目的である。

本稿は証明ではなく、「実用上問題ない」ことの解説である。

理想的な解決方法(解析的方法)

「乱数でピボットを選択したqsort」は
最悪計算時間や安全性の問題を、確率の問題として扱おうとしている。
ユニーク乱数による長さ n の配列をクイックソート(qsort)するとき、
要素の比較回数の平均は C × n × log2(n) である。
ソート1回における比較回数が C × n × log2(n) の a 倍 以上 になる確率 P ( n, a ) は
どのような数式になるのだろうか?
この問題に解析的な答えがあるらな、P( 1000000, 2.0 ) などを手(て)計算して、
その値が十分に小さいこと示せば、「実用上問題ない」ことを示せたことになる。
しかし、解析的な答えは作成不可能のように思われた。

  (「ユニーク乱数による配列」とは、ソートキーに同じものが1つもなくて、
    キーの並びを乱数で決定した配列のことである。)

シミュレーションによる解決方法

qsortの最悪計算時間には、次の3つの場合がある。
 1.n! 通りある要素の並びから選ばれた配列が、本当に本当に運悪く最悪である場合。
 2."並び" と ピボット選択方法 の相性が悪い場合。(並びが既昇順+ピボット先頭固定など)
 3.特定のqsortアルゴリズムを攻撃するために作成された「悪意のある配列」である場合。

この内、「1.」は qsort の本質的な特徴であり、改善することはできない。
一方、「ピボットの位置を乱数を用いて決定」することにより
「2.」と「3.」の出現確率は、「1.」と同じ出現確率になると考えられる。

以下では「乱数でピボットを選択」を導入した Sqsort について解説する。
これにより、「1.2.3.」全ての場合で最悪計算時間を実用上 O(n×log(n)) にできると考える。

ソートキーがユニーク乱数で、要素数が n=1000、ソートの繰返し回数が R=1万回 のとき、
1万回中の i 回目のソートにおける"要素の比較関数"の呼出し回数(以後,比較回数)を ci とする。
また、計算時間は ci に比例し、ci の平均は n×log(n)(=g)に比例するものとする。

以上を前提とするとき、ci/g (i=1,2,…,1万) は i や n が変化してもほぼ一定の値を取る(1.0に近い値)。
しかし、かなりの変動がある。R が大きいほど変動幅も大きくなる。
c1/g, c2/g, …, c1万/g を計算して ci/g の分布を取ると次のようになった。(n=1000, R=10000の場合)

ci/g区間 [0.9,1.0) [1.0,1.1) [1.1,1.2) [1.2,1.3) [1.3,1.4) [1.4,1.5) [1.5,1.6)
出現頻度 434 4907 3727 825 92 14   1 

これを n=1000、R=1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10 について計算したのが表1である。
ここで 1e4 は 10^4 を表している。 @1.2 は区間 [1.2,1.3) を表している。

ci/g区間 @0.8 @0.9 @1.0 @1.1 @1.2 @1.3 @1.4 @1.5 @1.6 @1.7 @1.8 @1.9 @2.0
R=1e3 47 454 397 89 12 1
R=1e4 434 49e2 37e2 825 92 14 1
R=1e5 45e2 48e3 38e3 82e2 10e2 98 3 1
R=1e6 2 46e3 49e4 38e4 82e3 98e2 827 44 4 #
R=1e7 20 45e4 49e5 37e5 82e4 99e3 86e2 555 32 3 #
R=1e8 250 45e5 49e6 37e6 82e5 99e4 85e3 59e2 299 13 1
R=1e9 23e2 45e6 49e7 37e7 82e6 99e5 85e4 58e3 33e2 158 10 1
R=1e10 24e3 45e7 49e8 37e8 82e7 99e6 85e5 58e4 33e3 1513 59 5 #

      表1 n=1000 での比較回数のバラツキ(ci/g)の分布 (空白欄は"0") (行の横合計=R)

表1の各行の右端の "1" に着目する。( "#" は "1" の出現が予測される位置である。)
ソートをR回繰返したとき、"1" は比較回数(バラツキci/g)が最大となる区間を表している。
R=1e4=1万の場合は、@1.5 の下に "1" があるので
ソートを1万回(R)繰返した場合、比較回数 @1.5 × n × log(n) 回を必要とするソートが
1万回(R)の中に平均で約 "1" 回あることを示している。
逆に言うと、「 @1.5 の変動を目撃したければ約1万回の繰返し(R)を覚悟しろ」と言うことである。

表1の "1"("#"を含む)に着目し、8個ある "1" を出現させるための繰返し回数(R)
を1行で表すと次のようになる。(n=1000の場合)

ci/g区間 @1.4 @1.5 @1.6 @1.7 @1.8 @1.9 @2.0
繰返し回数R 1e3 1e4 1e5 1e6 1e7~1e8 1e9 1e10

さらに、これを n=1000=1e3 だけでなく n=1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8
について計算したのが表2である。

ci/g区間 @1.4 @1.5 @1.6 @1.7 @1.8 @1.9 @2.0 @2.1 @2.2 @2.6
n=1e2 1e3 1e4 1e5 1e6 1e7 1e7 1e89 1e10 1e11 予想
n=1e3 1e3 1e4 1e5 1e6 1e78 1e9 1e10 (1e16)
n=1e4 1e4 1e4 1e56 1e78 1e9 (1e17)
n=1e5 1e3 1e35 1e67 1e8 (1e18)
n=1e6 1e3 1e4 1e57 (1e18)
n=1e7 1e3 1e45 1e67 (1e18)
n=1e8 1e34 1e5 1e6 (1e18)

   表2 各n,各区間に対応する "1" が出現するはずの繰返し回数(R) (@2.6の列は予想値)
      1e35 は 10^3~10^5 を表している。ただし、1e1* は 10^1* を表している。

この表の値を qsort で計算しようとすると時間がかかり過ぎて計算できない。
「ソートせずに比較回数(ci)だけ」を計算するプログラムsss(約100倍高速)
http://ww51.tiki.ne.jp/~srr-cake/qsort/sqs18j/main_sss.c を作成・使用している。
この表の空白欄は "0" ではない。sssでも時間がかかり過ぎて計算できない部分である。

計算できない部分(結論)

計算できない部分も予想することはできる。表2の@2.6の列は予想値である。
ただし、表2の "※" などの位置に小さな値(1e5など)が現れると予想はできなくなってしまう。
@2.6列はあくまで予想であって根拠はない。

n=1e3 行 で @2.6 列 の欄の予想回数(R)は、1e16 以上と予想される。
予想回数R(1e16回) のどの 比較回数(ci) も概ね n × log(n) 以上である。
よって、この場合の "総" 比較回数は
  予想回数R × n × log(n) ≒ 1e16 × 1000 × 10 = 1e20
以上となる。
比較1回あたりの計算時間を10ナノ秒とすると、
  予想回数実行に必要な計算時間 = 1e20比較 × 10ナノ秒/比較 ≒ 3万年
以上となる。

一般に、qsortの平均比較回数(=C×n×log(n))の C は 1.2~1.3 である。
@2.6 は C のほぼ2倍である。そのため、偶然に平均の2倍の計算時間に
遭遇するには、n=1000 の場合で "3万年" 待つことが "予想" される。
計算機の故障頻度などを考慮すれば、3万年に1度の現象はほぼ出現しない現象といえる。
そして、仮に出現したとしても、平均の "たった" 2倍の計算時間で正常にソートは終了する。
このことは、「1.」の n! がいかに 大きな数 で、
計算時間 O(n^2) を必要とするケースがいかに 稀 であるかを示している。
以上により、「乱数でピボットを選択したqsort」の最悪計算時間は
O(n^2) でなく、O(n×log(n))として実用上問題ないと思われる。

悪意のある配列について

「悪意のある配列」に対しても「乱数でピボットを選択したqsort」は有効である。
与えられた配列がたとえ「悪意のある配列」であったとしても、
乱数でピボットを選択することにより、「悪意のある並び順」が無効になるからである。

乱数を用いた Sqsort と qsort のソート結果の再現性

Sqsort はその処理の先頭で nanoTime() を1度だけ呼び出して乱数を作っている。
そのため、Sqsortの実行ごとに異なる乱数でピボットを選択する。
その結果、2つの同じ配列をソートしたとき、2つの"処理過程"は同じにはならない、
しかし、Sqsortは "安定" なソートなので "処理結果"(2つの配列) は同じになる。(再現性あり)

一方、従来のqsortに「乱数でピボットを選択」を導入した場合、
2つの同じ配列をソートしたとき、"処理結果" は異なる。(再現性なし)
qsortが "非安定" なため、ソートキーが同じ要素の最終位置が変動するからである。
「最悪計算時間」や「悪意のある配列」に関して、このqsortは良好な動作をする。
しかし、この再現性のなさは悪い動作といえる。

0
0
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
0