`std:: list <>:: sort()` - почему внезапный переход к стратегии сверху вниз?

Я помню, что с самого начала наиболее популярным подходом к реализации std::list<>::sort() был классический алгоритм Merge Sort, реализованный в снизу вверх (см. также Что делает реализацию сортировки gcc std:: list так быстро?).

Я помню, как кто-то со всей определенностью ссылался на эту стратегию как на подход "привязки лука".

По крайней мере, так, как это происходит в GCC-реализации стандартной библиотеки С++ (см., например, здесь). И так было в старой версии Dimkumware STL в стандартной библиотеке MSVC, а также во всех версиях MSVC вплоть до VS2013.

Однако стандартная библиотека, поставляемая с VS2015, внезапно перестает следовать этой стратегии сортировки. Библиотека, поставляемая с VS2015, использует довольно простую рекурсивную реализацию слияния Merge Sort сверху вниз. Это кажется мне странным, так как подход сверху вниз требует доступа к середине списка, чтобы разделить его пополам. Так как std::list<> не поддерживает случайный доступ, единственный способ найти эту середину - буквально перебирать половину списка. Кроме того, в самом начале необходимо знать общее количество элементов в списке (что не обязательно было операцией O (1) до С++ 11).

Тем не менее, std::list<>::sort() в VS2015 делает именно это. Здесь выдержка из этой реализации, которая находит середину и выполняет рекурсивные вызовы

...
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 iterator.

Что может быть причиной этого переключения, интересно? Все, что я вижу, это кажущаяся очевидная неэффективность повторяющихся вызовов std::next на каждом уровне рекурсии. Наивная логика говорит, что это медленнее. Если они готовы заплатить такую ​​цену, они, вероятно, ожидают получить что-то взамен. Что они потом получают? Я не сразу вижу, что этот алгоритм имеет лучшее поведение кэша (по сравнению с исходным подходом снизу вверх). Я не сразу вижу, как он лучше себя ведет на предварительно отсортированных последовательностях.

Конечно, поскольку С++ 11 std::list<> в основном требуется хранить свой счетчик элементов, что делает выше немного более эффективным, так как мы всегда знаем количество элементов заранее. Но этого все еще недостаточно, чтобы оправдать последовательное сканирование на каждом уровне рекурсии.

(По общему признанию, я не пытался участвовать в гонках друг против друга. Возможно, там есть какие-то сюрпризы.)

Ответ 1

1-е обновление - VS2015 представила неконструктивные и сохраняющие состояние распределители, которые по умолчанию представляют проблему при использовании локальных списков, как это было с предыдущим восходящим подходом. Я смог решить эту проблему, используя указатели узлов вместо списков (см. Ниже) для подхода "снизу вверх".

2-е обновление - Хотя переключение со списков на итераторы было одним из способов решения проблемы с распределителями и обработкой исключений, не было необходимости переключаться сверху вниз, так как снизу вверх можно реализовать с помощью итераторов. Я создал сортировку слиянием снизу вверх с итераторами и, по сути, ту же логику слияния/объединения, которая использовалась в подходе VS2015 сверху вниз. Это в конце этого ответа.

В комментарии @sbi он спросил автора нисходящего подхода Стефана Т. Лававея, почему было сделано изменение. Ответ Стефана был "чтобы избежать выделения памяти и создания распределителей по умолчанию". Новый подход "сверху вниз" медленнее, чем старый подход "снизу вверх", но он использует только итераторы (рекурсивно хранящиеся в стеке), не использует локальные списки и избегает проблем, связанных с нестраиваемыми по умолчанию или сохраняющими состояние распределителями. Операция слияния использует splice() с итераторами для "перемещения" узлов в списке, что обеспечивает безопасность исключений (при условии, что splice() не может завершиться с ошибкой). @TC ответ подробно расскажет об этом. 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 года и по электронной почте PJ Plauger об этом изменении 1 августа 2016 года. Фрагмент его ответа:

Интересно, что наш журнал изменений не отражает это изменение. Это, вероятно, означает, что это было "предложено" одним из наших крупных клиентов и получено мной при проверке кода. Все, что я теперь знаю, это то, что изменения произошли осенью 2015 года. Когда я просмотрел код, первое, что меня поразило, была строка:

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

что, конечно, может занять очень много времени для большого списка.

Код выглядит немного более элегантно, чем то, что я написал в начале 1995 года (!), Но определенно имеет худшую временную сложность. Эта версия была смоделирована после подхода Степанова, Ли и Муссера в оригинальном STL. Они редко оказываются ошибочными в выборе алгоритмов.

Теперь я возвращаюсь к нашей последней известной хорошей версии исходного кода.

