web-dev-qa-db-ja.com

`std :: list <> :: sort()`-なぜ突然トップダウン戦略に切り替わるのですか?

当初から、std::list<>::sort()を実装するための最も一般的なアプローチは、 ボトムアップ方式 で実装された古典的なマージソートアルゴリズムであったことを覚えています( gcc std :: listソートの実装はとても速いですか? )。

誰かがこの戦略を「タマネギ連鎖」アプローチと適切に呼んでいるのを見たのを覚えています。

少なくとも、それはGCCのC++標準ライブラリの実装における方法です(たとえば、 ここ を参照)。これは、MSVCバージョンの標準ライブラリの古いDimkumwareのSTLと、VS2013までのすべてのバージョンのMSVCでの方法です。

ただし、VS2015で提供される標準ライブラリは、突然この並べ替え戦略に従わなくなりました。 VS2015に同梱されているライブラリは、top-downマージソートのかなり単純な再帰的実装を使用しています。トップダウンのアプローチでは、リストを半分に分割するためにリストの中間点にアクセスする必要があるため、これは奇妙なことに思います。 _std::list<>_はランダムアクセスをサポートしていないため、その中間点を見つける唯一の方法は、リストの半分を文字通り繰り返すことです。また、最初に、リスト内の要素の総数を知る必要があります(これは、C++ 11より前のO(1)操作である必要はありません)。

それにもかかわらず、VS2015のstd::list<>::sort()はまさにそれを行います。これは、中間点を特定して再帰呼び出しを実行する実装からの抜粋です。

_...
iterator _Mid = _STD next(_First, _Size / 2);
_First = _Sort(_First, _Mid, _Pred, _Size / 2);
_Mid = _Sort(_Mid, _Last, _Pred, _Size - _Size / 2);
...
_

ご覧のとおり、彼らはさりげなく_std::next_を使用してリストの前半を歩き、__Mid_イテレータに到達します。

この切り替えの背後にある理由は何でしょうか?私が見ているのは、再帰の各レベルで_std::next_を繰り返し呼び出すことの明らかな非効率性だけです。素朴なロジックは、これが遅いであると言います。彼らがこの種の価格を喜んで支払うならば、彼らはおそらく見返りに何かを得ることを期待しています。彼らはそれから何を得ていますか?このアルゴリズムが(元のボトムアップアプローチと比較して)キャッシュの動作が優れているとすぐにはわかりません。事前にソートされたシーケンスでの動作がすぐに良くなるとは思いません。

確かに、C++ 11 _std::list<>_は基本的にその要素数を格納する必要があるため、要素数は常に事前にわかっているため、上記の効率が少し向上します。しかし、それでも、再帰の各レベルでの順次スキャンを正当化するには十分ではないようです。

(確かに、私は実装を互いに競争させようとはしていません。たぶん、そこにいくつかの驚きがあります。)

47
AnT

最初の更新-VS2015では、デフォルトでは構築できないステートフルアロケーターが導入されました。これは、以前のボトムアップアプローチで行われたようにローカルリストを使用するときに問題が発生します。ボトムアップアプローチでは、リスト(以下を参照)の代わりにノードポインターを使用することで、この問題を処理することができました。

2回目の更新-リストからイテレータへの切り替えは、アロケータと例外処理の問題を解決する1つの方法でしたが、ボトムアップを実装できるため、トップダウンからボトムアップに切り替える必要はありませんでした。イテレータを使用します。イテレータを使用してボトムアップのマージソートを作成しました。これは、VS2015のトップダウンアプローチで使用されているものと基本的に同じマージ/スプライスロジックです。それはこの答えの終わりにあります。

