it-swarm.com.de

Unter welchen Umständen sind verknüpfte Listen nützlich?

Meistens sehe ich Leute, die versuchen, verknüpfte Listen zu verwenden, es scheint mir eine schlechte (oder sehr schlechte) Wahl. Vielleicht wäre es nützlich, die Umstände zu untersuchen, unter denen eine verknüpfte Liste eine gute Wahl der Datenstruktur ist oder nicht.

Im Idealfall würden die Antworten die Kriterien erläutern, die bei der Auswahl einer Datenstruktur zu verwenden sind und welche Datenstrukturen unter bestimmten Umständen wahrscheinlich am besten funktionieren.

Edit: Ich muss sagen, ich bin ziemlich beeindruckt, nicht nur die Anzahl, sondern auch die Qualität der Antworten. Ich kann nur eine akzeptieren, aber es gibt noch zwei oder drei, die ich sagen müsste, wäre die Annahme wert gewesen, wenn es nicht etwas Besseres gegeben hätte. Nur ein paar (vor allem das, das ich letztendlich akzeptierte) wies auf Situationen hin, in denen eine verknüpfte Liste einen echten Vorteil bot. Ich denke, Steve Jessop verdient eine ehrenvolle Erwähnung, weil er nicht nur eine, sondern drei verschiedene Antworten hat, die alle sehr beeindruckend waren. Obwohl es nur als Kommentar und nicht als Antwort gepostet wurde, denke ich, dass Neils Blogeintrag ebenfalls lesenswert ist - nicht nur informativ, sondern auch recht unterhaltsam.

102
Jerry Coffin

Sie können für gleichzeitige Datenstrukturen nützlich sein. (Es gibt jetzt ein nicht-gleichzeitiges Anwendungsbeispiel aus der realen Welt - das wäre nicht da, wenn @Neil FORTRAN nicht erwähnt hätte. ;-) 

Beispielsweise verwendet ConcurrentDictionary<TKey, TValue> in .NET 4.0 RC verknüpfte Listen, um Elemente zu verketten, die einen Hash an denselben Bucket senden.

Die zugrunde liegende Datenstruktur für ConcurrentStack<T> ist auch eine verknüpfte Liste.

ConcurrentStack<T> ist eine der Datenstrukturen, die als Grundlage für den neuen Thread-Pool dienen (wobei die lokalen "Queues" im Wesentlichen als Stapel implementiert sind). (Die andere Hauptstruktur ist ConcurrentQueue<T>.)

Der neue Thread-Pool bildet wiederum die Grundlage für die Arbeitsplanung der neuen Task Parallel Library .

Sie können also durchaus nützlich sein - eine verknüpfte Liste dient derzeit als eine der Haupttragstrukturen für mindestens eine der großen neuen Technologien.

(Eine einfach verknüpfte Liste macht in diesen Fällen eine zwingende lock-free - aber nicht wartungsfreie - Wahl, da Hauptoperationen mit einem einzigen CAS (+) ausgeführt werden können retries) . In einer modernen GC-d-Umgebung - wie Java und .NET - kann das ABA-Problem leicht vermieden werden./.Wählen Sie einfach Elemente ein, die Sie in frisch erstellte Knoten einfügen, und verwenden Sie diese nicht erneut node - lassen Sie den GC seine Arbeit erledigen ... Die Seite über das ABA-Problem bietet auch die Implementierung eines lock-freien Stacks - der eigentlich in .Net (& Java) mit einem (GC-ed) Node funktioniert, der die Elemente enthält. )

Edit: @ Neil: Was Sie über FORTRAN angesprochen haben, erinnerte mich daran, dass dieselbe Art von verknüpften Listen in der wahrscheinlich am häufigsten verwendeten und missbrauchten Datenstruktur in .NET: .__ enthalten ist. das einfache .NET-Generic Dictionary<TKey, TValue>.

