• Keine Ergebnisse gefunden

Programmiertechniken (NF)

N/A
N/A
Protected

Academic year: 2022

Aktie "Programmiertechniken (NF)"

Copied!
105
0
0

Wird geladen.... (Jetzt Volltext ansehen)

Volltext

(1)

Vorlesungsmitschrift

Programmiertechniken (NF)

Priv.-Doz. Dr. Frank Huch

Institut f¨ur Informatik, Technische Fakult¨at der Christian Albrechts-Universit¨at zu Kiel

Version vom 28. Juni 2021

(2)

Inhaltsverzeichnis

I. Einleitung 4

II. Datenstrukturen und Algorithmen 5

1. Sortieren und Laufzeitanalysen 5

1.1. Sortieren durch Ausw¨ahlen . . . 5

1.2. Sortieren durch Einf¨ugen . . . 8

1.3. Die Fibonacci-Funktion . . . 10

1.4. O-Notation . . . 12

1.5. Suchen von Elementen . . . 12

1.5.1. Bin¨are Suche . . . 14

1.6. Effizientes Sortieren . . . 16

1.7. Andere effiziente Sortieralgorithmen . . . 19

2. Suchb¨aume 19 2.1. Motivation . . . 19

2.2. Bin¨arb¨aume . . . 20

2.3. Definition Suchb¨aume . . . 20

2.4. Suchen . . . 21

2.5. Einf¨ugen . . . 22

2.6. L¨oschen . . . 23

2.7. Implementierungen . . . 24

2.8. Suchb¨aume als verschachtelte Arrays . . . 25

3. Verzeigerte Datenstrukturen 29 3.1. Stack. . . 30

3.2. Implementierungen . . . 31

3.3. Queue . . . 32

3.4. Implementierungen . . . 33

3.5. Mutierende und nicht-mutierende Operationen . . . 34

3.6. Nicht-mutierender Stack . . . 36

3.7. Nicht-mutierende Queue . . . 38

3.8. Nicht-mutierender Suchbaum . . . 39

3.9. Alternative Array Implementierung . . . 41

3.10. Nicht-mutierende Implementierung von Array-B¨aumen . . . 45

3.11. Rekursion und Iteration . . . 46

4. Objektorientierte Datenmodellierung 48 4.1. Einfache Klassen und Objekte. . . 48

4.1.1. Immutable Klasse – Br¨uche . . . 48

4.1.2. Klasse mit ver¨anderbarem Zustand – Ticketautomat . . . 52

(3)

4.2. Vererbung . . . 53

4.3. Mehrfachvererbung . . . 57

4.4. Statische Attribute und statische Methoden . . . 59

5. Abstrakter Datentyp 60 5.1. Abstrakter Datentyp f¨ur ein Paar . . . 62

5.2. Abstrakter Datentyp f¨ur eine Menge (Set) . . . 62

5.3. Vorteile von Abstrakten Datentypen . . . 64

5.4. Abstrakter Datentyp vs. Klasse . . . 65

5.5. Funktionen als Daten . . . 66

5.6. Charakteristische Funktionen . . . 67

5.7. Implementierung des ADT Menge durch charakteristische Funktionen . . . 68

5.8. Darstellung eines Key-Value-Stores durch charakteristische Funktionen . . . 71

6. List-Comprehensions 71 7. Graphen 73 7.1. Speicherung des Graphen . . . 74

7.1.1. Speicherung des Graphen als Liste von Kanten und Liste von Knoten 75 7.1.2. Speicherung des Graphen als Adjazenzmatrix . . . 75

7.1.3. Speicherung des Graphen als Adjazenzliste . . . 76

7.2. Algorithmen zum Arbeiten mit Graphen . . . 78

7.2.1. Tiefensuche . . . 78

7.2.2. Breitensuche . . . 79

7.3. Suchen von k¨urzesten Wegen mit dem Dijkstra-Algorithmus . . . 80

7.3.1. Beispiel . . . 81

7.3.2. Implementierung. . . 83

8. Backtracking 83 8.1. Euler-Pfad in einem Graphen . . . 84

8.2. Acht-Damen-Problem . . . 86

III. Nebenl¨ aufigkeit 91

9. Threads 91 9.1. Probleme von Nebenl¨aufigkeit. . . 92

9.2. Threads in Python. . . 94

9.3. Synchronisation . . . 96

9.4. Dinierende Philosophen . . . 98

9.5. Deadlockvermeidung. . . 100

(4)

Teil I.

Einleitung

Aufbauend auf der Vorlesung Informatik I (2F/NF) werden wir in diesem Modul die Pro- grammierkenntnisse vertiefen. Hierbei werden wir insbesondere komplexere Datenstruktu- ren zur Speicherung von Daten kennenlernen, die es erm¨oglichen Daten sowohl effizient nachzuschlagen oder zu aktualisieren. Außerdem lernen wir Abstraktionskonzepte kennen, welche es uns erm¨oglichen, Code besser zu strukturieren und so wartbarer zu machen.

Ein weiteres Thema werden spezielle Algorithmen f¨ur komplexere Probleme, wie die Suche nach k¨urzesten Wegen oder das Parsen von Texten sein. Als Abschluss werden wir uns noch mit Nebenl¨aufigkeit besch¨aftigen. Diese findet in vielen reaktiven System Anwendung und bringt wichtige, aus der sequentiellen Programmierung nicht bekannte Problemstellungen mit sich.

Dieses Skript auf einer ¨alteren Version, welche im Sommersemester 2019 von Thora Fiedler zur Vorlesung Programmiertechniken erstellt wurde. Hierf¨ur noch einmal einen herzlichen Dank an Thora, die die erste Version dieses Skript damals mit viel Liebe geschrieben hat.

(5)

Teil II.

Datenstrukturen und Algorithmen

Bevor wir uns mit effizienten Datenstrukturen zum Speichern so genannter Schl¨ussel-Wert- Paare (key-value-stores) besch¨aftigen, wollen wir noch einmal die Algorithmen zum Sor- tieren aus Informatik I (2F/NF) wiederholen und an ihnen noch einmal die wesentlichen Ideen zur Laufzeitanalyse betrachten.

Im folgenden werden wir h¨aufig Listen aus Python verwenden, da Python keine Arrays, wie die meisten anderen imperativen Programmiersprachen als Basisdatentyp zur Verf¨ugung stellt. Listen in Python unterscheiden sich von Arrays dadurch, dass man sie effizient, ins- besondere mit der Methode append erweitern kann. Eine solche Operation ist bei normalen Array-Implementierungen nicht oder nur sehr ineffizient m¨oglich. Deshalb werden wir die- se Operation im folgenden bei der Realisierung unserer Algorithmen nicht verwenden. Im wesentlichen verwenden wir nur die Operationen zum Lesen und Schreiben von Werten an einer bestimmten Position in der Liste. Diese Operationen haben sowohl f¨ur Python-Listen als auch f¨ur Arrays in der Regel eine konstante, sehr effiziente Laufzeit.

1. Sortieren und Laufzeitanalysen

Ein interessantes Problem ist es eine große Anzahl von Werten zu sortieren. Funktionen bzw. Methoden zum Sortieren stellen alle g¨angigen Programmiersprachen zur Verf¨ugung.

Dennoch ist es sinnvoll sich einige Sortieralgorithmen genauer an zu schauen und hierbei auch deren Laufzeitverhalten zu untersuchen.

1.1. Sortieren durch Ausw¨ ahlen

1. L¨osungMinsort (oder Selectionsort)

Die Grundlegende Idee von Minsort ist es jeweils das kleinste Element im unsortierten Teil der Liste zu suchen und dies in den vorderen Teil der Liste zu packen.

Um diesen Algorithmus zu implementieren wandeln wir zun¨achst einen Algorithmus zur Bestimmung des Minimums einer Liste so ab, dass nicht das Minimum, sondern seine Position zur¨uckgeliefert wird. Zus¨atzlich wird das Minimum nur ab einer vorgegebenen Position gesucht:

d e f m i n p o s f r o m ( l , p o s ) : m i n p o s = p o s

f o r i i n range( p o s +1 ,l e n( l ) ) : i f l [ i ] < l [ m i n p o s ] :

(6)

m i n p o s = i r e t u r n m i n p o s

hierbei ist l die Liste, in der wir die Position des Minimums suchen und pos die Position, ab der gesucht werden soll. Dann liefert min pos from ([2,7,5,4,8],1) 3

Diese Funktion k¨onnen wir nun verwenden um zu sortieren. Wir laufen einmal durch die gesamte Liste und suchen jeweils die Position des Minimums ab der aktuellen Position.

Wenn wir dies nun gefunden haben, k¨onnen wir es mit dem Element an der aktuellen Position tauschen, wie das folgende Beispiel verdeutlicht:

Zur Realisierung dieses Vorgehens ist es g¨unstig eine Hilfsprozedur zum Vertauschen zweier Listenelemente zu definieren:

d e f swap ( l , i , j ) : # swap v e r a e n d e r t d i e L i s t e dummy = l [ i ]

l [ i ] = l [ j ] l [ j ] = dummy

Danach ist das Sortieren nur noch ein einfacher Durchlauf durch die Liste, bei dem das aktuelle Element mit dem Minimum der Restliste getauscht wird:

d e f m i n s o r t ( l ) :

f o r i i n range(l e n( l )−1) : #da e i n e l e m e n t i g e L i s t e immer s o r t i e r t p o s = m i n p o s f r o m ( l , i )

swap ( l , i , p o s ) r e t u r n l

Dann ergibt sich: print( min sort ([5,3,6,2,7,4])) [2,3,4,5,6,7]

es stellt sich die Frage, wie man einen Sortieralgorithmus beurteilen soll?

Es ist sicherlich zun¨achst sinnvoll, Laufzeiten zu vergleichen. Aber, was sind gute Testf¨alle?

Zun¨achst scheint es sinnvoll, die Laufzeit in Abh¨angigkeit der Eingabegr¨oße zu untersuchen.

Es zeigt sich folgendes Verhalten:

(7)

Gr¨oße der Liste Laufzeit in Sekunden

1000 0.308

*2 *4.16

2000 1.284

*2 *4.02

4000 5.156

*2 *3.82

5000 19.713

Der Algorithmus scheint also quadratische Laufzeit in der L¨ange der Listen zu haben.

Hierbei scheinen die konkreten Werte, welche in der Liste vorkommen, fast v¨ollig unwichtig zu sein. Die Laufzeiten ver¨andern sich nicht, wenn man die Funktion mit einer sortierten anstelle einer unsortierten Liste aufruft.

Dies liegt daran, dass das Programm zwei verschachtelte Schleifen verwendet. Bei beiden Schleifen handelt es sich um for-Schleifen, so dass die Anzahl der Schleifendurchl¨aufe nicht von den Werten abh¨angt. W¨urde man den Code der Funktion min sort pos inlinen (also an die Stelle kopieren, an der er aufgerufen wird und die Variablennamen entsprechend anpassen), so erg¨abe sich folgenden Struktur:

f o r i i n range(l e n( l )−1) : f o r j i n range( i +1 , l e n( l ) ) :

. . .

Als einziger Unterschied zwischen einer sortierten und einer unsortierten Liste fallen ein paar Zuweisungen in min sort pos weg.

Welche Werte werden also f¨uriundj durchlaufen?

i j

0 1

... 2

3 ... len(l)-1

1 2

... 3

... len(l)-1

2 3

... ... len(l)-1 len(l)-3 len(l)-2 len(l)-1 len(l)-2 len(l)-1

(8)

D.h. der Rumpf der Schleife wird so oft durchlaufen:

len(l)−1 +len(l)−2 +len(l)−3 +. . .+ 1 =

len(l)−1

X

n=1

n

= (len(l)−1)·len(l) 2

1

= len(l)2−len(l) 2

= 1

2len(l)2−1 2len(l)

D.h. bis auf ein paar 12len(l) viele Werte werden 12len(l)2 viele Werte durchlaufen. Die genaue Zahl der Durchl¨aufe ist nicht so wichtig. Wichtiger ist, dass ihre Anzahl quadratisch mit der Listengr¨oße w¨achst, also f¨ur doppelte Listengr¨oße die 4-fache Laufzeit ben¨otigt wird!

Es stellt sich nat¨urlich die Frage, ob wir auch effizienter sortieren k¨onnen.

1.2. Sortieren durch Einf¨ ugen

Unsch¨on am Algorithmus Min-Sort ist insbesondere, dass er auch f¨ur bereits sortierte Listen quadratische Laufzeit hat. Besser ist hier Insertion-Sort.

Die Idee ist, dass man die zu sortierenden Werte wie einen Kartenstapel sieht. Man zieht nach und nach eine Karte und f¨ugt sie dann in die bereitssortierten Karten ein. Dies erfolgt daduch, dass man von oben, bis zu der Position l¨auft, an die die Karte eingef¨ugt werden muss.

Ein Beispieldurchlauf sieht dann wie folgt aus:

1Hier verwenden wir die Gaußsche Summenformel (kleiner Gauß):

n

P

i=1

i= n·(n+1)2 .

(9)

Insertion-Sort k¨onnen wir wie folgt in Python implementieren:

d e f i n s s o r t ( l ) :

f o r i i n range(l e n( l ) ) : j = i

w h i l e j>0 and l [ j−1] > l [ j ] :

swap ( l , j−1 , j ) # W i e d e r v e r w e n d u n g von swap j = j − 1

r e t u r n l

Dann: print( ins sort ([5,3,6,2,7,4])) [2,3,4,5,6,7]

Es f¨allt auf, dass bei sortierten Listen fast nichts gemacht wird:

1 | 2 3 4 5

1 2 | 3 4 5

1 2 3 | 4 5

1 2 3 4 | 5

1 2 3 4 5 |

Der Algorithmus hat also im besten Fall lineare Laufzeit in der Gr¨oße der zu sortierenden Liste.

Aber warum ist die absolute Laufzeit schlechter als bei Min-Sort? Das Problem ist, dass wir die Elemente so lange tauschen bis wir ein Element an der richtiger Stelle eingef¨ugt habe.

Hierdurch ist die Operation, welche im Schleifenrumpf ausgef¨uhrt wird aufwendiger als bei Min-Sort. Es werden in jedem Schritt der ¨außeren schleife nicht mehr nur zwei Element vertauscht, sondern alle Elemente bis zu der Position an die das Element geh¨ort.

D.h. bis auf ein paar 12len(l) viele werden 12len(l)2 viele Werte durchlaufen und dabei getauscht. Dies kann dadurch optimiert werden, dass man sich das einzuf¨ugende Element merkt und die anderen nach hinten schiebt. Im letzten Schritt f¨ugt man dann das ein- zuf¨ugende Element ein.

In der Implementierung:

d e f i n s s o r t ( l ) :

f o r i i n range(l e n( l ) ) : j = i

i n s = l [ i ]

w h i l e j>0 and i n s < l [ j−1] : l [ j ] = l [ j−1]

j = j − 1

(10)

l [ j ] = i n s r e t u r n l

Diese Implementierung ist jetzt ungef¨ahr genau so schnell wie Selection-Sort f¨ur unsortierte Listen und viel schneller f¨ur sortierte Listen.

Wir erhalten folgende Komplexit¨aten:

Selection-Sort Insertion-Sort Best-Case quadratisch in linear in (sortierte Liste) Gr¨oße der Liste Gr¨oße der Liste

Worst-Case quadratisch in quadratisch in (unsortiere Liste) Gr¨oße der Liste Gr¨oße der Liste

Es ist sinnvoll, diese beiden F¨alle zu unterscheiden. Wir werden sp¨ater sehen, dass man zus¨atzlich noch versucht, abzusch¨atzen wie sich der Algorithmus im Durchschnitt verhalten wird. Dies kann letztlich die Laufzeit des Worst- oder des Best-Case, aber auch etwas dazwischen sein.

Bevor wir zu noch effizienteren Sortierverfahren kommen, wollen wir uns noch Algorithmen anschauen, die andere Laufzeiten als unsere bisherigen Algorithmen haben.

1.3. Die Fibonacci-Funktion

Zun¨achst betrachten wir noch einmal die rekursive Implementierung der Fibonacci-Funktion:

d e f f i b ( n ) : i f n == 0 :

r e t u r n 0 e l i f n == 1 :

r e t u r n 1 e l s e :

r e t u r n f i b ( n−1) + f i b ( n−2)

Wenn wir dieses Programm testen, sind wir ¨uberrascht. Schon f¨ur Werte ¨uber 20 ben¨otigt das Programm sehr lange und Werte ¨uber 50 scheinen gar nicht zu terminieren.

Woran liegt dies?

(11)

Beachte:In jeder Rekursion werden zwei rekursive Aufrufe gemacht. Hierdurch ergibt sich eineexponentielle Laufzeit.

Wie schlimm ist dies?

f ib(2) 22= 4Schritte f ib(10) 210= 1024Schritte f ib(20) 220= 1048576Schritte f ib(30) 230= 1073741824Schritte f ib(50) 250= 1,125·1015Schritte f ib(100) 2100= 1,268·1030Schritte

Nehmen wir an, dass ein Computer1000000Schritte/Sekunde ausf¨uhren kann. Dann f ib(100) = 1,268·1030

1000000 s

= 1,268·1024 s

= 4,021·1016 Jahre

Aber zum Gl¨uck ist f¨ur dieses Problem eine effizientere Implementierung m¨oglich ( ¨Ubung).

Es gibt aber auch Probleme, f¨ur die keine effizienteren L¨osungen als exponentielle L¨osungen bekannt sind. Solche Probleme haben wir schon in Informatik I (2F/NF) kennen gelernt.

(12)

1.4. O-Notation

Bisher haben wir Algorithmen mit unterschiedlichen Laufzeitverhalten kennengelernt: li- near, quadratisch, exponentiell, usw. Hierbei ist es nat¨urlich (außer bei konstanten Algo- rithmen) wichtig zu sagen, worin der Algorithmus diese Komplexit¨at hat. Als pr¨agnantere Schreibweise schlug der Zahlentheoretiker Landau die Schreibweise der sogenanntenGroß- O-Notationvor. Wir schreiben hierbei ein Algorithmus ist inO(1)falls er konstante Laufzeit besitzt. Lineare Algorithmen in einer Variablenn(was beispielsweise die L¨ange eines Strings oder der Wert eines Parameters sein kann) notieren wir mit der Laufzeit O(n), quadrati- sche mit O(n2) und exponentielle mit O(2n). Hierbei m¨ussen wir immer noch angeben, worin wir dieses Laufzeitverhalten angeben, d.h., wasnist.

Hinter der O-Notation steckt eine formale mathematische Theorie, welche wir hier nicht weiter behandeln wollen. Es ist nur wichtig festzuhalten, dass O(1) = O(2) = O(42), O(n) =O(42·n+ 42) und O(n2) =O(13n2+ 42n15 + 5n2). Konstante Faktoren, die multipliziert oder addiert werden und Faktoren mit kleinerem Exponenten sind also f¨ur die Laufzeitanalyse nicht relevant.

F¨ur unsere beiden Sortieralgorithmen gilt, dass sie in allen bisher diskutierten Implementie- rungen im Worst-Case in O(n2)sind. Obwohl Insertion-Sort zun¨achst sehr viel langsamer war. Er war aber nur um einen konstanten Faktor langsamer.

In der Praxis, schaut man in der Regel zun¨achst auf die Laufzeit im sinne der Landau- Notation und ignoriert h¨aufig die Konstanten. In einzelnen F¨allen kann es aber auch sein, dass ein Algorithmus nur auf kleine Probleme angewendet wird und deshalb die Konstanten einen relevanteren Einfluss haben. So kann ein linearer Algorithmus mit einer sehr großen Konstante f¨ur Probleme bis zu einer gewissen gr¨oße langsamer sein, als ein quadratischer Algorithmus mit einer kleinen Konstante. Diese F¨alle sind allerdings selten und sollte im Spezialfall dann aber ¨uber gemessene Laufzeiten analysiert werden.

1.5. Suchen von Elementen

Aufgabe:Gegeben ein Liste von Zahlenlund eine Zahln. ¨Uberpr¨ufe, obninlvorkommt.

Einzige m¨ogliche L¨osung:Vergleichenmit allen Elementen:

d e f e l e m ( n , l ) : i = 0

f o u n d = F a l s e

w h i l e not( f o u n d ) and i < l e n( l ) : i f l [ i ] == n :

f o u n d = True e l s e :

i = i + 1 r e t u r n f o u n d

p r i n t( e l e m ( 3 , [ 1 , 2 , 3 , 4 , 5 ] ) ) True

(13)

Welche Laufzeit hat elem? Im besten Fall ben¨otigt die Suche nur einen Schleifendurchlauf, wenn n =a[0] gilt. Die Best-Case-Laufzeit ist konstant. Der Best-Case ist eine beliebig lange Liste, welche mit dem gesuchten Element beginnt.

Man beachte dabei, dass nicht die leere Liste der Best-Case ist. Ein Best-Case (und auch Worst-Case)muss immer eine unendliche Menge von F¨allen enthalten. Dies ist notwendig, da f¨ur jede endliche Menge von F¨allen, alle Laufzeiten konstant sind und somit durch die l¨angste Laufzeit abgesch¨atzt werden k¨onnen und damit konstant sind.

Im schlechtesten Fall findet die Funktion elem das gesuchte Element nicht. Dann durchl¨auft sie die Schleife genau len(l)-mal. Im Worst-Case ist sie alsolinear in der Gr¨oße vonl. Der Worst-Case sind also die Listen in denen das Element nicht vorkommt.

