Algorithmen und Datenstrukturen

Autor: Andre Adrian
Version: 24.Juli.2015

Einleitung

Liebe Erstsemester Studenten der Informatik! Wahrscheinlich haben Sie eine Vorlesung mit dem Namen "Algorithmen und Datenstrukturen". Und wahrscheinlich liegt die Durchfallquote in der Klausur zwischen 70% und 80%. Ohne Lernwillen und Fleiß werden Sie die Klausur niemals schaffen und ihr Informatik-Studium ist schnell zu Ende. Diese Internet-Seite möchte Ihnen beim Lernen helfen. Sehen Sie es als eine Vorbereitung auf die Vorlesung an. Warum will ich ihnen helfen? Ich bin selbst Informatiker und habe mein Studium 1988 begonnen. Das Studium war seinerzeit hart. Aber heute ist es dank Bachelor gemein.
Bemerkung: Wenn ich Studenten schreibe meine ich natürlich Studentinnen und Studenten. Es ist mir nur zu umständlich dies immer auszuschreiben. Deshalb liebe Studentinnen: Bitte fühlen Sie sich bei dem Wort Studenten mit angesprochen!

Inhaltsverzeichnis

Werkzeuge

Als Student besitzen Sie bestimmt einen Computer, wahrscheinlich einen Laptop. Und höchstwahrscheinlich benutzen Sie das Betriebssystem MS-Windows. Für die Vorlesung "Algorithmen und Datenstrukturen" und für diese Internet-Seite benötigen Sie aber das Betriebssystem Linux. Warum? MS-Windows ist ein Spielzeug und Linux ist ein Werkzeug. Dies ist natürlich meine Informatiker-Meinung und diese ist oft nicht in Übereinstimmung mit der DAU-Meinung (DAU=Dümmster Anzunehmender User).
Neben dem Betriebssystem Linux benötigen Sie noch mindestens einen Editor und einen C-Compiler. Der C-Compiler für Linux ist (fast) immer der GNU C-Compiler gcc. Der Editor ist Geschmackssache. Einige Oldtimer benutzen den vi Editor, denn "wenn nichts geht, vi geht" (vi läuft auch ohne graphische Benutzeroberfläche). Andere benutzen den EMACS (Eight Megabytes And Constantly Swapping). Für einen GNOME-Desktop Linux ist gedit der mitgelieferte Editor, für einen KDE-Desktop Linux ist es kwrite. Einige Programmierer empfehlen geany. Dieser Editor malt den C-Quelltext schön bunt. Eine extreme Lösung ist die Installation von wine, dem MS-Windows Emulator für Linux, und dann die Installation von Notepad++ mit Hilfe von winetricks. Ich selbst benutze den total aus der Mode gekommenen nedit (Nirwana Editor). Unter Ubuntu Linux (getestet mit Version 14.04) lässt sich nedit leicht installieren - da scheinen noch andere Ewig-Gestrige unterwegs zu sein.
Neben dem absoluten Minimum Betriebssystem, Editor und Compiler sind weitere Werkzeuge nützlich. Unter Linux sind die man-pages eine prima Informationsquelle. Wie war das noch einmal mit der printf() Funktion? Einfach man 3 printf eintippen und schon wird ihnen geholfen. Zwei Werkzeuge die der Professor üblicherweise verschweigt und die auch in der Übung nicht vorgestellt werden sind der Pretty-Printer indent und der Debugger ddd. Der Debugger ist das Werkzeug um "mittelschwere" Programmfehler zu finden. Der Pretty-Printer gibt dem C-Quelltext eine standardisierte Formatierung. Warum ist das hilfreich? Der Mensch ist ein Gewohnheitstier. Ich kann nach meinem Geschmack "korrekt" formatierten Quelltext schnell lesen und verstehen. Ist der Quelltext meiner Meinung nach "falsch" formatiert, dann lasse ich durch den Pretty-Printer die Formatierung korrigieren. In den folgenden Listings benutze ich übrigens indent -i4 -nut -kr, d.h. die Einrückung ist vier Leerzeichen, die Einrückung erfolgt mit Leerzeichen und die Formatierung ist nach dem Kernighan-Ritchie Standard.

Die Kommandozeile

Eine graphische Benutzeroberfläche ist etwas feines. Ein bisschen Mäuse-Schubsen, ein wenig Klicken und schon ist die Arbeit getan. Früher, in der Computer Steinzeit, gab es nur die Kommandozeile. Und auch heute sollte der Informatiker noch mit der Kommandozeile arbeiten können. Was ist nun die Kommandozeile unter einer graphischen Oberfläche? Es ist ein Fenster in das man Befehle an das Betriebssystem eingeben kann. Ein solchen Befehl ist gcc -g -O0 -Wall -o list_delete_value list_delete_value.c, Mit diesem Befehl wird die Quelltextdatei list_delete_value.c in die ausführbare Datei list_delete_value übersetzt mit Optionen die ein Debuggen des Programms erlauben. Es gibt unter Linux etliche Kommandozeilen-Programme, wie xterm. Im folgenden Bild wird gezeigt wie man über die Suche-Funktion der KDE-Oberfläche das Programm xterm findet. Nach dem Anklicken des XTerm Icon startet das xterm Fenster. Rechts unten wird das xterm-Fenster dargestellt und rechts oben ist ein nedit Editor Fenster zu sehen. Auf dem Computer läuft übrigens Opensuse 13.1 in der 32-bit Version. Ich setze Suse-Linux seit 1994 ein. Natürlich sind Ubuntu, Red-Hat (Fedora) usw. auch schöne Linux Versionen.

xterm unter KDE

Abbildung: KDE Benutzeroberfläche mit nedit und xterm.

Das erste Programm

Das erste Programm im Buch "Programmieren in C" von Kernighan und Ritchie ist das inzwischen berühmte hello.c Programm. Hier das Original aus der zweiten, "ANSI-C" Ausgabe des Buches:

#include <stdio.h>

main()
{
    printf("hello, world\n");
}

Das Programm hello.c wird mit dem Kommando gcc -o hello hello.c von Quelltext nach ausführbares Programm übersetzt. Wenn wir das Programm ausführen unter Linux mit ./hello dann erscheint die Meldung hello, world. Soweit so schön. Leider hat der C Quelltext einige Schönheitsfehler. Die Compileroption -Wall (melde alle Warnungen) zeigt die Schwächen. In Zeile 3 wird gemeldet "return type defaults to 'int'" und in Zeile 6 wird gemeldet "control reaches end of non-void function". Damit wir gleich richtiges C lernen schreiben wir eine Profi-Version als hello1.c:

// hello1.c
#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("hello, world\n");
    return 0;
}

Den hello1.c Quelltext wollen wir nun besprechen. Die erste Zeile beginnt mit // dem Symbol für Kommentar bis Zeilenende. Die zweite beginnt mit einem Doppelkreuz (#). Eine solche Zeile gehört zum Präprozessor (pre processor). Der Präprozessor ist eine eigene Programmiersprache und ist kein C. Das Präprozessor-Kommando #include kopiert den Inhalt der Datei stdio.h in die Datei hello1.c, wenigstens aus Sicht des Compilers. Die Datei stdio.h erklärt dem Compiler wichtige Details wie z.B. den Funktionsprototypen der Funktion printf(). Die Header-Datei stdio.h wird fast immer benötigt. Wir können sie an den Anfang jeder C-Quelltextdatei schreiben - bis wir es besser wissen. Die Zeile int main(int argc, char *argv[]) ist ein ähnlicher Klassiker. Liebe Studenten, bitte lernt diese Zeile auswendig. Also "int Leerzeichen main runde-Klammer-auf int Leerzeichen argc Komma Leerzeichen char Leerzeichen Stern argv eckige-Klammer-auf eckige-Klammer-zu runde-Klammer-zu".
Was bedeutet nun diese Zeile? Zwischen dem C-Programm und der Kommandozeile gibt es eine Schnittstelle. Wenn in der Kommandozeile das Programm hello1 gestartet wird, dann wird im Programm die main() Funktion aufgerufen. Die Schnittstelle beschreibt nun was von der Kommandozeile an das Programm beim Programm-Start übergeben wird und was vom Programm beim Programm-Ende an die Kommandozeile zurück übergeben wird. Der erste Eingabe-Parameter (von der Kommandozeile an das Programm) ist argc. Diese Ganzzahl-Variable ist der argument count, d.h. benennt die Anzahl der Argumente welche übergeben werden. Der zweite Eingabe-Parameter ist argv, der argument vector. Diese C-String Feldvariable enthält die Argumente. Der Ausgabe-Parameter wird durch das int am Zeilenanfang angeben. Das Programm liefert an die Kommandozeile eine Ganzzahl zurück.
Die geschweiften Klammern { und } umschließen die Definition der Funktion. Der Compiler muss wissen wo die Funktion anfängt und wo die Funktion endet. In der Funktion gibt es zwei Anweisungen (statements). Die Bibliotheksfunktion printf() erzeugt eine Ausgabe. Als Argument erhält die Funktion die C-String Konstante "hello, world\n".  In der C-String Konstante hat das Zeichen \ eine besondere Funktion. Mit \ wird ein Escape-Zeichen begonnen. Das Escape-Zeichen \n ist ein Zeilenvorschub. Die zweite Anweisung return bestimmt den Rückgabewert (return value) der Funktion main(). Traditionell bedeutet der Wert 0 das keine Fehler aufgetreten sind im Programmlauf.

Warum C im Erstsemester Kurs?

Dieser Abschnitt ist nicht nötig um C zu lernen. Deshalb steht der Text in Kursivschrift. Vielleicht stellt sich der eine oder andere Student die Frage: Warum muss ich C lernen? Diese Programmiersprache ist doch total veraltet (es gibt sie seit 1973). Die Programmiersprache C ist eine gelungene Mischung aus minimalem Sprachumfang und mächtigen Bibliotheken. C ist für große Projekte geeignet. Das Betriebssystem Linux ist in C geschrieben und das Betriebssystem MS-Windows höchstwahrscheinlich auch. Als C entwickelt wurde gab es die Programmiersprachen Algol 68, PL/I und Pascal. Diese Dinosaurier sind schon lange ausgestorben, aber C hat überlebt. Als C schon bekannt und prominent war hat sich das US-amerikanische Verteidigungs-Ministerium eine eigene Programmiersprache namens Ada ausgedacht. Auch Ada hat keine Bedeutung mehr. Liebe Studenten, C ist ein echter Überlebenskünstler. Und damit ihr echte Überlebenskünstler werdet ist es sehr sinnvoll die Programmiersprache C als erste Programmiersprache zu lernen. Nach C könnt und müsst ihr noch etliche weitere Programmiersprachen lernen!
In den kursiv gesetzten Einschüben steckt hoffentlich lesenswerter Stoff. Als Autor kann ich mir vorstellen das einige Leser nur die kursiven Absätze lesen. Die Leser welche beides lesen finden hoffentlich den Wechsel der Perspektive anregend welcher das Thema "Algorithmen und Datenstrukturen" in den größeren Rahmen der praktischen Informatik stellt.

