Functional queue
A singly linked list gives constant-time access to one end only, so a queue, which adds at one end and removes at the other, cannot be a single immutable list. A functional queue uses two: a front list, whose head is the next element to leave, and a rear list holding the newer elements in reverse, whose head is the most recently added[2]. Both ends are then heads of lists. When the front is empty, the rear is reversed to become the new front.
The batched queue
The queue keeps the invariant that the front is empty only if the whole queue is, so the next element is always at the head of the front. Push conses onto the rear; pop takes the head of the front; and both restore the invariant by reversing the rear when the front has become empty.
A batched queue, and a banker's queue, with a counter for the work spent reversing.
(* Steps spent reversing, so the demos can measure the cost. *)let steps = ref 0let rev l = List.fold_left (fun acc x -> incr steps; x :: acc) [] l(* The batched queue: take from front, add to rear. The front is emptyonly if the whole queue is. *)module Batched = structtype 'a t = { front : 'a list; rear : 'a list }let empty = { front = []; rear = [] }let check = function| { front = []; rear } -> { front = rev rear; rear = [] }| q -> qlet push x q = check { q with rear = x :: q.rear }let pop q =match q.front with| [] -> None| x :: front -> Some (x, check { q with front })end(* The banker's queue: the front is a lazy list, and the rear isreversed onto it as soon as it grows longer than the front. *)module Banker = structtype 'a stream = Nil | Cons of 'a * 'a stream Lazy.ttype 'a t = { front : 'a stream Lazy.t; lenf : int; rear : 'a list; lenr : int }let rec append s t =lazy (match Lazy.force s with| Nil -> Lazy.force t| Cons (x, s') -> Cons (x, append s' t))let of_list l = List.fold_right (fun x s -> lazy (Cons (x, s))) l (lazy Nil)let empty = { front = lazy Nil; lenf = 0; rear = []; lenr = 0 }let check q =if q.lenr <= q.lenf then qelse{ front = append q.front (lazy (Lazy.force (of_list (rev q.rear))));lenf = q.lenf + q.lenr; rear = []; lenr = 0 }let push x q = check { q with rear = x :: q.rear; lenr = q.lenr + 1 }let pop q =match Lazy.force q.front with| Nil -> None| Cons (x, front) -> Some (x, check { q with front; lenf = q.lenf - 1 })end
Each element is moved from the rear to the front once, by one reversal step, so pushes and pops cost in total: amortized constant time per operation[4][5]. A single reversal can still take time.
Persistence breaks the amortization
The amortized argument assumes that the queue is used linearly, each version once. A functional queue is persistent, and nothing stops a program from popping the same old version repeatedly. If that version has an empty front and a long rear, every pop of it repeats the same expensive reversal.
Each queue used once, then one old version popped 1,000 times.
open Queuelet rec drain pop q acc = match pop q with None -> List.rev acc | Some (x, q) -> drain pop q (x :: acc)let n = 100_000
Running it.
order: 1 2 3 4 5 6 7 8 9 10batched used once: 100000 reversal steps for 100000 elementsbatched same version x1000: 99999000 reversal stepsbanker's used once: 100000 reversal steps for 100000 elementsbanker's same version x1000: 0 reversal steps
The batched queue pays the full reversal every time. Okasaki's banker's queue keeps the front as a lazy list and reverses the rear as soon as it becomes longer than the front, appending the reversal to the front as a suspension[3][4]. Every version that shares the suspension shares its result once it has been forced, so repeated pops of an old version do not repeat the work. Reversing when the rear exceeds the front, rather than when the front is empty, guarantees that enough cheap operations happen before the suspension is forced to pay for it. Okasaki's analysis of this with debits, the banker's method, gives amortized bounds that hold under persistent use[4].
With further work the reversal can be performed incrementally, a few steps per operation, giving worst-case queues. Hood and Melville did this without laziness[1], and Okasaki's real-time queues do it by forcing the lazy front a step at a time[3].
History
Hood and Melville published real-time queues in pure Lisp in 1981[1], and Burton described the two-list queue with amortized constant-time operations in 1982[2]. Okasaki showed in 1995 how laziness and memoization make amortized and real-time queues simple to write and correct under persistence[3], and his 1998 book used queues as the running example for the banker's and physicist's methods of amortized analysis[4]. OCaml's standard Queue is mutable; the two-list queue is the usual immutable alternative.
see also
- Lazy evaluationLazy evaluation, or call-by-need, delays computing an expression until its value is needed and then remembers the value, so it is computed at most once. It lets programs define infinite structures and consume only part of them, separates generating candidate values from choosing among them, and avoids work whose result is never used. Its costs are the memory held by unevaluated suspensions, which can cause space leaks, and harder reasoning about when work happens. Haskell is lazy by default; OCaml provides it explicitly through Lazy.t and Seq.
- Finger treeA persistent sequence with amortized constant-time access at both ends, and concatenation and splitting in logarithmic time. The ends are kept in buffers of one to four elements, and the middle is a finger tree of 2-3 nodes, one level deeper at each step down the spine.
- Leftist heapA leftist heap is a priority queue represented as a heap-ordered binary tree in which, at every node, the right spine of the left child is at least as long as the right spine of the right child. The right spine of the whole tree then has at most log₂(n + 1) nodes, and two heaps can be merged in O(log n) time by merging their right spines like sorted lists. Insertion and deletion of the minimum are special cases of merging. Because merging copies only the right spines, leftist heaps are a standard persistent priority queue in functional languages.
further reading
- [1]R. Hood, R. Melville, “Real-time queue operations in pure LISP”, Information Processing Letters 13 (1981).
- [2]F. W. Burton, “An efficient functional implementation of FIFO queues”, Information Processing Letters 14 (1982).
- [3]C. Okasaki, “Simple and efficient purely functional queues and deques”, Journal of Functional Programming 5 (1995).
- [4]C. Okasaki, Purely Functional Data Structures, ch. 5–7, Cambridge University Press (1998).
- [5]R. E. Tarjan, “Amortized computational complexity”, SIAM Journal on Algebraic and Discrete Methods 6 (1985).
last updated