F¨ur alle vorkommenden Werte wird die Schleife im Durchschnittlen(l)/2-mal durchlaufen.

Also 12·len(l)viel Durchl¨aufe. Durch den Faktor 12 wird die Laufzeit zwar verbessert, sie ist aber immer noch linear zu len(l), d.h. auch im Durchschnitt (Average-Case) ist elem linear in der Gr¨oße vonl.

K¨onnen wirl besser strukturieren/anordnen, damit elem effizienter werden kann?

Denkt man ein Telefonbuch, so scheint es uns sinnvoll zu sein, die Elemente in der Liste sortiert vorzuhalten.

Dann k¨onnen wir unsere Suche optimieren. Wir suchen nur solange, wie n kleiner als aktuelles Element:

d e f e l e m ( n , l ) : i = 0

f o u n d = F a l s e

w h i l e not( f o u n d ) and i < l e n( l ) and l [ i ] <= n : i f l [ i ]==n :

f o u n d = True e l s e:

i = i + 1 r e t u r n f o u n d

Dann wird auch bei nicht vorkommenden Elementen die Schleife durchschnittlich nur 12· len(l)-mal durchlaufen. Das ist zwar besser, aber immer noch lineare in der Gr¨oße vonl.

Das es auch noch effizienter geht verdeutlichen wir uns mit Hilfe eines kleinen Spiels:Zahl raten:

• Ein Spieler denkt sich eine Zahl aus, der andere muss diese erraten. Hat der zweite Spieler die Zahl nicht erraten, so sagt der erste Spieler dem zweiten Spieler als Hilfe, ob die geratene Zahl kleiner oder gr¨oßer als die gesuchte Zahl ist. Der zweite Spieler soll die Zahl m¨oglichst schnell finden.

• Um immer direkt die H¨alfte der Zahlen ausschließen zu k¨onnen ist es nat¨urlich sinn- voll, immer die Zahl in der Mitte des in Frage kommenden Bereichs zu nennen. So kann in einem Schritt die H¨alfte aller Werte ausgeschlossen werden.

• M¨ogliche Rateschritte w¨aren also f¨ur eine Zahl zwischen 1 und 64:

32 - zu klein 48 - zu groß 40 - zu klein 44 - zu groß 42

(14)

gefunden

Das hier verwendete Prinzip, den Suchraum in (ungef¨ahr) gleich große Teile aufzuteilen und dann schrittweise zu entscheiden, mit welchem Teil weitergemacht wird, nennt man Teile und Herrsche (devide and conquer). Diese Idee k¨onnen wir auch auf die Elementsuche ubertragen:¨

1.5.1. Bin¨are Suche

Die Idee ist, dass wir das gesuchte Elementnmit dem mittlerem Element vonlvergleichen:

nist gleich gefunden

nist kleiner suche in linker H¨alfte nist gr¨oßer suche in rechter H¨alfte

Danach erfolgt die Suche innerhalb der H¨alfte nach der gleichen Idee. Als Beispiel suchen wir die 8 in der folgenden Liste:

8 i n [ 2 , 4 , 6 , 8 , 9 , 1 1 , 1 4 , 1 5 , 1 6 , 1 7 , 1 9 , 2 2 , 2 3 ] x

x

x x

Es werden also nur 4 Vergleichsschritte ben¨otigt um das Element zu finden. Auch im Fall, dass das Element nicht in der Liste vorkommt, kann man schnell terminieren, wenn nur noch ein Bereich der L¨ange eins in Frage kommt. Eine rekursive Implementierung kann einfach angegeben werden.

d e f b i n s e a r c h ( l , n , s t a r t , end ) : i f s t a r t >= end :

r e t u r n F a l s e e l s e :

p o s = ( s t a r t + end ) // 2 i f n == l [ p o s ] :

r e t u r n True e l i f n < l [ p o s ] :

r e t u r n b i n s e a r c h ( l , n , s t a r t , p o s ) e l s e :

r e t u r n b i n s e a r c h ( l , n , p o s +1 , end ) d e f e l e m ( n , l ) :

r e t u r n b i n s e a r c h ( l , n , 0 ,l e n( l ) )

(15)

Im Beispiel ergibt sich:

elem(14,

=l

z }| {

[11,12,13,14,15,16,17,18,19,20]) bin search(l,14,0,10)

pos= (0 + 10)//2 = 5 a[5] = 166= 16(>14) bin search(l,14,0,5) pos= (0 + 5)//2 = 2 a[2] = 136= 14(<14) bin search(l,14,3,5) pos= (3 + 5)//2 = 4 a[4] = 14 == 14 return T rue

Wie viele Schritte (rekursive Aufrufe) ben¨otigt das Programm nun?

In jedem Schritt wird die eine H¨alfte der Werte ’weggeschmissen’, d.h. nicht weiter be- trachtet und nur noch die andere H¨alfte weiter untersucht. F¨uhrt man dies rekursiv immer wieder aus, ben¨otigt manlog2 viele Schritte in der L¨ange der Liste. Der Algorithmus ist alsologarithmisch in der L¨ange der Liste, also inO(logn)2mitnL¨ange der Liste.

Kann dieser Algorithmus auch iterativ implementiert werden? Ja, mit der gleichen Idee:

d e f b i n s e a r c h ( n , l ) : l e f t = 0

r i g h t = l e n( l )

p o s = ( l e f t +r i g h t ) // 2

w h i l e l e f t < r i g h t and l [ p o s ] != n : i f l [ p o s ] < n :

l e f t = p o s + 1 e l s e :

r i g h t = p o s − 1

p o s = ( l e f t + r i g h t ) // 2

r e t u r n p o s < l e n( l ) and l [ p o s ] == n

Beachte hierbei, dass(lef t+right)/2 =lef t+ (right−lef t)/2.

Die bin¨are Suche ist tats¨achlich ein sehr wichtiger Algorithmus, da er das Sortieren ¨uber- haupt sinnvoll macht. H¨atten wir keinen Algorithmus zum Finden eines Elementes in lo- garithmischer Laufzeit, w¨are sortieren gar nicht sinnvoll, da ein nachschlagen ja immer noch lineare Laufzeit ben¨otigen w¨urde. So k¨onnen wir aber Daten sortiert vorhalten und

2Wir verwenden log f¨ur den Logarithmus zur Basis 2. Tats¨achlich gilt aber f¨ur die logarithmischen Komplexit¨atsklassen KlassenO(logn) =O(lnn) =O(log10n) =O(logkn) f¨ur allek >1.

(16)

anschließend sehr effizient auf diese Daten zugreifen. Wenn man sich vorstellt, dass man nicht nur Zahlen sortiert, sondern beliebige Schl¨ussel und zu diesen Schl¨usseln auch Werte ablegt (die dann unsortiert, z.B. als zweite Komponente in einem Paar abgelegt werden).

Kann man z.B. Dictonaries, wie sie in Python existieren als sortierte Liste realisieren und die Schl¨ussel effizient nachschlagen. Wir werden sp¨ater aber noch geeignetere Strukturen kennen lernen, bei denen es dann auch m¨oglich ist, Elemente hinzuzuf¨ugen, ohne erneut sortieren zu m¨ussen.

1.6. Effizientes Sortieren

Es stellt sich die Frage ob es auch Sortierverfahren gibt, welche die Aufgabe schneller als in quadratischer Laufzeit l¨osen k¨onnen. Wirklich effiziente L¨osungen basieren auf der teile-und-herrsche Idee, welche wir uns am Beispiel desQuicksort-Algorithmus anschauen wollen.

Beim Quicksort-Algorithmus w¨ahlt man zun¨achst ein beliebiges Element (meistens das Erste) der Liste aus (Pivot-Element genannt) und vergleicht es nach und nach mit allen anderen Elementen des Listen. Es ist klar, dass alle kleineren Elemente links von diesem Pivot-Element einsortiert werden m¨ussen und alle anderen rechts von ihm. Wir teilen also alle anderen Elemente so auf, dass alle kleineren Elemente vor dem Pivot-Element stehen und alle gr¨oßeren Elemente hinter dem Pivot-Element. Mit den Kleineren und den Gr¨oßeren verfahre rekursiv so weiter, bis nur noch einelementige Listen ¨ubrig bleiben, die nat¨urlich sortiert sind.

Bsp.:

Als erste Implementierung, verwenden wir jeweils eine neue Liste, in die wir die kleineren Werte nach vorne und die gr¨oßeren Werte nach hinten packen k¨onnen. Danach wird rekursiv f¨ur die kleineren und gr¨oßeren Elemente mittels Quicksort sortiert. Wir verwenden jeweils das erste Element des zu sortierenden Bereichs (a[ l ]) als Pivot-Element.

d e f q s o r t h 1 ( l , s t a r t , end ) : # S o r t i e r t den B e r e i c h von

# s t a r t b i s end

l 1 = l i s t( l ) # l e g e e i n e K o p i e d e r L i s t e l an ,

# i n d i e w i r h i n e i n s o r t i e r e n i f s t a r t >= end :

r e t u r n l 1 e l s e :

j = s t a r t k = end − 1

(17)

f o r i i n range( s t a r t +1 , end ) :

i f l [ i ] < l [ s t a r t ] : # V e r g l e i c h m i t P i v o t−E l e m e n t l 1 [ j ] = l [ i ]

j = j +1 e l s e :

l 1 [ k ] = l [ i ] k = k−1

l 1 [ j ] = l [ s t a r t ] # V e r s c h i e b e P i v o t−E l e m e n t i n d i e M i t t e l 2 = q s o r t h 1 ( l 1 , s t a r t , j )

r e t u r n q s o r t h 1 ( l 2 , j +1 , end ) d e f q s o r t 1 ( l ) :

r e t u r n q s o r t h 1 ( l , 0 ,l e n( l ) ) p r i n t( q s o r t 1 ( [ 2 , 6 , 8 , 4 , 6 , 2 , 3 , 8 , 1 ] ) )

Dieser Algorithmus arbeitet aber nicht in Place, d.h. er ben¨otigt weiteren Speicher. Die Liste wird immer wieder kopiert. Hierdurch bleibt die Ursprungsliste zwar erhalten, aber auch f¨ur alle Zwischenlisten, wird separater Speicher verwendet, welcher sp¨ater aber wieder frei gegeben wird. Eine effizientere Implementierung basiert auf der Idee, dass man, wenn man eine Vertauschungsoperation verwendet auch durch geschicktes Vertauschen innerhalb der Listen die Elemente an Hand des Pivot-Elementes aufteilen kann:

5 1 7 3 6 9 2 4 8

m i

5 1 7 3 6 9 2 4 8

m i i

5 1 3 7 6 9 2 4 8

m i i i

5 1 3 2 6 9 7 4 8

m i