Das zweite Programm

Das erste Programm hatte nur eine Funktion, im zweiten Programm gibt es zwei Funktionen. Damit die Sache nicht langweilig wird wollen wir gleichzeitig die Parameterübergabe von der Kommandozeile an das Programm und die Programmschleife kennen lernen. Ladies and Gentlemen, bitte schnallen Sie sich an! Hier kommt das Programm-Listing von prog2.c:

// prog2.c
#include <stdio.h>
#include <stdlib.h>

void ausgabe(int n)
{
    int i;
    for (i = 0; i < n; ++i) {
        printf("Schleifendurchlauf %d\n", i);
    }
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        printf("usage: %s n\n", argv[0]);
        return 1;
    }
    int n = atoi(argv[1]);
    ausgabe(n);
    return 0;
}

Der kluge Leser erkennt bestimmt die Quelltext-Zeilen welche aus dem hello1.c Listing geklaut wurden: Die erste #include Zeile, unser "int Leerzeichen main runde-Klammer-auf" Gedicht und das return 0 am Ende des Listing. Zuerst wollen wir die main() Funktion besprechen. In der main() Funktion benutzen wird die Variablen argc und argv. Wie oben schon gesagt enthält argc die Anzahl der Programmaufruf-Argumente. Das Programm wird aufgerufen als ./prog2 3. Die Anzahl der Parameter (Parameter und Argument bedeuten das gleiche) ist 2. Der erste Parameter ist ./prog2, der zweite Parameter ist 3. Wird das Programm mit weniger als 2 Argumenten aufgerufen, dann wird eine kleine Bedienungsanleitung ausgeben. Wie funktioniert dies im Detail? Die main() Funktion beginnt mit einem if. Ein if ist eine Entscheidung, eine Wege-Gabelung im Programm.
Unter Informatikern gibt es folgenden blöden Witz: Sagt der eine Progger zum anderen: Der Programmfehler liegt bestimmt an einem if. Sagt der andere: Ja, aber an welchem, ich habe davon in meinem Programm so 10000.
Unser if ist ein einfaches if, eine "entweder ich tue etwas oder ich tue nichts" Entscheidung. Der Auslöser für den "ich tue etwas" Fall ist der Vergleich argc < 2. Wenn argc kleiner als 2 ist, dann tue etwas. Was getan wird steht zwischen den geschweiften Klammern { und }. Zuerst erfolgt eine Ausgabe durch die printf() Funktion und dann wird das Programm beendet mit dem return value 1, d.h. Fehlerfall Nummer 1 ist aufgetreten. Das Argument der printf() Funktion soll genauer betrachtet werden. Das Escape-Zeichen \n ist schon bekannt. Die Zeichen %s haben eine weitere spezielle Funktion. In der Ausgabe steht nicht %s, sondern der Inhalt der Variable argv[0]. Das Zeichen %s bedeutet: füge in der printf() Ausgabe eine C-String Variable ein. Die C-String Variable argv[0] enthält das erste Argument des Programmaufrufs, den Text "./prog2". Im Fehlerfall, d.h. wenn argc den Wert 1 hat, liefert das Programm die Ausgabe usage: ./prog2 n. Jeder Leser kann natürlich gerne eine schönere Meldung schreiben.
Was passiert aber wenn die Bedingung für das if nicht erfüllt wird, d.h. die Entscheidung lautet "ich tue nichts"? Dann werden die Befehle in der geschweiften Klammern nach dem if nicht ausgeführt. Der Programmfluss geht nach der geschweiften-Klammer-zu weiter. Die Zeile int n = atoi(argv[1]); ist unsere erste Definition einer lokalen Variable, unsere erste Zuweisung und gleichzeitig eine Initialisierung (Vorbelegung). Das Zeichen = hat in der Programmiersprache C nicht die Bedeutung aus der Mathematik "links und rechts vom = Zeichen steht das gleiche" sondern hat die Bedeutung: Kopiere das was auf der rechten Seite steht nach links. Weil die linke Seite und die rechte Seite unterschiedliche Bedeutung haben (es wird immer von rechts nach links kopiert) gibt es auch eigene Namen dafür. Der Informatiker spricht von l-value und von r-value um die linke Seite von = und die rechte Seite von = zu benennen. An der linke Seite, dem l-value, darf nur eine Variable stehen. Auf der rechten Seite darf natürlich auch eine Variable stehen, es darf aber auch eine Konstante stehen oder wie bei uns eine Funktion.
Die Programmiersprache C kennt lokale Variablen, globale Variablen und heap Variablen. Heutzutage ist die typische Variable lokal zu einer Funktion, entweder als Teil der Funktions-Schnittstelle oder als Teil des Funktions-Rumpf zwischen { und }. Die globalen Variablen dienen dem Datenaustausch zwischen "entfernten" Teilen eines Programmes. Eine bekannte globale Variable ist errno. Über diese Variable liefert das Betriebssystem Information warum der letzte OS-call (Betriebssystemaufruf) fehlgeschlagen ist. Die heap Variablen werden durch malloc() erzeugt und sind dynamische Variablen, d.h. bei Programmstart ist noch nicht bekannt wieviel Speicherplatz der heap einmal belegen wird.
Der Funktionsname atoi steht für ASCII-to-Integer und wandelt eine Ziffernfolge in eine Ganzzahl um. Das Argument von atoi ist argv[1], der zweite Übergabeparameter von der Kommandozeile an das Programm. Der Rückgabe-Wert (return value) der Funktion atoi wird in die Variable n kopiert. Die Variable n enthält in dieser Zeile nicht nur ihren Wert sondern wird gleichzeitig definiert. Diese Kombination von Variable erzeugen (definieren) und Variable mit einem Wert versehen ist sehr praktisch und erst seit wenigen Jahren in der Programmiersprache C erlaubt.
Wie bekannt können Computer eigentlich nur mit Zahlen rechnen. Deshalb ist jedes Zeichen wie A oder 7 oder ! eine kleine Zahl im Computer. Der ASCII Zeichensatz legt seit ungefähr 1963 fest welche kleine Zahl für das Zeichen A usw. steht. Für Details siehe
man 7 ascii. Der ASCII-Zeichensatz kennt 128 verschiedene Zeichen. Heutzutage versucht man alle Schriftzeichen im Computer darzustellen. Dazu benötigt man zehntausende von unterschiedlichen Zeichen. Ein moderner und weit verbreiteter Zeichensatz für alle Zeichen ist UTF-8. UTF-8 ist zu ASCII kompatibel, d.h. die ersten 128 UTF-8 Zeichen sind die ASCII Zeichen. Die Programmiersprache C versteht UTF-8 nicht besonders gut. C kommt mit dem Zeichensatz ISO-8859-15 besser klar. In diesem Zeichensatz sind die deutschen Umlaute und das Eurozeichen € enthalten.
Die Ziffernfolge 10 wird in einer 16-bit Ganzzahl als 0000000000001010 dargestellt. Die binäre Stelle rechts hat den Wert von 2 hoch 0 = 1. Die nächste Stelle hat den Wert 2 hoch 1 = 2. Die weiteren Werte sind 4, 8, 16. Wenn wir die Binärzahl 1010 mit der Stellenwertigkeit betrachten erhalten wir 1*8+0*4+1*2+0*1 =  10.
Die zweite Funktion ausgabe() wird in der Zeile ausgabe(n); aufgerufen. Das Programm springt bei einem Funktionsaufruf von der aktuellen Stelle im Programm zu der Funktion, aber vorher merkt sich das Programm die Stelle von der der Sprung aus erfolgte. Als Analogie: In einem Buch lesen wir einen Verweis "siehe Seite 15". Damit wir nach dem Verweis ohne Probleme weiter lesen können legen wir uns einen Zettel an der aktuellen Seite ins Buch, springen dann auf Seite 15, lesen den Verweis und gehen dann wieder zurück zu unserem Zettel. Diese "Zettelwirtschaft" heißt bei einem Computer Kellerspeicher (stack). Werden mehrere Zettel benötigt (der Verweis von Seite 15 verweist wiederum auf Seite 8) wird ein Kellerspeicherzeiger (stack pointer) benötigt. Der lesende Finger heißt beim Computer übrigens Programmzähler (program counter). Wenn wir über stack pointer und program counter reden, reden wir über die Schnittstelle zwischen Computer Hardware und Computer Software. Der program counter ist Teil der Computer Hardware, der Inhalt des program counter ist schon Software und zeigt "wo befindet sich das Programm gerade in der Ausführung".
Die Abkürzung von program counter ist PC. Dieses PC sollte nicht mit "personal computer" verwechselt werden. Obwohl, ein personal computer hat auch einen program counter.
Die Funktion ausgabe() enthält eine Zählschleife. Für C ist es typisch das für N Durchläufe die Zählvariable (index) von 0 bis N-1 läuft. Ein Array der Größe N hat als kleinsten Index 0 und als grössten Index N-1. Wer will kann die Zählschleife in prog2.c auch als for (i = 1; i <= n; ++i) schreiben. Auch bei der For-Schleife kann die Definition der Variablen i zusammen mit der Initialisierung erfolgen als for (int i = 0; i < n; ++i). Diese Schreibweise ist erst ab C99 erlaubt, dem C Standard von 1999. Es gibt leider einen deutlichen Nachteil. Oft wird der Wert der Zählvariable nach der Zählschleife benötigt, besonders wenn die Schleife mit break verlassen wurde. Bei der for (int i = 0 Schreibweise ist aber i außerhalb der Schleife nicht definiert.

Array und Zeiger Schreibweise

Die Array-Schreibweise unter C ist z.B. dest[n - 1]. Die Zeigerschreibweise ist z.B. dest + destlen. Der C Neuling ist bei gemischter Array- und Zeigerschreibweise oft irritiert. Natürlich lässt sich ein Mini-Programm entweder nur in Array-Schreibweise oder nur in Zeiger-Schreibweise erstellen. In C Listings der 100000 Quelltextzeilen Größenordung dürften aber immer beide Schreibweisen auftreten. Die Verbindung zwischen beiden Schreibweisen ist recht einfach:

    variable[index] = *(variable + index)
    &variable[index] = variable + index

Der C-Operator * übersetzt von Zeiger (Speicher-Adresse) auf Inhalt. Der C-Operator & übersetzt von Inhalt auf Zeiger. Die Array-Schreibweise wird benutzt wenn der Inhalt manipuliert werden soll. Die Zeigerschreibweise wird benutzt wenn der Zeiger manipuliert werden soll. Mit Hilfe der Operatoren * und & kann oft die eine Schreibweise durch die andere ersetzt werden. In char *argv[] sind beide Schreibweisen gemischt. Für den C Compiler ist die Schreibweise char **argv ebenfalls gültig und bewirkt das gleiche. Die Schreibweise char argv[][] ist nicht gültig.
Die Programmiersprache C ist Spielwiese für die Quelltext-Verwirrer. Alle Jahre wieder wird für Obfuscated C das unleserlichste C Listing gesucht. Erwachsene Menschen haben einen Heidenspaß daran sich möglichst kunstvoll ein Bein zu stellen. Wer Produktionscode schreibt sollte auf solche Tricks und Kniffe verzichten. Zu leicht hat man sich durch kunstvolle Schnörkel selbst schachmatt gesetzt.

Der Stack, LIFO

Eine einfache Datenstruktur ist der Stack oder LIFO (Last In First Out). Die Teller im Geschirrschrank bilden einen Stack. Der letzte Teller welcher auf den Tellerstapel gelegt wird wird als erster Teller wieder vom Tellerstapel genommen. Wenn kein Teller im Tellerstapel ist, dann ist der Stack leer (empty). Wenn der Geschirrschrank mit dem Tellerstapel voll ist, dann ist der Stack voll (full). In der Informatik wird gerne mit der Illusion des "unendlichen" Speicher gearbeitet. In der Realität ist jeder Speicher endlich. Im folgenden wird der Stack mit einem Array realisiert. Ein Array hat in der Programmiersprache C eine feste, endliche Größe.
Wie oben beschrieben wird im Computer ein Stack benutzt um die Rücksprung-Adressen von Funktionen zu speichern. Der return stack und der stack pointer arbeiten auf der untersten Software-Ebene des Computers. Ein C-Programm speichert auf dem return stack auch die lokalen Variablen.
Die ersten Computer der 1950er Jahre hatten keinen return stack. Diese Hardware-Limitierung war der Grund warum die Rekursion bei frühen FORTRAN Versionen nicht erlaubt war. Die Programmiersprache FORTRAN kannte schon den Unterprogramm-Aufruf. Für die Rücksprung-Adresse des Aufrufers wurde in dem Maschinensprache-Programm des Unterprogramms eine Speicherstelle reserviert. FORTRAN hat diesen Stack der Tiefe 1 selbst verwaltet. Weil der Hardware stack pointer gefehlt hat, gab es natürlich auch keine lokalen Variablen in frühen FORTRAN Versionen. "Moderne" Programmiersprachen wie C sind erst durch moderne Computer-Hardware möglich geworden.
Der Quelltext lifo.c beginnt mit der schon bekannten #include Anweisung. Mit der #define Anweisung wird der Präprozessor-Konstanten MAXBUF der Wert 3 zugewiesen. Unser Stack kann drei Werte speichern. Die Stack Datenstruktur wird als struct lifo definiert. Die struct Variable ndx enthält Verwaltungsdaten, die Feldvariable buf enthält die eigentlichen Daten, die Nutzdaten. Mit ndx wird die Stelle in der Feldvariablen buf markiert wo der nächste "Teller" abgelegt wird. Die Variable ndx ist der Stack-Index. Es gibt in derProgrammiersprache C eine Verwandtschaft zwischen Array-Index und Zeiger, siehe oben. Eine typedef Anweisung erlaubt einem Datentype einen anderen Namen zu geben. Es wird ein Alias definiert, d.h. über beide Namen wird das gleiche Daten-Objekt angesprochen. Der echte Namen ist struct lifo und der Alias ist LIFO.
Nachdem die Datenstruktur definiert wurde, können die Algorithmen für diese Datenstruktur definiert werden. Mit der Funktion lifo_put() wird ein Datum auf den Stack gelegt, mit der Funktion lifo_get() wird ein Datum vom Stack geholt. Die Schnittstelle der Funktion lifo_put() besteht aus dem Ein-/Ausgabeparametern f, dem Eingabeparameter value_ und dem Rückgabewert der Funktion als Ausgabeparameter. Die Stack-Datenstruktur wird durch die Funktion lifo_put geändert. Deshalb genügt es nicht wenn f nur ein Eingabeparameter ist. In C wird durch das Einfügen von einem zusätzlichen Stern (*) aus einem Eingabeparameter ein Ein-Ausgabeparameter. Der Parameter f ist durch den vorangestellten Stern eine Zeigervariable geworden. Um nun auf die einzelnen Werte der Stack-Datenstruktur zugreifen zu können ist der -> Zugriffsoperator nötig. Der Vergleich f->ndx >= MAXBUF bedeutet: Hole den ndx Wert von der Stack-Datenstruktur f und prüfe ob dieser Wert größer-gleich der Konstanten MAXBUF ist. Wenn ja dann führt das if die return -1 Anweisung aus. Der Rückgabewert -1 bedeutet es ist der Fehler "Stack ist voll" aufgetreten. Im Normalfall, d.h. wenn der Stack noch nicht voll ist, wird eine einzige Anweisung ausgeführt. In dieser einen Anweisung werden die Nutzdaten und die Verwaltungsdaten geändert. Unter der Array-Index Position f->ndx wird der Wert value_ abgelegt. Durch das an f->ndx angehängte ++ wird nach dieser Nutzdaten-Zuweisung das Verwaltungsdatum f->ndx um eins erhöht.
Die Programmiersprache C wird manchmal "Superassembler" genannt. Ein Assembler ist die primitivste Programmiersprache. Wenn ein Programm ausgeführt wird liest die CPU (central processing unit) Binärdaten ein und interpretiert diese. Das eine Binärdatum führt zu einer Addition von zwei Ganzzahlen, das nächste Binärdatum speichert das Additionsergebnis in einer bestimmten Speicherstelle. Diese Binärdaten oder Maschinensprachebefehle sind einzeln für sich betrachtet sehr primitiv. Die heutigen Computer können aber Millionen oder gar Milliarden von diesen primitiven Befehlen pro Sekunde ausführen und entwickeln dadurch eine große Rechenleistung. Ein Assembler-Quelltext zeigt nun diese Maschinensprachebefehle in einer für Menschen besser geeigneten Schreibweise nicht als Binärzahlen sondern als Mnemonics, einprägsame Abkürzungen. So steht z.B. ADD für Addition und STO für Abspeichern. Ein Maschinensprachebefehl der PDP-11 war "erhöhe den Inhalt einer Speicherstelle um 1". Dieser Befehl führte zu den C Operatoren ++ und --. Neben C und seinen Verwandten C++, C# und Java haben nur wenige Programmiersprachen so eine direkte Verbindung zwischen der Maschinensprache-Ebene und der Hochsprachen-Ebene.
Die genaue Auswirkung des C Operators ++ hängt davon ob ++ vor oder hinter dem Variablenname steht. Steht ++ vor der Variable dann wird zuerst erhöht und dann erst der Wert der Variablen benutzt für eine Zuweisung. Dieses Verhalten heisst Pre-Inkrement. Steht ++ nach der Variablen, dann wird in der Zuweisung der bisherige Wert der Variablen benutzt und erst dann erhöht, ein Post-Inkrement. Die Funktion lifo_put() liefert im Normalfall den Rückgabewert 0 für "keine Fehler".
Die Funktion lifo_get() kennt auch eine Fehlersituation. Wenn der Stack leer ist, dann kann lifo_get() kein Datum liefern. Wie bei lifo_put() wird Erfolg oder Misserfolg der Funktion über den Rückgabewert mitgeteilt. Der Rückgabewert steht deshalb nicht für den Stack-value zur Verfügung. Wieder ist die typische C Lösung die Verwendung eines zusätzlichen * in der Funktions-Schnittstelle um aus einem Eingabeparameter einen Ein-/Ausgabeparameter zu machen. Der Quelltext von lifo_get() ist sehr ähnlich zum Quelltext von lifo_put(). Es wird diesmal nicht nach "Stack ist voll" sondern nach "Stack ist leer" geprüft. Anstelle des Post-Inkrement Operator f->ndx++ wird der Pre-Dekrement Operator --f->ndx benutzt.
Eine Stack-Implementierung mit einer Array-Variablen kann die Kombination Post-Inkrement und Pre-Dekrement benutzen. Die andere funktionsfähige Kombination ist Pre-Inkrement und Post-Dekrement. Da bei uns der leere Stack den Array-Index 0 hat, ist Post-Inkrement und Pre-Dekrement die sinnvolle Kombination.
Der stack pointer der CPU steht bei Programmstart üblicherweise auf der höchsten Speicheradresse und wächst "nach unten". Deshalb ist für den CPU stack die Kombination Pre-Dekrement für das Ablegen von Rücksprungadresse auf den Stack beim Funktions-Aufruf und Post-Inkrement für das Holen der Rücksprungsadresse bei Funktion return sinnvoll. Der heap wächst übrigens von unten nach oben. Im heap werden die durch malloc() angelegten Datenstrukturen abgelegt. Weil der eine Speicherbereich von oben nach unten wächst und der andere Speicherbereich von unten nach oben wächst ist die größtmögliche Flexibilität gegeben. Eine richtig clevere Idee der Informatiker der 1960er Jahre!

// lifo.c
#include <stdio.h>

#define MAXBUF 3

struct lifo {
    int ndx;
    int buf[MAXBUF];
};
typedef struct lifo LIFO;

int lifo_put(LIFO * f, int value_)
{
    // Sonderfall: LIFO ist voll
    if (f->ndx >= MAXBUF)
        return -1;

    // Normalfall
    f->buf[f->ndx++] = value_;
    return 0;
}

int lifo_get(LIFO * f, int *value_)
{
    // Sonderfall: LIFO ist leer
    if (0 == f->ndx) {
        *value_ = 0;
        return -1;
    }

    // Normalfall
    *value_ = f->buf[--f->ndx];
    return 0;
}

int main(int argc, char *argv[])
{
    LIFO mylifo = { 0 };

    int i;
    for (i = 1; i <= MAXBUF + 1; ++i) {
        int rv = lifo_put(&mylifo, i);
        printf("lifo_put %d rv = %d\n", i, rv);
    }

    for (i = 1; i <= MAXBUF + 1; ++i) {
        int value;
        int rv = lifo_get(&mylifo, &value);
        printf("lifo_get value = %d rv = %d\n", value, rv);
    }

    return 0;
}

Die main() Funktion liefert das Testbett (test bed) für unsere Stack-Implementierung. Zuerst wird die LIFO variable mylifo definiert und initialisiert. In der geschweiften Klammer steht nur die Konstante 0. Dieser eine Wert wird in die erste Variable der Datenstruktur LIFO kopiert. Weil sich die Programmiersprache C so verhält ist es immer sinnvoll die Variablen der Datenstruktur welche Initialisierung benötigen an den Anfang zu setzen.
Die Programmiersprache C ist und bleibt eine kleine Sprache. Vielleicht weil C eine kleine Sprache ist, gibt es einige Verhaltensweisen welche nicht prominent verkündet werden. Das Verhalten bei der Initialisierung wenn nur ein Teil der Datenstruktur vorbelegt wird ist so ein "lesser known fact".
Um die Sonderfälle zu prüfen wird die for Schleife einmal häufiger durchlaufen als es Speicherstellen im Stack gibt. In der ersten Testschleife wird der lifo_put() Funktion mit &mylifo die Adresse der mylifo Datenstruktur mitgeteilt. Wenn in der Funktionsschnittstelle ein Ein-/Ausgabeparameter benötigt wird, d.h. ein *, dann ist beim Aufruf der Funktion oft ein & nötig. Der Operator & liefert zu einer Datenstruktur die Adresse und der Operator * liefert von einer Adresse den Inhalt. In der zweiten Testschleife muss zuerst eine Variable value definiert werden bevor dann beim Aufruf der lifo_get() Funktion die Adresse dieser Variable mit &value benutzt werden kann.
Die Beschreibung eines Programmes ist erst dann vollständig wenn nicht nur das test bed definiert wird sondern auch die Ausgaben des test bed dokumentiert werden. Die Ausgabe von ./lifo ist:

lifo_put 1 rv = 0
lifo_put 2 rv = 0
lifo_put 3 rv = 0
lifo_put 4 rv = -1
lifo_get value = 3 rv = 0
lifo_get value = 2 rv = 0
lifo_get value = 1 rv = 0
lifo_get value = 0 rv = -1

In der Praxis gibt es etliche Programme welche durch ein schlecht geschriebenes Pflichtenheft und durch einen umfangreichen Testkatalog definiert sind. Das Pflichtenheft ist üblicherweise vor der Programmierung entstanden und der Testkatalog ist während der Programmierung entstanden. Der Testkatalog beschreibt einzelne Testfälle mit pre-condition, event, post-condition (Zustand vor dem Testfall, Ereignis des Testfalls und Zustand nach dem Testfall). Die Anzahl der Testfälle geht üblicherweise in die Tausende oder Zehntausende bei Programmen mit heute üblichem Umfang von einigen Zehntausend Quelltextzeilen bis einige Millionen Quelltextzeilen. Ein Pi-mal-Daumen Wert ist ein Testfall auf 10 Quelltextzeilen. Wer heutzutage nicht zusammen mit dem Auftraggeber gemeinsam den umfangreichen Testkatalog erstellt darf sich über eine Gerichtsverhandlung nicht wundern. In der Prosa eines Pflichtenheftes lassen sich die unterschiedlichen Sichtweisen und Vorstellungen von Auftragnehmer und Auftraggeber noch gut verschleiert. Beim Testkatalog müssen beide Parteien schon deutlich ähnlichere Vorstellungen von "was soll das Programm machen" haben. Und nur diese durch die gemeinsame Arbeit am Testkatalog entstandene gemeinsame Sichtweise führt zu einem erfolgreichen EDV-Projektabschluß. Wer nicht genau weiss wo er hin will darf sich nicht wundern wenn er nicht ankommt.

Die Queue, FIFO

Was können Informatiker von Holzfällern lernen? Den Nutzen einer Queue. Die Holzfäller hacken den ganzen Tag und fällen einige Bäume. Einmal am Tag kommt ein Transporter und bringt die gefällten Bäume weg. Das Zwischenlager für die Bäume ist die Queue oder Warteschlange oder FIFO (First in, First out). Der erste Baum der gefällt wurde wird auch als erster Baum auf den Transporter geladen. Dieses Detail ist für Baum-Hacker nicht wichtig aber für Computer-Hacker. Die Holzfäller/Transporter Situation wird allgemein Erzeuger/Verbraucher Situation genannt. Am besten funktioniert Erzeuger/Verbraucher wenn im Durchschnitt Erzeuger und Verbraucher gleich schnell arbeiten aber es doch immer wieder Unterschiede in der Arbeitsgeschwindigkeit gibt. Wenn der Erzeuger etwas schneller als der Verbraucher arbeitet, dann füllt sich die Queue auf. Wenn der Erzeuger langsamer als der Verbraucher arbeitet, dann wird die Queue abgebaut. Wenn die Queue ganz leer ist, dann wird der Verbraucher pausiert. Die Verwaltung von Prozess-Zuständen (pending, running, waiting) ist hier nicht Thema und wird spätestens in der Betriebssystem-Vorlesung behandelt.
Die Queue Datenspeicherung im folgenden Programm erfolgt mit einem Array. Die Datenkapselung der Queue Implementierung erfolgt mit einer struct. Der Datensatz struct fifo enthält drei Zustandsvariablen neben dem eigentlichen Datenspeicher. Die Größe des Datenspeicher wird durch die Präprozessor Konstante MAXBUF festgelegt. Die Variable in wird beim Ereignis "Datum wird in der Queue abgelegt" geändert. Die Variable out wird beim Ereignis "Datum wird aus der Queue entnommen" geändert. Die Variable cnt enthält die Anzahl der Datenwerte in der Queue.

// fifo.c
#include <stdio.h>

#define MAXBUF 3

struct fifo {
    int in, out, cnt;
    int buf[MAXBUF];
};
typedef struct fifo FIFO;

int fifo_put(FIFO * f, int value_)
{
    // Sonderfall: FIFO ist voll
    if (f->cnt >= MAXBUF)
        return -1;

    // Normalfall
    f->buf[f->in++] = value_;
    f->in %= MAXBUF;
    ++f->cnt;
    return 0;
}

int fifo_get(FIFO * f, int *value_)
{
    // Sonderfall: FIFO ist leer
    if (0 == f->cnt) {
        *value_ = 0;
        return -1;
    }
   
    // Normalfall
    *value_ = f->buf[f->out++];
    f->out %= MAXBUF;
    --f->cnt;
    return 0;
}

int main(int argc, char *argv[])
{
    FIFO myfifo = { 0, 0, 0 };

    int i;
    for (i = 1; i <= MAXBUF + 1; ++i) {
        int rv = fifo_put(&myfifo, i);
        printf("fifo_put %d rv = %d\n", i, rv);
    }

    for (i = 1; i <= MAXBUF + 1; ++i) {
        int value;
        int rv = fifo_get(&myfifo, &value);
        printf("fifo_get value = %d rv = %d\n", value, rv);
    }

    return 0;
}

Die Ausgabe von ./fifo ist:

fifo_put 1 rv = 0
fifo_put 2 rv = 0
fifo_put 3 rv = 0
fifo_put 4 rv = -1
fifo_get value = 1 rv = 0
fifo_get value = 2 rv = 0
fifo_get value = 3 rv = 0
fifo_get value = 0 rv = -1


C-Strings

Die Zeichenkettenverarbeitung unter C hat zu Recht einen schlechten Ruf. Die string Klasse von C++ funktioniert viel besser. Es gibt aber noch sehr viel C Quelltext auf der Welt. Deshalb soll hier der sichere Umgang mit C-Strings vorgestellt werden. Zuerst wird aber der C-String vorgestellt.

strlen(), strcpy(), strcat()

Ein C-String ist ein char Array, eine Feldvariable mit dem Type char. Wird in der Programmiersprache C ein Array an eine Funktion übergeben als Parameter, dann erfolgt Call-by-Reference. Es wird nicht eine Kopie des C-Strings übergeben, sondern ein Zeiger auf den C-String. Ein Zeiger (pointer) ist die Speicheradresse, d.h. die Stelle im Computer wo der C-String abgelegt ist. Wird in der Funktion der übergebene C-String geändert, dann ist diese Änderung dauerhaft. Auch nachdem die Funktion verlassen wurde bleibt der C-String geändert. Zum Vergleich: Ein int Wert wird als Call-by-Value übergeben. Die Funktion erhält eine Kopie des Wertes. Ändert die Funktion ihre Kopie des int Wert ist diese Änderung nicht dauerhaft. Warum wird nun ein C-String als Zeiger übergeben? Der C-String ist ein Array. Unter C werden alle Arrays als Call-by-Reference übergeben. Das Kopieren von (langen) Arrays dauert eine gewisse Zeit und benötigt auch Speicherplatz für die Kopie. Die Call-by-Reference Parameterübergabe ist eine Möglichkeit Zeit und Speicherplatz zu sparen.
C wurde ab 1971 entwickelt. Zu dieser Zeit war der Digital PDP-11/20 ein typischer Computer. Dieser Computer hatte einen maximalen Arbeitsspeicher von 28672 16-Bit Worten. C wurde für das Betriebssystem UNIX entwickelt und die C Version von UNIX lief zuerst auf einer PDP-11. Natürlich durfte das Betriebssystem nicht den kompletten Arbeitsspeicher "auffressen" sondern sollte möglichst viel Speicher für die Nutzerprogramme übrig lassen.
Neben dem Call-by-Reference Problem gibt es das Größen-Problem. Wenn eine Funktion einen C-String erhält, d.h. einen Zeiger auf ein char Array, dann ist die Größe des char Array nicht bekannt. Die übliche Lösung in C ist, das ein C-String mit zwei Parametern, einem Doppelparameter, beschrieben wird. Der erste Parameter ist der Zeiger, der zweite Parameter ist die Größe des Array. Der Operator sizeof(variable) liefert die Größe des Array.
Das dritte Problem sind C-Strings ohne NUL-Byte am Ende. Ein C-String kann ja kürzer als die Array-Größe sein. Damit eine Funktion wie strlen() oder printf() die aktuelle Länge des C-Strings erkennt wird nach den gültigen Zeichen im C-String ein Zeichen mit der Wert 0 gesetzt. Im ASCII Zeichensatz wird dieses Steuerzeichen NUL genannt, deshalb NUL-Byte.
Das folgende Programm testet die Standard C-String Funktionen strlen(), strcpy() und strcat(). Dabei liefert strlen() die Länge des C-Strings, d.h. wie viele Zeichen enthält das char Array vor dem ersten NUL-Byte. Die Funktion strcpy() kopiert den Inhalt eines C-String in einen anderen C-String. Eine Zuweisung string1 = string2 kopiert nur den Zeiger. Mit strcat() wird die Kopie des zweiten Strings an den ersten String angehängt (concatenation).
In der Funktion f() wird zuerst ein C-String ohne NUL-Byte erzeugt. Dies ist erlaubt, der Compiler meldet keine Warning oder Error. Mit printf() wird der Wert von sizeof() und der Wert von strlen() ausgegeben. Die Funktion strlen() versagt weil das NUL-Byte fehlt. Üblicherweise gibt es im Arbeitsspeicher nach dem char Array Variable eine Speicherstelle welche zufällig ein NUL-Byte enthält und für strlen() das Stringende-Zeichen liefert. Die Funktion g() zeigt was passiert wenn ein langer C-String in ein zu kurzes char Array kopiert wird. Die Speicherstellen hinter dem char Array werden überschrieben. Auf meinem Computer erfolgt ein Programmabsturz mit der Fehlermeldung "Speicherzugriffsfehler". In der dritten Funktion h() zeigt strcat() seine Schwächen. Ähnlich wie bei g() werden Speicherstellen überschrieben die nicht zur char Array Variable gehören. Auch hier erfolgt ein Programmabsturz, diesmal ohne Fehlermeldung.
Der aufmerksame Leser erinnert sich vielleicht noch an das C Strickmuster: Eine kleine Sprache mit einer großen Bibliothek. Die Funktionen strlen(), strcpy() und strcat() sind Teil der Bibliothek und nicht Teil der Programmiersprache. Sie können leicht durch andere Funktionen ersetzt werden.

// strcpy_test.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void f()
{
    char s[5] = { 'a', 'b', 'c', 'd', 'e' };

    printf("sizeof(s) = %d\n", sizeof(s));
    printf("strlen(s) = %d\n", strlen(s));
}

void g()
{
    char s[10];

    strcpy(s, "a very long string and more");
    printf("%s\n", s);
}

void h()
{
    char s[10];

    strcpy(s, "a");
    printf("%s\n", s);

    int i;
    for (i = 0; i < 20; ++i) {
        strcat(s, "b");
        printf("%2d: %s\n", i, s);
    }
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        printf("usage: %s n\n", argv[0]);
        return 1;
    }
    int n = atoi(argv[1]);
    switch (n) {
    case 1:
        f();
        break;
    case 2:
        g();
        break;
    case 3:
        h();
        break;
    default:
        printf("n is 1 to 3\n");
        return 2;
    }
    return 0;
}

