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

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

Я помню, как кто-то метко назвал эту стратегию подходом «луковичной цепочки».

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

Однако стандартная библиотека, поставляемая с VS2015, внезапно перестала следовать этой стратегии сортировки. Библиотека, поставляемая с VS2015, использует довольно простую рекурсивную реализацию нисходящей сортировки слиянием. Это кажется мне странным, поскольку нисходящий подход требует доступа к средней точке списка, чтобы разделить его пополам. Поскольку std::list<> не поддерживает произвольный доступ, единственный способ найти эту среднюю точку - это буквально перебирать половину списка. Кроме того, в самом начале необходимо знать общее количество элементов в списке (что не обязательно было операцией O (1) до C ++ 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 итератора.

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

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

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


person AnT    schedule 16.11.2016    source источник
comment
который не обязательно был операцией O (1) до C ++ 11, не имеет значения. Они пишут это для своей собственной реализации, которая имеет O (1) size().   -  person T.C.    schedule 16.11.2016
comment
@ T.C .: Да, но O (1) size() здесь не имеет большого значения. Это полезно только один раз - на самом верхнем уровне рекурсии. Одного наличия O (1) size() недостаточно, чтобы оправдать этот алгоритм. Основная проблема, с которой я сталкиваюсь, - это O (n) std::next на каждом уровне рекурсии, и это на самом деле не связано с O (1) size() на самом верху.   -  person AnT    schedule 16.11.2016
comment
Вы совершенно правы: такая реализация не может быть оптимальной. Это только доказывает, что стандартные реализации нельзя считать всегда оптимальными. Кажется, что в стандартных реализациях C ++ есть немало беспорядка.   -  person cmaster - reinstate monica    schedule 16.11.2016
comment
@cmaster: Ваше утверждение просто неверно. Обратите внимание, что теоретическая цена нахождения средней точки составляет O (N), и мы делаем это на глубине O (log N), поэтому общая стоимость составляет O (N log N). Сортировка в любом случае была и есть O (N log N), поэтому теоретическая граница не изменится. А для практической производительности нужно учитывать реальное оборудование.   -  person MSalters    schedule 16.11.2016
comment
@mSalters Сложность не изменилась, и я никогда не говорил об этом. Однако при двойном сканировании до середины списка алгоритм теряет постоянный коэффициент времени, что делает алгоритм неоптимальным. Если бы мы использовали только сложность, нам пришлось бы использовать прямую сортировку по основанию системы счисления все время, потому что это O(n), что является лучшей сложностью, чем O(log(n)), что быстрая сортировка и т. Д. достигать. Тем не менее, прямая радиальная сортировка имеет настолько высокое постоянное слагаемое, что во всех соответствующих случаях она медленнее, чем быстрая сортировка, что делает бесполезную прямую радиальную сортировку. Никогда не забывайте о константах!   -  person cmaster - reinstate monica    schedule 16.11.2016
comment
list::sort libc ++ также использует нисходящий подход.   -  person T.C.    schedule 16.11.2016
comment
@cmaster: Radix-сортировка не может работать для std::list<T>::sort, потому что для этого требуется только std::less<T>. Что касается постоянного фактора, это делает предположения о поведении кеша. Похоже, что обход списка - это самый быстрый способ поместить первую половину списка в кеш.   -  person MSalters    schedule 16.11.2016
comment
@MSalters - обход списка не помогает, если узлы большого списка случайным образом разбросаны по памяти, и в этом случае вы получаете один узел на каждую строку кеша (см. Мой ответ для сравнения снизу вверх и сверху вниз).   -  person rcgldr    schedule 16.11.2016
comment
@mSalters Пожалуйста, прочтите ответ rcgldr, а затем еще раз оцените свое утверждение, что мое утверждение просто неверно. У меня не было доказательств того, что rcgldr обнаружил (он определенно получил за это мой +1), но я могу читать код и оценивать его влияние на производительность (что я и сделал и на чем основывалось мое утверждение). Так что, пожалуйста, в будущем будьте немного медленнее, называя утверждения других людей ложными.   -  person cmaster - reinstate monica    schedule 16.11.2016
comment
Сразу из уст: Я сделал это, чтобы избежать выделения памяти и создания распределителей по умолчанию. - Стефан Т. Лававей   -  person sbi    schedule 17.11.2016
comment
@sbi - Спасибо. Я обновил свой ответ. Я не знал, к кому обратиться, кроме П. Дж. Плауджера, так как это единственное имя, указанное в авторских правах на файлы STL (по крайней мере, на все из них, которые я читал).   -  person rcgldr    schedule 18.11.2016
comment
@AnT - Причина заключалась в том, чтобы избежать выделения памяти и построения распределителей по умолчанию, что было достигнуто с помощью итераторов, а T.C добавил к этой бесплатной базовой безопасности исключений. что было выполнено, поскольку слияние выполняется с помощью соединений, которые теперь работают только со списком вызывающего абонента. Однако не было необходимости переключаться на стратегию сверху вниз, поскольку оказалось, что стратегия снизу вверх также работает с итераторами (небольшим их массивом) и тем же слиянием через сплайсинг в списке вызывающих. Я обновил свой ответ, включив в него пример кода (сейчас он находится в верхней части моего ответа).   -  person rcgldr    schedule 05.03.2020
comment
@sbi - переход на использование итераторов вместо массива списков позволил избежать проблем с распределением, а также обеспечил безопасность исключений, но переключение сверху вниз не требовалось, так как подход снизу вверх также может быть реализован с использованием итераторов и той же логики слияния. . Я обновил свой ответ объяснением и примером кода, чтобы показать, как это можно сделать. Это запоздалый комментарий, так как я думал, что сделал этот комментарий давным-давно.   -  person rcgldr    schedule 05.03.2020
comment
@rcgldr: Вы можете сказать это STL, а не мне. Я был всего лишь посыльным.   -  person sbi    schedule 11.07.2020
comment
@sbi - В то время я переписывался с PJ Plauger из Dinkumware, компании, которая работала / работала с Microsoft над шаблонами, основанными на коде шаблона 1994 года или предшествующем коде шаблона, написанном HP (см. авторские права в конце исходные файлы шаблона). Я знаю, что Dinkumware не применяла подход сверху вниз, используемый в Visual Studio, но я не знаю, что они в итоге сделали.   -  person rcgldr    schedule 12.07.2020
comment
@sbi - в последний раз, когда я проверял, Visual Studio 2019 все еще использовала сверху вниз. Один из членов команды (я не знаю, кто) заявил, что производительность не была целью для шаблонов (что оспаривается П. Дж. Плаугером и предыдущей командой HP), и что для производительности было бы быстрее скопировать list в массив, отсортируйте массив, затем создайте отсортированный список из массива (похоже, оправдание плохого выбора сверху вниз). На мой взгляд, люди, участвовавшие в переходе на итераторы, не понимали, что предыдущий восходящий подход можно адаптировать для работы с итераторами.   -  person rcgldr    schedule 12.07.2020
comment
@rcgldr: Вы можете сказать это STL, а не мне. Я был всего лишь посыльным.   -  person sbi    schedule 09.08.2020
comment
@sbi - Кажется, я отправил письмо кому-то в STL, но не помню, кому. Как отмечалось выше, PJ Plauger изначально вернулся к предыдущей восходящей версии, и они собирались предложить альтернативное исправление. Я не знаю, что там произошло. Я так и не получил ответа от STL. У меня сложилось впечатление, что это низкий приоритет для Microsoft, и у П. Дж. Плоджера есть альтернативное решение.   -  person rcgldr    schedule 09.08.2020
comment
@rcgldr: STL относится к Стефану Т. Лававей, парню, который руководит отделом разработки std lib в Microsoft, и которого я цитировал. Я не думаю, что вы можете отправить электронное письмо кому-нибудь в Стефане.   -  person sbi    schedule 20.08.2020
comment
@sbi - Я отправил ему твит в его твиттер-аккаунте 24 апреля 2019 года. Я так и не получил ответа.   -  person rcgldr    schedule 20.08.2020


Ответы (2)


Обратите внимание, что этот ответ был обновлен для решения всех проблем, упомянутых в комментариях ниже и после вопроса, путем внесения того же изменения из массива списков в массив итераторов, сохраняя при этом более быстрый алгоритм сортировки слиянием снизу вверх и исключая небольшая вероятность переполнения стека из-за рекурсии с алгоритмом сортировки слиянием сверху вниз.

Причина, по которой я изначально не рассматривал итераторы, была связана с изменением VS2015 на нисходящий, что привело меня к мысли, что есть некоторая проблема с попыткой изменить существующий восходящий алгоритм для использования итераторов, требующих перехода на более медленный нисходящий алгоритм. Только когда я сам попытался проанализировать переход на итераторы, я понял, что есть решение для алгоритма снизу вверх.

В комментарии @ sbi он спросил автора подхода «сверху вниз» Стефана Т. Лававея, почему было внесено изменение. В ответ Стефан отказался от выделения памяти и построения распределителей по умолчанию. VS2015 представил не конструируемые по умолчанию и отслеживающие состояние распределители, что представляет проблему при использовании массива списков предыдущей версии, поскольку каждый экземпляр списка выделяет фиктивный узел, и потребуется изменение, чтобы не обрабатывать распределитель по умолчанию.

Решение Lavavej заключалось в том, чтобы переключиться на использование итераторов для отслеживания границ выполнения в исходном списке вместо внутреннего массива списков. Логика слияния была изменена, чтобы использовать 3 параметра итератора, 1-й параметр - итератор до начала левого прогона, 2-й параметр - итератор до конца левого прогона == итератор до начала правого прогона, 3-й параметр - итератор до конца правого прогона. Процесс слияния использует std :: list :: splice для перемещения узлов в исходном списке во время операций слияния. Это имеет дополнительное преимущество - безопасность исключений. Если функция сравнения вызывающего абонента вызывает исключение, список будет переупорядочен, но потери данных не произойдет (при условии, что сварка не может завершиться ошибкой). В предыдущей схеме некоторые (или большая часть) данных были бы во внутреннем массиве списков, если бы произошло исключение, и данные были бы потеряны из исходного списка.

Однако переключение на сортировку слиянием сверху вниз не потребовалось. Первоначально, думая, что существует какая-то неизвестная мне причина для переключения VS2015 сверху вниз, я сосредоточился на использовании внутренних интерфейсов так же, как std :: list :: splice. Позже я решил исследовать переключение снизу вверх, чтобы использовать массив итераторов. Я понял, что порядок запусков, хранящихся во внутреннем массиве, был от самого нового (array [0] = крайний правый) до самого старого (array [last] = крайний левый), и что он мог использовать ту же логику слияния на основе итератора, что и подход VS2015 сверху вниз.

Для сортировки слиянием снизу вверх array [i] является итератором для начала отсортированного подсписка с 2 узлами ^ i, или он пуст (используется std :: list :: end для обозначения пустоты). Конец каждого отсортированного подсписка будет началом отсортированного подсписка в следующей предыдущей непустой записи в массиве или, если в начале массива, в локальном итераторе (он указывает на конец самого нового запустить). Подобно подходу сверху вниз, массив итераторов используется только для отслеживания отсортированных границ выполнения в исходном связанном списке, в то время как процесс слияния использует std :: list :: splice для перемещения узлов в исходном связанном списке.

Если связанный список большой, а узлы разбросаны, будет много промахов кеша. Снизу вверх будет примерно на 30% быстрее, чем сверху вниз (эквивалентно заявлению, что сверху вниз примерно на 42% медленнее, чем снизу вверх). Опять же, если памяти достаточно, обычно быстрее переместить список в массив или вектор, отсортировать массив или вектор, а затем создать новый список из отсортированного массива или вектора.

Пример кода C ++:

#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 mi;          // middle iterator (end lft, bgn rgt)
    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();) {
        mi = ei++;
        for (i = 0; (i < ASZ) && ai[i] != ll.end(); i++) {
            mi = Merge(ll, ai[i], mi, ei);
            ai[i] = ll.end();
        }
        if(i == ASZ)
            i--;
        ai[i] = mi;
    }
    // merge array into single list
    ei = ll.end();                              
    for(i = 0; (i < ASZ) && ai[i] == ei; i++);
    mi = ai[i++];
    while(1){
        for( ; (i < ASZ) && ai[i] == ei; i++);
        if (i == ASZ)
            break;
        mi = Merge(ll, ai[i++], mi, 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 mi,
                             typename std::list<T>::iterator ei)
{
    std::list<T>::iterator ni;
    (*mi < *li) ? ni = mi : ni = li;
    while(1){
        if(*mi < *li){
            ll.splice(li, ll, mi++);
            if(mi == ei)
                return ni;
        } else {
            if(++li == mi)
                return ni;
        }
    }
}

Пример кода замены для std :: list :: sort () VS2019 (логика слияния была выделена в отдельную внутреннюю функцию, поскольку теперь она используется в двух местах).

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

Остальная часть этого ответа историческая.


Мне удалось воспроизвести проблему (старая сортировка не компилируется, новая работает) на основе демонстрации от @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 года и 1 августа 2016 года написал П.Дж. Плаугеру об этом изменении. Фрагмент его ответа:

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

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

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

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

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

Я не знаю, решило ли возвращение П.Дж.Плагера к исходному коду проблему с новым распределителем, или как Microsoft взаимодействует с Dinkumware.

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

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;
        }
