L' std::sort
algorithme (et ses cousins std::partial_sort
et std::nth_element
) de la bibliothèque standard C ++ est dans la plupart des implémentations une fusion compliquée et hybride d'algorithmes de tri plus élémentaires , tels que le tri par sélection, le tri par insertion, le tri rapide, le tri par fusion ou le tri par tas.
Il existe de nombreuses questions ici et sur des sites sœurs tels que https://codereview.stackexchange.com/ liés aux bogues, à la complexité et à d'autres aspects de la mise en œuvre de ces algorithmes de tri classiques. La plupart des implémentations proposées consistent en des boucles brutes, utilisent la manipulation d'index et des types concrets, et sont généralement non triviales à analyser en termes d'exactitude et d'efficacité.
Question : comment mettre en œuvre les algorithmes de tri classiques mentionnés ci-dessus en utilisant le C ++ moderne?
- pas de boucles brutes , mais en combinant les blocs de construction algorithmiques de la bibliothèque standard de
<algorithm>
- interface itérateur et utilisation de modèles au lieu de la manipulation d'index et de types concrets
- Style C ++ 14 , y compris la bibliothèque standard complète, ainsi que des réducteurs de bruit syntaxiques tels que des
auto
alias de modèle, des comparateurs transparents et des lambdas polymorphes.
Remarques :
- pour d'autres références sur les implémentations d'algorithmes de tri, voir Wikipedia , Rosetta Code ou http://www.sorting-algorithms.com/
- selon les conventions de Sean Parent (diapo 39), une boucle brute est une
for
boucle plus longue que la composition de deux fonctions avec un opérateur. Donc,f(g(x));
ouf(x); g(x);
ouf(x) + g(x);
ne sont pas des boucles brutes, ni les boucles dansselection_sort
et eninsertion_sort
dessous. - Je suis la terminologie de Scott Meyers pour désigner le C ++ 1y actuel déjà comme C ++ 14, et pour désigner C ++ 98 et C ++ 03 à la fois comme C ++ 98, alors ne me flambez pas pour cela.
- Comme suggéré dans les commentaires de @Mehrdad, je fournis quatre implémentations en tant qu'exemple en direct à la fin de la réponse: C ++ 14, C ++ 11, C ++ 98 et Boost et C ++ 98.
- La réponse elle-même est présentée en termes de C ++ 14 uniquement. Le cas échéant, je dénote les différences syntaxiques et de bibliothèque où les différentes versions linguistiques diffèrent.
Réponses:
Blocs de construction algorithmiques
Nous commençons par assembler les blocs de construction algorithmiques de la bibliothèque standard:
std::begin()
/std::end()
ainsi qu'avecstd::next()
sont disponibles uniquement à partir de C ++ 11 et au-delà. Pour C ++ 98, il faut les écrire lui-même. Il existe des substituts de Boost.Range dansboost::begin()
/boost::end()
et de Boost.Utility dansboost::next()
.std::is_sorted
algorithme n'est disponible que pour C ++ 11 et au-delà. Pour C ++ 98, cela peut être implémenté en termes destd::adjacent_find
et un objet fonction écrit à la main. Boost.Algorithm fournit également unboost::algorithm::is_sorted
comme substitut.std::is_heap
algorithme n'est disponible que pour C ++ 11 et au-delà.Goodies syntaxiques
C ++ 14 fournit des comparateurs transparents de la forme
std::less<>
qui agissent de manière polymorphe sur leurs arguments. Cela évite d'avoir à fournir un type d'itérateur. Cela peut être utilisé en combinaison avec les arguments de modèle de fonction par défaut de C ++ 11 pour créer une surcharge unique pour les algorithmes de tri qui prennent<
comme comparaison et ceux qui ont un objet de fonction de comparaison défini par l'utilisateur.En C ++ 11, on peut définir un alias de modèle réutilisable pour extraire le type de valeur d'un itérateur qui ajoute un encombrement mineur aux signatures des algorithmes de tri:
En C ++ 98, il faut écrire deux surcharges et utiliser la
typename xxx<yyy>::type
syntaxe verbeuseauto
paramètres qui sont déduits comme des arguments de modèle de fonction).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
de la syntaxe.boost::bind
et_1
/_2
placeholder.std::find_if_not
, alors que C ++ 98 a besoinstd::find_if
d'unstd::not1
autour d'un objet fonction.Style C ++
Il n'y a pas encore de style C ++ 14 généralement acceptable. Pour le meilleur ou pour le pire, je suis de près le projet Modern Modern C ++ de Scott Meyers et GotW remanié par Herb Sutter . J'utilise les recommandations de style suivantes:
()
et{}
lors de la création d'objets" de Scott Meyers et choisir systématiquement l'initialisation contreventement{}
au lieu de la bonne vieille initialisation entre parenthèses()
(afin de contourner tous les problèmes d'analyse les plus vexants dans le code générique).typedef
gagner du temps et ajoute de la cohérence.for (auto it = first; it != last; ++it)
modèle à certains endroits, afin de permettre la vérification invariante de boucle pour les sous-plages déjà triées. Dans le code de production, l'utilisation dewhile (first != last)
et++first
quelque part dans la boucle peut être légèrement meilleure.Tri de sélection
Le tri de sélection ne s'adapte en aucune façon aux données, donc son exécution est toujours
O(N²)
. Cependant, le tri par sélection a la propriété de minimiser le nombre de swaps . Dans les applications où le coût d'échange d'éléments est élevé, le tri par sélection peut très bien être l'algorithme de choix.Pour l'implémenter à l'aide de la bibliothèque standard, utilisez
std::min_element
à plusieurs reprises pour trouver l'élément minimum restant etiter_swap
pour le mettre en place:Notez que
selection_sort
la plage déjà traitée est[first, it)
triée comme invariant de boucle. Les exigences minimales sont des itérateurs directs , par rapport auxstd::sort
itérateurs à accès aléatoire de.Détails omis :
if (std::distance(first, last) <= 1) return;
(ou pour les itérateurs directs / bidirectionnels:)if (first == last || std::next(first) == last) return;
.[first, std::prev(last))
, car le dernier élément est garanti comme étant l'élément restant minimal et ne nécessite pas de swap.Tri par insertion
Bien qu'il s'agisse de l'un des algorithmes de tri élémentaires avec le
O(N²)
pire des cas, le tri par insertion est l'algorithme de choix lorsque les données sont presque triées (car elles sont adaptatives ) ou lorsque la taille du problème est petite (car elle a une faible surcharge). Pour ces raisons, et parce qu'il est également stable , le tri par insertion est souvent utilisé comme cas de base récursif (lorsque la taille du problème est faible) pour des algorithmes de tri avec division et conquête plus élevés, tels que le tri par fusion ou le tri rapide.Pour implémenter
insertion_sort
avec la bibliothèque standard, utilisezstd::upper_bound
à plusieurs reprises pour trouver l'emplacement où l'élément actuel doit aller et utilisezstd::rotate
pour déplacer les éléments restants vers le haut dans la plage d'entrée:Notez que
insertion_sort
la plage déjà traitée est[first, it)
triée comme invariant de boucle. Le tri par insertion fonctionne également avec les itérateurs avancés.Détails omis :
if (std::distance(first, last) <= 1) return;
(ou pour les itérateurs directs / bidirectionnels:)if (first == last || std::next(first) == last) return;
et une boucle sur l'intervalle[std::next(first), last)
, car le premier élément est garanti d'être en place et ne nécessite pas de rotation.std::find_if_not
algorithme de la bibliothèque standard .Quatre exemples en direct ( C ++ 14 , C ++ 11 , C ++ 98 et Boost , C ++ 98 ) pour le fragment ci-dessous:
O(N²)
comparaisons, mais cela améliore lesO(N)
comparaisons pour les entrées presque triées. La recherche binaire utilise toujours desO(N log N)
comparaisons.Tri rapide
Lorsqu'il est soigneusement mis en œuvre, le tri rapide est robuste et a la
O(N log N)
complexité attendue, mais avec laO(N²)
pire des situations qui peut être déclenchée avec des données d'entrée choisies de manière opposée. Lorsqu'un tri stable n'est pas nécessaire, le tri rapide est un excellent tri à usage général.Même pour les versions les plus simples, le tri rapide est un peu plus compliqué à implémenter à l'aide de la bibliothèque standard que les autres algorithmes de tri classiques. L'approche ci-dessous utilise quelques utilitaires d'itérateur pour localiser l' élément central de la plage d'entrée
[first, last)
comme pivot, puis utilisez deux appels àstd::partition
(qui sontO(N)
) pour partitionner à trois voies la plage d'entrée en segments d'éléments plus petits, égaux à, et plus grand que le pivot sélectionné, respectivement. Enfin, les deux segments externes avec des éléments plus petits et plus grands que le pivot sont triés récursivement:Cependant, le tri rapide est plutôt difficile à obtenir correctement et efficacement, car chacune des étapes ci-dessus doit être soigneusement vérifiée et optimisée pour le code au niveau de la production. En particulier, pour la
O(N log N)
complexité, le pivot doit se traduire par une partition équilibrée des données d'entrée, qui ne peut pas être garantie en général pour unO(1)
pivot, mais qui peut être garantie si l'on définit le pivot comme laO(N)
médiane de la plage d'entrée.Détails omis :
O(N^2)
complexe pour l' entrée " tuyau d'orgue "1, 2, 3, ..., N/2, ... 3, 2, 1
(car le milieu est toujours plus grand que tous les autres éléments).O(N^2)
.std::partition
n'est pas l'O(N)
algorithmele plus efficacepour obtenir ce résultat.O(N log N)
complexité garantie peut être obtenue grâce à la sélection de pivot médian à l' aidestd::nth_element(first, middle, last)
, suivie d'appels récursifs àquick_sort(first, middle, cmp)
etquick_sort(middle, last, cmp)
.O(N)
complexité destd::nth_element
peut être plus cher que celui de laO(1)
complexité d'un pivot médian de 3 suivi d'unO(N)
appel àstd::partition
(qui est un passage direct unique compatible avec le cache les données).Tri par fusion
Si l'utilisation
O(N)
d'espace supplémentaire n'est pas un problème, le tri par fusion est un excellent choix: c'est le seul algorithme de tri stableO(N log N)
.Il est simple à implémenter à l'aide d'algorithmes standard: utilisez quelques utilitaires d'itérateur pour localiser le milieu de la plage d'entrée
[first, last)
et combinez deux segments triés récursivement avec astd::inplace_merge
:Le tri par fusion nécessite des itérateurs bidirectionnels, le goulot d'étranglement étant le
std::inplace_merge
. Notez que lors du tri des listes liées, le tri par fusion ne nécessite qu'unO(log N)
espace supplémentaire (pour la récursivité). Ce dernier algorithme est implémenté parstd::list<T>::sort
dans la bibliothèque standard.Tri par tas
Le tri par segment est simple à implémenter, effectue un
O(N log N)
tri sur place, mais n'est pas stable.La première boucle,
O(N)
phase "heapify", met le tableau en ordre de tas. La deuxième boucle, laO(N log N
phase de "tri", extrait à plusieurs reprises le maximum et restaure l'ordre du tas. La bibliothèque standard rend cela extrêmement simple:Dans le cas où vous considérez qu'il est "tricheur" d'utiliser
std::make_heap
etstd::sort_heap
, vous pouvez aller plus loin et écrire ces fonctions vous-même en termes destd::push_heap
etstd::pop_heap
, respectivement:La bibliothèque standard spécifie à la fois
push_heap
et enpop_heap
tant que complexitéO(log N)
. Notez cependant que la boucle externe sur la plage[first, last)
entraîne uneO(N log N)
complexité pourmake_heap
, alors qu'ellestd::make_heap
n'a qu'uneO(N)
complexité. Pour laO(N log N)
complexité globale deheap_sort
celui-ci n'a pas d'importance.Détails omis :
O(N)
implémentation demake_heap
Essai
Voici quatre exemples en direct ( C ++ 14 , C ++ 11 , C ++ 98 et Boost , C ++ 98 ) testant les cinq algorithmes sur une variété d'entrées (non censées être exhaustives ou rigoureuses). Notez juste les énormes différences dans le LOC: C ++ 11 / C ++ 14 ont besoin d'environ 130 LOC, C ++ 98 et Boost 190 (+ 50%) et C ++ 98 plus de 270 (+ 100%).
la source
auto
(et beaucoup de gens ne sont pas d'accord avec moi), j'ai apprécié de bien utiliser les algorithmes de bibliothèque standard. J'avais voulu voir quelques exemples de ce type de code après avoir vu la conversation de Sean Parent. De plus, je n'avais aucune idée de l'std::iter_swap
existence, bien qu'il me semble étrange que ce soit là<algorithm>
.if (first == last || std::next(first) == last)
. Je pourrais mettre à jour cela plus tard. L'implémentation des éléments dans les sections "détails omis" dépasse la portée de la question, OMI, car ils contiennent des liens vers des questions-réponses entières. La mise en œuvre de routines de tri de mots réels est difficile!nth_element
à mon avis.nth_element
fait déjà un demi-tri rapide (y compris l'étape de partitionnement et une récursion sur la moitié qui inclut le n-ième élément qui vous intéresse).Un autre petit et plutôt élégant trouvé à l'origine sur la revue de code . Je pensais que ça valait le coup d'être partagé.
Tri par comptage
Bien qu'il soit assez spécialisé, le comptage du tri est un simple algorithme de tri des entiers et peut souvent être très rapide à condition que les valeurs des entiers à trier ne soient pas trop éloignées. C'est probablement l'idéal si l'on a besoin de trier une collection d'un million d'entiers connus pour être compris entre 0 et 100 par exemple.
Pour implémenter un tri de comptage très simple qui fonctionne avec des entiers signés et non signés, il faut trouver les éléments les plus petits et les plus grands dans la collection à trier; leur différence indiquera la taille du tableau de comptes à allouer. Ensuite, un deuxième passage dans la collection est effectué pour compter le nombre d'occurrences de chaque élément. Enfin, nous réécrivons le nombre requis de chaque entier dans la collection d'origine.
Bien que cela ne soit utile que lorsque la plage des entiers à trier est connue pour être petite (généralement pas plus grande que la taille de la collection à trier), rendre le tri plus générique le rendrait plus lent dans ses meilleurs cas. Si la plage n'est pas connue pour être petite, un autre algorithme tel qu'un tri radix , ska_sort ou spreadsort peut être utilisé à la place.
Détails omis :
Nous aurions pu passer les limites de la plage de valeurs acceptées par l'algorithme comme paramètres pour nous débarrasser totalement du premier
std::minmax_element
passage dans la collection. Cela rendra l'algorithme encore plus rapide lorsqu'une limite de plage utilement petite est connue par d'autres moyens. (Cela ne doit pas être exact; passer une constante de 0 à 100 est toujours bien mieux qu'un passage supplémentaire sur un million d'éléments pour découvrir que les vraies limites sont de 1 à 95. Même 0 à 1000 en vaut la peine; le les éléments supplémentaires sont écrits une fois avec zéro et lus une fois).Grandir
counts
à la volée est une autre façon d'éviter un premier passage séparé. Le fait de doubler lacounts
taille chaque fois qu'il doit croître donne du temps O (1) amorti par élément trié (voir l'analyse des coûts d'insertion de table de hachage pour la preuve que la croissance exponentielle est la clé). Grandir à la fin pour un nouveaumax
est facile avec l'std::vector::resize
ajout de nouveaux éléments mis à zéro. Le changementmin
à la volée et l'insertion de nouveaux éléments mis à zéro à l'avant peuvent être effectuésstd::copy_backward
après la croissance du vecteur. Puis mettrestd::fill
à zéro les nouveaux éléments.La
counts
boucle d'incrémentation est un histogramme. Si les données sont susceptibles d'être hautement répétitives et que le nombre de compartiments est petit, il peut être utile de dérouler sur plusieurs baies pour réduire le goulot d'étranglement de la dépendance des données de sérialisation du stockage / rechargement dans le même compartiment. Cela signifie plus de comptes à zéro au début et plus à boucler à la fin, mais cela en vaut la peine sur la plupart des processeurs pour notre exemple de millions de 0 à 100 nombres, surtout si l'entrée peut déjà être (partiellement) triée et ont de longues séries du même nombre.Dans l'algorithme ci-dessus, nous utilisons une
min == max
vérification pour retourner tôt lorsque chaque élément a la même valeur (auquel cas la collection est triée). Il est en fait possible de vérifier complètement si la collection est déjà triée tout en trouvant les valeurs extrêmes d'une collection sans perte de temps supplémentaire (si le premier passage est toujours goulot d'étranglement avec le travail supplémentaire de mise à jour min et max). Cependant, un tel algorithme n'existe pas dans la bibliothèque standard et en écrire un serait plus fastidieux que d'écrire le reste du comptage lui-même. Il est laissé comme exercice au lecteur.Étant donné que l'algorithme ne fonctionne qu'avec des valeurs entières, des assertions statiques peuvent être utilisées pour empêcher les utilisateurs de faire des erreurs de type évidentes. Dans certains contextes, un échec de substitution avec
std::enable_if_t
peut être préféré.Alors que le C ++ moderne est cool, le futur C ++ pourrait être encore plus cool: les liaisons structurées et certaines parties des Ranges TS rendraient l'algorithme encore plus propre.
la source
std::minmax_element
qui ne collecte que des informations). La propriété utilisée est le fait que les entiers peuvent être utilisés comme indices ou décalages, et qu'ils sont incrémentables tout en préservant cette dernière propriété.counts | ranges::view::filter([](auto c) { return c != 0; })
afin que vous n'ayez pas à tester de manière répétée des décomptes non nuls à l'intérieur dufill_n
.small
unrather
etappart
- puis-je les garder jusqu'à la modification concernant reggae_sort?)counts[]
à la volée serait une victoire contre la traversée de l'entrée avecminmax_element
avant l'histogramme. Surtout pour le cas d'utilisation où cela est idéal, de très grande entrée avec de nombreuses répétitions dans une petite plage, car vous passerez rapidementcounts
à sa taille maximale, avec peu de fautes de branche ou de doublons de taille. (Bien sûr, connaître une limite suffisamment petite sur la plage vous permettra d'éviter unminmax_element
balayage et d' éviter la vérification des limites à l'intérieur de la boucle d'histogramme.)