Funktionale Datenstrukturen
In diesem Kapitel behandeln wir die Implementierung ausgewählter Datenstrukturen inHaskell. Wir haben bereits Listen zur Darstellung von Sequenzen kennengelernt sowieSuchbäume zur Darstellung von Mengen vergleichbarer Elemente.
Queues
Listen entsprechen im Wesentlichen der Stack-Abstraktion, was die folgende Stack-Implementierung verdeutlicht:
type Stack a = [a]
emptyStack :: Stack aemptyStack = []
isEmptyStack :: Stack a -> BoolisEmptyStack = null
push :: a -> Stack a -> Stack apush = (:)
top :: Stack a -> atop = head
pop :: Stack a -> Stack apop = tail
Alle definierten Operationen haben konstante Laufzeit. Eng verwandt mit Stacks sindQueues. Während Stacks nach dem Last-In First-Out (LIFO) Prinzip funktionieren,arbeiten Queues nach dem First-In First-Out (FIFO) Prinzip. Die Elemente einer Queuewerden also in der Reihenfolge entnommen, in der sie eingefügt wurden.Auch Queues könnten wir in Haskell als Listen implementieren:
type Queue a = [a]
emptyQueue :: Queue aemptyQueue = []
isEmptyQueue :: Queue a -> BoolisEmptyQueue = null
enqueue :: a -> Queue a -> Queue aenqueue x q = q ++ [x]
1
next :: Queue a -> anext = head
dequeue :: Queue a -> Queue adequeue = tail
Wie die Operationen top und pop haben auch next und dequeue konstante Laufzeit inder Größe des Arguments. Die Laufzeit von enqueue ist aber linear, da die ++-Funktionmit der gegebenen Queue als erstem Argument aufgerufen wird. Hätten wir Queues alsListen in umgekehrter Reihenfolge dargestellt, dann könnten wir enqueue mit konstanterLaufzeit (durch (:)) implementieren müssten für next und dequeue aber die last bzw.die init-Funktion verwenden, die beide lineare Laufzeit haben.
Können wir eine Implementierung von Queues angeben, die sowohl enqueue als auchnext und dequeue in konstanter Laufzeit erlaubt? Da wir sowohl auf den Anfang (wegenenqueue) als auch auf das Ende der Liste (wegen next und dequeue) in konstanter Zeitzugreifen wollen, verwenden wir für die Darstellung zwei Listen:
data Queue a = Queue [a] [a]
emptyQueue :: Queue aemptyQueue = Queue [] []
isEmptyQueue :: Queue a -> BoolisEmptyQueue (Queue xs ys) = null xs && null ys
Die erste Liste enthält die ältesten Elemente, also die, die als nächstes entfernt werden,die zweite Liste hingegen enthält die neuesten Elemente, also die, die als letztes eingefügtwurden und zwar in umgekehrter Reihenfolge. Um einer Queue ein Element hinzuzufügen,können wir es daher vorne der zweiten Liste hinzufügen. Um eines zu entfernen, nehmenwir es aus der ersten Liste.
enqueue :: a -> Queue a -> Queue aenqueue x (Queue xs ys) = Queue xs (x:ys)
next :: Queue a -> anext (Queue (x:_) _) = x
dequeue :: Queue a -> Queue adequeue (Queue (_:xs) ys) = Queue xs ys
Die Implementierungen von next und dequeue sind noch unvollständig. Beide Funktionenliefern kein Ergebnis, wenn die erste Liste leer ist, die zweite aber nicht. Dieser Fallerfordert es, die zweite Liste, die die Elemente ja in umgekehrter Reihenfolge speichert,komplett zu durchlaufen, um das nächste Element zu entfernen.
2
Um diesen ungünstigen Fall zu vermeiden, legen wir eine Invariante für den Queue-Datentyp fest:
Wenn die erste Liste leer ist, ist auch die zweite leer.
Gilt diese Invariante, so finden wir bei dequeue das zu entfernende Element immer inder ersten Liste, da diese immer ein Element enthält, wenn die zweite eines enthält.
Die oben gezeigten Implementierungen von enqueue und dequeue erhalten diese Invari-ante aber nicht aufrecht: Nach dem Einfügen eines Elementes in eine leere Queue ist dieerste Liste leer, die zweite aber nicht. Außerdem tritt diese Situation ein, wenn die ersteListe vor dem Aufruf von dequeue einelementig ist.
Wir implementieren daher eine Konstruktor-Funktion queue, die sicher stellt, dass diezweite Liste leer ist, falls die erste leer ist:
queue :: [a] -> [a] -> Queue aqueue [] ys = Queue (reverse ys) []queue xs ys = Queue xs ys
Da die Elemente in der zweiten Liste in umgekehrter Reihenfolge gespeichert werden,müssen wir die zweite Liste umdrehen, bevor wir sie als neue erste Liste verwenden. Mitder queue-Funktion können wir enqueue und dequeue wie folgt definieren:
enqueue :: a -> Queue a -> Queue aenqueue x (Queue xs ys) = queue xs (x:ys)
dequeue :: Queue a -> Queue adequeue (Queue (x:xs) ys) = queue xs ys
Im Unterschied zu den vorherigen Definitionen haben wir die queue-Funktion statt desQueue-Konstruktors in den rechten Regelseiten verwendet.
Um zu testen, ob eine Queue leer ist, brauchen wir dank der Invariante nur noch zutesten, ob die erste Liste leer ist:
isEmptyQueue :: Queue a -> BoolisEmptyQueue (Queue xs _) = null xs
Die Implementierung der next-Funktion ist jetzt korrekt, da die Invariante verhindert,dass die zweite Liste Elemente enthält, wenn die erste Liste leer ist.
Trotz des Aufrufs von queue in enqueue, hat enqueue konstante Laufzeit: Der potentiellteure Aufruf von reverse passiert nur dann, wenn die erste Liste xs leer ist, und indem Fall ist auf Grund der Invariante auch ys leer also das Argument von reverseeinelementig.
Die Laufzeit von dequeue ist im schlechtesten Fall jedoch noch immer linear: Falls dieerste Liste einelementig ist und die zweite n − 1 Elemente enthält, benötigt der queue
3
Aufruf (auf Grund des reverse Aufrufs) n − 1 Schritte. Dieser Fall tritt zum Beispieldann ein, wenn n Elemente hintereinander mit enqueue einer leeren Queue hinzugefügtwerden.
Haben wir gegenüber der einfachen Implementierung mit einer einzigen Liste überhauptetwas gewonnen? Zwar ist die pessimale Laufzeit von dequeue linear, die amortisierteLaufzeit der beiden Operationen ist aber konstant.
Bei amortisierter Laufzeit betrachtet man nicht die Laufzeit einer einzigen Operationsondern die Laufzeit mehrerer Operationen hintereinander: Wenn beliebige n Queue-Operationen hintereinander ausgeführt werden und die Gesamtlaufzeit dabei immer inO(n) liegt, dann ist die amortisierte Laufzeit der Operationen konstant. Dabei könneneinzelne Aufrufe der Operationen durchaus schlechtere Laufzeit haben, solange dabei niedie Gesamtlaufzeit beeinträchtigt wird.
Wir betrachten beispielhaft die folgende Hintereinanderausführung mehrerer Queue-Operationen:
dequeue(dequeue(dequeue(enqueue 1(enqueue 2(enqueue 3 emptyQueue)))))
Mit der einfachen Implementierung ergibt sich daraus
dequeue(dequeue(dequeue((([] ++ [3]) ++ [2]) ++ [1])))
Da ++ linksassoziativ aufgerufen wird, ist hier die Gesamtlaufzeit quadratisch in derAnzahl der eingefügten Elemente also auch quadratisch in der Anzahl der verwendetenOperationen. Die amortisierte Laufzeit der beiden Queue-Operationen ist also linear,denn die n-fache Anwendung einer Operation mit linearer Laufzeit führt zu quadratischerGesamtlaufzeit. In diesem Fall ist die amortisierte Laufzeit der Operationen also nichtbesser als die pessimale.
Betrachten wir das selbe Beispiel mit der zweiten Queue-Implementierung ergibt sich(verkürzt):
deq (deq (deq (enq 1 (enq 2 (enq 3 e)))))= deq (deq (deq (enq 1 (enq 2 (q [] [3])))))= deq (deq (deq (enq 1 (enq 2 (Q [3] [])))))
4
= deq (deq (deq (enq 1 (Q [3] [2]))))= deq (deq (deq (Q [3] [1,2])))= deq (deq (q [] [1,2]))= deq (deq (Q [2,1] [])) -- teuer!= deq (Q [1] [])= Q [] []
Die Gesamtlaufzeit dieser Aufrufe ist linear in der Anzahl der Operationen, da fast alleSchritte konstante Laufzeit haben. Nur ein Schritt hat lineare Laufzeit, die Gesamtlaufzeitbleibt aber linear in der Anzahl der Operationen. Daher ist die amortisierte Laufzeit derOperationen (anders als die pessimale Laufzeit) konstant.
Diese Aufrufkette verdeutlicht, dass der teure reverse-Aufruf nur selten auftritt. ImAllgemeinen muss jedes eingefügte Element genau einmal “durch reverse hindurch” bevores wieder entfernt wird. Die reverse-Aufrufe sind so selten, dass die Gesamtlaufzeit einerbeliebigen Folge von Queue-Operationen immer lineare Gesamtlaufzeit hat.
Obwohl die pessimale Laufzeit von dequeue linear ist, ist die gezeigte Queue-Implementierung auf Grund der konstanten amortisierten Laufzeit der Operationen sehrbrauchbar.
Arrays
In vielen imperativen Programmiersprachen werden Arrays bereit gestellt. Imperative Ar-rays erlauben die Abfrage und Manipulation von Elementen an einer gegebenen Positionmit konstanter Laufzeit. In Haskell gibt es eine (im Modul Data.Array) vordefinierte An-bindung an Arrays, die es erlaubt, ein Element an einer gegebenen Position in konstanterZeit abzufragen und Arrays in linearer Zeit aus Listen zu erzeugen. Allerdings hat dieOperation zum Ändern eines Index lineare Laufzeit. Sie kopiert das gesamte Array, daSeiteneffekte in reinen funktionalen Sprachen wie Haskell nicht erlaubt sind. Insbesonderesoll auch das alte Array nach dem Update unverändert zur Verfügung stehen.
Können wir Arrays mit konstanter Laufzeit auch rein funktional implementieren?
Wir definieren dazu den folgenden Datentyp:
data Array a = Entry a (Array a) (Array a)
Wir stellen zunächst fest, dass alle Werte dieses Typs unendlich sind, da es keinen Fallfür das leere Array gibt, doch dazu später mehr.
Auch Indizes scheinen im Array-Datentyp nicht dargestellt zu werden. Die Idee dieserImplementierung ist, dass der zu einem bestimmten Index gehörige Wert an einer be-stimmten Position im von Entry-Knoten erzeugten Binärbaum steht. Zum Beispiel stehtdas Element mit dem Index Null an der Wurzel, links davon ist ein Array mit allenungeraden Indizes und rechts davon eines mit allen geraden Indizes. Die Teil-Arrays
5
haben ihrerseits die selbe Struktur: Zieht man von den Indizes eins ab und dividiertdas Ergebnis (mit ganzzahliger Division) durch zwei steht an der Wurzel die Null, linksdavon gerade Indizes und rechts ungerade. Insgesamt ergibt sich dadurch die folgendeVerteilung der Indizes:
0
1 2
3 5 4 6
7 11 9 13 8 12 10 14
... ... ... ... ... ... ... ... ...
Abbildung 1: Indizes in einem funktionale Array
Diese Verteilung erlaubt es, die Abfrage eines Elementes effizient zu implementieren:
(!) :: Array a -> Int -> aEntry x odds evens ! n
| n == 0 = x| odd n = odds ! m| even n = evens ! m
wherem = (n-1) `div` 2
Wenn der Index Null ist, steht das gesuchte Element an der Wurzel. Wenn nicht suchenwir rekursiv im linken oder rechten Teil-Array, je nachdem, ob der Index ungerade(dann links) oder gerade ist (dann rechts). Der neue Index wird dabei dekrementiert undhalbiert.
Um zum Beispiel das Element an Position 9 nachzuschlagen, steigen wir rekursiv in denlinken Teilbaum ab, da die 9 ungerade ist und suchen dort rekursiv den Index 4. Dieserist gerade, deshalb suchen wir rekursiv im rechten Teilbaum den Index 1. Dieser Index istwieder ungerade, also suchen wir im linken Teilbaum den Eintrag mit Index Null gebenalso die Wurzel dieses Teilbaums aus.
6
Auch die Funktion zum Ändern eines Eintrags lässt sich auf diese Weise implementieren:
update :: Array a -> Int -> a -> Array aupdate (Entry x odds evens) n y
| n == 0 = Entry y odds evens| odd n = Entry x (update odds m y) evens| even n = Entry x odds (update evens m y)
wherem = (n-1) `div` 2
Je nachdem, ob der Index gerade oder ungerade ist, steigen wir wieder rekursiv in dasrechte oder linke Teil-Array ab und manipulieren einen entsprechend angepassten Index.
Die update-Funktion erzeugt dabei ein neues Array, lässt also das Argument andersals Array-Updates in imperativen Sprachen unverändert. update kopiert aber nicht dasganze Array sondern nur den Pfad von der Wurzel zum gesuchten Element. GemeinsameTeile im Argument und Ergebnis werden geteilt, also nicht kopiert.
Die Laufzeit der (!) und update-Funktionen ist logarithmisch in der Größe des Index,also insbesondere unabhängig von der Array-Größe. Wenn man ehrlich ist, ist auch inimperativen Sprachen der Array-Zugriff nicht konstant sondern logarithmisch in derIndexgröße, da alle (logarithmisch vielen) Bits des Index angesehen werden müssen, umden richtigen Eintrag zu finden.
Der Vorteil funktionaler Arrays ist, dass sowohl die neue als auch die alte Varianteeines Arrays nach einem Update verfügbar sind. Der zusätzliche Speicherbedarf ist dabeilogarithmisch in der Indexgröße. Mit imperativen Arrays verwendet man in diesem Fallmeist eine Kopie, benötigt also linearen zusätzlichen Speicherbedarf in der Array-Größe.
Wie wir bereits festgestellt haben, sind alle Werte vom Typ Array a unendlich. Es stelltsich also die Frage, wie wir endliche Arrays darstellen. Das leere Array ist ein unendlichesArray, das nur Fehlermeldungen enthält:
emptyArray :: Array aemptyArray = Entry err emptyArray emptyArraywhereerr = error "accessed non-existent entry"
Wir können aus einer Liste ein Array machen, indem wir sukzessive update auf ein leeresArray anwenden:
fromList :: [a] -> Array afromList = foldl insert emptyArray . zip [0..]whereinsert a (n,x) = update a n x
Die Laufzeit von fromList ist O(n log n). Allerdings werden in dieser Variante viele Entry-Konstruktoren erzeugt und durch spätere update Aufrufe wieder ersetzt. Die folgende
7
Implementierung vermeidet dies, indem sie die Eingabeliste in zwei Teile, nämlich dieElemente mit ungeradem und die mit geradem Index, aufteilt.
fromList :: [a] -> Array afromList [] = emptyArrayfromList (x:xs) =
Entry x (fromList ys) (fromList zs)where (ys,zs) = split xs
Die split-Funktion berechnet aus einer Liste zwei, indem sie die Elemente abwechselndder einen und der anderen hinzufügt:
split :: [a] -> ([a],[a])split [] = ([],[])split [x] = ([x],[])split (x:y:zs) = (x:xs,y:ys)where (xs,ys) = split zs
Diese Variante von fromList hat zwar auch die Laufzeit O(n log n) erzeugt aber keineunnötigen Entry-Knoten, die sie später wieder verwirft und ist deshalb schneller. Es istsogar möglich, fromList mit linearer Laufzeit zu implementieren (Okasaki ’97).
Die hier gezeigte Implementierung von Arrays ist (mit einer weiteren wichtigen Optimie-rung, auf die wir hier nicht eingehen) im Modul Data.IntMap implementiert.
Array-Listen
Arrays erlauben anders als Listen einen effizienten Zugriff auf Elemente an einem beliebi-gen Index. Listen bieten anders als Arrays effiziente Funktionen zum Entfernen des erstenElements und Hinzufügen eines neuen ersten Elements. Die Funktionen (:) und tailhätten mit der beschriebenen Array-Implementierung lineare Laufzeit, da sich durch siedie Indizes aller Einträge verschieben.
Array-Listen bieten wie Arrays einen effizienten Zugriff auf beliebige Elemente undwie Listen effiziente Funktionen zum Hinzufügen und Entfernen des ersten Elements.Ihre interne Darstellung ähnelt der von Binärzahlen. Eine Array-Liste ist eine Listevollständiger, nur an Blättern beschrifteter Binärbäume, deren Höhe ihrer Position inder Liste entspricht.
Hier sind beispielhaft Listen der Länge eins bis fünf dargestellt:
o|5
.-----o
8
/ \4 5
o-----o| / \3 4 5
.-----.-----o/ \
/\ /\2 3 4 5
o-----.-----o| / \1 /\ /\
2 3 4 5
Eine Array-Liste der Länge n enhält genau an den Positionen einen vollständigen Binär-baum, an denen die Binärdarstellung von n eine 1 hat (wenn man mit dem niedrigst-wertigen Bit anfängt). Ein Binärbaum an Position i in der Liste enthält dabei genau 2i
Elemente. Eine Array-Liste ist also eine Liste optionaler Binärbäume, wobei das letzteElement immer vorhanden sein muss. Eine Liste wie
o-----.-----.|7
ist also nicht erlaubt. Insgesamt ergeben sich die folgenden Invarianten:
1. Der letzte Baum ist nicht leer.2. Jeder Binärbaum ist vollständig.3. Ein Baum an Position i hat 2i Elemente.
Diese Darstellung erlaubt es, alle erwähnten Operationen in logarithmischer Laufzeit zuimplementieren.
Wir stellen Array-Listen als Werte des folgenden Datentyps dar.
type ArrayList a = [Bit a]data Bit a = Zero | One (BinTree a)data BinTree a = Leaf a
| Fork (BinTree a) (BinTree a)
Die leere Array-Liste ist die leere Liste.
9
empty :: ArrayList aempty = []
Dank der ersten Invariante genügt es für den Leerheitstest, zu testen, ob die Liste vonBits leer ist. Eine nicht-leere Liste nur aus Zeros ist nicht erlaubt.
isEmpty :: ArrayList a -> BoolisEmpty = null
Wir wollen nun eine Funktion (<:) für Array-Listen definieren, die sich wie (:) fürListen verhält, also ein neues erstes Element hinzufügt. Da sich die Länge der Array-Liste dabei um eins erhöht, ist die (<:)-Funktion dem Inkrementieren einer Binärzahlnachempfunden. Wenn das niedrigste Bit Null ist, wird es auf eins gesetzt, wenn es einsist, wird es auf Null gesetzt und die restlichen Bits werden inkrementiert.
(<:) :: a -> ArrayList a -> ArrayList ax <: l = cons (Leaf x) l
Wir verwenden eine Hilfsfunktion cons auf Binärbäumen, da wir im rekursiven Aufrufmehrere Elemente auf einmal zum Inkrementieren benutzen:
cons :: BinTree a -> ArrayList a -> ArrayList acons u [] = [One u]cons u (Zero : ts) = One u : tscons u (One v : ts) = Zero : cons (Fork u v) ts
Statt einfach nur die Bits zu manipulieren, fügen wir einer Eins einen Binärbaumentsprechender Größe hinzu. Die Invarianten erhalten wir dadurch aufrecht, dass wirim rekursiven Aufruf einen doppelt so großen Baum verwenden, wie im Aufruf selbst.Die Bäume werden dabei nicht durchlaufen, also ist die Laufzeit von cons durch dieLänge der Liste von Binärbäumen beschränkt. Diese ist wegen der ersten Invariantelogarithmisch in der Länge der Array-Liste.
Das folgende Beispiel zeigt die Schrittweise Anwendung von (<:).
ghci> 3 <: empty[One (Leaf 3)]ghci> 2 <: it[Zero,One (Fork (Leaf 2) (Leaf 3))]ghci> 1 <: it[One (Leaf 1),One (Fork (Leaf 2) (Leaf 3))]
Statt head und tail definieren wir, um Namenskonflikte zu vermeiden, Funktionenfirst und rest. Wir definieren diese Funktionen mit Hilfe einer einzigen Funktion, diebeide Ergebnisse berechnet.
10
first :: ArrayList a -> afirst l = xwhere (Leaf x, _) = decons l
rest :: ArrayList a -> ArrayList arest l = xswhere (_, xs) = decons l
Die Funktion decons arbeitet wie cons auf Binärbäumen statt Bits. Sie ist dem Dekre-mentieren einer Binärzahl nachempfunden und liefert den Teilbaum zurück, der zumniedrigsten Bit gehört, das nicht Null ist. Bäume aus höherwertigen Bits werden dabeiaufgeteilt. Der linke Teil wird als erste Komponente des Ergebnisses zurück geliefert, derandere Teil wird vor das Ergebnis der rekursiven Dekrementierung gehängt.
decons :: ArrayList a -> (BinTree a, ArrayList a)decons [One u] = (u, [])decons (One u : ts) = (u, Zero : ts)decons (Zero : ts) = (u, One v : ws)where(Fork u v, ws) = decons ts
Die erste Regel sorgt dafür, dass die erste Invariante, dass der letzte Eintrag der Listevon Bits nicht Null ist, aufrecht erhalten wird. Auch die anderen Invarianten bleibenerhalten. Die Implementierung verlässt sich auf die Invarianten, da nur durch sie sichergestellt ist, dass das Pattern-Matching auf Fork beim rekursiven Aufruf nicht fehlschlägt.Auch das Patten-Matching in first ist nur auf Grund der Invarianten sicher.
Die Laufzeit von decons, also auch von first und rest ist durch die Anzahl der Bitsbeschränkt also logarithmisch in der Länge der Array-Liste. Hier ist ein Beispielaufrufauf eine vier-elementige Liste:
decons .-----.-----o/ \
/\ /\1 2 3 4
let (Fork u v, ws) = decons .-----o/ \
/\ /\1 2 3 4
in (u, One v : ws)
let (Fork u v, ws) =let (Fork u' v', ws') = decons o
/ \
11
/\ /\1 2 3 4
in (u', One v' : ws')in (u, One v : ws)
let (Fork u v, ws) =let Fork u' v' = o
/ \/\ /\
1 2 3 4ws' = []
in (u', One v' : ws')in (u, One v' : ws')
let Fork u v = o/ \
1 2ws = [One o ]
/ \3 4
in (u, One v : ws)
(1, o-----o )| / \2 3 4
Wir definieren nun Funktionen zum Zugriff auf Elemente anhand ihres Index. Wie beiArrays erlaubt (!) ein Element abzufragen.
(!) :: ArrayList a -> Int -> al ! n = select 1 l n
Wir verwenden eine Hilfsfunktion select, die als zusätzlichen Parameter die Größe desnächsten Binärbaums mitführt.
select :: Int -> ArrayList a -> Int -> a
Diese Größe wird in jedem rekursiven Aufruf verdoppelt. Wenn das erste Bit Null ist,suchen wir in den restlichen Bits weiter.
select size_t (Zero : ts) n =select (2*size_t) ts n
Wenn das erste Bit Eins ist, entscheiden wir anhand der Größe des nächsten Binärbaums,ob wir das gesuchte Element in ihm finden oder rekursiv abteigen. Wenn der gesuchte
12
Index kleiner als die Größe des nächsten Binärbaums ist, suchen wir in diesem, sonstrekursiv in den restlichen Bits, mit einem entsprechend angepassten Index.
select size_t (One t : ts) n| n < size_t =
selectBinTree (size_t`div`2) t n| otherwise =
select (2*size_t) ts (n-size_t)
Die Berechnung des Größenparameters ist dabei nur korrekt, wenn die Invarianten gelten.Wenn man zum Beispiel die Nullen bei der Darstellung wegließe, könnte man den Indexnicht mehr auf diese Weise berechnen.Die Funktion selectBinTree verwenden wir, um ein Element in einem vollständigenBinärbaum zu suchen. Auch sie hat einen Größenparameter, der hier die Größe des linkenTeilbaums des Arguments beschreibt, oder Null ist, wenn das Argument in Blatt ist.
selectBinTree :: Int -> BinTree a -> Int -> aselectBinTree 0 (Leaf x) 0 = xselectBinTree size_u (Fork u v) n
| n < size_u =selectBinTree (size_u`div`2) u n
| otherwise =selectBinTree (size_u`div`2) v (n-size_u)
Wie bei select, verwenden wir auch hier den Größenparameter, um zu entscheiden, obwir in den linken Teilbaum absteigen oder ihn überspringen.Die Laufzeit von select ist beschränkt durch die Anzahl der Bits plus die Größe desgrößten Binärbaums. Beides ist logarithmisch in der Länge der Array-Liste, also auch dieLaufzeit von (!).Auch das Pattern-Matching in selectBinTree ist nur dann sicher, wenn die Invariantengelten, also der Binärbaum vollständig ist.Schließlich definieren wir noch eine Funktion modify zur Manipulation eines Elements aneinem Index. Zusätzlich zum Index bekommt diese Funktione einen Funktions-Parameterübergeben, der auf das zu ändernde Element angewendet wird.
modify :: Int -> (a -> a) -> ArrayList a -> ArrayList amodify = update 1
Auch modify verwendet eine Hilfsfunktion mit zusätzlichem Größenparameter. DieImplementierung dieser Funktion ähnelt der von select, baut aber die komplette Listewieder auf, während sie zum gesuchten Element absteigt.Wenn das erste Bit Null ist, verarbeiten wir rekursiv die restlichen Bits.
update size_t n f (Zero : ts) =Zero : update (2*size_t) n f ts
13
Wenn nicht, entscheiden wir uns wieder fürs Absteigen oder Überspringen und verwendenim ersten Fall die Funktion updateBinTree.
update size_t n f (One t : ts)| n < size_t =
One (updateBinTree (size_t`div`2) n f t):ts| otherwise =
One t : update (2*size_t) (n-size_t) f ts
updateBinTree steigt in den Binäybaum wie select, liefert aber den veränderten Baumzurück, statt nur das gesuchte Element.
updateBinTree 0 0 f (Leaf x) = Leaf (f x)updateBinTree size_u n f (Fork u v)
| n < size_u =Fork (updateBinTree (size_u`div`2) n f u) v
| otherwise =Fork u (updateBinTree
(size_u`div`2) (n-size_u) f v)
Trotzdem ist die Laufzeit von update wie die von select nur logarithmisch, da wesentlicheTeile des Binärbaums und auch der Liste von Binärbäumen geteilt, also nicht kopiert,werden.
Zur Vervollständigung unseres Interfaces definieren wir noch eine Funktion zum Umwan-deln normaler Listen in Array-Listen, was einfach mittels foldr möglich ist:
fromList :: [a] -> ArrayList afromList = foldr (<:) empty
Analysiert man die Laufzeit dieser Funktion, würde man zunächst davon augehen, dass siedie Laufzeit O(n · log n) hat. Diese Abschätzung ist allerdings zu pessimistisch. Zwar hatdie Anwendung der Funktion (<:) im schlechtesten Fall die Laufzeit log n mit n Anzahlder Elemente im Array. Überlegt man sich aber, wie oft dieser Worst-Case-Fall aber nurauftritt, so amortisiert sich die Gesamtlaufzeit auf O(n) mit n Länge der Eingabeliste.Bereits jede zweite Anwendung von (<:) erfolgt auf im obersten Binärbaum, jede vierteAnwendung auf dem zweiten Binärbaum, jede achte auf den dritten Binärbaum, usw. Mitanderen Worten, benötigt (<:) beim Umwandeln einer Liste der Länge n nur einnmallog n Schritte, zweimal (log n) − 1 Schritte , viermal (log n) − 2, usw. Summiert man alldiese Schritte auf, erhält man O(n) Schritte, für n LÄnge der Liste.
Sichere Implementierung von Array-Listen
Obwohl wir uns bemüht haben, die Invarianten bei der Definition der Operatoren aufrechtzu erhalten, ist die Implementierung komplex genug, dass Fehler nicht ausgeschlossensind. Um uns zu vergewissern, dass die Invarianten tatsächlich erhalten bleiben, können
14
wir die definierten Funktionen testen. Dazu verwenden wir QuickCheck, damit wir nurdie Eigenschaften und nicht die Test-Eingaben selbst definieren müssen.
Das Prädikat isValid prüft, ob eine gegebene Array-Liste die geforderten Invariantenerfüllt.
isValid :: ArrayList a -> BoolisValid l = (isEmpty l || nonZero (last l))
&& all zeroOrComplete l&& and (zipWith zeroOrHeight [0..] l)
Die Funktion nonZero testet, ob ein Bit Eins ist, zeroOrComplete testet ob ein Bit Nullist oder der enthaltene Baum vollständig und zeroOrHeight testet, ob ein Bit Null istoder der enthaltene Baum die gegebene Höhe hat. Statt die Anzahl der Blätter zu zählen,genügt es bei einem vollständigen Baum, die Höhe zu berechnen.
Wir verzichten hier auf die Angabe der Hilfsfunktionen. Deren Definitionen sowie geeigneteEigenschaften zum Testen der Operationen stehen im Modul PartialArrayList.
Nach der Definition eines geeigneten QuickCheck-Generators für Array-Listen, könnenwir automatisch testen, ob unsere Implementierung korrekt ist. Alle gezeigten Funktionensind korrekt implementiert, es wäre aber ein leichtes gewesen, Fehler einzubauen.
Zum Beispiel sieht die folgende Regel für die cons-Funktion auf den ersten Blick korrektaus, ist es aber nicht:
cons u (One v : ts) = cons (Fork u v) ts
Ebenso ist das folgende keine korrekte Regel für decons:
decons (One u : ts) = (u, ts)
Obwohl QuickCheck gute Dienste leistet, solche Fehler zu finden, wäre es schön, wenn wirsie gar nicht erst machen könnten. Das Problem ist, dass unser Datentyp für Array-ListenWerte erlaubt, die keine gültigen Array Listen sind. Besser wäre, wenn wir den Typ sodefinieren könnten, dass gar keine ungültigen Array-Listen dargestellt werden können.Dann wären die obigen Fehler Typfehler und würden schon zur Kompilier-Zeit erkannt.
Auf den ersten Blick ist unklar, we man eine so komplexe Invariante wie die für Array-Listen im Typsystem kodieren kann. Dies ist aber tatsächlich möglich.
Wir beginnen mit einer einfachen Idee, die es uns später erlaubt, die erste Invariantesicher zu stellen, dass am Ende der Liste immer ein Baum steht. Dazu verwenden wir stattnormaler Listen einen eigenen Listendatentyp, der sicher stellt, dass am Ende immer einElement steht. So einen Datentyp für nicht-leere Listen könnte man wie folgt definieren:
data NEList a = End a | Cons a (NEList a)
Schwieriger ist es, sicherzustellen, dass alle Einträge einer Liste vollständige Binärbäumeeiner festen, in jedem Schritt um eins wachsenden Höhe sind. Der folgende Datentyp für
15
Array-Listen stellt dies sicher. Da wir intern nicht-leere Listen von Bäumen verwenden,stellen wir die leere Array-Liste durch einen eigenen Konstruktor dar:
data ArrayList a = Empty| NonEmpty (TreeList a)
Eine Liste von Bäumen ist entweder einelementig oder beginnt mit einem Bit gefolgtvon weiteren Bäumen.
data TreeList a = Single a| Bit a :< TreeList (a,a)
Bemerkenswert ist hierbei der sich ändernde Typparameter von TreeList. Datentypen,die in ihrer Definition mit veränderten Typparametern verwendet werden, nennt mannicht-regulär oder nested data types. Der Effekt dieser Definition ist, dass die Restliste einerTreeList Int nicht Ints sondern Paare von Ints enthält! Die Restliste der Restlisteenthält Paare von Paaren von Ints und so weiter. Dadurch wird die Baumstrukturder enthaltenen Binärbäume durch die Paar-Konstruktoren erzeugt, wie die folgendenBeispiele zeigen:
Single 1
Zero :< Single (2,3)
One 1 :< Zero :< Single ((2,3),(4,5))
Alle diese Werte sind vom Typ TreeList Int, der Bit-Datentyp ist also nun wie folgtdefiniert und enthält keine expliziten Binärbäume mehr:
data Bit a = Zero | One a
Der Versuch, eine ungültige Array-Liste zu bauen, wird jetzt vom Typchecker verhindert:
ghci> Zero :< Single (42 :: Int)Couldn't match expected type `(a, a)'against inferred type `Int'
Die Funktionen auf Array-Listen lassen sich wie folgt auf den neuen Datentyp übertragen.
Die leere Array-Liste wird durch Empty dargestellt:
empty :: ArrayList aempty = Empty
isEmpty :: ArrayList a -> BoolisEmpty Empty = TrueisEmpty _ = False
16
Um ein Element vorne an eine Array-Liste anzuhängen, definieren wir wieder eineFunktion (<:). Wir behandeln zunächst leere Array-Listen gesondert:
(<:) :: a -> ArrayList a -> ArrayList ax <: Empty = NonEmpty (Single x)x <: NonEmpty l = NonEmpty (cons x l)
Die Funktion cons definieren wir wieder in Anlehnung an das Inkrementieren einerBinärzahl:
cons :: a -> TreeList a -> TreeList acons x (Single y) = Zero :< Single (x,y)cons x (Zero :< xs) = One x :< xscons x (One y :< xs) = Zero :< cons (x,y) xs
Wenn wir bei dieser Definition in der ersten oder letzten Regel die Null vergessen, führtdas zu einem Typfehler:
Occurs check:cannot construct the infinite type:a = (a, a)
Bemerkenswert ist der rekursive Aufruf von cons in der letzten Regel. Sein erstesArgument ist vom Typ (a,a) und die Liste xs ist vom Typ TreeList (a,a). Wenn derTyp einer Funktion im rekursiven Aufruf ein anderer ist, als der beim umgebenden Aufruf,nennt man das polymorphe Rekursion. Diese wird typischerweise bei nicht-regulärenDatentypen verwendet, die ja eine rekursive Komponente mit veränderten Typparameternhaben.
Der Typ einer polymorph rekursiven Funktion1 kann nicht inferiert werden, wir dürfendie Typsignatur von cons also nicht weglassen. Tun wir es doch, bekommen wir den ebengezeigten Typfehler.
Zur Definition von first und rest behandeln wir einelementige Array-Listen gesondertund verwenden dann wieder eine Hilfsfunktion decons, die die Ergebnisse von first undrest auf einmal berechnet.
first :: ArrayList a -> afirst (NonEmpty (Single x)) = xfirst (NonEmpty l) = fst $ decons l
rest :: ArrayList a -> ArrayList arest (NonEmpty (Single _)) = Emptyrest (NonEmpty l) = NonEmpty . snd $ decons l
1im Gegensatz zum Typ einer (nur) polymorphen, rekursiven Funktion
17
decons wird nie mit einelementigen Listen aufgerufen, entspricht also dem Dekrementiereneiner Binärzahl größer als zwei.
decons :: TreeList a -> (a, TreeList a)decons (One x :< xs) = (x, Zero :< xs)decons (Zero :< Single (x,y)) = (x, Single y)decons (Zero :< xs) = (x, One y :< ys)where ((x,y),ys) = decons xs
Da vor jeden Aufruf von decons die einelementige Liste gesondert behandelt wird, istdiese partielle Definition von decons sicher. Auch hier bekämen wir wieder Typfehler,wenn wir im Ergebnis Listen erzeugen würden, die die Invarianten verletzen oder dieTypsignatur wegließen.
Die Definition der Funktionen zum Zugriff auf einen beliebigen Index wird durch dieneue Darstellung dadurch erschwert, dass wir die Funktionen zum Absteigen in dievollständigen Binärbäume nicht mehr so leicht definieren können. Unterschiedlich großeBinärbäume haben unterschiedliche Typen, wir können also keine einzige Funktionschreiben, die Bäume beliebiger Größe akzeptiert.
Eine mögliche Lösung des Problems ist es, die Funktion zum Nachschlagen eines Blattesin einem Binärbaum als zusätzlichen Parameter mitzuführen. Der Typ dieser Funktionändert sich nämlich genau wie der Typ der TreeList, die wir verarbeiten.
Die Funktion (!) ist wie folgt definiert:
(!) :: ArrayList a -> Int -> aEmpty ! _ = error "ArrayList.!: empty list"NonEmpty l ! n = select 1 sel l nwheresel x m
| m == 0 = x| otherwise =
error $ "ArrayList.!: invalid index "++ show n
Wir verwenden wieder eine Hilfsfunktion select, die die Größe des nächsten Binärbaumsmitführt. Zusätzlich führt sie nun aber auch noch eine Funktion sel mit, die ein Blattin diesem Binärbaum nachschlagen kann. Im ersten Aufruf hat die sel-Funktion denTyp a -> Int -> a, dieser ändert sich aber in rekursiven Aufrufen wie der Typ derTreeList. Dadurch, dass wir sel lokal definieren, können wir den ursprünglichen Indexn im Fehlerfall ausgeben. Der Index m muss Null sein, da ein einelementiger Binärbaumnur am Index Null ein Element enthält.
Der Typ der select-Funktion zeigt, dass die übergebene Funktion als Argument genauden Typ nimmt, den die TreeList (als erstes) enthält.
18
select :: Int-> (b -> Int -> a)-> TreeList b -> Int -> a
In der Definition von select behandeln wir zunächst den Fall einer einelementigen Liste:
select _ sel (Single x) n = sel x n
Hierbei ignorieren wir den Größenparameter, da die Funktion sel die Größe den Binär-baums kennt und den Index nachschlagen kann.
Die zweite Regel verwendet wie vorher den Größenparameter, um zu entscheiden, obder nächste Baum übersprungen werden soll. Zusätzlich übergeben wir im rekursivenAufruf eine angepasste Funktion, die einen Wert in einem doppelt so großen Binärbaumnachschlägt.
select size_x sel (bit :< xs) n =case bit of
Zero -> select (2*size_x) descend xs nOne x ->if n < size_x then sel x n elseselect (2*size_x) descend xs (n-size_x)
wheredescend (l,r) m | m < size_x = sel l m
| otherwise = sel r (m-size_x)
Da der Teilbaum, den descend als Argument erhält, doppelt so groß ist wie x, entsprichtdie Größe von x genau der Größe des linken Teilbaums dieses Arguments. Die descend-Funktion entscheidet anhand dieser Größe, ob sie in den linken oder rechten Teilbaum desBinärbaums absteigt und verwendet statt eines rekursiven Aufrufs die vorher übergebeneFunktion sel, die für halb so große Bäume definiert wurde.
Die modify-Funktion definieren wir analog dazu auch durch eine Hilfsfunktion updatemit zwei Zusatzparametern: einem für die Größe des nächsten Baums und einem zumVerändern eines solchen Baums.
modify :: Int->(a->a)->ArrayList a->ArrayList amodify _ _ Empty =
error "ArrayList.modify: empty list"modify n f (NonEmpty l) =
NonEmpty $ update 1 upd n lwhereupd m x
| m == 0 = f x| otherwise =
error $ "ArrayList.modify: invalid index "++ show n
19
Die upd-Funktion für einen einelementigen Baum, wendet die gegebene Funktion aufdiesen Baum, der ja nur durch seine Beschriftung selbst dargestellt wird, an. Bei ungültigenIndizes liefert sie eine Fehlermeldung mit dem ursprünglichen Index.
Die update-Funktion nimmt als Argument eine solche upd-Funktion, die die Elementeder übergeben TreeList manipuliert.
update :: Int-> (Int -> a -> a)-> Int -> TreeList a -> TreeList a
Die Regel für einelementige Listen, wendet diese upd-Funktion auf das Element der Listean:
update _ upd n (Single x) = Single $ upd n x
Die zweite Regel definiert wie select eine abgewandelte Funktion descend für denrekursiven Aufruf, die die ursprüngliche upd Funktion verwendet.
update size_x upd n (bit :< xs) =case bit of
Zero ->Zero :< update (2*size_x) descend n xs
One x ->if n < size_x then One (upd n x) :< xs elsebit :<update (2*size_x) descend (n-size_x) xs
wheredescend m (l,r)
| m < size_x = (upd m l, r)| otherwise = (l, upd (m-size_x) r)
Damit ist die Implementierung typsicherer Array-Listen komplett. Wir brauchen nun nichtmehr QuickCheck zu verwenden, um zu testen, ob die Invarianten eingehalten werden, dadies schon durch die Typprüfung sichergestellt ist. Wir sollten natürlich trotzdem Testsschreiben, die die Korrektheit der Operationen prüfen. Nur weil die Invariante aufrechterhalten wird, heißt das noch nicht, dass die Funktionen die Reihenfolge der Elementenicht aus Versehen verändern oder falsche Elemente manipuliert werden. Tests, die dieKorrektheit der Implementierung überprüfen, stehen im Modul ArrayList, das auch diehier gezeigte Implementierung enthält.
Tries
Im Kapitel über Arrays haben wir gesehen, wie man Indizes effizient Werte zuordnen kann,ohne die Indizes explizit zu speichern. Ein Array haben wir dabei als Baum dargestellt,in dem jede Position implizit einem Index entsprach. Dabei war die Entfernung dieser
20
Position von der Wurzel des Baumes genau die Länge der Binärdarstellung des Index. Indiesem Kapitel werden wir Datenstrukturen, sogenannte Tries2, kennen lernen, die dieseIdee für andere Schlüssel als Zahlen verwenden.
Die Idee hinter Tries steht im Kontrast zur expliziten Darstellung der Schlüssel in einemSuchbaum oder einer sortierten Liste. Eine Zuordnung von beliebigen vergleichbarenSchlüsseln zu beliebigen Werten kann man als Liste von Paaren darstellen, wie hier amBeispiel von Char-Schlüsseln:
type CharMap a = [(Char,a)]
Die leere Zurodnung ist die leere Liste.
emptyCharMap :: CharMap aemptyCharMap = []
Wir schlagen einen Wert in einer CharMap nach, indem wir den zugehörigen Wert zumgegebenen Schlüssel suchen und liefern Nothing zurück, falls kein Wert zu diesem Schlüsselgespeichert ist:
lookupChar :: Char -> CharMap a -> Maybe alookupChar _ [] = NothinglookupChar c ((c',x):xs)
| c == c' = Just x| otherwise = lookupChar c xs
Um einen neuen Eintrag zu speichern, fügen wir ihn vorne an die CharMap an und löschenden alten Eintrag aus ihr.
insertChar :: Char -> a -> CharMap a -> CharMap ainsertChar c x xs = (c,x) : deleteChar c xs
Löschen können wir einen Eintrag, indem wir nur solche Einträge behalten, die ein anderesZeichen als Schlüssel enthalten.
deleteChar :: Char -> CharMap a -> CharMap adeleteChar c = filter ((c/=) . fst)
Effizienter wäre eine Implementierung mittels eines Suchbaums, die man immer dannverwenden kann, wenn es eine Ordnung auf dem Typ der Schlüssel gibt.
Wir lernen nun eine weitere Möglichkeit kennen, Werte Schlüsseln zuzuordnen, die sichan der Struktur der Schlüssel orientiert. Als erstes Beispiel verwenden wir Stringsals Schlüssel. Statt die Ordnung auf Strings auszunutzen und einen Suchbaum zuverwenden, nutzen wir deren Struktur, um Werte an bestimmte Positionen in einemBaum zu schreiben. Zum Beispiel speichert der folgende Baum die Zuordnung
2Trie kommt von *re__trie__ve* wird aber dennoch von einigen, zur Unterscheidung von tree, wie tryausgesprochen.
21
"to" -> 17"tom" -> 42"tea" -> 11"ten" -> 10
t
17
o e
42
m
11
a
10
n
Abbildung 2: String-Trie
In disem Baum enthalten manche Knoten Werte und andere nicht. Der zu einem Wertgehörige Schlüssel kann an den Kanten, die von der Wurzel zu diesem Wert führen,abgelesen werden. Jede StringMap ist also ein Knoten und besteht aus einem optionalenWert und einer Zuordnung von Zeichen zu kleineren StringMaps, die die Zuordnung vomRestwort zu einem Wert speichern:
data StringMap a =StringMap (Maybe a) (CharMap (StringMap a))
Hierbei verwenden wir die oben definierte CharMap, um die Kanten in dem Baum zuspeichern. Die obige Beispielzuordnung wird mit diesem Datentyp wie folgt dargestellt:
StringMap Nothing
22
[('t',StringMap Nothing[('o',StringMap (Just 17)
[('m',StringMap (Just 42) [])]),('e',StringMap Nothing
[('a',StringMap (Just 11) []),('n',StringMap (Just 10) [])])])]
Die leere StringMap speichert keinen Wert (ein Wert an der Wurzel wäre der Eintrag, derdem leeren String zugeordnet ist) und eine leere Zuordnung von Zeichen zu StringMaps.
emptyStringMap :: StringMap aemptyStringMap = StringMap Nothing emptyCharMap
Zum Nachschlagen eines zu einem String gespeicherten Wertes untersuchen wir dieStruktur des Schlüssels.
lookupString :: String -> StringMap a -> Maybe a
Wenn der Schlüssel der leere String ist, geben wir den an der Wurzel gespeichertenEintrag zurück:
lookupString [] (StringMap a _) = a
Wenn der Schlüssel aus einem ersten Zeichen c und restlichen Zeichen cs besteht, suchenwir aus der CharMap die zu c gehörige StringMap heraus und suchen in dieser rekursivden Schlüssel cs. Durch die Verwendung der Maybe-Monade ist das GesamtergebnisNothing, wenn die CharMap keinen Eintrag für c enthält.
lookupString (c:cs) (StringMap _ b) =lookupChar c b >>= lookupString cs
Um einen Wert unter einem String einzufügen, speichern wir ihn an der Wurzel, wennder String leer ist,
insertString :: String -> a -> StringMap a -> StringMap ainsertString [] x (StringMap _ b) = StringMap (Just x) b
oder wir fügen der CharMap unter dem ersten Zeichen c einen Eintrag hinzu, der diealten Einträge enthält und zusätzlich den neuen unter den restlichen Zeichen cs.
insertString (c:cs) x (StringMap a b) = StringMap a $case lookupChar c b of
Nothing ->insertChar c
(insertString cs x emptyStringMap) bJust m ->
insertChar c(insertString cs x m) b
23
Diese Definition verwendet die Funktion lookupChar, und fügt je nach deren Ergebnis dierestlichen Zeichen entweder in die leere StringMap oder oder in die nachgschlagene ein. Dadie rechten Seiten des case-Ausdrucks sich nur im letzten Argument von insertStringunterscheiden, können wir Code-Duplikation vermeiden, indem wir die Fallunterscheidungin dieses Argument hineinziehen:
insertString (c:cs) x (StringMap a b) =StringMap a(insertChar c(insertString cs x(maybe emptyStringMap id (lookupChar c b)))
b)
Die Funktion maybe :: b -> (a -> b) -> Maybe a -> b ist in der Prelude vordefi-niert.
Die Laufzeit von insertString ist (wenn wir von der Laufzeit der ineffizient imple-mentierten CharMap absehen) linear in der Länge des als Schlüssel übergebenen Strings.Anders als bei Suchbäumen, deren Laufzeit logarithmisch in der Anzahl der gespeichertenWerte ist, ist die Laufzeit von Trie-Funktionen unabhängig von der Größe des Tries. Daauch Suchbaum-Implementierungen den Schlüssel ansehen müssen, um ihn zu vergleichen,hängt auch deren Laufzeit von der Größe der Schlüssel ab, so dass die Laufzeit vonTrie-Operationen theoretisch besser ist. Oft ist aber der Vergleich eines Schlüssels nicht soteuer wie der Abstieg entsprechend seiner Struktur in einem Trie. Welche Datenstrukturin der Praxis besser ist, hängt vom Anwendungsbeispiel ab, insbesondere davon, wiedicht die Datenstruktur besetzt ist.
Beim Löschen eines Eintrags gehen wir ähnlich vor wie zum Einfügen, um den gesuchtenSchlüssel zu finden.
deleteString :: String -> StringMap a -> StringMap a
Wenn der Schlüssel der leere String ist, löschen wir den Eintrag an der Wurzel.
deleteString [] (StringMap _ b) =StringMap Nothing b
Ansonsten entfernen wir aus der unter dem ersten Zeichen gespeicherten StringMap denReststring.
deleteString (c:cs) (StringMap a b) =case lookupChar c b of
Nothing -> StringMap a bJust m ->
StringMap a(insertChar c (deleteString cs d) b)
24
Auch hier können wir die Duplikation gemeinsamer Teile der rechten Seiten vermeiden,indem wir die maybe-Funktion verwenden.
deleteString (c:cs) (StringMap a b) =StringMap a(maybe b(\d -> insertChar c (deleteString cs m) b)(lookupChar c b))
Sowohl die insertString als auch die deleteString Funktion verwenden abgesehenvom rekursiven Aufruf die lookupChar Funktion zusammen mit insertChar, um dieStringMap mit dem Reststring zu verändern. Eleganter wäre, wenn man dazu nicht zweiFunktionen verwenden müsste, die die CharMap beide durchlaufen, sondern eine einzigeFunktion updateChar zum Verändern einer CharMap verwenden könnte.
Da wir mit updateChar sowohl Elemente einfügen als auch entfernen wollen, geben wirihr den folgenden Typ.
updateChar :: Char-> (Maybe a -> Maybe a)-> CharMap a -> CharMap a
Das erste Argument ist das Zeichen, dessen Eintrag geändert werden soll und daszweite eine Funktion, die die Änderung vornimmt. Sowohl der Argument- als auch derErgebnistyp dieser Funktion ist Maybe a. Um einen Wert einzufügen, übergeben wirdieser Funktion Nothing, um eines zu löschen, liefert diese Funktion Nothing.
Zum Ändern einer leeren CharMap rufen wir also die übergebene Funktion mit Nothingauf und tragen das Ergebnis dieses Aufrufs in die CharMap ein, wenn es nicht Nothingist.
updateChar c upd [] =maybe [] (\x -> [(c,x)]) (upd Nothing)
Bei einer nicht-leeren CharMap übergeben wir Just x an upd, falls x unter dem Zeichen cgespeichert ist, und Ändern den Eintrag unter c gemäß des Ergebnisses dieses Aufrufs. Esist also nicht nur möglich vorhandene Einträge zu löschen sondern auch, sie zu verändern.
updateChar c upd ((c',x):xs)| c == c' =
maybe xs (\y -> (c,y):xs) (upd (Just x))| otherwise = (c',x) : updateChar c upd xs
Statt updateChar zu verwenden, um insertString und deleteString zu definieren,definieren wir eine Funktion updateString, mit deren Hilfe wir beide Funktion defi-nieren können. Angenommen, updateString wäre schon definiert, dann könnten wirinsertString und deleteString wie folgt definieren.
25
insertString s x = updateString s (const (Just x))
deleteString s = updateString s (const Nothing)
Der Typ der Funktion updateString entspricht dem von updateChar.
updateString :: String-> (Maybe a -> Maybe a)-> StringMap a -> StringMap a
Um den unter dem leeren String gespeicherten Wert zu ändern, wenden wir die übergebeneupd-Funktion auf diesen an.
updateString [] upd (StringMap a b) =StringMap (upd a) b
Bei einem nicht-leeren String wenden wir updateChar und geschachtelt updateStringan. Dabei übergeben wir den updateString Aufruf als upd-Funktion an updateCharund kombinieren diesen dazu mit Funktionen, die dafür sorgen, dass er einen Maybe-Wertals Argument nimmt und als Ergebnis liefert.
updateString (c:cs) upd (StringMap a b) =StringMap a
(updateChar c(Just . updateString cs upd
. maybe emptyStringMap id)b)
Die neuen Implementierungen von insertString und deleteString durchlaufen dieCharMaps seltener. Das allgemeinere update-Verfahren hat, so wie wir es implementierthaben, aber auch einen Nachteil. Beim Löschen eines nicht vorhandenen Werts, wird einEintrag für den gelöschten Schlüssel erzeugt (und mit Nothing belegt), auch wenn dieservorher gar nicht vorhanden war:
ghci> deleteString "a" emptyStringMapStringMap Nothing [('a',StringMap Nothing [])]
Die alte Implementierung hat dieses Problem zwar nicht, entfernt allerdings auch keine vor-handenen Einträge, wenn sie leer sind. Mit der alten (wie mit der neuen) Implementierungvon deleteString ergibt sich:
ghci> let a=insertString "a" 42 emptyStringMapghci> deleteString "a" aStringMap Nothing [('a',StringMap Nothing [])]
Um leere Zweige im Baum zu vermeiden, kann man die Implementierung der update-Funktionen anpassen (siehe Übung).
26
Verallgemeinerte Tries
Die Idee, die Struktur der Schlüssel auszunutzen und ihnen feste Positionen in einerDatenstruktur zuzuordnen, lässt sich auf andere Datentypen verallgemeinern. Wir lernennun zwei Beispiele kennen, die das verdeutlichen. Zunächst betrachten wir einen Datentypfür Binärzahlen, um den Zusammenhang zwischen Tries und den zuvor definierten Arrayszu klären. Später betrachten wir als Beispiel eines komplizierteren rekursiven DatentypsBäume als Schlüssel.
Positive Binärzahlen können als Werte des folgenden Datentyps dargestellt werden.
data Nat = One | O Nat | I Nat
One ist die Darstellung der Zahl 1 oder allgemeiner des höchst-wertigen Bits einerbeliebigen positiven Zahl. Führende Nullen (also auch die Zahl 0) können mit diesemDatentyp nicht dargestellt werden. Der äußerste Konstruktor ist immer das niedrigsteBit. Zum Beispiel wird die Zahl 6 als O (I One) dargestellt.
Die Trie-Struktur für diesen Datentyp enthält Knoten mit drei Einträgen:
• einem für den Eintrag des Schlüssels One,
• eine NatMap für die restlichen Bits der Schlüssel, die mit O beginnen, und
• eine NatMap für die restlichen Bits der Schlüssel, die mit I beginnen.
data NatMap a =NatMap (Maybe a) (NatMap a) (NatMap a)
Dieser Datentyp kann aus der Deklaration des Nat-Datentyps abgeleitet werden. DerNatMap-Konstruktor hat für jeden Konstruktor des Nat-Typs ein Argument. Die Typender Argumente des NatMap-Konstruktors ergeben sich aus den Typen der Argumenteder entsprechenden Nat-Konstrutoren. Hier hat der One-Konstruktor kein Argument, derNatMap-Konstruktor hat also an der entsprechenden Stelle einen Wert vom Typ Maybea. Die beiden anderen Konstruktoren haben jeweils ein Argument vom Typ Nat, derNatMap-Konstruktor hat also an den entsprechenden Stellen Argumente vom Typ NatMapa.
An der Wurzel einer NatMap steht der Eintrag, der zu One gehört, Darunter stehen dieNatMaps, die zu allen geraden bzw. ungeraden Schlüsseln gehören. Das folgende Bild zeigtdie Schlüssel der ersten vier Ebenen einer NatMap.
Wenn wir die Einträge in ihre Dezimaldarstellung konvertieren, ergibt sich fast das Bildder Indizes in unserer Array-Implementierung, nur dass die Schlüssel alle um eins größersind als die Array-Indizes, die bei Null anfangen, statt bei eins.
Wie ein Array ist auch eine NatMap immer unendlich. Anders als ein Array enthält eineNatMap aber den Wert Nothing (statt eines Laufzeitfehlers) an Positionen, die keinemWert zugeordnet sind. Die leere NatMap definieren wir also wie folgt.
27
1
01
O
11
I
001
O
011
I
101
O
111
I
0001
O
0011
I
1001
O
1011
I
0101
O
0111
I
1101
O
1111
I
Abbildung 3: Nat-Trie
emptyNatMap :: NatMap aemptyNatMap =
NatMap Nothing emptyNatMap emptyNatMap
Die Funktion zum Nachschlagen eines Schlüssels in einer NatMap folgt, wie die Definitiondes NatMap-Datentyps selbst, der Struktur der Werte vom Typ Nat.
lookupNat :: Nat -> NatMap a -> Maybe alookupNat One (NatMap a _ _) = alookupNat (O n) (NatMap _ b _) = lookupNat n blookupNat (I n) (NatMap _ _ c) = lookupNat n c
Wenn der Schlüssel One ist, wird das erste Argument geliefert, wenn er mit O beginnt, wirdlookupNat rekursiv auf das zweite Argument angewendet und, wenn er mit I beginnt,auf das dritte.
Die insert- und delete-Funktionen definieren wir wieder mit Hilfe einer verallgemeiner-ten update-Funktion.
insertNat :: Nat -> a -> NatMap a -> NatMap ainsertNat n = updateNat n . const . Just
deleteNat :: Nat -> NatMap a -> NatMap adeleteNat n = updateNat n (const Nothing)
28
Auch updateNat folgt wie lookupNat der Struktur der Nat-Werte.
updateNat :: Nat-> (Maybe a -> Maybe a)-> NatMap a -> NatMap a
updateNat One upd (NatMap a b c) =NatMap (upd a) b c
updateNat (O n) upd (NatMap a b c) =NatMap a (updateNat n upd b) c
updateNat (I n) upd (NatMap a b c) =NatMap a b (updateNat n upd c)
Anders als bei StringMaps brauchen wir uns hier nicht um leere Zweige zu kümmern(können wir auch gar nicht!), da diese durch die unendliche Struktur der NatMap-Wertenicht zu vermeiden sind. Die Darstellung von NatMaps ist anders als die von StringMapsnicht redundant.
Die NatMaps entsprechen also, abgesehen von der Index-Verschiebung und den exlizitenNothing-Einträgen, genau unseren Arrays. Auch die Laufzeiten der Funktionen sindidentisch. Der Array-Zugriff hat logarithmische Laufzeit in der Größe des Index, derNatMap-Zugriff hat lineare Laufzeit in der Größe der gegebenen Binärzahl. Da die Größeeiner Binärzahl logarithmisch in der Größe der dargestellten Zahl ist, entsprechen sichdiese Laufzeiten.
Neben den definierten Funktionen sind weitere denkbar. Zum Beispiel können wir eine map-Funktion für NatMaps angeben, die eine Funktion auf die Wert einer NatMap anwendet,indem wir eine Instanz der Klasse Functor definieren. Auch eine Funktion, die eineNatMap in eine Liste ihrer Schlüssel/Wert-Paare umwandelt, wäre nützlich. Leider könnenwir keine solche Funktion definieren, die terminiert, da NatMaps immer unendlich großsind, selbst, wenn sie nur endlich viele Schlüssel/Wert-Paare enthalten.
Wir können aber eine Monoid-Instanz definieren, bei der die Verknüpfung die Vereinigungzweier NatMaps berechnet. Dabei soll die Implementierung von mappend die Einträge derlinken NatMap bevorzugen, wenn beide Argumente einen Eintrag zum selben Schlüsselenthalten.
instance Monoid (NatMap a) wheremempty = emptyNatMap
NatMap a1 b1 c1 `mappend` NatMap a2 b2 c2 =NatMap (a1 `mplus` a2)
(b1 `mappend` b2)(c1 `mappend` c2)
29
Auch den Schnitt zweier NatMaps könnten wir auf diese Weise berechnen.
Nat-Werte als Schlüssel sind etwas einfacher als Strings, im Folgenden betrachten wiretwas kompliziertere Schlüssel, nämlich Bäume:
data Tree = Leaf String | Fork Tree Tree
Die Blätter solcher Bäume sind mit Strings beschriftet, innere Knoten haben genau zweiNachfolger und sind unbeschriftet.
Die zu diesem Typ gehörende Trie-Struktur ist eine Baum-Struktur, in der jede Positionzu einem Baum vom Typ Tree gehört. Die Schlüssel für eine TreeMap sind Trees.
data TreeMap a =TreeMap (StringMap a) (TreeMap (TreeMap a))
Wieder ergibt sich die Definition des TreeMap-Typs aus der des Tree-Typs. Der TreeMap-Konstruktor hat zwei Argumente, da der Tree-Typ zwei Konstruktoren hat. Das ersteArgument ist eine StringMap, da das (einzige) Argument des ersten Tree-KonstruktorsLeaf vom Typ String ist. Der zweite Tree-Konstruktor Fork hat zwei Argumente, diebeide vom Typ Tree sind. Das zweite Argument des TreeMap-Konstruktors hat daherden Typ TreeMap (TreeMap a). Mehrere Argumente eines Konstruktors werden also inder Trie-Struktur zu geschachtelten Tries entsprechender Typen. Dieses Muster habenwir auch bei der Definition der StringMap benutzt, wo das zu (:) gehörige Argumentden Typ CharMap (StringMap a) hat.
Anders als bei der StringMap ist durch Anwendung dieses Musters auf den Tree-Datentypder Typ TreeMap ein Nested Datatype. Statt auf die Typvariable a wird zumindest einVorkommen des TreeMap-Typkonstruktors auf einen anderen Typ, nämlich TreeMapa angewendet. Nested Datatypes sind uns schon bei der Definition von Array-Listenbegegnet, wo wir sie ausgenutzt haben, um Invarianten der Darstellung im Typsystemzu kodieren. Wie dort brauchen wir auch hier polymorphe Rekursion, um rekursiveFunktionen auf TreeMaps zu definieren.
Zunächst definieren wir die leere TreeMap.
emptyTreeMap :: TreeMap aemptyTreeMap =
TreeMap emptyStringMap emptyTreeMap
Schon hier hat der rekursive Aufruf von emptyTreeMap einen anderen Typ als derumgebende Aufruf. Der Typ von emptyTreeMap kann also nicht inferiert werden und wirdürfen die Typsignatur nicht weglassen.
Die lookup-Funktion folgt wieder der Struktur des Schlüssels, der jetzt vom Typ Treeist.
lookupTree :: Tree -> TreeMap a -> Maybe alookupTree (Leaf s) (TreeMap a _) =
30
lookupString s alookupTree (Fork l r) (TreeMap _ b) =
lookupTree l b >>= lookupTree r
In der ersten Regel verwenden wir einfach lookupString um die Beschriftung desgegebenen Blattes in der zugehörigen StringMap nachzuschlagen. In der zweiten Regelschachteln wir zwei Aufrufe von lookupTree in der Maybe-Monade, wenn einer fehlschlägt,schlägt also der gesamte Aufruf fehl. Der erste rekursive Aufruf wendet lookupTree miteinem anderen Typ an als der umgebende Aufruf, der den gleichen Typ hat wie der zweiterekursive Aufruf. Das Egebnis des ersten rekursiven Aufrufs ist eine TreeMap auf die wirwieder lookupTree aufrufen. Auch lookupTree ist also polymorph rekursiv.Ebenso verhält es sich mit der updateTree-Funktion.
updateTree :: Tree-> (Maybe a -> Maybe a)-> TreeMap a -> TreeMap a
updateTree (Leaf s) upd (TreeMap a b) =TreeMap (updateString s upd a) b
updateTree (Fork l r) upd (TreeMap a b) =TreeMap a
(updateTree l(Just . updateTree r upd
. maybe emptyTreeMap id)b)
In der ersten Regel rufen wir die updateString-Funktion mit der Beschriftung des gege-benen Blattes auf, in der zweiten schachteln wir zwei rekursive Aufrufe von updateTreemit unterschiedlichen Typen und passen den inneren so an, dass er Maybe-Werte nimmtund liefert.Die insert und delete-Funktionen definieren wir wie üblich.
insertTree::Tree -> a -> TreeMap a -> TreeMap ainsertTree t = updateTree t . const . Just
deleteTree :: Tree -> TreeMap a -> TreeMap adeleteTree t = updateTree t (const Nothing)
Zum Beispiel liefert der Aufruf
insertTree(Fork (Leaf "a") (Leaf "bc"))42emptyTreeMap
das Ergebnis
31
TreeMapemptyStringMap(TreeMap(StringMapNothing[('a',
StringMap(Just (TreeMap
(StringMapNothing[('b',
StringMapNothing[('c',
StringMap (Just 42) [])])])emptyTreeMap)))])
emptyTreeMap)
Verkürzt und etwas übersichtlicher lässt sich dieses Ergebnis wie folgt darstellen:
Die Konstruktoren des als Schlüssel verwendeten Baums werden also von links nachrechts der Reihe nach verwendet, um die zugehörige Position im Trie zu finden. DerAbstand eines Eintrags von der Wurzel des Tries entspricht der Größe des als Schlüsselverwendeten Baums. Die Laufzeiten von lookupTree und updateTree sind entsprechendlinear in der Größe des als Schlüssel verwendeten Baums.
32
emptyStringMap
Leaf Fork
Leaf
emptyTreeMap
Fork
'a'
Leaf
emptyTreeMap
Fork
'b'
42
'c'
Abbildung 4: Beispiel einer ‘TreeMap‘
33
Top Related