Qual è l'algoritmo più veloce per ordinare un elenco collegato?


96

Sono curioso di sapere se O (n log n) è il meglio che una lista collegata può fare.


31
Solo per farti sapere, O (nlogn) è il limite per gli ordinamenti basati sul confronto. Esistono ordinamenti non basati sul confronto che possono fornire prestazioni O (n) (ad esempio, conteggio dell'ordinamento), ma richiedono ulteriori vincoli sui dati.
MAK

Quelli erano i giorni in cui domande diverse da "perché questo codice non funziona ?????" erano accettabili su SO.
Abhijit Sarkar

Risposte:


100

È ragionevole aspettarsi che non si possa fare di meglio di O (N log N) nel tempo di esecuzione .

Tuttavia, la parte interessante è verificare se è possibile ordinarlo sul posto , in modo stabile , il suo comportamento nel peggiore dei casi e così via.

Simon Tatham, famoso per Putty, spiega come ordinare un elenco collegato con merge sort . Conclude con i seguenti commenti:

Come ogni algoritmo di ordinamento che si rispetti, questo ha un tempo di esecuzione O (N log N). Poiché questo è Mergesort, il tempo di esecuzione nel caso peggiore è ancora O (N log N); non ci sono casi patologici.

Il fabbisogno di stoccaggio ausiliario è piccolo e costante (cioè poche variabili all'interno della routine di smistamento). Grazie al comportamento intrinsecamente diverso degli elenchi collegati dagli array, questa implementazione di Mergesort evita il costo di archiviazione ausiliario O (N) normalmente associato all'algoritmo.

C'è anche un'implementazione di esempio in C che funziona sia per elenchi collegati singolarmente che doppiamente.

Come @ Jørgen Fogh menziona di seguito, la notazione O grande può nascondere alcuni fattori costanti che possono far funzionare meglio un algoritmo a causa della località della memoria, a causa di un numero basso di elementi, ecc.


3
Questo non è per un singolo elenco collegato. Il suo codice C utilizza * prev e * next.
LE

3
@LE In realtà è per entrambi . Se vedi la firma per listsort, vedrai che puoi cambiare usando il parametro int is_double.
csl

1
@LE: ecco una versione Python del listsortcodice C che supporta solo elenchi collegati singolarmente
jfs

O (kn) è teoricamente lineare e può essere ottenuto con il bucket sort. Supponendo un k ragionevole (numero di bit / dimensione dell'oggetto che stai ordinando), potrebbe essere un po 'più veloce
Adam

74

A seconda di una serie di fattori, potrebbe essere effettivamente più veloce copiare l'elenco in un array e quindi utilizzare un Quicksort .

Il motivo per cui questo potrebbe essere più veloce è che un array ha prestazioni della cache molto migliori rispetto a un elenco collegato. Se i nodi nell'elenco sono dispersi nella memoria, potresti generare errori di cache dappertutto. Poi di nuovo, se l'array è grande, si otterranno comunque errori di cache.

Mergesort parallelizza meglio, quindi potrebbe essere una scelta migliore se è quello che vuoi. È anche molto più veloce se lo esegui direttamente nell'elenco collegato.

Poiché entrambi gli algoritmi vengono eseguiti in O (n * log n), prendere una decisione informata comporterebbe la profilazione di entrambi sulla macchina su cui si desidera eseguirli.

--- MODIFICARE

Ho deciso di testare la mia ipotesi e ho scritto un programma C che misurava il tempo (usando clock()) impiegato per ordinare un elenco collegato di int. Ho provato con un elenco collegato in cui è stato allocato ogni nodomalloc() e un elenco collegato in cui i nodi erano disposti linearmente in un array, quindi le prestazioni della cache sarebbero state migliori. Li ho confrontati con il qsort integrato, che includeva la copia di tutto da un elenco frammentato a un array e la copia di nuovo del risultato. Ogni algoritmo è stato eseguito sugli stessi 10 set di dati e i risultati sono stati mediati.

Questi sono i risultati:

N = 1000:

Elenco frammentato con merge sort: 0,000000 secondi

Array con qsort: 0,000000 secondi

Packed list con merge sort: 0,000000 secondi

N = 100000:

Elenco frammentato con merge sort: 0,039000 secondi

Array con qsort: 0,025000 secondi

Packed list con merge sort: 0,009000 secondi

N = 1000000:

Elenco frammentato con merge sort: 1.162000 secondi

Array con qsort: 0.420000 secondi

Packed list con merge sort: 0,112000 secondi

N = 100000000:

Elenco frammentato con merge sort: 364.797000 secondi

Array con qsort: 61.166000 secondi

Packed list con merge sort: 16.525000 secondi

Conclusione:

Almeno sulla mia macchina, vale la pena copiare in un array per migliorare le prestazioni della cache, dal momento che nella vita reale raramente si dispone di un elenco collegato completo. Va notato che la mia macchina ha un Phenom II da 2.8GHz, ma solo 0.6GHz di RAM, quindi la cache è molto importante.


2
Buoni commenti, ma dovresti considerare il costo non costante di copiare i dati da un elenco a un array (dovresti attraversare l'elenco), così come il tempo di esecuzione del caso peggiore per Quicksort.
csl

1
O (n * log n) è teoricamente uguale a O (n * log n + n), che includerebbe il costo della copia. Per ogni n sufficientemente grande, il costo della copia in realtà non dovrebbe avere importanza; attraversare una lista una volta fino alla fine dovrebbe essere n volta.
Dean J

1
@DeanJ: Teoricamente, sì, ma ricorda che il poster originale presenta il caso in cui le micro-ottimizzazioni sono importanti. In tal caso, è necessario considerare il tempo impiegato per trasformare un elenco collegato in un array. I commenti sono penetranti, ma non sono completamente convinto che fornirebbe un aumento delle prestazioni nella realtà. Potrebbe funzionare per una N molto piccola, forse.
csl

1
@csl: In realtà, mi aspetto che i vantaggi della località si attivino per una grande N. Supponendo che i mancati riscontri nella cache siano l'effetto dominante sulle prestazioni, l'approccio copy-qsort-copy si traduce in circa 2 * N cache mancate per la copia, più il numero di errori per qsort, che sarà una piccola frazione di N log (N) (poiché la maggior parte degli accessi in qsort sono a un elemento vicino a un elemento a cui si accede di recente). Il numero di errori per l'ordinamento di unione è una frazione maggiore di N log (N), poiché una percentuale maggiore di confronti causa un errore di cache. Quindi per la grande N, questo termine domina e rallenta il mergesort.
Steve Jessop,

2
@Steve: hai ragione sul fatto che qsort non è un sostituto immediato, ma il mio punto non riguarda realmente qsort e mergesort. Semplicemente non avevo voglia di scrivere un'altra versione del mergesort quando qsort era prontamente disponibile. La libreria standard è molto più comoda della tua.
Jørgen Fogh

8

Gli ordinamenti di confronto (ovvero quelli basati sul confronto di elementi) non possono essere più veloci di n log n. Non importa quale sia la struttura dati sottostante. Vedi Wikipedia .

Altri tipi di ordinamento che sfruttano la presenza di molti elementi identici nell'elenco (come l'ordinamento conteggio), o alcune distribuzioni previste di elementi nell'elenco, sono più veloci, anche se non riesco a pensare a nessuno che funzioni particolarmente bene in un elenco collegato.


8

Questo è un bel paper su questo argomento. La sua conclusione empirica è che Treesort è il migliore, seguito da Quicksort e Mergesort. L'ordinamento dei sedimenti, l'ordinamento delle bolle, l'ordinamento della selezione hanno prestazioni molto scadenti.

UNO STUDIO COMPARATO DEGLI ALGORITMI DI ORDINAMENTO DELLE LISTE COLLEGATE di Ching-Kuang Shene

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


5

Come affermato molte volte, il limite inferiore dell'ordinamento basato sul confronto per i dati generali sarà O (n log n). Per riassumere brevemente questi argomenti, ci sono n! diversi modi in cui un elenco può essere ordinato. Qualsiasi tipo di albero di confronto che abbia n! (che è in O (n ^ n)) eventuali ordinamenti finali richiederanno almeno log (n!) come altezza: questo ti dà un limite inferiore O (log (n ^ n)), che è O (n log n).

Quindi, per i dati generali su un elenco collegato, il miglior ordinamento possibile che funzionerà su tutti i dati che possono confrontare due oggetti sarà O (n log n). Tuttavia, se hai un dominio più limitato di cose su cui lavorare, puoi migliorare il tempo necessario (almeno proporzionale a n). Ad esempio, se stai lavorando con numeri interi non più grandi di un valore, puoi usare Counting Sort o Radix Sort , poiché questi usano gli oggetti specifici che stai ordinando per ridurre la complessità con proporzione a n. Fai attenzione, però, questi aggiungono alcune altre cose alla complessità che potresti non considerare (ad esempio, Counting Sort e Radix sort aggiungono entrambi fattori basati sulla dimensione dei numeri che stai ordinando, O (n + k ) dove k è la dimensione del numero più grande per l'ordinamento conteggio, ad esempio).

Inoltre, se ti capita di avere oggetti che hanno un hash perfetto (o almeno un hash che mappa tutti i valori in modo diverso), potresti provare a utilizzare un conteggio o un ordinamento digitale sulle loro funzioni hash.


3

Un ordinamento Radix è particolarmente adatto a una lista concatenata, poiché è facile creare una tabella di puntatori a testa corrispondente a ogni possibile valore di una cifra.


1
Puoi spiegare di più su questo argomento o fornire un collegamento a una risorsa per l'ordinamento digitale nell'elenco collegato.
LoveToCode

2

L'ordinamento di tipo merge non richiede l'accesso O (1) ed è O (n ln n). Nessun algoritmo noto per l'ordinamento dei dati generali è migliore di O (n ln n).

Gli algoritmi di dati speciali come l'ordinamento digitale (limita la dimensione dei dati) o l'ordinamento dell'istogramma (conta i dati discreti) potrebbero ordinare un elenco collegato con una funzione di crescita inferiore, purché si utilizzi una struttura diversa con accesso O (1) come memoria temporanea .

Un'altra classe di dati speciali è una sorta di confronto di una lista quasi ordinata con k elementi fuori ordine. Questo può essere ordinato in operazioni O (kn).

Copiare l'elenco in un array e viceversa sarebbe O (N), quindi qualsiasi algoritmo di ordinamento può essere utilizzato se lo spazio non è un problema.

Ad esempio, dato un elenco collegato contenente uint_8, questo codice lo ordinerà in O (N) tempo utilizzando un ordinamento istogramma:

#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
È stato dimostrato che non esistono algoritmi di ordinamento basati sul confronto più veloci di n log n.
Artelius

9
No, è stato dimostrato che nessun algoritmo di ordinamento basato sul confronto su dati generali è più veloce di n log n
Pete Kirkham

No, qualsiasi algoritmo di ordinamento più veloce di quanto O(n lg n)non sarebbe basato sul confronto (ad esempio, radix sort). Per definizione, l'ordinamento per confronto si applica a qualsiasi dominio che ha un ordine totale (cioè può essere confrontato).
bdonlan

3
@bdonlan il punto dei "dati generali" è che ci sono algoritmi che sono più veloci per l'input vincolato, piuttosto che l'input casuale. Nel caso limite, puoi scrivere un banale algoritmo O (1) che ordina un elenco dato che i dati di input sono vincolati a essere già ordinati
Pete Kirkham

E questo non sarebbe un ordinamento basato sul confronto. Il modificatore "sui dati generali" è ridondante, poiché gli ordinamenti di confronto gestiscono già i dati generali (e la notazione O grande è per il numero di confronti effettuati).
Steve Jessop,

1

Non è una risposta diretta alla tua domanda, ma se utilizzi un elenco da saltare , è già ordinato e ha un tempo di ricerca O (log N).


1
O(lg N)tempo di ricerca previsto , ma non garantito, poiché gli elenchi da saltare si basano sulla casualità. Se ricevi input non attendibili, assicurati che il fornitore dell'input non sia in grado di prevedere il tuo RNG, altrimenti potrebbe inviarti dati che attivano le sue prestazioni nel caso peggiore
bdonlan

1

Come so, il miglior algoritmo di ordinamento è O (n * log n), qualunque sia il contenitore: è stato dimostrato che l'ordinamento nel senso ampio della parola (stile mergesort / quicksort ecc.) Non può essere inferiore. L'uso di un elenco collegato non ti darà un tempo di esecuzione migliore.

L'unico algoritmo che viene eseguito in O (n) è un algoritmo di "hacking" che si basa sul conteggio dei valori piuttosto che sull'ordinamento effettivo.


3
Non è un algoritmo di hacking e non viene eseguito in O (n). Funziona in O (cn), dove c è il valore più grande che stai ordinando (beh, in realtà è la differenza tra i valori più alti e quelli più bassi) e funziona solo su valori integrali. C'è una differenza tra O (n) e O (cn), poiché a meno che tu non possa dare un limite superiore definitivo per i valori che stai ordinando (e quindi vincolato da una costante), hai due fattori che complicano la complessità.
DivineWolfwood

A rigor di termini, funziona O(n lg c). Se tutti i tuoi elementi sono unici, allora c >= n, e quindi ci vuole più tempo di O(n lg n).
bdonlan

1

Ecco un'implementazione che attraversa l'elenco solo una volta, raccogliendo le esecuzioni, quindi pianifica le unioni nello stesso modo in cui fa il mergesort.

La complessità è O (n log m) dove n è il numero di elementi em è il numero di esecuzioni. Il caso migliore è O (n) (se i dati sono già ordinati) e il caso peggiore è O (n log n) come previsto.

Richiede una memoria temporanea O (log m); l'ordinamento viene eseguito sul posto negli elenchi.

(aggiornato di seguito. Il commentatore uno fa un buon punto sul fatto che dovrei descriverlo qui)

L'essenza dell'algoritmo è:

    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

L'accumulo di discese non richiede molte spiegazioni, ma è bene cogliere l'occasione per accumulare sia discese che ascendenti (invertite). Qui antepone elementi più piccoli dell'inizio della sequenza e aggiunge elementi maggiori o uguali alla fine della sequenza. (Si noti che la preposizione dovrebbe utilizzare rigorosamente meno di per preservare la stabilità dell'ordinamento.)

È più semplice incollare qui il codice di unione:

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

Considera l'idea di ordinare l'elenco (dagibecfjh) (ignorando le esecuzioni). Gli stati dello stack procedono come segue:

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

Quindi, finalmente, unisci tutti questi elenchi.

Notare che il numero di elementi (esecuzioni) nello stack [i] è zero o 2 ^ i e la dimensione dello stack è limitata da 1 + log2 (monache). Ogni elemento viene unito una volta per livello di stack, quindi confronti O (n log m). C'è una somiglianza passeggera con Timsort qui, anche se Timsort mantiene il suo stack usando qualcosa come una sequenza di Fibonacci in cui usa poteri di due.

L'accumulo di corse sfrutta tutti i dati già ordinati in modo che la complessità del caso migliore sia O (n) per un elenco già ordinato (una corsa). Dal momento che stiamo accumulando sia le sequenze ascendenti che quelle discendenti, le sequenze saranno sempre almeno di lunghezza 2. (Ciò riduce la profondità massima dello stack di almeno uno, pagando il costo di trovare le piste in primo luogo). O (n log n), come previsto, per dati altamente randomizzati.

(Um ... Secondo aggiornamento.)

O guarda semplicemente wikipedia sul mergesort dal basso verso l'alto .


Avere eseguito la creazione funziona bene con "input invertito" è un bel tocco. O(log m)non dovrebbe essere necessaria memoria aggiuntiva: è sufficiente aggiungere le corse a due elenchi alternativamente finché uno non è vuoto.
barba grigia

1

Puoi copiarlo in un array e quindi ordinarlo.

  • Copia nell'array O (n),

  • ordinamento O (nlgn) (se usi un algoritmo veloce come merge sort),

  • copia di nuovo alla lista collegata O (n) se necessario,

quindi sarà O (nlgn).

nota che se non conosci il numero di elementi nell'elenco collegato non conoscerai la dimensione dell'array. Se stai codificando in java puoi usare un Arraylist per esempio.




0

La domanda è LeetCode # 148 e ci sono molte soluzioni offerte in tutte le principali lingue. Il mio è il seguente, ma mi chiedo quale sia la complessità temporale. Per trovare l'elemento centrale, esaminiamo ogni volta l'elenco completo. La prima volta che gli nelementi vengono ripetuti, la seconda volta gli 2 * n/2elementi vengono ripetuti, così via e così via. Sembra che sia O(n^2)arrivato il momento.

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)
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.