Nicht eine, sondern viele verknüpfte Listen werden in einem Array gespeichert. 

  • Dadurch werden viele kleine (De-) Zuordnungen für Einfügungen/Löschvorgänge vermieden.
  • Das anfängliche Laden der Hashtabelle ist ziemlich schnell, da das Array sequentiell gefüllt wird (spielt sehr schön mit CPU-Cache). 
  • Ganz zu schweigen davon, dass eine verkettete Hash-Tabelle teuer ist - und dieser "Trick" die "Zeigergrößen" auf x64 halbiert.

Im Wesentlichen werden viele verknüpfte Listen in einem Array gespeichert. (eine für jeden verwendeten Bucket.) Eine freie Liste der wiederverwendbaren Knoten wird zwischen ihnen "verwoben" (wenn es Löschungen gab) . Ein Array wird beim Start/bei Rehash zugewiesen und Knoten von Ketten werden beibehalten es. Es gibt auch einen free - Zeiger - einen Index im Array -, der auf das Löschen folgt. ;-) Also - ob Sie es glauben oder nicht - die FORTRAN-Technik lebt immer noch weiter. (... und nirgendwo anders als in einer der am häufigsten verwendeten .NET-Datenstrukturen ;-).

38
Andras Vass

Verknüpfte Listen sind sehr nützlich, wenn Sie viele Einfügungen und Entfernungen vornehmen müssen, jedoch nicht zu viel suchen, und zwar in einer Liste beliebiger (zur Kompilierzeit unbekannter) Länge.

Das Aufteilen und Zusammenfügen (bidirektional verknüpfter) Listen ist sehr effizient.

Sie können auch verknüpfte Listen kombinieren, z. Baumstrukturen können als "vertikale" verknüpfte Listen (Eltern/Kind-Beziehungen) implementiert werden, die horizontale verknüpfte Listen (Geschwister) verbinden.

Die Verwendung einer Array-basierten Liste für diese Zwecke hat gravierende Einschränkungen:

  • Wenn Sie ein neues Element hinzufügen, muss das Array neu zugewiesen werden (oder Sie müssen mehr Speicherplatz zuweisen, als Sie benötigen, um zukünftiges Wachstum zu ermöglichen und die Anzahl der Neuzuordnungen zu reduzieren.)
  • Durch das Entfernen von Elementen bleibt Platz verschwendet oder erfordert eine Neuzuweisung
  • das Einfügen von Elementen an einem anderen Ort als dem Ende beinhaltet das (möglicherweise Neuzuordnen und) Kopieren von Lots der Daten an einer Position
48
Jason Williams

Verknüpfte Listen sind sehr flexibel: Mit der Änderung eines Zeigers können Sie eine massive Änderung vornehmen, bei der dieselbe Operation in einer Array-Liste sehr ineffizient wäre.

20
Chris Lercher

Arrays sind die Datenstrukturen, mit denen verknüpfte Listen normalerweise verglichen werden.

Normalerweise sind verknüpfte Listen hilfreich, wenn Sie die Liste selbst stark modifizieren müssen, während Arrays bessere Ergebnisse erzielen als Listen mit direktem Elementzugriff.

Hier ist eine Liste von Operationen, die für Listen und Arrays ausgeführt werden können, verglichen mit den relativen Operationskosten (n = Listen-/Arraylänge):

  • Element hinzufügen:
    • in Listen müssen Sie lediglich Speicher für das neue Element reservieren und Zeiger umleiten. O (1)
    • bei Arrays müssen Sie das Array verschieben. Auf)
  • Element entfernen
    • auf Listen leiten Sie einfach Zeiger um. O (1).
    • bei Arrays, die Sie O(n) Zeit verwenden, um das Array zu verschieben, wenn das zu entfernende Element nicht das erste oder letzte Element des Arrays ist; Andernfalls können Sie den Zeiger einfach an den Anfang des Arrays verschieben oder die Arraylänge verringern
  • Ein Element an eine bekannte Position bringen:
    • in Listen müssen Sie die Liste vom ersten Element zum Element an der spezifischen Position bewegen. Schlechtester Fall: O (n)
    • auf Arrays können Sie sofort auf das Element zugreifen. O (1)

