web-dev-qa-db-fra.com

Comment implémenter les algorithmes de tri classiques en C ++ moderne?

L'algorithme std::sort (et ses cousins ​​std::partial_sort et std::nth_element) de la bibliothèque standard C++ est dans la plupart des implémentations ne fusion complexe 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 apparentés tels que https://codereview.stackexchange.com/ liées aux bugs, à la complexité et à d'autres aspects de la mise en œuvre de ces algorithmes de tri classiques. La plupart des implémentations proposées se composent de boucles brutes, utilisent des types de manipulation d'index et de type concret, et sont généralement non triviales à analyser en termes de correction et d'efficacité.

Question : comment implémenter les algorithmes de tri classiques mentionnés ci-dessus à l'aide du C++ moderne?

  • pas de boucles brutes , mais en combinant les blocs de construction algorithmiques de la bibliothèque standard à partir de <algorithm>
  • interface itérateur et utilisation de modèles à la place de la manipulation d'index et de types concrets
  • Style C++ 14 , incluant la bibliothèque standard complète, ainsi que des réducteurs de bruit syntaxiques tels que auto, des alias de modèles, des comparateurs transparents et lambdas polymorphes.

Notes :

  • pour plus de références sur les implémentations d'algorithmes de tri, voir Wikipedia , Code Rosetta ou http://www.sorting-algorithms.com/
  • selon les conventions de Sean Parent (diapositive 39), une boucle brute est une boucle for- plus longue que la composition. de deux fonctions avec un opérateur. Ainsi, f(g(x)); ou f(x); g(x); ou f(x) + g(x); ne sont pas des boucles brutes, pas plus que les boucles dans selection_sort et insertion_sort ci-dessous.
  • Je suis la terminologie de Scott Meyers pour désigner le C++ 1y actuel en tant que C++ 14, et pour désigner C++ 98 et C++ 03 en C++ 98, ne vous enflammez pas pour cela.
  • Comme suggéré dans les commentaires de @Mehrdad, je propose quatre implémentations sous forme d’exemple dynamique à 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 signale les différences syntaxiques et de bibliothèque entre les différentes versions linguistiques.
320
TemplateRex

Blocs de construction algorithmiques

Nous commençons par assembler les blocs de construction algorithmiques de la bibliothèque standard:

_#include <algorithm>    // min_element, iter_swap, 
                        // upper_bound, rotate, 
                        // partition, 
                        // inplace_merge,
                        // make_heap, sort_heap, Push_heap, pop_heap,
                        // is_heap, is_sorted
#include <cassert>      // assert 
#include <functional>   // less
#include <iterator>     // distance, begin, end, next
_
  • les outils d'itérateur tels que std::begin()/std::end() non membre ainsi que avec std::next() ne sont disponibles qu'à partir de C++ 11 et des versions ultérieures. Pour C++ 98, il faut les écrire lui-même. Il existe des substituts de Boost.Range dans boost::begin()/boost::end() et de Boost.Utility dans boost::next().
  • l'algorithme _std::is_sorted_ est uniquement disponible pour C++ 11 et les versions ultérieures. Pour C++ 98, cela peut être implémenté sous la forme de _std::adjacent_find_ et d'un objet fonction écrit à la main. Boost.Algorithm fournit également un _boost::algorithm::is_sorted_ comme substitut.
  • l'algorithme _std::is_heap_ est uniquement disponible pour C++ 11 et les versions ultérieures.

Goodies syntaxiques

