(2017/09/10 パフォーマンス比較にOrderByを追加。それに伴い、結論をやや修正)
ListとLinkedListの挿入ソートおよびマージソートを実装します。どちらも安定ソートです。
何を考えて実装したのか、長々と別の記事に書いてあるので、興味があればそっちもよろしくです。
マージソートを単純に実装するより、対象となる要素数が少ない時は挿入ソートのほうが速いので、挿入ソートに差し替える対応をします。なので、マージソートの実装がメインで、最適化の副産物で挿入ソートもあるよ、という感じです。
実装
拡張メソッドとして、ListとLinkedListに挿入ソート(insertionSort)とマージソート(mergeSort)を実装します。マージソートは再帰処理をしない方法で実装します。
また、高速化のため、要素数が閾値以下の部分リストに対しては、挿入ソートを適用します。挿入ソートに差し替えたくなければ、閾値を1にします。実装コード中に定義されているデフォルトの閾値は、私の環境で計測した結果を見て、良さそうな値を入れています。なんとなく2のべき乗を選んでいますが、2のべき乗でないと動かないというわけではないです。
ComparisonのIComparer変換のため、下記クラスを使います。今回の性能テストでは使ってませんが。
List
マージソートは再帰処理をする実装がよく知られていますが、最小部分リストから範囲を倍にしていけばループで書けるので、非再帰処理として実装しました。これで前方部分リストを詰めるためのバッファの確保は一度にまとまります。
LinkedList
List向けに実装した処理をLinkedList向けに直しました。LinkedListなら挿入が簡単ですが、その結果として先頭ノードが変わることがあるのがややこしいです。ランダムアクセスできないのが弱みなので、なるべく無駄なループは排除するよう配慮しました。
LinkedListNodeはほとんどのメンバがinternalで定義されていて外から直接触れないので、面倒ですがRemove()してからAddBefore()という手順で挿入をしています。すごく無駄な気がしますが仕方ない。
ちなみに、LinkedListに実装した挿入ソートおよびマージソートはアロケーションしません。
挿入ソート、マージソートのほか、arraySortを実装していますが、これは全要素をListに詰めてSort()したものをLinkedListに入れ直す処理です。そのため、In-placeではないし、不安定ソートです。性能比較で使います。
パフォーマンス比較
Unity使いなので、Unityの環境での検証になります。ご容赦ください。
テスト環境はUnity 5.6.3p1です。
int型のリストのソートについて、パフォーマンス測定します。
- 要素数100000のソートを20回繰り返した中で、最良の実行時間を選択(イレギュラーな値を排除したかったので中央値でも良かった)。
- 手法名で括弧書きのものは、括弧内に挿入ソートに切り替える要素数を示している。
- 「メモリ確保」は理論上、想定されるアロケートによるメモリ使用量をオーダーで示したもの。
- 「速度比較」は、最速だったList.Sort()を基準に、各手法が何倍時間がかかったかを示したもの。
コンテナ | 手法 | 安定性 | メモリ確保量 | 速度比較 | 実行時間 |
---|---|---|---|---|---|
List | Sort | 不安定 | O(1) | 1.00 | 0.0664 |
LinkedList | arraySort | 不安定 | O(N) | 1.22 | 0.0808 |
List | OrderBy | 安定 | O(N) | 1.23 | 0.0817 |
List | mergeSort(4) | 安定 | O(N) | 1.32 | 0.0877 |
List | mergeSort(8) | 安定 | O(N) | 1.33 | 0.0881 |
List | mergeSort(2) | 安定 | O(N) | 1.35 | 0.0900 |
List | mergeSort(1) | 安定 | O(N) | 1.39 | 0.0923 |
List | mergeSort(16) | 安定 | O(N) | 1.40 | 0.0932 |
List | mergeSort(32) | 安定 | O(N) | 1.62 | 0.1075 |
List | stableSort | 安定 | O(N) | 1.67 | 0.1109 |
LinkedList | stableSort | 安定 | O(N) | 1.77 | 0.1179 |
LinkedList | mergeSort(8) | 安定 | O(1) | 2.55 | 0.1697 |
LinkedList | mergeSort(16) | 安定 | O(1) | 2.58 | 0.1713 |
LinkedList | mergeSort(1) | 安定 | O(1) | 2.60 | 0.1725 |
LinkedList | mergeSort(4) | 安定 | O(1) | 2.61 | 0.1735 |
LinkedList | mergeSort(2) | 安定 | O(1) | 2.65 | 0.1758 |
LinkedList | mergeSort(32) | 安定 | O(1) | 2.74 | 0.1820 |
(2017/09/10追記)ListのOrderByの計測結果も加えました。Listに対してOrderBy(e => e).ToList()
を実行したパフォーマンスです。OrderBy()だけだと遅延評価によりソートが実行されないので、GetEnumerator()の呼び出しをしないと計測になりません。また、Listのソート結果をListで受け取りたかった都合もあり、ToList()までの性能を計測しています。
ListとLinkedListそれぞれにstableSortというのもありますが、これは各要素にインデックスを振ったものを、Listに詰めてからSort()したあとに、元のコンテナに詰め直すという処理です。インデックスによって標準ソートを安定ソートとして利用できるようにしたものです。stableSortの実装は別の記事にまとめました。OrderBy()より速くなることを期待したのですが、全然ダメでしたね。
前回の検討により、UnityのListはcomparerにnullを与えるのが場合によっては最速、という話がありましたが、性能比較がややこしくなるので避けました。なので、上位2位を抑えているSortとarraySortについては、intやdoubleに対してさらに速度を上げることはできます。
余談。クイックソートにも安定ソートの実装が存在しています。swapの代わりに順序保証した挿入を行えばよいので、LinkedListに都合がいいかと思って実装してみましたが、上記の表で一番下にまとまる程度の速度しか出なかったので割愛します。
まとめ
(2017/09/10)Listについて、OrderByを考慮する内容に修正。
Listに関しては内部ソートは諦めましたが、安定ソートとしてはマージソートがかなり速くて良いですね。ただ、OrderBy()がなんだかんだ速くて、速度だけを求めるならこれで良さそうです。マージソートは速度的にOrderBy()とほぼ遜色なく、OrderBy()ほどメモリを使用しないことに価値があります(オーダーは同じでも定数倍違います)。あとは部分ソートができます。
個人的にはUnityではListの安定ソートには、マージソートを使っていきたいです(詳しくは割愛しますが、拡張されたMonoヒープは縮小されずGC時間が長くなってスパイクが発生するので、あまりでかいヒープは使いたくないというゲームプログラムの事情によります)。
一方、LinkeListですが、LinkedList上でのソート実装はどうしてもList.Sort()の2.5倍程度の時間がかかってしまいました。結局、処理速度が重要なときはListにソートさせた方が速いので、arraySortかstableSortを選択することになりそうです。内部ソートであることに価値があるなら今回実装したマージソートは使えます。
少ない要素数のとき挿入ソートに差し替える最適化については、Listのマージソートは多少効いてますが、LinkedListのマージソートに対してはそれほど差が出ていないような気もします。LinkedListNodeの挿入コストが高すぎるのでしょうか。internalでなければなぁ・・・。
Listにしても、LinkedListにしても、stableSortのオーバーヘッドはそこそこあるので、最初からリスト要素にインデックスを持たせる設計にして、そのまま安定ソートを保証できるようにしてList.Sort()やLinkeList.arraySort()を使うのが無難そうではあります。