Objektorientierte Programmierung OOP
KE6 Algorithmus und Datenstrukturen
KE6 Algorithmus und Datenstrukturen
Kartei Details
Karten | 106 |
---|---|
Sprache | Deutsch |
Kategorie | Informatik |
Stufe | Universität |
Erstellt / Aktualisiert | 15.01.2013 / 18.05.2022 |
Weblink |
https://card2brain.ch/box/objektorientierte_programmierung_oop
|
Einbinden |
<iframe src="https://card2brain.ch/box/objektorientierte_programmierung_oop/embed" width="780" height="150" scrolling="no" frameborder="0"></iframe>
|
Mit welcher Struktur können wir z.B. Listen von Rechnungen einfach wachsen lassen, indem wir am Ende oder vor den Anfang der Liste oder aber an beliebiger Stelle zwischen zwei vorhandenen Elementen ein neues hinzufügen, und wir können die Liste schrumpfen lassen, indem wir einfach ein Element streichen.
Ein Abbild dieser Struktur in Programmen ist die verkettete Liste (engl. linked list). Eine verkettete Liste ist die einfachste Form einer Sammlung von Datenobjekten, denen eine lineare Ordnung aufgeprägt ist.
Welche Eigenschaften haben verkettet Listen?
Listen sind dynamische Datenstrukturen, die folgende Eigenschaften haben:
1. Die einzelnen Knoten (engl. node) oder Elemente der Liste sind geordnet.
2. Listen können beliebig lang werden.
3. Listen können auch leer sein.
4. Man kann Knoten aus einer Liste löschen oder neue Knoten hinzufügen, ohne die bisherigen Knoten umordnen zu müssen.
Auf was müssen Sie bei verketteten Listen noch achten?
Eine Liste verwendet Speicherplatz im Verhältnis zur Anzahl ihrer aktuellen Knoten. Auf die Listenknoten kann nur sequenziell zugegriffen werden. Listen verwendet man besser für Datenkollektionen, deren Größe man nicht vorherbestimmen kann und die sich häufig ändern.
Definieren Sie Verkettete Liste.
Eine einfach verkettete Liste ist eine (möglicherweise leere) Menge von Knoten, wobei jeder Knoten einen Eintrag und einen Verweis auf den nächsten Knoten enthält.
Der erste Knoten einer Liste wird Kopf (engl. head), der letzte wird Schwanz (engl. tail) genannt. Der Verweis des letzten Elements hat, außer bei einer zirkulären Liste, den Wert null.
Wo für stehen die Attribute entry und next?
public class ListNode {
private int entry;
private ListNode next;
}
Erstellen Sie für die verkettet Liste, eine Klasse ListNode inkl. einer Methode für die Ausgabe des Listeneintrag.
public class ListNode { private int entry; private ListNode next; public ListNode(int value) { this(value, null); } public ListNode(int value, ListNode nextNode) { this.entry = value; this.next = nextNode; } public void setEntry(int value) { this.entry = value; } public void setNext(ListNode nextNode) { this.next = nextNode; } public int getEntry() { return this.entry; } public ListNode getNext() { return this.next; }
public void print() { System.out.print(this.entry); }}
Erstellen Sie den ersten Entwurf für die LinkedList Methode, die wir im laufe weiter entwickeln werden.
public class LinkedList { private ListNode head;
public LinkedList() { this.head = null; } public void add(int value) { ListNode newNode = new ListNode(value, this.head); this.head = newNode; }}
Ein Objekt der Klasse LinkedList hat als einziges Attribut einen Listenkopf head vom Typ ListNode. Nach der Erzeugung eines LinkedList-Objekts enthält dieses noch keine Knoten, so dass der Listenkopf head eine null-Referenz darstellt. Die Abbildung zeigt schematisch eine leere Liste.
Geben Sie das weitere vorgehen der add Methode an.
Wir können eine verkettete Liste durch das sukzessive Einfügen von Knoten am Anfang der Liste aufbauen. Die Methode add()
1. erzeugt einen neuen Listenknoten,
2. trägt den Wert des Eintrags ein,
3. lässt das next-Attribut dieses Knotens auf den Kopf der Liste zeigen und
4. setzt den Kopf der Liste auf den neu erzeugten Listenknoten.
Ergänzen die verkettete Liste um die Methode size(), damit Sie die länge der Liste ermitteln können.
public int size() {
ListNode current = this.head;
int count = 0;
while (current != null) {
count++;
current = current.getNext();
}
return count;
}
Die Methode size() zeigt eine typisches Muster, um über eine verkettete Liste zu iterieren. Solange nicht das Ende der Liste erreicht ist (current != null), wird eine Aktion auf den betrachteten Knoten (current) angewendet, und anschließend der nächste Knoten (current.getNext()) aufgesucht.
Wird die Methode size() auf unsere oben erstellte zweielementige Liste angewendet, so verweist die lokale Variablecurrent zunächst auf den Listenkopf (der das Element 11 enthält) und hangelt sich im ersten Durchlauf der while-Schleife zum zweiten Listenknoten (der das Element 47 enthält) vor. Dessen null-Referenz übernimmt sie im zweiten Durchlauf derwhile-Schleife, worauf diese beendet wird.
Formulieren Sie die Methode size() mit Hilfe einer for-Schleife.
public int size() { int count = 0; for (ListNode current = this.head; current != null; current = current.getNext()) { count++; } return count; }
Erstellen Sie eine einfache Methode, die die Liste nach einem bestlmmten Wert durchsucht.
public boolean contains(int value) { ListNode current = this.head; while (current != null) { if (current.getEntry() == value) { return true; } current = current.getNext(); } return false; }
Überlegen sie, ob Listen auch für die binäre Suche geeignet sind.
Die binäre Suche setzt zunächst eine Ordnung unter den zu durchsuchenden Elementen voraus. Selbst auf einer sortierten Liste lässt sie sich jedoch nicht effizient implementieren, da Listen auf sequenziellem Zugriff beruhen. Die binäre Suche auf linearen Datenstrukturen erfordert wahlfreien Zugriff.
Schreiben Sie eine Methode sum(), die iterativ die Einträge der Listenknoten aufsummiert und die Summe zurückgibt.
public int sum() { ListNode current = this.head; int sum = 0; while (current != null) { sum += current.getEntry(); current = current.getNext(); } return sum; }
Wir implementieren nun die Methode size() erneut, diesmal rekursiv:
public int size() { return size(this.head); }
private int size(ListNode node) { // 1 if (node == null) { // 2 return 0; // 3 } // 4 return size(node.getNext()) + 1; // 5 }
Beachten Sie das Zusammenspiel der öffentlichen und der privaten size()-Methoden. Die öffentliche Methode ruft mit dem Listenkopf die private Methode auf, die rekursiv die eigentliche Arbeit erledigt. Dieses Muster ist uns im Zusammenhang mit rekursiven Methoden bereits bekannt und wird uns auch bei rekursiven Datenstrukturen oft begegnen.
Schreiben Sie ein Methodenpaar sum(), das die Einträge in den Listenknoten rekursiv aufsummiert:
public int sum() {
return sum(this.head); } private int sum(ListNode node) { if (node == null) { return 0; } return node.getEntry() + sum(node.getNext()); }
Schreiben Sie eine rekursive Methode, die alle Einträge auf dem Bildschirm ausgibt.
public void printList() { printList(this.head); } private void printList(ListNode node) { if (node == null) { return; } node.print(); System.out.print(" "); printList(node.getNext()); }
Schreiben Sie ein Methodenpaar printReverseList(), das die Einträge der Listenknoten in umgekehrter Reihenfolge druckt.
public void printReverseList() { printReverseList(this.head); } private void printReverseList(ListNode node) { if (node == null) { return; } printReverseList(node.getNext()); node.print(); System.out.print(" "); }
Eine Liste ist ein dynamisches Objekt, das es uns auch erlaubt, Knoten zu löschen oder neue Knoten an einer bestimmten Position einzufügen. Wenn wir einen bestimmten Knoten entfernen wollen, müssen wir zwei Aufgaben lösen:
Wir definieren eine rekursive Methode remove(), die einen Knoten node und einen ganzzahligen Wert value als Argumente akzeptiert. Die prinzipielle Idee ist die, dass die Methode rekursiv durch die Liste läuft, um den Knoten mit dem Eintrag valuezu finden. Wenn solch ein Knoten gefunden werden kann, wird er entfernt und sein Nachfolger wird zurückgegeben. Falls der Wert in keinem Knoten vorkommt, bleibt die Liste unverändert.
Erneut haben wir eine öffentliche Methode und einen nicht-öffentlichen rekursiven Algorithmus definiert. Beachten Sie, dass hier zwei Basisfälle vorliegen:
1.Ein Basisfall tritt auf, wenn kein passender Knoten in der Liste gefunden wurde oder wenn die Liste leer ist (Zeile 2 ).
2. Der zweite Basisfall liegt vor, wenn der Eintrag eines Knotens in der Liste mit dem gesuchten Wert, der als zweiter Parameter (value) beim Aufruf der Methode übergeben wurde, übereinstimmt (Zeile 5).
Falls keiner dieser Basisfälle auftritt, wird die Methode remove() rekursiv auf den Rest der Liste angewendet (Zeile 8). Das Ergebnis jedes rekursiven Aufrufs ist entweder die ursprüngliche Restliste oder die um den gesuchten Knoten verringerte Restliste. Wir müssen aber auf jeden Fall das Ergebnis mit dem Knoten, der auf die ursprüngliche Restliste verwies, über dasnext-Attribut verknüpfen (Zeile 8), um bei der Rückabwicklung der rekursiven Aufrufe die sich aus den Basisfällen ergebende Restliste mit dem unveränderten Vorspann der Ursprungsliste Knoten für Knoten wieder zu verknüpfen.
Wir definieren eine rekursive Methode remove(), die einen Knoten node und einen ganzzahligen Wert value als Argumente akzeptiert. Die prinzipielle Idee ist die, dass die Methode rekursiv durch die Liste läuft, um den Knoten mit dem Eintrag valuezu finden. Wenn solch ein Knoten gefunden werden kann, wird er entfernt und sein Nachfolger wird zurückgegeben. Falls der Wert in keinem Knoten vorkommt, bleibt die Liste unverändert.
public void remove(int value) { this.head = remove(this.head, value); } private ListNode remove(ListNode node, int value) { // 1 if (node == null) { // 2 return null; // 3 } // 4 if (node.getEntry() == value) { // 5 return node.getNext(); // 6 } // 7 node.setNext(remove(node.getNext(), value)); // 8 return node; // 9 }
Was würde passieren, wenn wir Zeile 8 wie folgt:
remove(node.getNext(), value); // 8
änderten und
1. Methode remove(5) auf die Liste anwendeten?
1. Die Liste bleibt unverändert.
2.Die Liste enthält nur noch die Elemente .
3. Die Liste ist leer.
2. Was wäre das Ergebnis des Aufrufs remove(0)?
Antwort 1 beantwortet die erste Frage richtig. Da keine neue Liste konstruiert wird, liefert die private, rekursive remove()-Methode gemäß Zeile 7 als Ergebnis den Wert des ersten Aufrufs, das head-Attribut, zurück.
Die Antwort auf Frage 2 ist die gleiche wie auf Frage 1.
Schreiben Sie ein Methodenpaar contains(), das rekursiv prüft, ob in der Liste ein Knoten mit einem bestimmten Wert vorhanden ist:
public boolean contains(int value) { //... } private boolean contains(...) { //... }
public boolean contains(int value) { return contains(this.head, value); } private boolean contains(ListNode node, int value) { if (node == null) { return false; } if (node.getEntry() == value) { return true; } return contains(node.getNext(), value); }
Beschreiben Sie den Basisfall der Methode contains() und erörtern Sie, warum die Rekursion konvergiert.
Hier gibt es zwei Basisfälle: der erste ist das Ende der Liste bzw. eine leere Liste, der zweite ist der Fall, dass ein passendes Element gefunden wird. Falls der gesuchte Eintrag gefunden wird, endet das Verfahren nach endlich vielen Schritten. Falls der gesuchte Eintrag nicht gefunden wird, erreichen wir nach endlich vielen Schritten über die next-Attribute das Ende der Liste, das durch den null-Verweis gekennzeichnet ist, und das Verfahren konvergiert durch den ersten Basisfall.
Nicht immer möchte man neue Einträge am Anfang einer Liste einfügen, wie es bisher durch die Methode add() geschieht. Wir wollen die add()-Methode überladen, um die Möglichkeit zu schaffen, einen Knoten mit einem neuen Eintrag an einer gewählten Stelle index in Listen einzufügen.
public void add(int index, int value) { if (index < 0 || index > size()) { throw new IndexOutOfBoundsException("index muss im " + "Bereich von 0 bis " + size() + " liegen."); } this.head = add(this.head, index, value); } private ListNode add(ListNode node, int steps, int value) { // 1 if (steps == 0) { // 2 return new ListNode(value, node); // 3 } // 4 node.setNext(add(node.getNext(), steps - 1, value)); // 5 return node; // 6 }
Die private rekursive add()-Methode bewältigt mehrere Aufgaben:
-
Zunächst muss sie sich rekursiv an die Einfügestelle heranzählen (steps - 1 in Zeile 5).
-
Ist die Einfügestelle erreicht (Zeile 2), muss ein neuer Knoten mit dem erwünschten Eintrag und einer Referenz auf den Nachfolgerknoten erzeugt werden (Zeile 3). Dies ist der Basisfall.
-
Das Ergebnis jedes rekursiven Aufrufs ist eine Teilliste, die mit dem Knoten, der auf die ursprüngliche Teilliste verwies, durch setNext() verknüpft wird (Zeile 5), Hier erfolgt auch die Verkettung des Vorgängerknotens mit dem neuen Knoten.
Die öffentlichen add()-Methode hat vor dem Aufruf der privaten add()-Methode noch die wichtige Aufgabe, zu prüfen, ob die im Parameter index übergebene Einfügestelle zulässig ist.
Verkettete Listen können viele weitere Methoden haben. So könnte man z. B. den Eintrag an einer bestimmten Position abrufen, die Liste leeren, eine Teilliste erzeugen wollen und vieles mehr.
Implementieren sie eine Methode, um den Eintrag an einer bestimmten Position abzurufen (ohne die Liste zu verändern):
public int get(int index) { //... }
Bedenken Sie den Fall, dass die Methode mit einem ungültigen Index als Parameter aufgerufen werden könnte. Entwerfen Sie je eine iterative und eine rekursive Lösung.
public int get (int index) { if (index <0 | | index> = size ()) { throw new IndexOutOfBoundsException ( "Ungültiger Index"); } ListNode current = this.head; for (int i = 0; i <index; i + +) { Strom = current.getNext (); } zurück current.getEntry (); }
Rekursiv:
public int get (int index) { if (index <0 | | index> = size ()) { throw new IndexOutOfBoundsException ( "Ungültiger Index"); } zurück zu bekommen (this.head, index) getEntry (). }
Private ListNode bekommen (ListNode Knoten int Stufen) { if (Schritte == 0) { zurück Knoten; } zurück zu bekommen (node.getNext (), nur wenige Schritte - 1); }
Implementieren Sie eine Methode, um die Liste zu leeren.
public void clear() { //... }
public void clear() { this.head = null; }
Implementieren Sie eine Methode, die eine Teilliste zurückliefert.
public LinkedList subList(int fromIndex, int toIndex) { //... }
Die Teilliste soll alle Einträge zwischen fromIndex (einschließlich) und toIndex (ausschließlich) in der richtigen Reihenfolge enthalten. Sind fromIndex und toIndex identisch, so ist die resultierende Liste leer. Bedenken Sie den Fall, dass die Methode mit ungültigen Indizes als Parameter aufgerufen werden könnte. Als ungültig ist auch der Fall fromIndex > toIndex zu werten.
öffentlichen LinkedList Unterliste (int fromIndex, int toIndex) { if (fromIndex <0 | | toIndex> size () | | FromIndex> toIndex) { throw new IndexOutOfBoundsException ( "Ungültiger Index"); } LinkedList list = new LinkedList (); list.head = Unterliste (this.head, fromIndex, toIndex - fromIndex); zurück zur Liste; } Private ListNode Unterliste (ListNode Knoten int Schritte, int size) { if (size == 0) { return null; } if (Schritte == 0) { ListNode subListNode = new ListNode (node.getEntry ()); subListNode.setNext (Unterliste (node.getNext (), 0, size - 1)); zurück subListNode; } zurück Unterliste (node.getNext (), nur wenige Schritte - 1, size); }
Zum Abschluss unserer Betrachtungen über einfach verkettete Listen kommen wir auf den Sortier-Algorithmus „Merge-Sort“ zurück. Nehmen wir an, uns liegt eine umfangreiche unsortierte Liste vom Typ LinkedList vor. Diese soll in eine sortierte Liste vom Typ LinkedList überführt werden. Der Algorithmus „Merge-Sort“ lässt sich für unseren TypLinkedList wie folgt implementieren:
public LinkedList mergeSort() { int length = this.size(); if (length <= 1) { return this; } LinkedList leftSorted = this.subList(0, length / 2).mergeSort(); LinkedList rightSorted = this.subList(length / 2, length).mergeSort(); leftSorted.mergeWith(rightSorted); return leftSorted; } private void mergeWith(LinkedList otherList) { if (otherList.isEmpty()) { return; } if (this.isEmpty()) { this.head = otherList.head; return; } if (otherList.head.getEntry() <= this.head.getEntry()) { int first = otherList.removeFirst(); this.mergeWith(otherList); this.add(first); return; } int first = this.removeFirst(); otherList.mergeWith(this); otherList.add(first); this.head = otherList.head; } private int removeFirst() { if (this.head == null) { throw new IndexOutOfBoundsException("leere Liste"); } int first = this.head.getEntry(); this.head = this.head.getNext(); return first; }
Vergleichen Sie die Implementierung mit der Beschreibung des Sortier-Algorithmus „Merge-Sort“ in Kapitel 34.
Eine aufsteigend sortierte Liste für int-Werte können wir ähnlich wie die Klasse LinkedList definieren. Um zu gewährleisten, dass die Liste jederzeit sortiert ist, müssen wir dafür sorgen, dass Einträge von Anfang an gemäß der gewählten Ordnung in die Liste eingefügt werden.
public class SortedList { private ListNode head; public SortedList() { this.head = null; } public void add(int value) { this.head = add(this.head, value); } private ListNode add(ListNode node, int value) { if (node == null) { return new ListNode(value, node); } if (node.getEntry() > value) { return new ListNode(value, node); } node.setNext(add(node.getNext(), value)); return node; }}
Die Methode add() fügt jeden neuen Eintrag an der richtigen Stelle in die sortierte Liste ein.
Welche Methoden der Klasse LinkedList könnten wir ohne Nachteil für SortedList identisch übernehmen?
printList(), printReverseList(), size(), sum() (alle Varianten), get(), clear(), subList(). Methoden, die als Parameter Indexpositionen erwarten (get(), subList()), werden allerdings für sortierten Listen selten verwendet.
Was hält uns davon ab, die Klasse LinkedList zu spezialisieren?
Wir hatten in der Klasse LinkedList im vorherigen Abschnitt die Methode add() überladen. Die zweite add()-Methode dient dazu, einen Knoten mit einem neuen Eintrag an einer gewählten Stelle index in eine Liste einzufügen. Für eine sortierte Liste wäre eine solche Funktionalität fatal.
Es wäre sehr schlechter Programmierstil, die Methode entgegen ihrer Zweckbestimmung mit einer unschädlichen Funktionalität zu überschreiben wie etwa
public void add(int index, int value) { this.add(value); // Tun Sie so etwas nie! }
Eher wäre zu überlegen, die für beide Klassen nützlichen Methodendefinitionen in eine gemeinsame (ggf. abstrakte) Oberklasse zu verlagern und diese einmal mit LinkedList und einmal mit SortedList zu spezialisieren.
Um den Effizienzvorteil sortierter Listen nutzen zu können, wollen wir nun eine Methode remove()speziell für eine aufsteigend sortierte Liste definieren.
public void remove(int value) { this.head = remove(this.head, value); } private ListNode remove(ListNode node, int value) { // 1 if (node == null) { // 2 return null; // 3 } // 4 if (node.getEntry() > value) { // 5 return node; // 6 } // 7 if (node.getEntry() == value) { // 8 return node.getNext(); // 9 } // 10 node.setNext(remove(node.getNext(), value)); // 11 return node; // 12 }
Die Effizienz ergibt sich aus dem zweiten Basisfall (Zeile 5). Sobald bei der rekursiven Ausführung ein Eintrag angetroffen wird, der größer als value ist, steht fest, dass ein Eintrag mit dem Wert value auch in der restlichen Liste nicht mehr gefunden wird.
Welche weiteren Unterschiede bestehen zwischen den remove()-Methoden der Klassen LinkedList und SortedList?
Es bestehen keine weiteren Unterschiede.
Implementieren Sie eine effiziente Methode contains() für SortedList, die prüft, ob in der Liste ein Knoten mit einem bestimmten Wert vorhanden ist.
public boolean contains (int value) { Rückgabe enthält (this.head, value); } private boolean contains (ListNode Knoten, int value) { if (Knoten == null) { return false; } if (node.getEntry ()> value) { return false; } if (node.getEntry () == value) { return true; } Rückgabe enthält (node.getNext (), value); }
Nennen Sie ein Beispiel für eine doppelt Verkettet Liste.
Für einige Abstraktionen ist eine einfach verkettete Liste nicht das optimale Modell. Wenn z. B. eine Warteschlange mit Hilfe einer verketteten Liste implementiert werden soll, die das Einfügen am Ende und das Entfernen am Anfang der Schlange unterstützt, können diese Methoden effizienter implementiert werden, wenn die Elemente der Liste in beide Richtungen, also vorwärts und rückwärts, verkettet sind.
Eine Knoten einer doppelt verketteten Liste kann ähnlich wie der uns bekannte ListNode definiert werden. Für die Einträge wählen wir diesmal den Typ String.
public class DoublyLinkedListNode { private String entry; private DoublyLinkedListNode next; private DoublyLinkedListNode prev;}
Eine Warteschlangen stellt typischerweise zwei Methoden enqueue() zum Hinzufügen eines Eintrags und dequeue() zum Entfernen eines eines Eintrags bereit. Sie arbeitet nach dem First In First Out-Prinzip, so dass mit dequeue() immer der Eintrag aus der Warteschlange zurückgegeben wird, der der sich am längsten in der Warteschlage befindet.
Ergänzen Sie den Typ DoublyLinkedListNode um die benötigten Konstruktoren und Methoden. Entwerfen Sie eine KlasseDoublyLinkedList mit den Methoden enqueue() zum Hinzufügen eines String-Eintrags am Ende der Liste und dequeue() zum Entfernen eines String-Eintrags am Anfang der Liste.
public class DoublyLinkedListNode { private String-Eintrag; Private DoublyLinkedListNode next; Private DoublyLinkedListNode prev; öffentlichen DoublyLinkedListNode (String value, DoublyLinkedListNode prevNode) { this.entry = Wert; this.prev = prevNode; this.next = null; } öffentlichen DoublyLinkedListNode getNext () { return next; } public void SetNext (DoublyLinkedListNode nextNode) { this.next = nextNode; }