Galileo Computing <openbook>
Galileo Computing - Programming the Net
Galileo Computing - Programming the Net


Java ist auch eine Insel von Christian Ullenboom
Programmieren für die Java 2-Plattform in der Version 1.4
Buch: Java ist auch eine Insel - Zum Katalog
gp Kapitel 11 Datenstrukturen und Algorithmen
  gp 11.1 Mit einem Iterator durch die Daten wandern
    gp 11.1.1 Bauernregeln aufzählen
  gp 11.2 Dynamische Datenstrukturen
  gp 11.3 Die Klasse Vector
    gp 11.3.1 Vektoren erzeugen
    gp 11.3.2 Funktionen
    gp 11.3.3 Arbeitsweise des internen Arrays
    gp 11.3.4 Die Größe eines Felds
    gp 11.3.5 Eine Aufzählung und gleichzeitiges Verändern
    gp 11.3.6 Die Funktionalität eines Vektors erweitern
  gp 11.4 Stack, der Stapel
    gp 11.4.1 Die Methoden vom Stack
    gp 11.4.2 Das oberste Stack-Element duplizieren
    gp 11.4.3 Ein Stack ist ein Vektor – Aha!
  gp 11.5 Die Klasse BitSet für Bitmengen
    gp 11.5.1 BitSet anlegen und füllen
    gp 11.5.2 Mengenorientierte Operationen
    gp 11.5.3 Funktionsübersicht
    gp 11.5.4 Primzahlen in einem BitSet verwalten
  gp 11.6 Die Klasse Hashtable und assoziative Speicher
    gp 11.6.1 Ein Objekt der Klasse Hashtable erzeugen
    gp 11.6.2 Einfügen und Abfragen der Datenstruktur
    gp 11.6.3 Die Arbeitsweise einer Hashtabelle
    gp 11.6.4 Aufzählen der Elemente
    gp 11.6.5 Ausgabe der Hashtabelle und Gleichheitstest
    gp 11.6.6 Klonen
  gp 11.7 Die abstrakte Klasse Dictionary
    gp 11.7.1 Zugriff und Abfrage
    gp 11.7.2 Metainformationen
    gp 11.7.3 Iterationen über die Elemente
  gp 11.8 Die Properties-Klasse
    gp 11.8.1 Über die Klasse Properties
    gp 11.8.2 put(), get() und getProperties()
    gp 11.8.3 Eigenschaften ausgeben
    gp 11.8.4 Systemeigenschaften der Java-Umgebung
    gp 11.8.5 Browser-Version abfragen
    gp 11.8.6 Properties von der Konsole setzen
  gp 11.9 Queue, die Schlange
  gp 11.10 Die Collection API
    gp 11.10.1 Die Schnittstelle Collection
    gp 11.10.2 Schnittstellen, die Collection erweitern
    gp 11.10.3 Abstrakte Basisklassen für Container
    gp 11.10.4 Konkrete Container-Klassen
    gp 11.10.5 Unterschiede zu den älteren Datenstrukturen und Synchronisation
    gp 11.10.6 Das erste Programm mit Container-Klassen
    gp 11.10.7 Iteratoren
    gp 11.10.8 Der Comparator
    gp 11.10.9 toArray() von Collection verstehen – Chance für eine Falle erkennen
  gp 11.11 Listen
    gp 11.11.1 AbstractList
    gp 11.11.2 Optionale Methoden
    gp 11.11.3 ArrayList
    gp 11.11.4 LinkedList
  gp 11.12 Algorithmen
    gp 11.12.1 Datenmanipulation
    gp 11.12.2 Größten und kleinsten Wert einer Collection finden
    gp 11.12.3 Sortieren
    gp 11.12.4 Elemente in der Collection suchen
  gp 11.13 Typsichere Datenstrukturen
  gp 11.14 Ein Design-Pattern durch Beobachten von Änderungen
    gp 11.14.1 Design-Pattern
    gp 11.14.2 Das Beobachter-Pattern (Observer/Observable)

Kapitel 11 Datenstrukturen und Algorithmen

Einen Rat befolgen heißt,
die Verantwortung verschieben.
– Urzidil

Algorithmen sind ein zentrales Thema in der Informatik. Die Erforschung und Untersuchung von Algorithmen nimmt dort einen bedeutenden Platz ein. Algorithmen operieren nur dann effektiv mit Daten, wenn diese für einen Algorithmus geeignet strukturiert sind. Schon das Beispiel Telefonbuch zeigt, wie wichtig die Ordnung der Daten nach einem Schema ist. Die Suche nach einer Telefonnummer bei gegebenem Namen gelingt schnell, jedoch ist die Suche nach einem Namen bei bekannter Telefonnummer ein mühseliges Unterfangen. Datenstrukturen und Algorithmen sind also eng miteinander verbunden und die Wahl der richtigen Datenstruktur entscheidet über effiziente Laufzeiten; beide erfüllen nie alleine ihren Zweck. Leider ist die Wahl der »richtigen« Datenstruktur nicht so einfach, wie es sich anhört, und eine Reihe von schwierigen Problemen in der Informatik sind wohl deshalb noch nicht gelöst, da eine passende Datenorganisation bis jetzt nicht gefunden wurde.

