web-dev-qa-db-fra.com

Dans quelles circonstances les listes chaînées sont-elles utiles?

La plupart du temps, je vois des gens essayer d’utiliser des listes chaînées, c’est pour moi un choix pauvre (ou très pauvre). Peut-être serait-il utile d'explorer les circonstances dans lesquelles une liste chaînée constitue ou non un bon choix de structure de données.

Idéalement, les réponses préciseraient les critères à utiliser pour sélectionner une structure de données et les structures de données qui fonctionneraient le mieux dans certaines circonstances.

Edit: Je dois dire que je suis assez impressionné non seulement par le nombre, mais par la qualité des réponses. Je ne peux en accepter qu'un, mais il y en aurait deux ou trois de plus que j'aurais valu la peine d'accepter si quelque chose d'un peu mieux n'avait pas été là. Seul un couple (en particulier celui que j'ai finalement accepté) a signalé des situations dans lesquelles une liste chaînée offrait un réel avantage. Je pense que Steve Jessop mérite une mention honorable pour avoir fourni non pas une, mais trois réponses différentes, ce que j'ai trouvé assez impressionnant. Bien sûr, même s’il n’a été posté que comme commentaire, pas comme réponse, je pense que l’entrée de blog de Neil mérite également d'être lue - elle est non seulement informative, mais aussi très amusante.

102
Jerry Coffin

Ils peuvent être utiles pour les structures de données simultanées. (Il existe maintenant un exemple d'utilisation non concurrente dans le monde réel ci-dessous - il ne serait pas là si @Neil n'avait pas mentionné FORTRAN. ;-) 

Par exemple, ConcurrentDictionary<TKey, TValue> dans .NET 4.0 RC utilise des listes chaînées pour chaîner des éléments qui sont hachés vers le même compartiment.

La structure de données sous-jacente pour ConcurrentStack<T> est également une liste chaînée.

ConcurrentStack<T> est l'une des structures de données qui servent de base au nouveau pool de threads (essentiellement avec les "files d'attente" locales implémentées sous forme de piles). (L'autre structure de support principale étant ConcurrentQueue<T>.)

Le nouveau pool de threads constitue à son tour la base de la planification du travail du nouveau bibliothèque de tâches parallèle .

Elles peuvent donc certainement être utiles: une liste chaînée constitue actuellement l’une des principales structures de soutien d’au moins une excellente nouvelle technologie.

