1 Wissenschaftliches Rechnen I Hubert Grassmann, WS 2001 10. Januar 2001 Die erste Version dieses Skripts wurde im Jahre 1997 erstellt; sie ist noch nie in der neuen“ Schreibweise verfaßt worden. ” Inhaltsverzeichnis 1 Einleitung 2 2 Zahldarstellung im Computer 2.1 Gleitkommazahlen und der IEEE-Standard . . . . . . . . . . . . . . . . 2.2 Runden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Ausnahmebehandlung: Divsion durch Null . . . . . . . . . . . . . . . . 3 6 8 9 3 Java-Grundlagen 3.1 Primitive Typen . . . 3.2 Operatoren . . . . . 3.3 Felder . . . . . . . . 3.4 Programmsteuerung 3.5 Methoden . . . . . . 3.6 Programmeinheiten . 3.7 Grafik . . . . . . . . 3.8 Dateibehandlung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 12 12 13 14 17 18 24 25 4 Kommunikation mitttels Computer 27 5 Crash2TeX 5.1 Maßangaben . . . . . . . 5.2 Stile . . . . . . . . . . . 5.3 Untergliederungen . . . . 5.4 Schriftarten . . . . . . . 5.5 Aufzählungen . . . . . . 5.6 Regelsätze . . . . . . . . 5.7 Kästen . . . . . . . . . . 5.8 Teilseiten (minipages) . . 5.9 Tabellen . . . . . . . . . 5.10 Mathematische Formeln 5.11 Einfache Zeichnungen . . 5.12 Eigene Befehle . . . . . . . . . . . . . . . . . . 31 32 33 33 34 34 34 35 35 35 36 37 39 6 Komplexität 6.1 Effektives und uneffektives Rechnen . . . . . . . . . . . . . . . . . . . . 6.1.1 Der Kreis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.1.2 Der größte gemeinsame Teiler . . . . . . . . . . . . . . . . . . . 41 41 41 44 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . INHALTSVERZEICHNIS 6.2 6.3 6.4 6.5 6.1.3 Ausrollen von Schleifen 6.1.4 Komplexe Zahlen . . . Polynome . . . . . . . . . . . Auswertung vieler Polynome . Matrixoperationen . . . . . . Zufallszahlen . . . . . . . . . 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 47 47 50 50 53 7 Suchen 55 8 Einfache Datenstrukturen und ihre Implementation 60 9 Ein paar Beispiele 70 10 Sortieren 10.1 Sortieren durch Zählen . . . . . . . . . . 10.2 Sortieren durch Verteilen (Vorsortieren) . 10.3 Sortieren durch Einfügen . . . . . . . . . 10.4 Sortieren durch Verketten . . . . . . . . 10.5 Sortieren durch Tauschen, (bubble sort) . 10.6 Partitionen und Inversionstabellen . . . . 10.7 Quicksort . . . . . . . . . . . . . . . . . 10.8 Binärdarstellung der Schlüssel . . . . . . 10.9 Sortieren durch direkte Auswahl . . . . . 10.10tree selection . . . . . . . . . . . . . . . 10.11heap sort . . . . . . . . . . . . . . . . . . 10.12Sortieren durch Mischen (merge sort) . . 10.13Natürliches Mischen . . . . . . . . . . . 10.14list merge sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 Das charakteristische Polynom 12 Graphen 12.1 Bäume . . . . . . . . . . . . . . 12.2 Gefädelte binäre Bäume . . . . 12.3 Paarweises Addieren . . . . . . 12.4 Baum-Darstellung des Speichers 12.5 Balancierte Bäume . . . . . . . 72 74 74 75 75 76 76 78 82 83 84 84 85 85 87 88 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89 93 98 100 101 106 13 Computeralgebra 108 13.1 Langzahlarithmetik: Rohe Gewalt . . . . . . . . . . . . . . . . . . . . . 108 13.2 Wirkliche Langzahlarithmetik . . . . . . . . . . . . . . . . . . . . . . . 110 13.3 Polynome und Formelmanipulation“ . . . . . . . . . . . . . . . . . . . 121 ” 14 Boolesche Algebren und Boolesche Funktionen Literaturhinweise: 123 1 EINLEITUNG 3 1. http://www.informatik.uni-siegen.de/psy/docu/GoToJava2/html/cover.html 2. http://www.uni-muenster.de/ZIV/Mitarbeiter/BennoSueselbeck/Java-ws99/ 3. Brandstädt, Graphen und Algorithmen, Teubner 1994 4. Flanagan, Java examples in a nutshell, O’Reilly (Köln) 1998 5. Appelrath/Ludewig, Skriptum Informatik – eine konventionelle Einführung, Teubner 1995 6. Golub/Ortega, Scientific Computing, Teubner 1996 7. Knuth, The Art of Computer Programming, Addison-Wesley 1973 8. Steyer, Java 1.2 – Schnell und sicher zum Ziel, Heyne 1998 (preiswert) 9. Krüger, Java 1.1 – Anfangen, Anwenden, Verstehen, Addison-Wesley 1997 (auch online) 10. Partl, Schlegl, Hyna, LATEX- Kurzbeschreibung, lkurz.dvi (bei emtex) 11. Scheller, Boden, Geenen, Kampermann, Internet: Werkzeuge und Dienste, Springer 1994 12. Solymosi/Grude, Grundkurs Algorithmen und Datenstrukturen, Vieweg 2000 13. Kofler, Linux, Addison-Wesley 1998 1 Einleitung Das Anliegen des Kurses Wissenschaftliches Rechnen I ist es zunächst, Kenntnisse zu vermitteln und Fertigkeiten zu entwickeln, die zur Nutzung von Computern zur Lösung mathematischer Probleme befähigen, und, weiter gefaßt, eine Einführung in die sinnvolle Nutzung vorhandener Computerprogramme zu geben. Dazu gehören solche Problemkreise wie • Was kann ein Computer, was kann er nicht? • Welche vorhandenen Programme kann man wofür nutzen? • Was tut ein in einer bestimmten Programmiersprache vorgelegtes Programm? Wie implementiert man bekannte Algorithmen in einer Programmiersprache? • Welche Möglichkeiten bieten Computer zur Kommunikation? • Wie erstellt man (mathematische) Texte? 1 EINLEITUNG 4 Die Antworten auf solche Fragen wollen wir in der Vorlesung und den zugehörigen Übungen sowie im Praktikum gemeinsam erarbeiten. Vorab will ich drei Zitate anbringen, die uns zu denken geben sollten. Das erste ist dem im Jahre 1957 erschienenen Buch Praktische Mathematik für Inge” nieure und Physiker“ von R. Zurmühl entnommen (S. 5/6): Auch das Zahlenrechnen ist eine Kunst, die gelernt sein will. Es erfordert ständige Sorgfalt und Konzentration, und auch dann lassen sich Rechenfehler nicht ausschalten. Zu deren Aufdeckung sind, soweit irgend möglich, laufende Kontrollen in die Rechnung einzubauen, und wo es solche nicht gibt, ist doppelt zu rechnen, z.B. von zwei Personen parallel. Es empfiehlt sich das Arbeiten mit dem Bleistift, um leicht korrigieren zu können. Die Anlage gut überlegter und übersichtlicher Rechenschemata . . . hilft Fehler vermeiden und erlaubt es vor allem, die Rechnung angelernten Hilfskräften zu überlassen. H. J. Appelrath und J. Ludewig geben in ihrem Skriptum Informatik“ von 1995 fol” gende Hinweise zum Programmierstil (S. 96): Programme sollten mit kleinstmöglichem Aufwand korrigier- und modifizierbar sein. Ein Ansatz zur Erlangung dieses Ziels ist eine hohe Lokalität durch enge Gültigkeitsbereiche. Größtmögliche Lokalität ist daher vorrangiges Ziel einer guten Programmierung! Ein Programm sollte daher folgende Merkmale aufweisen: • Die auftretenden Programmeinheiten (Prozeduren, Funktionen, Hauptprogramm) sind überschaubar. • Die Objekte sind so lokal wie möglich definiert, jeder Bezeichner hat nur eine einzige, bestimmte Bedeutung. • Die Kommunikation zwischen Programmeinheiten erfolgt vorzugsweise über eine möglichst kleine Anzahl von Parametern, nicht über globale Variablen. Schließlich gehen Golub und Ortega darauf ein, was ein gutes Programm ausmacht: • Zuverlässigkeit – Das Programm darf keine Fehler enthalten; man muß darauf vertrauen können, daß es das berechnet, was man zu berechnen beabsichtigt. • Robustheit – Das Programm . . . muß in der Lage sein, . . . ungeeignete Daten zu entdecken und sie in einer den Benutzer zufriedenstellenden Art und Weise zu behandeln. • Portierbarkeit – Gewöhlich erreicht man das dadurch, daß man das Programm in einer maschinenunabhängigen höheren Programmiersprache wie FORTRAN schreibt und keine Tricks“, die sich auf die charakteristischen Eigenschaften ” eines speziellen Rechners stützen, benutzt . . . • Wartungsfreundlichkeit – Es gibt kein Programm, das nicht von Zeit zu Zeit geändert werden muß, . . . und dies sollte mit möglichst geringer Mühe geschehen können. 2 2 ZAHLDARSTELLUNG IM COMPUTER 5 Zahldarstellung im Computer Wenn im Computer ein Programm abläuft, so befinden sich im Arbeitsspeicher Informationen, die auf unterschiedliche Weise interpretiert werden: einerseits sind dies Befehle“, die abzuarbeiten sind, andererseits Daten“, die zu bearbeiten sind. ” ” Was sind Daten“? ” Die kleinste Informationseinheit kann in einem Bit abgelegt werden. Ein Bit kann genau eine von zwei Informationen der Art ja/nein“, an/aus“, wahr/falsch“, 0/1“ usw. ” ” ” ” enthalten. Der kleinste adressierbare Datenbereich ist ein Byte, das sind acht Bit. Ein Byte kann also 256 verschiedene Werte annehmen. Solche Werte können als Zahlen (0 ≤ x ≤ 255), als logischer Wert, als Druckzeichen (’A’, ’B’, . . . ), als Speicheradresse oder als Maschinenbefehl interpretiert werden. Für viele Zwecke reicht diese Informationsmenge nicht aus, meist faßt man zwei oder vier Byte zu einem Maschinenwort“ zusammen, ein Wort kann also 216 = 65536 bzw. ” 232 = 4.294.967.296 Werte annehmen. Zur Darstellung negativer Zahlen gibt es mehrere Möglichkeiten. Wenn das höchstwertige Bit bei nichtnegativen Zahlen auf 0, bei negativen Zahlen auf 1 gesetzt wird, so schränkt dies den darstellbaren Zahlbereich auf ±215 ein. Eine anderer Möglichkeit ist es, die zur positiven Zahl x entgegengesetzte Zahl x1 dadurch zu gewinnen, daß man alle Bits umkippt, dies nennt man das Einerkomplement von x. Zum Beispiel x = 01101100 x1 = 10010011 = 64 + 32 + 8 + 4 = 108 Dann gilt x + x1 = 11111111 = 2^8 - 1. Wenn zum Einerkomplement noch 1 addiert (x2 = x1 + 1 = 10010100), so gilt x + x2 = 2^8 = 1|00000000 = 0, wenn man das neunte Bit einfach überlaufen läßt. Diese Darstellung der zu x entgegengesetzen Zahl hat die angenehme Eigenschaft, daß x - y = x + y2 gilt. Die Subtraktion ist auf die Addition des sogenannten Zweierkomplements zurückgeführt worden. Die Bearbeitung der Daten geschieht in sogenannten Registern, diese können je ein Wort (oder zwei Worte) aufnehmen, und ein Maschinenbefehl wie ADD AX,BX (hexadezimal: 03 C3, dezimal: 3 195) bewirkt die Addition des Inhalts des Registers BX zum Inhalt von AX. Neben arithmetischen Operationen (Addition, Subtraktion, Multiplikation, Division mit Rest) stehen z.B. bitweise Konjunktion, Shift-Operation usw. zur Verfügung. Um das Erstellen eigener Programme zu erleichtern, wurden seit den 60er Jahren höhe” re“ Programmiersprachen entwickelt. Sie sollten den Programmierer davon entlasten, den Maschinencode kennen zu müssen, die Adressen der Daten zu verwalten, komplexe Datenstrukturen (mehr als nur Zahlen) zu verwalten sowie häufig angewandte Befehlsfolgen bequem aufzurufen. Wir geben hier eine Ahnentafel der gebräuchlichsten Programmiersprachen an (nach Appelrath/Ludewig); 2 ZAHLDARSTELLUNG IM COMPUTER 1945 1952 1956 1959 1960 1962 1964 1967 1968 1969 Plankalkül (K. Zuse für Z3) Assembler Fortran 1 Algol60 LISP COBOL BASIC APL 3 PL1 SIMULA Algol68 Logo 6 2 1970 1974 1975 1977 1980 1987 1988 1990 1995 Prolog C APL2 Fortran77 Smalltalk-80 C++ Pascal MODULA-2 ADA Oberon Fortran90 Java Als nutzbarer Zahlbereich stehen also nur die Zahlen zwischen 0 und 65355 oder, wenn man das höchste Bit als Vorzeichenbit interpretiert, zwischen -32768 und 32767 zur Verfügung, das sind die Zahlen, deren Datentyp man als INTEGER bezeichnet. Da dies, außer bei der Textverarbeitung und ähnlichen Problemen, nicht ausreicht, stellen höhere Programmiersprachen zusätzlich Gleitkommazahlen zur Verfügung, die wie bei Taschenrechnern in der Form 1.23456E-20 dargestellt werden, dabei ist die Mantisse meist auf sechs bis acht Dezimalstellen und der Exponent auf Werte zwischen -40 und 40 beschränkt. Diese Schranken sind stark Hardware- und implementationsabhängig, auch 16-stellige Genauigkeit und Exponenten bis ±317 sind möglich. Es gibt hier Standards, an die sich eigentlich jeder Computerhersteller halten müßte, darauf gehen wir nun ein. Beim Rechnen mit Computerzahlen treten einige Probleme auf, deren elementarste wir hier besprechen wollen: x:= 40000, y:= 30000, z:= 20000 x + y: -30536 x + y - z: 25000 int(x+y-z): -15536 Die fehlerhafte Berechnung von x + y ist durch den Überlauf (x + y ≥ 215 ) begründet, wenn sich das Resultat im richtigen“ Bereich befindet, wird alles wieder gut. Solche ” Fehler können während des Programmlaufs erkannt werden, wenn bei der Übersetzung eine Bereichsüberprüfung eingestellt wird. Nach dem Standard darf so etwas nicht passieren. Auf alle Fälle wird 20000 - 10000 + 15000 richtig berechnet, das Kommutativgesetz der Addition ist also für Computerzahlen nicht gültig, das Gleiche gilt für das Assoziativgesetz. Wir kommen nun zu den Gleitkommazahlen (der Typ-Name real“ ist irreführend). ” Hierzu ist zunächst folgendes zu sagen: 1. Es gibt nur endlich viele Gleitkommazahlen. 1 formula translator common business oriented language 3 a programming language 2 2 ZAHLDARSTELLUNG IM COMPUTER 7 2. Die Bilder der vorhandenen Gleitkommazahlen sind auf der Zahlengeraden unterschiedlich dicht. Wenn z.B. mit 7stelliger Mantisse gerechnet wird, so ist die Nachbarzahl von 1.0 · 10−10 die Zahl 1.000001 · 10−10 , der Unterschied ist 10−16 . Die Nachbarzahl von 1.0 · 1010 ist 1.000001 · 1010 , der Unterschied ist 105 . Das hat folgende Konsequenzen: 3. Das Addieren einer kleinen Zahl zu einer großen ändert diese evtl. nicht (das macht aber nichts). 4. Die Subtraktion fast gleichgroßer Zahlen kann zu erheblichen Fehlern führen; da sich die führenden Ziffern gegenseitig auslöschen, werden hinten Nullen nachgeschoben. Dieser in der Dualdarstellung der Zahlen noch sichtbare Effekt wird aber nach der Konvertierung in die Dezimaldarstellung verschleiert. Deshalb lautet eine goldene Regel: Vermeidbare Subtraktionen fast gleichgroßer Zahlen sollen vermieden werden. Wir wollen einige Effekte vorstellen, die beim Rechnen mit Gleitkommazahlen auftreten: Die Terme √ 1 1 √ = √ 99 − 70 2 = 99 + 70 2 (1 + 2)6 ergeben bei (dezimaler) 10-stelliger Genauigkeit die Werte 0.0050506600000, 0.005050633885, 0.005050633890, bei 5-stelliger Genauigkeit erhalten wir 0.006, 0.0050507, 0.0050508, bei 3-stelliger Genauigkeit (so genau konnte man mit einem Rechenschieber rechnen) 0.300, 0.00506, 0.00506. Mein Taschenrechner mit 8stelliger Genauigkeit liefert in allen drei Fällen dasselbe: 0.0050506. Um die Subtraktion fast gleichgroßer Zahlen zu vermeiden, gibt es ein paar Tricks: 1 1 1 − = x x+1 x(x + 1) 1 1 2 2 − = + 2 x+1 x−1 x(x − 1) x √ √ 1 x+1− x= √ √ x+1+ x Wir berechnen noch die Summe nk=1 k1 , und zwar einmal vorwärts und einmal rückwärts: n 10 100 1000 vorwärts rückwärts 2.928 968 254 ... 54 5.187 377 520 ... 19 7.485 470 857 ... 65 Das zweite Ergebnis ist genauer, da zuerst die kleinen Zahlen addiert werden. Zu bemerken ist außerdem, daß diese divergente Reihe auf Computern konvergiert (Das Ergebnis liegt etwa bei 14). 2 2.1 ZAHLDARSTELLUNG IM COMPUTER 8 Gleitkommazahlen und der IEEE-Standard Gleitkommazahlen werden in einer Form dargestellt, die der Exponentenschreibweise ±m · 10e ähnelt. Anstelle der Basis 10 für Dezimalzahlen wird die Basis 2 verwendet, wenn ein Maschinenword von 32 Bit zur Verfügung steht, so könnte ein Bit für das Vorzeichen (0 für +, 1 für −), acht Bit für den Exponenten verwendet werden. Wenn negative Exponenten in Zweierkomplementdarstellung geschrieben werden, sind als Werte hierfür – 128 bis 127 möglich. Die restlichen 23 Bit stehen für die Mantisse zur Verfügung: x = b0 .b1 . . . b22 Die Darstellung heißt normalisiert, wenn b0 = 1, also 1 ≤ x < 2 ist. Wir überlegen, warum die normalisierte Darstellung Vorteile hat: 1 = (0.0001100110011 . . .)2 10 Da wir den unendlichen 2er-Bruch abschneiden müssen, behalten wir möglichst viele signifikante Stellen, wenn wir auf die führenden Nullen verzichten. Die normalisierte 1 ist 1.100110011 . . . · 2−4 . Für die Zahl Null = 0.0 . . . 0 exisiert keine Darstellung von 10 normalisierte Darstellung. Die Lücke zwischen der Zahl 1 und der nächstgrößeren Zahl heißt die Maschinengenauigkeit ; dies ist die kleinste Mashinenzahl, die bei der Addition zur 1 etwas von 1 verschiedenes ergibt. Hier ist also = 2−22 . Wenn wir mit × ∈ {+, −, ·, :} eine Rechenoperation mit exakten Zahlen und mit ⊗ die entsprechende Rechenoperation mit Maschinenzahlen bezeichnen, so gilt a ⊗ b = (a × b) · (1 + k) mit |k| < . Wir veranschaulichen die Verhältnisse, indem wir uns einen Spielzeugcomputer vorstellen, wo für jede Mantisse 3 Bit zur Verfügung stehen und der Exponent die Werte −1, 0, 1 annehmen kann, also b0 .b1 b2 · 2e . Die größte darstellbare Zahl ist (1.11)2 ·21 = 3.5, die kleinste positive ist (1.00)2 ·2−1 = 12 . Insgestamt sind die folgenden und die zu ihnen entgegengesetzten darstellbar: 0, 12 , 58 , 68 , 78 , 1, 1 14 , 1 12 , 1 34 , 2, 2 12 , 3, 3 12 . 0 1 2 3 Die Maschinengenauigkeit ist = 0.25. Die Lücke zwischen der Null und der kleinsten positiven Zahl ist viel größer als die Lücken zwischen den kleinen positiven Zahlen. Die oben genannte Darstellung von Zahlen wurde bei Rechnern der IBM 360/370 - Serie in den 70ger Jahren verwendet. Auf der VAX wurde die Maschinengenauigkeit dadurch halbiert (2−23 ), daß man die führende 1 in der normalisierten Darstellung wegließ und somit ein Bit für die Mantisse gewann. Bei der Darstellung der Null ging das allerdings nicht, denn hier gibt es keine führende 1 und .000000 bedeutet eben die Zahl 1. Hier mußte also eine Sonderregelung getroffen 2 ZAHLDARSTELLUNG IM COMPUTER 9 werden. Darüberhinaus ist es sinnvoll, die Lücken um die Null zu füllen. Für diese neue Darstellung wird das Exponentenfeld mitgenutzt, was die maximal möglichen Exponenten etwas einschränkt. Die im folgenden behandelte Darstellung wurde 1985 als IEEE-Standard (Institute for Electrical and Electronis Engineers) entwickelt. Dieser Standard wird von den führenden Chipherstellern Intel und Motorola sorgfältig eingehalten. Die Bedeutung der Bitfolgen ±a1 . . . a8 b1 . . . b23 ist aus folgender Tabelle abzulesen: a1 . . . a 8 0 . . . 00 = 0 0 . . . 01 = 1 0 . . . 10 = 2 ... 01 . . . 0 = 127 10 . . . 0 = 128 ... 11 . . . 10 = 254 11 . . . 11 = 255 numerischer Wert 0.b1 . . . b23 · 2−126 1.b1 . . . b23 · 2−126 1.b1 . . . b23 · 2−125 ... 1.b1 . . . b23 · 20 1.b1 . . . b23 · 21 ... 1.b1 . . . b23 · 2127 ±∞, wenn b1 = . . . = b23 = 0 not a number“ sonst ” Bemerkungen 1. Der Zahlbereich umfaßt etwa 10−38 bis 1038 , die Genauigkeit beträgt 6 bis 7 Dezimalstellen. 2. Anstelle des Exponenten e wird e + 127 gespeichert (biased representation, beeinflußte Darstellung). 3. Die Zahlen in der ersten Zeile füllen die Lücke zwischen 0 und den kleinsten normalisierten Zahlen, diese Zahlen heißen subnormal. Die Genauigkeit subnormaler Zahlen ist geringer als die normalisierter. 4. Der Vergleich zweier Zahlen kann durch bitweisen Vergleich von links nach rechts durchgeführt werden, wobei man beim ersten Unterschied abbrechen kann. 5. Durch Einführung des Symbols ∞ kann bei Division durch Null ein definierter Zustand hergestellt werden. 6. Die angegebene Darstellung heißt IEEE single precision, daneben gibt es double precision (64 Bit) und extended precision (auf PCs 80 Bit). 2.2 Runden Sei x eine reelle Zahl (ein unendlicher Zweierbruch); mit x− wird die nächstkleinere Gleitkommazahl bezeichnet, sie entsteht durch Abschneiden nach dem 23. Bit. Mit x+ wird die nächstgrößere Gleikommazahl bezeichnet. Wenn b23 = 0 ist, so setzt man b23 = 1. Wenn aber b23 = 1 ist, so erhält man x+ durch einige Bitüberträge. 2 ZAHLDARSTELLUNG IM COMPUTER 10 Die zur reellen Zahl x gerundete Gleitkommazahl ist entweder x− oder x+ , jenachdem, ob nach oben oder unten oder zur nächstliegenden Zahl gerundet werden soll (letzteres ist am meisten verbreitet). Falls die Entscheidung unentschieden ausgeht, ist die Zahl zu wählen, deren letztes Bit gleich 0 ist. Bei Multiplikationen/Divisionen mit Gleitkommazahlen ist das exakte Ergebnis oft keine Gleitkommazahl; der IEEE-Standard fordert, das das Resultat der korrekt gerundete Wert des exakten Ergbenisses ist. Dazu ist es z.B. bei Subtraktionen nötig, mit mindestens einem zusätzlichen Bit (nach dem 23.) zu rechnen; wir berechnen als Beispiel 1.00 · 20 − 1.11 · 2−1 = 1.00 · 2−3 mit dreistelliger Mantisse: 1.00 - 0.11|1 -------0.00|1 1.00 - 0.11 ------0.01 Wenn die 1 nach dem senkrechten Strich weggelassen werden wird, erhält man das falsche Ergebnis 2−2 . Dieses zusätzliche Bit war ursprünglich bei IBM-360-Rechnern nicht vorgesehen und ist es heute auf Cray-Rechnern immer noch nicht. Die Register einer korrekt arbeitenden Maschine, etwa im 387er Koprozessor, im 486 DX oder im Pentium sind also breiter als 32 Bit. Wenn kein Koprozessor vorhanden ist, so wird die Gleitkommaarithmetik vom verarbeitenden Programm emuliert und man ist zunächst nicht sicher, ob dabei der IEEE-Standard eingehalten wird. Weiter ist zu beachten, daß die in Registern vorhandenen Zahlen eine höhere Genauigkeit besitzen, als in einer Gleitkommavariablen abgespeichert werden kann. Solche Effekte werden wir in den Übungen behandeln: Vermeiden Sie Tests wie if (a == 0) oder do while (a > 0) sondern führen Sie Funktionen boolean null(a) oder boolean pos(a) ein; dabei gelangen die Registerinhalte in den Speicher und eine unausgeglichene“ Arithmetik wird ” verhindert. 2.3 Ausnahmebehandlung: Divsion durch Null In den 50er Jahren gab man als Ergebnis einer Division durch Null einfach die größte darstellbare Zahl aus und hoffte, daß der Nutzer dann schon merken würde, daß da etwas nicht stimmt. Das ging aber so oft schief (∞ − ∞ = 0), daß man später dazu überging, das Programm mit einer Fehlermeldung abzubrechen. Das muß aber nicht sein: Der Gesamtwiderstand R zweier parallelgeschalteter Widerstände R1 , R2 ist gleich R= 1 R1 1 + 1 R2 . 2 ZAHLDARSTELLUNG IM COMPUTER 11 Wenn R1 = 0 ist, so ist R= 1 0 1 1 1 = + R2 ∞+ 1 R2 = 1 = 0, ∞ was ja auch sinnvoll ist. Im IEEE-Standard sind sinnvolle Ergebnisse für 10 = ∞, x + ∞ = ∞, . . ., aber ∞ · 0, 0/0, ∞ − ∞, ∞/∞ liefern Ergebnisse vom Typ NaN (not a number), und weitere Operationen mit NaNs ergeben NaNs, so daß man den Fehler erfolgreich suchen kann. 1 Zu Abschluß schauen wir uns an, wie ein Pascal-Programm die Werte √ √ a + b − a √ √ a+b+ a und , die übereinstimmen, für a = 1000, b = 0, 001 berechnet, und zwar b mit den Zahltypen real, single und extended (bei der Verwendung von double erfolgte ein Laufzeitfehler: Division durch Null). real single extended 63245.56901478767400 63245.56901478767400 63245.56640625000000 63245.56640625000000 63245.56901475061060 63245.56901475193460 Bei Fortran unterscheiden sich bei real*4-Arithmetik die Ergebnisse bereits in der zweiten Dezimalstelle, bei real*8-Arithmetik in der letzten angezeigten: 63245.56601074943 Bei Java gibt es folgende Ergebnisse: 63245.56901639513 63245.569014751934 1.6431949916295707E-6 den ganzzahligen Teil subtrahiert: 0.569016395129438 0.5690147519344464 1.6431949916295707E-6 aber es kommen Zweifel auf, ob die dritte Nachkommastelle überhaupt zuverlässig ist. Mit einem Taschenrechner habe ich 63243.107 (!) bzw. 63245.569 berechnet“. Man ” fragt sich, welches denn nun eigentlich das richtige“ Ergebnis ist. Hier hat aber Java ” die Nase vorn, da es auch eine lange“ Arithmetik bereitstellt. ” Paarweise Addition Wenn viele Zahlen a1 , . . . , an zu addieren sind und man das naheliegende Verfahren s = s + ai , i = 1, . . . , n verwendent, so wird, wenn etwa a1 die restlichen Summanden deutlich dominiert, das Ergebnis fast gleich a1 sein, selbst wenn die Summe der restlichen Terme — für sich genommen — einen bedeutenden Beitrag liefer würde. In diesem Fall wäre es besser, a1 + (a2 + . . . + an ) zu berechnen, da das Assoziativgesetzt nicht gilt. Die beschriebene Situation kann man natürlich nicht voraussehen. 2 ZAHLDARSTELLUNG IM COMPUTER 12 Das Ergebnis könnte genauer sein, wenn wir schrittweise benachbarte Summanden addieren: ((a1 + a2 ) + (a3 + a4 )) + · · · Noch besser könnte es gehen, wenn die Paare jeweils betragsmäßig dieselbe Größenordnung hätten, die Folge der ai also zuvor sortiert würde. Eine genauere Analyse dieser Situation wird im 2. Semester vorgenommen. Wie man soetwas organisieren kann, werden wir im Lauf des Semesters sehen. Es wird dann ein höherer Organisationsaufwand betrieben (was Zeit kostet), aber die Genauigkeit des Ergebnisses kann eigentlich nicht schlechter werden. Ähnliche Effekte treten beim symbolischen Rechnen (bei Computeralgebrasystemen) auf; hier treten keine Genauigkeits-, sondern Zeit- und Speicherplatzprobleme auf. Auch hier muß man bei komplexen Algorithmen nachschauen, welche Operation zu welchen Zeitpunkt ausgeführt bzw. unterlassen werden sollte. Auch dazu später mehr. Linux Wir werden die Übungen und das Praktikum am Rechner durchführen, und zwar unter dem Betriebssystem Linux (von Linus Torvalds, Helsinki 1991). Hier soll kurz eine Übersicht über die wichtigsten Kommandos gegeben werden: passwd ändern des Paßworts man kommando zeigt das Manual zum kommando cd name wechselt in das Unterverzeichnis name cd .. wechselt in das übergeordnete Verzeichnis cd wechselt in das Heimatverzeichnis more name zeigt den Inhalt der Datei name ls zeigt Inhaltsverzeichnis ls -al zeigt Inhaltsverzeichnis detailliert ls -al | more zeigt Inhaltsverzeichnis detailliert, jeweils ein Bildschirm mkdir a erstellt Unterverzeichnis a cp a b kopiert die Datei a in die Datei b mv a b verschiebt die Datei a in die Datei b rm a löscht a (unwiederbringlich) chmod ändert Zugriffsrechte Das Kommando chmod bestimmt die Zugriffsrechte für den User (u), die Gruppe (g), und alle Welt (a); die Rechte sind Lesen (r = 4), Schreiben (w = 2) und ausführen (x = 1) (bei Verzeichnissen bedeutet x: Zugang). Also chmod 744 * bedeutet: ich kann alles, alle können nur lesen. Die Tastatur kann man sich (unter dem X-System) umdefinieren, indem man in die Datei .xinitrc zum Beispiel die Zeile xmodmap -e ’’ keycode 79 = braceleft ’’ einträgt. Dadurch erhält man die geschweifte öffnende Klammer auf der Taste 7 im numerischen Block (allerdings erst beim nächsten Einloggen). Die Codes der Tasten: 7 hat 79; 8 hat 80; 9 hat 81; 4 hat 83; 5 hat 84; 6 hat 85; 1 hat 87; 2 hat 88; 3 hat 89. 3 3 JAVA-GRUNDLAGEN 13 Java-Grundlagen Vorbemerkungen: Java ist nicht einfach“ zu erlernen, es ist eine richtige“ Programmiersprache, deren ” ” Regeln man sich erarbeiten muß. Java-Programme werden in einen Bytecode“ übersetzt, zur Ausführung ist ein In” terpreter notwendig. Daraus resultiert das Vorurteil, daß Java langsam sei. Daran ist etwas Wahres: Rechenintensive Programme können im Vergleich zu C-Programmen bis zum 10-fachen der Rechenzeit benötigen. Daran arbeiten die Java-Entwickler. Viele ” Beobachter gehen davon aus, daß Java-Programme in 2-3 Jahren genausoschnell wie C/C++-Programme laufen.“ Dieses Zitat stammt aus dem Jahr 1997. Das Abtract Windowing Toolkit (AWT), das Graphik-Routinen bereitstellt, werden wir manchmal nutzen, ohne daß hier auf dessen Funktionalität eingegangen wird. Die zu bearbeitenden Daten werden als Variablen“ bezeichnet (dies hat nichts mit dem ” gleichnamigen Begriff aus der Analysis zu tun!), Konstanten“ sind spezielle Variable, ” deren Wert beim Programmstart feststeht und der nicht mehr geändert werden kann. Jede im Programm verwendete Variable hat einen Namen, der aus den Zeichen A, B, ... , Z, a, b, ... z, 0, ... , 9 und dem Unterstrich zusammengsetzt ist; dabei darf das erste Zeichen keine Ziffer sein. Vor einer ersten Verwendung einer Variablen muß der Typ“ der Variablen festgelegt ” werden (die Variable wird deklariert“). Der Typ einer Variablen legt einerseits fest, ” wieviel Speicherplatz hierfür bereitgestellt werden soll; andererseits wird anhand der Typen während der Übersetzung festgestellt, ob mit den Operanden auszuführende Operationen erlaubt sind, eine Typunverträglichkeit führt zu einem Syntaxfehler. Dies ist für den Programmierer oft ärgerlich, aber immer hilfreich, weil sich bei Syntaxfehlern (neben Schreibfehlern) oft echte Programmierfehler entdecken lassen. Allerdings sind verschiedene Programmiersprachen unterschiedlich streng bei der Typ-Prüfung. Manche Sprachen lassen sogar arithmetische Operationen zwischen Zahlen und Wahrheitswerten zu. Auf der anderen Seite ist manchmal eine Typ-Umwandlung notwendig, hierzu werden spezielle Funktionen bereitgestellt ((float) 1.0 ), oder die Programmierer greifen zu dirty tricks“, wobei sie bei Java auf Granit beißen. Viele Fehler, die ” z.B. beim Programmieren in C++ dadurch entstehen, daß der Programmierer für die Speicherverwaltung selbst zuständig ist, können bei Java nicht mehr auftreten. Es gibt keine Pointer. 3.1 Primitive Typen Bei Java stehen die folgenden Grundtypen zur Verfügung (alle Zahlen sind vorzeichenbehaftet): 3 JAVA-GRUNDLAGEN Typ byte short int long 14 Bemerkung -128 ... 127 - 32768 ... 32767 2 Milliarden 92..07 ∼ 1019 (ÜA) bisher alle ganzzahlig float 4 Byte Gleitkommazahl, IEEE 754-1985 Standard double 8 Byte max. 10317 char 2 Byte Unicode-Zeichensatz, noch lange nicht ausgefüllt boolean 1 Bit true oder false Wir werden den Typ float nicht verwenden. 3.2 Größe 1 Byte 2 Byte 4 Byte 8 Byte Operatoren Mit Hilfe von Operatoren kann man aus Variablen Ausdrücke zusammensetzen, es stehen u.a. die arithmetischen Operatoren +, -, *, /, % zur Verfügung (% ist der modulo-Operator), darüberhinaus gibt es Zuweisungsoperatoren (man spart Schreibarbeit, aber es wird etwas unübersichtlich), den Inkrementoperator ++, den Dekrementoperator --, Bitoperatoren, Verschiebungsoperatoren, die Vergleichsoperatoren ==, !=, <, >, <=, >= sowie die logischen Operatoren &&, ||, !. Wenn in einem Ausdruck mehrere logische Operatoren vorkommen, so werden die Ergebnisse von links nach rechts ausgewertet. Die Auswertung wird abgebrochen, sobald das Ergebnis feststeht (das Ergebnis einer || -Operation ist true oder das Ergebnis einer &&-Operation ist false); das ist nützlich, wenn z.B. weiter rechts stehende Operanden gar nicht existieren. Bei Java werden die Operatoren in Ausdrücken, in denen unterschiedliche Zahltypen vorkommen, automatisch in den den übergeordneten Zahltyp konvertiert. Das sollte man ausprobieren. Es darf nämlich nicht falsch verstanden werden! Ein häufiger Fehler ist double x; x = 1 / 2; Nun, das ist völlig korrekt, es wird nur nicht das berechnet, was man vielleicht denkt: x ist eine Gleitkommazahl, also wird das Ergebnis gleich 0.5 sein. Nein, 1 und 2 sind ganzzahlig (im Gegensatz zu 1.0 und 2.0) und der Term 1/2 wird ganzzahlig berechnet, ist also gleich 0. An diese Stelle gehört ein ganz großes Ausrufungszeichen. Ein Programm setzt sich aus Anweisungen zusammen, z.B. a = b + c; x = y * z; Rechts vom Gleichheitszeichen steht ein Ausdruck, links eine Variable, die den Wert des berechneten Ausdrucks erhält. Einer Charakter-Variablen weist man mit a = ’a’ einen konstanten Wert zu; bei einer String-Variablen (einer Zeichenkette) sind die Anführungszeichen zu verwenden: 3 JAVA-GRUNDLAGEN 15 s = "abc"; numerische Konstanten werden als Gleitkommazahlen gedeutet, wenn irgendetwas darauf hinweist, das es welche sein sollen, z.B. 3.14, 2f, 0F, 1e1, .5f, 6.; wenn f oder F (für float) fehlt, aber ein Dezimalpunkt oder ein Exponent auf eine Gleitkommazahl hinweist, wird double angenommen. Obwohl nicht alle Operatoren eingeführt wurden, soll hier die Vorrangs-Reihenfolge dargestellt werden; wenn davon abgewichen werden soll, sind Klammern zu setzen: . [] () einstellig: + ! ++ -- instanceof zweistellig: * / % + - < <= >= > == != & ^ | && || ?: = Variablen, die einem der bisher genannten primitiven Datentypen zugehören, stehen nach ihrer Deklaration sofort zur Verfügung, können also belegt und weiter verarbeitet werden. Die Deklarationen müssen nicht, wie in anderen Sprachen, am Anfang eines Blocks stehen, jedenfalls aber vor der ersten Verwendung. Bei einer Division durch Null entsteht der Wert ±∞, wenn der Zahlbereich überschritten wird, ensteht NaN. 3.3 Felder Ein Feld (array) ist eine Folge von Variablen ein- und desselben Datentyps (der nicht primitiv sein muß, ein Feld von gleichartigen Feldern ist eine Matrix usw.). Um mit einem Feld arbeiten zu können, muß es 1. deklariert werden, int folge[]; int[] folge; double[][] matrix; 2. Speicherplatz erhalten, folge = new int[5]; damit stehen folge[0], ... , folge[4] zur Verfügung, 3. gefüllt werden: folge[1] = 7; Eine andere Möglichkeit besteht darin, das Feld sofort zu initialisieren: int folge[] = {1,2,3,4,5}; dann gibt folge.length die Länge an. 3.4 Programmsteuerung Um den Programmfluß durch die bearbeiteten Daten steuern zu können, benötigt man Auswahlanweisungen; es gibt die if-, if-else- und die switch-Anweisung. if ((a != b) || (c == d)) { a:= 2; // hier sind 2 Anweisungen zu einen Block zusammengefasst worden 3 JAVA-GRUNDLAGEN 16 b:= c + d; } else a:= 0; Die Auswertung der logischen Bedingungen erfolgt in der angegebenen Reihenfolge; es wird höchstens einer der Blöcke abgearbeitet. Der else-Zweig kann entfallen. Zählergesteuerte Wiederholungsanweisung: Syntax: for (init; test; update) anweisung; for (i = 1; i <= 10; i++) { a = a * a; b = b * a; } for (i = a.length - 1, i >= 0; i--) s = s + a[i]; Kopfgesteuerte Wiederholungsanweisung: Syntax: while (condition) anweisung; while (true) a = a * a; // dies ist eine Endlosschleife Die while-Schleife wird solange wiederholt, wie die angegebene Bedingung wahr ist. Innerhalb der Schleife sollte etwas getan werden, was den Wahrheitswert der Bedingung ändert. Fußgesteuerte Wiederholungsanweisung: Syntax: do anweisung; while (condition); do { a = a * a; b = b * a; } while (b < 20); Die do-Schleife ist nicht-abweisend, sie wird mindestens einmal durchlaufen. Schleifen können auch geschachtelt werden: for (i = 1; i <= 10; i++) for (j = 1; j <=10; j++) a[i][j] = b[j][i]; 3 JAVA-GRUNDLAGEN 17 Ich habe mit Pascal den Test gemacht, ob (bei nicht-quadratischen Feldern) die Reihenfolge des Durchlaufs der Schleifenvariablen eine Auswirkung auf die Rechenzeit hat und keinen Unterschied feststellen können. Man sollte aber beachten: In for-Schleifen wird normalerweise auf indizierte Variable zugegriffen und die Berechnung der Adresse eines Feldelements aus dem Index braucht Zeit. Man sollte solche Dereferenzierungen so selten wie möglich durchführen. Es ist sicher ein schlechter Progammierstil, wenn man gezwungen ist, die Berechnung innerhalb einer Schleife abzubrechen oder die Schleife ganz zu verlassen. Für diesen Notfall stehen die Anweisungen continue und break zur Verfügung, die auch noch mit Sprungmarken versehen werden können (das erinnert doch sehr an die goto-Möglichkeiten, mit der man sich Programm-Labyrithe schaffen kann). Schließlich sind noch Anweisungen zur Ein- und Ausgabe von Daten notwendig. System.out.print(Zeichenkette); gibt die Zeichenkette aus. Zeichenketten lassen sich mit dem +-Operator verknüpfen: wenn a eine numerische Variable ist, so wird sie innerhalb der print-Anweisung mittels " " + a in eine Zeichenkette umgewandelt und ausgegeben. Wenn println verwendet wird, so erfolgt ein Zeilenvorschub. Die Eingabe ist etwas schwieriger: Hier müssen mögliche Fehler explizit abgefangen werden (es muß aber nichts getan werden): Wir lesen eine ganze Zahl, indem wir eine Zeichenkette lesen und diese umwandeln: import java.io.*; public static int readint() { String s; int i, k; DataInput d = new DataInputStream(System.in); try { s = d.readLine(); i = 0; for (k=0; k < s.length(); k++) i = 10 * i + ((int)s.charAt(k) - 48); return i; } catch (IOException ignored) {} return(0); } Die Berechnung von i in der for-Schleife kann durch i = Integer.parseInt(s) ersetzt werden. import java.io.*; public class cat { public static void main(String args[]) 3 JAVA-GRUNDLAGEN { DataInput d = new DataInputStream(System.in); String line; try { while ((line = d.readLine()) != null) System.out.println(line); } catch (IOException ignored){} } } Noch ein Beispiel für das Rechnen mit beliebig langen Zahlen: import java.math.*; import java.util.*; import java.io.*; class v2 { public static BigInteger lies() // soll eine lange Zahl lesen { BigInteger a; String s; s = B.readstr(); a = new BigInteger(s); return(a); } public static void schreib(BigInteger a) { String s = a.toString(); System.out.println(" "+s); } public static void main(String args[]) { BigInteger a,b,c; a = lies(); schreib(a); b = lies(); schreib(b); c = a.multiply(b); schreib(c); } } 18 3 3.5 JAVA-GRUNDLAGEN 19 Methoden Rechenabläufe, die mehrmals (mit verschiedenen Parametern) wiederholt werden, oder die man zur besseren Strukturierung eines Programms vom Rest des Programms abtrennen will, realisiert man durch Methodenaufrufe. Dazu müssen also Methoden definiert werden. Die Kopfanweisung einer Methodendeklaration kann folgende Form haben: public static TYP name (f1, f2, ...) Dabei gibt TYP den Ergebnistyp der Metode an, der mit der return-Anweisung erzeugt wird; damit wird die Arbeit der Methode beendet. Das folgende Beispiel realisiert das sogenannte Hornerschema zur Berechnung des Werts f (x0 ) für ein Polynom f (x) = an xn + an−1 xn−1 + . . . + a1 x + a0 , dabei wird die Ausklammerung f (x) = (. . . ((an x + an−1 )x + an−2 )x + . . . + a1 )x + a0 genutzt, um das Berechnen von Potenzen zu vermeiden. import java.io.*; public class horner { public static double horn (double[] a, double x0) { int i, n; double f; n = a.length - 1; f = a[n]; for (i = n-2; i >= 0; i--) f = f * x0 + a[i]; return f; } public static void main(String arg[]) { double a[] = {1,1,1}; // also x^2+x+1 double h; h = horn(a, 2.0); System.out.println(h); } Falls keine Parameter übergeben werden (müssen), ist eine leere Parameterliste ( ) zu übergeben. Es sind rekursive Aufrufe möglich. Beim Aufruf eines Unterprogramms wird bei nichtprimitiven Typen direkt auf den Speicherplatz, den die Parameter belegen, zugegriffen (call by reference), d.h. auch die Eingangsparameter können verändert werden (Vorsicht!). Als aktuelle Parameter können nicht nur Variable, sondern auch Ausdrücke übergeben werden (die kann man naturgemäß nicht ändern). Eine sorgfältige Dokumentation aller Parameter ist unbedingt anzuraten. Eine einfache Sortiermethode: 3 JAVA-GRUNDLAGEN 20 public static void sort(int[] feld) { int i, j, l = feld.length - 1, tausch; for (i = 1; i <= l-1; i++) for (j = i+1; j <= l; j++) if (feld[i] >= feld[j]) { tausch = feld[i]; feld[i] = feld[j]; feld[j] = tausch; } } 3.6 Programmeinheiten Die Java-Programmeinheiten sind Klassen. In einer Klasse können Objekte und Methoden vereinbart werden. Ein ausführbares Programm (eine Applikation) enthält eine main-Methode, an die Parameter von der Komandozeile übergeben werden können. In jeder Klasse kann (zu Testzwecken) eine main-Methode implementiert werden. public class Klasse { public static void main(String arg[]) { int i; if (arg.length == -1) System.out.println("Eingabe erwartet"); else { for (i = 0; i <= arg.length - 1; i++) System.out.print(arg[i] + " "); System.out.println(); } } } Methoden für Objekte public class rational { int zaehler, nenner; // das Objekt rational hat 2 Komponenten public int ganzerTeil() { return this.zaehler / this.nenner; } 3 JAVA-GRUNDLAGEN 21 } import rational; import java.io.*; public class test { public static void main(String a[]) { rational c = new rational(); c.zaehler = 5; c.nenner = 2; System.out.println(c.ganzerTeil()); } } Die Superklasse object enthält Methoden, die für alle Arten von Objekten zugänglich sind, z.B. a.equals(b) Vergleich, nicht: a == b b = (TYP)a.clone() kopiert, nicht: b = a s = a.toString() ergibt eine (druckbare) Zeichenkette. Bei Deklarationen können (gleichnamige) Objekte in übergeordneten Klassen verdeckt oder auch überschrieben werden. Wenn man das vermeiden will, sollte man den Variablen ihren Klassen-Namen aufzwingen. Wenn eine Klasse nur Variable (sog Instanzmerkmale), aber keine statischen Methoden enthält, so entspricht dies dem Verbunddatentyp aus C oder Pascal (struct bzw. record). Objekte werden angelegt, indem eine Variable vom Typ der Klasse deklariert und ihr mit Hilfe des new-Operators ein neu erzeugtes Objekt zugewiesen wird: rational c = new rational(); Es wird eine neue Instanz der KLasse rational angelegt. Attribute von Klassen, Methoden und Variablen public: in der Klasse, in abgeleiteten Klassen und beim Aufruf einer Instanz der Klasse verfügbar; private: nur innerhalb der Klasse verfügbar; static: nicht an die Existenz eines konkreten Objekts gebunden (wie oben ganzerTeil, equals usw.), existiert solange wie die Klasse; final: unveränderbar (Konstanten). Klassenvariable (Attribut static) werden nur einmal angelegt und existieren immer; sie können von jedermann verändert werden (sie sind so etwas wie globale Variable). Die Bezugnahme geschieht mit klasse.name Nicht-statische Methoden werden beim Erzeugen einer Instanz der Klasse verfügbar: 3 JAVA-GRUNDLAGEN 22 class c { public c {}; public String i() {}; } Aufruf: c nc = new c(); String x; x = nc.i(); Die folgende Methode liefert nicht das, was man erwartet hat: public static void tausch (int i, int j) { int h = i; i = j; j = h; } Hier wird zwar lokal etwas vertauscht, dadurch ändern sich aber die Werte i, j nicht. Wenn die Parameter einer Methode einen nichtprimitiven Typ haben und verändert werden, wirks sich dies global aus. Parameter werden mittels call by value“ überge” ben; hier sind es Adressen, welche auch unverändert bleiben. Aber die Inhalte können verändert werden. Java importiert automatisch das Paket java.lang.*, dazu gehört z.B. die Klasse Math, hier finden man z.B. Math.sqrt(x), die Math - Methoden haben als Ergebnistyp double. Nützlich ist auch Integer.MAX_VALUE. Programmelemente – Zusammenfassung • Anweisungen (können auch Deklarationen enthalten), • Blöcke {anw1; anw2; dekl1; anw3; } //nur im Block gueltig Ein Block ist nach außen nur eine Anweisung (nach if(...) usw. darf nur eine Anweisung stehen, z.B. ein Block). • Methoden: sie haben Namen, evtl. Parameter und Rückgabewerte; ihre Parameter sind als Referenzen (Zeiger) angelegt (call by reference); • Klassen: sie enthalten Variable, die den Zustand von Objekten beschreiben, und Methoden, die das Verhalten von Objekten festlegen; Klassen sind schachtelbar: ihre Variablen können den Typ derselben Klasse haben; damit sind rekursive Datenstrukturen (Listen) realisierbar; 3 JAVA-GRUNDLAGEN 23 Beispiel: Polymome import import import import java.math.*; java.io.*; java.util.*; hgR.*; // Operationen mit rationalen Zahlen public class hgP { public hgR co; public int ex; public hgP next; public hgP() // Konstrukteur { this.co = hgR.assign(0); this.ex = 0; this.next = null; } public static boolean iszero(hgP p) { return (!(p instanceof hgP) || hgR.zero(p.co)); } public static void writep(hgP p) { if(!(p instanceof hgP)) System.out.print(" NULL "); else if (hgR.zero(p.co)) System.out.print(" zero "); while (p instanceof hgP) { if (hgR.positiv(p.co)) System.out.print("+"); hgR.aus(p.co); System.out.print("x^" + p.ex); p = p.next; } System.out.print(" "); } Wenn eine Klasse importiert wurde, reicht der Klassenname zur Dereferenzierung einer Methode / Variablen aus (nicht der ganze Name ist nötig); 3 JAVA-GRUNDLAGEN 24 • Pakete: Sammlungen von Klassen; jeder Methoden- oder Variablenname besteht aus drei Teilen: – Paketname, – Klassen- oder Objektname, – Methoden- oder Variablenname, z.B. java.lang.Math.sqrt Java weiß natürlich, wo es seine eigenen Pakete zu suchen hat, das obige steht in .../java/classes/java/lang/Math evtl. gibt es unter java auch nur eine zip-Datei, die gar nicht ausgepackt werden muß (der Compiler weiß, wie er damit umzugehen hat). • Applikationen (= Programme): Klassen mit main-Methoden; • Applets: werden aus einer HTML-Seite heraus aufgerufen. Eigene Pakete erstellt man, indem man den beteiligten Klassen sagt, daß sie beteiligt sind: package P; public class A { ... } Dies gehört in die Datei A.java, wie ja der Klassenname sagt. Woher kann aber der Compiler eine Information über das Paket P erhalten? Es gibt keine Datei mit diesem Namen. Man kann sein Paket aber auch nicht unter .../classes unterbringen, denn dort hat man keine Schreibrechte. Also: Die Datei A.java gehört ins Verzeichnis ./P Dort wird sie gesucht und gefunden. Pakete nutzt man mit import P.*; Die Klassen im aktuellen Verzeichnis . werden zu einem namenlosen Paket zusammengefaßt; deren Methoden können ohne Importe verwendet werden. Das Paket java.lang wird automatisch importiert, man hat also automatisch eine Funktion wie Math.sin zur Verfügung. Entwicklung eines Programms 1. Quelltext erstellen, z.B T.java, 2. Übersetzen des Quelltexts mittels javac T.java 3 JAVA-GRUNDLAGEN 25 ggf. werden Syntaxfehler angezeigt, die zu korrigieren sind. Wenn keine Fehler vorhanden sind, entsteht die Datei T.class dies ist keine ausführbare Datei (kein Maschinencode), sondern Java-Bytecode“, ” der auf allen Plattformen durch einen Java-Interpreter ausgeführt werden kann: java T 3.7 Grafik Ein Beispiel: import java.awt.*; // fuer Grafik import java.awt.event.*; // zum Abschluss public class sinus extends java.applet.Applet // Die Applet-Klasse wird erweitert; ihre Methoden stehen hier zur Verfuegung. { public static int s = 100; // 60 Pixel soll die Einheit sein, (nur) hier ist ggf. zu ndern; // s soll ein Teiler von 600 sein public static double p = 1.0 / s; // 1 Pixel public void paint(Graphics g) { int ya, y, i,j; for (i = 0; i <= 600; i = i+s) // Koordinatensystem g.drawLine(i,0,i,600); for (i = 0; i <= 600; i = i+s) g.drawLine(0,i,600,i); g.setColor(Color.red); g.drawLine(0,300,600,300); g.drawLine(300, 0, 300, 600); g.setColor(Color.blue); ya = (int)(-Math.sin(-300*p)*s) + 300; // Anfang for (i = 1; i<= 600; i++) { y = (int)(-Math.sin((i-300)*p)*s) + 300; // verschieben if ((y > 0) && (y < 600)) // drin ? g.drawLine(i-1,ya,i,y); // von alt nach neu ya = y; // weitersetzen } } public static void main(String[] arg) { 3 JAVA-GRUNDLAGEN 26 double w; Frame f = new Frame("Sinus"); f.setBackground(Color.white); f.addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent e) {System.exit(0);}} ); sinus m = new sinus(); f.setSize(600, 600); f.show(); } } 3.8 Dateibehandlung Dies wollen wir uns nur anhand einiger Beispiele (Rezepte) ansehen, ohne auf die Bedeutung der Funktionen einzugehen. import java.io.*; public class ausgabe // Guido Krueger { public static void main(String arg[]) { BufferedWriter f; String s; if (arg.length == -1) System.out.println("Dateiname erwartet"); else { try { f = new BufferedWriter(new FileWriter(arg[0])); for (int i = 1; i <= 100; i++) { s = "Dies ist die " + i + ". Zeile"; f.write(s); f.newLine(); } f.close(); } catch(IOException e) { System.out.println("Fehler"); } } } import java.io.*; 3 JAVA-GRUNDLAGEN 27 public class nummer { public static void main(String arg[]) { LineNumberReader f; String line; try { f = new LineNumberReader(new FileReader("nummer.java")); while ((line = f.readLine()) != null) { System.out.print(f.getLineNumber() + ": "); System.out.println(line); } f.close(); } catch(IOException e) { System.out.println("Fehler"); } } } Zum Schluß noch ein schönes Beispiel: import java.math.BigInteger; import java.util.*; // insbes. wird die Klasse Vector importiert, // das ist ein Feld, das wachsen kann public class factor // D. Flanagan { protected static Vector table = new Vector(); static { table.addElement(BigInteger.valueOf(1)); public static BigInteger factorial(int x) { if (x < 0) throw new IllegalArgumentException("x darf nicht negativ sein"); for (int size = table.size(); size <= x; size++) { BigInteger lastfact = (BigInteger)table.elementAt(size - 1); BigInteger nextfact = lastfact.multiply(BigInteger.valueOf(size)); table.addElement(nextfact); } return (BigInteger)table.elementAt(x); } 4 KOMMUNIKATION MITTTELS COMPUTER 28 public static void main(String[] arg) { for (int i = 1; i <= 50; i++) System.out.println(i + "! = " + factorial(i)); } Den Umgang mit Dateien (genauer: die notwendige Behandlung eventueller Ausnahmen) lernt man am Besten anhand von Beispielen, z.B. bei Flanigan, S. 175 - 212. 4 Kommunikation mitttels Computer Wenn Rechner miteinander verbunden werden, kann man von einem Rechner aus auf die Resourcen des anderen zugreifen: Man kann die auf der anderen Festplatte vorhandenen Daten und Programme nutzen. Die einfachste Verbindung zweier PCs geschieht durch ein Nullmodem, das sind vier Drähte, die bestimmte Anschlüsse der seriellen oder parallen Schnittstellen verbinden; dann braucht man noch etwas Software, die den Datenaustausch organisiert. In den USA wurde Ende der 60er Jahre begonnen, Computer militärischer Einrichtungen und von Universitäten zu verbinden. Das Internet, ein die ganze Welt umspannendes Netz von ca. 3 Millionen Computern, entstand Mitte der 80er Jahre. Die einfachste Art der Kommunikation ist das Versenden und Empfangen von Briefen: die elektronische Post, e-mail. Zur Verwendung von e-mail sind drei Dinge notwendig: Der Text eines Briefs muß vorhanden sein. Die Adresse des Empfängers muß bekannt sein. Es muß ein Programm vorhanden sei, das die Post verschickt. Zuerst zur Adresse: Jede Person, die Zutritt zum Internet erlangt, erhält mit seinem Login gleichzeitig eine e-mail-Adresse. Wenn sein Login-Name N lautet, er bei der Institution I arbeitet und im Land L wohnt, könnte seine e-Mail-Adresse [email protected] lauten, z.B. [email protected]. Daß die Nutzernamen innerhalb eines lokalen Netzes eindeutig sind, wird durch die jeweilgen System-Verantwortlichen gewährleistet. Daß sich die verschiedenen Rechner voneinander unterscheiden lassen, ist durch die Vergabe eindeutiger Rechnernummern durch die in jedem Land wirkenden Netzwerk-Informations-Zentren (NIC) zu sichern. Nun zum Verschicken: Wenn die Datei D den zu versendenden Text enthält, so schreibt man mail [email protected] < d dabei wird dem Programm mail die Datei D als Eingabedatei übergeben. Wenn man nur mail [email protected] schreibt, folgt die Aufforderung 4 KOMMUNIKATION MITTTELS COMPUTER 29 Subject: und man soll einen Betreff“ eingeben. Danach schreibt man seinen Text. Die Ein” gabedatei für mail ist jetzt die Tastatur. Man beendet eine Datei, indem man das Dateiende-Kennzeichen <cntrl>+Z (unter DOS) bzw. <cntrl>+D (unter Unix / Linux) eingibt, oder indem man eine Zeile schreibt, die nur aus einem Punkt besteht; letzeres ist am einfachsten. (Es gibt auch mailtools, die etwas mehr Komfort bieten.) Man kann eingegangene Post lesen, indem man das Programm mail ohne Parameter aufruft. Man erhält zunächst eine Liste der Absender eingegangener Briefe, aus denen man sich einen auswählen kann. Die herkömmliche gelbe Post wird von e-mail-Nutzern als snail mail“ bezeichnet, sie ” hat aber auch einige Vorzüge: Wirklich wichtige Dinge sollte man nicht ausschießlich per e-mail verschicken; aus diesem oder jenen Grund kann elektronische Post auch verlorengehen. Man soll sich also bestätigen lassen, daß die Nachricht angekommen ist. Auf der anderen Seite soll man wirklich vertrauliche Dinge auch nicht, zumindest nicht unverschlüsselt, dem Internet anvertrauen (fast jeder kann alles lesen). Allerdings denken die entsprechenden staatlichen Stellen intensiv darüber nach, wie es zu verhindern ist, daß unkontrolliert verschlüsselte Botschaften versandt werden. Mit dem Programm telnet, dem ersten Dienst, der im Internet eingerichtet wurde, kann man auf einem fremdem Rechner so arbeiten, als säße man davor. Man schreibt telnet rechnername und wird vom fremden Rechner zum Einloggen aufgefordert. Zum Einloggen muß man natürlich berechtigt sein, aber wenn man das nicht wäre, wüßte man sicher gar nicht den Namen des Rechners, den man nutzen will. Es ist allerdings zu beachten, daß das Paßword unverschlüsselt über die Leitung geschickt wird. Bei Verwendung des Dienstes ssh (security shell) ist diese Sicherheitslücke geschlossen, jedoch muß dieser Dienst auf beiden beteiligten Rechnern installiert sein, was man nicht ohne weiteres voraussetzen kann. Ein weiterer Dienst ist das File Transfer Protocol ftp. Es gibt an vielen Universitäten spezielle Rechner, die nichts anderes tun, als Daten für den Austausch bereitzuhalten. Dies sind die FTP-Server. Auf diese Rechner darf jeder zugreifen, man schreibt: ftp rechnername und wird nach seinem Namen gefragt username: Es ist üblich, daß ein Login mit dem Namen anonymous oder einfach ftp eingerichtet ist, mit diesem loggt man sich ein. Danach wird ein Paßwort“ abgefragt, das keins ” ist. Man wird gebeten, als Paßwort seine vollständige e-mail-Adresse anzugeben. Dann folgt der Prompt ftp> 4 KOMMUNIKATION MITTTELS COMPUTER 30 Normalerweise weiß man, in welchem Verzeichnis die Daten liegen, die man sich holen will. Wenn nicht, so muß man den Verzeichnisbaum durchstöbern. Mit dem Kommando ls schaut man sich Inhaltsverzeichnisse an, mit cd wechselt man Verzeichnisse. Mit get filename oder put filename holt man sich eine Datei bzw. schreibt eine Datei (falls man dies darf, dies ist nicht automatisch erlaubt, ggf. muß man sich mit dem Administrator des Servers in Verbindung setzen). Will man mehrere Dateien lesen oder schreiben, so gibt man am besten zunächst das Kommando prompt das schaltet Einzelrückfragen ab, danach z.B. mget *.dat oder mput a* Dabei werden ASCII-Dateien übertragen. Will man binäre Dateien, z.B. gepackte Programme, übertragen, so sendet man vorher das Kommando bin Mit quit kappt man die Verbindung. Mit den Diensten Gopher und WWW wurde ein bequemer Zugriff auf weltweit verstreute Informationen möglich. Hierzu gab es zunächst zeilenorientierte Web-browser“, ” also Netz-Stöberer“; das World Wide Web wurde 1993/94 bekannt, als mit dem Brow” ser mosaic eine grafische Oberfläche geboten wurde. Wenn die Texte im Format Hypertext Markup Language (HTML) erstellt wurden, so kann man sich durch einen Mausklick auf markierte Worte automatisch auf andere Rechner/Dateien weiterleiten lassen. Dieses System wurde von Marc Andreesen entwickelt, er war damals Student, später gründete er die Firma Netscape, deren gleichnamiger Browser mosaic so gut wie verdrängt hat. 4 KOMMUNIKATION MITTTELS COMPUTER 31 In die Gestaltung ihrer Internet-Seiten verwenden die Autoren immer aufwendigere Techniken, bunte, bewegte Bilder usw., oft einfach, um aufzufallen. Daraus resultieren teilweise erhebliche Ladezeiten. Einige Browser zeigen den aktuellen Datendurchsatz beim Laden an. Wenn dieser bei 1 ... 2 KByte / s liegt, kann man froh sein, bei 150 Byte / s wird man ungeduldig. Das Wort “stalled“ heißt: abgewürgt. Für den Hausgebrauch (wenn also beim Surfen Telefonkosten anfallen) ist die Verwendung der frei erhältlichen Browser lynx oder arena vorteilhaft, diese übertragen keine Bilder und Farben und unterstützen keine Maus-Operationen; den Links folgt man mit den Pfeil-Tasten. Wenn es einem einmal gelungen ist, ins Netz einzudringen (dazu muß man nur eine Adresse kennen), so wird man durch freundliche vorhandene Insassen um die ganze Welt geleitet. Auf die Heimatseite desInstituts für Mathematik gelangt man durch Eingabe der URL(uniform resource locator) http://www.mathematik.hu-berlin.de/ Dort kann man sich das Vorlesungsverzeichnis ansehen, zur Informatik, dem Rechenzentrum oder zu anderen mathematischen Instituten in Berlin bzw. Deutschland durchhangeln oder in den von Mitarbeitern und Studenten gestalteten persönlichen Seiten“ ” nachschauen, worauf diese hinweisen wollen. Da ist man schnell in der Gemäldegalerie von Bill Gates gelandet. Zur Gestaltung eigener Web-Seiten ist es zweckmäßig, zuerst mal eine Seite von einem Bekannten, die in HTML geschreiben ist, zu kopieren (der Bekannte ist auf die gleiche Weise zu seiner Vorlage gekommen) und sich als ASCII-Text anzusehen. Da erfährt man schon viel über die Struktur dieses Formats. Nun ändert man die persönlichen Angaben einfach ab und speichert die Datei unter einem vom SysOrg vorgegebenen Namen, oft Wellcome.html oder index.html in einem ebenfalls vorgegebenen Verzeichnis, in dem alle Nutzern Leserechte haben, ab; das Verzeichnis könnte z.B. public_html heißen. (Unter DOS ist die Dateinamenserweiterung .htm zu verwenden.) Eine Einführung in HTML wird in dem Buch“ HTML-Einführung von H. Partl gege” ben, das im Web u.a. unter der URL http://www.mathematik.hu-berlin.de/~richter/hein.html zu finden ist, gegeben. Eine Einführung ins Internet findet man in dem Buch von M. Scheller u.a., das ebenfalls im Web lesbar ist; fragen Sie unter [email protected] an. Wenn man an einem Rechner sitzt und auf einem anderen etwas editieren will, muß man dazu natürlich berechtigt sein. Man kann aber evtl. seinen favorisierten Editor nicht benutzen, weil der fremde Rechner den gar nicht kennt oder nicht weiß, wohin der denn die Daten schicken soll (der fremde Rechner weiß nichts über Sie). Hier hilft der Editor vi, der auf jedem Unix-Rechner installiert ist. Der Aufruf erfolgt mittels vi dateiname Mit dem Kommando :set nu kann man sich Zeilennummern anzeigen lassen. Nun kann man sich mit den Kursor-Tasten im Text bewegen, wenn das nicht geht, helfen 5 CRASH2TEX 32 die Tasten h, j, k, l und die Return-Taste. Das Zeichen unter dem Kursor streicht man mit x, man ersetzt es mit r neues-Zeichen. Die aktuelle Zeile streicht man mit dd. Um etwas in den Text einfügen zu können, muß man sich mittels i in den Insert-Modus begeben oder mit o bzw. O eine Leerzeile unter bzw. über der aktuellen einfügen. Nun kann man schreiben. Den Insert-Modus verläßt man mit der Escape-Taste. Zum Sichern schreibt man :wq. Das waren nur die allereinfachsten Kommandos, natürlich gibt es auch über vi ganze Bücher. 5 LATEX In diesem Abschnitt soll kurz ein Texterstellungssystem vorgestellt werden, das sich besonders zum Schreiben mathematischer Texte eignet. Heute ist auf PCs das Textverarbeitungssystem Word weitverbreitet. Hier hat man Menüs, an denen man viele Verarbeitungseigenschaften (Schriftgrößen usw.) einstellen kann, und man hat eine Schreibfläche, auf der der Text so dargestellt wird, wie er später gedruckt aussieht. Derartige Textverarbeitungssysteme machen aus dem PC eine komfortable Schreibmaschine. Die Datei, die den gespeicherten Text enthält, enthält außerdem zahlreiche Formatierungsinformationen, oft in Form nichtdruckbarer Zeichen. Eine derartige Datei sollte man nie von Hand“ verändern. ” Demgegenüber ist TEX ein Programm, das einen Text als Eingabe erwartet und daraus ein Ergebnis erarbeitet, das alle Informationen enthält, um fertige Druckseiten zu erstellen. TEX erfüllt also die Aufgabe eines Schriftsetzers; es ist als Programmiersprache eines Satzautomaten aufzufassen. TEX wurde 1978 bis 1982 von Donald Knuth an der Stanford University entwickelt. Einen weiten Anwenderkreis hat es durch das von Leslie Lamport geschaffene Werkzeug LATEX erhalten. Es versteht sich, daß dieser Text mittels LATEX (genauer: glatex von emtex erstellt wurde. Die Eingabedatei – eine reine ASCII-Datei – stellt man mit Hilfe eines Editors her. Ein Wort“ ist eine Zeichenkette, die kein Leerzeichen und kein Zeilenende-Kennzeichen ” enthält (dies sind Trennzeichen, die auf die späteren Wortabstände keinen Einfluß haben). Ein Absatz wird durch eine Leerzeile beendet, Zeilen- und Seitenumbrüche werden automatisch durchgeführt. Innerhalb des Textes können Befehle stehen, die entweder mit einem Backslash \ beginnen oder ein verbotenes Zeichen darstellen; verbotene Zeichen, die also eine besondere Bedeutung haben, sind #, $, &, ~, _, ^, %, {, }. Wenn ein # im Text nötig ist, so muß man \# schreiben. Befehle haben eventuell Argumente: \befehl[optional]{zwingend} Im Vorspann werden globale Eigenschaften des Texts festgelegt: 5 CRASH2TEX 33 • Papierformat, • Textbreite und -höhe, • Seitenköpfe, Numerierung. Unumgänglich sind die folgenden Angaben \documentstyle[Optionen]{Stiltyp} bzw. \documentclass... \begin{document} Dann folgt der Text. Am Ende steht \end{document} Der Name der Eingabedatei muß auf .tex enden, z.B. text.tex, der Aufruf latex text erzeugt die Datei text.dvi, die den formatierten Text in druckerunabhängiger Form enthält. DVI steht für device independent“, und das ist ernst zu nehmen: man ” kann eine DVI-Datei am PC erzeugen und an Unix-Rechner anschauen. Diese Datei wird schließlich von einem Treiber (z.B. dvips) zu einer druckbaren Datei weiterverarbeitet. Eingestellte Optionen gelten innerhalb einer bestimmten Umgebung. Eine Umgebung beginnt mit \begin{umg} und ended mit \end{umg}. Die Wirkung von Änderungsbefehlen (Zeichengröße, Schriftart usw.) endet beim aufhebenden Änderungsbefehl oder am Umgebungsende. Beispiele für Umgebungsnamen sind center, quote (beidseitig einrücken), flushleft, flushright. Es gibt auch namenlose Umgebungen {}, z.B. {\bf fetter Druck} erzeugt fetten Druck. Innerhalb der verbatim-Umgebung werden alle Zeichen (auch Leerzeichen und Zeilenumbrüche) im tt-Format genauso gedruckt, wie sie geschrieben wurden. 5.1 Maßangaben Als Maßeinheiten können Zentimeter cm, Millimeter mm, Zoll in, Punkte pt, Picas pc, die Breite eines Gedankenstrichs em und die Höhe des kleinen x ex verwendet werden. Ein Zoll sind 72,27 Punkte, ein Pica sind 12 Punkte. Beispiele: \setlength{\textwidth}{12.5cm} \setlength{\parskip}{1ex plus0.5cm minus0.2cm} \parskip bezeichnet den Absatzabstand, mit plus und minus kann man Dehn- und Schrumpfwerte angeben, die (wenn möglich) Anwendung finden; damit hat man ela” stische Maße“. Besonders elastisch ist das Maß \fill, z.B. vergrößert man Abstände mit 5 CRASH2TEX 34 \hspace{\fill} Der Parameter von hspace kann auch negativ sein. Sonderzeichen in speziellen Sprachen gibt es auch: \pounds, \OE, \’{o}, "A, "s, \today Dies stellt £, Œ, ó, Ä, ß, 10. Januar 2001 dar. 5.2 Stile Man kann sich aus vorgefertigten Dateien Standard-Einstellungen laden: \documentstyle[option, ...]{stil} Optionen beziehen sich jeweils auf eine .sty-Datei, z.B. 11pt, 12pt, twoside, twocolumn, bezier, german, a4. Als Stile gibt es book, article, report, letter. Ebenfalls im Vorspann wird der Seitenstil vereinbart: \pagestyle{stil} Als Stile kommen in Frage: empty (keine Seitennummern), plain (Seitennummern), headings (lebende Seitenüberschriften). Den Stil der aktuellen Seite kann man abweichend festlegen: \thispagestylestil. Abstände sind z.B. \baselineskip (Zeilenabstand), \parskip (Absatzabstand) und \parindent (die Größe des Einzuge der ersten Zeile eines Absatzes). z.B. \setlength{\parskip}{0cm} 5.3 Untergliederungen \chapter, \section, \subsection unterteilt in Abschnitte, \chapter gibt es nur in Büchern. Z.B. gibt man dem Abschnitt Gleichungssysteme“ die Seitenüberschrift Systeme“ durch ” ” \section[Systeme]{Gleichungssysteme} Die einzelnen Abschnitte werden automatisch durchnummeriert. Das kann man auch selbst beeinflussen: \setcounter{chapter}{-1} Mittels \tableofcontents erstellt man ein Inhaltsverzeichnis. Wenn dies am Anfang des Texts stehen soll, muß man LATEX zweimal denselben Text bearbeiten lassen, denn es wird stets die bisher vorhandene Information über das Inhaltsverzeichnis benutzt. 5 CRASH2TEX 5.4 35 Schriftarten Folgende Schriftarten stehen zur Verfügung: \rm (Roman, 10pt, dies ist der Standard), \em (emphasize, kursiv), \bf (fett), \tt (Schreibmaschine), \sl (slanted, geneigt), \sc (small capitals), \sf (sans serif). Dieser Text ist jeweils (immer zwei Worte weise) mit den vorhandenen Schriftarten gesetzt worden. Die Größe der Buchstaben ist eine der folgenden: \tiny, \small, \normal, \large, \Large, \LARGE, \huge, \Huge. 5.5 Aufzählungen \begin{itemize} \item erstens \item zweitens \end{itemize} ergibt • erstens • zweitens Außerdem gibt es enumerate und description, dabei werden die Items numeriert bzw. an Stichworten festgemacht. All das kann man auch verschachteln. Um die Markierung zu ändern, schreibt man z.B. in den Vorspann: \renewcommand{\labelitemiii}{+}} Dabei wird in der dritten Schachtelungsstufe als Marke ein + verwendet. 5.6 Regelsätze Hierunter versteht man die in mathematischen Texten übliche gleichartige Gestaltung der Form Satz 5.1 (Gauß) Dies ist der Satz von Gauß. Satz 5.2 (Fermat) Dies ist der Satz von Fermat. Man erreicht dies durch die Vereinbarung \newtheorem{satz}{Satz}[chapter] und den Aufruf 5 CRASH2TEX 36 \begin{satz}[Gau"s] ... \end{satz} Dabei ist satz der Bezeichner und Satz der zu setzende Titel; die Angabe von chapter in der Vereinbarung hat zur Folge, daß Sätze innerhalb eines Kapitels durchnumeriert werden. Wenn auch Lemmata, Folgerungen usw. gemeinsam durchnumeriert werden sollen, so kann man \newtheorem{lemma}[satz]{Lemma} \newtheorem{folg}[satz]{Folgerung} vereinbaren. 5.7 Kästen Durch \framebox[4cm][l]{Text} wird ein Kasten erzeugt, der linksbündigen Text Text enthält. Wenn anstelle von der Positionierungsangabe l ein r steht, wird der Text rechtbündig rechter Text Ohne Angabe wird er zentriert. angebracht. 5.8 Teilseiten (minipages) Durch eine Vereinbarung wie \begin{minipage}[pos]{breite} .... \end{minipage} wobei pos die Ausrichtung an der aktuellen Zeile bestimmt und die Werte b, t, c annehmen kann (bottom, top, centered), erstellt man einen Textteil, der logisch wie ein Wort behandelt wird; man kann also mehrere Teilseiten nebeneinander setzen (man nennt dies auch mehrspaltigen Satz). Ich mußte jedoch die Erfahrung machen, daß innerhalb von minipages die ansonsten automatisch durchgeführte Silbentrennung nicht funktionierte; hier muß man selbst Hand anlegen: wenn \- in die Silbenfugen eingefügt wird, entsteht dort eine mögliche Trennungsfuge. 5.9 Tabellen \begin{Tab-Typ}[pos]{spaltenformat} ... \end{Tab-Typ} 5 CRASH2TEX 37 Der Tab-typ kann tabular oder array sein, für pos kann t oder b stehen (Ausrichtung an aktueller Zeile), als spaltenformat sind möglich: l, r, c : links- oder rechtsbündig bzw. zentriert | : senkrechter Strich || : zwei Striche *{5}{|c}| ergibt |c|c|c|c|c| Die Zeilen einer Tabelle sind durch \\ zu trennen, der jeweilige Spaltenanfang wird durch & markiert (außer 1. Spalte; die Spalten werden richtig untereinandergesetzt), mit \\ \hline wird eine waagerechte Linie eingefügt. 5.10 Mathematische Formeln 1. In mathematischen Texten werden Variable stets in einer anderen als der Grundschrift gesetzt, normalerweise kursiv. Die Bezeichner von Standardfunktionen werden jedoch steil gesetzt. 2. Es gibt eine Vielzahl vordefinierter mathematischer Symbole, wie , , ∈ , ×, −→ usw, die nur in einer Mathematik-Umgebung vorkommen dürfen. Es gibt zwei derartige Umgebungen: eine für Formeln, die in der laufenden Zeile vorkommen, und eine für abgesetzte“ Formeln, die einen neuen Absatz eröffnen und zentriert ge” setzt werden. Als Umgebungsgrenzen können jeweils ein oder zwei Dollarzeichen genommen werden, z.B.: Es gilt $ \sum _{i=1} ^n i = \frac{(n+1)n}{2} $ ergibt Es gilt n i=1 i= (n+1)n , 2 während $$ \sum _{i=1} ^n i = \frac{(n+1)n}{2} $$ n (n + 1)n i= 2 i=1 ergibt. 3. Wir stellen die einige Möglichkeiten zusammen, für weitere möge man in entsprechenden Tabellen nachsehen. 5 CRASH2TEX 38 x2 , xa+b x2 , xa+b Exponenten Indizes Brüche Wurzeln Summe Integral x^2, \; x^{a+b} x_2, \; x_{a+b} \frac{c}{a+b} sqrt{x}, \; sqrt[n]{x} \sum \int Punkte Funktionen Striche etc. . . \ldots \; \cdots \; \vdots \; \ddots . . . · · · .. . . \exp, \; \lim exp, lim \overline{xxx}, \; \underbrace{yyy}_n xxx, yyy c a+b √ √ x, n x große Klammern \left[\matrix{1 & 2 \cr 3 &4} \right] 5.11 n 1 2 3 4 Einfache Zeichnungen Eine Bildumgebung wird durch \begin{picture}(breite, hoehe) eingerichtet; dabei bezeichnen breite und hoehe die Größe des Bildes, das an der aktuellen Stelle eingefügt werden soll. Die Maßzahlen beziehen auf die z.B. durch \setlength{\unitlength}{3cm} festgelegte Längeneinheit. Man stelle sich nun ein Koordinatensytem vor, dessen Ursprung in der linken unteren Ecke des Bildes liegt. Mit \put(x,y){objekt} positioniert man an der Stelle (x,y) ein Objekt, dabei ist es zulässig, daß x und y beliebige Werte haben; es wird auch außerhalb des Bildes gezeichnet. Einen Text bringt man z.B. so an: \put(x,y){text} \put(x,y){\makebox(0,0){text}} Aber eigenlich soll ja etwas gezeichnet werden. \setlength{\unitlength}{1cm} \begin{picture}(4,4) \put(0,1){\line(1,0){4}} \put(3,0){\line(0,1){3}} \put(0,0){\line(2,1){4}} \put(4,4){drei Linien} \end{picture} 5 CRASH2TEX 39 ~ ✬✩ drei Linien ✟ ✟✟ ✟✟ ✟✟ ✫✪ ✟✟ ✟ ✟ ergibt ✟ Eine Linie zieht man mit dem Befehl \line(x,y){proj} Dabei ist y/x der Tangens des Anstiegswinkels, für x und y sind die Werte 0, 1, 2, 3, 4, 5, 6 zulässig, sie müssen zudem teilerfremd sein. Der Wert proj gibt bei senkrechten Linien die Länge, sonst die Länge der Projektion auf die x-Achse an. Analog kann man mit \vector(x,y){proj} einen Pfeil zeichnen, hier sind als Abszissen Werte zwischen 0 und 4 zugelassen. Mit \circle{durchm}, \circle*{durchm} kann man kleine Kreise bzw. Kreisflächen (bis 1/2 Zoll Durchmesser) zeichnen. Größere Kreise zeichnet man am besten mit Hilfe des Bezier-Stils: Mit \bezier{punktzahl}(x1,y1)(x2,y2)(x3,y3) zeichnet man eine Parabel durch die Punkte P1 und P3, deren entsprechende Tangenten sich im Punkt P2 schneiden; durch Zusammensetzen solcher Kurven erhält man glatte Übergänge. ✘ ✘ ✘✘✘ ✁ ✁ ✁ ✁ ✁ ✁ ✘ ✘✘✘ ✘ ✘ ✘ \begin{picture}(5,3) \bezier{150}(0,0)(1,2)(5,3) \thinlines \put(0,0){\line(1,2){1}} \put(1,2){\line(4,1){4}} \end{picture} Bilder können auch verschachtelt werden. 5 CRASH2TEX 5.12 40 Eigene Befehle Häufig wiederholte Befehlsfolgen möchte man vielleicht zu einem einzigen neuen Befehl zusammenfassen, wobei evtl. noch Parameter übergeben werden sollen. Die folgenden Konstrukte schaffen neue Befehle bzw. überschreiben alte: \newcommand{\name}[parameterzahl]}{definition} \renewcommand{\name}[parameterzahl]}{definition} Die Parameteranzahl kann zwischen 0 und 9 liegen. Der Aufruf erfolgt durch \name{...} Beispiele: \newcommand{\xvecnmath}{(x_1, \ldots , x_n)} \newcommand{\xvecntext}{$(x_1, \ldots , x_n)$} Der Aufruf von \xvectmath ist nur im mathemematischen Modus möglich, der von xvecttext nur im Textmodus, da eben Indizes _“ in Texten nicht erlaubt sind. Als ” Trick für beide Modi kann man \newcommand{\xvecnallg}{\mbox{$(x_1, \ldots , x_n)$}} vereinbaren, dies ist in beiden Modi legal und liefert (x1 , . . . , xn ) bzw. (x1 , . . . , xn ). Wenn man aber (yk , . . . , yl ) erzeugen will, so kann man Parameter übergeben: \newcommand{\yvecallg}[3]{\mbox{$(#1_#2, \ldots , #1_3)$}} Der Aufruf hierfür würde \yvecallg{y}{k}{l} lauten. Falls jeder Parameter nur ein Zeichen enthält, kann man auch \yvecallgykl schreiben, das ist (im Quelltext) aber schwer lesbar. Eigene Definitionen von Befehlen, die man auch andernorts noch verwenden möchte, schreibt man am besten in eine eigene Start-Datei, die man am Anfang des Dokuments mit dem Befehl \input eingabe einliest. Mittels % leitet man einen Kommentar ein, der bis zum Zeilenende geht. \symbol{60} ergibt das Kleinerzeichen, \sim ist eine Tilde; es gibt auch \smile ... Zum Abschluß füge ich meine Standard-Anfangsdatei an. Überlegen Sie, warum die angegebenen Definitionen gewählt wurden und was sie bewirken. 5 CRASH2TEX \def\Bbb#1{{\bf #1}} \def\square{\hfill \hbox{\vrule\vbox{\hrule\phantom{o}\hrule}\vrule}} \documentstyle[german,bezier,12pt]{book} \textwidth15.5cm \textheight23cm \oddsidemargin0mm \evensidemargin-4.5mm \topmargin-10mm \pagestyle{headings} \markboth{l}{r} \setlength{\parindent}{0pt} \setlength{\unitlength}{1cm} \makeindex \begin{document} \newtheorem{satz}{Satz}[chapter] \newtheorem{lemma}[satz]{Lemma} \newtheorem{folg}[satz]{Folgerung} \newcommand{\p}{\par \noindent} \newcommand{\m}{\par \medskip \noindent} \newcommand{\diag}[8] { $$ \begin{array}{clclc} & #1 & \stackrel{\displaystyle #2}{\longrightarrow} & #3 \\ #4 & \Big\downarrow & & \Big\downarrow & #5 \\ & #6 & \stackrel{\displaystyle #7}{\longrightarrow} & #8 \end{array} $$ } \newcommand{\betq}[1]{\left| #1 \right| ^2} \newcommand{\bet}[1]{\left| #1 \right|} \newcommand{\pr}[2]{\langle #1, #2 \rangle} \newcommand{\bi}{\begin{itemize}} \newcommand{\jj}{\item} \newcommand{\ei}{\end{itemize}} \newcommand{\be}{\begin{enumerate}} \newcommand{\ee}{\end{enumerate}} \newcommand{\bs}{\begin{satz}} \newcommand{\es}{\end{satz}} \newcommand{\bl}{\begin{lemma}} \newcommand{\el}{\end{lemma}} \newcommand{\bfo}{\begin{folg}} \newcommand{\efo}{\end{folg}} \newcommand{\de}{{\bf Definition: }} \newcommand{\Pa}{\left\| } 41 6 KOMPLEXITÄT 42 \newcommand{\ap}{\right\| } \newcommand{\eps}{\; \epsilon \;} \newcommand{\la}{\longrightarrow} \newcommand{\kreis} { \bezier{100}(1.707,1.707)(2,1.414)(2,1) \bezier{100}(1,2)(1.414,2)(1.707,1.707) \bezier{100}(0.293,0.293)(0,0.586)(0,1) \bezier{100}(1,0)(0.586,0)(0.293,0.293) \bezier{100}(1.707,0.293)(2,0.586)(2,1) \bezier{100}(1,0)(1.414,0)(1.707,0.293) \bezier{100}(0.293,1.707)(0,1.414)(0,1) \bezier{100}(1,2)(0.586,2)(0.293,1.707) } 6 Komplexität Die Effizienz von Algorithmen mißt man an der verbrauchten Zeit und dem belegten Speicherplatz. Wir behandeln hier die Zeit-Komplexität. Wenn verschiedenen Implementierungen (verschiedene Algorithmen), die ein und dieselbe Funktion realisieren, mit denselben Eingabedaten und auf denselben Rechner verglichen werden sollen, genügt die Zeitmessung mit der eingebauten Rechneruhr, z. B. t = B.zeit(t). Wenn vom Rechner oder, allgemeiner, vom Maschinenmodell abstrahiert werden soll, die Daten aber gleich sind, so kann man die Operationen zählen; der konkrete Zeitbedarf hängt von den Ausführungszeiten auf dem konkreten Rechner ab. Wenn auch von den Eingabedaten abstrahiert werden soll, so berechnet man die Zahl der Operationen in Abhängigkeit von der Anzahl der Eingabedaten. Wenn ein Algorithmus für die Verarbeitung von n Daten 10 · n log n Rechenschritte benötigt und ein anderer braucht n2 + 10n, welcher ist dann schneller? Für n = 10 braucht der erste etwa 400 Schritte, der zweite nur 200. Aber für n = 100 braucht der erste 8000 Schritte und der zweite 10000 und für immer größere Anzahlen schneidet der erste Algorithmus immer besser ab. Wenn es um die Effizienzbestimmung bei großen Datenmengen geht, so nimmt man eine asymptotische Abschätzung des Aufwandes vor. Dabei sieht man von konstanten Faktoren ab: zwei Algorithmen, die Rechenzeiten von einem bzw. zehn Jahren benötigen, sind gleichermaßen inakzeptabel. 6.1 6.1.1 Effektives und uneffektives Rechnen Der Kreis Die Aufgabe besteht darin, die Koordinaten der Punkte des Einheitskreises zu berechnen, z.B. um Kreise zu zeichnen. 1. Methode: naiv 6 KOMPLEXITÄT 43 Für α = 0, 1, . . . , 359 berechne x(α) = cos(2π · α/360), y(α) = sin(2π · α/360). Dafür braucht man 720 Funktionsaufrufe. 2. Methode: effektiver Setze β = 2π/360, CB = cos β, SB = sin β. Wenn x(α), y(α) schon bekannt sind, so ist x(α + 1) = cos(α + β) = cos α cos β − sin α sin β, y(α + 1) = sin(α + β) = sin α cos β + cos α sin β. Also: x(0) := 1, y(0) := 0, Für α = 1, . . . , 359 : CA := x(α), SA := y(α), x(α + 1) := CA · CB − SA · SB, y(α + 1) := SA · CB + CA · SB. In der Schleife findet gar kein Funktionsaufruf mehr statt, aber die Rundungsfehler pflanzen sich fort. 3. Methode: Beachten von Symmetrien Wir lassen den Winkel α nur von 1 bis 45 laufen und weisen neben (x, y) auch (±x, ±y) und (±y, ±x) die entsprechenden Werte zu. Man sollte überprüfen, ob man wirklich nur 1/8 der Zeit dafür braucht. 4. Methode: Tabellieren Wenn viele Kreise zu behandeln sind (verschobene, mit verschiedenen Radien), so kann man die Daten des Einheitskreises tabellieren und neue Daten daraus berechnen. Bei kleinen Kreisen braucht man evtl. nur jeden 10. Punkt anzufassen. 5. Ganzzahlige Kreiskoordinaten Wenn Punkte auf den Bildschirm gezeichnet werden sollen, so braucht man ganzzahlige Koordinaten; man kann also im Integer-Bereich rechnen, das geht schnell. Im Bereich zwischen 0 und 45 Grad ist der obere Nachbarpunkt des Kreispunkts (x, y) entweder der Punkt (x − 1, y + 1) oder (x, y + 1). Wir prüfen also, welcher der Werte | (x − 1)2 + (y + 1)2 − r2 | und | x2 + (y + 1)2 − r2 | der kleinere ist. Wir berechnen x(y) in Abhängigkeit von y: x[0] := r für y = 1, . . . , √12 r berechne: A = (x[y − 1])2 + y 2 − r2 6 KOMPLEXITÄT 44 wenn abs(A) < abs(A − 2x[y − 1] + 1) ist, so setze x[y] = x[y − 1], sonst x[y] = x[y − 1] − 1. Ellipsenpunkte berechnet man, indem man die Koordinatenpunkte des Einheitskreises geeignet mit Faktoren multiplipliziert. Nochmals Symmetrien: Die Diedergruppe D4 u ❅ ❅ ❅ ❅ ❅ ❅ ❅ ❅ ❅ w ❅ ❅ ❅ ❅ ❅ ❅ ❅ s ❅ ✏✏❅ ❅ r ❅ ❅ d ❅ Ein Quadrat hat 8 Symmetrien: die Spiegelungen an den Achsen w, s, d, u und die Drehungen r0 , r, r2 , r3 um Vielfache von 90o . Die Nacheinanderausführung zweier solcher Symmetrien ist wieder eine Symmetrie, sie sind bijektive Abbildungen und für die Komposition von Abbildungen gilt das Assoziativgesetz; die Symmetrien bilden die sog. Diedergruppe D4 . Es gilt D4 = {e, r, r2 , r3 , s, rs = u, r2 s = w, r3 s = d}, diese Gruppe wird also durch die Elemente r, s erzeugt (wobei wir anstelle von s irgendeine Spiegelung einsetzen dürfen). Durch die Rechenregel sr = r−1 s sind bereits alle Produkte festgelegt. Nun ist aber auch jede Drehung ein Produkt zweier (geeigneter) Spiegelungen, etwa r = wd. Wir denken wieder an ein Koordinatensystem und wollen wissen, in welchen Punkt (i, j) bei der Drehung r übergeht. Hier haben wir die Spiegelungen w, d bevorzugt, da diese Operationen am einfachsten zu implementieren sind (der Koordinatenursprung ist links oben, das Quadrat mit der Seitenlänge n soll um seinen Mittelpunkt gedreht werden): d : (i, j) → (j, i), 6 KOMPLEXITÄT 45 w : (i, j) → (n − i, j), also r = wd : (i, j) → (j, i) → (n − j, i). 6.1.2 Der größte gemeinsame Teiler Den ggT zweier natürlicher Zahlen a, b berechnet man mit Hilfe des Euklidischen Algorithmus: r = 1; while (r != 0) { r = a % b; a = b; b = r; } return a; Wenn ggT (a, b) = d ist, so gibt es ganze Zahlen u, v mit d = u · a + v · b, die Zahl d ist die betragsmäßig kleinste (von Null verschiedene) Zahl, die sich als Vielfachsumme von a und b darstellen läßt. Beim Lösen diophantischer Gleichungen ist es hilfreich, eine derartige Darstellung von d zu kennen. Man kann eine solche Darstellung erhalten, wenn man die Schritte des Euklidischen Algorithmus rückwärts verfolgt. Wir können aber einen der Faktoren auch gleich mitberechnen: Wir setzen r0 = a; r1 = b; t0 = 0; t1 = 1; es sei r0 = q 1 r1 + r 2 . Im k-ten Schritt sei rk−1 = qk rk + rk+1 , tk−1 = qk tk + tk+1 , wobei wir das jeweilige qk in der ersten Zeile berechnen und in der zweiten Zeile zur Berechnung von tk+1 verwenden. Wir setzen nun voraus, daß der Term rk − btk durch die Zahl a teilbar ist; der Induktionsanfang 1 · a − b · 0 = a, 1·b−b·1=0 rechtferigt dies. Dann folgt aber auch, daß rk+1 − btk+1 = −qk rk + rk−1 − b · (tk−1 − qk tk ) 6 KOMPLEXITÄT 46 durch a teilbar ist (der Induktionsschritt geht von k − 1undk zu k + 1). Am Ende ist also d = rk+1 und mit v = tk+1 au = d − bv, woraus u berechnet werden kann. 6.1.3 Ausrollen von Schleifen Schauen Sie sich das folgende Programm an. In beiden Schleifen werden dieselben Rechenoperationen durchgeführt. In der zweiten Schleife werden mehrere nacheinanderfolgende Operationen zusammengefaßt und der Zählindex entsprechend vergrößert. import java.io.*; import java.lang.Math.*; public class ausrollen { public static int nn = 100 * 65536; // durch 8 teilbar ! public static byte a[] = new byte[nn]; public static int s; public static void init() { for (int i = 0; i < nn; i++) a[i] = (byte)(Math.random() * 100); } public static int summe1() { s = 0; for (int i = 0; i < nn; i++) s = s + a[i]; return s; } public static int summe2() { s = 0; for (int i = 0; i < nn - 7; i = i + 8) s = s+a[i]+a[i+1]+a[i+2]+a[i+3]+a[i+4]+a[i+5]+a[i+6]+a[i+7]; return s; } public static void main(String arg[]) { 6 KOMPLEXITÄT long t = B.zeit(0); init(); int s; t = B.zeit(t); s = summe1(); t = B.zeit(t); System.out.println(s); s = summe2(); t = B.zeit(t); System.out.println(s + " 47 "); } } Es treten zwei Effekte auf: 1. Die zweite Schleife benötigt bei Fortran nur etwa die Hälfte der Rechenzeit; dies trifft bei sehr langen Schleifen auch bei Pascal zu. Bei Java ist der Beschleunigungseffekt auch zu messen, ich konnte ihn nur nicht so deutlich nachweisen. Das liegt daran, daß Felder nicht beliebig groß gemacht werden können: Die Indizierung hat durch int-Zahlen zu erfolgen; wenn man ein Feld anlegen will, für das der Speicher nicht ausreicht, erhält man nicht etwa bei der Übersetzung eine Fehlermitteilung, sondern beim Versuch, die Klasse abarbeiten zu lassen, findet“ ” Java die Klasse nicht. Wenn ich wie oben ein Feld aus 10 Mio Bytes aufsummiere, braucht mein Rechner 3 bzw. 2 Sekunden. (Allerdings dauerte die Initialisierung über eine Minute.) Da float-Felder nicht so groß werden können, ergaben sich jeweils Rechenzeiten von 0 Sekunden. Wenn allerdings in der Schleife kompliziertere Operationen (Funktionsaufrufe) ausgeführt werden, geht die Beschleunigung zurück. 2. Die Ergebnisse der Summation unterschieden sich bei Fortran in der letzten Dezimalstelle. Welches Ergebnis war wohl richtiger als das andere? Hier sind alle Summanden positiv und innerhalb eines Achterblocks wahrscheinlich ungefähr von derselben Größenordnung, so daß die Zahl der abgeschnittenen Stellen gering bleiben könnte. Die Zahl s wird aber ständig größer, so daß sich s bei kleinen ai evtl. gar nicht verändert. Wahrscheinlich ist die zweite Rechnung vorzuziehen. (Wenn die Summe der ersten 216 natürlichen Zahlen im real*4Bereich berechnet wird, so ist beim ersten Verfahren die fünfte (von 6 bis 7) Dezimalstellen falsch, beim zweiten richtig). Wenn die Länge der auszurollenden Schleife nicht durch die Ausroll-Breite teilbar ist, muß das Schleifenende natürlich nachbehandelt werden. 6 KOMPLEXITÄT 6.1.4 48 Komplexe Zahlen Eine komplexe Zahl hat die Gestalt z = a + bi, wobei a, b reell sind und i2 = −1 gilt; diese Darstellung mit Real- und Imaginärteil heißt die kartesische Darstellung von z. Für die Polarkoordinaten des Punkts (a, b) gilt a = r cos ϕ b = r sin ϕ, √ dabei ist r = |z| = a2 + b2 und ϕ das Argument von z. Bei der Berechnung des Betrags kann ein Überlauf vorkommen, den man dadurch abfangen kann, daß man max(|a| , |b|) ausklammert. Das Argument bestimmt man am einfachsten durch ϕ = arccos(a/r). Während sich die Addition und Subtraktion komplexer Zahlen am einfachsten in der kartesischen Darstellung durchführen läßt, sind die Multiplikation, Division, das Wurzelziehen oder allgemeiner die Potenzierung in der Polardarstellung einfacher: bei der Multiplikation multiplizieren sich die Beträge und addieren sich die Argumente. 6.2 Polynome Werte berechnen Für das Polynom f (x) = a0 xn + a1 xn−1 + · · · an−1 x + an soll der Wert f (b) berechnet werden. Die naive Methode geht so: w := a0 für i = n − 1 : w := w + ai ∗ M ath.pow(b, i) Aufwand: Falls der Compiler pfiffigerweise die Potenzberechnung in b ∗ b · · · ∗ b verwandelt, also keine Potenzreihenentwicklung durchführt, sind 1 + 2 + · · · + n = n(n+1) ∼ n2 2 Multiplikationen nötig. Das Schema von Horner (1819) benötigt nur n Multiplikationen, denn wegen f (b) = (· · · ((a0 b + a1 )b + a2 )b + · · · an−1 )b + an kann man so rechnen: w := a0 für i = 1, . . . , n : w := w ∗ b + ai Potenzieren Dieselbe Idee kann man verwenden, um (große) Potenzen einer Zahl zu berechnen. Um xn zu berechnen, stellen wir den Exponenten im Binärsystem dar: n = (bk . . . b0 )2 = bi 2i . Wir sehen also, daß n der Wert eines gewissen Polynoms an der Stelle 2 ist. Ein Summand (1) verlangt eine Multiplikation, ein Faktor (2) verlangt eine Quadrierung. 6 KOMPLEXITÄT 49 y = 1 w = x solange n > 0: wenn n ungerade: y = y * w wenn n > 1: w = w * w n = n / 2 Das Ergebnis ist y Dies entspricht der Auswertung des Exponenten mittels des Horner-Schemas: Wir stellung uns die binär dargestellte Zahl als Polynom vor, dessen Koeffizienten Nullen und Einsen sind, in das die Zahl 2 eingesetzt ist. Polynomdivision mit Rest Seien f (x), g(x) Polynome, dann gibt es eindeutig bestimmte Polynome q(x), r(x) mit 1. f (x) = g(x)q(x) + r(x) und 2. r(x) = 0 oder deg(r) < deg(g). Das Polynom q(x) heißt der Quotient, r(x) der Rest der Polynomdivision. Falls nun g(x) = x − b ein lineares Polynom ist, dann ist der Divisionsrest eine Konstante, und zwar gleich f (b), wie man aus f (x) = (x − b)q(x) + r durch Einsetzen von x = b sieht. Das wäre eine neue Methode der Wertberechnung. Wie effektiv ist sie? Wir werden gleich sehen, daß man mittels einer kleinen Modifizierung des Hornerschemas den Quotienten und den Rest der Polynomdivision berechnen kann. Sei wieder f (x) = a0 xn + a1 xn−1 + · · · an−1 x + an . In unserem Fall hat der Quotient den Grad n − 1; wir machen den Ansatz q(x) = b0 xn−1 + · · · + bn−k−1 xk + · · · + bk xn−k−1 + · · · + bn−1 und vergleichen in f (x) = q(x)(x − b) + bn die Koeffizenten der x-Potenzen (den Rest nennen wir bn ; gelegentlich ist eine geschickte Notation hilfreich): a0 = b 0 xn−k : ak = bk − bk−1 b Damit erhalten wir schrittweise die Koeffizienten von q(x): b 0 = a0 6 KOMPLEXITÄT 50 bk = ak + bk−1 b, k = 1, . . . , n und bn = f (b) ist der Divisionsrest. Der Rechenaufwand ist derselbe wie beim Hornerschema. Wenn man eine reelle Nullstelle b des Polynoms f (x) bestimmt hat, so kann man dieses Verfahren nutzen, um den Faktor x−b aus f (x) zu entfernen und kann nach Nullstellen des Quotienten suchen. Wenn a + bi eine komplexe Nullstelle von f (x) ist, so ist auch a − bi eine Nullstelle von f (x), d.h. f (x) ist durch das quadratische (reelle) Polynom (x − a − bi)(x − a + bi) = x2 − 2ax + a2 + b2 teilbar. Um diesen Faktor aus f (x) zu entfernen, verwenden wir eine Verallgemeinerung des Hornerschemas (Collatz 1940): Wir berechnen also den Quotienten und den Rest bei der Division von f (x) durch x2 + px + t. Der Quotient sei q(x) = bo xn−2 + b1 xn−3 + · · · + bn−2 , der Rest sei r(x) = bn−1 x + bn . Der Koeffizientenvergleich in f (x) = (x2 + px + t)q(x) + r(x) liefert a0 a1 ak an = = ··· = ··· = b0 b1 + pb0 = = ··· = ··· = a0 a1 − pb0 bk + pbk−1 + tbk−2 , k = 2, . . . , n − 1 tbn−2 + bn also b0 b1 bk bn ak − pbk−1 − tbk−2 , k = 2, . . . , n − 1 an − tbn−2 Um die vorgestellten Verfahren zur Nullstellenabtrennung nutzen zu können, muß man zuerst eine Nullstelle kennen. Im Buch von Beresin und Shidkov, Numerische Methoden 2, VEB Deutscher Verlag der Wissenschaften, Berlin 1971 wird auf S. 169 ein Verfahren von Muller (1956) vorgestellt, mit dessen Hilfe komplexe Nullstellen von Polynomem berechnet werden können. Der Witz besteht darin, daß (anders als beim Newton-Verfahren) keine Anfangsnäherung bekannt sein muß; das Verfahren ist zwar theoretisch nicht streng abgesichert, liefert aber in der Praxis gute Ergebnisse. 6 KOMPLEXITÄT 51 Für das Verfahren von Graeffe zur Bestimmung reeller Nullstellen habe ich z.Z. nur die Referenz auf Zurmühl, Praktische Mathematik für Ingernieure und Physiker, Springer 1957, S. 62).Zur Bestimmung komplexer Nullstellen gibt es Verfahren von Nickel bzw. Laguerre, die z.B. bei Gander, Computermathematik, Birkhäuser 1992, S. 110 ff vorgestellt werden. Verwenden Sie bitte diese Algorithmen, um Nullstellen zu berechnen und abzutrennen. Berechnen Sie z.B. die Nullstellen von xn −1, dies sind (für geeignetes n) die Positionen der Sterne im EU-Wappen. 6.3 Auswertung vieler Polynome Wenn nicht der Wert eines Polynoms an einer Stelle, sondern die Werte vieler Polynome vom Grad ≤ n an denselben Stellen x0 , x1 , . . . , xn berechnet werden sollen, so ist es besser, sich zuerst die Potenzen xji bereitzustellen. Für das Polynom p(x) = pi xi gilt dann: p(x0 ) p0 1 x0 x20 . . . xn0 1 x p1 p(x1 ) x21 . . . xn1 1 · . = . . .. ... .. 1 xn x2n . . . xnn pn p(xn ) Umgekehrt kann man bei vorgegebenen Werten p(xi ) die Koeffizienten des Polynoms p durch Multiplikation mit der zu (xji ) inversen Matrix bestimmen. Das vereinfacht das formale Rechnen mit Polynomen kollossal: Um das Produkt (die Summe) von p(x) und q(x) zu bestimmen, berechnet man einfach p(xi ) · q(xi ) und bestimmt die Koeffizienten des Polynoms mit diesen Werten. Das lohnt sich natürlich erst dann, wenn viele Opeartionen mit Polynomen durchgeführt werden: Am Anfang wird jedes Polynom durch seinen Wertevektor“ kodiert, am Ende werden die Koeffi” zientenvektoren ermittelt. Wenn für die xi die (n+1)-sten Einheitswurzeln gewählt werden, so ist die Matrix (ω ij ) 1 (ω −ij ). Dieses Verfahren zu betrachten; deren Inverse ist ganz einfach aufgebaut: n+1 heißt Diskrete Fourier-Transformation“ (DFT). ” 6.4 Matrixoperationen Wir wollen uns zunächst überlegen, was zu tun ist, wenn auf die Komponenten eines zweidimensionalen Feldes zugegriffen werden soll. Dabei soll xxx ein Datentyp sein, der w Bytes belegt. character[][] = new a[m][n]; Die Zahl der Spalten der Matrix ist n, die Anfangsadresse sei b; dann hat die Komponente aij (bei zeilenweiser Abspeicherung) die Adresse s = b + (j · n + i) · w, zur Laufzeit werden also 4 Rechenoperationen (für den Zugriff!) durchgeführt. 6 KOMPLEXITÄT 52 Diese Überlegungen könnten interessant sein, wenn man häufig auf Teilfelder zugreifen will und sich ein mehrdimensionales Feld selbst als eindimensionales organisiert. Ein Beispiel: Matrixmultiplikation (Es sei a eine (m, n)-Matrix und b eine (n, q)-Matrix. Die (m, q)-Matrix c wird jeweils vorher als Nullmatrix initialisiert.) for (k = ...) // in der inneren Schleife for (j = ...) // wird viermal dereferenziert for (i = ...) c[i][j] = c[i][j] + a[i][k] * b[k][j]; for (k = ...) for (j= ...) { s = b[k][j]; // dadurch wird etwa 1/6 der for (i = ...) // Rechenzeit gespart c[i][j]= c[i][j] + a[i][k] * s; } for (i = ...) for (j = ...) { s = 0.0; // dadurch wird etwa 1/3 der } for (k = ...) // Rechenzeit gespart } s = s + a[i][k] * b[k][j]; c[i][j] = s; } Die gesparte Rechenzeit ist eben die zum Dereferenzieren benötigte. Die letzte Möglichkeit ist aber nur bei der dort gegebenen Reihenfolge der Schleifen möglich. Ein guter Compiler optimiert alledings den Rechenablauf neu und ganz anders, als man es gedacht hat. Wir wollen nun untersuchen, wie viele Additionen/Subtraktionen und Multiplikationen/Divisionen zur Lösung eines linearen Gleichungssystems nötig sind. Gegeben sei ein Gleichungssystem Ax = b, wobei A eine invertierbare n × n-Matrix ist; das Gleichungssystem hat also eine eindeutig bestimmte Lösung. Wir verwenden als Lösungsverfahren die Gauß-Jordan-Elimination und setzen der Einfachheit halber voraus, daß dabei keine Zeilenvertauschungen durchgeführt werden müssen. Durch Addition von Vielfachen einer Zeile zu anderen wird die erweiterte Koeffizientenmatrix (A | b) zunächst in eine Dreiecksform 1 a12 0 1 ... 0 ... ... a23 a1n ... 0 1 b1 b2 bn 6 KOMPLEXITÄT 53 und danach durch Rücksubstitution in die Form 1 0 0 . . . 0 b1 1 . . . 0 b2 , ... 0 . . . 0 1 bn gebracht, dann ist das Lösungstupel sofort ablesbar. Die folgenden elementaren Überlegungen sind im Lehrbuch von H. Anton, Lineare Algebra, Spektrum 1995 auf den Seiten 538 ff. ausführlich erläutert. Schritt 1 2 n−1 1 2 n−1 Mult n n(n − 1) n−1 (n − 1)(n − 2) Add 0 n(n − 1) 0 (n − 1)(n − 2) 2 2 0 2 n−1 n−2 n−1 n−2 1 1 Bemerkung Erzeugen der Anfangseins Nullen in n − 1 Zeilen unter der 1 Erzeugen der Anfangseins Nullen in n − 2 Zeilen unter der 1 ... Erzeugen der Anfangseins Null in Zeile unter der 1 Rückwärtsphase Nullen in n − 1 Zeilen über der 1 Nullen in n − 2 Zeilen über der 1 ... Null in Zeile über der 1 Somit erhalten wir als Anzahl der Additionen n i2 − i=1 n i+ i=1 n−1 i = n3 /3 + n2 /2 − 5n/6 i=1 und als Anzahl der Multiplikationen n i=1 i2 + n−1 i = n3 /3 + n2 − n/3, i=1 insgesamt als ca. n3 Rechenoperationen. Bemerkung: Wenn sofort auch über den Einsen Nullen erzeugt werden, sind 12 (n3 − n) Additionen und 12 (n3 + n) Multiplikationen nötig. Schließlich geben wir einige Typen von Matrizen an, die leicht zu erzeugen sind und wo für die Determinante bzw. die Inverse explizite Formeln bekannt sind. Damit kann man eigene Algorithmen testen. 6 KOMPLEXITÄT Typ Vandermonde 54 Determinante xi · (xj − xi ) (xji ) i<j kombinatorische (y + δij · x) Cauchy Hilbert 1 xi + yj 1 i+j−1 x n−1 i<j · (x + ny) (xj − xi )(yj − yi ) (xi + yj ) Inverse −y + δij · (x + ny) x(x + ny) (xj + yk )(xk + yi ) k (xj + yi ) (xj − xk ) k=j i,j (yi − yk ) k=i Spezialfall von Cauchy Die Inverse einer Hilbert-Matrix hat ganzzahlige Einträge; Hilbert-Matrizen werden gern für Tests genutzt, denn dieser Typ ist numerisch instabil. 6.5 Zufallszahlen Ein beliebtes Mittel, um einen Algorithmus auf Korrektheit zu testen“, ist es, ein ” Zahlenbeispiel mit der Hand durchzurechnen und nachzusehen, ob das Programm dasselbe Ergebnis liefert. Man kann auch viele Eingaben erzeugen und abwarten, ob der Rechner abstürzt oder nicht. Wenn das aber bei einigen Eingaben korrekt abläuft und bei anderen nicht, dann hat man ein Problem. Es ist mitunter hilfreich, wenn man eine zufällige“ Folge von Zahlen erzeugen kann, ” deren Erzeugung aber wiederholbar ist. Ein klassisches Verfahren stammt von J. v. Neuman (1946): der middle square“ ” Algorithmus. Wir rechnen mit 4-stelligen Zahlen, beginnen mit einer Zahl, quadrieren und entnehmen die mittleren vier Dezimalstellen: x = x2 /102 mod 104 . Es kann allerdings passieren, daß man in kurze Zyklen gerät, z.B. ist 6100 eine wenig geignete Startzahl (6100, 2100, 4100, 8100, 6100). Wie kann man die Güte eines Zufallszahlen-Generators überprüfen? Sei M eine endliche Menge, f : M −→ M eine Abbildung und x0 ∈ M ein Startwert. Man bildet nun xi+1 = f (xi ). Wegen der Endlichkeit von M können nicht alle xi voneinander verschieden sein. Es sieht etwa so aus: ✬ ✩ xn+i = xm x ✫n ✪ Es gibt also eine Periode, die bei einem Wert xm , m ≥ 0 beginnt und die Länge l ≥ 1 hat. Es gilt also xi = xi+l für i ≥ m. Der durch f gegebene Algorithmus ist gut“, wenn desses Periodenlänge groß ist, die ” Länge der Vorperiode ist unerheblich. Wie können diese Daten bestimmt werden? 6 KOMPLEXITÄT 55 Wir bemerken zuerst, daß es eine Zahl n gibt, für die xn = x2n gilt (solch ein x heißt idempotent). Denn es ist xr = xn gdw. r − n = k · l ein Vielfaches von l und r > n ≥ m ist. Wir suchen also das kleinste n mit n = kl, dann ist x2n = xn+kl = xn . Aus diesen Idempotenz-Index n können nun die Werte von m und l bestimmt werden: Wenn wir die Vorperiode auf die Periode aufwickeln“, so liegt deren Startpunkt gerade ” bei xn . Also suchen wir das kleinste i mit xi = xn+i , dies ist unser m. Wenn die Vorperiode m nicht um die Periode herumreicht (k = 1), so ist die Periodenlänge l = n, andernfalls ist l gleich dem kleinsten i mit xm+i = xm . Ein einfacher Generator ist die Funktion f (x) = x · 5 mod 97, also xn ≡ 5n , er hat keine Vorperiode und die (maximal mögliche) Länge 96 (warum ?). In Fortran sah das so aus: (Wir suchen nacheinander i mit f i (x) = f 2i (x), dann ist n = i; f i (x) = f n+i (x); dann ist m = i; f m (x) = f m+i (x); dann ist l = i. c program rand Periode eines Zufallsgenerators: Vorperiode: m; Periode: l integer m, f2n, s, h, x, i, n, l parameter (h = 97) do s = 2, h print *, "s = ", s do i = 1, h if (f2n(x, s, h, i) .eq. f2n(x,s,h, 2*i)) then print *, "Idempotent", i, f2n(x,s,h,i), f2n(x,s,h,2*i) n = i goto 1 end if end do 1 do i = 1, h if (f2n(x, s, h, i) .eq. f2n(x, s, h, n+i)) then m = i goto 2 end if end do 2 print *, "Vorperiode ", m print *, "x^m = ", f2n(x, s, h, m) do i = 1, m if (f2n(x,s,h,i) .eq. f2n(x,s,h,n+i)) then l = i 7 SUCHEN 56 goto 3 else l = n end if end do 3 print *, "Laenge ", l print *, "Kontrolle : " print *,(i,":", f2n(x,s,h,i), i=1,50) read * end do end integer function f(x, s, h) integer x, s, h f = mod(x*s, h) end c 7 integer function f2n(x, s, h, n) f2n(x) = f^n(x) integer x, s, h, n, i, r, f, y y = x do i = 1, n r = f(y, s, h) y = r end do f2n = y end Suchen Komplexität Sortieralgorithmen reagieren unterschiedlich, wenn die zu sortierende Menge mehr oder weniger vorsortiert ist. Es kommt also bei der Bewertung von Algorithmen nicht auf die Laufzeit bei einer konkreten Eingabe, sondern auf die maximale oder auch die mittlere Laufzeit bei beliebigen Eingaben der Größe n an. Die Laufzeit eines Algorithmus A ist also eine Funktion LA : N −→ N , für die man eine obere bzw. untere Schranke bestimmen möchte. Definition: Eine Funktion f : N −→ N ist eine obere Schranke für die Funktionenmenge O(f ) = {g : N −→ N | es gibt c > 0 und n0 mit g(n) ≤ c · f (n) für alle n > n0 }. Eine Funktion f : N −→ N ist eine untere Schranke für die Funktionenmenge U (f ) = {g : N −→ N | es gibt c > 0 und n0 mit g(n) ≥ c · f (n) für alle n > n0 }. 7 SUCHEN 57 Eine Funktion f : N −→ N ist eine exakte Schranke für die Funktionenmenge E(f ) = {g : N −→ N | es gibt c > 0 und n0 mit 1 ·f (n) ≤ g(n) ≤ c·f (n) für alle n > n0 }. c Ein Algorithmus A heißt linear zeitbeschränkt, wenn LA ∈ O(n) ist, er heißt polynomial zeitbeschränkt, wenn ein k ∈ N existiert, so daß LA ∈ O(nk ) ist, und er heißt exponentiell zeitbeschränkt, wenn ein k existiert, so daß LA ∈ O(k n ) gilt. Wir werden uns bei konkreten Algorithmen auch für ihre Zeitkomplexität interessieren. In der Praxis sind bei großen Datenmengen nur Algorithmen einsetzbar, die höchstens polynomiale Zeitschranken haben, und dabei muß der Exponent noch klein“ sein. ” Bedenken Sie: n = 3.5 · 106 (die Zahl der Transistoren auf einem modernen Mikrochip) und k = 4 ergibt nk = 1.5 · 1026 , aber 1017 Nanosekunden sind 3 Jahre. Die Entwicklung der letzten Jahrzehnte verlief so, daß alle 10 Jahre 100mal mehr Speicher zur Verfügung stand und die Rechengeschwindigkeit verhundertfacht wurde. Welche Auswirkung hat das auf die Größe der bearbeitbaren Probleme? Ein Algorithmus habe polynomiale Komplexität: O(np ) ∼ C · np . Wenn ein neuer Computer eingesetzt wird, der um den Faktor k besser ist, so kann man Probleme der Größe kn bearbeiten. Dazu sind dann C(kn)p Operationen nötig, die Rechenzeit ist dann also C(kn)p /k = Ck p−1 np . Wenn also p größer als 1 ist, hat man Probleme. Als Beipiele betrachten wir einige Suchalgorithmen. Lineares Suchen Wenn ein Wert W in einer Liste L der Länge n, über deren Inhalt nichts weiter bekannt ist, gesucht werden soll, so muß man die Liste sequentiell durchlaufen: i:= 1 solange i <= n und L[i] <> W ist, erhoehe i. Wenn i <= n ist, so wurde W gefunden. Aufwand: Wenn W nicht in der Liste vorkommt, sind n Schritte nötig, wenn W vorsein. kommt, sind maximal n − 1 Schritte notwendig, im Durchschnitt werden es n−1 2 Der Zeitaufwand ist linear. Man kann das Zeitverhalten noch verbessern: i:= 1 L[n+1]:= W solange L[i] <> W ist, erhoehe i. Wenn i <= n ist, so wurde W gefunden. Man hat den Test, ob die Feldgrenze erreicht ist, eingespart. Dieser Test ist nötig, wenn man nicht weiß, ob W in der Liste vorkommt. Binäres Suchen 7 SUCHEN 58 In geordneten Strukturen geht das Suchen schneller. Beispiel: Ich denke mir eine natürliche Zahl zwischen 0 und 100. Wenn man fragt: Ist ” es 1, ist es 2, . . . ?“, so hat man sie nach einiger Zeit auf alle Fälle gefunden. Wenn man fragt: Ist es kleiner, gleich oder größer als 50?“, so hat man in Abhängigkeit von der ” Antwort die zu durchsuchende Menge in einem Schritt halbiert. Eine Zahl unter 100 errät man also in höchstens 7 Schritten. Dieses Verfahren heißt binäres Suchen, es ist ein Beipiel für das Prinzip des Teilens und Herrschens. Wir implementieren den Algorithmus zuerst rekursivsuch1 und betrachten den Zeitaufwand: Bei jedem neuen Aufruf von such1 halbiert sich der Aufwand. Wenn 2m−1 < n ≤ 2m , so sind m = log2 n, Schritte nötig. Mit gleichem Zeitaufwand arbeitet die sequentielle Prozedur such2; wir beschleunigen sie durch den Trick, in jeder Schleife nur eine Abfrage durchzuführen: import java.io.*; import java.lang.Math.*; public class suchen { public static int nn = 92; public static long a[] = new long[nn]; public static long s; // Die Position des Werts s soll im geordnetet Feld a gefunden werden. public static void init() { a[0] = 1; a[1] = 1; for (int i = 2; i < nn; i++) a[i] = a[i-2] + a[i-1]; } public static int such1(int links, int rechts) { int i; System.out.print("+"); if (links <= rechts) { i = (links + rechts) / 2; if (a[i] > s) return such1(links, i-1); else if (a[i] < s) return such1(i+1, rechts); else return i; // gefunden ! } else return -1; // nicht gefunden ! 7 SUCHEN 59 } public static int such2() { int links = 0, rechts = nn-1, i; while (links < rechts) { System.out.print("*"); i = (links + rechts) / 2; if (a[i] < s) links = i + 1; else rechts = i; } if (a[rechts] == s) return rechts; else return -2; } public static void main(String arg[]) { init(); s = 13; int i; i = such1(0,nn-1); System.out.println(i); i = such2(); System.out.println(i); } } Hashing Das Suchen in binären Bäumen entspricht dem Auffinden der richtigen Antwort durch ja/nein-Entscheidungen. Komplexere Entscheidungen können aber evtl. schneller zum Ziel führen: Die Telefonnummer unserer Sekretärin, Frau Zyska, würde ich nicht suchen, indem ich das Telfonbuch zuerst in der Mitte aufschlage, sondern am Ende. Also: Wir legen für jeden Eintrag einen Schlüssel fest, der die ungefähre Position in der Tabelle festlegt. In Suchbäumen oder Listen ist die Zuordnung: Eintragung → Standort injektiv. Wir betrachten nun eine Hash-Funktion h : {Einträge} −→ {Indizes}, die nicht notwendigerweise injektiv sein muß, für die aber die Urbildmengen h−1 (i) für alle i ungefähr gleich groß sein sollen. Wenn die zu verwaltenden Daten (kleine) Zahlen sind, so kann man sie selbst als Index in einem Feld verwenden und der Zugriff ist trivial. Wenn die Einträge aber Zeichen- 7 SUCHEN 60 ketten aus den Buchstaben a, . . . , z sind und die Wortlänge auf 16 festgelegt ist, so gibt es 2616 = 4 · 1022 Möglichkeiten, es gibt aber nur 105 vernünftige Worte, man hätte also einen 1017 -fachen Aufwand betrieben. (Man veranschauliche sich die Größenordnung: das Weltall existiert seit 1016 Sekunden; dabei sind die längeren Schaltjahre noch gar nicht berücksichtigt.) Bei Kollisionen (h(s) = h(t), s = t) reicht es nicht aus, in einer Tabelle die zu s gehörige Information abzulegen, man braucht auch s selbst, um eine aufgetretenen Kollision erkennen zu können. Außerdem muß man eine Strategie zur Kollisionsauflösung entwickeln. Es folgen einige Beispiele für Hash-Funktionen zur Verwaltung von Zeichenketten Z = (z1 , . . . , zk ) mit einem Indexbereich I = [0 . . . n]: 1. h(z) = ki=1 (pi ·(int)(zi ) mod n), wo pi die i-te Primzahl und (int)(A) der ASCIIIndex ist. Diese Funktion erzeugt wenig Kollisionen, ist aber sehr rechenaufwendig. 2. h(z) = (int)(z1 ); Ersetze h := (256 · h + (int)(zi )) mod n für i = 2 bis k. Dabei sollte ggT(n, 256) = 1 sein. Die Multiplikation mit 256 ist einfach eine Verschiebung um 8 Bit und dürfte schnell sein. 3. Wir verwenden einzelne Zeichen und die Länge von z: h(z) = (7 · (int)(z1 ) + 11 · (int)(zk−1 ) + 19k) mod n. Die Länge n der Tabelle sollte eine Primzahl sein. Die Felder a[1], . . . , a[n] können dann Listen enthalten, die die zu speichernden Informationen enthalten, innerhalb dieser (hoffentlich kurzen) Listen kann man dann suchen. Teilen und Herrschen Dies ist eine oftmals effektive Lösungsmethode. Sei ein Problem der Größe n zu lösen. Wir suchen eine obere Schranke L(n) für die benötigte Laufzeit. Wir nehmen an, das Problem lasse sich in t Teilprobleme desselben Typs zerlegen, diese Teilprobleme mögen die Größe ns haben und zum Zerlegen und Zusammenfügen sei der Zeitaufwand c(n) nötig. Wir haben also folgende Rekursion zu bearbeiten: L(n) = Satz 7.1 Für n = sk gilt L(n) = c(n) n=1 t · L( ns ) + c(n) sonst k n ti c( i ). s i=0 Beweis: Wir führen die Induktion über k. Für k = 0, also n = 1 ist L(1) = c(n) gültig. Weiter gilt: n k−1 k−1 k n n n ti c( si )+c(n) = ti+1 c( i+1 )+c(n) = ti c( i ). L(n) = L(sk ) = t·L( )+c(n) = t s s s s i=0 i=0 i=0 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 61 Satz 7.2 Wenn c(n) = c · n gilt, so ist O(n) t<s L(n) = O(n log n) t = s t>s O(nlogs t ) Beweis: Es gilt also L(n) = k 1=0 ti c k n t = nc ( )i . i s i=0 s 1 beschränkt (geometrische Reihe), also ist 1 − st L(n) ≤ c · K · n = O(n). Für t = s gilt L(n) = cn(k + 1) = O(n log n). Für t > s gilt Für t < s ist die Summe durch K = L(n) = cn n ( t )k+1 − 1 t t ( )i = cn s t = O(sk ( )k ) = O(tlogs n ) = O(nlogs t ). i=0 s s −1 s Die letzte Identität erkennt man, indem man auf beiden Seiten logs bildet. 8 Einfache Datenstrukturen und ihre Implementation Die einfachste Datentruktur ist eine lineare Liste. Wir haben n > 0 Einträge x[1], x[2], . . ., x[n]. Über die Struktur der gespeicherten Daten machen wir uns keine Gedanken. Die Grundaufgaben zur Bearbeitung solchen Listen sind die folgenden: • Zugriff auf den k-ten Eintrag, Auswertung, Änderung. • Vor dem k-ten Eintrag ist ein neuer einzufügen. • Löschen des k-ten Eintrags. • Zwei Listen sollen zu einer vereinigt werden. • Eine Liste ist in zwei Teillisten zu zerlegen. • Eine Kopie einer Liste soll erstellt werden. • Die Länge einer Liste ist zu bestimmen. • Die Einträge einer Liste sollen geordnet werden. • Eine Liste soll nach speziellen Einträgen durchsucht werden. In Abhängikeit davon, welche Operationen im Einzelfall mehr oder weniger häufig auszuführen sind, gibt es unterschiedliche Darstellungen von Listen in Rechnern, die für eine Operation mehr oder weniger effektiv sind. Für Listen, wo der Zugriff (Einfügen, Streichen) immer am Anfang oder am Ende der Liste stattfindet, gibt es spezielle Namen: 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 62 • Stack (Stapel, Keller): Einfügen und Streichen stets am Ende (filo), • Queue (Warteschlange): Alle Einfügungen an einer Seite, alle Streichungen an der anderen Seite (fifo), • Deque (double-ended queue): alle Zugriffe an den Enden. Typische Anwendungen eines Stacks sind die folgenden: Eine Menge wird durchforstet, evtl. relevante Daten werden einstweilen beiseite (in den Keller) gelegt, um sie später zu verarbeiten. Beim Aufruf eines Unterprogramms werden die Registerinhalte auf einem Stack gerettet, das Unterprogramm kann die Register selbst nutzen, nach dem Verlassen des Unterprogramms werden die Daten aus dem Stack zurückgeholt. Oder nehmen wir ein Abstellgleis. Es kommen vier Wagen in der Reihenfolge 1, 2, 3, 4 an und sollen in der Reihenfolge 2, 4, 3, 1 abfahren. Wagen 1 fährt aufs Abstellgleis, Wagen 2 auch, fährt aber gleich wieder runter. Nun fahren 3 und 4 aufs Abstellgleis, 4 fährt wieder runter, dann verlassen zuerst 3 und dann 1 das Gleis. Kann man 123456 in 325641 überführen? Und in 154623? Es kann nie die Situation i < j < k und pk < pi < pj eintreten. Wenn die Einträge in einer Liste alle die gleiche Anzahl C von Bytes belegen, kann man die Liste sequentiell abspeichern, dann gilt: Addr(x[i+1]) = Addr(x[i]) + C Wenn X einen Stack realisieren soll, so braucht man sich nur den aktuellen Index des Endes (den sog. stack pointer) zu merken, beim Einfügen ist er zu erhöhen, beim Streichen zu senken. Bei einer Warteschlange müssen wir uns zwei Indizes A, E für Anfang und Ende merken (besser: den ersten freien Platz nach dem Ende). Wenn A = E gilt, so ist die Schlange leer. Wenn stets am Anfang gestrichen und am Ende eingefügt wird, so bewegt sich die Schlange in immerhöhere Speicherbereiche, während die unteren ungenutzt bleiben. Es ist dann besser, die Warteschlange als Ring zu organisieren. Wenn nun nach dem Streichen A = E gilt, so ist die Schlange leer. Wenn aber nach dem Einfügen A = E ist, so ist die Schlange voll. Wenn man in einer leeren Liste streichen will, dann hat man einen Fehler gemacht. Wenn man in eine volle Liste noch etwas einfügen will, dann ist wahrscheinlich die zu bearbeitende Datenmenge zu groß geworden. Solche Überläufe muß man abfangen. Eine andere Möglichkeit besteht darin, daß sich zwei Stacks den Speicher teilen: Liste1 Anfang1 Ende1 Ende2 Liste2 Anfang2 Ein Überlauf tritt hier erst ein, wenn der Platz für beide Listen nicht mehr ausreicht. Wenn mehrere Stacks zu verwalten sind, dann geht das nicht so. Dennoch kann es nützlich sein, den vorhandenen Speicher gemeinsam zu nutzen: der i-te Stack liegt zwischen A[i] und E[i], wenn zwei Stacks zusammenstoßen, verschiebt man die Stacks. Dies wird in Knuth’s Buch (Band 1) auf den Seiten 245 und 246 beschrieben, wir werden uns diesem Problem im Praktikum widmen. 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 63 Einige häufige Anwendung eines Stacks ist die Auflösung rekursiver Funktionsaufrufe. Man kann z.B. n! folgendermaßen berechnen: public static int fak(int n) { if (n == 1) return 1; else return n * fak(n - 1); } Als allgemeines Schema zur Auflösung der rekursiven Aufrufe kann folgendes gelten: Man richtet sich einen Stapel (stack) ein, in dem bei jeden Prozeduraufruf Daten eingetragen und beim Verlassen der Prozedur gestrichen werden. Jedem Prozeduraufruf wird ein Fach (frame) auf dem Stapel zugeordnet, der die folgenden Informationen enthält: head Übergabeparameter von anderen Prozeduren head - 1 aktuelle Parameter head - 2 lokale Variable head - 3 nach außen zu übergebende Parameter (Ergebnisse) head - 4 Rücksprungadresse Diese Fächer sind gleichgroß und liegen aufeinanderfolgend auf dem Stapel; die Verwaltung geschieht einfach durch einen Zeiger (stack pointer) auf die Stapelspitze. Jedes Fach enthält die Informationen für einen Prozeduraufruf. Sei A die aktuell arbeitende Prozedur, dann sieht der Stapel so aus: Fach fürs Hauptprogramm .. . Fach für die Prozedur, die A gerufen hat Fach für A Wenn nun A die Prozedur B (oder sich selbst mit neuen Parametern) aufruft, dann machen wir folgendes: 1. Ein neues Fach wird auf die Spitze des Stapels gelegt. Dort legt man (in einer Reihenfolge, die B bekannt ist) folgendes ab: (a) die Adressen der aktuellen Parameter von B (wenn ein Parameter ein Ausdruck ist, wird er zuerst ausgewertet und die Adresse des Ergebnisses übergeben; bei Feldern genügt die Anfangsadresse), (b) leerer Raum für die lokalen Variablen von B, (c) die Nummer der Anweisung, die A nach dem Aufruf von B durchzuführen hat (Rücksprungadresse). 2. Nun werden die Anweisungen von B ausgeführt. Die Adressen der Werte aller Parameter von B findet B durch Abzählen von der Stapelspitze aus (rückwärts). 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 64 3. Wenn B beendet ist, wird wie folgt die Kontrolle an A übergeben: (a) Die Rücksprungadresse steht im B-Fach. (b) Ggf. steht ein Funktionswert an der A bekannten Stelle. (c) Das B-Fach wird entfernt, indem einfach der Zeiger auf die Stapelspitze um die passende Größe zurückgesetzt wird. (d) Die Arbeit von A wird an der richtigen Stelle fortgesetzt. Für die folgende Fakultätsberechnung benötigen wir nicht alle diese Informationen. import java.io.*; import java.lang.Math.*; public class fak { public static int nn = 100; public static int a[] = new int[nn]; public static int x, h; // x! soll gerechnet werden public static void init() { h = 5; a[h-1] = x; h = h + 5; a[h-4] = 1; a[h-1] = a[h-1-5]; } // der Stack // Stack einrichten public static void rechne() { a[h-5] = a[h-3]; h = h - 5; a[h-3] = a[h-1] * a[h]; // Stack abbauen } public static void main(String arg[]) { x = 5; init(); while (true) { if (a[h-1] == 1) { 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 65 a[h-3] = 1; break; } else { h = h + 5; // Stack aufbauen a[h-4] = 3; a[h-1] = a[h-5-1] - 1; } } // rechne(); while (a[h-4] != 1) rechne(); a[h-5] = a[h-3]; System.out.println(x + "! = " + a[h-3]); } } Speicherverwaltung Als erstes betrachen wir verbundene Listen: x[1] # ✲ x[2] # ✲ x[3] # ✲ Jeder Eintrag enthält die Adresse des nachfolgenden Eintrags. Hier ist folgendes zu beachten: 1. Es wird zusätzlicher Speicher für die Adresse des Nachfolgers benötigt. 2. Das Streichen und Einfügen ist einfach: X[2] wird entfernt, indem der bei X[1] beginnende Pfeil auf X[3] gerichtet wird, und X[4] wird zwischen X[1] und X[2] eingefügt, indem der Pfeil von X[1] auf X[4] gerichtet wird, während er von X[4] auf X[2] zeigt. 3. Der Zugriff auf die k-te Komponente ist langsamer als bei der sequentiellen Liste. Das schadet aber dann nichts, wenn ohnehin die ganze Liste durchlaufen werden soll. 4. Das Zusammenfassen zweier Listen und die Teilung einer Liste sind leicht. Zur Implementierung derartiger verbundenen Listen benötigt man zwei Felder, wir nennen sie INFO, INFO[p] enthält den Inhalt, der zu speichern ist, und LINK, dabei enthält LINK[p] die Adresse der nächsten Zelle. Eine Speicherplatzzuweisung realisiert man wie folgt: Man hat eine als Stack organisierte Liste NEXT der verfügbaren freien Adressen, die Variable AVAIL zeigt auf die Spitze dieser Liste. Neuen freien Speicher holt man mit X:= AVAIL; AVAIL:= NEXT(AVAIL) 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 66 Speicher gibt man mit NEXT(X):= AVAIL; AVAIL:= X zurück. Der Aufruf von subroutine insert(item, free, position) info(free) = item next(free) = next(position) next(position) ) free end fügt item nach position ein. Schließlich kann man noch doppelt verkettete Listen herstellen, wo jedes Element seinen Vorgänger und seinen Nachfolger kennt, oder ringförmig verkettete Listen, damit kann man z.B. dünn besetzte Matrizen effektiv abspeichern (Es werden (fast) nur die von Null verschiedenen Einträge gespeichert). Wenn ein Unterprogramm aufgerufen wird, müssen aktuelle Variable des aufrufenden Programms gerettet werden. Dies betrifft insbesondere die Inhalte der Register des Prozessors, die Rücksprungadressen enthalten oder Standardaufgaben erfüllen. Dazu werden diese Werte in durchdachter Reihenfolge auf einen Stack gelegt (push) und zu gegebener Zeit in umgekehrter Reihenfolge zurückgeholt (pop). Während des Programmlaufs wird der Stack, der ja einen Teil des Arbeitsspeichers belegt, größer und kleiner, er hat aber keine Lücken“. Zur Verwaltung des Stacks ist eine einzige Varia” ble, der Stack-Pointer“, nötig, deren Wert (in PCs) in einem eigens dafür reservierten ” Register gehalten wird. Der vom Programmcode und vom Stack nicht genutzte Speicher steht für Programmvaiable zur Verfügung. Ein einfacher Editor reserviert sich beispielsweise ein Feld von 64 kByte (ca. 32 Druckseiten) und trägt dort die Zeichen ein, die von der Tastatur eingegeben werden. Der Speicherbedarf solcher Programme ist konstant und von vorn herein bekannt. Der Norton-Editor bekennt ggf. Die Datei ist zu groß zum bearbeiten“. ” Um Algorithmen zu implementieren, die während des Programmlaufs neue Variable konstruieren, deren Anzahl und Größe nicht vorhersehbar ist, stellen die Programmiersprachen Werkzeuge zu dem Zweck bereit, hierfür Speicherplatz zu beschaffen und zu verwalten. Beispiele: new, dispose, getmem, freemem Pascal NEW, DISPOSE, ALLOCATE, DEALLOCATE Modula 2 malloc, free C ∅ Fortran 77 new Java Wir wollen uns an einem Beispiel klarmachen, was für Probleme da eigentlich zu bewältigen sind. 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 67 Bei einem Zeichenprogramm können Linien mit der Maus eingegeben werden. Eine Linie wird in eine Folge von Strecken zerlegt, deren jede durch vier Zahlen, die Koordinaten der Endpunkte, beschrieben werden kann. Es kann natürlich festgelegt werden, daß eine Linie aus maximal 1000 Strecken bestehen darf, aber die Anzahl der Linien soll doch nicht durch das Programm, sondern höchstens durch den verfügbaren Speicher beschränkt sein. Für jede neue Linie ist also ein Stück freien Speichers zu finden und für die Linie zu reservieren (dieser Teil ist also belegt“). Wenn Linien gestrichen werden, kann (soll!) ” der vorher belegte Speicherplatz wieder freigegeben weren. In Pascal könnte das so aussehen: type strecke = array[1..4] of integer; linie = array[1..1000] of strecke; linienp = ^linie; { die Anfangsadresse einer Linie } var lp: linienp; begin ... new(lp); ... lp^[i][1]:= x1; lp^[i][2]:= y1; ... dispose(lp); end. Wenn aber viele kurze Linien gezeichnet werden sollen, ist die obige Datenstruktur unangebracht: Jede Linie verbraucht 8 kByte. Leider verfügen einige Programmiersprachen über gar keine Möglichkeiten zur privaten Speicherverwaltung, oder der Nutzer ist mit den gelieferten Verfahren unzufrieden. Dann muß man sich eine eigene Verwaltung schaffen. Wir beginnen mit dem einfachen Fall. Konstante Blockgröße Wir wollen mit verbundenen Listen arbeiten. Dazu vereinbaren wir ein sehr großes Feld mem, in dem wir unsere Daten unterbringen, ein Feld avail, das die Indizes der freien Felder enthält, ein Feld link, das bei jedem Listenelement auf dessen Nachfolger zeigt, und einen Zeiger ff (first free), der das erste freie Feld angibt. Diese Variablen sollen nicht als Parameter an Unterprogramme weitergegeben werden, sondern global gültig sein. Es folgt eine einfache Implementation. import java.io.*; import java.lang.Math.*; public class memory 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION { public static int nn = public public public public public static static static static static 10000; char a[][] = new char[nn][8]; // der Speicher int link[] = new int[nn]; // Nachfolger int avail[] = new int[nn]; // Freistellen int ff; // erste Freistelle char leer[] = {’#’,’#’,’#’,’#’,’#’,’#’,’#’,’#’}; public static void init() // einrichten { ff = 1; for (int i = 1; i < nn; i++) { a[i] = (char[])leer.clone(); // nicht einfach " a[i] = leer;" link[i] = 0; avail[i] = i; } ausgabe(); } public static int neu() { int n = avail[ff]; avail[ff] = 0; link[ff] = 0; ff++; return n; } public static int frei1(int i) { a[i] = leer; ff--; int h = i; i = link[i]; avail[ff] = h; return i; } public static void frei(int i) { while (i > 0) i = frei1(i); } 68 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 69 public static boolean lies(int sp) { String s = new String(B.readstr()); if (s.charAt(0) == ’#’) return false; for (int i = 0; i < s.length(); i++) a[sp][i] = s.charAt(i); return true; } public static int eingabe() { int alt = -1, sp, spp; boolean ok; spp = neu(); sp = spp; ok = lies(sp); while (ok) { alt = sp; link[sp] = neu(); sp = link[sp]; ok = lies(sp); } frei1(sp); link[alt] = 0; return spp; } public static void ausgabe(k) { for (int i = k; i < 10; i++) System.out.println(i + " " + a[i] + " " +link[i] + " " + avail[i]); } public static void main(String arg[]) { init(); spp = eingabe(); ausgabe(spp); } } 8 EINFACHE DATENSTRUKTUREN UND IHRE IMPLEMENTATION 70 Variable Blockgröße Schwieriger wird es, wenn Blöcke variabler Länge zu verwalten sind. Beispiel: Ein Integer-Feld der Länge n kann im binären Zahlsystem als ganze Zahl interpretiert werden, deren maximaler Absolutwert 216n − 1 ist. Stellen wir uns vor, jemand hätte Prozeduren geschaffen, die arithmetische Operationen mit solchen Zahlen realisieren. Die Summe zweier m-Bit-Zahlen ist maximal eine (m + 1)-Bit-Zahl, minimal aber gleich Null. Ein Produkt zweier m-Bit-Zahlen belegt maximal 2m Bit. Es ist also bei umfangreichen Rechnungen wenig sinnvoll, für jede lange Zahl einen Block konstanten Größe zu reservieren. Somit sind an eine Speicherverwaltung folgende Aufgaben zu stellen: 1. Speicheranforderung: Für eine neu zu schaffende Variable soll ein freier (zusammenhängender) Speicherblock gefunden werden. 2. Blockverschmelzung: Benachbarte freie Blöcke sollen zu einem größeren verschmolzen werden. 3. Speicherbereinigung (garbage collection): Nicht mehr benötigte Blöcke sollen als frei erkannt und wieder zur Verfügung gestellt werden. Wir vereinbaren wieder ein größes Feld memo aus Zellen“ konstanter Länge, ein an” zufordernder Block ist dann ein zusammenhängender Bereich memo(i:j). Wir haben weiter eine Liste BEL der belegten und eine Liste FREI der freien Blöcke, wir vermerken dort jeweils den Anfangsindex und die Größe des Blocks. Methoden der Speicherplatzanforderung: 1. first fit Wir suchen in der Frei-Liste den ersten Eintrag, in den der gewünschte Block hineinpaßt. Vorteil: Man muß nicht die gesamte Liste durchlaufen. Nachteil: Von großen freien Blöcken wird evtl. nur wenig genutzt. 2. best fit Wir suchen den kleinsten Block in der Frei-Liste, in den der gewünschte Block hineinpaßt. Vorteil: Der Speicher wird bestmöglich genutzt. Nachteil: Evtl. lange Suchzeiten. 3. Blockspaltung: Wir verwenden eine der vorigen Methoden, melden aber den nicht verbrauchten Rest als frei. Nachteil: Nach einger Zeit haben wir viele kleine freie Blöcke, in die nichts mehr hineinpaßt (dies ist bei best fit besonders zu erwarten). (Schweizer Käse). 9 EIN PAAR BEISPIELE 71 4. rotating first fit Wir organisieren die Frei-Liste zyklisch und suchen nicht vom Anfang, sondern von der zuletzt vergebenen Stelle aus. Blockverschmelzung: Um die Fragmentierung des Speichers zu begrenzen, ist es sinnvoll, bei der Freigabe eines Blocks in den beiden Nachbarblöcken nachzusehen, ob einer davon frei ist, und den vereinigten großen Block zurückzugeben. Dazu sollte FREI als doppelt verkettete Liste organisiert werden. 9 Ein paar Beispiele Die folgenden Beispiele sind dem Buch von Solymosi entnommen. Das Halteproblem Es gibt Routinen, die nach endlicher Zeit ein Ergebnis liefern (und halten), andere tun dies nicht. void haltWennGroesser1(int n) { while (n != 1) n = n/2; } void evtl(int n) { while (n != 1) if (n % 2 == 0) n = n/2; else n = 3*n + 1; } Wir suchen einen Algorithmus, der folgendes entscheidet: Die Eingabe bestehe aus zwei Zeichenketten programm und eingabe. Es soll true ausgegeben werden, wenn programm ein Java-Quelltext ist und (nach der Übersetzung) programm eingabe hält, andernfalls (wenn es kein Java-Text vorliegt oder das Programm nicht hält) soll false ausgegeben werden. Behauptung: Es gibt keine Implementierung der obigen Funktion. Um dies zu beweisen, nehmen wir an, es gäbe eine solche Implementierung, etwa boolean turing(String programm, String eingabe). Dann implementieren wir die folgende Methode: 9 EIN PAAR BEISPIELE 72 void programm(String s) { if (turing(s,s)) while (true); // Endlosschleife else ; // Halt } Wir rufen Sie wie folgt auf: program("void program(String s){ if turing(s,s)) while (true); else ;}"); 1. Fall: Wenn Turing mit true antwortet, so kommt das Programm in eine Endlosschleife, also ist die Antwort falsch. 2. Fall: Wenn die Antword false ist, so hält das Program an, also ist die Antwort falsch. Maximale Teilsummen Gegeben ist eine Folge von n positiven und negativen Zahlen ai , gesucht ist die Teilfolge aufeinanderfolgender Glieder, wo ak + ak+1 + . . . + ak maximal ist. 1. Lösung (naiv): max = 0; for (von = 0; von <= n; von ++) for (bis = von; bis <= n; bis++) { s = o; for (i = von; i <= bis; i++) s = s + a[i]; max = Math.max(s, max); } Da drei for-Schleifen zu durchlaufen sind, werden etwa n3 Schritte nötig sein. 2. Lösung: Zeit für Raum (O(n2 )): Wir sammeln in einer Tabelle schrittweise Teilsummen der Länge k, die wir aus Teilsummen der Länge k − 1 berechnen: zuerst alle von 0 bis 0, dann von 0 bis 1, von 0 bis 2 usw. Wir setzen s[von][bis] = s[von - 1][bis] - a[von - 1] und suchen dann das Maximum. 3. Lösung: Teilen und Herrschen (O(n log n)) Wir teilen die Folge in der Mitte, dann befindet sich die maximale Teilfolge an einer von drei stellen: 10 SORTIEREN 73 hier oder oder hier hier Wir müssen also sowohl maximale Teilsummen suchen, die innerhalb der Folge liegen, also auch welche, die am linken bzw. rechten Rand anstoßen. Wenn dies für beide Teile geschen ist, erhalten wir die maximale Teilsumme der gesamten Folge als max(Innen(links), innen(rechts), rechterRand(links) + linkerRand(rechts)). Aber auch hier wird wie oben jedes Element mehrfach angefaßt. Besser als O(n) kann es nicht gelingen, aber wir können diese Größenordnung erreichen. 4. Lösung: Die maximale Teilsumme liegt am rechten Rand einer gewissen links beginnenden Teilfolge. for (i = 0; i <= n; i++) { randMax = Math.max(0, randMax + a[i]); max = Math.max(max, randmax); } 10 Sortieren Sei M eine Menge, der eine Relation ≤ gegeben ist. Für folgende Eigenschaften führen wir Namen ein: 1. Reflexivität: Für alle a ∈ M gilt a ≤ a. 2. Antisymmetrie: Aus a ≤ b und b ≤ a folgt a = b. 3. Transitivität: Aus a ≤ b und b ≤ c folgt a ≤ c. 4. Vergleichbarkeit: Für a, b ∈ M gilt a ≤ b oder b ≤ a. Eine Relation ≤ heißt Ordnung (manchmal auch Halbordnung), wenn die Eigenschaften 1 bis 3 erfüllt sind, sie heißt totale Ordnung (manchmal auch Ordnung), wenn zusätzlich 4. gilt. Beispiele für Ordnungen (welcher Art ?): 1. Wir betrachten die Mengen N, Z oder R mit der gewöhnlichen Kleiner-GleichRelation. 2. Wir betrachten die Menge der natürlichen Zahlen und wählen als Vergleichsrelation die Teilbarkeitsrelation. 3. Sei M = P (X) die Menge aller Teilmengen der Menge X, als Vergleichsrelation nehmen wir die Inklusion ⊆. 10 SORTIEREN 74 4. Sei M = X ∗ die Menge aller Worte über dem (geordneten) Alphabet X, wir ordnen sie wie im Wörterbuch: x1 . . . xn < y1 . . . ym gdw. xi = yi für alle i = 1, . . . , k − 1 und xk < yk Man nennt dies die lexikographische Ordnung. Das Sortierproblem besteht nun darin, zu einer gegebenen Eingabemenge {a1 , . . . , an } ⊂ M eine Ausgabefolge (ai1 , . . . , ain ) zu bilden, für die aip ≤ aiq für p < q gilt. Die häufigsten Anwendungen sind: • Das Zusammenbringen zusammengehöriger Dinge, • das Finden gleicher Elemente, • das Suchen eines bestimmten Elements. Das Suchen ist in geordneten Mengen leichter als in ungeordneten. Ein Sortieralgorithmus kann mit Hilfe eines binären Entscheidungsbaums dargestellt werden. Wir betrachten das Beispiel M = {a, b, c}. ✏ ✒ ✑ a≤b ✛ b≤c ✚ ✘ ✙ ✖ ✕ ✛ ✘ a≤c ✚ a≤c≤b ✔ a≤c ✛ a≤b≤c ✗ b≤c b≤a≤c ✙ ✚ c≤a≤b ✘ b≤c≤a ✙ c≤b≤a Die minimale Anzahl der zum Sortieren notwendigen Vergleiche ist gleich der Tiefe des Entscheidungsbaums Satz 10.1 En sei ein binärer Entscheidungsbaum, um n Elemente zu sortieren. Dann ist die Tiefe von En mindestens O(n · log n). (D.h. es gibt eine Konstante k, so daß die Tiefe mindestens gleich k · n · log n ist. Beweis: Die Anzahl der Blätter ist mindestens n!, da es ja soviele Anordnungsmöglichkeiten für n Elemente gibt. Ein binärer Baum der Tiefe d hat aber höchstens 2d Blätter, also gilt für n ≥ 4 2d ≥ n!, d ≥ log(n!), n! ≥ n · (n − 1) · . . . · n/2 ≥ n/2 n 2 10 SORTIEREN log(n!) ≥ 75 n n n n · log( ) = · (log n − 1) ≥ log n = O(n log n) 2 2 2 4 Wir kommen nun zu einigen speziellen Sortiervervahren. Im Band 3 des Buchs von D. Knuth werden etwa 25 Sortierverfahren diskutiert. Einige davon sollen hier vorgestellt und später im Praktikum implementiert werden können. Es sollen jeweils N Objekte R1 , . . . , RN sortiert werden. 10.1 Sortieren durch Zählen Die Idee ist folgende: Der j-te Eintrag in einer geordneten Liste ist größer als genau j − 1 andere Einträge (Wenn R größer als 27 andere ist, so muß R an Stelle 28 stehen). Wir vergleichen also jedes Objekt genau einmal mit jedem anderen und zählen, wie oft es das größere ist. In einem Feld count speichern wir die neuen Stellen, die die Rs einnehmen; die neue Stelle von Rj soll count[j] + 1 sein. 1. count[i]:= 0 für alle i. 2. Für i = N, N − 1, . . . , 2 gehe zu 3. 3. Für j = i − 1, i − 1, . . . , 1 gehe zu 4. 4. Wenn Ri < Rj ist, so setze count[j] = count[j] + 1, sonst setze count[i] = count[i] + 1. Die benötigte Zeit ist proportional zu N 2 . Wir bemerken, daß die Objekte eigentlich gar nicht sortiert wurden, aber in count steht, wie sie richtig angeordnet werden müssen. 10.2 Sortieren durch Verteilen (Vorsortieren) Wir sortieren die Ri anhand ihrer Schlüssel“ Ki . Wir setzen voraus, daß für den ” Schlüssel Ki von Ri und 1 ≤ j ≤ N gilt u ≤ Ki ≤ v. 1. count(u) = 0, count(u+1) = 0, ... , count(v) = 0 2. Für j = 1, . . . , N : count(Kj ) = count(Kj )+1 Jetzt ist count(i) gleich der Zahl der Schlüssel, die gleich i sind. 3. Für i = u+1, ... , v: count(i) = count(i) + count(i-1) Jetzt ist count(i) gleich der Zahl der Schlüssel ≤ i, insbesondere ist count(v) = N. 4. Für j = N, N − 1, . . . , 1: (Sammeln zusammengehöriger Sätze) i = count(Kj ), Si = Rj , count(Kj ) = i-1 Hier werden der Reihe nach die meistgefragtesten Objekte ausgesucht. Die Folge S1 , S2 , . . . ist nun vorsortiert. 10 SORTIEREN 76 (Datei distsort.for ?) 10.3 Sortieren durch Einfügen Wir gehen davon aus, daß die Objekte R1 , . . . , Rj−1 bereits an der richtigen Stelle stehen, bevor Rj bearbeitet wird. Nun vergleichen wir Rj mit Rj−1 , Rj−2 , . . ., bis wir die Stelle finden, wo es hingehört: zwischen Ri und Ri+1 . Dann wird von der Stelle i + 1 alles hochgehoben und Rj anstelle von Ri+1 eingefügt. Man kann das Vergleichen und das Verschieben kombinieren, indem man Rj mit jedem größeren vertauscht; es sinkt dann bis zur richtigen Stelle. 1. Für j = 2, 3, . . . , N gehe zu 2. 2. i := j − 1, R := Rj . 3. Wenn R ≥ Ri ist, gehe zu 5. 4. Ri+1 := Ri ; i := i − 1;, wenn i > 0 ist, gehe zu 3. 5. Ri+1 := R. Der Zeitbedarf ist ebenfalls ungefähr N 2 . Eine Variante wäre folgende: Wenn Vergleichsoperationen langsam sind, so findet man durch binäres Suchen schnell die richtige Einfügestelle und verschiebt danach den Rest. 10.4 Sortieren durch Verketten Eine verkettete Liste vereinfacht das Einfügen, hier sind keine Verschiebungen nötig. Wenn die zu sortierenden Objekte sequentiell in einem Feld gespeichert sind, so baut man sich während des Sortierens eine Verkettungsliste auf. Dies kann wie folgt geschehen: Neben den zu sortierenden Objekten R1 , . . . , RN nehmen wir noch ein künstliches R0 hinzu. Wir verwenden eine zirkuläre Liste L0 , . . . , LN mit L0 = N, LN = 0. Wenn p die Permutation mit Rp(1) ≤ . . . ≤ Rp(N ) ist, so wird L0 = p(1), Lp(i) = p(i + 1), Lp(N ) = 0 sein. 1. Führe die Schritte 2 bis 5 für j = N − 1, N − 2, . . . , 1 aus. 2. p := L0 , q := 0, R := Rj . Als nächstes wird für Rj seine richtige Stelle in L gefunden, indem R mit den vorangehenden Einträgen verglichen wird; dabei zeigen p und q auf die aktuellen Plätze der Liste; es ist p = Lq , d.h. q liegt vor p. 3. Wenn R < Rp ist, gehe zu 5 (dann ist Rq < R < Rp ). 4. q := p; p := Lq . Wenn p > 0 ist, gehe zu 3. Die Zeiger p, q werden weitergesetzt. Wenn p = 0 ist, so ist R der größte bisher gefundene Eintrag, gehört also ans Ende der Liste: zwischen Rq und R0 . 10 SORTIEREN 77 5. Lq := j, Lj := p. Rj wird an der richtigen Stelle eingefügt. Welcher Aufwand ist nötig? (N 2 ) Verfeinerung: Die Schlüssel mögen zwischen 1 und K liegen; wir teilen das Intervall in M Teilintervalle [1 . . . K/M ], [K/M + 1 . . . 2K/M ], . . . , [(M − 1)K/M . . . K] und legen für jedes Intervall eine Verkettungsliste Li an. Wenn der Schlüssel Ki ins Intervall [rK/M...] paßt, verwalten wir ihn mit der Liste Lr nach dem obigen Algorithmus. Wenn die Schlüssel halbwegs gleich verteilt sind, werden die Listen deutlich kürzer. 10.5 Sortieren durch Tauschen, (bubble sort) Dies ist eigentlich das einfachste Sortierverfahren: Von oben beginnend vertauschen wir jeweils zwei Objekte, wenn sie nicht in der richtigen Reihenfolge stehen. Die großen Elemente steigen dann wie Blasen nach oben (daher der Name). 1. Setze Schranke = N (die höchste Stelle, wo die Liste nocht nicht geordnet ist). 2. t := 0, für j = 1, 2, . . . , Schranke − 1 gehe zu 3. Gehe danach zu 4. 3. Wenn Rj > Rj+1 , vertausche Rj und Rj+1 und setze t = j. 4. Wenn t = 0 ist, so sind wir fertig. Andernfalls setze Schranke = t und gehe zu 2. Welcher Aufwand ist nötig? (N 2 ) Alles oberhalb des zuletzt bewegten Elements ist schon in der richtigen Reihenfolge und muß nicht mehr beachtet werden. Um die Anzahl der notwendigen Vertauschungen bei bubble sort beurteilen zu können, machen wir einen Ausflug: 10.6 Partitionen und Inversionstabellen Der Folge a = (a1 , . . . , an ) mit {a1 , . . . , an } = {1, . . . , n} (also einer Permutation) ordnen wir ihre Inversionstabelle“ b = (b1 , . . . , bn ) zu, wobei bj = Zahl der ai > j ist, ” die in a links von j stehen, dies ist gleich der Zahl der Inversionen der Form (x, j). Beispiel: a= 1 2 3 4 5 6 7 8 9 5 9 1 8 2 6 4 7 3 Die Inversionen sind (5,1), (9,1), (5,2), (9,2), (8,2), ..., also b = (2 3 6 4 0 2 2 1 0). Es gilt 10 SORTIEREN ≤ ≤ ... 0 ≤ 0 0 b1 b2 bn−1 bn 78 ≤ n−1 ≤ n−2 ≤ = 1 0 Wenn eine Folge b mit dieser Eigenschaft gegeben ist, so existiert eine eindeutig bestimmte Permutation a mit b als Inversionstabelle. Wir finden diese, indem wir sukzessive die relative Stellung von n, n − 1, n − 2, . . . bestimmen. Wir führen das beispielhaft mit dem obigen b durch: 1. Schreibe 9 2. wegen b8 = 1 steht 8 hinter 9: 9 8 3. b7 = 2 ⇒ 7 hinter 9, 8: 9 8 7 4. b6 = 2 ⇒ 6 hinter 2 der Ziffern: 9 8 6 7 5. b5 = 0 ⇒ 5 ganz links: 5 9 8 6 7 6. b4 = 4 ⇒ 4 an 5. Stelle: 5 9 8 6 4 7 7. b3 = 6 ⇒ 3 ganz rechts: 5 9 8 6 4 7 3 8. b2 = 3 ⇒ 5 9 8 2 6 4 7 3 9. b1 = 2 ⇒ 5 9 1 8 2 6 4 7 3 Nützlich ist eine bildliche Darstellung: In einem n × n-Schachbrett zeichnen wir in der i-ten Zeile eien Punkt in Spalte ai . An der Stelle (i, j) machen wir ein Kreuz, wenn darunter und rechts daneben ein Punkt steht. x x x x s x x x x x x x s s x x s x x s s x x x s x s s Die Zahl der Kreuze in der Spalte j ist gleich bj ; die Gesamtzahl der Kreuze ist gleich der Zahl aller Inversionen. Zum transponierten Feld gehört die inverse Permutation a−1 ; da die Anzahl der Kreuze gleichbleibt, hat a−1 genauso viele Inversionen wie a. 10 SORTIEREN 79 Es folgt ein Algorithmus zur Bestimmung der Inversionstabelle in n · log(n) Schritten: Start: b1 = . . . = bn = 0 für k = log(n), log(n − 1), . . . , 0: setze xs = 0 für 0 ≤ s ≤ n/(2k+1 ) für j = 1, . . . , n: r = aj /2k mod2 s = aj /2k+1 wenn r = 0 : baj = baj + xs wenn r = 1 : xs = xs + 1 Bei bubble sort sind so viele Vertauschungen nötig, wie die entsprechende Permutationen Inversionen besitzt. Bei einem Paß von bubble sort verringert sich jeder von 0 verschiedene Eintrag in der Inversionstafel um 1. Eine Verbesserung der Laufzeit ist beim cocktail shaker sort gegeben: Wir durchlaufen die Folge abwechselnd auf- und abwärts. Wenn wir weniger als O(N 2 ) aufwenden wollen, dürfen wir uns nicht auf das Vertauschen benachbarter Objekte beschränken. Die leistet der folgende Algorithmus von Batcher: 1. Sei t die kleinste Zahl mit 2t ≥ N ; setze p = 2t . 2. q = 2t−1 , r = 0, d = p 3. für alle , 0 ≤ i < N mit i IAND p = r: (Die Funktion IAND ergibt das bitweise AND zweier Zahlen.) 4. wenn Ki+1 > Ki+d+1 : tausche Ri+1 und Ri+d+1 ; 5. wenn q = p: setze d = q − p, q = q/2, r = p und gehe zu 3. 6. p = p/2, wenn p > 0, so gehe zu 2. 10.7 Quicksort Dies ist das schnellste bekannte Sortierverfahren. Der Grundgedanke ist, daß der Tausch zweier Feldelemente dann den besten Fortschritt bringt, wenn diese möglichst weit voneinander entfernt sind. Wir wählen zufällig ein Element a aus der Mitte des Feldes und laufen vom linken Rand nach rechts, bis wir ein Element Ri gefunden haben, das größer als a ist. Gleichzeitig laufen wir vom rechten Rand nach links, bis wir ein Element Rj gefunden haben, das kleiner als a ist. Nun vertauschen wir Ri und Rj und fahren fort, bis sich die Indizes getroffen haben. Nun haben wir zwei Teilfelder, deren Elemente alle kleiner (bzw. größer) als a sind. Wir haben die Aufgabe in zwei Teilaufgaben zerlegt. Im Idealfall sind beide Teilfelder gleich groß, im schlechtesten Fall (a ist ein Extremwert) ist ein Teilfeld leer, im zweiten Fall haben wir n2 Arbeitsschritte, das ist nicht gerade quick“. Aber im Durchschnitt“ reichen n log n Schritte aus. ” ” 10 SORTIEREN 80 Ein einfacher, rekursiver Algorithmus ist der folgende: Quicksort(S) Wenn |S| > 1 ist, dann wähle zufällig ein a ∈ S. Setze S1 = {x ∈ S | x < a}, S2 = {x ∈ S | x ≥ a}. Quicksort(S1 ) Quicksort(S2 ) Setze S[i] = S1 [i] für i = 1, . . . , |S1 | = m, S[j] = S2 [j − m − 1] für j = m + 1, . . . , n. Einen cleveren (nichtrekursiven) Algorithmus findet man bei Knuth, Band 3, Seite 114, er stammt von C.A.R. Hoare (1962). Wir wollen dies hier kurz darstellen. Es sollen R1 , . . . RN ∈ Z sortiert werden. Wir setzen R0 = −∞, RN +1 = ∞. Als das Element, das an seine endgültige Stelle gebracht wird, können wir R1 wählen. Alle Vergleiche innerhalb eines Zyklus beziehen sich auf dieses Element; es ist vernünftig, eine Extra-Variable mit dem Wert von R(1) zu belegen, denn Operationen mit (normalen) Variablen sind schneller als welche mit indizierten Variablen. Um die Grenzen von noch zu sortierenden Teilen zu speichern, genügen jeweils zwei Variable, die auf dem Stack abgelegt werden können. Dabei werden die Grenzen des jeweils größeren Teils gemerkt und der kleinere Teil wird bearbeitet. Teilstrukturen der Länge ≤ M werden unsortiert gelassen, damit wird am Ende aufgeräumt. 1. Wenn N ≤ M , gehe zu 9. Sonst: Erzeuge leeren Stack, l := 1, r := N . 2. i := l; j := r + 1, R := Rl . (Neuer Durchgang: Wir sortieren nun Rl , . . . , Rr , es gilt r ≥ l + M, Rl−1 ≤ Ri ≤ Rr+1 für i = l, . . . , r ). 3. (Vergleiche Ri mit R) i := i + 1, solange Ri < R ist. 4. (Vergleiche R mit Rj ) j := j − 1, solange R < Rj ist. 5. Wenn j ≤ i ist, so vertausche Rl mit Rj und gehe zu 7. (Die Indizes haben sich getroffen.) 6. Vertausche Ri und Rj und gehe zu 3. 7. Wenn r − j ≥ j − l > M , so lege (j + 1, r) auf den Stack, setze r = j − 1, gehe zu 2. Wenn j − l > r − j > M ist, so lege (l, j − 1) auf den Stack, setze l = j + 1, gehe zu 2. Wenn r − j > M ≥ j − l ist, setze l = j + 1, gehe zu 2. Wenn j − l > M ≥ r − j ist, setze r = j − 1, gehe zu 2. 8. Wenn der Stack nicht leer ist, so hole (l, r) vom Stack und gehe zu 2. 9. (Nacharbeit, falls M > 1 ist) Für j = 2, . . . , N gehe folgende Schritte: R := Rj , i := j − 1, wiederhole Ri+1 := Ri , i := i − 1, bis Ri ≤ R ist. Setze Ri+1 = R. (Wir lassen Rj sinken.) 10 SORTIEREN 81 Das folgende Programm realisiert diesenAlgorithmus. import java.io.*; public class quick { public static int public static int public static int public static int nn = 20; stack[] = new int[nn+1]; rr[] = new int[nn+1]; i, j, r, l, st, rh, hh; public static void init() // einrichten { random.h = 32003; for (int i = 1; i < nn-1; i++) rr[i] = random.f2n(15, i); rr[0] = -100000; rr[nn-1] = 100000; } public static void s1() { l = 1; r = nn - 1; st = 0; } public static void s2() { i = l; j = r + 1; rh = rr[l]; s3(); } public static void s3() { i++; if (rr[i] < rh) s3(); s4(); } public static void s4() { j--; if (rh < rr[j]) s4(); s5(); } 10 SORTIEREN public static void s5() { if (j <= i) { hh = rr[l]; rr[l] = rr[j]; rr[j] = hh; s7(); } else s6(); } public static void s6() { hh = rr[i]; rr[i] = rr[j]; rr[j] = hh; s3(); } public static void s7() { if ((r-j >= j-l) && (j-l > 1)) { stack[st+1] = j + 1; stack[st+2] = r; st = st + 2; r = j - 1; s2(); } else if ((j-l > r-j) && (r-j > 1)) { stack[st+1] = l; stack[st+2] = j - 1; st = st + 2; l = j + 1; s2(); } else if ((r-j > 1) && (1 >= j-l)) { l = j + 1; s2(); } else if ((j-l > 1) && (1 >= r-j)) { r = j - 1; s2(); 82 10 SORTIEREN 83 } else s8(); } public static void s8() { if (st > 0) { r = stack[st]; l = stack[st-1]; st = st - 2; s2(); } } public static void main(String arg[]) { init(); s1(); s2(); s3(); s4(); s5(); s6(); System.out.println(); for (int i = 1; i < nn; i++) System.out.print(rr[i] + " "); } } 10.8 s7(); s8(); Binärdarstellung der Schlüssel Dies ist eine Variante von quick sort, die die Bitdarstellung der Schlüssel (-Zahlen) nutzt. Wir sortieren nach dem höchstwertigen Bit: zuerst alle mit 0, dann alle mit 1. also: Suche den am weitesten links stehenden Schlüssel Ki mit führendem Bit = 1; Suche den am weitesten rechts stehenden Schlüssel Kj mit führendem Bit = 0. Tausche Ri und Rj , erhöhe i, senke j, bis i > j ist. Sei F0 = { Schlüssel mit führendem Bit = 0 }, sei F1 = { Schlüssel mit führendem Bit = 1 }. Num Sortieren wir F0 und F1 durch Vergleich des zweiten Bits, usw. Im Einzelnen ist folgendes zu tun: (Wenn max(Ki ) < 2m ist, so brauchen wir einen Stack der Größe m − 1 für m-Bit-Zahlen. 1. leeren Stack erzeugen, l = 1, r = N, b = 1 (links, rechts, Bitnummer) 2. Wenn l = r ist, so gehe zu 10. Sonst setze i = l, j = r (Neuer Durchgang: wir untersuchen das Bit b bei Kl , . . . , Kr ). 3. Wenn Bit b von Ki gleich 1 ist, gehe zu 6. 10 SORTIEREN 84 4. i = i + 1; wenn i ≤ j: gehe zu 3 sonst gehe zu 8 5. Wenn Bit b von kj+1 gleich 0 ist, gehe zu 7 6. j = j − 1; wenn i ≤ j: gehe zu 5 sonst gehe zu 8 7. Tausche Ri und Rj+1 , gehe zu 4 8. (Ein Durchgang ist beendet, es ist i = j + 1, Bit b von Kl , . . . , Kj ist 0,Bit b von Ki , . . . , Kr ist 1.) b = b + 1; wenn b > m: gehe zu 10 Wenn j < l oder j = r: gehe zu 2 (Alle b-Bits waren gleich) Wenn j = l, setze l = l + 1, gehe zu 2 (nur ein b-Bit ist 0) 9. Lege das Paar (r, b) auf den Stack (wir merken uns die rechte Grenze, wo noch Bit b geprüft werden muß), setze r = j; gehe zu 2 10. (Stack leeren) Wenn der Stack leer ist, sind wir fertig. Sonst setze l = r + 1, hole (r , b ) vom Stack, setze r = r , b = b und gehe zu 2. 10.9 Sortieren durch direkte Auswahl Wir suchen den kleinsten Schlüssel, geben den Entsprechenden Datensatz aus und ersetzen den Schlüssel durch ∞. Wir wiederholen dies, bis N Sätze bearbeitet sind. Also: Die zu sortierende Liste muß vollständig vorliegen, die Ausgabe erfolgt sequentiell. Dies ist genau das umgekehrte Vorgehen wie beim Sortieren durch Einfügen: dort ist die Eingabe sequentiell, Ergebnisse erhält man erst ganz zum Schluß. Bei jedem Durchgang sind N − 1 Vergleiche nötig. Besser ist es, den ausgewählten Satz an seine“ Stelle zu bringen (Tausch mit dem Inhaber) und später nicht mehr zu ” betrachten: Für j = N, N − 1, . . . , 2 : sei Ki = max(K1 , . . . , Kj ) Tausche Ri und Rj . (dann ist Rj , . . . , RN geordnet). Sind für die Bestimmung des Maximums von N Zahlen wirklich N −1 Vergleiche nötig, ist die Komplexität also immer noch O(N 2 )? √ √ Besser: Wir zerlegen die Daten in N Gruppen zu N Elementen, suchen in jeder Gruppe das Maximum und bestimmen √das Maximum der Gruppenersten; dieses wird √ dann entfernt. Dazu sind etwa N + N Vergleiche nötig. √ Wenn das iteriert wird, können wir in O(N · N ) Schritten sortieren. Man nennt dieses √ Verfahren quadratische Auswahl“. Es ist auch eine kubische Auswahl mit O(N · 3 N ) ” Schritten denkbar, wir nähern uns langsam der magischen Schranke O(n · log(n). 10 SORTIEREN 10.10 85 tree selection Diese Schranke wird wie bei Tennisturnieren im k.o.-System erreicht. Wenn 2n Kandidaten antreten, ist der Sieger nach n Runden ermittelt. Wer aber ist der Zweitbeste? Es ist einer der n Spieler, der gegen den Sieger verloren hat. Wir belegen die Blätter eines binären Baums mit N Blättern durch die Schlüssel Ki und belegen jeweils den Vaterknoten durch das Maximum der beiden Söhne. In der Wurzel haben wir also den Ersten E. Um den zweiten zu finden, ersetzen wir E überall durch −∞ und bilden entlang der Astfolge zur Wurzel wieder die Maxima, die Länge dieses Weges ist gleich log(N ) und wir haben den Zweiten gefunden; usw. Insgesamt sind also N · log(N ) Vergleiche nötig. Dies ist das Peter-Prinzip: Jeder steigt in der Hierarchie so weit auf, bis er sein Nineau der Inkompetenz erreicht hat. 10.11 heap sort Eine Folge K1 , . . . , KN heißt Halde (heap), wenn Ki/2 ≥ Ki für alle i ist, also K 1 ≥ K2 , K 1 ≥ K3 , K 2 ≥ K4 , . . . Demnach ist K1 = max(K1 , K2 , . . . , KN ), das größte Element liegt oben auf der Halde. Man veranschaulicht sich das am besten an einem binären Baum: der Vater ist größer als die beiden Söhne. Es gelte Kj/2 ≥ Kj für l < j/2 < j ≤ N ; dies ist für l = N/2 trivialerweise erfüllt (es gibt kein j). Diese Heap-Bedingung wollen wir schrittweise für kleinere j erhalten. Wir formen die Eingabeliste zu einer Halde um, entfernen die Spitze und bringen sie an die richtige Stelle. 1. setze l = N/2 + 1, r = N 2. (l oder r verkleinern) wenn l > 1 ist, setze l = l − 1, R = Rl , K = Kl sonst setze R = Rr , K = Kr , Rr = R1 , r = r − 1 Wenn nun r = 1 ist, setze R1 = R und beende. 3. (Wir haben eine Halde ab l und Rk hat für r < k ≤ N den richtigen Platz; wir wollen eine Halde ab k mit k = l/2.) setze j = l 4. (ab hier ist i = j/2) Setze i = j, j = 2i wenn j < r ist, gehe zu 5 wenn j = r ist, gehe zu 6 wenn j > r ist, gehe zu 8 10 SORTIEREN 86 5. wenn Kj < Kj+1 setze j = j + 1 (finde größeren Sohn) 6. wenn K ≥ Kj ist, gehe zu 8 7. (hochheben) setze Ri = Rj , gehe zu 4 8. (speichere R) setze Ri = R, gehe zu 2 Die Komplexität ist garantiert N · log(N ), denn in Schritt 4 wird das Intervall halbiert. 10.12 Sortieren durch Mischen (merge sort) Hier haben wir zwei sortierte Folgen, die zu einer Folge vereinigt werden sollen. Wir verleichen die beiden Minima, geben das kleinere aus, entfernen es und beginnen von vorn. Wenn eine der Listen leer ist, so ist die andere Liste der Rest. Seien also x1 ≤ x2 ≤ . . . ≤ xm und y1 ≤ y2 ≤ . . . ≤ yn gegeben. 1. i = 1, j = 1, k = 1 2. wenn xi ≤ yj , so gehe zu 3 sonst gehe zu 5 3. setze zk = xi , k = k + 1, i = i + 1 wenn i ≤ m ist, gehe zu 2 4. Setze (zk , . . . zm+n ) = (yj , . . . yn ) und beende. 5. setze zk = yj , k = k + 1, j = j + 1 wenn j ≤ n ist, gehe zu 2 6. Setze (zk , . . . , zm+n ) = (xi , . . . , xm ) und beende. Der Aufwand ist m + n, d.h. Mischen ist leichter als Sortieren. Dies ist eines der ersten auf Rechnern implementiertes Sortierverfahren (J. v. Neumann, 1945). 10.13 Natürliches Mischen Wir schauen uns die Eingabefolge von beiden Seiten an. Ein Aufstieg“ ist eine Folge ” ai ≤ ai+1 ≤ . . . ≤ ak . Die Gesamtfolge ist eine Folge von Aufstiegen, die je durch einen Abwärtsschritt getrennt werden. Wie gesagt betrachten wir die Folge von links und von rechts. 10 SORTIEREN 87 Beispiel (die markierten Abschnitte stellen jeweils Aufstiege dar): 503 703 765 61 612 908 154 275 426 653 897 509 170 677 512 87 Wir mischen nun den am weitesten links stehenden Aufstieg mit dem am weitesten rechts stehenden Aufstieg zu einem neuen linken Aufstieg und entfernen die alten. Dann mischen wir die beiden nächsten Aufstiege zu einem neuen Aufstieg rechts, usw. Das Ergebnis des ersten Durchgangs ist in unserem Beispiel 87 503 512 677 703 765 154 275 426 653 908 897 612 509 170 61 Als Eingabedaten haben wir R1 , . . . , RN ; wir brauchen einen Hilfsspeicher RN +1 , . . . , R2N , dessen Anfangsinhalt irrelevant ist. 1. s = 0 (Wenn s = 0 ist, bringen wir die Dinge von R1 , . . . , RN nach RN +1 , . . . , R2N , bei s = 1 umgekehrt.) 2. wenn s = 0 ist, setze i = 1, j = N, k = N + 1, l = 2N wenn s = 1 ist, setze i = N + 1, j = 2N, k = 1, l = N d = 1, f = 1 (Ausgaberichtung; noch ein Paß?) 3. (Schritte 3 bis 7: Mischen) wenn Ki > Kj : gehe zu 8 wenn i = j: setze Rk = Ri , gehe zu 13 4. setze Rk = Ri , k = k + d 5. (Abwärtsschritt?) i = i + 1; wenn Ki−1 ≤ Ki : gehe zu 3 6. setze Rk = Rj , k = k + d 7. (Abwärts?) j = j − 1, wenn Kj+1 ≤ Kj : gehe zu 6 sonst gehe zu 12 8. (Schritte 8 bis 11 sind dual zu 3, ..., 7) setze Rk = Rj , k = k + d 9. (Abwärts?) j = j − 1, wenn Kj+1 ≤ Kj : gehe zu 3 10. setze Rk = Ri , k = k + d 11. (Abwärts?) i = i + 1, wenn Ki−1 ≤ Ki : gehe zu 10 12. (Seiten vertauschen) Setze f = 0, d = −d, tausche k ↔ l, gehe zu 3 10 SORTIEREN 88 13. (Tausche unten und oben) wenn f = 0, setze s = 1 − s, gehe zu 2 Sonst sind wir fertig. Wenn s = 0 sein sollte, ist noch der Hilfsspeicher nach unter zu kopieren. Verbesserung: Wir legen fest, daß beim ersten Durchgang alle Aufstiege die Länge 1 haben (d.h. wir prüfen nicht, wo die Aufstiege enden). Dann haben aber beim zweiten Durchgang alle Aufstiege die Länge 2, beim dritten die Länge 4 ... Leider ist der Speicheraufwand mit 2N recht groß, wobei stets die Hälfte des Speichres ungenutzt ist. Besser wäre es, mit verbundenen Listen zu arbeiten. 10.14 list merge sort Wir stellen eine Link-Liste L1 , . . . , LN bereit, wo Zahlen zwischen −N − 1 und N + 1 stehen können; zusätzlich nutzen wir L0 , LN +1 , R0 , RN +1 . Am Ende ist L0 der Index des kleinsten Ri und Lk ist der Index des NAchfolgers von Rk , oder Lk = 0, falls Rk maximal ist. R0 und RN +1 sind die Listenköpfe. Wenn ein Link negavit ist, so ist das Ende einer geordneten Teilliste erreicht. Wir verwenden die Fortran-Funktion SIGN folgendermaßen: Ls = SIGN(p, Ls); dabei behält Ls sein Vorzeichen bei und erhält den Wert p oder -p. 1. setze L0 = 1, LN +1 = 2, LN −1 = LN = 0 fr i = 1, . . . N − 2 : Li = −(i + 2) (Wir haben zwei Teillisten R1 , R3 , R5 , . . . und R2 , R4 , R6 , . . . und die negativen Links bedeuten, daß jede geordnete Teilliste einelementig ist.) 2. (neuer Durchgang; s ist die letzte bearbeitete Eintragung, t ist das Ende der vorher bearbeiteten Liste, p, q durchlaufen die Liste) setze s = 0, t = N + 1, p = Ls , q = Lt . Wenn q = 0 ist, sind wir fertig. 3. wenn Kp > Kq : gehe zu 6 4. setze Ls = SIGN (Ls , p), s = p, p = Lp ; wenn p > 0: gehe zu 3 5. (Teilliste ferstigstellen) setze Ls = q, s = t wiederhole t = q, q = Lq bis q ≤ 0 gehe zu 8 6. setze Ls = SIGN (Ls , p), s = q, q = Lq wenn q > 0: gehe zu 3 7. setze Ls = p, s = t wiederhole t = p, p = Lp bis p ≤ 0 11 DAS CHARAKTERISTISCHE POLYNOM 89 8. (Durchgang fertig, d.h. p ≤ 0, q ≤ 0) setze p = −p, q = −q wenn q = 0: setze Ls = SIGN (Ls , p), Lt = 0, gehe zu 2 sonst gehe zu 3 11 Das charakteristische Polynom Wenn A eine n × n-Matrix und x eine Unbestimmte ist, so heißt die Determinante χA (x) = | xEn − A | das charakteristische Polynom von A. Da die Einträge der Matrix xE − A keine Körperelemente, sondern Polynome (vom Grad 0 oder 1) sind, kann die Determinante nicht mit Hilfe des Gaußschen Algorithmus berechnet werden. Was nun? Für die Leibnizsche Methode wäre eine Polynomarithmetik nötig; der Rechenaufwand ist mit n! unakzeptabel hoch. Wenn man die Tatsache ausnutzen will, daß der Koeefizient von xn−1 in χA (x) gleich der Summe der i-Hautminoren von A ist, so muß man bedenken, daß der Koeffizient n n 2 von x die Summe von n Determinanten ist, allein deren Anzahl ist schon riesig. 2 Wenn man sich aber die Mühe macht und eine Polynomarithmetik implementiert, so geht es leicht. Man braucht die Addition/Subtraktion, Multiplikation und Division mit Rest für Polynome in einer Variablen. Wir bringen dann die Polynommatrix xEn − A schrittweise in Dreiecksform: Durch Zeilen-/Spaltentausch bringen wir die Komponente minimalen Grades an die Position (1,1). Dann dividieren wir mit Rest: ai,1 = a1,1 ·q +r. Wenn wir nun das q-fache der ersten Zeile von der i-ten subtrahieren, erhalten wir in der ersten Spalte gerade r, was Null ist oder aber zumindest von minimalem Grad (aller Komponenten) ist. Dies iterrieren wir, bis die erste Spalte Nullen enthält. Dann ist die zweite Spalte dran usw. Drei Dinge sollen noch angeführt werden: Mit demselben Verfahren kann man die Matrix in Diagonalform überfhren, und man kann es erreichen, daß die Diagonalkomponenten einander teilen. Diese Diagonalform ist eindeutig bestimmt und heißt Smithsche Normalform; die Diagonalkomponenten heißen ihre Invariantenteiler. Der höchste“ Invariantenteiler von xEn − A ist das Mi” nimalpoynom von A. Das Ganze klappt natürlich auch für beliebige Polynommmatrizen. Wenn die Einträge einer Matrix ganzzahlig sind, kann man mit dem beschriebenen Verfahren die Determinante ohne Divisionen berechnen. Wir wollen den Rechenaufwand grob abschätzen: Es entstehen Polynome bis zum Grad n. Um ein solches zu Null zu machen, sind maximal n Zeilenoperationen nötig. Eine Zeilenoperation mit Polynomen kostet etwa n2 Rechenoperationen. Es sind etwa n2 Komponenten zu bearbeiten. Die Zeitkomplexität ist also etwa n5 . 12 GRAPHEN 90 Ein noch besseres Verfahren basiert auf den Newtonschen Formeln 1 , das darüberhinaus fast ohne Divisionen auskommt: Für die Koeffizenten von χA (x) = ai xn−i gilt die Rekursionsgleichung i−j 1 ai = − aj Spur(Ai−j ). i j=0 Also a0 = 1, a1 = −Spur(A), 1 a2 = − Spur((A + a1 E)A), 2 usw. Es sind ungefähr n4 Rechenoperationen notwendig. Das soeben vorgestellte Verfahren stammt von Leverrier und Faddejew. Da der konstante Term des charakteristischen Polynoms bis aufs Vorzeichen gleich der Determinante von A ist, kann diese relativ schnell, fast ohne Divisionen berechnet werden. Nach dem Satz von Hamilton/Cayley gilt ai An−i = 0, Nach Multiplikation mit A−1 (falls dies existiert) kann diese Gleichung nach A−1 aufgelöst werden; die Inverse einer Matrix kann also auch fast ohne Divisionen berechnet werden. Dies wurde in den achtziger Jahren von einem Informatiker wiederentdeckt und publiziert. In einer Rezension des betreffenden Artikels wurde das Verfahren allerdings als numerisch unstabil“, weil Divisionen erfordernd, abqualifiziert. Ich kann mir ” aber kein Berechnungsverfahren zur Matrixinversion vorstellen, das ohne Divisionen auskommt, nur welche mit mehr oder weniger vielen. Daß die Matrixinversion genauso schwierig wie die Matrixmultiplikation ist, erkennen wir daran, daß sich die Multiplikation auf die Inversion zurückführen läßt: E 0 0 12 A E 0 −1 0 B E E =0 0 −A AB E −B 0 E Graphen Definition: Sei E eine Menge, deren Elemente wir Ecken“ nennen werden, und K eine ” Menge ungeordneter Paare (e1 , e2 ) mit ei ∈ E. Dann nennen wir das Paar G = (E, K) einen Graphen. Die Elemente von K nennen wir die Kanten von G. Wir sagen, die Kante (e1 , e2 ) verbindet die Ecken e1 und e2 . Ein Graph G1 = (E1 , K1 ) heißt Teilgraph von G, wenn E1 ⊂ E und K1 ⊂ K ist. 1 Für einen Beweis vgl. Grassmann, Algebra und Geometrie I, II, III im Kapitel Polynome“; ” http://www-irm.mathematik.hu-berlin.de/ ˜hgrass. 12 GRAPHEN 91 G1 heißt spannender Teilgraph von G, wenn E1 = E ist. Sei x ∈ E, dann bezeichnen wir mit deg(x) = |{y ∈ E | (x, y) ∈ K}| den Grad der Ecke x, es ist die Anzahl der Kanten, die von x ausgehen. Ein Graph heißt vollständig, wenn jeweils zwei seiner Ecken verbunden sind. Lemma 12.1 x∈E deg(x) = 2 |K| . Beweis: Jede Kante verbindet zwei Ecken. Definition: Eine Folge x1 , . . . , xn von Ecken heißt Weg der Länge n, wenn jeweils (xi , xi+1 ) ∈ K gilt. Mit d(x, y) bezeichnen wir den Abstand der Ecken x, y, dies ist als die Länge des kürzesten Wegs von x nach y definiert. Ein Graph heißt zusammenhängend, wenn es zu zwei beliebigen Ecken einen Weg gibt, der sie verbindet. Wir nennen G einen gewichteten Graphen, wenn jeder Kante (x, y) eine positive reelle Zahl d(x, y) zugeordnet ist, dies nennen wir das Gewicht der Kante und als Abstand zweier Ecken wählen wir die die Summe der Gewichte eines kürzesten Verbindungswegs. Wir wollen nun ein Verfahren von Dijkstra (1959) behandeln, das einen kürzesten Weg zwischen zwei Ecken v und w eines Graphen bestimmt. Dazu führen wir folgende Funktion ein: Sei v ∈ U ⊂ E eine Menge von Ecken, die v nicht enthält. Für x ∈ U setzen wir d(v, x), wenn es einen Weg v = v1 , . . . , vn = x mit vi ∈ U für i = 1, . . . , n − 1 gibt, lU (x) = ∞ sonst. Sei nun x1 ∈ U die Ecke in U , für die das Minimum lU (x1 ) = min(lU (x) | x ∈ U ) angenommen wird. Dann ist lU (x1 ) die Länge des kürzesten Weges von v nach x1 , denn andernfalls gäbe es einen kürzeren Weg von v nach x1 . Dieser Weg kann aber nicht vollständig (bis auf x1 ) außerhalb von U liegen, da ja lU (x1 ) minimal sein sollte. Dieser Weg enthält also eine Ecke z aus U und wir hätten lU (z) < lU (x1 ), ein Widerspruch. (Für die anderen Ecken x aus U kann es Wege von v aus geben, die kürzer als lU (x) sind.) Wie kann man nun lU bestimmen? Im folgenden Algorithmus haben wir stets die folgende Situation: 1. Für jede Teilmenge U ⊂ E kennt man lU (x) für alle x ∈ U . 2. Für alle y ∈ W = E − U kennt man einen kürzesten Weg von v nach y, der U nicht berührt. Also: Man kennt für jede Ecke die Länge des kürzesten Wegs von v aus, der mit Ausnahme höchstens der letzen Ecke außerhalb von U verläuft; für die Ecken außerhalb von U sind dies überhaupt die kürzesten Wege. Sei nun z ∈ U beliebig, wir setzen V = U − {z}. Wenn nun x ∈ U ist, so gilt lV (x) = min(lU (x), lU (z) + d(z, x)). Beweis: Von v gelangt man auf kürzestem Weg, ohne V vorzeitig zu betreten, über z oder nicht über z. Wenn wir nicht über z gehen, so wird U auch nicht betreten, 12 GRAPHEN 92 also ist lV (x) = lU (x). Im anderen Fall ist z die vorletzte Ecke eines kürzesten Weges von v nach x, denn sonst gäbe es ein y ∈ U auf einem Weg zwischen v und x und wir kennen einen kürzesten Weg von v nach y, der U nicht berührt. Damit hätten wir einen kürzesten Weg, der z nicht enthält, im Widerspruch zu Voraussetzung. Also ist hier lV (x) = lU (z) + d(z, x). Nun kommen wir zum Algorithmus, der d(v, x) bestimmt und einen Weg dieser Länge aussucht. 1. Initialisierung: W = ∅, U = E − W, lU (v) = 0, lU (x) = ∞ für x = v, p(x) = > für alle x ∈ E, dies wird später die nächste Weg-Ecke sein. 2. Sei z ∈ U die Ecke mit minimalen lU -Wert (im ersten Schritt ist dies v), wir setzen d(v, z) = lU (z). 3. Setze W := W ∪ {z}, V = U − {z}. Für alle x ∈ V setzen wir lV (x) = min(lU (x), lU (z) + d(z, x)) und wenn dabei lU (x) > lU (z) + d(z, x) ist, so setzen wir p(x) = z. Nun setzen wir U := V . 4. Wenn W = E ist, so sind wir fertig. Wenn lU (x) = ∞ für alle x ∈ U ist, so ist der Graph nicht zusammenhängend. Andernfalls gehe zu 2. Der Arbeitsaufwand hat die Größenordnung |K 2 |. Einen kürzesten Weg von x nach v geht durch die Ecken x, p(x), p(p(x)), . . .. Wir führen dies in einem Beispiel durch. Wir betrachten den folgenden gewichteten Graphen (die Gewichte sind an die Kanten herangeschrieben): b 2 d r ✧ ✧ r❛ ❛ ✪ ❛❛ 7 5 ✧ 9 ✪ ❛❛ ✧ ✪ ❛ ❛r f ✧ ✪ # ✧ # 2 4 a r✧ # ✪ ❜ # ❜ 2 ✪ 7 ❜ #2 ✪ ❜ # ❜ ✪ # ❜ ❜ ❜✪ r r # ✧ c 8 e Wir suchen die kürzesten Wege zwischen a und den anderen Ecken. Bei der Initialisierung wird W = ∅, U = {a, b, c, d, e, f }, lU (a) = 0, l(x) = ∞ sonst, p(x) = > für alle x. Beim ersten Durchlauf wird z = a, d(a, a) = 0, W = {a}, U = {b, c, d, e, f }, lU (b) = 5, lU (c) = 2, p(b) = a, p(c) = a. Zweiter Durchlauf: z = c, d(a, c) = 2, W = {a, c}, U = {b, d, e, f }, lU (b) = 4, lU (d) = 9, l(e) = 10, p(b) = c, p(d) = c, p(e) = c. Dritter Durchlauf: z = b, d(a, b) = 4, W = {a, c, b}, U = {d, e, f }, l(d) = 6, l(f ) = 13, p(d) = b, p(f ) = b. Vierter Durchlauf: z = d, d(a, d) = 6, W = {a, b, c, d}, U = {e, f }. Fünfter Durchlauf: z = e, d(a, e) = 10, W = {a, b, c, d, e}, U = {f }, l(f ) = 12, p(f ) = e. 12 GRAPHEN 93 Letzter Durchlauf: z = f, d(a, f ) = 12, U = ∅. Als Datenstrukturen für die Verwaltung bieten sich die folgenden an: 1. Die Adjazenzmatrix des Graphen: AG = (aij ) mit aij = 1, 0 (ei , ej ) ∈ K . sonst Dies ist nicht besonders effektiv, wenn viele Nullen gespeichert werden. 2. Wenn der Grad der Eckpunkte durch eine Zahl d beschränkt ist, so kann man die Information in einer verketteten Liste ablegen, deren einzelne Zellen folgende Daten enthalten: • laufende Nummer des Eckpunkts, • Anzahl der Nachbarn, • ein Feld der Größe d für die Nummern der Nachbarecken. 3. Für binäre Graphen, das sind solche, wo jeder Eckpunkt einen Vater“ und ” höchstens zwei Söhne“ hat, kann man ein Feld g mit folgenden Informationen ” anlegen: Die Söhne von g[i] sind g[2i] und g[2i + 1], der Vater von g[i] ist g[i div 2]. Welche Informationen kann man der Adjazenzmatrix eines Graphen entnehmen? m 2 ✒ 1m✛ ✲ m 3 ❅ ❅ ❘ ❅ m 4 m 5 Dies ist ein gerichteter“ Graph; wir verallgemeinern die Zuordnungsvorschrift für die ” Adjazenzmatrix (aij ) wie folgt: die Zahl aij bezeichnet das Gewicht“ der Kante (i, j), ” es ist aij = 0, wenn (i, j) ∈ K ist; die Adjazenzmatrix ist also genau dann symmetrisch, wenn der Graph ungerichtet“ ist. In dem Beispiel ist ” 0 1 0 0 0 0 0 1 1 0 . A= 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 Mit unserer Verallgemeinerung entspricht nun aber jeder Matrix ein Graph, ggf. schreibt man an jeder Kante ihr Gewicht an. Welcher Graph gehört dann zur Summe der Adjazenzmatrizen zweier Graphen? Welcher Graph gehört zur transponierten Adjazenzmatrix? Was ist mit Matrixprodukten? 12 GRAPHEN Hier ist 0 1 A2 = 0 0 0 94 0 0 0 1 0 1 0 0 0 0 1 0 0 0 0 0 0 , 0 0 0 1 0 A3 = 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 1 0 0 0 , 0 0 0 A4 = A3 Die Einträge in A2 entsprechen den Wegen der Länge 2 im ursprünglichen Graphen. Die Erreichbarkeitsmatrix“ ” ? Ai läßt alle möglichen Verbindungen erkennen. i=0 Einem Verkehrsnetz K können wir eine Matrix mit den Einträgen ars = Entfernung r → s, ohne einen anderen Ort zu durchlaufen, zuordnen (die Matrix muß angesichts von Einbahnstraßen nicht symmetrisch sein). Wir führen hier eine neue Matrixmultiplikation ein (genauer: nur eine Potenzierung): A(2) rs = min (ark + aks ). 1≤k≤n Dann gibt es ein i mit A(i+1) = A(i) und aus dieser Matrix kann man die kürzeste Verbindung zweier Orte ablesen. Beispiel: 0 14 8 A= 7 5 12.1 14 8 7 5 0 10 ∞ ∞ , 10 0 3 ∞ ∞ 3 0 ∞ ∞ ∞ ∞ 0 A(2) 0 14 8 = 7 5 14 8 7 5 0 10 13 19 . 10 0 3 13 13 3 0 12 19 13 12 0 Bäume Definition: Ein Graph G = (E, K) heißt Baum, wenn die folgenden äquivalenten Bedingungen erfüllt sind: 1. Je zwei Ecken sind durch genau einen Weg verbunden. 2. G ist zusammenhängend und |K| = |E| − 1. 3. G enthält keinen Kreis, aber beim Hinzufügen einer Kante entsteht ein Kreis. Wir zeichnen eine Ecke eines Baums aus und nennen sie Wurzel. Wenn wir uns einen Graphen als aus Stäben hergestellt vorstellen, die an den Eckpunkten Gelenke haben, so fassen wir einen Baum bei seiner Wurzel und heben ihn an. Dann sieht es etwa so aus: Niveau Wurzel ✈ 0 ✏✏PPP ✏ P ✏ P✈ ✈ ✏ 1 ✈ ❢ ❅ ❅✈ ❢ ❢ ❅ ❢❅❢ 2 ❅❢ ❢ ❅ 3 12 GRAPHEN 95 Man erhält so eine hierarchische Struktur. Die Ecken vom Grad 1 nennt man die Blätter des Baums. Man nennt einen binären Baum regulär, wenn jeder Eckpunkt, der kein Blatt ist, genau zwei Söhne hat. Also: Jede Ecke hat den Grad 1, 2 oder 3 und es gibt genau einen Knoten vom Grad 2, die Wurzel, | K |=| E | −1. Reguläre binäre Bäume haben folgende Eingenschaften: 1. Die Anzahl der Ecken ist ungerade, denn es gibt genau ein x ∈ E mit deg(x) = 2, die anderen Ecken haben einen ungeraden Grad und die Summe der Grade ist eine gerade Zahl. 2. Wenn |E| = n ist, so gibt es (n + 1)/2 Blätter. Beweis: Sei p die Zahl der Blätter, dann gibt es n − p − 1 Ecken vom Grad 3, also 1 |K| = n − 1 = (p + 3(n − p − 1) + 2) 2 2n − 2 = 3n − 2p − 1 −n − 1 = −2p 3. Die maximale Zahl der Ecken auf dem Niveau k ist 2k . 4. Wenn ein Baum die Höhe h und n Knoten hat, so ist h ≥ log2 (n + 1) − 1. Durch Bäume lassen sich arithmetische Ausdrücke beschreiben: Die inneren Knoten markieren wir mit +, −, >, / und die Blätter mit Variablen oder Konstanten. Beispiel: (a − b) > c + (c − a)/(b − c) ✛✘ + ✛✘ ✚✙ ✛✘ > / ✚✙ ✚✙ ✛✘ ✛✘ ✛✘ ✛✘ – c – – ✚✙ ✚✙ ✚✙ ✚✙ ✛✘ ✛✘ ✛✘ ✛✘ ✛✘ ✛✘ a b c a b c ✚✙ ✚✙ ✚✙ ✚✙ ✚✙ ✚✙ Oft ist es nötig, alle Eckpunkte eines Baums einmal zu durchlaufen. Hierfür gibt es drei rekursive Methoden: • Inorder-Durchlauf: 1. Durchlaufe den linken Teilbaum der Wurzel nach Inorder. 2. Besuche die Wurzel. 3. Durchlaufe den rechten Teilbaum nach Inorder. 12 GRAPHEN 96 • Preorder-Durchlauf: 1. Besuche die Wurzel. 2. Durchlaufe den linken Teilbaum nach Preorder. 3. Durchlaufe den rechten Teilraum nach Preorder. • Postorder-Durchlauf: 1. Durchlaufe den linken Teilbaum nach Postorder. 2. Durchlaufe den rechten Teilbaum nach Postorder. 3. Besuche die Wurzel. Wir illustrieren diese drei Methoden am obigen Beispiel und schreiben die Markierung der besuchten Punkte nacheinander auf: • Inorder: a - b * c + c - a / b - c, dies ist ist der oben angegebene Term, allerdings ohne Klammern. • Predorder: + * - a b c / - c a - c b, man nennt dies die Präfixnotation oder polnische Notation. • Postorder: a b - c * c a - b c - / +, dies heißt Postfixnotation oder umgekehrte polnische Notation (UPN). Wir sehen, daß sich bei den beiden letzten Notationen der Term (a − b) > . . . ohne Klammern eindeutig darstellen läßt. Die drei genannten Durchmusterungsalgorithmen sind rekursiver Natur. Wir geben für den Inorder-Durchlauf eines binären Baums einen sequentiellen Algorithmus an. Dabei stellen wir uns den Baum als verkettete Liste mit den Einträgen RECHTS, LINKS (Zeiger auf die beiden Söhne) und INHALT (z.B. ein Name) vor. Weiter haben wir eine globale Variable STACK, einen Stack, den wir mit einer Prozedur PUSH füllen und mit POP leeren. TOP(STACK) ist der oberste Eintrag. EMPTY stellt fest, ob der Stack leer ist und der Zeigerwert NULL gibt an, daß die Liste zu Ende ist. 1: subroutine inorder(q) integer q, p p = q while p<>0 push(p,stack) p = links(p) end while if not empty(stack) p = top(stack) pop(stack) print *, inhalt(p) p = rechts(p) end if if not (empty(stack) and (p = null)) goto 1 end Jeder Knoten wird einmal auf den Stack gelegt und wieder entfernt. Bäume eignen sich gut zur Strukturierung einer Datenmange, in der immer wieder gesucht werden muß. Seien also die Elemente x1 , . . . , xn in die Knotes eines binären Suchbaums eingetragen und zwar so, daß im linken Unterbaum eines Knotens xi alle kleineren und im rechten Unterbaum alle größeren Elemente zu finden sind. Dann ist 12 GRAPHEN 97 die Zahl der notwendigen Vergleiche, um ein Element zu finden, zu löschen oder an der richtigen Stelle einzufügen, durch die Tiefe des Baums beschränkt. Die besten Resultate wird man erhalten, wenn es gelingt, den Baum möglichst gleichmäßig zu gestalten und nicht zu einem einzigen Zweig ausarten zu lassen. Beim Abarbeiten eines Suchbaums mit dem Inorder-Verfahren werden die Elemente in der gegebenen Ordnung durchsucht. Man kann sie sich also in der sortierten Reihenfolge ausgeben lassen. hier: balancierte Bäume Präfix-Codes Wir betrachten einen binären Baum, wo jede Kante zu einem linken Sohn mit 0 und jede Kante zu einem rechten Sohn mit 1 markiert ist. Den Blättern ordnen wir Buchstaben eines Alphabets zu, den inneren Knoten wird nichts zugeordnet. ❜ a ❜ ✟✟❅ ✟✟ ❅ ✟ ❜✟ ❅❜ ❅ ❅ ❅ ❅ ❜ ❅❜ ❅❜ ❅ ❅ d ❅ ❅ ❜ ❅❜ ❅❜ ❜ ❅ b e f ❅ ❅❜ c Den zu den Blättern führenden Wege entsprechen die Worte 00, 010, 0111, 10, 110, 111. Keines dieser Worte ist Anfangsstück eines anderen, denn den Anfangsstücken eines Wortes entsprechen innere Knoten und keine Blätter. Wenn man mit einem solchen Baum ein Alphabet kodiert, so kann man aus gesendeten 0-1-Folgen die einzelnen Code-Werte eindeutig entnehmen. Z.B. bedeutet 00101110111 die Folge adfc. Ermitteln Sie die Baumdarstellung des Präfixcodes aus den Worten 010, 011, 00111, 1010, 111001, 111010, 111011. Durchmusterung der Kanten eines Graphen Definition: Ein Baum, der ein spannender Teilgraph eines Graphen G ist, heißt spannender Baum; die restlichen Kanten heißen Sehnen. Lemma 12.2 Ein Graph besitzt genau dann einen spannenden Baum, wenn er zusammenhängend ist. Beweis: Aus der Existenz eines spannenden Baums folgt der Zusammenhang. Umgekehrt: Ein zusammenhängender Graph ist entweder ein Baum oder er enthält einen Kreis. Wenn wir eine Kreis-Kante weglassen, bleibt der Rest-Graph zusammenhängend. Der Rest-Graph ist einweder ein Baum oder er enthält einen Kreis. Und so weiter. Wir betrachten zwei Methoden: 12 GRAPHEN 98 1. Breitensuche (breadth-first-search) Wir befinden uns in einem Knoten. Als erstes durchlaufen wir alle angrenzenden Kanten. Dann gehen wir zu einem Nachbarknoten und beginnen von vorn. 2. Tiefensuche (depth-first-search) Wir befinden uns in einem Knoten. Wir betrachten eine noch nicht begangene Kante und gehen zu deren anderen Endknoten und beginnen von vorn. Wenn es nicht weitergeht, müssen wir umkehren (backtracking). Wir betrachten die Tiefensuche genauer. Wir werden sehen, daß wir dadurch einen spannenden Baum und eine Nummerierung der Knoten konstruieren können. Algorithmus zur Tiefensuche: x0 sei der Startknoten und N (x) die Nummer von x. Es entsteht der spannende Baum B und die Menge der Rückkehrkanten R besteht aus den übrigbleibenden Sehnen. 1. x := x0 , i := 0, B := ∅, R := ∅, N (x) := 0 für alle x. 2. i := i + 1, N (x) := i 3. Wenn es eine noch nicht durchlaufene Kante (x, y) gibt, so gehe nach y; wenn nicht, so gehe zu 5. 4. Wenn N (y) = 0 ist, so wurde y noch nicht besucht. Füge dann (x, y) zu B hinzu, setze x = y und gehe zu 2. Wenn 0 = N (y) < N (x), so war man schon in y. Füge dann (x, y) zu R hinzu und gehe zu 3. (Nun sind wir wieder in x). 5. Wenn es eine Kante (z, x) ∈ R gibt, so gehen wir nach z zurück. Wir setzen x = z und gehen zu 2. Andernfalls ist x = x0 und wir haben alle Kanten durchlaufen. Wir führen das an einem Beispiel durch: x3 x2 ❜ ❜ ✧ ✧ ✧ ❅ ❜ ❜ ❜ ✧ ✧ ❜ ✧ ✧ ✧ ✧ ✧ ✧ ❜ ❜ ✧ ❜✧ ✧❜ ✧ ❜ ❅ ❅ ❅ ❅ x4 ❜ ❜ ❜ ❜ x1 x0 Wir können z.B. folgenden Durchlauf machen: (x0 , x1 ), (x1 , x2 ), (x2 , x0 ), (x2 , x3 ), (x3 , x0 ), (x3 , x1 ), (x2 , x4 ), (x4 , x0 ) 12 GRAPHEN 99 Dabei entsteht der spannende Baum B = {(x0 , x1 ), (x1 , x2 ), (x2 , x3 ), (x2 , x4 )} und als Sehnen verbleiben R = {(x2 .x0 ), (x3 , x0 ), (x3 , x1 ), (x4 , x0 )}. Die Tiefensuche hat folgende Eigenschaften. 1. Die Knoten werden mit 1, . . . , n numeriert und die Kanten erhalten eine Richtung, sie werden orientiert. 2. Die Kanten im Baum bilden einen gerichteten spannenden Baum mit der Wurzel x0 ; wenn eine Kante (x, y) zum Baum gehört, so gilt N (x) < N (y). 3. Die Kanten in R sind die Sehnen, aus (x, y) ∈ R folgt N (x) > N (y). 12.2 Gefädelte binäre Bäume Um einen binären Baum darzustellen, merken wir uns zu jedem Knoten die Adressen LINKS, RECHTS seines linken und rechten Sohns. Wenn ein Sohn fehlt, wird diese Adresse auf 0 gesetzt. Bei einem binären Baum mit n Knoten wird also Platz für n + 1 Nullen gebraucht. Diesen Speicherplatz kann man sinnvoller nutzen, wenn man sich merkt, daß ein Sohn fehlt (das braucht ein Bit) und sich lieber die Adressen des Vorgängers bzw. Nachfolgers des aktuellen Knotens (bei einer fixierten Durchlaufordnung) merkt. Solche Verbindungen eines Knotens zu seinem Vorgänger/Nachfolger heißen Fäden. Man braucht also in jedem Knoten p eine 2-Bit-Information L(p), R(p) darüber, ob unter LINKS und RECHTS die Adressen von Sohnen oder aber Fäden zu finden sind (es liegt nahe, hierfür das Vorzeichen-Bit zu verwenden). true false L(p) LINKS = Sohn LINKS = Vorgänger R(p) RECHTS = Sohn RECHTS = Nachfolger Der folgende einfache Algorithmus gibt den Inorder-Nachfolger q = nach(p) eines Knotens p an: 1. Wenn R(p) = false, dann q = RECHTS(p), 2. Wenn R(p) = true ist, so ersetze q durch LINKS(q), solange L(q) = true ist. 3. q ist der Inorder-Nachfolger von p. Aufgabe: Erstellen Sie einen Algorithmus zur Bestimmung des Vorgängers von p. Im folgenden Bild ist ein binärer Baum nebst Fäden zu den Inorder-Vorgängern bzw. -Nachfolgern dargestellt: 1 2 3 4 5 6 7 8 9 links 2 4 5 0 3 5 8 8 7 rechts 3 1 7 2 6 3 9 7 0 t t t f f f t f f L t f t f t f t f f R 12 GRAPHEN 100 Wir bemerken, daß zum Durchlauf eines gefädelten Baums kein Stack verwaltet werden muß. Der folgende Algorithmus fügt einen Knoten q als rechten Sohn des Knotens p ein, falls p keinen rechten Sohn hatte; wenn der rechte Sohn vorhanden ist, wird q zwischen p und rechts(p) eingefügt. 1. (Parameterübergabe) rechts(q) = rechts(p), R(q) = R(p), rechts(p) = q, R(p) = true, L(q) = false, links(q) = p. 2. (War rechts(p) ein Faden?) Wenn R(q) = true, so setze links(nach(q)) = q. Dabei bestimmt man nach(q) mittels des obigen Algorithmus. (Wenn links und rechts vertauscht werden, so ist nach durch vor zu ersetzen.) Wir befassen uns num mit einer Darstellung gefädelter binärer Bäume im Computer. Die Knoten mögen folgende Form haben: L links Typ R rechts Inhalt Es ist notwendig, alle Algorithmen so zu entwerfen, daß sie auch bei leeren binären Bäumen korrekt arbeiten. Wenn t die Adresse eines Baums ist, so sollte unter der Adresse nach(t) der Inhalt des ersten Knotens stehen. Dazu fügen wir einen Kopf head ein, dies ist der folgende Knoten: Sohn Anfang Sohn head Wenn der Baum aber leer ist, setzen wir Faden head Sohn head Der Baum wächst am linken Sohn des Kopfes. Die Fäden, die auf Null zeigen, werden auf den Kopf gelenkt. In einer Feld-Darstellung eines Baums hätte der Kopf die Adresse 0; wir werden aber eine Listen-Darstellung von Bäumen verwenden, um mit mehreren Bäumen gleichzeitig arbeiten zu können und dabei nicht für jeden (möglicherweise leeren) Baum ein großes Feld bereitstellen zu müssen. Algorithmus zu Bestimmung des Preorder-Nachfolgers q = pnach(p) des Knotens p aus der Inorder-Fädlung: 1. Wenn L(p) = Sohn ist, setze q = links(p) und beende. 2. Setze q = p. Wenn R(p) = Sohn, gehe zu 3. Sonst wiederhole q = rechts(q), bis R(q) = Sohn. 3. Setze q = rechts(q). Das muß man mal probieren. Kopieren binärer Bäume: Am Anfang zeigt head auf t, u zeigt auf u (leerer Baum); am Ende zeigt u auf t. 12 GRAPHEN 101 1. p = head, q = u, gehe zu 4. 2. (Ist rechts etwas?) Wenn p einen nichtleeren rechten Unterbaum hat, so beschaffe einen neuen Knoten r und füge r rechts von q ein. 3. Info(q) = Info(p) (alles kopieren.) 4. (Ist links etwas?) Wenn p einen nichtleeren linken Unterbaum hat, beschaffe r und füge r links von q ein. 5. (Weitersetzen) p = pnach(p), q = pnach(q) 6. Wenn p = head ist (das ist genau dann der Fall, wenn q = rechts(u), falls u einen nichtleeren rechten Unterbaum hat), so beende; sonst gehe zu 2. Wir werden versuchen, derartige Baumstrukturen zur Formelmanipulation zu verwenden. 12.3 Paarweises Addieren Wir haben dieses Problem schon am Anfang besprochen. Es soll ni=1 = ((a1 +a2 )+(a3 + a4 ) + · · · berechnet werden. Dazu stellen wir uns einen binären Baum mit n Blättern vor, in denen wir die ai eintragen. Dann belegen wir schrittweise jeden Knoten durch die Summe seiner Söhne, die wir daraufhin entfernen. In der Wurzel erhalten wir die Summe. Das folgende Programm stellt eine einfache Implementation dieses Gedankens dar. static public class paar { int bb = 8000; float t[] = new float[bb * 2] float s, v; public static int nextpower(w) { int w, p2 = 1; while (w > p2) p2= p2 * 2; return p2; } public static void main(String a[]) { int i, k, kk; kk= bb; k= nextpower(kk); 12 GRAPHEN 102 for (i = k, i <= kk+k-1; i++) { v = Math.random(); t(i) = v * i; } for (i = kk+k; i <= 2*k; i++) t[i]= 0.0; while (k > 1) { i = 0; while (i < k) { t[(k+i)/2] = t[k+i] + t[k+i+1]; i = i + 2; } k= k / 2; } } 12.4 Baum-Darstellung des Speichers Unter dem Gesichtspunkt der Langzahlarithmetik erscheint es als sinnvoll, den Speicher in Blöcken der Größe 2k , 2k+1 , . . . , 2m zu organisieren (bei der Multiplikation verdoppelt sich der Platzbedarf). Zwei benachbarte Blöcke der Größe 2i können zu einem Block der Größe 2i+1 verbunden werden. Wir stellen uns also den Speicher als binären Baum, also hierarchisch geordnet, vor. ✘✉ ✘ ✘✘✘ ✘✘✘ ✘ ✘✘ ✘ ✉ ✘ ✘ ✘✘✘ ✘ ❡ ✉ ❍ ✟ ❍ ✟✟❍❍ ✟ ❍❡ ❍✉ ✟ ✟ ✉ ❡ ❅ ❅ ❅ ❅❡ ✉ ❅✉ ✉ ❅❡ ❡ ❅❡ ❡ ❅ ❡ ✘✘ ✘ ✘ ✘ ✘ ❡ ❡ ❍ ✟ ❍ ✟✟❍❍ ✟ ❍❡ ❍❡ ✟ ✟ ❡ ❡ ❅ ❅ ❅ ❅❡ ❡ ❅❡ ❡ ❅❡ ❡ ❅❡ ❡ ❅ Jeder Vater der Größe m hat zwei Söhne der Größe m/2. Wir teilen jedem Knoten mit, ob er frei ist — in diesem Fall sind auch all seine Nachkommen frei, oder ob er besetzt ist — in diesem Fall sind auch all seine Vorgänger besetzt (d.h. nicht im ganzen Ausmaß zu vergeben). Wenn ein Vater besetzt ist, so kann man bei den Söhnen nachfragen, welcher ggf. frei ist. Der Baum möge N Blätter besitzen, er hat also die Tiefe log(N ). Wie wir früher gesehen haben, sind die Adressen der Söhne in einem binären Baum leicht aus der des Vaters zu berechnen (und umgekehrt), wenn der Baum in einem linearen Feld dargestellt wird. Es sind also folgende Operationen nötig: Wenn ein freier Block der Größe s gesucht wird, so durchläuft man im entsprechenden Niveau des Baums alle Knoten, bis man einen freien gefunden hat. Die Blöcke der 12 GRAPHEN 103 Größe s haben die Tiefe t = ld(N ) − ld(s) − 1, sie haben die Nummern 2t , . . . , 2t+1 − 1. Wenn ein freier Knoten i gefunden wurde, so sind all seine Vorfahren i div 2, i div 4, . . . als belegt zu melden, und die Knoten des Unterbaums mit der Wurzel i ebenfalls. Den Unterbaum durchlaufen wir mit einer der behandelten Durchlaufungsmethode. Wenn ein Knoten freigegeben wird, sind die Nachkommen freizugeben. Wenn der Bruder auch frei ist, so ist der Vater freizugeben und mit diesem ist ebenso zu verfahren. Aus der Nummer eines gefundenen freien Knotens berechnet man die Stelle in memo, wo man seine Daten eintragen kann. Es folgt eine Implementation: import java.io.*; public class baum { public static int MaxLevel = 7, N = 128, LU = N / 2, Width = N * 2; // N = 2 ^ maxlevel, LU = N / 2 (= linke untere Ecke public static boolean tree[] = new boolean[N + 1]; public static int ld(int i) // { int l, j; l = -1; j = 1; while (j < i) { j = j * 2; l++; } return l + 1; } public static int two2(int i) { int j, t = 1; for (j = 1; j <= i; j++) t = 2 * t; return t; } public static int left(int i) { if (i < LU) return 2*i; else { Log zur Basis 2 } 12 GRAPHEN 104 return 0; } public static int right(int i) { if (i < LU) return 2*i + 1; else return 0; } public static int father(int i) { if (i > 0) return i/2; else return 0; } public static int brother(int i) { if (2*(i/2) != i) // odd return i - 1; else return i + 1; } public static int search4(int s) // { int z, i; z = two2(ld(N) - ld(s) - 1); for (i = z; i <= 2*z - 1; i++) if (tree[i]) return i; if (!tree[2*z - 1]) return 0; return 0; } { sucht einen Platz der groesse s } public static void putinorder(int i, boolean b) { int[] stack = new int[LU]; int j = i, st = 0; while (true) { 12 GRAPHEN 105 while (j != 0) { st++; stack[st] = j; j = left(j); } if (st > 0) { j = stack[st]; st--; tree[j] = b; j = right(j); } if ((st <= 0) && (j > 0)) break; } } public static void get(int i) { int j = i; while (tree[j]) { tree[j] = false; j = j / 2; } putinorder(i, false); } public static void free(int i) //gibt Stelle i frei { boolean weiter = true; while (weiter) { if (i == 1) break; tree[i] = true; putinorder(i, true); System.out.print(" freigegeben: " + i); if (tree[brother(i)]) //unterhalb von i kann nichts frei sein } i = father(i); else weiter = false; } } 12 GRAPHEN 106 static char bild[][] = new char[MaxLevel+2][Width+2]; public static void drawtree(int Level) { int x, i, j, C; if (Level < MaxLevel) drawtree(Level + 1); x = Width / two2(Level); C = Width / two2(Level - 1); j = two2(Level - 1); for (i = 0; i < j; i++) { if (tree[j + i]) { bild[Level][x] = ’<’; bild[Level][x+1] = ’>’; } else { bild[Level][x] = (char)((i+j) / 10 + 48); bild[Level][x+1] = (char)((i+j) % 10 + 48); } x = x + C; } } public static void main(String arg[]) { int i; for (i = 1; i <= N; i++) tree[i] = true; System.out.println("belegte Bloecke"); drawtree(1); for (i = 0; i < MaxLevel; i++) System.out.println(bild[i]); while (true) { System.out.print("(zum Freigeben: 0); angeforderte Blockgroesse: "); i = B.readint(); if (i <= 0) break; i = search4(i); if (i == 0) { 12 GRAPHEN 107 System.out.println("kein Platz"); break; } get(i); System.out.println("Nummer des belegten Blocks: " + i); System.out.println("belegte Bloecke"); drawtree(1); for (i = 0; i < MaxLevel; i++) System.out.println(bild[i]); System.out.println(); } while (true) { System.out.print("(zum Belegen: 0); streiche Block mit Nummer: "); i = B.readint(); if (i <= 0) break; free(i); System.out.println("belegte Bloecke"); drawtree(1); for (i = 0; i < MaxLevel; i++) System.out.println(bild[i]); System.out.println(); } } } 12.5 Balancierte Bäume Ein binärer Baum heißt balanciert, wenn für jeden Knoten gilt: Die Höhne des rechten und des linken Unterbaums unterschieden sich höchstens um 1. s ❅ s s ❅s s ❅ ❆❆s ✁s✁❆❆s ❆❆s ❅ ❅s ❅s s ❅ ❅s s ❅ Zum Beispiel: Es ist klar, daß die Suche in balancierten Bäumen effektiver als in unbalancierten durchgeführt werden kann. 12 GRAPHEN 108 A A ❅ ❅ ❅ ❅ ❅ ❅B a ❅ ❅ b ❅ ❅B a ❅ c ❅ ❅ C d ❅ ❅ c b X X Beim Einfügen eines Knotens (bei X) kann die Balance verlorengehen. Dabei sind zwei Fälle möglich (Die durch Kästen dargestellten Unterbäume haben die Höhe h, h − 1, h + 1): Durch Rotieren“ kann sie wiederhergestellt werden. ” B C ◗ ❅ ◗ ◗ ◗ ❅ ❅ ❅ A a ❅ ❅ b ◗◗B A c a ❅ ❅ b c ❅ ❅ d 13 COMPUTERALGEBRA 13 13.1 109 Computeralgebra Langzahlarithmetik: Rohe Gewalt Zu Beginn will ich eine sehr einfache Implementierung für eine Langzahlarithmetik angeben, die zwar sehr durchsichtig, aber nicht sehr effizient ist. program big10 integer a(60), b(60), c(60), zehn, w 1 5 30 common zehn format (’+’, A) zehn = 10 print *, ’ ’ write (*, 1) ’a :’ call eingabe(a) call ausgabe(a) write (*, 1) ’b :’ call eingabe(b) write (*, 1) ’a + b :’ call add(a, b, c) call ausgabe(c) write (*, 1) ’a - b :’ call sub(a, b, c) call ausgabe(c) call mult(a, b, c) write (*, 1) ’a * b :’ call ausgabe(c) w = 10 call power(a, w, c) call ausgabe(c) goto 5 end subroutine put9(zahl) integer zahl(60), i, zehn common zehn do i= 1 , 60 zahl(i)= zehn-1 end do end 10 20 subroutine kontr (a) integer a(60), i format (60I2) write (*, 30) (a(i), i = 1, 60) read (*, ’(A)’) c end subroutine init(zahl) integer zahl(60) integer i do i= 1 , 60 zahl(i)= 0 end do end + + + subroutine ausgabe (zahl) integer zahl(60) character*60 s character blank parameter (blank = ’.’) integer i logical fuehrendenull fuehrendenull= .true. print *, ’ ’ format (’+’, A) format (’+’, I1) do i= 1 , 60 if (fuehrendenull .and. (zahl(i) .eq. 0) .and. (i .lt. 60)) then s(i:i) = blank else s(i:i) = char(zahl(i) + ichar(’0’)) fuehrendenull= .false. end if end do print *, s print *, ’ ’ end 13 COMPUTERALGEBRA subroutine malzehn(zahl1, zahl2) integer zahl1(60), zahl2(60) integer i if (Zahl1(1) .ne. 0) then print *, ’Ueberlauf * 10’ stop else do i= 2 , 60 zahl2(i-1)= zahl1(i) end do zahl2(60)= 0 end if end subroutine setze(zahl, wert) integer zahl(60), wert, i, zehn common zehn call init(zahl) i= 60 do while (wert .gt. 0) zahl(i)= mod(wert, zehn) wert= wert / zehn i = i - 1 end do end subroutine add(s1, s2, summe) integer s1(60), s2(60), summe(60) integer i, carry, zehn common zehn carry= 0 i= 60 do while (i .gt. 0) carry= s1(i) + s2(i) + carry / zehn summe(i)= mod(carry , zehn) i = i - 1 end do if (carry .gt. zehn-1) then print *, ’Ueberlauf +’ stop end if end 110 subroutine sub(s1, s2, diff) integer s1(60), s2(60), diff(60) integer i, carry, zehn common zehn carry= 0 do i= 60, 1, -1 carry= s1(i) - s2(i) + carry if (carry .lt. 0) then diff(i)= carry + zehn carry= -1 else diff(i)= carry carry= 0 end if end do end integer function anf(a) integer a(60) integer i i= 1 do while ((i .le. 60) .and. (a(i) .eq. 0)) i = i + 1 end do anf= i end subroutine mult (f1, f2, produkt) integer f1(60), f2(60), produkt(60), i, anf integer prod(60), f2no(60) f2no= f2 call init(prod) i= anf(f2no) do while (i .le. 60) call malzehn(prod, prod) do while (f2no(i) .gt. 0) call add(f1, prod, prod) f2no(i) = f2no(i) - 1 end do i = i + 1 end do produkt= prod end 13 COMPUTERALGEBRA + 111 integer function length(s) character*60 s integer i i = 1 do while ((s(i:i) .ge. ’0’) .and. (s(i:i) .le. ’9’)) i = i + 1 end do length = i - 1 end subroutine eingabe(a) integer a(60), length character*60 s integer h(60), g(60) integer i read (*, ’(A60)’) s call init(a) do i= 1 , length(s) call setze(h, ichar(s(i:i)) - ichar(’0’)) call malzehn(a, g) call add(g, h, a) end do end subroutine power(a, hoch, a2) integer a(60), a2(60), acopy(60) integer hoch acopy= a call setze(a2, 1) do while (hoch .gt. 0) call mult(acopy, a2, a2) hoch = hoch - 1 end do end 13.2 Wirkliche Langzahlarithmetik Im diesem Abschnitt werden wir sehen, wie (innerhalb der durch den Rechner gesetzten Speichergrenzen) eine exakte Arithmetik zu implementieren ist. Als Hilfsmittel dazu ist eine verfeinertere Arithmetik für lange ganze Zahlen nötig, mit der wir uns hier befassen. Wir beginnen nicht mit natürlichen Zahlen, sondern wollen uns auch das Vorzeichen einer Zahl merken. Wir stellen eine ganze Zahl n in einem Stellensystem dar, wie dies seit der Einführung der arabischen Zahlen üblich ist. Dazu wählen wir eine Basiszahl B > 1 und können die Zahl n in eindeutiger Weise als L ni B i , 0 ≤ ni < B, n=0 darstellen, die Werte n0 , n1 , . . . , nL nennen wir die Ziffern der Zahl n, die Zahl L nennen wir die Länge von n. 13 COMPUTERALGEBRA 112 Wenn wir B ≤ 256 wählen, passen die ni jeweils in ein Byte, bei B ≤ 65536 = 216 passen sie in ein Maschinenwort eines PC. Um alle Informationen über unsere langen Zahlen anzuspeichern, gibt es mehrere Möglichkeiten: 1. Wir legen eine maximale Zahllänge fest: NumSize = 64 und legen die Ziffern einer langen Zahl in einem Feld ab: type Digit = byte; IntArr = array [1..NumSize] of Digit; IntNumber = record l: integer; { Länge } m: IntArr; { Ziffernfolge } n: boolean; { negativ ? } end; dies ist die einfachste Methode. Sie hat zwei Nachteile: Bei kurzen Zahlen (l < NumSize) wird Speicherplatz verschenkt, andererseits addieren sich bei Multiplikationen oft die Längen der Faktoren, die vorgegebene Maximalgrenze ist schnell erreicht. 2. Wir legen nur die notwendigen L Ziffern in einer verketteten Liste ab: type IntList = POINTER TO NatNumRec; IntNumRec = record m: Digit; { Ziffer } n: boolean; { negativ ? } f: IntList; { Adresse der nächsten Ziffer } end; So können wir wirklich“ beliebig lange Zahlen anspeichern, üblicherweise weist man ” der letzten gültigen Ziffer im Feld f eine leicht zu erkennende Adresse zu, etwa NIL (= 0). Wenn man eine neue Zahlvariable belegen will, muß man sich zuerst Speicherplatz für diese Variable holen, dafür gibt es Allockierungsfunktionen (z.B. NEW). Für eine Zahl der Länge L benötigen wir L * (SizeOf(byte) + SizeOf(ADDRESS)), auf einem PC also 5L Byte. Diese Möglichkeit ist (wegen der gefallenen Schranke) besser als die erste, aber sehr speicherintensiv. Warum haben wir eigentlich nur ein Byte für eine Ziffer verwendet, man könnte auch ein Wort (= CARDINAL) oder einen noch größeren Speicherbereich (LongWord, LONGCARD) verwenden. Nun, die Rechenoperationen mit langen Zahlen werden später auf Rechenoperationen mit Ziffern zurückgeführt werden, und auf der Maschinenebene gibt es elementare Befehle, die diese Operationen mit Bytes ausführen. Das Produkt von zwei Bytes paßt in ein Doppelbyte, dies läßt sich leicht in zwei Bytes zerlegen. Wir können also als Zifferntyp den größten Typ von Maschinenzahlen wählen, für den es einen doppeltsogroßen Maschinenzahltyp gibt. Wenn wir also neu type Digit = CARDINAL; festlegen, so sinkt der Adressierungsaufwand. 13 COMPUTERALGEBRA 113 3. Als Kompromiß zwischen beiden Varianten bietet es sich an, die Ziffern in einem dynamischen Feld zu speichern, dies ist zwar echt begrenzt, aber die Grenze ist doch recht hoch: Wir beginnen von vorn: const NumSize = 32000; type Digit = CARDINAL; IntArr = array[1..NumSize] of Digit; IntRec = record l: integer; { Länge } n: boolean; { negativ ? } m: IntArr; { Ziffernfolge } end; IntNumber = POINTER TO IntRec; Beachten Sie bitte, daß im Gegensatz zur allerersten Methode das Record-Feld m, dessen Größe variabel ist, als letztes in der Definition des Datentyps aufgeführt ist. Der Speicherplatz für solch ein Record wird in dieser Reihenfolge angelegt, wenn n hinter m käme, würde diese Eintragung an einer Stelle erfolgen, für die gar kein Speicherplatz angefordert wird. Bevor wir eine Variable N vom Typ IntNumber belegen können, müssen wir hierfür Speicherplatz holen. Hier ist die Verwendung von NEW unangemessen, denn wir wollen ja für eine Zahl der Länge L nur L * SizeOf(Digit)+SizeOf(integer)+SizeOf(boolean) Bytes belegen. Also verwenden wir ALLOCATE(N, L· SizeOf(Digit)+SizeOf(integer)+SizeOf(boolean)); Die maximale auf diese Weise darstellbare Zahl hat etwa 20000 Dezimalstellen (etwa 10 A4-Druckseiten), das dürfte (für’s erste) reichen. Wenn wir aus zwei Zahlen X, Y eine neue Zahl Z berechnen wollen, müssen wir den Platzbedarf von Z kennen. Hierfür hat man folgende Abschätzungen: Falls Z = X + Y oder Z = X − Y , so ist die Länge LZ von Z kleiner oder gleich Max(LX , LY ) + 1. Falls Z = X · Y und X, Y = 0 sind, so ist LZ ≤ LX + LY . Bevor wir mit den arithmetischen Operationen beginnen, wollen wir überlegen, wie die Basis B unseres Zahlsystems zweckmäßigerweise gewählt werden sollte. Wenn wir für B eine Zehnerpotenz wählen, so sind die Ein- und Ausgabeoperationen sehr leicht zu implementieren. Leider sind diese Operationen diejenigen, die am allerseltensten aufgerufen werden, meist nur am Beginn und am Ende eines Programmlaufs. Wenn wir B = 32768 setzen, so bleibt das höchste Bit jeder Ziffer frei, wie im Fall von B = 10k wird so Speicher verschenkt. Wir haben oben die Festlegung Digit = byte durch Digit = CARDINAL ersetzt. Der Grund für diese Änderung (Adressierungsaufwand) ist inzwischen entfallen, wir verwenden ja dynamische Felder. Jenachdem, für welche Ziffer-Größe man sich entscheidet, wäre B = 256 oder B = 65536 zu wählen. Die internen Darstellungen einer Zahl auf diese oder die andere Weise unterscheiden sich um kein Bit, nur die 13 COMPUTERALGEBRA 114 Länge L ist im ersten Fall doppelt so groß wie im zweiten. Wenn im folgenden also Laufanweisungen der Länge L (oder gar der Länge L2 ) auftreten, ist eine kürzere Darstellungslänge eventuell von Vorteil. Da im Fall B = 65536 ständig CARDINALs auf LONGCARDs gecastet“ und LONGCARDs in CARDINALs zerlegt werden, läßt ” sich die richtige Wahl nicht rechner- und compilerunabhängig treffen. Man erlebt die größten Überraschungen. Wir beginnen nun mit der Beschreibung von Implementationen der Rechenoperationen. Wir definieren zunächst CONST basis = LONGCARD(65536); Die einfachste Operation ist die Addition von Zahlen gleichen Vorzeichens. Die enstprechenden Stellen der Summanden werden als LONGCARDs addiert, der Rest w mod basis ist die entsprechende Stelle der Summe, der Quotient w basis ist der Übertrag, er ist gleich 0 oder 1. PROCEDURE al(a, b: IntNumber; VAR c: IntNumber); (* Add longs, signs are equal *) VAR i, max, min, w: LONGCARD; cc: IntNumber; BEGIN IF a^.l > b^.l THEN min := b^.l; max := a^.l ELSE min := a^.l; max := b^.l; END; newl(c, max + 1); { allockiert Platz f"ur c } clear(c); { belegt c mit Nullen } w:= 0; FOR i:= 1 TO min DO w:= w + a^.m[i] + b^.m[i]; c^.m[i]:= w MOD basis; w:= w DIV basis; END; { a oder b ist abgearbeitet } IF max = l THEN { von a noch ein Rest } FOR i:= min + 1 TO max DO w:= w + a^.m[i]; c^.m[i]:= w MOD basis; w:= w DIV basis; END ELSE { oder von b noch ein Rest } FOR i:= min + 1 TO max DO w:= w + b^.m[i]; c^.m[i]:= w MOD basis; w:= w DIV basis; END; END; IF w>0 THEN { an letzter Stelle "Ubertrag? } c^.m[max +1]:= w; ELSE newl(cc, c^.l-1); { sonst c verk"urzen } cc^.n:= c^.n; FOR i:= 1 TO cc^.l DO cc^.m[i]:= c^.m[i]; END; lrWegl(c); { altes c wegwerfen } c:= cc; END; c^.n:= a^.n; END al; Wir kommen nun zur Subtraktion, und zwar dem einfachen Fall, daß die Operanden das gleiche Vorzeichen haben und der Subtrahend den größeren Betrag hat. Die einzelnen Stellen werden als LONGINTs subtrahiert und ein Übertrag von der nächsthöheren Stelle des Subtrahenten subtrahiert. 13 COMPUTERALGEBRA PROCEDURE sl(a, b: IntNumber; VAR c: IntNumber); (* Subtract longs, signs are equal, a > b *) VAR i, j, jj: CARDINAL; cc: IntNumber; lh, li, ln, lb: LONGINT; BEGIN newl(c, a^.l); clear(c); { c:= 0 } ln := a^.m[1]; FOR i:= 1 TO b^.l DO { zun"achst a und b } li:= ln ; ln := a^.m[i+1]; lb:=(b^.m[i]; lh:= li - lb; IF lh < 0 THEN { "Ubertrag } DEC(ln); INC(lh, basis); END; c^.m[i]:= lh; END; FOR i:= b^.l+1 TO a^.l DO { nun Rest von a } li:= ln ; ln := a^.m[i+1]; 115 IF li < 0 THEN DEC(ln); INC(li, basis); END; c^.m[i]:= li; END; j:= l; WHILE (j>0) AND (c^.m[j]=0) DO { wieweit ist c belegt ? } DEC(j); END; IF j=0 THEN lrWegl(c); { c = 0 } RETURN; END; IF j < a^.l THEN newl(cc, j); { c verk"urzen } clear(cc); FOR i:= 1 TO j DO cc^.m[i]:= c^.m[i]; END; lrWegl(c); c:= cc; END; c^.n:= a^.n; { Ergebnis hat Vorzeichen der gr"o"seren Zahl } END sl; Nun kommt der allgemeine Fall der Addition und Subtraktion ganzer Zahlen. PROCEDURE add(a, b:IntNumber; VAR c: IntNumber); (* c:= a + b *) VAR s: CARDINAL; BEGIN\p IF a^.n = b^.n THEN { gleiches Vorzeichen } al(a,b,c) ELSE\p s:= compabs(a, b); { Vergleiche Absolutbetr"age } CASE s OF 2: sl(b, a, c); { b > a } |1: sl(a, b, c); { a > b } |0: c:= NIL; { a = b } END; END; END add; PROCEDURE sub(a, b:IntNumber; VAR c: IntNumber); (* c:= a - b *) VAR s: CARDINAL; BEGIN IF a=NIL THEN { wenn a = 0 } lrCopi(b, c); { c:= b } IF c$<>$NIL THEN c^.n:= NOT c^.n { c:= -c } END; RETURN; END; IF b=NIL THEN { wenn b = 0 } lrCopi(a, c); { c:= a } RETURN; END; 13 COMPUTERALGEBRA 116 IF a^.n <> b^.n THEN { verschiedene Vorzeichen ? } al(a,b,c) { dann addieren } ELSE s:= compabs(a, b); { sonst Vergleich } CASE s OF 1: sl(a, b, c); { a > b } |2: sl(b, a, c); c^.n:= NOT b^.n; { a < b, Vorzeichen ! } |0: c:= NIL; { a = b } END; END; END sub; Wir kommen nun zur Multiplikation. Zunächst führen wir eine Hilfsprozedur an, die ein effektiver Ersatz für folgende Prozeduraufrufe ist: al(a, b, c); { hier steckt mindestens eine Allockierung dahinter } wegl(a); a:= c; Die Variable a wird nur einmal angelegt. PROCEDURE a2((*VAR*) a, b: IntNumber); (* a:= a + b, signs are equal, FOR i:= min +1 TO max DO a already exists ! *) w:= w + a^.m[i]; VAR i, max, min : INTEGER; m[i]:= w MOD basis); w: LONGCARD; w:= w DIV basis; BEGIN END; min:= b^.l; max:= a^.l; w:= 0; IF w>0 THEN WITH a^ DO m[max +1]:= w; FOR i:= 1 TO min DO END; w:= w + a^m[i] + b^.m[i]; END; m[i]:= w MOD basis; END a2; w:= w DIV basis; END; Die Multiplikation wird genauso durchgeführt, wie Sie es in der Schule gelernt haben: Ein Faktor wird mit einer einstelligen Zahl multipliziert und das Ergebnis wird verschoben, dies leistet die Unterprozedur mh. Diese Werte werden aufaddiert, dies leistet a2. Zu beachten ist, daß die Länge der abzuarbeitenden FOR-Schleife für die Rechenzeit eine wesentliche Bedeutung hat; es wird die kürzere Schleife gewählt. (Die von nun an auftretenden Vorsilbe lr“ in den Prozedurnamen wird verwendet, um ” daran zu erinnern, daß es sich um Operationen mit langen rationalen Zahlen handelt. 13 COMPUTERALGEBRA 117 PROCEDURE lrMl(a, b:IntNumber; VAR c: IntNumber); (* mult long, c:= a * b *) VAR hh: IntNumber; la, lb, i, j, hl: CARDINAL; h: LONGCARD; PROCEDURE mh(a: IntNumber; b: LONGCARD; s: CARDINAL); VAR i: CARDINAL; BEGIN h:= 0; WITH a^ DO FOR i:= 1 TO l DO h:= h + m[i] * b; hh^.m[i+s]:= h MOD basis; h:= h DIV basis; END; hh^.m[l+s+1]:= ORD(h); END; IF h=0 THEN hh^.l:= a^.l+s ELSE hh^.l:= a^.l+s+1; END; END mh; BEGIN (* lrMl*) la:= a^.l; lb:= b^.l; hl:= la+lb; newl(c, hl); clear(c); newl(hh, hl); clear(hh); IF la < lb THEN { k"urzere Schleife au"sen ! } FOR i:= 1 TO la-1 DO IF a^.m[i]<>0 THEN mh(b, a^.m[i], i-1); a2(c, hh); FOR j:= i TO hh^.l DO hh^.m[j]:= 0; END; END; END; mh(b, a^.m[la], la-1); a2(c, hh); ELSE FOR i:= 1 TO lb-1 DO IF b^.m[i]<>0 THEN mh(a, b^.m[i], i-1); a2(c, hh); FOR j:= i TO hh^.l DO hh^.m[j]:= 0; END; END; END; mh(a, b^.m[lb], lb-1); a2(c, hh); END; IF c^.m[hl]=0 THEN shrink(c); END; c^.n:= a^.n <> b^.n; hh^.l:= hl; lrWegl(hh); END lrMl; Nun kommen wir zur Division, das war schon in der Schule etwas schwieriger. Gegeben sind zwei Zahlen ai B i und b = bi B i , a= gesucht sind Zahlen q und r mit a = bq + r, 0 ≤ r < b. Wenn a < b sein sollte, setzen wir q = 0 und r = a und sind fertig. Andernfalls muß man die einzelnen Stellen von q nun erraten. Wir bezeichnen wieder mit L(a) die Länge der Zahl a. Als Länge von q ist etwa L(a) − L(b) zu erwarten, wir setzen Q = aL(a) div bL(B) q = Q · B L(a)−L(b) und prüfen, ob 0 ≤ r = a − bq < b ist. Wenn nicht, so haben wir zu klein geraten, wir erhöhen Q, oder r ist negativ, dann haben wir zu groß geraten, wir erniedrigen Q und probieren es nochmal. Wenn r endlich im richtigen Bereich liegt, haben wir die erste Stelle von q gefunden, wir ersetzen nun a durch r und berechnen in analoger Weise die nächste Stelle von q. 13 COMPUTERALGEBRA 118 Das beschriebene Falschraten“ kann sehr häufig vorkommen und die Rechenzeit er” heblich belasten. Seit Erscheinen der Algorithmenbibel von D. Knuth wird allenthalben vorgeschlagen, die Zahlen a und b durch Erweitern“ so anzupassen, daß b ≥ B/2 ist, ” dadurch ist gesichert, daß der geratene Quotient Q um höchstens 2 vom richtigen Quotienten abweicht. Ich hatte dies erst nachträglich in meine Implementation eingefügt und habe tatsächlich eine Beschleunigung erlebt. Einen anderen Vorschlag hat W. Pohl (z.Z. Kaiserslautern) gemacht, der deutlich besser ist: Wir handeln den Fall, daß b eine einstellige Zahl ist, extra ab, das ist auch recht einfach. Nun bilden wir aus den jeweils beiden ersten Stellen von a und b Long-Cardinal-Zahlen und wählen deren Quotienten als Näherung für Q, da liegen wir schon ganz richtig. Ich will uns den Abdruck der Divisionsprozedur ersparen, die nimmt etwa zwei Druckseiten ein. Die vorgestellten Prozeduren für die Grundrechenarten sind in Pascal oder MODULA geschrieben. Bei der Multiplikation sehen Sie, daß häufig Wort-Variable auf LongintVariable gecastet“ werden, weil das Produkt zweier 16-Bit-Zahlen eben eine 32-Bit” Zahl ist. Es ist vorstellbar, daß man effektiver arbeiten könnte, wenn man die Fähigkeiten des Prozessors besser nutzen würde. Henri Cohen schreibt, daß eine Beschleunigung um den Faktor 8 erreicht werden würde, wenn man in Assemblersprache programmiert. Meine Erfahrungen gehen noch weiter: In einer früheren Version meines CA-Systems, wo die Zahllänge konstant war (64 Byte), hat Sven Suska für die Addition und die Multiplikation Assembler-Routinen geschrieben, allein deren Einsatz brachte eine Beschleunigung um den Faktor 6. Auch für die Division hat er eine Routine geschrieben, die leider den Nachteil hatte, ab und zu den Rechner zum Absturz zu bringen. Wenn sie aber fehlerfrei funktionierte, so erbrachte das nochmals eine Beschleunigung un den Faktor 6. Assembler-Routinen sind allerdings schwer zu transportieren; die erwähnten funktionierten nur im Real-Modus des 286er Prozessors, wo man maximal 500 KByte Speicher zur Verfügung hat. Außerdem waren sie sehr umfangreich (über 18 KByte Quelltext) und für einen Laien wie mich völlig unverständlich. Cohen schlägt vor, nur einige Grundfunktionen in Assembler zu schreiben. Dazu sollen zwei globale Variable namens remainder und overflow, die überall bekannt sind, geschaffen werden. Die Zahl-Basis sei M = 2 16 , a, b und c seien vorzeichenlose 2Byte-Zahlen. Die Grundfunktionen sollen folgendes leisten: c c c c c c c c = = = = = = = = add(a, b) addx(a, b) sub(a, b) subx(a, b) mul(a, b) div(a, b) shiftl(a, k) shiftr(a, k) : : : : : : : : a + b = overflow * M + c a + b + overflow = overflow * M + c a - b = c - overflow * M a - b - overflow = c - overflow * M a * b = remainder * M + c remainder * M + a = b * c + remainder 2^k * a = remainder * M + c a * M / 2^k = c * M + remainder Aus diesen kurzen Funktionen kann man dann die oben angeführten Prozeduren für lange Zahlen zusammensetzen. 13 COMPUTERALGEBRA 119 Wir fahren fort. Wir wollen den größten gemeinsamen Teiler zweier Zahlen bestimmen, dafür verwenden wir den Euklidischen Algorithmus. h:= x mod y; solange h > 0 ist: x:= y; y:= x; h:= x mod y; ggT(x, y):= y; Eine Variante des Euklidischen Algorithmus liefert für den größten gemeinsamen Teiler g von a und b gleich seine Darstellung als Vielfachsumme: g = au + bv. Das ist natürlich noch nicht der Weisheit letzter Schluß. Wenn die Eingabewerte die Größenordnung N haben, so durchläuft der Euklidische Algorithmus etwa log(N )mal eine Schleife, und jedesmal wird eine Langzahldivision durchgeführt. Der folgende Algorithmus von Lehmer verwendet überwiegend Wort-Divisionen. Die Zahlen a, b seien gegeben, die Variablen ah, bh, A, B, C, D, T, q sind Worte, t und r sind lange Zahlen, das Zeichen /“ bezeichnet die ganzzahlige Division. ” 1. Wenn b < M ist, so verwende den alten Algorithmus. Sonst: ah = oberste Stelle von a, bh = oberste Stelle von b, A:= 1, B:= 0, C:= 0, D:= 1. 2. Wenn bh + C = 0 oder bh + D = 0, so gehe zu 4. Sonst: q:= (ah + A)/(bh + C). Wenn q <> (ah + B)/(bh + D) ist, so gehe zu 4. (Hier kann ein "Uberlauf auftreten, den man abfangen mu"s.) 3. T:= A - q*C, A:= C, C:= T, T:= B - q*D, B:= D, D:= T, T:= ah - q*bh, ah:= bh, bh:= T, gehe zu 2. 4. Wenn B = 0 ist, so sei t = a mod b, a:= b, b:= t, gehe zu 1. (Hier braucht man Langzahlarithmetik, dieser Fall tritt jedoch (angeblich) nur mit der Wahrscheinlichkeit 1,4/M = 0,00002 auf.) Sonst: t:= A*a, t:= t + B*b, r:= C*a, r:= r + D*b, a:= t, b:= r, Gehe zu 1. Eine weitere Variante vermeidet Divisionen (fast) vollständig, es kommen nur Subtraktionen und Verschiebungen vor: 1. r:= a mod b, a:= b, b:= r. (Hier wird ein einziges Mal dividiert, dieser Schritt kann auch weggelassen werden. Von nun an haengt aber die Zahl der Schritte nur noch von der Laenge der kleineren der eingegebenen Zahlen ab.) 2. Wenn b = 0 ist, so ist a das Ergebnis. 13 COMPUTERALGEBRA 120 Sonst: k:= 0, solange a und b beide gerade sind: k:= (Am Ende haben wir 2^k als genauen Faktor des 3. Wenn a gerade ist, so wiederhole a:= a/2, bis Wenn b gerade ist, so wiederhole b:= b/2, bis 4. t:= (a-b)/2 wenn t = 0 ist, so ist 2^k*a das Ergebnis. 5. Solange t gerade ist, wiederhole t:= t/2. Wenn t > 0 ist, so a:= t. Sonst: b:= -t. Gehe zu 4. k + 1, a:= a/2, b:= b/2. ggT.) a ungerade ist. b ungerade ist. Als fünfte Grundrechenart wollen wir die Potenzierung betrachten. Es hat sicher nicht viel Sinn, etwa ab bilden zu wollen, wobei auch der Exponent b beliebig lang ist, es reicht vielleicht schon b < 32000. Wir verwenden einen Trick, der nicht b Multiplikationen erfordert, sondern nur log(b) Operationen: Wir sehen es am Beispiel a8 = ((a2 )2 )2 . y:= 1; w:= x; solange exp > 0 ist: wenn exp ungerade ist: y:= y * w; wenn exp > 1 ist: w:= w * w; exp:= exp div 2; dann ist x^exp = y; Auch dieser Algorithmus läßt sich verbessern, zwar nicht in der Anzahl der Rechenschritte, aber in der Größe der Operanden: Im folgenden Algorithmus wird im Schritt 3 immer mit derselben Zahl z multipliziert. Um g n zu berechnen, benötigen wir die Zahl e mit 2e ≥ n < 2e+1 , um diese zu bestimmen, müssen die Bits von n durchmustert werden. 1. N:= n, z:= g, y:= z, E:= 2^e. 2. Wenn E = 1 ist, so ist y das Ergebnis. Sonst: E:= E/2. 3. y:= y * y Wenn N >= E ist, so N:= N - E, y:= y * z. Gehe zu 2. Man kann die Division vermeiden, wenn man sich die Bits von n anschaut: 1. N:= n, z:= g, y:= z, f:= e. 2. Wenn f = 0 ist, so ist y das Ergebnis. 13 COMPUTERALGEBRA 121 Sonst: f:= f - 1. 3. y:= y * y Wenn bit(f, N) = 1 ist, so y:= y * z. Gehe zu 2. Zum Abschluß wollen wir uns dem Problem der Ein- und Ausgabe langer ganzer Zahlen widmen, einem Fragenkreis, der in den einschlägigen Computeralgebra-Büchern ausgespart bleibt. Wir geben hier PASCAL-Prozeduren an, mit MODULA ist es nicht ganz so einfach. Es versteht sich, daß die eingebauten“ Prozeduren zum Lesen von ” 2-Byte- oder Gleitkommazahlen nicht brauchbar sind. Wir lesen also eine Zeichenkette wie ’123444444444444444444’ und wandeln diese in eine lange Zahl um, dazu lesen wir (von links) Zeichen für Zeichen, wandeln es in eine Zahl um, multiplizieren mit 10 und addieren die nächste Ziffer: procedure readl(var a: IntNumber); var s: string; h, zehn: IntNumber; i: integer; begin readln(s); assic(10, zehn); { zehn := 10 } a:= NIL; for i:= 1 to Length(s) do begin mult(a, zehn, b); assic(ord(s[i]) - ord(’0’), h); { h ist die Zahl, die das Zeichen s[i] darstellt } wegl(a); add(b, h, a); wegl(b); wegl(h); end; end; Das Schreiben ist auch nicht schwierig, wir können aber die Ziffern nicht sofort ausgeben, weil wir sie von rechts nach links berechnen müssen: procedure writel(a: IntNumber); var s: string; q, r, zehn: IntNumber; begin s:= ’’; while a <> NIL do begin divmod(a, zehn, q, r); a:= q; i:= r^.m[1]; i: integer; 13 COMPUTERALGEBRA 122 s:= char(i+ord(’0’)) + s; { das Zeichen der Ziffer i wird an s vorn angeh"angt } end; write(s); end; Wir sehen, daß wir bereits zur Ein- und Ausgabe die Rechenoperationen mit langen Zahlen benötigen. Das macht den Test der Arithmetik etwas schwierig, da man sich Zwischenergebnisse, etwa innerhalb der Prozedur divmod, nicht mit writel ausgeben lassen kann. 13.3 Polynome und Formelmanipulation“ ” Wenn man bereits mit ganzen Zahlen rechnen kann, so ist die Implementation der Grundrechenoperationen mit rationalen Zahlen sehr einfach: Eine rationale Zahl ist ein Paar ganzer Zahlen, die wir Zähler bzw. Nenner nennen. Dann gilt ad ± bc a c ac a c ad a c ± = , · = , : = . b d bd b d bd b d bc (Wenn der Nenner verbotenerweise mal gleich Null sein sollte, passiert auch nichts Schlimmes, jedenfalls kein Rechnerabsturz.) Gelegentlich sollte man einen Bruch kürzen. Dazu bestimmt man den größten gemeinsamen Teiler von Zähler und Nenner und dividert diese durch den ggT. Wir wollen nun mit Formeln rechnen. Wenn x, y, z Unbestimmte sind, so nennt man einen Ausdruck wie 5x2 + 7xy + z 5 ein Polynom. Die Menge aller Polynome in x, y, z mit Koeffizienten aus einem Körper K wird mit K[x, y, z] bezeichnet. Polynome kann man addieren, subtrahieren, multiplizieren. Wie stellt man Polynome im Rechner dar? Einen Term xi y j z k nennt man ein Monom. Wenn man sich merkt, daß x die 1., y die 2., z die 3. Unbestimmte ist, so ist dieses Monom durch seinen Exponentenvektor“ [i, j, k] eindeutig bestimmt. Wenn man die ” Größe der Exponenten beschränkt, so ist die Zahl der möglichen Monome beschränkt, man kann also ein Polynom folgendermaßen abspeichern: Man numeriert alle möglichen Monome durch (es seien etwa N Stück), dann schafft man sich ein Feld mit N Komponenten und merkt sich zu jedem Monom den entsprechenden Koeffizienten. Wenn ein Monom in einem Polynom gar nicht vorkommt, ist der entsprechende Koeffizient eben gleich Null. Diese Darstellungsform wird die dichte“ genannt. Wir wollen uns aber auf die dünne“ ” ” Darstellung beziehen, die nur die wirklich vorkommenden Monome und ihre Koeffizienten speichert. type monom = array[1..max ] of integer; type polynom = Pointer to polrec; polrec = Record c: rat; (* Koeffizient *) e: monom; (* Exponentenvektor *) n: polynom; 13 COMPUTERALGEBRA 123 end; In der Komponente n (= next) haben wir die Adresse des nächsten Summanden gespeichert, beim letzten Summanden setzen wir n = NIL. Vernünftig ist es, die Monome nicht zufällig in so eine Liste einzutragen, man ordnet sie am Besten der Größe nach. Wir brauchen also eine Vergleichsrelation für Monome. Der Grad eines Monoms ist die Summe seiner Exponenten, der Grad eines Polynoms ist das Maximum der Grade seiner Monome. Ein Monom soll größer als ein anderes sein, wenn sein Grad größer ist. Für Monome vom Grad 1 legen wir eine Ordnung willkürlich fest, etwa x > y > z. Für Polynome gleichen Grades können wir jetzt die lexikographische Ordnung nehmen: x3 > x2 y > xy 2 > y 3 > x2 z > y 2 z > z 3 . Diese Grad-lexikographische Ordnung ist nur eine aus einer Vielzahl von Ordnungsmöglichkeiten. Man fordert aber gewöhnlich, daß für Monome p, q, r mit p > q auch stets pr > qr gilt, dies ist hier erfüllt. Nun können wir Rechenoperationen für Polynome implementieren: Addition: Seien zwei Polynome p, q gegeben, in beiden seien die Monome der Größe nach geordnet. Wir schauen uns die jeweils größten Monome an. Es gibt drei Möglichkeiten: 1. p^.e > q^.e, dann übernehmen wir p^.e nebst seinem Koeffizienten in p + q und ersetzen p durch p^.n. 2. p^.e < q^.e, dann übernehmen wir q^.e nebst seinem Koeffizienten in p + q und ersetzen q durch q^.n. 3. p^.e = q^.e, dann bilden wir a = p^.c + q^.c, wenn a = 0 ist, so übernehmen wir p^.e mit dem Koeffizienten a in p + q und ersetzen p durch p^.n und q durch q^.n. Dies wiederholen wir, bis p und q abgearbeitet sind, also beide auf NIL zeigen. Die Subtraktion ist analog zu behandeln. Bei der Multiplikation bekandeln wir zuerst den Spezialfall, wo das Polynom q nur einen Summanden besitzt. Hier ist einfach jeder Summand von p mit q^.c zu multiplizieren, zu den Exponentenvektoren von p ist q^.e zu addieren. Da eventuell sehr oft mit derselben Zahl q^.c zu multiplizieren ist, ist es ratsam, diese Zahl am Anfang zu kürzen. Den allgemeinen Fall führen wir auf den Spezialfall zurück: Für jeden Summanden m von q bilden wir m · p und addieren all diese Polynome. Man kann ein Programm schreiben, daß eine eingegebene Zeichenkette in Blöcke zerlegt, die z.B. als Zahlen √ (12, 1, ...), als Bezeichner von Variablen (x, x1, ...), als Funktionswerte (sin(x), x, ...) usw. interpretiert und Regeln kennt, wie mit diesen Objekten umzugehen ist, etwa cos(x + y) = cos(x) cos(y) − sin(x) sin(y). Man kann √ dann symbolisch rechnen, auf numerische Werte kommt es gar nicht an (x = 5 wird nicht berechnet, man weiß aber, daß x2 = 5 ist). Wenn das Programm Differentiations- und Integrationsregeln kennt, so kann man (falls das überhaupt geht), unbestimmte Integrale in geschlossener Form darstellen. Der 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN 124 Rechner kann in Ruhe alle Tricks durchprobieren. Aus diesem Grund sind Computeralgebrasysteme bei theoretischen Physikern besonders beliebt. Weit verbreitet sind folgende CA-Systeme, die meist sowohl als UNIX- als auch als DOS-Version verfügbar sind: • REDUCE von Anthony Hearns, einem Nestor der der Computeralgebra, • MAPLE, • DERIVE ist die Fortsetzung von muMath, neben der Software-Version ist es auf einem TI-Taschenrechner fest verdrahtet“ (wie bekommt man dann ein Upda” te?), • Mathematica von Stephen Wolfram braucht ein knapp 1000 Seiten dickes Handbuch, und da steht noch gar nicht alles drin (steht in dem Buch), zu den Packe” ges“ gibt es nochmals vier Handbücher. • Macaulay von Stillman, Stillman und Bayer dient vor allem für Rechnungen in der kommutativen Algebra, ebenso • Singular (Gerd-Martin Greuel, Gerhard Pfister und andere) wurde in Berlin und Kaiserslautern entwickelt; hierfür habe ich die Arithmetik und Matrixoperationen beigesteuert. Am Montag, dem 5.8.96, erzählte mir Gerhard Pfister, daß in KL der Wert einer 53-reihigen Determinante mit ganzzahligen Einträgen der Größenordnung 1020 mittels aller verfügbarer CA-Systeme berechnet wurde. Da keine Software mit Bestimmtheit völlig fehlerfrei ist, kann man sich auf die Korrektheit eines Resultats nur dann verlassen, wenn man es auf unterschiedlichen Systemen verifizieren kann. Die Rechenzeiten der Systeme unterschieden sich heftig: Maple Reduce Mathematica Singular 500 h 80 h 20 h 0,5 h Aber auch das Resultat war nicht immer dasselbe: Mathematica hatte ein anderes Ergebnis als die anderen. Natürlich kann kein Mensch das Resultat überprüfen. Die Antwort von Wolfram Research wegen des Fehlers lautete: Haben Sie nicht ein kleineres Beispiel?“ ” æ 14 Boolesche Algebren und Boolesche Funktionen Definition: Eine Menge B mit drei Operationen + : B × B −→ B, · : B × B −→ B und ¯: B −→ B sowie zwei ausgezeichnenten Elementen 0, 1 ∈ B heißt Boolesche Algebra, wenn für a, b, c ∈ B folgende Rechenregeln erfüllt sind: a + (b + c) = (a + b) + c, a · (b · c) = (a · b) · c (Assoziativität) 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN a + b = b + a, a·b=b·a (Kommutativität) a + a = a, a·a=a (Idempotenz) a + (b · c) = (a + b) · (a + c), a · (b + c) = a · b + a · c a + (a · b) = a, a · (a + b) = a (Absorbtion) 0 + a = a, 0·a=0 1 + a = 1, 1·a=a a + ā = 1, a · ā = 0. 125 (Distibutivität) Manchmal schreibt man anstelle von + auch ∨ oder ∪ und nennt diese Operation Disjunktion, Vereinigung oder Supremum; für · schreibt man dann ∧ oder ∩ und nennt es Konjunktion, Durchschnitt oder Infimum. Die Operation ¯ heißt Komplementierung oder Negation. Das einfachste Beipiel einer Booleschen Algebra ist die Algebra B = {0, 1}, wo sich die Definition der Rechenoperationen schon aus den obigen Regeln ergibt. Ein weiteres Beispiel ist die Potenzmenge P (M ) = {U | U ⊆ M } einer Menge M mit Durchschnitt und Vereinigung sowie Komplemmentärtmengenbildung als Rechenoperationen. Da, wie man oben sieht, für die Addition und die Multiplikation genau dieselben Rechenregeln gelten, ist es egal, ob wir den Durchschnitt als Addition oder als Multiplikation auffassen. Wenn B und C Boolesche Algebren sind, so ist B × C mit komponentenweise definierten Rechenoperationen ebenfalls eine Boolesche Algebra, insbesondere also auch jede kartesische Potenz B n von B. Von nun an bezeichne B stets eine Boolesche Algebra. Für die Komplementierung gelten die folgenden DeMorganschen Regeln: Satz 14.1 x · y = x̄ + ȳ, x + y = x̄ · ȳ. Beweis: Wenn a das Komplement von x · y bezeichnet, so haben wir a + (x · y) = 1 und a · (x · y) = 0 nachzuweisen: (x · y) + (x̄ + ȳ) = (x + x̄ + ȳ) · (y + x̄ + ȳ) = 1 · 1 = 1, (x · y) · (x̄ + x̄) = (x · y · x̄) + (x · y · ȳ) = 0 + 0 = 0. Der Beweis der anderen Regel verläuft analog. Die soeben benutze Beweismethode ( analog“) ist typisch für die Arbeit mit Booleschen ” Algebren: Man vertauscht die Rechenoperationen miteinander und wendet die analogen Regeln an; dies nennt man Dualisierung“. ” Lemma 14.2 (Kürzungsregel) Für x, y, z ∈ B gelte (1) x · y = x · z und (2) x + y = x + z. Dann folgt y = z. Beweis: Zur ersten Gleichung wird sowohl y als auch z addiert: (x · y) + y = (x + y) · (y + y) = (x + y) · y = y nach der Absorbtionsregel; wegen (1) ist dies = (x · z) + y = (x + y) · (z + y), 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN 126 (x · z) + z = (x + z) · (z + z) = (x + y) · z = z = (x · y) + z = (x + z) · (y + z) und die beiden letzten Terme jeder Gleichung stimmen wegen (2) überein. Wir können in B wie folgt eine Ordnung einführen: a ≤ b genau dann, wenn a · b = a gilt. Lemma 14.3 a ≤ b gdw. a + b = b. Beweis: b = (a + b) · b = a · b + b · b = a + b. Die Umkehrung beweist man surch Vertauschung von a, b und Dualisierung. Definition: Seien a ≤ b ∈ B, dann heißt die Menge {x | a ≤ x ≤ b} = [a, b] das durch a und b bestimmte Intervall von B. Wir bemerken, daß [a, b] bezüglich der Addition und Multiplikation abgeschlossen sind. Wenn wir a als Nullelement und b als Einselement auffassen und die Komplementierung in [a, b] relativ zu diesen durchführt (was auch immer das heißen mag), so wird [a, b] wieder eine Boolesche Algebra. Eine Abbildung zwischen Booleschen Algebren, die mit den jeweiligen drei Rechenoperationen verträglich ist, heißt Homomorphismus Boolescher Algebren. Ein bijektiver Homomorphismus heißt Isomorphismus. Nun beweisen wir einen Struktursatz, der eine Übersicht über alle endlichen Boolschen Algebren ergibt. Satz 14.4 Wenn B eine endliche Boolesche Algebra ist, so gilt B ∼ = Bn für eine natürliche Zahl n. Beweis: Wir führen die Induktion über |B|. Wenn |B| = 2 ist, so ist nichts zu zeigen. Sei also die Behauptung für kleine“ Boolesche Algebren schon bewiesen. ” Wir wählen ein Element a ∈ B, a = 0, 1. Wir setzen Xa = {(a · b, a + b) | b ∈ B}, dies ist eine Teilmenge von [0, a] × [a, 1]. Weiter sei f : B −→ Xa folgende Abbildung: f (b) = (a · b, a + b). Nach der obigen Kürzungsregel ist f injektiv. Wir zeigen die Verträglichkeit mit den Rechenoperationen: f (b · c) = (a · (b · c), a + (b · c)), f (b) · f (c) = (a · b, a + b) · (a · c, a + c) = (a · a · b · c, (a + b) · (a + c)) (komponentenweise Operation) = (a · b · c, a + (b · c)), 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN 127 das ist die Behauptung; die Gleichung f (b + c) = f (b) + f (c) zeigt man analog. Beim Komplement müssen wir aufpassen: Wir zeigen zunächst, daß a · b̄ das Komplement von a · b in [0, a] ist. a · b̄ + a · b = a · (b + b̄) = a · 1 = a ist das größte Element und (a · b̄) · (a · b) = a · 0 = 0 ist das kleinste. Analog: a + b̄ ist das Komplement von a + b in [a, 1], da a + b̄ + a + b = 1 und (a + b̄) · (a + b) = a + (b̄ · b) = a + 0 = a ist das kleinste Element. Nun folgt f (b̄) = (a · b̄, a + b̄) = (a · b, a + b) = f (b). Nun ist f auch noch surjektiv, denn für (x, y) ∈ [0, a] × [a, 1] setzen wir b = y · (ā + x), dann ist f (b) = (a · y · (ā + x), a + y · (ā + x)) = (a · y · ā + a · y · x, (a + y)(a + ā + x)); der erste Term ist Null, der zweite wegen x ≤ a ≤ y gleich x, der dritte Term ist gleich (a + y) · (a + ā + x) = (a + y) · 1 = y. Also ist f ein Isomorphismus Boolescher Algebren. Da nun sowohl [0, a] als auch [a, 1] weniger Elemente als B haben, gilt für sie die Induktionsvoraussetzung: [0, a] ∼ = Bm , also B ∼ = Bk+m . = Bk , [a, 1] ∼ Die Menge Bn ist isomorph zur Potenzmenge der Menge {1, . . . , n}, wir ordnen dem Tupel (i1 , . . . , in ) die Menge der k mit ik = 0 zu. Dies ist mit den Operationen verträglich. Folgerung 14.5 (Stonescher Darstellungssatz) B ∼ = P (M ) für eine endliche Menge M . Folgerung 14.6 Zwei gleichmächtige endliche Boolsche Algebren (mit 2n Elementen) sind isomoprh (zu Bn ). Wir betrachten nun n-stellige Abbildungen der Form f : B n −→ B. Wenn f, g zwei solche Abbildungen sind, so können wir (f ·g)(x) = f (x)·g(x), (f +g)(x) = f (x)+g(x) und (f¯)(x) = f (x) setzen und es ist nicht schwer nachzuweisen, daß die Menge Fn (B) = {f : B n −→ B} so eine Boolsche Algebra wird. Definition: Ein Boolsches Polynom in x1 , . . . , xn ist folgendes: (1) x1 , . . . , xn , 0, 1 sind Boolesche Polynome, (2) wenn p und q Boolesche Polynome sind, so sind auch (p) + (q), (p) · (q) und (p) Boolesche Polynome. Ein Boolesches Polynom ist also einfach eine Zeichenkette, es gilt x1 + x2 = x2 + x1 . Wenn aber f (x1 , . . . , xn ) ein Boolesches Polynom und B eine Boolesche Algebra ist, so können wir eine Funktion f ∗ : B n −→ B durch f ∗ (b1 , . . . , bn ) = f (b1 , . . . , bn ) konstruieren, indem wir die bi einfach in f einsetzen und den Wert ausrechnen. Dann gilt natürlich (x1 + x2 )∗ = (x2 + x1 )∗ . Definition: 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN 128 Zwei Boolsche Polynome f, g heißen äquivalent (f ∼ g), wenn die zugehörigen Funktionen auf der Algebra B gleich sind. Zur Vereinfachung führen wir folgende Schreibweisen ein: x1 = x, x−1 = x̄. Satz 14.7 Jedes Boolesche Polynom ist äquivalent zu einer disjunktiven Normal” form“ di1 ...in · xi11 · . . . · xinn , di1 ...in ∈ {0, 1}. fd (x1 , . . . , xn ) = i1 ,...,in ∈{−1,1} Jedes Boolesche Polynom ist äquivalent zu einer konjunktiven Normalform“ ” fk (x1 , . . . , xn ) = k1 ,...,kn ∈{−1,1} (ki1 ...in + xi11 + . . . + xinn ), ki1 ...in ∈ {0, 1}. Beweis: Es ist f ∗ (1j1 , . . . , 1jn ) = di1 ...in 1i1 j1 . . . 1in jn und ein Summand ist genau dann gleich 1, wenn i1 = j1 , . . . , in = jn und di1 ...in = 1 ist, das heißt, die fd mit verschiedenen d sind jeweils inäquivalent. Nun ist aber die Anzahl der disjunktiven Normalformen n gleich 22 , also gleich der Zahl aller Funktionen Bn −→ B. Die zweite Aussage ergibt sich durch Dualisierung. Folgerung 14.8 In der obigen Darstellung ist di1 ...in = f ∗ (1i1 , . . . , 1in ). Beispiel: f = ((x1 + x2 ) · x̄1 ) + (x2 · (x1 + x̄2 )), dann ist f (0, 0) = f (1, 0) = 0 und f (0, 1) = f (1, 1) = 1, die disjunktive Normalform von f erhalten wir, indem wir in der Wertetabelle die Stellen aufsuchen, wo der Wert 1 angenommen wird. Wenn hier ein Argument gleich 0 ist, so ist die entsprechende Variable zu komplementieren, sonst nicht. Also f ∼ x̄1 x2 + x1 x2 . Dies kann weiter vereinfacht werden: f ∼ (x̄1 + x2 ) · x2 = 1 · x2 = x 2 . Wir überlegen nun, wie man eine Darstellung von Polynomen vereinfachen kann. Definition Es seien p und q Boolesche Polynome; wir sagen, daß p das Polynom q impliziert, wenn aus p∗ (b1 , . . . , bn ) = 1 folgt, daß auch q ∗ (b1 , . . . , bn ) = 1 gilt (dabei ist bi ∈ {0, 1}. Wir bezeichnen ein Polynom als Produkt“, wenn es kein +-Zeichen enthält. ” Das Polynom p heißt Primimplikant von q, wenn gilt 1) p ist ein Produkt, 2) p impliziert q, 3) kein Teilprodukt von p impliziert q. Sei zum Beispiel q = x1 x2 x3 + x1 x̄2 x3 + x̄1 x̄2 x̄3 und p = x1 x2 , dann wird q von p impliziert, denn p∗ = 1 gilt nur für x1 = x3 = 1 und es ist q ∗ (1, x2 , 1) = (x2 + x̄2 + x̄2 )∗ = 1, aber z.B. x1 impliziert q nicht, da q ∗ (1, x2 , x3 ) = (x2 x3 + x̄2 x3 + 0)∗ = (x2 + x̄2 )x3 = x3 = 1 ist. Wir bemerken, daß ein Produkt genau dann 1 ist, wenn alle nichtkomplementierten Variablen gleich 1 und alle komplementierten Variablen gleich 0 gesetzt werden. Alle Summanden einer disjunktiven Normalform sind Implikanten. Satz 14.9 Jedes Polynom ist äquivalent zur Summe seiner Primimplikanten. 14 BOOLESCHE ALGEBREN UND BOOLESCHE FUNKTIONEN 129 Beweis: Seien p1 , . . . , pm die Primimplikanten von q, wir setzen p = p1 + . . . + pm . Sei nun p∗ (b1 , . . . , bn ) = 1, dann gibt es ein pi mit pi (b1 , . . . , bn ) = 1 und da pi das Polynom q impliziert, gilt auch q ∗ (b1 , . . . , bn ) = 1. Sei umgekehrt q ∗ (b1 , . . . , bn ) = 1, wir setzen s = xi11 · · · xinn mit ik = 1, falls bk = 1 und ik = −1 für bk = 0, dann ist s ein Implikant von q. Wir lassen nun aus dem Term s alle die xi weg, für die q ∗ (b1 , . . . , bi−1 , b̄i , . . .) = 1 ist; das Ergebnis sei r. Dann gilt: r impliziert q, aber kein Teilwort von r impliziert q, folglich ist r als Primimplikant gleich eienm der pj , also folgt p∗ (b1 , . . . , bn ) = 1, d.h. p ∼ q. Von der disjunktiver Normalform eines Polynoms ausgehend kann man eine Darstellung als Summe von Primimplikanten erhalten, indem man für alle Paare von Summanden, wo dies möglich ist, die Regel px + px̄ ∼ p anwendet.