Die wichtigsten Datenstrukturen wie Liste, Vektor und Hash-Tabelle sollen in diesem Kapitel vorgestellt werden. In der zweiten Hälfte des Kapitels wollen wir uns dann mehr den Algorithmen widmen, die auf diesen Datenstrukturen operieren.


Galileo Computing

11.1 Mit einem Iterator durch die Daten wandern  downtop

Wir wollen bei den Datenstrukturen eine Möglichkeit kennen lernen, wie sich die gespeicherten Daten unabhängig von der Implementierung immer mit derselben Technik abfragen lassen. Bei den Datenstrukturen handelt es sich meistens um Daten in Arrays, Bäumen oder Ähnlichem. Oft wird nur die Frage nach der Zugehörigkeit eines Werts zum Datenbestand gestellt, also »Gehört das Wort dazu?«. Dieses Wortproblem ist durchaus wichtig, aber die Möglichkeit, die Daten in irgendeiner Weise aufzuzählen, ist nicht minder wichtig. Bei Arrays haben wir die Möglichkeit über den Index auf die Elemente zuzugreifen. Da wir jedoch nicht immer ein Array als Datenspeicher haben und uns auch die objektorientierte Programmierung verbietet, hinter die Kulisse zu schauen, benötigen wir nach Möglichkeit einen allgemeineren Weg. Hier bieten sich Enumeratoren an. Das Interface Enumeration bietet die zwei Funktionen hasMoreElements() und nextElement() an, mit denen durch eine Datenstruktur iteriert werden kann – wir sprechen in diesem Fall auch von einem Iterator. Bei jedem Aufruf von nextElement() bekommen wir ein weiteres Element der Datenstruktur. Im Gegensatz zum Index eines Felds können wir nicht noch einmal ein Objekt auslesen oder hin und her springen. Wollten wir ein Element zweimal besuchen, zum Beispiel von rechts nach links noch einmal durchwandern, dann müssen wir wieder ein neues Enumeration-Objekt erzeugen.

Abbildung

Damit wir durch eine Datenstruktur iterieren können, muss das Objekt beide Funktionen so implementieren, dass sie die Daten ihrer zu Grunde liegenden Datenstruktur preisgeben. Wenn wir später die anderen Datenstrukturen wie Vector oder Stack kennen lernen, so werden wir sehen, dass auch sie das Interface Enumeration implementieren.

interface java.util.Enumeration

gp  boolean hasMoreElements()
Testet, ob noch ein weiteres Element aufgezählt werden kann.
gp  Object nextElement()
Liefert das nächste Element der Enumeration zurück. Diese Funktion kann eine NoSuch ElementException auslösen, wenn nextElement() aufgerufen wird, und das Ergebnis false beim Aufruf von hasMoreElements() ingoriert wird.

Die Aufzählung geschieht meistens über einen Zweizeiler: Die Datenstruktur ds besitzt eine Methode elements(), die ein Enumeration-Objekt zurückgibt, welches die Elemente von ds aufzählen kann.

for ( Enumeration e = ds.elements();
e.hasMoreElements(); )
System.out.println( e.nextElement() );

Galileo Computing

11.1.1 Bauernregeln aufzählen  downtop

Doch nun zu einem Beispiel. Es zeigt die Möglichkeit, Bauernregeln aufzuzählen. Die Funktion nextElement() löst eine NoSuchElementException aus, wenn das Ergebnis false von hasMoreElements() ignoriert wird. NoSuchElementException ist eine RuntimeException, sodass sie nicht ausdrücklich aufgefangen werden muss.

Listing 11.1   BauernregelnEnumerator.java
import java.util.*;

class BauernregelnEnumerator implements Enumeration
{
private int counter = 0;

private String slogan[] =
{
"Der dümmste Bauer erntet die dicksten Kartoffeln.",
"Wenn der Hahn kräht auf dem Mist, dann ändert sich "+
"das Wetter oder es bleibt wie es ist.",
"Ist der Juni kalt und nass, füllt's dem Bauern Scheun' "+
"und Fass.",
"Sind die Hühner platt wie Teller, war der Bauer "+
"wieder schneller.",
"Jodelt laut die Magd im Stall, kriegt die Kuh 'nen "+
"Herzanfall",
"Regnet es ins Hühnerhaus, holt der Hahn das Shampoo raus!",
"Liegt der Bauer im Mist, weiß er nicht wie spät es ist.",
"Liegt der Bauer tot im Zimmer, lebt er nimmer.",
"Bauen im April die Schwalben, gibt es viel Futter, Korn "+
"und Kalben."
};

public boolean
hasMoreElements()
{
return ( counter < slogan.length );
}

public
Object nextElement()
{
if ( !hasMoreElements() )
throw new
NoSuchElementException("Keine Bauernregeln mehr!");

return slogan[counter++];
}
}
Listing 11.2   Bauernregeln.java
class Bauernregeln
{
public static void main( String args[] )
{
Enumeration e = new BauernregelnEnumerator();

while ( e.hasMoreElements() )
System.out.println( e.nextElement() );
}
}

