Какой самый быстрый алгоритм сортировки связанного списка?

96

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

Дирк
источник
31
Просто чтобы вы знали, O (nlogn) - это ограничение для сортировки на основе сравнения. Существуют сортировки, не основанные на сравнении, которые могут дать производительность O (n) (например, сортировка с подсчетом), но они требуют дополнительных ограничений на данные.
MAK,
Это были времена, когда вопросы в отличие от "почему этот код не работает ?????" были приемлемы на SO.
Абхиджит Саркар

Ответы:

100

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

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

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

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

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

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

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

csl
источник
3
Это не для односвязного списка. Его код на C использует * prev и * next.
LE
3
@LE Это на самом деле для обоих . Если вы видите подпись для listsort, вы увидите, что вы можете переключиться с помощью параметра int is_double.
csl
1
@LE: вот версия listsortкода C для Python, которая поддерживает только односвязные списки
jfs
O (kn) теоретически линейно и может быть достигнуто с помощью сортировки по ведру. Предполагая разумное k (количество бит / размер объекта, который вы сортируете), это могло бы быть немного быстрее
Адам
74

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

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

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

Поскольку оба алгоритма работают за 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 ГГц, поэтому кэш очень важен.

Йорген Фог
источник
2
Хорошие комментарии, но вы должны учитывать непостоянные затраты на копирование данных из списка в массив (вам придется перемещаться по списку), а также время работы быстрой сортировки в худшем случае.
csl 06
1
O (n * log n) теоретически то же самое, что O (n * log n + n), которое будет включать стоимость копии. Для любого достаточно большого n стоимость копии не имеет значения; прохождение списка до конца должно быть n раз.
Dean J,
1
@DeanJ: Теоретически да, но помните, что на исходном плакате описан случай, когда микрооптимизации имеют значение. И в этом случае необходимо учитывать время, потраченное на преобразование связанного списка в массив. Комментарии проницательны, но я не совсем уверен, что на самом деле это обеспечит прирост производительности. Возможно, это сработает для очень маленького N.
csl 08
1
@csl: На самом деле, я бы ожидал, что преимущества локальности проявятся для больших N. Предполагая, что промахи в кэше являются доминирующим эффектом производительности, тогда подход copy-qsort-copy приводит к примерно 2 * N промахам кеша для копирования, плюс количество промахов для qsort, которое будет небольшой долей от N log (N) (поскольку большинство обращений в qsort относятся к элементу, близкому к элементу, к которому недавно осуществлялся доступ). Количество промахов для сортировки слиянием составляет большую долю от N log (N), поскольку более высокая доля сравнений вызывает промахи в кэше. Таким образом, для больших N этот член доминирует и замедляет сортировку слиянием.
Стив Джессоп,
2
@Steve: Вы правы, что qsort не является заменой, но моя точка зрения на самом деле не в qsort vs. mergesort. Мне просто не хотелось писать еще одну версию сортировки слиянием, когда qsort был легко доступен. Стандартная библиотека является способ более удобным , чем прокатки свой собственный.
Jørgen Fogh
8

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

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

Artelius
источник
8

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

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

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

Нил Рихтер
источник
5

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

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

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

Божественный
источник
3

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

Марк Рэнсом
источник
1
Не могли бы вы объяснить больше по этой теме или дать ссылку на любой ресурс для сортировки по основанию в связанном списке.
LoveToCode
2

