Symboltabellen und binäre Suche

Symboltabellen und binäre Suche
Michael Henn
In diesem Kapitel werden zunächst Symboltabellen definiert und
es wird ein Abstrakter Datentyp für diese eingeführt. Anschließend
wird auf die Schlüsselindizierte und sequentielle Suche, basierend
auf Symboltabellen, eingegangen. Bei der sequentiellen Suche wird
dabei die Implementierung einerseits mit Arrays, andererseits mit
verketteten Listen näher erläutert. Schließlich wird noch kurz die
binäre Suche und anschließend ein Verbesserungsvorschlag der binären Suche erklärt.
1
Definition
„Eine Symboltabelle ist eine Datenstruktur von Elementen mit
Schlüsseln, die zwei grundlegende Operationen unterstützt: Einfügen eines neuen Elements und Zurückgeben eines Elements mit einem gegebenen Schlüssel.“ [Sedgewick 1994]
Manchmal findet man in Büchern auch den Begriff „Wörterbuch“
anstatt „Symboltabelle“. Beispiele für Symboltabellen sind Wörterbücher, Telefonbücher, Lexika, Arrays und assoziative Speicher
(siehe Technische Informatik). Computerbasierte Symboltabellen
haben den Vorteil, flexibler zu sein und sich die Operationen auf
Symboltabellen wesentlich schneller ausführen lassen als das von
Hand möglich ist.
2
ADT für Symboltabellen
scan() frägt den Key und die Information vom Benutzer ab, wobei als int zurückgegeben wird, ob die Eingabe geklappt hat, oder
nicht (==0 bedeutet Fehler), und show() gibt das Item aus (wobei der Outstream selbst gewählt werden kann). Am Schluss wird
noch der Operator << überladen, damit man z.B. schreiben kann:
(cout << Item;) was dann das Item mit show() ausgibt.
# inclue < stdlib .h >
# include < iostram .h >
static int maxKey =1000;
typedef int Key ;
class Item {
private :
Key keyval ;
float info ;
public :
Item ()
{ keyval = maxKey ; }
Key key ()
{ return keyval ; }
int null ()
{ return keyval == maxKey ; }
void rand ()
{ keyval = 1000*:: rand ()/ RAND_MAX ;
info = 1.0*:: rand ()/ RAND_MAX ; }
int scan ( istream & is = cin )
{ return ( is >> keyval >> info ) != 0; }
void show ( ostream & os = cout )
{ os << keyval << " " << info << endl ; }
};
ostream & operator < <( ostream & os , Item & x )
{ x . show ( os ); return os ; }
Die Operationen, die uns interessieren sind folgende:
Einfügen (insert) eines neuen Elements
Suchen (search) nach einem Element (oder nach Elementen) mit
einem gegebenen Schlüssel
Entfernen (remove) eines gegebenen Elements
Auswählen (select) des k-größten Elements in einer Symboltabelle
Sortieren (sort) der Symboltabelle (alle Elemente in der Reihenfolge ihrer Schlüssel anzeigen)
Verknüpfen (join) zweier Symboltabellen
Häufig wird noch „Konstruieren“, „Testen ob leer“, „Zerstören“,
„Kopieren“ und „Suchen und Einfügen (in einem)“ implementiert.
Suchen und Einfügen ist deshalb sinnvoll, da man oft erst das einzufügende Element suchen will und nur bei Nichtvorhandensein des
gesuchten Elements das Neue einfügen will.
2.1
Beispielimplementierung eines abstrakten Datentyps für Elemente
Zunächst wird vereinbart, dass die Schlüssel (keyval) der Symboltabelle ganzzahlig sind und die zu den Elementen gehörige Information (info) aus Gleitkommazahlen bestehen. Item() ist der
Konstruktor, key() gibt den Key zurück, null() prüft, ob es sich
um ein nullItem handelt (der boolesche Wert keyval == maxKey
wird zu einem int gecastet), rand() erzeugt ein zufälliges Item,
2.2
Beispielimplementierung eines abstrakten Datentyps für Symboltabellen
template < class Item , class Key >
class ST
{
private :
// I m p l e m e n t i e r u n g s a b h a e n g i g e r Code
public :
ST ( int ); // Konstruktor
int count (); // Zaehlt die Items in ST
Item search ( Key );
void insert ( Item );
void remove ( Item );
Item select ( int );
// k - kleinstes Element auswaehlen
void show ( ostream &);
// Items nach Key geordnet ausgeben
};
Hierfür ist keine explizite Implementierung angegeben, da die Methoden davon abhängen, welche Art von Symboltabelle vorliegt.
Z.B. kommt es beim Suchen darauf an, ob die Symboltabelle geordnet ist, oder nicht (kann man < und > benutzen, oder geht nur =?).
So auch beim Einfügen. Beim Entfernen kommt es darauf an, inwiefern direkt auf Items zugegriffen werden kann (Muss das Item
erst gesucht werden?), usw.
Bei den bisherigen Beispielen ging es nur um Symboltabellen mit
eindeutigen Schlüsseln. Es kann jedoch auch vorkommen, dass
Schlüssel mehrfach vorkommen. Um dies in der Implementierung
des abstrakten Datentyps zu berücksichtigen, gibt es drei Möglichkeiten:
Mit zwei Datenstrukturen:
Eine primäre Datenstruktur, die nur einfach vorkommende Schlüssel speichert und deren Elemente auf eine sekundäre Datenstruktur verweisen, die nur Elemente mit gleichen Schlüsseln verwalten.
Der Vorteil dieser Strategie ist, dass man bei einem Suchaufruf alle Elemente mit dem selben Schlüssel auf einmal bekommen kann,
eventuell noch sortiert (lässt sich leicht implementieren).
Mit nur einer Datenstruktur:
Mehrfach vorkommende Schlüssel sind erlaubt und beim Suchen
werden alle Elemente mit gleichem Schlüssel zurückgegeben. Dabei muss leider bei unsortierten Symboltabellen immer die ganze Symboltabelle durchlaufen werden (⇒ nicht effizient für große
Symboltabellen) und die Reihenfolge der gesuchten Elemente muss
egal sein, da selbst wenn man die Symboltabelle nach Schlüsseln
sortiert hält, die Elemente mit gleichen Schlüsseln zwar alle nacheinander in der Symboltabelle stehen, jedoch untereinander nicht
sortiert werden können.
Noch eine Möglichkeit mit nur einer Datenstruktur mit mehrfach
vorkommenden Schlüsseln auszukommen ist, einen Zweitschlüssel
zu bestimmen, mit deren Hilfe die Suche (bzw. falls erwünscht die
Sortierung) eindeutig wird.
2.3
Beispiel eines Clientprogramms für Symboltabellen
# include < iostream .h >
# include < stdlib .h >
# include " Item . cxx " // Beispiel siehe oben
# include " ST . cxx "
// Beispiel siehe oben
int main ( int argc , char * argv []) {
int N , maxN = atoi ( argv [1]) , sw = atoi ( argv [2]);
ST < Item , Key > st ( maxN ); // K ons tr uk tor au fr u f
for ( N =0; N < maxN ; N ++) {
Item v ;
if ( sw ) v . rand ();
else if (! v . scan ()) break ;
if (!( st . search ( v . key ())). null ()) continue ;
st . insert ( v );
}
st . show ( cout );
cout << endl ;
cout << N << " Schluessel " << endl ;
cout << st . count () << " eindeutige Schluessel "
<< endl ;
}
Der Befehl atoi() bedeutet Array to Integer. Es wird also beim
Aufruf des Programms als Parameter übergeben, wie groß maxN
sein soll und ob die Items zufällig generiert, oder vom Benutzer abgefragt werden sollen. Wenn sie vom Benutzer abgefragt werden
und er macht sinnlose Eingaben wird das Programm abgebrochen
(wegen „if (!v.scan()) break;“). Wenn mit rand() Items,
deren Key schon in der Symboltabelle vorkommen, erzeugt werden, wird dieses Item nicht eingefügt (N++ aus der for-Schleife
wird trotzdem ausgeführt).
Bei der genauen Implementierung kommt es darauf an, wofür die
Symboltabelle verwendet wird, ob Schlüssel mehrfach vorkommen
und welche Methoden häufig und welche weniger Häufig verwendet werden. Wenn man z.B. wenig einfügt und viel sucht, dann hält
man die Symboltabelle möglichst sortiert. Dadurch kann man Suchen sehr effizient implementieren, jedoch dauert Einfügen dann
länger, da man erst die richtige Stelle für das einzufügende Element suchen muss.
Wird Einfügen häufiger benutzt als Suchen, kann man auf die Sortierung in der Symboltabelle verzichten und somit schnell (irgendwo) einfügen, dafür dauert Suchen umso länger.
3
Schlüsselindizierte Suche
Im Folgenden Teil wird angenommen, dass die Schlüsselwerte eindeutige, kleine, ganze, und positive Zahlen sind und dass die Größe
der Symboltabelle schon bekannt ist. Als Symboltabelle wird ein
schlüsselindiziertes Array benutzt.
template < class Item , class Key >
class ST {
private :
Item nullItem , * st ;
int M ; // M > key fuer alle keys
public :
ST ( int maxN ) { // Konstruktor
M = maxN ;
st = new Item [ maxN ];
} // mit " new " werden alle Items
// mit nullItem initialisiert
int count () { // Zaehlt die Elemente
int N = 0;
for ( int i =0; i < M ; i ++) {
if (! st [ i ]. null ()) N ++; return N ;
}
}
void insert ( Item x ) { st [ x . key ()] = x ; }
Item search ( Key v ) { return st [ v ]; }
void remove ( Item x ) { st [ x . key ()]= nullItem ;}
Item select ( int k ) { // k ’ tes Element waehlen
for ( int i =0; i < M ; i ++) {
if (! st [ i ]. null ()) {
if (k - - == 0) { return st [ i ]; }
}
} return nullItem ;
}
void show ( ostream & os ) {
for ( int i =0; i < M ; i ++) {
if (! st [ i ]. null ()) st [ i ]. show ( os );
}
}
};
Auffallend ist hier vor allem, dass Suchen und Einfügen konstante
Laufzeit haben, was sonst äußerst wenige Datenstrukturen ermöglichen. Deshalb ist die schlüsselindizierte Adressierung zu bevorzugen, fall sie anwendbar ist. Leider wächst die Laufzeit von Initialisieren, Auswählen und Sortieren proportional zur Oberschranke M
des Schlüsselwertebereichs.
Für die Operation count() gibt es noch einen anderen Ansatz, den
man den „fleißigen Ansatz“ nennt, wobei der in der obigen Implementierung der „faule Ansatz“ genannt wird. Der fleißige Ansatz
aktualisiert eine Variable bei jedem Einfügen und Löschen eines
Elements, die dann bei der count()-Operation nur noch ausgelesen werden muss. Der fleißige Ansatz ist dem faulen offensichtlicherweise vorzuziehen, wenn man count() häufig benutzt und
der faule Ansatz ist dem fleißigen vorzuziehen, wenn viel eingefügt und gelöscht wird und die count()-Operation selten benutzt
wird. Wenn man nicht sicher ist, was häufig und was selten benutzt
wird, sollte man sich für den fleißigen Ansatz entscheiden, da er
die Laufzeit von count() konstant macht und sich die Laufzeit
von Einfügen und Löschen nur um eine (relativ) kleine Konstante
verlängert.
4
4.1
Sequentielle Suche
int N ; // # Elemente
link head ; // Kopfelement
Item searchR ( link t , Key v ) { // t = listenkopf
if ( t ==0) return nullItem ;
if (t - > item . key ()== v ) return t - > item ;
return searchR (t - > next , v );
}
public :
ST ( int maxN ) {
head =0; N =0;
}
int count () {
return N ;
}
Item search ( Key v ) {
return searchR ( head , v );
}
void insert ( Item x ) {
head = new node (x , head ); N ++;
}
Arraybasierte Symboltabellen
Nun beschäftigen wir uns mit dem Fall, dass der Wertebereich der
Schlüssel zu groß wird und wir aus Platzgründen kein schlüsselindiziertes Array verwenden können. Wir verwenden zwar noch ein
Array, aber ordnen (in folgendem Beispiel) die Einträge nach ihren
Schlüsselwerten und speichern sie in aufeinanderfolgender Reihenfolge in dem Array. Hier müssen die Schlüssel auch nicht mehr
ganzzahlig, klein oder positiv sein. So dauert Einfügen zwar lang,
aber Suchen uns Selektierten geht schnell.
Eine zweite Möglichkeit wäre, das Array unsortiert zu lassen und
immer nur am Ende Elemente einzufügen, was jedoch auf Kosten
von Suchen und Selektieren geht. Hier ein Beispielprogramm für
die erste Möglichkeit:
template < class Item , class Key >
class ST {
private :
Item nullItem , * st ;
int N ; // groesster Index
public :
ST ( int maxN ) { st = new Item [ maxN +1]; N =0; }
int count () { return N ; } // fleissiger Ansatz
void insert ( Item x ) {
int i = N ++; Key v = x . key ();
while (i >0 && v < st [i -1]. key ()) {
st [ i ]= st [i -1] , i - -;
} st [ i ]= x ;
}
Item search ( Key v ) {
for ( int i =0; i < N ; i ++) {
if (!( st [ i ]. key () < v )) break ;
} if ( v == st [ i ]. key ()) return st [ i ];
return nullItem ;
}
Item select ( int k ) { return st [ k ]; }
void show ( ostream & os ) {
int i =0;
while (i < N ) st [ i ++]. show ( os );
}
};
Die Implementierung von search() kann man noch verbessern,
indem man vor der Suche das gesuchte Element an die letzte (leere)
stelle des Arrays schreibt und die Abfrage i<N weglässt, da dann
die Suche immer terminiert. Bei Suchfehlern landet man bei dem
letzten Element und weiß somit, dass das Element eigentlich nicht
in der Symboltabelle war. Somit entfällt eine if-Abfrage, was sich
besonders bei großen Symboltabellen lohnt.
4.2
Auf verketteten Listen basierende Symboltabellen
Hier eine Beispielimplementierung für eine ungeordnete Symboltabelle mit einer verketteten Liste:
class ST {
private :
Item nullItem ;
struct node {
Item item ; node * next ;
node ( Item x , node * t ) { item = x ; next = t ; }
};
typedef node * link ;
// mit link ist also immer Referenz von node
// gemeint ( wie & node )
};
Ein Vorteil der verketteten Liste ist, dass die Größe der Liste und
die Anzahl der Elemente keine Rolle mehr spielt, jedoch wird für
die Pointer extra Speicher benötigt. Auch bei den verketteten Listen kann man wieder zwischen geordneten und ungeordneten Listen
unterscheiden. In der geordneten Liste kann man schnell suchen,
dafür dauert Einfügen lang, da man erst suchen muss, wo das einzufügende Element seinen Platz bekommen muss, damit die Liste
geordnet bleibt. Bei der ungeordneten Version lässt sich Einfügen
einfach implementieren, dafür dauert die Suche länger, da deren
Laufzeit von der Anzahl der Elemente in der Liste abhängt. Auswählen lässt sich in der ungeordneten Liste überhaupt nicht effizient implementieren. Generell ist die join()-Operation sehr einfach
implementierbar, was jedoch nur wichtig ist, wenn man diese überhaupt benötigt, heißt wenn man mehrere Symboltabellen verwalten
muss.
4.3
Eigenschaften der sequentiellen Suche
Im Durchschnittsfall benötigt man für Suchtreffer N/2 Schritte,
Einfügen kostet bei geordneten Listen bzw. Array N/2 Vergleiche
und bei ungeordneten konstante Zeit und für Suchfehler benötigt
man bei geordneten Symboltabellen N/2 und bei ungeordneten N
Vergleiche.
5
Binäre Suche
Die binäre Suche ist eines der schnellsten bekannten Suchverfahren. Sie benötigt zum Auswählen eines Elements (kkleinstes/größtes) nur konstante Zeit und zum Suchen nur ld N
Schritte. Die Voraussetzung, um die binäre Suche verwenden zu
können, ist jedoch, die Symboltabelle sortiert zu halten.
private :
Item searchR ( int l , int r , Key v ) {
if (l > r ) { return nullItem ; }
int m =( l + r )/2;
if ( v == st [ m ]. key ()) { return st [ m ]; }
if ( l == r ) { return nullItem ; }
if (v < st [ m ]. key )) { return searchR (l ,m -1 , v ); }
else { return searchR ( m +1 ,r , v ); }
public :
Item search ( Key v ) { return searchR (0 ,N -1 , v ); }
Die binäre Suche ist ein typisches Beispiel für die „Divide and
Conquer“-Strategie. Das Array wird zuerst ind zwei Teilarrays aufgespalten und dann wird in dem Teilarray, in dem sich das gesuchte Item befindet rekursiv die binäre Suche nochmal aufgerufen. Die binäre Suche benötigt höchstens bldcN + 1 Vergleiche, was
der Länge Binärdarstellung von N (=
b Anzahl der Elemente) entspricht. Ein Nachteil der binären Suche ist, dass das Array sortiert
bleiben muss, woraus folgt, dass die Einfüge-Operation recht teuer
wird, da erst der Platz für das neue Element gesucht werden muss
und dann alle größeren Elemente um einen Platz weiter geschoben
werden müssen. Einfügen würde effizienter gehen, wenn man eine
verkettete Liste benutzen würde, jedoch funktioniert dann die binäre Suche nicht mehr gut, da man ja immer in die Mitte der Liste
springen muss, was bei der verketteten Liste bedeutet, die ersten
N/2 Elemente zu besuchen. Die binäre Suche ist übrigens bei der
C-Standardbibliothek als bsearch enthalten. Die binäre Suche lässt
sich noch verbessern, indem man nicht immer stur die Mitte des
Arrays als Trennpunkt nimmt, sondern ein neues Verfahren, das Interpolationssuche genannt wird. Statt m=(l+r)/2; wird jetzt
m = l + (v − a[l].key()) ∗ (r − l)/(a[r].key() − a[l].key());
|
{z
} |
{z
}
=Abstand von v und dem key von l
= AbstandAbstand(l,r)
der keys von
r,l
benutzt, wobei v der gesuchte Key ist. Diese Methode setzt voraus,
dass die Schlüssel numerisch und gleichverteilt sind. Sollten sie
nicht gleichverteilt sein, kann dieses Verfahren sogar länger dauern, als das herkömmliche. Wenn sie wirklich gleichverteilt sind,
benötigt die Interpolationssuche weniger als ld ld N + 1 Vergleiche
(ld ld(1.000.000.000) < 5) also beinahe konstant viele. Hier noch
eine Zusammenfassung der Laufzeiten der hier genannten Verfahren:
Ungünstigster Fall
Suchen
Auswählen
Einfügen
Suchtreffer
Suchfehler
1
1
M
1
1
1
N
N
1
N/2
N/2
N/2
N
N
N
N/2
N/2
N/2
1
N
N lg N
1
N/2
N
N
lg N
1
N/2
lg N
lg N
N
N
N
lg N
lg N
lg N
lg N
lg N
lg N
lg N
lg N
lg N
N
N
N
lg N
lg N
lg N
1
N
N lg N
1
1
1
Schlüsselindiziertes
Array
Geordnetes
Array
Geordnete
verkettete
Liste
Ungeordnetes
Array
Ungeordnete
verkettete
Liste
Binäre Suche
Binärer Suchbaum
Rot-SchwarzBaum
Durchschnittlicher Fall
Einfügen
Zufallsbasierter
Baum
Hashing
Literatur
Sedgewick Algorithmen und Datenstrukturen in C++ (1994)