Je suis curieux de savoir si O (n log n) est le meilleur qu'une liste chaînée puisse faire.
Je suis curieux de savoir si O (n log n) est le meilleur qu'une liste chaînée puisse faire.
Réponses:
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.
listsort
, vous verrez que vous pouvez changer en utilisant le paramètre int is_double
.
listsort
code C qui ne prend en charge que les listes à liaison
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.
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.
C'est un joli petit article sur ce sujet. Sa conclusion empirique est que Treesort est le meilleur, suivi de Quicksort et Mergesort. Le tri des sédiments, le tri des bulles, le tri par sélection fonctionnent très mal.
UNE ÉTUDE COMPARATIVE DES ALGORITHMES DE TRI DE LISTE LIÉE par Ching-Kuang Shene
http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981
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.
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.
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 );
}
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é).
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).
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
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.
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)
.
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 .
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.
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.
Mergesort est le mieux que vous puissiez faire ici.
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)