Einleitung
Im ersten Teil des Tutorials haben wir gesehen, wie eine einfach verkettete Liste aufgebaut ist und welche Nachteile sie besitzt.
In diesem Abschnitt werden wir diese Nachteile eliminieren und so eine brauchbare Liste bekommen, die sich sehen lassen kann. Um mehr Flexibilität zu erreichen, werden wir auf das Konzept der Template-Klassen zurückgreifen. Grundkenntnisse der Templates sind also eine Voraussetzung um dieses Tutorial zu verstehen, allerdings gibt’s hier eine Minieinführung zu diesen interessanten Themengebiet.
Fangen wir gleich damit an.
Template – Klassen
Mit Hilfe von Template-Klassen lässt sich ein Datentyp in einer Klasse variieren ohne, dass dafür die Klassendeklaration geändert werden muss.
Wenn eine neue Instanz der Liste erstellt werden soll, so muss nur angegeben werden, welchen Datentyp die Liste verwalten soll.
Ein Beispiel einer Template-Klasse.
template <typename T > class Classname { public: Classname (void); ~ Classname (); void setData(const T &data); const T &getData(void) const; private: T data; };
Die Zeile template < typename T > sagt dem Compliler, dass es sich um eine Template-Klasse handelt und dass der frei wählbare Datentyp „T“ heißt. Das „T“ hätte genauso gut „Datentyp“ heißen können, denn der Typname ist frei wählbar und die Namensgebung erfolgt nach den gleichen Regeln wir bei Variablen. Allerdings ist die Bezeichnung „T“ etwas wie ein Standard geworden und ich werde hier im Weiteren auch „T“ verwenden.
Die Implementierung der Methoden hat auch eine eigene Syntax.
// Konstruktor template < typename T > Classname <T>:: Classname (void) { } // Destruktror template < typename T > Classname <T>::~ Classname () { } // Setzt Daten template < typename T > void Classname <T>::setData(const T &data) { this->data = data; } // Liest Daten template < typename T > const T &Classname<T>::getData(void) const { return data; }
Die Schreibweise ist gewöhnungsbedürftig aber nicht weiter schwierig und erfolgt immer nach dem gleichen Schema.
template < typename T> Rückgabetyp KlassenName<T>::Methodenname(...)
Das Erstellen einer Instanz.
// eine Instanz für die Verwaltung von int-Variablen erstellen TemplateKlasseListe<int> listeFuerInt; // eine Instanz für die Verwaltung von float-Variablen erstellen TemplateKlasseListe<float> listeFuerFloat;
Und noch was sehr wichtiges. Normalerweise ist man in C++ gewöhnt, Klassendeklaration in *.h-Dateien und Klassenimplementierung in *.cpp-Dateien auszulagern. Dies geht mit Template-Klassen nicht. Alles muss in einer *.h-Datei liegen oder man lagert die Implementierung in *.inl-Dateien aus.
Ich werde alles in liste.h schreiben.
Mit diesem Wissen können wir die Listenimplementierung aus dem ersten Tutorial als eine Template-Klasse schreiben.
Einfach verkettete Liste als Template-Klasse
Wir schreiben die Klasse der einfach verketteten Liste als Template. Hier sollte für Sie nichts Neues zu finden sein, denn es handelt sich dabei um altbekannte Methoden und Attribute aus dem ersten Teil des Artikels.
// Grundgerüst template<typename T> class Liste { private: // Definition eines Listenelements class Listenelement { public: // Konstruktor Listenelement(T daten) { this->daten = daten; nachfolger = NULL; } // Zeiger auf den Nachfolger Listenelement *nachfolger; // Das sind die Daten die wir verwalten wollen ( Datenbereich) T daten; }; // Listenkopf Listenelement* kopf; // Listenende Listenelement* ende; public: // Konstruktor Liste(void); // Destruktor ~Liste(void); // einen Film in die Liste einfügen void hinzufuegen(T daten); // prüft ob die Liste leer ist bool istLeer(void) { return (kopf == NULL) ? true : false; } // Liste löschen void loeschen(void); }; // Konstruktor template<typename T> Liste<T>::Liste(void) { kopf = ende = NULL; } // Destruktor template<typename T> Liste<T>::~Liste() { // beim zerstören der instanz, die liste immer leeren loeschen(); } // einen Film in die Liste einfügen template<typename T> void Liste<T>::hinzufuegen(T daten) { // Ein neues Listenelement erstellen und mit 'daten' initialisieren Listenelement *neuesListenelement = new Listenelement(daten); // liste ist leer if(istLeer()) ende = kopf = neuesListenelement; else // am ende der Liste einfügen { // das letzte Element zeigt auf das neue Element ende->nachfolger = neuesListenelement; // das neue Element wird zum Letzten ende = neuesListenelement; } } // Liste löschen template<typename T> void Liste<T>::loeschen(void) { if(istLeer()) return; // solange der Zeiger nicht Null ist, also noch Elemente vorhanden sind... while(kopf->nachfolger != NULL) { // ...suche das vorletzte ELement Listenelement *vorletztesElement = kopf; while(vorletztesElement->nachfolger != ende) { vorletztesElement = vorletztesElement->nachfolger; } // lösche das letzte Element delete ende; // das vorletzte Element wird zum Letzten vorletztesElement->nachfolger = NULL; ende = vorletztesElement; } // zuletzt noch den Listenkopf löschen delete kopf; kopf = NULL; }
Die einzige Neuerung ist die Methode istLeer(), die überprüft ob die Liste leer ist oder nicht.
Wie man sieht besitzt die aktuelle Implementierung keine Methode um auf die Daten zuzugreifen. Im ersten Teil haben wir direkt auf die Datenstruktur zugegriffen. Jetzt können wir das nicht machen, da wir nicht wissen, welche Daten in der Liste verwaltet werden.
Unser Ziel ist es einige Methoden zu implementieren, so dass folgender Code funktioniert.
Liste<Film> filme; filme.hinzufuegen(film); filme.hinzufuegen(film1); filme.hinzufuegen(film2); for(Liste<Film>::Iterator it = filme.start(); it != filme.postende(); it++) { std::cout << "Film: " << it.gibDaten().titel.c_str() << std::endl; }
Dabei greifen wir auf das Konzept von Iteratoren zurück. Ein Iterator stellt ein Zeiger auf das aktuelle Listenelement dar und besitzt Methoden um sich in der Liste zu bewegen.
Der obere Code bedeutet also folgendes: Erstelle einen Iterator für eine Liste mit Film-Elementen. Initialisiere ihn mit dem ersten Listenelement. So lange der Iterator nicht auf das Element nach dem letzten Listenelement zeigt, bewege ihn ein Element weiter.
Da der Iterator in jedem Durchlauf auf ein anderes Element zeigt, können wir so die ganze Liste durchlaufen.
Die Implementierung der Iteratorklasse beschränkt sich auf die wichtigsten Methoden.
Der Iterator kommt als private Klasse in die Listenklasse rein.
// Iterator für die Liste class Iterator { protected: // Zeiger auf das aktuelle Listenelement Listenelement *iter; public: // der Listeklasse erlauben auf protected-Elemente zuzugreifen friend Liste; // Konstruktor Iterator(void) : iter(NULL) {} Iterator(Listenelement* le) : iter(le) {} // Zuweisungsoperator void operator=(Listenelement<T>* le) { iter = le; } // Vergleichsoperator bool operator!=(Listenelement<T>* le) { if(NULL != iter && iter!=le) return true; else return false; } // Inkrementierungsoperator void operator++ () { if(iter != NULL) iter = iter->nachfolger; } // Zugriff auf den Datenbereich T& gibDaten(void) { return iter->daten; } };
Ich habe hier die Operatorenüberladung benutzt. Genauso so gut könnte man normale Methoden verwenden und zum Beispiel den Inkrement-Operator als next()-Methode implementieren.
Als nächstes brauchen wir noch zwei kleine Methoden in der Listen-Klasse, die das erste Element und das Element nach dem Listenende zurückgeben.
// gibt Startknoten zurück Listenelement* start(void) { return kopf; } // gibt das Element nach dem Endelement zurück Listenelement* postende(void) { if(ende==NULL) return NULL; else return ende->nachfolger; }
Somit wäre unsere einfach verkettete Liste als Template-Implementierung komplett.
Das war ja hartes Stückchen Arbeit. Doch die Mühe hat sich gelohnt, denn jetzt haben wir eine ziemlich flexible Liste, die mit verschiedensten Datentypen zurechtkommt.
Ein Beispiel zur Verwendung der Liste.
// Liste für int-Werde erstellen Liste<int> int_liste; std::cout<<"Liste erstellt."<<std::endl; // Liste mit Daten füllen for(int i = 0; i<1000; i++) { int_liste.hinzufuegen(i); } std::cout<<"Liste gefuellt."<<std::endl; // Liste durchlaufen for(Liste<int>::Iterator it = int_liste.start(); it != int_liste.postende(); it++) { std::cout<<it.gibDaten()<<std::endl; } std::cout<<"Daten ausgegeben."<<std::endl; // Liste löschen (optional) int_liste.loeschen(); std::cout<<"Liste geloescht"<<std::endl;
Perfekt oder? Nicht ganz! Schon bei diesem einfachen Beispiel merkt man, dass die Geschwindigkeit nicht besonders rasant ist. Um sich einen Eindruck über die Geschwindigkeit der Liste zu verschaffen haben, habe ich folgenden Code verwendet.
unsigned int anzahl_elemente = 100000; unsigned int zeit = 0; // Liste - test // Wegen timeGetTime() muss winmm.lib eingebunden werden zeit = timeGetTime(); Liste<int> liste; for(unsigned int i = 0; i<anzahl_elemente; i++) liste.hinzufuegen(i); for(Liste<int>::Iterator iit = liste.start(); iit != liste.postende(); iit++) { int test = iit.gibDaten(); } liste.loeschen(); std::cout<<"zeit: "<< (timeGetTime()-zeit) << std::endl; // std::liste test zeit = timeGetTime(); std::list<int> std_liste; for(unsigned long i = 0; i<anzahl_elemente; i++) std_liste.push_back(i); std::list<int>::const_iterator sit; for(sit = std_liste.begin(); sit != std_liste.end(); sit++) int test = *sit; std_liste.clear(); std::cout<<"zeit: "<< (timeGetTime()-zeit) << std::endl; std::cout<<"ENDE"<<std::endl;
Die Liste wird also mit Werten gefüllt, dann durchgelaufen und zum Schluss geleert.
Der Test wurde mit 20000, 40000, 60000, 80000 und 1000000 Elementen ausgeführt. Das Ergebnis wurde in einem Plot dargestellt.
Im Gegensatz zur std::list, die eine lineare Laufzeitcharakteristik besitzt, skaliert unsere Liste in quadratischer Ordnung.
Den Flaschenhals haben wir schon im ersten Teil des Artikels ausgemacht – die Methode loeschen().
Ich habe die Anwendung durch einen Profiler überwachen lassen. Das Resultat ist äußerst beeindruckend.
Demnach ist das Programm über 99% der Ausführungszeit mit der Methode loeschen() beschäftigt. Eine Optimierung muss her.
An dieser Stelle erweitern wir unsere Listenelement-Klasse um einen Zeiger auf das Vorgänger-Element.
Doppelt verkettete Liste
Der Sprung von einfach zu doppelt verketteter Liste ist relativ einfach. Die einzige Neuerung besteht darin, dass ein Listenelement nicht nur einen Zeiger besitzt, sondern zwei. Der eine Zeiger zeigt wie gehabt auf das nächste Element und der zweite auf das Vorherige.
Eine schematische Darstellung einer doppelt verketteten Liste mit vier Elementen.
Diese kleine Datenstrukturerweiterung bringt Vorteile beim Durchlaufen der Liste.
Man kann sich jetzt nicht nur vorwärts, sondern auch rückwärts in der Liste bewegen.
Genau das werden wir uns von Nutzen machen und die Methode loeschen() beschleunigen.
Aber zuerst müssen wir den zweiten Zeiger und damit verbundene Änderungen implementieren.
Die Klasse Listenelement wird um einen Zeiger erweitert.
// Zeiger auf den Vorgänger Listenelement* vorgaenger;
Damit wir mit diesem Zeiger auch arbeiten können, muss er auf ein gültiges Listenelement verweisen. Dafür sorgen wir in der Methode hizufuegen(..). Das Ganze beschränkt sich auf eine Zeile gleich nach der Erzeugen eines neuen Elements.
// das letzte Element zeigt auf das neue Element ende->nachfolger = neuesListenelement; // NEU: das aktuelle Ende wird zum Vorgänger des neuen Elements neuesListenelement->vorgaenger = ende; // das neue Element wird zum neuen Ende ende = neuesListenelement;
Da das neue Element an das Ende drangehängt wird, wird es das letzte Element in der Liste sein. Dadurch wird das momentan letzte Element automatisch zum Vorletzten.
Als nächstes werden wir die Methode loeschen() überarbeiten.
Dazu ändern wir den Algorithmus wie folgt ab. Der Codeabschnitt …
// ...suche das vorletzte ELement Listenelement *vorletztesElement = kopf; while(vorletztesElement->nachfolger != ende) { vorletztesElement = vorletztesElement->nachfolger; }
wird ersetzt durch
// Nimm das vorletzte Element Listenelement *vorletztesElement = ende->vorgaenger;
Die Suche nach dem vorletzten Element wird uns somit erspart, was dazu führt, dass das Löschen viel schneller geht.
Das war’s, alles andere bleibt unverändert.
Das Laufzeitverhalten sieht jetzt folgendermaßen aus.
Ein lineares Laufzeitverhalten und somit optimal. In O-Notation: Einfügen in O(1), Durchlaufen in O(n), Löschen in O(n).
Was jetzt noch fehlt ist etwas mehr Benutzerfreundlichkeit.
Zuerst erweitern wir die Iteratorklasse um einen Dekrement-Operator. Mit dieser Methode können wir uns dann in der Liste rückwärts bewegen.
// Dekrementierungsoperator void operator-- () { if(iter != NULL) iter = iter->vorgaenger; }
Als letzten Schritt schauen wir uns an, wie man Listenelemente löscht.
Listenelemente löschen
Es sind vier Fälle zu unterscheiden.
Wenn die Liste nur aus einem Element besteht, so löscht man dieses Element und setzt alle Zeiger auf null.
Besteht die Liste aus mehreren Elementen so ist zu unterscheiden, an welcher Position gelöscht wird.
Das erste Element löschen:
Das zweite Element wird zum Listenkopf.
Über den Vorgänger-Zeiger wird der alte Listenkopf gelöscht.
Anschließend wird der Vorgänger-Zeiger auf null gesetzt.
Das letzte Element löschen:
Die Vorgehensweise geht analog wie beim Entfernen des ersten Elements.
Das vorletzte Element wird zum Letzten erklärt.
Das letzte Element wird über den Nachfolger-Zeiger von Listenende gelöscht.
Der Nachfolger-Zeiger wird auf null gesetzt.
Listenelement in der Mitte löschen:
Angenommen es wird das zweite Element gelöscht.
Zuerst wird der Nachfolger-Zeiger des Vorgängers so umgeleitet, dass er auf den Nachfolger zeigt.
Danach wird der Vorgänger-Zeiger des Nachfolgers so umgeleitet, dass er auf den Vorgänger zeigt.
Da der Zeiger auf das zweite Element zwischengespeichert wurde, wissen wir wo es sich befindet. Das zweite Element wird gelöscht.
Diese vier Fälle werden nacheinander in der vorgestellten Reihenfolge abgearbeitet.
// Element löschen template<typename T> void Liste<T>::loeschen(Iterator& it) { // unterscheiden wo sich das zu löschende element befinden // das einzige element in der Liste if(kopf->nachfolger==NULL) { delete kopf; kopf = ende = NULL; } else if(it==kopf) // das erste element löschen { // das erste element wird zum ersten kopf = kopf->nachfolger; // lösche das alte erste element delete kopf->vorgaenger; kopf->vorgaenger = NULL; } else if(it==ende) // das letzte element löschen { // das vorletzte element wird zum letzten ende = ende->vorgaenger; // lösche das alte letzte element delete ende->nachfolger; ende->nachfolger = NULL; } else // ein element in der mitte der liste { // der vorgänger muss auf den nachfolger zeigen it.iter->vorgaenger->nachfolger = it.iter->nachfolger; // das nachfolger muss auf den vorgänger zeigen it.iter->nachfolger->vorgaenger = it.iter->vorgaenger; // das aktuelle element löschen delete it.iter; } }
Übergeben wird der aktuelle Iterator. Will man zum Beispiel das zweite Element löschen so geht man wie folgt vor.
it = filme.start(); it++; filme.loeschen(it);
Das Ändern von Elementen geht ähnlich.
it = filme.start(); it++; it.gibDaten().titel = "Bla bla";
Der ganze Code als Download: Doppelt verkettete Liste
Fazit
Im Laufe dieses Tutorials haben wir uns eine flexible und schnelle Liste geschrieben, die über die wichtigsten Funktionen verfügt. Das Erweitern und Ausbauen ist jedem selbst überlassen.
Die von mir vorgestellte Implementierung soll nur die Funktionsweise einer dynamischen Liste aufzeigen, so dass man eine Vorstellung bekommt, was darunter zu verstehen ist.
Für den produktiven Einsatz empfehle ich die Verwendung von std::list. Diese Implementierung ist nicht nur schnell, sondern auch besonders sicher.
Viel Spaß beim Programmieren =)
Lieber Tutorialersteller,
vielen Dank für diese sehr ausführliche Erläuterung!
Eine Frage hätte ich dennoch: angenommen man würde versuchen, via Konsoleneingabe einen weiteren Film an das ListenEnde bzw. sogar an den Listenanfang zu setzen, wie würde man da genau vorgehen.
Könntest du mir vielleicht für einen der beiden Fälle den einzubindenden Code erläutern?
Vielen Dank für deine Hilfe!
Hallo,
das ist relativ einfach. Zuerst musst du natürlich zuerst eine Filmliste und einen Film erstellen.
Liste< Film > filmliste;
Film neuerFilm;
Dann die nötigen Daten abfragen. Hier als Beispiel nur mit dem Filmtitel.
std::cout << "Geben Sie den Titel des Films ein." << std::endl;
std::cin >> neuerFilm.titel; // dafür #include nicht vergessen
Jetzt muss der neue Film nur in die Liste kopiert werden.
filmliste.hinzufuegen(neuerFilm);
Wenn du mehrere Filme eingeben willst, musst du das in einer Schleife packen. Schau dazu meinen C++ Tutorial zu Schleifen an.
Hallo Maxim,
ein wirklich gelungenes Tutorium, aber ich habe zwei Fragen:
1. Wo genau muss ich bei der einfach verk. Liste die zwei kleinen Methoden start() und postende() einfügen?
2. Wie könnte ich den Zugriff auf die Liste alternativ lösen, wenn ich nicht die komplette Liste durchgehen möchte sondern z.B. ein aktuelles Element habe und auf dieses oder den Nachfolger zugreifen möchte?
Vielen Dank im voraus!
Hallo Leif,
1. Die kommen direkt in die Klassendeklaration, also in die Headerdatei. Alternativ kann man sie auch in CPP-Datei ablegen, aber da die Methoden so klein sind, lohnt es sich eigentlich nicht.
2. Du brauchst die Position deines Element in der Liste. Da die Liste mit Iteratoren arbeitet, brauchst du also einen Iterator auf dein Element.
z.B. hast du den Iterator
Liste::Iterator it
, der auf dein Element zeigt, dann kannst du mitit++;
zum nächsten Element gehen und mitit.gibDaten();
auf die Daten zugreifen.Wenn du einen direkten Zugriff haben willst, dann ist eine Liste die falsche Datenstruktur. Du brauchst einen dynamischen Array, der in C++ durch std::vector realisiert wird.
Hallo Maxim, wie kann in der Liste Klasse eine find(start,postende, zu_suchender_Film)
implementieren ?
Hallo Maxim.
Wie kann ich denn auf den Iterator außerhalb der Listenklasse zu greifen, wenn dieser als private Klasse in der Listenklasse ist?
Wenn ich das richtig verstehe, sollte man dann ja nicht von außerhalb so einen Iterator anlegen können oder?
LG Hans