Quel est l'intérêt d'utiliser des listes sur des vecteurs, en C ++?

32

J'ai exécuté 3 expériences différentes impliquant des listes et des vecteurs C ++.

Ceux avec des vecteurs se sont révélés plus efficaces, même lorsque de nombreuses insertions au milieu étaient impliquées.

D'où la question: dans quel cas les listes ont-elles plus de sens que les vecteurs?

Si les vecteurs semblent plus efficaces dans la plupart des cas, et compte tenu de la ressemblance de leurs membres, quels avantages restent-ils pour les listes?

  1. Générez N entiers et placez-les dans un conteneur afin que le conteneur reste trié. L'insertion a été effectuée naïvement, en lisant les éléments un par un et en insérant le nouveau juste avant le premier plus grand.
    Avec une liste, le temps passe par le toit lorsque la dimension augmente, par rapport aux vecteurs.

  2. Insérez N entiers à la fin du conteneur.
    Pour les listes et les vecteurs, le temps a augmenté du même ordre de grandeur, bien qu'il ait été 3 fois plus rapide avec les vecteurs.

  3. Insérez N entiers dans un conteneur.
    Démarrer la minuterie.
    Triez le conteneur en utilisant list.sort pour les listes et std :: sort pour les vecteurs. Arrêtez la minuterie.
    Encore une fois, le temps augmente du même ordre de grandeur, mais il est en moyenne 5 fois plus rapide avec les vecteurs.

Je pourrais continuer à effectuer des tests et à trouver quelques exemples où les listes s'avéreraient meilleures.

Mais l'expérience commune de vous qui lisez ce message pourrait fournir des réponses plus productives.

Vous avez peut-être rencontré des situations où les listes étaient plus faciles à utiliser ou plus performantes?