Zum Abschluß noch ein paar Worte zur main() Funktion. Die switch() Anweisung kann als Alternative zu if() else if() else if() else Konstruktionen benutzt werden wenn die Bedingungen in den if() ein Vergleich zu konstanten Zahlen sind. Jeder case Zweig wird meistens mit einem break abgeschlossen. Dieses break ist ein Sprung hinter die geschweifte-Klammer-zu der switch() Anweisung. Manchmal ist es sinnvoll auf ein break zu verzichten. Dann wird oft anstelle von break ein C Kommentar /* no break */ geschrieben um diese Absicht deutlich zu machen.

ISO/IEC TR24731-1 C-Strings

ISO und IEC produzieren internationale Standards. In dem Dokument TR24731-1 werden Bibliotheksfunktionen beschrieben welche robuster sind als die POSIX Funktionen strlen(), strcpy() und strcat(). Das Größen-Problem von C-Strings wird durch die TR24731-1 C-String Funktionen behandelt. Diese Funktionen benutzen einen Doppelparameter (Zeiger und Größe) für den Ziel (destination) C-String. Auch die TR24731-1 C-Strings verarbeiten C-Strings ohne NUL-Byte fehlerhaft wenn die char Array Größe des Quell-C-String kleiner ist als die Größe des Ziel-C-String.
In dem folgenden Listing fehlt die main() Funktion. Sie ist genauso wie im vorherigen Listing. Die POSIX.1-2008 Funktion strnlen() ersetzt strlen(). Die TR24731-1 Funktion strcpy_s() ersetzt strcpy() und strcat_s() ersetzt strcat(). Unter Linux sind die TR24731-1 Funktionen nicht Standard-Lieferumfang, bei MS-Windows schon. Anhand der TR24731-1 Beschreibung dieser Funktionen sind sie aber schnell programmiert.
Die Funktion strcpy_s() ruft die Funktion strcpy() auf wenn einige Prüfungen erfolgreich waren. Zuerst werden die Übergabeparameter geprüft. Die C-String Variablen müssen gültige Zeiger sein, die Größe des Ziel-C-String muss zwischen einer unteren und oberen Grenze liegen. Die untere Grenze ist größer als 0 und die obere Grenze ist kleiner als die Präprozessor Konstante RSIZE_MAX. Falls die Prüfungen fehlschlagen wird der Inhalt des Ziel C-String gelöscht und ein Rückgabewert ungleich 0 geliefert. Der Operator || bedeutet logisches Oder. Zur Erinnerung: Ein Rückgabewert von 0 bedeutet bei C oft "kein Fehler", aber nicht immer. Einige Leser stören sich bestimmt an der goto Anweisung. Ein goto ist gültiges C und als Sprung zu einem gemeinsamen "error-exit" sogar strukturiert nach meiner Meinung.