person rcgldr    schedule 16.11.2016
comment
Эта реализация страдает той же проблемой, что и GCC: она неправильно обрабатывает не конструируемые по умолчанию распределители или распределители с отслеживанием состояния. В случае с Dinkumware это также вызывает динамическое распределение, потому что их list имеет динамически выделяемый контрольный узел. Конечно, проблема не решаемая. - person T.C.; 16.11.2016
comment
Сторожевой узел Dinkumware размещается в куче (или распределителем), а не встроен в сам объект списка. - person T.C.; 16.11.2016
comment
_Templist и _Binlist создаются по умолчанию. Они не обязательно могут быть сконструированы по умолчанию (потому что их распределитель не обязательно). - person T.C.; 16.11.2016
comment
Нет. Почему бы тебе не написать тривиальный AllocatorWithoutADefaultConstructor<T> и не попробовать? Вы очень скоро поймете, что я имею в виду. - person T.C.; 16.11.2016
comment
Вы, конечно же, передаете копию распределителя конструктору, принимая аргумент распределителя. Вы просто не можете создать такой список по умолчанию. - person T.C.; 17.11.2016
comment
иначе возникает ошибка времени компиляции. В этом вся суть ... Старая реализация просто не компилируется, если у распределителя нет конструктора по умолчанию. - person T.C.; 18.11.2016
comment
Вы всегда можете передать this->get_allocator(), но это решает только 1/ (3*(_MAXBINS + 1)) проблемы. Смотрите мой ответ. - person T.C.; 18.11.2016
comment
Функция компаратора определяет строгий слабый порядок как часть контракта. Компаратор функции не выкидывает нет. - person T.C.; 15.05.2018
comment
Как это сравнить с созданием вектора указателей узлов, сортировкой, а затем повторным связыванием списка в новом порядке? - person Deduplicator; 24.04.2019
comment
@Deduplicator - Предполагая большой список со случайно разбросанными узлами (так что кеш попадает на каждый узел), было бы намного быстрее переместить данные в вектор, отсортировать вектор, а затем создать новый список из-за проблем с локализацией кеша) . В целях защиты от исключений исходный список необходимо сохранить до завершения сортировки, что потребует пространства данных O (n). - person rcgldr; 24.04.2019
comment
Что ж, это будет зависеть от относительно дешевой замены предметов. И это добавляет к контейнерам требования к типу элемента: en.cppreference.com/w / cpp / container / list - person Deduplicator; 24.04.2019
comment
@Deduplicator - возможно, я не понимаю ваш последний комментарий. Сортировка слиянием в массиве или векторе данных не выполняет подкачки. Два входных прогона считываются последовательно, а записи сливаются последовательно, что происходит довольно быстро с 3 доступными строками кэша. Сортировка массива или вектора указателей может привести к попаданию в кеш при каждом доступе, если объем данных намного превышает размер кеша. - person rcgldr; 24.04.2019
comment
Что ж, требований к std::list::element_type очень мало. Сортировка указателей и повторное связывание узлов добавляет наименьшее количество дополнительных (просто с согласованным порядком). Для перемещения в смежное пространство, сортировки и возврата требуется гораздо больше. - person Deduplicator; 24.04.2019
comment
@Deduplicator - меня не волновали требования к пространству, а только время. Если проблема связана с пространством, то для использования векторных указателей требуется пространство указателя O (n), и это не решает проблему большого и разрозненного списка, в котором при каждом доступе к узлу в списке происходит попадание в кеш. - person rcgldr; 24.04.2019
comment
Нет, я имел в виду требования к поведению типов, не считая размера. Да, отсутствие возможности копирования и / или перемещения означает, что нельзя уменьшить проблему плохой локальности кеша, но вряд ли можно получить все. - person Deduplicator; 24.04.2019
comment
Позвольте нам продолжить это обсуждение в чате. - person rcgldr; 24.04.2019

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

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

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