Dies ist ein Vergleich der beiden gängigen und grundlegenden Datenstrukturen auf sehr untergeordneter Ebene, und Sie können feststellen, dass Listen in Situationen, in denen Sie viele Änderungen an der Liste selbst vornehmen müssen, besser ist (Entfernen oder Hinzufügen von Elementen). Auf der anderen Seite sind Arrays besser als Listen, wenn Sie direkt auf die Elemente des Arrays zugreifen müssen.

Aus Sicht der Speicherzuordnung sind Listen besser, da nicht alle Elemente nebeneinander liegen müssen. Auf der anderen Seite gibt es den (kleinen) Aufwand, die Zeiger auf das nächste (oder sogar auf das vorherige) Element zu speichern.

Die Kenntnis dieser Unterschiede ist für Entwickler wichtig, um zwischen Listen und Arrays in ihren Implementierungen zu wählen.

Beachten Sie, dass dies ein Vergleich von Listen und Arrays ist. Es gibt gute Lösungen für die hier gemeldeten Probleme (z. B. SkipLists, Dynamic Arrays usw.) .. In dieser Antwort habe ich die grundlegende Datenstruktur berücksichtigt, die jeder Programmierer kennen sollte.

14
Andrea Zilio

Eine einzeln verknüpfte Liste ist eine gute Wahl für die freie Liste in einem Zellenverteiler oder Objektpool:

  1. Sie benötigen nur einen Stapel, daher reicht eine einzeln verknüpfte Liste aus.
  2. Alles ist bereits in Knoten unterteilt. Für einen aufdringlichen Listenknoten gibt es keinen Zuweisungsaufwand, vorausgesetzt, die Zellen sind groß genug, um einen Zeiger zu enthalten.
  3. Ein Vektor oder ein Deque würde einen zusätzlichen Zeiger pro Block verursachen. Dies ist insofern von Bedeutung, als beim Erstellen des Heapspeichers alle Zellen frei sind, was im Voraus Kosten verursacht. Im schlimmsten Fall verdoppelt sich der Speicherbedarf pro Zelle.
4
Steve Jessop

Eine doppelt verknüpfte Liste ist eine gute Wahl, um die Reihenfolge einer Hashmap zu definieren, die auch eine Reihenfolge der Elemente definiert (LinkedHashMap in Java), insbesondere wenn sie nach dem letzten Zugriff geordnet ist:

  1. Mehr Speicheraufwand als ein zugehöriger Vektor oder Deque (2 Zeiger statt 1), jedoch bessere Leistung beim Einfügen/Entfernen.
  2. Kein Allokationsaufwand, da Sie ohnehin einen Knoten für einen Hash-Eintrag benötigen.
  3. Die Referenzlokalität ist im Vergleich zu einem Vektor oder Deque von Zeigern kein zusätzliches Problem, da Sie jedes Objekt so oder so in den Speicher ziehen müssen.

Sicher, Sie können sich darüber streiten, ob ein LRU-Cache überhaupt eine gute Idee ist, verglichen mit etwas anspruchsvollerem und abstimmbarem, aber wenn Sie eine haben möchten, ist dies eine recht anständige Implementierung. Sie möchten nicht bei jedem Lesezugriff ein Delete-from-Middle-and-Add-to-the-End auf einen Vektor oder ein Deque ausführen, aber das Verschieben eines Knotens an das Ende ist normalerweise in Ordnung.

4
Steve Jessop

Sie sind nützlich, wenn Sie schnelles Push, Pop und Rotation benötigen und die Indizierung von O(n) nicht stört.

Verknüpfte Listen sind eine der natürlichen Möglichkeiten, wenn Sie nicht steuern können, wo Ihre Daten gespeichert sind, aber Sie müssen trotzdem von Objekt zu Objekt gelangen. 

Wenn Sie zum Beispiel Memory Tracking in C++ implementieren (Neu-/Lösch-Ersetzung), benötigen Sie entweder eine Kontrolldatenstruktur, die verfolgt, welche Zeiger freigegeben wurden, die Sie vollständig selbst implementieren müssen. Die Alternative besteht darin, eine Gesamtliste zu erstellen und am Anfang jedes Datenblocks eine verknüpfte Liste hinzuzufügen.

