Symboltabellen und binäre Suche
Transcription
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)