Marek Stanley
la source
2
Vous devriez jeter un œil à Quand utiliser une liste chaînée sur un tableau / liste de tableaux? si vous n'avez pas déjà
Karthik T
1
Voici une autre bonne ressource sur le sujet: stackoverflow.com/a/2209564/8360 également, la plupart des conseils C ++ que j'ai entendu est d'utiliser le vecteur par défaut, ne listez que si vous avez une raison spécifique.
Zachary Yates
Merci. Cependant, je ne suis pas d'accord avec la plupart de ce qui est dit dans la réponse préférée. La plupart de ces idées préconçues ont été invalidées par mes expériences. Cette personne n'a fait aucun test et appliqué une théorie largement répandue enseignée dans les livres ou à l'école.
Marek Stanley
1
Un fait listprobablement mieux si vous supprimez beaucoup d'éléments. Je ne crois pas qu'une vectorvolonté rendra la mémoire au système jusqu'à ce que le vecteur entier soit supprimé. Gardez également à l'esprit que votre test n ° 1 ne teste pas le temps d'insertion seul. C'est un test combinant recherche et insertion. C'est de trouver l'endroit où insérer où listest lent. L'insertion réelle sera plus rapide que le vecteur.
Gort the Robot
3
Il est si typique que cette question soit décrite en termes de performances (d'exécution), de performances et uniquement de performances. Cela semble être un angle mort pour beaucoup de programmeurs - ils se concentrent sur cet aspect et oublient qu'il existe des dizaines d'autres aspects qui sont souvent beaucoup, beaucoup plus importants.
Doc Brown

Réponses:

34

La réponse courte est que les cas semblent rares. Il y en a probablement quelques-uns cependant.

La première serait que vous ayez besoin de stocker un petit nombre de gros objets - en particulier, des objets si grands qu'il est impossible d'allouer de l'espace pour quelques-uns d'entre eux en plus. Il n'y a fondamentalement aucun moyen d'empêcher un vecteur ou deque d'allouer de l'espace pour des objets supplémentaires - c'est ainsi qu'ils sont définis (c'est-à-dire qu'ils doivent allouer de l'espace supplémentaire pour répondre à leurs exigences de complexité). Si vous ne pouvez pas allouer cet espace supplémentaire, un std::listpeut être le seul conteneur standard qui répond à vos besoins.

Un autre serait quand / si vous stockez un itérateur vers un point "intéressant" dans une liste pendant une longue période de temps, et lorsque vous effectuez des insertions et / ou des suppressions, vous le faites (presque) toujours à partir d'un emplacement auquel vous avez déjà un itérateur, donc vous ne parcourez pas la liste pour arriver au point où vous allez faire l'insertion ou la suppression. Évidemment, la même chose s'applique si vous travaillez avec plus d'un emplacement, mais prévoyez toujours de stocker un itérateur à chaque endroit avec lequel vous travaillerez probablement, de sorte que vous manipulez le plus les emplacements que vous pouvez atteindre directement et que vous parcourez rarement la liste pour obtenir à ces endroits.

Pour un exemple du premier, considérez un navigateur Web. Il peut conserver une liste liée d' Tabobjets, chaque objet tab représentant un onglet ouvert dans le navigateur. Chaque onglet peut contenir quelques dizaines de mégaoctets de données (en plus, surtout si quelque chose comme une vidéo est impliqué). Votre nombre typique d'onglets ouverts peut facilement être inférieur à une douzaine, et 100 est probablement proche de l'extrême supérieur.

Pour un exemple de la seconde, considérons un traitement de texte qui stocke le texte sous forme de liste chaînée de chapitres, chacun pouvant contenir une liste chaînée de (disons) paragraphes. Lorsque l'utilisateur modifie, il va généralement trouver un endroit particulier où il va éditer, puis faire une bonne quantité de travail à cet endroit (ou à l'intérieur de ce paragraphe, de toute façon). Oui, ils vont passer d'un paragraphe à l'autre de temps en temps, mais dans la plupart des cas, ce sera un paragraphe près de l'endroit où ils travaillaient déjà.

De temps en temps (des choses comme la recherche globale et le remplacement), vous finissez par parcourir tous les éléments de toutes les listes, mais c'est assez rare, et même si vous le faites, vous allez probablement faire assez de travail pour rechercher un élément dans la liste, que le temps de parcourir la liste est presque sans conséquence.

Notez que dans un cas typique, cela est susceptible de correspondre également au premier critère - un chapitre contient un nombre assez petit de paragraphes, dont chacun est susceptible d'être assez grand (au moins par rapport à la taille des pointeurs dans le nœud, etc.). De même, vous avez un nombre relativement faible de chapitres, chacun pouvant être de plusieurs kilo-octets environ.

Cela dit, je dois admettre que ces deux exemples sont probablement un peu artificiels, et même si une liste chaînée pourrait parfaitement fonctionner pour l'un ou l'autre, elle ne fournirait probablement pas un énorme avantage dans les deux cas également. Dans les deux cas, par exemple, l'allocation d'espace supplémentaire dans un vecteur pour certaines pages / onglets Web (vides) ou certains chapitres vides ne devrait pas poser de problème réel.

Jerry Coffin
la source
4
+1, mais: le premier cas disparaît lorsque vous utilisez des pointeurs, que vous devez toujours utiliser avec de gros objets. Les listes liées ne conviennent pas non plus à l'exemple de la seconde; les tableaux sont propres à toutes les opérations quand elles sont aussi courtes.
amara
2
Le grand boîtier d'objet ne fonctionne pas du tout. L'utilisation std::vectorde pointeurs sera plus efficace que tous ces objets de noeud de liste liée.
Winston Ewert
Il existe de nombreuses utilisations pour les listes chaînées - c'est juste qu'elles ne sont pas aussi courantes que les tableaux dynamiques. Un cache LRU est une utilisation courante d'une liste chaînée.
Charles Salvia
De plus, un std::vector<std::unique_ptr<T>>pourrait être une bonne alternative.
Déduplicateur
24

Selon Bjarne Stroustrup lui-même, les vecteurs devraient toujours être la collection par défaut pour les séquences de données. Vous pouvez choisir la liste si vous souhaitez optimiser pour l'insertion et la suppression d'éléments, mais normalement vous ne devriez pas. Les coûts de la liste sont la traversée lente et l'utilisation de la mémoire.

Il en parle dans cette présentation .

Vers 0:44, il parle de vecteurs vs listes en général.

La compacité est importante. Les vecteurs sont plus compacts que les listes. Et les modèles d'utilisation prévisibles sont extrêmement importants. Avec les vecteurs, vous devez repousser beaucoup d'éléments, mais les caches sont vraiment très bons dans ce domaine. ... Les listes n'ont pas d'accès aléatoire. Mais lorsque vous parcourez une liste, vous continuez à faire un accès aléatoire. Il y a un nœud ici, et il va à ce nœud, en mémoire. Ainsi, vous accédez en fait de manière aléatoire à votre mémoire et vous maximisez vos échecs de cache, ce qui est exactement le contraire de ce que vous voulez.

Vers 1 h 08, on lui pose une question à ce sujet.

Ce que nous devrions voir, c'est que nous avons besoin d'une séquence d'éléments. Et la séquence d'éléments par défaut en C ++ est le vecteur. Maintenant, parce que c'est compact et efficace. L'implémentation, le mappage au matériel, compte. Maintenant, si vous souhaitez optimiser l'insertion et la suppression - vous dites: «Eh bien, je ne veux pas la version par défaut d'une séquence. Je veux celui spécialisé, qui est une liste ». Et si vous faites cela, vous devriez en savoir suffisamment pour dire: «J'accepte certains coûts et certains problèmes, comme des traversées lentes et une utilisation accrue de la mémoire».

Pete
la source
1
Pourriez-vous écrire brièvement ce qui est dit dans la présentation que vous liez à "vers 0:44 et 1:08"?
gnat
2
@gnat - certainement. J'ai essayé de citer les choses qui ont du sens séparément, et qui nécessitent le contexte des diapositives.
Pete
11

Le seul endroit où j'utilise habituellement des listes est l'endroit où j'ai besoin d'effacer des éléments et de ne pas invalider les itérateurs. std::vectorinvalide tous les itérateurs lors de l'insertion et de l'effacement. std::listgarantit que les itérateurs des éléments existants sont toujours valides après insertion ou suppression.

UldisK
la source
4

En plus des autres réponses déjà fournies, les listes ont certaines fonctionnalités qui n'existent pas dans les vecteurs (car elles seraient incroyablement chères). Les opérations d'épissage et de fusion étant les plus importantes. Si vous avez fréquemment un tas de listes qui doivent être ajoutées ou fusionnées, une liste est probablement un bon choix.

Mais si vous n'avez pas besoin d'effectuer ces opérations, alors probablement pas.

David C.
la source
3

Le manque de cache inhérent / convivialité des pages des listes liées a tendance à les faire presque entièrement rejetées par de nombreux développeurs C ++, et avec une bonne justification sous cette forme par défaut.

Les listes liées peuvent toujours être merveilleuses

Pourtant, les listes chaînées peuvent être merveilleuses lorsqu'elles sont soutenues par un allocateur fixe qui leur redonne cette localité spatiale qui leur manque intrinsèquement.

Là où ils excellent, c'est que nous pouvons diviser une liste en deux listes, par exemple, simplement en stockant un nouveau pointeur et en manipulant un ou deux pointeurs. Nous pouvons déplacer des nœuds d'une liste à une autre en temps constant par simple manipulation de pointeur, et une liste vide peut simplement avoir le coût en mémoire d'un seul headpointeur.

Accélérateur de grille simple

À titre d'exemple pratique, considérons une simulation visuelle 2D. Il a un écran de défilement avec une carte qui s'étend sur 400x400 (160 000 cellules de la grille) utilisé pour accélérer des choses comme la détection de collision entre des millions de particules se déplaçant à chaque image (nous évitons ici les quadruples car ils ont tendance à avoir de moins bonnes performances avec ce niveau de données dynamiques). Un tas de particules se déplacent constamment à chaque image, ce qui signifie qu'elles passent constamment d'une cellule de grille à une autre.

Dans ce cas, si chaque particule est un nœud de liste à liaison unique, chaque cellule de la grille peut commencer comme un simple headpointeur pointant vers nullptr. Lorsqu'une nouvelle particule est née, nous la plaçons simplement dans la cellule de grille où elle se trouve en définissant le headpointeur de cette cellule pour pointer vers ce nœud de particule. Lorsqu'une particule se déplace d'une cellule de grille à la suivante, nous manipulons simplement des pointeurs.

Cela peut être beaucoup plus efficace que de stocker 160 000 vectorspour chaque cellule de la grille et de repousser et d'effacer tout le temps du milieu image par image.

std :: list

Il s'agit cependant de listes roulées à la main, intrusives et liées individuellement, soutenues par un allocateur fixe. std::listreprésente une liste doublement liée et elle peut ne pas être aussi compacte lorsqu'elle est vide qu'un seul pointeur (varie selon l'implémentation du fournisseur), en plus c'est un peu difficile d'implémenter des allocateurs personnalisés sous std::allocatorforme.