5 1 3 2 4 9 7 6 8

m i

4 1 3 2 5 9 7 6 8

m i

Dies kann wie folgt in Python umgesetzt werden:

d e f q s o r t h ( l , s t a r t , end ) : # S o r t i e r t den B e r e i c h von l b i s r i f s t a r t >= end :

r e t u r n l e l s e :

m = s t a r t

f o r i i n range( s t a r t +1 , end ) : i f l [ i ] < l [ s t a r t ] :

m = m + 1

(18)

swap ( l , i ,m) # swap s i e h e I n s e r t i o n−S o r t swap ( l , s t a r t ,m)

q s o r t h ( l , s t a r t , m−1) r e t u r n q s o r t h ( l ,m+1 , end ) d e f q s o r t ( l ) :

r e t u r n q s o r t h ( l , 0 ,l e n( l ) )

In der ¨Ubung werden wir eine alternative Variante realisieren.

Laufzeitanalyse: Im Durchschnitt wird das Pivot-Element die Liste in zwei gleich große H¨alften teilen welche dann beide jeweils wieder sortiert werden m¨ussen:

Die Laufzeitanalyse klingt, als ob wir eine Best-Case-Analyse gemacht h¨atten. Nur bei guten Pivot-Elementen wird gleichm¨aßig aufgeteilt. Der Fall tritt allerdings fast immer auf, weshalb man als Average-Case LaufzeitO(n·logn)mitnL¨ange der zu sortierenden Listen erh¨alt.

Allerdings gibt es immer noch einen schlechten Fall, in dem Quicksort eine schlechtere Laufzeit hat:

Die Worst-Case Laufzeit betr¨agt also immer nochO(n2), mitnL¨ange der Liste.

Man verhindert dies dadurch, dass ein zuf¨alliges Pivot-Element gew¨ahlt wird. Hierzu kann man zu Beginn des else-Falles das erste Element gegen ein anderes zuf¨allig gew¨ahltes Element getauscht werden:

import random

d e f q s o r t h ( l , s t a r t , end ) : # S o r t i e r t den B e r e i c h von

# s t a r t b i s end i f l s t a r t >= end :

r e t u r n a e l s e :

swap ( l , s t a r t , random . r a n d r a n g e ( s t a r t , end ) ) # c r e a t e random number n

m = s t a r t # w i t h n >= s t a r t and n < end

. . .

(19)

Dann ist der Worst-Case sehr unwahrscheinlich⇒in der Praxis immer LaufzeitO(n·logn) mitn=len(l).

1.7. Andere effiziente Sortieralgorithmen

Merge-Sort Teile Liste in zwei gleich große H¨alften, sortiere die H¨alften rekursiv und mische die beiden sortierten Ergebnisse zusammen. Nachteil gegen¨uber Quicksort: nicht einfach in Place m¨oglich: beim Mischen wird zweite Liste ben¨otigt.

1 3 5 7 2 4 6 8 Wie innerhalb des 1 2 3 4 5 6 7 8 Listen machbar?

Aber:Algorithmus hat auch Worst-Case-Komplexit¨atn·logn mitn= #zu sortierende Werte, im Gegensatz zu Quicksort, bei dem dies nur randomisiert erreicht werden kann.

Bsp.:

Allerdings ist Quicksort in der Praxis oft schneller (insbesondere randomisiert) und wird in den meisten Systemen zum sortieren verwendet.

2. Suchb¨ aume

2.1. Motivation

Im letzten Kapitel haben bereits das Prinzip der bin¨aren Suche betrachtet. Der Vorteil dieses Algorithmus ist, dass man sehr effizient Elemente in einer großen Datenbasis suchen und finden kann. Durch den Divide-and-Conquer-Ansatz wurde die Laufzeit von linear auf logarithmisch (in der L¨ange der Liste) reduzieren. In der Praxis kann ein logarithmischer Faktor fast als konstant angesehen werden, da er extrem langsam w¨achst.

Sortierte Listen haben aber auch einen Nachteil, wenn man sie erweitern m¨ochte. Ein neues Element kann nicht einfach an einem Ende hinzugef¨ugt werden, da die Liste dann nicht mehr sortiert w¨are. Ein erneutes sortieren h¨atte die LaufzeitO(n·logn) und w¨are somit ineffizient. Tats¨achlich k¨onnte man das hinzuf¨ugen eines Elements auch etwas effizienter gestalten, indem man zun¨achst mit bin¨arer Suche die Position sucht, an die das Element geh¨oren w¨urde und dann die Liste zerschneidet und zusammen mit dem neuen Element wieder zusammen setzt.

(20)

Beispiel

Die 30 soll in folgende Liste eingef¨ugt werden:

[1,3,5,8,10,42,73]

Mit der bin¨aren Suche ergibt sich schnell, dass sie zwischen der 10 und der 42 stehen muss, aber es gibt keine L¨ucke, in die der Wert eingef¨ugt werden kann. Deshalb muss die ganze Liste neu erstellt werden mit:

[1,3,5,8,10] + [30] + [42,73]

Dieses umst¨andliche Verfahren l¨asst sich durch die Verwendung von Suchb¨aumen verbes- sern.

Das Zusammenf¨ugen der Listen hat aber wieder eine lineare Laufzeit in der L¨ange der Listen, wodurch dieses Verfahren in O(n)ist, mitnL¨ange der Liste.

Ein besseres Verfahren ist die Verwendung vonSuchb¨aumen, welche es erm¨oglichen durch eine Baumstruktur sowohl Werte in logarithmischer Laufzeit nachzuschlagen als auch hin- zuzuf¨ugen.

2.2. Bin¨ arb¨ aume

In einem bin¨aren Baum hat jeder Knoten bis zu zwei Kindknoten. Wir unterscheiden fol- gende Knotenarten:

• Elternknoten: Der Elternknoten eines Knotens befindet sich im Baum oberhalb von diesem Knoten und ist durch eine Kante mit ihm verbunden. Jeder Knoten kann maximal einen Elternknoten haben.

• Kindknoten: Ein Kindknoten eines Knotens befindet sich im Baum unterhalb von diesem Knoten und ist durch eine Kante mit ihm verbunden. Es kann einen linken und einen rechten Kindknoten geben.

• Wurzelknoten: Der Wurzelknoten ist der oberste Knoten des Baumes, d.h. er hat keinen Elternknoten. Jeder Baum hat genau einen Wurzelknoten, außer es ist der leere Baum, dann hat er einfach keine Knoten.

• Bl¨atter: Bl¨atter sind die untersten Knoten des Baumes, d.h. sie haben keine Kind- knoten.

2.3. Definition Suchb¨ aume

Suchb¨aume sind bin¨are B¨aume, deren Knoten mit Werten beschriftet sind. Dar¨uber hinaus zeichnen sich Suchb¨aume dadurch aus, dass sie sortiert sind: F¨ur jeden Knoten gilt, dass alle Werte im rechten Teilbaum gr¨oßer und alle Werte im linken Teilbaum kleiner sind als sein Wert.

Hierbei ist es wichtig, dass dies f¨ur jeden Knoten im Suchbaum gilt.

(21)

Wir gehen zun¨achst davon aus, dass jeder Wert nur einmal im Suchbaum vorkommt, im Gegensatz zu den Listen, die wir sortiert haben und bei denen Werte mehrfach vorkommen durften.

Beispiele

8 3 1 5

42

10 73

(a)Suchbaum

8 3 1

5

42

10 73

(b)Suchbaum

8 5 1

3

42 10 73

(c)keinSuchbaum!

Der Suchbaum in Abbildung (c) ist kein Suchbaum, obwohl f¨ur jeden Knoten im Suchbaum gilt, dass sein linkes Kind kleiner und sein rechtes Kind gr¨oßer als der Knoten ist. Der Fehler ist, dass die 3 im rechten Teilbaum der Wurzel vorkommt, aber kleiner als der Wert der Wurzel (8) ist. Korrigieren k¨onnte man dies z.B. dadurch, dass man die 3 als rechten Kindknoten der 1 einf¨ugt.

2.4. Suchen

Ein Suchbaum ist eine direkte Repr¨asentation f¨ur das Prinzip der bin¨aren Suche.

Jede Suche beginnt beim Wurzelknoten. Durch einen Gr¨oßer/Kleiner/Gleich-Vergleich wird entschieden, ob es danach im rechten oder im linken Teilbaum weitergeht, oder ob der Wert bereits gefunden wurde.

Beispiel

Im Suchbaum aus Beispiel (a) soll die 5 gesucht werden:

1. Die 5 ist kleiner als die 8, also wird der rechte Teilbaum gew¨ahlt.

2. Die 5 ist gr¨oßer als die 3, also wird der linke Teilbaum gew¨ahlt.

3. Die 5 ist gleich der 5, also wurde die 5 gefunden.

Das Suchen im Suchbaum funktioniert also genau wie die bin¨are Suche in einer Liste, mit dem Vorteil, dass der Index des n¨achsten Wertes nicht ausgerechnet werden muss, sondern direkt der n¨achste Kindknoten verwendet wird. Dabei ist es f¨ur die Laufzeit wichtig, dass der Baum einigermaßen ausgeglichen ist, also dass sich links und rechts ungef¨ahr gleich viele Knoten befinden. Der folgende Baum zum Beispiel erf¨ullt alle Eigenschaften eines Suchbaums, eignet sich aber nicht zum schnellen Suchen:

(22)

8 5 1

3

42 10

73

Im weiteren Verlauf der Veranstaltung gehen wir f¨ur die Laufzeitbetrachtungen immer davon aus, dass Suchb¨aume relativ ausgeglichen sind, ein solcher Fall also nicht auftritt.

Tats¨achlich kann man Suchb¨aume nach Einf¨uge- und L¨osch-Operationen immer wieder geschickt umbalancieren, so dass sie ausgeglichen bleiben. Der interessierte Leser kann hierzu weitere Informationen unter den Stichworten AVL-B¨aume oder Rot-Schwarz-B¨aume finden. Sowohl die Wikipedia als auch YouTube bieten gute Erkl¨arungen der Algorithmen.

In vordefinierten Suchbaumimplementierungen werden auch genau diese Algorithmen im- plementiert, was aber technisch aufwendig ist, so dass wir sie hier nicht im Detail bespre- chen. F¨ur die folgenden Laufzeit-Absch¨atzungen gehen wir aber davon aus, dass es m¨oglich ist und geben die Laufzeit zum Suchen, Hinzuf¨ugen und L¨oschen eines Wertes in einem Suchbaum mitO(logn)an.

2.5. Einf¨ ugen

Zum Einf¨ugen eines Wertes in einen Suchbaum muss zun¨achst mit der bin¨aren Suche die richtige Stelle gefunden werden. Sobald die Suche ins Leere l¨auft, kann der Wert genau an dieser Stelle als neues Blatt des Baumes eingef¨ugt werden.

