Quel est l'algorithme le plus rapide pour trier une liste chaînée?


96

Je suis curieux de savoir si O (n log n) est le meilleur qu'une liste chaînée puisse faire.


31
Juste pour que vous sachiez, O (nlogn) est la limite pour les tris basés sur la comparaison. Il existe des tris non basés sur des comparaisons qui peuvent donner des performances O (n) (par exemple, tri par comptage), mais ils nécessitent des contraintes supplémentaires sur les données.
MAK

C'était l'époque où les questions ne ressemblaient pas à "pourquoi ce code ne fonctionne pas ?????" étaient acceptables sur SO.
Abhijit Sarkar

Réponses:


100

Il est raisonnable de s'attendre à ce que vous ne puissiez pas faire mieux que O (N log N) en temps d'exécution .

Cependant, la partie intéressante est de déterminer si vous pouvez le trier sur place , de manière stable , son comportement le plus défavorable, etc.

Simon Tatham, de renommée Putty, explique comment trier une liste liée avec le tri par fusion . Il conclut par les commentaires suivants:

Comme tout algorithme de tri qui se respecte, celui-ci a un temps d'exécution O (N log N). Comme il s'agit de Mergesort, le temps d'exécution le plus défavorable est toujours O (N log N); il n'y a pas de cas pathologiques.