@sbiのコメントで、彼はトップダウンアプローチの作者であるStephan T.Lavavejに変更が行われた理由を尋ねました。 Stephanの応答は、「メモリ割り当てとデフォルトの構築アロケータを回避するため」でした。新しいトップダウンアプローチは、古いボトムアップアプローチよりも低速ですが、イテレータ(スタックに再帰的に格納される)のみを使用し、ローカルリストを使用せず、デフォルトでは構築できない、またはステートフルなアロケータに関連する問題を回避します。マージ操作では、イテレータを使用してsplice()を使用してリスト内のノードを「移動」します。これにより、例外安全性が提供されます(splice()が失敗しないことを前提としています)。 @ T.C。の答えはこれについて詳しく説明しています。 2回目の更新-ただし、ボトムアップアプローチは、イテレータと基本的に同じマージロジックに基づくこともできます(この回答の下部にあるサンプルコード)。マージロジックが決定されると、イテレータとスプライスベースのマージに基づくボトムアップアプローチが調査されなかった理由がわかりません。

パフォーマンスに関しては、十分なメモリがある場合は、通常、リストを配列またはベクトルに移動し、並べ替えてから、並べ替えられた配列またはベクトルをリストに戻す方が高速です。

@IgorTandetnikのデモに基づいて、問題を再現できます(古いソートはコンパイルに失敗し、新しいソートは機能します)。

#include <iostream>
#include <list>
#include <memory>

template <typename T>
class MyAlloc : public std::allocator<T> {
public:
    MyAlloc(T) {}  // suppress default constructor

    template <typename U>
    MyAlloc(const MyAlloc<U>& other) : std::allocator<T>(other) {}

    template< class U > struct rebind { typedef MyAlloc<U> other; };
};

int main()
{
    std::list<int, MyAlloc<int>> l(MyAlloc<int>(0));
    l.Push_back(3);
    l.Push_back(0);
    l.Push_back(2);
    l.Push_back(1);
    l.sort();
    return 0;
}

2016年7月にこの変更に気づき、2016年8月1日にこの変更についてP.J.Plaugerにメールを送信しました。彼の返信の抜粋:

興味深いことに、変更ログにはこの変更が反映されていません。それはおそらく、それが私たちのより大きな顧客の1人によって「提案」され、コードレビューで私によって得られたことを意味します。私が今知っているのは、変更が2015年の秋頃に行われたということだけです。コードを確認したとき、最初に私を驚かせたのは次の行でした。

    iterator _Mid = _STD next(_First, _Size / 2);

もちろん、これは大きなリストの場合、非常に長い時間がかかる可能性があります。

コードは1995年の初めに書いたものよりも少しエレガントに見えますが(!)、間違いなく時間の複雑さが悪化しています。そのバージョンは、元のSTLでのStepanov、Lee、およびMusserによるアプローチをモデルにしています。アルゴリズムの選択が間違っていることはめったに見つかりません。

私は今、元のコードの最新の既知の良いバージョンに戻っています。

P.J. Plaugerが元のコードに戻ったことが新しいアロケーターの問題に対処したのか、それともMicrosoftがDinkumwareと対話するのかどうかはわかりません。

トップダウン方式とボトムアップ方式を比較するために、400万個の要素を含むリンクリストを作成しました。各要素は、64ビットの符号なし整数1つで構成され、ほぼ順番に並べられたノードの二重リンクリストになると想定しています。動的に割り当てられます)、乱数で埋めてから並べ替えます。ノードは移動せず、リンケージのみが変更されますが、リストをトラバースすると、ランダムな順序でノードにアクセスします。次に、それらのランダムに順序付けられたノードに別の乱数のセットを入力し、それらを再度ソートしました。 2015年のトップダウンアプローチを、2015年に行われた他の変更と一致するように変更された以前のボトムアップアプローチと比較しました(sort()は、2つの別個の関数ではなく、述語比較関数を使用してsort()を呼び出すようになりました)。これらが結果です。 pdate-ノードポインタベースのバージョンを追加し、リストからベクトルを作成し、ベクトルを並べ替え、コピーバックする時間も記録しました。

sequential nodes: 2015 version 1.6 seconds, prior version 1.5  seconds
random nodes:     2015 version 4.0 seconds, prior version 2.8  seconds
random nodes:                  node pointer based version 2.6  seconds
random nodes:    create vector from list, sort, copy back 1.25 seconds

シーケンシャルノードの場合、以前のバージョンは少しだけ高速ですが、ランダムノードの場合、以前のバージョンは30%速く、ノードポインターバージョンは35%速く、リストからベクトルを作成し、ベクトルを並べ替えてからコピーします。 69%高速です。