Для уточнения: реализация до VS2015 страдает несколькими дефектами:

  • _Myt _Templist, _Binlist[_MAXBINS]; creates a bunch of intermediate lists (_Myt is simply a typedef for the current instantiation of list; a less confusing spelling for that is, well, list) to hold the nodes during sorting, but these lists are default constructed, which leads to a multitude of problems:
    1. If the allocator used is not default constructible (and there is no requirement that allocators be default constructible), this simply won't compile, because the default constructor of list will attempt to default construct its allocator.
    2. Если используемый распределитель отслеживает состояние, то созданный по умолчанию распределитель может не сравниваться с this->get_allocator(), что означает, что более поздние splice и merge являются технически неопределенным поведением и вполне могут сломаться в отладочных сборках. («Технически», потому что в конце все узлы объединяются, поэтому вы фактически не освобождаете память с неправильным распределителем, если функция успешно завершается.)
    3. list Dinkumware использует динамически выделяемый контрольный узел, что означает, что вышеприведенное будет выполнять _MAXBINS + 1 динамическое распределение. Я сомневаюсь, что многие люди ожидают, что sort потенциально бросит bad_alloc. Если распределитель отслеживает состояние, то эти контрольные узлы могут даже не выделяться из нужного места (см. №2).
  • The code is not exception safe. In particular, the comparison is allowed to throw, and if it throws while there are elements in the intermediate lists, those elements are simply destroyed with the lists during stack unwinding. Users of sort don't expect the list to be sorted if sort throws an exception, of course, but they probably also don't expect the elements to go missing.
    • This interacts very poorly with #2 above, because now it's not just technical undefined behavior: the destructor of those intermediate lists will be deallocating and destroying the nodes spliced into them with the wrong allocator.

