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:
emails = []emails.append("Rechnung")emails.append("Newsletter")emails.append("Bericht")emails.insert(0, "Passwort")next_email = emails.pop(0)print("Sende: " + next_email)print("Übrig: " + ", ".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 None. 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: def __init__(self, value): self.value = value self.next = None
class SimpleLinkedList: def __init__(self): self.head = None self.tail = None self.size = 0
def __len__(self): return self.size
def add_first(self, value): node = Node(value) node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1
def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1
def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return value
def remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return True
def find(self, value): index = 0 n = self.head while n is not None: if n.value == value: return index index += 1 n = n.next return -1
def get(self, index): if index < 0 or index >= self.size: raise IndexError(f"index {index}, size {self.size}") n = self.head for _ in range(index): n = n.next return n.value
def __str__(self): values = [] n = self.head while n is not None: values.append(n.value) n = n.next return " → ".join(values)Sieh dir an, welche Pfeile sich bei jeder Operation ändern:
class Node: def __init__(self, value): self.value = value self.next = None
def __init__(self): self.head = None self.tail = None self.size = 0class Node: def __init__(self, value): self.value = value self.next = None
def __init__(self): self.head = None self.tail = None self.size = 0def add_first(self, value): node = Node(value) node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return valuedef remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return TrueSchritt 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.
- add_first(„Passwort“): ein neuer Knoten, sein next zeigt auf den alten ersten, und head springt auf ihn. Zwei Pfeile, nichts wird verschoben: O(1).
- add_last(„Einladung“): tail kennt den letzten schon, also wird nichts durchlaufen. Sein next zeigt auf den neuen Knoten, und tail wandert weiter: O(1).
- remove_first(): 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).
emails = SimpleLinkedList()emails.add_last("Rechnung")emails.add_last("Newsletter")emails.add_last("Bericht")emails.add_first("Passwort")emails.add_last("Einladung")print(emails)print("Sende: " + str(emails.remove_first()))emails.remove("Newsletter")print(emails)print("Position von Bericht: " + str(emails.find("Bericht")))print("E-Mails: " + str(len(emails)))Passwort → Rechnung → Newsletter → Bericht → EinladungSende: PasswortRechnung → Bericht → EinladungPosition von Bericht: 1E-Mails: 3remove_first auf einer leeren Liste wirft IndexError, wie deque.popleft. __str__ ist das, was print(emails) ausgibt. get ist eine Methode und kein [i]: So siehst du, dass sie das Element Schritt für Schritt erreicht.Probier es aus
Sag die Ausgabe voraus
Sieben Operationen hintereinander. Achte darauf, welche nach dem Entfernen die letzte ist:
class Node: def __init__(self, value): self.value = value self.next = None
class SimpleLinkedList: def __init__(self): self.head = None self.tail = None self.size = 0
def __len__(self): return self.size
def add_first(self, value): node = Node(value) node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1
def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1
def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return value
def remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return True
def find(self, value): index = 0 n = self.head while n is not None: if n.value == value: return index index += 1 n = n.next return -1
def get(self, index): if index < 0 or index >= self.size: raise IndexError(f"index {index}, size {self.size}") n = self.head for _ in range(index): n = n.next return n.value
def __str__(self): values = [] n = self.head while n is not None: values.append(n.value) n = n.next return " → ".join(values)
emails = SimpleLinkedList()emails.add_first("Bericht")emails.add_first("Rechnung")emails.add_last("Einladung")emails.remove_first()emails.add_first("Passwort")emails.remove("Einladung")emails.add_last("Newsletter")print(emails)print("E-Mails: " + str(len(emails)))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: def __init__(self, value): self.value = value self.next = None
class SimpleLinkedList: def __init__(self): self.head = None self.tail = None self.size = 0
def __len__(self): return self.size
def add_first(self, value): node = Node(value) node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1
def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1
def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return value
def remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return True
def find(self, value): index = 0 n = self.head while n is not None: if n.value == value: return index index += 1 n = n.next return -1
def get(self, index): if index < 0 or index >= self.size: raise IndexError(f"index {index}, size {self.size}") n = self.head for _ in range(index): n = n.next return n.value
def __str__(self): values = [] n = self.head while n is not None: values.append(n.value) n = n.next return " → ".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: def __init__(self, value): self.value = value self.next = None
class SimpleLinkedList: def __init__(self): self.head = None self.tail = None self.size = 0
def __len__(self): return self.size
def add_first(self, value): node = Node(value) node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1
def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1
def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return value
def remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return True
def find(self, value): index = 0 n = self.head while n is not None: if n.value == value: return index index += 1 n = n.next return -1
def get(self, index): if index < 0 or index >= self.size: raise IndexError(f"index {index}, size {self.size}") n = self.head for _ in range(index): n = n.next return n.value
def __str__(self): values = [] n = self.head while n is not None: values.append(n.value) n = n.next return " → ".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 __str__ tut: Das ist O(n).
Genauer, für Neugierige
Dieselben Zahlen in deiner Sprache, mit Zählern in der Klasse:
class Node: def __init__(self, value): self.value = value self.next = None
class SimpleLinkedList: def __init__(self): self.head = None self.tail = None self.size = 0 self.writes = 0 self.steps = 0
def __len__(self): return self.size
def add_first(self, value): node = Node(value) self.writes += 1 node.next = self.head self.head = node if self.tail is None: self.tail = node self.size += 1
def add_last(self, value): node = Node(value) if self.tail is None: self.head = node else: self.tail.next = node self.tail = node self.size += 1
def remove_first(self): if self.head is None: raise IndexError("die Liste ist leer") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None self.size -= 1 return value
def remove(self, value): prev = None n = self.head while n is not None and n.value != value: prev = n n = n.next if n is None: return False if prev is None: self.head = n.next else: prev.next = n.next if n is self.tail: self.tail = prev self.size -= 1 return True
def find(self, value): index = 0 n = self.head while n is not None: if n.value == value: return index index += 1 n = n.next return -1
def get(self, index): if index < 0 or index >= self.size: raise IndexError(f"index {index}, size {self.size}") n = self.head self.steps += 1 for _ in range(index): n = n.next self.steps += 1 return n.value
def __str__(self): values = [] n = self.head while n is not None: values.append(n.value) n = n.next return " → ".join(values)
for n in [10, 1000, 100000]: list_ = SimpleLinkedList() for i in range(n): list_.add_first(str(1001 + i)) print(f"am Anfang, n = {n}: {list_.writes} Schreibvorgänge")for n in [10, 1000]: list_ = SimpleLinkedList() for i in range(n): list_.add_last(str(1001 + i)) for i in range(n): list_.get(i) print(f"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 add_last geschrieben. Drei E-Mails hinten angefügt, und die Liste sieht so aus:
emails = SimpleLinkedList()emails.add_last("Rechnung")emails.add_last("Newsletter")emails.add_last("Bericht")print(emails)print("E-Mails: " + str(len(emails)))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?
def remove(self, value):
prev = None
n = self.head
while n is not None and n.value != value:
prev = n
n = n.next
if n is None:
return False
if prev is None:
self.head = n.next
else:
prev.next =
if n is self.tail:
self.tail = prev
self.size -= 1
return Trueprev muss auf den Knoten nach n zeigen, um ihn zu umgehen: n.next. Ist n der letzte, ist das None, 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.
collections.deque in Python ist doppelt verkettet, aber jeder Knoten ist ein Block aus 64 Elementen. So ist sie an beiden Enden schnell, und für die Pfeile wird weniger Speicher gebraucht.Die Idee
Wo die verkettete Liste wirklich gewinnt
Für eine gewöhnliche Warteschlange ist die eingebaute besser. deque hält Blöcke von 64 Elementen und verschiebt nichts. 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:
from collections import OrderedDict
class Recent: def __init__(self, limit): self.limit = limit self.items = OrderedDict()
def open(self, product): if product in self.items: self.items.move_to_end(product) else: if len(self.items) == self.limit: self.items.popitem(last=False) self.items[product] = True
def __str__(self): return ", ".join(self.items)
recent = Recent(3)for product in ["Laptop", "Maus", "Monitor", "Laptop", "Tastatur"]: recent.open(product) print(recent)LaptopLaptop, MausLaptop, Maus, MonitorMaus, Monitor, LaptopMonitor, Laptop, TastaturOrderedDict hält die Schlüssel in einer doppelt verketteten Liste. move_to_end schiebt das Produkt nach hinten, und popitem(last=False) wirft das älteste hinaus.Ü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 add_first, add_last, remove_first und remove_last, alle in O(1). Halte beide Enden richtig: Wird die Liste leer, sind head und tail beide None.
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.
remove_all(self, 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
dictvom 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