Introduction to Functional Programming

Russ Ross

Computer Laboratory
University of Cambridge
Lent Term 2005


Lecture 5

Functional queues

Lists provide a convenient representation for stacks, but what about queues in a purely functional setting?

We can't add a new element to the end of a list or remove the last element without copying the rest of the list in the process, but if we use two lists we can get a fast and simple queue implementation.

The first list, f, contains the front elements of the queue in order, and the second list, r, contains the rear elements in reverse order, e.g., the queue with integers 1…6 might be represented as one of these

([1,2,3], [6,5,4])
([1,2,3,4,5,6], [])
([1], [6,5,4,3,2])

Functional queues

The head of the queue is the first element of f, so head returns this element and tail removes it. To add an element to the end of the queue, we just add it to the beginning of r using the function snoc

- fun head (x::f, r) = x        
  fun tail (x::f, r) = (f, r) 
  fun snoc ((f, r), x) = (f, x :: r);
> val ('a, 'b) head = fn : 'a list * 'b -> 'a
  val ('a, 'b) tail = fn : 'a list * 'b -> 'a list * 'b
  val ('a, 'b) snoc = fn : ('a * 'b list) * 'b -> 'a * 'b list

To ensure that head always succeeds on a non-empty queue, we must maintain the invariant that f is only ever empty if r is also empty. When f is exhausted, we reverse r to replenish it. This needs to happen in tail and snoc.

Functional queues

- fun head (x::f, r) = x               
  fun checkf ([], r) = (rev r, [])   
    | checkf q = q
  fun snoc ((f, r), x) = checkf (f, x::r)  
  fun tail (x::f, r) = checkf (f, r);
> val ('a, 'b) head = fn : 'a list * 'b -> 'a
  val 'a checkf = fn : 'a list * 'a list -> 'a list * 'a list
  val 'a snoc = fn : ('a list * 'a list) * 'a -> 'a list * 'a list
  val 'a tail = fn : 'a list * 'a list -> 'a list * 'a list

head obviously runs in O(1) time, but in the worst case, tail and snoc could take O(n) time.

The worst case doesn't give a realistic picture. If we look at the amortized time bound, we see that the n operations a snoc takes in its worst case can only happen after n times with the O(1) case. Similarly, the n operations a tail takes in its worst case ensures that the next n tail operations will be O(1).

All operations on functional queues run in O(1) amortized time.

Insertion sort

Recall the insertinorder function, called insert here in this version for reals. It inserts a new element into the proper place in a sorted list

- fun insert (elt : real, []) = [elt]
    | insert (elt, lst as x::xs) =
        if elt < x then elt :: lst
        else x :: insert (elt, xs);
> val insert = fn : real * real list -> real list

We can use insert to implement an insertion sort

- val insort = foldl insert [];
> val insort = fn : real list -> real list

Insertion sort takes O(n2) comparisons in the average case and in the worst case.

Quick sort

Quick sort works by picking a pivot value and partitioning the list into two lists: values less than the pivot and values greater or equal to the pivot. The sort is applied recursively to the two partitions and the resulting (sorted) lists are concatenated together.

With help from the List.partition functional, which partitions a list according to a predicate function, quick sort is very elegant and clear

- fun qsort [] = [] : real list
    | qsort (x::xs) =
        case (List.partition (fn y => y < x) xs) of
          (left, right) => qsort left @ x :: qsort right;
> val qsort = fn : real list -> real list

Quick sort takes O(n log n) comparisons on average and O(n2) in the worst case, depending on how well the pivot values are chosen. Quick sort is excellent for imperative languages because it can sort an array in-place, but in functional languages the merge sort tends to do better.

Merge sort

Merge sort is another divide-and-conquer sorting algorithm. It works by dividing the input into two random groups, sorting them, then merging them in order. Merging two lists is an O(n) operation on the length of the resulting list

We use the basis functions List.take and List.drop which take and drop the first n elements of a list, respectively

- List.take;
> val 'a it = fn : 'a list * int -> 'a list
- List.drop;
> val 'a it = fn : 'a list * int -> 'a list

Merge sort

Note that this implementation is top-down: it completely sorts one half of the list before starting the other half.

- 
fun merge xs [] = xs : real list
  | merge [] ys = ys
  | merge (xlst as x::xs) (ylst as y::ys) =
      if x < y then x :: merge xs ylst
      else y :: merge xlst ys

fun msort [] = []
  | msort [x] = [x]
  | msort lst = let val k = length lst div 2
                in merge (msort (List.take (lst, k)))
                         (msort (List.drop (lst, k)))
                end;
> val merge = fn : real list -> real list -> real list
  val msort = fn : real list -> real list

Merge sort takes O(n log n) comparisons on average and in the worst case.

A bottom-up approach is also possible: starting with individual elements and merging them into larger and larger lists until the sort is complete. The top-down approach is often faster on large lists because of cache effects.