Galileo Computing

11.2 Dynamische Datenstrukturen  downtop

Dynamische Datenstrukturen passen ihre Größe der Anzahl der Daten an, die sie aufnehmen. Die meisten Datenstrukturen sind dynamisch, haben aber dadurch den Nachteil, dass zur Laufzeit Speicher angefordert werden muss, wenn Daten eingefügt werden. Dieses Problem wurde mittlerweile in der Informatik erkannt, und der Trend geht wieder zu festen, nicht dynamischen Datenstrukturen – natürlich nur dort, wo dies möglich ist. Nicht dynamische Datenstrukturen sind beispielsweise Arrays, die einmal fest in einer bestimmen Größe erzeugt sind. Eine Liste kann durch Verketteten von Objekten zusammengefügt werden oder aber die Listenelemente werden der Reihe nach in einem Array abgelegt. Dem Array ist dann der Vorzug zu geben, wenn sich die Anzahl der Listenelemente nicht ändert. Sollten doch zusätzliche Elemente hinzukommen, muss der gesamte Inhalt der Liste in ein größeres Array kopiert werden.

Die Java-Entwickler haben auch die folgende, in der Bibliothek vordefinierte Datenstruktur Vector intern durch ein Array implementiert, das bei Bedarf durch ein neues, größeres Array ersetzt wird.


Galileo Computing

11.3 Die Klasse Vector  downtop

Jedes Exemplar der Klasse Vector vertritt ein Array mit variabler Länge. Der Zugriff auf die Elemente erfolgt über Indizes, zwar nicht über den Operator [], aber doch über Methoden, die einen Index als Parameter annehmen. Da in einem Vector jedes Exemplar einer von Object abgeleiteten Klasse Platz findet, ist ein Vector nicht auf bestimmte Datentypen fixiert. Dies ist ein Problem, denn beim Zugriff auf Elemente des Vektors muss der Entwickler wissen, welchen Typ die Vektorelemente haben werden. Dasselbe Problem haben wir bei anderen Datenstrukturen auch.


Galileo Computing

11.3.1 Vektoren erzeugen  downtop

Um ein Vector-Objekt zu erzeugen, existieren vier Konstruktoren.

class java.util.Vector
extends AbstractList
implements List, Cloneable, Serializable
gp  Vector()
Ein leerer Vektor mit einer Anfangskapazität von zehn Elementen wird angelegt.
gp  Vector( int kapazität )
Ein leerer Vektor wird angelegt, der Platz für zunächst kapazität Elemente bietet, ohne dass der Vektor vergrößert werden muss.
gp  Vector( int kapazität, int kapazitätsZuwachs )
Ein Vektor mit Platz für zunächst kapazität Elemente wird angelegt. Zusätzlich beschreibt die Zahl kapazitätsZuwachs, um wie viele Elemente die Kapazität des Vektors jedes Mal erhöht wird, wenn der verfügbare Platz erschöpft ist.

Ein weiterer Konstruktor erlaubt ein beliebiges Objekt vom Typ Collection als Parameter. Wir werden uns später näher mit Collections auseinander setzen.

Beispiel Erstelle einen Vector v und fülle die Datenstruktur mit einer Zeichenkette und einer Ganzzahl
Vector v = new Vector();
v.
addElement( "ICE 924 Hildegard von Bingen" );
v.
addElement( new Integer(23) );

Die eingefügte Zahl macht deutlich, dass als Elemente nur Objekte akzeptiert werden, und keine primitiven Datentypen. Eine Unterklasse könnte jedoch Vector erweitern und die Daten für den Benutzer unsichtbar in Objekten kapseln.

Abbildung


Galileo Computing

11.3.2 Funktionen  downtop

Die Klasse Vector bietet eine Vielzahl von Methoden, die wir hier zusammenfassen:

class java.util.Vector
extends AbstractList