以下は、std :: list :: sort()の最初の置換コードです。以前のボトムアップと小さな配列(_BinList [])メソッドを、VS2015のトップダウンアプローチと比較するために使用しました。比較を公平にしたかったので、 <リスト>のコピー。

    void sort()
        {   // order sequence, using operator<
        sort(less<>());
        }

    template<class _Pr2>
        void sort(_Pr2 _Pred)
        {   // order sequence, using _Pred
        if (2 > this->_Mysize())
            return;
        const size_t _MAXBINS = 25;
        _Myt _Templist, _Binlist[_MAXBINS];
        while (!empty())
            {
            // _Templist = next element
            _Templist._Splice_same(_Templist.begin(), *this, begin(),
                ++begin(), 1);
            // merge with array of ever larger bins
            size_t _Bin;
            for (_Bin = 0; _Bin < _MAXBINS && !_Binlist[_Bin].empty();
                ++_Bin)
                _Templist.merge(_Binlist[_Bin], _Pred);
            // don't go past end of array
            if (_Bin == _MAXBINS)
                _Bin--;
            // update bin with merged list, empty _Templist
            _Binlist[_Bin].swap(_Templist);
            }
            // merge bins back into caller's list
            for (size_t _Bin = 0; _Bin < _MAXBINS; _Bin++)
                if(!_Binlist[_Bin].empty())
                    this->merge(_Binlist[_Bin], _Pred);
        }

小さな変更を加えました。元のコードは_Maxbinという名前の変数の実際の最大ビンを追跡していましたが、最終的なマージのオーバーヘッドは十分に小さいため、_Maxbinに関連付けられたコードを削除しました。配列のビルド中に、元のコードの内部ループが_Binlist []要素にマージされ、続いて_Templistにスワップされましたが、これは無意味に思えました。内部ループを_Templistにマージするように変更し、空の_Binlist []要素が見つかった場合にのみスワップします。

以下は、さらに別の比較に使用したstd :: list :: sort()のノードポインターベースの置換です。これにより、割り当てに関連する問題が解消されます。比較例外が発生する可能性があり、発生した場合、配列および一時リスト(pNode)内のすべてのノードを元のリストに追加し直す必要があります。そうでない場合、比較例外は比較未満として扱われる可能性があります。

    void sort()
        {   // order sequence, using operator<
        sort(less<>());
        }

    template<class _Pr2>
        void sort(_Pr2 _Pred)
        {   // order sequence, using _Pred
        const size_t _NUMBINS = 25;
        _Nodeptr aList[_NUMBINS];           // array of lists
        _Nodeptr pNode;
        _Nodeptr pNext;
        _Nodeptr pPrev;
        if (this->size() < 2)               // return if nothing to do
            return;
        this->_Myhead()->_Prev->_Next = 0;  // set last node ->_Next = 0
        pNode = this->_Myhead()->_Next;     // set ptr to start of list
        size_t i;
        for (i = 0; i < _NUMBINS; i++)      // zero array
            aList[i] = 0;
        while (pNode != 0)                  // merge nodes into array
            {
            pNext = pNode->_Next;
            pNode->_Next = 0;
            for (i = 0; (i < _NUMBINS) && (aList[i] != 0); i++)
                {
                pNode = _MergeN(_Pred, aList[i], pNode);
                aList[i] = 0;
                }
            if (i == _NUMBINS)
                i--;
            aList[i] = pNode;
            pNode = pNext;
            }
        pNode = 0;                          // merge array into one list
        for (i = 0; i < _NUMBINS; i++)
            pNode = _MergeN(_Pred, aList[i], pNode);
        this->_Myhead()->_Next = pNode;     // update sentinel node links
        pPrev = this->_Myhead();            //  and _Prev pointers
        while (pNode)
            {
            pNode->_Prev = pPrev;
            pPrev = pNode;
            pNode = pNode->_Next;
            }
        pPrev->_Next = this->_Myhead();
        this->_Myhead()->_Prev = pPrev;
        }

    template<class _Pr2>
        _Nodeptr _MergeN(_Pr2 &_Pred, _Nodeptr pSrc1, _Nodeptr pSrc2)
        {
        _Nodeptr pDst = 0;          // destination head ptr
        _Nodeptr *ppDst = &pDst;    // ptr to head or prev->_Next
        if (pSrc1 == 0)
            return pSrc2;
        if (pSrc2 == 0)
            return pSrc1;
        while (1)
            {
            if (_DEBUG_LT_PRED(_Pred, pSrc2->_Myval, pSrc1->_Myval))
                {
                *ppDst = pSrc2;
                pSrc2 = *(ppDst = &pSrc2->_Next);
                if (pSrc2 == 0)
                    {
                    *ppDst = pSrc1;
                    break;
                    }
                }
            else
                {
                *ppDst = pSrc1;
                pSrc1 = *(ppDst = &pSrc1->_Next);
                if (pSrc1 == 0)
                    {
                    *ppDst = pSrc2;
                    break;
                    }
                }
            }
        return pDst;
        }

