Dynamische Datenstrukturen – Doppelt verkettete Liste

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.

stdlist_vs_liste

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.

liste profiling

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.
doppelt verkettete liste

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.
stdlist_vs_liste2

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.
listenelement loeschen

Über den Vorgänger-Zeiger wird der alte Listenkopf gelöscht.
listenelement loeschen

Anschließend wird der Vorgänger-Zeiger auf null gesetzt.
listenelement loeschen

Das letzte Element löschen:

Die Vorgehensweise geht analog wie beim Entfernen des ersten Elements.

Das vorletzte Element wird zum Letzten erklärt.
listenelement_loeschen21

Das letzte Element wird über den Nachfolger-Zeiger von Listenende gelöscht.
listenelement_loeschen22

Der Nachfolger-Zeiger wird auf null gesetzt.
listenelement_loeschen23

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.

listenelement_loeschen32

Danach wird der Vorgänger-Zeiger des Nachfolgers so umgeleitet, dass er auf den Vorgänger zeigt.
listenelement_loeschen33

Da der Zeiger auf das zweite Element zwischengespeichert wurde, wissen wir wo es sich befindet. Das zweite Element wird gelöscht.
listenelement_loeschen34

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 =)

6 Gedanken zu „Dynamische Datenstrukturen – Doppelt verkettete Liste“

  1. 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!

  2. 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.

  3. 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!

  4. 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 mit it++; zum nächsten Element gehen und mit it.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.

  5. 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

Schreibe einen Kommentar