Мені цікаво, чи O (n log n) - це найкраще, що може зробити зв’язаний список.
Мені цікаво, чи O (n log n) - це найкраще, що може зробити зв’язаний список.
Відповіді:
Розумно очікувати, що ви не можете зробити нічого кращого за O (N log N) під час роботи .
Однак найцікавішим є дослідження, чи можна сортувати його на місці , стабільно , його найгіршу поведінку тощо.
Саймон Тетхем, відомий у галузі Putty, пояснює, як сортувати пов'язаний список за допомогою сортування злиттям . Він закінчує такими коментарями:
Як і будь-який самоповажний алгоритм сортування, він має час роботи O (N log N). Оскільки це Mergesort, найгірший час роботи все ще залишається O (N log N); патологічних випадків немає.
Вимога до додаткового зберігання є невеликою і постійною (тобто декілька змінних у межах процедури сортування). Завдяки суттєво різній поведінці пов'язаних списків із масивів, ця реалізація Mergesort дозволяє уникнути витрат на допоміжне зберігання O (N), як правило, пов'язаних з алгоритмом.
Існує також приклад реалізації на мові C, який працює як для поодиноких, так і для подвійних списків.
Як @ Jørgen Fogh згадує нижче, позначення big-O може приховувати деякі незмінні фактори, які можуть призвести до кращої роботи одного алгоритму через локалізацію пам'яті, через малу кількість елементів тощо.
listsort
, ви побачите, що можете переключитися за допомогою параметра int is_double
.
listsort
коду С, яка підтримує лише
Залежно від ряду факторів, насправді може бути швидше скопіювати список у масив, а потім використовувати швидку сортування .
Причиною цього може бути швидше те, що масив має набагато кращу продуктивність кешу, ніж пов'язаний список. Якщо вузли у списку розпорошені в пам'яті, можливо, ви генеруєте помилки кешу повсюдно. Знову ж таки, якщо масив великий, ви все одно отримаєте помилки кешу.
Паралелізувати злиття краще, тому може бути кращим вибором, якщо це те, що ви хочете. Це також набагато швидше, якщо ви виконуєте це безпосередньо у зв’язаному списку.
Оскільки обидва алгоритми працюють в 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 ГГц оперативної пам'яті, тому кеш-пам’ять дуже важлива.
Сорти порівняння (тобто такі, що базуються на порівнянні елементів) не можуть бути швидшими, ніж n log n
. Немає значення, яка основна структура даних. Дивіться Вікіпедію .
Інші види сортування, які використовують перевагу наявності в списку безлічі однакових елементів (наприклад, сортування підрахунку) або якийсь очікуваний розподіл елементів у списку, швидші, хоча я не можу придумати жодного, який працює особливо добре у зв’язаному списку.
Це приємна невеличка стаття на цю тему. Його емпіричний висновок полягає в тому, що найкращим є Treesort, а потім Quicksort та Mergesort. Сортування осаду, сортування бульбашок, сортування відбором виконують дуже погано.
ПОРІВНЯЛЬНЕ ДОСЛІДЖЕННЯ АЛГОРИТМІВ СОРТУВАННЯ ЗВ'ЯЗАНОГО СПИСКУ Чін-Куанг Шене
http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981
Як неодноразово зазначалося, нижньою межею для сортування на основі порівняння для загальних даних буде 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 - це розмір найбільшого числа для сортувальної сортування, наприклад).
Крім того, якщо у вас трапляються об'єкти, які мають ідеальний хеш (або, принаймні, хеш, який по-різному відображає всі значення), ви можете спробувати використовувати підрахунок або сортування за радіусом для їх хеш-функцій.
Radix сорт особливо підходить для пов'язаного списку, так як це легко зробити таблицю покажчиків голови , відповідних кожному можливому значенню цифри.
Сортування злиття не вимагає доступу 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 );
}
O(n lg n)
не базується на порівнянні (наприклад, сортування radix). За визначенням, сортування порівняння застосовується до будь-якого домену, який має загальний порядок (тобто можна порівняти).
Не є прямою відповіддю на ваше запитання, але якщо ви використовуєте пропущений список , він уже відсортований і має час пошуку O (log N).
O(lg N)
час пошуку - але не гарантований, оскільки списки пропусків покладаються на випадковість. Якщо ви отримуєте ненадійне введення, переконайтеся, що постачальник вводу не може передбачити ваш RNG, або він може надіслати вам дані, які ініціюють найгіршу ефективність
Як я знаю, найкращим алгоритмом сортування є O (n * log n), незалежно від контейнера - було доведено, що сортування в широкому сенсі слова (стиль злиття / швидкого сортування тощо) не може бути нижчим. Використання пов’язаного списку не дасть вам кращого часу роботи.
Єдиним алгоритмом, який працює в O (n), є алгоритм "хак", який покладається на підрахунок значень, а не насправді на сортування.
O(n lg c)
. Якщо всі ваші елементи унікальні, то c >= n
, і тому це займає більше часу O(n lg n)
.
Ось реалізація яка перетинає список лише один раз, збираючи прогони, а потім планує злиття так само, як це робить 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)
додаткова пам'ять не потрібна - просто по черзі додайте прогони до двох списків, поки один не порожній.
Ви можете скопіювати його в масив, а потім відсортувати.
Копіювання в масив O (n),
сортування O (nlgn) (якщо ви використовуєте швидкий алгоритм, як сортування злиттям),
копіювання назад до пов'язаного списку O (n), якщо це необхідно,
так це буде O (nlgn).
зауважте, що якщо ви не знаєте кількість елементів у зв’язаному списку, ви не будете знати розмір масиву. Якщо ви кодуєте в java, ви можете використати Arraylist, наприклад.
Mergesort - найкраще, що ви можете тут зробити.
Питання в 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)