C++ 14 fournit [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. Ceci peut être utilisé en combinaison avec C++ 11 arguments de modèle de fonction par défaut pour créer ne seule surcharge pour le tri des algorithmes prenant _<_ comme comparaison et ceux qui ont un objet fonction de comparaison défini par l'utilisateur.

_template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
_

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 fouillis mineur aux signatures des algorithmes de tri:

_template<class It>
using value_type_t = typename std::iterator_traits<It>::value_type;

template<class It, class Compare = std::less<value_type_t<It>>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
_

En C++ 98, il faut écrire deux surcharges et utiliser la syntaxe verbose _typename xxx<yyy>::type_

_template<class It, class Compare>
void xxx_sort(It first, It last, Compare cmp); // general implementation

template<class It>
void xxx_sort(It first, It last)
{
    xxx_sort(first, last, std::less<typename std::iterator_traits<It>::value_type>());
}
_
  • Une autre particularité syntaxique est que C++ 14 facilite l’enveloppement de comparateurs définis par l’utilisateur par lambdas polymorphes (avec auto paramètres déduits comme des arguments de modèle de fonction).
  • C++ 11 n'a que des lambdas monomorphes, qui nécessitent l'utilisation de l'alias de modèle ci-dessus _value_type_t_.
  • En C++ 98, il est nécessaire d'écrire un objet de fonction autonome ou de recourir au type de syntaxe _std::bind1st_/_std::bind2nd_/_std::not1_ détaillé.
  • Boost.Bind améliore cela avec la syntaxe _boost::bind_ et __1_/__2_.
  • C++ 11 et les versions ultérieures ont également _std::find_if_not_, alors que C++ 98 requiert _std::find_if_ avec un _std::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 texte de Scott Meyers --- [projet en vigueur Effective Modern C++ ) et celui de Herb Sutter --- Gotow . J'utilise les recommandations de style suivantes:

  • Herb Sutter's "Presque toujours Auto" et Scott Meyers "Préférer les déclarations de types spécifiques" ), pour lequel la brièveté est inégalée, bien que sa clarté soit parfois contestée .
  • Scott Meyers's --- "Distinguer _()_ et _{}_ lors de la création d'objets" et choisir de manière cohérente l'initialisation masquée _{}_ au lieu de la bonne vieille parenthèse initialization _()_ (afin de contourner tous les problèmes les plus inquiétants du code générique).
  • Scott Meyers's "Préférez les déclarations d'alias aux typedefs" . De toute façon, c'est un must pour les modèles, et l'utiliser partout au lieu de typedef permet de gagner du temps et ajoute de la cohérence.
  • J'utilise un modèle for (auto it = first; it != last; ++it) à certains endroits, afin de permettre la vérification d'invariants de boucle pour les sous-plages déjà triées. Dans le code de production, l'utilisation de while (first != last) et d'un _++first_ quelque part dans la boucle peut être légèrement meilleure.

Tri de sélection

Tri de la sélection ne s'adapte en aucune façon aux données. Son temps d'exécution est donc toujours O(N²). Cependant, le tri par sélection a la propriété de réduisant le nombre de swaps. Dans les applications où le coût d’échange d’éléments est élevé, le choix de la sélection peut très bien être l’algorithme de choix.

Pour l'implémenter à l'aide de la bibliothèque standard, utilisez plusieurs fois _std::min_element_ pour rechercher l'élément minimal restant et _iter_swap_ pour le remplacer:

_template<class FwdIt, class Compare = std::less<>>
void selection_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last; ++it) {
        auto const selection = std::min_element(it, last, cmp);
        std::iter_swap(selection, it); 
        assert(std::is_sorted(first, std::next(it), cmp));
    }
}
_

Notez que _selection_sort_ a la plage déjà traitée _[first, it)_ triée comme son invariant de boucle. Les exigences minimales sont itérateurs, comparées aux itérateurs à accès aléatoire de _std::sort_.

détails omis:

  • le tri par sélection peut être optimisé avec un test précoce if (std::distance(first, last) <= 1) return; (ou pour les itérateurs en avant/bidirectionnels: if (first == last || std::next(first) == last) return;).
  • pour itérateurs bidirectionnels, le test ci-dessus peut être combiné à une boucle sur l'intervalle [first, std::prev(last)), car le dernier élément est garanti comme étant l'élément minimal restant et ne nécessite pas d'échange.

Tri par insertion

Bien qu’il s’agisse de l’un des algorithmes de tri élémentaires avec O(N²) pire des cas, insertion type est l’algorithme de choix soit lorsque les données sont presque triées (car - adaptatif) ou lorsque la taille du problème est petite (car les frais généraux sont faibles). 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 divis-and-conquer plus lourds, tels que la fusion trier ou trier rapidement.

