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