// strcpy_TR24731_test.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define RSIZE_MAX 10000

// TR24731-1 Implementierung
int strcpy_s(char *dest, size_t n, const char *src)
{
    if (NULL == dest)
        return -1;
    if (NULL == src || 0 == n || n >= RSIZE_MAX)
        goto error;
    if (strnlen(src, n) < n) {
        strcpy(dest, src);
        return 0;
    }
error:  
    *dest = '\0';
    return -1;
}

// TR24731-1 Implementierung
int strcat_s(char *dest, size_t n, const char *src)
{
    if (NULL == dest)
        return -1;
    if (NULL == src || 0 == n || n >= RSIZE_MAX)
        goto error;
    if (strnlen(dest, n) + strnlen(src, n) < n) {
        strcat(dest, src);
        return 0;
    }
error:  
    *dest = '\0';
    return -1;
}

void f()
{
    char s[5] = { 'a', 'b', 'c', 'd', 'e' };

    printf("sizeof(s) = %d\n", sizeof(s));
    printf("strnlen(s) = %d\n", strnlen(s, sizeof(s)));
}

void g()
{
    char s[10];

    strcpy_s(s, sizeof(s), "a very long string and more");
    printf("%s\n", s);
}

void h()
{
    char s[10];

    strcpy_s(s, sizeof(s), "a");
    printf("%s\n", s);

    int i;
    for (i = 0; i < 20; ++i) {
        strcat_s(s, sizeof(s), "b");
        printf("%2d: %s\n", i, s);
    }
}