Сортировка слиянием не требует доступа 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 );
}
Пит Киркхэм
источник
5
Это было доказано , что никакие сравнения на основе сортировки algorthms не существует , что быстрее , чем п войти п.
Artelius
9
Нет, было доказано, что никакие алгоритмы сортировки на основе сравнения общих данных не работают быстрее, чем n log n
Пит Киркхэм,
Нет, любой алгоритм сортировки быстрее, чем O(n lg n)не основанный на сравнении (например, сортировка по основанию). По определению сортировка сравнения применяется к любому домену, имеющему общий порядок (т. Е. Можно сравнивать).
bdonlan 07
3
@bdonlan суть "общих данных" в том, что есть алгоритмы, которые быстрее подходят для ограниченного ввода, чем для случайного ввода. В предельном случае вы можете написать тривиальный алгоритм O (1), который сортирует список, учитывая, что входные данные уже отсортированы,
Пит Киркхэм,
И это не было бы сортировкой на основе сравнения. Модификатор «на общие данные» является избыточным, так как сортировки сравнения уже обрабатывают общие данные (а нотация большого О предназначена для количества выполненных сравнений).
Стив Джессоп,
1

Это не прямой ответ на ваш вопрос, но если вы используете список пропуска , он уже отсортирован и имеет время поиска O (log N).

Митч Уит
источник
1
ожидаемое O(lg N) время поиска - но не гарантируется, так как списки пропуска зависят от случайности. Если вы получаете ненадежные входные данные, убедитесь, что поставщик входных данных не может предсказать ваш ГСЧ, или они могут отправить вам данные, которые вызывают его худшую производительность
bdonlan
1

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

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

Лаура
источник
3
Это не алгоритм взлома, и он не работает за O (n). Он работает в O (cn), где c - наибольшее значение, которое вы сортируете (ну, на самом деле это разница между наивысшим и наименьшим значениями), и работает только с целыми значениями. Существует разница между O (n) и O (cn), поскольку, если вы не можете дать окончательную верхнюю границу для сортируемых значений (и, таким образом, ограничить ее константой), у вас есть два фактора, усложняющих сложность.
DivineWolfwood, 06
Собственно говоря, обкатывает O(n lg c). Если все ваши элементы уникальны, то c >= n, следовательно, требуется больше времени, чем O(n lg n).
bdonlan 07
1

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

Сложность равна 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);
    }

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

    [ ]
    [ (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), как и ожидалось, для сильно рандомизированных данных.

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

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

Стэн Свитцер
источник
Запуск создания с «обратным вводом» - приятный штрих. O(log m)дополнительная память не требуется - просто добавляйте запуски в два списка поочередно, пока один не станет пустым.
greybeard
1

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

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

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

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

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

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

Ширин
источник
Что это добавляет к ответу Йоргена Фога ?
глиняный кувшин
0

Mergesort - лучшее, что вы можете здесь сделать.

ypnos
источник
9
См. Сайт
Доминик Роджер,
11
Было бы лучше, если бы вы пояснили, почему .
csl, 06
0

Вопрос в LeetCode # 148 , и есть множество решений, предлагаемых на всех основных языках. Моя такова, но меня интересует временная сложность. Чтобы найти средний элемент, мы каждый раз просматриваем полный список. nПовторяются элементы в первый раз, элементы повторяются во второй раз 2 * n/2, и так далее и так далее. Вроде O(n^2)пора.

def sort(linked_list: LinkedList[int]) -> LinkedList[int]:
    # Return n // 2 element
    def middle(head: LinkedList[int]) -> LinkedList[int]:
        if not head or not head.next:
            return head
        slow = head
        fast = head.next

        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        return slow

    def merge(head1: LinkedList[int], head2: LinkedList[int]) -> LinkedList[int]:
        p1 = head1
        p2 = head2
        prev = head = None

        while p1 and p2:
            smaller = p1 if p1.val < p2.val else p2
            if not head:
                head = smaller
            if prev:
                prev.next = smaller
            prev = smaller

            if smaller == p1:
                p1 = p1.next
            else:
                p2 = p2.next

        if prev:
            prev.next = p1 or p2
        else:
            head = p1 or p2

        return head

    def merge_sort(head: LinkedList[int]) -> LinkedList[int]:
        if head and head.next:
            mid = middle(head)
            mid_next = mid.next
            # Makes it easier to stop
            mid.next = None

            return merge(merge_sort(head), merge_sort(mid_next))
        else:
            return head

    return merge_sort(linked_list)
Абхиджит Саркар
источник