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 SmallVectors
leiden 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::vector
Instanzen 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:
... 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:
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 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.