Da Sie immer sofort wissen, wo Sie sich beim Aufruf von delete in der Liste befinden, können Sie den Speicher in O (1) ganz einfach aufgeben. Das Hinzufügen eines neuen Blocks, der gerade neu zugeordnet wurde, befindet sich ebenfalls in O (1). Das Durchlaufen der Liste ist in diesem Fall sehr selten erforderlich, daher sind die O(n) Kosten hier kein Thema (das Gehen einer Struktur ist O(n) sowieso).

3
LiKao

Einfach verknüpfte Listen sind die offensichtliche Implementierung des üblichen Datentyps "list" in funktionalen Programmiersprachen:

  1. Das Hinzufügen zum Kopf ist schnell, und (append (list x) (L)) und (append (list y) (L)) können fast alle ihre Daten gemeinsam nutzen. Keine Notwendigkeit zum Kopieren in einer Sprache ohne Schreibvorgänge. Funktionale Programmierer wissen dies zu nutzen.
  2. Das Hinzufügen des Endes ist leider langsam, aber jede andere Implementierung wäre auch so.

Im Vergleich dazu wäre ein Vektor oder Deque an beiden Enden normalerweise langsam hinzuzufügen, was (zumindest in meinem Beispiel von zwei verschiedenen Anhängen) erfordert, dass eine Kopie der gesamten Liste (Vektor) oder des Indexblocks und des Datenblocks erstellt wird an (deque) angehängt werden. Tatsächlich gibt es da vielleicht etwas zu sagen für Deque bei großen Listen, die aus irgendeinem Grund am Ende hinzugefügt werden müssen. Ich bin nicht ausreichend über die funktionale Programmierung informiert, um dies beurteilen zu können.

3
Steve Jessop

Aus meiner Erfahrung, Implementierung von spärlichen Matrizen und Fibonacci-Haufen. Verknüpfte Listen geben Ihnen mehr Kontrolle über die Gesamtstruktur solcher Datenstrukturen. Ich bin mir zwar nicht sicher, ob spärliche Matrizen am besten mit Hilfe von verknüpften Listen implementiert werden - wahrscheinlich gibt es einen besseren Weg, aber es hat wirklich geholfen, die Grundlagen von spärlichen Matrizen mithilfe von verknüpften Listen in undergrad CS zu lernen :)

2
zakishaheen

Ein Beispiel für eine gute Verwendung einer verknüpften Liste ist, wenn die Listenelemente sehr groß sind, d. groß genug, dass nur ein oder zwei gleichzeitig in den CPU-Cache passen können. An diesem Punkt ist der Vorteil, den benachbarte Blockcontainer wie Vektoren oder Arrays für die Iteration haben, mehr oder weniger aufgehoben, und ein Leistungsvorteil kann möglich sein, wenn viele Einfügungen und Entfernungen in Echtzeit erfolgen.

2
metamorphosis

Beachten Sie, dass eine verknüpfte Liste in einer domänengesteuerten Designimplementierung eines Systems sehr nützlich sein kann, das Teile enthält, die mit der Wiederholung ineinander greifen. 

Ein Beispiel, das Ihnen in den Sinn kommt, könnte sein, wenn Sie eine hängende Kette modellieren. Wenn Sie wissen wollten, was die Spannung bei einem bestimmten Link war, könnte Ihr Interface einen Getter für "scheinbares" Gewicht enthalten. Die Implementierung davon würde einen Link einschließen, der den nächsten Link nach seinem scheinbaren Gewicht fragt und dann sein eigenes Gewicht zum Ergebnis hinzufügt. Auf diese Weise wird die gesamte Länge bis zum Ende mit einem einzigen Anruf vom Client der Kette ausgewertet.

Als Befürworter von Code, der sich wie natürliche Sprache liest, mag ich, dass der Programmierer einen Kettenglied fragen lässt, wie viel Gewicht er trägt. Es hält auch die Sorge, diese Eigenschaften von Eigenschaften innerhalb der Grenzen der Verbindungsimplementierung zu berechnen, wodurch ein Kettengewichtsberechnungsdienst entfällt ".

