Grundzüge der Informatik V15. Datenstrukturen Prof. Dr. Max Mühlhäuser FG Telekooperation TU-Darmstadt 9.1.2003 © MM ... GdI1 - Datenstrukturen 1 Agenda Datenstrukturen Warum abstrakte Datentypen? Programmierung und Benutzung einiger Datentypen Implementierung dynamischer Strukturen “Verzeigerte” Datenstrukturen Effizienzbetrachtungen Polymorphie Einsatz spezieller Knotentypen 9.1.2003 © MM ... GdI1 - Datenstrukturen 2 Lernziele • Die Eigenschaften der Datenstrukturen Liste, Stack und Queue zu kennen • Selbstständig einfache dynamische Datenstrukturen mit ihren Operationen entwerfen, programmieren, beurteilen und adäquat einsetzen zu können • Polymorphie sinnvoll bei der Implementierung von Datentypen einsetzen zu können 9.1.2003 © MM ... GdI1 - Datenstrukturen 3 Warum abstrakte Datentypen? Warum ist es sinnvoll neben den bereits bekannten primitiven und Behälterdatentypen (z.B. Array) noch weitere zur Verfügung zu stellen? • Prinzipiell wären die bereits bekannten Typen in Kombination mit entsprechenden Algorithmen ausreichend • Die Formulierung von Algorithmen wird jedoch erheblich einfacher, wenn sie sich auf Datenstrukturen mit Funktionalitäten abstützen können • Ein wichtiger Aspekt der objektorientierten Programmierung ist die Entwicklung neuer Typen und deren Wiederverwendung (Reuse). 9.1.2003 © MM ... GdI1 - Datenstrukturen 4 Datenstruktur: Liste • Eine verkettete Liste (linked list) ist eine dynamische Struktur mit einfachen Einfüge- und Löschmöglichkeiten. Im Gegensatz zu Arrays können Listen zur Laufzeit beliebig wachsen und verändert werden. • Jedes Listenelement besteht aus zwei Komponenten: – Einem Objekt oder primitiven Datentyp. Diese Komponente ist das in der Liste abgelegte Element. – Einem Zeiger auf das nächste Element in Form eines Zeigers auf ein Listenelement. • Schematischer Aufbau eines Listenelements: 9.1.2003 © MM ... GdI1 - Datenstrukturen 5 Datenstruktur: Liste • Eine verkettete Liste besteht nun aus dem Aneinanderfügen von mehreren Listenelementen. • Da die next-Komponente eine Referenz auf ein Listenelement ist, kann dort der Verweis auf ein weiteres Element abgelegt werden. • Die so entstehende Struktur sieht wie folgt aus: • Das Zeichen am Listenende steht dabei für eine leere Referenz, auch als null bezeichnet. null bedeutet, dass kein entsprechendes Objekt existiert. Im Beispiel gibt es bei Objekt 4 kein nächstes Element – die Liste endet hier. 9.1.2003 © MM ... GdI1 - Datenstrukturen 6 Navigation in Listen • Die Navigation in Listen, d.h. das Laufen von einem Element zu einem anderen, erfolgt durch Zugriffe auf das next Element: Element head; // Verweis auf das erste Element Element current; // Verweis auf das aktuelle Element // Gehe zum nächsten Element der Liste // Ist das aktuelle Element null, gehe zum Anfang public void navigate() { if (current != null) // Gibt es ein aktuelles Element? current = current.next(); // Ja => gehe zum nächsten Element else current = head; // Sonst gehe zum ersten Element } 9.1.2003 © MM ... GdI1 - Datenstrukturen 7 Einfügen von Elementen • Beim Einfügen eines neuen Elements newElem in eine Liste sind drei Fälle zu unterscheiden: – Einfügen vor dem ersten Listenelement („prepend“) Das erste Listenelement wird zum Nachfolger des neuen Elements; head zeigt auf das neue Element. – Einfügen hinter einem gegebenen Element („insert“) Es wird zu dem gewünschten Element gelaufen. next des neuen Elements übernimmt den Wert next des aktuellen Elements; anschließend wird current.next auf das neue Element gesetzt. – Einfügen als letztes Element („append“) Die Liste wird bis zum letzten Element durchlaufen, d.h. bis current.getNext()==null. current.next wird auf die Adresse des neuen Elements gesetzt. => Ein Sonderfall von „insert“. • In den folgenden Abbildungen geben gepunktete Linien die neue Position der vorherigen Elemente an; gestrichelte Linien markieren geänderte Einträge. 9.1.2003 © MM ... GdI1 - Datenstrukturen 8 Listen: Einfügen Einfügen am Anfang 1. newElem.setNext(head) // im Beispiel: newElem == Objekt 1 2. head = newElem 9.1.2003 © MM ... GdI1 - Datenstrukturen 9 Listen: Einfügen Einfügen innerhalb der Liste 1. Laufe zu dem Listenelement, hinter dem eingefügt werden soll (hier: Objekt 2) 2. newElem.setNext(current.next()) // hier: newElem==Objekt 3 3. current.setNext(newElem) 9.1.2003 © MM ... GdI1 - Datenstrukturen 10 Listen: Einfügen Einfügen am Ende der Liste 1. Laufe zum letzten Listenelement (current.next()==null), hier: Objekt 4 2. current.setNext(newElem) // im Beispiel: newElem == Objekt 5 9.1.2003 © MM ... GdI1 - Datenstrukturen 11 Löschen von Elementen • Beim Löschen des Elements delElem einer verketteten Liste sind drei Fälle zu unterscheiden: – Löschen des ersten Listenelements Setze first = first.getNext(). – Löschen innerhalb der Liste Die Liste wird bis zum Vorgänger des zu löschenden Elements durchlaufen, d.h. bis zu current.getNext() == delElem. Setze dann current.setNext(delElem.getNext()). – Löschen des letzten Elements Ein einfacher Sonderfall des Löschens innerhalb der Liste. 9.1.2003 © MM ... GdI1 - Datenstrukturen 12 Listen: Löschen Löschen des ersten Listenelements 1. first = first.next() 2. delElem.setNext(null) (optional) 9.1.2003 © MM ... GdI1 - Datenstrukturen 13 Listen: Löschen Löschen eines inneren Listenelements 1. Durchlaufe die Liste ab head bis current.next()==delElem. 2. current.setNext(delElem.next()) // hier: delElem == Objekt 3 3. delElem.setNext(null) (optional) 9.1.2003 © MM ... GdI1 - Datenstrukturen 14 Listen: Löschen Löschen des letzten Listenelements 1. Durchlaufe die Liste ab first bis current.next() == delElem. Im Beispiel zeigt danach also current auf Objekt 4. 2. current.setNext(null) 9.1.2003 © MM ... GdI1 - Datenstrukturen 15 Verkettete Liste • Vorteile: – Dynamische Länge – Es ist kein Kopieren der Elemente bei Löschen oder Einfügen erforderlich – Keine Speicherverschwendung durch Vorreservierung des Speicherplatzes – Einfache Einfüge- und Löschoperationen – In den Listenelementen enthaltene Objekte können verschiedenen Typ haben • Nachteile: – Zugriff erfolgt immer sequentiell – Positionierung liegt in O(n) – Vorgänger sind nur aufwändig erreichbar: Suche ab first nach Element elem mit elem.getNext() == current – Operationen Einfügen / Löschen nur hinter dem aktuellen Element möglich, sonst komplette Positionierung erforderlich 9.1.2003 © MM ... GdI1 - Datenstrukturen 16 Doppelt verkettete Liste • Die doppelt verkettete Liste (doubly-linked list) behebt die Einschränkung der Navigation nur zum Nachfolger der verketteten Liste. • Hierzu wird neben dem Zeiger next noch ein Zeiger prev eingefügt, der auf das vorherige Element der Liste zeigt, bzw. null ist, falls es sich um das erste Element handelt. • Durch diesen Zeiger ist nun auch die Navigation zum vorhergehenden Element möglich. • Allerdings aufwändigere Einfüge-/Löschoperationen und höherer Speicherbedarf. 9.1.2003 © MM ... GdI1 - Datenstrukturen 17 Datenstruktur: Stack • Ein Stack (Stapel- oder Kellerspeicher) Tellerstapel in der Mensa: – ist eine Folge von Elementen – zugreifbar ist immer nur das oberste Element (top) – Das zuletzt eingefügte Element wird zuerst entfernt = LIFO-Prinzip (Last-In First-Out) • Operationen – void push(Element e) • Legt ein Element e auf den Stack. – Element pop() • Entfernt das oberste Element vom Stack und liefert dieses zurück. – Element top() (Java: peek()) • Liest das oberste Element, ohne es zu entfernen. Feder schiebt Teller nach oben – boolean isEmpty() • Ergibt true, wenn der Stack leer ist, sonst false. • Die Operationen pop() und top() sind nur zulässig, wenn der Stack nicht leer ist. 9.1.2003 © MM ... GdI1 - Datenstrukturen 18 Datenstruktur: Stack • Typische Anwendungen für Stacks sind beispielsweise – Verwaltung von Rücksprungadressen Dies tritt vor allem bei Rekursion oder Funktionsaufrufen auf. – Hilfestellung bei der Syntaxanalyse von Programmen Insbesondere wird der Stack dabei für die Zuteilung der passenden Regel der BNF zu den gelesenen Zeichenfolgen notwendig. – Auswertung von logischen oder arithmetischen Ausdrücken. Ein einfaches Beispiel dazu folgt auf den nächsten Folien. 9.1.2003 © MM ... GdI1 - Datenstrukturen 19 Beispiel für Stackanwendung • Als Beispiel soll ein arithmetischer Ausdruck in Postfix-Notation ausgewertet werden unter Benutzung eines Stacks. • Während bei der herkömmlichen (Infix-)Notation das Rechenzeichen immer zwischen den Operatoren steht („5 + 3 * 7“), steht es bei der Postfix-Notation hinter den Operatoren („5 3 + 7 *“). • Der Vorteil dieser Notation ist die leichte Überprüf- und Auswertbarkeit. Zusätzlich werden keine Klammern benötigt. • Im folgenden schematischen Beispiel wird angenommen, dass eine Routine nextToken existiert, die ein Zeichen zurückgibt, das entweder „+“, „*“ oder eine Ziffer von 0 bis 9 ist. Das Programm schreibt nun der Reihe nach die gelesenen Zahlen auf den Stack. Trifft es auf eine Operation (+, *), so berechnet es das Ergebnis der Operation und schreibt dieses wieder auf den Stack, bis das Zeichen '.' als Eingabeende erkannt wurde. 9.1.2003 © MM ... GdI1 - Datenstrukturen 20 Beispiel für Stackanwendung // Werte einen eingegebenen Ausdruck aus mittels eines Stacks public int evaluate() { IntStack s = new IntStack(); char value; // Gelesenes Zeichen while ((value = nextToken()) != '.') // Zeichen lesen; weiter? { if (value == '+') // Addition s.push(s.pop() + s.pop()); // Summe der beiden letzten Werte else if (value == '*') // Multiplikation s.push(s.pop() * s.pop()); // Produkt der letzten Werte else if (value >= '0' && value <= '9') // Zahl? s.push(value – '0'); // Wert auf den Stack schreiben } return s.pop(); } 9.1.2003 © MM ... GdI1 - Datenstrukturen 21 Beispiel für Stackanwendung • Als Ergebnis der Operation wird s.pop() = 258 ausgegeben. 9.1.2003 © MM ... GdI1 - Datenstrukturen 22 Stack: Implementierung Implementierung mit Array – Elemente werden in einem Array gespeichert. Die maximale Größe des Stacks ist somit durch die Arraygröße limitiert. – Es ist nicht notwendig, bei einem pop das oberste Element tatsächlich zu löschen. public class IntStack { private int[] elements = new int[256]; // Elementespeicher private int count = 0; // Anzahl gespeicherter Elemente public boolean isEmpty() { return count == 0; } public void push(int e) { elements[count++] = e; } // Element speichern und Anzahl erhöhen public int pop() { return elements[--count]; } // Oberstes Element zurückgeben und Anzahl verringern public int top() { return elements[count-1]; } } 9.1.2003 © MM ... GdI1 - Datenstrukturen 23 Stack: Implementierung Implementierung mit verketteter Liste public class IntStack { private Node top; // erster Knoten public Stack() { top = null; // Noch kein Element enthalten } public boolean isEmpty() { return top == null; } public void push(int e) { top = new Node(e, top); } // Neuer Knoten zeigt auf das alte top public int pop() { int v = top.element(); top = top.next(); return v; } // top auf nachfolgenden Knoten setzen public int top() { return top.element(); } // Auf Inhalt des top-Knotens zugreifen } 9.1.2003 © MM ... GdI1 - Datenstrukturen 24 Stack: Implementierung Implementierung mit verketteter Liste class Node { int element; Node next; // Gespeicherter Inhalt // Nachfolgender Knoten Node(int e, Node n) { element = e; // Element speichern next = n; // Verweis auf nachfolgenden Knoten speichern } int element() { return element; } Node next() { return next; } } 9.1.2003 © MM ... GdI1 - Datenstrukturen 25 Stack Vergleich der Implementierungen • Zugriffe sind bei beiden gleich effizient: O(1) • „Knoten“-Stapel kann dynamisch wachsen • Die Größe muss nicht vorher bekannt sein • Wachsen kann ohne „Erweiterungskosten“ erfolgen • Größe nur durch Hauptspeicher begrenzt; für Arrays muss ein kontinuierlicher Block zur Verfügung stehen • Speicherplatzvorteil hängt von der Anzahl der Elemente ab • Arrays sind speichereffizienter, wenn sie gut ausgelastet sind • Verkettete Knoten sind speichereffizienter, wenn viel vom entsprechenden Array ungenützt bliebe 9.1.2003 © MM ... GdI1 - Datenstrukturen 26 Datenstruktur: Queue • Eine Queue (Warteschlange) – ist eine Folge von Elementen – Das zuerst eingefügte Element wird zuerst entfernt = FIFO-Prinzip (First-In First-Out) Warteschlange in der Mensa: • Operationen – void enqueue(Element e) • Fügt e am Ende der Warteschlange ein. – void dequeue() • Entfernt das erste Element. – Element front() • Liefert das erste Element der Warteschlange. Hinten anstellen – boolean isEmpty() Vorne bedient werden • Ergibt true, wenn die Warteschlange leer ist, sonst false. • Anwendungen – Pufferung von Ereignissen/Daten, Aufreihung von „Threads“, Breitensuche bei Graphen, usw. 9.1.2003 © MM ... GdI1 - Datenstrukturen 27 Datenstruktur: Queue Implementierung mit Array • Naive Implementierung erfordert das Verschieben von Elementen beim „Bedienen“ head • „Zirkuläres“ Array vermeidet Verschieben • head & tail werden einfach Hier wird erhöht „bedient“ • Beim Erreichen der Arraygrenze wird Hier wird wieder am Anfang „angestellt“ begonnen tail • „Modulo-Arithmetik“ 9.1.2003 © MM ... GdI1 - Datenstrukturen 28 Datenstruktur: Queue Implementierung mit Array public class Queue { private int[] elements; // Elementespeicher private int head=0, tail=0; // Bedien- & Anstellposition private int c; // Nur zur kürzeren Schreibweise public Queue(int n) { // Konstruktor mit Anzahlangabe elements=new int[n]; // Entsprechendes Array erzeugen c=elements.length; // Nur zur kürzeren Schreibweise } public boolean isEmpty() { return head==tail; } public void enqueue(int e) { elements[tail]=e; tail=(tail+1)%c; } public void dequeue() { head=(head+1)%c; } public int front() { return elements[head]; } } Nur gültig, wenn !isEmpty() 9.1.2003 © MM ... GdI1 - Datenstrukturen 29 Datenstruktur: Queue Implementierung mit verketteter Liste public class Queue { private Node head=null, tail=null;// Bedien- & Anstellknoten public Queue() {} // keine Anzahlangabe nötig public boolean isEmpty() { return head==null; } public void dequeue() { head=head.next(); } // head auf nachfolgenden Knoten setzen public int front() { return head.element(); } public void enqueue(int e) Leere Schlange => { if (tail==null) { Ende & Anfang auf head=tail=new Node(e, null); gleichen Knoten setzen } else { tail.setNext(new Node(e, null)); Schlange nicht leer => Knoten hinten anhängen tail=tail.next(); } tail-Zeiger auffrischen } } 9.1.2003 © MM ... GdI1 - Datenstrukturen 30 Datenstruktur: Queue Analyse der Implementierungen • „Knoten“-Schlange kann dynamisch wachsen • analog zu Stack... • Zugriffe sind bei beiden gleich effizient: O(1) • Die Array-Implementierung erreicht dies nur durch die „zirkuläre“ Nutzung des Arrays. Ansonsten hätten beim Anstellen eines Elements immer alle Element verschoben werden müssen: O(n). • Die Knoten-Implementierung erreicht dies nur durch Hinzunahme eines „tail“ Zeigers. Ansonsten hätte für das Anstellen eines Elements immer die ganze Schlange durchlaufen werden müssen: O(n). 9.1.2003 © MM ... GdI1 - Datenstrukturen 31 Vergleich Datentypen • Die unterschiedlichen Datentypen haben spezifische Vor- und Nachteile. Die Auswahl erfolgt je nach Anwendung. • Array – Array-basierter Datentyp, der dynamisch wächst: java.util.Vector. – Elemente adressierbar, daher direkter Zugriff möglich. – Einfüge/Löschoperationen im inneren der Liste ineffizient: O(n). – Verbinden mehrerer Listen ebenfalls ineffizient: Verweise kopieren und ggf. Array vergrößern. • Verkettete Liste – Einfügen und Löschen effizient: O(1). – Einfaches Verbinden von Listen möglich. – Nur sequentieller Zugriff möglich, kein direkter Zugriff. 9.1.2003 © MM ... GdI1 - Datenstrukturen 32 Polymorphe Knotentypen Im allgemeinen müssen Operationen auf Listen Fallunterscheidungen vornehmen – Liste leer / Cursor ganz vorne – Nur ein Element enthalten / Cursor am Ende – Mehrere Elemente enthalten / Cursor in der Mitte Probleme • Wird die Funktionalität erweitert (z.B. Elemente zählen), so muss die gleiche Fallunterscheidung wieder implementiert werden • Einzelne Fallunterscheidungen können komplex ausfallen (verschachtelte if-statements) 9.1.2003 © MM ... GdI1 - Datenstrukturen 33 Polymorphe Knotentypen • Leerer Stack count → 0 top • Ein Element top count → 1 3 count → 1 + next().count() • Mehrere Elemente top 9.1.2003 © MM ... 3 next count → 1 + next().count() 1 next GdI1 - Datenstrukturen 1 count → 1 34 Polymorphe Knotentypen Knoten für leeren Stack Gemeinsame Oberklasse aller Knotentypen class EmptyNode extends Node { int element() { throw new IllegalStateException(); } Node next() { throw new IllegalStateException(); } Node pop() { throw new IllegalStateException(); } Node push(int e) { return new EndNode(e, null); } int size() { return 0; } } • Elementanzahl = 0 • Abheben nicht erlaubt • Drauflegen ergibt Endknoten-Objekt 9.1.2003 © MM ... GdI1 - Datenstrukturen 35 Polymorphe Knotentypen Knoten für einelementigen Stack Stellt element(), next() bereit class EndNode extends DataNode { EndNode(int e, Node n) { super(e,n); } Node pop() { return new EmptyNode(); } Node push(int e) { return new InnerNode(e, this); } int size() { return 1; } } • Elementanzahl = 1 • Abheben ergibt das Leere-Stack-Objekt • Drauflegen ergibt Innerer-Knoten-Objekt 9.1.2003 © MM ... GdI1 - Datenstrukturen 36 Polymorphe Knotentypen Knoten für mehrelementigen Stack class InnerNode extends DataNode { InnerNode(int e, Node n) { super(e,n); } Node pop() { return next(); } Node push(int e) { return new InnerNode(e, this); } int size() { return 1 + next().size(); } } • Elementanzahl = 1 + Elementanzahl des Rests • Abheben ergibt den restlichen Stack • Drauflegen ergibt Innerer-Knoten-Objekt 9.1.2003 © MM ... GdI1 - Datenstrukturen 37 Polymorphe Knotentypen Benutzung durch einen „polymorphen“ Stack public class PolyStack { private Node top; public PolyStack() { top=new EmptyNode(); } public int size() public void pop() public void push(int e) // Noch kein Element enthalten { return top.size(); } { top = top.pop(); } { top = top.push(e); } } • Es wird keine einzige Fallunterscheidung benötigt! • Verschiedene Fälle auf Knotentypen entzerrt 9.1.2003 © MM ... GdI1 - Datenstrukturen 38 Datenstrukturen: Zusammenfassung • Datenstrukturen erleichtern die Formulierung von Algorithmen – Grundprinzip der OOP: Wiederverwendung • Datenstrukturen mit einer „Knoten“Implementierung können dynamisch wachsen • Doppelt-verkettete Listen können alle sequentiellen Operationen in O(1) unterstützen • Polymorphe Knotentypen können die Programmierung vereinfachen – Fälle/Verantwortlichkeiten werden aufgeteilt 9.1.2003 © MM ... GdI1 - Datenstrukturen 39