Doppelt verkettete Listen in C
Doppelt verkettete Listen (oder doubly linked lists) sind häufig benutze Datenstrukturen und eine Verallgemeinerung der einfach verketteten Listen, die ich hier anhand von Beispielen in der Programmiersprache C vorstellen will.
Sie funktioniert in jeder Programmiersprache genauso, die Zeiger oder Referenzen und so etwas wie Klassen oder Structs zur Verfügung stellt.
Was bringen doppelt verkettete Listen?
Doppelt verkettete Listen, oder "double linked lists" braucht man immer dann, wenn man sich in einer Liste leicht vorwärts und rückwärts bewegen können muß, und wenn man schnell und einfach Elemente der Liste an beliebigen Positionen löschen und neue einfügen muß.
Ein Beispiel ist z.B. die Implementierung Spärlich besetzter Matrizen ("Sparse Matrices"), aber es gibt viele weiter Anwendungen.
Die Grundidee
Die Idee einer doppelt verketteten Liste ist es, für jedes Element zwei Zeiger zu speichern, einen nach links und einen nach rechts:
struct node { struct node *left; struct node *right; int data; };
Die eigentlichen Daten, die in der Liste gespeichert werden sollen,
stehen in data
, das hier als int
definiert
ist. Jeder andere Datentyp, z.B. ein Zeiger auf beliebige andere
Strukturen, funktioniert genau so.
Die Illustrationen weiter unten werden dazu beitragen, die Struktur verständlich zu machen.
Eine Liste erzeugen
Eine Liste ist nichts anderes als ein einzelnes Element, das auf sich selbst zeigt. Mit diesem Wissen kann man eine neue Liste erzeugen:
struct node * new_list(){ struct node *new = (struct node*) malloc(sizeof(struct node)); new->data = -1; new->right = new; new->left = new; return new; }
(Anmerkung: In C++ darf man die Variable nicht new
nennen, da
das ein reserviertes Wort ist).
Dadurch, dass das erste Element auf das letzte zeigt und umgekehrt, kann man sich bei den späteren Operationen Abfragen ersparen, ob man bei dem letzten Element angekommen ist.
Oft empfiehlt es sich, dem ersten Element einen Wert zu geben, den
die anderen Elemente nicht annehmen können, z.B. -1
wenn alle anderen Elemente größer gleich Null sind.
Elemente einfügen
Jetzt kommt der eigentliche Vorteil doppelt verketteter Listen zum tragen: das Einfügen neuer Elemente ist sehr einfach:
struct node * insert_right(struct node *list, int data){ struct node *new = (struct node *) malloc(sizeof(struct node)); new->data = data; new->left = list; new->right = list->right; list->right = new; new->right->left = new; return new; }
Die Schritte im einzelnen:
Ein neues struct node
wird erstellt und die Daten
werden initialisierst.
Die Zeiger des neuen Elements werden gesetzt.
Der Zeiger right
des Elements auf der linken Seite wird
auf das neue Element gesetzt, ebenso der Zeiger left
des
Elements auf der rechten Seite des neu eingefügten Elements.
Noch ein Beispiel für das Einfügen
Da das vorherige Beispiel ein Spezialfall einer Liste mit nur einem
Element war, hier noch ein Beispiel, diesmal mit einer zweielementigen
Liste, in die ein drittes Element rechts neben
head
, also in der Mitte eingefügt werden soll.
Eine doppelt verkettete Liste mit zwei Elementen.
Ein neues Objekt wird erstellt, der Zeiger
new->left
wird auf head
gesetzt, der
Zeiger new->right
wird auf head->right
gesetzt, was gleichbedeutend mit node1
ist.
Der Zeiger head->right
wird auf new
gesetzt. Jetzt muss noch node1->left
auf
new
gesetzt werden, aber da wir keinen Zeiger mehr von
head
aus auf node1
haben, ist der einfachste
Weg über new->right
: new->right->left
= new
.
Und schon sind wir fertig - mit dem umbiegen von nur vier Zeigern.
Hier noch einmal die gleiche Liste, diesmal mit dem neuen Element auf gleicher Höhe wie die anderen Elemente. Sieht doch gleich viel übersichtlicher aus...
topElemente Löschen
Für viele Backtracking-Algorithmen ist es hilfreich, wenn man Elemente schnell entfernen und gegebenenfalls wieder einfügen kann. Hier erst mal der codes fürs löschen:
struct node * delete(struct node *list){ list->right->left = list->left; list->left->right = list->right; return list->left; }
Am Beispiel:
Aus dieser Liste soll node2
entfernt werden, es wird
also delete(node2)
aufgerufen.
list->right->left
, also
node3->left
, auf den linken Nachbarn des zu
löschenden Elements
gesetzt, also auf node1
.
Danach kommt die andere Richtung dran, d.h.
list->left->right
, also
node1->right
, wird auf den rechten Nachbarn des zu
löschenden Elements gesetzt, also auf node3
.
Wenn man sich jetzt von head
aus an den Zeigern
entlang durch die Liste hangelt, erreicht man node2
nicht mehr, das Element ist also quasi gelöscht.
Allerdings wird in Funktion den Speicher, den node2
,
oder wie es innerhalb der Funktion heißt, list
benötigt, nicht wieder freigegeben, denn es können ja noch
andere Zeiger auf dieses Element existieren.
Und wenn man das Element nicht löscht, kann man es später wiederherstellen, wenn man noch einen Zeiger darauf hat.
Elemente wiederherstellen
Gelöschte Elemente, auf die man noch einen Pointer hat, können sehr einfach wieder in die Liste eingefügt werden:
struct node * restore(struct node *list){ list->right->left = list; list->left->right = list; return list; }
Das ist sogar noch weniger Aufwand als das Einfügen, da zwei der Zeiger schon richtig gesetzt sind, und zwar die in dem wiederherzustellendem Element.
Die Liste durchlaufen
Es ist sehr einfach, die doppelt verkettete Liste nach rechts oder links zu durchlaufen, man muss lediglich darauf aufpassen, dass man nach einem Durchlauf aufhört.
Hier ein Beispiel zur Ausgabe einer Liste:
void print_all(struct node* list){ struct node *head = list; struct node *current = list; printf("%d ", head->data); while (head != (current = current->right)){ printf("%d ", current->data); } printf("\n"); }
Hier wird das erste Element der übergebenen Liste in der
Variable head
gespeichert, und current
läuft durch die Liste, in diesem Fall nach rechts.
Wenn beide wieder gleich sind, d.h. die Liste einmal vollständig durchlaufen ist, wird die Schleife beendet.
Ein kleines Testprogramm
Zeit, die geschriebenen Funktionen zu testen:
int main(int argc, char** argv){ /* erstelle eine neue Liste: */ struct node *head = new_list(); struct node *current = head; int i; /* Befuelle die List mit den Zahlen 1 bis 10 */ for(i = 1; i <= 10; i++){ current = insert_right(current, i); } /* Gebe die Liste aus, einmal von head aus startent, einmal vom letzten Element aus: */ print_all(head); print_all(current); /* loesche das vorletzte Element: */ current = current->left; current = delete(current); printf("Liste nach loeschen:\n"); print_all(head); current = delete(head); printf("Liste nach loeschen des Kopfes:\n"); print_all(current); head = restore(head); printf("Liste nach Wiederherstellen des Kopfes:\n"); print_all(head); return 0; }
Und die passende Ausgabe dazu:
-1 1 2 3 4 5 6 7 8 9 10 10 -1 1 2 3 4 5 6 7 8 9 Liste nach loeschen: -1 1 2 3 4 5 6 7 8 10 Liste nach loeschen des Kopfes: 10 1 2 3 4 5 6 7 8 Liste nach Wiederherstellen des Kopfes: -1 1 2 3 4 5 6 7 8 10
Offensichtlich funktionieren die Funktionen wie gewünscht.
Mögliche Probleme
Hier sei noch auf ein paar Fehler hingewiesen, die man vermeiden sollte:
Endlosschleifen
Was macht folgender Code?
delete(current); print_all(current);
Das Element current
wird gelöscht, und
anschließend wird die Liste ausgegeben.
Prinzipiell sollte das funktionieren, schließlich sind in
dem entfernten Element noch Zeiger auf den Rest der Liste gespeichert.
Allerdings erzeugt man damit eine Endlosschleife in der Funktion
print_all
, da diese solange läuft, bis sie zum
ursprünglichen Element zurückkommt.
Da das Element aber nicht von der Liste aus erreichbar ist, wird die Liste so oft ausgegeben, bis der Benutzer das Programm abbricht.
Daraus kann man sich folgende Regel herleiten:
Mit entfernten Elementen einer Liste darf man nichts
anderes machen, als restore
aufzurufen.
Memory Leaks
Was macht folgender Code?
current = insert_right(current, 2);
current = delete(current);
Nun, erst wird ein Knoten mit dem Wert 2 eingefügt, und dann wird er wieder entfernt, die Liste ist weiterhin intakt.
Allerdings wurde in insert_right
ein Stück
Speicher für das neue Element reserviert, auf den es keinen
Pointer mehr gibt, der aber auch nicht frei geworden wurde.
Wenn das mehrfach in den Programm passiert, läuft der Speicher mit nutzlosen Daten voll.
Abhilfe schafft es, das Element mit free()
zu
löschen und den Speicher an das Betriebssystem
zurückzugeben:
current = insert_right(current, 2); struct node * tmp = current; current = delete(current); free(tmp); tmp = NULL;
Wenn das häufiger vorkommt, ist es sinnvoll eine Funktion
delete_and_free
zu schreiben, die das gelöschte
Element automatisch frei gibt:
struct node * delete_and_free(struct node *list){ list->right->left = list->left; list->left->right = list->right; struct node *res = list->left; free(list); return list->left; }
Diese beiden Probleme, Endlosschleifen und Memory Leaks, kann man
umgehen, indem man die Datenstruktur weiter kapselt und als Interface
z.B. immer nur head
und einen aktuellen Zeiger
anbietet.
Allerdings verliert man damit auch ein wenig Funktionalität.
Löschen und wiederherstellen mehrerer Elemente
Man betrachte eine Liste aus einem Kopf und drei Elementen
node1
, node2
und node3
, aus der
das zweite Element gelöscht wird:
Und jetzt lösche man node1
:
Das sieht nicht mehr so schön aus. Wenn man jetzt erst
node1
und dann node2
wieder herstellen, sind
wir wieder bei der ursprünglichen Liste.
Aber was passiert, wenn man erst node2
wiederherstellt?
Die Antwort ist, dass ein Zeiger von dem gelöschten
node1
anstatt von head
auf das
wiederhergestellte Element verbogen wurde, das Resultat ist keine
gültige, doppelt verkettete Liste.
Aus diesem Beispiel kann man sich eine Regel herleiten:
Gelöschte Elemente dürfen nur in umgekehrter Reihenfolge wiederhergestellt werden.
Zusammenfassung
Die meisten Operationen auf doppelt verketteten Listen bestehen aus dem umsetzen von einem bis vier Zeigern; solange man die Übersicht bewahrt und sich vorher aufmalt, welche Zeiger wohin müssen, sind die Operationen auch nicht kompliziert.
Das bewegen in der Liste sowie das Einfügen und Löschen von Elementen geht jeweils sehr schnell, nur der Zugriff auf ein Element mit einer bestimmten Nummer - was die Stärke von Arrays ist - erfordert das Durchlaufen der einzelnen Elemente.
Weiteres Lesematerial
- Einfach verkettete Listen
- Heaps und Heapsort
Bücher
"Algorithmen in C" von Robert Sedgewik ist allgemeinverständlich und praktisch. "The Art of Computer Programming" von Donald Knuth ist ein Klassiker, und beschreibt für den mathematisch interessierten Leser Algorithmen, Datenstrukturen und ihre Vor- und Nachteile im Detail (Englisch).