1
780farva

Einer der nützlichsten Fälle, die ich für verknüpfte Listen in leistungskritischen Bereichen wie Netz- und Bildverarbeitung, Physik-Engines und Raytracing finde, besteht darin, dass die Verwendung verknüpfter Listen die Referenzlokalität verbessert und die Heap-Zuweisung und manchmal sogar den Speicherbedarf im Vergleich reduziert die einfachen Alternativen.

Das kann wie ein komplettes Oxymoron wirken, das verknüpfte Listen all das tun könnten, da sie dafür bekannt sind, dass sie oft das Gegenteil tun. Sie haben jedoch die einzigartige Eigenschaft, dass jeder Listenknoten eine feste Größe und Ausrichtungsanforderungen hat, die wir nutzen können, um dies zuzulassen Sie werden zusammenhängend gespeichert und in einer konstanten Zeit entfernt, und zwar auf eine Art und Weise, die von Dingen mit variabler Größe nicht möglich ist.

Nehmen wir also einen Fall, in dem wir das analoge Äquivalent des Speicherns einer Sequenz variabler Länge ausführen wollen, die eine Million verschachtelte Subsequenzen variabler Länge enthält. Ein konkretes Beispiel ist ein indiziertes Netz, das eine Million Polygone speichert (einige Dreiecke, einige Quads, einige Fünfecke, einige Sechsecke usw.). Manchmal werden Polygone aus dem gesamten Netz entfernt. Manchmal werden Polygone neu aufgebaut, um einen Scheitelpunkt in ein vorhandenes Polygon oder ein vorhandenes Polygon einzufügen eine entfernen Wenn wir in diesem Fall eine Million winziger std::vectors speichern, wird für jeden einzelnen Vektor eine Heap-Zuweisung sowie die Verwendung explosionsgefährdeter Speicher angezeigt. Eine Million winzige SmallVectors kann dieses Problem in den meisten Fällen nicht so sehr leiden, aber dann kann der vorab zugewiesene Puffer, der nicht separat Heap-zugeordnet ist, immer noch explosive Speicherauslastung verursachen.

Das Problem hier ist, dass eine Million std::vector-Instanzen versuchen würden, eine Million Dinge variabler Länge zu speichern. Dinge mit variabler Länge neigen dazu, eine Heap-Zuweisung zu wünschen, da sie nicht sehr effektiv zusammenhängend gespeichert und in konstanten Zeiten (zumindest auf unkomplizierte Weise ohne einen sehr komplexen Zuweiser) entfernt werden können, wenn sie ihren Inhalt nicht anderweitig auf dem Heap speichern.

Wenn wir stattdessen Folgendes tun:

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;
};

... dann haben wir die Anzahl der Heap-Zuordnungen und Cache-Misses drastisch reduziert. Statt für jedes einzelne Polygon, auf das wir zugreifen, eine Heap-Zuweisung und möglicherweise zwingende Zwischenspeicherfehler zu erfordern, benötigen wir jetzt nur noch eine Heap-Zuweisung, wenn einer der beiden Vektoren, die im gesamten Netz gespeichert sind, seine Kapazität überschreitet (Amortized Cost). Und obwohl der Schritt, von einem Scheitel zum nächsten zu gelangen, immer noch zu einem Teil der Cache-Fehlschübe führen kann, ist es häufig noch weniger, als wenn jedes einzelne Polygon ein separates dynamisches Array speichert, da die Knoten zusammenhängend gespeichert werden und die Wahrscheinlichkeit eines benachbarten Scheitelpunkts besteht vor der Räumung aufgerufen werden (vor allem wenn man bedenkt, dass viele Polygone ihre Scheitelpunkte auf einmal hinzufügen, wodurch der Löwenanteil der Polygonscheitelpunkte perfekt zusammenhängend ist).

Hier ist ein anderes Beispiel:

 enter image description here

