Purely Functional Data Structures 2

Werbung
Purely Functional Data Structures 2
Fortgeschrittene Konzepte der funktionalen Programmierung
Michael Schreier
Fakultät für Informatik
Technische Universität München
Email: [email protected]
VI
Kurzfassung— In dieser Arbeit wird die klassische
amortisierte Laufzeitanalyse auf rein funktionalen Datenstrukturen angewandt. Danach wird eine amortisierte Laufzeitanalyse für persistente, funktionale Datenstrukturen vorgestellt. Zuletzt wird eine Methode
vorgestellt, wie eine amortisierte Datenstruktur in eine
worst-case Datenstruktur umgewandelt werden kann.
Dies wird zudem beispielhaft an einer Queue gezeigt.
Das Ganze basiert auf Okasakis Purely Functional
”
Data Structures“ [1]
Schlüsselworte— Amortisierte Laufzeitanalyse, funktionale Datenstrukturen, persistente Datenstrukturen,
Worste-Case Laufzeit, Queue
Einleitung
1
II
Besonderheiten in Funktionalen Programmiersprachen
II-A
Persistenz . . . . . . . . . . . . . . .
II-B
Evaluation von Ausdrücken . . . . . .
II-C
Listen in funktionalen Programmiersprachen . . . . . . . . . . . . . . . .
2
III
Warteschlangen - Queues
2
IV
Amortisierte Laufzeitanalyse
IV-A
Ziel . . . . . . . . . . . . . . . . . . .
IV-A.1
Bankkontomethode . . . .
IV-A.2
Physiker Methode . . . . .
IV-B
Klassische amortisierte Analyse einer
Queue . . . . . . . . . . . . . . . . . .
IV-C
Problem bei persistenten Datenstrukturen . . . . . . . . . . . . . . .
3
3
3
3
V
Amorisierte Laufzeitanalyse von persistenten Datenstrukturen
V-A
Zukunft einer Datenstruktur . . . . .
V-B
Lazyiness als wichtige Voraussetzung
V-C
Laufzeitanalyse von Lazy Datenstrukturen . . . . . . . . . . . . . . .
V-D
Akkumulierte Schuld . . . . . . . . .
V-E
Verzögerung von Ausführungen . . .
V-F
Bankkonto Methoden für persistente
Datenstrukturen . . . . . . . . . . . .
V-G
Bankkonto Methoden für persistente
Warteschlangen . . . . . . . . . . . .
8
8
9
VII Zusammenfassung und Ausblick
10
Literatur
10
I. Einleitung
Für uns heutige Programmierer ist der Umgang mit
Daten in der digitalen Welt von zentraler Bedeutung.
Diese Daten müssen natürlich auch gespeichert werden,
dafür verwenden wir Datenstrukturen. In den weitverbreiteten imperativen und objektorientierten Sprachen sind
diese auch bekannt und werden ausführlich gelehrt. In
rein funktionalen Sprachen kann man auf diese Konzepte
nicht eins zu eins zurückgreifen. Da wir immer bemüht
sind schnell laufende Programme zu schreiben, die meisten
Anwender nehmen ungern Wartezeiten in Kauf, ist es
auch wichtig, dass wir effiziente Datenstrukturen verwenden. Um die Laufzeit abzuschätzen, gibt es verschiedene
Möglichkeiten wie eine worst-case Abschätzung, die aussagt, wie schnell meine Datenstruktur im schlimmsten Fall
ist. Des Weiteren gibt es die amortisierte Abschätzung,
die aussagt, wie schnell ist meine Datenstruktur, wenn
ich beachte, dass schnelle Operationen öfter als langsame
ausgeführt werden. In dieser Arbeit werden von Okasaki
in Purely Functional Data Structures“ [1, Kap 7] erstellte
”
Methoden, Algorithmen und Verfahren verwendet. Dem
Leser soll hierbei möglichst verständlich erklärt werden,
wie eine amortisierte Laufzeitanalyse in einer funktionalen
Sprache generell funktionieren kann, danach wie sie auf
persistenten Datenstrukturen funktioniert und zuletzt wie
man eine amortisierte Datenstruktur in eine mit worstcase Laufzeit der gleichen Komplexitätsklasse umwandeln
kann. Das Ganze soll am Beispiel einer Warteschlange und
in der Programmiersprache Haskell gezeigt werden, soweit
dies möglich ist.
Inhaltsverzeichnis
I
Eliminierung von Amortisierung
VI-A
Allgemeines Verfahren: Scheduling . .
VI-B
Real-Time Queues . . . . . . . . . . .
1
2
2
4
5
5
5
5
II. Besonderheiten in Funktionalen
Programmiersprachen
Als Erstes ist es wichtig sich vor Augen zu führen,
was die Besonderheiten der funktionalen Programmiersprachen gegenüber den bekannteren imperativen Sprachen sind. Dies ist wichtig, um zu verstehen, aus welchem Grund man in funktionalen Programmiersprachen,
6
6
6
6
7
1
die bereits vorhandenen Konzepte überdenken sollte und
auch um zu sehen, welche Besonderheiten, die funktionale
Programmierung bietet. Die Hauptunterschiede liegen vor
allem darin, wie die Daten gespeichert werden und wie
Anweisungen abgearbeitet werden.
zusätzlich noch sharing und memoisation verwendet, dabei
werden ausgewertete Argumente gespeichert, sodass das
gleiche Argument nur einmal ausgewertet werden muss [3]
[1, Kap 1].
A. Persistenz
Aus der Unterscheidung zwischen stricter und nonstrict Evaluation folgt auch, dass Listen in funktionalen
Programmiersprachen unterschiedlich umgesetzt werden.
Eine klassische Liste in imperativen Programmiersprachen beinhaltet einen Datenteil sowie einen Pointer auf
das nachfolgende Listenelement, bzw. dessen Position im
Speicher. In funktionalen Programmiersprachen werden
die Listenelemente mittels einer Funktion verknüpft. Diese
wird meist als cons bezeichnet, in Haskell symbolisch als
(:), die die einzelnen Listenelemente in eine leere Liste
einfügt. Beispielsweise sieht eine Liste mit den Elementen
1,2,3 in Haskell wie folgt aus:
((:) 1 ((:) 2 ((:) 3 [] )))
Eine solche Liste kann nun inkrementell oder monolithisch ausgewertet werden. Dies hängt davon ab, ob sie
lazy evaluiert wird, oder nicht. Eine stricte Liste wird
monolithisch ausgewertet, das heißt, sobald die erste Funktion ausgewertet wird, wird die komplette Liste ausgewertet. Bei einer inkrementellen Auswertung, wird nur
soviel ausgewertet, wie benötigt wird. Die restlichen Teile
werden als Verzögerung (engl. suspension) verkettet. Okasaki bezeichnet inkrementelle Listen in Purely Functional
”
Data Structures“ [1, Kap 4.2] als Stream, hier wird aber
weiterhin von einer Liste die Rede sein.
Den Unterschied zwischen monolithischer Liste und
inkrementeller Liste sieht man deutlich bei der Konkatenation zweier Listen (in Haskell die append (++)
Operation). In einer monolithischen Liste werden beide
Listen ausgewertet und danach zusammengefügt. In einer
inkrementellen Liste wird die Ausführung der Operation
verzögert. Sollte die erste Liste leer sein, wird die zweite
Liste zurückgegeben. Sonst wird nur das erste Element der
ersten Liste zurückgegeben und die restlichen Schritte, die
zur Konkatenation notwendig sind, verzögert [1, Kap 4.2].
C. Listen in funktionalen Programmiersprachen
In den bekannteren imperativen Datenstrukturen werden Veränderungen immer direkt auf der jeweiligen Datenstruktur ausgeführt. Nach einer Operation ist die alte
Datenstruktur zerstört und nur noch die neue, veränderte
Version erhalten. Eine solche Datenstruktur wird ephemeral genannt.
In der funktionalen Programmierung hingegen werden
alle vorherigen Versionen behalten und für die Veränderungen jeweils eine neue Kopie der Datenstruktur erstellt.
Beide Versionen bleiben danach erhalten. Um die Effizienz
zu erhöhen, wird neben dem Kopieren auch ein scharing
(deutsch Teilen) zwischen neuer und alter Liste betrieben.
Zum Beispiel wenn das dritte Element einer fünfelementigen Liste verändert werden muss, werden die ersten beiden
Elemente kopiert und sind in beiden Listen identisch, das
dritte Element existiert auch zweimal, einmal in der alten
und einmal in der neuen Version. Alle nachfolgenden Elemente werden nicht kopiert und beide Listen verwenden
diese. Dies ist in Abbildung 1 dargestellt. Eine solche
Datenstruktur, bei der sowohl die alte als auch neue
Version erhalten bleibt, nennt man persistent [1, Kap. 1]
[1, Kap. 2].
Abbildung 1.
2.1]
Update einer persitenten Liste mit sharing. [1, Kap.
III. Warteschlangen - Queues
B. Evaluation von Ausdrücken
In dieser Ausarbeitung soll die Technik der amortisierten Laufzeitanalyse und der Elimination einer amortisierten Datenstruktur anhand einer konkreten Datenstruktur
gezeigt werden. Dies wird eine Warteschlange, englisch
Queue, sein. Eine Queue ist eine spezielle Liste, bei der
nur hinten Elemente hinzugefügt werden können und nur
vorne Elemente entfernt werden können. In gängigen imperativen bzw. objektorientierten Programmiersprachen
wird für das Einfügen und entfernen eines Elementes
meist je eine Schnittstelle angeboten. Zum Beispiel in Java
add bzw. offer zum Einfügen und remove bzw. poll zum
Entfernen [4].
Bei einem funktionalem Ansatz teilt Okasaki [1, Kap
5.1, 5.2] das Entfernen in zwei Funktionen auf. Zum einen
In imperativen Programmiersprachen werden in der
Regel alle Anweisungen ausgeführt, sobald diese im Code
aufgerufen werden. In funktionalen Programmiersprachen
verhält sich dies mit unter etwas anders. Hier gibt es verschiedene Möglichkeiten. Eine Unterscheidung ist zwischen
strictness und non-strictness. Strictness bedeutet, dass bei
einem Funktionsaufruf zuerst die Argumente ausgewertet
werden, non-strictness, dass zuerst die äußeren Funktionen ausgewertet werden, bevor die Argumente berechnet
werden [2]. Bei einer non-strict Evaluation werden oft die
Ausdrücke nur ausgewertet, wenn sie auch für das Ergebnis
benötigt werden. Diese Verzögerung der Evaluation wird
als Suspension bezeichnet. Bei einer Lazy Evaluation wird
2
head, um auf das vorderste Element zuzugreifen und zum
anderen tail um die Queue ohne das erste Element zu
bekommen. Zudem wird zum Einfügen die Funktion snoc
verwendet. Auch sollte es eine Funktion geben, die eine
leere Queue zurück gibt und eine Funktion, um zu prüfen,
ob Elemente in der Queue vorhanden sind. Algorithmus 1
zeigt die Schnittstellen die Okasaki in Purely Functional
”
Data Structures“ [1, Appendix A] für seine Queue definiert.
Algorithmus 1.
Appendix A]
A. Ziel
Das Ziel der amortisierten Laufzeitanalyse ist es, eine
obere Schranke für die tatsächliche Laufzeit zu finden.
Dazu berechnet man die amortisierten Kosten jeder einzelnen Operation ai der n-elementigen Sequenz, deren
Summe muss größer sein als die Summe der tatsächlichen
Laufzeiten ti der einzelnen Operationen:
n
X
ai ≥
i=1
Signatur einer funktionalen Queue in Haskell [1,
n
X
ti
i=1
Somit erlaubt man einigen Operationen, dass ihre echte
Laufzeit größer ist als die amortisierte, diese bezeichnet
man als teuer. Dann muss es allerdings auch Operationen
geben, deren echte Laufzeit kleiner ist, als die amortisierte
Laufzeit, solche nennt man günstig [1, Kap. 5.1].
1) Bankkontomethode: Die erste Möglichkeit ist die
Bankkonto Methode. Die Vorstellung ist, dass günstige
Operationen Guthaben, sogenannte Token, auf ein imaginäres Konto einzahlen. Teure Operationen können nun
von diesem Konto Token abheben, um somit ihre Laufzeit
klein zu rechnen. Somit berechnen sich die amortisierten
Kosten ai für eine Operation aus der tatsächlichen Zeit
ti , die die Operation benötigt, den Tokens ci , die auf das
Konto eingezahlt werden und den Token c̄i die vom Konto
abgehoben werden mit:
module Queue (Queue(..)) where
import Prelude hiding (head, tail)
class Queue q where
empty :: q a
isEmpty :: q a −> Bool
snoc :: q a −> a −> q a
head :: q a −> a
tail :: q a −> q a
Im Idealfall sollte jede dieser Operationen in konstanter
Laufzeit O(1) laufen. Head und tail sind einfach zu implementieren, sodass sie diese Voraussetzung erfüllen, da sie
am vorderen Ende der Liste arbeiten und die Liste nicht
traversieren müssen. Bei einer einfachen Implementation
der Queue mittels einer normalen Liste würde snoc allerdings die komplette Liste traversieren müssen und somit
in linearer Laufzeit O(n) laufen.
Im Folgenden werden Möglichkeiten vorgestellt, wie es
möglich ist, eine rein funktionale Queue zu erstellen, bei
der alle Operationen in konstanter Laufzeit erfolgen. Dies
wird zuerst mit einer amortisierten Laufzeit in O(1) getan
und anschließend mit einer worst-case Laufzeit in O(1).
ai = ti + ci − c̄i
Hierbei gibt es zwei zusätzliche Einschränkungen. Erstens darf kein Token ausgegeben werden, bevor es eingezahlt worden ist. Zweitens darf kein eingezahltes Token
zweimal abgehoben werden. Dadurch ist sichergestellt,
dass der Betrag des Kontos nicht negativ wird. Des Weiteren folgt daraus, dass die Summe der eingezahlten Token
größer oder gleich der Summe der abgehobenen Token ist.
Da:
n
n
n
n
n
X
X
X
X
X
c̄i
ci −
ti +
(ti + ci − c̄i ) =
ai =
IV. Amortisierte Laufzeitanalyse
Eines der wichtigsten Auswahlkriterien für eine Datenstruktur ist deren Schnelligkeit. Es gibt im Allgemeinen
drei wichtige Arten der Laufzeitanalyse: eine best-case,
eine worst-case und average-case Laufzeit, die die beste,
schlechteste bzw. durchschnittliche Laufzeit einer Datenstruktur bzw. der Algorithmen dieser Datenstruktur angeben. Je nach Anwendungsfall ist man meist an der worstcase Laufzeit, oder der average-case Laufzeit interessiert.
Eine worst-case Laufzeit ist besonders von Interesse, wenn
die Datenstruktur ein Ergebnis immer innerhalb einer
bestimmten Zeit liefern soll, zum Beispiel in zeitkritischen
Anwendungen. Eine gute average-case Laufzeit ist vor
allem wichtig, wenn viele Operationen stattfinden sollen,
wobei die Wichtigkeit die Dauer einzelner Operationen
sekundär ist. Eine Analysemöglichkeit für die average-case
Laufzeit einer Datenstruktur ist die amortisierte Laufzeitanalyse.
Bevor die amortisierte Laufzeitanalyse für funktionale
Datenstrukturen betrachtet wird, soll in Erinnerung gerufen werden, wie diese grundsätzlich funktioniert, wie
sie auch Okasaki in Purely Functional Data Structures“
”
vorstellt [1, Kap 5].
i=1
i=1
i=1
gilt wegen
n
X
ci ≥
i=1
n
X
i=1
i=1
c̄i
i=1
folgendes:
n
X
i=1
ai =
n
X
i=1
ti +
n
X
i=1
ci −
n
X
i=1
c̄i ≥
n
X
ti
i=1
Somit ist sichergestellt, dass die amortisierte Laufzeit
mindestens so groß wie die tatsächliche Laufzeit ist [1, Kap
5.1].
2) Physiker Methode: Eine andere Methode ist die Physiker Methode, bei der eine Funktion Φ definiert wird,
die der Ausgabe einer Operation d eine reelle Zahl zuordnet, welches als Potenzial bezeichnet wird. Diese Potenzialfunktion wird normalerweise nicht-negativ gewählt.
Die amortisierten Kosten einer Operation bestehen, bei
3
anderem den Vorteil, dass head in O(1) laufen kann. Diese
interne Datenstruktur würde in Haskell durch Algorithmus
2 dargestellt.
diesem Verfahren, aus tatsächlicher Zeit, die die Operation
benötigt ti , sowie dem Wert des Potentials der Ausgabe
der vorherigen Operation und dem Wert des Potenzials
der aktuellen Ausgabe:
Algorithmus 2. Typdatendefenition in einer Haskell Batched Queue
[1, Appendix A]
ai = ti + Φ (di ) − Φ (di−1 )
data BatchedQueue a = BQ [a][a]
check [] r = BQ (reverse r) []
check f r = BQ f r
[1, Kap 5.1].
Für die komplette n-elementige Sequenz ergibt sich:
n
X
ai =
i=1
=
n
X
Wenn ein Element in die Queue eingefügt werden soll,
wird das Element vorne in die r Liste eingefügt, siehe
Algorithmus 3.
(ti + Φ (di ) − Φ (di−1 ))
i=1
n
X
ti −
i=1
n
X
(Φ (di−1 ) − Φ (di ))
Algorithmus 3.
A]
i=1
snoc (BQ f r) x = check f (x:r)
hierbei lässt sich die Teleskop-summe:
n
X
Um das erste Element der Queue zurückzugeben reicht
es, das erste Element in der vorderen internen Liste
zurückzugeben. Sollte die vordere Liste leer sein, wissen
wir, dass unsere gesamte Queue leer ist und können deshalb ohne weitere Überprüfungen einen Fehler zurückgeben. Die Methode head ist in Algorithmus 4 gezeigt.
(Φ (di−1 ) − Φ (di ))
i=1
zu
Φ (d0 ) − Φ (n)
auswerten
Daraus ergibt sich:
n
X
i=1
ai =
n
X
Snoc in einer Haskell Batched Queue [1, Appendix
Algorithmus 4.
A]
Head in einer Haskell Batched Queue [1, Appendix
head (BQ [] ) = error ”empty queue”
head (BQ (x:f) ) = x
ti − Φ (d0 ) + Φ (n)
i=1
Bei der tail Funktion, wird die Queue so zurückgegeben,
dass aus der vorderen Liste das erste Element fehlt. Sollte
die vordere Liste hierbei leer werden, wird sie durch die
umgedrehte hintere Liste ersetzt, siehe Algorithmus 5.
Wenn man nun Φ (d0 ) als Null definiert und beachtet, dass
Φ immer positiv ist, erhält man:
n
X
i=1
ai ≥
n
X
ti
i=1
Algorithmus 5.
A]
[1, Kap. 5.1]
In dieser Arbeit wird hauptsächlich die Bankkonto Methode verwendet. Grundsätzlich ist es auch möglich die
Physiker Methode auf persistente, funktionale Datenstrukturen anzuwenden. Dies tut Okasaki in Purely Functional
”
Data Structures“ [1] auch des Öfteren.
Tail in einer Haskell Batched Queue [1, Appendix
tail (BQ [] ) = error ”empty queue”
tail (BQ (x:f) r) = check f r
Wieso diese Batched Queue in einer amortisierten Laufzeit in O(1) liegt zeigt Okasaki auch: Die Funktion snoc,
fügt an die hintere Liste ein Element hinzu und ruft danach
die Funktion check auf. Sollte f mindestens ein Element
enthalten, gibt check f und r in einer Batched Queue
zurück, diese Operation liegt in O(1). Wenn f leer ist,
dann muss r vor dem Einfügen auf Grund der Invarianz
auch leer gewesen sein. Aus diesem Grund enthält r in der
check Methode nur ein Element, weshalb reverse r auch
in O(1) liegt. Somit ist die ganze snoc Methode in O(1).
Noch einfacher ist zu sehen, dass head in konstanter Zeit
läuft, da bei dieser Funktion nur das erste Listenelement
der vorderen Liste aufgerufen und zurückgegeben wird.
Somit besitzen diese beiden Funktionen sogar eine worstcase Laufzeit in O(1).
Um zu zeigen, dass tail einen amortisierten Laufzeitaufwand in O(1) besitzt, kann man die Bankkonto Methode
verwenden. Wenn man für jedes Einfügen eines snocs
zusätzlich ein Token auf das Konto einzahlt, besitzt snoc
eine amortisierte Laufzeit von zwei und bleibt in O(1).
Somit kann man sich vorstellen, dass jedes Element, das
B. Klassische amortisierte Analyse einer Queue
Wir haben nun das Grundgerüst einer rein funktionalen
Queue in Algorithmus 1 gesehen. Im vorherigen Kapitel
wurde die Technik der amortisierten Laufzeitanalyse vorgestellt. In diesem Abschnitt soll beides wie bei Okasaki in
Purely Functional Data Structures“ [1, Kap 5.2] zusam”
mengefügt werden. Für die amortisierte Laufzeitanalyse
wird die Bankkontomethode verwendet.
Intern werden zur Verwaltung der Queue zwei Listen, f
und r, verwendet. Hierbei enthält f die vorderen Elemente
und r die hinteren, wobei r diese in umgekehrter Reihenfolge enthält. Die Liste mit den Elementen 1 bis 6 würde
durch die zwei Listen f=[1,2,3] und r=[6,5,4] gebildet sein
[1, Kap 5.2]. Zusätzlich soll noch die Invarianz gelten, dass,
wenn die vordere Liste leer ist, die komplette Queue leer ist
und immer, wenn die vordere Liste leer wird, sie durch die
umgedrehte hintere Liste ersetzt wird. Dies besitzt unter
4
in der hinteren Liste enthalten ist, für ein Token auf den
Konto steht. Bei einer hinteren Liste von m Elementen
sind somit m Token auf dem Konto. Wenn die tail Funktion auf eine Batched Queue mit einelementiger vorderen
Liste aufgerufen wird, muss die hintere Liste umgedreht
werden. Die Reverse Funktion benötigt für jedes einzelne
Element eine Operation, da das Element vorne von der
alten Liste entfernt wird und vorne in die neue Liste eingefügt wird. Wenn die Liste m Elemente lang ist, benötigt
das Umdrehen dieser Liste m Operationen. Diese m Operationen können vom Konto abgehoben werden, da sich
dort m Token befinden. Die restlichen Operationen der
tail Funktion sind konstant, weshalb die Laufzeit bei einem
solchen tail Funktionsaufruf m + O(1) ist und amortisiert
eine Laufzeit von m + O(1) − m = O(1) ergibt. Im Fall,
dass die vordere Liste nicht leer wird, wird nur das erste
Element der vorderen Liste zurückgeben. Somit läuft diese
Funktion auch in O(1). Da jede einzelne Operation wie
gesehen in O(1) liegt, liegt diese Datenstruktur amortisiert
in O(1), wenn das reverse auf die hintere Liste nur einmal
aufgerufen wird.
zuzugreifen. Im nächsten Kapitel wird Okasakis Methode
erklärt werden, wie es möglich ist, eine amortisierte Laufzeitanalyse auf persistente Datenstrukturen anzuwenden
[1, Kap. 5.6].
V. Amorisierte Laufzeitanalyse von
persistenten Datenstrukturen
Im vorherigen Kapitel haben wir gesehen, dass es funktionale Datenstrukturen mit guter amortisierter Laufzeit
geben kann. Sobald man persistente Datenstrukturen betrachtet, stößt man allerdings auf Probleme. In diesem
Abschnitt soll nachvollzogen werden wie es Okasaki in
Purely Functional Data Structures“ [1, Kap 6] schafft,
”
eine amortisierte Laufzeitanalyse auf persistente Datenstrukturen anzuwenden. Dazu wird als Erstes betrachtet,
wie man das Verhalten einer persistenten Datenstruktur
beschreiben kann und danach eine Methode, wie man die
amortisierte Laufzeit konkret berechnen kann.
A. Zukunft einer Datenstruktur
Grundsätzlich wurde festgestellt, dass das Problem der
klassischen amortisierten Analyse im Funktionalen ist,
dass mehrere Operationen versuchen werden das gleiche
Token vom Konto abzuheben. Dies hat damit zu tun, dass
aus einem Zustand der Datenstruktur in Zukunft mehrere
Formen entstehen können, die unabhängig voneinander
agieren.
Um dies besser zu verstehen lohnt es sich mittels execution traces, die Okasaki in Purely Functional Data
”
Structures“ [1, Kap. 6.1] aufgreift, zu betrachten, wie eine
Berechnungssequenz ausgeführt wird. Ein execution trace
ist ein gerichteter Graph, bei dem die Operationen die
Knoten sind. Es gibt eine Kante vom Knoten v nach v 0 ,
wenn die Operation in v 0 auf das Ergebnis von v zugreift.
Hierauf kann man die logische Vergangenheit (logical
history) eines Knoten v definieren, als die Menge aller
Knoten von denen die Operation von v abhängt. Dies sind
alle Knoten für die ein Pfad zu v existiert. Die logische
Zukunft (logical future ) eines Knoten v ist ein Pfad mit
den Operationen, die vom Ergebnis von v abhängen, somit
ein Pfad von v zu einem Knoten ohne ausgehende Kanten.
Wenn es von einem Knoten mehrere solcher Pfade gibt,
dann hat dieser Knoten mehrere logische Zukünfte.
In klassischen Datenstrukturen besitzt jede Operation
nur maximal eine logische Zukunft, weswegen die amortisierten Methoden auch gut funktionieren. In persistenten
Datenstrukturen ist die Anzahl an möglichen logischen
Zukünften unbeschränkt. Auch kann ein solcher execution
trace, im Falle von Selbstrekursionen, Kreise enthalten.
Auch Multikanten sind möglich [1, Kap. 6.1].
C. Problem bei persistenten Datenstrukturen
Wir haben oben gesehen, dass die oben beschriebene
Batched Queue die gewünschte Laufzeit besitzt. Allerdings
wurde davon ausgegangen, dass die Datenstruktur nicht
kopiert wird. Wir haben allerdings in II-A erfahren, dass
viele funktionale Programmiersprachen persistent sind
und die Daten kopieren. Für persistente Datenstrukturen
funktioniert die Batched Queue nicht, was folgendes Beispiel zeigt:
Angenommen q ist eine Batched Queue, in die n Elemente eingefügt wurden. Demnach ist im vorderen Teil
ein Element enthalten und im hinteren Teil sind n-1
Elemente. Ruft man nun n-mal die Funktion tail jeweils
auf q auf, so wird n mal die hintere Liste in q umgedreht.
Das Umdrehen der n-elementigen Liste läuft in O(n) und
somit hat diese Sequenz eine Laufzeit in O(n2 ), da n-mal
die Liste umgedreht wird. Wir erwarten allerdings, dass
diese Sequenz in O(n) liegt. Das Problem hierbei ist, dass
die Bankkonto Methode voraussetzt, dass ein Token nur
einmal abgehoben werden darf. Der erste Aufruf der tail
Funktion hebt alle Token vom Konto ab, weswegen weitere
Aufrufe der tail Funktion nicht mehr diese Token abrufen
dürfen [1, Kap. 5.6].
Wie wir gesehen haben, ist es nicht möglich die klassische Bankkontomethode auf funktionale persistente Datenstrukturen anzuwenden. Vor allem das Ansparen von
Token birgt Probleme, da diese später nur einmal verwendet werden können. Die persistente Datenstruktur
ermöglicht es, eine Funktion beliebig oft auf den gleichen
Zustand einer Datenstruktur anzuwenden. Damit die klassische Bankkonto Methode funktioniert, ist es allerdings
notwendig, dass sich in einer Funktion, die Token verbraucht, auch der Zustand der Datenstruktur verändert
und es nicht mehr möglich ist, auf den alten Zustand
B. Lazyiness als wichtige Voraussetzung
In Abschnitt IV-C war das Problem der amortisierten Datenstruktur, dass eine teure Funktion wiederholt
aufgerufen wird. Folglich ist es wichtig, dass, wenn eine
solche teure Funktion wiederholt aufgerufen wird, die
5
darauf folgenden Aufrufe billig sind. Nach Okasaki [1, Kap.
6.2.1] ist dies nur in lazy Programmiersprachen möglich,
vor allem wegen dem memoisation, also die einen bereits
berechneten Wert bei dem ersten Aufruf der Funktion
auswerten und danach so speichern, sodass alle darauffolgenden Aufrufe der Funktion auf diesen berechneten Wert
zugreifen können.
Zudem ist es nicht gestattet eine Verzögerung aufzulösen,
bevor nicht alle Schulden, die mit dieser Verzögerung
verknüpft wurden, aufgelöst sind.
Eine gute, anschauliche Parallele zur echten Welt, die
auch Okasaki [1, Kap 6.2.2] anbringt, ist das Zurückstellen eines Einkaufes. Wenn man zum Beispiel ein Auto,
das man gerne kaufen will, sieht, man es sich allerdings
momentan noch nicht leisten kann, dann könnte man mit
dem Verkäufer vereinbaren, dass er dieses für den Käufer
reserviert. Der Käufer zahlt von Zeit zu Zeit einen Teil
des Preises, und erhält das Auto erst vollständig, wenn er
den ganzen Preis abbezahlt hat. In der lazy Datenstruktur könnte man Operationen finden, deren Berechnung
man sich noch nicht leisten. Diese Berechnung verzögert
man nun und nimmt einen Betrag an Schulden auf, der
im Verhältnis zu den geteilten Kosten der Operation
steht. Danach zahlt man schrittweise diese Schuld zurück.
Wenn die Schuld vollständig abbezahlt ist, kann man die
verzögerte Operation ausführen.
C. Laufzeitanalyse von Lazy Datenstrukturen
Die Laufzeitanalyse von strikten Datenstrukturen ist
nicht immer ganz trivial. Die von lazy Datenstrukturen
ist etwas schwerere, da es unter anderem schwer zu sagen
ist, wann und ob welche Operation ausgeführt wird. Da
die lazy Eigenschaft für die Datenstrukturen wichtig ist,
muss diese auch bei der Laufzeitanalyse beachtet werden.
Im Folgenden wird ein Framework vorgestellt, das Okasaki
in Purely Functional Data Structures“ [1, Kap 6.2.2]
”
benutzt, um lazy Datenstrukturen zu analysieren.
Dazu teilt man die Kosten einer Operation in verschiedene Kategorien auf. Zum einen in ungeteilte Kosten (unshared costs) und geteilte Kosten (shared costs). Ungeteilte
Kosten sind die Kosten für die Operation, die auftreten
würden, wenn alle Verzögerung bereits aufgelöst wurden
und die Ergebnisse gespeichert wurden. Die geteilten Kosten, sind die Kosten, die benötigt werden, um alle erzeugten, aber noch nicht evaluiert Verzögerungen, aufzulösen.
Die gesamten Kosten (complete cost) sind die Summe aus
geteilten und ungeteilten Kosten. Diese gesamten Kosten
sind die gleichen Kosten, wenn wie wenn die Operation
strict ausgeführt werden würde.
Des Weiteren teilt man die geteilten Kosten in realisierte
Kosten (realized costs) und unrealisierte Kosten (unrealized costs) auf. Die realisierten Kosten sind die geteilten
Kosten für Verzögerungen, die ausgeführt werden. Die
unrealisierten Kosten sind die Kosten für Verzögerungen,
die nicht ausgeführt werden.
Die tatsächlichen Kosten (total actual cost) einer Abfolge von Operationen sind die Summe aus den ungeteilten
Kosten und den realisierten geteilten Kosten.
E. Verzögerung von Ausführungen
Einer der besonderen Punkte bei einer lazy Evaluation
ist, dass gewisse Operationen verzögert und erst später
ausgeführt werden. Okasaki [1, Kap 6.2.2] stellt bei diesen Verzögerungen (suspension) im Zusammenhang einer
amortisierten Analyse mit akkumulierter Schuld drei wichtige Momente fest. Als Erstes muss eine solche Verzögerung erstellt werden. Als Zweites, wenn die Schuld für diese
Verzögerung abbezahlt wurde. Der letzte Moment ist,
wenn diese verzögerte Operation tatsächlich ausgeführt
wird. Für die amortisierte Analyse ist es entscheidend,
dass bewiesen wird, dass die Schuld immer zuerst abbezahlt wird und danach erst die verzögerte Operation
ausgeführt wird.
Wenn die Schuld immer abbezahlt wird bevor die Operation ausgeführt wird, dann ist die Menge an abbezahlter
Schuld eine obere Schranke für die realisierten Kosten.
Dies liegt daran, dass die Schuld aus den geteilten Kosten
der verzögerten Operation bestand. Somit sind auch die
amortisierten Kosten, die ungeteilten Kosten plus die Summe der zurückgezahlten Schuld, eine obere Schranke für
die tatsächlichen Kosten, der Summe aus den ungeteilten
Kosten und der realisierten geteilten Kosten.
Ein Problem bei lazy Programmen ist, dass man verschiedene logische Zukünfte beachten muss. Okasaki [1,
Kap 6.2.2] umgeht dies, indem er jede einzeln betrachtet.
Daher muss auch jede logische Zukunft für sich die Schulden einer verzögerten Operation begleichen. Eine Schuld
kann nicht durch die Zusammenarbeit zweier logischen
Zukünfte beglichen werden. Dies bedeutet auch, dass eine
Schuld öfter abbezahlt werden kann. Dies ist allerdings
nicht schlimm, da hierdurch nur die Laufzeit überschätzt
wird und die Schranke zu hoch angesetzt wird.
D. Akkumulierte Schuld
In die amortisierte Analyse führt Okasaki [1, Kap 6.2]
zusätzlich den Begriff der akkumulierten Schuld ein. Von
der Vorstellung her war bei der Bankkonto Methode das
Problem, dass versucht wurde ein gespartes Token öfter
auszugeben. Im Gegensatz dazu würde es bei einer Schuld
nichts ausmachen, wenn diese öfter abbezahlt wird.
Die akkumulierte Schuld benutzt man um die geteilten
Kosten zu bestimmen. Am Anfang ist diese akkumulierte
Schuld null. jedes mal wenn man eine Verzögerung erzeugt,
wird die akkumulierte Schuld erhöht und zwar um die
geteilten Kosten der Verzögerung, sowie auch jeder verketteten Verzögerung. Jede Operation zahlt nun einen Teil
der akkumulierten Schuld zurück. Die amortisierten Kosten sind nun die Summe aus den ungeteilten Kosten plus
der Anzahl der abbezahlten Schulden bei dieser Operation.
F. Bankkonto Methoden für persistente Datenstrukturen
Da wir nun gesehen haben, wie dies theoretisch und
allgemein funktionieren sollte, soll im Folgenden dies spe6
ziell für die Bankkonto Methode gezeigt werden. Hierfür
verwendet Okasaki [1, Kap 6.3] anstelle von aufsummierten Guthaben auf dem Kontostand, eine aufsummierte
Schuld. Wobei diese Schulden für die verzögerten Operationen stehen und proportional zu den geteilten Kosten der
Operation sind. Somit ist die Schuld in etwa die Kosten,
die man braucht um die Verzögerung aufzulösen. Des
Weiteren wird jede Schuld mit einem bestimmten Ort in
der untersuchten Struktur verknüpft. Bei monolithischer
Ausführung ist dieser immer an der Wurzel des Ergebnisses, bei inkrementeller Ausführung, an den Wurzeln der
einzelnen Teilergebnisse.
Die amortisierten Kosten einer Operation definiert man
als die ungeteilten Kosten (die Kosten die man für die
Operation benötigt, wenn die Verzögerung aufgelöst ist)
plus den abbezahlten Schulden. Hierbei sollten als erstes
immer die Schulden abbezahlt werden, die an einem Ergebnis hängen, das bald benötigt wird. Damit die amortisierte
Grenze gültig ist, muss bevor eine Operation ausgeführt
wird, jegliche Schuld die mit dem Ort der Operation
verknüpft ist, aufgelöst sein. Hierdurch wird garantiert,
dass die Summe, der abbezahlten Schulden eine obere
Schranke für die realisierten Kosten sind. Somit folgt
direkt, dass die amortisierten Kosten eine obere Schranke
für die realisierten Kosten plus die ungeteilten Kosten sind
und somit auch eine obere Schranke für die totalen Kosten.
Danach können immer noch Schulden im untersuchten
Objekt übrig sein. Diese sind allerdings an Orten verknüpft, die nie ausgewertet wurden und somit unrealisierte
Kosten, die für die Laufzeit irrelevant sind. Okasaki gibt in
Purely Functional Data Structures“ [1, Kap 6.3.1] einen
”
ausführlichen formalen Beweis, weshalb dies gerechtfertigt
ist, der hier aber ausgelassen wird.
data BankersQueue a = BQ Int [a] Int [a]
check lenf f lenr r
|lenr <= lenf = BQ lenf f lenr r
|otherwise =
BQ (lenf+lenr) (f ++ reverse r) 0 []
instance Queue BankersQueue where
empty = BQ 0 [] 0 []
isEmpty (BQ lenf f lenr r) = (lenf == 0)
snoc (BQ lenf f lenr r) x =
check lenf f (lenr+1) (x:r)
head (BQ lenf [] lenr r) =
error ”empty queue”
head (BQ lenf (x:f’) lenr r) = x
tail (BQ lenf [] lenr r) =
error ”empty queue”
tail (BQ lenf (x:f’) lenr r) =
check (lenf−1) f’ lenr r
Im Folgenden soll, wie in Okasaki Purely Functional
”
Data Structures“ [1, Kap 6], gezeigt werden, wieso jede
Operation dieser Bankers Queue in O(1) liegt. Zur Erinnerung: Die gesamten Kosten einer Operation bestehen
aus den ungeteilten Kosten und den realisierten geteilten
Kosten. Offensichtlich sind die ungeteilten Kosten jeder
Operation in O(1), da das sowohl das Ausgeben des ersten
Elements, als auch das vorne hinzufügen bzw. entfernen
von Listenelementen in O(1) liegt. Der reverse Aufruf
ist immer in einer Verzögerung und daher noch nicht
aufgelöst, wie für die ungeteilten Kosten nach Definition
nötig. Daher bleibt zu zeigen, dass das Auflösen der Schulden auch in O(1) liegt.
Dazu nehmen wir an, dass snoc eine Schuld abbezahlt
und tail zwei. Des Weiteren definieren wir zwei Größen:
d (i) ist die Anzahl an Schulden, die mit dem i-ten
PElement
i
der vorderen Liste verknüpft sind und D (i) = j=0 d(j)
die Summe der Schulden die mit den ersten i Elementen
verknüpft sind. Um dies einfacher zu zeigen, lohnt es sich
zwei Bedingungen einzuführen: Zum einen soll D (i) ≤ 2 · i
, zum anderen soll D (i) ≤ |f | − |r| gelten. Durch Ersteres
ist das vorderste Element immer schuldenfrei und kann
somit von head immer ausgegeben werden. Das Zweite
führt dazu, dass immer, bevor die hintere Liste umgedreht
werden muss, alle Schulden abbezahlt sind. Somit müssen
wir zeigen, dass nach jeder Operation gilt:
G. Bankkonto Methoden für persistente Warteschlangen
Die oben vorgestellte Methode soll nun beispielhaft
an einer Queue dargestellt werden. Diese wird auf der
in Abschnitt IV-B vorgestellten Implementation basieren
und leicht angepasst sein:
Die interne Datenstruktur wird nun um zwei Integer
erweitert, die jeweils angeben, wie viele Elemente in der
vorderen beziehungsweise hinteren Liste sind. Dies soll
vor allem die einzelnen Operationen vereinfachen. Die
wichtigste Änderung ist in der check Funktion. Nun wird
die hintere Liste nicht umgedreht, wenn die vordere Liste
leer ist, sondern immer wenn die hintere Liste echt länger
als die vordere Liste ist, drehen wir die hintere Liste
um und fügen sie hinten an die vordere Liste an. Die
restlichen Methoden bleiben in ihrer Funktionalität gleich,
siehe Algorithmus 6
Algorithmus 6.
D (i) ≤ min (2 · i, |f | − |r|)
Betrachten wir zuerst die einfachen Fälle, in denen keine
Rotation auftritt, die hintere Liste kleiner als die vordere
ist. Durch ein snoc würde |f | − |r| um eins kleiner, da |r|
sich um eins erhöht. Wenn wir nun das erste Schuldenelement in der Queue abbezahlen, stellen wir sicher, dass
D (i) ≤ |f | − |r| gilt. Tail entfernt das erste Element aus
Haskell Bankers Queue [1, Appendix A]
module BankersQueue (BankersQueue) where
import Prelude hiding (head, tail)
import Queue
7
der Liste und somit ist |f | um eins kleiner. Hierdurch wird
|f | − |r| auch um eins kleiner, des Weiteren wird 2 · i um 2
kleiner, da sich der Index i um eins nach vorne verschiebt.
Wenn wir nun die ersten beiden Schulden in der Queue
abbezahlen, stellen wir sicher, dass beide Bedingungen für
D (i) gelten.
Bleibt noch ein snoc bzw. tail das eine Rotation verursacht. In diesem Fall sind durch unsere Konstruktion
alle Schulden abbezahlt. Für eine solche Rotation sind
zwei Schritte notwendig: Das Anhängen, append ((++)
Operation) der umgedrehten hinteren Liste an die vordere
und das Umdrehen der hinteren Liste. Wenn in der vorderen Liste m Elemente sind benötigt die append-Funktion
|f | = m, die Reverse Operation |r| = m + 1 Schritte. Diese
Operationen nehmen wir als Schulden auf. Da wir eine
lazy Datenstruktur haben ist die append-Funktion inkrementell umsetzbar, weshalb wir mit jedem Element eine
Schuld verknüpfen können. Die reverse-Funktion ist monolithisch, da wir das letzte Element der ursprünglichen Liste
benötigen, müssen wir die Liste zuerst umdrehen, bevor
wir auf das Element zugreifen können. Daher müssen wir
die Schulden mit dem m-ten Element verknüpfen. Dadurch
ergeben sich folgende Werte für die Schuldfunktionen:


if i < m
1
d (i) = m + 1 if i = m


0
if i > m
Reverse-Operation als Verzögerung, wodurch kein Sharing
dieser stattfinden kann. Allerdings kann nun jeder Zweig
die Kosten für seine eigene Reverse Operation abbezahlen,
da genug Elemente vor dieser vorhanden sind.
VI. Eliminierung von Amortisierung
In den vorherigen Kapiteln habe wir gesehen wie man
eine rein funktionale und persistente Datenstruktur mit
guter amortisierter Laufzeit entwickeln kann. In diesem
Kapitel geht es darum, sich anzuschauen, wie man aus
einer amortisierten Datenstruktur eine worst-case Datenstruktur der gleichen Komplexitätsklasse erhalten kann.
Anschließend wird dies an dem Beispiel einer Real-Time
Queue durchgeführt. Auch hierfür gibt Okasaki in Purely
”
Functional Data Structures“ [1, Kap. 7] eine Anleitung.
Worst-case Datenstrukturen haben den Vorteil, dass die
Laufzeit gleichmäßig verteilt ist und es nicht zu einzelnen sehr langsamen Operationen kommt. In zeitkritischen
Systemen bringt es zum Beispiel nichts, wenn viele Operationen weit vor ihrer Deadline fertig sind, eine Operation
hingegen diese Deadline überschreitet. Auch in Benutzeranwendungen ist es meist besser, wenn der Benutzer
immer ein schnelles Feedback vom System bekommt, als
viele noch schnellere, die durch ein spürbar langsameres
Feedback erkauft sind.
A. Allgemeines Verfahren: Scheduling
Ein wichtiger Unterschied zwischen der worst-case Analyse und der amortisierten Analyse ist, dass bei Ersterer
die Kosten einer Operation zum Zeitpunkt der Ausführung
genau dieser Operation berechnet werden. Bei der amortisierten Laufzeitanalyse werden die Kosten einer Operation
auf andere Operationen verteilt und somit erst später
berechnet. In einer rein lazy-evaluierten Datenstruktur,
würde man nur eine amortisierte Laufzeit erhalten, da die
Auswertungen immer verzögert werden. Daher benötigt
man hierfür eine strikte Sprache. Daher muss eine Worst
Case Datenstruktur in diesem Fall in einer Sprache geschrieben sein, die sowohl lazy als auch strikte Evaluation
unterstützt.
Für eine solche Transformation benötigt man zwei
Schritte: Als Erstes muss man die intrinsischen Kosten,
die Kosten, die man braucht, um eine Verzögerung aufzulösen unter der Annahme, dass alle davon abhängigen
Verzögerungen bereits aufgelöst und gespeichert sind, zu
reduzieren und unter der gewünschten Schranke zu halten. Dazu muss man meist monolithische Funktionen in
eine inkrementelle Version umschreiben. Trotzdem kann es
passieren, dass einige Operationen länger brauchen, als die
Schranke festlegt. Dies ist der Fall wenn diese Operationen
von anderen Operationen abhängen, die alle ausgeführt
werden müssen, sobald die erste Verzögerung aufgelöst
wird.
Daher ist noch ein zweiter Schritt notwendig: Es müssen
diese voneinander abhängigen Verzögerungen aufgelöst
werden. Dies erreicht man indem man das Auflösen dieser
Daraus folgt für die summierte Schuld:
(
i+1
if i < m
D (i) =
2 · m + 1 if i ≥ m
Um die Bedingung für D (i) ≤ min (2 · i, |f | − |r|) einzuhalten müssen beide Operationen nun selbst eine Schuld
abbezahlen. Diese würde sonst am ersten Element verletzt.
Diese Queue funktioniert auch auf persistenten Datenstrukturen. Ein Beweis hierfür ist in Okasaki Purely
”
Functional Data Structures“ [1, Kap 6] gegeben. Folgendes
Beispiel soll dies verdeutlichen: Sei q eine Queue mit
m = |f | = |r|. Des Weiteren werden wiederholt tail auf
die Queue aufgerufen. Somit wird beim ersten tail-Aufruf
die Reverse-Operation als Verzögerung gespeichert. Beim
m+1-ten tail wird die Reverse-Operation ausgeführt, da
alle Elemente aus der vorderen Liste entfernt sind. Verzweigt man die Liste nun bei der k-ten Operation gibt es
zwei Möglichkeiten. Ersten, die Verzweigung ist nachdem
die Reverse Operation angehängt wird, dies wäre zum
Beispiel bei k = m + 1 der Fall. Dann würde ein tail auf
einen Zweig sofort eine Ausführung von reverse erzwingen.
Dank Sharing ist diese Reverse Funktion allerdings in allen
hier entstandenen Zweigen die gleiche und wird daher nur
einmal ausgeführt, wodurch die Kosten auch nur einmal
entstehen. Die zweite Möglichkeit ist, dass die Verzweigen
geschieht bevor der Reverse-Operation angehängt wird,
z.B. k = 0. In diesem Fall erzeugt jedes tail eine neue
8
Verzögerung mittels Scheduling plant, sodass alle abhängigen Verzögerungen aufgelöst sind, wenn die Erste ausgeführt wird. Im Grunde ist dies nichts anderes als ein
reales abbezahlen der Schuld, wie es im letzten Kapitel
vorgestellt wurde.
Der allgemeine Ansatz dies zu implementieren ist, die
Datenstruktur um einen Schedule zu erweitern, der von
der Idee her, ein Pointer auf die noch bestehenden Verzögerungen ist. Jede Operation die auf die Datenstruktur
ausgeführt wird, sorgt nun dafür, dass ein paar dieser
Verzögerungen aufgelöst werden.
rotate [] (y:[]) a
= [] ++ (reverse (y:[])) ++ a
=y:a
B. Real-Time Queues
[1, Kap. 7.2]
Hier wurde die Definition von rotate eingesetzt und die
Auswertungsreihenfolge von Cons (:) und Append (++)
verändert. Danach wurde wieder die Definition von rotate
in der umgekehrten Reihenfolge eingesetzt.
Daraus ergibt sich eine vollständig inkrementelle rotate
Funktion, siehe Algorithmus 8:
[1, Kap. 7.2]
Für den Rekursionsfall gilt:
rotate (x:xs) (y:ys) a
= (x:xs) ++ (reverse (y:ys)) ++ a
= (x:xs) ++ (reverse (y:ys)) ++ a)
= x:(xs ++ reverse ys ++ (y:a))
= x:(rotate xs ys (y:a))
Das oben beschrieben Verfahren soll nun konkret in
einer Real-Time Queue umgesetzt werden. Dies ist eine
Queue, bei der jede Operation eine worst-case Laufzeit in
O(1) besitzt. Diese wird ausgehend von der BankersQueue,
die im vorherigen Kapitel vorgestellt wurde, entwickelt.
Zur Erinnerung: diese besitzt bereits eine amortisierte
Laufzeit in O(1). Das Ganze ist Okasaki Purely Func”
tional Data Structures“ [1, Kap 7.2] entnommen. Dort
stellt Okasaki diese nur in Standard ML vor und gibt keine
äquivalente Umsetzung in Haskell. Da das Gros der Leser
dieser Seminararbeit mit der Haskell Syntax im Gegensatz
zu Standard ML vertraut sind, wird hier der Standard ML
code aus Purely Functional Data Structures“ [1, Kap 7.2]
”
in Haskell übersetzt ohne auf die stricte Evaluation in den
Code Beispielen explizit Rücksicht zu nehmen.
Nach obiger Beschreibung ist der erste Schritt, die
monolithischen Funktionen in inkrementelle Funktionen
umzuwandeln. In der BankersQueue war die reverse Funktion noch monolithisch. Der Zweck der reverse Funktion
ist es, die hintere Liste umzudrehen und diese an die
vordere Liste, mittels der (++) Funktion, zu hängen.
Dies soll nun gleichzeitig geschehen. Dazu kann man eine
einfache Version einer rotate Funktion wie Algorithmus 7
definieren. Die Funktion enthält den vorderen Teil xs einer
Liste, der bereits richtig geordnet ist. Als Nächstes den Teil
ys, der umgedreht werden muss, und an den vorderen Teil
gehängt wird. Zudem noch eine weitere Liste a, in der man
Zwischenergebnisse speichern kann.
Algorithmus 8. Inkrementelle rotate Funktion für eine Real-Time
Queue in Haskell Syntax [1, Kapitel 7.2]
rotate (RQ [] (y: ) a) = y:a
rotate (RQ (x:xs) (y:ys) a) =
x:(rotate (RQ xs ys (y:a)))
Hiermit sind alle monolithischen Funktionen der originalen BankersQueue in inkrementelle Funktionen umgewandelt.
Der nächstes Schritt ist nun einen Schedule einzuführen.
Dieser ist eine einfache Liste vom gleichen Typ der Liste
und verwaltet die noch nicht ausgeführten Rotationen.
Da in der Real-Time Queue die Rotationen inkrementell
ausgeführt werden und die Bedingung |f | ≥ |r| implizit
über den Schedule erhalten bleiben wird, muss man die
Längen der beiden Listen nicht speichern. Somit besteht
die Datentypdefinition der Real-Time Queue aus drei Listen: f, r und s, wie in Algorithmus 9:
Algorithmus 9. Datentypdefinition einer Real-Time Queue in Haskell Syntax [1, Kapitel 7.2]
data RealTimeQueue a = RQ [a] [a] [a]
Sobald sich der Zustand der Liste durch das Einfügen
oder Entfernen von Elementen verändert, soll das nächste
Element im Schedule verarbeitet werden. Dazu wird eine
Hilfsfunktion exec, wie in Algorithmus 10, definiert. Diese
Funktion sorgt mittels dem Patternmatching auf die Schedule Liste dafür, dass die Rotate Funktion dort einmal
ausgewertet wird. Der Schedule ist genau dann leer, wenn
|r| = |f | + 1. In diesem Fall wird der Vorgang gestartet
die hintere Liste umzudrehen. Mit dem let Ausdruck wird
dafür gesorgt, dass sowohl in der vorderen Liste als auch im
Schedule die gleiche Liste vorhanden ist und eine Operation auf den Schedule, das Patternmatching, durch Sharing
und Memoisation auch die vordere Liste beeinflusst.
Algorithmus 7.
Rotate Funktion für eine Real-Time Queue in
Haskell Syntax [1, Kapitel 7.2]
rotate xs ys a = xs ++ reverse ys ++ a
In dieser Funktion ist immer noch ein reverse Aufruf
enthalten, den wir loswerden wollen. In der BakersQueue
wurden Rotationen immer gestartet, wenn |r| = |f | + 1
galt, der Zeitpunkt an dem r zum ersten Mal echt größer
als f wird. Diese Bedingung bleibt nun beständig. Dies
bedeutet unter anderem, dass immer wenn die vordere
Liste leer ist, die umzudrehende nur noch genau ein Element enthält. Mit diesem Wissen muss man bei der rotate
Funktion weniger Fälle beachten.
Für den Basisfall, in dem xs leer ist und ys genau ein
Element enthält, gilt somit folgendes:
Algorithmus 10. Execution Funktion um die Ausführung einer Rotation zu erzwingen Real-Time Queue in Haskell Syntax [1, Kapitel
7.2]
9
exec (RQ f r (x:s)) = (RQ f r s)
exec (RQ f r []) =
let f’ = rotate (RQ f r [])
in (RQ f’ [] f’)
= exec (RQ [1,2,3] [7,6,5,4] [])
−− f= rotate (RQ [1,2,3] [6,5,4] [])
= (RQ rotate (RQ [1,2,3] [7,6,5,4] []) []
rotate (RQ [1,2,3] [7,6,5,4] []))
Die übrigen Funktionen kapseln nur die exec Funktion,
Algorithmus 11 zeigt die vollständige Real-Time Queue in
Haskell.
Algorithmus 11.
Fügt man hier nun eine weitere Zahl ein, so wird angefangen den Schedule abzuarbeiten. Siehe Algorithmus 13.
Das Patternmatching in der exec Funktion wird zwar nur
gegen den Schedule ausgeführt, dank dem Sharingkonzept
führt dies dazu, dass auch die Funktion in der vorderen
Liste einmal ausgewertet wird.
RealTime Queue in Haskell [1, Kapitel 7.2]
module RealTimeQueue where
import Prelude hiding (head, tail)
import Queue
Algorithmus 13.
eingefügt
data RealTimeQueue a = RQ [a] [a] [a]
In Algorithmus 12 wird ein weiteres Element
snoc (RQ rotate (RQ 1 : [2,3] [7,6,5,4] [])
[] rotate (RQ 1 : [2,3] [7,6,5,4] [])) 8
instance Queue RealTimeQueue where
empty = RQ [] [] []
isEmpty (RQ [] ) = True
isEmpty = False
= exec (RQ rotate (RQ [1,2,3] [7,6,5,4] [])
[8] rotate (RQ [1,2,3] [7,6,5,4] []))
{−−
rotate (RQ [1,2,3] [7,6,5,4] []))
= 1: rotate (RQ [2,3] [6,5,4] [7])
−−}
= exec (RQ (1: rotate (RQ [2,3] [6,5,4] [7])) [8]
(1: rotate ((RQ [2,3] [6,5,4] [7]))
rotate (RQ [] (y: ) a) = y:a
rotate (RQ (x:xs) (y:ys) a) =
x:(rotate (RQ xs ys (y:a)))
exec (RQ f r (x:s)) = (RQ f r s)
exec (RQ f r []) =
let f’ = rotate (RQ f r [])
in (RQ f’ [] f’)
= (RQ (1: rotate (RQ [2,3] [6,5,4] [7])) [8]
(rotate (RQ [2,3] [6,5,4] [7]))
Dass die einzelnen Operationsaufrufe in O(1) sind, lässt
sich leicht sehen, da jede Funktion ruft nur eine weitere
Funktion auf, die in einem Datenkonstruktor endet, weshalb dies in O(1) liegt. Einzig zu beachten ist, dass das
Auflösen der Verzögerungen auch in O(1) liegt. Dies lässt
sich daran sehen, dass die Ergebnisse der rotate Funktion
sofort wieder eine Verzögerung enthalten. Ein formaler
Beweis ist in Okasaki Purely Functional Data Structures“
”
[1, Kap 7.1] zu finden.
snoc (RQ f r s) x = exec (RQ f (x:r) s)
head (RQ [] ) = error ”empty queue”
head (RQ (x:f) r s) = x
tail (RQ [] ) = error ”empty queue”
tail (RQ (x:f) r s) = exec (RQ f r s)
Wichtig hierbei ist, dass die Aufrufe tail und snoc nicht
lazy getätigt werden, sondern dafür gesorgt wird, dass die
tail bzw. snoc Funktion in die nächste WHNF (weak head
normal form) ausgewertet werden, also bis der outermost
Ausdruck ein Datenkonstrukt ist. Dazu wird intern die
Exec Funktion einmal ausgeführt. Um dies zu verdeutlichen, wird im Folgenden ein Beispiele in Haskellsyntax
gegeben, wie die Ausführung einer Operation der originalen Version in Standard ML von Okasaki gedacht ist.
Angenommen man fügt in eine Real-Time Queue in der 6
Elemente eingefügt wurden, das 7. Element ein. In diesem
Fall ist der Schedule leer und es wird beim Einfügen die
Bedingung |r| = |f | + 1 erfüllt. Damit muss das Rotieren
der hinteren Liste gestartet werden. Der ganze Ablauf ist
in Algorithmus 12 zu sehen.
VII. Zusammenfassung und Ausblick
Diese Arbeit hat gezeigt, dass es auch in persistenten, funktionalen Programmiersprachen möglich ist, effiziente Datenstrukturen zu bauen. Diese Techniken lassen
sich auch auf weitere Datenstrukturen anwenden. Okasaki
zeigt dies zum Beispiel auch noch für Heaps. Dies ist ein
sehr spannendes Gebiet, da funktionale Programmierung
lange das Problem hatte klassische Datenstrukturen effizient nachzubauen.
Literatur
[1] C. Okasaki, Purely Functional Data Structures.
Cambridge
University Press, 1998.
[2] Lazy
vs.
nonstrict.
[Online].
Available:
https://wiki.haskell.org/Lazy vs. non-strict
[3] Lazy
evaluation.
[Online].
Available:
https://wiki.haskell.org/Lazy evaluation
[4] Queue.
[Online].
Available:
http://docs.oracle.com/javase/7/docs/api/java/util/Queue.html
Algorithmus 12. Einfügen des siebten Elements nachdem 6 Elemente
bereits eingefügt wurden
snoc (RQ [1,2,3] [6,5,4] []) 7
10
Herunterladen