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