Який найшвидший алгоритм сортування пов’язаного списку?


95

Мені цікаво, чи O (n log n) - це найкраще, що може зробити зв’язаний список.


31
Тільки для того, щоб ви знали, O (nlogn) є обов'язковим для сортування на основі порівняння. Існують сортування на основі порівняння, які можуть дати ефективність O (n) (наприклад, сортування підрахунку), але вони вимагають додаткових обмежень на дані.
MAK

Це були часи, коли виникали запитання на відміну від "чому цей код не працює ?????" були прийнятними для SO.
Абхіджіт Саркар,

Відповіді:


100

Розумно очікувати, що ви не можете зробити нічого кращого за O (N log N) під час роботи .

Однак найцікавішим є дослідження, чи можна сортувати його на місці , стабільно , його найгіршу поведінку тощо.

Саймон Тетхем, відомий у галузі Putty, пояснює, як сортувати пов'язаний список за допомогою сортування злиттям . Він закінчує такими коментарями:

Як і будь-який самоповажний алгоритм сортування, він має час роботи O (N log N). Оскільки це Mergesort, найгірший час роботи все ще залишається O (N log N); патологічних випадків немає.

Вимога до додаткового зберігання є невеликою і постійною (тобто декілька змінних у межах процедури сортування). Завдяки суттєво різній поведінці пов'язаних списків із масивів, ця реалізація Mergesort дозволяє уникнути витрат на допоміжне зберігання O (N), як правило, пов'язаних з алгоритмом.

Існує також приклад реалізації на мові C, який працює як для поодиноких, так і для подвійних списків.

Як @ Jørgen Fogh згадує нижче, позначення big-O може приховувати деякі незмінні фактори, які можуть призвести до кращої роботи одного алгоритму через локалізацію пам'яті, через малу кількість елементів тощо.


3
Це не для одного зв’язаного списку. Його код С використовує * попередній і * наступний.
LE

3
@LE Це насправді для обох . Якщо ви бачите підпис для listsort, ви побачите, що можете переключитися за допомогою параметра int is_double.
csl

1
@LE: ось версія Python listsortкоду С, яка підтримує лише
однопов'язані

