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


110

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

Im Idealfall werden in den Antworten die Kriterien erläutert, 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 nicht nur von der Anzahl, sondern auch von der Qualität der Antworten ziemlich beeindruckt. Ich kann nur einen akzeptieren, aber es gibt noch zwei oder drei, von denen ich sagen müsste, dass es sich gelohnt hätte, sie zu akzeptieren, wenn etwas Besseres nicht da gewesen wäre. Nur ein paar (insbesondere das, das ich letztendlich akzeptiert habe) wiesen auf Situationen hin, in denen eine verknüpfte Liste einen echten Vorteil bot. Ich denke, Steve Jessop verdient eine lobende Erwähnung, weil er nicht nur eine, sondern drei verschiedene Antworten gefunden hat, die ich alle sehr beeindruckend fand. Natürlich, obwohl es nur als Kommentar und nicht als Antwort gepostet wurde, denke ich, dass Neils Blogeintrag auch lesenswert ist - nicht nur informativ, sondern auch sehr unterhaltsam.


34
Die Antwort auf Ihren zweiten Absatz dauert ungefähr ein Semester.
Seva Alekseyev

2
Für meine Meinung siehe Punchlet.wordpress.com/2009/12/27/letter-the-fourth . Und da dies eine Umfrage zu sein scheint, sollte es wahrscheinlich CW sein.

1
@Neil, nett, obwohl ich bezweifle, dass CS Lewis zustimmen würde.
Tom

@Neil: Ich denke eine Art Umfrage. Meistens ist es ein Versuch zu sehen, ob jemand eine Antwort finden kann, die eine Grundlage hat, die ich zumindest als vernünftig kaufen könnte. @Seva: Ja, beim erneuten Lesen habe ich den letzten Satz etwas allgemeiner gemacht, als ich ursprünglich beabsichtigt hatte.
Jerry Coffin

2
@Yar Leute (einschließlich mir, muss ich leider sagen) haben verknüpfte Listen ohne Zeiger in Sprachen wie FORTRAN IV (die keine Ahnung von Zeigern hatten) implementiert, ähnlich wie sie es mit Bäumen taten. Sie haben Arrays anstelle von "echtem" Speicher verwendet.

Antworten:


40

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

Zum Beispiel ConcurrentDictionary<TKey, TValue>verknüpfen in .NET 4.0 RC Verwendung Listen Ketten Artikel , dass Hash auf den gleichen Eimer.

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

ConcurrentStack<T>ist eine der Datenstrukturen, die als Grundlage für den neuen Thread-Pool dienen (wobei die lokalen "Warteschlangen" im Wesentlichen als Stapel implementiert sind). (Die andere Haupttragstruktur 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 wichtigsten unterstützenden Strukturen für mindestens eine großartige neue Technologie.

(Eine einfach verknüpfte Liste trifft in diesen Fällen eine überzeugende, sperrenfreie, aber nicht wartefreie Wahl, da Hauptoperationen mit einem einzigen CAS ausgeführt werden können (+ Wiederholungsversuche). In einer modernen GC-d-Umgebung - wie z Java und .NET - das ABA-Problem kann leicht vermieden werden. Packen Sie einfach Elemente, die Sie hinzufügen, in frisch erstellte Knoten und verwenden Sie diese Knoten nicht wieder - lassen Sie den GC seine Arbeit erledigen. Die Seite zum ABA-Problem bietet auch die Implementierung einer Sperre. Free Stack - das funktioniert tatsächlich in .Net (& Java) mit einem (GC-ed) Knoten, der die Elemente enthält.)

Edit : @Neil: Eigentlich hat mich das, was Sie über FORTRAN erwähnt haben, daran erinnert, dass die gleiche Art von verknüpften Listen in der wahrscheinlich am häufigsten verwendeten und missbrauchten Datenstruktur in .NET zu finden ist: dem einfachen .NET-Generikum Dictionary<TKey, TValue>.

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

  • Es werden viele kleine (De-) Zuweisungen für Einfügungen / Löschungen vermieden.
  • Das anfängliche Laden der Hash-Tabelle ist ziemlich schnell, da das Array nacheinander gefüllt wird (spielt sich sehr gut mit dem CPU-Cache).
  • Ganz zu schweigen davon, dass eine Verkettungs-Hash-Tabelle speicherintensiv ist - und dieser "Trick" halbiert "Zeigergrößen" auf x64.

Im Wesentlichen werden viele verknüpfte Listen in einem Array gespeichert. (eine für jeden verwendeten Bucket.) Eine freie Liste wiederverwendbarer Knoten wird zwischen ihnen "verwoben" (falls gelöscht wurde). Ein Array wird beim Start / beim erneuten Aufwärmen zugewiesen und Knoten von Ketten werden darin beibehalten. Es gibt auch einen freien Zeiger - einen Index in das Array - der auf Löschungen folgt. ;-) Also - ob Sie es glauben oder nicht - die FORTRAN-Technik lebt weiter. (... und nirgendwo anders als in einer der am häufigsten verwendeten .NET-Datenstrukturen ;-).


