Какой самый быстрый алгоритм сортировки связанного списка? - PullRequest
85 голосов
/ 06 октября 2009

Мне любопытно, если O (n log n) - лучшее, что может сделать связанный список.

Ответы [ 12 ]

87 голосов
/ 06 октября 2009

Разумно ожидать, что вы не можете добиться большего, чем O (N log N) в время работы .

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

Саймон Тэтхэм, известный в Putty, объясняет, как сортировать связанный список с помощью сортировки слиянием . Он завершает со следующими комментариями:

Как и любой уважающий себя алгоритм сортировки, он имеет время работы O (N log N). Поскольку это Mergesort, время выполнения в худшем случае по-прежнему равно O (N log N); патологических случаев нет.

Требование к вспомогательному хранилищу мало и постоянно (то есть несколько переменных в процедуре сортировки). Благодаря принципиально отличному поведению связанных списков от массивов, эта реализация Mergesort позволяет избежать затрат на вспомогательное хранение O (N), обычно связанных с алгоритмом.

Существует также пример реализации в C, который работает как для односвязных, так и для двусвязных списков.

Как @ Jørgen Fogh упоминает ниже, запись big-O может скрывать некоторые постоянные факторы, которые могут заставить один алгоритм работать лучше из-за локальности памяти, из-за малого количества элементов и т. Д.

67 голосов
/ 06 октября 2009

В зависимости от ряда факторов, на самом деле может быть быстрее скопировать список в массив и затем использовать Быстрая сортировка .

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

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

Поскольку оба алгоритма работают в O (n * log n), принятие взвешенного решения потребует профилирования их обоих на той машине, на которой вы хотели бы их запустить.

--- РЕДАКТИРОВАТЬ

Я решил проверить свою гипотезу и написал C-программу, которая измеряла время (используя clock()), необходимое для сортировки связного списка целых чисел. Я попытался с помощью связанного списка, где каждый узел был выделен с malloc(), и связанного списка, где узлы были расположены линейно в массиве, поэтому производительность кэша была бы лучше. Я сравнил их со встроенным qsort, который включал копирование всего из фрагментированного списка в массив и повторное копирование результата. Каждый алгоритм был запущен на одних и тех же 10 наборах данных, и результаты были усреднены.

Вот результаты:

N = 1000:

Фрагментированный список с сортировкой слиянием: 0,000000 секунд

Массив с qsort: 0,000000 секунд

Упакованный список с сортировкой слиянием: 0,000000 секунд

N = 100000:

Фрагментированный список с сортировкой слиянием: 0,039000 секунд

Массив с qsort: 0,025000 секунд

Упакованный список с сортировкой слиянием: 0,009000 секунд

N = 1000000:

Фрагментированный список с сортировкой слиянием: 1,162000 секунд

Массив с qsort: 0,420000 секунд

Упакованный список с сортировкой слиянием: 0,112000 секунд

N = 100000000:

Фрагментированный список с сортировкой слиянием: 364,797000 секунд

Массив с qsort: 61,166000 секунд

Упакованный список с сортировкой слиянием: 16,525000 секунд

Вывод:

По крайней мере на моем компьютере копирование в массив стоит того, чтобы повысить производительность кэша, поскольку в реальной жизни у вас редко бывает полностью упакованный связанный список. Следует отметить, что на моей машине установлен процессор Phenom II с частотой 2,8 ГГц, но оперативная память только 0,6 ГГц, поэтому кэш-память очень важна.

7 голосов
/ 06 октября 2009

Сравнительные сортировки (то есть те, которые основаны на сравнении элементов) не могут быть быстрее, чем n log n. Неважно, что лежит в основе структуры данных. См. Википедия .

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

5 голосов
/ 29 декабря 2010

Это хорошая маленькая статья на эту тему. Его эмпирический вывод заключается в том, что лучше всего использовать Treesort, а затем Quicksort и Mergesort. Сортировка отложений, пузырьковая сортировка, сортировка по выбору работают очень плохо.

СРАВНИТЕЛЬНОЕ ИССЛЕДОВАНИЕ АЛГОРИТМОВ СОРТИРОВКИ СВЯЗАННЫХ СПИСКОВ Чинг-Куанг Шене

