Uploaded by Jannis Leitner

CC++ Das umfassende Lehrbuch by Kaiser Ulrich, Guddat Martin. (z-lib.org)

advertisement
Ulrich Kaiser, Martin Guddat
C/C++
Das umfassende Handbuch
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
almut.poll@galileo-press.de
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
Wir hoffen sehr, dass Ihnen dieses Buch gefallen hat. Bitte teilen Sie uns doch Ihre Meinung
mit. Eine E-Mail mit Ihrem Lob oder Tadel senden Sie direkt an die Lektorin des Buches:
almut.poll@galileo-press.de. Im Falle einer Reklamation steht Ihnen gerne unser Leserservice zur
Verfügung: service@galileo-press.de. Informationen über Rezensions- und
Schulungsexemplare erhalten Sie von: britta.behrens@galileo-press.de.
Informationen zum Verlag und weitere Kontaktmöglichkeiten finden Sie auf unserer Verlagswebsite www.galileo-press.de. Dort können Sie sich auch umfassend und aus erster Hand
über unser aktuelles Verlagsprogramm informieren und alle unsere Bücher versandkostenfrei
bestellen.
An diesem Buch haben viele mitgewirkt, insbesondere:
Lektorat Almut Poll, Erik Lipperts
Korrektorat Friederike Daenecke
Herstellung Martin Pätzold
Einbandgestaltung Janina Conrady
Typografie und Layout Vera Brauner
Satz Typographie & Computer, Krefeld
Druck und Bindung C. H. Beck, Nördlingen
Dieses Buch wurde gesetzt aus der TheAntiqua (9,35/13,7 pt) in FrameMaker.
Gedruckt wurde es auf chlorfrei gebleichtem Offsetpapier (70 g/m2).
Der Name Galileo Press geht auf den italienischen Mathematiker und Philosophen Galileo
Galilei (1564–1642) zurück. Er gilt als Gründungsfigur der neuzeitlichen Wissenschaft und
wurde berühmt als Verfechter des modernen, heliozentrischen Weltbilds. Legendär ist sein
Ausspruch Eppur si muove (Und sie bewegt sich doch). Das Emblem von Galileo Press ist der
Jupiter, umkreist von den vier Galileischen Monden. Galilei entdeckte die nach ihm benannten
Monde 1610.
Bibliografische Information der Deutschen Nationalbibliothek
Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar.
ISBN 978-3-8362-2757-5
© Galileo Press, Bonn 2014
5., aktualisierte und überarbeitete Auflage 2014
Das vorliegende Werk ist in all seinen Teilen urheberrechtlich geschützt. Alle Rechte
vorbehalten, insbesondere das Recht der Übersetzung, des Vortrags, der Reproduktion,
der Vervielfältigung auf fotomechanischem oder anderen Wegen und der Speicherung in
elektronischen Medien.
Ungeachtet der Sorgfalt, die auf die Erstellung von Text, Abbildungen und Programmen
verwendet wurde, können weder Verlag noch Autor, Herausgeber oder Übersetzer für mögliche
Fehler und deren Folgen eine juristische Verantwortung oder irgendeine Haftung übernehmen.
Die in diesem Werk wiedergegebenen Gebrauchsnamen, Handelsnamen, Warenbezeichnungen
usw. können auch ohne besondere Kennzeichnung Marken sein und als solche den gesetzlichen
Bestimmungen unterliegen.
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 <<-Operators ..................................................................
Tastatureingabe ......................................................................................
Dateioperationen ....................................................................................
748
749
750
752
Der this-Pointer .....................................................................................................
755
Beispiele ..................................................................................................................
Menge .......................................................................................................
756
756
20.10 Aufgaben ................................................................................................................
771
21 Das Zusammenspiel von Objekten
775
20.7
Ein- und Ausgabe in C++ .....................................................................................
20.7.1
20.7.2
20.7.3
20.8
20.9
20.9.1
21.1
Modellierung von Beziehungen ........................................................................
775
21.2
Komposition eigener Objekte ............................................................................
776
13
Inhalt
Komposition in C++ .................................................................................
Implementierung der print-Methode für timestamp .........................
Der Konstruktor von timestamp ............................................................
Parametrierter Konstruktor einer komponierten Klasse ....................
Konstruktionsoptionen der Klasse timestamp ....................................
779
780
781
783
785
Eine Klasse text .....................................................................................................
786
788
790
791
793
794
796
21.2.1
21.2.2
21.2.3
21.2.4
21.2.5
21.3
21.3.1
21.3.2
21.3.3
21.3.4
21.3.5
21.3.6
21.4
Übungen/Beispiel .................................................................................................
Bingo .........................................................................................................
797
797
Aufgabe ..................................................................................................................
803
21.4.1
21.5
Der Copy-Konstruktor .............................................................................
Implementierung eines Copy-Konstruktors .........................................
Zuweisung von Objekten ........................................................................
Implementierung des Zuweisungsoperators .......................................
Erweiterung der Klasse text ...................................................................
Vorgehen für eigene Objekte .................................................................
22 Vererbung
22.1
Darstellung der Vererbung .................................................................................
22.1.1
22.1.2
22.1.3
22.2
22.3
Vererbung in C++ ..................................................................................................
22.2.1
22.2.2
22.2.3
22.2.4
22.2.5
22.2.6
Ableiten einer Klasse ...............................................................................
Gezieltes Aufrufen des Konstruktors der Basisklasse .........................
Der geschützte Zugriffsbereich einer Klasse ........................................
Erweiterung abgeleiteter Klassen .........................................................
Überschreiben von Funktionen der Basisklasse ...................................
Unterschiedliche Instanziierungen und deren Verwendung .............
22.2.7
22.2.8
22.2.9
22.2.10
22.2.11
22.2.12
Virtuelle Memberfunktionen .................................................................
Verwendung des Schlüsselwortes virtual ............................................
Mehrfachvererbung ................................................................................
Zugriff auf die Methoden der Basisklassen ..........................................
Statische Member ...................................................................................
Rein virtuelle Funktionen .......................................................................
Beispiele ..................................................................................................................
22.3.1
22.3.2
14
Mehrere abgeleitete Klassen .................................................................
Wiederholte Vererbung ..........................................................................
Mehrfachvererbung ................................................................................
Würfelspiel ...............................................................................................
Partnervermittlung .................................................................................
805
805
806
807
807
808
809
810
812
813
814
817
820
821
822
824
826
829
831
831
855
Inhalt
23 Zusammenfassung und Überblick
879
23.1
Klassen und Instanzen .........................................................................................
879
23.2
Member ..................................................................................................................
881
881
882
885
887
889
891
891
894
898
899
23.2.1
23.2.2
23.2.3
23.2.4
23.2.5
23.2.6
23.2.7
23.2.8
23.2.9
Datenmember .........................................................................................
Funktionsmember ...................................................................................
Konstante Member .................................................................................
Statische Member ...................................................................................
Operatoren ...............................................................................................
Zugriff auf Member .................................................................................
Zugriff von außen ....................................................................................
Zugriff von innen .....................................................................................
Der this-Pointer .......................................................................................
23.2.10 Zugriff durch friends ...............................................................................
23.3
Vererbung ...............................................................................................................
23.3.1
23.3.2
23.3.3
23.3.4
23.3.5
23.4
Konstruktion von Objekten ....................................................................
Destruktion von Objekten ......................................................................
Kopieren von Objekten ...........................................................................
Instanziierung von Objekten ..................................................................
Explizite und implizite Verwendung von Konstruktoren ....................
Initialisierung eingelagerter Objekte ....................................................
Initialisierung von Basisklassen .............................................................
Instanziierungsregeln .............................................................................
922
925
928
929
934
937
939
941
943
Typüberprüfung und Typumwandlung ...........................................................
23.6.1
23.7
916
Geschützte Member ................................................................................ 917
Zugriff auf die Basisklasse ...................................................................... 917
Modifikation von Zugriffsrechten ......................................................... 921
Der Lebenszyklus von Objekten ........................................................................
23.5.1
23.5.2
23.5.3
23.5.4
23.5.5
23.5.6
23.5.7
23.5.8
23.6
900
900
905
911
914
915
Zugriffsschutz und Vererbung ...........................................................................
23.4.1
23.4.2
23.4.3
23.5
Einfachvererbung ....................................................................................
Mehrfachvererbung ................................................................................
Virtuelle Funktionen ...............................................................................
Virtuelle Destruktoren ............................................................................
Rein virtuelle Funktionen .......................................................................
946
Dynamische Typüberprüfungen ............................................................ 946
Typumwandlung in C++ ......................................................................................
948
15
Inhalt
24 Die C++-Standardbibliothek und Ergänzung
953
24.1
Generische Klassen (Templates) ........................................................................
954
24.2
Ausnahmebehandlung (Exceptions) ................................................................
962
24.3
Die C++-Standardbibliothek ...............................................................................
973
24.4
Iteratoren ...............................................................................................................
973
24.5
Strings (string) .......................................................................................................
24.5.1
24.5.2
24.5.3
24.5.4
24.5.5
24.5.6
24.6
Dynamische Arrays (vector) ................................................................................
Die Beispielklasse klasse .........................................................................
Einbinden dynamischer Arrays ..............................................................
Konstruktion ............................................................................................
Zugriff .......................................................................................................
Iteratoren .................................................................................................
Manipulation ...........................................................................................
Speichermanagement ............................................................................
990
990
991
991
992
993
994
998
Listen (list) ..............................................................................................................
998
24.6.1
24.6.2
24.6.3
24.6.4
24.6.5
24.6.6
24.6.7
24.7
976
977
978
981
986
987
Speichermanagement ............................................................................ 988
Ein- und Ausgabe .....................................................................................
Zugriff .......................................................................................................
Manipulation ...........................................................................................
Vergleich ...................................................................................................
Suchen ......................................................................................................
24.7.1
24.7.2
24.7.3
24.7.4
24.7.5
Konstruktion ............................................................................................ 998
Zugriff ....................................................................................................... 999
Iteratoren ................................................................................................. 1000
Manipulation ........................................................................................... 1002
Speichermanagement ............................................................................ 1014
24.8
Stacks (stack) ......................................................................................................... 1014
24.9
Warteschlangen (queue) ..................................................................................... 1017
24.10 Prioritätswarteschlangen (priority_queue) .................................................... 1019
24.11 Geordnete Paare (pair) ........................................................................................ 1024
24.12 Mengen (set und multiset) ................................................................................. 1025
24.12.1 Konstruktion ............................................................................................ 1026
24.12.2 Zugriff ....................................................................................................... 1027
24.12.3 Manipulation ........................................................................................... 1029
24.13 Relationen (map und multimap) ....................................................................... 1030
24.13.1 Konstruktion ............................................................................................ 1030
16
Inhalt
24.13.2 Zugriff ....................................................................................................... 1031
24.13.3 Manipulation ........................................................................................... 1032
24.14 Algorithmen der Standardbibliothek ............................................................... 1032
24.14.1 Vererbung und virtuelle Funktionen in Containern ............................ 1037
A Aufgaben und Lösungen
1041
Kapitel 1 .................................................................................................................. 1042
Kapitel 3 .................................................................................................................. 1055
Kapitel 4 .................................................................................................................. 1069
Kapitel 5 .................................................................................................................. 1090
Kapitel 6 .................................................................................................................. 1103
Kapitel 7 .................................................................................................................. 1120
Kapitel 8 .................................................................................................................. 1144
Kapitel 10 ................................................................................................................ 1155
Kapitel 14 ................................................................................................................ 1162
Kapitel 20 ............................................................................................................... 1186
Kapitel 21 ................................................................................................................ 1203
Index ........................................................................................................................................................ 1209
17
Vorwort
Als die erste Auflage dieses Buches erschien, war Roman Herzog Präsident der Bundesrepublik Deutschland. Auf Herzog folgten Rau, Köhler, Wulff und Gauck und
jeweils eine neue Auflage dieses Buches. Jetzt liegt die fünfte, vollständig überarbeitete Auflage vor. Der Leitgedanke des Buches ist aber über all die Jahre gleich geblieben. Dazu möchte ich aus dem Vorwort der ersten Auflage zitieren:
Ziel des Buches ist es, Leser ohne Vorkenntnisse auf ein professionelles Niveau
der C- und C++-Programmierung zu führen. Unter »Programmierung« wird dabei
weitaus mehr verstanden als die Beherrschung einer Programmiersprache. So
wie »Schreiben« mehr ist, als Wörter unter Beachtung der Regeln von Rechtschreibung, Zeichensetzung und Grammatik zu Sätzen zusammenzufügen, ist
Programmieren mehr als die Erstellung formal korrekter Programme. Zum Programmieren gehört ein Überblick über die Grundlagen und die Anwendungen
der Programmierung. Der Leitgedanke dieses Buches ist es, wichtige Grundlagen
und Konzepte der Informatik darzustellen und unmittelbar mit der Programmierung zu verknüpfen. Die Grundlagen liefern dann die Ideen zur Programmierung,
und die Programmierung liefert die Motivation für die Beschäftigung mit den
Grundlagen.
Dem ist auch heute nichts hinzuzufügen.
Es freut mich, dass ich mit Martin Guddat einen Kollegen gefunden habe, der die
Arbeit am Buch für die Amtsperioden der nächsten fünf Bundespräsidenten fortsetzen wird. Dazu wünsche ich ihm viel Erfolg.
Bocholt, im September 2014
Ulrich Kaiser
Ich verwende das Buch des Kollegen Kaiser seit mehreren Jahren in meinen eigenen
Vorlesungen und empfehle es immer wieder gerne als umfassendes und konsistentes Werk, das eine breite Basis für die Programmierung legt. Umso mehr freut es
mich, dass ich die Gelegenheit bekomme, das Buch in den kommenden Jahren weiterzuführen, zu pflegen und an neue Entwicklungen anzupassen.
19
Vorwort
Dafür möchte ich Ulrich Kaiser meinen besonderen Dank aussprechen und hoffe,
dass ich seiner riesigen Vorarbeit gerecht werde.
Für die Durchsicht des gesamten Manuskripts, für seine Anmerkungen und seine
Änderungsvorschläge danken wir beide besonders Herrn Daniel Hacirisoglu!
Bocholt, im September 2014
Martin Guddat
20
Kapitel 1
Einige Grundbegriffe
1
Computer Science is no more about computers than astronomy is
about telescopes.
– Edsger W. Dijkstra
Womit beschäftigen wir uns in diesem Buch? Mit Informatik? Mit Programmierung?
Mit der Programmiersprache C/C++? Mit Computern? Alles scheint miteinander verwoben. Und dann sagt auch noch einer der bedeutendsten Informatiker und Pioniere
der Programmierung:
Computerwissenschaft hat mit Computern genauso viel zu tun wie Astronomie mit Teleskopen.
Sie sind vielleicht über das Interesse an Technik zu Computern und über das Interesse an Computern zu Programmiersprachen gekommen. Wir möchten mit Ihnen
diesen Weg weitergehen und Sie über das Interesse an Programmiersprachen zur
Programmierung und über das Interesse an der Programmierung zur Informatik
führen. Ein weiter Weg, der merkwürdigerweise mit einem Kochrezept beginnt.
Im Internet habe ich das folgende Rezept zur Herstellung eines Pfannkuchens gefunden:
Zutaten:
50 g Butter oder Magarine
100 g Zucker
1 Pck. Vanillezucker
4 Eier
200 ml Milch
200 g Mehl
1 TL Backpulver
etwas Butter zum Ausbacken
Zubereitung:
Butter mit Zucker und Vanillezucker vermengen (dazu evtl. in der
Mikrowelle weich werden lassen). Die Eigelbe hinzufügen und
schaumig rühren, dann die Milch zugeben und unterrühren. Mehl
mit Backpulver über die Masse sieben und glatt rühren. Eiweiße steif
schlagen und zum Schluss unterheben.
Eine Pfanne bei mittlerer Hitze heiß werden lassen. Portionsweise
aus dem Teig nun Pfannkuchen in wenig Butter von beiden Seiten
braten, bis sie goldgelb sind.
Abbildung 1.1 Ein Pfannkuchenrezept
21
1
Einige Grundbegriffe
Das Rezept gliedert sich in zwei Teile. Im ersten Teil werden die erforderlichen Zutaten
genannt, und im zweiten Teil wird die Zubereitung beschrieben. Die beiden Teile sind
wesentlich verschieden und gehören doch untrennbar zusammen. Ohne Zutaten ist
die Zubereitung nicht möglich, und ohne Zubereitung bleiben die Zutaten ungenießbar. Außerdem sehen Sie, dass sich der Autor bei der Formulierung des Rezepts einer
bestimmten Fachsprache (schaumig rühren, steif schlagen, unterheben) bedient. Ohne
diese Fachsprache wäre die Anleitung wahrscheinlich weitschweifiger, umständlicher
und vielleicht sogar missverständlich. Die Verwendung einer Fachsprache setzt allerdings voraus, dass sich Autor und Leser des Rezepts zuvor (ausgesprochen oder unausgesprochen) auf eine gemeinsame Terminologie verständigt haben.
Wir übertragen dieses Beispiel in unsere Welt – die Welt der Datenverarbeitung:
왘
Die Zutaten für das Rezept sind die Daten bzw. Datenstrukturen, die wir verarbeiten wollen.
왘
Die Zubereitungsvorschrift ist ein Algorithmus1, der festlegt, wie die Daten verarbeitet werden sollen.
왘
Das Rezept insgesamt ist ein Programm, das alle Datenstrukturen (Zutaten) und
Algorithmen (Zubereitungsvorschriften) zum Lösen der gestellten Aufgabe enthält.
왘
Die gemeinsame Terminologie, in der sich Autor und Leser des Rezepts verständigen, ist die Programmiersprache, in der das Programm geschrieben ist. Die Programmiersprache muss dabei alle im Hinblick auf die Zutaten und die
Zubereitung bedeutsamen Informationen zweifelsfrei zu übermitteln.
왘
Die Küche ist die technische Infrastruktur zur Umsetzung von Rezepten in
schmackhafte Gerichte und ist vergleichbar mit einem Computer, seinem
Betriebssystem und den benötigten Entwicklungswerkzeugen.
왘
Der Koch übersetzt das Rezept in einzelne Arbeitsschritte in der Küche. Üblicherweise geht ein Koch in zwei Schritten vor. Im ersten Schritt bereitet er die Zutaten
einzeln und unabhängig voneinander vor (z. B. Kartoffeln kochen), um die Einzelteile dann in einem zweiten Schritt zusammenzufügen und abzuschmecken. In
der Datenverarbeitung sprechen wir in diesem Zusammenhang von Compiler und
Linker.
왘
Das fertige Gericht ist das lauffähige Programm, das vom Anwender (Esser)
genutzt (verzehrt) werden kann.
Nur, welche Rolle spielen wir in diesem Szenario? Sollte für uns kein Platz vorgesehen
sein? Nein, wir suchen uns die interessanteste Aufgabe aus:
1 Dieser Begriff geht zurück auf Abu Jafar Muhammad Ibn Musa Al-Khwarizmi, der als Bibliothekar
des Kalifen von Bagdad um 825 ein Rechenbuch verfasste und dessen Name in der lateinischen
Übersetzung von 1200 als »Algorithmus« angegeben wurde.
22
왘
Wir sind Autoren, die sich neue, schmackhafte Gerichte für unterschiedliche
Anlässe ausdenken und Rezepte bzw. Kochbücher mit den besten Kreationen veröffentlichen.
Was müssen wir lernen, um unsere Rolle ausfüllen zu können?
왘
Wir müssen die Sprache beherrschen, in der Rezepte formuliert werden.
왘
Wir müssen einen Überblick über die üblicherweise verwendeten Zutaten, deren
Eigenschaften und Zubereitungsmöglichkeiten haben.
왘
Wir müssen einen Vorrat an Zubereitungsverfahren bzw. kompletten Rezepten
abrufbereit im Kopf haben.
왘
Wir müssen wissen, welche Zutaten oder Verfahren miteinander harmonieren
und welche nicht.
왘
Wir müssen wissen, was in einer Küche üblicherweise an Hilfsmitteln vorhanden
ist und wie bzw. wozu diese Hilfsmittel verwendet werden.
왘
Bei anspruchsvolleren Gerichten müssen wir wissen, in welcher Reihenfolge und
mit welchem Timing die Einzelteile zuzubereiten sind und wie die einzelnen Aufgaben verteilt werden müssen, damit alles zeitgleich serviert werden kann.
왘
Wir müssen auch wissen, worauf ein potenzieller, späterer Esser Wert legt und
worauf nicht. Dies ist besonders wichtig, wenn wir Rezepte für einen ganz besonderen Anlass erstellen.
Letztlich möchten wir komplette Festmenüs und deren Speisefolge komponieren
und benötigen dazu eine Mischung aus Phantasie, Kreativität, logischer Strenge, Ausdauer und Fleiß, wie sie auch ein guter Koch, Komponist oder Architekt benötigt.
Zurück zu den Grundbegriffen der Informatik. Wir haben informell folgende Begriffe
eingeführt:
왘
Datenstruktur
왘
Algorithmus
왘
Programm
Dabei haben Sie bereits erkannt, dass diese Begriffe untrennbar zusammengehören
und eigentlich nur unterschiedliche Facetten ein und desselben Themenkomplexes
sind.
왘
Algorithmen arbeiten auf Datenstrukturen. Algorithmen ohne Datenstrukturen
sind leere Formalismen.
왘
Datenstrukturen benötigen Algorithmen, die auf ihnen operieren und sie damit
zum »Leben« erwecken.
왘
Programme realisieren Datenstrukturen und Algorithmen. Datenstrukturen und
Algorithmen sind zwar ohne Programme denkbar, aber viele Datenstrukturen
23
1
1
Einige Grundbegriffe
und Algorithmen wären ohne Programmierung allenfalls von akademischem
Interesse.
In einem ersten Wurf versuchen wir, die Begriffe Algorithmus, Datenstruktur und
Programm einigermaßen exakt zu erfassen.
1.1
Algorithmus
Um unsere noch sehr vage Vorstellung von einem Algorithmus zu präzisieren, starten wir mit einer Definition:
Was ist ein Algorithmus?
Ein Algorithmus ist eine endliche Menge genau beschriebener Anweisungen, die
unter Verwendung vorgegebener Anfangsdaten in einer genau festgelegten Reihenfolge ausgeführt werden müssen, um die Lösung eines Problems in endlich vielen Schritten zu ermitteln.
Bei dem Begriff »Algorithmus« denkt man heute sofort an »Programmierung«. Das
war nicht immer so. In der Tat gab es Algorithmen schon lange, bevor man auch nur
entfernt an Programmierung dachte. Bereits im antiken Griechenland wurden Algorithmen zur Lösung mathematischer Probleme formuliert, so z. B. der euklidische
Algorithmus zur Bestimmung des größten gemeinsamen Teilers zweier Zahlen oder
das sogenannte Sieb des Eratosthenes zur Bestimmung aller Primzahlen unterhalb
einer vorgegebenen Schranke.2
Sie kennen den Algorithmus zur schrittweisen Berechnung des Quotienten zweier
Zahlen. Um etwa 84 durch 16 zu dividieren, gehen Sie nach einem Schema vor, das Sie
bereits in der Schule gelernt haben:
84 ÷ 16 = 5.25
80
40
32
80
80
0
Abbildung 1.2 Die schriftliche Division
2 Euklid von Alexandria (um 300 v. Chr.) und Eratosthenes von Kyrene (um 200 v. Chr.)
24
1.1
Algorithmus
Dieses Schema ist aber keine ausreichend präzise Verfahrensbeschreibung. Das Verfahren sollte so beschrieben werden, dass es jemand quasi mechanisch ohne fremde
Hilfe anwenden kann. Sie erinnern sich noch an die Definition von Algorithmus.
Dort hatten wir im Zusammenhang mit einem Algorithmus die Begriffe Problem,
Anfangsdaten und Anweisungen verwendet. Als Erstes müssen Sie das Problem und
die Anfangsdaten identifizieren. Danach können Sie sich Gedanken über Anweisungen zur Lösung des Problems machen:
Problem:
Berechne den Quotienten zweier natürlicher Zahlen!
Anfangsdaten:
z = Zähler (z ⱖ 0)
n = Nenner (n > 0)
a = Anzahl der zu berechnenden Nachkommastellen3
Anweisungen:
1. Bestimme die größte ganze Zahl x mit nx ⱕ z! Dies ist der Vorkomma-Anteil der
gesuchten Zahl.
2. Zur Bestimmung der Nachkommastellen fahre wie folgt fort:
2.1 Sind noch Nachkommastellen zu berechnen (d. h. a > 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 <stdio.h>
# include <stdlib.h>
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 <stdio.h>
# include <stdlib.h>
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 <stdio.h>
# include <stdlib.h>
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 <stdio.h>
# include <stdlib.h>
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
<
x<y
kleiner
<=
x <= y
kleiner oder gleich
>
x>y
größer
>=
x >= 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 <= 2*(b+1)
a+1 == a*a
Das Ergebnis eines Vergleichs ist ein logischer Wert (»wahr« oder »falsch«), der in C
durch 1 (wahr) oder 0 (falsch) dargestellt wird. Mit diesem Wert können Sie dann, wie
mit einem durch einen arithmetischen Ausdruck gewonnenen Wert, weiterarbeiten.
Vergleiche stellt man allerdings üblicherweise nicht an, um mit dem Vergleichsergebnis zu rechnen, sondern um anschließend im Programm zu verzweigen. Man
möchte erreichen, dass das Programm in Abhängigkeit vom Ergebnis des Vergleichs
unterschiedlich fortfährt. Wie Sie das erreichen, erfahren Sie im nächsten Abschnitt
über den »Kontrollfluss«.
3.5
Kontrollfluss
Bei einem Programm kommt es ganz entscheidend darauf an, in welcher Reihenfolge
die einzelnen Anweisungen ausgeführt werden. Üblicherweise werden Anweisungen
in der Reihenfolge ihres Vorkommens im Programm ausgeführt. Sie haben aber im
Eingangsbeispiel (Divisionsalgorithmus aus der Schule) bereits gesehen, dass es
erforderlich ist, Fallunterscheidungen und gezielte Wiederholungen von Anweisungsfolgen zu ermöglichen.
56
3.5
3.5.1
Kontrollfluss
Bedingte Befehlsausführung
Die bedingte Ausführung einer Anweisungsfolge realisieren wir in C durch eine ifAnweisung, die die folgende Struktur hat:
3
Hier steht eine Bedingung
(zumeist ein Vergleichsausdruck).
Handelt es sich hier um eine
einzelne Anweisung, können
die geschweiften Klammern
weggelassen werden.
if ( ... )
{
...
...
...
}
Die hier stehenden Anweisungen
werden ausgeführt, wenn die
Auswertung der Bedingung
einen Wert ≠ 0 ergibt.
Abbildung 3.3 Bedingte Befehlsausführung
Zum besseren Verständnis betrachten wir einige einfache Beispiele.
Das folgende Codefragment berechnet den Absolutbetrag einer Variablen a:
if( a < 0)
a = -a;
Wenn der Wert von a kleiner als der Wert von b ist, dann tausche die Werte von a
und b:
if( a < b)
{
c = a;
a = b;
b = c;
}
Weise der Variablen max den größeren der Werte von a und b zu:
max = a;
if( a < b)
max = b;
Mit else können wir einem if-Ausdruck Anweisungen hinzufügen, die ausgeführt
werden sollen, wenn die if-Bedingung nicht zutrifft. Von der Struktur her sieht das
vollständige if-Statement dann wie folgt aus:
57
3
Ausgewählte Sprachelemente von C
Hier steht eine Bedingung
(zumeist ein Vergleichsausdruck).
Handelt es sich hier um eine einzelne
Anweisung, können die geschweiften
Klammern weggelassen werden.
Dieser Teil kann vollständig fehlen.
Handelt es sich hier um eine einzelne
Anweisung, können die geschweiften
Klammern weggelassen werden.
if ( ... )
{
...
...
...
}
else
{
...
...
...
}
Die hier stehenden Anweisungen
werden ausgeführt, wenn die
Bedingung erfüllt ist.
Die hier stehenden Anweisungen
werden ausgeführt, wenn die
Bedingung nicht erfüllt ist.
Abbildung 3.4 Die vollständige if-Anweisung
Auch dazu betrachten wir einige einfache Beispiele.
Berechne das Maximum zweier Zahlen a und b:
if( a < b)
max = b;
else
max = a;
Berechne den Abstand von a und b:
if( a < b)
abst = b – a;
else
abst = a – b;
Die Prüfung, ob eine Bedingung erfüllt ist, ist letztlich eine Prüfung auf gleich oder
ungleich 0. Das heißt, wenn Sie eine Variable auf gleich oder ungleich 0 testen möchten, können Sie die Bedingung vereinfachen:
if ( a != 0)
{
...
}
kann auch so ausgedrückt werden:
58
3.5
Kontrollfluss
if ( a)
{
...
}
3
Andersherum kann
if ( a == 0)
{
...
}
auch so dargestellt werden:
if ( !a)
{
...
}
Da der C-Programmierer mit Buchstaben im Quellcode geizt, favorisiert er zumeist
die kürzere Formulierung, aber bleiben Sie zunächst ruhig bei der längeren, wenn Sie
den Code dann besser lesen können.
Bei der Verwendung von if sollten Sie beachten, dass ein Vergleich auf Gleichheit
mit dem doppelten Gleichheitszeichen durchgeführt wird. Das einfache Gleichheitszeichen bedeutet eine Zuweisung. Die Verwechslung des Vergleichs auf Gleichheit
mit der Zuweisungsoperation ist einer der »beliebtesten« Anfängerfehler in C. Im folgenden Codefragment
if( a = 1)
b = 5;
wird zunächst der Variablen a der Wert 1 zugewiesen. Das Ergebnis dieser Zuweisung
ist 1, sodass die nachfolgende Zeile (b = 5) immer ausgeführt wird. Sagen Sie daher im
Beispiel oben nicht »if a gleich 1«, sondern »if a ist gleich 1«, dann kann Ihnen der Fehler nicht so leicht unterlaufen.
3.5.2
Wiederholte Befehlsausführung
Am Beispiel der Division aus dem ersten Kapitel haben Sie gesehen, dass es erforderlich sein kann, in einem Algorithmus eine bestimmte Folge von Anweisungen wiederholt zu durchlaufen, bis eine bestimmte Situation eingetreten ist. Wir nennen
dies eine Programmschleife. Versucht man, die Anatomie von Schleifen allgemein zu
beschreiben, stößt man auf ein immer wiederkehrendes Muster:
59
3
Ausgewählte Sprachelemente von C
왘
Es gibt eine Reihe von Dingen, die zu tun sind, bevor man mit der Durchführung
der Schleife beginnen kann. Wir nennen dies die Initialisierung der Schleife.
왘
Zunächst muss eine Prüfung durchgeführt werden, ob die Bearbeitung der
Schleife abgebrochen oder fortgesetzt werden soll. Wir nennen dies den Test auf
Fortsetzung der Schleife.
왘
Bei jedem Schleifendurchlauf werden die eigentlichen Tätigkeiten durchgeführt.
Wir nennen dies den Schleifenkörper.
왘
Nach dem Ende eines einzelnen Schleifendurchlaufs müssen gewisse Operationen
durchgeführt werden, um den nächsten Schleifendurchlauf vorzubereiten. Wir
nennen dies das Inkrement der Schleife.
Machen Sie sich dies am Beispiel einer Routineaufgabe klar:
Sie haben Ihre Post erledigt und wollen die Briefe frankieren, bevor Sie sie zum Briefkasten bringen. Dazu stellen Sie zunächst die erforderlichen Hilfsmittel bereit. Sie
besorgen sich einen Bogen mit Briefmarken und legen den Stapel der unfrankierten
Briefe vor sich auf den Schreibtisch. Das ist die Initialisierung. Bevor Sie nun fortfahren,
prüfen Sie, ob der Stapel der Ausgangspost noch nicht abgearbeitet ist. Das ist der Test
auf Fortsetzung. Liegen noch Briefe vor Ihnen, treten Sie in den eigentlichen Arbeitsprozess ein. Sie trennen eine Briefmarke ab, befeuchten sie auf der Rückseite und kleben sie auf den obersten Brief des Stapels. Das ist der Schleifenkörper. Nachdem Sie
einen Brief frankiert haben, nehmen Sie ihn und legen ihn in den Postausgangskorb.
Das ist das Inkrement, mit dem Sie den nächsten Frankiervorgang vorbereiten. Danach
setzen Sie die Arbeit mit dem Test fort. Wir fassen Initialisierung, Test und Inkrement
unter dem Begriff Schleifenkopf zusammen und zeichnen ein Flussdiagramm:
Initialisierung, Test und Inkrement
nennen wir den Schleifenkopf.
Dies ist die Initialisierug.
Sie wird nur einmal ausgeführt.
Briefe und Marken
bereitstellen
Bearbeiteten Brief in
Postausgang legen
Dies ist das Inkrement.
Es wird nach jedem
Arbeitsschritt durchgeführt.
Dies ist der Test.
Er wird immer vor dem nächsten
Arbeitsschritt durchgeführt.
Sind
noch Briefe
unbearbeitet?
ja
Dies ist der Schleifenkörper,
in dem immer ein Arbeitsschritt
durchgeführt wird.
Brief nehmen
Marke abtrennen
Marke befeuchten
Marke aufkleben
Abbildung 3.5 Flussdiagramm »Briefe frankieren«
60
nein
3.5
Kontrollfluss
In C gibt es ein Sprachelement, das das hier diskutierte Schleifenmuster exakt abbildet. Es handelt sich um die for-Anweisung, die sich aus Schleifenkopf und Schleifenkörper zusammensetzt. Der Schleifenkopf enthält drei durch Semikolon getrennte
Ausdrücke, die die Abarbeitung der Schleife steuern:
3
Der Test wird vor jedem möglichen Eintritt in den
Schleifenkörper ausgewertet. Ergibt sich dabei ein Wert
≠ 0, wird der Schleifenkörper ausgeführt. Andernfalls
wird die Bearbeitung der Schleife abgebrochen.
Die Initialisierung wird vor dem
ersten Eintritt in die Schleife
einmal ausgeführt.
for ( ... ; ... ; ... )
Initialisierung, Test und
Inkrement bezeichnen wir
als den Schleifenkopf.
Der Schleifenkörper wird bei
jedem Schleifendurchlauf ausgeführt. Besteht der Schleifenkörper nur aus einer einzelnen
Anweisung, können die
geschweiften Klammern weggelassen werden.
{
...
...
...
...
...
...
}
Das Inkrement wird immer
nach dem Verlassen und vor
einem möglichen Wiedereintritt in den Schleifenkörper
ausgeführt.
Abbildung 3.6 Die for-Anweisung
Der Test dient letztlich dazu, die Schleife abzubrechen. Deshalb spricht man oft etwas
oberflächlich von einer »Abbruchbedingung«. Dies suggeriert, dass die Schleife abgebrochen wird, wenn der Test positiv ausfällt. Es ist aber genau umgekehrt: Die
Schleife wird abgebrochen, wenn der Test negativ ausfällt, bzw. fortgesetzt, wenn der
Test positiv ausfällt. In diesem Sinne handelt es sich also bei dem Test um eine »Weitermachbedingung«. Prägen Sie sich daher den irreführenden Begriff »Abbruchbedingung« und das damit verbundene Bild erst gar nicht ein.
Eine der häufigsten Anwendungen von Schleifen ist die sogenannte Zählschleife.
Hier wird die Anzahl der Schleifendurchläufe über eine Zählvariable gesteuert. Konkret kann das etwa so aussehen:
int i, summe;
summe = 0;
for( i = 1; i <= 100; i = i + 1)
summe = summe + i;
In dieser Schleife werden die Zahlen von 1 bis 100 aufsummiert. Das kann man natürlich auch rückwärts machen:
61
3
Ausgewählte Sprachelemente von C
summe = 0;
for( i = 100; i > 0; i = i – 1)
summe = summe + i;
Der Endwert in summe ist jeweils der gleiche.
Man kann auch mehrere Anweisungen durch Komma getrennt in die Initialisierung
oder das Inkrement der Schleife aufnehmen. In unserem Beispiel nehmen wir die Initialisierung der Summe mit in den Schleifenkopf auf:
for(summe = 0, i = 1; i <= 100; i++)
summe = summe + i;
Anstelle von i = i + 1 habe ich hier die Kurzform i++ verwendet.
In der folgenden Schleife wird eine Variable a von 1 ausgehend hoch- und eine Variable b von 100 ausgehend heruntergezählt, solange a dabei kleiner als b bleibt:
for(a = 1, b = 100; a < b; a++, b--)
...;
Einzelne Felder im Schleifenkopf können auch leer gelassen werden. Ist die Initialisierung oder das Inkrement leer, wird dort nichts gemacht. Ein leer gelassenes Feld
für den Test führt dazu, dass der Test immer positiv ausfällt. Achtung, beim folgenden Beispiel handelt es sich um eine Endlosschleife:
for( ; ; )
;
Eine solche Endlosschleife zu programmieren ist natürlich Unsinn. Trotzdem gibt es
Situationen, in denen man den Test weglässt, da man den Ablauf der Schleife auch
aus dem Schleifenkörper heraus steuern kann. Um das zu verstehen, kehren wir noch
einmal zu dem einführenden Beispiel zurück und diskutieren zwei Sonderfälle, die
beim Frankieren der Briefe auftreten können:
1. Sie stellen fest, dass oben auf dem Stapel ein Brief liegt, der an jemanden in der
Nachbarschaft gerichtet ist. Sie beschließen, das Porto zu sparen und den Brief
selbst vorbeizubringen. Dazu überspringen Sie die weitere Bearbeitung dieses
Briefes und legen den Brief unfrankiert in den Postausgang. Sie fahren dann mit
der Abarbeitung des Stapels fort.
2. Sie stellen fest, dass Ihnen die Briefmarken ausgegangen sind. Es bleibt Ihnen
nichts anderes übrig, als die Bearbeitung der Schleife vorzeitig abzubrechen.
Wir nehmen diese beiden Fälle in das Flussdiagramm auf:
62
3.5
Kontrollfluss
Briefe und Marken
bereitstellen
3
Bearbeiteten Brief in
Postausgang legen
Sind
noch Briefe
unbearbeitet?
nein
ja
Brief nehmen
ja
Brief an
Nachbarn?
Hier wird nur ein einzelner
Arbeitsschritt abgebrochen.
nein
Keine Marken
mehr?
nein
ja
Hier wird die Bearbeitung
komplett abgebrochen.
Marke abtrennen
Marke befeuchten
Marke aufkleben
Abbildung 3.7 Das erweiterte Flussdiagramm
Um auf Sonderfälle sinnvoll zu reagieren, müssen wir aus dem Schleifenkörper heraus in die Schleifensteuerung eingreifen. Das ist in C durch eine break- oder eine continue-Anweisung möglich:
for ( ... ; ... ; ... )
Bei einer continue-Anweisung
wird der derzeitige Schleifendurchlauf abgebrochen, die
Schleifenbearbeitung insgesamt
aber über Inkrement und Test
fortgesetzt.
...
{
...
...
if ( ... )
continue ;
...
...
if ( ... )
break;
...
...
}
Bei einer break-Anweisung
wird die Bearbeitung der
Schleife abgebrochen.
Abbildung 3.8 Schleifensteuerung innerhalb der for-Anweisung
63
3
Ausgewählte Sprachelemente von C
Beachten Sie noch einmal den Unterschied! Durch continue wird nur der aktuelle
Schleifendurchlauf abgebrochen, die Schleife insgesamt jedoch über Inkrement und
Test fortgesetzt. Durch break wird dagegen die Schleife sofort abgebrochen. Natürlich
kann es mehrere break- oder continue-Anweisungen in beliebiger Reihenfolge in
einer Schleife geben. Solche Anweisungen werden aber immer unter einer Bedingung stehen. Ein unbedingtes break oder continue ist nicht sinnvoll, da der nachfolgende Code nie erreicht würde.
In die oben konstruierte Schleife zum Aufsummieren aller Zahlen zwischen 1 und
100 bauen wir jetzt zusätzlich eine continue-Anweisung ein, die dafür sorgt, dass alle
durch 7 teilbaren Zahlen bei der Summation übersprungen werden:
for(summe = 0, i = 1; i <= 100; i = i + 1)
{
if( i%7 == 0)
continue;
summe = summe + i;
}
Beachten Sie, dass wir jetzt die geschweiften Klammern benötigen, da wir mehr als
eine Anweisung im Schleifenkörper haben. Was berechnet das Programm, wenn Sie
die geschweiften Klammern weglassen? Wenn Sie nicht sicher sind, probieren Sie es
aus.
Wir wollen zusätzlich noch einen harten Schleifenabbruch in unser Programm einbauen:
for(summe = 0, i = 1; i <= 100; i = i + 1)
{
if( i%7 == 0)
continue;
summe = summe + i;
if( summe > 1000)
break;
}
Jetzt wird die Schleife sofort verlassen, wenn sich in summe ein Wert größer als 1000
ergibt. Wissen Sie, bei welchem Wert von i die Schleife jetzt abgebrochen wird und
welchen Wert die Variable summe dann hat? Implementieren Sie das Programm, um es
herauszufinden.
Ganz selbstverständlich haben wir in unserem Beispiel eine Bedingung in eine
Schleife eingebaut. Das zeigt, dass man offensichtlich die verschiedenen Kontroll-
64
3.5
Kontrollfluss
strukturen ineinander schachteln kann. Diese Beobachtung wollen wir im folgenden
Abschnitt noch etwas vertiefen.
Für die Testbedingung im Schleifenkopf gilt das bereits zur if-Bedingung Gesagte.
Bei einer Prüfung auf gleich oder ungleich 0 kann man vereinfachen:
for ( …; !a; …)
{
for ( …; a == 0; …)…
{
}
…
}
for ( …; a; …)
{
for ( …; a != 0; …)
…
{
}
…
}
Abbildung 3.9 Vereinfachung der Testbedingung
Insbesondere muss auch hier wieder auf den Unterschied zwischen Test auf Gleichheit (==) und Zuweisung (=) hingewiesen werden.
Wenn eine Schleife keine Initialisierung und kein Inkrement benötigt, können Sie
anstelle einer for- auch eine while-Anweisung verwenden:
for ( ; …Test…; )
{
…
}
while ( …Test… )
{
…
}
Abbildung 3.10 Ersatz der for- durch eine while-Anweisung
Die Anweisungen break und continue können bei while genauso wie bei for verwendet werden. Im Grunde genommen ist while entbehrlich, da es ein Spezialfall von for
ist. Umgekehrt könnte auch for vollständig durch while nachgebildet werden. Das ist
aber im Sinne einer guten Lesbarkeit der Programme nicht immer sinnvoll, da Initialisierung und Inkrement der Schleife nicht mehr explizit ausgewiesen und im restlichen Programm »versteckt« sind. Das kann bei Programmänderungen oder
-erweiterungen zu Problemen führen.
3.5.3
Verschachtelung von Kontrollstrukturen
Kontrollstrukturen können beliebig ineinander eingesetzt werden. Möglich sind z. B.:
왘
ein if in einem if
왘
ein if in einem for
왘
ein for in einem for
65
3
3
Ausgewählte Sprachelemente von C
왘
ein for in einem if
왘
ein if in einem for in einem for
왘
ein for in einem if in einem for in einem if
Als Beispiel betrachten wir ein Programm, das das »kleine Einmaleins« durch zwei
ineinander geschachtelte Zählschleifen berechnet:
for( i = 1; i <= 10; i = i + 1)
{
for( k = 1; k <= 10; k = k + 1)
produkt = i*k;
}
Die Variable i durchläuft in der äußeren Schleife die Werte von 1 bis 10. Für jeden
Wert von i durchläuft dann die Variable k in der inneren Schleife ebenfalls die Werte
von 1 bis 10. Insgesamt wird damit die Berechnung in der inneren Schleife 100-mal
für alle möglichen Kombinationen von i und k ausgeführt.
Das folgende Programm berechnet das Produkt nur, wenn beide Faktoren gerade
sind:
for( i = 1; i <= 10; i = i + 1)
{
if( i%2 == 0)
{
for( k = 1; k <= 10; k = k + 1)
{
if( k%2 == 0)
produkt = i*k;
}
}
}
Nur wenn i gerade ist, wird jetzt in die innere Schleife über k eingetreten, und dort
wird dann das Produkt nur dann berechnet, wenn k ebenfalls gerade ist.
Beachten Sie, dass in diesem Beispiel alle geschweiften Klammern und auch die Einrückungen eigentlich überflüssig sind. Zusätzlich gesetzte Klammern und einheitliche Einrückungen verbessern aber die Lesbarkeit des Programms.
Das oben dargestellte Programm berechnet zwar das kleine Einmaleins, aber die
Ergebnisse verfliegen im luftleeren Raum. Sinnvoll wäre es, immer dann, wenn man
ein neues Ergebnis ermittelt hat, dieses auf dem Bildschirm auszugeben. Damit werden wir uns im nächsten Abschnitt beschäftigen.
66
3.6
3.6
Elementare Ein- und Ausgabe
Elementare Ein- und Ausgabe
Um erste einfache Programme schreiben zu können, müssen Sie Werte von der Tastatur in Variablen einlesen und Werte von Variablen auf dem Bildschirm ausgeben
können. Es geht dabei nicht darum, komplexe Interaktionen mit dem Benutzer abzuwickeln. Es reicht, wenn Sie einige wenige Benutzereingaben in Ihre Programme hinein- und einige wenige Ergebnisse aus Ihren Programmen herausbekommen.
Dementsprechend spartanisch sind die Methoden, die ich Ihnen hier vorstellen
werde. Da Computernutzer heutzutage von opulenten Bedienoberflächen verwöhnt
sind, wird das vielleicht enttäuschend für Sie sein. Aber vielleicht lenkt gerade diese
Genügsamkeit Ihren Blick auf das Wesentliche.
C hat keine Sprachelemente für Ein- oder Ausgabe. Ein- und Ausgabe werden nicht
durch die Sprache selbst, sondern durch sogenannte Funktionen erledigt. Die hinter
den Funktionen stehenden Konzepte werde ich Ihnen später vorstellen. Sie können
Funktionen aber auch verwenden, ohne genau verstanden zu haben, was bei der Verwendung »unter der Haube« passiert. Sollten bei den folgenden Erklärungen noch
Fragen offen bleiben, versichere ich Ihnen, dass ich diese Fragen später ausführlich
beantworten werde.
3.6.1
Bildschirmausgabe
Um einen Text auf dem Bildschirm auszugeben, verwenden wir die Funktion printf
und schreiben:
printf( "Dieser Text wird ausgegeben\n");
Der auszugebende Text wird in doppelte Hochkommata eingeschlossen. Die am
Ende des Textes stehende Zeichenfolge \n erzeugt einen Zeilenvorschub. Vergessen
Sie nicht das Semikolon am Ende der Zeile!
In den auszugebenden Text können wir Zahlenwerte einstreuen, indem wir als Platzhalter für die fehlenden Zahlenwerte eine sogenannte Formatanweisung in den Text
einfügen. Eine solche Formatanweisung besteht aus einem Prozentzeichen, gefolgt
von dem Buchstaben d (für Dezimalwert) oder f (für Gleitkommawert) – also %d oder
%f. Die zugehörigen Werte werden dann als Konstanten oder Variablen durch Kommata getrennt hinter dem Text angefügt.
67
3
3
Ausgewählte Sprachelemente von C
Das Programmfragment
int wert = 1;
printf ( "Die %d. Zeile hat %d Buchstaben!\n", wert, 26);
wert = 2;
printf ( "Dies ist die %d. Zeile!\n", wert);
Abbildung 3.11 Programmfragment zur Ausgabe
führt zu der Ausgabe:
Die 1. Zeile hat 26 Buchstaben!
Dies ist die 2. Zeile!
Der auszugebende Wert kann auch ohne Verwendung einer Variablen direkt dort
berechnet werden, wo er benötigt wird:
printf( "Ergebnis = %d\n", 3*a + b);
Der Ausdruck 3*a+b wird zunächst vollständig ausgewertet, und das Ergebnis wird an
der durch %d markierten Stelle in die Ausgabe eingefügt.
Zur Ausgabe von Gleitkommazahlen verwendet man die Formatanweisung %f. Im
folgenden Beispiel
float preis;
preis = 10.99;
printf( "Die Ware kostet %f EURO\n", preis);
erhalten wir die Ausgabe:
Die Ware kostet 10.99 EURO
Wichtig ist, dass die Formatanweisung exakt zum Typ des auszugebenden Werts
passt – also %d bei ganzen Zahlen und %f bei Gleitkommazahlen.
Wir wollen unser Beispiel zur Berechnung des kleinen Einmaleins jetzt mit einer Ausgabe ausstatten:
for( i = 1; i <= 10; i = i + 1)
{
for( k = 1; k <= 10; k = k + 1)
{
68
3.6
Elementare Ein- und Ausgabe
produkt = i*k;
printf( "%d mal %d ist %d\n", i, k, produkt);
}
printf( "\n");
}
3
Das Programm gibt jetzt das kleine Einmaleins auf dem Bildschirm aus und erzeugt
nach jedem Zehnerpäckchen einen zusätzlichen Zeilenvorschub. Das sieht so aus:
2
2
2
2
2
mal
mal
mal
mal
mal
6 ist 12
7 ist 14
8 ist 16
9 ist 18
10 ist 20
3
3
3
3
3
3
3
3
3
3
mal
mal
mal
mal
mal
mal
mal
mal
mal
mal
1 ist 3
2 ist 6
3 ist 9
4 ist 12
5 ist 15
6 ist 18
7 ist 21
8 ist 24
9 ist 27
10 ist 30
4 mal 1 ist 4
4 mal 2 ist 8
4 mal 3 ist 12
3.6.2
Tastatureingabe
Eine oder mehrere ganze Zahlen lesen wir mit der Funktion scanf von der Tastatur ein.
int zahl1, zahl2;
printf ( "Bitte geben Sie zwei Zahlen ein: ");
scanf ( "%d %d", &zahl1, &zahl2);
printf ( "Sie haben %d und %d eingegeben\n", zahl1, zahl2);
Abbildung 3.12 Einlesen von Werten
69
3
Ausgewählte Sprachelemente von C
Beim Einlesen müssen Variablen angegeben werden, denen die Werte zugewiesen
werden sollen. Wir stellen dazu dem Variablennamen ein & voran. Die exakte Bedeutung des &-Zeichens können Sie im Moment noch nicht verstehen, sie wird später
erklärt. Lassen Sie das & jedoch nicht weg, auch wenn es Ihnen an dieser Stelle unmotiviert erscheint.
Der zum oben dargestellten Programm gehörende Bildschirmdialog sieht bei entsprechenden Benutzereingaben wie folgt aus:
Bitte geben Sie zwei Zahlen ein: 123 456
Sie haben 123 und 456 eingegeben!
Für die Eingabe von Gleitkommazahlen verwenden Sie dann natürlich Gleitkommavariablen und die Formatanweisung %f.
Wir können das Programm zur Ausgabe des kleinen Einmaleins jetzt so erweitern,
dass die Bereiche, in denen das kleine Einmaleins berechnet werden soll, durch den
Benutzer festgelegt werden. Hier sehen Sie das vollständige Programm dazu:
void main()
{
int i, k;
int maxi, maxk;
int produkt;
printf( "Bitte maxi eingeben: ");
scanf( "%d", &maxi);
printf( "Bitte maxk eingeben: ");
scanf( "%d", &maxk);
for( i = 1; i <= maxi; i = i + 1)
{
for( k = 1; k <= maxk; k = k + 1)
{
produkt = i*k;
printf( "%d mal %d ist %d\n", i, k, produkt);
}
printf( "\n");
}
}
Listing 3.7 Das kleine Einmaleins
70
3.6
Elementare Ein- und Ausgabe
Der Benutzer wird aufgefordert, Maximalwerte für i und k einzugeben. Die eingegebenen Werte werden dann in den Schleifen verwendet, um die zulässigen Werte für i
und k nach oben zu begrenzen. Das folgende Bild zeigt einen möglichen Programmlauf:
3
Bitte
Bitte
1 mal
1 mal
1 mal
1 mal
1 mal
maxi eingeben: 3
maxk eingeben: 5
1 ist 1
2 ist 2
3 ist 3
4 ist 4
5 ist 5
2
2
2
2
2
mal
mal
mal
mal
mal
1
2
3
4
5
ist
ist
ist
ist
ist
2
4
6
8
10
3
3
3
3
3
mal
mal
mal
mal
mal
1
2
3
4
5
ist
ist
ist
ist
ist
3
6
9
12
15
Die Formatanweisung in scanf kann neben den %-Anweisungen auch zusätzliche
Zeichen enthalten. Zum Beispiel
int zahl;
scanf( "ABC%dxyz", &zahl);
In diesem Fall erwartet scanf genau das in der Formatanweisung angegebene Muster
in der Eingabe, also ABC, gefolgt von einer Zahl, die der Variablen zahl zugewiesen
wird, und dann wiederum gefolgt von xyz. Auf diese Weise können Sie Ihre Eingaben
aus einem komplexeren Kontext »herauspicken« oder den Eingabetext in seine Einzelbestandteile zerlegen. Diese strenge Auslegung der Eingabe verlangt allerdings
vom Benutzer, dass er die Zeichen genauso eingibt, wie im Formatstring vorgegeben.
Eine Abweichung führt zu Fehleingaben oder zu scheinbar unmotiviertem Warten
auf weitere Eingaben. Wir wollen das hier nicht weiter diskutieren, da diese Art der
Eingabe bei modernen Softwaresystemen mit grafischer Benutzeroberfläche nicht
verwendet wird.
71
3
Ausgewählte Sprachelemente von C
3.6.3
Kommentare und Layout
C-Programme können durch Kommentare verständlicher gestaltet werden. Es gibt
zwei Arten, ein Programm in C zu kommentieren:
왘
Einzeilige Kommentare beginnen mit // und erstrecken sich dann bis zum Ende
der Zeile.
왘
Mehrzeilige Kommentare beginnen mit /* und enden mit */.
Kommentare werden beim Übersetzen des Programms einfach ignoriert.
/*
** Variablendefinitionen
*/
int zahl1; // Dies ist eine Zahl
/*
** Programmcode
*/
zahl1 = 123; // Der Wert ist jetzt 123
Setzen Sie Kommentare nur dort ein, wo sie wirklich etwas zum Programmverständnis beitragen! Vermeiden Sie Plattitüden wie im Beispiel oben!
Die in diesem Buch als Beispiele vorgestellten Programme enthalten in der Regel
keine Kommentare. Das liegt daran, dass alle Beispielprogramme im umgebenden
Text ausführlich besprochen werden. Lassen Sie sich durch das Fehlen von Kommentaren nicht zu der irrigen Annahme verleiten, dass Kommentare in C-Programmen
überflüssig sind.
Das Layout des Programmtextes können Sie, von den #-Anweisungen, die immer am
Anfang einer Zeile stehen müssen, einmal abgesehen, mit Leerzeichen, Zeilenumbrüchen, Seitenvorschüben und Tabulatorzeichen relativ frei gestalten. Ein einheitliches, klar gegliedertes Layout erhöht die Lesbarkeit und damit auch die Pflegbarkeit
eines Programms. Die Frage nach einer einheitlichen und verbindlichen Gestaltung
des Programmcodes gewinnt insbesondere dann an Bedeutung, wenn Software von
mehreren Programmierern im Team erstellt wird und die Notwendigkeit besteht,
dass ein und derselbe Code von verschiedenen Entwicklern bearbeitet wird. Viele
Unternehmen haben daher Codier-Richtlinien aufgestellt, und die Entwickler sind
gehalten, sich an diesen Vorgaben zu orientieren. Ich werde Ihnen an einigen Stellen
Empfehlungen über einen »guten« Gebrauch der durch C bzw. C++ zur Verfügung
gestellten Sprachmittel geben. Eine vollständige Bereitstellung von Codier-Richtlinien finden Sie in diesem Buch jedoch nicht.
72
3.7
Beispiele
So sollten Sie es jedenfalls nicht machen:
void main() { int i; int k; int maxi; int maxk; int produkt; printf(
"Bitte maxi eingeben: "); scanf( "%d", &maxi); printf(
"Bitte maxk eingeben: "); scanf( "%d", &maxk); for( i = 1; i <= maxi; i =
i + 1) { for( k = 1; k <= maxk; k = k + 1) { produkt = i*k; printf(
"%d mal %d ist %d\n", i, k, i*k); } printf( "\n"); } }
3
Es gibt übrigens einen hochinteressanten Wettbewerb (International Obfuscated C
Code Contest) im Internet, bei dem es darum geht, möglichst kreativ C-Programme
zu erstellen, die ihre wahre Funktion verschleiern. Das ist sozusagen das genaue
Gegenteil dessen, was von einem seriösen Programmierer erwartet wird. Im Rahmen
dieses Wettbewerbs sind im Laufe der Jahre kleine Kunstwerke entstanden, die nur
mit perfekten C-Kenntnissen analysiert und verstanden werden können. Im
Moment ist es vielleicht noch etwas zu früh für Sie, sich an solchen Programmen zu
versuchen, aber wenn Sie später einmal testen wollen, ob Sie C wirklich verstanden
haben, finden Sie dort echte Herausforderungen.
3.7
Beispiele
Es wird Sie vielleicht überraschen, aber mit dem, was Sie bisher gelernt haben, können Sie bereits alles programmieren, was man überhaupt nur programmieren kann.
Im Grunde genommen könnten Sie dieses Buch jetzt zuklappen und den Rest vergessen. Ich hoffe natürlich, dass Sie weiterlesen, denn wenn Sie jetzt aufhören, wäre das
so, als würde ich Sie mit einem Teelöffel vor ein riesiges Schwimmbecken stellen und
sagen, dass Sie jetzt alles haben, was Sie benötigen, um das Becken zu leeren.
Es ist noch ein weiter Weg zum Ziel, das ja professionelle Programmierung heißt.
Aber ein wichtiges Etappenziel haben Sie erreicht. Um zu sehen, was Sie bereits können, finden Sie hier einige Beispiele.
3.7.1
Das erste Programm
Was liegt näher, als mit Ihren frisch erworbenen Programmierkenntnissen zu versuchen, den Algorithmus zur Division aus dem ersten Kapitel zu realisieren? Erinnern
Sie sich an das zugehörige Flussdiagramm, das Sie jetzt in ein C-Programm umsetzen
können.
73
3
Ausgewählte Sprachelemente von C
void main()
{
int z, n, a, x;
printf ( "Zu teilende Zahl: ");
scanf ( "%d", &z);
printf ( "Teiler: ");
scanf ( "%d", &n);
printf ( "Anzahl Nachkommastellen: ");
scanf ( "%d", &a);
x = z/n;
printf ( "Ergebnis = %d.", x);
Start
Eingabe: z, n, a
x = größte ganze Zahl mit nx ≤ z
Ausgabe: »Ergebnis = x.«
for ( ; a > 0; a = a – 1)
{
nein
a>0
ja
z = 10 * ( z - n*x);
z = 10 (z – nx)
ja
if ( z == 0 )
break;
z=0
nein
x = z/n;
printf ( "%d", x);
x = größte ganze Zahl mit nx ≤ z
Ausgabe: »x«
}
a=a–1
}
Ende
Abbildung 3.13 Flussdiagramm der schriftlichen Division
Die Schleife wird so lange ausgeführt, wie Ziffern zu berechnen sind – es sei denn,
dass der Divisionsrest 0 wird. Dann wird die Schleife vorzeitig durch die break-Anweisung beendet.
Wir testen das Programm mit unserem Standardfall (84:16)
Zu teilende Zahl: 84
Teiler: 16
Anzahl Nachkommastellen: 4
Ergebnis = 5.25
und mit einem Testfall, bei dem das Abbruchkriterium über die Stellenzahl zum Zuge
kommt (100:7):
74
3.7
Beispiele
Zu teilende Zahl: 100
Teiler: 7
Anzahl Nachkommastellen: 6
Ergebnis = 14.285714
3
Das Programm arbeitet einwandfrei.
3.7.2
Das zweite Programm
Wir betrachten ein einfaches Spiel, bei dem eine Kugel durch eine Reihe von Weichen
(weiche1 bis weiche4) zu einem von zwei möglichen Ausgängen fällt:
0
weiche1
1
1
weiche2
0
2
1
0
1 weiche3
3
0
1
weiche4
4
5
Abbildung 3.14 Das Kugelspiel
Die möglichen Positionen der Kugel auf dem Weg zu einem der Ausgänge sind in
Abbildung 3.14 fortlaufend von 1 bis 5 nummeriert. Die Weichen sind so konstruiert,
dass sie beim Passieren einer Kugel umschlagen und auf diese Weise die nächste
Kugel in die entgegengesetzte Richtung lenken. Die Frage, an welchem Ausgang die
Kugel das System verlässt, können wir über eine Reihe geschachtelter Verzweigungen beantworten, wenn wir den Weg einer Kugel durch das System nachvollziehen.
75
3
Ausgewählte Sprachelemente von C
Wir modellieren die Problemlösung zunächst durch ein Flussdiagramm, in dessen
Struktur man das Spiel direkt wiedererkennt:
Weichenstellungen
eingeben
weiche
1
==1
ja
nein
position = 1
position = 2
weiche1 umschlagen
ja
ja
nein
weiche2
==1
position = 4
nein
position
==1
ja
position = 3
position = 5
position = 3
weiche2 umschlagen
nein
weiche3
==1
weiche3 umschlagen
nein
position
==3
ja
ja
nein
weiche4
==1
position = 4
position = 5
weiche4 umschlagen
Position
ausgeben
Abbildung 3.15 Flussdiagramm des Kugelspiels
Dieses Flussdiagramm setzen wir dann in C-Code um. Zum Programmstart lassen wir
den Benutzer die Anfangsstellung der vier Weichen eingeben, dann läuft der Algorithmus so ab, wie im Flussdiagramm vorgegeben:
76
3.7
Beispiele
void main()
{
int weiche1, weiche2, weiche3, weiche4;
int position;
3
printf( "Bitte geben Sie die Weichenstellungen ein: ");
scanf( "%d %d %d %d", &weiche1, &weiche2, &weiche3, &weiche4);
if( weiche1 == 1)
position = 1;
else
position = 2;
weiche1 = 1 – weiche1;
if( position == 1)
{
if( weiche2 == 1)
position = 4;
else
position = 3;
weiche2 = 1 – weiche2;
}
else
{
if( weiche3 == 1)
position = 3;
else
position = 5;
weiche3 = 1 – weiche3;
}
if( position == 3)
{
if( weiche4 == 1)
position = 4;
else
position = 5;
weiche4 = 1 – weiche4;
}
printf( "Auslauf: %d, ", position);
printf( "neue Weichenstellung %d %d %d %d\n",
weiche1, weiche2, weiche3, weiche4);
}
Listing 3.8 Implementierung des Kugelspiels
77
3
Ausgewählte Sprachelemente von C
Das Umschlagen der Weichen realisieren wir durch weiche = 1 – weiche. Diese Anweisung bewirkt, dass der Wert von weiche immer zwischen 0 und 1 hin- und herschaltet.
Und so läuft das Programm aus Benutzersicht ab:
Bitte geben Sie die Weichenstellungen ein: 1 0 1 0
Auslauf: 5, neue Weichenstellung 0 1 1 1
Um mehrere Kugeln durch das System laufen zu lassen, müssen wir die Anzahl der
gewünschten Kugeln erfragen und den einzelnen Durchlauf in eine Schleife einpacken. Dazu dienen die folgenden Erweiterungen:
void main()
{
int weiche1, weiche2, weiche3, weiche4;
int position;
int kugeln;
printf( "Bitte geben Sie die Weichenstellungen ein: ");
scanf( "%d %d %d %d", &weiche1, &weiche2, &weiche3, &weiche4);
printf( "Bitte geben Sie die Anzahl der Kugeln ein: ");
scanf( "%d", &kugeln);
for( ; kugeln > 0; kugeln = kugeln – 1)
{
... wie bisher ...
}
}
Listing 3.9 Erweiterung des Kugelspiels
Und so läuft das erweiterte Programm ab:
Bitte geben
Bitte geben
Auslauf: 5,
Auslauf: 4,
Auslauf: 4,
Auslauf: 5,
Auslauf: 5,
78
Sie die Weichenstellungen ein: 0 1 0 1
Sie die Anzahl der Kugeln ein: 5
neue Weichenstellung 1 1 1 1
neue Weichenstellung 0 0 1 1
neue Weichenstellung 1 0 0 0
neue Weichenstellung 0 1 0 1
neue Weichenstellung 1 1 1 1
3.7
3.7.3
Beispiele
Das dritte Programm
Für unser drittes Programm stellen wir uns die folgende Programmieraufgabe:
Der Benutzer soll eine von ihm vorab festgelegte Anzahl von Zahlen eingeben. Das
Programm summiert unabhängig voneinander die positiven und die negativen Eingaben und gibt am Ende die Summe der negativen Eingaben, die Summe der positiven Eingaben und die Gesamtsumme aus.
In einem konkreten Beispiel soll das Programm so ablaufen, dass zunächst im Dialog
mit dem Benutzer alle erforderlichen Eingaben erfragt werden:
Wie viele Zahlen sollen eingegeben werden: 8
1. Zahl: 1
2. Zahl: 2
3. Zahl: –5
4. Zahl: 4
5. Zahl: 5
6. Zahl: –8
7. Zahl: 3
8. Zahl: –7
Anschließend werden die gewünschten Berechnungsergebnisse ausgegeben:
Die Summe aller positiven Eingaben ist: 15
Die Summe aller negtiven Eingaben ist: –20
Die Gesamtsumme ist: –5
Zur Realisierung nehmen wir unseren Standardprogrammrahmen und ergänzen die
gewünschte Funktionalität:
# include <stdio.h>
# include <stdlib.h>
A
B
void main()
{
int anzahl;
int z;
int summand;
int psum;
int nsum;
printf( "Wie viele Zahlen sollen eingegeben werden: ");
scanf( "%d", &anzahl);
fflush( stdin);
79
3
3
Ausgewählte Sprachelemente von C
C
psum = 0;
nsum = 0;
D
for( z = 1; z <= anzahl; z = z + 1)
{
printf( "%d. Zahl: ", z);
scanf( "%d", &summand);
E
F
G
if( summand > 0)
psum = psum + summand;
else
nsum = nsum + summand;
}
printf( "Summe aller positiven Eingaben: %d\n", psum);
printf( "Summe aller negativen Eingaben: %d\n", nsum);
printf( "Gesamtsumme: %d\n", psum + nsum);
}
Listing 3.10 Das dritte Programm
Weil dies eines unserer ersten Programme ist, wollen wir alle Teile noch einmal
intensiv betrachten und diskutieren:
Bereich A: Hier werden die benötigten Variablen definiert. Alle Variablen sind ganzzahlig und werden in der folgenden Bedeutung verwendet:
왘
anzahl ist die vom Benutzer gewählte Zahl der Eingaben.
왘
z ist die Kontrollvariable für die Zählschleife.
왘
summand ist die vom Benutzer aktuell eingegebene Zahl.
왘
psum ist die jeweils aufgelaufene Summe der positiven Eingaben.
왘
nsum ist die jeweils aufgelaufene Summe der negativen Eingaben.
Bereich B: Hier wird der Benutzer zunächst aufgefordert, die gewünschte Anzahl einzugeben. Dann wird die Benutzereingabe in die Variable anzahl übertragen. Vergessen Sie nicht das &-Zeichen vor der einzulesenden Variablen!
Bereich C: Die zur Summenbildung verwendeten Variablen (psum, nsum) werden mit 0
initialisiert.
Zeile D: In einer Schleife werden für z = 1,2,...,anzahl jeweils die Unterpunkte E–G ausgeführt.
80
3.8
Aufgaben
Bereich E: Der Benutzer wird aufgefordert, die nächste Zahl einzugeben, und diese
Zahl wird der Variablen summand zugewiesen.
Bereich F: Wenn die vom Benutzer eingegebene Zahl (summand) größer als 0 ist, wird
psum entsprechend erhöht, andernfalls wird nsum entsprechend verkleinert.
Bereich G: Die gewünschten Ergebnisse psum, nsum und psum+nsum werden ausgegeben.
3.8
Aufgaben
A 3.1
Machen Sie sich mit Editor, Compiler und Linker Ihrer Entwicklungsumgebung vertraut, indem Sie die Programme dieses Kapitels eingeben und zum
Laufen bringen!
A 3.2 Schreiben Sie ein Programm, das zwei ganze Zahlen von der Tastatur einliest
und anschließend deren Summe, Differenz, Produkt, den Quotienten und den
Divisionsrest auf dem Bildschirm ausgibt!
1. Zahl: 10
2. Zahl: 4
Summe
Differenz
Produkt
Quotient
10 +
10 –
10*4
10/4
4
4
=
=
= 14
= 6
40
2 Rest 2
Was passiert, wenn man versucht, durch 0 zu dividieren?
A 3.3 Erstellen Sie ein Programm, das unter Verwendung der in Aufgabe 1.3 formulierten Regeln berechnet, ob eine vom Benutzer eingegebene Jahreszahl ein
Schaltjahr bezeichnet oder nicht!
A 3.4 Erstellen Sie ein Programm, das zu einem eingegebenen Datum (Tag, Monat
und Jahr) berechnet, um den wievielten Tag des Jahres es sich handelt! Berücksichtigen Sie dabei die Schaltjahrregel!
A 3.5 Schreiben Sie ein Programm, das alle durch 7 teilbaren Zahlen zwischen zwei
zuvor eingegebenen Grenzen ausgibt!
A 3.6 Schreiben Sie ein Programm, das berechnet, wie viele Legosteine zum Bau der
folgenden Treppe mit der zuvor eingegebenen Höhe h erforderlich sind:
81
3
3
Ausgewählte Sprachelemente von C
Abbildung 3.16 Treppe aus Legosteinen
A 3.7 Schreiben Sie ein Programm, das eine vom Benutzer festgelegte Anzahl von
Zahlen einliest und anschließend die größte und die kleinste der eingegebenen Zahlen auf dem Bildschirm ausgibt!
A 3.8 Implementieren Sie das Ratespiel aus Aufgabe 1.4 entsprechend dem von
Ihnen gewählten Algorithmus!
A 3.9 Implementieren Sie Ihren Algorithmus aus Aufgabe 1.5 zur Feststellung, ob
eine Zahl eine Primzahl ist!
A 3.10 Schreiben Sie ein Programm, das das kleine Einmaleins berechnet und in
Tabellenform auf dem Bildschirm ausgibt! Die Darstellung auf dem Bildschirm sollte wie folgt sein:
1 2 3 4 5 6 7 8 9 10
--------------------------------------------1 | 1 2 3 4 5 6 7 8 9 10
2 | 2 4 6 8 10 12 14 16 18 20
3 | 3 6 9 12 15 18 21 24 27 30
4 | 4 8 12 16 20 24 28 32 36 40
5 | 5 10 15 20 25 30 35 40 45 50
6 | 6 12 18 24 30 36 42 48 54 60
7 | 7 14 21 28 35 42 49 56 63 70
8 | 8 16 24 32 40 48 56 64 72 80
9 | 9 18 27 36 45 54 63 72 81 90
10 | 10 20 30 40 50 60 70 80 90 100
---------------------------------------------
Die Ausgabe einer ganzen Zahl in einer bestimmten Feldbreite erreichen Sie
übrigens dadurch, dass Sie in der Formatanweisung zwischen dem Prozentzeichen und dem Buchstaben für den Datentyp die gewünschte Feldbreite, z. B. in
der Form "%3d", angeben.
82
Kapitel 4
Arithmetik
Der Mangel an mathematischer Bildung gibt sich durch nichts so auffallend zu erkennen wie durch maßlose Schärfe im Zahlenrechnen.
– Carl Friedrich Gauß
4
Computer bedeutet im Wortsinn Rechner. Einen Computer für eine einmalig vorkommende Berechnung zu verwenden ist nicht besonders sinnvoll. In einer solchen
Situation nimmt man besser einen Taschenrechner. Eine besondere Hilfe sind Computerprogramme aber bei sich stereotyp wiederholenden Rechenoperationen. Solche Operationen werden Sie in diesem Abschnitt kennenlernen.
Es gibt einige fundamentale Unterschiede zwischen Berechnungen in der Mathematik und in der Programmierung. Ein wichtiger Unterschied ist, dass die Mathematik
mit unendlich vielen Zahlen arbeitet, während ein Computer nur endlich viele Zahlen kennt. In der Mathematik ist es so, dass es zwischen zwei verschiedenen Zahlen
immer eine weitere Zahl gibt. Auf einem Computer ist das nicht immer so. Ein Computer muss das mathematische Modell von unendlich vielen Zahlen in ein endliches
Zahlenmodell pressen, wobei es dann immer eine größte und eine kleinste Zahl und
auch einen Mindestabstand zwischen Zahlen gibt. Bei dieser »Diskretisierung« ergeben sich zwangsläufig Probleme (z. B. Rechenungenauigkeit), mit denen man umzugehen lernen muss.
Es gibt aber noch einen weiteren Unterschied zwischen der Arithmetik der Mathematik und der Arithmetik der Informatik, der mir hier viel wichtiger ist. In der Mathematik versucht man, arithmetische Zusammenhänge durch möglichst einfache und
elegante Formeln auszudrücken. In der Programmierung schaut man aus einem
anderen Blickwinkel auf diese Formeln, da man sich fragt, wie man einen Formelausdruck möglichst effizient berechnen kann. Naiv würde man vielleicht vermuten, dass
eine elegante mathematische Formulierung auch eine effiziente Berechnung nach
sich zieht. Das ist aber nicht so. Wir betrachten den folgenden mathematischen Ausdruck:
a · x5 + b · x4 + c · x3 + d · x2 + e · x + f
Mit den arithmetischen Grundoperationen können wir den Ausdruck wie folgt
berechnen:
83
4
Arithmetik
a·x·x·x·x·x + b·x·x·x·x + c·x·x·x + d·x·x +e·x +f
Aus Sicht der Mathematik ist hier nichts einzuwenden. Der erfahrene Programmierer aber formt den Ausdruck durch systematisches Ausklammern um:
((((a·x + b)·x + c)·x + d)·x + e)·x + f
Das ist jetzt nicht mehr so gut lesbar, aber es gibt einen frappierenden Unterschied
zur ersten Formulierung. Während die erste Formel 15 Multiplikationen enthält,
kommt die zweite mit fünf Multiplikationen aus. Additionen gibt es in beiden Formeln gleich viele. Die zusätzlichen Klammern in der zweiten Formel steigern den
Berechnungsaufwand nicht. Sie legen ja nur fest, in welcher Reihenfolge die einzelnen Operationen durchzuführen sind. Das bedeutet, dass man bei der Programmierung die zweite Formulierung bevorzugen wird, zumal diese Formulierung ein sehr
einfaches, leicht zu programmierendes Bildungsgesetz aufweist. In dieser Formel
klingt der Rhythmus der Informatik: »a mal x + b mal x + c mal x + d ...«
Wir haben die Ausgangsformel in eine einfache Abfolge gleichartiger Rechenschritte
zerlegt und wollen diesen Prozess im Folgenden noch präziser beschreiben:
Zunächst indizieren wir die Koeffizienten. Anstelle von a, b, c, d, e und f schreiben wir
a0, a1, a2, a3, a4 und a5. Wir erhalten eine Folge von Zwischenergebnissen z1 ... z6, wobei
z6 zugleich das Endergebnis ist:
z1 = a0
z 2 = z1 · x + a 1
z 3 = z2 · x + a2
z 4 = z3 · x + a 3
z 5 = z 4 · x + a4
z6 = z5 · x + a5
Jetzt erkennen Sie ein wiederkehrendes Muster, das sich auch wie folgt beschreiben
lässt:
z1 = a0
zn+1 = zn· x + an für n= 1,2, ... 5
Damit haben wir eine ganz präzise Vorschrift gefunden, die sich leicht in ein Programm umsetzen lässt1. Diesen Ansatz werden wir jetzt weiterverfolgen und mit der
Programmierung verbinden.
1 Sie wissen noch nicht, wie Sie »indizierte« Variablen erzeugen können. Dazu später mehr.
84
4.1
4.1
Folgen
Folgen
In konkreten Problemstellungen stoßen Sie häufig auf Folgen von Zahlen, die einem
bestimmten Bildungsgesetz unterliegen. Zum Beispiel:
1 1 1 1
1, --, ---, ---, -----, ...
2 4 8 16
4
Das allgemeine Bildungsgesetz ist in dieser Schreibweise zwar zu erkennen, aber
nicht exakt festgelegt. Sie präzisieren dies, indem Sie das Bildungsgesetz für die k-te
Zahl exakt aufschreiben:
1
a k = ----k
2
k = 0, 1, ...
Jetzt können Sie genau sagen, welchen Wert eine bestimmte Zahl in der Folge hat,
indem Sie den entsprechenden Wert für k einsetzen und ausrechnen.
1
a 0 = ------ = 1
0
2
1
1
a 1 = ---- = -1
2
2
1
1
a 2 = ----- = --2
4
2
1
1
a 3 = ----- = --3
8
2
Wir sprechen in diesem Zusammenhang von einer expliziten Definition der Folge ak.
Sie können die Folge ak aber auch unter einem anderen Blickwinkel betrachten:
Das erste Glied der Folge hat den Wert 1, alle weiteren Glieder erhalten Sie
jeweils durch Halbieren des vorangegangenen Werts.
In einer etwas formaleren Notation schreiben wir das wie folgt:
⎧ 1
falls k = 0
⎪
ak = ⎨ ak – 1
⎪ ------------ falls k = 1, 2, 3, ...
⎩ 2
Dies bezeichnen wir als eine induktive Definition der Folge ak. Sie erkennen intuitiv,
dass durch die induktive und die explizite Definition die gleiche Zahlenfolge
beschrieben ist. An dieser Stelle sollten Sie sich klarmachen, dass induktiv definierte
Folgen in der Programmierpraxis häufig vorkommen und sich in besonderer Weise
für eine Berechnung durch Computerprogramme eignen.
85
4
Arithmetik
Wir betrachten dazu ein konkretes Problem. Dieses Problem wollen wir in drei Schritten lösen.
1. Analyse
2. Modellierung
3. Programmierung
Wir beginnen mit der Analyse. Dazu müssen wir das Problem zunächst einmal formulieren:
Ein Student möchte bei seiner Bank ein Darlehen in einer bestimmten Höhe
aufnehmen. Er vereinbart eine feste monatliche Ratenzahlung. Diese Rate
dient dazu, die monatlich anfallenden Zinsen zu bezahlen, und enthält darüber
hinaus einen Tilgungsbetrag, mit dem das Darlehen abbezahlt wird. In dem
Maße, in dem die Restschuld abgetragen wird, sinkt der Anteil der Zinsen an
der monatlichen Ratenzahlung, und der Tilgungsbetrag wächst entsprechend.
Daraus ergibt sich ein ganz bestimmter Tilgungsplan, den wir im Folgenden
aufstellen wollen. Darüber hinaus werden wir noch einige durchaus bankenübliche Zusatzregelungen wie etwa Zinsbindung und Sondertilgungen in die
Berechnung einfließen lassen.
Wir stellen noch einmal alle relevanten Begriffe zusammen und präzisieren die Aufgabenstellung:
Ausgangspunkt für den Tilgungsplan ist die anfängliche Darlehenssumme
bzw. die Restschuld, die jeweils noch zu Buche steht. Mit der Bank wird ein sogenannter Nominalzins vereinbart. Die Restschuld wird monatlich mit 1/12 dieses
Nominalzinses verzinst. Die monatlich zu zahlende Rate wird ebenfalls festgelegt und muss natürlich größer als die anfallenden Zinsen sein, damit noch ein
Tilgungsbetrag übrig bleibt. Der Tilgungsbetrag ergibt sich dann aus der
Monatsrate nach Abzug der monatlichen Zinsen. Wegen des Risikos von Zinsschwankungen garantiert die Bank den obigen Nominalzins allerdings nur
über einen gewissen Zeitraum. In diesem Zeitraum besteht dann eine Zinsbindung. Nach Ablauf dieser Zeit gelten die dann marktüblichen Zinsen, die im
Vorhinein natürlich nur geschätzt werden können und ein gewisses Risiko im
Tilgungsplan darstellen. Letztlich wird mit der Bank noch vereinbart, dass jährliche Sondertilgungen in einer bestimmten Höhe vorgenommen werden können.
Damit ist das Problem noch nicht gelöst, sondern nur abgegrenzt. Der wesentliche
Schritt zur Lösung ist die jetzt folgende Modellierung:
Den Kreditnehmer interessiert, wie hoch nach einer gewissen Anzahl von Monaten
seine Restschuld ist. Wir bezeichnen die Restschuld nach Ablauf von Monaten mit
restn. In diesem Sinne ist rest0 der volle Darlehensbetrag, aber über die weitere Ent-
86
4.1
wicklung der Folge restn wissen Sie noch nicht sehr viel. Sie wissen aber, dass die Zinsen einen großen Einfluss auf die Entwicklung dieser Folge haben. Nun ist der
Zinssatz ebenfalls abhängig von der Zeit, da Sie ja einen Zinssatz (zins1) für den Zeitraum innerhalb der Zinsbindung und einen weiteren Zinssatz (zins2) außerhalb der
Zinsbindung zu betrachten haben. Wenn Sie die Anzahl der Jahre, für die die Zinsbindung besteht, mit bindung bezeichnen, erhalten Sie die folgende Formel für den gültigen Zinssatz (zins) im n-ten Monat:
⎧ zins1
zins n = ⎨
⎩ zins2
falls n ≤ bindung ⋅ 12
falls n > bindung ⋅ 12
Mit diesem Zinssatz können Sie dann die monatliche Zinslast (zinsen) auf der Restschuld berechnen:
rest n ⋅ zins n
zinsen n = -------------------------------1200
Was von der monatlichen Rate nach Abzug der Zinsen (rate – zinsenn) noch übrig
bleibt, dient zur Tilgung des Darlehens. Ist dieser mögliche Tilgungsbetrag größer als
die Restschuld, wird nur in Höhe der Restschuld getilgt, denn der Kreditnehmer will
natürlich nicht mehr Geld zurückzahlen, als er bekommen hat. Damit ergibt sich für
die Tilgung im n-ten Monat:
⎧ rate – zinsen n
tilgung n = ⎨
rest n
⎩
falls rate – zinsen n ≤ rest n
falls rate – zinsen n > rest n
Die Restschuld mindert sich dann um diesen Tilgungsbetrag. Sie haben aber noch die
jährlich vereinbarten Sonderzahlungen zu berücksichtigen. Diese dürfen natürlich
ebenfalls nicht den nach Abzug der Tilgung verbleibenden Darlehensrest übersteigen, und es gilt:
⎧
⎪ sondertilgung
⎪
⎪
sonderz n = ⎨
⎪ rest n – tilgung n
⎪
⎪
0
⎩
falls n durch 12 teilbar
und sondertilgung < rest n – tilgung n
falls n durch 12 teilbar
und sondertilgung ≥ rest n – tilgung n
falls n nicht durch 12 teilbar
Insgesamt ergibt sich dann nach Abzug aller Zahlungen der neue Darlehensrest:
restn+1 = restn – tilgungn – sonderzn
Sie haben damit alle für unser Problem relevanten Formeln hergeleitet, und unser
Modell ist fertig. Jetzt können Sie mit der Programmierung beginnen.
87
Folgen
4
4
Arithmetik
Schritt für Schritt erstellen Sie das Programm. Zunächst legen Sie die erforderlichen
Variablen an. Verwenden Sie dabei die oben eingeführten Namen, sodass der Verwendungszweck der Variablen klar sein sollte:
void main()
{
float rest, rate, zins1, zins2, sondertilgung;
int bindung;
int monat;
float zins, zinsen, tilgung, sonderz;
}
Listing 4.1 Deklaration der verwendeten Variablen
Für die ersten 6 Variablen muss der Benutzer Werte eingeben, während die restlichen
nur zur internen Verarbeitung dienen. Den Dialog mit dem Benutzer führen Sie in
der folgenden Weise aus:
void main()
{
float rest, rate, zins1, zins2, sondertilgung;
int bindung;
int monat;
float zins, zinsen, tilgung, sonderz;
printf( "Darlehen:
scanf( "%f", &rest);
printf( "Nominalzins:
scanf( "%f", &zins1);
printf( "Monatsrate:
scanf( "%f", &rate);
printf( "Zinsbindung (Jahre):
scanf( "%d", &bindung);
printf( "Zinssatz nach Bindung:
scanf( "%f", &zins2);
printf( "Jaehrliche Sondertilgung:
scanf( "%f", &sondertilgung);
}
Listing 4.2 Dialog mit dem Benutzer
88
");
");
");
");
");
");
4.1
Folgen
Jetzt sind alle Daten zur Erstellung des Tilgungsplans eingegeben, und Sie können
mit der Berechnung des Plans beginnen. Zunächst wird eine Überschrift ausgegeben.
Dann gehen Sie in einer Schleife Monat für Monat vor. Die Schleife endet, wenn das
Darlehen vollständig abgetragen ist, also kein Rest mehr bleibt.
void main()
{
... Variablendefinition und Eingaben wie oben ...
printf( "\nTilgungsplan:\n\n");
printf( "Monat Zinssatz Zinsen Tilgung Sondertilg Rest\n");
A
B
C
D
4
for( monat = 1; rest > 0; monat = monat + 1)
{
printf( "%5d", monat);
if( monat <= bindung * 12)
zins = zins1;
else
zins = zins2;
printf( " %10.2f", zins);
zinsen = rest * zins / 1200;
printf( " %10.2f", zinsen);
E
tilgung = rate – zinsen;
if( tilgung > rest)
tilgung = rest;
printf( " %10.2f", tilgung);
rest = rest – tilgung;
F
G
sonderz = 0;
if( (monat % 12) == 0)
{
sonderz = sondertilgung;
if( sonderz > rest)
sonderz = rest;
}
printf( " %10.2f", sonderz);
rest = rest – sonderz;
printf( " %10.2f", rest);
printf( "\n");
}
H
I
}
Listing 4.3 Vollständige Berechnung des Tilgungsplans
89
4
Arithmetik
Dazu einige Erklärungen:
(A) In einer Schleife wird Monat für Monat bearbeitet. Für jeden Monat werden die
Anweisungen (B–I) ausgeführt. Die Schleife endet, wenn keine Restschuld mehr
besteht, das Darlehen also vollständig getilgt ist.
(B) Zunächst wird die laufende Nummer des Monats ausgegeben. Die Feldbreite für
die Ausgabe wird durch die Zahl 5 in der Formatanweisung festgelegt. Es erfolgt kein
Zeilenvorschub. Alle Ausgaben für einen Monat erscheinen in der gleichen Zeile.
(C) Jetzt wird der zur Anwendung kommende zins ermittelt. Vor Ablauf der Zinsbindung ist dies zins1, danach zins2. Bei der Ausgabe des Zinssatzes wird eine spezielle
Formatanweisung für Gleitkommazahlen verwendet, die die Feldbreite (10) und die
Anzahl der Nachkommastellen (2) festlegt.
(D) Hier werden die auf die Restschuld fälligen Zinsen berechnet.
(E) Dann wird die Tilgung nach der oben hergeleiteten Formel berechnet und ausgegeben.
(F) Der Darlehensrest wird um die Tilgung gemindert.
(G) Hier wird festgestellt, ob eine Sondertilgung fällig ist. Eine Sondertilgung ist fällig,
wenn die Monatszahl ohne Rest durch 12 teilbar ist. Wir verwenden hier den Operator %, der den Rest einer Division ermittelt. Das Ergebnis von monat % 12 ist 0, wenn ein
komplettes Jahr abgelaufen ist und eine Sonderzahlung geleistet wird. Vor der Ausgabe wird noch dafür gesorgt, dass die Sondertilgung nicht höher als der Darlehensrest ausfällt. Zur formatierten Ausgabe der Sondertilgung siehe Punkt C.
(H) Jetzt wird auch noch die Sondertilgung vom Darlehensrest abgezogen. Der jetzt
noch verbleibende Betrag wird entsprechend formatiert ausgegeben.
(I) Ein Zeilenvorschub schließt die Ausgabezeile für einen Monat ab.
In einem konkreten Lauf erfragt das Programm zunächst alle für das Darlehen relevanten Daten.
Darlehen:
Nominalzins:
Monatsrate:
Zinsbindung (Jahre):
Zinssatz nach Bindung:
Jaehrliche Sondertilgung:
100000
6.5
3000
1
8.0
10000
Im Anschluss wird dann der zugehörige Tilgungsplan erzeugt:
90
4.1
Folgen
Tilgungsplan:
Monat
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
Zinssatz
6.50
6.50
6.50
6.50
6.50
6.50
6.50
6.50
6.50
6.50
6.50
6.50
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
8.00
Zinsen
541.67
528.35
514.96
501.50
487.97
474.36
460.68
446.93
433.10
419.19
405.21
391.16
397.37
380.02
362.55
344.97
327.27
309.45
291.51
273.45
255.28
236.98
218.56
200.02
114.68
95.45
76.08
56.59
36.97
17.22
Tilgung Sondertilg Rest
2458.33
0.00 97541.66
2471.65
0.00 95070.02
2485.04
0.00 92584.98
2498.50
0.00 90086.48
2512.03
0.00 87574.45
2525.64
0.00 85048.80
2539.32
0.00 82509.48
2553.07
0.00 79956.41
2566.90
0.00 77389.51
2580.81
0.00 74808.70
2594.79
0.00 72213.91
2608.84 10000.00 59605.07
2602.63
0.00 57002.44
2619.98
0.00 54382.45
2637.45
0.00 51745.00
2655.03
0.00 49089.97
2672.73
0.00 46417.23
2690.55
0.00 43726.68
2708.49
0.00 41018.20
2726.55
0.00 38291.65
2744.72
0.00 35546.93
2763.02
0.00 32783.91
2781.44
0.00 30002.46
2799.98 10000.00 17202.48
2885.32
0.00 14317.16
2904.55
0.00 11412.61
2923.92
0.00
8488.70
2943.41
0.00
5545.29
2963.03
0.00
2582.26
2582.26
0.00
0.00
4
Das Beispiel zeigt, wie einfach Sie in einer Programmschleife eine iterativ definierte
Folge berechnen können, ohne sich Gedanken über eine explizite Darstellung der
Folge machen zu müssen. Das Beispiel zeigt auch, dass Sie eine Aufgabenstellung
zunächst mit Papier und Bleistift analysieren sollten, bevor Sie mit der Programmierung beginnen.
Im Prinzip handelt es sich bei dem oben dargestellten Programm um die Simulation
eines endlichen Prozesses. Sie wissen ja, dass die Schuld irgendwann vollständig
getilgt ist, wenn jeden Monat ein gewisser Mindestbetrag getilgt wird. Manchmal
91
4
Arithmetik
haben Sie es aber auch mit Prozessen zu tun, bei denen es nicht von vornherein klar
ist, dass sie enden oder dass sich ein stabiles Ergebnis einstellt. Einem solchen Prozess wollen wir uns jetzt zuwenden.
Wenn Sie bei einem einfachen Problem auf eine Gleichung wie x ·x = 10 stoßen, können Sie diese Gleichung mit den arithmetischen Grundoperationen nicht lösen, da
Sie zur Lösung ja die Wurzel ziehen müssen. Bei einem Taschenrechner drücken Sie
einfach auf die » «-Taste und erhalten:
10 = 3.162...
Das ist natürlich nur ein Näherungswert, und Sie müssen davon ausgehen, dass der
exakte Wert im endlichen Zahlenmodell des Computers nicht vorkommt. Um eine
Näherungslösung zu finden, folgen Sie einer uralten Idee des griechischen Mathematikers Heron2. Für die alten Griechen war Mathematik im Wesentlichen Geometrie3,
und auch das »Ziehen der Wurzel« war für sie ein geometrisches Problem:
Gesucht ist die Kantenlänge eines Quadrats, das eine vorgegebene Fläche a (z. B.
a = 10) hat.
Wir nennen die gesuchte Lösung w und starten mit einer mehr oder weniger willkürlichen ersten Näherung:
w0 = a
Wenn wir w0 als eine Seitenlänge eines Rechtecks auffassen, das die Fläche a haben
a
soll, müssen wir ----w0- als Länge der anderen Seite wählen. Das ist sicher noch eine ungenügende Annäherung an ein Quadrat, aber wenn wir im nächsten Schritt den Mittelwert aus den beiden Kantenlängen wählen, wird unser Rechteck schon deutlich
quadratischer:
1
a
w 1 = -- ⎛ w 0 + --------⎞
2⎝
w ⎠
0
Das setzen wir jetzt einfach so fort:
1
a
w 2 = -- ⎛ w 1 + -------⎞
2⎝
w1 ⎠
Abbildung 4.1 zeigt die Entwicklung unserer Folge, die sich offensichtlich längs des
10
Funktionsgraphen f ( x ) = ------ an das Ziel 10 herantastet:
x
2 Heron von Alexandria lebte und lehrte vermutlich im 1. Jahrhundert n. Chr. in Alexandria.
3 Die Algebra stammt zwar auch aus Griechenland, wurde aber erst ca. 300 Jahre nach Heron entdeckt.
92
4.1
10
ƒ( x) =
Folgen
10
x
9
8
4
7
10 = 3,162
6
5
4
3
w
²
w1
2
w0
1
0
0
1
2
3
4
5
6
7
8
9
10
Abbildung 4.1 Entwicklung der Folge
Die Folge
⎧
a
⎪
w n = ⎨ --1 ⎛
a
--------------⎞
⎪ 2 ⎝ wn – 1 + w
⎠
n–1
⎩
falls n = 0
falls n = 1, 2, ...
scheint eine gute Annäherung an den Zielwert w = a zu liefern. Sie probieren das
mit einem Programm aus, wobei Sie den Wert a für die zu berechnende Wurzel vom
Benutzer eingeben lassen. Sie wissen allerdings noch nicht, wie oft Sie die Iteration
durchführen müssen, bis das Ergebnis genau genug ist. Versuchen Sie es zunächst
mit zehn Durchläufen:
93
4
Arithmetik
void main()
{
float a, w;
int i;
printf( "Bitte Zahl eingeben: ");
scanf( "%f", &a);
w = a;
for( i = 0; i < 10; i++)
{
w = (w + a/w)/2;
printf( "%f\n", w);
}
}
Listing 4.4 Berechnung der Wurzel durch Iteration
Dieses Programm arbeitet dann wie folgt:
Bitte Zahl eingeben: 10
5.500000
3.659091
3.196005
3.162456
3.162278
3.162278
3.162278
3.162278
3.162278
3.162278
Das ist eine sehr gute Näherung, offensichtlich hätten sogar weniger Schleifendurchläufe ausgereicht. Aber das Programm selbst kann Ihnen nicht sagen, ob es allgemein
(d. h. nicht nur für 10) funktioniert. Selbst weitere Tests könnten Sie nicht zufriedenstellen, da immer ein Restzweifel bestehen bleibt. Eine befriedigende Antwort kann
Ihnen nur die Mathematik geben. Sie muss Ihnen zwei Fragen beantworten, bevor Sie
diesem Programm trauen können:
Konvergiert dieses Verfahren allgemein – und wenn ja, gegen welchen Wert?
Die Mathematik sagt4:
w n ≥ w n + 1 ≥ a ( für n = 1, 2, ... )
94
4.1
Folgen
Die Mathematik sagt auch, dass solche Folgen, die monoton fallen und nach unten
beschränkt sind, konvergieren. Wenn Sie sicher sind, dass das Verfahren konvergiert,
können Sie sehr einfach den Grenzwert ermitteln. Wir nennen den Grenzwert w und
machen in der Formel
1
a
w n = -- ⎛ w n – 1 + --------------⎞
2⎝
wn – 1 ⎠
4
auf beiden Seiten den »Grenzübergang ins Unendliche« und erhalten für w die folgende Gleichung:
1
a
w = -- ⎛ w + ----⎞
2⎝
w⎠
Aus dieser Gleichung folgt unmittelbar:
w2 = a
Also:
w=
a
Nach diesen Überlegungen sind Sie sicher, dass Sie das Verfahren nach einem Schritt
mit der Bedingung
wn · wn – a < fehlerschranke
abbrechen können. Der Schleifenzähler wird nicht mehr benötigt:
void main()
{
float a, w;
printf( " Bitte Zahl eingeben: ");
scanf( "%f", &a);
w = a;
for( ; ; )
{
w = (w + a/w)/2;
printf( " %f\n", w);
4 Wenn Sie der mathematische Beweis interessiert, schauen Sie im Internet unter dem Stichwort
»Heron-Verfahren« nach.
95
4
Arithmetik
if( w*w – a < 0.001)
break;
}
}
Listing 4.5 Iteration mit Abbruchkriterium
Jetzt bricht das Programm ab, sobald die geforderte Genauigkeit erreicht ist:
Bitte Zahl eingeben: 10
5.500000
3.659091
3.196005
3.162456
Das Programm zur Berechnung der Wurzel ist ein einfaches Beispiel für ein sogenanntes numerisches Verfahren. Numerische Verfahren sind sehr eng mit der
Mathematik verknüpft und werden eingesetzt, wenn Probleme aus der »analogen«
Welt in der diskreten Welt eines Computers simuliert und gelöst werden sollen. Denken Sie dabei an Wetter- oder Klimasimulationen, an die Simulation eines Tsunamis
oder des dynamischen Fahrverhaltens eines Autos. Allein das Gebiet der numerischen Verfahren ist so umfangreich, dass die Literatur dazu ganze Bibliotheken füllt.
4.2
Summen und Produkte
Am Ende des vorangegangenen Kapitels hatte ich Ihnen die Aufgabe gestellt zu
berechnen, aus wie vielen Steinen die folgende Legotreppe besteht, wenn Sie von
einer beliebigen Höhe h ausgehen:
1
2
3
4
Abbildung 4.2 Legotreppe
96
4.2
Summen und Produkte
Das iterative Bildungsgesetz für die Anzahl der Steine ist schnell gefunden:
0
⎧
sh = ⎨
s
⎩ h–1+h
für h = 0
für h > 0
Das bedeutet:
4
s0 = 0
s1 = 1
s2 = 1 + 2 = 3
s3 = 1 + 2 + 3 = 6
s4 = 1 + 2 + 3 + 4 = 10
s5 = 1 + 2 + 3 + 4 + 5 = 15
...
sh = 1 + 2 + 3 + 4 + 5 + ··· + h = ?
Sie können den gesuchten Wert iterativ durch ein C-Programm berechnen:
void main()
{
int max =9;
int steine = 0;
int h;
for ( h=1; h<= max; h= h + 1)
{
steine = steine + h;
printf(" Hoehe: %d, Steine = %d\n", h, steine);
}
}
Listing 4.6 Berechnung der Treppenelemente
Mit dem Programm erhalten Sie das folgende Ergebnis:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
Hoehe:
1,
2,
3,
4,
5,
6,
7,
8,
9,
Steine
Steine
Steine
Steine
Steine
Steine
Steine
Steine
Steine
=
=
=
=
=
=
=
=
=
1
3
6
10
15
21
28
36
45
97
4
Arithmetik
Aber Sie sind in diesem Fall auch in der Lage, eine explizite Formel anzugeben. Dazu
bauen Sie die gleiche Treppe noch einmal – auf dem Kopf stehend – neben die zu
untersuchende Treppe:
h+1
h
Abbildung 4.3 Zwei angeordnete Legotreppen
Sie sehen, dass jeweils h+1 Steine in h Schichten übereinander vorhanden sind und
dass das doppelt so viele Steine sind, wie in einer Treppe benötigt werden. Es gilt also:
( h + 1 )h
sh = 0 + 1 + 2 + 3 + 4 + 5 + ··· + h = -------------------2
Dieser Formel, der sogenannten gaußschen Summenformel, werden Sie häufiger
begegnen, da sie eine wichtige Rolle bei der Beurteilung von Algorithmen spielt.
Wir können die Addition in der gaußschen Summenformel durch eine Multiplikation ersetzen und uns das folgende Bildungsgesetz anschauen:
1
⎧
fn = ⎨
f
⎩ n–1⋅n
Auch diese Folge
f0 = 1
f1 = 1
f2 = 1 · 2 = 2
f3 = 1 · 2 · 3 = 6
98
für n = 0
für n > 0
4.2
Summen und Produkte
f4 = 1 · 2 · 3 · 4 = 24
f5 = 1 · 2 · 3 · 4 · 5 = 120
...
fn = 1 · 2 · 3 · 4 · 5 · … · n
können wir durch ein C-Programm berechnen:
4
void main()
{
int max = 9;
int f = 1;
int n;
for ( n=1; n<= max; n= n + 1)
{
f = f * n;
printf(" %d! = %d\n", n,f);
}
}
Listing 4.7 Berechnung der Fakultäten
Das Programm erzeugt diese Ausgabe:
1!
2!
3!
4!
5!
6!
7!
8!
9!
=
=
=
=
=
=
=
=
=
1
2
6
24
120
720
5040
40320
362880
Diese Folge ist so wichtig, dass man ihr einen eigenen Namen gegeben hat. Es ist die
Folge der Fakultäten. Das einzelne Folgenglied zum Index n nennen wir n-Fakultät
und schreiben dafür »n!«. Also:
0! = 1
1! = 1
2! = 1 · 2 = 2
3! = 1 · 2 · 3 = 6
4! = 1 · 2 · 3 · 4 = 24
0-Fakultät
1-Fakultät
2-Fakultät
3-Fakultät
4-Fakultät
99
4
Arithmetik
5! = 1 · 2 · 3 · 4 · 5 = 120
...
n! = 1 · 2 · 3 · 4 · 5 · ... · n
5-Fakultät
n-Fakultät
Auch diese Folge wird Ihnen bei der Programmierung häufig begegnen.
4.3
Aufgaben
A 4.1 Schreiben Sie ein Programm, das zu einem gegebenen Anfangskapital und
einem jährlichen Zinssatz berechnet, wie viele Jahre benötigt werden, damit
das Kapital eine bestimmte Zielsumme überschreitet!
A 4.2 Den größten gemeinsamen Teiler (ggT) von zwei natürlichen Zahlen können
Sie berechnen, indem Sie so lange die kleinere Zahl von der größeren Zahl
abziehen, bis beide Zahlen gleich sind. Sie möchten z. B. den ggT von 152 und
56 berechnen. Dann gehen Sie wie folgt vor:
152 – 56 = 96
96 – 56 = 40
56 – 40 = 16
40 – 16 = 22
22 – 16 = 8
16 – 8 = 8 = ggT
Erstellen Sie ein Programm, das mit diesem Algorithmus den ggT berechnet!
A 4.3 Sie haben zwei ausreichend große Eimer. Im ersten befinden sich x, im zweiten y Liter Wasser. Sie füllen nun immer a Prozent des Wassers aus dem ersten
in den zweiten und anschließend b Prozent des Wassers aus dem zweiten in
den ersten Eimer. Diesen Umfüllprozess führen Sie n-mal durch. Erstellen Sie
ein Programm, das nach Eingabe der Startwerte (x, y, a, b und n) die Füllstände
der Eimer nach jedem Umfüllen ermittelt und auf dem Bildschirm ausgibt!
Welche Aufteilung des Wassers ergibt sich auf lange Sicht für unterschiedliche
Startwerte?
A 4.4 In einem Schulbezirk gibt es 1200 Planstellen für Lehrer. Diese unterteilen sich
derzeit in 40 Studiendirektoren, 160 Oberstudienräte und 1000 Studienräte.
Alle drei Jahre ist eine Beförderung möglich, dabei steigen jeweils 10 % der
Oberstudienräte und 20 % der Studienräte in die nächsthöhere Gruppe auf.
Darüber hinaus gehen 20 % einer jeden Gruppe innerhalb von drei Jahren in
den Ruhestand. Die dadurch frei werdenden Planstellen werden mit Studienräten besetzt. Schreiben Sie ein Programm, das die bestehende Situation in
Dreijahreszyklen fortschreibt! Welche Verteilung von Direktoren, Oberräten
und Räten ergibt sich auf lange Sicht? Drehen Sie an der »Beförderungs-
100
4.3
Aufgaben
schraube« für Oberstudienräte und Studienräte, um andere Verteilungen zu
erreichen!
A 4.5 Epidemien (z. B. Grippewellen) breiten sich in der Bevölkerung nach gewissen
Gesetzmäßigkeiten aus. Die Bevölkerung zerfällt im Verlauf einer Epidemie in
drei Gruppen. Als Gesunde bezeichnen wir Menschen, die mit dem Krankheitserreger noch nicht in Berührung gekommen sind und deshalb ansteckungsgefährdet sind. Kranke sind Menschen, die akut infiziert und
ansteckend sind. Immunisierte schließlich sind Menschen, die die Krankheit
überstanden haben und weder ansteckend noch ansteckungsgefährdet sind.
Als Ausgangssituation betrachten wir eine feste Population von x Menschen,
unter denen sich bereits eine gewisse Anzahl y von Kranken befindet:
gesund0 = x – y
krank0 = y
immun0 = 0
Ausgehend von diesen Daten, wollen wir die Ausbreitung der Krankheit in
Zeitsprüngen von einem Tag berechnen. Wir überlegen uns dazu, welche Veränderungen von Tag zu Tag auftreten. Es gibt zwei Arten von Übergängen zwischen den Gruppen. Aus Gesunden werden Kranke (Infektion), und aus
Kranken werden Immune (Immunisierung).
Die Zahl der Infektionen ist proportional zur Zahl der Gesunden und proportional zum Anteil der Kranken in der Gesamtbevölkerung. Denn je mehr
Gesunde es gibt, desto mehr Menschen können sich anstecken, und je mehr
Ansteckende es gibt, desto mehr Menschen können angesteckt werden. Mit
einem geeigneten Proportionalitätsfaktor (Infektionsrate) nimmt daher die
Zahl der Gesunden ständig ab:
gesund n krank n
gesund n + 1 = gesund n – infektionsrate ------------------------------------------x
Die Zahl der Immunisierungen ist proportional zur Zahl der Kranken, denn je
mehr Menschen erkrankt sind, desto mehr Menschen erlangen Immunität. Mit
einem geeigneten Proportionalitätsfaktor (Immunisierungsrate) gilt daher:
immunn+1 = immunn + immunisierungsrate · krankn
Der Rest der Population ist krank.
krankn+1 = x – gesundn+1 – immunn+1
Die Proportionalitätsfaktoren (Infektionsrate und Immunisierungsrate) hängen dabei von medizinisch-sozialen Faktoren wie Art der Krankheit, hygienische Bedingungen, Bevölkerungsdichte, medizinische Versorgung etc. ab und
101
4
Arithmetik
können daher nur empirisch ermittelt werden. Sind Ihnen diese Faktoren aber
aus der Kenntnis früherer Epidemien her bekannt, können Sie mit einem einfachen Programm den Verlauf der Krankheitswelle vorausberechnen. Erstellen Sie das Programm, und ermitteln Sie den Verlauf einer Epidemie mit den
folgenden Basisdaten:
Infektionsrate:
Immunisierungsrate:
Gesamtpopulation:
Akut Kranke:
Anzahl Tage:
0.6
0.06
2000
10
25
Abbildung 4.4 zeigt für die oben genannten Basisdaten das epidemische
Anwachsen des Krankenstandes, bis dem Virus der Nährboden entzogen wird
und der Krankenstand langsam wieder abfällt:
2000
1800
1600
1400
1200
1000
800
600
400
200
Immun
Krank
Gesund
0
0
4
8
12
16
20
24
28
32
36
40
44
48
52
56
60
64
68
72
76
80
84
88
92
96
100
4
Abbildung 4.4 Epidemieverlauf
A 4.6 Der belgische Mathematiker Viktor d’Hondt entwickelte 1882 ein Verfahren,
um zu einem Wahlergebnis die zugehörige Sitzverteilung für ein Parlament
zu berechnen. Dieses Verfahren (d’Hondtsches Höchstzahlverfahren) wurde
bis 1983 verwendet, um die Sitzverteilung für den Deutschen Bundestag festzulegen.
Zur Durchführung des Verfahrens werden die Stimmergebnisse der Parteien
fortlaufend durch die Zahlen 1, 2, 3, 4, ... dividiert. Sind n Sitze im Parlament zu
102
4.3
Aufgaben
vergeben, werden die n größten Divisionsergebnisse ausgewählt, und die
zugehörigen Parteien erhalten für jede ausgewählte Zahl einen Sitz. Das folgende Beispiel zeigt das Ergebnis einer Wahl mit drei Parteien und 200000
abgegebenen Stimmen, bei der zehn Sitze zu vergeben waren:
Partei A
Partei B
Partei C
Stimmen
100000
80000
20000
1
100000
80000
20000
2
50000
40000
10000
3
33333
26666
6666
4
25000
20000
5000
5
20000
16000
4000
6
16666
13333
3333
7
14285
11429
2857
8
12500
10000
2500
Sitze
5
4
1
4
Tabelle 4.1 Stimmen und Sitzverteilung nach d’Hondt
Schreiben Sie ein Programm, das für eine beliebige Wahl mit drei Parteien die
Sitzverteilung berechnet! Die Anzahl der zu vergebenden Sitze und die Stimmen für die drei Parteien sollen dabei vom Benutzer eingegeben werden.
A 4.7 Im folgenden Zahlenkreis stehen die Buchstaben jeweils für eine Ziffer.
C
B
A
H
a
h
G
b c
g f
d
e
D
E
F
Abbildung 4.5 Zahlenkreis
103
4
Arithmetik
Bestimmen Sie diese Ziffern (1 bis 9) so, dass folgende Bedingungen erfüllt
werden:
왘
Aa, Bb, Cc, Dd, Ee, Ff, Gg und Hh sind Primzahlen.
왘
ABC ist ein Vielfaches von Aa.
왘
abc ist gleich cba.
왘
CDE ist Produkt von Cc mit der Quersumme von CDE.
왘
Bb ist gleich der Quersumme von cde.
왘
EFG ist ein Vielfaches von Aa.
왘
efg ist Produkt von Aa mit der Quersumme von efg.
왘
GHA ist Produkt von eE mit der Quersumme von ABC.
왘
Die Quersumme von gha ist Cc.
Zeigen Sie durch ein Programm, dass es genau eine mögliche Ziffernzuordnung gibt, und bestimmen Sie diese!
A 4.8 Erstellen Sie ein Programm, das zu einer vom Benutzer eingegebenen Zahl die
Primzahlzerlegung ermittelt
Zahl: 13230
13230 = 2*3*3*3*5*7*7
und auf dem Bildschirm ausgibt!
A 4.9 Wichtige mathematische Funktionen können näherungsweise durch Summen (man nennt dies Potenzreihenentwicklung) berechnet werden.
Zum Beispiel:
3
5
7
2
4
6
x
x x
sin ( x ) = x – ----- + ----- – ----- + …
3! 5! 7!
x
x
x
cos ( x ) = 1 – ----- + ------ – ------ + ...
2! 4! 6!
2
3
x
x
x
e = 1 + x + ----- + ----- + …
2! 3!
Die im Nenner der Brüche vorkommenden Fakultäten kennen Sie ja aus
Abschnitt 4.2, »Summen und Produkte«.
Erstellen Sie auf diesen Formeln basierende Berechnungsprogramme für
Sinus, Cosinus und e-Funktion! Überprüfen Sie die Ergebnisse Ihrer Programme mit einem Taschenrechner!
104
4.3
Aufgaben
A 4.10 Erstellen Sie Programme, um den Steinverbrauch für die in Abbildung 4.6
abgebildete Treppe und die beiden Pyramiden zu berechnen. Die Pyramiden
sind dabei innen nicht hohl.
4
Abbildung 4.6 Pyramiden
Versuchen Sie, auch explizite Formeln für den Steinverbrauch herzuleiten.
Vergleichen Sie die iterativ berechneten Ergebnisse mit den durch die expliziten Formeln gegebenen Zahlen.
105
Kapitel 5
Aussagenlogik
Logiker: Alle Katzen sind sterblich. Sokrates ist gestorben. Also ist
Sokrates eine Katze.
Älterer Herr: Ich habe eine Katze, die heißt Sokrates.
Logiker: Sehen Sie ...
Älterer Herr: Sokrates war also eine Katze!
Logiker: Die Logik hat es uns eben bewiesen.
– Aus »Die Nashörner« von Eugène Ionesco
5
Eine ganze Nacht habe ich mich mit der Frage gequält, wie ich in die Thematik dieses
Abschnitts einsteigen soll. Als ich heute beim Frühstück saß, war plötzlich alles ganz
einfach, denn in meiner Morgenlektüre fand ich den folgenden Artikel:
Abbildung 5.1 Anzeige wegen 40 Cent (aus der NRZ)
Natürlich handelt es sich bei dieser Frage um eine Scherzfrage, aber wie geht der Logiker mit solchen Sätzen um? Etwa mit dem folgenden Satz:
Wenn fünf Ochsen in fünf Minuten fünf Liter Milch geben, dann gibt es den
Osterhasen.
107
5
Aussagenlogik
Ist dieser Satz falsch, weil Ochsen keine Milch geben? Oder ist er falsch, weil es den
Osterhasen nicht gibt? Oder ist er vielleicht sogar richtig? Und wenn er richtig ist, ist
dann die Existenz des Osterhasen bewiesen? Mit so wichtigen Fragen werden wir uns
in diesem Abschnitt beschäftigen, und Sie werden auch wieder einiges an Programmierung lernen.
5.1
Aussagen
Die Aussagenlogik beschäftigt sich, wie nicht anders zu erwarten, mit Aussagen.
Unter einer Aussage verstehen wir einen Satz, der entweder wahr oder falsch ist. Wir
müssen nicht wissen, ob der Satz wahr oder falsch ist, wir müssen ihm nur prinzipiell
zugestehen, dass er wahr oder falsch ist. Genau genommen, interessieren wir uns
nicht einmal dafür, ob der Satz wahr oder falsch ist. Und ganz genau genommen,
interessieren wir uns nicht einmal dafür, was »wahr« und »falsch« inhaltlich bedeutet. Wir können jederzeit 0 oder 1 anstelle von »falsch« oder »wahr« sagen. Insofern
betreiben wir Logik als ein rein formales System ohne Bezug zur Realität.
Konkrete Aussagen sind z. B.:
»Köln liegt in Deutschland.«
»Köln hat mehr als 1 Mio. Einwohner.«
Keine Aussagen im Sinne unserer Begriffsbildung sind dagegen:
»Guten Tag, meine Damen und Herren!«
»Wie spät ist es?«
Bei der Programmierung haben wir es nicht mit umgangssprachlichen Aussagen,
sondern mit präzise formulierten Aussagen in einer Programmiersprache zu tun wie
»wert < 10« oder »a + b < c«. Wir wollen uns deshalb auch nicht zu weit auf das glatte
Eis umgangssprachlicher Aussagen hinausbegeben.
5.2
Aussagenlogische Operatoren
Durch die Aussagenlogik möchten wir nicht ergründen, ob eine Aussage wirklich
wahr oder falsch ist. Im Falle der Aussage über die Einwohnerzahl Kölns wäre dafür
auch eher das Einwohnermeldeamt als die Logik zuständig. Wir möchten aus elementaren Aussagen, deren Wahrheitswert wir als gegeben annehmen, komplexere
Aussagen zusammensetzen und uns Gedanken über den Wahrheitswert dieser
neuen Aussagen machen.
Eine zusammengesetzte Aussage ist etwa:
»Köln liegt in Deutschland und Köln hat mehr als 1 Mio. Einwohner.«
108
5.2
Aussagenlogische Operatoren
Der Wahrheitswert dieser zusammengesetzten Aussage hängt von den Wahrheitswerten der Einzelaussagen ab. Unser Sprachgefühl sagt uns, dass die Gesamtaussage
richtig ist, wenn beide Teilaussagen richtig sind. Kennen wir also den Wahrheitswert
der Teilaussagen, kennen wir auch den Wahrheitswert der Gesamtaussage.
Das Wort »und«, mit dem wir die Teilaussagen verbunden haben, ist ein sogenannter
logischer Operator. Unsere Sprache kennt viele weitere solcher Operatoren, die wir
täglich benutzen, ohne uns vielleicht jemals deren genaue Bedeutung klargemacht zu
haben. Als ein Beispiel betrachten wir den Operator »während«. Diesen Operator
benutzen wir in verschiedenen Bedeutungen, zum einen, um einen schwachen
Gegensatz, zum anderen, um einen gleichzeitigen Verlauf auszudrücken. Die Aussage
»Mein Auto ist rot, während dein Auto grün ist.«
heißt eigentlich nichts anderes als: »Mein Auto ist rot und dein Auto ist grün«. Dies
allerdings mit dem deutlichen Zusatz: »Man beachte den feinen Unterschied«.
Die Aussage
»Es regnete, während ich im Kino war.«
dagegen beschreibt den zeitlichen Verlauf zweier Ereignisse. Im ersten Fall macht der
Operator »während« einen subtilen Zusatz, der oft nur aus dem Zusammenhang und
für eingeweihte Zuhörer zu verstehen ist. Im zweiten Fall kann der Wahrheitswert
der Gesamtaussage nicht aus den Wahrheitswerten der einzelnen Aussagen abgeleitet werden, weil der Operator eine Zusatzaussage über die zeitliche Parallelität der
Einzelaussagen macht. Beide Varianten des Operators »während« sind für unsere
Zwecke ungeeignet, denn wir wollen hier nur Operatoren behandeln, bei denen sich
der resultierende Wahrheitswert zweifelsfrei aus den Wahrheitswerten der beteiligten Einzelaussagen herleiten lässt.
Weitere Beispiele für umgangssprachliche Operatoren sind:
왘
nicht ...
왘
... oder ...
왘
weder ... noch ...
왘
wenn ... dann ...
왘
zwar ... aber ...
왘
entweder ... oder ...
왘
sowohl ... als auch ...
Mit umgangssprachlichen Formulierungen sind wir wegen der häufig auftretenden
Fehlinterpretationen nicht zufrieden. Wir werden daher im Folgenden einige Präzisierungen vornehmen müssen.
Zunächst benutzen wir für die Wahrheitswerte »wahr« bzw. »falsch« die Symbole 1
bzw. 0. Für Aussagen setzen wir Großbuchstaben A, B, C etc. oder A1, A2. »Die Aussage
109
5
5
Aussagenlogik
A ist wahr« heißt dann in Formelschreibweise A = 1. Umgekehrt heißt A = 0: »Die
Aussage A ist falsch«.
Die drei wichtigsten Operatoren sind sicher: nicht, und und oder. Da die Operanden
eines logischen Operators nur die Werte 0 oder 1 annehmen, können wir einen logischen Operator durch eine Tabelle vollständig beschreiben: Eine solche Tabelle nennen wir Wahrheitstafel. Über solche Wahrheitstafeln werden wir jetzt die drei
wichtigsten Operatoren einführen. Dabei handelt es sich um: nicht, und und oder.
Die Aussage »nicht A« ist genau dann wahr, wenn die ursprüngliche Aussage A falsch
ist. Damit ergibt sich für den Nicht-Operator folgende Wahrheitstafel:
A
nicht A
0
1
1
0
Tabelle 5.1 Wahrheitstafel für den Nicht-Operator
Anstelle von »nicht A« schreiben wir in Formeln auch A oder !A.
Wir hatten bereits festgestellt, dass eine Und-Aussage genau dann wahr ist, wenn
beide Teilaussagen wahr sind. Den Operator »und« definieren wir also über die folgende Wahrheitstafel:
A
B
A und B
0
0
0
0
1
0
1
0
0
1
1
1
Tabelle 5.2 Wahrheitstafel für den Und-Operator
Auch für diesen Operator verwenden wir spezielle Formelsymbole. Anstelle von A
und B schreiben wir auch A ∧ B oder A && B.
Bleibt noch das »oder«, für das wir die Notationen A oder B, A ∨ B und A || B verwenden:
A
B
A oder B
0
0
0
0
1
1
1
0
1
1
1
1
Tabelle 5.3 Wahrheitstafel für den Oder-Operator
110
5.2
Aussagenlogische Operatoren
Eine Aussage, die aus zwei mit »oder« verbundenen Teilaussagen besteht, ist also
genau dann wahr, wenn mindestens eine der beiden Teilaussagen wahr ist.
An dieser Definition erhitzen sich gelegentlich die Gemüter. Vielfach wird gefordert,
dass die Aussage »A oder B« falsch zu sein habe, wenn A und B beide wahr sind. Dies
entspricht dem Operator »entweder ... oder ...«. Die deutsche Sprache1 trennt leider
nicht sauber zwischen »oder« und »entweder ... oder«. Vielfach wird dort, wo eigentlich »entweder ... oder ...« gemeint ist, einfach nur »oder« verwendet. In aller Regel ist
das unproblematisch, weil zumeist aus dem Zusammenhang klar ist, welcher der beiden Operatoren gemeint ist, oder weil sich die Alternativen sowieso gegenseitig ausschließen.
So bedeutet die Frage
»Sollen wir um 8 Uhr oder um 10 Uhr ins Kino gehen?«
in aller Regel:
»Sollen wir entweder um 8 Uhr oder um 10 Uhr ins Kino gehen?«
Der Fall, dass beide Alternativen gewählt werden, wird dabei von vornherein ausgeschlossen. Im strengen Sinne unseres Gebrauchs des Operators »oder« schließt die
erste Formulierung der Frage aber nicht aus, sowohl um 8 als auch um 10 ins Kino zu
gehen. Seien Sie also immer vorsichtig! Wenn Sie ein Logiker auf der Straße mit den
Worten »Geld oder Leben!« überfällt, und Sie geben Ihm das Geld, kann er immer
noch Ihr Leben nehmen, ohne wortbrüchig zu werden. Bestehen Sie also in dieser
Situation auf der Formulierung »Entweder Geld oder Leben«, und geben Sie erst
dann das Geld. Zum Schluss aber noch ein Beispiel dazu, dass wir auch in unserer
Umgangssprache das nicht ausschließende »oder« ganz selbstverständlich benutzen. Wenn Sie etwa an der Grenze gefragt werden
»Haben Sie Ihren Pass oder Ihren Personalausweis dabei?«,
würden Sie dann mit Nein antworten, wenn Sie zufällig beide Dokumente eingesteckt haben?
Mit den Bausteinen »nicht«, »und« und »oder« können wir jetzt beliebig komplexe
logische Ausdrücke zusammensetzen. Aber noch immer lässt die Umgangssprache
zu viel Interpretationsspielraum. Wenn etwa die Zollvorschriften besagen, dass man
entweder 1 Liter Spirituosen oder 5 Liter Bier und eine Stange Zigaretten
importieren darf, ist die Frage, ob
1 Die lateinische Sprache kennt z. B. »vel« für das nicht ausschließende und »aut« für das ausschließende »oder«.
111
5
5
Aussagenlogik
(entweder 1 Liter Spirituosen oder 5 Liter Bier) und eine Stange Zigaretten
oder
entweder 1 Liter Spirituosen oder (5 Liter Bier und eine Stange Zigaretten)
gemeint ist. Vermutlich das Erstere. Diese Vermutung leitet sich aber nicht aus dem
logischen Gerüst der Aussage, sondern aus der Tatsache ab, dass es sich bei Spirituosen und Bier um ähnliche und daher vielleicht austauschbare Dinge handelt. Soll ein
Logiker es aufgrund dieser vagen Annahme riskieren, mit 1 Liter Spirituosen und
einer Stange Zigaretten die Grenze zu überqueren? Dies wäre zwar bei der ersten
Interpretation erlaubt, bei der zweiten aber verboten.
Wir müssen präziser sein und Operatoren so definieren, dass immer eine eindeutige
Auswertungsreihenfolge gegeben ist. Dazu geben wir in gemischten Ausdrücken
»nicht« eine höhere Priorität als »und« und »und« eine höhere Priorität als »oder«.
Wollen wir eine andere Auswertungsreihenfolge erzwingen, setzen wir Klammern.
Das Ergebnis zusammengesetzter Ausdrücke kann mit diesen Zusatzregeln einfach
ermittelt werden, indem zunächst die Wahrheitswerte der Teilausdrücke und dann
sukzessive die Wahrheitswerte zusammengesetzter Ausdrücke ermittelt werden. Als
Beispiel wählen wir den Ausdruck (A ∨ B) ∧ (C ∨ A):
C˅A
( A˅ B ) ˄ (C ˅ A)
A
B
C
A
A ˅B
0
0
0
1
1
0
0
0
0
1
1
1
1
1
0
1
0
1
1
0
0
0
1
1
1
1
1
1
1
0
0
0
0
1
0
1
0
1
0
0
1
0
1
1
0
0
1
1
1
1
1
1
0
1
1
1
oder
und
nicht
oder
Abbildung 5.2 Wahrheitstafel eines zusammengesetzten Ausdrucks
112
5.2
Aussagenlogische Operatoren
Wenn Sie die Skizze unter der Tabelle an eine elektrische Schaltung erinnert, ist dieser Eindruck durchaus gewollt. Große Teile des Innenlebens eines Computers setzen
sich aus Schaltungen zusammen, die nach den Prinzipien der Aussagenlogik arbeiten.
Von besonderem Interesse sind für uns verschiedene Ausdrücke, die die gleichen
Werte in ihrer Wahrheitstabelle haben, denn solche Ausdrücke können wir in einer
Formel austauschen, ohne den logischen Gehalt der Formel zu ändern. Als Beispiel
betrachten wir die Ausdrücke A ∧ B bzw. A ∨ B.
A
B
A∧B
A∨B
0
0
1
1
0
1
1
1
1
0
1
1
1
1
0
0
Tabelle 5.4 Wahrheitstafel gleichwertiger Ausdrücke
Beide Ausdrücke beschreiben also die gleiche logische Funktion. Das war auch zu
erwarten, denn der Satz »Nicht beide Autos sind rot« ist logisch gleichwertig mit
»Eines der beiden Autos ist nicht rot«. Die Sätze sind nicht gleich, aber gleichwertig.
Das kennen Sie ja auch schon aus der Arithmetik: Die Formeln (a + b)2 und a2 + 2ab +
b2 sind auch nicht gleich (im Sinne von identisch), aber in jeder algebraischen Formel
können Sie den einen Ausdruck durch den anderen ersetzen. Ebenso können Sie jetzt
in jeder logischen Formel den Ausdruck A ∧ B durch A ∨ B ersetzen. Wir sprechen in
diesem Zusammenhang auch von logischer Äquivalenz oder Gleichheit. Um dies in
Formeln ausdrücken zu können, führen wir einen neuen Operator – den Äquivalenzoperator – ein:
A
B
A⇔B
0
0
1
0
1
0
1
0
0
1
1
1
Tabelle 5.5 Wahrheitstafel für den Äquivalenzoperator
113
5
5
Aussagenlogik
Um Klammern zu sparen, wollen wir vereinbaren, dass ⇔ schwächer bindet als die
zuvor eingeführten Operatoren. Dann können wir die Äquivalenz von A ∧ B und
A ∨ B auch durch eine Formel ausdrücken:
A∧B⇔A∨B
Dieser Ausdruck ist unabhängig von den Wahrheitswerten von A und B immer wahr.
Formeln, die unabhängig vom Wahrheitsgehalt der Elementaraussagen immer wahr
sind, bezeichnen wir als Tautologien. Tautologien haben in der Aussagenlogik die
gleiche Bedeutung wie wichtige Identitäten (z. B. binomische Formeln) in der Algebra. Einige wichtige Tautologien sind im Folgenden zusammengestellt:
Logische Äquivalenzen
A ˄ ( B ˄ C ) ⟺ ( A˄ B ) ˄ C
A ˅ ( B ˅ C ) ⟺ ( A˅ B ) ˅ C
A˄ B ⟺ B˄ A
A˅ B ⟺ B˅ A
( A˅ B ) ˄ A ⟺ A
( A˄ B ) ˅ A ⟺ A
A ˄ ( B ˅ C ) ⟺ ( A˄ B ) ˅ ( A˄ C )
A ˅ ( B ˄ C ) ⟺ ( A˅ B ) ˄ ( A˅ C )
A ˄ (B ˅ B ) ⟺ A
A ˅ (B ˄ B ) ⟺ A
A˄ A ⟺ A
A˅ A ⟺ A
A˄ B ⟺ A˅B
A˅ B ⟺ A˄B
A˄ A ⟺ 0
A˅ A ⟺ 1
Assoziativgesetz
Kommutativgesetz
Verschmelzungsgesetz
Distributivgesetz
Komplementgesetz
Idempotenzgesetz
De Morgansches Gesetz
A⟺A
Abbildung 5.3 Wichtige Tautologien
Diese Formeln eröffnen Ihnen die Möglichkeit, mit logischen Ausdrücken wie mit
algebraischen Formeln zu rechnen. Teilweise ähneln diese Formeln sehr stark Formeln, die Sie aus der Algebra kennen.
Einen letzten Operator, den Implikationsoperator, möchten wir Ihnen noch vorstellen und dafür das Symbol ⇒ verwenden:
A
B
A⇒B
0
0
1
0
1
1
1
0
0
1
1
1
Tabelle 5.6 Wahrheitstafel des Implikationsoperators
114
5.2
Aussagenlogische Operatoren
Dieser Operator ist sehr eng mit unserer logischen Schlussfolgerungsweise (wenn A
gilt, dann gilt auch B) verwandt. Trotzdem sollten Sie die Implikation nicht mit einem
logischen Schluss verwechseln. Wenn Sie sagen:
Wenn Köln 1 Mio. Einwohner hat, dann liegt Köln in Deutschland,
ist diese Aussage im Sinne zweier mit dem Implikationsoperator verbundener Teilaussagen gemäß obiger Wahrheitstafel wahr. Keinesfalls ist damit aber gemeint, dass
es eine Kausalität gibt, die besagt, dass Städte mit mehr als 1 Mio. Einwohnern immer
in Deutschland liegen. Wenn Köln mehr als 1 Mio. Einwohner hätte, könnte man der
Formulierung
Köln hat mehr als 1 Mio. Einwohner, also liegt Köln in Deutschland
sicherlich nicht zustimmen, da eine derartige Kausalität nicht besteht. Besteht allerdings eine kausale Beziehung, wie etwa in
wenn eine Zahl kleiner als 5 ist, dann ist sie auch kleiner als 10,
dann gilt auch die Implikation für alle konkret eingesetzten Zahlen, da der Fall einer
wahren Aussage links und einer falschen Aussage rechts vom Implikationspfeil
durch den Kausalzusammenhang ausgeschlossen ist.
Beachten Sie, dass wir eine Implikation als wahr definiert haben, wenn die Prämisse
– das ist die Aussage links vom Implikationspfeil – falsch ist; und zwar völlig unabhängig davon, was auf der rechten Seite folgt. Auch hier gibt es oft Widerspruch, weil
die Implikation unausgesprochen als Äquivalenz verstanden wird. Ein in diesem
Zusammenhang häufig zu beobachtender Fehler ist es, dass die Aussagen A ⇒ B und
A ⇒ B als gleichwertig angesehen werden. Eine Betrachtung der Wahrheitstafeln
zeigt aber, dass eine solche Gleichsetzung falsch ist. Wenn ich z. B. sage:
Wenn morgen die Sonne scheint, dann gehe ich ins Schwimmbad,
dann heißt das nicht, dass ich bei Regen nicht ins Schwimmbad gehe. Ich habe mich
für diesen Fall nicht festgelegt. Hätte ich mich auch in diesem Fall festlegen wollen,
hätte ich eine Äquivalenzaussage formulieren müssen:
Ich gehe morgen genau dann ins Schwimmbad, wenn die Sonne scheint.
Aber wer formuliert schon so gestelzt?
Auch für die Implikation gilt eine Reihe von Rechenregeln. Die drei vielleicht wichtigsten zeigt die folgende Tabelle:
( A ⟹ B ) ⟺ ( A˅ B)
(A ⟹ B ) ⟺ ( B ⟹ A)
( A ⟺ B ) ⟺ ( A ⟹ B ) ˄ (B ⟹ A )
Abbildung 5.4 Rechenregeln für die Implikation
115
5
5
Aussagenlogik
Die erste Regel zeigt, wie wir eine Implikation durch »nicht« und »oder« ausdrücken
können. Die zweite ermöglicht, eine Implikation »rückwärts« zu lesen. Die dritte formuliert einen naheliegenden Zusammenhang zwischen Implikation und Äquivalenz.
Jetzt können Sie übrigens die Eingangsfrage dieses Kapitels beantworten: »Wenn
fünf Ochsen in fünf Minuten fünf Liter Milch geben, dann gibt es den Osterhasen«.
Dieser Satz ist aussagenlogisch wahr, was aber keine Auswirkungen auf die Milchproduktion von Ochsen oder die Existenz des Osterhasen hat.
5.3
Boolesche Funktionen
Bevor wir uns wieder der Programmierung zuwenden, wollen wir uns Gedanken darüber machen, wie weit die logischen Operatoren uns denn tragen. Ein logischer Operator realisiert eine Funktion, und die Wahrheitstafel ist eigentlich nur eine
vollständige Wertetabelle dieser Funktion. Wenn Sie sich z. B. die Funktion
z = f(x,y) = x ∧ y
anschauen, dann handelt es sich um eine Funktion, die zwei logische Werte (x und y)
übergeben bekommt und daraus einen logischen Wert (z) berechnet. So eine Funktion nennen wir eine zweiststellige boolesche Funktion.
Hier stehen alle
möglichen Wertkombinationen für
die Eingabeparameter.
a1 a2 a3
a4
z = f(a1, a2, a3, a4 )
0
0
0
0
1
0
0
0
1
0
0
0
1
0
1
0
0
1
1
1
0
1
0
0
0
0
1
0
1
0
0
1
1
0
0
0
1
1
1
1
1
0
0
0
1
1
0
0
1
0
1
0
1
0
1
1
0
1
1
1
1
1
0
0
0
1
1
0
1
0
1
1
1
0
0
1
1
1
1
1
Abbildung 5.5 Eine vierstellige boolesche Funktion
116
Dies ist eine vierstellige
boolesche Funktion.
Dies ist ein
Funktionsergebnis
z = f(0, 1, 1, 0).
5.3
Boolesche Funktionen
Allgemein können wir n-stellige boolesche Funktionen betrachten. Letztlich ist eine
n-stellige boolesche Funktion durch eine Tabelle mit n Eingabespalten und einer
Ausgabespalte gegeben. In der Tabelle stehen nur 0 und 1 (siehe Abbildung 5.5).
Die Anzahl der Zeilen einer solchen Tabelle ist abhängig von der Anzahl der Eingabespalten. Bei vier Eingabespalten haben wir 16 Zeilen. Die Zahl der Zeilen verdoppelt
sich mit jeder hinzukommenden Spalte, sodass wir bei n Spalten 2n Zeilen in der
Tabelle haben. In jeder Zeile können wir dann einen Funktionswert angeben, sodass
n
wir insgesamt 2 ( 2 ) n-stellige boolesche Funktionen aufstellen können. Wenn Sie
noch kein Gefühl für das Wachstum dieser Zahl haben, dann betrachten Sie die
Tabelle in Abbildung 5.6.
n
n
2 (2 )
0
2
1
4
2
16
3
256
4
65536
5
4294967296
6
1,84467E+19
7
3,40282E+38
8
1,15792E+77
9
1,3408E+154
So viele fünfstellige
boolesche
Funktionen gibt es.
Abbildung 5.6 Anzahl n-stelliger boolescher Funktionen
Trotz dieser schieren Menge sind wir in der Lage, alle booleschen Funktionen mit
unseren drei logischen Grundoperatoren »nicht«, »und« und »oder« zu berechnen.
Wie das geht, zeige ich Ihnen an einem Beispiel. Dabei sollten Sie darauf achten, dass
sich das Vorgehen problemlos auf jedes beliebige andere Beispiel übertragen lässt.
Wir betrachten das Kugelspiel aus dem letzten Kapitel.
117
5
5
Aussagenlogik
1
0
A
0
1
0
B
1
C
1
0
D
1
0
A
B
C
D
z = f(A,B,C,D)
0
0
0
0
0
0
0
0
1
0
0
0
1
0
0
0
0
1
1
1
0
1
0
0
0
0
1
0
1
0
0
1
1
0
0
0
1
1
1
1
1
0
0
0
0
1
0
0
1
1
1
0
1
0
0
1
0
1
1
1
1
1
0
0
1
1
1
0
1
1
1
1
1
0
1
A ˄ B˄ C ˄D
A ˄ B˄ C ˄D
1
1
1
1
1
A ˄ B ˄ C˄ D
A ˄ B˄C ˄ D
A ˄ B˄C ˄ D
A ˄ B ˄ C˄ D
A ˄ B ˄ C˄ D
A ˄ B˄ C ˄D
z = A ˄ B ˄ C ˄ D ˅ A ˄ B ˄ C ˄ D ˅ A ˄ B ˄ C˄ D ˅ A ˄ B ˄ C˄ D
˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D
Abbildung 5.7 Darstellung des Kugelspiels
Für das Spiel erstellen Sie eine Wahrheitstafel, indem Sie alle 16 möglichen Weichenstelllungen in Gedanken durchspielen und den Auslauf notieren. Dann betrachten
Sie in der Tabelle die Zeilen, in denen Sie eine 1 als Ergebnis erhalten haben. Für diese
Zeilen gibt es eine einfache Darstellung ausschließlich mit »und« und »nicht«. Am
Ende sammeln Sie diese Terme durch eine Oder-Verknüpfung ein. Jede 1 in der Wertespalte der Funktionstabelle triggert damit genau einen Term, der eine 1 erzeugt.
Diese 1 sorgt dann dafür, dass sich in dieser Situation insgesamt eine 1 als Funktionsergebnis ergibt.
Um die Lesbarkeit unserer Formeln zu verbessern, lassen wir das ∧-Zeichen in den
Formeln einfach weg und erhalten:
z = A BCD ∨ ABCD ∨ AB CD ∨ ABCD ∨ ABC D ∨ ABCD ∨ ABCD ∨ ABCD
Diese Formel ist relativ komplex, und Sie können versuchen, sie zu vereinfachen.
Dazu gibt es Techniken, wie z. B. die sogenannten Karnaugh-Diagramme, die wir hier
aber nicht behandeln werden. Letztlich werden boolesche Funktionen mit zunehmender Stellenzahl so komplex, dass sie sich nur noch mit Computerunterstützung
optimieren lassen. Die Optimierung komplexer boolescher Funktionen ist ein ganz
118
5.4
Logische Operatoren in C
wichtiger Aspekt beim Entwurf digitaler Schaltungen – ohne Computerunterstützung könnte man solche Schaltungen heute nicht mehr entwickeln. Die wichtige
Erkenntnis für uns ist ja, dass wir jede boolesche Funktion – und sei sie noch so komplex – mit unseren drei Grundoperatoren2 realisieren können.
Vielleicht finden wir ja eine Ad-hoc-Vereinfachung, wenn wir noch einmal einen
Blick auf das Spiel werfen:
5
0
1
A
1
0
0
B
1
C
1
0
D
1
0
Abbildung 5.8 Suche nach Vereinfachungen
Wir müssen untersuchen, wann die Kugel den Ausgang 1 nimmt. Sie sehen, dass,
wenn A = 1 und B = 1 ist, alle Kugeln zum Ausgang 1 gelenkt werden, egal, wie die beiden anderen Weichen stehen. Wenn A = 1 und B = 0 ist oder wenn A = 0 und C = 1 ist,
geht die Kugel durch die Mitte, und Weiche D entscheidet, wo sie letztlich hingeht. In
allen anderen Fällen ist der Ausgang 0.
z = A B ∨ (A B ∨ A C)D
Im nächsten Abschnitt erfahren Sie, wie Sie in C boolesche Funktionen erstellen,
danach werden wir dieses Spiel programmieren.
5.4
Logische Operatoren in C
Als Variable für boolesche Werte können Sie in C einfach int verwenden. Als logische
Operatoren gibt es nur das »nicht«, das »und« und das »oder«, aber Sie wissen ja
2 Wenn Sie sich schon einmal mit digitalen Schaltungen beschäftigt haben, wissen Sie, dass es
sogar einen einzigen Operator gibt, mit dem man alle Schaltungen aufbauen kann; wenn nicht,
dann versuchen Sie, diesen Operator zu finden.
119
5
Aussagenlogik
schon, dass Sie mehr nicht brauchen. C verwendet die folgenden Zeichen für die logischen Operatoren:
Operator
Darstellung in C
nicht
!
und
&&
oder
||
Tabelle 5.7 Logische Operatoren in C
In der Auswertung boolescher Ausdrücke folgt C den Regeln, die wir oben bereits aufgestellt haben: ! vor && vor ||. Im Zweifel setzen Sie Klammern.
Das ist eigentlich schon alles, was Sie wissen müssen, um mit logischen Ausdrücken
zu programmieren.
5.5
Beispiele
Unsere Kenntnisse über die boolesche Algebra und die logischen Operatoren in C fassen wir jetzt zusammen, um zwei kleine Programmieraufgaben zu lösen.
5.5.1
Kugelspiel
Für das Kugelspiel hatten wir zwei Lösungsformeln hergeleitet:
z = A BCD ∨ ABCD ∨ AB CD ∨ ABCD ∨ ABC D ∨ ABCD ∨ ABCD ∨ ABCD
Und
z = A B ∨ (A B ∨ A C)D
Beide lassen sich einfach in C-Code umsetzen. Wir wollen beide Lösungen vergleichen und geben dazu die gesamte Funktionstabelle mit den beiden berechneten
Werten aus:
void main()
{
int A, B, C, D;
int z1, z2;
120
5.5
Beispiele
for( A = 0; A <= 1 ; A++)
{
for( B = 0; B <= 1 ; B++)
{
for( C = 0; C <= 1 ; C++)
{
for( D = 0; D <= 1 ; D++)
{
z1 = A&&B || (A&&!B || !A&&C) && D;
z2 = !A&&!B&&C&&D || !A&&B&&C&&D || A&&!B&&!C&&D ||
A&&!B&&C&&D || A&&B&&!C&&!D || A&&B&&!C&&D ||
A&&B&&C&&!D || A&&B&&C&&D;
printf( "%d %d %d %d | %d %d\n", A, B, C, D, z1, z2);
}
}
}
}
}
5
Listing 5.1 Erstellung aller Kombinationen
Neu ist für Sie vielleicht die Technik, mit der hier durch vier ineinander geschachtelte
Schleifen die Tabelle erzeugt wird. Aber das ist ganz einfach:
왘
A durchläuft die Werte 0 und 1.
왘
Für jeden Wert von A durchläuft dann B die Werte 0 und 1. Damit ergeben sich alle
Wertekombinationen von A und B.
왘
Für jede Wertekombination von A und B durchläuft dann C die Werte 0 und 1.
Damit ergeben sich alle Dreierkombinationen.
왘
Für jede Dreierkombination durchläuft dann D die Werte 0 und 1. Damit ergeben
sich alle Viererkombinationen.
Bei diesem Prozess bewegt sich A am trägsten und D am hektischsten. Auf diese Weise
entstehen die Kombinationen genau in der Reihenfolge, in der wir sie bisher auch
immer notiert haben, und wir sehen, dass die in der innersten Schleife berechneten
logischen Werte exakt den Erwartungen entsprechen (siehe Abbildung 5.9).
Das Umschlagen der Weichen können wir hier übrigens nicht mehr simulieren, da
dieses Verhalten in diesem booleschen Modell nicht mehr abgebildet ist.
121
5
Aussagenlogik
0
1
A
0
0
1
B
1
C
1
0
D
1
0
A
B
C
D
z = f(A,B,C,D)
0
0
0
0
0
0
0
0
1
0
0
0
1
0
0
0
0
1
1
1
0
1
0
0
0
0
1
0
1
0
0
1
1
0
0
0
1
1
1
1
1
0
0
0
0
1
0
0
1
1
1
0
1
0
0
1
0
1
1
1
1
1
0
0
1
1
1
0
1
1
1
1
1
0
1
1
1
1
1
1
0
0
0
0
0
0
0
0
1
1
1
1
1
1
1
1
0
0
0
0
1
1
1
1
0
0
0
0
1
1
1
1
0
0
1
1
0
0
1
1
0
0
1
1
0
0
1
1
0
1
0
1
0
1
0
1
0
1
0
1
0
1
0
1
:
:
:
:
:
:
:
:
:
:
:
:
:
:
:
:
0
0
0
1
0
0
0
1
0
1
0
1
1
1
1
1
0
0
0
1
0
0
0
1
0
1
0
1
1
1
1
1
Abbildung 5.9 Berechnete Werte für das Kugelspiel
5.5.2
Schaltung
Als eine weitere Anwendung der Aussagenlogik wollen wir ein Programm schreiben,
das alle Schalterstellungen, bei denen in der folgenden Schaltung die Lampe leuchtet,
tabellarisch ausgibt.
s1
s2
s3
s4
s5
s7
s6
Abbildung 5.10 Beispiel für eine Lampenschaltung
Wir verwenden für jeden der Schalter S1–7 eine Variable, die jeweils die Werte 0 oder
1 annehmen kann. Dabei bedeutet:
1 – Der Schalter ist geschlossen.
0 – Der Schalter ist geöffnet.
122
5.5
Beispiele
Zusätzlich wissen wir:
왘
Hintereinanderliegende Schalter realisieren eine Und-Verbindung.
왘
Parallel liegende Schalter realisieren eine Oder-Verbindung.
Für unsere Schaltung bedeutet dies:
s3
s1
oder
s2
und
und
s4
5
oder
s5
oder
und
s7
s6
Abbildung 5.11 Umsetzung der Schaltung
Damit können wir den Zustand der Lampe (1 = an, 0 = aus) als eine boolesche Funktion der Schalterstellungen darstellen. In C-Notation erhalten wir also:
lampe = (s1 || s2) && ((s3 && s4) || ((s5 || s6) && s7))
Jetzt müssen wir alle möglichen Schalterstellungen generieren und dann jeweils prüfen, ob die Lampe brennt. Alle 128 möglichen Schalterstellungen erzeugen wir, indem
wir in sieben ineinander geschachtelten Zählschleifen alle Schalter jeweils auf 0 bzw.
1 setzen. Diese Methode kennen Sie bereits aus der letzten Aufgabe.
void main()
{
int s1, s2, s3, s4, s5, s6, s7;
int lampe;
printf( "s1 s2 s3 s4 s5 s6 s7\n");
for( s1 = 0; s1 <= 1; s1 = s1 + 1)
{
for( s2 = 0; s2 <= 1; s2 = s2 + 1)
{
for( s3 = 0; s3 <= 1; s3 = s3 + 1)
{
for( s4 = 0; s4 <= 1; s4 = s4 + 1)
{
for( s5 = 0; s5 <= 1; s5 = s5 + 1)
{
123
5
Aussagenlogik
for( s6 = 0; s6 <= 1; s6 = s6 + 1)
{
for( s7 = 0; s7 <= 1; s7 = s7 + 1)
{
lampe = (s1||s2)&&((s3&&s4)||((s5||s6)&&s7));
if( lampe == 1)
printf( " %d %d %d %d %d %d %d\n",
s1, s2, s3, s4, s5, s6, s7);
}
}
}
}
}
}
}
}
Listing 5.2 Durchlaufen aller Schalterstellungen
Wenn Sie in der innersten Schleife erkennen, dass die Lampe leuchtet (lampe == 1),
geben Sie die zugehörigen Schalterstellungen aus. Sie erhalten eine Liste mit insgesamt 51 gültigen Schalterstellungen, von denen ein Teil hier dargestellt ist (siehe
Abbildung 5.12).
s1
0
0
0
0
0
0
0
0
0
0
0
0
0
…
s2
1
1
1
1
1
1
1
1
1
1
1
1
1
…
s3
0
0
0
0
0
0
1
1
1
1
1
1
1
…
s4
0
0
0
1
1
1
0
0
0
1
1
1
1
…
s5
0
1
1
0
1
1
0
1
1
0
0
0
0
…
s6
1
0
1
1
0
1
1
0
1
0
0
1
1
…
s7
1
1
1
1
1
1
1
1
1
0
1
0
1
…
Abbildung 5.12 Ausschnitt aus dem Ergebnis
Die Methode zur Generierung der Schalterkombinationen lässt sich durch eine Verzweigungsstruktur, die einem auf den Kopf gestellten Baum ähnelt, veranschaulichen (siehe Abbildung 5.13).
An jedem Verzweigungspunkt (Schalter) gibt es die Möglichkeit, nach links (0) oder
nach rechts (1) zu gehen. Jeder Weg durch den Baum entspricht genau einer Schalterkombination. Unser Programm sucht also in einer vollständigen Baumsuche unter
124
5.5
Beispiele
allen möglichen Wegen diejenigen heraus, die die gewünschte Eigenschaft haben. Eine
spezielle Lösung ist in Abbildung 5.13 hervorgehoben.
0
s1
s2
1
0
5
1
s3
1
s4
s5
0
0
s6
1
s7
1
Abbildung 5.13 Veranschaulichung der Lösungsstruktur
Viele der Programme, die wir hier betrachten, verwenden die Lösungsstrategie einer
vollständigen Baumsuche. Das liegt daran, dass sich bei abstrakter Betrachtung von
Problemen häufig Bäume als natürliche Modelle zur Beschreibung des Problem- oder
Lösungsraums anbieten. Die Diskussion von Bäumen wird daher im Laufe dieses
Buches noch breiten Raum einnehmen.
Ein Phänomen, das uns sehr zu schaffen machen wird, lässt sich an diesem Beispiel
bereits erahnen. Schauen Sie sich die Anzahl der zu untersuchenden Schalterkombinationen an, werden Sie feststellen, dass sich deren Zahl mit Hinzunahme eines
neuen Schalters jeweils verdoppelt, obwohl im Programmcode nur eine Schleife, die
zwei Werte durchläuft, hinzukommt. Verantwortlich dafür ist die Tiefe der Schachtelung, die mit jedem Schalter um 1 zunimmt. Bei Hinzunahme eines Schalters ist dann
aber auch mit einer Verdopplung der Laufzeit des Programms zu rechnen. Wenn Sie
sich vorstellen, dass Ihr Rechner zur Untersuchung einer Schalterkombination eine
bestimmte Zeiteinheit benötigt, werden zur Analyse einer Schaltung mit 20 Schaltern 1048576, bei 50 Schaltern bereits 1.26 · 1015 Zeiteinheiten benötigt. Wenn Sie
zusätzlich annehmen, dass die Analyse einer Schalterkombination 1/1000 sec dauert,
würde für die Analyse einer Schaltung mit 50 Schaltern ein Zeitraum von mehr als
35000 Jahren benötigt. Für wirklich große Schaltungen wird kein noch so schneller
Rechner der Welt diese Art der Schaltungsanalyse in akzeptabler Zeit durchführen
können. Wir sind mit diesem einfachen Beispiel bereits auf das Problem der »kombinatorischen Explosion« gestoßen, mit dem wir uns noch eingehend beschäftigen
werden.
125
5
Aussagenlogik
5.6
Aufgaben
A 5.1
Wir definieren einen neuen logischen Operator nand durch folgende Wahrheitstafel:
A
B
A nand B
0
0
1
0
1
1
1
0
1
1
1
0
Tabelle 5.8 Wahrheitstafel des nand-Operators
Zeigen Sie, dass man beliebige boolesche Funktionen unter alleiniger Verwendung des nand-Operators darstellen kann!
Hinweis: Es reicht, wenn Sie zeigen, dass man »nicht«, »und« und »oder« darstellen kann.
A 5.2 Erstellen Sie ein Programm, das Wahrheitstafeln für die folgenden booleschen
Ausdrücke auf dem Bildschirm ausgibt:
1. ( A ∧ B ) ⇒ ( C ∨ D )
2. A ∧ B ∨ C ∧ D
3. A ⇒ B ⇒ ( C ∨ D )
4. ( A ∨ B ) ∧ ( A ∨ C ) ∧ D
Beachten Sie, dass Sie eine Implikation X ⇒ Y durch X ∨ Y ausdrücken können!
A 5.3 Überprüfen Sie die folgenden Tautologien aus Abschnitt 5.2, »Aussagenlogische Operatoren« durch C-Programme.
Logische Äquivalenzen
A ˄ ( B ˄ C ) ⟺ ( A˄ B ) ˄ C
A ˅ ( B ˅ C ) ⟺ ( A˅ B ) ˅ C
A˄ B ⟺ B˄ A
A˅ B ⟺ B˅ A
( A˅ B ) ˄ A ⟺ A
( A˄ B ) ˅ A ⟺ A
A ˄ ( B ˅ C ) ⟺ ( A˄ B ) ˅ ( A˄ C )
A ˅ ( B ˄ C ) ⟺ ( A˅ B ) ˄ ( A˅ C )
A ˄ (B ˅ B ) ⟺ A
A ˅ (B ˄ B ) ⟺ A
A˄ A ⟺ A
A˅ A ⟺ A
A˄ B ⟺ A˅B
A˅ B ⟺ A˄B
A˄ A ⟺ 0
A˅ A ⟺ 1
A⟺A
Abbildung 5.14 Übersicht der Tautologien
126
Assoziativgesetz
Kommutativgesetz
Verschmelzungsgesetz
Distributivgesetz
Komplementgesetz
Idempotenzgesetz
De Morgansches Gesetz
5.6
Aufgaben
A 5.4 Die Schaltung aus Aufgabe 5.2 wird dahingehend abgeändert, dass zwei Schalter miteinander gekoppelt werden und eine neue Leitung gelegt wird.
Finden Sie eine möglichst einfache boolesche Funktion für diese Schaltung,
und erstellen Sie ein Programm, das alle Schalterstellungen ausgibt, in denen
die Lampe leuchtet!
s1
s2
s3
5
s4
s5
s7
s6
Abbildung 5.15 Schaltung mit gekoppelten Schaltern
A 5.5 Familie Müller ist zu einer Geburtstagsfeier eingeladen. Leider können sich
die Familienmitglieder (Anton, Berta, Claus und Doris) nicht einigen, wer hingeht und wer nicht. In einer gemeinsamen Diskussion kann man sich jedoch
auf die folgenden fünf Grundsätze verständigen:
1. Mindestens ein Familienmitglied geht zu der Feier.
2. Anton geht auf keinen Fall zusammen mit Doris.
3. Wenn Berta geht, dann geht Claus mit.
4. Wenn Anton und Claus gehen, dann bleibt Berta zu Hause.
5. Wenn Anton zu Hause bleibt, dann geht entweder Doris oder Claus.
Helfen Sie Familie Müller, indem Sie ein Programm erstellen, das alle Konstellationen ermittelt, in denen Familie Müller zur Feier gehen könnte.
A 5.6 Bankdirektor Schulze hat den Tresor seiner Bank durch ein elektronisches
Schloss sichern lassen. Dieses Schloss kann über neun Kippschalter geöffnet
werden, wenn man diese in die richtige Stellung (»unten« oder »oben«)
bringt. Da sich der Bankdirektor die richtige Schalterkombination nicht merken kann und bereits mehrfach einen Fehlalarm ausgelöst hat, hat er sich den
folgenden Merkzettel erstellt:
1. Wenn Schalter 3 auf »oben« gestellt wird, dann müssen sowohl Schalter 7
als auch Schalter 8 auf »unten« gestellt werden.
2. Wenn Schalter 1 auf »unten« gestellt wird, dann muss von den Schaltern 2
und 4 mindestens einer auf »unten« gestellt werden.
127
5
Aussagenlogik
3. Von den beiden Schaltern 1 und 6 muss mindestens einer auf »unten« stehen.
4. Wenn Schalter 6 auf »unten« gestellt wird, dann müssen 7 auf »unten« und
5 auf »oben« stehen.
5. Falls sowohl Schalter 9 auf »unten« als auch Schalter 1 auf »oben« gestellt
werden, dann muss 3 auf »unten« stehen.
6. Von den Schaltern 8 und 2 muss mindestens einer auf »oben« stehen.
7. Wenn Schalter 3 auf »unten« oder Schalter 6 auf »oben« steht oder beides
der Fall ist, dann müssen Schalter 8 auf »unten« und Schalter 4 auf »oben«
stehen.
8. Falls Schalter 9 auf »oben« steht, dann müssen Schalter 5 auf »unten« und
Schalter 6 auf »oben« stehen.
9. Wenn Schalter 4 auf »unten« steht, dann müssen Schalter 3 auf »unten«
und Schalter 9 auf »oben« stehen.
Schreiben Sie ein C-Programm, das den Tresor knackt!
A 5.7 Der Wikipedia habe ich das folgende Beispiel einer sogenannten Entscheidungstabelle entnommen:
Eine Entscheidungstabelle besteht aus vier Teilbereichen:
E
E
E
E
einer Auflistung der zu berücksichtigenden Bedingungen
einer Auflistung der möglichen Aktionen
einem Bereich, in dem die möglichen Bedingungskombinationen zusammengestellt sind
einem Bereich, in dem jeder Bedingungskombination die jeweils durchzuführenden
Aktivitäten zugeordnet sind
Tabellenbezeichnungen R1 R2 R3 R4 R5 R6 R7 R8
Bedingungen
Lieferfähig?
j
j
j
j
n
n
n
Angaben vollständig?
j
j
n
n
j
j
n
n
n
Bonität in Ordnung?
j
n
j
n
j
n
j
n
x
x
x
x
Aktionen
Lieferung mit Rechnung
Lieferung als Nachnahme
Angaben vervollständigen
Mitteilen: nicht lieferbar
x
x
x
x
x
x
Abbildung 5.16 Entscheidungstabelle zur Umsetzung in C
Erstellen Sie ein C-Programm, das die in der Tabelle genannten Bedingungen
abfragt und dann die erforderlichen Aktionen ausgibt.
128
Kapitel 6
Elementare Datentypen und ihre
Darstellung
Das Buch der Natur ist mit mathematischen Symbolen geschrieben.
– Galileo Galilei
6
Jemand bittet Sie, sich eine geheime Zahl zwischen 0 und 31 zu denken, und legt
Ihnen dann nacheinander die folgenden fünf Karten vor:
B
2
3
6
7
10
11
14
15
18
19
26
22
27
5
30
12
13
23
21
31
29
22
30
7
15
28
23
26
25
24
31
29
27
30
31
18
21
23
26
29
19
22
25
28
27
25
21
14
13
12
24
29
13
6
17
19
17
28
5
20
9
20
15
11
4
31
15
14
16
1
9
8
11
10
E
3
D
23
7
A
C
27
30
31
Abbildung 6.1 Zahlenraten
Er fordert Sie auf, jeweils zu sagen, ob die gedachte Zahl auf der Karte steht oder nicht.
Nachdem Sie die Fragen beantwortet haben, nennt er Ihnen, ohne lange zu zögern,
Ihre Geheimzahl.
129
6
Elementare Datentypen und ihre Darstellung
Versuchen Sie, hinter diesen Trick zu kommen. Wenn es Ihnen nicht gelingt, dann
lesen Sie aufmerksam das folgende Kapitel, denn Sie erfahren dort mehr über den
Hintergrund dieses Tricks. Im Laufe dieses Kapitels werden wir den Trick auflösen
und Ihnen zeigen, wie Sie ihn programmieren.
6.1
Zahlendarstellungen
Zahlen sind abstrakte mathematische Objekte. Damit man sie konkret benutzen kann,
brauchen sie eine »Benutzerschnittstelle«, mittels derer man sie addieren, multiplizieren oder vergleichen kann. Die denkbar einfachste Benutzerschnittstelle erhält
man, wenn man für die Eins einen Strich und für jede folgende Zahl jeweils einen weiteren Strich macht. Solche Darstellungen werden allerdings sehr schnell unübersichtlich, sodass man zur besseren Lesbarkeit Gruppierungen einführen muss:
Abbildung 6.2 Zahlendarstellung und Gruppierung
Dieses Strichsystem kennt im Prinzip nur eine Operation (Addition von 1) und hat in
dieser Beschränkung durchaus seine Vorteile, sodass wir es heute noch – z. B. auf
Bierdeckeln – verwenden. Wirklich große Zahlen lassen sich dadurch allerdings nicht
darstellen, sodass man gezwungen ist, zur Abkürzung zusätzliche Symbole einzuführen. So ist es z. B. im römischen Zahlensystem, aber rechnen Sie bitte mal CCCLXXVII
+ DCXXIII. Das römische Zahlensystem verwendet man heute nur noch aus nostalgischen Gründen – etwa auf den Zifferblättern von Uhren oder für Jahreszahlen in
Kalendern.
Heute werden zur Darstellung von Zahlen sogenannte Stellenwertsysteme verwendet. Im Alltag nutzen wir das Dezimalsystem:
Ziffernwerte
4711 = 4 · 103 + 7 · 102 + 1 · 101 + 1 · 100
Stellenwerte
Abbildung 6.3 Darstellung im Dezimalsystem
Diese Darstellung beruht auf der Zahl 10 als Basis. Im Grunde genommen müssten
Sie die Basiszahl an der Ziffernfolge vermerken (z. B. 471110), denn nur mithilfe der
Basis können Sie aus der Ziffernfolge den Zahlenwert rekonstruieren.
130
6.1
Zahlendarstellungen
Sie können jede andere natürliche Zahl größer als 1 als Basis verwenden – z. B. die Zahl
7. Sie erhalten dann nur eine andere Ziffernfolge:
Ziffernwerte
Basis 7
471110 = 1 · 74 + 6 · 73 + 5 · 72 + 1 · 71 + 0 · 70 = 165107
Basis 10
Stellenwerte
6
Abbildung 6.4 Darstellung im 7er-System
Wie kommen Sie zu dieser neuen Ziffernfolge? Wir formulieren den oben genannten
Ausdruck durch Ausklammern um:
471110 = (((1 · 7 + 6) · 7 + 5) · 7 + 1) · 7 + 0
Jetzt sehen Sie, dass Sie die Ziffernwerte erhalten, indem Sie die Reste bei Division
durch 7 betrachten. Also dividieren Sie 4711 fortlaufend durch die Basiszahl 7 und
notieren sich die Reste. Das ergibt die gesuchte Ziffernfolge – allerdings in umgekehrter Reihenfolge, da die niederwertigste Ziffer zuerst berechnet wird:
4711
673
96
13
7
= 673 · 7 +
0
= 96 · 7 +
1
= 13 · 7 +
5
=
1·7 +
6
=
0·7 + 1
16510
Abbildung 6.5 Umrechnung auf eine andere Basis
Eigentlich ist im 7er-System alles genauso wie im 10er-System, außer dass es nur die
Ziffern 0–61 gibt und dass beim Zählen immer bei 6 ein Übertrag erfolgt.
10er-System
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
7er-System
0
1
2
3
4
5
6
10
11
12
13
14
15
16
20
21
22
Abbildung 6.6 Vergleich des Dezimalsystems und des 7er-Systems
Auch rechnen können Sie im 7er-System genauso gut wie im 10er-System. Die vermeintliche Überlegenheit des Dezimalsystems ergibt sich daraus, dass wir durch jahrelange Übung eine große Vertrautheit mit diesem System erworben haben und alle
1 Divisionsreste größer als 6 können bei einer Division durch 7 ja nicht vorkommen.
131
6
Elementare Datentypen und ihre Darstellung
konkreten Rechenprozesse des Alltags (z. B. Münzen und Geldscheine im Zahlungsverkehr) auf dieses System ausgerichtet sind. Sie müssen in den verschiedenen Stellenwertsystemen nicht perfekt rechnen können, sollten aber zumindest von einem
Stellenwertsystem in ein anderes umrechnen können.
Um uns die Arbeit des Umrechnens zu erleichtern, schreiben wir ein kleines Programm, das die oben beschriebene fortlaufende Division durch die Basis vornimmt:
A
void main()
{
int basis = 7;
int zahl = 4711;
int z;
printf( "Basis: %d\n", basis);
printf( "-------------------\n");
B
C
for( z = zahl ; z != 0; z = z/basis)
printf( "%4d = %4d*%d + %2d\n", z, z/basis, basis, z%basis);
printf( " -------------------\n\n");
}
Listing 6.1 Programm zur Umrechnung auf andere Basen
Die Basis, auf die umgerechnet werden soll, ist 7, kann aber geändert werden (A). Im
Verlauf des Programms wird die umzurechnende Zahl so lange durch die Basis
geteilt, bis nichts mehr übrig bleibt (B), und in der Schleife wird jeweils eine Zeile ausgegeben (C), sodass wir folgendes Ergebnis erhalten:
Basis: 7
------------------4711 = 673*7 + 0
673 =
96*7 + 1
96 =
13*7 + 5
13 =
1*7 + 6
1 =
0*7 + 1
Eigentlich besteht dieses Programm fast nur aus Ausgaben. Die wesentlichen Berechnungen verstecken sich in den beiden Ausdrücken z/basis und z%basis, in denen der
ganzzahlige Quotient und der Rest ermittelt werden. Wir spielen alle Basen von 2 bis
9 durch und erhalten die folgenden Ergebnisse:
132
6.1
Basis: 2
----------------4711 = 2355*2 + 1
2355 = 1177*2 + 1
1177 = 588*2 + 1
588 = 294*2 + 1
294 = 147*2 + 0
147 =
73*2 + 1
73 =
36*2 + 1
36 =
18*2 + 0
18 =
9*2 + 0
9 =
4*2 + 1
4 =
2*2 + 0
2 =
1*2 + 0
1 =
0*2 + 1
Basis: 3
----------------4711 = 1570*3 + 1
1570 = 523*3 + 1
523 = 174*3 + 1
174 =
58*3 + 0
58 =
19*3 + 1
19 =
6*3 + 1
6 =
2*3 + 0
2 =
0*3 + 2
Basis: 6
----------------4711 = 785*6 + 1
785 = 130*6 + 5
130 =
21*6 + 4
21 =
3*6 + 3
3 =
0*6 + 3
Basis: 7
----------------4711 = 673*7 + 0
673 =
96*7 + 1
96 =
13*7 + 5
13 =
1*7 + 6
1 =
0*7 + 1
Basis: 4
----------------4711 = 1177*4 + 3
1177 = 294*4 + 1
294 =
73*4 + 2
73 =
18*4 + 1
18 =
4*4 + 2
4 =
1*4 + 0
1 =
0*4 + 1
Zahlendarstellungen
Basis: 5
----------------4711 = 942*5 + 1
942 = 188*5 + 2
188 =
37*5 + 3
37 =
7*5 + 2
7 =
1*5 + 2
1 =
0*5 + 1
6
Basis: 8
----------------4711 = 588*8 + 7
588 =
73*8 + 4
73 =
9*8 + 1
9 =
1*8 + 1
1 =
0*8 + 1
Basis: 9
----------------4711 = 523*9 + 4
523 =
58*9 + 1
58 =
6*9 + 4
6 =
0*9 + 6
Abbildung 6.7 Ausgabe für die Basen 2 bis 9
Grundsätzlich ist es kein Problem, eine Basis größer als 10 zu verwenden. Es werden
dann aber unter Umständen Ziffernwerte größer als 10 auftauchen. Wir testen dies
mit der Basis 13:
Basis: 13
-----------------4711 = 362*13 + 5
362 = 27*13 + 11
27 = 2*13 + 1
2 = 0*13 + 2
------------------
Abbildung 6.8 Ausgabe für die Basis 13
Es ergibt sich die Ziffernfolge 2, 1, 11, 5. Diese Ziffernfolge können Sie jedoch nicht nahtlos aneinanderreihen (21115), da dann die eindeutige Zuordnung der Ziffern zu den
Stellenwerten verloren gehen würde. Wir behelfen uns dadurch, dass wir die zusätzlichen Ziffernsymbole a, b und c (oder A, B und C) für die Ziffernwerte 10, 11 und 12 einführen. Damit lautet die Darstellung der Zahl 4711 im 13er-System 21b5 oder 21B5.
Eine zentrale Frage steht aber noch im Raum. Warum machen wir das eigentlich?
Sind wir nicht mit dem Dezimalsystem glücklich und zufrieden? Die Antwort auf
diese Frage erhalten Sie im nächsten Abschnitt.
133
6
Elementare Datentypen und ihre Darstellung
6.1.1
Dualdarstellung
Sie wissen, dass ein Digitalrechner intern mit zwei Zuständen – nennen wir sie 0 und
1 – arbeitet. Alle Daten und auch Programme im Rechner bestehen in diesem Sinne
aus Folgen von 0 und 1. Der Rechner braucht eine Organisation, über die er effizient
auf die Daten und Programme zugreifen kann. Dazu wird der Speicher des Rechners
in kleine Speicherzellen unterteilt, und die Speicherzellen werden fortlaufend nummeriert. Da alles nur mit 0 und 1 dargestellt wird, können Sie sich das wie folgt vorstellen:
Speicherzelle
Wert
0
0 0 0 0
0 1 1 1 0 0 1 0
1
0 0 0 1
1 0 0 1 0 1 0 0
2
0 0 1 0
3
0 0 1 1
4
0 1 0 0
5
0 1 0 1
6
0 1 1 0
7
0 1 1 1
8
1 0 0 0
9
1 0 0 1
10
1 0 1 0
11
1 0 1 1
12
1 1 0 0
13
1 1 0 1
14
1 1 1 0
15
1 1 1 1
weitere
Werte
Abbildung 6.9 Organisation der Speicherzellen
Wenn Sie dem Rechner die Anweisung geben, Ihnen den Wert aus der Speicherzelle
13 zu geben, wird der Rechner intern die Zellennummer im 2er-System erwarten und
Ihnen auch den Wert der Speicherzelle im 2er-System zurückgeben. Wenn Sie sich
also mit den Interna des Rechners beschäftigen wollen, kommen Sie um das 2er-System – auch Dualsystem genannt – nicht herum. Für uns Menschen hat dieses System
aber erhebliche Nachteile. Wegen der kleinen Basis sind die Zahlen viel zu lang und
nur umständlich zu handhaben. Außerdem fehlt uns jegliche Größenvorstellung für
134
6.1
Zahlendarstellungen
Dualzahlen. Wenn etwa in der Zeitung ein Auto zu einem Kaufpreis von 23456 Euro
annonciert wäre, würde bei Verwendung des Dualsystems dort ein Kaufpreis von
101101110100000 Euro stehen. Bei einer 0 mehr am Ende der Ziffernfolge wäre es der
doppelte Kaufpreis. Das könnte man nur schwer erkennen.
Als Ergänzung zum Dualsystem benötigen wir dringend Zahlensysteme, die einfache
Umrechnungen ins Dualsystem erlauben, dabei aber »menschenfreundlicher« sind
als das Dualsystem.
6
6.1.2
Oktaldarstellung
Wir betrachten noch einmal unsere Lieblingszahl 4711, für die wir bereits die Dualdarstellung 1001001100111 kennen. Ich setze zwei führende Nullen hinzu und gruppiere
die Ziffern in Dreierpäckchen: 001 001 001 100 111
001 001 001 100 1112 = 0 · 214 + 0 · 213 + 1 · 212 +
0 · 211 + 0 · 210 + 1 · 29 +
0 · 28 + 0 · 27 + 1 · 26 +
1 · 25 + 0 · 24 + 0 · 23 +
1 · 22 + 1 · 21 + 1 · 20
Jetzt klammere ich in jeder Zeile die höchste vorkommende Zweierpotenz aus:
001 001 001 100 1112 = (0 · 22 + 0 · 21 + 1) · 212 +
(0 · 22 + 0 · 21 + 1) · 29 +
(0 · 22 + 0 · 21 + 1) · 26 +
(1 · 22 + 0 · 21 + 0) · 23 +
(1 · 22 + 1 · 21 + 1) · 20
In den Klammern stehen jetzt Ziffernwerte zwischen 0 und 7. Die rechnen wir aus:
001 001 001 100 1112 = 1 · 212 +
1 · 29 +
1 · 26 +
4 · 23 +
7 · 20
Da aber 23 = 8 ist, können wir auch schreiben:
001 001 001 100 1112 = 1 · 84 +
1 · 83 +
1 · 82 +
4 · 81 +
7 · 80
= 111478
135
6
Elementare Datentypen und ihre Darstellung
Damit haben wir eine einfache Umrechnung zwischen dem 8er-System (Oktalsystem) und dem 2er-System gefunden. Sie müssen einfach nur vom Ende der Zahl her
Dreiergruppen bilden und diese Dreiergruppen mit folgender Tabelle ziffernweise in
das Oktalsystem übersetzen:
Oktal
0
1
2
3
4
5
6
7
Dual
000
001
010
011
100
101
110
111
Abbildung 6.10 Darstellung im Oktalsystem
6.1.3
Hexadezimaldarstellung
Die Hexadezimaldarstellung verwendet die Basis 16 und die zusätzlichen Ziffernsymbole a, b, c, d, e und f (oder A, B, C, D, E und F), sodass wir die folgende
Übersetzungstabelle für die Ziffern des Hexadezimalsystems nutzen können.
Hexadezimal
0
1
2
3
4
5
6
7
8
9
a
b
c
d
e
f
Dual 0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111
Abbildung 6.11 Darstellung im Hexadezimalsystem
Da 16 = 24 genauso eine Potenz von 2 ist wie 8 = 23, gilt das zur Umrechnung zwischen
der Dual- und Oktaldarstellung Gesagte auch für die Umrechnung zwischen Dualund Hexadezimaldarstellung. Der einzige Unterschied ist, dass anstelle der Dreierpäckchen jetzt Viererpäckchen gebildet werden müssen. Abbildung 6.12 zeigt Ihnen
die Umrechnung an einem Beispiel:
Oktal
Dual
Hexadezimal
5
1
6
0
1
b
1
1
4
0
1
a
0
3
0
0
1
1
3
Abbildung 6.12 Oktal-, Dual- und Hexadezimalsystem in der Gegenüberstellung
Mit dem Hexadezimalsystem und dem Oktalsystem haben wir Zahlendarstellungen
gefunden, die einerseits sehr nah an der internen Zahlendarstellung des Computers
sind und andererseits der menschlichen Auffassung von Zahlen näher kommen als
das Dualsystem. Das liegt daran, dass 8 und 16 die beiden der 10 am nächsten liegenden Zweierpotenzen sind.
136
6.2
6.2
Bits und Bytes
Bits und Bytes
Die kleinste Informationseinheit auf einem Digitalrechner bezeichnen wir als Bit2.
Ein Bit kann die logischen Werte 0 (Bit gelöscht) und 1 (Bit gesetzt) annehmen. Alle
Informationen auf einem Rechner, seien es nun Programme oder Daten, sind als Folgen von Bits gespeichert. Den Speicherinhalt eines Rechners können wir zu einem
Zeitpunkt als eine (sehr) lange Folge von Bits betrachten. Um Teile dieser Informationen gezielt ansprechen und manipulieren zu können, muss der Bitfolge eine Struktur gegeben werden. Zunächst fassen wir jeweils acht Bits zusammen und nennen
diese Informationsgröße ein Byte.
Niederwertigstes oder
least significant Bit
Höchstwertigstes oder
most significant Bit
Byte
Bit 7
Bit 6
Bit 5
Bit 4
Bit 3
Bit 2
Bit 1
Bit 0
0
1
1
0
1
0
0
1
7
2
2
6
2
5
2
4
2
3
2
2
2
1
20
Stellenwerte
Abbildung 6.13 Darstellung eines Bytes
Als Dualzahl interpretiert, kann ein Byte also Zahlen von 0–255 (hex. 00–ff) darstellen.
Bit 7 bezeichnen wir dabei als das höchstwertige (most significant), Bit 0 als das niederwertigste (least significant) Bit. Im Sinne der Interpretation als Dualzahl hat das
höchstwertige Bit den Stellenwert 27 = 128, das niederwertigste den Stellenwert 20 = 1.
Jedes Byte im Speicher bekommt eine fortlaufende Nummer, seine Adresse. Über
diese Adresse kann es vom Prozessor angesprochen (adressiert) werden (siehe Abbildung 6.14).
Der Prozessor wählt über den Adressbus eine Speicherzelle an und kann dann über
den Datenbus den Inhalt der Speicherzelle laden, um die Daten zu verarbeiten. Auf
dem gleichen Weg kann er dann Daten in den Speicher zurückschreiben.
Wir können die Informationen auf dem Adress- und Datenbus als Dualzahlen auffassen. In unserem Beispiel ist die Speicherstelle mit der Adresse 10112 = b16 angewählt,
und in dieser Speicherstelle steht der Wert 110110012 = d916 = 217.
2 Binary Digit, gleichzeitig engl. bit = ein bisschen, kein Bier aus der Eifel
137
6
Elementare Datentypen und ihre Darstellung
Prozessor
1 1 0 1 1 0 0 1
Adressbus
Datenbus
1 0 1 1
1 1 0 1 1 0 0 1
0 0 0 0
0 1 1 0 1 1 0 1
0 0 0 1
1 1 0 1 0 1 0 1
0 0 1 0
0 1 1 1 1 0 1 0
0 0 1 1
1 0 1 1 0 0 0 1
0 1 0 0
0 1 1 0 1 1 0 1
0 1 0 1
1 0 0 1 1 0 0 0
0 1 1 0
0 0 0 1 0 1 1 1
0 1 1 1
1 0 0 1 1 1 0 0
1 0 0 0
0 1 1 0 1 0 0 0
1 0 0 1
1 1 0 1 0 0 0 0
1 0 1 0
1 0 1 1 0 1 0 1
1 0 1 1
1 1 0 1 1 0 0 1
1 1 0 0
0 1 0 1 0 1 0 1
1 1 0 1
1 1 1 1 1 0 0 0
1 1 1 0
1 0 1 0 1 1 0 1
1 1 1 1
0 1 0 1 0 0 1 0
Daten
1 0 1 1
Adressen
6
Speicher
Abbildung 6.14 Adress- und Datenbus
Aus der »Breite« von Adress- und Datenbus ergeben sich grundlegende Leistungsdaten für einen Computer. Der hier schematisch gezeichnete Rechner kann über den
vierstelligen Adressbus insgesamt 16 Speicherzellen anwählen, in denen er jeweils
Werte im Bereich von 0–255 findet. Das ist natürlich nichts im Vergleich zu den Kapazitäten heutiger Rechner, die in der Regel über ein 64-Bit-Bussystem verfügen. Da
sich die Kapazität mit jedem zusätzlichen Bit verdoppelt, ergeben sich Werte in ganz
anderen Größenordnungen:
138
6.3
Breite
Adressierbare Zellen
4 Bit
24
16
8 Bit
28
256
16 Bit
216
65536
32 Bit
232
4294967296
64 Bit
264
1,84467 · 1019
Skalare Datentypen in C
6
Tabelle 6.1 Busbreite und adressierbare Zellen
Wenn man in diese Größenordnungen vorstößt, braucht man Begriffe für große
Datenmengen. Man orientiert sich dabei an den Maßeinheitenpräfixen (der Physik,
Kilo, Mega, ...). Anders als in der Physik, in der diese Präfixe immer eine Vervielfachung um den Faktor 103 = 1000 bedeuten, verwendet man in der Informatik den
Faktor 210 = 1024.
Physik (SI-Präfixe)
Informatik (Binärpräfixe)
rel. Abweichung
Kilo
103
210
Kibi
2,40 %
Mega
106
220
Mebi
4,86 %
Giga
109
230
Gibi
7,37 %
Tera
1012
240
Tebi
9,95 %
Peta
1015
250
Pebi
12,59 %
Exa
1018
260
Exbi
15,29 %
Tabelle 6.2 Vergleich von SI- und Binärpräfixen
Wegen der zunehmenden Abweichungen für große Werte sollte man in der Informatik eigentlich immer die Binärpräfixe verwenden. In der Praxis hat sich das jedoch
bisher nicht durchgesetzt. In der Regel verwendet man die SI-Präfixe und meint
damit die Werte der Binärpräfixe.
6.3
Skalare Datentypen in C
Sie wissen bereits, dass jede Variable in C einen Typ haben muss. Bisher haben Sie die
Typen int und float kennengelernt. In diesem Abschnitt kommen weitere soge-
139
6
Elementare Datentypen und ihre Darstellung
nannte skalare Datentypen hinzu. Unter einem skalaren Datentyp verstehen wir
einen Datentyp zur Darstellung eindimensionaler numerischer Werte.
6.3.1
Ganze Zahlen
Es gibt eine Reihe von Datentypen für ganze Zahlen. Betrachten Sie dazu das folgende
Diagramm3:
Syntaxgraph
char
signed
short
int
unsigned
long
long
Abbildung 6.15 Syntaxgraph für Datentypen
Egal, wie Sie das Diagramm durchlaufen, Sie erhalten immer eine gültige Typvereinbarung, auf die dann ein Variablenname folgen muss. Einige Beispiele:
int a;
signed char b;
unsigned short int c;
long d;
unsigned long long int e;
Zu Beginn der Typvereinbarung können Sie festlegen, ob es sich um einen vorzeichenbehafteten (signed) oder vorzeichenlosen (unsigned) Typ handelt. Wenn Sie an
dieser Stelle nichts angeben, wird ein vorzeichenbehafteter Typ angelegt. Aus diesem
Grund ist die explizite Angabe von signed überflüssig und kommt in C-Programmen
nur sehr selten vor. Danach folgt der eigentliche Datentyp, wobei die verschiedenen
Varianten (char, short, int, long, long long) sich dadurch unterscheiden, wie viele
Bytes der Datentyp im Speicher belegt. Das gegebenenfalls noch hinter short oder
long zusätzlich vorkommende int ist ohne Bedeutung und wird daher auch nur sehr
selten verwendet. Viele der theoretisch möglichen Kombinationen werden Sie nie in
einem C-Programm finden. Die wichtigsten Typen sind sicherlich char und int
zusammen mit ihren unsigned-Varianten. Das liegt daran, dass char der Typ ist, der
den wenigsten Platz belegt, und int der Typ ist, mit dem der Rechner am schnellsten
3 So etwas nennt man einen Syntaxgraphen.
140
6.3
Skalare Datentypen in C
rechnen kann. Speicherverbrauch und Rechengeschwindigkeit sind eben die wichtigsten Kriterien bei der Programmoptimierung.
C legt sich bezüglich einer Anzahl der Bytes bei den Datentypen nur auf eine Mindestgröße fest:
Datentyp
Mindestgröße
typische Größe
char
1 Byte
1 Byte
short
2 Bytes
2 Bytes
int
2 Bytes
4 Bytes
long
4 Bytes
4 Bytes
long long
8 Bytes
8 Bytes
6
Tabelle 6.3 Datentypen und deren Größen
Zusätzlich ist festgelegt, dass die Datentypen in der oben genannten Reihenfolge
ineinander enthalten sind. Auf unterschiedlichen Zielsystemen können Größen der
Datentypen durchaus unterschiedlich sein. Sie können die Werte auf Ihrem Rechner
mit einem kleinen Programm ermitteln:
printf(
printf(
printf(
printf(
printf(
"char:
"short:
"int:
"long:
"long long:
%d\n",
%d\n",
%d\n",
%d\n",
%d\n",
sizeof(
sizeof(
sizeof(
sizeof(
sizeof(
char));
short));
int));
long));
long long));
Listing 6.2 Größe unterschiedlicher ganzzahliger Datentypen
char:
short:
int:
long:
long long:
1
2
4
4
8
Wir verwenden hier den C-Operator sizeof, der uns die Größe eines Datentyps in
Bytes liefert. Abhängig von der Anzahl der Bytes ergibt sich dann ein unterschiedlich
großer Rechenbereich4:
4 Über die interne Darstellung vorzeichenbehafteter Zahlen haben wir nicht gesprochen, aber
anschaulich sollte klar sein, dass bei gleicher Bytezahl die größte vorzeichenbehaftete Zahl etwa
halb so groß ist wie die größte vorzeichenlose Zahl.
141
6
Elementare Datentypen und ihre Darstellung
signed
unsigned
Größe
min
max
min
max
1 Byte
–128
127
0
255
2 Bytes
–32768
32767
0
65535
4 Bytes
–2147483648
2147483647
0
4294967295
8 Bytes
–9,2234E+18
9,2234E+18
0
1,84467E+19
–2n–1
2n–1 – 1
0
2n – 1
Allgemein
n Bit
Tabelle 6.4 Rechenbereiche der Datentypen
Das Rechnen mit ganzen Zahlen ist exakt, solange Sie den vorgegebenen Rechenbereich nicht verlassen.
C unterstützt das Dezimal-, das Oktal- und das Hexadezimalsystem. Das Dualsystem
ist wegen seiner Nähe zu Oktal- bzw. Hexadezimalsystem dadurch mit abgedeckt.
Wenn wir mit Zahlen in verschiedenen Zahlensystemen in einem Programm arbeiten möchten, stellen sich drei Fragen:
왘
Wie schreiben wir eine Zahlkonstante in einem bestimmten Format im Quellcode?
왘
Wie lesen wir eine Zahl in einem bestimmten Format von der Tastatur ein?
왘
Wie schreiben wir eine Zahl in einem bestimmten Format auf dem Bildschirm?
Im Quellcode setzen wir einer Zahl ein Präfix voran, an dem man erkennt, ob es sich
um eine Zahl im Oktalsystem (Präfix 0) oder Hexadezimalsystem (Präfix 0x) handelt.
Bei der Eingabe mit scanf bzw. der Ausgabe mit printf verwenden wir spezielle Formatanweisungen ("%..."):
Zahlenformat
Eingabe
Ausgabe
Präfix
Dezimalsystem
"%d"
"%d"
(kein Präfix)
Oktalsystem
"%o"
"%o"
0
Hexadezimalsystem
"%x"
"%x"
0x
Tabelle 6.5 Zahlenformate bei Ein- und Ausgabe
Beachten Sie, dass 1234 und 01234 in einem C-Programm verschiedene Werte darstellen, da es sich im ersten Fall um eine Dezimal- und im zweiten Fall um eine Oktaldarstellung handelt.
142
6.3
Skalare Datentypen in C
Die folgenden Beispiele zeigen, wie Sie mit den verschiedenen Zahlendarstellungen
in einem Programm arbeiten können. Dabei haben wir uns hier auf den in diesem
Zusammenhang wichtigsten Datentyp (unsigned int) beschränkt. Im ersten Beispiel
werden Zahlkonstanten in verschiedenen Systemen einer Variablen zugewiesen.
unsigned int zahl;
zahl = 123456;
printf( "Dezimalausgabe:
%d\n", zahl);
printf( "Oktalausgabe:
%o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
6
zahl = 0123456;
printf( "Dezimalausgabe:
%d\n", zahl);
printf( "Oktalausgabe:
%o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
zahl = 0x123abc;
printf( "Dezimalausgabe:
%d\n", zahl);
printf( "Oktalausgabe:
%o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
Listing 6.3 Ausgabe von Zahlen in unterschiedlicher Darstellung
Diese wird dann in verschiedenen Darstellungen ausgegeben:
Dezimalausgabe:
123456
Oktalausgabe:
361100
Hexadezimalausgabe: 1e240
Dezimalausgabe:
42798
Oktalausgabe:
123456
Hexadezimalausgabe: a72e
Dezimalausgabe:
1194684
Oktalausgabe:
4435274
Hexadezimalausgabe: 123abc
Im zweiten Beispiel wird der Zahlenwert in unterschiedlichen Formaten von der Tastatur eingelesen:
unsigned int zahl;
printf( "Dezimaleingabe:
", &zahl);
143
6
Elementare Datentypen und ihre Darstellung
scanf( "%d", &zahl);
printf( "Dezimalausgabe:
%d\n", zahl);
printf( "Oktalausgabe:
%o\n", zahl);
printf( "Hexadezimalausgabe: %x\n\n\n", zahl);
printf( "Oktaleingabe:
scanf( "%o", &zahl);
printf( "Dezimalausgabe:
printf( "Oktalausgabe:
printf( "Hexadezimalausgabe:
printf( "Hexadezimaleingabe:
scanf( "%x", &zahl);
printf( "Dezimalausgabe:
printf( "Oktalausgabe:
printf( "Hexadezimalausgabe:
", &zahl);
%d\n", zahl);
%o\n", zahl);
%x\n\n\n", zahl);
", &zahl);
%d\n", zahl);
%o\n", zahl);
%x\n\n\n", zahl);
Listing 6.4 Einlesen von Werten in unterschiedlichen Formaten
Dezimaleingabe:
Dezimalausgabe:
Oktalausgabe:
Hexadezimalausgabe:
123456
123456
361100
1e240
Oktaleingabe:
Dezimalausgabe:
Oktalausgabe:
Hexadezimalausgabe:
123456
42798
123456
a72e
Hexadezimaleingabe:
Dezimalausgabe:
Oktalausgabe:
Hexadezimalausgabe:
123abc
1194684
4435274
123abc
6.3.2
Gleitkommazahlen
Für Gleitkommazahlen gibt es nicht so viele Typvarianten wie für ganze Zahlen:
Datentyp
typische Größe
float
1 Byte
einfache Genauigkeit
Tabelle 6.6 Typvarianten der Gleitkommazahlen
144
6.3
Datentyp
typische Größe
double
2 Bytes
doppelte Genauigkeit
long double
4 Bytes
besonders hohe Genauigkeit
Skalare Datentypen in C
Tabelle 6.6 Typvarianten der Gleitkommazahlen (Forts.)
Wertebereich und Genauigkeit der verschiedenen Gleitkommatypen sind im Standard nicht festgelegt. Der Speicherplatzbedarf auf einem konkreten Zielsystem lässt
sich wieder über ein kleines Programm ermitteln:
printf( "float:
%d\n", sizeof( float));
printf( "double:
%d\n", sizeof( double));
printf( "long double %d\n", sizeof( long double));
Listing 6.5 Größe unterschiedlicher Gleitkommadatentypen
Hier erhalten wir die folgende Ausgabe:
float
4
double
8
long double 8
Die Ergebnisse dieses Programms sind aber, wie schon bei den Ganzzahltypen,
maschinenabhängig.
Zur Eingabe konkreter Gleitkommazahlenwerte verwenden Sie die vom Taschenrechner her bekannte technisch-wissenschaftliche Notation mit Vorzeichen, Mantisse und Exponent:
Vorzeichen
Mantisse
E oder e
Vorzeichen
Exponent
–12.345 · 10–12
Abbildung 6.16 Bestandteile von Gleitkommazahlen
145
6
6
Elementare Datentypen und ihre Darstellung
Beispiele:
float a = –1;
float b = 1E2;
double c = 1.234;
long double d = –123.456E-345;
Gleitkommazahlen gibt es nur in Dezimalschreibweise, aber es gibt wieder verschiedene Formatanweisungen für die Ein- bzw. Ausgabe.
Typ
Eingabe
Ausgabe
float
"%f"
"%f"
double
"%lf"
"%lf"
long double
"%Lf"
"%LF"
Tabelle 6.7 Formatanweisungen für Gleitkommazahlen
Achtung, während das Rechnen mit ganzen Zahlen exakt ist, solange man im zulässigen Rechenbereich bleibt, ist das Rechnen mit Gleitkommazahlen fehleranfällig. Es
gibt einen Mindestabstand zwischen zwei Zahlen, unterhalb dessen der Rechner
nicht genauer auflösen kann. Durch häufige Rechenoperationen können sich die
Rechenfehler dann aufschaukeln. Dies bei numerischen Berechnungen zu vermeiden ist es eine anspruchsvolle Aufgabe, mit der wir uns hier aber nicht beschäftigen
werden.
6.4
Bitoperationen
Bisher haben wir Zahlen immer als Material für arithmetische Operationen gesehen,
aber man kann Zahlen auch viel elementarer als ein Bitmuster betrachten – also einfach als eine Folge von 0 und 1. Es ist hilfreich, wenn Sie im Folgenden gar nicht daran
denken, dass Zahlen einen Wert haben. Wir wollen uns überlegen, wie wir im Bitmuster einer ganzen Zahl gezielt Manipulationen durchführen können. Solche Manipulationen sind z. B.:
왘
Setze gezielt ein bestimmtes Bit.
왘
Lösche gezielt ein bestimmtes Bit.
왘
Invertiere gezielt ein bestimmtes Bit.
Natürlich haben solche Operationen auch eine arithmetische Bedeutung, aber uns
interessiert hier in erster Linie das Bitmuster. In C gibt es sechs Operationen auf Bit-
146
6.4
Bitoperationen
mustern, die Sie im Folgenden kennenlernen werden. Diese Operationen werden
vorrangig auf vorzeichenlosen Zahlen (unsigned char, unsigned short, unsigned int,
unsigned long und unsigned long long) durchgeführt, und in diesem Kontext werden
wir diese Operationen auch ausschließlich betrachten.
Das bitweise Komplement (~) invertiert das Bitmuster einer Zahl. Aus einer 0 wird
eine 1 und aus einer 1 eine 0:
Bitweises Komplement
x
1
0
0
1
1
0
1
1
~x
0
1
1
0
0
1
0
0
6
Abbildung 6.17 Bitweises Komplement
Das bitweise Und (&) benötigt zwei Operanden und verknüpft deren Muster Bit für Bit
mit einer logischen Und-Operation:
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
Abbildung 6.18 Bitweises Und
Ganz analog arbeitet das bitweise Oder (|) mit einer logischen Oder-Operation:
Bitweises Oder
x
1
1
0
0
0
0
1
0
y
1
0
0
1
1
0
1
1
x|y
1
1
0
1
1
0
1
1
Abbildung 6.19 Bitweises Oder
Zusätzlich gibt es das bitweise Entweder-Oder, das eine ausschließende Oder-Operation durchführt:
Bitweises Entweder-Oder
x
1
1
0
0
0
0
1
0
y
1
0
0
1
1
0
1
1
x^y
0
1
0
1
1
0
0
1
Abbildung 6.20 Bitweises Entweder-Oder
147
6
Elementare Datentypen und ihre Darstellung
Schließlich gibt es noch zwei Schiebeoperationen. Bei einem Bitshift nach rechts wird
das Bitmuster um eine gewisse Anzahl von Stellen nach rechts geschoben, und die
frei werdenden Stellen werden mit Nullen aufgefüllt:
Bitshift rechts
x
1
0
0
1
1
0
1
1
x>>2
0
0
1
0
0
1
1
0
Abbildung 6.21 Bitshift rechts
Der Bitshift nach links schiebt in die andere Richtung. Auch hier werden Nullen nachgeschoben:
Bitshift links
x
1
0
0
1
1
0
1
1
x<<2
0
1
1
0
1
1
0
0
Abbildung 6.22 Bitshift links
Ein Schieben um eine Stelle nach links entspricht übrigens einer Multiplikation mit
2, ein Schieben um eine Stelle nach rechts entspricht einer Division durch 2 ohne
Rest. Dementsprechend ist 1 << n = 2n.
Mit den jetzt bereitgestellten Grundoperationen können Sie die oben beschriebenen
Bitmanipulationen durchführen. Starten Sie mit der Aufgabe, in einer Zahl x ein
bestimmtes Bit – etwa das dritte von rechts5 – zu setzen. Dazu gehen Sie wie folgt vor:
Eine 1 wird um drei Positionen nach
links geschoben und über ein bitweises
Oder mit x verknüpft. Dadurch wird das
dritte Bit in x gesetzt.
Setzen des dritten Bits in x
1
0
0
0
0
0
0
0
1
1<<3
0
0
0
0
1
0
0
0
x
1
0
1
1
0
0
1
1
x|(1<<3)
1
0
1
1
1
0
1
1
Abbildung 6.23 Setzen des dritten Bits in x
5 Wenn ich vom »dritten Bit von rechts« spreche, meine ich das Bit mit dem Stellenwert 23. Ich
fange also, wie so oft in der Programmierung, bei 0 an zu zählen.
148
6.4
Bitoperationen
Zum Löschen eines Bits verwenden Sie das bitweise Und:
Eine 1 wird um vier Positionen nach links
geschoben, komplementiert und dann
über ein bitweises Und mit x verknüpft.
Dadurch wird das vierte Bit in x gelöscht.
Löschen des vierten Bits in x
1
0
0
0
0
0
0
0
1
1<<4
0
0
0
1
0
0
0
0
~(1<<4)
1
1
1
0
1
1
1
1
x
1
0
1
1
0
0
1
1
x&~(1<<4)
1
0
1
0
0
0
1
1
6
Abbildung 6.24 Löschen des vierten Bits in x
Um ein Bit zu invertieren, verwenden Sie das bitweise Entweder-Oder:
Eine 1 wird um fünf Positionen nach links
geschoben und über ein bitweises
Entweder-Oder mit x verknüpft. Dadurch
wird das fünfte Bit in x invertiert.
Invertieren des fünften Bits in x
1
0
0
0
0
0
0
0
1
1<<5
0
0
1
0
0
0
0
0
x
1
0
1
1
0
0
1
1
x^(1<<5)
1
0
0
1
0
0
1
1
Abbildung 6.25 Invertieren des fünften Bits in x
Wir fassen das noch einmal zusammen:
int n = 3;
unsigned int x = 0xaffe;
x = x | (1<<n); // Setzen des n-ten Bits in x
x = x & ~(1<<n); // Loeschen des n-ten Bits in x
x = x ^ (1<<n); // Invertieren des n-ten Bits in x
Das Bitmuster 1<<n, in dem ja genau ein Bit gesetzt ist, bezeichnet man auch als Maske,
weil über dieses Muster genau ein Bit aus der Zahl x herausgefiltert (maskiert) wird.
149
6
Elementare Datentypen und ihre Darstellung
Häufig will man ein Bitmuster nicht verändern, sondern einfach nur testen, ob ein
bestimmtes Bit in dem Bitmuster gesetzt ist. Das geht so:
int n = 3;
unsigned int x = 0xaffe;
if( x & (1<<n)) // Test, ob das n-te Bit in x gesetzt ist
{
...
}
Sie werden sich vielleicht fragen, was solche »Bitfummeleien« sollen. Darauf will ich
Ihnen zwei Antworten geben. Erstens, wenn Sie einmal maschinennah, etwa auf
einem Microcontroller, programmieren, werden Ihre Programme zu einem großen
Teil aus solchen Bitoperationen bestehen, und zweitens können Sie mit diesen Techniken den zu Beginn des Kapitels gezeigten Kartentrick programmieren. Das machen
wir als erstes Beispiel im nächsten Abschnitt.
6.5
Programmierbeispiele
Unsere Kenntnisse über die Darstellung von Zahlen in verschiedenen Zahlensystemen setzen wir jetzt in einigen Beispielen praktisch ein. Dabei kommen wir auch
noch einmal auf den Kartentrick vom Anfang des Kapitels zurück, den Sie mittlerweile schon durchschaut haben dürften.
6.5.1
Kartentrick
Ich weiß nicht, ob es Ihnen gelungen ist, hinter den am Anfang des Kapitels beschriebenen Kartentrick zu kommen. Wenn nicht, dann versuchen Sie es, bevor Sie weiterlesen, vielleicht noch einmal mit den inzwischen erworbenen Kenntnissen über
Dualzahlen und Bitmuster.
Wir schauen uns an, welche Dualdarstellung die Zahlen auf den Karten haben, und
kommen zu folgendem Ergebnis (siehe Abbildung 6.26).
Auf der Karte A stehen alle Zahlen, die das nullte Bit gesetzt haben. Auf der Karte B
stehen alle Zahlen, die das erste Bit gesetzt haben, und so geht das weiter. Das heißt,
jedes Mal, wenn der Besitzer der Geheimzahl sagt, dass die Zahl auf einer Karte steht
oder nicht, gibt er ein Bit seiner Zahl preis. Diese Bits müssen Sie nur noch zu einer
Zahl kombinieren. Dazu betrachten Sie die erste Zahl auf jeder Karte. In dieser Zahl ist
immer nur genau das für diese Karte charakteristische Bit gesetzt.
150
6.5
E
D
C
B
A
B
2
3
6
7
10
11
14
15
18
19
22
23
26
27
30
5
13
31
29
22
30
7
15
30
31
19
22
23
26
1
27
18
21
0
29
1
29
25
28
25
0
28
31
27
25
24
23
26
17
29
21
14
13
12
20
19
17
28
23
21
13
6
16
11
9
20
5
15
14
E
3
1
8
11
10
9
12
15
D
4
31
7
A
C
24
27
30
31
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 1
22
23
24
25
26
27
28
29
30
31
Programmierbeispiele
1 + 4 + 16 = 21
Abbildung 6.26 Die Auflösung des Kartentricks
Sie müssen also nur noch ein logisches Oder zwischen den ersten Zahlen aller ausgewählten Karten bilden, um die Geheimzahl zu erhalten. Da die Bitmuster dieser Zahlen sich aber nicht überschneiden, entspricht diese Oder-Verknüpfung einer
Addition. Wenn Sie also die jeweils ersten Zahlen der Karten, auf denen die Geheimzahl stehen, addieren, erhalten Sie die Geheimzahl.
Das können wir in einem Programm umsetzen:
void main()
{
int bit, z, zahl, antwort;
printf( "Denk dir eine Zahl zwischen 0 und 31\n");
A
B
for( bit = 1, zahl = 0; bit < 32; bit = bit << 1)
{
printf( "Ist die Zahl in dieser Liste");
for( z = 0; z < 32; z++)
{
151
6
6
Elementare Datentypen und ihre Darstellung
C
if( z & bit)
printf( " %d", z);
}
printf( ":");
scanf( "%d", &antwort);
if( antwort == 1)
zahl = zahl | bit;
}
D
printf( "Die Zahl ist %d\n", zahl);
}
Listing 6.6 Das Kartenraten als Programm
(A) Die Variable bit durchläuft in der Schleife die Werte:
1 = 000012
2 = 000102
4 = 001002
8 = 010002
16 = 100002
In der folgenden Schleife (B) werden alle 32 Zahlen durchlaufen, aber ausgegeben
werden nur die, die das bit gesetzt haben (C). Wenn die gesuchte Zahl auf der ausgegebenen Karte steht, wird das bit gesetzt (D).
Am Beispiel der Zahl 21 ergibt sich dann folgendes Ablaufprotokoll:
Denk dir eine Zahl zwischen 0 und 31
Ist die Zahl in dieser Liste 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31: 1
Ist die Zahl in dieser Liste 2 3 6 7 10 11 14 15 18 19 22 23 26 27 30 31: 0
Ist die Zahl in dieser Liste 4 5 6 7 12 13 14 15 20 21 22 23 28 29 30 31: 1
Ist die Zahl in dieser Liste 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31: 0
Ist die Zahl in dieser Liste 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31:
1
Die Zahl ist 21
6.5.2
Zahlenraten
Wenn Sie das letzte Beispiel abwandeln, kommen Sie zu einer anderen Form des Zahlenratens. Sie können das höchste Bit setzen und fragen, ob die Geheimzahl größer
oder kleiner ist. Wenn sie größer ist, bleibt das Bit gesetzt, ansonsten löschen wir es
wieder. Dann setzen wir das zweithöchste Bit und fragen wieder. Auf diese Weise
152
6.5
Programmierbeispiele
können wir mit jeder Frage die Anzahl der noch möglichen Zahlen halbieren, bis am
Ende nur noch eine Zahl übrig bleibt. Hier ist das Programm dazu:
void main()
{
int n, antwort;
unsigned int zahl, bit;
6
A
printf( "Anzahl Stellen: ");
scanf( "%d", &n);
printf( "Denk dir eine Zahl zwischen 0 und %d\n", (1<<n)-1);
B
for( zahl = 0, bit = (1<<n-1); bit > 0; bit = (bit>>1))
{
zahl = zahl | bit;
printf( "Ist die Zahl kleiner als %d: ", zahl);
scanf( "%d", &antwort);
if( antwort == 1)
zahl = zahl & ~bit;
}
C
D
printf( "Die Zahl ist %d\n", zahl);
}
Listing 6.7 Ein abgewandeltes Zahlenraten
In dem Programm werden n-stellige Dualzahlen betrachtet (A). Die größte n-stellige
Dualzahl ist:
111 ... 1
n-mal
⎧
⎨
⎪⎩
n
( 1 << n ) – 1 = 2 – 1 =
In der Schleife (B) wird das Bit zunächst gesetzt. Wenn die Zahl dann zu groß ist, wird
das Bit wieder gelöscht (D).
Innerhalb der Schleife durchläuft die Variable bit Potenzen von 2 (C):
2n–1 = 1000 ... 0002
2n–2 = 0100 ... 0002
2n–3 = 0010 ... 0002
...
21 = 0000 ... 0102
20 = 0000 ... 0012
153
6
Elementare Datentypen und ihre Darstellung
Als Geheimzahl wählen wir natürlich 4711 und lassen den Computer raten:
Anzahl Stellen: 16
Denk dir eine Zahl zwischen 0 und 65535
Ist die Zahl kleiner als 32768: 1
Ist die Zahl kleiner als 16384: 1
Ist die Zahl kleiner als 8192: 1
Ist die Zahl kleiner als 4096: 0
Ist die Zahl kleiner als 6144: 1
Ist die Zahl kleiner als 5120: 1
Ist die Zahl kleiner als 4608: 0
Ist die Zahl kleiner als 4864: 1
Ist die Zahl kleiner als 4736: 1
Ist die Zahl kleiner als 4672: 0
Ist die Zahl kleiner als 4704: 0
Ist die Zahl kleiner als 4720: 1
Ist die Zahl kleiner als 4712: 1
Ist die Zahl kleiner als 4708: 0
Ist die Zahl kleiner als 4710: 0
Ist die Zahl kleiner als 4711: 0
Die Zahl ist 4711
6.5.3
Addierwerk
Im nächsten Beispiel werden Sie ein Programm schreiben, das zwei Zahlen addieren
soll. Nichts leichter als das, werden Sie sagen, aber wir wollen es so machen, wie der
Rechner es intern macht. Das heißt, wir stellen uns vor, dass es noch gar keine Addition gibt und dass unser Programm nur elementare Operationen auf Bitmustern
durchführen kann. Sie werden also ein Programm schreiben, das zwei Zahlen addiert,
ohne dass irgendwo im Programm ein +-Zeichen auftaucht. Versuchen Sie es
zunächst einmal allein, bevor Sie sich meine Lösung ansehen.
Eine Addition läuft im Dualsystem genauso ab, wie Sie es in der Schule im 10er-System gelernt haben. Man schreibt beide Zahlen untereinander und addiert ziffernweise von rechts nach links, wobei gegebenenfalls ein Übertrag entsteht. Den
Übertrag verarbeitet man immer im nächsten Rechenschritt. Im Dualsystem müssen
Sie nur darauf achten, dass ein Übertrag schon bei 1 (1+1 = 10)6 und nicht erst bei 9 eintritt. Außerdem müssen Sie im Hinterkopf behalten, dass Sie einen endlichen
Rechenbereich haben und irgendwann ein Überlauf erfolgt. Die Berechnung der
Summe wird nach folgendem Schema durchgeführt:
6 Vielleicht kennen Sie den Spruch: »There are 10 types of people in the world: Those who understand binary and those who don’t.« Ich hoffe, Sie gehören inzwischen zur ersten Art.
154
6.5
Überlauf
1
0
1
1
0
1
0
1
1
1
0
1
0
1
0
1
1
+
1
0
+
0
1
+
0
0
+
1
1
+
0
+
0
1
1
+
1
1
+
Programmierbeispiele
0
Übertrag
0
6
Abbildung 6.27 Schema des Addierwerkes
Bezeichnen Sie die eingehenden Bits mit s1 und s2 und den Übertrag mit c. Wenn Sie
jetzt beachten, dass der Entweder-Oder-Operator einer Addition von Bits ohne Übertrag entspricht, ergibt sich die Summe (ohne Übertrag) der drei Werte durch diesen
C-Ausdruck:
summe = s1 ^ s2 ^c;
Einen Übertrag erhalten Sie, wenn mindestens zwei der drei Werte 1 sind:
c = (s1 & s2) | (s1 & c) | (s2 & c)
Jetzt können Sie das Addierwerk programmieren:
A
B
void main()
{
unsigned int z1, z2;
unsigned int s, s1, s2, sum, c;
printf( "Gib bitte zwei Zahlen ein: ");
scanf( "%d %d", &z1, &z2);
C
D
E
for( sum = 0,
{
s1 = z1 &
s2 = z2 &
sum = sum
c = (s1 &
}
s = 1, c = 0; s != 0; s = s << 1, c = c << 1)
s;
s;
| (s1 ^ s2 ^ c);
s2) | (s1 & c) | (s2 & c);
printf( "Summe: %d\n", sum);
}
Listing 6.8 Implementierung des Addierwerkes
155
6
Elementare Datentypen und ihre Darstellung
z1 und z2 sind die zu addierenden Zahlen (A), s ist die Maske, die über die Zahlen
geschoben wird, s1 und s2 sind die aus den Zahlen z1 und z2 maskierten Bits, sum ist
die zu berechnende Summe, und c ist der Übertrag (Carry) (B).
Im Programm werden Maske und Carry über die Zahlen geschoben (C) und in der
Schleife die Bits aus den Zahlen gefiltert (D), danach werden Summe und Übertrag für
die betrachtete Stelle berechnet (E).
Zum Abschluss können Sie das Programm testen:
Gib bitte zwei Zahlen ein: 12345 67890
Summe: 80235
Das Programm kann addieren, obwohl nirgendwo im Programm, außer in der Zählschleife, ein +-Zeichen steht.
6.6
Zeichen
Ein Computer soll nicht nur Zahlen, sondern auch Buchstaben und Text verarbeiten
können. Da der Computer intern aber nur Dualzahlen – besser gesagt: Bitmuster –
kennt, muss es eine Zuordnung von Buchstaben zu Bitmustern geben. Eine solche
Zuordnung nennt man einen Code.
Ein klassischer Code für die gebräuchlichsten Zeichen ist der ASCII-Code. Die meisten
Rechner benutzen diesen Code, haben jedoch oft individuelle Erweiterungen (nationale Zeichensätze, grafische Symbole etc.).
Quelle:
Q
ll Wiki
Wikipedia
di
Das Zeichen Z hat den ASCII-Code 5a16 = 9010.
Der numerische Wert ist dabei relativ unwichtig.
Wichtig ist das Bitmuster:
5a = 0101 1010
Abbildung 6.28 Der ASCII-Zeichensatz
156
6.6
Zeichen
Grundsätzlich unterscheiden wir im ASCII-Code druckbare und nicht druckbare Zeichen. Die druckbaren Zeichen (A, B, C, ...) sprechen für sich. Von den nicht druckbaren
Zeichen interessiert uns hier nur das Linefeed-Zeichen (LF), das, wenn man es auf
dem Bildschirm ausgibt, einen Zeilenvorschub erzeugt.
Zeichenkonstanten werden in einfache Hochkommata gesetzt ('a', 'Z'). Für das
nicht druckbare Linefeed-Zeichen verwenden wir die Ersatzdarstellung '\n'. Als Typ
für Zeichenvariablen wird char (oder unsigned char) verwendet. Bei der Ein- bzw. Ausgabe einzelner Zeichen wird "%c" als Formatanweisung verwendet:
char a, b, c;
A
a = 'x';
b = '\n';
printf( "Bitte gib einen Buchstaben ein: ");
scanf( "%c", &c);
C
printf( "Ausgabe: %c%c%c\n", a, b, c);
Listing 6.9 Verwendung von char
In dem Programm werden zuerst Zeichenkonstanten in Variablen gespeichert (A),
bevor in einem weiteren Schritt ein Zeichen von der Tastatur eingelesen wird. Zeichen werden mit der Formatanweisung %c eingelesen (B) bzw. ausgegeben (C). Dabei
erzeugt die Ausgabe von b einen Zeilenvorschub:
Bitte gib einen Buchstaben ein: Q
Ausgabe: x
Q
Beachten Sie, dass beim Lesen mit %c nur das eine Zeichen eingelesen wird und dass
zusätzlich eigegebene Zeichen, wie das unvermeidliche Linefeed zum Abschluss der
Eingabe, im Eingabepuffer verbleiben und dann gegebenenfalls bei der nächsten
Leseoperation gelesen werden.
Dass Zeichen (char) zugleich als Zahlen interpretiert werden können, hat den positiven Seiteneffekt, dass mit Zeichen wie mit Zahlen gerechnet werden kann. Für das
folgende Programm
char x;
for( x = 'A'; x <= 'K'; x = x + 1)
printf( "%d %c\n", x, x);
Listing 6.10 Interpretation von char als Zahlen
157
6
6
Elementare Datentypen und ihre Darstellung
erhalten wir diese Ausgabe:
65:
66:
67:
68:
69:
70:
71:
72:
73:
74:
75:
A
B
C
D
E
F
G
H
I
J
K
Da die Nummerierung der Zeichen der alphabetischen Reihenfolge entspricht, kann
dies z. B. genutzt werden, um Zeichen alphabetisch zu sortieren.
Zum Abschluss dieses Abschnitts wollen wir uns vergewissern, dass unser Rechner
den ASCII-Zeichencode verwendet. Dazu erstellen wir folgendes Programm:
A
B
C
D
E
F
G
H
I
void main()
{
unsigned char zeile, spalte, z;
printf( "
");
for( spalte = 0; spalte < 0x10; spalte ++)
printf( " .%x", spalte);
printf( "\n");
for( zeile = 0; zeile < 0x08; zeile++)
{
printf( " %x.", zeile);
for( spalte = 0; spalte < 0x10; spalte ++)
{
z = (zeile << 4)|spalte;
if( (z > 0x20) && ( z < 0x7f))
printf( " %c", z);
else
printf( " .");
}
printf( "\n");
}
}
Listing 6.11 Ausgabe des ASCII-Zeichensatzes
158
6.7
Arrays
Der hier verwendete Datentyp für Zeichen ist unsigned char (A). Das Programm
erzeugt in einer Schleife die Überschrift (B) mit insgesamt 0x10 = 16 Spalten. Dabei
werden die Spaltenüberschriften mit der Formatanweisung %x hexadezimal ausgegeben. Im Anschluss an die Überschrift folgt eine Doppelschleife zur Erzeugung der
Tabelle (D) und (F), auch hier wieder mit einer hexadezimalen Ausgabe (E). Der Index
des aktuellen Zeichens wird arithmetisch ermittelt, z = zeile · 24 + spalte (G).
Damit nur die druckbaren Zeichen in der Tabelle ausgegeben werden, erfolgt eine
Unterscheidung in druckbare bzw. nicht druckbare Zeichen (H). Die druckbaren Zeichen liegen dabei in dem Bereich 2016 < z < 7f16 und werden mit %c ausgegeben (I).
Mit unserem Programm erhalten wir eine eigene Tabelle des ASCII-Zeichensatzes:
0.
1.
2.
3.
4.
5.
6.
7.
.0
.
.
.
0
@
P
`
p
.1 .2 .3 .4 .5 .6 .7 .8 .9 .a .b .c .d .e .f
. . . . . . . . . . . . . . .
. . . . . . . . . . . . . . .
! " # $ % & ' ( ) * + , – . /
1 2 3 4 5 6 7 8 9 : ; < = > ?
A B C D E F G H I J K L M N O
Q R S T U V W X Y Z [ \ ] ^ _
a b c d e f g h i j k l m n o
q r s t u v w x y z { | } ~ .
Den Zeichencode für die auszugebenden Zeichen setzen wir im Programm als Bitmuster aus dem Zeilen- und dem Spaltenindex zusammen.
z = (zeile << 4) | spalte;
Der Zeilenindex wird um 4 Bit nach links geschoben. In die 4 unteren Bytes wird dann
der Spaltenindex montiert, damit ergibt sich in z der Zeichencode in der betrachteten Zeile und Spalte.
6.7
Arrays
Stellen Sie sich vor, dass Sie ein Programm erstellen sollen, das 100 Zahlen einliest
und die Zahlen in umgekehrter Reihenfolge wieder ausgibt. Mit Ihren derzeitigen
Programmierkenntnissen wären Sie tatsächlich gezwungen, 100 Variablen anzulegen, einzeln einzulesen und anschließend einzeln wieder auszugeben. Sie könnten
für die erforderlichen Ein- und Ausgaben nicht einmal eine Schleife verwenden, da
Sie keinen Datentyp kennen, der 100 Zahlen aufnehmen kann und dessen Inhalt flexibel über eine Schleife bearbeitet werden kann. Zum Glück handelt es sich bei dem
angesprochenen Problem nicht um einen Mangel der Programmiersprache C, son-
159
6
6
Elementare Datentypen und ihre Darstellung
dern um einen Mangel an Programmierkenntnissen in C, den wir umgehend beseitigen werden.
6.7.1
Eindimensionale Arrays
Ein Array ist eine Aneinanderreihung von Datenelementen gleichen Typs. Über
einen Index kann auf jedes Datenelement unmittelbar zugegriffen werden.
Wenn wir ein Array benötigen, müssen wir uns drei Fragen stellen:
1. Wie soll das Array heißen?
2. Wie viele Datenelemente soll das Array haben?
3. Welchen Datentyp sollen die Elemente des Arrays haben?
Wenn wir ein Array für fünf ganze Zahlen (int) benötigen, das meinezahlen heißen
soll, schreiben wir im Programm:
Typ der Elemente
meinezahlen
Name des Arrays
Anzahl der Elemente
int meinezahlen[5];
int i;
meinezahlen[0] = 123;
meinezahlen[1] = meinezahlen[0]+5;
Index
Wert
0
123
1
123 + 5 = 128
2
128 + 2 = 130
3
130 + 3 = 133
4
133 + 4 = 137
for( i = 2; i < 5; i = i+1)
meinezahlen[i] = meinezahlen[i-1]+i;
for( i = 0; i < 5; i = i+1)
printf( "%d: %d\n", i, meinezahlen[i]);
0:
1:
2:
3:
4:
123
128
130
133
137
Abbildung 6.29 Verwendung eines eindimensionalen Arrays
Natürlich können Sie einem Array auch jeden anderen Datentyp (char, unsigned
short, float, ...) zugrunde legen.
Auf die einzelnen Elemente des Arrays greifen wir mit einem sogenannten Index zu.
Verwendet werden die indizierten Elemente eines Arrays wie eine Variable des entsprechenden Datentyps. Der Index selbst ist eine ganze Zahl und kann über eine Kon-
160
6.7
Arrays
stante, eine Variable oder einen Formelausdruck gegeben sein. Das erste Element des
Arrays hat den Index 0, das zweite den Index 1 etc.:
Wenn das Array n Elemente hat, sind die Elemente von 0 bis n-1 nummeriert.
Achten Sie darauf, dass Sie nur gültige Indizes aus diesem Bereich verwenden. Der
Compiler überprüft das nicht. In der Regel stürzt Ihr Programm ab, wenn Sie einen
ungültigen Index verwenden.
Arrays können direkt bei ihrer Definition mit Werten gefüllt werden. Man gibt dazu
die gewünschten Werte in geschweiften Klammern und durch Kommata getrennt
an:
int meinezahlen[5] = { 1, 2, 3, 4, 5};
Dass dabei u. U. nicht alle Felder besetzt werden, ist unproblematisch. Der Compiler
füllt das Array von vorn beginnend. Nicht angesprochene Felder bleiben uninitialisiert.
In Verbindung mit Zählschleifen ergeben sich vielfältige Verarbeitungsmöglichkeiten für Arrays. Insbesondere können Sie das in der Einleitung gestellte Problem (100
Zahlen einlesen und in umgekehrter Reihenfolge wieder ausgeben) elegant lösen:
A
void main()
{
int daten[100];
int i;
B
for( i = 0; i < 100; i = i+1)
{
printf( "Gib die %d-te Zahl ein: ", i);
scanf( "%d", &daten[i]);
}
C
for( i = 99; i >= 0; i = i-1)
printf( "Die %d-te Zahl ist: %d\n", i, daten[i]);
}
Listing 6.12 Einlesen und Ausgeben von 100 Zahlen
Zuerst wird ein Array für 100 Zahlen erstellt (A). Dieses Array wird zunächst in aufsteigender Richtung durchlaufen, um Zahlen einzugeben (B). Nach erfolgter Eingabe
wird das Array in absteigender Richtung durchlaufen, um die Zahlen auszugeben (C).
161
6
6
Elementare Datentypen und ihre Darstellung
6.7.2
Mehrdimensionale Arrays
Der Wikipedia habe ich den folgenden Auszug entnommen:
Abbildung 6.30 Entfernungstabelle
Eine Entfernungstabelle ist eine zweidimensionale Reihung von Daten gleichen Typs
– also ein zweidimensionales Array:
A
void main()
{
int start, ziel, distanz;
int entfernung[5][5] = {
{ 0, 2, 5, 9,14},
{ 2, 0, 7,15,27},
{ 5, 7, 0, 9,23},
{ 9,15, 9, 0,12},
{14,27,23,12, 0}
};
B
printf( "Gib zwei Orte (0-4) ein: ");
scanf( "%d %d", &start, &ziel);
C
distanz = entfernung[start][ziel];
D
printf( "Entfernung zwischen %d und %d: %d km\n", start,
ziel, distanz);
}
Listing 6.13 Implementierung eines mehrdimensionalen Arrays
162
6.7
Arrays
In dem Programm wird in (A) ein zweidimensionales Array für 5 × 5 int-Werte angelegt und initialisiert. Anschließend werden Zeilen- und Spaltenindex eingelesen (B).
Mit den vorgegebenen Orten wird dann die Entfernung aus der Tabelle gelesen (C)
und ausgegeben (D).
Gib zwei Orte (0-4) ein: 0 3
Entfernung zwischen 0 und 3: 9 km
Beim Anlegen und beim Zugriff werden jetzt zwei Indizes (Zeilenindex und Spaltenindex) verwendet. Natürlich können Zeilen- und Spaltenzahl in einem Array unterschiedlich sein:
double tabelle[100][200];
Wichtig ist auch hier wieder, dass die Indizierung der Elemente beim Index 0
beginnt. Im oben dargestellten Beispiel sind also die Zeilen von 0 bis 99 und die Spalten von 0 bis 199 nummeriert.
»Zeilen« und »Spalten« sind anschauliche, aber im Grunde genommen irreführende
Begriffe, denn ein zweidimensionales Array ist im Rechner nicht »zweidimensional«
organisiert. Insbesondere werden diese Begriffe dann unbrauchbar, wenn man
Arrays noch höherer Dimension betrachtet, was problemlos möglich ist. Wenn Sie
z. B. über einen Zeitraum von 100 Tagen sekündlich die Temperatur aufzeichnen
wollten, könnten Sie ein vierdimensionales Array verwenden:
Temperaturen sind Gleitkommazahlen.
Temperaturaufzeichnung für 100 Tage
float temperatur[100][24][60][60];
24 Stunden am Tag
60 Minuten pro Stunde
60 Sekunden pro Minute
Abbildung 6.31 Beispielhaftes Array zur Aufzeichnung von Temperaturen
163
6
6
Elementare Datentypen und ihre Darstellung
Die Temperatur am 5. Tag der Aufzeichnungen um 10 Sekunden nach 14:00 Uhr
erhalten Sie dann mit dem Zugriff:
t = temperatur[4][14][0][10]
Beachten Sie auch hier wieder, dass die Zählung der Tage, Stunden, Minuten und
Sekunden im Array mit 0 beginnt. Am Beispiel der Uhrzeit sehen Sie auch, dass das
eine ganz natürliche Zählweise ist, da der Tag um 0 Uhr 0 beginnt.
6.8
Zeichenketten
Ein Wort der deutschen Sprache hat es bis in die englischen Zeitungen gebracht:
Abbildung 6.32 Zeitungsmeldung zur deutschen Sprache
In einem Computer wollen wir auch Worte, Sätze und Texte variabler Länge verarbeiten können. Wir sprechen in diesem Zusammenhang allgemein von Zeichenketten
oder Strings. Bisher kennen Sie nur Stringkonstanten wie "Hallo Welt\n" und einzelne Zeichen wie 'A' oder '\n'. Beachten Sie hier den Unterschied:
왘
'A' ist der Buchstabe A.
왘
"A" ist eine Zeichenkette, die nur den Buchstaben A enthält.
Diese Unterscheidung ist keine Spitzfindigkeit, da es sich um grundsätzlich verschiedene Datentypen handelt. Den Datentyp für Zeichenketten werden Sie jetzt kennenlernen.
Da Zeichenketten Reihungen von Zeichen (char) sind, ist es naheliegend, zur Speicherung von Zeichenketten ein Array zu verwenden. Da Sie Zeichenketten im Rechner
verändern möchten, ist es sinnvoll, eine Zeichenkette in einem ausreichend großen
Array abzulegen, das noch Platz, z. B. für das Einfügen von Zeichen, lässt. Im Array stehen dann die Zeichencodes der einzelnen Zeichen:
164
6.8
D i
e s
i
s
t
e
i
n
T e x
Zeichenketten
t ∅
Zeichenkette
Terminator
zugrunde liegendes Array
6
Abbildung 6.33 Aufbau einer Zeichenkette
Da die Zeichenkette unter Umständen nicht das ganze Array ausfüllt, benötigen Sie
einen Code, der das Ende der Zeichenkette markiert (Terminator). Dieser Code darf
natürlich nicht innerhalb der Zeichenkette als »normales« Zeichen vorkommen.
Deshalb wählen Sie die 0 als Terminator. Bitte beachten Sie, dass es sich hier nicht um
das Zeichen '0' (ASCII-Code 3016), sondern um eine »richtige« 0 (0016) handelt. Dieser
Code (ASCII-Zeichen NUL) steht ja nicht für einen sinnvollen Buchstaben.
Bevor Sie mit einer Zeichenkette arbeiten können, müssen Sie ein Array ausreichender Größe bereitstellen. Zum Beispiel:
char wort[100];
In ein solches Array können Sie eine Zeichenkette mit scanf einlesen. Die Eingabe
von Zeichenketten erfolgt mit der Formatanweisung "%s":
scanf( "%s", wort);
7
Achtung
왘
Beim Einlesen von Zeichenketten in ein Array wird dem Variablennamen kein &
vorangestellt7. Wenn Sie hier ein & setzen, wird Ihr Programm abstürzen.
왘
Bei der Eingabe wird nicht geprüft, ob das Array groß genug ist, um den String
aufzunehmen. Werden mehr Zeichen eingegeben, als das Array aufnehmen kann,
stürzt das Programm ab.
Mit scanf können Sie nur einzelne Wörter jeweils bis zum nächsten Trennzeichen
(Leerzeichen, Tabulator oder Zeilenumbruch) einlesen. Möchten Sie eine komplette
Eingabezeile, gegebenenfalls mit Leerzeichen und einschließlich des abschließenden
Zeilenvorschubs, in ein Array einlesen, verwenden Sie die folgende Anweisung:
7 Diese scheinbare Abweichung von der Norm werde ich Ihnen später erklären. Nehmen Sie das für
den Moment bitte zunächst ohne weitere Erklärung hin.
165
6
Elementare Datentypen und ihre Darstellung
char zeile[100];
fgets( zeile, 100, stdin);
Dabei übergeben Sie die Länge des Eingabe-Arrays (im Beispiel 100), um zu verhindern, dass die Grenzen des Arrays überschritten werden.
Da die Zeichenkette nach dem Einlesen in einem Array zur Verfügung steht, können
Sie über den Index auf jedes Zeichen zugreifen und es bei Bedarf verändern.
wort[0] = 'A';
// Erster Buchstabe A
wort[1] = wort[0]; // Zweiter Buchstabe auch A
wort[2] = 0;
// Terminator, Zeichenkette ist "AA"
Achtung: Bei der Veränderung von Zeichenketten müssen Sie Folgendes unbedingt
beachten:
왘
Die Nummerierung der Zeichen beginnt beim Index 0. Wenn die Zeichenkette n
Zeichen hat, sind diese von 0 bis n-1 nummeriert. Der Terminator hat den Index n.
왘
Die Zeichenkette befindet sich in einem Array fester Länge. Sie müssen darauf achten, dass bei Veränderungen (z. B. durch Anfügen von Buchstaben) die Grenzen
des zugrunde liegenden Arrays nicht überschritten werden.
왘
Wegen des Terminators muss das Array, das den String aufnimmt, mindestens ein
Element mehr haben, als der String Zeichen enthält.
왘
Die Zeichenkette muss nach eventuellen Manipulationen immer konsistent sein.
Insbesondere bedeutet das, dass das Terminator-Zeichen korrekt positioniert werden muss.
Auf die Rahmenbedingungen muss der Programmierer achten. Verletzt er eine dieser Bedingungen, stürzt das Programm in der Regel ab.
Die Ausgabe eines Strings, auch mit Leerzeichen oder Zeilenumbrüchen, kennen Sie
schon von Stringkonstanten. Man verwendet printf mit der Formatanweisung "%s":
printf( "%s", wort);
Jetzt können Sie ein erstes zusammenhängendes Beispiel programmieren. Sie werden ein Wort einlesen, um dann seine Länge festzustellen:
A
166
void main()
{
char wort[100];
int i;
6.8
B
printf( "Wort: ");
scanf( "%s", wort);
C
D
for( i = 0; wort[i] != 0; i++)
;
E
printf( "%s hat %d Zeichen\n", wort, i);
}
Zeichenketten
6
Listing 6.14 Verwendung einer Zeichenkette
Um eine Zeichenkette zu verwenden, wird das Array für die Zeichenkette bereitgestellt (A). In das Array kann dann das Wort eingelesen werden (B).
Beachten Sie, dass kein & vor dem Variablennamen steht!
In einer Schleife werden die Zeichen im Array gezählt, solange nicht der Terminator
auftaucht (C). Die Zählung findet komplett im Schleifenkopf statt, beachten Sie, dass
der Schleifenkörper leer ist (D).
Abschließend werden die Zeichenkette und ihre Länge ausgegeben (E):
Wort: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz hat 63 Zeichen
Im nächsten Beispiel werden Sie ein Wort einlesen und prüfen, ob es sich bei diesem
Wort um ein Palindrom handelt. Unter einem Palindrom verstehen wir ein Wort
oder eine Wortfolge, die vorwärts und rückwärts gelesen gleich ist, wobei es auf Leerzeichen, Satzzeichen oder Groß- bzw. Kleinschreibung nicht ankommt. Palindrome
sind z. B. »otto«, »lagerregal« oder »rentner«. Schreiben Sie bei der Eingabe alles klein
und zusammen, damit Sie keinen Zusatzaufwand bei der Überprüfung haben:
void main()
{
char wort[100];
int vorn, hinten;
A
B
printf( "Wort: ");
scanf( "%s", wort);
for( hinten = 0; wort[hinten] != 0; hinten++)
;
167
6
Elementare Datentypen und ihre Darstellung
C
D
E
F
for( vorn = 0, hinten--; vorn < hinten; vorn++, hinten--)
{
if( wort[vorn] != wort[hinten])
break;
}
if( vorn < hinten)
printf( "Kein Palindrom\n");
else
printf( "Palindrom\n");
}
Listing 6.15 Palindromerkennung
Zuerst müssen Sie das Wort, das Sie prüfen wollen, einlesen (A). In diesem Wort wird
dann das Wortende gesucht (B). Nach Ablauf der Schleife ist in hinten der Index des
Terminators. Dieser wird im folgenden Schleifenkopf (C) daher um 1 zurückgesetzt. In
dieser Schleife wird das Wort von vorn vorwärts und von hinten rückwärts durchlaufen, solange vorn noch vor hinten liegt. Innerhalb der Schleife erfolgt die Prüfung.
Wenn vorn ein anderes Zeichen steht als hinten, wird die Schleife abgebrochen (D).
Außerhalb der Schleife erfolgt die Prüfung des Ergebnisses, wenn die Schleife vorzeitig abgebrochen wurde (E), handelt es sich um kein Palindrom (F).
Wort: einnegermitgazellezagtimregennie
Palindrom
Bis jetzt haben Sie noch keine Zeichenketten verändert oder sogar neue Zeichenketten erstellt. Das machen Sie im nächsten Beispiel. Sie kennen sicherlich das Spiel
»Galgenmännchen«, bei dem man versucht, durch Raten von Buchstaben in möglichst wenig Versuchen ein unbekanntes Wort zu ermitteln. Im Folgenden sehen Sie
meine Lösung, die insofern etwas merkwürdig ist, als der Rater selbst das Geheimwort eingibt, es angezeigt bekommt und dann aufgefordert wird, das Wort zu raten.
Aber im Vordergrund steht ja nicht das Spiel, sondern das Programm:
A
168
void main()
{
char wort[100], anzeige[100];
char versuch;
int nochzuraten, i, anzahl;
6.8
B
C
D
E
F
G
H
I
Zeichenketten
printf( "Wort: ");
scanf( "%s", wort);
for( nochzuraten = 0; wort[nochzuraten] != 0; nochzuraten++)
anzeige[nochzuraten] = '-';
anzeige[nochzuraten] = 0;
for( anzahl = 1; nochzuraten != 0; anzahl++)
{
printf( "%s\n", anzeige);
printf( "%d-ter Versuch: ", anzahl);
6
scanf( "\n%c", &versuch);
for( i = 0; wort[i] != 0; i++)
{
if( (wort[i] == versuch) && (anzeige[i] == '-'))
{
anzeige[i] = versuch;
nochzuraten--;
}
}
}
printf( "%s\n", anzeige);
printf( "Du hast %d Versuche benoetigt\n", anzahl-1);
}
Listing 6.16 Galgenmännchen
Das Programm startet mit der Erstellung von Puffern für das Ratewort und den
Anzeigestring (A), das zu ratende Wort wird in (B) eingelesen.
In (C) wird der Anzeigestring aufbereitet, indem für alle Zeichen ein '-' gesetzt wird.
Gleichzeitig wird gezählt, wie viele Zeichen zu raten sind.
Nach Ablauf dieser Schleife wird der Anzeigestring terminiert (D). Jetzt beginnt das
eigentliche Spiel mit einer Schleife über alle Rateversuche (E).
In jedem Schleifendurchlauf erfolgt die Eingabe eines neuen Zeichens (F). Vor dem
Lesen wird mit \n der noch in der Eingabe stehende Zeilenvorschub aus der letzten
Eingabe konsumiert. Nach der Eingabe des zu ratenden Zeichens läuft eine Schleife
über das zu ratende Wort (G).
169
6
Elementare Datentypen und ihre Darstellung
Wenn der geratene Buchstabe mit dem Zeichen im Wort übereinstimmt und in der
Anzeige noch ein '-' steht, wird das Zeichen in die Anzeige übernommen, und es ist
nur noch ein Buchstabe weniger zu erraten (H) bis (I). Im folgenden Bildschirmdialog
habe ich versucht, das Wort »mississippi« zu erraten:
Wort: mississippi
----------1-ter Versuch: i
-i--i--i--i
2-ter Versuch: a
-i--i--i--i
3-ter Versuch: s
-ississi--i
4-ter Versuch: p
-ississippi
5-ter Versuch: m
mississippi
Du hast 5 Versuche benoetigt
An dieser Stelle sind einige ergänzende Informationen zur Eingabe mit scanf angebracht. Alle Eingaben des Benutzers landen in einem Zwischenpuffer, aus dem scanf
nach und nach die geforderten Eingaben abruft. Mit %c wird nur ein einzelnes Zeichen abgerufen. Alles, was der Benutzer zusätzlich eingegeben hat (z. B. Leerzeichen
oder Zeilenumbrüche), bleibt in der Eingabe stehen und muss gegebenenfalls durch
gezielte Leseoperationen beseitigt werden. Im oben dargestellten Beispiel wird durch
die Anweisung \n%c vor dem Lesen des Eingabezeichens (%c) der von der letzten Eingabe noch anstehende Zeilenumbruch (\n) beseitigt.
Häufig will man Zeichenketten kopieren oder miteinander vergleichen. Da ist es verlockend, es genauso wie bei Zahlen zu machen:
int a = 1, b;
b = a;
if( a == b)
...
Das geht bei Zeichenketten so nicht:
Zeichenketten können nicht mit = kopiert und nicht mit == oder != miteinander verglichen werden. Bei einer Kopie müssen die Zeichenketten Zeichen für
Zeichen kopiert und bei einem Vergleich Zeichen für Zeichen verglichen
werden.
170
6.8
Zeichenketten
Nach Ihrem derzeitigen Kenntnisstand bedeutet das, dass Sie das Kopieren und das Vergleichen von Strings selbst implementieren müssen. Fangen Sie mit dem Kopieren an:
char original[100], kopie[100];
int i;
printf( "Eingabe: ");
scanf( "%s", original);
A
B
6
for( i = 0; original[i] != 0; i++)
kopie[i] = original[i];
kopie[i] = 0;
printf( "\nOriginal: %s", original);
printf( "\nKopie:
%s", kopie);
Listing 6.17 Kopieren von Zeichenketten
In dem Programm wird Zeichen für Zeichen von original nach kopie kopiert (A),
abschließend wird die Kopie in (B) terminiert.
Eingabe: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Original: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Kopie:
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Im nächsten Programmfragment werden zwei Strings eingelesen und miteinander
verglichen. Das Vergleichsergebnis wird auf dem Bildschirm ausgegeben:
char wort1[100], wort2[100];
int i;
printf( "Wort1: ");
scanf( "%s", wort1);
printf( "Wort2: ");
scanf( "%s", wort2);
A
B
for( i = 0; (wort1[i] != 0) && (wort1[i] == wort2[i]); i++)
;
if( wort1[i] == wort2[i])
printf( "Die Worte sind gleich\n");
else
printf( "Die Worte sind verschieden");
Listing 6.18 Vergleich von Zeichenketten
171
6
Elementare Datentypen und ihre Darstellung
In dem Programm werden die eingegebenen Zeichenketten Zeichen für Zeichen
geprüft (A). Solange das erste Wort noch nicht beendet ist und die Zeichen im ersten
und zweiten Wort gleich sind, wird weitergeprüft.
Am zuletzt geprüften Zeichen können Sie erkennen, ob die beiden Worte gleich
waren (B).
Wort1:
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Wort2:
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesezz
Die Worte sind verschieden
Die Steuerung dieser Schleife ist durchaus trickreich, sodass wir uns die Testbedingung noch einmal genauer anschauen wollen:
Wort1 beendet
Wort2 beendet
Zeichen gleich
Aktion
Ergebnis
nein
nein
nein
abbrechen
verschieden
nein
nein
ja
weitermachen
offen
nein
ja
nein
abbrechen
verschieden
nein
ja
ja
kann nicht vorkommen
ja
nein
nein
abbrechen
ja
nein
ja
kann nicht vorkommen
ja
ja
nein
kann nicht vorkommen
ja
ja
ja
abbrechen
gleich
nein
nein
nein
abbrechen
verschieden
verschieden
Tabelle 6.8 Die Testbedingungen im Detail
Sie sehen, dass in jedem vorkommenden Fall die Schleife korrekt fortgesetzt oder
abgebrochen wird.
Sie werden an dieser Stelle mit Recht einwenden, dass es nicht sein darf, dass man für
Grundaufgaben wie Kopieren oder Vergleichen immer und immer wieder den gleichen Code schreiben muss. Es muss in einer Programmiersprache Möglichkeiten
geben, solche Aufgaben einmal und dann auch endgültig zu lösen. Mit den dazu
erforderlichen Sprachmitteln werden wir uns im nächsten Kapitel beschäftigen.
Zunächst aber schließen wir dieses Kapitel mit einigen Programmierbeispielen ab.
172
6.9
6.9
Programmierbeispiele
Programmierbeispiele
Beispielprogramme mit Verwendung von Zeichenketten und Arrays schließen dieses Kapitel ab.
6.9.1
Buchstabenstatistik
Sie werden ein Programm erstellen, das eine komplette Textzeile einliest und dann
eine Statistik über die vorkommenden Buchstaben (a–z) erzeugt:
A
B
C
D
E
F
void main()
{
char text[100];
int statistik[26];
int i;
for( i = 0; i < 26; i++)
statistik[i] = 0;
printf( "Text: ");
scanf( "%s", text);
for( i = 0; text[i] != 0; i++)
{
if( (text[i] >= 'a') && ( text[i] <= 'z'))
statistik[text[i]-'a']++;
}
printf( "\nAuswertung:\n");
for( i = 0; i < 26; i++)
printf( "%c: %d\n", 'a' + i, statistik[i]);
}
Listing 6.19 Erstellen einer Buchstabenstatistik
Auf das Zählen der Buchstaben möchte ich noch etwas genauer eingehen. Im Array
statistik haben wir 26 Zähler für das Vorkommen der Buchstaben (A). Das Vorkommen von a wollen wir in statistik[0], das Vorkommen von b in statistik[1] etc.
zählen. Wie kommen Sie nun von einem Zeichen zum Index seines Zählers? Ganz
einfach, Sie ziehen den Code von a als Zahlenwert vom Code des Zeichens ab. So
ergibt sich die Formel
statistik[zeichen[i]-'a']
173
6
6
Elementare Datentypen und ihre Darstellung
Und wie gelangen Sie umgekehrt von einem Index zu dem Zeichen, das zu diesem
Index gehört? Ganz einfach: Sie addieren den Zeichencode von a zum Index hinzu. So
erhalten Sie die Formel
'a' + i
in der Ausgabe der Statistik.
Mit dieser Erläuterung ist der Rest des Programmes schnell erklärt. In (B) werden alle
26 Buchstabenzähler auf 0 gesetzt. Dann wird in (C) der Text eingelesen und mit (D)
über die gesamte Eingabe iteriert. Dabei werden im Text nur Zeichen betrachtet, die
zwischen a und z liegen (E). Abschließend erfolgt die Ausgabe der Statistik (F).
Sie werden nun das Programm testen und dazu Eingaben verwenden, die möglichst
viele verschiedene Buchstaben enthalten. In der Wikipedia finden Sie eine Liste von
Pangrammen8, die als Testfälle geeignet sind:
Abbildung 6.34 Beispiele von Pangrammen
Eines der bekanntesten Pangramme der englischen Sprache ist übrigens »The quick
brown fox jumps over the lazy dog«. Dieses Pangramm wurde in der Steinzeit der
Datenverarbeitung benutzt, um Fernschreibverbindungen mit einem vollständigen
Satz an Zeichen zu testen. Noch viele Modems können heute diesen Satz auf Knopfdruck automatisch erzeugen. Sie testen Ihr Programm allerdings mit einem anderen
Pangramm (ohne Leerzeichen):
Text: franzjagtimkomplettverwahrlostentaxiquerdurchbayern
Auswertung:
a: 5
b: 1
c: 1
d: 1
e: 5
f: 1
8 Das sind Texte, die alle Zeichen des Alphabets enthalten.
174
6.9
g:
h:
i:
j:
k:
l:
m:
n:
o:
p:
q:
r:
s:
t:
u:
v:
w:
x:
y:
z:
Programmierbeispiele
1
2
2
1
1
2
2
3
2
1
1
6
1
5
2
1
1
1
1
1
6.9.2
6
Sudoku
Sicher haben Sie sich schon einmal an einem Sudoku-Rätsel versucht. Man muss ein
9 × 9-Zahlenschema, in dem gewisse Zahlen vorgegeben sind, so ausfüllen, dass in
jeder Zeile und in jeder Spalte und in jedem der neun Teilquadrate immer alle Zahlen
von 1 bis 9 stehen. Sie werden ein kleines Programm schreiben, das Sie bei der Lösung
unterstützt. Das Programm enthält aber nur die Ein- und Ausgabe. Weitergehende
Funktionen sind zunächst einmal nicht vorgesehen.
Das 9 × 9-Zahlenfeld bilden Sie natürlich auf einem zweidimensionalen Array ab:
int sudoku[9][9];
Der Einfachheit halber nummerieren Sie die Zeilen und Spalten des Sudokus von 0
bis 9. Dann kann es auch schon losgehen:
A
void main()
{
int sudoku[9][9] = {
{5,0,9,7,0,2,0,4,0},
{7,6,0,3,4,8,9,1,5},
{1,3,4,5,0,9,0,7,0},
175
6
Elementare Datentypen und ihre Darstellung
{6,7,1,0,0,0,0,0,0},
{8,0,5,6,2,1,7,3,4},
{0,0,3,0,5,0,1,6,9},
{0,5,8,1,0,0,3,2,7},
{3,0,0,2,0,0,0,9,6},
{9,0,0,0,7,3,8,5,0},
};
int zeile, spalte, zahl;
B
C
D
E
F
G
H
I
J
K
for( zahl = 1; zahl !=0; )
{
printf( "\n");
for( zeile = 0; zeile < 9; zeile++)
{
if( zeile %3 == 0)
printf( "+-------+-------+-------+\n");
for( spalte = 0; spalte < 9; spalte++)
{
if( spalte % 3 == 0)
printf( "| ");
if(sudoku[zeile][spalte] > 0)
printf( "%d ", sudoku[zeile][spalte]);
else
printf( " ");
}
printf( "|\n");
}
printf( "+-------+-------+-------+\n");
printf( "Zeile Spalte Zahl: ");
scanf( "%d %d %d", &zeile, &spalte, &zahl);
sudoku[zeile][spalte] = zahl;
}
}
Listing 6.20 Sudoku
In dem Programm wird in (A) eine Sudoku-Aufgabe festgelegt. Der Wert 0 steht für
ein nicht ausgefülltes Feld.
Innerhalb einer Schleife wird so lange fortgefahren, wie keine 0 eingegeben wurde (B).
Das Sudoku wird zeilen- und spaltenweise ausgegeben (C) und (E), jedes dritte Mal
kommt dabei eine Trennlinie (D) bzw. ein Trennstrich (F). Zahlen größer als 0 werden
ausgegeben (G), ansonsten werden Leerzeichen dargestellt. Die Ausgaben werden
176
6.9
Programmierbeispiele
mit einem Zeilenabschluss und einer Abschlusszeile beendet (H) und (I). Am Ende
eines jeden Durchlaufs erfolgen dann die Eingabe (J) und der Eintrag in das SudokuFeld (K).
Der folgende Screenshot zeigt das Programm bei der Arbeit:
+-------+-------+-------+
| 5 9 | 7 2 | 4 |
| 7 6 | 3 4 8 | 9 1 5 |
| 1 3 4 | 5 9 | 7 |
+-------+-------+-------+
| 6 7 1 |
|
|
| 8 5 | 6 2 1 | 7 3 4 |
|
3 | 5 | 1 6 9 |
+-------+-------+-------+
| 5 8 | 1
| 3 2 7 |
| 3
| 2
| 9 6 |
| 9
| 7 3 | 8 5 |
+-------+-------+-------+
Zeile Spalte Zahl: 0 1 8
6
+-------+-------+-------+
| 5 8 9 | 7 2 | 4 |
| 7 6 | 3 4 8 | 9 1 5 |
| 1 3 4 | 5 9 | 7 |
+-------+-------+-------+
| 6 7 1 |
|
|
| 8 5 | 6 2 1 | 7 3 4 |
|
3 | 5 | 1 6 9 |
+-------+-------+-------+
| 5 8 | 1
| 3 2 7 |
| 3
| 2
| 9 6 |
| 9
| 7 3 | 8 5 |
+-------+-------+-------+
Zeile Spalte Zahl:
Wenn Sie es sich zutrauen, können Sie zu diesem Programm noch Eingabeprüfungen, Lösungshinweise und das Erzeugen von Aufgaben hinzufügen. Das sind jedoch
anspruchsvolle Aufgaben, die Ihre derzeitigen Programmierkenntnisse noch übersteigen.
177
6
Elementare Datentypen und ihre Darstellung
6.10
Aufgaben
A 6.1 Erstellen Sie ein Programm, das einen String und einen Buchstaben übergeben bekommt und dann ausgibt, wie oft der Buchstabe in dem String vorkommt.
A 6.2 Schreiben Sie ein Programm, das die Reihenfolge der Zeichen in einem String
umkehrt.
A 6.3 Schreiben Sie ein Programm, das alle 'e' aus einem String entfernt.
A 6.4 Erweitern Sie das Programm zur Palindromerkennung so, dass nicht zwischen
Groß- und Kleinbuchstaben unterschieden wird. Es sollen also Worte wie
»Retsinakanister« korrekt als Palindrom erkannt werden.
A 6.5 Schreiben Sie ein Programm, das eine nur aus Ziffern bestehende Zeichenkette einliest und aus dem Eingabestring eine int-Zahl berechnet.
A 6.6 Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend auf
Wunsch bestimmte Zahlen wieder ausgibt. Das Programm soll wie folgt arbeiten:
Gib
Gib
Gib
Gib
Gib
Gib
Gib
Gib
Gib
Gib
die
die
die
die
die
die
die
die
die
die
1. Zahl ein: 23
2. Zahl ein: 17
3. Zahl ein: 234
4. Zahl ein: 875
5. Zahl ein: 328
6. Zahl ein: 0
7. Zahl ein: 519
8. Zahl ein: 712
9. Zahl ein: 1000
10. Zahl ein: 14
Welche Zahl soll ich ausgeben: 3
Die 3. Zahl ist 234
Welche Zahl soll ich ausgeben: 9
Die 9. Zahl ist 1000
Welche Zahl soll ich ausgeben: 2
Die 2. Zahl ist 17
A 6.7 Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend der
Größe nach sortiert wieder ausgibt.
178
6.10
Aufgaben
A 6.8 Unter einem magischen Quadrat der Kantenlänge 5 verstehen wir eine Anordnung der Zahlen 1 bis 25 in einem quadratischen Schema auf eine Weise, dass
die Summen in allen Zeilen, Spalten und den beiden Hauptdiagonalen gleich
sind. Das folgende Beispiel zeigt ein solches Quadrat:
19
3
12
21
10
11
25
9
18
2
8
17
1
15
24
5
14
23
7
16
22
6
20
4
13
6
Erstellen Sie ein Programm, das überprüft, ob es sich bei einem 5 × 5-Quadrat
um ein magisches Quadrat handelt.
A 6.9 Magische Quadrate ungerader Kantenlänge lassen sich nach folgendem Verfahren konstruieren:
1. Positioniere die 1 in dem Feld unmittelbar unter der Mitte des Quadrats!
2. Wenn die Zahl x in der Zeile i und der Spalte k positioniert wurde, dann versuche, die Zahl x+1 in der Zeile i+1 und der Spalte k+1 abzulegen! Handelt es
sich bei diesen Angaben um ungültige Zeilen- oder Spaltennummern,
wende Regel 4 an! Ist das Zielfeld bereits besetzt, wende Regel 3 an!
3. Wird versucht, eine Zahl in einem bereits besetzten Feld in der Zeile i und
der Spalte k zu positionieren, versuche stattdessen die Zeile i+1 und die
Spalte k-1. Handelt es sich bei diesen Angaben um ungültige Zeilen- oder
Spaltennummern, wende Regel 4 an. Ist das Zielfeld bereits besetzt, wende
Regel 3 erneut an!
4. Die Zeilen- und Spaltennummern laufen von 0 bis n-1. Ergibt sich im Laufe
des Verfahrens eine zu kleine Zeilen- oder Spaltennummer, setze die Nummer auf den Maximalwert n-1! Ergibt sich eine zu große Spalten- oder Zeilennummer, setze die Nummer auf den Minimalwert 0!
Erstellen Sie nach diesen Angaben ein Programm, das für ungerade Kantenlängen von 3 bis 9 ein magisches Quadrat erzeugen kann.
A 6.10 Informieren Sie sich im Internet, was unter einem Vigenère-Schlüssel zu verstehen ist. Erstellen Sie dann ein Programm, das einen eingegebenen String
mit einem Passwort verschlüsselt und wieder entschlüsselt.
179
Kapitel 7
Modularisierung
Divide et impera!
– Niccolò Machiavelli
7
Sie sind an einem Punkt angelangt, an dem Sie im Prinzip jede Programmieraufgabe
lösen können. Dabei haben Sie allerdings die Erfahrung gemacht, dass Ihre Programme mit wachsender Komplexität der Aufgabenstellung unübersichtlich und
unverständlich zu werden drohen. Es stellt sich daher die Frage:
Wie können Sie umfangreiche Programme noch handhabbar halten?
Die Antwort auf diese Frage ist naheliegend:
Sie müssen ein umfangreiches Programm in kleinere, jeweils noch überschaubare
Einzelteile zerlegen, diese Einzelteile möglichst unabhängig voneinander entwickeln
und dann zusammenfügen. Alle komplexen technischen Produkte – und große Softwaresysteme sind vielleicht die komplexesten technischen Produkte, die wir kennen
– entstehen heute auf diese Art.
Die Methodik des »Teilens und Herrschens« bezeichnet man in der Programmierung
als Modularisierung. Modularisierung wird in C durch sogenannte Funktionen unterstützt. Funktionen und Funktionsaufrufe haben wir übrigens, ohne besonders darauf
hinzuweisen, am Beispiel von printf und scanf bereits verwendet.
7.1
Funktionen
Wir nehmen noch einmal ein Kochbuch in die Hand und finden ein Rezept für Apfelkuchen, das ich stark gekürzt habe:
Ein Rezept für Apfelkuchen
Zutaten:
600 g Hefeteig
1,5 Kilo Äpfel
...
181
7
Modularisierung
Zubereitung:
Bereiten Sie den Hefeteig nach Rezept zu, und rollen Sie diesen dann auf einer
bemehlten Arbeitsfläche quadratisch aus. Geben Sie den Teig dann auf ein mit
Backpapier ausgelegtes Blech, und ziehen Sie den Rand an jeder Seite hoch. Der Teig
kann dann – mit einem Küchentuch abgedeckt – noch ein wenig stehenbleiben. In
der Zwischenzeit schneiden und entkernen Sie die Äpfel und schneiden sie in
schmale Spalten. ...
Wir finden wieder die übliche Aufteilung in Zutaten und Zubereitung. Bei der Zubereitung fällt eine Teilaufgabe (Hefeteig zubereiten) an, die in diesem Rezept nicht
weiter erklärt ist. Dazu wird auf ein anderes Rezept verwiesen. Dieses andere Rezept
beschreibt kein vollständiges Gericht, da man einen Hefeteig ohne weitere Zubereitung nicht essen sollte, aber es beschreibt eine klar abgegrenzte Teilaufgabe, die nicht
nur bei der Herstellung von Apfelkuchen anfällt. Daher ist es sinnvoll, die Zubereitung von Hefeteig in dem Kochbuch nur einmal zu beschreiben und dann aus anderen Rezepten darauf zu verweisen. Mit dem Hinweis »Hefeteig zubereiten« ist es im
Allgemeinen aber nicht getan. In der Regel müssen mit dem Hinweis Zusatzinformationen, etwa über die zu erstellende Menge oder spezielle Zutaten, gegeben werden.
Wir übertragen die Begriffe aus der Backstube in die Terminologie der Datenverarbeitung:
왘
Die Herstellung von Apfelkuchen ist unsere eigentliche Aufgabe. Das ist das
Hauptprogramm.
왘
Die Herstellung von Hefeteig ist eine Teilaufgabe im Rahmen der Herstellung
eines Apfelkuchens. Das ist eine Funktion oder ein Unterprogramm.
왘
Das Starten der Aktivität »Hefeteig erstellen« aus der Zubereitungsvorschrift von
Apfelkuchen bezeichnen wir als einen Aufruf des Unterprogramms aus dem
Hauptprogramm. Wir sprechen von einem Unterprogrammaufruf oder einem
Funktionsaufruf.
왘
Zwischen Haupt- und Unterprogramm müssen beim Aufruf ganz bestimmte
Informationen fließen, z. B. darüber, wie viel Hefeteig hergestellt und ob dem Teig
Zucker zugesetzt werden soll. Über den Austausch dieser Informationen muss
zwischen Haupt- und Unterprogramm eine präzise Vereinbarung bestehen. Das
Hauptprogramm muss wissen, welche Informationen das Unterprogramm benötigt und welche Ergebnisse es produziert. Eine solche Vereinbarung nennen wir
eine Schnittstelle.
왘
Eine im Rahmen der Schnittstelle vereinbarte Einzelinformation, wie z. B »Zuckerzugabe in Gramm«, nennen wir einen Parameter. Alle Parameter zusammen
beschreiben die Schnittstelle. Ein Parameter, durch den Informationen vom
Hauptprogramm zum Unterprogramm fließen, bezeichnen wir als Eingabepara-
182
7.1
Funktionen
meter. Einen Parameter, durch den Informationen vom Unterprogramm zum
Hauptprogramm zurückfließen, bezeichnen wir als Rückgabeparameter.
왘
Konkrete, durch die Parameter der Schnittstelle fließende Daten (z. B. 100 Gramm
Zuckerzugabe) bezeichnen wir als Parameterwerte. Entsprechend der Flussrichtung bezeichnen wir die Parameterwerte auch als Eingabewerte oder Rückgabewerte.
Stellen Sie sich vor, dass der Apfelkuchen von zwei Personen unabhängig voneinander hergestellt wird. Der Apfelkuchenbäcker ruft dem Hefeteigbäcker nur zu: »Ich
brauche 500 Gramm gesüßten Hefeteig«. Der Apfelkuchenbäcker muss nicht wissen,
wie man einen Hefeteig macht, und der Hefeteigbäcker muss nicht wissen, warum
oder wozu der Hefeteig benötigt wird. Auf diese Trennung von WIE und WARUM
kommt es uns an.
Durch die Aufteilung zwischen Haupt- und Unterprogramm erhalten wir also eine
Trennung zwischen WIE und WARUM. Das Unterprogramm weiß, WIE etwas
gemacht wird, aber nicht WARUM. Umgekehrt weiß das Hauptprogramm, WARUM
etwas gemacht wird, aber nicht WIE. Im Haupt- wie im Unterprogramm kann man
sich dann ganz auf die jeweilige Aufgabe konzentrieren und ist nicht mit überflüssigem Wissen über die jeweils andere Seite belastet.
Erst diese Technik ermöglicht es, größere Programme noch beherrschbar zu halten.
Große Softwaresysteme zu modularisieren, d. h. in kleinere, überschaubare funktionale Einheiten aufzuteilen und mit geeigneten Schnittstellen zu versehen, ist eine
zentrale Aufgabe des Programmdesigns. Der sichere Umgang mit dieser Technik ist
eine der wichtigsten Fähigkeiten, die einen guten Softwareentwickler auszeichnen.
Betrachten wir konkret die Programmiersprache C. Zu einer Funktion gehören zwei
Dinge:
1. eine Schnittstelle, die alle zwischen Haupt- und Unterprogramm fließenden Informationen festlegt
2. die Implementierung, in der die Funktion konkret ausprogrammiert wird
Stellen Sie sich vor, dass Sie im Rahmen einer Programmieraufgabe an verschiedenen Stellen Ihres Programms das Maximum zweier Zahlen bestimmen müssen.
Diese Berechnung möchten Sie an eine Funktion delegieren. Auch bezüglich der dazu
erforderlichen Schnittstelle haben Sie schon eine konkrete Vorstellung:
In die Funktion gehen zwei Gleitkommazahlen – nennen wir sie x und y – hinein, und
aus der Funktion kommt die größere der beiden Zahlen, also wieder eine Gleitkommazahl, als Ergebnis heraus. Einen Namen soll die Funktion auch haben – sie soll
maximum heißen. Damit ergibt sich die folgende Schnittstelle:
183
7
7
Modularisierung
Gleitkommazahlen x und y
gehen in die Funktion hinein
float maximum( float x, float y)
Die Funktion heißt »maximum«
Die Funktion gibt eine Gleitkommazahl zurück
Abbildung 7.1 Die Schnittstelle einer Funktion
Implementieren Sie die Funktion, indem Sie an die Schnittstelle (A) den Funktionskörper als Block anhängen (B–F). In diesem Block können die Parameter (hier x und y)
wie gewöhnliche Variablen des entsprechenden Typs verwendet werden:
A
B
C
D
E
F
float maximum( float x, float y)
{
if( x > y)
return x;
return y;
}
Listing 7.1 Funktion mit Funktionskörper
Innerhalb der Funktion wird geprüft, ob der Wert des Parameters x größer als der
Wert des Parameters y ist (C). Ist das der Fall, soll der Wert von x zurückgegeben werden (D), andernfalls der Wert von y (E).
Neu ist hier die return-Anweisung (D und E). Diese Anweisung bewirkt, dass der folgende Ausdruck ausgewertet und als Funktionsergebnis (Rückgabe- oder Returnwert) an das rufende Programm zurückgegeben wird. Das Unterprogramm ist damit
beendet, auch wenn die Anweisung nicht am Ende des Unterprogramms steht. Der
Typ des Rückgabewerts muss natürlich dem in der Schnittstelle vereinbarten Typ
entsprechen. Unsere Funktion hat zwei »Ausstiege«. Ist x>y, wird die Funktion mit
der Anweisung return x beendet. Die folgende Anweisung wird in diesem Fall nicht
mehr erreicht. Ist die Bedingung x>y nicht erfüllt, endet die Funktion mit der Anweisung return y. Letztlich wird also der größere der beiden Zahlenwerte zurückgegeben.
Die fertige Funktion maximum können Sie jetzt im Hauptprogramm verwenden:
184
7.1
A
B
Funktionen
void main()
{
float a = 1, b = 2.3, c;
c = maximum( a, b);
c = maximum( 12.3, a*b + 1);
c = b + maximum( 1, 2);
c = maximum( 1, maximum( a, b) + 1);
}
Listing 7.2 Verwendung der Funktion maximum im Hauptprogramm
7
Dem Funktionsaufruf maximum folgen in Klammern und durch Kommata getrennt die
Eingangsparameter, die an die Funktion übergeben werden (A).
Dabei können Konstanten, Variablen und Formelausdrücke übergeben werden (A, B).
Das Funktionsergebnis kann in Formeln oder Funktionsaufrufen benutzt werden (C,
D), und das Funktionsergebnis kann Variablen zugewiesen werden (D).
Eine Funktion kann Parameter unterschiedlicher Typen haben oder auch parameterlos sein. Ebenso kann der Rückgabewert einer Funktion einen beliebigen Datentyp
haben oder auch fehlen.
int vergleich( float x, int y)
{
if( x == y)
return 1;
return 0;
}
Listing 7.3 Funktion mit verschiedenen Parametern und Rückgabetypen
Wenn eine Funktion einen Returntyp hat, darf es keine Möglichkeit geben, die Funktion ohne eine explizite Returnanweisung mit Returnwert zu verlassen. Das rufende
Programm muss den Returnwert allerdings nicht verwenden.
Eine Funktion ohne Parameter wird mit einer leeren Parameterliste definiert.
int ausgabe()
{
printf( "Hallo Welt");
return 1;
}
Listing 7.4 Parameterlose Funktion
185
7
Modularisierung
Eine Funktion ohne Returntyp erkennen Sie am Pseudo-Returntyp void. Eine solche
Funktion kann jederzeit durch return ohne Wertangabe verlassen werden, es muss
eine solche Anweisung allerdings nicht geben.
A
B
C
D
void test()
{
int v;
v = vergleich( 1.0, 17);
if( v == 0)
return;
ausgabe();
}
Listing 7.5 Funktion ohne Rückgabe
Im angegebenen Beispiel wird eine Funktion ohne Returntyp erstellt (A). Die Funktion wird in (B) ohne Rückgabe eines Returnwertes verlassen. In (C) erfolgt der Aufruf
einer anderen Funktion ohne Verwendung des zurückgegebenen Returnwertes. Die
Funktion mit dem Rückgabetyp void kann dabei auch ohne return enden (D).
Natürlich kann es in einem Programm viele Funktionen geben, und Funktionen können ihrerseits wieder Funktionen rufen. Wichtig ist dabei immer, dass die an der
Schnittstelle getroffenen Typvereinbarungen eingehalten werden. Das heißt, dass
die in die Funktion eingehenden Parameterwerte den an der Schnittstelle festgelegten Typen entsprechen müssen und dass der Funktionswert nur dort verwendet werden kann, wo auch ein Ausdruck des gleichen Typs stehen könnte. Ein Unterprogramm erhält nur die Parameterwerte und hat daher keine Möglichkeit, die
Originaldaten des Hauptprogramms zu verändern.
Insgesamt ergibt sich ein C-Programm als eine Sammlung vieler Einzelfunktionen, die
alle einem gemeinsamen Zweck dienen und zusammen das Programm bilden. Das
Hauptprogramm main ist dabei nur der Einstiegspunkt, an dem der Kontrollfluss startet.
7.2
Arrays als Funktionsparameter
Arrays spielen eine Sonderrolle bei der Parameterübergabe an Funktionen. Das hat
damit zu tun, dass Arrays häufig sehr groß sind und eine Übergabe durch Kopieren
des kompletten Arrays sehr ineffizient wäre. Wenn also ein Array an eine Funktion
übergeben wird1, erhält die Funktion Zugriff auf die Originaldaten. Ich zeige Ihnen
das an einem Beispiel:
1 Was genau bei der Übergabe eines Arrays an eine Funktion passiert, erkläre ich Ihnen im
Abschnitt über Zeiger.
186
7.2
Arrays als Funktionsparameter
void init( int anz, int dat[])
{
int i;
Das Array wird im Hauptprogramm
angelegt.
void main()
{
int daten[10];
init( 10,
ausgeben(
umkehren(
ausgeben(
}
daten);
10, daten);
10, daten);
10, daten);
Die Unterprogramme erhalten die
Anzahl der Daten und den Zugriff auf
die Originaldaten.
Die Daten werden im Originalarray
initialisiert, umgekehrt und ausgegeben.
0 2 4 6 8 10 12 14 16 18
18 16 14 12 10 8 6 4 2 0
for( i = 0; i < anz; i++)
dat[i] = 2*i;
}
void ausgeben( int anz, int dat[])
{
int i;
7
for( i = 0; i < anz; i++)
printf( "%d ", dat[i]);
printf( "\n");
}
void umkehren( int anz, int dat[])
{
int v, h, t;
for(v = 0,h = 9; v < h; v++,h--)
{
t = dat[v];
dat[v] = dat[h];
dat[h] = t;
}
}
Abbildung 7.2 Arrays als Funktionsparameter
Die Funktionen sollten Sie mit Ihren bisher erworbenen Programmierkenntnissen
problemlos verstehen können. Wichtig ist, dass den Funktionen die Anzahl der
Datensätze im Array mitgeteilt wird, da diese Information im Array selbst nicht enthalten ist. Das Array wird dann mit unbestimmter Größe (dat[]) übergeben. Der
Zugriff erfolgt so, als wäre das Array in der Funktion angelegt, wobei der Durchgriff
auf die Daten im Hauptprogramm erfolgt.
Eine Rückgabe von Arrays aus einem Unterprogramm an das Hauptprogramm ist
nicht möglich2, aber auch nicht nötig, da das Hauptprogramm ja ein Array bereitstellen kann, das dann vom Unterprogramm entsprechend bearbeitet wird.
Das hier zu Arrays Gesagte gilt natürlich auch für Strings. Bei Strings wird jedoch in
der Regel keine Information über die Größe des zugrunde liegenden Arrays übertragen. Das Ende des Strings ist ja durch den Terminator eindeutig bestimmt. Als Bei-
2 Die Begründung dazu folgt ebenfalls später im Zusammenhang mit Zeigern.
187
7
Modularisierung
spiel erstellen wir Funktionen zur Ermittlung der Länge eines Strings und zum
Vergleich zweier Strings:
int stringlaenge( char s[])
{
int i;
void main()
{
int l, v;
for( i = 0; s[i] != 0; i++)
;
return i;
}
l = stringlaenge( "qwert");
printf( "Laenge: %d\n", l);
v = stringvergleich( "qwert", "qwerz");
if ( v == 1)
printf( "gleich\n");
else
printf( "ungleich\n");
}
int stringvergleich( char s1[], char s2[])
{
int i;
Laenge: 5
ungleich
for( i = 0; (s1[i]!=0)&&(s1[i]==s2[i]); i++)
;
return s1[i] == s2[i];
}
Abbildung 7.3 Strings als Funktionsparameter
Diese beiden Funktionen sind unkritisch, da die Strings in den Unterprogrammen
nur gelesen und nicht verändert werden. Wenn Sie aber Funktionen schreiben, die
Strings etwa verlängern, stoßen Sie auf eines der schwerwiegendsten Probleme im
Umgang mit C. In Listing 7.6 sehen Sie eine Funktion append, die ein Zeichen an einen
Text anhängt:
A
B
C
D
void append( char s[], char c)
{
int i;
for( i = 0; s[i] != 0; i++)
;
s[i] = c;
s[i+1] = 0;
}
Listing 7.6 Die Funktion append
Der Text und das anzuhängende Zeichen werden an die Funktion übergeben (A). In
der Schleife (B) wird der Text komplett durchlaufen. Abschließend wird an der Position am Ende des Textes das anzuhängende Zeichen angefügt (C) und schließlich die
Zeichenkette mit einer terminierenden 0 beendet (D).
188
7.2
A
B
C
D
Arrays als Funktionsparameter
void main()
{
char txt[20];
char b;
txt[0] = 0;
for( b = 'a'; b <= 'k'; b++)
{
append( txt, b);
printf( "%s\n", txt);
}
}
7
Listing 7.7 Verwendung von append im Hauptprogramm
Im Hauptprogramm wird ein leerer String erzeugt (A), und eine Schleife mit den Zeichen 'a' bis 'k' wird durchlaufen (B). In jedem Schleifendurchlauf wird das aktuelle Zeichen angehängt (C), und der entstandene String wird ausgegeben (D), was dann zur
folgenden Ausgabe führt:
a
ab
abc
abcd
abcde
abcdef
abcdefg
abcdefgh
abcdefghi
abcdefghi
abcdefghijk
Im Hauptprogramm wird ein Puffer für 20 Zeichen angelegt. Wenn Sie die Funktion
append zu oft rufen, wird im Unterprogramm ohne Kontrollen außerhalb des Puffers
geschrieben, und es kommt zu einem Buffer Overflow. Das kann zu schwerwiegenden Fehlern bis hin zu Programmabstürzen führen. Darum muss das rufende Programm dafür sorgen, dass der Puffer für die im Unterprogramm ausgeführten
Operationen groß genug ist.
Ein Buffer Overflow ist übrigens eine der Hauptangriffsstellen für Hacker. Hacker
versuchen, in einem Programm gezielt einen Buffer Overflow herbeizuführen und
dadurch schädlichen Code in das Programm zu injizieren.
189
7
Modularisierung
7.3
Lokale und globale Variablen
Innerhalb einer Funktion können nach Bedarf sogenannte lokale Variablen angelegt
werden. Diese Variablen gehören dann ausschließlich der Funktion. Das rufende Programm hat keinen Zugriff auf diese Variablen – genauso wie die Funktion keinen
Zugriff auf die Variablen des rufenden Programms hat. Auch eine zufällige Namensgleichheit von Variablen im rufenden und im gerufenen Programm ändert daran
nichts. Lokale Variablen werden automatisch erzeugt, wenn der Kontrollfluss in die
Funktion eintritt, und automatisch wieder beseitigt, wenn der Kontrollfluss die
Funktion verlässt. Solche Variablen heißen deshalb auch automatische Variablen.
Bei jedem Neueintritt in die Funktion werden die Variablen wieder neu erzeugt. Das
heißt, dass auch die Variablen verschiedener Aufrufe der gleichen Funktion nichts
miteinander zu tun haben. Auch die Funktionsparameter sind solche automatischen
Variablen. Sie werden beim Funktionsaufruf mit Kopien der übergebenen Werte
gefüllt und haben dann keine Beziehung mehr zu irgendwelchen Variablen des
Hauptprogramms. Dieses wichtige, auch als Information Hiding bezeichnete Prinzip
ermöglicht erst eine konsequente Modularisierung eines Programms. Eine Funktion
erzeugt sozusagen bei jedem Aufruf eine Blackbox, in die niemand hineinsehen, aus
der aber auch niemand heraussehen kann.
void main()
{
float a = 1, b = 2.3, c;
c = maximum( a, b);
}
1
2.3
2.3
float maximum( float x, float y)
{
Blackbox
}
Abbildung 7.4 Die Funktion als Blackbox
Der Informationsaustausch zwischen der Blackbox und ihrer Umwelt erfolgt allein
über die Funktionsschnittstelle.
Es gibt allerdings die Möglichkeit, sich Nebeneingänge in die Blackbox eines Funktionsaufrufs zu schaffen. Dazu dienen die sogenannten globalen Variablen. Globale
Variablen werden außerhalb jeglicher Funktion, auch außerhalb von main, angelegt.
Sie können dann in jeder Funktion benutzt werden.
190
7.3
A
int zaehler = 0;
B
int funktion( int x)
{
int y = 123;
C
D
E
Lokale und globale Variablen
zaehler++;
return x+y;
}
7
F
G
void main()
{
int i, x;
for( i = 1; i <= 5; i++)
{
x = funktion( i);
printf( "%d. Aufruf: %d\n", zaehler, x);
}
}
Listing 7.8 Globale und lokale Variablen
In dem angegebenen Code wird in (A) eine globale Variable zaehler angelegt. In (C)
und (F) werden lokale Variablen von funktion und main definiert. Die Variablen x in
funktion (B und E) und in main (F) haben nichts miteinander zu tun, sie sind unabhängig. Auf die globale Variable zaehler kann aber sowohl in funktion als auch in main
zugegriffen werden (D und G). Das Programm liefert damit das folgende Ergebnis:
1.
2.
3.
4.
5.
Aufruf:
Aufruf:
Aufruf:
Aufruf:
Aufruf:
124
125
126
127
128
Globale Variablen umgehen das konsequente Information Hiding und können überraschende Seiteneffekte auslösen. Sie sind daher eine potenzielle Fehlerquelle in
Ihren Programmen. Vor der Verwendung solcher Variablen sollten Sie daher immer
prüfen, ob der Seiteneffekt sinnvoll und notwendig ist. Auf keinen Fall sollten Sie aus
Bequemlichkeit globale Variablen anstelle einer sauberen Funktionsschnittstelle
verwenden, und es sollten keine globalen und lokalen Variablen gleichen Namens
vorkommen. Geben Sie globalen Variablen immer einen ausreichend langen, programmweit eindeutigen Namen, um solche Konflikte zu vermeiden.
191
7
Modularisierung
7.4
Rekursion
Mit einem Funktionsaufruf verbindet man gemeinhin die Vorstellung, dass eine
Funktion eine andere Funktion aufruft. Es gibt aber keinen Grund, auszuschließen,
dass eine Funktion sich mittelbar (d. h. auf dem Umweg über eine andere Funktion)
oder unmittelbar selbst aufruft. Man bezeichnet dies als Rekursion. Das bedeutet,
dass eine Funktion ihre Berechnungen unter Rückgriff auf sich selbst durchführt. Das
erscheint zunächst paradox, ist aber eine sehr sinnvolle Programmiertechnik.
Als Beispiel betrachten wir die Folge der Fakultäten, die Sie bereits aus dem Kapitel
über Arithmetik kennen. Sie erinnern sich vielleicht, dass n! (sprich »n-Fakultät«) das
Produkt der ersten n natürlichen Zahlen bezeichnet. Also:
n! =1 · 2 · 3 · ... · n
Diese Zahl lässt sich in einer Funktion recht einfach durch eine Schleife iterativ
berechnen:
A
B
C
D
int fakultaet_iter( int n)
{
int fak;
for( fak = 1; n > 1; n--)
fak = fak * n;
return fak;
}
void main()
{
int n;
for( n = 0; n < 10; n++)
printf( "%d! = %d\n", n, fakultaet_iter( n));
}
Listing 7.9 Iterative Berechnung der Fakultät
In der Funktion wird der in (A) übergebene Parameter n immer wieder mit fak multipliziert und dabei heruntergezählt.
Im Hauptprogramm werden die Fakultäten 1! Bis 9! berechnet und ausgegeben (D),
dabei wird die folgende Ausgabe erzeugt:
0!
1!
2!
3!
4!
192
=
=
=
=
=
1
1
2
6
24
7.4
5!
6!
7!
8!
9!
=
=
=
=
=
Rekursion
120
720
5040
40320
362880
Sie haben aber auch eine andere, eine rekursive Definition der Fakultät kennengelernt:
1
⎧
n! = ⎨
n
⋅
(
n
– 1 )!
⎩
falls n ≤ 1
7
falls n > 1
In dieser Formel wird die Berechnung der Fakultät auf die Berechnung der nächstkleineren Fakultät zurückgespielt. Genau das können wir auch in einem C-Programm
machen:
A
B
int fakultaet_rek( int n)
{
if( n <= 1)
return 1;
return n*fakultaet_rek( n-1);
}
void main()
{
int n;
for( n = 0; n < 10; n++)
printf( "%d! = %d\n", n, fakultaet_rek( n));
}
Listing 7.10 Rekursive Berechnung der Fakultät
Die rekursive Funktion gibt für einen Aufruf mit einem Parameter n <=1 eine 1 als
Rückgabewert (A). Für n>1 erfolgt der rekursive Aufruf der Funktion (B).
Beachten Sie, dass der Parameter n bei jedem Rekursionsschritt um 1 vermindert wird
und dann für n==1 (A) kein weiterer Selbstaufruf mehr erfolgt. So, wie Sie sich bei
einer Schleife immer Gedanken über eine geeignete Abbruchbedingung machen
müssen, müssen Sie sich auch bei Rekursion immer Gedanken über einen Ausstieg
machen, damit sich Ihr Programm nicht in einem endlosen rekursiven Abstieg verliert. Ein Absturz mit einem sogenannten Stack Overflow wäre die unausweichliche
Folge.
Von ihrem äußeren Verhalten her sind die iterative und die rekursive Implementierung der Fakultätsfunktion gleich. Beide haben die gleiche Schnittstelle und liefern
193
7
Modularisierung
für gleiche Argumente gleiche Ergebnisse. Auch der Implementierungsaufwand ist
identisch. Und doch gibt es einen wesentlichen Unterschied. Wenn Sie Laufzeitprofile3 machen, werden Sie feststellen, dass die iterative Implementierung weniger
Laufzeit benötigt als die rekursive. Das rekursive Verfahren stellt sich, insbesondere
für große Werte von n, als deutlich langsamer heraus. Der Grund dafür liegt in den
bei rekursiver Programmierung zusätzlich anfallenden Zeiten für die vielen Unterprogrammaufrufe. Angesichts dieses Ergebnisses kann man sich fragen, wofür denn
Rekursion überhaupt sinnvoll ist, zumal theoretische Untersuchungen zeigen, dass
Rekursion immer vermieden werden kann und gut optimierte iterative Algorithmen
grundsätzlich effizienter arbeiten als ihre rekursiven Gegenstücke. Trotzdem sind
rekursive Techniken von großem Nutzen in der Programmierung. Sie erlauben es oft,
komplizierte Operationen verblüffend einfach zu implementieren. Rekursive Algorithmen werden gern verwendet, wenn man ein Problem durch einen geschickten
Ansatz auf ein »kleineres« Problem der gleichen Struktur zurückführen kann. Dazu
wollen wir ein Beispiel betrachten.
Sie kennen vielleicht das Spiel »Türme von Hanoi«, bei dem ein Spieler die Aufgabe
hat, einen Stapel unterschiedlich großer Ringe von einem Ständer (Start) auf einen
anderen Ständer (Ziel) zu transportieren:
Bringe 5 Ringe von Start
über Tmp nach Ziel
1
2
3
4
5
Start
Tmp
Ziel
Abbildung 7.5 Aufgabenstellung der Türme von Hanoi
Dabei sind folgende Regeln zu beachten:
왘
Der Spieler darf einen Hilfsständer (Tmp) zur Zwischenablage benutzen.
왘
Es darf in einem Schritt immer nur ein Ring bewegt werden.
왘
Es darf nie ein kleinerer Ring unter einem größeren Ring liegen.
3 An dieser Stelle möchte ich keine konkreten Messungen durchführen. Wir werden uns später
noch intensiv mit dem Laufzeitverhalten von Funktionen auseinandersetzen.
194
7.4
Rekursion
Die letzte Bedingung besagt, dass die Stapel immer der Größe nach sortiert bleiben
müssen – egal, auf welchem Ständer sie sich befinden. Wir wollen versuchen, fünf
Ringe unter Beachtung der Bedingungen zu transportieren. Dazu legen wir eine
mutige Annahme zugrunde. Wir stellen uns vor, dass wir vier Ringe regelkonform
bewegen können4. Dann könnten wir wie folgt vorgehen:
Bringe zunächst 4 Ringe
von Start über Ziel nach Tmp
7
5
1
2
3
4
Start
Tmp
Ziel
Abbildung 7.6 Zwischenstand auf dem Weg zur Lösung
Wir haben ja angenommen, dass wir das können. Der nächste Schritt ist dann klar.
Wir legen den 5. Ring an seine endgültige Position:
Bringe den 5. Ring von
Start nach Ziel
Start
1
2
3
4
5
Tmp
Ziel
Abbildung 7.7 Ring 5 an seiner Zielposition
Danach können wir noch mal vier Ringe transportieren:
4 Wie das geht, interessiert uns nicht.
195
7
Modularisierung
Dann bringe 4 Ringe von
Tmp über Start nach Ziel
1
2
3
4
5
Start
Tmp
Ziel
Abbildung 7.8 Die gelöste Aufgabe
Und damit sind wir fertig. Das Verfahren hängt natürlich noch völlig in der Luft, denn
wir haben nur gezeigt, dass wir fünf Ringe schaffen, sofern wir vier Ringe schaffen.
Aber mit der gleichen Argumentation wie oben sehen Sie, dass man vier Ringe
schafft, sofern man drei Ringe schafft. Und man schafft zwei, sofern man einen
schafft. Und einen Ring schafft man locker, indem man ihn einfach umlegt. Jetzt
zieht der Schluss durch: Man schafft einen, also auch zwei. Man schafft zwei, also
auch drei ... Letztlich schafft man also beliebig große Stapel5.
Hinter dieser Vorüberlegung verbirgt sich auch schon das komplette Verfahren. Wir
müssen es nur noch programmieren:
A
B
C
D
E
F
void hanoi( int n, char start, char tmp, char ziel)
{
if (n > 1)
{
hanoi( n – 1, start, ziel, tmp);
printf( "Ring %d: %c -> %c\n", n, start, ziel);
hanoi( n – 1, tmp, start, ziel);
}
else
printf( "Ring %d: %c -> %c\n", n, start, ziel);
}
5 Streng mathematisch müsste man hier einen Beweis durch vollständige Induktion führen, aber
anschaulich ist klar, dass der Schluss »durchläuft«.
196
7.4
Rekursion
void main()
{
hanoi( 5, 'S', 'T', 'Z');
}
G
Listing 7.11 Die implementierte Funktion hanoi
Die Funktion hanoi bewegt n Ringe von start über tmp nach ziel (A). Wenn mehr als
ein Ring zu bewegen ist (B), dann führe die folgenden drei Aktionen aus:
왘
Bewege n-1 Ringe von start über ziel nach tmp (C).
왘
Bewege den n-ten Ring von start nach ziel (D).
왘
Bewege n-1 Ringe von tmp über start nach ziel (E).
7
Wenn nur ein Ring zu bewegen ist, ist, dann bewege ihn direkt von start nach ziel (F).
Ring 1
Ring 2
Ring 3
Ring 4
Ring 5
Der Aufruf der rekursiven hanoi-Funktion kann nun aus dem Hauptprogramm erfolgen, z. B. um fünf Ringe von S über T nach Z zu bewegen (G). Dieses Programm
erzeugt die erforderlichen Handlungsanweisungen:
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
Ring
1:
2:
1:
3:
1:
2:
1:
4:
1:
2:
1:
3:
1:
2:
1:
5:
1:
2:
1:
3:
1:
2:
1:
4:
1:
2:
1:
3:
1:
2:
1:
S
S
Z
S
T
T
S
S
Z
Z
T
Z
S
S
Z
S
T
T
S
T
Z
Z
T
T
S
S
Z
S
T
T
S
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
Z
T
T
Z
S
Z
Z
T
T
S
S
T
Z
T
T
Z
S
Z
Z
S
T
S
S
Z
Z
T
T
Z
S
Z
Z
Abbildung 7.9 Alle Handlungsanweisungen zur Lösung als Baum
197
7
Modularisierung
Links neben die Ausgabe habe ich eine »Baumstruktur« gezeichnet, die Ihnen helfen
soll zu verstehen, wie es zu dieser Ausgabe kommt. Jeder Knoten des Baums steht für
einen Aufruf der Funktion hanoi. Die durchgezogenen Linien stehen für einen Funktionsaufruf, die gestrichelten für das direkte Bewegen eines einzelnen Rings. Sie
sehen, dass auf den höheren Aufrufebenen (Ring = n > 1) immer ein Unterprogrammaufruf, gefolgt von einer Bewegung, gefolgt von einem erneuten Unterprogrammaufruf erfolgt. Auf den tiefsten Ebenen (Ring = n = 1) gibt es dann keine
Unterprogrammaufrufe mehr, sondern nur jeweils eine Bewegung des Rings. Exakt
so ist das durch das Programm vorgegeben. Der Baum wird so abgearbeitet, dass es
immer zuerst in die Tiefe geht, dann eine Ebene zurück, wenn es nicht mehr weitergeht. Die von einem Knoten ausgehenden Linien werden von oben nach unten abgefahren. Dadurch ergibt sich genau die rechts stehende Ausgabe.
Die Rekursion ist eine durchaus anspruchsvolle Technik, da man bei der Konzeption
rekursiver Funktionen leicht einen »Knoten im Gehirn« bekommen kann. Bewegen
Sie das oben erläuterte Programm so lange in Ihrem Kopf, bis der Knoten entwirrt ist.
Was hier zur Verwirrung beiträgt, ist, dass die Ständer in der Rekursion ihre Rollen
wechseln. Ein Ständer, der eben noch Startpunkt war, ist auf der nächsten Rekursionsebene vielleicht Zwischenablage oder Ziel. Nehmen Sie sich Zeit. Manchmal dauert es etwas länger, bis ein Groschen fällt. Ich kann Ihnen aber eines mit Sicherheit
versprechen: Wenn dieser Groschen fällt, werden auch alle weiteren Groschen in diesem Buch fallen.
7.5
Der Stack
Ein tieferes Verständnis für Rekursion gewinnen Sie, wenn Sie sich klarmachen, was
beim Aufruf einer Funktion »unter der Haube« abläuft. Beim Unterprogrammaufruf
spielt der sogenannte Stack eine entscheidende Rolle. Ein Stack ist ein »Stapelspeicher«, den Sie sich wie einen Tellerstapel in einem Restaurant vorstellen können.
Neue Informationen (Teller) werden auf den Stapel gelegt und können wieder vom
Stapel entfernt werden. Wichtig ist, dass die Information, die als letzte auf den Stapel
gekommen ist, auch als erste wieder heruntergenommen werden muss. Man
bezeichnet dies auch als Last-In-First-Out- oder kurz LIFO-Prinzip. Stacks haben in
der Informatik große Bedeutung und werden als allgemeine Warteschlangenstruktur
später noch ausführlich behandelt. Hier interessiert uns im Moment nur die Rolle
des Stacks beim Unterprogrammaufruf.
Wenn eine Funktion eine andere ruft, werden zunächst die Aufrufparameter, die ja
durch Formelausdrücke gegeben sein können, ausgewertet. Die Ergebnisse werden
auf den Stack gelegt. Beim Aufruf wird dem Unterprogramm mitgeteilt, wo es seine
Parameter auf dem Stack findet. Das Unterprogramm legt dann seine lokalen Variablen zusätzlich auf dem Stack ab. Eine Funktion – oder besser: jede Aufrufinstanz einer
198
7.5
Der Stack
Funktion – hat damit einen eigenen, dynamisch wachsenden Bereich auf dem Stack,
in dem es seine lokalen Daten ablegt. Wenn die Funktion beendet wird, wird der Stack
wieder abgebaut, und die lokalen Daten der Funktionsinstanz verschwinden. Das
rufende Programm sieht dann wieder seine lokalen Daten auf dem Stack, als hätte
der Unterprogrammaufruf nie stattgefunden.
Das hier beschriebene Prinzip gilt insbesondere für einen rekursiven Funktionsaufruf, bei dem ja mehrere Aufrufinstanzen ein und derselben Funktion ineinander
geschachtelt existieren können. Jede Instanz hat dann für ihren Lebenszyklus einen
eigenen Satz lokaler Variablen, der unabhängig von den lokalen Variablen anderer
Aufrufinstanzen der gleichen Funktion ist. Wenn es also in einer Funktion eine lokale
Variable x gibt, existiert diese Variable in allen Aufrufinstanzen dieser Funktion als
eigenständige Variable. Wenn dann in verschiedenen Aufrufinstanzen auf die Variable x zugegriffen wird, haben diese Zugriffe nichts miteinander zu tun, weil auf verschiedene Bereiche des Stacks zugegriffen wird.
Abbildung 7.10 zeigt dieses Vorgehen am Beispiel der Fakultätsberechnung:
Stack
void main()
{
int k;
k = fakultaet( 3);
}
k:6
Jede Aufrufinstanz hat ihre
eigenen lokalen Variablen
auf dem Stack.
6
n:3
int fakultaet(int n)
{
int f;
if( n <= 1)
return 1;
f = n*fakultaet(n-1);
return f;
1
}
n:2
int fakultaet(int n)
{
int f;
if( n <= 1)
return 1;
f = n*fakultaet(n-1);
return f;
}
n:1
f:6
f:2
1
2·1
3·2
int fakultaet(int n)
{
int f;
if( n <= 1)
return 1;
f = n*fakultaet(n-1);
return f;
2
}
f:?
Beim Aufruf werden die
übergeben Parameter als
lokale Variablen auf den
Stack gelegt.
Der Stack »wächst«, wenn
die Aufrufinstanz weitere
lokale Variablen anlegt.
Beim Verlassen des
Unterprogramms wird der
Stack »zurückgefahren« und
alle lokalen Variablen der
Aufrufinstanz verschwinden.
Abbildung 7.10 Der Stack
199
7
7
Modularisierung
7.6
Beispiele
Die Beispiele dieses Abschnitts werden etwas umfangreicher werden als alle vorangegangenen Beispiele. Ich will Ihnen ja zeigen, dass Sie durch Modularisierung auch
Probleme in Angriff nehmen können, an die Sie sich bisher nicht herangetraut hätten. Sie werden dabei auch sehen, dass Sie auch schon Beiträge zur Lösung eines Problems programmieren können, ohne bereits zu wissen, wie die endgültige Lösung
des Problems einmal aussehen könnte.
7.6.1
Bruchrechnung
Im ersten Beispiel geht es um Bruchrechnung. Sie werden positive Brüche addieren.
Dabei werden Sie nicht mit Gleitkommazahlen rechnen, sondern immer Zähler und
Nenner explizit berechnen, wie Sie das in der Schule gelernt haben. Also:
1 1
5
-- + -- = --2 3
6
Sie wissen, dass allgemein gilt:
a c
ad + bc
--- + --- = ------------------b d
bd
Dabei sollte das Ergebnis immer gekürzt sein. Kürzen bedeutet, dass Zähler und Nenner durch den größten gemeinsamen Teiler (ggT) dividiert werden. Sie wissen also,
dass Sie, egal, wie Sie später die Bruchrechnung programmieren werden, eine Funktion zur Berechnung des ggT benötigen. Mit dieser Funktion fangen Sie an.
Den ggT von zwei Zahlen berechnen Sie, indem Sie so lange die kleinere Zahl von der
größeren abziehen, bis beide Zahlen gleich sind:
A
B
C
int ggt( int a,
{
for( ; a !=
{
if( a >
a =
else
b =
}
return a;
}
int b)
b; )
b)
a – b;
b – a;
Listing 7.12 Berechnung des ggT
200
7.6
Beispiele
Solange a und b verschieden sind (A), wird die kleinere von der größeren Zahl abgezogen (B). Am Ende ist der ggT in a und wird zurückgegeben (C). Da a=b gilt, hätte aber
auch b zurückgegeben werden können.
Bei einem Bruch müssen Sie Zähler und Nenner speichern. Dafür bietet sich ein Array
mit zwei int-Werten an. Damit ist aber auch schon klar, wie Sie das Kürzen eines
Bruchs implementieren können. Sie müssen nur Zähler und Nenner durch den ggT
dividieren:
A
B
C
void kuerzen( int b[])
{
int gt;
gt = ggt( b[0], b[1]);
b[0] = b[0]/gt;
b[1] = b[1]/gt;
}
7
Listing 7.13 Funktion zum Kürzen eines Bruchs
In der Funktion wird zuerst der ggT von Zähler und Nenner berechnet (A), danach
werden Zähler und Nenner durch den ggT dividiert (B) und (C).
Beachten Sie, dass diese Funktion wie auch die nächste keinen Returnwert benötigt,
da Zähler und Nenner im Array direkt verändert werden.
void addieren( int b1[], int b2[], int erg[])
{
erg[0] = b1[0]*b2[1] + b2[0]*b1[1];
erg[1] = b1[1]*b2[1];
kuerzen( erg);
}
Listing 7.14 Funktion zum Addieren von Brüchen
Das Addieren der Brüche wird nun gemäß der Vorschrift ausgeführt und das Ergebnis dann gekürzt. Im Hauptprogramm erstellen Sie einen Testrahmen:
A
void main()
{
int bruch1[2], bruch2[2], ergebnis[2];
printf( "Bruch1: ");
scanf( "%d/%d", &bruch1[0], &bruch1[1]);
printf( "Bruch2: ");
scanf( "%d/%d", &bruch2[0], &bruch2[1]);
201
7
Modularisierung
B
addieren(bruch1,bruch2,ergebnis);
printf( "Ergebnis: %d/%d\n", ergebnis[0], ergebnis[1]);
}
Listing 7.15 Der Testrahmen für die Addition
In dem Testrahmen werden drei Brüche angelegt (A). Das Ergebnis der Addition von
bruch1 und bruch2 wird in ergebnis abgelegt. Der Testrahmen erzeugt damit die folgende Ausgabe:
Bruch1: 1/3
Bruch2: 1/6
Ergebnis: 1/2
Bei der Erstellung des Programms sind wir konsequent »bottom up« vorgegangen.
Das heißt, wir haben gerufene Funktionen vor rufenden Funktionen implementiert.
Das muss nicht so sein. Häufig geht man auch »top down« vor. Bottom up bietet den
Vorteil, dass Sie das Programm auf jeder Entwicklungsstufe testen können. Zum Beispiel können Sie die ggT-Funktion testen, ohne das Kürzen oder die Addition implementiert zu haben. Umgekehrt können Sie das Kürzen nicht ohne die ggT-Funktion
testen. Bei großen Softwaresystemen können Sie jedoch in der Regel nicht bottom up
vorgehen, da Sie die Detailinformationen, die Sie dazu benötigen, nicht oder noch
nicht haben. Typischerweise kommen in Softwareprojekten immer beide Vorgehensweisen vor.
7.6.2
Das Damenproblem
Unser nächstes Beispiel kommt aus dem Bereich des Schachspiels, daher zunächst
einige Vorbemerkungen für Nicht-Schachspieler. Die Dame ist die schlagkräftigste
und spielstärkste Figur im Schach. Von dem Feld, auf dem sie steht, beherrscht sie
alle waagerecht, senkrecht oder diagonal erreichbaren Felder.
Die Aufgabe des Damenproblems lautet, n Damen auf einem quadratischen Schachbrett der Breite n so zu positionieren, dass keine Dame eine andere schlagen kann.
Das bedeutet:
왘
höchstens (genau) eine Dame in jeder Zeile
왘
höchstens (genau) eine Dame in jeder Spalte
왘
höchstens eine Dame in jeder Diagonalen
202
7.6
Beispiele
D
7
Abbildung 7.11 Die Zugmöglichkeiten der Dame
Im klassischen Fall des 8×8-Schachbretts gibt es 92 verschiedene Lösungen, von
denen eine in Abbildung 7.12 dargestellt ist:
1
damen[0]
2
3
4
5
6
7
D
D
damen[1]
D
damen[2]
D
damen[3]
D
damen[4]
D
damen[5]
damen[6]
damen[7]
8
D
D
Abbildung 7.12 Eine mögliche Lösung des Damenproblems
Eine naheliegende Datenstruktur zur Lösung des Problems ist, wie die Zeichnung
bereits andeutet, ein Array mit acht Integer-Zahlen. Die Anweisung: damen[3] = 6;
bedeutet dann, dass die Dame mit dem Index 3 in die Spalte 6 gesetzt wird. Wenn es
203
7
Modularisierung
uns gelingt, in dem Array alle möglichen Stellungen zu erzeugen und dann die korrekten Stellungen herauszufiltern, hätten wir alle Lösungen gefunden.
Über die Erzeugung der Stellungen werden wir uns zunächst noch keine Gedanken
machen, aber wir wissen, dass wir Stellungen und einzelne Damen gegeneinander
prüfen müssen. Wir überlegen uns also, wann sich zwei Damen in unserem Modell
schlagen können. Dass zwei Damen in der gleichen Zeile stehen, ist in unserem
Modell ausgeschlossen. Es bleiben also die Fälle, dass zwei Damen in der gleichen
Spalte oder in der gleichen Diagonalen stehen:
1
2
3
4
5
6
7
8
damen[0]
damen[1]
D
Die Damen 1 und 6 können sich schlagen wegen:
damen[1] = damen[6]
damen[2]
D
damen[3]
damen[4]
damen[5]
damen[6]
D
D
damen[7]
Die Damen 3 und 7 können sich schlagen wegen:
|damen[3] – damen[7]| = |3 – 7|
Abbildung 7.13 Überprüfung einer Stellung
Im ersten Fall ist der Horizontalabstand 0, im zweiten Fall ist der Horizontalabstand
gleich dem Vertikalabstand. Offensichtlich spielt der Abstand (= Absolutbetrag der
Differenz) eine Rolle bei der Prüfung einer Stellung. Also implementieren Sie die
Abstandsberechnung als eigenständige Funktion:
int abstand( int x, int y)
{
if( x >= y)
return x – y;
return y – x;
}
Listing 7.16 Berechnung des Abstands
204
7.6
Beispiele
Mit dieser Hilfsfunktion können Sie prüfen, ob sich zwei Damen schlagen können:
A
B
C
D
E
int schlagen( int x, int y, int damen[])
{
int dh, dv;
dv = abstand( x, y);
dh = abstand( damen[x], damen[y]);
if( (dh == 0) || (dv == dh))
return 1;
return 0;
}
7
Listing 7.17 Prüfung auf Schlagmöglichkeit
Die Funktion erhält die Indizes der beiden zu prüfenden Damen sowie ein Array der
zu prüfenden Stellungen als Eingangsparameter (A) und berechnet den Vertikalabstand dv (B) und den Horizontalabstand dh (C). Im Folgenden wird geprüft, ob der
Horizontalabstand == 0 oder der Vertikalabstand == Horizontalabstand (D). Ist das
der Fall, können sich die Damen schlagen. Andernfalls können sich die Damen nicht
schlagen, und es wird 0 zurückgegeben.
Sie werden eine Stellung Schritt für Schritt aufbauen, indem Sie zunächst die erste,
dann die zweite, dann die dritte Dame etc. zu positionieren versuchen. Eine neue
Dame zu positionieren ist natürlich nur sinnvoll, wenn diese Dame keine der zuvor
positionierten Damen schlagen kann. Sie erstellen daher eine Funktion, die die dazu
erforderlichen Prüfungen vornimmt:
A
B
C
int stellung_ok( int x, int damen[])
{
int i;
for( i = 0; i < x; i = i+1)
{
if( schlagen( i, x, damen))
return 0;
}
return 1;
}
Listing 7.18 Prüfen auf gültige Stellung
In der Funktion wird eine Dame x gegen alle vorher gesetzten Damen i geprüft (A).
Wenn die Dame x eine dieser Damen schlagen kann, ist die Stellung nicht okay (B).
Ansonsten ist die Stellung okay (C).
205
7
Modularisierung
Schließlich brauchen Sie auch noch eine Funktion zur Ausgabe einer Lösung:
A
B
C
int laufendenummer = 0;
void print_loesung( int anz, int damen[])
{
int i;
laufendenummer++;
printf( "%2d. Loesung: ", laufendenummer);
for( i = 0; i < anz; i = i + 1)
printf( " %d", damen[i]);
printf( "\n");
}
Listing 7.19 Ausgabe einer Lösung
Die Lösungen werden in einer globalen Variablen gezählt (A). Diese globale Variable
wird in der Funktion fortlaufend hochgezählt (B). Die Lösungsausgabe erfolgt dann
jeweils mit der laufenden Nummer.
Wenn Ihnen eine feste Anzahl von Damen vorgegeben ist, können Sie alle möglichen
Stellungen durch ineinander geschachtelte Schleifen erzeugen. Das bedeutet allerdings, dass Sie so viele ineinander geschachtelte Schleifen wie Damen haben. Abgesehen davon, dass das sehr mühselig zu programmieren ist, ist diese Lösung auch sehr
unflexibel, da sie nur für diese eine feste Zahl von Damen gilt und für mehr oder
weniger Damen neu programmiert werden müsste. Trotzdem werden Sie diese
Lösung einmal für vier Damen erstellen:
A
B
C
D
206
void damen4()
{
int damen[4];
for( damen[0] = 1; damen[0] <= 4; damen[0]++)
{
for( damen[1] = 1; damen[1] <= 4; damen[1]++)
{
if( !stellung_ok( 1, damen))
continue;
for( damen[2] = 1; damen[2] <= 4; damen[2]++)
{
if( !stellung_ok( 2, damen))
continue;
for(damen[3] = 1; damen[3] <= 4; damen[3]++)
{
7.6
E
Beispiele
if( stellung_ok( 3, damen))
print_loesung( 4, damen);
}
}
}
}
}
Listing 7.20 Lösung des Damenproblems für vier Damen
In der Funktion wird zuerst ein Array für vier Damen erstellt (A). Danach werden
beginnend mit der ersten Schleife (A) in vier Schleifen alle möglichen Stellungen
erzeugt. Innerhalb der Schleifen wird geprüft, ob eine Dame eine zuvor gesetzte
Dame schlagen kann. Falls das der Fall ist, wird die Stellung nicht weiter untersucht
(C) und (D). Haben Sie eine Stellung gefunden, bei der das auf keiner Ebene der Fall ist,
haben Sie damit auch eine Lösung gefunden (E).
Bis auf die mangelnde Flexibilität ist das eine akzeptable Lösung. Aber wie kommen
Sie zu einer allgemeineren und flexibleren Lösung? Ganz einfach, indem Sie die erste
Dame bewegen und für die Bewegung der zweiten und jeder weiteren Dame das Programm in die Rekursion schicken. Dazu müssen Sie zunächst einmal eine »rekursionsfähige« Schnittstelle festlegen. Das Array mit den Damen darf jetzt nicht mehr in
der Funktion angelegt werden, da es dann ja bei jedem rekursiven Aufruf neu erzeugt
würde. Das Array müssen Sie also außerhalb der Rekursion anlegen und dann an der
Schnittstelle durchreichen. Zusätzlich müssen Sie dann auch die Größe des Arrays (=
Anzahl Damen) an der Schnittstelle übertragen. Die rekursiven Funktionsausrufe
arbeiten immer mit einer speziellen Dame. Auf Rekursionstiefe 0 arbeiten Sie mit
der Dame 0, auf Rekursionstiefe 1 mit der Dame 1 etc. Auch diese Information müssen Sie an der Schnittstelle bereitstellen. Dies setzen Sie in Programmcode um:
A
void damenproblem( int anz, int damen[], int lvl)
{
for( damen[lvl] = 1; damen[lvl] <= anz; damen[lvl]++)
{
/* Rekursion auf dem naechsten */
/* Level (lvl +1) */
}
}
Listing 7.21 Schema der Funktion für die rekursive Lösung
Bei der jetzt noch anstehenden Implementierung der Rekursion müssen Sie zwei
Aspekte beachten:
207
7
7
Modularisierung
왘
Wenn Sie das maximale Level (= anz) erreichen, haben Sie eine Lösung gefunden.
In diesem Fall müssen Sie die Lösung ausgeben und dürfen nicht erneut in die
Rekursion absteigen.
왘
In allen anderen Fällen steigen Sie nur dann weiter in die Rekursion ab, wenn die
bisher gefundene Stellung in Ordnung ist.
Damit haben Sie eine allgemeine Lösung für das Damenproblem:
A
B
C
D
E
void damenproblem( int anz, int damen[], int lvl)
{
if( lvl == anz)
{
print_loesung( anz, damen);
return;
}
for( damen[lvl] = 1; damen[lvl] <= anz; damen[lvl]++)
{
if( stellung_ok( lvl, damen))
damenproblem( anz, damen, lvl+1);
}
}
Listing 7.22 Rekursive Lösung des Damenproblems
Wenn in der rekursiven Funktion eine Lösung gefunden wird (A), erfolgt eine Ausgabe, es wird nicht weiter abgestiegen. Wenn eine Stellung okay ist (D), wird rekursiv
auf das nächste Level abgestiegen (E).
Wichtig ist jetzt noch der Aufruf der rekursiven Funktion. Sie müssen das DamenArray außerhalb der Funktion anlegen und die Funktion mit dem richtigen Startlevel
(0) rufen. Die Anzahl der Damen können Sie relativ frei wählen:
void main()
{
int damen[20];
int anz;
printf( "Anzahl: ");
scanf( "%d", &anz);
damenproblem( anz, damen, 0);
}
Listing 7.23 Damenproblem mit wählbarer Anzahl von Damen
208
7.6
Beispiele
Für das 8-Damen-Problem gibt es 92 Lösungen, von denen die ersten zehn folgendermaßen aussehen:
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
Loesung:
1
1
1
1
2
2
2
2
1
2
5
6
7
7
4
5
5
6
6
7
8
8
4
5
6
7
7
1
8
3
6
3
6
8
8
1
4
7
3
6
3
7
8
2
3
3
1
4
1
8
7
4
2
4
1
8
8
8
4
5
2
2
5
6
7
6
6
3
7
1
4
5
3
3
5
4
3
5
5
4
7
Wenn ich gesagt habe, dass Sie die Zahl der Damen nur »relativ« frei wählen können,
hat das damit zu tun, dass ich das Damen-Array auf 20 Einträge limitiert habe. Diese
Einschränkung mögen Sie als unglücklich empfinden, und wir werden uns später
darüber Gedanken machen, wie wir uns von solchen Beschränkungen lösen können.
An dieser Stelle möchte ich Ihren Blick darauf lenken, dass die Zahl 20 keine wirkliche
Beschränkung darstellt, da es hier eine ganz andere Beschränkung gibt – die Laufzeit
des Programms. Ich habe das Programm so geändert, dass nicht mehr die einzelnen
Lösungen ausgegeben werden, sondern nur deren Gesamtzahl. Zusätzlich habe ich
ausgegeben, wie viele Stellungen bei der Lösungssuche untersucht wurden. Sie erhalten das folgende bemerkenswerte Ergebnis:
Damen Loesungen
1
1
2
0
3
0
4
2
5
10
6
4
7
40
8
92
9
352
10
724
11
2680
12
14200
13
73712
14
365596
15
2279184
Stellungen
1
6
18
60
220
894
3584
15720
72378
348150
1806706
10103868
59815314
377901398
532748320
209
Modularisierung
Bei zunehmender Damenzahl sehen Sie einen extremen Anstieg der zu untersuchenden Stellungen. Das ist auch nicht verwunderlich, da insgesamt bei n Damen bis zu
n · n … n = nn
n mal
⎧
⎪
⎨
⎪
⎩
7
unterschiedliche Aufstellungen der Damen in der Rekursion erzeugt werden können. Viele davon fallen weg, da zu einer unfertigen, aber bereits als ungültig erkannten Stellung keine weiteren Damen mehr gesetzt werden, aber es bleiben genug
Stellungen übrig, um auch den schnellsten Rechner lahmzulegen. Das ist übrigens
kein Problem, das der Rekursion geschuldet ist. Ein iteratives Programm wäre sicher
schneller, würde aber auch an der kombinatorischen Explosion der zu untersuchenden Stellungen scheitern.
7.6.3
Permutationen
Stellen Sie sich vor, dass beim Scrabble ein Haufen von zehn Buchstabenklötzchen
vor Ihnen liegt und Sie herausfinden wollen, ob man mit den Buchstaben ein sinnvolles Wort legen kann. Eine Lösung könnte darin bestehen, die Buchstaben systematisch in allen möglichen Reihenfolgen – man nennt das Permutationen – auf den
Tisch zu legen und zu prüfen, ob dabei ein gültiges Wort vorkommt. Für das gesuchte
Verfahren drängt sich ein rekursives Vorgehen förmlich auf. Sie legen jeden der zehn
Buchstaben einmal an die erste Position. Dann müssen Sie nur noch die restlichen
neun Buchstaben in allen möglichen Reihenfolgen dahinterlegen. Das können Sie
sofort so programmieren:
A
B
C
D
E
F
G
H
I
210
void perm( int anz, char array[], int start)
{
int i;
char sav;
if( start < anz)
{
sav = array[ start];
for( i = start; i < anz; i = i+1)
{
array[start] = array[i];
array[i] = sav;
perm( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
else
7.6
J
Beispiele
printf( "%s\n", array);
}
Listing 7.24 Die Funktion perm
Die Funktion perm bekommt die Anzahl der Zeichen und das Array mit den Zeichen
übergeben. In dem Parameter start ist die Stelle enthalten, ab der noch weitere Vertauschungen durchzuführen sind (A).
Solange noch Vertauschungen vorgenommen werden müssen (B), werden die folgenden Schritte ausgeführt:
왘
Das Element an der Startposition wird gesichert (C).
왘
Danach werden alle Elemente ab der Startposition durchlaufen (D). Jedes Element
wird im Austausch mit sav einmal an die Startposition gebracht und am Ende wieder in seine Ausgangsposition gesetzt.
In (J) ist eine neue Permutation (start == anz) fertig und wird ausgegeben.
In einem Hauptprogramm testen wir das mit den Buchstaben »TEWR« und stellen
fest, dass »WERT« das einzige Wort ist, das wir mit diesen Buchstaben legen können:
void main()
{
char haufen[5] = "TEWR";
printf( "Vorher: %s\n", haufen);
perm( 4, haufen, 0);
printf( "Nachher: %s\n", haufen);
}
Listing 7.25 Berechnung der Permutationen
Das Programm erzeugt dabei die folgende Ausgabe:
Vorher: TEWR
TEWR
TERW
TWER
TWRE
TRWE
TREW
ETWR
ETRW
EWTR
EWRT
211
7
7
Modularisierung
ERWT
ERTW
WETR
WERT
WTER
WTRE
WRTE
WRET
REWT
RETW
RWET
RWTE
RTWE
RTEW
Nachher: TEWR
Die Ausgaben am Anfang und am Ende zeigen, dass die Reihenfolge der Elemente
nach allen zwischenzeitlich durchgeführten Vertauschungen am Ende wieder der
Ausgangsreihenfolge entspricht. Abbildung 7.14 zeigt, wie der Algorithmus vorgeht:
start = 0
start = 1
start = 2
TE??
T???
TW??
TR??
ET??
E???
EW??
ER??
????
WE??
W???
WT??
WR??
RE??
R???
RW??
RT??
Abbildung 7.14 Arbeitsweise des Algorithmus
212
start = 3
start = 4
TEW?
TER?
TWE?
TWR?
TRW?
TRE?
ETW?
ETR?
EWT?
EWR?
ERW?
ERT?
WET?
WER?
WTE?
WTR?
WRT?
WRE?
REW?
RET?
RWE?
RWT?
RTW?
RTE?
TEWR
TERW
TWER
TWRE
TRWE
TREW
ETWR
ETRW
EWTR
EWRT
ERWT
ERTW
WETR
WERT
WTER
WTRE
WRTE
WRET
REWT
RETW
RWET
RWTE
RTWE
RTEW
7.6
Beispiele
Um die Fragezeichen zu beseitigen, werden der Reihe nach alle noch verfügbaren
Buchstaben eingesetzt, und das Programm wird rekursiv zur Beseitigung der restlichen Fragezeichen aufgerufen. Das gibt auf der höchsten Ebene 4, dann jeweils 3,
dann 2 und dann einen Unterprogrammaufruf.
Insgesamt ergeben sich damit bei n Elementen n! (n-Fakultät) Vertauschungen. Das
heißt, dass wir für die eingangs angesprochenen zehn Buchstabenklötzchen insgesamt 10! = 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · 9 · 10 = 3628800 Vertauschungen erzeugen würden.
Eine wirkliche Hilfe beim Scrabblen wäre das nicht.
7
7.6.4
Labyrinth
In diesem Beispiel versetzen Sie sich in ein Labyrinth, das unfairerweise keinen Ausgang hat.
Abbildung 7.15 Das Labyrinth
Ihre Aufgabe besteht nun darin, für beliebige Start- und Zielpunkte einen Weg durch
das Labyrinth zu finden, sofern es einen solchen Weg überhaupt gibt.
Das Wegenetz des Irrgartens werden Sie in einem zweidimensionalen Array ablegen,
in dem Sie Mauern mit '#' und begehbare Bereiche mit ' ' markieren:
213
7
Modularisierung
char labyrinth[22][22] =
{
"#####################",
"# #
# #",
"# # ############# # #",
"# #
#
# #",
"# # ###### ###### # #",
"# # # #
# # # #",
"# # # # ##### # # # #",
"# # # #
# # # # #",
"# # # # ## ## # # # #",
"# ### ### # # # # #",
"# # # # # # #",
"# # # # # ### ### #",
"# # # # ## ## # # # #",
"# # # # #
# # # #",
"# # # # ##### # # # #",
"# # # #
# # # #",
"# # ###### ###### # #",
"# #
#
# #",
"# # ############# # #",
"# #
# #",
"#####################",
0
};
Listing 7.26 Die Umsetzung des Labyrinths im Code
In jeder Zeile des Arrays steht ein 0-terminierter String. Beachten Sie, dass der Compiler jede Zeile des Arrays mit 0 (nicht '0') abschließt. In das erste Feld der letzten
Zeile schreiben Sie explizit eine 0, um das Ende des Arrays zu markieren. Das so aufgebaute Array können Sie auf dem Bildschirm darstellen, indem Sie Zeile für Zeile
mit printf ausgeben:
void ausgabe()
{
int zeile;
for( zeile = 0; labyrinth[zeile][0] != 0; zeile++)
printf( "%s\n", labyrinth[zeile]);
}
Listing 7.27 Ausgabe einer Labyrinthzeile
214
7.6
Beispiele
Die Daten werden Zeile für Zeile ausgegeben, die Ausgabe wird beendet, wenn in der
ersten Spalte der betrachteten Zeile eine 0 steht. Für das Beispiel ergibt sich dann folgende Ausgabe:
#####################
# #
# #
# # ############# # #
# #
#
# #
# # ###### ###### # #
# # # #
# # # #
# # # # ##### # # # #
# # # #
# # # # #
# # # # ## ## # # # #
# ### ### # # # # #
# # # # # # #
# # # # # ### ### #
# # # # ## ## # # # #
# # # # #
# # # #
# # # # ##### # # # #
# # # #
# # # #
# # ###### ###### # #
# #
#
# #
# # ############# # #
# #
# #
#####################
7
Wenn ein Start- und einen Zielpunkt jeweils durch Zeilen- und Spaltenindex vorgegeben ist, können Sie versuchen, einen Weg zwischen den beiden Punkten zu finden. Es
geht dabei nicht darum, einen möglichst kurzen Weg zu finden, Hauptsache, Sie finden überhaupt einen Weg. Wichtig ist, dass Start- und Zielpunkt dabei auf gültigen,
begehbaren Feldern liegen. Das wird vom Programm nicht geprüft und muss bei der
Eingabe der Daten beachtet werden. Zeilen- und Spaltenindex beginnen, wie üblich,
bei 0. Rekursiv ist die Wegesuche verblüffend einfach zu programmieren:
A
B
C
D
int weg( int start_z, int start_s, int ziel_z, int ziel_s)
{
if( (start_z == ziel_z) && (start_s == ziel_s))
{
labyrinth[start_z][start_s] = '+';
return 1;
}
if( labyrinth[start_z-1][start_s] == ' ')
{
labyrinth[start_z][start_s] = '^';
215
7
Modularisierung
E
F
G
H
if( weg( start_z-1, start_s, ziel_z, ziel_s))
return 1;
}
if( labyrinth[start_z+1][start_s] == ' ')
{
labyrinth[start_z][start_s] = 'v';
if( weg( start_z+1, start_s, ziel_z, ziel_s))
return 1;
}
if( labyrinth[start_z][start_s-1] == ' ')
{
labyrinth[start_z][start_s] = '<';
if( weg( start_z, start_s-1, ziel_z, ziel_s))
return 1;
}
if( labyrinth[start_z][start_s+1] == ' ')
{
labyrinth[start_z][start_s] = '>';
if( weg( start_z, start_s+1, ziel_z, ziel_s))
return 1;
}
labyrinth[start_z][start_s] = '-';
return 0;
}
Listing 7.28 Die rekursive Wegsuche
Die Funktion weg erhält als Eingangsparameter Startzeile und Startspalte sowie Zielzeile und Zielspalte (A). In der Funktion wird zuerst geprüft, ob Sie am Ziel sind (B). In
diesem Fall schreiben Sie an Ihrer Position ein '+' und geben 1 (= Erfolg) zurück.
In (C) sind Sie nicht am Ziel, stellen aber fest, dass Sie nach oben gehen können. Sie
schreiben an Ihrer Position ein '^' (D). Wenn Sie rekursiv von der Position oberhalb
einen Weg zum Ziel finden (E), geben Sie 1 zurück (F). Andernfalls machen Sie mit den
folgenden Fällen (unten, links, rechts) weiter.
Wenn Sie die Position (G) erreichen, bedeutet das, dass Sie von hier aus keinen Weg
zum Ziel gefunden haben. Markieren Sie das Feld mit '-', und geben Sie 0 (= Misserfolg) zurück (H).
Felder auf Ihrem aktuellen Weg und Felder, die bereits erfolglos besucht wurden, werden im Array markiert, um zu verhindern, dass Sie in Ihrer eigenen Spur zurücklaufen oder bereits als erfolglos erkannte Wege erneut einschlagen. Im Hauptprogramm
fragen Sie Start- und Zielpunkt ab und starten die Wegsuche.
216
7.6
Beispiele
void main()
{
int start_z, start_s, ziel_z, ziel_s;
ausgabe();
printf( "\nStart (Zeile Spalte): ");
scanf( "%d %d", &start_z, &start_s);
printf( "Ziel (Zeile Spalte): ");
scanf( "%d %d", &ziel_z, &ziel_s);
7
if( weg( start_z, start_s, ziel_z, ziel_s))
ausgabe();
else
printf( "Kein Weg gefunden!\n");
}
Listing 7.29 Das Hauptprogramm zur Wegsuche
Im Hauptprogramm werden die Start- und Zielpositionen eingegeben, und die Wegsuche wird gestartet. Mit konkreten Eingaben erhalten Sie ein etwas sprödes Bildschirmprotokoll, das ich noch grafisch aufbereitet habe:
nicht zum Ziel führende Bereiche
nicht untersuchte Bereiche
Start (Zeile Spalte): 1 1
Ziel (Zeile Spalte): 19 19
#####################
#v#-------------#>>v#
#v#-#############^#v#
#v#--------#
^#v#
#v#-######-######^#v#
#v#-#-#-----#>>v#^#v#
#v#-#-#-#####^#v#^#v#
#v#-#-#----# ^#v#^#v#
#v#-#-#-##-##^#v#^#v#
#v###-###>>v#^#v#^#v#
#v#>>v#>>^#>>^#>>^#v#
#v#^#v#^#---###-###v#
#v#^#v#^##-##-#-#-#v#
#v#^#v#^ #----#-#-#v#
#v#^#v#^#####-#-#-#v#
#v#^#>>^#-----#-#-#v#
#v#^######-######-#v#
#v#^
#--------#v#
#v#^#############-#v#
#v#>>^#-----------#+#
#####################
Abbildung 7.16 Die Suche und ihr Protokoll
217
7
Modularisierung
Anhand der Markierungen, die das Programm im Array zurückgelassen hat, können
Sie genau erkennen, wo der Weg entlangführt, welche Felder als erfolglos ausgeschlossen und welche Felder nicht getestet wurden. Sie können den Weg auch selbst
finden, wenn Sie an jedem Punkt vorrangig nach oben, dann nach unten, links und
rechts gehen. Wenn Sie dabei in eine Sackgasse geraten, gehen Sie so weit zurück, bis
es wieder eine Alternative gibt. Das folgende Beispiel deutet dieses Vorgehen an:
Abbildung 7.17 Darstellung des Suchvorgehens
7.7
Aufgaben
A 7.1
Erstellen Sie eine Funktion, die einen String und einen Buchstaben übergeben
bekommt und zurückgibt, wie oft der Buchstabe in dem String vorkommt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.1 als Vorlage.
A 7.2 Erstellen Sie eine Funktion, die die Reihenfolge der Zeichen in einem String
umkehrt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.2 als Vorlage.
A 7.3 Erstellen Sie eine Funktion, die alle 'e' aus einem String entfernt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.3 als Vorlage.
218
7.7
Aufgaben
A 7.4 Lösen Sie das Damenproblem mithilfe des Beispielprogramms zur Erzeugung
von Permutationen.
A 7.5 Betrachten Sie das folgende Schema, in dessen Felder die Zahlen von 1 bis 8 so
eingetragen werden müssen, dass sich die Zahlen in den durch eine Linie verbundenen Feldern um mehr als 1 unterscheiden:
7
Abbildung 7.18 Das Schema
Finden Sie alle Lösungen des Problems, indem Sie das Zahlenschema auf ein
Array abbilden und dann alle möglichen Anordnungen der Zahlen erzeugen
und jeweils prüfen, ob die geforderten Bedingungen erfüllt sind!
A 7.6 Erstellen Sie eine Funktion, die die Zahlen in einem Array sortiert.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor,
um ein Array mit n Elementen zu sortieren?
A 7.7 Erstellen Sie eine Funktion, die die Zahlen in einem Array so umordnet, dass
anschließend alle negativen Zahlen vor allen nicht negativen Zahlen stehen.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor,
um ein Array mit n Elementen umzuordnen? Versuchen Sie, mit deutlich
weniger Vertauschungen auszukommen als in Aufgabe 7.7.6.
A 7.8 Erstellen Sie eine rekursive Funktion, die die Reihenfolge der Zahlen in einem
Array umkehrt.
219
7
Modularisierung
A 7.9 Der Springer ist eine leichte und bewegliche Figur beim Schach, die von ihrer
aktuellen Position aus bis zu acht Felder im sogenannten Rösselsprung (zwei
vorwärts, eins seitwärts) mit einem Zug erreichen kann:
S
Abbildung 7.19 Bewegungen des Springers
Erstellen Sie ein Programm, das einen Springer von einem beliebigen Startpunkt zu einem beliebigen Zielpunkt auf einem Schachbrett ziehen kann! Die
vom Programm gewählte Zugfolge muss nicht optimal sein und soll bei der
Ausgabe durch fortlaufende Nummern angezeigt werden:
Startpunkt (Zeile Spalte): 1 1
Zielpunkt (Zeile Spalte): 1 2
+--+--+--+--+--+--+--+--+
| 0|39| |33| 2|35|18|21|
+--+--+--+--+--+--+--+--+
| | | 1|36|19|22| 3|16|
+--+--+--+--+--+--+--+--+
|38| |32| |34|17|20| 9|
+--+--+--+--+--+--+--+--+
| | |37| |23|10|15| 4|
+--+--+--+--+--+--+--+--+
| |31| | | |25| 8|11|
+--+--+--+--+--+--+--+--+
| | | |24| |14| 5|26|
+--+--+--+--+--+--+--+--+
|30| | | |28| 7|12| |
+--+--+--+--+--+--+--+--+
| | |29| |13| |27| 6|
+--+--+--+--+--+--+--+--+
220
7.7
Aufgaben
A 7.10 Erweitern Sie das Programm der vorangegangenen Aufgabe so, dass eine optimale, d. h. möglichst kurze, Zugfolge ermittelt wird:
Startpunkt (Zeile Spalte): 1 1
Zielpunkt (Zeile Spalte): 1 2
+--+--+--+--+--+--+--+--+
| 0| 3| | | | | | |
+--+--+--+--+--+--+--+--+
| | | 1| | | | | |
+--+--+--+--+--+--+--+--+
| 2| | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
7
221
Kapitel 8
Zeiger und Adressen
Wissen heißt wissen, wo es geschrieben steht.
– Albert Einstein
Zu Beginn dieses Kapitels stellen wir uns eine eigentlich ganz einfach erscheinende
Aufgabe. Wir wollen eine Funktion erstellen, die die Werte von zwei Integer-Variablen im rufenden Programm vertauscht. Wir legen ganz unbekümmert los:
A
B
void tausche( int a, int b)
{
int t;
t = a;
a = b;
b = t;
}
void main()
{
int x = 1;
int y = 2;
printf( "Vorher: %d %d\n", x, y);
tausche( x, y);
printf( "Nachher: %d %d\n", x, y);
}
Listing 8.1 Tauschen von Werten
Das Programm enthält eine Funktion tausche (A). Diese Funktion erhält zwei Parameter, a und b, und tauscht deren Werte (B). Im Hauptprogramm rufen wir die Funktion tausche, um die Werte von x und y zu tauschen.
Vorher: 1 2
Nachher: 1 2
223
8
8
Zeiger und Adressen
Das Ergebnis ist enttäuschend. Es passiert nichts. Das war aber auch zu erwarten. Sie
wissen ja bereits, dass beim Aufruf des Unterprogramms die Werte von x und y in
eigenständige, nur dem Unterprogramm gehörende, Variablen umkopiert werden.
Ein Vertauschen der Werte dieser Variablen im Unterprogramm hat keinerlei Auswirkungen auf die ursprünglichen Variablen im Hauptprogramm. Auch ein Umbenennen von x in a und y in b würde an dieser Situation nichts ändern.
Sie könnten die Variablen x und y des Hauptprogramms global anlegen und dann im
Unterprogramm auf diese globalen Variablen zugreifen. Bei genauer Betrachtung
erweist sich diese Idee aber als keine echte Lösung der gestellten Aufgabe, da das
Unterprogramm dann ja nur genau diese beiden Variablen vertauschen könnte und
nicht allgemein zur Vertauschung von Variablen eingesetzt werden könnte.
Um das Problem wirklich zu lösen, müssen Sie dem Unterprogramm den gezielten
Zugriff auf ausgewählte Variablen des Hauptprogramms ermöglichen. Dazu müssen
Sie dem Unterprogramm mitteilen, wo diese Variablen im Speicher stehen. Eine Variable steht im Speicher an einer bestimmten Adresse, die der Compiler kennt, weil er
die Variable dort angelegt hat. Sie können den Compiler auffordern, Ihnen diese
Adresse zu geben:
Die Speicheradresse einer Variablen erhalten Sie, indem Sie dem Variablennamen den Adress-Operator & voranstellen.
Der konkrete Wert einer Adresse interessiert uns in der Regel nicht. Eine Adresse ist
für uns nur eine eindeutige Zugriffsinformation auf das, was an dieser Adresse im
Speicher hinterlegt ist.
Ändern Sie das Hauptprogramm ab, indem Sie jetzt nicht mehr die Werte der Variablen, sondern die Adressen der Variablen übergeben:
A
void main()
{
int x = 1;
int y = 2;
printf( "Vorher: %d %d\n", x, y);
tausche( &x, &y);
printf( "Nachher: %d %d\n", x, y);
}
Listing 8.2 Funktion tausche mit Übergabe von Adressen
In (A) wird die Funktion mit den Adressen der Variablen aufgerufen.
Jetzt passen allerdings der Funktionsaufruf und die Funktionsschnittstelle nicht
mehr zueinander. In den Parametervariablen stehen jetzt nicht mehr Integer-Werte,
sondern Adressen von Integer-Variablen. So etwas bezeichnet man als Zeiger:
224
왘
Eine Variable, in der die Adresse einer anderen Variablen gespeichert ist, nennen
wir eine Zeigervariable oder kurz Zeiger bzw. Pointer.
왘
Die Variable, deren Adresse im Zeiger gespeichert ist, bezeichnen wir als die durch
den Zeiger referenzierte oder adressierte Variable.
왘
Über einen Zeiger kann auf die Daten der referenzierten Variablen zugegriffen
werden. Wir nennen dies Indirektzugriff oder auch Dereferenzierung.
왘
Zum Zugriff auf die referenzierte Variable verwendet man den Dereferenzierungsoperator *.
왘
Ist p ein Zeiger, ist *p der Wert der referenzierten Variablen.
Der Dereferenzierungsoperator ist das Gegenstück zum Adress-Operator. Mit dem
Adress-Operator kommen wir von einer Variablen zu ihrer Adresse, mit dem Dereferenzierungsoperator kommen wir von der Adresse wieder zu der Variablen bzw.
ihrem Wert:
A
B
int x;
float y;
C
D
int *pi;
float *pf;
E
F
pi = &x;
pf = &y;
G
H
*pi = 1234;
*pf = *pi + 0.5;
printf( "x: %d\n", x);
printf( "y: %f\n", y);
Listing 8.3 Die Dereferenzierung von Adressen
Das Beispielprogramm startet mit zwei »gewöhnlichen« Variablen in (A) und (B). Es
folgen ein Zeiger auf int (C) und ein Zeiger auf float (D). In (E) und (F) finden dann
Adresszuweisungen statt, pi referenziert jetzt x, und pf referenziert y. Über einen
Indirektzugriff erfolgt in (G) die Zuweisung x = 1234 und in (H) die Zuweisung y = x +
0.5 = 1234.5. Die Ausgabe sieht dann folgendermaßen aus:
x: 1234
y: 1234.5000000
225
8
8
Zeiger und Adressen
Wichtig ist, dass wir nicht allgemein von »Zeigern«, sondern immer konkret von
»Zeigern auf ...« sprechen. Im oben dargestellten Beispiel haben wir es mit einem
»Zeiger auf int« und einem »Zeiger auf float« zu tun. Dementsprechend können wir
dem ersten nur die Adresse einer int-Variablen und dem zweiten nur die Adresse
einer float-Variablen zuweisen. Mit anderen Worten:
Der dereferenzierte Zeiger muss den gleichen Typ haben wie die Variable, auf
die er zeigt.
Wir haben hier als Beispiel nur Zeiger auf int bzw. float betrachtet. Natürlich gibt es
auch Zeiger auf char oder Zeiger auf double. Wir können Variablen aller Datentypen
referenzieren.
Zurück zu dem Tauschprogramm, das nicht funktioniert hat. Wir ändern dieses Programm an einigen wenigen Stellen ab:
A
B
C
D
E
void tausche( int *a, int *b)
{
int t;
t = *a;
*a = *b;
*b = t;
}
void main()
{
int x = 1;
int y = 2;
printf( "Vorher: %d %d\n", x, y);
tausche( &x, &y);
printf( "Nachher: %d %d\n", x, y);
}
Listing 8.4 Die geänderte Funktion tausche
Die Parameter der Schnittstelle haben wir geändert (A), a und b sind Zeiger auf int.
Der Zugriff auf die durch a und b referenzierten Variablen erfolgt nun mit dem Operator * (A, B, C). Durch die geänderte Schnittstelle erfolgt der Aufruf der Funktion nun
passend durch die Übergabe der Adressen der Variablen x und y an die Funktion tausche in (E).
Vorher: 1 2
Nachher: 2 1
226
Jetzt macht das Programm genau das, was es machen soll. Es greift über die Zeiger a
und b auf die Variablen x und y des Hauptprogramms zu und ändert gegebenenfalls
deren Werte. Das ändert übrigens nichts daran, dass bei einem Unterprogrammaufruf Kopien der übergebenen Parameter erzeugt werden. Es handelt sich jetzt allerdings um Kopien der übergebenen Adressen, die im Unterprogramm zum Zugriff auf
die Daten des übergeordneten Programms genutzt werden.
Eigentlich ist das schon fast alles, was ich Ihnen in diesem Kapitel erzählen wollte,
aber Sie erhalten noch weitere Beispiele, da ich aus Erfahrung weiß, dass viele Leser
hier anfänglich Verständnisschwierigkeiten haben, die sich aber mit wachsender
Vertrautheit mit Zeigern legen werden. Das Verständnisproblem liegt in der Indirektion, da ein Zeiger sozusagen »einmal um die Ecke geht«. Aber im Grunde genommen verwenden wir im täglichen Leben häufig Referenzen oder Zeiger. Wenn wir
jemandem indirekten Zugriff auf uns selbst verschaffen wollen, geben wir ihm
unsere Handynummer. Das ist die Adresse. Diese Adresse speichert er in seinem
Handy ab. Das ist der Zeiger. Wenn er uns dann erreichen will, wählt er die Nummer
aus dem Adressbuch seines Handys. Das ist der Indirektzugriff über den Zeiger. Die
konkrete Telefonnummer ist dabei ein notwendiges, aber eigentlich nebensächliches Detail.
Häufig nutzt man Zeiger, wenn man von einer Funktion mehr als einen Rückgabewert erwartet. Über return kann eine Funktion ja nur einen Wert zurückgeben. Über
Zeiger kann eine Funktion dagegen beliebig viele Werte zurückgeben. Als Beispiel
erstellen wir eine Funktion, die ein Array von Integer-Zahlen übergeben bekommt
und den größten und kleinsten Wert zurückgibt.
A
B
C
D
void minmax( int anz, int daten[], int *pmin, int *pmax)
{
int i, min, max;
min = daten[0];
max = daten[0];
for( i = 1; i < anz; i++)
{
if( daten[i] < min)
min = daten[i];
if( daten[i] > max)
max = daten[i];
}
*pmin = min;
*pmax = max;
}
227
8
8
Zeiger und Adressen
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
E
minmax( 10, zahlen, &min, &max);
printf( "Minimum: %d\n", min);
printf( "Maximum: %d\n", max);
}
Listing 8.5 Rückgabe von Werten über Zeiger
In der Schnittstelle der Funktion (A) werden neben der Anzahl und dem Daten-Array
die Zeiger auf die Variablen für die Rückgabe von Minimum und Maximum übergeben. Die Berechnung von Minimum und Maximum in min bzw. max erfolgt in dem
Bereich (B–C), in (D) werden Minimum und Maximum in den Variablen des rufenden
Programms gespeichert. Zum Aufruf der Funktion werden die Adressen von min und
max an die Funktion minimax übergeben (E). Wir erhalten die erwartete Ausgabe:
Minimum: –12
Maximum: 32
Achtung, eine Funktion kann keinen Zeiger auf eine eigene lokale Variable zurückgeben, da diese Variable nach dem Rücksprung der Funktion nicht mehr existiert. Der
Zeiger würde ins Leere zeigen1. Dies heißt natürlich nicht, dass Funktionen grundsätzlich keine Zeiger zurückgeben können. Wir stellen die Maximumfunktion, die
wir schon öfter betrachtet haben, konsequent auf die Verwendung von Zeigern um:
A
B
C
D
int *maximum( int *x, int *y)
{
if( *x > *y)
return x;
else
return y;
}
1 Ins Leere zeigende Zeiger sind übrigens das größte Problem der C-Programmierung. Darüber
erfahren Sie später noch mehr.
228
void main()
{
int a = 1;
int b = 2;
int c;
E
c = *maximum( &a, &b);
printf( "a = %d, b = %d, c = %d\n", a, b, c);
}
Listing 8.6 Die Funktion maximum mit Zeigern
8
Die umgestellte Funktion gibt einen Zeiger auf int zurück (A). In (B) werden die Werte
der Variablen verglichen, und die Adresse der Variablen mit dem größeren Wert wird
zurückgegeben (C oder D). Über die zurückgegebene Adresse wird dann auf den Wert
zugegriffen (E), und wir erhalten die Ausgabe:
a = 1, b = 1, c = 2
Der hier von der Maximumfunktion zurückgegebene Zeiger »überlebt« den Funktionsaufruf, weil die Adresse ja ursprünglich aus dem Hauptprogramm kommt und
daher die Lebenserwartung des Hauptprogramms hat. Die Zeigerversion der Maximumfunktion wirkt auf den ersten Blick wie eine umständliche Variante der
ursprünglichen Lösung, aber man kann diese Implementierung zusätzlich in einer
ganz anderen Weise verwenden:
void main()
{
int a = 1;
int b = 2;
A
*maximum( &a, &b) = 3;
printf( "a = %d, b = %d\n", a, b);
}
Listing 8.7 Zugriff auf den zurückgegebenen Wert
Hier wird über den zurückgegeben Zeiger zugegriffen (A). Das heißt, der Variablen
mit dem größeren Wert wird 3 zugewiesen. Also b = 3.
a = 1, b = 3
Sie sehen, dass das Funktionsergebnis jetzt auch auf der linken Seite einer Zuweisung
verwendet werden kann, was vorher nicht möglich war. Auf der linken Seite einer
229
8
Zeiger und Adressen
Zuweisung können Sie nur etwas verwenden, das einen Speicherplatz referenziert.
Man nennt dies einen L-Value. Was auf der rechten Seite einer Zuweisung verwendet
werden kann, nennt man einen R-Value. Jeder L-Value ist ein R-Value, aber nicht
jeder R-Value ist ein L-Value, was sofort einsichtig ist, da Sie a = 1, aber nicht 1 = a
schreiben können. Der wesentliche Unterschied zwischen den beiden Varianten der
Maximumfunktion ist also, dass die erste einen R-Value und die zweite einen L-Value
zurückgibt. Die zweite Variante kann damit viel flexibler verwendet werden.
Sie könnten aus den bisherigen Beispielen den Eindruck gewinnen, dass Zeiger nur
an Funktionsschnittstellen von Bedeutung sind. Das ist aber nicht der Fall. Zeiger
sind ein ganz wichtiges, vielleicht sogar das wichtigste Programmiermittel in C. In
vollem Umfang können Sie das erst erkennen, wenn wir uns mit dynamischen
Datenstrukturen beschäftigen.
8.1
Zeigerarithmetik
Bisher haben wir Zeiger nur verwendet, um über den Zeiger auf die referenzierte
Variable zuzugreifen. Wir haben dem Zeiger dazu einen Initialwert (die Adresse seiner referenzierten Variablen) gegeben und diesen Wert danach nicht mehr verändert. Man kann den Wert eines Zeigers aber auch ändern, da es sich bei dem Zeiger
um eine ganz normale Variable handelt. Insbesondere kann man auch mit Zeigern
rechnen. Was bedeutet es aber, wenn man zu einem Zeiger etwa 1 hinzuaddiert? Man
könnte vermuten, dass der Adresswert des Zeigers um 1 erhöht wird. Wir testen diese
Vermutung durch ein kleines Programm:
A
B
C
D
E
void main()
{
int *p = 0;
printf(
printf(
printf(
printf(
}
"p
"p
"p
"p
+
+
+
+
0
1
2
3
=
=
=
=
%d\n",
%d\n",
%d\n",
%d\n",
p
p
p
p
+
+
+
+
0);
1);
2);
3);
Listing 8.8 Adresswert des Zeigers
Der Zeiger wird mit 0 initialisiert (A). Dann werden 0, 1, 2 und 3 zum Zeigerwert
addiert und der Adresswert ausgegeben (B–E).
230
8.1
p
p
p
p
+
+
+
+
0
1
2
3
=
=
=
=
Zeigerarithmetik
0
4
8
12
Der Adresswert des Zeigers erhöht sich offensichtlich jedes Mal um 4. Dies hat damit
zu tun, dass es sich um einen Zeiger auf int handelt und eine int-Zahl 4 Bytes im
Speicher belegt. Allgemein gilt der folgende Zusammenhang:
Wenn man zu einem Zeiger 1 addiert, erhöht sich der Adresswert um die Größe
des Datentyps, auf den der Zeiger zeigt. Bei Addition oder Subtraktion beliebiger ganzer Zahlen ändert sich der Adresswert um entsprechende Vielfache der
Größe des referenzierten Datentyps.
Würde man das oben gezeigte Beispiel mit einem Zeiger auf char anstelle eines Zeigers auf int umsetzen, wäre das Ergebnis entsprechend anders, weil der Datentyp
char auf unserem Rechner 1 Byte im Speicher belegt:
void main()
{
char *p = 0;
A
B
C
D
E
printf(
printf(
printf(
printf(
}
"p
"p
"p
"p
+
+
+
+
0
1
2
3
=
=
=
=
%d\n",
%d\n",
%d\n",
%d\n",
p
p
p
p
+
+
+
+
0);
1);
2);
3);
Listing 8.9 Adresswert des Zeigers für char
In (A) haben wir nun einen im Vergleich zum letzten Beispiel geänderten Datentyp.
Damit erhalten wir auch ein geändertes Ergebnis:
p
p
p
p
+
+
+
+
0
1
2
3
=
=
=
=
0
1
2
3
Zwei Zeiger zu addieren macht keinen Sinn, aber die Differenz zwischen zwei Zeigern,
die auf den gleichen Typ zeigen, liefert durchaus einen sinnvoll zu interpretierenden
Abstand.
Das Rechnen mit Zeigern ist besonders nützlich, wenn wir mit Arrays arbeiten. Damit
beschäftigen wir uns im nächsten Abschnitt.
231
8
8
Zeiger und Adressen
8.2
Zeiger und Arrays
Der vorangegangene Abschnitt lässt bereits erahnen, dass Zeiger und Arrays in C eng
miteinander verwandt sind. Wenn wir etwa ein Array von acht Integer-Zahlen anlegen, verwendet C den Namen dieses Arrays wie einen Zeiger auf das erste Element
des Arrays. Da die restlichen Elemente des Arrays in festem Abstand entsprechend
der Größe des Datentyps folgen, besteht dann der folgende Zusammenhang:
a
+ 0
+ 1
a[0]
a[1]
+ 2
a[2]
+ 3
a[3]
+ 4
a[4]
+ 5
a[5]
+ 6
a[6]
+ 7
a[7]
Abbildung 8.1 Zusammenhang zwischen Index und Zeiger
Allgemein kann man also sagen:
Ist a ein Array, ist a zugleich ein Zeiger auf das erste Element des Arrays.
Es gilt also: *a = a[0]
Wegen der oben beschriebenen Gesetze der Zeigerarithmetik folgt dann für einen
Index i:
Ist a ein Array, ist a+i ein Zeiger auf das i-te Element des Arrays.
Es gilt also: *(a+i) = a[i]
Wir haben also die folgende Beziehung:
a
+ 0
a[0]
=
*(a+0)
+ 1
a[1]
=
*(a+1)
+ 2
a[2]
=
*(a+2)
+ 3
a[3]
=
*(a+3)
+ 4
a[4]
=
*(a+4)
+ 5
a[5]
=
*(a+5)
+ 6
a[6]
=
*(a+6)
+ 7
a[7]
=
*(a+7)
Abbildung 8.2 Vergleich von Array- und Zeigernotation
232
8.2
Zeiger und Arrays
Die Array- und die Zeigernotation können also synonym verwendet werden. Als Beispiel erstellen wir eine Funktion, die berechnet, wie viele 'a' in einem String vorkommen, einmal in Array- und einmal in Zeigernotation. Zur Array-Notation muss nichts
gesagt werden, so haben wir es ja schon immer gemacht:
int zaehle( char string[])
{
int a;
for( i = 0, a = 0; *string[i] != 0; i++)
{
if( string[i] == 'a')
a++;
}
return a;
}
8
Listing 8.10 Zählen von Zeichen in Array-Notation
In Zeigernotation sieht die Funktion dann so aus:
A
B
int zaehle( char *string)
{
int a;
for( a = 0; *string != 0; string++)
{
if( *string == 'a')
a++;
}
return a;
}
void main()
{
int anz;
anz = zaehle( "Panamakanalaal");
printf( "%d a gefunden\n", anz);
}
Listing 8.11 Zählen von Zeichen in Zeigernotation
233
8
Zeiger und Adressen
In der Schnittstelle der Funktion finden wir string als Zeiger auf char (A). Das erste
Zeichen im String ist *string (B), und string++ rückt den Zeiger auf dasnächste Zeichen vor (B). Als Ergebnis erhalten wir:
7 a gefunden
Sie sehen, dass der Index, den wir bei der Array-Notation verwendet hatten, hier
überflüssig ist, weil wir den an der Schnittstelle übergebenen Zeiger Zeichen für Zeichen durch den zu untersuchenden Text schieben können, bis wir auf den Terminator stoßen. Um noch einmal zu demonstrieren, wie elegant man mit Zeigern in
einem String operieren kann, erstellen wir eine Funktion zur Palindromerkennung:
A
B
C
D
E
F
G
int palindrom( char *string)
{
char *vorn = string;
char *hinten = string;
for(; *hinten != 0; hinten++)
;
hinten--;
for( ; vorn < hinten; vorn++, hinten--)
{
if( *vorn != *hinten)
return 0;
}
return 1;
}
void main()
{
int ok;
ok = palindrom( "retsinakanister");
if( ok == 1)
printf( "Palindrom erkannt\n");
}
Listing 8.12 Palindromerkennung
Die Funktion erhält den Zeiger auf den zu untersuchenden Text (A). Zuerst werden
Hilfszeiger deklariert, die auf den Anfang des Textes gesetzt werden (B).
Der Zeiger hinten wird bis zum Terminator vorgeschoben und dann wieder ein Zeichen zurückgesetzt (C und D). Damit zeigt er auf das letzte Zeichen.
234
8.3
Funktionszeiger
Die Zeiger laufen vorwärts bzw. rückwärts durch den Text, bis sie sich in der Mitte
treffen. Werden dabei Unterschiede festgestellt, ist es kein Palindrom (E bis F).
Ansonsten handelt es sich um ein Palindrom, und es erfolgt eine entsprechende
Rückgabe (G). Das Hauptprogramm erzeugt dann die folgende Ausgabe:
Palindrom erkannt
Wenn Sie diesen Abschnitt aufmerksam gelesen haben, dann werden Sie jetzt wissen,
warum wir den skalaren Variablen beim Einlesen mit scanf ein &-Zeichen vorangestellt haben und warum wir das bei Arrays nicht gemacht haben. Wir haben die
Adresse von skalaren Variablen übergeben, damit die Funktion scanf den Eingabewert in die Variable eintragen konnte. Bei Arrays war das nicht erforderlich, weil der
Name des Arrays bereits als Zeiger behandelt wird.
8.3
Funktionszeiger
Nicht nur Variablen, sondern auch Funktionen haben eine Adresse im Speicher. Konsequenterweise kann man die Adresse einer Funktion in einer Variablen speichern
und die Funktion dann aus der Variablen heraus aufrufen. Dies führt zu einer außerordentlich wichtigen und zugleich eleganten Programmiertechnik, die wir uns
anhand eines einfachen Beispiels Schritt für Schritt erarbeiten möchten. Wir werden
eine Funktion erstellen, die wahlweise das Minimum oder das Maximum einer Reihe
von Zahlen berechnet. Ausgangspunkt sind Grundfunktionen zum Berechnen von
Minimum und Maximum zweier Zahlen:
int minimum( int a, int b)
{
if( a < b)
return a;
return b;
}
int maximum( int a, int b)
{
if( a > b)
return a;
return b;
}
Listing 8.13 Getrennte Funktionen für Minimum und Maximum
235
8
8
Zeiger und Adressen
Jetzt erstellen wir eine Funktion, die wahlweise das Minimum oder das Maximum
einer Zahlenreihe berechnet. Dazu übergeben wir an der Schnittstelle einen zusätzlichen Parameter, über den gesteuert wird, ob das Minimum oder das Maximum
bestimmt werden soll. Die Zahlenreihe selbst befindet sich in einem Array:
A
B
C
int suche( int anz, int *daten, int modus)
{
int i, m;
m = daten[0];
for( i = 1; i < anz; i++)
{
if( modus == 1)
m = minimum( m, daten[i]);
else
m = maximum( m, daten[i]);
}
return m;
}
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
D
min = suche( 10, zahlen, 1);
printf( "Minimum: %d\n", min);
E
max = suche( 10, zahlen, 2);
printf( "Maximum: %d\n", max);
}
Listing 8.14 Minimum und Maximum in einer Funktion
In der Funktion wird über den Parameter modus gesteuert, ob das Minimum oder das
Maximum berechnet werden soll (A). Die Unterscheidung erfolgt dann in (B) und (C).
Als Test rufen wir die Funktion suche mit unterschiedlichem modus auf und erhalten
das Ergebnis:
Minimum: –12
Maximum: 32
236
8.3
Funktionszeiger
Jetzt kommt der entscheidende Schritt. Anstatt einen Modus zu übergeben und in
der Schleife entsprechend dem Modus zu verzweigen, können wir auch direkt die zu
verwendende Funktion übergeben. Dabei stellt sich die Frage, wie ein Parameter
deklariert werden muss, der eine Funktion (besser Funktionsadresse) transportieren
soll. Damit eine Typüberprüfung durch den Compiler durchgeführt werden kann,
müssen eingehende Parameter und der Returntyp der übergebenen Funktion in der
Parameterdeklaration festgelegt werden. Die Funktionen minimum und maximum haben
eine identische Schnittstelle, wobei es auf die Benennung der Schnittstellenvariablen
nicht ankommt. Hier geht es nur um die Struktur der Schnittstelle.
Für die Funktion minimum:
8
int minimum( int, int)
Für die Funktion maximum:
int maximum( int, int)
Allgemein sieht die Schnittstelle damit so aus:
int fkt( int, int)
Wir lesen das so:
fkt ist eine Funktion, die zwei int-Werte übergeben bekommt und einen int-
Wert zurückgibt.
Diese allgemeine Beschreibung passt jetzt sowohl auf die Funktion minimum als auch
auf die Funktion maximum. Auf die gleiche Weise können wir auch andere Funktionen
mit andersartiger Parameterstruktur beschreiben. Wir stellen unser aktuelles Beispiel auf die Verwendung von Funktionszeigern um:
int minimum( int a, int b) {...}
int maximum( int a, int b) {...}
A
B
int suche( int anz, int *daten, int fkt( int, int))
{
int i, m;
m = daten[0];
for( i = 1; i < anz; i++)
m = fkt( m, daten[i]);
return m;
}
237
8
Zeiger und Adressen
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
C
min = suche( 10, zahlen, minimum);
printf( "Minimum: %d\n", min);
D
max = suche( 10, zahlen, maximum);
printf( "Maximum: %d\n", max);
}
Listing 8.15 Verwendung von Funktionszeigern
Im Parameter fkt wird eine Funktion übergeben, die zwei int-Werte übergeben
bekommt und einen int-Wert zurückgibt (A). Diese im Parameter fkt übergebene
Funktion wird in (B) aufgerufen.
Beim Aufruf der Funktion suche wird dann im dritten Parameter die bei der Suche zu
verwendende Hilfsfunktion übergeben (C und D). Wir erhalten wieder das bekannte
Ergebnis:
Minimum: –12
Maximum: 32
Welche Funktion in der suche-Funktion gerufen wird, entscheidet sich erst zur Laufzeit anhand der übergebenen Funktionsadresse, der Compiler überwacht dabei nur,
dass an der Schnittstelle ausschließlich Funktionen mit der passenden Parameterstruktur verwendet werden. Das ist ein grundsätzlicher Unterschied zur bisherigen
Verwendung von Funktionen. Bisher war immer schon zur Compile-Zeit erkennbar,
welche Funktion gerufen wird. Immer, wenn Sie Entscheidungen vom Compiler in
die Laufzeit verlegen, gewinnen Sie an Flexibilität und verlieren dafür an Ausführungsgeschwindigkeit. Durch Bereitstellung einer geeigneten Funktion konnten wir
die Suchfunktion auf Minimumsuche oder Maximumsuche einstellen. Man nennt
diese Art zu programmieren auch Callback und die übergebenen Funktionen Callback-Funktionen. In diesem Bild sagt man zur Funktion suche:
Such bitte eine Zahl in diesem Array mit zehn Zahlen, und wenn du bei zwei
Zahlen entscheiden musst, welche zu nehmen ist, dann frag doch bitte bei mir
über meine Callback-Funktion (Rückruf-Funktion) nach.
Callback-Funktionen sind ein wichtiges Prinzip bei der Erstellung von Softwaresystemen. Zum Beispiel werden Callbacks bei grafischen Benutzeroberflächen häufig verwendet, um an die Eingaben des Benutzers spezielle Aktionen (Funktionen) des
238
8.4
Aufgaben
Programms zu binden. Wenn das System dann zur Laufzeit feststellt, dass der Benutzer z. B. mit der Maus geklickt hat, ruft es eine bestimmte, vom Programmierer für
diesen Fall bereitgestellte Callback-Funktion.
8.4
Aufgaben
A 8.1 Schreiben Sie eine Funktion, die ein Array von Gleitkommazahlen übergeben
bekommt und die größte Zahl, die kleinste Zahl und den Mittelwert aller Zahlen zurückgibt.
A 8.2 Schreiben Sie eine Funktion, die ein Array von Integer-Zahlen übergeben
bekommt und auf allen Zahlen des Arrays eine konfigurierbare Operation ausführt. Die auszuführende Operation soll der Funktion über einen Funktionszeiger mitgeteilt werden. Die übergebene Funktion soll einen int-Wert als
Parameter erhalten und einen int-Wert zurückgeben. Testen Sie Ihr Programm mit folgenden Operationen:
왘
Integer-Division durch 2
왘
Rest bei Division durch 2
왘
Ausgabe auf dem Bildschirm
A 8.3 Erstellen Sie eine rekursive Funktion zur Berechnung der Länge eines Strings,
die konsequent auf die Verwendung von Zeigern setzt.
A 8.4 Erstellen Sie eine rekursive Funktion zum Vergleich zweier Strings, die konsequent auf die Verwendung von Zeigern setzt.
A 8.5 Erstellen Sie eine rekursive Lösung der Aufgabe 7.8, die konsequent auf die
Verwendung von Zeigern setzt.
A 8.6 In C können Sie Funktionen mit einer unbestimmten Anzahl an Parametern
definieren. Anstelle der fehlenden Funktionsparameter können Sie einfach
drei Punkte setzen. Zum Beispiel können Sie die folgende Funktion
int add( int anz, ...)
{
// Funktionscode
}
erstellen, die Sie dann mit unterschiedlicher Parameterzahl rufen können.
Zum Beispiel:
a = add( 5, 1, 2, 3, 4, 5);
a = add( 6, 2,-2, 7,-1, 4, 0);
a = add( 7, 0,-4,-2, 8, 1, 5, 6);
239
8
8
Zeiger und Adressen
Wir wollen die Funktion add immer so rufen, dass im ersten Parameter, der ja
auf jeden Fall vorhanden ist, die Anzahl der noch folgenden Parameter steht.
Versuchen Sie jetzt, die Funktion add so zu programmieren, dass sie die
Summe der auf den ersten Parameter folgenden Parameterwerte berechnet
und zurückgibt. In unseren Beispielen sollen also für a die folgenden Werte
berechnet werden:
a = add( 5, 1, 2, 3, 4, 5);
// a = 1+2+3+4+5 = 15
a = add( 6, 2,-2, 7,-1, 4, 0);
// a = 2-2+7-1+4+0 = 10
a = add( 7, 0,-4,-2, 8, 1, 5, 6); // a = 0-4-2+8+1+5+6 = 14
A 8.7 Ganze Zahlen werden im Rechner als Folge von Bytes abgelegt. Damit ist aber
noch nicht festgelegt, in welcher Reihenfolge die Bytes einer Zahl gespeichert
werden. Eine 2-Byte-Zahl wird an zwei aufeinanderfolgenden Speicheradressen abgelegt. Aber steht das höherwertige Byte an der kleineren oder der größeren Adresse?
왘
Wenn das niederwertige Byte an der kleineren Adresse und das höherwertige Byte an der größeren Adresse steht, spricht man vom Little-EndianFormat.
왘
Wenn das höherwertige Byte an der kleineren Adresse und das niederwertige Byte an der größeren Adresse steht, spricht man vom Big-EndianFormat.
Beide Formate kommen in unterschiedlichen Hardwarearchitekturen vor. Der
Unterschied zwischen Little- und Big-Endian ist auch im Zusammenhang mit
Netzwerkkommunikation von zentraler Bedeutung, da festgelegt werden
muss, in welcher Reihenfolge die einzelnen Bytes einer Integer-Zahl im Netzwerk übertragen werden müssen. Das Internetprotokoll gibt eine Big-EndianReihenfolge vor. Alle Systeme müssen bei der Übertragung von Integer-Zahlen diese sogenannte Network Byte Order einhalten. Umgangssprachlich ist
Ihnen dieses Problem bekannt. Wenn wir zweistellige Zahlen sprachlich übermitteln, verwenden wir im Deutschen das Little-Endian-Format (»einundzwanzig«), während in Großbritannien das Big-Endian-Format (»twenty one«)
verwendet wird.
Schreiben Sie ein Programm, das feststellt, ob Ihre Hardware eine Big- oder
Little-Endian-Darstellung verwendet.
240
Kapitel 9
Programmgrobstruktur
Ich bin von je der Ordnung Freund gewesen.
– Johann Wolfgang von Goethe
Bisher besteht für uns ein Programm aus einer einzigen Quellcodedatei, in der wir das
Hauptprogramm und alle Unterprogramme finden. Spätestens aber dann, wenn wir
ein Programm mit zwei oder mehr Personen parallel entwickeln wollen, sind wir gezwungen, den Quellcode auf mehrere Dateien aufzuteilen. Das wird dann aber zwangsläufig dazu führen, dass z. B. eine Funktion in einer anderen Quellcodedatei steht als
die Aufrufe dieser Funktion. Wie erfährt der Compiler, der ja jede Quellcodedatei einzeln übersetzt, welche Funktionen es anderweitig gibt und welche Schnittstellen sie
haben? Im Grunde genommen waren wir von Anfang an mit diesem Problem konfrontiert – wir haben es bisher nur ignoriert. Funktionen wie scanf oder printf stehen
ja auch nicht in unserem Quellcode, sondern »irgendwo anders«. Das Geheimnis liegt
in den Include-Anweisungen am Anfang unseres Programms (A und B):
A
B
# include <stdio.h>
# include <stdlib.h>
void main()
{
...
...
...
}
Listing 9.1 Die include-Anweisung
Diese und weitere Anweisungen dieser Art werden Sie jetzt kennenlernen.
9.1
Der Präprozessor
Der C-Präprozessor ist ein Werkzeug, das relativ unabhängig von der Sprache C
betrachtet werden kann. Es handelt sich um einen Vorübersetzer, der, bevor der
Compiler den Quellcode zu sehen bekommt, Textersetzungen durchführt. Dieser
241
9
9
Programmgrobstruktur
Ersetzungsprozess wird durch gewisse, in den Programmcode eingelagerte Anweisungen, sogenannte Präprozessor-Direktiven, gesteuert. Diese Direktiven beginnen,
damit sie klar vom C-Code unterschieden werden können, immer mit einem »#« am
Zeilenanfang. In der Regel erstrecken sich Präprozessor-Direktiven auf nur eine Zeile,
bei Bedarf kann jedoch mit »\« eine Fortsetzungszeile angefügt werden.
9.1.1
Includes
Mit einer Include-Direktive können komplette Dateien vor der Übersetzung in den
Quellcode eingefügt (inkludiert) werden. Üblicherweise handelt es sich dabei um
sogenannte Header-Dateien. Diese Dateien erkennen Sie an der Dateinamenserweiterung .h. Grundsätzlich müssen Sie zwischen System-Header-Dateien und ProjektHeader-Dateien unterscheiden. System-Header-Dateien sind Dateien, die mit dem
Compiler oder mit speziellen System- oder Entwicklungskomponenten geliefert werden und auf Ihrem Entwicklungsrechner bereits installiert sind. Diese Dateien liegen
in speziellen Systemverzeichnissen, die Ihrer Entwicklungsumgebung bekannt sind1.
Projekt-Header-Dateien sind Header-Dateien, die Sie als Programmierer in Ihrem
Projekt selbst erstellen. Diese Dateien liegen zusammen mit den von Ihnen ebenfalls
erstellten Quellcodedateien im Projektordner Ihres Projekts.
stdio.h
Syste
m
# include <stdio.h>
# include "header.h"
heade
r.h
Proje
kt
void main()
{
…
…
…
}
Abbildung 9.1 Projekt- und Systemdateien
1 Wie man eine Entwicklungsumgebung konfiguriert, damit diese Dateien gefunden werden, werden wir hier nicht behandeln.
242
9.1
Der Präprozessor
Der Compiler erkennt an den spitzen Klammern bzw. den Anführungszeichen, ob er
die Datei in Systemverzeichnissen oder im Projektverzeichnis suchen soll. Was
genau in den Header-Dateien steht, werden wir später betrachten, im Moment sind
es für uns einfach nur Dateien.
Wenn ich gesagt habe, dass eine Header-Datei durch den Compiler in den Quellcode
eingefügt wird, ist das nicht ganz richtig, da eine Quellcodedatei durch den Compiler
nie verändert wird. Der Compiler nimmt beim Lesen der Quellcodedatei nur einen
»Umweg« durch die inkludierte Datei. Sie können sich das aber so vorstellen, als
würde die Datei anstelle der Include-Direktive stehen:
Lesefluss des Compilers
// Quellcodedatei
…
…
# include "header1.h
…
…
…
…
…
…
…
// header1.h
…
…
# include "header2.h
…
…
…
9
// header2.h
…
…
…
Abbildung 9.2 Der Lesefluss des Compilers
Sie werden sich vielleicht fragen, warum man dann nicht einfach anstelle der
Include-Direktive direkt den Inhalt der Header-Datei hinschreibt. Das hat einen einfachen Grund. Die Header-Dateien sind so etwas wie die »allgemeinen Geschäftsbedingungen (AGB)« Ihres Programms. Stellen Sie sich vor, dass Sie einen Webshop
programmieren, bei dem auf hunderten von Seiten auf die allgemeinen Geschäftsbedingungen hingewiesen werden muss. Wenn Sie an diesen Stellen immer die vollständigen AGB einfügen würden, wäre das zwar prinzipiell möglich, aber höchst
problematisch. Das Problem würde evident, sobald Sie die AGB ändern müssten.
Dann müssten Sie auf hunderten von Seiten die AGB aktualisieren, was am Ende
garantiert zu inkonsistenten Geschäftsgrundlagen führen würde.
Header-Dateien können, wie oben in Abbildung 9.2 bereits angedeutet, ihrerseits
wieder Header-Dateien inkludieren. Auf diese Weise kann eine komplexe Hierarchie
243
9
Programmgrobstruktur
von ineinander geschachtelten Dateien entstehen, die unter Umständen schwer zu
durchschauen ist. Insbesondere besteht die Gefahr, dass ein und dieselbe Datei
mehrfach oder sogar rekursiv inkludiert wird. Wie Sie sich mit einem einfachen Trick
vor solchen Problemen schützen können, erfahren Sie weiter unten, wenn ich
erkläre, wie Header-Dateien üblicherweise aufgebaut sind.
9.1.2
Symbolische Konstanten
Durch symbolische Konstanten können Werte, die an unterschiedlichen Stellen im
Quellcode einheitlich verwendet werden sollen, an zentraler Stelle festgelegt und
gepflegt werden.
Symbolische Konstanten
# define PI 3.14
# define MAX 10
# define MIN MAX/2
float array[MAX];
int i;
float array[10];
int i;
Präprozessor
Compiler
for( i = 10/2; i < 10; i++)
array[i] = 3.14;
for( i = MIN; i < MAX; i++)
array[i] = PI;
Abbildung 9.3 Verwendung symbolischer Konstanten
Im oben dargestellten Beispiel wird durch die symbolische Konstante MAX sichergestellt, dass die Größe und die Initialisierung des Arrays a immer aufeinander abgestimmt sind. Es wird verhindert, dass bei einer Vergrößerung oder Verkleinerung des
Arrays vergessen wird, die Initialisierungsschleife entsprechend anzupassen. Die
symbolische Konstante MIN greift auf den zuvor festgelegten Wert der symbolischen
Konstanten MAX zurück.
Beachten Sie, dass symbolische Konstanten vom Präprozessor vollständig aufgelöst
werden, sodass der folgende Compiler sie nicht mehr zu sehen bekommt. Es handelt
sich insbesondere nicht um Variablen, denen Sie z. B. einen Wert zuweisen könnten.
Es geht auch nicht ausschließlich um numerische Werte. Im Prinzip können Sie beliebige Ersetzungen durch den Präprozessor vornehmen:
# define PLUS +
# define MAL *
int x;
int x;
Compiler
Präprozessor
x = 2 MAL (5 PLUS 1);
Abbildung 9.4 Arbeitsweise des Präprozessors
244
x = 2 * (5 + 1);
9.1
Der Präprozessor
Wichtig ist nur, dass am Ende gültiger C-Code entsteht, da Sie ja noch kompilieren
wollen.
Bei symbolischen Konstanten werden keine Ausdrücke ausgewertet. Betrachten Sie
dazu das folgende Beispiel:
# define ZWEI 1+1
int x;
int x;
Präprozessor
x = ZWEI*ZWEI;
Compiler
x = 1+1*1+1;
Abbildung 9.5 Probleme bei der Verwendung des Präprozessors
9
Die Variable x erhält hier nicht, wie man auf den ersten Blick vielleicht vermuten
könnte, den Wert 4, sondern 3. Setzen Sie daher Klammern, wenn Sie sichergehen
wollen, dass ein arithmetischer Ausdruck in einem beliebigen Kontext eingesetzt
werden kann:
# define ZWEI (1+1)
int x;
int x;
Präprozessor
x = ZWEI*ZWEI;
Compiler
x = (1+1)*(1+1);
Abbildung 9.6 Korrekte Klammerung im Präprozessor
Bedenken Sie also immer:
Symbolische Konstanten sind keine Variablen, sondern nur Platzhalter für
einen Ersatztext!
Wählen Sie die Namen für symbolische Konstanten so, dass Sie sie von Variablennamen unterscheiden können! Ein brauchbarer Ansatz ist es, für symbolische Konstanten nur Großbuchstaben und für Variablennamen nur Kleinbuchstaben zu
verwenden. Symbolische Konstanten, die übergreifend in mehreren Programmdateien benötigt werden, gehören natürlich in eine Header-Datei, damit sie dort zentral
gepflegt werden können.
9.1.3
Makros
Häufig benötigt man in einem Programm »Minifunktionen«. Das Dilemma mit solchen Funktionen ist, dass man sich die einheitliche Verarbeitung durch eine Funktion wünscht, ohne die zusätzlichen Laufzeitkosten für einen Funktionsaufruf in
245
9
Programmgrobstruktur
Kauf nehmen zu wollen. Eine gewisse Abhilfe schaffen hier die sogenannten Makros.
Makros stellen eine Verallgemeinerung symbolischer Konstanten dar und können
zusätzliche Parameter enthalten:
Macro
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*(r)*(r))
double x;
double x;
x = KREIS_FLAECHE( 5);
Präprozessor
Compiler
x = (3.14*(5)*(5));
Abbildung 9.7 Parameter im Präprozessor
Makros können auch mehrere Parameter haben. Setzen Sie bei arithmetischen Ausdrücken um die Parameter immer Klammern, da Sie nicht wissen, was dort als Parameter einmal eingesetzt wird, und weil durch den Präprozessor keine Ausdrücke
ausgewertet werden. Das Weglassen der Klammern im oben dargestellten Beispiel
kann zu einer sicherlich nicht gewünschten Auflösung führen:
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*r*r)
double x;
double x;
x = KREIS_FLAECHE( 1+1);
Präprozessor
Compiler
x = (3.14*1+1*1+1);
Abbildung 9.8 Klammerung bei der Verwendung von Parametern
Setzen Sie, wie schon bei den symbolischen Konstanten, immer Klammern um das
gesamte Makro, da Sie nicht wissen, wo das Makro überall eingesetzt wird.
Problematisch können Makros werden, wenn durch ungeschickte Verwendung
unwissentlich Seiteneffekte ausgelöst werden:
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*(r)*(r))
double x;
double x;
int a = 1;
x = KREIS_FLAECHE( a++);
Präprozessor
Compiler
x = (3.14*(a++)*(a++));
Abbildung 9.9 Seiteneffekte bei der Verwendung des Präprozessors
Die zweimalige Erhöhung des Werts von a ist in diesem Beispiel sicher nicht gewollt.
Beachten Sie also:
246
9.1
Der Präprozessor
Makros sind keine Funktionen, sondern nur parametrierte Platzhalter für
einen Ersatztext!
Verschleiern Sie dies nicht, indem Sie gleiche Namenskonventionen für Makros und
Funktionen verwenden! Gehen Sie ähnlich vor wie bei Variablen und symbolischen
Konstanten, und schreiben Sie Funktionsnamen immer klein und Makronamen
immer groß.
9.1.4
Bedingte Kompilierung
Oft ist es erforderlich, von einem Softwaresystem unterschiedliche Varianten (z. B.
für verschiedene Betriebssysteme) zu erstellen. Die Varianten unterscheiden sich
vielleicht nur minimal, aber der erforderliche Code ist auf einem System für das
jeweils andere System nicht kompilierbar. In diesem Fall kann man mit sogenannten
Compile-Schaltern aus einer Quelle die verschiedenen Versionen erzeugen. Im folgenden Beispiel wollen wir zwei Versionen erzeugen. Die eine soll einen Testausdruck enthalten, die andere nicht. Dazu habe ich einen Compile-Schalter TEST
eingeführt:
Compileschalter TEST nicht gesetzt
# undef TEST
int a = 1;
int a = 1;
Präprozessor
# ifdef TEST
printf( "a = %d\n", a);
# endif
a = a + 1;
Compiler
a = a + 1;
Compileschalter TEST gesetzt
# define TEST
int a = 1;
int a = 1;
# ifdef TEST
printf( "a = %d\n", a);
# endif
Präprozessor
Compiler
printf( "a = %d\n", a);
a = a + 1;
a = a + 1;
Abbildung 9.10 Die Verwendung von Compile-Schaltern
Über den Compile-Schalter können Sie wahlweise eine Version mit Testausgabe
(define TEST) oder ohne Testausgabe (undef TEST) erzeugen. Dies nennt man bedingte
Kompilierung. Beachten Sie, dass, im Gegensatz zu einer Fallunterscheidung, der
nicht auszuführende Code vollständig aus dem Quellcode entfernt wird, bevor der
Compiler das Programm übersetzt. Weitere mögliche Direktiven für Compile-Schalter sind:
247
9
9
Programmgrobstruktur
# define
Setzen eines Schalters
# undef
Rücksetzen eines Schalters
# if
Fallunterscheidung aufgrund eines konstanten Ausdrucks (0 oder != 0)
# ifdef
Fallunterscheidung aufgrund eines gesetzten Compile-Schalters
# ifndef
Fallunterscheidung aufgrund eines nicht gesetzten Compile-Schalters
# else
Alternative zu if, ifdef oder ifndef
# elif
Alternative wie else, allerdings mit erneuter if-Bedingung
# endif
Ende einer Fallunterscheidung
Tabelle 9.1 Liste der Direktiven
Mit Compile-Schaltern können Sie das oben bereits angesprochene Problem mehrfacher oder rekursiver Includes ein und derselben Header-Datei lösen. Dazu wählen Sie
zu jeder Header-Datei einen projektweit eindeutigen Namen (im Beispiel HEADER_H)
und versehen jede Header-Datei in der folgenden Weise mit einem Rahmen aus
Direktiven:
HEADER_H
ist nicht definiert.
Lesefluss des Compilers
…
…
…
# include "header.h"
…
…
…
# include "header.h"
…
…
…
# ifndef HEADER_H
# define HEADER_H
…
…
HEADER_H
…
wird definiert.
…
# endif
HEADER_H
ist definiert.
# ifndef HEADER_H
# define HEADER_H
…
…
Inhalt der Datei
…
wird ausgeblendet.
…
# endif
Abbildung 9.11 Vermeidung rekursiver Includes
248
Headerdatei header.h
wird zweimal inkludiert.
9.2
Ein kleines Projekt
Beim ersten Include wird die Header-Datei, da HEADER_H noch undefiniert ist, ganz
normal durchlaufen. Dabei wird allerdings HEADER_H definiert. Beim nächsten Versuch eines Includes derselben Datei ist dann HEADER_H definiert, und der Inhalt der
Datei wird ausgeblendet.
9.2
Ein kleines Projekt
Wir wollen jetzt auf engstem Raum ein kleines Projekt mit Header- und Quellcodedateien erstellen. An diesem Miniprojekt möchten wir Ihnen das Zusammenspiel von
Header-Dateien und Quellcodedateien erläutern. Dazu benötigen wir vorab noch
einige Begriffe.
Grundsätzlich unterscheiden wir in einem C-Programm zwischen:
왘
Direktiven
왘
Deklarationen
왘
Definitionen
Direktiven richten sich an den Präprozessor. Es handelt sich dabei um die oben diskutierten #-Anweisungen.
Deklarationen sind Vereinbarungen, die nur über die Definition gewisser Objekte
informieren. Zum Beispiel handelt es sich bei einem Funktionsprototyp oder einem
Externverweis auf eine globale Variable um Deklarationen.
Definitionen dagegen sind Vereinbarungen, die konkrete Objekte und damit Code
erzeugen. Zum Beispiel handelt es sich bei einer Funktionsimplementierung oder
dem Anlegen einer Variablen um Definitionen. Definitionen sind immer zugleich
auch Deklarationen.
Jetzt werfen wir einen Blick auf das Miniprojekt, in dem es drei Dateien (maximum.h,
maximum.c und main.c) gibt (siehe Abbildung 9.12).
In der Header-Datei (maximum.h) verhindert ein Compile-Schalter (MAXIMUM_H), dass
die Header-Datei mehrfach oder gar rekursiv inkludiert wird. Statten Sie jede HeaderDatei mit einem solchen Schutz aus! Sorgen Sie dafür, dass für jede Header-Datei ein
projektweit eindeutiger Schalter verwendet wird! Erzeugen Sie den Schalter z. B. aus
dem Dateinamen! Innerhalb des durch den Compile-Schalter geschützten Bereichs
werden in unserem Beispiel eine globale Variable (absolutes_maximum) und eine
Funktion (maximum) deklariert. Beachten Sie, dass weder die Variable noch die Funktion hier wirklich erzeugt werden, sondern hier wird nur mitgeteilt, dass es die Variable und die Funktion irgendwo im Projekt gibt.
249
9
9
Programmgrobstruktur
maximum.h
# ifndef MAXIMUM_H
# define MAXIMUM_H
Hier werden die globale Variable
absolutes_maximum und die
Funktion maximum deklariert.
extern int absolutes_maximum;
extern int maximum( int a, int b);
# endif
main.c
# include <stdio.h>
# include <stdlib.h>
# include "maximum.h"
maximum.c
# include <limits.h>
# include "maximum.h"
int absolutes_maximum = INT_MIN;
int maximum( int a, int b)
{
int max;
if( a > b)
max = a;
else
max = b;
if( max > absolutes_maximum)
absolutes_maximum = max;
}
Hier werden die globale Variable
absolutes_maximum und die
Funktion maximum definiert.
void main()
{
int a, b, c;
a = maximum( 1, -5);
b = maximum( -10, 17);
c = absolutes_maximum;
printf( "%d\n", c);
}
Hier werden die globale
Variable absolutes_maximum
und die Funktion maximum
verwendet.
INT_MIN ist eine symbolische
Konstante, die in limits.h als
der kleinstmöglichte int-Wert
festgelegt ist.
Abbildung 9.12 Ein Projekt mit drei Dateien
In der Implementierungsdatei (maximum.c) wird die Header-Datei (maximum.h)
inkludiert, und die globale Variable und die Funktion werden definiert. Der Compiler
kann dann prüfen, ob die Schnittstelle so implementiert wird, wie es in der Deklaration vorgegeben ist. Zusätzlich wird in der Datei eine System-Header-Datei (limits.h)
inkludiert. In dieser Datei werden grundlegende Zahlenbereichsgrenzen durch symbolische Konstanten beschrieben. Die symbolische Konstante INT_MIN nennt den
Wert der kleinstmöglichen int-Zahl auf unserem System. Mit diesem Wert initialisieren wir die globale Variable absolutes_maximum. Die beiden Dateien maximum.h und
maximum.c bilden zusammen ein funktionierendes Modul, das natürlich noch
mehr Funktionen als nur die Maximumfunktion enthalten könnte. Wer die Funktionen dieses Moduls nutzen will, muss die Header-Datei maximum.h inkludieren.
Das Hauptprogramm unseres Projekts befindet sich in der Datei main.c. Hier wird
maximum.h inkludiert, und es wird auf die Funktion und die globale Variable des
Maximum-Moduls zugegriffen. Die Header-Datei limits.h wird hier nicht benötigt
und muss nicht inkludiert werden. Würde man limits.h aus irgendeinem Grund hier
250
9.2
Ein kleines Projekt
ebenfalls benötigen, hätte man das Include von limits.h in die Header-Datei maximum.h gelegt. Dann würde jeder, der maximum.h inkludiert, automatisch limits.h
mit inkludieren.
Ich hoffe, dass Sie jetzt verstehen, wie Header- und Source-Dateien zusammenspielen und sich voneinander abgrenzen. In einer Header-Datei stehen nur Direktiven
und Deklarationen. Definitionen sollten in einer Header-Datei nicht stehen, da die
dort definierten Objekte, sobald die Header-Datei von mehreren Quellcodedateien
inkludiert würde, doppelt angelegt würden. Das würde der Linker nicht akzeptieren.
In einer Quellcodedatei können Direktiven, Deklarationen und Definitionen stehen.
Eine Quellcodedatei ohne eine Definition ist sinnlos. In einer Quellcodedatei sollten
aber nur Direktiven und Deklarationen stehen, die ausschließlich in dieser Datei
benötigt werden. Alle Deklarationen und Direktiven, die in mehr als einer Quellcodedatei benötigt werden, gehören in eine Header-Datei.
Einige der hier diskutierten Konzepte mögen Ihnen im Moment unmotiviert, vielleicht sogar überflüssig erscheinen. Das ist verständlich, da wir bisher nur kleine Programme erstellt und den Programmcode immer in einer Datei zusammengehalten
haben. Eine Aufteilung wie in unserem Mikroprojekt wirkt künstlich und aufgesetzt.
Die Programmerstellung wird sich aber nicht auf Dauer in einem so kleinen und
überschaubaren Rahmen bewegen. Spätestens, wenn mehrere Programmierer an
einem Programm arbeiten oder wenn Programmteile entstehen, die in unterschiedlichem Zusammenhang Verwendung finden sollen, ist es unumgänglich, eine Aufteilung auf mehrere Dateien vorzunehmen. Sie sollten sich daher bereits »im Kleinen«
an die später »im Großen« zwingend notwendigen Maßnahmen gewöhnen.
251
9
Kapitel 10
Die Standard C Library
Ich habe mir das Paradies immer als eine Art Bibliothek vorgestellt.
– Jorge Luis Borges
Eine der Entwurfsideen bei der Entwicklung von C war, den Sprachumfang so klein
wie möglich zu halten. C enthält darum im Gegensatz zu vielen anderen Programmiersprachen keine Sprachelemente zur Dateibearbeitung, zur Bildschirmausgabe
oder zur Bearbeitung von Zeichenketten. Dies und vieles mehr wird in C durch Funktionsbibliotheken erledigt. Wir wollen uns hier nur mit der sogenannten Standard C
Library (auch C Runtime Library) beschäftigen. Diese Funktionsbibliothek enthält
einige hundert Funktionen und ist ebenso wie die Sprache C selbst durch die ANSI1
normiert. Sie können also davon ausgehen, dass die Funktionen dieser Bibliothek in
jeder C-Programmierumgebung dem Standard entsprechend verfügbar sind. Ich
kann hier natürlich nicht jede Funktion dieser Library besprechen und gebe Ihnen
daher nur einen groben Überblick über eine Auswahl von Funktionen. Wenn Sie den
Sinus oder die Wurzelfunktion, die aktuelle Uhrzeit oder das Datum in einem Ihrer
Programme benötigen, schauen Sie zuerst immer in der Runtime Library nach, ob
geeignete Funktionen nicht bereits vorhanden sind. Alle Details über diese Funktionen entnehmen Sie dann Ihren Compiler-Handbüchern, dem Hilfesystem Ihrer Entwicklungsumgebung oder einer der zahlreichen Informationsseiten im Internet.
Dort finden Sie auch Informationen darüber, welche Headerfiles Sie in Ihrem Quellcode inkludieren müssen, um die jeweiligen Funktionen, ihren Prototypen entsprechend, korrekt verwenden zu können.
Im Folgenden werden wir einige wichtige Funktionen der Runtime Library herausgreifen und Ihnen anhand von Beispielen vorstellen. Viele dieser Funktionen sind
erst von Interesse, wenn sie für konkrete Probleme benötigt werden. Wenn Sie z. B.
nicht unmittelbar planen, ein Programm mit trigonometrischen Berechnungen zu
erstellen, müssen Sie sich jetzt nicht mit Sinus und Cosinus beschäftigen. In der
C-Programmierung würde Sie das nicht weiterbringen. Auf keinen Fall überspringen
sollten Sie aber die Abschnitte:
왘
Stringoperationen
왘
Freispeicherverwaltung
1 American National Standards Institute
253
10
10
Die Standard C Library
Diese Abschnitte sind für das weitere Verständnis von zentraler Bedeutung.
10.1
Mathematische Funktionen
Die Standard Library enthält viele mathematische Funktionen vom Sinus bis zur
Exponentialfunktion. Im folgenden Beispiel werden die Funktionen sqrt (Quadratwurzel), exp (e-Funktion), fabs (Absolutbetrag), pow (Potenzfunktion) sowie sin
(Sinus) und cos (Cosinus) in Formeln verwendet:
A
# include <math.h>
void main()
{
double x, y, z;
x = 1.2;
y = 3.4;
B
z = sqrt(x*x + y*y);
printf( "z = %f\n", z);
C
z = sqrt(exp(x) + y);
printf( "z = %f\n", z);
D
z = fabs( pow(sin(x)+cos(y*y),5));
printf( "z = %f\n", z);
}
Listing 10.1 Beispiele für die Verwendung mathematischer Funktionen
Durch die Einbindung von math.h (A) werden die mathematischen Funktionen zur
Verwendung eingebunden. Dies darf nicht vergessen werden, da die verwendeten
Funktionen sonst nicht verfügbar sind.
Im Programm werden dann die Werte für die folgenden Formeln berechnet und ausgegeben:
2
2
왘
z =
x + y (B)
왘
z =
e + y (C)
왘
z = ( sin ( x ) + cos ( y ) ) (D)
254
x
2
5
10.1
Mathematische Funktionen
Wir erhalten die folgende Ausgabe:
Z = 3.605551
Z = 2.592319
Z = 6.793692
Manchmal benötigen Sie in einem Programm Zufallswerte, um z. B. den Wurf eines
Würfels zu simulieren. Das folgende Programm zeigt, wie Sie das mithilfe der Funktionen srand und rand realisieren können:
A
# include <stdlib.h>
void main()
{
int seed, wurf, i;
B
seed = 4711;
C
srand( seed);
D
10
for( i = 1; i <= 5; i++)
{
wurf = rand()%6 + 1;
printf( "%d. Wurf: %d\n", i, wurf);
}
}
Listing 10.2 Verwendung von Zufallszahlen
Zuerst erfolgt die Einbindung der Bibliothek zur Verwendung der Funktionen srand
und rand (A). Die Funktion srand initialisiert den Zufallszahlengenerator mit einem
Startwert, den Sie frei wählen können (B und C). Danach können mit der Funktion
rand Zufallszahlen2 abgerufen werden. Die Funktion rand liefert eine ganze Zahl, die
Sie noch in den Bereich von 1–6 bringen müssen, damit sie einem gültigen Wurf eines
Würfels entspricht (D). Dazu bilden Sie den Rest bei Division durch 6 und erhalten
eine Zufallszahl zwischen 0 und 5. Wenn Sie jetzt noch 1 addieren, bekommen Sie
eine Zufallszahl im Bereich zwischen 1 und 6. Wenn Sie also eine Zufallszahl zwischen
a und b benötigen, erreichen Sie dies mit dem Formelausdruck rand()%(b-a+1)+a. Das
Programm erzeugt die folgende Ausgabe:
2 Diese Zahlen werden natürlich durch einen Algorithmus berechnet und sind daher nicht wirklich
zufällig. Man spricht deshalb auch von Pseudozufallszahlen.
255
10
Die Standard C Library
1.
2.
3.
4.
5.
Wurf:
Wurf:
Wurf:
Wurf:
Wurf:
3
1
5
1
4
Wenn Sie mit dem gleichen Startwert starten, erhalten Sie immer die gleiche Folge
von Zufallszahlen. Das ist durchaus wünschenswert, da man Programme häufig mit
Zufallswerten testet und nach einer Fehlerkorrektur mit der Testsequenz, die den
Fehler aufgedeckt hat, noch einmal testen will, um festzustellen, ob der Fehler nicht
mehr auftritt3.
10.2
Zeichenklassifizierung und -konvertierung
Es gibt zahlreiche Funktionen zur Klassifizierung und Konvertierung von Zeichen.
Zur Klassifizierung gehören Fragen wie: Ist das Zeichen ein druckbares Zeichen? Handelt es sich um eine Ziffer, einen Buchstaben oder ein Satzzeichen? Eine typische
Konvertierungsaufgabe ist die Konvertierung von Großbuchstaben in Kleinbuchstaben oder umgekehrt. Wir zeigen Ihnen auch hier wieder nur ein kleines Beispiel:
A
# include <ctype.h>
void main()
{
char text[100];
int u, l;
char *p;
printf( "Eingabe: ");
scanf( "%s", text);
B
C
for( p = text, u = l = 0; *p; p++)
{
if( isupper( *p))
u++;
if( islower( *p))
l++;
}
printf( "%d Gross-, %d Kleinbuchstaben\n", u, l);
3 Man nennt dies einen Regressionstest.
256
10.3
Stringoperationen
for( p = text; *p; p++)
*p = toupper( *p);
printf( "Gross: %s\n", text);
D
for( p = text; *p; p++)
*p = tolower( *p);
printf( "Klein: %s\n", text);
}
E
Listing 10.3 Zeichenklassifizierung und -konvertierung
Initial erfolgt ein Include zur Verwendung der Funktionen zur Zeichenkonvertierung
(A). Innerhalb des Programms erfolgen über die ganze eingegebene Zeichenkette das
Zählen der Großbuchstaben (B) und das Zählen der Kleinbuchstaben (C). Abschließend wird die zeichenweise Konvertierung in Großbuchstaben (D) und in Kleinbuchstaben (E) durchgeführt. Es ergibt sich z. B. die folgende Ausgabe:
Eingabe: AbCdEfGhIjKlMnOpQrStUvWxYz
13 Gross-, 13 Kleinbuchstaben
Gross: ABCDEFGHIJKLMNOPQRSTUVWXYZ
Klein: abcdefghijklmnopqrstuvwxyz
Die in diesem Beispiel verwendeten Klassifizierungs- und Konvertierungsroutinen
sind übrigens Makros. Beachten Sie die Unterschiede zwischen Funktionen und
Makros!
10.3
Stringoperationen
In vorangegangenen Abschnitten haben wir mehr oder weniger umständlich Funktionen zur Feststellung der Stringlänge und zum Vergleichen von Strings erstellt.
Natürlich hält die Standard Library auch für diese Aufgaben fertige Funktionen
bereit.
In einem Beispielprogramm wollen wir folgende Funktionen verwenden, um einen
Text aus mehreren Eingaben zusammenzusetzen:
왘
strlen – Stringlänge ermitteln
왘
strcmp – Strings vergleichen
왘
strcat – String an String anhängen
257
10
10
Die Standard C Library
A
B
C
D
E
F
# include <string.h>
void main()
{
char eingabe[100];
char text[500];
for( text[0] = 0; ; )
{
printf( "Eingabe: ");
scanf( "%s", eingabe);
if( strcmp( eingabe, "ende") == 0)
break;
if( strlen(text) + strlen(eingabe) < 500)
strcat( text, eingabe);
}
printf( "%s\n", text);
}
Listing 10.4 Beispiel für Stringoperationen
Für die verwendeten Stringfunktionen der Standardbibliothek wird zuerst die entsprechende Datei inkludiert (A). Innerhalb des Hauptprogramms werden die Puffer
für die Eingabe und den kumulierten Text angelegt (B). Beim Start der Schleife ist der
kumulierte Text leer und wird entsprechend gesetzt (C). Es erfolgt jeweils die Eingabe
neuer Textstücke, die Schleife endet, wenn »ende« eingegeben wird (D).
Nach jeder Eingabe wird geprüft, ob ausreichend Platz vorhanden ist (E). Ist dies der
Fall, wird der eingegebene Text an den kumulierten Text angefügt. Das Programm
erzeugt z. B. die folgende Ausgabe:
Eingabe: the
Eingabe: quick
Eingabe: brown
Eingabe: fox
Eingabe: jumps
Eingabe: over
Eingabe: the
Eingabe:lazy
Eingabe:dog
Eingabe: ende
thequickbrownfoxjumpsoverthelazydog
258
10.3
Stringoperationen
Zur Funktion strcmp (Stringvergleich) muss noch erwähnt werden, dass die Funktion
0 zurückgibt, wenn die beiden übergebenen Strings übereinstimmen, und dass bei
der Überprüfung zwischen Groß- und Kleinbuchstaben unterschieden wird. Genau
genommen, ist der Funktionswert die Differenz der beiden ersten Zeichen, in denen
sich die beiden Strings unterscheiden, oder 0, wenn sie sich nicht unterscheiden.
Dadurch liefert die Funktion strcmp nicht nur eine Information über die Gleichheit,
sondern auch über die lexikographische Ordnung4 der beiden Strings. Diese Information können Sie z. B. zum Sortieren von Strings verwenden.
Ich möchte Sie an dieser Stelle auch noch einmal eindringlich daran erinnern, dass
bei der Verwendung von Operationen, die Strings verändern, immer die Gefahr des
Pufferüberlaufs besteht. Das rufende Programm muss dafür sorgen, dass die übergebenen Puffer groß genug sind, damit es nicht zu einem Pufferüberlauf kommt. Im
Falle eines Pufferüberlaufs stürzt Ihr Programm in der Regel ab. Es könnte aber auch
in einem inkonsistenten Zustand weiterlaufen und später einen Fehler verursachen,
den Sie dann keiner Ursache mehr zuordnen könnten. Die beste Art, mit Fehlern dieser Art umzugehen, ist, sie erst gar nicht zu machen.
Beachten Sie auch, dass Sie im oben dargestellten Beispiel effizienter programmieren
können, wenn Sie bereits ermittelte Längen oder Zeiger auf Stringende nicht immer
wieder neu berechnen. Sie sollten daher immer im Blick behalten, dass bei einer
Stringoperation der zu untersuchende String Zeichen für Zeichen abgearbeitet wird.
Bei langen Strings kann das spürbar Laufzeit kosten, insbesondere, wenn die Operation in Schleifen vielfach aufgerufen wird. Betrachten Sie dazu das folgende Codefragment:
A
for( i= 0; str[i] != 0; i++)
{
...
}
B
C
len = strlen( str);
for( i = 0; i < len; i++)
{
...
}
D
for( i = 0; i < strlen(str); i++)
{
...
}
Listing 10.5 Laufzeitkosten von Funktionsaufrufen
4 Wenn beide Strings nur groß- oder nur kleingeschrieben sind.
259
10
10
Die Standard C Library
Die Schleifenkonstruktion in (A) ist effizient, da nur einmal über den String iteriert
wird. Die Schleifenkonstruktion in (B) und (C) ist bereits weniger effizient, da zweimal
über den String iteriert wird. Die Schleife in (D) ist ineffizient, da so oft über den
String iteriert wird, wie der String Zeichen hat.
Zusätzlich zu den hier betrachteten Funktionen gibt es zahlreiche weitere Funktionen, etwa um einen String zu kopieren (strcpy), ein bestimmtes Zeichen in einem
String zu finden (strchr) oder zu prüfen, ob ein String in einem anderen String enthalten ist (strstr).
10.4
Ein- und Ausgabe
Für die Ein- bzw. Ausgabe haben Sie die Funktionen scanf und printf kennengelernt.
Dabei soll es im Prinzip auch bleiben, obwohl es noch viele weitere Funktionen zur
Tastatureingabe und Bildschirmausgabe gibt.
Ein- und Ausgabe müssen sich aber nicht unbedingt auf Tastatur oder Bildschirm
beziehen. Wir können Daten ja auch aus einer Datei einlesen und in eine Datei ausgeben. Für ein C-Programm macht das keinen großen Unterschied. Eingabequellen wie
Tastatur oder Datei und Ausgabeziele wie Bildschirm oder Datei sind aus Sicht eines
C-Programms sogenannte Streams. Obwohl es offensichtliche Unterschiede zwischen Datei, Bildschirm und Tastatur gibt5, versucht das Laufzeitsystem, auf der abstraktesten Ebene die unterschiedlichen Streams einheitlich zu behandeln. Erst in
tieferen, systemnäheren Schichten, die wir aber nicht betrachten werden, werden die
Unterschiede sichtbar.
Wenn ein C-Programm startet, öffnet das Laufzeitsystem zwei6 Streams:
왘
stdin (Standard Input)
왘
stdout (Standard Output)
Die Funktion scanf liest ihre Eingaben vom Standard-Inputstream. Die Funktion
printf schreibt ihre Ausgaben auf den Standard-Outputstream. Diese Streams sind
dann mit der Tastatur bzw. dem Bildschirm verknüpft. Die Funktionen printf und
scanf nutzen diese Streams implizit. Wir können die Streams aber auch explizit nutzen. Dazu verwenden wir die Funktionen fscanf und fprintf. Diese Funktionen lassen sich in der folgenden Weise verwenden:
5 Zum Beispiel endet die Eingabe aus einer Datei, wenn die Datei vollständig gelesen ist. Die Eingabequelle »Tastatur« versiegt jedoch nie.
6 Genau genommen wird noch ein dritter Stream (stderr) geöffnet, aber der soll uns hier nicht
interessieren.
260
10.4
Ein- und Ausgabe
void main()
{
char name[100];
int alter;
A
B
fprintf( stdout, "Bitte gib deinen Namen und dein Alter an: " );
fscanf( stdin, "%s %d", name, &alter);
fprintf( stdout, "Du heisst %s und bist %d Jahre alt.\n", name, alter);
}
Listing 10.6 Schreiben in Streams
In (A) und (B) werden Funktionen wie printf und scanf verwendet, wobei im ersten
Parameter der Stream steht.
Bitte gib deinen Namen und dein Alter an: Otto 42
Du heisst Otto und bist 42 Jahre alt.
Das ist natürlich noch kein Gewinn, da es printf und scanf auch getan hätten, aber
Sie können eigene Streams öffnen und mit fscanf Daten aus diesen Streams lesen
und mit fprintf in diese Streams schreiben. Dazu müssen Sie nur zwei weitere Funktionen kennenlernen. Mit fopen können Sie einen Stream aus einer Datei öffnen, und
mit fclose können Sie den Stream wieder schließen. Das sieht dann so aus:
# include <stdio.h>
# include <stdlib.h>
A
B
C
D
E
void main()
{
char token[100];
int counter = 0;
FILE *pf;
pf = fopen( "Test.c", "r");
if( pf == 0)
return;
for( ; ; )
{
fscanf( pf, "%s", token);
if( feof( pf))
261
10
10
Die Standard C Library
E
break;
counter++;
printf( "Token %3d: %s \n", counter, token);
}
fclose(pf);
F
G
}
Listing 10.7 Anwendung von Dateioperationen
Mit dem Datentyp für einen Stream legen wir die Variable pf an (A). fopen versucht,
die Datei zu öffnen, und gibt den Stream zurück (B). Dabei öffnet, in unserem Beispiel, das Programm seine eigene Quellcodedatei (Test.c) zum Lesen ("r" steht für
read).
Falls die Datei nicht geöffnet werden konnte, wird das Programm beendet (D).
Ansonsten wird jeweils ein Wort aus der Datei gelesen (D). Erst wenn nichts mehr
gelesen werden konnte, wird die Schleife beendet (E). Im anderen Fall wird das Wort
auf dem Bildschirm ausgegeben (F). Nach Verlassen der Schleife wird die Datei wieder
geschlossen (G). Wir erhalten damit diese Ausgabe:
Token
Token
Token
Token
Token
Token
Token
Token
Token
...
1:
2:
3:
4:
5:
6:
7:
8:
9:
#
include
<stdio.h>
#
include
<stdlib.h>
void
main()
{
Beim Aufruf von fopen übergeben wir zwei Parameter. Bei dem ersten handelt es sich
um den Dateinamen, dem auch ein Dateipfad vorangestellt sein könnte, und beim
zweiten Parameter können wir festlegen, ob die Datei zum Lesen ("r") oder zum
Schreiben ("w") geöffnet werden soll. Weitere Öffnungsmodi sind möglich, werden
hier aber nicht diskutiert. Die Funktion gibt einen Zeiger (einen sogenannten Filepointer) zurück, der, wenn er nicht 0 ist, auf den geöffneten Stream zeigt. Über diesen
Zeiger greifen dann alle nachfolgenden Funktionen auf die Datei zu. Die Funktion
feof7 testet, ob ein Leseversuch hinter dem Dateiende gemacht wurde. Ist das der
Fall, wird die Leseschleife abgebrochen. Am Ende des Hauptprogramms wird die
Datei mit fclose geschlossen. Auch wenn das Laufzeitsystem zum Programmende
7 EOF = End of File
262
10.5
Variable Anzahl von Argumenten
alle offenen Streams schließt, ist es guter Stil, Streams zu schließen, sobald man sie
nicht mehr benötigt, da dann die durch den Stream belegten Systemressourcen freigegeben werden können.
In unserem Beispiel haben wir nur Daten gelesen, aber völlig analog können Sie
natürlich auch in eine zum Schreiben geöffnete Datei mit fprintf Daten schreiben.
Mehrere Dateien gleichzeitig zum Lesen und/oder Schreiben geöffnet zu halten, ist
selbstverständlich auch möglich; Sie müssen nur für jede Datei eine eigene Zeigervariable anlegen.
Das war ein kurzer Einblick in die Dateioperationen der Standardbibliothek. Es gibt
viele weitere Funktionen, die auch den Zugriff auf systemnäheren Ebenen ermöglichen. Auch auf die vielfältigen Möglichkeiten, die Ein- bzw. Ausgabe über den Formatstring zu gestalten, bin ich nicht eingegangen. Ich habe mich bewusst sehr kurz
gefasst, weil diese Dateioperationen nicht so wichtig sind, wie man auf den ersten
Blick vermuten könnte. Bei großen Datenmengen, die Sie flexibel in einem Programm verwalten möchten, verwenden Sie Datenbanken, und auch bei einfachen
Dateien finden Sie heute in der Regel Strukturen wie z. B. XML. Versuchen Sie erst gar
nicht, eine XML-Datei mit diesen Dateioperationen einzulesen. Dafür gibt es sogenannte XML-Parser, die in eigenen Bibliotheken frei verfügbar sind und diese Aufgabe viel eleganter und effizienter erledigen.
10.5
Variable Anzahl von Argumenten
Stellen Sie sich vor, dass Sie eine Funktion wie printf selbst schreiben wollten. Sie
würden schon daran scheitern, dass Sie keine Funktion erstellen könnten, die eine
variable Anzahl von Parametern hat. Alle unsere Funktionen haben eine Schnittstelle
mit einer festgelegten Anzahl von Parametern. Von dieser Restriktion wollen wir uns
befreien, indem wir eine Funktion erstellen, die beliebig viele als Parameter übergebene Zahlen addiert und das Ergebnis zurückgibt. Wir schauen direkt auf den Code:
# include <stdio.h>
# include <stdlib.h>
A
# include <stdarg.h>
B
int summe( int anz,...)
{
va_list ap;
int sum;
int summand;
C
263
10
10
Die Standard C Library
D
E
F
G
va_start( ap, anz);
for( sum = 0; anz; anz--)
{
summand = va_arg( ap, int);
sum += summand;
}
va_end( ap);
return sum;
}
Listing 10.8 Variable Anzahl von Argumenten
Sie wissen sicher noch, dass die einer Funktion übergebenen Parameter auf dem
Stack liegen. Wenn wir von dem zwingend notwendigen ersten Parameter die
Adresse nehmen, haben wir einen Zeiger in den Stack. Wenn wir zusätzlich den
Datentyp des ersten Parameters kennen und wissen, wie der Stack auf unserem System organisiert ist, können wir daraus die Adresse des ersten unspezifizierten Parameters ermitteln. Genau das macht das Makro va_start (D) mit dem in (C)
angelegten Stackpointer für den Parameterzugriff. In der Schleife über alle unspezifizierten Parameter (E) rückt das Makro va_arg dann den Zeiger entsprechend dem
zuletzt betrachteten Datentyp weiter (F). Nach dem Durchlaufen der Schleife wird die
Stack-Operation beendet (G).
Wenn ich Ihnen nicht genau sage, wie diese Berechnungen im Einzelnen aussehen,
liegt das daran, dass hier auf verschiedenen Rechnerarchitekturen unter Umständen
unterschiedliche Berechnungen ausgeführt werden müssen. Wenn es Sie interessiert, wie das auf Ihrem System gemacht wird, schauen Sie in die Header-Datei
stdarg.h. Im Hauptprogramm können wir jetzt die Funktion summe mit unterschiedlicher Parameterzahl rufen:
void main()
{
int a=1, b=2, c=3, d=4;
int x;
x = summe( 2, a, b);
printf( "%d\n", x);
x = summe( 3, a, b, c);
printf( "%d\n", x);
x = summe( 4, a, b, c, d);
printf( "%d\n", x);
}
Listing 10.9 Verwendung der Funktion summe
264
10.6
Freispeicherverwaltung
Die Funktion summe wird im Programm mit unterschiedlich vielen Parametern aufgerufen:
3
6
10
Wichtig ist, dass wir im ersten Parameter mitteilen, wie viele unspezifizierte Parameter folgen. Eine Funktion mit variabler Argumentzahl muss irgendwo die Information erhalten, mit wie vielen Parametern sie aufgerufen wurde. In unserem Fall
übergeben wir diese Zahl explizit als ersten Parameter. Bei printf ist das z. B. nicht so.
Die Funktion printf wertet den Formatstring aus, und immer wenn sie aufgrund
eines %-Zeichens einen neuen Parameter eines bestimmten Typs benötigt, holt sie
sich ihn vom Stack. Das Beispiel von printf zeigt auch, dass eine Funktion mit variabler Argumentliste eine heterogene Parameterstruktur haben kann. Die Funktion
muss aber erkennen können, mit wie vielen Parametern welchen Typs sie gerufen
wurde, um den Stack richtig zu interpretieren.
10.6
Freispeicherverwaltung
Die größte Beschränkung, die unsere Programme derzeit noch haben, ist, dass wir
zur Compilezeit bereits wissen und festlegen müssen, welches Datenvolumen wir im
Speicher verarbeiten wollen. Besonders störend ist uns das bei Arrays aufgefallen.
Wir müssen zur Compilezeit entscheiden, wie viele Elemente ein Array haben soll
und können zur Laufzeit nichts mehr an dieser Entscheidung ändern. Von diesen
Fesseln kann man sich durch die Funktionen malloc, calloc, realloc und free
befreien. Mit malloc und calloc kann man sich zur Laufzeit dynamisch Speicher
holen (allokieren), mit realloc kann man diesen Speicher vergrößern und mit free
wieder freigeben.
Als erstes Beispiel zur Verwendung dieser Funktionen schreiben wir ein früher
bereits erstelltes Programm, das vom Benutzer eingegebene Zahlen in umgekehrter
Reihenfolge wieder ausgibt. Diesmal wollen wir aber den Benutzer vor der Eingabe
entscheiden lassen, wie viele Zahlen er eingeben will:
A
B
void main()
{
int *p;
int anz, i;
printf( "Wie viele Zahlen: ");
scanf( "%d", &anz);
p = (int *)malloc( anz*sizeof(int));
265
10
10
Die Standard C Library
C
D
for( i = 0; i < anz; i++)
{
printf( "%d. Zahl: ", i+1);
scanf( "%d", p+i);
}
for( i = anz-1; i >= 0; i--)
printf( "%d\n", p[i]);
free( p);
}
Listing 10.10 Verwendung der Freispeicherverwaltung
Wie viele Zahlen: 5
1. Zahl: 1
2. Zahl: 2
3. Zahl: 3
4. Zahl: 4
5. Zahl: 5
5
4
3
2
1
Wir starten mit einem Zeiger auf Integer, da wir ja Integer-Zahlen in einem Array verwalten wollen8 (A). Mit der Funktion malloc wird dann Speicher allokiert und dem
Zeiger zugewiesen (B). Dazu teilen wir der Funktion mit, wie viel Bytes Speicher
(anz*sizeof(int)) wir benötigen. Der sizeof-Operator sagt uns, wie viele Bytes eine
Integer-Zahl belegt. Die Funktion malloc kann nicht wissen, wofür wir den Speicher
benötigen und gibt daher einen unspezifizierten Zeiger (void *) zurück. Diesen müssen wir daher noch durch eine Typumwandlung auf den richtigen Typ (int *) konvertieren. Jetzt ist der Speicher allokiert und wir können ihn über den Zeiger wie
einen »normalen« Array verwenden (C). (p+i) entspricht dabei &p[i]. Am Ende wird
der nicht mehr benötigte Speicher wieder freigegeben. Mit malloc allokierter Speicher liegt nicht auf dem Stack und wird daher beim Verlassen der Funktion, in der er
angelegt wurde nicht automatisch wieder beseitigt. Das passiert erst, wenn wir es
explizit durch Aufruf der free-Funktion veranlassen (D). Immer, wenn Sie in einem
Programm malloc (oder realloc s.u.) verwenden, müssen Sie sich Gedanken darüber
machen, wann, wo und wie der Speicher wieder freizugeben ist. Wenn Sie das vergessen, hat Ihr Programm ein »Speicherleck« aus dem sozusagen Speicher entweicht,
8 Sie erinnern sich noch an den Zusammenhang zwischen Zeigern und Arrays.
266
10.6
Freispeicherverwaltung
der dann für Ihr Programm nicht mehr nutzbar ist. Mit malloc und free kann man
bizarre Programmierfehler erzeugen. Das soll uns im Moment aber nicht interessieren. Im Moment wollen wir uns über die neue Programmierfreiheit freuen, die uns
diese Funktionen liefern.
So groß ist die Freiheit allerdings nun auch wieder nicht, denn noch immer muss der
Benutzer die Größe des Arrays vorgeben. Das heißt, dass er vor Beginn der Eingabe
schon wissen muss, wie viele Zahlen er eingeben will. Von dieser Beschränkung wollen wir uns jetzt lösen. Der Benutzer soll so lange Zahlen eingeben können, bis er die
Eingabe durch die Zahl –1 beendet. Wir könnten das mit malloc realisieren, indem wir
mit einer bestimmten Arraygröße starten und immer, wenn der Array überzulaufen
droht, einen größeren Array allokieren, die Daten aus dem alten in den neuen Array
umkopieren und den alten Array wieder freigeben. Mit der Funktion realloc lässt
sich das aber auch einfacher realisieren:
A
B
C
D
E
F
G
void main()
{
int size = 0, increment = 2;
int anz, i, z;
int *p = 0;
for( anz = 0; ; anz++)
{
printf( "%d. Zahl: ", anz+1);
scanf( "%d", &z);
if( z == –1)
break;
if( anz >= size)
{
size = size + increment;
p = (int *)realloc( p, size*sizeof( int));
printf( "Array auf %d Elemente vergroessert\n", size);
}
p[anz] = z;
}
for( i = anz-1; i >= 0; i--)
printf( "%d\n", p[i]);
free( p);
}
Listing 10.11 Reallokieren von Speicher
267
10
10
Die Standard C Library
1. Zahl: 1
Array auf 2 Elemente vergroessert2
2. Zahl: 2
3. Zahl: 3
Array auf 4 Elemente vergroessert
4. Zahl: 4
5. Zahl: 5
Array auf 6 Elemente vergroessert
6. Zahl: 6
7. Zahl: –1
6
5
4
3
2
1
Die Funktion realloc erhält neben der Größe des zu allokierenden Speichers im ersten Parameter einen Zeiger. Ist dieser Zeiger 0, so verhält sich realloc wie malloc und
allokiert neuen Speicher in der erforderlichen Größe. Ist der Zeiger ungleich 0, so
geht realloc davon aus, dass dort bereits Speicher allokiert ist und prüft, ob dieser
Speicher für die neue Anforderung bereits ausreicht. Ist das nicht der Fall, so allokiert
realloc neuen Speicher, kopiert den Speicherinhalt vom alten in den neuen Speicher
um, gibt den alten Speicher frei und gibt einen Zeiger auf den neuen Speicher an das
rufende Programm zurück.
Mit dieser Funktion können wir unseren Array bedarfsgerecht wachsen lassen. Wir
starten mit Arraygröße 0 (size) und vergrößern den Array immer um eine
bestimmte Anzahl von Elementen (increment) (A). Am Anfang ist noch kein Speicher
allokiert (B). Wenn die Größe nicht mehr ausreicht (C) wird die neue Kapazität
berechnet (D) und Speicher allokiert (E). Nach Abbruch der Schleife erfolgt die Ausgabe rückwärts (F) und die Freigabe des Speichers (G).
So kleinschrittig wie in dem obigen Beispiel wird man allerdings normalerweise
nicht vorgehen.
Sollte uns einmal der Speicher ausgehen, so geben Funktionen wie malloc und free
den Wert 0 zurück. Das ist eine schwierige Situation. Da aber bei unseren Programmen diese Situation sicher nicht eintreten wird, ignorieren wir dieses Problem.
Der effiziente und korrekte Umgang mit Speicher ist eine der anspruchsvollsten Aufgaben der C-Programmierung. Leider werden hier immer wieder Fehler gemacht, die
zu Abstürzen oder dramatischen Sicherheitslücken in Programmen führen können.
Traurige Berühmtheit erlangte dabei im Jahr 2014 der sogenannte Heartbleed-Bug,
268
10.6
Freispeicherverwaltung
der einen Großteil der Server im Internet betraf. Ich will versuchen, Ihnen diesen
Fehler auf einfache Weise zu erklären. Um eine gesicherte Internetverbindung zu
einem Server auch bei längerer Inaktivität des Benutzers aufrecht zu erhalten, kann
der Client spontan ein beliebiges Datentelegramm schicken und den Server bitten, es
zurückzuschicken. Man nennt dies einen Heartbeat. Der Client sendet dabei auch die
Information, wie lang sein Datentelegramm ist. Der Server war nun so programmiert, dass er, entsprechend der vom Client mitgeteilten Telegrammlänge, Speicher
allokierte, das Telegramm entsprechend der Anzahl der effektiv gesendeten Bytes in
den Speicher kopierte und dann den Speicherinhalt entsprechend der mitgeteilten
Telegrammlänge zurückschickte. Beachten Sie, dass hier zwei verschiedene Längen
im Spiel sind. Einerseits die vom Client behauptete Telegrammlänge und andererseits die Länge des vom Client effektiv gesendeten Telegramms.
Wenn man jetzt den Client so programmiert, dass er bei jedem Heartbeat behauptet,
dass sein Telegramm 512 Bytes groß ist, er aber nur 1 Byte effektiv sendet, führt das
dazu, dass der Server mit jedem Heartbeat 511 Bytes aus seinem Hauptspeicher
schickt, die unter Umständen noch mit Daten von einer früheren Nutzung belegt
sind. Auf diese Weise erhält der Client kleine Puzzlestücke des Serverspeichers, die er
versuchen kann zu einem größeren Bild zusammenzusetzen. Im übertragenen Speicher findet der Client dann gegebenenfalls Passwörter oder andere sicherheitsrelevante Informationen. Diese Sicherheitslücke war besonders heimtückisch, weil man
nicht feststellen konnte, welche Informationen abgegriffen wurden. Der Fehler lässt
sich natürlich ganz einfach vermeiden, indem man ein Telegramm in der effektiv
empfangenen Länge zurückschickt. Dann erhält der Client nur seine gesendeten
Daten zurück.
Bevor wir uns den Aufgaben dieses Kapitels zuwenden, möchte ich Ihnen zeigen, wie
Sie mit dem Zufallszahlengenerator sehr einfach Testdaten erzeugen können. Als
Erstes erstellen wir eine Funktion, die eine Zufallszahl in einem vorgegebenen
Bereich berechnet:
int zfzahl (int min, int max)
{
return rand()%(max-min+1) + min;
}
Listing 10.12 Berechnung von Zufallszahlen
Die Formel habe ich Ihnen ja im Abschnitt 10.1, »Mathematische Funktionen«,
erklärt.
Aufbauend auf dieser Funktion, erstellen wir jetzt eine Funktion, die Zeichenketten
zufällig erzeugt:
269
10
10
Die Standard C Library
void zfstring (char *s, int len, char von, char bis)
{
int i;
for ( i = 0; i < len; i++)
s[i] = zfzahl( von, bis);
s[i] = 0;
}
Listing 10.13 Erzeugen eines Zufallsstrings
Als Parameter erhält die Funktion einen ausreichend großen Stringpuffer, die Länge
des zu erzeugenden Strings und den gewünschten Zeichenbereich.
Diese Funktionen testen wir in einem Hauptprogramm:
void main()
{
int seed = 12345;
int i;
char str[100];
srand( seed );
for( i = 0; i < 5; i++ )
{
zfstring( str, zfzahl( 1, 10 ), 'a', 'z' );
printf( "%2d: %s\n", i, str );
}
printf( "\n" );
for( i = 0; i < 5; i++ )
{
zfstring( str, zfzahl( 1, 10 ), '0', '9' );
printf( "%2d: %s\n", i, str );
}
}
Listing 10.14 Test der Zufallsstringerzeugung
0:
1:
2:
3:
4:
270
cdzef
feejghws
wyxxafq
khdac
aghycwulci
10.7
0:
1:
2:
3:
4:
Aufgaben
99467
78070
0877
4
01
Sie sehen, dass wir jetzt beliebige Zeichenketten wahlweise mit Buchstaben oder mit
Ziffern erzeugen können. So haben wir die Möglichkeit, Testdaten für Funktionen,
die Zeichenketten verarbeiten, zu erzeugen und damit Massentests durchzuführen.
10.7
Aufgaben
A 10.1 Erstellen Sie eine Funktion, die 1000 Zufallsstrings erzeugt und diese in eine
Datei schreibt, deren Name als Parameter an die Funktion übergeben wird.
A 10.2 Erstellen Sie eine Funktion, die die in Aufgabe 10.1 erstellten Strings aus der
Datei einliest und eine Statistik über die Buchstabenhäufigkeit erstellt.
A 10.3 Betrachten Sie folgende Funktionen der Standard Library:
왘
atoi
왘
strcat
왘
strchr
왘
strcmp
왘
strcpy
왘
strcspn
왘
strlen
왘
strncat
왘
strncmp
왘
strncpy
왘
strpbrk
왘
strrchr
왘
strspn
왘
strstr
왘
strtok
왘
strtol
Besorgen Sie sich Informationen über die Schnittstelle dieser Funktionen.
Implementieren Sie dann die Funktionen myatoi, mystrcat, ... mit gleicher
Funktionalität und Schnittstelle. Erstellen Sie einen Testrahmen, in dem Sie
271
10
10
Die Standard C Library
Massentestdaten generieren und die Ergebnisse Ihrer Funktionen mit denen
der Originalfunktionen vergleichen.
Lesen Sie die Testdaten wahlweise auch aus einer Textdatei ein, die Sie zuvor
erzeugt haben.
272
Kapitel 11
Kombinatorik
La multitude qui ne se réduit pas à l'unité est confusion
(Vielfalt, die nicht auf Einheit zurückgeht, ist Wirrwarr)
– Blaise Pascal
Häufig haben wir es bei der Programmierung mit Problemen zu tun, bei denen in
einem einfach strukturieren, aber sehr großen Suchraum eine kleine, durch komplexe Bedingungen gegebene Menge von Lösungen zu finden ist. Oft kann man die
gesuchten Lösungen nicht direkt konstruieren, sondern muss den gesamten Suchraum durchlaufen und die gültigen Lösungen herausfiltern. Man nennt das eine
Brute-Force-Attacke, weil man mit brachialer Gewalt versucht, die Lösungen unter
allen nur denkbaren Kandidaten zu finden. Durch eine Brute-Force-Attacke haben
wir z. B. alle Lösungen des Damenproblems gefunden. Sie kennen vielleicht das Beispiel, wie man auf diese Weise einen Tiger fängt. Man baut einen Zaun um sich
herum und kann sicher sein, dass in dem durch den Zaun begrenzten Außengebiet
ein Tiger ist. Jetzt kommt es nur noch darauf an, das Innengebiet systematisch zu vergrößern und dadurch das Außengebiet systematisch zu verkleinern. Man muss nur
aufpassen, dass dabei kein Tiger entwischt. Sie sehen, dass man auf diese Weise nicht
nur einen Tiger, sondern alle Tiger fangen kann. Besser wäre es natürlich, wenn man
in der Lage wäre, einen Tiger gezielt aufzuspüren.
Kombinatorik ist ein Teilgebiet der Mathematik, das sich, vereinfacht gesprochen,
mit den Möglichkeiten beschäftigt, Elemente aus einer Menge in verschiedenartiger
Weise auszuwählen und zusammenzustellen. Wir wollen hier keine Mathematik
betreiben, aber wir interessieren uns für die Kombinatorik insoweit, wie sie uns bei
der Programmierung hilft. Für die Programmierung gibt es im Wesentlichen zwei
kombinatorische Fragestellungen:
왘
Wie viele verschiedene, einem bestimmten Schema folgende Auswahlen gibt es?
왘
Wie können alle einem bestimmten Schema folgenden Auswahlen erzeugt
werden?
Die Antwort auf die erste Frage sagt uns, wie groß der Suchraum unseres Problems
ist. Durch die Beantwortung dieser Frage hoffen wir, Formeln zu finden, die uns helfen, die zu erwartende Rechenzeit zur Lösungssuche vorab zu bestimmen. Durch die
273
11
11
Kombinatorik
Beantwortung der zweiten Frage hoffen wir, konkrete Algorithmen zu finden, die uns
bei der »erschöpfenden Lösungssuche« helfen.
In diesem Kapitel formulieren wir vier kombinatorische Grundaufgaben, für die wir
dann die beiden oben gestellten Fragen beantworten werden.
11.1
Kombinatorische Grundaufgaben
Man könnte die Ziehung der Lottozahlen (6 aus 49) in zweierlei Hinsicht ändern:
왘
Man könnte zulassen, dass eine Zahl mehrfach gezogen wird. Man würde dazu die
gezogene Kugel immer wieder in das Ziehungsgerät zurücklegen. Sechsmal die 1
wäre dann ein gültiger Tipp und eine mögliche Ziehung.
왘
Man könnte verlangen, dass man die gezogenen Zahlen in der korrekten Ziehungsreihenfolge getippt haben muss, um zu gewinnen. 1, 2, 3, 4, 5, 6 wäre dann ein
anderes Ziehungsergebnis als 6, 5, 4, 3, 2, 1. Das Ziehungsverfahren müsste man
dazu nicht ändern. Man müsste nur das Ziehungsergebnis in der Reihenfolge der
Ziehung bekannt geben.
In beiden Fällen müssten natürlich die Tippscheine neu gestaltet werden.
Insgesamt ergeben sich durch die Kombination der beiden Varianten vier verschiedene Ziehungsmodalitäten. Man könnte mit/ohne Wiederholungen und mit/ohne
Berücksichtigung der Ziehungsreihenfolge arbeiten. Diese vier Fälle wollen wir im
Folgenden diskutieren.
11.2
Permutationen mit Wiederholungen
An Ihrem Fahrrad haben Sie ein Zahlenschloss. Ein solches Schloss besteht in der
Regel aus vier Zahlenringen, die unabhängig voneinander auf Zahlen zwischen 1 und
9 eingestellt werden können. Zum Öffnen des Schlosses kommt es darauf an, die richtige Zahl an der korrekten Position einzustellen.
Was hat das mit der Ziehung der Lottozahlen zu tun?
In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal
eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel wieder in die Trommel zurück. Gewonnen hat, wer die gezogenen Kugeln in der richtigen Reihenfolge getippt hat.
Wir führen also eine Ziehung von k Kugeln aus einer Grundgesamtheit mit n Kugeln
mit Zurücklegen und mit Beachtung der Reihenfolge durch.
Wie viele verschiedene Ziehungsergebnisse gibt es?
274
11.3
Permutationen ohne Wiederholungen
Es gibt n Möglichkeiten, die erste Kugel zu ziehen.
In jedem dieser n Fälle gibt es n Möglichkeiten, die zweite Kugel zu ziehen.
Das sind insgesamt n2 Fälle.
In jedem dieser n2 Fälle gibt es n Möglichkeiten, die dritte Kugel zu ziehen. Das
macht insgesamt n3 Fälle.
Setzt man diese Überlegung auf alle k zu ziehenden Kugeln fort, ergibt sich,
dass es insgesamt nk mögliche Ziehungsergebnisse gibt.
Im Falle des Fahrradschlosses bedeutet dies, dass es 94 = 6561 verschiedene Einstellungsmöglichkeiten gibt und dass die Chance, die richtige Einstellung auf Anhieb zu
erraten, 1:6561 ist.
Wir fassen unsere Ergebnisse zusammen und führen dabei einen neuen Begriff ein:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es auf
die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Permutation
mit Wiederholungen, wenn in der Auswahl Wiederholungen von Elementen
vorkommen dürfen.
Es gibt nk solcher Permutationen.
11.3
Permutationen ohne Wiederholungen
Sie sind beim Pferderennen. Acht Pferde sind am Start, und Sie wollen einen Tipp auf
den korrekten Einlauf der drei ersten Pferde abgeben. Wie groß sind Ihre Gewinnchancen, wenn man annimmt, dass alle acht Pferde gleich stark sind, also jeder mögliche Einlauf gleich wahrscheinlich ist? Eigentlich ist das die gleiche Fragestellung wie
im vorangegangenen Abschnitt – nur, dass hier Wiederholungen ausgeschlossen
sind, denn ein Pferd kann ja nicht gleichzeitig als Erster und als Zweiter oder Dritter
über die Ziellinie gehen. Das oben gewählte Modell können wir in abgewandelter
Form wiederverwenden:
In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal
eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel nicht wieder in die Trommel zurück. Gewonnen hat, wer die
gezogenen Kugeln in der richtigen Reihenfolge geraten hat.
Wie viele verschiedene Ziehungsergebnisse gibt es?
Es gibt n Möglichkeiten, die erste Kugel zu ziehen.
In jedem dieser n Fälle gibt es n-1 Möglichkeiten, die zweite Kugel zu ziehen.
Das sind insgesamt n · (n – 1) Fälle.
In jedem dieser n(n – 1) Fälle gibt es n-2 Möglichkeiten, die dritte Kugel zu ziehen. Das macht insgesamt n(n – 1)(n – 2) Fälle.
275
11
11
Kombinatorik
Setzt man diese Überlegung auf alle k zu ziehenden Kugeln fort, ergibt sich,
dass es insgesamt n · (n – 1) · ... · (n – k + 1) mögliche Ziehungsergebnisse gibt.
Mathematisch kann man dieses Ergebnis etwas kompakter formulieren, wenn man
geschickt erweitert und sich erinnert, dass das Produkt der ersten x natürlichen Zahlen mit x! (x-Fakultät) bezeichnet wird:
n( n – 1) ⋅ … ⋅ (n – k + 1)(n – k) ⋅ … ⋅ 1
n!
n ( n – 1 ) ⋅ … ⋅ ( n – k + 1 ) = ------------------------------------------------------------------------------------------------ = ------------------(n – k) ⋅ … ⋅ 1
( n – k )!
Die letzte Formel kann auch über eine andere Argumentation anschaulich hergeleitet werden. Wir nehmen die n Kugeln und legen sie in allen denkbaren Reihenfolgen
auf den Tisch. Dazu gibt es n! Möglichkeiten.
n Kugeln
n! Vertauschungen
k ausgewählte Kugeln
n-k nicht ausgewählte Kugeln
(n-k)! Vertauschungen
Abbildung 11.1 Darstellung der Permutation ohne Wiederholungen
Die ersten k Kugeln sollen die ausgewählten Kugeln sein. Jede Auswahl von k Kugeln
kommt aber so oft vor, wie es Vertauschungsmöglichkeiten im hinteren Teil gibt. Um
das Ergebnis zu erhalten, müssen wir also die Gesamtzahl der Möglichkeiten (n!)
durch die Anzahl der Vertauschungsmöglichkeiten im hinteren Teil ((n – k)!) dividieren. Als Ergebnis erhalten wir die Formel:
n!
------------------( n – k )!
Im Falle des Pferderennens (n = 8, k = 3) erhalten wir also
8!
8⋅7⋅6⋅5⋅4⋅3⋅2⋅1
----- = ------------------------------------------------------ = 336
3!
3⋅2⋅1
verschiedene Zieleinläufe. Die Chance zu gewinnen, ist also 1:336.
Wir fassen unser Ergebnis wieder unter einem neuen Begriff zusammen:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es auf
die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Permutation
ohne Wiederholungen, wenn in der Auswahl keine Wiederholungen von Elementen vorkommen dürfen.
n!
Es gibt ------------------- solcher Permutationen.
( n – k )!
276
11.3
11.3.1
Permutationen ohne Wiederholungen
Kombinationen ohne Wiederholungen
In Ihrem Bücherschrank stehen 100 Bücher. Sie wollen fünf Bücher auswählen, um
sie mit in den Urlaub zu nehmen. Wie viele verschiedene Buchpakete können Sie für
den Urlaub zusammenstellen? Diese Frage ähnelt den Fragestellungen des vorangegangenen Abschnitts. Der Unterschied besteht darin, dass es diesmal nicht auf die
Reihenfolge ankommt, in der die Elemente ausgewählt werden:
In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal
eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel nicht wieder in die Trommel zurück. Gewonnen hat, wer die
gezogenen Kugeln ohne Berücksichtigung der Ziehungsreihenfolge richtig
getippt hat.
Auf der Suche nach einer Formel legen wir wieder alle n Kugeln in allen möglichen
Reihenfolgen auf den Tisch. Die vorderen k Kugeln werden ausgewählt, die hinteren
n-k Kugeln sind nicht gewählt:
n Kugeln
n! Vertauschungen
k ausgewählte Kugeln
k! Vertauschungen
n-k nicht ausgewählte Kugeln
(n-k)! Vertauschungen
Abbildung 11.2 Darstellung der Kombinationen ohne Wiederholungen
Vertauschungen der Kugeln im vorderen wie im hinteren Teil vervielfachen jetzt die
Lösungsgesamtheit und müssen durch Divisionen entfernt werden. Damit ergibt
sich als Ergebnis:
n!
-----------------------k! ( n – k )!
Dieser Ausdruck ist so bedeutsam für die Mathematik, dass man ihm einen eigenen
Namen gegeben hat. Man nennt diesen Ausdruck Binomialkoeffizient und hat eine
abkürzende Schreibweise dafür eingeführt, bei der man die Zahlen n und k in einer
Klammer untereinanderschreibt:
n!
⎛ n⎞ = -----------------------⎝ k⎠
k! ( n – k )!
Man liest diesen Ausdruck dann »n über k«1. In gekürzter Form ist dies:
1 Die Bedeutung der Binomialkoeffizienten reicht weit über die Kombinatorik hinaus.
277
11
Kombinatorik
k-Faktoren
⎫
⎪
⎪
⎪
⎪
⎪
⎬
⎪
⎪
⎪
⎪
⎪
⎪⎭
11
n ( n – 1 ) ( n – 2 )… ( n – k + 1 )
⎛ n⎞ = ----------------------------------------------------------------------------⎝ k⎠
k ( k – 1 ) ( k – 2 )…1
Man schreibt also einen Bruch mit k Faktoren im Zähler und im Nenner – im Zähler
von n absteigend und im Nenner von k absteigend. Im Falle unserer Büchersammlung (n = 100, k = 5) ergeben sich
⋅ 99 ⋅ 98 ⋅ 97 ⋅ 96
⎛ 100⎞ = 100
------------------------------------------------------ = 75287520
⎝ 5 ⎠
5⋅4⋅3⋅2⋅1
verschiedene Buchpakete für den Urlaub. Hätten Sie gedacht, dass die Auswahl so
groß ist?
Zusammenfassung des Ergebnisses:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es nicht
auf die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Kombination ohne Wiederholungen, wenn in der Auswahl keine Wiederholungen von
Elementen vorkommen dürfen.
n!
Es gibt ⎛ n⎞ = ------------------------ solcher Kombinationen.
⎝ k⎠
k! ( n – k )!
11.3.2
Kombinationen mit Wiederholungen
Sie haben eine Tüte mit 100 verschiedenfarbigen Bonbons und wollen diese Bonbons
an fünf Kinder verteilen. Die Frage ist: Wie viele verschiedene Verteilungen gibt es?
Zur Verteilung wählen wir ein Kind aus und geben ihm das erste Bonbon. Dann wählen wir wieder ein Kind und geben ihm das nächste Bonbon. Dieses Verfahren setzen
wir fort, bis alle Bonbons verteilt sind2. Auch hier kommt es uns auf die Reihenfolge,
in der wir die Bonbons verteilen, nicht an. Im Gegensatz zum letzten Abschnitt können hier aber Wiederholungen auftreten, da ein Kind mehrfach beschenkt werden
kann.
Zur Veranschaulichung des allgemeinen Falls wählen wir wieder das Bild der Lostrommel:
In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal
eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel wieder in die Trommel zurück. Gewonnen hat, wer die gezogenen Kugeln ohne Berücksichtigung der Ziehungsreihenfolge getippt hat.
Zur Herleitung einer Formel stellen wir uns vor, dass wir eine Kugel aus der Menge
der n Kugeln herausnehmen und auf den Tisch legen. Zu den restlichen Kugeln fügen
wir k neue, von den anderen Kugeln unterscheidbare Kugeln hinzu. Die n+k-1 Kugeln
2 Achtung, wir teilen den Bonbons Kinder zu und nicht den Kindern Bonbons!
278
11.3
Permutationen ohne Wiederholungen
legen wir jetzt in allen möglichen Reihenfolgen hinter die am Anfang herausgelegte
Kugel auf den Tisch. Damit ergibt sich z. B. folgendes Bild:
n+k–1
n–1
k
Abbildung 11.3 Darstellung der Kombinationen mit Wiederholungen
Ausgewählt sind jetzt diejenigen grauen Kugeln, denen eine oder mehrere schwarze
Kugeln folgen, und zwar so oft, wie schwarze Kugeln folgen. Auf diese Weise sind k
der n grauen Kugeln ausgewählt, da es ja k schwarze Kugeln gibt. Natürlich kommen
auch hier die gesuchten Auswahlen entsprechend vielfach vor. Um die Vielfachen
auszuscheiden, müssen wir noch durch die Anzahl der möglichen Vertauschungen
der schwarzen Kugeln untereinander (das sind k!) und durch die Anzahl der möglichen Vertauschungen der grauen Kugeln inklusive ihrer schwarzen Nachfolger
untereinander (das sind (n – 1)!) dividieren. Insgesamt ergibt sich also die Formel:
( n + k – 1 )!
---------------------------k! ( n – 1 )!
Dies ist aber der Binomialkoeffizient:
⎛ n + k – 1⎞
⎝
k ⎠
Im Falle der Bonbonverteilung (n = 5, k = 100) gibt es
104!
104 ⋅ 103 ⋅ 102 ⋅ 101
⎛ 104 ⎞ = ---------------- = ------------------------------------------------- = 4598126
⎝ 100⎠
100!4!
4⋅3⋅2⋅1
mögliche Bonbonverteilungen. Auch hier erstaunt Sie sicher die große Zahl3.
Wir haben in diesem Abschnitt Kombinationen mit Wiederholungen betrachtet und
wieder eine Formel gewonnen:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es nicht
auf die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Kombination mit Wiederholungen, wenn in der Auswahl Wiederholungen von Elementen vorkommen dürfen.
3 Beachten Sie, dass ich eingangs von »verschiedenfarbigen«, also individuell unterscheidbaren,
Bonbons gesprochen habe. Das heißt, es ist ein Unterschied, ob ein Kind ein rotes oder ein hellrotes Bonbon bekommt.
279
11
11
Kombinatorik
( n + k – 1 )!
Es gibt ⎛ n + k – 1⎞ = ---------------------------- solcher Kombinationen.
⎝
k ⎠
k! ( n – 1 )!
11.3.3
Zusammenfassung
Wir haben vier kombinatorische Grundaufgaben betrachtet und sind jeweils zu Formeln über die Anzahl der möglichen Auswahlen gekommen:
Zahlenschloss k = 4 Ringe mit
n = 9 Einstellungsmöglichkeiten:
Finale mit n = 8 Läufern und k = 3 Medaillen
n!
= 8 · 7 · 6 = 336
(n – k)!
4
n = 9 = 6561
k
verschiedene Zahlencodes.
n-k-Auswahl
verschiedene Medaillenvergaben.
mit Wiederholungen
ohne Wiederholungen
k≤n
Permutation mit
Wiederholungen
Permutation ohne
Wiederholungen
mit Reihenfolge
n!
(n – k)!
nk
Kombination mit
Wiederholungen
ohne Reihenfolge
(
n+k–1
k
)
Verteilung von k = 100 Bonbons an n = 5 Kinder:
n+k–1
104
104
=
=
= 104 · 103 · 102 · 101 = 4598126
k
100
4
4·3·2·1
verschiedene Verteilungen.
(
) ( )( )
Kombination ohne
Wiederholungen
()
n
k
Ziehung der Lottozahlen k = 6 aus n = 49
n
49
=
= 49 · 48 · 47 · 46 · 45 · 44 = 13983816
k
6
6·5·4·3·2·1
verschiedene Ziehungsergebnisse.
() ( )
Abbildung 11.4 Übersicht über die kombinatorischen Grundaufgaben
Beachten Sie, dass, wenn Wiederholungen zugelassen sind, der Wert von k durchaus
größer als der Wert von n sein kann, was bei Auswahlen ohne Wiederholungen natürlich ausgeschlossen ist.
Es lohnt sich, die Entwicklung des Binomialkoeffizienten für unterschiedliche Werte
von n und k etwas genauer zu betrachten. Der Binomialkoeffizient liefert, bei gleichem n und k, jeweils die kleinste Zahl in der oben dargestellten Tabelle, da es ja weniger Kombinationen als Permutationen gibt und sich ohne Wiederholung weniger
Anordnungen ergeben als mit Wiederholungen. Den größten Wert hat der Binomialn
koeffizient ⎛ ⎞ bei festem n, wenn k etwa oder genau halb so groß ist wie n. Für n =
⎝ k⎠
100 ist
⎛ 100⎞ ≈ 10 29
⎝ 50 ⎠
Abbildung 11.5 zeigt das rasante Wachstum des Binomialkoeffizienten:
280
11.3
Permutationen ohne Wiederholungen
200000
180000
n
k
160000
140000
120000
100000
80000
60000
18
15
12
40000
20000
9
0
0 1 2 3
4 5 6 7
8 9 10
11 12
k
6
11
n
3
1314 15 16
17 18 19
0
20
Abbildung 11.5 Wachstum des Binomialkoeffizienten
Die anderen kombinatorischen Größen dieses Abschnitts wachsen noch weitaus
schneller als der Binomialkoeffizient. Wenn Sie also Algorithmen für kombinatorische Auswahlen entwickeln, sollten Sie sich darüber im Klaren sein, dass Ihre Programme für große Suchräume schnell an praktische Grenzen stoßen werden. Wer
will schon mehrere Tage oder Wochen auf das Ergebnis einer Berechnung warten? So
einfach eine Lösungssuche mit kombinatorischen Algorithmen auch ist, muss es
immer Ihr Bestreben sein, effizientere Algorithmen zu finden. Ob das prinzipiell
immer gelingen kann, ist eine der spannendsten und bis heute unbeantworteten Fragen der theoretischen Informatik, auf die ein Kopfgeld von 1 Million Dollar4 ausgesetzt ist.
Im Folgenden wollen wir Programme entwickeln, die Kombinationen bzw. Permutationen mit und ohne Wiederholungen erzeugen. Als Grundmenge betrachten wir
dabei stets n Zahlen zwischen 0 und n-1. Das ist keine Einschränkung, weil wir diese
Zahlen als Indizes für den Zugriff auf die eigentlich auszuwählenden Elemente verwenden können. Mit anderen Worten: Wir permutieren und kombinieren nicht die
eigentliche Menge, sondern deren Indexmenge.
4 Wenn Sie die Frage beantworten können, können Sie sich Ihre Prämie hier abholen:
http://www.claymath.org/millennium/P_vs_NP/.
281
11
Kombinatorik
Stellen Sie sich vor, dass wir acht Gleitkommazahlen in einem Array haben. Das sind
die eigentlichen Daten, aus denen wir eine Auswahl treffen wollen:
double daten[8] = { 1.234, 3.14, 0.815, 47.11, 11.11, 0.1, 1.14, 2.718}
Eine Auswahl ist durch ein Array von Indizes gegeben:
int auswahl[3] = {2, 0, 7}
Ausgewählt sind in diesem Fall die drei Elemente mit den Indizes 2, 0 und 7:
daten[auswahl[0]] = daten[2] = 0.815
daten[auswahl[1]] = daten[0] = 1.234
daten[auswahl[2]] = daten[7] = 2.718
Abbildung 11.6 zeigt am Beispiel von 4-2-Auswahlen, welche Indexmengen unsere
Programme jeweils erzeugen werden:
n-k-Auswahl
mit Reihenfolge
ohne Reihenfolge
mit Wiederholungen
ohne Wiederholungen
k≤n
4-2-Permutationen
4-2-Permutationen
mit Wiederholungen
ohne Wiederholungen
1: < 0, 0>
1: < 0, 0>
2: < 0, 1>
2: < 1, 0>
3: < 0, 2>
3: < 0, 2>
4: < 0, 3>
4: < 2, 0>
5: < 1, 0>
5: < 0, 3>
6: < 1, 1>
6: < 3, 0>
7: < 1, 2>
7: < 1, 2>
8: < 1, 3>
8: < 2, 1>
9: < 2, 0>
9: < 1, 3>
4!
10: < 2, 1>
10: < 3, 1>
= 12
11: < 2, 2>
11: < 2, 3>
(4 – 2)!
12: < 2, 3>
12: < 3, 2>
13: < 3, 0>
14: < 3, 1> 42 = 16
15: < 3, 2>
16: < 3, 3>
4-2-Permutationen
4-2-Permutationen
mit Wiederholungen
ohne Wiederholungen
1: < 0, 0>
1: < 0, 1>
2: < 0, 1>
2: < 0, 2>
3: < 0, 2>
3: < 0, 3>
4: < 0, 3>
4: < 1, 2>
4
5: < 1, 1>
5: < 1, 3>
=6
2
6: < 1, 2>
6: < 2, 3>
7: < 1, 3>
8: < 2, 2>
9: < 2, 3>
4+2–1
10: < 3, 3>
= 10
2
()
(
Abbildung 11.6 Übersicht der erzeugten Indexmengen
282
)
11.4
11.4
Kombinatorische Algorithmen
Kombinatorische Algorithmen
Im Rahmen unserer Programmierübungen sind wir bereits mehrfach auf Probleme
gestoßen, die wir durch Erzeugung von Permutationen gelöst haben:
왘
In Kapitel 5, »Aussagenlogik«, haben wir in einem Programm alle möglichen
Schalterstellungen von sieben Schaltern erzeugt, um festzustellen, ob eine Lampe
leuchtet. Letztlich handelte es sich bei den Schalterstellungen um 2-7-Permutationen mit Wiederholungen.
왘
In Abschnitt 7.4, »Rekursion«, haben wir ein Programm perm erstellt, das n-n-Permutationen ohne Wiederholungen erzeugte. Das ist ein Spezialfall der hier zu
betrachtenden n-k-Permutationen.
왘
Das 8-Damen-Problem haben wir nun durch die Erzeugung von 8-8-Permutationen zu lösen versucht. Wir hatten hier zunächst Permutationen mit Wiederholungen erzeugt und dann die Wiederholungen (= zwei Damen in gleicher Spalte)
herausgefiltert.
In diesem Abschnitt greifen wir das Thema der Erzeugung von Permutationen und
Kombinationen noch einmal auf – diesmal allerdings in Kenntnis der mathematischen Grundlagen und mit etwas mehr Systematik. Zur Implementierung werden
wir durchweg rekursive Algorithmen verwenden.
Alle Algorithmen dieses Abschnitts werden Arrays mit Permutationen bzw. Kombinationen der Zahlen von 0 bis n-1 als Ergebnis erzeugen. Es ist daher sinnvoll, vorweg
eine zentrale Funktion zur Ausgabe solcher Arrays zu erstellen:
int count = 0;
void print_array( int k, int array[])
{
int i;
printf( "%3d: (", ++count);
for( i = 0; i < k-1; i++)
printf( "%2d,", array[i]);
printf( "%2d)\n", array[k-1]);
}
Listing 11.1 Hilfsfunktion zur Ausgabe von Arrays
Der Funktion wird ein Array mit ganzen Zahlen und die Anzahl der Zahlen im Array
übergeben. Außerhalb der Funktion wird ein globaler Zähler angelegt und verwendet, um mitzuzählen, wie viele Permutationen bzw. Kombinationen bisher ausgegeben wurden.
283
11
11
Kombinatorik
Dieser Zähler wird jeder Ausgabe vorangestellt. Die Ausgaben, die diese Funktion
erzeugt, haben Sie ja bereits im letzten Abschnitt kennengelernt.
Jetzt kommen wir zu unseren eigentlichen Algorithmen.
11.4.1
Permutationen mit Wiederholungen
Wir behandeln zunächst n-k-Permutationen mit Wiederholungen, da dies der einfachste der hier zu untersuchenden Fälle ist. Veranschaulichen können wir uns das
Problem durch k Stangen, die von 0 bis k-1 nummeriert, jeweils mit Zahlen von 0 bis
n-1 beschriftet und verschiebbar auf einer Querleiste montiert sind.
0
0
1
2
3
4
5
6
7
8
.
.
1
0
1
2
3
4
5
6
7
8
.
.
n-1
2
0
1
2
3
4
5
6
7
8
.
.
x
0
1
2
3
4
5
6
7
8
.
.
k-1
n-1
n-1
0
1
2
3
4
5
6
7
8
.
.
n-1
n-1
Abbildung 11.7 Veranschaulichung der Permutationen mit Wiederholungen
Wie beim Zahlenschloss eines Fahrrads liefert jede Einstellung der Stangen eine Permutation von Zahlen zwischen 0 und n-1, wobei sich Zahlen wiederholen können. Bei
fest vorgegebener Anzahl von Stangen könnte man diesen Mechanismus mit entsprechend vielen ineinander geschachtelten Schleifen simulieren. Da wir aber die
Zahl der Stangen variabel halten wollen, müssen wir es anders machen und entscheiden uns für Rekursion.
284
11.4
A
B
C
D
E
F
Kombinatorische Algorithmen
void perm_mw(int n, int k, int array[], int x)
{
int i;
if( x < k)
{
for( i = 0; i < n; i++)
{
array[x] = i;
perm_mw( n, k, array, x+1);
}
}
else
print_array( k, array);
}
11
Listing 11.2 Erzeugen von Permutationen mit Wiederholungen
Als Parameter erhält die Funktion die Anzahl der Werte auf einer Stange n, die Anzahl
der Stangen k, das Array array mit den Stellungen der k Stangen und den Index x der
Stange, die in dieser Funktionsinstanz gesetzt werden soll (A). Solange wir noch nicht
am Ende angekommen sind (B), wird die Stange x in alle möglichen Stellungen
gebracht (C) und (D), und die restlichen Stangen ab Index x+1 werden positioniert (E).
Ansonsten ist eine neue Permutation erzeugt und wird ausgegeben (F).
Mit einem entsprechenden Hauptprogramm können Sie jetzt Permutationen mit
Wiederholungen generieren:
A
B
void main()
{
int array[3];
printf( "2-3-Permutationen\nmit Wiederholungen\n");
perm_mw( 2, 3, array, 0);
}
Listing 11.3 Test und Ausgabe der Permutationen
Die Parameter n und k können Sie dabei frei wählen. Das Array muss nur groß genug
sein, um die k ausgewählten Zahlen aufzunehmen (A). Beachten Sie jedoch, dass das
Programm für n = k = 10 insgesamt 10 Milliarden Zeilen ausgeben würde.
In (B) erzeugen wir 2-3-Permutationen mit Wiederholung im Array, starten mit
Stange 0 und erhalten die folgende Ausgabe:
285
11
Kombinatorik
2-3-Permutationen
mit Wiederholungen
1: ( 0, 0, 0)
2: ( 0, 0, 1)
3: ( 0, 1, 0)
4: ( 0, 1, 1)
5: ( 1, 0, 0)
6: ( 1, 0, 1)
7: ( 1, 1, 0)
8: ( 1, 1, 1)
11.4.2
Kombinationen mit Wiederholungen
Bei Kombinationen ist die Reihenfolge, in der die Auswahlen getroffen werden, nicht
von Bedeutung. Wir können uns daher auf eine spezielle Reihenfolge, die dann stellvertretend für alle möglichen Reihenfolgen steht, beschränken. Naheliegenderweise
wählen wir die Reihenfolge, bei der alle Zahlen der Größe nach geordnet sind. Um das
in unserem Stangenmodell zu erzwingen, wird der Bewegungsspielraum der Stangen
so eingeschränkt, dass eine Stange niemals weiter nach unten geschoben werden
kann als die vorangegangene. Mechanisch würden Sie das Problem lösen, indem Sie
sich im Baumarkt ein paar Winkeleisen besorgen und diese unten an den Stangen als
Anschlag anbringen würden.
k-1
0
0
1
2
3
4
5
6
7
.
.
.
1
0
1
2
3
4
5
6
7
.
.
.
n-1
n-1
2
0
1
2
3
4
5
6
7
.
.
.
x
0
1
2
3
4
5
6
7
.
.
.
0
1
2
3
4
5
6
7
.
.
.
n-1
n-1
n-1
Abbildung 11.8 Veranschaulichung der Kombinationen mit Wiederholungen
286
11.4
Kombinatorische Algorithmen
Diese Winkel müssen wir jetzt in die Funktion zur Generierung der Kombinationen
einbauen. Das ist aber nicht schwer. Wir machen das durch einen zusätzlichen Parameter, der den Startwert oder Minimalwert für jede Stange transportiert:
A
B
C
void komb_mw(int n, int k, int array[], int x, int min)
{
int i;
if( x < k)
{
for( i = min; i < n; i++)
{
array[x] = i;
komb_mw( n, k, array, x+1, i);
}
}
else
print_array( k, array);
}
11
Listing 11.4 Kombination mit Wiederholungen
Dieser Wert wird als min zusätzlich für die aktuelle Stange übergeben (A). Im weiteren
Verlauf werden nur Werte ab dem Minimum eingestellt (B).
Beim rekursiven Aufruf wird dieser Parameter auf den Wert der aktuellen Stange
gesetzt, um dann auf der nächsten Ebene wieder als Startwert verwendet zu werden (C).
Diese Funktion komb_mw können wir nun wieder in einem passenden Hauptprogramm nutzen:
void main()
{
int array[4];
A
printf( "3-4-Kombinationen\nmit Wiederholungen\n");
komb_mw( 3, 4, array, 0, 0);
}
Listing 11.5 3-4-Kombinationen mit Wiederholungen
Im Hauptprogramm müssen Sie darauf achten, den Parameter für das Minimum mit
0 zu belegen, damit die erste Stange beim Wert 0 starten kann (A):
287
11
Kombinatorik
3-4-Kombinationen
mit Wiederholungen
1: ( 0, 0, 0, 0)
2: ( 0, 0, 0, 1)
3: ( 0, 0, 0, 2)
4: ( 0, 0, 1, 1)
5: ( 0, 0, 1, 2)
6: ( 0, 0, 2, 2)
7: ( 0, 1, 1, 1)
8: ( 0, 1, 1, 2)
9: ( 0, 1, 2, 2)
10: ( 0, 2, 2, 2)
11: ( 1, 1, 1, 1)
12: ( 1, 1, 1, 2)
13: ( 1, 1, 2, 2)
14: ( 1, 2, 2, 2)
15: ( 2, 2, 2, 2)
11.4.3
Kombinationen ohne Wiederholungen
Mit geringfügigen Änderungen können wir die Funktion zur Generierung von Kombinationen mit Wiederholungen auf Kombinationen ohne Wiederholungen umstellen. Wir müssen nur die Winkel eine Stelle höher anschrauben, damit verhindert
wird, dass gleiche Werte erzeugt werden können:
k-1
0
0
1
2
3
4
5
6
7
8
.
.
1
0
1
2
3
4
5
6
7
8
.
.
2
0
1
2
3
4
5
6
7
8
.
.
x
0
1
2
3
4
5
6
7
8
.
.
0
1
2
3
4
5
6
7
8
.
.
n-1
n-1
n-1
n-1
n-1
Abbildung 11.9 Veranschaulichung der Kombinationen ohne Wiederholungen
288
11.4
Kombinatorische Algorithmen
In dieser Konstruktion kann man eine Stange nicht mehr beliebig weit nach oben
schieben, da noch ausreichend Platz für die nachfolgenden Stangen bleiben muss.
Wenn wir die Stange x positionieren, müssen wir berücksichtigen, dass noch k-1-x
Stangen folgen werden, für die die Zahlen größer als (n-1)-(k-1-x) = n-k+x reserviert
bleiben müssen. Das müssen wir bei der Programmierung berücksichtigen:
void komb_ow(int n, int k, int array[], int x, int min)
{
int i;
A
B
if( x < k)
{
for( i = min; i <= n-k+x; i++)
{
array[x] = i;
komb_ow( n, k, array, x+1, i+1);
}
}
else
print_array( k, array);
}
11
Listing 11.6 Kombination ohne Wiederholungen
Die Schleife wird so angepasst, dass nach oben Platz für die noch folgenden Stangen
bleibt (A), und die nächste Stange muss mindestens 1 höher als die aktuelle Stange
sein (B).
Im Hauptprogramm erzeugen wir 5-3-Kombinationen ohne Wiederholungen:
void main()
{
int array[3];
printf( "5-3-Kombinationen\nohne Wiederholungen\n");
komb_ow( 5, 3, array, 0, 0);
}
Listing 11.7 5-3-Kombinationen ohne Wiederholungen
289
11
Kombinatorik
5-3-Kombinationen
ohne Wiederholungen
1: ( 0, 1, 2)
2: ( 0, 1, 3)
3: ( 0, 1, 4)
4: ( 0, 2, 3)
5: ( 0, 2, 4)
6: ( 0, 3, 4)
7: ( 1, 2, 3)
8: ( 1, 2, 4)
9: ( 1, 3, 4)
10: ( 2, 3, 4)
11.4.4
Permutationen ohne Wiederholungen
Es bleiben die Permutationen ohne Wiederholungen, die allerdings nicht so einfach
zu behandeln sind wie die bisher implementierten Auswahlen. Auch hier werden die
Bewegungsmöglichkeiten der Stangen eingeschränkt, aber nicht durch einen so einfachen Mechanismus wie zuvor. Hier wird gefordert, dass eine Stange auf keinen der
zuvor verwendeten Werte eingestellt wird.
k-1
0
0
1
2
3
4
5
6
7
8
.
.
1
0
1
2
3
4
5
6
7
8
.
.
n-1
2
0
1
2
3
4
5
6
7
8
.
.
x
0
1
2
3
4
5
6
7
8
.
.
0
1
2
3
4
5
6
7
8
.
.
n-1
n-1
n-1
n-1
Abbildung 11.10 Veranschaulichung der Permutationen ohne Wiederholungen
290
11.4
Kombinatorische Algorithmen
Die zur mechanischen Umsetzung dieser Vorschrift erforderlichen Bauteile gibt es
leider in keinem Baumarkt.
Wir wollen zweistufig vorgehen, indem wir zunächst, mit der Funktion des letzten
Abschnitts, n-k-Kombinationen ohne Wiederholungen erzeugen und dann für jede
dieser Auswahlen alle möglichen Reihenfolgen, d. h. alle k-k-Permutationen ohne
Wiederholungen, berechnen. Als Ergebnis erhalten wir dann alle n-k-Permutationen
ohne Wiederholungen.
Eine Funktion zur Berechnung von k-k-Permutationen ohne Wiederholungen haben
Sie in Abschnitt 7.4, »Rekursion«, unter dem Namen perm bereits kennengelernt. Wir
nehmen also diese Funktion und ändern sie so ab, dass, sobald eine Permutation
erzeugt wurde, das Programm print_array aufgerufen wird:
void perm( int anz, int array[], int start)
{
int i, sav;
A
11
if( start < anz)
{
sav = array[ start];
for( i = start; i < anz; i++)
{
array[start] = array[i];
array[i] = sav;
perm( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
else
print_array( anz, array);
}
Listing 11.8 Abgeänderte Funktion perm
Diese Funktion wurde bereits in Abschnitt 7.4, »Rekursion«, ausführlich erklärt. An
der Position (A) wurde eine neue Permutation erzeugt.
Jetzt modifizieren wir die Funktion komb_ow so, dass an der Stelle, an der eine n-kKombination erzeugt wurde, anstelle einer Ausgabe die Generierung aller k-k-Permutationen dieser Kombination angestoßen wird. Den Prozedurnamen ändern wir
gleichzeitig in perm_ow:
291
11
Kombinatorik
A
B
void perm_ow(int n, int k, int array[], int x, int min)
{
int i;
if( x < k)
{
for( i = min; i <= n-k+x; i++)
{
array[x] = i;
perm_ow( n, k, array, x+1, i+1);
}
}
else
perm( k, array, 0);
}
Listing 11.9 Permutation ohne Wiederholung
In dem Programm hat sich, im Vergleich zu komb_ow, nichts geändert, außer, dass die
Funktion von komb_ow in perm_ow umbenannt wurde (A). Sobald eine neue Kombination erzeugt wurde, wird diese permutiert (B).
Im Hauptprogramm rufen wir diese Funktion, um 4-2-Permutationen ohne Wiederholungen zu erzeugen:
void main()
{
int array[2];
printf( "4-2-Permutationen\nohne Wiederholungen\n");
perm_ow( 4, 2, array, 0, 0);
}
Listing 11.10 4-2-Permutationen ohne Wiederholungen
Wir erhalten das folgende Ergebnis:
4-2-Permutationen
ohne Wiederholungen
1: ( 0, 1)
2: ( 1, 0)
3: ( 0, 2)
4: ( 2, 0)
292
11.5
5:
6:
7:
8:
9:
10:
11:
12:
11.5
(
(
(
(
(
(
(
(
0,
3,
1,
2,
1,
3,
2,
3,
Beispiele
3)
0)
2)
1)
3)
1)
3)
2)
Beispiele
Da in unseren Programmierbeispielen bisher nur Permutationen vorgekommen
sind, wollen wir zum Abschluss dieses Kapitels zwei Beispiele mit Kombinationen
erstellen. Im ersten Beispiel betrachten wir Kombinationen ohne Wiederholungen
und im zweiten Beispiel Kombinationen mit Wiederholungen. In beiden Beispielen
wollen wie vorab abschätzen, wie viele Fälle untersucht werden müssen, um einen
Eindruck von der Laufzeit unserer Programme zu erhalten. Im zweiten Beispiel werden Sie dabei sehen, dass es nicht immer empfehlenswert ist, mit kombinatorischen
Algorithmen zu arbeiten.
11.5.1
Juwelenraub
Zwei Ganoven haben die Scheibe eines Juwelierladens eingeschlagen und in aller Eile
zehn Schmuckstücke zusammengerafft. Wieder zu Hause angekommen, streiten sie
sich um eine gerechte Verteilung der Beute. Zum Glück sind alle Beutestücke mit
einem Preisschild versehen, aber wie soll man eine Verteilung vornehmen, bei der
beide einen annähernd gleichen Anteil erhalten? Wir werden alle denkbaren Teilauswahlen mit einem, zwei, drei, vier oder fünf Beutestücken betrachten und jeweils den
Wert der Teilauswahl berechnen. Man entscheidet sich dann für die Teilauswahl,
deren Wert der halben Gesamtsumme am nächsten liegt. Teilauswahlen sind Kombinationen ohne Wiederholungen, da die Reihenfolge der Zuteilung keine Rolle spielt
und jedes Schmuckstück nur einmal zugeteilt werden kann. Teilauswahlen mit mehr
als fünf Beutestücken müssen nicht betrachtet werden, da dann ja die gegenteilige
Auswahl weniger als fünf Beutestücke hat und bereits in der Betrachtung enthalten
ist.
Bei einer n-k-Auswahl ohne Beachtung der Reihenfolge und ohne Wiederholungen
haben wir n über k mögliche Ergebnisse. Wir machen eine Aufstellung der Binomialkoeffizienten 10 über k für k zwischen 1 und 5:
293
11
11
Kombinatorik
10
k
( )
k Auswahlen
1
10
2
45
3
120
4
210
5
252
Summe:
637
Abbildung 11.11 Binomialkoeffizienten
»10 über k« für k zwischen 1 und 5
Insgesamt müssen also 637 Fälle betrachtet werden, und es ist nicht erkennbar, dass
man Fälle davon außer Betracht lassen kann.
Wir kommen zur Implementierung des Programms. Dazu legen wir einige globale
Variablen an:
A
double beute[10] = { 333.33, 655.99, 387.50, 1420.10, 4583.17,
7500.00, 215.12, 3230.17, 599.00, 3775.11};
B
double summe;
int anzahl;
int auswahl[10];
double teilsumme;
double abweichung;
Listing 11.11 Globale Variablen des Programms Juwelenraub
Hier sind die Preise der zehn Beutestücke (A) sowie eine Variable für den Gesamtwert
der Beute angelegt (B).
Die darauffolgenden vier Variablen beschreiben eine Teilauswahl der Beute:
왘
anzahl ist die Anzahl der ausgewählten Beutestücke.
왘
auswahl ist das Array mit den Indizes der ausgewählten Beutestücke.
왘
teilsumme ist der Wert der Teilauswahl.
왘
abweichung ist die Abweichung der Teilsumme von der Hälfte des Gesamtwerts.
Der Gesamtwert der Beute muss noch berechnet werden. Das machen wir in der
Funktion vorbereitung, in der wir auch die abweichung initialisieren und eine Übersicht über die Beute ausgeben:
294
11.5
Beispiele
void vorbereitung()
{
int i;
A
B
for( i = 0, summe = 0.0; i < 10; i++)
{
printf( "Beutestueck %2d: %10.2f Euro\n", i+1, beute[i]);
summe += beute[i];
}
printf( "Gesamtbeute:
%10.2f Euro\n\n", summe);
abweichung = summe + 1;
}
Listing 11.12 Die Hilfsfunktion vorbereitung
Die Funktion berechnet den Gesamtwert der Beute (A) und setzt die Abweichung auf
einen großen Anfangswert, damit beliebige Auswahlen diesen Wert später unterbieten (B).
Wenn wir die Funktion aus einem Hauptprogramm aufrufen, erhalten wir die folgende Ausgabe:
Beutestueck 1:
Beutestueck 2:
Beutestueck 3:
Beutestueck 4:
Beutestueck 5:
Beutestueck 6:
Beutestueck 7:
Beutestueck 8:
Beutestueck 9:
Beutestueck 10:
Gesamtbeute:
333.33
655.99
387.50
1420.10
4583.17
7500.00
215.12
3230.17
599.00
3775.11
22699.49
Euro
Euro
Euro
Euro
Euro
Euro
Euro
Euro
Euro
Euro
Euro
Für abweichung versuchen wir, ein Minimum zu finden. Das geht besonders einfach,
wenn Sie anfangs einen Wert nehmen, der größer als alle im Weiteren vorkommenden Werte ist. Sie ersparen sich dann die Abfrage, ob Sie schon einen gültigen Vergleichswert in der Variablen haben.
Wir wollen die Funktion komb_ow verwenden. Dort haben wir immer, wenn eine neue
Kombination erzeugt wurde, die Funktion print_array gerufen. Das interessiert uns
hier nicht. Wir wollen ja nicht jede mögliche Auswahl ausgeben, sondern vergleichen, bewerten und am Ende nur die beste Auswahl ausgeben. Dazu erstellen wir
eine Funktion mit der gleichen Schnittstelle wie print_array, die die Aufgabe hat,
295
11
11
Kombinatorik
eine Kombination zu bewerten und, wenn sie besser ist als die bisher beste, in den
bereitgestellten globalen Variablen zu sichern, damit sie am Ende des Programms für
die Ausgabe zur Verfügung steht.
A
B
C
D
void aufteilung( int k, int array[])
{
int i;
double teil;
double abw;
for( i = 0, teil = 0.0; i < k; i++)
teil += beute[array[i]];
abw = fabs( summe/2 – teil);
if( abw < abweichung)
{
abweichung = abw;
teilsumme = teil;
anzahl = k;
for( i = 0; i < k; i++)
auswahl[i] = array[i];
}
}
Listing 11.13 Die Funktion aufteilung
An der Schnittstelle von aufteilung wird eine Auswahl von k Beutestücken übergeben (A). Für die übergebene Auswahl der Beute wird dann der Wert berechnet (B). Die
Abweichung vom Optimum wird mit der Funktion für den Absolutbetrag fabs
berechnet (C). Falls sich das Ergebnis verbessert hat (D), wird die Auswahl gesichert.
Dazu wird u. a. das Array mit den Indizes umkopiert (D).
Bis auf den geänderten Unterprogrammaufruf können wir die Funktion komb_ow
unverändert übernehmen:
void komb_ow(int n, int k, int array[], int x, int min)
{
int i;
if( x < k)
{
for( i = min; i <= n-k+x; i++)
{
array[x] = i;
komb_ow( n, k, array, x+1, i+1);
296
11.5
A
Beispiele
}
}
else
aufteilung( k, array);
}
Listing 11.14 Kombination ohne Wiederholungen (leicht modifiziert)
Hier wird jetzt aufteilung anstelle von bisher print_array gerufen (A).
Im Hauptprogramm werden nach der Vorbereitung alle relevanten Kombinationen
erzeugt und getestet:
void main( )
{
int array[5];
int i;
A
11
vorbereitung();
for( i = 1; i <= 5; i++)
komb_ow( 10, i, array, 0, 0);
auswertung();
}
Listing 11.15 Hauptprogramm für den Juwelenraub
Dazu erzeugen wir in einer Schleife 10-1-, 10-2-, 10-3-, 10-4- und 10-5-Kombinationen (A).
Die beste Auswahl liegt nach dem Verlassen der Schleife in der globalen Datensicherung, aus der sie mit der Funktion auswertung ausgegeben wird. Diese Funktion muss
ich Ihnen der Vollständigkeit halber noch nachreichen:
void auswertung()
{
int i;
printf( "Der Komplize erhaelt:\n\n");
for( i = 0; i < anzahl; i++)
{
printf( "
Beutestueck %2d %10.2f Euro\n", auswahl[i]+1,
beute[auswahl[i]]);
}
297
11
Kombinatorik
printf( "\nTeilsumme
printf( "\nAbweichung
}
%10.2f Euro\n", teilsumme);
%10.2f Euro\n", abweichung);
Listing 11.16 Auswertung des gefundenen Ergebnisses
Bei Aufruf unseres kompletten Programms erhalten wir das folgende Ergebnis für
die Aufteilung:
Der Komplize erhaelt:
Beutestueck
Beutestueck
Beutestueck
Beutestueck
3
6
7
8
Teilsumme
387.50
7500.00
215.12
3230.17
Euro
Euro
Euro
Euro
11332.79 Euro
Abweichung
16.95 Euro
Es bleibt eine unvermeidliche Differenz, die aber durch Zahlung von 16,95 Euro ausgeglichen werden kann.
Die 637 Fälle, die hier zu betrachten waren, stellen keine besondere Herausforderung
für einen Computer dar. Aber beachten Sie, dass bei 100 Beutestücken allein für eine
50:50-Aufteilung
⎛ 100⎞ ≈ 10 29
⎝ 50 ⎠
Fälle zu betrachten wären.
11.5.2
Geldautomat
Wir wollen einen Geldautomaten, der intern unbegrenzt viele 5-, 10-, 20-, 50-, 100-,
200- und 500-Euro-Scheine vorhält, so programmieren, dass er einen Geldbetrag mit
bis zu 20, aber möglichst wenig Geldscheinen auszahlt. Wenn eine solche Auszahlung nicht möglich ist, soll eine entsprechende Meldung ausgegeben werden.
Eine Auszahlung ist eine Kombination der oben genannten sieben Banknoten mit
Wiederholungen. Wir können also, ähnlich wie im letzten Beispiel, der Reihe nach
alle 7-1-, 7-2-, 7-3-, ... 7-20-Kombinationen mit Wiederholungen erzeugen, bis wir eine
erste Lösung gefunden haben. Sobald wir die erste Lösung gefunden haben, können
wir das Verfahren abbrechen, da wir an weiteren Lösungen nicht interessiert sind.
Die Lösung, die wir als erste finden, kommt ja auch mit den wenigsten Geldscheinen
298
11.5
Beispiele
aus. Trotzdem kann es natürlich sein, dass alle Auswahlen durchsucht werden müssen. Was das bedeutet, sehen Sie in der Tabelle in Abbildung 11.12:
(
7+k–1
k
)
k
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Auswahlen
7
28
84
210
462
924
1716
3003
5005
8008
12376
18564
27132
38760
54264
74613
100947
134596
177100
230230
Summe:
888029
11
Abbildung 11.12 Übersicht über alle Auswahlmöglichkeiten
Trotz dieser beeindruckenden Zahl wollen wir das Problem mit einem kombinatorischen Algorithmus lösen. Wir stellen dazu ein globales Array mit den sieben verfügbaren Banknoten und eine globale Variable für den auszuzahlenden Betrag bereit:
int noten[7] = {500, 200, 100, 50, 20, 10, 5};
int betrag;
int pruefungen;
Listing 11.17 Globale Variablen für den Geldautomaten
Diese Daten sind global und können von allen Unterprogrammen genutzt werden.
Der Zähler pruefungen wird für die eigentliche Aufgabe des Programms nicht benötigt. Mithilfe dieses Zählers werden wir messen, wie aufwendig die Lösungssuche ist.
Als Nächstes erstellen wir eine Funktion, die prüft, ob eine an der Schnittstelle übergebene Auswahl von Notenwerten dem angeforderten Betrag entspricht:
299
11
Kombinatorik
A
int pruefe( int k, int array[])
{
int i;
int summe;
B
pruefungen++;
C
D
for( i = 0, summe = 0; i < k; i++)
summe += noten[array[i]];
E
return summe == betrag;
}
Listing 11.18 Prüfung auf den passenden Betrag
An der Schnittstelle der Funktion wird eine Auswahl von k Banknoten übergeben (A).
In dieser Funktion zählen wir, wie oft die Funktion aufgerufen wurde, um Informationen über das Laufzeitverhalten des Algorithmus zu gewinnen (B).
Zur eigentlichen Prüfung wird das Geld in der Auswahl gezählt (C), dazu erfolgt vom
Index in der Auswahl der Zugriff auf den Notenwert (D).
Falls die ermittelte Summe dem geforderten Betrag entspricht, gibt die Funktion eine
1 zurück, sonst 0 (E).
Zur Lösungssuche verwenden wir komp_mw in einer modifizierten Fassung. Ein
wesentlicher Unterschied zu den vorangegangenen kombinatorischen Programmen
ist nämlich, dass wir diesmal gar nicht an einer erschöpfenden Suche interessiert
sind, sondern mit der Suche aufhören wollen, sobald die erste Lösung gefunden ist.
Das heißt, dass wir den Rückgabewert des Unterprogramms pruefe nutzen wollen,
um die Suche abzubrechen:
int komb_mw(int n, int k, int array[], int x, int min)
{
int i;
if( x < k)
{
for( i = min; i < n; i++)
{
array[x] = i;
300
11.5
A
B
C
Beispiele
if( komb_mw( n, k, array, x+1, i))
return 1;
}
return 0;
}
else
return pruefe( k, array);
}
Listing 11.19 Kombination mit Wiederholungen (leicht modifiziert)
Die modifizierte Version bricht mit einer Erfolgsmeldung ab, wenn in der Rekursion
eine Lösung gefunden wird (A), ansonsten wird weitergesucht.
Wenn sich die Auswahl auf dieser Aufrufebene nicht zu einer Lösung fortsetzen lässt,
wird gegebenenfalls auf höheren Aufrufebenen weitergesucht (B).
Wenn eine vollständige Auswahl erzeugt wurde, dann wird das Ergebnis der Prüfung
zurückgegeben (C).
Im Hauptprogramm fügen wir alle Puzzlesteine zusammen und erhalten einen funktionierenden Geldautomaten:
void main()
{
int array[20];
int k, i;
for( ; ;)
{
printf( "Betrag: ");
scanf( "%d", &betrag);
A
if( betrag <= 0)
break;
B
pruefungen = 0;
C
for( k = 1; k <= 20; k++)
{
if( komb_mw( 7, k, array, 0, 0))
{
printf( "Auszahlung: ");
301
11
11
Kombinatorik
for( i = 0; i < k; i++)
printf( "%d ", noten[array[i]]);
printf( "\n");
break;
}
D
E
}
if( k > 20)
printf( "Keine Auszahlung moeglich\n");
printf( "Es wurden %d Pruefungen durchgefuehrt\n\n", pruefungen);
}
F
}
Listing 11.20 Hauptprogramm für den Geldautomaten
Im Hauptprogramm erfragen wir den abzuhebenden Betrag und brechen ab, wenn
ein Betrag ≤ 0 eingegeben wurde (A). Mit jedem neuen Betrag wird der Zähler der Prüfungen zurückgesetzt (B). Danach wird die Suche nach Lösungen mit 1, 2, 3, ... 20 Geldscheinen gestartet (C). Falls die Suche erfolgreich abgeschlossen werden konnte, wird
in (D) die Notenstückelung ausgegeben und die Suche für den Betrag beendet (E).
Wenn keine Lösung gefunden wurde, erfolgt ebenfalls eine Ausgabe. Wir testen dieses Programm für einige Fälle:
Betrag: 885
Auszahlung: 500 200 100 50 20 10 5
Es wurden 2353 Pruefungen durchgefuehrt
Betrag: 1235
Auszahlung: 500 500 200 20 10 5
Es wurden 926 Pruefungen durchgefuehrt
Betrag: 500
Auszahlung: 500
Es wurden 1 Pruefungen durchgefuehrt
Betrag: 1
Keine Auszahlung moeglich
Es wurden 888029 Pruefungen durchgefuehrt
Das Ausführungsprotokoll zeigt, dass beim Versuch, 1 Euro auszuzahlen, tatsächlich
888029 Prüfungen durchgeführt werden.
Dass eine so einfache Aufgabe einen so großen Rechenaufwand verursacht, lässt uns
natürlich keine Ruhe, und wir denken über Alternativen nach. Wie würden wir denn
302
11.5
Beispiele
eine Auszahlung vornehmen, wenn wir am Kassenschalter sitzen würden? Wir würden so lange 500-Euro-Scheine ausgeben, wie der Auszahlungsbetrag nicht überschritten würde. Dann würden wir so lange 200-Euro-Scheine auszahlen, wie der
noch fehlende Betrag nicht überschritten würde. Dann ginge es mit 100-Euro-Scheinen weiter. Das würden wir so lange machen, bis entweder der Betrag vollständig
ausgezahlt wäre oder ein Rest (1, 2, 3 oder 4 Euro) bleiben würde, den wir nicht zahlen
könnten.
Das können Sie sofort programmieren:
A
B
void auszahlung( int betrag)
{
int noten[7] = { 500, 200, 100, 50, 20, 10, 5};
int n;
int rest;
11
printf( "Auszahlung: ");
C
D
E
F
for( n = 0, rest = betrag; rest >= 5; )
{
if( noten[n] <= rest)
{
printf( "%d ", noten[n]);
rest -= noten[n];
}
else
n++;
}
printf( "\n");
if( rest)
printf( "Rest %d Euro kann nicht ausgezahlt werden\n", rest);
}
Listing 11.21 Die Funktion auszahlung
Die Funktion erhält an ihrer Schnittstelle den Auszahlungsbetrag übergeben (A).
Innerhalb der Funktion sind die Notenwerte definiert (B). Die Auszahlung startet mit
dem mit dem ersten (größten) Notenwert und fährt fort, solange Teile des Restbetrags noch ausgezahlt werden können (C). Innerhalb der Schleife wird geprüft, ob der
betrachtete Notenwert kleiner als der Restbetrag ist (D). Ist das der Fall, wird der Wert
ausgezahlt (E), ansonsten wird der nächstkleinere Notenwert betrachtet (F).
303
11
Kombinatorik
Diese Funktion findet die gleichen Auszahlungen wie der kombinatorische Algorithmus und macht dabei nur einige wenige Schleifendurchläufe.
Den hier verwendeten Algorithmus bezeichnet man als Greedy5-Algorithmus, weil er
sich immer den größten Happen schnappt und so versucht, das Problem dadurch
möglichst schnell zu lösen. Gier kann allerdings blind machen. Wenn Sie sich vorstellen, dass der Automat nur mit Noten ab 20 Euro bestückt wäre und 60 Euro auszuzahlen wären, dann würde der Algorithmus zunächst 50 Euro auszahlen und wäre
mit seiner Gier in eine Sackgasse gelaufen, obwohl er mit drei 20-Euro-Noten den
Betrag hätte auszahlen können.
5 engl. greedy = gierig, gefräßig
304
Kapitel 12
Leistungsanalyse und
Leistungsmessung
If people do not believe that mathematics is simple, it is only because
they do not realize how complicated life is.
– John von Neumann
Bisher haben wir zur Lösung spezieller Programmieraufgaben immer den erstbesten
Algorithmus genommen und implementiert. Dabei haben wir gesehen, dass es Algorithmen sehr unterschiedlicher Leistungsfähigkeit geben kann. Um dies noch einmal zu
vertiefen, wollen wir drei verschiedene Algorithmen für dieselbe Aufgabe formulieren
und bewerten. Wir wollen alle ganzzahligen, nicht-negativen Lösungen der Gleichung
x+y+z=n
bestimmen. Die Zahl n ist dabei beliebig, aber fest vorgegeben.
Beim ersten Lösungsansatz lassen wir die Variablen x, y und z im gesamten Suchraum (0 bis n) variieren und prüfen für jede Variablenkombination, ob eine Lösung
vorliegt. Wir erzeugen also durch drei ineinander geschachtelte Schleifen alle theoretisch denkbaren Möglichkeiten und filtern die korrekten Lösungen durch eine
Abfrage aus. Dann fragen wir uns, wie oft einzelne Zeilen in diesem Programm durchlaufen werden:
1
1
A
51
B
2601
C
132651
void gleichung1( int n)
{
int x, y, z;
for( x = 0; x <= n; x++)
{
for( y = 0; y <= n; y++)
{
for( z = 0; z <= n; z++)
{
if( x + y + z == n)
printf( "%d + %d + %d = %d\n", x, y, z, n);
305
12
12
Leistungsanalyse und Leistungsmessung
}
}
}
}
Listing 12.1 Durchläufe der einzelnen Zeilen
Für n = 50 ergeben sich konkrete Zahlenwerte, die angeben, wie oft eine bestimmte
Codezeile ausgeführt wurde. Diese habe ich in der linken Spalte dem Code vorangestellt. Für (A) gilt n + 1 = 51, für (B) gilt (n + 1)2 = 2601 und für (C) gilt (n + 1)3 = 132651.
Mehr als diese konkreten Zahlenwerte interessiert uns aber eine Formel, die aussagt,
wie viele Fälle allgemein betrachtet werden. Da sich mit jeder Schleife die Zahl der
untersuchten Fälle um den Faktor n + 1 vervielfacht, haben wir insgesamt (n + 1)3 Fälle
zu untersuchen, und unser Programm wird mit einem unbekannten Proportionalitätsfaktor c die Laufzeit
t(n) = c(n + 1)3
haben. Der konkrete Wert des Proportionalitätsfaktors interessiert uns nicht, zumal
dieser Faktor auf unterschiedlich schnellen Rechnern unterschiedlich ausfallen wird
und damit keine Kenngröße des Algorithmus ist.
Es ist Ihnen natürlich längst aufgefallen, dass in dem oben dargestellten Algorithmus
unnötige Fälle untersucht werden, da der Wert für z feststeht, sobald konkrete Werte
für x und y vorgegeben sind. Es kommt dann nur z = n-x-y infrage, um die geforderte
Gleichung zu erfüllen. Damit erweist sich die innere Schleife als überflüssig, und wir
können das Programm wie folgt vereinfachen:
1
1
A
51
B
2601
2601
1326
void gleichung2( int n)
{
int x, y, z;
for( x = 0; x <= n; x++)
{
for( y = 0; y <= n; y++)
{
z = n – x – y;
if( z >= 0)
printf( "%d + %d + %d = %d\n", x, y, z, n);
}
}
}
Listing 12.2 Durchläufe im vereinfachten Programm
306
Die Anzahl der betrachteten Fälle reduziert sich deutlich auf (n + 1)2 (B), und es ist
davon auszugehen, dass dieses Programm mit einer Laufzeit von t(n) = c(n + 1)2 bei
gleicher Funktionalität entsprechend schneller am Ziel ist.
Wenn Sie jetzt noch einmal genau hinschauen, werden Sie feststellen, dass es sinnlos
ist, y immer durch den gesamten Bereich von 0 bis n zu variieren, weil oberhalb von
y = n-x keine Lösungen für z mehr gefunden werden können. Wir können die Schleife
über y also bei Überschreitung des Werts n-x abbrechen. Da z = n-x-y in dieser Situation stets größer oder gleich 0 ist, ist dann die Abfrage z ≥ 0 nicht mehr erforderlich,
und wir können das Programm noch einmal vereinfachen:
11
1
A
51
B
1326
1326
void gleichung3( int n)
{
int x, y, z;
for( x = 0; x <= n; x++)
{
for( y = 0; y <= n-x; y++)
{
z = n – x – y;
printf( "%d + %d + %d = %d\n", x, y, z, n);
}
}
}
12
Listing 12.3 Durchläufe im weiter vereinfachten Programm
(n + 1)(n + 2)
Jetzt sind es in (A) n + 1 = 51 und in (B) ----------------------------------- = 1326 Durchläufe.
2
Bei gegebenem x gibt es für y jetzt nur noch n-x+1 verschiedene Möglichkeiten. Insgesamt ergibt sich damit1:
x
Möglichkeiten
für y
0
n+1
1
n
…
…
n-1
2
n
1
Summe:
(n + 2) (n + 1)
2
Abbildung 12.1 Anzahl der Möglichkeiten
k(k + 1)
1 Sie erinnern sich: 1 + 2 + ... k = -------------------- .
2
307
12
Leistungsanalyse und Leistungsmessung
Die Anzahl der zu betrachtenden Fälle halbiert sich etwa bei Verwendung dieses Programms, sodass wir nochmals eine deutliche Verringerung der Laufzeit erwarten
können. Die vergleichende Grafik in Abbildung 12.2 zeigt ein sehr unterschiedliches
Laufzeitverhalten der drei Programme:
2500
2000
(n + 1)3
1500
1000
500
0
1
2
3
4
(n + 1)2
5
6
7
8
9
10
11
12
(n + 2)(n + 1)
2
Abbildung 12.2 Laufzeitverhalten der drei Programme
Auffallend ist, dass das erste Programm für »große« Werte von n deutlich aus dem
Rahmen fällt. Hier scheinen wir es mit verschiedenen »Leistungsklassen« zu tun zu
haben, während sich die beiden letzten Programme trotz des Leistungsunterschieds
in etwa gleich zu entwickeln scheinen. Diese Beobachtung wollen wir im Rahmen
dieses Kapitels auf eine saubere Grundlage stellen.
12.1
Leistungsanalyse
Die theoretische Analyse von Algorithmen ist ein anspruchsvolles Feld. Nur in einfach gelagerten Fällen können Sie einen Algorithmus vollständig rechnerisch in den
Griff bekommen. Im Regelfall werden Sie unbedeutende Beiträge zur Laufzeit eines
Programms unter den Tisch fallenlassen und sich mit den Teilen beschäftigen, die
einen substanziellen Beitrag zur Gesamtlaufzeit des Programms leisten. Dazu müssen Sie zunächst einmal lernen, die wesentlichen Teile, die die Laufzeit prägen, zu
identifizieren. Sinnvollerweise orientieren Sie sich dabei an den Bausteinen von Programmen. Diese sind:
308
12.1
왘
Blöcke
왘
Fallunterscheidungen
왘
Schleifen
왘
Unterprogramme
Leistungsanalyse
Wir betrachten ein einfaches Beispiel, an dem wir eine komplette Analyse durchführen wollen.
Das Beispiel basiert auf drei Unterprogrammen konstanter Laufzeit:
void upr1()
{
int i;
for( i = 0; i < 500; i++)
machwas();
}
12
void upr2()
{
int i;
for( i = 0; i < 50; i++)
machwas();
}
void upr3()
{
machwas();
}
void machwas()
{
int i;
int a, b, c;
a = b = c = 0;
for( i = 0; i < 300000; i++)
{
a = b;
b = c;
Dieses Programm hat keinen Sinn, es
c = a;
soll nur Rechenzeit verbrauchen.
}
}
Abbildung 12.3 Die drei Unterprogramme konstanter Laufzeit
309
12
Leistungsanalyse und Leistungsmessung
Die drei Unterprogramme haben einzig und allein die Aufgabe, Laufzeit zu produzieren. Wir vermuten, dass upr2 die 50-fache und upr1 die 500-fache Laufzeit von upr3
hat. Die effektiven Laufzeiten werden wir später messen, sie sind uns nicht bekannt.
Der eigentliche Algorithmus, für dessen Laufzeitverhalten wir uns interessieren, ist
durch das Unterprogramm test gegeben.
void test(int n, int m)
{
int i1, i2, i3;
for( i1 = 0; i1 < n; i1++)
{
upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
upr2();
else
for( i3 = 0; i3 < i2; i3++)
upr3();
}
}
}
Listing 12.4 Das eigentliche Unterprogramm test
Dieses Programm macht nichts Sinnvolles. Wir interessieren uns nur für die Laufzeit
des Programms, die von den Parametern n und m abhängt. Um die Laufzeit in den Griff
zu bekommen, zerlegen wir den Algorithmus in seine Bestandteile. Wir verwenden
dazu eine grafische Notation, die unmittelbar einsichtig ist.
test
for
{}
upr1
for
if
upr2
for
upr3
Abbildung 12.4 Der Strukturbaum des Programms
310
12.1
Leistungsanalyse
An den Blättern des Baums finden Sie die Unterprogramme upr1 bis upr3, die wir
nicht weiter zerlegt haben, da sie eine konstante Laufzeit haben. Diese Laufzeiten
werden jetzt im Baum über die Knoten nach oben propagiert, bis wir am Ende an der
Wurzel die Laufzeit des gesamten Programms erhalten. Je nach Sprachkonstrukt
erfolgt an den Knoten natürlich eine andere Art der Propagierung von Laufzeiten.
Das werden wir jetzt im Detail diskutieren.
Wir erstellen zunächst eine detailreichere Grafik (siehe Abbildung 12.5), in der auch
schon die Laufzeiten der drei Unterprogramme als unbekannte Konstanten t1, t2 und
t3 eingetragen sind.
void test( int m, int n)
for( i1 = 0; i1 < n; i1++)
12
{ … }
upr1()
for( i2 = 0; i2 < 2*m; i2++)
t1
if( i2%2)
upr2()
for( i3 = 0; i3 < i2; i3++)
t2
upr3()
t3
Abbildung 12.5 Strukturbaum mit Laufzeiten der Unterprogramme
Wir beginnen mit der Bottom-up-Analyse unseres Programms. Zunächst propagieren wir die Laufzeit des Unterprogramms upr3 in die übergeordnete Schleife. Dazu
erinnern wir uns an die Kontrollflusssteuerung innerhalb einer Schleife:
311
12
Leistungsanalyse und Leistungsmessung
Initialisierung
Inkrement
Test
nein
ja
Schleifenkörper
Abbildung 12.6 Kontrollflusssteuerung innerhalb der Schleife
Alle Teile tragen zur Gesamtlaufzeit einer Schleife bei. Im Allgemeinen kann man
daher nicht einfach etwas weglassen. Es ist durchaus denkbar, dass etwa in der Initialisierung einer Schleife eine sehr rechenintensive Prozedur gerufen wird und der
Aufwand zur Initialisierung der Schleife alle anderen Aufwände deutlich übersteigt.
Wir versuchen daher eine vollständige Bilanz:
Gegeben sei eine Schleife der Form:
for( init; test; incr)
body
Außerdem sei:
tinit
die Laufzeit der Initialisierung
ttest (k)
die Laufzeit des Tests vor dem k-ten Schleifendurchlauf
tbody (k) die Laufzeit des Schleifenkörpers im k-ten Durchlauf
tincr (k)
die Laufzeit des Inkrements am Ende des k-ten Durchlaufs
Dann berechnet sich die Laufzeit der Schleife nach n Durchläufen wie folgt:
t(n) = tinit + ttest (1)
+ tbody (1) + tincr (1) + ttest (2)
+ tbody (2) + tincr (2) + ttest (3)
...
+ tbody (n) + tincr (n) + ttest (n + 1)
312
12.1
Leistungsanalyse
Diese Formel ist sehr unhandlich und führt, wenn sie in dieser Form im Strukturbaum eines Programms propagiert wird, zu nicht mehr handhabbaren Ausdrücken.
Unter gewissen zusätzlichen Annahmen lässt sich die Formel erheblich vereinfachen.
Wenn die Laufzeiten zur Schleifensteuerung eine im Vergleich zum Schleifenkörper
zu vernachlässigende Größenordnung haben, kann die Formel wie folgt vereinfacht
werden:
t(n) = tbody (1) + tbody (2) + ... + tbody (n)
Gibt es zusätzlich eine gemeinsame obere Schranke tmax für die Laufzeit des Schleifenkörpers, ist:
t(n) ≤ ntmax
Ist die Laufzeit des Schleifenkörpers sogar unabhängig vom einzelnen Schleifendurchlauf, ergibt sich:
t(n) = ntbody
In unserem Beispiel sind in der inneren Schleife die Bedingungen zur Vereinfachung
gegeben. Wir können daher wie folgt propagieren:
Die Laufzeiten zur Schleifensteuerung
können vernachlässigt werden.
for( i3 = 0; i3 < i2; i3++)
Es finden i2 Schleifendurchläufe statt.
i2t3
Die Laufzeit pro Schleifendurchlauf ist t3.
upr3()
t3
Die Laufzeit des Schleifenkörpers hängt
nicht vom Schleifendurchlauf ab.
Abbildung 12.7 Propagierung der Ergebnisse
Dieses Ergebnis fließt nun zusammen mit der Laufzeit von upr2 in eine Fallunterscheidung ein.
Zur vollständigen Bilanzierung einer Fallunterscheidung müssen der Test und die
beiden Alternativen berücksichtigt werden:
313
12
12
Leistungsanalyse und Leistungsmessung
Alternative1
ja
Bedingung
nein
Alternative2
Abbildung 12.8 Fallunterscheidung
Gegeben sei eine Fallunterscheidung der Form:
if( bedingung)
alternative1
else
alternative2
Außerdem sei:
tbed
die Laufzeit zur Überprüfung der Bedingung
talt1
die Laufzeit der Alternative1
talt2
die Laufzeit der Alternative2
Dann berechnet sich die Laufzeit der Fallunterscheidung wie folgt:
⎧ t alt1
t = t bed + ⎨
⎩ t alt2
falls die Bedingung erfüllt ist
falls die Bedingung nicht erfüllt ist
Kann die Laufzeit zur Überprüfung der Bedingung im Vergleich zur Laufzeit der
Alternativen vernachlässigt werden, vereinfacht sich die Formel zu:
⎧ t alt1
t = ⎨
⎩ t alt2
falls die Bedingung erfüllt ist
falls die Bedingung nicht erfüllt ist
In unserem konkreten Beispiel ergibt sich:
314
12.1
Leistungsanalyse
if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2
upr2()
for( i3 = 0; i3 < i2; i3++)
t2
i2t3
upr3()
t3
Abbildung 12.9 Aktualisiertes Ergebnis
12
Die in der Formel vorkommende Fallunterscheidung macht die Formel allerdings
unhandlich, da bei der weiteren Berechnung jetzt immer zwei Fälle zu betrachten
sind. In der Regel versucht man daher, eine solche Variantenbildung so früh wie
möglich zu unterbinden. Dazu betrachten wir zwei Möglichkeiten. Zunächst versuchen wir es mit einer gemeinsamen Abschätzung beider Alternativen:
Wenn es eine gemeinsame obere Schranke tmax für die Laufzeiten der beiden Alternativen gibt, kann man die Laufzeit der Fallunterscheidung abschätzen:
t ≤ tbed + tmax
Als obere Schranke ist die Laufzeitsumme der beiden Alternativen geeignet:
t ≤ tbed + talt1 + talt2
Auch hier kann tbed weggelassen werden, wenn die Laufzeit zur Prüfung der Bedingung im Vergleich zu den anderen Laufzeiten klein ist.
In unserem Beispiel können wir wie folgt abschätzen:
t ≤ t 2 + i 2t 3
Beachten Sie hier aber zweierlei:
1. Bei den einzelnen Laufzeiten handelt es sich im Allgemeinen um Funktionen, die
noch von außen liegenden Variablen abhängen. Es geht hier also nicht darum, einfach nur den größeren zweier Werte zu bestimmen, sondern eine Funktion zu fin-
315
12
Leistungsanalyse und Leistungsmessung
den, die (möglichst knapp) oberhalb der Funktionen für die Alternativen verläuft
und möglichst einfach ist. Eine solche Funktion ist nicht immer leicht zu finden.
2. Unter Umständen kommen Sie bei einer Abschätzung durch die Einbeziehung seltener, aber rechenintensiver Sonderfälle zu sehr ungünstigen Werten, die die
wirkliche Leistungsfähigkeit des Algorithmus nicht mehr wiedergeben.
Im zweiten Fall kann eine Betrachtung der Wahrscheinlichkeit, mit der die Alternativen eintreten, hilfreich sein.
Gibt es Informationen darüber, mit welcher Wahrscheinlichkeit p (0 ≤ p ≤ 1) die
Bedingung in der Fallunterscheidung wahr wird, lässt sich die mittlere Laufzeit wie
folgt ermitteln:
t = tbed + ptalt1 + (1 – p) talt2
Wie üblich kann die Laufzeit zur Prüfung der Bedingung weggelassen werden, wenn
sie durch die anderen Terme dominiert wird.
In diesem Fall liefert die Formel allerdings keine Aussage mehr über die maximal zu
erwartende Laufzeit, sondern über die durchschnittlich zu erwartende Laufzeit. An
diesem Ergebnis ist man aber häufig genauso stark interessiert wie an der maximalen Laufzeit, weil es etwas über das Verhalten eines Programms in typischen Lastsituationen aussagt.
Da in unserem Beispiel die Fallunterscheidung gleich häufig mit geraden und ungeraden Werten für i2 gerufen wird, können wir t wie folgt berechnen:
1
1
t = -- t 2 + -- i 2 t 3
2
2
Wir wollen in unserem Beispiel aber mit der exakten Formel
⎧ t2
⎨
⎩ i2 t3
falls i 2 ungerade ist
falls i 2 gerade ist
weiterrechnen, da wir in der Lage sind, die Fallunterscheidung auf der nächsten
Ebene wieder zu eliminieren, wenn wir in der übergeordneten Schleife zwischen
geraden und ungeraden Werten der Schleifenvariablen unterscheiden:
316
12.1
Leistungsanalyse
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
i2 = 1, 3, 5, … , 2m – 1
m
Laufzeit: t2
Laufzeit: mt2
else
Laufzeit: i2t3
}
i2 = 0, 2, 4, … , 2(m – 1)
Laufzeit:
(2 + 4 + … + 2(m – 1))t3 = 2(1 + 2 + … + m – 1)t3 = 2
m(m – 1)
t3 = m(m – 1)t3
2
Abbildung 12.10 Unterscheidung zwischen den Schleifenwerten
Die Laufzeit der Schleife ergibt sich als Summe der Laufzeiten für die geraden und
ungeraden Werte der Schleifenvariablen. Diesen Wert tragen Sie in den Strukturbaum des Programms ein:
12
for( i2 = 0; i2 < 2*m; i2++)
mt2 + m(m – 1)t3
if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2
upr2()
t2
for( i3 = 0; i3 < i2; i3++)
i2t3
upr3()
t3
Abbildung 12.11 Weiter aktualisierter Strukturbaum
Auf der nächsten Ebene finden Sie einen Block. Die Berechnungsvorschrift für einen
Block ist ganz einfach. Die Laufzeit in einem Block ist gleich der Summe aller Laufzeiten der einzelnen Anweisungen:
317
12
Leistungsanalyse und Leistungsmessung
Gegeben sei ein Block der Form:
{
anweisung_1
anweisung_2
...
anweisung_n
}
Außerdem sei:
tk die Laufzeit der k-ten Anweisung im Block.
Dann berechnet sich die Gesamtlaufzeit des Blocks nach der Formel:
t = t1 + t2 + ... + tn
Dominierte Terme können weggelassen werden.
Blöcke können auch einen Eigenanteil am Berechnungsaufwand (z. B. für das Anlegen lokaler Variablen) haben. Diesen Aufwand können Sie jedoch in der Regel vernachlässigen.
In unserem Beispiel enthält der Block zwei Anweisungen, und wir propagieren mit
der Summe:
{ … }
t1 + mt2 + m(m – 1)t3
upr1()
for( i2 = 0; i2 < 2*m; i2++)
t1
mt2 + m(m – 1)t3
if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2
upr2()
for( i3 = 0; i3 < i2; i3++)
t2
i2 t3
upr3()
t3
Abbildung 12.12 Weitere Propagierung der Summen
318
12.1
Leistungsanalyse
Der Block gehört zu einer Schleife. Die Laufzeit des Blocks hängt aber nicht vom Schleifendurchlauf ab. Wir können unser bisheriges Ergebnis daher einfach mit der Anzahl
der Schleifendurchläufe multiplizieren. Schließlich werden alle Anweisungen des Algorithmus zu einem Unterprogramm zusammengefasst. Auch hier fällt durch die Laufzeitkosten für den Unterprogrammaufruf noch einmal ein Eigenanteil an. Aber auch
diese Kosten können Sie vernachlässigen. Wir erhalten letztlich an der Wurzel unseres
Strukturbaums die Gesamtkosten für den Algorithmus (siehe Abbildung 12.13).
void test( int m, int n)
t(n,m) = n(t1 + mt2 + m(m – 1)t3)
for( i1 = 0; i1 < n; i1++)
n(t1 + mt2 + m(m – 1)t3)
{ … }
12
t1 + mt2 + m(m – 1)t3
upr1()
for( i2 = 0; i2 < 2*m; i2++)
mt2 + m(m – 1)t3
t1
if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2
upr2()
for( i3 = 0; i3 < i2; i3++)
t2
i2 t 3
upr3()
t3
Abbildung 12.13 Vollständiger Strukturbaum
Da wir zusätzlich wissen, dass t1 = 500t3 und t2 = 50t3 ist, haben wir die Laufzeit unseres Programms bis auf einen Proportionalitätsfaktor c vollständig im Griff:
t(n,m) = cn(m2 + 49m + 500)
Den Proportionalitätsfaktor können wir nur durch konkrete Messungen ermitteln.
Er gilt dann aber auch nur für den Rechner, auf dem die Messung durchgeführt
wurde.
319
12
Leistungsanalyse und Leistungsmessung
Viel interessanter ist aber die Frage, welchen Einfluss die drei Unterprogramme2 auf
die Gesamtlaufzeit des Programms haben. Dazu betrachten wir noch einmal die
ursprüngliche Formel:
t(n,m) = n(t1 + mt2 + m(m – 1)t3)
Wie Sie sehen, haben bezüglich n alle drei Programme das gleiche Gewicht. Bezüglich
m spielt das dritte Unterprogramm, obwohl es nur einen Bruchteil der Laufzeit des
ersten hat, eine bedeutend gewichtigere Rolle. Wenn m z. B. den Wert 1000 hat, geht
das erste Unterprogramm einfach, das zweite tausendfach und das dritte nahezu millionenfach in die Laufzeitbilanz ein. Dies zeigt, dass man sich bei einer Optimierung
des Algorithmus in erster Linie auf das dritte Unterprogramm konzentrieren sollte.
Sie sehen, dass uns die Laufzeitformel viel über das Programm verrät, was wir bei bloßer Betrachtung des Codes vielleicht nicht erkannt hätten. Wenn Sie lernen wollen,
effizient zu programmieren, ist es daher unerlässlich, sich mit der Mathematik hinter
den Programmen zu beschäftigen.
Im nächsten Abschnitt wollen wir feststellen, ob unsere theoretischen Vorüberlegungen auch einer praktischen Überprüfung standhalten. Danach werden wir die
mathematischen Aspekte der Programmierung noch etwas vertiefen.
12.2
Leistungsmessung
Eine Messung oder eine Messreihe ist in der Regel viel einfacher durchzuführen als die
mathematische Analyse eines Programms. Man muss sich allerdings fragen, was eine
Messung oder auch viele Messungen über die Laufzeitfunktion eines Programms aussagen. Ohne zusätzliche Informationen sagen einzelne Messwerte so viel – oder besser
gesagt: so wenig – aus wie einzelne Punkte über den Verlauf einer Kurve:
Messwerte
Abbildung 12.14 Einzelne Messwerte passen auf unterschiedliche Kurven.
2 Stellen Sie sich an dieser Stelle vor, dass die drei Programme nichts miteinander zu tun hätten.
320
12.2
Leistungsmessung
Aus Abbildung 12.14 geht hervor, dass einzelne Messungen eigentlich nichts über den
weiteren Verlauf einer Laufzeitfunktion jenseits der Messpunkte aussagen. Auch die
Hinzunahme weiterer Messpunkte führt nicht zu einer endgültigen Sicherheit, wie
sie eine theoretische Analyse liefert. Da aber eine vollständige theoretische Analyse
von Algorithmen oft unmöglich ist, muss man zur Beurteilung der Leistungsfähigkeit von Algorithmen letztlich doch auf praktische Messungen zurückgreifen. Parallel zu den Messungen sollte man sich aber stets anhand theoretischer Überlegungen
darüber klar werden, inwieweit die Messergebnisse plausibel und verallgemeinerungsfähig sind.
Für die folgenden Messungen legen wir das bereits ausführlich diskutierte Testprogramm mit
t(n,m) = n(t1 + mt2 + m(m – 1)t3)
zugrunde:
void test(int n, int m)
{
int i1, i2, i3;
12
for( i1 = 0; i1 < n; i1++)
{
upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
upr2();
else
for( i3 = 0; i3 < i2; i3++)
upr3();
}
}
}
t(n,m) = n(t1 + mt2 + m(m – 1)t3)
Abbildung 12.15 Das bereits bekannte Testprogramm
Bei der Messung eines Programms interessieren uns vorrangig zwei Gesichtspunkte:
왘
Wie oft werden Teile des Programms durchlaufen?
왘
Wie viel Rechenzeit wird für Teile des Programms benötigt?
321
12
Leistungsanalyse und Leistungsmessung
Um Antworten auf diese Fragen zu erhalten, müssen Messungen am laufenden Programm durchgeführt werden. Dazu besteht natürlich die Möglichkeit, die Programme durch einen Testrahmen zu erweitern. Durch spezielle, geschickt platzierte
Zähler kann man ermitteln, wie oft ein bestimmter Messpunkt angelaufen wird.
Durch Aufruf von Timerfunktionen des C-Laufzeitsystems kann die abgelaufene Zeit
an bestimmten Kontrollpunkten gemessen und kumuliert werden. Dies bedeutet
aber, dass der Programmcode für Test- und Messzwecke verändert werden muss. Dieser Eingriff in das Programm verfälscht die eigentlichen Messergebnisse. Wir messen
ja nicht das Programm, sondern wir messen das Programm im Messrahmen, also das
in Messung befindliche Programm. Für spezielle Tests müsste der Messrahmen verändert werden. Es stellt sich die Frage, inwieweit dann verschiedene Messungen noch
vergleichbar sind. Grundsätzlich sind solche Messungen unbefriedigend und sollten
nur dann genutzt werden, wenn keine anderen Möglichkeiten zur Verfügung stehen.
In der Regel finden Sie heute in jeder Softwareentwicklungsumgebung Werkzeuge
zur Programmanalyse3 – sogenannte Profiler. Diese Werkzeuge erfordern keinen Eingriff in den Programmcode und sind daher »handgestrickten« Testrahmen vorzuziehen. Prinzipiell unterscheiden wir zwei Arten der Analyse:
왘
die Überdeckungsanalyse
왘
die Performance-Analyse
Im ersten Fall wird ermittelt, wie oft gewisse Programmteile durchlaufen werden,
während im zweiten Fall die Laufzeit gewisser Programmteile gemessen wird.
12.2.1
Überdeckungsanalyse
Für konkrete Messungen müssen wir mit konkreten Werten für die Parameter n und
m unseres Programms arbeiten. Wir setzen mehr oder weniger willkürlich n = 17 und
m = 13. Entsprechend unseren Vorüberlegungen erwarten wir in dieser Situation:
왘
n = 17 Aufrufe von upr1
왘
n · m = 17 ·13 = 221 Aufrufe von upr2
왘
n · m · (m – 1) = 17 · 13 · 12 = 2652 Aufrufe von upr3
Die Überdeckungsanalyse bestätigt das zuvor theoretisch hergeleitete Ergebnis:
3 Ich möchte Ihnen hier kein konkretes Werkzeug vorstellen, da Sie je nach Entwicklungsumgebung andere Werkzeuge vorfinden.
322
12.2
1
17
442
221
221
2652
Leistungsmessung
void test(int n, int m)
{
int i1, i2, i3;
for( i1 = 0; i1 < n; i1++)
{
upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
upr2();
else
for( i3 = 0; i3 < i2; i3++)
upr3();
}
}
}
Abbildung 12.16 Ergebnisse der Überdeckungsanalyse
12
Überdeckungsanalysen sind übrigens nicht nur für Laufzeituntersuchungen von
Bedeutung. Solche Analysen sind wichtige Hilfsmittel für Tests und die Qualitätssicherung von Programmen. Beim Test von Programmen geht es ja darum, Testdaten
so zu wählen, dass alle Teile eines Programms auch wirklich getestet werden. Zur
Feststellung des Überdeckungsgrades eines Tests werden dann die hier diskutierten
Werkzeuge eingesetzt.
12.2.2
Performance-Analyse
In diesem Abschnitt wollen wir konkrete Laufzeitmessungen an unserem Programm
durchführen. Dazu analysieren wir das Programm mit einem Werkzeug zur Performance-Analyse, um sogenannte Laufzeitprofile zu erstellen. Die Tabelle4 in Abbildung 12.17 zeigt die Messergebnisse für zehn Messungen mit unterschiedlichen
Werten für n und m.
Die Messungen bestätigen unsere Annahmen über das Verhältnis der Laufzeiten der
Unterprogramme und zeigen, dass wir die Gesamtlaufzeit des Programms sehr präzise vorhersagen können, sofern uns die Laufzeiten der drei Unterprogramme
bekannt sind. Die Vorhersagen sind für große Werte von m weniger präzise, was aber
zu erwarten war, da dann ja Ungenauigkeiten bei der Messung von upr3 mit einem
relativ großen Faktor multipliziert werden.
4 Alle Zeitangaben sind in Millisekunden.
323
12
Leistungsanalyse und Leistungsmessung
n
m
upr1
Aufrufe
5
Zeit
511,70
upr2
Aufrufe
60
upr3
Zeit
51,15
Aufrufe
660
Zeit
1,01
Laufzeit
Laufzeit
gerechnet
gemessen
6294,10
6293,26
Abweichung
12
5
0,01%
17
13
17
509,89
221
50,98
2652
1,00
22586,71
22598,55
0,05%
7
33
7
512,04
231
50,93
7392
1,01
22815,03
22780,88
0,15%
0,02%
9
9
9
511,23
81
50,90
648
1,00
9371,97
9373,75
12
21
12
511,01
252
51,04
5040
1,01
24084,60
24066,44
0,08%
7
2
7
511,74
14
51,40
14
1,02
4316,06
4316,07
0,00%
14
10
14
510,31
140
51,13
1260
1,01
15575,14
15571,09
0,03%
19
3
19
511,04
57
51,17
114
1,01
12741,59
12741,02
0,00%
6
13
6
509,72
78
50,91
936
1,00
7965,30
7969,45
0,05%
5
18
5
509,85
90
51,00
1530
1,01
8684,55
8679,61
0,06%
Abbildung 12.17 Ergebnisse der Performance-Analyse
Wenn wir die Laufzeitformel
t(n, m) = n(t1 + mt2 + m(m – 1)t3)
noch einmal betrachten, stellen wir fest, dass m quadratisch in die Formel eingeht,
während n nur linear vorkommt. Dies bedeutet, dass sich große Werte von m erheblich stärker in der Laufzeit des Algorithmus niederschlagen als entsprechende Werte
von n. Die Laufzeiten der Unterprogramme haben nur einen untergeordneten Einfluss auf diesen Effekt. Egal, wie klein man t3 auch wählt, um den Einfluss von m zu
verringern, für hinreichend große Werte wird sich m immer als die die Laufzeit dominierende Einflussgröße durchsetzen. Solche Überlegungen zum sogenannten asymptotischen Laufzeitverhalten werden wir im Folgenden vertiefen.
12.3
Laufzeitklassen
Im letzten Abschnitt war es uns gelungen, eine vollständige Laufzeitanalyse eines
Programms durchzuführen. Wir haben aber erkennen müssen, dass eine vollständige theoretische Durchdringung eines komplexen Algorithmus mit den bisher
bereitgestellten Mitteln wohl kaum möglich ist. Für viele Zwecke ist eine solche Formel auch zu konkret und enthält noch zu viele unnötige Detailinformationen über
den Aufbau des Algorithmus. Wir möchten Algorithmen auf einer abstrakteren
Ebene miteinander vergleichen. Dazu benötigen wir ein Maß für die Leistungsfähigkeit eines Algorithmus, das uns einfache Klassifizierungen ermöglicht. Ein solches
Maß wollen wir jetzt entwickeln.
Für die weiteren Überlegungen dieses Abschnitts setze ich voraus, dass Sie einige
wichtige mathematische Grundfunktionen und Formeln beherrschen. Im Einzelnen
handelt es sich um:
324
12.3
왘
Potenzfunktionen (1, n, n2, n3, ...)
왘
Wurzelfunktionen ( 2 n , 3 n , 4 n , …)
왘
Exponentialfunktionen (2n, 3n, 4n, …)
왘
Logarithmen (log2(n), log3(n), log4(n), ...)
Laufzeitklassen
Außerdem benötigen wir im Laufe des Abschnitts folgende Formeln, die Sie in jeder
mathematischen Formelsammlung finden:
n(n + 1)
왘 1 + 2 + 3 + … + n = -------------------- (Summe der ersten n Zahlen)
2
왘
1 + 3 + 5 + 7 + … + 2n – 1 = n2 (Summe der ersten n ungeraden Zahlen)
왘
n ( n + 1 ) ( 2n + 1 )
1 + 22 + 32 + … + n2 = ----------------------------------------- (Summe der ersten n Quadratzahlen)
6
왘
–1
q
q0 + q1 + q2 + … + qn = --------------------- für q ≠ 1 (Summenformel der geometrischen Reihe)
q–1
n+1
Die Formeln sind wichtig, weil sie in ganz natürlicher Weise bei der Ermittlung von
Laufzeitfunktionen immer wieder benötigt werden, und die Funktionen sind wichtig, weil sie häufig als Laufzeitfunktionen von Algorithmen auftreten. Dass Potenzfunktionen und Exponentialfunktionen als Laufzeitfunktionen vorkommen, haben
Sie bereits an vielen Beispielen gesehen. Logarithmen und Wurzelfunktionen können aber ebenfalls auftreten. Das zeigen die nächsten beiden Beispiele.
Betrachten Sie das folgende Programm, und versuchen Sie herauszufinden, welche
Werte die Funktion funktion1 allgemein zurückgibt. Als Hilfe habe ich Ihnen schon
einmal die Werte für n = 1-20 angegeben:
int funktion1( int n)
{
int x;
int k = 0;
for( x = 1; x <= n; x *= 2)
k++;
return k-1;
}
void main()
{
int n;
for( n = 1; n <= 20; n++)
printf( "%2d %2d\n", n, funktion1( n));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0
1
1
2
2
2
2
3
3
3
3
3
3
3
3
4
4
4
4
4
Abbildung 12.18 Die Rückgabewerte von funktion1
325
12
12
Leistungsanalyse und Leistungsmessung
Wenn Sie genau hinsehen, werden Sie feststellen, dass die Funktion immer bei einer
Zweierpotenz ihren Wert um 1 erhöht. Das liegt daran, dass die Variable x immer
ihren Wert verdoppelt, bis sie die Schranke n erreicht.
Die Frage, wie oft man einen Wert verdoppeln kann, bis eine bestimmte Schranke
erreicht ist, beantwortet uns der Logarithmus, der ja die Umkehrung der Exponentialfunktion ist. Durch die Verdopplung ergibt sich in der Funktion: x = 2k. Damit folgt:
k
k
x ≤ n ⇔ 2 ≤ n ⇔ log 2 ( 2 ) ≤ log 2( n ) ⇔ k ⋅ log 2 ( 2 ) ≤ log 2( n ) ⇔ k ≤ log 2( n )
Somit läuft k immer bis zum nächstliegenden ganzzahligen Wert des Zweierlogarithmus von n. Die Laufzeitfunktion unseres Beispiels ist also eine »diskrete Abtastung«
des Logarithmus zur Basis 2:
6
log2(n)
5
4
3
2
Laufzeitfunktion
1
0
0
2
4
6
8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50
Abbildung 12.19 Logarithmus zur Basis 2
Da man in der Regel nur an einer (möglichst guten) Abschätzung der Laufzeitfunktion nach oben interessiert ist, kann man also sagen:
t(n) ≤ log2(n)
Wir betrachten ein weiteres Beispiel. Auch hier sollten Sie es zunächst einmal wieder
selbst versuchen. Analysieren Sie den Code und die Ausgabe in Abbildung 12.20. Versuchen Sie so, die Laufzeitfunktion zu bestimmen. Erst dann lesen Sie unterhalb der
Abbildung weiter.
326
12.3
int funktion2( int n)
{
int x, y;
int k = 0;
for( x = 0, y = 1; x <= n; x += y, y += 2)
k++;
return k-1;
}
void main()
{
int n;
for( n = 1; n <= 20; n++)
printf( "%2d %2d\n", n, funktion2( n));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Laufzeitklassen
1
1
1
2
2
2
2
2
3
3
3
3
3
3
3
4
4
4
4
4
12
Abbildung 12.20 Die Rückgabewerte von funktion2
In diesem Beispiel durchläuft y die ungeraden Zahlen. Die Variable x berechnet also
Summen ungerader Zahlen. Die Formel zur Berechnung dieser Summe habe ich
Ihnen am Anfang dieses Abschnitts vorgestellt. Die Summe der ersten k ungeraden
2
Zahlen ist k2. Hier ergibt sich also: x ≤ n ⇔ k ≤ n ⇔ k ≤ 2 n . Das heißt: t ( n ) ≤ n
Die Laufzeitfunktion dieses Beispiels ist also eine Diskretisierung der Wurzelfunktion:
8
7
2
6
x
5
4
3
2
Laufzeitfunktion
1
0
0
2
4
6
8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50
Abbildung 12.21 Wurzelfunktion
Sie sehen an diesen Beispielen bereits, dass Laufzeitfunktionen unangenehme Eigenschaften haben können. Sie können z. B. Sprünge machen, sodass es oft unmöglich
327
Leistungsanalyse und Leistungsmessung
ist, solche Funktionen durch geschlossene mathematische Terme auszudrücken. Sie
haben aber auch gesehen, dass eine geschickte Abschätzung für unsere Zwecke in der
Regel ausreichend ist. Diesen Weg wollen wir weiter beschreiten.
Wollen wir Algorithmen bezüglich ihrer Laufzeit miteinander vergleichen, müssen
wir die zugehörigen Laufzeitfunktionen betrachten. Der Vergleich zweier Funktionen ist allerdings nicht so einfach wie der Vergleich zweier Zahlenwerte, da beim Vergleich von Funktionen unendlich viele Funktionswerte betrachtet werden müssen.
Die Idealvorstellung, dass eine Laufzeitfunktion immer (d. h. für alle Funktionswerte)
besser ist als eine andere, wird sich im Allgemeinen nicht ergeben. Stellen Sie sich
vor, dass Sie ein Programm geschrieben haben, bei dem in einer Schleife eine komplexe Berechnung durchgeführt wird. Sie optimieren dieses Programm, und es
gelingt Ihnen, die Laufzeit in der Schleife deutlich zu verkürzen. Leider müssen Sie
dabei einen größeren Aufwand zur Initialisierung der Schleife in Kauf nehmen. Dies
bedeutet nun, dass der neue Algorithmus unter Umständen für kleine Datenmengen
schlechter ist als der alte und erst für große Datenmengen seine Überlegenheit
beweist, da die Initialisierung der Schleife ja bei wenigen Schleifendurchläufen stärker ins Gewicht fällt. Die konkreten Laufzeitfunktionen könnten qualitativ etwa wie
folgt aussehen:
Laufzeit
12
Laufzeitfunktion
vor Optimierung
t(n)
topt(n)
Laufzeitfunktion
nach Optimierung
Datenvolumen
n0
topt(n) ≤ t(n) für n ≥ n0
Abbildung 12.22 Mögliche konkrete Laufzeitfunktion
Welchen Algorithmus würden Sie in dieser Situation bevorzugen? Sicherlich den
zweiten5. Sinnvollerweise fordern wir daher nicht, dass eine Laufzeitfunktion
5 Es sei denn, dass die Verbesserung erst bei einem Datenvolumen eintritt, das in unserem Programm gar nicht vorkommt.
328
12.3
Laufzeitklassen
»immer« besser sein muss als eine andere, sondern nur »fast immer« – also ab einem
bestimmten Wert n0, der beliebig, aber fest ist. Diese Art des Vergleichs hat eine ganz
neue Qualität. Wir haben es jetzt mit einer infinitesimalen Begriffsbildung zu tun.
Das bedeutet, dass man endlich viele Werte der Funktion abändern kann, ohne dass
die Vergleichsaussage an Wert verliert. Die Entscheidung über den besseren Algorithmus fällt sozusagen erst »im Unendlichen«. Dies unterstreicht noch einmal die früher bereits getroffene Feststellung, dass endlich viele Messwerte eigentlich nichts
über die Qualität eines Algorithmus aussagen.
Bevor wir das in einer Definition festhalten, wollen wir noch einen anderen Aspekt
bei der Beurteilung von Laufzeitfunktionen diskutieren. Stellen Sie sich vor, dass Sie
ein Programm geschrieben haben, das Integer-Zahlen sortiert. Dieses Programm
stellen Sie auf die Sortierung von Gleitkommazahlen um. Da ein Rechner Gleitkommazahlen nicht so effizient verarbeiten kann wie Integer-Zahlen, wird sich die Laufzeit des Programms durch diese Änderung um einen konstanten Faktor c
verschlechtern:
12
Laufzeit
c · t(n)
t(n)
Datenvolumen
Abbildung 12.23 Laufzeitveränderungen mit konstantem Faktor
Trotzdem sind wir weit davon entfernt zu behaupten, dass der Algorithmus jetzt
schlechter geworden ist. Es handelt sich nach wie vor um den gleichen Algorithmus
mit dem gleichen Laufzeitverhalten. Daher interessiert uns eine solche multiplikative
Konstante bei der Beurteilung von Laufzeiten erst in zweiter Linie. Zur Beurteilung der
Leistungsfähigkeit von Algorithmen benötigen wir ein Klassifizierungsschema für
Laufzeitfunktionen, das von der konkreten Formel der Laufzeitfunktion abstrahiert,
trotzdem aber die wesentlichen Informationen über das qualitative Verhalten der
Funktion »im Unendlichen« enthält. Sehr hilfreich sind dafür die folgenden Begriffsbildungen:
329
Leistungsanalyse und Leistungsmessung
678
Laufzeitfunktion
Unter einer Laufzeitfunktion wollen wir im Folgenden stets eine nicht negative
Funktion von den natürlichen Zahlen in die natürlichen Zahlen verstehen.
Für zwei Laufzeitfunktionen f und g schreiben wir f Ɐ g, wenn es eine Konstante c > 0
und eine natürliche Zahl n0 so gibt, dass f(n) ≤ c · g(n) für alle natürlichen Zahlen n > n0 gilt.
Gilt sowohl f Ɐ g als auch g Ɐ f, schreiben wir f ≈ g.
Gilt f Ɐ g, aber nicht f ≈ g, schreiben wir auch f Ɱ g.
Ich füge noch zwei in der Mathematik und Informatik üblicherweise verwendete
Begriffe hinzu:
Mit O(g) bezeichnen wir die Menge aller Funktionen f, für die f Ɐ g gilt6. In diesem
Sinne kann man anstelle von f Ɐ g auch f ∈ O(g) schreiben. Weit verbreitet ist auch
die Notation7 f = O(g).
Mit Θ(g) bezeichnen wir die Menge aller Funktionen f, für die f ≈ g gilt8. In diesem
Sinne kann man anstelle von f ≈ g auch f ∈ Θ(g) schreiben. Weit verbreitet ist auch
die Notation f = Θ(g).
Anschaulich bedeutet f Ɐ g, dass f »nicht wesentlich schneller wächst« als g, f Ɱ g
bedeutet, dass f »wesentlich langsamer wächst« als g, und f ≈ g bedeutet, dass f und g
»im Wesentlichen gleich schnell« wachsen. Beachten Sie, dass f trotz f Ɐ g stets größer als g sein kann. Es muss nur eine durch c · g(n) gegebene Schranke geben, unterhalb derer sich f fast immer bewegt. Da f dabei nicht nach oben ausbrechen darf, hat
f maximal das Wachstum von g (siehe Abbildung 12.24).
c · g(n)
Laufzeit
12
f(n)
g(n)
n0
Datenvolumen
Abbildung 12.24 Obere Schranke im Laufzeitverhalten
6 Sprich: »groß O von g«
7 Streng genommen, ist diese Notation falsch, da auf der linken Seite des Gleichheitszeichens eine
Funktion und auf der rechten Seite eine Menge von Funktionen steht.
8 Sprich: »Theta von g«
330
12.3
Laufzeitklassen
Das Wachstum von f könnte aber durchaus geringer als das von g sein, da es nach
unten keine durch g definierte Auffanglinie gibt. Eine solche Linie gibt es zusätzlich,
falls f ≈ g ist. Dann gibt es einen durch zwei multiplikative Konstanten definierten
»Kanal«, in dem sich f fast immer bewegt (siehe Abbildung 12.25).
Laufzeit
c1 · g(n)
f(n)
g(n)
c2 · g(n)
12
n0
Datenvolumen
Abbildung 12.25 Obere und untere Schranke im Laufzeitverhalten
Da f weder nach unten noch nach oben ausbrechen darf, hat f das gleiche Wachstum
wie g. Der Kanal muss nicht, wie in der Skizze gezeigt, um g herum liegen. Wichtig ist
nur, dass g durch die beiden Konstanten das Wachstum des Kanals vorgibt. Auch der
Wert von n0 ist mehr oder weniger willkürlich. Wichtig ist nur, dass f ab n0 den vorgegebenen Kanal nicht mehr verlässt. Jeder größere Wert wäre auch geeignet. Diese
Willkür in der Wahl des »Kanals« führt oft zu Verwirrung:
Laufzeit
c1 · g(n)
f(n)
c2 · g(n)
g(n)
n0
Datenvolumen
Abbildung 12.26 Vorgegebener Kanal für die Laufzeit
331
12
Leistungsanalyse und Leistungsmessung
Wenn Sie sich auf das Wachstum von Laufzeitfunktionen konzentrieren, können Sie
die Funktionen oft erheblich vereinfachen, indem Sie Funktionsterme eliminieren,
die keinen wesentlichen Beitrag zum Wachstum der Funktion liefern. Wir betrachten
dazu zunächst einmal Polynome und stellen uns vor, dass wir die folgende Laufzeitfunktion zu einem Algorithmus ermittelt haben:
t(n) = n4 + 3n3 – 2n2 + n – 3
Wir wollen diese Funktion nach oben abschätzen. Dazu lassen wir negative Terme
einfach weg, da sie das Wachstum bremsen. Wir erhalten:
t(n) ≤ n4 + 3n3 + n
Das können wir, wegen n ≥ 1, weiter abschätzen9:
t(n) ≤ n4 + 3n4 + n4 = 5n4
Insgesamt haben wir damit erhalten:
t(n) Ɐ n4
Da wir eine analoge Abschätzung für ein beliebiges Polynom durchführen können,
erkennen wir, dass das Wachstum eines polynomialen Ausdrucks durch die höchste
Potenz dominiert wird. Wir versuchen jetzt noch eine Abschätzung in die umgekehrte Richtung. Dazu lassen wir zunächst die positiven Terme niedriger Potenz weg:
t(n) ≥ n4 – 2n2 – 3
Die negativen Terme vergrößern wir noch, indem wir zur dritten Potenz übergehen:
t(n) ≥ n4 – 2n3 – 3n3 = n4 – 5n3
Jetzt opfern wir die Hälfte unserer höchsten Potenz, um damit die niederen Potenzen
zu eliminieren:
3
1 4 1 4
1 4 1 3
t ( n ) ≥ -- n + -- n – 5n = -- n + -- n ( n – 10 )
2
2
2
2
Für n ≥ 10 ist der letzte Term nicht mehr negativ und kann weggelassen werden.
Damit haben wir:
1 4
t ( n ) ≥ -- n für n ≥ 10
2
9 Abschätzungen enthalten immer eine gewisse Willkür. Man könnte hier durchaus filigraner
abschätzen. Aber das ist nicht nötig. Stellen Sie sich vor, dass Sie sich beim Bäcker ein Brötchen
kaufen wollen. Durch einen flüchtigen Blick ins Portemonnaie sehen Sie, dass Sie noch Geldscheine haben. Dann würden Sie doch auch nicht anfangen, Ihr Kleingeld zu zählen, um festzustellen, ob es für ein Brötchen reicht.
332
12.3
Laufzeitklassen
Also:
t(n) Ɒ n4
Insgesamt ergibt sich:
t(n) ≈ n4
Abbildung 12.27 zeigt das durch die Abschätzung erhaltene Ergebnis:
200000
5n4
180000
160000
140000
120000
12
100000
80000
60000
40000
n0
n4 + 3n3 – 2n2 + n – 3
20000
0
1 n4
2
1 2 3 4 5 6 7 8 9 10 11 1213 14 1516 17 1819202122232425
Abbildung 12.27 Ergebnis der Abschätzung
Viel wichtiger als diese konkrete Abschätzung ist aber das durch Verallgemeinerung
gewonnene Ergebnis:
Für eine polynomiale Laufzeitfunktion
t(n) = aknk + ak – 1nk – 1 + ... + a2n2 + a1n + a0 mit ak > 0 gilt: t(n) ≈ nk
Wir müssen bei polynomialen Laufzeitfunktionen also immer nur auf die höchste
Potenz achten. Wegen des zusätzlichen Faktors n, den man durch keine Konstante
einfangen kann, haben höhere Potenzen ein echt größeres Wachstum. Die Laufzeitfunktionen im polynomialen Bereich sind also nach Potenzen geordnet:
1 Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...
Das Gleiche gilt auch für nicht ganzzahlige Potenzen – also auch für die Wurzeln
1⁄k
(k n = n
) . Auch hier »zählt« immer nur die höchste Potenz, und es ist insgesamt:
1 Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...
333
Leistungsanalyse und Leistungsmessung
Bei den Logarithmen müssen Sie nur wissen, dass sich Logarithmen unterschiedlicher Basis nur durch einen konstanten Faktor unterscheiden. Damit haben alle Logarithmen ungeachtet ihrer Basis das gleiche Wachstumsverhalten, das schwächer als
jede Potenz ist. Wir können also unsere Kette wie folgt erweitern:
1 Ɱ log(n) Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...
Am oberen Ende der Kette stehen die Exponentialfunktionen, die je nach Größe ihrer
Basis unterschiedlich schnell wachsen und jede Potenz in ihrem Wachstum übertreffen. Damit erhalten wir:
1 Ɱ log(n) Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ... Ɱ 2n Ɱ 3n Ɱ ... Ɱ kn Ɱ ...
Diese Kette zeigt nur einige wichtige Vertreter von Laufzeitfunktionen. Beliebige
3 n
Funktionen mit gebrochener Basis (z. B. ⎛ --⎞ ) oder gebrochenem Exponenten (z. B.
⎝
2⎠
3⁄
n 2 ) oder Kombinationen dieser Grundtypen (z. B. n · log(n)) können vorkommen.
Diese Funktionen bilden nur ein Gerüst, anhand dessen man weitere Laufzeitfunktionen einordnen kann (siehe Abbildung 12.28).
logarithmisch
1
konstant
log(n)
log2(n)
log3(n)
…
logk(n)
…
k
n
…
3
n
n · log(n)
n
n
linear
n · log2(n)
polynomial
n · log3(n)
n2
quadratisch
n3
kubisch
…
…
n · logk(n)
…
nk
exponentiell
12
…
2n
3n
…
kn
…
Abbildung 12.28 Typische Laufzeitklassen
334
12.3
Laufzeitklassen
Bei der Zuordnung einer Laufzeitfunktion zu einer Laufzeitklasse kommt es jetzt darauf an, möglichst früh unwesentliche Terme unter den Tisch fallenzulassen, damit
man möglichst elegant zu einem aussagekräftigen Ergebnis kommt. Wir betrachten
dazu einige Beispielprogramme, denen wir jeweils versuchen, eine Laufzeitklasse
zuzuordnen, ohne uns zu tief in Detailberechnungen zu verlieren.
12.3.1
Programm 1
int programm1( int n)
{
int i, k;
int z = 0;
for( i = 1; i <= n; i++)
{
for( k = 1; k <= i; k += (i/10 + 1))
z++;
}
return z;
}
12
Listing 12.5 Das Programm 1
Das Programm 1 ergibt die folgende Ausgabe:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
3
6
10
15
21
28
36
45
50
56
62
69
76
84
335
12
Leistungsanalyse und Leistungsmessung
Die äußere Schleife dieses Programms wird linear (1-n) durchlaufen, die innere dagegen höchstens 11-mal. Daraus folgt: n Ɐ t(n) Ɐ 11n. Das Programm ist also linear:
t(n) ≈ n.
12.3.2
Programm 2
int programm2( int n)
{
int i, k;
int z = 0;
for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k++)
z++;
}
return z;
}
Listing 12.6 Das Programm 2
Das Programm 2 ergibt die folgende Ausgabe:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
3
3
7
7
7
7
15
15
15
15
15
15
15
15
Wenn Sie sich bei diesem Programm die äußere Schleife wegdenken und stattdessen
einfach den Maximalwert i = n annehmen, sehen Sie, dass das Programm mindestens
linear ist. Auf der anderen Seite verdoppelt i mit jedem Schritt in der äußeren
336
12.3
Laufzeitklassen
Schleife seinen Wert, erreicht also das Schleifenende nach log2(n) Schritten. Stellen
Sie sich jetzt vor, dass wir im s-ten dieser Schritte sind. Dann hat i den Wert 2s. Dann
wurden in der inneren Schleife bisher 1 + 2 + 22 + ... + 2s Schritte durchgeführt. Nach
der eingangs erwähnten Summenformel der geometrischen Reihe ist:
s+1
2
s
s+1
s
2
–1
1 + 2 + 2 + … + 2 = -------------------- ≤ 2
= 2⋅2
2–1
Da es maximal log2(n) Schritte gibt, ist t(n) Y 2 · 2
log 2 ( n )
= 2n
Insgesamt ist also n Ɐ t(n) Ɐ 2n. Daher ist auch dieses Programm linear: t(n) ≈ n.
An dieser Stelle erkennen Sie deutlich den Nutzen dieser Überlegungen. Die beiden
ersten Programme haben, obwohl sie grundverschieden sind, die gleichen Wachstumseigenschaften und sind darum in der gleichen Leistungsklasse – so, wie man
etwa zwei grundverschiedene Autos bezüglich ihrer Motorleistung vergleichen kann.
12.3.3
12
Programm 3
Das dritte Programm ist dem zweiten oberflächlich durchaus ähnlich. Ich habe nur
die beiden Schleifen getauscht. Die Vervielfachung findet jetzt in der inneren Schleife
statt, während der Index der äußeren Schleife linear wächst. Diesmal werden Sie aber
kein lineares Verhalten erkennen. Doch zunächst werfen wir einen Blick auf das Programm:
int programm3( int n)
{
int i, k;
int z = 0;
for( i = 1; i <= n; i++)
{
for( k = 1; k <= i; k *= 2)
z++;
}
return z;
}
Listing 12.7 Das Programm 3
Das Programm 3 ergibt die folgende Ausgabe:
337
12
Leistungsanalyse und Leistungsmessung
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
3
5
8
11
14
17
21
25
29
33
37
41
45
49
In der inneren Schleife gibt es log2(i) Durchläufe. Das bedeutet, wenn man die Formel
log(a) + log(b) = log(a · b) iteriert anwendet:
t(n) ≈ log2(1) + log2(2) + ... log2(n) = log2(n!)
n⁄
n
Mit den Abschätzungen n 2 ≤ n! ≤ n folgt dann einerseits:
t(n) ≈ log2(n!) ≤ log2(nn) = n · log2(n)
und andererseits
n⁄
n
t ( n ) ≈ log 2 ( n! ) ≥ log 2 ( n 2 ) = --- ⋅ log 2 ( n )
2
Insgesamt ist daher: t ( n ) ≈ n ⋅ log ( n )
Das ist übrigens eine ganz wichtige Laufzeitklasse. Wenn wir uns in einem späteren
Abschnitt mit Sortierung beschäftigen, werden wir erneut auf diese Laufzeitklasse
stoßen.
Es ist übrigens ganz interessant, dieses Programm mit dem ersten Programm zu vergleichen. Obwohl dieses Programm wegen n Ɱ n · log(n) in einer schlechteren Laufzeitklasse ist als das erste, hat man bei Betrachtung der Bildschirmausgaben den
gegenteiligen Eindruck:
Durchlauf
Programm 1
Laufzeitklasse: n
Laufzeitklasse: n · log(n)
1
1
1
2
3
3
Tabelle 12.1 Die ersten 15 Durchläufe
338
12.3
Durchlauf
Programm 1
Laufzeitklasse: n
Laufzeitklasse: n · log(n)
3
6
5
4
10
8
5
15
11
6
21
14
7
28
17
8
36
21
9
45
25
10
50
29
11
56
33
12
62
37
13
69
41
14
76
45
15
84
49
Laufzeitklassen
12
Tabelle 12.1 Die ersten 15 Durchläufe (Forts.)
Das liegt daran, dass die Entscheidung erst »im Unendlichen« fällt. Erst bei n = 1919
entscheidet sich, welche Funktion die größere ist.
Durchlauf
Programm 1
Laufzeitklasse: n
Laufzeitklasse: n · log(n)
1915
19033
19029
1916
19043
19040
1917
19053
19051
1918
19063
19062
1919
19073
19073
1920
19083
19084
1921
19093
19095
Tabelle 12.2 Die Durchläufe 1915–1925
339
12
Leistungsanalyse und Leistungsmessung
Durchlauf
Programm 1
Laufzeitklasse: n
Laufzeitklasse: n · log(n)
1922
19103
19106
1923
19113
19117
1924
19123
19128
1925
19133
19139
Tabelle 12.2 Die Durchläufe 1915–1925 (Forts.)
12.3.4
Programm 4
Zur Entspannung analysieren wir jetzt ein ganz einfaches Programm, bei dem die
innere Schleife immer bis zum Quadrat des Schleifenindex der äußeren Schleife
läuft. Die resultierende Laufzeitkomplexität können Sie schon erahnen:
int programm4( int n)
{
int i, k;
int z = 0;
for( i = 1; i <= n; i++)
{
for( k = 1; k <= i*i; k++)
z++;
}
return z;
}
Listing 12.8 Das Programm 4
Das Programm 4 ergibt die folgende Ausgabe:
1
2
3
4
5
6
7
340
1
5
14
30
55
91
140
12.3
8
9
10
11
12
13
14
15
Laufzeitklassen
204
285
385
506
650
819
1015
1240
Es ist hier so, dass es in der inneren Schleife immer i2 Durchläufe gibt, sodass es insgesamt
n ( n + 1 ) ( 2n + 1 )
t ( n ) = 1 + 2 2 + 3 2 + … + n 2 = ----------------------------------------6
Durchläufe gibt. Hier haben wir sogar eine explizite Laufzeitformel, die Sie mit der
oben dargestellten Bildschirmausgabe vergleichen können. Da wir aber nur an der
Komplexitätsklasse interessiert sind, stellen wir fest, dass das asymptotische Verhalten durch die höchste vorkommende Potenz bestimmt wird. Es ist also: t(n) ≈ n3.
12.3.5
Programm 5
Im nächsten Beispiel wachsen die Schleifenindizes in beiden Schleifen exponentiell:
int programm5( int n)
{
int i, k;
int z = 0;
for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k *= 2)
z++;
}
return z;
}
Listing 12.9 Das Programm 5
Das Programm 5 ergibt die folgende Ausgabe:
341
12
12
Leistungsanalyse und Leistungsmessung
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
3
3
6
6
6
6
10
10
10
10
10
10
10
10
Dementsprechend moderat ist das Wachstum der Funktion, weil beide Schleifenzähler durch die Verdopplung sehr schnell ihr Ziel erreichen. Die äußere Schleife benötigt, wegen der Verdopplung log2(n) Schritte, wobei beim s-ten Schritt i den Wert i = 2s
hat. In der inneren Schleife benötigt die Variable k dann aber, ebenfalls wegen der
Verdopplung, s Schritte, um diesen Wert von i zu erreichen. Das heißt, im s-ten
Schleifendurchlauf der äußeren Schleife macht die innere Schleife genau s Durchläufe. Da die äußere Schleife log2(n) Durchläufe macht, ergibt das:
log 2 ( n ) ( log 2 ( n ) + 1 ) 1
t(n) = 1 + 2 + 3 + ... + log2(n) = ----------------------------------------------------- = -- ⎛ log 22 ( n ) + log 2 ( n )⎞
⎠
2
2⎝
Unter log2(n) wird hier wieder der nächstpassende ganzzahlige Wert verstanden, und
ich habe zur Auswertung der Summe die gaußsche Summenformel angewandt. Da
das Quadrat des Logarithmus den nicht quadrierten Logarithmus dominiert und der
Faktor ½ keine Rolle spielt, erhalten wir:
t(n) ≈ log2(n)
Dieses Programm hat die niedrigste Laufzeitkomplexität unter unseren sechs Beispielen.
12.3.6
Programm 6
In unserem letzten Beispiel werden wir es mit exponentieller Laufzeit zu tun haben:
342
12.3
Laufzeitklassen
int programm6( int n)
{
int i, k, m;
int z = 0;
for( i = 1, m = 1; i <= n; i++, m *= 2)
{
for( k = 1; k <= m; k++)
z++;
}
return z;
}
Listing 12.10 Das Programm 6
Das Programm 6 ergibt die folgende Ausgabe:
12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
3
7
15
31
63
127
255
511
1023
2047
4095
8191
16383
32767
Hier muss der Zähler k einem exponentiell wachsenden m hinterherlaufen. Die Variable m hat immer den Wert m = 2i–1. In der inneren Schleife werden also immer m = 2i–1
Durchläufe ausgeführt. Damit ist:
2n – 1
t ( n ) = 1 + 2 + 2 2 + 2 3 + … + 2 n – 1 = -------------- = 2 n – 1 ≈ 2 n
2–1
Dieses Programm ist also das mit der höchsten Laufzeitkomplexität unter unseren
sechs Beispielen.
Im Prinzip haben wir vier große Leistungsbereiche für Algorithmen ermittelt:
343
12
Leistungsanalyse und Leistungsmessung
왘
Algorithmen exponentieller Laufzeitkomplexität (untereinander abgestuft nach
der Größe der Basis). Dies sind die Algorithmen mit inakzeptabel wachsendem
Zeitbedarf. Der Programmierer sollte diese Algorithmen meiden, wo immer es
möglich ist.
왘
Algorithmen polynomialer Laufzeitkomplexität (untereinander abgestuft nach
der höchsten vorkommenden Potenz). Dies sind Algorithmen mit einem akzeptabel wachsenden Zeitbedarf. Natürlich ist man hier immer bemüht, die höchste
vorkommende Potenz so niedrig wie möglich zu halten.
왘
Algorithmen logarithmischer Laufzeitkomplexität (untereinander gleichwertig,
unabhängig von der Basis). Dies sind Algorithmen mit einem sehr moderaten
Wachstum, die sich jeder Programmierer wünscht.
왘
Algorithmen konstanter Laufzeit. Dies sind natürlich die besten Algorithmen.
Nur kommen sie bei ernsthaften Problemen in der Regel nicht vor.
Nun könnte man argumentieren, dass es eigentlich egal ist, welcher Leistungsklasse
ein Algorithmus angehört, da unsere Rechner immer schneller werden und irgendwann so schnell sein werden, dass die Frage nach der Effizienz von Algorithmen zu
den Akten gelegt werden kann. Dem kann man zweierlei entgegenhalten. Zum einen
ist Effizienz ein grundsätzlicher Wert, den man immer anstreben sollte, denn auch
auf einem schnelleren Rechner bleibt ein »guter« Algorithmus besser als ein
»schlechter«. Schnelle Rechner machen aus schlechten Programmen keine guten
Programme. Ein zweites Argument ist aber noch gewichtiger. Schauen Sie sich die
Tabelle in Abbildung 12.29 an. Sie zeigt, welche Gewinne man für Algorithmen unterschiedlicher Leistungsklassen aus einer Vervielfachung der Rechnerleistung zieht:
Laufzeitklasse
Heutiger
Rechner
10-mal
schnellerer
Rechner
100-mal
schnellerer
Rechner
1000-mal
schnellerer
Rechner
log(n)
x
x10
x100
x1000
n
x
10 · x
100 · x
1000 · x
2
x
3.16 · x
10 · x
31.6 · x
n
x
x + 3.32
x + 6.64
x + 9.96
10
x
x+1
x+2
x+3
n
2
n
Abbildung 12.29 Laufzeitklassen und Rechnerleistung
Die Tabelle zeigt, dass selbst eine Vertausendfachung der Rechnerleistung nur
geringe Gewinne im Bereich der exponentiell wachsenden Algorithmen bringt.
Selbst ein 1000-mal schnellerer Rechner schafft es nur, ein Problem der Leistungsklasse 2n für knapp zehn Elemente mehr in der gleichen Zeitvorgabe zu lösen. Für uns
bedeutet dies, dass die Suche nach Algorithmen niedriger Zeitkomplexität immer
344
12.3
Laufzeitklassen
ein wichtiges Anliegen der Programmierung sein wird und es keinen Sinn macht, auf
zukünftige Rechner zu warten. Unser Ziel muss immer sein, einen Algorithmus in
eine möglichst optimale Leistungsklasse zu bringen.
Grundsätzlich sollte allerdings auch gesagt werden, dass Algorithmen einer höheren
Laufzeitkomplexität nicht in jeder Situation schlechter sind als solche mit einer niedrigeren Laufzeitkomplexität. Sie erinnern sich, dass die entsprechende Ungleichung
erst ab einer bestimmten, unter Umständen sehr großen Zahl gelten muss. Es gibt
Fälle, in denen asymptotisch schlechtere Verfahren, z. B. aufgrund einer einfacheren
Implementierung, eingesetzt werden, weil entsprechend große Datenmengen nicht
zu verarbeiten sind, und es gibt auch Fälle, in denen die asymptotisch besten bekannten Verfahren nicht eingesetzt werden, weil ihre Vorzüge erst in Bereichen zum Tragen kommen, die nicht mehr praxisrelevant sind. Und es gibt leider auch Fälle, in
denen exponentiell wachsende Verfahren eingesetzt werden müssen, weil keine effizienteren Verfahren bekannt sind.
Nur ein Fall sollte auf keinen Fall eintreten. Es sollte nicht vorkommen, dass ineffiziente Verfahren aus Unkenntnis effizienterer Algorithmen oder aus dem Unvermögen heraus, eine Laufzeitanalyse durchzuführen, eingesetzt werden. Das ist so, als
würde ein Maschinenbauer, ohne sich um den Wirkungsgrad zu kümmern, einen
Motor konstruieren, der im Ergebnis überwiegend Verlustwärme produziert. Ineffiziente Algorithmen verursachen ja im wahrsten Sinne des Wortes Verlustwärme, da sie
die CPU des Rechners über das notwendige Maß hinaus beanspruchen.
345
12
Kapitel 13
Sortieren
Ordnung lehrt Euch Zeit gewinnen
– Johann Wolfgang von Goethe
Eine klassische Aufgabe der Datenverarbeitung ist das Sortieren von Datensätzen
nach einem bestimmten Kriterium. Wir wollen die Rahmenbedingungen stark vereinfachen, um uns auf den eigentlichen algorithmischen Kern von Sortierverfahren
konzentrieren zu können. Wir stellen uns die Aufgabe, ein Array von ganzen Zahlen
in aufsteigender Reihenfolge zu sortieren. Gesucht wird der effizienteste Algorithmus für dieses Problem.
13
13.1
Sortierverfahren
Das Thema der Sortierung ist so wichtig und zugleich so ergiebig, dass wir verschiedene Verfahren formulieren und als C-Programme realisieren werden. Konkret werden wir die folgenden Verfahren betrachten:
왘
Bubblesort
왘
Selectionsort
왘
Insertionsort
왘
Shellsort
왘
Quicksort
왘
Heapsort
Die verschiedenen Verfahren werden wir als Funktionen implementieren und mit
einer einheitlichen Schnittstelle ausstatten, an der wir die Anzahl der Daten (int n)
und das Array mit den Daten (int *daten) übergeben.
void XXXsort( int n, int *daten)
Damit sind wir in der Lage, vorab einen einheitlichen Testrahmen für alle Sortierprogramme dieses Abschnitts zu erstellen:
347
13
Sortieren
Testobjekt
Testrahmen
# define ANZAHL 100
# define SEED 4711
void main()
{
int daten[ANZAHL];
srand( SEED);
testdaten( ANZAHL, daten);
XXXsort( ANZAHL, daten);
if( pruefen( ANZAHL, daten))
printf( "ok\n");
else
printf( "nicht ok\n");
}
void XXXsort( int n, int *daten)
{
...
}
void testdaten( int n, int *daten)
{
int i;
for ( i = 0; i < n; i++)
daten[i] = rand() % n;
}
int pruefen( int n, int *daten)
{
int i;
for( i = 0; i < n-1; i++)
{
if( daten[i] > daten[i+1])
return 0;
}
return 1;
}
Abbildung 13.1 Einheitlicher Testrahmen für alle Sortierprogramme
Das Hauptprogramm enthält das zu sortierende Array (daten). Dieses Array kann, je
nach Anforderung, mehrere Tausend oder sogar Millionen von Zahlen enthalten. Das
wird über die symbolische Konstante ANZAHL gesteuert. Nachdem der Zufallszahlengenerator mit dem Startwert SEED gestartet wurde, wird das Array in der Funktion
testdaten mit Zufallszahlen gefüllt. Danach wird das Array mit dem zu testenden
Verfahren, hier vorläufig XXXsort genannt, sortiert. Eine Funktion zu schreiben, die
das Array vor und nach der Sortierung ausgibt, um eine Sichtkontrolle des Ergebnisses vorzunehmen, macht angesichts der möglichen Datenflut keinen Sinn. Wir komplettieren den Testrahmen daher durch eine Hilfsfunktion (pruefen), die prüft, ob das
Array korrekt sortiert ist:
In diesem Testrahmen werden wir mit entsprechend großen Arrays für alle hier
betrachteten Verfahren vergleichende Laufzeitbetrachtungen und Laufzeitmessungen anstellen, in der Hoffnung, das Beste aller Sortierverfahren zu finden. Später werden wir noch weitere Funktionen zur Testdatengenerierung hinzufügen.
348
13.1
13.1.1
Sortierverfahren
Bubblesort
Das erste Sortierverfahren, das wir untersuchen wollen, wird allgemein als Bubblesort bezeichnet. Der Name rührt vielleicht daher, dass die zu sortierenden Elemente
im Array wie Luftblasen im Wasser aufsteigen.
Das Verfahren läuft wie folgt ab:
Durchlaufe die Daten in aufsteigender Richtung! Betrachte dabei immer zwei
benachbarte Elemente. Wenn zwei benachbarte Elemente in falscher Ordnung
sind, dann vertausche sie! Nach einem Durchlauf ist auf jeden Fall das größte
Element am Ende der Daten.
Wiederhole diesen Verfahrensschritt so lange, bis die Daten vollständig sortiert
sind! Dabei muss jeweils das letzte Element des vorherigen Durchlaufs nicht
mehr betrachtet werden, da es schon seine endgültige Position gefunden hat!
Abbildung 13.2 veranschaulicht die einzelnen Durchläufe des Verfahrens am Beispiel
eines Arrays mit sechs Elementen. In dieser Grafik werden ein Vergleichsschritt durch
ein graues Rechteck und eine Vertauschung durch gekreuzte Linien dargestellt:
i
Array
Vor dem 1. Durchlauf
0
1
2
3
4
3
5
2
6
4
k
5
1
5
0
1
2
3
4
Nach dem 1. Durchlauf
3
2
5
4
1
6
4
0
1
2
3
Nach dem 2. Durchlauf
2
3
4
1
5
6
3
0
1
2
Nach dem 3. Durchlauf
2
3
1
4
5
6
2
0
1
Nach dem 4. Durchlauf
2
1
3
4
5
6
1
Nach dem 5. Durchlauf
1
2
3
4
5
6
0
0
Abbildung 13.2 Veranschaulichung von Bubblesort
349
13
13
Sortieren
Die in den beiden rechten Spalten stehenden Zahlen stellen bereits einen Bezug zu
den Schleifenzählern i und k des nachfolgenden Programms her. Startend mit n-1,
steht in der Zählvariablen i, wie viele Durchläufe noch durchzuführen sind. Innerhalb eines Durchlaufs zählt die Variable k dann die Anzahl der durchgeführten Vergleichsschritte.
void bubblesort( int n, int *daten)
{
int i, k, t;
A
B
C
for( i = n-1; i > 0; i--)
{
for( k = 0; k < i; k++)
{
if( daten[k] > daten[k+1])
{
t = daten[k];
daten[k] = daten[k+1];
daten[k+1] = t;
}
}
}
}
Listing 13.1 Die Bubblesort-Funktion
Die Sortierfunktion startet mit der äußeren Schleife. Am Anfang müssen alle Elemente betrachtet werden, dann immer eins weniger (A). Die zweite Schleife durchläuft den noch zu betrachtenden Bereich (B). Innerhalb dieser zweiten Schleife
werden zwei benachbarte Elemente verglichen (C). Wenn sie in der falschen Reihenfolge sind, werden sie getauscht.
Abbildung 13.3 zeigt Bubblesort bei der Arbeit auf einem Array mit 100 Zufallszahlen.
Am Anfang, zweimal zwischendurch und am Ende wurden dabei Schnappschüsse
des Arrays gemacht:
Abbildung 13.3 Der Sortierverlauf bei Bubblesort
350
13.1
Sortierverfahren
Das Bild zeigt, wie große Werte nach rechts wandern, bis sie ihre Position gefunden
haben, und so, von rechts nach links, Ordnung in die anfangs chaotische Punktwolke
einkehrt.
13.1.2
Selectionsort
Eine weitere Möglichkeit zur Sortierung besteht darin, immer das kleinste oder
größte Element im Array zu suchen und dieses dann durch Tausch direkt an die richtige Stelle zu bringen. Dieses Verfahren nennen wir Selectionsort:
Durchlaufe das Array in aufsteigender Richtung, und suche das kleinste Element! Vertausche das kleinste Element mit dem ersten Element! Das neue
erste Element ist jetzt an der korrekten Position und muss im Weiteren nicht
mehr betrachtet werden.
Durchlaufe das Array jetzt ab dem zweiten Element aufwärts, und suche wieder
das kleinste Element! Vertausche das gefundene Element mit dem zweiten Element! Jetzt sind die beiden ersten Elemente im Array in der richtigen Reihenfolge und müssen im Weiteren nicht mehr betrachtet werden.
Setze dieses Verfahren fort, bis das gesamte Array sortiert ist!
Auch hier veranschaulichen wir die einzelnen Verfahrensschritte durch eine Grafik:
i
Array
Vor dem 1. Durchlauf
0
1
2
3
4
5
3
5
2
6
4
1
k
0
1
2
3
4
5
Nach dem 1. Durchlauf
1
5
2
6
4
3
1
2
3
4
5
Nach dem 2. Durchlauf
1
2
5
6
4
3
2
3
4
5
Nach dem 3. Durchlauf
1
2
3
6
4
5
3
4
5
Nach dem 4. Durchlauf
1
2
3
4
6
5
4
5
Nach dem 5. Durchlauf
1
2
3
4
5
6
5
Abbildung 13.4 Veranschaulichung von Selectionsort
351
13
13
Sortieren
Die dunklen Felder zeigen dabei das aktuell im Verfahren ausgewählte, minimale Element. Am Ende eines Verfahrensschritts erfolgt dann der Tausch des jeweils kleinsten mit dem zuerst betrachteten Element.
Als Programm realisieren wir das wie folgt:
void selectionsort( int n, int *daten)
{
int i, k, t, min;
A
B
C
D
for( i = 0; i < n-1; i++)
{
min = i;
for( k = i+1; k < n; k++)
{
if( daten[k] < daten[min])
min = k;
}
t = daten[min];
daten[min] = daten[i];
daten[i] = t;
}
}
Listing 13.2 Die Selectionsort-Implementierung
In der äußeren Schleife werden die n-1 Verfahrensschritte durchgeführt (A). Zunächst
ist das erste zu betrachtende Element das kleinste (B), dann wird im Rest des Arrays
ein kleineres gesucht (C). In (D) wird dann das kleinste Element mit dem zuerst
betrachteten getauscht.
Beachten Sie, dass wir uns bei der Minimumsuche nicht den Wert des kleinsten Elements merken, sondern den Index, also die Stelle, an der das kleinste Element steht.
Dadurch haben wir die Möglichkeit, am Ende die Elemente zu tauschen.
Im Vergleich zu Bubblesort finden deutlich weniger Elementvertauschungen statt,
denn die Vertauschung wird hier im Gegensatz zu Bubblesort in der äußeren der beiden Schleifen durchgeführt. Dafür befindet sich in der inneren Schleife die Suche
nach dem Minimum, die es bei Bubblesort nicht gibt. Wie sich das in der Laufzeitbilanz auswirkt, werden wir später untersuchen.
Auch hier beobachten wir den Algorithmus bei der Arbeit:
352
13.1
Sortierverfahren
Abbildung 13.5 Der Sortierverlauf bei Selectionsort
Aus der ungeordneten Punktwolke im rechten Teil wird jeweils das kleinste Element
entfernt und an die geordnete Kette im linken Teil angefügt. Dadurch wird die Sortierung systematisch von links nach rechts aufgebaut. Rechts verbleiben die noch
unsortierten Elemente, die aber alle größer als die Elemente im bereits sortierten Teil
sind.
13.1.3
Insertionsort
Insertionsort ist ein Sortierverfahren, das so arbeitet, wie wir Spielkarten auf der
Hand sortieren.
3
1
2
7 8
4 5
10
6
9
Abbildung 13.6 Die Arbeitsweise von Insertionsort
Die erste Karte ganz links ist sortiert. Wir nehmen die zweite Karte und stecken
sie, je nach Größe, vor oder hinter die erste Karte. Damit sind die beiden ersten
Karten relativ zueinander sortiert.
Wir nehmen die dritte, vierte, fünfte ... Karte und schieben sie so lange nach
links, bis wir an die Stelle kommen, an der sie hineinpasst. Dort stecken wir sie
hinein.
353
13
13
Sortieren
In einem Array geht das Verschieben von Daten nicht so leicht wie bei einem Kartenspiel auf der Hand. Wir können im Array nicht einfach ein Element »dazwischenschieben«. Dazu müssen zunächst alle übersprungenen Elemente nach rechts
aufrücken, um für das einzusetzende Element einen Platz frei zu machen.
Zur Veranschaulichung des Verfahrens dient Abbildung 13.7:
i
Array
Vor dem 1. Durchlauf
0
1
2
3
4
3
5
2
6
4
k
5
1
1
1
Nach dem 1. Durchlauf
3
5
2
6
4
1
2
2
1
Nach dem 2. Durchlauf
2
3
5
6
4
1
3
3
Nach dem 3. Durchlauf
2
3
5
6
4
1
4
4
3
2
Nach dem 4. Durchlauf
2
3
4
5
6
1
5
5
4
3
2
1
Nach dem 5. Durchlauf
1
2
3
4
5
6
6
Abbildung 13.7 Veranschaulichung von Insertionsort
Zu Beginn jedes Verfahrensschritts wird das einzusortierende Element aus dem
Array entnommen. Solange das einzusortierende Element seinen Platz noch nicht
gefunden hat, rücken die Vergleichselemente von links her auf. Am Ende wird das
zuvor entnommene Element an der frei gewordenen Position abgelegt.
Mit diesen Informationen ist Insertionsort einfach zu realisieren:
354
13.1
Sortierverfahren
void insertionsort( int n, int *daten)
{
int i, k, v;
A
for( i = 1; i < n; i++)
{
v = daten[i];
B
C
D
for( k = i; (k >= 1)&&(daten[k-1] > v); k--)
daten[k] = daten[k-1];
E
daten[k] = v;
}
}
Listing 13.3 Die Insertionsort-Implementierung
Die äußere Schleife führt die n-1 Verfahrensschritte durch (A). Innerhalb dieser
Schleife wird das betrachtete Element außerhalb des Arrays gesichert (B). Im Array
rücken größere Elemente dann auf (C) und (D). Abschließend erhält das gesicherte
Element seine korrekte Position (E).
Wie bei Bubblesort und Selectionsort haben wir es im Programm mit einer Doppelschleife zu tun. Anstelle von Elementvertauschungen wird jetzt jedoch mit Elementverschiebungen gearbeitet. Wie viele Elementverschiebungen in der inneren Schleife
durchgeführt werden, ist auf Anhieb nicht erkennbar.
Auch hier betrachten wir einige Schnappschüsse:
Abbildung 13.8 Der Sortierverlauf bei Insertionsort
Sie sehen, wie die Punktwolke von links nach rechts abgearbeitet wird. Im Gegensatz
zu den bisher diskutierten Verfahren werden die noch unsortierten Daten nicht
umgeordnet, und im sortierten Bereich können immer noch Elemente eingeschoben
werden.
355
13
13
Sortieren
13.1.4
Shellsort
Zur Einführung des nächsten Sortierverfahrens schwächen wir das Verfahren aus
dem vorangegangenen Abschnitt zunächst ab. Wir modifizieren Insertionsort so,
dass das Array nicht in Einerschritten, sondern in Schritten mit der Schrittweite h
durchlaufen wird. Dazu ersetzen wir die in Insertionsort vorkommende Konstante 1
durch eine Variable h (A, B und C), die wir zusätzlich an der Schnittstelle der Funktion
übergeben:
void insertion_h_sort( int n, int *daten, int h)
{
int i, k, v;
A
for( i = h; i < n; i++)
{
v = daten[i];
for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
daten[k] = daten[k-h];
daten[k] = v;
}
}
B
C
Listing 13.4 Ersetzen der Schrittweite im Insertionsort
Für h = 1 ist dies unser altbekanntes Programm Insertionsort. Aber was macht dieses
Programm für h > 1? Nach wie vor das Gleiche wie Insertionsort, allerdings mit dem
Unterschied, dass sich der Algorithmus bei einem Durchlauf immer nur für Elemente mit Abstand h interessiert.
Betrachten wir dies am Beispiel eines Arrays mit 17 Elementen und wählen dazu die
Schrittweite h = 3:
3
12
5
3
2
14
9
2
12
11
4
8
14
5
8
10
7
6
17
7
10
4
Abbildung 13.9 Sortierung mit der Schrittweite h = 3
356
16
1
11
9
1
13
15
6
16
15
13
17
13.1
Sortierverfahren
Der Algorithmus betrachtet jetzt immer Elemente mit Abstand 3. Dadurch ergeben
sich drei ineinander verzahnte Teil-Arrays, die durch den Algorithmus sortiert werden. Damit ergibt sich das folgende Ergebnis:
1
2
6
3
10
4
1
6
4
7
11
5
2
10
5
8
12
9
3
11
9
15
13
16
7
12
16
14
17
8
13
17
15
14
Abbildung 13.10 Beispiel einer h-Sortierung
Das Ergebnis ist also ein Array, in dem alle Teilauswahlen von Elementen mit
Abstand h korrekt sortiert sind. Wir nennen diese »schwache« Form von Sortierung
eine h-Sortierung.
Betrachten Sie jetzt das folgende Programm:
shellsort( int n, int *daten)
{
int h;
A
for( h = 1; h <= n/9; h = 3*h+1)
;
B
C
for( ; h > 0; h /= 3)
insertion_h_sort( n, daten, h);
}
Listing 13.5 Die Shellsort-Implementierung
Am Anfang wird die Schrittweite h immer mit 3 multipliziert und dann noch um 1
vergrößert. Dadurch ergibt sich eine Folge von h-Werten1. Wenn die Schleife abgebron
chen wird, ist h ≈ --- (A).
3
In der zweiten Schleife wird dann, wegen der Division ohne Rest, exakt die gleiche
Folge wieder rückwärts durchlaufen (B). Für n = 10000 ergibt sich dadurch z. B. die
Folge:
n+1
– 1 , aber das soll uns hier nicht interessieren.
1 Exakt ist das die Folge h n = 3---------------------2
357
13
13
Sortieren
1
4
13
40
121
364
1093
3280
3280
1093
364
121
40
13
4
1
Da für jeden h-Wert der Folge die Funktion insertion_h_sort gerufen (C) wird, wird
mit fallender Schrittweite h immer wieder eine h-Sortierung durchgeführt. Das führt
am Ende dazu, dass das Array sortiert ist, da h = 1 als letzter Wert der Folge vorkommt.
Wir haben also ein neues Sortierverfahren gefunden, das wir noch optimieren können, wenn wir die Funktion insertion_h_sort direkt im übergeordneten Programm
implementieren:
void shellsort( int n, int *daten)
{
int i, k, h, v;
A
A
A
A
A
A
A
for( h = 1; h <= n/9; h = 3*h+1)
;
for( ; h > 0; h /= 3)
{
for( i = h; i < n; i++)
{
v = daten[i];
for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
daten[k] = daten[k-h];
daten[k] = v;
}
}
}
Listing 13.6 Die optimierte Shellsort-Implementierung
358
13.1
Sortierverfahren
Im Bereich (A) ist jetzt insertion_h_sort direkt in die Implementierung integriert.
Dass die Daten durch Shellsort sortiert werden, steht außer Frage, da ja für h = 1 Insertionsort durchgeführt wird. Es drängt sich natürlich die Frage auf, warum man den
Algorithmus derart verkompliziert und nicht sofort mit Insertionsort eine Sortierung durchführt.
Eine plausible Antwort auf diese Frage ist nicht einfach. Der Vorteil liegt, grob gesprochen, darin, dass Shellsort zunächst weiträumige Vertauschungen im Array durchführt, während Insertionsort mit vielen (zu vielen) Nachbarvergleichen und -vertauschungen arbeitet. Wenn Shellsort schließlich mit h = 1 Insertionsort durchführt,
ist das Array schon so geschickt vorsortiert, dass Insertionsort hier viel effizienter
abläuft als auf einem nicht vorsortierten Array. Sie werden später sehen, dass Shellsort in der Praxis viel effizienter arbeitet als Insertionsort und den scheinbaren
Mehraufwand geradezu spielend kompensiert.
Auch in den Schnappschüssen zeigt sich für Shellsort ein ganz anderes Bild als bei
den bisherigen Verfahren:
13
Abbildung 13.11 Der Sortierverlauf bei Shellsort
13.1.5
Quicksort
Wir werden jetzt ein Sortierverfahren konstruieren, das auf dem Prinzip »Teile und
herrsche« beruht und rekursiv arbeitet. Das Prinzip ist einfach:
Zerlege das Array in zwei Teile, wobei alle Elemente des ersten Teils kleiner oder
gleich allen Elementen des zweiten Teils sind. Die beiden Teile können jetzt
unabhängig voneinander betrachtet werden, da beim Sortieren keine Elemente
mehr von dem einen Teil in den andern bewegt werden müssen.
Zerlege jedes der beiden Teile aus dem vorherigen Schritt in gleicher Weise wieder in zwei Teile.
Setze den Prozess des Zerlegens fort, bis die Zerlegungsprodukte nur noch ein
Element haben und damit sortiert sind.
359
13
Sortieren
Besonders effizient ist dieses Verfahren, wenn es gelingt, die beiden Teile, in die wir
zerlegen, immer in etwa gleich groß zu halten.
Zur Zerlegung des Arrays konstruieren wir einen sogenannten Pivot2. Dabei handelt
es sich um ein von der Größe her möglichst in der Mitte liegendes Element mit der
Eigenschaft, dass alle Elemente links vom Pivot kleiner (im Sinne von ≤) und alle Elemente rechts vom Pivot größer (im Sinne von ≥) als der Pivot sind. Der Pivot selbst ist
unter diesen Voraussetzungen bereits richtig platziert und muss bei der weiteren
Verarbeitung nicht mehr betrachtet werden.
Abbildung 13.12 zeigt die Verfahrensidee:
rechts
links
Pivot
alle Werte ≤ x
x
alle Werte ≥ x
Abbildung 13.12 Die Verfahrensidee bei Quicksort
Die Rekursion bricht ab, wenn wir durch Zerlegung auf Arrays mit einem oder gar
keinem Element stoßen, bei denen ja nichts mehr zu sortieren ist.
Wenn wir annehmen, dass wir bereits über eine Funktion
int aufteilung( int links, int rechts, int *daten)
verfügen, die die gewünschte Aufteilung vornimmt und uns für die weitere Verarbeitung den Index des Pivots, also die Stelle, an der aufgeteilt wurde, zurückgibt, können
wir das Hauptprogramm wie folgt realisieren:
2 frz. Pivot = Dreh- und Angelpunkt
360
13.1
A
Sortierverfahren
void qcksort( int links, int rechts, int *daten)
{
int i;
B
if( links < rechts)
{
i = aufteilung( links, rechts, daten);
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}
C
D
E
Listing 13.7 Die Quicksort-Implementierung
In der Schnittstelle wird mit links und rechts der Bereich der Daten übergeben, die
sortiert werden sollen (A). Wenn hier links < rechts ist, dann muss weiter aufgeteilt
werden (B). In diesem Fall wird der Index des Pivots bestimmt und das Array dadurch
in zwei Teile geteilt (C). Von diesem geteilten Bereich wird nun links vom Pivot sortiert (D) und danach rechts vom Pivot (E).
13
Unklar bleibt dabei zunächst noch, wie wir die Aufteilung konstruieren können.
Natürlich sollte der Pivot vom Wert her möglichst mittig liegen, um eine gleichmäßige Aufteilung zu gewährleisten. Aber, um das wertmäßig in der Mitte liegende Element zu finden, müsste man schon sortiert haben. Als Pivot wählen wir einen mehr
oder weniger zufälligen Wert, von dem wir hoffen, dass er zentral in den Daten liegt.
Wir wählen einfach das letzte Element des Arrays, ernennen es zum Pivot und versuchen es dann, durch geschickte Umordnung, an seine exakte Position bringen. Sie
können sich das Verfahren an einem Beispiel mit der folgenden Ausgangslage klarmachen:
Dies ist der gewählte Pivot.
3
12
5
2
14
9
8
11
4
1
10
16
7
6
17
15
Abbildung 13.13 Die Ausgangslage für die Aufteilung
Jetzt müssen wir eine Ordnung herstellen, in der alle Elemente links vom Pivot kleiner als der Pivot und rechts vom Pivot größer als der Pivot sind. Wir arbeiten uns
dazu von den Ecken des Arrays zur Mitte hin vor und überspringen alle Elemente, die
im Sinne der angestrebten Aufteilung bereits korrekt platziert sind.
361
13
13
Sortieren
Hier ist alles in Ordnung (≤ 13).
3
12
5
2
14
9
Hier ist alles in Ordnung (≥ 13).
8
11
4
1
10
16
7
6
17
15
13
7
14
17
15
13
Diese beiden Werte sind falsch,
also werden sie getauscht.
3
12
5
2
6
9
8
11
4
1
10
16
Abbildung 13.14 Vorgehensweise bei der Aufteilung
Wenn es nicht mehr weitergeht, vertauschen wir die beiden Elemente, die unser weiteres Vorgehen blockiert haben, und können uns danach weiter zur Mitte vorarbeiten.
Hier ist alles in Ordnung (≤ 13).
3
12
5
2
6
9
8
11
Hier ist alles in Ordnung (≥ 13).
4
1
10
16
7
14
17
15
13
15
13
Diese beiden Werte sind falsch,
also werden sie getauscht.
3
12
5
2
6
9
8
11
4
1
10
7
16
14
17
Treffpunkt
Abbildung 13.15 Die Vertauschung bei einer Blockade
Nachdem wir die nächste Blockade beseitigt haben, stoßen wir bei unserem Vorgehen von links und rechts aufeinander. Links vom Treffpunkt ist jetzt alles kleiner und
rechts vom Treffpunkt alles größer als der Pivot (13). Abschließend tauschen wir noch
den Pivot mit dem Element rechts vom Treffpunkt:
362
13.1
3
12
5
2
6
9
8
11
4
1
10
7
16
14
17
Sortierverfahren
15
13
Tausche den Pivot mit der
Zahl rechts vom Treffpunkt.
3
12
5
2
6
9
8
11
4
1
10
7
13
14
17
15
16
Abbildung 13.16 Abschließender Tausch des Pivots
Die gewünschte Aufteilung ist damit hergestellt. Der Pivot ist irgendwo – hoffentlich
halbwegs in der Mitte – gelandet, und links vom Pivot ist alles kleiner, rechts vom Pivot
alles größer. Der linke und der rechte Teil des Arrays können jetzt unabhängig voneinander sortiert werden. Der Pivot hat bereits seine endgültige Position gefunden.
Die Aufteilung muss noch implementiert werden:
13
int aufteilung( int links, int rechts, int *daten)
{
int pivot, i, j, t;
A
B
C
D
E
F
G
H
I
pivot = daten[rechts];
i = links-1;
j = rechts;
for(;;)
{
while( daten[++i] < pivot)
;
while( (j > i) && (daten[--j] > pivot))
;
if( i >= j)
break;
t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
daten[rechts] = daten[i];
daten[i] = pivot;
return i;
}
Listing 13.8 Die Implementierung der Aufteilung
363
13
Sortieren
Zu Beginn ist der Wert ganz rechts der Pivot (A). Die Aufteilung startet nun mit zwei
Fingern links (B) und rechts (C) vom aufzuteilenden Bereich. Solange links alles in
Ordnung ist, wird nach rechts gegangen (D), danach wird, solange rechts alles in Ordnung ist, nach links gegangen (E).
Wenn die Bedingung i >= j zutrifft (F), haben sich die Finger getroffen, und die Ausführung geht nach dem break bei (H) weiter. Andernfalls werden die blockierenden
Elemente getauscht (G). Die Schleife läuft so lange, bis es zu dem oben beschriebenen
Abbruch kommt, wenn sich die Finger getroffen haben. Nach diesem Abbruch wird
der Pivot mit dem Element rechts vom Treffpunkt getauscht (H), abschließend wird
der Index des Pivots zurückgegeben, und die Aufteilung ist abgeschlossen (I).
Beachten Sie, dass ich in diesem Programm die Operatoren ++ und -- in Präfixnotation verwende. Dies bedeutet, dass in daten[++i] und daten[--i] das Inkrement bzw.
Dekrement von i bzw. j durchgeführt wird, bevor der Zugriff in das Array erfolgt.
Eine gewisse Effizienzsteigerung erreichen wir dadurch, dass wir auf den Funktionsaufruf von aufteilung verzichten und die Funktion innerhalb von qcksort realisieren.
void qcksort( int links, int rechts, int *daten)
{
int pivot, i, j, t;
A
A
A
A
A
A
A
A
A
A
A
A
A
A
A
A
A
364
if( rechts > links)
{
pivot = daten[rechts];
i = links-1;
j = rechts;
for( ; ;)
{
while( daten[++i] < pivot)
;
while((j > i) && (daten[--j] > pivot))
;
if( i >= j)
break;
t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
daten[rechts] = daten[i];
daten[i] = pivot;
13.1
Sortierverfahren
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}
Listing 13.9 Die Implementierung von Quicksort mit integrierter Aufteilung
Die Sortierung des gesamten Arrays wird dann durch den Aufruf
qcksort( 0, n-1, daten)
veranlasst. Die Standardschnittstelle unserer Sortierverfahren konnten wir nicht
implementieren, da die Funktion qcksort rekursionsfähig sein musste. Aus Gründen
der Einheitlichkeit versehen wir jetzt aber noch Quicksort mit der gleichen Schnittstelle wie die anderen Sortierverfahren:
void quicksort( int n, int *daten)
{
qcksort( 0, n-1, daten);
}
13
Listing 13.10 Implementierung von Quicksort mit unserer Standardschnittstelle
Auch hier habe ich wieder ein paar Schnappschüsse gemacht. Da immer zuerst ein
rekursiver Abstieg in die linke Hälfte des Arrays erfolgt, ergibt sich ein Aufbau des
sortierten Arrays von links nach rechts. Das heißt, zunächst wird der linke Teil vollständig sortiert, bevor der rechte Teil in Angriff genommen wird. Für alle weiteren
Unterteilungen gilt das in gleicher Weise:
Abbildung 13.17 Der Sortierverlauf bei Quicksort
Sie sehen, wie immer kleinere Pakete entstehen, die unabhängig voneinander bearbeitet werden können, da alle Werte in einem Paket größer als die Werte in den linken Nachbarpaketen bzw. kleiner als die Werte in den rechten Nachbarpaketen sind.
Unter dem Vergrößerungsglas erkennen Sie, wie sich die beiden ersten Pakete gebildet haben:
365
13
Sortieren
Pivot
Abbildung 13.18 Der Sortierverlauf im Detail
Die Implementierung von Quicksort verwendet Rekursion. Im Abschnitt über Rekursion haben Sie erfahren, dass Rekursion immer vermeidbar ist und dass gute nicht
rekursive Alternativen laufzeiteffizienter sind, weil die Laufzeitkosten für die Unterprogrammaufrufe vermieden werden. Ich möchte Ihnen daher zeigen, wie Sie die
Rekursion bei Quicksort vermeiden können. Wir betrachten dazu noch einmal die
»Urversion« unserer Quicksort-Implementierung, bei der die Aufteilungsfunktion
noch nicht integriert war:
void qcksort( int links, int rechts, int *daten)
{
int i;
if( links < rechts)
{
i = aufteilung( links, rechts, daten);
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}
Listing 13.11 Unsere ursprüngliche Implementierung von Quicksort
366
13.1
Sortierverfahren
Rekursion arbeitet so, dass die lokalen Variablen einer Funktion auf den Stack gelegt
werden und damit für jede Aufrufinstanz der Funktion separat zur Verfügung stehen. Ist das Unterprogramm beendet, werden die Variablen des rufenden Programms wiederhergestellt, und es kann weiterarbeiten, als wäre nichts geschehen.
Wenn wir einen kleinen Stack nachbilden und dort die Werte für links und rechts zwischenspeichern, können wir die Rekursion vermeiden. Ein Stack ist nichts anderes als
ein Stapel, auf den oben etwas gelegt und dem von oben wieder etwas entnommen
werden kann3. Was zuletzt auf den Stapel gelegt wurde, kommt als Erstes wieder herunter. Man spricht deswegen auch von einem Last-In-First-Out- oder kurz LIFOSpeicher.
Zur Implementierung eines Stacks benötigen Sie ein Array und einen Zeiger (Stackpointer) auf das oberste Element des Stapels (Stacktop). Das kann z. B. so aussehen:
A
B
int stack[100];
int pos = 0;
int i;
13
C
D
E
printf( "Push: ");
for( i = 0; i < 8; i++)
{
printf( "%d " , i);
stack[pos++] = i;
}
printf( "\nPop: ");
while( pos)
{
i = stack[--pos];
printf( "%d ", i);
}
Listing 13.12 Implementierung eines einfachen Stacks
Zuerst wird ein Stack für 100 Integer-Zahlen angelegt (A). Der zugehörige Stackpointer pos zeigt immer auf die nächste freie Position (B). Als Nächstes wird in der Schleife
jeweils eine Zahl auf den Stack gelegt, und der Stackpointer wird inkrementiert (C).
Diese Stack-Operation heißt Push (Teller auf Stapel legen). Nachdem wir den Stack
gefüllt haben, entnehmen wir nun Elemente vom Stack. Dazu testen wir jeweils, ob
noch etwas auf dem Stack liegt (D). Ist das der Fall, wird der Stackpointer dekrementiert, und eine Zahl wird vom Stack entfernt (E). Diese Stack-Operation heißt Pop (Teller vom Stapel nehmen).
3 Denken Sie dabei an den Tellerstapel im China-Restaurant.
367
13
Sortieren
Wir erhalten für unsere Stack-Operationen das folgende Ergebnis:
Push: 0 1 2 3 4 5 6 7
Pop: 7 6 5 4 3 2 1 0
Achtung: Die Implementierung dieses Stacks ist insofern unvollständig, als ein Speicherüberlauf (Stack Overflow) nicht abgefangen wird, aber das soll uns hier nicht
stören.
In der Funktion qcksort legen wir jetzt einen Stack an. Dieser Stack übernimmt die
Rolle einer Warteschlange für Sortieraufträge. Die Funktion liest ihre Sortieraufträge
vom Stack und erzeugt, wenn es erforderlich ist, neue Sortieraufträge auf dem Stack.
Ein Sortierauftrag bezieht sich dabei immer auf einen Teilbereich des Arrays – also
auf den linken und rechten Randpunkt des zu sortierenden Teilbereichs:
void qcksort( int links, int rechts, int *daten)
{
int i;
rekursive Version
if( links < rechts)
{
i = aufteilung( links, rechts, daten);
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);void qcksortiter(int links, int rechts, int *daten)
{
}
int i;
}
int stack[256];
Stack und Stackpointer
int pos = 0;
nicht rekursive
Version
stack[pos++] = links;
stack[pos++] = rechts;
der erste Auftrag
solange noch Aufträge in der
Warteschlange sind
while( pos)
{
rechts = stack[--pos];
Lies den nächsten
links = stack[--pos];
Auftrag vom Stack!
if( links < rechts)
{
Bearbeite den Auftrag!
i = aufteilung( links, rechts, daten);
stack[pos++] = links;
stack[pos++] = i-1;
Erzeuge zwei neue
stack[pos++] = i+1;
Aufträge auf dem
stack[pos++] = rechts;
Stack!
}
}
}
Abbildung 13.19 Gegenüberstellung der rekursiven und rekursionsfreien QuicksortImplementierung
Die Reihenfolge der Abarbeitung der Pakete ist hier übrigens genau umgekehrt wie
beim ursprünglichen Quicksort. Wir legen jetzt das linke Paket zuerst auf den Stack
368
13.1
Sortierverfahren
und dann das rechte. Dadurch wird das rechte Paket zuerst heruntergeholt und bearbeitet. Aber das hat weder Einfluss auf das Ergebnis noch auf die Effizienz des Verfahrens.
Beachten Sie auch, dass der Stack eine begrenzte Größe hat. Das schränkt das Datenvolumen ein, das diese Funktion bearbeiten kann. Wie stark diese Einschränkung ist,
hängt von der Verteilung der Daten ab. Die benötigte Größe des Stacks entspricht der
doppelten Rekursionstiefe der alten Version, da in der neuen Version immer zwei
Zahlen auf den Stack kommen, wenn die alte Version in die Rekursion gegangen ist.
Bei optimaler Verteilung der Daten kann die Größe des Arrays mit jedem Rekursionsschritt halbiert werden. Somit könnte das Array in diesem Fall 2128 ≈ 3.4 · 1028 Elemente haben. Das ist mehr als ausreichend. Falls das Array aber bereits sortiert ist,
kann der zu betrachtende Bereich in jedem Verfahrensschritt nur um ein Element
verkleinert werden, und das Array könnte daher maximal 128 Elemente haben. Hier
ist also Vorsicht geboten.
Im oben dargestellten Programm lässt sich der Aufruf der Funktion aufteilung, wie
schon bei der rekursiven Variante gezeigt, eliminieren. Bei dieser Gelegenheit stellen
wir das Programm auch auf unsere Standardschnittstelle um, da jetzt ja keine rekursionsfähige Schnittstelle mehr erforderlich ist: Damit erhalten wir die endgültige
Implementierung von Quicksort:
void quicksort(int n, int *daten)
{
int pivot, i, j, t;
int links, rechts;
int stack[256];
int pos = 0;
stack[pos++] = 0;
stack[pos++] = n-1;
while( pos)
{
rechts = stack[--pos];
links = stack[--pos];
A
A
A
A
if( links < rechts)
{
i = links-1;
j = rechts;
pivot = daten[rechts];
for(;;)
369
13
13
Sortieren
A
A
A
A
A
A
A
A
A
A
A
A
A
{
while( daten[++i] < pivot)
;
while((j > i) && (daten[--j] > pivot))
;
if( i >= j)
break;
t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
daten[ rechts] = daten[i];
daten[ i] = pivot;
stack[pos++]
stack[pos++]
stack[pos++]
stack[pos++]
}
=
=
=
=
links;
i-1;
i+1;
rechts;
}
}
Listing 13.13 Rekursionsfreie Implementierung und Eliminierung der Funktion aufteilung
Der mit (A) gekennzeichnete Bereich enthält die Aufteilung. Es gibt noch zahlreiche
weitere Möglichkeiten, dieses Programm weiter zu optimieren, aber das würde auf
Kosten der Lesbarkeit und Verständlichkeit des Programms gehen. Wir wollen es
daher bei dieser Version belassen. Im Moment wissen wir noch nicht, welchen Laufzeitgewinn wir durch diese Umstellung erzielt haben. Später werden wir die rekursive und die nicht rekursive Variante gegeneinander antreten lassen.
13.1.6
Heapsort
Bevor Sie das letzte Sortierverfahren kennenlernen, machen wir uns ein paar Gedanken über sogenannte Heaps4.
Sie können sich die Elemente eines Arrays wie in einem Baum angeordnet vorstellen.
4 engl. Heap = Haufen
370
13.1
0
1
2
3
4
5
6
7
8
9
10
Sortierverfahren
11
Abbildung 13.20 Array-Elemente als Baum angeordnet
Die Verweise von Knoten auf Folgeknoten bzw. Blätter sind dabei natürlich nicht
explizit, sondern nur gedanklich vorhanden. In etwas übersichtlicherer Darstellung
haben wir:
Linker Nachfolger
des Knotens i ist
der Knoten 2i + 1.
Rechter Nachfolger
des Knotens i ist
der Knoten 2i + 2.
0
13
1
2
3
7
4
8
9
5
10
6
11
Abbildung 13.21 Übersichtlichere Baumdarstellung
Damit wird zumindest gedanklich aus dem Array eine baumartige Struktur. An dem
Bild erkennen Sie auch, dass es einfache Rechenvorschriften gibt, um aus dem Index
eines Knotens den Index seines linken bzw. rechten Nachfolgers zu berechnen. Auf
diese Formeln werden wir noch zurückkommen.
Im Array, d. h. in den Knoten des Baums, können beliebige Zahlenwerte stehen.
Heap-Bedingung
Wir sagen, dass ein Baum die sogenannte Heap-Bedingung erfüllt, wenn für jeden
Knoten des Baums gilt, dass der Wert des Knotens größer oder gleich den Werten
seiner Nachfolgerknoten ist.
371
13
Sortieren
Einen Baum, der die Heap-Bedingung erfüllt, nennen wir einen Heap. Abbildung
13.22 zeigt einen Heap:
10
0
9
8
1
2
7
5
2
3
3
4
5
6
1
5
2
3
1
7
8
9
10
11
Abbildung 13.22 Darstellung eines Heaps
Ein Heap stellt eine Vorstufe zur Sortierung dar, denn im Heap ist der Wert an einem
Knoten immer größer (im Sinne von ≥) als die Werte an allen nachfolgenden Knoten.
Insbesondere steht das größte Element ganz oben in der Wurzel. Dadurch können Sie
einem Heap sehr einfach das größte Element entnehmen.
Stellen Sie sich jetzt vor, dass die Heap-Bedingung an einer Stelle, etwa durch Wertänderung eines einzelnen Elements, verletzt ist. Zum Beispiel haben wir im obigen
Heap den Wert 10 an der Wurzel durch 4 ersetzt:
Hier ist die HeapBedingung gestört.
4
9
8
7
1
5
5
2
2
3
1
Abbildung 13.23 Eine gestörte Heap-Bedingung
372
3
13.1
Sortierverfahren
In dieser Situation gibt es eine sehr einfache und elegante Strategie, um die HeapBedingung wiederherzustellen. Sie müssen das störende Element nur mit seinem
größten Nachfolger tauschen und dann diesen Tauschprozess, im Baum absteigend,
so lange fortsetzen, bis alles wieder in Ordnung ist. Sie verlagern das Problem so sukzessive weiter nach unten, bis es sozusagen unten aus dem Baum herauswächst. In
unserem Beispiel tauschen wir 4 mit 9, dann 4 mit 7 und letztlich 4 mit 5. Dann haben
wir wieder einen intakten Heap:
Die Heap-Bedingung ist
wiederhergestellt.
9
9
4
7
7
8
4
13
5
5
2
3
4
5
1
4
2
3
1
Abbildung 13.24 Wiederherstellung der Heap-Bedingung
Auch wenn Ihnen sicherlich noch nicht klar ist, was diese Überlegungen konkret mit
unserer Sortieraufgabe zu tun haben, können wir diesen Reparaturalgorithmus für
Heaps ja mal programmieren:
A
B
C
D
E
F
G
H
void adjustheap( int n, int *daten, int k)
{
int j, v;
v = daten[k];
while( k < n/2)
{
j = 2*k+1;
if( (j < n-1) && (daten[j] < daten[j+1]))
j++;
if( v >= daten[j])
break;
daten[k] = daten[j];
373
13
Sortieren
H
I
J
daten[k] = daten[j];
k = j;
}
daten[k] = v;
}
Listing 13.14 Funktion zur Wiederherstellung der Heap-Bedingung
Unsere Funktion zur Reparatur erhält als Parameter die Größe des Heaps n, den Heap
selbst und den Index k des Störenfrieds (A).
Zuerst wird in (B) der Störenfried aus dem Heap genommen. Im Anschluss startet
eine Schleife, die so lange fortfährt, wie der betrachtete Knoten noch mindestens
einen Nachfolger hat (C). In dieser Schleife wird der Index j zunächst auf den linken
Nachfolger gesetzt (D). Falls es aber einen rechten Nachfolger gibt und dieser größer
ist als der linke (E), dann gehe zum rechten Nachfolger (j++). Falls der Störenfried größer ist als sein größter Nachfolger (F), muss nicht weiter abgestiegen werden (G).
Andernfalls wird der größte Nachfolger eine Ebene hochgezogen (H) und an dessen
Knoten weitergemacht (I). Abschließend wird in (J) der Störenfried in den frei gewordenen Knoten gelegt, der Heap ist repariert.
Mit diesem Hilfsprogramm können wir unser letztes Sortierverfahren realisieren.
Wir bauen dazu zunächst vom rechten Rand des Arrays her einen Heap im Array auf.
Dann nehmen wir das größte Element vom Heap, vertauschen es mit dem letzten
Element im Array und stellen die Heap-Bedingung in dem um ein Element verkleinerten Baum wieder her. Jetzt steht das größte Element am Ende des Arrays, und die
n-1 Elemente davor bilden wieder einen Heap. Damit steht das zweitgrößte Element
am Anfang. Wir tauschen es jetzt durch das vorletzte Element im Array aus und
adjustieren wieder den Heap, der jetzt nur noch n-2 Elemente hat. Dadurch wird das
drittgrößte Element nach vorn geholt. Im Code liest sich das wie folgt:
void heapsort( int n, int *daten)
{
int k, t;
A
B
C
374
for( k = n/2; k;)
adjustheap( n, daten, --k);
while( --n)
{
t = daten[0];
daten[0] = daten[n];
daten[n] = t;
13.1
D
Sortierverfahren
adjustheap( n, daten, 0);
}
}
Listing 13.15 Die Implementierung von Heapsort
In (A) wird von hinten nach vorn im Array ein Heap aufgebaut, die Erklärung dazu
erhalten Sie im Anschluss. Solange sich der Heap durch Abtrennen des letzten Elements verkleinern lässt (B), tausche das erste (größte) Element mit dem letzten (C),
und repariere den um ein Element verkleinerten Heap (D).
Zum Aufbau des Heaps im Array möchte ich Ihnen nun noch ein paar Erklärungen
geben. Wir bekommen ja ein beliebiges unsortiertes Array vorgelegt. Die hintere
Hälfte des Arrays ist dabei ungeachtet der Datenwerte immer für einen Heap geeignet, da diese Knoten keine Nachfolgerknoten haben und die Heap-Bedingung für
diese Knoten irrelevant ist.
13
0
1
2
3
4
Hier wird der Heap aufgebaut.
5
6
7
8
9
10
11
Hier ist alles in Ordnung.
Abbildung 13.25 Der Aufbau des Heaps
In der vorderen Hälfte gehen wir Schritt für Schritt zurück und integrieren das jeweils
neue Element an der Wurzel durch adjustheap in den im Aufbau befindlichen Heap.
Wenn wir vorn angekommen sind, ist der Heap (noch nicht die Sortierung) fertig.
Aus diesem Heap erzeugen wir dann die Sortierung, indem wir immer das größte Element ganz vorn mit dem letzten Element tauschen und den dadurch an der Wurzel
gestörten, um ein Element verkleinerten Heap wieder reparieren.
Dieser Algorithmus ist sicher nicht leicht zu verstehen, aber er ist einer der faszinierendsten Algorithmen der Informatik. Wenn Sie diesen Algorithmus verstehen, werden Sie jeden Algorithmus verstehen.
Ich habe auch von diesem Algorithmus Schnappschüsse gemacht:
375
13
Sortieren
Abbildung 13.26 Der Sortierverlauf bei Heapsort
Diesmal habe ich aber den ersten Schnappschuss nicht ganz am Anfang genommen,
sondern gewartet, bis der Heap aufgebaut war. Anschließend (Bilder 2, 3, 4)
schrumpfte der Heap, und die Sortierung baute sich von rechts nach links auf.
13.2
Leistungsanalyse der Sortierverfahren
In diesem Abschnitt möchten wir Laufzeitformeln für unsere Sortierverfahren herleiten, da wir ja noch immer auf der Suche nach dem besten aller Verfahren sind.
13.2.1
Bubblesort
Algorithmen wie Bubblesort haben wir schon häufig betrachtet. Sie wissen, dass der
Code in der inneren Schleife
n(n – 1)
( n – 1 ) + ( n – 2 ) + … + 2 + 1 = -------------------2
mal ausgeführt wird. Eine Überdeckungsanalyse mit 1000 zufällig gewählten Zahlen
bestätigt dies (siehe Abbildung 13.27).
Die Überdeckung zeigt, dass bei zufälliger Anfangsverteilung der Daten etwa in der
Hälfte der Fälle in der inneren Schleife getauscht werden muss. Wenn wir davon ausgehen, dass die mittlere Laufzeit in der inneren Schleife cbub ist, erhalten wir für Bubblesort die folgende Laufzeitformel:
n(n – 1)
t bub ( n ) = c bub -------------------- ≈ n 2
2
Bubblesort hat also eine quadratische Laufzeit. Was das im Vergleich zu den anderen
Algorithmen wert ist, werden Sie im Weiteren sehen.
376
13.2
Leistungsanalyse der Sortierverfahren
n = 1000
void bubblesort( int n, int *daten)
1
{
int i, k, t;
n–1
999
for( i = n-1; i > 0; i--)
{
for( k = 0; k < i; k++)
{
if( daten[k] > daten[k+1])
{
t = daten[k];
daten[k] = daten[k+1];
daten[k+1] = t;
}
}
}
}
499500
n(n) – 1
2
239518
≈
n(n – 1)
4
cbub
Abbildung 13.27 Überdeckungsanalyse für Bubblesort
13.2.2
13
Selectionsort
Ähnliche Überlegungen wie bei Bubblesort wenden wir jetzt auf Selectionsort an.
Zunächst zeigt der Überdeckungstest bei identischer Ausgangssituation das folgende
Bild:
n = 1000
void selectionsort( int n, int *daten)
1
n–1
n(n – 1)
2
999
499500
5419
999
{
int i, k, t, min;
for( i = 0; i < n-1; i++)
{
min = i;
for( k = i+1; k < n; k++)
{
if( daten[k] < daten[min])
min = k;
}
t = daten[min];
daten[min] = daten[i];
daten[i] = t;
}
}
csel2
csel1
Abbildung 13.28 Überdeckungsanalyse für Selectionsort
377
13
Sortieren
Daraus folgt:
n(n – 1)
t sel ( n ) = c sel1 -------------------- + c sel2 ( n – 1 ) ≈ n 2
2
Selectionsort ist also, wie Bubblesort, von quadratischer Laufzeit. Für kleine Werte
von n mag Selectionsort wegen des Terms csel2(n – 1) schlechter sein als Bubblesort.
Für große Werte von n ist Selectionsort aber wegen der offensichtlich besseren Laufzeit im Kern (csel1 < cbub) sicherlich schneller als Bubblesort – vielleicht drei- bis fünfmal so schnell.
13.2.3
Insertionsort
Auch bei Insertionsort haben wir zwei ineinander geschachtelte Schleifen zu analysieren. Hier wird jedoch die innere Schleife über eine zusätzliche Bedingung kontrolliert (daten[k-1] > v) und gegebenenfalls vorzeitig abgebrochen. Bei zufällig
verteilten Daten können wir davon ausgehen, dass diese Bedingung im Mittel bei der
Hälfte des zu durchlaufenden Indexbereichs erfüllt ist, die Schleife also im Durchschnitt auf halber Strecke abgebrochen werden kann. Bei einer ungünstigen Verteilung der Daten muss jedoch der gesamte Bereich durchlaufen werden. Diese
Überlegung lässt vermuten, dass Insertionsort sehr sensibel auf eine Vorsortierung
in den Daten reagieren wird. Je besser die Vorsortierung ist, desto schneller wird
Insertionsort sein. Im Moment bleiben wir aber bei zufällig sortierten Daten und
sehen unsere Vermutung, dass die innere Schleife zur Hälfte durchlaufen wird, bestätigt:
n = 1000
void insertionsort( int n, int *daten)
{
1
n–1
999
239518
≈
n(n – 1)
4
int i, k, v;
for( i = 1; i < n; i++)
{
v = daten[i];
for( k = i; (k>=1)&& (daten[k-1] > v); k--)
daten[k] = daten[k-1];
cins1
daten[k] = v;
}
}
Abbildung 13.29 Überdeckungsanalyse für Insertionsort
Bei zufällig verteilten Daten ergibt sich damit:
n( n – 1)
t ins ( n ) = c ins1 -------------------- + c ins2 ( n – 1 ) ≈ n 2
4
378
cins2
13.2
Leistungsanalyse der Sortierverfahren
1
Das ist wieder ein quadratisches Verfahren, aber wegen cins1 ≈ -- csel1 könnte Insertion2
sort doppelt so schnell wie Selectionsort sein.
13.2.4
Shellsort
Die bisher betrachteten Sortierverfahren hatten ein quadratisches Laufzeitverhalten,
weil sie aus zwei verschachtelten Schleifen bestanden, die beide linear durchlaufen
wurden. Die Unterschiede lagen im Wesentlichen im Berechnungsaufwand innerhalb der Schleifen. Bei Shellsort finden wir sogar drei ineinander verschachtelte
Schleifen. Die Befürchtung, dass daraus ein kubisches Laufzeitverhalten resultieren
könnte, kann jedoch schon durch den Überdeckungstest zerstreut werden. Im Überdeckungstest ergeben sich bei gleichem Datenvolumen deutlich kleinere Anzahlen
von Schleifendurchläufen als bei den zuvor betrachteten Sortierverfahren:
n = 1000
void shellsort( int n, int *daten)
1
13
{
int i, k, h, v;
5
4821
9690
for( h = 1; h <= n/9; h = 3*h+1)
;
for( ; h > 0; h /= 3)
{
for( i = h; i < n; i++)
{
v = daten[i];
for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
daten[k] = daten[k-h];
daten[k] = v;
}
}
}
Abbildung 13.30 Überdeckungsanalyse für Shellsort
Wie oft die inneren Schleifen aber wirklich durchlaufen werden (hier 4821 bzw. 9690),
konnte bisher nicht allgemein berechnet werden, zumal hier ja auch noch die spezielle Wahl der Distanzenfolge (hier 1, 4, 7, ...) eine wichtige Rolle spielt. Man kennt relativ
schlechte und relativ gute Distanzenfolgen. Die hier gewählte Folge ist z. B. als relativ
gut bekannt. Aber man kennt nicht »die beste« Distanzenfolge. Für das oben darge5⁄
stellte Programm wird ein asymptotisches Verhalten wie n(log(n))2 oder n 4 vermutet. Bewiesen ist aber keine der beiden Vermutungen.
379
13
Sortieren
Eine Abschätzung, die wir hier nicht beweisen wollen, besagt, dass Shellsort für die
3⁄
hier gewählte Distanzenfolge asymptotisch nicht schlechter als n 2 und damit
zumindest für entsprechend große Arrays besser als Bubblesort, Insertionsort und
Selectionsort ist. Auch in der Praxis zeigt Shellsort eine deutlich bessere Performance
als die zuvor diskutierten Verfahren.
13.2.5
Quicksort
Im Gegensatz zu Shellsort gibt es über das Laufzeitverhalten von Quicksort reichhaltige Untersuchungen mit konkreten Ergebnissen.
Der Überdeckungstest zeigt bei Quicksort erfreulich niedrige Zahlen, noch niedriger
als bei Shellsort:
void qcksort(int links, int rechts, int *daten)
1333
{
int pivot, i, j, t;
666
2407
2407
2407
666
1741
666
if( links < rechts)
{
i = links-1;
j = rechts;
pivot = daten[rechts];
for(;;)
{
while( daten[++i] < pivot)
;
while( (j > i) && (daten[--j] > pivot))
;
if( i >= j)
break;
t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
daten[ rechts] = daten[i];
daten[ i] = pivot;
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}
Abbildung 13.31 Überdeckungsanalyse für Quicksort
Um qualitative Aussagen zur Laufzeit zu gewinnen, betrachten wir noch einmal die
Aufrufstruktur von Quicksort:
380
13.2
Leistungsanalyse der Sortierverfahren
rechts
links
Pivot
x
alle Werte ≥ x
log(n)
alle Werte ≤ x
n
Abbildung 13.32 Aufrufstruktur von Quicksort
Wenn wir eine in etwa zentrierte Lage des Pivots unterstellen, hat Quicksort wegen
der fortlaufenden Halbierung der zu betrachtenden Teilbereiche eine Rekursionstiefe von log(n). Auf jedem Teilbereich arbeitet dann das Unterprogramm aufteilung. Sie erinnern sich, dass in diesem Unterprogramm linear von den Ecken des
aufzuteilenden Bereichs zu einem Treffpunkt vorgerückt wurde, wobei gelegentlich
Vertauschungen durchgeführt werden mussten. Selbst wenn bei jedem Schritt eine
Vertauschung erforderlich wäre, käme dabei nicht mehr als eine linear wachsende
Laufzeit heraus. Da auf jeder Rekursionsebene über alle Teilbereiche hinweg (maximal) n Elemente zu betrachten sind und das Unterprogramm aufteilung in jedem
dieser Teilbereiche mit linearer Zeitkomplexität arbeitet, ergibt sich für Quicksort
das folgende Laufzeitverhalten:
tqck(n) = n · log(n)
Damit ist Quicksort das effizienteste der bisher betrachteten Sortierverfahren.
Zumindest bei der Sortierung großer Arrays mit Zufallsdaten wird Quicksort die Nase
vorn haben. Sie sehen aber auch, dass die Wahl des Pivots die Schwachstelle von
Quicksort ist. Liegt der Pivot ungünstig, wird ein Teilbereich immer nur um 1 verkleinert, was eine Rekursionstiefe von n und damit (Worst Case) eine quadratische Laufzeit zur Folge hätte.
13.2.6
Heapsort
n
In der Funktion Heapsort wird n – 1 + --- -mal adjustheap gerufen. Das zeigt auch die
2
Überdeckungsanalyse:
381
13
Sortieren
n = 1000
void heapsort( int n, int *daten)
1
{
int k, t;
n
2
for( k = n/2; k;)
adjustheap( n, daten, --k);
while( --n)
{
t = daten[0];
daten[0] = daten[n];
daten[n] = t;
adjustheap( n, daten, 0);
}
}
500
n–1
999
Abbildung 13.33 Überdeckungsanalyse für Heapsort
Die Funktion adjustheap war aber eine Funktion, die einen Baum in der Tiefe durchlief, und wir wissen bereits, dass ein gleichmäßig aufgefüllter Baum mit n Elementen
die Tiefe log(n) hat:
9
9
4
7
7
log(n)
13
8
4
5
5
2
4
5
1
4
2
3
1
n
Abbildung 13.34 Tiefe des gleichmäßig gefüllten Baums
Damit ergibt sich für Heapsort wie für Quicksort:
theap(n) = n · log(n)
382
3
13.3
Leistungsmessung der Sortierverfahren
Obwohl Heapsort im asymptotischen Verhalten Quicksort entspricht, erwarten wir
aufgrund der aufwendigeren inneren Schleife ein schlechteres Laufzeitverhalten als
Quicksort. Allen anderen Verfahren dürfte Heapsort aber für hinreichend große
Datenmengen überlegen sein.
13.3
Leistungsmessung der Sortierverfahren
Bevor wir konkrete Messungen durchführen, fassen wir zusammen, was wir als
Ergebnis erwarten:
Es gibt drei Leistungsklassen:
1. Quicksort und Heapsort
2. Shellsort
3. Bubblesort, Selectionsort und Insertionsort
Für große Datenmengen müssten die Leistungsunterschiede zwischen den verschiedenen Klassen deutlich sichtbar werden. Von Quicksort erwarten wir die beste Performance. In der niedrigsten Leistungsklasse dürfte Insertionsort am besten und
Bubblesort am schlechtesten abschneiden. Shellsort ist schwer einzuschätzen.
Quicksort könnte problematisch sein, wenn spezielle Vorsortierungen im Array vorliegen, während Insertionsort von speziellen Vorsortierungen profitieren könnte.
Als Erstes testen wir mit zufällig verteilten Daten. Dazu verwenden wir den folgenden Testdatengenerator:
void testdaten1( int n, int *daten)
{
int i;
for( i = 0; i < n; i++)
daten[i] = rand()%n;
}
Zufallszahlen zwischen 0 und n-1
Abbildung 13.35 Testdatengenerator für die Sortierfunktionen
383
13
13
Sortieren
Für Arrays mit bis zu 10 Millionen Elementen erhalten wir dann die folgenden Messergebnisse:
testdaten1
Bubblesort
Selectionsort
Insertionsort
Shellsort
Quicksort
rekursiv
Quicksort
iterativ
Heapsort
100
0,01
0,01
0,00
0,00
0,00
0,00
1000
0,86
0,50
0,22
0,07
0,07
0,05
0,00
0,07
10000
150,26
39,72
18,90
1,05
0,86
0,66
0,88
14,60
100000
18159,78
3898,27
1938,95
9,10
7,59
11,29
1000000
ca. 30 min
ca. 6 min
3 min
456,57
85,17
150,36
10000000
ca. 2 Tage
ca. 12 std
ca. 6 std
4442,62
863,08
2507,95
Abbildung 13.36 Messergebnisse (in Millisekunden) für die Sortierfunktionen
Wie erwartet, ist die iterative Implementierung von Quicksort das schnellste Programm. Quicksort benötigt weniger als eine Sekunde, wenn Bubblesort bereits mehrere Tage rechnet.
Bevor Sie jetzt aber glauben, dass wir den besten Sortieralgorithmus bereits gefunden haben, testen wir noch mit anderen Datenverteilungen. Wir erstellen einen
Generator, der aufsteigend sortierte Daten mit »leichten Störungen« erzeugt:
void testdaten2( int n, int *daten)
{
int i;
for( i = 0; i < n; i++)
daten[i] = (9*i)/10 + rand()%(n/10);
}
10% Abweichung von aufsteigender Ordnung
Abbildung 13.37 Erstellung aufsteigend sortierter Testdaten mit leichten Störungen
Messungen führen wir jetzt nur noch für Insertionsort, Quicksort (iterativ) und
Heapsort durch, da die anderen Programme aufgrund der ersten Messung unwichtig
384
13.3
Leistungsmessung der Sortierverfahren
geworden sind und wir Insertionsort noch eine Chance geben wollen, sich bei vorsortierten Daten zu verbessern. Spannend ist die Frage, wie Quicksort jetzt abschneidet:
testdaten2
Insertionsort
Quicksort
iterativ
Heapsort
100
0,00
0,00
0,00
1000
0,03
0,05
0,07
10000
1,48
0,60
0,81
100000
142,68
7,34
9,84
1000000
4632,99
97,25
120,40
3796,46
1279,30
10000000
Abbildung 13.38 Vergleich der Laufzeiten (in Millisekunden) für leicht gestörte Testdaten
Sie sehen, dass sich Insertionsort deutlich verbessert, ohne jedoch Quicksort oder
Heapsort zu erreichen. Quicksort fällt für große Datenmengen hinter Heapsort
zurück.
Wir verschärfen die Situation dahingehend, dass das Array im vorderen Teil sortiert
ist und nur im hinteren Teil unsortierte Daten angefügt sind. Diese Verteilung
erzeugt uns der folgende Generator:
void testdaten3( int n, int *daten)
{
int i;
for( i = 0; i
daten[i]
for( ; i < n;
daten[i]
}
< (9*n)/10; i++)
= i;
i++)
= rand()%n;
Die letzten 10% der Daten sind
unsortiert.
Abbildung 13.39 Erstellung aufsteigend sortierter Testdaten mit unsortierten Daten am
Ende
385
13
13
Sortieren
Das ist übrigens ein durchaus realistisches Testszenario, wenn Sie sortierte Daten
haben und neue Daten hinzugefügt wurden, die einsortiert werden müssen:
testdaten3
Insertionsort
Quicksort
iterativ
Heapsort
100
0,00
0,00
0,00
1000
0,03
0,04
0,06
10000
3,64
0,49
0,68
100000
591,22
99,74
7,04
1000000
71322,23
15651,39
76,21
10000000
884,36
Abbildung 13.40 Vergleich der Laufzeiten (in Millisekunden) für Testdaten
mit unsortiertem Ende
Quicksort verschlechtert sich noch einmal und ist Heapsort jetzt deutlich unterlegen. Auch Insertionsort verschlechtert sich, bleibt aber besser als bei Zufallsdaten.
Das liegt daran, dass hier großräumigere Verschiebungen möglich sind als im vorherigen Testszenario.
In unserem letzten Testszenario gehen wir davon aus, dass die Daten im Wesentlichen korrekt sortiert sind und nur 10 % der Daten aufgrund von Schlüsseländerungen an der falschen Stelle stehen. Solche Daten erzeugen wir mit dem folgenden
Generator:
void testdaten4( int n, int *daten)
{
int i, k;
for( i = 0; i < n; i++)
daten[i] = i;
for( i = 0; i < n/10; i++)
{
k = rand()%n;
daten[k] = rand()%n;
}
}
10% zufällig gewählte Daten sind
unsortiert.
Abbildung 13.41 Erstellung von Testdaten mit teilweise unsortierten Daten
386
13.3
Leistungsmessung der Sortierverfahren
Quicksort verschlechtert sich noch einmal, während Heapsort unverändert bleibt.
Das schnellste Programm ist jetzt aber Insertionsort:
testdaten4
Insertionsort
Quicksort
iterativ
Heapsort
100
0,00
0,00
0,00
1000
0,03
0,06
0,06
10000
2,60
0,63
0,68
100000
75,01
1751,50
6,67
1000000
203,31
70,88
10000000
221,15
795,65
Abbildung 13.42 Vergleich der Laufzeiten (in Millisekunden) für teilweise
unsortierte Testdaten
Wir haben also folgendes Ergebnis:
왘
Bei zufällig verteilten Daten ist Quicksort das beste Sortierprogramm. Quicksort
kann aber empfindlich einbrechen, wenn die Daten teilsortiert sind.
왘
Insertionsort arbeitet am schnellsten, wenn zur Sortierung nur wenige Daten über
kleine Stecken bewegt werden müssen.
왘
Heapsort ist sehr robust gegenüber verschiedenen Vorsortierungen und erreicht
fast die Performance von Quicksort.
Man könnte versuchen, Quicksort durch eine bessere Wahl des Pivots robuster zu
machen. Dazu gibt es verschiedene Ansätze. Man könnte den Pivot zufällig wählen
oder drei zufällige Werte aus dem Array betrachten und den mittleren der drei auswählen. Aber keiner der Ansätze verbessert Quicksort in allen denkbaren Situationen, zumal solche Erweiterungen auch zusätzliche Rechenzeit verbrauchen.
Für welches Verfahren soll man sich entscheiden, wenn man keine Informationen
über die Verteilung der zu sortierenden Daten hat? Die Antwort lautet Introsort.
Introsort ist ein hybrides Verfahren, das die Vorteile von Quicksort, Heapsort und
Insertionsort zu kombinieren versucht. Introsort startet als Quicksort und beobachtet dabei die Tiefe des Abstiegs. Wenn dabei ein bestimmter Wert (z. B. 2 · log(n)) überschritten wird, schaltet das Verfahren auf Heapsort um (Stop Loss). Wenn am Ende
nur noch kleine Teilbereiche zu sortieren sind, wird die Feinarbeit mit Insertionsort
erledigt. Inzwischen hat Introsort Quicksort in den meisten Funktionsbibliotheken
abgelöst.
387
13
13
Sortieren
13.4
Grenzen der Optimierung von Sortierverfahren
Wir haben eine Reihe von Sortierverfahren unterschiedlicher Leistungsfähigkeit
gefunden, aber keinem der Verfahren ist es gelungen, die magische Grenze n · log(n)
zu unterbieten. Ist das unser Unvermögen, oder sind wir auf eine natürliche
Schranke – also quasi auf ein Naturgesetz – gestoßen? Diese Frage wollen wir jetzt diskutieren.
Ein Sortierverfahren erzeugt eine ganz bestimmte Permutation des zu sortierenden
Arrays. Wir wissen, dass es n! solcher Permutationen gibt. Ein Sortierverfahren, das
ein beliebiges Array mit n Elementen sortieren kann, muss also prinzipiell in der Lage
sein, alle möglichen Permutationen zu erzeugen, da es ja eine beliebig vorgegebene
Permutation rückgängig machen muss. Wenn das Verfahren dabei seine Informationen aus Einzelvergleichen zweier Elemente zieht, kann man Folgendes feststellen:
왘
Mit einem Vergleich können maximal zwei Permutationen erzeugt werden. Man
kann aufgrund des Vergleichs alles so lassen, wie es ist, oder eine ganz bestimmte
Vertauschung vornehmen.
왘
Mit k Vergleichen können maximal doppelt so viele Permutationen erzeugt werden wie mit k-1 Vergleichen, da man auch hier wieder zwei Möglichkeiten hat. Man
kann alles so lassen oder eine möglicherweise neue Permutation5 erzeugen.
Insgesamt kann man also sagen, dass man mit k Vergleichen maximal 2k Permutationen erzeugen kann. Die Anzahl k der Vergleiche muss also mindestens so groß sein,
dass 2k ≥ n! ist. Es muss also gelten:
n⁄
k
n
k = log ( 2 ) ≥ log ( n! ) ≥ log ( n 2 ) = --- log ( n )
2
Damit hat das Verfahren mindestens die Laufzeitkomplexität n · log(n).
Wir fassen dieses wichtige theoretische Ergebnis noch einmal zusammen:
Sortierverfahren, die auf Einzelvergleichen basieren, haben mindestens die Laufzeitkomplexität n · log(n).
In dem Umfeld, in dem wir bisher Lösungen gesucht haben, gibt es also keine »wirklich« besseren Verfahren als Quicksort oder Heapsort. Das heißt aber nicht, dass es
keine besseren Sortierverfahren als Quicksort und Heapsort gibt. Man kann durchaus Verfahren konstruieren, die nicht auf Einzelvergleichen beruhen und dann effizienter als Quicksort sind. Wir betrachten dazu ein Verfahren, mit dem die Post Briefe
nach Postleitzahlen sortiert.
5 Gewisse Permutationen können dabei mehrfach erzeugt werden, aber das soll uns hier nicht
kümmern, da wir nur an einer Maximalzahl erzeugter Permutationen interessiert sind.
388
13.4
Grenzen der Optimierung von Sortierverfahren
Zur Vereinfachung stellen wir uns vor, dass es vierstellige Postleitzahlen gibt, die nur
die Ziffern 1, 2 und 3 enthalten dürfen. Im ersten Verfahrensschritt nehmen wir die
Briefe und sortieren sie entsprechend der letzten Ziffer der Postleitzahl in drei verschiedene Fächer:
1
2
3
2
2
2
3
1
3
3
2
1
3
2
2
1
2
1
2
3
1
3
1
3
3
2
1
3
2
3
1
2
3 2 2 1
3 2 1 1
2 3 1 2
2 3 1 2
1 1 3 2
1 3 2 3
2 1 3 3
2 2 3 3
1
2
3
13
Abbildung 13.43 Erster Schritt der Postleitzahlensortierung
Dann entnehmen wir die Stapel den drei Fächern und legen sie aufeinander, den Stapel aus Fach 1 zuoberst, dann den Stapel aus Fach 2, zuunterst den Stapel aus Fach 3.
Anschließend sortieren wir die Briefe wieder in die drei Fächer ein. Diesmal sortieren
wir aber nach der vorletzten Stelle und legen Wert darauf, dass die im ersten Schritt
hergestellte Vorsortierung dabei nicht zerstört wird:
3
3
2
2
1
1
2
2
2
2
3
3
1
3
1
2
2
1
1
1
3
2
3
3
1
1
2
2
2
3
3
3
3 2 1 1
2 3 1 2
2 3 1 2
3 2 2 1
1 3 2 3
1 1 3 2
2 1 3 3
2 2 3 3
1
2
3
Abbildung 13.44 Zweiter Schritt der Postleitzahlensortierung
389
13
Sortieren
Die Briefe in den drei Kästen sind jetzt nach den beiden letzten Ziffern korrekt sortiert. Wir wiederholen den gleichen Schritt jetzt noch zweimal. Also: zusammenlegen
und nach der zweiten Ziffer verteilen und dann noch einmal zusammenlegen und
nach der ersten Ziffer verteilen:
1 1 3 2
1 3 2 3
1
1
2
3
3
2
2
2
1
1
1
2
2
2
3
3
3
3
3
1
2
3
1
1
2
2
3
1
1
3
2
2
3
2
2
2
2
1
2
3
3
3
3
1
1
3
3
2
2
2
3
2
2
3
1
1
2
2
3 2 1 1
3 2 2 1
1 1 3 2
2 1 3 3
3
1
2
3
3
2
3
1
1
2
1
1
1
2
2
3
3
3
1
2
2
1
3
2
3
3
3 2 1 1
3 2 2 1
2 2 3 3
2
2 3 1 2
2 3 1 2
1 3 2 3
3
Abbildung 13.45 Dritter und vierter Schritt der Postleitzahlensortierung
Ein letztes Mal legen wir die Briefe aufeinander, und der dabei entstehende Stapel ist
korrekt sortiert:
1
1
2
2
2
2
3
3
1
3
1
2
3
3
2
2
3
2
3
3
1
1
1
2
2
3
3
3
2
2
1
1
Abbildung 13.46 Ergebnis der Postleitzahlensortierung
Dieses Verfahren nennt man Distributionsort. Bei einer genauen Betrachtung des
Verfahrens können wir Folgendes feststellen:
Zu keinem Zeitpunkt haben wir die Postleitzahlen zweier Briefe miteinander
verglichen.
Dieses Verfahren basiert also nicht auf Einzelvergleichen. Und noch etwas ist frappierend: Wir haben vier (= Anzahl der Stellen der Postleitzahl) Sortierläufe gemacht und
in jedem Durchlauf jeden Brief genau einmal in die Hand genommen. Das heißt,
390
13.4
Grenzen der Optimierung von Sortierverfahren
unser Verfahren ist asymptotisch linear und damit allen bisher vorgestellten Sortierverfahren für große Datenmengen weit überlegen.
Die Nachteile dieses Verfahrens liegen natürlich auch auf der Hand. Das Verfahren
lässt sich nicht allgemein implementieren, da zur Konstruktion konkrete Informationen über den Schlüssel (Anzahl Stellen, vorkommende Ziffern) benötigt werden,
und es wird zusätzlicher Platz zur Ablage der Briefe in den Fächern benötigt. Bei den
auf Elementvertauschung basierenden Verfahren war das nicht erforderlich. Hier
fanden alle Operationen innerhalb des zu sortierenden Arrays (in place) statt.
Sie sehen hier also, dass Laufzeit und Speicherplatz zwei Ressourcen sind, die in
gewisser Weise gegeneinander aufgerechnet werden können oder müssen. Häufig
kann man Rechenzeit auf Kosten zusätzlichen Speichers oder Speicherplatz auf Kosten zusätzlicher Rechenzeit sparen. Das Ziel, sowohl Speicherplatz als auch Rechenzeit zu sparen, lässt sich in der Regel nicht erreichen.
13
391
Kapitel 14
Datenstrukturen
Jetzt wächst zusammen,
was zusammengehört.
– Willy Brandt
Im Prinzip können Sie mit den bisher bereitgestellten Programmiermitteln jede nur
erdenkliche Programmieraufgabe lösen. Trotzdem erkennen Sie schnell die Grenzen
Ihrer derzeitigen Möglichkeiten, wenn Sie z. B. nur versuchen, ein einfaches Adressbuch zu programmieren. Im Adressbuch gibt es heterogene Daten, die zusammengehören. Zum Beispiel gehören Name, Telefonnummer und Geburtsdatum einer
Person zusammen und sollten daher auch immer gemeinsam betrachtet werden.
Wollten Sie das auf Ihrem derzeitigen Kenntnisstand zu modellieren versuchen,
müssten Sie für alle Daten unterschiedliche Arrays anlegen, da wir in einem Array ja
nur homogene Daten zusammenfassen können. Sie hätten also ein Array für alle
Namen, ein Array für alle Telefonnummern und ein Array für alle Geburtsdaten.
Schlimmer noch – da Geburtsdaten aus Tag, Monat und Jahr bestehen, hätten Sie
weitere Arrays für alle Datumsfelder eines Kalenderdatums. In dieser Situation wäre
es wünschenswert, alles, was zu einer Person gehört, in einer »Datenstruktur«
zusammenzufassen und dann ein Array aus dieser Datenstruktur aufzubauen.
Streng genommen, sind solche Datenstrukturen überflüssig. Sie bringen aber deutliche Verbesserungen in Richtung Komfort, Verständlichkeit, Erweiterbarkeit, Wiederverwertbarkeit – kurz: Qualität des Programmcodes – und sind daher für die
Programmierung zwar entbehrlich, aber für die Softwareentwicklung unentbehrlich.
Für die Softwareentwicklung spielen Datenstrukturen häufig sogar eine weitaus
wichtigere Rolle als Algorithmen, da Datenstrukturen in der Regel langlebiger sind
als Algorithmen und daher eine zentrale Rolle im Design von Softwaresystemen
spielen. Denken Sie z. B. an ein Programm, das die Studenten einer Hochschule verwaltet. Die einem Studenten zugehörigen Daten wären hier »Name«, »Vorname«,
»Matrikelnummer« sowie weitere Informationen über belegte Vorlesungen, Noten
etc. Die erforderlichen Datenstrukturen sind ein unmittelbares Abbild der in der Realität vorkommenden Daten und ihrer Beziehungen untereinander und als solches
weitaus stabiler als ein bestimmter Algorithmus, der etwa aus Einzelnoten eine
Gesamtnote berechnet oder die Teilnehmer einer Klausur nach aufsteigenden Matrikelnummern ordnet. Ein Algorithmus kann in einem gut modularisierten Pro-
393
14
14
Datenstrukturen
gramm relativ einfach durch einen anderen Algorithmus ersetzt werden, ohne dass
Auswirkungen auf andere Teile des Programms zu befürchten sind. Änderungen an
einer Datenstruktur erfordern dagegen in der Regel Änderungen in allen Algorithmen, die auf dieser Datenstruktur arbeiten, und haben somit Auswirkungen in
unterschiedlichen Teilen eines Programms.
Die Wahl einer Datenstruktur ist also in aller Regel eine wesentlich »härtere« DesignEntscheidung als die Wahl eines Algorithmus. Daraus folgt, dass die Festlegung von
Datenstrukturen mit großer Sorgfalt getroffen werden muss, um den Aufwand für
zukünftige Änderungen so gering wie möglich zu halten. Dies ist besonders schwierig, da man häufig zu dem Zeitpunkt, zu dem die Datenstruktur festgelegt werden
muss, noch nicht weiß, welche Algorithmen auf der Datenstruktur arbeiten werden.
Auf den Internetseiten des Deutschen Fußballbundes habe ich eine Bilanz aller Fußballspiele der deutschen Nationalmannschaft gefunden. In diese Bilanz sind alle
Spielergebnisse von 1908 bis 2013 eingegangen und nach Nationen verdichtet aufgeführt. Diese Daten habe geringfügig aufbereitet1 und in der Textdatei Laenderspiele.txt abgespeichert:
Abbildung 14.1 Daten der Länderspiele der deutschen Fußballnationalmannschaft
Diese kleine Datensammlung soll als Grundlage für ein Programm dienen, das sich
wie ein roter Faden durch dieses Kapitel ziehen wird. Von einfachen Strukturen bis
hin zu komplexeren Modellierungstechniken, wie Listen, Bäumen oder Hash-Tabellen, werden wir immer wieder auf diese Daten zurückgreifen.
1 Ich habe Umlaute entfernt und Leerzeichen in Ländernamen durch Bindestriche ersetzt.
394
14.1
14.1
Strukturdeklarationen
Strukturdeklarationen
In den beiden letzten Spalten unserer Datentabelle finden Sie die Kalenderdaten für
das erste und das letzte Spiel gegen die anderen Nationen. Sie könnten ein Datum als
Text einlesen und in einem String speichern, aber ein Datum soll nicht einfach nur
ein Text sein. Es soll zwar zusammengehören wie ein Text, aber wir wollen einzeln
auf Tag, Monat und Jahr zugreifen können. Dazu benötigen wir eine Datenstruktur.
Bevor Sie eine Datenstruktur verwenden können, müssen Sie sie deklarieren:
datum
Deklaration der
Datenstruktur datum
tag
monat
jahr
struct datum
{
int tag;
int monat;
int jahr;
};
Die Felder haben
jeweils einen Typ
und einen Namen.
14
Abbildung 14.2 Deklaration der Datenstruktur datum
Im Grunde genommen, ist durch solch eine Deklaration noch nichts passiert –
zumindest ist noch kein ausführbarer Code entstanden. Wir haben nur eine Schablone bereitgestellt, durch die wir auf unsere Daten blicken wollen. Konkrete Daten
sind noch nicht entstanden.
Abbildung 14.3 Interpretation der Datenstruktur als Schablone
Alle elementaren Datentypen (char, int, float, ...) sind der Rohstoff, aus dem Datenstrukturen zusammengesetzt werden können.
395
Datenstrukturen
kt
pun
t
c
u
str {
x;
ble
dou le y;
b
dou
};
str
uct
far
be
{
cha
r
cha rot;
r
cha gruen
r b
lau ;
};
;
nis
ergeb
spiel
t
c
u
r
st
{
ore;
int t entore;
eg
g
int
stru
;
}
ct a
rtik
el
{
int
arti
int
keln
a
floa nzahl; ummer;
t ei
floa
n
t ve kaufsp
re
};
rkau
fspr is;
eis;
struct beispiel
{
unsigned char c;
short s;
int i;
float f;
double d;
};
Abbildung 14.4 Verschiedene Beispiele von Datenstrukturen
gew
dfb
gegner
datum
gesamt
tore
Nach Bedarf können aus allen Grunddatentypen Datenstrukturen zusammengestellt
werden, auch wenn wir es in unserem Beispiel nur mit ganzen Zahlen zu tun haben.
spiele
14
unent
verl
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
monat
jahr
struct tore
{
int dfb;
int gegner;
};
struct datum
{
int tag;
int monat;
int jahr;
};
Abbildung 14.5 Interpretation der Länderspieldaten als Datenstrukturen
396
tag
14.1
Strukturdeklarationen
Strukturen können ihrerseits wieder Strukturen und Arrays fester Länge – auch
Arrays von Strukturen – enthalten. Unter dem Strukturnamen bilanz wollen wir eine
Zeile unserer Datentabelle modellieren. Dazu greifen wir auf die bereits deklarierten
Teilstrukturen (spiele, tore, datum) zurück und fügen noch ein Array von 30 Zeichen
für den Namen des Landes hinzu.
Damit ergibt sich die folgende Strukturdeklaration:
name [30]
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
ergebnisse
spiele
gesamt
gew
unent
verl
tore
bilanz
treffer
dfb
gegner
datum
erstes
tag
monat
struct bilanz
{
char name[30];
struct spiele ergebnisse;
struct tore treffer;
struct datum erstes;
struct datum letztes;
};
struct tore
{
int dfb;
int gegner;
};
struct datum
{
int tag;
int monat;
int jahr;
};
14
jahr
datum
letztes
tag
monat
jahr
struct datum
{
int tag;
int monat;
int jahr;
};
Abbildung 14.6 Erweiterte Strukturdeklaration für die Länderspieldaten
Alles in dieser Datenstruktur haben wir mit einem Namen versehen. Grundsätzlich
gibt es zwei verschiedene Arten von Namen:
왘
Strukturnamen (in der Grafik senkrecht geschrieben) wie bilanz oder datum. Mit
diesen Namen werden neue Strukturen eindeutig benannt.
왘
Feldnamen (in der Grafik waagerecht geschrieben) wie monat oder treffer. Diese
Namen dienen zum Zugriff auf die Felder einer Datenstruktur.
Die Struktur bilanz enthält z. B. unter dem Namen ergebnisse eine Struktur spiele.
Insbesondere ist die Struktur datum zweimal in der Struktur bilanz vorhanden. Auf
das eine Datum kann unter dem Namen erstes, auf das zweite unter dem Namen
letztes zugegriffen werden. Wie ein Zugriff auf die Daten konkret aussieht, werden
Sie später sehen. Noch gibt es ja gar keine Daten, sondern nur Schablonen mit Struktur- und Zugriffsinformationen.
397
Datenstrukturen
Um die Datentabelle als Ganzes zu modellieren, werden wir jetzt noch ein Array von
ausreichend vielen Bilanzen erstellen und zusätzlich speichern, wie viele Einträge in
diesem Array gültig sind.
struct bilanz
{
…
};
anzahl
bilanz
…
bilanz
…
bilanz
land
daten
…
struct daten
{
int anzahl;
struct bilanz land[100];
};
struct bilanz
{
…
};
struct bilanz
{
…
};
…
…
bilanz
14
…
struct bilanz
{
…
};
Abbildung 14.7 Vollständige Strukturdeklaration
In der Modellierung steckt immer ein gewisses Maß an Willkür. Man hätte es auch
ganz anders machen können. Das ist wie bei dem Entwurf eines Architekten für eine
Wohnung. Im Grundriss steckt Willkür, aber es gibt funktionierende und nicht funktionierende Grundrisse. Es ist die Erfahrung des Architekten, aus der Vielzahl der
Möglichkeiten, den Rahmenbedingungen der Bauphysik und den Anforderungen
des Kunden eine geeignete Synthese zu finden. Genauso ist es die Erfahrung des Softwareentwicklers, aus der kombinatorischen Vielfalt der Möglichkeiten, den Rahmenbedingungen der Programmiersprache und den Anforderungen an das Programm
geeignete Strukturen zu entwickeln.
Beachten Sie, dass wir die Arrays in diesem Beispiel auf die zu erwartende Maximallast (maximal 29 Buchstaben im Ländernamen, maximal 100 verschiedene Länder)
ausgelegt haben. Das war in diesem Fall möglich, ist aber eine grundsätzliche, störende Beschränkung, von der wir uns später befreien werden. Vorher wollen wir aber
konkret Datenstrukturen anlegen und mit diesen Datenstrukturen arbeiten.
14.1.1
Variablendefinitionen
Durch die Deklaration einer Datenstruktur wird im Prinzip ein neuer Datentyp eingeführt. Üblicherweise findet man Datenstruktur-Deklarationen in Header-Dateien,
die dann von allen Quelldateien, die diese Datenstrukturen verwenden wollen, inklu-
398
14.1
Strukturdeklarationen
diert werden. Werden Datenstruktur-Deklarationen nur in einer einzigen Quelldatei
benötigt, können sie auch dort, typischerweise am Anfang der Datei, stehen. Konkrete Daten einer bestimmten Struktur erhalten Sie, indem Sie eine Variable anlegen.
Wenn Sie z. B. ein Kalenderdatum benötigen, können Sie schreiben:
Typ der Variablen
struct datum geburtstag;
Name der Variablen
Abbildung 14.8 Beispiel der Struktur für ein Kalenderdatum
Jetzt ist ein konkretes Datum entstanden, das auch schon bei der Definition mit Werten gefüllt werden kann:
Tag
Monat
Jahr
14
struct datum geburtstag = {17, 11, 2013};
Abbildung 14.9 Befüllung mit Werten bei der Definition
Im Speicher werden jetzt die konkreten Werte hinterlegt:
Abbildung 14.10 Ablage der Werte im Speicher
Auch komplexe, verschachtelte Strukturen können auf diese Weise angelegt und initialisiert werden. Folgen Sie dazu einfach der durch die Schablone vorgegebenen
Struktur.
399
14
Datenstrukturen
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
struct bilanz beispiel =
{ "Lummerland",
{ 6, 1, 2, 3},
{ 13, 17},
{ 2, 3, 1975},
{ 24, 12, 2000}
};
struct bilanz
{
charname [30];
struct spiele ergebnisse;
struct tore treffer;
struct datum erstes;
struct datum letztes;
};
struct tore
{
int dfb;
int gegner;
};
struct datum
{
int tag;
int monat;
int jahr;
};
struct datum
{
int tag;
int monat;
int jahr;
};
Abbildung 14.11 Anlage komplexer Strukturen bei der Definition
14.2
Zugriff auf Strukturen
Die Werte einer Variablen können einer anderen Variablen zugewiesen werden, egal,
ob die Variablen nur auf einem einfachen Datentyp oder einer komplexen Struktur
basieren. Wichtig ist, dass bei einer Zuweisung auf der linken und rechten Seite des
Gleichheitszeichens der gleiche Datentyp steht.
struct datum heute = {31, 8, 2014};
struct datum morgen;
morgen = heute;
Ein komplettes Datum wird zugewiesen.
Abbildung 14.12 Zuweisung einer Datenstruktur
400
14.2
Zugriff auf Strukturen
Operationen wie z. B. Größenvergleich (<, >) oder arithmetische Operationen können
Sie auf Datenstrukturen nicht ausführen2. Wie sollte der Compiler auch wissen, wie
etwa der Vergleich zweier Kalenderdaten, im Sinne eines Vorher-nachher-Vergleichs,
durchgeführt werden sollte? Dazu müsste er ja die Bedeutung dieser Datenstruktur
kennen.
Natürlich können Sie auch gezielt auf die einzelnen Felder einer Datenstruktur
zugreifen, um diese zu lesen oder zu verändern. Damit werden wir uns im Folgenden
beschäftigen. Wir unterscheiden dabei:
왘
Direktzugriff
왘
Indirektzugriff
14.2.1
Direktzugriff
Zum direkten Zugriff auf die Felder einer Datenstruktur dient der Punkt-Operator (.):
Datum: 1.9.2014
struct datum heute = {31, 8, 2014};
struct datum morgen;
morgen = heute;
morgen.tag= morgen.tag+1;
if( morgen.tag > 31)
{
morgen.tag= 1;
morgen.monat++;
}
14
Ein komplettes Datum wird zugewiesen.
Zugriff auf einzelne Felder
eines Datums
printf( "Datum: %d.%d.%d\n", morgen.tag, morgen.monat, morgen.jahr);
Abbildung 14.13 Zugriff auf die Elemente der Datumsstruktur
Sie können Schritt für Schritt mit dem Punkt-Operator in die Datenstruktur hineinzoomen, bis Sie auf dem Level angekommen sind, auf dem Sie arbeiten möchten –
egal, wie tief die Strukturen verschachtelt sind.
Abbildung 14.14 zeigt zwei unterschiedlich tiefe Zugriffe in die Datenstruktur mit beispielhaften Zuweisungen.
Wichtig ist, immer im Blick zu behalten, welchen Datentyp Sie auf welcher Zugriffsstufe jeweils erhalten, damit Sie wissen, welche Operationen Sie auf dem jeweiligen
Level ausführen können.
2 Dazu müssen Sie sich noch bis zur objektorientierten Programmierung gedulden.
401
14
Datenstrukturen
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
struct bilanz beispiel;
struct spiele sp = {6,1,2,3};
beispiel.ergebnisse = sp;
beispiel.erstes.jahr = 1950;
struct bilanz
{
char name[30];
struct spiele ergebnisse;
struct tore treffer;
struct datum erstes;
struct datum letztes;
};
struct tore
{
int dfb;
int gegner;
};
struct datum
{
int tag;
int monat;
int jahr;
};
struct datum
{
int tag;
int monat;
int jahr;
};
Abbildung 14.14 Zugriffe unterschiedlicher Tiefe
struct daten dat;
dat.land[3].name[7] = 'a';
char
Array von char
struct bilanz
Array von struct bilanz
struct daten
Abbildung 14.15 Unterschiedliche Datentypen je Ebene
Auf der tiefsten Ebene haben Sie in diesem Beispiel den Datentyp char, sodass Sie
einen Buchstaben zuweisen können.
Im nächsten Beispiel endet der Zugriff auf einer Integer-Zahl:
402
14.2
Zugriff auf Strukturen
struct daten dat;
dat.land[2].ergebnisse.verl = 123;
int
struct spiele
struct bilanz
Array von struct bilanz
struct daten
Abbildung 14.16 Weiterer Zugriff mit anderem Datentyp
14.2.2
Indirektzugriff
Sie können auch Zeiger auf Datenstrukturen anlegen, wie Sie bereits Zeiger auf die
Grunddatentypen angelegt haben:
Zeiger auf ein Datum
struct datum *pointer;
Abbildung 14.17 Zeiger auf ein Datum
Beachten Sie, dass pointer keine Datenstruktur mit Feldern tag, monat und jahr ist,
sondern nur die Adresse einer solchen Datenstruktur, also einen Verweis auf eine
solche Datenstruktur, enthalten kann. Der Zeiger ist unbrauchbar, solange ihm nicht
die Adresse einer konkreten Datenstruktur zugewiesen wird. Sie wissen schon, dass
Sie mit dem Adress-Operator (&) die Adresse eine Variablen ermitteln und mit dem
Dereferenzierungsoperator (*) über eine Adresse zugreifen können. Das funktioniert
unabhängig davon, ob die Variable als Typ einen der Grunddatentypen oder eine
selbst angelegte Struktur hat.
403
14
14
Datenstrukturen
Noch haben geburtsdatum und
pointer nichts miteinander zu tun.
struct datum geburtsdatum;
struct datum *pointer;
pointer = &geburtsdatum;
(*pointer).tag = 17;
(*pointer).monat = 11;
(*pointer).jahr = 2013;
Der Zeiger erhält einen Adresswert.
Über den Zeiger werden Werte in
die referenzierte Datenstruktur
eingetragen.
Abbildung 14.18 Zeiger auf Datenstrukturen
Erst dadurch, dass in dem oben dargestellten Beispiel der Zeiger pointer die Adresse
von geburtsdatum erhält, kann über den Zeiger sinnvoll zugegriffen werden. Der
Zugriff erfolgt mit dem Dereferenzierungsoperator, den Sie ja bereits in Kapitel 8,
»Zeiger und Adressen«, kennengelernt haben. Da diese Art des Zugriffs sehr häufig
vorkommt und in der hier gezeigten Notation etwas sperrig ist, gibt es einen eigenen
Operator, der die Dereferenzierung mit einem sofortigen Zugriff auf ein Feld der referenzierten Datenstruktur verbindet:
Der Zugriffsoperator für Indirektzugriff
Ist p ein Zeiger auf eine Datenstruktur und x ein Feld dieser Datenstruktur, lässt sich
auf das Feld mit den beiden geichwertigen Ausdrücken
(*p).x bzw. p->x
zugreifen. Beide Ausdrücke sind dabei als R-Value und L-Value – also sowohl auf der
rechten als auch auf der linken Seite einer Zuweisung – geeignet.
Den Ausdruck p->x lesen wir als »p points x«.
Damit können wir den Strukturzugriff im Beispiel oben etwas eleganter formulieren:
Zugriff mit *-Operator
struct datum geburtsdatum;
struct datum *pointer;
Zugriff mit Points-Operator
struct datum geburtsdatum;
struct datum *pointer;
pointer = &geburtsdatum;
pointer = &geburtsdatum;
(*pointer).tag = 17;
(*pointer).monat = 11;
(*pointer).jahr = 2013;
pointer->tag = 17;
pointer->monat = 11;
pointer->jahr = 2013;
Abbildung 14.19 Direkt- und Indirektzugriff im Vergleich
404
14.3
Datenstrukturen und Funktionen
Wir könnten alle Beispiele aus dem vorangegangenen Abschnitt von Direktzugriff
auf Indirektzugriff umstellen, aber dann würden Sie sich zu Recht fragen, warum
man einen Zeiger anlegt und mit einem Adresswert versieht, nur um anschließend
indirekt anstatt direkt zugreifen zu können. Den wahren Wert von Zeigern erkennt
man erst im Zusammenhang mit Funktionen und dynamischen Datenstrukturen.
Auf diese Themen wollen wir jetzt zielstrebig zusteuern.
14.3
Datenstrukturen und Funktionen
Datenstrukturen können als Parameter an Funktionen übergeben werden, und
Datenstrukturen können auch von Funktionen zurückgegeben werden.
Als Beispiel erstellen wir eine Funktion, die ein Kalenderdatum als lokale Variable
anlegt, den Benutzer nach Tag, Monat und Jahr fragt und das komplette Datum als
Returnwert zurückgibt:
A
B
C
struct datum datumseingabe()
{
struct datum dat;
14
printf( "Datum: ");
scanf( "%d.%d.%d", &dat.tag, &dat.monat, &dat.jahr);
return dat;
}
Listing 14.1 Strukturen als Rückgabetyp
Abgesehen davon, dass der Rückgabetyp hier eine Datenstruktur ist (A), kennen Sie
das von Funktionen, die einen Basistyp wie int oder float als Rückgabetyp hatten.
Innerhalb der Funktion werden Tag, Monat und Jahr vom Benutzer eingegeben (B),
und eine in der Funktion erzeugte Struktur wird zurückgegeben (C).
Wir erstellen jetzt noch eine Funktion, die die Aufgabe hat, zwei Kalenderdaten miteinander zu vergleichen, um festzustellen, welches der beiden Daten das frühere ist.
Dieser Funktion müssen wir zwei Datenstrukturen übergeben:
A
B
int datumsvergleich( struct datum d1, struct datum d2)
{
if( d1.jahr != d2.jahr)
return d1.jahr – d2.jahr;
405
14
Datenstrukturen
C
if( d1.monat != d2.monat)
return d1.monat – d2.monat;
D
return d1.tag – d2.tag;
}
Listing 14.2 Übergabe von Strukturen als Parameter
Wenn die Jahre in den als Strukturen übergebenen Parametern (A) unterschiedlich
sind (B), wird die Jahresdifferenz zurückgegeben.
Wenn die Jahre gleich und die Monate unterschiedlich sind (C), wird die Monatsdifferenz zurückgegeben.
Wenn die Jahre und Monate gleich sind, wird die Tagesdifferenz zurückgegeben (D).
Im Hauptprogramm testen wir die beiden Funktionen:
A
void main()
{
struct datum datum1, datum2;
B
C
datum1 = datumseingabe();
datum2 = datumseingabe();
D
if( datumsvergleich( datum1, datum2) < 0)
printf( "Das erste Datum liegt vor dem zweiten.\n");
else
printf( "Das zweite Datum liegt vor dem ersten\n");
}
Listing 14.3 Test des Datumsvergleichs
In unserem Testprogramm werden zwei Kalenderdaten (A) eingegeben, zugewiesen
und miteinander verglichen (D). Wir erhalten das erwartete Ergebnis.
Datum: 3.7.2001
Datum: 4.7.2001
Das erste Datum liegt vor dem zweiten.
Machen Sie sich noch einmal klar, was bei einem Funktionsaufruf an der Schnittstelle
passiert. Es werden Kopien der übergebenen Daten auf dem Stack erzeugt, die Funktion arbeitet mit diesen Kopien, und beim Rücksprung werden die Daten auf dem
Stack wieder beseitigt. Datenstrukturen können sehr groß sein, sodass bei einem
Funktionsaufruf gegebenenfalls große Datenmengen, zumeist überflüssigerweise,
406
14.3
Datenstrukturen und Funktionen
dupliziert werden. Die damit verbundenen Speicher- und Laufzeitkosten entfallen,
wenn man anstelle der Datenstrukturen nur Zeiger auf die Datenstrukturen übergibt
und die Funktion auf den Originaldaten des aufrufenden Programms arbeiten lässt.
Wir stellen die Funktion datumsvergleich auf die konsequente Verwendung von Zeigern um:
A
int datumsvergleich( struct datum *pd1, struct datum *pd2)
{
if( pd1->jahr != pd2->jahr)
return pd1->jahr – pd2->jahr;
if( pd1->monat != pd2->monat)
return pd1->monat – pd2->monat;
return pd1->tag – pd2->tag;
}
Listing 14.4 Umgestellte Version von datumsvergleich
In der umgestellten Version werden nun zwei Zeiger auf Strukturen als Parameter
übergeben (A). Der Zugriff auf die Daten erfolgt jetzt mit dem Pfeil-Operator, ansonsten hat sich nichts geändert.
Auch die Rückgabe großer Datenstrukturen mit anschließendem Kopieren in die
Datenstruktur des aufrufenden Programms können Sie vermeiden, wenn Sie einen
Zeiger auf die im Hauptprogramm bereitstehende Datenstruktur übergeben. Wir
praktizieren das am Beispiel der Funktion datumseingabe:
A
B
void datumseingabe( struct datum *pd)
{
printf( "Datum: ");
scanf( "%d.%d.%d", &pd->tag, &pd->monat, &pd->jahr);
}
Listing 14.5 datumseingabe mit veränderter Übergabe
In der Schnittstelle ist eine explizite Rückgabe jetzt nicht mehr erforderlich (A), da die
Daten über den Zeiger direkt in die Datenstruktur des aufrufenden Programms eingetragen werden. Innerhalb der Funktion erfolgt der Zugriff auf die Daten mit dem
Pfeil-Operator (B).
Funktionen wie scanf haben ja immer schon nach diesem Prinzip der Rückgabe über
Zeiger gearbeitet.
Im Hauptprogramm müssen Sie jetzt darauf achten, Zeiger anstelle von Datenstrukturen zu übergeben:
407
14
14
Datenstrukturen
void main()
{
struct datum datum1, datum2;
A
B
datumseingabe( &datum1);
datumseingabe( &datum2);
C
if( datumsvergleich( &datum1, &datum2) < 0)
printf( "Das erste Datum liegt vor dem zweiten.\n");
else
printf( "Das zweite Datum liegt vor dem ersten\n");
}
Listing 14.6 Angepasstes Testprogramm
In dem angepassten Testprogramm werden jetzt Adressen übergeben (A, B, C),
anstatt die Daten zu kopieren.
Wir haben zwei funktionell gleichwertige Varianten unseres Beispielprogramms
erstellt. Der Unterschied zwischen den beiden Varianten liegt im Speicherverbrauch
und im Laufzeitverhalten. Der zusätzliche Speicherverbrauch auf dem Stack ist in
unseren Beispielen nicht von Bedeutung, zumal der Speicher immer nur sehr kurz
während eines Funktionsaufrufs benötigt wird. Bei sehr großen Datenstrukturen in
Verbindung mit Rekursion könnte das anders aussehen. Was die Laufzeit betrifft,
können Sie damit rechnen, dass die auf Zeiger umgestellten Funktionen ca. zwei- bis
dreimal so schnell wie die ursprünglichen Funktionen sind. Das liegt daran, dass es
sich um Funktionen handelt, bei denen die Behandlung der Schnittstellenparameter
einen substanziellen Anteil der Gesamtlaufzeit ausmacht. Bei häufigem Funktionsaufruf, z. B. beim Sortieren einer großen Zahl von Kalenderdaten, kann sich das deutlich bemerkbar machen.
Verwenden Sie daher, wo immer es sinnvoll möglich ist, Zeiger bei der Übergabe von
Datenstrukturen an Funktionen, um den damit verbundenen Laufzeit-und Speichergewinn mitzunehmen3.
3 Im klassischen Kernighan-Ritchie-C mussten übrigens Datenstrukturen immer per Zeiger übergeben werden.
408
14.4
14.4
Ein vollständiges Beispiel (Teil 1)
Ein vollständiges Beispiel (Teil 1)
Zurück zur Aufgabe dieses Abschnitts. Wir wollen die Daten aus der Länderspielbilanz des DFB einlesen und im Speicher zur Weiterverarbeitung zur Verfügung stellen.
Eine Datenstruktur dafür haben wir bereits erstellt. Abbildung 14.20 zeigt den aktuellen Enwicklungsstand:
struct daten
struct spiele
{
{
int anzahl;
int gesamt;
struct bilanz land[100];
int gew;
};
int unent;
struct datum
struct bilanz
int verl;
{
{
};
int tag;
charname[30];
int monat;
struct spiele ergebnisse;
int jahr;
struct tore treffer;
struct tore
};
struct datum erstes;
{
struct datum letztes;
int dfb;
};
int gegner;
};
14
Abbildung 14.20 Die Daten in unterschiedlichen Strukturen
Unsere Datenstruktur ist in zweierlei Hinsicht limitiert. Der Name eines Landes darf
nicht mehr als 29 Buchstaben4 umfassen, und es darf maximal 100 Länder geben.
Diese beiden Grenzen stellen kein ernsthaftes Problem dar, aber Sie werden später
sehen, wie Sie sich von diesen Beschränkungen befreien können. Zunächst aber
arbeiten wir mit dieser Beschränkung und lesen die Daten aus der Datei Laenderspiele.txt in diese Datenstruktur ein. Wir starten im Hauptprogramm:
A
B
void main()
{
struct daten dat;
lies_datei( &dat, "Laenderspiele.txt");
}
Listing 14.7 Hauptprogramm zum Einlesen der Daten
4 Vergessen Sie die terminierende 0 hinter jedem String nicht!
409
14
Datenstrukturen
In dem Programm soll die Datenstruktur dat den gesamten Inhalt der Datei aufnehmen (A).
Die Funktion lies_datei liest die Daten aus der Datei Laenderspiele.txt und speichert
sie in der Datenstruktur dat (B).
Jetzt geht es ans Einlesen der Daten aus der Datei Laenderspiele.txt. Dazu müssen Sie
sich zunächst einmal an einige wichtige Dateioperationen erinnern:
왘
fopen – Öffnen einer Datei
왘
feof – Test, ob hinter dem Dateiende gelesen wurde
왘
fscanf – Einlesen von Daten aus der Datei ähnlich scanf
왘
fclose – Schließen der Datei
Wichtig ist auch der Dateihandle (Datentyp FILE), den wir beim Öffnen der Datei
erhalten und bei allen Zugriffsfunktionen auf die Datei als Parameter verwenden
müssen. Wenn Ihnen das nichts mehr sagt, schlagen Sie es in Abschnitt 10.4, »Einund Ausgabe«, noch einmal nach. Mit diesen Dateioperationen können wir die Funktion lies_datei erstellen, wobei wir das Lesen einer einzelnen Länderspielbilanz
noch einmal in ein Unterprogramm (lies_bilanz) auslagern:
A
B
C
D
E
F
G
int lies_datei( struct daten *pd, char *dateiname)
{
FILE *pf;
int anz;
pd->anzahl = 0;
pf = fopen( dateiname, "r");
if( !pf)
return 0;
for( anz = 0; lies_bilanz( pf, pd->land+anz); anz++)
;
fclose( pf);
pd->anzahl = anz;
return anz;
}
Listing 14.8 Die Funktion lies_datei
Die Funktion erhält als Parameter einen Zeiger auf die Datenstruktur, die die Daten
aufnehmen soll, und den Namen der Datei, in der die Daten stehen (A).
Anfangs enthält die Datenstruktur noch keine Daten (B), die Anzahl wird auf 0
gesetzt. In (C) wird die Datei zum Lesen geöffnet. Wenn beim Öffnen der Datei ein
Fehler festgestellt wird, erfolgt ein Abbruch der Funktion (D).
410
14.4
Ein vollständiges Beispiel (Teil 1)
Wenn die Datei erfolgreich geöffnet wurde, startet die Leseschleife, deren Notation
weiter unten noch erläutert wird (E): Mit der Funktion lies_bilanz (siehe unten) wird
jeweils der nächste Datensatz (struct bilanz) gelesen. In anz wird die Anzahl der gelesenen Datensätze gezählt. Am Rückgabewert der Funktion lies_bilanz wird erkannt,
ob noch ein Datensatz gelesen werden konnte.
Nach Abschluss der Leseschleife wird die Datei geschlossen (F). Die Anzahl der erfolgreich gelesenen Datensätze wird in die Datenstruktur geschrieben und zusätzlich
von der Funktion zurückgegeben (G).
Die oben bereits verwendete Funktion lies_bilanz sieht dann folgendermaßen aus:
A
B
C
D
E
F
G
H
int lies_bilanz( FILE *pf, struct bilanz *pb)
{
fscanf( pf, "%s", pb->name);
if( feof( pf))
return 0;
fscanf( pf, "%d %d %d %d", &pb->ergebnisse.gesamt,
&pb->ergebnisse.gew,
&pb->ergebnisse.unent,
&pb->ergebnisse.verl);
fscanf( pf, "%d:%d", &pb->treffer.dfb,
&pb->treffer.gegner);
fscanf( pf, "%d.%d.%d", &pb->erstes.tag,
&pb->erstes.monat,
&pb->erstes.jahr);
fscanf( pf, "%d.%d.%d", &pb->letztes.tag,
&pb->letztes.monat,
&pb->letztes.jahr);
return 1;
}
14
Listing 14.9 Die Funktion lies_bilanz
Die Funktion erhält als Übergabeparameter die zum Lesen geöffnete Datei pf und
einen Zeiger auf die Datenstruktur pb, in die die Daten eingetragen werden sollen (A).
Die Funktion startet mit einem versuchsweisen Lesen des Ländernamens (B). Wenn
hier kein Land mehr gefunden wurde (End of File), dann wird die Funktion beendet (C).
Ansonsten wird angenommen, dass auch die restlichen Elemente nach dem Ländernamen gelesen werden können, und es werden die Ergebnisse (D), Treffer (E) sowie das
Datum des ersten (F) und letzten Spiels (G) eingelesen. Nach dem erfolgreichen Lesen
des Datensatzes wird ein entsprechender Rückgabewert geliefert.
411
14
Datenstrukturen
Beim Aufruf der Funktion lies_bilanz in Listing 14.8 (E) bedarf der Parameter
pd->land+anz sicher noch einer weiteren Erklärung. In Abschnitt 8.2, »Zeiger und
Arrays«, habe ich auf die Analogie zwischen Arrays und Zeigern hingewiesen. Ich
hatte dort gesagt:
Ist a ein Array, ist a+i ein Zeiger auf das i-te Element des Arrays.
Es gilt also: *(a+i) = a[i]
Das gilt natürlich auch, wenn die Elemente des Arrays Datenstrukturen (hier struct
bilanz) sind. Damit ergibt sich die folgende Leseanleitung für den Aufruf der
Funktion:
pd
pd->land
pd->land[anz]
&(pd->land[anz])
ist ein Zeiger auf eine Datenstruktur daten
ist das Array mit den Länderbilanzen
ist die Bilanz mit dem Index anz
ist die Adresse an der Bilanz mit dem Index anz
Wegen array[i] = *(array+i) ist &array[i] = array+i.
lies_bilanz( pf, pd->land+anz)
Anstelle von &(pd->land[anz]) können wir daher auch
pd->land+anz schreiben.
int lies_bilanz( FILE *pf, struct bilanz *pb)
{
…
}
Abbildung 14.21 Leseanleitung für den Funktionsaufruf
Mit der Funktion lies_datei sind wir in der Lage, die Daten aus unserer Textdatei
einzulesen. Vorher erstellen wir aber noch eine Funktion zur Ausgabe der kompletten Datenstruktur, damit wir uns nach dem Einlesen davon überzeugen können,
dass alles richtig angekommen ist. Zu dieser Funktion muss ich nicht viel erklären, da
wir im Prinzip nur das Lesen in vereinfachter Form umkehren.
void print_daten( struct daten *pd)
{
int i;
for( i = 0; i < pd->anzahl; i++)
print_bilanz( pd->land + i);
}
Listing 14.10 Die Funktion print_daten
void print_bilanz( struct bilanz *pb)
{
printf( "%-25s", pb->name);
printf( " %3d %3d %3d %3d", pb->ergebnisse.gesamt,
412
14.4
printf(
printf(
printf(
printf(
}
Ein vollständiges Beispiel (Teil 1)
pb->ergebnisse.gew,
pb->ergebnisse.unent,
pb->ergebnisse.verl);
" %4d:%-4d", pb->treffer.dfb,
pb->treffer.gegner);
" %02d.%02d.%4d", pb->erstes.tag,
pb->erstes.monat,
pb->erstes.jahr);
" %02d.%02d.%4d", pb->letztes.tag,
pb->letztes.monat,
pb->letztes.jahr);
"\n");
Listing 14.11 Die Funktion print_bilanz
Im Hauptprogramm lesen wir jetzt die Daten aus der Datei in die Datenstruktur ein
und geben sie sofort danach zum Test auf dem Bildschirm aus:
14
void main()
{
struct daten dat;
lies_datei( &dat, "Laenderspiele.txt");
print_daten( &dat);
}
Abbildung 14.22 Ergebnis unseres Ausgabetests
Jetzt haben wir die komplette Länderspielbilanz im Speicher und können beliebige
Anfragen beantworten und Auswertungen durchführen. Ich möchte Ihnen dafür nur
ein Beispiel geben. Wir fragen uns, wer denn der Lieblingsgegner der deutschen
Mannschaft ist. Das heißt, wir suchen das Land, gegen das Deutschland bisher die
meisten Länderspiele absolviert hat, und wollen die Bilanz gegen diesen Gegner aus-
413
14
Datenstrukturen
geben. Wir erstellen dazu eine Funktion, die einen Zeiger auf die gesamten Daten
erhält, diese durchsucht und den Index des Lieblingsgegners zurückgibt:
A
B
C
D
E
F
int lieblingsgegner( struct daten *pd)
{
int i;
int index;
int max = –1;
for( i = 0; i < pd->anzahl; i++)
{
if( pd->land[i].ergebnisse.gesamt > max)
{
max = pd->land[i].ergebnisse.gesamt;
index = i;
}
}
return index;
}
Listing 14.12 Finden des Lieblingsgegners
Wir suchen den Index des Lieblingsgegners (A) und arbeiten dazu mit einer Schleife
über alle Länderbilanzen (B). Wenn ein Gegner mit mehr Spielen gefunden wird (C),
dann speichere das neue Maximum (D) und den Index des Gegners (E). Abschließend
wird der Index des Lieblingsgegners zurückgegeben (F).
Im Hauptprogramm ist dann nur noch wenig zu tun:
A
B
void main()
{
struct daten dat;
int i;
lies_datei( &dat, "Laenderspiele.txt");
i = lieblingsgegner( &dat);
printf( "\nLieblingsgegner\n");
print_bilanz( dat.land+i);
}
Listing 14.13 Das zugehörige Hauptprogramm
Wir suchen nach dem Einlesen der Daten den Lieblingsgegner (A), geben dessen
Bilanz auf dem Bildschirm aus (B) und erhalten das folgende Ergebnis:
414
14.5
Lieblingsgegner
Schweiz
51 36
6
9 138:65
Dynamische Datenstrukturen
05.04.1908 26.05.2012
Der Lieblingsgegner der deutschen Fußballnationalmannschaft ist also die Schweiz
mit 51 Länderspielen.
Auf weitere Auswertungen möchte ich an dieser Stelle verzichten, zumal wir die
Datenstruktur noch einmal grundlegend überarbeiten werden, um die Beschränkung auf eine bestimmte Anzahl von Datensätzen endgültig zu beseitigen.
14.5
Dynamische Datenstrukturen
In diesem Abschnitt wollen wir uns von der Beschränkung lösen, dass wir zum Kompilationszeitpunkt bereits festlegen müssen, wie groß die zu verarbeitenden Datenstrukturen maximal sind. Eine zentrale Rolle spielen dabei die Funktionen malloc
und free, die Sie in Abschnitt 10.6, »Freisprecherverwaltung«, bereits kennengelernt
haben. Stellen Sie sich vor, dass wir an irgendeiner Stelle unseres Programms 1000
Bytes brauchen, um dort etwa einen Text zu speichern. Dann können wir über die
Funktion malloc genau die benötigten 1000 Bytes vom Laufzeitsystem anfordern.
Als Ergebnis des Funktionsaufrufs erhalten wir die Adresse des für uns reservierten
Speichers. Diese Adresse müssen wir in einem Zeiger speichern, damit wir über diesen Zeiger auf den Speicher zugreifen können. Im Code könnte das so aussehen:
1000 Bytes werden
angefordert.
char *zeiger;
zeiger = (char *)malloc( 1000);
Die Adresse wird dem Zeiger zugewiesen.
Der Datentyp wird angepasst.
Abbildung 14.23 Allokieren von Speicher
Lassen Sie sich durch die Typanpassung (Cast-Operator) nicht verwirren. Die Typanpassung ist formal erforderlich, damit der Returnwert der Funktion malloc zum
Datentyp unseres Zeigers passt und zugewiesen werden kann. Die Funktion malloc
kann ja, weil sie den benötigten Datentyp nicht kennt, nur einen strukturlosen Zeigertyp (void *) liefern, der nicht zu dem hier benötigten Zeigertyp (char *) passt. Dem
strukturlosen Zeiger wird durch die Typumwandlung die benötigte Struktur aufgeprägt. Im Grunde genommen passiert dabei gar nichts, und wenn Sie die Typumwandlung weglassen, erhalten Sie allenfalls einen Warnhinweis auf nicht kompatible
Zeigertypen. Entscheidend ist, dass unser Zeiger nach der Zuweisung den korrekten
Adresswert hat und wir über den Zeiger auf unserem Speicher arbeiten können:
415
14
14
Datenstrukturen
Über den Zeiger wird
auf den Speicher
zugegriffen.
zeiger[0] = 'A';
zeiger[1] = 'B';
zeiger[2] = 0;
printf( "%s\n",zeiger);
AB
Abbildung 14.24 Zugriff auf den allokierten Speicher
Wichtig ist auch, dass der Speicher, wenn wir ihn nicht mehr benötigen, wieder freigegeben werden muss.
free ( zeiger );
Der Speicher wird
wieder freigegeben.
Die Speicheradresse
darf danach nicht mehr
verwendet werden.
Abbildung 14.25 Freigabe des allokierten Speichers
Der Zeiger hat nach der Freigabe immer noch den alten Adresswert, dieser darf aber
nicht mehr zum Zugriff verwendet werden. Der Zeiger selbst kann natürlich weiterverwendet werden, wenn er einen neuen, gültigen Adresswert erhält.
Genauso können Sie vorgehen, wenn Sie es mit einer Datenstruktur zu tun haben.
Hier stellt sich allerdings die Frage, wie viel Speicher Sie für eine Datenstruktur anfordern müssen, da Sie in der Regel nicht genau wissen, wie viele Bytes eine Datenstruktur konkret im Speicher belegt. Sie sollten jetzt auch nicht anfangen, die Bytes in der
Struktur zu zählen. Dazu gibt es den sizeof-Operator, der uns die vom Compiler festgelegte, »amtliche« Größe mitteilt. Im folgenden Beispiel wird der Speicher für ein
Kalenderdatum allokiert, kurz verwendet und wieder freigegeben:
A
B
C
416
struct datum
{
int tag;
int monat;
int jahr;
};
struct datum *pdat;
pdat = (struct datum *)malloc( sizeof( struct datum));
14.5
D
E
Dynamische Datenstrukturen
pdat->tag = 31;
pdat->monat = 12;
pdat->jahr = 2000;
free( pdat);
Listing 14.14 Allokieren einer Datenstruktur
In dem Programm wird die Datenstruktur für ein Kalenderdatum angelegt (A) und
ein Zeiger auf so eine Struktur bereitgestellt (B). Danach wird die Größe der Datenstruktur ermittelt und entsprechend Speicher angefordert (C). Innerhalb der Zeile
wird mit (struct datum *) der Datentyp angepasst und die Adresse schließlich dem
Zeiger zugewiesen. Die dynamisch allokierte Datenstruktur kann nun verwendet
werden (D), bevor sie zum Programmende wieder freigegeben wird (E).
In der Regel erfolgt die Freigabe des Speichers natürlich nicht unmittelbar nach einer
einmaligen Verwendung, sondern dann, wenn die Datenstruktur nicht mehr benötigt wird. Das kann an einer ganz anderen Stelle im Programm, unter Umständen
auch ganz am Ende des Programms, sein. Das Laufzeitsystem gibt übrigens bei Programmende alle Ressourcen, die Ihr Programm belegt hat, wieder frei. Dazu gehört
auch der von Ihnen allokierte Speicher. Trotzdem ist es guter Programmierstil, nicht
mehr benötigten Speicher zeitnah an das Laufzeitsystem zurückzugeben. Immer
wenn Sie in einem Ihrer Programme eine der Funktionen malloc oder calloc rufen,
sollten Sie sich Gedanken darüber machen, wo das zugehörige free gerufen wird.
Unser eigentliches Problem haben wir aber noch nicht gelöst, sondern nur verlagert.
Denn wenn wir eine unbekannte Zahl an Datenstrukturen verarbeiten wollen, können
wir diese zwar bedarfsgerecht allokieren, benötigen dazu aber eine unbekannte Zahl
an Zeigern. Zur Lösung des eigentlichen Problems verwenden wir jetzt einen ganz einfachen Trick. Immer wenn wir den Speicher für eine neue Datenstruktur holen, holen
wir uns auch den Speicher für einen Zeiger auf eine weitere Datenstruktur. Das heißt,
wir bauen in die Datenstruktur einen Zeiger auf die nächste Datenstruktur ein. Dieser
Gedanke führt uns zum Konzept der Liste (siehe Abbildung 14.26).
Wir erweitern die Datenstruktur für ein Kalenderdatum in diesem Sinne:
A
struct datum
{
struct datum *next;
int tag;
int monat;
int jahr;
};
Listing 14.15 Erweiterte Datenstruktur
417
14
Datenstrukturen
Das ist der Listenanker.
Der Datentyp ist »Zeiger auf
datum«, also struct datum *.
Das ist der Verkettungszeiger.
Der Datentyp ist »Zeiger auf
datum«, also struct datum *.
next
next
tag
monat
jahr
tag
monat
jahr
datum
next
datum
liste
datum
14
tag
monat
jahr
Das sind die Listenelemente vom
Typ struct datum.
Abbildung 14.26 Das Konzept einer Liste
Wir haben nun innerhalb der Struktur einen Zeiger auf ein Folgeelement eingeführt
(A). Mit dieser Datenstruktur können wir eine gegebenenfalls sehr lange Kette von
Daten aufbauen. Dazu muss es allerdings eine Möglichkeit geben, das Ende der Kette
zu erkennen. Als Endemarkierung eignet sich der Adresswert 0, da 0 keine gültige
Speicheradresse ist. Um deutlich zu machen, dass Sie eigentlich nicht den Adresswert
0, sondern den Nullzeiger meinen, können Sie auch NULL schreiben. Bei NULL handelt
es sich um ein Makro, das durch (void *)0 definiert ist. Bei NULL handelt es sich also
um einen unspezifizierten Zeiger mit dem Adresswert 0.
Wir erstellen jetzt das Hauptprogramm, in dem wir eine Liste anlegen:
A
B
C
D
void main()
{
struct datum *liste = NULL;
liste = eingabe();
ausgabe( liste);
freigabe( liste);
}
In der Funktion ist in (A) der Listenanker definiert. Zu Beginn ist die Liste noch leer.
Die Arbeit mit der Liste habe ich in drei Unterprogramme ausgelagert (B, C und D),
die wir einzeln betrachten wollen. In der ersten Funktion (eingabe) wird die Liste aufgebaut, und ein Zeiger auf den Listenanker wird an das aufrufende Programm
zurückgegeben.
418
14.5
struct datum *eingabe()
{
struct datum *anker = NULL;
struct datum *pneu;
int weiter;
Dynamische Datenstrukturen
Dies ist der Listenanker. Die Liste ist noch leer.
Hilfszeiger für neue Listenelemente
for( ; ;)
Der Benutzer will noch ein weiteres Datum
{
eingeben, also wird eine neue Datenstruktur
printf( "Noch ein Datum? ");
allokiert und mit Werten gefüllt.
scanf( "%d", &weiter);
if( !weiter)
break;
pneu = (struct datum *)malloc( sizeof( struct datum));
printf( "Datum: ");
scanf( "%d.%d.%d", &pneu->tag, &pneu->monat, &pneu->jahr);
pneu->next = anker;
anker = pneu;
}
return anker;
}
1
Das neue Element wird vorn in die Liste eingekettet:
2
anker
2
1
neu
Der Listenanker wird zurückgegeben.
14
Abbildung 14.27 Die Funktion eingabe
Das Hauptprogramm speichert sich den Rückgabewert dieser Funktion als Listenanker und kann dann nach Belieben weitere Operationen auf der Liste ausführen. Dazu
iteriert man in der Regel über die Elemente der Liste, um dann auf einzelnen Elementen die gewünschten Operationen auszuführen. Als Beispiel geben wir die Liste auf
dem Bildschirm aus:
Diese Liste soll ausgegeben werden.
void ausgabe( struct datum *liste)
{
struct datum *pd;
Hilfszeiger
printf( "Daten in der Liste:\n");
Starte am
Listenanfang.
Solange die Liste
nicht zu Ende ist …,
… gehe zum nächsten
Listenelement.
for( pd = liste; pd; pd = pd->next)
printf( " %d.%d.%d\n", pd->tag, pd->monat, pd->jahr);
}
Gib ein Listenelement aus.
Abbildung 14.28 Die Funktion ausgabe
419
14
Datenstrukturen
Dieses Beispiel zeigt, wie einfach und elegant Sie mit Zeigern auf dynamischen
Datenstrukturen arbeiten können. Sie hätten sogar auf den Hilfszeiger verzichten
können und direkt mit der Kopie des Listenankers durch die Liste iterieren können.
Sie können, auch wenn ich das hier nicht zeige, die Liste jederzeit verändern, indem
Sie Elemente einfügen oder entfernen. Die Liste bleibt im Speicher erhalten, bis sie
explizit durch Aufruf der Funktion freigabe wieder beseitigt wird.
Diese Liste soll vollständig freigegeben werden, …
static void freigabe( struct datum *liste)
{
struct datum *pd;
… solange die Liste nicht leer ist.
for( ; liste;)
{
1
pd= liste;
liste = liste->next;
free( pd);
2
}
}
Das erste Element wird im Hilfszeiger
gemerkt, dann ausgekettet
2
liste
pd
1
und abschließend freigegeben.
Abbildung 14.29 Die Funktion freigabe
Jetzt kann der Benutzer so viele Kalenderdaten eingeben, wie er will. Die Eingabe ist
nur durch den verfügbaren Speicher begrenzt. Würde man diese Grenze erreichen,
sodass kein Speicher mehr zugeteilt werden könnte, würde die Funktion malloc
einen Nullzeiger zurückgeben. Ich überprüfe das hier allerdings nicht, da diese
Grenze durch manuelle Eingaben nicht erreicht werden kann. Alle Daten werden in
der Liste gesammelt und nach Abschluss der Eingabe in umgekehrter Reihenfolge
wieder ausgegeben.
A
B
C
D
void main()
{
struct datum *liste = NULL;
liste = eingabe();
ausgabe( liste);
freigabe( liste);
}
Wir erhalten z. B. das folgende Ergebnis:
420
14.6
Ein vollständiges Beispiel (Teil 2)
Noch ein Datum? 1
Datum 29.12.1999
Noch ein Datum? 1
Datum 30.12.1999
Noch ein Datum? 1
Datum 31.12.1999
Noch ein Datum? 1
Datum 1.1.2000
Noch ein Datum? 0
Daten in der Liste:
1.1.2000
31.12.1999
30.12.1999
29.12.1999
Dass die Ausgabe in umgekehrter Reihenfolge erfolgt, liegt daran, dass wir neue Elemente immer am Anfang der Liste einfügen. Wollte man die Eingabereihenfolge
erhalten, müsste man neue Elemente am Ende der Liste anfügen. Dazu erhalten Sie
später noch ein Beispiel.
14.6
Ein vollständiges Beispiel (Teil 2)
Zum Abschluss dieses Kapitels werden wir das Beispiel mit der Länderspielbilanz
vollständig auf dynamische Datenstrukturen umstellen. Große Teile des Codes aus
der nicht dynamischen Anfangsversion können wir dabei übernehmen, einiges müssen wir aber ändern.
Als Erstes ändern wir die Datenstruktur. Wir arbeiten jetzt mit einer Liste von Bilanzen. Die umfassende Datenstruktur (struct daten) mit dem Array und der Anzahl der
Array-Elemente benötigen wir nicht mehr, dafür müssen wir aber zur Bilanz ein Verkettungsfeld hinzufügen:
A
B
struct bilanz
{
struct bilanz *next;
char *name;
struct spiele ergebnisse;
struct tore treffer;
struct datum erstes;
struct datum letztes;
};
421
14
14
Datenstrukturen
Neben diesem hinzugefügten Feld next (A) habe ich zusätzlich das Array für den Ländernamen, das fest auf 30 Zeichen ausgelegt war, durch einen Zeiger ersetzt (B). Das
bedeutet, dass der Ländername jetzt nicht mehr innerhalb der Datenstruktur bilanz
steht, sondern außerhalb der Struktur allokiert werden muss. Innerhalb der Struktur
steht, jetzt nur noch ein Zeiger, der auf den Namen verweist. Vorher wurden 30 Bytes
für jeden Ländernamen verbraucht – egal, wie lang der Ländername wirklich war.
Ländernamen mit mehr als 29 Zeichen (+ Terminator) waren nicht möglich. Jetzt
wird für jeden Namen nur noch so viel Speicher allokiert, wie er wirklich belegt, und
Ländernamen können mehr als 30 Zeichen enthalten. Beim Zugriff auf den Ländernamen besteht übrigens kein Unterschied, da ein Array ja immer schon wie ein Zeiger behandelt wurde. Die Änderungen müssen wir beim Lesen einer Bilanz
berücksichtigen:
A
B
C
struct bilanz *lies_bilanz( FILE *pf)
{
char land[100];
struct bilanz *pb;
fscanf( pf, "%s", land);
if( feof( pf))
return NULL;
pb = (struct bilanz *)malloc( sizeof( struct bilanz));
D
E
pb->name = (char *)malloc( strlen( land)+1);
strcpy( pb->name, land);
F
fscanf( pf, "%d %d %d %d", &pb->ergebnisse.gesamt,
&pb->ergebnisse.gew,
&pb->ergebnisse.unent,
&pb->ergebnisse.verl);
fscanf( pf, "%d:%d", &pb->treffer.dfb,
&pb->treffer.gegner);
fscanf( pf, "%d.%d.%d", &pb->erstes.tag,
&pb->erstes.monat,
&pb->erstes.jahr);
fscanf( pf, "%d.%d.%d", &pb->letztes.tag,
&pb->letztes.monat,
&pb->letztes.jahr);
return pb;
}
G
Listing 14.16 Geändertes Einlesen einer Bilanz
422
14.6
Ein vollständiges Beispiel (Teil 2)
Zunächst wird versucht, den Ländernamen in einen 100 Zeichen großen Puffer zu
lesen (A, B). Dabei wird festgestellt, ob es überhaupt noch einen Datensatz gibt. Gibt
es noch einen Datensatz, wird zuerst der erforderliche Speicher für eine Bilanz allokiert (C). Danach wird der Speicher für den Ländernamen in der erforderlichen Länge
(Stringlänge + Terminatorzeichen) geholt und direkt mit der Bilanz verknüpft (D).
Jetzt ist ausreichend Speicher bereitgestellt, der Ländername kann kopiert (E) werden, und die restlichen Daten können aus der Datei nachgeladen werden (F). Die
Funktion gibt am Ende einen Zeiger auf den neuen Datensatz zurück (G).
Auf der übergeordneten Aufrufebene öffnen wir die Datei und verketten die einzelnen Bilanzen zu einer Liste:
A
B
C
struct bilanz *lies_datei( char *dateiname)
{
FILE *pf;
struct bilanz *liste, *pb;
pf = fopen( dateiname, "r");
if( !pf)
return NULL;
for( liste = NULL; pb = lies_bilanz( pf); )
{
pb->next = liste;
liste = pb;
}
fclose( pf);
return liste;
}
14
Listing 14.17 Geändertes Einlesen einer Datei
Die Rückgabe der Funktion ist eine Liste von Bilanzen, dazu erhält sie als Parameter
den Namen der Datei, in der die Daten stehen (A). Die Funktion liest in einer Schleife
jeweils eine Bilanz aus der geöffneten Datei und gibt einen Zeiger auf den Datensatz
zurück (B). Der neue Datensatz wird dann am Anfang der Liste eingekettet (C).
Das rufende Programm erhält einen Zeiger auf das erste Listenelement und kann
damit iterativ auf die gesamte Liste zugreifen. Da immer am Anfang der Liste eingekettet wird, ergibt sich eine Liste, in der die Länder alphabetisch rückwärts sortiert
sind. Das ist nicht weiter problematisch, aber wenn es Sie stört, können Sie die Kette
auch umgekehrt aufbauen. Wenn Sie dabei die doppelte Indirektion verwenden, geht
das Einketten am Ende sogar einfacher als das Einketten am Anfang:
423
Datenstrukturen
A
B
struct bilanz *lies_datei2( char *dateiname)
{
FILE *pf;
struct bilanz *liste;
struct bilanz **ppb;
pf = fopen( dateiname, "r");
if( !pf)
return NULL;
for( ppb = &liste; *ppb = lies_bilanz( pf); ppb = &((*ppb)->next))
;
fclose( pf);
return liste;
}
Listing 14.18 Einlesen der Datei mit Einketten am Ende
In der abgewandelten Version der Funktion wird der Zeiger auf den Listenanfang (A)
verwendet sowie ein Zeiger auf einen Zeiger auf eine Bilanz (B) – eine doppelte Indirektion.
Wenn Sie gerade erst gelernt haben, mit Indirektion (Zeigern) umzugehen, ist es
sicherlich nicht ganz einfach, mit doppelter Indirektion konfrontiert zu werden. Aber
wir wollen es einmal versuchen. Zunächst ist da die Variable ppb. Vor dieser Variablen
steht ein doppelter Stern. Diese Variable ist damit ein Zeiger – und zwar ein Zeiger, der
auf einen anderen Zeiger zeigt. Die Variable ppb enthält also die Adresse eines Zeigers
auf eine Bilanz. Wir wollen diese Variable in diesem Programm so verwenden, dass sie
immer auf die Adresse im Speicher zeigt, an der die nächste Bilanz eingetragen werden
muss. Am Anfang ist das die Adresse des Listenankers – also ppb = &liste. Mit der
Anweisung *ppb = lies_bilanz( pf) tragen wir dann an dieser Stelle einen neuen Zeiger auf eine Bilanz ein. Mit der Anweisung (*ppb)->next gehen wir danach zum Verkettungsfeld dieser frisch eingetragenen Bilanz und ermitteln mit dem AdressOperator die Speicheradresse dieses Verkettungsfeldes – also &(*ppb)->next). Das ist
die Adresse, an der der Zeiger auf die nächste Bilanz eingetragen werden muss.
ppb
next
Abbildung 14.30 Darstellung der doppelten Indirektion
424
next
bilanz
next
bilanz
liste
bilanz
14
14.6
Ein vollständiges Beispiel (Teil 2)
Wird im Unterprogramm keine Bilanz mehr gefunden, kommt eine 0 zurück und
wird ebenfalls eingetragen. Damit ist die Liste automatisch terminiert und in der Reihenfolge aufgebaut, in der die Datensätze in der Datei stehen.
Wir dürfen natürlich die Freigabe der Liste nicht vergessen. Dazu erstellen wir eine
Funktion, die wir am Ende unseres Hauptprogramms rufen werden:
A
B
C
D
E
F
void freigabe( struct bilanz *liste)
{
struct bilanz *pb;
while( liste)
{
pb = liste;
liste = liste->next;
free( pb->name);
free( pb);
}
}
14
Listing 14.19 Die Funktion freigabe
In der Funktion wird die komplette Liste freigegeben (A). Die Freigabe wird fortgesetzt, solange die Liste nicht leer ist (B). Zur Freigabe wird das erste Element der Liste
genommen und ausgekettet (C) und (D). Bevor die Bilanz in (F) freigegeben wird,
muss der Speicher für den Ländernamen freigegeben werden (E).
Wenn wir jetzt noch eine Funktion zur Ausgabe einer Bilanz erstellen,
void print_bilanz( struct bilanz *pb)
{
printf( "%-25s", pb->name);
printf( " %3d %3d %3d %3d", pb->ergebnisse.gesamt,
pb->ergebnisse.gew,
pb->ergebnisse.unent,
pb->ergebnisse.verl);
printf( " %4d:%-4d", pb->treffer.dfb,
pb->treffer.gegner);
printf( " %02d.%02d.%4d", pb->erstes.tag,
pb->erstes.monat,
pb->erstes.jahr);
printf( " %02d.%02d.%4d", pb->letztes.tag,
pb->letztes.monat,
pb->letztes.jahr);
425
14
Datenstrukturen
printf( "\n");
}
Listing 14.20 Ausgeben eine Bilanz
können wir testen, ob der Auf- und Abbau der Liste klappen.
void main()
{
struct bilanz *liste;
struct bilanz *pb;
A
liste = lies_datei2( "Laenderspiele.txt");
B
for( pb = liste ; pb; pb = pb->next)
print_bilanz( pb);
C
freigabe( liste);
}
Listing 14.21 Auf- und Abbau der Liste
Das entsprechende Hauptprogramm ist übersichtlich, es besteht nur aus dem Einlesen der Liste (A), ihrer Ausgabe (B) und der Freigabe aller Daten (C).
Das Programm liefert die vollständige Liste als Ausgabe, von der wir hier nur die ersten Zeilen darstellen:
Aegypten
Albanien
Algerien
Argentinien
Armenien
Aserbaidschan
Australien
Belgien
Boehmen-Maehren
Bolivien
1 0
14 13
2 0
20 6
2 2
4 4
4 3
25 20
1 0
1 1
0
1
0
5
0
0
0
1
1
0
1
0
2
9
0
0
1
4
0
0
1:2
38:10
1:4
28:28
9:1
15:2
12:5
58:26
4:4
1:0
28.12.1958
08.04.1967
01.01.1964
08.06.1958
09.10.1996
12.08.2009
18.06.1974
16.05.1910
12.11.1939
17.06.1994
28.12.1958
06.06.2001
16.06.1982
15.08.2012
10.09.1997
07.06.2011
29.03.2011
11.10.2011
12.11.1939
17.06.1994
Nachdem wir die Länderspielbilanz erfolgreich eingelesen haben, haben wir eine
kleine »Datenbank« im Speicher unseres Rechners, die wir befragen können. Typischerweise iteriert man dazu über die Daten und wählt die Datensätze aus, die
bestimmten Kriterien entsprechen. Wir wollen Ihnen dazu einige Beispiele geben.
426
14.6
Ein vollständiges Beispiel (Teil 2)
Als Erstes wollen wir die Bilanz eines Landes anhand des Ländernamens finden. Dazu
erstellen wir die folgende Funktion:
A
B
C
D
struct bilanz *select_land( struct bilanz *pb, char *land)
{
for( ; pb; pb = pb->next)
{
if( !strcmp( land, pb->name))
return pb;
}
return NULL;
}
Listing 14.22 Suchen einer Bilanz anhand des Ländernamens
Die Funktion sucht in der übergebenen Liste pb das Land land (A). Dazu wird über die
Liste iteriert (B). Falls in der Schleife die Namen übereinstimmen, wird ein Zeiger auf
den gefundenen Datensatz zurückgegeben (C). Wenn das Land nicht gefunden
wurde, erfolgt die Rückgabe von NULL (D).
Im Hauptprogramm suchen wir »Italien« und geben die Bilanz gegen Italien auf dem
Bildschirm aus:
void main()
{
struct bilanz *liste;
struct bilanz *pb;
liste = lies_datei2( "Laenderspiele.txt");
A
B
pb = select_land( liste, "Italien");
if( pb)
print_bilanz( pb);
freigabe( liste);
}
Listing 14.23 Testen der Bilanzsuche
Das Hauptprogramm sucht die Daten für Italien (A), gibt den gefundenen Datensatz
aus (B) und erzielt damit dieses Ergebnis:
Italien
31
7
9 15
35:47
01.01.1923 28.06.2012
427
14
14
Datenstrukturen
Auch Suchen mit mehreren Treffern stellen kein Problem dar. Wir suchen z. B. alle
Länder mit dem Anfangsbuchstaben 'B' und erstellen dazu die folgende Funktion:
A
B
C
D
struct bilanz *select_buchstabe( struct bilanz *pb, char buchstabe)
{
for( ; pb; pb = pb->next)
{
if( pb->name[0] == buchstabe)
return pb;
}
return NULL;
}
Listing 14.24 Selektieren eines Landes anhand des Anfangsbuchstabens
Die Funktion sucht in der Liste der Bilanzen pb das erste Land, dessen Name mit diesem Buchstaben beginnt (A). Dazu wird innerhalb der Funktion über die Liste iteriert
(B). Falls die Buchstaben übereinstimmen, wird ein Zeiger auf den gefundenen
Datensatz zurückgegeben (B). Wenn die Funktion keinen Treffer findet, gibt sie den
Wert NULL zurück (D).
Abgesehen von dem unterschiedlichen Vergleichskriterium, ist diese Funktion identisch mit der Funktion zur Ländersuche. Wir werden diese Funktion nur etwas anders
verwenden:
void main()
{
struct bilanz *liste;
struct bilanz *pb;
A
B
liste = lies_datei2( "Laenderspiele.txt");
for( pb = liste; pb = select_buchstabe( pb, 'B'); pb = pb->next)
print_bilanz( pb);
freigabe( liste);
}
Listing 14.25 Ausgabe der Länderspiele gegen Länder mit ›B‹
Die Schleife (A) startet am Listenanfang, läuft so lange, bis ein weiteres Land gefunden wird, und geht nach jedem Durchlauf zum nächsten Land. Innerhalb des Schleifenkörpers wird dann nur noch der Treffer ausgegeben (B).
428
14.6
Belgien
Boehmen-Maehren
Bolivien
Bosnien-Herzegowina
Brasilien
Bulgarien
25 20
1 0
1 1
2 1
21 4
21 16
1 4
1 0
0 0
1 0
5 12
2 3
58:26
4:4
1:0
4:2
24:39
56:24
Ein vollständiges Beispiel (Teil 2)
16.05.1910
12.11.1939
17.06.1994
11.10.2002
05.05.1963
20.10.1935
11.10.2011
12.11.1939
17.06.1994
03.06.2010
10.08.2011
21.08.2002
Wir verwenden die Funktion jetzt iterativ, indem wir uns in einer Schleife immer den
nächsten Treffer geben lassen. Beachten Sie, dass im Test der Schleife kein Vergleich,
sondern eine Zuweisung steht. Das Ergebnis des Funktionsaufrufs wird dem Zeiger
pb zugewiesen, und solange das Ergebnis nicht 0 ist, also ein weiterer Treffer gefunden wurde, wird weitergemacht.
Ich möchte Ihnen an diesem Beispiel noch einmal das Prinzip der Callback-Funktionen demonstrieren. Sie haben dieses wichtige Prinzip im Zusammenhang mit Funktionszeigern ja bereits kennengelernt. Wir haben hier zwei select-Funktionen, die im
Prinzip identisch sind, nur in ihrem Inneren jeweils eine andere Testfunktion verwenden. Viele weitere Tests sind denkbar. Das kann man vereinheitlichen, wenn
man der select-Funktion die Testfunktion als Parameter übergibt. Die Situation ist
hier aber insofern etwas komplizierter, als die Testfunktionen ihrerseits Vergleichsparameter (Ländername, Anfangsbuchstabe) benötigen, die der select-Funktion unbekannt und darüber hinaus strukturell verschieden sind. Wir machen uns
zunächst einmal klar, wie es ablaufen soll:
왘
Das Hauptprogramm ruft die select-Funktion und übergibt dieser die Liste, die
Testfunktion und den Vergleichsparameter.
왘
Die select-Funktion iteriert über die Liste und ruft für jedes Element in der Liste
die Testfunktion. Sie übergibt der Testfunktion dabei das Element und den Vergleichsparameter.
왘
Die Testfunktion prüft anhand des Vergleichsparameters, ob das Element das Vergleichskriterium erfüllt, und meldet an die select-Funktion zurück, ob das Element ausgewählt werden soll.
왘
Die select-Funktion gibt das nächste ausgewählte Element an das Hauptprogramm zurück.
Problematisch ist, dass die select-Funktion den Typ des Vergleichsparameters nicht
kennt und auch nicht kennen darf, weil dies die Allgemeinheit des Ansatzes verletzen würde. Wir übergeben der select-Funktion daher einen unspezifizierten Zeiger
(void *), den wir dort, wo wir den Typ kennen, wieder in einen spezifischen Zeiger
zurückverwandeln. Wir fangen mit der Implementierung bei der Testfunktion an.
Um ein anderes Testszenario zu haben, wollen wir einen Datumsvergleich vornehmen:
429
14
14
Datenstrukturen
A
B
int test( struct bilanz *pb, void *p)
{
struct datum *pd = (struct datum *)p;
int d1, d2;
C
D
d1 = ((pd->jahr * 12) + pd->monat)*31 + pd->tag;
d2 = ((pb->letztes.jahr * 12) + pb->letztes.monat)*31
+ pb->letztes.tag;
E
return d2 >= d1;
}
Listing 14.26 Datumsvergleich
Die Schnittstelle der Funktion beinhaltet einen Zeiger pb auf die zu untersuchende
Bilanz und p als Vergleichsparameter. Hier handelt es sich eigentlich um einen Zeiger
auf ein Kalenderdatum (struct datum *), der als Erstes mit dem Cast-Operator auf den
korrekten Datentyp umgesetzt wird (B).
Innerhalb der Funktion erfolgt dann die Berechnung der Tage in den Kalenderdaten
(C und D). Wenn das Spiel nach dem übergebenen Datum (pd) stattgefunden hat, wird
eine 1 zurückgegeben, ansonsten eine 0.
Zum Datumsvergleich berechne ich hier ein Tagesäquivalent, das auf zwölf Monaten
mit 31 Tagen beruht. Das ist aber nicht das Wesentliche in diesem Programm. Das
Wesentliche ist die Schnittstelle, an der ein Zeiger auf eine Bilanz und ein weiterer,
unspezifizierter Zeiger übergeben werden. Das Programm geht davon aus, dass sich
hinter dem unspezifizierten Zeiger in Wirklichkeit ein Zeiger auf ein Kalenderdatum
verbirgt, und interpretiert den Zeiger entsprechend. Dies zeigt noch einmal deutlich,
dass bei einer Datenstruktur nur eine Schablone über die eigentlichen Daten gelegt
wird. Und dieses Programm betrachtet die Daten jetzt durch die Schablone eines
Kalenderdatums. Die Schnittstelle zum rufenden Programm ist:
int test (struct bilanz *, void *);
Das übergeordnete Programm, das wir jetzt erstellen werden, weiß gar nicht, was es
in dem unspezifizierten Zeiger transportiert, da es nur diese Schnittstelle kennt:
A
B
C
430
struct bilanz *select( struct bilanz *pb, int testfkt( struct bilanz *
, void *data), void *p)
{
for( ; pb; pb = pb->next)
{
if( testfkt( pb, p))
14.6
D
E
Ein vollständiges Beispiel (Teil 2)
return pb;
}
return NULL;
}
Listing 14.27 Die Funktion select
Die Schnittstelle der Funktion (A) enthält als ersten Parameter pb als Zeiger auf eine
Bilanz, fkt als Zeiger auf eine Funktion, die einen Zeiger auf eine Bilanz und einen Zeiger
auf einen unspezifizierten Zeiger erhält und einen int-Wert zurückgibt, und schließlich
als dritten Parameter einen unspezifizierten Zeiger als Vergleichsparameter.
Die Funktion iteriert über die komplette Liste (B) und ruft für jeden Listeneintrag die
als Parameter übergebene Funktion mit der aktuell betrachteten Bilanz und dem
Vergleichsparameter auf (C). Wenn der Vergleich erfolgreich war, wird die entsprechende Bilanz ausgewählt und zurückgegeben (D). Wenn keine passende Bilanz
gefunden wird, gibt die Funktion NULL zurück (E).
Die Funktion select iteriert also über die Bilanzen und fragt bei jeder Bilanz bei der
Callback-Funktion nach, ob die Bilanz gewählt werden soll. Dabei übergibt sie transparent den Vergleichsparameter an die Callback-Funktion, ohne dessen Typ oder
Bedeutung zu kennen.
Im Hauptprogramm müssen wir jetzt nur noch diese Funktion geeignet aufrufen
und erhalten eine Liste aller Länder, gegen die es nach dem 1.1.2013 noch ein Länderspiel gegeben hat:
A
B
void main()
{
struct bilanz *liste;
struct bilanz *pb;
struct datum dat = {1,1,2013};
liste = lies_datei2( "Laenderspiele.txt");
for( pb = liste; pb = select( pb, test, (void *)&dat); pb = pb->next)
print_bilanz( pb);
freigabe( liste);
}
Listing 14.28 Hauptprogramm zur Datumssuche
Das Programm definiert zuerst ein Vergleichsdatum (A), liest die Daten aus der Datei
und ruft dann die select-Funktion mit drei Parametern auf, und zwar pb als die Liste
431
14
14
Datenstrukturen
der Bilanzen, test als Callback-Funktion und das Vergleichsdatum dat als unspezifizierten Zeiger (B).
Als Ergebnis wird dargestellt:
Ecuador
Frankreich
Kasachstan
Paraguay
USA
2
25
4
2
9
2
8
4
1
6
0 0
6 11
0 0
1 0
0 3
7:2
42:41
14:1
4:3
21:15
20.06.2006
15.03.1931
12.10.2010
15.06.2002
13.06.1993
29.05.2013
06.02.2013
26.03.2013
14.08.2013
02.06.2013
Für eine einzelne Verwendung der select-Funktion würde man diesen Programmieraufwand sicherlich nicht in Kauf nehmen, aber bei jeder weiteren Verwendung
würde man von der einmal geleisteten Arbeit profitieren, da man jetzt nur noch eine
Callback-Funktion erstellen muss, die prüft, ob eine Bilanz ausgewählt werden soll.
Solche Programmiertechniken verwendet man daher immer dann, wenn man ein
allgemeines Vorgehen sehr häufig in speziellen unterschiedlichen Situationen
anwenden will – typischerweise in Funktionsbibliotheken.
14.7
Die Freispeicherverwaltung
Mit den Funktionen zur Freispeicherverwaltung (malloc, calloc, realloc und free)
können wir uns – im Rahmen des verfügbaren Speichers – von den Fesseln der statischen Datenstrukturen lösen. Doch zusätzliche Freiheiten bringen auch zusätzliche
Gefahren, und es ist eine besondere Sorgfalt im Umgang mit den erweiterten Möglichkeiten geboten. Durch die neuen Funktionen ergibt sich eine ganz neue Art von
schwer zu entdeckenden, ja geradezu heimtückischen Fehlerquellen. Die neue Qualität dieser Fehler liegt darin, dass sie:
왘
vom Compiler nicht entdeckt werden können und erst zur Laufzeit auftreten
왘
sich wie Zeitbomben verhalten, da die Auswirkungen eines Fehlers nicht unmittelbar, sondern unter Umständen erst lange, nachdem der Fehler gemacht worden
ist, sichtbar werden
왘
Fernwirkung haben können, da die Fehlersymptome an ganz anderen Stellen des
Programms auftreten als die Fehler und keine ursächliche Verknüpfung zwischen
Fehlerursache und Fehlerwirkung zu erkennen ist
왘
schwer zu reproduzieren sind, da sie stark vom dynamischen Programmkontext
abhängen und manchmal nur in selten vorkommenden Konstellationen auftreten
왘
sich manchmal gar nicht zeigen, sondern das Programm nur verschlechtern oder
bei Langzeitbetrieb das System zunehmend beanspruchen oder gar blockieren
432
14.7
Die Freispeicherverwaltung
Um diese neuen Fehlerquellen zu verstehen und um Fehler im Umgang mit dem
Freispeichersystem zu vermeiden, müssen wir uns mit der dynamischen Speicherverwaltung des Laufzeitsystems beschäftigen. Es handelt sich dabei natürlich um
Dienste, die vom jeweiligen Betriebssystem bereitgestellt werden und daher auf verschiedenen Systemen unterschiedlich implementiert sein können. Trotzdem gibt es
einige gemeinsame Prinzipien, die Sie hier kennenlernen sollen.
Das Betriebssystem verwaltet den Speicher in Form von Seiten (Pages). Typische
Page-Größen sind 1 oder 4 Kilobyte. Bei einem virtuellen Speichersystem sieht der CProgrammierer gar nicht die wirklichen (physikalischen) Adressen, sondern das System stellt einem Programm einen virtuellen Adressraum zur Verfügung, der unter
Umständen viel größer sein kann als der vorhandene, physikalische Adressraum.
Das Betriebssystem muss dann natürlich sicherstellen, dass immer dann, wenn ein
Programm über eine virtuelle Adresse zugreift, die physikalische Adresse hinterlegt
wird. Diesen Vorgang des bedarfsgerechten Seitenwechsels nennt man »Paging«.
Da ein Programm aber seinen Speicher in unterschiedlichen Größenordnungen
anfordern möchte, muss es über dem Paging-System noch eine Organisation zur
Speicherzuteilung geben. Das System verwaltet dazu eine Liste freier Speicherblöcke.
Diese Liste ist z. B. nach aufsteigenden Speicheradressen geordnet. Jeder Speicherblock enthält am Anfang eine gewisse Verwaltungsinformation (Länge, nächster
freier Block, ...). Fordert nun ein Programm Speicher einer gewissen Größe an, durchläuft das System diese Kette auf der Suche nach einem Speicherblock geeigneter
Größe. Es gibt dafür verschiedene Strategien. Das System kann sich für den ersten
Block, der groß genug ist (First Fit), oder für den kleinsten freien Block, der groß
genug ist (Best Fit), entscheiden. Wird kein Block gefunden, werden neue Pages vom
Betriebssystem angefordert. Geht das auch nicht, kann die Nachfrage nicht befriedigt
werden. Wurde ein Block gefunden, kann das System den Block noch optimal anpassen, indem der nicht benötigte Teil abgespalten und in die Freikette aufgenommen
wird. Das System kann aber auch einfach einen zu großen Block ausliefern, etwa
wenn der Rest zu klein ist, um noch nach Abzug des Verwaltungsanteils genutzt zu
werden. In jedem Fall liefert das System als Ergebnis der Speicherallokierung einen
Zeiger auf den Nutzbereich des allokierten Blocks an das rufende Programm.
Wird ein Speicherblock wieder zurückgegeben, wird der Block wieder in die Freikette
eingefügt. Beachten Sie, dass das System die Größe des Blocks an der transparent
mittransportierten Verwaltungsinformation erkennen kann. Um eine unnötige
Fragmentierung des Speichers zu vermeiden, werden dann benachbarte Blöcke in
der Freikette wieder zu größeren Einheiten zusammengefasst.
Der Speicher ist letztlich ein großer Flickenteppich, bestehend aus Verwaltungsdaten
und nutzbaren Bereichen. Erstere sind entweder unter Kontrolle des Speicherverwaltungssystems oder transparent an ein Programm ausgeliehen. Letztere liegen entweder in der Freikette brach oder sind zur Nutzung an ein Programm ausgeliehen.
433
14
14
Datenstrukturen
Da im C-Laufzeitsystem Effizienz den höchsten Vorrang hat, gibt es keine Vorkehrungen, diese verschiedenen Speicherbereiche vor Missbrauch zu schützen, und auch
keine Vorkehrungen, ungenutzten Speicher automatisch der Freispeicherverwaltung wieder zuzuführen (Garbage Collection).
Im Folgenden zeigen wir Ihnen einige typische Fehler im Umgang mit dem Freispeichersystem.
Die hier zunächst aufgeführten Fehler führen in der Regel zu Programmabstürzen.
Sie sind jedoch schwer zu lokalisieren, da eine Beziehung zwischen Ursache und Wirkung kaum auszumachen ist.
Ursache
Auswirkung
Es wird vergessen, Speicher zu allokieren.
Über »wilde«, d. h. nicht sinnvoll initialisierte, Zeiger
wird irgendwo im Speicher geschrieben. Die Folge sind
korrupte Daten und/oder Programmabstürze.
Speicher wird nach der
Freigabe noch benutzt.
Dies kann noch gut gehen, bis das Betriebssystem diesen
Speicherbereich wieder ausliefert. Spätestens dann aber
treten unabsehbare Fehler auf.
Allokierter Speicher wird
mehrfach freigegeben.
Die Daten der Freispeicherverwaltung werden zerstört.
Früher oder später treten interessante Fehler oder
scheinbar unmotivierte Programmabstürze auf.
Ein Programmteil
schreibt in Datenbereichen anderer Programmteile.
In den anderen Programmteilen treten unerklärliche
Fehler auf, die ihrerseits wieder unabsehbare Folgefehler
haben können. Oft treten unerklärliche Fehler in Programmen auf, die seit langer Zeit stabil laufen. Oft sind
sogar beide Programmteile lange stabil gelaufen, und
der Fehler ist erst zu beobachten, nachdem an ganz
anderer Stelle etwas geändert und dadurch etwa nur die
Reihenfolge, in der die beiden Programmteile ablaufen,
vertauscht wurde. Eine Zuordnung von Ursache und Wirkung ist nicht möglich.
Das Programm überschreibt die Verwaltungsinformation des
Freispeichersystems.
Die Freispeicherverwaltung ist korrupt. Der Fehler muss
nicht sofort sichtbar werden, sondern erst, wenn das
System eine Operation auf dem korrupten Block durchführen will. Auch hier ist eine Zuordnung von Ursache
und Wirkung nicht möglich.
Tabelle 14.1 Ursache und Wirkung bei Fehlern in der Speicherverwaltung
434
14.8
Aufgaben
Darüber hinaus gibt es Fehler, die vielleicht gar nicht bemerkt werden, da sie nur in
einer schleichenden Verschlechterung des Laufzeitverhaltens des Programms sichtbar werden.
Ursache
Auswirkung
Allokierter Speicher wird
nicht freigegeben.
Geschieht dies etwa in einer häufig durchlaufenen
Schleife, kann das Programm krebsartig wachsen und
letztlich so viel Ressourcen beanspruchen, dass das System überlastet ist.
Speicher wird in zu großen Portionen allokiert.
Das System arbeitet ineffizient, da große Speicherbereiche ungenutzt sind.
Speicher wird in zu kleinen Portionen allokiert.
Das Programm arbeitet ineffizient. Gemeinsam zu verarbeitende Datenbereiche sind über viele Pages verstreut.
Das System wird durch übermäßiges Paging verlangsamt oder blockiert. Das Verhältnis von Nutzdaten zu
Verwaltungsdaten in der Freispeicherverwaltung ist sehr
schlecht.
14
Tabelle 14.2 Ursache und Wirkung bei Fehlern in der Speicherverwaltung
Scheinbar geringfügige Fehler dieser Art können insbesondere in großen Systemen,
bei denen in der Regel verschiedene Programmierer an den betroffenen Programmteilen arbeiten, einen erheblichen Aufwand bei der Fehlersuche verursachen.
14.8
Aufgaben
A 14.1 In Abschnitt 7.6.1, »Bruchrechnung«, haben wir Brüche durch zwei-elementige
Arrays dargestellt und die Addition und das Kürzen von Brüchen programmiert. Erstellen Sie die gleichen Funktionen erneut, verwenden Sie diesmal
zur Speicherung von Brüchen jedoch eine geeignete Datenstruktur.
A 14.2 Erstellen Sie eine Datenstruktur, die den Namen und das Alter einer Person
aufnehmen kann. Erstellen Sie dann ein Programm, das diese Daten für zehn
Personen einliest und nach Alter sortiert wieder ausgibt.
A 14.3 Erstellen Sie eine Funktion, die ein Array von ganzen Zahlen übergeben
bekommt und den größten, den kleinsten und den Mittelwert der übergebenen Werte in einer Datenstruktur zurückgibt.
A 14.4 Erstellen Sie eine Datenstruktur für ein Fußballturnier mit einer festen Anzahl
von Mannschaften. Bei dem Turnier spielt jede Mannschaft gegen jede Mann-
435
14
Datenstrukturen
schaft in einem Hinspiel und einem Rückspiel. Die Datenstruktur sollte folgende Informationen aufnehmen können:
왘
die Namen aller beteiligten Mannschaften
왘
die Ergebnisse aller durchgeführten Spiele
Darüber hinaus sollte eine Tabelle berechnet werden können und ebenfalls in
der Datenstruktur abgelegt werden. Zur Bearbeitung der Datenstruktur erstellen Sie folgende Funktionen:
왘
eine Funktion, die alle Daten einliest
왘
eine Funktion, die die Tabelle berechnet
왘
eine Funktion, die alle Daten und die aktuelle Tabelle ausgibt
A 14.5 Erstellen Sie ein Programm, das eine Liste von Zahlen verwaltet. Das Programm soll folgende Funktionen enthalten:
왘
Erzeugen der Liste mit einer wählbaren Anzahl fortlaufender Zahlen
왘
Ausgeben der Liste
왘
Ausgeben der Liste in umgekehrter Reihenfolge
왘
Addieren aller Zahlenwerte in der Liste
왘
Umkehren der Liste
왘
Freigeben der Liste
A 14.6 Erstellen Sie eine doppelt verkettete Liste mit fortlaufend nummerierten Zahlen als Nutzlast. Erstellen Sie dann eine Ausgabefunktion, mit der man interaktiv durch die Liste iterieren kann. Die Ausgabefunktion soll dabei durch
folgende Kommandos gesteuert werden:
왘
+ Vorwärtsschritt
왘
- Rückwärtsschritt
왘
0 Abbruch
In der Ausgabefunktion wird immer der Wert des aktuell betrachteten Listenelements ausgegeben.
436
Kapitel 15
Ausgewählte Datenstrukturen
Trees sprout up just about everywhere in Computer Science.
(Bäume schlagen in der Informatik praktisch überall aus.)
– Donald E. Knuth
Natürlich können wir nicht für alle möglichen Aufgabenstellungen angepasste
Datenstrukturen bereitstellen. Ähnlich wie bei Algorithmen müssen wir hier eine
Auswahl treffen. Die wichtigste Aufgabe von Datenstrukturen ist, eine große Menge
von Daten so zu speichern, dass konkrete Daten in der Datenstruktur effizient
gesucht und natürlich auch gefunden werden können. Dazu gibt es verschiedene
Ansätze, die wir in diesem Abschnitt verfolgen werden. Zunächst aber präzisieren wir
die Aufgabenstellung.
Stellen Sie sich vor, dass Sie in einem Programm das Telefonbuch einer großen Stadt
mit Hundertausenden von Einträgen verwalten wollen. Jeder Eintrag besteht aus
einer Datenstruktur, die den Namen, den Vornamen und die Telefonnummer des
Teilnehmers enthält. Sie wissen vorab nicht, wie viele Einträge es geben wird, und es
können jederzeit Einträge hinzukommen oder entfernt werden. Darüber hinaus soll
es möglich sein, über den Namen auf einen Eintrag zuzugreifen. Für dieses Szenario
wollen wir möglichst allgemeingütige Datenstrukturen modellieren und uns Gedanken über Speicher- und Zugriffseffizienz machen. Die einzige Datenstruktur, die Sie
bisher kennen und mit der Sie dieses Problem lösen könnten, ist eine Liste. Vielleicht
finden wir aber noch etwas Besseres.
Als Ausgangspunkt unserer Überlegungen betrachten wir eine vereinfachte Länderspieldatei, die für jeden Gegner der Nationalmannschaft nur den Ländernamen und
die Anzahl der Länderspiele enthält.
...
Bolivien
Oesterreich
Paraguay
Marokko
Belgien
...
1
38
2
4
25
Abbildung 15.1 Vereinfachte Länderspieldatei
437
15
15
Ausgewählte Datenstrukturen
Die Reihenfolge der Länder ist dabei nicht alphabetisch, sondern zufällig gewählt. Für
die Datensätze in der Datei haben wir auch bereits eine Datenstruktur gegner angelegt:
struct gegner
{
char *name;
int spiele;
};
Wir wollen jetzt einen »Container« programmieren, in dem wir eine unbeschränkte
Anzahl von Gegnern speichern können.
Bolivien
Oesterreich
Container
Paraguay
Marokko
Abbildung 15.2 Container zur Speicherung von Spielgegnern
Der Container soll dabei folgende Funktionen haben:
왘
Erzeuge einen leeren Container.
왘
Füge einen Gegner zum Container hinzu.
왘
Suche einen Gegner im Container.
왘
Lösche den Container mit seinem Inhalt.
Wir wollen verschiedene Speichertechniken für den Container entwickeln und diese
bezüglich ihrer Laufzeit- und Speichereffizienz miteinander vergleichen.
Konkret implementiert wird der Container als:
왘
Liste
왘
Binärbaum
왘
Treap
왘
Hash-Tabelle
Mit den Listen fangen wir an.
438
15.1
15.1
Listen
Listen
Eine Liste ist eine endliche Menge von (Daten-)Elementen, die durch eine Nachfolgeoperation miteinander verbunden oder verkettet sind. Über die Nachfolgeoperation
sind dann in naheliegender Weise die Begriffe Nachfolger und Vorgänger eines Elements definiert. Es gibt genau ein Element, das keinen Vorgänger hat. Dieses Element
heißt Listenanfang. Außerdem gibt es genau ein Element, das keinen Nachfolger hat.
Dieses Element heißt Listenende. Jedes Element der Liste ist vom Listenanfang aus
durch eine genau bestimmte Anzahl von Nachfolgeoperationen erreichbar.
Als grafische Notation für ein Element wählen wir ein Rechteck. Die Nachfolgeroperation visualisieren wir durch Pfeile:
Abbildung 15.3 Visualisierung einer Liste
15
Listen sind eine häufig anzutreffende und sehr flexible Form der Speicherung vorrangig sequenziell zu verarbeitender Daten. Die Daten können dabei durchaus inhomogen sein, müssen also untereinander weder die gleiche Struktur noch die gleiche
Größe haben. Jedes Datenelement enthält einen Zeiger auf das nächstfolgende Element. Das heißt, in jeder Datenstruktur ist die Adresse der nachfolgenden Datenstruktur eingetragen. Die Liste wird durch den Null-Zeiger abgeschlossen. Der NullZeiger ist ein Zeiger mit dem Wert 0. Da 0 nicht als normaler Adresswert vorkommt,
kann man mit diesem Wert das Listenende markieren.
Enthält jedes Element der Liste auch einen Rückverweis auf seinen Vorgänger, sprechen wir von einer doppelt verketteten Liste.
Abbildung 15.4 Visualisierung einer doppelt verketteten Liste
439
15
Ausgewählte Datenstrukturen
Eine doppelt verkettete Liste kann man vorwärts wie rückwärts durchlaufen.
Häufig gehören zu einer Liste noch zwei weitere Zeiger: ein Zeiger auf das erste Element (Anker), um für eine sequenzielle Verarbeitung in die Liste einsteigen zu können, und ein weiterer Zeiger auf das letzte Element, um am Ende anfügen zu können,
ohne die ganze Liste sequenziell durchlaufen zu müssen:
Listenende
Listenanker
Abbildung 15.5 Doppelt verkettete Liste mit Anker und Ende
Dies ist eine logische Sicht. Im Speicher können die einzelnen Listenelemente in
beliebiger Reihenfolge verstreut liegen.
Die Frage, ob Sie in einem Programm ein Array, eine einfach verkettete oder eine
doppelt verkette Liste verwenden sollten, können Sie anhand der folgenden Vergleichstabelle zu entscheiden versuchen:
Operation
Array
Einfach verkettete
Liste
wahlfreier
Zugriff
sehr gut durch
Zugriff über Index
schlecht, da die Liste sequenziell durchlaufen
werden muss
Einfügen
hinter einem
Element
schlecht, da alle
nachfolgenden
Elemente verschoben werden
müssen; wenn
kein Platz im
Array ist, sogar
sehr aufwendig
sehr einfach durch Einketten des neuen
Elements
Einfügen
vor einem
Element
aufwendig, da der
Vorgänger gesucht
werden muss
Tabelle 15.1 Vergleich von Arrays und Listen
440
Doppelt verkettete
Liste
einfach durch Einketten des neuen
Elements
15.1
Operation
Array
Einfach verkettete
Liste
Entfernen
hinter einem
Element
schlecht, da alle
nachfolgenden
Elemente verschoben werden
müssen
sehr einfach durch Ausketten des Elements
Entfernen
vor einem
Element
aufwendig, da der
Vorgänger gesucht
werden muss
Listen
Doppelt verkettete
Liste
einfach durch Ausketten des Elements
Gehe zum
Nachfolger!
einfach durch
Erhöhung des
Index
einfach durch Ausnutzung der Vorwärtsverkettung
Gehe zum
Vorgänger!
einfach durch
Heruntersetzen
des Index
aufwendig, da der
Vorgänger gesucht
werden muss
einfach durch Ausnutzung der Rückwärtsverkettung
Vertauschen
zweier
Elemente
einfach
aufwendig, da zur
Aktualisierung der
Verkettung eine Vorgängersuche erforderlich ist
etwas verzwickt,
aber nicht so aufwendig wie bei einfach verketteten
Listen
15
Tabelle 15.1 Vergleich von Arrays und Listen (Forts.)
Operationen auf Listen können häufig durch Ändern von Zeigerwerten ausgeführt
werden, ohne dass große Datenmengen im Speicher bewegt werden müssen. Das
macht Listen flexibler als Arrays.
Grundsätzlich kann man sagen:
Bei dynamisch wachsenden und schrumpfenden, vielleicht sogar inhomogenen Datenbeständen stark variierender Anzahl mit häufigen Einsetz- und
Löschoperationen und vorrangig sequenziellem Zugriff sollten Sie Listen
bevorzugen.
Bei homogenen Datenbeständen fester Anzahl, die einen effizienten und wahlfreien Zugriff erfordern, sind Arrays die erste Wahl.
Zurück zum Container für die Länderspieldaten. Im Container implementieren wir
eine einfach verkettete Liste. Die Listeneinträge werden dabei alphabetisch sortiert.
441
15
Ausgewählte Datenstrukturen
struct liste
{
struct listentry *first;
};
struct listentry
{
struct listentry *nxt;
struct gegner *geg;
};
Paraguay
Oesterreich
Marokko
Bolivien
Abbildung 15.6 Darstellung des Containers
Der Container besteht aus einem Header (struct liste), der nur einen Zeiger auf das
erste Listenelement (first) enthält. Die eigentliche Liste ist eine Verkettung von Listenelementen (struct listentry), die jeweils einen Zeiger auf den durch sie verwalteten Gegner (geg) und einen Zeiger auf das nächste Listenelement (nxt) enthalten. Die
Liste implementieren wir außerhalb der eigentlichen Nutzdaten, sodass in die Struktur dieser Daten (struct gegner) nicht eingegriffen werden muss.
Alle Datenstrukturen im Container werden dynamisch erzeugt. Ein leerer Container
besteht aus einem Header (struct liste), dessen first-Zeiger den Wert 0 hat, da es
noch keine Listeneinträge gibt. Im Konstruktor (list_create) wird ein solcher leerer
Container angelegt:
struct liste *list_create()
{
struct liste *l;
A
B
C
l = (struct liste *)malloc( sizeof( struct liste));
l->first = 0;
return l;
}
Listing 15.1 Anlegen eines leeren Containers
Die erforderliche Struktur wird allokiert (A), initialisiert (B) und an das rufende Programm zurückgegeben (C). Bei jedem Aufruf der list_create-Funktion wird ein
442
15.1
Listen
neuer Container erzeugt. Ein Anwendungsprogramm kann daher mehrere Container
erzeugen und unabhängig voneinander verwenden:
struct liste *container1;
struct liste *container2;
container1 = list_create();
container2 = list_create();
Stellen Sie sich vor, dass der Container jetzt bereits mit Daten gefüllt ist und Sie ein
Element mit einem bestimmten Namen im Container suchen. Dazu müssen Sie über
die Liste im Container iterieren und dabei berücksichtigen, dass die Elemente nach
dem Namen sortiert sind. Für jedes betrachtete Element müssen Sie drei Fälle unterscheiden:
1. Der Name des betrachteten Elements entspricht dem gesuchten Namen. Dann ist
das Objekt gefunden, und der Zeiger auf das Objekt kann zurückgegeben werden.
2. Der Name des betrachteten Elements ist alphabetisch größer als der gesuchte
Name. Dann kann der Name in der restlichen Liste nicht mehr vorkommen, da ja
nur noch größere Elemente folgen. Die Suche muss erfolglos abgebrochen werden.
3. Der Name des betrachteten Elements ist alphabetisch kleiner als der gesuchte
Name. Dann muss noch weitergesucht werden.
Wir implementieren diese Suchstrategie:
A
B
C
D
E
F
struct gegner *list_find( struct liste *l, char *name)
{
struct listentry *e;
int cmp;
for( e = l->first; e; e = e->nxt)
{
cmp = strcmp( name, e->geg->name);
if( !cmp)
return e->geg;
else if( cmp < 0)
break;
}
return 0;
}
Listing 15.2 Implementierung der Suchstrategie
443
15
15
Ausgewählte Datenstrukturen
Die Funktion erhält an ihrer Schnittstelle den Parameter l als Liste, in der das Objekt
mit dem Namen name zu suchen ist (A). Innerhalb der Funktion wird über die Liste iteriert (B), und die Namen der Listenelemente werden mit dem gesuchten Namen verglichen (C). Bei einem Ergebnis von cmp == 0 ist der passende Eintrag gefunden und
wird zurückgegeben (E). Ein Ergebnis von cmp <0 bedeutet, dass die Suche in der Liste
erfolglos war und abgebrochen wird (E). Bei einem Ergebnis von cmp > 0 wird weitergesucht, bis alle Einträge betrachtet worden sind. Wenn kein Ergebnis gefunden
wurde, gibt die Funktion eine 0 zurück (F).
Soll ein Element in den Container eingefügt werden, muss zunächst die Einfügeposition gesucht werden. Gibt es schon ein Element gleichen Namens, kann das Element
nicht eingefügt werden. Wenn das Element eingefügt werden kann, wird der Speicher
für einen weiteren Listeneintrag (struct listentry) allokiert, und die erforderlichen
Verkettungen werden hergestellt:
int list_insert( struct liste *l, struct gegner *g)
{
struct listentry **e, *neu;
int cmp;
A
B
C
D
E
F
G
H
I
for( e = &(l->first); *e; e = &((*e)->nxt))
{
cmp = strcmp( g->name, (*e)->geg->name);
if( !cmp)
return 0;
else if( cmp < 0)
break;
}
neu = (struct listentry *)malloc( sizeof( struct listentry));
neu->nxt = *e;
*e = neu;
neu->geg = g;
return 1;
}
Listing 15.3 Einfügen in die Liste
Das Suchen der Einfügeposition startet in (A). Wieder findet für jedes Element ein
Namensvergleich statt (B). Wenn ein Element gleichen Namens schon vorhanden ist,
wird das Einfügen abgebrochen (C). Ist die Einfügeposition gefunden, wird die
Suchschleife beendet (D). Zum Einfügen wird der benötigte Speicher allokiert (E) und
das einzufügende Element eingekettet (F und G). Nach Eintragen des Gegners (H)
wird der Erfolg der Funktion an den Aufrufer zurückgemeldet (I).
444
15.1
Listen
Das Einfügen entspricht, von der Vorgehensweise her, der Suche. Allerdings wird hier
wieder mit doppelter Indirektion gearbeitet, um das neue Element direkt an der
gefundenen Position einsetzen zu können. Iteriert wird also nicht von Element zu
Element, sondern von Einfügeposition zu Einfügeposition.
Es fehlt noch die Funktion, um einen Container vollständig – also einschließlich der
im Container gespeicherten Nutzdaten – freizugeben:
void list_free( struct liste *l)
{
struct listentry *e;
A
B
C
D
E
F
while( e = l->first)
{
l->first = e->nxt;
free( e->geg->name);
free( e->geg);
free( e);
}
free( l);
}
15
Listing 15.4 Freigeben des Containers
In der Funktion ist e der Zeiger auf das nächste zu löschende Element (A). Innerhalb
der while-Schleife wird e ausgekettet (B), der Name des Gegners wird freigegeben (C),
der Gegner (D) und der Listeneintrag selbst (E) werden ebenfalls freigegeben. Wenn
alle Elemente gelöscht worden sind, wird der Container selbst freigegeben (F).
Beachten Sie, dass bei while( e = l->first) eine Zuweisung an den Zeiger e erfolgt.
Sollte dabei der Null-Zeiger zugewiesen worden sein, wird die Schleife abgebrochen.
Wir testen jetzt den Container mit konkreten Daten. Insbesondere interessiert uns
der Aufbau des Containers mit den Funktionen list_create und list_insert. Das
Öffnen der Datei, das Einlesen der Daten aus der Datei und das Befüllen der Nutzdatenstruktur kennen Sie bereits aus Kapitel 14, »Datenstrukturen«.
struct liste *list_load( char *datei)
{
FILE *pf;
char land[100];
struct liste *l;
struct gegner *g;
445
15
Ausgewählte Datenstrukturen
pf = fopen( datei, "r");
A
l = list_create();
for( ; ;)
{
fscanf( pf, "%s", land);
if( feof( pf))
break;
g = (struct gegner *)malloc( sizeof( struct gegner));
g->name = (char *)malloc( strlen( land)+1);
strcpy( g->name, land);
fscanf( pf, "%d", &g->spiele);
B
C
list_insert( l, g);
}
fclose( pf);
return l;
}
Listing 15.5 Öffnen und Einlesen der Datei
Die nötigen Anpassungen gegenüber dem bereits bekannten Vorgehen sind gering.
Geändert wurden hier das Erzeugen eines leeren Containers (A), das Einfügen eines
neuen Elements in den Container (B) und die Rückgaben des gefüllten Containers (C).
Im Hauptprogramm kann der Container jetzt verwendet werden:
void main()
{
struct liste *l;
char land[100];
struct gegner *g;
int i;
A
l = list_load( "Laenderspiele.txt");
B
for( i = 0; i < 3; i++)
{
printf( "Land: ");
scanf( "%s", land);
g = list_find( l, land);
446
15.1
C
Listen
if( g)
printf( "Gegen %s gab es bisher %d Spiele\n", g->name
, g->spiele);
else
printf( "%s nicht gefunden\n", land);
}
list_free( l);
}
Listing 15.6 Verwenden des Containers im Hauptprogramm
Um den Container zu verwenden, laden wir in (A) die Daten. Innerhalb des Containers suchen wir dann mit (B). Wenn alle Arbeiten erledigt sind, wird der erzeugte
Container in (C) wieder freigegeben. Wir erhalten bei einem Durchlauf z. B. folgendes
Ergebnis:
Land Bolivien:
Gegen Bolivien gab es bisher 1 Spiele
Land: Lummerland
Lummerland nicht gefunden
15
Für die Laufzeiteigenschaften des Containers ist die Suchtiefe beim Einsetzen bzw.
Finden von Elementen entscheidend. Die maximale Suchtiefe entspricht der Anzahl
der Elemente in der Liste. Bei zufälliger Sortierung der Elemente in der Liste können
wir erwarten, dass die mittlere Suchtiefe der Hälfte der Anzahl der Listeneinträge entspricht. Wir testen dies mit 50 ausgewählten Ländern, indem wir nach jedem Gegner
einmal suchen und den Mittelwert berechnen (siehe Abbildung 15.7).
Listen sind einfach zu implementieren und können durch kleine, überschaubare
Funktionen gepflegt werden. Aber je größer der Datenbestand in einer Liste wird,
desto mehr zeigen sich die Nachteile von Listen gegenüber Arrays. Der Suchaufwand
in einer Liste ist proportional zur Anzahl der gespeicherten Objekte. Da Sie in einem
Array, durch eine einfache Indexberechnung, wahlfrei auf jedes Element zugreifen
können, können Sie in einem sortierten Array eine Suchstrategie mit logarithmischem Aufwand implementieren. Dazu betrachten Sie immer das Element in der
Mitte des Suchbereichs. Entweder haben Sie dann das gesuchte Element gefunden,
oder Sie können den Suchbereich halbieren. Durch fortlaufende Halbierung des
Suchbereichs erhalten Sie eine maximal logarithmische Suchtiefe. Für große Suchräume bedeutet das, wie Sie aus unseren Überlegungen zur Laufzeitkomplexität
bereits wissen, einen erheblichen Unterschied.
447
15
Ausgewählte Datenstrukturen
+-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
Liste für 50 zufällig gewählte Gegner
Maximale Suchtiefe: 50
Mittlere Suchtiefe: 25.5
Abbildung 15.7 Suche und Suchtiefe im Container (Liste)
Vielleicht gelingt es uns ja, eine Speicherstruktur zu finden, die so flexibel wie eine
Liste ist, aber nur logarithmische Suchtiefe hat.
15.2
Bäume
Ein Baum verallgemeinert den Begriff der Liste dahingehend, dass jedes Element eine
endliche Folge von Nachfolgern haben kann. Wir sprechen dann vom 1., 2., 3. Nachfolger etc. Die Elemente im Baum bezeichnen wir als Knoten. Einen Baum zeichnen wir
in der folgenden Weise (siehe Abbildung 15.8).
In einem Baum gibt es genau einen Knoten, der keinen Vorgänger hat. Diesen
bezeichnen wir als den Wurzelknoten oder die Wurzel. Alle Knoten sind von der Wurzel aus durch endlich viele Nachfolgeroperationen auf genau einem Weg erreichbar
(es gibt keine Schleifen oder Zyklen). Knoten, die keine Nachfolger haben, bezeichnen
wir als Blätter.
448
15.2
Bäume
Wurzel
Blätter
Abbildung 15.8 Darstellung eines Baums
Binärbaume sind Bäume, bei denen ein Knoten maximal zwei Nachfolger hat. Bei
einem Binärbaum sprechen wir dann vom linken und vom rechten Nachfolger,
obwohl die Begriffe »links« und »rechts« softwaretechnisch keinen Sinn haben.
Binärbaum
Abbildung 15.9 Darstellung eines Binärbaums
Hinweis: Wenn wir in diesem Kapitel von einem Baum sprechen, meinen wir immer
einen Binärbaum.
Betrachten wir einen Baum, stellen wir eine starke Selbstähnlichkeit fest. Wenn wir
uns auf einen beliebigen Knoten K des Baums positionieren und alle von diesem
Knoten aus erreichbaren Knoten betrachten, bildet diese Unterstruktur wieder einen
449
15
15
Ausgewählte Datenstrukturen
Baum. Diese Unterstruktur wird als Teilbaum mit der Wurzel K bezeichnet. Bei einem
Binärbaum bezeichnen wir den mit dem linken Nachfolger eines Knotens K als Wurzel beginnenden Teilbaum als den linken Teilbaum des Knotens K. Entsprechend
definieren wir den rechten Teilbaum.
Durch die Anzahl von Nachfolgeroperationen, die man benötigt, um von der Wurzel
aus einen bestimmten Knoten zu erreichen, sind in einem Baum Levels definiert.
Jeder Knoten ist genau einem Level zugeordnet. Das maximale Level eines Baums + 1
bezeichnen wir als die Tiefe des Baums.
Level 0
Tiefe 4
Level 1
Level 2
Level 3
Abbildung 15.10 Die Ebenen eines Baums
Damit ergeben sich die folgenden, wichtigen mathematischen Formeln:
왘
Auf dem Level i eines Binärbaums sind maximal 2i Knoten.
왘
2t – 1
Ein Binärbaum der Tiefe t hat maximal 2 0 + 2 1 + 2 2 + … + 2 t – 1 = ------------- = 2 t – 1
2–1
Knoten.
왘
Ein Binärbaum mit n Knoten hat mindestens die Tiefe log2(n).
Insbesondere kann man an dieser Stelle bereits feststellen, dass die Tiefe eines »vollständig gefüllten« Binärbaums proportional zum Logarithmus der Anzahl seiner
Knoten ist. Wenn man also jetzt noch geeignete Suchstrategien hätte, könnte man
eine mit sortierten Arrays vergleichbare Suchtiefe erreichen.
In der Knotenstruktur für einen Binärbaum müssen wir Zeiger für den linken und
den rechten Nachfolger anlegen. Eine einfache Knotenstruktur könnte dann so aussehen:
450
15.2
Bäume
struct node
{
struct node *left;
struct node *right;
int value;
};
Als erstes Beispiel legen wir einen Baum mit 14 Knoten an. Jeder Knoten (struct node)
hat neben seinen Nutzdaten (hier nur ein Zahlenwert, value) Zeiger auf einen möglichen linken oder rechten Nachfolger (left, right). Diese Zeiger haben den Wert 0,
wenn der Nachfolger nicht existiert. Ausgehend von dieser einfachen Knotenstruktur, werden die Knoten statisch angelegt1 und miteinander verkettet.
struct node
{
struct node *left;
struct node *right;
int value;
};
16
6
4
2
18
14
10
8
Der Knoten n22 hat den
Wert 22, den Knoten n20
als linken und den Knoten
n26 als rechten Nachfolger.
12
26
24
0,
0,
0,
0,
8};
12};
24};
28};
/* Knoten des Levels 3 */
struct node n02 = {
0,
0, 2};
struct node n10 = { &n08, &n12, 10};
struct node n20 = {
0,
0, 20};
struct node n26 = { &n24, &n28, 26};
/* Knoten des Levels 2 */
struct node n04 = { &n02,
0, 4};
struct node n14 = { &n10,
0, 14};
struct node n22 = { &n20, &n26, 22};
22
20
/* Knoten des Levels 4 */
struct node n08 = {
0,
struct node n12 = {
0,
struct node n24 = {
0,
struct node n28 = {
0,
/* Knoten des Levels 1 */
struct node n06 = { &n04, &n14, 6};
struct node n18 = {
0, &n22, 18};
28
/* Wurzel */
struct node n16 = { &n06, &n18, 16};
Abbildung 15.11 Statisches Anlegen eines Baums
Dieser Baum wird als ein Beispiel für weitere Überlegungen dieses Abschnitts dienen.
15.2.1
Traversierung von Bäumen
Bei Listen gab es eine natürliche Weise, vorwärts oder rückwärts durch alle Elemente
zu iterieren. Bei Bäumen gibt es dagegen keine natürliche Besuchsreihenfolge. Man
kann unterschiedliche Besuchsstrategien entwickeln. Zum Beispiel kann man »Kinder« vor »Geschwistern« (Tiefensuche, Depth-First) oder »Geschwister« vor »Kindern« (Breitensuche, Breadth-First) besuchen:
1 Später werden unsere Bäume natürlich dynamisch aufgebaut werden, aber für eine erste
Betrachtung reicht dieser Baum erst einmal aus.
451
15
15
Ausgewählte Datenstrukturen
?
Abbildung 15.12 Traversierungsmöglichkeiten
Solche Besuchsstrategien führen zum Begriff der Traversierung:
Unter der Traversierung eines Baums verstehen wir das systematische Aufsuchen aller Knoten des Baums, um an den Knoten gewisse Operationen durchführen zu können.
Um alle Knoten eines Baums zu besuchen, kann man die Selbstähnlichkeit des
Baums ausnutzen und rekursiv vorgehen.
Wir werden Ihnen im Folgenden vier Traversierungsstrategien vorstellen:
왘
Preorder-Traversierung
왘
Inorder-Traversierung
왘
Postorder-Traversierung
왘
Levelorder-Traversierung
Preorder-Traversierung
Wir starten an einem Knoten (initial die Wurzel) und bearbeiten den Knoten, gehen
danach zum linken Nachfolger und fahren dort rekursiv mit der Bearbeitung fort.
Wenn wir vom linken Knoten und allen darunterliegenden Knoten zurückkommen,
starten wir rekursiv mit der Bearbeitung des rechten Nachfolgers:
void preorder( struct node *n)
{
if( n)
{
printf( " %2d", n->value);
preorder( n->left);
452
15.2
Bäume
preorder( n->right);
}
}
Listing 15.7 Preorder-Traversierung des Baums
Aufgerufen an der Wurzel
void main()
{
printf( "Preorder:\n");
preorder( &n16);
}
ergibt sich die folgende Besuchsreihenfolge:
Preorder:
16 6 4 2 14 10
8 12 18 22 20 26 24 28
Grafisch lässt sich der Weg durch den Baum wie folgt darstellen:
15
16
6
4
2
18
14
10
8
22
20
12
26
24
28
Abbildung 15.13 Grafische Darstellung der Preorder-Traversierung
453
15
Ausgewählte Datenstrukturen
Inorder-Traversierung
Tauschen Sie bei der Preorder-Traversierung nur zwei Zeilen im Quellcode, erhalten
Sie die Inorder-Traversierung:
void inorder( struct node *n)
{
if( n)
{
inorder( n->left);
printf( " %2d", n->value);
inorder( n->right);
}
}
Listing 15.8 Inorder-Traversierung des Baums
Wir tauchen in die Behandlung des linken Teilbaums eines Knotens ab, bevor wir den
Knoten selbst behandeln. Dadurch ergibt sich das folgende Programm:
void main()
{
printf( "Inorder:\n");
inorder( &n16);
}
Hier sehen Sie die geänderte Besuchsreihenfolge:
Inorder:
2 4 6 8 10 12 14 16 18 20 22 24 26 28
Zur grafischen Darstellung müssen wir nur den Besuchspfad leicht verschieben
(siehe Abbildung 15.14).
Die Besuchsreihenfolge entspricht in diesem Fall der Knotennummerierung. Das
liegt daran, dass ich bei der Knotennummerierung ein ganz bestimmtes Schema
gewählt habe, das ich Ihnen später noch erläutern werde. Sie können sich ja schon
einmal Gedanken darüber machen, wie dieses Schema aufgebaut ist.
454
15.2
Bäume
16
6
4
2
18
14
10
22
20
26
15
8
12
24
28
Abbildung 15.14 Grafische Darstellung der Inorder-Traversierung
Postorder-Traversierung
Bei der Postorder-Traversierung erfolgt die Behandlung eines Knotens, nachdem
beide am Knoten hängenden Teilbäume bearbeitet wurden:
void postorder( struct node *n)
{
if( n)
{
postorder( n->left);
postorder( n->right);
printf( " %2d", n->value);
}
}
Listing 15.9 Postorder-Traversierung des Baums
Dementsprechend ergibt sich mit dem geänderten Hauptprogramm
455
15
Ausgewählte Datenstrukturen
void main()
{
printf( "Postorder:\n");
postorder( &n16);
}
wieder eine andere Besuchsreihenfolge:
Postorder:
2 4 8 12 10 14
6 20 24 28 26 22 18 16
In der Grafik in Abbildung 15.15 zeigt sich wieder eine Verschiebung des Besuchspfads:
16
6
4
2
18
14
10
8
22
20
12
26
24
28
Abbildung 15.15 Grafische Darstellung der Postorder-Traversierung
Die bisher vorgestellten Traversierungsverfahren arbeiten rekursiv. Sie wissen ja
bereits, dass sich Rekursion des Hardware-Stacks bedient und dass Sie Rekursion vermeiden können, wenn Sie in Ihrem Programm einen eigenen Stack mitführen.
456
15.2
Bäume
Wir wollen jetzt die Inorder-Traversierung mit dieser Technik rekursionsfrei
machen. Das ist nicht besonders spannend, da wir etwas Ähnliches z. B. schon beim
Sortierverfahren Quicksort gemacht haben. Anschließend werden wir dann aber den
Stack durch eine sogenannte Queue ersetzen und ein neues Traversierungsverfahren
erhalten. Zum besseren Verständnis dieser Operation finden Sie hier aber zunächst
einen kleinen Exkurs über Stacks und Queues. Stacks haben Sie ja bereits in Abschnitt
7.5, »Der Stack«, kennengelernt2.
Stacks sind unfaire Warteschlangen, weil der, der zuletzt kommt, zuerst bedient wird
(LIFO, Last In First Out). Bei einem Stack wird am gleichen Ende der Warteschlange
geschrieben und gelesen:
Eingang (schreiben)
Ausgang (lesen)
Abbildung 15.16 Schreiben und Lesen auf dem Stack
Ein Stack kann als Array mit einem Schreib-Lesezeiger implementiert werden:
A
B
C
D
15
int i;
int stack[100];
int pos = 0;
for( i = 0; i < 10; i++)
{
printf( " %d", i);
stack[pos++] = i;
}
printf( "\n");
while( pos)
{
i = stack[--pos];
printf( " %d", i);
}
Listing 15.10 Implementierung des Stacks
Das Array für den Stack wird in (A) angelegt, der Schreib-Lesezeiger in (B) für den leeren Stack initialisiert. Das Schreiben (C) legt den Wert auf dem Stack ab und erhöht
2 Stacks und Queues kommen, in unterschiedlichen Zusammenhängen, mehrfach in diesem Buch
vor. Das ist dadurch gerechtfertigt, weil dies die wichtigsten Datenstrukturen der Informatik
sind.
457
15
Ausgewählte Datenstrukturen
den Schreib-Lesezeiger, beim Lesen wird der oberste Wert vom Stack geholt und der
Schreib-Lesezeiger dekrementiert (D). Wir erhalten das folgende Ergebnis:
0 1 2 3 4 5 6 7 8 9
9 8 7 6 5 4 3 2 1 0
Queues sind faire Warteschlangen, weil der, der zuerst kommt, auch zuerst bedient
wird (FIFO, First In First Out). Bei einer Queue wird an verschiedenen Enden geschrieben und gelesen.
Ausgang
(lesen)
Eingang
(schreiben)
Abbildung 15.17 Schreiben und Lesen aus der Queue
Eine Queue kann als Array mit einem Schreib- und einem Lesezeiger implementiert
werden:
A
B
C
D
E
int
int
int
int
i;
queue[100];
first = 0;
last = 0;
for( i = 0; i < 10; i++)
{
printf( " %d", i);
queue[last++] = i;
}
printf( "\n");
while( first < last)
{
i = queue[first++];
printf( " %d", i);
}
Listing 15.11 Implementierung der Queue
Die Implementierung legt zuerst eine Queue als Array an (A) und initialisiert den
Lesezeiger (B) und den Schreibzeiger (C). Beim Schreiben wird der Schreibzeiger
inkrementiert (D) und beim Lesen der Lesezeiger (E). Die Queue liefert die folgende
Ausgabe:
458
15.2
Bäume
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
Sie sehen, dass die Daten jetzt in der gleichen Abfolge abgerufen werden, in der sie
eingespeichert wurden. Das unterscheidet die Queue vom Stack. Die Daten der
Queue »wandern« bei dieser Implementierung übrigens durch das Array, da Schreibund Lesezeiger immer nur nach rechts verschoben werden. Das bedeutet, dass man
bei häufigem Schreiben und Lesen irgendwann ein Speicherproblem bekommt,
obwohl unter Umständen nur sehr wenige Elemente in der Queue sind. Im Kapitel 16,
»Abstrakte Datentypen«, werden wir dieses Problem lösen. Hier soll es uns nicht stören, wir machen das Array einfach groß genug.
Nach diesen Vorüberlegungen kommen wir zur letzten Traversierungsstrategie:
Levelorder-Traversierung
Wie bereits angekündigt, erstellen wir zunächst eine rekursionsfreie Variante der
Preorder-Traversierung:
void preorder( struct node *n)
{
if( n)
{
printf( " %2d", n->value);
preorder( n->left);
preorder( n->right);
}
void preorder( struct node *n)
}
{
struct node *stack[100];
int pos = 0;
15
stack[pos++] = n;
while( pos)
{
n = stack[--pos];
if( n)
{
printf( " %2d", n->value);
stack[pos++] = n->right;
stack[pos++] = n->left;
}
}
}
Abbildung 15.18 Rekursionsfreie Preorder-Traversierung
459
15
Ausgewählte Datenstrukturen
Anstatt in die Rekursion zu gehen, legen wir Zeiger auf die anstehenden Knoten auf
den Stack, um sie in nachfolgenden Schleifenläufen wieder vom Stack zu holen und
zu bearbeiten. Die Reihenfolge, in der wir die Knoten (left, right) auf den Stack
legen, unterscheidet sich dabei von der Reihenfolge der rekursiven Aufrufe, weil der
Stack die Reihenfolge dreht.
Dieses Programm führt natürlich nach wie vor eine Preorder-Traversierung durch.
Jetzt aber tauschen wir den Stack durch eine Queue aus:
void levelorder( struct node *n)
{
struct node *queue[100];
int first = 0, last = 0;
queue[last++] = n;
while( first < last)
{
n = queue[first++];
if( n)
{
printf( " %2d", n->value);
queue[last++] = n->left;
queue[last++] = n->right;
}
}
}
Listing 15.12 Implementierung der Levelorder-Traversierung
Dies bedeutet, dass »Geschwister« jetzt vor »Kindern« bearbeitet werden, da sie ja
früher in die Queue kommen und dort fair behandelt werden. Im Hauptprogramm
testen wir die neue Traversierungsstrategie
void main()
{
printf( "Levelorder:\n");
levelorder( &n16);
}
und erhalten folgendes Ergebnis:
Levelorder:
16 6 18 4 14 22
460
2 10 20 26
8 12 24 28
15.2
Bäume
Dies ist die sogenannte Levelorder-Traversierung, bei der der Baum Level für Level
abgearbeitet wird:
16
6
4
2
18
14
10
8
22
20
12
26
24
15
28
Abbildung 15.19 Grafische Darstellung der Levelorder-Traversierung
15.2.2
Aufsteigend sortierte Bäume
Wir kehren jetzt zu unserem eigentlichen Anliegen, einen Container auf Basis eines
Binärbaums zu erstellen, zurück. Würden wir den Baum »ungeordnet« aufbauen,
müssten wir den Baum immer vollständig traversieren, um ein bestimmtes Element
zu finden. Wir benötigen daher eine »geordnete« Baumstruktur, die es uns ermöglicht, effizient in einem Baum zu suchen. Dazu definieren wir zunächst den Begriff
des aufsteigend sortierten Baums.
Wir betrachten im Folgenden Bäume, deren Knoten der Größe nach verglichen werden können, wobei mit »Größe« nicht unbedingt eine numerische Größe gemeint
ist. Es können z. B. auch den Knoten zugeordnete Namen bezüglich ihrer lexikographischen Ordnung verglichen werden.
461
15
Ausgewählte Datenstrukturen
Sortierter Binärbäum
Ein Binärbaum, bei dem die Knoten mittels einer Ordnungsrelation (<) verglichen
werden können, heißt aufsteigend sortiert, wenn an jedem Knoten K die Bedingungen
X < K für alle Knoten X des linken Teilbaums von K
und
X > K für alle Knoten X des rechten Teilbaums von K
gelten.
Beachten Sie, dass die Bedingungen X < K und X > K nicht nur für den rechten bzw.
linken Nachfolger von K, sondern für alle Knoten im linken bzw. rechten Teilbaum
von K gelten müssen.
Abbildung 15.20 zeigt einen aufsteigend sortierten Baum.
16
6
4
2
18
14
10
22
20
26
Hier ist alles kleiner als 6.
Hier ist alles größer als 6.
8
12
24
28
Abbildung 15.20 Aufsteigend sortierter Baum
Durch Vertauschen von »links« und »rechts« nach der oben genannten Definition
erhält man den Begriff des absteigend sortierten Baums. Wenn wir im Folgenden von
sortierten Bäumen sprechen, meinen wir immer aufsteigend sortierte Bäume.
462
15.2
Bäume
Sortierte Bäume sind für uns von besonderem Interesse, weil man in diesen Bäumen
sehr effizient von der Wurzel zu einem gesuchten Knoten absteigen kann. Wir
machen uns das an einem konkreten Beispiel im oben dargestellten, aufsteigend sortierten Baum klar. Wir starten an der Wurzel und suchen den Knoten mit dem Wert 8:
Suche 8!
16
8 < 16,
also gehe nach links!
6
18
8 > 6,
also gehe nach rechts!
4
14
22
8 < 14,
also gehe nach links!
8 < 10,
also gehe nach links!
8 gefunden
2
10
20
26
15
8
12
24
28
Abbildung 15.21 Suche im aufsteigend sortierten Baum
Um ein Element zu finden, wird in einer Schleife gezielt nach links bzw. rechts im
Baum abgestiegen, bis das gesuchte Element gefunden oder das Ende des Baums
erreicht wurde. Die maximale Suchtiefe entspricht dabei der maximalen Tiefe des
Baums.
Wir implementieren dieses Verfahren in unserem Beispielbaum:
void find( struct node *n, int v)
{
while( n)
{
463
15
Ausgewählte Datenstrukturen
A
B
C
D
if( n->value == v)
{
printf( " %d gefunden\n", v);
return;
}
if( v < n->value)
{
printf( " %2d->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 <= 10; i++)
{
printf( "Suche %2d ", i);
find( &n16, i);
}
}
464
15.2
Suche
Suche
Suche
Suche
Suche
Suche
Suche
Suche
Suche
Suche
1
2
3
4
5
6
7
8
9
10
16->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 << 6) | (*name – '@')) % size;
return h;
}
15
Listing 15.22 Eine geeignetere Hash-Funktion
Die Hash-Funktion enthält eine komplexe Berechnung, in die alle Zeichen des
Namens »gleichberechtigt« eingehen (A).
Durch die Modulo-Operation am Ende der Berechnung wird erzwungen, dass der
berechnete Wert ein gültiger Tabellenindex ist.
Hash-Funktionen haben auch in anderen Bereichen der Informatik (z. B. in der Kryptologie) eine große Bedeutung. Mit Hash-Funktionen (z. B. MD5, Message-Digest
Algorithm 5) versucht man, »Fingerabdrücke« von Daten zu erhalten, aus denen man
keine Rückschlüsse auf die Ausgangsdaten gewinnen kann. Solche Hash-Funktionen
sind naturgemäß weitaus komplexer als die hier verwendete Funktion.
Um ein Element zu finden, wird zunächst mit der Hash-Funktion der Einstieg in die
Hash-Tabelle berechnet. In der Tabelle steht dann der Anker der Synonymkette, oder
0, wenn zu dem Hash-Wert noch nichts gespeichert wurde. In der Synonymkette
wird das Element dann gesucht. Die Suche in der Synonymkette ist die Listensuche,
die Sie ja bereits kennen.
485
15
Ausgewählte Datenstrukturen
A
struct gegner *hash_find( struct hashtable *h, char *name)
{
unsigned int index;
struct hashentry *e;
B
index = hashfunktion( name, 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 <= ANZAHL; i++)
printf( "%5d %s-%s\n",
distanz[minpfad[i-1]] [minpfad[i%ANZAHL]],
stadt[minpfad[i-1]],
stadt[minpfad[i%ANZAHL]]);
printf( "%5d\n", mindist);
}
Abbildung 17.49 Kürzester gefundener hamiltonscher Weg
Ob das Rundreiseprogramm wirklich die absolut kürzeste Rundreise durch Deutschland geliefert hat, wissen wir nicht. Das Programm hat nur die kürzeste Rundreise
innerhalb des ihm zur Verfügung stehenden Graphen berechnet. Der Graph enthielt
aber nur ausgewählte Städteverbindungen. Zum Beispiel gab es keine Direktverbindung von Berlin nach Frankfurt, und diese Direktverbindung wird kürzer sein als der
Weg über Hannover oder Leipzig. Die kürzeste Reiseroute könnte mit einer Direktfahrt von Berlin nach Frankfurt beginnen. Leipzig und Hannover würden dann später
angefahren. Wenn wir wirklich die kürzeste Reiseroute zwischen unseren zwölf Städten finden wollen, müssen wir einen Graphen betrachten, der alle paarweisen Städteverbindungen enthält. In solch einem Graphen steht jede Permutation der Knoten
für einen hamiltonschen Weg. Da der erste Knoten fest gewählt werden kann, müssen noch 11! = 39916800, also knapp 40 Millionen hamiltonsche Wege, untersucht
werden. In unserem Programm wurden dagegen nur sechs Wege verglichen.
564
17.18
Das Travelling-Salesman-Problem
Bei der vollständigen Untersuchung werden Sie feststellen, dass ein kürzerer Weg als
der von uns bereits gefundene trotz des erheblich größeren Suchraums nicht gefunden werden kann. Irgendwie ist es uns mit Intuition oder Glück gelungen, durch eine
geschickte Vorauswahl von Kanten den Umfang der Aufgabe drastisch zu verkleinern, ohne die optimale Lösung zu verlieren. Das liegt natürlich an der geometrischen Anschaulichkeit des Problems. Bei vielen Optimierungsaufgaben fehlt diese
Anschauung, und die kombinatorische Zahl der zu betrachtenden Permutationen ist
noch um ein Vielfaches größer.
Ein Computer hat nicht die Intuition, eine geeignete Vorauswahl zu treffen, und es ist
bisher nicht gelungen, eine allgemeine Lösung des Travelling-Salesman-Problems zu
finden, die die kombinatorische Explosion vermeidet. Beim Versuch, die Explosion
zu vermeiden, hat man aber Überraschendes und Tiefliegendes entdeckt.
Für die Menge aller mit polynomialer Berechnungskomplexität lösbaren Probleme
verwenden wir die Bezeichnung P.
Die Menge aller Probleme, die man algorithmisch lösen kann und für die man eine
Lösung mit polynomialer Komplexität überprüfen kann, nennen wir NP. P ist eine
Teilmenge von NP.
Das Travelling-Salesmann-Problem ist z. B. ein Problem aus NP.
Es gibt in NP – und das kann man beweisen – sogenannte NP-vollständige Probleme.
Dies sind Probleme, auf die man alle Probleme in NP mit einem maximal polynomialen Zusatzaufwand reduzieren kann. Das TSP ist ein solches Problem. Hätten Sie also
einen Algorithmus polynomialer Laufzeit für ein NP-vollständiges Problem (z. B. für
das TSP), könnten Sie alle Probleme aus NP mit polynomialem Aufwand lösen. Es wäre
NP = P. In diesem Fall müssten alle Bücher über Algorithmen neu geschrieben werden.
NP-vollständig
NP
TSP
P
Abbildung 17.50 Zusammenhang von P und NP
Die Frage, ob es für ein NP-vollständiges Problem einen polynomialen Lösungsalgorithmus gibt, ist vielleicht die bedeutendste Frage der theoretischen Informatik, und
sie ist bis heute unbeantwortet. Auf die Beantwortung dieser Frage ist eine Beloh-
565
17
17
Elemente der Graphentheorie
nung von 1 Million Dollar ausgesetzt. Sollten Sie die Antwort auf diese Frage finden,
können Sie sich hier Ihr Preisgeld abholen: http://www.claymath.org.
Für die allgemeine Lösung des TSP sind nur Algorithmen exponentieller Laufzeit verfügbar. Das bedeutet, dass man das TSP für »große« Graphen nicht in akzeptabler Zeit
lösen kann. Da man aber für viele technische und betriebswirtschaftliche Fragestellungen an einer Lösung des TSP interessiert ist, muss man einen Kompromiss zwischen zwei gegensätzlichen Anforderungen suchen:
1. Der Suchraum sollte so klein sein, dass man zu einem Algorithmus von polynomialer Laufzeit kommt.
2. Der Suchraum sollte so groß sein, dass sich die optimale Lösung oder zumindest
eine halbwegs optimale Lösung noch darin befindet.
Gesucht ist ein Algorithmus polynomialer Laufzeitkomplexität, der eine Näherungslösung für das Problem des Handlungsreisenden findet, die eine garantierte Maximalabweichung von der unbekannten optimalen Lösung hat.
Wir treffen zwei zusätzliche Annahmen über den Graphen:
1. Für je zwei Knoten gibt es eine direkte Verbindung (Kante) im Graphen.
2. Direkte Verbindungen sind nie länger als Umwegstrecken über einen dritten Ort.
Die allgemeinen Rahmenbedingungen Symmetrie, Zusammenhang und keine negativ bewerteten Kanten bleiben natürlich weiterhin bestehen.
Unter diesen Annahmen, die in wichtigen Anwendungsfällen zutreffen, ist es möglich, eine Näherungslösung für das TSP zu finden. Der Algorithmus dazu basiert auf
minimalen Spannbäumen, die wir ja in polynomialer Laufzeit berechnen können.
Wenn Sie aus einem hamiltonschen Weg eine Kante entfernen, erhalten Sie einen
Spannbaum. Umgekehrt können Sie versuchen, aus einem Spannbaum einen hamiltonschen Weg zu konstruieren.
Wir betrachten den Spannbaum des deutschen Autobahnnetzes und durchlaufen diesen Baum, von Berlin aus startend, in Tiefensuche, wobei wir die Nachfolger eines
Knotens in alphabetisch aufsteigender Reihenfolge besuchen (siehe Abbildung 17.51).
Dann ergibt sich die folgende Besuchsreihenfolge:
Start: Berlin
Vor: Leipzig-Dresden
Zurück: Leipzig
Vor: Hannover-Bremen-Hamburg
Zurück: Bremen-Hannover
Vor: Dortmund-Düsseldorf-Köln-Frankfurt-Stuttgart-München
Zurück: Stuttgart-Frankfurt-Köln-Düsseldorf-Dortmund-Hannover-Leipzig
Ziel: Berlin
566
17.18
Bremen
Das Travelling-Salesman-Problem
Hamburg
Berlin
Hannover
Dortmund
Düsseldorf
Leipzig
Köln
Dresden
Frankfurt
17
Stuttgart
München
Abbildung 17.51 Hamiltonscher Weg und Spannbaum
Überspringen Sie beim Rückzug aus der Tiefensuche die Knoten, an denen Sie schon
waren, erhalten Sie die folgende Besuchsfolge:
Start: Berlin
Vor: Leipzig-Dresden
Zurück: Leipzig
Vor: Hannover-Bremen-Hamburg
Zurück: Bremen-Hannover
Vor: Dortmund-Düsseldorf-Köln-Frankfurt-Stuttgart-München
Zurück: Stuttgart-Frankfurt-Köln-Düsseldorf-Dortmund-Hannover-Leipzig
Ziel: Berlin
Das ist ein hamiltonscher Weg der Länge 2558 km.
567
17
Elemente der Graphentheorie
Bremen
Hamburg
Berlin
Hannover
Dortmund
Düsseldorf
Leipzig
Köln
Dresden
2558 km
aus dem minimalen
Spannbaum
konstruierter
hamiltonscher Weg
Frankfurt
Stuttgart
minimaler
Spannbaum
München
Abbildung 17.52 Ermittelter hamiltonscher Weg im Autobahnnetz
Je nach Startknoten erhalten Sie unter Umständen einen anderen hamiltonschen
Weg. Sie könnten für jeden Startknoten diesen hamiltonschen Weg berechnen und
den kürzesten dieser Wege als Näherungslösung für das TSP verwenden.
Wir wollen dieses Verfahren implementieren und verwenden eine Distanzenmatrix,
die die Entfernungen zwischen allen Städtepaaren enthält (siehe Abbildung 17.53).
Einen minimalen Spannbaum haben wir zuvor bereits berechnet. Wir übernehmen
das Ergebnis in Form einer Adjazenzmatrix:
568
555
466
264
469
232
0
284
119
343
502
427
495
int distanz [ ANZAHL][ ANZAHL] =
0
{
{ 0,412,488,205,572,555,284,282,569,179,584,634},
{412, 0,233,470,317,466,119,125,312,362,753,640},
{488,233, 0,607, 63,264,343,208, 83,532,653,451},
{205,470,607, 0,629,469,502,364,589,108,484,524},
{572,317, 63,629, 0,232,427,292, 47,558,621,419},
{555,466,264,469,232, 0,495,352,189,395,400,217},
{284,119,343,502,427,495, 0,154,422,391,782,668},
{282,125,208,364,292,352,154, 0,287,256,639,526},
{569,312, 83,589, 47,189,422,287, 0,515,578,376},
{179,362,532,108,558,395,391,256,515, 0,425,465},
{584,753,653,484,621,400,782,639,578,425, 0,220},
{634,640,451,524,419,217,668,526,376,465,220, 0},
};
282
125
208
364
292
352
154
0
Leipzig
Köln
569
312
83
589
47
189
422
287
0
179
362
532
108
558
395
391
256
515
0
München
572
317
63
629
0
Hannover
Hamburg
Frankfurt
Düsseldorf
Dresden
Dortmund
412 488 205
0 233 470
0 607
0
Das Travelling-Salesman-Problem
Stuttgart
0
Bremen
Berlin
17.18
584
753
653
484
621
400
782
639
578
425
0
634
640
451
524
419
217
668
526
376
465
220
0
Berlin
Bremen
Dortmund
Dresden
Düsseldorf
Frankfurt
Hamburg
Hannover
Köln
Leipzig
Stuttgart
München
Abbildung 17.53 Distanzenmatrix zwischen den Städtepaaren
17
Hamburg
Bremen
int spannbaum[ ANZAHL][ ANZAHL] =
{
{0,0,0,0,0,0,0,0,0,1,0,0},
{0,0,0,0,0,0,1,1,0,0,0,0},
{0,0,0,0,1,0,0,1,0,0,0,0},
{0,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,0,0,0,0,0,0,1,0,0,1},
{0,1,0,0,0,0,0,0,0,0,0,0},
{0,1,1,0,0,0,0,0,0,1,0,0},
{0,0,0,0,1,1,0,0,0,0,0,0},
{1,0,0,1,0,0,0,1,0,0,0,0},
{0,0,0,0,0,0,0,0,0,0,0,1},
{0,0,0,0,0,1,0,0,0,0,1,0},
};
119
125
284
154
Berlin
Hannover
282
233
Dortmund 208
256
Düsseldorf 63
47
Köln
83
352
395
Frankfurt
217
400
Stuttgart
179 205
108
264
189
Leipzig
220
Dresden
BERLIN
BREMEN
DORTMUND
DRESDEN
425 DUESSELDORF
FRANKFURT
HAMBURG
HANNOVER
KOELN
LEIPZIG
München MUENCHEN
STUTTGART
0
1
2
3
4
5
6
7
8
9
10
11
Abbildung 17.54 Adjazenzmatrix des Spannbaums
569
17
Elemente der Graphentheorie
Wir erstellen ein Array (pfad), um den bei der Tiefensuche konstruierten Weg aufzunehmen:
static int pfad[ANZAHL];
int position;
Die globale Variable position legt dabei die aktuelle Schreibposition in diesem Array
fest.
Die Tiefensuche wird rekursiv implementiert:
void tiefensuche( int knoten, int herkunft)
{
int i;
A
B
pfad[position++] = knoten;
for( i = 0; i < ANZAHL; i++)
{
if( spannbaum[knoten][i] && (herkunft != i))
tiefensuche( i, knoten);
}
}
Listing 17.32 Implementierung der Tiefensuche
Als Parameter werden der aktuelle Knoten (knoten) und der Knoten, über den man zu
diesem Knoten gelangt ist (herkunft), mitgegeben. Der aktuelle Knoten wird an der
nächsten Schreibposition in den Pfad geschrieben (A). Danach wird zu allen Folgeknoten im Baum gegangen (B). Über den Parameter herkunft wird dabei verhindert,
dass man dabei zu dem Knoten zurückgeht, von dem man gekommen ist.
Da immer nur neu erreichte Knoten in den Pfad eingetragen werden, entstehen
keine Dubletten in dem Pfad.
Die Funktion ausgabe gibt den aktuell im Array pfad vorliegenden Weg auf dem Bildschirm aus.
Bei der Ausgabe wird die Länge des Weges berechnet und abschließend ebenfalls ausgegeben.
Im Hauptprogramm werden in einer Schleife alle Knoten einmal als Startpunkt
gewählt.
570
17.18
Das Travelling-Salesman-Problem
void ausgabe()
{
int i;
int d;
for( i = 0, d = 0; i < ANZAHL; i++)
{
printf( "%d-", pfad[i]);
d += distanz[pfad[i]][pfad[(i+1)%ANZAHL]];
}
printf( "%d (%d km)\n", pfad[0], d);
Listing 17.33 Ausgabe des vorliegenden Pfades
void main()
{
int start;
for( start = 0; start < ANZAHL; start++)
{
position = 0;
tiefensuche( start, –1);
ausgabe();
}
}
17
Listing 17.34 Hauptprogramm für die Näherungslösung des TSP
Für jeden Knoten wird, nachdem die Schreibposition zurückgesetzt wurde, die Tiefensuche gestartet und das Ergebnis ausgegeben.
Das Programm berechnet so viele hamiltonsche Wege, wie der Graph Knoten hat.
Insgesamt werden also zwölf hamiltonsche Wege erzeugt und ausgegeben, von
denen der kürzeste mit 2376 km eine gute Approximation des optimalen Weges (2210
km) ist.
Zum Abschluss dieses Kapitels wollen wir uns fragen, wie gut denn die Näherungslösung ist, die wir aus dem Spannbaum erzeugt haben. Im Allgemeinen werden Sie die
optimale Lösung nicht kennen und müssen daher versuchen, abzuschätzen, wie weit
Sie im »Worst Case« vom Optimum entfernt sind.
571
17
Elemente der Graphentheorie
2210
0-9-3-7-1-6-2-4-8-5-11-10-0
1-6-7-2-4-8-5-11-10-9-0-3-1
2-4-8-5-11-10-7-1-6-9-0-3-2
3-9-0-7-1-6-2-4-8-5-11-10-3
4-2-7-1-6-9-0-3-8-5-11-10-4
5-8-4-2-7-1-6-9-0-3-11-10-5
6-1-7-2-4-8-5-11-10-9-0-3-6
7-1-6-2-4-8-5-11-10-9-0-3-7
8-4-2-7-1-6-9-0-3-5-11-10-8
9-0-3-7-1-6-2-4-8-5-11-10-9
10-11-5-8-4-2-7-1-6-9-0-3-10
11-5-8-4-2-7-1-6-9-0-3-10-11
(2558
(2496
(3001
(2376
(3126
(2670
(2499
(2496
(2821
(2496
(2447
(2447
km)
km)
km)
km)
km)
km)
km)
km)
km)
km)
km)
km)
Leipzig
2376
BERLIN
BREMEN
DORTMUND
DRESDEN
DUESSELDORF
FRANKFURT
HAMBURG
HANNOVER
KOELN
LEIPZIG
MUENCHEN
STUTTGART
0
1
2
3
4
5
6
7
8
9
10
11
Stuttgart
München
Abbildung 17.55 Approximation des optimalen Weges
Im Folgenden sei:
m die Länge des minimalen hamiltonschen Weges
s die Länge des minimalen Spannbaums
h die Länge des durch dieses Verfahren ermittelten hamiltonschen Weges
Wenn man aus dem minimalen hamiltonschen Weg eine Kante entfernt, erhält man
einen Spannbaum, der kürzer ist als der minimale hamiltonsche Weg. Daraus folgt,
dass der minimale Spannbaum kürzer ist als der minimale hamiltonsche Weg. Es ist
also: s ≤ m.
Wenn man den minimalen Spannbaum in Tiefensuche durchläuft, wird jede Kante
des Spannbaums maximal zweimal abgefahren. Beim Entfernen der doppelt vorkommenden Knoten wird diese Länge nicht vergrößert, da wir ja vorausgesetzt
haben, dass direkte Verbindungen nie länger als Umwege sind. Es gilt also für den mit
unserem Verfahren ermittelten hamiltonschen Weg: h ≤ 2s.
Insgesamt folgt: h ≤ 2s ≤ 2m
Der aus dem Spannbaum gewonnene hamiltonsche Weg ist also maximal doppelt so
lang wie der kürzeste hamiltonsche Weg. Damit haben wir eine Route für den Handlungsreisenden gefunden, die maximal doppelt so lang ist wie die optimale Route.
572
17.18
Das Travelling-Salesman-Problem
Das mag unbefriedigend sein, aber das Approximationsverfahren hat polynomiale
Laufzeit, während die vollständige Lösungssuche exponentielle Laufzeit hat.
Für das TSP gibt es hunderte von Verfahren, die versuchen, die Lösungssuche unter
speziellen Randbedingungen zu verbessern oder zu beschleunigen, aber keines dieser Verfahren löst das allgemeine Problem in polynomialer Laufzeit.
Das TSP ist vielleicht das am intensivsten untersuchte und am meisten diskutierte
Problem der Informatik. Ein Ende dieser Diskussion ist nicht in Sicht.
17
573
Kapitel 18
Zusammenfassung und Ergänzung
Denn was man schwarz auf weiß besitzt, kann man getrost nach
Hause tragen.
– Johann Wolfgang von Goethe
In diesem Kapitel finden Sie ein Kompendium der wichtigsten Fakten zur C-Programmierung. Die Informationen sind alphabetisch in Stichworten gegliedert. Hier
können Sie Ihr Wissen über die C-Programmierung auffrischen oder vertiefen.
Adressen
Variablen und Funktionen liegen zur Laufzeit an konkreten Stellen im Speicher des
Rechners und haben daher eine Speicheradresse. Diese Adresse kann mit dem
Adress-Operator (&) ermittelt werden.
Beispiel für eine Variable:
18
A
int v;
B
printf( "%d\n", &v);
Die Variable v wird angelegt (A), und ihr Adresswert wird ausgegeben (B):
4061360
Beispiel für eine Funktion:
A
void f()
{
}
B
printf( "%d\n", &f);
Die Funktion f wird definiert (A), und die Adresse der Funktion f wird z. B. mit diesem
Adresswert ausgegeben (B):
575
18
Zusammenfassung und Ergänzung
10752470
Bei einer Funktion kann die explizite Angabe des Adress-Operators weggelassen werden, da aus dem Zusammenhang klar ist, dass es sich nur um eine Funktionsadresse
handeln kann.
Im Grunde genommen interessieren uns konkrete Adresswerte von Variablen oder
Funktionen nicht; wichtig ist, dass wir über die Adresse auf das ursprüngliche Objekt
zugreifen können. Das ist ja auch bei alltäglichen Adressen so. Wir speichern eine EMail-Adresse im Adressbuch unseres E-Mail-Programms und verwenden sie, wenn
wir dem Kontakt eine E-Mail schicken wollen. Der konkrete Adresswert (yxz@abc.de)
interessiert uns dabei eigentlich nicht.
Adressen werden dazu verwendet, die Zugriffsinformation auf eine Variable oder
Funktion in einem Programm zu verwalten. Zum Beispiel kann die Adresse einer
Variablen in eine Datenstruktur geschrieben werden. Die Datenstruktur kann an eine
Funktion übergeben werden. Diese Funktion kann dann über die in der Datenstruktur gespeicherte Adresse auf die Variable zugreifen.
Mit Adressen von Variablen können Sie rechnen. Was passiert, wenn Sie zu einer
Adresse eine Zahl (ein sogenanntes Offset) addieren, zeigt das folgende Beispiel:
int v;
printf(
printf(
printf(
printf(
"%d\n",
"%d\n",
"%d\n",
"%d\n",
&v);
&v+1);
&v+2);
&v+3);
Es wird z. B. folgende Ausgabe erzeugt:
2293136
2293140
2293144
2293148
Der Adresswert erhöht sich bei einer Addition von 1 um die Größe des Objekts (hier
int, Größe 4), dessen Adresse genommen wurde. Hätte man also eine Reihung von
Objekten gleichen Typs im Speicher (siehe Abschnitt »Arrays«), würde eine Addition
von 1 die Adresse des nächsten Objekts im Speicher liefern. Adressen sind also besonders geeignet, um sich in homogenen Datenbeständen wahlfrei zu bewegen. Mit
Adressen von Funktionen kann man nicht rechnen.
Adressen sind aber auch geeignet, um verkettete Datenstrukturen (Listen, Bäume) zu
erstellen. Sie erstellen eine Verkettung, indem Sie in eine Struktur die Adresse einer
576
Alignment
anderen Struktur eintragen. Verkettete Strukturen haben den Vorteil, dass sie zur
Laufzeit dynamisch aufgebaut werden können, sodass der Umfang der zu verarbeitenden Daten zur Compile-Zeit noch nicht bekannt sein muss. Weitere Informationen zu dynamischen Datenstrukturen finden Sie in Abschnitt 18.75, »Speicherallokation«, und mehr über den Zugriff mittels Adressen erfahren Sie in Kapitel 8,
»Zeiger und Adressen«.
Alignment
Bei der Definition von Datenstrukturen gibt es gewisse Möglichkeiten, den benötigten Speicherplatz zu optimieren. Um uns das klarzumachen, erstellen wir eine Datenstruktur mit vier Zahlen (long) und vier Zeichen (char). Wir erstellen zwei Varianten
dieser Datenstruktur und vertauschen nur die Reihenfolge der Felder:
struct test1
{
char c1;
long l1;
char c2;
long l2;
char c3;
long l3;
char c4;
long l4;
};
18
Entsprechend vertauscht, sieht die Struktur dann so aus:
struct test2
{
long l1;
long l2;
long l3;
long l4;
char c1;
char c2;
char c3;
char c4;
};
Beide Datenstrukturen enthalten exakt die gleichen Informationen, und beide
Datenstrukturen sind in der Verwendung identisch. Sie werden vielleicht vermuten,
577
18
Zusammenfassung und Ergänzung
dass daher auch der Speicherplatzbedarf dieser Datenstrukturen gleich ist und sich
leicht aus den Grunddatentypen berechnen lässt. Wenn man annimmt, dass eine
long-Zahl vier und ein Zeichen ein Byte belegt, sollten das in der Summe 20 Bytes
sein. Wenn wir die Größe der beiden Datenstrukturen mit dem sizeof-Operator
bestimmen, erleben wir eine Bestätigung und eine Überraschung:
void main()
{
printf ("test1: %d\n", sizeof(test1));
printf ("test2: %d\n", sizeof(test2));
}
Wir erhalten die folgende Ausgabe:
test1: 32
test2: 20
Die zweite Datenstruktur hat die erwartete Größe, während die erste um mehr als
50 % größer ist. Der Grund dafür ist eine unterschiedliche Ausrichtung der Daten im
Speicher. Man spricht in diesem Zusammenhang auch von Alignment. Die unterschiedliche Ausrichtung hat mit der Hardwarearchitektur des Zielsystems zu tun.
Der Compiler legt die Felder der Datenstruktur so an, dass der Prozessor des Zielsystems möglichst effizient mit den Daten arbeiten kann, ohne dabei die Reihenfolge
der Felder zu verändern. Stellen Sie sich vor, dass der Rechner einen vier Bytes breiten Datenbus hat und mit einem Speicherzugriff immer vier Bytes gleichzeitig lesen
kann. Dann wird er den Speicher in 4-Byte-Blöcken lesen und schreiben. Das bedeutet, dass er eine 4-Byte-Integer-Zahl mit einem Zugriff lesen kann, wenn sie auf einer
durch 4 teilbaren Speicheradresse beginnt. Ist das nicht der Fall, muss der Prozessor
mit zwei Lesezugriffen insgesamt acht Bytes lesen und aus diesen die vier relevanten
Bytes zusammenstellen:
Optimal ausgerichtet.
Die Zahl kann in einem Zug gelesen werden.
Nicht optimal ausgerichtet.
Es müssen zwei Lesevorgänge gemacht und das Ergebnis
muss aus den beiden Vorgängen montiert werden.
Abbildung 18.1 Optimale Ausrichtung der Bytes
578
Arithmetische Operatoren (+, –, *, /, %)
Der Rechner kann also mit 4-Byte-Zahlen besonders effizient umgehen, wenn sie im
Speicher ein 4-Byte-Alignment haben, d. h., wenn sie auf einer durch 4 teilbaren
Adresse beginnen. Aus diesem Grund hat der Compiler in die erste Datenstruktur
Füllfelder eingefügt, um ein günstiges Alignment zu erzwingen:
c1
l1
c2
l2
c4
l3
c3
l4
Abbildung 18.2 Anordnung durch den Compiler, um ein Alignment zu erzwingen
Bei der zweiten Datenstruktur war das nicht erforderlich, da ohne Füllfelder bereits
ein optimales Alignment vorliegt:
l2
l1
l3
l4
c1 c2 c3 c4
Abbildung 18.3 Alignment ohne zusätzliche Füllfelder
Dementsprechend kleiner ist die Datenstruktur. Ein Rechner kann auch mit nicht
optimal ausgerichteten Daten arbeiten, und man kann einen Compiler so einstellen,
dass er die Datenstrukturen speicheroptimiert und nicht zugriffsoptimiert ablegt.
Darauf werde ich hier jedoch nicht eingehen. Ein optimales (speicher- und zugriffsoptimales) Alignment erhalten Sie, wenn Sie die Datenfelder in Ihren Datenstrukturen, ohne Rücksicht auf die Bedeutung, der Größe nach sortieren – also etwa alle long
vor allen int vor allen short vor allen char, so wie ich es bei der zweiten Datenstruktur gemacht habe.
Arithmetische Operatoren (+, –, *, /, %)
Für beliebige Zahlenwerte gibt es die zweistelligen arithmetischen Operatoren für
Addition (+), Subtraktion (–), Multiplikation (*) und Division (/).
Hinzu kommen einstellige Operatoren (+, –) für das Vorzeichen.
Für ganzzahlige Werte gibt es den »Rest bei Division« (Modulo-Operation).
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
+
+x
plus x
R
14
-
-x
minus x
arithmetischer
Operator
Tabelle 18.1 Arithmetische Operatoren
579
18
18
Zusammenfassung und Ergänzung
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
*
x*y
Multiplikation
L
13
/
x/y
Division
arithmetischer
Operator
%
x%y
Rest bei Division
+
x+y
Addition
L
12
–
x–y
Subtraktion
arithmetischer
Operator
Tabelle 18.1 Arithmetische Operatoren (Forts.)
Die Operatorzeichen + und – kommen in doppelter Bedeutung vor, aber das ist kein
Problem, da man anhand der Verwendung (einstellig, zweistellig) erkennen kann,
welche Bedeutung innerhalb einer Formel gemeint ist. Die Prioritäten sind so
gewählt, dass sie der vertrauten Sicht der Schulmathematik entsprechen.
Sind die Operanden eines arithmetischen Operators ganzzahlig, ist auch das Ergebnis ganzzahlig. Für die Division bedeutet dies, dass eine Division ohne Rest durchgeführt wird, wenn beide an der Division beteiligten Operanden ganzzahlig sind. In
Formeln wird bei der Auswertung immer so lange wie möglich ganzzahlig gerechnet,
auch wenn am Ende unter Umständen eine Gleitkommazahl herauskommt. Manchmal ergeben sich dadurch Ergebnisse, die in einem scheinbaren Widerspruch zur
Schulmathematik stehen. Dies ist insbesondere mit Blick auf die Division wichtig.
Schulmathematisch ist
x = 10 · 10 = 1 und y = 10 · 10 = 1
100
100
Im Programm ist aber
x = 10*(10/100) = 10*0 = 0
y = (10*10)/100 = 100/100 = 1
Abbildung 18.4 Division mit ganzen Zahlen
Die Division mit Rest und den Rest bei Division (Modulo-Operation) betrachtet man
üblicherweise nur bei positiven Zahlen. Dort ist alles eindeutig geregelt:
Da 7 = 2 · 3 + 1 ist, ergibt sich:
a = 2 und b = 1
a = 7/3;
b = 7%3;
Abbildung 18.5 Ganzzahlige Division mit Rest
580
Arrays
Bei negativen Zahlen gibt es verschiedene Möglichkeiten, die Modulo-Operation zu
definieren, je nachdem, ob Sie hier negative oder positive Divisionsreste festlegen:
–5 = –2*3 + 1
oder
–5 = –1*3 – 2
Am besten verwenden Sie diese Operationen bei negativen Zahlen nicht, da unterschiedliche Compiler unterschiedliche Ergebnisse liefern können.
Arrays
Große, homogene Datenbestände, auf die man zur Laufzeit flexibel zugreifen muss,
kann man in einem sogenannten Array ablegen. Arrays sind Reihungen von Daten
des gleichen Typs.
Im einfachsten Fall handelt es sich um eine eindimensionale Anordnung, wobei die
einzelnen Elemente über einen Index angesprochen werden können:
Index
a[0]
a[1]
a[2]
a[3]
Abbildung 18.6 Indexierung im eindimensionalen Array
18
Die Reihungen können dabei in mehreren Dimensionen gebildet werden, wobei es
dann in jeder Dimension einen Index gibt:
a[4][0][0]
a[4][0][1]
a[4][0][2]
a[4][0][3]
2. Dimension 0 – 2
4
0–
a[4][1][0]
a[4][1][1]
a[4][1][2]
a[4][1][3]
a[3][0][0]
a[3][0][1]
a[3][0][2]
a[3][0][3]
n
io
a[4][2][0]
a[4][2][1]
a[4][2][2]
a[4][2][3]
s
en
a[3][1][0]
a[3][1][1]
a[3][1][2]
a[3][1][3]
im
D
a[2][0][0]
a[2][0][1]
a[2][0][2]
a[2][0][3]
1.
a[3][2][0]
a[3][2][1]
a[3][2][2]
a[3][2][3]
a[2][1][0]
a[2][1][1]
a[2][1][2]
a[2][1][3]
a[1][0][0]
a[1][0][1]
a[1][0][2]
a[1][0][3]
3. Dimension 0–3
a[2][2][0]
a[2][2][1]
a[2][2][2]
a[2][2][3]
a[1][1][0]
a[1][1][1]
a[1][1][2]
a[1][1][3]
a[0][0][0]
a[0][0][1]
a[0][0][2]
a[0][0][3]
a[1][2][0]
a[1][2][1]
a[1][2][2]
a[1][2][3]
a[0][1][0]
a[0][1][1]
a[0][1][2]
a[0][1][3]
a[0][2][0]
a[0][2][1]
a[0][2][2]
a[0][2][3]
Abbildung 18.7 Indexierung im mehrdimensionalen Array
581
18
Zusammenfassung und Ergänzung
Zu einem Array gehören:
왘
ein Datentyp für die Felder (Feldtyp)
왘
ein Name, über den das Array angesprochen werden kann
왘
eine feste Anzahl von Dimensionen
왘
eine feste Anzahl von Elementen in jeder Dimension
Datentyp
Name
Anzahl Elemente für jede Dimensionen
int daten[3][7][5];
Anzahl der Dimensionen = 3
Abbildung 18.8 Anlegen eines Arrays
Arrays können für alle verfügbaren Datentypen gebildet werden. Es gibt also:
왘
Arrays von Ganzzahlen
왘
Arrays von Gleitkommazahlen
왘
Arrays von Datenstrukturen
왘
Arrays von Aufzählungstypen
왘
Arrays von Bitfeldern
왘
Arrays von Zeigern
Ein Array ist homogen, d. h., alle Felder haben den gleichen Datentyp. Die Anzahl der
Dimensionen sowie die Anzahl der Elemente in den einzelnen Dimensionen sind
beliebig, müssen aber bei der Definition des Arrays festgelegt werden und können
danach nicht mehr geändert werden.
Das folgende Beispiel zeigt ein Array, das insgesamt 3 · 5 · 7 = 105 Gleitkommazahlen
aufnehmen kann, die in drei Dimensionen zu 3, 7 bzw. 5 Elementen gruppiert sind:
float daten[3][7][5];
Arrays können bei der Definition mit Initialwerten versehen werden:
582
Arrays
int matrix[3][2] = {
{ 11, 12},
{ 21, 22},
{ 31, 32}
};
Diese Werte können natürlich im Laufe des Programms geändert werden.
Die Anzahl der Elemente in der ersten Dimension kann auch implizit durch die Initialisierung des Arrays festgelegt werden. Die beiden folgenden Arrays haben jeweils
drei Elemente in der ersten Dimension:
int matrix[][2] = {
{ 11, 12},
{ 21, 22},
{ 31, 32}
};
float zahlen[] = {1.1, 2.2, 3.3};
Die Felder eines Arrays sind in jeder Dimension, beginnend mit 0, fortlaufend nummeriert.
Hat ein Array in einer Dimension n Elemente, sind diese von 0 bis n-1 nummeriert.
Zugegriffen wird auf die Felder eines Arrays, indem Sie in jeder Dimension einen gültigen Index angeben. Der Index kann durch eine Konstante, eine Variable oder einen
beliebigen Ausdruck gegeben sein, der zur Laufzeit zu einem gültigen ganzzahligen
Index ausgewertet werden kann:
int daten[3][7][5];
int x;
int y;
x = 1;
y = 2;
daten[1][x][2*x+y-1]= 15;
daten[1][2][1] = daten[2][x+1][3]+4;
Das Ergebnis eines indizierten Zugriffs ist von dem Datentyp, der durch den Feldtyp
des Arrays festgelegt ist, und kann wie eine Variable dieses Typs verwendet werden.
583
18
18
Zusammenfassung und Ergänzung
Es gibt keine Prüfungen, ob der Programmierer korrekte Indizes benutzt. Das folgende Programm wird gnadenlos abstürzen:
int a[100];
a[100] = 1;
Falsch berechnete Array-Indizes sind eine der häufigsten Fehlerursachen in C-Programmen. Bei iterierter Verarbeitung von Arrays durch Schleifen sollten Sie daher
immer den Minimal- und den Maximalwert überprüfen, um sicherzustellen, dass der
gültige Indexbereich nicht verlassen wird.
Arrays als Funktionsparameter
Wenn ein Array an eine Funktion übergeben wird, dann wird das Array als Zeiger
(siehe Abschnitt »Arrays und Zeiger«) übergeben. Damit erhält das Unterprogramm
eine Referenz auf die Originaldaten und kann diese gegebenenfalls verändern.
Da ein Unterprogramm nicht ermitteln kann, wie viele Elemente ein Array hat, wird
in der Regel zusätzlich zu dem Array eine Integer-Variable übergeben, in der die
Anzahl der Elemente, die im Unterprogramm zu bearbeiten sind, festgelegt ist:
void initialisierung( int anzahl, int *daten)
{
int i;
void main()
{
int zahlen[10];
initialisierung ( 10, zahlen);
ausgabe ( 10, zahlen);
}
1 2 3 4 5 6 7 8 9 10
for( i = 0; i < anzahl; i++)
daten[i] = i+1;
}
void ausgabe( int anzahl, int *daten)
{
int i;
for( i = 0; i < anzahl; i++)
printf( "%d ", daten[i]);
printf( " \n");
}
Abbildung 18.9 Arrays als Funktionsparameter
Bei der Übergabe eines mehrdimensionalen Arrays kann nur die Anzahl der Elemente in der ersten Dimension unbestimmt bleiben. Alle anderen Angaben sind
unverzichtbar, da sie für die korrekte »Serialisierung« des Arrays im Speicher notwendig sind.
584
Arrays und Zeiger
void initialisierung( int anzahl, int (*daten)[5])
{
int i, k;
void main()
{
int zahlen[3][5];
initialisierung( 3, zahlen);
ausgabe( 3, zahlen);
}
0 1 2 3 4
1 2 3 4 5
2 3 4 5 6
for( i = 0; i < anzahl; i++)
{
for( k = 0; k < 5; k++)
daten[i][k] = i+k;
}
}
void ausgabe( int anzahl, int (*daten)[5])
{
int i, k;
for( i = 0; i < anzahl; i++)
{
for( k = 0; k < 5; k++)
printf( "%d ", daten[i][k]);
printf( "\n");
}
}
Abbildung 18.10 Mehrdimensionale Arrays als Funktionsparameter
Arrays und Zeiger
Arrays und Zeiger werden in C synonym verwendet. Mit anderen Worten:
Wenn a ein Array ist, dann kann a wie ein Zeiger auf das erste Element im Array
verwendet werden.
Grundsätzlich ist ein Array aber kein Zeiger, weil das Array die Elemente physikalisch
enthält, während der Zeiger die Elemente nur referenziert. In der Verwendung kann
man aber Array und Zeiger nicht unterscheiden.
Wir demonstrieren dies, indem wir in einem Programm ein Array mit Feldtyp int
und einen Zeiger auf int anlegen:
A
int i;
int zahlen[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
B
int *z;
C
z = zahlen;
D
for( i = 0; i < 10; i++)
printf( "%d ", zahlen[i]);
printf( "\n");
585
18
18
Zusammenfassung und Ergänzung
E
for( i = 0; i < 10; i++)
printf( "%d ", z[i]);
printf( "\n");
Listing 18.1 Arrays und Zeiger
Das Programm legt ein Array mit zehn Integer-Zahlen (A) sowie einen Zeiger auf Integer (B) an. Diesem Zeiger kann das Array zugewiesen werden, da das Array auch als
Zeiger verstanden werden kann (C). Im weiteren Verlauf werden Array und Zeiger
dann synonym verwendet (D und E), und wir erhalten diese Ausgabe:
1 2 3 4 5 6 7 8 9 10
1 2 3 4 5 6 7 8 9 10
Bei der Zuweisung (z = zahlen) wird nur die Startadresse des Arrays in den Zeiger
übertragen. Die Elemente im Array werden nicht kopiert – wohin auch? Die umgekehrte Zuweisung (zahlen = z) ist nicht möglich, da ein Zeiger kein Array ist und keine
Elemente enthält, die in das Array kopiert werden könnten.
Das zu den eindimensionalen Arrays Gesagte gilt auch für mehrdimensionale Arrays.
Sie müssen sich nur klarmachen, dass ein zweidimensionales Array ein »Array von
Arrays« ist. Am besten schauen wir uns das direkt im Code an:
int i,k;
A
int zahlen[2][3] = {{1,2,3},{4,5,6}};
B
int (*z)[3];
C
z = zahlen;
D
for( i = 0; i < 2; i++)
{
for( k = 0; k < 3; k++)
printf( "%d ", zahlen[i][k]);
printf( "\n");
}
printf( "\n");
E
586
for( i = 0; i < 2; i++)
{
for( k = 0; k < 3; k++)
printf( "%d ", z[i][k]);
Arrays und Zeiger
printf( "\n");
}
printf( "\n");
Listing 18.2 Mehrdimensionale Arrays und Zeiger
Hier wird ein zweidimensionales Array angelegt (A) und ein Zeiger auf ein eindimensionales Array mit drei Integer-Werten erstellt (B). Diesem Zeiger wird das Array
zugewiesen (C). Array und Zeiger werden nun synonym verwendet (D und E), und wir
erhalten diese Ausgabe:
1 2 3
4 5 6
1 2 3
4 5 6
Für höhere Dimensionen gilt Entsprechendes, da ein n-dimensionales Array immer
ein eindimensionales Array über einem n-1-dimensionalen Array ist.
Mit den Regeln der Zeigerarithmetik folgt, dass wir ein Element des Arrays sowohl
über seinen Index als auch sein Offset ansprechen können.
In einem Array a sind die Zugriffe a[i] und *(a+i) identisch.
Im Zweidimensionalen heißt dies, dass die Zugriffe a[i][k], (*(a+i))[k] und
*(*(z+i)+k) identisch sind.
Das kann zu unleserlichen Ausdrücken und ausgesprochen bizarren Identitäten führen. Zum Beispiel:
a[i] = *(a+i) = *(i+a) = i[a]
Bei dem folgenden Beispiel handelt es sich konsequenterweise um korrekten, aber
unleserlichen C-Code:
float a[3] = {1.1, 2.2, 3.3};
int i = 1;
float x;
x = i[a];
Probieren Sie es aus, aber verwenden Sie es nicht in Ihren Programmen.
587
18
18
Zusammenfassung und Ergänzung
ASCII-Zeichencode
Zur rechnerinternen Darstellung von Zeichen wird häufig der ASCII-Zeichencode verwendet. Durch den ASCII-Zeichencode wird jedem Zeichen eine Zahl zugeordnet:
Quelle: Wikipedia
Das Zeichen \ hat den ASCII-Code 5c16 = 1348.
Abbildung 18.11 Auszug aus der ASCII-Zeichentabelle
Im ASCII-Zeichencode wird jedes Zeichen durch eine 7-Bit-Codierung dargestellt und
kann daher in einem 8-Bit-Datenwort (unsigned char) abgelegt werden.
Der ASCII-Zeichencode stammt aus der Frühzeit der Datenverarbeitung und umfasst
nur die Buchstaben des englischen Alphabets und einige Sonderzeichen. Zur Darstellung umfassenderer Zeichensätze werden erweiterte Zeichencodes benötigt, die
unter Umständen auch eine Darstellung in mehreren Bytes erfordern. Solche Zeichencodes werden hier nicht behandelt.
Ausgabe
In der C Runtime Library gibt es eine Reihe von Funktionen zur Bildschirmausgabe.
Die wichtigste dieser Funktionen ist printf, die zur formatierten Ausgabe von Zeichen, Zahlen und Text dient. Die printf-Funktion hat eine variable Anzahl von Parametern. Festgelegt ist dabei nur der erste Parameter, der einen Formatstring enthält.
Dieser String enthält, neben dem auszugebenden Text, sogenannte Formatanweisungen. Jede Formatanweisung korrespondiert mit einem Parameter der Funktion,
der den auszugebenden Wert enthält. Die Formatanweisung legt fest, wie der Parameterwert ausgegeben werden soll.
Eine Formatanweisung hat den folgenden formalen Aufbau:
588
Ausgabe
%[flags][width][.precision][length]specifier
Die in eckigen Klammern aufgeführten Bestandteile sind optional; sie können also
fehlen. Im einfachsten Fall hat eine Formatanweisung also die Form:
%specifier
Der hier genannte specifier ist ein einzelnes Zeichen wie s, d oder f und steht für den
auszugebenden Datentyp und dessen grundlegendes Ausgabeformat. Gültige Formatanweisungen sind etwa %d, %s oder %f.
Tabelle 18.2 zeigt alle gültigen Format-Specifier mit ihrem Datentyp und dem zugehörigen Ausgabeformat:
Specifier
Datentyp
Ausgabeformat
d oder i
ganze Zahl
dezimal
u
vorzeichenlose ganze
Zahl
dezimal
o
vorzeichenlose ganze
Zahl
oktal
x
vorzeichenlose ganze
Zahl
hexadezimal mit a, b, s, d, e und f
X
vorzeichenlose ganze
Zahl
hexadezimal mit A, B, C, D, E und F
f
Gleitkommazahl
keine Exponentenschreibweise
F
Gleitkommazahl
keine Exponentenschreibweise
e
Gleitkommazahl
Exponentenschreibweise mit e
E
Gleitkommazahl
Exponentenschreibweise mit E
g
Gleitkommazahl
kürzeste Variante von f oder e
G
Gleitkommazahl
kürzeste Variante von F oder E
a
Gleitkommazahl
hexadezimal mit Kleinbuchstaben
A
Gleitkommazahl
hexadezimal mit Großbuchstaben
c
Zeichen
s
String
18
Tabelle 18.2 Specifier für die Ausgabe
589
18
Zusammenfassung und Ergänzung
Specifier
Datentyp
Ausgabeformat
p
Zeiger (Adresswert)
hexadezimal
n
int *
Keine Ausgabe. Die Länge der bisherigen
Ausgabe wird in der referenzierten Variablen gespeichert.
%
Ausgabe eines %-Zeichens
Tabelle 18.2 Specifier für die Ausgabe (Forts.)
Zwischen dem %-Zeichen und dem Format-Specifier können die folgenden Kennzeichen angegeben werden:
Kennzeichen
Bedeutung
-
Die Ausgabe wird links ausgerichtet.
Bei fehlendem Kennzeichen wird rechts ausgerichtet.
+
Ausgabe mit Vorzeichen.
Bei fehlendem Kennzeichen wird nur bei negativen Werten ein
Vorzeichen ausgegeben.
Leerzeichen
Ausgabe eines Leerzeichens anstelle des +-Zeichens
#
Bei Oktal- und Hexadezimalausgaben wird 0, 0x bzw. 0X vorangestellt.
Bei Gleitkommaausgaben wir auch dann ein Dezimalpunkt ausgegeben, wenn keine Ziffern hinter dem Punkt folgen.
0
Zahlen werden von links mit führenden Nullen aufgefüllt.
Tabelle 18.3 Kennzeichen für die Ausgabe
Durch die Angabe von width können Sie eine Feldbreite für die Ausgabe festlegen:
width
Bedeutung
zahl
Die Zahl gibt die verwendete Feldbreite an, sofern die Ausgabe kürzer als die angegebene Feldbreite ist. Ist die Ausgabe länger, wird
die Feldbreite ignoriert. Die Ausgabe wird also nie abgeschnitten.
*
Die Feldbreite wird aus dem nächsten Parameter der Parameterliste gelesen. Als Parameter wird ein Ganzzahlwert erwartet.
Tabelle 18.4 Feldbreite für die Ausgabe
590
Ausgabe
Durch die Angabe einer precision kann die Genauigkeit der Ausgabe beeinflusst
werden (Tabelle 18.5):
precision
Bedeutung
zahl
Bei Ganzahlen gibt zahl die Anzahl der mindestens auszugebenden Ziffern an, wobei gegebenenfalls mit führenden Nullen aufgefüllt wird.
Für Gleitkommazahlen, die mit a, A, e, E und f, F ausgegeben werden,
legt zahl die Anzahl der auszugebenden Nachkommastellen fest.
Für Gleitkommazahlen, die mit g oder G ausgegeben werden, ist zahl
die Gesamtzahl signifikanter Ziffern, die ausgegeben werden sollen.
Bei Strings ist zahl die maximale Anzahl von Zeichen, die ausgegeben
werden sollen.
*
Der Wert für die Präzision wird aus dem nächsten Parameter der Parameterliste gelesen. Als Parameter wird ein Ganzzahlwert erwartet.
Tabelle 18.5 Präzisionsangabe für die Ausgabe
Die optionale Abgabe von length ermöglicht Konvertierungen zwischen verschiedenen Typen von Eingabeparametern, wenn der übergebene Parameter einmal nicht
den Datentyp hat, der üblicherweise erwartet wird. Dieser Teil der Formatanweisung
ist sehr technisch und wird hier nicht umfassender diskutiert.
Mit den Formatanweisungen können Sie sehr flexibel Ausgabeformatierungen
erzeugen. Die Ausgabe mit printf bezieht sich aber auf Ausgaben im Konsolenfenster. Im Zeitalter grafischer Benutzeroberflächen verliert diese Art der Ausgabe mehr
und mehr an Bedeutung und wird eigentlich nur noch für Prüfdrucke verwendet.
Abbildung 18.12 zeigt einige Beispiele:
double d = 3.14;
printf(
printf(
printf(
printf(
printf(
"%f\n", d);
"%10.2f\n", d);
"%-10.3f\n", d);
"%-10.*f\n", 4, d);
"%E\n", d);
Ohne besondere Formatierung
Feldbreite 10, 2 Nachkommastellen, rechtsbündig
Feldbreite 10, 3 Nachkommastellen, linksbündig
Feldbreite 10, 4 Nachkommastellen, linksbündig
Exponentialschreibweise
3.140000
3.14
3.140
3.1400
3.140000E+000
Abbildung 18.12 Verschiedene Formatanweisungen und ihre Wirkung
591
18
18
Zusammenfassung und Ergänzung
auto
Mit dem Zusatz auto wird eine Variable als »automatische Variable« klassifiziert. Man
spricht in diesem Zusammenhang auch von einer Speicherklasse, die der Variablen
zugeordnet wird:
auto int summe;
auto double wert;
Die Speicherklasse auto kann nur bei Variablendefinitionen innerhalb eines Blocks
(also innerhalb geschweifter Klammern) verwendet werden und legt fest, dass es sich
um eine lokale Variable handelt, deren Lebensdauer automatisch bestimmt wird. Die
Variable wird erzeugt, wenn der Kontrollfluss in den Block eintritt. Beim Verlassen des
Blocks wird die Variable wieder beseitigt. Automatische Variablen liegen auf dem Stack.
Da Variablen innerhalb von Blöcken ohne explizite Zuordnung einer Speicherklasse
immer als automatische Variablen angelegt werden, findet man eine auto-Anweisung in C-Programmen relativ selten. Sie sollten auto in diesem Sinn auch nicht
mehr verwenden, da auto in neueren Standards – seit C++11 – eine geänderte Bedeutung hat. Dort bedeutet auto, dass der Compiler eine automatische Typerkennung
für diese Variable durchführt.
Bedingte Auswertung
Einfache Berechnungsalternativen können Sie durch bedingte Auswertung sehr einfach formulieren. Sie verwenden dazu den dreistelligen ? :-Operator.
Wenn Sie z. B. den größeren von zwei Werten (a, b) ermitteln und zuweisen möchten,
können Sie alternativ zu einer Fallunterscheidung
if( a > 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 links
Bitoperator
L
11
>>
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<<2
1
0
0
1
1
0
1
1
0
1
1
0
1
1
0
0
Abbildung 18.15 Bitweises Verschieben
595
18
18
Zusammenfassung und Ergänzung
Mit Bitoperationen können gezielt einzelne Bits in einem Datenwort gesetzt,
gelöscht, invertiert oder abgefragt werden:
int n = 3;
unsigned int x = 0xaffe;
x = x | (1<<n); // Setzen des n-ten Bits in x
x = x & ~(1<<n); // Loeschen des n-ten Bits in x
x = x ^ (1<<n); // Invertieren des n-ten Bits in x
if( x & (1<<n)) // Test, ob das n-te Bit in x gesetzt ist
{
...
}
Bitoperationen wendet man üblicherweise nur auf vorzeichenlose ganze Zahlen an.
Bei vorzeichenlosen Zahlen werden beim Schieben alle frei werdenden Bitstellen mit
0 besetzt. Der Standard legt aber nicht fest, was bei einem Bitshift nach rechts in die
frei werdenden Bitstellen einer vorzeichenbehafteten Zahl geschoben wird. Auf manchen Systemen ist es eine 0, auf manchen das Vorzeichenbit (Carry). Das Programmfragment in Abbildung 18.16 zeigt, dass sich die Schiebeoperationen auf meinem
System für signed- und unsigned-Operanden unterschiedlich verhalten:
unsigned char a = 0xff;
signed char b = 0xff;
int i;
for( i = 0; i <= 8; i++)
{
printf( "%d\n", a);
a = a >> 1;
}
printf( "\n");
for( i = 0; i <= 8; i++)
{
printf( "%d\n", b);
b = b >> 1;
}
255
127
63
31
15
7
3
1
Hier (unsigned) wird
0
0 nachgeschoben.
-1
-1
-1
-1
-1
-1
-1
-1
-1
Hier (signed) wird 1
nachgeschoben.
Abbildung 18.16 Unterschiedliches Verhalten des Verschiebens abhängig vom Datentyp
Auf einem anderen System könnte das anders sein. Seien Sie daher sehr vorsichtig,
wenn Sie Schiebeoperationen mit vorzeichenbehafteten Zahlen verwenden.
596
Blöcke
Blöcke
Anweisungen können Sie durch geschweifte Klammern zu Blöcken zusammenfassen. Solche Blöcke können Sie strukturell als eine einzelne Anweisung auffassen und
wieder mit anderen Anweisungen und Blöcken zu neuen Blöcken zusammenfassen.
Auf diese Weise ergibt sich eine Hierarchie ineinander geschachtelter Blöcke und Einzelanweisungen.
Nur am Anfang eines Blocks können Sie Variablen definieren1. Diese Variablen sind
dann nur innerhalb des Blocks, in dem sie angelegt wurden, und darin eingeschlossenen Blöcken sichtbar und können auch nur dort verwendet werden. Sie können sich
das so vorstellen, dass jeder eingeschlossene Block auf einer neuen, höheren Ebene
liegt, von der aus man auf die darunterliegenden Ebenen blicken kann:
}
...
}
{
int
x;
...
{
int
a;
...
...
...
18
Abbildung 18.17 Blöcke als Ebenen
Von den unteren Ebenen kann man aber nicht die Informationen auf den darüberliegenden Ebenen erkennen. Im Falle von Namenskonflikten, also gleich benannten
Variablen auf verschiedenen Ebenen, haben die Variablen, die einem »näher« sind,
Vorrang vor »entfernteren« Variablen. Sie sollten solche Konflikte aber prinzipiell
vermeiden.
Beachten Sie auch den Unterschied zwischen automatischen und statischen Variablen. Automatische Variablen werden immer wieder neu erzeugt, wenn der Kontrollfluss in den Block eintritt, und wieder beseitigt, wenn der Kontrollfluss den Block
verlässt. Statische Variablen haben die Lebensdauer des Hauptprogramms. Näheres
dazu finden Sie unter den Stichworten auto bzw static.
1 Diese Einschränkung wird später, in C++, aufgehoben.
597
18
Zusammenfassung und Ergänzung
break
Eine break-Anweisung dient zum Unterbrechen des Kontrollflusses innerhalb von
Schleifen (for, while, do) oder Sprungleisten (switch) und tritt nie als alleinstehende
Anweisung auf. Schauen Sie sich daher die Abschnitte »for«, »while«, »do« und
»switch« in diesem Kapitel an.
case
Mit case werden Einsprungstellen innerhalb einer switch-Sprungleiste festgelegt. Da
case immer nur in Verbindung mit switch auftritt, erhalten Sie alle weiteren Informationen im Abschnitt über switch.
Cast-Operator
In vielen Fällen werden in C automatisch Typkonvertierungen durchgeführt. Wenn
z. B. eine Integer-Variable einer Float-Variaben zugewiesen wird, haben wir es streng
genommen mit zwei verschiedenen Typen zu tun. Trotzdem ist die Zuweisung möglich, weil eine Integer-Zahl ohne Informationsverlust in eine Float-Variable hineinpasst.
int i = 100;
float f;
f = i;
Wenn man umgekehrt eine Float-Variable einer Integer-Variablen zuweist, besteht
die Gefahr eines Informationsverlustes, und der Compiler erzeugt zumindest einen
Warnhinweis. Diesen Hinweis können Sie durch eine explizite Typumwandlung
beseitigen und nehmen dann den Informationsverlust in Kauf:
float f = 1.23;
int i;
i = (int)f;
Zur Typumwandlung schreiben Sie den gewünschten Ergebnistyp in runden Klammern vor den zu konvertierenden Ausdruck.
Sinnvoll ist eine Typumwandlung z. B., wenn Sie innerhalb einer Berechnung von
Integer- auf Gleitkommarechnung umsteigen möchten:
598
char
float f;
f ist 0.0,
da eine Integer-Division durchgeführt wird.
f = 10/100;
f = (float)(10/100);
f = ((float)10)/100;
f ist 0.0,
da die Konvertierung in float erst
nach der Division durchgeführt wird.
f ist 0.1,
da der Operand 10 vor der Division
in float konvertiert wird.
Abbildung 18.18 Beispiele für den sinnvollen Einsatz der Typumwandlung
Gern verwendet werden Typkonvertierungen auch in Verbindung mit Zeigern an
Funktionsschnittstellen. Eine Funktion wie malloc allokiert Speicher, ohne wissen zu
können, wofür der Speicher benötigt wird. Die Funktion gibt daher einen unspezifizierten Zeiger (void *) zurück. Damit ein solcher Zeiger sinnvoll verwendet werden
kann, muss eine Konvertierung auf den tatsächlich benötigten Zeigertyp stattfinden:
int *pointer;
pointer = (int *)malloc( ...);
Verschiedene Zeigertypen können immer ohne Informationsverlust konvertiert
werden, da mit einem anderen Zeigertyp nur eine andere Interpretation des referenzierten Objekts einhergeht. Der Programmierer ist allerdings dafür verantwortlich,
dass diese Interpretation auch stimmt. Durch einfaches Casting wird z. B. aus einer
Zahl kein String und aus einem String keine Zahl.
Im Zusammenhang mit Callback-Funktionen werden häufig Parameterkonvertierungen benötigt, wenn man Informationen transparent durch eine Funktion an eine
Callback-Funktion weiterleiten will. Dazwischenliegende Funktionsinstanzen reichen dann die Daten nur durch, ohne deren Typ zu kennen. Erst in der Zielfunktion
wird dann der wirkliche Datentyp durch eine Cast-Operation wiederhergestellt. Dazu
muss die Zielfunktion die richtige Interpretation der Daten kennen.
char
Der Datentyp char bezeichnet eine »sehr kleine« ganze Zahl oder ein einzelnes Zeichen. Es handelt sich um einen vorzeichenbehafteten Datentyp, üblicherweise im
Bereich von –128 bis +127.
599
18
18
Zusammenfassung und Ergänzung
Mehr darüber erfahren Sie in den Abschnitten über Datentypen für ganze Zahlen
bzw. Datentypen für Gleitkommazahlen.
Compile-Schalter
Oft ist es erforderlich, von einem Softwaresystem unterschiedliche Varianten (z. B.
für verschiedene Betriebssysteme) zu erstellen. Die konsistente Pflege der verschiedenen Varianten stellt ein erhebliches Problem dar, wenn man das Single-SourcePrinzip verletzt. Dieses Prinzip besagt, dass es auch bei unterschiedlichen Zielvarianten immer nur eine Variante des Quellcodes geben darf. Compile-Schalter ermöglichen es, aus einer Quelle verschiedene Varianten eines Programms zu erzeugen.
Wir verdeutlichen dies am Beispiel von Prüfdrucken. Stellen Sie sich vor, dass in der
Testvariante eines Programms an vielen unterschiedlichen Stellen Prüfdrucke eingebaut sind. Diese Prüfdrucke protokollieren den Programmablauf und unterstützen
damit die Fehlersuche. In der Variante, die an einen Kunden ausgeliefert wird, sollen
natürlich keine Prüfdrucke mehr vorhanden sein. Mehr noch, es soll nicht einmal
mehr der Code für Prüfdrucke in der Kundenvariante vorhanden sein, da unnützer
Code das Programm nur unnötig aufblähen würde. Möchten Sie in dieser Situation
vermeiden, dass der Programmcode in zwei Varianten zerfällt, können Sie CompileSchalter verwenden:
Preprozessorlauf
mit gesetztem Compileschalter
# define TESTVARIANTE
void main()
{
int i, s;
void main()
{
int i, s;
for( i = 1, s = 0; i < 10; i++)
{
s = s + i;
printf( "Zwischenergebnis: i = %d, s = %d\n", i, s);
}
Zwischenergebnis: i = 1, s = 1
printf( "Endergebnis: %d\n", s);
Zwischenergebnis: i = 2, s = 3
}
Zwischenergebnis: i = 3, s = 6
for( i = 1, s = 0; i < 10; i++)
{
s = s + i;
# ifdef TESTVARIANTE
printf( "Zwischenergebnis: i = %d, s = %d\n", i, s);
# endif
}
void main()
printf( "Endergebnis: %d\n", s);
{
}
int i, s;
Preprozessorlauf
mit ungesetztem Compileschalter
# undef TESTVARIANTE
Zwischenergebnis:
Zwischenergebnis:
Zwischenergebnis:
Zwischenergebnis:
Zwischenergebnis:
Zwischenergebnis:
Endergebnis: 45
i
i
i
i
i
i
=
=
=
=
=
=
4,
5,
6,
7,
8,
9,
s
s
s
s
s
s
=
=
=
=
=
=
10
15
21
28
36
45
for( i = 1, s = 0; i < 10; i++)
{
s = s + i;
}
printf( "Endergebnis: %d\n", s);
}
Endergebnis: 45
Abbildung 18.19 Die Verwendung von Compile-Schaltern
Je nachdem, ob der Compile-Schalter TESTVARIANTE bei der Erzeugung des Programms gesetzt ist oder nicht, werden die Prüfdrucke dann übernommen oder her-
600
Compile-Schalter
ausgefiltert. Typischerweise wird der Compile-Schalter in einer zentralen HeaderDatei mit #define gesetzt oder #undef zurückgenommen. Diese Header-Datei wird
dann von allen betroffenen Quellcodedateien inkludiert. Der Compile-Schalter kann
aber auch über die Kommandozeile des Compilers gesetzt werden, sodass zum
Erzeugen unterschiedlicher Varianten nicht einmal der Inhalt einer Datei geändert
werden muss.
Für das Verständnis von Compile-Schaltern ist wichtig, dass die jeweils nicht aktivierten Codeteile nicht durch Abfragen zur Laufzeit umsprungen werden, sondern
bereits vor der Kompilation durch den Präprozessor ausgefiltert werden und von
daher im Code des laufenden Programms gar nicht mehr vorkommen. Dies ermöglicht es, in den verschiedenen Varianten systemspezifischen Code zu implementieren, der für gewisse Zielsysteme nicht kompilierbar wäre.
Im Zusammenhang mit bedingter Kompilierung gibt es die folgenden Steueranweisungen:
Anweisung
Bedeutung
# define
Setzen eines Schalters
# undef
Rücksetzen eines Schalters
# if
Fallunterscheidung aufgrund eines konstanten Ausdrucks (0 oder ‡0)
# ifdef
Fallunterscheidung aufgrund eines gesetzten Compile-Schalters
# ifndef
Fallunterscheidung aufgrund eines nicht gesetzten CompileSchalters
# else
Alternative bei if, ifdef oder ifndef
# elif
Alternative mit erneuter if-Bedingung
# endif
Ende der Fallunterscheidung
18
Tabelle 18.7
Steueranweisungen für die bedingte Kompilierung
Beachten Sie, dass es hier keine Gruppierungen mit Klammern gibt und dass eine
vollständige Fallunterscheidung wie folgt aussehen könnte:
# define VERSION 2
...
# if VERSION < 1
601
18
Zusammenfassung und Ergänzung
...
# elif VERSION == 2
...
# else
...
# endif
Es gibt weitere Möglichkeiten, den Compiler über spezielle Präprozessoranweisungen zu steuern, die hier aber nicht im Detail diskutiert werden:
Anweisung
Bedeutung
# line
Festlegung einer Zeilennummer
# error
Abbruch des Compiler-Laufs mit einer Fehlermeldung
# pragma
spezifische Anweisung an den Compiler
Tabelle 18.8 Weitere Präprozessoranweisungen
const
Mit dem Zusatz const werden Daten als konstant, also unveränderlich, definiert:
const int x = 4711;
const double d = 1.234;
Konstanten haben wie Variablen einen Datentyp. Anders als Variablen müssen sie
aber bei der Definition mit einem Wert versehen werden. Dieser Wert kann dann
nicht mehr geändert werden. Konstanten können daher nur dort verwendet werden,
wo auch ein konkreter Wert des gleichen Typs verwendet werden könnte.
Konstanten haben natürlich keine Adresse, sodass der Adress-Operator nicht auf
Konstanten angewandt werden kann.
Ansonsten unterscheidet sich der Umgang mit Konstanten nicht vom Umgang mit
Variablen. Überall, wo eine Variable verwendet wird, ohne deren Wert zu verändern,
kann auch eine Konstante benutzt werden.
602
Dateioperationen
Konstanten bieten keine zusätzliche Programmierfunktionalität, richtig eingesetzt,
schützen sie aber vor missbräuchlicher Verwendung von Daten.
continue
Eine continue-Anweisung dient zum Fortsetzen des Kontrollflusses innerhalb von
Schleifen und tritt nie als alleinstehende Anweisung auf. Mehr darüber erfahren Sie
in den Abschnitten »for«, »while« und »do«.
C Standard Library
Die C Standard Library (auch C Runtime Library) ist eine standardisierte Sammlung
von Funktionen, symbolischen Konstanten, Makros und Datentypen. Die Elemente
der Library sind keine Elemente der Sprache C, aber sie sind mit C standardisiert und
in jeder C-Entwicklungsumgebung identisch verfügbar.
Die C Standard Library wird in Kapitel 10 auszugsweise diskutiert. Eine vollständige
Diskussion dieser Library würde den Rahmen dieses Buches sprengen.
Dateioperationen
Ein C-Programm kann Text oder Binärdaten aus einer Datei einlesen oder solche
Daten in einer Datei speichern. Dazu muss eine Datei zunächst mit der Funktion
fopen geöffnet werden. Beim Öffnen der Datei geben Sie den Dateinamen und den
Modus an, in dem Sie die Datei öffnen möchten.
Es gibt die folgenden Öffnungsmodi:
Modus
Bedeutung
Wenn die Datei
existiert
Wenn die Datei
nicht existiert
Schreib-/
Lesezeiger
r
öffnet die Datei
zum Lesen
Öffnen
Fehler
Dateianfang
w
öffnet die Datei
zum Schreiben
leere Datei
erzeugen
leere Datei
erzeugen
Dateianfang
a
öffnet die Datei
zum Anfügen
Datei öffnen
leere Datei
erzeugen
Dateiende
Tabelle 18.9 Öffnungsmodi für Dateien
603
18
18
Zusammenfassung und Ergänzung
Modus
Bedeutung
Wenn die Datei
existiert
Wenn die Datei
nicht existiert
Schreib-/
Lesezeiger
r+
öffnet die Datei
zum Lesen und
Schreiben
Datei öffnen
Fehler
Dateianfang
w+
öffnet die Datei
zum Lesen und
Schreiben
leere Datei
erzeugen
leere Datei
erzeugen
Dateianfang
a+
öffnet die Datei
zum Anfügen
mit Lese- und
Schreibzugriff
Datei öffnen
leere Datei
erzeugen
Dateiende
wx
öffnet die Datei
zum Schreiben
leere Datei
erzeugen
Fehler
Dateianfang
w+x
öffnet die Datei
zum Lesen und
Schreiben
leere Datei
erzeugen
Fehler
Dateianfang
Tabelle 18.9 Öffnungsmodi für Dateien (Forts.)
Der Modus wird der Funktion fopen als String (z. B. "r+") übergeben. Beim Öffnen
einer Datei wird, je nach Modus, ein Schreib-/Lesezeiger positioniert. Dieser Zeiger
legt fest, an welcher Position der nächste Schreib-/Lesezugriff erfolgt. Bei einer
Schreib-/Leseoperation rückt der Zeiger dann entsprechend voran. Die Position dieses Zeigers kann aber auch abgefragt (ftell) und explizit gesetzt (fseek) werden.
Beim Schreiben werden die Daten nicht eingefügt, sondern gegebenenfalls bestehende Daten werden überschrieben. Überschreiten Sie mit einer Schreiboperation
das Dateiende, wird die Datei automatisch vergrößert. Versuchen Sie, mit einer Leseoperation das Dateiende zu überschreiten, erhalten Sie die Information EOF (End of
File). Im Anfügemodus wird immer am Dateiende gearbeitet, egal, wie der Schreib-/
Lesezeiger positioniert wird. Unter Windows gibt es zusätzlich die Möglichkeit, eine
Datei im Binärmodus (Zusatz b im Modus) zu öffnen. Dies bewirkt, dass man die Originaldaten in der Datei liest und schreibt und keine automatische Übersetzung von
CR-LF in LF2 und umgekehrt erfolgt.
Beim erfolgreichen Öffnen einer Datei erhalten Sie einen Zeiger vom Typ FILE. Über
diesen Zeiger können Sie dann mit einer Reihe von Funktionen auf die Datei zugreifen und z. B. Daten lesen oder schreiben. Wird der Zugriff auf eine Datei nicht weiter
2 Windows hat etwa im Vergleich zu Unix andere Konventionen zur Markierung des Zeilenendes
in Textdateien.
604
Dateioperationen
benötigt, sollte sie mit fclose geschlossen werden. Im folgenden Beispiel werden
zehn Zeilen in eine Datei geschrieben, anschließend wieder ausgelesen und auf dem
Bildschirm angezeigt:
FILE *meinedatei;
int i;
char c;
Datei erstellen
E Öffnen (Modus w)
E Test auf Erfolg
E Daten schreiben
E Schließen
Datei einlesen
E Öffnen (Modus r)
E Test auf Erfolg
E Daten lesen
E Test auf Dateiende
E Schließen
meinedatei = fopen( "test.txt", "w");
if( !meinedatei)
return;
for( i = 0; i < 10; i++)
fprintf( meinedatei, "%c: %d\n", 'a'+ i, i);
fclose( meinedatei);
meinedatei = fopen( "test.txt", "r");
if( !meinedatei)
return;
for( ; ; )
{
fscanf( meinedatei, "%c: %d\n", &c, &i);
if( feof( meinedatei))
break;
printf( "%c: %d\n", c, i);
}
fclose( meinedatei);
a:
b:
c:
d:
e:
f:
g:
h:
i:
0
1
2
3
4
5
6
7
8
Abbildung 18.20 Öffnen, Auslesen und Ausgeben einer Datei
Die formatierte Eingabe und Ausgabe für Dateien entspricht den Funktionen scanf
und printf für die Bildschirmein- und -ausgabe. Sie müssen lediglich beachten, dass
der Dateizeiger bei allen Funktionen als zusätzlicher Parameter übergeben werden
muss.
Hier finden Sie eine Auswahl wichtiger Funktionen für Dateioperationen:
Funktionsname
Beschreibung
fclose
Schließen einer zuvor mit fopen geöffneten Datei
feof
Test auf Dateiende (EOF)
fgetc
Lesen eines Zeichens aus einer Datei
fgets
Lesen eines Strings (ohne Leerzeichen) aus einer Datei
fopen
Öffnen einer Datei
fprintf
formatierte Ausgabe in eine Datei
Tabelle 18.10 Einige wichtige Funktionen für Dateioperationen
605
18
18
Zusammenfassung und Ergänzung
Funktionsname
Beschreibung
fputc
Ausgabe eines einzelnen Zeichens in eine Datei
fputs
Ausgabe eines Strings in eine Datei
fread
Lesen einer bestimmten Anzahl von Bytes aus einer Datei
fscanf
formatiertes Einlesen aus einer Datei
fseek
Position des Schreib-/Lesezeigers ermitteln
ftell
Position des Schreib-/Lesezeigers setzen
fwrite
Schreiben einer bestimmten Anzahl von Bytes in eine Datei
rewind
Rücksetzen des Schreib-/Lesezeigers auf den Dateianfang
Tabelle 18.10 Einige wichtige Funktionen für Dateioperationen (Forts.)
Geöffnete Dateien sind, wie auch Tastatur und Bildschirm, sogenannte Streams
(Ein-/Ausgabeströme). Weitere Informationen dazu erhalten Sie im Abschnitt über
Streams. C kennt nur den Dateityp Stream, der im Prinzip einer nicht weiter strukturierten Zeichenkette entspricht. Komplexere Dateitypen (z. B. indexsequenzielle
Dateien) sind im Standardumfang von C nicht verfügbar.
Datentypen für ganze Zahlen
Für ganze Zahlen gibt es die Typen char, short, int, long und long long. Diese Typen
können jeweils vorzeichenlos (unsigned) oder vorzeichenbehaftet (signed) sein.
Damit ergeben sich folgende Möglichkeiten zur Festlegung eines Datentyps für
ganze Zahlen:
Syntaxgraph
char
signed
short
int
unsigned
long
long
Abbildung 18.21 Datentypdiagramm für ganze Zahlen
Der Datentyp char (bzw. unsigned char) wird auch für Zeichen (besser Zeichencodes)
verwendet.
606
Datentypen für Gleitkommazahlen
Die verschiedenen Typen unterscheiden sich hinsichtlich ihres Platzbedarfs im
Speicher:
Datentyp
Mindestgröße
Typische Größe
char
1 Byte
1 Byte
short
2 Bytes
2 Bytes
int
2 Bytes
4 Bytes
long
4 Bytes
4 Bytes
long long
8 Bytes
8 Bytes
Tabelle 18.11 Speicherbedarf der Datentypen
und folglich auch hinsichtlich des Zahlenbereichs, den sie abdecken:
signed
unsigned
Größe
min
max
min
max
1 Byte
–128
127
0
255
2 Bytes
–32768
32767
0
65535
4 Bytes
–2147483648
2147483647
0
4294967295
8 Bytes
–9,2234E+18
9,2234E+18
0
1,84467E+19
18
Tabelle 18.12 Zahlenbereiche der Datentypen
Der am häufigsten verwendete Ganzahldatentyp ist int bzw. unsigned int. Dies ist
der Datentyp, mit dem das Zielsystem am effizientesten rechnen kann. Dieser Datentyp wird immer dort verwendet, wo es einfach nur darum geht, ganzzahlig zu
rechnen.
Datentypen für Gleitkommazahlen
Für Gleitkommazahlen gibt es die Datentypen float, double und long double.
Diese Typen unterscheiden sich hinsichtlich ihres Speicherplatzbedarfs, hinsichtlich
des Zahlenbereichs, den sie abdecken, und hinsichtlich der Genauigkeit, mit der sie
ihren Zahlenbereich abdecken.
607
18
Zusammenfassung und Ergänzung
Grundsätzlich kann man sagen, dass double einen größeren Bereich präziser abdeckt
als float und long double einen größeren Bereich präziser abdeckt als double.
Speicherbedarf, Abdeckung und Präzision sind aber maschinenabhängig, sodass ich
hier keine allgemeingültigen Angaben machen kann. Auch die interne Darstellung
von Gleitkommazahlen wird hier nicht diskutiert.
Datentypen für Zeichen
Für einzelne Zeichen wird der ASCII-Code des Zeichens in einem Datum des Typs char
bzw. unsigned char gespeichert. Da es sich bei dem Zeichencode um eine Zahl handelt, kann mit Zeichen gerechnet werden wie mit kleinen ganzen Zahlen. Rechnerintern besteht kein Unterschied zwischen einem Zeichen und einer Zahl.
Mehr darüber erfahren Sie in den Abschnitten über Datentypen für ganze Zahlen
bzw. ASCII-Zeichencode.
Datentypen (allgemein)
Die einfachsten Datentypen sind die Datentypen für:
왘
ganze Zahlen
왘
Gleitkommazahlen
왘
Zeichen
Aus diesen Grundtypen kann man durch Aggregation komplexere Typen zusammensetzen. Aggregationen sind:
왘
Array
왘
Struct
왘
Union
Bei der Aggregation sind beliebige Schachtelungen möglich, z. B.:
왘
Array in Array, Struct oder Union
왘
Struct in Array, Struct oder Union
왘
Union in Array, Struct oder Union
Durch Zeiger können beliebige Querverweise zwischen Datenstrukturen hergestellt
werden. Über solche Querverweise können dann »verkettete« Datenstrukturen wie
Listen oder Bäume aufgebaut werden.
Zu allen kursiv gedruckten Begriffen dieses Kapitels finden Sie weiterführende Informationen in den entsprechenden Abschnitten dieser Zusammenfassung.
608
Datenzugriff
Datenzugriff
Wir unterscheiden drei Arten des Datenzugriffs:
왘
den direkten Zugriff
왘
den indizierten Zugriff
왘
den indirekten Zugriff
Den Direktzugriff verwenden Sie, wenn ein Datum über eine Variable unmittelbar
gegeben ist.
Handelt es sich um einen der Grunddatentypen (int, float, ...), verwenden Sie den
Variablennamen zum Zugriff:
int var1;
float var2;
A
var1 = 4711;
var2 = 1.234 + 5*(var1 + 3);
Im angegebenen Beispiel erfolgt ein Zugriff auf die durch var1 bzw. var2 gegebenen
Daten (A).
Auch bei zusammengesetzten Datentypen können Sie auf diese Weise auf die Datenstruktur als Ganzes zugreifen. Zusätzlich verwenden Sie den Punkt-Operator, um
innerhalb der Struktur gezielt Teilinformationen anzusprechen:
struct typ1
{
int a;
float b;
};
struct typ2
{
int c;
float d;
struct typ1 e;
};
struct typ1 var1;
struct typ2 var2;
A
A
var1.a = 123;
var1.b = var1.a + 3.14;
609
18
18
Zusammenfassung und Ergänzung
A
A
A
A
var2.c =
var2.d =
var2.e =
var2.e.b
2*var1.a;
var1.b + var2.c;
var1;
= var2.e.b + 1;
Auch hier erfolgt der Zugriff auf die durch var1 bzw. var2 gegebenen Daten (A).
Auf die einzelnen Elemente eines ein- oder mehrdimensionalen Arrays wird indiziert
mit dem []-Operator zugegriffen:
int var1[10];
float var2[5][7];
var1[8] = 1.23;
var2[1][2] = var1[8]+7;
var2[2][1] = 2*(var2[1][2] + var1[8]);
Haben Sie einen Zeiger auf ein Datum, können Sie durch Dereferenzierung mit dem
*-Operator auf das Datum zugreifen (Indirektzugriff):
int var;
int *ptr;
ptr = &var;
*ptr = 123;
Über den Zeiger ptr wird der Wert der Variablen var verändert.
Handelt es sich bei dem referenzierten Datum um eine Struktur (struct oder union),
können Sie mit dem Pfeil-Operator (->) auf die einzelnen Felder zugreifen, ohne
zuvor explizit dereferenzieren zu müssen:
struct typ
{
int a;
float b;
};
struct typ var;
struct typ *ptr;
ptr = &var;
610
Deklarationen und Definitionen
ptr->a = 123;
ptr->b = ptr->a + 1.234;
Auch hier wird über den Zeiger ptr der Inhalt der Variablen var verändert.
Die hier einzeln dargestellten Zugriffstechniken können natürlich auch in Kombination auftreten:
struct typ1
{
int a;
float b[20];
};
struct typ2
{
struct typ1 daten[10][5];
};
struct typ2 var;
struct typ2 *ptr;
ptr = &var;
18
ptr->daten[1][2].a = 123;
ptr->daten[2][3].b[8] = 2*ptr->daten[1][2].a + 123;
default
Eine default-Anweisung dient zur Behandlung von Standardfällen in Sprungleisten.
Weitere Informationen dazu finden Sie im Abschnitt über switch.
Deklarationen und Definitionen
Wenn in C ein Objekt (Datum oder Funktion) angelegt wird, sprechen wir von einer
Definition.
Im folgenden Codefragment werden eine Variable x und eine Funktion test
definiert:
611
18
Zusammenfassung und Ergänzung
int x;
void test( int a)
{
...
}
Wenn ein Objekt definiert wird, entsteht durch den Compiler »raumgreifender«
Code im lauffähigen Programm. Definitionen erzeugen also Codesubstanz, die zur
Laufzeit im Code lokalisiert werden kann.
Wenn dagegen über die Existenz eines Objekts (Variable oder Funktion) informiert
wird, spricht man von einer Deklaration. Eine Definition ist in diesem Sinne immer
auch eine Deklaration, da mit der Definition auch immer eine Information über die
Existenz verbunden ist. Es gibt aber auch Deklarationen, die nicht zugleich Definition sind. Diese beginnen mit dem Schlüsselwort extern.
Im folgenden Codefragment werden eine Variable x und eine Funktion test
deklariert:
extern int x;
extern void test( int a);
Wenn der Quellcode eines Programms auf mehrere Dateien verteilt ist, passiert es
zwangsläufig, dass man in einer Datei eine Variable oder Funktion nutzen will, die in
einer anderen Datei definiert ist. Wenn eine Datei kompiliert wird, benötigt der Compiler Informationen über die korrekte Verwendung auch anderweitig definierter
Objekte. Er benötigt den Typ anderweitig definierter Variablen und die Schnittstelle
anderweitig definierter Funktionen. Genau diese Informationen stellt eine Variablen- oder Funktionsdeklaration zur Verfügung.
Die Deklaration extern int x bedeutet also, dass es irgendwo eine int-Variable mit
dem Namen x gibt. Analog bedeutet die Deklaration extern void test(int a), dass es
irgendwo eine void-Funktion mit dem Namen test gibt, die einen int-Parameter hat.
Der Parametername (hier a) dient ja zum Zugriff auf den Parameter aus der Funktion
und kann bei der Deklaration auch weggelassen werden:
extern void test( int);
Üblicherweise verwendet man aber die Parameternamen auch in Deklarationen, da
sie oft hilfreich zum Verständnis einer Funktionsschnittstelle sind.
extern void copy( char *destination, char *source);
Deklarationen bezeichnet man auch als Vorwärtsverweise Funktionsdeklarationen
auch als Funktionsprototypen.
612
do
Obwohl Deklarationen überall im Code stehen können, stehen sie typischerweise in
Header-Dateien, da sie dann einheitlich von einer zentralen Stelle aus überall genutzt
werden können.
Dezimaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
do
Bei do ... while handelt es sich um ein Schleifenkonstrukt, das im Gegensatz zu for
keine Initialisierung und kein Inkrement hat und bei dem der Test auf Fortsetzung
am Ende eines jeden Durchlaufs durchgeführt wird:
int i = 1;
do
Schleife ohne Initialisierung
und Inkrement
{
printf( "%d\n", i);
i++;
} while( i < 10);
1
2
3
4
5
6
7
8
9
18
Prüfung auf Fortsetzung am
Ende des Schleifenkörpers
Abbildung 18.22 Schleifenablauf bei der do-Schleife
Beachten Sie den wesentlichen Unterschied zwischen for und do. Bei for wird der
Test vor jedem Eintritt, also auch vor dem ersten Eintritt in den Schleifenkörper,
durchgeführt. Bei do hingegen wird der Test nach jedem Verlassen des Schleifenkörpers und vor dem möglichen Wiedereintritt in den Schleifenkörper ausgeführt. Das
führt dazu, dass eine do-Schleife auf jeden Fall mindestens einmal durchlaufen wird
(siehe Abbildung 18.23).
Bei for spricht man auch von einer kopfgesteuerten, bei do von einer fußgesteuerten
Schleife.
Aus dem Schleifenkörper kann die do...while Schleife genauso wie die for-Schleife
durch break bzw. continue gesteuert werden. Mit break wird die Schleife sofort abgebrochen, während mit continue nur ein einzelner Schleifendurchlauf abgebrochen
und die Schleife über den Test fortgesetzt wird.
613
18
Zusammenfassung und Ergänzung
Schleife wird keinmal durchlaufen
int i = 1;
int i = 1;
do
for( ; i < 0; )
{
printf( "%d\n", i);
i++;
}
{
printf( "%d\n", i);
i++;
} while( i < 0);
1
Schleife wird einmal durchlaufen
Abbildung 18.23 Gegenüberstellung von fuß- und kopfgesteuerter Schleife
double
Bei double handelt es sich um einen Datentyp für Gleitkommazahlen »doppelter«
Genauigkeit. Wie viele Bytes der Datentyp double im Speicher belegt und welchen
Zahlenbereich er genau abdeckt, kann systemspezifisch unterschiedlich sein.
Mehr darüber erfahren Sie im Abschnitt »Datentypen für Gleitkommazahlen«.
Eingabe
In der C Runtime Library gibt es eine Reihe von Funktionen zur Tastatureingabe. Die
wichtigste dieser Funktionen ist scanf, die zur formatierten Eingabe von Zeichen,
Zahlen und Text dient. Die scanf-Funktion hat eine variable Anzahl von Parametern.
Festgelegt ist dabei nur der erste Parameter, der einen Formatstring enthält. Dieser
String enthält wiederum sogenannte Formatanweisungen. Jede Formatanweisung
korrespondiert mit einem Parameter, der die Variable referenziert, in die der einzulesende Wert kopiert werden soll. Die Parameter der scanf-Funktion sind also Zeiger.
Die Formatanweisung legt fest, in welchem Format der einzugebende Wert erwartet
wird. Die Formatanweisungen sind genauso aufgebaut wie bei der Ausgabe mit
printf (siehe Abschnitt »Ausgabe«).
Wichtig ist, dass die Eingabe exakt so erfolgen muss, wie es in den Formatanweisungen festgelegt ist. Die Anweisung
int a, b;
scanf( "(%x,%x)", &a, &b);
614
enum (Aufzählungstypen)
erwartet z. B. die Eingabe von zwei Ganzzahlen in Hexadezimalschreibweise durch
ein Komma getrennt und in Klammern eingeschlossen. Zusätzliche Leerzeichen sind
dabei möglich. Eine gültige Eingabe wäre in diesem Fall:(1a, b2). Solche Eingabeaufforderungen findet man allerdings sehr selten, da sie sehr restriktiv sind. In der Regel
stehen im Formatstring nur die Formatanweisungen und kein zusätzlicher Text. Bei
einer Eingabe aus einer Datei mit fscanf kann es aber sehr sinnvoll sein, in der Formatanweisung zusätzlichen Text zu verwenden, um strukturierende Zeichen wie
Komma, Semikolon oder Doppelpunkt bei der Eingabe zu filtern. Wichtig ist, dass die
Eingabefunktion aus dem Eingabestrom immer so viele Zeichen liest, wie sie benötigt, um alle Parameter entsprechend der Formatanweisungen zu sättigen. Fehlen
noch Zeichen, wird die Eingabe noch nicht abgeschlossen. Bleibt Text, den der Benutzer zu viel eingegeben hat, übrig, wird dieser bei der nächsten Eingabe gelesen. Dies
führt manchmal zu einem unerwarteten Verhalten, das dann als Programmfehler
interpretiert wird, obwohl die Eingabefunktion nur konsequent der Formatvorgabe
folgt.
Die Funktion scanf stellt ein potenzielles Sicherheitsrisiko dar. Sie erhält einen Zeiger
als Parameter und überschreibt dann den durch den Zeiger referenzierten Speicherbereich mit den Eingaben, ohne zu prüfen, ob der bereitgestellte Speicherbereich
ausreichend groß für die Eingabe ist. Schadprogramme versuchen, durch spezielle
Eingaben einen Überlauf (Buffer Overflow) zu provozieren, bei dem dann schädlicher
Code im Hauptspeicher platziert wird.
18
else
Mit else formulieren Sie eine Alternative innerhalb einer Fallunterscheidung mit if.
Die Anweisung else tritt nie als eigenständige Anweisung auf. Schauen Sie sich daher
auch den Abschnitt zu if an.
enum (Aufzählungstypen)
Wenn Sie »sprechende« Namen für Zahlenwerte benutzen möchten, verwenden Sie
den Aufzählungstyp enum. Ein Aufzählungstyp ist ein Datentyp, bei dem der Programmierer selbst geeignete Bezeichnungen für einzelne Werte vergeben kann.
Einen Aufzählungstyp führen Sie z. B. wie folgt ein:
enum wochentag { Montag, Dienstag, Mittwoch, Donnerstag, Freitag,
Samstag, Sonntag};
615
18
Zusammenfassung und Ergänzung
Einmal in dieser Weise deklariert, ist wochentag ein Datentyp wie int, der die symbolischen Werte Montag bis Sonntag annehmen kann. Verwendet wird ein solcher Aufzählungstyp dann wie folgt:
enum wochentag geburtstag;
geburtstag = Freitag;
if( geburtstag == Sonntag)
{
// Ausschlafen
}
Intern wird ein Aufzählungstyp auf int abgebildet. Welche Zahlenwerte dabei zugeordnet werden, ist nicht festgelegt. Man kann jedoch eine bestimmte Festlegung
durch Angabe konkreter Werte erzwingen:
enum wochentag { Montag=1, Dienstag=2, Mittwoch=3, Donnerstag=4,
Freitag=5, Samstag=6, Sonntag=7};
Es wird nicht geprüft, ob Variablen von einem Aufzählungstyp wirklich nur die für
den Aufzählungstyp festgelegten Werte enthalten. Man kann solchen Variablen eine
beliebige ganze Zahl zuweisen und mit den Variablenwerten wie mit ganzen Zahlen
rechnen. Aufzählungstypen bieten insofern keine umfassenden Funktionen, sondern dienen nur einer besseren Lesbarkeit des Programmcodes.
Escape-Sequenzen
Escape-Sequenzen werden als Ersatzdarstellung für nicht druckbare Zeichen wie Tabulator oder Seitenvorschub verwendet. In C gibt es die folgenden Escape-Sequenzen:
Sequenz
Bedeutung
\a
Alarmton
\b
Rückschritt (Backspace)
\f
Seitenvorschub (Form Feed)
\n
Zeilenvorschub (Line Feed)
\r
Wagenrücklauf (Carriage Return)
\t
horizontaler Tabulator
Tabelle 18.13 Escape-Sequenzen
616
extern
Sequenz
Bedeutung
\v
vertikaler Tabulator
\'
einfaches Hochkomma
\"
Anführungszeichen
\?
Fragezeichen
\\
Backslash
Tabelle 18.13 Escape-Sequenzen (Forts.)
Beliebige Zeichen können auch durch Angabe ihres ASCII-Zeichencodes in Oktaloder Hexadezimaldarstellung beschrieben werden.
Bei einem Oktalcode schreibt man einfach eine ein- bis dreistellige Oktalzahl in die
Escape-Sequenz:
\101 ein A
\12 ein Zeilenvorschub
\134 ein Backslash
Einem ein- oder zweistelligem Hexadezimalcode wird ein x vorangestellt:
\x41 ein A
\xa ein Zeilenvorschub
\x5c ein Backslash
18
Escape-Sequenzen gibt es nur im Quellcode und nicht im ausführbaren Programm.
Sie werden durch den Compiler aufgelöst und durch den entsprechenden Binärcode
ersetzt. Insofern belegen sie im Rechner auch nur ein Byte und nicht die Anzahl an
Bytes, die die Sequenz im Quellcode hat.
In diesem Sinne liefert der Aufruf von
x = strlen( "\n");
den Wert x = 1, da die Zeichenkette "\n" nur ein einzelnes Zeichen, nämlich ein Line-
feed-Zeichen, enthält.
extern
Mit dem Schlüsselwort extern wird in C eine Deklaration eingeleitet. Mehr zu Deklarationen erfahren Sie unter dem Stichwort »Deklarationen und Definitionen«.
617
18
Zusammenfassung und Ergänzung
float
Bei float handelt es sich um einen Datentyp für Gleitkommazahlen einfacher
Genauigkeit.
Wie viele Bytes der Datentyp float im Speicher belegt und welchen Zahlenbereich er
genau abdeckt, kann systemspezifisch unterschiedlich sein.
Lesen Sie dazu auch den Abschnitt »Datentypen für Gleitkommazahlen«.
for
Bei einer for-Anweisung handelt es sich um die wesentliche Kontrollstruktur zur
Implementierung von Schleifen. Wir unterscheiden:
왘
Schleifenkopf
왘
Schleifenkörper
Der Schleifenkopf ist untergliedert in:
왘
Initialisierung
왘
Test
왘
Inkrement
Der Schleifenkörper wird durch geschweifte Klammern eingefasst, allerdings können
diese Klammern auch fehlen, wenn der Schleifenkörper nur aus einer einzigen
Anweisung besteht. Die Anweisungen im Schleifenkopf steuern die Schleife in der
folgenden Weise:
Der Test wird vor jedem möglichen Eintritt in den Schleifenkörper
ausgewertet. Ergibt sich dabei ein Wert ≠ 0, so wird der Schleifenkörper
ausgeführt. Andernfalls wird die Bearbeitung der Schleife abgebrochen.
Die Initialisierung wird vor dem Eintritt
in die Schleife einmal ausgeführt.
Das Inkrement wird immer nach dem Verlassen
und vor einem möglichen Wiedereintritt in den
Schleifenkörper ausgeführt.
for( ... ; ... ; ... )
{
...
Initialisierung, Test und Inkrement
...
bezeichnen wir als den Schleifenkopf.
if( ... )
continue;
...
Der Schleifenkörper wird bei jedem
...
Schleifendurchlauf ausgeführt. Besteht
if( ... )
der Schleifenkörper nur aus einer
break;
einzelnen Anweisung, so können die
...
geschweiften Klammern weggelassen
werden.
...
}
Abbildung 18.24 Elemente der for-Schleife
618
Bei einer continue-Anweisung wird
der derzeitige Schleifendurchlauf
abgebrochen, die Schleifenbearbeitung
insgesamt aber fortgesetzt.
Bei einer break-Anweisung wird die
Bearbeitung der Schleife abgebrochen.
Funktionsaufruf
Funktionen
Funktionen sind das wesentliche Modularisierungskonzept in jeder Programmiersprache.
Eine Funktion hat eine Schnittstelle und eine Implementierung. Durch die Schnittstelle wird festgelegt, wie die Funktion heißt, welche Daten in die Funktion hineingehen und welche Daten aus ihr herauskommen. In der Implementierung wird
festgelegt, wie die Daten verarbeitet werden.
Funktionsdefinition
Funktionsschnittstelle
char meinefunktion( int x, float y)
{
// Funktionscode
}
Funktionsimplementierung
Abbildung 18.25 Schnittstelle und Implementierung einer Funktion
Weitere Informationen zu Funktionen finden Sie unter den Begriffen Funktionsaufruf, Funktionsprototyp, Funktionsschnittstelle und Funktionsimplementierung.
Funktionsaufruf
18
Um eine Funktion aufrufen zu können, muss man ihre Schnittstelle kennen. Beim
Aufruf muss man die Daten übergeben, die an der Schnittstelle verlangt sind. Eine
Funktion mit der Schnittstelle
char meinefunktion( int, float);
erwartet beim Aufruf einen int- und einen float-Wert und gibt nach Erledigung
ihrer Aufgabe einen char-Wert zurück. Sie kann also in der folgenden Weise aufgerufen werden:
int a = 1;
float b = 2.3;
char c;
c = meinefunktion( a, b);
Wichtig ist, dass die beim Aufruf verwendeten Datentypen den in der Schnittstelle
geforderten Datentypen entsprechen oder automatisch in diese konvertiert werden
können.
619
18
Zusammenfassung und Ergänzung
Beim Aufruf werden Kopien der übergebenen Parameter erzeugt und an die Funktion übergeben. Nach Übergabe der Daten besteht keine Kopplung mehr zwischen
den Daten des rufenden Programms und den Daten, auf denen die Funktion arbeitet.
Möchten Sie einer Funktion die Möglichkeit geben, auf ausgewählten Daten des
rufenden Programms zu arbeiten, muss müssen Sie mit Zeigern (siehe Abschnitt
»Zeiger«) arbeiten.
Das Funktionsergebnis muss nicht entgegengenommen und zugewiesen werden.
Wenn Sie am zurückgegebenen Funktionswert nicht interessiert sind, können Sie die
oben dargestellte Funktion auch in der folgenden Weise aufrufen:
meinefunktion( a, b);
Sie können den zurückgegebenen Funktionswert aber auch direkt in einer Formel
oder als Parameter für einen weiteren Funktionsaufruf verwenden:
printf( "%c", meinefunktion( a, b)+1);
Funktionsimplementierung
Die Funktionsimplementierung folgt in der Funktionsdefinition direkt auf die Funktionsschnittstelle und ist in geschweifte Klammern eingeschlossen. In der Funktionsimplementierung wird ausprogrammiert, wie die an der Schnittstelle übergebenen Daten zu verarbeiten sind, um die vereinbarten Rückgabewerte zu berechnen.
Funktionsdefinition
Funktionsschnittstelle
char meinefunktion( int x, float y)
{
// Funktionscode
}
Funktionsimplementierung
Abbildung 18.26 Schnittstelle und Implementierung einer Funktion
Die Funktion greift über die Parameternamen auf die übergebenen Daten zu. Dabei
handelt es sich um Kopien der vom rufenden Programm übergebenen Daten, sodass
eine Änderung der Werte keine Auswirkungen auf die Daten im Hauptprogramm
hat. Auch eine zufällige Namensgleichheit von Funktionsparametern und Variablen
im Hauptprogramm ändert daran nichts.
In der Funktionsimplementierung können alle Daten- und Kontrollstrukturen verwendet werden. Eine besondere Bedeutung hat dabei die return-Anweisung, die
unter einem eigenen Stichwort behandelt wird.
620
Funktionsschnittstelle
Funktionen können andere Funktionen, aber auch sich selbst mittelbar oder unmittelbar aufrufen. Letzteres bezeichnet man als Rekursion. Rekursion ist ein wichtiges
Programmiermittel, das ebenfalls unter einem eigenen Stichwort behandelt wird.
Funktionsprototyp
Ein Funktionsprototyp ist die »Bekanntgabe« einer Funktionsschnittstelle. Möchten
Sie etwa die Existenz einer Funktion mit dem Namen meinefunktion, die einen intund einen float-Wert erhält und ein einzelnes Zeichen (char) zurückgibt, bekannt
geben, schreiben Sie:
extern char meinefunktion( int, float);
Häufig fügt man hier noch die Namen hinzu, über die die Funktion auf die Parameter
zugreift:
extern char meinefunktion( int anzahl, float wert);
Die Namen dienen der Beschreibung der Funktion. Sie sind in einem Funktionsprototyp aber weder notwendig, noch müssen sie mit den Namen übereinstimmen, die
in der Implementierung der Funktion tatsächlich zum Zugriff verwendet werden.
Einen Funktionsprototyp finden Sie vorrangig in einer Header-Datei, die dann von
allen Quellcodedateien inkludiert werden sollte, in denen die Funktion verwendet
wird. Der Compiler kann dann beim Übersetzen der Quellcodedatei prüfen, ob der
Aufruf der Funktion in der Quellcodedatei konform zum Funktionsprototyp in der
Header-Datei ist.
Funktionsschnittstelle
Eine Funktionsschnittstelle ist die formale Beschreibung aller in eine Funktion eingehenden und aus der Funktion herauskommenden Datentypen. Die Schnittstelle
umfasst den Namen der Funktion, den Typ und die Reihenfolge der Parameter und
den Rückgabetyp. Die Namen, über die auf die Funktion bzw. auf die Parameter der
Funktion zugegriffen wird, gehören im engeren Sinn nicht zur Schnittstelle.
Eine Funktion, die einen String und ein Zeichen übergeben bekommt und berechnet,
wie oft das Zeichen in dem String vorkommt, hat die folgende Schnittstelle:
erster Parametertyp: char * (der String)
zweiter Parametertyp: char (das Zeichen)
Rückgabetyp: int (die Anzahl der Vorkommnisse)
621
18
18
Zusammenfassung und Ergänzung
Wenn man die Funktion zaehlezeichen, den eingehenden String s und das eingehende Zeichen c nennt, ergibt sich die folgende Funktionsdeklaration, die insbesondere die Schnittstelle festlegt:
int zaehlezeichen( char *s, char c);
Als Datentypen sind alle verfügbaren Grunddatentypen (int, float, ...), aber auch
Zeiger, Arrays und Datenstrukturen möglich.
Funktionszeiger
Funktionen haben eine Adresse. Diese Adresse kann einer Variablen zugewiesen werden. Eine Variable, die die Adresse einer Funktion enthält, wird als Funktionszeiger
bezeichnet.
Damit ein Funktionszeiger konsistent verwendet werden kann, wird bei der Definition des Zeigers die Schnittstelle der zu referenzierenden Funktion angegeben:
A
int (*fz1)();
B
void (*fz2)(int);
C
float (*fz3)( char*, int);
fz1 ist ein Zeiger auf eine parameterlose Funktion, die einen int-Wert zurückgibt (A).
fz2 ist ein Zeiger auf eine Funktion, die einen int-Wert erhält und keinen Wert
zurückgibt (B).
fz3 ist ein Zeiger auf eine Funktion, die einen Pointer auf char und einen int-Wert
erhält und einen float-Wert zurückgibt (C).
Einem Funktionszeiger kann die Adresse einer beliebigen Funktion zugewiesen werden, deren Schnittstelle mit der bei der Definition des Zeigers angegebenen Schnittstelle übereinstimmt. Über den Funktionszeiger kann die referenzierte Funktion
aufgerufen werden (siehe Abbildung 18.27).
Streng formal müsste es in dem in der Abbildung dargestellten Beispiel
funktionszeiger = &potenz
und
z = (*funktionszeiger)( 1.2, 2)
heißen, aber Adress- und Dereferenzierungsoperator können weggelassen werden,
da für den Compiler aus dem Zusammenhang eindeutig klar ist, welche Operationen
hier gemeint sind.
622
Funktionszeiger
float potenz( float x, int n)
{
float y = 1;
for( ; n; n--)
y *= x;
return y;
}
eine Funktion, die y = xn berechnet
Schnittstelle:
float potenz( float, int)
ein Zeiger auf eine beliebige
Funktion mit der Schnittstelle:
float...( float, int)
void main()
{
float (*funktionszeiger)( float, int);
float z;
Dem Zeiger wird die
funktionszeiger = potenz;
Funktionsadresse zugewiesen.
z = funktionszeiger( 1.2, 2);
}
Die Funktion wird über den
Funktionszeiger aufgerufen.
Funktionsergebnis: z = 1.44
Abbildung 18.27 Verwendung eines Funktionszeigers
Besonders häufig werden Funktionszeiger als Funktionsparameter verwendet, um
einer Funktion mitzuteilen, dass sie eine andere Funktion rufen soll. Man nennt dies
einen Callback, und die als Parameter übergebene Funktion bezeichnet man als Callback-Funktion.
float potenz( float x, int n)
{
float y = 1;
for( ; n; n--)
y *= x;
return y;
}
eine Funktion, die y = xn berechnet
18
Schnittstelle:
float potenz( float, int)
void berechne( float *dat, int anz, float x, float fkt( float, int))
{
eine Funktion, die zu einer Funktion
int i;
fkt eine Funktionstabelle erstellt
for( i = 0; i < anz; i++)
dat[i] = fkt( x, i);
}
void main()
{
float daten[10];
int i;
berechne( daten, 10, 1.2, potenz);
for( i = 0; i < 10; i++)
printf( "%d: %f\n", i, daten[i]);
}
Es werden die Werte
fkt(x,0)
fkt(x, 1)
…
fkt( x, anz-1)
in den Array dat eingetragen.
Berechnen der Funktionstabelle für die
Funktion potenz.
0:
1:
2:
3:
4:
5:
6:
7:
8:
9:
1.000000
1.200000
1.440000
1.728000
2.073600
2.488320
2.985985
3.583182
4.299818
5.159782
Abbildung 18.28 Verwendung eines Funktionszeigers für eine Callback-Funktion
623
18
Zusammenfassung und Ergänzung
Ganze Zahlen
Bei der Darstellung ganzer Zahlen unterscheiden wir die Dezimal-, Hexadezimal- und
Oktaldarstellung.
왘
Die Dezimaldarstellung beginnt mit einer Ziffer von 1 bis 9, optional gefolgt von
weiteren Ziffern zwischen 0 und 9.
왘
Die Oktaldarstellung beginnt mit 0, optional gefolgt von weiteren Ziffern zwischen 0 und 7.
왘
Die Hexadezimaldarstellung beginnt mit 0x, gefolgt von weiteren Ziffern zwischen 0 und 9 sowie zwischen a und f bzw. A und F.
In allen drei Systemen kann als Vorzeichen + oder – vorangestellt werden, obwohl ein
negatives Vorzeichen in der Hexadezimal- und Oktaldarstellung unüblich ist.
An die Zahlen kann ein Suffix (l, L, ll, LL, Ll und lL) angefügt werden. Dieses Suffix
zeigt an, dass es sich um long- bzw. long long-Zahlen handelt.
An die Zahlen kann ein weiteres Suffix (u, U) angefügt werden. Dieses Suffix zeigt an,
dass es sich um eine vorzeichenlose Zahl (unsigned) handelt. Dieses Suffix ist natürlich, obwohl von vielen Compilern akzeptiert, mit dem negativen Vorzeichen unvereinbar.
Die Suffixe für Größe und Vorzeichenlosigkeit können in beliebiger Reihenfolge
angehängt werden.
Damit ergibt sich ein breites Spektrum möglicher Darstellungen. Zum Beispiel:
123
–123
0123
0xAffe
123L
–123UL
0123lu
0x1ae7LL
0xEdel
Abbildung 18.29 zeigt einige Möglichkeiten, ganze Zahlen zu bilden.
Die hier vorgestellten Darstellungen sind rein äußerliche Darstellungen im Quellcode. Im Rechner selbst gibt es nur die Binärdarstellung.
624
Gleitkommazahlen
Ganze Zahl
Okt
0-7
0
Vorzeichen
Suffix
Dez
0-9
+
l,ll,lL,Ll,LL
u,U
u,U
l,ll,lL,Ll,LL
1-9
0-9,a-f,A-f
0x
Hex
Abbildung 18.29 Alle Möglichkeiten, ganze Zahlen zu bilden
Gleitkommazahlen
Gleitkommazahlen gibt es nur in Dezimaldarstellung. Diese Darstellung entspricht
der bekannten Darstellung für wissenschaftliche Taschenrechner. Deshalb nur ein
paar Beispiele:
18
1.23
0.123
.123
–1.234
-.123
1.2345e6
–12.34E-12
1e23
Gleitkommazahlen können ein Suffix haben. Möglich sind hier f bzw. F und l bzw. L.
Damit zeigen Sie an, dass es sich um eine float- (f, F) bzw. long double-Zahl (l, L) handelt. Geben Sie kein Suffix an, ist die Zahl vom Typ double:
1.23f
0.123l
–12.34E-12F
1e23L
Die hier vorgestellten Darstellungen sind rein äußerliche Darstellungen im Quellcode. Im Rechner selbst gibt es nur die Binärdarstellung.
625
18
Zusammenfassung und Ergänzung
goto
Mit goto können Sprünge innerhalb eines Programms realisiert werden. Dazu definieren Sie zunächst ein Label, das später angesprungen werden kann:
int i = 1;
printf( "Anfang\n");
weiter:
printf( "%d\n", i);
i++;
if( i < 10)
goto weiter;
printf( "Ende\n");
Anfang
1
2
3
4
5
6
7
8
9
Ende
Hier wird ein Label definiert. Ein Label
dient als mögliches Sprungziel für eine
goto-Anweisung
Kommt die goto-Anweisung zur
Ausführung, wird zum angegebenen
Label gesprungen.
Abbildung 18.30 Verwendung der goto-Anweisung
Das Beispiel zeigt, dass man mit Sprunganweisungen problemlos eine Schleife nachbilden kann. Das geht allerdings zu Lasten der Lesbarkeit des Programms. Sprunganweisungen werden daher in höheren Programmiersprachen meist nur unter großen
Vorbehalten verwendet, da sie einen unübersichtlichen Kontrollfluss (sogenannten
Spaghetti-Code) erzeugen können3 und die Gefahr, besteht, dass man im Gestrüpp
des Kontrollflusses die Orientierung verliert. Darum wird allgemein davon abgeraten, goto zu verwenden.
Normalerweise sollten Sprunganweisungen in einem C-Programm nicht vorkommen, zumal sie prinzipiell vermeidbar sind. Ein Sprung kann sinnvoll sein, um bei
einer massiven Abbruchbedingung aus einer tief verschachtelten Schleifenstruktur
auszusteigen, da ein break in einer solchen Situation immer nur die innerste Schleife
beendet. Dabei besteht aber schon die Gefahr, dass wichtige Aufräumarbeiten am
jeweiligen Schleifenende übergangen werden.
Sprünge können vorwärts und rückwärts gerichtet sein, und ein Label kann von
unterschiedlichen Stellen aus angesprungen werden. Sprünge können aber immer
nur auf einer Funktionsebene durchgeführt werden. Das heißt, es ist nicht möglich,
aus einer Funktion zu einem Label in einer anderen Funktion zu springen, auch nicht
aus einer gerufenen Funktion zurück in die aufrufende Funktion.
3 Googlen Sie dazu den Artikel »Go-to statement considered harmful« von E. W. Dijkstra.
626
Header-Datei
Bei der Verwendung von goto sollten Sie sich strenge Selbstkontrollen auferlegen.
Versuchen Sie zunächst immer, goto zu vermeiden! Benutzen Sie goto nur, wenn es
keine sinnvolle Variante ohne goto gibt! Vermeiden Sie es auf jeden Fall, mit goto in
einen undefinierten Kontext (z. B. in einen Schleifenkörper) zu springen!
Hauptprogramm
Das Hauptprogramm ist der Einstiegspunkt in ein C-Programm. Hier startet der Kontrollfluss. Das Hauptprogramm wird mit main bezeichnet.
Mehr dazu erfahren Sie unter dem Stichwort »main«.
Hexadezimaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
Header-Datei
Ein C-Programm besteht aus Quellcodedateien und Header-Dateien. Header-Dateien
erkennen Sie an der Namenserweiterung .h. Eine Header-Datei enthält nur Elemente,
die nicht »raumgreifend« sind. Darunter werden Elemente verstanden, die vom
Compiler zur Erzeugung des Codes benötigt werden, aber nicht in konkreten Code
übersetzt werden. Ein typisches Beispiel für solche Elemente sind Funktionsprototypen, die vom Compiler benötigt werden, um zu überprüfen, ob Funktionsschnittstellen korrekt implementiert und verwendet werden.
Nicht »raumgreifende« Elemente sind:
왘
Präprozessor-Direktiven
– Includes
– Compile-Schalter
– symbolische Konstanten
– Makros
왘
Deklarationen
– Externverweise auf statische Variablen und Konstanten
– Funktionsprototypen
– Datenstrukturen und Typvereinbarungen
627
18
18
Zusammenfassung und Ergänzung
Typischerweise enthält eine Header-Datei nur Elemente, die in mehr als einer Quellcodedatei benötigt werden. Elemente, die nur in einer Quellcodedatei benötigt werden, stehen üblicherweise in dieser einen Quellcodedatei.
Bei der Verwendung von Funktionsbibliotheken spielen Header-Dateien eine entscheidende Rolle. Möchten Sie eine Funktion aus einer Funktionsbibliothek aufrufen, müssen Sie in der zugehörigen Dokumentation nachschlagen, welche HeaderDateien inkludiert werden müssen. Diese Includes stellen Sie dann an den Anfang
jeder Quellcodedatei, in der die Funktion aufgerufen wird.
Kontrollstrukturen
Zur Steuerung des Kontrollflusses gibt es in C:
왘
Fallunterscheidungen
왘
Schleifen
왘
die goto-Anweisung
Fallunterscheidungen können mit if, if-else oder switch realisiert werden:
if(...)
{
...
...
}
if(...)
{
...
...
}
else
{
...
...
}
switch(...)
{
case ...:
...
break;
case ...:
case ...:
...
break;
default:
...
break;
}
Abbildung 18.31 Die Struktur von if, if-else und switch
Es gibt drei verschiedene Schleifenkonstrukte (for, while und do-while), von denen
for sicherlich das wichtigste ist (siehe Abbildung 18.32).
Darüber hinaus gibt es die goto-Anweisung, mit der man einen beliebig komplexen
Kontrollfluss modellieren kann, die aber zu Recht in der strukturierten Programmierung nur in Ausnahmefällen verwendet wird (siehe Abbildung 18.33).
628
Identifier
for (...;...;...)
{
...
if (...)
break;
...
if (...)
continue;
...
}
while (...)
{
...
if(...)
break;
...
if(...)
continue;
...
}
do
{
...
if(...)
break;
...
if(...)
continue;
...
} while(...);
Abbildung 18.32 Die Struktur von for, while und do-while
label:
...
...
...
if(...)
goto label;
Abbildung 18.33 Die Struktur von goto
Zu den Kontrollstrukturen finden Sie weitere Hinweise unter den Stichworten if,
switch, for, do, while und goto.
18
Identifier
Identifier verwendet der Programmierer, um etwas eindeutig zu benennen, damit er
später, gegebenenfalls in einem anderen Zusammenhang, darauf Bezug nehmen
kann. Im Einzelnen handelt es sich um:
왘
Variablennamen
왘
Namen für Funktionen und Funktionsparameter
왘
Namen für Datenstrukturen (struct und union) oder Felder in Datenstrukturen
왘
Namen für eigendefinierte Datentypen (typedef)
왘
Namen für Aufzählungstypen und deren mögliche Werte (enum)
왘
Namen für symbolische Konstanten
왘
Namen für Makros und Makroparameter
왘
Namen für Sprungziele (goto)
Identifier müssen von Zahlen, Zeichen, Zeichenketten oder Operatoren unterscheidbar sein und beginnen daher immer mit einem Buchstaben (a–z, A–Z) oder einem
Unterstrich (_). Danach können beliebige Buchstaben, Ziffern und Unterstriche fol-
629
18
Zusammenfassung und Ergänzung
gen. Insbesondere enthalten Identifier keine Leerzeichen oder Sonderzeichen wie +,
– oder =. Schlüsselwörter wie if oder for können ebenfalls nicht als Identifier verwendet werden.
C ist case-sensitiv. Das bedeutet, dass in C immer zwischen Groß- und Kleinschreibung unterschieden wird.
if
Fallunterscheidungen werden durch eine if-Anweisung programmiert.
Hier steht eine Bedingung
(zumeist ein Vergleichsausdruck).
Handelt es sich hier um eine einzelne
Anweisung, können die geschweiften
Klammern weggelassen werden.
Dieser Teil kann vollständig fehlen.
Handelt es sich hier um eine einzelne
Anweisung, können die geschweiften
Klammern weggelassen werden.
if ( ... )
{
...
...
...
}
else
{
...
...
...
}
Die hier stehenden Anweisungen
werden ausgeführt, wenn die
Bedingung erfüllt ist.
Die hier stehenden Anweisungen
werden ausgeführt, wenn die
Bedingung nicht erfüllt ist.
Abbildung 18.34 Die Struktur der if-else-Anweisung im Detail
Als Bedingung kann ein beliebiger Ausdruck, der zu einem Wert ausgewertet werden
kann, verwendet werden. Ergibt sich bei der Auswertung ein von 0 verschiedener
Wert, gilt der Ausdruck als »wahr«, und die Anweisungen unter dem if werden ausgeführt. Ergibt sich der Wert 0, gilt der Ausdruck als »falsch«, und die Anweisungen
unter einem gegebenenfalls vorhandenen else kommen zur Ausführung.
Bei der Verwendung von if-else gibt es einen möglichen Zuordnungskonflikt. Die
Frage ist, welchem if ein else zugeordnet werden soll, wenn dies nicht aufgrund von
Klammersetzungen eindeutig erkennbar ist (vgl. Abbildung 18.35):
630
Include-Anweisung
Zu welchem if gehört dieses else?
if( ...)
if( ...)
...;
else
...;
if( ...)
{
if( ...)
...;
}
else
...;
if( ...)
{
if( ...)
...;
else
...;
}
Abbildung 18.35 Zuordnung der else-Anweisung
Die Regel besagt, dass ein else in dieser Situation (dangling else) dem nächsten darüberstehenden if zuzuordnen ist, das noch kein else zugeordnet hat. In unserem Beispiel ist also die zweite Interpretation korrekt. Vermeiden Sie solche Situationen und
die damit verbundenen Verständnisschwierigkeiten dadurch, dass Sie geschweifte
Klammern setzten.
18
Include-Anweisung
Bei einer Include-Anweisung handelt es sich um eine Präprozessor-Direktive der
Form
# include <dateiname>
oder
# include "dateiname"
Diese Anweisung veranlasst den Präprozessor, die angegebene Datei anstelle der
Include-Anweisung in den Programmtext einzuschleusen. Um die Auswirkung dieser Anweisung zu verstehen, müssen Sie sich nur vorstellen, dass der Inhalt der angesprochenen Datei anstelle der Include-Anweisung stehen würde. Abbildung 18.36
verdeutlicht den Lesefluss des Compilers für zwei eingebundene Header-Dateien.
631
18
Zusammenfassung und Ergänzung
Lesefluss des Compilers
// Quellcodedatei
…
…
# include "header1.h
…
…
…
…
…
…
…
// header1.h
…
…
# include "header2.h
…
…
…
// header2.h
…
…
…
Abbildung 18.36 Verwendung der include-Anweisung
Ob der Dateiname in spitze Klammern (<...>) oder Anführungszeichen ("...") gesetzt
wird, beeinflusst die Strategie, mit der die Datei gesucht wird. Im ersten Fall wird die
Datei in bestimmten, vordefinierten Systemverzeichnissen, im zweiten Fall in Ihrem
Projektverzeichnis gesucht. Eine Include-Anweisung verwendet man nur für
Dateien, die keinen »raumgreifenden« Code enthalten. Dies sind die mit der Dateinamenserweiterung .h versehenen Header-Dateien (siehe Abschnitt »Header-Datei)«.
Der Dateiname kann einen relativen
# include ".../test/include/abc.h"
oder einen absoluten
# include "C:/uvw/xyz/abc.h"
Zugriffspfad enthalten. Dabei müssen Sie die betriebsystemspezifischen Konventionen beachten. Gegebenenfalls müssen Escape-Sequenzen (siehe Abschnitt »EscapeSequenzen«) verwendet werden, um spezielle Zeichen (etwa \) im Dateipfad angeben
zu können.
Inkludierte Dateien können ihrerseits wieder Dateien inkludieren. Dabei müssen Sie
darauf achten, dass keine Zyklen entstehen, die zu einem endlosen Einlagerungsprozess führen würden. Zyklen können durch Compile-Schalter (siehe Abschnitt
»Compile-Schalter«) verhindert werden. Eine Header-Datei, z. B. abc.h, versehen Sie
632
Logische Operatoren ( !, &&, ||)
dazu mit einem eindeutigen Compile-Schalter, den Sie z. B. aus dem Dateinamen
erzeugen:
ifndef ABC_H
# define ABC_H
...
# endif
Wird diese Datei dann innerhalb eines Compiler-Laufs erstmalig inkludiert, wird der
Compile-Schalter gesetzt. Bei weiteren Includes dieser Datei innerhalb desselben
Compiler-Laufs ist der Compile-Schalter dann gesetzt, und die Datei wird ausgeblendet.
int
Der Datentyp int bezeichnet eine »normale« vorzeichenbehaftete ganze Zahl. Siehe
Abschnitt »Datentypen für ganze Zahlen«.
Logische Operatoren ( !, &&, ||)
Logische Operatoren berechnen aussagenlogische Verknüpfungen mit nicht (!) und
(&&) und oder (||):
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
!
!x
logische
Verneinung
logischer Operator
R
14
&&
x && y
logisches Und
logischer Operator
L
5
||
x || y
logisches Oder
logischer Operator
L
4
Tabelle 18.14 Logische Operatoren
In klassischem C gibt es keine Wahrheitswerte wie »true« und »false«. An die Stelle
von »true« und »false« treten numerische Werte. Alles, was ungleich 0 ist, wird als
wahr und alles, was 0 ist, als falsch verstanden.
Die drei logischen Operatoren können durch Wahrheitstafeln definiert werden:
633
18
18
Zusammenfassung und Ergänzung
a
!a
a
b
a&&b
a
b
a||b
0
1
0
0
0
0
0
0
≠0
0
0
≠0
0
0
≠0
1
≠0
0
0
≠0
0
1
≠0
≠0
1
≠0
≠0
1
Abbildung 18.37 Die Wahrheitstafeln der drei logischen Operatoren
Mit diesen Operatoren können beliebige logische Ausdrücke gebildet werden, die
dann z. B. als Bedingung oder Test in Fallunterscheidungen bzw. Schleifen Verwendung finden.
Bei der Auswertung logischer Ausdrücke wird die sogenannte Shortcut Evaluation
durchgeführt. Das bedeutet, dass Terme, die als irrelevant für das Endergebnis
erkannt werden, nicht weiter ausgewertet werden. Wenn z. B. ein Teilausdruck
bereits als wahr erkannt wurde, ist es nicht notwendig, weitere mit »oder« verknüpfte Ausdrücke zu betrachten, da der Gesamtausdruck nicht »wahrer als wahr«
werden kann. Wenn man einen Teilausdruck als falsch erkannt hat, ist es nicht notwendig, weitere mit »und« verknüpfte Ausdrücke zu betrachten, da der Gesamtausdruck nicht »falscher als falsch« werden kann. Diese Art der Auswertung verbessert
das Laufzeitverhalten und ist unproblematisch, solange in den nicht ausgewerteten
Formelbestandteilen keine Seiteneffekte verborgen sind. In Abbildung 18.38 finden
Sie ein Beispiel dazu:
Der Formelteil rechts von || wird nicht
ausgewertet, da die Formel wegen a = 1
bereits als »wahr« erkannt ist.
int a = 1;
int b = 0;
a || b++;
!a && b++;
Der Formelteil rechts von && wird nicht
ausgewertet, da die Formel wegen !a = 0
bereits als »falsch« erkannt ist.
printf( "b = %d\n", b);
b++ || a;
b++ && !a;
b ist unverändert.
Hier wird dagegen b
zweimal inkrementiert
printf( "b = %d\n", b);
Abbildung 18.38 Auswertung logischer Operatoren
634
b = 0
b = 2
main
Also: Vorsicht bei Seiteneffekten in logischen Ausdrücken!
long
Der Datentyp long bezeichnet eine »große« vorzeichenbehaftete ganze Zahl.
Lesen Sie dazu auch den Abschnitt »Datentypen für ganze Zahlen«.
long double
Bei long double handelt es sich um einen Datentyp für Gleitkommazahlen besonders
großer Genauigkeit.
Mehr darüber erfahren Sie im Abschnitt »Datentypen für Gleitkommazahlen«.
main
Das Hauptprogramm trägt in C den Namen main. In der einfachsten Form sieht das
Hauptprogramm wie folgt aus:
void main()
{
...
}
18
Ein C-Compiler akzeptiert ein Hauptprogramm in der oben dargestellten Form, aber
der Standard sieht für das Hauptprogramm eine andere Schnittstelle vor, da das
Hauptprogramm über Aufrufparameter und einen Rückgabewert mit dem Laufzeitsystem verzahnt werden kann (siehe Abbildung 18.39).
Das Hauptprogramm hat zwei Eingabeparameter und einen Rückgabewert. Der erste
Eingabeparameter sagt, mit wie vielen Parametern das Hauptprogramm gerufen
wurde, wobei der Programmname (hier meinprogramm) als nullter Parameter mit zu
den Aufrufparametern zählt. Danach folgen die eigentlichen Parameter (hier eins,
zwei, drei), die der Benutzer beim Aufruf hinzugefügt hat. Das Programm erhält diese
Parameter als ein Array von Strings. Der normale Rückgabewert im Falle eines erfolgreichen Programmlaufs ist 0. Andere Rückgabewerte signalisieren spezielle Fehler
und sind von System zu System verschieden.
635
18
Zusammenfassung und Ergänzung
Programmaufruf:
Standardschnittstelle des
Hauptprogramms
meinprogramm eins zwei drei
int main(int argc, char* argv[])
{
int i;
Anzahl der Parameter
Array mit Parameterstrings
printf( "Anzahl Argumente %d\n", argc);
for( i = 0; i < argc; i++)
printf( "%s\n", argv[i]);
Anzahl Argumente 4
meinprogramm
return 0;
eins
zwei
}
drei
Rückgabewert 0 bedeutet Erfolg, andere
Rückgabewerte sind systemspezifisch.
Abbildung 18.39 Die Standardschnittstelle von main
Makros
Makros definieren – wie symbolische Konstanten – Textersetzungen, die durchgeführt werden, bevor der Compiler den Quellcode übersetzt. Die Ersetzungen können
zusätzlich durch Parameter gesteuert werden.
Makros zur Berechnung von Quadrat
und Summe.
# define QUADRAT( a)
# define SUMME( a, b)
a*a
a+b
void main()
{
int x, y, z;
x = SUMME( 1, 2);
y = QUADRAT( x);
z = SUMME( QUADRAT(2), QUADRAT(3));
}
x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
x = 1+2;
Also: x = 3
y = QUADRAT( x);
wird vom Präprozessor übersetzt zu
y = x*x;
Also: y = 9
z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = 2*2+3*3;
Also: z = 13
Abbildung 18.40 Textersetzung durch Makros
Bei der Auflösung von Makros findet eine reine Textersetzung statt. Es werden keine
Ausdrücke ausgewertet oder vereinfacht. Das kann zu ineffizientem Code oder sogar
zu unerwünschten Berechnungen führen.
636
Makros
# define QUADRAT( a)
# define SUMME( a, b)
a*a
a+b
void main()
{
int x, y, z;
int a = 1;
x = QUADRAT( 1+1);
y = 2*SUMME( 1, 1);
z = QUADRAT( ++a);
}
Makros zur Berechnung von Quadrat
und Summe.
x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
x = 1+1*1+1;
Also: x = 3
y = QUADRAT( x);
wird vom Präprozessor übersetzt zu
y = 2*1+1;
Also: y = 3
z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = ++a*++a;
Also: a = 3 und z = 9
Abbildung 18.41 Unerwünschte Nebeneffekte bei der Textersetzung
Setzen Sie daher immer Klammern um die Parameter, da Sie nicht wissen, was als
Parameter übergeben wird. Setzen Sie ebenfalls Klammern um den gesamten Ausdruck, da Sie nicht wissen, in welchem Kontext der Ausdruck aufgelöst wird:
# define QUADRAT( a)
# define SUMME( a, b)
void main()
{
int x, y, z;
int a = 1;
x = QUADRAT( 1+1);
y = 2*SUMME( 1, 1);
z = QUADRAT( ++a);
}
((a)*(a))
((a)+(b))
Makros zur Berechnung von Quadrat und
Summe mit vollständiger Klammerung
18
x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
x = ((1+1)*(1+1));
Also: x = 4
y = QUADRAT( x);
wird vom Präprozessor übersetzt zu
y = 2*((1)+(1));
Also: y = 4
z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = ((++a)*(++a));
Also: a = 3 und z = 9
Das Ergebnis ist nach wie vor unerwünscht.
Abbildung 18.42 Weitere Nebeneffekte bei der Verwendung von Makros
Bei Seiteneffekten (wie im Beispiel ++a) schützt auch die Klammersetzung nicht vor
Fehlberechnungen. Vermeiden Sie daher Seiteneffekte in Makros.
Als Parameter können beliebige Texte, also nicht nur Zahlen, übergeben werden.
Wichtig ist nur, dass nach der Übersetzung durch den Präprozessor gültiger C-Quellcode entstanden ist.
637
18
Zusammenfassung und Ergänzung
In Verbindung mit Makroparametern können spezielle Operatoren verwendet werden. Der Operator # setzt Anführungszeichen um einen Ausdruck, und der Operator
## verschmilzt zwei Ausdrücke zu einem. Mit diesen Operatoren können Sie erstaunliche Effekte erzeugen, die Sie aber in der Regel nicht benötigen.
# define STRING( a)
# define VERSCHMELZUNG( a, b)
Makros mit Anführungszeichen
und Verschmelzung.
#a
#a ## #b
void main()
{
char *s, *q;
s = STRING( Dies ist ein Beispiel-String);
q = VERSCHMELZUNG( Dies ist ein Beispiel, -String);
}
In beiden Fällen ergibt sich:
"Dies ist ein Beispiel-String"
Abbildung 18.43 Verwendung von Makroparametern und Verschmelzung
Ein Makro kann eine variable Anzahl von Parametern haben, auf die dann kumulativ
mit __VA_ARGS__ Bezug genommen werden kann. Zum Beispiel kann das folgende
Makro PRINTF wie eine Bildschirmausgabe mit printf verwendet werden, stellt aber
jeder Ausgabe einen Zeilenvorschub und den Text »Ausgabe: « voran:
# define PRINTF( format, ...)
printf( "\nAusgabe: "##format, __VA_ARGS__)
Makrodefinitionen können sich über mehrere Zeilen erstrecken, wenn Sie mit Backslash (\) am Ende einer Zeile eine Folgezeile anfügen. Makrodefinitionen können mit
"# undef <makroname>" zurückgenommen werden.
Modulo-Operation
Die Modulo-Operation liefert den Rest bei der Division zweier ganzer Zahlen und
wird in C durch den Modulo-Operator (%) durchgeführt. Die Modulo-Operation
gehört zu den arithmetischen Operationen und ist eine Rechenoperation, die in der
Informatik genauso wichtig wie Addition, Subtraktion, Multiplikation und Division
ist. Insbesondere in der Kryptologie, also bei der Ver- und Entschlüsselung von
Daten, ist diese Operation unverzichtbar.
Weitere Informationen dazu finden Sie im Abschnitt »Arithmetische Operatoren (+,
–, *, /, %)«.
638
Operatoren
Oktaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
Operatoren
Operationen, wie z. B. die Addition, sind eigentlich nur spezielle Funktionen. Die
naheliegende Schreibweise dafür ist die Funktionsschreibweise:
z = pus( x, y)
Dabei ist plus der Operator, x und y sind seine Operanden, und z ist das Ergebnis der
Operation. Anstelle der Funktionsschreibweise verwendet man die Operatorschreibweise
z = x plus y
und anstelle eines Funktionsnamens ein Operatorzeichen:
z = x + y
Wie Funktionen können Operatoren eine unterschiedliche Zahl an Argumenten
(Operanden) haben. Üblich sind einstellige (z. B. –x) und zweistellige Operatoren (z. B.
x+y). Die Zahl der Operanden, die ein Operator benötigt, bezeichnet man als die Stelligkeit des Operators. Im Wesentlichen haben wir es mit ein- und zweistelligen Operatoren zu tun4. Bei mehrstelligen Operatoren schreiben Sie das Operatorzeichen
zwischen die Operanden (Infixnotation). Bei einstelligen Operatoren können Sie es
voranstellen (Präfixnotation) oder hinten anfügen (Postfixnotation).
Komplexere Formeln entsprechen ineinander geschachtelten Funktionsaufrufen.
mal( a, plus(b,c))
a*(b+c)
plus( mal(a,b), c)
(a*b)+c
Abbildung 18.44 Ersatz von Operatoren durch ineinander geschachtelte Funktionsaufrufe
Bei der Auflösung der Funktionsaufrufe in die Operatorschreibweise entstehen
Klammern, die Sie nicht ohne Weiteres weglassen können. Um Klammern zu sparen,
arbeiten Sie mit Prioritäten. Wenn Sie festlegen, dass * eine höhere Priorität hat als +,
können Sie anstelle von (a*b) + c vereinfachend a*b+c schreiben. Bei a*(b+c) sind die
Klammern aber nach wie vor erforderlich.
Bei Operatoren gleicher Priorität sind Sie jedoch bei vielen Operatoren nach wie vor
auf Klammern angewiesen.
4 Mit der bedingten Auswertung gibt es in C auch einen dreistelligen Operator.
639
18
18
Zusammenfassung und Ergänzung
div( a, div(b,c))
a/(b/c)
div( div(a,b), c)
(a/b)/c
Abbildung 18.45 Ersatz von Operatoren gleicher Priorität
Möchten Sie hier Klammern sparen, müssen Sie eine Auswertungsreihenfolge (von
links nach rechts oder von rechts nach links) festlegen. Legen Sie für den Divisionsoperator eine Auswertung von links nach rechts fest, können Sie anstelle von (a/b)/c
auch a/b/c schreiben. Bei a/(b/c) sind die Klammern nach wie vor erforderlich. Bei
einer Auflösung von links nach rechts sprechen wir von Linksassoziativität, im
umgekehrten Fall von Rechtsassoziativität.
Wenn Sie ein formales Gebäude an Operatoren für eine Programmiersprache errichten möchten, benötigen Sie also für jeden Operator die folgenden Informationen:
왘
Operatorzeichen (z. B. +, *, /)
왘
Stelligkeit (1, 2 oder 3)
왘
Notation (Infix, Präfix oder Postfix)
왘
Priorität im Vergleich zu anderen Operatoren
왘
Assoziativität (Links- oder Rechtsassoziativität)5
Wenn Sie die – trotz dieser Rahmenbedingungen – noch verbleibenden Freiheiten
ausnutzen, kommen Sie zu einer reichhaltigen Formelsprache und zu Formelausdrücken, die nicht immer leicht zu verstehen sind. Immerhin hat C fast 50 Operatoren
unterschiedlicher Stelligkeit, Priorität und Assoziativität. Manche Programmierer
treiben es auf die Spitze und machen sich einen Sport daraus, möglichst umfassende,
klammerfreie, bis zur Unleserlichkeit optimierte Formeln zu programmieren.
Gerade als Anfänger sollten Sie diese Optimierung aber lieber dem Compiler überlassen. Berechnen Sie komplexe Formeln in Teilschritten mit Speicherung von Zwischenergebnissen, und setzen Sie, um die beabsichtigte Auswertung zu verdeutlichen gelegentlich auch überflüssige Klammern.
5 Beachten Sie, dass durch Assoziativität und Priorität nicht festgelegt ist, zu welchem Zeitpunkt
ein Teil eines Ausdrucks auf dem Rechner wirklich ausgewertet wird. Im Ausdruck 1*2-3*4-5*6
ist zwar festgelegt, dass die Subtraktionen von links nach rechts ausgeführt werden und die Multiplikationsergebnisse vorliegen müssen, bevor sie in einer Subtraktion verwendet werden. Es ist
aber nicht festgelegt, dass 1*2 vor 5*6 ausgerechnet werden muss. Das ist unkritisch, solange
keine »Seiteneffekte« in den Formeln vorkommen. Ein Seiteneffekt ist z. B. gegeben, wenn die
Auswertung eines Teils eines Ausdrucks Einfluss auf die Werte anderer Teile des Ausdrucks hat.
Mit einigen der im Folgenden diskutierten Operatoren (z. B. ++-Operator) können Sie leicht solche Seiteneffekte erzeugen. Dann ist höchste Vorsicht geboten.
640
Operatoren
Bevor wir Ihnen die verfügbaren Operatoren im Einzelnen vorstellen, erhalten Sie
zunächst einen Überblick über das Gesamtgebäude:
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
()
f (x, y)
Funktionsaufruf
Auswertungsoperator
L
15
[]
a [i]
Array-Zugriff
Zugriffsoperator
->
p->x
Indirektzugriff
.
a.x
Strukturzugriff
++
x++
Post-Inkrement
--
x--
Post-Dekrement
!
!x
logische
Verneinung
logischer Operator
R
14
~
~x
bitweises
Komplement
Bitoperator
++
++x
Pre-Inkrement
--
--x
Pre-Dekrement
Zuweisungsoperator
+
+x
plus x
-
-x
minus x
*
*p
Dereferenzierung
&
&x
Adress-Operator
()
(type)
Typkonvertierung
sizeof
sizeof (x)
Typspeichergröße
new
new class
Objekt allokieren
delete
delete a
Objekt deallokieren
*
x*y
Multiplikation
/
x/y
Division
%
x%y
Rest bei Division
+
x+y
Addition
-
x-y
Subtraktion
Zuweisungs
operator
arithmetischer
Operator
18
Zugriffsoperator
DatentypOperator
arithmetischer
Operator
L
13
arithmetischer
Operator
L
12
Tabelle 18.15 Operatoren in C
641
18
Zusammenfassung und Ergänzung
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
<<
x<<y
Bitshift links
Bitoperator
L
11
>>
x>>y
Bitshift rechts
<
x<y
kleiner als
L
10
<=
x<=y
kleiner oder gleich
Vergleichsoperator
>
x>y
größer als
>=
x>=y
größer oder gleich
==
x==y
gleich
L
9
!=
x!=y
ungleich
Vergleichsoperator
&
x&y
bitweises Und
Bitoperator
L
8
^
x^y
bitweises
Entweder-Oder
Bitoperator
L
7
|
x|y
bitweises Oder
Bitoperator
L
6
&&
x && y
logisches Und
logischer Operator
L
5
||
x || y
logisches Oder
logischer Operator
L
4
?:
x?y:z
bedingte Auswertung
Auswertungsoperator
L
3
=
x=y
Wertzuweisung
R
2
+=
x+=y
-=
x-=y
Operation mit
anschließender
Zuweisung
Zuweisungsoperator
*=
x*=y
/=
x/=y
%=
x%=y
&=
x&=y
^=
x^=y
|=
x|=y
<<=
x<<=y
>>=
x>>=y
Tabelle 18.15 Operatoren in C (Forts.)
642
Operatoren
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
,
x,y
sequenzielle
Auswertung
Auswertungsoperator
L
1
Tabelle 18.15 Operatoren in C (Forts.)
Wichtig zur Arbeit mit der Tabelle ist noch die folgende Leseanleitung:
왘
Die Operatoren werden hier mit Werten von 1 bis 15 priorisiert. Operatoren mit
höherer Priorität binden dabei stärker als Operatoren niedriger Priorität und werden in Formelausdrücken vorrangig ausgewertet.
Bezüglich der Assoziativität gibt es zwei Möglichkeiten:
왘
Linksassoziativität (L in der Tabelle) bedeutet, dass die Auswertung bei gleicher
Priorität von links nach rechts erfolgt.
왘
Rechtsassoziativität (R in der Tabelle) bedeutet, dass die Auswertung bei gleicher
Priorität von rechts nach links erfolgt.
Ein so umfangreiches Modell an Operatoren sinnvoll zu balancieren ist nicht einfach.
Und in der Tat sind nicht alle Design-Entscheidungen plausibel. Zum Beispiel hat der
Vergleichsoperator == eine höhere Priorität als der logische Operator &&. Das bedeutet, dass der Ausdruck
a && b == c && d
18
als
a && (b == c) && d
zu lesen ist. Dies ist sicher gewöhnungsbedürftig und entspricht nicht der Auswertungsreihenfolge, die Sie von der Aussagenlogik her kennen und hier nur durch entsprechende Klammersetzung
(a && b) == (c && d)
erzwingen können. Um vor unangenehmen Überraschungen geschützt zu sein, sollten Sie deshalb immer, wenn Sie sich Ihrer Sache nicht ganz sicher sind, Klammern
setzen. Setzen Sie lieber ein überflüssiges Klammernpaar, als ein wesentliches irrtümlich zu vergessen.
Weitere Informationen zur Bedeutung und Verwendung einzelner Operatoren finden Sie in dieser Kurzreferenz zu den in der Tabelle in der Spalte »Klassifizierung«
genannten Sammelbegriffen:
643
18
Zusammenfassung und Ergänzung
왘
arithmetische Operatoren
왘
Bitoperatoren
왘
logische Operatoren
왘
Vergleichsoperatoren
왘
Zugriffsoperatoren
왘
Zuweisungsoperatoren
Die hier unter den Begriffen Auswertungsoperator und Datentyp-Operator zusammengefassten Operatoren sind sehr uneinheitlich und haben daher jeweils einen
eigenen Stichworteintrag. Dabei handelt es sich um folgende Stichwörter:
왘
Funktionsaufruf
왘
Bedingte Auswertung
왘
Sequenzielle Auswertung (Komma-Operator)
왘
Cast-Operator
왘
sizeof
Präprozessor
Der Präprozessor stellt eine Vorverarbeitungsstufe zur eigentlichen Quellcodeübersetzung durch Compiler und Linker dar. Auf dieser Vorverarbeitungsstufe werden
elementare Texteinblendungen, -ausblendungen oder -ersetzungen durchgeführt.
Der Präprozessor wird durch sogenannte Präprozessor-Direktiven gesteuert. Solche
Direktiven befinden sich typischerweise in Header-Dateien oder am Anfang von
Quellcodedateien und wirken dann auf den nachfolgenden Text.
Präprozessor-Direktiven beginnen mit dem Zeichen # am Zeilenanfang. Sie sind in
der Regel einzeilig und können an beliebiger Stelle im Quellcode eingestreut werden.
Soll sich eine Präprozessor-Direktive über mehrere Zeilen erstrecken, können durch
\ am Zeilenende Fortsetzungszeilen angefügt werden.
Inhaltlich unterscheiden wir vier verschiedene Arten von Direktiven:
왘
Include-Anweisungen
왘
symbolische Konstanten
왘
Makros
왘
Compile-Schalter
Zu jedem dieser Begriffe finden Sie einen eigenen Abschnitt in dieser Zusammenfassung.
644
register
Quellcodedatei
Ein C-Programm besteht aus Quellcodedateien und Header-Dateien. Quellcodedateien erkennen Sie an der Namenserweiterung .c (oder .cpp, falls es sich um C++Quellcodedateien handelt). Quellcodedateien können alle Elemente aus C enthalten.
Diese sind:
왘
Präprozessor-Direktiven
– Includes
– Compile-Schalter
– symbolische Konstanten
– Makros
왘
Deklarationen
– Externverweise auf statische Variablen und Konstanten
– Funktionsprototypen
– Datenstrukturen und Typvereinbarungen
왘
Definitionen
– statische Variablen und Konstanten
– Funktionen
Typischerweise enthält eine Quellcodedatei alle Variablen und Funktionen eines
bestimmten Themenkomplexes und darüber hinaus alle erforderlichen Deklarationen, die aber nicht außerhalb der Datei bekannt sein müssen. Deklarationen, die
auch außerhalb der Quellcodedatei bekannt sein müssen, stehen in einer HeaderDatei (siehe Abschnitt »Header-Datei«).
Zu einem vollständigen Programm gehört immer eine Quellcodedatei, die eine Funktion mit dem Namen main enthält. In dieser Funktion startet der Kontrollfluss des
Programms.
register
Der Zusatz register kann vor der Definition automatischer Variablen stehen.
register int a;
Diese Anweisung weist der Variablen die Speicherklasse register zu. Es handelt es
sich dabei um eine Empfehlung an den Compiler, diese Variable in ein internes Register des Prozessors zu legen, damit mit dem Wert sehr effizient umgegangen werden
kann. Der Compiler folgt dieser Empfehlung allerdings nur, wenn es ihm möglich ist.
645
18
18
Zusammenfassung und Ergänzung
Gut optimierende Compiler brauchen solche Hilfestellungen durch den Programmierer heute nicht mehr.
Wenn überhaupt, dann sollten Sie register nur mit Integer-Variablen verwenden, da
das die typischen Registerinhalte sind. Die Speicherklasse register ist unvereinbar
mit statischen oder globalen Variablen, da Variablen immer nur kurzfristig in Prozessorregistern gespeichert werden sollten. Eine Registervariable hat auch keine
Adresse, da sie ja nicht im adressierbaren Speicher liegt. Wenn Sie im Code die
Adresse einer Variablen verwenden, wird der Compiler eine register-Anweisung für
diese Variable ignorieren.
Rekursion
Rekursion ist eine Programmiertechnik, bei der sich eine Funktion unmittelbar oder
mittelbar selbst aufruft. C unterstützt, wie die meisten höheren Programmiersprachen, diese Technik.
Rekursion kann immer dann verwendet werden, wenn man ein Problem auf ein oder
mehrere kleinere Probleme der gleichen Art zurückführen kann.
In der in Abbildung 18.46 dargestellten Funktion reverse wird die Reihenfolge der
Elemente eines Arrays umgekehrt:
Kehre die Reihenfolge der Zahlen im Array um.
Ist noch etwas zu tun?
Tausche den ersten und den letzten Wert im Array.
Kehre die Reihenfolge der Zahlen im kleineren Array um.
void reverse( int von, int bis, int *daten)
{
int t;
if( von < bis)
{
t = daten[von];
daten[von] = daten[bis];
daten[bis] = t;
reverse( von+1, bis-1, daten);
}
}
void main()
{
int zahlen[10] = {0,1,2,3,4,5,6,7,8,9};
reverse( 0, 9, zahlen);
}
Abbildung 18.46 Verwendung von Rekursion
Es ist wichtig, ein Abbruchkriterium für die Rekursion zu finden. Im Beispiel oben ist
das sehr einfach. Es muss weitergemacht werden, solange von kleiner als bis ist.
646
return
Mit Rekursion lassen sich komplexe Probleme manchmal sehr einfach und elegant
lösen. Dazu sollten Sie aber zwei grundsätzliche Hinweise im Hinterkopf behalten:
왘
Rekursion ist niemals zwingend erforderlich, da man Rekursion immer durch ein
iteratives Vorgehen ersetzen kann.
왘
Rekursive Lösungen eines Problems sind langsamer als gut optimierte iterative
Lösungen des gleichen Problems.
Bevor Funktionen in einer Laufzeitbibliothek allgemein zur Verfügung gestellt werden, wird daher häufig die Rekursion entfernt.
return
Mit einer return-Anweisung kann unmittelbar aus einem Unterprogramm in das
aufrufende Programm zurückgesprungen werden. Dabei kann ein Rückgabewert an
das rufende Programm übergeben werden.
Wenn eine Funktion einen Returntyp hat, muss jeder mögliche Ausführungspfad der
Funktion mit einer return-Anweisung mit Rückgabe eines Returnwerts enden:
Diese Funktion muss int zurückgeben.
int funktion()
{
Rücksprung mit Wert 0
int x;
...
if( ...)
return 0;
...
return x+1;
}
18
Rücksprung mit Ausdruck
Abbildung 18.47 Funktion mit Returntyp
Der Returnwert kann eine Konstante, eine Variable oder ein Ausdruck sein. Wichtig
ist, dass sich nach Auswertung des Ausdrucks ein Wert ergibt, dessen Datentyp
»kompatibel« mit dem geforderten Rückgabetyp ist. So kann in einer float-Funktion
durchaus ein int-Wert zurückgegeben werden, da eine implizite Konvertierung von
int in float möglich ist. Umgekehrt ist das nicht ohne Datenverlust möglich, daher
kann in einer int-Funktion nicht ein float-Wert zurückgegeben werden. Auch wenn
nicht jeder Compiler das als Fehler ansieht, wird zumindest auf den möglichen
Datenverlust hingewiesen.
647
18
Zusammenfassung und Ergänzung
Wenn ein Unterprogramm keinen Returnwert hat (void-Funktion), muss es nicht
unbedingt eine explizite return-Anweisung geben. Wenn es allerdings eine solche
Anweisung gibt, darf sie keinen Returnwert haben:
Diese Funktion hat keinen Returnwert.
void funktion()
{
Expliziter Rücksprung
...
if( ...)
return;
...
...
}
Impliziter Rücksprung
Abbildung 18.48 Funktion ohne Returnwert
In jedem Fall erfolgt bei einer return-Anweisung der sofortige Rücksprung in das
rufende Programm. return-Anweisungen im Inneren einer Funktion stehen daher
immer unter einer Bedingung, da ansonsten der nachfolgende Code niemals erreicht
würde. Einzig am Ende einer Funktion kann ein unbedingtes return stehen.
Schlüsselwörter
In jeder Programmiersprache gibt es eine Reihe reservierter Wörter. Diese auch als
Schlüsselwörter oder Keywords bezeichneten Wörter haben eine genau definierte
Bedeutung und dürfen nur in dieser Bedeutung verwendet werden. Schlüsselwörter
dürfen Sie z. B. nicht als Variablennamen oder Funktionsnamen verwenden.
In C sind folgende Wörter reserviert:
auto
double
int
struct
break
else
long
switch
case
enum
register
typedef
char
extern
return
union
const
float
short
unsigned
continue
for
signed
void
Tabelle 18.16 Reservierte Schlüsselwörter in C
648
Sequenzielle Auswertung (Komma-Operator)
default
goto
sizeof
volatile
do
if
static
while
Tabelle 18.16 Reservierte Schlüsselwörter in C (Forts.)
Erläuterungen zu jedem dieser Schlüsselwörter finden Sie unter dem entsprechenden Stichwort in diesem Kapitel.
Sequenzielle Auswertung (Komma-Operator)
Der Komma-Operator für die sequenzielle Auswertung erlaubt es, mehrere Ausdrücke durch Komma getrennt hintereinanderzuschreiben. Die Ausdrücke werden von
links nach rechts ausgewertet, und das Ergebnis ist der Wert des zuletzt ausgewerteten Ausdrucks. In der Regel wird das Ergebnis nicht zugewiesen oder verwendet. Zum
Beispiel verwendet man den Komma-Operator häufig bei der Initialisierung oder im
Inkrement von Schleifen:
for( i = 0, k= 1; i < 100; i++, k *= 2)
{
...
}
Man kann aber auch das Ergebnis einer Sequenz zuweisen und weiterverarbeiten:
int ergebnis;
ergebnis = (1, 2, 3);
In diesem Beispiel wird der Variablen ergebnis der Wert des zuletzt ausgewerteten
Ausdrucks zugewiesen. Der Wert von ergebnis ist also 3.
Lässt man übrigens die Klammern weg,
ergebnis = 1, 2, 3;
ist wegen der höheren Priorität des Zuweisungsoperators gegenüber dem KommaOperator das Ergebnis der Zuweisung 1.
649
18
18
Zusammenfassung und Ergänzung
short
Der Datentyp short bezeichnet eine »kleine« vorzeichenbehaftete ganze Zahl, die
von der Größe zwischen char und int einzuordnen ist.
Bezüglich der Anzahl der Bytes und des Rechenbereichs legt sich der Standard nicht
fest. Typischerweise belegt der Datentyp 2 Bytes und kann damit Zahlen zwischen
–215 = –32768 und 215 – 1 = 32767 darstellen.
Mehr dazu erfahren Sie im Abschnitt »Datentypen für ganze Zahlen«.
signed
Das Schlüsselwort signed kann Integer-Datentypen vorangestellt werden, um festzulegen, dass es sich um einen vorzeichenbehafteten Datentyp handelt.
Da die Integer-Typen auch ohne explizite Angabe von signed vorzeichenbehaftet
sind, ist der Zusatz signed streng genommen überflüssig und wird auch nur sehr selten verwendet.
Schauen Sie sich dazu ebenfalls den Abschnitt »Datentypen für ganze Zahlen« an.
sizeof
Der sizeof-Operator berechnet die Größe eines Datentyps, wobei unter der Größe
die Anzahl der Bytes zu verstehen ist, die der Datentyp im Speicher belegt. Der
sizeof-Operator kann auf einen Datentyp oder eine Variable eines Datentyps angewandt werden:
int s1, s2, s2;
struct b
{
int x;
double d;
};
s1 = sizeof( int); // maschinenabhängig, aber typischerweise 4
s2 = sizeof( double) // maschinenabhängig, aber typischerweise 8
s3 = sizeof( b);
// maschinenabhängig, aber typischerweise 16
Dieses Beispiel zeigt die interne Ausrichtung der Daten im Speicher (Alignment). Da
das double-Feld in der Datenstruktur auf eine durch 8 teilbare Adresse ausgerichtet
650
Speicherallokation
wird, entsteht zwischen x und d eine Lücke von 4 Bytes, sodass die Struktur insgesamt 16 Bytes groß ist. Das zeigt, dass Sie die Größe von Datenstrukturen nicht selbst
berechnen sollten, da die Größe der Grunddatentypen (int, float) maschinenabhängig ist und sich auch die Größe zusammengesetzter Typen nicht ohne Weiteres aus
der Größe der Grunddatentypen errechnen lässt. Überlassen Sie es immer dem Compiler, zu berechnen, wie groß die von ihm erzeugten Datenstrukturen sind.
Angewandt auf ein Array, liefert der sizeof-Operator die Anzahl der Bytes in einem
Array. Damit können Sie die Anzahl der Elemente in einem Array berechnen:
int daten[] = {13,21,7,4};
int anzahl = sizeof( daten)/sizeof(int); // Anzahl ist 4
Verwechseln Sie den sizeof-Operator nicht mit der strlen-Funktion für Strings.
Betrachten Sie dazu das folgende Beispiel:
char *p = "Programmierung";
printf( "%d\n", sizeof( "Programmierung"));
printf( "%d\n", sizeof( p));
printf( "%d\n", strlen( "Programmierung"));
printf( "%d\n", strlen( p));
Im ersten Fall wird der Speicherplatz, den der String "Programmierung" belegt, berechnet. Das sind einschließlich des Terminatorzeichens 15 Bytes. Im zweiten Fall wird der
Speicherplatz berechnet, den der Zeiger p belegt. Das sind 4 Bytes. Die strlen-Funktion berechnet in jedem Fall die Länge des gegebenen Strings ohne das Terminatorzeichen, und das sind 14 Bytes.
Speicherallokation
Wenn ein Programm zur Laufzeit Speicher benötigt, kann es diesen mit Funktionen
wie malloc und calloc vom Laufzeitsystem anfordern. Von diesen Funktionen erhält
das Programm die Adresse des reservierten (allokierten) Speichers, die dann üblicherweise einem Zeiger zugewiesen wird, damit über den Zeiger auf den Speicher
zugegriffen werden kann. Nicht mehr benötigter Speicher sollte mit der Funktion
free freigegeben (deallokiert) werden, damit er vom Laufzeitsystem erneut disponiert werden kann.
Im Zusammenhang mit dem Allokieren und Deallokieren von Speicher gibt es die
folgenden Funktionen:
651
18
18
Zusammenfassung und Ergänzung
Funktion
Beschreibung
malloc
Allokiert eine gewünschte Anzahl von Bytes.
calloc
Allokiert einen zusammenhängenden Bereich aus einer bestimmten
Anzahl von Blöcken einer bestimmten Größe. Zusätzlich wird der allokierte Speicher mit 0 initialisiert.
realloc
Reallokiert (vergrößert, verkleinert) einen zuvor allokierten Bereich.
Wird dabei ein neuer Bereich angelegt, werden die Daten aus dem
alten Bereich in den neuen Bereich kopiert, und der alte Bereich wird
freigegeben. Beim Kopieren in einen kleineren Bereich werden überschüssige Daten nicht kopiert. Beim Kopieren in einen größeren
Bereich bleiben überschüssige Daten uninitialisiert.
free
Gibt den mit malloc, calloc oder realloc allokierten Speicher frei.
Tabelle 18.17 Funktionen zum Allokieren und Deallokieren von Speicher
Der Rückgabewert von malloc, calloc und realloc ist NULL, wenn die Anforderung
mangels Speichers nicht ausgeführt werden kann.
Funktionen zur Speicherallokation werden verwendet, um den Speicher für Datenstrukturen und Arrays (insbesondere Strings) zu allokieren.
In Abbildung 18.49 sehen Sie ein Beispiel für die Allokation einer Datenstruktur mit
abschließender Freigabe:
struct test
{
int wert1;
float wert2;
char txt[3];
};
Datenstruktur, für die Speicher
allokiert werden soll.
Zeiger für den allokierten Speicher.
Typkonvertierung
Benötigte Speichermenge.
struct test *ptr;
ptr = (struct test *)malloc( sizeof( struct test));
ptr->wert1 = 123;
ptr->wert2 = 4.56;
ptr->txt[0] = 'A';
ptr->txt[1] = 'B';
ptr->txt[2] = 0;
Allokieren des Speichers.
Verwenden des Speichers.
printf( "Daten: %d, %f, %s\n", ptr->wert1, ptr->wert2, ptr->txt);
free( ptr);
Freigabe des Speichers.
Daten: 123, 4.560000, AB
Abbildung 18.49 Allokieren und Freigeben einer Datenstruktur
652
Speicherallokation
Beispiel für die Allokation eines Arrays mit abschließender Freigabe:
Zeiger für den zu allokierten Array
int *ptr;
int i;
Speicher für 10 int-Werte allokieren
Typkonvertierung
ptr = (int *)calloc(10, sizeof(int));
for( i = 0; i < 10; i++)
ptr[i] = i;
for( i = 0; i < 10; i++)
printf( "%d\n", ptr[i]);
free( ptr);
Verwenden des Speichers
Freigabe des Speichers
0
1
2
3
4
5
6
7
8
9
Abbildung 18.50 Allokieren und Freigeben eines Arrays
Beispiel für die systematische Vergrößerung eines Puffers mit abschließender Freigabe:
18
Zeiger für den zu allokierten Puffer
char *ptr = 0;
int size = 0;
char c = 0;
Aktuelle Puffergröße
Eingabezeichen
printf( "Text: ");
for( ; c != '\n'; size++)
{
scanf( "%c", &c);
ptr = (char *)realloc( ptr, size+1);
ptr[size] = c;
}
ptr[size-1] = 0;
printf( "Ergebnis: %s\n", ptr);
free( ptr);
Ein Zeichen einlesen
Puffer vergrößern
Zeichen im Puffer speichern
Zeichenkette terminieren
Freigabe des Speichers
Text:
the quick brown fox jumps over the lazy dog
Ergebnis: the quick brown fox jumps over the lazy dog
Abbildung 18.51 Allokieren und dynamisches Vergrößern des Speichers
653
18
Zusammenfassung und Ergänzung
Die Vergrößerung von Speicher erfolgt üblicherweise nicht so kleinschrittig, wie im
letzten Beispiel gezeigt, und die Freigabe von Speicher erfolgt in der Regel nicht, wie
in den Beispielen suggeriert, unmittelbar nach der ersten Verwendung. Normalerweise wird allokierter Speicher über einen längeren Zeitraum verwendet und erst
dann freigegeben, wenn er nicht mehr benötigt wird.
Speicherallokation in Verbindung mit Zeigern ermöglicht es, dynamisch große
Datenbestände (z. B. in Form von Listen oder Bäumen) aufzubauen und zu verwalten.
Ohne diese Technik vollständig verstanden zu haben, können Sie keine professionellen Programme schreiben.
Streams (stdin, stdout, stderr)
In der Laufzeitumgebung eines C-Programms gibt es drei vordefinierte Streams:
왘
stdin
왘
stdout
왘
stderr
Diese Streams werden vom Laufzeitsystem bereitgestellt, wenn das Programm
startet.
Der Stream stdin ist zum Lesen geöffnet und mit dem Eingabemedium (Tastatur) des
Computers verbunden. Alle Tastatureingaben des Benutzers landen in diesem
Stream und können vom Programm von dort mit entsprechenden Dateioperationen
gelesen werden.
Der Stream stdout ist zum Schreiben geöffnet und mit dem Ausgabemedium (Bildschirm) des Computers verbunden. Alle Bildschirmausgaben des Programms landen
in diesem Stream und werden anschließend auf dem Bildschirm dargestellt.
Der Stream stderr ist wie stdout zum Schreiben geöffnet und mit dem Bildschirm
verbunden. Alle Fehlerausgaben des Programms landen in diesem Stream.
Die Zuordnungen der Streams zu konkreten Ein-/Ausgabegeräten sind flexibel und
können vom Programmierer im Rahmen des technisch Möglichen geändert werden.
Zum Beispiel kann der Stream stdout auf eine zum Schreiben geöffnete Datei umgelenkt werden. Dazu verwendet man die Funktion freopen aus der Standardbibliothek.
Nach der Umlenkung von stdout in die Datei ausgabe.txt erscheinen die Ausgaben
nicht mehr auf dem Bildschirm, sondern werden in die festgelegte Datei geschrieben.
654
static-Funktion
Umlenken der Standardausgabe
in die Datei ausgabe.txt.
freopen("ausgabe.txt", "w", stdout);
printf("Test...Test...Test\n");
Abbildung 18.52 Umlenken der Standardausgabe in die Datei ausgabe.txt
Auf die Standardstreams können Sie im Prinzip die gleichen Funktionen anwenden
wie auf Dateien. Dateien sind aus Sicht eines C-Programms letztlich auch Streams. So
macht es keinen Unterschied, ob Sie
printf( "Ausgabe\n");
oder
fprintf( stdout, "Ausgabe\n");
18
schreiben. Es ergibt sich die gleiche Ausgabe.
static-Funktion
Vor einer Funktion bedeutet der Zusatz static, dass diese Funktion nur in der Compilationseinheit (= Quellcodedatei), in der sie steht, bekannt ist und verwendet werden kann.
static int funktion( int x, float y)
{
...
}
Durch den Zusatz static kann man vermeiden, dass es Namenskonflikte zwischen
zufällig gleich benannten Funktionen in verschiedenen Quellcodedateien gibt. Funktionen, die ausschließlich als Hilfsfunktionen innerhalb eines Moduls verwendet
werden, die also nicht aus anderen Modulen heraus gerufen werden, sollten static
sein.
655
18
Zusammenfassung und Ergänzung
static-Variable
Vor einer Variablen außerhalb von Funktionen bedeutet static, dass die Variable wie
eine globale Variable bei Programmstart angelegt und gegebenenfalls initialisiert
wird und dann über die gesamte Programmlaufzeit verfügbar ist. Im Gegensatz zu
einer globalen Variablen ist die Variable aber nur in der Compilationseinheit
(= Quellcodedatei), in der sie steht, bekannt und kann auch nur dort verwendet werden.
static int zahl1;
static double zahl2 = 1.234;
Vor einer Variablen innerhalb einer Funktion oder eines Blocks bedeutet static, dass
die Variable beim erstmaligen Eintritt in die Funktion/den Block erzeugt und gegebenenfalls initialisiert wird und dann bei allen weiteren Eintritten in die Funktion/den
Block mit dem zuletzt gesetzten Wert verfügbar ist. Die Variable ist dabei nur innerhalb der Funktion/des Blocks bekannt und kann auch nur dort verwendet werden.
void function()
{
static int zahl1;
static double zahl2 = 1.234;
...
}
Eine solche Variable ist also wie eine globale Variable, deren Sichtbarkeit und Verwendbarkeit auf eine einzelne Funktion bzw. einen einzelnen Block beschränkt ist.
struct
Mit struct definierte Datenstrukturen sind benutzerdefinierte Datentypen, die
durch Aggregation bestehender Datentypen erzeugt werden. Die einzelnen Bestandteile können dabei ihrerseits wieder folgende Elemente sein:
왘
Ganzzahlen
왘
Gleitkommazahlen
왘
Arrays
왘
Strukturen
왘
Unions
왘
Aufzählungstypen
왘
Bitfelder
왘
Zeiger
656
switch
Zu einer Datenstruktur gehören:
왘
ein Name
왘
ein Datentyp und ein Name für jedes Element der Datenstruktur
Beispiel:
struct datum
{
int tag;
int monat;
int jahr;
};
struct person
{
char name[100];
char vorname[100];
struct datum geburtstag;
char familienstand;
float groesse;
};
struct verein
{
char name[100];
int anzahl_mitglieder;
struct person mitglieder[1000];
};
18
Eine Struktur ist nur eine Schablone für Daten. Die eigentlichen Daten werden durch
Definition von Variablen (siehe Abschnitt »Variablen«) angelegt. Auf die Felder einer
Datenstruktur wird dann mit speziellen Operatoren (siehe Abschnitt »Zugriffsoperatoren ([], ->., ., *, &)«) zugegriffen.
switch
Bei switch handelt es sich um ein Kontrollkonstrukt, mit dem sogenannte
Sprungleisten realisiert werden. switch kann eingesetzt werden, wenn bei einer Verzweigung mehrere, insbesondere mehr als zwei Fälle, zu betrachten sind:
657
18
Zusammenfassung und Ergänzung
Hier kann ein Ausdruck mit ganzahligem Wert stehen.
Entsprechend dem Wert wird ein Label angesprungen.
case-Label müssen
ganzahlige Konstante
Ausdrücke sein.
Label können
kaskadiert werden,
um mehrere Fälle
zusammenzufassen.
Ein default-Label
erfasst alle Fälle, die
nicht durch andere
Label abgedeckt sind.
switch( i)
{
case 1:
printf(
break;
case 2:
printf(
break;
case 3:
case 5:
case 7:
printf(
break;
case 4:
case 6:
case 8:
printf(
case 9:
printf(
break;
default:
printf(
break;
}
Ist der Wert des obigen Ausdrucks 1
wird hier hin gesprungen.
"Eins\n");
break bricht die Behandlung ab.
"Gerade Primzahl\n");
Ist der Wert 3, 5 oder 7 wird hier hin gesprungen.
"Primzahl\n");
"Gerade Zahl\n");
Wenn break fehlt, läuft
der Kontrollfluss in den
nächsten Fall, auch wenn
das Label dort nicht passt.
"Keine Primzahl\n");
"Unbehandelte Zahl\n");
Abbildung 18.53 Mehrere Verzweigungen mit der switch-Anweisung
Die Verzweigung erfolgt bezüglich eines Ausdrucks mit ganzzahligem Wert. Dabei
kann es sich durchaus um einen komplexen Formelausdruck handeln. Je nach Wert
werden dann sogenannte case-Label angesprungen. Als case-Label sind beliebige
ganzzahlige konstante Ausdrücke zugelassen, also nicht nur Zahlen wie 1 oder 2, sondern auch Ausdrücke wie 'a' oder 1<<4. Natürlich darf kein Label mehrfach vorkommen.
Ein default-Label sammelt alle Fälle, die nicht durch andere Label abgedeckt sind. Ein
solches Label muss es nicht geben, und es muss auch nicht am Ende der Sprungleiste
stehen. Es ist aber guter Programmierstil, ein default-Label am Ende einer
Sprungleiste zu haben.
Beachten Sie die Bedeutung der break-Anweisung, die die Behandlung nicht nur
eines Falles, sondern der gesamten Fallunterscheidung abbricht. Fehlt die breakAnweisung bei einem Fall, läuft der Kontrollfluss automatisch in den nächsten Fall
hinein. In der Regel ist dieses Verhalten nicht erwünscht, und Sie sollten daher
immer prüfen, ob Sie kein break vergessen haben. In speziellen Fällen ist das »feh-
658
Symbolische Konstanten
lende« break aber durchaus sinnvoll. Das break beim letzten Fall kann natürlich
immer weggelassen werden.
Wenn Sie das Beispiel oben fortlaufend mit i = 0, 1, 2, ... 10 durchlaufen, erhalten Sie
die folgende Ausgabe:
0:
Unbehandelte Zahl
1:
Eins
2:
Gerade Primzahl
3:
Primzahl
4:
Gerade Zahl
Keine Primzahl
5:
Primzahl
6:
Gerade Zahl
Keine Primzahl
7:
Primzahl
8:
Gerade Zahl
Keine Primzahl
18
9:
Keine Primzahl
10:
Unbehandelte Zahl
Abbildung 18.54 Ausgabe der switch-Anweisung
Beachten Sie, dass hier bei den Zahlen 4, 6 und 8 wegen des fehlenden break zwei Fälle
durchlaufen werden, was aber so gewollt ist, da diese Zahlen sowohl gerade als auch
keine Primzahlen sind.
Symbolische Konstanten
Symbolische Konstanten werden durch Präprozessor-Anweisungen der Form
# define <Name> <Wert>
festgelegt. Typischerweise stehen solche symbolischen Konstanten in HeaderDateien und bewirken, dass der Präprozessor, nachdem er die Anweisung gelesen
hat, alle Vorkommnisse von <Name> im Quellcode durch <Wert> ersetzt.
659
18
Zusammenfassung und Ergänzung
#
#
#
#
#
#
define
define
define
define
define
define
ganzzahl
wenn
ist
plus
anfang
ende
int
if
==
+
{
}
Symbolische
Konstanten.
ganzzahl a = 1;
wenn( a ist 1)
anfang
a = a plus 1;
ende
Quellcode vor
Preprocessing
int a = 1;
if( a == 1)
{
a = a + 1;
}
Quellcode nach
Preprocessing
Abbildung 18.55 Verwendung symbolischer Konstanten
Nach dem Preprocessing entsteht gültiger C-Code, der problemlos übersetzt werden
kann.
Solche Spielereien werden Sie in seriösen C-Programmen nicht finden, aber das
Bespiel zeigt, dass nur eine Textersetzung durchgeführt wird. Einzig wichtig ist, dass
der Compiler nach der Textersetzung gültigen C-Code erhält.
Häufig verwendet man symbolische Konstanten, um Array-Grenzen einheitlich im
Quellcode ansprechen zu können:
# define ANZAHL 10
int i;
int daten[ANZAHL];
Die Größe des Arrays ist durch
die symbolische Konstante
ANZAHL gegeben.
for( i = 0; i < ANZAHL; i++)
daten[i] = 2*i;
for( i = 0; i < ANZAHL; i++)
printf( "%d\n", daten[i]);
0
2
4
6
8
10
12
14
16
18
Abbildung 18.56 Symbolische Konstanten für Arrays
660
typedef
Es gibt fünf vordefinierte symbolische Konstanten, die jeweils mit einem doppelten
Unterstrich beginnen und enden:
Konstante
Ersetzung
__FILE__
Dateiname der Quelldatei als Zeichenkette
__LINE__
aktuelle Zeilennummer als numerischer Wert
__DATE__
aktuelles Datum als Zeichenkette
__TIME__
aktuelle Zeit als Zeichenkette
__STDC__
1, falls der Compiler ANSI-C-konform ist
Tabelle 18.18 Vordefinierte symbolische Konstanten
Mit der Konstanten __TIME__ kann ein Programm z. B. seine Compile-Zeit in den Code
einbrennen und ausgeben:
printf( "Compile-Zeit: %s\n", __TIME__);
Compile-Zeit: 09:29:56
typedef
18
Durch typedef können neue Typbezeichner definiert werden.
Durch die Anweisung
typedef int zahl;
wird z. B. ein neuer Typbezeichner (zahl) für den bestehenden Datentyp int eingeführt. Dieser neue Bezeichner kann dann als Alias anstelle von int verwendet werden:
zahl z;
z = 1;
Hauptsächlich werden benutzerspezifische Typbezeichner in Verbindung mit Datenstrukturen (struct, union) verwendet.
661
18
Zusammenfassung und Ergänzung
struct pkt
{
int x;
int y;
};
typedef struct pkt punkt;
punkt p;
p.x = 1;
p.y = 2;
Man kann Struktur und Typbezeichner auch in einem Zug einführen:
typedef struct pkt
{
int x;
int y;
} punkt;
punkt p;
p.x = 1;
p.y = 2;
Der Strukturname ist in dieser Situation überflüssig:
typedef struct
{
int x;
int y;
} punkt;
punkt p;
p.x = 1;
p.y = 2;
Die Einführung eines neuen Typbezeichners ist mehr eine kosmetische Operation als
eine wirklich notwendige Funktion.
662
union
union
Eine Union (union) ist wie eine Struktur (struct) eine Datenstruktur. Der formale Aufbau einer Union entspricht exakt dem einer Struktur, anstelle des Schlüsselworts
struct wird jedoch das Schlüsselwort union verwendet. Mehr darüber erfahren Sie im
Abschnitt über das Schlüsselwort »struct«.
Der inhaltliche Unterschied zwischen Struktur und Union besteht darin, dass bei
einer Union der Compiler angewiesen wird, die einzelnen Felder im Speicher nicht
hintereinander, sondern platzsparend übereinander anzulegen. Die bedeutet natürlich, dass Sie zu einem Zeitpunkt immer nur ein Feld einer Union nutzen können und
auch jederzeit wissen sollten, welches der Felder Sie genutzt haben. Unions eignen
sich daher nur für Datenstrukturen, deren Felder alternativ (im Sinne eines Entweder-Oder) genutzt werden.
Im folgenden Beispiel wird ein Zeitraum durch zwei verschiedene Strukturdefinitionen modelliert, zum einen über Anfangs- und Enddatum (zeitraum1) und zum anderen über ein Anfangsdatum und die Länge in Tagen (zeitraum2):
struct datum
{
int tag;
int monat;
int jahr;
};
18
struct zeitraum1
{
struct datum von;
struct datum bis;
};
struct zeitraum2
{
struct datum anfang;
int anzahl tage;
};
Bis hier wurden nur struct-Vereinbarungen verwendet. Beide Strukturen (zeitraum1
und zeitraum2) sollen jetzt aber in einem Programm verwendet werden, wobei zu
einem Zeitpunkt immer nur eine der beiden Varianten benötigt wird. Dies können
Sie durch eine Union modellieren:
663
18
Zusammenfassung und Ergänzung
union zeitraum
{
struct zeitraum1 z1;
struct zeitraum2 z2;
};
Da man einer Variablen vom Typ union zeitraum nicht ansehen kann, ob in ihr ein
Zeitraum vom Typ zeitraum1 oder zeitraum2 gespeichert ist, fügt man häufig einer
Union noch eine sogenannte Diskriminante hinzu. Dazu bettet man die Union in
eine Struktur ein, die zusätzlich die Diskriminante (hier typ) enthält:
struct termin
{
int typ;
union zeitraum z;
};
Wenn man jetzt die Diskriminante konsequent mitführt (z. B. typ = 1 bedeutet
zeitraum1, typ = 2 bedeutet zeitraum2), kann man die Union konsistent verwenden.
In der Verwendung (Definition, Zugriff) unterscheidet sich die Union nicht von einer
Struktur, Sie müssen lediglich darauf achten, dass immer nur eine der Varianten gültig ist und verwendet werden kann.
unsigned
Das Schlüsselwort unsigned kann allen Integer-Datentypen vorangestellt werden, um
festzulegen, dass es sich um einen vorzeichenlosen Datentyp handelt:
unsigned char c;
unsigned int i;
unsigned int funktion( unsigned char x)
{
...
}
Beachten Sie dazu auch den Abschnitt »Datentypen für ganze Zahlen«.
664
Variablen
Unterprogramme
Ein C-Programm besteht aus einem Hauptprogramm (main) und vielen Unterprogrammen, die mittelbar oder unmittelbar vom Hauptprogramm gerufen werden.
Unterprogramme werden in C durch Funktionen (siehe Abschnitt »Funktionen«)
realisiert. Unterprogramme in dem Sinne, dass eine Funktion nur lokal innerhalb
einer anderen Funktion existiert, gibt es in C nicht.
Variablen
Mit Variablen modelliert man die Daten eines Programms. Zu einer Variablen gehören:
왘
eine Speicherklasse
왘
ein Datentyp
왘
ein Name
왘
ein Wert
왘
eine Adresse
Speicherklasse
Datentyp
18
Name
Wert
static int meinezahl = 17;
Abbildung 18.57 Elemente einer Variablen
Die Speicherklasse legt den Speicherort (Prozessorregister, Stack, Heap) und die
Lebensdauer einer Variablen fest. Es gibt die Speicherklassen:
왘
auto
왘
register
왘
static
왘
extern
Mehr zu Speicherklassen erfahren Sie unter den entsprechenden Stichwörtern.
665
18
Zusammenfassung und Ergänzung
Als Datentyp kommen alle vordefinierten Datentypen (int, float, ...), alle zusammengesetzten Datentypen (struct, union), Arrays und Zeiger infrage. Siehe auch die
Abschnitte »int«, »float«, »struct« und »union«.
Der Name einer Variablen wird vom Programmierer relativ frei festgelegt und folgt
den Bezeichnungsregeln für Identifier (siehe Abschnitt »Variablen«).
Einen Wert erhält eine Variable durch Initialisierung oder Zuweisung über eine Konstante oder über eine andere Variable. Mehr über Wertzuweisungen erfahren Sie bei
den einzelnen Datentypen und im Abschnitt über Zuweisungsoperatoren.
Die Adresse einer Variablen wird nicht vom Programmierer, sondern vom Compiler
vergeben, siehe Abschnitt »Adressen«.
Vergleichsoperator (<, <=, >, >=, ==, !=)
Vergleichsoperatoren dienen dazu, Zahlen – egal, ob ganze Zahlen oder Gleitkommazahlen – untereinander auf gleich, ungleich, größer oder kleiner zu testen:
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
<
x<y
kleiner als
Vergleichsoperator
L
10
<=
x <= y
kleiner oder gleich
>
x>y
größer als
>=
x >= y
größer oder gleich
==
x == y
gleich
Vergleichsoperator
L
9
!=
x != y
ungleich
Tabelle 18.19 Vergleichsoperatoren
Beachten Sie, dass ein Vergleich auf Gleichheit mit dem doppelten Gleichheitszeichen (==) durchgeführt wird. Ein einfaches Gleichheitszeichen bedeutet eine Zuweisungsoperation.
Vergleichen Sie ganze Zahlen nach Möglichkeit »sortenrein«, also signed mit signed
und unsigned mit unsigned, ansonsten könnten Sie unangenehme Überraschungen
erleben:
666
volatile
unsigned int a = 1;
signed int b = –1;
if( a < b)
printf( "a < b");
Sie erhalten folgende Ausgabe:
a < b
Der Compiler warnt Sie vor solchen Vergleichen. Nehmen Sie die Warnungen des
Compilers nicht auf die leichte Schulter.
void
Das Schlüsselwort void ist ein Surrogat, das überall dort auftaucht, wo eigentlich ein
Datentyp erwartet wird, es aber keinen Datentyp gibt – z. B. bei einer Funktion, die
nichts zurückgibt:
void funktion( int x);
oder bei einem unspezifizierten Zeiger, bei dem nicht festgelegt ist, auf welchen
Datentyp er zeigt:
18
void *zeiger;
volatile
Variablen kann bei der Definition das Schlüsselwort volatile6 vorangestellt werden.
volatile int a;
Dies ist ein Hinweis des Programmierers an den Compiler, dass diese Variable unter
Umständen von außerhalb des Programms geändert wird. Das bedeutet, dass man
sich nicht darauf verlassen kann, dass die Variable ihren Wert zwischen zwei Lesezugriffen beibehält, weil noch ein unbekannter Dritter seine Hände im Spiel hat. Der
Compiler unterlässt bei solchen Variablen Optimierungen und fordert den Wert bei
jedem Lesezugriff erneut an.
In maschinenfernen Anwendungsprogrammen kommen solche Variablen nicht vor.
6 engl. volatile = flüchtig, unbeständig, unberechenbar
667
18
Zusammenfassung und Ergänzung
while
Mit while können einfache Schleifen erstellt werden. Bei while handelt es sich um
eine vereinfachte Variante von for, da Initialisierung und Inkrement fehlen:
while( ...)
{
...
...
...
}
for( ; ...; )
{
...
...
...
}
Abbildung 18.58 Struktur der while-Schleife
Schleifensteuerung mit break und continue ist, wie bei for, möglich.
Die while-Anweisung wird häufig verwendet, ist aber im Grunde genommen überflüssig, da sie jederzeit durch ein for, bei dem Initialisierung und Inkrement leer
gelassen werden, ersetzt werden kann.
Zeichen
Zeichen werden in einfache Hochkommata eingeschlossen. Handelt es sich um ein
druckbares Zeichen, können Sie das Zeichen direkt verwenden:
'a'
'Z'
Bei nicht druckbaren Zeichen verwenden Sie die zugehörige Escape-Sequenz:
'A'
\101
\x41
ein A
ein A
ein A
'\n' ein Zeilenvorschub
'\12' ein Zeilenvorschub
'\xa' ein Zeilenvorschub
668
Zeichenketten
Sprachlich unterscheidet man nicht immer sauber zwischen einem Zeichen, seinem
Literal und seinem Code. Das Zeichen ist das Schriftsymbol, um das es eigentlich
geht. Das Literal ist die Darstellung des Zeichens im Quelltext, und der Zeichencode
ist die Darstellung des Zeichens im Rechner. Im Rechner gibt es daher keine Zeichen,
sondern nur Zeichencodes, also Bitmuster, die wir als Zahlen interpretieren können.
Auch wenn ein Zeichenliteral, wie etwa '\x5c', im Quellcode aus mehreren Buchstaben besteht, steht es für ein einzelnes Zeichen (hier Backslash) und belegt im ausführbaren Programm nur ein Byte.
Zeichenketten
Zeichenketten werden in doppelte Hochkommata eingeschlossen und können
Escape-Sequenzen enthalten:
"ABCD\n"
"\x41\x42\x43\x0a" ebenfalls "ABCD\n"
Bei Zeichenketten gibt es verschiedene Abstraktionsebenen, die sprachlich nicht
immer sauber getrennt werden. Wenn man »Zeichenkette« oder »String« sagt, meint
man in der Regel die Zeichenfolge (Wort oder Satz), um die es eigentlich geht. Manchmal meint man aber auch das Literal, das das Wort im Quellcode darstellt (z. B.
»Auto«). Manchmal ist aber auch die interne Darstellung im Rechner gemeint.
Im Rechner besteht eine Zeichenkette aus einer ununterbrochenen Reihung (Array)
von Zeichencodes, die durch den Zeichencode 0 (Terminatorzeichen) abgeschlossen
wird. Beachten Sie, dass es sich bei dem Terminatorzeichen nicht um das Zeichen ›0‹
mit Zeichencode 0x30, sondern das Zeichen NULL mit dem Zeichencode 0 handelt.
Typischerweise steht eine Zeichenkette in einem Puffer, der mindestens ein Byte
mehr hat als die Zeichenkette Zeichen hat, da das Terminatorzeichen mitgespeichert
werden muss. Der Puffer kann statisch oder dynamisch allokiert sein, und auf die einzelnen Zeichen der Zeichenkette kann mit einem Index zugegriffen werden. Als Programmierer müssen Sie darauf achten, dass bei Operationen auf Zeichenketten die
zugrunde liegende Pufferlänge nicht überschritten wird. Dies gilt insbesondere für
Operationen, die eine Zeichenkette verlängern.
Zur Verarbeitung von Zeichenketten (z. B. Vergleich, Kopieren) gibt es zahlreiche
Runtime-Library-Funktionen.
669
18
18
Zusammenfassung und Ergänzung
Zeiger
Zeiger dienen zum Zugriff auf Objekte über deren Adresse. Zu einem Zeiger gehören
immer drei Dinge:
왘
ein Name, über den der Zeiger angesprochen wird
왘
der Datentyp des Objekts, auf das der Zeiger zeigt
왘
die Adresse des Objekts, auf das der Zeiger zeigt
Bei der Definition eines Zeigers muss der Typ des Objekts bekannt sein, dessen
Adresse der Zeiger aufnehmen soll. Das folgende Codefragment zeigt die Definition
einiger Zeiger:
int *pointer;
// pointer ist ein Zeiger auf einen int-Typ
struct abc *p;
// p ist ein Zeiger auf eine Datenstruktur abc
int (*f)(int, float); // f ist ein Zeiger auf eine Funktion mit der
// Schnittstelle int xxx( int, float)
Bevor ein Zeiger verwendet werden kann, muss ihm die Adresse eines Objekts (Funktion oder Datum) zugewiesen werden. Mehr darüber erfahren Sie im Abschnitt
»Adressen«. Das folgende Codefragment zeigt die Zuweisung von Adressen:
// Benoetigte Strukturen, Variablen und Funktionen
struct abc
{
int x;
int y;
};
int variable1;
struct abc variable2;
int meinefunktion( int a, float b)
{
...
}
// Definition der Zeiger
int *pointer;
struct abc *p;
int (*f)(int, float);
670
Zeiger
// Zuweisung der Adressen
pointer = &variable1;
p = &variable2;
f = meinefunktion;
Es gibt in C keine automatischen Laufzeitprüfungen, die feststellen, ob ein Zeiger
einen gültigen Adresswert enthält. Arbeitet man in einem Programm mit nicht oder
nicht korrekt initialisierten Zeigern, stürzt das Programm ab. Es ist daher sehr sinnvoll, Zeiger, die noch keine gültige Adresse enthalten, mit dem Wert 0 (oder NULL) zu
initialisieren. Da 0 kein gültiger Adresswert ist, kann so geprüft werden, ob ein Zeiger
bereits eine Adresse enthält:
int *pointer = NULL;
if( pointer != NULL)
*pointer = *pointer + 1;
Sobald ein Zeiger einen gültigen Adresswert hat, kann über diese Adresse auf die Originaldaten zugegriffen werden. Zum Zugriff wird der *-Operator verwendet:
*pointer = 1;
(*p).x = 2;
18
*pointer = (*f)( 3, 4.5);
Beim Zugriff über einen Zeiger in eine Datenstruktur kann der Pfeil-Operator (->) verwendet werden:
p->x = 2;
p->y = p->x + 3;
/* wie (*p).x = 2; */
Beim Aufruf einer Funktion über einen Zeiger kann der *-Operator weggelassen
werden:
int x;
x = f( 5, 7.8);
Mit den Adresswerten in den Zeigern kann gerechnet werden. Näheres dazu erfahren
Sie im Abschnitt über Adressen.
671
18
Zusammenfassung und Ergänzung
Zeiger haben eine wichtige Bedeutung als Funktionsparameter, für den Zugriff auf
Arrays und den Aufbau dynamischer Datenstrukturen wie Listen oder Bäume. Siehe
die Kapitel 8, »Zeiger und Adressen«, Kapitel 14, »Datenstrukturen« und Kapitel 15,
»Ausgewählte Datenstrukturen«.
Zugriffsoperatoren ([], ->, ., *, &)
Zugriffsoperatoren dienen zum Zugriff auf Daten. Im Einzelnen unterscheiden wir:
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
[]
a[i]
Array-Zugriff
Zugriffsoperator
L
15
->
p->x
Indirektzugriff
.
a.x
Strukturzugriff
*
*p
Dereferenzierung
Zugriffsoperator
R
14
&
&x
Adress-Operator
Tabelle 18.20 Zugriffsoperatoren
Zugriffsoperatoren sind eng verknüpft mit den Datentypen, auf die zugegriffen werden soll.
Der Operator [] wird zum indizierten Zugriff in Arrays verwendet. Die Operatoren &
und * werden im Zusammenhang mit Adressen und Zeigern verwendet. Die Operatoren . und -> dienen zum direkten bzw. indirekten Zugriff auf einzelne Teile innerhalb
zusammengesetzter Datentypen (struct, union).
Weitere Informationen erhalten Sie in den Abschnitten über Adressen, Zeiger, Arrays
und Datenzugriff.
Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=,
^=, |= <<=, >>=)
Die übliche Wertzuweisung an eine Variable erfolgt über das Gleichheitszeichen. Auf
der linken Seite des Gleichheitszeichens steht das Objekt, das einen Wert bekommen
soll, auf der rechten Seite steht der Wert, der zugewiesen werden soll. Objekt und
Wert müssen dabei den gleichen Datentyp haben, oder es muss eine implizite Konvertierung – ohne Datenverlust – vom Typ des Werts in den Typ des Objekts möglich
sein.
672
Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=, ^=, |= <<=, >>=)
Es besteht eine gewisse Asymmetrie zwischen dem Objekt auf der linken Seite und
dem Wert auf der rechten Seite einer Zuweisung. Eine Zuweisung der Form
a = 1;
ist möglich, sofern a für eine numerische Variable steht, während eine Formulierung
wie
1 = a;
sinnlos ist, da der Konstanten 1 kein Wert zugewiesen werden kann.
Alles, was auf der linken Seite einer Zuweisungsoperation stehen kann, bezeichnet
man als L-Value. Alles, was auf der rechten Seite stehen kann, wird als R-Value
bezeichnet. Ein L-Value ist stets auch ein R-Value.
L-Values sind Variablen, aber nicht nur Variablen. L-Values können auch mittels der
Zugriffsoperatoren ([], ., ->, *) aus Variablen gewonnen werden. Beispiele:
struct x
{
int x1;
int x2;
};
int a;
int *b;
int c[100];
struct x d;
struct x *e;
18
a = 12;
b = &a;
c[a+1] = 17;
d.x1 = 123;
e = &d;
e->x2 = 456;
Der Zugriffsoperator & (Adress-Operator) liefert nur einen R-Value, da man die
Adresse eines Objekts nicht ändern kann.
Im Grunde genommen braucht man nicht mehr als die einfache Zuweisung mit dem
Gleichheitszeichen. Die weiteren Zuweisungsoperatoren sind prinzipiell vermeidbar
und dienen nur dem Programmierkomfort.
673
18
Zusammenfassung und Ergänzung
Zunächst gibt es die Inkrement- und Dekrement-Operatoren, um den Wert eines
L-Values um 1 zu erhöhen oder zu reduzieren. Diese Operatoren können ihrem Operanden vorangestellt oder angeschlossen werden. Im ersten Fall wird die Operation
ausgeführt, bevor der Wert in die weitere Berechnung eingeht. Im zweiten Fall wird
die Operation ausgeführt, nachdem der Wert in die weitere Berechnung eingegangen
ist.
Operation
Bedeutung
x++
x = x + 1 nach Verwendung von x in einem Ausdruck
x--
x = x – 1 nach Verwendung von x in einem Ausdruck
++x
x = x + 1 vor Verwendung von x in einem Ausdruck
--x
x = x – 1 vor Verwendung von x in einem Ausdruck
Tabelle 18.21 Zuweisungsoperatoren
Seien Sie sehr vorsichtig bei der Verwendung dieser Operatoren in komplexen Formeln. Sie können mit diesen Operatoren sehr kurz und knapp formulieren, aber auch
schwer zu erkennende Seiteneffekte erzeugen. Häufig verwendet man solche Operatoren in einfachen Formeln, z. B. in Schleifen, zum Herauf- oder Herunterzählen
eines Schleifenzählers:
int i;
for( i = 0; i < 100; i++)
{
...
}
In diesem Fall ist es egal, ob man in der Form i++ oder ++i zählt.
Schon bei einer einfachen Zuweisung ist es allerdings ein Unterschied, ob man
x = i++;
oder
x = ++i;
schreibt.
674
Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=, ^=, |= <<=, >>=)
Darüber hinaus gibt es eine Reihe von Operatoren, die eine Operation mit gleichzeitiger Wertzuweisung verbinden. Diese sind:
Operation
Bedeutung
x += y
x=x+y
x -= y
x=x–y
x *= y
x=x*y
x /= y
x=x/y
x %= y
x=x%y
x &= y
x=x&y
x ^= y
x=x^y
x |= y
x=x|y
x <<= y
x = x << y
x <<= y
x = x >> y
Tabelle 18.22 Operation und Wertzuweisung in einem Operator
Tabelle 18.23 zeigt zusammenfassend alle Operatoren dieses Abschnitts mit ihrer
Assoziativität und Priorität:
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
++
x++
Post-Inkrement
Zuweisungsoperator
L
15
--
x--
Post-Dekrement
++
++x
Pre-Inkrement
Zuweisungsoperator
R
14
--
--x
Pre-Dekrement
Tabelle 18.23 Alle Operatoren dieses Abschnitts im Überblick
675
18
18
Zusammenfassung und Ergänzung
Zeichen
Verwendung
Bezeichnung
Klassifizierung
Ass
Prio
=
x=y
Wertzuweisung
Zuweisungsoperator
R
2
+=
x += y
-=
x -= y
Operation mit
anschließender
Wertzuweisung
*=
x *= y
/=
x /= y
%=
x %= y
&=
x &= y
^=
x ^= y
|=
x |= y
<<=
x <<= y
>>=
x >>= y
Tabelle 18.23 Alle Operatoren dieses Abschnitts im Überblick (Forts.)
676
Kapitel 19
Einführung in C++
Der oft zitierte »Paradigmenwechsel« ist meist leeres Gerede, in C++
gibt es ihn jedoch wirklich. Dieses Kapitel bereitet Sie darauf vor.
Wenn Sie das Buch bis zu diesem Punkt durchgearbeitet haben, beherrschen Sie die
Grundlagen der Programmiersprache C; Sie kennen verschiedene Algorithmen, können neue Algorithmen umsetzen und eigene Programme schreiben.
Bereits im ersten Kapitel dieses Buches haben Sie erfahren, dass Programmiersprachen bestimmte Paradigmen unterstützen. Die Programmiersprache C basiert auf
dem prozeduralen Paradigma. Das prozedurale Programmieren haben Sie mittlerweile kennengelernt. C++ unterstützt dazu auch die Objektorientierung. Im verbleibenden Teil des Buches werde ich Ihnen das objektorientierten Paradigma und die
damit verbundene »Denke« vorstellen.
C++ bietet aber auch wichtige Erweiterungen gegenüber C, die gar nichts mit objektorientierter Programmierung zu tun haben. Dennoch bringen auch diese Erweiterungen eine deutliche Erleichterung bei der prozeduralen Programmierung. Als
Einstieg in C++ zeige ich Ihnen zuerst diese Veränderungen, bevor ich im folgenden
Kapitel die eigentliche objektorientierte Entwicklung erläutere.
Bei der Entwicklung von C++ ist viel Wert auf die Kompatibilität gelegt worden,
sodass sich die meisten C-Programme auch mit einem C++-Compiler übersetzen lassen. Lassen Sie sich aber von diesem Abschnitt nicht dazu verleiten, C++ auf ein leicht
erweitertes C zu reduzieren. Sie werden in den folgenden Kapiteln noch völlig neue
Möglichkeiten der Modellierung entdecken.
19.1
Schlüsselwörter
In C++ bleiben die bereits in C eingeführten Schlüsselwörter in ihrer Bedeutung
erhalten:
auto
double
int
struct
break
else
long
switch
Tabelle 19.1 Schlüsselwörter in C
677
19
19
Einführung in C++
case
enum
register
typedef
char
extern
return
union
const
float
short
unsigned
continue
for
signed
void
default
goto
sizeof
volatile
do
if
static
while
Tabelle 19.1 Schlüsselwörter in C (Forts.)
Zusätzlich wurde in C++ eine Reihe weiterer Schlüsselwörter eingeführt. Die meisten
dieser zusätzlichen Schlüsselwörter und deren Verwendung lernen Sie im Laufe des
Buches kennen:
asm
dynamic_cast
namespace
reinterpret_cast
try
bool
explicit
new
static_cast
typeid
catch
false
operator
template
typename
class
friend
private
this
using
const_cast
inline
public
throw
virtual
delete
mutable
protected
true
wchar_t
Tabelle 19.2 Neue Schlüsselwörter in C++
Auch die neuen Schlüsselwörter sind reservierte Wörter, die z. B. nicht zur Benennung von Variablen verwendet werden können. Wenn C-Programme diese Schlüsselwörter verwendet haben, lassen sie sich mit einem C++-Compiler nicht mehr
übersetzen und müssen vorher angepasst werden.
19.2
Kommentare
C++ bietet zusätzliche Kommentierungsmöglichkeiten, die die Dokumentation von
Code deutlich erleichtern. In C musste ein Kommentar ähnlich der Klammersetzung
immer gestartet /* und beendet */ werden. Mit dieser Methode können leicht mehrere Zeilen Kommentar ergänzt oder »Codebereiche« auskommentiert werden. Die
Syntax macht es aber umständlich, eine einzelne Zeile zu kommentieren. In C++ können mit // Kommentare erstellt werden, die bis zum Ende der Zeile reichen:
678
19.3
Datentypen, Datenstrukturen und Variablen
y = 2; /* C-Style-Kommentar */
// C++-Kommentar ab dem Start der Zeile
x = 1; // Hier steht ein C++-Kommentar für den Rest der Zeile
Solche Kommentare können überall in der Zeile starten. Prinzipiell ist es sogar möglich, solche einzeiligen Kommentare über das Zeilenende hinaus zu verlängern. Das
geht mit einem Backslash \ am Ende der Zeile:
A
B
z = 3; // Ein C++-Kommentar kann (unüblich!) auch so -> \
z = x * y; fortgesetzt werden
Von dieser Möglichkeit (A) sollten Sie aber keinen Gebrauch machen. Die Verwendung dieser Variante ist sehr unüblich, da die Weiterführung des Kommentars
extrem leicht übersehen wird, wie Sie hier vielleicht schon selbst erleben. In Zeile (B)
startet kein neuer Code, stattdessen handelt es sich auch hier immer noch um Kommentartext.
19.3
Datentypen, Datenstrukturen und Variablen
Auch beim Thema Datentypen, -strukturen und Variablen gibt es einige Neuigkeiten.
Die meisten Änderungen entfalten erst mit der Objektorientierung ihre volle Wirkung, einige Punkte möchte ich Ihnen aber vorab vorstellen.
19.3.1
Automatische Typisierung von Aufzählungstypen
19
Die Aufzählungstypen, die Sie aus C kennen, gibt es auch in C++:
enum wochentag {Mo, Di, Mi, Do, Fr, Sa, So};
Wenn Sie eine Variable des neuen Typs in C anlegen, müssen Sie so vorgehen:
enum wochentag wt_c; /* C-Stil */
Das Schlüsselwort enum muss hier vor dem Namen des Datentyps erneut angegeben
werden. In C++ kann die explizite Verwendung von enum entfallen. Mit der oben
erfolgten Anlage des enum ist durch die automatische Typisierung implizit ein neuer
Datentyp eingeführt worden, der im Weiteren direkt verwendet werden kann:
wochentag wt_cpp; // C++-Stil
679
19
Einführung in C++
19.3.2
Automatische Typisierung von Strukturen
So wie für Aufzählungstypen enum wird in C++ auch für Datenstrukturen struct implizit ein Datentyp erstellt. Die Deklaration erfolgt weiter, wie Sie es bereits kennen:
struct punkt
{
int x;
int y;
};
In C erfolgt dagegen die Verwendung bekanntlich so:
struct punkt p_c; /* C-Stil */
In C++ kann auch für eine struct wie für ein enum das zusätzliche Schlüsselwort an
dieser Stelle entfallen:
punkt p_cpp; // C++-Stil
Damit sind die in C oft verwendeten Konstruktionen
typedef struct punkt PUNKT
oder
# define PUNKT struct punkt
hier nicht mehr notwendig und auch nicht mehr üblich. Aufgrund der Kompatibilität mit C können die Konstruktionen aber weiterverwendet werden.
19.3.3
Vorwärtsverweise auf Strukturen
C++ erlaubt Vorwärtsverweise innerhalb von Strukturen. Damit kann später auf
Strukturen verwiesen werden, die an dieser Stelle noch nicht definiert sind. Dies ist
insbesondere notwendig, wenn es sich um Zirkelverweise handelt.
A
struct student;
struct bachelorarbeit;
struct student
{
char name[50];
680
// Vorwaertsverweis auf student
// Vorwaertsverweis auf bachelorarbeit
19.3
B
Datentypen, Datenstrukturen und Variablen
bachelorarbeit* ba; // Verweis auf bachelorarbeit
};
struct bachelorarbeit
{
char thema[200];
float note;
student* stud; // Verweis auf den studenten
};
Listing 19.1 Verwendung des Vorwärtsverweises
In dem Beispiel finden sich zuerst die Vorwärtsverweise auf die Strukturen student
und bachelorarbeit (A). Diese Angaben sagen nur, dass entsprechende Strukturen
noch bereitgestellt werden. In der Deklaration der Struktur student erfolgt dann ein
Verweis auf die Struktur bachelorarbeit (B) (hier noch nicht vollständig deklariert).
Beachten Sie dabei, dass in den Datenstrukturen nur Zeiger auf noch nicht deklarierte Strukturen vorkommen dürfen. Durch den Vorwärtsverweis wird es nicht
möglich, die Struktur selbst zu verwenden, da die einzelnen Felder und die Größe der
einzubindenden Struktur an dieser Stelle noch nicht bekannt sind. Das folgende Beispiel führt daher zu einem Fehler bei der Übersetzung:
struct student;
struct bachelorarbeit;
// Vorwaertsverweis auf student
// Vorwaertsverweis auf bachelorarbeit
19
struct student
{
char name[50];
bachelorarbeit ba; // Fehler, falls bachelorarbeit
// noch nicht vollständig deklariert wurde
};
Listing 19.2 Fehlerhafte Verwendung des Vorwärtsverweises
19.3.4
Der Datentyp bool
Sie haben im Kapitel über Logik erfahren, wie C den Datentyp int verwendet, um die
logischen Aussagewerte wahr und falsch mit den Zahlenwerten 1 und 0 abzubilden.
Genau genommen, ist das Ergebnis einer logischen Operation aber keine Zahl, sondern ein Wahrheitswert – eben wahr oder falsch. Viele andere Programmiersprachen
haben daher einen eigenen Datentyp für Wahrheitswerte.
681
19
Einführung in C++
Die in C verwendete Sichtweise hat dabei durchaus Vorteile, weil man mit logischen
Ergebnissen wie mit Zahlen rechnen kann und z. B. die Ergebnisse einer logischen
Operation zur Gesamtzahl der Ergebnisse mit dem Wert wahr aufaddieren kann.
In C++ hat man den Mittelweg beschritten und einen Datentyp bool eingeführt, der
die beschriebenen Eigenschaften vereinigt und mit int kompatibel ist.
Der Datentyp bool kann die Werte true und false annehmen . Gleichzeitig gilt aber
auch true = 1 und false = 0. Damit können Sie logische Ausdrücke, die Sie bisher mit
int gebildet haben, jetzt auch mit bool bilden, z. B. so:
bool b1, b2, b3, b4;
b1 = true;
b2 = !b1;
b3 = 3 > 2;
if( b2 != false)
{
b4 = b1 || b2;
}
Listing 19.3 Verwendung des Datentyps bool
Der Datentyp bool ist zwar kompatibel mit dem Datentyp int, aber nicht identisch. Er
verhält sich wie ein int, der nur die Werte 0 und 1 aufnehmen kann. Eine Wertzuweisung ist damit in beide Richtungen fehlerfrei möglich. Das Codefragment demonstriert dies, und
bool b = 7;
int ausgabe = b;
printf( "Der Wert von ausgabe ist '%d'\n", ausgabe);
erzeugt die folgende Ausgabe:
Der Wert von ausgabe ist '1'
Viele Entwickler setzen den Datentyp bool mit true und false kaum ein und verwenden weiterhin die Ihnen bereits bekannten Mechanismen aus C.
19.3.5
Verwendung von Konstanten
Sie haben die Verwendung symbolischer Konstanten in C kennengelernt, etwa um
Arrays zu definieren:
682
19.3
Datentypen, Datenstrukturen und Variablen
#define ANZAHL 10
int array[ANZAHL]; /* Vorgehen in ANSI-C */
Dies hatte den Grund, dass die folgende Definition in C nicht möglich war:
const int anzahl = 10;
int array[anzahl]; /* In ANSI-C nicht moeglich */
Genau dieses Konstrukt ist in C++ möglich und erwünscht.
const int anzahl = 10;
int array[anzahl]; // Typische Vorgehensweise in C++
Der definierte konstante Wert anzahl kann bereits zur Übersetzungszeit verwendet
werden.
Die Verwendung von Konstanten anstelle symbolischer Konstanten sollte in C++
bevorzugt werden. Symbolische Konstanten resultieren nur in einer Textersetzung
durch den Präprozessor. Echte Konstanten ermöglichen dem Compiler eine Typprüfung und erhöhen damit die Typsicherheit.
Der Präprozessor, der in C noch von entscheidender Bedeutung ist, verliert in C++
durch den Ersatz symbolischer Konstanten an Relevanz. Auch die Makros, die Sie
auch schon kennen, werden ersetzt – und zwar durch die Inline-Funktionen, die wir
noch in diesem Kapitel behandeln.
Die Include-Anweisungen #include und die Compile-Schalter wie #ifdef behalten
allerdings auch in C++ ihre Bedeutung.
19.3.6
Definition von Variablen
Während in C Variablen am Anfang eines Blocks definiert werden müssen, können
sie in C++ frei eingeführt werden. Voraussetzung ist nur, dass sie vor der erstmaligen
Verwendung definiert werden:
int x = 0;
x = 99;
A
int a = 0;
B
for( int i = 0; i<100; i++)
{
683
19
19
Einführung in C++
a += i;
}
C
Listing 19.4 Definition von Variablen in C++
In dem Beispiel wird die Variable a nach der Verwendung von x definiert (A), dies ist
in C nicht möglich. Ebenso können in C++ die Schleifenvariablen im Schleifenkopf
definiert werden, wie hier die Variable i (B). Die Schleifenvariable verliert ihre Gültigkeit mit dem Ende des zugehörigen Blocks in (C) und kann danach nicht mehr verwendet werden.
Im Moment mag Ihnen diese Erweiterung der Variablendefinition wie eine (nützliche) Spielerei erscheinen. Sie werden aber im folgenden Kapitel sehen, wie in C++ bei
der Definition von Variablen automatisch spezieller Code ablaufen kann. Die Stelle
der Definition bestimmt den Zeitpunkt der Codeausführung und gewinnt damit
extrem an Bedeutung.
19.3.7
Verwendung von Referenzen
In C erfolgt die Übergabe von Parametern an eine Funktion immer als Kopie. Eine
Änderung dieser Kopie innerhalb der Funktion ist für den Aufrufer ohne Wirkung.
Sollen Werte einer Variablen des Hauptprogramms innerhalb einer Funktion geändert werden, muss deren Adresse übergeben werden. Die Funktion erhält dann einen
Zeiger auf den entsprechenden Wert. Über den Zeiger wird der Wert dann aus der
Funktion heraus manipuliert.
Ich zeige Ihnen die Vorgehensweise in C noch einmal anhand einer swap-Funktion
zum Vertauschen zweier Variablenwerte:
A
B
C
D
void swap( int* a, int* b)
{
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
}
int main()
{
int x, y;
...
684
19.3
E
Datentypen, Datenstrukturen und Variablen
swap ( &x, &y);
return 0;
}
Listing 19.5 Tauschen von Variablen in C
In der Schnittstelle der Funktion erfolgt die Übergabe der Parameter als Zeiger auf
int (A). Der eigentliche Tausch der Werte erfolgt mithilfe der Hilfsvariablen tmp (B)
und der dereferenzierten Zeiger (B–D). Bei Aufruf der Funktion werden die Adressen
der zu tauschenden Variablen übergeben (E).
Innerhalb der Funktion werden die Adressen nur dereferenziert verwendet, um an
die dahinterliegenden Werte zu gelangen. An den eigentlichen Adressen sind wir ja
gar nicht interessiert.
C++ bietet Ihnen hier eine elegante und effiziente Alternative: die sogenannten Referenzen. Über Referenzen können Funktionen übergebene Variablen ändern!
Referenzen verhalten sich wie ein konstanter Zeiger, der bei jeder Verwendung automatisch referenziert wird. Eine Referenz ist ein L-Value. Sie kann also auf der rechten
und auf der linken Seite einer Zuweisung verwendet werden.
Um an der Schnittstelle einer Funktion eine Referenz zu übergeben, wird bei der Vereinbarung der Parameter dem Datentyp ein & hintenangestellt. Gelesen wird dies als:
»x« vom Typ Referenz Datentyp.
Die Schnittstelle der Funktion zum Tauschen der Werte sieht mit Referenzen so aus:
19
void swap( int& a, int& b)
Die Funktion swap liefert weiter keinen Rückgabewert und erhält jetzt aber zwei Parameter, a und b, vom Typ Referenz auf int.
Innerhalb der Funktion werden die Variablen dann wie »normale« int-Werte verwendet. Die gesamte Funktion zum Tauschen der Werte und ihr Aufruf sehen damit
so aus:
A
B
C
D
void swap( int& a, int& b)
{
int tmp;
tmp = a;
a = b;
b = tmp;
}
685
19
Einführung in C++
int main()
{
int x = 1, y = 2;
// ...
printf( "Vorher; %d %d\n", x, y);
E
swap( x, y);
printf( "Nachher; %d %d\n", x, y);
}
Listing 19.6 Tauschen von Variablen in C++ mit Referenzen
In (A) wird die Funktion, wie bereits beschrieben, deklariert. Der Tausch der Werte
über direkte Zuweisung erfolgt in (B–D). Hier sind durch die Verwendung der Referenzen keine Indirektionen mehr notwendig. Im Grunde handelt es sich bei Referenzen um Zeiger, die bei jeder Übergabe implizit dereferenziert werden. Der Aufruf der
Funktion erfolgt in (E) direkt mit den Variablen ohne einen Adress-Operator. Es
kommt zur erwarteten Ausgabe:
Vorher: 1 2
Nachher: 2 1
Referenzen sind nicht nur nützlich, wenn Sie übergebene Werte ändern wollen. Referenzen sind bei der Übergabe auch sehr effizient, da nur der Verweis anstelle des ganzen Elements über den Stack übergeben wird. Bei einer großen Struktur kann dies ein
deutlicher Vorteil sein.
Achtung!
Bei der Übergabe eines Wertes per Zeiger sieht man an der Schnittstelle sofort, dass
vom Zeiger referenzierte Werte in der Funktion geändert werden könnten.
Bei der Übergabe per Referenz ist dies für den Aufrufer nicht mehr so offensichtlich!
Das ist auch der Grund, warum viele C-Programmierer die Verwendung von Referenzen mit gemischten Gefühlen betrachten. Bei der Verwendung von Zeigern
muss der Aufrufer einer Funktion explizit die Adresse eines Wertes angeben und ist
sich damit dessen bewusst, dass die Funktion die Werte möglicherweise ändert.
Dies ist bei Referenzen nicht der Fall. Ich werde Ihnen in diesem Abschnitt aber noch
eine Möglichkeit zeigen, das Problem abzumildern.
Zuerst werden wir aber noch weitere Details der Übergabe per Referenz betrachten.
Dazu erstellen wir zuerst eine einfache Maximumfunktion und danach verschiedene
Varianten. Die erste Variante ist wenig überraschend:
686
19.3
Datentypen, Datenstrukturen und Variablen
int max1( int a, int b )
{
if( a >= b )
return a;
else
return b;
}
Listing 19.7 Eine bereits bekannte max-Funktion
Die Funktion gibt das Maximum der beiden als Kopie übergebenen Werte als Ergebnis zurück. Auch das Ergebnis wird als Kopie an den Aufrufer übergeben.
In der zweiten Variante werden Referenzen als Parameter übergeben:
int max2( int&
{
if( a >= b
return
else
return
}
a, int& b )
)
a;
b;
Listing 19.8 Alternative max-Funktion mit Referenzen
In der üblichen Verwendung
19
z = max1( x, y );
z = max2( x, y );
zeigt sich kein Unterschied.
Wenn Sie nun aber konstante Werte an die Funktion übergeben:
const int u =99, v= 100;
z = max2( 99, 100 ); // Compiler-Fehler
z = max2( u, v );
// Compiler-Fehler
erhalten Sie für den Aufruf von max2 eine Fehlermeldung. Der Datentyp der Konstanten kann nicht in eine Referenz auf ein int verwandelt werden.
In der Schnittstelle deklarierte Referenzen können und dürfen in einer Funktion verändert werden. Daher können solchen Referenzen keine konstanten Werte übergeben werden, da hier die Veränderung unmöglich wäre. Wenn Sie konstante Werte
übergeben wollen, müssen Sie die Referenzen in der Schnittstelle ebenfalls als konstant deklarieren.
687
19
Einführung in C++
Wenn Sie dem Compiler damit angeben, dass die Referenzen innerhalb der Funktion
nicht verändert werden, dann können Sie eine entsprechende Funktion auch mit
Konstanten aufrufen:
int max3( const int& a, const int& b )
{
if( a >= b )
return a;
else
return b;
}
Listing 19.9 Maximumfunktion mit konstanten Referenzen
Durch die Angabe von const int& in der Schnittstelle werden in der Funktion konstante Referenzen verwendet. Der Compiler erstellt hier bei Bedarf Zwischenvariablen, die für den Zugriff verwendet werden, und der folgende Aufruf wird möglich:
z = max3( 99, 100 ); // ok
z = max3( u, v );
// ok
Die Verwendung einer konstanten Referenz in der Schnittstelle sorgt nicht nur dafür,
dass die Funktion mit konstanten Werten aufgerufen werden kann. Sie zeigt dem
Benutzer einer Funktion auch an, dass die Funktion die übergebenen Referenzen
nicht verändern wird.
Funktionen, die die Übergabe per Referenz aus Performance-Gründen nutzen und
die übergebenen Werte nicht verändern, sollten die Referenzen als konstant deklarieren. Dadurch können sie den Performance-Vorteil der Übergabe per Referenz
gegenüber der Kopie nutzen. Anhand der Schnittstelle sieht der Benutzer aber, dass
die Funktion die Werte nicht ändert.
19.3.8
Referenzen als Rückgabewerte
Sie können Referenzen auch als Rückgabewerte verwenden. Ein Beispiel für eine entsprechende Nutzung ist die folgende Variante der max-Funktion:
int& max4( int& a, int& b )
{
if( a >= b )
return a;
else
688
19.3
Datentypen, Datenstrukturen und Variablen
return b;
}
Listing 19.10 Maximumfunktion mit Rückgabe einer Referenz auf int
Die Funktion max4 erhält Referenzen auf int und gibt eine Referenz auf int zurück.
Damit wird die die folgende Verwendung möglich:
void main()
{
int x = 1, y = 2;
A
max4( x, y ) = 4711;
printf( "y: %d, x: %d \n", y, x );
}
Listing 19.11 Verwendung der Referenz als Rückgabewert (L-Value)
Hier ist auch der Rückgabewert der Funktion eine Referenz und damit ein L-Value,
der auch auf der linken Seite einer Zuweisung stehen darf (A).
Aus der Funktion wird die Variable, die den größeren Wert enthält, als Referenz
zurückgegeben. In diesem Fall ist das y. Dieser Variablen wird dann als L-Value der
Wert 4711 zugewiesen.
Die Ausgabe ist damit:
19
y: 4711, x: 1
19.3.9
Referenzen außerhalb von Schnittstellen
Die bisherigen Beispiele legen zu Recht nahe, dass Referenzen hauptsächlich bei
Funktionsschnittstellen zum Einsatz kommen. Referenzen können aber auch als
Variablen in einem Programm verwendet werden. Hier werden sie oft auch als Aliasnamen für andere Variablen bezeichnet und müssen bei der Deklaration initialisiert
werden:
int i;
int& ref = i;
i = 1;
printf(" %d %d\n", i, ref);
689
19
Einführung in C++
i++;
printf(" %d %d\n", i, ref);
ref++;
printf(" %d %d\n", i, ref);
Listing 19.12 Verwendung einer Referenz als Aliasname
Im Beispiel ist ref eine Referenz auf i, und die beiden Variablen können völlig synonym verwendet werden. Das Programm liefert die folgende Ausgabe:
1 1
2 2
3 3
Dabei haben ref und i nicht nur immer den gleichen Wert, sie bezeichnen auch den
gleichen Speicherbereich. Der Adress-Operator auf ref und i angewandt, liefert das
gleiche Ergebnis. Damit produziert diese Zeile
printf(" %d \n", (&ref – &i));
die erwartete Ausgabe 0.
Generell müssen Sie beachten, dass die Referenz initialisiert werden muss:
int& ref = i;
Dabei handelt es sich um einen einmaligen Vorgang. Wie Sie schon gesehen haben,
steht ref danach synonym für i. Spätere Wertzuweisungen
ref = x;
ändern nichts an der Initialisierung und der erfolgten Zuordnung, sondern setzen
nur neue Werte.
19.4
Funktionen
Gerade im Bereich der Funktionen gibt es einige wichtige Erweiterungen in C++, die
ich Ihnen noch zeigen werde, bevor wir in die objektorientierte Programmierung einsteigen.
690
19.4
19.4.1
Funktionen
Funktionsdeklarationen und Prototypen
Sie haben es im Verlauf des Buches bereits als guten Programmierstil kennengelernt,
für alle Funktionen auch Funktionsprototypen zur Verfügung zu stellen. In C++ ist es
erforderlich, für jede Funktion, die gerufen wird, bevor sie definiert worden ist, einen
Funktionsprototyp bereitzustellen. Dies gibt dem Compiler die Möglichkeit, auch
über Modulgrenzen hinweg eine konsequente Typüberprüfung vorzunehmen.
Wenn Sie sich bereits an diesen guten Programmierstil gehalten haben, gibt es für Sie
keine Änderung, ansonsten ist hier die richtige Gelegenheit, damit zu beginnen.
Wie andere Warnungen und Fehlermeldungen des Compilers sollten Sie diese Anforderungen nicht als Behinderung bei der Arbeit begreifen. Stattdessen sollten Sie die
Warnung des Compilers als Hilfe auffassen, guten Code zu generieren. Der Compiler
gibt Ihnen eine Unterstützung bei der Fehlervermeidung, indem er Sie zwingt, Ihre
Absichten möglichst präzise zu formulieren. Dann kann er Sie auch frühzeitig darauf
hinweisen, wenn es Abweichungen gibt.
Für das Hauptprogramm main sind im C++-Standard zwei Varianten vorgesehen. Für
ein Hauptprogramm, das eine unbekannte Anzahl von Parametern erhält, typischerweise von der Kommandozeile:
int main( int argc, char** argv )
{
//...
return 0;
}
19
Alternativ der parameterlose Aufruf:
int main()
{
...
return 0;
}
Die return-Anweisung in der main-Funktion darf in C++ weggelassen werden. Der
Compiler ergänzt dann automatisch ein:
return 0;
Die allgemeine Regel, dass Funktionen mit einem Rückgabetyp auch einen Wert
zurückgeben müssen, gilt weiter1.
1 In den folgenden Kapiteln haben einige main-Funktionen nur eine Handvoll Zeilen. Dort habe ich
die return-Anweisung weggelassen, um das Beispiel in den Vordergrund zu rücken. Generell bin
ich aber dafür, sie zu verwenden.
691
19
Einführung in C++
19.4.2
Vorgegebene Werte in der Funktionsschnittstelle (Default-Werte)
Häufig haben Sie es in der Programmierung mit Funktionen zu tun, bei denen Sie
nicht immer alle Parameter benötigen. Wir sehen uns das am Beispiel einer Funktion
zum Hochzählen (und Ausgeben) von Werten an:
void hochzaehlen( int start, int ende, int inkrement)
{
int zaehler = start;
while ( zaehler <= ende)
{
printf("%d\n", zaehler);
zaehler += inkrement;
}
printf("\n");
}
Die Funktion wird z. B. mit den folgenden Parametern aufgerufen:
hochzaehlen ( 0, 10, 2);
und liefert das erwartete Ergebnis:
0
2
4
6
8
10
Im Laufe der weiteren Programmierung und Verwendung der Funktion stellen Sie
vielleicht fest, dass die Funktion überwiegend mit einem Inkrement von 1 eingesetzt
wird. Bei jedem Aufruf müssen aber alle Parameter mit angegeben werden. Sie würden Ihre Funktion hier gerne um eine sinnvolle Vorgabe ergänzen.
C++ bietet mit den sogenannten Default-Werten eine solche Möglichkeit. Dies sind
vorgegebene Argumentwerte, die an einer Funktionsschnittstelle verwendet werden, wenn vom rufenden Programm keine Werte übergeben wurden. Das rufende
Programm kann also bestimmte Werte im Aufruf einfach auslassen, die fehlenden
Werte werden in der Funktion ersetzt. Die Default-Werte werden auch Standardwerte genannt.
692
19.4
Funktionen
In C++ erhalten wir diese Vorgaben, indem die gewünschten Default-Werte wie eine
Zuweisung oder Initialisierung an den entsprechenden Parameter angefügt werden
(hier die 1 für inkrement):
void hochzaehlen( int start, int ende , int inkrement = 1 )
Jetzt können wir die Funktion mit zwei oder drei Parametern verwenden:
hochzaehlen( 0, 6, 2);
hochzaehlen( 0, 3);
und erhalten folgendes Ergebnis:
0
2
4
6
0
1
2
3
Bei Aufruf ohne den dritten Parameter wird automatisch der Standardwert verwendet.
Wenn auch der Endwert unseres Inkrements ende einen typischen Wert hat, kann
natürlich auch hier ein entsprechender Default-Wert festgelegt werden:
void hochzaehlen( int start, int ende = 5 , int inkrement = 1 )
Jetzt könnte die Funktion auch mit einem Parameter aufgerufen werden. Sogar für
den Startwert start können wir einen Default-Wert festlegen, sodass die Funktion
dann so aussieht
void hochzaehlen( int start = 0, int ende = 5 , int inkrement = 1 )
und ganz ohne Parameter aufgerufen werden kann:
hochzaehlen();
Für die Default-Werte gibt es einige naheliegende Einschränkungen:
왘
Default-Werte können immer nur für die »letzten« Argumente einer Funktion
(d. h. ab einer bestimmten Position, dann aber für alle folgenden Argumente)
angegeben werden.
693
19
19
Einführung in C++
왘
Beim Aufruf einer Funktion mit Default-Argumenten können immer nur Argumente vom Ende der Parameterliste weggelassen werden. Benötigen Sie einen
bestimmten Parameter, müssen alle davorliegenden Parameter mit angegeben
werden.
Hat eine Funktion mit Default-Argumenten einen Funktionsprototyp, gehört die
Festlegung der Standardwerte dorthinein:
void hochzaehlen( int start = 0, int ende = 5 , int inkrement = 1);
So werden alle Nutzer der Funktion über die Schnittstelle informiert. Für Bibliotheken kennt der Benutzer meist nur die Header-Dateien und nicht den Quellcode.
Durch die Default-Werte im Prototyp ist auch von außen sichtbar, welche Standardwerte angewandt werden. Naheliegenderweise dürfen die Defaults dann bei der Implementierung der Funktion nicht erneut definiert werden, da sonst Standardwerte
an zwei Stellen festgelegt würden.
void hochzaehlen( int start, int ende, int inkrement)
{
int zaehler = start;
//...
}
19.4.3
Inline-Funktionen
Insbesondere bei der objektorientierten Programmierung entstehen häufig sehr
kleine Funktionen, die einen Aufruf als Funktion eigentlich »nicht lohnen«. In C werden solche Funktionen vielfach als Präprozessor-Makros realisiert, um zu verhindern, dass Parameter aufwendig über den Stack übergeben werden müssen. Die
Risiken, die dabei auch existieren, haben Sie in Kapitel 9 zur Programmgrobstruktur
schon kennengelernt.
Für solche Aufgaben bietet C++ als Lösung Inline-Funktionen an. Inline-Funktionen
haben die Effizienz von Makros, verbunden mit der Konsistenz und Schnittstellensicherheit von Funktionen.
Um eine Funktion zu einer Inline-Funktion zu machen, stellen Sie der Funktionsdefinition das Schlüsselwort inline voran:
A
694
inline int max( int a, int b)
{
return a > b ? a : b;
}
19.4
Funktionen
void main()
{
int x = 10, y = 100
int m = max( x, y);
}
Listing 19.13 Verwendung von Inline-Funktionen
Überall dort, wo die Inline-Funktion verwendet wird, ersetzt der Compiler den Funktionsaufruf durch den entsprechenden Code der Funktion. Die Übergabe der Parameter über den Stack entfällt. Aus dem oben dargestellten Beispiel wird praktisch der
folgende Quelltext:
void main()
{
int x = 10, y = 100
int m = x > y ? x : y;
}
Listing 19.14 Praktisch entstandener Code nach Ersetzung der Inline-Funktion
Die Inline-Anweisung kann vom Compiler nicht immer ausgeführt werden, etwa
dann, wenn es sich um eine rekursive Funktion handelt. Sie ist daher als eine Empfehlung an den Compiler zu verstehen.
Sinnvoll ist das Inlining besonders für kleine Funktionen, die sehr oft gerufen werden und bei denen der Aufwand der Parameterübergabe über den Stack im Verhältnis zum Ausführungsaufwand hoch ist. Bei kleinen und einfachen Funktionen
bedeutet Inlining oft sowohl kompakteren Programmcode als auch schnellere Ausführung und ist auf jeden Fall zu empfehlen2.
Bei größeren Funktionen kann Inlining zu umfangreicherem Code führen, ergibt
aber trotzdem oft eine bessere Performance.
Da der Compiler den Code der Inlining-Funktion an der Stelle ihres Aufrufs einsetzt,
muss die Definition einer solchen Funktion bereits vorliegen, wenn sie verwendet
wird. Der Prototyp reicht hier nicht aus. Falls eine Inline-Funktion in mehreren
Modulen benutzt werden soll, muss ihre Definition daher in einer Header-Datei
2 Inlining kann zu erheblichen Performance-Gewinnen führen, z. B. bei einer Funktion, die in einer
Schleife oft gerufen wird. Sie können z. B. Funktionen in den Sortieralgorithmen als Inline-Funktionen deklarieren und damit erhebliche Performance-Gewinne erzielen – etwa bei den Funktionen insertion_h_sort oder adjustheap in Kapitel 13 zum Sortieren.
695
19
19
Einführung in C++
abgelegt sein, die dann von den Modulen inkludiert wird, die die Funktion verwenden3.
Es gibt noch eine weitere Methode, um Inline-Funktionen zu erstellen. Diese stelle
ich Ihnen im folgenden Kapitel im Rahmen der Objektorientierung vor.
19.4.4
Überladen von Funktionen
Die wichtigste nicht-objektorientierte Funktion von C++ ist das Überladen von Funktionen. Eine typische Situation ohne Überladung zeige ich Ihnen hier noch einmal.
Ich werde dabei von folgender Datenstruktur ausgehen:
struct punkt
{
int x;
int y;
};
struct
{
int
int
int
};
vektor
x;
y;
z;
Typische Funktionen für deren Ausgabe sind in der Programmiersprache C dann die
folgenden:
A
B
void print_punkt( punkt p)
{
printf( "Punkt: (%d, %d)\n", p.x, p.y);
}
void print_vektor( vektor v)
{
printf( "Vektor: (%d, %d, %d)\n", v.x, v.y, v.z);
}
void main ()
{
struct punkt p = {5, 4};
3 Dies widerspricht nicht der Anforderung, dass in einer Header-Datei kein Code stehen soll. Wie
bei einer Datenstruktur steht in einer Header-Datei nur eine formale Definition, die erst dann
Code wird, wenn sie in einem Programm verwendet wird.
696
19.4
C
D
Funktionen
struct vektor v = {1, 2, 3};
print_punkt( p);
print_vektor( v);
}
Listing 19.15 Ausgabe unterschiedlicher Strukturen
In (A) und (B) erfolgt die Definition einer Ausgabefunktion je Datentyp mit unterschiedlichen Namen, da gleichnamige Funktionen zum Fehler führen würden. Die
Ausgabe erfolgt dann unter Verwendung der passenden Funktionen zum jeweiligen
Datentyp in (C) und (D).
In C++ wird die Verwendung der Datentypen durch das Überladen von Funktionen
deutlich vereinfacht. Überladen bedeutet dabei, dass Funktionen nicht nur anhand
ihres Namens, sondern auch anhand ihrer Parametersignatur unterschieden werden. Damit kann es in C++ verschiedene Funktionen gleichen Namens geben, sofern
sich die Art und/oder Anzahl der Parameter unterscheiden.
Mit diesem Wissen erstellen wir nun zwei gleichnamige Ausgabefunktionen mit
unterschiedlicher Schnittstelle, die wir gleich verwenden:
A
B
C
D
void print( punkt p)
{
printf( "Punkt: (%d, %d)\n", p.x, p.y);
}
void print( vektor v)
{
printf( "Vektor: (%d, %d, %d)\n", v.x, v.y, v.z);
}
19
void main ()
{
punkt p = {5, 4};
vektor v = {1, 2, 3};
print( p);
print( v);
}
Listing 19.16 Ausgabe mit überladenen Funktionen
In (A) definieren wir die Ausgabe für den Datentyp punkt, in (B) für den vektor. Beide
Funktionen haben den Namen print.
697
19
Einführung in C++
In der Programmiersprache C würde der Compiler diesen Namenskonflikt nicht
akzeptieren. In C++ werden die Funktionen jedoch anhand ihrer verschiedenen Aufrufparameter differenziert.
In (C) wird die Funktion print für einen punkt gerufen, in (D) die Variante für einen
vektor.
Die passenden Funktionen werden vom Compiler anhand der Signatur ausgewählt,
und wir erhalten die erwartete Ausgabe:
Punkt (5, 4)
Vektor (1, 2, 3)
Verschiedene Funktionen gleichen Namens stellen in C++ kein Problem dar, sofern
sie anhand ihrer Parametersignatur unterschieden werden können.
19.4.5
Parametersignatur von Funktionen
Zur Unterscheidung und Auswahl (überladener) Funktionen dient neben dem Funktionsnamen die Parametersignatur. Zu der Signatur gehören Anzahl, Reihenfolge
und Typ der übergebenen Parameter.
Der Typ des Rückgabewertes geht nicht mit in die Parametersignatur ein. Ebenso
werden Default-Parameter nicht berücksichtigt.
Bei den folgenden Beispielen unterscheiden sich weder der Name noch die Parametersignatur der Funktionen:
int fkt1( int a) {return 0;}
void fkt1( int a) {return;}
int main()
{
fkt1( 1);
}
Das Programm kann nicht übersetzt werden, da die Funktionen fkt1 für den Compiler nicht unterscheidbar sind. Eine Funktion kann generell ohne Information zum
erwarteten Rückgabetyp aufgerufen werden, daher kann der Rückgabetyp kein
Unterscheidungskriterium sein. Ebenso wie der Rückgabetyp gehen Default-Werte
nicht mit in die Signatur ein:
698
19.4
Funktionen
int fkt2( int a) {return a;}
int fkt2( int a, int b=10) {return a + b;}
int main()
{
fkt2( 1);
}
Auch dieses Beispiel kann nicht übersetzt werden. Aus der Sicht des Compilers sind
die beiden Varianten von fkt2 nicht unterscheidbar.
19.4.6
Zuordnung der Parametersignaturen und der passenden Funktion
Um die Zuordnung der überladenen Funktionen zu ermöglichen, benutzt der Compiler ein einfaches Verfahren. Er verändert die Funktionsnamen intern so, dass Typ
und Anzahl der Parameter mit in den Namen der Funktion eingehen. Diese Modifikation wird auch als das Dekorieren von Namen oder auch Function Name Encoding
bezeichnet. Am folgenden Beispiel können Sie sehen, wie die Namensdekoration
konkret abläuft. Wir nehmen die Funktion mit dem Namen xxx als Ausgangspunkt:
void xxx( int a, char b, float c);
Der vom Compiler generierte Funktionsname im erzeugten Objektcode lautet:
xxx_Ficf
19
und beinhaltet die Parameter int, char und float. Über diesen Namen greift der Linker dann auf die Funktion im Objektcode zu. Sie müssen den Prozess nicht im Detail
verstehen. Sie müssen nur wissen, dass es ihn gibt und dass er als Teil des Sprachstandards normiert ist. Die Normierung ist notwendig, denn nur so kann die Interoperabilität der verschiedenen C++-Compiler sichergestellt werden. So ist es möglich, dass
Sie nicht nur die Bibliotheken verwenden können, die Ihr Compiler mitbringt, sondern auch bereits übersetzte Bibliotheken aus anderen Quellen nutzen können.
Im gesamten Prozess der Funktionsauswahl und Namensgenerierung ist insbesondere die Auswahl der Funktionen nicht immer leicht nachzuvollziehen. Zuerst wird
natürlich nach Funktionen gesucht, die eine exakt passende Signatur besitzen. Aber
durch die verschiedenen Möglichkeiten der Konvertierung wird die Auswahl dann
trickreich. So kann eine Funktion, die einen Parameter vom Typ float erwartetet,
auch durchaus mit einem int aufgerufen werden, eine Konvertierung von int nach
float ist ja verlustfrei möglich. Wir werden das Thema daher nicht näher betrachten
und lassen es bei dieser kurzen Übersicht bewenden.
699
19
Einführung in C++
19.4.7
Verwendung von C-Funktionen in C++-Programmen
Wenn Sie C-Funktionen in ein C++-Programm einbinden möchten, wird der gerade
besprochene Weg zur Namensermittlung für überladene Funktionen zum Problem.
Wie Sie gesehen haben, führt der Aufruf der Funktion
void xxx( int a, char b, float c);
dazu, dass der Linker eine Funktion mit dem Namen xxx_Ficf sucht.
Wenn diese Funktion mit einem C-Compiler übersetzt worden ist, wird es diese Funktion nicht geben, da der C-Compiler die Regeln der C++-Namensgenerierung natürlich nicht kennt und nicht anwendet.
Es muss also eine Möglichkeit geben, innerhalb von C++ für eine bestimmte Funktion, wie hier die Funktion xxx, das Function Name Encoding abzuschalten. Dazu
deklarieren wir die Funktionen als extern "C". Das kann für eine einzelne Funktion
passieren:
extern "C"
void abc(int, char b, float c);
Es kann aber auch ein ganzer Block als extern "C" ausgezeichnet werden:
extern "C"
{
void abc(int, char b, float c);
int aaa();
}
Mit dieser zusätzlichen Auszeichnung gibt es allerdings ein neues Problem, wenn sie
in einer Header-Datei verwendet wird, die parallel in C- und C++-Programme inkludiert werden soll. Innerhalb eines C-Compilers ist die Anweisung extern "C" unbekannt und soll es auch sein. Ein C-Compiler soll unabhängig von den C++-Standards
sein. Genau genommen, soll er nicht einmal wissen müssen, dass C++ existiert. Binden wir daher einen solchen Header in ein C-Programm ein, bedeutet dies dort einen
Fehler.
Wir müssen daher verhindern, dass die entsprechende Zeile für den C-Compiler
sichtbar wird. Es gibt innerhalb der C++-Compiler die vorbelegte symbolische Konstante __cplusplus. Wenn diese Konstante definiert ist, kann davon ausgegangen
werden, dass gerade ein C++-Compiler am Werk ist und die zusätzlichen extern "C"Anweisungen benötigt werden. Mit diesem Wissen können wir eine entsprechende
Header-Datei nun für die Bearbeitung durch den Präprozessor folgendermaßen
gestalten:
700
19.5
Operatoren
#ifdef __cplusplus
extern "C"
{
#endif
void abc(int, char b, float c);
int aaa();
#ifdef __cplusplus
}
#endif
Dabei verlassen wir uns darauf, dass die symbolische Konstante in C nicht existiert.
Aufgrund des nicht definierten __cplusplus sieht der C-Compiler nach dem Durchlauf des Präprozessors nur noch diesen Code:
void abc(int, char b, float c);
int aaa();
Für den C++-Compiler sind die extern "C"-Anweisungen aber weiterhin sichtbar:
extern "C"
{
void abc(int, char b, float c);
int aaa();
}
19
Entsprechend ausgestattet, können wir Header-Dateien erstellen, die sowohl von C
als auch von C++ verwendet werden können. Dies geht zwar auf Kosten der Lesbarkeit, hat aber dennoch große Vorteile. Wenn Sie sich die Header-Dateien von Bibliotheken – auch die Ihres Compilers – ansehen, werden Sie entsprechende Konstrukte
(und weitere dieser Art) finden.
19.5
Operatoren
Auch bei den Operatoren gibt es Erweiterungen und Ergänzungen. Insbesondere
werden in C++ einige ganz neue Operatoren eingeführt:
Operator
Bezeichnung
::
Globalzugriff
Tabelle 19.3 Neue Operatoren in C++
701
19
Einführung in C++
Operator
Bezeichnung
::
Class-Member-Zugriff
.*
Pointer-to-Member-Zugriff (direkt)
->*
Pointer-to-Member-Zugriff (indirekt)
new
Objekt Allokator
delete
Objekt Deallokator
Tabelle 19.3 Neue Operatoren in C++ (Forts.)
Den Operator für den Globalzugriff werde ich Ihnen im Folgenden direkt vorstellen,
die anderen Operatoren kommen erst mit der objektorientierten Programmierung
zum Einsatz.
19.5.1
Der Globalzugriff
Anders als in C können in C++ auch »verdeckte« globale Elemente sichtbar gemacht
werden. Dazu wird der Operator :: für den Globalzugriff verwendet. Ich zeige Ihnen
im folgendem Beispiel direkt seine Funktion:
A
int a = 1;
B
void main()
{
int a = 2;
// globale Variable a
// lokale Variable a
C
printf( "lokal: %d\n", a);
D
printf( "global: %d\n", ::a); // mit Scope Resolution
}
// ohne Scope Resolution
Listing 19.17 Der Globalzugriff
Im Programm wird die globale Variable a definiert (A). In der main-Funktion überdeckt die lokale Variable a (B) die globale Variable gleichen Namens. In der Programmiersprache C wäre der globale Wert damit nicht mehr adressierbar, und es wäre nur
ein Zugriff auf den lokalen Wert möglich (C). In C++ können Sie über den Globalzugriff das globale a mit der Notation ::a ansprechen (D), und es ergibt sich die folgende Ausgabe:
702
19.5
Operatoren
lokal: 2
global: 1
Über den Globalzugriff lässt sich der Konflikt an dieser Stelle entschärfen. Generell
sollten Sie Namensüberschneidungen natürlich dennoch vermeiden – im Allgemeinen indem Sie die Namen der lokalen Funktion ändern, da hier die Auswirkungen
leichter zu überblicken sind. Dass Sie Überschneidungen vermeiden sollten, liegt
auch daran, dass Sie mit der hier gezeigten Methode nur auf globale Variablen zugreifen können. Nicht-globale Überlagerungen können auch mit dieser Methode nicht
aufgelöst werden.
Der Operator :: für den Globalzugriff wird auch als 
Download