Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

OCaml  Module 6 – Standard library collections

Stacks, queues, priority queues and buffers

Use OCaml Stack, Queue and 5.4 Pqueue, run Dijkstra with a min-priority queue, grow strings with Buffer instead of ^, and pick a container from its cost table.

  • Intermediate
  • 15 minutes
  • Examples run with ocaml 5.5.1, opam 2.6.0, dune 3.24.2 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Use Stack, Queue and OCaml's 5.4 Pqueue module
  • Implement Dijkstra's shortest paths with Pqueue.MakeMin
  • Build strings with Buffer and measure its advantage over repeated concatenation
  • Choose a container from its cost table

Before you start

What you’ll learn · Before you start

What you will learn

  • Use Stack, Queue and OCaml's 5.4 Pqueue module
  • Implement Dijkstra's shortest paths with Pqueue.MakeMin
  • Build strings with Buffer and measure its advantage over repeated concatenation
  • Choose a container from its cost table

Before you start

On this page

Building a 10,000-line report by joining strings with ^ allocated 80,393,105 words of memory; the same report built with a Buffer allocated 488,915, about 164 times fewer. The standard library keeps a mutable container for each shape of waiting work, and reaching for the wrong one is how a program that passes every small test falls over on real input.

You will use Stack, Queue and OCaml 5.4’s Pqueue, implement Dijkstra’s shortest travel times with a min-priority queue, build a long string with Buffer and measure what it saves over ^, and choose a container by reading its cost table.

A stack is last in, first out

A LIFO: Last in, first out: the order a stack serves items, where the most recently added item is removed first. serves the item added most recently. The OCaml Stack module is mutable: Stack.push x s adds x to the top in place and returns unit, and Stack.pop s removes and returns the top. Adding to an immutable list gives you a second list and leaves the first untouched; pushing changes the one stack you already hold. Push 10, 20 and 30, then pop them, and they come back 30, 20, 10, the reverse of the order they went in:

Interactive · step through it A stack: push 10, 20, 30, then pop them top first Push puts an item on top; pop takes the top item off.
Stack, bottom to top: 10.10top
  • being compared or looked at
  • being moved or written

Pseudo-code

  1. push(x): top ← top + 1; a[top] ← x
  2. pop(): if empty: error "underflow"; x ← a[top]; top ← top − 1; return x
  3. peek(): return a[top]

A stack of 1 item. Items go on and come off at the same end, the top: the last one in is the first one out (LIFO).

When it finishes: Empty stack.

Size 1
Operations 0

An empty stack has nothing to return, so Stack.pop raises Stack.Empty; Stack.pop_opt answers None instead, and Stack.top looks at the top without removing it. An OCaml Stack holds elements of one type, written 'a Stack.t. The toplevel session shows each of these, then compiles an editor whose undo and redo are two stacks:

Stack push, pop, the empty exception, and an undo and redo editor Python · stack_session.py
from session import run, toplevel, write

# A stack is last in, first out: the same pushes and pops as the AlgoViz stack figure, then the two exceptions, then
# an editor whose undo and redo are two stacks. Deterministic: no times, no addresses.

toplevel("""
let s = Stack.create ();;

Stack.push 10 s; Stack.push 20 s; Stack.push 30 s; Stack.length s;;

Stack.top s;;

Stack.pop s;;

Stack.pop s;;

Stack.pop s;;

Stack.pop s;;

Stack.pop_opt s;;
""")

write("undo_redo.ml", """let undo : string Stack.t = Stack.create ()
let redo : string Stack.t = Stack.create ()
let doc = ref ""

let show label =
  Printf.printf "%-13s doc=%-14S undo=%d redo=%d\\n"
    label !doc (Stack.length undo) (Stack.length redo)

let typ s =
  Stack.push !doc undo;   (* remember the document before the edit *)
  Stack.clear redo;       (* a fresh edit cancels anything to redo *)
  doc := !doc ^ s;
  show (Printf.sprintf "type %S" s)

let undo_one () =
  match Stack.pop_opt undo with
  | None -> print_string "nothing to undo\\n"
  | Some prev -> Stack.push !doc redo; doc := prev; show "undo"

let redo_one () =
  match Stack.pop_opt redo with
  | None -> print_string "nothing to redo\\n"
  | Some next -> Stack.push !doc undo; doc := next; show "redo"

let () =
  show "start";
  typ "hello";
  typ " world";
  undo_one ();
  undo_one ();
  redo_one ()
""")

run("""
ocamlfind ocamlopt undo_redo.ml -o undo_redo
./undo_redo
""")

Output

$ ocaml
OCaml version 5.5.1
Enter #help;; for help.

# let s = Stack.create ();;
val s : '_weak1 Stack.t = <abstr>
# 
  Stack.push 10 s; Stack.push 20 s; Stack.push 30 s; Stack.length s;;
  - : int = 3
# 
  Stack.top s;;
  - : int = 30
# 
  Stack.pop s;;
  - : int = 30
# 
  Stack.pop s;;
  - : int = 20
# 
  Stack.pop s;;
  - : int = 10
# 
  Stack.pop s;;
  Exception: Stdlib.Stack.Empty.
# 
  Stack.pop_opt s;;
  - : int option = None
