117 12 29MB
German Pages 1220 [1223] Year 2014
An den Leser
Liebe Leserin, lieber Leser, wir freuen uns, Ihnen die fünfte Auflage dieses Lehrwerkes zu C und C++ vorzustellen. Entstanden aus einem Kurs von Prof. Dr. Kaiser über Grundlagen der Informatik und Programmiersprachen, ist dieses Buch seinem Anliegen immer treu geblieben: Es lehrt Programmieren auf professionellem Niveau, ohne konkrete Kenntnisse vorauszusetzen. Die Syntax der Sprachen ist dabei ein notwendiges Hilfsmittel, das Programmieren selbst darf vielleicht als Kunst verstanden werden; in jedem Fall aber als eine Praxis, für die Talent, Neugierde und ein Verständnis der Grundlagen der Informatik von Bedeutung sind. Letzteres erarbeiten Sie sich mit diesem Buch, das Theorie und Praxis lebendig verbindet. Es gibt dabei keine Vorgriffe auf den Stoff späterer Kapitel, so dass sich Anfänger problemlos von den Grundbegriffen zu den fortgeschrittenen Themen vorarbeiten können. Sie können die nötige Theorie nicht nur leicht nachvollziehen, sondern lernen ihren Nutzen auch im großen Zusammenhang kennen. Alles wird anhand anschaulicher Beispiele erläutert – wo es um die Genauigkeit einer mathematischen Abschätzung geht, denken Sie etwa an den prüfenden Blick ins Portemonnaie, ob Ihr Bargeld für ein Brötchen reicht. Im Laufe der Zeit konnten viele Leserwünsche und Lehrerfahrungen einfließen – so haben die Behandlung der Standardbibliotheken, der Abbau bestimmter Hürden bei mathematischen Inhalten und die ausführlichen, vollständigen Musterlösungen das Buch verbessert. Neu in dieser Auflage: Falls Sie einmal nicht weiterkommen, schauen Sie erst nach Lösungshinweisen, bevor Sie sich die vollständige Lösung ansehen. Die Codebeispiele und Lösungen finden Sie außerdem zum Download bei den Materialien zum Buch unter http://www.galileo-press.de/3536. Dieses Buch wurde mit großer Sorgfalt geschrieben, geprüft und produziert. Sollten Sie dennoch etwas nicht so vorfinden, wie Sie es erwarten, so zögern Sie nicht, mit uns Kontakt aufzunehmen. Ihre Anmerkungen, Ihr Lob oder Ihre konstruktive Kritik sind mir herzlich willkommen!
Ihre Almut Poll Lektorat Galileo Computing
[email protected] www.galileocomputing.de Galileo Press · Rheinwerkallee 4 · 53227 Bonn
Auf einen Blick
Auf einen Blick 1
Einige Grundbegriffe ............................................................................................
21
2
Einführung in die Programmierung ................................................................
35
3
Ausgewählte Sprachelemente von C ..............................................................
45
4
Arithmetik ................................................................................................................
83
5
Aussagenlogik ......................................................................................................... 107
6
Elementare Datentypen und ihre Darstellung ............................................ 129
7
Modularisierung ..................................................................................................... 181
8
Zeiger und Adressen ............................................................................................. 223
9
Programmgrobstruktur ....................................................................................... 241
10
Die Standard C Library .......................................................................................... 253
11
Kombinatorik .......................................................................................................... 273
12
Leistungsanalyse und Leistungsmessung ..................................................... 305
13
Sortieren ................................................................................................................... 347
14
Datenstrukturen .................................................................................................... 393
15
Ausgewählte Datenstrukturen ......................................................................... 437
16
Abstrakte Datentypen .......................................................................................... 493
17
Elemente der Graphentheorie ........................................................................... 507
18
Zusammenfassung und Ergänzung ................................................................ 575
19
Einführung in C++ .................................................................................................. 677
20
Objektorientierte Programmierung ................................................................ 717
21
Das Zusammenspiel von Objekten .................................................................. 775
22
Vererbung ................................................................................................................. 805
23
Zusammenfassung und Überblick ................................................................... 879
24
Die C++-Standardbibliothek und Ergänzung ............................................... 953
A
Aufgaben und Lösungen ..................................................................................... 1041
Impressum
Impressum Dieses E-Book ist ein Verlagsprodukt, an dem viele mitgewirkt haben, insbesondere: Lektorat Almut Poll, Erik Lipperts Korrektorat Friederike Daenecke Herstellung E-Book Martin Pätzold Covergestaltung Janina Conrady Satz E-Book Typographie & Computer, Krefeld Bibliografische Information der Deutschen Nationalbibliothek: Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.dnb.de abrufbar. ISBN 978-3-8362-8912-2 5., aktualisierte und überarbeitete Auflage 2014 © Galileo Press, Bonn 2014
Inhalt
Inhalt Vorwort ..................................................................................................................................................
19
1
21
Einige Grundbegriffe
1.1
Algorithmus ...........................................................................................................
24
1.2
Datenstruktur ........................................................................................................
28
1.3
Programm ...............................................................................................................
30
1.4
Programmiersprachen .........................................................................................
31
1.5
Aufgaben ................................................................................................................
33
2
Einführung in die Programmierung
35
2.1
Softwareentwicklung ..........................................................................................
35
2.2
Die Programmierumgebung ...............................................................................
40 41 42 43 43 43
2.2.1 2.2.2 2.2.3 2.2.4 2.2.5
3
Der Editor ................................................................................................. Der Compiler ............................................................................................ Der Linker ................................................................................................. Der Debugger ........................................................................................... Der Profiler ...............................................................................................
Ausgewählte Sprachelemente von C
45
3.1
Programmrahmen ................................................................................................
45
3.2
Zahlen .....................................................................................................................
46
3.3
Variablen ................................................................................................................
46
Operatoren .............................................................................................................
48 48 49 55 55
3.4
3.4.1 3.4.2 3.4.3 3.4.4
Zuweisungsoperator ............................................................................... Arithmetische Operatoren ..................................................................... Typkonvertierungen ................................................................................ Vergleichsoperationen ............................................................................
5
Inhalt
3.5
Kontrollfluss .......................................................................................................... 3.5.1 3.5.2 3.5.3
3.6
Elementare Ein- und Ausgabe ............................................................................ 3.6.1 3.6.2 3.6.3
3.7
4
Bildschirmausgabe .................................................................................. Tastatureingabe ...................................................................................... Kommentare und Layout ........................................................................
Beispiele ..................................................................................................................
67 67 69 72
Das erste Programm ............................................................................... Das zweite Programm ............................................................................. Das dritte Programm ..............................................................................
73 73 75 79
Aufgaben ................................................................................................................
81
3.7.1 3.7.2 3.7.3
3.8
Bedingte Befehlsausführung ................................................................. Wiederholte Befehlsausführung ........................................................... Verschachtelung von Kontrollstrukturen .............................................
56 57 59 65
Arithmetik
83
4.1
Folgen ......................................................................................................................
85
4.2
Summen und Produkte ........................................................................................
96
4.3
Aufgaben ................................................................................................................
100
5
Aussagenlogik
107
5.1
Aussagen ................................................................................................................
108
5.2
Aussagenlogische Operatoren ...........................................................................
108
5.3
Boolesche Funktionen .........................................................................................
116
5.4
Logische Operatoren in C ....................................................................................
119
5.5
Beispiele .................................................................................................................. 5.5.1 5.5.2
5.6
6
120 Kugelspiel ................................................................................................. 120 Schaltung ................................................................................................. 122
Aufgaben ................................................................................................................
126
Inhalt
6
Elementare Datentypen und ihre Darstellung Dualdarstellung ....................................................................................... Oktaldarstellung ...................................................................................... Hexadezimaldarstellung ........................................................................
130 134 135 136
6.2
Bits und Bytes ........................................................................................................
137
6.3
Skalare Datentypen in C ......................................................................................
6.1
Zahlendarstellungen ............................................................................................
129
6.1.1 6.1.2 6.1.3
6.3.1 6.3.2
139 Ganze Zahlen ........................................................................................... 140 Gleitkommazahlen .................................................................................. 144
Bitoperationen ......................................................................................................
146
Programmierbeispiele ......................................................................................... Kartentrick ................................................................................................ Zahlenraten .............................................................................................. Addierwerk ...............................................................................................
150 150 152 154
6.6
Zeichen ....................................................................................................................
156
6.7
Arrays ......................................................................................................................
6.4 6.5
6.5.1 6.5.2 6.5.3
6.7.1 6.7.2
6.8 6.9
Zeichenketten .......................................................................................................
164
Programmierbeispiele ......................................................................................... Buchstabenstatistik ................................................................................ Sudoku ......................................................................................................
173 173 175
Aufgaben ................................................................................................................
178
6.9.1 6.9.2
6.10
7
159 Eindimensionale Arrays .......................................................................... 160 Mehrdimensionale Arrays ...................................................................... 162
Modularisierung
181
7.1
Funktionen .............................................................................................................
181
7.2
Arrays als Funktionsparameter .........................................................................
186
7.3
Lokale und globale Variablen .............................................................................
190
7.4
Rekursion ................................................................................................................
192
7.5
Der Stack .................................................................................................................
198
Beispiele ..................................................................................................................
200 200 202
7.6
7.6.1 7.6.2
Bruchrechnung ........................................................................................ Das Damenproblem ................................................................................
7
Inhalt
Permutationen ......................................................................................... Labyrinth ..................................................................................................
210 213
Aufgaben ................................................................................................................
218
7.6.3 7.6.4
7.7
8
Zeiger und Adressen
223
8.1
Zeigerarithmetik ...................................................................................................
230
8.2
Zeiger und Arrays ..................................................................................................
232
8.3
Funktionszeiger .....................................................................................................
235
8.4
Aufgaben ................................................................................................................
239
9 9.1
Programmgrobstruktur Der Präprozessor ................................................................................................... Includes .................................................................................................... Symbolische Konstanten ........................................................................ Makros ...................................................................................................... Bedingte Kompilierung ...........................................................................
241 242 244 245 247
Ein kleines Projekt ................................................................................................
249
9.1.1 9.1.2 9.1.3 9.1.4
9.2
241
10 Die Standard C Library
253
10.1
Mathematische Funktionen ...............................................................................
254
10.2
Zeichenklassifizierung und -konvertierung ....................................................
256
10.3
Stringoperationen ................................................................................................
257
10.4
Ein- und Ausgabe ..................................................................................................
260
10.5
Variable Anzahl von Argumenten .....................................................................
263
10.6
Freispeicherverwaltung .......................................................................................
265
10.7
Aufgaben ................................................................................................................
271
8
Inhalt
11 Kombinatorik
273
11.1
Kombinatorische Grundaufgaben ....................................................................
274
11.2
Permutationen mit Wiederholungen ...............................................................
274
Permutationen ohne Wiederholungen ............................................................
275 277 278 280
11.3
11.3.1 11.3.2 11.3.3
11.4
Kombinatorische Algorithmen .......................................................................... 11.4.1 11.4.2 11.4.3 11.4.4
11.5
Kombinationen ohne Wiederholungen ................................................ Kombinationen mit Wiederholungen ................................................... Zusammenfassung .................................................................................
283 284 286 288 Permutationen ohne Wiederholungen ................................................. 290 Permutationen mit Wiederholungen ................................................... Kombinationen mit Wiederholungen ................................................... Kombinationen ohne Wiederholungen ................................................
Beispiele .................................................................................................................. 11.5.1 11.5.2
Juwelenraub ............................................................................................. Geldautomat ............................................................................................
12 Leistungsanalyse und Leistungsmessung 12.1 12.2
308
Leistungsmessung ................................................................................................ Überdeckungsanalyse ............................................................................. Performance-Analyse ..............................................................................
320 322 323
Laufzeitklassen ......................................................................................................
324
13 Sortieren 13.1
305
Leistungsanalyse ................................................................................................... 12.2.1 12.2.2
12.3
293 293 298
Sortierverfahren .................................................................................................... 13.1.1 13.1.2 13.1.3
Bubblesort ................................................................................................ Selectionsort ............................................................................................ Insertionsort .............................................................................................
13.1.4 13.1.5 13.1.6
Shellsort .................................................................................................... Quicksort .................................................................................................. Heapsort ...................................................................................................
347 347 349 351 353 356 359 370
9
Inhalt
Bubblesort ................................................................................................ Selectionsort ............................................................................................ Insertionsort ............................................................................................. Shellsort .................................................................................................... Quicksort .................................................................................................. Heapsort ...................................................................................................
376 376 377 378 379 380 381
13.3
Leistungsmessung der Sortierverfahren ..........................................................
383
13.4
Grenzen der Optimierung von Sortierverfahren ............................................
388
13.2
Leistungsanalyse der Sortierverfahren ............................................................ 13.2.1 13.2.2 13.2.3 13.2.4 13.2.5 13.2.6
14 Datenstrukturen 14.1
Strukturdeklarationen ......................................................................................... 14.1.1
Variablendefinitionen .............................................................................
395 398
Direktzugriff ............................................................................................. Indirektzugriff ..........................................................................................
400 401 403
14.3
Datenstrukturen und Funktionen .....................................................................
405
14.4
Ein vollständiges Beispiel (Teil 1) .......................................................................
409
14.5
Dynamische Datenstrukturen ............................................................................
415
14.6
Ein vollständiges Beispiel (Teil 2) ......................................................................
421
14.7
Die Freispeicherverwaltung ...............................................................................
432
14.8
Aufgaben ................................................................................................................
435
14.2
Zugriff auf Strukturen .........................................................................................
393
14.2.1 14.2.2
15 Ausgewählte Datenstrukturen 15.1 15.2
Listen .......................................................................................................................
439
Bäume .....................................................................................................................
448 451 461
15.2.1 15.2.2
15.3
Traversierung von Bäumen .................................................................... Aufsteigend sortierte Bäume .................................................................
Treaps ...................................................................................................................... 15.3.1 15.3.2
10
437
470 Heaps ........................................................................................................ 471 Der Container als Treap .......................................................................... 473
Inhalt
15.4
Hash-Tabellen ........................................................................................................ 15.4.1 15.4.2
482 Speicherkomplexität ............................................................................... 489 Laufzeitkomplexität ................................................................................ 490
16 Abstrakte Datentypen
493
16.1
Der Stack als abstrakter Datentyp ....................................................................
495
16.2
Die Queue als abstrakter Datentyp ..................................................................
500
17 Elemente der Graphentheorie
507
17.1
Graphentheoretische Grundbegriffe ................................................................
510
17.2
Die Adjazenzmatrix ..............................................................................................
511
17.3
Beispielgraph (Autobahnnetz) ...........................................................................
512
17.4
Traversierung von Graphen ................................................................................
514
17.5
Wege in Graphen ..................................................................................................
516
17.6
Der Algorithmus von Warshall ..........................................................................
518
17.7
Kantentabellen .....................................................................................................
522
17.8
Zusammenhang und Zusammenhangskomponenten .................................
523
17.9
Gewichtete Graphen ............................................................................................
530
17.10 Kürzeste Wege ......................................................................................................
532
17.11 Der Algorithmus von Floyd .................................................................................
533
17.12 Der Algorithmus von Dijkstra ............................................................................
539
17.13 Erzeugung von Kantentabellen .........................................................................
546
17.14 Der Algorithmus von Ford ...................................................................................
548
17.15 Minimale Spannbäume .......................................................................................
551
17.16 Der Algorithmus von Kruskal .............................................................................
552
17.17 Hamiltonsche Wege .............................................................................................
557
17.18 Das Travelling-Salesman-Problem ....................................................................
562
11
Inhalt
18 Zusammenfassung und Ergänzung
575
19 Einführung in C++
677
19.1
Schlüsselwörter .....................................................................................................
677
19.2
Kommentare ..........................................................................................................
678
19.3
Datentypen, Datenstrukturen und Variablen ................................................
679 679 680 680 681 682 683 684 688 689
19.4
19.3.1 19.3.2 19.3.3
Automatische Typisierung von Aufzählungstypen ............................. Automatische Typisierung von Strukturen .......................................... Vorwärtsverweise auf Strukturen .........................................................
19.3.4 19.3.5 19.3.6 19.3.7 19.3.8 19.3.9
Der Datentyp bool ................................................................................... Verwendung von Konstanten ................................................................ Definition von Variablen ........................................................................ Verwendung von Referenzen ................................................................. Referenzen als Rückgabewerte .............................................................. Referenzen außerhalb von Schnittstellen ............................................
Funktionen ............................................................................................................. 19.4.1 19.4.2 19.4.3 19.4.4 19.4.5 19.4.6 19.4.7
19.5
692 694 696 698 699 700
701 Der Globalzugriff ..................................................................................... 702 Alle Operatoren in C++ ............................................................................ 703 Überladen von Operatoren .................................................................... 707
Auflösung von Namenskonflikten .................................................................... 19.6.1
12
Vorgegebene Werte in der Funktionsschnittstelle (Default-Werte) ....................................................................................... Inline-Funktionen .................................................................................... Überladen von Funktionen ..................................................................... Parametersignatur von Funktionen ...................................................... Zuordnung der Parametersignaturen und der passenden Funktion ................................................................................................... Verwendung von C-Funktionen in C++-Programmen .........................
Operatoren ............................................................................................................. 19.5.1 19.5.2 19.5.3
19.6
690 Funktionsdeklarationen und Prototypen ............................................. 691
Der Standardnamensraum std ..............................................................
711 715
Inhalt
20 Objektorientierte Programmierung
717
20.1
Ziele der Objektorientierung ..............................................................................
717
20.2
Objektorientiertes Design ..................................................................................
719
20.3
Klassen in C++ ........................................................................................................
725
20.4
Aufbau von Klassen ..............................................................................................
725 726 727 729 731 735 739
20.4.1 20.4.2 20.4.3 20.4.4 20.4.5 20.4.6
20.5
Instanziierung von Klassen ................................................................................. 20.5.1 20.5.2 20.5.3 20.5.4 20.5.5 20.5.6 20.5.7
20.6
Zugriffsschutz von Klassen .................................................................... Datenmember ......................................................................................... Funktionsmember ................................................................................... Verwendung des Zugriffsschutzes ........................................................ Konstruktoren .......................................................................................... Destruktoren ............................................................................................ Automatische Variablen in C ................................................................. Automatische Instanziierung in C++ ..................................................... Statische Variablen in C .......................................................................... Statische Instanziierung in C++ ............................................................. Dynamische Variablen in C .................................................................... Dynamische Instanziierung in C++ ........................................................ Instanziierung von Arrays in C++ ...........................................................
740 740 741 741 742 743 743 744
Operatoren auf Klassen ....................................................................................... 20.6.1 20.6.2
745 Friends ...................................................................................................... 746 Operator als Methode der Klasse .......................................................... 747 Überladen des 0)? Wenn nein, dann beende das Verfahren! 2.2 Setze z = 10(z-nx)! 2.3 Ist z = 0, beende das Verfahren! 2.4 Bestimme die größte ganze Zahl x mit nx ⱕ z! Dies ist die nächste Ziffer. 2.5 Jetzt ist eine Ziffer weniger zu bestimmen. Vermindere also den Wert von a um 1, und fahre anschließend bei 2.1 fort! Führen Sie diese Anweisungen an dem Beispiel z = 84, n = 16 und a = 5 Schritt für Schritt durch, und Sie werden sehen, dass sich das Ergebnis 5.25 ergibt. Die einzelnen Anweisungen und ihre Abfolge können Sie sich durch ein sogenanntes Flussdiagramm veranschaulichen. In einem solchen Diagramm werden alle beim Ablauf des Algorithmus möglicherweise vorkommenden Wege unter Verwendung bestimmter Symbole grafisch beschrieben. Die dabei zulässigen Symbole sind in einer Norm (DIN 66001) festgelegt. Von den zahlreichen in dieser Norm festgelegten
3 Zunächst ist a die Anzahl der zu berechnenden Nachkommastellen. Im Verfahren verwenden wir a als die Anzahl der noch zu berechnenden Nachkommastellen. Wir werden den Wert von a in jedem Verfahrensschritt herunterzählen, bis a = 0 ist und keine Nachkommastellen mehr zu berechnen sind.
25
1
1
Einige Grundbegriffe
Symbolen möchten wir Ihnen an dieser Stelle nur einige wenige vorstellen und sie verwenden:
Start oder Ende des Algorithmus Ein- oder Ausgabe Allgemeine Operation Verzweigung Abbildung 1.3 Symbole im Flussdiagramm
Mit diesen Symbolen können Sie den zuvor nur sprachlich beschriebenen Algorithmus grafisch darstellen, wenn Sie zusätzlich die Abfolge der einzelnen Operationen durch Richtungspfeile kennzeichnen: Start
Eingabe: z, n, a
1
x = größte ganze Zahl mit nx ≤ z
Ausgabe: »Ergebnis = x.«
2.1
a>0
nein
ja
2.2
2.3
z = 10(z – nx) ja z=0 nein
x = größte ganze Zahl mit nx ≤ z
2.4 Ausgabe: »x«
2.5
a=a–1
Abbildung 1.4 Flussdiagramm des Algorithmus
26
Ende
1.1
Algorithmus
In Abbildung 1.4 können Sie den Ablauf des Algorithmus für konkrete Anfangswerte »mit dem Finger« nachfahren und erhalten so eine recht gute Vorstellung von der Dynamik des Verfahrens. Wir möchten Ihnen den Divisionsalgorithmus anhand des Flussdiagramms für konkrete Daten (z=84, n=16, a=4) Schritt für Schritt erläutern. Mehrfach durchlaufene Teile zeichnen wir dabei entsprechend oft, nicht durchlaufene Pfade lassen wir weg: Start Eingabe: z = 84, n = 16, a=4
1
x = größte ganze Zahl mit 16 x ≤ 84 = 5 Ausgabe: »Ergebnis = 5.«
2.1
2.2
2.3
a>0
a>0 ja
ja
z = 10 (84 – 16 · 5) = 40
z = 10 (40 – 16 · 2) = 80
z = 10 (80 – 16 · 5)
z=0 nein
x = größte ganze Zahl mit 16 x ≤ 40 = 2
z=0
z=0
nein
x = größte ganze Zahl mit 16 x ≤ 80 = 5
ja Ende
2.4
2.5
a>0
ja
Ausgabe: »2«
Ausgabe: »5«
a=4–1=3
a=3–1=2
Abbildung 1.5 Das Flussdiagramm für einen konkreten Fall
Als Ergebnis erhalten wir die Ausgabe "5.25". Sie sehen, dass der Algorithmus gewisse Verfahrensschritte (z. B. 2.1) mehrfach – allerdings mit unterschiedlichen Daten – durchläuft. Die Daten steuern letztlich den konkreten Ablauf des Algorithmus. Das Verfahren zeigt im Ablauf eine gewisse Regelmäßigkeit – um nicht zu sagen Monotonie. Gerade solche monotonen Aufgaben würde man sich gern von einer Maschine abnehmen lassen. Eine Maschine müsste natürlich jeden einzelnen Verfahrensschritt »verstehen«, um das Verfahren als Ganzes durchführen zu können. Einige unserer Schritte (z. B. 2.2) erscheinen unmittelbar verständlich, während andere (z. B. 2.4) ein gewisses mathematisches Vorverständnis voraussetzen. Je nachdem, welche Intelligenz man bei demjenigen (Mensch oder Maschine) voraussetzt, der den Algorithmus durchführen soll, wird man an manchen Stellen noch präziser formulieren und einen Verfahrensschritt gegebenenfalls in einfachere Teilschritte zerlegen müssen.
27
1
1
Einige Grundbegriffe
Festgehalten werden sollte noch, dass wir von einem Algorithmus gefordert haben, dass er nach endlich vielen Schritten zu einem Ergebnis kommt (terminiert). Dies ist bei unserem Divisionsalgorithmus durch die Vorgabe der Anzahl der zu berechnenden Nachkommastellen sichergestellt, auch wenn in unserem konkreten Beispiel ein vorzeitiger Abbruch eintritt. Würden wir das Abbruchkriterium fallenlassen, würde unser Verfahren unter Umständen (z. B. bei der Berechnung von 10:3) nicht abbrechen, und eine mit der Berechnung beauftragte Maschine würde endlos rechnen. Es ist zu befürchten, dass die Eigenschaft des Terminierens für manche Verfahren schwer oder vielleicht auch gar nicht nachzuweisen ist.
1.2
Datenstruktur
Wir starten wieder mit einer Definition:
Was ist eine Datenstruktur? Eine Datenstruktur ist ein Modell, das die zur Lösung eines Problems benötigten Informationen (Ausgangsdaten, Zwischenergebnisse, Endergebnisse) enthält und für alle Informationen genau festgelegte Zugriffswege bereitstellt.
Auch Datenstrukturen hat es bereits lange vor der Programmierung gegeben, obwohl man hier mit einigem Recht sagen kann, dass die Theorie der Datenstrukturen erst mit der maschinellen Datenverarbeitung zur Blüte gekommen ist. Als Beispiel betrachten wir ein Versandhaus, das seine Geschäftsvorfälle durch drei Karteien organisiert: Eine Kundenkartei mit den personenbezogenen Daten aller Kunden, eine Artikelkartei für die Stammdaten und den Lagerbestand aller lieferbaren Artikel und eine Bestellkartei für alle eingehenden Bestellungen (siehe Abbildung 1.6).
Kunde Kundennummer: Name: Vorname: Adresse:
Artikel Bezeichnung: Art.Nr.: Lagerbestand: EK-Preis: VK-Preis:
1234 Meier Otto … Bestellung Kunde: Datum: Artikel: Anzahl: Artikel: Anzahl: …
Abbildung 1.6 Verbundene Karteikästen
28
1234 13.06.2013 12-3456 1 … …
Kamera 12-3456 11 123,45… 345,67
1.2
Datenstruktur
Ein einzelner Datensatz entspricht einer ausgefüllten Karteikarte. Auf jeder Karteikarte sind zwei Bereiche erkennbar. Links steht jeweils die Struktur der Daten, während rechts die konkreten Datenwerte stehen. Die Datensätze für Kunden, Artikel und Bestellungen sind dabei strukturell verschieden. Neben der Struktur der Karteikarten ist natürlich auch noch die Organisation der einzelnen Karteikästen von Bedeutung. Stellen Sie sich vor, dass die Kundendatei nach Kundennummern, die Artikeldatei nach Artikelnummern und die Bestelldatei nach Bestelldatum sortiert ist. Darüber hinaus gibt es noch Querverweise zwischen den Datensätzen der verschiedenen Karteikästen. In der Bestelldatei finden Sie auf jeder Karteikarte z. B. Artikelnummern und eine Kundennummer. Die drei Karteikästen mit ihrer Sortierung, der Struktur ihrer Karteikarten und der Querverweisstruktur bilden insgesamt die Datenstruktur. Beachten Sie, dass die konkreten Daten – also das, was auf den ausgefüllten Karteikarten steht – nicht zur Datenstruktur gehören. Die Datenstruktur legt nur die Organisationsform der Daten fest, nicht jedoch die konkreten Datenwerte. Auf der Datenstruktur arbeiten Algorithmen (z. B. Kundenadresse ändern, Rechnung stellen, Artikel nachbestellen, Lieferung zusammenstellen etc.). Die Effizienz dieser Algorithmen hängt dabei ganz entscheidend von der Organisation der Datenstruktur ab. Zum Beispiel ist die Frage: »Was hat der Kunde Müller dem Unternehmen bisher an Umsatz eingebracht?« ausgesprochen schwer zu beantworten. Dazu müssten Sie zunächst in der Kundendatei die Kundennummer des Kunden Müller finden. Als Nächstes müssten Sie alle Bestellungen durchsuchen, um festzustellen, ob die Kundennummer von Müller dort vorkommt, und schließlich müssten Sie dann auch noch die Preise der in den betroffenen Bestellungen vorkommenden Artikel in der Artikeldatei suchen und aufsummieren. Die Frage: »Welche Artikel in welcher Menge sind im letzten Monat bestellt worden?« lässt sich mit dieser Datenstruktur erheblich einfacher beantworten. Das Problem, eine »bestmögliche« Organisationsform für Daten zu finden, ist im Allgemeinen unlösbar, weil Sie dazu in der Regel gegenläufige Optimierungsaspekte in Einklang bringen müssten. Sie könnten z. B. bei der oben dargestellten Datenstruktur den Verbesserungsvorschlag machen, alle Kundendaten mit auf der Bestellkartei zu vermerken, um die Rechnungsstellung zu erleichtern. Dadurch erhöht sich dann aber der Aufwand, den Sie bei der Adressänderung eines Kunden in Kauf zu nehmen hätten. Die Erstellung von Datenstrukturen, die alle Algorithmen eines bestimmten Problemfeldes wirkungsvoll unterstützen, ist eine ausgesprochen schwierige Aufgabe, zumal man häufig zum Zeitpunkt der Festlegung einer Datenstruktur noch gar nicht absehen kann, welche Algorithmen in Zukunft mit den Daten dieser Struktur arbeiten werden.
29
1
1
Einige Grundbegriffe
Bei der Fülle der in der Praxis vorkommenden Probleme können Sie natürlich nicht erwarten, dass Sie für alle Probleme passende Datenstrukturen bereitstellen können. Sie müssen lernen, typische, immer wiederkehrende Bausteine zu identifizieren und zu beherrschen. Aus diesen Bausteinen können Sie dann komplexere, jeweils an ein bestimmtes Problem angepasste Strukturen aufbauen.
1.3
Programm
Ein Programm ist, im Gegensatz zu einer Datenstruktur oder einem Algorithmus, etwas sehr Konkretes – zumindest dann, wenn Sie schon einmal ein Programm erstellt oder benutzt haben.
Was ist ein Programm? Ein Programm ist eine eindeutige, formalisierte Beschreibung von Algorithmen und Datenstrukturen, die durch einen automatischen Übersetzungsprozess auf einem Computer ablauffähig ist. Den zur Formulierung eines Programms verwendeten Beschreibungsformalismus bezeichnen wir als Programmiersprache.
Im Gegensatz zu einem Algorithmus fordern wir von einem Programm nicht explizit, dass es terminiert. Viele Programme (z. B. ein Betriebssystem oder Programme zur Überwachung und Steuerung technischer Anlagen) sind auch so konzipiert, dass sie im Prinzip endlos laufen könnten. Eine Programmiersprache muss nach dieser Definition Elemente zur exakten Beschreibung von Datenstrukturen und Algorithmen enthalten. Programmiersprachen dienen daher nicht nur zur Erstellung lauffähiger Programme, sondern auch zur präzisen Festlegung von Datenstrukturen und Algorithmen. Dazu müssen Sie lernen, in einer Programmiersprache so selbstverständlich zu »reden« wie in einer natürlichen Sprache. Eigentlich stellen wir gegensätzliche Forderungen an eine Programmiersprache. Sie sollte automatisch übersetzbar, d. h. maschinenlesbar, und möglichst verständlich und leicht erlernbar, d. h. menschenlesbar, sein, und sie sollte darüber hinaus die maschinellen Berechnungs- und Verarbeitungsmöglichkeiten eines Computers möglichst vollständig ausschöpfen. Maschinenlesbarkeit und Menschenlesbarkeit sind bei den heutigen Maschinenkonzepten unvereinbare Begriffe. Da die Maschinenlesbarkeit jedoch unverzichtbar ist, müssen zwangsläufig bei der Menschenlesbarkeit Kompromisse gemacht werden; Kompromisse, von denen Berufsgruppen wie Systemanalytiker oder Programmierer leben.
30
1.4
1.4
Programmiersprachen
Programmiersprachen
Sie kennen das sicherlich aus dem einen oder anderen Internetforum zur Programmierung. Da fragt ein Newbie um Rat, und es entwickelt sich folgender Dialog: Newbie: Hallo, ich bin neu hier und habe da eine Frage. Wie kann man in der Programmiersprache abc ... Experte1: Hallo Newbie, ich kenne abc nicht. Ich programmiere aber schon seit Jahren in xyz. In xyz kann man dein Problem ganz einfach lösen ... Experte2: Also Experte1, du lebst ja völlig hinter dem Mond. Kein Mensch programmiert heute mehr in xyz. So etwas macht man in uvw ... Der Expertenstreit, ob nun xyz oder uvw die bessere Programmiersprache sei, wird dann mit wachsender Schärfe über mehrere Wochen ausgefochten, bis beide Kontrahenten ermüdet aufgeben, nicht ohne vorher noch einmal deutlich klarzustellen, dass der jeweils andere keine Ahnung habe und jedes weitere Wort Zeitverschwendung sei. Vielleicht kommt auch der Newbie noch mal zu Wort: Newbie: Hallo, ich habe inzwischen eine Lösung gefunden. Es war eigentlich ganz einfach ... Lassen Sie sich auf solche zwecklosen ideologischen Grabenkriege, die seit Jahren mit erstarrten Fronten geführt werden, nicht ein. Sicherlich gibt es Sprachen, die für den einen oder anderen Anwendungszweck besser geeignet sind als andere, aber aus Sicht der Informatik sind alle Sprachen gleich gut (oder eher schlecht). Wichtig ist, dass es verschiedene Programmiersprachen gibt, denn nur diese Vielfalt und der damit verbundene Wettbewerb sorgen für die stetige Weiterentwicklung aller Programmiersprachen. Vielleicht hilft Ihnen ein bisschen Statistik weiter. Der Tiobe-Index (tiobe.com) listet 225 verschiedene Programmiersprachen, die in einer monatlichen Statistik auf ihre Relevanz untersucht werden. Aktuell ergibt sich dabei das folgende Ranking: Rang
Name
Anteil
1
C
17,8 %
2
Java
16,6 %
3
Objective-C
10,3 %
4
C++
8,8 %
5
PHP
5,9 %
Tabelle 1.1 Ranking der Programmiersprachen
31
1
1
Einige Grundbegriffe
Betrachtet man innerhalb dieser Tabelle die Sprachen, die sich explizit auf C als »Muttersprache« berufen, machen diese einen Anteil von über 40 % aus. Auch Programmiersprachen wie Java oder PHP sind sprachlich eng mit C verwandt, auch wenn sie auf anderen Laufzeitkonzepten beruhen. Der Tiobe-Index unterscheidet auch verschiedene Programmierparadigmen4 und kommt hier zu folgendem Ergebnis: Rang
Name
Anteil
1
Objektorientiertes Paradigma
58,5 %
2
Prozedurales Paradigma
36,6 %
3
Funktionales Paradigma
3,2 %
4
Logisches Paradigma
1,8 %
Tabelle 1.2 Ranking der Programmierparadigmen
Diese Unterscheidung ist eigentlich viel wichtiger als die Unterscheidung in einzelne Programmiersprachen, denn wer eine Sprache eines bestimmten Paradigmas beherrscht, dem fällt es in der Regel leicht, auf eine andere Sprache des gleichen Paradigmas zu wechseln. Sie lernen hier mit C das prozedurale und mit C++ das objektorientierte Paradigma und sind damit für über 90 % aller Fälle bestens gerüstet. Wenn Sie Ihre Programmierkenntnisse beruflich nutzen wollen, können Sie in der Regel die Programmiersprache, die in einem Softwareprojekt verwendet wird, nicht frei wählen. Die Sprache ist meistens durch innere oder äußere Randbedingungen festgelegt. In dieser Situation ist es wichtig, dass Sie »programmieren« können, und darunter verstehe ich weitaus mehr als die Beherrschung einer Programmiersprache. Wenn ein Verlag einen Autor sucht, dann wird jemand gesucht, der »schreiben« kann. Dabei bedeutet »schreiben« mehr als die bloße Beherrschung von Rechtschreibung und Grammatik. In diesem Sinne versteht sich dieses Buch als ein Lehrbuch zum Programmieren, wobei programmieren weitaus mehr ist als die Beherrschung einer konkreten Programmiersprache. Eines der bedeutendsten Bücher der Informatik heißt: The Art of Computer Programming5 (Die Kunst der Computerprogrammierung) In diesem mehrbändigen Werk finden Sie nicht eine einzige Zeile Code in einer konkreten Programmiersprache.
4 Unter dem Paradigma einer Programmiersprache versteht man, locker gesprochen, die »Denke«, die hinter einer Programmiersprache steckt. 5 Donald E. Knuth, The Art of Computer Programming
32
1.5
Aufgaben
Natürlich macht Programmieren erst richtig Spaß, wenn das Ergebnis (z. B. ein Computerspiel) am Ende über den Bildschirm eines Computers flimmert. Darum nehmen konkrete Programmierbeispiele in C und C++ in diesem Buch breiten Raum ein.
1.5
Aufgaben
A 1.1
Formulieren Sie Ihr morgendliches Aufsteh-Ritual vom Klingeln des Weckers bis zum Verlassen des Hauses als Algorithmus. Berücksichtigen Sie dabei auch verschiedene Wochentagsvarianten! Zeichnen Sie ein Flussdiagramm!
A 1.2
Verfeinern Sie den Algorithmus zur Division zweier Zahlen aus Abschnitt 1.1 so, dass er von jemandem, der nur Zahlen addieren, subtrahieren und der Größe nach vergleichen kann, durchgeführt werden kann! Zeichnen Sie ein Flussdiagramm!
A 1.3
In unserem Kalender sind zum Ausgleich der astronomischen und der kalendarischen Jahreslänge in regelmäßigen Abständen Schaltjahre eingebaut. Zur exakten Festlegung der Schaltjahre dienen die folgenden Regeln: 1. Ist die Jahreszahl durch 4 teilbar, ist das Jahr ein Schaltjahr. Diese Regel hat allerdings eine Ausnahme: 2. Ist die Jahreszahl durch 100 teilbar, ist das Jahr kein Schaltjahr. Diese Ausnahme hat wiederum eine Ausnahme: 3. Ist die Jahreszahl durch 400 teilbar, ist das Jahr doch ein Schaltjahr. Formulieren Sie einen Algorithmus, mit dessen Hilfe man feststellen kann, ob ein bestimmtes Jahr ein Schaltjahr ist oder nicht!
A 1.4 Sie sollen eine unbekannte Zahl x (a ⱕ x ⱕ b) erraten und haben beliebig viele Versuche dazu. Bei jedem Versuch erhalten Sie die Rückmeldung, ob die gesuchte Zahl größer, kleiner oder gleich der von Ihnen geratenen Zahl ist. Entwickeln Sie einen Algorithmus, um die gesuchte Zahl möglichst schnell zu ermitteln! Wie viele Versuche benötigen Sie bei Ihrem Verfahren maximal? A 1.5
Formulieren Sie einen Algorithmus, der prüft, ob eine gegebene Zahl eine Primzahl ist oder nicht!
A 1.6 Ihr CD-Ständer hat 100 Fächer, die fortlaufend von 1–100 nummeriert sind. In jedem Fach befindet sich eine CD. Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die CDs alphabetisch nach Interpreten sortieren können! Das Verfahren soll dabei auf den beiden folgenden Grundfunktionen basieren: vergleiche(n,m)
33
1
1
Einige Grundbegriffe
Vergleichen Sie CDs in den Fächern n und m. Das Ergebnis ist »richtig« oder »falsch« – je nachdem, ob die beiden CDs in der richtigen oder falschen Reihenfolge im Ständer stehen. tausche(n,m)
Tauschen Sie die CDs in den Fächern n und m. A 1.7
Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die CDs in Ihrem CDStänder jeweils um ein Fach aufwärts verschieben können! Die dabei am Ende herausgeschobene CD kommt in das erste Fach. Das Verfahren soll nur auf der Grundfunktion tausche aus Aufgabe 1.6 beruhen.
A 1.8 Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die Reihenfolge der CDs in Ihrem CD-Ständer umkehren können! Das Verfahren soll nur auf der Grundfunktion tausche aus Aufgabe 1.6 beruhen. A 1.9 In einem Hochhaus mit 20 Stockwerken gibt es einen Aufzug. Im Aufzug sind 20 Knöpfe, mit denen man sein Fahrziel wählen kann, und auf jeder Etage ist ein Knopf, mit dem man den Aufzug rufen kann. Entwickeln Sie einen Algorithmus, der den Aufzug so steuert, dass alle Aufzugbenutzer gerecht bedient werden! A 1.10 Beim Schach gibt es ein einfaches Endspiel, wenn die eine Seite den König und einen Turm, die andere Seite dagegen nur noch den König auf dem Spielfeld hat:
Abbildung 1.7 Darstellung des Endspiels
Versuchen Sie, den Algorithmus für das Endspiel so zu formulieren, dass auch ein Nicht-Schachspieler die Spielstrategie versteht!
34
Kapitel 2 Einführung in die Programmierung
2
Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it. – Brian Kernighan
Bevor wir in den Mikrokosmos der C-Programmierung abtauchen, wollen wir Softwaresysteme und ihre Erstellung von einer höheren Warte aus betrachten. Dieser Abschnitt dient der Einordnung dessen, was Sie später im Detail kennenlernen werden, in einen Gesamtzusammenhang. Auch wenn Ihnen noch nicht alle Begriffe, die hier fallen werden, unmittelbar klar sind, ist es doch hilfreich, wenn Sie bei den vielen Details, die später wichtig werden, den Blick für das Ganze nicht verlieren.
2.1
Softwareentwicklung
Damit ein Problem durch ein Softwaresystem gelöst werden kann, muss es zunächst einmal erkannt, abgegrenzt und adäquat beschrieben werden. Der Softwareingenieur spricht in diesem Zusammenhang von Systemanalyse. In einem weiteren Schritt wird das Ergebnis der Systemanalyse in den Systementwurf überführt, der dann Grundlage für die nachfolgende Realisierung oder Implementierung ist. Der Softwareentwicklungszyklus beginnt also nicht mit der Programmierung, sondern es gibt wesentliche, der Programmierung vorgelagerte, aber auch nachgelagerte Aktivitäten. Obwohl wir in diesem Buch nur die »Softwareentwicklung im Kleinen« und hier auch nur Realisierungsaspekte behandeln werden, möchten wir Sie doch zumindest auf einige Aktivitäten und Werkzeuge der »Softwareentwicklung im Großen« hinweisen. Für die Realisierung großer Softwaresysteme muss zunächst einmal ein sogenanntes Vorgehensmodell zugrunde gelegt werden. Ausgangspunkt sind dabei Standardvorgehensmodelle wie etwa das V-Modell:
35
2
Einführung in die Programmierung
System Anforderungsanalyse
System Integration
DV Anforderungsanalyse
DV Integration
Software Anforderungsanalyse Software Integration Software Grobentwurf
Software Feinentwurf
Implementierung Abbildung 2.1 Das V-Modell
Große Unternehmen verfügen in der Regel über eigene Vorgehensmodelle zur Softwareentwicklung. Ein solches allgemeines Modell muss auf die Anforderungen eines konkreten Entwicklungsvorhabens zugeschnitten werden. Man spricht in diesem Zusammenhang von Tailoring. Das auf ein konkretes Projekt zugeschnittene Vorgehensmodell nennt dann alle prinzipiell anfallenden Projektaktivitäten mit den zugeordneten Eingangs- und Ausgangsprodukten (Dokumente, Code ...) sowie deren mögliche Zustände (geplant, in Bearbeitung, vorgelegt, akzeptiert) im Laufe der Entwicklung. Durch Erstellung einer Aktivitätenliste, Aufwandsschätzungen, Reihenfolgeplanung und Ressourcenzuordnung1 entsteht ein Projektplan. Wesentliche Querschnittsaktivitäten eines Projektplans sind: 왘
Projektplanung und Projektmanagement
왘
Konfigurations- und Change Management
왘
Systemanalyse
왘
Systementwurf
1 Ressourcen sind Mitarbeiter, aber auch technisches Gerät oder Rechenzeit.
36
2.1
왘
Implementierung
왘
Test und Integration
왘
Qualitätssicherung
Softwareentwicklung
2
Diese übergeordneten Tätigkeiten werden dabei oft noch in viele (hundert) Einzelaktivitäten zerlegt. Der Projektplan wird durch regelmäßige Reviews überprüft (Soll-IstVergleich) und dem wirklichen Projektstand angepasst. Ziel ist es, Entwicklungsengpässe, Entwicklungsverzögerungen und Konfliktsituationen rechtzeitig zu erkennen, um wirkungsvoll gegensteuern zu können. Für alle genannten Aktivitäten gibt es Methoden und Werkzeuge, die den Softwareingenieur bei seiner Arbeit unterstützen. Einige davon seien im Folgenden aufgezählt: Für die Projektplanung gibt es Werkzeuge, die Aktivitäten und deren Abhängigkeiten sowie Aufwände und Ressourcen erfassen und verwalten können. Solche Werkzeuge können dann konkrete Zeitplanungen auf Basis von Aufwandsschätzungen und Ressourcenverfügbarkeit erstellen. Mithilfe der Werkzeuge erstellt man dann Aktivitäten-Abhängigkeitsdiagramme (Pert-Charts) und Aktivitäten-Zeit-Diagramme (GanttCharts) sowie Berichte über den Projektfortschritt, aufgelaufene Projektkosten, SollIst-Vergleiche, Auslastung der Mitarbeiter etc. Das Konfigurationsmanagement wird von Werkzeugen, die alle Quellen (Programme und Dokumentation) eines Projekts in ein Archiv aufnehmen und jedem Mitarbeiter aktuelle Versionen mit Sperr- und Ausleihmechanismen zum Schutz vor konkurrierender Bearbeitung zur Verfügung stellen, unterstützt. Die Werkzeuge halten die Historie aller Quellen nach und können jederzeit frühere, konsistente Versionen der Software oder der Dokumentation restaurieren. Bei der Systemanalyse werden objektorientierte Analysemethoden und Beschreibungsformalismen, insbesondere UML (Unified Modeling Language), eingesetzt. Für die Analyse der Datenstrukturen verwendet man häufig sogenannte Entity-Relationship-Methoden. Alle genannten Methoden werden durch Werkzeuge (sogenannte CASE2-Tools) unterstützt. In der Regel handelt es sich dabei um Werkzeuge zur interaktiven, grafischen Eingabe des jeweiligen Modells. Alle Eingaben werden über ein zentrales Data Dictionary (Datenwörterbuch oder Datenkatalog) abgeglichen und konsistent gehalten. Durch einen Transformationsschritt erfolgt bei vielen Werkzeugen der Übergang von der Analyse zum Design, d. h. zum Systementwurf. Auch hier stehen wieder computerunterstützte Verfahren vom Klassen-, Schnittstellen- und Datendesign bis hin zur Codegenerierung oder zur Generierung eines Datenbankschemas oder von Teilen der Benutzeroberfläche (Masken, Menüs) zur Verfügung. Je nach Entwicklungsumgebung gibt es eine Vielzahl von Werkzeugen, die den Programmierer bei der Implementierung unterstützen. Verwiesen sei hier besonders auf 2 Computer Aided Software Engineering
37
Einführung in die Programmierung
die heute sehr kompletten Datenbank-Entwicklungsumgebungen sowie die vielen interaktiven Werkzeuge zur Erstellung grafischer Benutzeroberflächen. Sogenannte Make-Utilities verwalten die Abhängigkeiten aller Systembausteine und automatisieren den Prozess der Systemgenerierung aus den aktuellen Quellen. Werkzeuge zur Generierung bzw. Durchführung von Testfällen und zur Leistungsmessung runden den Softwareentwicklungsprozess in Richtung Test und Qualitätssicherung ab. Von den oben angesprochenen Themen interessiert uns hier nur die konkrete Implementierung von Softwaresystemen. Betrachtet man komplexe, aber gut konzipierte Softwaresysteme, findet man häufig eine Aufteilung (Modularisierung) des Systems in verschiedene Ebenen oder Schichten. Die Aufteilung erfolgt so, dass jede Schicht die Dienstleistungen der darunterliegenden Schicht nutzt, ohne deren konkrete Implementierung zu kennen. Typische Schichten eines Grobdesigns sehen Sie in Abbildung 2.2.
Interaktion
Funktion
Synchronisation
Visualisierung
Kommunikation
2
Datenzugriff
Abbildung 2.2 Schichten eines Softwaresystems
Jede Schicht hat ihre spezifischen Aufgaben. Auf der Ebene der Visualisierung werden die Elemente der Benutzerschnittstelle (Masken, Dialoge, Menüs, Buttons ...), aber auch Grafikfunktionen bereitgestellt. Früher wurde auf dieser Ebene mit Maskengeneratoren gearbeitet. Heute findet man hier objektorientierte Klassenbibliotheken und Werkzeuge zur interaktiven Erstellung von Benutzeroberflächen. Angestrebt wird eine konsequente Trennung von Form und Inhalt. Das heißt, das Layout der Elemente der Benutzerschnittstelle wird getrennt von den Funktionen des Systems. Unter Interaktion sind die Funktionen zusammengefasst, die die anwendungsspezifische Steuerung der Benutzerschnittstelle ausmachen. Einfache, nicht anwendungsbezogene Steuerungen, wie z. B. das Aufklappen eines
38
2.1
Softwareentwicklung
Menüs, liegen bereits in der Visualisierungskomponente. In der Regel werden die Funktionen zur Interaktion über den Benutzer (Mausklick auf einen Button etc.) angestoßen und vermitteln dann zwischen den Benutzerwünschen und den eigentlichen Funktionen des Anwendungssystems, die hier unter dem Begriff Funktion zusammengefasst sind. Auf den Ebenen Interaktion und Funktion zerfällt ein System häufig in unabhängige, vielleicht sogar parallel laufende Module, die auf einem gemeinsamen Datenbestand arbeiten. Die Datenhaltung und der Datenzugriff werden häufig in einer übergreifenden Schicht vorgenommen, denn hier muss sichergestellt werden, dass unterschiedliche Funktionen trotz konkurrierenden Zugriffs einen konsistenten Blick auf die Daten haben. Bei großen Softwaresystemen kommen Datenbanken mit ihren Management-Systemen zum Einsatz. Diese verfügen über spezielle Sprachen zur Definition, Abfrage, Manipulation und Integritätssicherung von Daten. Unterschiedliche Teile eines Systems können auf einem Rechner, aber auch verteilt in einem lokalen oder weltweiten Netz laufen. Wir sprechen dann von einem »verteilten System«. Unter dem Begriff Kommunikation werden Funktionen zum Datenaustausch zwischen verschiedenen Komponenten eines verteilten Systems zusammengefasst. Über Funktionen zur Synchronisation schließlich werden parallel arbeitende Systemfunktionen, etwa bei konkurrierendem Zugriff auf Betriebsmittel, wieder koordiniert. Die Schichten Kommunikation und Synchronisation stützen sich stark auf die vom jeweiligen Betriebssystem bereitgestellten Funktionen und sind von daher häufig an ein bestimmtes Betriebssystem gebunden. In allen anderen Bereichen versucht man, nach Möglichkeit portable Funktionen, d. h. Funktionen, die nicht an ein bestimmtes System gebunden sind, zu erstellen. Man erreicht dies, indem man allgemein verbindliche Standards, wie z. B. die Programmiersprache C, verwendet. Von den zuvor genannten Aspekten betrachten wir, wie durch eine Lupe, nur einen kleinen Ausschnitt, und zwar die Realisierung einzelner Anwendungsfunktionen:
Funktion Fu un
Synchronisation
Interaktion Interakt ak kttiio o
Synchronisa
Kommunikation
Visualisierung
Datenzugriff Datenzugrif gri g grif rriiiff ffff
Abbildung 2.3 Realisierung von Anwendungsfunktionen
39
2
2
Einführung in die Programmierung
In den Schichten Visualisierung und Interaktion werden wir uns auf das absolute Minimum beschränken, das wir benötigen, um lauffähige Programme zu erhalten, die Benutzereingaben entgegennehmen und Ergebnisse auf dem Bildschirm ausgeben können. Auch den Datenzugriff werden wir nur an sehr spartanischen Dateikonzepten praktizieren. Kommunikation und Synchronisation behandeln wir hier gar nicht. Diese Themen werden in Büchern über Betriebssysteme oder verteilte Systeme thematisiert.
2.2
Die Programmierumgebung
Bei der Realisierung von Softwaresystemen ist die Programmierung natürlich eine der zentralen Aufgaben. Abbildung 2.4 zeigt die Programmierung als eine Abfolge von Arbeitsschritten:
Editor
Programmtext erstellen bzw. modifizieren
Compiler
Programmtext übersetzen
Linker
Ausführbares Programm erzeugen
Debugger
Programm ausführen und testen
Profiler
Programm analysieren und optimieren
Abbildung 2.4 Arbeitsschritte bei der Programmierung
40
2.2
Die Programmierumgebung
Der Programmierer wird bei jedem dieser Schritte von folgenden Werkzeugen unterstützt: 왘
Editor
왘
Compiler
왘
Linker
왘
Debugger
왘
Profiler
2
Sie werden diese Werkzeuge hier nur grundsätzlich kennenlernen. Es ist absolut notwendig, dass Sie, parallel zur Arbeit mit diesem Buch, eine Entwicklungsumgebung zur Verfügung haben, mit der Sie Ihre C/C++-Programme erstellen. Um welche Entwicklungsumgebung es sich dabei handelt, ist relativ unwichtig, da wir uns mit unseren Programmen nur in einem Bereich bewegen werden, der von allen Entwicklungsumgebungen unterstützt wird. Alle konkreten Details über Editor, Compiler, Linker, Debugger und Profiler entnehmen Sie bitte den Handbüchern Ihrer Entwicklungsumgebung!
2.2.1
Der Editor
Ein Programm wird wie ein Brief in einer Textdatei erstellt und abgespeichert. Der Programmtext (Quelltext) wird mit einem sogenannten Editor3 erstellt. Es kann nicht Sinn und Zweck dieses Buches sein, Ihnen einen bestimmten Editor mit all seinen Möglichkeiten vorzustellen. Die Editoren der meisten Entwicklungsumgebungen orientieren sich an den Möglichkeiten moderner Textverarbeitungssysteme, sodass Sie, sofern Sie mit einem Textverarbeitungssystem vertraut sind, keine Schwierigkeiten mit der Bedienung des Editors Ihrer Entwicklungsumgebung haben sollten. Über die reinen Textverarbeitungsfunktionen hinaus hat der Editor in der Regel Funktionen, die Sie bei der Programmerstellung gezielt unterstützen. Art und Umfang dieser Funktionen sind allerdings auch von Entwicklungsumgebung zu Entwicklungsumgebung verschieden, sodass wir hier nicht darauf eingehen können. Üben Sie gezielt den Umgang mit den Funktionen Ihres Editors, denn auch die »handwerklichen« Aspekte der Programmierung sind wichtig! Mit dem Editor als Werkzeug erstellen wir unsere Programme, die wir in Dateien ablegen. Im Zusammenhang mit der C-Programmierung sind dies: 왘
Header-Dateien
왘
Quellcodedateien
3 engl. to edit = einen Text erstellen oder überarbeiten
41
2
Einführung in die Programmierung
Header-Dateien (engl. Headerfiles) sind Dateien, die Informationen zu Datentypen und -strukturen, Schnittstellen von Funktionen etc. enthalten. Es handelt sich dabei um allgemeine Vereinbarungen, die an verschiedenen Stellen (d. h. in verschiedenen Source- und Headerfiles) einheitlich und konsistent benötigt werden. Headerfiles stehen im Moment noch nicht im Mittelpunkt unseres Interesses. Spätestens mit der Einführung von Datenstrukturen werden wir Ihnen jedoch die große Bedeutung dieser Dateien erläutern. Die Quellcodedateien (engl. Sourcefiles) enthalten den eigentlichen Programmtext und stehen für uns zunächst im Vordergrund. Den Typ (Header oder Source) einer Datei können Sie bereits am Namen der Datei erkennen. Header-Dateien sind an der Dateinamenserweiterung .h, Quellcodedateien an der Erweiterung .c in C bzw. .cpp und .cc in C++ zu erkennen.
2.2.2
Der Compiler
Ein Programm in einer höheren Programmiersprache ist auf einem Rechner nicht unmittelbar ablauffähig. Es muss durch einen Compiler4 in die Maschinensprache des Trägersystems übersetzt werden. Der Compiler übersetzt den Quellcode (die C- oder CPP-Dateien) in den sogenannten Objectcode und nimmt dabei verschiedene Prüfungen zur Korrektheit des übergebenen Quellcodes vor. Alle Verstöße gegen die Regeln der Programmiersprache5 werden durch gezielte Fehlermeldungen unter Angabe der Zeile angezeigt. Nur ein vollständig fehlerfreies Programm kann in Objectcode übersetzt werden. Viele Compiler mahnen auch formal zwar korrekte, aber möglicherweise problematische Anweisungen durch Warnungen an. Bei der Fehlerbeseitigung sollten Sie strikt in der Reihenfolge, in der der Compiler die Fehler gemeldet hat, vorgehen. Denn häufig findet der Compiler nach einem Fehler nicht den richtigen Wiederaufsetzpunkt und meldet Folgefehler in Ihrem Programmcode, die sich bei genauem Hinsehen als gar nicht vorhanden erweisen. Der Compiler erzeugt zu jedem Sourcefile genau ein Objectfile, wobei nur die innere Korrektheit des Sourcefiles überprüft wird. Übergreifende Prüfungen können hier noch nicht durchgeführt werden. Der vom Compiler erzeugte Objectcode ist daher auch noch nicht lauffähig, denn ein Programm besteht in der Regel aus mehreren Sourcefiles, deren Objectfiles noch in geeigneter Weise kombiniert werden müssen.
4 engl. to compile = zusammenstellen 5 Man nennt so etwas einen Syntaxfehler.
42
2.2
2.2.3
Die Programmierumgebung
Der Linker
Die noch fehlende Montage der einzelnen Objectfiles zu einem fertigen Programm übernimmt der Linker6. Der Linker nimmt dabei die noch ausstehenden übergreifenden Prüfungen vor. Auch dabei kann noch eine Reihe von Fehlern aufgedeckt werden. Zum Beispiel kann der Linker in der Zusammenschau aller Objectfiles feststellen, dass versucht wird, eine Funktion zu verwenden, die es nirgendwo gibt. Letztlich erstellt der Linker das ausführbare Programm, zu dem auch weitere Funktions- oder Klassenbibliotheken hinzugebunden werden können. Bibliotheken enthalten kompilierte Funktionen, zu denen zumeist kein Quellcode verfügbar ist, und werden z. B. vom Betriebssystem oder dem C-Laufzeitsystem zur Verfügung gestellt. Im Internet finden Sie viele nützliche, freie oder kommerzielle Bibliotheken, die Ihnen die Programmierarbeit sehr erleichtern können.
2.2.4
Der Debugger
Der Debugger7 dient zum Testen von Programmen. Mit dem Debugger können die erstellten Programme bei ihrer Ausführung beobachtet werden. Darüber hinaus können Sie in das laufende Programm durch manuelles Ändern von Variablenwerten etc. eingreifen. Ein Debugger ist nicht nur zur Lokalisierung von Programmierfehlern, sondern auch zur Analyse eines Programms durch Nachvollzug des Programmablaufs oder zum interaktiven Erlernen einer Programmiersprache ausgesprochen hilfreich. Arbeiten Sie sich daher frühzeitig in die Bedienung des Debuggers Ihrer Entwicklungsumgebung ein und nicht erst, wenn Sie ihn zur Fehlersuche benötigen. Bei der Fehlersuche in Ihren Programmen bedenken Sie stets, was Brian Kernighan, neben Dennis Ritchie und Ken Thomson einer der Väter der Programmiersprache C, in dem eingangs bereits erwähnten Zitat sagt, das frei übersetzt lautet: Fehlersuche ist doppelt so schwer wie das Schreiben von Code. Wenn man also versucht, den Code so intelligent wie möglich zu schreiben, ist man prinzipiell nicht in der Lage, seine Fehler zu finden.
2.2.5
Der Profiler
Wenn Sie die Performance Ihrer Programme analysieren und optimieren wollen, sollten Sie einen Profiler verwenden. Ein Profiler überwacht Ihr Programm zur Laufzeit und erstellt sogenannte Laufzeitprofile, die Informationen über die verbrauchte Rechenzeit und den in Anspruch genommenen Speicher enthalten. Häufig können Sie ein Programm nicht gleichzeitig bezüglich seiner Laufzeit und seines Speicher6 engl. to link = verbinden 7 engl. to debug = entwanzen
43
2
2
Einführung in die Programmierung
verbrauchs optimieren. Ein besseres Zeitverhalten erkauft man oft mit einem höheren Speicherbedarf und einen geringeren Speicherbedarf mit einer längeren Laufzeit. Sie kennen das von der Kaufentscheidung für ein Auto. Wenn Sie mehr transportieren wollen, müssen Sie Einschränkungen bei der Höchstgeschwindigkeit hinnehmen. Wenn Sie umgekehrt ein schnelles Auto wollen, haben Sie in der Regel weniger Raum. Im Extremfall müssen Sie sich zwischen einem Lkw und einem Sportwagen entscheiden. Die Analyse der Speicher- und Laufzeitkomplexität von Programmen gehört zur professionellen Softwareentwicklung wie die Analyse der Effizienz eines Motors zu einer professionellen Motorenentwicklung. Ein ineffizientes Programm ist wie ein Motor, der die zugeführte Energie überwiegend in Abwärme umsetzt.
44
Kapitel 3 Ausgewählte Sprachelemente von C 3
Hello, World – Sprichwörtlich gewordene Ausgabe eines C-Programms von Brian Kernighan
Dieses Kapitel führt im Vorgriff auf spätere Kapitel einige grundlegende Programmkonstrukte sowie Funktionen zur Tastatureingabe bzw. Bildschirmausgabe ein. Ziel dieses Kapitels ist es, Ihnen das minimal notwendige Rüstzeug zur Erstellung kleiner, interaktiver Beispielprogramme bereitzustellen. Es geht in den Beispielen dieses Kapitels noch nicht darum, komplizierte Algorithmen zu entwickeln, sondern sich anhand einfacher, überschaubarer Beispiele mit Editor, Compiler und gegebenenfalls Debugger vertraut zu machen. Es ist daher wichtig, dass Sie die Beispiele – so banal sie Ihnen anfänglich auch erscheinen mögen – in Ihrer Entwicklungsumgebung editieren, kompilieren, linken und testen.
3.1
Programmrahmen
Der minimale Rahmen für unsere Beispielprogramme sieht wie folgt aus: A B
# include # include
C
void main() { ... ... ...
D
... ... ... }
Listing 3.1 Ein minimaler Programmrahmen
45
3
Ausgewählte Sprachelemente von C
Die beiden ersten mit # beginnenden Zeilen (mit A und B am Rand gekennzeichnet) übernehmen Sie einfach in Ihren Programmcode. Ich werde später etwas dazu sagen. Das eigentliche Programm besteht aus einem Hauptprogramm, das in C mit main bezeichnet werden muss. Den Zusatz void und die hinter main stehenden runden Klammern werde ich ebenfalls später erklären. Die auf main folgenden geschweiften Klammern umschließen den Inhalt des Hauptprogramms, der aus Variablendefinitionen (im mit C markierten Bereich) und Programmcode (im folgenden Bereich D) besteht. Geschweifte Klammern kommen in der Programmiersprache C immer vor, wenn etwas zusammengefasst werden soll. Geschweifte Klammern treten immer paarig auf. Sie sollten die Klammern so einrücken, dass man sofort erkennen kann, welche schließende Klammer zu welcher öffnenden Klammer gehört. Das erhöht die Lesbarkeit Ihres Codes. Der hier gezeigte Rahmen stellt bereits ein vollständiges Programm dar, das Sie kompilieren, linken und starten können. Sie können natürlich nicht erwarten, dass dieses Programm irgendetwas macht. Damit das Programm etwas macht, müssen wir den Bereich zwischen den geschweiften Klammern mit Variablendefinitionen und Programmcode füllen.
3.2
Zahlen
Natürlich benötigen wir in unseren Programmen gelegentlich konkrete Zahlenwerte. Man unterscheidet dabei zwischen ganzen Zahlen, z. B.: 1234 –4711
und Gleitkommazahlen, z. B.: 1.234 –47.11
Diese Schreibweisen sind Ihnen bekannt. Wichtig ist, dass bei Gleitkommazahlen, den angelsächsischen Konventionen folgend, ein Dezimalpunkt verwendet wird.
3.3
Variablen
Variablen bilden das »Gedächtnis« eines Computerprogramms. Sie dienen dazu, Datenwerte eines bestimmten Typs zu speichern, die wir für unser Programm benötigen. Bei den Typen denken wir vorerst nur an Zahlen, also ganze Zahlen oder Gleitkommazahlen. Später werden auch andere Datentypen hinzukommen.
46
3.3
Variablen
Was ist eine Variable? Unter einer Variablen verstehen wir einen mit einem Namen versehenen Speicherbereich, in dem Daten eines bestimmten Typs hinterlegt werden können. Das im Speicherbereich der Variablen hinterlegte Datum bezeichnen wir als den Wert der Variablen.
Zu einer Variablen gehören also: 왘
ein Name
왘
ein Typ
왘
ein Speicherbereich
왘
ein Wert
Den Namen vergibt der Programmierer. Der Name dient dazu, die Variable im Programm eindeutig ansprechen zu können. Denkbare Typen sind derzeit »ganze Zahl« oder »Gleitkommazahl«. Der Speicherbereich, in dem eine Variable angelegt ist, wird durch den Compiler/Linker festgelegt und soll uns im Moment nicht interessieren. Zunächst möchten wir Ihnen erläutern, wie Sie Variablen in einem Programm anlegen und wie Sie sie dann mit Werten versehen. Variablen müssen vor ihrer erstmaligen Verwendung angelegt (definiert) werden. Dazu wird im Programm der Typ der Variablen, gefolgt vom Variablennamen, angegeben (A). Die Variablendefinition wird durch ein Semikolon abgeschlossen. Mehrere solcher Definitionen können aufeinanderfolgen, und mehrere Variablen gleichen Typs können in einem Zug definiert werden (B): # include # include
A B
void main() { int summe; float hoehe; int a, b, c; }
Listing 3.2 Unterschiedliche Variablendefinitionen
Sie sehen hier zwei verschiedene Typen: int und float. Der Typ int1 steht für eine ganze Zahl, float2 für eine Gleitkommazahl. Für numerische Berechnungen würde 1 engl. Integer = ganze Zahl 2 engl. Floatingpoint Number = Gleitkommazahl
47
3
3
Ausgewählte Sprachelemente von C
eigentlich der Typ float ausreichen, da eine ganze Zahl immer als Gleitkommazahl dargestellt werden kann. Es ist aber sinnvoll, diese Unterscheidung zu treffen, da ein Computer mit ganzen Zahlen sehr viel effizienter umgehen kann als mit Gleitkommazahlen. Das Rechnen mit ganzen Zahlen ist darüber hinaus exakt, während das Rechnen mit Gleitkommazahlen immer mit Ungenauigkeiten verbunden ist. Auf der anderen Seite haben Gleitkommazahlen einen erheblich größeren Rechenbereich als ganze Zahlen und werden dringend benötigt, wenn man sehr kleine oder sehr große Zahlen verarbeiten will. Grundsätzlich sollten Sie aber, wann immer möglich, den Datentyp int gegenüber float bevorzugen. Der Variablenname kann vom Programmierer relativ frei vergeben werden und besteht aus einer Folge von Buchstaben (keine Umlaute oder ß) und Ziffern. Zusätzlich erlaubt ist das Zeichen »_«. Das erste Zeichen eines Variablennamens muss ein Buchstabe (oder »_«) sein. Grundsätzlich sollten Sie sinnvolle Variablennamen vergeben. Darunter verstehe ich Namen, die auf die beabsichtigte Verwendung der Variablen hinweisen. Variablennamen wie summe oder maximum helfen unter Umständen, ein Programm besser zu verstehen. C unterscheidet im Gegensatz zu manchen anderen Programmiersprachen zwischen Buchstaben in Groß- bzw. Kleinschreibung. Das bedeutet, dass es sich bei summe, Summe und SUMME um drei verschiedene Variablen handelt. Vermeiden Sie mögliche Fehler oder Missverständnisse, indem Sie Variablennamen immer kleinschreiben.
3.4
Operatoren
Variablen und Zahlen an sich sind wertlos, wenn man nicht sinnvolle Operationen mit ihnen ausführen kann. Spontan denkt man dabei sofort an die folgenden Operationen: 왘
Variablen Zahlenwerte zuweisen
왘
mit Variablen und Zahlen rechnen
왘
Variablen und Zahlen miteinander vergleichen
Diese Möglichkeiten gibt es natürlich auch in der Programmiersprache C.
3.4.1
Zuweisungsoperator
Variablen können direkt bei ihrer Definition oder später im Programm Werte zugewiesen werden. Die Notation dafür ist naheliegend:
48
3.4
Operatoren
# include # include
A B C
void main() { int summe = 1; float hoehe = 3.7; int a, b = 0, c;
D E F
3
a = 1; hoehe = a; a = 2; }
Listing 3.3 Wertzuweisung an Variablen
Bei einer Zuweisung steht links vom Gleichheitszeichen der Name einer zuvor definierten Variablen (A–F). Dieser Variablen wird durch die Zuweisung ein Wert gegeben. Als Wert kommen dabei konkrete Zahlen, aber auch Variablenwerte oder allgemeinere Ausdrücke (Berechnungen, Formeln etc.) infrage. Variablen können auch direkt bei der Definition initialisiert werden (A–C). Die Wertzuweisungen erfolgen in der angegebenen Reihenfolge, sodass wir im oben genannten Beispiel davon ausgehen können, dass a bereits den Wert 1 hat, wenn die Zuweisung an hoehe erfolgt (E). Zuweisungen sind nicht endgültig. Sie können den Wert einer Variablen jederzeit durch eine erneute Zuweisung ändern. Nicht initialisierte Variablen wie a und c in der Zeile (C) haben einen »Zufallswert«. Wichtig ist, dass der zugewiesene Wert zum Typ der Variablen passt. Das bedeutet, dass Sie einer Variablen vom Typ int nur einen int-Wert zuweisen können. Einer float-Variablen können Sie dagegen einen int- oder einen float-Wert zuweisen, da ja eine ganze Zahl problemlos auch als Gleitkommazahl aufgefasst werden kann. Eine Zuweisungsoperation hat übrigens den zugewiesenen Wert wiederum als eigenen Wert, sodass Zuweisungen, wie im folgenden Beispiel gezeigt, kaskadiert werden können: a = b = c = 1;
3.4.2
Arithmetische Operatoren
Mit Variablen und Zahlen können Sie rechnen, wie Sie es von der Schulmathematik her gewohnt sind:
49
3
Ausgewählte Sprachelemente von C
# include # include void main() { int summe = 1; float hoehe; int a, b, c = 0; A B C
hoehe = 1.2 + 2*c; a = b + c; summe = summe + 1; }
Listing 3.4 Verwendung arithmetischer Operatoren
Variablenwerte können durch Formeln berechnet werden, und in Formeln können dabei wieder Variablen vorkommen (A). Besondere Vorsicht ist bei der Verwendung nicht initialisierter Variablen geboten, da das Ergebnis einer Operation auf nicht initialisierten Variablen undefiniert ist (B). Die gleiche Variable kann auch auf beiden Seiten einer Zuweisung vorkommen (C). In den Formelausdrücken auf der rechten Seite der Zuweisung können dabei die folgenden Operatoren verwendet werden: Operator
Verwendung
Bedeutung
+
x+y
Addition von x und y
-
x–y
Subtraktion von x und y
*
x*y
Multiplikation von x und y
/
x/y
Division von x durch y (y ≠ 0)
%
x%y
Rest bei ganzzahliger Division von x durch y (ModuloOperator, y ≠ 0)
Tabelle 3.1 Grundlegende Operatoren in C
Sie können in Formelausdrücken Klammern setzen, um eine bestimmte Auswertungsreihenfolge zu erzwingen. In Fällen, die nicht durch Klammern eindeutig geregelt sind, greift dann die aus der Schule bekannte Regel:
50
3.4
Operatoren
Punktrechnung (*, /, %) geht vor Strichrechnung (+, -). Im Zweifel sollten Sie Klammern setzen, denn Klammern machen Formeln besser lesbar und haben keinen Einfluss auf die Verarbeitungsgeschwindigkeit des Programms. Einige Beispiele: int a; float b; float c; a = 1; b = (a+1)*(a+2); c = (3.14*a – 2.7)/5;
Ganze Zahlen und Gleitkommazahlen können in Formeln durchaus gemischt vorkommen. Es wird immer so lange wie möglich im Bereich der ganzen Zahlen gerechnet. Sobald aber die erste Gleitkommazahl ins Spiel kommt, wird die weitere Berechnung im Bereich der Gleitkommazahlen durchgeführt. Die Variable auf der linken Seite einer Zuweisung kann auch auf der rechten Seite derselben Zuweisung vorkommen. Zuweisungen dieser Art sind nicht nur möglich, sie kommen sogar ausgesprochen häufig vor. Zunächst wird der rechts vom Zuweisungsoperator stehende Ausdruck vollständig ausgewertet, dann wird das Ergebnis der Variablen links vom Gleichheitszeichen zugewiesen. Die Anweisung a = a+1;
enthält also keinen mathematischen Widerspruch, sondern erhöht den Wert der Variablen a um 1. Treffender wäre daher eigentlich die Notation: a ← a+1;
Anweisungen wie a = a + 5 oder b = b – a werden in Programmen sogar recht häufig verwendet. Sie können dann vereinfachend a += 5 oder b -= a schreiben. Insgesamt gibt es folgende Vereinfachungsmöglichkeiten: Operator
Verwendung
Entsprechung
+=
x += y
x=x+y
-=
x -= y
x=x–y
*=
x *= y
x=x*y
Tabelle 3.2 Vereinfachende Operatoren
51
3
3
Ausgewählte Sprachelemente von C
Operator
Verwendung
Entsprechung
/=
x /= y
x=x/y
%=
x %= y
x=x%y
Tabelle 3.2 Vereinfachende Operatoren (Forts.)
In dem noch häufiger vorkommenden Fall einer Addition oder Subtraktion von 1 kann man noch einfacher formulieren: Operator
Verwendung
Entsprechung
++
x++ bzw. ++x
x=x+1
--
x-- bzw. --x
x=x–1
Tabelle 3.3 Operatoren für die Addition und Subtraktion von 1
Diese Operatoren gibt es in Präfix- und Postfixnotation. Das heißt, diese Operatoren können ihrem Operanden voran- oder nachgestellt werden. Im ersten Fall wird der Operator angewandt, bevor der Operand in einen Ausdruck eingeht, im zweiten Fall erst danach. Das kann ein kleiner, aber bedeutsamer Unterschied sein. Betrachten Sie dazu das folgende Beispiel: int i, k;
A
i = 0; k = i++;
B
i = 0; k = ++i;
In der Postfix-Notation (A) wird der Wert von i erst nach der Zuweisung an k erhöht. Also: k = 0. In der Präfix-Variante hingegen (B) wird der Wert von i vor der Zuweisung an k erhöht. Also: k = 1. Die Variable i hat in beiden Fällen im Anschluss an die Zuweisung den Wert 1. Auf eine Besonderheit möchten wir Sie an dieser Stelle unbedingt hinweisen: Das Ergebnis einer arithmetischen Operation, an der nur ganzzahlige Operanden beteiligt sind, ist immer eine ganze Zahl. Im Falle einer Division wird in dieser Situation eine Division ohne Rest (Integer-Division) durchgeführt.
52
3.4
Operatoren
Betrachten Sie dazu das folgende Codefragment: a = (100*10)/100; b = 100*(10/100);
Rein mathematisch müsste eigentlich in beiden Fällen 10 als Ergebnis herauskommen. Im Programm ergibt sich aber a = 10 und b = 0. Dabei handelt es sich nicht um einen Rechen- oder Designfehler, das ist ein ganz wichtiges und gewünschtes Verhalten. Die Integer-Division ist für die Programmierung mindestens genauso wichtig wie die »richtige« Division. Wenn Sie sich bei einer Integer-Division für den unter den Tisch fallenden Rest interessieren, können Sie diesen mit dem Modulo-Operator (%) ermitteln. Der Ausdruck a = 20 %7;
berechnet den Rest, der bei einer Division von 20 durch 7 bleibt, und weist diesen der Variablen a zu. Die Variable a hat also anschließend den Wert 6. Im Gegensatz zu den anderen hier besprochenen Operatoren müssen bei einer Modulo-Operation beide Operanden ganzzahlig und sollten sogar positiv sein. Die Integer-Division bildet zusammen mit dem Modulo-Operator ein in der Programmierung unverzichtbares Operatorengespann. Ich möchte Ihnen das an einem Beispiel erläutern. Stellen Sie sich vor, dass Sie im Rechner eine zweidimensionale Struktur (z. B. ein Foto) mit einer gewissen Höhe (hoehe) und Breite (breite) verwalten: spalte
0
1
2
3
4
5
6
7
0
00
01
02
03
04
05
06
07
zeile 1
10
11
12
13
14
15
16
17
2
20
21
22
23
24
25
26
27
hoehe
breite Abbildung 3.1 Beispiel einer zweidimensionalen Struktur
53
3
3
Ausgewählte Sprachelemente von C
Dieses Bild werden Sie nun in eine eindimensionale Struktur (z. B. eine Datei) umspeichern: position 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 16
17
18
19
20
21
22 23
00
01
02
03
04
05
06
07
10
11
12
13
14
15
16
17
21
22
23
24
25
26
20
27
Abbildung 3.2 Beispiel einer eindimensionalen Struktur
Wenn Sie aus Zeile und Spalte in der zweidimensionalen Struktur die Position in der eindimensionalen Struktur berechnen möchten, geht das mit der Formel: position = zeile*breite + spalte;
Um umgekehrt aus der Position die Zeile und die Spalte für einen Bildpunkt zu berechnen, benötigen Sie die Integer-Division und den Modulo-Operator. Es ist nämlich: zeile = position/breite; spalte = position%breite;
Beachten Sie dabei, dass alle Positionsangaben hier beginnend mit der Startposition 0 festgelegt sind. Das werden wir auch zukünftig immer so halten, da diese Festlegung zu einfacheren Positionsberechnungen führt, als wenn man mit der Position 1 beginnen würde. Also: Das 1. Element befindet sich an der Position 0, das 2. an der Position 1 etc. Das Beispiel zeigt, dass in der Integer-Welt das Tandem aus Integer-Division und Modulo-Operation in gewisser Weise die Umkehrung der Multiplikation darstellt und somit an die Stelle der »richtigen« Division tritt. Auf dieses Tandem werden Sie immer wieder bei der Programmierung stoßen. Wenn Sie z. B. die drittletzte Ziffer einer bestimmten Zahl im Dezimalsystem bestimmen wollen, erhalten Sie diese mit der Formel: ziffer = (zahl/100)%10;
Bedenken Sie aber immer, dass bei der Integer-Division eine Berechnung der Form (a/b)*b nicht den Wert a als Ergebnis haben muss. Das Ergebnis ist im Vergleich zur exakten Rechnung die nächstkleinere Zahl, die durch b teilbar ist.
54
3.4
3.4.3
Operatoren
Typkonvertierungen
Manchmal möchte man, obwohl man es nur mit Integer-Werten zu tun hat, eine »richtige« Division durchführen und das Ergebnis einer Gleitkommazahl zuweisen. Die bloße Zuweisung an eine Gleitkommazahl konvertiert das Ergebnis zwar automatisch in eine Gleitkommazahl, aber erst nachdem die Division durchgeführt wurde:
A
void main() { int a = 1, b = 2; float x; x = a/b; }
Listing 3.5 Beispiel der Integer-Division
Das Ergebnis der Division in der Zeile (A) ist 0. Bevor Sie nun künstlich eine Gleitkommazahl in die Division einbringen, können Sie in der Formel eine Typkonvertierung durchführen. Sie ändern z. B. für die Berechnung (und nur für die Berechnung) den Datentyp von a in float, indem Sie der Variablen den gewünschten Datentyp in Klammern voranstellen: void main() { int a = 1, b = 2; float x; A
x = ((float)a)/b; }
Listing 3.6 Typumwandlung vor der Division
Durch die explizite Typumwandlung (A) wird a vor der Division in float konvertiert. Das Ergebnis der Division ist dann 0.5. Bei der Typumwandlung handelt es sich um einen einstelligen Operator – den sogenannten Cast-Operator. Eine Typumwandlung bezeichnet man auch als Typecast.
3.4.4
Vergleichsoperationen
Zahlen und Variablen können untereinander verglichen werden. Tabelle 3.4 zeigt die in C verwendeten Vergleichsoperatoren:
55
3
3
Ausgewählte Sprachelemente von C
Operator
Verwendung
Entsprechung
= y
größer oder gleich
==
x == y
gleich
!=
x != y
ungleich
Tabelle 3.4 Vergleichsoperatoren
Auf der linken bzw. rechten Seite eines Vergleichsausdrucks können beliebige Ausdrücke (üblicherweise handelt es sich um arithmetische Ausdrücke) mit Variablen oder Zahlen stehen: a < 7 a li", n->value); n = n->left; } else { printf( " %sd->re", n->value); n = n->right; } } printf( " %d nicht gefunden\n", v); }
Listing 15.13 Implementierung der Suche im Baum
Wenn der betrachtete und der gesuchte Wert übereinstimmen, ist das Element gefunden (A). Ist das gesuchte Element kleiner, erfolgt ein Abstieg nach links (B). Ist das gesuchte Element größer, geht der Abstieg nach rechts (C). Wenn das Element nicht gefunden wird, erfolgt eine entsprechende Ausgabe (D). Diese Funktion ist so geschrieben, dass der Abstieg ausführlich protokolliert wird. Hier sehen Sie den Suchweg und das Bildschirmprotokoll bei der Suche nach Knoten mit den Werten 1–10: void main() { int i; for( i = 1; i li 16->li 16->li 16->li 16->li 16->li 16->li 16->li 16->li 16->li
Bäume
6->li 4->li 2->li 1 nicht gefunden 6->li 4->li 2 gefunden 6->li 4->li 2->re 3 nicht gefunden 6->li 4 gefunden 6->li 4->re 5 nicht gefunden 6 gefunden 6->re 14->li 10->li 8->li 7 nicht gefunden 6->re 14->li 10->li 8 gefunden 6->re 14->li 10->li 8->re 9 nicht gefunden 6->re 14->li 10 gefunden
16
6
18 15
4
2
14
10
8
22
20
12
26
24
28
Abbildung 15.22 Suche im Baum
Jetzt haben wir das nötige Rüstzeug, um den Container als aufsteigend sortierten Baum zu realisieren (siehe Abbildung 15.23). Der Container besteht aus einem Header (struct tree), der nur einen Zeiger auf die Wurzel des Baums (root) enthält. Der eigentliche Baum ist eine Verkettung von Knoten (struct treenode), die jeweils einen Zeiger auf den durch sie verwalteten Gegner (geg) und einen »linken« (left) sowie »rechten« (right) Nachfolger enthalten.
465
15
Ausgewählte Datenstrukturen
struct tree { struct treenode *root; };
struct treenode { struct treenode *left; struct treenode *right; struct gegner *geg; };
Paraguay Marokko Oesterreich Bolivien
Abbildung 15.23 Sortierter Baum als Container
Im Konstruktor (tree_create) wird eine leere, aber konsistent initialisierte Datenstruktur erzeugt und an das aufrufende Programm zurückgegeben: struct tree *tree_create() { struct tree *t; A B C
t = (struct tree *)malloc( sizeof( struct tree)); t->root = 0; return t; }
Listing 15.14 Erzeugung eines Baums als Container
Dazu wird der erforderliche Speicher allokiert (A), der noch leere Container wird initialisiert (B) und an das rufende Programm zurückgegeben (C). Die Verwendung der neuen Funktion sieht wie folgt aus: struct tree *container1; struct tree *container2; container1 = tree_create(); container2 = tree_create();
466
15.2
Bäume
Wie bei der Listenimplementierung können beliebig viele Container erzeugt und unabhängig voneinander genutzt werden. Ein nicht mehr benötigter Container wird mit tree_free wieder beseitigt:
A B
void tree_free( struct tree *t) { tree_freenode( t->root); free( t); }
Hier werden zunächst alle Knoten freigegeben (A), bevor dann auch die HeaderStruktur freigegeben wird (B). Die einzelnen Knoten des Baums werden dabei mit tree_freenode freigegeben. Die Funktion zur Freigabe der Knoten arbeitet rekursiv, um zunächst die an einem Knoten hängenden linken und rechten Teilbäume freizugeben, bevor der Knoten selbst einschließlich des referenzierten Gegners freigegeben wird:
A B C D E F
void tree_freenode( struct treenode *tn) { if( !tn) return; tree_freenode( tn->left); tree_freenode( tn->right); free( tn->geg->name); free( tn->geg); free( tn); }
15
Listing 15.15 Freigabe eines Knotens
Wenn ein tn mit dem Wert NULL übergeben wurde, ist die Funktion am Ende dieses Zweigs angekommen, dann gibt es nichts mehr zu tun (A). Ansonsten werden der linke und der rechte Teilbaum (B und C), der Gegner (D und E) und der betrachtete Knoten selbst (F) freigegeben. Dieses Vorgehen entspricht der Postorder-Traversierung, wobei der Baum natürlich nach der Traversierung nicht mehr vorhanden ist. Das Finden und Löschen von Knoten unterscheidet sich nicht wesentlich von den entsprechenden Verfahren des Listencontainers. Der Unterschied besteht darin, dass beim Abstieg zu der zu bearbeitenden Position im Baum mal nach links und mal nach rechts verzweigt wird. Diese Verzweigungsmöglichkeiten gab es ja bei Listen nicht.
467
15
Ausgewählte Datenstrukturen
Wir betrachten zunächst die Find-Funktion: A
B C D E F G
struct gegner *tree_find( struct tree *t, char *name) { struct treenode *tn; int cmp; for( tn = t->root; tn; ) { cmp = strcmp( name, tn->geg->name); if( cmp == 0) return tn->geg; if( cmp < 0) tn = tn->left; else tn = tn->right; } return 0; }
Listing 15.16 Die Implementierung von tree_find
Die Funktion erhält als Parameter für die Suche im Baum t ein Objekt mit Namen name (A). Die Suche startet an der Wurzel des Baums und macht weiter, solange das Ende des Baums noch nicht erreicht ist (tn != 0) (B). Das Vergleichsergebnis (C) bestimmt das weitere Vorgehen. Bei cmp==0: ist das Objekt gefunden und wird zurückgegeben (D). Bei cmp < 0: ist das Objekt kleiner, und es wird nach links im Baum abgestiegen (E), und bei cmp > 0: ist das Objekt größer, und der Abstieg im Baum erfolgt nach rechts (F). Wenn die Suche erfolglos war, gibt die Funktion eine 0 zurück (G). Soll ein Element eingefügt werden, muss zunächst die Einfügeposition gesucht werden. Gibt es schon ein Element gleichen Namens, kann das neue Element nicht eingefügt werden. Wenn das Element eingefügt werden kann, wird der Speicher für einen weiteren Knoten (struct treenode) allokiert, und die erforderlichen Verkettungen werden hergestellt: Beim Einsetzen arbeiten wir wieder mit der inzwischen vertrauten doppelten Indirektion: int tree_insert( struct tree *t, struct gegner *g) { struct treenode **node, *neu; int cmp;
468
15.2
Bäume
for( node = &(t->root); *node; ) { cmp = strcmp( g->name, (*node)->geg->name); if( !cmp) return 0; if( cmp < 0) node = &((*node)->left); else node = &((*node)->right); } neu = (struct treenode *)malloc( sizeof( struct treenode)); neu->left = 0; neu->right = 0; neu->geg = g; *node = neu; return 1; } Listing 15.17 Implementierung des Einfügens in den Baum
Diese Funktion sollten Sie inzwischen ohne weiteren Kommentar verstehen können. Das Laden der Daten in den Container und der Testrahmen für den Container unterscheiden sich bis auf die Benennung der Containerfunktionen nicht von den entsprechenden Funktionen für den Listencontainer. Diese Funktionen müssen hier nicht noch einmal eigens gezeigt werden. Man hätte sogar eine abstraktere, für beide Containertypen identische Schnittstelle verwenden können, sodass der Anwender gar nicht hätte erkennen können, welche Datenstruktur (Liste oder Baum) der Implementierung des Containers zugrunde liegt. Entscheidend ist, welche Suchtiefe sich ergibt, wenn man zufällig angeordnete Datensätze aus einer Datei einliest. Dies zeigt Abbildung 15.24. Im Vergleich zur Liste sinkt die maximale Suchtiefe von 50 auf 9 und die mittlere Suchtiefe von 25.5 auf 5.96. Mit einer maximalen Suchtiefe von 9 liegt der Baum nicht weit vom theoretischen Optimum für Binärbäume entfernt, das für 50 Elemente bei 6 (log2(50) = 5.64) liegt. Beachten Sie, dass das im allgemeinen Fall eine Reduktion von n auf log(n) bedeutet, was für große Datenmengen eine noch viel dramatischere Einsparung ist, als das konkrete Zahlen für n = 50 zum Ausdruck bringen. Ein Problem darf natürlich nicht verschwiegen werden. Die Suchtiefen können nicht garantiert werden. Sie schwanken mit der Reihenfolge, in der die Daten eingelesen werden. Sollten die Daten in der Datei in aufsteigend sortierter Reihenfolge vorliegen, werden neue Elemente immer nur rechts im Baum angefügt, und der Baum wird zu
469
15
15
Ausgewählte Datenstrukturen
einer Liste. Der Baum ist in dieser Situation sogar schlechter als eine Liste, da er für die gleiche Suchqualität mehr Speicher verbraucht und aufwendigere Algorithmen hat. Baum für 50 zufällig gewählte Gegner /--Wales | \--V-A-Emirate /--Ukraine | | /--USA | | | \--Tunesien | \--Tuerkei Thailand | /--Suedkorea | | | /--Serbien-Montenegro | | | /--San-Marino | | \--Russland | | | /--Rumaenien | | \--Portugal | /--Polen | | \--Peru | /--Paraguay | | \--Oman | /--Oesterreich | | | /--Norwegen | | | | | /--Nordirland | | | | \--Niederlande | | | | \--Neuseeland | | | | \--Mexiko | | \--Marokko | | | /--Luxemburg | | \--Kolumbien | | \--Kasachstan | | \--Jugoslawien \--Japan | /--Italien | | \--Israel | | \--Island \--Irland | /--GUS | /--Frankreich | /--Finnland | | | /--Faeroeer | | | /--Estland | | | /--Ecuador | | \--Daenemark | | | /--China | | | /--Chile | | \--Bulgarien | /--Bosnien-Herzegowina \--Bolivien \--Belgien \--Argentinien | /--Algerien \--Albanien \--Aegypten
Maximale Suchtiefe: 9 Mittlere Suchtiefe: 5.96
Abbildung 15.24 Suche und Suchtiefe im Container (Baum)
Mit der Frage, wie man die Degeneration des Baums vermeiden kann, werden wir uns bei unserem nächsten Containertyp – dem Treap – beschäftigen.
15.3
Treaps
Die Struktur des Baums hat sich als Alternative zu Listen erwiesen. Es müssen jetzt noch Algorithmen gefunden werden, die verhindern, dass ein Baum beim Einsetzen und Löschen von Elementen aus der Balance gerät. Diese Algorithmen sollten eine sich beim Einsetzen oder Löschen aufbauende Schieflage sofort wieder ausgleichen. Es gibt zahlreiche Ansätze, dieses Problem in den Griff zu bekommen. Allerdings steht man hier vor dem üblichen Dilemma. Je besser der Baum balanciert wird, desto aufwendiger sind die Algorithmen zur Balancierung. Das bedeutet, dass ein Teil des Gewinns, den man durch kürzere Suchwege erzielt, durch aufwendigere Algorithmen wieder verloren geht. Aus den vielen sich anbietenden Alternativen (z. B. AVL-Bäume oder Rot-SchwarzBäume) habe ich hier die sogenannten Treaps ausgewählt. Zum einen sind die Algo-
470
15.3
Treaps
rithmen für Treaps recht einfach, und zum anderen zeigen Treaps, wie wirkungsvoll man den Zufall zur effizienten Lösung eines Problems einsetzen kann. Bei den in diesem Abschnitt vorgestellten Algorithmen handelt es sich um sogenannte probabilistische oder randomisierte Algorithmen. Dies ist eine Klasse von Algorithmen, bei denen der Zufall eine Rolle spielt. Das exakte Ergebnis eines solchen Algorithmus, in diesem Fall der konkrete Aufbau des Baums, ist nicht vorhersagbar. Entscheidend ist, dass das Ergebnis unter statistischen Gesichtspunkten gut ist. Bei dem Begriff Treap handelt es sich um ein Kunstwort, das aus der Verschmelzung von Tree (Baum) mit Heap (Haufen) entstanden ist. Im Deutschen sagt man daher manchmal auch »Baufen«. Mit Heaps hatten wir uns bereits im Zusammenhang mit dem Sortierverfahren Heapsort befasst. Dies bedarf aber sicher noch einer Auffrischung, zumal wir hier den Heap nicht in einem Array, sondern in einem Baum3 realisieren werden.
15.3.1
Heaps
Stack, Queue und Heap sind Warteschlangen, die man vereinfacht wie folgt charakterisieren kann:
15
Stack: Wer zuletzt kommt, wird zuerst bedient. Queue: Wer zuerst kommt, wird zuerst bedient. Heap: Wer am wichtigsten ist, wird zuerst bedient. Bei einem Heap spricht man deshalb auch von einer Prioritätswarteschlange. Prioritätswarteschlangen spielen überall dort eine wichtige Rolle, wo Aufgaben prioritätsgesteuert abgearbeitet werden müssen. Ein Heap wird durch die folgende Heap-Bedingung definiert: Ein Heap ist ein Baum, in dem jeder Knoten eine Priorität hat und jeder Knoten eine höhere Priorität hat als seine Nachfolgerknoten. Abbildung 15.25 zeigt einen Heap:
3 Stacks, Queues und Heaps sind keine konkreten Datenstrukturen, sondern abstrakte Speicherund Zugriffskonzepte, die man konkret z. B. durch Arrays oder Bäume implementieren kann. Im folgenden Kapitel 16, »Abstrakte Datentypen«, werde ich diesen Gedanken noch einmal vertiefen.
471
15
Ausgewählte Datenstrukturen
10
9
8
7
1
5
5
2
2
3
3
1
Abbildung 15.25 Darstellung eines Heaps
Bei einem Heap steht an der Wurzel des Baums das Element mit der höchsten Priorität im Baum. Das Gleiche gilt für jeden Teilbaum des Baums. Wenn die Heap-Bedingung an einer (und nur einer) Stelle im Baum gestört ist, kann man sie sehr einfach wiederherstellen.
Hier ist die HeapBedingung gestört.
4
9
8
7
1
5
5
2
2
3
3
1
Abbildung 15.26 Heap mit einer gestörten Heap-Bedingung
Man tauscht den Störenfried so lange mit seinem größten Nachfolger, bis die Störung nach unten aus dem Baum herausgewachsen ist:
472
15.3
Treaps
Die Heap-Bedingung ist wiederhergestellt.
9 9
4
7
7
8
4
5
5
2
3
4 5
1
4
2
3
1
Abbildung 15.27 Wiederherstellung der Heap-Bedingung
Es gibt einfache Algorithmen, um ein Element in einen Heap einzufügen und das Element mit der höchsten Priorität aus einem Heap zu entnehmen.
Entnehmen des Elements mit der höchsten Priorität: 1. Entferne das Element an der Wurzel. Dies ist das gesuchte Element mit der höchsten Priorität. 2. Bringe irgendein Blatt des Baums an die Wurzel. 3. Stelle die an der Wurzel gestörte Heap-Bedingung wieder her.
Einfügen eines neuen Elements: 1. Füge das Element als Blatt im Baum ein. 2. Gehe von dem Element zurück zur Wurzel, und führe dabei jeweils einen Reparaturschritt (Tausch mit größtem Nachfolger) durch. Beide Operationen erzeugen, wenn sie auf einem intakten Heap ausgeführt werden, am Ende wieder einen intakten Heap. Die Laufzeitkomplexität ist bei beiden Operationen proportional zur Tiefe des Baums.
15.3.2
Der Container als Treap
Ausgangspunkt für die folgenden Überlegungen ist ein Baum, bei dem jeder Knoten zwei Ordnungskriterien trägt. Das erste Ordnungskriterium nennen wir Schlüssel, das zweite Priorität. Ein Baum mit den beiden Ordnungskriterien Schlüssel und Priorität heißt Treap, (Tree + Heap) wenn er bezüglich des Schlüssels ein aufsteigend sortierter Baum und bezüglich der Priorität ein Heap ist.
473
15
15
Ausgewählte Datenstrukturen
Abbildung 15.28 zeigt einen Treap, wobei der Schlüssel an jedem Knoten links oben und die Priorität rechts unten notiert ist: 16
6
4
2
30
10
10
8
50
18
45
14
40
22
42
20
33
36
26
31
24
15
25
28
16
22
Abbildung 15.28 Darstellung eines Treaps
In diesen Treap wollen wir ein neues Element (z. B. mit Schlüssel 13 und Priorität 48) einfügen. Dabei interessieren wir uns zunächst nur für den Schlüssel und setzen das Element mit dem aus dem letzten Kapitel bekannten Verfahren in den aufsteigend sortierten Baum ein (siehe Abbildung 15.29).
13
16
48
6
4
2
10
8
30
10
15
50
18
45
14
33
13
40
22
42
20
48
31
24
36
26
16
25
28
22
Abbildung 15.29 Einfügen eines Elements in den Treap
474
15.3
Treaps
Dabei ist allerdings die Heap-Eigenschaft verloren gegangen. Ein einfaches Wiederherstellen der Heap-Eigenschaft, wie Sie es im Exkurs über Heaps gelernt haben, wäre nicht zielführend, da dabei die aufsteigende Ordnung zerstört würde. Es kommt also darauf an, Algorithmen zu finden, die die Heap-Eigenschaft wiederherstellen, ohne die aufsteigende Ordnung zu zerstören. An dieser Stelle kommen die Rotationen ins Spiel. Da wir vom Knoten 10 zum Knoten 13 nach rechts abgestiegen sind und diese Knoten die Heap-Bedingung verletzen, korrigieren wir den Baum durch eine Linksrotation (siehe Abbildung 15.30).
d
b
16
6
4
2
10
8
30
10
15
14
33
13
e
a
d
16
Linksrotation
50
18
45
b
a
20
48
c
e
6
40
22
42
c
31
24
4
36
26
16
2 25
28
30
10
10 22
8
13
33
45
14
48
50
18
40
22
42
20
31
24
36
26
16
15 25
28
15
Abbildung 15.30 Linksrotation im Treap
Jetzt haben wir das Problem um eine Ebene nach oben zur Wurzel hin verlagert. Das Problem ist aber immer noch nicht gelöst, da die Knoten 14 und 13 jetzt in der falschen Reihenfolge sind. Da es von 14 nach 13 nach links geht, korrigieren wir durch Rechtsrotation:
475
22
15
Ausgewählte Datenstrukturen
d
b
16
6
4
2
30
13
10
10
8
33
45
14
48
b
e
a
d
Rechtsrotation
50
18
a
20
c
31
24
4
36
26
16
2
25
28
22
10
8
16
e
6
40
22
42
c
30
10
15
50
18
45
13
33
40
22
48
14
42
20
31
24
36
26
16
25
28
22
15
Abbildung 15.31 Rechtsrotation im Treap
Das Problem wurde dadurch wieder nach oben verlagert, besteht jetzt aber zwischen den Knoten 6 und 13. Hier muss jetzt wieder eine Linksrotation durchgeführt werden (siehe Abbildung 15.30). Nach diesem Rotationsschritt ist die Heap-Bedingung wiederhergestellt, und die aufsteigende Sortierung besteht nach wie vor. Wir haben also wieder einen Treap. Beachten Sie, dass der leere Baum ein Treap ist. Da wir beim Einsetzen eines Elements immer wieder einen Treap herstellen können, sind wir in der Lage, einen Treap mit beliebig vielen Knoten aufzubauen. Ich hoffe, dass Ihnen durch diese Erklärungen auch klar geworden ist, welche Rolle der Schlüssel und die Priorität anschaulich beim Aufbau des Baums spielen: 왘
Der Schlüssel bestimmt die aufsteigende Sortierung und sorgt damit für die Linksrechts-Ausrichtung der Knoten im Baum.
왘
Die Priorität bestimmt die Heap-Ordnung und sorgt damit für die Oben-untenAusrichtung der Knoten im Baum.
476
15.3
d
16
6
4
2
10
8
30
10
15
33
a
18
a
d
16 c
e
13
22
42
c
40
48
14
e Linksrotation
45
13
b
b
50
20
31
24
6
36
26
Treaps
4
25
16
28
22
2
10
30
8
10
15
18
48
14
45
50
33
40
22
42
20
31
24
36
26
25
28
16
22
Abbildung 15.32 Erneute Linksrotation
Da diese beiden Sortierrichtungen »orthogonal« zueinander sind, können sie offensichtlich in einem Baum koexistieren. Es fehlt noch die entscheidende Idee, warum wir mithilfe eines Treaps die Entartung des Baums zur Liste vermeiden können. Die Knoten, die wir in den Baum einsetzen, enthalten zunächst nur einen Schlüssel – im konkreten Beispiel den Ländernamen. Wenn wir jetzt noch allen Knoten beim Einsetzen eine Zufallszahl als Priorität geben, sorgt diese Priorität dafür, dass der Baum nicht in Vertikalrichtung degeneriert. Wir gewinnen sozusagen die Zufälligkeit, die wir bei einer geordneten Eingabe verlieren, auf diese Weise zurück. Die Implementierung des Containers als Treap ist viel einfacher, als es die umfangreichen Erklärungen dieses Abschnitts vermuten lassen. 1. In der Knotenstruktur muss nur ein Feld für die Priorität hinzugenommen werden. 2. Konstruktor und Destruktor für den Container sind identisch mit den entsprechenden Funktionen für unbalancierte Bäume, da sich ja nur die Knotenstruktur geändert hat. 3. Die Find-Funktion ist für Treaps ebenfalls identisch mit der entsprechenden Funktion für aufsteigend sortierte Bäume, da der Treap ein aufsteigend sortierter Baum ist. 4. Die Insert-Funktion mit den beiden Rotationen muss neu implementiert werden. Wir betrachten hier nur die Punkte 1 und 4.
477
15
15
Ausgewählte Datenstrukturen
In der Datenstruktur besteht der einzige Unterschied zum Baum in dem zusätzlichen Feld für die Priorität (prio) in der Knotenstruktur treapnode:
struct treap { struct treapnode *root; };
struct treapnode { struct treapnode *left; struct treapnode *right; struct gegner *geg; unsigned int prio; };
Paraguay Marokko Oesterreich Bolivien
Abbildung 15.33 Treap als Container
Mit Blick auf das Einsetzen neuer Knoten implementieren wir jetzt die beiden Rotationen. Wir starten mit der Rechtsrotation:
b
d
b
a
e
d
Rechtsrotation
a
c
c
e
Feld, in dem der Knoten d im Vaterknoten eingehängt ist
tn ist der linke Nachfolger von d, also tn = b.
void treap_rotate_right( struct treapnode **node) { struct treapnode *tn; Der rechte Nachfolger von b (also c) wird zum tn = (*node)->left; (*node)->left = tn->right; tn->right = *node; *node = tn; }
linken Nachfolger von d. d wird der neue rechte Nachfolger von b.
b wird im Vaterknoten eingehängt.
Abbildung 15.34 Implementierung der Rechtsrotation
478
15.3
Treaps
Die Linksrotation wird analog zur Rechtsrotation implementiert:
d
b
b
e
a
d
Linksrotation
a
c
c
e
Abbildung 15.35 Die Linksrotation void treap_rotate_left( struct treapnode **node) { struct treapnode *tn;
15 tn = (*node)->right; (*node)->right = tn->left; tn->left = *node; *node = tn; } Listing 15.18 Implementierung der Linksrotation
Zum Einsetzen eines Elements gehen Sie rekursiv vor. int treap_insert_rek( struct treapnode **node, struct gegner *g) { int cmp; A B C
if( *node) { cmp = strcmp( g->name, (*node)->geg->name); if( cmp > 0) { if( !treap_insert_rek(&((*node)->right), g)) return 0; if ((*node)->prio < (*node)->right->prio) treap_rotate_left( node);
479
15
Ausgewählte Datenstrukturen
D
E
F
G
return 1; } if( cmp < 0) { if( !treap_insert_rek(&((*node)->left), g)) return 0; if ((*node)->prio < (*node)->left->prio) treap_rotate_right( node); return 1; } return 0; } *node = (struct treapnode *) malloc( sizeof( struct treapnode)); (*node)->left = 0; (*node)->right = 0; (*node)->geg = g; (*node)->prio = rand(); return 1; }
Listing 15.19 Rekursives Einfügen in den Treap
In der Rekursion wird die Einfügeposition im aufsteigend sortierten Baum gesucht. Wenn noch nicht vorhanden, wird das Element eingefügt. Das Element erhält beim Einfügen eine zufällige Priorität. Beim Rückzug aus der Rekursion wird durch Rotationen die Heap-Bedingung hergestellt, sofern sie verletzt ist. Wurde beim Abstieg nach links gegangen, erfolgt beim Rückzug eine Rechtsrotation. Wurde beim Abstieg nach rechts gegangen, erfolgt beim Rückzug eine Linksrotation. Im Ablauf der Funktion sieht dies folgendermaßen aus: Zuerst wird geprüft, ob der Platz besetzt ist (A). Ist das der Fall, folgt ein Namensvergleich (B), anhand dessen Ergebnis entweder der Abstieg nach rechts und anschließend gegebenenfalls eine Rotation nach links erfolgt (C) oder der Abstieg nach links und anschließend gegebenenfalls eine Rotation nach rechts (D). Ist das Element schon vorhanden, springt die Funktion zurück (E). Ist der Platz frei, ist der Abstieg beendet, und der Knoten wird eingesetzt (F). Das Element bekommt dabei seine Priorität (G). Um die rekursive Einsetzprozedur wird noch eine Aufrufschale gesetzt, um die vorgegebene Schnittstelle zu erhalten:
480
15.3
A B
Treaps
int treap_insert( struct treap *t, struct gegner *g) { return treap_insert_rek( &(t->root), g); }
Die Funktion stellt dabei den passenden Namen und die vereinbarten Parameter (A) und ruft intern die Rekursion auf (B). Um Performance zu gewinnen, können Sie die Rekursion eliminieren, indem Sie einen Stack mitführen, auf dem Sie Aufträge für die beim Rückzug zu bearbeitenden Knoten ablegen. Sie kennen diese Technik bereits aus anderem Zusammenhang, darum möchte ich Sie an dieser Stelle nur auf das beigefügte Programm aus dem Download-Bereich verweisen (unter http://www.galileo-press.de/3536, »Materialien zum Buch«). Wir wollen jetzt noch überprüfen, ob der Treap die in ihn gesetzten Erwartungen erfüllt. Bei zufällig gewählten Daten wird sich zwar ein anderer Aufbau des Baums ergeben, aber bezüglich der Tiefe sind keine Änderungen zu erwarten. Was aber passiert, wenn wir 50 alphabetisch sortierte Länderspielgegner in den Treap-Container laden?
15
/--Niederlande | | /--Neuseeland | | | | /--Moldawien | | | \--Mexiko | \--Marokko | \--Malta | \--Luxemburg /--Litauen | | /--Liechtenstein | | /--Lettland | | | | /--Kuwait | | | \--Kroatien | \--Kolumbien | \--Kasachstan | \--Kanada /--Kamerun | \--Jugoslawien Japan | /--Italien | /--Israel | /--Island | | | /--Irland | | | | \--Iran | | | | | /--Griechenland | | | | | | | /--Ghana | | | | | | \--Georgien | | | | \--Frankreich | | | | \--Finnland | | \--Faeroeer | | \--Estland \--England | /--Elfenbeinkueste | | \--Ecuador | /--Daenemark | | \--Costa-Rica | | \--China \--Chile | /--Bulgarien | | \--Brasilien \--Bosnien-Herzegowina | /--Bolivien | /--Boehmen-Maehren | | \--Belgien | /--Australien | | \--Aserbaidschan \--Armenien | /--Argentinien \--Algerien \--Albanien \--Aegypten
Treap für 50 sortierte Gegner Maximale Suchtiefe: 9 Mittlere Suchtiefe: 5.60
Abbildung 15.36 Suche und Suchtiefe im Container (Treap)
481
15
Ausgewählte Datenstrukturen
Es ergeben sich Werte, die nahezu identisch mit den Resultaten des Baums für Zufallsdaten sind. Durch Randomisierung ist es uns also gelungen, einen Container zu entwickeln, der sehr robust gegenüber vorsortierten Daten ist und in jeder Situation deutlich kürzere Suchwege als eine Liste hat.
15.4
Hash-Tabellen
Stellen Sie sich vor, dass Sie für ein Übersetzungsprogramm alle Wörter eines Wörterbuchs (ca. 500000 Stichwörter) mit ihrer Übersetzung in einem Programm speichern wollen. Ein balancierter Binärbaum hätte in dieser Situation eine Suchtiefe von ca. 20. Damit sind Sie nicht zufrieden. Sie haben das ehrgeizige Ziel, die Suchtiefe unter 2 zu drücken. Ideal wäre ein Array, das für jedes Wort genau einen Eintrag hätte. Dazu müssten Sie aus dem Wort einen eindeutigen Index berechnen, der dann die Position im Array festlegt. Wenn Sie sich auf Worte der Länge 20 und die 26 Kleinbuchstaben a–z (gegeben durch die Werte 0–25) beschränken, können Sie eine einfache Funktion zur Indexberechnung angeben. h(b0, b1, ..., b19) = b0 · 260 + b1 · 261 + b2 · 262 + ... + b19 · 2619 Das dazu benötigte Array müsste allerdings 2620 Felder haben, da theoretisch so viele verschiedene Wörter vorkommen können. Das ist nicht möglich. Sie könnten die Streuung der Funktion h reduzieren, indem Sie z. B. am Ende der Berechnung eine Modulo-Operation mit der gewünschten Tabellengröße vornehmen: h(b0, b1, ..., b19) = (b0 · 260 + b1 · 261 + b2 · 262 + ... + b19 · 2619)%500000 Eine solche Funktion bezeichnet man als Hash-Funktion. Jetzt wäre allerdings nicht mehr gewährleistet, dass jedes Wort genau einen Index bekommt. Es kann jetzt vorkommen, dass verschiedene Wörter auf den gleichen Index abgebildet werden. Wir nennen dies eine Kollision. Im Fall einer Kollision könnten Sie die kollidierenden Einträge in Form einer Liste (Synonymkette) an das Array anhängen. Die auf diese Weise entstehende Datenstruktur nennt man ein Hash-Tabelle. Hash-Tabellen kombinieren die Geschwindigkeit von Arrays mit der Flexibilität von Listen. Durch eine breite Vorselektion über ein Array erhalten Sie eine hoffentlich kurze Liste, die dann durchsucht wird:
482
15.4
Hash-Tabellen
Wörterbuch
… white gray yellow pink red green blue brown orange violet black …
HashTabelle
gray red orange white
Kollision
yellow
pink
blue
green
brown
violet
black
Synonymkette
HashFunktion
Abbildung 15.37 Schema einer Hash-Tabelle
Die Hash-Funktion hat entscheidenden Einfluss auf die Performance der HashTabelle. Die Hash-Funktion sollte möglichst zufällig und breit streuen, um wenig Kollisionen zu erzeugen, und sehr effizient zu berechnen sein, damit durch die bei jedem Zugriff erfolgende Vorselektion möglichst wenig Rechenzeit verloren geht. Im Container implementieren Sie ein dynamisch allokiertes Array, an das die Synonymketten angehängt werden. struct hashtable { int size; struct hashentry **table; };
struct hashentry { struct hashentry *nxt; struct gegner *geg; };
Paraguay Oesterreich
Marokko Bolivien
Abbildung 15.38 Hash-Tabelle als Container
483
15
15
Ausgewählte Datenstrukturen
Der Container besteht aus einem Header (struct hashtable), der neben der Größe der Tabelle einen Zeiger auf die eigentliche Hash-Tabelle (struct hashentry **) enthält. In der Hash-Tabelle stehen Zeiger auf die Synonymkette, die aus Verkettungselementen (struct hashentry) besteht, die jeweils einen Zeiger auf den durch sie verwalteten Gegner (geg) und einen Zeiger auf das nächste Listenelement (nxt) enthalten. Die Synonymketten sind strukturell genauso aufgebaut wie die Listen im Listencontainer. Ein leerer Container besteht aus einem Header (struct hashtable), an den bereits eine Tabelle angehängt ist. In der Funktion hash_create wird ein leerer Container erzeugt: struct hashtable *hash_create( int siz) { struct hashtable *h; h = (struct hashtable *)malloc( sizeof( struct hashtable)); h->size = siz; h->table = (struct hashentry **)calloc( siz, sizeof( struct hashentry *)); return h; } Listing 15.20 Erzeugen der Hashtable
Die gewünschte Tabellengröße (siz) wird als Parameter übergeben und in die Header-Struktur eingetragen (h->size). Danach wird die Tabelle allokiert. Die Tabelle enthält initial nur Null-Zeiger (calloc), da noch keine Daten verlinkt sind. Bei jedem Aufruf der hash_create-Funktion wird ein neuer Container erzeugt. Ein Anwendungsprogramm kann daher mehrere Container erzeugen und unabhängig voneinander verwenden: struct hashtable *container1; struct hashtable *container2; container1 = hash_create(); container2 = hash_create();
Hash-Tabellen und Hash-Funktionen (man spricht auch von Streuwertfunktionen) sind keine Erfindung der Informatik, es gibt sie schon seit ewigen Zeiten. Zum Beispiel ist eine Registratur, in der Akten nach dem ersten Buchstaben eines Stichworts abgelegt werden, eine Hash-Tabelle. Kollidierende Akten kommen dann in das
484
15.4
Hash-Tabellen
gleiche Fach und müssen dort sequenziell gesucht werden. Die zugehörige HashFunktion ist: unsigned int hashfunktion( char *name) { return *name; } Listing 15.21 Eine Hash-Funktion
Diese Hash-Funktion ist sehr einfach, aber für große Registraturen unbrauchbar, da sie nur sehr gering streut. Die mathematische Analyse von Hash-Funktionen ist sehr komplex und soll hier nicht betrieben werden. Wir verwenden in unseren Beispielen die folgende Funktion: unsigned int hashfunktion( char *name, unsigned int size) { unsigned int h; A
for( h = 0; *name; name++) h = ((h size);
C
for( e = h->table[index]; e; e = e->nxt) { if( !strcmp( name, e->geg->name)) return e->geg; } return 0; }
D E
Listing 15.23 Die Suche im Hash
Die Funktion erhält als Parameter die Hash-Tabelle h, in der das Element mit dem Namen name gefunden werden soll (A). Für die Suche wird zuerst der Hash-Index zum gesuchten Namen berechnet (B), um über den Hash-Index den Anker der Synonymkette zu finden, über die dann iteriert wird (C). Wenn das Element gefunden wird, wird es entsprechend zurückgegeben (D), ansonsten ist die Rückgabe 0 (E). Das Einsetzen in die Hash-Tabelle verläuft analog zur Suche. Mit der Hash-Funktion wird der Einstieg in die Synonymkette berechnet. Das dann folgende Einsetzen in die Synonymkette mittels doppelter Indirektion kennen Sie bereits als Listenoperation: int hash_insert( struct hashtable *h, struct gegner *g) { unsigned int ix; struct hashentry **e, *neu; A
ix = hashfunktion( g->name, h->size);
B
for( e = h->table + ix; *e; e = &((*e)->nxt)) { if( !strcmp( g->name, (*e)->geg->name)) return 0; } neu = (struct hashentry *)malloc( sizeof( struct hashentry)); neu->nxt = *e;
C
D
486
15.4
Hash-Tabellen
neu->geg = g; *e = neu; return 1; } Listing 15.24 Einfügen in den Hash
In der Funktion wird wieder zuerst der Hash-Index berechnet (A). Danach erfolgt eine Iteration über die Synonymkette (B). Ist ein Element gleichen Namens schon vorhanden, kann es nicht eingesetzt werden (C). Ansonsten wird das neue Element in die Synonymkette eingefügt (D), und der Erfolg wird zurückgemeldet (E). Im Gegensatz zum Listencontainer werden die Listen hier nicht alphabetisch sortiert aufgebaut. Die Listen werden kurz sein, sodass sich der Zusatzaufwand für das Sortieren wahrscheinlich nicht auszahlt. Wird eine Hash-Tabelle nicht mehr benötigt, wird der belegte Speicher freigegeben. Bevor die eigentliche Hash-Tabelle und der Header freigegeben werden können, muss über die Tabelle iteriert werden, um alle Synonymketten mit allen anhängenden Datensätzen freizugeben:
15
void hash_free( struct hashtable *h) { unsigned int ix; struct hashentry *e; A B C D E
F G
for( ix = 0; ix < h->size; ix++) { while( e = h->table[ix]) { h->table[ix] = e->nxt; free( e->geg->name); free( e->geg); free( e); } } free( h->table); free( h); }
Listing 15.25 Freigeben des Hash
Die Funktion startet mit der Iteration über die Tabelle (A). Innerhalb der Iterationsschleife erfolgt die Iteration über eine Synonymkette (B). Hier wird mit dem Ausket-
487
15
Ausgewählte Datenstrukturen
ten eines Elements gestartet (C), bevor die Freigabe der Nutzdaten (D) und der Verkettungsstruktur (E) erfolgt. Erst danach kann dann die Freigabe der Tabelle (F) und des Headers (G) vorgenommen werden. Beachten Sie, dass im Schleifenkopf der while-Anweisung while( e = h->table[ix])
eine Zuweisung an den Zeiger e erfolgt. Sollte dabei der Null-Zeiger zugewiesen worden sein, wird die Schleife abgebrochen. Das Einlesen der Daten und das Anwendungsprogramm enthalten nur minimale Abweichungen von den zuvor betrachteten Containertypen und müssen daher nicht erneut betrachtet werden. Viel interessanter sind die Ergebnisse für unterschiedliche Tabellengrößen. Die Hash-Tabelle zeigt sehr geringe Suchtiefen, selbst dann, wenn die Tabelle nur so groß ist wie die Anzahl der zu erwartenden Nutzdaten.
Hash-Tabelle für 50 Gegner Tabellengröße 50 Maximale Suchtiefe: 5 Mittlere Suchtiefe: 1.44 Tabellengröße 100 Maximale Suchtiefe: 4 Mittlere Suchtiefe: 1.24 Tabellengröße 200 Maximale Suchtiefe: 3 Mittlere Suchtiefe: 1.16 Abbildung 15.39 Suchtiefen der Hash-Tabelle für unterschiedliche Größen
Anders als die zuvor diskutierten Containertypen reflektiert die Hash-Tabelle nicht die Ordnung der Daten. Hashing ist ja geradezu der Versuch, jede Ordnungsstruktur in den Daten zu zerschlagen (to hash = zerhacken). Insofern ist eine Hash-Tabelle auch invariant gegenüber jeglicher Vorsortierung der Daten. Abbildung 15.40 zeigt den Aufbau der Hash-Tabelle für 50 Gegner der deutschen Nationalmannschaft. Möchten Sie die vorgestellten Container miteinander vergleichen, müssen Sie die Speicher- und die Laufzeitkomplexität berücksichtigen.
488
15.4
Hash-Tabellen
49: 48: 47: 46: 45: 44: 43: 42: 41: 40: 39: 38: 37: 36: 35: 33: 32: 31: 30: 29: 28: 27: 26: 25: 24: 23: 22: 21: 20: 19: 18: 17: 16: 15: 14: 13: 12: 11: 10: 9: 8: 7: 6: 5: 4: 3: 2: 1: 0: Tabellengröße 200 Maximale Suchtiefe: 3 Mittlere Suchtiefe: 1.16
Rumaenien. Chile.
Tabellengröße 100 Maximale Suchtiefe: 4 Mittlere Suchtiefe: 1.24
Argentinien.
Tabellengröße 50 Maximale Suchtiefe: 5 Mittlere Suchtiefe: 1.44
San-Marino. Bolivien, Oesterreich, Oman, Faeroeer, Kasachstan.
Italien. GUS. USA.
Luxemburg. Belgien. Bosnien-Herzegowina, Suedkorea. Japan, Tunesien.
Kolumbien. Daenemark, Serbien-Montenegro. Finnland, Jugoslawien.
Bulgarien, Nordirland. China, Tuerkei. Portugal, Estland. Niederlande. Frankreich.
Algerien, Island.
Norwegen. Wales. Polen, Israel.
Neuseeland. Peru.
Ukraine, V-A-Emirate. Albanien. Marokko. Russland. Paraguay, Mexiko. Thailand, Aegypten.
Irland.
Hash-Tabelle für 50 Gegner
Abbildung 15.40 Suche und Suchtiefe im Container (Hash-Tabelle)
15
15.4.1
Speicherkomplexität
Alle Verfahren benötigen über die Nutzdaten hinaus zusätzlichen Speicher zum Aufbau der internen Datenstrukturen. Wir bezeichnen den Speicherbedarf für einen Pointer/Integer mit p. Dann ergibt sich, abhängig von der Zahl der zu speichernden Daten n, der zusätzliche Speicherbedarf s(n): Bei Listen haben wir für jedes Element zwei Zeiger, einen auf das Element und einen auf den nächsten Listeneintrag: s(n) = 2pn Bei Bäumen haben wir neben dem Zeiger auf das Element jeweils Zeiger auf den linken und den rechten Nachfolger: s(n) = 3pn Bei Treaps kommt die Priorität hinzu: s(n) = 4pn Bei einer Hash-Tabelle, die dreimal so groß angelegt ist, wie die zu erwartende Anzahl von Einträgen, ist: s(n) = 5pn
489
15
Ausgewählte Datenstrukturen
15.4.2
Laufzeitkomplexität
Bei der Laufzeitkomplexität muss man eigentlich alle Containeroperationen einzeln betrachten. Es ist ja so, dass etwa Treaps im Vergleich zu Bäumen zusätzliche Laufzeit beim Einsetzen von Elementen verbrauchen. Diese Investition zahlt sich aber beim Suchen von Elementen durch die kürzeren Suchwege wieder aus. Streng genommen, kommt es auf das Verhältnis von Einsetz-, Such- und Löschoperationen an. Da aber auch Einsetz- und Löschoperationen von kürzeren Suchwegen profitieren, beschränke ich mich beim Vergleich auf die Suchtiefe. Tabelle 15.2 zeigt gemessene Suchtiefen für zufällig generierte Daten: Liste
Baum
Treap
Hash-Tabelle
Anzahl
Maximum
Durchschnitt
Maximum
Durchschnitt
Maximum
Durchschnitt
Maximum
Durchschnitt
1000
1000
500
20
11
23
13
2
1,16
10000
10000
5000
30
16
29
16
2
1,03
100000
100000
50000
40
21
41
21
2
1,07
1000000
100000
500000
52
25
49
25
4
1,34
Tabelle 15.2 Suchtiefen für zufällig generierte Daten
Wie zu erwarten ist, wachsen die Suchtiefen bei Listen linear, bei Bäumen und Treaps logarithmisch, und die Suchtiefe beim Hashing ist konstant. Letzteres gilt allerdings nur, wenn die Tabellengröße proportional zum Datenvolumen ist. Besonders interessant ist noch der Vergleich zwischen Treap und Baum bei vorsortierten Daten. Hier ergeben sich dramatische Vorteile des Treaps: Baum
Treap
Anzahl
Maximum
Durchschnitt
Maximum
Durchschnitt
1000
1000
500
20
11
10000
10000
5000
29
17
100000
100000
50000
39
21
1000000
1000000
500000
49
25
Tabelle 15.3 Suchtiefen für vorsortierte Daten
490
15.4
Hash-Tabellen
Bei kleinen Datenmengen ist es unerheblich, welche Speichertechnik Sie verwenden. Bei großen Datenmengen gibt es jedoch signifikante Unterschiede. Listen sind dann nicht mehr empfehlenswert und unbalancierte Bäume nur dann, wenn die Daten zufällig eingetragen werden. Sind Sie in der Anwendung an der Sortierordnung interessiert, sollten Sie balancierte Bäume verwenden. Interessiert Sie die Ordnung dagegen nicht, ist Hashing unschlagbar.
15
491
Kapitel 16 Abstrakte Datentypen Controlling complexity is the essence of computer programming. – Brian Kernighan
In diesem Kapitel werden Sie eigentlich nichts Neues über die Programmiersprache C erfahren, sondern einen Programmierstil kennenlernen, der von vielen Programmierern als ungeschriebene Regel der C-Programmierung akzeptiert und verwendet wird. Gleichzeitig ist dieses Kapitel bereits ein kleiner Schritt in Richtung der objektorientierten Programmierung. Mit einem Datentyp sind immer gewisse für diesen Datentyp zulässige Operationen verbunden. Sie können Zahlen etwa addieren, multiplizieren oder der Größe nach vergleichen. In der Definition einer Programmiersprache ist genau festgelegt, welche Operationen auf welchen Grunddatentypen durchgeführt werden können. Unzulässige Operationen, wie etwa die Division von zwei Arrays, werden vom Compiler abgelehnt. Wenn man nun einen neuen Datentyp anlegt, stellt man sich sinnvollerweise die Frage, welche Operationen denn auf diesem Typ zulässig sein sollen. Als Beispiel betrachten wir ein Kalenderdatum, bestehend aus Tag, Monat und Jahr. Eine Datenstruktur dazu ist einfach erstellt: struct datum { int tag; int monat; int jahr; };
Grundsätzlich kann in dieser Struktur aber alles gespeichert werden, was sich aus drei ganzen Zahlen zusammensetzt – z. B. die Abmessungen einer Kiste in Millimetern. Damit man wirklich von einem Kalenderdatum sprechen kann, müssen unter anderem die folgenden einschränkenden Bedingungen erfüllt sein: 왘
Der Monat muss immer eine Zahl zwischen 1 und 12 sein.
왘
Die Anzahl der Tage eines Monats variiert nach vorgegebenen Gesetzmäßigkeiten zwischen 28 und 31.
493
16
16
Abstrakte Datentypen
왘
Die Schaltjahresregelung ist zu beachten.
Darüber hinaus gibt es eine Vielzahl wünschenswerter Operationen. Zum Beispiel: 왘
Berechne den Wochentag zu einem gegeben Datum.
왘
Berechne die Anzahl der Tage zwischen zwei Kalenderdaten.
왘
Addiere eine bestimmte Zahl von Tagen zu einem Kalenderdatum.
왘
Vergleiche zwei Kalenderdaten im Sinne von früher/später.
Bei all diesen Operationen muss davon ausgegangen werden, dass die eingehenden Daten korrekte Kalenderdaten sind und als Ergebnis wieder korrekte Kalenderdaten erzeugt werden. Es ist daher sinnvoll, die Datenstruktur zusammen mit ihren Operationen als eine Einheit zu begreifen.
Was ist ein abstrakter Datentyp? Ein abstrakter Datentyp ist eine Datenstruktur zusammen mit einer Reihe von Funktionen, die auf dieser Datenstruktur arbeiten. Der abstrakte Datentyp verbirgt nach außen seine Implementierung und wird ausschließlich über die Schnittstelle seiner Funktionen bedient.
Wir veranschaulichen dies durch die Skizze in Abbildung 16.1:
Abstrakter Datentyp funktion1 funktion2 funktion3 funktion4
Interne Datenstruktur
Abbildung 16.1 Trennung von Schnittstelle und Implementierung
Der abstrakte Datentyp verbirgt alle Implementierungsdetails (z. B. den Aufbau der internen Datenstruktur) vor dem Benutzer. Unter den Funktionen zur Bedienung des abstrakten Datentyps gibt es in der Regel zwei wichtige Funktionen, die eine besondere Bedeutung haben. Der Konstruktor hat die Aufgabe, den abstrakten Datentyp in einen konsistenten Anfangszustand zu bringen, und wird einmal zur
494
16.1
Der Stack als abstrakter Datentyp
Initialisierung des abstrakten Datentyps ausgeführt. Der Destruktor hat die Aufgabe, einen abstrakten Datentyp rückstandslos zu beseitigen, und wird einmal, ganz am Ende des Lebenszyklus eines abstrakten Datentyps, aufgerufen. Anhand zweier Beispiele (Stack und Queue) werden Sie die Denkweise kennenlernen, die hinter dem Konzept des abstrakten Datentyps steht. In C ist ein abstrakter Datentyp eine rein gedankliche Abstraktion, die von der Programmiersprache nicht unterstützt wird, sodass es hier mehr darum geht, Ihnen einen gewissen Programmierstil vorzustellen, der dem Konzept des abstrakten Datentyps nahekommt. Trotzdem ist die Vorstellung, es bei der Implementierung von Datenstrukturen mit abstrakten Datentypen zu tun zu haben, sehr hilfreich für den Entwurf und die Realisierung von Programmen, da dieser Ansatz über eine konsequente Modularisierung zu qualitativ besseren Programmen führt. Erst mit dem Klassenkonzept in C++ wird dieser Ansatz eine befriedigende Abrundung erfahren.
16.1
Der Stack als abstrakter Datentyp
Wir haben bereits häufiger einen Stack betrachtet und dabei den Vergleich zu einem Tellerstapel gezogen, auf dem oben Teller abgelegt und von oben wieder Teller entnommen werden können. Wir wollen jetzt einen Stack implementieren, der einen ihm unbekannten Datentyp verwaltet, von dem er nur die Größe (in Bytes) kennt. Neben Konstruktor und Destruktor gibt es die Operationen push und pop und eine Funktion isempty, die testet, ob der Stack leer ist.
construct
Stack
isempty push pop destruct
Abbildung 16.2 Der Stack als abstrakter Datentyp
Damit ergibt sich die folgende Schnittstelle für einen abstrakten Datentyp:
495
16
16
Abstrakte Datentypen
Operation
Eingehende Parameter
Ausgehende Parameter
Beschreibung
construct
Stack-Größe und Elementgröße
Stack
Erzeuge einen leeren Stack der gewünschten StackGröße für Elemente der gewünschten Elementgröße.
isempty
Stack
0 oder 1
Teste, ob der Stack leer ist.
push
Stack und Element
OK oder OVERFLOW
Lege ein Element auf den Stack.
pop
Stack
EMPTY oder OK
Hole ein Element vom Stack.
und sofern OK, das oberste Element vom Stack destruct
Stack
Tabelle 16.1 Schnittstelle des Stacks
Diese Schnittstelle legen wir in einer Header-Datei fest: # define OK 1 # define OVERFLOW –1 # define EMPTY 0 struct stack { char *stck; int ssize; int esize; int pos; }; struct stack *stack_construct( int ssiz, int esiz); void stack_destruct( struct stack *s); int stack_isempty( struct stack *s); int stack_push( struct stack *s, void *v); int stack_pop( struct stack *s, void *v); Listing 16.1 Header-Datei der Schnittstelle für den Stack
496
Beseitige den mit construct erzeugten Stack.
16.1
Der Stack als abstrakter Datentyp
Bei der Konstruktion (construct) wird festgelegt, wie viele Elemente maximal auf dem Stack liegen können (ssiz) und wie groß die einzelnen Elemente (esiz) sind. Der Stack kennt nur die Größe der zu verwaltenden Datenpakete und erhält daher einen unspezifizierten Zeiger (void *), wenn er die Daten auf den Stack legen oder vom Stack nehmen soll. Im Konstruktor muss im Wesentlichen der erforderliche Speicher allokiert werden. Es handelt sich dabei um die Datenstruktur für die Verwaltung des Stacks (struct stack) selbst und das Array zur Aufnahme der Nutzdaten (stck): struct stack *stack_construct( int ssiz, int esiz) { struct stack *s; s = (struct stack *)malloc( sizeof( struct stack)); s->stck = (char *)malloc( ssiz*esiz); s->ssize = ssiz; s->esize = esiz; s->pos = 0; return s; } Listing 16.2 Erzeugung des Stacks
16
Neben der Speicherung der Kenngrößen (ssiz, esiz) wird insbesondere der StackZeiger (pos) auf 0 gesetzt. Dieser Zeiger indiziert immer den Platz, an dem das nächste Element gespeichert werden muss. Am Ende der Funktion wird ein Zeiger auf den initialisierten, aber noch leeren Stack zurückgegeben. Die Operationen push und pop sind einfach zu implementieren, allerdings müssen Sie darauf achten, dass bei push kein Overflow und bei pop kein Underflow auftritt: int stack_push( struct stack *s, void *v) { if( s->pos >= s->ssize) return OVERFLOW; memcpy( s->stck + s->pos*s->esize, v, s->esize); s->pos++; return OK; } int stack_pop( struct stack *s, void *v) {
497
16
Abstrakte Datentypen
if( !s->pos) return EMPTY; s->pos--; memcpy( v, s->stck + s->pos*s->esize, s->esize); return OK; } Listing 16.3 Implementierung von push und pop für den Stack
Der Datenaustausch zwischen Anwendungsprogramm und Stack erfolgt über einen unspezifizierten Zeiger (void *v). Nur das Anwendungsprogramm kennt die genaue Bedeutung dieses Zeigers. Der Stack weiß nur, wie viele Bytes (esize) die durch den Zeiger referenzierten Elemente haben. Diese Information benötigt er, um die Daten zu kopieren (memcpy). Die Funktion memcpy(dst,src,size) kopiert eine gewisse Anzahl (size) Bytes von einer Quelladresse (src) zu einer Zieladresse (dst). Da der Stack-Zeiger immer hinter dem zuletzt gespeicherten Element des Stacks steht, wird er vor dem Lesen dekrementiert (s->pos--) und nach dem Schreiben inkrementiert (s->pos++). Der Returnwert der Funktionen informiert über Erfolg oder Misserfolg der gewünschten Operation. Der Stack ist leer, wenn der Stack-Zeiger den Wert 0 hat. Damit kann die Anfrage, ob der Stack leer ist, sehr einfach beantwortet werden: int stack_isempty( struct stack *s) { return s->pos == 0; } Listing 16.4 Prüfung des Stacks
Durch den Destruktor wird ein Stack vollständig beseitigt, indem die allokierten Speicherressourcen wieder freigegeben werden: void stack_destruct( struct stack *s) { free( s->stck); free( s); } Listing 16.5 Beseitigung des Stacks
Im Anwendungsprogramm wird eine Testdatenstruktur (test) erstellt. Für diese Datenstruktur wird dann ein Stack erzeugt, und es werden Daten mit push und pop auf dem Stack abgelegt bzw. vom Stack zurückgeholt:
498
16.1
A
B
C
D
E F
G
Der Stack als abstrakter Datentyp
struct test { int i1; int i2; }; void main() { struct stack *mystack; struct test t; int i; srand( 12345); mystack = stack_construct( 100, sizeof( struct test)); for( i = 0; i < 5; i++) { t.i1 = rand( )%1000; t.i2 = rand()%1000; printf( "(%3d, %3d) ", t.i1, t.i2); stack_push( mystack, &t); } printf( "\n"); while( !stack_isempty( mystack)) { stack_pop( mystack, &t); printf( "(%3d, %3d) ", t.i1, t.i2); } printf( "\n"); stack_destruct( mystack); }
16
Listing 16.6 Test des Stacks
Im Testprogramm wird zuerst die Struktur angelegt, die auf den Stack soll (A). Nach der Deklaration eines Zeigers auf den abstrakten Datentyp (B) wird der Stack für 100 Datenstrukturen der entsprechenden Größe konstruiert (C). Auf den konstruierten Stack erfolgt dann ein Push von Zufallsdaten (D). Nach dem Befüllen des Stacks erfolgt über den Test auf einen leeren Stack (E) die Entnahme aller Testdaten über pop (F), bevor der Stack wieder zerstört wird (G).
499
16
Abstrakte Datentypen
Wir erhalten vom Testprogramm z. B. folgende Ausgabe: (584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404) ( 72, 404) (477, 413) (828, 405) (795, 125) (584, 164)
16.2
Die Queue als abstrakter Datentyp
Wenn man bei einem Tellerstapel die Teller immer oben hinzufügen, aber unten wieder entnehmen würde, würde man nicht von einem Stack, sondern einer Queue sprechen. Eine Warteschlange vor der Kasse eines Supermarkts wäre vielleicht ein treffenderes Beispiel. Nimmt man den Stack als Vorbild, kann man eine Queue mit wenigen Veränderungen implementieren. Auch die Queue soll einen ihr unbekannten Datentyp verwalten, von dem sie nur die Größe (in Bytes) kennt. Neben Konstruktor (construct), Destruktor (destruct) und dem Test auf Leere (isempty) haben wir jetzt die Operationen put und get, um Daten in die Queue einzustellen bzw. aus der Queue zu lesen:
construct
Queue
isempty put get destruct
Abbildung 16.3 Die Queue als abstrakter Datentyp
Damit ergibt sich die folgende Schnittstelle: Operation
Eingehende Parameter
Ausgehende Parameter
Beschreibung
construct
Queue-Größe und Elementgröße
Queue
Erzeuge eine leere Queue der gewünschten QueueGröße für Elemente der gewünschten Elementgröße.
Tabelle 16.2 Schnittstelle der Queue
500
16.2
Die Queue als abstrakter Datentyp
Operation
Eingehende Parameter
Ausgehende Parameter
Beschreibung
isempty
Queue
0 oder 1
Teste, ob die Queue leer ist.
put
Queue und Element
OK oder OVERFLOW
Lege ein Element in die Queue.
get
Queue
EMPTY oder OK
Hole ein Element aus der Queue.
und sofern OK, das nächste Element aus der Queue destruct
Queue
Beseitige die mit construct erzeugte Queue.
construct
Queue-Größe und Elementgröße
Queue
Erzeuge eine leere Queue der gewünschten QueueGröße für Elemente der gewünschten Elementgröße.
isempty
Queue
0 oder 1
Teste, ob die Queue leer ist.
put
Queue und Element
OK oder OVERFLOW
Lege ein Element in die Queue.
get
Queue
EMPTY oder OK
Hole ein Element aus der Queue.
und sofern OK, das nächste Element aus der Queue
16
Tabelle 16.2 Schnittstelle der Queue (Forts.)
Aus dieser Tabelle können wir unmittelbar die erforderlichen Funktionsprototypen für die Header-Datei ableiten: # define OK 1 # define OVERFLOW –1 # define EMPTY 0 struct queue { char *que; int qsize;
501
16
Abstrakte Datentypen
int esize; int first; int anz; }; struct queue *queue_construct( int qsiz, int esiz); void queue_destruct( struct queue *q); int queue_isempty( struct queue *q); int queue_put( struct queue *q, void *v); int queue_get( struct queue *q, void *v); Listing 16.7 Die Header-Datei der Queue
In der Datenstruktur für eine Queue speichern wir den Index des ersten Elements (first) und die Anzahl (anz) der Elemente, die aktuell vorhanden sind. Um ein unnötiges Umkopieren von Daten innerhalb des Nutzdaten-Arrays zu vermeiden, wollen wir die Daten als Ringpuffer anlegen. Ein Ringpuffer ist ein Array, das gedanklich zu einem Ring geschlossen ist, sodass man, wenn man hinten herausläuft, vorn wieder hineinkommt. In einem Ringpuffer können Sie eine Queue mit Schreib- und Lesezeiger anlegen, die nicht aus dem zugrunde liegenden Array hinausläuft. Sie müssen nur darauf achten, dass der Schreibzeiger den Lesezeiger nicht überrundet. Das kann dann so aussehen: 0
0
Lesezeiger
Lesezeiger
Schreibzeiger
Der Schreibzeiger ist physikalisch und logisch vor dem Lesezeiger.
Schreibzeiger
Abbildung 16.4 Ringpuffer mit Schreibzeiger vor Lesezeiger
Aber der Schreibzeiger kann in einem Ringpuffer auch hinter dem Lesezeiger sein1. Genau genommen, gibt es die Begriffe »vorn« und »hinten« in einem Ringpuffer nicht mehr (siehe Abbildung 16.5). Die Zeigerbewegungen in einem Ringpuffer können mit einfachen Modulo-Operationen implementiert werden: zeiger = (zeiger + offset)%pufferlänge 1 Sebastian Vettel kann hinter Fernando Alonso herfahren und trotzdem in Führung liegen, weil die Rennstrecke ein Ringpuffer ist.
502
16.2
0
Die Queue als abstrakter Datentyp
Lesezeiger
Schreibzeiger
0
Schreibzeiger
Lesezeiger
Der Schreibzeiger ist physikalisch hinter, aber logisch vor dem Lesezeiger. Abbildung 16.5 Ringpuffer mit Schreibzeiger »hinter« Lesezeiger
Mit diesen Vorüberlegungen können wir alle Funktionen der Queue implementieren. Wir starten dazu mit dem Konstruktor struct queue *queue_construct( int qsiz, int esiz) { struct queue *q; q = (struct queue *)malloc( sizeof( struct queue)); q->que = (char *)malloc( qsiz*esiz); q->qsize = qsiz; q->esize = esiz; q->first = 0; q->anz = 0; return q; }
16
Listing 16.8 Erzeugen der Queue
und dem Destruktor: void queue_destruct( struct queue *q) { free( q->que); free( q); } Listing 16.9 Zerstören der Queue
Beim Test, ob eine Queue leer ist, muss nur das Datenfeld anz befragt werden: int queue_isempty( struct queue *q) { return q->anz == 0; Listing 16.10 Prüfung der Queue
503
16
Abstrakte Datentypen
Bei der Implementierung der Schreib-/Lesezugriffe müssen Sie Folgendes beachten: Im Ringpuffer läuft der Schreibzeiger dem Lesezeiger immer um q->anz Elemente logisch voraus, wobei physikalisch im Array Modulo q->qsize gerechnet wird. Der Schreibzeiger kann sich physikalisch hinter dem Lesezeiger befinden, überrundet ihn aber nicht, da immer q->anz < q->qsize ist. Das setzen wir in den Funktionen put und get um: int queue_put( struct queue *q, void *v) { if( q->anz >= q->qsize) return OVERFLOW; memcpy( q->que + ((q->first+q->anz)%q->qsize)*q->esize, v, q->esize); q->anz++; return OK; } Listing 16.11 Ablegen in der Queue int queue_get( struct queue *q, void *v) { if( !q->anz) return EMPTY; memcpy( v, q->que + q->first*q->esize, q->esize); q->first = (q->first+1)%q->qsize; q->anz--; return OK; } Listing 16.12 Entnahme aus der Queue
Das Testprogramm kennen Sie bereits vom Testen des Stacks. Hier wird allerdings eine Queue konstruiert. Dementsprechend ergibt sich auch eine andere Reihenfolge der Daten beim Datenabruf mit get: A
struct test { int i1; int i2; }; void main() {
504
16.2
B
Die Queue als abstrakter Datentyp
struct queue *myqueue; int i; struct test t; srand( 12345);
C
D
E F
G
myqueue = queue_construct( 100, sizeof( struct test)); for( i = 0; i < 5; i++) { t.i1 = rand( )%1000; t.i2 = rand( )%1000; printf( "(%3d, %3d) ", t.i1, t.i2); queue_put( myqueue, &t); } printf( "\n"); while( !queue_isempty( myqueue)) { queue_get( myqueue, &t); printf( "(%3d, %3d) ", t.i1, t.i2); } printf( "\n"); queue_destruct( myqueue); }
16
Listing 16.13 Test der Queue
Das Vorgehen ist analog zum Test des Stacks, es wird zuerst die Struktur deklariert, die in die Queue soll (A). Es folgt die Deklaration eines Zeigers auf den abstrakten Datentyp (B) und die Konstruktion einer Queue für 100 Datenstrukturen der entsprechenden Größe (C). Nach dem Put von Zufallsdaten (D) werden die Daten über den Test auf eine leere Queue (E) per get entnommen (F). Abschließend wird die Queue beseitigt (G). Wir erhalten z. B. die folgende Ausgabe: (584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404) (584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404)
Durch die abstrakten Datentypen »Stack« und »Queue« haben wir eine saubere Trennung zwischen WAS und WIE vollzogen. Das Anwendungsprogramm weiß, WAS gespeichert wird, aber nicht WIE. Stack und Queue wissen, WIE gespeichert wird, aber nicht WAS. Diese Trennung ermöglicht eine vollständige Entkopplung der eigentlichen Funktionalität des Anwendungsprogramms von seiner Datenhaltung. Dieser Gedanke wird durch die objektorientierte Programmierung konsequent fortgesetzt.
505
Kapitel 17 Elemente der Graphentheorie Man versteht etwas nicht wirklich, wenn man nicht versucht, es zu implementieren. – Donald E. Knuth
Die geografische Lage von Königsberg am Pregel ist gekennzeichnet durch vier Landgebiete (Festland oder Inseln), die durch sieben Brücken miteinander verbunden sind:
17 Abbildung 17.1 Die sieben Brücken von Königsberg
Die Königsberger Bürger stellten sich die Frage, ob es einen Spazierweg gäbe, bei dem sie jede Brücke genau einmal überqueren und am Ende zum Ausgangspunkt zurückkehren könnten. Als der berühmte Mathematiker Leonhard Euler1 mit diesem Problem konfrontiert wurde, abstrahierte er von der konkreten geografischen Situation und stellte die Struktur des Problems durch einen »Graphen« dar, in dem Kreise (sogenannte Knoten, A–D) die Landgebiete und Linien (sogenannte Kanten, a–g) die Brücken repräsentierten (siehe Abbildung 17.2). Das Königsberger Brückenproblem ist ein klassisches Problem der »Graphentheorie«, dessen Lösung auf den berühmten Mathematiker Leonhard Euler (1707–1783) zurückgeht. Den gesuchten Rundweg bezeichnet man daher auch als eulerschen Weg.
1 Leonhard Euler (1707–1783) gilt als einer der Väter der modernen Analysis. Nach ihm ist die eulersche Konstante e = 2,1718... benannt.
507
17
Elemente der Graphentheorie
C c
C g
d
c
g d
e
D
A a
f
b
e
A
B
a
b
D
f
B
Abbildung 17.2 Die sieben Brücken als Graph
Beim Versuch, das Problem zu lösen, findet man drei einfache Kriterien, die erfüllt sein müssen, damit es einen eulerschen Weg gibt: 1. Der Graph muss zusammenhängend sein. Das heißt, man muss jeden Knoten von jedem anderen Knoten aus über einen Weg erreichen können. 2. Zu dem Startknoten muss es neben der Kante, über die man ihn verlässt, eine weitere Kante geben, über die man ihn am Ende des Weges wieder erreicht. 3. Wenn man einen Knoten auf dem gesuchten Rundweg über eine Kante erreicht und der Weg noch nicht beendet ist, muss es eine weitere, noch nicht benutzte Kante geben, über die man ihn wieder verlassen kann. Die Bedingungen 2 und 3 besagen, dass die Kanten an jedem Knoten »paarig« auftreten müssen, damit ein eulerscher Weg überhaupt existieren kann. Der Königsberger Brückengraph erfüllt diese Bedingungen nicht. Er ist zwar zusammenhängend, aber es gibt sogar an keinem Knoten eine gerade Anzahl von Kanten. Es kann den gesuchten Rundweg nicht geben. Jeder Versuch wird zwangsläufig scheitern, da man irgendwann an einem Knoten landet, von dem keine unbenutzte Kante mehr wegführt:
?
Abbildung 17.3 Die Knoten und Kanten des Graphen
Um das Problem der Existenz eines eulerschen Weges allgemein zu lösen, denken wir uns jetzt einen zusammenhängenden Graphen, bei dem es an jedem Knoten eine gerade Anzahl Kanten gibt.
508
Wir starten an einem beliebigen Knoten zu einer Wanderung. Die dabei benutzten Kanten markieren wir, damit wir sie nicht noch einmal verwenden. Wenn wir zu einem Knoten kommen, versuchen wir, den Knoten über eine beliebige, noch nicht benutzte Kante wieder zu verlassen. Irgendwann wird die Wanderung an einem Knoten enden, den wir nicht mehr verlassen können, da alle Kanten an dem Knoten markiert sind. Dieser Knoten kann nur unser Startknoten sein, da wir beim Durchwandern eines Knotens immer zwei Kanten streichen und immer keine oder eine gerade Anzahl von Kanten übrig bleibt. Das heißt, entweder kommen wir zu dem Knoten nicht mehr hin, oder wenn wir hinkommen, können wir ihn auch wieder verlassen. Wir haben also eine Rundwanderung gemacht, haben unter Umständen allerdings noch nicht alle Kanten verwendet. Wir laufen daher unseren Weg noch einmal ab, bis wir auf einen Knoten kommen, an dem es eine noch nicht verwendete Kante gibt. Dort starten wir wieder eine Rundwanderung über noch ungenutzte Kanten, die uns zwangsläufig wieder zu diesem Knoten zurückführt. Die so gelaufene »Schleife« fügen wir zu unserem Weg hinzu. Diesen Prozess setzen wir fort, bis es an unserem Weg keine unbenutzten Kanten mehr gibt. Wir haben jetzt aber einen eulerschen Weg gefunden, denn gäbe es noch irgendwo eine ungenutzte Kante, dann gäbe es ja einen Weg von dieser Kante zum Startknoten unseres Weges. Irgendwo würde dieser Weg auf unseren Rundwanderweg treffen. Dort gäbe es dann aber eine noch ungenutzte Brücke an unserem Weg. Wir haben damit ein Verfahren beschrieben, um in einem zusammenhängenden Graphen, mit gerader Kantenzahl an jedem Knoten, einen eulerschen Weg zu finden.
Finden Sie in diesem Graphen einen eulerschen Weg, indem Sie das oben beschriebene Verfahren durchführen.
Abbildung 17.4 Beispiel eines Graphen für einen eulerschen Weg
509
17
17
Elemente der Graphentheorie
Wir fassen unsere Ergebnisse zusammen: 1. Einen eulerschen Weg kann es in einem Graphen nur geben, wenn der Graph zusammenhängend ist und alle Knoten eine gerade Anzahl von Kanten haben. 2. Wenn ein Graph zusammenhängend ist und alle Knoten eine gerade Anzahl von Kanten haben, dann gibt es einen eulerschen Weg. Damit können wir den folgenden Satz formulieren: In einem Graphen gibt es genau dann einen eulerschen Weg, wenn der Graph zusammenhängend ist und alle Knoten eine gerade Anzahl von Kanten haben. Leonard Euler hat mit diesem Satz 1736 den Grundstein für die Graphentheorie gelegt. Heute ist die Graphentheorie eine unerschöpfliche Quelle für Datenstrukturen und Algorithmen mit großer Bedeutung für die Lösung wichtiger Probleme.
17.1
Graphentheoretische Grundbegriffe
Ein Graph ist eine grundlegende Struktur, die Strukturen wie Baum oder Liste verallgemeinert: Unter einem Graphen verstehen wir eine Struktur, die aus endlich vielen Knoten und Kanten besteht. Einer Kante ist jeweils ein Anfangsknoten und ein Endknoten zugeordnet. Typischerweise bezeichnen wir Knoten mit Großbuchstaben (A, B, C, ...) und Kanten mit Kleinbuchstaben (a, b, c, ...). Wenn eine Kante k den Anfangsknoten A und den Endknoten E hat, sagen wir, dass die Kante von A nach E führt und schreiben k = (A, E). Es ist nicht ausgeschlossen, dass Anfangs- und Endknoten einer Kante gleich sind. Es ist auch nicht ausgeschlossen, dass es zu einem Knoten keine Kante gibt. Wir visualisieren einen Graphen, indem wir die Knoten als Kreise und die Kanten als Pfeile von ihrem Anfangsknoten zu ihrem Endknoten zeichnen. A
E
D
d a
b B
f
c e
C g
a = (B,A) b = (A,B) c = (B,D) d = (C,A) e = (B,C) f = (D,C) g = (C,C)
Abbildung 17.5 Darstellung und Notation eines Graphen
510
17.2
Die Adjazenzmatrix
Was Knoten und Kanten konkret sind oder sein könnten (z. B. Landgebiete und Brücken), interessiert uns nicht. Diese Abstraktion ermöglicht die universelle Verwendbarkeit von Graphen für unterschiedlichste Aufgaben. Grundsätzlich ist nicht ausgeschlossen, dass es in einem Graphen verschiedene Kanten mit gleichem Anfangs- und gleichem Endknoten (Parallelkanten) gibt. a A
B b
Abbildung 17.6 Graph mit Parallelkanten
Wir wollen hier aber nur Graphen ohne Parallelkanten betrachten. Wenn in einem Graphen zu jeder Kante k = (A,B) auch die Kante k' = (B,A) vorkommt, bezeichnen wir den Graphen als ungerichtet oder symmetrisch. Da in einem symmetrischen Graphen zu jeder Kante auch die in umgekehrter Richtung verlaufende Kante vorhanden ist, identifizieren wir die beiden Kanten miteinander und zeichnen für Kante und Umkehrkante jeweils nur eine Linie. Die Pfeile lassen wir in solchen Graphen weg:
A
D
A
D
17
B
C
B
C
Abbildung 17.7 Darstellung eines ungerichteten (symmetrischen) Graphen
17.2
Die Adjazenzmatrix
Zur Speicherung eines Graphen in einem Programm dient häufig die sogenannte Adjazenzmatrix:
Die Adjazenzmatrix eines Graphen Gegeben sei ein Graph mit fortlaufend nummerierten Knoten (E1, E2, E3, ... En). Die Matrix
( )
A = ai , j
⎛ a1,1 " a1,n ⎞ ⎜ ⎟ =⎜ # % # ⎟ ⎜a ⎟ ⎝ n,1 " an,n ⎠
511
Elemente der Graphentheorie
mit Es gibt eine Kante von E i nach E j
⎧1 a i, j = ⎨ ⎩0
Es gibt keine Kante von E i nach E j
heißt die Adjazenzmatrix des Graphen.
In Abbildung 17.8 sehen Sie einen Graphen mit seiner Adjazenzmatrix:
A
nach A B C D
D d
a
b B
f
c e
von
17
A B C D
0 1 1 0
1 0 0 0
0 1 1 1
0 1 0 0
Es gibt eine Kante von B nach D.
C Es gibt keine Kante von D nach B.
Abbildung 17.8 Ein Graph und seine Adjazenzmatrix
Symmetrische Graphen haben eine symmetrische Adjazenzmatrix (ai,j = aj,i). Das heißt, die Matrix ist spiegelsymmetrisch zur Hauptdiagonalen (von links oben nach rechts unten). Streng genommen, kann man gar nicht von der Adjazenzmatrix eines Graphen reden, da die Matrix ja von der betrachteten Reihenfolge der Knoten abhängt. Da wir aber nur Eigenschaften betrachten, die unabhängig von der gewählten Reihenfolge sind, ist es egal, welche Knotenreihenfolge wir betrachten.
17.3
Beispielgraph (Autobahnnetz)
Wie ein roter Faden wird sich ein Beispiel durch diesen Abschnitt ziehen. Es handelt sich um eine Auswahl deutscher Städte mit Autobahnverbindungen. Die Städte sind die Knoten, die Autobahnen die Kanten eines Graphen. Für dieses Bespiel definieren wir zunächst einige grundsätzliche Konstanten. Es handelt sich um eine Auswahl von zwölf Städten: # define ANZAHL 12
Für jede Stadt haben wir eine Nummer und einen Klartextnamen:
512
17.3
# # # # # # # # # # # #
define define define define define define define define define define define define
BERLIN BREMEN DORTMUND DRESDEN DUESSELDORF FRANKFURT HAMBURG HANNOVER KOELN LEIPZIG MUENCHEN STUTTGART
Beispielgraph (Autobahnnetz)
0 1 2 3 4 5 6 7 8 9 10 11
char *stadt[ANZAHL] = { "Berlin", "Bremen", "Dortmund", "Dresden", "Duesseldorf", "Frankfurt", "Hamburg", "Hannover", "Koeln", "Leipzig", "Muenchen", "Stuttgart" };
17
Damit können wir den Autobahngraphen dieser zwölf Städte durch eine Adjazenzmatrix einführen (siehe Abbildung 17.9). Anhand dieses Graphen werden wir wichtige graphentheoretische Problemstellungen diskutieren. Zum Beispiel werden wir uns fragen, ob und wie man von Stuttgart nach Berlin kommt. An diesem Beispiel erkennen Sie bereits, dass man Fragen, die man durch einen einfachen Blick auf die Karte beantworten kann, nicht so einfach aus der Adjazenzmatrix herauslesen kann.
513
17
Elemente der Graphentheorie
Bremen
Hamburg Berlin Hannover
Dortmund Düsseldorf
Leipzig
Köln
Dresden
Frankfurt
Stuttgart München
unsigned int adjazenz[ ANZAHL][ ANZAHL] = { {0,0,0,1,0,0,1,1,0,1,0,0}, {0,0,1,0,0,0,1,1,0,0,0,0}, {0,1,0,0,1,1,0,1,1,0,0,0}, {1,0,0,0,0,0,0,0,0,1,0,0}, {0,0,1,0,0,0,0,0,1,0,0,0}, {0,0,1,0,0,0,0,1,1,1,1,1}, {1,1,0,0,0,0,0,1,0,0,0,0}, {1,1,1,0,0,1,1,0,0,1,0,0}, {0,0,1,0,1,1,0,0,0,0,0,0}, {1,0,0,1,0,1,0,1,0,0,1,0}, {0,0,0,0,0,1,0,0,0,1,0,1}, {0,0,0,0,0,1,0,0,0,0,1,0}, };
Abbildung 17.9 Der Autobahngraph und seine Adjazenzmatrix
17.4
Traversierung von Graphen
Einen Graphen, in dem alle Knoten von allen Knoten aus erreichbar sind, können Sie, von einem beliebigen Knoten startend, wie einen Baum rekursiv traversieren. Sie müssen nur darauf achten, dass Sie Knoten, die Sie bereits besucht haben, nicht erneut besuchen, weil Sie sonst in einer endlosen Rekursion gefangen sind. Wir legen daher ein Array (war_da) an, in dem wir festhalten, ob wir einen bestimmten Knoten schon einmal besucht haben. Vor der Traversierung markieren wir alle Knoten mit dem Wert 0 als »noch nicht besucht«: void main() { int i; int war_da[ANZAHL];
A B
for( i = 0; i < ANZAHL; i++) war_da[i] = 0; traverse( BERLIN, war_da, 0); }
Listing 17.1 Traversieren eines Graphen
514
17.4
Traversierung von Graphen
Wir starten mit der Markierung aller Knoten als »noch nicht besucht« (A), bevor wir die Traversierung von Berlin aus beginnen (B). Die eigentliche Traversierungsstrategie orientiert sich an der Preorder-Traversierung für Bäume. Wenn wir auf einem bisher unbesuchten Knoten ankommen, führen wir zunächst die gewünschte Knotenoperation aus (machwas), um danach alle vom Standort aus erreichbaren Knoten zu besuchen, an denen wir noch nicht waren: A
void traverse( int knoten, int war_schon_da[], int level) { int i;
B C D
machwas( knoten, level); war_schon_da[knoten] = 1; for( i = 0; i < ANZAHL; i++) { if( adjazenz[knoten][i] && !war_schon_da[i]) traverse( i, war_schon_da, level+1); } }
E F
Listing 17.2 Implementierung von traverse
Die Schnittstelle der Funktion enthält neben dem knoten, der besucht wird, die Information über die bereits besuchten Knoten und den Rekursionslevel (A). Der Rekursionslevel wird nur für das Einrücken der Ausgabe verwendet. Die Funktion gibt zuerst den besuchten Knoten aus (B) und markiert diesen dann als besucht (C). In der folgenden Schleife über alle Knoten (D) werden die Knoten, die erreichbar sind und noch nicht als besucht markiert worden sind (E), besucht (F). In der machwas-Funktion geben wir nur den Knoten in der entsprechenden Einrückungstiefe level aus: void machwas( int knoten, int level) { int i; for( i = 0; i < level; i++) printf( " "); printf( "%s\n", stadt[knoten]); } Listing 17.3 Funktion machwas zur Ausgabe der besuchten Knoten
Dieser Algorithmus erzeugt die folgende Ausgabe:
515
17
17
Elemente der Graphentheorie
Berlin Dresden Leipzig Frankfurt Dortmund Bremen Hamburg Hannover Duesseldorf Koeln Muenchen Stuttgart
Der Algorithmus geht, in Berlin startend, immer zu der (alphabetisch) ersten Stadt, die direkt erreichbar ist und in der er noch nicht war. Gibt es keine solche Stadt mehr, erfolgt der Rücksprung auf die nächsthöhere Aufrufebene. Der Algorithmus geht also in seiner eigenen Spur zurück, bis er eine noch nicht besuchte Stadt findet. Auf diese Weise wird in dem Graphen ein Baum aller von Berlin aus erreichbaren Städte konstruiert.
Berlin Bremen
Hamburg Dresden Berlin Hannover
Dortmund Düsseldorf
Leipzig
Leipzig
Köln
Frankfurt
Dortmund
Dresden
Frankfurt
Stuttgart
München
Bremen
Düsseldorf
Hamburg
Köln
Stuttgart
Hannover München
Abbildung 17.10 Graph aller erreichbaren Städte
17.5
Wege in Graphen
Wie schon angekündigt, wollen wir uns mit der »Wegesuche« in Graphen beschäftigen. Dazu müssen wir zunächst einmal definieren, was wir unter einem Weg in
516
17.5
Wege in Graphen
einem Graphen verstehen. Bei dieser Gelegenheit führen wir noch eine Reihe weiterer Begriffe ein: 왘
Eine endliche Folge A1, A2, ... An von Knoten eines Graphen heißt Weg, wenn je zwei aufeinanderfolgende Knoten durch eine Kante miteinander verbunden sind.
왘
A1 wird als der Anfangs-, An als der Endknoten des Weges bezeichnet, und man spricht von einem Weg von A1 nach An.
왘
Sind Anfangs- und Endknoten eines Weges gleich, sprechen wir von einem geschlossenen Weg oder einer Schleife.
왘
Ein Weg heißt schleifenfrei, wenn alle vorkommenden Knoten voneinander verschieden sind.
왘
Ein Weg heißt Kantenzug, wenn alle im Weg vorkommenden Kanten voneinander verschieden sind.
왘
Ein geschlossener Kantenzug heißt Kreis.
왘
Ein Graph heißt kreisfrei, wenn er keine Kreise enthält.
왘
Die Anzahl der Kanten in einem Weg wird auch als die Länge des Weges bezeichnet.
Wir veranschaulichen diese Begriffe an einem einfachen Beispiel: A
D d
a
b B
17
f
c e
C g
Abbildung 17.11 Beispielgraph
In diesem Graphen gilt: 왘
Die Folge (B, A, B, D, C) ist ein Weg der Länge 4.
왘
Die Folge (A, B, C, C, A) ist ein geschlossener Weg.
왘
Der Weg (A, B, D, C) ist schleifenfrei.
왘
Der Weg (B, A, B, D) ist ein Kantenzug, aber nicht schleifenfrei.
왘
Der Weg (A, B, A) ist ein Kreis.
Die Adjazenzmatrix eines Graphen liefert nur die Information, welche Knoten durch eine Kante, also durch einen Weg der Länge 1, miteinander verbunden sind. Wir wollen jetzt die allgemeinere Frage, welche Knoten durch einen beliebigen Weg miteinander verbunden werden können, beantworten. Dazu definieren wir die Wegematrix eines Graphen:
517
17
Elemente der Graphentheorie
Die Wegmatrix eines Graphen Gegeben sei ein Graph mit fortlaufend nummerierten Knoten (E1, E2, E3, ... En). Die Matrix
⎛ w 1,1 " w 1,n ⎞ ⎜ ⎟ W = wi , j = ⎜ # % # ⎟ ⎜w ⎟ ⎝ n,1 " wn,n ⎠
( )
mit Es gibt einen Weg von E i nach E j
⎧1 w i, j = ⎨ ⎩0
Es gibt keinen Weg von E i nach E j
heißt die Wegematrix des Graphen.
Die Wegematrix eines Graphen ist in der Regel nicht bekannt. Um sie aus der Adjazenzmatrix zu berechnen, verwenden wir das Verfahren von Warshall.
17.6
Der Algorithmus von Warshall
Wir versuchen jetzt, ein Verfahren zu konstruieren, das, ausgehend von der Adjazenzmatrix, die Wegematrix in einem Graphen konstruiert. Wenn uns das gelingt, können wir die Frage der Verbindbarkeit von Knoten vollständig beantworten. Wir betrachten einen beliebigen Graphen mit Knoten E1, E2, E3, ... En und der Adjazenzmatrix A. Für diesen Graphen bilden wir eine Folge von Mengen, die am Anfang leer ist und nach und nach alle Knoten aufnimmt: M0 = Ø M1 = {E1} M2 = {E1, E2} _ Mn = {E1, E2, …, En} Dazu berechnen wir eine Folge von Matrizen W0, W1, ... Wn, die wir aus der Adjazenzmatrix ableiten: M0
M1
M2
M3
Mn
↓ ↓ ↓ ↓ ↓ A = W0 → W1 → W 2 → W 3 … → W n
518
17.6
Der Algorithmus von Warshall
Wir versuchen dabei, die folgende Eigenschaft zu realisieren: Die Matrix Wk hat in Zeile i und Spalte j genau dann den Wert 1, wenn es einen Weg von Ei nach Ej gibt, dessen Zwischenpunkte sämtlich in Mk liegen. Die Matrix W0 hat diese Eigenschaft, weil W0 die Adjazenzmatrix ist, die ja die Verbindungen ohne Zwischenpunkte enthält. Wenn es jetzt gelingt, die Eigenschaft durch ein Konstruktionsverfahren (das wir noch nicht kennen) von Matrix zu Matrix (Wk → Wk+1) zu übertragen, haben wir am Ende in Wn die gesuchte Wegematrix, da die Eigenschaft für k = n die Wegematrix charakterisiert. Wir gehen davon aus, dass wir die Matrix Wk erfolgreich konstruiert haben. Das heißt: Es gilt die obige Eigenschaft. Jetzt wollen wir die Matrix Wk+1 konstruieren. Dazu bilden wir die Menge Mk+1, indem wir zur Menge Mk den Knoten Ek+1 hinzunehmen. Wir betrachten jetzt zwei beliebige Knoten Ei und Ej. Dabei geht es um zwei unterschiedliche Fälle: 왘
Wenn die beiden Knoten bereits durch einen Weg in Mk verbunden sind, dann steht in Wk in der entsprechenden Zeile und Spalte bereits eine 1, und diese 1 wird dann in Wk+1 übernommen.
Ei
Ej
17
Mk
Mk+1 Ek+1
Abbildung 17.12 Es besteht bereits ein Weg zwischen den Knoten. 왘
Wenn die betrachteten Knoten in Mk noch nicht verbunden sind, können sie in Mk+1 nur über den Zwischenpunkt Ek+1 verbunden werden. Dazu muss es in Mk aber bereits Wege von Ei nach Ek+1 und von Ek+1 nach Ej geben. Das können wir in den entsprechenden Zeilen und Spalten der Matrix Wk überprüfen. Wenn beide Prüfungen positiv ausfallen, können wir Ei und Ej in Wk+1 als verbindbar markieren.
519
17
Elemente der Graphentheorie
Ei
Mk
Mk+1 Ek+1
Ej
Abbildung 17.13 Weg über einen Zwischenpunkt
Wenn wir dieses Verfahren für alle Knotenpaare Ei, Ej durchgeführt haben, hat Wk+1 die gewünschte Eigenschaft und zeigt die Verbindbarkeit von Knoten über Mk+1 an. Bei der Implementierung des Verfahrens arbeiten wir »in place«. Das heißt, wir erzeugen nicht ständig neue Matrizen, sondern modifizieren die Adjazenzmatrix Schritt für Schritt, bis aus ihr die Wegematrix entstanden ist. Der Algorithmus ist einfacher zu implementieren, als die Herleitung des Verfahrens es vermuten lässt: void warshall() { int von, nach, zpkt; A B
for( zpkt = 0; zpkt < ANZAHL; zpkt++) { for( von = 0; von < ANZAHL; von++) { if( weg[von][zpkt]) {
C
for( nach = 0; nach < ANZAHL; nach++) { if( weg[zpkt][nach]) weg[von][nach] = 1; } } } } }
Listing 17.4 Algorithmus von Warshall
Der Algorithmus startet mit einer Schleife über Zwischenpunkte (A). Dies ist der Zwischenpunkt, der jeweils neu zur Menge der Zwischenpunkte hinzugenommen wird.
520
17.6
Der Algorithmus von Warshall
Die Schleife erzeugt also gedanklich die Mengenfolge M1, M2, ..., Mn. Anschließend werden in der Doppelschleife (B und C) alle Knotenpaare betrachtet, und es wird untersucht, ob eine Verbindung über den Zwischenpunkt möglich ist. Der Fall einer Verbindbarkeit ohne Verwendung des Zwischenpunkts muss nicht geprüft werden, da diese Information bereits aus der vorherigen Iteration in der Matrix vorhanden ist und durch die »In-place«-Strategie übernommen wird. Die Wegematrix im deutschen Autobahnnetz zu berechnen ist wenig ergiebig, da das Autobahnnetz zusammenhängend ist und die Wegematrix in allen Feldern den Wert 1 enthalten wird. Wir machen daher die Autobahnen zu Einbahnstraßen. Jetzt ist nicht mehr jede Stadt von jeder anderen aus erreichbar. Es ergibt sich folgende Adjazenzmatrix, die ich bereits weg genannt habe, weil sie in die Wegematrix umgerechnet werden soll:
Bremen
Hamburg
Berlin Hannover Dortmund
# define ANZAHL 12
Leipzig
Düsseldorf
Köln
Dresden
Frankfurt
Stuttgart München
17
unsigned int weg[ ANZAHL][ ANZAHL] = { { 0,0,0,1,0,0,1,1,0,1,0,0}, {0,0,1,0,0,0,1,1,0,0,0,0}, {0,0,0,0,1,1,0,1,1,0,0,0}, { 0,0,0,0,0,0,0,0,0,1,0,0}, {0,0,0,0,0,0,0,0,1,0,0,0}, {0,0,0,0,0,0,0,1,1,1,1,1}, {0,0,0,0,0,0,0,1,0,0,0,0}, {0,0,0,0,0,0,0,0,0,1,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,1,0}, {0,0,0,0,0,0,0,0,0,0,0,1}, {0,0,0,0,0,0,0,0,0,0,0,0}, };
Abbildung 17.14 Einbahnstraßen-Autobahngraph und seine Adjazenzmatrix
Angewandt auf diese Ausgangsmatrix, erzeugt der Algorithmus die folgende Ergebnismatrix:
521
17
Elemente der Graphentheorie
Ber Bre Dor Dre Due Fra Ham Han Koe Lei Mue Stu
Ber 0 0 0 0 0 0 0 0 0 0 0 0
Bre 0 0 0 0 0 0 0 0 0 0 0 0
Dor 0 1 0 0 0 0 0 0 0 0 0 0
Dre 1 0 0 0 0 0 0 0 0 0 0 0
Due 0 1 1 0 0 0 0 0 0 0 0 0
Fra 0 1 1 0 0 0 0 0 0 0 0 0
Ham 1 1 0 0 0 0 0 0 0 0 0 0
Han 1 1 1 0 0 1 1 0 0 0 0 0
Koe 0 1 1 0 1 1 0 0 0 0 0 0
Lei 1 1 1 1 0 1 1 1 0 0 0 0
Mue 1 1 1 1 0 1 1 1 0 1 0 0
Stu 1 1 1 1 0 1 1 1 0 1 1 0
Die Ergebnismatrix zeigt, von welcher Stadt aus welche Städte erreichbar sind. Das Erreichbarkeitsproblem ist damit vollständig gelöst. Die Matrix zeigt allerdings nicht, welchen Weg man im Falle der Erreichbarkeit einschlagen sollte. Mit dieser Frage werden wir uns später beschäftigen.
17.7
Kantentabellen
Eine Adjazenzmatrix ist eine sinnvolle Repräsentation für einen Graphen, wenn man eine knotenorientierte Verarbeitung des Graphen plant. Die Algorithmen, die Sie bisher kennengelernt haben, waren knotenorientiert. Manchmal ist es aber sinnvoll, in einem Algorithmus kantenorientiert vorzugehen. Das heißt, man möchte der Reihe nach alle Kanten eines Graphen betrachten, um gewisse Berechnungen durchführen zu können. In dieser Situation bietet es sich an, eine Kantentabelle anstelle einer Adjazenzmatrix zu verwenden. Eine Kantentabelle ist ein Array (oder eine Liste), in der alle Kanten des Graphen mit Anfangs- und Endpunkt aufgeführt sind.
A
D
a b
d a
b
e
B
von B A nach A B
f
c
C g
Abbildung 17.15 Kantenmatrix eines Graphen
522
c
Kante d e
B C B D A C
f
g
D C C C
Die erste Kante geht von B nach A.
17.8
Zusammenhang und Zusammenhangskomponenten
Ein Graph mit n Knoten kann n2 Kanten haben, wenn alle Knoten paarweise miteinander verbunden sind. In der Regel werden es aber deutlich weniger Kanten sein. Verwenden Sie bei einem kantenorientierten Verfahren eine Adjazenzmatrix, müssen Sie alle n2 Knotenpaare betrachten und werden quadratische Laufzeit haben. Bei Verwendung einer Kantentabelle können Sie die Laufzeit reduzieren, wenn es relativ wenig Kanten im Vergleich zum Quadrat der Knotenzahl gibt. Eine konkrete Implementierung einer Kantentabelle werden Sie im nächsten Abschnitt kennenlernen.
17.8
Zusammenhang und Zusammenhangskomponenten
Zur Anwendung von Kantentabellen werden wir Ihnen jetzt die sogenannten Zusammenhangskomponenten eines symmetrischen Graphen vorstellen. Dazu erläutern wir Ihnen zunächst den Begriff des Zusammenhangs in beliebigen Graphen: Ein Graph heißt schwach zusammenhängend, wenn es für je zwei Knoten A und B einen Weg von A nach B oder einen Weg von B nach A gibt. Ein Graph heißt stark zusammenhängend oder einfach zusammenhängend, wenn es für je zwei Knoten A und B einen Weg von A nach B gibt. Ein zusammenhängender Graph ist immer schwach zusammenhängend. In symmetrischen Graphen fallen die beiden Begriffe zusammen.
17
In Abbildung 17.16 sehen Sie dazu einige einfache Beispiele: nicht schwach zusammenhängend
schwach zusammenhängend, aber nicht zusammenhängend
zusammenhängend
A
D
A
D
A
D
B
C
B
C
B
C
Abbildung 17.16 Beispiele für zusammenhängende Graphen
Ein ungerichteter, zusammenhängender kreisfreier Graph wird auch als Baum bezeichnet. In einem ungerichteten Graphen ergeben sich immer »Cluster« von paarweise untereinander zusammenhängenden Knoten. Diese Cluster heißen Zusammenhangskomponenten. Im folgenden Beispiel sehen Sie vier Zusammenhangskomponenten:
523
17
Elemente der Graphentheorie
Abbildung 17.17 Vier Zusammenhangskomponenten
Die Zusammenhangskomponenten bilden immer eine »disjunkte Zerlegung« der Knotenmenge. Das bedeutet, dass jeder Knoten genau einer Zusammenhangskomponente zugeordnet ist. Würde man im oben dargestellten Beispiel eine zusätzliche Kante von einem Knoten eines Clusters zu einem Knoten eines anderen Clusters ziehen, würden die beiden Cluster sofort verschmelzen.
Abbildung 17.18 Verschmelzung von Zusammenhangskomponenten
Ist der Graph zusammenhängend, dann gibt es nur eine Zusammenhangskomponente. Die Cluster bilden sich, weil die Verbindungsbeziehung in symmetrischen Graphen die folgenden drei Eigenschaften hat: 1. Jeder Knoten kann mit sich selbst verbunden werden. 2. Wenn A mit B verbunden werden kann, dann kann auch B mit A verbunden werden. 3. Wenn A mit B und B mit C verbunden werden kann, dann kann auch A mit C verbunden werden.
524
17.8
Zusammenhang und Zusammenhangskomponenten
Diese drei Eigenschaften heißen Reflexivität, Symmetrie und Transitivität. Eine Beziehung, die diese drei Eigenschaften hat, nennt sich Äquivalenzrelation. Äquivalenzrelationen haben immer die Eigenschaft, die Grundmenge vollständig in paarweise elementfremde Teilmengen (sogenannte Äquivalenzklassen) zu zerlegen. Äquivalenzrelationen sind eine ganz wesentliche Grundlage unseres Denkens. Immer wenn wir abstrahieren, verwenden wir (bewusst oder unbewusst) eine Äquivalenzrelation. Betrachten Sie z. B. die Menge aller Autos und auf dieser Menge die Relation »vom gleichen Hersteller sein«. Diese Relation ist eine Äquivalenzrelation (die Bedingungen 1–3 sind erfüllt) und zerlegt die Menge der Autos in elementfremde Klassen von Autos, die jeweils vom gleichen Hersteller kommen. Diese Klassen heißen dann Audi, BMW, Mercedes oder VW. In diesem Sinne bilden Äquivalenzrelationen auch das theoretische Fundament der objektorientierten Programmierung (siehe ab Kapitel 20). Die Zusammenhangskomponenten sind die Äquivalenzklassen bezüglich der Äquivalenzrelation »durch einen Weg verbindbar« über der Knotenmenge eines Graphen. Wir wollen einen Algorithmus entwickeln, der die Zusammenhangskomponenten für einen Graphen berechnet, und folgen dabei der Idee von der Verschmelzung der Cluster. Zunächst modifizieren wir unser Standardbeispiel, damit überhaupt verschiedene Zusammenhangskomponenten entstehen, und erstellen für den modifizierten Graphen eine Kantentabelle.
Bremen
Hamburg
Berlin Hannover Dortmund Düsseldorf
Leipzig
Köln
Dresden
Frankfurt
Stuttgart München
# define ANZ_KNOTEN 12 # define ANZ_KANTEN 17
struct kante { int von; int nach; };
struct kante kanten tabelle[ANZ_KANTEN] = { {0,3}, {1,6}, {0,7}, # define BERLIN {1,7}, # define BREMEN {6,7}, # define DORTMUND {2,8}, # define DRESDEN {4,8}, # define DUESSELDORF {5,8}, # define FRANKFURT {0,9}, # define HAMBURG {3,9}, # define HANNOVER {2,4}, # define KOELN {2,5}, # define LEIPZIG {0,6}, # define MUENCHEN {7,9}, # define STUTTGART {5,10}, {5,11}, {10,11} };
0 1 2 3 4 5 6 7 8 9 10 11
Abbildung 17.19 Kantentabelle des modifizierten Graphen
525
17
17
Elemente der Graphentheorie
Wir haben jetzt die Cluster »Südwest« und »Nordost«. Diese beiden Cluster wollen wir aus der Kantentabelle berechnen. Dabei lassen wir uns von der folgenden Idee leiten: Um die Zusammenhangskomponenten zu bestimmen, bilden wir Mengen von Knoten. Am Anfang liegt jeder Knoten für sich allein in einer eigenen Menge. Dann betrachten wir der Reihe nach alle Kanten des Graphen. Wenn Anfangsund Endpunkt der Kante bereits in der gleichen Menge liegen, ist nichts zu tun. Wenn aber der Anfangs- und der Endpunkt in verschiedenen Mengen liegen, müssen die beiden Mengen verschmolzen werden. Die Mengen, die nach Betrachtung aller Kanten noch übrig sind, sind die Zusammenhangskomponenten. Es bleibt die Frage: Wie kann man möglichst einfach eine Datenstruktur für eine Menge von Zahlen (Knotenindizes) implementieren, die die folgenden Operationen unterstützt: 왘
Einfügen eines Elements (Knotenindex) in eine Menge
왘
Vereinigen von zwei Mengen
Die benötigten Mengen werden als logische Baumstruktur in einem Array gespeichert. int vorgaenger[ANZ_KNOTEN];
Bisher haben wir Bäume immer so implementiert, dass wir Knotenstrukturen hatten, in denen jeweils die Nachfolgerknoten referenziert wurden. Wenn wir dies als eine Vorwärtsverkettung auffassen, gehen wir jetzt genau umgekehrt vor. Wir speichern in dem Array zu jedem Knoten den Index seines Vaterknotens. Durch diese Rückwärtsindizierung können wir auf einfache Weise zu einem Knoten seine Wurzel finden. Abbildung 17.20 veranschaulicht dieses Konzept:
2
2
4
0
0
5
3
4
3
6
6 vorgaenger[3] = 5 bedeutet, dass der Knoten mit dem Index 5 der Vorgänger des Knotens mit dem Index 3 ist.
1
1
v orgaenger
2
3
-1
5
2
2
5
index
0
1
2
3
4
5
6
Abbildung 17.20 Datenstruktur zur Speicherung des Baums
526
5
17.8
Zusammenhang und Zusammenhangskomponenten
Im Array können sogar mehrere elementfremde Bäume liegen. Die Wurzel eines Baums erkennen Sie am Index –1. Im Grunde genommen interessiert uns der genaue Aufbau des Baums aber nicht. Wichtig ist nur, dass jeder Baum im Array eine Menge beschreibt. Alles, was im selben Baum ist, ist in derselben Menge. Jetzt geht es darum, die Zusammenhangskomponenten aufzubauen. Am Anfang liegt jeder Knoten allein in einer Menge. Jeder Knoten ist also die Wurzel in einem ansonsten leeren Baum. Um dies zu erreichen, müssen Sie alle Werte im Rückverweis-Array (vorgaenger) auf –1 setzen: void init() { int i; for( i= 0; i < ANZ_KNOTEN; i++) vorgaenger[i] = –1; } Listing 17.5 Initialisierung der Datenstruktur
Die folgende Funktion join dient dazu, zwei Mengen zu vereinigen. Sie erhält zwei Knotenindizes und geht im Baum zu den zu diesen Knoten gehörenden Wurzeln. Sind die Wurzeln gleich, sind die beiden Knoten bereits im selben Baum. Sind die Wurzeln verschieden, werden die beiden Mengen vereinigt, indem Sie nur die Wurzel der einen Menge (egal, welche von beiden) unter die Wurzel der anderen bringen:
A B
C
void join( int a, int b) { while( vorgaenger[a] != –1) a = vorgaenger[a]; while( vorgaenger[b] != –1) b = vorgaenger[b]; if( a != b) vorgaenger[b] = a; }
Listing 17.6 Vereinigung der Mengen mit join
Dazu arbeitet sich die Funktion zur Wurzel von a (A) und zur Wurzel von b (B). Haben die beiden Knoten unterschiedliche Wurzeln, dann wird die Wurzel b unter die Wurzel a gebracht (C). Nach dem Aufruf dieser Funktion sind die Menge, die den Knoten a enthält, und die Menge, die den Knoten b enthält, miteinander verschmolzen.
527
17
17
Elemente der Graphentheorie
Der Rest des Algorithmus ist genauso einfach zu implementieren. Um die Zusammenhangskomponenten zu berechnen, wird nach der Initialisierung über die Kanten der Kantentabelle iteriert. Für jede Kante wird die Menge, in der der Anfangspunkt liegt, mit der Menge, in der der Endpunkt liegt, verschmolzen: void bilde_komponenten() { int k; init(); for( k = 0; k < ANZ_KANTEN; k++) join( kantentabelle[k].von, kantentabelle[k].nach); } Listing 17.7 Bilden der Zusammenhangskomponente
Nach Aufruf dieser Funktion liegen die Zusammenhangskomponenten im Vorgänger-Array vor. Sie müssen nur noch ausgegeben werden. void ausgabe() { int i, k, z; for( i = 0, z = 0; i < ANZ_KNOTEN; i++) { if( vorgaenger[i] == –1) { printf( "%d-te Zusammenhangskomponente:\n", ++z); for( k = 0; k < ANZ_KNOTEN; k++) { if( wurzel( k) == i) printf( " %2d %s\n", k, stadt[k]); } printf( "\n"); } } } Listing 17.8 Die Ausgabefunktion
In der Ausgabefunktion werden alle Knoten gesucht, die Wurzeln eines Baums sind. Jeder dieser Knoten repräsentiert eine Zusammenhangskomponente. In der inneren Schleife werden dann alle Knoten gesucht, die den in der äußeren Schleife gefundenen Knoten als Wurzel haben, und ausgegeben. Die Ausgabe verwendet noch eine Hilfsfunktion, um zu einem Knoten den Index seiner Wurzel zu ermitteln:
528
17.8
Zusammenhang und Zusammenhangskomponenten
int wurzel( int a) { while( vorgaenger[a] != –1) a = vorgaenger[a]; return a; } Listing 17.9 Indexermittlung für die Wurzel
Es fehlt noch das Hauptprogramm, in dem alle Fäden zusammengeknüpft werden: void main() { bilde_komponenten(); ausgabe(); } Listing 17.10 Das Programm zur Erzeugung und Ausgabe der Komponenten
Das Hauptprogramm berechnet die Komponenten und gibt sie auf dem Bildschirm aus. Abbildung 17.21 zeigt zusammenfassend die Ausgangssituation, die durch den Algorithmus erzeugten Bäume und die abschließende Bildschirmausgabe:
17
Bremen
Hamburg 1 Berlin
0
Hannover Dortmund Düsseldorf
3
6 7
5 9
4
10
11
2
Leipzig 8
Köln
Dresden 1-te Zusammenhangskomponente: 0 Berlin 1 Bremen 3 Dresden 6 Hamburg 7 Hannover 9 Leipzig
Frankfurt
Stuttgart München
2-te Zusammenhangskomponente: 2 Dortmund 4 Duesseldorf 5 Frankfurt 8 Koeln 10 Muenchen 11 Stuttgart
Abbildung 17.21 Ergebnis aus der Ermittlung der Zusammenhangskomponenten
529
17
Elemente der Graphentheorie
17.9
Gewichtete Graphen
Bisher haben wir in unseren Graphen nur Informationen über die prinzipielle Verbindbarkeit von Knoten gespeichert. Wenn Sie an wichtige Anwendungen, wie etwa ein Navigationssystem im Auto, denken, dann kommt es aber nicht nur auf die Existenz einer Verbindung, sondern auch auf die Entfernung und die optimale Route zum Ziel an. Ein System, das nach der Zieleingabe nur »ja, Ihr Ziel ist erreichbar« sagen würde, wäre als Navigationssystem unbrauchbar. Wir wollen unsere Graphen daher um »Entfernungsangaben« erweitern: Wenn in einem Graphen jeder Kante ein Zahlenwert zugeordnet ist, sprechen wir von einem gewichteten oder bewerteten Graphen. Den Zahlenwert einer Kante bezeichnen wir als das Kantengewicht. Kantengewichte können in konkreten Beispielen unter anderem Entfernungskilometer, Reise- oder Produktionskosten, Reise- oder Produktionszeiten, Gewinne oder Verluste bzw. Leitungs- oder Transportkapazitäten bedeuten. In der Darstellung schreiben wir die Kantengewichte zusätzlich an die einzelnen Kanten: A
D
b/-1 a/1
f/0 d/2
c/4 e/-5
B
C g/-3
Abbildung 17.22 Darstellung eines gewichteten Graphen
Wenn einzelne Kanten eines Graphen bewertet sind, können Sie auch ganze Wege bewerten: In einem gewichteten Graphen wird die Summe der Kantengewichte aller Kanten eines Weges als das Gewicht oder die Bewertung des Weges bezeichnet. A
D
b/-1 a/1 B
Der Weg (a, b, a, b, c) hat das Gewicht 4. Der Weg (a, b, c, f, d, b) hat das Gewicht 5. Der Weg (f, g, g, d) hat das Gewicht –4.
f/0 d/2
c/4 e/-5
C g/-3
Abbildung 17.23 Gewicht eines Weges im gewichteten Graphen
530
17.9
Gewichtete Graphen
Je nach Bedeutung (Entfernung/Dauer/Preis) der Kantengewichte können wir uns dann z. B. fragen: Was ist der kürzeste/schnellste/kostengünstigste Weg, also der Weg mit dem niedrigsten Gewicht, von einem Knoten zu einem anderen? Auf diese Frage gibt es nur dann eine Antwort, wenn es keine negativ bewerteten Schleifen in einem Graphen gibt. Wir wollen im Folgenden nur Graphen mit nicht negativen Kantengewichten betrachten, dann gibt es keine negativ bewerteten Schleifen, und wir sind sicher, dass es immer Wege mit minimalem Gewicht gibt, sofern es überhaupt Wege gibt. Ausgangspunkt der folgenden Betrachtungen ist eine »Adjazenzmatrix«, in die wir, anstelle von 0 oder 1 für die Existenz einer Kante, das Kantengewicht eintragen. In unserem Beispiel (Autobahnnetz) sprechen wir dann auch von einer Distanzenmatrix. Abbildung 17.24 zeigt die Distanzenmatrix für unser Standardbeispiel: # define ANZAHL 12 # define xxx 10000
Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
47 Köln
83
Leipzig
352
108
264 395
189
179 205
Dresden
unsigned int distanz[ANZAHL][ANZAHL] = { { 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx}, {xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx}, {xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx}, {205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx}, {xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx}, {xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217}, {284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx}, {282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx}, {xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx}, {179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx}, {xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220}, {xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0}, };
Frankfurt
217 400 Stuttgart
425
220 München
Abbildung 17.24 Darstellung der Distanzenmatrix
In der Distanzenmatrix stehen die Entfernungen zwischen Städten, die durch eine Kante verbunden sind. Bei Städten, die nicht direkt durch eine Kante verbunden sind, steht dort ein »großer« Wert (xxx, 10000), der erkennbar keine gültige Entfernungsangabe darstellt.
531
17
17
Elemente der Graphentheorie
17.10
Kürzeste Wege
Sobald wir einen Graphen mit Distanzangaben haben, können wir uns die Frage nach kürzesten Wegen zwischen zwei Knoten stellen. In einem analogen Modell ist das Problem, einen kürzesten Weg zu finden, einfach zu lösen. Man baut den Graphen als Drahtmodell, wobei die Länge der Drähte dem Kantengewicht entspricht, fasst an den beiden Knoten an und zieht sie so weit, wie es geht, auseinander.
Abbildung 17.25 Ermittlung des kürzesten Weges
Die Folge der unter Spannung stehenden Drähte bildet dann den gesuchten Weg. In einem digitalen Modell, etwa unter Verwendung der Distanzenmatrix, wird dieser Weg nicht so einfach zu finden sein. Wir betrachten einen Graphen mit nicht negativen Kantengewichten. Die Kantengewichte werden dabei als Entfernungen interpretiert. Dann gibt es, was die Wegesuche betrifft, drei verschiedene Aufgabenstellungen mit offensichtlich wachsendem Lösungsaufwand: 1. Finde den kürzesten Weg von einem Knoten A zu einem Knoten B. 2. Finde die kürzesten Wege von einem Knoten A zu allen anderen Knoten des Graphen. 3. Finde die kürzesten Wege zwischen allen Knoten des Graphen. Wenn Sie die erste Aufgabe für zwei Knoten A und B lösen, fallen alle kürzesten Verbindungen zwischen Knoten längs des Wegs von A nach B als Nebenergebnis mit ab, da ja Teilstrecken optimaler Wege ebenfalls optimal sind. Mehr noch, es fallen alle optimalen Strecken von A nach B über einen beliebigen Zwischenpunkt C mit ab, da
532
17.11
Der Algorithmus von Floyd
ja geprüft werden muss, ob ein Weg über C die kürzeste Verbindung von A nach B ermöglicht. Das bedeutet, dass Sie die Aufgabe 1 nicht lösen können, ohne zugleich die Aufgabe 2 zu lösen. Sie haben es also de facto nur mit zwei Aufgaben zu tun: Aufgabe I: Finde die kürzesten Wege zwischen allen Knoten des Graphen. Aufgabe II: Finde die kürzesten Wege von einem Knoten A zu allen anderen Knoten des Graphen. Wir werden im Folgenden drei Algorithmen betrachten: 1. Algorithmus von Floyd (Aufgabe I) 2. Algorithmus von Dijkstra (Aufgabe II) 3. Algorithmus von Ford (Aufgabe II)
17.11
Der Algorithmus von Floyd
Bevor wir einen Algorithmus zur Suche aller optimalen Wege erstellen können, müssen wir uns überlegen, wie eine Datenstruktur aussehen könnte, in der wir das Ergebnis speichern können. Spontan würde man sagen, dass wir eine Liste aller Knotenpaare erstellen, die zu jedem Knotenpaar eine Liste mit den Knoten des jeweils optimalen Weges enthält. Es geht aber viel einfacher und eleganter. Wir benötigen zwei Matrizen. Die eine ist die Distanzenmatrix, die zu jedem Knotenpaar die Entfernung aufnimmt. Die zweite Matrix ist eine Zwischenpunktmatrix, die zu jedem Knotenpaar A, B einen Zwischenpunkt auf dem optimalen Weg von A nach B enthält. Abbildung 17.26 zeigt dies an einem einfachen Beispiel: Die Distanz von D nach C beträgt zwölf Einheiten. Distanzenmatrix
E 5
4
A
D
1
3 B
2
C
A B C D E
0 – – – 5 A
1 0 – – – B
– – 2 – 0 3 – 0 – – C D
Distanzmatrix – – – 4 0 E
Algorithmus von Floyd
Graph
A B C D E
0 1 3 6 10 14 0 2 5 9 12 13 0 3 7 9 10 12 0 4 5 6 8 11 0 A B C D E
Zwischenpunktmatrix A B C D E A – – B C D B E – – C D C E E – – D D E E E – – E – A B C –
Der kürzeste Weg von D nach C geht über den Zwischenpunkt E.
Abbildung 17.26 Der Algorithmus von Floyd
533
17
17
Elemente der Graphentheorie
Da alle Teilstrecken optimaler Wege ihrerseits optimal sind, reicht es aus, für je zwei Knoten X und Y einen Zwischenpunkt Z in einer Zwischenpunktmatrix zu speichern. Die weiteren Zwischenpunkte findet man dann, indem man in der Matrix Zwischenpunkte zu X und Z bzw. Z und Y sucht und dieses Verfahren (rekursiv) fortsetzt, bis keine Zwischenpunkte mehr gefunden werden. Im folgenden Beispiel wird der kürzeste Weg von D nach C aus der Zwischenpunktmatrix in Abbildung 17.26 gelesen:
12
D 4
C 8
E 6 5
A
B
2
1
Abbildung 17.27 Kürzester Weg mit Zwischenpunkten
Durch eine kleine Datenstruktur könnte man die beiden Matrizen noch miteinander verschmelzen. Das wollen wir hier aber nicht machen. Wir arbeiten mit zwei getrennten Matrizen, die wie folgt angelegt werden: int distanz[ANZAHL][ANZAHL]; int zwischenpunkt[ANZAHL][ANZAHL];
Wir erstellen Hilfsfunktionen, um diese Matrizen auszugeben: int zwischenpunkt[ANZAHL][ANZAHL]; void print_zwischenpunkte() { int z, s; printf( "Zwischenpunkte:\n"); for( z = 0; z < ANZAHL; z++) { for( s = 0; s < ANZAHL; s++) printf( "%3d ", zwischenpunkt[z][s]); printf( "\n"); } } Listing 17.11 Hilfsfunktion zur Ausgabe von Zwischenpunkten
534
17.11
Der Algorithmus von Floyd
int distanz[ANZAHL][ANZAHL]; void print_distanzen() { int z, s; printf( "Distanzen:\n"); for( z = 0; z < ANZAHL; z++) { for( s = 0; s < ANZAHL; s++) printf( "%3d ", distanz[z][s]); printf( "\n"); } } Listing 17.12 Hilfsfunktion zur Ausgabe von Distanzen
Um aus den Matrizen einen optimalen Weg auszugeben, verwenden wir die Funktionen print_path und print_nodes: void print_path( int von, int nach) { printf( "%s", stadt[von]); print_nodes( von, nach); printf( "->%s", stadt[nach]); printf( " (%d km)\n", distanz[von][nach]); }
17
Listing 17.13 Die Funktion print_path
Die Funktion print_path erhält Start- und Zielpunkt und gibt diese samt Entfernung aus. Alle Zwischenpunkte auf dem Weg vom Start- zum Zielpunkt werden dabei mit der rekursiven Funktion print_nodes aus der Zwischenpunktmatrix gelesen und ausgegeben. void print_nodes( int von, int nach) { int zpkt; zpkt = zwischenpunkt[von][nach]; if( zpkt == –1) return;
535
17
Elemente der Graphentheorie
print_nodes( von, zpkt); printf( "->%s", stadt[zpkt]); print_nodes( zpkt, nach); } Listing 17.14 Die Funktion print_nodes
Bevor wir uns auf die Suche nach kürzesten Wegen machen, müssen wir noch die Zwischenpunktmatrix initialisieren. Der Wert –1 in einem Feld der Zwischenpunktmatrix zeigt an, dass für den zugehörigen Weg noch kein Zwischenpunkt berechnet wurde. Die Zwischenpunktmatrix wird dementsprechend initialisiert: void init() { int von, nach; for( von = 0; von < ANZAHL; von++) { for( nach = 0; nach < ANZAHL; nach++) zwischenpunkt[von][nach] = –1; } } Listing 17.15 Initialisierung der Zwischenpunktmatrix
Als Distanzenmatrix wird initial die Adjazenzmatrix verwendet. Von der Idee her ist der Algorithmus von Floyd identisch mit dem Algorithmus von Warshall (siehe dort). Auch hier wird Schritt für Schritt eine Menge bereits bearbeiteter Knoten aufgebaut. Hier wird jedoch nicht nur nach der Existenz eines Weges über den jeweils neu hinzugekommenen Zwischenpunkt gefragt, sondern es wird auch geprüft, ob der Weg über den Zwischenpunkt kürzer ist als der bisher kürzeste Weg. Ist das der Fall, werden die neue Distanz in der Distanzenmatrix und der Zwischenpunkt in der Zwischenpunktmatrix gespeichert. void floyd() { int von, nach, zpkt; unsigned int d; A B
536
for( zpkt = 0; zpkt < ANZAHL; zpkt++) { for( von = 0; von < ANZAHL; von++) {
17.11
C
Der Algorithmus von Floyd
for( nach = 0; nach < ANZAHL; nach++) { d = distanz[von][zpkt] + distanz[zpkt][nach]; if( d < distanz[von][nach]) { distanz[von][nach] = d; zwischenpunkt[von][nach] = zpkt; } } }
D E F
} } Listing 17.16 Der Algorithmus von Floyd
In der Funktion wird geprüft, ob man über den Zwischenpunkt zpkt den Weg vom Knoten von zum Knoten nach verkürzen kann (A, B und C). Ist eine Verkürzung möglich, hat man eine neue Distanz (D) und einen neuen Zwischenpunkt (E und F). Angewandt auf unseren Standardgraphen mit dem deutschen Autobahnnetz, erzeugt der Algorithmus von Floyd die Distanzen- und die Zwischenpunktmatrix.
17 Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
47 Köln
83
Leipzig
352
108
264 395
189
179 205
Dresden
Frankfurt
217 400 Stuttgart
425
# # # # # # # # # # # #
define define define define define define define define define define define define
BERLIN BREMEN DORTMUND DRESDEN DUESSELDORF FRANKFURT HAMBURG HANNOVER KOELN LEIPZIG MUENCHEN STUTTGART
0 1 2 3 4 5 6 7 8 9 10 11
220 München
Abbildung 17.28 Graph mit dem Autobahnnetz
537
17
Elemente der Graphentheorie
Aus diesen Matrizen können konkrete Fahrtrouten mit Entfernungsangaben (im Beispiel Berlin-Stuttgart und München-Hamburg) ausgegeben werden. void main() { init(); floyd(); print_distanzen(); print_zwischenpunkte(); print_path( BERLIN, STUTTGART); print_path( MUENCHEN, HAMBURG); } Listing 17.17 Anwendung des Algorithmus von Floyd
Wir erhalten die folgenden Distanzen aus print_distanzen: Distanzen: 0 403 490 403 0 233 490 233 0 205 489 572 553 296 63 574 477 264 284 119 352 282 125 208 573 316 83 179 381 464 604 806 664 791 694 481
205 489 572 0 635 503 489 364 655 108 533 720
553 296 63 635 0 236 415 271 47 527 636 453
574 477 264 503 236 0 506 352 189 395 400 217
284 119 352 489 415 506 0 154 435 410 835 723
282 125 208 364 271 352 154 0 291 256 681 569
573 316 83 655 47 189 435 291 0 547 589 406
179 381 464 108 527 395 410 256 547 0 425 612
604 806 664 533 636 400 835 681 589 425 0 220
791 694 481 720 453 217 723 569 406 612 220 0
Mit diesen Zwischenpunkten aus print_zwischenpunkte: Zwischenpunkte: –1 6 7 –1 7 9 –1 6 –1 –1 9 2 7 –1 7 –1 –1 9 –1 –1 1 –1 9 9 –1 9 9 0 7 2 –1 9 –1 8 2 9 7 –1 9 8 –1 7 –1 –1 1 0 2 7 –1
538
–1 7 –1 9 9 –1 2 7 9 7 –1 –1 7 5 5 9 9 –1 9 9 2 –1 7 8 8 –1 –1 –1 –1 –1 –1 2 7 9 7
17.12
Der Algorithmus von Dijkstra
–1 –1 –1 9 2 –1 –1 –1 2 –1 9 5 7 2 –1 9 –1 –1 2 2 –1 7 5 5 –1 7 7 –1 7 –1 7 –1 7 –1 –1 5 9 9 5 9 8 –1 9 9 5 –1 –1 –1 9 7 5 9 8 –1 7 5 5 5 –1 –1
Und für die Strecken Berlin-Stuttgart und München-Hamburg erhalten wir die folgenden Pfade: Berlin->Leipzig->Frankfurt->Stuttgart (791 km) Muenchen->Leipzig->Hannover->Hamburg (835 km)
Die Distanzenmatrix ist symmetrisch, weil hier ein symmetrischer Graph vorliegt. Das Verfahren setzt aber nicht voraus, dass der Graph symmetrisch ist. Bei einem asymmetrischen Graphen (Einbahnstraßen) könnte sich ein asymmetrischer Distanzengraph ergeben. Dies bedeutet, dass Hin- und Rückfahrt gegebenenfalls unterschiedliche Streckenführungen und unterschiedliche Distanzen hätten. Die Aufgabe, alle kürzesten Verbindungen in einem Graphen zu finden, ist damit befriedigend gelöst. Gelöst ist damit natürlich auch die Aufgabe, die kürzesten Wege von einem festen Startpunkt zu allen möglichen Zielpunkten zu finden. Wir hoffen aber, dass wir, wenn wir uns auf diese Teilaufgabe beschränken, effizientere Algorithmen finden können. Sie werden für diese Aufgabe zwei verschiedene Verfahren kennenlernen: eines (Dijkstra), das knotenorientiert arbeitet, und ein anderes (Ford), das kantenorientiert vorgeht.
17.12
Der Algorithmus von Dijkstra
Die Verfahrensidee des Algorithmus von Dijkstra möchten wir Ihnen an einem einfachen Beispiel vorstellen. Wir betrachten dazu den folgenden Graphen, in dem wir alle kürzesten Wege vom Startpunkt A aus suchen:
A 9
5
B
3
C
6
2
4
D
3
E
Abbildung 17.29 Beispielgraph für den Algorithmus von Dijkstra
539
17
17
Elemente der Graphentheorie
Dazu bietet sich die folgende Vorgehensweise an: 1. Starte am Knoten A, und bewerte die Knoten, die von dort aus direkt erreichbar sind, entsprechend der Entfernung. 2. Wähle den am günstigsten bewerteten Knoten (das ist C), und markiere den Weg, der zu dieser Bewertung geführt hat. Danach bewerte alle von A oder C aus direkt erreichbaren Knoten. Dabei ergeben sich gegebenenfalls neue Bewertungen oder Verbesserungen bisheriger Bewertungen. 3. Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (B), und markiere den Weg, der zu dieser Bewertung geführt hat. Danach bewerte alle von A, C oder B direkt erreichbaren Knoten. 4. Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (E), und markiere den Weg, der zu dieser Bewertung geführt hat. Danach bewerte alle von A, C, B oder E direkt erreichbaren Knoten. Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (D), und markiere den Weg, der zu diesem Knoten geführt hat. Beende das Verfahren, da keine Knoten mehr zu bewerten sind. Abbildung 17.30 zeigt dieses Vorgehen Schritt für Schritt: (1)
A 5
9 B
3
6
2
D
3
9
(2)
A 5
9
5
8
(3)
A 9
B
3
4
6
2
4
6
2
4
E
D
3
E
D
3
E
C
C
B
3
C
5
8
9
14
(4)
A B
C
6
2
4
D
3
E
5
8
9
12
(5)
A B
C
5
8
9
12
A B
C
D
E
5
6 D
3
E
9
Abbildung 17.30 Das Vorgehen bei Dijkstra Schritt für Schritt
Das Verfahren konstruiert einen Baum (den Baum der günstigsten von A ausgehenden Wege) in den Graphen hinein. Beachten Sie übrigens, dass die insgesamt kostengünstigste Kante (B–E) nicht ausgewählt wurde. Ein Greedy-Verfahren, das sich zuerst auf günstigste Kanten stürzen würde, würde also nicht zum Ziel führen. Es war kein Zufall, dass sich in unserem Beispiel ein Baum als Lösungsstruktur ergeben hat. Das liegt daran, dass Teilstrecken kürzester Wege ebenfalls kürzeste Wege sind und daher einmal eingetretene Pfade nicht mehr verlassen. Zur Speicherung aller kürzesten Wege von einem festen Ausgangspunkt bietet sich daher eine Baumstruktur an. Für diese Baumstruktur verwenden wir wieder das Prinzip der Rückverweise zum Vaterknoten. Zusätzlich zum Rückverweis benötigen wir für jeden Knoten noch die Distanz zum Startknoten und aus verfahrenstechnischen Gründen noch eine Information, ob ein Knoten bereits bearbeitet wurde. Daher verwenden wir im Verfahren die folgende Datenstruktur:
540
17.12
# define ANZAHL
Der Algorithmus von Dijkstra
12
struct knoteninfo { unsigned int distanz; int vorgaenger; char erledigt; }; struct knoteninfo info[ANZAHL];
Im Array info stehen also für jeden Knoten die Information über den Vorgänger, die Distanz zum Ausgangspunkt und der Bearbeitungsvermerk. In unserem Standardbeispiel wird sich zum Startpunkt Berlin der folgende Baum ergeben:
Bremen
# # # # # # # # # # # #
Hamburg
284
403
Berlin
0
Hannover
282 Dortmund Düsseldorf
553
Leipzig
define define define define define define define define define define define define
BERLIN BREMEN DORTMUND DRESDEN DUESSELDORF FRANKFURT HAMBURG HANNOVER KOELN LEIPZIG MUENCHEN STUTTGART
0 1 2 3 4 5 6 7 8 9 10 11
17
490 179
Köln 573
205
Dresden
574
Von Berlin nach Dortmund sind es 490 km. 0
Frankfurt
Distanz
791
1
2
3
4
5
6
7
8
9
10 11
0 403 490 205 553 574 284 282 573 179 604 791
Vorgänger –1
6
7
0
2
9
0
0
2
0
9
5
Erledigt
1
1
1
1
1
1
1
1
1
1
1
1
Stuttgart
604 München
Der Knoten Dortmund ist bearbeitet.
Der Vorgänger des Knotens 2 (Dortmund) ist der Knoten 7 (Hannover).
Abbildung 17.31 Entstehender Baum für den Startpunkt Berlin
Sie sehen, dass wir aus dieser Struktur alle benötigten Informationen herauslesen können. Wir müssen sie jetzt nur noch erzeugen. Zur Initialisierung des info-Arrays werden die Entfernungen aus der zum Startknoten gehörenden Zeile der Distanzenmatrix übernommen.
541
17
Elemente der Graphentheorie
# define xxx 10000 unsigned int distanz[ ANZAHL][ ANZAHL] = { { 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx}, {xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx}, {xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx}, {205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx}, {xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx}, {xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217}, {284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx}, {282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx}, {xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx}, {179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx}, {xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220}, {xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0}, }; Listing 17.18 Distanzenmatrix als Basis für den Dijkstra-Algorithmus
Wenn es keine direkte Verbindung durch eine Kante gibt, ist dieser Wert zunächst noch »sehr« groß (xxx = 10000). Der Vorgänger aller Knoten ist zunächst der Startknoten, nur der Startknoten selbst hat als Wurzel natürlich keinen Vorgänger: void init( int ausgangspkt) { int i; for( i = 0; i < ANZAHL; i++) { info[i].erledigt = 0; info[i].distanz = distanz[ausgangspkt][i]; info[i].vorgaenger = ausgangspkt; } info[ausgangspkt].erledigt = 1; info[ausgangspkt].vorgaenger = –1; } Listing 17.19 Initialisierung des info-Arrays
Nur der Ausgangspunkt wird als »erledigt« markiert. Alle anderen Knoten müssen noch bearbeitet werden.
542
17.12
Der Algorithmus von Dijkstra
In der Hilfsfunktion knoten_auswahl wird unter allen noch nicht erledigten Knoten derjenige ermittelt, der momentan den geringsten Abstand zum Startknoten hat. int knoten_auswahl() { int i, minpos; unsigned int min; min = xxx; minpos = –1; for( i = 0; i< ANZAHL; i++) { if( info[i].distanz < min && !info[i].erledigt) { min = info[i].distanz; minpos = i; } } return minpos; } Listing 17.20 Knotenauswahl
Die Funktion gibt den Index des gesuchten Knotens (oder –1, falls alle Knoten bereits erledigt sind) zurück. Sie können die Effizienz der Knotensuche steigern, wenn Sie eine Datenstruktur zur Zwischenspeicherung von Knoten verwenden, die eine effiziente Entnahme des jeweils am nächsten liegenden Knotens ermöglicht, wobei die Struktur nach Einbau eines neuen Knotens in die Menge der erledigten Knoten reorganisiert werden müsste, da sich die Abstände vermindern. Eine geeignete Struktur wäre ein sogenannter Fibonacci-Heap, den wir hier aber nicht behandeln. Wir kommen jetzt zum algorithmischen Kern des Dijkstra-Verfahrens. Diesen Kern haben wir Ihnen ja bereits oben vorgestellt, sodass wir hier direkt in den Code einsteigen können: void dijkstra( int ausgangspkt) { int i, knoten, k; unsigned int d; init( ausgangspkt);
543
17
17
Elemente der Graphentheorie
A B C D E F G
for( i = 0; i < ANZAHL-2; i++) { knoten = knoten_auswahl(); info[knoten].erledigt = 1; for( k = 0; k < ANZAHL; k++) { if( info[k].erledigt) continue; d = info[knoten].distanz + distanz[knoten][k]; if( d < info[k].distanz) { info[k].distanz = d; info[k].vorgaenger = knoten; } } } }
Listing 17.21 Implementierung des Dijkstra-Verfahrens
Der Ausgangsknoten ist bereits erledigt, und der letzte, am Ende übrig bleibende Knoten muss nicht mehr eigens behandelt werden. Also wird die Schleife ANZAHL-2 mal durchlaufen (A). In der Schleife wird der nächste (= nächstliegende) Knoten gewählt (B). Der Knoten ist dann erledigt (C). Jetzt wird über alle noch nicht erledigten Knoten k iteriert (D und E). Wenn der Weg zum Knoten k über den Knoten knoten verkürzt werden kann, dann ergeben sich eine kürzere Distanz (F und G) und ein neuer Vorgänger. Ansonsten bleibt alles beim Alten. Dieser Algorithmus erzeugt den Kürzeste-Wege-Baum, den wir dann nur noch ausgeben müssen. Da der Baum allerdings rückwärtsverkettet aufgebaut ist, drehen wir die Ausgabereihenfolge der Knoten durch Rekursion um: void print_all() { int i; for( i = 0; i < ANZAHL; i++) { print_path( i); printf( "%d km\n", info[i].distanz); } } Listing 17.22 Ausgabe der Knoten durch Rekursion
544
17.12
Der Algorithmus von Dijkstra
Die Funktion print_all ruft die print_path-Funktion, die sich rekursiv selbst ruft: void print_path( int i) { if( info[i].vorgaenger != –1) print_path( info[i].vorgaenger); printf( "%s-", stadt[i]); }
Im Hauptprogramm wird der Kürzeste-Wege-Baum durch den Dijkstra-Algorithmus erzeugt und anschließend ausgegeben: void main() { dijkstra( BERLIN); print_all(); }
Bremen
Hamburg
284
17
403
Berlin
0
Hannover
282 Dortmund Düsseldorf
553
Leipzig
490 179
Köln 573
205
Dresden
574 Frankfurt
791 Stuttgart
604 München
Abbildung 17.32 Alle kürzesten Wege vom Startpunkt Berlin
545
17
Elemente der Graphentheorie
Berlin-0 km Berlin-Hamburg-Bremen-403 km Berlin-Hannover-Dortmund-490 km Berlin-Dresden-205 km Berlin-Hannover-Dortmund-Duesseldorf-553 km Berlin-Leipzig-Frankfurt-574 km Berlin-Hamburg-284 km Berlin-Hannover-282 km Berlin-Hannover-Dortmund-Koeln-573 km Berlin-Leipzig-179 km Berlin-Leipzig-Muenchen-604 km Berlin-Leipzig-Frankfurt-Stuttgart-791 km
17.13
Erzeugung von Kantentabellen
Wie angekündigt, lernen Sie noch ein zweites Verfahren kennen, um den KürzesteWege-Baum zu erzeugen, das, im Gegensatz zum Dijkstra-Algorithmus, kantenorientiert vorgehen wird. Natürlich können Sie alle Kanten in der Adjazenzmatrix eines Graphen finden. Wenn Sie aber von vornherein ein kantenorientiertes Vorgehen planen, ist es sinnvoll, anstelle einer Adjazenzmatrix eine Kantentabelle zu verwenden. Wir wollen aus der Distanzenmatrix eines Graphen eine Kantentabelle, die für jede Kante deren Anfangs- und Endpunkt sowie das Kantengewicht enthält, erzeugen:
Kantentabelle
A 5
9 B
3
C
6
2
4
D
3
E
Kante Kante Kante Kante Kante Kante Kante
1: 2: 3: 4: 5: 6: 7:
A→B A→ C B →A B→C B→D B→E C →A
9 5 9 3 6 2 5
Kante 8: Kante 9: Kante 10: Kante 11: Kante 12: Kante 13: Kante 14:
C →B C →E D→B D→E E →B E →C E →D
3 4 6 3 2 4 3
Abbildung 17.33 Beispielgraph und die zugehörige Kantentabelle
Ein Graph mit n Knoten hat maximal, wenn jeder Knoten mit jedem verbunden ist, n2 Kanten. Wir erzeugen daher ein Array, das auf diese Maximallast ausgelegt ist und für jede Kante den Anfangs- und Endknoten sowie das Kantengewicht bereitstellt:
546
17.13
Erzeugung von Kantentabellen
# define ANZAHL 5 # define xxx 10000 int distanz[ ANZAHL][ ANZAHL]; struct kante { int von; int nach; int distanz; }; int anzahl_kanten; struct kante kantentabelle[ANZAHL*ANZAHL];
Die Kantentabelle (kantentabelle) befüllen wir jetzt mit Daten, indem wir die Distanzenmatrix auswerten. Dabei ergibt sich auch die Anzahl der effektiv vorhandenen Kanten (anzahl_kanten): void setup_kantentabelle() { int i, j, k, d;
17
for( i = k = 0; i < ANZAHL; i++) { for( j = 0; j < ANZAHL; j++) { d = distanz[i][j]; if((d > 0) && (d < xxx)) { kantentabelle[k].distanz = d; kantentabelle[k].von = i; kantentabelle[k].nach = j; k++; } } anzahl_kanten = k; } } Listing 17.23 Befüllen der Kantentabelle
547
17
Elemente der Graphentheorie
Auf diese Weise lässt sich einfach eine Kantentabelle aus der Distanzenmatrix erzeugen, und wir gehen im Folgenden davon aus, dass für unseren Graphen eine Kantentabelle vorliegt.
17.14
Der Algorithmus von Ford
Der Algorithmus von Ford ist ein kantenorientiertes Verfahren, mit dem alle kürzesten Wege von einem festen Startpunkt aus ermittelt werden können. Ausgangspunkt ist die Kantentabelle eines Graphen. Wir betrachten als Beispiel den bei den Kantentabellen besprochenen Graphen:
Kantentabelle
A
Kante Kante Kante Kante Kante Kante Kante
5
9 B
3
C
6
2
4
D
3
E
1: 2: 3: 4: 5: 6: 7:
A→B A→ C B →A B→C B→D B→E C →A
9 5 9 3 6 2 5
Kante 8: Kante 9: Kante 10: Kante 11: Kante 12: Kante 13: Kante 14:
C →B C →E D→B D→E E →B E →C E →D
3 4 6 3 2 4 3
Abbildung 17.34 Ausgangsgraph für den Algorithmus von Ford
Wir wollen alle kürzesten, vom Knoten D ausgehenden Wege ermitteln. Das Verfahren besteht aus mehreren Durchläufen. In jedem Durchlauf werden der Reihe nach alle Kanten betrachtet und, sofern sie eine Verkürzung zu einem Zielknoten ermöglichen, in den Ergebnisbaum eingebaut. 1. Durchlauf
Durchlauf beendet A
A
B
C
D
E
Kante 1–Kante 9 bringen nichts.
6
A
B
C
D
E
Kante 10 wird eingebaut.
6
A
B
C
D
E
Kante 11 wird eingebaut.
5
3
A
B
C
D
E
5
3
Kante 12 wird anstelle von Kante 11 eingebaut.
B
C
D
E
7
3
Kante 13 wird eingebaut, Kante 14 bringt nichts.
Abbildung 17.35 1. Durchlauf des Algorithmus von Ford
Interessant ist hier die Betrachtung der Kante 12 von E nach B. Bei Betrachtung dieser Kante zeigt sich, dass man den Knoten B über diese Kante günstiger (5 statt bisher 6) erreichen kann als über Kante 11. Darum wird Kante 11 wieder ausgebaut und stattdessen Kante 12 genommen.
548
17.14
Der Algorithmus von Ford
Nach dem ersten Durchlauf ist bereits ein Teilbaum entstanden, der aber weder vollständig noch endgültig sein muss. Es können sowohl weitere Kanten hinzukommen als auch Kanten wieder entfernt werden, wenn neue oder bessere Wege gefunden werden. Darum startet man einen zweiten Durchlauf mit genau der gleichen Strategie: 2. Durchlauf
14
A 5
B
C
D
E
7
3
5
14
A
B
C
D
E
Kante 1 und Kante 2 bringen nichts.
7
3
Kante 3 wird eingebaut.
5
12
A
B
C
D
E
7
3
Kante 4 – Kante 6 bringen nichts.
5
Durchlauf beendet A
B
C
D
E
7
3
Kante 7 wird anstelle von Kante 3 eingebaut. Kanten 8 – 14 bringen nichts.
Abbildung 17.36 2. Durchlauf des Algorithmus von Ford
Auch in diesem Durchlauf haben sich Verbesserungen ergeben. Das Verfahren wird so lange durchgeführt, wie innerhalb eines Durchlaufs noch Verbesserungen möglich sind. Es gibt daher noch ein weiteren Durchlauf, in dem es aber nicht mehr zu Verbesserungen kommt. Das Verfahren ist damit abgeschlossen, und der KürzesteWege-Baum ist berechnet. Die im Algorithmus von Ford zur Speicherung des Ergebnisbaums verwendete Datenstruktur ist bis auf eine Kleinigkeit (das Feld erledigt in der Datenstruktur knoteninfo wird nicht benötigt) identisch mit der beim Algorithmus von Dijkstra verwendeten Struktur: struct knoteninfo { unsigned int distanz; int vorgaenger; }; struct knoteninfo info[ANZAHL];
Dementsprechend gleichen sich auch die Funktionen zur Initialisierung und zur Ausgabe dieser Struktur und müssen hier nicht noch einmal gesondert aufgeführt werden. Wir können uns also direkt um den Kernalgorithmus kümmern, dessen Verfahrensidee uns ja bereits bekannt ist:
549
17
17
Elemente der Graphentheorie
void ford( int ausgangspkt) { int von, nach; unsigned int d; int stop; int kante; A
init( ausgangspkt);
B
for( stop = 0; !stop; ) { stop = 1; for( kante = 0; kante < anzahl_kanten; kante++) { von = kantentabelle[kante].von; nach = kantentabelle[kante].nach; d = info[von].distanz + kantentabelle[kante].distanz; if( d < info[nach].distanz) { info[nach].distanz = d; info[nach].vorgaenger = von; stop = 0; } } } }
C D E F G H
I
Listing 17.24 Implementierung des Algorithmus von Ford
In der Funktion wird zuerst die Ergebnisstruktur initialisiert (A). Solange das Stop-Kennzeichen nicht gesetzt ist, wird in einer Schleife die Kantentabelle durchlaufen (B). Innerhalb der Schleife wird jeweils versuchsweise das Stop-Kennzeichen gesetzt (C). In der nachfolgenden Iteration über alle Kanten (D) wird jeweils der Anfangs- und Endpunkt der betrachteten Kante abgerufen (E und F) und die Distanz zum Endpunkt bei Verwendung der aktuellen Kante ermittelt (G). Wenn diese Distanz kürzer ist als die bisher ermittelte Distanz (H), wird die Kante in den Ergebnisbaum eingebaut. Eine gegebenenfalls vorher genutzte Kante wird dabei automatisch überschrieben (I). Das Ergebnis des Algorithmus von Ford ist natürlich identisch mit dem Ergebnis des Dijkstra-Algorithmus:
550
17.15
Bremen
void main() { setup_kantentabelle(); print_kantentabelle(); ford( BERLIN); print_all(); }
Hamburg
284
403
Berlin
0
Hannover
282 Dortmund Düsseldorf
553
Leipzig
490 179
Köln 573
205
Dresden
574 Frankfurt
791 Stuttgart
Berlin-0 km Berlin-Hamburg-Bremen-403 km Berlin-Hannover-Dortmund-490 km Berlin-Dresden-205 km Berlin-Hannover-Dortmund-Duesseldorf-553 km 604 Berlin-Leipzig-Frankfurt-574 km Berlin-Hamburg-284 km München Berlin-Hannover-282 km Berlin-Hannover-Dortmund-Koeln-573 km Berlin-Leipzig-179 km Berlin-Leipzig-Muenchen-604 km Berlin-Leipzig-Frankfurt-Stuttgart-791 km
Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante Kante
Minimale Spannbäume
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44:
Berlin->Dresden 205 Berlin->Hamburg 284 Berlin->Hannover 282 Berlin->Leipzig 179 Bremen->Dortmund 233 Bremen->Hamburg 119 Bremen->Hannover 125 Dortmund->Bremen 233 Dortmund->Duesseldorf 63 Dortmund->Frankfurt 264 Dortmund->Hannover 208 Dortmund->Koeln 83 Dresden->Berlin 205 Dresden->Leipzig 108 Duesseldorf->Dortmund 63 Duesseldorf->Koeln 47 Frankfurt->Dortmund 264 Frankfurt->Hannover 352 Frankfurt->Koeln 189 Frankfurt->Leipzig 395 Frankfurt->Muenchen 400 Frankfurt->Stuttgart 217 Hamburg->Berlin 284 Hamburg->Bremen 119 Hamburg->Hannover 154 Hannover->Berlin 282 Hannover->Bremen 125 Hannover->Dortmund 208 Hannover->Frankfurt 352 Hannover->Hamburg 154 Hannover->Leipzig 256 Koeln->Dortmund 83 Koeln->Duesseldorf 47 Koeln->Frankfurt 189 Leipzig->Berlin 179 Leipzig->Dresden 108 Leipzig->Frankfurt 395 Leipzig->Hannover 256 Leipzig->Muenchen 425 Muenchen->Frankfurt 400 Muenchen->Leipzig 425 Muenchen->Stuttgart 220 Stuttgart->Frankfurt 217 Stuttgart->Muenchen 220
Abbildung 17.37 Ergebnis des Algorithmus von Ford
17
17.15
Minimale Spannbäume
Im Folgenden betrachten wir ungerichtete, zusammenhängende, gewichtete Graphen mit nicht-negativen Kantengewichten. Unter einem Spannbaum verstehen wir einen Teilgraphen eines Graphen, der ein Baum (zusammenhängend und kreisfrei) ist und alle Knoten des Graphen enthält. Ein Graph hat in der Regel viele Spannbäume. Einen Spannbaum erhält man, wenn man aus dem Graphen so lange wie möglich Kanten entfernt, ohne den Zusammenhang zu zerstören. Ich habe das einmal mehr oder weniger willkürlich beim Standardbeispiel des Autobahnnetzes durchgeführt (siehe Abbildung 17.38). Das Beispiel zeigt einen Spannbaum, der eine Kantengewichtssumme von 2466 hat. Wir suchen jetzt unter allen möglichen Spannbäumen eines Graphen denjenigen mit der geringsten Kantengewichtssumme: Als minimalen Spannbaum eines Graphen bezeichnen wir den Spannbaum, der unter allen Spannbäumen die niedrigste Kantengewichtssumme hat.
551
17
Elemente der Graphentheorie
Hamburg
Bremen
284 125
Berlin Hannover
Dortmund 208
282
Leipzig
Düsseldorf 63
179 205
83 395
Köln
Dresden
Frankfurt
217 425 Stuttgart München
284 282 179 205 125 208 63 83 395 217 425 2466
Abbildung 17.38 Beispielhafte Spannbäume im Autobahnnetz
Es gibt zahlreiche Optimierungsfragen, zu deren Lösung ein minimaler Spannbaum konstruiert werden muss. Zum Beispiel könnte das kürzeste Glasfasernetz längs der Autobahn gesucht sein, das alle Großstädte Deutschlands miteinander verbindet. Gesucht ist ein Algorithmus, der den minimalen Spannbaum eines Graphen berechnet.
17.16
Der Algorithmus von Kruskal
Der Algorithmus von Kruskal dient dazu, den minimalen Spannbaum in einem Graphen zu ermitteln, und basiert auf der im Folgenden dargestellten Verfahrensidee.
552
17.16
Der Algorithmus von Kruskal
Ausgangspunkt für den Algorithmus ist eine Kantentabelle, in der die Kanten nach Kantenlänge sortiert sind. Wenn eine solche Tabelle nicht vorliegt, können Sie sie aus der Distanzenmatrix erzeugen und mit einem der bekannten Sortierverfahren sortieren. Aus dieser Tabelle berechnet der Algorithmus von Kruskal dann den minimalen Spannbaum: Minimaler Spannbaum
Graph
Sortierte Kantentabelle
A
Kante Kante Kante Kante Kante Kante Kante
1
6 B
5
C
9
2
3
D
8
E
1: A ↔ C 2: B ↔ E 3: C ↔ E 4: B ↔ C 5: A ↔ B 6: D ↔ E 7: B ↔ D
A
1 2 3 5 6 8 9
1 B
C 3
2 8
D
E
Abbildung 17.39 Minimaler Spannbaum nach dem Algorithmus von Kruskal
Das Verfahren läuft dann wie folgt ab: Bilde für jeden Knoten eine Menge, die nur diesen einzelnen Knoten enthält. Betrachte dann der Länge nach alle Kanten. Wenn Anfangs- und Endpunkt der Kante in verschiedenen Mengen liegen, dann nimm die Kante hinzu, und vereinige die beiden Mengen. Wenn alle Kanten betrachtet sind, ist der minimale Spannbaum fertig.
17
Abbildung 17.40 zeigt das Verfahren anhand des oben dargestellten Graphen:
1
6
1
6 5
C
B
5
C
9
2
3
9
2
3
D
8
E
D
Jeder Knoten liegt in einer eigenen Menge. Betrachte jetzt der Reihe nach alle Kanten.
1
6
B
8
A
A
A
A
E
Betrachte Kante 1, und vereinige die Mengen. Kante 1 gehört zum Spannbaum.
5
B
2
9 D
8
1
6 C 3 E
Betrachte Kante 2, und vereinige die Mengen. Kante 2 gehört zum Spannbaum.
5
B
2
9 D
8
A 1
6 C 3 E
Betrachte Kante 3, und vereinige die Mengen. Kante 3 gehört zum Spannbaum.
5
B 9 D
2 8
C 3 E
Kanten 4 und 5 bringen nichts. Betrachte Kante 6, und vereinige die Mengen. Kante 7 bringt nichts mehr.
Abbildung 17.40 Schema des Verfahrens nach Kruskal
553
17
Elemente der Graphentheorie
Die Implementierung des Verfahrens besteht eigentlich nur aus einer geschickten Assemblierung von Teilen, die wir anderweitig bereits erstellt haben. Zunächst brauchen wir aber wieder eine geeignete Datenstruktur. Zur Speicherung der Mengen verwenden wir wieder rückwärtsverkettete Baumstrukturen in einem Array. # define ANZAHL 12 int vorgaenger[ANZAHL];
Die im Laufe des Verfahrens ausgewählten Kantenindizes werden ebenfalls in einem Array festgehalten: # define ANZ_KANTEN 22 int ausgewaehlt[ANZ_KANTEN];
Als Datenstruktur für die Kantentabelle wird die folgende struct verwendet: struct kante { int distanz; int von; int nach; };
Eigentlich benötigt man für die Kantenauswahl die Kantenlänge (distanz) nicht. Wichtig ist nur, dass die Kanten, nach Länge sortiert, in einem Array (kantentabelle) vorliegen. In unserem konkreten Beispiel ist dieses Array wie folgt definiert (siehe Abbildung 17.41). Zur Initialisierung erhält jeder Knoten eine eigene Menge, indem er zur Wurzel (-1) eines rückwärts verketteten Baums gemacht wird. void init() { int i; for( i= 0; i < ANZAHL; i++) vorgaenger[i] = –1; } Listing 17.25 Initialisierung der Knoten
554
17.16
struct kante kantentabelle[ANZ_KANTEN] = { { 47, 4, 8}, { 63, 2, 4}, { 83, 2, 8}, { 108, 3, 9}, { 119, 1, 6}, { 125, 1, 7}, { 154, 6, 7}, { 179, 0, 9}, { 189, 5, 8}, { 205, 0, 3}, { 208, 2, 7}, { 217, 5,11}, { 220,10,11}, { 233, 1, 2}, { 256, 7, 9}, { 264, 2, 5}, { 282, 0, 7}, { 284, 0, 6}, { 352, 5, 7}, { 395, 5, 9}, { 400, 5,10}, { 425, 9,10}, };
Der Algorithmus von Kruskal
Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
47 Köln
83
Leipzig
352
108
264 395
189
179 205
Dresden
Frankfurt
217 400 Stuttgart
425
220 München
Abbildung 17.41 Das Array kantentabelle im Beispiel
Zur Vereinigung der zu den Knoten a und b gehörenden Mengen werden zunächst die Wurzeln zu a und b gesucht. Sind die Wurzeln gleich, dann sind die beiden Knoten schon in der gleichen Menge, und es muss nichts gemacht werden (return 0). Sind die Knoten ungleich, werden die Mengen vereinigt, indem die eine Wurzel (b) unter die andere (a) gebracht wird. In diesem Fall wird Erfolg zurückgemeldet (return 1). int join( int a, int b) { while( vorgaenger[a] != –1) a = vorgaenger[a]; while( vorgaenger[b] != –1) b = vorgaenger[b]; if( a == b) return 0; vorgaenger[b] = a; return 1; } Listing 17.26 Vereinigung der Knoten
555
17
17
Elemente der Graphentheorie
In der Funktion kruskal werden die Kanten der Reihe nach betrachtet, und Kanten, die zur Vereinigung von zwei Mengen führen, werden im Array ausgewaehlt markiert: void kruskal() { int kante; init(); for( kante = 0; kante < ANZ_KANTEN; kante++) ausgewaehlt[kante] = join( kantentabelle[kante].von , kantentabelle[kante].nach); } Listing 17.27 Implementierung des Algorithmus von Kruskal
Es fehlt noch eine Funktion, um die gewählten Kanten auszugeben: void ausgabe() { int kante; unsigned int summe; for( kante = 0, summe = 0; kante < ANZ_KANTEN; kante++) { if( ausgewaehlt[kante]) { summe += kantentabelle[kante].distanz; printf( "%4d %s-%s\n", kantentabelle[kante].distanz, stadt[kantentabelle[kante].von], stadt[kantentabelle[kante].nach]); } } printf( "----\n%4d\n", summe); } Listing 17.28 Ausgabe der Kanten
In dieser Funktion werden gleichzeitig die Gewichte der ausgewählten Kanten addiert, und die Kantengewichtssumme wird am Ende ausgegeben. In Abbildung 17.42 sehen Sie den berechneten minimalen Spannbaum:
556
17.17
Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
83
47 Köln
Hamiltonsche Wege
Leipzig
352
108
264 395
189
179 205
Dresden
Frankfurt
217 400 Stuttgart
425
void main() { kruskal(); ausgabe(); }
47 63 108 119 125 179 189 208 217 220 256 ---1731
Duesseldorf-Koeln Dortmund-Duesseldorf Dresden-Leipzig Bremen-Hamburg Bremen-Hannover Berlin-Leipzig Frankfurt-Koeln Dortmund-Hannover Frankfurt-Stuttgart Muenchen-Stuttgart Hannover-Leipzig
220 München
Abbildung 17.42 Ergebnis des Algorithmus von Ford
17
17.17
Hamiltonsche Wege
Im Jahre 1859 stellte der irische Mathematiker W. R. Hamilton eine Knobelaufgabe vor, bei der es darum ging, auf einem Dodekaeder2 eine »Reise um die Welt« zu machen.
Abbildung 17.43 Dodekaeder für die Reise um die Welt 2 Ein Dodekaeder ist ein Körper, dessen Oberfläche aus zwölf regelmäßigen Fünfecken besteht.
557
17
Elemente der Graphentheorie
Ausgehend von einem beliebigen Eckpunkt des Dodekaeders, sollte man, immer an den Kanten entlangfahrend, alle anderen Eckpunkte besuchen, um schließlich zum Ausgangspunkt zurückzukehren, ohne einen Eckpunkt zweimal besucht zu haben. Auf den ersten Blick ähnelt dieses Problem dem Königsberger Brückenproblem. Bei genauerem Hinsehen sind die beiden Probleme jedoch grundverschieden. Bei dem hamiltonschen Problem geht es darum, alle Knoten eines Graphen genau einmal zu besuchen, während es bei dem eulerschen Problem darum geht, alle Kanten eines Graphen genau einmal zu benutzen. Dieser Unterschied wirkt unbedeutend, doch erstaunlicherweise sind die Probleme von extrem verschiedener Berechnungskomplexität. Während sich das Problem des eulerschen Weges in einem Graphen in polynomialer Zeitkomplexität lösen lässt, sind für das Problem, den kürzesten hamiltonschen Weg zu finden, nur Algorithmen exponentieller Laufzeit bekannt. Wir definieren, was wir unter einem hamiltonschen Weg verstehen wollen: Ein Weg in einem ungerichteten Graphen heißt hamiltonscher Weg, wenn die folgenden drei Bedingungen erfüllt sind: 1. Der Weg ist geschlossen. 2. Alle Knoten des Weges, außer Anfangs- und Endpunkt, sind voneinander verschieden. 3. Jeder Knoten des Graphen kommt in dem Weg vor. Wenn wir einen hamiltonschen Weg in einem Graphen haben, dann muss der Weg genau so viele Kanten haben, wie der Graph Knoten hat, und in jedem Knoten des Graphen muss genau eine Kante des hamiltonschen Weges einlaufen und genau eine Kante auslaufen. Mit diesen Kriterien können wir erkennen, dass es im Allgemeinen keinen hamiltonschen Weg geben muss. In dem in Abbildung 17.44 dargestellten Graphen müsste man, um einen hamiltonschen Weg zu erhalten, genau eine Kante außer Betracht lassen. In jedem Fall gäbe es dann aber immer einen Knoten mit nur einer Kante.
Abbildung 17.44 Graph ohne hamiltonschen Weg
558
17.17
Hamiltonsche Wege
Im Falle des Dodekaeders gibt es aber viele hamiltonsche Wege. Um das zu erkennen, abstrahieren wir von der räumlichen Gestalt des Dodekaeders und modellieren ihn durch einen Graphen: 2
10 9 1
11 18
17
12
8 16
3
19
7
13 15 14
6 5 0
4
Abbildung 17.45 Der Dodekaeder als Graph
Ein hamiltonscher Weg ist eine Permutation der Knotenmenge, die zusätzlich die folgenden Bedingungen erfüllt: 1. Jeder Knoten, außer dem letzten, der Permutation muss mit seinem Nachfolger durch eine Kante verbunden sein. 2. Der letzte Knoten der Permutation muss mit dem ersten durch eine Kante verbunden sein. Um einen hamiltonschen Weg zu finden, können Sie alle Permutationen der Knotenmenge erzeugen und für jede Permutation anhand der oben genannten Bedingungen prüfen, ob sie einen hamiltonschen Weg beschreibt. Auf diese Weise erhalten Sie nicht nur einen, sondern alle hamiltonschen Wege. Permutationen können wir bereits erzeugen. Sie erinnern sich hoffentlich an das Programm perm aus Abschnitt 7.4, »Rekursion«. Dieses Programm können wir so modifizieren, dass es hamiltonsche Wege findet. Wir starten wieder mit der Adjazenzmatrix, die für den Dodekaeder recht verwirrend ist (siehe Abbildung 17.46). Wie schon angekündigt, werden die Permutationen mit einer Abwandlung des Programms perm erzeugt. Die Abwandlung besteht darin, dass beim Einfügen eines neuen Knotens in die im Aufbau befindliche Permutation immer geprüft wird, ob der Knoten mit seinem Vorgängerknoten verbunden werden kann. Nur wenn eine solche Verbindungsmöglichkeit besteht, wird mit der Erzeugung der Permutation fortgefahren.
559
17
17
Elemente der Graphentheorie
# define ANZAHL 20
2
10 9 1
11 18
17
12
8 16
19
7
13 15 14
6 5 0
4
unsigned int dodekaeder[ ANZAHL][ ANZAHL] = { {0,1,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0}, {1,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0}, {0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0}, {0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0}, {1,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0}, {0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0}, {1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0}, 3 {0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0}, {0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0}, {0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1}, {0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0}, {0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1}, {0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0}, {0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0}, {0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1}, {0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0} };
Abbildung 17.46 Adjazenzmatrix des Dodekaeders
Ist eine Permutation vollständig erzeugt, wird abschließend noch geprüft, ob es eine Kante vom letzten wieder zum ersten Knoten der Permutation gibt. Dies ist natürlich ein Brute-Force-Ansatz, bei dem mehr als 1017 Fälle überprüft werden müssen. Hier sehen Sie das Knotenpermutationsprogramm mit den zusätzlichen Prüfungen: void hamilton( int anz, int array[], int start) { int i, sav; A
B C D
E
560
if( start == anz) { if( dodekaeder[array[anz-1]][array[0]]) { for( i = 0; i < anz; i++) printf( "%d-", array[i]); printf( "%d\n", array[0]); } } else { sav = array[ start]; for( i = start; i < anz; i++) {
17.17
Hamiltonsche Wege
array[start] = array[i]; array[i] = sav; if( dodekaeder[array[start-1]][array[start]]) hamilton( anz, array, start + 1); array[i] = array[start]; } array[start] = sav; }
F
} Listing 17.29 Permutation der Knoten
Wenn eine neue Permutation erzeugt wurde (A), wird geprüft, ob der Endpunkt mit dem Anfangspunkt durch eine Kante verbunden ist. Wenn das der Fall ist, liegt ein hamiltonscher Weg vor (B). In diesem Fall wird der gefundene Weg ausgegeben (C und D). Andernfalls ist die Permutation noch nicht vollständig (E) und wird fortgesetzt. Nur wenn der betrachtete Knoten mit seinem Vorgänger verbunden werden kann, lohnt es sich, mit der Erzeugung der Permutation fortzufahren (F). Wird dieses Programm aus einem entsprechenden Hauptprogramm
A
void main() { int pfad[ANZAHL]; int i;
B
for( i = 0; i < ANZAHL; i++) pfad[i] = i;
C
hamilton(ANZAHL, pfad, 1); }
17
Listing 17.30 Hauptprogramm zum Aufruf von hamilton
gerufen, das ein Array für die Permutationen definiert (A) und initialisiert (B), findet es 60 verschiedene hamiltonsche Wege, 1: 0-1-2-3-4-14-13-12-11-10-9-8-7-16-17-18-19-15-5-6-0 2: 0-1-2-3-4-14-5-15-16-17-18-19-13-12-11-10-9-8-7-6-0 ... 59: 0-6-7-16-17-18-11-12-13-19-15-5-14-4-3-2-10-9-8-1-0 60: 0-6-7-16-17-18-19-15-5-14-13-12-11-10-9-8-1-2-3-4-0
von denen ich den ersten und den letzten hier grafisch dargestellt habe:
561
17
Elemente der Graphentheorie
2
2
10
10
9 1
11
9
18
17
12
8 16
3
1
12 16
13
13 15
14
14
6
5 0
3
19
7
15 6
18
17 8
19
7
11
5 4
0
4
Abbildung 17.47 Erster und letzter gefundener hamiltonscher Weg
17.18
Das Travelling-Salesman-Problem
Zum Abschluss dieses Kapitels wollen wir eines der am meisten untersuchten Probleme der Informatik diskutieren. Das Problem, einen möglichst kurzen hamiltonschen Weg in einem nicht negativ bewerteten Graphen zu finden, wird auch als das Problem des Handlungsreisenden (engl. Travelling Salesman Problem, kurz TSP) bezeichnet. Hinter der Bezeichnung Problem des Handlungsreisenden steht die folgende Veranschaulichung: Ein Handlungsreisender will alle seine Kunden besuchen. Er startet mit der Rundreise von seinem Büro und möchte am Ende der Rundreise wieder an seinem Schreibtisch sitzen. Unter allen möglichen Reiserouten möchte er natürlich die mit der kürzesten Gesamtstrecke wählen. Mit der Lösungsstrategie der »Reise um die Welt« können wir dieses Problem lösen, wenn wir zusätzlich die Weglängen berechnen und uns den jeweils kürzesten Weg speichern. Zusätzlich zur Distanzenmatrix (distanz) benötigen wir globale Variablen für die Länge der kürzesten Rundreise (mindist) und für ein Array (minpfad), in dem wir den Pfad der kürzesten Rundreise ablegen. Wir erzeugen, wie in der »Reise um die Welt«, alle möglichen Rundreisen im deutschen Autobahnnetz. Immer, wenn eine neue Rundreise erzeugt wurde, berechnen wir deren Länge. Wenn die Rundreise kürzer als die bisher kürzeste Rundreise ist, kopieren wir den Pfad der Rundreise in das Array minpfad um und erhalten eine neue minimale Distanz (mindist).
562
17.18
Das Travelling-Salesman-Problem
# define ANZAHL 12 # define xxx 10000 Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
47 Köln
83
Leipzig
352
108
264 395
189
Dresden
Frankfurt
217 400 Stuttgart
179 205
425
220 München
int distanz[ ANZAHL][ ANZAHL] = { { 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx}, {xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx}, {xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx}, {205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx}, {xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx}, {xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217}, {284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx}, {282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx}, {xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx}, {179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx}, {xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220}, {xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0}, }; int mindist = xxx; int minpfad[ANZAHL];
Abbildung 17.48 Erweiterungen zur Speicherung des minimalen Pfades
Listing 17.31 zeigt die notwendigen Erweiterungen: void hamilton( int anz, int array[], int start) { int i, sav; unsigned int d;
17
A B C D E
if( start == anz) { if( distanz[array[anz-1]][array[0]] < xxx) { for( i = 0, d = 0; i < anz; i++) d += distanz[array[i]][array[(i+1)%anz]]; if( d < mindist) { mindist = d; for( i = 0, d = 0; i < anz; i++) minpfad[i] = array[i]; } } } else { ... } }
Listing 17.31 Erweiterungen bei hamilton für das TSP
563
17
Elemente der Graphentheorie
In der geänderten Funktion hamilton wird jedes Mal, wenn eine neue Rundreise gefunden wurde (A), die Länge der entsprechenden Reise berechnet (B). Ist die neue Rundreise kürzer als die bisherige minimale Reise (C), wird der entsprechende Pfad als neuer minpfad gesichert (D). Das Programm findet sechs hamiltonsche Wege, von denen der im Folgenden dargestellte der kürzeste ist:
Hamburg
Bremen
119 125
284
154
Berlin
Hannover
282
233 Dortmund 208
256
Düsseldorf 63
83
47 Köln
Leipzig
352
Berlin-Dresden Dresden-Leipzig Leipzig-Muenchen Muenchen-Stuttgart Stuttgart-Frankfurt Frankfurt-Koeln Koeln-Duesseldorf Duesseldorf-Dortmund Dortmund-Hannover Hannover-Bremen Bremen-Hamburg Hamburg-Berlin
108
264 395
189
179 205
205 108 425 220 217 189 47 63 208 125 119 284 2210
Dresden
void main() { int pfad[ANZAHL]; int i;
Frankfurt
217 400 Stuttgart
425
220 München
char *stadt[ANZAHL] = { "Berlin", "Bremen", "Dortmund", "Dresden", "Duesseldorf", "Frankfurt", "Hamburg", "Hannover", "Koeln", "Leipzig", "Muenchen", "Stuttgart" };
for( i = 0; i < ANZAHL; i++) pfad[i] = i; hamilton(12, pfad, 1); for( i = 1; i b) max = a; else max = b;
den Operator für die bedingte Auswertung verwenden: max = a > b ? a : b;
Die allgemeine Form des Operators ist: test ? ausdruck1 : ausdruck2 Zur Berechnung des Ergebnisses wird zunächst der Ausdruck test ausgewertet. Ist dieser Ausdruck wahr (≠ 0), wird ausdruck1 ausgewertet und geht als Ergebnis in die weitere Verarbeitung ein. Ist der Ausdruck test falsch (= 0), wird der ausdruck2 ausgewertet und ist das Ergebnis. Beachten Sie, dass von den beiden Ausdrücken auf der
592
Bitfelder
rechten Seite ja nach Ausgang des Tests nur einer ausgewertet wird, was bei Seiteneffekten unter Umständen zu schwer verständlichem Code führen kann. In der Zuweisung max = a > b ? a++ : b++;
wird nur der größere der beiden Werte (bei Gleichheit b) nach der Zuweisung noch um 1 erhöht.
Bitfelder Bitfelder sind durch den Programmierer größenoptimierte Datenstrukturen. Manchmal legt man innerhalb von Datenstrukturen Felder an, die man in der vom System bereitgestellten Größe nicht benötigt. Wenn Sie z. B. nur eine Ja-/Nein-Information speichern möchten und dafür ein int-Feld anlegen, verbrauchen Sie 32 oder 64 Bit Speicher, obwohl Sie nur 1 Bit benötigen. Durch Verwendung von Bitfeldern können Sie Datenstrukturen mit Integer-Feldern auf eine geeignete Größe komprimieren. Als Beispiel betrachten wir die Datenstruktur für ein Kalenderdatum auf einem 32-Bit-System: struct datum { unsigned int tag; unsigned int monat; unsigned int jahr; };
18
Hier werden jeweils 32 Bit (= 4 Bytes) für Tag, Monat und Jahr reserviert. Das sind insgesamt 12 Bytes. Sie wissen aber, dass zur Speicherung der Tageszahl (1–31) 5 Bit ausreichend sind. Für den Monat (1–12) reichen sogar 4 Bit, und für das Jahr benötigen Sie maximal 11 Bit. Insgesamt wären also nur 20 Bit erforderlich, und Sie könnten die gesamte Information in einer 4-Byte-Integer-Zahl ablegen. Wenn Sie dem C-Compiler mitteilen, wie viele Bits Sie für die einzelnen Felder benötigen, kann er die Datenstruktur optimieren: struct datum { unsigned int tag : 5; unsigned int monat : 4;
593
18
Zusammenfassung und Ergänzung
unsigned int jahr : 11; };
Auf diese Weise können Sie bis auf ein einzelnes Bit heruntergehen. Sie könnten in der struct datum z. B. noch die Information, ob es sich um ein Schaltjahr handelt, hinzufügen, ohne dass sich der Speicherbedarf vergrößert, da Sie in der Datenstruktur (siehe Alignment) noch 12 Bit Reserve haben: struct datum { unsigned unsigned unsigned unsigned };
int int int int
tag : 5; monat : 4; jahr : 11; schaltjahr : 1;
Bitfelder können mit allen ganzzahligen Datentypen (vorrangig unsigned) genutzt werden. Bitfelder werden allerdings nicht sehr häufig verwendet, da der Hauptanwendungsbereich in der maschinennahen Programmierung liegt und man es dort bevorzugt, durch Verwendung von Bitoperationen die vollständige Kontrolle über die erzeugten Bitmuster zu haben.
Bitoperatoren (~, , &, ^, |) Bitoperationen dienen dazu, auf einzelne Bits eines Datums lesend oder schreibend zuzugreifen. Man kann Bits invertieren, mit »und«, »oder« bzw. »entweder oder« verknüpfen und nach links oder rechts schieben: Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
~
~x
bitweises Komplement
Bitoperator
R
14
x >> y
Bitshift rechts
&
x&y
bitweises Und
Bitoperator
L
8
^
x^y
bitweises Entweder-Oder
Bitoperator
L
7
|
x|y
bitweises Oder
Bitoperator
L
6
Tabelle 18.6 Bitoperatoren
594
Bitoperatoren (~, , &, ^, |)
Die Verknüpfungsoperationen führen eine Operation auf allen Bitstellen ihrer Operanden durch. Abbildung 18.13 zeigt dies am Beispiel des bitweisen Und auf einem 8-Bit-Datenwort:
Bitweises Und
x
1
1
0
0
0
0
1
0
y
1
0
0
1
1
0
1
1
x&y
1
0
0
0
0
0
1
0
0 und 1 ist 0.
Für jede Bitstelle wird eine eigene Und-Verknüpfung durchgeführt. Abbildung 18.13 Bitweises Und auf ein Datenwort
Insgesamt gibt es folgende Verknüpfungen (Abbildung 18.4): Bitweises Und
Bitweises Komplement
x
1
0
0
1
1
0
1
1
~x
0
1
1
0
0
1
0
0
Bitweises Oder
x
1
1
0
0
0
0
1
0
y
1
0
0
1
1
0
1
1
x&y
1
0
0
0
0
0
1
0
Bitweises Entweder-Oder
x
1
1
0
0
0
0
1
0
x
1
1
0
0
0
0
1
0
y
1
0
0
1
1
0
1
1
y
1
0
0
1
1
0
1
1
x|y
1
1
0
1
1
0
1
1
x^y
0
1
0
1
1
0
0
1
Abbildung 18.14 Bitweise Verknüpfungen in der Übersicht
Neben diesen bitweisen Verknüpfungen gibt es noch Schiebeoperationen. Bei diesen Operationen werden die Bits eines Datenworts nach links oder rechts verschoben: Bitshift rechts
x x>>2
Bitshift links
1
0
0
1
1
0
1
1
0
0
1
0
0
1
1
0
x x