Es gibt einige Kritik an TR24731-1. Wegen dieser Kritik gibt es diese Funktionen nicht in der glibc. Für mich enthält die Kritik viel "real programmer" Machismo: Echte Programmierer brauchen keinen Zeiger/Größe Doppelparameter. Echte Programmierer schießen sich lieber in den Fuß.

Truncation C-Strings

Für mich ist die TR24731-1 Lösung im Fehlerfall einen leeren C-String zu liefern nicht die beste Lösung. Bei der Truncation (Abschneiden) Methode wird die Variable bis zur maximalen Größe gefüllt. Es wird soviel geliefert wie in den Ziel-C-String hineinpasst. Im folgenden Listing werden nur die Änderungen zum vorherigen Listing gezeigt. Die Funktion memcpy() kopiert eine Anzahl von Bytes ohne Auswertung des NUL-Bytes.

// strcpy_trunc_test.c
// Truncation Implementierung
int strcpy_s(char *dest, size_t n, const char *src)
{
    if (NULL == dest)
        return -1;
    if (NULL == src || 0 == n || n >= RSIZE_MAX) {
        *dest = '\0';
        return -1;
    }
    if (strnlen(src, n) < n) {
        strcpy(dest, src);
        return 0;
    } else {
        memcpy(dest, src, n);
        dest[n - 1] = '\0';
        return -1;
    }
}

// Truncation Implementierung
int strcat_s(char *dest, size_t n, const char *src)
{
    if (NULL == dest)
        return -1;
    if (NULL == src || 0 == n || n >= RSIZE_MAX) {
        *dest = '\0';
        return -1;
    }
    size_t destlen = strnlen(dest, n);
    if (destlen + strnlen(src, n) < n) {
        strcat(dest, src);
        return 0;
    } else {
        memcpy(dest + destlen, src, n - destlen);
        dest[n - 1] = '\0';
        return -1;
    }
}

Die Liste

Die Liste (list) ist nach dem Array die nächste Datenstruktur welche vorgestellt wird. Es gibt die einfach verkettete Liste, die einfach verkettete Liste mit einen Zeiger auf das Listen-Ende (tail pointer) und die doppelt verkettete Liste. In folgenden beschäftigen wir uns mit der einfach verketteten Liste.

Liste einfügen und löschen am Kopf

