Verkettete Liste
Kurz gesagt: Eine verkettete Liste ist eine Kette von Knoten, die jeweils auf den nächsten zeigen: Mit head und tail sind Hinzufügen an beiden Enden und Entfernen vorne O(1), während der Zugriff über den Index O(n) ist.
Problem
Die Warteschlange, die den Versand bremst
Der Server verschickt E-Mails aus einer Warteschlange. Neue kommen hinten hinein, dringende (ein Passwort-Reset) vorne, und der Worker nimmt immer die erste. Der Code benutzt die gewöhnliche Liste:
var emails = new List<string>();emails.Add("Rechnung");emails.Add("Newsletter");emails.Add("Bericht");emails.Insert(0, "Passwort");string next = emails[0];emails.RemoveAt(0);Console.WriteLine("Sende: " + next);Console.WriteLine("Übrig: " + string.Join(", ", emails));Sende: PasswortÜbrig: Rechnung, Newsletter, BerichtAm Montagmorgen füllt der Newsletter an alle Kunden die Warteschlange mit 100.000 E-Mails. Der Worker verschickt langsamer als sonst, dann fast nichts mehr, und die Passwort-Reset-E-Mails kommen Minuten zu spät.
Der Grund: Die Liste ist ein dynamisches Array. Wer die erste nimmt, schiebt alle anderen einen Platz nach vorn, und eine dringende vorne schiebt alle zurück. 100.000 E-Mails nacheinander zu verschicken sind 99.999 + 99.998 + … + 1 = 4.999.950.000 Verschiebungen, nur um jedes Mal die erste zu nehmen.
Die Idee
Knoten, head und tail
Den Knoten und das Einfügen am Anfang hast du schon in der Lektion „Stack, Queue und verkettete Liste“ gesehen: Jedes Element ist ein eigenes Objekt mit einem Wert und einem Pfeil Next zum nächsten. Hier schreibst du die ganze Klasse und rechnest aus, was jede Operation kostet.
Die Liste hält drei Dinge:
- head: der erste Knoten. Von dort beginnt jedes Durchlaufen.
- tail: der letzte Knoten. Mit ihm läuft das Hinzufügen am Ende nicht durch die ganze Liste.
- die Größe, damit nicht jedes Mal gezählt werden muss.
Beide Enden müssen nach jeder Operation stimmen. Die leere Liste hat head und tail gleich null. Eine Liste mit einem Knoten hat head und tail, die auf denselben Knoten zeigen. Fast jeder Fehler in einer verketteten Liste ist ein vergessenes Ende: Du entfernst den letzten, und tail zeigt noch auf ihn.
Sieh es dir an
Deine eigene verkettete Liste
Hier ist die ganze Klasse. Die Knoten bewegen sich nie: Jede Operation ändert nur Pfeile.
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
class SimpleLinkedList{ private Node head; private Node tail; private int size;
public int Count => size;
public void AddFirst(string value) { var node = new Node(value); node.Next = head; head = node; if (tail == null) { tail = node; } size++; }
public void AddLast(string value) { var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++; }
public string RemoveFirst() { if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value; }
public bool Remove(string value) { Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true; }
public int Find(string value) { int index = 0; for (Node n = head; n != null; n = n.Next) { if (n.Value == value) { return index; } index++; } return -1; }
public string Get(int index) { if (index < 0 || index >= size) { throw new ArgumentOutOfRangeException(nameof(index)); } Node n = head; for (int i = 0; i < index; i++) { n = n.Next; } return n.Value; }
public override string ToString() { var values = new List<string>(); for (Node n = head; n != null; n = n.Next) { values.Add(n.Value); } return string.Join(" → ", values); }}Sieh dir an, welche Pfeile sich bei jeder Operation ändern:
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
private Node head;private Node tail;private int size;class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
private Node head;private Node tail;private int size;public void AddFirst(string value){ var node = new Node(value); node.Next = head; head = node; if (tail == null) { tail = node; } size++;}public void AddLast(string value){ var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++;}public string RemoveFirst(){ if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value;}public bool Remove(string value){ Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true;}Schritt für Schritt, in Worten
- Drei E-Mails, jede in ihrem eigenen Knoten: der Wert und Next, der Pfeil zum nächsten. head zeigt auf den ersten, tail auf den letzten.
- AddFirst(„Passwort“): ein neuer Knoten, sein Next zeigt auf den alten ersten, und head springt auf ihn. Zwei Pfeile, nichts wird verschoben: O(1).
- AddLast(„Einladung“): tail kennt den letzten schon, also wird nichts durchlaufen. Sein Next zeigt auf den neuen Knoten, und tail wandert weiter: O(1).
- RemoveFirst(): head wandert zum nächsten Knoten. Auf „Passwort“ zeigt nichts mehr, und sein Speicher wird von selbst freigegeben.
- Remove(„Newsletter“): Knoten haben keinen Index. n startet bei head und läuft, bis er ihn findet; prev bleibt einen Knoten dahinter. Das ist O(n).
- prev.Next = n.Next: Ein Pfeil führt um „Newsletter“ herum. „Bericht“ und „Einladung“ bewegen sich nicht.
- An den Enden: zwei Pfeile, O(1). In der Mitte: ein Pfeil, aber zur Stelle kommst du Schritt für Schritt, O(n).
var emails = new SimpleLinkedList();emails.AddLast("Rechnung");emails.AddLast("Newsletter");emails.AddLast("Bericht");emails.AddFirst("Passwort");emails.AddLast("Einladung");Console.WriteLine(emails);Console.WriteLine("Sende: " + emails.RemoveFirst());emails.Remove("Newsletter");Console.WriteLine(emails);Console.WriteLine("Position von Bericht: " + emails.Find("Bericht"));Console.WriteLine("E-Mails: " + emails.Count);Passwort → Rechnung → Newsletter → Bericht → EinladungSende: PasswortRechnung → Bericht → EinladungPosition von Bericht: 1E-Mails: 3RemoveFirst auf einer leeren Liste wirft InvalidOperationException, wie LinkedList<T>.RemoveFirst. ToString mit override ist das, was Console.WriteLine(emails) ausgibt. Get ist eine Methode und kein Indexer [i]: So siehst du, dass sie das Element Schritt für Schritt erreicht. Darum hat auch LinkedList<T> keinen Indexer.Probier es aus
Sag die Ausgabe voraus
Sieben Operationen hintereinander. Achte darauf, welche nach dem Entfernen die letzte ist:
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
class SimpleLinkedList{ private Node head; private Node tail; private int size;
public int Count => size;
public void AddFirst(string value) { var node = new Node(value); node.Next = head; head = node; if (tail == null) { tail = node; } size++; }
public void AddLast(string value) { var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++; }
public string RemoveFirst() { if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value; }
public bool Remove(string value) { Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true; }
public int Find(string value) { int index = 0; for (Node n = head; n != null; n = n.Next) { if (n.Value == value) { return index; } index++; } return -1; }
public string Get(int index) { if (index < 0 || index >= size) { throw new ArgumentOutOfRangeException(nameof(index)); } Node n = head; for (int i = 0; i < index; i++) { n = n.Next; } return n.Value; }
public override string ToString() { var values = new List<string>(); for (Node n = head; n != null; n = n.Next) { values.Add(n.Value); } return string.Join(" → ", values); }}
var emails = new SimpleLinkedList();emails.AddFirst("Bericht");emails.AddFirst("Rechnung");emails.AddLast("Einladung");emails.RemoveFirst();emails.AddFirst("Passwort");emails.Remove("Einladung");emails.AddLast("Newsletter");Console.WriteLine(emails);Console.WriteLine("E-Mails: " + emails.Count);Nach den ersten fünf ist die Liste Passwort → Bericht → Einladung. Remove entfernt die letzte und setzt tail auf Bericht. Darum wird Newsletter hinter Bericht angehängt. Würde tail noch auf die entfernte zeigen, würde Newsletter an sie angehängt und aus der Liste verschwinden.
Die Idee
Was was kostet: Array gegen verkettete Liste
| Operation | Dynamisches Array | Verkettete Liste |
|---|---|---|
| Lesen über den Index | O(1) | O(n) |
| vorne hinzufügen | O(n) | O(1) |
| hinten hinzufügen | O(1) amortisiert | O(1) mit tail |
| den ersten entfernen | O(n) | O(1) |
| den letzten entfernen | O(1) | O(n), doppelt verkettet: O(1) |
| in der Mitte | O(n): Verschieben | O(n): Laufen bis zur Stelle |
Die letzte Zeile ist die Falle. Das Umhängen in der Mitte ist für sich genommen O(1), aber zur Stelle kommst du von head aus, Schritt für Schritt. Die verkettete Liste gewinnt in der Mitte nur, wenn du den Knoten schon hältst, zum Beispiel weil du ihn gerade mit einer Hashtabelle gefunden hast.
Es gibt auch einen Preis im Speicher. Das Array hält die Werte (oder die Referenzen) nebeneinander. Jeder Knoten ist ein eigenes Objekt: der Wert, der Pfeil und der Kopf, den die Laufzeitumgebung für jedes Objekt anlegt. Bei kleinen Werten braucht ein Knoten mehrfach so viel Speicher wie ein Platz im Array.
Sieh es dir an
Miss es nach
Einfügen am Anfang: das dynamische Array aus der vorherigen Lektion gegen die verkettete Liste. Wir zählen die Schreibvorgänge von Elementen: beim Array das neue und jedes Verschieben, bei der Liste nur den neuen Knoten. Die Pfeile, die sich ändern, sind zwei pro Einfügen und wachsen nicht mit n.
- Vorne einfügen
O(n²) - Verkettete Liste: vorne
O(n)
| Algorithmus | n = 10 | n = 1.000 | n = 10.000 | n = 100.000 |
|---|---|---|---|---|
| Vorne einfügen | 63 | 501.516 | 50.021.376 | ≈ 5.002.137.600 |
| Verkettete Liste: vorne | 10 | 1.000 | 10.000 | 100.000 |
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
class SimpleLinkedList{ private Node head; private Node tail; private int size;
public int Count => size;
public void AddFirst(string value) { var node = new Node(value); node.Next = head; head = node; if (tail == null) { tail = node; } size++; }
public void AddLast(string value) { var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++; }
public string RemoveFirst() { if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value; }
public bool Remove(string value) { Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true; }
public int Find(string value) { int index = 0; for (Node n = head; n != null; n = n.Next) { if (n.Value == value) { return index; } index++; } return -1; }
public string Get(int index) { if (index < 0 || index >= size) { throw new ArgumentOutOfRangeException(nameof(index)); } Node n = head; for (int i = 0; i < index; i++) { n = n.Next; } return n.Value; }
public override string ToString() { var values = new List<string>(); for (Node n = head; n != null; n = n.Next) { values.Add(n.Value); } return string.Join(" → ", values); }}Bei 10.000 E-Mails macht das Array 50.021.376 Schreibvorgänge und die verkettete Liste 10.000.
Jetzt umgekehrt: jedes Element über den Index lesen, get(0), get(1) und so weiter bis zum Ende. Wir zählen die Stellen, die du erreichst: Im Array ist get(i) ein Sprung, in der Liste durchläuft es i + 1 Knoten.
- Array: get(i) für jedes i
O(n) - Verkettete Liste: get(i) für jedes i
O(n²)
| Algorithmus | n = 10 | n = 1.000 | n = 10.000 | n = 100.000 |
|---|---|---|---|---|
| Array: get(i) für jedes i | 10 | 1.000 | 10.000 | 100.000 |
| Verkettete Liste: get(i) für jedes i | 55 | 500.500 | 50.005.000 | ≈ 5.000.500.000 |
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
class SimpleLinkedList{ private Node head; private Node tail; private int size;
public int Count => size;
public void AddFirst(string value) { var node = new Node(value); node.Next = head; head = node; if (tail == null) { tail = node; } size++; }
public void AddLast(string value) { var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++; }
public string RemoveFirst() { if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value; }
public bool Remove(string value) { Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true; }
public int Find(string value) { int index = 0; for (Node n = head; n != null; n = n.Next) { if (n.Value == value) { return index; } index++; } return -1; }
public string Get(int index) { if (index < 0 || index >= size) { throw new ArgumentOutOfRangeException(nameof(index)); } Node n = head; for (int i = 0; i < index; i++) { n = n.Next; } return n.Value; }
public override string ToString() { var values = new List<string>(); for (Node n = head; n != null; n = n.Next) { values.Add(n.Value); } return string.Join(" → ", values); }}Bei 10.000 Elementen macht das Array 10.000 Sprünge und die verkettete Liste 50.005.000 Schritte. Eine Schleife über den Index auf einer verketteten Liste ist O(n²). Lauf stattdessen mit der Schleife entlang der Pfeile (oder mit for-each) durch, wie es ToString tut: Das ist O(n).
Genauer, für Neugierige
Dieselben Zahlen in deiner Sprache, mit Zählern in der Klasse:
class Node{ public string Value; public Node Next;
public Node(string value) { Value = value; }}
class SimpleLinkedList{ private Node head; private Node tail; private int size; public long Writes { get; private set; } public long Steps { get; private set; }
public int Count => size;
public void AddFirst(string value) { var node = new Node(value); Writes++; node.Next = head; head = node; if (tail == null) { tail = node; } size++; }
public void AddLast(string value) { var node = new Node(value); if (tail == null) { head = node; } else { tail.Next = node; } tail = node; size++; }
public string RemoveFirst() { if (head == null) { throw new InvalidOperationException("die Liste ist leer"); } string value = head.Value; head = head.Next; if (head == null) { tail = null; } size--; return value; }
public bool Remove(string value) { Node prev = null; Node n = head; while (n != null && n.Value != value) { prev = n; n = n.Next; } if (n == null) { return false; } if (prev == null) { head = n.Next; } else { prev.Next = n.Next; } if (n == tail) { tail = prev; } size--; return true; }
public int Find(string value) { int index = 0; for (Node n = head; n != null; n = n.Next) { if (n.Value == value) { return index; } index++; } return -1; }
public string Get(int index) { if (index < 0 || index >= size) { throw new ArgumentOutOfRangeException(nameof(index)); } Node n = head; Steps++; for (int i = 0; i < index; i++) { n = n.Next; Steps++; } return n.Value; }
public override string ToString() { var values = new List<string>(); for (Node n = head; n != null; n = n.Next) { values.Add(n.Value); } return string.Join(" → ", values); }}
foreach (int n in new[] { 10, 1000, 100000 }){ var list = new SimpleLinkedList(); for (int i = 0; i < n; i++) { list.AddFirst((1001 + i).ToString()); } Console.WriteLine($"am Anfang, n = {n}: {list.Writes} Schreibvorgänge");}foreach (int n in new[] { 10, 1000 }){ var list = new SimpleLinkedList(); for (int i = 0; i < n; i++) { list.AddLast((1001 + i).ToString()); } for (int i = 0; i < n; i++) { list.Get(i); } Console.WriteLine($"get(i) für jedes i, n = {n}: {list.Steps} Schritte");}am Anfang, n = 10: 10 Schreibvorgängeam Anfang, n = 1000: 1000 Schreibvorgängeam Anfang, n = 100000: 100000 Schreibvorgängeget(i) für jedes i, n = 10: 55 Schritteget(i) für jedes i, n = 1000: 500500 SchritteBench zählt Operationen, keine Zeit. Auf der Uhr ist der Unterschied beim Durchlaufen wegen des Caches noch größer zugunsten des Arrays: Das Array ist ein Block, die Knoten liegen verstreut, und der jeweils nächste ist vielleicht nicht im Cache. Das ist hier nicht gemessen, sondern nur erklärt, in der Lektion über das dynamische Array.
Probier es aus
Finde den Fehler
Ein Kollege hat AddLast geschrieben. Drei E-Mails hinten angefügt, und die Liste sieht so aus:
var emails = new SimpleLinkedList();emails.AddLast("Rechnung");emails.AddLast("Newsletter");emails.AddLast("Bericht");Console.WriteLine(emails);Console.WriteLine("E-Mails: " + emails.Count);Rechnung → BerichtE-Mails: 3Die Größe ist 3, und es sind zwei E-Mails. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der neue Knoten wird an head angehängt und nicht an den letzten. Die zweite E-Mail wird zum Nächsten der ersten. Die dritte wird wieder zum Nächsten der ersten und ersetzt die zweite, die nicht mehr in der Liste ist. Der neue Knoten muss an tail angehängt werden, deshalb wird tail gehalten.
Probier es aus
Ergänze
Remove hat den Knoten n gefunden und den davor, prev. Worauf muss prev jetzt zeigen?
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = ;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}prev muss auf den Knoten nach n zeigen, um ihn zu umgehen: n.Next. Ist n der letzte, ist das null, und darum wandert tail weiter unten auf prev.
Die Idee
Die doppelt verkettete Liste
Eine einfach verkettete Liste kann den letzten nicht schnell entfernen: Sie braucht den vorletzten, und auf den zeigt nichts zurück. Eine doppelt verkettete Liste hält in jedem Knoten zwei Pfeile, Next und Prev. Dann sind beide Enden O(1), und ein Knoten, den du schon hältst, wird mit vier Pfeilen entfernt, ohne den vorherigen zu suchen.
Der Preis ist ein weiterer Pfeil in jedem Knoten und mehr Umhängen bei jeder Operation.
LinkedList<T> in C# ist doppelt verkettet. Die Knoten sind sichtbar: AddLast gibt einen LinkedListNode<T> mit Next und Previous zurück, und Remove(node) entfernt den Knoten in O(1).Die Idee
Wo die verkettete Liste wirklich gewinnt
Für eine gewöhnliche Warteschlange ist die eingebaute besser. Queue<T> ist ein Array, das beide Enden ohne Verschieben hält. Wie das funktioniert, steht in der Lektion Stapel und Warteschlange. Die verkettete Liste gewinnt, wenn du den Knoten schon hältst und ihn herausnehmen oder verschieben musst:
- Ein LRU-Cache. Der Shop hält die zuletzt geöffneten Produkte im Speicher und wirft das am längsten nicht geöffnete hinaus. Eine Hashtabelle findet den Knoten des Produkts in O(1), und eine doppelt verkettete Liste schiebt ihn nach hinten. Der älteste steht immer vorne.
- Verlauf mit Zurück und Vor. Browser und Editor halten die Schritte in einer doppelt verketteten Liste: Zurück und Vor sind ein Pfeil.
- Die Listen im Kernel des Betriebssystems. Prozesse und Puffer tragen ihre Pfeile selbst und werden in O(1) aus der Liste genommen.
Hier ist ein LRU mit den eingebauten Mitteln, die letzten drei geöffneten Produkte:
class Recent{ private readonly int limit; private readonly LinkedList<string> order = new(); private readonly Dictionary<string, LinkedListNode<string>> nodes = new();
public Recent(int limit) { this.limit = limit; }
public void Open(string product) { if (nodes.TryGetValue(product, out var node)) { order.Remove(node); } else if (order.Count == limit) { nodes.Remove(order.First.Value); order.RemoveFirst(); } nodes[product] = order.AddLast(product); }
public override string ToString() { return string.Join(", ", order); }}
var recent = new Recent(3);foreach (string product in new[] { "Laptop", "Maus", "Monitor", "Laptop", "Tastatur" }){ recent.Open(product); Console.WriteLine(recent);}LaptopLaptop, MausLaptop, Maus, MonitorMaus, Monitor, LaptopMonitor, Laptop, TastaturDictionary findet den Knoten, und LinkedList<string> nimmt ihn heraus und hängt ihn hinten an. Beides ist O(1).Übung
Eine doppelt verkettete Liste
Löse die Aufgabe in deinem Editor.
Von beiden Enden entfernen. Baue eine DoublyLinkedList: Der Knoten hat auch Prev. Schreibe AddFirst, AddLast, RemoveFirst und RemoveLast, alle in O(1). Halte beide Enden richtig: Wird die Liste leer, sind head und tail beide null.
Füge die drei E-Mails hinten an und die mit dem Passwort vorne. Gib die Liste aus, entferne die letzte und die erste, gib sie wieder aus. Entferne zwei weitere von hinten, gib die Größe aus und füge dann die Einladung hinten an:
Passwort → Rechnung → Newsletter → BerichtLetzte: BerichtErste: PasswortRechnung → NewsletterE-Mails: 0EinladungWeitere Aufgaben
- Die Mitte mit zwei Zeigern. Finde den mittleren Knoten in einem Durchlauf: Ein Zeiger geht einen Schritt, der andere zwei. Hat der schnelle das Ende erreicht, steht der langsame in der Mitte.
- Ein Zyklus in der Liste. Ein Fehler hat den letzten Knoten an einen der vorherigen gehängt. Erkenne das mit denselben zwei Zeigern: Gibt es einen Zyklus, holt der schnelle den langsamen ein.
- Alle Vorkommen entfernen.
RemoveAll(string value)entfernt in einem Durchlauf jeden Knoten mit diesem Wert und gibt zurück, wie viele es waren. Vergiss tail nicht. - LRU von Hand. Baue den LRU-Cache aus der Karte oben mit deiner eigenen doppelt verketteten Liste und einer
Dictionaryvom Produkt zum Knoten.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Bester, durchschnittlicher und schlechtester Fall · Speicherkomplexität · Amortisierte Komplexität · Das dynamische Array von innen
- Geht weiter in: Hashtabelle · Binärer Suchbaum
- Aufgaben zum Thema: Datenstrukturen