Induktive Mengen, Datentypen und Datenstrukturen

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