Kapitel 4 Sortieren und Suchen 4.1 Sortieren Die Sortierung von Mengen von Datensätzen ist eine häufige algorithmische Operation auf Mengen bzw. Folgen von gleichartigen Datenobjekten (insbesondere in der betriebswirtschaftlichen Datenverarbeitung). Normalerweise gibt es einen Schlüsselbegriff nach dem sortiert wird, und zugehörige Attribute, die dem Schlüsselbegriff zugeordnet sind, d.h. man sortiert records bzw. Objekte. Im allgemeinen braucht man eine Prä-Ordnungsrelation (transitiv, reflexiv, total, nicht notwendig antisymmetrisch), die die Schlüsselbegriffe sortiert. Diese ist nicht automatisch gegeben. Z.B. ist die Sortierung von Texten, die im englischen / amerikanischen einfach auf der internen Darstellung durchgeführt wird, problematisch bei der Verwendung von Umlauten, da diese normalerweise aufgrund der internen Darstellung oft falsch einsortiert werden. Für die richtige Sortierung benötigt man die Definition einer passenden Ordnung auf den Zeichen, die z.B. ü zum u dazusortiert, d.h. t < u < ü < v, oder auch t < u ∼ ü < v, wie in einem Wörterbuch. Wir betrachten im folgenden das etwas vereinfachte Problem der Sortierung einer gegebenen Liste (Menge) von ganzen Zahlen. Wir betrachten hier vier typische Sortierverfahren. Sortieren durch Einfügen (Insertion Sort) fügt die restlichen Elemente nach und nach in die bereits sortierte Liste der abgearbeiteten Zahlen. Sortieren mit Blasensort (Bubble Sort) Betrachtet jeweils zwei benachbarte Elemente und bringt diese in die richtige Reihenfolge. Der Vergleich läuft in zwei Schleifen: Die innere startet mit den letzten beiden Elementen und schiebt das Vergleichsfenster nach vorne. Danach ist das kleinste Element am Anfang der Liste. Dieses Verfahren wird jeweils mit der kleineren Liste wiederholt. Man kann abbrechen, wenn sich in einem inneren Durchlauf nichts verändert hat. Quicksort Rekursives Verfahren, das das erste Element als Pivot nimmt, dann 1 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 2 die Liste zerlegt in eine Liste der kleineren und eine Liste der größeren Elemente, diese rekursiv sortiert, und dann einfach hintereinander hängt mit dem Pivot dazwischen. Mischsort (Merge Sort) Rekursives Verfahren, das die Liste in zwei Hälften zerlegt, diese rekursiv sortiert und dann wieder zusammenmischt. Zunächst die Implementierung der vier Sortierverfahren in Haskell. Hierbei wird die Kernidee der entsprechenden Verfahren demonstriert, nicht jedoch die Methoden der Speicherausnutzung wie in imperativen Programmiersprachen. Der Aspekt der Speicherverwaltung bei den Sortierverfahren wird noch angesprochen im Zusammenhang mit einer Implementierung in Python. ---Sortieren von Listen von Zahlen -- einfuegen sorteinfuegen xs = sorteinfuegenr xs [] sorteinfuegenr [] ys = ys sorteinfuegenr (x:xs) [] = sorteinfuegenr xs [x] sorteinfuegenr (x:xs) ys = sorteinfuegenr xs (sorteinfuegenr1 x ys) sorteinfuegenr1 x [] = [x] sorteinfuegenr1 x (y:ys) = if x <= y then x:y:ys else y : (sorteinfuegenr1 x ys) -- Insert-Sort: stabil und sorteinfuegeno xs = reverse sorteinfuegenor [] ys = ys sorteinfuegenor (x:xs) [] = sorteinfuegenor (x:xs) ys = O(n) f"ur vorsortierte: (sorteinfuegenor xs []) sorteinfuegenor xs [x] sorteinfuegenor xs (sorteinfuegenor1 x ys) sorteinfuegenor1 x [] = [x] sorteinfuegenor1 x (y:ys) = if x >= y then x:y:ys else y : (sorteinfuegenor1 x ys) -- Blasensort (bubblesort) bubblesort [] = [] bubblesort [x] = [x] bubblesort xs = let y:resty = bubblesort1 xs in y: (bubblesort resty) bubblesort1 [x] = [x] bubblesort1 (x:rest) = let (y:resty) = bubblesort1 rest Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 in if x else > y then y:x: resty (x: y:resty) -- Blasensort mit Optimierung bubblesorto [] = [] bubblesorto [x] = [x] bubblesorto xs = let (aenderung,y:resty) = bubblesorto1 xs in if aenderung then y: (bubblesorto resty) else xs bubblesorto1 [x] = (False,[x]) bubblesorto1 (x:rest) = let (aenderung, y:resty) = bubblesorto1 rest in if x > y then (True,y:x: resty) else (aenderung,x: y:resty) -- quicksort quicks [] = [] quicks [x] = [x] quicks [x,y] = if x <= y then [x,y] else [y,x] quicks (x:xs) = let (llt,lge) = splitlist x xs in (quicks llt) ++ (x: (quicks lge)) splitlist x y = splitlistr x y [] [] splitlistr x [] llt lge = (llt,lge) splitlistr x (y:ys) llt lge = if y < x then splitlistr x ys (y: llt) lge else splitlistr x ys llt (y:lge) -Versuch einer Optimierung durch variierte Wahl des Pivots. quicks2 [] = [] quicks2 [x] = [x] quicks2 [x,y] = if x <= y then [x,y] else [y,x] quicks2 (x:xs@(y:z:rest)) = if x >= y && x <= z || x >= z && x <= y then let (llt,lge) = splitlist x xs in (quicks2 llt) ++ (x: (quicks2 lge)) else quicks2 (z:x:y:rest) --- merge-sort mischsort mergesort mergesort mergesort xs = mergesort (length xs) xs _ [] = [] _ [x] = [x] _ [x,y] = if x <= y then [x,y] else [y,x] 3 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 4 mergesort len xs = let lenh = len ‘div‘ 2 in mische (mergesort lenh (take lenh xs)) (mergesort (len -lenh) (drop lenh xs)) mische [] ys = ys mische xs [] = xs mische (x:xs) (y:ys) = if x <= y then x: (mische xs (y:ys)) else y: (mische (x:xs) ys) Aussage 4.1.1 Sortieren von n Zahlen mittels Vergleich von Zahlen benötigt im schlechtesten Fall mindestens n ∗ log2 (n) Vergleiche. Genauer: Man nimmt an: das Programm hat die Eingaben a1 , . . . , an , das Programm ist ein Entscheidungsbaum, der an den Verzweigungen die Frage stellt: ist ai > aj für bestimmte Indizes i, j, und die Ausgabe am Ende ist die sortierte Permutation der ai . Dann gibt es immer eine Eingabemöglichkeit, so dass n ∗ log(n) Vergleiche nötig sind. Begründung. Der Entscheidungsbaum hat mindestens n! Blätter: pro Permutation der Eingabe ein Blatt. Die Tiefe d des Baumes ist mindestens log2 (n!) Offenbar gilt n! ≥ |1 ∗ .{z . . ∗ 1} ∗ n/2 ∗ . . . ∗ n/2 = (n/2)(n/2) . Anwenden des Loga| {z } n/2 n/2 rithmus ergibt log2 (n!) > log2 ((n/2)(n/2) ) = 0.5 ∗ n ∗ log2 (n/2) = 0.5 ∗ n ∗ (log2 (n) − 1). D.h. Ω(n ∗ log2 (n)). Eine andere Abschätzung dieser unteren Schranke der minimal notwendigen Anzahl der Vergleiche ist möglich mittels der Stirlingformel für n!. Der Faktor 0.5 steigt mit wachsendem n, aber bleibt < 1, da ja n! < nn . Allerdings konnte man noch nicht allgemein nachweisen, dass dies auch eine untere Schranke ist, wenn man alle Algorithmen zulässt. In bestimmten Spezialfällen kann es schnellere Algorithmen geben, z.B. wenn die Länge des Schlüssels nicht zu groß wird. Bemerkungen zu den Eigenschaften der Sortierverfahren Sortieren durch Einfügen Ist ein einfaches Verfahren, das für kleinere Listen schnell genug ist. Die Anzahl Reduktionen (Zeitbedarf) ist im schlechtesten Fall quadratisch: man benötigt für eine Liste der Länge n höchstens: 1 + 2 + . . . + (n − 1) Vergleiche (und Operationen). Dies sind (n − 1) ∗ n/2, d.h. O(n2 ). Das Verfahren ist auch im besten Fall linear, da der Vergleich und Einfügen evtl. nur einmal pro Element gemacht werden, wenn die Liste in der richtigen Reihenfolge vorsortiert ist. Sortieren mit Bubbleort Ebenfalls quadratisch im schlechtesten Fall: (n − 1) + (n − 2) + . . . + 1 Operationen. Der beste Fall tritt ein, wenn die Liste Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 5 schon sortiert ist, oder schon fast sortiert ist, da dann abgebrochen werden kann. Quicksort Dieses Verfahren hat Vorteile, wenn die Listen groß sind und die Werte zufällig verteilt sind. Quicksort ist im schlechtesten Fall quadratisch. Im Mittel ist der Aufwand n ∗ log(n). Beim Experimentieren in Haskell mit zufällig erzeugten Listen erscheint es als das beste Verfahren. Mischsort Ist im schlechtesten Fall O(n ∗ log(n)). Beim Experimentieren in Haskell ist es nur dann besser als Quicksort, wenn Teile der Listen bereits sortiert sind. In imperativen Sprachen hat es den Nachteil, dass man bei einer naiven Implementierung die Listen jeweils neu aufbauen muss. Es gibt eine Implementierung, die auch ohne extra Speicher auskommt, und die effizient ist, und einen angepassten und ausgefeilten Merge-Algorithmus verwendet. Wir zeigen, dass der Mischsort einen Zeitbedarf von O(n ∗ log(n)) hat: Dazu müssen wir redms abschätzen. redms (n) = c + redmische (n) + 2 ∗ redms (n/2) = c + n + 2 ∗ redms (n/2) ≤ c + n + 2 ∗ (c + n/2 + 1) + 4 ∗ redms (n/4). Da dies log(n) Schritte erfordert, ergibt sich als Abschätzung: c ∗ n + n ∗ log(n) + 2 ∗ log(n). Der Term in der Mitte ist asymptotisch am größten, d.h. der Mischsort ist O(n ∗ log(n)). 4.1.1 Sortierprogramme in imperativen Programmiersprachen, Speicherverwaltung In imperativen Programmiersprachen kommt zur eigentlichen Kernidee der Sortieralgorithmen noch der Aspekt der effizienten Speicherverwaltung dazu. Normalerweise ist die Eingabe ein Array der Schlüssel und Daten. Algorithmen, die die Sortierung nur innerhalb des Arrays vornehmen durch Tauschvorgänge, nennt man auch In-Place“Verfahren. ” Die betrachteten Sortierverfahren lassen sich bis auf Misch-Sort leicht als InPlace-Verfahren programmieren. Der entsprechende Programmcode in Python folgt. Der Merge-Sort kann durch ein effizientes In-Place-Merge-Verfahren ebenfalls implementiert werden, allerdings ist der Algorithmus nicht offensichtlich zu finden: Man benötigt dazu ein schnelles in-place-Mischen. Ein neuer Algorithmus wurde von Arne Kutzner und Pok-Son Kim gefunden def Quicksort(soArray): return Quicksortr(soArray,0,len(soArray)-1) def Quicksortr(soArray,iLo,iHi): if (iHi-iLo == 1): ##Optimierung, wenn Teilfeld zwei Elemente enthaelt if_groesser_then_tausch(soArray,iLo,iHi); return soArray; Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 ## int Lo, Hi, Mid; ## long T,Mid2; Lo = iLo; Hi = iHi; Mid = (Lo + Hi) / 2; ## print "Mid: ", Mid; while (Lo <= Hi): while (isKleiner(soArray,Lo,Mid)): Lo = Lo+1 while (isGroesser(soArray,Hi,Mid)): Hi = Hi -1; if (Lo <= Hi): if (Mid == Hi): Mid = Lo; elif (Mid == Lo): Mid = Hi; if (Lo != Hi): vertausche(soArray,Lo, Hi); Lo = Lo +1; Hi = Hi-1; if (Hi > iLo): if (Lo < iHi): return soArray Quicksortr(soArray,iLo, Hi); Quicksortr(soArray,Lo, iHi); def isGroesser(soArray,x,y): def isKleiner(soArray,x,y): return (soArray[x] > soArray[y]) return (soArray[x] < soArray[y]) def vertausche(soArray,ind1, ind2): x = soArray[ind1]; soArray[ind1] = soArray[ind2]; soArray[ind2] = x; def if_groesser_then_tausch(soArray,ind1,ind2): if isGroesser(soArray,ind1,ind2): vertausche(soArray,ind1, ind2); Der Bubblesort in Python: def Bubblesort(soArray): laenge = len(soArray) for i in range(0,laenge-1): aenderung = 0 for j in range(0,laenge-1-i): 6 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 7 if soArray[j] > soArray[j+1]: vertausche(soArray,j, j+1) aenderung = 1 if aenderung == 0: break Der Insertsort in Python: def Insertsort(soArray): laenge = len(soArray) for i in range(1,laenge): for j in range(i,0,-1): if soArray[j] < soArray[j-1]: vertausche(soArray,j, j-1) else: break Ein Sortierverfahren nennt man stabil, wenn es die Reihenfolge der Eingabe nicht unnötig ändert, d.h. wenn die Eingaben mit gleichen Schlüsselwerten nicht in der Reihenfolge vertauscht werden. Hier verhalten sich die Haskell-Algorithmen anders als die imperativen Algorithmen. Die Haskell-Sortier-Algorithmen sind alle stabil, aber benötigen zusätzliche Listen zum Sortieren. Die imperativen In-Place-Sortierverfahren sind nur teilweise stabil: Einfügeund Bubble-Sort lassen sich leicht stabil implementieren. Der Merge-Sort kann ebenfalls stabil implementiert werden. Der imperative Quicksort, so wie oben implementiert, ist durch die nicht-lokalen Vertauschungen nicht stabil. Der Grund ist, dass beim Tauschen der Elemente um den Pivot herum die Reihenfolge gleicher Elemente vertauscht wird. 4.2 Datenabstraktion, abstrakte Datentypen Datenabstraktion ist eine Strukturierungsmethode für Programme und Implementierung, die unabhängig von einer Programmiersprache verwendet werden kann. Diese wird normalerweise als Modul implementiert, damit man die Funtionalität wiederverwenden kann. Wichtiges Prinzip ist die Trennung von interner Implementierung der Daten und der Zugriffsfunktionen von deren Verwendung. Auch dies wird durch Module unterstützt. Wesentliche Aspekte sind: • Die Daten sind über klar definierte Zugriffsfunktionen verfügbar • Die Algorithmen verwenden nur diese Zugriffsfunktionen • Die Daten haben eine Semantik, die von der Implementierung respektiert wird. Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 8 • Die Implementierung der Daten und der eigentlichen Zugriffe ist nicht sichtbar (Kapselung, information hiding). Hierzu werden normalerweise Module verwendet, die i.a. einen bestimmten Datentyp zusammen mit den Zugriffsfunktionen zusammenfassen und implementieren, aber nur ausgewählte Zugriffsfunktionen nach außen hin sichtbar machen (exportieren). Es gibt zwei Dinge, die man vermeiden sollte: • den Durchgriff auf die Implementerung, auch wenn diese kurzfristig die Effizienz steigert oder bequemer beim Programmieren ist. Die unsaubere Methode des Durchgriffs kann langfristig in Projekten zu Problemen führen, wenn z.B. die interne Implementierung des Moduls geändert wird. • Verwenden des durch die Implementierung bedingten Verhaltens, das aber nicht gerantiert ist. D.h. nicht in der Spezifikation enthalten ist. Z.B. darf man bei einer Implementierung von Mengen nicht darauf bauen, dass die Elemente in der Reihenfolge des Einfügens ausgegeben werden. Will man das ausnutzen, dann benötigt man die Datenstruktur Liste“. ” Die Funktionen auf einem abstrakten Datentyp kann man grob unterscheiden in • Konstruktoren bzw. Eingabe der Objekte • Fallunterscheidung nach den Konstruktoren und Selektoren; bzw.Frage nach der logischen Struktur der Datenobjekte, bzw. Ausgabe der Objekte, • interne Service-Funktionen. Z.B. Operatoren auf Objekten, Gleichheitstests, usw. • externe Service-Funktionen. Z.B. die Funktion map auf Listen. • Datenkonversionen Beispiele für typische Datentypen sind: Zahlen, ganze Zahlen, komplexe Zahlen oder rationale Zahlen. Beispiel 4.2.1 rationale Zahlen: • Konstruktion erfolgt durch Eingabe eines Paars von zwei ganzen Zahlen : Zähler und Nenner. • Ausgabe entweder durch Abfrage von Zähler bzw. Nenner. Hier ist zu beachten, dass die interne Darstellung im allgemeinen gekürzt ist, d.h. es gibt keine 1-1 Beziehung zwischen Konstruktion und Selektion. • Servicefunktion sind z.B. alle arithmetischen Funktionen. • Eine Datenkonversionen sind z.B. die Konversion einer natürlichen Zahl in eine rationale, oder umgekehrt, die Konversion eines Bruchs in eine ganze Zahl, die nicht immer exakt geht. Hierbie kann man runden oder abschneiden. Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 9 Oft kann man die Semantik eines abstraktion Datentypen durch Gleichheitsaxiome festlegen. Beispiel 4.2.2 Listen: Man braucht eigentlich nur die zwei Konstruktoren cons, nil und die zwei Selektoren head, tail mit den Gleichungen head (cons x y) tail (cons x y) = x = y Die Haskell-Implementierung kann erfolgen mittels head (x:y) tail (x:y) cons x y nil = = = = x y x:y [] Servicefunktionen auf Listen sind z.B. reverse, ++, concat, die man als intern bezeichnen kann, während map, filter eher externe Servicefunktionen sind. Beispiel 4.2.3 Der Datentyp natürliche Zahl“ kann mit den Konstruktoren ” Null, S aufgebaut werden. As Ausgabe für S(S(S(Null))) kann man dann S(S(S(Null))) drucken, oder die gemeinte Zahl: 3. Es ist kein Problem, aufbauend auf diese Darstellung alle arithmetischen Operatoren zu definieren. Die in Programmiersprachen verfügbaren Zahlen sind im allgemeinen auf binären Strings aufgebaut. Die Änderung der internen Implementierung sollte (außer Effizienz) keine Änderungen der Funktionalität bewirken. Beispiel 4.2.4 Der Datentyp ganze Zahlen“ kann auf dem Datentyp der ” natürlichen Zahlen aufbauen, indem man eine ganze Zahl als Paar (s, n) implementiert, wobei s das Vorzeichen + oder − ist, und n die positive Zahl. Diese Darstellung hat nur an einer Stelle eine Doppeldeutigkeit, nämlich bei der internen Darstellung der 0: die kann (+, 0) oder (−, 0) sein. Beispiel 4.2.5 Fließkommazahlen (Gleitkommazahlen): Es gibt Funktionen zum Erzeugen und zur Ausgabe, und die mathematischen Operationen: ∗, /, +, −. Hier ist die Semantik nicht die mathematische für reelle Zahlen, da sich irrationale Zahlen i.a. nicht beliebig genau darstellen lassen. D.h. man muss Näherungen einführen. Die Semantik der implementierten arithmetischen Operationen garantiert dann, dass diese die richtigen Werte der Muliplikation möglichst gut annähern. Damit dies auf allen Rechnern eindeutig ist, gibt es Normierungen (IEEE-Normen) für die internen Berechnungen, Rundungen und Fehlermeldungen bei Ausnahmen, je nach Genauigkeit. Beispiel 4.2.6 Gleitkommazahlen in Intervallarithmetik: Es gibt Funktionen zum Erzeugen, Anzeigen, mathematische Operationen: ∗, /, +, −. Hier ist die axiomatische Semantik auch der Operationen mathematisch erfassbar: Die Ergebnisse müssen immer in dem angegebenen Intervall liegen. Allerdings gelten dann nicht mehr alle arithmetischen Gesetze. Division durch ein Intervall, das die Null enthält muss verboten sein (bzw, einen Fehler ergeben.) Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 10 Beispiel 4.2.7 Der abstrakte Datentyp (Modul) Menge“ (endliche Mengen ” mit Elementen von gleichem Typ) sollte folgende Funktionalität bereitstellen: • Erzeugen von Mengen, gegeben eine endliche Aufzählung der Elemente • Test auf Enthaltensein eines Elementes in einer Menge • Drucken einer Menge • Kardinalität einer Menge • Bildung von Schnitt, Vereinigung, Differenz, Potenzmenge • Test auf Gleichheit von Mengen Bei allen obigen Funktionalitäten benötigt man einen Gleichheitstest der Elemente. Diese ist einfach bei Zahlen, aber bei komplizierteren Datenobjekten muss ein (evtl. nichttrivialer) Gleichheitstest für die Elemente zur Verfügung stehen. Beispiel 4.2.8 Der abstrakte Datentyp (Modul) Multimenge“: endliche Multi” mengen mit Elementen von gleichem Typ, wobei Elemente mehrfach vorkommen dürfen. Multimengen sind wie Listen, bei denen man von der Reihenfolge der Elemente absieht; Oder: Multimengen sind wie Mengen, bei denen man mehrfaches Vorkommen der Elemente erlaubt. Die Operatoren sind analog zu Mengen. Z.B. gilt für Multimengen {1, 1, 1, 2, 2} ∩ {1, 1, 2, 2, 2} = {1, 1, 2, 2} und {1, 1, 1, 2, 2} ∪ {1, 1, 2, 2, 2} = {1, 1, 1, 1, 1, 2, 2, 2, 2, 2}. Ein Gleichheitstest auf den Elementen ist jetzt nur noch nötig für die Funktion enthalten in“, den Glkeichheitstest von 2 Mutimengen, aber nicht für die ” Kardinalität. Die Implementierung von Multimengen ist besonders einfach auf der Basis von Listen. Als Basisfunktionen kann man nehmen: anzahl, einfuegen, cons, null. Der Datentyp wäre (Multimenge a) mit den Konstruktoren cons, null. Zwei exemplarische Gleichheiten sind dann: anzahl null = 0 ∀s :: a, t :: M ultimenge a : anzahl(einfuegen s t) = (anzahl t) + 1 Die Listenimplementierung mit anzahl = length, einfuegen = cons ist dann insoweit korrekt, denn: anzahl null → length [] → 0 anzahl (einfuegen s t) → length (cons s t) → (length t) + 1. Eine Implementierungsmöglichkeit für Mengen sind Listen von Elementen. Da man Schnitte von Mengen und die Kardinalität von Mengen ausrechnen will, muss man Gleichheit von Elementen testen können. Eine weitere Möglichkeit ist die Implementierung auf der Basis von Multimengen. Ein Datentyp “unendliche Liste“ oder “unendliche Menge“ ist möglich, allerdings auch problematisch, denn nicht alle unendlichen Mengen sind endlich darstellbar. Ein Effekt ist, dass man den Elementtest dann nicht korrekt implementieren kann. Abstraktionsbarrieren für die Beispiele Mengen / Multimengen / Listen sind: Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 16. Dezember 2004 11 Mengen: Schnitt, ... Schnittstellenfunktionen Multimengen: Schnitt, Vereinigung, Gleichheit, ... Schnittstellenfunktionen Listen. append, element, ... Problem: Der Ressourcenbedarf und die Effizienz hängt ab von der Implementierung der Zwischenschichten und oft auch von dem Zusammenspiel der verschiedenen Zwischenschichten. 4.3 Bäume Zunächst führen wir Graphen ein. Die einfachste Vorstellung ist, dass ein Graph gegeben ist als • eine Menge von Knoten und • eine Menge von zugehörigen (gerichteten oder ungerichtete) Kanten zwischen den Knoten. Einige Begriffe für ungerichtete Graphen sind: 12 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 Schlingen Kanten mit gleichem Anfangs- und Endknoten Wege Kantenfolgen (A, B), (B, C), . . . Kreise Kantenfolgen (A, B), (B, C), . . . , (Z, A) Erreichbarkeit A ist von B aus erreichbar, wenn es einen Weg von A nach B gibt zusammenhängend Wenn alle Knoten von jedem Knoten aus erreichbar sind. markierter Graph Knoten bzw. Kanten haben Markierungen Ein Baum ist ein (gerichteter oder ungerichteter) Graph, der zusammenhängend ist, ohne Kreise, und mit ausgezeichnetem Knoten (Wurzel) In Zeichnungen wird meist die Wurzel oben hingezeichnet, die Blätter sind unten. Vorgänger Vater Tocher Nachfolger Blatt Einige wichtige Begriffe für Bäume: geordneter Baum markierter Baum Rand des Baumes binärer Baum Höhe (Tiefe) balancierter (binärer) geordneter Baum Grad eines Knoten Grad eines Baumes Es gibt eine Links-Rechts-Ordnung auf den Töchtern Die Knoten haben Markierung (bzw. Kanten) Liste der Blattmarkierungen eines geordneten Baumes Jeder Knoten ist Blatt oder hat genau zwei Töchter maximale Länge eines Weges von der Wurzel zu einem Blatt hat unter (binären) Bäumen mit gleichem Rand kleinste Tiefe Anzahl der Töchter maximaler Grad eines Knoten Wir stellen binäre, geordnete Bäume mit folgender Datenstruktur dar: data Binbaum a = Bblatt Wr • • • • a | Bknoten (Binbaum a) (Binbaum a) betrachten zunächst Bäume mit folgenden Eigenschaften: Daten (Markierungen) sind an den Blättern des Baumes Die Daten sind von gleichem Typ Jeder (innere) Knoten hat genau zwei Tochterknoten es gibt einen linken und rechten Tochterknoten (geordnet) 13 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 Beispiel 4.3.1 Der folgende binäre Baum 7 1 3 4 hat eine Darstellung als Bknoten (Bknoten (Bblatt 1) (Bknoten (Bblatt 3) (Bblatt 4))) (Bknoten (Bblatt 7) (Bblatt 8)) Einige Verarbeitungsfunktionen sind: -- Liste der Markierungen der Blaetter b_rand (Bblatt x) = [x] b_rand (Bknoten bl br) = (b_rand bl) ++ (b_rand br) -- testet, ob Element im Baum ist b_in x (Bblatt y) = (x == y) b_in x (Bknoten bl br) = b_in x bl || b_in x br -- wendet eine Funktion auf alle Elemente des Baumes an, -Resultat: Baum der Resultate b_map f (Bblatt x) = Bblatt (f x) b_map f (Bknoten bl br) = Bknoten (b_map f bl) (b_map f br) --Groesse des Baumes: b_size (Bblatt x) = 1 b_size (Bknoten bl br) = 1 + (b_size bl) + (b_size br) --Anzahl der Bl"atter b_blattnr (Bblatt x) = 1 b_blattnr (Bknoten bl br) = (b_blattnr bl) + (b_blattnr br) 8 14 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 --Summe aller Blaetter, falls Zahlen: b_sum (Bblatt x) = x b_sum (Bknoten bl br) = (b_sum bl) + (b_sum br) -- Erzeugung grosser Baeume b_mkbt_test 0 k = Bblatt k b_mkbt_test n k = Bknoten (b_mkbt_test (n-1) (k+1)) (b_mkbt_test (n-1) (2*k)) b_mkbt_testll [x] = Bblatt x b_mkbt_testll (x:xs) = Bknoten (Bblatt x) (b_mkbt_testll xs) b_mkbt_testlr [x] = Bblatt x b_mkbt_testlr (x:xs) = Bknoten (b_mkbt_testlr xs) (Bblatt x) -- schnelles fold "uber bin"are B"aume foldbt :: (a -> b -> b) -> b -> Binbaum a -> b foldbt op a (Bblatt x) = op x a foldbt op a (Bknoten x y) = (foldbt op (foldbt op a y) x) --- effizientere Version von b_rand b_rand_eff = foldbt (:) [] foldbt mit optimiertem Stackverbrauch: foldbt’ :: (a -> b -> b) -> b -> Binbaum a -> b foldbt’ op a (Bblatt x) = op x a foldbt’ op a (Bknoten x y) = (((foldbt’ op) $! (foldbt’ op a y)) x) --- effizientere Version von b_rand und b_sum b_rand_eff = foldbt (:) [] b_sum_eff = foldbt’ (+) 0 Um zu begründen, warum foldbt relativ schnell ist, betrachte den Zwischenausdruck, der aus einem Baum tr mit der Struktur (((1, 2), 3), (4, 5)) entsteht, wenn man foldbt (+) 0 tr auswertet.: (Wir verwenden hier eine etwas vereinfachte Notation) foldbt (+) 0 (((1,2),3),(4 ,5)) --> foldbt (+) (foldbt (+) 0 (4 ,5)) ((1,2),3) --> foldbt (+) (foldbt (+) (foldbt (+) 0 (5)) (4) --> foldbt (+) (foldbt (+) (5+0) (4) ((1,2),3)) --> foldbt (+) (4+ (5+0)) ((1,2),3) ((1,2),3)) 15 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 --> --> --> --> --> foldbt foldbt foldbt foldbt 1+ (2+ (+) (+) (+) (+) (3+ (foldbt (+) (4+ (5+0)) (3)) (1,2) (3+ (4+ (5+0))) (1,2) (foldbt (+) (3+ (4+ (5+0))) (2) (1)) (2+ (3+ (4+ (5+0)))) (1) (4+ (5+0)))) D.h. Wenn ein binärer Baum tr mit Randliste [a1 , . . . , an ] gegeben ist, dann entspricht foldbt f a tr dem Ausdruck f a_1 (f a_2 (... (f a_n a) ...s)). D.h. es entspricht einem foldr (++) [], das z.B.als Definition für concat schneller als foldl (++) []. Wir zeigen beispielhaft eine Analyse der Funktion b rand:, wobei wir nur volle binäre Bäume betrachten. Für diese Bäume gilt: #(innere Knoten) + 1 = #(Blätter) = 2T ief e Für die Anzahl der Reduktionen von b rand baum bei n Blättern gilt: τ (n) ... = = = = = n/2 + 2 + 2 ∗ τ (n/2) n/2 + 2 + 2 ∗ (n/4 + 2 + 2 ∗ τ (n/4)) n/2 + 2 + n/2 + 2 ∗ 2 + 4 ∗ τ (n/4)) ... n/2 ∗ log2 (n) + 2 ∗ n Tiefe = log2 (n) Hinzu kommen noch n Reduktionen für die Blätter . Da wir diese Analyse mit der von Hugs-Statistik vergleichen können, nehmen wir noch folgende Funktion is list hinzu und werten is_list (b_rand testbaum_n) aus. is_list [] = True is_list (_:xs) = is_list xs Deshalb kommen noch (is_list lst) = length lst Reduktionen dazu. Die folgende Tabelle zeigt die Anzahl der Reduktionen von is_list (b_rand testbaum_n) allgemein und für einige ausgewählte Werte. Tiefe m 10 12 13 14 #Blätter #berechnet 2m 2m + 2m−1 ∗ m + 3 ∗ 2m n n + n/2 ∗ log2 (n) + 3 ∗ n 1024 9216 4096 40960 8192 86016 16384 180224 #tatsächliche 2m + 2m−1 ∗ m + 3 ∗ 2m + 14 n + n/2 ∗ log2 (n) + 3 ∗ n + 14 9230 40974 86030 180238 Wir machen auch eine Analyse von b rand eff, um vergleich zu können, ebenfalls nur für volle binäre Bäume. Zur Erinnerung nochmal die Reduktion von foldbt: → foldbt (:) [] (Bknoten lb rb) foldbt (:) (foldbt (:) [] rb) lb 16 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 Die Anzahl der Reduktionen bei n Blättern kann man wie folgt abzählen: pro Bknoten wird ein foldbt-Ausdruck eingesetzt. Dies erfordert n − 1 Reduktionen zu foldbt-Ausdrücken. Pro Blatt wird die Reduktion (foldbt (:) rand (Bblatt a)) → a : rand ausgeführt, d.h. pro Blatt ein Reduktionsschritt. Die Gesamtanzahl der Reduktionen ist somit in etwa 2 ∗ n. Folgende Tabelle zeigt die theoretischen und die praktisch ermittelten Werte des Ausdrucks is_list (b_rand_eff testbaum_n) Tiefe m 10 12 13 14 #Blätter #berechnet 2m 2m + 2 ∗ 2m n 3n 1024 3072 4096 12288 8192 24576 16384 49152 #tatsächliche 2m + 2 ∗ 2m + 15 3n + 15 3087 12303 24591 49167 #Red(b rand) 9230 40974 86030 180238 Man sieht, dass foldbt tatsächlich schneller ist als normales rekursives Programmieren. Der Grund ist der gleiche wie bei der Beschleunigung des mit foldl programmierten concat durch das mit foldr programmierte. Was man auch sieht, ist dass lineare Terme die logarithmische Verbesserung etwas dämpfen. Erst bei sehr großen Bäumen sind die Effekte deutlich sichtbar. Man kann weitere Funktionen auf Bäumen definieren und auch etwas allgemeinere Bäume als Datenstruktur verwenden. Zum Beispiel kann man dann als Typ- und Konstruktordefinition verwenden: data Nbaum a = Nblatt a | Nknoten [Nbaum a] 4.4 Suche und Zugriff Wir betrachten das in indizierten Dateien und Datenbanken vorkommende Problem, eine Menge von gleichartigen Datenobjekten zu verwalten, Z.B. alle DiplomstudententInnen dieses Jahrgangs, oder ein Lexikon von Worten mit zugehöriger Übersetzung. Wir wollen dieses Problem zunächst vereinfacht betrachten. Die Objekte (Sätze) seien dargestellt als Paare, wobei das erste Argument eine Zahl ist (Integer), der sogenannte Schlüssel (key), und das zweite Argument der Inhalt. Die Datenstruktur, die dafür benötigt wird, entspricht in etwa einer Menge mit markierten Elementen: der Schlüssel bezeichnet das Element, während die Markierung den zugehörigen Daten entspricht. Operationen: Erzeugen, Einfügen, Suchen, Drucken, Entfernen. data Maybe a = Nothing | Just a data Satz a = Satz Integer a data Datenbank a = ??? intialisiereDb :: Datenbank a Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 17 einfuegeDb :: Satz a -> Datenbank a -> Datenbank a istinDb :: Integer -> Datenbank a -> Bool sucheDb :: Integer -> Datenbank a -> Maybe a druckeDb :: Datenbank a -> [(Integer,a)] entferneDb :: Integer -> Datenbank a -> Datenbank a Unter Benutzung dieser Operationen könnten wir jetzt spezifizieren, was wir von dieser Datenbank an Verhalten erwarten: Wir formulieren umgangssprachlich: • Eine neu intialisierte Datenbank ist leer • Ein eingefügter Satz ist nach dem Einfügen auch zu finden (mit gleichem Inhalt) • Die Datenbank enthält genau die eingefügten Sätze. • Nach dem Entfernen eines Satzes ist dieser nicht mehr zu finden. • Ein Satz lässt sich nur einmal einfügen. Eine weitergehende Funktionalität ist eine Anfrageschnittstelle, die logisch verknüpfte Anfragen beantworten kann. Als mögliche Antworten beschränken wir uns hier auf Mengen von Sätzen. Ruft man die Anfragen modular auf, so braucht man auf unterer Ebene eine schnelle Möglichkeit, Mengen von Sätzen zu schneiden, zu vereinigen, oder zu komplementieren bzw. Differenzen von (markierten) Mengen zu bilden Wir betrachten verschiedene Möglichkeiten der internen Darstellung: Assoziationsliste: Wir beschreiben nur einige der notwendigen Funktionen. data Satz a = Satz Int a data Db a = Dbl [Satz a] intialisiereDb x = Dbl [] einfuegeDb (Satz x sx) (Dbl db) = Dbl ((Satz x sx): db) druckeDb (Dbl db) = map (\(Satz x y) -> (x,y)) db istinDb (Satz x y) (Dbl []) = False istinDb (Satz x y) (Dbl ((Satz a _): restDb)) = (x == a) || istinDb (Satz x y) (Dbl restDb) sucheDb (Satz x y) (Dbl []) = Nothing sucheDb (Satz x y) (Dbl ((Satz k sk): restDb)) = if x == k then Just sk else sucheDb (Satz x y) (Dbl restDb) entferneDb (Satz x _) (Dbl []) = (Dbl []) entferneDb (Satz k ky) (Dbl ((Satz x y) : restDb)) = Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 18 if k == x then entferneDb (Satz k ky) (Dbl restDb) else let Dbl restDblist = entferneDb (Satz k ky) (Dbl restDb) in Dbl ((Satz x y) : restDblist) Eine Schwäche dieser Implementierung ist die Laufzeit O(n) für die Suche (istinDb) eines Elementes, was bei einer kleineren Menge von Sätzen akzeptabel ist. 4.4.1 sortierte Liste Betrachte jetzt eine aufsteigend sortierte Liste, sortiert nach Zahlen: --- sortierte Liste einfuege2Db (Satz x sx) (Dbl ((Satz a sa): restDb)) = if x < a then Dbl ((Satz x sx) : ((Satz a sa): restDb)) else if x == a then error "bereits vorhanden" else let Dbl restDblist = einfuege2Db (Satz x sx) (Dbl restDb) in Dbl ((Satz a sa): restDblist) istin2Db (Satz x y) (Dbl ((Satz a _): restDb)) = if x < a then False else if (x == a) then True else istin2Db (Satz x y) (Dbl restDb) Diese Methode hat bereits erhebliche Vorteile bei der Bildung von Vereinigungen, Schnitten und Differenzen. Die folgenden Funktionen sind die Schnittbildung, Vereinigung und Differenz auf sortierten Sätzen. intersect2Dbtop (Dbl xs) (Dbl ys) = Dbl (intersect2Db xs ys) intersect2Db [] ys = [] intersect2Db xs [] = [] intersect2Db ((Satz k1 d1): xs1) ((Satz k2 d2): xs2) = if k1 == k2 then (Satz k1 d1): (intersect2Db xs1 xs2) else if k1 < k2 then intersect2Db xs1 ((Satz k2 d2): xs2) else intersect2Db ((Satz k1 d1): xs1) xs2 union2Dbtop (Dbl xs) (Dbl ys) = Dbl (union2Db xs ys) union2Db [] ys = ys union2Db xs [] = xs union2Db ((Satz k1 d1): xs1) ((Satz k2 d2): xs2) = if k1 == k2 then (Satz k1 d1): (union2Db xs1 xs2) else if k1 < k2 19 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 then else (Satz k1 d1): union2Db (Satz k2 d2): union2Db xs1 ((Satz k2 d2): xs2) ((Satz k1 d1): xs1) xs2 -xs ohne ys diff2Dbtop (Dbl xs) (Dbl ys) = Dbl (diff2Db xs ys) diff2Db [] ys = [] diff2Db xs [] = xs diff2Db ((Satz k1 d1): xs1) ((Satz k2 d2): xs2) = if k1 == k2 then (diff2Db xs1 xs2) else if k1 < k2 then (Satz k1 d1): diff2Db xs1 ((Satz k2 d2): xs2) else diff2Db ((Satz k1 d1): xs1) xs2 Man kann leicht nachprüfen, dass diese Funktionen einen Zeitbedarf haben, der linear in der Anzahl der Summe der Elemente der beteiligten Listen ist. 4.4.2 Suchbaum Diese Implementierung ist komplizierter, allerdings gibt es Vorteile gegenüber der Assoziationsliste beim Zugriff und Auffinden der schon eingetragenen Sätze. Jeder Knoten enthält als Markierung den größten Schlüssel des Teilbaumes mit den kleineren Werten. 5 7 1 7 1 11 3 3 --- 5 Suchbaum data Suchbaum a = Sblatt Int a | Sknoten (Suchbaum a) Int (Suchbaum a) | Suchbaumleer 20 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 data DbS a = Suchbaum (Satz a) intialisiereDbS x = Suchbaumleer einfuegeDbS (Satz x sx) Suchbaumleer = Sblatt x (Satz x sx) einfuegeDbS (Satz x sx) (Sblatt k satzk) = if x < k then Sknoten (Sblatt x (Satz x sx)) x (Sblatt k satzk) else if x == k then error " schon eingetragen" else Sknoten (Sblatt k satzk) k (Sblatt x (Satz x sx)) einfuegeDbS (Satz x sx) (Sknoten l k r) = if x < k then Sknoten (einfuegeDbS (Satz x sx) l) k r else if x == k then error " schon eingetragen" else Sknoten l k (einfuegeDbS (Satz x sx) r) istinDbS (Satz x y) Suchbaumleer = False istinDbS (Satz x y) (Sblatt k _) = (x == k) istinDbS (Satz x y) (Sknoten bl k br) = if x < k then istinDbS (Satz x y) bl else if x == k then True else istinDbS (Satz x y) br erzeugeDbS [] = Suchbaumleer erzeugeDbS (x:xs) = einfuegeDbS (Satz x x) druckeDbS sb = blaetterSB foldSB foldSB foldSB foldSB :: op op op (erzeugeDbS xs) (a -> b -> b) -> b -> Suchbaum a -> b a Suchbaumleer = a a (Sblatt _ x) = op x a a (Sknoten x _ y) = (foldSB op (foldSB op a y) x) --- effiziente Version blaetterSB = foldSB (:) [] Eine Menge M von Suchbäumen hat logarithmische Tiefe, wenn es eine Konstante c gibt, so dass ∀B ∈ M : log(#(Knoten vonB)) ≥ c ∗ (Tiefe von B) Analyse des Ressourcenbedarfs der verschiedenen Zugriffsfunktionen: einfuegeDb hat linearen Ressourcenbedarf abhängig von der Tiefe der möglichen Suchbäume. D.h. wenn die Suchbäume logarithmische Tiefe haben, dann ist der Ressourcenbedarf O(log(n)), wobei n die Anzahl der Einträge in die Datenbank ist. 21 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 istinDb hat linearen Ressourcenbedarf abhängig von der Tiefe des Suchbaumes. Ist ebenfalls O(log(n)) für Suchbäume mit logarithmischer Tiefe. Beispiel 4.4.1 Wenn der Suchbaum nicht balanciert ist, dann ist der Zeitbedarf des Enthaltenseinstest von der Größenordnung O(n). Betrachte einen Baum der Form: Sknoten (Sblatt (Satz 1 "eins") 1 Sknoten (Sblatt (Satz 2 "zwei") 2 .... Dann benötigt die Suche darin offenbar genau soviele Weiterschaltungen wie Blätter in diesem Baum sind. Das Problem ist, dass diese Bäume durchaus auch durch normales Einfügen entstehen können. Ein Ausweg sind balancierte Bäume, mit der charakteristischen Eigenschaft, dass die Tiefe des Baumes nicht zu groß wird. Optimal verteilt sind die Blätter, wenn die Tiefe am kleinsten ist. Dies ist allerdings beim Einfügen in beliebiger Reihenfolge nicht so einfach und kann eine völlige Reorganisation des Baumes beim Einfügen eines einzigen Elementes erzwingen. Es gibt verschiedene Möglichkeiten, mit logarithmischem Aufwand balancierte Bäume zu verwalten: eine einfache sind 2-3-Bäume, die an jedem inneren Knoten 2 oder 3 Nachfolger haben dürfen. Dadurch ist es leicht, beim Einfügen den Baum jeweils so umzuordnen, dass kein ausgearteter Baum entsteht. Eine Verallgemeinerung sind B-Bäume, die für vorgegebenes k an jedem inneren Knoten jeweils zwischen k und 2k−1 Nachfolger haben dürfen (natürlich nicht für zu kleine Bäume). Eine Datenstruktur mit Operationen, die eine Balance-Bedingung erfüllt, aber nicht optimal balanciert sind, sind AVL-Bäume (nach Adel’son-Vel’skii und Landis) Die Bedingung ist, dass in jedem Knoten die Differenz der Höhe des rechten und linken Teilbaumes höchstens 1 ist. Man kann zeigen, dass in AVL-Bäumen die Höhe logarithmisch ist, d.h. die Funktion f (n) = max{h | h ist die Höhe eines AVL-Baumes mit n Blättern} ist O(log n). Man kann durch Anreichern der Datenstruktur um Balancefaktoren −1, 0, +1 den Aufwand für die beim Einfügen und Löschen erforderlichen Rotationen gering halten. Natürlich muss dann auch jede Veränderung für den richtigen Wert dieser Faktoren sorgen. 22 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 A B h+1 C h-1 h h-1 ⇓ B A C h+1 h h-1 h-1 Die Programmierung in Haskell von leicht abgewandelten AVL-Bäumen sieht folgendermaßen aus: ------- AVL-Suchbaum nur fuer Zahlen Ablatt Wert Aknoten linke-Hoehe linker-Baum data AVLsuchbaum = Ablatt rechte-Hoehe rechter-Baum, Int | Aknoten Int AVLsuchbaum Int AVLsuchbaum | Abaumleer Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 23 intialisiereAVL x = Abaumleer -- Resultat: (Tiefe erh"oht, Baum) einfuegeAVL x Abaumleer = Ablatt x einfuegeAVL x (Ablatt k) = if x < k then (Aknoten 1 (Ablatt x) x (Ablatt k)) else if x == k then error " schon eingetragen" else Aknoten 1 (Ablatt k) k (Ablatt x) einfuegeAVL x (Aknoten _ l k r) | x < k = let lneu = (einfuegeAVL x l) ldl = adepth lneu ldr = adepth r baumneu = Aknoten (1+ (max ldl ldr)) lneu k r in if ldl - ldr > 1 then arotate baumneu else baumneu | x == k = error " schon eingetragen" | otherwise = let rneu = (einfuegeAVL x r) ldl = adepth l ldr = adepth rneu baumneu = Aknoten (1+ (max ldl ldr)) l k rneu in if ldr - ldl > 1 then arotate baumneu else baumneu adepth (Ablatt x) = 0 adepth (Aknoten d _ _ _) = d arotate (Aknoten d0 (Ablatt s1) k0 (Aknoten d01 s01 k01 t01@(Aknoten _ _ _ _))) = let dneu1 = (adepth s01) +1 dneu0 = 1+ (max dneu1 (adepth t01)) baumneu = (Aknoten dneu0 (Aknoten dneu1 (Ablatt s1) k0 s01) k01 t01) in if abs (dneu1 - adepth t01) > 1 then arotate baumneu else baumneu arotate (Aknoten d0 (Aknoten d01 s01@(Aknoten _ _ _ _) k01 t01) k0 (Ablatt s1)) = let dneu1 = (adepth t01) +1 dneu0 = 1+ (max dneu1 (adepth s01)) baumneu = (Aknoten dneu0 s01 k01 (Aknoten dneu1 t01 k0 (Ablatt s1))) in if abs(dneu1 - adepth s01) > 1 then arotate baumneu else baumneu Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 24 arotate (Aknoten d0 s0@(Ablatt s1) k0 (Aknoten d01 s01@(Aknoten _ s011 k011 s012) k01 t01@(Ablatt t011))) = arotate4 s0 k0 s011 k011 s012 k01 t01 arotate (Aknoten d0 (Aknoten d01 s01@(Ablatt _) k01 (Aknoten _ t11 k11 t12)) k0 s11@(Ablatt s1)) = arotate4 s01 k01 t11 k11 t12 k0 s11 arotate (Aknoten d0 s1@(Aknoten d01 s01 k01 t01) k0 t1 @(Aknoten d11 s11 k11 t11)) = if d01 > d11 then arotate3 s01 k01 t01 k0 t1 else arotate3 s1 k0 s11 k11 t11 -- 5 faelle: 544, 454 445, 554,455 arotate3 b1 k1 b2 k2 b3 = let d1 = adepth b1 d2 = adepth b2 d3 = adepth b3 dd1 = d1-d2 dd2 = d2 -d3 (Aknoten _ b21 k21 b22) = b2 in if dd1 == 1 && dd2 == 0 || dd1 == 0 && dd2 == 1 then Aknoten (1+ (max d1 (1 + (max d2 d3)))) b1 k1 (Aknoten (1+ (max d2 d3)) b2 k2 b3) else if dd1 == 0 && dd2 == -1 || dd1 == -1 && dd2 == 0 then Aknoten (1+ (max d3 (1+ (max d1 d2)))) (Aknoten (1+ max d1 d2) b1 k1 b2) k2 b3 else arotate4 b1 k1 b21 k21 b22 k2 b3 -3 faelle: 4444, 4344, 4434 arotate4 b1 k1 b2 k2 b3 k3 b4 = let d1 = adepth b1 in Aknoten (d1+2) (Aknoten (d1+1) b1 k1 b2) k2 (Aknoten (d1+1) b3 k3 b4) istinAVL x Abaumleer = False istinAVL x (Ablatt k) = (x == k) istinAVL x (Aknoten _ bl k br) = if x < k then istinAVL x bl else if x == k then True else istinAVL x br erzeugeAVL [] = Abaumleer erzeugeAVL (x:xs) = einfuegeAVL x (erzeugeAVL xs) Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 25 foldAVL op a Abaumleer = a foldAVL op a (Ablatt x) = op x a foldAVL op a (Aknoten _ x _ y) = (foldAVL op (foldAVL op a y) x) blaetterAVL = foldAVL (:) [] druckeAVL sb = blaetterAVL testvarAVL = (erzeugeAVL ([100,99..50] ++ [1..49])) testmischeqAVL = (blaetterAVL (erzeugeAVL ([1..30]++[60,59..31]++[61..100]))) == mischsort ([1..30]++[60,59..31]++[61..100]) 4.4.3 Binäre Suche Diese Suche hat als Ausgangspunkt ein Feld (oder eine Liste) von sortierten Elementen. Gegeben ein Element, finde den Index. Implementiert man dies als Liste, so ist die Suche von linearer Zeitbedarf, da man die Liste durchlaufen muss, um an die Elemente zu kommen. Dies ist verbesserbar, wenn die Elemente in einem Feld sind: Man kann dann die Suche nach der Methode der Intervallhalbierung durchführen. binaere_suche x ar = let (m,n) = (bounds ar) in binaere_suche_r x ar m n binaere_suche_r x ar m n = if m == n then if (ar!m) == x then (True,m) else (False, error "binsuche") else let mid = (m+n) ‘div‘ 2 in if ar ! mid > x binaere_suche_r x ar m mid else binaere_suche_r x ar mid n 4.5 Schnitt und Vereinigung von Mengen Die interessanten bisher noch fehlenden Operationen der Datenstruktur Menge sind Schnitt, Vereinigung, Differenz und Potenzmenge. unsortierte Listen In diesem Fall ist es zur Berechnung des Schnitts zweier Mengen A, B notwendig, alle Elemente der Liste A mit allen Elementen der Liste zu B zu vergleichen. D.h. die Anzahl der Reduktionen ist |A|∗|B|, d.h. O((|A|+|B|)2 ) bzw. O(n2 ), wenn n die Gesamtgröße der Eingabelisten ist. sortierte Listen Die Berechnung des Schnittes oder der Vereinigung erfolgt durch Mischen (Merge). Hierbei kann man sogar ohne viel Extra-Aufwand dafür sorgen, dass das Resultat aufsteigend sortiert ist. 26 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 merge_schnitt [] xb = [] merge_schnitt (x:xs) [] = [] merge_schnitt (x:xs) (y:ys) = if x < y then merge_schnitt xs (y:ys) else if x == y then x : merge_schnitt xs ys else merge_schnitt (x:xs) ys Offenbar wird bei jedem Schritt ein Element verarbeitet, das danach nicht mehr angefasst wird. D.h.der Zeitbedarf ist O((|A| + |B|)). balancierte Suchbäume Wir gehen vereinfacht davon aus, dass wir Mengen von ganzen Zahlen haben. Die Idee zur Berechnung des Schnitts von A und B ist einfach: man nimmt jedes Element des Suchbaumes für A und testet, ob es im Suchbaum für B ist. schnittSB xa xb = schnittSBliste (blaetterSB xa) xb schnittSBliste [] _ = Suchbaumleer schnittSBliste (x:xs) sb = if istinDb x sb then einfuegeDbS x (schnittSBliste xs else schnittSBliste xs sb sb Der Aufwand berechnet sich folgendermaßen: Zeit = |A| ∗ log(|B|), d.h. n ∗ log(n) im schlechtesten Fall, wenn n = |A| + |B|. Die zweite Möglichkeit ist es, die Misch-Idee auch für Suchbäume zu verwenden: -Schneiden von Mengen mit Suchbaeumen durch Mischen -!! Diese haelt nicht die Bedingung ein, dass --Knotenmarkierung = Maximum der linken -Schluessel, sondern nur noch die Bedingung: ist in der Mitte mschnittSB Suchbaumleer _ = Suchbaumleer mschnittSB _ Suchbaumleer = Suchbaumleer mschnittSB (Sblatt x a) (Sblatt y _) = if x == y then (Sblatt x a) else Suchbaumleer mschnittSB (Sknoten lta ka rta) (Sblatt y b) = if y == ka then (Sblatt y b) else if y < ka then mschnittSB lta (Sblatt y b) else mschnittSB rta (Sblatt y b) mschnittSB tl@(Sknoten lta ka rta) tr@(Sknoten ltb kb rtb) = if ka == kb 27 Praktische Informatik 1, WS 2004/05, Kapitel 4, vom 28.01.2005 then mappendSB (mschnittSB lta ltb) ka (mschnittSB rta rtb) else if ka < kb then mappendSB (mschnittSB lta ltb) ka (mschnittSB rta tr) else mappendSB (mschnittSB lta tr) ka (mschnittSB rta rtb) mschnittSB x y = mschnittSB y x -- Sblatt + Sknoten mappendSB Suchbaumleer _ t = t mappendSB t _ Suchbaumleer = t mappendSB l k r = Sknoten l k r Ein Analyseversuch bei balancierten Bäumen ergibt eine Gleichung τ (m, n) = τ (m/2, n/2) + τ (m/2, n) für den Zeitbedarf τ . Dies deutet darauf hin, dass diese Methode keinen linearen Zeitbedarf hat, sondern etwas mehr, vermutlich sogar quadratisch. Nimmt man an, dass bei einem weiteren Schritt der linke und rechte Baum beide zerlegt werden, so ergibt sich eine Rekursionsgleichung von τ (m, n) = 3 ∗ τ (m/2, n/2). Deren Lösung ist O(nlog3 ) = O(n1,585 ). D.h., diese Methode ist offenbar langsamer als das einzelne Testen. Beste Methode zur Schnitt-Berechnung (Vereinigung,Differenz) Asymptotisch und im worst-case am besten ist folgende Methode: • zunächst erzeuge die Liste der Blätter (linear). • Verarbeite diese mit einer Misch-operation Schnitt, Vereinigung, Differenz (linear). • und danach wandle die Liste in ein Feld um (linear). Hier ist allerdings der logarithmische Faktor im Index versteckt. • Baue den Baum aus dem Feld wieder auf (linear).