Pour implémenter _insertion_sort_ avec la bibliothèque standard, utilisez plusieurs fois _std::upper_bound_ pour rechercher l'emplacement où l'élément actuel doit être déplacé, puis utilisez _std::rotate_ pour déplacer les éléments restants vers le haut de la plage d'entrée:

_template<class FwdIt, class Compare = std::less<>>
void insertion_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last; ++it) {
        auto const insertion = std::upper_bound(first, it, *it, cmp);
        std::rotate(insertion, it, std::next(it)); 
        assert(std::is_sorted(first, std::next(it), cmp));
    }
}
_

Notez que _insertion_sort_ a la plage déjà traitée _[first, it)_ triée comme son invariant de boucle. Le tri par insertion fonctionne également avec les itérateurs directs.

détails omis:

  • le tri par insertion peut être optimisé avec un test précoce if (std::distance(first, last) <= 1) return; (ou pour les itérateurs en avant/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 et ne nécessite pas de rotation.
  • pour itérateurs bidirectionnels, la recherche binaire pour trouver le point d'insertion peut être remplacée par une recherche linéaire inversée à l'aide de l'algorithme _std::find_if_not_ de la bibliothèque standard.

Quatre Exemples vivants ( C++ 14 , C++ 11 , - C++ 98 et Boost , C++ 98 ) pour le fragment ci-dessous:

_using RevIt = std::reverse_iterator<BiDirIt>;
auto const insertion = std::find_if_not(RevIt(it), RevIt(first), 
    [=](auto const& elem){ return cmp(*it, elem); }
).base();
_
  • Pour les entrées aléatoires, cela donne des comparaisons O(N²), mais cela améliore les comparaisons O(N) pour les entrées presque triées. La recherche binaire utilise toujours des comparaisons O(N log N).
  • Pour les petites plages d’entrées, la meilleure localisation en mémoire (cache, prélecture) d’une recherche linéaire peut également dominer une recherche binaire (il faut bien entendu tester cela).

Tri rapide

Lorsqu'il est soigneusement mis en œuvre, [sort rapide est robuste et présente la complexité attendue O(N log N), mais avec la complexité O(N²) dans le cas le plus défavorable pouvant être déclenchée à l'aide de données d'entrée choisies de manière adverse. Lorsqu'un tri stable n'est pas nécessaire, le tri rapide est un excellent tri polyvalent.

Même pour les versions les plus simples, le tri rapide est un peu plus compliqué à mettre en œuvre à 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 le élément du milie de la plage de saisie _[first, last)_ comme pivot, puis utilisez deux appels à _std::partition_ (qui sont O(N)) -way partitionne la plage d'entrée en segments d'éléments inférieurs, égaux et supérieurs au pivot sélectionné, respectivement. Enfin, les deux segments externes avec des éléments plus petits et plus grands que le pivot sont triés de manière récursive:

_template<class FwdIt, class Compare = std::less<>>
void quick_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
    auto const N = std::distance(first, last);
    if (N <= 1) return;
    auto const pivot = *std::next(first, N / 2);
    auto const middle1 = std::partition(first, last, [=](auto const& elem){ 
        return cmp(elem, pivot); 
    });
    auto const middle2 = std::partition(middle1, last, [=](auto const& elem){ 
        return !cmp(pivot, elem);
    });
    quick_sort(first, middle1, cmp); // assert(std::is_sorted(first, middle1, cmp));
    quick_sort(middle2, last, cmp);  // assert(std::is_sorted(middle2, last, cmp));
}
_

Cependant, un tri rapide est plutôt délicat à obtenir correctement et efficacement, car chacune des étapes ci-dessus doit être soigneusement vérifiée et optimisée pour le code de niveau de production. En particulier, pour la complexité de O(N log N), le pivot doit aboutir à une partition équilibrée des données d'entrée, qui ne peut pas être garantie en général pour un pivot O(1), mais qui peut être garantie si vous définissez le pivot comme médiane O(N) de la plage d'entrée. .

