古典的なソートアルゴリズムを現代の C++ で実装するにはどうすればよいでしょうか? 質問する

古典的なソートアルゴリズムを現代の C++ で実装するにはどうすればよいでしょうか? 質問する

C++標準ライブラリのアルゴリズム(およびその類似のおよび)は、ほとんどの実装で使用されてstd::sortいるstd::partial_sortstd::nth_elementより基本的なソートアルゴリズムの複雑でハイブリッドな融合選択ソート、挿入ソート、クイックソート、マージソート、ヒープソートなど。

ここや姉妹サイトには多くの質問があります。https://codereview.stackexchange.com/これらの古典的なソートアルゴリズムの実装のバグ、複雑さ、その他の側面に関連しています。提供されている実装のほとんどは、生のループで構成され、インデックス操作と具体的な型を使用しており、正確性と効率性の観点から分析するのは一般的に簡単ではありません。

質問: 上記の古典的なソートアルゴリズムを、最新の C++ を使用して実装するにはどうすればよいでしょうか?

  • 生のループはありませんが、標準ライブラリのアルゴリズムの構成要素を組み合わせて<algorithm>
  • イテレータインターフェースと、インデックス操作と具体的な型の代わりにテンプレートを使用する
  • C++14 スタイルには、完全な標準ライブラリのほか、auto、テンプレート エイリアス、透過的なコンパレータ、多態的なラムダなどの構文ノイズ削減機能が含まれています。

ノート

  • ソートアルゴリズムの実装に関するさらなる参考文献については、ウィキペディアロゼッタコードまたはhttp://www.sorting-algorithms.com/
  • によるとショーン・ペアレントのコンベンション(スライド 39)、raw ループは、for演算子を使用した 2 つの関数の合成よりも長いループです。したがって、f(g(x));またはf(x); g(x);またははraw ループではなく、および以下f(x) + g(x);のループも raw ループではありません。selection_sortinsertion_sort
  • 私は Scott Meyers の用語に従って、現在の C++1y をすでに C++14 として表し、C++98 と C++03 の両方を C++98 として表しています。そのため、そのことで私を非難しないでください。
  • @Mehrdad のコメントで示唆されているように、回答の最後にライブ例として C++14、C++11、C++98、Boost と C++98 の 4 つの実装を示します。
  • 回答自体は C++14 のみに基づいて提示されています。関連する箇所では、さまざまな言語バージョンで異なる構文とライブラリの違いを示します。

ベストアンサー1

アルゴリズムの構成要素

まず、標準ライブラリからアルゴリズムの構成要素を組み立てます。

#include <algorithm>    // min_element, iter_swap, 
                        // upper_bound, rotate, 
                        // partition, 
                        // inplace_merge,
                        // make_heap, sort_heap, push_heap, pop_heap,
                        // is_heap, is_sorted
#include <cassert>      // assert 
#include <functional>   // less
#include <iterator>     // distance, begin, end, next
  • 非メンバーのstd::begin()/std::end()や with などの反復ツールstd::next()は、C++11 以降でのみ使用できます。C++98 では、これらを自分で記述する必要があります。boost::begin()/の Boost.Rangeboost::end()や の Boost.Utility には代替手段がありますboost::next()
  • このstd::is_sortedアルゴリズムは C++11 以降でのみ使用できます。C++98 では、これを とstd::adjacent_find手書きの関数オブジェクトで実装できます。Boost.Algorithm はboost::algorithm::is_sorted代替として も提供します。
  • このstd::is_heapアルゴリズムは C++11 以降でのみ使用できます。

構文上の利点

C++14は透明な比較器引数に対して多態的に動作するフォームstd::less<>。これにより、イテレータの型を指定する必要がなくなります。これは、C++11のデフォルトの関数テンプレート引数比較として受け取るソート アルゴリズムと、ユーザー定義の比較関数オブジェクトを持つソート アルゴリズムの単一のオーバーロードを作成します。<

template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});

C++11では、再利用可能なテンプレートエイリアスソートアルゴリズムのシグネチャに若干の混乱を加えるイテレータの値の型を抽出します。

template<class It>
using value_type_t = typename std::iterator_traits<It>::value_type;

template<class It, class Compare = std::less<value_type_t<It>>>
void xxx_sort(It first, It last, Compare cmp = Compare{});