2
Für den Fall, dass Sie etwas verpasst haben, hier Neils Kommentar: "Leute (einschließlich ich, es tut mir leid zu sagen) haben verknüpfte Listen ohne Zeiger in Sprachen wie FORTRAN IV (die keine Ahnung von Zeigern hatten) implementiert, ähnlich wie Bäume Sie haben Arrays anstelle von "echtem" Speicher verwendet. "
Andras Vass

Ich sollte hinzufügen, dass der Ansatz "Verknüpfte Listen in einem Array" im Falle der DictionarySpeicherung in .NET erheblich mehr ist: Andernfalls würde jeder Knoten ein separates Objekt auf dem Heap erfordern - und jedes auf dem Heap zugewiesene Objekt hat einen gewissen Overhead. ( en.csharp-online.net/Common_Type_System%E2%80%94Object_Layout )
Andras Vass

Es ist auch gut zu wissen, dass die Standardeinstellung von C ++ std::listin einem Multithread-Kontext ohne Sperren nicht sicher ist.
Mooing Duck

49

Verknüpfte Listen sind sehr nützlich, wenn Sie viele Einfügungen und Entfernungen, aber nicht zu viele Suchvorgänge in einer Liste beliebiger Länge (zur Kompilierungszeit unbekannt) durchführen müssen.

Das Teilen und Verbinden von (bidirektional verknüpften) Listen ist sehr effizient.

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

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

  • Das Hinzufügen eines neuen Elements bedeutet, dass das Array neu zugewiesen werden muss (oder Sie müssen mehr Speicherplatz zuweisen, als Sie benötigen, um zukünftiges Wachstum zu ermöglichen und die Anzahl der Neuzuweisungen zu verringern).
  • Das Entfernen von Elementen verschwendet Platz oder erfordert eine Neuzuweisung
  • Das Einfügen von Elementen an einer anderen Stelle als am Ende umfasst das (möglicherweise Neuzuweisen und) Kopieren vieler Daten an einer Position

5
Die Frage reduziert sich also auf: Wann müssen Sie in der Mitte einer Sequenz viele Einfügungen und Entfernungen vornehmen, aber nicht sehr viele Suchvorgänge in der Liste nach Ordnungszahl? Das Durchlaufen einer verknüpften Liste ist in der Regel genauso oder teurer als das Kopieren eines Arrays. Daher ist alles, was Sie über das Entfernen und Einfügen von Elementen in Arrays sagen, für den Direktzugriff in Listen genauso schlecht. Der LRU-Cache ist ein Beispiel, an das ich denken kann. Sie müssen viel in der Mitte entfernen, aber Sie müssen nie durch die Liste gehen.
Steve Jessop

2
Das Hinzufügen zu einer Liste umfasst die Speicherzuweisung für jedes Element, das Sie hinzufügen. Dies kann einen Systemaufruf beinhalten, der sehr teuer ist. Das Hinzufügen zu einem Array erfordert einen solchen Aufruf nur, wenn das Array vergrößert werden muss. Tatsächlich ist in den meisten Sprachen (genau aus diesen Gründen) das Array die bevorzugte Datenstruktur, und Listen werden kaum verwendet.