détails omis:

  • la mise en œuvre ci-dessus est particulièrement vulnérable aux intrants spéciaux, par ex. sa complexité est O(N^2) 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).
  • [médiane-de- ) == La sélection de pivot de Éléments choisis aléatoirement à partir de la plage d'entrée vous protège contre des entrées presque triées pour lesquelles la complexité se détériorerait autrement à O(N^2).
  • [partitionnement à 3 voies (éléments séparateurs plus petits que, égaux et plus grands que le pivot), comme indiqué par les deux appels à _std::partition_ n'est pas le plus efficace O(N) algorithme pour obtenir ce résultat.
  • pour itérateurs à accès aléatoire, une complexité garantie de O(N log N) peut être obtenue grâce à sélection du pivot médian à l'aide de std::nth_element(first, middle, last), suivie d'appels récursifs à quick_sort(first, middle, cmp) et quick_sort(middle, last, cmp).
  • cette garantie a toutefois un coût, car le facteur constant de la complexité O(N) de _std::nth_element_ peut être plus onéreux que celui de la complexité O(1) d'un pivot médian sur 3 suivi d'un appel O(N) à _std::partition_ (qui est un transfert unique convivial pour le cache sur les données).

Tri par fusion

Si vous utilisez O(N), l'espace supplémentaire n'est pas un problème, alors [fusionner le tri est un excellent choix: il s'agit du seul stableO(N log N) algorithme de tri.

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 un _std::inplace_merge_:

_template<class BiDirIt, class Compare = std::less<>>
void merge_sort(BiDirIt first, BiDirIt last, Compare cmp = Compare{})
{
    auto const N = std::distance(first, last);
    if (N <= 1) return;                   
    auto const middle = std::next(first, N / 2);
    merge_sort(first, middle, cmp); // assert(std::is_sorted(first, middle, cmp));
    merge_sort(middle, last, cmp);  // assert(std::is_sorted(middle, last, cmp));
    std::inplace_merge(first, middle, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
_

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 chaînées, le tri par fusion nécessite uniquement O(log N) espace supplémentaire (pour la récursivité). Ce dernier algorithme est implémenté par _std::list<T>::sort_ dans la bibliothèque standard.

Sorte de tas

[Tri de tas est simple à mettre en œuvre. Il effectue un tri sur place O(N log N), mais n'est pas stable.

La première boucle, O(N) "heapify" phase, place le tableau en ordre de tas. La deuxième boucle, la phase de "sortdown" _O(N log N_), extrait à plusieurs reprises le maximum et restaure l'ordre des segments. La bibliothèque standard rend cela extrêmement simple:

_template<class RandomIt, class Compare = std::less<>>
void heap_sort(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    lib::make_heap(first, last, cmp); // assert(std::is_heap(first, last, cmp));
    lib::sort_heap(first, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
_

Si vous considérez comme "tricher" d'utiliser _std::make_heap_ et _std::sort_heap_, vous pouvez aller plus loin et écrire ces fonctions vous-même en termes de _std::Push_heap_ et _std::pop_heap_, respectivement:

_namespace lib {

// NOTE: is O(N log N), not O(N) as std::make_heap
template<class RandomIt, class Compare = std::less<>>
void make_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    for (auto it = first; it != last;) {
        std::Push_heap(first, ++it, cmp); 
        assert(std::is_heap(first, it, cmp));           
    }
}

template<class RandomIt, class Compare = std::less<>>
void sort_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
    for (auto it = last; it != first;) {
        std::pop_heap(first, it--, cmp);
        assert(std::is_heap(first, it, cmp));           
    } 
}

}   // namespace lib
_

La bibliothèque standard spécifie à la fois _Push_heap_ et _pop_heap_ en tant que complexité O(log N). Notez cependant que la boucle externe au-dessus de la plage _[first, last)_ entraîne O(N log N) complexité pour _make_heap_, alors que _std::make_heap_ n'a que O(N) complexité. Pour la complexité globale O(N log N) de _heap_sort_, cela n'a pas d'importance.

Détails omis: --- [O(N) implémentation de _make_heap_

Essai

Voici quatre Exemples vivants ( C++ 14 , C++ 11 , --- [C++ 98 et Boost , C++ 98 ) en testant les cinq algorithmes sur diverses entrées (pas destiné à être exhaustif ou rigoureux). Il suffit de noter les énormes différences dans le LOC: C++ 11/C++ 14 nécessite environ 130 LOC, C++ 98 et Boost 190 (+ 50%) et C++ 98 plus de 270 (+ 100%).

377
TemplateRex

Un autre petit et plutôt élégant trouvé à l'origine dans la révision du code . Je pensais que ça valait la peine de partager.

Compter le genre

Bien qu'il soit plutôt spécialisé, counting sort == est un algorithme de tri d'entiers simple et peut souvent être très rapide à condition que les valeurs des entiers à trier ne soient pas trop éloignées les unes des autres. C'est probablement l'idéal si vous avez besoin de trier une collection d'un million d'entiers comprise 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 est nécessaire de rechercher les éléments les plus petits et les plus grands de la collection à trier; leur différence indiquera la taille du tableau de comptes à allouer. Ensuite, un second 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.

template<typename ForwardIterator>
void counting_sort(ForwardIterator first, ForwardIterator last)
{
    if (first == last || std::next(first) == last) return;

    auto minmax = std::minmax_element(first, last);  // avoid if possible.
    auto min = *minmax.first;
    auto max = *minmax.second;
    if (min == max) return;

    using difference_type = typename std::iterator_traits<ForwardIterator>::difference_type;
    std::vector<difference_type> counts(max - min + 1, 0);

    for (auto it = first ; it != last ; ++it) {
        ++counts[*it - min];
    }

    for (auto count: counts) {
        first = std::fill_n(first, count, min++);
    }
}

Bien que cela ne soit utile que lorsque la plage des nombres 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 rendrait le tri plus générique, dans le meilleur des cas. Si la plage n’est pas connue pour être petite, un autre algorithme tel que tri tri , 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 en tant que paramètres pour éliminer totalement le 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 n’a pas besoin d’être exact; passer une constante de 0 à 100 est toujours bien meilleur qu’un dépassement de plus d’un million d’éléments pour découvrir que les vraies bornes sont 1 à 95. Même 0 à 1000 en valait la peine, les éléments supplémentaires sont écrits une fois avec zéro et lus une fois).

  • Cultiver counts à la volée est un autre moyen d'éviter un premier passage séparé. Doubler la taille counts à chaque fois que celle-ci doit croître donne O(1) temps trié amorti (voir l'analyse du coût d'insertion dans une table de hachage pour la preuve que la clé est la croissance exponentielle). Grandir à la fin pour un nouveau max est facile avec std::vector::resize pour ajouter de nouveaux éléments mis à zéro. Changer min à la volée et insérer de nouveaux éléments mis à zéro à l'avant peut être fait avec std::copy_backward après la croissance du vecteur. Ensuite, std::fill pour mettre à zéro les nouveaux éléments.

  • La boucle d'incrémentation counts est un histogramme. Si les données sont susceptibles d’être très répétitives et que le nombre de bacs est réduit, il peut être intéressant de dérouler sur plusieurs baies == de réduire le goulot d’étranglement de stockage/rechargement en dépendance de données en sérialisation le même bac. Cela signifie plus de comptages à zéro au début et plus à boucler à la fin, mais cela devrait valoir la peine pour la plupart des processeurs pour notre exemple de millions de 0 à 100 nombres, surtout si l'entrée peut déjà être triée (partiellement) et avoir de longues courses du même nombre.

  • Dans l'algorithme ci-dessus, nous utilisons un contrôle min == max pour revenir plus tôt lorsque tous les éléments ont 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 recherchant les valeurs extrêmes d'une collection sans perte de temps supplémentaire (si le premier passage est toujours goulot de mémoire avec le travail supplémentaire de mise à jour des valeurs 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 de la sorte de comptage elle-même. Il est laissé comme un exercice pour le lecteur.

  • Comme l'algorithme ne fonctionne qu'avec des valeurs entières, des assertions statiques pourraient être utilisées pour empêcher les utilisateurs de commettre 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: liaisons structurées et certaines parties du Ranges TS rendraient l'algorithme encore plus propre.

14
Morwenn