Früher war die Welt einfach: die Programmiersprache FORTRAN kannte nur Arrays und die Programmiersprache Lisp kannte nur Listen. Heute kann jede Programmiersprache alles und dank so leistungsfähiger Bibliotheken wie der Standard Template Library (STL) von C++ kann jeder Programmierer diese Leistung auch einfach nutzen. Wir arbeiten aber mit C und müssen deshalb die Listen von Hand programmieren.
Das Array hat den Nachteil der festen Größe. Eine Liste ist eine dynamische Datenstruktur. Es wird nur soviel Arbeitsspeicher benötigt wie es auch Datensätze in der Liste gibt. Das Einfügen eines neuen Datums in ein sortiertes Array benötigt viel Um-kopieren. Bei einer sortierten Liste muss nur die Einfüge-Stelle gesucht werden, das Um-kopieren entfällt. Natürlich gibt es die Vorteile der Liste nicht umsonst. Jedes Element der Liste benötigt Speicherplatz für einen Zeiger auf das nächste Element der Liste.
In einer Programmiersprache ohne Garbage Collector (automatisches Aufräumen von nicht mehr belegtem Arbeitsspeicher) wie C und C++ muss der Programmierer zum korrekten Zeitpunkt den nicht mehr benötigten Arbeitsspeicher freigeben. Hier passieren oft Programmierfehler. Entweder wird der Speicher nicht freigegeben und das Programm erstickt irgendwann an diesem "Ballast" oder es wird zu früh der Speicher freigegeben. Oft funktioniert das Programm dann noch eine Weile bis der fälschlich freigegebene Speicher anders benutzt wird. Erst dann wird der alte Inhalt geändert und erst dann wird der Programmierfehler sichtbar. Damit solche Fehlerquellen schnell sichtbar werden ist es sinnvoll einen Speicherbereich vor dem Freigeben mit NUL-Bytes zu überschreiben.
Die einfach verkettete Liste wird in C üblicherweise mit einem struct realisiert. Ein struct ist ein Datensatz oder ein Record oder eine Struktur. In einem struct gibt es mehrere Datenfelder. Minimal nötig für die Liste ist ein Datenfeld für den Zeiger auf das nächste Listenelement und ein Datenfeld für die Nutzdaten. Die einfachsten Nutzdaten sind Ganzzahlen. Eine einfache Liste hat somit folgende Datendefinition:

typedef struct elem *LIST;
struct elem {
  LIST next;
  int value;
};

Die zwei Datenelemente des struct elem sind next und value. Vor der struct Definition gibt es noch eine Definition eines Zeigers mit typedef. Mit typedef wird die Tipparbeit reduziert. Für den Compiler sind die Ausdrücke struct elem * und LIST identisch. Die beiden Anweisungen sehen aus wie ein Henne-Ei Problem. Die erste Anweisung bezieht sich auf elem, eine struct die noch gar nicht definiert ist. Es ist aber kein Henne-Ei Problem. Ein Zeiger hat immer eine bestimmte Größe, egal auf was der Zeiger zeigt.
Bei einem 32-bit Betriebssystem ist ein Zeiger 32 bit groß, bei einem 64-bit Betriebssystem ist er 64 bit groß, eigentlich logisch, oder? Eine Ganzzahl (int) ist üblicherweise 32 bit groß. Unser struct elem besteht bei einem 64-bit Betriebssystem somit aus 32 bit Nutzdaten und 64 bit Verwaltungsdaten. Wer genau 4GByte Arbeitsspeicher hat sollte ein 32-bit Betriebssystem benutzen, einfach weil da das Verhältnis von Nutzdaten zu Verwaltungsdaten besser ist.
Die Funktion list_print() zeigt den Inhalt der Liste. Das Liste durchlaufen benutzt eine for Schleife. Die for Schleife von C ist sehr flexibel. Zwischen der runden-Klammer-auf und der runden-Klammer-zu stehen drei, durch Semikolon getrennte, Ausdrücke. Der erste Ausdrück initialisiert die for Schleifen Variable. Der zweite Ausdruck beendet die for Schleife und der dritte Ausdruck gibt der for Schleifen Variable einen neuen Wert. Für eine Liste ist der neue Wert der Inhalt des next-Feldes und die Schleifen-Ende Bedingung ist das dieses Feld den Wert NULL hat.
Einige Professoren benutzen für das Liste durchlaufen eine while Schleife anstelle einer for Schleife. Das ist schlecht. Bei einer for Schleife ist genau festgelegt wo der Ausdruck für Initialisierung und für die Berechnung der neuen for Schleifen Variable steht. Bei einer while Schleife müssen diese Ausdrücke auch vorhanden sein, aber der Quelltext-Leser muss sie erst suchen. "Das haben wir schon immer so gemacht" ist eine schlechte Begründung und der natürliche Feind der Verbesserung.
Die Funktion list_insert() hat einige Besonderheiten in der Schnittstelle. An die Funktion wird LIST *l übergeben. Laut typedef ist LIST ein Zeiger auf ein struct elem. Ein LIST *l ist somit ein Zeiger auf einen Zeiger auf eine struct elem. Was soll das? Ein zusätzlicher Stern ist in C die übliche Methode um aus einem Eingabeparameter einen Ein- und Ausgabeparameter zu machen. Die Funktion list_insert verändert die Liste. Ein Ein-Ausgabeparameter ist da praktisch. Die Alternative ist die Liste einmal als Eingabeparameter aufzuführen und dann als Rückgabewert erneut zu haben. Dieses umständliche Verfahren ist bei der Programmiersprache Java nötig.
Die Programmiersprachen Pascal und Java haben keine Zeiger. Deshalb kann es in diesen Programmiersprachen keine Programmfehler durch Zeiger geben (Applaus, Applaus). Dafür haben diese Programmiersprachen Referenzen. Referenzen sind Zeiger für Arme. Ob Programme mit Referenzen weniger Programmfehler haben als Programme mit Zeigern ist noch aktuelles Forschungsgebiet. Erste Erkenntnisse lauten: "Man kann in jeder Programmiersprache FORTRAN programmieren".
Der zweite Eingabeparameter hat mit value_ eine seltsamen Namen. Der Hintergrund ist einfach: Die Programmiersprache C++ erlaubt für eine Variable und für ein struct Datenelement in der gleichen Funktion nicht den gleichen Namen. Bevor wir uns nun mühevoll einen anderen Namen für den Eingabeparameter ausdenken, hängen wir einfach einen Unterstrich an den Namen. Achtung: Vor den Namen sollten wir keinen Unterstrich anfügen. Die Laufzeitumgebung des Programms benötigt einige Variablen. Diese Variablen beginnen mit Unterstrich. Solange man es nicht genau weiß sollte man im eigenen Programm solche Variablen nicht benutzen.
Der return value von list_insert() ist 0 für keine Fehler und -1 für Fehler. Die Fehlerursache ist ein fehlgeschlagener Aufruf von malloc(). Die Funktion malloc() reserviert Arbeitsspeicher beim Betriebssystem. Dieser Arbeitsspeicher ist endlich. In der Funktion main() wird in der ersten for Schleife der Arbeitsspeicher angefordert. Eine Liste mit 10 Elementen macht noch keine Speichernot. Wird aber aus 10 eine Milliarde (1000000000) dann wird es interessant. Mein Computer mit einem 32-bit Linux sagt dann: Error: out of memory at i = 201076697. Für mich als "altgedienter" Informatiker ist es sehr wichtig den jungen Informatikern gleich die richtigen Lektionen zu vermitteln: error handling ist nötig! Und jedes error handling muss getestet werden! Bei einem Programm mit einigen dutzend Quelltextzeilen lässt sich die Fehlerursache vielleicht ohne error handling finden, bei einem Programm mit 50000 Zeilen nicht mehr. Und 50000 LOC (lines of code) sind heute mittelgroße Programme. Was ist nun error handling? Ganz einfach, wir werten die Rückgabewerte aus und melden sie nach oben. Der eigentliche Fehler "kein freier Arbeitsspeicher mehr" wird von malloc() entdeckt. Das return -1 meldet diesen Fehler an den Aufrufer von list_insert(). Der Aufrufer von list_insert() wertet ebenfalls den Rückgabewert aus und schreibt eine Fehlermeldung. Für das Verständnis von list_insert() ist dieses error handling nicht nötig, für das Verständnis von praktischer Informatik aber schon.
Die Steigerung von error handling ist error recovery: Wie kann ein Programm wieder aus einer Fehlersituation herauskommen. Error recovery ist ein Thema für Fortgeschrittene. Im Moment nur soviel: Die mission critical Software der Deutschen Flugsicherung enthält sehr viel error handling und error recovery Quelltext. In mancher Software stellen diese Teile sogar die Mehrheit des Quelltextes.
Die eigentliche Funktion von list_insert() ist einfach. Zuerst wird ein neues Listenelement erzeugt und mit den Nutzdaten gefüllt. Im zweiten Schritt werden Zeiger umsortiert. Der Zeiger vom Listenkopf zeigt nicht mehr auf das alte erste Listenelement sondern auf das neue erste Listenelement. Und der alte Zeiger vom Listenkopf ist nun im next-Feld des neuen ersten Listenelements. Die Reihenfolge diese beiden Zeigermanipulationen ist wichtig. Zuerst muss der alte Listenkopf-Zeiger kopiert werden, bevor der neue Listenkopf-Zeiger gesetzt werden kann. Der Listenkopf ist übrigens der Ein-Ausgabeparameter LIST *l der Funktion list_insert().

// list.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct elem *LIST;
struct elem {
    LIST next;
    int value;
};

// Liste durchlaufen und ausgeben
void list_print(LIST l)
{
    printf("Liste: ");
    LIST j;
    for (j = l; j != NULL; j = j->next) {
        printf("%d ", j->value);
    }
    printf("\n");
}

// einfuegen am Kopf
int list_insert(LIST * l, int value_)
{
    // neues Element erzeugen
    LIST pelem = malloc(sizeof(struct elem));
    if (NULL == pelem)
        return -1;
    pelem->value = value_;

    // neues Element am Listen-Kopf einfuegen
    pelem->next = *l;
    *l = pelem;
    return 0;
}

// loeschen am Kopf
int list_delete(LIST * l)
{
    // Sonderfall leere Liste
    if (NULL == *l)
        return 0;   // kein Element geloescht
       
    // erstes Element loeschen
    LIST oldl = *l;
    *l = oldi->next;
    memset(oldl, 0, sizeof(struct elem));
    free(oldl);
    return 1;   // ein Element geloescht
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        printf("usage: %s n\n", argv[0]);
        return 1;
    }
    int n = atoi(argv[1]);
   
    // Liste definieren und initialisieren
    LIST mylist = NULL;

    // Liste fuellen
    int i;
    for (i = 0; i < n; ++i) {
        int rv = list_insert(&mylist, i);
        if (rv) {
            printf("Error: out of memory at i = %d\n", i);
            return 1;
        }
    }

    list_print(mylist);

    // Liste loeschen
    for (i = 0; i < n+1; ++i) {
        int rv = list_delete(&mylist);
        if (0 == rv) {
            printf("Error: List empty = %d\n", i);
            return 1;
        }
    }
    return 0;
}