1
Angenommen, welche? Dass die Zuordnung erstaunlich schnell ist, ist offensichtlich - normalerweise muss die Objektgröße einem Zeiger hinzugefügt werden. Der Gesamtaufwand für GC ist gering? Als ich das letzte Mal versuchte, es mit einer echten App zu messen, war der entscheidende Punkt, dass Java die ganze Arbeit erledigte, wenn der Prozessor ansonsten ohnehin im Leerlauf war, so dass es natürlich die sichtbare Leistung nicht wesentlich beeinträchtigte. In einem Busy-CPU-Benchmark war es leicht, Java zu verärgern und eine sehr schlechte Worst-Case-Zuweisungszeit zu erhalten. Dies war jedoch vor vielen Jahren, und die Müllabfuhr von Generationen hat die Gesamtkosten von GC seitdem deutlich gesenkt.
Steve Jessop

1
@Steve: Sie irren sich, wenn die Zuordnung zwischen Listen und Arrays "gleich" ist. Jedes Mal, wenn Sie Speicher für eine Liste zuweisen müssen, weisen Sie einfach einen kleinen Block zu - O (1). Für ein Array müssen Sie einen neuen Block zuweisen, der groß genug für die gesamte Liste ist, und dann die gesamte Liste kopieren - O (n). Um an einer bekannten Stelle in einer Liste einzufügen, aktualisieren Sie eine feste Anzahl von Zeigern - O (1), aber um sie in ein Array einzufügen und spätere Elemente an einer Position zu kopieren, um Platz für die Einfügung zu schaffen - O (n). Es gibt viele Fälle, in denen Arrays daher viel weniger effizient sind als LLs.
Jason Williams

1
@ Jerry: Ich verstehe. Mein Punkt ist, dass ein großer Teil der Kosten für die Neuzuweisung des Arrays nicht die Zuweisung von Speicher ist , sondern die Notwendigkeit, den gesamten Array-Inhalt in den neuen Speicher zu kopieren . Um in Element 0 eines Arrays einzufügen, müssen Sie den gesamten Array-Inhalt um eine Position im Speicher kopieren . Ich sage nicht, dass Arrays schlecht sind; Nur dass es Situationen gibt, in denen kein wahlfreier Zugriff erforderlich ist und in denen das Einfügen / Löschen / erneute Verknüpfen von LLs mit wirklich konstanter Zeit vorzuziehen ist.
Jason Williams

20

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.


Wäre es möglich zu motivieren, warum überhaupt eine Liste und kein Set oder eine Karte verwendet wird?
Patrik

14

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

Normalerweise sind verknüpfte Listen nützlich, wenn Sie viele Änderungen an der Liste selbst vornehmen müssen, während Arrays beim direkten Elementzugriff eine bessere Leistung erzielen als Listen.

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

  • Hinzufügen eines Elements:
    • In Listen müssen Sie nur Speicher für das neue Element zuweisen und Zeiger umleiten. O (1)
    • Auf Arrays müssen Sie das Array verschieben. Auf)
  • Ein Element entfernen
    • Auf Listen leiten Sie nur Zeiger um. O (1).
    • Bei Arrays verbringen Sie O (n) Zeit damit, das Array zu verschieben, wenn das zu entfernende Element nicht das erste oder das letzte Element des Arrays ist. Andernfalls können Sie den Zeiger einfach auf den Anfang des Arrays verschieben oder die Arraylänge verringern
  • Ein Element an eine bekannte Position bringen:
    • Bei Listen müssen Sie die Liste vom ersten Element zum Element an der jeweiligen Position verschieben. Schlimmster Fall: O (n)
    • Auf Arrays können Sie sofort auf das Element zugreifen. O (1)

Dies ist ein Vergleich dieser beiden gängigen und grundlegenden Datenstrukturen auf sehr niedriger Ebene. Sie können feststellen, dass Listen in Situationen, in denen Sie viele Änderungen an der Liste selbst vornehmen müssen (Entfernen oder Hinzufügen von Elementen), eine bessere Leistung erzielen. Andererseits 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 stehen müssen. Andererseits gibt es den (geringen) Aufwand, die Zeiger auf das nächste (oder sogar auf das vorherige) Element zu speichern.

Für Entwickler ist es wichtig, diese Unterschiede zu kennen, um in ihren Implementierungen zwischen Listen und Arrays 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.


Dies gilt in gewisser Weise für eine gute Implementierung von Listen und eine schreckliche Implementierung von Arrays. Die meisten Array-Implementierungen sind weitaus ausgefeilter, als Sie ihnen zuschreiben. Und ich glaube nicht, dass Sie verstehen, wie teuer die dynamische Speicherzuweisung sein kann.