$ ocamlfind ocamlopt undo_redo.ml -o undo_redo
$ ./undo_redo
start         doc=""             undo=0 redo=0
type "hello"  doc="hello"        undo=1 redo=0
type " world" doc="hello world"  undo=2 redo=0
undo          doc="hello"        undo=1 redo=1
undo          doc=""             undo=0 redo=2
redo          doc="hello"        undo=1 redo=1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stack_session.py

The editor keeps the document in a ref: A mutable cell holding one value, created with ref v: !r reads its contents and r := v replaces them. It is a record with a single mutable field, contents. More in “Refs, loops and mutable state” and two stacks. Typing pushes the old document onto the undo stack and clears the redo stack, because a fresh edit cancels anything there was to redo. Undo pops the undo stack onto the redo stack; redo does the reverse. Follow the two stacks through two edits, two undos and one redo:

Interactive · step by step Undo and redo as two stacks Step through the edits. The document moves between the undo stack (the past) and the redo stack (the undone future), and a fresh edit empties the future.
Undo and redo as two stacks, step 6 of 6. Showing Document ("hello"), Undo stack (holds 1), Redo stack (holds 1). In focus: Document, Undo stack, Redo stack.Document"hello"Undo stackholds 1Redo stackholds 1pop into doc

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Start. The document is empty and both stacks are empty. Each edit will remember the document on the undo stack before it changes.
  2. Type hello. Typing pushes the old document onto the undo stack, then changes the document. The undo stack now holds one earlier version, the empty string.
  3. Type a space and world. A second edit pushes "hello" onto the undo stack and changes the document again. The undo stack holds two earlier versions now.
  4. Undo. Undo pops the top of the undo stack back into the document, and pushes the version it replaced onto the redo stack, so a redo can bring it back.
  5. Undo again. A second undo empties the undo stack and leaves the document empty. The redo stack now holds both undone versions, newest on top.
  6. Redo. Redo pops the top of the redo stack into the document and pushes the replaced version back onto the undo stack. One step remains in each direction.

After typing hello and world, the undo stack held two earlier documents. Two undos emptied it and left the redo stack holding two, and a single redo moved one back, so the document returned to hello with one step left to undo and one to redo.

Forgetting to clear redo on a fresh edit

Wrong: leaving the redo stack untouched when the user types after an undo, so a later redo brings back text from a branch the user abandoned. Right: Stack.clear redo on every edit, so redo only ever replays undos that have not been overwritten.

That single Stack.clear is the difference between a redo the user can trust and one that resurrects text from a branch they had left behind.

Key idea

A stack hands back the most recently pushed item, so two stacks model undo and redo: one holds the past, the other the undone future, and an edit moves a document between them and clears the future.

A queue is first in, first out

A FIFO: First in, first out: the order a queue serves items, where the item that has waited longest is removed first. serves the item that has waited longest. The OCaml Queue module is mutable too: Queue.add x q adds x at the back, Queue.take q removes and returns the front, and Queue.peek q looks at the front without removing it. Queues keep their arrival order, so adding 1, 2 and 3 and taking three times gives 1, 2, 3, the order they arrived:

Interactive · step through it A queue: add 1, 2, 3, peek the front, then take them in order Enqueue adds at the rear; dequeue takes from the front.
Queue, front to rear: 1 2 3.123frontrear
  • being compared or looked at

Pseudo-code

  1. enqueue(x): add x at the rear
  2. dequeue(): if empty: error "underflow"; remove and return the front item
  3. peek(): return the front item

A queue of 3 items. Items join at the rear and leave from the front: the first one in is the first one out (FIFO).

When it finishes: Empty queue.

Size 3
Operations 0

Taking from an empty queue raises Queue.Empty, and Queue.take_opt returns None instead. A round-robin scheduler uses a queue to give each task one time slice in turn: take the task at the front, run one slice, and if it still has work, add it to the back so the others go before it comes round again. The session shows both:

Queue add, take, the empty exception, and a round-robin scheduler Python · queue_session.py
from session import run, toplevel, write

# A queue is first in, first out: the same enqueues and takes as the AlgoViz queue figure, the empty-queue exception
# and take_opt, then a round-robin scheduler that gives each task one time slice in turn. Deterministic.

toplevel("""
let q = Queue.create ();;

Queue.add 1 q; Queue.add 2 q; Queue.add 3 q; Queue.length q;;

Queue.peek q;;

Queue.take q;;

Queue.take q;;

Queue.take q;;

Queue.take q;;

Queue.take_opt q;;
""")

write("round_robin.ml", """(* Each task needs a number of time slices; the scheduler gives one slice to the
   task at the front, then puts it back if it still has work. *)
let q : (string * int) Queue.t = Queue.create ()

let () =
  List.iter (fun job -> Queue.add job q) [ "build", 3; "lint", 1; "test", 2 ];
  let slice = ref 0 in
  while not (Queue.is_empty q) do
    let name, left = Queue.take q in
    incr slice;
    Printf.printf "slice %d: %s (%d left)\\n" !slice name (left - 1);
    if left - 1 > 0 then Queue.add (name, left - 1) q
  done;
  Printf.printf "finished after %d slices\\n" !slice
""")

run("""
ocamlfind ocamlopt round_robin.ml -o round_robin
./round_robin
""")

Output

$ ocaml
OCaml version 5.5.1
Enter #help;; for help.