gp  void addElement( Object o )
Fügt das Objekt o dem Vektor hinter dem letzten hinzugefügten Element an. Reicht der interne Puffer nicht aus, wird gegebenenfalls der Vektor vergrößert.
gp  int capacity()
Liefert die aktuelle Kapazität des Vektors. capacity() ist in der Praxis eher unwichtig, denn das Kapazitätsmanagement soll die Klasse selber machen.
gp  Object clone()
Die Klasse implementiert die clone()-Methode von Object, das heißt eine Referenz auf eine Kopie des aufrufenden Vektors wird zurückgegeben. Es handelt sich um eine flache Kopie; Original-Vektor und Kopie referenzieren also dieselben Element-Objekte.
gp  boolean contains( Object o )
Sucht das Element. Liefert true zurück, wenn ein mit o inhaltlich gleiches Objekt im Vektor enthalten ist.
gp  void copyInto( Object array[] )
Kopiert die Elemente des Vektors in das Array array.
gp  Object elementAt( int index )
Das an der Stelle index befindliche Vektorelement wird zurückgegeben.
gp  Enumeration elements()
Gibt ein Aufzählungs-Objekt zurück. Somit können wir die Elemente eines Vektors aufzählen.
gp  Object firstElement()
Gibt das erste Element des Vektors zurück.
gp  int indexOf( Object o )
Sucht im Vektor nach dem Objekt o. Liefert den Index des passenden Vektorelements zurück oder -1, falls kein gleiches Element vorkommt.
gp  int indexOf( Object o, int index )
Sucht im Vektor ab der Position index nach dem Objekt o. Liefert den Index des passenden Vektorelements zurück oder -1, falls kein gleiches Element vorkommt.
gp  void insertElementAt( Object o, int index )
Fügt Objekt o an der Stelle index in den Vektor ein und verschiebt die anderen Elemente um eine Position nach hinten. Auch das ursprünglich an der Position index befindliche Element wird verschoben.
gp  boolean isEmpty()
Gibt true zurück, falls der Vektor kein Element enthält.
gp  Object lastElement()
Gibt das letzte Element des Vektors (das mit dem größten Index) zurück.
gp  int lastIndexOf( Object o )
Sucht rückwärts durch dem Vektor nach o und gibt die Position des passenden Elements zurück. Existiert kein zu o gleiches Element im Vektor, wird -1 zurückgegeben.
gp  int lastIndexOf( Object o, int index )
Sucht rückwärts ab Position index durch den Vektor nach o und gibt die Position des passenden Elements zurück. Existiert kein zu o gleiches Element im Vektor, wird -1 zurückgegeben.
gp  void removeAllElements()
Entfernt alle Elemente aus dem Vektor.
gp  boolean removeElement( Object o )
Entfernt das erste mit o übereinstimmende Element aus dem Vektor. Das Funktionsergebnis signalisiert, ob ein passendes Element gefunden (und gelöscht) wurde.
gp  void removeElementAt( int index )
Entfernt das Element an der Stelle index. Alle nachfolgenden Elemente rücken um eine Position vor.
gp  void setElementAt( Object o, int index )
Das Objekt o wird an der Stelle index im Vektor platziert. Das vorher an dieser Position befindliche Element wird aus dem Vektor entfernt.
gp  void setSize( int size )
Setzt die Größe des Vektors. Überzählige Elemente werden aus dem Vektor entfernt bzw. Null-Referenzen als neue Einträge angehängt.
gp  int size()
Gibt die Anzahl der Elemente im Vektor zurück. Es gilt stets: v.size() <= v.capacity().
gp  String toString()
Konvertiert den Vektor in einen String; eine textuelle Aufzählung seiner Elemente.
gp  void trimToSize()
Übersteigt die Kapazität eines Vektors die Anzahl der enthaltenen Elemente, so beseitigt diese Methode die überzählige Kapazitätsreserve. Dadurch wird der Speicherbedarf minimiert.

Galileo Computing

11.3.3 Arbeitsweise des internen Arrays  downtop

Ein Vektor muss zwei Größen verwalten: Zum einen die Anzahl der gespeicherten Elemente nach außen, zum anderen die interne Größe des Felds. Ist die Kapazität des Felds größer als die Anzahl der Elemente, so können noch Elemente aufgenommen werden, ohne dass der Vektor etwas unternehmen muss. Die Anzahl der Elemente im Vektor, die Größe, liefert die Methode size() und Kapazität des darunter liegenden Arrays liefert capacity().

Der Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen werden, als ursprünglich an Platz vorgesehen war. Diese Operation heißt Resizing. Dabei spielen die Größen initialCapacity und capacityIncrement besonders für ein effizientes Arbeiten eine wichtige Rolle. Sie sollten passend gewählt sein. Schauen wir uns daher zunächst einmal die Funktionsweise des Vektors an, falls das interne Array zu klein ist.

Wenn das Array zehn Elemente fasst, nun aber ein elftes eingefügt werden soll, so muss das Laufzeitsystem einen neuen Speicherbereich reservieren und jedes Element des alten Felds in das neue kopieren. Dies kostet Zeit. Schon aus diesem Grunde sollte der Konstruktor Vector(int initialCapacity) gewählt werden, da dieser eine Initialgröße festsetzt. Das Wissen über unsere Daten hilft dann der Datenstruktur. Falls kein Wert voreingestellt wurde, so werden zehn Elemente angenommen. In vielen Fälle ist dieser Wert zu klein.

Nun haben wir zwar darüber gesprochen, dass ein neues Feld angelegt wird und die Elemente kopiert werden, haben aber nichts über die Größe des neuen Felds gesagt. Hier gilt die »Verdopplungsmethode«. Wird der Vector vergrößert, so ist das neue Feld doppelt so groß wie das alte. Dies ist eine Vorgehensweise, die für kleine und schnell wachsende Felder eine clevere Lösung darstellt, für große Felder aber schnell zum Verhängnis werden kann. In dem Fall, wo wir die Vergrößerung selbst bestimmen wollen, nutzen wir den Konstruktor Vector(int initialCapacity, int capacityIncrement) oder ändern die Größe über Methoden.