新しいVS2015std :: list :: sort()の代わりに、このスタンドアロンバージョンを使用できます。

template <typename T>
void listsort(std::list <T> &dll)
{
    const size_t NUMLISTS = 32;
    std::list <T> al[NUMLISTS]; // array of lists
    std::list <T> tl;           // temp list
    while (!dll.empty()){
        // t1 = next element from dll
        tl.splice(tl.begin(), dll, dll.begin(), std::next(dll.begin()));
        // merge element into array
        size_t i;
        for (i = 0; i < NUMLISTS && !al[i].empty(); i++){
            tl.merge(al[i], std::less<T>());
        }
        if(i == NUMLISTS)       // don't go past end of array
            i -= 1;
        al[i].swap(tl);         // update array list, empty tl
    }
    // merge array back into original list
    for(size_t i = 0; i < NUMLISTS; i++)
        dll.merge(al[i], std::less<T>());
}

または、同様のgccアルゴリズムを使用します。


更新#2:それ以来、イテレーターの小さな配列を使用してボトムアップのマージソートを作成しました。これは、VS2015 std :: list :: sortのスプライス関数を介した基本的に同じイテレーターベースのマージであり、アロケーターと例外の問題を排除するはずです。 VS2015のstd :: list :: sortによって対処されます。以下のサンプルコード。 Merge()でのsplice()の呼び出しは少し注意が必要です。イテレータのポストインクリメントがstd :: listに実装され、スプライスを補正する方法のため、最後のイテレータは実際のスプライスの呼び出しの前にポストインクリメントされます。配列操作の自然な順序により、マージ/スプライス操作によるイテレーターの破損が回避されます。配列内の各イテレータは、ソートされたサブリストの先頭を指します。ソートされた各サブリストの終わりは、配列内の次の前の空でないエントリのソートされたサブリストの開始、または配列の開始時の場合は変数の開始になります。

// iterator array size
#define ASZ 32

template <typename T>
void SortList(std::list<T> &ll)
{
    if (ll.size() < 2)                  // return if nothing to do
        return;
    std::list<T>::iterator ai[ASZ];     // array of iterators
    std::list<T>::iterator li;          // left   iterator
    std::list<T>::iterator ri;          // right  iterator
    std::list<T>::iterator ei;          // end    iterator
    size_t i;
    for (i = 0; i < ASZ; i++)           // "clear" array
        ai[i] = ll.end();
    // merge nodes into array
    for (ei = ll.begin(); ei != ll.end();) {
        ri = ei++;
        for (i = 0; (i < ASZ) && ai[i] != ll.end(); i++) {
            ri = Merge(ll, ai[i], ri, ei);
            ai[i] = ll.end();
        }
        if(i == ASZ)
            i--;
        ai[i] = ri;
    }
    // merge array into single list
    ei = ll.end();                              
    for(i = 0; (i < ASZ) && ai[i] == ei; i++);
    ri = ai[i++];
    while(1){
        for( ; (i < ASZ) && ai[i] == ei; i++);
        if (i == ASZ)
            break;
        li = ai[i++];
        ri = Merge(ll, li, ri, ei);
    }
}