# let q = Queue.create ();;
val q : '_weak1 Queue.t = <abstr>
# 
  Queue.add 1 q; Queue.add 2 q; Queue.add 3 q; Queue.length q;;
  - : int = 3
# 
  Queue.peek q;;
  - : int = 1
# 
  Queue.take q;;
  - : int = 1
# 
  Queue.take q;;
  - : int = 2
# 
  Queue.take q;;
  - : int = 3
# 
  Queue.take q;;
  Exception: Stdlib.Queue.Empty.
# 
  Queue.take_opt q;;
  - : int option = None
$ ocamlfind ocamlopt round_robin.ml -o round_robin
$ ./round_robin
slice 1: build (2 left)
slice 2: lint (0 left)
slice 3: test (1 left)
slice 4: build (1 left)
slice 5: test (0 left)
slice 6: build (0 left)
finished after 6 slices

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queue_session.py

Three tasks needing 3, 1 and 2 slices finished in six slices, each task stepping to the back after its turn: build, lint, test, build, test, build. A stack would have run one task to completion before starting the next; the queue shares the machine fairly because the longest waiter always goes first.

Stacks and queues are unsynchronized

The manual calls an unsynchronized access to a stack or a queue “a programming error”: two domains, OCaml 5’s parallel workers, using one without a lock can leave it in an invalid state. Guard a shared stack or queue with a Mutex, or give each domain its own.

Checkpoint

Check: A stack is last in, first out · A queue is first in, first out

  1. Question 1 of 2 You push 1, then 2, then 3 onto an empty stack, then pop twice. What do the two pops return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 3, then 2

    push adds at the top and pop removes from the top, so the last item in is the first out. After pushing 1, 2, 3 the top is 3, then 2.

  2. Question 2 of 2 What does Queue.take do when the queue is empty?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises Queue.Empty

    take must return an element, so on an empty queue it raises Queue.Empty. Use take_opt when None is the answer you want.

A priority queue hands back the smallest first

A list keeps arrival order and a sorted structure keeps every element in order; a priority queue: A collection that hands back the item of highest priority; OCaml's Pqueue serves the smallest (MakeMin) or largest (MakeMax) element under a compare function. keeps only enough order to answer one question fast: what is the smallest element right now? OCaml’s Pqueue, new in 5.4, provides it. Pqueue.MakeMin builds a min-priority queue over a total order: An ordering in which any two values are either equal or one is smaller than the other, the answer agrees whichever way round they are compared, and it is transitive: if a < b and b < c then a < c. Map.Make and Set.Make need a compare that is one. More in “Maps and sets” type, and Pqueue.MakeMax a max one. The module stores its elements as a A tree kept in an array where every parent is at most (a min-heap) or at least (a max-heap) its children, so the extreme element is always at the root. in a dynamic array: A collection that keeps its elements in an array and replaces that array with a larger one when it fills, so elements can be appended without a length being fixed in advance; OCaml's is Dynarray. More in “Arrays, Dynarray and immutable arrays”, so min_elt is constant time while add and pop_min take time logarithmic in the size. Watch the min-heap take shape as five numbers are inserted, the smallest climbing to the root:

Interactive · step through it Inserting 5, 3, 8, 1, 9 into a min-heap, smallest at the root Each key goes into the next free slot and climbs while its parent is larger.
Heap as an array: empty.empty treearray:
  • being compared or looked at
  • being moved or written
  • in its final place

Pseudo-code

  1. insert(x): a[size] ← x; i ← size; size ← size + 1
  2. while i > 0 and a[parent(i)] > a[i]:
  3. swap a[i] and a[parent(i)]; i ← parent(i)
  4. parent(i) = ⌊(i − 1) / 2⌋

Insert 5 keys into a min-heap: every parent is at most its children, so the smallest key is always at the root, a[0].

When it finishes: Heap array: 1 3 8 5 9.

Comparisons 0
Swaps 0

The same five numbers go into a Pqueue.MakeMin (Int) queue below. of_list builds the queue in linear time, faster than adding one at a time; min_elt returns the smallest without removing it, and pop_min removes and returns it. The manual guarantees that min_elt names exactly the element pop_min will remove. On an empty queue min_elt and pop_min return None, while get_min_elt raises Invalid_argument:

MakeMin: of_list, min_elt, pop_min, length, and the empty queue Python · pqueue_session.py
from session import toplevel

# A priority queue always hands back the smallest. MakeMin (Int) shows the API the curriculum names (create, add,
# min_elt, pop_min, length, of_list), that min_elt names the element pop_min removes, the empty cases, and the
# tuple-of-pops evaluation-order trap with its let fix. The signature printed once lists the whole module.

toplevel("""
module Pq = Pqueue.MakeMin (Int);;

let q = Pq.of_list [ 5; 3; 8; 1; 9 ];;

Pq.length q;;

Pq.min_elt q;;

Pq.pop_min q;;

Pq.pop_min q;;

Pq.length q;;

let empty = Pq.create () in Pq.min_elt empty, Pq.pop_min empty;;

let empty = Pq.create () in Pq.get_min_elt empty;;

let q2 = Pq.of_list [ 5; 3; 8; 1; 9 ] in
(Pq.pop_min q2, Pq.pop_min q2, Pq.length q2);;

let q3 = Pq.of_list [ 5; 3; 8; 1; 9 ] in
let a = Pq.pop_min q3 in
let b = Pq.pop_min q3 in
let n = Pq.length q3 in
(a, b, n);;
""")