... wo die Gitterzellen verwendet werden, um die Teilchen-Teilchen-Kollision für beispielsweise 16 Millionen Teilchen zu beschleunigen, die sich in jedem Bild bewegen. In diesem Beispiel für ein Partikelgitter können Sie mithilfe von verknüpften Listen ein Partikel von einer Gitterzelle in eine andere verschieben, indem Sie lediglich 3 Indizes ändern. Das Löschen von einem Vektor und das Zurückschieben auf einen anderen kann erheblich teurer sein und mehr Heap-Zuordnungen einführen. Die verknüpften Listen reduzieren außerdem den Speicher einer Zelle auf 32 Bit. Ein Vektor kann, abhängig von der Implementierung, sein dynamisches Array bis zu dem Punkt vorbelegen, an dem er 32 Bytes für einen leeren Vektor beanspruchen kann. Wenn wir etwa eine Million Gitterzellen haben, ist das ein großer Unterschied.

... und hier finde ich, dass verknüpfte Listen heutzutage am nützlichsten sind, und ich finde insbesondere die Sorte "indexierte verknüpfte Liste" nützlich, da 32-Bit-Indizes den Speicherbedarf der Links auf 64-Bit-Maschinen halbieren und diese implizieren Knoten werden zusammenhängend in einem Array gespeichert.

Oft kombiniere ich sie auch mit indizierten freien Listen, um das Entfernen und Einfügen konstanter Zeit überall zu ermöglichen:

 enter image description here

In diesem Fall zeigt der next-Index entweder auf den nächsten freien Index, wenn der Knoten entfernt wurde, oder auf den nächsten verwendeten Index, wenn der Knoten nicht entfernt wurde.

Und dies ist der Anwendungsfall Nummer eins, den ich heutzutage für verknüpfte Listen finde. Wenn wir zum Beispiel eine Million Teilsequenzen variabler Länge speichern möchten, die jeweils etwa 4 Elemente umfassen (aber manchmal werden Elemente entfernt und zu einer dieser Teilsequenzen hinzugefügt), können wir mit der verknüpften Liste 4 Millionen speichern verknüpfte Listenknoten zusammenhängend statt 1 Million Container, die jeweils einzeln zugeteilt werden: ein riesiger Vektor, dh keine Million kleiner.

1
Dragon Energy

Es gibt zwei komplementäre Operationen, die trivial O(1) in Listen sind und in O(1) in anderen Datenstrukturen sehr schwer zu implementieren sind - Entfernen und Einfügen eines Elements aus einer beliebigen Position, vorausgesetzt, Sie müssen dies tun die Reihenfolge der Elemente beibehalten. 

Hash-Maps können offensichtlich in O(1) eingefügt und gelöscht werden, aber dann können Sie die Elemente nicht der Reihe nach durchlaufen.

Angesichts der oben genannten Tatsache kann die Hash-Map mit einer verknüpften Liste kombiniert werden, um einen einfachen LRU-Cache zu erstellen: Eine Map, die eine feste Anzahl von Schlüssel-Wert-Paaren speichert und den Schlüssel mit den letzten Zugriffsrechten löscht, um Platz für neue zu schaffen.

Die Einträge in der Hash-Map müssen über Zeiger auf die verknüpften Listenknoten verfügen. Beim Zugriff auf die Hash-Map wird der verknüpfte Listenknoten von seiner aktuellen Position getrennt und an den Kopf der Liste verschoben (O (1), yay für verknüpfte Listen!). Wenn das am wenigsten kürzlich verwendete Element entfernt werden muss, muss das Element am Ende der Liste gelöscht werden (wiederum O(1), vorausgesetzt, Sie behalten den Zeiger auf den Endknoten) zusammen mit dem zugehörigen Hash-Map-Eintrag (daher sind Backlinks von der Liste zur Hash-Map erforderlich.) 

0
Rafał Dowgird

Ich habe in einer C/C++ - Anwendung in der Vergangenheit verknüpfte Listen (sogar doppelt verknüpfte Listen) verwendet. Dies war vor .NET und sogar stl.

Ich würde wahrscheinlich keine verknüpfte Liste jetzt in einer .NET-Sprache verwenden, da der gesamte benötigte Durchlaufcode über die Linq-Erweiterungsmethoden bereitgestellt wird.

0
ChrisF