O (kn) теоретично лінійний і може бути досягнутий за допомогою відсортованого сортування. Якщо прийняти розумне k (кількість біт / розмір об'єкта, який ви сортуєте), це може бути трохи швидше
Адам

74

Залежно від ряду факторів, насправді може бути швидше скопіювати список у масив, а потім використовувати швидку сортування .

Причиною цього може бути швидше те, що масив має набагато кращу продуктивність кешу, ніж пов'язаний список. Якщо вузли у списку розпорошені в пам'яті, можливо, ви генеруєте помилки кешу повсюдно. Знову ж таки, якщо масив великий, ви все одно отримаєте помилки кешу.

Паралелізувати злиття краще, тому може бути кращим вибором, якщо це те, що ви хочете. Це також набагато швидше, якщо ви виконуєте це безпосередньо у зв’язаному списку.

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

--- РЕДАГУВАТИ

Я вирішив перевірити свою гіпотезу і написав С-програму, яка вимірювала час (з використанням 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 секунд

Висновок:

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


2
Хороші коментарі, але вам слід врахувати непостійні витрати на копіювання даних зі списку в масив (вам доведеться обходити список), а також найгірший час роботи для швидкого сортування.
csl

1
O (n * log n) теоретично те саме, що O (n * log n + n), що включало б вартість копії. Для будь-якого достатньо великого n вартість копії дійсно не повинна мати значення; обхід списку один раз до кінця повинен бути n раз.
Dean J

1
@DeanJ: Теоретично так, але пам’ятайте, що оригінальний плакат висуває випадок, коли мікрооптимізація має значення. І в такому випадку слід враховувати час, витрачений на перетворення зв’язаного списку в масив. Коментарі проникливі, але я не повністю впевнений, що це забезпечить підвищення ефективності в реальності. Можливо, це спрацює для дуже малого N, можливо.
csl

1
@csl: Насправді, я би очікував, що переваги від місцевості посиляться для великої N. Припускаючи, що пропуски кешу є домінуючим ефектом продуктивності, тоді підхід copy-qsort-copy призводить до приблизно 2 * N пропусків кешу для копіювання, плюс кількість пропусків для qsort, яка буде невеликою часткою N log (N) (оскільки більшість звернень в qsort здійснюються до елемента, близького до нещодавно отриманого елемента). Кількість пропусків для сортування злиття становить більшу частку N журналу (N), оскільки більша частка порівнянь спричиняє промах кешу. Отже, для великих N цей термін домінує і уповільнює злиття.
Steve Jessop

2
@Steve: Ви маєте рацію, що qsort не є заміною, що випадає, але моя суть стосується не qsort проти mergesort. Мені просто не хотілося писати чергову версію mergesort, коли qsort був легко доступний. Стандартна бібліотека є спосіб більш зручним , ніж прокатки свій власний.
Йорген Фог

8

Сорти порівняння (тобто такі, що базуються на порівнянні елементів) не можуть бути швидшими, ніж n log n. Немає значення, яка основна структура даних. Дивіться Вікіпедію .

Інші види сортування, які використовують перевагу наявності в списку безлічі однакових елементів (наприклад, сортування підрахунку) або якийсь очікуваний розподіл елементів у списку, швидші, хоча я не можу придумати жодного, який працює особливо добре у зв’язаному списку.


8

Це приємна невеличка стаття на цю тему. Його емпіричний висновок полягає в тому, що найкращим є Treesort, а потім Quicksort та Mergesort. Сортування осаду, сортування бульбашок, сортування відбором виконують дуже погано.

ПОРІВНЯЛЬНЕ ДОСЛІДЖЕННЯ АЛГОРИТМІВ СОРТУВАННЯ ЗВ'ЯЗАНОГО СПИСКУ Чін-Куанг Шене

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


5

Як неодноразово зазначалося, нижньою межею для сортування на основі порівняння для загальних даних буде 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 , оскільки вони використовують конкретні об'єкти, які ви сортуєте, щоб зменшити складність пропорційно n. Будьте обережні, однак вони додають до складності деякі інші речі, які ви можете не враховувати (наприклад, Counting Sort та Radix sort обидва додають у коефіцієнтах, заснованих на розмірі чисел, які ви сортуєте, O (n + k ) де k - це розмір найбільшого числа для сортувальної сортування, наприклад).

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


3

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


1
Чи можете ви пояснити більше на цю тему або дати будь-яке посилання на ресурс для сортування radix у зв’язаному списку.
LoveToCode

2

Сортування злиття не вимагає доступу O (1) і має значення O (n ln n). Жоден відомий алгоритм сортування загальних даних не є кращим за O (n ln n).

Спеціальні алгоритми даних, такі як сортування radix (обмежує розмір даних) або сортування гістограми (підраховує дискретні дані), можуть сортувати пов’язаний список із нижчою функцією зростання, якщо ви використовуєте іншу структуру з доступом 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 не існує , що швидше , ніж п увійти п.
Артелій

9
Ні, доведено, що жоден алгоритм сортування на основі порівняння не є швидшим, ніж n log n
Піт Кіркхем,

Ні, будь-який алгоритм сортування швидший, ніж O(n lg n)не базується на порівнянні (наприклад, сортування radix). За визначенням, сортування порівняння застосовується до будь-якого домену, який має загальний порядок (тобто можна порівняти).
bdonlan

3
@bdonlan суть "загальних даних" полягає в тому, що існують алгоритми, які швидші для обмеженого введення, а не випадкового введення. В обмежувальному випадку ви можете написати тривіальний алгоритм O (1), який сортує список, за яким введені дані обмежуються вже сортуванням
Піт Кіркхем,

І це не було б різновидом порівняння. Модифікатор "на загальних даних" надлишковий, оскільки сорти порівняння вже обробляють загальні дані (а позначення big-O стосується кількості проведених порівнянь).
Steve Jessop

1

Не є прямою відповіддю на ваше запитання, але якщо ви використовуєте пропущений список , він уже відсортований і має час пошуку O (log N).


1
очікуваний O(lg N) час пошуку - але не гарантований, оскільки списки пропусків покладаються на випадковість. Якщо ви отримуєте ненадійне введення, переконайтеся, що постачальник вводу не може передбачити ваш RNG, або він може надіслати вам дані, які ініціюють найгіршу ефективність
bdonlan

1

Як я знаю, найкращим алгоритмом сортування є O (n * log n), незалежно від контейнера - було доведено, що сортування в широкому сенсі слова (стиль злиття / швидкого сортування тощо) не може бути нижчим. Використання пов’язаного списку не дасть вам кращого часу роботи.

Єдиним алгоритмом, який працює в O (n), є алгоритм "хак", який покладається на підрахунок значень, а не насправді на сортування.


3
Це не хакерський алгоритм, і він не працює в O (n). Він працює в O (cn), де c - найбільше значення, яке ви сортуєте (ну, насправді це різниця між найвищим та найнижчим значеннями) і працює лише на інтегральних значеннях. Існує різниця між O (n) та O (cn), оскільки, якщо ви не можете дати остаточну верхню межу для значень, які ви сортуєте (і, таким чином, обмежили їх константою), у вас є два фактори, що ускладнюють складність.
DivineWolfwood

Строго кажучи, це вбігає O(n lg c). Якщо всі ваші елементи унікальні, то c >= n, і тому це займає більше часу O(n lg n).
bdonlan

1

Ось реалізація яка перетинає список лише один раз, збираючи прогони, а потім планує злиття так само, як це робить mergesort.

Складність - 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). Тут є побіжна схожість з Тимсортом, хоча Тимсорт підтримує свій стек, використовуючи щось на зразок послідовності Фібоначчі, де він використовує повноваження двох.

Накопичувальні прогони використовують переваги будь-яких вже відсортованих даних, так що найкраща складність випадку - O (n) для вже відсортованого списку (один прогін). Оскільки ми накопичуємо як зростаючі, так і спадаючі прогони, пробіги завжди будуть мати принаймні довжину 2. (Це зменшує максимальну глибину стека принаймні на одну, платячи за першочергові витрати на пошук прогонів.) Найгірша складність випадку O (n log n), як і очікувалось, для даних, які є сильно рандомізованими.

(Гм ... Друге оновлення.)

Або просто подивіться вікіпедію на об’єднанні знизу вгору .


Якщо запустити створення, добре працювати з "зворотним введенням", це приємний штрих. O(log m)додаткова пам'ять не потрібна - просто по черзі додайте прогони до двох списків, поки один не порожній.
greybeard

1

Ви можете скопіювати його в масив, а потім відсортувати.

  • Копіювання в масив O (n),

  • сортування O (nlgn) (якщо ви використовуєте швидкий алгоритм, як сортування злиттям),

  • копіювання назад до пов'язаного списку O (n), якщо це необхідно,

так це буде O (nlgn).

зауважте, що якщо ви не знаєте кількість елементів у зв’язаному списку, ви не будете знати розмір масиву. Якщо ви кодуєте в java, ви можете використати Arraylist, наприклад.




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)
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.