6.4. Queues

Unlike stacks, in which the elements added the last, come out first (last-in-first-out, LIFO), queues implement a complementary adding/removal strategy, known as first-in-first-out (FIFO), allowing to process their elements in the order they come.

6.4.1. The Queue interface

We can define an abstract data type for queues by means the following OCaml module signature:

module type Queue =
  sig
    type 'e t
    val mk_queue : int -> 'e t
    val is_empty : 'e t -> bool
    val is_full : 'e t -> bool
    val enqueue : 'e t -> 'e -> unit
    val dequeue : 'e t -> 'e option
    val queue_to_list : 'e t -> 'e list
  end

Indeed, one is at freedom to decide which functionality should be added to an ADT interface — a point we demonstrate by making the queue signature a bit more expressive, in terms of functionality it provides, than a stack interface.

As in the example of stacks, a queue of elements of type 'e is represented by an abstract parameterised type 'e t. Two methods, is_empty and is_full allow one to check whether it’s empty or full, correspondingly. enqueue and dequeue provide the main FIFO functionality of the queue: the former adds elements to the “back” of the queue object, while the latter removes elements from its “front”. Finally, the utility method queue_to_list transforms a current snapshot of the queue to an immutable OCaml list.

Similarly, to the stack ADT, queues defined by means of the Queue signature are mutable, i.e., functions enqueue and dequeue modify the contents of a queue in-place rather than create a new queue.

6.4.2. An Array-Based Queue

The following module implements a queue based on a finite-size array:

module ArrayBasedQueue : Queue =
  struct
    type 'e t = {
      elems : 'e option array;
      head : int ref;
      tail : int ref;
      size : int
    }
    let mk_queue sz = {
      elems = Array.make sz None;
      head = ref 0;
      tail = ref 0;
      size = sz
    }

    (* More functions come here *)

end

Since a queue, unlike stack, can be changed on both sides, “front” and “back”, the empty slots may appear both in the beginning and at the end of its carrier array. In order to utilise the array efficiently, we will engineer our concrete implementation, so it would “wrap” around and use the empty array cells in the beginning.

In our representation the head pointer points to the next element to be removed via dequeue, while tail points to the next array cell to install an element (unless the queue is full). This implementation requires some care in managing the head/tail references. For instance, both empty and fully packed queue are characterised by head and tail pointing to the same array cell:

let is_empty q =
  !(q.head) = !(q.tail) &&
  q.elems.(!(q.head)) = None

let is_full q =
  !(q.head) = !(q.tail) &&
  q.elems.(!(q.head)) <> None

The only difference is that in the case of the queue being full that cell, at which both head and tail point is occupied some element (and, hence, is not None), whereas it is None if the queue is empty.

Adding and removing elements to/from the queue is implemented in a way that takes the “wrapping” around logic into the account. For instance, enqueue checks whether the queue is full and whether the tail reference has reached the end of the array. In case if it has, but the queue still has slots to add elements, it “wraps around” by setting tail to be 0 (i.e., point to the beginning of the array):

let enqueue q e =
  if is_full q
  then raise (Failure "The queue is full!")
  else (
    let tl = !(q.tail) in
    q.elems.(tl) <- Some e;
    q.tail :=
      if tl = q.size - 1
      then 0
      else tl + 1)

Similarly, dequeue operates with the head pointer, wrapping it around in the case when it reaches the upper boundary of the array, but the queue is not yet empty:

let dequeue q =
  if is_empty q
  then None
  else (
    let hd = !(q.head) in
    let res = q.elems.(hd) in
    q.elems.(hd) <- None;
    q.head :=
      (if hd = q.size - 1
      then 0
      else hd + 1);
    res)

Finally, queue_to_list constructs the queue by considering two possibilities:

  • head reference points to the array slot less or equal than that of the tail reference, in which case it returns a sub-array enclosed between the two, and,

  • head reference points to the array slot greater than that of the tail reference, in which case it returns a concatenation of two sub-arrays, from the end and the beginning of the carrier array:

    let queue_to_list q =
      let hd = !(q.head) in
      let tl = !(q.tail) in
      if is_empty q then []
      else if hd < tl then
        List.map get_exn (subarray_to_list hd (tl + 1) q.elems)
      else
        let l1 = subarray_to_list hd q.size q.elems in
        let l2 = subarray_to_list 0 tl q.elems in
        List.map get_exn (l1 @ l2)
    

6.4.3. Debugging queue implementations

We can pring the content of a queue using the following module:

module QueuePrinter(Q: Queue) = struct

  let print_queue q pp =
    Printf.printf "[";
    List.iter (fun e ->
      Printf.printf "%s; " (pp e))
      (Q.queue_to_list q);
    Printf.printf "]\n"
  end

For instance, it can be instantiated as follows for printing queues of pairs of type int * string:

module ABQPrinter = QueuePrinter(ArrayBasedQueue)

let pp (k, v) = Printf.sprintf "(%d, %s)" k v