Я не знаю, имел ли место возвращение PJ Plauger к исходному коду с новой проблемой распределителя, или как Microsoft взаимодействует с Dinkumware.

Для сравнения методов сверху вниз и снизу вверх я создал связанный список с 4 миллионами элементов, каждый из которых состоит из одного 64-разрядного целого числа без знака, предполагая, что я получу дважды связанный список почти последовательно упорядоченных узлов (даже если они будет динамически размещаться), заполнить их случайными числами, а затем отсортировать их. Узлы не перемещаются, изменяется только связь, но теперь, пересекая список, получает доступ к узлам в случайном порядке. Затем я заполнил эти случайно упорядоченные узлы другим набором случайных чисел и снова отсортировал их. Я сравнил подход сверху вниз 2015 года с подходом предыдущего снизу вверх, модифицированным для соответствия другим изменениям, внесенным в 2015 году (sort() теперь вызывает sort() с функцией сравнения предикатов, а не с двумя отдельными функциями). Это результаты. обновление - я добавил версию, основанную на указателе узла, а также отметил время для простого создания вектора из списка, сортировки вектора, копирования обратно.

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 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;
        }

В качестве альтернативы новой VS2015 std :: 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: с тех пор я написал сортировку слиянием снизу вверх с использованием небольшого массива итераторов и, по сути, ту же функцию слияния, основанную на итераторах, через функцию сплайсинга из std :: list :: sort из VS2015, которая должна устранить проблемы с распределением и исключениями адресовано VS2015 std :: list :: sort. Пример кода ниже. Вызов splice() в Merge() немного сложен, последний итератор пост-инкрементно увеличивается до фактического вызова сплайсинга из-за того, как пост-инкремент итератора реализован в 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):

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);
        }
    }

Ответ 2

@sbi спросил Стефан Т. Лававей, сопровождающий стандартную библиотеку MSVC, который ответил:

Я сделал это, чтобы избежать выделения памяти и построения по умолчанию распределители.

К этому я добавлю "бесплатную базовую безопасность исключений".

Чтобы разработать: реализация перед VS2015 страдает от нескольких дефектов:

  • _Myt _Templist, _Binlist[_MAXBINS]; создает кучу промежуточного list (_Myt - это просто typedef для текущего экземпляра list; менее запутанная орфография для этого - хорошо, list), чтобы удерживать узлы во время сортировка, но эти list построены по умолчанию, что приводит к множеству проблем:
    • Если используемый распределитель не является конструктивным по умолчанию (и нет требования, что распределители по умолчанию конструктивны), это просто не будет компилироваться, потому что конструктор по умолчанию list попытается по умолчанию построить свой распределитель.
    • Если используемый распределитель является состоятельным, то построитель, построенный по умолчанию, может не сравниваться с this->get_allocator(), а это значит, что более поздние splice и merge являются технически undefined и могут сильно ломаться в отладочных сборках, ( "Технически", поскольку все узлы объединены в конце, поэтому вы фактически не освобождаете от неправильного распределителя, если функция успешно завершена.)
    • Dinkumware list использует динамически назначенный дозор node, что означает, что выше будет выполняться динамическое распределение _MAXBINS + 1. Я сомневаюсь, что многие люди ожидают sort потенциально бросить bad_alloc. Если распределитель имеет состояние, то эти узлы-узлы могут быть даже не выделены из нужного места (см. № 2).
  • Код не является безопасным для исключений. В частности, сопоставление разрешено бросать, и если оно выбрасывается, когда в промежуточном list s есть элементы, эти элементы просто уничтожаются с помощью list во время разматывания стека. Пользователи sort не ожидают, что список будет отсортирован, если sort выдает исключение, конечно, но они, вероятно, также не ожидают, что элементы будут отсутствовать.
    • Это очень плохо взаимодействует С# 2 выше, потому что теперь это не просто техническое поведение undefined: деструктор этих промежуточных list будет освобождать и уничтожать узлы, сращенные в них с неправильным распределителем.

Являются ли эти дефекты фиксируемыми? Вероятно. # 1 и # 2 можно зафиксировать, передав get_allocator() в конструктор list s:

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

Проблема безопасности исключений может быть устранена путем окружения цикла с помощью try-catch, который связывает все узлы промежуточного list обратно в *this без учета порядка, если выбрано исключение.

Фиксация # 3 сложнее, потому что это означает, что не использовать list вообще как держатель узлов, что, вероятно, требует приличного количества рефакторинга, но это выполнимо.

Вопрос: стоит ли перепрыгивать через все эти обручи, чтобы улучшить производительность контейнера, который снизил производительность по дизайну? В конце концов, кто-то, кто действительно заботится о производительности, вероятно, не будет использовать list в первую очередь.