Einführung, Algorithmen und Datenstrukturen 1 Erweiterungsteile zum Skript als Anhang am ENDE Das vorliegende Script wurde in der Vergangenheit erfolgreich für die Ausbildung von Fernstudenten genutzt. Eine Überarbeitung im Rahmen der augenblicklichen Lehrveranstaltung ist vorgesehen, der Originaltext bleibt aber für das volle Semester im Netz verfügbar. Einführung in die Informatik, Algorithmen und Datenstrukturen (Studiengang Informatik, Fernstudium) Dozent Dr.-Ing.habil. Georg Paul Dipl.Inf. Dirk Jesko Otto-von-Guericke-Universität Fakultät für Informatik Institut für Technische und Betriebliche Informationssysteme Dez. 1999 Einführung, Algorithmen und Datenstrukturen 2 Vorwort Die Informatik hat sich in den letzten Jahren zu einer umfangreichen Fachwissenschaft entwickelt. Das drückt sich u.a. in der großen Zahl der Lehrfächer aus, die zum Beispiel in einem Studiengang Informatik angeboten werden. Natürlich müssen in den ersten Semestern die Grundlagen in den mathematischen, naturwissenschaftlichen und Informatikfächern gelegt werden. Das Lehrfach „Einführung, Algorithmen und Datenstrukturen“ soll dabei eine Plattform für die sich anschließenden Fächer aus den Teilgebieten der Theoretischen, Technischen und Praktischen Informatik bilden. Dabei ist die Motivation für die Informatik ein erklärtes Ziel der Lehrveranstaltung. Der erste Teil beinhaltet eine Einführung, indem Aspekte des Entwurfs, der Theorie und der Ausführung von Algorithmen behandelt werden. Dieser Teil lehnt sich dabei sehr eng an den Klassiker in der Informatikliteratur Goldschlager/Lister: Informatik - Eine moderne Einführung, 3., bearbeitete und erweiterte Auflage, Hanser Verlag München, 1990 an. Natürlich ist es gut, bereits bei den praktischen Übungen zur Entwicklung von Algorithmen an eine Implementierung zu denken. Deshalb ist ein zweites Lehr- und Lernziel des ersten Teiles das Kennenlernen einer Programmiersprache. Vereinbarungsgemäß sollte dies im Lehrplan der Fakultät für Informatik der Otto-von-Guericke-Universität Magdeburg eine prozedurale Programmiersprache sein. Der Lehrende entschied sich für C/C++. Als abrufbare Literaturquelle steht zum Erlernen der Syntax und zum Üben ein Skriptum Paul/Nikolov: Grundlagen der Informatik für Ingenieure , überarbeitete Auflage 1998 http://wwwiti.cs.uni-magdeburg.de/ zur Verfügung. Einführung, Algorithmen und Datenstrukturen 1 2 3 4 5 3 EINFÜHRUNG 7 1.1 Geschichte der Informatik [REM91] 7 1.2 Computer und Algorithmen 12 1.3 Programme und Programmiersprachen 14 1.4 Software-Hardware-Hierarchie 16 1.5 Bedeutung der Algorithmen 16 ENTWURF VON ALGORITHMEN 18 2.1 Algorithmen, Programme, Programmiersprachen 18 2.2 Syntax und Semantik 18 2.3 Schrittweise Verfeinerung von Algorithmen 19 2.4 Steueralgorithmen 19 2.5 Modularität 21 2.6 Rekursion 22 2.7 Parallelität 24 2.8 Datenstrukturen 25 THEORIE DER ALGORITHMEN 28 3.1 Berechenbarkeit 28 3.2 Komplexität 32 3.3 Korrektheit 36 3.4 Nichtprozedurale Algorithmen 39 ALGORITHMENAUSFÜHRUNG: AUFBAU VON COMPUTERN 42 4.1 Struktur von Computern 42 4.2 Physikalische und elektronische Bausteine 42 4.3 Mikroprogrammierte Computer 43 ALGORITHMENAUSFÜHRUNG: SYSTEMSOFTWARE 44 Einführung, Algorithmen und Datenstrukturen 4 5.1 Sprachübersetzer 44 5.2 Betriebssysteme 46 6 EINFÜHRENDES BEISPIEL : GRÖßTER GEMEINSAMER TEILER 7 ZUSAMMENHANG DATENMODELLE, DATENSTRUKTUREN, ALGORITHMEN 48 48 7.1 Datenmodelle, Datenstrukturen in C++ (C) 48 7.2 Elementare Datenstrukturen 49 8 BÄUME 58 9 REKURSION 59 10 SORTIERALGORITHMEN 59 10.1 Elementare Sortierverfahren 59 10.2 Quicksort 59 10.3 MergeSort 59 10.4 Prioritätswarteschlangen 60 11 SUCHVERFAHREN 63 12 VERARBEITUNG VON ZEICHENFOLGEN 63 12.1 Pattern Matching 12.2 SYNTAXANALYSE (PARSING) 12.4 Kryptologie 13 ALGORITHMEN FÜR GRAPHEN 65 69 74 77 13.1 Allgemeines 77 13.2 Darstellung von Graphen A BC D EFGH IJKLM 78 80 13.3 Operationen auf Graphen 13.3.1 Tiefensuche 13.3.2 Breitensuche 81 81 83 13.4 Zweifacher Zusammenhang (biconnectivity) 84 Einführung, Algorithmen und Datenstrukturen 5 13.5 Gewichtete Graphen 84 13.6 Gerichtete Graphen 85 ABBILDUNG 1.1-1: GRUNDSTRUKTUREN EINES RECHENSYSTEMS 8 ABBILDUNG 1.1-2 RECHNERARCHITEKTUR 11 ABBILDUNG 1.2-1:KOMPONENTEN EINES COMPUTERS 14 ABBILDUNG 1.3-1: STUFEN DER ALGORITHMUSAUSFÜHRUNG 15 ABBILDUNG 2.6-1: TÜRME VON HANOI 22 ABBILDUNG 2.7-1: PAARWEISE ADDITION VON ZAHLEN 24 ABBILDUNG 2.8-1: UNSTRUKTURIERTE DATEN 25 ABBILDUNG 2.8-2: STRUKTURIERTE DATEN 26 ABBILDUNG 2.8-3: BAUMSTRUKTUR 26 ABBILDUNG 2.8-4: SORTIERTER BINÄRBAUM 27 ABBILDUNG 3.1-1: ABLAUF DES ALGORITHMUS STOPP-TESTER 29 ABBILDUNG 3.1-2: ALGORITHMUS SPAßIG 30 ABBILDUNG 3.1-3: PARTIELLE BERECHENBARKEIT 31 ABBILDUNG 3.2-1: DIE HERAUSFORDERUNG AN DIE COMPUTERWELT 32 ABBILDUNG 3.2-2: STANDARDALGORITHMUS ZUR MULTIPLIKATION 33 1,59 ABBILDUNG 3.2-3: EIN N -MULTIPLIKATIONSALGORITHMUS 33 ABBILDUNG 3.2-4: KASTENPROBLEM MIT EINER LÖSUNG 36 ABBILDUNG 3.4-1: FUNKTIONALER ALGORITHMUS ZUM AUFSUMMIEREN EINER LISTE 39 ABBILDUNG 5.1-1: ÜBERSETZUNGSPHASEN 45 ABBILDUNG 7.2-1: VERKETTETE LISTE 50 ABBILDUNG 7.2-2:VERKETTETE LISTE MIT ANFANGS- UND ENDKNOTEN 50 ABBILDUNG 7.2-3: ÄNDERUNG DER REIHENFOLGE IN EINER VERKETTETEN LISTE 50 ABBILDUNG 7.2-4:EINFÜGEN EINES LISTENELEMENTES 50 ABBILDUNG 10.4-1: EIN HEAP ALS VOLLSTÄNDIGER BAUM 61 ABBILDUNG 10.4-2: HEAP ALS FELD 61 ABBILDUNG 10.4-3:EINFÜGEN VON P 62 ABBILDUNG 10.4-4: EINFÜGEN VON C FÜR DAS GRÖßTE ELEMENT 62 ABBILDUNG 10.4-5: ENTFERNEN DES GRÖßTEN ELEMENTS 62 ABBILDUNG 12.1-1 AUTOMAT ZUR MUSTERERKENNUNG 66 ABBILDUNG 12.1-2 KONSTRUKTION VON ZUSTANDSAUTOMATEN 67 ABBILDUNG 12.1-3 SYNTAXBAUM FÜR (A*B+AC)D 69 ABBILDUNG 12.1-4TRIE ZUR KODIERUNG DER ZEICHEN A,B,C,D UND R 72 ABBILDUNG 12.1-5 HÄUFIGKEIT IN DER ZEICHENFOLGE A SIMPLE.. 73 ABBILDUNG 13.1-1 BEISPIEL EINES GERICHTETEN GRAPHEN 77 ABBILDUNG 13.1-2 MARKIERTER GRAPH MIT 2 KNOTEN 77 ABBILDUNG 13.2-1 GRAPH ZUR ADJAZENZLISTE 80 ABBILDUNG 13.3-1 TIEFENSUCH-WALD, ADJAZENZLISTE 82 ABBILDUNG 13.3-2 TIEFENSUCH-WALG, ADJAZENZMATRIX 83 ABBILDUNG 13.4-1 EIN NICHT ZWEIFACH ZUSAMMENHÄNGENDER GRAPH 84 ABBILDUNG 13.5-1 UNGERICHTETER, GEWICHTETER GRAPH 84 ABBILDUNG 13.6-1EIN GERICHTETER GRAPH 86 ABBILDUNG 13.6-2 TIEFENWALD-SUCHE FÜR EINEN GERICHTETEN GRAPHEN 86 TABELLE 1.1-1VERGLEICH DUAL - UND DEZIMALSYSTEM 7 Einführung, Algorithmen und Datenstrukturen 6 TABELLE 1.1-2: MERKMALE DER RECHNERGENERATIONEN 11 TABELLE 1.2-1 ALGORITHMEN FÜR ALTTAGSPROZESSE 13 TABELLE 2.7-1: AUSFÜHRUNG DES ALGORITHMUS ZUR PAARWEISEN ADDITION25 TABELLE 3.2-1: AUSFÜHRUNGSZEITEN FÜR ALGORITHMEN 33 TABELLE 12.1-1 AUTOMAT ALS FELD 67 TABELLE 12.1-2 INHALT DER DEQUE WÄHREND DER ERKENNUNG VON AAABD68 TABELLE 13.2-1 ADJAZENZMATRIX EINES GERICHTETEN GRAPHEN NACH OBIGER ABBILDUNG 79 Einführung, Algorithmen und Datenstrukturen 7 Teil I: Einführung 1 Einführung 1.1 Geschichte der Informatik [REM91] (Zahlensysteme, Grundelemente eines Rechensystems, Rechenmaschinen/Computer, Rechnerarten, Aufbau eines Digitalrechners) Literatur: Rembold/Levi : Einführung in die Informatik für Naturwissenschaftler und Ingenieure, 3. Auflage, Hanser Verlag München 1999 Die Entwicklung des Rechners begann mit der Erfindung der Zahlensysteme. Die meisten wichtigen Kulturvölker hatten ihre eigenen Zahlensysteme (Sumerer, Ägypter, Babylonier, Römer, Chinesen, Maya.. ). Diese Systeme waren Stellenwert- oder Positionssysteme, d.h., der Wert einer Zahl hängt nicht nur von der Form eines Zeichens sondern auch von seiner Stellung in einer Zahl ab. Beispiel: Jahreszahl 1965 im römischen System MCMLXV (2 * 1000 - 100) +50 +10 + 5 Das von uns am meisten verwendete Dezimalsystem kam im Mittelalter aus Indien. Elektronische Rechner arbeiten nach dem Prinzip der Dualarithmetik (Leibniz 1646-1716). Der Ursprung könnte jedoch bei den alten Chinesen liegen. Eine Dualzahl kann den Wert 0 oder 1 annehmnen. Diese Werte können mit elektrischen Schaltungen verwirklicht werden, d.h., liegt z.B. keine Spannung an, deuten wir es als Wert 0, ansonsten als Wert 1. Technisch kann man dies mit Schaltern, Relais, Röhren oder Transistoren realisieren. Mit integrierten Schaltungen können sodann komplexe Rechenwerke aufgebaut werden, die die vier Grundrechenarten Addieren, Subtrahieren, Multiplizieren und Dividieren sehr effizient durchführen können. Dual-System (Basis 2) m= 10 9 8 7 6 5 4 3 2 1 0 0 1 10 11 100 101 1010 10100 110010 1100100 111110100 1111101000 11110101101 Dezimalsystem (10) 3210 0 1 2 3 4 5 10 20 50 100 500 1000 1965 Tabelle 1.1-1Vergleich Dual - und Dezimalsystem Ein Rechenvorgang besteht nun aus Eingabe, Verarbeitung und Ausgabe. Die Einführung, Algorithmen und Datenstrukturen 8 Grundelemente eines Rechners sind jene nach Abbildung 1.1. Eingabe Verarbeitung Rechenwerk Steuereinheit Speicher Ausgabe Abbildung 1.1-1: Grundstrukturen eines Rechensystems Die Arbeit eines solchen Rechenssystems besteht also darin, daß über eine Eingabe Daten übernommen werden, die von einem Programm, das z:B. im Speicher liegt, abgerufen werden, im Rechenwerk anschließend verarbeitet werden, um die Ergebnisse an die Ausgabe zu übergeben. Die Steuereinheit wacht über die ordnungsgemäße Ausführung. Auf der Basis dieser Erkenntnisse wurden zahlreiche Versuche zur Entwicklung von Rechenmaschinen unternommen. Die ersten Ergebnisse dieser Bemühungen sind die 1623 von Schickard gebaute Rechenmaschine (Addition, Subtraktion, Multiplikation, Division) sowie die 1641 von Pascal konstruierte, erste, erhaltengebliebene Vierspeziesmaschine ("Pascaline" im Londoner Museum). Weitere Entwicklungsetappen stellen dar: 1804 Jacquard konstruierte den lochkartengesteuerten mechanischen Webstuhl, der die Lochungen mit Nadeln abtastete. 1822 Babbage entwarf das Konzept einer mechanischen, programmgesteuerten Rechenmaschine mit den Einheiten Speicher, Steuereinheit und Verarbeitungseinheit. Die Anlage konnte aufgrund der begrenzten Leistungsfähigkeit der ausschließlich mechanischen Bauteile nicht zur vollständigen Funktionstüchtigkeit entwickelt werden. 1886 Hollerith konstruierte die elektrische Lochkartenmaschine, die in den USA zur Auswertung der Volkszählung verwendet wurde. 1941 Zuse entwarf und konstruierte die Anlage Z3, die erste programmgesteuerte Rechenmaschine mit ca. 3000 Relais und 50 Operationen je Minute. 1944 Aiken entwickelte den Relaisrechner Mark I. 1964 Eckart, Mauchly gaben die Ideen zum Bau des Elektronenröhrenrechners ENIAC (electric integrater and numerical computer). Diese Ausführungen zeigen, daß die EDV keineswegs eine zufällige Erfindung war, sondern das Ergebnis langen Suchens sowohl nach technischen Lösungen als auch nach geeigneten funktionalen Prinzipien. Funktionsprinzipien in Verbindung mit den dafür geeigneten Technologien erschließen der EDV laufend neue Gebiete, die Wirkungen erzeugen, die weit über die ursprünglichen Bedürfnisse hinausgehen. Obwohl der Computer als Werkzeug zur Datenverarbeitung das Ergebnis einer zielstrebigen Forschung und Entwicklung war und ist, überraschen seine weitreichenden Anwendungen selbst Fachleute immer wieder. Die Ursachen liegen im Zusammentreffen mehrerer grundsätzlich neuer Dinge. Dazu gehören: - gespeichertes Programm, Einführung, Algorithmen und Datenstrukturen 9 - kybernetisches Prinzip der informationellen Steuerung auf der Basis digital dargestellter Informationen, - Entwicklung einer Jahrhunderttechnologie in Form integrierter Schaltkreise auf Si-Basis. Die Verbindung neuer funktionaler Prinzipien und kaum begrenzter Leistungstechnologien bewirken den bedeutungsvollen Schritt in das Informationszeitalter. Während die Si-Technik größte Beachtung erfährt, ist es ungleich schwieriger, die revolutionierende Bedeutung der geistig-logischen Prinzipien an hervorragender Stelle darzustellen. In dieser Reihe sind besonders zu erwähnen: Leibniz Boole Turing Auf ihn geht die Verallgemeinerung der Ziffernschreibweise im Dualprinzip sowie die Erfindung einer formalen, universellen Sprache zurück (1680-1690). Erfand die Algebra der Logik (1815-1864). Entwickelte die mathematische Logik weiter zum Konzept der Berechenbarkeit und ihrer Formulierung in einem Automatenmodell (1936). Den entscheidenden Durchbruch gab es mit der bereits erwähnten Konzeption des speicherprogrammierbaren Rechners durch den ungarischen Mathematiker Johann von Neumann (1944/45). Das gespeicherte Programm schafft eine informationsverarbeitende Maschine, die die Möglichkeit hat, nicht nur Nutzinformationen, sondern auch ihre eigene Arbeitsfolge, die als Informationen ebenfalls gespeichert ist, - zu verarbeiten, - zu verändern und - den Ablauf der Arbeit damit selbst zu steuern. Dieser Prozeß war bisher in der Technik nicht bekannt. Der dadurch praktizierte Übergang zur informatorischen Steuerung hat viel grundsätzlichere Bedeutung, indem verschiedenartige Steuerungsvorgänge abstrahiert und auf einheitliche Verarbeitungsmethoden zurückgeführt werden. Hierin liegt auch das Erfolgsgeheimnis der Mikroprozessoren. Die Entwicklung integrierter Schaltkreise in Si-Technik stellt eine wissenschaftlich-technische Kombinationsleistung dar, die zusammen mit der Kernenergie- und Raumfahrttechnik die Geschichte der Technik im 20. Jahrhundert prägt. Die technische Perfektion der Si-Technik führt zur Absenkung des Preis/Leistungsverhältnisses. Waren anfänglich geringe Produktionsausbeuten wegen ungenügend beherrschter Herstellungsprozesse zu verzeichnen, so gelang es mittels zuverlässiger Technologien, die Schaltkreise auf immer kleineren Si- Chips unterzubringen und damit die Packungsdichte zu erhöhen. Höhere Ausbeute, schnellere und leistungsfähigere Schaltkreise führen hinsichtlich der Computerleistung zu einem Effekt in dritter Potenz. Die alte Ingenieurerfahrung, nach der Fortschritt an der einen Stelle seinen Preis an anderer Stelle hat, tritt in diesem Falle in mehrfacher Wirkung auf. Dafür gibt es in der Naturwissenschaft nur wenige Parallelen (z.B. Nutzbarmachung des Feuers, Erfindung der Buchdruckerkunst). Äquivalenz zwischen Steuerung und Informationen Auf der Suche nach fundamentalen Grundsätzen in der Wissenschaft war es stets nützlich, Quelle und Entwicklung des heute gesicherten Wissens im historischen Rückblick zu analysieren. Die Entwicklungen von Wissenschaft und Technik sind oft einen großen Schritt Einführung, Algorithmen und Datenstrukturen 10 dadurch vorangebracht worden, daß große Denker unabhängig voneinander bekannte Gesätzmäßigkeiten in anderen Erscheinungen gefunden haben. Dieser Sachverhalt wird durch den Begriff "Äquivalenz" ausgedrückt. Beispiele: Isaac Newton Robert Meyer Albert Einstein Johann von Neumann 1683 1842 1907/17 1946 Schwerkraft-Massenanziehung Wärme und Energie Masse und Energie Information und Steuerung Das Prinzip des als Information gespeicherten Programms präsentiert sich als weitreichendes Prinzip, dem sich fast täglich neue Anwendungen eröffnen. Alle seit dieser Zeit der von Neumann'schen Entdeckung entwickelten Computeranlagen haben dieses Prinzip als Kernstück. Daher ist die Organisation der informatorischen Steuerung sowohl in zentralen als auch in verteilten Systemen zu einer Primäraufgabe der Softwareentwicklung und -architektur geworden. Dies erfordert, daß sich die Ingenieurgenerationen im verstärkten Maße der Ausarbeitung und Beherrschung logischer Konzeptionen von Systemen widmen müssen. Die Möglichkeit, ursprüglich technische Prozesse als informatorische Strukturen zu erkennen und zu betrachten, führt dazu, daß die Informationsverarbeitung mehr und mehr als eine Wechselwirkung zwischen Programmen und Informations-Komplexen zu verstehen ist. Die von Neumann'schen Architekturen markieren den "Königsweg" der EDV zu ihrem zivilisationsgeschichtlichen Universalanspruch. Vor diesem Hintergrund vollzieht sich gegenwärtig ein dramatischer Technologiewandel - der Übergang zur 6. Rechnergeneration. Nach Rembold [REMB, S.25 ff.] ist die Entwicklung der elektronischen Rechnermaschinen dadurch gekennzeichnet, daß sich jede der sechs Perioden durch typische Software- und Hardwareentwicklungsstufen auszeichnet. (Die Zeitangaben differieren in den einzelnen Quellen, die Merkmale sind jedoch zumeist eindeutig.) Periode I (1953-58) -Hardware: Vakuumröhren, Magnetbänder, Magnettrommeln als Externspeicher, Zugriffszeit 10-3 sec. -Software: keine unterstützenden Betriebssysteme bzw. Compiler -Einsatz : vor allem im Rechnungswesen Periode II (1958-66) -Transistoren, verbesserte Kernspeicher, 10-6 sec. -Betriebssytem und Compiler (Cobol, Fortran) vorhanden -wissenschaftliche Rechnungen, Betriebsüberwachung Periode III (1966-74) -integrierte Schaltungen, Mikroprozessoren, 2*10E-9 sec. Kleinrechner, Halbleiterspeicher, Rechnerverbund -Softwarekrise, da mit Hardwareentwicklung nicht schritthaltend -Rechner als Konstruktionshilfe (CAD) Periode IV (1974-82) -Verkleinerung der Schalkreise (>130 000 Transistoren) 64 K Bit-Speicher, Super- und Kleinrechner Einführung, Algorithmen und Datenstrukturen 11 -CAD/CAM-Anlagen, Netze Periode V (1982-1990) -höchstintegrierte Schaltkreise, 16 - 64M Bit-Speicherchips -Workstation -Expertensysteme, KI Periode VI (ab 1990) - 64 Bit-Prozessoren, 16 Mbit DRAM Speicher, - rapide Zunahme des Einsatzes von PCs, Verbundsysteme - Ausdehnung der Anwendungen auch auf nichtkommerzielle Bereiche z.B. Medizin, Ausbildung, Medientechnik Nr. Elektronische Basis Jahr Charakteristik 1 Röhren 1945 Schaltsekunden 1ms 2 Transistoren, RTL 1955 1 mikrosec. 3 Integrierte Schaltkreise, TTL 1965 1 nanosec., geringe Magnetkernspeicher 4 Prozessoren, VLSI, MOS 1975 Softwareentwicklung 5 ULSI, Gesamtsystem 1987 Künstliche Intelligenz 6 Superskalares System 1990 Verbundsysteme Baugröße Tabelle 1.1-2: Merkmale der Rechnergenerationen Rechner verarbeiten Daten. Die Datenein- und Ausgabemöglichkeiten sind heute vielfältig, so daß prinzipiell alle bekannten Kommunikationsmittel wie Text, Bild, Sprache, Video verarbeitet werden können. Ein Digitalrechner (Computer) benötigt entsprechende Software, um die Hardware bertreiben zu können. Dazu gehören Betriebssystemprogramme ( zur Ablaufsteuerung, Ein-/Ausgabesteuerung, externen Datenverarbeitung, Zentralspeicherverwaltung, Konfigurationsverwaltung), Dienstprogramme (Compiler, Interpreter), Anwenderprogramme (vom Nutzer generierte Programme zur Problemlösung). Abbildung 1.19, Seite 35 Rembold übernehmen Abbildung 1.1-2 Rechnerarchitektur Einführung, Algorithmen und Datenstrukturen 12 1.2 Computer und Algorithmen (Computerrevolution mit der Wirkung der Steigerung der geistigen Kräfte des Menschen, Was ist ein Computer? Algorithmus, Prozeß, Prozessor, Computermerkmale (Geschwindigkeit, Zuverlässigkeit, Speicher, Kosten)) Wir leben im Zeitalter der Computerrevolution. Wie jede Revolution ist sie umfassend, durchdringend und wird bleibende, fundamentale Auswirkungen für die gesellschaftliche Entwicklung haben. Sie wirkt sich insbesondere auf die Denk- und Lebensweise jedes Einzelnen aus. - industrielle Revolution, das bedeutete im wesentlichen eine Steigerung der körperlichen Kräfte des Menschen, der Muskelkräfte: * Druck auf den Knopf veranlaßt die Maschine, ein Muster in ein Metallblech zu stanzen. * Zug an einem Hebel bewegt eine schwere Baggerschaufel durch eine Kohlenmasse. * Bestimmte, sich wiederholende körperliche Tätigkeiten werden von Maschinen übenommen. - Computerrevolution als tragende Säule der technischen Revolution, das bedeutet Steigerung der geistigen Kräfte des Menschen: * Druck auf einen Knopf kann eine Maschine veranlassen, verzwickte Berechnungen durchzuführen, komplizierte Entscheidungen zu fällen oder Informationsmengen zu speichern und wieder aufzufinden. * Bestimmte, sich wiederholende geistige Tätigkeiten werden von Maschinen übernommen. Was ist ein Computer, daß er solche revolutionären Auswirkungen besitzt ? Eine Maschine, die geistige Routineaufgaben ausführt, indem sie einfache Operationen mit hoher Geschwindigkeit vornimmt, wobei die Einfachheit der Operationen (z.B. Addition oder Vergleich zweier Zahlen) mit Geschwindigkeit ausgeglichen wird und eine hohe Zahl von Operationen ausführbar ist. Somit kann ein Computer bedeutende Aufgaben lösen. Definition für Algorithmusbegriff: Einen Computer dahin zu bringen, daß er eine Aufgabe ausführt, bedeutet, ihm mitzuteilen, welche Operationen er ausführen soll - man muß beschreiben, wie die Aufgabe auszuführen ist. Solch eine Beschreibung nennt man Algorithmus. Er beschreibt demzufolge die Methode, mit der eine Aufgabe gelöst wird. Der Algorithmus besteht aus einer Folge von Schritten, deren korrekte Abarbeitug die gestellte Aufgabe löst. Diesen Vorgang bezeichnet man als Prozeß. Weitere Definitionen: Ein Algorithmus liegt genau dann vor, wenn gegebene Größen (Eingabegrößen, Eingabeinformationen, Aufgaben) aufgrund eines Systems von Regeln (Umformungsregeln) eindeutig in andere Größen (Ausgabegrößen, Ausgabeinformationen, Lösungen) umgeformt oder umgearbeitet werden können. Ein Algorithmus dient stets zur Lösung einer Klasse von Aufgaben einheitlichen Typs. Ein Algorithmus ist ein eindeutig bestimmtes Verfahren unter Anwendung von Einführung, Algorithmen und Datenstrukturen 13 Grundoperationen über primitiven (gegebenen) Objekten. Der Algorithmus ist keine Besonderheit der Informatik. Viele Alltagsvorgänge lassen sich durch Algorithmen beschreiben (siehe Tabelle 1.3) Prozeß Pullover stricken Modellflug-zeug bauen Kuchen backen Kleider nähen Sonate spielen Algorithmus Strickmuster Typische Schritte im Algorithmus stricke Rechtsmasche, stricke Linksmasche Montage-anleitung leime Teil A an den Flügel B Rezept Schnitt-muster Notenblatt nimm 3 Eier, schaumig schlagen nähe seitlichen Saum spiele Note o/ Tabelle 1.2-1 Algorithmen für Alttagsprozesse Beispiel: Algorithmus für das Suchen der größten Zahl aus einer Menge von positiven ganzen Zahlen; die Zahl -1 kennzeichnet das Ende der Menge Verbale Beschreibung: Dieser Algorithmus besteht aus 3 wesentlichen Schritten - Eingabe und Speicherung (1,2) - Schleife (3,4,5) - Ausgabe (6) Schritt 1: lies die erste Zahl Schritt 2: initialisiere z mit der gelesenen Zahl (die Zahl der Variablen z zuweisen) Schritt 3: lies die nächste Zahl x Schritt 4: wenn diese Zahl x größer z, dann setze auf z diese Zahl Schritt 5: wenn noch Zahlen vorhanden, dann gehe nach Schritt 3 Schritt 6: gib z aus Pascal - Programm: program maxnr(input,output); var z,x:integer; begin readln(z); (* Schritt 1 und 2 *) repeat readln(x); (* Schritt 1 und 3 *) if x>z then z:=x (* Schritt 4, Vergleich *) until x=-1; (* Schritt 5, Abbruch *) writeln(z); (* Schritt 6 *) readln (* Ausgabebildschirm bleibt solange, *) (* bis eine Taste gedrückt wird *) end. Einführung, Algorithmen und Datenstrukturen 14 Personen oder Einheiten, die solche Prozesse ausführen, nennt man Prozessoren. Der Computer ist ein spezieller Prozessor, der im wesentlichen aus 3 Hauptkomponenten besteht: 1. Zentraleinheit (CPU, central processing unit), die die Basisoperationen ausführt, 2. Speicher (memory), der a) die auszuführenden Operationen als Algorithmus und b) die Information oder die Daten, auf denen die Operationen wirken, enthält, 3. Ein- und Ausgabegeräte (input and output devices), über die der Algorithmus und die Daten in den Hauptspeicher gebracht werden und über die der Computer die Ergebnisse seiner Tätigkeit mitteilt. Diese 3 Komponenten bilden die Hardware. Abbildung 1.2-1:Komponenten eines Computers Merkmale, die für einen Computer kennzeichnend sind: 1. Geschwindigkeit (Operationen/Zeiteinheit) Computer der heutigen Zeit können mehrere Millionen Operationen pro Sekunde ausführen. Diese Geschwindigkeit wird auch gebraucht, da es eine große Zahl von Algorithmen gibt, deren Ausführung sehr viele einzelne Operationen verlangen und damit sehr zeitaufwendig sind. 2. Zuverlässigkeit (Fehlerhäufigkeit) Computer haben den Vorteil, daß sie selbst in ihrer Arbeitsweise nahezu fehlerlos sind. Wenn trotzdem Abstürze vorkommen, so liegt das zumeist an der Unvollkommenheit der Algorithmen. 3. Speicherfähigkeit (Menge der Informationseinheiten) Als die „ Computer laufen lernten“, waren Speicherkapazität und Zugriffszeit in Dimensionen unterschiedlich zu jenen heutiger Zeit. Frühere Kraftakte zur Formulierung von Algorithmen, die sich durch speichersparende Lösungen auszeichnen, sind momentan nicht mehr so sehr im Blickfeld. 4. Kostenaufwand (Preis/Leistungsverhältnis) Computer sollen Aufgaben übernehmen, die der Mensch nicht effktiv selbst ausführen kann. Computer sind deshalb Hilfsmittel, die sich gemessen am Preis-Leistungsverhältnis amortisieren müssen. 1.3 Programme und Programmiersprachen (Computer als Prozessor,Algorithmus als Programm in einer Programmiersprache, Maschinensprache, Assemblersprache bis zu höheren Programmiersprachen, Sprachübersetzer, Sprachen wie Fortran, Pascal, C, C++, Java, PROLOG, LISP...) Einführung, Algorithmen und Datenstrukturen 15 An den Algorithmus wird der Anspruch gestellt, daß er so ausgedrückt wird, daß der Prozessor ihn versteht und ausführen kann. Der Prozessor muß den Algorithmus interpretieren können, indem er a) versteht, was jeder Schritt bedeutet und b) die jeweilige Operation ausführen kann. Zum Beispiel muß der Pianist Noten lesen und spielen können, der Koch muß ein Rezept umsetzen und der Strickende muß Nadeln und Wolle handhaben können. Ist der Prozessor ein Computer, muß der Algorithmus in Form eines Programmes ausgeführt werden. Dazu bedarf es einer geeigneten Programmiersprache. Um den Algorithmus als Programm zu formulieren, muß man programmieren. Jeder Algorithmusschritt wird durch eine Anweisung (instruction) oder einen Befehl (statement) beschrieben. Ein Algorithmus besteht somit aus einer Folge von Anweisungen, von denen jede Operationen angibt, die der Computer ausführen soll. Die Abbildung 1.3-1 vermittelt die Stufen der Algorithmusausführung mittels Computer. Abbildung 1.3-1: Stufen der Algorithmusausführung Einführung, Algorithmen und Datenstrukturen 16 1.4 Software-Hardware-Hierarchie Programme bilden die Software, Geräteausstattung ist gleich Hardware. Eine Sammlung von Programmen nennt man Anwendungspaket, Anwendungspakete plus benutzergeschriebene Programme gleich Anwendungssoftware. Systemsoftware( z.B. Betriebssystem ) erfüllt Dienste für Anwendungssoftware. Rollen der Soft- und Hardware sind nicht scharf zu trennen. Prinzipiell kann jede Funktion eines Computersystems durch Hard- oder Software realisiert werden! 1.5 Bedeutung der Algorithmen Schritte eines Computerprozesses sind: Algorithmus entwerfen, Algorithmus in Programm umsetzen, Programm durch Computer ausführen. Was interessiert die Informatik an Algorithmen? 1. Aspekt des Entwurfs (design) Fragestellung: Gibt es einen Algorithmus zum Entwurf von Algorithmen? 2. Aspekt der Berechenbarkeit (computability) Fragestellung: Gibt es Prozesse, für die kein Algorithmus existiert? 3. Aspekt der Komplexität (complexity) Fragestellung: Welche Ressourcen (Zeit, Speicher..) werden benötigt? 4. Aspekt der Korrektheit (correctness) Fragestellung: Macht ein Algorithmus auch wirklich das, was er soll? Wie dargelegt, erfordert die Durchführung eines Prozesses auf einem Computer, daß 1. ein Algorithmus entworfen wird, 2. der Algorithmus in einer geeigneten Programmiersprache ausgedrückt wird, 3. der Computer das Programm ausführt. Die Rolle der Algorithmen ist grundlegend: Ohne Algorithmus kein Programm, Ohne Programm keine Ausführung. Algorithmen sind weiterhin sowohl unabhängig von der Programmiersprache als auch vom Computertyp. Als Analogie zum Alltagsleben: Ein Rezept für einen Obstkuchen kann in Deutsch oder Englisch ausgedrückt werden - der Algorithmus ist derselbe. Falls das Rezept gewissenhaft befolgt wird, entsteht der gleiche Kuchen - unabhängig vom Code. Technisch ausgedrückt heißt das: Alle Computer (wie alle Köche) können die gleichen Grundoperationen ausführen, obwohl diese sich in Details unterscheiden. Daraus resultiert der Schluß: Algorithmen können unabhängig von der Tagestechnologie erzeugt und studiert werden - die Ergebnisse bleiben trotz neuer Computermodelle und Programmiersprachen gültig . Programmiersprachen und Computer sind Mittel, um Algorithmen in Form von Prozessen auszuführen. Computertechnologie und Programmiersprachen bestimmen jedoch Einführung, Algorithmen und Datenstrukturen 17 entscheidend die schnellere, billigere und zuverlässigere Ausführung von Algorithmen (Beispiel: Möglichkeit der computergestützten Wettervorhersage). 4 allgemeine Merkmale für Algorithmen : Ein Algorithmus muß von einer Maschine durchgeführt werden können. Die für den Ablauf des Algorithmus benötigte Information muß zu Beginn vorhanden sein. Ein Algorithmus muß allgemeingültig sein. Die Größe der Datenmenge, auf die der Algorithmus angewandt wird, darf nicht eingeschränkt sein. Der Algorithmus besteht aus einer Reihe von Einzelschritten und Anweisungen über die Reihenfolge. Jeder Schritt muß in seiner Wirkung genau definiert sein. Ein Algorithmus muß nach einer endlichen Zeit (und nach einer endlichen Zahl von Schritten) enden. Für das Ende des Algorithmus muß eine Abbruchbedingung formuliert sein. Einführung, Algorithmen und Datenstrukturen 18 2 Entwurf von Algorithmen Literatur Goldschlager/Lister: Informatik-Eine moderne Einführung, 3. Auflage, Hanser Verlag, München 1990 2.1 Algorithmen, Programme, Programmiersprachen Algorithmen sind im allgemeinen dazu da, um die Ausführung von Prozessen zu beschreiben. Auf Computern bestehen die Prozesse darin, Eingabedaten zu übernehmen, dieselben zu verarbeiten, um sie dann auszugeben. Die Prozesse sollen dabei endlich ablaufen. (Beispiel Pullover stricken!) Es gibt jedoch auch unendlich ablaufende Prozesse. (Beispiel Ampel steuern!) Algorithmus und Programm Welche Programmiersprache sollte gewählt werden ? Warum nicht die gewohnte Umgangssprache? Abweisende Gründe: 1.Umgangssprache ist zu umfangreich im Vokabular und Grammatik. Es müßte ein Algorithmus existieren, der diese Sprache zergliedern kann, um sie übersetzen zu können. 2. Verständnis der Sprache hängt nicht nur von der Grammatik sondern auch vom Umfeld ( Kontext ) ab. Der Sinn ( die Semantik ) ist von entscheidender Bedeutung! (Beispiel: Das war ein Wink mit dem Zaunpfahl!) Schlußfolgerung: Einfachere Programmiersprachen müssen her! Alle diese Programmiersprachen haben ein bestimmtes Paradigma, ein Vokabular und eine Grammatik. Bei der Entwicklung von Programmiersprachen werden hauptsächlich diese Ziele verfolgt: 1. Möglichkeit der einfachen und knappen Darstellung der Algorithmen 2. leicht verständlich für Computer zu sein 3. leicht verständlich für den Menschen zu sein 4. Minimierung der Fehlermöglichkeiten 5. Programm sollte die Ausführung des Prozesses erkennen lassen. 2.2 Syntax und Semantik Ein Prozessor (Computer) muß befähigt sein (1) die Darstellung des Algorithmus zu verstehen und (2) die Operationen ausführen zu können. Schritt (1) zerfällt in 2 Teilschritte: Den verwendeten Symbolen muß eine Bedeutung zugeordnet werden, d.h., Vokabular und Grammatik, die als Syntax bezeichnet werden, müssen dem Prozessor bekannt sein. Hierbei auftretende Fehler werden als Syntaxfehler bezeichnet. Beispiel: x=x+1; Syntaxfehler in Pascal da richtig: x:=x+1; Der 2. Teilschritt, der notwendig ist, um einen algorithmischen Ausdruck zu verstehen, verlangt ,jedem Schritt eine Bedeutung zuzumessen, die dann als ausführbare Operationen erkannt werden. Die Bedeutung der Ausdrucksformen heißt Semantik einer Sprache. Die dabei begangenen Fehler nennt man semantische Fehler. Beispiel: Einführung, Algorithmen und Datenstrukturen 19 var monat: 1..12; n: integer; readln (n); { falls n > 12 eingelesen wird, erzeugt die Ausgabe} writeln (monat); {einen semantischen Fehler.} Zusammenfassend: Um jeden Schritt eines Algorithmus interpretieren zu können, muß ein Prozessor in der Lage sein 1. die Symbole, in denen der Algorithmusschritt ausgedrückt ist, zu verstehen, 2. dem Algorithmusschritt in Form von auszuführenden Operationen eine Bedeutung zuzuordnen, 3. die entsprechenden Operationen auszuführen. Bei der Nutzung eines Computers werden die Schritte 1. und 2. durch einen Übersetzer vollzogen. Neben syntaktischen und semantischen Fehlern treten auch logische Fehler auf, die der Computer nicht entdeckt. Beispiel: U:= Pi*R; richtig: U:=2 *Pi*R; 2.3 Schrittweise Verfeinerung von Algorithmen Sind zu lösende Aufgaben leicht überschaubar, so hat auch der Entwickler keine Probleme, um einen Algorithmus für die Computernutzung vorzubereiten. Komplexere Aufgaben müssen methodisch aufbereitet werden. Die Vorgehensweise dafür ist die schrittweise Verfeinerung (top-down-design). Schrittweises Verfeinern entspricht dem Gedanken „divide et conquera“ (teile und herrsche). Der auszuführende Prozeß ist in Teilschritte zu zergliedern, die durch einfache, überschaubare Algorithmen zu beschreiben sind. (Beispiel: Roboter soll Kaffee kochen![GOLI90, S.22 ff]) 2.4 Steueralgorithmen Algorithmusschritte können in bestimmter Reihenfolge angeordnet sein. Sind sie einfach hintereinander aufgereiht, so spricht man von einer Folge oder Sequenz. Beispiel. Umgangssprache Gib einen Wert ein. Bilde das Quadrat dieses Wertes. Gib den neuen Wert aus. in C++-Notation cin >> Wert; Wert=Wert*Wert; cout >>Der neue Wert ist<< Wert; Oftmals muß eine Auswahl zwischen zwei oder mehreren Schritten getroffen werden. Man spricht in diesem Fall von einer Auswahl oder Selektion. Beispiel: Falls Alter größer als 17 if (Alter>17) dann schreibe volljährig cout >>volljährig; sonst schreibe minderjährig else cout >>minderjährig; Einführung, Algorithmen und Datenstrukturen 20 Häufig kommt es vor, daß bestimmte Algorithmusschritte wiederholt werden müssen, um das gewünschte Ziel zu erreichen. Beispiel: Summe der ganzen Zahlen 1 - n Nehmen wir an, wir wollen die Summe der Zahlen 1 bis n bilden. Dann könnte der Algorithmus so aussehen (verbal und Pascal): Lies n ein! Setze die Summe auf Null! Setze die Zahl auf 1! Wiederhole Summe = Summe + Zahl Zahl = Zahl + 1 Bis Zahl > n; readln(n) summe:= 0; zahl:=1; repeat summe:= summe + zahl; zahl:= zahl + 1; until zahl>n; Prinzipiell besteht diese Schleife oder auch Iteration aus diesen Teilen: wiederhole Algorithmusteil bis Bedingung repeat {Algorithmusteil} until Bedingung Das bedeutet, daß der Algorithmusteil zwischen den Worten wiederhole und bis solange wiederholt wird, bis die Bedingung erfüllt ist. Den Algorithmusteil nennt man Schleifenkörper, die Bedingung heißt Abbruchbedingung. Die Schleife ermöglicht, einen Prozeß unbekannter Dauer zu beschreiben. Hierin liegt nun auch die Verantwortung des Entwicklers solcher Konstrukte, indem er für eine gewollte Beendigung der Schleife sorgt. Fehlt die korrekte Endbedingung, tritt einer der häufigsten Fehler beim Algorithmusentwurf auf. Die Wiederhole bis - Schleife ist nicht immer probates Mittel für eine Iteration, da in dieser Schleife der Schleifenkörper auf jeden einmal durchlaufen wird. Es handelt sich um eine sogenannte nichtabweisende, endgeprüfte Schleife. Als Alternative steht die Solange führe aus – Schleife als anfangsgeprüfte Schleife zur Verfügung, die bei Nichterfüllung der Bedingung abweisend ist: Solange Bedingung führe aus Algorithmusteil while Bedingung do begin {Schleifenkörper} end; Beispiel: Größter gemeinsamer Teiler zweier ganzer Zahlen Euklids Algorithmus zur Bestimmung des größten gemeinsamen Teilers (GGT) lautet: GGT(x,y) = GGT(y, Rest von x/y) falls y < 0 und GGT(x,y) = x falls y= 0 GGT (24,9) = GGT (9,6) = GGT(6,3) = GGT(3,0) = 3 verbal Solange y 0 führe aus Berechne Rest von x/y Pascal while (y <> 0) do begin rest := x mod y; Einführung, Algorithmen und Datenstrukturen Ersetze x durch y Ersetze y durch Rest Schreibe als Ergebnis x 21 x := y; y := rest; end; writeln (x); 2.5 Modularität Bei der schrittweisen Verfeinerung der Algorithmen fällt uns auf, daß bestimmte Komponenten immer wiederkehren und eigentlich unabhängig vom Gesamtalgorithmus der zu lösenden Aufgabenstellung sind. Diese Unabhängigkeit der Algorithmen kann soweit führen, daß deren Entwicklung separat durch Personen betrieben werden kann, die eigentlich nichts mit der Lösung der Gesamtaufgabenstellung zu tun haben. Man benötigt nur bestimmte Absprachen über die Leistung., die die Komponente - oder auch Modul - erbringen soll. Ein Modul beinhaltet demzufolge einen Algorithmus, der problemlos in einen übergeordneten Algorithmus eingefügt werden kann. In den einzelnen Programmiersprachen gibt es dafür unterschiedliche Bezeichner: procedure, function, routine, subroutine, subprogram.. Moduln können in allgemeiner Form wie folgt beschrieben werden: Modul Modulname (Formalparameter) {Spezifikation des Prozesses, den der Modul beschreibt} Modulrumpf Beispiel: Berechnung des Sinuswertes eines Winkels function Sinus (Winkel: integer) : real; begin Winkel := Winkel*PI/180; {Umrechnung in Bogenmaß!} Sinus := sin(Winkel); end; Der Aufruf des Moduls erfolgt über seinen Namen und die aktuellen Parameter: Modulname (Aktualparameter) Im obigen Falle z. B. mit Ergebnis := Wert*Sinus(alfa); Ein Algorithmus, der aus mehreren Moduln besteht, nennt man modular. Eine Schnittstelle zwischen einem Modul und der aufrufenden Einheit besteht in zweifacher Hinsicht: (1) aus der Schnittstelle, die sich aus den Übergabeparametern ergibt, (2) aus der Schittstelle, die sich implizit aus den Vereinbarungen über den Modulrumpf ergeben. Das Arbeiten mit Moduln bietet wesentliche Vorteile: Moduln können problemlos in einen Entwurfsprozeß nach der Top-Down-Methode eingefügt werden. Ein Modul ist eine in sich geschlossene Komponente, die sowohl getrennt entwickelt als auch gestestet werden kann. Um einen Modul in einen Algorithmus einzufügen, muß man nur wissen, was der Modul leistet und nicht wie er funktioniert. Da bei der Nutzung eines Moduls nur dessen Wirkungen verstanden werden müssen, erleichtert dies das Vorgehen bei der Änderung des aufrufenden Algorithmus. Moduln können in Bibliotheken verwaltet werden. Einführung, Algorithmen und Datenstrukturen 22 2.6 Rekursion Wir folgen dem Konzept von [GOLI90] und erklären bereits an dieser Stelle das Prinzip der Rekursion. Der Grund liegt in der hohen Bedeutung dieser Technik. Der Grundansatz der Rekursion besteht in dem Selbstaufruf von Moduln, wobei die Eingabe für diesen Modul sich stetig vereinfacht und schließlich in einem Begrenzungsfall endet. Beispiel der Fakultätsberechnung n! = n (n-1)! Lösung ( in Pascal) als Iteration Rekursion function fakit (n:integer):integer; i:integer; begin fakit:= 1; for i:= 2 to n do fakit:= i*fakit; end; function fakrek (n:integer ):integer; begin if n>1 then fakrek:=n*fakrek(n-1) else fakrek:=1; end; Ein rekursiver Algorithmus ist demzufolge ein Algorithmus, der sich selbst aufruft. Rekursive Algorithmen sind oftmals kürzer und eleganter als iterative Algorithmen, wobei sich jeder rekursive Algorithmus auf einen iterativen Algorithmus zurückführen läßt. Ein bekanntes Beispiel, das leichter rekursiv als iterativ zu lösen ist, ist als Türme von Hanoi bekannt. Senke Quelle Arbeitsbereich 1 Abbildung 2.6-1: Türme von Hanoi Die Aufgabe besteht nun darin, die Scheiben auf einen der äußeren Türme zu legen, ohne daß zwischendurch eine größere Scheibe auf einer kleineren zu liegen kommt. Nennen wir den einen Turm Quelle, den zweiten Senke und den Hilfsturm Arbeitsbereich, so können wir den rekursiven Modul so formulieren: Modul Turmbewegung (n, Quelle, Senke, Arbeitsbereich) {Bewegt einen Turm mit n Scheiben von Quelle zu Senke und benutzt erforderlichenfalls Arbeitsbereich} Falls n=1 dann bewege Scheibe von der Quelle zur Senke sonst Turmbewegung (n-1, Quelle, Arbeitsbereich, Senke) Einführung, Algorithmen und Datenstrukturen 23 bewege 1 Scheibe von der Quelle zur Senke Turmbewegung (n-1, Arbeitsbereich, Senke, Quelle) Lösung in C++ nach [AWILLMS97] #include <iostream.h> void rekhanoi(int x, char a, char b, char c) { if(x==1) { cout << "Eine Scheibe von " << a << " nach " << c << " legen.\n"; return; } rekhanoi(x-1,a,c,b); rekhanoi(1,a,b,c); rekhanoi(x-1,b,a,c); } void hanoi(int x) { rekhanoi(x,'A','B','C'); } void main(void) { int x; cout <<"Wieviel Scheiben :"; cin >> x; hanoi(x); } Ergebnis Wieviel Scheiben :3 Eine Scheibe von A nach C legen. Eine Scheibe von A nach B legen. Eine Scheibe von C nach B legen. Eine Scheibe von A nach C legen. Eine Scheibe von B nach A legen. Eine Scheibe von B nach C legen. Eine Scheibe von A nach C legen. Die buddhistischen Mönche wollten einen Turm mit 64 Scheiben übertragen. Für einen Turm mit n Scheiben benötigt man 2n - 1 Ausführungen, bei n = 3 also 7 mal (siehe oben!). Die Mönche würden womöglich heute noch daran arbeiten. Einführung, Algorithmen und Datenstrukturen 24 2.7 Parallelität Alle bisherigen Algorithmenausführungen gingen davon aus, daß nur 1 Prozessor zur Bearbeitung zur Verfügung steht. Das bedeutet, daß alle Schritte in Folge, also sequentiell erledigt werden. Nun könnte man sich vorstellen, daß Computer mit mehreren Prozessoren ausgerüstet werden, die dann Aufgaben parallel bearbeiten. Vorausetzung ist, daß sich der ursprüngliche Algorithmus in Teilschritte zerlegen läßt.Der Vorteil dieser Algorithmen liegt im Zeitgewinn. Als Beispiel betrachten wir die Addition von n Zahlen. sequentieller Algoritmus Modul Summe-sequentiell(n) {Addiert eine Reihe von nZahlen} Setze Gesamtsumme auf 0 Beginne mit dem Anfang der Reihe Wiederhole n-mal Addiere Zahl zur Gesamtsumme Lies die nächste Zahl der Reihe Der Algorithmus durchläuft die Schleife n-mal, deshalb wird die Ausführungszeit auch proportional n sein. Einen parallelen Algorithmus kann man sich aus dem Abbildung 2-2 ableiten. 3 1 6 4 5 4 11 2 7 6 15 13 28 Abbildung 2.7-1: Paarweise Addition von Zahlen Man benötigt mindest n/2 Prozessoren, die eine Ausführungszeit benötigen, die log n proportional ist (log bedeutet Logarithmus zur Bais 2). Das Zeitverhalten sequentieller Algorithmus zum parallelen Algorithmus beträgt demzufolge n / log n. Bezeichnen wir einen Prozessor mit i, so kann der Algorithmus für den i-ten Prozessor geschrieben werden Modul Summe-parallel(i) {Summationsalgorithmus für den i-ten Prozessor} Wiederhole log n-mal Setze Zahl[i] auf Zahl[2i-1] + Zahl[2i] Die Addition erfolgt entsprechend nachfolgender Tabelle . Ursprüngliche Reihe Reihe nach 1. Addition 3 4 1 11 6 6 5 7 4 4 2 2 7 7 Einführung, Algorithmen und Datenstrukturen Reihe nach 2. Addition Reihe nach 3. Addition 25 15 28 13 13 6 6 7 7 4 4 2 2 7 7 Tabelle 2.7-1: Ausführung des Algorithmus zur paarweisen Addition Da die Ausgabe (das Ergebnis) jeder Stufe die Eingabe für die nachfolgende bildet, darf keine Stufe beginnen, bevor die vorhergehnde abgeschlossen ist. Da heißt, alle Prozessoren müssen mit der gleichen Geschwindigkeit arbeiten oder aufeinander warten. Der Ausdruck für dieses Prinzip lautet Synchronisation. Im obigen Beispiel ist die benötigte Gesamtzeit gleich jener, die ein Prozessor für die Lösung des Algoritmus benötigt, demnach proportional zu log n .Damit ist der parallele Algorithmus im Verhältnis n/log n schneller als der sequentielle. ( Übung: Erschließen Sie sich das Beispiel des parallelen Sortieralgorithmus nach Goldschlager/Lister, ab Seite 65!) Je nach Ausführung ( parallel oder sequentiell oder auch kombiniert) gibt es nun Algorithmen, die in ihrer Ausführungszeit n-, log n-, n log n- oder n²- proportional sind. Bei wenigen Prozeßschritten mag dies noch nicht so sehr ins Gewicht fallen, aber so verlangt die Sortierung von 100000 Elementen nach einem Algorithmus, der jedes Element sequentiell miteinander vergleicht, 100000 * 100000 = 1010 Zeiteinheiten. Geschwindigkeitssteigerung muß jedoch durch Kostensteigerung erkauft werden. Der beste bekannte parallele Sortieralgorithmus liefert eine n-fache Geschwindigkeitssteigerung auf Kosten einer n log n - fachen Steigerung der Prozessorenzahl. 2.8 Datenstrukturen Die bisherigen Ausführungen stellten die Kontrollstrukturen, d.h. die Strukturen zur Ablaufsteuerung, in den Mittelpunkt. Kontrollstrukturen regeln, in welcher Reihenfolge unter welchen Bedingungen die Algorithmen ausgeführt werden. Nun sind die Algorithmen im allgemeinen dazu da, Daten zu verarbeiten. Deshalb nun die Konzentration auf die Abbildung der Daten. Daten sind Informationsteile, die in bestimmten Beziehungen zueinander stehen. Nehmen wir ein Beispiel: Ein Tierarzt hat eine Kundendatei. Dort stehen Daten über das Tier und den Tierhalter. Hund 6 Paul Katze Förderstedt 2 Berger Magdeburg Abbildung 2.8-1: unstrukturierte Daten Man erkennt, daß diese Daten aus dem wahllosen Durcheinander in eine strukturierte Form überführt werden können. Dazu bildet man Datensätze( records!). In Beispielfall könnte man den Datensatz wie folgt aufbauen: Tierart Alter Tierhalter Adresse Hund 6 Katze Paul Förderstedt 2 Berger Magdeburg Einführung, Algorithmen und Datenstrukturen Sittich 26 10 Grundig Schönebeck Abbildung 2.8-2: strukturierte Daten Aus dieser Struktur heraus können noch weitere Beziehungen zwischen den Komponenten der Datensätze aufgebaut werden. So kann man alle Datensätze z. B.in einer Liste (Datei) zusammenfassen, um dann bei einem Suchvorgang feststellen zu können, ob die Person Kunde mit welchen Tieren ist. Ein typischer Algorithmus für eine sequentielle Listenbearbeitung ist dieser: Beginne mit dem Listenanfang Solange Listenende nichterreicht führe aus Bearbeite nächstes Element Bestimmte Listenformen finden immer wieder Verwendung, so daß die Informatiker ihnen einen besonderen Namen gaben. Vektor (array) ist eine Liste fester Länge , bei der jedes Element durch seine Position identifiziert wird. Das Feldelement wird über einen Index angesprochen. Warteschlange (queue) ist eine Liste variabler Länge, bei der Elemente stets an einem Ende angefügt und am anderen Ende entfernt werden. Mit dieser Datenstruktur kann das Prinzip „wer zuerst kommt, mahlt zuerst (fist in-first out)“ realisiert werden. Stapel (stack) ist eine Liste variabler Länge, bei der Elemente nur an einem Ende hinzugefügt oder entfernt werden (Last in-first out). Eine andere, häufig verwendete Struktur ist der Baum (tree). Ein Baum ist eine hierarchische Struktur. Er besteht aus Knoten (node), die in Zweigen (branch) geordnet werden , wobei der erste Knoten als Wurzel (root) und die untersten als Blätter (leaf) bezeichnet werden. Fahrrad Rahmen Sattel Lenker Tretlager Rad Vorderrad Zubehör Hinterrad Elektrik Bremse Felge Nabe Speiche Bereifung Abbildung 2.8-3: Baumstruktur Oftmals werden bei der Ausführung von Algorithmen Zwischendatenstrukturen eingeführt, um das gewünschte Ziel zu erreichen. Ein Beispiel ist die Sortierung einer Liste unter Verwendung der Zwischendatenstruktur Binärbaum (ein Baum, in dem von jedem Knoten höchstens 2 Zweige ausgehen!). Die Liste der Namen lautet: Jochen, Karin, Sepp, Franz, Bernd, Jim, Maria. Das zu erwartende Ergebnis hat dieses Aussehen in Baumform. Jochen Franz Karin Einführung, Algorithmen und Datenstrukturen Bernd 27 Jim Sepp Maria Abbildung 2.8-4: sortierter Binärbaum Warum ist dieser Baum sortiert? Die Erklärung liegt in der Vereinbarung, daß alle Daten die links vor einem Knoten angeordnet sind, vor dem Knoten liegen, die rechts angeordnet sind, jedoch hinter dem Knoten. Im konkreten Fall bedeutet das, Bernd liegt vor Franz, Franz vor Jim, alle 3 liegen vor Jochen, Karin liegt hinter Jochen, jedoch vor Sepp, wobei Maria vor Sepp liegt. Der Algorithmus der Sortierung hat zwei Schritte: Überführe die unsortierte Liste in einen sortierten Binärbaum Überführe den sortierten Baum in eine Ausgabeliste. Beide Anweisungen können getrennt behandelt werden. Modul Baum-bilden(L,B) {Macht aus der Liste einen Binärbaum.} Beginne mit dem Anfang von L Solange L nicht erschöpft ist führe aus Füge nächsten Namen in B ein Die letzte Anweisung kann rekursiv aufgelöst werde. Modul Namen-einfügen (Name,B) {Fügt Name in sortierten Binärbaum ein.} Falls B leer ist dann erzeuge einen neuen Teilbaum mit Name als Wurzel sonst falls Name vor dem Namen in Wurzel von B liegt dann Namen-einfügen (Name, linker Teilbaum von B) sonst Namen-einfügen (Name, rechter Teilbaum von B) Der Ausgabemodul wird ebenfalls rekursiv gelöst mit Modul Baum -ausgeben (B) {Gibt alle Knoten eines Binärbaumes B nach der Regel „links vor rechts“ aus .} Falls B nicht leer ist dann Baum-ausgeben (linker Teilbaum von B) Schreibe Namen in der Wurzel von B nieder Baum-ausgeben (rechter Teilbaum von B) Der komplette Sortieralgorithmus hat dann diese Form: Modul Sortiere (L) {Sortiert Liste in alphabetischer Ordnung.} Beginne mit einem leeren Baum, der mit B gekennzeichnet ist Baum-bilden (L,B) Baum-ausgeben (B) Einführung, Algorithmen und Datenstrukturen 28 3 Theorie der Algorithmen In den Kapiteln 1 und 2 haben wir uns bereits mit Interpretationen und Anwendungen von Algorithmen bekanntgemacht. Im Kapitel 3 soll nun der Versuch gemäß Goldschlager/Lister unternommen werden, zu untersuchen, durch welche allgemeinen Eigenschaften sich Algorithmen auszeichnen. Auf tiefschürfende mathematische Beweise wird zum großen Teil verzichtet. Dies bleibt den Spezialvorlesungen vorbehalten [Dassow, Smid]. Es wird also vom Leser dieser Ausführungen erwartet, daß er den Standpunkt der Autoren folgt, um den roten Faden zu behalten. Literatur: Dassow: Theoretische Informatik, Vorlesung Smid: Effiziente Algorithmen, Vorlesung 3.1 Berechenbarkeit Das tägliche Leben schreibt viele Geschichten, die durch Prozesse bestimmt werden, die ihreseits wieder der verschiedensten Algorithmen, d.h. Lösungsschritte, bedürfen, um überhaupt ausgeführt werden zu können. Meistens benötigen wir dazu keine Computer. Wenn wir jedoch auf einen Computer zurückgreifen, dann wissen wir, daß die Algorithmen computergerecht vorbereitet werden müssen. Oftmals wird der Computer als Allheilmittel verstanden, der Probleme aller Art lösen hilft. Spätestens an dieser Stelle müssen wir uns fragen, ob es Probleme gibt, für die es keinen sinnvollen Algorithmus gibt und die damit auch nicht auf dem Computer ausführbar sind. Mit dieser Frage beschäftigt sich die Aufgabe zur Festellung der Berechenbarkeit von Algorithmen. Überraschenderweise ist die Zahl der mit dem Computer lösbaren Aufgaben endlich im Vergleich mit den unendlich vielen nichtlösbaren Aufgaben. Das Hilbertsche Problem Es gab in der Geschichte der Berechenbarkeit genügend Versuche, diese Lücke der Unlösbarkeit zu schließen. Ein berühmter Vertreter dieser Schule war der Mathematiker David Hilbert (1862-1943) , der im Prinzip diesen Grundsatz verfolgte: „Da ist ein Problem, suche die Lösung. Du kannst sie durch reines Denken finden; denn in der Mathematik gibt es kein Ignorabismus.“ Das Ziel Hilberts bestand also darin, ein mathematisches System zu ersinnen, in dem alle Probleme präzise als Aussagen formulierbar sind, die entweder war oder falsch sind. (Hilbertsches Entscheidungsproblem!) 1931 widerlegte Kurt Gödel diese Aussage durch die Veröffentlichung seines Unvollständigkeitstheorems, das u.a. besagt, daß es keinen Algorithmus gibt, der als Eingabe irgendeine Aussage über die natürlichen Zahlen erhält und der eine Ausgabe erzeugt, die angibt, ob diese Aussage wahr oder falsch ist. Auch die Mathematiker Alonso Church, Stephen Kleene, Emil Prost, Alan Turing fanden Probleme, die keine algorithmische Lösung besitzen. Die Church-Turing-These Um den Beweis führen zu können, daß es für bestimmte Aufgaben keine Lösung - keinen Algorithmus - gibt, muß erst einmal exakt festgelegt werden, was wir unter einem Einführung, Algorithmen und Datenstrukturen 29 Algorithmus zu verstehen haben. Dazu gab es viele Versuche: Gödel Algorithmus als Folge von Regeln zur Bildung komplizierter mathematischer Funktionen aus einfacheren mathematischen Funktionen Church Verwendete einen Formalismus, der als Lambda-Kalkül bekannt wurde. Turing Erklärte den Algorithmusbegriff mit einer hypothetischen Maschine, der Turingmaschine. Man stellte sobald fest, daß alle plausiblen Erklärungen gleichwertig waren und formulierte die nachfolgenden allgemeingültigen Aussagen: Alle vernünftigen Definitionen von „Algorithmus“ , soweit sie bekannt sind, sind gleichwertig und gleichbedeutend. (2) Jede vernünftige Definition von „Algorithmus“, die jemals irgendwer aufgestellt hat, ist gleichwertig und gleichbedeutend zu denen, die wir kennen. Diese beiden Annahmen sind als Church-Turing-These bekannt. Modern ausgedrückt können wir alles, was von einem Computer ausführbar ist, als „Algorithmus“ definieren. Weil die Algorithmen unabhängig von dem Computer sind, auf dem sie ausgeführt werden, haben sie die Eigenschaft der Universalität. (1) Das Halteproblem Eines dieser nichtberechenbaren Probleme ist das Halteproblem. Das Interesse der Informatik an diesem Problem besteht darin, einen Algorithmus zu finden, der feststellt, ob ein Programm ordnungsgemäß endet oder in eine nichtgewollte Endlosschleife geht. Die Lösung wäre ein Algorithmus, der bei gegebenen Programm P und seinen Eingabedaten D ermittelt, ob P jemals hält, falls es mit den Eingabedaten D ausgeführt wird. Praktisch könnte man diese Frage lösen, indem eine Zeitschranke mit einprogrammiert wird. Was geschieht aber bei jenen Lösungen, wo man die Zeitschranke kaum oder gar nicht abschätzen kann? Dies ist sicherlich bei vielen technischen Simulationen zu erwarten. Leider ist dieses Problem nicht lösbar, was durch die nachfolgenden Ausführungen beschrieben werden soll. Wir nehmen an, es gibt einen Algorithmus zur Lösung des Halteproblems, genannt StoppTester. Dieser hat die Eingaben P und D. Der Tester gibt die Antwort „OK“ aus, wenn P ausgeführt mit D hält, ansonsten „NOK“. P D P Stoppt P(D)? Ausgabe OK NOK und anhalten Stoppt P(P)? Ausgabe NOK und anhalten a) Ablauf Stopp-Tester Ausgabe OK Ausgabe und anhalten und anhalten b) Ablauf Stopp-Tester-neu Abbildung 3.1-1: Ablauf des Algorithmus STOPP-Tester Stopp-Tester unterscheidet sich von Stopp-Tester-neu nur dadurch, daß der Einführung, Algorithmen und Datenstrukturen 30 Eingabedatenstrom D durch das Programm P selbst ersetzt wird. Dies ist nicht ungewöhnlich, da ein Programm selbst nur aus Zeichen besteht und Zeichen können bekannterweise als Eingabedaten dienen. Der Modul für Stopp-Tester-neu lautet nun: Modul Stopp-Tester-neu (P) {Prüft, ob das Programm P endet, falls es mit den Daten P ausgeführt wird.} Stopp-Tester (P,P) Mit diesen Annahmen gelingt nun der Aufbau des folgenden Algorithmus Spaßig, der nur eine Eingabe P hat und voraussetzt, daß Stopp-Tester und damit auch Stopp-Tester-neu existieren. Modul Spaßig (P) {Dieser Modul setzt die Existenz von Stopp-Tester voraus.} Falls Stopp-Tester-neu (P) NOK ausgibt dann stoppe sonst schleife endlos P Spaßig Stoppt Spaßig (P) ? ja nein Stoppt Spaßig (Spaßig)? ja nein anhalten anhalten Endlosschleife Endlosschleife a) Ablauf von Spaßig (P) b) Ablauf von Spaßig (Spaßig) Abbildung 3.1-2: Algorithmus Spaßig Schauen wir uns die Wirkung der Ausführung von Spaßig (Spaßig) an, so entdecken wir einen Widerspruch, denn einerseits bildet der Algorithmus eine Endlosschleife, wenn Spaßig (Spaßig ) stoppt und andererseits, wenn Spaßig (Spaßig) eine Endlosschleife bildet, dann stoppt der Algorithmus. Oder zusammenfassend: Die Ausführung von Spaßig (Spaßig) kann weder stoppen noch endlos schleifen. Dieser Widerspruch kann nur aufgelöst werden, wenn Spaßig (Spaßig) als Algorithmus nicht existiert. Das Bedeutet aber wiederum, daß Stopp-Tester nicht existieren kann, womit das Halteproblem keine Lösung hat. Der obige Beweis hat diese Schritte: (1) Man nehme an, es kann ein Programm Stopp-Tester geschrieben werden. (2) Man benutze es, um ein anderes Programm Spaßig zu bilden (mittels eines Zwischenprogramms Stopp-Tester-neu). (3) Man zeige, daß das Programm Spaßig eine undenkbare Eigenschaft hat (es kann weder halten noch endlos sein). (4) Man schlußfolgere, daß Schritt (1) falsch ist. Wir haben mit diesem Nachweis gezeigt, daß es generell kein Programm geben kann, das das Einführung, Algorithmen und Datenstrukturen 31 Halteproblem löst. Trotz alledem gibt es natürlich viele Programme, wo wir von vornherein absehen können, daß diese stoppen. Aber es gibt mindestens ebenso viele, wo dies nicht gelingt. Ein bekanntes Beispiel ist die Lösung des Fermatschen Satzes. Dieser Satz behauptet, daß es keine positive Ganzzahlen a, b und c gibt, so daß die Gleichung an + bn = cn mit n > 2 erfüllt wird. Der Leser wird aufgefordert, einen Algorithmus zu schreiben, der diese Aufgabe löst! Weitere nicht-berechenbare Probleme sind das Totalitätsproblem, das die Frage nach dem Halten eines Programmes bei allen Eingabedaten stellt, sowie das Äquivalenzproblem, das die Fragestellung aufwirft, ob es einen Algorithmus gibt, der 2 Programme miteinander vergleicht, um festzustellen, ob diese Programme die gleiche Ausgabe liefern bei gleichen Eingaben. Wir wissen (Ohne an dieser Stelle den Beweis anzutreten!), daß diese Algorithmen leider nicht existieren, obwohl wir beim Entwurf unserer Programme gern wüßten, welchen Datenstrom sie verarbeiten können oder wie sicher eine Transformation eines Programmes von der einen Sprache in eine andere erfolgte. partielle Berechenbarkeit Wir haben bisher einige nicht-berechenbare Probleme besprochen (Halteproblem, Totalitätsproblem, Äquivalenzproblem). Einige davon sind weniger berechenbar als andere. Gemeint ist mit dieser Aussage die Tatsache, daß es für manche Aufgaben Algorithmen gibt, die zumindest eine Teillösung, eine partielle Lösung liefern. Ein Beispiel dafür ist das Halteproblem. Der Algorithmus gibt „JA“ aus, wenn P(D) stoppt, und geht in eine Endlosschleife, wenn P(D) nicht stoppt. Eingabe Eingabe Algorithmus Algorithmus JA NEIN JA Endlosschleife bei Nein a) berechenbare Aufgabenstellung b) partiell-berechenbare Aufgabenstellung Abbildung 3.1-3: partielle Berechenbarkeit Fassen wir zusammen: Probleme können berechenbar, partiell-berechenbar oder nicht-berechenbar sein. berechenbar Es gibt einen Algorithmus, der für jede Eingabe korrekt mit „JA“ oder „NEIN“ antwortet. partiell-berechenbarEs gibt einen Algorithmus, der mit „JA“ antwortet, wenn die Antwort korrekt ist. nicht-berechenbar Es gibt keinen Algorithmus, der immer korrekt mit „JA“ oder „NEIN“ Einführung, Algorithmen und Datenstrukturen 32 antwortet. Oder anders ausgedrückt: Für jedes berechenbare oder partiell-berechnbare Problem gibt es eine Beweismethode, falls die Antwort „JA“ vorliegt und stimmt. Bei nicht einmal partiellberechenbaren Problemen gelingt dieser Beweis nicht. Rekursionssatz Im Kapitel 2 haben wir bereits über rekursive Funktionen gesprochen. Wir wollen jetzt über Algorithmen diskutieren, die über sich selbst sprechen. Dieser Sachverhalt wird durch den Rekursionssatz beschrieben. Der Rekursionssatz besagt, daß Algorithmen eine Kopie von sich selbst bearbeiten können. Das bedeutet, daß es für jeden Algorithmus, der eine beliebige Zeichenfolge D bearbeitet, auch einen Algorithmus gibt, der die Zeichenfolge des Algorithmus selbst bearbeitet. Diese Art von Algorithmen beziehen sich auf sich selbst.(Weiter siehe Goldschlager/Lister, S.91ff.) 3.2 Komplexität Im Kapitel 3.2 standen die Fragen der generellen Lösbarkeit von Problemen im Mittelpunkt. Nun ist es aber genauso interessant zu wissen, welche Ressourcen (Betriebsmittel) einzusetzen sind, um die lösbaren Aufgaben zu bewältigen. Mit dieser Fragestellung setzt sich die Komplexitätstheorie auseinander. Wir wissen bereits, daß es unter allen möglichen Aufgabenstellungen nur eine begrenzte Menge gibt, die auch algorithmisch lösbar ist. Davon ist nun wiederum nur eine Untermenge aus der Sicht der Betriebsmittelanforderungen ausführbar. alle Aufgabenstellungen berechenbare Augabenstellungen durchführbare Aufgabenstellungen Abbildung 3.2-1: Die Herausforderung an die Computerwelt Als Computerressourcen interessieren im wesentlichen die Geräte (Hardware), Speicher (Memory) und die Rechenzeit (Time). Geräte umfassen alle Einheiten, die zur Ausführung eines Algorithmus benötigt werden. Speicher umschreibt den vom Algorithmus angeforderten Speicherplatz. Und unter Zeit versteht man die Dauer der Ausführung des Algorithmus vom Start bis zum Ende. Vielleicht ist es in der heutigen Zeit, wo Rechner mit hoher Taktfrequenz, großen Arbeitsspeichern und mehreren Prozessoren zur Verfügung stehen, gar nicht so wichtig, die Frage der Komplexität in den Vordergrund zu stellen. Doch dies ist ein Trugschluß, denn mit neuen Betriebsmitteln kann man auch die Menge der bisher kaum ausführbaren Aufgabenstellungen erweitern. Deshalb ist es nach wie vor eine erklärte Zielstellung, Algoritmen mit möglichst geringem Betriebsmittelverbrauch zu entwickeln. Ein Beispiel: Wir wollen zwei n-stellige Zahlen miteinander multiplizieren. 1984 x 6713 11904 13888 1984 5952 13318592 Einführung, Algorithmen und Datenstrukturen 33 Abbildung 3.2-2: Standardalgorithmus zur Multiplikation Dieser Algorithmus, der üblicher Weise auch in der Schule vermittelt wird, benötigt diese Schritte zur Ausführung: Es müssen zunächst n Zwischenrechnungen zur Bestimmung der Zwischenzeilen durchgeführt werden. Anschließend sind n x n (oder n²) Zeiteinheiten erforderlich, um die Addition der Zwischenzeilen durchzuführen. Die Ausführungszeit des gesamten Algorithmus ist deshalb proportional zu n². Es geht aber auch schneller, wie in Abbildung 3-6 festgehalten. A 19 B 84 x C 67 D 13 AC = 19x67 = 1273 (A+B)(C+D)-AC-BD=(103x80)-1273-1092 = 5875 BD = 84x13 = 1092 13318592 Abbildung 3.2-3: Ein n 1,59-Multiplikationsalgorithmus An diesen beiden Beispielen erkennt man 2 Sachverhalte: 1.Verschiedene Algorithmen haben einen verschiedene Betriebsmittelbedarf. Das Auffinden von Algorithmen mit dem geringsten Betriebsmittelbedarf ist eine große Herausforderung. Dabei konkurrieren die Betriebsmittelarten miteinander. Weniger Zeit wird oftmals durch mehr Hardware (Prozessoren) erreicht. 2. Der Betriebsmittelbedarf hängt von der Menge der Eingabedaten ab. Im allgemeinen kann man bei einer Eingabe von n Zeichen den Betriebsmittelbedarf als Funktion von n auffassen. (z.B. n , 3n² + 5n, 2nlogn + n +17) In diesen Funktionen gibt es immer einen Ausdruck, der die anderen dominiert und somit das asymptotische Verhalten der Funktion beschreibt. Genau dieses Verhalten wird zur Beschreibung des Betriebsmittelbedarfs herangezogen. Die nachfolgende Tabelle 3.-1 vermittelt eine Vorstellung über die Durchführbarkeit von Algorithmen. Größe n der Eingabedaten log2 n Mikrosekunden n Mikrosekunden n² Mikrosekunden 2n Mikrosekunden 10 0.000003 Sekunden 0.00001 Sekunden 0.0001 Sekunden 0.001 Sekunden 100 0.000007 Sekunden 0.0001 Sekunden 0.01 Sekunden 1014 Jahrhunderte 1000 0.00001 Sekunden 0.001 Sekunden 1 Sekunde astronomisch 10000 0.000013 Sekunden 0.01 Sekunden 1.7 Minuten astronomisch 100000 0.000017 Sekunden 0.1 Sekunden 2.8 Stunden astronomisch Tabelle 3.2-1: Ausführungszeiten für Algorithmen Algorithmen mit dem asymptotischen Verhalten cn , wie 2n, heißen exponentielle Algorithmen. Einführung, Algorithmen und Datenstrukturen 34 Diese sind praktisch kaum durchführbar. Algorithmen mit dem Verhalten nc , wie n oder n² oder n³, heißen polynomiale Algorithmen. Man sieht, daß Algorithmen, die der Zahl der Eingabedaten direkt proportional sind, ein brauchbares Zeitverhalten aufweisen. Dieses Verhalten kann auf sequentiellen Rechnern erzielt werden. Algorithmen, die sich wie log n verhalten, können nur auf parallelen Rechnern ausgeführt werden. Die Komplexitätsproblematik versucht auch Antworten darauf zu geben, wieviel Betriebsmittel (z.B. Zeit) im schlechtesten Fall benötigt werden (worst-case complexity), wieviel Betriebsmittel im Durchschnitt (average-case complexity), wie groß die Standardabweichung ist (standard deviation). Neben der Komplexität von Algorithmen spricht man noch von der Problemkomplexität. Darunter versteht man die Komplexität des besten Algorithmus, der das Problem löst. Der Betriebsmittelbedarf für den besten, das Problem lösenden Algorithmus nennt man obere Grenze (upper bound), der mindestens notwendige Betriebsmittelbedarf zur Ausführung eines Algorithmus wird als untere Grenze (lower bound) bezeichnet. Teile und herrsche Dieses Prinzip beinhaltet den Ansatz, komplexe Probleme durch die Aufgliederung des Gesamtproblems in kleinere Einheiten überschaubarer zu machen. Ein Beispiel dafür ist das Sortieren einer Liste, indem erst die eine Hälfte bearbeitet wird, anschließend die zweite, um die sortierten Teile dann zu mischen. Modul Sortiere (Liste) {Sortiert eine Liste von n Namen alphabetisch.} Falls n>1 dann Sortiere (erste Listenhälfte) Sortiere (zweite Listenhälfte) Mische die zwei Hälften zusammen Nun stellt sich die Frage nach dem Zeitverbrauch dieses Algorithmus. Wie lange benötigt der Algorithmus, um n Namen zu sortieren? Die Zeit zur Sortierung von n Namen sei T(n). Für die erste Hälfte benötigt man demzufolge die Zeit T(n/2), für die zweite Hälfte ebenfalls T(n/2). Weiterhin muß das Mischen durchgeführt werden. Dafür ist einzusehen, daß die Zeit proportional zu n, also cn sein wird. Insgesamt kann man konstatieren: T(n) = 2 T(n/2) + cn. Diese Art von Gleichungen nennt man rekursive Relationen. Es sei vorweggenommen, daß die Lösung dieser Relation das Ergebnis T(n) = cn log n +kn liefert. Das asymptotische Verhalten des Misch-Sortieralgorithmus ist proportional zu n log n. In gleicher Art kann man die rekursive Relation für den Algorithmus zur Multiplikation ganzer Zahlen nach Abbildung 3.6 mit T(n) = 3T(n/2) + cn mit der Lösung Einführung, Algorithmen und Datenstrukturen 35 T(n) = (2c + k)nlog 3 - 2cn ableiten. Damit ist dieser Algorithmus proportional nlog 3 , was in etwa n1.59 entspricht. Durchführbare und undurchführbare Algorithmen lassen sich näherungsweise aus ihrem Zeitverhalten schlußfolgern. Wir halten fest, daß Algorithmen mit polynomialem Betriebsmittelbedarf durchführbar sind. Diese Schlußfolgerung soll maschinenunabhängig sein, denn wir sagen, daß alle denkbaren sequentiellen Computer ähnliche polynomiale Ausführungszeiten haben (These der sequentiellen Berechenbarkeit, sequential computation thesis!). Undurchführbare Aufgaben Nun haben wir ein Merkmal der Durchführbarkeit gerade fixiert, so müssen wir schon wieder Einschränkungen machen. Einige Aufgaben sind nicht ausführbar, weil es eben nicht gelingt, einen Algorithmus mit polynomialem Zeitbedarf zu finden. Ein Beispiel ist die Verallgemeinerung des Schachspiels für ein n x n -Brett. Hierfür wurde ein exponentieller Zeitbedarf bewiesen. Aber es ist noch kein ausführbarer polynomialer Ansatz gefunden worden. Weitere Aufgaben sind unter den Namen Kastenproblem, Problem des Handelsreisenden, Stundenplanproblem bekannt (siehe Goldschlager/Lister, S.103 ff). Eine Möglichkeit, dieses Problem doch einer Lösung zuzuführen, besteht in der Nutzung sogenannter fehlerhafter Algorithmen. Diese Algorithmenart, bei der Fehler einkalkuliert werden, die aber sehr selten auftreten, nennt probalistische Algorithmen. NP-Vollständig Wir mußten feststellen, daß für viele Probleme durchführbare, polynomiale Algorithmen fehlen. Aber oftmals gibt es eine Lösung für einen Beispielfall. 3t 3t 4t 8t n=6 Kästen 5t 5t T= 2 Lastzüge max. Ladung G= 14 Tonnen Einführung, Algorithmen und Datenstrukturen 3 36 3 4 8 5 5 5 5 Abbildung 3.2-4: Kastenproblem mit einer Lösung Anhand des Beispielfalles kann man nun verifizieren, daß die Lösung korrekt ist. Für diese Aufgabenstellungen gibt es einen Algorithmus, der für einen Beispielfall und eine vorgeschlagene Lösung mit polynomialem Zeitbedarf spricht. Die Menge der Aufgabenstellungen, die einen schnellen Verifikationsalgorithmus besitzen, heißt NP. Alle durchführbaren Aufgaben gehören zur Menge NP. In dieser Menge gibt es Aufgaben, deren Lösung zu den schwersten gehört. Solche Aufgabenstellungen, für die nach wie vor ein polynomialer Ansatz gesucht wird, nennt man NP-vollständig. Zusammengefaßt: Durchführbare Aufgabenstellungen haben einen schnellen Algorithmus für ihre Lösung. Aufgabenstellungen in der Menge NP können einen solchen haben oder auch nicht, aber wenigstens sind die vorgeschlagenen Lösungen leicht verifizierbar. NPvollständige Aufgaben sind die schwersten in der Menge NP und es wird angenommen, daß es für sie keine schnellen Lösungen gibt. Parallele Computer In üblicher Weise konzentrierten sich die Überlegungen zur Kompexitätstheorie auf sequentiellen Zeit- und Speicherbedarf. Heute gibt es Rechner, die mehrere Prozessoren vereinigen. Damit kann die Frage nach einer parallelen Bearbeitung und somit nach einem parallelen Zeitbedarf gestellt werden. Die Prozessoren können hierbei sternförmig um einen Speicher oder als Netz angeordnet sein. Diese Parallelcomputer zeichnen sich durch 2 Merkmale aus: - Sie bestehen aus einer Anzahl von Prozessoren mit entsprechenden Speichereinheiten und - sie arbeiten synchron, d.h. alle Prozessoren führen ihre Berechnungen Schritt für Schritt im Gleichklang durch. Man kann also mit parallelen Computern eine höhere Rechengeschwindigkeit erzielen. Verringerter Zeitbedarf und erhöhte Prozessoranzahl stehen dabei in einem Wechselverhältnis. Ein Beispiel ist die Addition von n Zahlen. Eine sequentielle Maschine benötigt Zeit, die proportional zu n ist, eine parallele Maschine dagegen Zeit proportional zu log n bei einem Aufwand von n Prozessoren. 3.3 Korrektheit Berechenbarkeit und Komplexität sind interessante Aspekte über Algorithmen. Was können wir aber nun über die Korrektheit entworfener Algorithmen sagen, wie gelingt es dieselbe nachzuweisen? Einführung, Algorithmen und Datenstrukturen 37 Wenden wir uns im folgenden Kapitel diesem Aspekt zu. Fehler Leider müssen wir feststellen, daß viele der auch heute noch benutzten Programme Fehler enthalten, die sich in den verschiedensten Stufen des Lebenszyklusses (von der Entwicklung , über die Nutzung und Wartung ) eines solchen Programmes bemerkbar machen. Oftmals gibt man sich damit zufrieden, daß die Programme das erwartete Ergebnisspektrum liefern, ohne alle Eventualitäten abzuchecken. Die Fehlersuche und -korrektur wird als Debugging bezeichnet. Vorgehensweisen zur Herstellung von nahezu fehlerfreien Programmen unterteilen sich in 2 Kategorien: Testen (testing) und Beweisen (proving). Testen bedeutet, ein Programm mit einer bestimmten Datenmenge ausführen und den Ergebnisdatenstrom bewerten. Die Betonung liegt hierbei darauf, daß nicht alle möglichen Testdaten erzeugt werden und als Eingabedatenstrom in das Programm übergeben werden, sondern daß ein interessierender Teil von Testdaten überprüft wird. Wie wir bereits feststellen konnten, gibt es keinen Algorithmus, der für ein beliebiges Programm einen Testdatenstrom erzeugt. Korrektheit beweisen bedeutet dagegen, die Richtigkeit eines Programmes für alle zugelassenen Eingabedaten zu beweisen. Die Korrektheit beinhaltet demzufolge eine Aussage über das Gesamtverhalten eines Programmes hinsichtlich der Ausführung mit allen möglich Eingabedaten. Es versteht sich, daß die Beweisführung komplizierter und aufwendiger ist als ein Testen mit bestimmtem Testrahmen. Daher muß der Aufwand zur Beweisführung ( Beweistiefe) und der zu erwartende Sicherheitsgrad im Einklang stehen. Programme zur Steuerung „lebenswichtiger“ Prozesse bedürfen eines höheren Aufwandes als andere Prozesse. Der Korrektheitsnachweis kann nun in einem informalen Beweis z. B. als entwurfsbegleitende Dokumentation geführt werden oder man erstellt einen meist aufwendigeren formalen Beweis. Algorithmen können nur als korrekt oder unkorrekt bezeichnet werden, wenn man sie in Beziehung zu dem Zweck des Algorithmus setzt. Dies wird allgemein als Spezifikation (specification) bezeichnet. Leider gehört der Korrektheitsnachweis eines Programmes hinsichtlich seiner Spezifikation zu den nichtberechenbaren Aufgaben. Korrektheit bedeutet demzufolge, sich ein eindeutiges Verständnis über die in Programmen abgelegten Algorithmen bilden zu können. Im Zusammenhang mit Korrektheit sind weiterhin die Begriffe partielle Korrektheit, Terminiertheit und Ausführbarkeit zu klären. Induktion Eine Möglichkeit, die Korrektheit von Algorithmen festzustellen, ist das Verfahren der Induktion. Der Grundgedanke dabei ist jener, die Richtigkeit einer Behauptung für einen besonderen Fall nachzuweisen, anschließend den Beweis auf weitere Fälle auszudehnen, um dann endlich darauf schließen zu können, daß die Behauptung für alle Fälle gilt. Die Phasen eines solchen Beweises sind: - Induktionsbehauptung - Induktionsanfang - Induktionsschritt - Induktinsschluß. Nehmen wir das Beispiel einer Potenzbildung 2x [Goldschloger/Lister, S. 115 ff] Einführung, Algorithmen und Datenstrukturen 38 Modul Exponentiere (x) {Gibt 2x aus, wobei x als nicht-negative ganze Zahl angenommen wird.} Übertrage 1 nach SUMME Wiederhole x-mal Übertrage SUMME + SUMME nach SUMME ***{Wenn diese Stelle zum n-ten Male erreicht wird, dann ist SUMME gleich 2n .} Gib SUMME aus Dadurch daß in den Algorithmus bereits erklärende Kommentare eingefügt wurden und damit das Verständnis für den Algorithmus erhöht wurde, kann man dieses Konstrukt quasi als informalen Beweis deuten. Der induktive Beweis sieht nun wie folgt aus: Induktionsbehauptung Mit dem Erreichen von *** zum n-ten Male ist SUMME=2n Induktionsanfang Wenn n=1, dann wird 1 + 1 nach SUMME übertragen, die somit den Wert 21 annimmt. Induktionsschritt Angenommen, die Induktionsbehauptung ist wahr für einen bestimmten Wert von n. Die Stelle *** wurde zum (n + 1)-ten Male erreicht. SUMME enthält dann den Wert der Addition von SUMME + SUMME mit den vorhergehenden Werten. Der vorhergehende Wert ist laut Behauptung 2n . Somit ist der neue Wert von SUMME nun 2n + 2n = 2n + 1 . Die Induktionsbehauptung gilt somit auch für n + 1. Induktionsschluß Aus Induktionsanfang und -schritt folgt, daß die Induktionsbehauptung für alle Werte n 1 gilt. Die Führung von Korrektheitsbeweisen für umfangreiche Algorithmen gestaltet sich schwierig, da kaum eine Person allein das notwendige Verständnis für den gesamten Algorithmus im einzelnen beherrschen kann. Man bedient sich in diesem Fall der Modularisierung und versucht den Korrektheitsbeweis für jeden Modul zu führen. Die Spezifikation eines jeden Moduls besteht aus 2 Teilen: - aus der Spezifikation der Eingaben, mit dem der Modul arbeiten soll und - und der erwarteten Wirkung, d.h. aus den Ergebnissen, die der Modul liefern soll. Diese beiden Teile nennt man Prä- und Postkonditionen oder auch Zusicherungen . Beim Beweis modularisierter Algorithmen kommt es demzufolge darauf an, die Zusicherungen zu bewiesen. Bisher hatten wir uns mit dem Beweis von Algorithmen auseinandergesetzt, die irgendein Ergebnis produzieren - somit wurde eine partielle Korrektheit nachgewiesen. Will man jedoch wissen, ob der Algorithmus in jedem Fall zu einem Ergebnis führt, so muß der Beweis einer totalen Korrektheit geführt werden. Es kommt hierbei darauf an, den Algorithmus in seiner zeitlichen Entwicklung - in seiner Terminiertheit zu verfolgen. D.h. , man muß sich ein Bild über die Veränderungen z. B. in einer Schleife machen. Wir wollen diesen Aspekt hier nicht weiter verfolgen. Der Nachweis der Korrektheit eines Algorithmus ist eine wichtige Information in Hinblick auf Einführung, Algorithmen und Datenstrukturen 39 seine Realisierung. Ebenso notwendig ist, daß man sich ein Bild darüber verschafft, ob dieser Algorithmus auch in dem zur Verfügung stehenden technischen Umfeld ausführbar ist. Dies beinhaltet die Frage nach den erforderlichen Betriebsmitteln bzw. nach den Betriebsmittelschranken. Der Einsatz der Betriebsmittel ist abhängig von Zeitverhalten des Algorithmus. Letzteres kann durch Induktion ermittelt werden. 3.4 Nichtprozedurale Algorithmen Unser Verständnis für den Algorithmusbegriff bestand bisher darin, daß wir unter Algorithmus eine Reihung von Grundoperationen verstehen, die durch Folge, Auswahl und Wiederholung gesteuert werden. Solche Algorithmen sind prozedural. Zwei Merkmale charakterisieren prozedurale Algorithmen: 1. Festlegung, welche Operationen der Prozessor auszuführen hat. 2. Festlegung der Reihenfolge, in der die Operationen auszuführen sind. Vorteil dieser Art von Algorithmen ist der, daß sie genau der Arbeitsweise üblicher Computer entsprechen. Nachteile ergeben sich aus dem erhöhten Steueraufwand zur Ausführung der Operationen (Selektion, Iteration) und aus der mathematischen Unhandlichkeit von Programmen, die aus prozeduralen Algorithmen zusammengesetzt sind. Funktionale Pogrammierung (auf Church`s Lambda-Kalkül aufbauend) und die logische Programmierung (auf dem Prädikaten-Kalkül aufbauend) sind alternative Programmierstile. funktionale Programmierung( z. B. LISP) Prinzipiell wird der Algorithmus als Funktion aufgefaßt, die Eingabedaten zu Ausgabedaten verarbeitet. L Liste Summe Y Zahl Y=Summe(L) Abbildung 3.4-1: funktionaler Algorithmus zum Aufsummieren einer Liste Wir wollen diese Funktion Summe entwickeln. Zunächst definieren wir L [1 2 3] Liste von Elementen Kopf(L) = 1 erstes Element der Liste Rumpf(L) = [2 3] Rest der Liste Summe (L) = wenn L = [ ]dann 0 sonst addiere (Kopf(L), Summe(Rumpf(L)) wobei addiere definiert ist als addier (x,y) = x + y. Man beachte, daß die Funktion Summe rekursiv gebraucht wird. In der funktionalen Programmierung übernimmt die Rekursion quasi die Rolle der Iteration in der prozeduralen Programmierung. Summe [1 2 3] wird wie folgt aufgelöst. Summe [1 2 3] = = = = addiere(1, Summe([2 3]) addiere(1, addiere(2,Summe ([3])) addiere(1,addiere(2,addiere(3,Summe([ ])))) addiere(1,addiere(2,addiere(3,0))) Einführung, Algorithmen und Datenstrukturen 40 = addiere(1,addiere(2,3)) = addiere(1,5) = 6 Ähnlich kann ein Funktion zur Multilikation der Listenelemente gebildet werden mit Produkt (L) = wenn L = [ ] dann 1 sonst multipliziere (Kopf(L),Produkt(Rumpf(L))), wobei multipliziere (x,y) = x * v ist. Aus den Gemeinsamkeiten beider Ansätze kann man eine allgemeine Funktion, die auf einer Liste arbeitet, formulieren: reduziere(Op,Basis,L) = wenn L = [ ] dann Basis sonst Op(Kopf(L), reduziere(Op,Basis,Kopf(L))) Damit können wir schreiben Summe = reduziere(addiere,0,L) oder auch Summe = reduziere(addiere,0) Produkt = reduziere(multipliziere,1) Größtes = reduziere(max,0) (sucht größtes Element in L) mit max(x,y) = wenn x<y dann x sonst y. An diesen Beispielen erkennen wir, daß Funktionen wiederum Funktionen erzeugen. Hierin liegt die Stärke der funktionalen Programmierung. Die Besonderheit der Ausführung von funktionalen Programmen besteht nun darin, daß sich der Programmierer nicht um die Ausführungsfolge der Funktionen kümmern muß, da dies vom Prozessor übernommen wird, indem dieser die Strategie verfolgt, nach vereinfachenden Argumenten und anzuwendenden Funktionen zu suchen. Warum nicht grundsätzlich funktional programmieren? Erstens können funktionale Programme von herkömmlichen Computern nicht direkt ausgeführt werden und zweitens werden komplexere Datenstrukturen benötigt, um die Strategien zur Funktionsbehandlung zu realisieren. logische Programmierung Logische Programmierung beruht auf der Prädikatenlogik. In der logischen Programmierung wird ein Problem durch eine Behauptung oder Zielaussage, die als richtig zu bestätigen ist , ausgedrückt. Ein Algorithmus wird als Folge von Behauptungen oder Klauseln betrachtet, die als richtig angenommen werden, und die dazu benutzt werden können, die Richtigkeit der Zielaussage aufgrund einer systematischen Anwendung der Klauseln zu bestätigen. Beispiel Vogel(X) legt-Eier(X), hat-Flügel(X) Diese Formulierung sagt aus, daß jedes Tier X ein Vogel ist, wenn es Eier legt und Flügel hat. Die linke Seite einer Klausel heißt Kopf (head) und die rechte Seite Rumpf (body). X ist eine Variable. Eine Klausel ohne Rumpf ist eine Tatsache (fact). z.B. Einführung, Algorithmen und Datenstrukturen 41 legt-Eier(Papagei). Eine Zielaussage ist eine Klausel mit leerem Kopf, zum Beispiel ?Vogel(Papagei). Ein Programm, gebildet aus den obigen Elementen könnte so aussehen: Vogel(X) legt-Eier(X), hat-Flügel(X) legt-Eier(Papagei) hat-Flügel(Papagei) Dieses Programm besteht aus drei Klauseln, wobei die letzten Fakten darstellen. Um die Richtigkeit der Zielaussage ?Vogel(Papagei) zu bestätigen, geht der Prozessor wie folgt vor. Zunächst vergleicht der Prozessor die Zielaussage mit den Köpfen der Klauseln. In der ersten Klausel findet er eine Übereinstimmung mit Vogel und ersetzt X durch Papagei Vogel(Papagei) legt-Eier(Papagei), hat-Flügel(Papagei) Damit sind die Gemeinsamkeiten erledigt. Im Zweiten Schritt wird der Rumpf der ersten Klausel zur Zielaussage mit ? legt-Eier(Papagei), hat-Flügel(Papagei) Nun wird diese neue Zielaussage mit der nächsten Klausel verglichen und es wird partielle Übereinstimmung festgestellt und ? hat-Flügel(Papagei) zur neuen, letzten Zielausage umfunktioniert, die dann erfolgreich mit der dritten Klausel verglichen wird, womit die Richtigkeit der Zielaussage bewiesen wird. Führen Sie einmal den Beweis am nachfolgenden Beispiel aus. Vogel(X) legt-Eier(X), hat-Flügel(X) legt-Eier(Schlange) hat-Flügel(Papagei) Zielaussage: ? Vogel(Schlange) Eine weit verbreitete logische Programmiersprache ist PROLOG In dieser Sprache wird die Ausführungsstrategie für logische Programme in der Art praktiziert, daß die Komponenten der Zielaussage von links nach rechts und die Klausel in der Reihenfolge ihres Auftretens verglichen werden. In diesem Sinne muß sich auch hier der Programmierer nicht um die Ausführungsreiehenfolge kümmern. Einführung, Algorithmen und Datenstrukturen 42 4 Algorithmenausführung: Aufbau von Computern Im vorhergehenden Kapitel hatten wir die Bedeutung von Algorithmen erläutert. Um diese ausführen zu können, bedarf es geeigneter Prozessoren. In die Rolle eines Prozessors kann sowohl der Mensch als auch eine Maschine schlüpfen. Eine solche Maschine wird in der Regel als Computer bezeichnet. Wir wollen Goldschlager und Lister folgen und uns zunächst dem Aufbau von Computern zuwenden. Hierbei besteht das Ziel darin, die technischen Voraussetzungen für einen Computer im groben kennenzulernen und die in diesem Umfeld geprägten Begriffe zu verstehen. Näheres vermitteln Spezialbücher. 4.1 Struktur von Computern Wir hatten bereits festgestellt, daß ein Computer aus einer Zentraleinheit (CPU), dem Speicher und Ein- und Ausgabegeräten besteht. Der Speicher nimmt das Programm und die Daten auf, die CPU führt die im Programm festgelegten Operationen aus und Ein- und Ausgabegeräte sorgen für die Kommunikation mit der Umgebung. Wir benutzen zur Formulierung eines Programmes eine höhere Programmiersprache. Dies ist zwar bequem für den Menschen, hat aber den Makel, daß der Rechner dieselbe nicht direkt verarbeiten kann. Notwendig ist demzufolge eine Maschinensprache. Es versteht sich, daß die Übersetzung von einer höheren Sprache in eine Maschinensprache eine weitere Aufgabe ist, die unter das Kapitel Algorithmenausführung fällt. Bestandteile solcher Maschinensprachen sind solche Anweisungen wie LOAD (Lade) und STORE (Speichere). Hierdurch werden Daten von der CPU in den Speicher oder umgekehrt transportiert. ADD, SUBSTRACT, MULTIPLY, DIVIDE organisieren in der CPU arithmetische Operationen. JUMP und RETURN ermöglichen die Steuerung der Anweisungen. Maschinensprachen unterscheiden sich je nach Hersteller. Eine Anzahl grundlegender Operationen ist jedoch immer gleich. Diese nennt man Mikroanweisungen. Dazu gehören die oben beschriebenen Anweisungen. Computerhersteller liefern ein in Mikroanweisungen geschriebenen Interpreter, der in der Lage ist, Maschinenprogramme zur Ausführung zu bringen. Die Computer, die in dieser Art arbeiten, heißen mikroprogrammierte Computer. Der Interpreter gehört zur sogenannten Firmware. Hardware beinhaltet die physikalischen Einrichtungen des Computers. Software faßt alle Programme, die auf dem Computer laufen zusammen. 4.2 Physikalische und elektronische Bausteine Im Zusammenhang mit der Hardware con Computern sind einige Begriffe zu klären. Halbleiter In einem Computer finden wir als kleinste Bauelemente Chips, die auf Siliziumbasis aufgebaut sind und die mit verschieden Beimengungen versehen sind (Aluminium, Phosphor, Brom und Siliziumdioxid). Diese Spuren von Beimengungen führen zu guten Leitern (Aluminium), zu n-(negativ)leitenden Halbleitern (Silizium + Phosphor), zu p-(positiv) leitenden Halbleitern (Silizium + Brom) und Nichtleitern (Siliziumdioxid). Transistoren Halbleiter werden zu Mustern zusammengefaßt, die man Transistoren nennt. Die elektrischen Eigenschaften der Halbleiter machen den Transistor zum Schalter. Einführung, Algorithmen und Datenstrukturen 43 Gatter Transistoren werden zu Gruppen zusammengefaßt und bilden die Gatter oder Schaltelemente. Die Eigenschaft von Schaltelementen ist, daß sie nur zwei Spannungswerte annehmen können, hoch oder tief oder 1 oder 0. Die Verwertung dieser Eigenschaften verleiht dieser Computerfamilie den Beinamen digital, weil nur diese beiden diskreten Meßgrößen 0 und 1 verwendet werden. Komponenten Gatter werden je nach Zweckbestimmung zu Komponenten wie Speicher, Addierwerke, Busse, Zeitgeber und Steuerwerke zusammengefaßt. Speicher Speicherzellen nehmen die Werte 0 oder 1 an. Eine solche Zelle heißt Flip-Flop. Somit kann in dieser Einheit ein Bit (binary digit) gespeichert werden. Acht Bits werden zu einem Byte zusammengefaßt. Werden Daten in Bündeln fester Länge behandelt, so nennt man diese Bündel auch Worte. Die Wortgröße ist unterschiedlich und kann 8, 16, 32 oder mehr Bits betragen. Wenn die Wortgröße N - Bits beträgt, werden N Flip-Flops benötigt und wir erhalten ein Register. In ein Wort passen unterschiedliche Datenarten. Zur Abbildung der Daten muß ein Darstellungsschlüssel zur Verfügung gestellt werden - ein Code -, der die entsprechende Interpretation zuläßt (z. B. als Zahl oder als Zeichen). Addierwerke Im Dualsystem sind die Möglichkeiten zur Addition auf 4 Möglichkeiten beschränkt: 0 + 0 = 0, Übertrag 0 0 + 1 = 1, Übertrag 0 1 + 0 = 1, Übertrag 0 1 + 1 = 0, Übertrag 1. Diese Additionen können nun über die Schaltelemente UND, ODER und NICHT realisiert werden. Daraus werden sogenannte Halbaddierer und Volladdierer gebildet. Datenbus Zum Datentransfer zwischen den Computerkomponenten werden Datenbusse eingerichtet. Datenbusse bestehen aus Leitungen, deren Anzahl von der Wortgröße des Computers abhängt., d.h. , die Daten werden stets ganzheitlich als Wort übertragen. Taktgeber und Steuerwerk Da der Bus die verschiedenen Komponenten des Computers mit Daten versorgt, benötigt man eine Einheit, die quasi die Bedienung der Komponenten steuert. Diese Funktion übernimmt das Steuerwerk. Ein Taktgeber erzeugt in regelmäßigen Abständen Signale, die das Steuerwerk zur Synchronisation der Arbeit der Komponenten benutzt. 4.3 Mikroprogrammierte Computer Mit dem soeben vorgestellten Komponenten kann ein Computer zusammengebaut werden. (siehe [GOLI90, Kapitel 4.4]. Für den Betrieb muß ein Mikroprogramm geschrieben werden, oder im Falle der Nutzung von Maschinensprachen ist ein mikroprogrammierter Interpreter zur Verfügung zu stellen. Für die Kommunikation des Computers mit der Außenwelt benötigt man die E/A-Geräte. Einführung, Algorithmen und Datenstrukturen 44 Eingabegeräte sind: Tastatur, Sensoren, Belegleser, Lochkartenleser, Barcodeleser, Magnetschriftleser, Maus, Lichtstift, Kamera, Mikrophon, Tablett... Ausgabegeräte : Bildschirm, Drucker, Plotter, Lautsprecher, Effektoren,.... Natürlich müssen die speziellen Programme zur Kommunikation mit diesen Geräten vorbereitet werden. Ebenso kann auch die Kommunikation von Rechnern untereinander organisiert werden. Dazu wird ein Netz eingerichtet. Dieses Netz kann dann zusätzliche Dienste anbieten wie electronic mail, electronic news, gemeinsame Nutzung von Diensten (Datenbanken, E/AEinheiten), Verteilung von Aufgaben (Client-Server - Architektur), Internet, Filetransfer, Telnet,... Rechnernetze können als lokale Netze (LAN-Local Area Network) oder als entfernte Netze ( Wide AN) installiert werden. Die Rechner sind dabei als Netz, Ring oder an einem Bus angeordnet. Die Kommunikation erfolgt über entsprechende Protokolle (TCP/IP,..). All die bisher beschriebenen Computer arbeiten zur selben Zeit nur eine Instruktion ab. Man sagt, daß sie sequentiell arbeiten. Läßt man mehrere Prozessoren an einer Aufgabe gleichzeitig arbeiten, so benötigt man neben parallelen Algorithmen auch Programme, die die Synchronisation vornehmen. Diese Aufgabe ist heute Gegenstand der parallelen Programmierung. 5 Algorithmenausführung: Systemsoftware Wir haben bereits erwähnt, daß die Handhabung des Computers über die Maschinensprache zwar sehr effektiv , aber auch nicht besonders nutzerfreundlich ist. Demzufolge werden durch den Computerhersteller Dienste bereitgestellt, die den Umgang mit dem Computer erleichtern. Diese Dienste faßt man allgemein unter dem Begriff Systemsoftware zusammen. Dazu gehören Betriebssystem (operating system), Unterstützt den Nutzer bei der Kommunikation mit Speicher und E/A-Geräten. Bietet die Möglichkeit zur Programm - und Datenänderung. Verteilt die Kapazitäten des Computers auf mehrere Nutzer. Sprachübersetzer ( language translators), Übersetzen Programme höherer Programmiersprache in Maschinensprache Interpretierer ( interpreter) und Kompilierer (compiler) sind verfügbar. Datenaufbereiter ( editor), Stellt ein Systemprogramm zur Erstellung bzw. Änderung von Programmen zur Verfügung. Lader ( loader) Bindet Moduln zu einem Ganzen und überträgt Programm in den Hauptspeicher. 5.1 Sprachübersetzer Sprachübersetzer überführen Programme in Maschinensprache, die wiederum durch einen mikroprogrammierten Interpreter ausgeführt werden. In einem Programm in einer höheren Programmiersprache befinden sich Anweisungen und Datenstrukturen. Beide müssen überführt werden. Dafür gibt es 2 Strategien: Die Interpretierung und Kompilierung. Einführung, Algorithmen und Datenstrukturen 45 Bei der Interpretierung wird jede Anweisung übersetzt und sodann sofort ausgeführt. Eine Ausführung geht demzufolge einer weiteren Übersetzung voraus. Der Algorithmus eines Interpreters lautet: Beginne mit dem Anfang des Programmes Wiederhole Analysiere die Syntax der nächsten Anweisung, um ihren Typ und ihre Operanden zu bestimmen Falls kein Syntaxfehler vorliegt dann rufe den Modul für diesen Anweisungstyp mit den Operanden als Parameter auf sonst melde einen Fehler Bis das Ende des Programmes erreicht ist oder ein Syntaxfehler auftritt. Bei der Kompilierung hingegen wird erst das gesamte Programm übersetzt und anschließend ausgeführt. Der Algorithmus lautet: Beginne mit dem Anfang des Programmes Wiederhole Übersetze die nächste Anweisung Bis das Ende des Programms erreicht ist Treffe Vorkehrungen zur Ausführung des übersetzeten Programmes. Das Ausgangsprogramm wird als Quellprogramm bezeichnet und das übersetzte Programm als Objektprogramm. Objektprogramme werden separat gespeichert und können beliebig oft ausgeführt werden. In den Algorithmen ist der Schritt Übersetze die nächste Anweisung vorgesehen. Dieser muß verfeinert werden. Prinzipiell sieht die Verfeinerung so aus (hier am Beispiel des Kompilierens): Quellprogramm Objektprogramm in höherer Programmiersprache LEXIKALISCHE SYNTAX- CODE- ANALYSE ANALYSE ERZEUGUNG in Maschinensprache Abbildung 5.1-1: Übersetzungsphasen Lexikalische Analyse Das Quellprogramm wird in eine Folge getrennter Symbole (Token) für die Namen der Datenelemente , die Operatoren und reservierten Worte zergliedert. Syntaxanalyse Die Zeichenfolge aus der lexikalischen Analyse wird mit Hilfe der Grammatikregeln der Sprache analysiert. Dieser Vorgang wird als Parsing bezeichnet. Codeerzeugung Für jedes syntaktische Element des Programmes werden geeignete MaschinensprachAnweisungen erzeugt, die das Objektprogramm bilden. Einführung, Algorithmen und Datenstrukturen 46 Hinsichtlicht tiefergehender Informationen wird auf die Nachfolgelehrveranstaltungen verwiesen. 5.2 Betriebssysteme Betriebssysteme gehören zur Systemsoftware, die den Nutzer von Details befreien soll und andererseits die Arbeit mit dem Rechner wirtschaftlich gestalten soll. Die wesentlichen Aufgaben wurden bereits zu Beginn des Kapitels angeführt. Es ist interessant zu verfolgen, welche Schritte der Benutzer ausführen müßte, um eine Aufgabe (einen Auftrag) zu erledigen, ohne ein Betriebssystem zur Verfügung zu haben. 1. Bereitstellen des Quellprogramms auf einem geeigneten Eingabegerät. 2. Starten eines Kompilierers zum Lesen , Übersetzen und Speichern des Objektprogrammes. 3. Starten eines Laders, um Objektprogramm vom Sekundärspeicher in den Hauptspeicher zu holen. 4. Bereitstellen der Eingabedaten durch geeignetes Gerät. 5. Starten der Ausführung des Objektprogrammes. 6. Präsententation der Ergebnisse auf einem bestimmten Ausgabegerät. Um aus diesem Ablauf die menschliche Komponente, die in der Regel sehr langsam ist, möglichst herauszunehmen, müssen Betriebssysteme noch weiter Funktionen übernehmen. Im wesentlichen sind das die Aufgaben a) der Job-Verteilung (dispatching), b) der Unterbrechungsbehandlung (interupt handling), c) der Betriebsmittelzuteilung (resource allocation), d) des Betriebsmittelschutzes (resource protection), e) der Ablaufplanung (scheduling). All diese Maßnahmen zielen darauf hin , den Durchsatz auf der verfügbaren Basishardware zu steigern auf Kosten eines Betriebssystems mit hohem Funktionsaufwand. Einführung, Algorithmen und Datenstrukturen 47 Teil II: Algorithmen und Datenstrukturen Basisliteratur: [SEDG92] Robert Sedgewick: Algorithmen in C++, Addison –Wesley Verlag, Bonn 1992 [AHULL96] Alfred V. Aho; Jeffrey D. Ullmann: Informatik, Datenstrukturen und Konzepte der Abstraktion, Int. Thomson Publishing Company, Bonn 1996 [DASS98] Jürgen Dassow: Algorithmen und Datenstrukturen, Vorlesungsskript FIN 1996 [WILLMS97] Andre`Willms: C++-Programmierung, Addison-Wesley , Bonn 1997 Einführung, Algorithmen und Datenstrukturen 48 6 Einführendes Beispiel : größter gemeinsamer Teiler Quelle: [DASS96], Kapitel 2.1.1, S. 35-39 als Primzahlzerlegung nach dem Euklid´schen Algorithmus 7 Zusammenhang Datenmodelle, Datenstrukturen, Algorithmen [AHULL96] Datenmodelle sind Abstraktionen, die zur Beschreibung von Problemen verwendet werden. Die verschiedenen Programmiersprachen enthalten unterschiedliche Datenmodelle. C++ kennt z. B. als Abstraktionen ganze Zahlen, Gleitkommazahlen, Zeichen, Felder, Verbunde und Zeiger. Andere Sprachen verwenden als Datenmodell z. B. die Liste (Lisp, Prolog), die in C++ erst als Datenstruktur abgebildet werden muß. Datenmodelle haben im allgemeinen 2 Aspekte: a) Sie werden durch die Werte beschrieben, die das Modell annehmen kann. Dieser Aspekt wird statisch genannt (z.B. int kann Werte im einem bestimmten ganzzahligen Bereich annehmen). Die in der Sprache erlaubten Operationen darauf machen den dynamischen Aspekt aus ( z. B. arithmetische Operationen..). Selbstdefinierte Datenmodelle wie Liste verlangen dann auch nach selbstdefinierten Operationen wie Liste gründen, Listenelement einfügen usw.. Datenstrukturen sind Abstraktionen als Datenmodelle, die aus von der Sprache nicht a priori bereitgestellt werden, sondern aus den verfügbaren Datenmodellen zusammengesetzt werden müssen. In C++ muß das Datenmodell einer Liste z.B. als Datenstruktur einer verketteten Liste aufgebaut werden. Algorithmen sind präzise und eindeutige Spezifikationen einer Folge von Schritten, die mechanisch auf dem Rechner ausgeführt werden können. 7.1 Datenmodelle, Datenstrukturen in C++ (C) [SEDG92] Die Sprache C++ stellt uns elementare Datenmodelle über die Definition von Datentypen bereit. Diese sind int ganze Zahl float, double Gleitkommazahl char Zeichen int [ ] Feld (Array) *int Zeiger (Pointer) struct Datensatz (Record) class Klasse als abstrakter Datentyp. Wir wollen uns im folgenden mit den daraus abgeleiteten Datenstrukturen auseinandersetzen. Vorher sollten wir jedoch noch die Begriffe konkreter Datentyp und abstrakter Datentyp erklären. Datenstrukturen und Algorithmen zur Bearbeitung dieser Datenstrukturen kann man als abstrakte Datentypen zusammenfassen. Voraussetzung ist, daß man die Implementierungsdetails dadurch verbirgt, daß der Zugriff über auszuführende Operationen organisiert wird. Auf eine Liste kann man z.B. die Operationen insert, delete u.a. schreiben, die bei ihrem Aufruf die entsprechenden Algorithmen zum Einfügen oder Löschen eines Einführung, Algorithmen und Datenstrukturen 49 Listenelementes ausführen. Das entscheidende Merkmal eines abstrakten Datentyps ist jenes, daß alles was außerhalb dieses Typs organisiert wird, keinen Bezug dazu hat außer über die definierte Schnittstelle als Funktionsaufruf. Beweggrund für diese Technologie ist, die Programmlösungen überschaubarer zu halten. Abstrakte Datentypen können wieder benutzt werden, um daraus weitere abzuleiten. Aus verketteten Listen werden z. B. Stapel. Im Rahmen der Lehrveranstaltung benutzen wir konkrete Datentypen, indem wir aus Lehrgründen neben den Datenstrukturen auch die dazugehörigen Algorithmen offenlegen. 7.2 Elementare Datenstrukturen [SEDG92] ab S. 35 Felder Felder wurden bereits im kurzen Programmierkurs behandelt. Wegen der grundlegenden Bedeutung wollen wir dies vertiefen. Ein Feld ist eine feste Anzahl von einzelnen Daten, die zusammenhängend gespeichert werden. Der Zugriff erfolgt über den Index. Natürlich sollte die Speicherzelle gefüllt sein, bevor darauf Bezug genommen wird. Beispiel: Ausgabe der Primzahlen bis 1000 nach dem Algorithmus „Sieb des Eratosthenes“. # include <iostream> const int N = 1000; main() { int i, j, a[N+1]; for (a[1]=0; i=2; i<=N; i++) a[i]=1; for (i=2; i<=N/2; i++) for (j=2; j<=N/i; j++) a[i*j)]=0; for (i=1; i<=N; i++) if (a[i]) cout << i << ; cout << \n; } Hinweis: Entwickeln Sie die Feldbelegung anhand einer Tabelle! Dieses Programm nutzt den sequentiellen Zugriff auf die Feldelemente. Zu beachten ist, daß die Feldgrenze vorher festgelegt werden muß. Verkettete Listen Worin besteht der Unterschied einer Liste zum Feld? Listen können in ihrer Größe zu- und abnehmen. Ihre Größe braucht nicht von vornherein festgelegt werden. Ein zweiter Vorteil ergibt sich durch eine höhere Flexibilität im Umgang mit den Listen, indem man die Elemente beliebig umhängen kann. Das geht aber auf Kosten eines schnellen Zugriffs. Eine verkettete Liste ist eine Menge von Elementen, die wie in einem Feld sequentiell geordnet ist. In einem Feld ist die sequentielle Anordnung durch die Position (Index) des Feldelementes bestimmt, in einer Liste muß diese explizit zugewiesen werden. Dies wird durch Knoten erreicht, die sowohl des Element als auch die Verkettung zum nächsten Knoten enthält. Einführung, Algorithmen und Datenstrukturen A L 50 I S T Abbildung 7.2-1: Verkettete Liste Diese Liste muß nun noch mit einem Listenkopf und einem Listenende komplettiert werden. Den Anfang der Liste nennen wir Kopf (head). Er enthält einen Zeiger auf das erste Element. Das Ende bildet ein Zeiger z, der auf sich selbst zeigt. Kopf z A L I S T Abbildung 7.2-2:Verkettete Liste mit Anfangs- und Endknoten Schauen wir uns nun einige Operationen auf dieser Datenstruktur an. Zunächst wollen wir das Element T hinter den Kopf hängen. Das bedeutet konkret, drei Verkettungen zu ändern. Bei einem Feld hätten wir alle Elemente verschieben müssen. Kopf z A L I S T A L I T Kopf z S Abbildung 7.2-3: Änderung der Reihenfolge in einer verketteten Liste Noch wichtiger als das Umhängen ist das Einfügen eines Elementes in eine Liste. Kopf X z A L A L I I S X T S T Abbildung 7.2-4:Einfügen eines Listenelementes Für diesen Vorgang müssen wir nur 2 Zeiger umhängen: von I auf X und von X auf S. Einführung, Algorithmen und Datenstrukturen 51 Zum Entfernen von X hängen wir einfach den Zeiger von I auf S um. Beginnen wir nun mit der Implementation. struct node { int key; struct node *next;} struct node *head, *z; head = new node; z = new node; head - > next = z; z - > next = z; Dieses Konstrukt liefert eine leere Liste. Schritte zum Einfügen eines Knotens mit dem Schlüsselwert v hinter einem bestimmten Knoten t: Erzeugen eines neuen Knotens x x = new node; Schlüsselwert v übertragen x - > key = v; Verkettung von t in den neuen Knoten kopieren x -> next = t -> nexxt; Verkettung von t auf x ausrichten t ->next = x; Schritte zum Entfernen eines Knotens hinter dem Knoten t: Zeiger auf x richten x = t- > next; Zeiger von t auf Nachfolger von x setzen t -> next = x -> next; Knoten x löschen mit delete x Beispiel: „Problem des Josephus“ Wir stellen uns vor, daß N Personen Massenselbstmord betreiben wollen. Sie stellen sich in einem Kreis auf, zählen die M-te Person ab. //--------------------------------------------------------------------------#include <iostream.h> #include <stdlib.h> #include <string.h> #include <conio.h> struct node {int key; struct node *next; } ; main () { int i,N,M; struct node *t, *x; cin >> N >> M; t = new node; t ->key = 1; x = t; for (i=2; i<=N; i++) { t->next = new node; t = t->next; t->key = i; } t->next=x; while (t != t->next) Einführung, Algorithmen und Datenstrukturen 52 { for ( i=1; i<M; i++) t= t->next; cout << t->next->key << ' '; x=t->next; t->next=x->next; delete x; } cout << t->key << '\n'; getche (); } //--------------------------------------------------------------------------Ergebnis 4 2 2431 Speicherzuweisung Wir können eine verkettete Liste auch als Array darstellen. Wir bewahren die Elemente in einem Feld key und die Verkettungen in einem Feld next auf. Feld key enthält die Datenelemente, Feld next die Struktur. S A L I T 4 1 6 5 3 2 1 Key next Zeichnen Sie die dazugehörigen Listen! Abbildung 7.2-5 :Speicherung einer verketteten Liste über parallele Felder Stapel Der Stapel ist die wichtigste Datenstruktur mit beschränktem Zugriff. Worin liegt nun diese Beschränkung? Es gibt nur 2 Grundoperationen auf dieser Struktur: Man kann ein Element auf dem Stapel ablegen, das heißt , am Anfang einfügen (push), oder vom Stapel entnehmen (pop). Zu diesen beiden Funktionen gesellt sich noch die Funktion empty. Die Stapelbearbeitung wird oft mit dem Rangierproblem bei der Bahn verglichen. Wir wollen einen Stapel (stack) als Feld in C++ als Klassenkonstrukt definieren. class Stack Klassentyp { private: itemType *stack; Läßt Stacktyp offen! int p; public: Stack (int max = 100) Konstruktor Einführung, Algorithmen und Datenstrukturen 53 { stack = new itemType[max]; p = 0; } ~Stack() { delete stack; } inline void push (itemType v) { stack (p++) = v; } inline itemType pop() { return stack [--p]; } inline int empty () { return !p; } Destruktor Funktionen }; Beispiel: Auswertung arithmetischer Ausdrücke Zu berechnen ist der Ausdruck 5*(((9 + 8) * (4 + 6)) + 7) in Infix-Notation. Zunächst wird der Ausdruck in Postfix – Notation (umgekehrte polnische Notation ) umgewandelt in 598+ 46**7+*. Der Programmausschnitt für diese Aufgabe könnte lauten:[SEDG92, 48ff.] #include <iostream.h> #include <conio.h> class Stack private: int *stack; int p; public: Stack (int max = 100) { stack = new int [max]; p = 0; } ~Stack() { delete stack; } inline void push (int v) { stack int pop() { return stack [--p]; } inline int empty () { return !p; } }; main () { char c; Stack acc (50); int x; while (cin.get (c) { x = 0; while ( c == ) cin.get ( c ); if ( c == + ) x = acc.pop() + acc.pop(); if ( c == * ) x = acc.pop() * acc.pop(); while ( c >= 0 && c <= 9 ) { x = 10 *x + ( c - 0); cin.get ( c ); } acc.push (x); cout<< x; } Einführung, Algorithmen und Datenstrukturen 54 cout << acc.pop() << \n ; getche (); } Alternativ kann ein Stapel als verkettete Liste formuliert werden. Eine verkettete Liste erlaubt auf elegantere Weise das Wachsen und Schrumpfen eines Stapels. Beispiel des Ablegens eines String auf dem Stapel mit umgekehrter Ausgabe [Willms97, S. 71ff.] #ifndef __STACK_H #define __STACK_H class Stack { private: int *data; unsigned long anz; unsigned long maxanz; public: Stack(unsigned long); ~Stack(void); int Push(int); int Pop(void); unsigned long Size(void); }; #endif /* __STACK_H */ #include "stack.h" Stack::Stack(unsigned long s) { data=new(int[s]); if(data) { anz=0; maxanz=s; } else { anz=maxanz=0; } } Stack::~Stack(void) { if(data) delete[](data); } int Stack::Push(int w) { if(anz<maxanz) { data[anz++]=w; Einführung, Algorithmen und Datenstrukturen 55 return(1); } else { return(0); } } int Stack::Pop(void) { if(anz>0) return(data[--anz]); else return(0); } unsigned long Stack::Size(void) { return(anz); } #include <iostream.h> #include <string.h> #include "stack.h" void main(void) { const unsigned long SIZE=100; Stack stack(SIZE); char str[SIZE]; unsigned int x; cout << "Bitte String eingeben:"; cin.getline(str,SIZE); for(x=0;x<strlen(str);x++) stack.Push(str[x]); cout << "\n"; while(stack.Size()) cout << (char)(stack.Pop()); cout << "\n"; } Ergebnis: Bitte String eingeben:Vorlesung gnuselroV G N U S E L R O V Stapel: LIFO! Abbildung 7.2-6: Stapelbearbeitung Schlange Eine Schlange als abstrakter Datentyp übernimmt die neuen Elemente am Kopf und gibt sie am Ende wieder aus. Das Prinzip heißt Firstin-Firstout (FIFO). Einführung, Algorithmen und Datenstrukturen Schlange G 56 N U S E L R O V Abbildung 7.2-7: Schlangenbearbeitung In Anlehnung an die Ausführungen zum Stapel hier nun der Vorschlag für die Operationen auf Schlangen nach [Sedg92, S. 53]: void Queue :: put (itemType v) { queue [tail++] = v; if (tail > size ) tail =0; } itemType Queue :: get () { itemType t = queue [head++]; if (head > size) head = 0; return t; } int Queue :: empty () { return head == tail; } Demnach fügt die Funktion put ein Element am Schwanz der Schlange an, um mit get ein Element vom Kopf abzuholen. Obiges Beispiel nun als Schlange organisiert. #ifndef __QUEUE_H #define __QUEUE_H class Queue { private: int *data; unsigned long anz; unsigned long maxanz; unsigned long inpos, outpos; public: Queue(unsigned long); ~Queue(void); int Enqueue(int); int Dequeue(void); unsigned long Size(void); }; #endif /* __QUEUE_H */ #include "queue.h" Queue::Queue(unsigned long s) { data=new(int[s]); Einführung, Algorithmen und Datenstrukturen 57 if(data) { anz=inpos=outpos=0; maxanz=s; } else { anz=maxanz=inpos=outpos=0; } } Queue::~Queue(void) { if(data) delete[](data); } int Queue::Enqueue(int w) { if(anz<maxanz) { anz++; data[inpos++]=w; if(inpos==maxanz) inpos=0; return(1); } else { return(0); } } int Queue::Dequeue(void) { if(anz>0) { unsigned long aktpos=outpos; if((++outpos)==maxanz) outpos=0; anz--; return(data[aktpos]); } else return(0); } unsigned long Queue::Size(void) { return(anz); } #include <iostream.h> #include <string.h> #include "queue.h" # include < conio.h> void main(void) Einführung, Algorithmen und Datenstrukturen 58 { const unsigned long SIZE=100; Queue queue(SIZE); char str[SIZE]; unsigned int x; cout << "Bitte String eingeben:"; cin.getline(str,SIZE); for(x=0;x<strlen(str);x++) queue.Enqueue(str[x]); cout << "\n"; while(queue.Size()) cout << (char)(queue.Dequeue()); cout << "\n"; getche(); } Ergebnis Bitte String eingeben:Vorlesung Vorlesung 8 Bäume Bäume sind im Gegensatz zu Stapeln und Schlangen, die sich als eindimensionale verkettete Strukturen darstellen, zweidimensional. Das bedeutet, daß einem Element aus der Struktur mehr als nur ein weiteres Element folgen kann. E A A R S E T M P L E Abbildung 8.-1: Ein allgemeiner Baum Im Umgang mit Bäumen wurden bestimmte Begriffe geprägt: Knoten Kanten Pfad Wurzel Ein Knoten ist ein Element in einem Baum, das einen Namen hat und bestimmte Informationen trägt, z. B. S. Kanten verbinden die knoten. Ein Pfad ist eine Liste von Knoten zwischen zwei Knoten, z. B. zwischen L und R. Ein Knoten aus dem Baum wird hervorgehoben, z.B. E und damit Wurzel genannt. Bei der Abbildung hat dieser dann keinen Vorgänger. Innere, äußere Knoten Innere Knoten haben Vorgänger und Nachfolger. Äußere Knoten haben keine Nachfolger und werden auch Blätter genannt. Einführung, Algorithmen und Datenstrukturen Wald Ebene Höhe Pfadlänge 59 Bäume können zu einem Wald zusammengesetzt werden. Die Ebene ist die Anzahl der Knoten auf dem Pfad von einem Knoten auf die Wurzel zu (ohne ihn selbst). Die Höhe eines Baumes ist höchste Ebene in einem Baum. Ist die Summe der Länge der Pfade aller Knoten. Wenn ein Knoten in einem Baum eine bestimmte Zahl von Nachfolgern haben muß, so spricht man von einem n-ären Baum. Binärer Baum Eigenschaften von Bäumen Darstellung binärer Bäume Darstellung von Wäldern Baum ohne die Verwendung von Pseudoknoten Traversierung von Bäumen (Pre-, In-, Post- und Levelorder) Beispiel Syntaxbaum unter Verwendung eines Stapels Baum als verkettete Felder 9 Rekursion Definition Rekurrente Beziehungen Prinzip Teile und Herrsche Rekursive Traversierung von Bäumen Beseitigung der Rekursion 10 Sortieralgorithmen 10.1 Elementare Sortierverfahren Warum? Regeln Laufzeit Rahmenprogramm SelectionSort InsertionSort BubbleSort Sortieren von Dateien mit großen Datensätzen ShellSort Distribution Counting 10.2 Quicksort Vorteile, Nachteile Algorithmus Beispiel Kenngrößen 10.3 MergeSort Mischen Eigenschaften Beispielimplementation Einführung, Algorithmen und Datenstrukturen 60 Kenngrößen 10.4 Prioritätswarteschlangen Anwendungsfeld: Datensätze mit Schlüsseln sollen der Reihe nach verarbeitet werden, jedoch nicht unbedingt in einer vollständig sortierten Reihenfolge und nicht unbedingt alle auf einmal. Z.B.: Sammlung von Datensätzen mit anschließender Verarbeitung des größten Datensatzes, dann weitere Sammlung und anschließend Verarbeitung des nächstgrößten, usw.. Datenstruktur muß gefunden werden, die das Einfügen eines neuen Elements und das Löschen des größten Elements unterstützt. Die Ähnlichkeit mit Schlangen und Stapeln führt zum Begriff der Prioritätswarteschlange (priority queue). Anwendungsfelder sind z.B. Simulationssysteme, Job-Scheduling, numerische Berechnungen, Verdichtung von Dateien, Durchsuchen von Graphen.. Zielstellung Aufbau einer Datenstruktur, die Datensätze mit numerischen Schlüsseln (Prioritäten) enthält und diese Operationen unterstützt: - Aufbau einer Prioritätswarteschlange aus N gegebenen Elementen (construct) - Einfügen eines neuen Elements (insert) - Entfernen des größten Elements (remove) - Ersetzen des größten Elements durch ein neues Element (replace) - Verändern der Priorität eines Elements (change) - Löschen eines beliebigen Elements (delete) Zusammenfügen von 2 Prioritätswarteschlangen (join). Implementation - als ungeordnete Liste, wobei die Elemente in einem Feld a[1]... a[N] aufbewahrt werden, ohne die Schlüssel zu beachten. (a[0] und a[N+1] sind für bestimmte Marken reserviert.) Class PQ { private : itemType *a; int N; public: PQ (int max) { a = new itemType[max]; N = 0;} ~PQ ( ) { delete a;} void insert (itemType v) {a[++N] = v; } itemType remove ( ) { int j; max = 1; for (j = 2; j <= N; j ++) if ( a[j] > a[max] ) max = j; swap (a,max,N); return a[N--]; } Einführung, Algorithmen und Datenstrukturen 61 } Anstelle der oben angegebenen Implementation mit Hilfe eines Feldes können auch verkettete Listen für die ungeordnete Liste oder die geordnete Liste verwendet werden. Die Datenstruktur des Heaps Wir verwenden eine Datenstruktur, die die Datensätze in einem Feld so speichert, daß jeder Schlüssel größer ist als auf 2 bestimmten anderen Positionen. Die Schlüssel auf diesen Positionen müssen wiederum größer sein als zwei weitere Schlüssel. Diese Struktur kann als ein vollständig binärer Baum abgebildet werden. Wenn wir diese Struktur als Feld implementieren, können wir den Heap definieren als ein als Feld dargestellter vollständiger binärer Baum, in dem jeder Knoten der HeapBedingung genügt. 1 2 4 3 G 8A 5 9 X T E 10 R S O 6M 11 A 12 7 N I Abbildung 10.4-1: Ein Heap als vollständiger Baum k 1 2 3 4 5 6 7 8 9 10 11 12 a[k] X T O G S M N A E R A I Abbildung 10.4-2: Heap als Feld Alle Algorithmen operieren entlang eines Pfades, z.B. bei der Wurzel beginnend zum unteren Ende des Heaps, indem sie sich einfach vom Vorgänger zum Nachfolger oder umgekehrt bewegen. Auf einem Heap mit N Knoten befinden etwa lg N Knoten. Folglich werden alle Operationswarteschlangen bei Verwendung von Heaps in logarithmischer Zeit ausgeführt (außer join). Algorithmen mit Heaps Alle Algorithmen laufen im Prinzip so ab, daß zunächst eine Strukturänderung vorgenommen wird, die sodann die Heapbedingung verletzt, um diese anschließend zu beheben. Insert –Algorithmus Einfügen des Schlüssels P in den obigen Heap X T P G A S E R O A I N M Einführung, Algorithmen und Datenstrukturen 62 Abbildung 10.4-3:Einfügen von P P wird ursprünglich an die jetzige Stelle von M eingefügt, dann mit dem Vorgänger M verglichen, getauscht, da P>M, dann mit Vorgänger O verglichen und wiederum getauscht. Damit ist die Heapbedingung wieder hergestellt. Implementation void PQ:: upheap (int k) {itemType v; v = a[k]; a[0] = itemMax; while (a[k/2]<= v) { a[k] = a[k/2]; k = k/2; } a[k] = v; } void PQ :: insert(itemType v) { a[++N] = v; upheap (N);} Replace-Algorithmus Diese Operation ersetzt die Wurzel durch einen neuen Schlüssel ( z.B. X durch C ) und stellt sodann die Ordnung wieder her. T S P G A R E C O A I N M Abbildung 10.4-4: Einfügen von C für das größte Element Remove – Algorithmus Diese Operation entfernt das größte Element, indem zunächst das Element M in die Wurzel bewegt wird und anschließend die Heapbedingung wiederhergestellt wird.. Nach Entfernung von T aus Abb.10.4-4 entsteht der untere Heap. S R P G A M E C O A I Abbildung 10.4-5: Entfernen des größten Elements Zur Realisierung wird die Funktion downheap benutzt. void PQ :: downheap(int k) { N Einführung, Algorithmen und Datenstrukturen 63 int j; itemType v; v = a[k]; while (k <= N/2) { j = k + k; if (j<N && a[j]<a[j+1]) j++; if (v >= a[j] ) break; a[k] = a[j]; k = j; } a[k] = v; } itemType PQ:: remove() { itemType v = a[1]; a[1] = a[N--]; downheap (1); return v; } itemType PQ:: replace (itemType v) { a[0] = v; downheap ( 0 ); return ( 0 ); } Eigenschaft: Alle grundlegenden Operationen insert, remove, replace, delete, und change erfordern weniger als 2 lg N Vergleiche, wenn sie für einen Heap mit N Elementen ausgeführt werden. Heapsort Diese Methode sortiert M Elemente in M lg M Schritten, wobei kein zusätzlicher Speicherplatz benötigt wird und Unabhängigkeit von den Eingabedaten besteht. Die Idee des Algorithmus besteht darin, einen Heap aufzubauen und sodann in der richtigen Reihenfolge zu entfernen. void heapsort (itemType a[ ], int N) { int i; PQ heap (N); for ( i = 1; i <= N; i++) heap.insert ( a[i]); for ( i = N; i >= 1; i--) a[i] = heap. Remove(); } 11 Suchverfahren 12 Verarbeitung von Zeichenfolgen Dieses Kapitel setzt sich mit Algorithmen zur Bearbeitung von Zeichenfolgen auseinander. Die Unterkapitel gliedern sich in - Suchen in Zeichenfolgen Einführung, Algorithmen und Datenstrukturen - 64 Pattern Matching Syntaxanalyse (Parsing) Datenkomprimierung Kryptologie. Zeichenfolgen haben insbesondere ihre Bedeutung in der Textverarbeitung, d. h. u.a. auch in der Dokumentenverarbeitung. Unter Zeichenfolgen versteht man demzufolge einen String, der nicht weiterhin strukturiert werden kann. Dieser String besteht aus Buchstaben, Ziffern und Sonderzeichen. Eine besondere Art ist die binäre Zeichenfolge, die nur aus Nullen und Einsen besteht. Der Vorrat an Zeichen wird durch das jeweilige Alphabet bestimmt. Algorithmen auf Zeichenfolgen haben eine Geschichte. Oftmals war die Aufgabe gestellt, aus einer Zeichenkette mit der Länge N ein Muster der Länge M zu finden (Musteranpassung = pattern matching). Ab 1970 setzten sich mit diesem Problem S.A. Cook, D.E. Knuth, V.R.Pratt, J.H. Morris, R.S. Boyer und J.S. Moore auseinander. Später folgten R.M. Karp und M.O.Rabin. Verallgemeinert kann aufgrund dieser Bemühungen ein grober Algorithmus angegeben werden, der ein Muster p[1...M] in der Text-Zeichenfolge a[1..N] sucht: Int brutesearch (char *p, char *a) { int i, j, M =strlen (p), N=strlen(a); for (i = 0; j = 0; j < M && i < N; i++, j++) if (a[i] != p[j]) (i - = j – 1; j = - 1; } if ( j == M) return i – M; else return i; } Beispiel: Suchen des Musters STING in der Text_Zeichenfolge A STRING SEARCHING EXAMPLE CONSISTING OF.... Übung: Entwickeln Sie eine Tabelle für den Suchlauf! Algorithmus Merkmal Anzahl der Zeichenvergleiche Grober Algorithmus Algorithmus von Knuth-Morris-Pratt bei Fehlstart ungefähr NM zurücksetzen auf das 1. Zeichen Ausnutzen der Informationen über den Fehlstart <N+M Algorithmus von Boyer-Moore Muster von rechts nach links durchsuchen Algorithmus von Rabin-Karp Muster wird über Hashtabelle abgebildet < M + N, N / M bei großem Alphabet und kleinem Muster proportional zu M + N (linear) Einführung, Algorithmen und Datenstrukturen 65 12.1 Pattern Matching Oftmals sucht man nach einem unvollständig spezifizierten Muster. So suchen wir z.B nach einer Datei über das Betriebssystem, deren Namen wir mit Datei kennen, wobei die Extension (txt, dot, doc.. ) nicht bekannt ist und stellvertretend mit * angegeben wird. In diesem Falle bedient man sich des Pattern Matching oder auch Musteranpassung. Das Problem ist nicht ganz trivial. Zunächst brauchen wir - eine „Sprache“, die die Muster spezifizieren hilft, - um sodann die Suche mit Automaten durchführen zu können. Der Algorithmus zum Pattern Matching soll die Arbeitsweise dieses Automaten zur Mustererkennung beschreiben. Beschreibung von Mustern Es werden Symbole benutzt, die mit Hilfe der folgenden 3 grundlegenden Operationen verknüpft werden. (i) Verkettung (Concatenation) Eine Verkettung zweier Zeichen AB liegt genau dann vor, wenn die im Text gleichen beiden Zeichen benachbart sind, B folgt auf A (ii) Oder (Or) Diese Operation ermöglicht die Vorgabe von Alternativen in Mustern. Als Zeichen soll + genutzt werden. A + B bedeutet entweder A oder B. C(AC + B)D bedeutet entweder CACD oder CBD. (A + C))B + C)D) bedeutet entweder ABD oder CBD oder ACD oder CCD. (iii) Hüllenbildung (Closure) Diese Operation gestattet es, Teile des Musters beliebig oft zu wiederholen. Die Hüllenbildung soll mit * gekennzeichnet werden. Z.B. stimmt AB* mit Zeichenfolgen überein, die aus einem A bestehen, gefolgt von einer beliebigen Anzahl oder 0-mal B, (AB)* stimmt mit Zeichenfolgen überein, die sich aus abwechselnden A und B bestehen. Eine Folge von Symbolen, die unter Verwendung dieser 3 Operationen gebildet werden, nennt man einen regulären Ausdruck. Alle regulären Ausdrücke sind spezielle Text-Muster. Die Aufgabe besteht nun darin, einen Algorithmus zu finden, der feststellen kann, ob irgendein Muster sich in einer gegebenen Text-Zeichenfolge befindet. Der nachfolgend zu besprechende Algorithmus ist eine Verallgemeinerung des zu Beginn dargestellten groben Algorithmus. Automaten für das Pattern Matching Wir benutzen das Modell eines endlichen Automaten, der auf ein Textzeichen hin untersucht und bei Übereinstimmung in einen bestimmten Zustand übergeht und bei Nichtübereinstimmung in einen anderen Zustand. Nichtübereinstimmung bedeutet, daß an dieser Stelle das Muster nicht beginnen kann. Der Algorithmus selbst wird als eine Simulation des Automaten angesehen. Wird der Zustandsübergang eindeutig durch das nächste eingegebene Zeichen bestimmt, heißt der Automat deterministisch. Um reguläre Ausdrücke behandeln zu können, reicht jedoch ein solcher Automat nicht aus. Wir müssen ihn noch mit der Fähigkeit ausstatten, nichtdetrministisches Vorgehen ausführen zu können. Dies bedeutet, daß dem Automaten verschiedene Wege zur Anpassung von Mustern angeboten werden , aus denen er sich den richtigen aussucht. Beispiel: Suchen einer Musterbeschreibung der Art (A*B + AC)D in einer Text-Zeichenfolge Einführung, Algorithmen und Datenstrukturen 66 6 7 A C 8 5 9 D 0 1 A 2 3 B 4 Abbildung 12.1-1 Automat zur Mustererkennung Mit diesem Automaten könnten wir z.B. die Zeichenkette CDAABCAAABDDACDAAC durchmustern. Starten wir den Automaten in der Reihenfolge der Zeichen, so könnte er uns ab dem 7. Zeichen einen Erfolg melden, indem er die Teilkette AAABD findet, die der obigen Musterbeschreibung entspricht. Für einen gegebenen regulären Ausdruck können wir einen Automaten konstruieren, indem für die einzelnen Komponenten des Ausdrucks Teilautomaten nach folgendem Prinzip gebildet werden. Automat für die Erkennung eines Zeichens: A Automat Verkettung Automat1 Automat2 Automat Oder Automat1 Einführung, Algorithmen und Datenstrukturen 67 Automat2 Automat Hüllenbildung Abbildung 12.1-2 Konstruktion von Zustandsautomaten Wie können wir nun Automaten in eine Datenstruktur überführen? Dazu benutzen wir ein Feld. Am Beispiel des Automaten aus Abb. 12-1 sieht dieses Feld so aus. Die Zustände 0-9 (state) bilden die erste Dimension des Feldes, die Belegung der Zeichen (ch) und die Zustandsübergänge (next1 und next2) die davon abgeleiteten Dimensionen. Tabelle 12.1-1 Automat als Feld state ch next1 next2 0 5 5 1 A 2 2 2 3 1 3 B 4 4 4 5 8 8 6 2 6 A 7 7 7 C 8 8 8 D 9 9 9 0 0 Dieses Feld kann indem Sinne interpretiert werden, daß wenn man sich in einem Zustand state befindet und ein Zeichen ch[state] vorfindet , man dann zum Zustand next1[state] oder next2[state] übergeht. Zustand 9 ist der Endzustand, Zustand 0 ein Pseudo-Zustand, der durch next spezifiziert wird. Der freihandmarkierte Pfad zeigt eine Möglichkeit der Auswertung dieser Tabelle an. Nun brauchen wir quasi noch ein Programm, das die Arbeitsweise eines nichtdeterministischen Pattern Matching-Automaten simuliert. Natürlich kann ein Programm einen richtigen Weg nicht erraten. Das bedeutet für die Programmierung, alle Möglichkeiten abzutesten. Sedgewick schlägt für diesen Fall eine nichtrekursive Implementation vor, in welcher die betrachteten Zustände in einer speziellen Datenstruktur, einer Warteschlange mit zweiseitigem Zugriff (double-ended-queue oder deque) ablegt werden. Die Idee besteht darin - alle Zustände zu registrieren, die eingenommen werden könnten, wenn der Automat das aktuelle Zeichen betrachtet, - alle Zustände werden der Reihe nach abgearbeitet, - Nullzustände führen zu zwei oder weniger Zuständen, - Zustände für Zeichen, die mit dem aktuellen Eingabewert nicht übereinstimmen, werden eliminiert, - jene , die übereinstimmen, führen zu neuen Zuständen, die bei der Prüfung des nächsten Zeichens zu verwenden sind. Einführung, Algorithmen und Datenstrukturen 68 Zur Aufbewahrung dieser Zustände wird die bereits genannte Liste deque benutzt. Die Funktion match liefert den notwendigen Algorithmus. const int scan= -1; int match (char *a) { int n1,n2; Deque dq (100); int j = 0, N = strlen(a), state = next1 [ 0 ]; dq.put (scan); while (state) { if (state == scan) { j++; dq.put (scan); } else if (ch [state] == a [ j ] ) dq.put (next1 [state] ); else if (ch [state ] == ` `) { n1 = next1 [state]; n2 = next2 [state]; dq.push(n1); if (n1 != n2) dq.push (n2); } if (dq.empty ( ) || j == N) return 0; state = dq.pop ( ); } return j; } Tabelle 12.1-2 Inhalt der deque während der Erkennung von AAABD + steht für scan 1 + 0 + 6 2 + 6 3 2 + 6 3 2 + 6 7 2 + + 7 2 + 7 3 1 2 + 7 3 2 + 7 2 + + 2 + 3 1 2 + 3 2 + + 2 + 3 1 + 3 + 4 + 4 + 8 9 + + 9 Aus den Darlegungen kann abgeleitet werden die Eigenschaft 12.1 : Die Simulation der Arbeitsweise eines Automaten mit M Zuständen bei der Suche nach Mustern in einer Text-Zeichenfolge aus N Zeichen kann im ungünstigsten Fall mit weniger als NM Zustandsübergängen erfolgen. (Achtung: In deque dürfen keine Kopien auftreten!) Einführung, Algorithmen und Datenstrukturen 69 12.2 Syntaxanalyse (Parsing) Die Syntaxanalyse beschäftigt sich mit der Untersuchung der Struktur von Sprachen. Im Kontext der Programmiersprachen verwenden wir diese Methoden, um z.B. ein Programm in höherer Programmiersprache (C++, Java.. ) in eine Assembler- oder Maschinensprache umzuwandeln, die sodann auf dem Rechner ausführbar ist. Diese Tätigkeiten führt ein Compiler aus. Programme, die eine Syntaxanalyse durchführen, werden Parser genannt. Zwei Grundsätzliche Vorgehensweisen werden in der Syntaxanalyse genutzt: 1. Top-down-ablaufende Methoden überprüfen ein Programm auf Zulässigkeit, indem Teile von Programmen so weit heruntergebrochen werden, daß dieselben sodann auf Übereinstimmung mit den Eingabedaten geprüft werden können.(rekursive Arbeitsweise) 2. Bottom-up-Methoden setzen Teile der Eingabedaten in einer strukturierten Weise zusammen, daß immer größere Teilstücke entstehen, die letztendlich zu einem zulässigen Programm führen (iterative Arbeitsweise). Im folgenden wird in Anlehnung an Sedgewick ein Parser konstruiert, der der Beschreibung regulärer Ausdrücke für den Entwurf einer einfachen Sprache dient. Sodann wird dieser modifiziert , so daß dieser reguläre Ausdrücke in Pattern-Matching-Automaten umwandelt. Kontextfreie Grammatiken Die Beschreibung der Zulässigkeit von Teilen einer Sprache erfolgt über Grammatiken. Programmiersprachen werden oft durch kontextfreie Grammatiken ( Auf der linken Seite der Produktionsregel steht genau ein Nichtterminal.) beschrieben. Folgend eine kontextfreie Grammatik für die Menge aller zulässigen regulären Ausdrücke , wie sie im letzten Kapitel verwandt wurden. <Ausdruck> ::= <Term> | <Term> + <Ausdruck> <Term> ::= <Faktor> | <Faktor> <Term> <Faktor> ::= <( <Ausdruck>) | v > (<Ausdruck>)* | v* Nach dieser Grammatik können z.B. reguläre Ausdrücke der Art (1+01)*(0+1) oder (A*B+AC)D gebildet werden. Jede Zeile in der Grammatik wird eine Produktion oder Regel genannt. Die Produktionen bestehen aus den Terminalsymbolen ( ) + und * („v“ ist ein spezielles Symbol und steht für einen beliebigen Buchstaben oder eine beliebige Ziffer ), weiterhin aus Nichtterminalsymbolen <Ausdruck>, <Term>, <Faktor>. Die Symbole ::= als „ist ein“ und | als „oder“ sind Metasymbole . Mit Hilfe dieser Vereinbarungen können nun Produktionen abgeleitet werden. Diesen Ableitungsprozeß kann man mittels Syntaxbaum (parse tree) darstellen. Abbildung 12.1-1 Syntaxbaum für (A*B+AC)D Ausdruck Term Faktor ( Ausdruck Term Faktor + Term Term ) Ausdruck Term Faktor D Einführung, Algorithmen und Datenstrukturen A * 70 Faktor Faktor Term B A Faktor C Die Hauptaufgabe eines Parsers besteht nun darin, die auf obige Weise abgeleiteten Zeichenfolgen dahingehend zu untersuchen, ob dafür ein Syntaxbaum existiert oder nicht. Die Top-down – Methode arbeitet sich dabei von den oberen Nichtterminalsymbolen bis zu den im Bottom zu erkennenden Zeichenfolgen durch. Bottom-up vollzieht sich umgekehrt. Top-down-Syntaxanalyse- Der rekursive Abstieg Es gibt eine Methode der Syntaxanalyse, die zur Erkennung der nach einer bestimmten Sprache erzeugten Zeichenfolge einen rekursiven Bezug auf die Grammatik nimmt. Das bedeutet, daß aus der Grammatik unmittelbar ein Programm abgeleitet werden kann. Man geht so vor: Jede Produktion entspricht einer Funktion mit dem Namen des Nichtterminalsymbols auf der linken Seite. Nichtterminalsymbole auf der rechten Seite entsprechen Funktionsaufrufen, Terminalsymbole entsprechen dem Durchlaufen der eingegebenen Zeichenfolge. Teil eines Top-down-Parsers kann diese Funktion sein: expression () { term (); if (p[j] == `+ `) { j++; expression (); } } p[j] enthält die Zeichenfolge, die zu untersuchen ist. In dieser Funktion wird term () aufgerufen, die definiert ist mit term () { factor (); if ( ( p[j ] == `( ` ) || letter (p [j] )) term(); } factor () { if (p[j] == `(` ) { j++; expression (); if (p[j] == `)` ) j++; else error (); else if (letter (p[j] )) j++; else error(); if (p[j] == `*` ) j++; } } Einführung, Algorithmen und Datenstrukturen 71 Funktion error() enthält eine Fehlermeldung, Funktion letter liefert ein Zeichen v. Der Syntaxbaum gibt nun die Struktur der rekursiven Aufrufe während der Syntaxanalyse an. Wenn wir als Zeichenfolge p den regulären Ausdruck (A*B+AC)D auf syntaktische Richtigkeit entsprechend der vorherigen Grammatik untersuchen wollen, so spannt sich der nachfolgende Syntaxbaum auf. Ausdruck Term Faktor ( Ausdruck Term Faktor A * Term Faktor B + Ausdruck Term Faktor A Term Faktor ) Term Faktor C D 12.3 Datenkomprimierung Bisher ging es bei den vorgestellten Algorithmen im wesentlichen um den Aspekt der Laufzeiteffizienz. Nachfolgend sollen durch geeignete Methoden der Speicherplatzbedarf optimiert werden. Textdateien und binäre Dateien können erheblich verdichtet werden. Komprimierungsverfahren gewinnen trotz der Verfügbarkeit großer Speicher gegenwärtig wieder an Bedeutung, da die zu speichernden Daten gigantisch zunehmen und das Arbeiten auf schnellen, aber teuren und damit begrenzten Speichern deutlich zunimmt. Lauflängenkodierung Komprimieren kann man beispielsweise durch die Eliminierung von Redundanzen. Nehmen wir eine beliebige Zeichenkette, AAAABBBAABBBBBCCCCCCCCCCCC........ Wir können diese Zeichenketten durch eine Lauflängenkodierung in 4A3BAA5B12C..... kodieren. Aus dem Beispiel kann man entnehmen, daß für die Kodierung 2 Zeichen benutzt werden – ein Zeichen für die Anzahl, ein Zeichen für den Buchstaben. Läufe der Länge 1 oder 2 werden nicht kodiert (z.B. AA). Für binäre Dateien wird eine verfeinerte Variante dieser Methode verwandt, die zu drastischen Einsparungen führen kann. Die Idee besteht darin, die Lauflängen zu speichern und auf die Speicherung von 0 und 1 zu verzichten, da diese sich ja abwechseln. Beispiel: Lauflängenkodierung eines Bitrasters Einführung, Algorithmen und Datenstrukturen 000000000011111111000000 000001111111111000000000 011111111111111111111111 72 10 8 6 5 10 9 1 23 Man erkennt, daß dieses unvollständige Raster immer mit einer führenden Null beginnt. Diese Art der Komprimierung lohnt sich natürlich nur dann, wenn die benötigte Speicherkapazität für die Komprimierung wesentlich kleiner ist als die Original-Bitzahl. Diese Art der Lauflängenkodierung erfordert unterschiedliche Darstellungen für die Originaldatei und ihre kodierte Variante. Dies ist nicht immer machbar. Enthält z.B. die Originaldatei Ziffern, so versagt dieses Verfahren. Kodierung mit variabler Länge Die Idee dieses Verfahrens besteht darin, von der ursprünglichen Art des Speicherns von Textdateien mit sieben oder acht Bits für jedes Zeichen abzugehen . Für Zeichen, die häufig vorkommen, werden weniger Bits benutzt, für selten vorkommende dagegen mehr. Beispiel: Es soll die Zeichenfolge „ABRACADABRA“ kodiert werden. Die standardmäßige Kodierung mit 5 Bits für den Buchstaben i im Alphabet (0 für Leerzeichen) ergibt diese Bitfolge: 00001 00010 10010 00001 00011 00001 00100 00001 00010 10010 00001 (Die Leerzeichen wurden nur zur besseren Darstellung eingefügt!) In dieser Zeichenfolge kommt das A 5x vor, das D jedoch nur 1x. Bei Verwendung eines Codes mit variabler Länge kann eine Einsparung erzielt werden, indem für häufig auftretende Zeichen weniger Bits als für selten auftretende verwendet werden. Wenn wir A mit 0, B mit 1, R mit 01, C mit 10 und D mit 11 kodieren, wird die obige Bitfolge in 0 1 01 0 10 0 11 0 1 01 0 umgewandelt. Für ursprünglich 55 Bits werden 15 Bits benötigt. Dazu gehören jedoch noch 10 notwendige Begrenzer (hier als Leerzeichen!), da die Bitfolge ansonsten fehlinterpretiert werden könnte. Uns stören natürlich noch die Begrenzer. Deshalb verwenden wir zur Darstellung eines Codes einen Trie. Jeder Trie mit M äußeren Knoten kann dazu benutzt werden, um jede beliebige Zeichenfolge mit M Zeichen zu kodieren. Der Code für jedes Zeichen wird durch den Pfad von der Wurzel zu diesem Zeichen bestimmt, 0 steht für „nach links gehen“, 1 für „nach rechts gehen“. O O O B O C D A R Abbildung 12.1-2Trie zur Kodierung der Zeichen A,B,C,D und R Unter Verwendung dieser Vereinbarung wird die Zeichenfolge ABRACADABRA Kodiert zu 1100011110101110110001111 . Somit werden aus ursprünglich 55 Bit 25 Bit. Bei der Dekodierung gehe man den umgekehrten Weg, indem man den Bit folgend den Baum absteigt bis ein äußerer Knoten erreicht wird und das entsprechende Zeichen ausgibt. Welchen Trie sollte man benutzen? Dafür lieferte D. Huffman 1952 ein Verfahren, das Huffman-Kodierung genannt wird. Einführung, Algorithmen und Datenstrukturen 73 Erzeugung des Huffman-Codes Der erste Schritt bei der Erzeugung eines Huffman-Codes besteht darin, die Häufigkeit eines jeden Zeichens innerhalb einer Zeichenfolge zu zählen. for (i = 0 ; i<= 26; i++) count [i]; for ( i = 0; i< M; i++) count [ index(a[i])] ++; (Die Funktion index dient dazu, den Häufigkeitswert für den i-ten Buchstaben des Alphabets in dem Eintrag count [i] einzufügen. Index 0 wird für das Leerzeichen benutzt.) Beispiel: A SIMPLE STRING TO BE ENCODED USING A MINIMAL NUMBER OF BITS K Count [k] A B C D E F G I L M N O P R S T U 0 1 2 3 4 5 6 7 9 12 13 14 15 16 18 19 20 21 11 3 3 1 2 5 1 2 6 2 4 5 3 1 2 4 3 2 Abbildung 12.1-3 Häufigkeit in der Zeichenfolge A SIMPLE.. Nun wird ein Trie anhand der Häufigkeitsverteilung aufgebaut. Jede Häufigkeit wird zunächst in einem Knoten abgelegt. Anschließend werden die beiden Knoten mit der kleinsten Häufigkeit ausgewählt und mit einem Knoten verbunden, der als Häufigkeit die Summe beider Einzelknoten hat, usw. . Schließlich entsteht ein Baum. (siehe Abb.22.4 bei Sedgewick) Eigenschaft 12. : Die Länge der kodierten Zeichenfolge ist gleich der gewichteten äußeren Pfadlänge des Huffmann-Baumes. Gewichtete äußere Pfadlänge = Summe der Produkte Gewicht X Pfadlänge Implementation Für die Konstruktion des Häufigkeitsbaumes wird der Algorithmus für die Entfernung des kleinsten Elementes aus einer Menge untergeordneter Elemente benötigt. Hierfür bietet sich das Konzept der Prioritätswarteschlangen an. for (i=0; i<=26; i++) if (count[i] ) pq.insert (count [i], i); for ( ; !pq.empty (); i++) { t1 = pq.remove(); t2 = pq.remove(); dad[i] = 0; dad[t1] = i; dad[t2] = -i; count[i] = count[t1] + count[t2]; if (!pq.empty()) pq.insert (count[i], i); } Dieses Konstrukt arbeitet in der Weise, daß - alle von Null verschiedenen Häufigkeitszähler in die Prioritätswarteschlange eingefügt werden, - dann die beiden kleinsten Elemente entnommen, addiert werden, um das Ergebnis in die pq abzulegen, - dieser Vorgang wiederholt wird, bis die Schlange leer ist. Die Verkettungen dad [t] bilden den Baum. Einführung, Algorithmen und Datenstrukturen 74 Abb.22.5 und 22.6 einfügen! Dieser Trie reicht aus, um den eigentlichen Code zu beschreiben. Abb. 22. 7 zeigt den vollständigen Code für das Beispiel. Dazu werden 2 Felder genutzt. Die am weitesten rechts befindlichen len[k] Bits in der Binärdarstellung der ganzen Zahl code[k] bilden den Code für den k-ten Buchstaben. Z.B ist Buchstabe I der 9. Buchstabe und hat den Code 011, somit wird code [9] = 3 und len[9] = 3. Die Implementierung sieht wie folgt aus: for (k=0; k<=26; k++) { i=0; x=0; j=1; if (count[k]) for (t=dad[k] ; t; t=dad[t], j+=j, i++) if (t<0) {x+=j; t=-t;} code[k] = x; len[k] = i; } Abb. 22.7 einfügen! 12.4 Kryptologie Im vorhergehenden Kapitel haben wir uns im Sinne der Speicherplatzeinsparung mit Methoden beschäftigt, die Originaldaten verdichten und damit auch verschlüsseln. Nun steht eine ähnliche Aufgabe an, nämlich die Originaldaten vor unbefugten Zugriff durch Verschlüsselung zu schützen. Hiermit beschäftigt sich die Kryptologie, die aus den Teilgebieten - Kryptographie, die sich mit der Entwicklung von Systemen zur Geheimhaltung auseinandersetzt und - der Kryptoanalyse, die Methoden sucht, um diese Verschlüsselung wieder rückgängig machen zu können, zusammensetzt. Ihre Anwendung fanden diese Verfahren ursprünglich im Militär, doch auch der Zugriff auf sensible Daten im dienstlichen oder privaten Bereich ist genauso wichtig. Spielregeln Ein Kryptosystem , das die geheime Kommunikation zwischen 2 Partnern gestattet, vollzieht im wesentlichen dieses Prozedere: Der Absender sendet eine Botschaft (Klartext!) an den Empfänger, indem er diesen in eine geeignete geheime Form (Chiffretext!) umwandelt. Dazu benutzt er eine bestimmte Verschlüsselungsmethode mit Schlüsselparametern. Der Empfänger braucht zum Lesen eine Entschlüsselungsmethode und die gleichen Parameter. Der Kryptoanalytiker versucht nun in die Verschlüsselung einzubrechen, da er ja ebenfalls die gängigen Methoden kennt. Was ihm verschlossen bleibt, sind die zwischen Absender und Empfänger vereinbarten Parameter in deren Kombination miteinander. Bei der Entwicklung von Kryptosystemen spielt die Kostenfrage eine entscheidende Rolle. Es gilt der Grundsatz, soviel in ein Kryptosystem zu investieren, daß der Analytiker mehr zur Verschlüsselung aufwenden muß als die Originaldaten wert sind. Einführung, Algorithmen und Datenstrukturen 75 Einfache Methoden Cäsar-Chiffre Falls ein Buchstabe im Klartext der N-te Buchstabe im Alphabet ist, so ersetze man ihn durch den (N+K)-ten Buchstaben im Alphabet. K ist eine feste Zahl. Beispiel: K=1 Klartext A T T A C K A T D AW N Chiffretext BUUBDLABUAEBXO Dies ist sehr einfach zu entschlüsseln, da der Analytiker nur alle 26 Möglichkeiten durchprobieren braucht. Verschlüsselungstabelle Besser ist es, eine Verschlüsselungstabelle zu benutzen. ABCDEFGHIJKLMNOPQRSTUVWXYZ THE QUICKBROWNFXJMPDVRLAZYG Obiges Beispiel wird dann wie folgt verschlüsselt: Klartext A T T A C K A T D A W N (Angriff in der Morgendämmerung) Chiffretext HVVH OTHVTQH AF Diese Methode verlangt vom Analytiker schon wesentlich mehr Aufwand . Er müßte ungefähr 1028 Tabellen ausprobieren. Vigenere-Chiffre Die Verallgemeinerung der Cäsar-Chiffre führt zur Vigenere-Chiffre. Ein kurzer, sich wiederholender Schlüssel wird benutzt, um den Wert von K für jeden Buchstaben neu zu bestimmen. Bei jedem Schritt wird der Index des Buchstabens im Schlüssel zum Index des Buchstabens im Klartext addiert, um den Index des Buchstabens im Chiffre-Text zu bestimmen. Schlüssel ABCABCABCABCAB Klartext A T T A C K A T D AW N Chiffre-Text B V W B E N A CW A F D X P Ist der Schlüssel genauso lang wie der Klartext spricht man von der Vernam-Chiffre (durch einmalige Überlagerung oder one time-pad), die als relativ sicher gilt, weil der Aufwand zur Entschlüsselung enorm ist. Ver- und Entschlüsselungsmaschinen Die Methode der einmaligen Überlagerung ist bei großen Datenmengen weniger geeignet. Gebraucht wird ein großer „Pseudo“- Schlüssel, der sich aus einem verteilten, kleinen Schlüssel ergibt. Die Vorgehensweise ist die , daß der Absender Kryptovariablen (echter Schlüssel) eingibt, die die Maschine benutzt, um eine lange Folge von Schlüssel-Bits (PseudoSchlüssel) zu erzeugen. Durch eine XOR-Verknüpfung dieser Bits mit dem Klartext wird ein Chiffre-Text erzeugt. Der Empfänger geht den umgekehrten Weg. Kryptovariablen werden durch Schlüsselgeneratoren erzeugt, deren Erzeugungsalgorithmus komplizierter als jener von Zufallsgeneratoren ist. Einführung, Algorithmen und Datenstrukturen 76 Im kommerziellen Bereich benutzt man Verfahren zur Schlüsselverteilung, die unter den Begriffen Kryptosysteme mit öffentlichen Schlüssel oder Public-Key-System geführt werden. Die Idee besteht in der Verwendung eines „Telefonbuchs“ mit Schlüsseln für die Verschlüsselung. Jede Person besitzt einen öffentlichen Schlüssel P und einen geheimen Schlüssel S. Um eine Botschaft M zu übermitteln, sucht der Absender den öffentlichen Schlüssel des Empfängers und benutzt ihn , um die Botschaft C= P(M) zu verschlüsseln. Der Empfänger verwendet seinen privaten Schlüssel für die Entschlüsselung der Botschaft. Damit dieses System funktioniert, müssen dies Bedingungen erfüllt sein: (i) (ii) (iii) (iv) S(P(M))=M für jede Botschaft von M Alle Paare (S,P) sind verschieden. Die Ableitung von S aus P ist ebenso schwer wie das Entschlüsseln von M ohne Kenntnis des Schlüssels S. Sowohl S als auch P lassen sich leicht berechnen. Dieses allgemeine Schema wurde 1976 von Diffie und Hellmann entwickelt. Eine Methode zur Umsetzung dieser Bedingungen wurde von Rivest, Shamir, Adleman entwickelt und ist unter dem Namen RSA-Kryptopsystem mit öffentlichen Schlüsseln bekannt. Dabei sind die Schlüssel P und S Paare ganzer Zahlen (N,p) und (N,s) mit z.B. N aus 200 Ziffern und p und s aus 100 Ziffern bestehend. Auf weitere Betrachtungen soll an dieser Stelle verzichtet werden. Einführung, Algorithmen und Datenstrukturen 77 13 Algorithmen für Graphen 13.1 Allgemeines In der Praxis kommt es häufig vor, daß sich Probleme auftun, die nur dadurch entstehen, daß bestimmte Verbindungen zwischen Objekten bestehen. Das einleuchtendste Beispiel sind Schaltelemente, die die Objekte Transistor, Widerstand und Kondensator in bestimmter Weise miteinander verbinden. Aber auch die Frage nach dem kürzesten Weg zwischen 2 Städten oder die Prozeßplanung in der Fertigung sind durch ihre Verbindungen zwischen den Objekten ( hier Städte, dort auszuführende Prozesse) geprägt. Mit dem mathematischen Objekt Graph kann man solche Situationen modellieren. Mathematisch betrachtet besteht ein gerichteter Graph (directed graph) aus 1. einer Menge von N Knoten (nodes) und 2. einer zweistelligen Relation A auf N. A ist die Menge der Kanten des gerichteten Graphen. 1 2 3 4 5 Abbildung 13.1-1 Beispiel eines gerichteten Graphen In Abb. 13.1-1 finden wir diese Mengen vor: N = {1,2,3,4,5} als Knoten und A={(1,1),(1,2),(1,3),(2,4),(3,1),(3,2),(3,5),(4,3),(4,5),(5,2)} als Kanten. Jedes Paar (u,v) in A wird durch einen Pfeil von u nach v repräsentiert. In Texten kann man das mit u v schreiben. u ist der Vorgänger, v der Nachfolger. Ein Bogen auf sich selbst wird als Schlinge bezeichnet. Knoten und Kanten können markiert werden. beißt 1 2 Hund Katze Abbildung 13.1-2 Markierter Graph mit 2 Knoten Ein Pfad in einem Graphen ist eine Liste von Knoten (v1,v2,...vk). Die Länge des Pfades beträgt k-1. Es gibt zyklische und azyklische Graphen. Ein Zyklus in einem Graphen ist ein Pfad , der an derselben Stelle beginnt, wo er auch endet. Das obige Beispiel beinhaltet mehrere Zyklen (1,1), (1,3,1), (2,4,5,2)... Einführung, Algorithmen und Datenstrukturen 78 Ein Pfad wird als azyklisch bezeichnet, wenn kein Knoten mehr als einmal darin erscheint. Manchmal ist es sinnvoll, die Knoten durch Linien zu verbinden, die keine Richtung vorgeben. Wir haben es mit einem ungerichteten Graph zu tun. Formal ist die Kante eine Menge aus 2 Knoten {u,v}, wobei diese in beiden Richtungen verbunden sind. Ein Graph ohne Zyklen ist ein Baum. In gewichteten Graphen werden den Kanten Gewichte (z.B. ganze Zahlen) beigefügt, die in irgendeiner Weise die Beziehung zwischen den Knoten verstärken. Gerichtete, gewichtete Graphen sind Netzwerke. 13.2 Darstellung von Graphen Es gibt zwei Standardverfahren, deren Anwendung davon abhängt, ob der Graph dicht (wenige der möglichen Kanten fehlen) oder licht (Graph mit relativ wenig Kanten) ist. Die Darstellung erfolgt als Adjazenzliste oder Adjazenzmatrix. Adjazent kommt aus dem Lateinischen und bedeutet soviel wie angrenzend. Die Darstellung vollzieht sich in mehreren Schritten. 1. Es werden die Namen der Knoten in ganze Zahlen zwischen 1 und v umgewandelt, damit diese dann über den Feldindex schnell erreichbar sind. Die Umwandlung kann über eine Hash-Tabelle mit der Funktion index erfolgen, umgekehrt soll die Funktion name ganze Zahlen in Knotennamen umwandeln. Im folgenden werden nur einzelne Buchstaben als Knotennamen benutzt, und der i-te Buchstabe im Alphabet entspricht der ganzen Zahl i. 2. Die einfachste Darstellungsweise für Graphen ist die Adjazenzmatrix. Es wird ein Feld der Größe v mal v aus boolschen Variablen geführt. Existiert eine Verbindung zwischen 2 Knoten, so wird das Feldelement mit 1 belegt, ansonsten mit 0. 3. Weiterhin ist es zweckmäßig anzunehmen, daß jeder Knoten zu sich selbst eine Kante hat, die mit 1 ausgewiesen wird. Wir sehen, daß wir eigentlich nur 2 Bits für die Feldelemente benötigen. Nach all diesen Vorbemerkungen noch einmal zusammenfassend: Ein Graph ist durch eine Menge von Knoten und eine Menge der sie verbindenden Kanten definiert. Zur Eingabe des Graphen muß man sich auf ein Format einigen. Es besteht die Möglichkeit, die Adjazenzmatrix selbst als Eingabeformat zu benutzen , das aber für lichte Graphen zu aufwendig ist. Als Alternative kann man so vorgehen, daß man zuerst die Namen der Knoten und sodann Paare von Knoten, die eine Kante bilden, einliest. (Anzahl der Knoten= V, Anzahl der Kanten= E) Die Vorgehensweise ist damit diese: Die Knotennamen werden in eine Hash-Tabelle oder einen binären Suchbaum eingelesen und jedem Knoten wird eine ganze Zahl zugewiesen. Dies wird benutzt, um auf knotenindizierte Felder von der Art der Adjazenzmatrix zuzugreifen. Dem i-ten Knoten wird über die Funktion index die Zahl i zugewiesen. Nach dem Einlesen von V und E wird die Matrix zu 0 initialisiert, anschließend wird die Diagonale mit 1 besetzt, um dann über das Einlesen der Knotenpaare die endgültige Belegung zu erreichen. Programmausschnitt zur Darstellung eines ungerichteten Graphen int V, E; int a [maxV][maxV]; void adjmatrix () { int j, x,y; cin >>V >>E; Einführung, Algorithmen und Datenstrukturen 79 for (x = 1; x <= V; x++) for (y = 1; y <= V; y++) a[x][y]=0; for (x = 1; x <= V; x++) a[x][x] = 1; for (j = 1; j <= E; j++) { cin >> v1 >> v2; /* Variablen müssen vorher deklariert werden!*/ x = index(v1); y = index (v2); a [x][y] =1; a [y][x] = 1; } } Die Darstellung mit Hilfe der Adjazenzmatrix ist nur dann effizient, wenn die zu verarbeitenden Graphen dicht sind. Die Matrix benötigt V² Bits Speicherplatz und zumindest V² Schritte zur Initialisierung mit 0. Bei einem lichten Graphen wird damit die Initialisierung zum dominierenden Anteil im Algorithmus. Für den Graph in Abb. 13.1 ergibt sich diese Matrix. Tabelle 13.2-1 Adjazenzmatrix eines gerichteten Graphen nach obiger Abbildung 1 2 3 4 5 1 1 0 1 0 0 2 1 0 1 0 1 3 1 0 0 1 0 4 0 1 0 0 0 5 0 0 1 1 0 Eine zweite Möglichkeit besteht darin, eine Adjazenzliste aufzubauen. - Dazu nutzen wir die Datenstruktur der verketteten Liste. - Programmausschnitt, ungerichteter Graph struct node { int v; struct node *next;}; int V,E; struct node *adj[maxV], *z; void adjlist () { int j,x,y; struct node *t; cin >>V >> E; z = new node; znext = z; for (j = 1; j <= V; j++) adj [j] = z; for (j = 1; j <= E; j++) { cin >>v1 >> v2; x = index (v1); y = index (v2); t = new node; tv = x; tnext = adj[y]; adj[y] = t; t = new node; t v = y; t next = adj[x]; adj[x] = t; Einführung, Algorithmen und Datenstrukturen 80 } } Haben wir gemäß Sedgewick Knoten von A bis M (V=13) und die Kanten AG AB AC LM JM JL JK ED FD HI FE AF und GE (E=13), so entstehen im Prinzip 13 Listen, siehe Tab.. 13. 2. Graphdarstellung für einen ungerichteten Graphen mit Hilfe einer Adjazenzstruktur, aus Listen bestehend ( Kopf- und Ende-Zeiger) A F C B G B A C A D F E E G F D F A E D H I J K L M G E A H I I H J K L M K J L J M M J L A F B C D E G Abbildung 13.2-1 Graph zur Adjazenzliste Diese Darstellung eignet sich für dünn besetzte Graphen, da nur O(V+E) Speicherplatz benötigt wird im Gegnsatz zu O(V²) im Falle der Adjazenzmatrix. Wir haben die Begriffe - gerichteter Graph, - ungerichteter Graph und - gewichteter Graph benutzt. Die Darstellung unterscheidet sich dadurch, daß ein gerichteter Graph einem ungerichteten mit der Belegung nur in einer Richtung entspricht. Bei einem gewichteten Einführung, Algorithmen und Datenstrukturen 81 Graphen werden die Bits 0 und 1 in der Adjazenzmatrix durch die Gewichte ersetzt bzw. in der Adjazenzliste muß in jedem Knoten ein zusätzliches Element für die Gewichte eingeführt werden. 13.3 Operationen auf Graphen Das Arbeiten auf Graphen wirft automatisch einige Fragen zur Beschaffenheit des Graphen auf: - Ist der Graph zusammenhängend? - Wenn nicht, welche sind seine zusammenhängenden Komponenten? - Enthält der Graph einen Zyklus? Diese und weitere Fragen soll uns die Methode der Tiefensuche liefern. Hierbei wird jeder Knoten besucht , um dann jede Kante abzuprüfen. 13.3.1 Tiefensuche Zunächst entwickeln wir eine Methode, mit der jeder Knoten systematisch betrachtet werden kann ,um dann die Ergebnisse derselben zu speichern. - Ein Feld val [V] wird benutzt, um die Reihenfolge des Besuchens der Knoten zu speichern. Am Anfang werden die Feldelemente auf unseen gesetzt. - Alle Knoten id = 1,2..V werden besucht und die id in das entsprechende Feldelement val[id] übernommen. - Funktion Tiefensuche void search () { int k; for (k= 1; k<= V; k++) val[k] = unseen; for (k = 1; k<= V; k++) if (val[k] == unseen) visit (k); } Die Funktion visit(k) besucht die Knoten und wird noch in Abhängigkeit vom Datenmodell zu spezifizieren sein. Für die Tiefensuche mittels Adjazenzliste sieht die Funktion wie folgt aus. void visit (int k) { struct node * t; val [k] = ++id; for ( t = adj [k]; t != z; t = tnext) if (val[tv] == unseen) visit (tv); } Die Wirkungsweise dieser rekursiven Funktion am Beispiel der großen Komponente des Graphen nach Abb.13.3 zeigt Abb. 13.4 (Sedgewick, Abb. 29.5). Man kann auch die Arbeitsweise als Tiefenbaum nachvollziehen (Abb. 13.5) Einführung, Algorithmen und Datenstrukturen 82 A F C H B J I K E G L M D Abbildung 13.3-1 Tiefensuch-Wald, Adjazenzliste Eigenschaft 13.1.: Für eine Tiefensuche in einem Graph, der mit Hilfe von Adjazenzlisten dargestellt ist, ist eine V+E proportionale Zeit erforderlich. Wählen wir die Darstellung mittels Adjazenzmatrix, so müssen wir die Funktion visit anpassen. void visit (int k) { int t; val [k] = ++id; for (t = 1; t <= V; t++) if ( a[k][t] != 0) if (val[t] == unseen) visit (t); } Im Unterschied zum Durchsuchen in den Listen wird jetzt zeilenweise durchsucht. Eigenschaft 13.2: Für die Tiefensuche in einem Graphen mit Hilfe einer Adjazenzmatrix ist eine V² proportinale Zeit erforderlich. A B C H F D E G I J K L M Einführung, Algorithmen und Datenstrukturen 83 Abbildung 13.3-2 Tiefensuch-Walg, Adjazenzmatrix Tiefensuche in einem Graph ist die Verallgemeinerung der Traversierung in einem Baum. Welche eingangs gestellten Fragen können wir nun mit dem Ergebnis der Tiefensuche beantworten? 1. Die Anzahl der zusammenhängenden Komponenten (im Beispiel = 3) ist gleich der Anzahl der Aufrufe von visit (). 2. Ein Zyklus ist enthalten, wenn in visit ein von unseen verschiedener Eintrag in val vorliegt. Als nichtrekursive Variante der Tiefensuche bietet sich an, die Ergebnisse der Suche in einem Stack abzulegen. Ersetzen wir den Stapel durch eine Warteschlange , so können wir zur Traversierung von Graphen die Breitensuche praktizieren. 13.3.2 Breitensuche Die Implementierung dieses Sachverhaltes kann so aussehen. Queue queue (maxV); void visit (int k) // Breitensuche , Adjazenzlisten { struct node *t; queue.put (k); while (!queue.empty () ) { k = queue.get (); val [k] = ++id; for (t = adj[k]; t != z; t = tnext) if ( val [tv] == unseen) { queue.put(tv); val [tv] = -1; } } } Nach diesem Algorithmus werden die Kanten in der Reihenfolge AF AC AB AG FA FE FD CA BA GE GA DF DE EG EF ED HI ICH JK JL JM KJ LJ LM MJ ML besucht. Aus dieser Reihenfolge kann aus den Kanten, die zum erstenmal zu einem Knoten führen, wiederum ein Baum aufgebaut werden. A F E C H B G I J K L M D Abbildung 13.3-3: Breitensuch-Wald Die Anwendung der dargelegten Verfahren ist zum Beispiel auf Labyrinthe bekannt. Einführung, Algorithmen und Datenstrukturen 84 13.4 Zweifacher Zusammenhang (biconnectivity) Tiefensuche im vorhergehenden Kapitel hat uns zusammenhängende Komponenten finden lassen. Nun können sich Graphen weiterhin dahingehend auszeichnen, daß es mehrere Wege (mindestens 2) zwischen 2 Knoten gibt. In diesem falle sagt man, daß der Graph zweifach zusammenhängend ist. Löscht man einen dieser Knoten mit all seinen Verbindungen , so bleibt der Graph trotzdem zusammenhängend.In diesem Kontext wird der Begriff Gelenkpunkt eingeführt. Ein Knoten hat die Eigenschaft eines Gelnkpunktes, wenn beim Entfernen dieses Knotens derselbe auseinanderbrechen würde. A H I B D C E G F J K L M Abbildung 13.4-1 ein nicht zweifach zusammenhängender Graph Gelenkpunkte des Graphen sind: A, H, J, G. Dies ist dadurch gegeben, daß beim Löschen dieser Punkte entweder der Zusammenhang zu den Knoten B oder I oder K verlorengeht bzw. der Graph in zwei Teile zerfällt. Bei manchen Graphen soll einfach festgestellt werden, ob es eine Verbindung zwischen Knoten x und y gibt. Diese Aufgabe hat Ähnlichkeit mit jener zur Prüfung der Zugehörigkeit von Elementen zu einer Menge. In diesem Zusammenhang entspricht eine Menge einer Äquivalenzklasse. Übertragen auf Graphen bedeutet das, daß zusammenhängende Komponenten einer Menge oder Äquivalenzklasse mit den Knoten als Objekte und den Kanten als Zugehörigkeitsinformation. Wird ein Graph durch weitere Kanten dynamisch erweitert , um diesen dann anschließend zu durchsuchen, dann benötigt man Algorithmen zur Vereinigungs-Suche. 13.5 Gewichtete Graphen Viele Probleme, die mit Graphen modelliert werden können, benötigen für ihre Aussagekraft noch bestimmte Gewichte an den Kanten. Spannt man z.B.einen Graphen zur Beschreibung von Städteverbindungen auf, so sind nicht nur die möglichen Verbindungen sondern auch die Länge der Strecken von Interesse. 200 HH B D 300 500 300 140 MD 450 250 650 500 180 M 400 DD Abbildung 13.5-1 ungerichteter, gewichteter Graph Welche Fragen können hierzu aufgeworfen werden? Einführung, Algorithmen und Datenstrukturen 85 1. Welcher ist der kürzeste Weg zwischen Dresden und Düsseldorf? – Problem des kürzesten Pfades! 2. Finde eine Möglichkeit alle Knoten zu verbinden, wobei die Länge ein Minimum ist. – Problem des minimalen Spannbaumes! Minimaler Spannbaum Definition: Ein minimaler Spannbaum eines gewichteten Graphen ist eine Menge von Kanten, die alle Knoten so verbindet, daß die Summe der Kantengewichte ein Minimum ist. Im obigen Beispiel könnten die fetten Kanten einen solchen minimalen Spannbaum bilden. Der Algorithmus verfolgt das Ziel, ausgehend von einem Knoten (D) den nahesten Nachbarn zu finden (HH). Nun sucht man den kürzesten Weg von einem der beiden markierten Knoten und findet (B), dann von diesen drei Knoten ausgehend (MD) , u.s.w. , bis alle Knoten einmal erreicht sind. Wie kann diese Strategie implementiert werden? Dadurch daß eine Prioritätswarteschlange benutzt wird. Man geht dabei so vor, daß eine Einteilung aller Knoten in drei Mengen vorgenommen wird: - Baumknoten: Beinhalten die bereits untersuchten Knoten. - Randknoten: Diese warten auf ihre Verarbeitung. - Unsichtbare Knoten: Diese wurden noch nicht berührt. Das Verfahren der Suche besteht darin, einen Knoten x vom Rand zum Baum zu bewegen, wobei die unsichtbaren Knoten in den Rand aufgenommen werden sollen. (siehe Abb. 31.1-31.4, Sedgewick) Ein alternatives Verfahren ist jenes nach J. Kruskal. Hierbei werden nacheinander die kürzesten Kanten zusammengefügt. Kürzester Pfad Definition: Der kürzeste Pfad zwischen 2 Knoten in einem gewichteten Graphen ist durch die minimale Summe aller Kanten, die auf dem Wege liegen, gekennzeichnet. Das heißt, es führen gegebenenfalls mehrere Wege von x nach y, jedoch der gesuchte Weg ist der kürzeste. Ist das Gewicht überall 1, dann wird der kürzeste Pfad durch die kleinste Menge der durchlaufenen Kanten beschrieben. Die Lösung liegt darin, daß für jeden Ausgangsknoten der minimale Spannbaum aufgebaut wird. (Abb. 31.10, Sedgewick) 13.6 Gerichtete Graphen Definition: Gerichtete Graphen sind Graphen, deren Kanten eine Richtung haben. Als praktisches Beispiel könnte man sich ein Straßennetz einer Stadt vorstellen, wobei alle Straßen Einbahnstraßen sind. Die vorgegebene Richtung kann u.a. auch eine Reihenfolgebeziehung modellieren. In einer Fertigung könnte so z.B. die Reihenfolge der Ausführung der Fertigungsschritte beschrieben werden. Probleme im Zusammenhang mit gerichteten Graphen sind: - die Tiefensuche - die Berechnung der transitiven Hülle - topologisches Sortieren - Berechnung streng zusammenhängender Komponenten. Einführung, Algorithmen und Datenstrukturen 86 Bei der Darstellung gerichteter Graphen wird die allgemeine Darstellung ungerichteter Graphen quasi eingeschränkt, indem in der Adjazenzliste jede Kante nur einmal erscheint und in der Adjazenzmatrix wird die Symmetrie aufgehoben. A B C D E H I J K L M G F Abbildung 13.6-1Ein gerichteter Graph Tiefensuche Für die Tiefensuche gilt der gleiche Algorithmus wie in ungerichteten Graphen. Als Suchbaum für obiges Beispiel entsteht jener der nachfolgenden Abbildung. Die durchgehenden Linien beschreiben die Kanten, die tatsächlich durchlaufen werden, um mittels rekursiver Aufrufe die Knoten zu besuchen. Die gestrichelten Kanten entsprechen jenen, die auf Knoten zeigen, die schon besucht worden sind. Die Reihenfolge des Aufsuchens ist A F E D B G J K L M C H I. A F H B G E D J K I C L M Abbildung 13.6-2 Tiefenwald-Suche für einen gerichteten Graphen Einführung, Algorithmen und Datenstrukturen 87 Ergänzender Anhang 6 Einführendes Beispiel in C++: Sieb des Eratosthenes Problem: Berechnung der Primzahlen Objektorientierter Entwurfspfad Analyse: Siebverfahren gehen von Zahlenmenge aus Zielmenge durch Herausfiltern unerwünschter Zahlen Algorithmus: 1 Man schreibe alle Zahlen von 1 bis n hin und streiche die Zahl 1. 2 Sei i die kleinste noch nicht durchgestrichene und nicht eingerahmte Zahl. Man rahme i ein und streiche alle Vielfachen von i. 3 Man wiederhole (2) solange, bis i2 > n. 4 Die eingerahmten und nicht durchgestrichenen Zahlen sind die Primzahlen von 1 bis n. Design: Sieb des Erathostenes ist Spezialisierung allgemeinen Siebverfahrens Natürliche Nachbildung durch Klassen sieve_of_Eratosthenes und subset_of_n Objekte der Klassenexemplare tauschen Nachrichten Struktur wiederverwendbar für andere Siebverfahren Höhere Transparenz gegenüber prozeduralem (Pascal-)Verfahren abstract set_of_N subset_of_N sieve sieve_of_Erathostenes Einführung, Algorithmen und Datenstrukturen subset_of_N 88 Sieve_of_Erathostenes get_min_unmarked mark_element delete_multiples display_elements sieve_primes Quelle: Klaus Quibeldey- Cirkel, Das Objekt- Paradigma in der Informatik, Teubner 1994 7 Datenmodelle, Datenstrukturen, Algorithmen Datenmodelle Abstraktionen zur Typisierung von Daten und zugehörigen Operationen ("Datentypen") Vordefinierte Datentypen sprachabhängig C++: Integerzahl, Gleitkommazahl, Zeichen, Feld, Verbund, Zeiger Lisp: Liste, in C++ erst durch extra Datenstruktur Datenmodelle beschreibbar a) durch Werte, die Modell annehmen kann ("Statischer Aspekt") z.B. int Werte in bestimmtem ganzzahligen Bereich b) durch erlaubte Operationen z.B. + - ("Dynamischer Aspekt") Selbstdefinierte Datenmodelle (z.B. Liste) verlangen selbstdefinierte Operationen (z.B. Liste erzeugen, Listenelement einfügen usw.) Datenstrukturen Abstraktionen zur Bildung zusammengesetzter Datenmodelle, die in Sprache nicht a priori bereitgestellt werden C++: Liste z.B. als Datenstruktur über Verkettung von Listenelementen aufzubauen Algorithmen sind präzise und eindeutige Spezifikationen einer Folge von Schritten, die mechanisch auf dem Rechner ausgeführt werden können Datenmodelle, Datenstrukturen in C++ (C) Elementare Datenmodelle über die Definition von Datentypen int ganze Zahl float, double Gleitkommazahl char Zeichen int [ ] Feld (Array) *int Zeiger (Pointer) struct Datensatz (Record) class Klasse als abstrakter Datentyp Abstrakte Datentypen Implementierungsdetails werden verborgen Einführung, Algorithmen und Datenstrukturen 89 Zugriff nur über auszuführende Operationen Liste z.B. durch Aufruf von insert und delete veränderbar Ergebnis ist das Einfügen oder Löschen eines Listenelementes Zugang nur über die definierte Schnittstelle als Funktionsaufruf Programmlösungen so überschaubarer benutzbar für Ableitung weiterer Typen Stapel z.B. auf verkettete Liste zurückführbar Konkrete Datentypen hier bevorzugt, um aus Lehrgründen Datenstrukturen und zugehörige Algorithmen offenzulegen Elementare Datenstrukturen Feld feste Anzahl von Einzel- Daten werden zusammenhängend gespeichert Zugriff erfolgt über Index Speicherzelle sollte vor Bezugnahme gefüllt sein Beispiel: Ausgabe der Primzahlen bis 1000 nach dem Algorithmus „Sieb des Eratosthenes“. # include <iostream> const int N = 1000; main( ) { int i, j, a[N+1]; for (a[1]=0; i=2; i<=N; i++) a[i]=1; for (i=2; i<=N/2; i++) for (j=2; j<=N/i; j++) a[i*j)]=0; for (i=1; i<=N; i++) if (a[i]) cout << i << ; cout << \n; } Aufgabe: Entwickeln Sie die Feldbelegung anhand einer Tabelle! Sequentieller Zugriff auf Feldelemente Feldgrenze ist vorher festzulegen Verkettete Listen Listen können wachsen und schrumpfen Größe nicht vorher festzulegen Höhere Flexibilität durch "Umhängen" der Elemente Aber: auf Kosten schnellen Zugriffs Einführung, Algorithmen und Datenstrukturen 90 Verkettete Liste ist Menge von Elementen, die (wie beim Feld) sequentiell geordnet ist. Im Feld: Sequentielle Anordnung durch Position (Index) des Feldelementes 1,2,3,... bestimmt. Bei Liste: Explizite Verkettung nötig, da Position ohne Bedeutung für Reihenfolge A L I S T Realisierung: Liste der Elemente ("Knoten") mit Listenkopf ("head") und Listenende ("tail") komplettiert Head zeigt auf 1. Element Tail zeigt auf sich selbst Head z A L I S T Modifikation durch Verschieben von Element T an den Anfang? Bei Liste drei Verkettungen ändern Bei Feld alle Elemente nach rechts um 1 verschieben Kopf z A L I S T T A L I S S T Kopf z Einfügen neuen Elementes Kopf X z A L A L I I X S T Einführung, Algorithmen und Datenstrukturen 91 Nur 2 Zeiger umhängen: von I auf X und von X auf S Entfernen von X Zeiger von I auf S umhängen Erzeugen neuer Liste struct node { int key; struct node *next;} struct node *head, *z; head = new node; z = new node; head - > next = z; z - > next = z; Konstrukt liefert "leere" Liste. Einfügen eines Knotens mit Schlüsselwert v hinter bestimmtem Knoten t: Erzeugen eines neuen Knotens x x = new node; Schlüsselwert v übertragen x - > key = v; Verkettung von t in den neuen Knoten kopieren x -> next = t -> next; Verkettung von t auf x ausrichten t ->next = x; Schritte zum Entfernen eines Knotens hinter dem Knoten t: Zeiger auf x richten x = t- > next; Zeiger von t auf Nachfolger von x setzen t -> next = x -> next; Knoten x löschen mit delete x Beispiel für Listenverarbeitung: „Problem des Josephus“ N Personen planen Massenselbstmord. Aufstellen im Kreis, Abzählen der M-ten Person #include <iostream.h> #include <stdlib.h> #include <string.h> #include <conio.h> struct node {int key; struct node *next; } ; main () { int i,N,M; struct node *t, *x; cin >> N >> M; t = new node; t ->key = 1; x = t; for (i=2; i<=N; i++) { t->next = new node; t = t->next; t->key = i; } Einführung, Algorithmen und Datenstrukturen 92 t->next=x; while (t != t->next) { for ( i=1; i<M; i++) t= t->next; cout << t->next->key << ' '; x=t->next; t->next=x->next; delete x; } cout << t->key << '\n'; getche (); } Beispiel: N=4 M=2 2431 Speicherzuweisung Verkettete Liste mittels Array darstellbar: Datenelemente im Feld key Verkettungen im Feld next ( Struktur) U S A L I T 4 1 7 5 6 3 1 key next Stapel Wichtigste Datenstruktur mit beschränktem Zugriff 2 Grundoperationen: Element auf Stapel ablegen (= am Anfang einfügen , push) Element vom Stapel entnehmen (pop) Zusätzlich Funktion empty für Test auf Leere Stapel (stack) als Feld in C++ als Klassenkonstrukt definieren: class Stack { Klassentyp private: itemType *stack; int p; Läßt Stacktyp offen! Einführung, Algorithmen und Datenstrukturen 93 public: Stack (int max = 100) { stack = new itemType[max]; p = 0; } ~Stack() { delete stack; } inline void push (itemType v) { stack [p++] = v; } inline itemType pop() { return stack [--p]; } inline int empty () { return !p; } Konstruktor Destruktor Funktionen }; Beispiel: Auswertung arithmetischer Ausdrücke Berechnung von 5*(((9 + 8) * (4 + 6)) + 7) in Infix- Notation. Überführung von Ausdruck in Postfix – Notation (umgekehrte polnische Notation ): 598+ 46**7+*. #include <iostream.h> #include <conio.h> class Stack private: int *stack; int p; public: Stack (int max = 100) { stack = new int [max]; p = 0; } ~Stack() { delete stack; } inline void push (int v) { stack int pop() { return stack [--p]; } inline int empty () { return !p; } }; main () { char c; Stack acc (50); int x; while (cin.get (c) { x = 0; while ( c == ) cin.get ( c ); if ( c == + ) x = acc.pop() + acc.pop(); if ( c == * ) x = acc.pop() * acc.pop(); while ( c >= 0 && c <= 9 ) Einführung, Algorithmen und Datenstrukturen 94 { x = 10 *x + ( c - 0); cin.get ( c ); } acc.push (x); cout<< x; } cout << acc.pop() << \n ; getche (); } Alternative: Stapel als verkettete Liste Beispiel: Ablegens eines Strings auf Stapel mit umgekehrter Ausgabe #ifndef __STACK_H #define __STACK_H class Stack { private: int *data; unsigned long anz; unsigned long maxanz; public: Stack(unsigned long); ~Stack(void); int Push(int); int Pop(void); unsigned long Size(void); }; #endif /* __STACK_H */ #include "stack.h" Stack::Stack(unsigned long s) { data=new(int[s]); if(data) { anz=0; maxanz=s; } else { anz=maxanz=0; } } Stack::~Stack(void) { if(data) delete[](data); } int Stack::Push(int w) { if(anz<maxanz) Einführung, Algorithmen und Datenstrukturen 95 { data[anz++]=w; return(1); } else { return(0); } } int Stack::Pop(void) { if(anz>0) return(data[--anz]); else return(0); } unsigned long Stack::Size(void) { return(anz); } #include <iostream.h> #include <string.h> #include "stack.h" void main(void) { const unsigned long SIZE=100; Stack stack(SIZE); char str[SIZE]; unsigned int x; cout << "Bitte String eingeben:"; cin.getline(str,SIZE); for(x=0;x<strlen(str);x++) stack.Push(str[x]); cout << "\n"; while(stack.Size()) cout << (char)(stack.Pop()); cout << "\n";} Ergebnis: G Bitte String eingeben: VORLESUNG Stapel: LIFO! GNUSELROV Stapel N U S E L R O V Einführung, Algorithmen und Datenstrukturen 96 Schlange Schlange als abstrakter Datentyp übernimmt die neuen Elemente am Kopf und gibt sie am Ende wieder aus (Firstin-Firstout, FIFO) Schlange G N U S Schlangenbearbeitung void Queue :: put (itemType v) { queue [tail++] = v; if (tail > size ) tail =0; } itemType Queue :: get () { itemType t = queue [head++]; if (head > size) head = 0; return t; } int Queue :: empty () { return head == tail; } put fügt ein Element am Schwanz get holt Element vom Kopf ab Obiges Beispiel als Schlange : #ifndef __QUEUE_H #define __QUEUE_H class Queue { private: int *data; unsigned long anz; unsigned long maxanz; unsigned long inpos, outpos; public: Queue(unsigned long); ~Queue(void); int Enqueue(int); int Dequeue(void); unsigned long Size(void); }; #endif /* __QUEUE_H */ #include "queue.h" Queue::Queue(unsigned long s) { E L R O V V Einführung, Algorithmen und Datenstrukturen 97 data=new(int[s]); if(data) { anz=inpos=outpos=0; maxanz=s; } else { anz=maxanz=inpos=outpos=0; } } Queue::~Queue(void) { if(data) delete[](data); } int Queue::Enqueue(int w) { if(anz<maxanz) { anz++; data[inpos++]=w; if(inpos==maxanz) inpos=0; return(1); } else { return(0); } } int Queue::Dequeue(void) { if(anz>0) { unsigned long aktpos=outpos; if((++outpos)==maxanz) outpos=0; anz--; return(data[aktpos]); } else return(0); } unsigned long Queue::Size(void) { return(anz); } Einführung, Algorithmen und Datenstrukturen 98 #include <iostream.h> #include <string.h> #include "queue.h" # include < conio.h> void main(void) { const unsigned long SIZE=100; Queue queue(SIZE); char str[SIZE]; unsigned int x; cout << "Bitte String eingeben:"; cin.getline(str,SIZE); for(x=0;x<strlen(str);x++) queue.Enqueue(str[x]); cout << "\n"; while(queue.Size()) cout << (char)(queue.Dequeue()); cout << "\n"; getche(); } Direkte und indirekte Adressierung innerhalb einer Liste Direkte Adressierung: Position (=Zeiger) liefert Lage des Records direkt Sentinel (Tail, Stopper) als zusätzliches Listenelement am Ende pos Sentinel a1 l ai-1 ai+1 ai First () Previous (pos) Next (pos) an NULL End() Verbesserung: l zeigt auf Sentinel Erst Sentinel zeigt auf 1. Knoten Sentinel l a1 Indirekte Adressierung: ai-1 ai ai+1 an Einführung, Algorithmen und Datenstrukturen 99 Position zeigt auf Vorgänger von gewünschtem Record Listenkopf (Header), nicht Sentinel als zusätzliches Listenelement am Anfang Indir. Adr. von Rec. ai+1 Listenkopf ai-1 l ai Record mit key x ai+1 First ( ) Previous (pos) Find(x) Next (pos) an End( ) Elementfunktionen der Klasse "List" (Vergleich direkte - indirekte Adressierung ) // Konstruktor, kreiert leere Liste template<class TR, class TK> List<TR,TK>::List() { TR r; l=new ListNode<TR,TK>(r); } // Sentinel // Konstruktor: kreiert leere zirkulare Liste mit Listenkopf template<class TR, class TK> List<TR,TK>::List() { TR r; // leerer Record l=new ListNode<TR,TK>(r); // Listenkopf l->next=l; // Liste mit sich selbst verketten } // liefert Position auf ersten Listenrecord template<class TR, class TK> LPosition<TR,TK> List<TR,TK>::First() { Einführung, Algorithmen und Datenstrukturen 100 LPosition<TR,TK> pos; pos.p=l; return pos; } // liefert indirekte Position des ersten Records der Liste template<class TR, class TK> LPosition<TR,TK> List<TR,TK>::First() { LPosition<TR,TK> pos; pos.p=l->next; return pos; } // liefert Position des Listenendes (Sentinel) template<class TR, class TK> LPosition<TR,TK> List<TR,TK>::End() { LPosition<TR,TK> pos; // Initialwert von pos ist NULL ListNode<TR,TK> *q=l; // Beginn am Listenanfang while (q->next) // Listenende? q=q->next; // Vorrücken in Liste pos.p=q; // Ergebnis in pos return pos; } // liefert indirekte Position des Listenendes template<class TR, class TK> LPosition<TR,TK> List<TR,TK>::End() { LPosition<TR,TK> pos; pos.p=l; return pos; } // Position des pos folgenden Records template <class TR, class TK> LPosition<TR,TK> List<TR,TK>::Next(LPosition<TR,TK> pos) { LPosition<TR,TK> pos1; if (pos.p) pos1.p=pos.p->next; return pos1; } // liefert indirekte Position des pos folgenden Records template <class TR, class TK> LPosition<TR,TK> List<TR,TK>::Next(LPosition<TR,TK> pos) { LPosition<TR,TK> pos1; // Initialwert von pos1 ist NULL if (pos.p) pos1.p=pos.p->next; return pos1; Einführung, Algorithmen und Datenstrukturen 101 } // Record in r vor Position pos einfügen // liefert die Position von r zurück. template <class TR,class TK> void List<TR,TK>::Insert(TR &r,LPosition<TR,TK> &pos) { LPosition<TR,TK> pos1; ListNode<TR,TK> *q=new ListNode<TR,TK>(r); if (!pos.p) return; // pos nicht vorhanden pos1=Previous(pos); // pos1 zeigt auf Vorgänger if (!pos1.p) // am Anfang einfügen { q->next=l; l=q; } else { q->next=pos1.p->next; pos1.p->next=q; } } // fügt r vor dem durch pos indirekt adressierten Record ein template <class TR,class TK> void List<TR,TK>::Insert(TR &r,LPosition<TR,TK> pos) { if (pos.p) { ListNode<TR,TK> *q=new ListNode<TR,TK>(r); // kreiert neuen Listenrecord q->next=pos.p->next; // nach hinten verketten pos.p->next=q; // nach vorn verketten if (pos.p==l) l=q; // wenn am Listenende eingefügt } } // Record an der Position pos entfernen template <class TR, class TK> void List<TR,TK>::Delete(LPosition<TR,TK> &pos) { LPosition<TR,TK> pos1; if (pos.p) { if (pos.p!=l) { pos1=Previous(pos); pos1.p->next=pos.p->next; Einführung, Algorithmen und Datenstrukturen 102 } else l=pos.p->next; // Record am Anfang entfernen ListNode<TR,TK> *q=pos.p; pos.p=pos.p->next; // Vorrücken auf nächsten Knoten delete q; // Speicher freigeben } } 8 Bäume Stapel und Schlangen sind eindimensionale Strukturen: Je Element der Struktur ("Objekt") höchstens ein Nachfolger/ Vorgänger möglich. Bäume sind zweidimensionale ("nichtlineare") Strukturen: Je Element der Struktur höchstens ein Vorgänger, aber mehrere Nachfolger möglich ("1:k-Beziehung" zwischen einem Vorgänger und k Nachfolgern) Vorgänger ist Nachfolger vor- gesetzt ("Hierarchische Beziehung"), grafische Nachbildung: Graph aus "Knoten" und "Kanten" mit Vorgängerknoten oberhalb von Nachfolgerknoten Beispiele: Familienstammbaum Inhaltsverzeichnis Klassifizierungssystem E A A R S T M P Grundbegriffe Knoten E ist ein Element einer Struktur, das einen Namen hat, bestimmte Informationen trägt, durch Kreis/ Kästchen beschreibbar ist L E Einführung, Algorithmen und Datenstrukturen 103 Kante beschreibt Beziehungen in einer Struktur die Knoten verbinden als Verbindungslinie beschreibbar sind Wurzel - Knoten ohne Vorgänger (hier: E) Innerer Knoten - Knoten ohne Vorgänger- und Nachfolgerknoten Äußerer Knoten - Knoten ohne Nachfolger ("Blatt") Pfad - Liste von Knoten r1, r2, ... rk mit ri als Vorgänger von ri+1 (Weg) (z. B. RTL) Länge des Wegs = Wegknotenanzahl - 1 Baumstruktur: Spezielle Struktur mit einem ausgewählten Knoten ohne Vorgänger ("Wurzel") für alle anderen Knoten mit genau einem Vorgänger mit genau einem Pfad von der Wurzel zu beliebigem Knoten r ("Weg des Knoten r") Rekursive Definition: Baum kann sein leere Menge einzelner Knoten Verknüpfung eines einzelnem Knoten mit k (Teil-) Bäumen durch k Kanten zu deren Wurzeln r1, r2, ... rk Wald - Vereinigung von Bäumen Grad des Knotens - Anzahl seiner Nachfolger Grad des Baums (Ordnung) - ist höchster Grad k aller im Baum vorkommenden Knoten ("k- ärer Baum", für k=2: "binärer Baum") - ist für alle Knoten gleich k, wenn fehlende Nachfolger durch leere Bäume ersetzt werden Baum hat Ordnung k, wenn jeder Knoten höchstens k Nachfolger und mindestens ein Knoten k Nachfolger hat (k-ärer Baum) Einführung, Algorithmen und Datenstrukturen 104 Stufe (Ebene) - Knoten liegt auf Stufe n, wenn er (von der Wurzel aus als n- ter Knoten erreicht wird (Wurzel auf Stufe0) Höhe - gleich größter Stufe n eines Baums Baum mit n Knoten - hat n -1 Kanten maximale Höhe n -1 minimale Höhe [log2 n] ADT Binärer Baum Binärer Baum- Baum der Ordnung 2 Umwandlung k-ärer Baum binärer Baum linker Ast im binären Baum = linker Nachfolger im k-ären Baum rechter Ast im binären Baum = Knoten im k-ären Baum auf gleicher Höhe a b e f a c b d g h e c d f g Vollständiger binärer Baum- Knotenpositionen auf niederen Stufen alle besetzt (Ausnahme: höchste Stufe) Erweiterter binärer Baum - 0 oder 2 Teilbäume je Knoten Speicherung - verkettet über Zeiger - sequentiell in Feld a b 0e c 0 0 0 0 f d Traversierung - Durchlaufen eines binären Baums nach bestimmter Regel Traversieren in Präordnung (Preorder): 1. Besuche Wurzel 2. Traversiere linken Teilbaum in Präordnung 3. Traversiere rechten Teilbaum in Präordnung Traversieren in symmetrischer Ordnung (inorder): 1. Traversiere linken Teilbaum in symm. Ordnung 2. Besuche Wurzel h Einführung, Algorithmen und Datenstrukturen 105 3. Traversiere rechten Teilbaum in symm. Ordnung Traversieren in Postordnung (Postorder): 1. Traversiere linken Teilbaum in Postordnung 2. Traversiere rechten Teilbaum in Postordnung 3. Besuche Wurzel ADT Binärer Baum //Klasse "BinaryTree" #ifndef BAUM.H #define BAUM.H #include <typen.h> #include <fstream.h> typedef long Position; template <class TR> class TPosition { public: TPosition() { p=NULL; } boolean TPos() { return (p) ? true : false; } boolean TPos(TPosition pos) { return (pos.p==p) ? true : false; } friend class BinaryTree<TR>; private: TreeNode<TR> *p; }; template <class TR> class TreeNode { public: TreeNode(TR r) : TRvalue(r),left(NULL),right(NULL) {} // Konstruktor TR TRvalue; // Wert vom Typ TR TreeNode *left, // Zeiger auf linken Teilbaum *right; // Zeiger auf rechten Teilbaum void SetSuccessors(TreeNode<TR> *l,TreeNode<TR> *r); }; template <class TR> class BinaryTree { public: BinaryTree(); // kreiert binären Baum ~BinaryTree(); // Destruktor TPosition<TR> Root(); // liefert Position der Wurzel des Baums TPosition<TR> Left(TPosition<TR> pos); Einführung, Algorithmen und Datenstrukturen 106 // liefert Position des linken Teilbaums des Knotens an der Position pos TPosition<TR> Right(TPosition<TR> pos); // liefert Position des rechten Teilbaums des Knotens an der Position pos void InsertRoot(TR r); // fügt Record in r als Wurzel des Baums ein void InsertLeft(TR r,TPosition<TR> pos); // fügt Record r als linken Teilbaum in Knoten Position pos ein void InsertRight(TR r,TPosition<TR> pos); // fügt Record r als rechten Teilbaum in Knoten an Position pos ein TPosition<TR> DeleteNode(TPosition<TR> pos); // entfernt Knoten an der Position pos void DeleteTree(); // löscht den Baum TR GetNode(TPosition<TR> pos); // liefert Record vom Typ TR an der Position pos void SetNode(TR &r, TPosition<TR> pos); // ersetzt Record an Position pos durch Record in r void PreOrder(TPosition<TR> pos); // Traversieren in Präordnung void InOrder(TPosition<TR> pos); // Traversieren in symmetrischer Ordnung void PostOrder(TPosition<TR> pos); // Traversieren in Postordnung unsigned NumNodes(TPosition<TR> pos); // Anzahl der Knoten im Baum void Visit(TR r); // Ausgabe des Buchstaben void InfixExpr(TPosition<TR> pos); // Ausgabe des Ausdrucks in einem Syntaxbaum in Infix-Notation boolean BuildTree(char *c); // Erstellung eines binären Syntaxbaums für einen Ausdruck in Postfixnotation private: TreeNode<TR> *t; // Zeiger auf Wurzel void DelTree(TPosition<TR> pos); }; #endif //Elementfunktionen der Klasse "BinaryTree" #include <\kap06\baum.h> #include <iostream.h> // Konstruktor: generiert einen leeren Binärbaum template <class TR> BinaryTree<TR>::BinaryTree() : t(NULL) { } // gibt Baum frei template <class TR> BinaryTree<TR>::~BinaryTree() { if (t) DeleteTree(); } // liefert Position der Wurzel des Baums template <class TR> Einführung, Algorithmen und Datenstrukturen 107 TPosition<TR> BinaryTree<TR>::Root( ) { TPosition<TR> pos; pos.p=t; return pos; } // liefert für Knoten pos die Position des linken Teilbaums template <class TR> TPosition<TR> BinaryTree<TR>::Left(TPosition<TR> pos) { TPosition<TR> pos1; if (pos.p) pos1.p=pos.p->left; return pos1; } // liefert für Knoten pos die Position des rechten Teilbaums template <class TR> TPosition<TR> BinaryTree<TR>::Right(TPosition<TR> pos) { TPosition<TR> pos1; if (pos.p) pos1.p=pos.p->right; return pos1; } // fügt Record in r als Wurzel des Baums ein template <class TR> void BinaryTree<TR>::InsertRoot(TR r) { if (!t) t=new TreeNode<TR>(r); // Knoten mit Record r } // fügt Record r als linken Teilbaum in Knoten p ein template <class TR> void BinaryTree<TR>::InsertLeft(TR r,TPosition<TR> pos) { if (t==NULL) return; TreeNode<TR> *q=new TreeNode<TR>(r); // Knoten mit Record r q->right=pos.p->left; pos.p->left=q; } // fügt Record r als rechten Teilbaum in Knoten p ein template <class TR> void BinaryTree<TR>::InsertRight(TR r,TPosition<TR> pos) { if (t==NULL) return; TreeNode<TR> *q=new TreeNode<TR>(r); // Knoten mit Record r q->right=pos.p->right; pos.p->right=q; } // löscht alle Knoten im Baum - nach Postordnung Einführung, Algorithmen und Datenstrukturen 108 template <class TR> void BinaryTree<TR>::DeleteTree() { DelTree(Root()); t=NULL; } template <class TR> void BinaryTree<TR>::DelTree(TPosition<TR> pos) { if (pos.TPos()) { DelTree(Left(pos)); DelTree(Right(pos)); delete pos.p; } } // liefert Record an der Position pos template <class TR> TR BinaryTree<TR>::GetNode(TPosition<TR> pos) { TR r; if (pos.p) return pos.p->TRvalue; else return r; } // ersetzt Record an der Position pos durch Record r template <class TR> void BinaryTree<TR>::SetNode(TR &r, TPosition<TR> pos) { if (pos.p) pos.p->TRvalue=r; } // Traversieren in Präordnung template <class TR> void BinaryTree<TR>::PreOrder(TPosition<TR> pos) { if (pos.TPos()) { Visit(GetNode(pos)); PreOrder(Left(pos)); PreOrder(Right(pos)); } } // Traversieren in symmetrischer Ordnung template <class TR> void BinaryTree<TR>::InOrder(TPosition<TR> pos) { if (pos.TPos()) { InOrder(Left(pos)); Einführung, Algorithmen und Datenstrukturen 109 Visit(GetNode(pos)); InOrder(Right(pos)); } } // Traversieren in Postordnung template <class TR> void BinaryTree<TR>::PostOrder(TPosition<TR> pos) { if (pos.TPos()) { PostOrder(Left(pos)); PostOrder(Right(pos)); Visit(GetNode(pos)); } } // Anzahl der Knoten im Baum template <class TR> unsigned BinaryTree<TR>::NumNodes(TPosition<TR> pos) { if (pos.TPos()) return NumNodes(Left(pos))+NumNodes(Right(pos))+1; else return 0; } Einführung, Algorithmen und Datenstrukturen 110 Beispiel: Traversierung in Präordnung traverse (struct node *t) { if (t != z) { visit (t) traverse(t->l); traverse(t->r); traverse (struct node *t) { l: if (t == z) visit (t) traverse(t->l); t = t -> r; x: ; } Transformationsschritte "End- Rekursionvermeidung" durch Sprunganweisung goto l zum Programmanfang (mit neuer Marke l:) } Standardschritte für UP- Aufruf: Werte lokaler Variabler (hier: t) und Rückkehradresse in Stack eintragen (hier: entfällt, da fest = r) Aktuelle Parameter für UP setzen Springen zu UP- Anfang goto x; goto l; traverse (struct node *t) { l: if (t == z) visit (t) stack.push (t); t = t -> l; r: t = t->r; s: if (stack.empty()) t= stack.pop(); x: } Standardschritte für Rückkehr von UP: Werte lokaler Variabler (hier:t) und Rückkehradresse (hier: entfällt, da bekannt = r) aus Stack holen Lokale Werte zurücksetzen Springen zu Rückkehradresse goto s; goto l; goto l; goto x; goto r; ; } traverse (struct node *t) { l: while (t != z) { visit (t) stack.push (t); t = t -> l; if (stack.empty()) t= stack.pop(); x: ; Ersetzen von durch While-Schleife Beseitigen von r: durch Verschieben der Zeile nach goto r t= t->r gleich Stapelentnahme goto l; } goto x; goto l; } Einführung, Algorithmen und Datenstrukturen 111 traverse (struct node *t) { stack.push (t); while (!stack.empty() ); { t= stack.pop(); while (t != z) { visit (t) stack.push (t->r); t = t -> l; traverse (struct node *t) { stack.push (t); while (!stack.empty() ); { t= stack.pop(); if (t != z) { visit (t) stack.push (t->r); stack.push (t->r); 9 goto- Eliminierung durch zweite whileSchleife mit t- Ablage im Stack beim ersten Aufruf von traverse } } } Nach weiteren Vereinfachungen erreichbare Endform Große Ähnlichkeit mit rekursiver Ausgangsform Großer Unterschied: Ausführbar auf jedem Rechner! } } } Rekursion Rekursion - Mathematisches Prinzip zur Definition von Mengen Basis: Nichtrekursive Definition primitiver Objekte Rekursionsteil: Rekursive Vorschrift zur Einführung neuer Objekte Menge besteht nur aus Objekten, die nach dieser Vorschrift entstehen Beispiel: Einführung natürlicher Zahlen Basis: 1 ist natürliche Zahl Rekursionsteil: WENN x natürliche Zahl, DANN auch x+1 natürliche Zahl def(1) = 1 def(n) = def(n-1) + 1 für n>1 def(3) = def(2) + 1= def(1) +1 +1= 1 +1 +1 Alternative zur Wiederholung von Programm durch Iteration: Wiederholter "Aufruf" desselben Programms für einfachere Teilaufgabe bis zu Basisfall Danach schrittweise "Rückkehr" in dasselbe Programm als aufrufendes Programm hinter die Aufrufstelle //Beispiel: Transformation einer natürlichen Zahl x in die Folge ihrer Ziffernzeichen #include <iostream.h> void main() { int x=1234,i=0; cout << "Transformation der natürlichen Zahl 1234 in die Folge " Einführung, Algorithmen und Datenstrukturen 112 << "\nihrer Ziffernzeichen\n"; cout << "=====================================================\n\n"; char d[10]; do { d[i]=x%10+'0'; x/=10; i++; } while (x>0); do { i--; cout.put(d[i]); } while (i>0); cout << "\n\nE n d e"; } Beispiel: Rekursions- Auflösung einer Preorder- Traversierung A A pb pc tr z = zero = NULL trav = traverse B C D E B z pd C z D z z E z z pe z Einführung, Algorithmen und Datenstrukturen 113 (pa) visit trav trav (pb) visit trav trav (pc) visit trav trav (pd) visit trav trav (pe) visit trav trav (z ) visit trav trav (Anweisungen-) Raum A B D C E Zeit Wiederholter Aufruf desselben Programms durch Absprung in eigenständige Einführung, Algorithmen und Datenstrukturen 114 Programmkopie unakzeptabel Code eines rekursiven Programms wird nur einmal („reentrant“) geladen bei allen Aufrufen gemeinsam verwendet Je rekursivem Aufruf („Aktivivierung“) wird Kopie lokaler Variabler (und interner Register) angelegt und im Stack abgelegt Aktuelle Programmausführung wird zwar unterbrochen, kann bei Rückkehr jedoch durch Wiederholen abgelegter Daten „normal“ fortgesetzt werden A pa B pb pa Z D B Z Z pa pd pa pa D -pc C A pe pc E Z E pc Z -C Zeit Rekursion erfordert Zusatzaufwände für Aktivierungen und Umschaltvorgänge Einführung, Algorithmen und Datenstrukturen 115 Einsatz zweckmäßig bei rekursiven Aufgabenstellungen (z.B. Türme von Hanoi) Viele Aufgaben durch Rekursion elegant und knapp beschreibbar, aber ... effektiver durch Iteration lösbar Beispiel: Folge des Fibonacci Fn = Fn-1 + Fn-2 , F0 =F1= 1 1,1,2,3,5,8,13,21,..... Anzahl der Aktivierungen für Berechnung von Fn beträgt Fn = n mit =1.1618...(Verhältnis des „Goldenen Schnitts“) Rekursion erfordert damit exponentiell wachsenden Aufwand (O(2n) Iteration kommt mit linearem Aufwand O(n) aus Beispiel: Konvertierung einer Integerzahl in Folge von druckfähigen Ziffern (ASCIICode) Gegebene Zahl: x = d n-1 * 10 n-1 + d n-2 * 10 n-2 + .....+ d 1 * 10 + d 0 Gewünschte Zeichenfolge: ´d n-1´ ,´d n-2´ ,´d 1´ ,´d 0´ Lösung: (1) Abspaltung letzter Ziffer durch x modulo 10 (2) x= x/10 (3) Springen nach (1), solange x > 0 Ausgabe muß verzögert werden, da Zeichendruck von links nach rechts Rekursive Definition: Zifferntransformation Basis: x < 10 Gib das Ziffernzeichen x aus Rekursionsteil: x 10 Wende Zifferntransformation auf x/10 an Beispiel: Ausgabe von 135 Aktivierung 1 2 3 2 1 Operation x if (x>=10) digits (13) 135 if (x>=10) digits (1) 13 put (1) put (3) put (5) Ausgabe 1 13 135 Lösung mittels Iteration: #include <iostream.h> void main() { int x=135,i=0; cout << "Transformation der natürlichen Zahl 135 in die Folge " << "\nihrer Ziffernzeichen\n"; cout << "===================================\n\n"; char d[10]; 1 3 5 Einführung, Algorithmen und Datenstrukturen do { d[i]=x%10+'0'; x/=10; i++; while (x>0); do { i--; cout.put(d[i]); } while (i>0); cout << "\n\nE n d e"; 116 } } Lösung mittels Rekursion: void digits (unsigned int x); { if (x>=10) digits (x/10); cout.put (x%10 + ´0´); } Divide-and-Conquer-Algorithmen "Teile-und -Herrsche"- Strategie als nützliche Anwendung von Rekursion Zerlegung einer Aufgabe in kleinere Teilaufgaben gleichen Typs Berechnungsschema: Falls nur wenige Daten: Löse Problem direkt. Sonst: Divide-Schritt: Zerlege Datenmenge in 2 (möglichst gleiche) Teilmengen Conquer-Schritt: Löse Problem für jede der Teilmengen Merge-Schritt: Berechne aus Teillösungen die Gesamtlösung Anwendungen: Sortieren, Suchen // Vertauschen der beiden Records a und b template <class TR, class TK> void Table<TR,TK>::Swap(TR &a, TR &b) { TR t=a; a=b; b=t; } Einführung, Algorithmen und Datenstrukturen 117 9. Sortieren 9.1 Einführung Sortierproblem: Umordnen von Datensätzen (records) aus Dateien (files) nach wachsender (fallender) Ordnung ihrer Schlüssel (key) Beispiel: (4,A), (2,X), (1,A), (3,B) (1,A), (2,X),(3,B),(4,A) Key- Wertebereich = {1,2,3,....} (i, j) Nutzdaten- Wertebereich = {A,B,....Z} Key UserData Ordnung: Festlegung i.a. durch numerische und alphabetische ("lexikografische") Reihenfolge (1 < 2 < 3....; A < B < C <....; XA < XB < XC ......) Schlüssel: Teilbereich eines Datensatzes zu dessen eindeutiger Identifizierung, z.B. Artikelnummer als positive ganze Nummer Identifikator- Eigenschaft: Schlüssel aller Datensätze des Files müssen sich unterscheiden (d.h. 2 beliebig gewählte Datensätze dürfen nie gleichen Wert haben) Bedeutung der Sortierung: Verbesserte Informationsverarbeitung durch erleichtertes Auffinden (Nachweisen) von Datensätzen bei bekanntem Schlüssel vereinfachte Auswertung geordneter Datenbestände Beispiel: Telefonbuch (Meier A., 1234)< (Meier O., 1005)< (Schulze P., 1002) Sortierschlüssel: Gleichwertiger Teil jedes Datensatzes eines Files als Vergleichs- Basis Beispiel: (4,A), (2,X), (1,A), (3,B) (4,A),(1,A),( 3,B), (2,X) "Stabile" Sortierung: bei Wertegleichheit bleibt Reihenfolge der Sätze nicht verändert Beispiel: (4,A) vor (1,A) bleibt erhalten Tabelle: Sonderfall von Datensätzen ("Tabellenknoten") gleicher Länge und gleichen Aufbaus ( Anhang: ADT Tabelle) Bedeutung von Tabellen: Einfachste Erfassung der Records a[1], a[2], .... a[n] als Array Tabelle (= "Relation") ist Basis relationaler Datenbanken Tabellensortierung: Umordnung ("Permutation") der Records nach (Sortier-) Schlüssel a[p1], a[p2], .... ,a[pn] mit a[p1].key a[p2].key .... a[pn].key (pi - Index) Einteilung nach Trägermedium: Unterscheidung bei Sortierverfahren zwischen intern (im Hauptspeicher) extern (auf Magnetband, Magnetplatte) 9.1.1 Elementare Sortierverfahren Einfache und leichtverständliche Verfahren (hier o.B.d.A. demonstriert für Tabellen) Unterscheidung zwischen direktem Sortieren (Records wechseln Ort) durch Einfügen, Auswählen, Austauschen indirektem Sortieren (Records bleiben am Ort, Sortierung der Indizies) Immer ausreichend bei Recordanzahl n < 100, bei seltenem Einsatz auch für n >> 100 Laufzeit ("Komplexität"): O (n2) Einführung, Algorithmen und Datenstrukturen 118 Prinzip: Zerlegung einer Tabelle in 2 Teile (links sortiert, rechts unsortiert), die sich mit jedem Durchlauf i verändert mit Vergrößerung linken Teils um 1 Datensatz Verminderung rechten Teil um 1 Datensatz a[0], a[1], ...,a[i-1] a[i], a[i+1],..... ,a[n] sortiert unsortiert 9.1.2 Schnelle Sortieralgorithmen Beschleunigung der Suche für große n Nach Theorie bestmögliche Laufzeit, die für Verfahren mit Schlüsselvergleich möglich: O(n *log n) Bekannte Vertreter: QuickSort, MergeSort, Heapsort 9.2 Elementare Algorithmen 9.2.1 Selection Sort (Direktes Auswählen) Prinzip: Je Durchlauf aktuell kleinstes Element der unsortierten rechten Teilmenge bestimmen Austausch mit erstem Element der unsortierten rechten Menge Beispiel: Satzmenge mit Schlüsselfolge <6 ,7, 1, 5, 9, 4, 2, 8> 1. Durchlauf (i=1) liefert kleinstes Element 1 Austausch der Sätze mit Schlüssel 1 und 6 i 0 1 2 3 4 5 6 7 8 k 0 6 1 1 1 1 1 1 1 1 1 7 7 2 2 2 2 2 2 2 2 1 6 6 4 4 4 4 4 4 3 5 5 5 5 5 5 5 5 5 4 9 9 9 9 9 6 6 6 6 5 4 4 4 6 6 9 7 7 7 Implementierung: // Sortieren durch Auswählen template <class TR, class TK> void Table<TR,TK>::SelectionSort() { int i,j,imin; for (i=0; i<n-1; i++) { imin=i; for (j=i+1; j<n; j++) if (KeyCmp(a[j].key,a[imin].key)<0) imin=j; Swap(a[i],a[imin]); // vertauscht die beiden Records 6 2 2 7 7 7 7 7 7 8 } 7 8 8 8 8 8 8 8 8 9 } Einführung, Algorithmen und Datenstrukturen 119 // Zeichenketten vergleichen mittels gemeinsamer "Hilfsfunktion" KeyCmp template <class TR, class TK> int Table<TR,TK>::KeyCmp(TK key1,TK key2) { switch (keychar) { case false: if (key1==key2) return 0; if (key1>key2)return 1; else return -1; case true: char * k1=(char*) key1,*k2=(char *) key2; int i=0; while (k1[i]==k2[i]) { if (k1[i]=='\0') return 0; i++; if (k1[i]<k2[i]) return -1; else return 1; return 0; } } } // Vertauschen der beiden Records a und b template <class TR, class TK> void Table<TR,TK>::Swap(TR &a, TR &b) { TR t=a; a=b; b=t; } 9.2.2 Insertion Sort (Direktes Einfügen) Prinzip: Je Durchlauf erstes Element der unsortierten rechten Teilmenge nehmen Einfügen in sortierter linker Teilmenge an passender Stelle unsortierte Restmenge rechts bleibt unverändert 6 6 6 1 1 1 1 1 1 7 7 7 6 5 5 4 2 2 1 1 1 7 6 6 5 4 4 5 5 5 5 7 7 6 5 5 9 9 9 9 9 9 7 6 6 4 4 4 4 4 4 9 7 7 2 2 2 2 2 2 2 9 8 8 8 8 8 8 8 8 8 9 Einführung, Algorithmen und Datenstrukturen 120 Imlementierung: // Direktes Sortieren durch Einfügen template <class TR, class TK> void Table<TR,TK>::InsertionSort() { int i,j; for (i=1; i<n; i++) { a[n]=a[i]; j=i; while (j>0 && KeyCmp(a[n].key,a[j-1].key)<0) { a[j]=a[j-1]; j--; a[j]=a[n]; } } } 9.2.3 Bubble Sort (Direktes Austauschen) Prinzip: Je Durchlauf Vertauschen benachbarter Elemente in Position (n-1,n), ....,(1,2), (0,1) Kleinstes Element kommt links an Wiederholung solange, bis keine Vertauschungen mehr 6 1 7 1 6 1 1 7 5 2 2 9 2 5 4 2 9 2 2 4 8 8 = Explizite Vertauschung 1 1 1 1 1 1 1 1 6 2 2 2 2 2 2 2 7 6 4 4 4 4 4 4 2 7 6 5 5 5 5 5 5 4 7 6 6 6 6 6 9 5 5 7 7 7 7 7 4 9 8 8 8 8 8 8 8 8 9 9 9 9 9 9 Einführung, Algorithmen und Datenstrukturen 121 Implementierung: // Sortieren durch Austauschen template <class TR, class TK> void Table<TR,TK>::ExchangeSort() { int j; for (int i=0; i<n-1; i++) { for (j=n-2; j>=i; j--) if (KeyCmp(a[j].key,a[j+1].key)>0) { a[n]=a[j+1]; a[j+1]=a[j]; a[j]=a[n]; } } } 9.2.4 Indirektes Sortieren Prinzip: Sortieren der Indizies k bei unveränderter Lage der Datensätze im Array a Nachteil ist zusätzlicher Platz für Index- Array s k 0 a[k].key 6 0 s[k] 1 7 1 2 1 2 3 5 3 4 9 4 5 4 5 6 2 6 7 8 7 k 0 a[k].key 6 s[k] 2 1 7 6 2 1 5 3 5 3 4 9 0 5 4 1 6 2 7 7 8 4 Implementierung: // Indirekte Sortierung template <class TR> void IndirectSort ( TR *a, int *s, int n) { int i,j,imin,rj; for (i=0; i<n; i++) s[i] = i; for (i=0; j<n-1; i++) { imin = s[i] ; rj = i; for (j = i+1; j<n; j++) { if ( a[s[j]].GetKey < a[imin].GetKey)>0) { imin = s[j] ; rj = j; } } s[rj] = s[i]; s[i] = imin Erweiterung: Shell Sort } } Einführung, Algorithmen und Datenstrukturen 122 9.3 Schnelle Sortieralgorithmen 9.3.1 Quicksort Verfahren von C. A. R. Hoare (1961) nach Divide- and- Conquer- Strategie Prinzip: Zerlegung beliebiger Menge in zwei Teilmengen so, daß Schlüssel eines beliebigen Records der linken kleiner Teilmenge immer kleiner gleich Schlüssel eines beliebigen Records der rechten Teilmenge Weg: Zerlegung einer Menge a[l] a[l+1] ... a[r] (l- linker Rand, r- rechter Rand) durch Auswahl irgendeines zugehörigen Vergleichsrecords a[m] ("Pivot-Record") z.B. in Tabellenmitte 3= [7/2 ]) Austausch von Elementen so, daß durch systematischen Vergleich mit Pivotelement je 2 Elemente in "falscher" Lage ausgetauscht werden Anfangszustand k 0 1 a[k].key 2 8 l 2 3 3 6 m 4 5 5 4 6 7 7 9 r 7 6 Pivotelement a[m] mit a[r] vertauschen: a[3] a[7] a[k].key 2 8 3 9 5 4 Vergleich von a[k].key (k =l, l+1,...bzw. k=r,r-1,...) mit a[7].key = 6 in Pfeilrichtung a[1] = 8 (> 6) und a[5] = 4 (< 6) liegen auf "falscher" Seite Erster Austausch a[k].key 4 3 9 5 8 7 6 3 5 9 8 7 6 5 6 8 7 9 Zweiter Austausch a[k].key 2 2 4 Dritter und letzter Austausch [k].key 2 4 3 Implementierung: // Folge in zwei Teilfolgen zerlegen, Ergebnis: Index des Pivots template <class TR, class TK> int Table<TR,TK>::Partition(int l, int r) { int i=l-1,j=r; Einführung, Algorithmen und Datenstrukturen 123 Mean(l,r); while (i<j) { while (KeyCmp(a[++i].key,a[r].key)<0); // i Durchlauf von links her, i wird vor // dem Vergleich inkrementiert while (KeyCmp(a[--j].key,a[r].key)>0) // j Durchlauf von // rechts her, j wird vor dem Vergleich dekrementiert if (j==i) break; // Stopp links if (i<j) Swap(a[i],a[j]); // Elementaustausch } Swap(a[r],a[i]); // Pivot in richtige Position return i; } // Pivotelement bestimmen und ans rechte Ende bringen template <class TR, class TK> void Table<TR,TK>::Mean(int l, int r) { int m=(l+r)/2; Swap(a[m],a[r]); } // Sortieren der Tabelle mit Quicksort template <class TR, class TK> void Table<TR,TK>::QuickSortTable(int l, int r) { int m; m=Partition(l,r); if (l<m-1) QuickSortTable(l,m-1); if (m+1<r) QuickSortTable(m+1,r); } Pivotisierung durch Median- Nutzung besser: Unter a[l], a[m], a[r] wird Record mit mittlerem Schlüsselwert ausgewählt als Pivot-Record Leistung im mittleren Fall nur 40% schlechter als Optimum ABER: im ungünstigsten Fall, wenn Pivot- Element immer kleinstes (größtes) Element ist, gilt O(n2) // Pivotelement gleich Median template <class TR, class TK> void Table<TR,TK>::Median(int l, int r) { int m=(l+r)/2; if (KeyCmp(a[l].key,a[r].key)<0) { if (KeyCmp(a[m].key,a[l].key)<0) Swap(a[r],a[l]); else if (KeyCmp(a[m].key,a[r].key)<0) Swap(a[m],a[r]); } else { if (KeyCmp(a[m].key,a[r].key)<0) return; if (KeyCmp(a[m].key,a[l].key)>0) Swap(a[r],a[l]); else Swap(a[m],a[r]); } return; } Einführung, Algorithmen und Datenstrukturen 124 Anhang: ADT Table //Typendatei für Klasse "Table" #ifndef TYPEN.H #define TYPEN.H #include <table.h> #include <string.h> #define N 50 enum boolean {false,true}; class Node { public: Node(); Node(unsigned s, char *str); // Konstruktor boolean operator != (Node &r); unsigned GetKey() { return key; } void SetKey(unsigned s) { key=s; } void SetInfo(char * str) { strcpy(info,str); } friend class Table<Node,unsigned>; friend ostream & operator << (ostream & os, Node &r); private: unsigned key; char info[N]; }; Node::Node() : key(0) { info[0]='\0'; } Node::Node(unsigned s, char *str) : key(s) { if (strlen(str) < N) strcpy(info,str); else { for (int i=0; i<N-1; i++) info[i]=str[i]; info[N-1]='\0'; } } Einführung, Algorithmen und Datenstrukturen 125 boolean Node::operator != (Node &r) { if (key!=r.key) return true; if (strcmp(info,r.info)) return true; else return false; } ostream & operator << (ostream & os, Node &r) { os << endl << r.key << " " << r.info; return os; } //Weitere Elementfunktionen der Klasse "Table" #include <table.h> #include <typen.h> #include <assert.h> // kreiert leere Tabelle für maximal m Records template <class TR, class TK> Table<TR,TK>::Table(unsigned m,boolean b) : max(m),keychar(b) { a=new TR[m+1]; assert(a); n=iterator=0; direkt=false; s=NULL; for (int i=0; i<m; i++) a[i].key=0; } // gibt von der Tabelle belegten Speicherbereich frei template <class TR, class TK> Table<TR,TK>::~Table() { delete [] a; } // Tabelle um m zusätzliche Records erweitern template <class TR, class TK> void Table<TR,TK>::Reorg(unsigned m) { TR *b =new TR[max+m+1]; assert(b); max+=m; for (int i=0; i<n; i++) b[i]=a[i]; for ( ; i<max; i++) b[i].key=0; delete [] a; // gibt den Speicher des Arrays a frei a=b; Einführung, Algorithmen und Datenstrukturen 126 } // fügt neuen Record am Ende ein template <class TR, class TK> boolean Table<TR,TK>::Append(TR r) { if (n<max) { a[n]=r; n++; return true; } else return false; } // true, wenn Tabelle leer, sonst false template <class TR, class TK> boolean Table<TR,TK>::IsEmpty() { if (n==0)return true; else return false ; } // true, wenn Tabelle voll, sonst false template <class TR, class TK> boolean Table<TR,TK>::IsFull() { if (n==max) return true; else return false; } // liefert aktuelle Länge der Tabelle template <class TR, class TK> unsigned Table<TR,TK>::Length() { return n; } // erster Record wird zum aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::First() { if (n>0) { iterator=0; return true; } iterator=-1; return false; } Einführung, Algorithmen und Datenstrukturen 127 // nächster Record wird zum aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::Next() { if (n>0 && iterator>=0 && iterator<n-1) { iterator++; return true; } iterator=-1; return false; } // vorhergehender Record wird zum aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::Previous() { if (iterator>0) { iterator--; return true; } iterator=-1; return false; } // Record mit Schlüssel x wird zum aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::Find(TK &x) { if (IsEmpty()) { iterator=-1; return false; } for (iterator=0; iterator<n; iterator++) if (!KeyCmp(a[iterator].key,x)) return true; iterator=-1; return false; } / Einführung, Algorithmen und Datenstrukturen 128 / erster Record mit Schlüssel größer oder gleich x wird zum aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::FindFirst(TK &x) { if (IsEmpty()) { iterator=-1; return false; } for (iterator=0; iterator<n; iterator++) if (KeyCmp(a[iterator].key,x)>=0) return true; iterator=-1; return false; } // fügt neuen Record vor dem aktuellen Record ein template <class TR, class TK> boolean Table<TR,TK>::Insert(TR &r) { if (iterator>=0 && n<max) { for (int i=n-1; i>=iterator; i--) a[i+1]=a[i]; // umspeichern einschließlich Iterator-Index a[iterator]=r; n++; // Anzahl Records aktualisieren iterator++; // Iterator zeigt auf den alten Record return true; } else return false; } // löscht aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::Delete() { if (iterator<0) return false; for (int i=iterator; i<n; i++) a[i]=a[i+1]; n--; iterator=-1; return true; } Einführung, Algorithmen und Datenstrukturen 129 // liefert aktuellen Record template <class TR, class TK> boolean Table<TR,TK>::GetNode(TR &r) { if (iterator>=0) { r=a[iterator]; return true; } return false; } // ersetzt aktuellen Record durch Record r template <class TR, class TK> boolean Table<TR,TK>::SetNode(TR &r) { if (iterator>=0) { a[iterator]=r; return true; } return false; } // Such-Methoden // sequentielle Suche nach Record mit Schlüssel x template <class TR, class TK> boolean Table<TR,TK>::SeqSearch(TK &x) { iterator=0; a[n].key=x; while (KeyCmp(a[iterator].key,x)) iterator++; if (iterator<n) return true; else { iterator=-1; return false; } } // binäre Suche nach Record mit Schlüssel x template <class TR, class TK> boolean Table<TR,TK>::BinSearch(TK &x) { int l=0, r=n-1, m; while (l<r) { m=(l+r)/2; if (KeyCmp(a[m].key,x) < 0) l=m+1; else r=m; Einführung, Algorithmen und Datenstrukturen 130 } if (KeyCmp(a[l].key,x)==0) { iterator=l; return true; } else { iterator=-1; return false; } } // MergeSort rekursiv anwenden template <class TR, class TK> void Table<TR,TK>::MergeSort(int l, int r) { int m; if (r>l) { m=(l+r)/2; // Folge halbieren MergeSort(l,m); // 1. Teilfolge rekursiv Sortieren MergeSort(m+1,r); // 2. Teilfolge rekursiv Sortieren Merge(l,m,r); // sortierte Teilfolgen mischen } } // Sortieren durch Austauschen: Abbruch, wenn kein Austauschen mehr vorkommt template <class TR, class TK> void Table<TR,TK>::ExchangeSort1() { int i=0,j; do { a[n]=a[n-1]; for (j=n-2; j>=i; j--) if (KeyCmp(a[j].key,a[j+1].key)>0) { a[n]=a[j+1]; a[j+1]=a[j]; a[j]=a[n]; } i++; } while (a[n].key!=a[n-1].key); } // Sortieren durch Austauschen template <class TR, class TK> void Table<TR,TK>::ExchangeSort2() { int i=0,j,t; do { a[n]=a[n-1]; for (j=n-2; j>=i; j--) if (KeyCmp(a[j].key,a[j+1].key)>0) { a[n]=a[j+1]; a[j+1]=a[j]; a[j]=a[n]; Einführung, Algorithmen und Datenstrukturen 131 t=j; } i=t; } while (a[n]!=a[n-1]); } Sortierung nach Quicksort (Fortsetzung): 1. Durchlauf: Pivot = 6 für Index 2 = [(0 + 7)/ 2 ] ("Mean") 2 2 8 8 3 3 6 9 5 5 4 4 7 7 9 6 2 4 3 5 6 8 7 9 2. Durchlauf (links): Pivot =4 für Index 2= [(0 +3)/2] 2 4 3 5 2 5 3 4 2 3 5 4 2 3 4 5 3. Durchlauf (links): Pivot =2 für Index 0 =[ (0 + 1)/ 2 ] 2 3 3 2 2 3 Teilfolge links ist sortiert Vorteil: Laufzeit O(n * log n) in average (falls zufällig gewähltes Pivot- Element: nur 40% unter Optimum) 2 Nachteil: O(n ) in worst case (falls Pivot- Element jedes Mal kleinstes oder größtes Element der Teilmenge eine der Teilmengen immer leer n (statt mittlerer [log 2 n] ) Zerlegungen in Teilsätze nötig n 2 Vergleichsund Austauschoperationen bei sortierten Mengen nicht Außenelement als Pivot-Element wählen) Schlechte Laufzeiten für kleine n in der Praxis 10. 3. 2 MergeSort Alternative Divide- and- Conquer- Methode zu Quicksort Zerlegung in zwei etwa gleich große Teilfolgen (divide) Sortieren jeder Teilfolge nach MergeSort- Verfahren (conquer) Mischen (merge) Mischen: Verschmelzen von 2 oder mehr in sich geordneten Teilfolgen zu geordneter Gesamtfolge ("Zweiwegemischen", "Mehrwegemischen") Prinzip: Bei jedem Durchlauf werden Paarungen benachbarter vorsortierter Teilfolgen gemischt Beim n. Durchlauf (n=1,2,..) enthalten beide Teilfolgen 2*n geordnete Elemente : 1, 2, 4,.. Ergebnis sind Teilfolgen der Länge 2 * (n+1), abweichende Länge höchstens in letzter Einführung, Algorithmen und Datenstrukturen 132 Teilfolge 1. Durchlauf 6 6 7 7 1 0 0 1 9 4 4 9 2 2 8 8 5 3 3 5 7 1 0 6 1 7 9 2 4 4 2 8 8 9 5 3 3 5 1 1 6 2 7 4 2 6 4 7 8 8 9 9 3 3 5 5 1 1 2 2 6 4 7 5 8 6 9 7 3 8 5 9 2. Durchlauf 6 0 3. Durchlauf 0 0 4. Durchlauf 0 0 4 3 Nachteil: Gleich großer Zusatzspeicher für Zwischenspeicherung nötig Vorteil: Laufzeit T(n) = O(n * log n ) in allen Fällen / Benachbarte sortierte Teilfolgen mischen template <class TR, class TK> void Table<TR,TK>::Merge(int l, int m, int r) { int i=l,j=m+1,k=0; // i Laufvariable 1.Folge, j 2.Folge TR *b=new TR[r-l+1]; // Hilfstabelle b while (i<=m && j<=r) { // beide Teilfolgen noch nicht komplett durchlaufen if (KeyCmp(a[i].key,a[j].key)<=0) b[k]=a[i++]; else b[k]=a[j++]; k++; } if (i>m) for ( ; j<=r; j++) b[k++]=a[j]; // restliche Elemente a[j] übertragen else for ( ; i<=m; i++) b[k++]=a[i]; // restliche Elemente a[i] übertragen k=0; for (i=l; i<=r; i++) a[i]=b[k++]; // Übertragen aus Hilfstabelle in Ausgangstabelle delete b; } // MergeSort rekursiv anwenden template <class TR, class TK> void Table<TR,TK>::MergeSort(int l, int r) { int m; if (r>l) { m=(l+r)/2; // Folge halbieren MergeSort(l,m); // 1. Teilfolge rekursiv Sortieren Einführung, Algorithmen und Datenstrukturen 133 MergeSort(m+1,r); // 2. Teilfolge rekursiv Sortieren Merge(l,m,r); // sortierte Teilfolgen mischen } } 10.4 Prioritätswarteschlangen Anwendungsfeld: Sequentielle Verarbeitung von Datensätzen nicht unbedingt in sortierter Form nicht notwendig auf einmal Beispiel: Sammlung von Datensätzen Verarbeitung des Datensatzes mit größtem Schlüssel Sammlung von Datensätzen Verarbeitung des nächstgrößten , usw. Problem: Datenstruktur, die Einfügen neuen Elements und Löschen größten Elements unterstützt? Benennung: Prioritätswarteschlange (priority queue) wegen Ähnlichkeit mit Schlangen und Stapeln Zielstellung: Aufbau einer Datenstruktur, die Datensätze mit numerischen Schlüsseln (Prioritäten) enthält und folgende Operationen unterstützt: - Aufbau einer Prioritätswarteschlange aus N gegebenen Elementen (construct) - Einfügen eines neuen Elements (insert) - Entfernen des größten Elements (remove) - Ersetzen des größten Elements durch ein neues Element (replace) - Verändern der Priorität eines Elements (change) - Löschen eines beliebigen Elements (delete) - Zusammenfügen von 2 Prioritätswarteschlangen (join). Implementation: Ungeordnete Liste, wobei die Elemente in einem Feld a[1]... a[N] aufbewahrt werden, ohne die Schlüssel zu beachten. (a[0] und a[N+1] sind für bestimmte Marken reserviert.) Class PQ { private : itemType *a; int N; Einführung, Algorithmen und Datenstrukturen 134 public: PQ (int max) { a = new itemType[max]; N = 0;} ~PQ ( ) { delete a;} void insert (itemType v) {a[++N] = v; } itemType remove ( ) { int j; max = 1; for (j = 2; j <= N; j ++) if ( a[j] > a[max] ) max = j; swap (a,max,N); return a[N--]; } } Alternative: Verkettete Listen Heap: Datenstruktur, die vollständig binären Baum so in ein Feld speichert, daß jeder Knoten (=Schlüssel) auf Position j größer als beide Schlüssel auf NachfolgerPositionen 2j oder 2j+1 ist ("Heap-Bedingung") Heap als vollständiger Baum 1X 2T 3O 4G 8A 5S 9E 10 R 6M 7N 11 A 12 I Heap als Feld k 1 2 3 4 5 6 7 8 9 10 11 12 a[k] X T O G S M N A E R A I Algorithmen operieren auf Pfaden von Blättern zu Wurzeln und umgekehrt Auf Heap mit N Knoten sind etwa lg N Knoten. Operationswarteschlangen sind daher mit Heaps in logarithmischer Zeit ausführbar (außer join) Algorithmen auf Heaps Zunächst Strukturänderung, die Heapbedingung verletzt Dann anschließende Wiederherstellung Insert –Algorithmus Beispiel: Einfügen von P Einführung, Algorithmen und Datenstrukturen 135 X T P G A S E R O A I N M P wird am Ende eingefügt (jetzige Stelle von M) Vergleich mit Vorgänger M und Tausch, da P>M Vergleich mit Vorgänger O und wiederum Heapbedingung gilt wieder Implementation void PQ:: upheap (int k) {itemType v; v = a[k]; a[0] = itemMax; while (a[k/2]<= v) { a[k] = a[k/2]; k = k/2; } a[k] = v; } void PQ :: insert(itemType v) { a[++N] = v; upheap (N);} Replace-Algorithmus Ersetzen von Wurzel durch neuen Schlüssel ( z.B. X aus Vorgängerbeispiel durch C ) Heapordnung wiederherstellen Beispiel: Einfügen von C für das größte Element T S P G A R E C O A I N M Remove – Algorithmus Letztes Element M in Wurzel Heapordnung wiederherstellen Beispiel: Entfernen größten Elements T (aus Vorgängerbeispiel) S R G P M O N Einführung, Algorithmen und Datenstrukturen A E C 136 A I Funktion downheap void PQ :: downheap(int k) { int j; itemType v; v = a[k]; while (k <= N/2) { j = k + k; if (j<N && a[j]<a[j+1]) j++; if (v >= a[j] ) break; a[k] = a[j]; k = j; } a[k] = v; } itemType PQ:: remove() { itemType v = a[1]; a[1] = a[N--]; downheap (1); return v; } itemType PQ:: replace (itemType v) { a[0] = v; downheap ( 0 ); return ( 0 ); } Eigenschaft: Alle grundlegenden Operationen insert, remove, replace, delete, und change erfordern weniger als 2 lg N Vergleiche, wenn sie für einen Heap mit N Elementen ausgeführt werden. Heapsort Methode sortiert M Elemente in M lg M Schritten ohne zusätzlichen Speicherplatz und unabhängig von Eingabedaten Idee: Heap aufbauen Elemente in richtige Reihenfolge entfernen. void heapsort (itemType a[ ], int N) { int i; PQ heap (N); for ( i = 1; i <= N; i++) heap.insert ( a[i]); for ( i = N; i >= 1; i--) a[i] = heap. Remove(); } Einführung, Algorithmen und Datenstrukturen 137