Example: Queues

Queue Signature

Queues and stacks are fairly similar interfaces. Here, for sake of example, we do something different with the interface that has nothing to do with the data structure is a stack or a queue: we choose to return an option from some functions instead of raising an exception. We could go back to our stack signature and re-code it in a similar way.

Which way to code it is in part a matter of taste:

  • The way we do it here, with options, ensures that surprising exceptions regarding empty queues never occur at run-time. The program is therefore more robust.

  • The way we did it before, with exceptions, means that programmers don't have to write as much code. If they are sure that an exception can't occur, they can omit the code for handling it.

There is thus a tradeoff between writing more code early (with options) or doing more debugging later (with exceptions).

module type Queue = sig
  (* An ['a queue] is a queue whose elements have type ['a]. *)
  type 'a queue

  (* The empty queue. *)
  val empty : 'a queue

  (* Whether a queue is empty. *)
  val is_empty : 'a queue -> bool

  (* [enqueue x q] is the queue [q] with [x] added to the end. *)
  val enqueue : 'a -> 'a queue -> 'a queue

  (* [peek q] is [Some x], where [x] is the element at the front of the queue,
     or [None] if the queue is empty. *)
  val peek : 'a queue -> 'a option

  (* [dequeue q] is [Some q'], where [q'] is the queue containing all the elements
     of [q] except the front of [q], or [None] if [q] is empty. *)
  val dequeue : 'a queue -> 'a queue option
end

Queue Implemented as a List

Here is an implementation of a functional queue data structure as a list:

module ListQueue : Queue = struct
  (* Represent a queue as a list.  The list [x1; x2; ...; xn] represents
     the queue with [x1] at its front, followed by [x2], ..., followed
     by [xn]. *)
  type 'a queue = 'a list

  let empty = []

  let is_empty q = q = []

  let enqueue x q = q @ [x] 

  let peek = function
    | [] -> None
    | x::_ -> Some x

  let dequeue = function
    | [] -> None
    | _::q -> Some q
end

Dequeueing is constant-time with this representation, but enqueueing is a linear-time operation. That's because dequeue does a single pattern match, whereas enqueue must traverse the entire list to put the new element at the end.

Queue Implemented with Two Lists

Here is a second, more efficient implementation of the Queue interface, using two lists to represent a single queue. This representation was invented by Robert Melville as part of his PhD dissertation at Cornell (Asymptotic Complexity of Iterative Computations, Jan 1981), which was advised by Prof. David Gries.

module TwoListQueue : Queue = struct
  (* [{front=[a;b]; back=[e;d;c]}] represents the queue
     containing the elements a,b,c,d,e. That is, the
     back of the queue is stored in reverse order. 
     [{front; back}] is in *normal form* if [front]
     being empty implies [back] is also empty. 
     All queues passed into or out of the module 
     must be in normal form. *)
  type 'a queue = {front:'a list; back:'a list}

  let empty = {front=[]; back=[]}

  let is_empty = function
    | {front=[]; back=[]} -> true
    | _ -> false

  (* Helper function to ensure that a queue is in normal form. *)
  let norm = function
    | {front=[]; back} -> {front=List.rev back; back=[]}
    | q -> q

  let enqueue x q = norm {q with back=x::q.back} 

  let peek = function 
    | {front=[]; _} -> None
    | {front=x::_; _} -> Some x

  let dequeue = function
    | {front=[]; _} -> None
    | {front=_::xs; back} -> Some (norm {front=xs; back})
end

With two-list queues, we now get a constant time enqueue operation: just cons a new element onto back. But dequeue is no longer just a simple pattern match: it has to call norm to ensure the queue it returns is in normal form. So it might seem as though dequeue no longer has constant time efficiency. Nonetheless, with an advanced algorithmic analysis technique (not covered here) called amortized analysis, it is possible to conclude that this implementation of dequeue is essentially constant time.

That efficiency comes at a price in readability, though. If we compare ListQueue and TwoListQueue, it's hopefully clear that ListQueue is a simple and correct implementation of a queue data structure. It's probably far less clear that TwoListQueue is a correct implementation!

Some of the exercises at the end of this chapter ask you to explore the efficiencies of these two implementations a bit further.

results matching ""

    No results matching ""