This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
::otype()); ... ::isPtr; foo(i2t()); foo(i2t::isPtr>());
Wird die Anzahl der zu unterscheidenden Fälle größer, so kann die Deklaration eines speziellen Typs für jeden Anwendungsfall durch eine Abbildung von Konstanten auf einen Typ vermieden werden: template
Komplexere Implementationen dieses Typs sind const bool b=ObjectType
Durch diese Eigenschaft des C++–Compilers, während der Übersetzung eines Kodes Typen in Konstante und Konstante in Typen zu überführen und gleichzeitig komplexe Rechnungen durchzuführen (selbst Iterationen oder Rekursionen sind kein Problem), eröffnen sich sehr breite Möglichkeiten des Designs von Anwendungen, bei denen korrekte Typ- und Musterverwendung weitgehend durch den Compiler sichergestellt werden. Da an keiner Stelle Instanzen gebildet werden beziehungsweise formale Instanzen wie in den Funktionsaufrufen ungenutzt bleiben und vom Optimierer entfernt werden, erfordert die gesamte Unterstützung des Compilers keinerlei zusätzlichen Aufwand zur Laufzeit. Allerdings erfordern die Konstruktionen ein hohes Maß an Abstraktionsfähigkeit beim Entwickler und werden auch nicht von allen Compilern unterstützt. Wenn Sie solche Techniken einsetzen wollen, führen sie deshalb zunächst einmal einige Tests mit dem von Ihnen verwendeten System durch. Das gleiche gilt für die Verwendung der Containerklassen der STL. Auch hier sollten Sie prüfen, auf welche Datentypen sich die Klassen automatisch einstellen können. Allokator–Klassen Die unterschiedlichen Variablen- und Containermodelle setzen auch unterschiedliche Speichermodelle voraus, die dem Container in Form eines zweiten template–Parameters mitgeteilt werden. Durch die Containerdefinition
4.4 Verwaltung des Objektspeichers
155
template
wird eine STL–Standardstrategie als default–Strategie vordefiniert. Dabei handelt es sich natürlich nicht bei jedem Containertyp um die gleiche Strategie, wie Sie sich anhand der Diskussion der verschiedenen Typen wohl vorstellen können. Wir werden uns hier in der Diskussion auch zunächst auf den Fall der linearen Felder beschränken. Die STL–Entwickler haben jedoch einige Mühe in die Standardstrategien gesteckt, und man ist in der Regel gut beraten, sich daran zu halten. Wie kann nun die Speicherstrategie für eine große Menge an C++–Objekten optimiert werden? Die Basisüberlegung besteht darin, zunächst einmal die C–und die C++–Anteile an der Speicherung eines Objektes zu trennen. Im Klartext: Die Bereitstellung des Speicherplatzes eines Objektes wird von der Initialisierung des Speicherplatzes entkoppelt. Die Klasse allocator stellt daher zwei Funktionssätze für die Datenverwaltung im Container zur Verfügung, denen zwei Funktionen des Containers entsprechen: a) Kapazität eines Containers. Die erste Funktionengruppe umfasst die Methoden reserve(..) allocate(..) deallocate()
// Container-Methode // Allocator-Methode // Allocator-Methode
Die Methode allocate fordert einen zusammenhängenden Speicherplatz vom Betriebssystem für die Aufnahme einer bestimmten Anzahl von Objekten vom Betriebssystem an. Der Speicherplatz wird nicht initialisiert. T* _buf = (T*) malloc(n*sizeof(T)); deallocate gibt den Speicherplatz wieder frei. free(_buf);
Bei Aufruf der Methode reserve(..) können beide Methoden zusammen mit Kopierfunktionen genutzt werden: h=allocate(n_neu); memcpy(h,_buf,n*sizeof(T)); deallocate(_buf); _buf=h; n=n_neu;
69
69 Da man nicht so genau weiß, was in den Objekten passiert, kann die Methode memcpy(..) im allgemeinen Fall eingesetzt werden. Aufgabe 4.4-1: Was ist im allgemeinen Fall zu tun und warum?
156
4 Die Standard-Template-Library (STL)
Der so bereit gestellte Speicher kann nun eine bestimmte Anzahl von Objekten aufnehmen, ohne dass neuer Speicherplatz vom Betriebssystem angefordert werden muss. Für die Aufnahme eines Objektes muss nur ein Stück des Speichers initialisiert werden. Der Speicher definiert damit tatsächlich die Aufnahmekapazität eines Containers. Eine Änderung der Kapazität ist mit größerem Aufwand verbunden. So weit möglich, sollte eine entsprechende Planung stattfinden, um weder Speicherplatz zu verschwenden noch großen Aufwand durch ständige Kapazitätsausweitung zu verursachen. b) Größe eines Containers. Die zweite Funktionengruppe umfasst die Methoden resize(..) construct(..) deconstruct()
// Container-Methode // Allocator-Methode // Allocator-Methode
Die Methode construct(..) initialisiert Teile des durch allocate (..) bereitgestellten Speicherplatzes. Das geschieht erst dann, wenn neue Variable aus der Reserve zum Beispiel durch Aufruf der Methode resize(..) in den genutzten Bereich gezogen werden for(i=k;i
struktor auf, entfernt aber nicht den Speicherplatz. T* _tp; for(_tp=&_buf[k];_tp!=&_buf[l];++_tp) _tp->~T();
Beachten Sie die speziellen Aufrufformen! Beim new–Operator bietet C++ die Möglichkeit, die Adresse anzugeben, an der ein Objekt erzeugt wird. Der Konstruktor wird dann an die angegebene Adresse umgeleitet, ohne dass Speicherplatz reserviert wird. Der Destruktor, der ohnehin wesentlich mehr mit normalen Methoden zu tun hat als die Konstruktoren, wird wie eine Methode direkt aufgerufen. Beides dürfte in „normalen“ Anwendungen recht selten auftreten. Der Sinn der Trennung von Speicherplatzanforderung und Speicherinitialisierung erschließt sich bei der Betrachtung komplexerer Klassen. Deren Objekte haben oft ein recht komplexes Innenleben, das beispielsweise zusätzlichen Speicherplatz in Form von Zeigerattributen und/oder langwie-
4.4 Verwaltung des Objektspeichers
157
rige Initialisierungsvorgänge erfordert. Die Beschränkung auf die benötigte Objektanzahl schont so Ressourcen und Rechenzeit. Die Methoden allocate/construct sowie deconstruct/deallocate werden vom Container immer im oben beschriebenen Sinn ausgeführt. Wenn Sie schon auf den Gedanken kommen sollten, eigene Allokatoren zu implementieren, Sie aber nicht auch noch eine neue Containerimplementation verfassen möchten, müssen sich die eigenen Allokatoren an diese Philosophie halten. Zum Funktionieren der Container allgemeine beziehungsweise des Standard–Allokators ist die Implementation und freie Zugänglichkeit von Standard- und Kopierkonstruktor sowie Zuweisungsoperator notwendig (siehe Kapitel 5.4.2). Aufgabe 4.4-2. Die Diskussion hat sich auf Containertypen mit linearen Feldern von Objekten beschränkt. Wie sind beispielsweise Listen oder Bäume zu implementieren? Entwerfen Sie Modelle.
4.5 Die Containerklassen der Standardbibliothek Wir werden nun die Container–Klassen der STL jeweils mit einigen charakteristischen Eigenschaften diskutieren. Das soll nicht erschöpfend sein (es gibt dicke Bücher darüber, siehe Literaturverzeichnis), Ihnen aber zusammen mit den Speicherstrategien ermöglichen, den für Ihre Anwendung passenden Typ ausfindig zu machen und nach einigen Versuchen zu beherrschen. 4.5.1 Speichertyp A): valarray Ob dieser Containertyp zum eigentlichen Kern der STL gehört, darf bezweifelt werden, da er eigentlich nichts von den diskutierten Speicherund Iteratormechanismen zur Verfügung stellt und die implementierten Methoden in allgemeinerer Form auch für die weiter unten diskutierten Container zur Verfügung stehen. Er wird aber nun mal von der C++–Bibliothek zur Verfügung gestellt und entspricht dem ersten diskutierten Feldtyp, so dass wir ihn hier nicht auslassen wollen. Ein valarray stellt ein lineares C–konformes Feld von Variablen des angegebenen Typs zur Verfügung, symbolisch ulong size; T * t = new T[size];
158
4 Die Standard-Template-Library (STL)
und ist vorzugsweise für mathematische Anwendungen vorgesehen 70, in denen Felder konstanter Größe von Objekten benötigt werden, auf die schnell über Indizes zugegriffen wird. Spezielle Iteratoren sind nicht implementiert, da C–konforme Zeiger in das Feld genügen. Objekte der Klasse werden normalerweise in der benötigten Größe initialisiert, wobei die Feldinhalte auf Null oder auf einen vorgegebenen Wert gesetzt werden template
Zur Übernahme von Daten aus anderen Objekten oder allgemeinen Feldern sind Kopierkonstruktoren implementiert. Die vier unteren Konstruktoren in der folgenden Liste sind für Objekte vorgesehen, die Teilfelder eines vorhandenen valarray übernehmen. Die Indizierung der Teilfelder diskutieren wir weiter unten valarray(const valarray(const valarray(const valarray(const valarray(const valarray(const
T& val, size_t n)); T *p, size_t n); slice_array
Rechenoperationen mit Arrays Während die Kopierkonstruktoren exakte Kopien anfertigen, werden bei Zuweisungsoperatoren nur so viele Daten übernommen, wie das vorhandene Feld aufnehmen kann, allerdings auch nicht weniger. Die Feldgrößen in solchen Anweisungen müssen deshalb kompatibel sein, um Datenunfug zu vermeiden valarray
4.5 Die Containerklassen der Standardbibliothek
159
void fill(const T& val); void free();
Die Methode fill() und free() stellen Spezialisierungen des Zuweisungsoperators für Konstante dar. Arithmetische und logische Operatoren verknüpfen ebenfalls die korrespondierenden Elemente von Feldern miteinander (die Feldgrößen müssen wieder kompatibel sein).71 valarray
Die unären Operatoren und die arithmetischen Operatoren ohne Zuweisung erzeugen für das Ergebnis ein neues Feld, das Original bleibt erhalten. Die Größe eines Feldes kann mittels der Methoden size_t size() const; void resize(size_t n, const T& c = T());
kontrolliert und verändert werden, wobei bei einer Größenänderung grundsätzlich ein neues Feld erzeugt und der Inhalt kopiert wird. Die Kapazitätsfunktion reserve(..) ist nicht vorhanden, da keine Kapazitätsreserven gebildet werden. Obwohl möglich, gehören Größenänderungen somit nicht gerade zu den Stärken der Klasse. Der Zugriff auf die Dateneinträge erfolgt mit dem Indexoperator: T& operator[](size_t n) const;
Der Indexoperator weist noch weitere Funktionen auf, die bei den Unterfeldern diskutiert werden. Die Methoden valarray
verschieben die Felder eines valarray, wobei die erste Methode die jeweils letzten Elemente verliert und an der anderen Seite mit Nullelementen auffüllt, die zweite Methode die Einträge zyklisch verschiebt. Das Ergebnis wird auf einem neuen Feld abgespeichert. Das gleiche gilt für die Methode 71 Das wirkt eher von der Syntaxseite systematisch durchgezogen als wirklich im Hinblick auf echte Anwendungen konstruiert. In einigen Algorithmen der Bildverarbeitung oder bei der Prozessdatenverarbeitung können die Operatoren eingesetzt werden, allerdings stellt sich dann die Frage, ob solche speziellen Anwendungen die Aufnahme der Klasse in eine Universalbibliothek rechtfertigt.
160
4 Die Standard-Template-Library (STL) valarray
die es erlaubt, eine frei definierte Methode auf jedes Element des Feldes anzuwenden. Der Feldwert wird der Methode als Parameter übergeben, das Ergebnis wird als neuer Wert abgespeichert. Die Methoden T sum() const; T max() const; T min() const;
berechnen die Summe über alle Elemente beziehungsweise ermitteln das kleinste und das größte Element. Wie bereits eingangs beschrieben, sind spezielle Iteratoren auf einem valarray nicht definiert, können aber leicht über Zeiger realisiert und in Algorithmen eingesetzt werden: valarray<double> a; double *it_start, *it_end; it_start=&a[0]; it_end=&a[a.size()];
Überstrukturen und Unterfelder Die bisher beschriebenen Methoden operieren immer auf dem gesamten Feld. In vielen Algorithmen sind jedoch nicht alle Elemente miteinander zu verrechnen. Typische Beispiele: Bei der Matrixrechnung in der linearen Algebra sind selektiv Zeilen oder Spalten einer Matrix in einer Rechnung einzusetzen. In der Bildverarbeitung kann ein Bild ebenfalls als Matrix betrachtet werden. In vielen Filtern sind quadratische kleine Matrizen der Größe 3 3, 5 5 usw. über einen bestimmen Bildpunkt zu legen. Für die Auswahl bestimmter Elemente aus dem Feld stellt valarray deshalb mehrere Mechanismen durch spezialisierte Versionen des Indexoperators operator[](..) zur Verfügung. Das Ergebnis ist ein spezielles Feld, das eine Referenz auf das Original enthält: slice_array
4.5 Die Containerklassen der Standardbibliothek
161
Werden diese speziellen Felder in Rechenoperationen eingesetzt, so ändern sich die indizierten Elemente des Quellfeldes.72 Zusammen mit den Konstruktoren oder Zuweisungsoperatoren können Kopien aber auch Kopien der gewünschten Elemente auf einem neuen valarray erzeugt werden.73 valarray
Sehen wir uns die Arten der Indizierung nun genauer an. Eine Indizierung besteht jeweils aus zwei Teilen: a) einem Indexträger, der in diskreter Form oder in Form einer Formel die Indizes verwaltet, und b) einem Feldträger, der den Indexträger und eine Referenz auf das valarray verwaltet und Methoden für den Zugriff auf die indizierten Elemente bereitstellt. Es stehen die folgenden Indizierungsarten zur Verfügung: a) Direkte Angabe. Durch einen zweiten valarray wird die explizite Reihenfolge angegeben
72 Hier sind sich die Bibliotheken verschiedener Entwicklungssystems alles andere als einig. Einige erlauben das direkte Rechnen mit den Unterfeldern und Zuweisungen auf die Felder, andere erfordern das Erstellen einer Kopie auf einem neuen valarray, bevor bestimmte Operationen funktionieren. Die Beispiele funktionieren daher nicht auf allen Systemen. Als Konsequenz sollten wir die Klasse daher vorzugsweise als Lehrstück betrachten, wie man eine Aufgabe angehen kann, und bei Anwendungen mit der Verwendung eher vorsichtig sein. 73 Der Stream cout ist in dieser Form nicht bedienbar. Der Kode soll die Ausgabe nur andeuten.
162
4 Die Standard-Template-Library (STL) valarray
Der spezialisierte Indexoperator erzeugt zunächst einen Feldträger des Typs indirect_array mit der Struktur template
Eine Variante dieses Typs ist die Indizierung durch valarray
Ein Objekt von slice enthält den Startindex, die Anzahl der Elemente und den Abstand zwischen den Elementen im valarray. Die Klasse slice_array erbt von slice und enthält wieder zusätzlich eine Referenz auf den valarray. Ein Elementzugriff erfolgt durch
4.5 Die Containerklassen der Standardbibliothek
163
(*orig)[i*stride()+start()]
Die Spielwiese dieser Indizierungsform ist eigentlich die Rechnung mit Matrizen, aber gerade dort sind die Einsätze recht holprig, wie wir noch sehen werden. Die Entwickler scheinen da doch andere Anwendungen im Auge gehabt zu haben. c) Erweiterte Indexarithmetik. Die einfache Indexarithmetik erlaubt nicht die Auswahl einer Untermatrix, wie sie in der Bildverarbeitung benötigt wird, da hierzu Gruppen eng benachbarter Felder gebildet werden müssen, die untereinander größere Abstände aufweisen. Durch Erweiterung des Längen- und Abstandsbegriffs lässt sich aber auch das bewerkstelligen. Dazu werden mehrere slices miteinander kombiniert: class gslice { public: gslice(); gslice(size_t st, const valarray<size_t> len, const valarray<size_t> str); size_t start() const; const valarray<size_t> size() const; const valarray<size_t> stride() const; };
Der erste Parameter ist wieder der Startindex im Feld, die Wertepaare korrespondierender Einträge in den beiden folgenden Feldern sind mit den anderen Attributen eines slice identisch. Beispielsweise indiziert die folgende Parametrierung des ersten Paares die ersten vier Elemente der ersten Zeile einer 10*10–Matrix: valarray
Das nächste Wertepaar indiziert wiederum eine Anzahl und einen Abstand, der auf alle zuvor indizierten Elemente angewandt wird: valarray
Das Gesamtbeispiel indiziert somit eine Untermatrix mit vier Spalten und drei Zeilen. Es können beliebige weitere Paare assoziiert werden,
164
4 Die Standard-Template-Library (STL)
so dass auch Auswahlen in mehrstufigen Tensoren usw. möglich sind. Der vom Indexoperator erzeugte Datentyp gslice_array unterliegt den gleichen Nutzungsbeschränkungen wie die bereits beschriebenen Typen. Damit sind nun alle wesentlichen Eigenschaften der Klasse valarray vorgestellt. Obwohl der erste Eindruck nun vermutlich recht positiv ist, trübt sich das Bild doch beim weiteren Studium der Klasse. Soll beispielsweise ein Skalarprodukt zweier Vektoren berechnet werden, ohne selbst eine Schleife zu programmieren, so bieten sich folgende Lösungen an s = (v1*=v2).sum(); .. s = (v1*v2).sum(); Variable
// v1 wird verändert // v1 konstant, temporäre
Aufgabe 4.5-2. Vergleichen Sie diese Lösungen mit einer optimalen (die Sie natürlich jetzt erst konstruieren müssen). Auch bei den Indizierungen von Unterfeldern wird dem Betrachter nicht so ganz warm ums Herz. Für die Feldträgerklassen sind nämlich nicht alle Operatoren definiert, insbesondere ist operator[](..) nicht definiert. Gerade das ist aber peinlich, weil dies dem Anwendungsentwickler die Arbeit der Indexarithmetik abnehmen würde. Versucht man nun, eine Matrixmuliplikation der Form A B C durch die vorhandenen Hilfsmittel zu realisieren, so gelangt man etwa zu folgender Lösung: valarray
Wenn Sie eine solche Aufgabe einmal auf herkömmliche Weise umsetzen (Sie sollten das ruhig einmal machen, ohne dass ich das hier als Aufgabe formuliere), werden Sie sehen, dass der Algorithmus mit drei Schleifen nicht nur effizienter, sondern auch verständlicher ist. Auch bei anderen Anwendungen auf der Bildverarbeitung bin ich mit den Werkzeugen nicht so ganz glücklich gewesen, so das ich insgesamt den Eindruck habe, die Klasse valarray ist ein wenig nach dem Motto „und was brauche ich jetzt?“ gestrickt worden, ohne sich ausreichend in die theoretischen Hintergründe einzuarbeiten. Wie eingangs bemerkt, gehört
4.5 Die Containerklassen der Standardbibliothek
165
valarray nicht zum Kern der STL und wird wohl auch nicht weiterentwickelt. Allerdings nimmt auch die STL das slice–Konzept nicht
wieder auf, so dass wir dem Thema „mehrdimensionale Felder“ ein eigenes Kapitel (6) widmen werden. 4.5.2 Speichertyp B): vector vector ist die erste eigentliche Kernklasse der STL und basiert wie valarray auf dem Speichertyp eines linearen Feldes, allerdings nun mit einer Reserve am Ende des Feldes. vector verwendet die beschriebene allocator–Klasse zur Steuerung der Speicherverwaltung und definiert Iteratoren zur Bewegung auf den Elementen. Subfelder wie auf valarray
werden (leider) nicht definiert. Den im Rahmen der Verallgemeinerungen notwendigen Datentypen werden in der Klassendefinition einheitliche Namen zugewiesen, die auch in den anderen Containern verwendet werden (Transparenzprinzip: Der Anwender verwendet das Containerkonzept, muss sich aber nicht um die speziellen Details eines bestimmten Containers kümmern). Aus den Vorbemerkungen in den Kapiteln 4.1 bis 4.4 sollte klar sein, was hinter folgenden Vereinbarungen steckt: template
Die Unterschiede in der Speichernutzung zwischen valarray und vector werden bei Untersuchung der Standard–Allokator–Klasse für diesen Containertyp sichtbar (siehe auch Kapitel 4.4, die Typen size_t und ptrdiff_t sind ganzzahlig):
166
4 Die Standard-Template-Library (STL) template
Während bei valarray sofort ein zusammenhängendes Feld von vollständig initialisierten Variablen erzeugt wird, wird in dem zweistufigen Prozess beim vector zunächst mit allocate(..) nur ein gleich großer leerer Speicherplatz erstellt, der die angegebene Anzahl der Variablen aufnehmen kann. Erst im zweiten Schritt wird in construct(..) der Platz mit sinnvollen Werten initialisiert. Mit den Methoden void reserve(size_type n); void resize(size_type n, T x = T()); void clear(); // Abfragemethoden: size_type capacity() const; size_type size() const; size_type max_size() const; bool empty() const; // Allokator-Ermittlung A get_allocator() const;
werden somit systemabhängig recht unterschiedliche Aktionen ausgelöst. Auch die Iteratoren sind nun eigenständige Typen, da sie einerseits einer Klassifizierung unterliegen (siehe Kapitel 4.2), andererseits vom Allokator das Speichermodell in Erfahrung bringen müssen, um korrekt auf die Daten weisen und auf ein anderes Objekt vorrücken zu können.
4.5 Die Containerklassen der Standardbibliothek
167
Die Konstruktorenliste enthält Erzeugungs- und Kopierkonstruktoren, wobei unter Verwendung von Iteratoren Vektorobjekte aus beliebigen anderen Containern erzeugt werden können. explicit vector(const A& al = A()); explicit vector(size_type n, const T& v = T(), const A& al = A()); vector(const vector& x); vector(const_iterator first, const_iterator last, const A& al = A());
Aufgabe 4.5-3. Implementieren Sie den letzten Konstruktor der Liste. Objektzugriffe sind über Indexfunktionen reference at(size_type pos); const_reference at(size_type pos) const; reference operator[](size_type pos); const_reference operator[](size_type pos); reference front(); const_reference front() const; reference back(); const_reference back() const;
oder Iteratoren möglich. Start- und Endwerte für Iteratoren werden vom Containerobjekt durch die Methoden iterator begin(); const_iterator begin() const; iterator end(); iterator end() const; reverse_iterator rbegin(); const_reverse_iterator rbegin() const; reverse_iterator rend(); const_reverse_iterator rend() const;
gegeben. Das automatische Anfügen oder Löschen von Objekten am Ende des Vektors erfolgt durch die Methoden void push_back(const T& x); void pop_back();
Zu beachten ist, dass sich bei Änderung der Anzahl der gespeicherten Elemente in jedem Fall der auf das Ende zeigende Iterator ändert, bei Änderungen, die auch den Reservebereich betreffen, alle Iteratoren ihre Gültigkeit verlieren. In einer korrekten Implementation sind daher nach allen Operationen, die mit einer Größenänderung des Containers ver-
168
4 Die Standard-Template-Library (STL)
bunden sind, sämtliche in Gebrauch befindliche Iteratoren neu zu initialisieren. Eine Reihe von Methoden erlauben den Austausch der Inhalte mehrerer Variabler gleichzeitig. Die Methode void assign(const_iterator first, const_iterator last);
tauscht beginnend ab dem ersten Element die Inhalte der Feldvariablen gegen die durch die Iteratorsequenz gegebenen Inhalte aus, entspricht also einer Zuweisung, die Methode void assign(size_type n, const T& x = T());
ersetzt n Elemente durch den angegebenen Wert. Der Austausch der Inhalte zweier Vektoren wird durch void swap(vector x);
durchgeführt. Die folgenden Methoden erlauben das Einfügen oder Löschen von Sequenzen ab der ersten Iteratorposition, das heißt die folgenden Elemente werden auf dem Vektor entsprechen verschoben iterator insert(iterator it, const T& x = T()); void insert(iterator it, size_type n, const T& x); void insert(iterator it, const_iterator first, const_iterator last); iterator erase(iterator it); iterator erase(iterator first, iterator last);
Unbedingt zu beachten: Alle Einfüge- und Löschoperationen verändern die Containergröße, und damit verlieren alle laufende Iteratoren ihre Gültigkeit (falls Sie einen Iterator finden, der hinterher noch gültige Zugriffe erlaubt, ist das Zufall). Spezielle mathematische Algorithmen zwischen Vektorobjekten sind nicht direkt implementiert (dazu dienen die später zu diskutierenden Algorithmen), ebenso wenig indizierte Zugriffe auf Vektoren. Da die Klasse die gleiche Struktur wie valarray besitzt, eignet sie sich für den Einsatz in Bereichen, in denen es auf Rechenleistung ankommt. 4.5.3 Speichertyp C): Deque Die Erweiterung des Speichermodells nach Typ C) beinhaltet die Fähigkeit, schnell an beiden Enden des vorhandenen Feldes neue Objekte hinzufügen oder alte löschen zu können. Die Schnittstelle der Klasse deque
4.5 Die Containerklassen der Standardbibliothek
169
stimmt mit der von vector überein und implementiert nur weitere Methoden zur Bedienung des Feldbeginns: void push_front(const T& x); void pop_front();
Die Methoden reserve(..) und capacity() sind nicht mehr notwendig, da das Feld segmentweise verwaltet und erweitert wird, und deshalb in der Klassendefinition auch nicht mehr vorhanden. Strukturell entspricht die Klasse weitgehend einem Feld von Vektoren (allerdings kann ein Segment an beiden Enden eine Kapazitätsreserve aufweisen). Da neue Segmente beliebig an beiden Enden angefügt oder entfernt werden können, eignet sie sich zur Implementation von Warteschlangen. Aufgrund der Segmentierung sind die Iteratoren aber nun nicht mehr einfache Zeiger wie bei den beiden vorhergehenden Klassen. Für die Überwindung der Segmentgrenzen muss entsprechender Aufwand getrieben werden, so dass sich dieser Datentyp nicht mehr für den Einsatz in aufwendigen Rechenalgorithmen eignet. Aufgabe 4.5-4. Implementieren Sie einen Vorwärts–Iterator. Beschränken Sie sich auf den Iterator und fordern Sie von der Containerklasse nur die benötigten Eigenschaften, ohne über eine Implementation nachzudenken. Warteschlangen: Stack und Queue Auf der Grundlage von vector<..> und deque<..> sind in der STL weitere Containerklassen für spezielle Warteschlangen oder Datentypen realisiert, die an dieser Stelle ebenfalls vorgestellt werden sollen. Dabei wird teilweise die vorhandene Schnittstelle auf den Umfang reduziert, der für die spezielle Warteschlange notwendig ist, teilweise allgemeine Algorithmen als Spezialisierungen in die Klasse übernommen. Die Klasse stack stellt eine Schnittstelle für eine ausschließliche LIFO–Speicherliste zur Verfügung: template
170
4 Die Standard-Template-Library (STL)
Es können nur an einer Seite Objekte hinzugefügt oder entfernt werden, wobei der Zugriff auf das letzte Objekt mit der Methode top() möglich ist. Das Gegenstück mit gleicher Schnittstelle, aber Entnahme der Objekte am vorderen Ende der Schlange (FIFO–Warteschlange) heißt queue . Prioritätswarteschlange Neben der LIFO– und der FIFO–Warteschlange lässt sich als weitere Warteschlange noch eine Prioritätsliste konstruieren. Dazu muss zwischen den Elementen eine Relation existieren, die eine Sortierung der Elemente in einer „natürlichen Reihenfolge“ erlaubt. Beispielsweise können Zahlen nach Größe oder Betrag sortiert werden. Bei Einfügen eines neuen Elementes wird dieses nicht einfach an eines der Enden der Schlange geschrieben, sondern mit Hilfe der Relation an eine Stelle positioniert, die seinem Rang entspricht, bei Entnahme wird das jeweils rangniedrigste Element entnommen. Eine Warteschlange dieser Art ist durch die Klasse priority_queue realisiert. Sie besitzt die gleiche Schnittstelle wie stack, hat aber als zusätzlichen Vorlagenparameter eine Struktur less<..>, die die Vergleichsrelation definiert: template
Im Normalfall ist less<..> durch einen einfachen operator<(..)– Vergleich implementiert. Die Details werden bei der Diskussion der Algorithmen in Kapitel 4.6 vorgestellt. Unabhängig von der Reihenfolge der push(..)–Operationen wird immer das Objekt von der Methode pop() ausgegeben, das intern die niedrigste Bewertung aufweist. Vermutlich hätten Sie erwartet, solche Warteschlangen durch den Containertyp „verkettete Liste“ oder ähnliches realisiert zu sehen und nicht durch ein Derivat von deque. Wir im Abschnitt über Baumstrukturen aber schon erwähnt, lassen sich binäre Bäume auf linearen Feldern implementieren, und es existiert ein Sortieralgorithmus, der bei Vorliegen eines sortierten Feldes auch sehr effektiv das korrekte Positionieren eines weitere Elementes erlaubt. Wir werden uns die Speicherstruktur und den Sortieralgorithmus im Kapitel 4.6.1 noch genauer ansehen. Die Art der Sortierung hat die Konsequenz, dass einmal in der Liste befindliche Objekte ihre relative Position zueinander nicht mehr ändern können. Beispielsweise kann durch „Zeitattribute“ dafür gesorgt werden,
4.5 Die Containerklassen der Standardbibliothek
171
dass sich die Priorität von Objekten in der Schlange sich gegenüber neuen Objekten stetig verbessert und die Aufenthaltszeit eines Elementes somit auf jeden Fall begrenzt ist, es ist jedoch nicht vorgesehen, ein Objekt schneller altern zu lassen als andere und es diese überholen zu lassen. Da der Baum nicht nachsortiert wird, geht in diesem Fall die Sortierung und damit so ziemlich alles verloren. Wenn Sie Prioritätsschlangen mit ständiger kompletter Neusortierung benötigen, müssen Sie sich selbst etwas einfallen lassen. Bitfelder Bei der Verwaltung von (logischen) Schaltern, die nur die Werte true oder false annahmen können, genügt ein Bit für die Informationsspeicherung. Als weitere Spezialisierung von vector enthält die STL dafür die Klasse bit_vector, die funktionsmäßig etwa vector
172
4 Die Standard-Template-Library (STL) size_type rfind(const basic_string& str, size_type pos = npos) const; // Identifizieren der Position bestimmter Zeichen // in einer ZK aus einer vorgegebenen Menge size_type find_first_of(const basic_string& str, size_type pos = 0) const; size_type find_last_of(const basic_string& str, size_type pos = npos) const; size_type find_first_not_of( const basic_string& str, size_type pos = 0) const; size_type find_last_not_of( const basic_string& str, size_type pos = npos) const; // Teil-ZK und Vergleiche von ZK basic_string substr(size_type pos = 0, size_type n = npos) const; int compare(const basic_string& str) const;
Die Methoden sind jeweils in mehreren Versionen implementiert, die als Argumente andere Objekte des Typs basic_string, Felder, C–Strings (sofern sinnvoll) oder Iteratoren erlauben. template
4.5 Die Containerklassen der Standardbibliothek
173
haben als Rückgabewert beide einen Zeiger auf den Beginn der Zeichenkette, wobei aber c_str() dafür sorgt, dass die Kette durch eine Null abgeschlossen ist. Bei allgemeinen Zeichenketten muss das bei data() nicht der Fall sein, so dass sich eine Auswertung als String außerhalb der eigentlichen Zeichenkette abspielen kann. Für die Zeichenkettenauswertung wesentlich ist der Vorlagenparameter char_traits<..>, in dem festgelegt wird, welchen Eigenschaften die Zeichen einer Kette haben und wie eine Zeichenkette auszuwerten ist. Genauer: Der Templateparameter von char_traits<..> gibt den Datentyp der Zeichen in der Kette an, in char_traits<..> werden für die Arbeit in der Stringklasse notwendige Zugriffsmethoden und Algorithmen hinterlegt. struct char_traits<E> { ... static void assign(E& x, const E& y); static E *assign(E *x, size_t n, const E& y); static bool eq(const E& x, const E& y); static bool lt(const E& x, const E& y); static int compare(const E *x, const E *y, size_t n); static size_t length(const E *x); static E *copy(E *x, const E *y, size_t n); static E *move(E *x, const E *y, size_t n); static const E *find(const E *x, size_t n, const E& y); static E to_char_type(const int_type& ch); static int_type to_int_type(const E& c); static bool eq_int_type(const int_type& ch1, const int_type& ch2); static int_type eof(); static int_type not_eof(const int_type& ch); };//end class
Die Methoden enthalten das, was man auf den ersten Blick vermuten darf, also in Bezug auf den Datentyp char ziemliche Trivialitäten, aber auch die Konventionen für C–Strings. Spezielle Festlegungen sind aber beispielsweise notwendig für erweiterte Alphabete wie chinesische/japanische Schrift, Hieroglyphen und sonstige Schriften, die Silben oder ganze Worte oder mehr als 255 Zeichen umfassen. Mit der Bearbeitung von Strings werden wir uns in den weiteren Kapiteln noch recht intensiv auseinander setzen, so dass ich hier keine Übungsaufgaben stelle.
174
4 Die Standard-Template-Library (STL)
4.5.4 Speichertyp D), E): slist und list Mit den Listenklassen wird der Indexzugriff auf beliebige Elemente zugunsten einer weniger aufwendigen Sortierbarkeit aufgegeben. Bei den Containertypen vector und deque bedeutet eine Sortierung der Elemente ein aufwendiges Kopieren der Elementinhalte an andere Positionen, was bei umfangreichen Objekten wie beispielsweise Strings schon einiges an Rechenaufwand bedeuten kann. Werden anstelle der Inhalte nur die Zeiger auf die Inhalte kopiert, ist der Aufwand geringer. Genau dieses Modell beinhalten die Speicherklassen D) und E), die aber nur bei einem sequentiellen Zugriff auf die Elemente effektiv arbeiten.74 Verwendet wird meist die doppelt verkettete Liste list<..>. Die Schnittstelle hält wenig Überraschungen bereit. Außer den Indexzugriffen finden wir alle Iteratoren sowie Zugriffsmöglichkeiten auf die Enden der Kette wie bei deque wieder. Auch die Methoden void resize(size_type n, T x = T()); size_type size() const; bool empty() const;
sind implementiert und erzeugen wie gewohnt eine Liste der angegebenen Größe (beziehungsweise hängen entsprechend viele Elemente an das Ende der vorhandenen Liste an) beziehungsweise ermitteln die Größe. Das Innenleben der Methoden kann allerdings kaum noch auf Formeln zurückgreifen, sondern basiert auf Abzählungen. Einfügen und Löschen von Elementen oder Elementsequenzen erfolgt mit den bekannten Methoden iterator insert(iterator it, const T& x = T()); void insert(iterator it, size_type n, const T& x); void insert(iterator it, const_iterator first, const_iterator last); void insert(iterator it, const T *first, const T *last); iterator erase(iterator it); iterator erase(iterator first, iterator last);
Besonders einfach abzuwickeln ist natürlich das Verbinden oder Teilen von Listen. Der Transfer von Elementen einer anderen Liste in einer gegebene erfolgt mit der Methode 74 Eigentlich ist der Datentyp string ein schlechtes Beispiel, da bei eine Sortierung von Strings aus internen Gründen, die wir später diskutieren, Zeiger auf die Daten sortiert werden und die Klassen vector und deque deshalb sogar recht gut abschneiden. Es genügt eben nicht, nur eine Komponente in einer Anwendung zu kennen.
4.5 Die Containerklassen der Standardbibliothek
175
void splice(iterator it, list& x, iterator first); void splice(iterator it, list& x, iterator first, iterator last);
„Transfer“ bedeutet, dass die Elemente gleichzeitig aus der Quellliste entfernt werden. Bei allen diesen Methoden wird ähnlich wie bei den vorher diskutierten Containern keine Sortierung vorgenommen, sondern es werden nur die angegebenen Iteratorpositionen verwendet. Weitere Methoden untersuchen allerdings auch den Inhalt der Objekte. void remove(const T& x); void remove_if(binder2nd<not_equal_to
löscht alle Objekte, die mit x übereinstimmen oder die in der zweiten Methode angegebenen Bedingung erfüllen. Wie die Bedingung anzugeben ist, wird in Kapitel 4.6 bei den Algorithmen erläutert. Die Methoden void unique(); void unique(not_equal_to
löschen Mehrfacheinträge in der Liste (es bleibt aber ein Element erhalten). Das Sortieren einer Liste oder das sortierte Zusammenfügen zweier Listen übernehmen void sort(); template
Aufgabe 4.5-5. Implementieren Sie einen Algorithmus zum sortierten Zusammenführen von Listen. 4.5.5 Speichertyp F), G): hash, set, map Bei Klassen mit einem Objektzugriff über Schlüsselbegriffe können wir folgende Möglichkeiten unterscheiden: Der Schlüssel ist mit dem Objekt identisch oder im Objekt enthalten oder der Schlüssel ist eine vom eigentlichen Objekt unterschiedene Größe. Die Schlüssel können eine Unterscheidung der Objekte ermöglichen ( operator==(..) ) oder
176
4 Die Standard-Template-Library (STL)
eine Reihenfolge der Objekte ermöglichen (hierzu ist zusätzlich der operator>(..) notwenig) Und schließlich können die Container gleiche Objekte ausschließen oder gleiche Objekte zulassen. Daraus ergeben sich eine ganze Reihe unterschiedlicher Implementationsmöglichkeiten. Gemeinsam ist allen Klassen, dass sie für einen schlüsselorientierten Zugriff entworfen sind und Indexzugriffe gar nicht sowie sequentielle Zugriffe weniger effizient durchzuführen vermögen als die bisherigen Container. Die Schlüsselorientierung des Containers hat weitere Konsequenzen: Der Schlüsselinhalt darf (und kann) nicht verändert werden, da ansonsten die Sortierung verloren gehen würde. Die Iteratoren der Containerklassen präsentieren sich zwar nach Außen wie normale Iteratoren, agieren aber zumindest hinsichtlich der Schlüsselbegriffe wie konstante Iteratoren, das heißt die Zuweisung von Daten zum Schlüsselbegriff des Iterators wird vom Compiler nicht zugelassen. Soll also ein Objekt (mitsamt dem Schlüssel) durch ein anderes Objekt ersetzt werden, so ist zunächst das vorhandene Objekt zu löschen, anschließend das Neue einzufügen (Objektveränderungen ohne Wirkung auf den Schlüssel sind natürlich zulässig).75 Nicht sortierbare Schlüssel Beginnen wir mit Objekten, die sich zwar unterscheiden aber nicht sortieren lassen. Diese können nach Speichermethode G) durch einen Hashwert verschlüsselt werden.76 Hierzu sind die vier Klassen 75 In dieser Hinsicht ist das Klassifizierungskriterium (a) noch einmal zu revidieren. Ob der Schlüssel im Objekt enthalten ist, spielt weniger eine Rolle, da in den Containervorlagen eine spezialisierte Auswertungsmethode angegeben werden kann, die das wesentliche extrahiert und auch der notwendige Speicherplatz für die Ablage eines kompletten Objektes im Schlüsselsystem kaum eine Rolle spielt. Wesentlich ist, ob die nicht an der Bildung des Schlüssels beteiligten Attribute veränderbar sein sollen. Ist diese der Fall, so liegt Typ (b) vor und des Schlüssel ist, obwohl vielleicht im Objekt bereits vorhanden, im Indexsystem ein weiteres Mal zu speichern. 76 Als Beispiel denke der Leser an Matrizen, Farben, Bilder oder ähnliches. Solche Objekte zu unterscheiden ist in der Regel recht einfach, sie aber in eine Reihenfolge zu zwängen, lässt sich in den meisten Fällen nur mit tiefen Griffen in irgendeine Trickkiste bewältigen. Das Ergebnis muss dann nicht unbedingt besonders logisch wirken und seine Erzeugung kann im Gegensatz zur
4.5 Die Containerklassen der Standardbibliothek
177
hash_set
definiert. Lassen sich die Schlüssel direkt aus den Objekten ermitteln und müssen diese nicht modifiziert werden, so kann der Containertyp set verwendet werden. Dieser stellt mit den bisher diskutierten Containern kompatible Iteratoren zur Verfügung. set<string>::iterator its(st.begin()); vector<string>::iterator itv(vec.begin()); *itv=*its; // ok ... *its=*itv; // ungültig, da its const_iterator
Für den Containertyp map gilt dies nicht mehr; er stellt einen Iterator vom Typ pair
Speichern und Löschen von Objekten erfolgt mit Hilfe der Methoden pair
Das Ergebnis eines insert(..)–Befehls ist vom verwendeten Containertyp abhängig. Sofern es sich nicht um einen __multi__–Container handelt, werden nämlich Objekte mit gleichen Schlüsseln nicht ein weiteres Mal eingefügt, was durch ungültige Iteratorwerte angezeigt wird. Bei sortierbaren Schlüsseln ist dies kein Problem, da in den meisten Fällen der Schlüsselbegriff eindeutig sein sollte; bei Hashfunktionen ist aber nicht grundsätzlich auszuschließen, dass deutlich verschiedene Objekte den glei-
Feststellung der Ungleichheit mit einem erheblichen Aufwand verbunden sein.
178
4 Die Standard-Template-Library (STL)
chen Hashwert ergeben. Eine sorgfältige Auswahl der Hashfunktion ist deshalb notwendig. Sortierbare Schlüssel Ist eine Sortierung der Objekte möglich, so wird anstelle der Hashtabelle ein Suchbaum durch die folgenden Containerklassen aufgebaut: set
Ein Teilbereich eines Baums kann mit den Methoden iterator lower_bound(const Key& key); iterator upper_bound(const Key& key);
ausgelotet werden. lower_bound liefert einen Iterator auf das Objekt mit nächstkleinerem Schlüssel, upper_bound entsprechend einen Iterator auf das Objekt mit nächstgrößerem Schlüssel. Zwischen diesen Werten kann nun beispielsweise sequentiell iteriert werden.
4.6 Algorithmen und Container Es ist klar, dass man nicht nur Objekte in Containern sammeln, sondern auch bestimmte Berechnungen mit ihnen oder auf ihnen durchführen möchte. Der eine oder andere Containertyp besitzt denn auch neben den reinen Zugriffsschnittstellen bereits Methoden, die bestimmte Algorithmen auf dem Inhalt durchführen. Die meisten Berechnungen sind recht spezieller Natur, es existieren aber auch einige, die von fast allen Anwendern und unabhängig vom Containertyp benötigt werden oder in Zukunft benötigt werden könnten. Wollte man nun die Container in herkömmlicher Weise durch entsprechende Methoden erweitern, so stünde man vor einigen Problemen: Es würden ständig neue Versionen der Klassen erstellt, deren Herausgabe koordiniert werden müsste, und für den in einer Anwendung eingesetzten Container stünde der benötigte Algorithmus gerade nicht zur Verfügung. Aus diesem Grund wurde für die STL ein anderer Weg gewählt: Für jeden Algorithmus kann eine allgemeine Lösung implementiert werden, die das Ergebnis auf der Grundlage einer C– Zeigerarithmetik ermittelt. Sofern ein Algorithmus auf einem bestimmten Containertyp zulässig ist (Sortieren eines set–Containers mach zum Beispiel keinen Sinn), muss er mit Hilfe des Iteratorkonzeptes damit auch lauffähig sein – wie holprig das im Einzelfall auch immer sein mag. Algo-
4.6 Algorithmen und Container
179
rithmen werden daher als Templatefunktionen unabhängig von den Containern definiert und implementiert (Templateparameter der Funktionen sind Iteratoren). Wie wir noch sehen werden, ist damit sogar die Möglichkeit gegeben, Elemente unterschiedlicher Container in einem Algorithmus zu verarbeiten. Werden nun weitere Algorithmen implementiert, so verlängert sich die Liste der Algorithmen um einige Methoden, und weder der Verwaltungsaufwand noch die mangelnde Verfügbarkeit treten als Problem in Erscheinung. Darüber hinaus ist es auch möglich, die Algorithmen für bestimmte Containertypen zu spezialisieren. Der Anwender kann die Spezialisierung nach dem nächsten Update der Bibliothek nutzen, ohne dass er sich darum kümmern muss. Bevor wir uns die zum Zeitpunkt des Entstehens dieses Buches verfügbaren Algorithmen ansehen, sehen wir uns für einige Algorithmen die Theorie etwas genauer an. Immer wieder benötigt werden Algorithmen für das Suchen bestimmter Elemente in einem Container oder das Sortieren der Objekte eines Containers. 4.6.1 Suchen und Sortieren von Elementen Sortierrelationen Um nach einem bestimmten Element in einem Container zu suchen, benötigt man eine Relation equal zwischen Objekten. Für Sortierungen ist zusätzlich eine Relation less notwendig, da mit der Relation equal zwar zwischen Objekten unterschieden werden kann, dies aber noch nicht zu einer Reihenfolge der Elemente führt. Suchen und Sortieren unterscheiden sich außerdem dadurch, dass das Referenzobjekt beim Suchen von Außen kommt, während die Sortiervorgänge sich vollständig innerhalb eines Containers abspielen. Bei einem Such- oder Sortiervorgang bedeutet a b allerdings nicht automatisch b a , sondern es kann ja auch der Fall a b vorliegen. Für Sortierungen an sich ist das relativ egal, für die Suche nach einem Element jedoch nicht unbedingt, denn wir haben ja nun mindestens zwei Elemente im Container, die die Suchrelation mit dem von Außen vorgegebenen Objekt erfüllen. In sortierbaren Containern wird zur Vermeidung von Mehrdeutigkeiten und Missverständnisse meist auch recht eindeutig festgelegt, ob mehrere Elemente mit gleichen Schlüsseln zulässig sind. In den meisten (Anwendungs-)Fällen gilt innerhalb eines Containers
a b
ba
Wie der Blick auf die Containerliste zeigt, gilt das auch für die Standardcontainer, und der Fall
180
4 Die Standard-Template-Library (STL)
a b a b b a wird durch Spezialversionen vertreten. Wir wollen im folgenden einige Such- und Sortieralgorithmen anreißen. Da dies kein Lehrbuch zu diesem Thema ist und die Algorithmen in der STL in fertiger Form vorliegen, fasse ich mich kurz. Suchen in unsortierten Containern Besteht nur eine Relation „=“ zwischen den Objekten oder ist der Container nicht sortiert, so kann ein Suchen nach einem bestimmten Element nach zwei Strategien erfolgen: a) Der Container wird sequentiell durchsucht, bis das gesuchte Element gefunden ist. Der Suchaufwand ist proportional zur Anzahl der Elemente im Container. Diese Suchmethode funktioniert immer. b) Für die Objekte wird eine Hashliste angelegt, in der nach Berechnen des Hashwertes des gesuchten Begriffs überprüft werden kann, ob ein Eintrag existiert. Auch das funktioniert im Prinzip immer, ist aber mit der Nebenbedingung verbunden, bei Änderung der Objekte die Hashliste anzupassen. Ist zwischen den Objekten zusätzlich die Relation less gegeben, kann die Suche auch nach dieser Relation durchgeführt werden, das heißt wenn ein Objekt mit den gewünschten Eigenschaften nicht gefunden wird, kann zumindest das angegeben werden, was dem möglichst nahe kommt. Wenn Sie sich die Ausführungen über Hashfunktionen in Erinnerung rufen, stellen Sie allerdings fest, dass dies nur noch mit Strategie a) funktioniert und mit erheblichem Aufwand verbunden ist, da immer der komplette Container untersucht werden muss. Sortierung eines Listencontainers Eine Liste lässt sich recht effektiv sortieren, so dass anschließend beim sequentiellen Lesen die Elemente in aufsteigender Reihenfolge erscheinen. Zwar muss auch beim Suchen nach einem bestimmten Element die Liste sequentiell untersucht werden, jedoch kann die Suche in einer sortierten List abgebrochen werden, sobald ein geprüftes Listenelement die Relation greater erfüllt. Das bringt zumindest einen kleinen Vorteil gegenüber völlig unsortierten Listen. Bei der Durchführung des Sortiervorgangs wird die Liste mittels eines Schlüsselwertes in zwei Teillisten zerlegt. Eine Teilliste nimmt alle Objek-
4.6 Algorithmen und Container
181
te auf, die die Relation less erfüllen, die andere die restlichen. Günstig für den Sortiervorgang sind etwa gleichstarke Teillisten nach Aufteilung aller Elemente; der Sortiervorgang funktioniert jedoch auch dann, wenn jede Liste mindestens ein Element enthält. Die entstandenen Teillisten werden im nächsten Schritt einzeln nach dem gleichen Schema mit einem neuen Schlüsselwert rekursiv weiter zerlegt, bis jede Teilliste nur noch aus einem Element besteht. Beim Zurückgehen werden die Listen jeweils wieder zusammengefügt. Jede der dabei entstehenden Teillisten ist nun sortiert. Symbolisch kann der Algorithmus so dargestellt werden: liste qsort(liste alt, Key key){ liste a,b; int i; if(alt.size()==1) return alt; for(i=0;i
Die
Tiefe der Rekursion ist bei optimaler Unterlistenbildung ld size() , also auch bei großen Listen meist unkritisch. Wenn allerdings wenig über die optimale Wahl der Teilungsschlüssel bekannt ist und beispielsweise eine lineare Teilung zwischen dem größten und dem kleinsten Schlüssel gewählt wird, in der Liste aber eine logarithmische Verteilung der Schlüsselwerte vorliegt, können aber auch unangenehm viele Funktionsaufrufe auftreten, so dass eine Stackkontrolle sinnvoll sein kann. Sortierung eines Feldcontainers Das Sortieren eines Feldes mit Indexzugriff kann durch paarweises Vertauschen von Elementen erfolgen. Jedes Element wird dabei nacheinander mit allen nachfolgenen verglichen und das jeweils kleinere an die vordere Position geschrieben. Am Schluss eines Durchgangs steht das kleinste Element an erster Stelle, und die Wiederholung der Sortierung mit der jeweils nächsten Position als Startpunkt führt zum gewünschten Ergebnis. Der Sortieraufwand ist quadratisch von der Anzahl der Elemente in der Liste abhängig: for(i=0;i
182
4 Die Standard-Template-Library (STL)
Suchen in einem sortierten Feldcontainer In einer sortierten Liste lässt sich wesentlich effektiver suchen als in einer unsortierten. Der Suchvorgang in einer sortierten Liste mit Indexzugriff ähnelt dem Sortiervorgang für Listen. Dazu wird das Feld zunächst durch das zentrale Element in zwei Intervalle geteilt. Ist der Schlüssel des gesuchten Elementes kleiner als der des zentralen Referenzelementes, wird als neues zu durchsuchende Feld das untere Intervall verwendet, ist der Schlüssel größer, wird das obere Intervall genommen. Bei Schlüsselgleichheit ist die Suche beendet. Falls die Anzahl der Elemente im Container eine Potenz von Zwei ist, führt folgender Algorithmus zum Ziel: dn=n/4; n=n/2; do { switch(sign(a-o[n])){ case -1: n=dn; break; case 0: return o[n]; case 1: n=n+dn; break; }//endswitch dn=dn/2; }while(dn>=1);
Da die Intervallbreite in jedem Schritt halbiert wird, hängt der Suchaufwand logarithmisch von der Anzahl der Elemente ab O ld n . Aufgabe 4.6-1. Modifizieren Sie den Algorithmus für die Suche in Feldern beliebiger Länge. Binäre Bäume auf Feldcontainern Die diskutierten Algorithmen sind für die Sortierung unsortierter Felder gedacht. Es stellt sich die Frage, ob für das Anfügen eines weiteren Elementes zu einem bereits sortierten Feld nicht ein effizienteres Verfahren existiert. Eine Möglichkeit besteht darin, die Position für das Einfügen mittels des Suchalgorithmus zu ermitteln, die folgenden Elemente um eine Position zu verschieben und das neue Objekt einzufügen. Lineare Felder können aber auch in Form von binären Bäumen vorsortiert werden. Wird hier ein neues Element hinzugefügt oder ein vorhandenes gelöscht, muss der Baum nur so lange nachsortiert werden, bis der zuletzt untersuchte
4.6 Algorithmen und Container
183
Verweis wieder korrekt ist. Die Sortierung erfasst meist nur einen Teil des Baumes und ist daher relativ schnell. Das Verfahren wurde bei der Klasse priority_queue verwendet und wird nun erläutert. Erläutern wir zunächst, was ein lineares Feld mit Indexzugriff mit einem binären Baum zu tun hat. Ein Eintrag in einem binären Baum besteht aus einem Objekt und zwei Zeigern, wovon der eine auf Objekte mit kleineren Schlüsseln, der andere auf solche mit größeren Schlüsseln zeigt. Zur Veranschaulichung kann auf das Schema in Kapitel 4.3 zurückgegriffen werden. Für die Speicherung eines solchen Baumes werden die Zeiger bei genauerer Überlegung jedoch gar nicht benötigt. Wird ein Objekt an der Position k 1 in einem linearen Feld gespeichert, so steht für die Ablage des Objekt mit dem nächstkleineren Schlüssel die Position 2 k , für das mit dem nächstgrößeren Schlüssel die Position 2 k 1 zur Verfügung, wie Sie sich am folgenden Indizierungsschema leicht überlegen können: 1 -> 2 -> 3 -> 4 -> ...
2, 4, 6, 8,
3 5 7 9
Das Indizierungsschema ist eindeutig, das heißt zu einem vorgegebenen k lassen sich alle drei Nachbarn durch einen einfachen Algorithmus angeben. Die Suche in dem binären Baum beginnt jeweils an der Stelle k 1 und ist wie im vorhergehenden Fall im Mittel nach ld n Schritten beendet. Wie schon bei den mehrwertigen Bäumen dargelegt, ist es notwendig, die Bäume im Gleichgewicht zu halten, das heißt beginnend bei k 1 müssen bei zufälliger Entscheidung, ob nach unten oder nach oben verzweigt wird, immer gleich viele Schritte möglich sein. Diese Eigenschaft muss auch erhalten bleiben, wenn Elemente hinzukommen oder alte verschwinden. Sehen wir uns dazu zunächst das Einfügen eines neuen Elementes an. Im ersten Schritt wird es am Ende der Liste eingefügt. Im folgenden Diagramm entspricht dies dem Kästchen unten rechts. I D
M
B A
F C
E
K H
J
O L
N
184
4 Die Standard-Template-Library (STL)
Fügen Sie hier ein Element mit dem Schlüssel „G“ ein. Damit ist nun die letzte Verzweigung nicht mehr korrekt. Zur Korrektur vertauschen Sie nun „G“ mit „O“. Setzen Sie das Verfahren iterativ fort. Dabei vertauscht „G“ nacheinander mit folgenden Objekten die Position G -> O -> M -> I -> D -> F
Nach der letzten Vertauschung ist der Baum wieder korrekt. Ähnlich ist beim Löschen eines Objektes vorzugehen. Als Beispiel diene wieder das Element G, das nun entfernt werden sollt. Dazu wird das letzte Element, nun das O, an die Stelle des zu löschenden gesetzt und die letzte Position entfällt. Da nun wiederum die Verweise nicht stimmen, wird wie im ersten Durchgang iterativ getauscht. Verifizieren Sie, dass hierdurch der ursprüngliche Zustand wieder hergestellt wird! Die Indizes im linearen Feld können Sie im Baumschema darstellen, wenn Sie von oben nach unten und von links nach rechts durchnummerieren. Durch das Anfügen an höchster freier Indexposition beziehungsweise Verschieben von der höchsten Position auf eine freiwerdende Position bleibt der Baum im Gleichgewicht. Das Einfügen und Löschen erfordert einen Aufwand proportional zu ld n . Aufgabe 4.6-2. Implementieren Sie die gerade auf dem Papier durchgeführten Algorithmen. Aufgabe 4.6-3. Sie haben in Kapitel 4.5 Bekanntschaft mit mehrwertigen Suchbäumen und nun mit binären Suchbäumen gemacht. Diskutieren Sie die Einsatzbereiche der unterschiedlichen Typen. Suchen in Strings In der Praxis relativ häufig benötigt wird eine Suche nach bestimmten Mustern in Strings. Oft tritt noch als Nebenbedingung hinzu, dass von einem vorgegebenen Muster auch Teilübereinstimmungen zu finden sind; diese Fälle werden wir hier aber nicht betrachten. Die naive Lösung besteht darin, das erste Zeichen des Suchmusters im zu prüfenden String zu suchen und bei einer Übereinstimmung die folgenden Zeichen zu vergleichen. A B C D E G A B C D E F G H I K L M N O A B C D E F
4.6 Algorithmen und Container
185
In diesem Beispiel hätte zunächst ein Vergleich der kompletten Musterkette ab dem ersten Zeichen des zu prüfenden Strings stattgefunden, der am Buchstaben „G“ gescheitert wäre. Durch Vorschieben wäre das zweite Auftreten des Prüfmusters erreicht und anschließend ein kompletter Vergleich positiv durchgeführt worden. Im ungünstigsten Fall liegt der theoretische Aufwand einer solchen Suche bei O len Prüfstringlen Musterstring weshalb dieses Suchverfahren meist schlechte Bewertungen bekommt. Das ist jedoch nicht korrekt. In solchen theoretischen Betrachtungen werden nämlich meist Strings wie „abaabbabbbaabaaab“ eingesetzt, die aber weder vom Umfang der Zeichensätze noch von der Grammatik her in der Praxis auftreten. Untersucht man Zeichenketten realer Sprachen (wozu auch DNA–Sequenzen in der Biologie gezählt werden können), so kommt man bei der Suche nach dem Wort „Auftritt“ in einem Text schnell dahinter, dass die Suche nach „A“ gegenüber einer Suche nach „t“ wesentlich mehr falsche Primärtreffer liefert und bei Auffinden eines „t“ die Prüfung auf ein zweites „t“ im Abstand 3 sinnvoller ist als die Prüfung des nächsten Buchstabens „r“. Ebenso macht es wenig Sinn, nach einem Misserfolg die Prüfung an der nächsten Position fortzusetzen, denn je nach verwendeten Zeichen des Prüfmusters können eine Reihe von Positionen übersprungen werden, da dort ohnehin kein Erfolg eintreten kann. Trägt man dem durch eine Klassendefinition wie template
mit
einer Spracheigenschaftsklasse language_traits ähnlich char_traits Rechnung, so dürfte der Aufwand für das Finden des Musters eher in der Größenordnung O len Prüfstring
liegen. Iteratoren auf dieser Klasse sind sicher keine ganz einfachen Gebilde mehr, da ein Inkrementieren sowohl Vor- als auch Rückwärtsbewegung bedeuten kann und der Iterator auf dem anderen String entsprechend zu synchronisieren ist. Solche Klassen existieren aber zumindest in den Standardbibliotheken nicht. Für andere Such- und Sortierprobleme in Strings, die beispielsweise
186
4 Die Standard-Template-Library (STL)
Unterschiede zwischen verschiedenen Texten ermitteln sollen oder bei denen nur Teilübereinstimmungen verlangt sind, ist eine Vorverarbeitung des zu prüfenden Strings sinnvoll. Dies ist jedoch eher Stoff eines eigenen Hauptkapitels, weshalb wir an dieser Stelle nicht weiter darauf eingehen.77 4.6.2 Algorithmen und ihre Anwendung Mit Hilfe von Iteratoren ist es relativ einfach möglich, bestimmte Operationen (Algorithmen) auf den Elementen eines Containers oder den Elementen verschiedener Container ausführen zu lassen, ohne dass man sich über die Art der Container Gedanken machen müsste (von Containern mit Iteratoren des Typs pair
Klar ist, dass die durch die Iteratoren (beg,end) und (sec) repräsentierten Container gleich groß sein (zumindest darf der zweite Container nicht kleiner sein) und kompatible Datentypen aufweisen, aber nicht gleich sein müssen. Grunddesign der Algorithmen Die STL stellt eine Vielzahl von fertigen Algorithmen zur Verfügung. Dabei kommt das folgende Konstruktionsprinzip zur Anwendung: // Datenverdichtung template
4.6 Algorithmen und Container
187
InpIt_2 beg2, InpIt_3 beg3) { .. } ... // Computermanipulation template
Der Iterator des ersten Containers wird jeweils mit Start und Ende übergeben, alle anderen Container übergeben nur ihren Startiterator. Bei Operationen auf Containern wird für die Ausgabe ein separater Startiterator angegeben. Dieser darf mit einem der Eingabeiteratoren übereinstimmen, wenn für das Ergebnis kein neuer Container verwendet werden soll. Intern kann die Übergabe eines Eingabeiterators erhebliche Auswirkungen auf den Rechengang haben, wenn vorzeitiges Überschreiben von später im Algorithmus noch benötigten Werten vermieden werden muss (siehe Kapitel 3). Bei der Konstruktion eigener Algorithmen wird dies häufig übersehen. Wenn im ersten Anlauf nicht alles implementiert wird (ich habe ja oben selbst darauf hingewiesen, den Arbeitsaufwand zunächst auf das Notwendige zu deckeln), sollte der Algorithmus bei einem späteren anderen Einsatz zumindest höflich darauf hinweisen, dass er mit einer bestimmten Verarbeitungsart noch nicht klar kommt, anstatt kommentarlos Datenunfug auszugeben. Wir sehen uns nun die von der STL zur Verfügung gestellten Algorithmen an. Der Algorithmus template
führt auf allen Elementen des Containers die Funktion void f(*InIt) aus und gibt anschließend f als Rückgabewert aus. Gemäß STL–Konvention sollen dabei die Werte der Containerelemente unverändert bleiben.78 Falls das jetzt etwas verwirrend auf Sie wirkt, schauen Sie sich die folgenden Beispiele an: void Print(T& t){ cout << t << endl; 78 Wer dennoch etwas ändert, kann sich zwar freuen, wenn das herauskommt, was er sich erhofft hat, darf sich aber nicht beschweren, wenn der STL-Programmierer die Sache etwas anders angefasst hat und es nicht klappt.
188
4 Die Standard-Template-Library (STL) }//end function ... for_each(v.begin(),v.end(),Print);
druckt mittels der Funktion Print alle Elemente des Vektors v aus. Ist Vektor nun Beispielsweise ein echter Vektor, dessen Betrag ermittelt werden soll, kann das folgendermaßen implementiert werden: struct Betrag { double ssum; Betrag(){ssum=0;}; inline void operator()(double& d){ ssum+=(d*d); };//end operator };//end struct ... r=sqrt(for_each(v.begin(),v.end(),Betrag()).ssum);
Für Änderungen von Containerelementen sind die Methoden template
mit *x = uop(*first)
beziehungsweise *x = bop(*first1, *first2)
zuständig. Die unären beziehungsweise binären Methoden können als normale Funktionen programmiert und als Vorlagenparameter angegeben werden. Darüber hinaus stellt die STL aber auch eine komplette Werkzeugbibliothek zur Verfügung, die weiter unten vorgestellt wird. Suchalgorithmen für einzelne Elemente79 Suchen nach einzelnen Elementen in einem Container. Gesucht wird das erste (oder letzte) Element, das einen bestimmten Wert aufweist, eine bestimmte (unäre) Relation erfüllt, in einem vorgegebenen zweiten Con79 Die Zeile template
4.6 Algorithmen und Container
189
tainer enthalten oder nicht enthalten ist beziehungsweise mit einem/keinem Element dieses Containers eine bestimmte (binäre) Relation erfüllt oder den gleichen Wert wie sein Nachbarelement aufweist beziehungsweise mit diesem eine Relation erfüllt.80 Der Rückgabewert ist ein Iterator auf das betreffender Element des ersten Containers. Die ersten beiden Algorithmen suchen nach dem ersten Element in einem Container, das einen bestimmten Wert aufweist oder eine bestimmte Relation erfüllt (siehe weiter hinten). InIt InIt
find (InIt first, InIt last, const T& val); find_if (InIt first, InIt last, Pred pr);
Die folgenden Algorithmen suchen nach übereinstimmenden Folgen von Elementen beziehungsweise Folgen, die durchgehend eine bestimmte Relation erfüllen, in zwei Containern, wobei die letzte auftretende Folge ermittelt wird. FwdIt1 find_end (FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2); FwdIt1 find_end (FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2, Pred pr);
Die folgenden Algorithmen suchen nach Teilübereinstimmungen, das heißt zumindest einige Elemente der zweiten Folge finden sich in der ersten wieder. Der Rückgabeiterator verweist auf den Beginn des Auftretens der Prüffolge. Findet sich beispielsweise eine Übereinstimmung zwischen Position 20 der zu testenden Folge und der Position 5 der Prüffolge, so weist der Rückgabeiterator auf die Position 15 in der zu testenden Folge, obwohl die Übereinstimmung weiter hinten liegt. FwdIt1 find_first_of(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2); FwdIt1 find_first_of(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2, Pred pr); FwdIt1 find_first_not_of(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2);
80 Das war ein langer komplizierter Satz, und ich werde mich, hoffentlich erfolgreich, bemühen, das nicht häufiger zu machen. Ich möchte jedoch die Liste der Bibliotheksalgorithmen möglichst kompakt abhandeln, da Sie sich der Algorithmen zwar im Bedarfsfall bedienen, diese aber nicht selbst implementieren sollen.
190
4 Die Standard-Template-Library (STL) FwdIt1 find_first_not_of(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2, Pred pr);
Zum Auffinden benachbarter gleicher Elemente dienen die Algorithmen FwdIt FwdIt
adjacent_find(FwdIt first, FwdIt last); adjacent_find(FwdIt first, FwdIt last, Pred pr);
Vollständige Übereinstimmung Die Algorithmen haben ähnliche Aufgaben wie die Suchfunktionen nach einzelnen Elementen, jedoch müssen nun Zeichenketten (im allgemeinen Sinn, also Ketten beliebiger Objekte) im ersten und zweiten Container vollständig übereinstimmten beziehungsweise elementweise die Relation erfüllen. FwdIt1 search(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2); FwdIt1 search(FwdIt1 first1, FwdIt1 last1, FwdIt2 first2, FwdIt2 last2, Pred pr);
Anstelle eines zweiten Containers kann eine Konstante vorgegebene sein, die in der angegebenen Multiplizität in einer Folge vorhanden sein muss, das heißt es folgen n gleiche Zeichen hintereinander FwdIt FwdIt
search_n(FwdIt Dist search_n(FwdIt Dist
first, FwdIt last, n, const T& val); first, FwdIt last, n, const T& val, Pred pr);
Auf sortierten Containern sind schnellere Suchverfahren möglich. Da einige Containertypen sowohl sortierte als auch unsortierte Instanzen zulassen, der Zustand aber nicht unbedingt leicht sichtbar ist, sollte der Nutzer Kontrollmechanismen vorsehen. bool binary_search(FwdIt const bool binary_search(FwdIt const
first, FwdIt last, T& val); first, FwdIt last, T& val, Pred pr);
4.6 Algorithmen und Container
191
Anzahlen bestimmter Elemente Feststellen der Anzahlen von Elementen mit bestimmten Werten oder erfüllten unären Relationen size_t count(InIt first, InIt last, const T& val, Dist& n); size_t count_if(InIt first, InIt last, Pred pr);
Unterschiede und Ähnlichkeiten Finden von Unterschieden in zwei Containern. Der Rückgabewert enthält Iteratoren auf die entsprechenden Positionen in beiden Containern und ist deshalb vom Typ pair<..>. Alternativ können auch zwei Container auf den gleichen Inhalt, auf Enthalten sein des zweiten im ersten oder auf elementweise Erfüllung einer < - Relation überprüft werden. Der Rückgabewert ist in diesem Fall true oder false pair
first1, first2, first1, first2,
InIt1 InIt2 InIt1 InIt2
last1, last2); last1, last2, Pred pr);
bool lexicographical_compare(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2); bool lexicographical_compare(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, Pred pr);
Zu beachten ist, dass bei den ersten beiden Algorithmen kein Enditerator des zweiten Containers angegeben wird. Um Laufzeitfehler zu vermeiden, muss sichergestellt werden, dass der zweite Container mindestens die Größe des ersten besitzt. Kopieren von Containern Kopieroperationen und elementweise Austauschoperationen zwischen Containern. Die Methoden sind nicht überlappungssicher. Zum Kopieren
192
4 Die Standard-Template-Library (STL)
in überlappenden Sequenzen muss der Anwender selbst die kompatible Kopieroperation aussuchen. Der erste Algorithmus kopiert den Inhalt des ersten Containers in den zweiten, der eine entsprechende Größe besitzen muss, und liefert einen Iterator auf das erste Zeichen hinter der kopierten Sequenz zurück. OutIt
copy(InIt first, InIt last, OutIt x);
Der zweite Algorithmus kopiert den Inhalt des ersten Containers vom Ende her in den zweiten, das heißt der Iterator x muss mindestens einen Abstand zum Beginn des Containers aufweisen, der der Größe des ersten Containers entspricht. Der dritte Algorithmus tauscht die Elemente zwischen den Containern aus. BidIt2 copy_backward(BidIt1 first, BidIt1 last, BidIt2 x); Beispiel:
C1: C2: Re:
„x1xx2x“ „yyyyyyyyy“, x=C2.last „yyyx1xx2x“
FwdIt2 swap_ranges(FwdIt1 first, FwdIt1 last, FwdIt2 x);
Austauschen von Elementen Austauschoperationen von einzelnen Elementen in Containern, wobei zwischen einem direkten Austausch im vorgegebenen Container und einer Kopie unter Austausch unterschieden wird. Anstelle eines Austauschs, der eine Untersuchung der vorhandenen Elemente und eine Ausführung der Austauschanweisung nur bei Erfüllen einer Relation voraussetzt, ist auch ein Füllen eines bestimmten Bereiches mit einer Konstanten oder einem generierten Wert (beispielsweise einer Zufallzahl, Funktionstyp *OutIt g() ) möglich. Die Funktionen sollten auch ohne weitere Erläuterung verständlich sein. void replace(FwdIt first, FwdIt last, const T& vold, const T& vnew); void replace_if(FwdIt first, FwdIt last, Pred pr, const T& val); OutIt replace_copy(InIt first, InIt last, OutIt x, const T& vold, const T& vnew); OutIt replace_copy_if(InIt first, InIt last, OutIt x, Pred pr, const T& val); void fill(FwdIt first, FwdIt last, const T& x);
4.6 Algorithmen und Container
193
void fill_n(OutIt first, Size n, const T& x); void generate(FwdIt first, FwdIt last, Gen g); void generate_n(OutIt first, Dist n, Gen g);
Löschen von Elementen Löschen von Elementen mit bestimmten Werten oder von Mehrfacheinträgen in einem Container. Auch sind weitere Kommentare wohl nicht notwendig. FwdIt remove(FwdIt first, FwdIt last, const T& val); FwdIt remove_if(FwdIt first, FwdIt last, Pred pr); OutIt remove_copy(InIt first, InIt last, OutIt x, const T&val); OutIt remove_copy_if(InIt first, InIt last, OutIt x, Pred pr); FwdIt FwdIt OutIt OutIt
unique(FwdIt first, FwdIt last); unique(FwdIt first, FwdIt last, Pred pr); unique_copy(InIt first, InIt last, OutIt x); unique_copy(InIt first, InIt last, OutIt x, Pred pr);
Reihenfolgeänderungen Sortierfunktionen beziehungsweise Funktionen zur Veränderung der Reihenfolge. Die erste Funktionengruppe kehrt die Reihenfolge der Elemente eines Containers um beziehungsweise führt ein zyklisches Linksschieben durch, bei der das durch den Iterator middle gekennzeichnete Element an die erste Position geschoben wird. void reverse(BidIt first, BidIt last); OutIt reverse_copy(BidIt first, BidIt last, OutIt x); void rotate(FwdIt first, FwdIt middle, FwdIt last); OutIt rotate_copy(FwdIt first,FwdIt middle, FwdIt last,OutIt x)
Die zweite Funktionengruppe ordnet die Elemente nach dem Zufallsprinzip beziehungsweise der durch die Funktion f() gegebenen Reihenfolge an. void random_shuffle(RanIt first, RanIt last); void random_shuffle(RanIt first, RanIt last, Fun& f);
194
4 Die Standard-Template-Library (STL)
Die Veränderung der Reihenfolge kann auch systematisch erfolgen, in dem ausgehend von einer sortierten Folge nacheinander alle Permutationen erzeugt werden. Jeder Aufruf erzeugt eine andere Reihenfolge, der Rückgabewert ist true, wenn die sortierte Reihenfolge wieder erreicht wird. Ein Beispiel zu diesem Algorithmus findet sich im Unterkapitel „Auswahl des richtigen Algorithmus“ bool next_permutation(BidIt first, bool next_permutation(BidIt first, Pred pr); bool prev_permutation(BidIt first, bool prev_permutation(BidIt first, Pred pr);
BidIt last); BidIt last, BidIt last); BidIt last,
Die Elemente werden durch die Algorithmen der dritten Gruppe in zwei Klassen sortiert, wobei die ersten Elemente bis zum Rückgabeiterator die angegebene Relation erfüllen, die weiteren Elemente nicht. Der zweite Algorithmus ändert dabei die relative Position der Elemente in einer Partition nicht, das heißt kommt x vor y im Quellcontainer und befinden sich beide anschließend in der gleichen Partition, so kommt immer noch x vor y . BidIt partition(BidIt first, BidIt last, Pred pr); FwdIt stable_partition(FwdIt first, FwdIt last, Pred pr);
In der vierten Gruppe werden die Elemente mit Hilfe der Relation < (oder einer speziellen Relation) vollständig sortiert, wobei die zweite Teilgruppe Elemente mit gleicher Bewertung in der ursprünglichen relativen Reihenfolge belässt. Die dritte Teilgruppe sortiert nur die Elemente, die kleiner als das angegebene Element middle sind, die vierte Teilgruppe generiert eine Partition um den angegebenen Iterator nth oder die angegeben Relation. void void void void void
sort(RanIt first, RanIt last); sort(RanIt first, RanIt last, Pred pr); stable_sort(BidIt first, BidIt last); stable_sort(BidIt first, BidIt last, Pred pr); partial_sort(RanIt first, RanIt middle, RanIt last); void partial_sort(RanIt first,RanIt middle, RanIt last,Pred pr) RanIt partial_sort_copy(InIt first1, InIt last1, RanIt first2, RanIt last2); RanIt partial_sort_copy(InIt first1, InIt last1, RanIt first2, RanIt last2, Pred pr); void nth_element(RanIt first, RanIt nth,
4.6 Algorithmen und Container
195
RanIt last); void nth_element(RanIt first, RanIt nth, RanIt last, Pred pr);
Eine spezielle Gruppe von Sortieralgorithmen bilden die heap–Algorithmen, die binäre Bäume erzeugen (siehe Sortieralgorithmen, Kapitel 4.6.1). Der Algorithmus make_heap erzeugt das in Kapitel 4.6.1 dargestellte Speicherschema, der Algorithmus sort_heap erzeugt daraus eine Sequenz mit normaler aufsteigender Sortieren. push_heap und pop_heap setzen das Vorliegen einer Heap–Sortierung voraus. push_heap sortiert das letzte Element in den Heap ein, pop_heap kopiert das erste Element an die letzte Position und sortiert den Heap neu. Die Einzelheiten kann der Leser anhand des Schemas in Kapitel 4.6.1 nachvollziehen. void void void void void void void void
push_heap(RanIt first, RanIt last); push_heap(RanIt first, RanIt last, Pred pr); pop_heap(RanIt first, RanIt last); pop_heap(RanIt first, RanIt last, Pred pr); make_heap(RanIt first, RanIt last); make_heap(RanIt first, RanIt last, Pred pr); sort_heap(RanIt first, RanIt last); sort_heap(RanIt first, RanIt last, Pred pr);
Extremalwerte Die folgenden Funktionen ermitteln das kleinste beziehungsweise größte Element in einem Container. Die Methoden min(..) / max(..) sind für die Untersuchung einzelner Elemente implementiert. FwdIt max_element(FwdIt first, FwdIt max_element(FwdIt first, Pred pr); FwdIt min_element(FwdIt first, FwdIt min_element(FwdIt first, Pred pr);
FwdIt last); FwdIt last, FwdIt last); FwdIt last,
In den folgenden vier Methoden wird die Position ermittelt, hinter der oder vor der ein neues Element eingefügt werden kann. equal_range kombiniert beide Operationen und gibt beide Iteratoren in einer pair–Variablen zurück FwdIt lower_bound(FwdIt const FwdIt lower_bound(FwdIt const FwdIt upper_bound(FwdIt const
first, FwdIt T& val); first, FwdIt T& val, Pred first, FwdIt T& val);
last, last, pr); last,
196
4 Die Standard-Template-Library (STL) FwdIt upper_bound(FwdIt first, FwdIt last, const T& val, Pred pr); pair
Mischen von Containern Das Zusammenlegen von Containern kann durch sukzessives Kopieren erfolgen. Sind die Container sortiert, so ist das Ergebnis der folgenden Zusammenführungen ein ebenfalls sortierter Container. Während ein merge– Algorithmus alle Elemente beider Container zusammenführt, behandeln die set–Algorithmen die Container wie Mengen, das heißt gleiche Elemente tauchen in einer Vereinigung nur einmal auf usw. OutIt merge(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x); OutIt merge(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x, Pred pr); void inplace_merge(BidIt first, BidIt middle, BidIt last); void inplace_merge(BidIt first, BidIt middle, BidIt last, Pred pr); OutIt set_union(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x); OutIt set_union(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x, Pred pr); OutIt set_intersection(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x); OutIt set_intersection(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x, Pred pr); OutIt set_difference(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x); OutIt set_difference(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x, Pred pr); OutIt set_symmetric_difference(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x); OutIt set_symmetric_difference(InIt1 first1, InIt1 last1, InIt2 first2, InIt2 last2, OutIt x, Pred pr);
4.6 Algorithmen und Container
197
Die Auswahl des richtigen Algorithmus Damit ist die Liste der Algorithmen komplett. Zu klären bleibt noch, wie die Bedingungen Pred, unter denen die Algorithmen etwas ausführen sollen, implementiert werden. Man kann sich die Frage stellen, ob diese kurzen Erläuterungen überhaupt hinreichend für die Benutzung der STL– Algorithmen sind oder nicht jeweils ausführliche Beispiele sinnvoll gewesen wären. Mit großer Wahrscheinlichkeit werden Sie auch mit diesen kurzen Angaben in der Lage sein, für eine Aufgabe einen oder mehrere geeignete Kandidaten zu ermitteln. Wenn ein Algorithmus erstmalig eingesetzt wird, ist erfahrungsgemäß eine kleine Testreihe, was der Algorithmus macht, was er speziell mit Ihren Daten macht und ob das herauskommt, was Sie erwarten, besser als viele Worte. Ich unterstelle daher, dass unsere kurze Diskussion für eine grundlegende Orientierung ausreicht und Sie Einzelheiten bei Bedarf durch kleine Versuchsimplementationen feststellen, wie im folgenden Beispiel für Permutationsalgorithmen: vector<string> v(0,3) ; vector<string>::iterator it; v[0] = "A" ; v[1] = "B" ; v[2] = "C" ; for(it = v.begin(); it != v.end(); ++it) cout << *it << " " ; cout << endl ; while ( next_permutation(v.begin(), v.end()) ) { for(it = v.begin(); it != v.end(); ++it) cout << *it << " " ; cout << endl; }//endwhile //Programm-Ausgabe: A A B B C C
B C A C A B
C B C A B A
Binäre und unäre Operatoren Nicht in der STL vorhandene Algorithmen können alternativ zu einer klassischen Implementierung auch mit Hilfe der transform–Algorithmen konstruiert werden. Wir greifen die bereits oben angegebenen Algorithmen daher an dieser Stelle nochmals auf und klären nun auch, was mit Pred gemeint war:
198
4 Die Standard-Template-Library (STL) template
Die Funktionen akzeptieren ein oder zwei Eingabeiteratoren und einen Ausgabeiterator sowie eine Operatorfunktion, die die Werte miteinander verknüpft. Da nur von einem der Eingabeiteratoren ein Enditerator zu den Parameter gehört, muss der zweite Eingabecontainer und der Ausgabecontainer, der je nach Operatoraufbau auch einer der Eingabecontainer sein kann, mindestens die gleiche Größe besitzen. Um die Möglichkeiten, die hinter diesem Konzept stecken, erkennen zu können, ist eine genauere Untersuchung der letzten Vorlagenparameter notwendig, die unäre oder binäre Operatoren bezeichnen. Hierfür stellt die STL wiederum eine Reihe von fertigen Operatoren zur Verfügung: // Binäre Funktionen: divides
Die Operatoren sind ihrerseits Spezialisierungen zweier Basisklassen, die vom Anwendungsprogrammierer für die Implementation eigener spezieller Operatoren genutzt werden können, falls das gewünschte nicht in der Liste vorhanden ist: template
4.6 Algorithmen und Container
199
Diese Basisklassen können naturgemäß noch keine eigenen Funktionen ausführen und stellen lediglich Typisierungen der template–Parameter bereit, die für Typkontrollen in komplexeren Methoden verwendet werden. Die Funktionalität wird in den erbenden Klassen mit Hilfe des Klammeroperators operator()(..) implementiert. negate als unäre und plus als binäre Funktion sind damit folgendermaßen spezialisiert: template
Die Ersatzimplementierung für valarray
mit dem formalen Innenleben for(;it1!=end1;++it1,++it2,++ot) *ot=plus(*it1,*it2);
Aufgabe 4.6-4. Geben Sie die formale Implementation für einige der anderen Standardoperatoren an. Das Konzept ermöglicht auch das Durchbrechen einer weiteren Schranke: War es nach der bisherigen Diskussion nur möglich, Iteratoren unterschiedlicher Container, aber gleicher Datentypen zu mischen, zu können nun natürlich auch Operatoren definiert werden, die beliebige unterschiedliche Typen verarbeiten. Aufgabe 4.6-5. Implementieren Sie einen unären Operator, der ein Feld von Vektoren auswertet und ein Feld der Beträge der Vektoren erzeugt.
200
4 Die Standard-Template-Library (STL)
Adapterklassen für komplexe Operationen Mit diesem Satz an Funktionen sind allerdings eine Reihe häufig auftretender Fälle nur unzureichend abgedeckt. Beispielsweise sind die Operationen (*it < 7) // Größenvergleich mit einer Konstanten ... *ito = *it1 * *it1 + *it2 // mehr als 2 Operationen
so nicht umzusetzen und müssen durch eigene Operatoren zu implementiert werden. Dies würde aber bedeuten, dass jede etwas komplexere Anwendung größere Mengen eigener Operationen implementieren müsste, die nur einmal benötigt werden – ein nicht praktikabler Aufwand gegenüber der herkömmlichen Arbeitsweise. Spezielle Adapterklassen beheben dieses Problems (zumindest für einfache Beziehungen). Die beiden Beispiele lassen sich nämlich durchaus mit den vorhandenen Operatoren bearbeiten, wenn im ersten Beispiel eine Konstante anstelle eines Iterators in den Vergleichsoperator eingesetzt wird, im zweiten Beispiel mehrere Operatoren miteinander gekoppelt werden. Für diese unterschiedlichen Aufgaben existieren mehrere Adapterklassen. Die Adapter binder1st und binder2nd erlauben es, binäre Funktionen in Algorithmen einzusetzen, die nur einen Eingabeiterator besitzen. Der zweite für binäre Funktionen notwendige Eingabewert ist eine Konstante, die vom Adapter bereit gestellt wird. Soll beispielsweise das erste Element größer Null einer Sequenz gesucht werden, so wird dem zweiten Argument des Operators greater mit einem Binder der Wert Null zugewiesen: list
Aufgabe 4.6-6. Bevor Sie jetzt weiterlesen, legen Sie bitte eine kurze Pause ein und skizzieren Sie eine Implementation der Klasse binder2nd . Stimmen Ihre Vorstellungen in etwa mit der Implementation überein, die in der STL so aussieht? template
4.6 Algorithmen und Container
201
result_type operator()(const argument_type& x) const; protected: Pred op; Pred::second_argument_type value; };//end class
Ist Ihnen aufgefallen, dass die Klasse binder2nd heißt, der Aufruf in der transform(..)–Methode aber bind2nd geschrieben wird? Zur Vereinfachung der Erzeugung einer Instanz der Binderklasse und zur Typisierung und Typprüfung ist eine Methode implementiert, die einiges an Schreibarbeit einspart: template
Setzen Sie zur Übung nun einmal die Implementation von greater in den Kode ein und lösen Sie die Relationen auf (das ergibt einen guten Einblick in das, womit sich der Compiler zu beschäftigen hat): template
An der Implementation wird übrigens auch der Sinn der Typisierung in den Basisklassen deutlich: Ohne die Typisierung second_argument_type ist es nicht möglich, die Verwendung eines binären Operators als template–Parameter durch den Compiler sicherstellen zu lassen. Auf ähnliche Art werden mehrfache Operationen realisiert. Die Methoden compose1 und compose2 erlauben die Verbindung mehrere unärer oder binärer Methoden in den Klassen composer_1 und composer_2. Der Aufruf list
sucht das erste Element im Bereich 1 x 10 , der Aufruf
202
4 Die Standard-Template-Library (STL) transform(angles.begin(), angles.end(), sines.begin(), compose1(negate<double>(), compose1(ptr_fun(sin), bind2nd(multiplies<double>(), pi/180.))));
berechnet den negativen Sinus von in der Einheit [Grad] gespeicherten Zahlen. Aufgabe 4.6-7. Um auf die Konstruktion von composer_1 und composer_2 aus diesem Beispielkode schließen zu können, analysieren Sie die Mathematik und den Berechnungsweg. Die Lösungen finden Sie bei den Hinweisen zu den Aufgaben. Wir sehen uns hier nun nicht die STL–Konstruktion von composer_n an, sondern konstruieren stattdessen eine eigene Klasse für das Beispiel 2 res x y . Der Rückschluss auf die Konstruktion der composer_n– Klassen dürfte dann kein Problem sein. Hier haben wir einen Algorithmus mit verschachtelten binären und unären Operationen vor uns, die schematisch folgendermaßen aufgespalten werden können res = bin_op( un_op1(x), un_op2(y) ) mit
bin_op un_op1 un_op2
... ... ...
plus square identity
Zunächst definieren wir eine Klasse zur Berechnung des Quadrats, die von unary_function erbt, da mit einem Eingabewert ein Ausgabewert zu erzeugen ist template
Für die Identität konstruieren wir eine entsprechende Klasse, die das Argument unverarbeitet zurückgibt (und vertrauen auf die Fähigkeit des Optimierers, das aus dem Laufzeitkode zu streichen). Des weiteren benötigen wir eine Binderfunktion ähnlich compose2, die zwei unäre Funktionen und zwei Eingabewerte miteinander verknüpft. Sie muss von binary_function erben und eine binäre sowie zwei unäre Funktionen als Attribute übernehmen. Die Templateparameter sind die Eingangstypen der beiden unären Operationen sowie der Ausgangstyp der binären Opera-
4.6 Algorithmen und Container
203
tion. Fassen wir die Überlegungen zusammen, so gelangen wir unter Nutzung der STL–Namensgebung zu der Klassendefinition template
Wie zuvor stellt die Typisierung sicher, dass nur die vorgesehenen Operatorenklassen verwendet werden können. Im Klammeroperator operator ()(..) wird außerdem vom Compiler sichergestellt, dass die Eingangsund Ausgangstypen der Operatoren miteinander kompatibel sind. Für das Erzeugen eines Objektes dieser Klasse konstruieren wir wieder eine Methode, die einen einfacheren Aufruf ermöglicht und sinngemäß bereits oben in den STL–Beispielen genutzt wurde. template
Der Algorithmus wird schließlich implementiert durch vector<double> v1,v2,v3; ... transform(v1.begin(),v1.end(), v2.begin(),v3.begin(), compose3(plus<double>(), square<double>(), identity<double>()));
204
4 Die Standard-Template-Library (STL)
Aufgabe 4.6-8. Falls in Aufgabe 4.6-6 aufgrund der dort vielleicht noch bestehenden Unklarheiten nicht erledigt, vergleichen Sie den Aufruf mit einer Aufrufkonstruktion ohne die Methode compose3(). Aufwandsabschätzung Grundsätzlich lassen sich auf diesem Weg (gegebenenfalls nach Formulierung weiterer nicht in der STL vorhandener compose–Klassen) fast alle Algorithmen formulieren, wenn das Bild vielleicht auch recht ungewohnt ist (schauen Sie sich die Schleife zur Berechnung eines Skalarproduktes in einem valarray noch einmal im Vergleich zur Algorithmusimplementation mit einem vector an). Der Abstand zur mathematischen Formulierung scheint größer zu werden, wenn Methoden und Adapter verwendet werden, gegebenenfalls in iterierter Form wie in einigen der angegebenen Beispiele. Wir wollen daher an dieser Stelle eine Frage stellen, die wir bereits einmal gestellt haben: Lohnt sich die Verwendung dieser Techniken überhaupt ? Wir können diese Frage aus mehreren Gründen in den meisten Fällen mit einem JA beantworten. Sehen wir uns die Gründe genauer an. A. Die meisten der von der STL angebotenen Funktionen scheinen relativ einfach, so dass es dem Programmentwickler anfangs vermutlich leichter fällt, in drei bis sechs Programmzeilen die Funktion direkt zu implementieren als in der STL die passenden Algorithmusfunktionen zu identifizieren und sich mit der ungewohnten Notation herumzuschlagen.81 Da der Programmierentwickler das Innenleben des Containers nicht so detailliert kennt, wie der STL–Entwickler, muss dieser Kode allerdings nicht unbedingt die effizienteste Lösung sein, und auch nicht jeder denkt automatisch an bestimmte Optimierungsschritte. Zwischen „spontanen“ und „state of the art“–Implementierungen können aber durchaus auch Größenordnungen in der Ausführungszeit liegen. Als einfaches Beispiel begutachten Sie bitte die folgenden Implementierungen: container
4.6 Algorithmen und Container
205
// Alternative 2 container
Sofern sich hinter a.end() etwas anderes verbirgt als ein inline–Durchgriff auf ein Attribut, ist die zweite Version vorzuziehen. Zum einen weiß das aber nur der Entwickler der Bibliothek, zum anderen denkt nicht jeder Entwickler immer an die zweite Alternative. Das ist aber nur ein Beispiel. Insgesamt gilt Einfache Optimierungen werden nicht übersehen, der Kode ist effizient. Der Kode ist von vornherein stabil.82 Fehler durch mehrfache Verwendung des gleichen Parameters können nicht auftreten. Auch die Fallen ungültiger Iteratoren nach Einfüge- oder Löschoperationen sowie die Probleme beim Übergang vom inversen zum Vorwärtsiterator werden nicht übersehen. Der Algorithmus ist optimal gewählt. Soll beispielsweise eine Sortierung vorgenommen werden, so kann zwischen einer Vielzahl unterschiedlicher Algorithmen gewählt werden. Wenn sich der optimale Algorithmus auch erst bei genauer Analyse des vorliegenden Problems ergibt, so existieren doch bestimmte Präferenzen für die verschiedenen Containertypen, die auf jeden Fall berücksichtigt sind. Und da man wohl unterstellen kann, dass der Container nach einer Problemanalyse bewusst ausgewählt wurde, ist der automatisch implementierte Algorithmus mit guter Wahrscheinlichkeit mit dem optimalen identisch. Versteckte Optimierungen sind berücksichtigt. Der Bibliotheksprogrammierer kennt alle Eigenschaften des Containers und der Iteratoren und kann daher an Stellen optimieren, die außerhalb der Möglichkeiten eines normalen Programmierers liegen. B. Die Notation unterscheidet sich zwar von mathematischen Formeln, die man in eigenen Algorithmen 1:1 umsetzen könnte, und die Ausdrucke werden teilweise recht lang. Verwendet man bei längeren Schachtelungen in einem Algorithmus jedoch eine strukturierte Schreibweise, wie sie für die Anweisungen im Kode ebenfalls Verwendung finden, so lässt sich feststellen Der Kode wird nicht unübersichtlich, sondern ist unmittelbar mit der Theorie vergleichbar.
82 Diese Aussage bezieht sich natürlich nicht auf rechnerische Instabilitäten.
206
4 Die Standard-Template-Library (STL)
Der Kode wird programmtechnisch gesehen sogar kürzer (eine Anweisungszeile gegenüber einer for–Schleife mit mehreren Anweisungen) und aussagekräftiger, da der Name des Algorithmus bereits im Klartext aussagt, was passieren wird. All das erleichtert die Revision. Durch die durchgehende Verwendung von inline–Anweisungen kann der Übersetzer hocheffizienten Kode erzeugen, der in der Effizienz direkt notierten Formeln nicht nachsteht. Wenn Sie in der STL (noch) nicht vorhandene Algorithmen implementieren (da die STL laufend ergänzt wird, empfiehlt sich bei Bedarf zunächst ein Blick auf die allerneueste Version) und sich an die vorgestellten Konstruktionsprinzipien halten (und Ihr Optimierer korrekt funktioniert), entsteht bei der Implementation von Operatoren hocheffizienter Kode. An einigen Stellen können auch komplexere Ergänzungen notwendig werden, beispielsweise durch eine transform–Methode, die drei oder mehr Eingangsiteratoren verarbeitet. Da Sie die Details der Containerimplementationen nicht kennen, wird Ihre Implementation aber immer relativ einfach bleiben und möglicherweise nicht das Optimum darstellen. Ein Beispiel Geben wir zum Abschluss noch ein Beispiel zur praktischen Demonstration dieser Aussagen. Es sei eine Sequenz von Werten gegeben (beispielsweise durch Einlesen aus einer Datei), zu denen eine Konstante addiert werden soll und die anschließend ohne Änderung der Reihenfolge an den Beginn einer bereits existierenden zweiten Sequenz des Typs deque geladen werden sollen (push_back(..) kommt also nicht in Frage). Ohne dass das eine ehrenrührige Bemerkung sein soll, können wir wohl unterstellen, dass unser Musterentwickler die Containerwerkzeuge (nur) so weit beherrscht, wie das in den letzten Wochen seiner Arbeit notwendig gewesen ist, und sich ihrer zunächst intuitiv zu bedienen versucht. Ein erster Versuch könnte dann durchaus so aussehen: deque
Das funktioniert zwar zunächst, jedoch stellt er bei einer anschließenden Prüfung fest, dass die Elemente in der umkehrten Reihenfolge im Container gelandet sind. Also startet er einen zweiten Versuch:
4.6 Algorithmen und Container
207
deque
Sieht gut aus, funktioniert aber gar nicht! Haben Sie den Fehler erkannt? Genau, nach der Einfügeoperation ist der Iterator nicht mehr gültig, und sowohl Inkrementieren als auch die Nutzung im nächsten Aufruf gehen daneben. Der nächste Versuch funktioniert nun allerdings, nachdem sich unser Musterentwickler die Methode insert noch einmal genau angesehen hat: for(i=0,it=d.begin();i
Als Algorithmus geht das allerdings noch eleganter. Dabei setzen wir zunächst voraus, dass die Werte (mittels einer for–Schleife) in ein Feld data[n] einlesen worden sind – eine Operation, bei der auch unser Musterentwickler wahrscheinlich keine Fehler machen kann. Das Feld selbst kann als einfacher Container mit Zeigern auf die Elemente als Iteratoren aufgefasst werden, die Addition der Konstanten können wir durch Binder und binäre Methoden erledigen. Für die gesuchte Einfügeoperation finden wir in der STL die Adapterklasse template
die über die Hilfsfunktion inserter initialisiert wird und genau das macht, was wir hier benötigen: Das Einfügen von Elementen in eine Aus-
208
4 Die Standard-Template-Library (STL)
gabe–Sequenz unter Beibehaltung der Reihenfolge. Unser Problem wird nun durch den Algorithmus T dat[n]; ... transform(dat,dat+n, inserter(d,d.begin()), bind2nd(plus
gelöst. Um zu dieser Implementation zu gelangen, ist sicher auch etwas Aufwand notwendig (schließlich haben wir erst eine weitere Adapterklasse ausfindig machen müssen), aber wenn wirklich so viel schief laufen sollte, wie im Beispiel angegeben, hätte sich der Aufwand der Suche in der Bibliothek sicher gelohnt.
4.7 Hinweise zu den Aufgaben Aufgabe 4.1-1. Der mathematische Nachweis ist rekursiv: Im ersten Schritt liegt 1 r n 1k r nq n 1 , k 1 vor. Setzt man hier r n aus der vorhergehenden Gleichung ein, so bleibt die Form der Gleichung erhalten. Das kann fortgesetzt werden, bis a , b die Stelle der Rest eingenommen haben. Der erweiterte Algorithmus kann mathematisch mit Hilfe zweier Vektoren folgendermaßen formuliert werden:
b Startwerte: W 0 1 , W 1 0 Iteration: q k W k 1,1 W k ,1
a 0 1 , W k 1W k
1
q kW k
Den Rest einschließlich Probe überlasse ich Ihnen. Aufgabe 4.2-1. Der auszuschließende Operator oder die auszuschließende Methode (das kann auch bestimmte Konstruktoren betreffen) wird in einem private– oder protected–Bereich der Klassenvereinbarung definiert. Beachten Sie, dass die Compiler „freundlicherweise“ einige Konstruktoren oder Methoden für Sie implementieren, wenn diese nicht in der Klassenvereinbarung enthalten sind. Nicht in der Definition heißt also nicht „nicht existent“. Was auszuschließen ist, sollte daher auf jeden Fall im privaten oder geschützten Bereich vereinbart werden. Aufgabe 4.2-2. Eine „kleiner als“–Relation wird benötigt, um im Container nach aufsteigenden Schlüsseln zu sortieren, allerdings betrifft das nur den Schlüssel, also das Attribut first. Was sich hinter dem Attribut
4.7 Hinweise zu den Aufgaben
209
second verbirgt, muss aber eine solche Relation gar nicht berücksichtigen
(während eine Gleichheitsrelation immer existiert). Es macht also eigentlich keinen Sinn, eine solche Relation für den allgemeinen Zugriff zu implementieren. Aufgabe 4.3-1. Die Segmente werden mit eine festen Länge angelegt. Die Objekte in einem Segment sind zusammenhängend, jedoch muss diese Kette nicht an einem der Enden beginnen. Die Strukturen können also folgendermaßen angelegt werden: struct Segment { int start, ende; T * obj; };//end struct class Container { ... int n_seg; Segment * seg; };//end class
Um das n-te Objekt zu finden, müssen die Anzahlen der Objekte in den Segmenten untersucht werden (ohne Indexkontrolle): T& operator[](int n){ int i; for(i=0;i
Aufgabe 4.3-2. Konstruktor und Destruktor können folgendermaßen implementiert werden: class Container { public: Container(){ start=0; }; ~Container(){ if(start!=0) delete start;}; private: List * start, * end; }//end class struct List { ... ~List(){ if(next!=0) delete next; } List * last, *next; }//end struct
210
4 Die Standard-Template-Library (STL)
Jeweils einer der beiden Zeiger ist Eigentümer eines Speicherbereichs, der zweite zeigt nicht auf eigenen Speicher (siehe Kapitel 3.2). Aufgabe 4.3-3. Bei dieser Aufgabe möchte ich auf Beispielkode verzichten, da doch eine Reihe von Methoden zusammenkommt und der Umfang hier zur groß würde. Gehen Sie zweckmäßigerweise folgendermaßen vor: Beschränken Sie sich zunächst auf eine Struktur, die nur den Schlüssel enthält (Schlüssel = Objekt). Sehen Sie von vornherein drei Einträge vor, um den Überhang aufnehmen zu können. Bauen Sie den Baum doppelt verkettet auf, das heißt sehen Sie in jedem Knoten einen Zeiger auf den darüber liegenden vor, so dass Sie sich in beiden Richtungen im Baum bewegen könne. Aufgabe 4.3-5. Der erste Schritt besteht im Auffinden der Istposition im Baum. Der Rest kann den Knotenobjekte überlassen werden. Die folgende Methode liefert den neuen Schlüssel zurück, wobei sie im Baum aufwärts und abwärts springt. Analysieren Sie am besten selbst: Key Node::first(){ if(less!=0) return less->first(); else return k1; }//end function Key inc(Key key){ if(key
Aufgabe 4.4-1. Im Standardfall ist eine der alten Objektanzahl entsprechende Anzahl neuer Objekte zu konstruieren, der Inhalt zu kopieren (gegebenenfalls per Kopierkonstruktor), anschließend sind die alten
4.7 Hinweise zu den Aufgaben
211
Objekte per Destruktor zu Löschen. Die memcpy(..)–Funktion ist nicht durchzuhalten, wenn zwischen den Objekten im Container Zeigerbeziehungen bestehen oder bidirektionale Zeigerbeziehungen zu Objekten außerhalb des Containers bestehen. Solche Beziehungen können nur durch die Objekte selbst kontrolliert werden und werden beim Einsatz bloßen Speicherkopierens zerstört. Aufgabe 4.4-2. Im Falle von (verketteten) Listen können die Objekte direkt initialisiert werden, da Listenelemente erst dann erzeugt werden, wenn sie benötigt werden, eine Kapazitätsreserve also nicht existiert. Eine Implementation kann dass so aussehen: template
Bei Bäumen mit mehreren Verweisen pro Knoten ist auf die für lineare Felder beschriebenen Techniken zurückzugreifen, da nicht alle Knoten vollständig besetzt sind. Bei binären Bäumen mögen Sie beim derzeitigen Kenntnisstand auf den Gedanken kommen, dies ähnlich wie Listenelemente zu behandeln. Binäre Bäume können jedoch in einem linearen Feld gespeichert werden, weshalb wir diese Diskussion noch ein wenig verschieben. Aufgabe 4.5-1. Auf ein indirektes Feld könnte mit return (*orig)[index[pos]]
zugegriffen werden, ein maskiertes Feld erfordert eine Abzählschleife for(i=0,j=0;j<pos;++i) if(index[j]) ++j; return (*orig)[j];
Der Konjunktiv beruht darauf, dass mir noch keine Implementation untergekommen ist, die einen indizierten Zugriff auf einem Unterfeld zulässt. Aufgabe 4.5-2. Mit Indexoperatoren ist eine optimale Lösung sum=0; for(i=o;i
Hierbei wird weder eines der Felder verändert noch wird ein temporäres Feld, das ja zunächst einmal erzeugt und hinterher wieder vernichtet
212
4 Die Standard-Template-Library (STL)
werden muss, für Zwischenergebnisse benötigt. Zumindest für Standarddatentypen ist diese Implementation effizienter; bei komplexeren Datentypen lässt sich die hier immer noch notwendige temporäre Variable ebenfalls vermeiden. Die Hintergründe dazu kommen jedoch erst in Kapitel 9 zur Sprache. Aufgabe 4.5-4. Eine Iteratorklasse sollte möglichst schlank gehalten werden. Neue Parameter an den Segmenübergängen können vom Bezugscontainer nachgeladen werden. Sinngemäß führt das zu einer Implementation der Art: template
Aufgabe 4.5-5. Das Zusammenführen lässt sich beispielsweise durch wechselseitiges Anfügen der alten Listen in einer neuen erledigen: void list::merge(list
4.7 Hinweise zu den Aufgaben
213
_first=kn; }//end function
Aufgabe 4.6-2. Ich gebe hier einen Algorithmus für das Verschieben an: // Parameter: a[.] .. Feld mit Indexzugriff // k .. Position im Feld // size .. Größe des Felder while(true){ if(k%2==1){ if(a[k]>a[k/2]){ swap(a[k],a[k/2]); k=k/2; continue; }//endif }else ... if(2*k<size && a[k]
Aufgabe 4.6-3. Das Speichermodell eines binären Baumes ist gegenüber dem Modell eines mehrwertigen Baumes so elegant, dass man im ersten Moment versucht ist, das zweite Modell nicht weiter zu beachten. Binäre Bäume sind insbesondere dann effektiv einsetzbar, wenn der komplette Baum im Speicher abgelegt werden kann. Bei sehr großen Datenmengen oder sehr großen Objekten sind aber nur Teile eines Gesamtbaumes einlesbar (wenn auch die Größenordnungen aufgrund der heute verfügbaren Speichergrößen schon recht beeindruckend sind). In diesem Fall sind mehrwertige Bäume aufgrund der geringeren Anzahl an Plattenzugriffen günstiger. Auf einem Blatt eines mehrwertigen Baumes kann ebenfalls wiederum binär gesucht werden. Im Datenbanksystemen wird ein mehrstufiger Mix verschiedener Strategien eingesetzt, um die notwendigen Durchsätze bei komplexeren Abfragen zu erreichen. Aufgabe 4.6-5. Benötigt wird hierzu eine unäre Funktion: template
214
4 Die Standard-Template-Library (STL) sum+=(*it * *it); return sum; }//end }//end vector
Man kann den Aufwand noch weiter treiben und die Datentypen der Vektoren und des Ergebnisses trennen. Vermutlich sind dann aber auch noch weitere Griffe in die mathematische Theorie notwendig, um das ganze sinnvoll zu halten. Aufgabe 4.6-7. Im ersten Beispiel soll der Algorithmus abbrechen, sobald das erste x mit 0 x 10 gefunden ist. find_if ist ein Algorithmus mit nur einem Iterator und bedient eine unäre Funktion. Die Klasse compose2 „verdoppelt“ den Iterator gewissermaßen, um ihn in zwei unären Operationen statt nur eine einsetzen zu können. Das Ergebnis dieser beiden unären Operationen sind zwei Werte, die durch eine binäre Operation wieder zu einem unären Wert verdichtet werden müssen. Schematisch result = bin_op( un_op1(arg), un_op2(arg) )
Im Beispiel sind die unären Operationen durch bind2nd fixierte binäre Operationen. template