http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981

5 голосов
/ 06 октября 2009

Как указывалось много раз, нижняя граница для сортировки на основе сравнения для общих данных будет O (n log n). Чтобы кратко резюмировать эти аргументы, есть n! Различные способы сортировки списка. Любое дерево сравнения, которое имеет n! (который находится в O (n ^ n)) возможные окончательные сортировки будут требовать, по крайней мере, log (n!) в качестве своей высоты: это дает вам нижнюю границу O (log (n ^ n)), которая равна O (n войти n).

Таким образом, для общих данных в связанном списке наилучшая возможная сортировка, которая будет работать с любыми данными, которые могут сравнивать два объекта, будет O (n log n). Однако, если у вас есть более ограниченная область работы, вы можете сократить время, которое требуется (по крайней мере, пропорционально n). Например, если вы работаете с целыми числами, не превышающими какое-либо значение, вы можете использовать Подсчет сортировки или Сортировка по радикалу , так как они используют конкретные объекты, которые вы сортируете, чтобы уменьшить сложность пропорционально п. Будьте осторожны, однако, они добавляют некоторые другие вещи к сложности, которые вы можете не учитывать (например, Counting Sort и Radix sort оба добавляют факторы, которые основаны на размере сортируемых чисел, O (n + k) ) где k - это размер наибольшего числа, например, для сортировки при подсчете).

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

3 голосов
/ 06 октября 2009

A Radix sort особенно подходит для связанного списка, поскольку легко составить таблицу указателей заголовков, соответствующую каждому возможному значению цифры.

2 голосов
/ 06 октября 2009

Сортировка слиянием не требует доступа O (1) и имеет значение O (n ln n). Нет известных алгоритмов для сортировки общих данных лучше, чем O (n ln n).

Специальные алгоритмы данных, такие как радикальная сортировка (ограничивает размер данных) или гистограмма (подсчитывает дискретные данные), могут сортировать связанный список с более низкой функцией роста, если вы используете другую структуру с доступом O (1). как временное хранилище.

Другой класс специальных данных - это сравнительный вид почти отсортированного списка с k элементами по порядку. Это можно отсортировать в операциях O (kn).

Копирование списка в массив и обратно будет O (N), поэтому любой алгоритм сортировки может быть использован, если пробел не является проблемой.

Например, при наличии связанного списка, содержащего uint_8, этот код отсортирует его за O (N) время, используя сортировку гистограммы:

#include <stdio.h>
#include <stdint.h>
#include <malloc.h>

typedef struct _list list_t;
struct _list {
    uint8_t value;
    list_t  *next;
};


list_t* sort_list ( list_t* list )
{
    list_t* heads[257] = {0};
    list_t* tails[257] = {0};

    // O(N) loop
    for ( list_t* it = list; it != 0; it = it -> next ) {
        list_t* next = it -> next;

        if ( heads[ it -> value ] == 0 ) {
            heads[ it -> value ] = it;
        } else {
            tails[ it -> value ] -> next = it;
        }

        tails[ it -> value ] = it;
    }

    list_t* result = 0;

    // constant time loop
    for ( size_t i = 255; i-- > 0; ) {
        if ( tails[i] ) {
            tails[i] -> next = result;
            result = heads[i];
        }
    }

    return result;
}

list_t* make_list ( char* string )
{
    list_t head;

    for ( list_t* it = &head; *string; it = it -> next, ++string ) {
        it -> next = malloc ( sizeof ( list_t ) );
        it -> next -> value = ( uint8_t ) * string;
        it -> next -> next = 0;
    }

    return head.next;
}

void free_list ( list_t* list )
{
    for ( list_t* it = list; it != 0; ) {
        list_t* next = it -> next;
        free ( it );
        it = next;
    }
}

void print_list ( list_t* list )
{
    printf ( "[ " );

    if ( list ) {
        printf ( "%c", list -> value );

        for ( list_t* it = list -> next; it != 0; it = it -> next )
            printf ( ", %c", it -> value );
    }

    printf ( " ]\n" );
}