Diese Antwort soll nicht das Programm eines Kurses der Data Structures University abdecken. Dies ist ein Vergleich, der unter Berücksichtigung verknüpfter Listen und Arrays geschrieben wurde, die so implementiert sind, wie Sie, ich und die meisten Menschen es wissen. Geometrisch expandierende Arrays, Überspringlisten usw. sind Lösungen, die ich kenne, verwende und studiere, die jedoch einer tieferen Erklärung bedürfen und die nicht zu einer Stackoverflow-Antwort passen.
Andrea Zilio

1
"Aus Sicht der Speicherzuordnung sind Listen besser, da nicht alle Elemente nebeneinander stehen müssen." Im Gegenteil, zusammenhängende Container sind besser, weil sie die Elemente nebeneinander halten. Auf modernen Computern ist die Datenlokalität König. All das Herumspringen im Speicher beeinträchtigt Ihre Cache-Leistung und führt dazu, dass Programme, die ein Element an einer (effektiv) zufälligen Stelle einfügen, mit einem dynamischen Array wie C ++ schneller arbeiten std::vectorals mit einer verknüpften Liste wie C ++ std::list, einfach weil sie das durchlaufen Liste ist so teuer.
David Stone

@DavidStone Vielleicht war ich nicht klar genug, aber mit diesem Satz bezog ich mich auf die Tatsache, dass Sie keinen zusammenhängenden Speicherplatz benötigen, um Ihre Elemente zu speichern. Insbesondere wenn Sie etwas nicht zu Kleines speichern möchten und nur über begrenzten verfügbaren Speicher verfügen, verfügen Sie möglicherweise nicht über genügend zusammenhängenden freien Speicherplatz , um Ihre Daten zu speichern. Sie können Ihre Daten jedoch wahrscheinlich stattdessen mithilfe einer Liste anpassen (obwohl Sie den Overhead von Zeigern haben ... sowohl wegen des Platzbedarfs als auch wegen der von Ihnen erwähnten Leistungsprobleme). Ich sollte wahrscheinlich meine Antwort aktualisieren, um es klarer zu machen.
Andrea Zilio

4

Eine einfach verknüpfte Liste ist eine gute Wahl für die kostenlose Liste in einem Zellenzuweiser oder Objektpool:

  1. Sie benötigen nur einen Stapel, daher ist eine einfach verknüpfte Liste ausreichend.
  2. Alles ist bereits in Knoten unterteilt. Es gibt keinen Zuordnungsaufwand für einen aufdringlichen Listenknoten, vorausgesetzt, die Zellen sind groß genug, um einen Zeiger zu enthalten.
  3. Ein Vektor oder eine Deque würde einen Overhead von einem Zeiger pro Block auferlegen. Dies ist insofern von Bedeutung, als beim ersten Erstellen des Heaps alle Zellen frei sind. Dies ist also ein Vorabpreis. Im schlimmsten Fall verdoppelt sich der Speicherbedarf pro Zelle.

Gut einverstanden. Aber wie viele Programmierer erstellen solche Dinge tatsächlich? Die meisten implementieren einfach neu, was std :: list usw. Ihnen bietet. Und tatsächlich hat "aufdringlich" normalerweise eine etwas andere Bedeutung als Sie es angegeben haben - dass jedes mögliche Listenelement einen von den Daten getrennten Zeiger enthält.

1
Wie viele? Mehr als 0, weniger als eine Million ;-) War Jerrys Frage "Listen gut gebrauchen" oder "Listen gut gebrauchen, die jeder Programmierer täglich benutzt" oder etwas dazwischen? Ich kenne keinen anderen Namen als "aufdringlich" für einen Listenknoten, der in dem Objekt enthalten ist, das ein Listenelement ist - ob als Teil einer Vereinigung (in C-Begriffen) oder nicht. Punkt 3 gilt nur in Sprachen, in denen Sie dies tun können - C, C ++, Assembler gut. Java schlecht.
Steve Jessop

4

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

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