Die main() Funktion enthält die übliche Kommandozeile-Schnittstelle und beginnt mit dem if für die Mini-Bedienungsanleitung. Die vier Tätigkeiten der main() Funktion sind erzeugen der Liste, füllen der Liste, ausgeben der Liste und löschen der Liste. Wie schon in den vorherigen Programmen wird Definition und Initialisierung einer Variable in einer Anweisung ausgeführt. Dies sind "kleine Fische", aber trotzdem wichtige "best practices".
Das Liste füllen erfolgt mit einer Zählschleife. So weit so bekannt. Die Funktion list_print wurde oben besprochen. Einfügen am Kopf der Liste ist einfach und entfernen am Kopf der Liste ebenfalls. Es gibt nur den Sonderfall der leeren Liste. Beim löschen des ersten Elements muß zuerst eine Kopie des Listenkopf-Zeigers angelegt werden. Diese Kopie in der Variablen oldl wird für die Speicherfreigabe free() benötigt. Vor der Freigabe wird das struct elem noch mit NUL-Bytes vollgeschrieben mit der Anweisung memset(oldl, 0, sizeof(struct elem));. Dies ist ein Beispiel für defensive Programmierung wie oben empfohlen. Eigentlich ist diese Anweisung überflüssig, aber man hat als Informatiker schon einige Fehler gesehen. Und diese Fehler waren bei erster Betrachtung alle nicht logisch. In der main() Funktion wird list_delete() einmal häufiger als list_insert() aufgerufen um auch den Sonderfall löschen in der leeren Liste zu testen.

Liste Element löschen nach value

Der value eines Listenelements kann ebenfalls der Grund zum löschen sein. Zuerst gibt es eine grundsätzliche Design-Frage: Was soll passieren wenn es mehrere Listen-Elemente mit dem gleichen value gibt und dieser value gelöscht wird? Soll die list_delete() genau ein Element löschen oder alle Elemente mit diesem value? Wenn nur ein Element gelöscht wird, wie sollte dann die list_delete() Schnittstelle aussehen damit man auch alle Elemente löschen kann? Weiterhin gibt es einige Sonderfälle zu berücksichtigen. Wenn die Liste leer ist liegt der erste Sonderfall vor. Wenn das erste Element in der Liste gelöscht werden soll liegt der zweite Sonderfall vor.
Der Informatik-Professor Niklaus Wirth, welcher 1975 das erste Buch mit dem Titel "Algorithmen und Datenstrukturen" geschrieben hat, empfiehlt die Methode der schrittweisen Verfeinerung. Wir schreiben zuerst eine unvollständige Version des Programms. Diese Version kann vielleicht gerade den einfachsten Sonderfall bearbeiten. Wir können diese Programmversion aber schon testen. Es gibt das Sprichwort: "der Appetit kommt beim Essen". Oft hilft die Beschäftigung mit der Programmierung und dem Test des leichtesten Falles dem Informatiker dabei auch eine Lösung für die anderen Fälle zu finden. Wenn jeder Fall für sich programmiert und gleich darauf getestet wird, dann vergisst man so leicht auch nicht das Testen von allen Fällen, man geht ja schrittweise voran. Dieses uralte Vorgehen wird heutzutage als test driven development (TDD) verkauft. Die Neuerung bei TDD ist: Zuerst muss man den Test definieren mit Eingangsbedingung, Ereignis und Ergebnis (initial condition, event, result). Erst dann beginnt die Arbeit am "system under test".
Bei dem folgenden Listing fehlt der Anfang. Aus dem obigen Listing list.c wird der Quelltext bis zum Ende der list_insert() Funktion benötigt. Die list_delete_value() Funktion beginnt wie die list_delete() Funktion. Kleine Änderungen gibt es wegen dem Parameter value_. Erst die letzte for Schleife in der Funktion ist richtig neu. Es ist die bis jetzt komplizierteste for Schleife. Wie bekannt stehen zwischen runde-Klammer-auf und runde-Klammer-zu drei Ausdrücke. Durch ein Komma in solch einem Ausdruck lassen sich sogar mehrere Anweisungen schreiben. Mit dem Ausdruck oldi = *l, i = oldi->next werden die zwei Variablen oldi und i initialisiert und mit dem Ausdruck oldi = i, i = i->next werden die Variablen oldi und i auf neue Werte gesetzt. Die Variable oldi enthält den Wert von i aus dem letzten Schleifendurchlauf. Warum benötigen wir dies? Wenn wir ein zu löschendes struct elem gefunden haben, dann müssen wir das next-Feld des "upstream" struct elem ändern. Dies ist nur möglich wenn wir uns den vorherigen Wert von i aufgehoben haben. Beim Durchlaufen der Liste gibt es nur eine "Fahrtrichtung". Die Richtung aus der wir kommen ist flussaufwärts (upstream), die Richtung in die wir laufen ist flussabwärts (downstream).
Einige Programmierer sagen: "C sieht aus als wenn sich ein Gürteltier auf der Tastatur gewälzt hat". Dieser Aussage will ich nicht widersprechen. Trotzdem gehört die Schreibweise mit der Manipulation von zwei Variablen zum normalen Gebrauch von C. Auch wenn hier nur einfache Algorithmen und Datenstrukturen vorgestellt werden möchte ich trotzdem kein Kindergarten C zeigen.


// list_delete_value.c
// ein Element mit value loeschen
int list_delete_value(LIST * l, int value_)
{
    // Sonderfall leere Liste
    if (NULL == *l)
        return 0;   // kein Element geloescht
       
    // Sonderfall erstes Element
    if ((*l)->value == value_) {
        LIST oldl = *l;
        *l = (*l)->next;
        memset(oldl, 0, sizeof(struct elem));
        free(oldl);
        return 1;   // ein Element geloescht
    }
   
    // Normalfall weitere Elemente
    LIST i, oldi;
    for (oldi = *l, i = oldi->next; i != NULL; oldi = i, i = i->next) {
        if (i->value == value_) {
            oldi->next = i->next;
            memset(i, 0, sizeof(struct elem));
            free(i);
            return 1;
        }
    }
    return 0;
}

int main(int argc, char *argv[])
{
    LIST mylist = NULL;
   
    // teste loesche leere Liste
    int rv = list_delete_value(&mylist, 1);
    printf("teste loesche leere Liste rv = %d\n", rv);
       
    list_insert(&mylist, 3);   
    list_insert(&mylist, 3);
    list_insert(&mylist, 2);
    list_insert(&mylist, 2);
    list_insert(&mylist, 1);
    list_print(mylist);
   
    // teste loesche erstes Element
    rv = list_delete_value(&mylist, 1);
    printf("teste loesche erstes Element rv = %d\n", rv);
    list_print(mylist);
   
    // teste loesche nicht vorhandenes Element
    rv = list_delete_value(&mylist, 4);
    printf("teste loesche nicht vorhandenes Element rv = %d\n", rv);
    list_print(mylist);
   
    // teste loeschen mehrere Elemente
    while ((rv = list_delete_value(&mylist, 3))) {
        printf("teste loesche Normalfall rv = %d\n", rv);
    }
    list_print(mylist);

    return 0;
}

Die main() Funktion realisiert unser Testbett (test bed). Was zu einem richtigen Testbett noch fehlt sind die korrekten Antworten des Programms. Deshalb folgt die Ausgabe von dem Programmlauf. Von mir als der höchsten Testinstanz wird diese Ausgabe zur "baseline" erklärt. Nach Programmänderungen wie einer Laufzeitoptimierung soll das Programm das gleiche Verhalten zeigen wie die "baseline" Version.
Das die "baseline" Version eines Programmes das korrekte Verhalten zeigt ist ein (Irr)-Glaube. Es gibt immer die Möglichkeit das in der "baseline" auch ein Fehler steckt der durch eine Programmänderung repariert wurde. Bei Abweichungen ist immer nach der echten Fehlerquelle zu suchen. Die Fehlerquelle kann vor dem Computer sitzen (Bedien- oder Wahrnehmungsfehler des Testers), der Fehler kann in der neuen Software-Version oder in der alten Software-Version sitzen.

Hier kommt die "Soll"-Ausgabe des Programms:

teste loesche leere Liste rv = 0
Liste: 1 2 2 3 3
teste loesche erstes Element rv = 1
Liste: 2 2 3 3
teste loesche nicht vorhandenes Element rv = 0
Liste: 2 2 3 3
teste loesche Normalfall rv = 1
teste loesche Normalfall rv = 1
Liste: 2 2

Und nun die Klausur-relevante Frage: Wer hat alleine durch Lesen des Quelltextes und "ausführen" des Quelltextes im Kopf herausgefunden was die Programmausgabe ist?
In der main() Funktion haben wir zum ersten Mal eine while Schleife benutzt. Nach meiner Meinung ist die while Schleife nur dann sinnvoll wenn weder eine (Zähl-)Variable Initialisierung noch ein Änderung der (Zähl-)Variable bei jedem Schleifendurchlauf nötig sind. In der while Schleife findet eine Zuweisung statt. Dies ist eine praktische Eigenschaft von C. Der Compiler gibt bei der Option -Wall die Warnung "suggest parentheses around assignment". Deshalb gibt es ein Paar Klammern ( ) mehr als nötig. Anstelle von while ((rv = list_delete_value(&mylist, 3))) kann man auch schreiben while (0 != (rv = list_delete_value(&mylist, 3))). Bei der zweiten Schreibweise sind wegen der Vorrang-Regeln von C die Klammern um die Zuweisung nötig.
Es gibt immer kluge Sprüche wenn ein Thema etwas kompliziert wird. Im Zusammenhang mit der Operator-Priorität lautet der kluge Spruch: "Im Zweifel benutzen sie Klammern". Überflüssige Klammern finde ich häßlich. Mein Tipp lautet deshalb: "kompilieren Sie mit -Wall und hören Sie auf den Compiler". Ein Editor welcher zu jeder Klammer-auf die passende Klammer-zu anzeigt ist hilfreich. Der nedit kann dies.

Sortierte Liste

