Einführung in die Informatik, Algorithmen und Datenstrukturen

Werbung
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-berechenbarEs 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; znext = 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;
tv = x; tnext = 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 = tnext)
if (val[tv] == unseen) visit (tv);
}
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 = tnext)
if ( val [tv] == unseen)
{ queue.put(tv); val [tv] = -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
Herunterladen