Listen

Verkettete Liste

Eine verkettete Liste (linked list) stellt eine Datenstruktur dar, die aus einer Reihe von Knoten besteht, die miteinander verknüpft sind. Jeder Knoten enthält einen Wert und einen Verweis auf den nächsten Knoten in der Liste. Der letzte Knoten hat keinen Nachfolger und ist entsprechend gekennzeichnet, z. B. durch den Wert null in der Referenz auf das nächste Element.

verkettete Liste (linked list)

  • zwei Formen
    • einfach verkettet
    • doppelt verkettet
  • Knoten können leicht eingefügt und gelöscht werden
  • Zugriff über Index ist aufwändig
Einfach verkettete Liste,
Einfach verkettete Liste, Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage
Doppelt verkettete Liste,
Doppelt verkettete Liste, Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage

Typische Methoden

  • prepend: Fügt ein Element vorne ein
  • append: Fügt ein Element am Ende ein
  • insert: Fügt ein Element an einer bestimmten Stelle ein
  • remove: Entfernt ein Element an einer bestimmten Stelle
  • find: findet ein Element
  • size: gibt die Anzahl der Elemente in der Liste zurück
  • isEmpty: gibt an, ob die Liste leer ist oder nicht

Eine verkettete Liste hat den Vorteil, dass Elemente einfach hinzugefügt oder entfernt werden können, ohne dass die restlichen Elemente verschoben werden müssen.

Es gibt zwei Arten von verketteten Listen: einfach verkettete Listen (engl. singly linked list) und doppelt verkettete Listen (engl. doubly linked list).

  • Einfach verkettete Liste: Jeder Knoten enthält einen Wert und einen Verweis auf den nächsten Knoten.
  • Doppelt verkettete Liste: Jeder Knoten enthält einen Wert, einen Verweis auf den nächsten Knoten und einen Verweis auf den vorherigen Knoten.

Die erste und die letzten Knoten haben spezielle Bezeichnungen: head und tail.

verkettete Listen sind nützlich in Situationen, in denen häufig Elemente eingefügt oder entfernt werden, da diese Operationen in einer verketteten Liste in der Regel in konstanter Zeit durchgeführt werden können. Ein Nachteil von verketteten Listen ist, dass es schwieriger und zeitaufwendiger ist, ein Element an einer bestimmten Position zu finden, da man durch die Liste iterieren muss, um die richtige Position zu finden.

Einfach verkettete Liste

Die einfach verkettete Liste besteht aus Knoten (Node), die neben den eigentlichen Daten (data) auch noch eine Referenz auf den nächsten Knoten (next) enthalten.

Das folgende Beispiel zeigt eine einfach verkettete Liste, die int-Werte verarbeiten kann. Das Schema lässt sich auf jeden anderen Datentyp erweitern. In der Praxis würde man die Liste als generichen Typ ausprägen, sodass man mit einer Implementierung beliebige Datentypen speichern und verarbeiten kann.

Einzelner Knoten der Liste
class Node {
    int data;
    Node next;

    public Node(int data) {
        this.data = data;
        this.next = null;
    }
}

Die Liste selbst hat nur eine einzige Referenz auf den Anfangsknoten (head).

Liste
public class LinkedList {
    private Node head;
    ...
}

Bei einer realen Implementierung würde man aus Effizienzgründen noch weitere Daten mitführen, z. B. die Anzahl der Elemente.

Eine Hilfsmethode last() innerhalb der Liste sucht den letzten Knoten der Liste. Diese Methode würde man bei einer doppelt verketteten Liste nicht benötigen, weil diese ihren Endknoten speichert.

Diese Methode muss die gesamte Liste durchlaufen, benötigt also bei einer Liste der Länge n auch n Schritte, \( \mathcal{O}(n) \).

last
private Node last() {
    Node result = head;

    while (result != null && result.next != null) {
        result = result.next;
    }

    return result;
}

Einfach verkettete Liste: append

Das folgende Beispiel zeigt, wie man am Ende der Liste einen neuen Eintrag anfügt. Die Methode append() greift hier auf die last()-Methode zurück, um das Ende der Liste zu finden.

append
public void append(int data) {
    Node newNode = new Node(data);
    Node last = last();

    if (last == null) {
        head = newNode;
    }
    else {
        last.next = newNode;
    }
}

Die Benutzung der last()-Methode führt dazu, dass das Anfügen eines Elements bei der einfach verketteten Liste keine effiziente Operation ist.

Die folgende Grafik zeigt, wie das Programm die Liste beim Anfügen verändert.

Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage

Einfach verkettete Liste: prepend

Die prepend()-Methode fügt ein Element am Anfang der Liste hinzu. Die Methode is effizient und unabhängig von der Größe der Liste, weil der erste Knoten bekannt ist und nicht gesucht werden muss.