let print_kv_queue q = ABQPrinter.print_kv_queue q pp

Let us experiment with the queue by first creating it:

# open ArrayBasedQueue;;
# let q = mk_queue 10;;
val q : '_weak103 ArrayBasedQueue.t = <abstr>

We can then fill a queue from a randomly generater array a:

# let a = generate_key_value_array 10
# a;;
- : (int * string) array =
[|(7, "sapwd"); (3, "bsxoq"); (0, "lfckx"); (7, "nwztj"); (5, "voeed");
  (9, "jtwrn"); (8, "zovuq"); (4, "hgiki"); (8, "yqnvq"); (3, "gjmfh")|]
# for i = 0 to 9 do enqueue q a.(i) done;;
- : unit = ()
# print_kv_queue q;;
[(7, sapwd); (3, bsxoq); (0, lfckx); (7, nwztj); (5, voeed); (9, jtwrn); (8, zovuq); (4, hgiki); (8, yqnvq); (3, gjmfh); ]
- : unit = ()
# is_full q;;
- : bool = true

We can then start removing elements from the queue, checking that they come out in the same order as elements in the original array:

# dequeue q;;
- : (int * string) option = Some (7, "sapwd")
# dequeue q;;
- : (int * string) option = Some (3, "bsxoq")
# dequeue q;;
- : (int * string) option = Some (0, "lfckx")
# print_kv_queue q;;
[(7, nwztj); (5, voeed); (9, jtwrn); (8, zovuq); (4, hgiki); (8, yqnvq); (3, gjmfh); ]
- : unit = ()
# enqueue q (13, "lololo");;
- : unit = ()
# print_kv_queue q;;
[(7, nwztj); (5, voeed); (9, jtwrn); (8, zovuq); (4, hgiki); (8, yqnvq); (3, gjmfh); (13, lololo); ]
- : unit = ()
# dequeue q;;
- : (int * string) option = Some (7, "nwztj")

6.4.4. Doubly Linked Lists

  • File: DoublyLinkedList.ml

The obvious limitation of an array-based queue is its limited capacity, bounded by the size of the carrier array. To allow for the queue of an arbitrary size, we will need an auxiliary data structure, known as doubly-linked list.

A doubly-linked list is one of the most characteristic linked data structures, which aggressively employs OCaml’s references as its main building component, and can be efficiently implemented in other imperative programming languages, such as C and Java. As they embrace mutability, doubly-linked lists provide a variety of ways to modify their contents and structure by simply manipulating with the references and exploiting the indirection in data structure encoding.

Let us start the definition of a concrete module implementing the doubly-linked list data structure by defining its key components:

module DoublyLinkedList =
  struct
    type 'e dll_node = {
      value : 'e ref;
      prev  : 'e dll_node option ref;
      next  : 'e dll_node option ref
    }
    type 'e t = 'e dll_node option

    let mk_node e = {
      value = ref e;
      prev = ref None;
      next = ref None
    }

    (* More of implementation comes here *)
  end

The “elements” of doubly linked list (DLL) are represented by the 'e dll_node record type, which accounts for the possibility of them storing arbitrary data of type 'e as “payload”. In addition to payload, each node has references to other nodes, namely the “previous” and the “next” one in the list. As a node might not have a previous or a next one, and the predecessor/successor might change over time, the type of those fields is 'e dll_node option ref (a reference to an option containing a node of element of type 'e).

The function mk_node e creates a new “detached” node that contains an payload e, and has no designated predecessor/successor. Some other utility functions, allowing to refer to elements of a node, as well as to change a node’s payload, are as follows:

let prev n =  !(n.prev)
let next n =  !(n.next)
let value n = !(n.value)
let set_value n v = n.value := v

How do we construct a list out of those disparate nodes? The following two functions allow to insert new nodes before and after some other existing nodes, thus, updating the linked structure:

let insert_after n1 n2 =
  let n3 = next n1 in
  (match n3 with
   | Some n -> n.prev := Some n2
   | _ -> ());
  n2.next := n3;
  n1.next := Some n2;
  n2.prev := Some n1

let insert_before n1 n2 =
  let n0 = prev n2 in
  (match n0 with
   | Some n -> n.next := Some n1
   | _ -> ());
  n1.prev := n0;
  n1.next := Some n2;
  n2.prev := Some n1

Specifically the function insert_after n1 n2 inserts a node n2 after a node n1, “re-wiring” their both’s references to a predecessor/successor. Similarly, insert_before n1 n2 inserts n1 before n2. Using these two functions, one can update the structure of the list by inserting nodes in the middle of it (in contrast OCaml’s immutable lists only allow to insert/remove nodes at the head).

Warning

Both functions insert_after and insert_before make some implicit assumptions about the topology of the nodes, i.e., the set-up of the links. Specifically, when using insert_before n1 n2, one is assumed to be sure that n2 is not yet transitively reachable from n1, otherwise the resulting list might become circular. The same applies to insert_before n1 n2.