Sicher, Sie können darüber streiten, ob ein LRU-Cache überhaupt eine gute Idee ist, verglichen mit etwas Anspruchsvollerem und Optimierbarem, aber wenn Sie einen haben wollen, ist dies eine ziemlich anständige Implementierung. Sie möchten nicht bei jedem Lesezugriff ein Löschen aus der Mitte und ein Hinzufügen zum Ende eines Vektors oder einer Deque durchführen, aber das Verschieben eines Knotens zum Ende ist normalerweise in Ordnung.


3

Sie sind nützlich, wenn Sie schnelles Drücken, Pop und Drehen benötigen und die O (n) -Indizierung nichts ausmacht.


Haben Sie sich jemals die Mühe gemacht, C ++ - verknüpfte Listen im Vergleich zu (sagen wir) einer Deque zu messen?

@ Neil: Kann ich nicht sagen.
Ignacio Vazquez-Abrams

@Neil: Wenn C ++ seine verknüpfte Listenklasse absichtlich sabotiert hat, um sie langsamer als jeden anderen Container zu machen (was nicht weit von der Wahrheit entfernt ist), was hat das mit einer sprachunabhängigen Frage zu tun? Eine aufdringliche verknüpfte Liste ist immer noch eine verknüpfte Liste.
Steve Jessop

@Steve C ++ ist eine Sprache. Ich kann nicht sehen, wie es Willenskraft haben kann. Wenn Sie vorschlagen, dass Mitglieder des C ++ - Komitees verknüpfte Listen irgendwie sabotieren (was für viele Operationen logischerweise langsam sein muss), dann nennen Sie die Schuldigen!

3
Es ist keine wirkliche Sabotage - externe Listenknoten haben ihre Vorteile, aber die Leistung gehört nicht dazu. Allerdings war sich sicherlich jeder bewusst, als er den Kompromiss zwischen dem machte, was Ihnen bekannt ist, nämlich dass es ziemlich schwierig ist, eine gute Verwendung für zu finden std::list. Eine aufdringliche Liste passt einfach nicht zur C ++ - Philosophie der Mindestanforderungen an Containerelemente.
Steve Jessop

3

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

  1. Das Hinzufügen zum Kopf ist schnell und (append (list x) (L))und(append (list y) (L)) kann fast alle Daten gemeinsam nutzen. Keine Notwendigkeit zum Kopieren beim Schreiben in einer Sprache ohne Schreibvorgänge. Funktionale Programmierer wissen, wie sie dies nutzen können.
  2. Das Hinzufügen zum Schwanz ist leider langsam, aber auch jede andere Implementierung.

Im Vergleich dazu ist es normalerweise langsam, einen Vektor oder eine Deque an beiden Enden hinzuzufügen, was (zumindest in meinem Beispiel mit zwei unterschiedlichen Anhängen) erfordert, dass eine Kopie der gesamten Liste (Vektor) oder des Indexblocks und des Datenblocks erstellt wird angehängt werden an (deque). Tatsächlich kann dort auf großen Listen etwas zu sagen sein, das aus irgendeinem Grund am Ende hinzugefügt werden muss. Ich bin nicht ausreichend über funktionale Programmierung informiert, um beurteilen zu können.


3

Verknüpfte Listen sind eine der natürlichen Möglichkeiten, wenn Sie nicht steuern können, wo Ihre Daten gespeichert sind, aber dennoch irgendwie von einem Objekt zum nächsten gelangen müssen.

Wenn Sie beispielsweise die Speicherverfolgung in C ++ implementieren (Neu- / Löschersetzung), benötigen Sie entweder eine Steuerdatenstruktur, die nachverfolgt, welche Zeiger freigegeben wurden, und die Sie vollständig selbst implementieren müssen. Die Alternative besteht darin, eine verknüpfte Liste am Anfang jedes Datenblocks zu ordnen und 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) leicht aufgeben. Das Hinzufügen eines neuen Blocks, der gerade mallociert wurde, befindet sich in O (1). Das Durchlaufen der Liste wird in diesem Fall sehr selten benötigt, daher sind die O (n) -Kosten hier kein Problem (das Durchlaufen einer Struktur ist ohnehin O (n)).


3

Ein Beispiel für eine gute Verwendung für eine verknüpfte Liste ist, wenn die Listenelemente sehr groß sind, d. H. groß genug, dass nur ein oder zwei gleichzeitig in den CPU-Cache passen. Zu diesem Zeitpunkt ist der Vorteil, den zusammenhängende 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