int main ( int nargs, char** args )
{
    list_t* list = make_list ( nargs > 1 ? args[1] : "wibble" );


    print_list ( list );

    list_t* sorted = sort_list ( list );


    print_list ( sorted );

    free_list ( list );
}
1 голос
/ 15 февраля 2019

Вы можете скопировать его в массив и затем отсортировать.

  • Копирование в массив O (n),

  • сортировка O (nlgn) (если вы используете быстрый алгоритм типа сортировки слиянием),

  • копирование обратно в связанный список O (n) при необходимости,

так что это будет O (nlgn).

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

1 голос
/ 08 декабря 2015

Вот реализация , которая обходит список только один раз, собирает прогоны, а затем планирует слияния таким же образом, как это делает слияние.

Сложность - O (n log m), где n - количество элементов, а m - количество прогонов. Наилучший случай - O (n) (если данные уже отсортированы), а наихудший - O (n log n), как и ожидалось.

Требуется O (log m) временной памяти; сортировка производится в списках по месту.

(обновлено ниже. Первый комментатор отмечает, что я должен описать это здесь)

Суть алгоритма:

    while list not empty
        accumulate a run from the start of the list
        merge the run with a stack of merges that simulate mergesort's recursion
    merge all remaining items on the stack

Накопление прогонов не требует особых объяснений, но полезно воспользоваться возможностью накопить как восходящие, так и нисходящие прогоны (в обратном порядке). Здесь он добавляет элементы, меньшие, чем начало цикла, и добавляет элементы, которые больше или равны концу цикла. (Обратите внимание, что для предварительной сортировки следует использовать строго меньше, чем для сохранения стабильности сортировки.)

Проще всего вставить код слияния здесь:

    int i = 0;
    for ( ; i < stack.size(); ++i) {
        if (!stack[i])
            break;
        run = merge(run, stack[i], comp);
        stack[i] = nullptr;
    }
    if (i < stack.size()) {
        stack[i] = run;
    } else {
        stack.push_back(run);
    }

Рассмотрите возможность сортировки списка (по умолчанию) (игнорируя прогоны). Состояния стека выполняются следующим образом:

    [ ]
    [ (d) ]
    [ () (a d) ]
    [ (g), (a d) ]
    [ () () (a d g i) ]
    [ (b) () (a d g i) ]
    [ () (b e) (a d g i) ]
    [ (c) (b e) (a d g i ) ]
    [ () () () (a b c d e f g i) ]
    [ (j) () () (a b c d e f g i) ]
    [ () (h j) () (a b c d e f g i) ]

Затем, наконец, объедините все эти списки.

Обратите внимание, что количество элементов (прогонов) в стеке [i] равно нулю или 2 ^ i, а размер стека ограничен 1 + log2 (nruns). Каждый элемент объединяется один раз на уровне стека, следовательно, O (n log m) сравнений. Здесь есть некоторое сходство с Timsort, хотя Timsort поддерживает свой стек, используя что-то вроде последовательности Фибоначчи, в которой используются степени двойки.

При накоплении прогонов используются все уже отсортированные данные, поэтому сложность в лучшем случае составляет O (n) для уже отсортированного списка (один прогон). Поскольку мы накапливаем как восходящие, так и нисходящие прогоны, прогоны всегда будут иметь длину не менее 2. (Это уменьшает максимальную глубину стека как минимум на единицу, оплачивая в первую очередь затраты на поиск прогонов.) В худшем случае сложность O (n log n), как и ожидалось, для данных с высокой степенью рандомизации.

(Гм ... Второе обновление.)

Или просто посмотрите википедию на восходящий слияние .

1 голос
/ 06 октября 2009

Как я знаю, лучший алгоритм сортировки - это O (n * log n), независимо от контейнера - было доказано, что сортировка в широком смысле слова (стиль слияния / быстрой сортировки и т. Д.) Не может быть ниже. Использование связанного списка не даст вам лучшего времени выполнения.

Единственный алгоритм, который работает в O (n) - это алгоритм "взлома", который полагается на подсчет значений, а не на их сортировку.

...