RCIE-ジャンクのコード屋

主に自分のためにコーディングのTIPSを蓄積しています。

【アルゴリズム】シェルソートの速度を考察した

はじめに

前提:挿入ソートとは何か

  • 「挿入ソート」とは、以下のようなソートアルゴリズムである。
    • 配列N番目(N≧2)の数字に注目する。
    • その数字が左隣の数字より小さい間は、左隣の数字と交換し続ける。
    • 配列N+1番目の数字に注目する。
    • その数字が左隣の数字より小さい間は、左隣の数字と交換し続ける。
  • 図を見た方が分かりやすい。赤字のところが交換した箇所である。

63045812711109
36045812711109
30645812711109
03645812711109
03465812711109
03456812711109
03456182711109
03451682711109
03415682711109
03145682711109
01345682711109
01345628711109
01345268711109
01342568711109
01324568711109
01234568711109
01234567811109
01234567810119
01234567810911
01234567891011

挿入ソートの特徴

  • 交換回数が少ない。
  • 1000くらいの要素なら、実用的な速さで動く。
  • (要素数)2 に比例した回数の比較処理を行うため、100万要素の並べ替えは現実的な時間ではできない。(2022年の時点)

挿入ソートの比較回数(ランダム配列の場合)

  • 素数をNとして、比較回数は 0.24 N2 に近似できる。

要素比較回数交換回数
103226
31245215
10029642870
3162428523981
1000234963233974
316224456242442476
100002388683323876841

  • もし、100万要素の並べ替えを行うなら、じつに 2400億回 もの比較が必要になる。
  • これを執筆しているPCでは、約7200秒(=120分)かかる見積もりだ。

改良版挿入ソート「シェルソート

  • ドナルド・シェルが、1959年に発表したソートアルゴリズム
  • 「挿入ソートは隣同士しか交換しないから遅い」という欠点を克服すべく、離れた位置の要素を交換するように改善した。
  • まずは、4n番目(0, 4, 8, 12, …)の要素を挿入ソートする。つまり4つ離れた位置の挿入ソート。
  • つぎに、4n+1番目(1, 5, 9, 13, …)の要素を挿入ソートする。
  • つぎに、4n+2番目(2, 6, 10, 14, …)の要素を挿入ソートする。
  • つぎに、4n+3番目(3, 7, 11, 15, …)の要素を挿入ソートする。
  • 最後に、n番目(0, 1, 2, 3, …)の要素について普通の挿入ソートをする。
  • 4n番目などの挿入ソートを最初に実行している分、遅くなりそうな雰囲気があるが、実際にはそうではない。
  • 図を見た方が分かりやすい。赤字のところが交換した箇所である。

11109876543210
71098116543210
71098365411210
31098765411210
36987105411210
36987254111010
32987654111010
32587694111010
32587614111090
32187654111090
32147658111090
32147650111098
32107654111098
23107654111098
21307654111098
12307654111098
12037654111098
10237654111098
01237654111098
01236754111098
01236574111098
01235674111098
01235647111098
01235467111098
01234567111098
01234567101198
01234567109118
01234567910118
01234567910811
01234567981011
01234567891011

  • 交換する距離は、配列の大きさによって変更する。

なぜ速いのか

  • 4つ離れた位置ごとのソートが済んでいる場合、普通のソートの実行時間は大幅に短縮されるから。(上の表を参照)
  • 素数がさらに大きいときは、挿入ソートをする間隔を以下のように広げていく。
    • 1 → 4 → 13 → 40 → 121 → (3倍+1)

シェルソートの比較回数(ランダム配列の場合)

  • 素数をNとして、比較回数は 3.5 N1.2・(logeN)0.1 に近似できる。
  • Wikipedia には、N1.25 のオーダーと書いてあるが、こちらの方が実測値に近い。

素数比較回数交換回数
101910
3113583
1001062881
31643333437
10001743613634
31627096655731
10000274549216244
316221123988907860
10000039637733101598
3162271619158713143666
10000006408612053433454

挿入ソート VS シェルソート

素数
N
挿入ソート
0.24 N2
シェルソート
3.5 N1.2・(logeN)0.1
速度比
1000 240000 15552 15倍
10000 24000000 253673 95倍
100000 2400000000 4111166 584倍
1000000 240000000000 66356454 3617倍

JavaScript での実装

function shellSort(ary){
	// 最初の交換間隔を決める(1→4→13→40→121→...)
	let d = 1;             // d : 交換間隔
	let z = ary.length;    // z : 配列長
	let dmax = z / 30 | 0; // 最初のdは配列長の1/9程度が良い
	while(d <= dmax){
		d = d + d + d + 1;
	}
	// 交換間隔が0になったら終了
	while(d > 0){
		// i : 交換間隔の分だけ繰り返す(間隔が40なら40回)
		for(let i = 0; i < d; i++){
			// j : i~末尾まで繰り返す
			for(let j = i; j + d < z; j += d){
				// k : j~先頭まで繰り返す
				for(let k = j; k >= 0; k -= d){
					// 左が大きい場合は交換する
					// 右が大きい場合はjを進める
					if(ary[k] > ary[k + d]){
						[ary[k], ary[k + d]] =
							[ary[k + d], ary[k]];
					}else{
						break;
					}
				}
			}
		}
//		交換間隔を小さくする(121→40→13→4→1→0)
		d = d / 3 | 0;
	}
}

その他、シェルソートの特性

  • ソート済配列の先頭や末尾に追加した場合、比較回数は O(N log1.2N) のオーダー。
    • 先頭や末尾に k個 追加した場合の比較回数(実測値)の近似値は、
      1.2 N・loge(k・N)1.2
    • 挿入ソートの末尾追加のオーダーは O(N) であるから、その場合では挿入ソートの方が有利になる。

まとめ

  • N個のランダム配列では、シェルソートの比較回数は 3.5 N1.2・(logeN)0.1 だと判明した。
    参考までに、JavaScriptで整数の配列をソートする場合、1億回の比較に3秒かかる。
  • シェルソートは22行で実装できるので、挿入ソートをシェルソートに置き換えるのはいいぞ。