Aus meiner Erfahrung, Implementierung von Sparse-Matrizen und Fibonacci-Haufen. Mit verknüpften Listen haben Sie mehr Kontrolle über die Gesamtstruktur solcher Datenstrukturen. Ich bin mir zwar nicht sicher, ob Sparse-Matrizen am besten mithilfe von verknüpften Listen implementiert werden können - wahrscheinlich gibt es einen besseren Weg, aber es hat wirklich geholfen, die Vor- und Nachteile von Sparse-Matrizen mithilfe von verknüpften Listen in Undergrad CS zu lernen :)


1

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

Hash-Maps können natürlich in O (1) einfügen und löschen, aber dann können Sie die Elemente nicht der Reihe nach durchlaufen.

In Anbetracht der obigen Tatsache kann die Hash-Map mit einer verknüpften Liste kombiniert werden, um einen raffinierten LRU-Cache zu erstellen: Eine Map, die eine feste Anzahl von Schlüssel-Wert-Paaren speichert und den Schlüssel, auf den zuletzt zugegriffen wurde, löscht, um Platz für neue zu schaffen.

Die Einträge in der Hash-Map müssen Zeiger auf die verknüpften Listenknoten haben. Beim Zugriff auf die Hash-Map wird der Knoten der verknüpften Liste von seiner aktuellen Position getrennt und an den Kopf der Liste verschoben (O (1), yay für verknüpfte Listen!). Wenn das zuletzt verwendete Element entfernt werden muss, muss das Element aus dem Ende der Liste entfernt werden (erneut O (1), vorausgesetzt, Sie behalten den Zeiger auf den Endknoten), zusammen mit dem zugehörigen Hash-Map-Eintrag (also Backlinks von Die Liste zur Hash-Map ist notwendig.)


1

Bedenken Sie, dass eine verknüpfte Liste bei der Implementierung eines Systems im Domain Driven Design-Stil, das Teile enthält, die mit Wiederholungen ineinander greifen, sehr nützlich sein kann.

Ein Beispiel, das Ihnen in den Sinn kommt, könnte sein, wenn Sie eine hängende Kette modellieren. Wenn Sie wissen möchten, wie hoch die Spannung an einem bestimmten Link ist, kann Ihre Benutzeroberfläche einen Getter für das "scheinbare" Gewicht enthalten. Die Implementierung würde einen Link beinhalten, der seinen 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 Aufruf vom Client der Kette ausgewertet.

Als Befürworter von Code, der sich wie eine natürliche Sprache liest, gefällt mir, wie der Programmierer ein Kettenglied fragen kann, wie viel Gewicht es trägt. Es bleibt auch das Anliegen, diese Kinder von Eigenschaften innerhalb der Grenzen der Link-Implementierung zu berechnen, wodurch die Notwendigkeit eines Dienstes zur Berechnung des Kettengewichts entfällt. "


1

Einer der nützlichsten Fälle, die ich für verknüpfte Listen finde, die in leistungskritischen Bereichen wie Netz- und Bildverarbeitung, Physik-Engines und Raytracing arbeiten, ist die Verwendung verknüpfter Listen, die die Referenzlokalität verbessern, die Heap-Zuweisungen reduzieren und manchmal sogar die Speichernutzung im Vergleich zu reduzieren die einfachen Alternativen.

Nun, das kann wie ein komplettes Oxymoron erscheinen, dass verknüpfte Listen all das tun könnten, da sie dafür berüchtigt sind, oft das Gegenteil zu tun, aber sie haben eine einzigartige Eigenschaft, da jeder Listenknoten eine feste Größe und Ausrichtungsanforderungen hat, die wir ausnutzen können, um dies zuzulassen Sie müssen zusammenhängend gespeichert und in konstanter Zeit auf eine Weise entfernt werden, die Dinge mit variabler Größe nicht können.