In a similar spirit, we can removing an arbitrary node from a DLL in \(O(1)\) time — something that would be impossible in an OCaml list (as it would require its traversal):

let remove n =
  (match prev n with
  | None -> ()
  | Some p -> p.next := next n);
  (match next n with
  | None -> ()
  | Some nxt -> nxt.prev := prev n);

Given an arbitrary node of a DLL, we can now “walk” forward/backwards by its predecessors/successors, in order to reach both ends of the list:

let rec move_to_head n =
   match prev n with
   | None -> None
   | Some m -> move_to_head m

We can use a similar walking logic to conver the “tail” of a double linked list to an ordinary OCaml list by walking by the successors:

let to_list_from n =
  let res = ref [] in
  let iter = ref (Some n) in
  while !iter <> None do
    let node = (get_exn !iter) in
    res := (value node) :: ! res;
    iter := next node
  done;
  List.rev !res

6.4.5. A queue based on doubly linked lists

  • File: Queues.ml (continued)

Let us now put doubly-linked lists to some good use and implement a queue that can grow arbitrarily large (or, at least, as large as one’s computer memory permits):

module DLLBasedQueue : Queue = struct
 open DoublyLinkedList

   type 'e t = {
     head : 'e dll_node option ref;
     tail : 'e dll_node option ref;
   }

   let mk_queue _sz =
     {head = ref None;
      tail = ref None}

   (*  More functions coming here *)

end

The queue is defined by means of holding two mutable references to (optional) nodes of a doubly-linked list, representing the head and the tail of the queue. The option accounts for the fact that the queue might be empty, which is the case for a freshly created instance (vai mk_queue _).

The emptyness of the queue can be checked by examining its head, and the is_full check now always returns false, as the queue may grow infinitely:

let is_empty q =
  !(q.head) = None

let is_full _q = false

Enqueueing an element is implemented by means of creating a new node and inserting it behind the tail (if it exists). Since mk_node always returns a new node, there is no risk of creating a circular DLL:

let enqueue q e =
  let n = mk_node e in
  (* Set the head *)
  (if !(q.head) = None
   then q.head := Some n);
  (* Extend the tail *)
  (match !(q.tail) with
   | Some t -> insert_after t n;
   | None -> ());
  q.tail := Some n

Dequeueing an element simply returns the payload of the node pointed to by head and moves the references to its successor:

let dequeue q =
  match !(q.head) with
  | None -> None
  | Some n ->
    let nxt = next n in
    q.head := nxt;
    remove n; (* This is not necessary, but helps GC *)
    Some (value n)

The removal of an node n on the penultimate line of dequeue is not necessary for the correctness of the data structure, but it helps to save the memory. To understand why it is essential, we need to know a bit about how Tracing Garbage Collector works in OCaml. While the garbage collection and automated memory management are outside of the scope of this course, let us just notice that not removing the node will make OCaml runtime treat it as being in use (as it is reachable from its successor), and hence keep it in memory, which could be otherwise used for something else.

A conversion to list is almost trivial, given the functionality of a doubly-linked list:

let queue_to_list q = match !(q.head) with
  | None -> []
  | Some n -> to_list_from n

Now, with this definition complete, we can do some experiments. First, as before, let us define a printer for the contents of the queue:

module DLQPrinter = QueuePrinter(DLLBasedQueue)

let pp (k, v) = Printf.sprintf "(%d, %s)" k v

let print_kv_queue q = DLQPrinter.print_kv_queue q pp

Finally, let us put and remove some elements from the queue:

# let dq = DLLBasedQueue.mk_queue 0;;
val dq : '_weak105 DLLBasedQueue.t = <abstr>
# let = a generate_key_value_array 10;;
- : (int * string) array =
[|(7, "sapwd"); (3, "bsxoq"); (0, "lfckx"); (7, "nwztj"); (5, "voeed");
  (9, "jtwrn"); (8, "zovuq"); (4, "hgiki"); (8, "yqnvq"); (3, "gjmfh")|]

Similarly to previous examples, we will up the queue from a randomly generated array:

# for i = 0 to 9 do enqueue dq a.(i) done;;
- : unit = ()
# print_kv_queue dq;;
[(7, sapwd); (3, bsxoq); (0, lfckx); (7, nwztj); (5, voeed); (9, jtwrn); (8, zovuq); (4, hgiki); (8, yqnvq); (3, gjmfh); ]
- : unit = ()

We can then ensure that the elements come out in the order they were added:

# is_empty dq;;
- : bool = false
# dequeue dq;;
- : (int * string) option = Some (7, "sapwd")
# dequeue dq;;
- : (int * string) option = Some (3, "bsxoq")
# dequeue dq;;
- : (int * string) option = Some (0, "lfckx")
# enqueue dq (13, "lololo");;
- : unit = ()
# print_kv_queue dq;;
[(7, nwztj); (5, voeed); (9, jtwrn); (8, zovuq); (4, hgiki); (8, yqnvq); (3, gjmfh); (13, lololo); ]
- : unit = ()