Jaki jest najszybszy algorytm sortowania połączonej listy?

96

Ciekaw jestem, czy O (n log n) jest najlepsze, co może zrobić lista połączona.

Sztylet
źródło
31
Abyś wiedział, O (nlogn) jest granicą dla sortowań opartych na porównaniu. Istnieją sortowania nie oparte na porównaniu, które mogą dać wydajność O (n) (np. Sortowanie zliczające), ale wymagają one dodatkowych ograniczeń danych.
MAK
To były czasy, kiedy pytania inne niż „dlaczego ten kod nie działa ?????” były do ​​zaakceptowania na SO.
Abhijit Sarkar

Odpowiedzi:

100

Rozsądnie jest oczekiwać, że nie możesz zrobić nic lepszego niż O (N log N) w czasie wykonywania .

Jednak interesującą częścią jest zbadanie, czy można go posortować na miejscu , stabilnie , jego najgorsze zachowanie i tak dalej.

Simon Tatham, znany z Putty, wyjaśnia, jak posortować listę połączoną za pomocą sortowania przez scalanie . Kończy następującymi komentarzami:

Jak każdy szanujący się algorytm sortowania, ma on czas działania O (N log N). Ponieważ jest to Mergesort, czas wykonywania w najgorszym przypadku nadal wynosi O (N log N); nie ma przypadków patologicznych.

Wymagania dotyczące pamięci pomocniczej są małe i stałe (tj. Kilka zmiennych w ramach procedury sortowania). Dzięki z natury innym zachowaniom połączonych list z tablic, ta implementacja Mergesort pozwala uniknąć kosztu pamięci dyskowej O (N), który zwykle wiąże się z algorytmem.

Istnieje również przykładowa implementacja w języku C, która działa zarówno dla list połączonych pojedynczo, jak i podwójnie.

Jak wspomina @ Jørgen Fogh poniżej, notacja duże-O może ukrywać pewne stałe czynniki, które mogą powodować lepsze działanie jednego algorytmu z powodu lokalizacji pamięci, z powodu małej liczby elementów itp.

csl
źródło
3
To nie jest dla pojedynczej połączonej listy. Jego kod C używa * prev i * next.
LE
3
@LE To właściwie dla obu . Jeśli zobaczysz podpis dla listsort, zobaczysz, że możesz przełączyć się za pomocą parametru int is_double.
csl
1
@LE: oto wersja listsortkodu C w języku Python, która obsługuje tylko listy połączone pojedynczo
jfs
O (kn) jest teoretycznie liniowe i można to osiągnąć za pomocą sortowania kubełkowego. Zakładając rozsądne k (liczba bitów / rozmiar sortowanego obiektu), może to być nieco szybsze
Adam
74

W zależności od wielu czynników, w rzeczywistości może być szybsze skopiowanie listy do tablicy, a następnie użycie Quicksort .

Powodem, dla którego może to być szybsze, jest to, że tablica ma znacznie lepszą wydajność pamięci podręcznej niż lista połączona. Jeśli węzły na liście są rozproszone w pamięci, możesz generować błędy pamięci podręcznej w każdym miejscu. Z drugiej strony, jeśli tablica jest duża, i tak otrzymasz błędy pamięci podręcznej.

Mergesort działa równolegle lepiej, więc może to być lepszy wybór, jeśli tego chcesz. Jest to również znacznie szybsze, jeśli wykonujesz to bezpośrednio na połączonej liście.

Ponieważ oba algorytmy działają w O (n * log n), podjęcie świadomej decyzji wymagałoby profilowania ich obu na komputerze, na którym chciałbyś je uruchomić.

--- EDYTOWAĆ

Postanowiłem sprawdzić moją hipotezę i napisałem program w języku C, który mierzył czas (za pomocą clock()) sortowania połączonej listy int. Próbowałem z połączoną listą, do której przydzielono każdy węzeł, malloc()i połączoną listą, w której węzły zostały ułożone liniowo w tablicy, więc wydajność pamięci podręcznej byłaby lepsza. Porównałem je z wbudowanym qsort, który obejmował skopiowanie wszystkiego, od pofragmentowanej listy do tablicy i ponowne skopiowanie wyniku. Każdy algorytm został uruchomiony na tych samych 10 zestawach danych, a wyniki uśredniono.

Oto wyniki:

N = 1000:

Lista pofragmentowana z sortowaniem przez scalanie: 0,000000 sekund

Tablica z qsort: 0,000000 sekund

Lista spakowana z sortowaniem przez scalanie: 0,000000 sekund

N = 100000:

Lista pofragmentowana z sortowaniem przez scalanie: 0,039000 sekund

Tablica z qsort: 0,025000 sekund

Lista spakowana z sortowaniem przez scalanie: 0,009000 sekund

N = 1000000:

Lista pofragmentowana z sortowaniem przez scalanie: 1,162000 sekund

Tablica z qsort: 0,420000 sekund

Lista spakowana z sortowaniem przez scalanie: 0,112000 sekund

N = 100000000:

Lista pofragmentowana z sortowaniem przez scalanie: 364,797000 sekund

Tablica z qsort: 61,166000 sekund

Lista spakowana z sortowaniem przez scalanie: 16,525000 sekund

Wniosek:

Przynajmniej na moim komputerze, kopiowanie do tablicy jest tego warte, aby poprawić wydajność pamięci podręcznej, ponieważ rzadko masz całkowicie spakowaną listę połączoną w prawdziwym życiu. Należy zauważyć, że moja maszyna ma 2,8 GHz Phenom II, ale tylko 0,6 GHz RAM, więc pamięć podręczna jest bardzo ważna.

Jørgen Fogh
źródło
2
Dobre komentarze, ale należy wziąć pod uwagę niestały koszt kopiowania danych z listy do tablicy (musiałbyś przejść przez listę), a także najgorszy czas działania dla szybkiego sortowania.
csl
1
O (n * log n) jest teoretycznie tym samym, co O (n * log n + n), co obejmowałoby koszt kopii. Dla każdego dostatecznie dużego n koszt kopii naprawdę nie powinien mieć znaczenia; przejście listy jeden raz do końca powinno trwać n raz.
Dean J
1
@DeanJ: Teoretycznie tak, ale pamiętaj, że oryginalny plakat przedstawia przypadek, w którym liczą się mikrooptymalizacje. W takim przypadku należy wziąć pod uwagę czas spędzony na przekształcaniu listy połączonej w tablicę. Komentarze są wnikliwe, ale nie jestem do końca przekonany, że w rzeczywistości zapewni to wzrost wydajności. Może to zadziała dla bardzo małego N.
csl
1
@csl: Właściwie spodziewałbym się, że korzyści z lokalizacji przyniosą duże korzyści dla dużych N. Zakładając, że chybienia pamięci podręcznej są dominującym efektem wydajności, wówczas podejście kopiuj-qsort-kopiuj skutkuje około 2 * N brakami pamięci podręcznej dla kopiowania, plus liczba chybień dla qsort, która będzie niewielkim ułamkiem N log (N) (ponieważ większość dostępów w qsort dotyczy elementu zbliżonego do ostatnio otwieranego elementu). Liczba braków w sortowaniu przez scalanie jest większą częścią N log (N), ponieważ wyższy odsetek porównań powoduje brak danych w pamięci podręcznej. Zatem w przypadku dużego N termin ten dominuje i spowalnia scalanie.
Steve Jessop
2
@Steve: Masz rację, że qsort nie jest zamiennikiem typu drop-in, ale moja uwaga nie dotyczy tak naprawdę qsort vs. Po prostu nie miałem ochoty pisać kolejnej wersji scalania, kiedy qsort był łatwo dostępny. Standardowa biblioteka jest sposób bardziej wygodny niż toczenia własne.
Jørgen Fogh
8

Sortowanie porównawcze (tj. Oparte na porównywaniu elementów) nie może być szybsze niż n log n. Nie ma znaczenia, jaka jest podstawowa struktura danych. Zobacz Wikipedię .

Inne rodzaje, które wykorzystują to, że na liście jest dużo identycznych elementów (takie jak sortowanie zliczające) lub pewne oczekiwane rozmieszczenie elementów na liście, są szybsze, chociaż nie przychodzi mi do głowy żaden, który działałby szczególnie dobrze na połączonej liście.

Artelius
źródło
8

To jest fajny mały artykuł na ten temat. Jego empiryczny wniosek jest taki, że najlepszy jest Treeort, a następnie Quicksort i Mergesort. Sortowanie osadu, sortowanie bąbelkowe, sortowanie przez selekcję działają bardzo źle.

BADANIE PORÓWNAWCZE ALGORYTMÓW SORTOWANIA LIST POWIĄZANYCH przez Ching-Kuang Shene

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

Neal Richter
źródło
5

Jak wielokrotnie stwierdzono, dolną granicą sortowania opartego na porównaniu dla danych ogólnych będzie O (n log n). Aby krótko podsumować te argumenty, jest n! różne sposoby sortowania listy. Dowolne drzewo porównawcze, które ma n! (co jest w O (n ^ n)) możliwe ostateczne sortowanie będzie wymagało co najmniej log (n!) jako swojej wysokości: daje to O (log (n ^ n)) dolną granicę, która wynosi O (n log n).