Les besoins en stockage auxiliaire sont faibles et constants (c'est-à-dire quelques variables dans la routine de tri). Grâce au comportement intrinsèquement différent des listes chaînées des tableaux, cette implémentation de Mergesort évite le coût de stockage auxiliaire O (N) normalement associé à l'algorithme.

Il existe également un exemple d'implémentation en C qui fonctionne à la fois pour les listes à liaison simple et double.

Comme @ Jørgen Fogh le mentionne ci-dessous, la notation big-O peut masquer certains facteurs constants qui peuvent améliorer les performances d'un algorithme en raison de la localisation de la mémoire, du faible nombre d'éléments, etc.


3
Ce n'est pas pour une seule liste chaînée. Son code C utilise * prev et * next.
LE

3
@LE C'est en fait pour les deux . Si vous voyez la signature pour listsort, vous verrez que vous pouvez changer en utilisant le paramètre int is_double.
csl

1
@LE: voici une version Python du listsortcode C qui ne prend en charge que les listes à liaison
unique

O (kn) est théoriquement linéaire et peut être obtenu avec un tri par seau. En supposant un k raisonnable (nombre de bits / taille de l'objet que vous triez), cela pourrait être un peu plus rapide
Adam

74

En fonction d'un certain nombre de facteurs, il peut en fait être plus rapide de copier la liste dans un tableau, puis d'utiliser un tri rapide .

La raison pour laquelle cela peut être plus rapide est qu'un tableau a de bien meilleures performances de cache qu'une liste liée. Si les nœuds de la liste sont dispersés dans la mémoire, vous risquez de générer des erreurs de cache partout. Là encore, si le tableau est grand, vous obtiendrez quand même des erreurs de cache.

Mergesort est mieux parallélisé, donc ce peut être un meilleur choix si c'est ce que vous voulez. C'est également beaucoup plus rapide si vous l'exécutez directement sur la liste chaînée.

Étant donné que les deux algorithmes fonctionnent en O (n * log n), prendre une décision éclairée impliquerait de les profiler tous les deux sur la machine sur laquelle vous souhaitez les exécuter.

--- ÉDITER

J'ai décidé de tester mon hypothèse et j'ai écrit un programme C qui mesurait le temps (en utilisant clock()) pris pour trier une liste chaînée d'entrées. J'ai essayé avec une liste liée où chaque nœud était alloué malloc()et une liste liée où les nœuds étaient disposés linéairement dans un tableau, de sorte que les performances du cache seraient meilleures. J'ai comparé ceux-ci avec le qsort intégré, qui comprenait la copie de tout d'une liste fragmentée vers un tableau et la recopie du résultat. Chaque algorithme a été exécuté sur les mêmes 10 ensembles de données et les résultats ont été moyennés.

Voici les résultats:

N = 1 000:

Liste fragmentée avec tri par fusion: 0,000000 seconde

Tableau avec qsort: 0,000000 seconde

Liste complète avec tri par fusion: 0,000000 seconde

N = 100 000:

Liste fragmentée avec tri par fusion: 0,039000 secondes

Tableau avec qsort: 0,025000 secondes

Liste complète avec tri par fusion: 0,009000 secondes

N = 1000000:

Liste fragmentée avec tri par fusion: 1,162000 secondes

Tableau avec qsort: 0,420000 secondes

Liste complète avec tri par fusion: 0,112000 secondes

N = 100000000:

Liste fragmentée avec tri par fusion: 364,797000 secondes

Tableau avec qsort: 61,166000 secondes

Liste complète avec tri par fusion: 16,525000 secondes

Conclusion:

Au moins sur ma machine, la copie dans un tableau en vaut la peine pour améliorer les performances du cache, car vous avez rarement une liste chaînée complètement remplie dans la vraie vie. Il faut noter que ma machine a un Phenom II 2,8 GHz, mais seulement 0,6 GHz de RAM, donc le cache est très important.


2
Bons commentaires, mais vous devriez considérer le coût non constant de la copie des données d'une liste vers un tableau (vous devrez parcourir la liste), ainsi que le pire des cas d'exécution pour le tri rapide.
csl

1
O (n * log n) est théoriquement identique à O (n * log n + n), ce qui inclurait le coût de la copie. Pour tout n suffisamment grand, le coût de la copie ne devrait vraiment pas avoir d'importance; parcourir une liste une fois jusqu'à la fin devrait être n fois.
Dean J

1
@DeanJ: Théoriquement, oui, mais rappelez-vous que l'affiche originale présente le cas où les micro-optimisations sont importantes. Et dans ce cas, le temps passé à transformer une liste chaînée en tableau doit être pris en compte. Les commentaires sont perspicaces, mais je ne suis pas complètement convaincu que cela apporterait un gain de performance dans la réalité. Cela pourrait fonctionner pour un très petit N, peut-être.
csl

1
@csl: En fait, je m'attendrais à ce que les avantages de la localité se manifestent pour les grands N.En supposant que les échecs de cache sont l'effet dominant sur les performances, alors l'approche copy-qsort-copy entraîne environ 2 * N manquements de cache pour la copie, plus le nombre d'échecs pour le qsort, qui sera une petite fraction de N log (N) (puisque la plupart des accès dans qsort sont à un élément proche d'un élément récemment accédé). Le nombre d'échecs pour le tri par fusion est une plus grande fraction de N log (N), car une proportion plus élevée de comparaisons entraîne un échec du cache. Donc, pour un grand N, ce terme domine et ralentit le tri de fusion.
Steve Jessop

2
@Steve: Vous avez raison de dire que qsort n'est pas un remplacement instantané, mais je ne veux pas vraiment dire qsort par rapport à mergesort. Je n'avais tout simplement pas envie d'écrire une autre version du mergesort alors que qsort était facilement disponible. La bibliothèque standard est bien plus pratique que la vôtre.
Jørgen Fogh

8

Les tris de comparaison (c'est-à-dire ceux basés sur la comparaison d'éléments) ne peuvent pas être plus rapides que n log n. La structure de données sous-jacente n'a pas d'importance. Voir Wikipedia .

D'autres types de tri qui tirent parti du fait qu'il y a beaucoup d'éléments identiques dans la liste (comme le tri de comptage), ou une distribution attendue des éléments dans la liste, sont plus rapides, bien que je ne puisse penser à aucun qui fonctionne particulièrement bien sur une liste chaînée.



5

Comme indiqué à plusieurs reprises, la limite inférieure du tri basé sur la comparaison pour les données générales sera O (n log n). Pour résumer brièvement ces arguments, il existe n! différentes manières de trier une liste. Toute sorte d'arbre de comparaison qui a n! (qui est en O (n ^ n)) les tris finaux possibles auront besoin d'au moins log (n!) comme hauteur: cela vous donne une borne inférieure O (log (n ^ n)), qui est O (n log n).

Ainsi, pour les données générales sur une liste chaînée, le meilleur tri possible qui fonctionnera sur toutes les données pouvant comparer deux objets sera O (n log n). Cependant, si vous avez un domaine de travail plus limité, vous pouvez améliorer le temps nécessaire (au moins proportionnel à n). Par exemple, si vous travaillez avec des entiers ne dépassant pas une certaine valeur, vous pouvez utiliser le tri par comptage ou Radix Sort , car ils utilisent les objets spécifiques que vous triez pour réduire la complexité avec une proportion à n. Attention, cependant, cela ajoute d'autres choses à la complexité que vous ne pouvez pas prendre en compte (par exemple, le tri par comptage et le tri par Radix ajoutent tous deux des facteurs basés sur la taille des nombres que vous triez, O (n + k ) où k est la taille du plus grand nombre pour le tri par comptage, par exemple).

De plus, si vous avez des objets qui ont un hachage parfait (ou au moins un hachage qui mappe toutes les valeurs différemment), vous pouvez essayer d'utiliser un tri de comptage ou de base sur leurs fonctions de hachage.


3

Un tri Radix est particulièrement adapté à une liste chaînée, car il est facile de faire un tableau de pointeurs d'en-tête correspondant à chaque valeur possible d'un chiffre.


1
Pouvez-vous s'il vous plaît expliquer plus sur ce sujet ou donner un lien de ressource pour le tri radix dans la liste liée.
LoveToCode

2

Le tri par fusion ne nécessite pas d'accès O (1) et est O (n ln n). Aucun algorithme connu pour trier les données générales n'est meilleur que O (n ln n).

Les algorithmes de données spéciaux tels que le tri par base (limite la taille des données) ou le tri par histogramme (compte les données discrètes) peuvent trier une liste liée avec une fonction de croissance inférieure, tant que vous utilisez une structure différente avec un accès O (1) comme stockage temporaire .

Une autre classe de données spéciales est une sorte de comparaison d'une liste presque triée avec k éléments dans le désordre. Cela peut être trié en opérations O (kn).

La copie de la liste dans un tableau et inversement serait O (N), donc n'importe quel algorithme de tri peut être utilisé si l'espace n'est pas un problème.

Par exemple, étant donné une liste chaînée contenant uint_8, ce code la triera en temps O (N) en utilisant un tri d'histogramme:

#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
Il a été prouvé qu'il n'existe pas d'algorithmes de tri basés sur des comparaisons plus rapides que n log n.
Artelius

9
Non, il a été prouvé qu'aucun algorithme de tri basé sur la comparaison sur les données générales n'est plus rapide que n log n
Pete Kirkham

Non, tout algorithme de tri plus rapide que celui O(n lg n)qui ne serait pas basé sur la comparaison (par exemple, tri de base). Par définition, le tri de comparaison s'applique à tout domaine qui a un ordre total (c'est-à-dire, peut être comparé).
bdonlan

3
@bdonlan le point des "données générales" est qu'il existe des algorithmes plus rapides pour une entrée contrainte que pour une entrée aléatoire. Au cas limite, vous pouvez écrire un algorithme trivial O (1) qui trie une liste étant donné que les données d'entrée sont contraintes d'être déjà triées
Pete Kirkham

Et ce ne serait pas une sorte de comparaison. Le modificateur "sur les données générales" est redondant, car les tris de comparaison traitent déjà des données générales (et la notation big-O est pour le nombre de comparaisons effectuées).
Steve Jessop

1

Ce n'est pas une réponse directe à votre question, mais si vous utilisez une liste de sélection , elle est déjà triée et a un temps de recherche O (log N).


1
temps de O(lg N) recherche prévu - mais non garanti, car les listes de sauts reposent sur le caractère aléatoire. Si vous recevez une entrée non approuvée, assurez-vous que le fournisseur de l'entrée ne peut pas prédire votre RNG, ou il pourrait vous envoyer des données qui déclenchent ses pires performances
bdonlan

1

Comme je le sais, le meilleur algorithme de tri est O (n * log n), quel que soit le conteneur - il a été prouvé que le tri au sens large du terme (style mergesort / quicksort, etc.) ne peut pas descendre plus bas. L'utilisation d'une liste liée ne vous donnera pas un meilleur temps d'exécution.

Le seul algorithme qui s'exécute en O (n) est un algorithme de "hack" qui repose sur le comptage des valeurs plutôt que sur le tri.


3
Ce n'est pas un algorithme de hack, et il ne fonctionne pas en O (n). Il s'exécute en O (cn), où c est la plus grande valeur que vous triez (enfin, c'est vraiment la différence entre les valeurs les plus élevées et les plus basses) et ne fonctionne que sur les valeurs intégrales. Il y a une différence entre O (n) et O (cn), car à moins que vous ne puissiez donner une limite supérieure définitive pour les valeurs que vous triez (et donc la lier par une constante), vous avez deux facteurs qui compliquent la complexité.
DivineWolfwood

À proprement parler, ça rentre O(n lg c). Si tous vos éléments sont uniques, alors c >= n, et par conséquent, cela prend plus de temps que O(n lg n).
bdonlan

1

Voici une implémentation qui parcourt la liste une seule fois, collectant les exécutions, puis planifie les fusions de la même manière que mergesort.

La complexité est O (n log m) où n est le nombre d'éléments et m est le nombre d'exécutions. Le meilleur des cas est O (n) (si les données sont déjà triées) et le pire des cas est O (n log n) comme prévu.

Il nécessite une mémoire temporaire O (log m); le tri se fait sur place sur les listes.

(mis à jour ci-dessous. le commentateur un fait un bon point que je devrais le décrire ici)

L'essentiel de l'algorithme est:

    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'accumulation de courses ne nécessite pas beaucoup d'explications, mais il est bon de profiter de l'occasion pour accumuler à la fois des courses ascendantes et descendantes (inversées). Ici, il ajoute les éléments plus petits que la tête de l'analyse et ajoute les éléments supérieurs ou égaux à la fin de l'analyse. (Notez que le préfixe doit utiliser strict less-than pour préserver la stabilité du tri.)

Il est plus simple de coller simplement le code de fusion ici:

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

Pensez à trier la liste (dagibecfjh) (en ignorant les exécutions). Les états de la pile se déroulent comme suit:

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

Puis, enfin, fusionnez toutes ces listes.

Notez que le nombre d'éléments (exécutions) à la pile [i] est zéro ou 2 ^ i et que la taille de la pile est limitée par 1 + log2 (nruns). Chaque élément est fusionné une fois par niveau de pile, d'où O (n log m) comparaisons. Il y a une similitude passagère avec Timsort ici, bien que Timsort conserve sa pile en utilisant quelque chose comme une séquence de Fibonacci où cela utilise des puissances de deux.

L'accumulation d'exécutions tire parti de toutes les données déjà triées de sorte que la complexité du meilleur cas soit O (n) pour une liste déjà triée (une exécution). Puisque nous accumulons des exécutions ascendantes et descendantes, les exécutions seront toujours d'au moins la longueur 2. (Cela réduit la profondeur maximale de pile d'au moins un, payant le coût de recherche des exécutions en premier lieu.) Le pire cas de complexité est O (n log n), comme prévu, pour les données hautement aléatoires.

(Euh ... Deuxième mise à jour.)

Ou simplement voir wikipedia sur le tri de fusion ascendant .


Avoir de bonnes performances de création de run avec "entrée inversée" est une bonne idée. O(log m)de la mémoire supplémentaire ne devrait pas être nécessaire - ajoutez simplement des exécutions à deux listes en alternance jusqu'à ce qu'une seule soit vide.
greybeard

1

Vous pouvez le copier dans un tableau, puis le trier.

  • Copie dans le tableau O (n),

  • tri O (nlgn) (si vous utilisez un algorithme rapide comme le tri par fusion),

  • recopie dans la liste chaînée O (n) si nécessaire,

donc ça va être O (nlgn).

notez que si vous ne connaissez pas le nombre d'éléments dans la liste chaînée, vous ne connaîtrez pas la taille du tableau. Si vous codez en java, vous pouvez utiliser un Arraylist par exemple.


Qu'est-ce que cela ajoute à la réponse de Jørgen Fogh ?
barbe grise


0

La question est LeetCode # 148 , et de nombreuses solutions sont proposées dans toutes les langues principales. La mienne est la suivante, mais je m'interroge sur la complexité du temps. Afin de trouver l'élément du milieu, nous parcourons la liste complète à chaque fois. Les néléments de la première fois sont itérés, les 2 * n/2éléments de la seconde fois sont itérés, ainsi de suite. Il semble qu'il soit O(n^2)temps.

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)
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.