C++98では、2つのオーバーロードを記述し、冗長なtypename xxx<yyy>::type構文を使用する必要があります。

template<class It, class Compare>
void xxx_sort(It first, It last, Compare cmp); // general implementation

template<class It>
void xxx_sort(It first, It last)
{
    xxx_sort(first, last, std::less<typename std::iterator_traits<It>::value_type>());
}
  • もう 1 つの構文上の利点は、C++14 では、ユーザー定義の比較子を多態的なラムダ(auto関数テンプレートの引数のように推測されるパラメーターを持つ) でラップすることが容易になることです。
  • C++11 にはモノモーフィック ラムダのみがあり、上記のテンプレート エイリアスの使用が必要ですvalue_type_t
  • C++98 では、スタンドアロンの関数オブジェクトを記述するか、冗長なstd::bind1st/ std::bind2nd/std::not1タイプの構文に頼る必要があります。
  • Boost.Bind は、boost::bindand _1/_2プレースホルダー構文を使用してこれを改善します。
  • C++11 以降では も使用できますstd::find_if_notが、C++98 では関数オブジェクトの周囲std::find_ifに が必要です。std::not1

C++ スタイル

一般的に受け入れられるC++14スタイルはまだありません。良くも悪くも、私はScott Meyersの効果的なモダンC++の草案ハーブ・サッターの改良されたGotW私は以下のスタイル推奨事項を使用します。

  • ハーブ・サッターの「ほぼ常に自動」スコット・マイヤーズの「特定の型宣言よりも自動宣言を優先する」簡潔さでは他に類を見ない勧告であるが、その明快さは時として論争中
  • スコット・マイヤーズの「オブジェクトを作成するときに()と を区別する」{}{}そして、古き良き括弧付き初期化の代わりに、一貫して中括弧付き初期化を選択します()(汎用コードにおける最も厄介な解析の問題をすべて回避するため)。
  • スコット・マイヤーズの「typedef よりもエイリアス宣言を優先する」テンプレートの場合、これはいずれにしても必須であり、 の代わりにどこでもこれを使用すると、typedef時間が節約され、一貫性が向上します。
  • for (auto it = first; it != last; ++it)すでにソートされたサブ範囲のループ不変チェックを可能にするために、いくつかの場所でパターンを使用しています。製品コードでは、ループ内のどこかでwhile (first != last)と a を使用する方++firstが若干良いかもしれません。

選択ソート

選択ソートはデータにまったく適応しないため、実行時間は常に です。ただし、選択ソートにはスワップの回数を最小限に抑えるO(N²)という特性があります。アイテムのスワップコストが高いアプリケーションでは、選択ソートが最適なアルゴリズムである可能性があります。

標準ライブラリを使用してこれを実装するには、繰り返し使用してstd::min_element残りの最小要素を見つけ、iter_swapそれを所定の位置に交換します。

template<class FwdIt, class Compare = std::less<>>
void selection_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last; ++it) {
        auto const selection = std::min_element(it, last, cmp);
        std::iter_swap(selection, it); 
        assert(std::is_sorted(first, std::next(it), cmp));
    }
}

selection_sortには、ループ不変条件として、すでに処理された範囲がソートされていることに注意してください。のランダム アクセス イテレータと比較すると、[first, it)最小要件は、順方向イテレータです。std::sort

詳細は省略

  • 選択ソートは、早期テストif (std::distance(first, last) <= 1) return;(または前方/双方向イテレータの場合:if (first == last || std::next(first) == last) return;)で最適化できます。
  • 双方向イテレータの場合、[first, std::prev(last))最後の要素が残りの最小要素であることが保証され、スワップを必要としないため、上記のテストは間隔のループと組み合わせることができます。

挿入ソート

O(N²)これは最悪ケース時間を伴う基本的なソートアルゴリズムの1つですが、挿入ソート挿入ソートは、データがほぼソートされている場合(適応型であるため)、または問題のサイズが小さい場合(オーバーヘッドが低いため)に選択されるアルゴリズムです。これらの理由と、挿入ソートが安定しているため、マージソートやクイックソートなどのオーバーヘッドが高い分割統治ソートアルゴリズムの再帰的な基本ケース(問題のサイズが小さい場合)としてよく使用されます。

insertion_sort標準ライブラリを使用して実装するには、 を繰り返し使用してstd::upper_bound現在の要素を配置する場所を見つけ、 を使用してstd::rotate残りの要素を入力範囲内で上方にシフトします。