Tak więc dla danych ogólnych na połączonej liście najlepszym możliwym sortowaniem, które będzie działać na dowolnych danych, które mogą porównywać dwa obiekty, będzie O (n log n). Jeśli jednak masz bardziej ograniczoną domenę rzeczy do pracy, możesz skrócić czas potrzebny (przynajmniej proporcjonalnie do n). Na przykład, jeśli pracujesz z liczbami całkowitymi nie większymi niż pewna wartość, możesz użyć Sortowanie zliczania lub Sortowanie według radix , ponieważ używają one określonych obiektów, które sortujesz, aby zmniejszyć złożoność proporcjonalnie do n. Uważaj jednak, te dodają kilka innych rzeczy do złożoności, których możesz nie brać pod uwagę (na przykład sortowanie zliczania i sortowanie radiksu dodają czynniki, które są oparte na rozmiarze sortowanych liczb, O (n + k ), gdzie k to na przykład wielkość największej liczby dla sortowania zliczania).

Ponadto, jeśli zdarzy ci się mieć obiekty, które mają doskonały hash (lub przynajmniej hash, który odwzorowuje wszystkie wartości inaczej), możesz spróbować użyć liczenia lub sortowania radix w ich funkcjach skrótu.

DivineWolfwood
źródło
3

Sortowanie pozycyjne jest szczególnie nadaje się do połączonej listy, ponieważ jest to łatwe do wykonania tablicę wskaźników głowy odpowiadających każdej możliwej wartości cyfry.

Mark Okup
źródło
1
Czy możesz wyjaśnić więcej na ten temat lub podać link do zasobów dotyczących sortowania radix na połączonej liście.
LoveToCode
2

Sortowanie przez scalanie nie wymaga dostępu O (1) i ma wartość O (n ln n). Żaden znany algorytm sortowania danych ogólnych nie jest lepszy niż O (n ln n).

Specjalne algorytmy danych, takie jak sortowanie radix (ogranicza rozmiar danych) lub sortowanie histogramu (zlicza dane dyskretne), mogą sortować połączoną listę z mniejszą funkcją wzrostu, o ile używasz innej struktury z dostępem O (1) jako tymczasowego przechowywania .

Inną klasą danych specjalnych jest rodzaj porównania prawie posortowanej listy z k elementami nieuporządkowanymi. Można to posortować w operacjach O (kn).

Kopiowanie listy do tablicy iz powrotem byłoby O (N), więc można użyć dowolnego algorytmu sortowania, jeśli przestrzeń nie jest problemem.

Na przykład, biorąc pod uwagę połączoną listę zawierającą uint_8, ten kod posortuje ją w czasie O (N) przy użyciu sortowania histogramu:

#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 );
}
Pete Kirkham
źródło
5
To zostało udowodnione , że nie ma porównania oparte algorthms sort istnieć że są szybsze niż n log n.
Artelius
9
Nie, zostało udowodnione, że żadne algorytmy sortowania oparte na porównaniu danych ogólnych nie są szybsze niż n log n
Pete Kirkham
Nie, dowolny algorytm sortowania szybszy niż O(n lg n)nie oparty na porównaniu (np. Sortowanie radix). Z definicji sortowanie porównawcze dotyczy dowolnej domeny, która ma uporządkowanie całkowite (tj. Można ją porównać).
bdonlan
3
@bdonlan sednem „danych ogólnych” jest to, że istnieją algorytmy, które są szybsze w przypadku ograniczonych danych wejściowych, a nie przypadkowych danych wejściowych. W ograniczonym przypadku możesz napisać trywialny algorytm O (1), który sortuje listę, biorąc pod uwagę, że dane wejściowe są ograniczone, aby były już posortowane
Pete Kirkham,
I to nie byłoby sortowanie oparte na porównaniu. Modyfikator „na danych ogólnych” jest zbędny, ponieważ sortowania porównawcze obsługują już dane ogólne (a notacja duże-O dotyczy liczby wykonanych porównań).
Steve Jessop
1

Nie jest to bezpośrednia odpowiedź na twoje pytanie, ale jeśli używasz listy pominiętych , jest ona już posortowana i ma czas wyszukiwania O (log N).

Mitch Wheat
źródło
1
oczekiwany O(lg N) czas wyszukiwania - ale nie jest gwarantowany, ponieważ listy pominięć są oparte na losowości. Jeśli otrzymujesz niezaufane dane wejściowe, upewnij się, że dostawca danych wejściowych nie może przewidzieć twojego RNG, lub może wysłać ci dane, które wyzwalają jego najgorszą wydajność
bdonlan
1

Jak wiem, najlepszym algorytmem sortowania jest O (n * log n), niezależnie od kontenera - zostało udowodnione, że sortowanie w szerokim znaczeniu tego słowa (styl łączenie / sortowanie itp.) Nie może być niższe. Korzystanie z listy połączonej nie zapewni lepszego czasu działania.

Jedynym algorytmem działającym w O (n) jest algorytm „hakerski”, który polega na liczeniu wartości, a nie na ich sortowaniu.