Galileo Computing

11.3.4 Die Größe eines Felds  downtop

Mit capacity() gelangen wir an die interne Größe des Arrays. Sie kann mit ensureCapacity() geändert werden. Ein Aufruf von ensureCapacity(int minimumCapacity) bewirkt beim Vektor, dass er insgesamt mindestens minimumCapacity Elemente aufnehmen kann, ohne dass ein Resizing nötig wird. Ist die aktuelle Kapazität des Vektors kleiner als minimumCapacity, so wird mehr Speicher angefordert. Der Vektor verkleinert nicht die aktuelle Kapazität, falls sie schon höher als minimumCapacity ist. Um aber auch diese Größe zu ändern, und somit ein nicht mehr wachsendes Vektorarray so groß wie nötig zu machen, gibt es, ähnlich wie beim String mit Leerzeichen, die Methode trimToSize(). Sie reduziert die Kapazität des Vektors auf die Anzahl der Elemente, die gerade im Vector sind. Mit size() lässt sich die Anzahl der Elemente im Vektor erfragen. Sie gibt die wirkliche Anzahl der Elemente zurück. Wollen wir diese ändern, so nutzen wir dazu setSize(int newSize). Ist die neue Größe kleiner als die alte, so werden die Elemente am Ende des Vektors abgeschnitten. Ist newSize größer als die alte Größe, werden die neu angelegten Elemente mit null initialisiert. Vorsicht ist bei newSize=0 geboten, denn setSize(0) bewirkt das Gleiche wie removeAllElements().

Die Vector-Klasse bietet uns für die Konvertierung von Vector-Objekten in Felder eine Methode an.

class java.util.Vector
extends AbstractList

gp  final synchronized void copyInto( Object[] obArray )
Kopiert die Elemente des Vektors in das Array. Falls das bereitgestellte Objektfeld nicht so groß ist wie der Vektor, so tritt eine ArrayIndexOutOfBoundsException auf.

Der folgende Programmausschnitt erzeugt ein Array für Objektreferenzen und kopiert Referenzen für alle Elemente eines Vektors hinein:

Object obArray[] = new Object[myVector.size()];
myVector.copyInto( obArray );

Galileo Computing

11.3.5 Eine Aufzählung und gleichzeitiges Verändern  downtop

Noch vor einem weiteren Fehler bei der Aufzählung von Elementen eines Vectors muss gewarnt werden. Da dieser Fehler allerdings in der Version 1.2 behoben wurde, ist nachfolgendes Beispiel nur von historischer Bedeutung. Das folgende Programm fügt einem Vector drei Integer hinzu. Nach dem Hinzufügen besorgen wir uns eine Aufzählung, geben das erste Element aus und löschen es dann. Anschließend geben wir das nächste Element aus.

Listing 11.3   VectorEnumerator.java
import java.util.*;

public class VectorEnumerator
{
public static void main( String args[] )
{
Vector temp = new Vector();
int i = 0;

temp.addElement( new Integer(i++) );
temp.addElement( new Integer(i++) );
temp.addElement( new Integer(i++) );

Enumeration e = temp.elements();
System.out.println( e.nextElement() );
temp.removeElementAt( 0 );
System.out.println( e.nextElement() );
}
}

Die Programmausführung ergibt folgende Ausgabe:

Exemplarisch sehen wir, wie problematisch es ist, wenn in einer existierenden Aufzählung die Datenstruktur geändert wird. In diesem Fall werden beim Löschen des i-ten Elements die Elemente ab der Position i+1 bis zur Größe des Vektors um eine Stelle verschoben. Damit haben wir aber das Problem, dass jede Aufzählung auf dem Vektor das aktuelle Argument überspringt. In dem oberen Beispiel fehlt uns das Element 1. Mit den Möglichkeiten der Aufzählung unter Verwendung der Schnittstelle Enumeration ist das Problem auch unter 1.2 nicht behoben.

Mit dem Iterator-Interface, das unter 1.2 im Collection-Framework eingeführt worden ist, werden diese Probleme umgangen. Ohne dieses neue Iterator-Interface bleibt uns nichts anderes übrig, als sehr aufmerksam den Vektor zu füllen oder eine eigene Implementierung des Vektors vorzunehmen, der die Löschoperationen sichert, bevor die Aufzählung durch den Vektor vollständig ist.

Iteratoren mit der remove()-Methode

Enumeration und Iterator verbieten beide Modifikationen an der zu Grunde liegenden Datenstruktur während über die Elemente iteriert wird. Die Iteratoren für die Collection-Klassen aus JDK 1.2 erkennen diesen Verstoß allerdings mit sehr hoher Wahrscheinlichkeit und lösen einen entsprechenden Laufzeitfehler aus. Enumerations verhalten sich einfach nur stillschweigend undefiniert, so wie oben im Beispiel gezeigt. Die obige Konstruktion mit removeElementAt() löst auch mit einem Iterator nur eine Exception aus. Umgangen wird das Problem nur, wenn die remove()-Methode des Iterators verwendet wird. Falls gleichzeitig mehrere Iteratoren über denselben Vektor laufen, gibt es aber immer noch Ausnahmen von anderen Iteratoren, außer dem, dessen remove()-Methode aufgerufen wurde.