Nehmen wir als Ergebnis einen Fall, in dem wir das analoge Äquivalent zum Speichern einer Sequenz variabler Länge ausführen möchten, die eine Million verschachtelter Teilsequenzen variabler Länge enthält. Ein konkretes Beispiel ist ein indiziertes Netz, in dem eine Million Polygone (einige Dreiecke, einige Quads, einige Fünfecke, einige Sechsecke usw.) gespeichert sind. Manchmal werden Polygone von einer beliebigen Stelle im Netz entfernt und manchmal werden Polygone neu erstellt, um einen Scheitelpunkt in ein vorhandenes Polygon oder einzufügen entferne einen. Wenn wir in diesem Fall eine Million Winzlinge speichern std::vectors, sehen wir uns einer Heap-Zuweisung für jeden einzelnen Vektor sowie einer potenziell explosiven Speichernutzung gegenüber. Eine Million Winzlinge SmallVectorsleiden in normalen Fällen möglicherweise nicht so häufig unter diesem Problem, aber ihr vorab zugewiesener Puffer, der nicht separat auf dem Heap zugeordnet ist, kann dennoch zu einer explosiven Speichernutzung führen.

Das Problem hierbei ist, dass eine Million std::vectorInstanzen versuchen würden, eine Million Dinge variabler Länge zu speichern. Dinge mit variabler Länge möchten in der Regel eine Heap-Zuordnung, da sie nicht sehr effektiv zusammenhängend gespeichert und in konstanter Zeit entfernt werden können (zumindest auf einfache Weise ohne einen sehr komplexen Allokator), wenn sie ihren Inhalt nicht an anderer Stelle auf dem Heap gespeichert haben.

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-Zuweisungen und Cache-Fehler drastisch reduziert. Anstatt für jedes einzelne Polygon, auf das wir zugreifen, eine Heap-Zuordnung und möglicherweise obligatorische Cache-Fehlschläge zu erfordern, benötigen wir diese Heap-Zuordnung nur dann, wenn einer der beiden im gesamten Netz gespeicherten Vektoren ihre Kapazität überschreitet (amortisierte Kosten). Und während der Schritt von einem Scheitelpunkt zum nächsten immer noch zu einem Anteil von Cache-Fehlern führen kann, ist er oft immer noch geringer als wenn jedes einzelne Polygon ein separates dynamisches Array speichert, da die Knoten zusammenhängend gespeichert werden und die Wahrscheinlichkeit besteht, dass ein benachbarter Scheitelpunkt vorhanden ist vor der Räumung zugegriffen werden (insbesondere 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 weiteres Beispiel:

Geben Sie hier die Bildbeschreibung ein

... wo die Gitterzellen verwendet werden, um die Partikel-Partikel-Kollision für beispielsweise 16 Millionen Partikel zu beschleunigen, die sich in jedem einzelnen Frame bewegen. In diesem Beispiel für ein Partikelgitter können wir mithilfe verknüpfter Listen ein Partikel von einer Gitterzelle in eine andere verschieben, indem wir nur 3 Indizes ändern. Das Löschen von einem Vektor und das Zurückschieben zu einem anderen kann erheblich teurer sein und mehr Heap-Zuweisungen einführen. Die verknüpften Listen reduzieren auch den Speicher einer Zelle auf 32 Bit. Ein Vektor kann abhängig von der Implementierung sein dynamisches Array bis zu dem Punkt vorab zuweisen, an dem er 32 Bytes für einen leeren Vektor benötigen kann. Wenn wir ungefähr eine Million Gitterzellen haben, ist das ein ziemlicher Unterschied.

... und hier finde ich verknüpfte Listen heutzutage am nützlichsten, und ich finde speziell die Variante "indizierte verknüpfte Liste" nützlich, da 32-Bit-Indizes den Speicherbedarf der Verknüpfungen auf 64-Bit-Computern halbieren und implizieren, dass die Knoten werden zusammenhängend in einem Array gespeichert.

Oft kombiniere ich sie auch mit indizierten freien Listen, um zeitlich konstante Entfernungen und Einfügungen überall zu ermöglichen:

Geben Sie hier die Bildbeschreibung ein

In diesem Fall zeigt der nextIndex 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 beispielsweise eine Million Teilsequenzen variabler Länge speichern möchten, die durchschnittlich 4 Elemente bilden (manchmal jedoch Elemente entfernen und zu einer dieser Teilsequenzen hinzufügen), können wir in der verknüpften Liste 4 Millionen speichern verknüpfte Listenknoten zusammenhängend anstelle von 1 Million Containern, die jeweils einzeln einem Heap zugeordnet sind: ein Riesenvektor, dh nicht eine Million kleine.


0

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

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

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.