template<class FwdIt, class Compare = std::less<>>
void insertion_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last; ++it) {
        auto const insertion = std::upper_bound(first, it, *it, cmp);
        std::rotate(insertion, it, std::next(it)); 
        assert(std::is_sorted(first, std::next(it), cmp));
    }
}

ループ不変条件として、insertion_sortすでに処理された範囲がソートされていることに注意してください。挿入ソートは、前方反復子でも機能します。[first, it)

詳細は省略

  • 挿入ソートは、最初の要素が確実に所定の位置に配置され、回転を必要としないため、早期テストif (std::distance(first, last) <= 1) return;(または順方向 / 双方向イテレータの場合: if (first == last || std::next(first) == last) return;) と区間 のループを使用して最適化[std::next(first), last)できます。
  • 双方向イテレータの場合、挿入ポイントを見つけるためのバイナリ検索は、標準ライブラリのアルゴリズムを使用した逆線形検索std::find_if_notに置き換えることができます。

4つの実例C++14C++11C++98 と BoostC++98) を以下のフラグメントに適用します。

using RevIt = std::reverse_iterator<BiDirIt>;
auto const insertion = std::find_if_not(RevIt(it), RevIt(first), 
    [=](auto const& elem){ return cmp(*it, elem); }
).base();
  • ランダムな入力の場合、これはO(N²)比較を提供しますが、O(N)ほぼソートされた入力の場合、比較に改善されます。バイナリ検索では常にO(N log N)比較が使用されます。
  • 入力範囲が小さい場合、線形検索のメモリ局所性 (キャッシュ、プリフェッチ) が優れているため、バイナリ検索よりも優れている可能性があります (もちろん、これをテストする必要があります)。

クイックソート

慎重に実施すれば、クイックソート堅牢で、O(N log N)予想される複雑さを備えていますが、O(N²)敵対的に選択された入力データによって最悪の場合の複雑さが発生する可能性があります。安定したソートが必要ない場合は、クイック ソートは優れた汎用ソートです。

最も単純なバージョンであっても、クイック ソートを標準ライブラリを使用して実装するのは、他の従来のソート アルゴリズムよりもかなり複雑です。以下のアプローチでは、いくつかの反復ユーティリティを使用して、入力範囲の中央の要素[first, last)をピボットとして特定し、 を 2 回呼び出してstd::partition( O(N))、入力範囲を、それぞれ選択したピボットより小さい、等しい、大きい要素のセグメントに 3 方向に分割します。最後に、ピボットより小さい要素とピボットより大きい要素を持つ 2 つの外側のセグメントが再帰的にソートされます。

template<class FwdIt, class Compare = std::less<>>
void quick_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    auto const N = std::distance(first, last);
    if (N <= 1) return;
    auto const pivot = *std::next(first, N / 2);
    auto const middle1 = std::partition(first, last, [=](auto const& elem){ 
        return cmp(elem, pivot); 
    });
    auto const middle2 = std::partition(middle1, last, [=](auto const& elem){ 
        return !cmp(pivot, elem);
    });
    quick_sort(first, middle1, cmp); // assert(std::is_sorted(first, middle1, cmp));
    quick_sort(middle2, last, cmp);  // assert(std::is_sorted(middle2, last, cmp));
}

ただし、クイック ソートは、上記の各ステップを慎重にチェックし、実稼働レベルのコードに合わせて最適化する必要があるため、正確かつ効率的に行うのがかなり難しいです。特に、O(N log N)複雑さに関しては、ピボットは入力データのバランスの取れたパーティションになる必要がありますが、これは一般にピボットでは保証できませんが、ピボットを入力範囲の中央値O(1)として設定すれば保証できます。O(N)