prepend
public void prepend(int data) {
    Node newNode = new Node(data);

    if (head == null) {
        head = newNode;
        return;
    }

    newNode.next = head;
    head = newNode;
}

Einfach verkettete Liste

Die Grafik zeigt den Ablauf des Hinzufügens eines Elements am Anfang der Liste.

Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage

Doppelt verkettete Liste

Das Anfügen von Elemente an eine Liste ist eine sehr häufige Operation in realen Programmen. Gerade hier ist aber die einfach verkettete Liste ineffizient und muss jedes Mal die gesamte Liste durchlaufen, um das Ende zu finden. Dieses Problem löst man mit der doppelt verketteten Liste, bei der jeder Knoten (Node) sowohl seinen Nachfolger (next), als auch seinen Vorgänger (prev) kennt.

Einzelner Knoten der Liste
class Node {
    int data;
    Node next;
    Node prev;
    Node(int data) {
        this.data = data;
        this.next = null;
        this.prev = null;
    }
}

Die Liste selbst speichert dann sowohl den ersten (head) als auch den letzten Knoten (tail) der Liste.

Liste
public class DoubleLinkedList {
    Node head;
    Node tail;
}

Doppelt verkettete Liste: append

Durch die doppelte Verkettung ist es sehr effizient möglich, neue Knoten am Ende der Liste einzufügen. Der Aufwand dieser Operation ist unabhängig von der Anzahl der Elemente und damit in konstanter Zeit, \( \mathcal{O}(1) \), möglich.

append
public void append(int data) {
    Node newNode = new Node(data);

    if (head == null) {
        head = newNode;
        tail = newNode;
        return;
    }

    tail.next = newNode;
    newNode.prev = tail;
    tail = newNode;
}

Die Grafik zeigt den Ablauf beim Anfügen eines neuen Elements an das Ende der List.

Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage

Doppelt verkettete Liste: prepend

Möchte man ein Element am Anfang der doppelt verketteten Liste anfügen, so unterscheidet sich dieser Vorgang nicht von der einfach verketteten Variante. Der einzige Unterschied ist, dass man eine Referenz mehr (prev) setzen muss.

prepend
public void prepend(int data) {
    Node newNode = new Node(data);

    if (head == null) {
        head = newNode;
        tail = newNode;
        return;
    }

    newNode.next = head;
    head.prev = newNode;
    head = newNode;
}

Iterator

Ein Iterator (auch als Zeiger oder Cursor bezeichnet) ist ein abstrakter Datentyp, der es ermöglicht, durch die Elemente einer Datenstruktur (wie z. B. einer Liste oder eines Arrays) zu iterieren, ohne dass die tatsächliche Implementierung der Datenstruktur bekannt ist.

Iterator bietet einheitlichen Zugriff auf unterschiedliche Datenstrukturen (Listen, Sets, …)

Typische Methoden

  • next: gibt das nächste Element in der Datenstruktur zurück und bewegt den Iterator eine Position weiter
  • hasNext: gibt an, ob es noch weitere Elemente in der Datenstruktur gibt
  • reset: setzt den Iterator zurück auf den Anfang der Datenstruktur
Quelle: Gunter Saake und Kai-Uwe Sattler: Algorithmen und Datenstrukturen, 6. Auflage

Ein Iterator ermöglicht es, durch die Elemente einer Datenstruktur zu iterieren, ohne dass die tatsächliche Implementierung der Datenstruktur bekannt ist. Es erhöht auch die Wiederverwendbarkeit von Code, da der Iterator unabhängig von der spezifischen Datenstruktur verwendet werden kann.

Beispiel: Ein Iterator kann verwendet werden, um durch die Elemente einer Liste zu iterieren, ohne dass die tatsächliche Implementierung der Liste (z. B. Array, verkettete Liste) bekannt ist. So kann der Iterator auf jede Art von Liste angewendet werden, solange sie die notwendigen Methoden zum Durchlaufen der Elemente bereitstellt.

Ein weiteres Beispiel wäre das Verwenden von einem Iterator für das Durchlaufen durch eine komplexe Datenstruktur wie einem Baum oder einem Graphen.

Das folgende Beispiel zeigt einen einfachen Iterator, der über int-Array iterieren kann.

public class ArrayIterator {
    private final int[] array;
    private int pos = 0;

    public ArrayIterator(int[] array) {
        this.array = array.clone();
    }

    public boolean hasNext() {
        return pos < array.length;
    }

    public int next() {
        return array[pos++]; // ArrayIndexOutOfBoundsException
    }

    public void reset() { pos = 0; }
}

Die clone()-Operation im Konstruktor ist hier eingefügt, damit der Iterator gegen Änderungen des Arrays während der Iteration immun ist. Will man solche Änderungen live im Iterator sehen, muss man das clone() entfernen.


Copyright © 2025 Thomas Smits