Notez que cette réponse a été mise à jour pour répondre à tous les problèmes mentionnés dans les commentaires ci-dessous et après la question, en effectuant le même changement d'un tableau de listes à un tableau d'itérateurs, tout en conservant l'algorithme de tri par fusion ascendante plus rapide, et en éliminant le faible risque de débordement de pile dû à la récursion avec l'algorithme de tri par fusion descendante.
La raison pour laquelle je n'avais pas envisagé les itérateurs à l'origine était due au passage de VS2015 à l'algorithme top down, ce qui m'a amené à penser qu'il y avait un problème à essayer de modifier l'algorithme bottom up existant pour utiliser les itérateurs, ce qui nécessitait de passer à l'algorithme top down plus lent. Ce n'est que lorsque j'ai essayé d'analyser moi-même le passage aux itérateurs que j'ai réalisé qu'il y avait une solution pour l'algorithme bottom up.
Dans le commentaire de @sbi, il a demandé à l'auteur de l'approche top down, Stephan T. Lavavej, pourquoi ce changement a été effectué. La réponse de Stephan a été "pour éviter l'allocation de mémoire et les allocateurs constructibles par défaut". VS2015 a introduit les allocateurs non-constructibles par défaut et stateful, ce qui présente un problème lors de l'utilisation du tableau de listes de la version précédente, car chaque instance d'une liste alloue un nœud fictif, et un changement serait nécessaire pour gérer l'absence d'allocateur par défaut.
La solution de Lavavej a été de passer à l'utilisation d'itérateurs pour garder la trace des limites d'exécution dans la liste originale au lieu d'un tableau interne de listes. La logique de fusion a été modifiée pour utiliser 3 paramètres d'itérateurs, le 1er paramètre est l'itérateur au début de l'exécution gauche, le 2ème paramètre est l'itérateur à la fin de l'exécution gauche == itérateur au début de l'exécution droite, le 3ème paramètre est l'itérateur à la fin de l'exécution droite. Le processus de fusion utilise std::list::splice pour déplacer les noeuds dans la liste originale pendant les opérations de fusion. Cela présente l'avantage supplémentaire d'être sans danger pour les exceptions. Si la fonction de comparaison de l'appelant lève une exception, la liste sera réordonnée, mais aucune perte de données ne se produira (en supposant que le splice ne peut pas échouer). Avec le schéma précédent, certaines (ou la plupart) des données se trouveraient dans le tableau interne des listes si une exception se produisait, et des données seraient perdues dans la liste d'origine.
Toutefois, le passage au tri par fusion descendante n'était pas nécessaire. Initialement, pensant qu'il y avait une raison inconnue pour moi pour le passage de VS2015 au tri descendant, je me suis concentré sur l'utilisation des interfaces internes de la même manière que std::list::splice. J'ai ensuite décidé d'étudier la possibilité de passer du bas vers le haut pour utiliser un tableau d'itérateurs. J'ai réalisé que l'ordre des exécutions stockées dans le tableau interne était du plus récent (array[0] = le plus à droite) au plus ancien (array[last] = le plus à gauche), et qu'il pouvait utiliser la même logique de fusion basée sur les itérateurs que l'approche top down de VS2015.
Pour le tri par fusion de bas en haut, array[i] est un itérateur vers le début d'une sous-liste triée avec 2^i noeuds, ou il est vide (en utilisant std::list::end pour indiquer vide). La fin de chaque sous-liste triée sera le début d'une sous-liste triée dans la prochaine entrée antérieure non vide du tableau, ou si elle est au début du tableau, dans un itérateur local (il pointe vers la fin de la dernière exécution). Comme dans l'approche descendante, le tableau d'itérateurs n'est utilisé que pour garder la trace des limites des parcours triés dans la liste liée originale, tandis que le processus de fusion utilise std::list::splice pour déplacer les noeuds dans la liste liée originale.
Si une liste chaînée est grande et que les nœuds sont dispersés, il y aura beaucoup de manques dans le cache. La méthode ascendante sera environ 30% plus rapide que la méthode descendante (ce qui équivaut à dire que la méthode descendante est environ 42% plus lente que la méthode ascendante). Par ailleurs, si la mémoire est suffisante, il est généralement plus rapide de déplacer la liste dans un tableau ou un vecteur, de trier le tableau ou le vecteur, puis de créer une nouvelle liste à partir du tableau ou du vecteur trié.
Exemple de code C++ :
#define ASZ 32
template <typename T>
void SortList(std::list<T> &ll)
{
if (ll.size() < 2) // return if nothing to do
return;
std::list<T>::iterator ai[ASZ]; // array of iterators
std::list<T>::iterator mi; // middle iterator (end lft, bgn rgt)
std::list<T>::iterator ei; // end iterator
size_t i;
for (i = 0; i < ASZ; i++) // "clear" array
ai[i] = ll.end();
// merge nodes into array
for (ei = ll.begin(); ei != ll.end();) {
mi = ei++;
for (i = 0; (i < ASZ) && ai[i] != ll.end(); i++) {
mi = Merge(ll, ai[i], mi, ei);
ai[i] = ll.end();
}
if(i == ASZ)
i--;
ai[i] = mi;
}
// merge array into single list
ei = ll.end();
for(i = 0; (i < ASZ) && ai[i] == ei; i++);
mi = ai[i++];
while(1){
for( ; (i < ASZ) && ai[i] == ei; i++);
if (i == ASZ)
break;
mi = Merge(ll, ai[i++], mi, ei);
}
}
template <typename T>
typename std::list<T>::iterator Merge(std::list<T> &ll,
typename std::list<T>::iterator li,
typename std::list<T>::iterator mi,
typename std::list<T>::iterator ei)
{
std::list<T>::iterator ni;
(*mi < *li) ? ni = mi : ni = li;
while(1){
if(*mi < *li){
ll.splice(li, ll, mi++);
if(mi == ei)
return ni;
} else {
if(++li == mi)
return ni;
}
}
}
Exemple de code de remplacement pour std::list::sort() de VS2019 (la logique de fusion a été transformée en une fonction interne distincte, car elle est maintenant utilisée à deux endroits).
private:
template <class _Pr2>
iterator _Merge(_Pr2 _Pred, iterator _First, iterator _Mid, iterator _Last){
iterator _Newfirst = _First;
for (bool _Initial_loop = true;;
_Initial_loop = false) { // [_First, _Mid) and [_Mid, _Last) are sorted and non-empty
if (_DEBUG_LT_PRED(_Pred, *_Mid, *_First)) { // consume _Mid
if (_Initial_loop) {
_Newfirst = _Mid; // update return value
}
splice(_First, *this, _Mid++);
if (_Mid == _Last) {
return _Newfirst; // exhausted [_Mid, _Last); done
}
}
else { // consume _First
++_First;
if (_First == _Mid) {
return _Newfirst; // exhausted [_First, _Mid); done
}
}
}
}
template <class _Pr2>
void _Sort(iterator _First, iterator _Last, _Pr2 _Pred,
size_type _Size) { // order [_First, _Last), using _Pred, return new first
// _Size must be distance from _First to _Last
if (_Size < 2) {
return; // nothing to do
}
const size_t _ASZ = 32; // array size
iterator _Ai[_ASZ]; // array of iterators to runs
iterator _Mi; // middle iterator
iterator _Li; // last (end) iterator
size_t _I; // index to _Ai
for (_I = 0; _I < _ASZ; _I++) // "empty" array
_Ai[_I] = _Last; // _Ai[] == _Last => empty entry
// merge nodes into array
for (_Li = _First; _Li != _Last;) {
_Mi = _Li++;
for (_I = 0; (_I < _ASZ) && _Ai[_I] != _Last; _I++) {
_Mi = _Merge(_Pass_fn(_Pred), _Ai[_I], _Mi, _Li);
_Ai[_I] = _Last;
}
if (_I == _ASZ)
_I--;
_Ai[_I] = _Mi;
}
// merge array runs into single run
for (_I = 0; _I < _ASZ && _Ai[_I] == _Last; _I++);
_Mi = _Ai[_I++];
while (1) {
for (; _I < _ASZ && _Ai[_I] == _Last; _I++);
if (_I == _ASZ)
break;
_Mi = _Merge(_Pass_fn(_Pred), _Ai[_I++], _Mi, _Last);
}
}
Le reste de cette réponse est historique.
J'ai pu reproduire le problème (l'ancien tri ne compile pas, le nouveau fonctionne) en me basant sur une démo de @IgorTandetnik :
#include <iostream>
#include <list>
#include <memory>
template <typename T>
class MyAlloc : public std::allocator<T> {
public:
MyAlloc(T) {} // suppress default constructor
template <typename U>
MyAlloc(const MyAlloc<U>& other) : std::allocator<T>(other) {}
template< class U > struct rebind { typedef MyAlloc<U> other; };
};
int main()
{
std::list<int, MyAlloc<int>> l(MyAlloc<int>(0));
l.push_back(3);
l.push_back(0);
l.push_back(2);
l.push_back(1);
l.sort();
return 0;
}
J'ai remarqué ce changement en juillet 2016 et j'ai envoyé un courriel à P.J. Plauger à ce sujet le 1er août 2016. Voici un extrait de sa réponse :
Il est intéressant de noter que notre journal des modifications ne reflète pas ce changement. Cela probablement que cela a été "suggéré" par l'un de nos plus gros clients et qu'il et que je l'ai eu lors de la revue de code. Tout ce que je sais maintenant, c'est que le changement est arrivé aux alentours de l'automne 2015. Quand j'ai revu le code, la première chose qui m'a frappé était la ligne :
iterator _Mid = _STD next(_First, _Size / 2);
qui, bien sûr, peut prendre un muy longtemps pour une grande liste.
Le code semble un peu plus élégant que ce que j'ai écrit au début de 1995( !), mais sa complexité temporelle est bien pire. Cette version était modelée après l'approche de Stepanov, Lee, et Musser dans la STL originale. Il est rare qu'ils se trompent dans leur choix d'algorithmes.
Je reviens maintenant à notre dernière bonne version connue du code original.
Je ne sais pas si le retour au code d'origine de P.J. Plauger a permis de résoudre le problème du nouvel allocateur, ni si ou comment Microsoft interagit avec Dinkumware.
Pour comparer les méthodes descendantes et ascendantes, j'ai créé une liste chaînée de 4 millions d'éléments, chacun consistant en un entier non signé de 64 bits, en supposant que je me retrouverais avec une liste doublement chaînée de nœuds ordonnés presque séquentiellement (même s'ils seraient alloués dynamiquement), je les ai remplis de nombres aléatoires, puis je les ai triés. Les noeuds ne se déplacent pas, seul le lien est modifié, mais maintenant, en parcourant la liste, on accède aux noeuds dans un ordre aléatoire. J'ai ensuite rempli ces nœuds dans un ordre aléatoire avec un autre ensemble de nombres aléatoires et je les ai triés à nouveau. J'ai comparé l'approche descendante de 2015 avec l'approche ascendante antérieure, modifiée pour correspondre aux autres changements apportés en 2015 (sort() appelle désormais sort() avec une fonction de comparaison de prédicats, au lieu d'avoir deux fonctions distinctes). Voici les résultats. mise à jour - J'ai ajouté une version basée sur les pointeurs de nœuds et j'ai également noté le temps nécessaire pour simplement créer un vecteur à partir de la liste, trier le vecteur, le recopier.
sequential nodes: 2015 version 1.6 seconds, prior version 1.5 seconds
random nodes: 2015 version 4.0 seconds, prior version 2.8 seconds
random nodes: node pointer based version 2.6 seconds
random nodes: create vector from list, sort, copy back 1.25 seconds
Pour les nœuds séquentiels, la version antérieure est à peine plus rapide, mais pour les nœuds aléatoires, la version antérieure est 30 % plus rapide, et la version avec pointeur de nœud 35 % plus rapide, et la création d'un vecteur à partir de la liste, le tri du vecteur, puis la recopie est 69 % plus rapide.
Ci-dessous le premier code de remplacement pour std::list::sort() que j'ai utilisé pour comparer la méthode antérieure bottom up avec petit tableau (_BinList[]) par rapport à l'approche top down de VS2015 Je voulais que la comparaison soit équitable, j'ai donc modifié une copie de < list >.
void sort()
{ // order sequence, using operator<
sort(less<>());
}
template<class _Pr2>
void sort(_Pr2 _Pred)
{ // order sequence, using _Pred
if (2 > this->_Mysize())
return;
const size_t _MAXBINS = 25;
_Myt _Templist, _Binlist[_MAXBINS];
while (!empty())
{
// _Templist = next element
_Templist._Splice_same(_Templist.begin(), *this, begin(),
++begin(), 1);
// merge with array of ever larger bins
size_t _Bin;
for (_Bin = 0; _Bin < _MAXBINS && !_Binlist[_Bin].empty();
++_Bin)
_Templist.merge(_Binlist[_Bin], _Pred);
// don't go past end of array
if (_Bin == _MAXBINS)
_Bin--;
// update bin with merged list, empty _Templist
_Binlist[_Bin].swap(_Templist);
}
// merge bins back into caller's list
for (size_t _Bin = 0; _Bin < _MAXBINS; _Bin++)
if(!_Binlist[_Bin].empty())
this->merge(_Binlist[_Bin], _Pred);
}
J'ai fait quelques changements mineurs. Le code original gardait la trace du bac maximum réel dans une variable nommée _Maxbin, mais l'overhead dans la fusion finale est suffisamment faible pour que je supprime le code associé à _Maxbin. Pendant la construction du tableau, la boucle interne du code original fusionnait dans un élément _Binlist[], suivi d'un swap dans _Templist, ce qui semblait inutile. J'ai modifié la boucle interne pour qu'elle se contente de fusionner dans _Templist, et qu'elle ne permute que lorsqu'un élément _Binlist[] vide est trouvé.
Vous trouverez ci-dessous un remplacement de std::list::sort() basé sur les pointeurs de nœuds que j'ai utilisé pour une autre comparaison. Cela élimine les problèmes liés à l'allocation. Si une exception de comparaison est possible et se produit, tous les noeuds du tableau et de la liste temporaire (pNode) devraient être ajoutés à la liste originale, ou éventuellement une exception de comparaison pourrait être traitée comme une comparaison inférieure à la comparaison.
void sort()
{ // order sequence, using operator<
sort(less<>());
}
template<class _Pr2>
void sort(_Pr2 _Pred)
{ // order sequence, using _Pred
const size_t _NUMBINS = 25;
_Nodeptr aList[_NUMBINS]; // array of lists
_Nodeptr pNode;
_Nodeptr pNext;
_Nodeptr pPrev;
if (this->size() < 2) // return if nothing to do
return;
this->_Myhead()->_Prev->_Next = 0; // set last node ->_Next = 0
pNode = this->_Myhead()->_Next; // set ptr to start of list
size_t i;
for (i = 0; i < _NUMBINS; i++) // zero array
aList[i] = 0;
while (pNode != 0) // merge nodes into array
{
pNext = pNode->_Next;
pNode->_Next = 0;
for (i = 0; (i < _NUMBINS) && (aList[i] != 0); i++)
{
pNode = _MergeN(_Pred, aList[i], pNode);
aList[i] = 0;
}
if (i == _NUMBINS)
i--;
aList[i] = pNode;
pNode = pNext;
}
pNode = 0; // merge array into one list
for (i = 0; i < _NUMBINS; i++)
pNode = _MergeN(_Pred, aList[i], pNode);
this->_Myhead()->_Next = pNode; // update sentinel node links
pPrev = this->_Myhead(); // and _Prev pointers
while (pNode)
{
pNode->_Prev = pPrev;
pPrev = pNode;
pNode = pNode->_Next;
}
pPrev->_Next = this->_Myhead();
this->_Myhead()->_Prev = pPrev;
}
template<class _Pr2>
_Nodeptr _MergeN(_Pr2 &_Pred, _Nodeptr pSrc1, _Nodeptr pSrc2)
{
_Nodeptr pDst = 0; // destination head ptr
_Nodeptr *ppDst = &pDst; // ptr to head or prev->_Next
if (pSrc1 == 0)
return pSrc2;
if (pSrc2 == 0)
return pSrc1;
while (1)
{
if (_DEBUG_LT_PRED(_Pred, pSrc2->_Myval, pSrc1->_Myval))
{
*ppDst = pSrc2;
pSrc2 = *(ppDst = &pSrc2->_Next);
if (pSrc2 == 0)
{
*ppDst = pSrc1;
break;
}
}
else
{
*ppDst = pSrc1;
pSrc1 = *(ppDst = &pSrc1->_Next);
if (pSrc1 == 0)
{
*ppDst = pSrc2;
break;
}
}
}
return pDst;
}
1 votes
"ce qui n'était pas nécessairement une opération O(1) avant C++11" est sans importance. Ils l'écrivent pour leur propre implémentation, qui a O(1)
size()
.1 votes
@T.C.: Oui, mais O(1)
size()
ne fait pas beaucoup de différence ici. C'est seulement utile une fois - au tout plus haut niveau de récursion. Avoir O(1)size()
seul n'est pas suffisant pour justifier cet algorithme. Le principal problème que j'ai avec cela est O(n)std::next
à chaque niveau de récursion et cela n'est pas vraiment lié à O(1)size()
tout en haut.0 votes
Vous avez parfaitement raison : cette implémentation ne peut pas être optimale. Ce qui prouve seulement que les implémentations standard ne sont pas toujours optimales. Surtout dans les implémentations standard de C++, il semble y avoir pas mal de code superflu.
1 votes
@cmaster: Votre déclaration est tout simplement fausse. Notez que le prix théorique pour trouver le point médian est O(N), et nous le faisons à des profondeurs O(log N), donc le coût total est de O(N log N). Le tri était et est de O(N log N) de toute façon, donc la limite théorique ne change pas. Et pour les performances pratiques, vous devez tenir compte du matériel réel.
2 votes
@mSalters La complexité n'est pas modifiée, et je ne l'ai jamais dit. Cependant, en scannant deux fois jusqu'au milieu de la liste, l'algorithme perd un facteur constant de temps, rendant l'algorithme suboptimal. Si l'on se basait uniquement sur la complexité, nous devrions toujours utiliser le tri par base directe car c'est
O(n)
, ce qui est une meilleure complexité que leO(log(n))
obtenu par quicksort et compagnie. Néanmoins, le tri par base directe a un si grand terme constant qu'il est plus lent que quicksort dans tous les cas pertinents, rendant le tri par base directe inutile. N'oubliez jamais les constantes!0 votes
La fonction
sort
de la bibliothèque libc++ utilise également l'approche top-down.0 votes
@cmaster: Le tri par base ne peut pas fonctionner pour
std::list::sort
car cela nécessite uniquementstd::less
. En ce qui concerne le facteur constant, cela revient à faire des hypothèses sur le comportement du cache. La marche dans la liste semble être le moyen le plus rapide pour mettre la première moitié de la liste dans le cache.1 votes
@MSalters - la marche de la liste n'aide pas si les nœuds d'une grande liste sont dispersés de manière aléatoire dans la mémoire, auquel cas vous obtenez un nœud par ligne de cache (voir ma réponse pour la comparaison du bas vers le haut versus le haut vers le bas).
0 votes
@mSalters Veuillez lire la réponse de rcgldr et ensuite réévaluer votre affirmation selon laquelle ma "déclaration est simplement fausse". Je manquais de la preuve que rcgldr a déterrée (il a certainement gagné mon +1 pour cela), mais je sais lire le code et évaluer ses impacts sur les performances (c'est ce que j'ai fait et sur quoi ma déclaration était basée). Alors, s'il vous plaît, soyez un peu plus lent à l'avenir lorsque vous qualifiez les déclarations des autres de fausses.
7 votes
Tout droit sorti de la bouche du cheval : "J'ai fait cela pour éviter l'allocation de mémoire et la construction par défaut d'allocateurs." – Stephan T. Lavavej
0 votes
@sbi - Merci. J'ai mis à jour ma réponse. Je ne savais pas qui contacter d'autre que P. J. Plauger car son est le seul nom affiché dans les droits d'auteur des fichiers STL (du moins tous ceux que j'ai lus).
0 votes
@AnT - La raison donnée était "pour éviter l'allocation de mémoire et la construction par défaut des allocateurs", ce qui a été accompli en utilisant des itérateurs, et T.C a ajouté à cela "la sécurité de base des exceptions gratuite", ce qui a été accompli car la fusion est effectuée via des splices qui n'opèrent maintenant que sur la liste de l'appelant. Cependant, il n'était pas nécessaire de passer à une stratégie de haut en bas, car il s'est avéré qu'une stratégie de bas en haut fonctionne également avec des itérateurs (un petit tableau d'entre eux) et la même fusion via splice sur la liste de l'appelant. J'ai mis à jour ma réponse pour inclure un code d'exemple pour cela (il est maintenant près du haut de ma réponse).
0 votes
@sbi - Le passage à l'utilisation d'itérateurs au lieu d'un tableau de listes a évité les problèmes d'allocation et a également assuré la sécurité des exceptions, mais le passage au haut en bas n'était pas nécessaire, car une approche de bas en haut peut également être mise en œuvre en utilisant des itérateurs et la même logique de fusion. J'ai mis à jour ma réponse avec une explication et un code d'exemple pour montrer comment cela pourrait être fait. Il s'agit d'un commentaire tardif, car je pensais avoir fait ce commentaire il y a longtemps.
0 votes
@rcgldr : Tu devrais dire ça à STL, pas à moi. Je n'étais que le messager.
0 votes
@sbi - À l'époque, j'ai correspondu avec P J Plauger de Dinkumware, la société qui a travaillé ou travaille avec Microsoft sur les modèles basés sur le code de modèle de 1994 ou antérieur écrit par HP (regardez le droit d'auteur à la fin des fichiers source de modèle). Je sais que Dinkumware n'a pas adopté l'approche descendante utilisée dans Visual Studio, mais je ne sais pas ce qu'ils ont finalement fait.
0 votes
@sbi - la dernière fois que j'ai vérifié, Visual Studio 2019 utilisait toujours le top down. Un des membres de l'équipe (je ne sais pas qui) a affirmé que la performance n'était pas un objectif pour les modèles (ce qui est contesté par P J Plauger et l'ancienne équipe de HP), et que pour la performance, il serait plus rapide de copier la liste dans un tableau, trier le tableau, puis créer une liste triée à partir du tableau (semble être une excuse pour un mauvais choix en haut en bas). À mon avis, je pense que les personnes impliquées dans le passage aux itérateurs n'ont pas réalisé que l'approche antérieure basée sur le bas vers le haut pouvait être adaptée pour fonctionner avec les itérateurs.
0 votes
@rcgldr: Tu devrais dire ça à STL, pas à moi. Je n'étais que le messager.
0 votes
@sbi - Je pense avoir envoyé un e-mail à quelqu'un chez STL, mais je ne me souviens pas qui. Comme commenté ci-dessus, P J Plauger est revenu à la version précédente de bas en haut et ils allaient trouver une solution alternative. Je ne sais pas ce qu'il s'est passé. Je n'ai jamais reçu de réponse de la part de STL. Mon impression est que c'est une faible priorité pour Microsoft, et que P J Plauger a déjà une solution alternative à présent.
0 votes
@rcgldr : "STL" fait référence à Stephan T. Lavavej, l'homme qui dirige le département de développement de la bibliothèque standard chez Microsoft, et que je citais. Je ne pense pas que vous puissiez envoyer un e-mail "à quelqu'un chez Stephan".
0 votes
@sbi - Je lui ai envoyé un tweet sur son compte Twitter le 24 avril 2019. Je n'ai jamais reçu de réponse.