Je dois admettre que je n'en utilise jamais list. Mais les listes chaînées peuvent toujours être merveilleuses! Pourtant, ils ne sont pas merveilleux pour les raisons pour lesquelles les gens sont souvent tentés de les utiliser, et pas si merveilleux à moins qu'ils ne soient soutenus par un allocateur fixe très efficace qui atténue au moins les défauts de page obligatoires et les manquements de cache associés.


la source
1
Il y a une liste standard des liaisons simples liés depuis C ++ 11, std::forward_list.
sharyex
2

Vous devez considérer la taille des éléments dans le conteneur.

int Le vecteur d'éléments est très rapide car la plupart des données tiennent dans le cache CPU (et les instructions SIMD peuvent probablement être utilisées pour la copie de données).

Si la taille de l'élément est supérieure, le résultat des tests 1 et 3 pourrait changer considérablement.

D'une comparaison très complète des performances :

Cela permet de tirer des conclusions simples sur l'utilisation de chaque structure de données:

  • Chiffrage des nombres: utilisez std::vectoroustd::deque
  • Recherche linéaire: utilisez std::vectoroustd::deque
  • Insertion / suppression aléatoire:
    • Petite taille de données: utiliser std::vector
    • Grande taille d'élément: utiliser std::list(sauf si elle est principalement destinée à la recherche)
  • Type de données non trivial: à utiliser std::listsauf si vous avez besoin du conteneur spécialement pour la recherche. Mais pour de multiples modifications du conteneur, ce sera très lent.
  • Poussez vers l'avant: utilisez std::dequeoustd::list