Output

$ ocaml
OCaml version 5.5.1
Enter #help;; for help.

# module Pq = Pqueue.MakeMin (Int);;
module Pq :
  sig
    type t = Pqueue.MakeMin(Int).t
    val create : unit -> t
    val length : t -> int
    val is_empty : t -> bool
    val add : t -> Int.t -> unit
    val add_iter : t -> ((Int.t -> unit) -> 'x -> unit) -> 'x -> unit
    val min_elt : t -> Int.t option
    val get_min_elt : t -> Int.t
    val pop_min : t -> Int.t option
    val remove_min : t -> unit
    val clear : t -> unit
    val copy : t -> t
    val of_array : Int.t array -> t
    val of_list : Int.t list -> t
    val of_iter : ((Int.t -> unit) -> 'x -> unit) -> 'x -> t
    val iter_unordered : (Int.t -> unit) -> t -> unit
    val fold_unordered : ('acc -> Int.t -> 'acc) -> 'acc -> t -> 'acc
  end
# 
  let q = Pq.of_list [ 5; 3; 8; 1; 9 ];;
  val q : Pq.t = <abstr>
# 
  Pq.length q;;
  - : int = 5
# 
  Pq.min_elt q;;
  - : Int.t option = Some 1
# 
  Pq.pop_min q;;
  - : Int.t option = Some 1
# 
  Pq.pop_min q;;
  - : Int.t option = Some 3
# 
  Pq.length q;;
  - : int = 3
# 
  let empty = Pq.create () in Pq.min_elt empty, Pq.pop_min empty;;
  - : Int.t option * Int.t option = (None, None)
# 
  let empty = Pq.create () in Pq.get_min_elt empty;;
  Exception: Invalid_argument "empty priority queue".
# 
  let q2 = Pq.of_list [ 5; 3; 8; 1; 9 ] in
  (Pq.pop_min q2, Pq.pop_min q2, Pq.length q2);;
    - : Int.t option * Int.t option * int = (Some 3, Some 1, 5)
# 
  let q3 = Pq.of_list [ 5; 3; 8; 1; 9 ] in
  let a = Pq.pop_min q3 in
  let b = Pq.pop_min q3 in
  let n = Pq.length q3 in
  (a, b, n);;
          - : Int.t option * Int.t option * int = (Some 1, Some 3, 3)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pqueue_session.py

pop_min returned 1 then 3, the two smallest, and the length fell from 5 to 3. For a job scheduler the element is usually a (priority, payload) pair, with a compare that looks only at the priority, so two jobs of equal priority are treated as equal. The manual says nothing about which of two equal-priority elements leaves first, so never rely on that order. The next program drains such a queue one pop at a time, and takes the top three scores from a MakeMax queue:

A (priority, payload) queue and a MakeMax queue for top scores Python · jobs_session.py
from session import run, write

# (priority, payload) pairs: a compare that looks only at the priority, draining the queue one pop at a time (a match,
# never a tuple or list, so the pops run in order), and a MakeMax queue. Equal priorities come out in an order the
# manual does not fix, so the two priority-5 jobs may appear either way round.

write("jobs.ml", """(* The element is (priority, payload); the compare ignores the payload. *)
module Jq = Pqueue.MakeMin (struct
  type t = int * string
  let compare (p1, _) (p2, _) = Int.compare p1 p2
end)

let () =
  let jq = Jq.of_list [ (5, "mail"); (3, "backup"); (5, "cleanup"); (1, "alert") ] in
  let rec drain () =
    match Jq.pop_min jq with
    | None -> ()
    | Some (p, name) ->
        Printf.printf "run %s (priority %d)\\n" name p;
        drain ()
  in
  drain ()

module Top = Pqueue.MakeMax (Int)

let () =
  let scores = Top.of_list [ 71; 88; 64; 95; 88 ] in
  let rec take n =
    if n = 0 then []
    else match Top.pop_max scores with
      | None -> []
      | Some s -> s :: take (n - 1)
  in
  Printf.printf "top three scores: %s\\n"
    (String.concat ", " (List.map string_of_int (take 3)))
""")

run("""
ocamlfind ocamlopt jobs.ml -o jobs
./jobs
""")

Output

$ ocamlfind ocamlopt jobs.ml -o jobs
$ ./jobs
run alert (priority 1)
run backup (priority 3)
run cleanup (priority 5)
run mail (priority 5)
top three scores: 95, 88, 88

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 jobs_session.py

The jobs ran in priority order: the alert (1), then the backup (3), then the two priority-5 jobs, and the manual does not promise which of those two goes first. A Pqueue keeps its heap in a Dynarray, whose manual calls unsynchronized access a programming error, so a shared priority queue needs a lock just as a stack or a queue does. The MakeMax queue gave the top three scores as 95, 88 and 88: a repeated score counts once per entry.

Do not read several pops in one tuple

OCaml does not fix the order in which the parts of a tuple are evaluated, so (Pq.pop_min q, Pq.pop_min q, Pq.length q) read the length before either pop ran and returned the two pops the wrong way round. Sequence the effects with a chain of let bindings, as the session’s fix does and as evaluation order and scope explains.

Sequenced with let, the pops happen in the order you read them, and the queue’s own behaviour is simple to reason about.

Key idea

A priority queue answers “what is the smallest?” in constant time and removes it in logarithmic time; put the priority first in a pair and compare only that, and treat equal priorities as having no guaranteed order.

Shortest travel times with a priority queue

Dijkstra’s algorithm finds the shortest path from one place to every other, and a min-priority queue is the piece that makes it fast: it always hands back the nearest city not yet settled. The queue holds (distance so far, city) pairs and compares only the distance. The program runs on a small made-up network of Indian cities, with invented travel times in whole hours; the two-letter codes are DL Delhi, JP Jaipur, AG Agra, LK Lucknow, BP Bhopal, NG Nagpur and HY Hyderabad. Step through the same graph the program uses, settling the closest unsettled city each round:

Interactive · step through it Dijkstra from Delhi on a made-up seven-city network Settle the closest unsettled node, then relax its edges; the table keeps every distance.
Graph of 7 nodes and 10 edges. queue (by dist): DL 0.54496685117AG∞BP∞DL0HY∞JP∞LK∞NG∞queue (by dist):DL 0
  • being compared or looked at
  • being moved or written
  • in its final place
  • the key, pivot or target
  • visited or ruled out

Pseudo-code

  1. dist(start) ← 0; dist(v) ← ∞ for every other v; add start to the queue
  2. while the queue is not empty:
  3. u ← the queued node with the smallest dist; settle u
  4. for each edge u → v with weight w:
  5. if dist(u) + w < dist(v): dist(v) ← dist(u) + w; prev(v) ← u; queue v
Distances found so far
Nodedistprev
AG∞—
BP∞—
DL0—
HY∞—
JP∞—
LK∞—
NG∞—

Shortest distances from DL. Every node starts at ∞ except DL at 0. Each round settles the queued node with the smallest distance: with no negative weights, no later path can beat it.

When it finishes: AG: 4, BP: 10, DL: 0, HY: 21, JP: 5, LK: 10, NG: 15.

Settled 0
Relaxations 0

Each round pops the nearest city, settles its distance and adds its neighbours with their new tentative distances. The program prints the cities as it settles them and then the final times:

Dijkstra's shortest travel times with Pqueue.MakeMin Python · dijkstra_session.py
from session import run, write

# Dijkstra's shortest paths with Pqueue.MakeMin, on the SAME made-up graph of Indian cities the AlgoViz dijkstra
# figure runs. Travel times are invented, in whole hours. Two-letter codes: DL Delhi, JP Jaipur, AG Agra, LK Lucknow,
# BP Bhopal, NG Nagpur, HY Hyderabad. Output is deterministic (cities are settled in a fixed order).

write("dijkstra.ml", """(* An element of the priority queue: (distance so far, city). The compare looks
   only at the distance, so the queue always hands back the nearest unsettled city. *)
module Frontier = Pqueue.MakeMin (struct
  type t = int * string
  let compare (d1, _) (d2, _) = Int.compare d1 d2
end)

(* The made-up road network: each edge is undirected, the weight a travel time in hours. *)
let roads =
  [ "DL", "JP", 5; "DL", "AG", 4; "JP", "AG", 4; "JP", "BP", 9;
    "AG", "LK", 6; "AG", "BP", 6; "LK", "BP", 8; "BP", "NG", 5;
    "BP", "HY", 11; "NG", "HY", 7 ]

let neighbours city =
  List.concat_map
    (fun (a, b, w) ->
      if a = city then [ (b, w) ] else if b = city then [ (a, w) ] else [])
    roads

let dijkstra start =
  let best = Hashtbl.create 16 in
  let q = Frontier.create () in
  Frontier.add q (0, start);
  let rec loop () =
    match Frontier.pop_min q with
    | None -> ()
    | Some (d, city) ->
        if not (Hashtbl.mem best city) then begin
          Hashtbl.add best city d;
          Printf.printf "settled %s at %2d h\\n" city d;
          List.iter
            (fun (next, w) ->
              if not (Hashtbl.mem best next) then Frontier.add q (d + w, next))
            (neighbours city)
        end;
        loop ()
  in
  loop ();
  best

let () =
  let best = dijkstra "DL" in
  print_string "shortest times from Delhi:\\n";
  List.iter
    (fun city -> Printf.printf "  %s %2d h\\n" city (Hashtbl.find best city))
    [ "AG"; "JP"; "LK"; "BP"; "NG"; "HY" ]
""")

run("""
ocamlfind ocamlopt dijkstra.ml -o dijkstra
./dijkstra
""")

Output

$ ocamlfind ocamlopt dijkstra.ml -o dijkstra
$ ./dijkstra
settled DL at  0 h
settled AG at  4 h
settled JP at  5 h
settled LK at 10 h
settled BP at 10 h
settled NG at 15 h
settled HY at 21 h
shortest times from Delhi:
  AG  4 h
  JP  5 h
  LK 10 h
  BP 10 h
  NG 15 h
  HY 21 h

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dijkstra_session.py

Delhi settled at 0, Agra at 4 and Jaipur at 5; Lucknow and Bhopal both at 10, and the priority queue settled whichever it popped first without the result depending on that choice; then Nagpur at 15 and Hyderabad at 21. A city can be added to the queue more than once with different distances, so the program keeps the settled distances in a Hashtbl and ignores a city it has already settled when it pops a stale, larger entry. Ignoring is safe because a travel time is never negative: the first time a city comes off the queue, no route still waiting can reach it sooner. That is simpler than searching the queue for an entry to update, which Pqueue has no operation for: the program only ever adds entries and pops the smallest.

Checkpoint

Check: A priority queue serves the smallest first · Dijkstra with a min-priority queue

  1. Question 1 of 2 A Pqueue.MakeMin (Int) queue is built from [4; 1; 7] with of_list. What do two calls to pop_min return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Some 1, then Some 4

    MakeMin keeps the smallest at the root, so pop_min returns 1, then 4. of_list builds the heap in linear time but imposes the same min order.

  2. Question 2 of 2 In the Dijkstra program a city can be added to the queue more than once. Why does it ignore a city it pops that is already settled?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first time a city is popped it has its shortest distance, so later, larger entries for it are stale

    With no negative weights, the smallest tentative distance popped is final, so the first time a city is settled is correct and later, larger entries for it can be skipped.

Building a long report without recopying it

Joining strings with ^ makes a brand-new string every time, copying both sides, so building a report line by line with acc := !acc ^ line copies the whole report so far on every line. The cost grows with the square of the number of lines. A Buffer is a mutable, growing byte store: Buffer.add_string writes into the space it already has and only occasionally grows, which the manual gives as constant amortised cost: The average cost of one operation over a long run of them: a Dynarray append may copy every element when the array fills, yet n appends copy about 2n elements in all, so each append costs a constant on average. More in “Arrays, Dynarray and immutable arrays” time per character. The program builds a 10,000-line report both ways and measures the words each allocates, counting allocation rather than time so two runs print the same numbers:

Building a 10,000-line report with ^ and with Buffer, words counted Python · buffer_session.py
from session import run, write

# Building a long string: repeated ^ copies the whole text so far on every line, so its cost grows with the square of
# the number of lines; Buffer writes into one growing byte store. We measure words allocated (Gc.allocated_bytes,
# which counts the large strings that skip the minor heap), never time, so two runs print the same lines.

write("report.ml", """let lines = 10_000

let row i = Printf.sprintf "row %d: ok\\n" i

(* Words allocated while f runs, from the GC's own total counter. *)
let words f =
  let before = Gc.allocated_bytes () in
  let r = f () in
  (r, int_of_float ((Gc.allocated_bytes () -. before) /. 8.))

let with_concat () =
  let acc = ref "" in
  for i = 1 to lines do
    acc := !acc ^ row i
  done;
  !acc

let with_buffer () =
  let b = Buffer.create 256 in
  for i = 1 to lines do
    Buffer.add_string b (row i)
  done;
  Buffer.contents b

let () =
  let s1, w1 = words with_concat in
  let s2, w2 = words with_buffer in
  Printf.printf "lines:            %d\\n" lines;
  Printf.printf "same report:      %b (%d bytes)\\n" (s1 = s2) (String.length s1);
  Printf.printf "concat allocated: %d words\\n" w1;
  Printf.printf "buffer allocated: %d words\\n" w2;
  Printf.printf "concat / buffer:  %d times\\n" (w1 / w2)
""")

run("""
ocamlfind ocamlopt report.ml -o report
./report
""")

Output

$ ocamlfind ocamlopt report.ml -o report
$ ./report
lines:            10000
same report:      true (128894 bytes)
concat allocated: 80393105 words
buffer allocated: 488915 words
concat / buffer:  164 times

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 buffer_session.py

Both ways produced the identical 128,894-byte report, but the ^ version allocated 80,393,105 words against the buffer’s 488,915, about 164 times more, almost all of it thrown away at once as each longer intermediate string replaced the last. The earlier strings lesson introduces Buffer and Bytes for text that changes in place; here the point is the cost: buffers turn a quadratic pile of copies into a single growing store.

Growing a string with ^ in a loop

Wrong: let acc = ref "" in List.iter (fun line -> acc := !acc ^ line) lines, which recopies everything on every line. Right: a Buffer, Buffer.add_string b line in the loop and Buffer.contents b at the end, or String.concat over a list built once.

The report was identical both ways, so the only thing the buffer changed was the cost of building it.

Key idea

Repeated ^ recopies the whole string on every join, so assembling text in a loop costs time and allocation that grow with the square of the length; a Buffer writes into one store and stays linear.

Choosing a container from its cost table

The standard library’s own “Comparison of Standard Containers” tabulates what each operation costs, and the choice of container follows from which operation a task does most. The four mutable containers of this lesson each do their own job in constant or near-constant time, as the page and the modules’ manuals state:

Container Add Serve or read Costs the most on
Stack push, O(1) pop, O(1), newest first nothing: push and pop are both O(1)
Queue add, O(1) take, O(1), oldest first nothing: both ends are O(1)
Priority queue add, O(log n) pop_min, O(log n); min_elt, O(1) every add and removal, by a log factor
Buffer add_char, O(1) amortised contents, reads the whole string one big copy when you ask for contents

Stacks, queues and priority queues serve one end only, and a buffer holds nothing but bytes (Buffer.nth reads one by position), so none of them is the container for a lookup by key or for general access by position. For those you want the containers of the earlier lessons: arrays and Dynarray for access by position, maps and sets for sorted keys, and hash tables for fast lookup by key. Answer two questions about your task and the table marks what fits:

Interactive · answer and compare Which mutable container fits your task? Answer two questions about how your task serves items and whether domains share the container. The table marks what fits and says why the others do not.
What decides which item you handle next?
Will more than one domain use it at the same time?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four mutable containers against how they add, serve, peek and share, with the costs the standard library documents
Container Add cost Serve cost Peek at next Share across domains
Stack Serves next: the most recently pushed item Good push, O(1) Good pop, O(1), newest first Good top, O(1) Poor unsynchronised: needs a Mutex
Queue Serves next: the item that has waited longest Good add, O(1) Good take, O(1), oldest first Good peek, O(1) Poor unsynchronised: needs a Mutex
Priority queue Serves next: the smallest or largest key Mixed add, O(log n) Mixed pop_min, O(log n) Good min_elt, O(1) Poor unsynchronised: needs a Mutex
Buffer Serves next: nothing: it builds one string Good add_char, O(1) amortised Poor no item to serve; read with contents Poor no front element Poor unsynchronised: needs a Mutex
How the answers decide
  • “What decides which item you handle next?” The one I added most recently: rules out Queue, Priority queue and Buffer. A stack serves the most recently pushed item; a queue serves the oldest, a priority queue serves by key, and a buffer serves no items.
  • “What decides which item you handle next?” The one that has waited longest: rules out Stack, Priority queue and Buffer. A queue serves the item that has waited longest; a stack would serve the newest instead.
  • “What decides which item you handle next?” The smallest or largest key: rules out Stack, Queue and Buffer. Only a priority queue orders by key, so it alone returns the smallest or largest first.
  • “What decides which item you handle next?” None: I am assembling one string: rules out Stack, Queue and Priority queue. A buffer accumulates one growing string in amortised constant time per character; it serves no items.
  • “Will more than one domain use it at the same time?” Yes, several domains: adds a caveat to Stack, Queue, Priority queue and Buffer. All four are mutable and unsynchronised, so use from several domains at once needs a Mutex or one container per domain.

In short: a stack when you want the most recent item back, a queue when you want the oldest, a priority queue when you want the smallest or largest key, and a buffer when you are assembling one string rather than serving items at all.

Checkpoint

Check: Buffer against repeated concatenation · Choosing a container from its cost table

  1. Question 1 of 2 You build an n-line report with acc := !acc ^ line in a loop. How does the total work grow with n?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Like n squared

    ^ makes a fresh string each time, copying both sides, so appending line by line copies the whole prefix every time and the total grows with the square of n. A Buffer stays linear.

  2. Question 2 of 2 A scheduler must always run the job with the highest priority next, from a set that keeps changing. Which container fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A priority queue

    Only a priority queue orders by key, so it returns the highest-priority job while letting you add new jobs cheaply.

Practise: stacks, queues, priority queues and buffers

These questions put new values through the lesson’s containers. Predict each result or cost first, then check it:

Practice that adapts to you

Practise this lesson

13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Which element does Stack.pop remove and return?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The one pushed most recently

    pop removes the top of the stack, which is the element pushed most recently.

  2. Question 2 of 13 An editor keeps undo and redo as two stacks. The user undoes twice, then types a new character. What must happen to the redo stack?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: It is cleared, because the new edit starts a fresh history

    A fresh edit after undo abandons the undone future, so the editor clears the redo stack; only the undo stack records the edit.

  3. Question 3 of 13 You add 1, 2, 3 to a queue and take three times. In what order do they come out?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: 1, 2, 3

    Queue.add appends at the back and Queue.take removes from the front, so the first in is the first out.

  4. Question 4 of 13 A round-robin scheduler holds three tasks needing 3, 1 and 2 slices. It gives one slice to the front task, then re-queues it if work remains. How many slices until all are done?

    Type a number.

    Show the answer: Question 4 of 13

    Answer: 6 slices

    Each slice does one unit of one task, and the total work is 3 + 1 + 2 = 6, so the queue empties after six slices.

  5. Question 5 of 13 What does min_elt return on a non-empty Pqueue.MakeMin queue, and does it change the queue?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The smallest element, as Some, without removing it

    min_elt returns Some of the smallest element and leaves the queue as it was; the manual guarantees it names the element pop_min would remove.

  6. Question 6 of 13 You store (priority, job) pairs in a Pqueue.MakeMin queue so that pop_min returns the most urgent job. What should the compare function look at?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Only the priority, the first element of the pair

    The element type is (priority, job); a compare that returns Int.compare on the priorities alone makes the queue serve the most urgent job.

  7. Question 7 of 13 Two jobs have the same priority in a Pqueue.MakeMin queue. What can you rely on about the order they are popped?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Nothing: the manual does not fix the order of equal-priority elements

    The Pqueue manual guarantees priority order but says nothing about ties, so equal-priority elements may be popped in any order, and code that needs a particular order among them cannot get it from the queue as it stands.

  8. Question 8 of 13 In the Dijkstra program, what does each element of the priority queue hold, and what is compared?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: A (distance so far, city) pair, compared on the distance

    The element is (distance, city) and the compare looks only at the distance, so pop_min returns the closest unsettled city, which Dijkstra needs.

  9. Question 9 of 13 Lucknow and Bhopal both end up 10 hours from Delhi. Does the answer depend on which one the queue settles first?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: No: both get their correct final distance whichever is settled first

    Dijkstra settles each city at its true shortest distance, so two cities at the same distance are both correct regardless of which the queue happens to pop first.

  10. Question 10 of 13 Why is joining many strings with ^ in a loop slow?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Each ^ builds a new string, copying both sides every time

    OCaml strings are immutable, so ^ cannot grow one in place: it makes a new string and copies both operands, which in a loop recopies the whole report over and over.

  11. Question 11 of 13 In the measured run, which allocated far more memory to build the same 10,000-line report, and roughly by how much?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: The ^ version, about 164 times more than the Buffer

    Both produced the same 128,894-byte report, but the ^ version allocated about 164 times as many words, almost all of it discarded intermediate strings.

  12. Question 12 of 13 You buffer events between a fast producer and a slower consumer, and must process them in arrival order. Which container fits?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: A queue

    Processing in arrival order is first in, first out, which is a queue; it adds and takes in constant time.

  13. Question 13 of 13 You need to read the 500th record of a collection of records directly, by its position. Which of this lesson's containers fits, and what should you use instead?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: None of them; use an array, which reads any position in O(1)

    A stack, a queue and a priority queue read only the end they serve, and a buffer holds only bytes, so none of them reads a middle record; an array (or Dynarray) gives O(1) access by index, as the earlier lesson shows.

Interview questions

Warm-up (fresher to 2–5 years): how do a stack and a queue differ, and when does each fit?

Both are mutable containers you add to and take from in constant time, and both keep their contents in one place rather than copying. They differ in which item they give back. A stack is last in, first out: Stack.pop returns the item pushed most recently, which suits undo histories, backtracking and depth-first search, where you want to finish the newest piece of work before older ones. A queue is first in, first out: Queue.take returns the item that has waited longest, which suits fair scheduling, buffering between a producer and a consumer, and breadth-first search, where every item should be handled in the order it arrived. Reach for a stack when newer work should pre-empt older, and a queue when older work should go first.

Two harder questions follow. For each one, decide what you would reach for and what you would measure first, then open its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A job runner stores (priority, job) pairs in a Pqueue.MakeMin queue, comparing on priority. Jobs of the same priority must run in the order they were submitted, but some run out of order. Explain why, and make equal priorities first-come-first-served.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Two OCaml 5 domains share a job queue that sits behind one Mutex: a producer adds jobs in bursts and a consumer takes them. The consumer polls the empty queue and keeps a whole core busy, and during a burst the queue grows until memory runs out. Design a bounded, blocking queue from the standard library.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How do a stack and a queue differ in what they give back?

    A stack returns the item pushed most recently, last in first out; a queue returns the one that has waited longest, first in first out. Both add and serve in constant time.

  2. How do two stacks model undo and redo, and what must every fresh edit do?

    One stack holds the past and the other the undone future. A fresh edit must Stack.clear redo, or redo brings back text from an abandoned branch.

  3. Which jobs suit a stack, and which a queue?

    A stack: undo histories, backtracking and depth-first search. A queue: fair scheduling, buffering between producer and consumer, and breadth-first search.

  4. What does OCaml 5.4's Pqueue provide, and at what cost?

    MakeMin serves the smallest element and MakeMax the largest; min_elt is constant time and add and pop_min logarithmic, with no promised order among equal priorities.

  5. Why did (Pq.pop_min q, Pq.pop_min q, Pq.length q) return the pops the wrong way round?

    OCaml does not fix the order in which a tuple's parts are evaluated. Sequence the pops with a chain of let bindings.

  6. How does Dijkstra's shortest-path method use a min-priority queue?

    It keeps (distance, city) pairs compared on distance, always takes the smallest, and skips a stale entry for a city that is already settled.

  7. What did a 10,000-line report cost when joined with ^, against a Buffer?

    80,393,105 words with ^ and 488,915 with a Buffer, about 164 times fewer, because each ^ recopies the whole string built so far.

  8. May two domains use one Stack or Queue without a lock?

    No: the manual calls unsynchronized access a programming error. Guard a shared one with a Mutex, or give each domain its own.

Key takeaways

  • A stack is last in, first out and a queue is first in, first out; both add and serve in constant time, and two stacks model an editor’s undo and redo while a queue schedules tasks round-robin.
  • A priority queue serves the smallest (MakeMin) or largest (MakeMax) element, with min_elt constant time and add and pop_min logarithmic, and it does not promise an order among equal priorities.
  • Dijkstra’s shortest paths use a min-priority queue of (distance, city) pairs compared on distance, and ignore a stale entry for a city that is already settled.
  • Building a 10,000-line report with ^ allocated about 164 times the words of a Buffer, because each ^ recopies the whole string so far while a buffer writes into one growing store.
  • Stacks and queues cost O(1) at their ends and a priority queue O(log n); none of them looks an element up by key or reads a middle position, so an array, a map or a hash table is the right choice when you need that.

Terms in this lesson

FIFO
First in, first out: the order a queue serves items, where the item that has waited longest is removed first.
LIFO
Last in, first out: the order a stack serves items, where the most recently added item is removed first.
priority queue
A collection that hands back the item of highest priority; OCaml's Pqueue serves the smallest (MakeMin) or largest (MakeMax) element under a compare function.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.