Weiter oben wurde als Vorteil der Liste genannt das es einfach ist eine sortierte Liste auch nach dem Einfügen von neuen Datensätzen sortiert zu halten. Ein Array macht in dieser Hinsicht mehr Arbeit. Vor den Erfolg haben die Götter den Schweiß gesetzt. Die Funktion list_insert_sort() hat die meisten Sonderfälle bis jetzt. Der Quelltext von list_insert() nach dem Kommentar "neues Element am Listen-Kopf einfügen" wird benutzt um ein neues Element in eine leere Liste einzufügen oder um ein neues Element am Listen-Kopf einzufügen. Wo liegt der Unterschied? Bei einer nicht-leeren Liste muss man die Einfüge-Stelle bei einer sortierten Liste suchen. Die Suche funktioniert mit einem Vergleich. Wenn ein Datensatz gefunden wird welcher einen höheren value hat als das neue Element, dann wird vor diesem Datensatz eingefügt. Die Bearbeitung des Normalfalls benutzt wieder eine for Schleife welche zwei Variablen manipuliert. Die gleiche for Schleife haben wir für die Funktion list_delete_value() benutzt, siehe oben.
Das Lesen und Memorieren von gut geschriebenen Quelltext bildet. Wie in der "normalen" Sprache gibt es in einer Programmiersprache Redewendungen, Sprichwörter, Floskeln. Irgendein kluger Kopf hat sich die Redewendung vor Jahren ausgedacht und seit dieser Zeit wird sie durch copy-paste-modify immer wieder benutzt. Als Informatiker hat man zwei Möglichkeiten: Entweder man liest viel guten Quelltext oder man muss selbst die Redewendungen wieder-entdecken. Nun wieder eine Klausur-relevante Frage: Welche Möglichkeit hört sich nach mehr Arbeit an?
Der letzte Sonderfall betrifft das Einfügen am Ende der Liste. Hier verwenden wir erstmals die (Zähl)-Variable einer for-Schleife nach der for-Schleife. An dieser Stelle habe ich in der ersten Version der Funktion einen schönen Denkfehler gemacht. Anstelle von  oldi->next = pelem habe ich i->next = pelem geschrieben. Ein unfreundlicher Programmabsturz war die Folge. Bestimmt wissen meine Leser es besser. Welchen Wert hat i immer wenn die Programmstelle "einfügen am Ende" erreicht wird?

// list_insert_sort.c
// sortiertes einfuegen
int list_insert_sort(LIST * l, int value_)
{
    // neues Element erzeugen
    LIST pelem = malloc(sizeof(struct elem));
    if (NULL == pelem)
        return -1;
    pelem->value = value_;
   
    // Sonderfall leere Liste oder
    // Sonderfall neues Element am Listen-Kopf einfuegen
    if (NULL == *l || (*l)->value > value_) {
        pelem->next = *l;
        *l = pelem;
        return 0;
    }
   
    // Normalfall weitere Elemente
    LIST i, oldi;
    for (oldi = *l, i = oldi->next; i != NULL; oldi = i, i = i->next) {
        if (i->value > value_) {
            pelem->next = i;
            oldi->next = pelem;
            return 0;
        }
    }
   
    // Sonderfall einfuegen am Ende
    pelem->next = NULL;
    oldi->next = pelem;
    return 0;
}

int main(int argc, char *argv[])
{
    LIST mylist = NULL;
   
    int rv = list_insert_sort(&mylist, 5);
    printf("teste einfuegen in leere Liste rv = %d\n", rv);
    list_print(mylist);

    rv = list_insert_sort(&mylist, 1);
    printf("teste einfuegen am Kopf rv = %d\n", rv);
    list_print(mylist);
   
    rv = list_insert_sort(&mylist, 2);
    printf("teste einfuegen in der Mitte rv = %d\n", rv);
    list_print(mylist);
   
    rv = list_insert_sort(&mylist, 2);
    printf("teste einfuegen Duplikat rv = %d\n", rv);
    list_print(mylist);
   
    rv = list_insert_sort(&mylist, 9);
    printf("teste einfuegen am Ende rv = %d\n", rv);
    list_print(mylist);
    return 0;
}

Die main() Funktion stellt wieder das Testbett. In der Funktion list_insert_sort() haben wir vier Fälle behandelt. Im Testbett gibt es aber fünf Testfälle. Warum? Ich teste auch den Fall das es mehrere Listen-Elemente mit dem gleichen value gibt. Hier kommt die "Soll"-Ausgabe des Programms:

teste einfuegen in leere Liste rv = 0
Liste: 5
teste einfuegen am Kopf rv = 0
Liste: 1 5
teste einfuegen in der Mitte rv = 0
Liste: 1 2 5
teste einfuegen Duplikat rv = 0
Liste: 1 2 2 5
teste einfuegen am Ende rv = 0
Liste: 1 2 2 5 9

Liste Programmierschnittstelle (API)

So langsam geht die Einführung in die einfach verkettete Liste zu Ende. Wir haben einige Funktionen geschrieben und können diese zu einem "Application Programming Interface" API zusammenfassen. In der Programmiersprache C wird hierzu eine Headerdatei benutzt. Unsere Liste-Implementierung besteht aus der Datenstruktur und fünf Zugriffsfunktionen. Hier das Listing:

//list.h
typedef struct elem *LIST;
struct elem {
    int value;
    LIST next;
};

void list_print(LIST l);
int list_insert(LIST * l, int value_);
int list_insert_sort(LIST * l, int value_);
int list_delete(LIST * l);
int list_delete_value(LIST * l, int value_);

Es fällt vielleicht auf das das erste Argument immer vom Typ LIST ist. In der objektorientierten Programmiersprache C++ wird dieser Objektzeiger unsichtbar. In der Programmiersprache C können wir objektorientiert denken, auch wenn wir es nicht direkt hinschreiben können. Es gibt schon einen Grund warum alle unsere Funktionen mit list_ beginnen.

Short circuit evaluation

Die Anweisung if (NULL == *l || (*l)->value > value_) hat noch eine Besonderheit. Das Programm wertet den Ausdruck nur so weit aus bis das Ergebnis feststeht. Der Operator || bedeutet logisches Oder. Wenn der Vergleich NULL == *l korrekt ist (wahr ist), dann wird nicht der zweite Vergleich durchgeführt. Dieses Verhalten ist auch nötig. Bei einer leeren Liste führt die Auswertung von (*l)->value zu einem Programmabsturz. Für die Boole'sche Algebra ist  (*l)->value > value_ || NULL == *l identisch zu NULL == *l || (*l)->value > value_. Für die Programmiersprache C aber nicht.

Algorithmen-Wettkampf mit Groß-O

Wer ist der schnellste Läufer? Und wer ist der beste Sortier-Algorithmus? Informatiker lieben den Wettkampf genauso wie die Leichtathletik-Fans. Nur bevorzugen Informatiker ihren Wettkampf in geschlossenen Räumen hinter Computer-Bildschirmen. Um nun Algorithmen vergleichen zu können wurde die Groß-O Notation erfunden. Wie schneidet unsere Funktion list_insert_sort() als Sortier-Algorithmus ab? Hier die anschauliche Herleitung: Wie betrachten den worst-case, den schlechtesten Fall. Für unsere Funktion ist diese gegeben wenn jedes neue Element als letztes Element in der Liste abgelegt wird. Das Wandern durch die Liste kostet pro Schritt (pro Durchlauf der for-Schleife) den Preis von einem Groß-O. Die übliche Schreibweise ist: Es  kostet O(1). Nun betrachten wir das Einfügen von fünf worst-case Werten in die Liste. Für jeden einzelnen Wert müssen wir die Liste von Anfang bis Ende durchlaufen. Nun könnten wir eine komplizierte Rechnung anstellen oder wir können einfach nach dem worst-case fragen. Damit das fünfte Element seinen Platz am Ende der Liste findet sind O(5) Arbeitsschritte nötig. Damit wir fünf Elemente in die Liste bekommen müssen wir fünfmal die Funktion list_insert_sort() aufrufen.
Nun haben wir zwei Angaben. Ein Wert 5 weil für fünf Elemente muss man fünfmal die Funktion aufrufen und ein O(5) für den worst-case der Schleifendurchläufe in der Funktion. Der gesamte Aufwand für das Sortieren ist O(5)*5 oder O(52). Wenn wir nun von 5 Elemente auf N Elemente gehen, dann lautet das Ergebnis O(N2).
Diese Herleitung der Komplexität der Einfüge-Sortierung (insertion sort) war anschaulich. Natürlich geht es auch mathematisch streng. Dies kostet aber viel mehr Platz und sorgt nur für geistige Aussetzer bei den Studenten des ersten Semesters. Mein persönliches Ziel ist es nicht das 70% bis 80% der Leser meiner Ausführung nicht folgen können. Oder wie sagte es einmal so schön ein Professor zu einem anderen Professor: "Hochachtung Herr Kollege. Ihr neues Buch wird bestimmt ein Standardwerk. Ich habe schon die zweite Hälfte der Einleitung nicht mehr verstanden".

Literatur

Der Schwerpunkt dieser Internet-Seite liegt klar bei der Programmiersprache C. Dies zeigt sich auch in der Literaturliste. Es werden die "praktischen" Bücher empfohlen. Natürlich ist die Groß-O Notation interessant, aber es kommt bei unseren Algorithmen ja sowieso immer nur O(1), O(N), O(log(N)), O(N*log(N)) oder O(N2) heraus, egal wie lange der Professor an seiner Herleitung bastelt. Die Übersetzung von rekursiv formulierten Algorithmen in iterative Versionen hat auch nur begrenzten Nährwert. Hat man das Prinzip einmal verstanden (Rekursion am Anfang oder Ende ist übersetzbar, Rekursion in der Mitte nicht) wird es schnell langweilig. Immer interessant bleibt aber das Handwerk: Eine Programmiersprache so gut zu lernen das man sich in dieser Sprache ausdrücken kann. Mein Ziel war es Ihnen viele gute Sprach-Beispiele zu liefern.

Klassische Werke

Moderne Werke

Über den Autor

Kurzer Lebenslauf: Ausbildung bei Siemens AG, Studium der Informatik, seit 24 Jahren verschiedene Tätigkeiten entlang des "software life cycle" bei der Deutschen Flugsicherung GmbH. Ich bin Kinder-lieb, Tier-lieb und Studenten-lieb. Ich habe eine gewisse Neigung zum dozieren (Lehrer-Syndrom) und schreibe deshalb mangels eigener Studenten diese Web-Seite.