Galileo Computing

11.3.6 Die Funktionalität eines Vektors erweitern  downtop

Die Vector-Klasse wird, wie wir im nächsten Kapitel sehen werden, von der Klasse Stack erweitert. Dies ist sicherlich aus Sicht des OO-Entwurfs nicht besonders sinnvoll. Was jedoch noch einschränkender ist, ist die Tatsache, dass alle Methoden der Vector-Klasse final sind. Damit gibt es zwar ein wenig mehr Laufzeiteffizienz, was jedoch bei synchronized-Methoden keine große Rolle mehr spielt; es hindert aber dennoch, die Klasse zu erweitern.


Galileo Computing

11.4 Stack, der Stapel  downtop

Die Klasse Stack repräsentiert einen Stapelspeicher, auch »Keller« genannt, der als LIFO (Last-In-First-Out) Datenstruktur bekannt ist. Beim Hinzufügen von Elementen wächst die Datenstruktur dynamisch. Die Klasse Stack ist eine Erweiterung der Klasse Vector – wir diskutieren später noch einmal diese prickelnde Entscheidung –, womit die Klasse noch zusätzliche Funktionalität besitzt, beispielsweise die Fähigkeit der Aufzählung und des wahlfreien Zugriffs auf Kellerelemente.

Beispiel Füge in den Stack zwei Strings ein und lese sie wieder aus.
Stack s = new Stack();
s.push( 
"Michaela" );
s.push( "Kerstin" );
String s1 = (String)
s.pop();
String s2 = (String)
s.pop();


Galileo Computing

11.4.1 Die Methoden vom Stack  downtop

Stack besitzt nur wenig zusätzliche Methoden gegenüber dem Vektor.

class java.util.Stack

gp  Stack()
Der Konstruktor erzeugt einen Stack der Größe null.
gp  boolean empty()
Testet, ob Elemente auf dem Stapel vorhanden sind.
gp  Object push( Object )
Element vom Typ Object wird auf den Stapel gebracht.
gp  Object pop( Object )
Holt das letzte Element vom Stapel. EmptyStackException signalisiert einen leeren Stapel.
gp  Object peek()
Das oberste Element wird nur vom Stapel gelesen, aber nicht wie bei pop() entfernt. Beim leeren Stapel wird eine EmptyStackException ausgelöst.
gp  int search( Object o )
Sucht im Stapel nach dem obersten Eintrag, der mit dem Objekt o übereinstimmt. Gibt den Index zurück oder -1, falls das Objekt nicht im Stapel ist. 1 bedeutet, dass der gesuchte Eintrag ganz oben auf dem Stapelspeicher liegt, 2 bezeichnet die zweitoberste Position usw. Die Zählweise ist ungewöhnlich, denn sie ist nicht null-basiert wie alle anderen Funktionen, die mit Positionen arbeiten.
Hinweis Exceptions von Stack: Im Gegensatz zu Vector kann Stack die Exception EmptyStackException erzeugen, um einen leeren Stapel zu signalisieren. Durch einen Rückgabewert null ist ein Fehlschlag nicht angezeigt, da null ein gültiger Rückgabewert sein kann.


Galileo Computing

11.4.2 Das oberste Stack-Element duplizieren  downtop

Die Klasse Stack besitzt zwar die Basisfunktionalität, die ein Stapel besitzen sollte, aber auch nicht mehr. Hin und wieder wünschen wir uns aber eine Funktion, die das oberste Stack-Element dupliziert, kurz dup(). Bei der Implementierung treten allerdings zwei Fragen auf, mit denen zwei völlig unterschiedliche Lösungsansätze verbunden sind. Da die Klasse Stack wie die anderen Datenstrukturen auf Objekte ausgelegt ist, müssen wir uns darüber Klarheit verschaffen, wie das obere Objekt dupliziert werden soll. Soll eine Kopie der Objekt-Referenz neu auf den Stapel gelegt werden oder etwa das gesamte Objekt geklont werden?

Die einfache Lösung

Die einfachste Lösung besteht darin, das oberste Objekt einfach mittels der schon vorhandenen Stack-Methoden push() und peek() draufzulegen. So sieht die erste Variante so aus:

void dup() throws EmptyStackException
{
st.push( st.peek() );

peek() gibt aber lediglich eine Referenz auf das Objekt zurück. Und das anschließende push() speichert diese Referenz dann auf dem Stapel. Nehmen wir an, wir haben zwei StringBuffer-Objekte auf dem Stapel. Wenn wir nun dup() aufrufen und den String ändern, der oben auf dem Stapel liegt, so ändern wir automatisch das zweite Element gleich mit. Dies ist aber nicht unbedingt beabsichtigt, und wir müssen uns Gedanken über eine alternative Lösung machen. Wir sehen, dass dup() in der Klasse Stack fehlt, weil seine Implementierung davon abhängt, ob eine Referenz- oder eine Wertsemantik für Kellerelemente gewünscht ist.

Die kompliziertere Lösung

Um das oberste Stack-Element zu kopieren, bietet sich die clone()-Methode von Object an. All die Objekte, die sich klonen lassen, und das sind längst nicht alle, implementieren das Interface Cloneable. Nun ließe sich einfach folgern: Wenn das zu duplizierende Objekt ein Exemplar von Cloneable ist, dann können wir einfach die clone()-Methoden aufrufen und das zurückgegebene Objekt mittels push() auf den Stapel bringen.

void dup() throws CloneNotSupportedException
{
  try {
    Objekt top = peek();
 
    if ( top instanceof Cloneable 
)
      push( top.clone() );
 
  } catch ( EmptyStackException 
e ) { }

Beziehungsweise

void dup() throws CloneNotSupportedException, 
EmptyStackException
{
 push(peek().clone());

Dies funktioniert für die meisten Objekte, allerdings nicht für Objekte der Klasse Object. Denn clone() der Klasse Object ist protected – wir dürfen also von außen nicht dran, nur eine Unterklasse und die Klasse selbst. Hier haben wir also zwei Probleme.

gp  Leider lässt sich nur mit Aufwand überprüfen, ob das Objekt auf dem Stapel auch wirklich ein pures Object ist, denn alle Objekte sind instanceof Object. Glücklicherweise gibt es kaum eine Anwendung, wo reine Object-Elemente gesichert werden müssen.
gp  Was machen wir mit Objekten, die nicht klonbar sind? Leider gibt es für diese Frage keine direkte Antwort. Eine universelle Stack-Klasse mit einer uneingeschränkten dup()-Methode gibt es nicht. Wir müssen als Stack-Benutzer festlegen, dass das oberste Element Clonable ist, um zumindest eine eigene Implementierung nutzen zu können. Oder wir bleiben dabei, bei nicht klonbaren Objekten doch nur die Referenz zu duplizieren. Das wäre zumindest für eineindeutige Objekte mit Wertsemantik die ideale Lösung.

Galileo Computing

11.4.3 Ein Stack ist ein Vektor – Aha!  downtop

Eine genaue Betrachtung der Klasse Stack zeigt den unsinnigen und falschen Einsatz der Vererbung. Stack erbt alle Methoden von Vector und damit viele Funktionen, die im krassen Gegensatz zu den charakteristischen Eigenschaften eines Stapels stehen. Dazu zählen add(), addAll(), addElement(), capacity(), clear(), clone(), contains(), copyInto(), elementAt(), elements(), ensureCapacity(), firstElement(), get(), indexOf(), insertElement-At(), isEmpty(), lastElement(), lastIndexOf(), remove(), removeAllElements(), removeElement(), removeElementAt(), removeRange(), set(), setElementAt(), setSize(), size(), toArray(), trimToSize().

Abbildung

Für eine Änderung ist es aber nun auf Grund der Wahrung der Abwärtskompatibilität zu spät und die Implementierung bleibt. Sie hätte mit einem internen Vector oder einer ähnlichen Datenstruktur erfolgen müssen. Bleibt die Frage, warum sich der Autor Jonathan Payne für jene Variante entschieden hat. Aus Sicht der Softwaretechnik ist die Frage nicht so leicht zu beantworten. Hier stehen sich Kaufen (Delegation oder Komposition, also Verwenden eines Objekts) oder Erben (also Erweitern einer Klasse) gegenüber. Das Einzige, was wir als Programmierer aus diesem Missgeschick lernen können, ist die gründliche Analyse der Klassenbeziehungen. In einem Konflikt zwischen Kaufen und Erben sollte immer Kaufen statt Erben eingesetzt werden. Im Übrigen ist Jonathan Payne auch Autor der Klasse Vector.


Galileo Computing

11.5 Die Klasse BitSet für Bitmengen  downtop

Die Klasse BitSet bietet komfortable Möglichkeiten zur bitweisen Manipulation von Daten. Das Datum kann beliebig groß sein, und über Methoden von BitSet, lassen sich die einzelnen Bits leicht ändern. Zudem lassen sich Bits wie in einem Vektor zufügen, und das Objekt verwaltet selbstständig die Größe. Ein leeres BitSet wird mit dem Standard-Konstruktor angelegt. Ein weiterer Konstruktor erlaubt eine Startgröße, die ein Vergrößern aufschiebt.


Galileo Computing

11.5.1 BitSet anlegen und füllen  downtop

Jedes Bit besitzt zwei Zustände: gesetzt oder nicht gesetzt. Dies bringt sie in die Nähe der booleschen Werte, die ebenso zwei Zustände besitzen. Mit zwei Methoden lassen sich die Bits des BitSet leicht ändern: set(bitNummer) und clear(bitNummer). Da der Bit-Container automatisch wächst, ist es problemlos möglich, in einem BitSet-Exemplar mit 100 Bits, das Bit 300 zu setzen. Das Objekt wird automatisch mit 200 Null-Bits aufgefüllt, bevor das Bit 300 gesetzt wird.

Über die Methode size() erfahren wir, wie viele Bits das Objekt umfasst. Spannender ist die Methode length(). Sie liefert die Position des höchsten gesetzten Bits In size() werden überzählige führende Null-Bits mitgezählt, ähnlich wie ungenutzte Array-Elemente im capacity()-Wert eines Vektors. Die Abfrage, ob ein Bit gesetzt ist, erfolgt mit der Methode get(bitNummer). Sie gibt true zurück, falls das Bit gesetzt ist, andernfalls false.

Abbildung

Beispiel Setze in einem BitSet das erste und dritte Bit.
BitSet bs = new BitSet();
bs.set( 
0 );


Galileo Computing

11.5.2 Mengenorientierte Operationen  downtop

Das BitSet erlaubt mengenorientierte Operationen mit einer weiteren Menge. Jedes Bit, der im Parameter übergebenen Menge, wird mit dem Objekt in einer bestimmten Weise verknüpft. Das Ergebnis der Operation wird dem aktuellen Objekt zugewiesen. Drei Operationen sind verfügbar: Die Oder-Operation setzt das Bit, falls es im Objekt gesetzt ist oder das Bit im zweiten BitSet gesetzt ist. Die Und-Operation setzt das Bit, falls es im Objekt gesetzt ist und das Bit im zweiten BitSet gesetzt ist. Die Xor-Operation setzt das Bit, falls es nur in einem der beiden Objekte gesetzt ist. Damit werden die Operationen Mengenvereinigung, Durchschnitt und symmetrischer Durchschnitt implementiert.


Galileo Computing

11.5.3 Funktionsübersicht  downtop

Die Funktionen von BitSet sind überschaubar. Leider existieren keine Methoden, die Bits aus anderen Quellen, etwa einem Bitfeld aus byte[], übernehmen und einfügen. Da BitSet nicht final ist, können wir diese Zusatzfunktionalität in einer Unterklasse realisieren.

class java.util.BitSet

gp  BitSet()
Erzeugt ein neues BitSet-Objekt.
gp  BitSet( int )
Erzeugt ein BitSet mit vorgegebener Größe. Alle Bits sind am Anfang auf false gesetzt. Ist die Größe kleiner Null, so wird eine NegativeArraySizeException ausgelöst.
gp  void andNot( BitSet set )
Löscht alle Bits im Bitset, die dort set gesetzt sind.
gp  void set( int )
void clear( int )
Setzt oder löscht ein Bit. Ist der Index negativ, wird eine IndexOutOfBoundsException ausgelöst .
gp  boolean get( int )
Liefert der Wert des Bits am übergebenen Index. Kann bei negativem Index wieder IndexOutOfBoundsException auslösen.
gp  void and( BitSet )
void or( BitSet)
void xor( BitSet )
Verknüpft dieses BitSet-Exemplar per Und-, Oder-, Xor-Operation mit dem angegebenen BitSet-Objekt.
gp  boolen equals( Object o )
Vergleicht sich mit einem anderen BitSet-Objekt o.

Implementierungsdetails

Eine Optimierung der Geschwindigkeit war es, die Methoden der Klasse BitSet nicht zu synchronisieren. Greift also ein Thread auf die Daten zu, während ein anderer modifiziert, kommt es zu möglichen Inkonsistenzen. Witzigerweise ist in der API-Dokumentation die Methode hashCode() im Quellcode als synchronized abgedruckt. In der echten Implementierung fehlt aber diese Synchronisation.


Galileo Computing

11.5.4 Primzahlen in einem BitSet verwalten  downtop

Das folgende Programm zeigt die Anwendung der Klasse BitSet am Beispiel der Konstruktion der Menge von Primzahlen. Jedes gesetzte Bit entspricht einer Primzahl. In diesem Fall ist der Einsatz der Klasse BitSet angebracht, da eine Zahl in einem Wertebereich nur eine Primzahl oder keine sein kann.

Listing 11.4   Primtest.java
import java.util.*;
 
class Primtest
{
  final static int MAXPRIM = 30;
  
  public static void main( String 
args[] )
  {
    BitSet b = new BitSet();
  
    for ( int i = 2; i <= MAXPRIM; 
i++ )
    {
      boolean ok = true;
  
      for ( int j = 2; j < i; 
j++ )
        if ( b.get(j) && 
(i % j) == 0 ) {
          ok = false;
          break;
        }
  
      if ( ok )
         b.set(i);
    }
  
    for ( int i = 1; i <= MAXPRIM; 
i++ )
      if ( b.get(i) )
        System.out.println( "" 
+ i );
  }

Galileo Computing

11.6 Die Klasse Hashtable und assoziative Speicher  downtop