Можно ли исправить эти дефекты? Наверное. # 1 и # 2 можно исправить, передав get_allocator() конструктору lists:

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

person T.C.    schedule 18.11.2016
comment
@rcgldr Ни с сохранением состояния, ни с нестандартными распределителями по умолчанию не было стандартных вещей до C ++ 11. C ++ 03 требовал, чтобы распределители были конструктивными по умолчанию, а реализациям разрешалось предполагать, что они не имеют состояния. Я не понимаю вашего вопроса о дозорных узлах. Большинство list операций не требуют создания временных list. - person T.C.; 18.11.2016
comment
Если они используют созданный по умолчанию распределитель без сохранения состояния в стиле C ++ 03, ничего не меняется. Если они используют конфигурацию с отслеживанием состояния или конструкцию без возможности построения по умолчанию, они должны знать, что они делают. - person T.C.; 18.11.2016
comment
@rcgldr Теперь попробуйте компаратор, который запускает 42-й вызов. - person T.C.; 19.11.2016
comment
@rcgldr В своем ответе я обсуждал вопросы безопасности исключений, не так ли? - person T.C.; 19.11.2016
comment
Да, никто не сказал, что это невыполнимо. Вопрос в том, стоит ли это усилий. - person T.C.; 21.11.2016
comment
@rcgldr В спецификации для list::sort есть положение, предусматривающее, что происходит в случае исключений, начиная с C ++ 98. Так что эта часть STL вызвала серьезную озабоченность. - person T.C.; 20.10.2017
comment
Я обновил свой ответ, чтобы показать автономную версию сортировки слиянием снизу вверх, которая использует небольшой массив итераторов, что должно устранить проблемы с распределителем и исключениями. - person rcgldr; 23.04.2019
comment
Какая часть из тех, кто сказал, что это невыполнимо, трудно понять? - person T.C.; 23.04.2019
comment
Поскольку мой ответ был обновлен, чтобы использовать то же изменение от использования списков к использованию итераторов, я удалил свои теперь устаревшие комментарии. Стоило потраченных усилий - как только я подумал об использовании итераторов, переключение предыдущей сортировки слиянием снизу вверх с массива списков на массив итераторов заняло всего несколько часов, чтобы проанализировать проблемы и заставить его работать. Я подозреваю, что переход на итераторы и сверху вниз занял столько же или больше времени, если не было существующего примера, на котором основывались изменения VS2015. Я не думаю, что это было проблемой усилий, но вместо этого я признаю, что можно изменить принцип снизу вверх, чтобы использовать итераторы. - person rcgldr; 04.04.2020
comment
Продолжая, причина, по которой я изначально не рассматривал итераторы, была связана с изменением VS2015 на нисходящий принцип, что заставило меня поверить в то, что есть некоторая проблема с попыткой изменить существующий алгоритм снизу вверх для использования итераторов. И только когда я сам попытался проанализировать переход на итераторы, я понял, что есть решение. - person rcgldr; 04.04.2020