Funktionale Datenstrukturen

Werbung
Funktionale Datenstrukturen
In diesem Kapitel behandeln wir die Implementierung ausgewählter Datenstrukturen in Haskell. Wir haben bereits Listen zur Darstellung von Sequenzen
kennengelernt sowie Suchbä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 a
emptyStack = []
isEmptyStack :: Stack a -> Bool
isEmptyStack = null
push :: a -> Stack a -> Stack a
push = (:)
top :: Stack a -> a
top = head
pop :: Stack a -> Stack a
pop = tail
Alle definierten Operationen haben konstante Laufzeit. Eng verwandt mit
Stacks sind Queues. 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 Queue werden 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 a
emptyQueue = []
isEmptyQueue :: Queue a
isEmptyQueue = null
1
enqueue :: a -> Queue a -> Queue a
enqueue x q = q ++ [x]
next :: Queue a -> a
next = head
dequeue :: Queue a -> Queue a
dequeue = tail
Wie die Operationen top und pop haben auch next und dequeue konstante
Laufzeit in der Größe des Arguments. Die Laufzeit von enqueue ist aber linear,
da die ++-Funktion mit der gegebenen Queue als erstem Argument aufgerufen
wird. Hätten wir Queues als Listen in umgekehrter Reihenfolge dargestellt,
dann könnten wir enqueue mit konstanter Laufzeit (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 auch next und dequeue in konstanter Laufzeit erlaubt? Da wir sowohl auf
den Anfang (wegen enqueue) als auch auf das Ende der Liste (wegen next und
dequeue) in konstanter Zeit zugreifen wollen, verwenden wir für die Darstellung
zwei Listen:
data Queue a = Queue [a] [a]
emptyQueue :: Queue a
emptyQueue = Queue [] []
isEmptyQueue :: Queue a -> Bool
isEmptyQueue (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ügt wurden 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, nehmen wir es aus der ersten Liste.
enqueue :: a -> Queue a -> Queue a
enqueue x (Queue xs ys) = Queue xs (x:ys)
next :: Queue a -> a
next (Queue (x:_) _) = x
dequeue :: Queue a -> Queue a
dequeue (Queue (_:xs) ys) = Queue xs ys
2
Die Implementierungen von next und dequeue sind noch unvollständig. Beide
Funktionen liefern kein Ergebnis, wenn die erste Liste leer ist, die zweite aber
nicht. Dieser Fall erfordert es, die zweite Liste, die die Elemente ja in umgekehrter
Reihenfolge speichert, komplett zu durchlaufen, um das nächste Element zu entfernen.
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 in der 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
Invariante aber nicht aufrecht: Nach dem Einfügen eines Elementes in eine
leere Queue ist die erste Liste leer, die zweite aber nicht. Außerdem tritt diese
Situation ein, wenn die erste Liste vor dem Aufruf von dequeue einelementig
ist.
Wir implementieren daher eine Konstruktor-Funktion queue, die sicher stellt,
dass die zweite Liste leer ist, falls die erste leer ist:
queue :: [a] -> [a] -> Queue a
queue [] 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. Mit der queue-Funktion können wir enqueue und dequeue wie folgt
definieren:
enqueue :: a -> Queue a -> Queue a
enqueue x (Queue xs ys) = queue xs (x:ys)
dequeue :: Queue a -> Queue a
dequeue (Queue (x:xs) ys) = queue xs ys
Im Unterschied zu den vorherigen Definitionen haben wir die queue-Funktion
statt des Queue-Konstruktors in den rechten Regelseiten verwendet.
Um zu testen, ob eine Queue leer ist, brauchen wir dank der Invariante nur noch
zu testen, ob die erste Liste leer ist:
isEmptyQueue :: Queue a -> Bool
isEmptyQueue (Queue xs _) = null xs
3
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
potentiell teure Aufruf von reverse passiert nur dann, wenn die erste Liste xs
leer ist, und in dem Fall ist auf Grund der Invariante auch ys leer also das
Argument von reverse einelementig.
Die Laufzeit von dequeue ist im schlechtesten Fall jedoch noch immer linear:
Falls die erste Liste einelementig ist und die zweite n − 1 Elemente enthält,
benötigt der queue Aufruf (auf Grund des reverse Aufrufs) n − 1 Schritte.
Dieser Fall tritt zum Beispiel dann ein, wenn n Elemente hintereinander mit
enqueue einer leeren Queue hinzugefügt werden.
Haben wir gegenüber der einfachen Implementierung mit einer einzigen Liste
überhaupt etwas gewonnen? Zwar ist die pessimale Laufzeit von dequeue linear,
die amortisierte Laufzeit der beiden Operationen ist aber konstant.
Bei amortisierter Laufzeit betrachtet man nicht die Laufzeit einer einzigen Operation sondern die Laufzeit mehrerer Operationen hintereinander: Wenn beliebige n Queue-Operationen hintereinander ausgeführt werden und die Gesamtlaufzeit dabei immer in O(n) liegt, dann ist die amortisierte Laufzeit der Operationen konstant. Dabei können einzelne Aufrufe der Operationen durchaus
schlechtere Laufzeit haben, solange dabei nie die 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 der Anzahl der eingefügten Elemente also auch quadratisch in der Anzahl
der verwendeten Operationen. Die amortisierte Laufzeit der beiden QueueOperationen ist also linear, denn die n-fache Anwendung einer Operation mit
4
linearer Laufzeit führt zu quadratischer Gesamtlaufzeit. In diesem Fall ist die
amortisierte Laufzeit der Operationen also nicht besser 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] [])))))
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 alle Schritte konstante Laufzeit haben. Nur ein Schritt hat lineare Laufzeit,
die Gesamtlaufzeit bleibt aber linear in der Anzahl der Operationen. Daher ist
die amortisierte Laufzeit der Operationen (anders als die pessimale Laufzeit)
konstant.
Diese Aufrufkette verdeutlicht, dass der teure reverse-Aufruf nur selten auftritt.
Im Allgemeinen muss jedes eingefügte Element genau einmal “durch reverse
hindurch” bevor es wieder entfernt wird. Die reverse-Aufrufe sind so selten,
dass die Gesamtlaufzeit einer beliebigen Folge von Queue-Operationen immer
lineare Gesamtlaufzeit hat.
Obwohl die pessimale Laufzeit von dequeue linear ist, ist die gezeigte QueueImplementierung auf Grund der konstanten amortisierten Laufzeit der Operationen sehr brauchbar.
Arrays
In vielen imperativen Programmiersprachen werden Arrays bereit gestellt. Imperative Arrays erlauben die Abfrage und Manipulation von Elementen an einer
gegebenen Position mit konstanter Laufzeit. In Haskell gibt es eine (im Modul
Data.Array) vordefinierte Anbindung an Arrays, die es erlaubt, ein Element an
einer gegebenen Position in konstanter Zeit abzufragen und Arrays in linearer
Zeit aus Listen zu erzeugen. Allerdings hat die Operation zum Ändern eines
Index lineare Laufzeit. Sie kopiert das gesamte Array, da Seiteneffekte in reinen
funktionalen Sprachen wie Haskell nicht erlaubt sind. Insbesondere soll 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:
5
data Array a = Entry a (Array a) (Array a)
Wir stellen zunächst fest, dass alle Werte dieses Typs unendlich sind, da es
keinen Fall für das leere Array gibt, doch dazu später mehr.
Auch Indizes scheinen im Array-Datentyp nicht dargestellt zu werden. Die
Idee dieser Implementierung ist, dass der zu einem bestimmten Index gehörige
Wert an einer bestimmten Position im von Entry-Knoten erzeugten Binärbaum
steht. Zum Beispiel steht das Element mit dem Index Null an der Wurzel, links
davon ist ein Array mit allen ungeraden Indizes und rechts davon eines mit allen
geraden Indizes. Die Teil-Arrays haben ihrerseits die selbe Struktur: Zieht man
von den Indizes eins ab und dividiert das Ergebnis (mit ganzzahliger Division)
durch zwei steht an der Wurzel die Null, links davon gerade Indizes und rechts
ungerade. Insgesamt ergibt sich dadurch die folgende Verteilung der Indizes:
Diese Verteilung erlaubt es, die Abfrage eines Elementes effizient zu implementieren:
(!) :: Array a -> Int -> a
Entry x odds evens ! n
| n == 0 = x
| odd n = odds ! m
| even n = evens ! m
where
m = (n-1) ‘div‘ 2
Wenn der Index Null ist, steht das gesuchte Element an der Wurzel. Wenn nicht
suchen wir rekursiv im linken oder rechten Teil-Array, je nachdem, ob der Index
6
ungerade (dann links) oder gerade ist (dann rechts). Der neue Index wird dabei
dekrementiert und halbiert.
Um zum Beispiel das Element an Position 9 nachzuschlagen, steigen wir rekursiv
in den linken Teilbaum ab, da die 9 ungerade ist und suchen dort rekursiv den
Index 4. Dieser ist gerade, deshalb suchen wir rekursiv im rechten Teilbaum den
Index 1. Dieser Index ist wieder ungerade, also suchen wir im linken Teilbaum
den Eintrag mit Index Null geben also die Wurzel dieses Teilbaums aus.
Auch die Funktion zum Ändern eines Eintrags lässt sich auf diese Weise implementieren:
update :: Array a ->
update (Entry x odds
| n == 0 = Entry y
| odd n = Entry x
| even n = Entry x
where
m = (n-1) ‘div‘ 2
Int -> a -> Array a
evens) n y
odds evens
(update odds m y) evens
odds (update evens m y)
Je nachdem, ob der Index gerade oder ungerade ist, steigen wir wieder rekursiv
in das rechte oder linke Teil-Array ab und manipulieren einen entsprechend
angepassten Index.
Die update-Funktion erzeugt dabei ein neues Array, lässt also das Argument
anders als Array-Updates in imperativen Sprachen unverändert. update kopiert
aber nicht das ganze Array sondern nur den Pfad von der Wurzel zum gesuchten
Element. Gemeinsame Teile 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 in imperativen Sprachen der Array-Zugriff nicht konstant sondern
logarithmisch in der Indexgröße, da alle (logarithmisch vielen) Bits des Index
angesehen werden müssen, um den richtigen Eintrag zu finden.
Der Vorteil funktionaler Arrays ist, dass sowohl die neue als auch die alte Variante eines Arrays nach einem Update verfügbar sind. Der zusätzliche Speicherbedarf ist dabei logarithmisch in der Indexgröße. Mit imperativen Arrays verwendet man in diesem Fall meist 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 stellt sich also die Frage, wie wir endliche Arrays darstellen. Das leere Array
ist ein unendliches Array, das nur Fehlermeldungen enthält:
emptyArray :: Array a
emptyArray = Entry err emptyArray emptyArray
7
where
err = error "accessed non-existent entry"
Wir können aus einer Liste ein Array machen, indem wir sukzessive update auf
ein leeres Array anwenden:
fromList :: [a] -> Array a
fromList = foldl insert emptyArray . zip [0..]
where
insert a (n,x) = update a n x
Die Laufzeit von fromList ist O(nlogn). Allerdings werden in dieser Variante
viele Entry-Konstruktoren erzeugt und durch spätere update Aufrufe wieder
ersetzt. Die folgende Implementierung vermeidet dies, indem sie die Eingabeliste
in zwei Teile, nämlich die Elemente mit ungeradem und die mit geradem Index,
aufteilt.
fromList :: [a] -> Array a
fromList [] = emptyArray
fromList (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
abwechselnd der 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(nlogn) erzeugt aber
keine unnötigen Entry-Knoten, die sie später wieder verwirft und ist deshalb
schneller. Es ist sogar möglich, fromList mit linearer Laufzeit zu implementieren (Okasaki ’97).
Die hier gezeigte Implementierung von Arrays ist (mit einer weiteren wichtigen
Optimierung, auf die wir hier nicht eingehen) im Modul Data.IntMap implementiert.
8
Array-Listen
Arrays erlauben anders als Listen einen effizienten Zugriff auf Elemente an einem
beliebigen Index. Listen bieten anders als Arrays effiziente Funktionen zum
Entfernen des ersten Elements und Hinzufügen eines neuen ersten Elements. Die
Funktionen (:) und tail hätten mit der beschriebenen Array-Implementierung
lineare Laufzeit, da sich durch sie die Indizes aller Einträge verschieben.
Array-Listen bieten wie Arrays einen effizienten Zugriff auf beliebige Elemente
und wie Listen effiziente Funktionen zum Hinzufügen und Entfernen des ersten
Elements. Ihre interne Darstellung ähnelt der von Binärzahlen. Eine ArrayListe ist eine Liste vollständiger, nur an Blättern beschrifteter Binärbäume,
deren Höhe ihrer Position in der Liste entspricht.
Hier sind beispielhaft Listen der Länge eins bis fünf dargestellt:
o
|
5
.-----o
/ \
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ärbaum, an denen die Binärdarstellung von n eine 1 hat (wenn man mit
dem niedrigstwertigen 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 letzte Element immer vorhanden sein muss. Eine Liste
wie
o-----.-----.
9
|
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 zu implementieren.
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.
empty :: ArrayList a
empty = []
Dank der ersten Invariante genügt es für den Leerheitstest, zu testen, ob die
Liste von Bits leer ist. Eine nicht-leere Liste nur aus Zeros ist nicht erlaubt.
isEmpty :: ArrayList a -> Bool
isEmpty = null
Wir wollen nun eine Funktion (<:) für Array-Listen definieren, die sich wie (:)
für Listen 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ärzahl nachempfunden. Wenn das niedrigste Bit Null ist, wird es auf
eins gesetzt, wenn es eins ist, wird es auf Null gesetzt und die restlichen Bits
werden inkrementiert.
(<:) :: a -> ArrayList a -> ArrayList a
x <: l = cons (Leaf x) l
Wir verwenden eine Hilfsfunktion cons auf Binärbäumen, da wir im rekursiven
Aufruf mehrere Elemente auf einmal zum Inkrementieren benutzen:
10
cons
cons
cons
cons
:: BinTree a -> ArrayList a -> ArrayList a
u []
= [One u]
u (Zero : ts) = One u : ts
u (One v : ts) = Zero : cons (Fork u v) ts
Statt einfach nur die Bits zu manipulieren, fügen wir einer Eins einen Binärbaum
entsprechender Größe hinzu. Die Invarianten erhalten wir dadurch aufrecht,
dass wir im 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 die Länge der Liste von Binärbäumen beschränkt. Diese ist
wegen der ersten Invariante logarithmisch 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, Funktionen first und rest. Wir definieren diese Funktionen mit Hilfe einer einzigen
Funktion, die beide Ergebnisse berechnet.
first :: ArrayList a -> a
first l = x
where (Leaf x, _) = decons l
rest :: ArrayList a -> ArrayList a
rest l = xs
where (_, xs) = decons l
Die Funktion decons arbeitet wie cons auf Binärbäumen statt Bits. Sie ist
dem Dekrementieren einer Binärzahl nachempfunden und liefert den Teilbaum
zurück, der zum niedrigsten Bit gehört, das nicht Null ist. Bäume aus höherwertigen
Bits werden dabei aufgeteilt. Der linke Teil wird als erste Komponente des
Ergebnisses zurück geliefert, der andere 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
11
Die erste Regel sorgt dafür, dass die erste Invariante, dass der letzte Eintrag der
Liste von Bits nicht Null ist, aufrecht erhalten wird. Auch die anderen Invarianten bleiben erhalten. Die Implementierung verlässt sich auf die Invarianten,
da nur durch sie sicher gestellt 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 Bits beschränkt also logarithmisch in der Länge der Array-Liste. Hier ist
ein Beispielaufruf auf 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
/ \
/\
/\
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 4
ws’ = []
in (u’, One v’ : ws’)
in (u, One v’ : ws’)
let Fork u v = o
/ \
1
2
ws = [One o ]
/ \
3
4
in (u, One v : ws)
12
(1, o-----o )
|
/ \
2
3
4
Wir definieren nun Funktionen zum Zugriff auf Elemente anhand ihres Index.
Wie bei Arrays erlaubt (!) ein Element abzufragen.
(!) :: ArrayList a -> Int -> a
l ! n = select 1 l n
Wir verwenden eine Hilfsfunktion select, die als zusätzlichen Parameter die
Größe des nä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 Index kleiner als die Größe des nächsten Binärbaums ist,
suchen wir in diesem, sonst rekursiv 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 Index nicht mehr auf diese Weise berechnen.
Die Funktion selectBinTree verwenden wir, um ein Element in einem vollständigen
Binärbaum zu suchen. Auch sie hat einen Größenparameter, der hier die Größe
des linken Teilbaums des Arguments beschreibt, oder Null ist, wenn das Argument in Blatt ist.
13
selectBinTree :: Int -> BinTree a -> Int -> a
selectBinTree 0
(Leaf x)
0 = x
selectBinTree 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, ob wir in den linken Teilbaum absteigen oder ihn überspringen.
Die Laufzeit von select ist beschränkt durch die Anzahl der Bits plus die Größe
des größten Binärbaums. Beides ist logarithmisch in der Länge der Array-Liste,
also auch die Laufzeit von (!).
Auch das Pattern-Matching in selectBinTree ist nur dann sicher, wenn die
Invarianten gelten, also der Binärbaum vollständig ist.
Schließlich definieren wir noch eine Funktion modify zur Manipulation eines
Elements an einem 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 a
modify = update 1
Auch modify verwendet eine Hilfsfunktion mit zusätzlichem Größenparameter.
Die Implementierung dieser Funktion ähnelt der von select, baut aber die
komplette Liste wieder 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
Wenn nicht, entscheiden wir uns wieder fürs Absteigen oder Überspringen und
verwenden im 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
Baum zurück, statt nur das gesuchte Element.
14
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
wesentliche Teile des Binärbaums und auch der Liste von Binärbäumen geteilt,
also nicht kopiert, werden.
Obwohl wir uns bemüht haben, die Invarianten bei der Definition der Operatoren aufrecht zu erhalten, ist die Implementierung komplex genug, dass
Fehler nicht ausgeschlossen sind. Um uns zu vergewissern, dass die Invarianten
tatsächlich erhalten bleiben, können wir die definierten Funktionen testen. Dazu
verwenden wir QuickCheck, damit wir nur die Eigenschaften und nicht die TestEingaben selbst definieren müssen.
Das Prädikat isValid prüft, ob eine gegebene Array-Liste die geforderten Invarianten erfüllt.
isValid :: ArrayList a -> Bool
isValid 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 Null ist oder der enthaltene Baum vollständig und zeroOrHeight testet,
ob ein Bit Null ist oder 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 geeignete Eigenschaften zum Testen der Operationen stehen im Modul
PartialArrayList.
Nach der Definition eines geeigneten QuickCheck-Generators für Array-Listen,
können wir automatisch testen, ob unsere Implementierung korrekt ist. Alle
gezeigten Funktionen sind 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
korrekt aus, ist es aber nicht:
cons u (One v : ts) = cons (Fork u v) ts
Ebenso ist das folgende keine korrekte Regel für decons:
15
decons (One u : ts)
=
(u, ts)
Obwohl QuickCheck gute Dienste leistet, solche Fehler zu finden, wäre es schön,
wenn wir sie gar nicht erst machen könnten. Das Problem ist, dass unser Datentyp für Array-Listen Werte erlaubt, die keine gültigen Array Listen sind. Besser
wäre, wenn wir den Typ so definieren könnten, dass gar keine ungültigen ArrayListen 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 Invariante sicher zu stellen, dass am Ende der Liste immer ein Baum steht. Dazu
verwenden wir statt normaler Listen einen eigenen Listendatentyp, der sicher
stellt, dass am Ende immer ein Element 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äume einer festen, in jedem Schritt um eins wachsenden Höhe sind. Der
folgende Datentyp für 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 bit einem Bit
gefolgt von 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 man nicht-regulär oder nested data types. Der Effekt dieser Definition ist, dass die Restliste einer TreeList Int nicht Ints sondern Paare von
Ints enthält! Die Restliste der Restliste enthält Paare von Paaren von Ints und
so weiter. Dadurch wird die Baumstruktur der enthaltenen Binärbäume durch
die Paar-Konstruktoren erzeugt, wie die folgenden Beispiele zeigen:
Single 1
16
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 folgt definiert 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 a
empty = Empty
isEmpty :: ArrayList a -> Bool
isEmpty Empty = True
isEmpty _
= False
Um ein Element vorne an eine Array-Liste anzuhängen, definieren wir wieder
eine Funktion (<:). Wir behandeln zunächst leere Array-Listen gesondert:
(<:) :: a -> ArrayList a -> ArrayList a
x <: Empty
= NonEmpty (Single x)
x <: NonEmpty l = NonEmpty (cons x l)
Die Funktion cons definieren wir wieder in Anlehnung an das Inkrementieren
einer Binärzahl:
cons
cons
cons
cons
:: a -> TreeList a -> TreeList a
x (Single y)
= Zero :< Single (x,y)
x (Zero :< xs) = One x :< xs
x (One y :< xs) = Zero :< cons (x,y) xs
17
Wenn wir bei dieser Definition in der ersten oder letzten Regel die Null vergessen,
führt das 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 erstes Argument ist vom Typ (a,a) und die Liste xs ist vom Typ TreeList (a,a).
Wenn der Typ einer Funktion im rekursiven Aufruf ein anderer ist, als der
beim umgebenden Aufruf, nennt man das polymorphe Rekursion. Diese wird
typischerweise bei nicht-regulären Datentypen verwendet, die ja eine rekursive
Komponente mit veränderten Typparametern haben.
Der Typ einer polymorph rekursiven Funktion1 kann nicht inferiert werden,
wir dürfen die Typsignatur von cons also nicht weglassen. Tun wir es doch,
bekommen wir den eben gezeigten Typfehler.
Zur Definition von first und rest behandeln wir einelementige Array-Listen
gesondert und verwenden dann wieder eine Hilfsfunktion decons, die die Ergebnisse von first und rest auf einmal berechnet.
first :: ArrayList a -> a
first (NonEmpty (Single x)) = x
first (NonEmpty l) = fst $ decons l
rest :: ArrayList a -> ArrayList a
rest (NonEmpty (Single _)) = Empty
rest (NonEmpty l) = NonEmpty . snd $ decons l
decons wird nie mit einelementigen Listen aufgerufen, entspricht also dem
Dekrementieren einer Binärzahl größer als zwei.
decons
decons
decons
decons
where
:: TreeList a -> (a, TreeList a)
(One x :< xs) = (x, Zero :< xs)
(Zero :< Single (x,y)) = (x, Single y)
(Zero :< xs) = (x, One y :< ys)
((x,y),ys) = decons xs
Da vor jeden Aufruf von decons die einelementige Liste gesondert behandelt
wird, ist diese 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 die Typsignatur wegließen.
1 im
Gegensatz zum Typ einer (nur) polymorphen, rekursiven Funktion
18
Die Definition der Funktionen zum Zugriff auf einen beliebigen Index wird durch
die neue Darstellung dadurch erschwert, dass wir die Funktionen zum Absteigen
in die vollständigen Binärbäume nicht mehr so leicht definieren können. Unterschiedlich große Binärbäume haben unterschiedliche Typen, wir können also
keine einzige Funktion schreiben, die Bäume beliebiger Größe akzeptiert.
Eine mögliche Lösung des Problems ist es, die Funktion zum Nachschlagen eines
Blattes in 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 -> a
Empty
! _ = error "ArrayList.!: empty list"
NonEmpty l ! n = select 1 sel l n
where
sel 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ärbaums mitführt. Zusätzlich führt sie nun aber auch noch eine Funktion
sel mit, die ein Blatt in diesem Binärbaum nachschlagen kann. Im ersten
Aufruf hat die sel-Funktion den Typ a -> Int -> a, dieser ändert sich aber
in rekursiven Aufrufen wie der Typ der TreeList. Dadurch, dass wir sel lokal
definieren, können wir den ursprünglichen Index n im Fehlerfall ausgeben. Der
Index m muss Null sein, da ein einelementiger Binärbaum nur am Index Null ein
Element enthält.
Der Typ der select-Funktion zeigt, dass die übergebene Funktion als Argument
genau den Typ nimmt, den die TreeList (als erstes) enthält.
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ärbaums kennt und den Index nachschlagen kann.
19
Die zweite Regel verwendet wie vorher den Größenparameter, um zu entscheiden, ob der nächste Baum übersprungen werden soll. Zusätzlich übergeben wir
im rekursiven Aufruf eine angepasste Funktion, die einen Wert in einem doppelt
so großen Binärbaum nachschlägt.
select size_x sel (bit :< xs) n =
case bit of
Zero -> select (2*size_x) descend xs n
One x ->
if n < size_x then sel x n else
select (2*size_x) descend xs (n-size_x)
where
descend (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, entspricht die 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 des Binärbaums absteigt und verwendet statt
eines rekursiven Aufrufs die vorher übergebene Funktion sel, die für halb so
große Bäume definiert wurde.
Die modify-Funktion definieren wir analog dazu auch durch eine Hilfsfunktion
update mit zwei Zusatzparametern: einem für die Größe des nächsten Baums
und einem zum Verändern eines solchen Baums.
modify :: Int->(a->a)->ArrayList a->ArrayList a
modify _ _ Empty
=
error "ArrayList.modify: empty list"
modify n f (NonEmpty l) =
NonEmpty $ update 1 upd n l
where
upd m x
| m == 0
= f x
| otherwise =
error $ "ArrayList.modify: invalid index "
++ show n
Die upd-Funktion für einen einelementigen Baum, wendet die gegebene Funktion
auf diesen Baum, der ja nur durch seine Beschriftung selbst dargestellt wird,
an. Bei ungültigen Indizes liefert sie eine Fehlermeldung mit dem ursprünglichen
Index.
Die update-Funktion nimmt als Argument eine solche upd-Funktion, die die
Elemente der übergeben TreeList manipuliert.
20
update :: Int
-> (Int -> a -> a)
-> Int -> TreeList a -> TreeList a
Die Regel für einelementige Listen, wendet diese upd-Funktion auf das Element
der Liste an:
update _ upd n (Single x) = Single $ upd n x
Die zweite Regel definiert wie select eine abgewandelte Funktion descend für
den rekursiven 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 else
bit :<
update (2*size_x) descend (n-size_x) xs
where
descend 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 nicht mehr QuickCheck zu verwenden, um zu testen, ob die Invarianten
eingehalten werden, da dies schon durch die Typprüfung sichergestellt ist. Wir
sollten natürlich trotzdem Tests schreiben, die die Korrektheit der Operationen
prüfen. Nur weil die Invariante aufrecht erhalten wird, heißt das noch nicht,
dass die Funktionen die Reihenfolge der Elemente nicht aus Versehen verändern
oder falsche Elemente manipuliert werden. Tests, die die Korrektheit der Implementierung überprüfen, stehen im Modul ArrayList, das auch die hier 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 Position von der Wurzel des Baumes genau die
21
Länge der Binärdarstellung des Index. In diesem Kapitel werden wir Datenstrukturen, sogenannte Tries2 , kennen lernen, die diese Idee für andere Schlüssel
als Zahlen verwenden.
Die Idee hinter Tries steht im Kontrast zur expliziten Darstellung der Schlüssel
in einem Suchbaum oder einer sortierten Liste. Eine Zuordnung von beliebigen
vergleichbaren Schlüsseln zu beliebigen Werten kann man als Liste von Paaren
darstellen, wie hier am Beispiel von Char-Schlüsseln:
type CharMap a = [(Char,a)]
Die leere Zurodnung ist die leere Liste.
emptyCharMap :: CharMap a
emptyCharMap = []
Wir schlagen einen Wert in einer CharMap nach, indem wir den zugehörigen
Wert zum gegebenen Schlüssel suchen und liefern Nothing zurück, falls kein
Wert zu diesem Schlüssel gespeichert ist:
lookupChar :: Char -> CharMap a -> Maybe a
lookupChar _ [] = Nothing
lookupChar 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öschen den alten Eintrag aus ihr.
insertChar :: Char->a->CharMap a->CharMap a
insertChar c x xs = (c,x) : deleteChar c xs
Löschen können wir einen Eintrag, indem wir nur solche Einträge behalten, die
ein anderes Zeichen als Schlüssel enthalten.
deleteChar :: Char -> CharMap a -> CharMap a
deleteChar c = filter ((c/=) . fst)
Effizienter wäre eine Implementierung mittels eines Suchbaums, die man immer
dann verwenden kann, wenn es eine Ordnung auf dem Typ der Schlüssel gibt.
2 Trie kommt von retrieve wird aber dennoch von einigen, zur Unterscheidung von tree,
wie try ausgesprochen.
22
Wir lernen nun eine weitere Möglichkeit kennen, Werte Schlüsseln zuzuordnen,
die sich an der Struktur der Schlüssel orientiert. Als erstes Beispiel verwenden
wir Strings als Schlüssel. Statt die Ordnung auf Strings auszunutzen und
einen Suchbaum zu verwenden, nutzen wir deren Struktur, um Werte an bestimmte Positionen in einem Baum zu schreiben. Zum Beispiel speichert der
folgende Baum die Zuordnung
"to"
"tom"
"tea"
"ten"
->
->
->
->
17
42
11
10
23
In disem Baum enthalten manche Knoten Werte und andere nicht. Der zu
einem Wert gehö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 optionalen Wert und einer Zuordnung von Zeichen zu
kleineren StringMaps, die die Zuordnung vom Restwort zu einem Wert speich-
24
ern:
data StringMap a =
StringMap (Maybe a) (CharMap (StringMap a))
Hierbei verwenden wir die oben definierte CharMap, um die Kanten in dem Baum
zu speichern. Die obige Beispielzuordnung wird mit diesem Datentyp wie folgt
dargestellt:
StringMap Nothing
[(’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, der dem leeren String zugeordnet ist) und eine leere Zuordnung von
Zeichen zu StringMaps.
emptyStringMap :: StringMap a
emptyStringMap = StringMap Nothing emptyCharMap
Zum Nachschlagen eines zu einem String gespeicherten Wertes untersuchen wir
die Struktur des Schlüssels.
lookupString :: String -> StringMap a -> Maybe a
Wenn der Schlüssel der leere String ist, geben wir den an der Wurzel gespeicherten Eintrag zurück:
lookupString [] (StringMap a _) = a
Wenn der Schlüssel aus einem ersten Zeichen c und restlichen Zeichen cs besteht,
suchen wir aus der CharMap die zu c gehörige StringMap heraus und suchen in
dieser rekursiv den Schlüssel cs. Durch die Verwendung der Maybe-Monade ist
das Gesamtergebnis Nothing, wenn die CharMap keinen Eintrag für c enthält.
lookupString (c:cs) (StringMap _ b) =
lookupChar c b >>= lookupString cs
25
Um einen Wert unter einem String einzufügen, speichern wir ihn an der Wurzel,
wenn der String leer ist,
insertString
:: String -> a -> StringMap a -> StringMap a
insertString [] x (StringMap _ b) =
StringMap (Just x) b
oder wir fügen der CharMap unter dem ersten Zeichen c einen Eintrag hinzu,
der die alten Einträge enthält und zusätzlich den neuen unter den restlichen
Zeichen cs.
insertString (c:cs) x
case lookupChar c b
Nothing ->
insertChar c
(insertString
Just m ->
insertChar c
(insertString
(StringMap a b) =
of
cs x emptyStringMap) b
cs x m) b
Diese Definition verwendet die Funktion lookupChar, und fügt je nach deren
Ergebnis die restlichen Zeichen entweder in die leere StringMap oder oder in die
nachgschlagene ein. Da die rechten Seiten des case-Ausdrucks sich nur im letzten Argument von insertString unterscheiden, können wir Code-Duplikation
vermeiden, indem wir die Fallunterscheidung in 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
vordefiniert.
Die Laufzeit von insertString ist (wenn wir von der Laufzeit der ineffizient implementierten 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 gespeicherten Werte ist, ist die Laufzeit von Trie-Funktionen unabhängig von
der Größe des Tries. Da auch 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 von Trie-Operationen theoretisch besser
ist. Oft ist aber der Vergleich eines Schlüssels nicht so teuer wie der Abstieg
26
entsprechend seiner Struktur in einem Trie. Welche Datenstruktur in der Praxis
besser ist, hängt vom Anwendungsbeispiel ab, insbesondere davon, wie dicht die
Datenstruktur besetzt ist.
Beim Löschen eines Eintrags gehen wir ähnlich vor wie zum Einfügen, um den
gesuchten Schlü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
den Reststring.
deleteString (c:cs) (StringMap a b) =
case lookupChar c b of
Nothing -> StringMap a b
Just m ->
StringMap a
(insertChar c (deleteString cs d) b)
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 d) b)
(lookupChar c b))
Sowohl die insertString als auch die deleteString Funktion verwenden abgesehen vom rekursiven Aufruf die lookupChar Funktion zusammen mit insertChar,
um die StringMap mit dem Reststring zu verändern. Eleganter wäre, wenn man
dazu nicht zwei Funktionen verwenden müsste, die die CharMap beide durchlaufen, sondern eine einzige Funktion updateChar zum Verändern einer CharMap
verwenden könnte.
Da wir mit updateChar sowohl Elemente einfügen als auch entfernen wollen,
geben wir ihr den folgenden Typ.
updateChar :: Char
-> (Maybe a -> Maybe a)
-> CharMap a -> CharMap a
27
Das erste Argument ist das Zeichen, dessen Eintrag geändert werden soll und das
zweite eine Funktion, die die Änderung vornimmt. Sowohl der Argument- als
auch der Ergebnistyp dieser Funktion ist Maybe a. Um einen Wert einzufügen,
übergeben wir dieser Funktion Nothing, um eines zu Löschen, liefert diese Funktion Nothing.
Zum Ändern einer leeren CharMap rufen wir also die übergebene Funktion mit
Nothing auf und tragen das Ergebnis dieses Aufrufs in die CharMap ein, wenn
es nicht Nothing ist.
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 c gespeichert ist, und Ändern den Eintrag unter c gemäß des Ergebnisses
dieses Aufrufs. Es ist 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
definieren können. Angenommen, updateString wäre schon definiert, dann
könnten wir insertString und deleteString wie folgt definieren.
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
übergebene upd-Funktion auf diesen an.
updateString [] upd (StringMap a b) =
StringMap (upd a) b
28
Bei einem nicht-leeren String wenden wir updateChar und geschachtelt updateString
an. Dabei übergeben wir den updateString Aufruf als upd-Funktion an updateChar
und kombinieren diesen dazu mit Funktionen, die dafür sorgen, dass er einen
Maybe-Wert als 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 die CharMaps seltener. Das allgemeinere update-Verfahren hat, so wie
wir es implementiert haben, aber auch einen Nachteil. Beim Löschen eines nicht
vorhandenen Werts, wird ein Eintrag für den gelöschten Schlüssel erzeugt (und
mit Nothing belegt), auch wenn dieser vorher gar nicht vorhanden war:
ghci> deleteString "a" emptyStringMap
StringMap Nothing [(’a’,StringMap Nothing [])]
Die alte Implementierung hat dieses Problem zwar nicht, entfernt allerdings
auch keine vorhandenen Einträge, wenn sie leer sind. Mit der alten (wie mit der
neuen) Implementierung von deleteString ergibt sich:
ghci> let a=insertString "a" 42 emptyStringMap
ghci> deleteString "a" a
StringMap Nothing [(’a’,StringMap Nothing [])]
Um leere Zweige im Baum zu vermeiden, kann man die Implementierung der
update-Funktionen anpassen (siehe Übung).
Verallgemeinerte Tries
Die Idee, die Struktur der Schlüssel auszunutzen und ihnen feste Positionen
in einer Datenstruktur zuzuordnen, lässt sich auf andere Datentypen verallgemeinern. Wir lernen nun zwei Beispiele kennen, die das verdeutlichen. Zunächst
betrachten wir einen Datentyp für Binärzahlen, um den Zusammenhang zwischen Tries und den oben definierten Arrays zu klären. Später betrachten wir
als Beispiel eines komplizierteren rekursiven Datentyps Bäume als Schlüssel.
Positive Binärzahlen können als Werte des folgenden Datentyps dargestellt werden.
29
data Nat = One | O Nat | I Nat
One ist die Darstellung der Zahl 1 oder allgemeiner des höchst-wertigen Bits einer
beliebigen positiven Zahl. Führende Nullen (also auch die Zahl 0) können mit
diesem Datentyp nicht dargestellt werden. Der äußerste Konstruktor ist immer
das niedrigste Bit. 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. Der NatMap-Konstruktor hat für jeden Konstruktor des Nat-Typs ein
Argument. Die Typen der Argumente des NatMap-Konstruktors ergeben sich
aus den Typen der Argumente der entsprechenden Nat-Konstrutoren. Hier hat
der One-Konstruktor kein Argument, der NatMap-Konstruktor hat also an der
entsprechenden Stelle einen Wert vom Typ Maybe a. Die beiden anderen Konstruktoren haben jeweils ein Argument vom Typ Nat, der NatMap-Konstruktor
hat also an den entsprechenden Stellen Argumente vom Typ NatMap a.
An der Wurzel einer NatMap steht der Eintrag, der zu One gehört, Darunter
stehen die NatMaps, die zu allen geraden bzw. ungeraden Schlüsseln gehören.
Das folgende Bild zeigt die Schlüssel der ersten vier Ebenen einer NatMap.
30
Wenn wir die Einträge in ihre Dezimaldarstellung konvertieren, ergibt sich fast
das Bild der Indizes in unserer Array-Implementierung, nur dass die Schlüssel
alle um eins größer sind 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 eine NatMap aber den Wert Nothing (statt eines Laufzeitfehlers) an
Positionen, die keinem Wert zugeordnet sind. Die leere NatMap definieren wir
also wie folgt.
emptyNatMap :: NatMap a
emptyNatMap =
NatMap Nothing emptyNatMap emptyNatMap
Die Funktion zum Nachschlagen eines Schlüssels in einer NatMap folgt, wie die
Definition des NatMap-Datentyps selbst, der Struktur der Werte vom Typ Nat.
lookupNat
lookupNat
lookupNat
lookupNat
:: Nat -> NatMap a -> Maybe a
One
(NatMap a _ _) = a
(O n) (NatMap _ b _) = lookupNat n b
(I n) (NatMap _ _ c) = lookupNat n c
Wenn der Schlüssel One ist, wird das erste Argument geliefert, wenn er mit O
beginnt, wird lookupNat 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 verallgemeinerten update-Funktion.
insertNat :: Nat -> a -> NatMap a -> NatMap a
insertNat n = updateNat n . const . Just
deleteNat :: Nat -> NatMap a -> NatMap a
deleteNat n = updateNat n (const Nothing)
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) =
31
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-Werte nicht zu vermeiden sind. Die Darstellung von NatMaps ist
anders als die von StringMaps nicht redundant.
Die NatMaps entsprechen also, abgesehen von der Index-Verschiebung und den
exliziten Nothing-Einträgen, genau unseren Arrays. Auch die Laufzeiten der
Funktionen sind identisch. Der Array-Zugriff hat logarithmische Laufzeit in
der Größe des Index, der NatMap-Zugriff hat lineare Laufzeit in der Größe der
gegebenen Binärzahl. Da die Größe einer Binärzahl logarithmisch in der Größe
der dargestellten Zahl ist, entsprechen sich diese 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 eine NatMap in eine Liste ihrer Schlüssel/Wert-Paare
umwandelt, wäre nützlich. Leider können wir 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
Vereinigung zweier NatMaps berechnet. Dabei soll die Implementierung von
mappend die Einträge der linken NatMap bevorzugen, wenn beide Argumente
einen Eintrag zum selben Schlüssel enthalten.
instance Monoid (NatMap a) where
mempty = emptyNatMap
NatMap a1 b1
NatMap (a1
(b1
(c1
c1 ‘mappend‘ NatMap a2 b2 c2 =
‘mplus‘ a2)
‘mappend‘ b2)
‘mappend‘ c2)
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 wir etwas 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 zwei Nachfolger und sind unbeschriftet.
32
Die zu diesem Typ gehörende Trie-Struktur ist eine Baum-Struktur, in der jede
Position zu 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 erste Argument ist eine StringMap, da das (einzige) Argument des ersten Tree-Konstruktors Leaf vom Typ String ist. Der zweite Tree-Konstruktor
Fork hat zwei Argumente, die beide vom Typ Tree sind. Das zweite Argument
des TreeMap-Konstruktors hat daher den Typ TreeMap (TreeMap a). Mehrere
Argumente eines Konstruktors werden also in der Trie-Struktur zu geschachtelten Tries entsprechender Typen. Dieses Muster haben wir auch bei der Definition der StringMap benutzt, wo das zu (:) gehörige Argument den Typ
CharMap (StringMap a) hat.
Anders als bei der StringMap ist durch Anwendung dieses Musters auf den TreeDatentyp der Typ TreeMap ein Nested Datatype. Statt auf die Typvariable a
wird zumindest ein Vorkommen des TreeMap-Typkonstruktors auf einen anderen
Typ, nämlich TreeMap a angewendet. Nested Datatypes sind uns schon bei
der Definition von Array-Listen begegnet, wo wir sie ausgenutzt haben, um
Invarianten der Darstellung im Typsystem zu kodieren. Wie dort brauchen wir
auch hier polymorphe Rekursion, um rekursive Funktionen auf TreeMaps zu
definieren.
Zunächst definieren wir die leere TreeMap.
emptyTreeMap :: TreeMap a
emptyTreeMap =
TreeMap emptyStringMap emptyTreeMap
Schon hier hat der rekursive Aufruf von emptyTreeMap einen anderen Typ als
der umgebende Aufruf. Der Typ von emptyTreeMap kann also nicht inferiert
werden und wir dürfen die Typsignatur nicht weglassen.
Die lookup-Funktion folgt wieder der Struktur des Schlüssels, der jetzt vom
Typ Tree ist.
lookupTree :: Tree -> TreeMap a -> Maybe a
lookupTree (Leaf s)
(TreeMap a _) =
lookupString s a
lookupTree (Fork l r) (TreeMap _ b) =
lookupTree l b >>= lookupTree r
33
In der ersten Regel verwenden wir einfach lookupString um die Beschriftung des gegebenen Blattes in der zugehörigen StringMap nachzuschlagen. In
der zweiten Regel schachteln wir zwei Aufrufe von lookupTree in der MaybeMonade, wenn einer fehlschlägt, schlägt also der gesamte Aufruf fehl. Der erste rekursive Aufruf wendet lookupTree mit einem anderen Typ an als der
umgebende Aufruf, der den gleichen Typ hat wie der zweite rekursive Aufruf.
Das Egebnis des ersten rekursiven Aufrufs ist eine TreeMap auf die wir wieder
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 gegebenen Blattes auf, in der zweiten schachteln wir zwei rekursive Aufrufe
von updateTree mit unterschiedlichen Typen und passen den inneren so an,
dass er Maybe-Werte nimmt und liefert.
Die insert und delete-Funktionen definieren wir wie üblich.
insertTree::Tree -> a -> TreeMap a -> TreeMap a
insertTree t = updateTree t . const . Just
deleteTree :: Tree -> TreeMap a -> TreeMap a
deleteTree t = updateTree t (const Nothing)
Zum Beispiel liefert der Aufruf
insertTree
(Fork (Leaf "a") (Leaf "bc"))
42
emptyTreeMap
das Ergebnis
34
TreeMap
emptyStringMap
(TreeMap
(StringMap
Nothing
[(’a’,
StringMap
(Just (TreeMap
(StringMap
Nothing
[(’b’,
StringMap
Nothing
[(’c’,
StringMap (Just 42) [])])])
emptyTreeMap)))])
emptyTreeMap)
Verkürzt und etwas übersichtlicher lässt sich dieses Ergebnis wie folgt darstellen:
35
36
Die Konstruktoren des als Schlüssel verwendeten Baums werden also von links
nach rechts der Reihe nach verwendet, um die zugehörige Position im Trie zu
finden. Der Abstand eines Eintrags von der Wurzel des Tries entspricht der
Größe des als Schlüssel verwendeten Baums. Die Laufzeiten von lookupTree
und updateTree sind entsprechend linear in der Größe des als Schlüssel verwendeten Baums.
37
Herunterladen