template <typename T>
typename std::list<T>::iterator Merge(std::list<T> &ll,
                             typename std::list<T>::iterator li,
                             typename std::list<T>::iterator ri,
                             typename std::list<T>::iterator ei)
{
    std::list<T>::iterator ni;
    (*ri < *li) ? ni = ri : ni = li;
    while(1){
        if(*ri < *li){
            ll.splice(li, ll, ri++);
            if(ri == ei)
                return ni;
        } else {
            if(++li == ri)
                return ni;
        }
    }
}

VS2015のstd :: list :: sort()の置換コード(内部関数_Mergeを追加):

    template<class _Pr2>
        iterator _Merge(_Pr2& _Pred, iterator li, iterator ri, iterator ei)
        {
        iterator ni;
        _DEBUG_LT_PRED(_Pred, *ri, *li) ? ni = ri : ni = li;
        while(1)
            {
            if(_DEBUG_LT_PRED(_Pred, *ri, *li))
                {
                splice(li, *this, ri++);
                if(ri == ei)
                    return ni;
                }
            else
                {
                if(++li == ri)
                    return ni;
                }
            }
        }

    void sort()
        {   // order sequence, using operator<
        sort(less<>());
        }

    template<class _Pr2>
        void sort(_Pr2 _Pred)
        {
        if (size() < 2)                 // if size < 2 nothing to do
            return;
        const size_t _ASZ = 32;         // array size
        iterator ai[_ASZ];              // array of iterators
        iterator li;                    // left  iterator
        iterator ri;                    // right iterator
        iterator ei = end();            // end iterator
        size_t i;
        for(i = 0; i < _ASZ; i++)       // "clear array"
            ai[i] = ei;
        // merge nodes into array
        for(ei = begin(); ei != end();)
            {
            ri = ei++;
            for (i = 0; (i < _ASZ) && ai[i] != end(); i++)
                {
                ri = _Merge(_Pred, ai[i], ri, ei);
                ai[i] = end();
                }
            if(i == _ASZ)
                i--;
            ai[i] = ri;
            }
        // merge array into single list
        ei = end();                              
        for(i = 0; (i < _ASZ) && ai[i] == ei; i++);
        ri = ai[i++];
        while(1)
            {
            for( ; (i < _ASZ) && ai[i] == ei; i++);
            if (i == _ASZ)
                break;
            li = ai[i++];
            ri = _Merge(_Pred, li, ri, ei);
            }
        }

VS2019のstd :: list :: sort()の置換コード(内部関数_Mergeを追加し、VSテンプレートの命名規則を使用します):

private:
    template <class _Pr2>
    iterator _Merge(_Pr2 _Pred, iterator _First, iterator _Mid, iterator _Last){
        iterator _Newfirst = _First;
        for (bool _Initial_loop = true;;
            _Initial_loop       = false) { // [_First, _Mid) and [_Mid, _Last) are sorted and non-empty
            if (_DEBUG_LT_PRED(_Pred, *_Mid, *_First)) { // consume _Mid
                if (_Initial_loop) {
                    _Newfirst = _Mid; // update return value
                }
                splice(_First, *this, _Mid++);
                if (_Mid == _Last) {
                    return _Newfirst; // exhausted [_Mid, _Last); done
                }
            }
            else { // consume _First
                ++_First;
                if (_First == _Mid) {
                    return _Newfirst; // exhausted [_First, _Mid); done
                }
            }
        }
    }

    template <class _Pr2>
    void _Sort(iterator _First, iterator _Last, _Pr2 _Pred,
        size_type _Size) { // order [_First, _Last), using _Pred, return new first
                           // _Size must be distance from _First to _Last
        if (_Size < 2) {
            return;        // nothing to do
        }
        const size_t _ASZ = 32;         // array size
        iterator _Ai[_ASZ];             // array of iterators to runs
        iterator _Mi;                   // middle   iterator
        iterator _Li;                   // last     iterator
        size_t _I;                      // index to _Ai
        for (_I = 0; _I < _ASZ; _I++)   // "empty" array
            _Ai[_I] = _Last;            //   _Ai[] == _Last => empty entry
        // merge nodes into array
        for (_Li = _First; _Li != _Last;) {
            _Mi = _Li++;
            for (_I = 0; (_I < _ASZ) && _Ai[_I] != _Last; _I++) {
                _Mi = _Merge(_Pass_fn(_Pred), _Ai[_I], _Mi, _Li);
                _Ai[_I] = _Last;
            }
            if (_I == _ASZ)
                _I--;
            _Ai[_I] = _Mi;
        }
        // merge array runs into single run
        for (_I = 0; _I < _ASZ && _Ai[_I] == _Last; _I++);
        _Mi = _Ai[_I++];
        while (1) {
            for (; _I < _ASZ && _Ai[_I] == _Last; _I++);
            if (_I == _ASZ)
                break;
            _Mi = _Merge(_Pass_fn(_Pred), _Ai[_I++], _Mi, _Last);
        }
    }