詳細は省略

  • O(N^2)上記の実装は、特殊な入力に対して特に脆弱です。たとえば、 「オルガンパイプ」入力に対しては複雑になります1, 2, 3, ..., N/2, ... 3, 2, 1(中央の要素が常に他のすべての要素よりも大きいため)。
  • 3 の中央値ピボットの選択ランダムに選択された要素入力範囲から、複雑性が に低下することになるほぼソートされた入力から保護しますO(N^2)
  • 3ウェイパーティショニング(ピボットより小さい、等しい、大きい要素を分離する) は、 の 2 つの呼び出しで示されているように、この結果を達成するためのstd::partition最も効率的なアルゴリズムではありません。O(N)
  • ランダム アクセス イテレータの場合、を使用した中央ピボット選択に続いておよびを再帰的に呼び出すことで、保証されたO(N log N)複雑度を実現できます。std::nth_element(first, middle, last)quick_sort(first, middle, cmp)quick_sort(middle, last, cmp)
  • ただし、この保証にはコストがかかります。O(N)の複雑さの定数係数は、の呼び出し(これは、データに対するキャッシュ フレンドリーな単一のフォワード パスです)が続く 3 の中央値ピボットの複雑さstd::nth_elementの定数係数よりもコストがかかる可能性があるからです。O(1)O(N)std::partition

マージソート

余分なスペースを使うことにO(N)問題がなければ、マージソートは優れた選択肢です。これは唯一の安定した O(N log N)ソートアルゴリズムです。

標準アルゴリズムを使用して実装するのは簡単です。いくつかの反復ユーティリティを使用して入力範囲の中央を見つけ[first, last)、再帰的にソートされた 2 つのセグメントを次のように結合しますstd::inplace_merge

template<class BiDirIt, class Compare = std::less<>>
void merge_sort(BiDirIt first, BiDirIt last, Compare cmp = Compare{})
{
    auto const N = std::distance(first, last);
    if (N <= 1) return;                   
    auto const middle = std::next(first, N / 2);
    merge_sort(first, middle, cmp); // assert(std::is_sorted(first, middle, cmp));
    merge_sort(middle, last, cmp);  // assert(std::is_sorted(middle, last, cmp));
    std::inplace_merge(first, middle, last, cmp); // assert(std::is_sorted(first, last, cmp));
}

マージ ソートには双方向イテレータが必要であり、ボトルネックは ですstd::inplace_merge。リンク リストをソートする場合、マージ ソートには追加のスペース (再帰用) のみが必要であることに注意してください。後者のアルゴリズムは、標準ライブラリのO(log N)によって実装されています。std::list<T>::sort

ヒープソート

ヒープソート実装は簡単で、O(N log N)インプレースソートを実行しますが、安定していません。

最初のループ ( O(N)「ヒープ化」フェーズ) では、配列をヒープ順序にします。2 番目のループ ( O(N log N「ソートダウン」フェーズ) では、最大値を繰り返し抽出し、ヒープ順序を復元します。標準ライブラリでは、この処理が非常に簡単に行えます。

template<class RandomIt, class Compare = std::less<>>
void heap_sort(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    lib::make_heap(first, last, cmp); // assert(std::is_heap(first, last, cmp));
    lib::sort_heap(first, last, cmp); // assert(std::is_sorted(first, last, cmp));
}

std::make_heapとを使用するのが「ズル」であると思われる場合はstd::sort_heap、もう 1 レベル深く掘り下げて、それぞれ と を使ってこれらの関数を自分で記述することもできstd::push_heapますstd::pop_heap

namespace lib {

// NOTE: is O(N log N), not O(N) as std::make_heap
template<class RandomIt, class Compare = std::less<>>
void make_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last;) {
        std::push_heap(first, ++it, cmp); 
        assert(std::is_heap(first, it, cmp));           
    }
}

template<class RandomIt, class Compare = std::less<>>
void sort_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    for (auto it = last; it != first;) {
        std::pop_heap(first, it--, cmp);
        assert(std::is_heap(first, it, cmp));           
    } 
}

}   // namespace lib

標準ライブラリは、push_heapとの両方pop_heapを複雑度 として指定していますO(log N)。ただし、 の範囲での外側のループはに対して複雑度[first, last)になりますが、 には複雑度のみがあることに注意してください。 の全体的な複雑度については、問題になりません。O(N log N)make_heapstd::make_heapO(N)O(N log N)heap_sort

詳細は省略O(N)の実装make_heap

テスト

ここに4つのライブ例を示します(C++14C++11C++98 と BoostC++98) さまざまな入力に対して 5 つのアルゴリズムすべてをテストします (網羅的または厳密であることを意味するものではありません)。LOC に大きな違いがあることに注意してください。C++11/C++14 では約 130 LOC、C++98 と Boost では 190 LOC (+50%)、C++98 では 270 LOC 以上 (+100%) が必要です。

おすすめ記事