(Une liste à lien unique crée un choix convaincant de sans verrouillage mais pas sans attente dans ce cas, car les opérations principales peuvent être effectuées avec un seul CAS (+ tentatives) . Dans un environnement GC-d moderne - tel que Java et .NET - le problème ABA peut facilement être évité. Il vous suffit d'envelopper les éléments que vous ajoutez dans des nœuds fraîchement créés. et ne pas réutiliser ces nœuds - laissez le GC faire son travail . La page sur le problème ABA fournit également l'implémentation d'une pile sans verrouillage - qui fonctionne réellement en .Net (& Java) avec un (GC-ed) Nœud contenant les éléments.)

Edit: @ Neil: En fait, ce que vous avez mentionné à propos de Fortran m'a rappelé que le même type de listes chaînées se trouve dans la structure de données probablement la plus utilisée et la plus utilisée de .NET: le Dictionary<TKey, TValue> générique .NET simple.

Pas une, mais plusieurs listes chaînées sont stockées dans un tableau. 

  • Cela évite de faire beaucoup de petites (dés) allocations sur les insertions/suppressions.
  • Le chargement initial de la table de hachage est assez rapide, car le tableau est rempli séquentiellement (joue très bien avec le cache du processeur). 
  • Sans oublier qu'une table de hachage en chaîne coûte cher en mémoire - et cette astuce "coupe" la "taille du pointeur" de moitié sur x64.

Essentiellement, de nombreuses listes liées sont stockées dans un tableau. (une pour chaque compartiment utilisé.) Une liste libre de noeuds réutilisables est "entrelacée" entre eux (s'il y a eu des suppressions) . Un tableau est alloué au début/à la reprise et les noeuds de chaînes sont conservés il. Il existe également un pointeur free - un index dans le tableau - qui suit les suppressions. ;-) Alors, croyez-le ou non, la technique Fortran est toujours vivante. (... et nulle part ailleurs, que dans l'une des structures de données .NET les plus couramment utilisées ;-).

38
Andras Vass

Les listes chaînées sont très utiles lorsque vous devez effectuer de nombreuses insertions et suppressions, mais pas trop de recherche, sur une liste de longueur arbitraire (inconnue à la compilation).

La scission et la jonction de listes (liées de manière bidirectionnelle) sont très efficaces.

Vous pouvez également combiner des listes chaînées - par exemple Les arborescences peuvent être implémentées sous forme de listes chaînées "verticales" (relations parent/enfant) reliant des listes chaînées horizontales (frères).

L'utilisation d'une liste basée sur des tableaux à ces fins présente de sérieuses limitations:

  • L'ajout d'un nouvel élément signifie que le tableau doit être réaffecté (ou que vous devez allouer plus d'espace que nécessaire pour permettre la croissance future et réduire le nombre de réaffectations).
  • Supprimer des éléments laisse un espace perdu ou nécessite une réaffectation
  • insérer des éléments n'importe où sauf à la fin implique (éventuellement une réaffectation et) la copie d'une grande quantité de données d'une position
48
Jason Williams

Les listes chaînées sont très flexibles: avec la modification d'un pointeur, vous pouvez effectuer un changement massif, où la même opération serait très inefficace dans une liste de tableaux.

20
Chris Lercher

Les tableaux sont les structures de données auxquelles les listes chaînées sont généralement comparées.

Normalement, les listes chaînées sont utiles lorsque vous devez apporter beaucoup de modifications à la liste elle-même, tandis que les tableaux fonctionnent mieux que les listes sur un accès direct aux éléments.

Voici une liste des opérations pouvant être effectuées sur les listes et les tableaux, comparée au coût d'opération relatif (n = longueur de la liste/tableau):

  • Ajout d'un élément:
    • sur les listes, il vous suffit d'allouer de la mémoire pour le nouvel élément et de rediriger les pointeurs. O (1)
    • sur les tableaux, vous devez déplacer le tableau. Sur)
  • Retrait d'un élément
    • sur les listes, vous redirigez simplement les pointeurs. O (1).
    • sur les tableaux, vous passez O(n) temps à déplacer le tableau si l'élément à supprimer n'est pas le premier ou le dernier élément du tableau; sinon, vous pouvez simplement déplacer le pointeur au début du tableau ou diminuer la longueur du tableau
  • Obtenir un élément dans une position connue:
    • sur les listes, vous devez parcourir la liste du premier élément à l'élément dans la position spécifique. Le pire des cas: O (n)
    • sur les tableaux, vous pouvez accéder immédiatement à l'élément. O (1)

Il s'agit d'une comparaison très bas niveau de ces deux structures de données populaires et de base et vous pouvez voir que les listes fonctionnent mieux dans des situations où vous devez apporter beaucoup de modifications à la liste elle-même (suppression ou ajout d'éléments) . D'autre part, les tableaux fonctionnent mieux que les listes lorsque vous devez accéder directement aux éléments du tableau.

Du point de vue de l’allocation de mémoire, les listes sont meilleures car il n’est pas nécessaire d’avoir tous les éléments les uns à côté des autres. D'autre part, il y a la (petite) surcharge de stocker les pointeurs sur l'élément suivant (ou même sur l'élément précédent).

Il est important que les développeurs connaissent ces différences pour choisir entre des listes et des tableaux dans leurs implémentations.

Notez qu'il s'agit d'une comparaison de listes et de tableaux. Il existe de bonnes solutions aux problèmes signalés ici (par exemple: SkipLists, Dynamic Arrays, etc ...) . Dans cette réponse, j'ai pris en compte la structure de données de base que chaque programmeur devrait connaître.

14
Andrea Zilio

La liste à liens simples est un bon choix pour la liste libre dans un allocateur de cellules ou un pool d'objets:

  1. Vous n'avez besoin que d'une pile, une liste à lien unique est suffisante.
  2. Tout est déjà divisé en nœuds. Il n'y a pas de surcharge d'allocation pour un nœud de liste intrusif, à condition que les cellules soient suffisamment grandes pour contenir un pointeur.
  3. Un vecteur ou une deque imposerait une surcharge d'un pointeur par bloc. Cela est important étant donné que lorsque vous créez le segment de mémoire pour la première fois, toutes les cellules sont libres, il s'agit donc d'un coût initial. Dans le pire des cas, la capacité de mémoire requise est multipliée par deux.
4
Steve Jessop

La liste à double liens est un bon choix pour définir l'ordre d'une table de hachage qui définit également un ordre sur les éléments (LinkedHashMap en Java), en particulier lorsqu'il est commandé par le dernier accès:

  1. Plus de mémoire en surcharge qu'un vecteur ou un deque associé (2 pointeurs au lieu de 1), mais de meilleures performances d'insertion/suppression.
  2. Pas de surcharge d'allocation, car vous avez de toute façon besoin d'un nœud pour une entrée de hachage.
  3. La localisation de la référence n’est pas un problème supplémentaire par rapport à un vecteur ou à une combinaison de pointeurs, car il faudrait tirer chaque objet en mémoire de toute façon.

Bien sûr, vous pouvez vous demander si un cache LRU est une bonne idée en premier lieu, comparé à quelque chose de plus sophistiqué et ajustable, mais si vous en avez un, il s'agit d'une implémentation assez décente. Vous ne souhaitez pas effectuer de suppression ou supprimer une suppression à partir du milieu et à chaque fin de lecture à chaque accès en lecture, mais il est généralement préférable de déplacer un nœud vers la fin.

4
Steve Jessop

Ils sont utiles lorsque vous avez besoin de pousser, sauter et faire pivoter à grande vitesse, sans vous soucier de l'indexation O(n).

3

Les listes chaînées sont l’un des choix naturels lorsque vous ne pouvez pas contrôler où vos données sont stockées, mais vous devez quand même passer d’un objet à l’autre. 

Par exemple, lorsque vous implémentez le suivi de la mémoire en C++ (nouveau/supprimer, remplacement), vous avez également besoin d'une structure de données de contrôle qui garde la trace des pointeurs libérés, que vous devez pleinement implémenter. L'alternative consiste à sur-localiser et à ajouter une liste liée au début de chaque bloc de données.

Comme vous savez toujours immédiatement où vous vous trouvez dans la liste lorsque vous appelez la suppression, vous pouvez facilement perdre de la mémoire dans O (1). L'ajout d'un nouveau morceau qui vient d'être mallocé se fait également dans O (1). Parcourir la liste est très rarement nécessaire dans ce cas, de sorte que le coût O(n) n’est pas un problème ici (parcourir la structure est de O(n) de toute façon).

3
LiKao

Les listes simples sont la mise en œuvre évidente du type de données "liste" commun dans les langages de programmation fonctionnels:

  1. L'ajout à la tête est rapide, et (append (list x) (L)) et (append (list y) (L)) peuvent partager la quasi-totalité de leurs données. Pas besoin de copie sur écriture dans une langue sans écriture. Les programmeurs fonctionnels savent en tirer parti.
  2. L'ajout à la queue est malheureusement lent, mais il en serait de même pour toute autre implémentation.

Par comparaison, un vecteur ou un deque serait lent à ajouter à chaque extrémité, nécessitant (du moins dans mon exemple de deux annexes distinctes) la copie de la liste complète (vecteur), ou du bloc d'index et du bloc de données être ajouté à (deque). En fait, il y a peut-être quelque chose à dire là-bas pour deque sur de grandes listes qu'il faut ajouter à la fin pour une raison quelconque, je ne suis pas suffisamment informé sur la programmation fonctionnelle pour en juger.

3
Steve Jessop

De mon expérience, mise en œuvre des matrices clairsemées et des tas de fibonacci. Les listes chaînées vous permettent de mieux contrôler la structure globale de ces structures de données. Bien que je ne sois pas sûr que les matrices éparses soient mieux mises en œuvre en utilisant des listes chaînées - il existe probablement un meilleur moyen, mais cela a vraiment aidé à apprendre les tenants et aboutissants des matrices éparses à l'aide de listes chaînées dans les programmes de premier cycle CS :)

2
zakishaheen

Un bon exemple d'utilisation d'une liste chaînée est le cas où les éléments de la liste sont très volumineux, par exemple. suffisamment grand pour qu’un ou deux seulement puissent tenir dans le cache du processeur en même temps. À ce stade, l'avantage des conteneurs de blocs contigus, tels que les vecteurs ou les tableaux pour itérations, est plus ou moins annulé et un avantage en termes de performances peut être possible si de nombreuses insertions et suppressions ont lieu en temps réel.

2
metamorphosis

Considérez qu'une liste chaînée peut être très utile dans une implémentation de type conception par domaine d'un système qui inclut des pièces qui s'imbriquent dans la répétition. 

Un exemple qui me vient à l’esprit pourrait être si vous modélisiez une chaîne suspendue. Si vous voulez savoir quelle est la tension sur un lien en particulier, votre interface peut inclure un getter pour le poids "apparent". L'implémentation de ce qui inclurait un lien demandant à son prochain lien son poids apparent, puis ajoutant son propre poids au résultat. De cette façon, toute la longueur jusqu'au bas serait évaluée avec un seul appel du client de la chaîne.

Partisan du code qui se lit comme du langage naturel, j'aime bien que cela laisse le programmeur demander à un maillon de chaîne combien de poids il porte. Il garde également le souci de calculer ces enfants de propriétés dans les limites de la mise en œuvre du lien, éliminant ainsi le besoin d’un service de calcul du poids de la chaîne ".

1
780farva

Un des cas les plus utiles que je trouve pour les listes chaînées travaillant dans des domaines critiques pour la performance tels que le traitement des maillages et des images, les moteurs physiques et le traçage de rayons consiste à utiliser des listes chaînées pour améliorer la localité de référence et réduire les affectations de segments de mémoire. les alternatives simples.

Cela peut sembler être un oxymore complet, mais les listes chaînées pourraient faire tout cela, car elles sont réputées pour faire souvent le contraire, mais elles ont une propriété unique en ce que chaque nœud de liste a une taille fixe et des exigences d’alignement que nous pouvons exploiter pour permettre. qu'ils soient stockés de manière contiguë et supprimés à temps constant d'une manière que les objets de taille variable ne peuvent pas.

En conséquence, prenons le cas où nous voulons faire l'équivalent analogique du stockage d'une séquence de longueur variable contenant un million de sous-séquences imbriquées de longueur variable. Un exemple concret est un maillage indexé stockant un million de polygones (des triangles, des quadruples, des pentagones, des hexagones, etc.). Parfois, les polygones sont supprimés de n'importe où dans le maillage et parfois reconstitués pour insérer un sommet dans un polygone existant. enlever un. Dans ce cas, si nous stockons un million de std::vectors minuscules, nous nous retrouvons face à une allocation de tas pour chaque vecteur et à une utilisation potentiellement explosive de la mémoire. Un million de SmallVectors minuscules pourraient ne pas souffrir autant de ce problème dans les cas courants, mais leur mémoire tampon préallouée, qui n'est pas allouée séparément par tas, pourrait toujours provoquer une utilisation de mémoire explosive.

Le problème ici est qu'un million d'instances std::vector essaieraient de stocker un million d'éléments de longueur variable. Les objets de longueur variable ont tendance à vouloir une allocation de tas car ils ne peuvent pas être très efficacement stockés de manière contiguë et supprimés à temps constant (au moins de manière simple sans un allocateur très complexe) s'ils ne stockaient pas leur contenu ailleurs sur le tas.

Si, au contraire, nous faisons ceci:

struct FaceVertex
{
    // Points to next vertex in polygon or -1
    // if we're at the end of the polygon.
    int next;
    ...
};

struct Polygon
{
     // Points to first vertex in polygon.
    int first_vertex;
    ...
};

struct Mesh
{
    // Stores all the face vertices for all polygons.
    std::vector<FaceVertex> fvs;

    // Stores all the polygons.
    std::vector<Polygon> polys;
};

... alors nous avons considérablement réduit le nombre d'allocations de segments de mémoire et de manquements de mémoire cache. Au lieu d'exiger une allocation de tas et des erreurs de cache potentiellement obligatoires pour chaque polygone auquel nous accédons, nous n'exigeons plus cette allocation de tas que lorsque l'un des deux vecteurs stockés dans l'ensemble du maillage dépasse leur capacité (un coût amorti). Et même si la foulée nécessaire pour se rendre d’un sommet à l’autre peut encore causer sa part d’occurrences dans le cache, c’est souvent toujours moins que si chaque polygone stockait un tableau dynamique séparé, car les nœuds étaient stockés de manière contiguë et qu’il était probable qu’un sommet voisin être consultés avant l'expulsion (en particulier étant donné que de nombreux polygones ajouteront tous leurs sommets en même temps, ce qui rend la part du lion des sommets des polygones parfaitement contiguë).

Voici un autre exemple:

 enter image description here

... où les cellules de la grille sont utilisées pour accélérer la collision particule-particule, par exemple pour 16 millions de particules en mouvement par trame. Dans cet exemple de grille de particules, à l'aide de listes chaînées, nous pouvons déplacer une particule d'une cellule à une autre en modifiant simplement 3 indices. L'effacement d'un vecteur et le renvoi à un autre peuvent coûter beaucoup plus cher et introduire plus d'allocations de tas. Les listes liées réduisent également la mémoire d'une cellule à 32 bits. Un vecteur, selon son implémentation, peut préallouer son tableau dynamique au point où il peut prendre 32 octets pour un vecteur vide. Si nous avons environ un million de cellules de grille, c'est toute une différence.

... et c’est là que je trouve les listes chaînées les plus utiles de nos jours, et je trouve en particulier la variété "liste chaînée indexée" utile car les index 32 bits divisent par deux les besoins en mémoire des liens sur les machines 64 bits et impliquent que les nœuds sont stockés de manière contiguë dans un tableau.

Souvent, je les combine également avec des listes gratuites indexées pour permettre des suppressions et des insertions à temps constant n'importe où:

 enter image description here

Dans ce cas, l'index next pointe soit sur l'index libre suivant si le nœud a été supprimé, soit sur l'index utilisé suivant si le nœud n'a pas été supprimé.

Et c'est le cas d'utilisation numéro un que je trouve pour les listes chaînées ces jours-ci. Lorsque nous voulons stocker, disons, un million de sous-séquences de longueur variable dont la moyenne est de 4 éléments chacun (mais parfois, des éléments étant supprimés et ajoutés à l'une de ces sous-séquences), la liste chaînée nous permet de stocker 4 millions Les nœuds de la liste chaînée contiguës au lieu de 1 million de conteneurs, chacun étant individuellement attribué à un segment de mémoire: un vecteur géant, c’est-à-dire pas un million de petits.

1
Dragon Energy

Il y a deux opérations complémentaires qui sont trivialement O(1) sur des listes et très difficile à mettre en oeuvre dans O(1) dans d'autres structures de données - retirer et insérer un élément de position arbitraire, en supposant que vous ayez besoin de maintenir l'ordre des éléments. 

Les cartes de hachage peuvent évidemment faire l'insertion et la suppression dans O(1) mais vous ne pouvez pas ensuite parcourir les éléments dans l'ordre.

Étant donné ce qui précède, une carte de hachage peut être combinée à une liste chaînée pour créer un cache LRU astucieux: Une carte qui stocke un nombre fixe de paires clé-valeur et supprime la clé la moins récemment utilisée pour laisser la place à de nouvelles.

Les entrées de la carte de hachage doivent comporter des pointeurs vers les nœuds de la liste liée. Lors de l'accès à la carte de hachage, le nœud de la liste liée est dissocié de sa position actuelle et déplacé en tête de la liste (O (1), yay pour les listes liées!). Lorsqu'il est nécessaire de supprimer l'élément utilisé le moins récemment, l'élément de la liste de fin de liste doit être supprimé (encore une fois O(1) en supposant que vous gardiez le pointeur sur le nœud de fin) avec le hachage associé entrée de la carte (donc des backlinks de la liste à la carte de hachage sont nécessaires.) 

0
Rafał Dowgird

J'ai déjà utilisé des listes chaînées (même des listes doublement chaînées) dans une application C/C++. C'était avant .NET et même stl.

Je n'utiliserais probablement pas de liste chaînée dans un langage .NET car tout le code de traversée dont vous avez besoin vous est fourni via les méthodes d'extension Linq.

0
ChrisF