21
rcgldr

@ sbiからの質問 MSVCの標準ライブラリメンテナであるStephanT。Lavavej、 回答者

これは、メモリ割り当てとデフォルトの構築アロケータを回避するために行いました。

これに「無料の基本例外安全性」を追加します。

詳細に説明すると、VS2015より前の実装にはいくつかの欠陥があります。

  • __Myt _Templist, _Binlist[_MAXBINS];_は、中間のlistsの束を作成します(__Myt_は、listの現在のインスタンス化の単なるtypedefです。これは、それほど混乱しないスペルです。つまり、list)は、並べ替え中にノードを保持しますが、これらのlistsデフォルトで構築されているため、多くの問題が発生します:
    1. 使用されるアロケーターがデフォルトで構築可能でない場合(およびアロケーターがデフォルトで構築可能である必要がない場合)、listのデフォルトコンストラクターがそのアロケーターをデフォルトで構築しようとするため、これは単にコンパイルされません。
    2. 使用されるアロケータがステートフルである場合、デフォルトで構築されたアロケータはthis->get_allocator()と等しくない可能性があります。つまり、後のsplicesとmergesは技術的に未定義の動作であり、デバッグビルドで破損する可能性があります。 (「技術的に」、ノードはすべて最終的にマージされるため、関数が正常に完了した場合でも、実際には間違ったアロケーターの割り当てを解除することはありません。)
    3. Dinkumwareのlistは、動的に割り当てられたセンチネルノードを使用します。つまり、上記は__MAXBINS + 1_の動的割り当てを実行します。多くの人がsortが_bad_alloc_をスローする可能性があることを期待しているとは思えません。アロケータがステートフルである場合、これらのセンチネルノードは適切な場所からも割り当てられない可能性があります(#2を参照)。
  • コードは例外安全ではありません。特に、比較はスローが許可されており、中間のlistsに要素があるときにスローされた場合、それらの要素はスタックの巻き戻し中にlistsで単純に破棄されます。もちろん、sortのユーザーは、sortが例外をスローした場合にリストがソートされることを期待していませんが、要素が欠落することもおそらく期待していません。
    • これは、上記の#2との相互作用が非常に不十分です。これは、技術的な未定義の動作だけではないためです。これらの中間のlistsのデストラクタは、間違ったアロケータで接続されたノードの割り当てを解除して破壊します。

それらの欠陥は修正可能ですか?多分。 #1と#2は、get_allocator()listsのコンストラクターに渡すことで修正できます。

_ _Myt _Templist(get_allocator());
 _Myt _Binlist[_MAXBINS] = { _Myt(get_allocator()), _Myt(get_allocator()), 
                             _Myt(get_allocator()),  /* ... repeat _MAXBINS times */ };
_

例外の安全性の問題は、例外がスローされた場合に順序に関係なく、中間のlists内のすべてのノードを_try-catch_にスプライスする_*this_でループを囲むことで修正できます。

#3の修正はより困難です。これは、ノードのホルダーとしてlistをまったく使用しないことを意味し、おそらくかなりの量のリファクタリングが必要ですが、実行可能です。

問題は、設計上パフォーマンスが低下したコンテナーのパフォーマンスを向上させるために、これらすべてのフープを飛び越える価値があるかどうかです。結局のところ、パフォーマンスを本当に気にする人は、そもそもlistを使用しないでしょう。

9
T.C.