(comme une note annexe std::dequeest une structure de données très sous-estimée).

D'un point de vue pratique, std::listoffre la garantie que les itérateurs ne deviennent jamais invalides lorsque vous insérez et supprimez d'autres éléments. C'est souvent un aspect clé.

manlio
la source
2

À mon avis, la raison la plus importante d'utiliser des listes est l' invalidation des itérateurs : si vous ajoutez / supprimez des éléments à un vecteur, tous les pointeurs, références, itérateurs que vous déteniez sur des éléments particuliers de ce vecteur peuvent être invalidés et conduire à des bogues subtils. ou défauts de segmentation.

Ce n'est pas le cas des listes.

Les règles précises pour tous les conteneurs standard sont données dans ce post StackOverflow .

Jean-Michaël Celerier
la source
0

Bref, il n'y a pas de bonne raison d'utiliser std::list<>:

  • Si vous avez besoin d'un conteneur non trié, std::vector<>règles.
    (Supprimez les éléments en les remplaçant par le dernier élément du vecteur.)

  • Si vous avez besoin d'un conteneur trié, std::vector<shared_ptr<>>règles.

  • Si vous avez besoin d'un index clairsemé, des std::unordered_map<>règles.

C'est ça.

Je trouve qu'il n'y a qu'une seule situation où j'ai tendance à utiliser une liste chaînée: lorsque j'ai des objets préexistants qui doivent être connectés d'une manière ou d'une autre pour implémenter une logique d'application supplémentaire. Cependant, dans ce cas, je n'utilise jamais std::list<>, je préfère recourir à un pointeur suivant (intelligent) à l'intérieur de l'objet, d'autant plus que la plupart des cas d'utilisation aboutissent à un arbre plutôt qu'à une liste linéaire. Dans certains cas, la structure résultante est une liste chaînée, dans d'autres, c'est un arbre ou un graphe acyclique dirigé. Le but principal de ces pointeurs est toujours de construire une structure logique, jamais de gérer des objets. Nous avons std::vector<>pour ça.

cmaster
la source
-1

Vous devez montrer comment vous faisiez les inserts lors de votre premier test. Vos deuxième et troisième tests, vector gagneront facilement.

Une utilisation importante des listes est lorsque vous devez prendre en charge la suppression d'éléments lors de l'itération. Lorsque le vecteur est modifié, tous les itérateurs sont (potentiellement) invalides. Avec une liste, seul un itérateur de l'élément supprimé n'est pas valide. Tous les autres itérateurs restent valides.

L'ordre d'utilisation typique des conteneurs est vector, deque, puis list. Le choix du conteneur est généralement basé sur push_back choose vector, pop_front choose deque, insert choose list.

Bill Door
la source
3
lors de la suppression d'éléments pendant l'itération, il est généralement préférable d'utiliser un vecteur et de simplement créer un nouveau vecteur pour les résultats
amara
-1

Un facteur auquel je peux penser est qu'à mesure qu'un vecteur grandit, la mémoire libre se fragmente à mesure que le vecteur désalloue sa mémoire et alloue un bloc plus grand encore et encore. Ce ne sera pas un problème avec les listes.

Ceci s'ajoute au fait qu'un grand nombre de push_backs sans réserve provoquera également une copie lors de chaque redimensionnement ce qui le rend inefficace. L'insertion au milieu provoque également un déplacement de tous les éléments vers la droite, et est encore pire.

Je ne sais pas si c'est une préoccupation majeure, mais c'est la raison qui m'a été donnée lors de mon travail (développement de jeux mobiles), pour éviter les vecteurs.

Karthik T
la source
1
non, le vecteur copiera et c'est cher. Mais parcourir la liste chaînée (pour savoir où insérer) coûte aussi cher. La clé est en effet de mesurer
Kate Gregory
@KateGregory Je voulais dire en plus de cela, permettez-moi de modifier en conséquence
Karthik T
3
C'est vrai, mais croyez-le ou non (et la plupart des gens ne le croient pas) le coût que vous n'avez pas mentionné, parcourant la liste liée pour trouver où insérer OUTWEIGHS ces copies (surtout si les éléments sont petits (ou mobiles, car alors ce sont des mouvements)) et le vecteur est souvent (ou même généralement) plus rapide. Croyez-le ou non.
Kate Gregory