Beispiel

Im Suchbaum aus Beispiel (a) soll die 30 eingef¨ugt werden:

1. Die 30 ist gr¨oßer als die 8, also wird der rechte Teilbaum gew¨ahlt.

2. Die 30 ist kleiner als die 42, also wird der linke Teilbaum gew¨ahlt.

3. Die 30 ist gr¨oßer als die 10, also wird der rechte Teilbaum gew¨ahlt.

4. Der rechte Teilbaum ist leer, also kann die 30 hier eingef¨ugt werden.

(23)

Jeder Knoten, der hinzugef¨ugt wird, bietet durch die Bin¨arstruktur des Baumes genau zwei neue Positionen, an denen wieder Bl¨atter angeh¨angt werden k¨onnen. So ist sichergestellt, dass jeder beliebige Wert genau einen Platz hat, an dem er eingef¨ugt werden kann. Genau wie bei einer Liste existieren immer n+ 1 Positionen, an denen neue Werte eingef¨ugt werden k¨onnen, wobeindie Anzahl der schon vorhandenen Werte ist.

Man kann dies auch anders ausdr¨ucken: Jeder Bin¨arbaum mit nKnoten hat n+ 1nicht vorhandene Kindknoten.

2.6. L¨ oschen

Nach dem L¨oschen muss der Baum weiterhin zusammenh¨angend sein, d.h. es darf außer dem Wurzelknoten keinen Knoten ohne Elternknoten geben. Außerdem muss die Sortierung erhalten bleiben. Wir k¨onnen unterschiedliche F¨alle Unterscheiden:

1. Bl¨atter (also Knoten ohne Kindknoten) k¨onnen einfach gel¨oscht werden.

2. Bei Knoten, die nur einen Kindknoten haben, kann dieser Kindknoten mit seinem kompletten nachfolgenden Teilbaum an die Stelle des zu l¨oschenden Knoten gesetzt werden.

x

T1

3. Zum L¨oschen von Knoten mit zwei Kindknoten gibt es im wesentlichen zwei Varian- ten:

• Der Knoten mit dem gr¨oßten Wert aus dem linken Teilbaum kann an die Stelle des gel¨oschten Knoten wandern, da er gr¨oßer ist als alle Werte im linken Teil- baum und gleichzeitig kleiner als alle Werte im rechten Teilbaum. Dasselbe gilt nat¨urlich auch f¨ur den Knoten mit dem kleinsten Wert des rechten Teilbaums.

(24)

x

T1 T2

x

T1 T2

Beachte hierbei, dass der Knoten mit dem gr¨oßten Wert im linken Teilbaum selber noch einen linken Teilbaum haben kann. Wenn man diesen also l¨oscht, muss man Fall 2 f¨ur einen Knoten mit einem Kindknoten anwenden.

• Eine andere M¨oglichkeit ist, den kompletten rechten Teilbaum als rechten Un- terbaum an den rechtesten Knoten des linken Teilbaums zu h¨angen. Dann hat der zu l¨oschende Knoten nur noch einen Kindknoten und kann wie in Fall 2 gel¨oscht werden.

x

T1 T2

2.7. Implementierungen

Im Suchbaum hat ein Knoten bis zu zwei Kindknoten. Dies stellt man in der Regel im Speicher etwas anders dar. Man unterscheidet zwei Arten von Knoten:

• innere Knoten, welche eine Beschriftungvund exakt zwei Kindknotenlundrhaben:

node(l, v, r)

• Bl¨atter, welche weder Beschriftung noch Kindknoten haben:empty()

Einen Suchbaum mit den Werten 3 und 5 k¨onnen wir dann wie folgt repr¨asentieren:

node(empty(),3, node(empty(),5, empty())

Konkret k¨onnen wir die beiden Knoten repr¨asentieren als:

• Klasse:

Durch eine Klasse Knoten mit den Attributen Wert, linkes Kind, rechtes Kind, kann der ganze Baum, anfangend mit dem Wurzelknoten, verkn¨upft werden. Zur vollst¨andigen Implementierungsiehe ¨Ubung ( ¨Ubung 2, Aufgabe 1).

• verschachtelte Liste:

Jeder Knoten wird durch eine Liste mit den drei Eintr¨agen Wert, linkes Kind und rechtes Kind dargestellt. Die Eintr¨age linkes Kind und rechtes Kind sind dann wieder

(25)

dreielementige oder leere Listen. Diese Implementierung werden wir als n¨achstes realisieren.

• verschachteltes Dictionary:

Jeder Knoten wird durch ein Dictionary mit den drei Eintr¨agen Wert, linkes Kind und rechtes Kind dargestellt. Die Eintr¨age linkes Kind und rechtes Kind sind dann wieder solche oder leere Dictionaries. Zur vollst¨andigen Implementierungsiehe ¨Ubung ( ¨Ubung 2, Aufgabe 3).

2.8. Suchb¨ aume als verschachtelte Arrays

Suchb¨aume k¨onnen in Python als verschachtelte Arrays dargestellt werden. Alternativ kann man sehr ¨ahnlich auch Tupel verwenden,w as wir hier aber nicht weiter betrachten.

In unserer Implementierung stellen wir jeden Knoten, welcher einen Wert enth¨alt, als Array der Gr¨oße drei dar. Also auch die Bl¨atter in unseren Suchb¨aumen, die eigentlich keine Kindknoten haben. Die Bl¨atter in unserer Implementierung sind also vielmehr leere Listen, welche noch unterhalb der Bl¨atter des Baumes verwendet werden. Hierdurch hat tats¨achlich jeder Knoten im Bin¨arbaum entweder zwei oder kein Kind.

Als Beispiel betrachten wir den folgenden Suchbaum:

5

2 10

Mit verschachtelten Listen w¨urde er dann wie folgt repr¨asentiert:

5

, ,

2

,

, ,

10

,

tr

Suchbaum als verschachtelte Liste

Um Bin¨arb¨aume in dieser Form konstruieren zu k¨onnen ist es sinnvoll zun¨achst einen Satz Hilfsfunktionen zu definieren, die uns helfen, solche B¨aume zu konstruieren.

d e f node ( l , v , r ) :

(26)

r e t u r n [ l , v , r ] d e f empty ( ) :

r e t u r n [ ]

Unter Verwendung dieser Funktionen k¨onnen wir obigen Suchbaum konstruieren mittels t = node ( node ( empty ( ) , 2 , empty ( ) ) , 5 , node ( empty ( ) , 1 0 , empty ( ) ) Da man mit diesen Funktionen also recht elegant Datenstrukturen konstruieren kann, nennt man solche Funktionen auchSmartkonstruktoren. Wie wir sp¨ater sehen werden, verstecken Sie auch gewissermaßen die Implementierung, was dann auch durch passende Selektoren zum Selektieren der Kindb¨aume bzw. des Wertes undTestfunktionen zum Unterscheiden der beiden Knotentypen, elegant erg¨anzt werden kann:

# S e l e k t o r e n d e f l e f t ( l ) :

r e t u r n l [ 0 ] d e f r i g h t ( l ) :

r e t u r n l [ 2 ] d e f v a l u e ( l ) :

r e t u r n l [ 1 ]

# T e s t f u n k t i o n

d e f i s e m p t y ( t r e e ) : r e t u r n t r e e == [ ]

Die Verwendung unserer Hilfsfunktionen erleichtert nun die Realisierung der Funktion zum effizienten Suchen eines Wertes im Suchbaum:

d e f e l e m ( v , t r e e ) : i f i s e m p t y ( t r e e ) :

r e t u r n F a l s e

e l i f v < v a l u e ( t r e e ) :

r e t u r n e l e m ( v , l e f t ( t r e e ) ) e l i f v > v a l u e ( t r e e ) :

r e t u r n e l e m ( v , r i g h t ( t r e e ) ) e l s e :

r e t u r n True # h i e r g i l t v == v a l u e ( t r e e )

Durch die Hilfsfunktionen sehen wir in der Implementierung der Funktion elem gar nicht mehr, wie genau wir den Suchbaum implementiert haben und produzieren sehr gut lesbaren Code.

Als n¨achsten Schritt wollen wir eine Funktion add definieren, welche einen Wert zu unse- rem Suchbaum hinzuf¨ugt. Wir beginnen mit dem Absteigen, analog zur Implementierung von elem. Wir haben ja zun¨achst angenommen, dass Werten nicht mehrfach in unseren

(27)

Suchb¨aumen vorkommen. Sollte der Wert, den wir einf¨ugen wollen bereits in dem Baum vorhanden sein, k¨onnen wir dies mit dem R¨uckgabewert False anzeigen. Waren wir erfolg- reich liefert unsere Funktion True:

d e f add ( v , t r e e ) :

i f i s e m p t y ( t r e e ) :

? ? ? # E i n f u e g e n d e s n e u e n K n o t e n s r e t u r n True

e l i f v < v a l u e ( t r e e ) :

r e t u r n add ( v , l e f t ( t r e e ) ) e l i f v > v a l u e ( t r e e ) :

r e t u r n add ( v , r i g h t ( t r e e ) ) e l s e :

r e t u r n F a l s e # h i e r g i l t v == v a l u e ( t r e e )

An der Stelle ??? ist uns nicht klar, wie wir den neuen Knoten node(empty(),v,empty()) einf¨ugen k¨onnen. Der ¨ubergeordnete Knoten verweist auf die leere Liste, an die die Variable tree nun gebunden ist. In unserer Implementierung m¨ussen wir diese Liste zu der Liste node(empty(),v,empty()) mutieren, damit der ¨ubergeordnete Knoten auf unseren neuen Knoten verweist.

Hierzu erweitern wir unsere Hilfsfunktionen um eine spezielle Mutationsfunktion d e f e m p t y t o v a l u e ( t r e e , v ) :

i f t r e e ==[] :

t r e e . e x t e n d ( [ empty ( ) , v , empty ( ) ] )

Hierbei ist die Funktion extend eine mutierende Methode der list Klasse, d.h. das vorhan- dene Listenobjekt bleibt erhalten, aber wird ver¨andert (mutiert). Mit dieser Funktion ist es dann m¨oglich unsere Definition fertig zu stellen:

d e f add ( v , t r e e ) :

i f i s e m p t y ( t r e e ) :

e m p t y t o v a l u e ( t r e e , v ) r e t u r n True

e l i f v < v a l u e ( t r e e ) :

r e t u r n add ( v , l e f t ( t r e e ) ) e l i f v > v a l u e ( t r e e ) :

r e t u r n add ( v , r i g h t ( t r e e ) ) e l s e :

r e t u r n F a l s e # h i e r g i l t v == v a l u e ( t r e e ) Dieser Einf¨ugeschritt s¨ahe dann f¨ur das Einf¨ugen des Wertes 7 wie folgt aus:

(28)

5

, ,

2

,

, ,

10

,

, ,

7

tr

Einf¨ugen in einen Suchbaum, kodiert als verschachtelte Liste

Bei allen Implementierungen (also auch mit Klassen oder Dictionaries, siehe ¨Ubung) ist es wichtig, dass das Einf¨ugen eines neuen Wertes unbedingt als Mutation der bereits vorhan- denen Objekte/Eintr¨age geschieht, da der neue Eintrag sonst nicht mit seinem Elternknoten verbunden werden kann. Wenn man neue Eintr¨age/Objekte verwenden will, muss man die Rekursion bereits eine Ebene h¨oher beenden und den linken/rechten Kindknoten ersetzen.

Dies ist aber in der Regel sehr viel aufwendiger, so dass es besser ist, leere Bl¨atter zu verwenden, welche man in nicht-leere Knoten mutieren kann.

Wir haben nun einige Funktionen definiert, welche ein potentieller Nutzer unserer klei- nen Bibliothek verwenden k¨onnte. Hierbei ist es aber nur sinnvoll, dass ein Benutzer die Funktionen elem und add verwendet. Außerdem k¨onnte er noch empty zur Konstruktion eines leeren Suchbaums verwenden. Von einer Verwendung der Funktion node sollte ein Benutzer aber absehen, da man mit ihr auch ung¨ultige Suchb¨aume konstruieren kann. Wir verwenden die Funktion node lediglich, innerhalb unserer eigenen Definition. Deshalb ist es sinnvoll eine klare Benutzerschnittstelle zur Verf¨ugung zu stellen und hierzu ein Synonym f¨ur die Funktion empty zu definieren, welche f¨ur den Benutzer unserer Bibliothek gedacht ist:

# K o n s t r u k t o r f u e r den B e n u t z e r d e f e m p t y s e a r c h t r e e ( ) :

r e t u r n empty ( )

Damit stehen dem Benutzer nun drei Funktionen empty search tree , elem und add zur Verf¨ugung. In den ¨Ubungen kommt noch delete hinzu.

Das Abstraktionskonzept hinter dieser Aufteilung werden wir sp¨ater noch bei der Betrach- tung abstrakter Datentypen vertiefen.

Zum Abschluss des Kapitels beginnen wir noch mit einer Implementierung der Suchb¨aume mit Klassen. Klassen geben uns die M¨oglichkeit Objekte mit Zustand zu definieren. Die einzelnen Komponenten unserer Listen (linkes Kind, Wert und rechtes Kind) werden dabei Attribute des zugeh¨origen Objekts der Klasse. Außerdem verwenden wir noch ein weiteres

(29)

boolesches Attribut empty, welche uns zus¨atzlich anzeigt, ob der jeweilige Knoten leer ist (entspricht empty) oder nicht (node).

c l a s s S e a r c h T r e e :

d e f i n i t ( s e l f ) : s e l f . empty = True s e l f . v a l u e = None s e l f . l e f t = None s e l f . r i g h t = None d e f e l e m ( s e l f , v a l u e ) :

. . .

d e f add ( s e l f , v a l u e ) : . . .

Der Konstruktor entspricht hier dem Anlegen eines leeren Suchbaums, also der Funktion empty. Die Attribute left und right werden dann sp¨ater auch andere Objekte der Klasse SearchTree enthalten, wodurch die Baumstruktur repr¨asentiert wird. Ver¨andert man die- se Attribute entspricht dies genau der Mutation der leeren Liste in eine nichtleere Liste mit Hilfe der Funktion empty to value. Die Methoden elem und add werden dann in den Ubungen realisiert.¨

3. Verzeigerte Datenstrukturen

Bei der Implementierung von Suchb¨aumen haben wir verschachtelte Listen, Klassen oder Dictionaries verwendet. Hierbei war ein wesentliches Ziel eine dynamische Datenstruktur zu realisieren, welche beliebig groß werden kann. Urspr¨unglich verwendete man in Pro- grammiersprachen f¨ur diese F¨alle Zeiger, was Speicherzellen entspricht, die andere Spei- cherzellen adressieren, also eine Speicheradresse als Wert enthalten. Hierdurch ist es dann m¨oglich Datenstrukturen, wie unsere Suchb¨aume im Speicher darzustellen, ohne dass alle Werte direkt hintereinander angeordnet sein m¨ussen, wie es bei Listen oder Arrays der Fall w¨are. In modernen Programmiersprachen, verwendet man nicht mehr direkt Zeiger, sondern Klassen (oder in Python auch verschachtelte Arrays oder Dictionaries) um dy- namische Datenstrukturen als verzeigerte Strukturen zu realisieren. Außerdem wird man durch den Garbage Collector unterst¨utzt, der Objekte, auf welche kein Zeiger mehr ver- weist, wieder frei gibt. Beim n¨achsten Anlegen eines Objektes, welches auch Bestandteil einer dynamischen Datenstruktur sein kann, kann dieser Speicher dann wiederverwendet werden.

Obwohl man heutzutage nicht mehr direkt mit Zeigern programmiert, ist es sinnvoll die Implementierung von dynamischen Datenstrukturen als verzeigerte Struktur zu verstehen.

Hierzu realisieren wir einige grundlegende dynamische Datenstrukturen.

(30)

3.1. Stack

Ein Stack (im deutschen auch Keller oder Kellerspeicher genannt) ist eine Sammlung von Werten, bei der immer nur auf den Wert zugegriffen werden kann, der als letztes hinzugef¨ugt wurde. Auf den Wert, der davor hinzugef¨ugt wurde, kann erst zugegriffen werden, wenn dieser Wert entfernt wurde. Das Prinzip wird auch als Last InFirstOut, alsoLIFO-Prinzipbezeichnet. Die Operationen heißen in der Regel push (zum Hinzuf¨ugen) und pop (zum Entfernen) des obersten Elementes. Ggf. kann noch ohne entfernen mittels top auf das oberste Element des Stacks zugegriffen werden.

Eine m¨ogliche Implementierung eines Stacks ist eine simple Liste, bei der immer nur auf das letzte Element zugegriffen wird. Der Nachteil dieser Implementierung ist, dass im Speicher meist kein entsprechender Platz zur Verf¨ugung steht und die Liste beim Pushen eines Wertes ggf. kopiert werden muss. Um dies zu verhindern, kann der Stack auch als verkettete Liste3 realisiert werden. Hierbei wird ein Eintrag im Stack durch ein Liste der L¨ange zwei (wir k¨onnten auch verschachtelte Paare verwenden) bestehend aus dem Wert und dem n¨achsten Listenelement implementiert werden. Außerdem realisieren wir noch die Funktionen push(v), um einen neuen Wert v hinzuzuf¨ugen, und pop(), um den Wert des obersten Elements zur¨uckzugeben und es aus dem Stack zu l¨oschen, ben¨otigt.

Beispiel

In den folgenden Grafiken wird jedes Element im Stack als eine zweielementige Liste dar- gestellt. Ein Stack mit den Werten 2 und 3 sieht wie folgt aus:

3

,

2

,

st

Man beachte, dass die Variable st, die den Stack repr¨asentiert immer auf die zwei- elementige Liste zeigt, die das oberste Stack-Element enth¨alt. Der Aufruf von pop() w¨urde hier das oberste Element 3 zur¨uckgeben und die Variable st auf die n¨achste Liste verschie- ben.

Der Aufruf von push(1) f¨ugt das hervorgehobene Element hinzu und setzt den Wert der Variablen st darauf:

3 , 2 ,

1 ,

st

3Obwohl hier der Begriff Liste verwendet wird, hat eine verkettete Liste nichts mit den Listen von[1,2,3]* Python zu tun.

(31)

3.2. Implementierungen

Wie bei den Suchb¨aumen realisieren wir den Stack hier als verschachteltes Array. Allerdings m¨ussen wir daf¨ur Sorge tragen, dass wir einen zus¨atzlichen Zeiger haben, die Variable st, welcher immer auf das oberste Element unseres Stacks zeigt. Um dies zu erreichen, ist es am elegantesten, wenn wir (obwohl wir unseren Stack als Array implementieren) eine Klasse definieren, die diesen Zeiger auf das oberste Stackelement als Attribut verwendet.

c l a s s S t a c k :

d e f i n i t ( s e l f ) : s e l f . s t = [ ]

d e f p u s h ( s e l f , v ) : s e l f . s t = [ v , s e l f . s t ] d e f t o p ( s e l f ) :

r e t u r n s e l f . s t [ 0 ] d e f pop ( s e l f ) :

v = s e l f . s t [ 0 ]

s e l f . s t = s e l f . s t [ 1 ] r e t u r n v

Eine Verwendung der Klasse sieht dann wie folgt aus, wobei die zugeh¨orige Ausgabe als Kommentar in der entsprechenden Zeile steht:

s = S t a c k ( ) s . p u s h ( 4 2 ) s . p u s h ( 7 3 )

p r i n t( s . pop ( ) ) # 73 s . p u s h ( 7 )

p r i n t( s . pop ( ) ) # 7 p r i n t( s . pop ( ) ) # 42

p r i n t( s . pop ( ) ) # L a u f z e i t f e h l e r , da d e r S t a c k l e e r i s t

Betrachtet man die Methoden der Klasse, so sehen wir, dass ¨uberhaupt keine Schleifen verwendet werden. Alle Stack-Operationen haben also konstante Laufzeit, unabh¨angig von der Gr¨oße des Stacks.

Anstelle eines Arrays kann man auch wieder eine Klasse oder ein Dictionary verwenden.

Hierbei ben¨otigt man aber eine weitere Klasse, welche vom Prinzip nur Paare realisiert. Wir betrachten eine entsprechende Implementierung in den ¨Ubungen. Auf der anderen Seite ist es auch M¨oglich, anstelle der Klasse Stack ein Array zu verwenden, in welchem wir den Zeiger st, der auf das oberste Stackelement zeigt, als Eintrag kodieren. Dann k¨onnen wir dieses Listenelement (wie das Attribut in der Stack-Klasse) ver¨andern, wenn wir eine Element pushen oder popen. Der Code s¨ahe dann wie folgt aus:

(32)

d e f e m p t y s t a c k ( ) : r e t u r n [ [ ] ]

d e f p u s h ( v , s ) : s t = s [ 0 ] s [ 0 ] = [ v , s t ] d e f t o p ( s ) :

s t = s [ 0 ] r e t u r n s t [ 0 ] d e f pop ( s ) :

s t = s [ 0 ] v = s t [ 0 ] s [ 0 ] = s t [ 1 ] r e t u r n v

Anstelle der Klasse Stack verwenden wir ein Einelementiges Array, um den Zeiger st darin abzulegen. Wir k¨onnen auf ihn mittels s [0] zugreifen, was wir durch das Verwenden der Hilfsvariable st verdeutlichen. Beim ¨Andern von st ist es aber wichtig, dass wir s [0] mo- difizieren. Das Attribut aus der Klassenimplementierung entspricht ja dem nullten Element unserer einelementigen Liste s.

Die entsprechende Verwendung dieser Funktionen sieht dann wie folgt aus:

s = e m p t y s t a c k ( ) p u s h ( 4 2 , s )

p u s h ( 7 3 , s )

p r i n t( pop ( s ) ) # 73 p u s h ( 7 , s )

p r i n t( pop ( s ) ) # 7 p r i n t( pop ( s ) ) # 42

p r i n t( pop ( s ) ) # L a u f z e i t f e h l e r , da d e r S t a c k l e e r i s t

Der Stack wandert nat¨urlich als zus¨atzliches Argument in die Funktionen. Er ist aber genau wie zuvor ein Objekt (in der Implementierung ja ein Array), welches mutiert wird.

3.3. Queue

Im Gegensatz zum Stack realisiert eine Queue das First InFirst Out - FiFo-Prinzip. Im Deutschen verwendet man auch den BegriffWarteschlange. In einer Queue kann also immer nur lesend auf den ¨altesten Werte zugegriffen werden und auch nur dieser kann entfernt werden. Die Operationen einer Queue bezeichnet man als enqueue (zum Hinzuf¨ugen) und dequeue (zum Entfernen). Ggf. kann auch wieder mittels top auf das ¨alteste Element der Queue zugegriffen werden.

Auch f¨ur die Implementierung der Queue k¨onnen wir wieder eine verkettete Liste, wie beim Stack, verwenden. Allerdings ben¨otigen wir nun zwei Zeiger in unsere Struktur. Der eine

(33)

Zeiger zeigt auf das Ende, aus welchem wir lesen wollen (dequeue) und der andere Zeiger auf das Ende, in welches wir schreiben wollen (enqueue).

Beispiel

In den folgenden Grafiken wird jedes Element in der Queue als eine zweielementige Liste dargestellt. In der Queue sind die Werte 3 und 2 gespeichert. Der Aufruf von dequeue() w¨urde 3 zur¨uckgeben und den linken Zeiger (genau wie beim Stack) auf das n¨achste Element der Queue setzen, welches den Wert 2 enth¨alt.

3 , 2 ,

Der Aufruf von enqueue(1) f¨ullt das letzte leere Element mit der 1 und dem Zeiger auf eine neue leere Liste, in die dann der n¨achste Wert geschrieben werden kann.

1

,

2

,

3

,

3.4. Implementierungen

Diese Datenstruktur kann genau wie der Suchbaum und der Stack als Klasse, als ver- schachtelte Liste oder als verschachteltes Dictionary implementiert werden. Wie beim Stack werden keine Schleifen ben¨otigt und die Laufzeit aller Operationen ist konstant.

Da wir zwei Zeiger auf unserer Queue ben¨otigen, initialisieren wir zwei Attribute, welche f¨ur die leer Queue auf dieselbe leere Liste zeigen.

c l a s s Queue :

d e f i n i t ( s e l f ) : s e l f . hea d = [ ]

s e l f . end = s e l f . h ead

Wenn wir nun eine Element hinzuf¨ugen wollen, ¨andern wir nur das Attribut end. Zum einen mutieren wir das Array, welches das Ende der Queue markiert (alter Wert des end-Attributs). Zum anderen erstellen wir eine neue Leere Liste, welche wir in die Ket- te einh¨angen und welche wir im Attribut end speichern.

d e f e n q u e u e ( s e l f , v ) : n e w e n d = [ ]

s e l f . end . e x t e n d ( [ v , n e w e n d ] ) s e l f . end = n e w e n d

(34)

Im Gegenzug verschieben wir head in dequeue und liefern den ersten Wert zur¨uck. Wie bei pop beim Stack, liefert die Methode einen Laufzeitfehler, wenn die Queue leer ist.

d e f d e q u e u e ( s e l f ) : v = s e l f . he ad [ 0 ]

s e l f . hea d = s e l f . hea d [ 1 ] r e t u r n v

Wie beim Stack k¨onnen wir auch unsere Queue testen:

q = Queue ( ) q . e n q u e u e ( 4 2 ) q . e n q u e u e ( 7 3 )

p r i n t( q . d e q u e u e ( ) ) # 42 q . e n q u e u e ( 7 )

p r i n t( q . d e q u e u e ( ) ) # 73 p r i n t( q . d e q u e u e ( ) ) # 7

p r i n t( q . d e q u e u e ( ) ) # L a u f z e i t f e h l e r , da d i e Queue l e e r i s t Analog zum Stack k¨onnen wir auch eine Variante definieren, bei der wir unserer Zeiger in einem Array anstelle in einer Klasse speichern ( ¨Ubung).

Abschließend sollten wir noch einmal die Laufzeit unserer Stack- und Queue-Methoden be- trachten. Wir verwenden ¨uberhaupt keine Schleife. Dies bedeutet, dass alle Methoden kon- stante Laufzeit unabh¨angig von der Anzahl der Elemente in der Struktur sind. Tats¨achlich sind diese Implementierungen auch in der Praxis sehr effizient. Durch die wenigen Ope- rationen innerhalb der Methoden stellt diese Implementierungen optimale Realisierungen von Stacks und Queues dar.

3.5. Mutierende und nicht-mutierende Operationen

In Informatik I (2F/NF)/Informatik f¨ur die Naturwissenschaften wurden bereits mutieren- de und nicht-mutierende Datenstrukturen behandelt. So werden Zahlen in der Regel als Objekte repr¨asentiert, welche nicht mutiert werden k¨onnen. Addiert man z.B. zwei Zah- len, so wird nicht eine Zahl ver¨andert, vielmehr wird eine neue Zahl zur¨uck gegeben. In Python sind z.B. auch Strings immutable. Es gibt keine Methode, mit der man ein String- Objekt ver¨andern kann. Alle Methoden liefern neue Objekte zur¨uck. Im Gegensatz hierzu sind Listen mutierbar. Man kann sowohl Elemente, als auch Teillisten eines Listen-Objekts ver¨andern. Außerdem kann man Listen mit den Methoden append udn extend verl¨angern.

Dennoch gibt es auch nicht-mutierende Operationen f¨ur Listen, wie z.B. die Funktion +.

Untersucht man diese Funktion, etwas genauer, stellt man eine wichtige Eigenschaft von nicht-mutierenden Methoden/Funktionen fest:

l 1 = [ 1 , 2 , 3 ] l 2 = l 1 + [ ] l 1 [ 1 ] = 42

p r i n t( l 1 ) # [ 1 , 4 2 , 3 ] p r i n t( l 2 ) # [ 1 , 2 , 3 ]

(35)

Dies bedeutet, dass die Funktion + immer ein neues Objekt anlegt, auch wenn wie hier die Ergebnisliste identisch zur Ausgangsliste ist.

Dies ist f¨ur Listen besonders wichtig, da es eben auch mutierende Methoden gibt. Um zu verdeutlichen, warum es wichtig ist, immer neue Listen anzulegen, implementieren wir eine nicht-mutierende Funktion zum Umdrehen einer Liste. Um auch noch einmal Rekursion zu uben, implementieren wir die Funktion rekursiv:¨

d e f r e v ( l ) :

i f l e n( l ) <= 1 : r e t u r n l

e l s e :

r e t u r n r e v ( l [ 1 : ] ) + [ l [ 0 ] ]

Wir verwenden diese Funktion nun in einem kleinen Testprogramm:

l 1 = [ 1 , 2 , 3 ] l 2 = r e v ( l ) l 1 . append ( 4 2 )

p r i n t( l 1 ) # [ 1 , 2 , 3 , 4 2 ] p r i n t( l 2 ) # [ 3 , 2 , 1 ]

Andert man nun aber nur die initiale Liste ergibt sich ein v¨¨ ollig anderes Programmverhalten:

l 1 = [ 1 ] l 2 = r e v ( l ) l 1 . append ( 4 2 )

p r i n t( l 1 ) # [ 1 , 4 2 ] p r i n t( l 2 ) # [ 1 , 4 2 ]

Der Grund ist, dass die Funktion rev f¨ur Listen bis zur L¨ange 1 einfach ihren Parameter zur¨uck gibt. Somit verweisen l1 und l2 auf das gleiche Objekt und die Mutation von l1 mittels append ver¨andert (zumindest gef¨uhlt) auch l2. Tats¨achlich werden aber gar nicht die Variablen l1 und l2 ver¨andert, sonder das Objekt, auf welches beide Variablen verweisen wird mutiert.

Der Fehler in diesem Programm befindet sich also in der Funktion rev. Hier m¨ussen wir f¨ur jede Liste garantieren, dass nie dasselbe Objekt zur¨uckgegeben wird, mit welchem rev aufgerufen wurde. Ggf. m¨ussen wir eine Kopie erstellen:

d e f r e v ( l ) : i f l e n( l ) < 1 :

r e t u r n l i s t( l ) # Auch h i e r muss e i n n e u e s O b j e k t

# e r s t e l l t w e r d e n e l s e :

r e t u r n r e v ( l [ 1 : ] ) + [ l [ 0 ] ]

(36)

Man kann sich vorstellen, dass in gr¨oßeren Programmen solche Fehler nur sehr schwer zu finden sind. Variablen in v¨ollig unabh¨angigen Programmteilen k¨onnen f¨alschlicherweise auf das gleiche Objekt zeigen. Mutiert man dann das Objekt dann in einem Programmteil, ist das f¨ur den anderen Programmteil v¨ollig unerwartet und der Fehler kann der z.B. in der Funktion rev vorliegt, kann nur sehr schwer gefunden werden. Auch Unit-Tests der Funktion rev k¨onnen solche Fehler nicht finden.

Merksatz: Falls es f¨ur einen Datentypen auch mutierende Methoden/Ope- rationen gibt, m¨ussen die nicht mutierenden Methoden/Operationen f¨ur alle Eingaben neue Objekte als Ergebnis liefern, d.h. sie d¨urfen keine ¨ubergebenen Objekte unkopiert zur¨uckgeben. Funktionen und Methoden, die sich an diese Regel nicht halten, m¨ussen als inkorrekt angesehen werden.

3.6. Nicht-mutierender Stack

F¨ur manche Datenstrukturen zeigt sich, dass nicht-mutiernde Implementierungen auch einige Vorteile ausweisen. Ein Beispiel hierf¨ur sind Stacks.

Ein Nachteil der Stack-Implementierung mit mutierenden Methoden ist, dass es nicht einfach m¨oglich ist, alte Stacks zu speichern und weiter zu verwenden. Hierzu m¨usste man z.B. eine clone Funktion zur Verf¨ugung stellen, welche eine Kopie eines Stacks anlegt ( ¨Ubung). Allerdings kann diese Funktion nur in linearer Laufzeit in der L¨ange der Liste realisiert werden.

Eine alternativer Ansatz ist eine Implementierung, welche nur nicht-mutierende Operatio- nen zur verf¨ugung stellt. Die Idee ist, dass man bei push einfach einen neuen Stack zur¨uck gibt, welcher mit einem neuen Stackelement beginnt und auf den vorherigen Stack ver- weist. Ist der vorherige Stack noch (z.B. durch eine andere Variable) referenziert, so kann dieser immer noch zugegriffen werden.

Beispiel

Eine Verwendung dieser Stack-Implementierung k¨onnte wie folgt aussehen:

s t 0 = p u s h ( 2 , e m p t y s t a c k ) s t 1 = p u s h ( 3 , s t 0 )

s t 2 = p u s h ( 1 , s t 1 ) s t 3 = pop ( s t 2 ) s t 4 = p u s h ( 5 , s t 1 )

Wir haben also insgesamt f¨unf unterschiedliche Stacks, welche sich nat¨urlich durch push bzw. pop aus den anderen Stacks ergeben. Dies kann durch die folgende Speicherstruktur dargestellt werden:

(37)

3 , 2 , 1 ,

5 ,

st2 st3 st1

st4

st0

Wenn nicht jedes Mal eine neue Variable erzeugt werden soll, kann die alte nat¨urlich auch einfach ¨uberschrieben werden, z.B. st0 = push(3,st0) und der alte Stack ist nur noch als Reststack im neuen Stack, aber nicht mehr direkt referenziert.

Durch die nicht-mutierende Implementierung des Stacks ist es also m¨oglich, dass viele verschiedene Stacks sich gemeinsame Abschnitte teilen. Das funktioniert, da es keine mu- tierende Operation auf den gemeinsamen Abschnitten der Stacks gibt. Durch push(v, st ) werden neue Elemente hinzugef¨ugt, die von den schon bestehenden Stacks aber nicht er- reichbar sind, und somit keinen Einfluss darauf haben und durch pop(st) wird auch nicht

st ver¨andert, sondern nur der Zeiger auf das n¨achste Element zur¨uck gegeben.

st2

st3 st1

st4

st0

st5

F¨ur die Implementierung ist es wichtig, dass die beiden Funktionen einen neuen Stack als R¨uckgabewert haben. Das bedeutet aber insbesondere f¨ur die pop(st)-Funktion, dass sie nicht mehr gleichzeitig den letzten Wert zur¨uckgeben kann. Deshalb wird die Funktion top( st ) notwendig, die nichts weiter tut, als den letzten Wert des Stacks zur¨uckzugeben.

Um ihn zu

”l¨oschen“ muss dann noch zus¨atzlich die pop(st)-Funktion aufgerufen werden, die das Element aber nicht wirklich aus dem Speicher l¨oscht, sondern nur st auf das n¨achste Element zeigen l¨asst.

Die Implementierung mit verschachtelten Listen ist denkbar einfach:

d e f e m p t y s t a c k ( ) : r e t u r n [ ]

d e f p u s h ( v , s ) : r e t u r n [ v , s ] d e f t o p ( s ) :

r e t u r n s [ 0 ]

(38)

d e f pop ( s ) : r e t u r n s [ 1 ]

Eine Verwendung, wenn man immer nur den ver¨anderten Stack weiterverwendet sieht dann wie folgt aus:

s = e m p t y s t a c k ( ) s = p u s h ( 4 2 , s ) s = p u s h ( 7 3 , s )

p r i n t( t o p ( s ) ) # 73 s = pop ( s )

s = p u s h ( 7 , s )

p r i n t( t o p ( s ) ) # 7 s = pop ( s )

p r i n t( t o p ( s ) ) # 42

Es ist aber auch m¨oglich, alle Zwischen-Stacks weiterzuverwenden:

s = p u s h ( 2 , e m p t y s t a c k ( ) ) s 1 = p u s h ( 7 3 , s )

s 2 = p u s h ( 4 2 , s ) p r i n t( t o p ( s ) ) # 2 p r i n t( t o p ( s 1 ) ) # 73 p r i n t( t o p ( s 2 ) ) # 42

Eine Klassen-Implementierung ist ebenfalls m¨oglich und wird in den ¨Ubungen besprochen werden.

3.7. Nicht-mutierende Queue

Dieses Prinzip l¨asst sich leider nicht auf die Queue ¨ubertragen, da hier an zwei Stellen in den Werten ¨Anderungen vorgenommen werden k¨onnen, und nicht eindeutig festgelegt werden kann, welches Ende zu welchem Anfang geh¨ort. Folgende Grafik soll dies verdeutlichen:

q2

q3 q1

q4 q5

?

?

(39)

3.8. Nicht-mutierender Suchbaum

Die Technik zur Konstruktion eines nicht-mutierenden Stacks l¨asst sich auch auf andere Datenstrukturen ¨ubertragen. Ein sehr sch¨ones Beispiel sind Suchb¨aume. Hierdurch werden Suchb¨aume auch nicht mehr mutiert, sondern, es werden ver¨anderte B¨aume zur¨uck gege- ben. Dies klingt, als ob die gesamte Datenstruktur kopiert werden m¨usste. Dies ist aber nicht der Fall. Der gr¨oßte Teil der Suchb¨aume kann sie die Restlisten bei den Stacks zwi- schen den unterschiedlichen Suchb¨aumen geteilt werden,was wir noch genauer analysieren werden.

Beispiel

Wir betrachten den Suchbaum tr0, welcher die Werte 8, 5, 42, 1, 10 und 73 enth¨alt.

Wenn man nun den Wert 6 in diesen Suchbaum einf¨ugt, so muss dieser unterhalb der 5 einsortiert werden. Hierzu ist es aber ausreichend, wenn nur die Knoten von der Wurzel bis zum neuen Knoten 6 neu angelegt werden. Die jeweils anderen Teilb¨aume k¨onnen zwischen beiden B¨aumen geteilt werden, was man auch als sharing von Teilstrukturen bezeichnet.

In unserem Beispiel sind dies der vergleichsweise kleine Teilbaum, welcher nur die 1, aber auch der große Teilbaum, der die 42,10 und 73 enth¨alt.

So entsteht der ver¨anderte Suchbaum tr1.

8 5 1

42 10 73

8 5

6

tr1 tr0

Der Suchbaum tr0 bleibt aber unver¨andert und kann ebenfalls weiter verwendet werden.

Hierbei hat der Knoten 42 aber keinen eindeutigen Elternknoten mehr. Dies ist aber auch nicht notwendig, da auch in unserer bisherigen Implementierung kein Zeiger von einem Kindknoten zum Elternknoten gab. In einen Suchbaum m¨ussen wir bei allen Operationen nur ab- und nie aufgestiegen.

Sollte tr0 nicht mehr referenziert werden, werden seine Knoten 8 und 5 durch den Garbage- Collector automatisch frei gegeben, so dass auch keine zus¨atzlicher Speicher verbraucht wird.

Die Implementierung gestaltet sich ¨ahnlich einfach, wie die nicht-mutierende Implemen- tierung des Stacks. Hierbei ist es wichtig zu verstehen, dass man nun nicht mehr in den

(40)

Suchbaum absteigt und ihn dort unten ver¨andert. Vielmehr steigt man ab und rekonstruiert den Suchbaum auf dem R¨uckweg der Rekursion. Wir starten zun¨achst wieder mit unseren Smartkonstruktoren f¨ur Bin¨arb¨aume, sowie den passenden Selektoren und Testfunktionen.

d e f node ( l , v , r ) : r e t u r n [ l , v , r ] d e f empty ( ) :

r e t u r n [ ]

# S e l e k t o r e n d e f l e f t ( l ) :

r e t u r n l [ 0 ] d e f r i g h t ( l ) :

r e t u r n l [ 2 ] d e f v a l u e ( l ) :

r e t u r n l [ 1 ]

# T e s t f u n k t i o n

d e f i s e m p t y ( t r e e ) : r e t u r n t r e e == [ ]

Wie zuvor steigt die add-Funktion im Suchbaum ab. Auf dem R¨uckweg wird nun aber der neue Suchbaum wieder zusammengesetzt.

d e f add ( v , t r e e ) :

i f i s e m p t y ( t r e e ) :

r e t u r n node ( empty ( ) , v , empty ( ) ) e l i f v < v a l u e ( t r e e ) :

r e t u r n node ( add ( v , l e f t ( t r e e ) ) , v a l u e ( t r e e ) , r i g h t ( t r e e ) ) e l i f v > v a l u e ( t r e e ) :

r e t u r n node ( l e f t ( t r e e ) . v a l u e ( t r e e ) , add ( v , r i g h t ( t r e e ) ) ) e l s e :

r e t u r n t r e e # v i s t b e r e i t s im Baum , s o d a s s

# n i c h t s g e a e n d e r t w e r d e n muss

Im Gegensatz zu zuvor hat unserer nicht-mutierende Funktion bereits den ver¨anderten Suchbaum als R¨uckgabewert. Wir k¨onnen also nicht so einfach einen booleschen Wert als Ergebnis liefern, der angibt, ob der Wert vorher schon im Baum war oder nicht. In den Ubungen werden wir das aber unter Verwendung von zwei R¨¨ uckgabewerten in Form eines Paars realisieren.

Die Implementierung zerlegt in den einzelnen F¨allen also den aktuellen Knoten und setzt ihn im R¨uckgabewert neu zusammen. Dabei werden die unver¨anderten Teilb¨aume des aktuellen Knotens einfach mittels left ( tree ) bzw. right ( tree ) wieder in den neuen Knoten eingebaut, ohne in diese abzusteigen.

Referenzen

ÄHNLICHE DOKUMENTE

Peter Becker Fachbereich Informatik Sommersemester 2014 15... Mai 2014 in

Eine Reflexion wird durch eine Antireflexbeschichtung unterdr¨ uckt, so dass die gesamte Intensit¨at in das Glas eintritt.. Zur n¨aherungs- weisen Berechnung der Reflexion als

Universit¨ at T¨ ubingen T¨ ubingen, den 03.02.2009 Mathematisches

Zeigen Sie dann unter Verwendung von Aufgabe 10.4, dass Φ(N ) eine Lebesgue- Nullmenge ist. Abgabetermin:

Die Aufgabe kann jedoch auch vollst¨ andig ohne Verwendung des lokalen Dreibeins gel¨ ost werden (wenn auch weniger elegant).. Wie lautet die Energie

Dort liegt im doc- Verzeichnis das Tutorial tutorial.pdf, das beschreibt, wie eine komplexe Geometrie (Flasche mit Ge- winde) mit dem CAD-Kernel modelliert werden kann.. Zun¨achst

Wird zu einer Zeile einer Matrix ein Vielfaches einer anderen Zeile hinzuaddiert, so bezeichnen wir diese Zeilenumformung als Typ I.. Wird eine Zeile mit einer anderen getauscht,

[r]