Laura
źródło
3
To nie jest algorytm hakerski i nie działa w O (n). Działa w O (cn), gdzie c jest największą wartością, którą sortujesz (cóż, tak naprawdę jest to różnica między najwyższą a najniższą wartością) i działa tylko na wartościach całkowitych. Istnieje różnica między O (n) i O (cn), ponieważ jeśli nie możesz podać ostatecznej górnej granicy wartości, które sortujesz (i tym samym związać ją stałą), masz dwa czynniki komplikujące złożoność.
DivineWolfwood
Ściśle mówiąc, wbiega O(n lg c). Jeśli wszystkie twoje elementy są unikalne, to c >= ni dlatego trwa dłużej niż O(n lg n).
bdonlan
1

Oto implementacja, która przeszukuje listę tylko raz, zbiera przebiegi, a następnie planuje scalenia w taki sam sposób, jak to robi scalesort.

Złożoność wynosi O (n log m), gdzie n to liczba elementów, a m to liczba serii. Najlepszym przypadkiem jest O (n) (jeśli dane są już posortowane), a najgorszym przypadkiem jest O (n log n), zgodnie z oczekiwaniami.

Wymaga pamięci tymczasowej O (log m); sortowanie odbywa się na miejscu na listach.

(zaktualizowane poniżej. Komentator dobrze zauważa, że ​​powinienem to opisać tutaj)

Istota algorytmu to:

    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

Kumulacja biegów nie wymaga wielu wyjaśnień, ale dobrze jest skorzystać z okazji, aby skumulować zarówno biegi wznoszące, jak i opadające (odwrócone). W tym przypadku dołącza elementy mniejsze niż początek rozdziału i dołącza elementy większe lub równe na końcu rozdziału. (Należy pamiętać, że poprzedzanie powinno używać ściśle mniejszego niż, aby zachować stabilność sortowania).

Najłatwiej jest po prostu wkleić tutaj kod scalający:

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

Rozważ sortowanie listy (dagibecfjh) (ignorowanie przebiegów). Stany stosu są następujące:

    [ ]
    [ (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) ]

Na koniec połącz wszystkie te listy.

Zauważ, że liczba elementów (przebiegów) na stosie [i] wynosi zero lub 2 ^ i, a rozmiar stosu jest ograniczony przez 1 + log2 (nruns). Każdy element jest łączony raz na poziom stosu, stąd porównania O (n log · m). Występuje tutaj przemijające podobieństwo do Timsort, chociaż Timsort utrzymuje swój stos za pomocą czegoś w rodzaju sekwencji Fibonacciego, w której używa się potęgi dwóch.

Kumulacja przebiegów wykorzystuje wszelkie już posortowane dane, tak że najlepsza złożoność przypadku wynosi O (n) dla już posortowanej listy (jeden przebieg). Ponieważ gromadzimy zarówno biegi wznoszące, jak i opadające, przebiegi zawsze będą miały co najmniej długość 2. (Zmniejsza to maksymalną głębokość stosu o co najmniej jeden, płacąc przede wszystkim za koszt znalezienia przebiegów). Najgorszy przypadek to złożoność O (n log n), zgodnie z oczekiwaniami, dla danych, które są wysoce randomizowane.

(Um ... Druga aktualizacja.)

Lub po prostu zobacz wikipedię o oddolnym sortowaniu .

Stan Switzer
źródło
Uruchomienie kreacji działa dobrze z "odwróconym wejściem" to miły akcent. O(log m)dodatkowa pamięć nie powinna być potrzebna - po prostu dodawaj przebiegi do dwóch list na przemian, aż jedna będzie pusta.
siwobrody
1

Możesz skopiować go do tablicy, a następnie posortować.

  • Kopiowanie do tablicy O (n),

  • sortowanie O (nlgn) (jeśli używasz szybkiego algorytmu, takiego jak sortowanie przez scalanie),

  • kopiowanie z powrotem do połączonej listy O (n), jeśli to konieczne,

więc to będzie O (nlgn).

zwróć uwagę, że jeśli nie znasz liczby elementów w połączonej liście, nie będziesz znać rozmiaru tablicy. Jeśli piszesz w Javie, możesz na przykład użyć Arraylist.

Shirin
źródło
Co to dodaje do odpowiedzi Jørgena Fogha ?
siwobrody
0

Pytanie brzmi LeetCode # 148 i istnieje wiele rozwiązań oferowanych we wszystkich głównych językach. Mój jest następujący, ale zastanawiam się nad złożonością czasową. Aby znaleźć środkowy element, za każdym razem przechodzimy przez całą listę. nElementy za pierwszym razem są iterowane, 2 * n/2elementy za drugim razem są iterowane, tak dalej i tak dalej. Wydaje się, że O(n^2)nadszedł czas.

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)
Abhijit Sarkar
źródło