Th. Letschert Technische Hochschule Mittelhessen Fachbereich MNI Algorithmen und Datenstrukturen Induktive Mengen, Datentypen und Datenstrukturen 1. Induktive Mengen und Rekursive Funktionen 1.1 Induktive Mengen Java stellt eine Vielzahl von Typen zur Verfügung. Da sind zunächst einem die primitiven, in die Sprache “eingebauten” Typen int, etc. Dazu kommen die Klassentypen die wir in der API finden oder mit Hilfe von Klassen oder Interfaces selbst konstruieren können. Ein Typ ist einem Menge von Werten zusammen mit den Operationen die auf diesen Werten ausgeführt werden können. Ganz besonders einfache Typen bestehen aus einer festen Menge von Werten die einfach aufgezählt werden kann. Die Booleschen Werte bilden einen solchen Typ, aber auch der Typ int der zwar mehr Werte hat, deren Zahl aber auch fix ist und vielleicht den Bereich -2147483648 bis 2147483647 abdeckt. int-Werte sind nicht mit ganzen oder natürlichen Zahlen zu verwechseln, von denen es unendlich viele gibt. Wir können selbst auch Typen definieren von denen es unendlich viele Werte gibt. Dazu müssen wir das Prinzip der Rekursion auf Daten anwenden. Wir geben eines Basis an und dazu ein oder mehrere Konstruktions-Verfahren, wie aus Werten neue Werte erzeugt werden können. Ein bekanntes Beispiel sind die natürlichen Zahlen. Mit dem • Basiswert 0 und dem • Konstruktions-Verfahren +1 ist die Menge der natürlichen Zahlen festgelegt ohne dass wir die unendliche Folge der natürlichen Zahlen angeben müssen. Menge die auf diese Art durch Basiswerte und Konstruktions-Verfahren komplett und eindeutig definiert werden, nennt man induktive Mengen. Wichtig ist dabei dass für jeden Wert eindeutig klar ist, ob es sich um einen Basiswert handelt, oder um einen konstruierten Wert. Bei einem konstruierter Wert muss eindeutig klar sein, aus welchen Werten er wie konstruiert wurde. Bei den natürlichen Zahl gilt dies: Jede natürliche Zahl ist entweder die 0 oder kann durch Addition von 1 aus genau einer anderen natürlich Zahl gebildet werden. Induktive Mengen haben zwei große Vorteile: Man kann durch vollständige (oder strukturelle) Induktion auf recht einfache Art Eigenschaften der Elemente beweisen und man kann durch strukturelle Rekursion Funktionen auf den Elementen definieren. 1.2 Strukturelle Rekursion Die struktureller Rekursion nennt man auch Rekursion über die Struktur der Werte. Damit sind Funktionen gemeint, die sich genau an dem möglichen Konstruktionsprozess ihrer Argumente orientieren. Nehmen wir die natürlichen Zahlen und definieren einige Funktionen rekursiv über die Struktur ihrer Argumente: ( add(x, y) = y add(x − 1, y) + 1 ( mult(x, y) = if x = 0, if x 6= 0 0 mult(x − 1, y) + y ( 1 n! = (n − 1)! ∗ n if x = 0, if x 6= 0 if n = 0, if x 6= 0 1 1.3 Listen und Bäume als induktive Mengen Natürliche Zahlen sind interessant, speziell für Mathematiker und Kinder in der Grundschule. Aber es gibt noch andere interessante induktive Mengen. Listen von natürlichen Zahlen beispielsweise oder anderen Dingen können als induktive Menge aufgefasst werden. Mit dem • Basiswert leere Liste und dem • Konstruktions-Verfahren Vorsetzen eines Wert vor eine Liste ist die unendlich große Menge der Listen festgelegt. Es ist klar, dass alle Listen auf diese Art erzeugt werden können und dass jede Liste auf eindeutige Art wieder “rückwärts zerlegt” werden kann. Ein anderes Beispiel sind Bäume. Mit dem • Basiswert Blatt mit Wert v und dem • Konstruktions-Verfahren bei dem ein Baum aus einem neuen inneren Knoten und zwei Unterbäumen konstruieren wird, ist die unendlich große Menge der Bäume (hier der Binärbäume) festgelegt. Auf diesen Listen und Bäumen können Eigenschaften durch strukturelle Induktion und Funktionen durch Rekursion über die die Struktur der Argumente definiert werden. Als Beispiel wollen wir auf Listen und Bäumen jeweils eine Funktion definieren, die die Summe der enthaltenen Werte berechnet. ( 0 if l = ⊥ sum(l) = v + sum(l0 ) if v :: l0 Mit ⊥ sei hier die leere Liste gemeint und v :: l0 ist die Liste, die durch Vorsetzen des Werts v vor die Liste l0 entsteht. ( v if t = L(v), sum(t) = sum(t1 ) + sum(t2 ) if t = N (t1 , t2 ) Mit L(v) sei hier das Blatt mit dem Wert v gemeint und N (t1 , t2 ) ist der Baum der aus einem neuen inneren Knoten besteht, an den die Unterbäume t1 und t2 angehängt wurden. 1.4 Induktive Mengen als Algebraische Typen implementieren Elemente induktiver Mengen sind Basiselemente oder sie sind auf eine von mehreren möglichen Arten aus einem oder mehreren anderen Elementen der gleichen induktiven Menge konstruiert worden. Eine Liste ist leer oder nit leer. Eine nichtleere List besteht aus einem ersten Element und einem Rest. Typen, die eine solche klare und– oder–Struktur haben, nennt man etwas hochtrabend algebraische Typen. In Java, und jeder anderen objektorientierten Programmiersprache, werden solche und–oder–Typen (algebraische Typen) üblicherweise durch eine abstrakte Klasse (ein Interface) mit Unterklassen implementiert. Sehen wir uns die Implementierung der Listen und Bäume an: interface List {} class EmptyList implements List {} class NonEmptyList implements List { int head; List tail; 2 } interface Tree {} class LeafNode implements Tree { int v; } class InnerNode implements Tree { Tree left; Tree right; } Dazu wird man noch ein Konstruktoren und toString–Methoden definieren. Bei der Implementierung von Funktionen, die rekursiv über die Struktur deviiert sind, hat man zwei Optionen. Man kann sie als polymorphe Methoden oder als statische Methoden definieren. Definieren wir zunächst die polymorphe Version der Summe: // Lists interface List { int sum(); } class EmptyList implements List { @Override public int sum() { return 0; } } class NonEmptyList implements List { int head; List tail; @Override public int sum() { return head + tail.sum(); } } // Trees interface Tree { int sum(); } class LeafNode implements Tree { int v; @Override public int sum() { return v; } } class InnerNode implements Tree { Tree left; Tree right; @Override public int sum() { return left.sum() + right.sum(); } } In der Berechnung mit einer statischen Methode muss explizit geprüft werden, welchen Typ das Objekt hat: // Lists interface List { static int sum(List l) { if (l instanceof EmptyList) { return 0; } else if (l instanceof NonEmptyList) { NonEmptyList nel = (NonEmptyList) l; return nel.head + sum(nel.tail); 3 } else { throw new IllegalArgumentException(); } } } class EmptyList implements List { } class NonEmptyList implements List { int head; List tail; } // Trees interface Tree { static int sum(Tree t) { if (t instanceof LeafNode) { return ((LeafNode) t).v; } else if (t instanceof InnerNode) { InnerNode n = (InnerNode) t; return sum(n.left) + sum(n.right); } else { throw new IllegalArgumentException(); } } } class LeafNode implements Tree { int v; } class InnerNode implements Tree { Tree left; Tree right; } 1.5 Null-Zeiger oder Option statt leerer Objekte In der Listen-Implementierung werden Objekte erzeugt, die keinerlei Information enthalten. Bei den Bäumen ist das nicht der Fall. Objekte ohne jede Information sind nicht sehr sinnvoll. Der klassische Ersatz für leere Objekte ist der Null-Verweis. Die Klasse EmptyList der leeren Listen ist dann überflüssig. Damit dann auch das Interface. Die Liste wird dann zu: class List { int head; List tail; public List(int head, List tail) { this.head = head; this.tail = tail; } @Override public String toString() { return "[" + head + "::" + tail + "]"; } public int sum() { return head + (tail == null ? 0 : tail.sum()); } } public class Example_1 { public static void main(String[] args) { List l = new List(1, new List(2, new List(3, null ))); 4 System.out.println(l.sum()); // -> 6 } } Diese Version ist speichereffizienter als die vorherigen, aber nicht wirklich schön. Eine leere Liste wird nicht mehr mit List l = new EmptyList(); erzeugt sondern durch List l = null; Igitt! Pfui! Wie hässlich! Aber nicht so schlimm, diese Listen und Bäume sind sowieso nicht für Anwendungsprogrammierer gedacht. Wir sind hier im Bereich der Datenstrukturen, das ist etwas für Informatiker. Anwendungsprogrammier sollten sich von Datenstrukturen möglichst fern halten. Mit Java 8 wurde der Typ Option eingeführt. Er kann als Ersatz für den null-Zeiger eingesetzt werden: class List { int head; Optional<List> tail; public List(int head) { this.head = head; this.tail = Optional.empty(); } public List(int head, List tail) { this.head = head; this.tail = Optional.of(tail); } @Override public String toString() { return "[" + head + "::" + tail + "]"; } public static Optional<Integer> sum(Optional<List> ol) { return ol.flatMap((List l) -> Optional.of(l.head + sum(l.tail).orElse(0)) ); } } public class Example_1 { public static void main(String[] args) { List l = new List(1, new List(2, new List(3))); System.out.println(l); // -> [1::Optional[[2::Optional[[3::Optional.empty]]]]] System.out.println(List.sum(Optional.of(l))); // -> Optional[6] } } Mit dem generischen Typ Optional wollen wir uns hier aber zunächst einmal nicht weiter beschäftigen. 5 2. Datenstruktur und Datentyp 2.1 Datentypen und abstrakte Datentypen Ein Datentyp ist etwas Nützliches, etwas mit dem Anwendungsentwickler arbeiten können, mit dem ihre Ziele erreichen können, nämlich dem Benutzer eine bestimmte Funktionalität bereitzustellen. Datentypen sind Bausteine die ihnen dabei helfen. In der Urzeit der Programmierer standen den Entwicklern nur einfache Datentypen der Sprache zur Verfügung: Zahlen, Boolsche Werte und Arrays. Später wurden die Programmiersprachen entweder um flexible und mächtige Datentypen erweitert oder mit mit umfangreichen APIs ausgestattet. Damit wurde die Software-Entwicklung deutlich verbessert und heute sollte jeder Anwendungsprogrammierer seine Sprache und seine API gut kennen und nur in Ausnahmefällen etwas realisieren, was es dort gibt. Die API von Java enthält nicht nur Komponenten, die einen Datentyp realisieren, aber diese Komponenten stellen einen wesentlichen Bestandteil der API dar. Speziell das Packet java.util enthält viele Implementierungen von sehr nützlichen Datentypen. Es sind in erster Linie Datentypen mit denen Werte-Kollektionen verwaltet werden können: Listen, Abbildungen, Mengen. Datentypen wie List oder Map werden von der Sprache Java direkt angeboten werden, sondern sind auf Basis elementarer Typen in Java “ausprogrammiert”. Man nennt sie darum auch oft abstrakte Datentypen. Die Sprache Java ist, im Gegensatz zu vielen Script-Sprachen, recht sparsam mit Datentypen ausgestattet. Sie bietet aber ein ausgefuchstes Konzept um neue Komponenten, neue eigene Datentypen zu realisieren. Man nennt dieses ausgefuchste Konzept Objektorientierung. Die Objektorientierung wurde in der Konstruktion der API heftigst eingesetzt und ihre Nutzung setzt eine Vertrautheit mit den verwendeten objektorientierten Methoden voraus. Ein Prinzip der Objektorientierung ist, dass die Funktionalität von Dingen durch Interfaces (oder abstrakte Basisklassen) repräsentiert wird. Ein Datentyp stellt ein Funktionalität dar, er ist darum in einer OO-Sprache ganz natürlich mit einem Interface dargestellt werden. Die unterschiedlichen Arten diese Funktionalität zu realisieren wird dann durch Klassen bereitgestellt, die dieses Interface implementieren. 2.2 Datenstrukturen Auch wenn man die Entwicklung eigener Typen tunlichst vermeiden sollte, so kommt man doch gelegentlich in die Verlegenheit es tun zu müssen. Dann kommen die Datenstrukturen ins Spiel. Datenstrukturen sind Organisationsformen von Daten: Die Art und weise wie Daten zueinander in Beziehung stehen. Von Java – der Sprache Java, nicht der Java-API – werden nur (!) folgende prinzipiellen Organisationsformen für Daten angeboten: • Arrays: Daten werden in einem Speicherbereich fester Größe hintereinander abgespeichert. • Objekte: Daten können in einem Objekt zusammengefasst werden. • Zeiger: Daten könne mit Hilfe Zeigern (Pointern) mit einander in Beziehung gesetzt werden. Das ist alles. Alles Komplexere muss mit diesen Dingen zusammengebaut werden. Der Umgang speziell mit Zeigern ist schwierig und fehleranfällig. Früher musste jeder Anwendungsentwickler intensiv im Umgang mit Zeigern geschult werden. Heute ist das glücklicherweise nicht mehr notwendig. Entwickler sollten die API kennen und nutzen. Wird es kompliziert, dann können sie ja immer noch einen Informatiker zu Rate ziehen. Blöd nur, wenn man selbst ein solcher Informatiker ist. Vor dem Spiel kommt das Training. Da müssen Fussballer auch mal gegen Gummibänder laufen und Informatiker mit Zeigern turnen. 6 2.3 Datentyp trifft Datenstruktur I: Veränderliche Listen Datentypen werden realisiert mit Hilfe von Datenstrukturen sowie mit Algorithmen die auf ihnen arbeiten. Speziell wenn die Typen Kollektionen repräsentieren, können die Datenstrukturen und somit natürlich auch die Algorithmen recht komplex werden. Beginnen wir mit etwas Einfachen. Dem eigenen Datentyp MutableList. Instanzen dieses Typs sollen wie die Listen in Java veränderlich sein. Um die Funktionalität festlegen, definieren wir ein Interface: public interface MutableList { /** * Add a element at given position in the list. * After an invocation of this method the * list will contain v at index pos * @param i the index at which the element is to be inserted * @param v the element to be inserted * @throws IllegalArgumentException if the list does not contain at least i-elements */ void insert(int i, int v) throws IllegalArgumentException; /** * Get a element at given position in the list. * After an invocation of this method the * list will have removed the element at index pos * @param i the index at which the element is to be removed * @param v the element to be inserted * @throws IllegalArgumentException if the list does not contain at least i-elements */ int extract(int i); } Die Funktionalität der Liste ist der Übersichtlichkeit halber recht sparsam. Eine Implementierung mit Zeigerstrukturen ist recht einfach. Wir greifen einfach auf unsere Liste von oben zurück und definieren die Klasse LinkedMutableList: public class LinkedMutableList implements MutableList { // Datenstruktur private static class List { int head; Node tail; public List(int head, List tail) { this.head = head; this.tail = tail; } } private List anchor = null; // Implementierung der Funktionalitaet @Override public void insert(int i, int v) throws IllegalArgumentException { ... } @Override public int extract(int i) { ... } } 7 Wir haben jetzt eine äussere Definition einer Liste und eine innere. Die äussere implementiert das Interface und stellt dem Anwendungsprogrammierer die gewünschte Funktionalität zur Verfügung. Die innere Listendefinition gibt unsere Entscheidung wider, diese Funktionalität mit Hilfe von “verzeigerten” Objekten realisieren zu wollen. Der doppelte Gebrauch des Namens List ist eventuell verwirrend. Nennen wir die innere Liste darum um in Node: public class LinkedMutableList implements MutableList { // Datenstruktur private static class Node { int head; Node tail; public Node(int head, Node tail) { this.head = head; this.tail = tail; } } private Node anchor = null; // Implementierung der Funktionalitaet @Override public void insert(int i, int v) throws IllegalArgumentException { ... } @Override public int extract(int i) { ... } } Die Methoden sind mit etwas Übung in Pointer-Gymnastik auch schnell implementiert: public class LinkedMutableList implements MutableList { private static class Node { ... } private Node anchor = null; @Override public void insert(int i, int v) throws IllegalArgumentException { if (i == 0) { anchor = new Node(v, anchor); } else { int count = 0; Node prev = null; Node next = anchor; while (count < i) { if (next == null) { throw new IllegalArgumentException(); } prev = next; next = next.tail; count = count+1; } prev.tail = new Node(v, next); } } @Override public int extract(int i) { int count = 0; Node act = anchor; Node prev = null; 8 while (count < i) { if (act == null) { throw new IllegalArgumentException(); } prev = act; act = act.tail; count = count+1; } if (act == null) { throw new IllegalArgumentException(); } else { int res = act.head; if (prev != null) { prev.tail = act.tail; } else { anchor = anchor.tail; } return res; } } } 2.4 Datentyp trifft Datenstruktur II: Unveränderliche Listen Der Zeitgeist weht hierhin und dann wieder dorthin. Im Augenblick weht er in Richtung funktionaler Programmierung. Zur funktionalen Programmierung gehört fast untrennbar, das Konzept der unveränderlichen Kollektionstypen. Warum das so ist soll hier erst einmal nicht interessieren. Wir definieren einfach mal eine unveränderliche Variante der Liste: public interface ImmutableList { /** * Get element at given position. * @param i a position in the list * @return the element at position i * @throws IllegalArgumentException if the list has less than i elements */ int get(int i) throws IllegalArgumentException; /** * Create a copy of this list with additional element at position i. * @param i the position at which the element is to be inserted * @param v the element to be inserted * @return a copy of this with the additional element * @throws IllegalArgumentException if the list has less than i elements */ ImmutableList insert(int i, int v) throws IllegalArgumentException; /** * Create a copy of this list with element at position i removed. * @param i the position at which the element is to be removed * @return a copy of this without the removed element * @throws IllegalArgumentException if the list has less than i elements */ ImmutableList remove(int i) throws IllegalArgumentException; } Diese Listenvariante hat ein anderes Interface. Den funktionalen Charakter erkennt man daran, dass Methoden die in der veränderlichen Variante vom Typ void waren, jetzt eine Liste zurück geben. Die Implementierung ist anders, aber zunächst nicht wesentlich anspruchsvoller: public class LinkedImmutableList implements ImmutableList { 9 private static class Node { @Override public String toString() { return "Node [head=" + head + ", tail=" + tail + "]"; } int head; Node tail; public Node(int head, Node tail) { this.head = head; this.tail = tail; } public Node append(int v) { Node p = this; while (p.tail != null) { p = p.tail; } p.tail = new Node(v, null); return this; } } private Node anchor = null; @Override public int get(int i) throws IllegalArgumentException { Node act = anchor; int count = 0; while (count < i) { if (act == null) { throw new IllegalArgumentException(); } count = count + 1; act = act.tail; } return act.head; } @Override public ImmutableList insert(int i, int v) throws IllegalArgumentException { LinkedImmutableList res = new LinkedImmutableList(); Node act = anchor; int count = 0; while (true) { if (count == i) { res.anchor = (res.anchor == null) ? new Node(v, null) : res.anchor.append(v); } else if (count < i && act == null){ throw new IllegalArgumentException(); } else if (count > i && act == null){ break; } else { res.anchor = ((res.anchor == null) ? new Node(act.head, null) : res.anchor.append(act.head)); act = act.tail; } count = count + 1; } return res; } @Override public ImmutableList remove(int i) throws IllegalArgumentException { // TODO Auto-generated method stub 10 return null; } } Die Implementierung der Methode remove sei dem Leser als Übung überlassen. Das Einfügen eines Elements führt dazu, dass die gesamte Liste kopiert werden muss. – Nicht sehr effizient und der Grund warum Java die veränderlichen Listen bevorzugt. Mit dem Einsatz von etwas anspruchsvolleren Datenstrukturen und Algorithmen kann die Kopierarbeit aber stark reduziert werden. Moderne funktionale Programmiersprachen verwenden darum für ihre funktionalen Typen fortgeschrittene Datenstrukturen. 11