Using Trees, Dictionaries and Sets

« 15-210 SML Library Documentation

The library provides binary search trees, sorted dictionaries and sorted sets, in two layers. A binary search tree is the tree from lecture: you take it apart at its root using expose and build new trees using join_mid. A dictionary or set is built on a tree, but you use it through operations on keys and values, such as find, insert and union.

In each layer, a signature lists the operations and a functor builds a structure that has them. The library's functors build treaps, but code written against a signature, rather than a particular structure, is generic: it works with any structure that matches the signature. This page shows how to make each kind of structure and how to write generic code over it. The signature pages describe every operation in full.

Binary Search Trees

A tree holds key-value pairs. Its keys are distinct and increase from left to right, in the order given by Key.compare, where Key is the tree's key structure. Its values can have any type. The BST signature gives a tree four operations:

val size : 'a bst -> int
val expose : 'a bst -> 'a nodeview option

val empty : unit -> 'a bst
val joinMid : 'a bst * (Key.t * 'a) * 'a bst -> 'a bst

expose is the primary mechanism for writing generic code over BSTs. It returns a view of the root element: NONE for the empty tree, and otherwise SOME of a record holding the root's key and value and its two subtrees:

type 'a nodeview = { left : 'a bst,
                     key : Key.t,
                     value : 'a,
                     right : 'a bst }

joinMid (L, (k, v), R) works the other way, building one tree from a left subtree, a key-value pair and a right subtree. It requires every key of L to be less than k, and k to be less than every key of R. This is where a balanced tree does its balancing, so the result holds exactly those pairs but need not have k at its root.

Every other tree algorithm, from searching to splitting to taking a union, can be written with these four operations.

Making a Tree

MkTreap makes a tree structure from a key structure. The library provides key structures for the basic types, such as IntElt and StringElt. Trees are then built up from empty with joinMid:

structure Tree = MkTreap (structure Key = IntElt)

fun leaf (k, v) = Tree.joinMid (Tree.empty (), (k, v), Tree.empty ())
val t = Tree.joinMid (leaf (1, "a"), (2, "b"), leaf (3, "c"))

A treap also has split and join, described on the MkTreap page. They are not part of BST, so generic code cannot use them.

MkAugTreap makes an augmented tree, an AUG_BST. It also takes the type of the values, a function that combines them and its identity, and adds reducedVal, which works as it does for augmented dictionaries.

Generic Tree Code

Generic tree code is written as a functor that takes a structure matching BST. It can use the four operations and nothing else, so it works with any tree implementation. The usual shape is a recursion that exposes the tree, handles the empty and non-empty cases, and then recurs on the left or right subtree, or both.

functor MkMyTreeAlgorithms (structure Tree : BST) =
struct
  (* SOME v if t maps k to v, and NONE otherwise *)
  fun find (t : 'a Tree.bst) (k : Tree.Key.t) : 'a option =
    case Tree.expose t of
      NONE => NONE
    | SOME {left, key, value, right} =>
        case Tree.Key.compare (k, key) of
          LESS => find left k
        | EQUAL => SOME value
        | GREATER => find right k

  (* t with k mapped to v, replacing any value k already had *)
  fun insert (t : 'a Tree.bst, (k : Tree.Key.t, v : 'a)) : 'a Tree.bst =
    case Tree.expose t of
      NONE => Tree.joinMid (Tree.empty (), (k, v), Tree.empty ())
    | SOME {left, key, value, right} =>
        case Tree.Key.compare (k, key) of
          LESS => Tree.joinMid (insert (left, (k, v)), (key, value), right)
        | EQUAL => Tree.joinMid (left, (k, v), right)
        | GREATER => Tree.joinMid (left, (key, value), insert (right, (k, v)))
end

structure MyAlgorithms = MkMyTreeAlgorithms (structure Tree = Tree)

val t2 = MyAlgorithms.insert (t, (4, "d"))
val four = MyAlgorithms.find t2 4    (* SOME "d" *)

find only reads the tree, following one path down from the root. insert follows the same path, then rebuilds it with joinMid on the way back up. The new key always goes into the subtree where it belongs, so every joinMid gets the ordering it requires, and each one gives the tree a chance to rebalance.

Dictionaries and Sets

A dictionary maps keys to values, and a set is a collection of keys. Their keys are sorted by Key.compare, which gives meaning to operations such as first, next, split and getRange. The SORTED_DICT signature lists a dictionary's operations, and SORTED_SET lists a set's: the same operations without values, with contains in place of find.

Like everything in the library, dictionaries and sets are persistent. An operation such as insert returns a new dictionary and leaves its argument unchanged, so the old version remains valid and can still be used.

Making a Dictionary or Set

MkTreapDict and MkTreapSet take a key structure, as MkTreap does, and use ArraySequence for their sequences. The quickest way to fill a dictionary is fromSeq, which accepts pairs in any order and keeps the first value of a key that repeats:

structure Seq = ArraySequence
structure Dict = MkTreapDict (structure Key = IntElt)
structure Set = MkTreapSet (structure Key = IntElt)

(* Creates the dictionary {1 -> "a", 2 -> "b", 3 -> "c"} *)
val d = Dict.fromSeq (Seq.fromList [(3, "c"), (1, "a"), (3, "z"), (2, "b")])

(* Creates the set {1, 3, 5} *)
val s = Set.fromSeq (Seq.fromList [5, 1, 3, 1])

Generic Dictionary and Set Code

Generic dictionary code is written the same way, as a functor that takes a structure matching SORTED_DICT. Inside the functor the dictionary type is abstract: the code can use the signature's operations and nothing else, so it works with whichever dictionary the functor is applied to.

For example, a histogram counts how many times each key has occurred. It can be represented as a dictionary that maps each key to its count, with three operations: empty makes a histogram in which nothing has occurred, add records one more occurrence of a key, and count returns how many times a key has occurred, which is 0 if it never has:

functor MkHistogram (structure Dict : SORTED_DICT) =
struct
  type histogram = int Dict.dict

  fun empty () : histogram = Dict.empty ()

  (* h with one more occurrence of k *)
  fun add (h : histogram, k : Dict.Key.t) : histogram =
    case Dict.find h k of
      NONE => Dict.insert (h, (k, 1))
    | SOME n => Dict.insert (h, (k, n + 1))

  (* The number of times k has occurred in h *)
  fun count (h : histogram) (k : Dict.Key.t) : int =
    case Dict.find h k of
      NONE => 0
    | SOME n => n
end

Since insert replaces the value of a key that is already present, add looks up the old count first and then inserts one more. Applied to a dictionary with string keys, the histogram counts words:

structure Words = MkHistogram (structure Dict = MkTreapDict (structure Key = StringElt))

val h1 = Words.add (Words.empty (), "pear")
val h2 = Words.add (h1, "apple")
val h3 = Words.add (h2, "pear")

val pears = Words.count h3 "pear"       (* 2 *)
val earlier = Words.count h1 "pear"     (* 1, since h1 is unchanged *)

Generic set code takes a structure matching SORTED_SET instead. These two tests, whether one set is a subset of another and whether two sets have no keys in common, work with any set:

functor MkSetRelations (structure Set : SORTED_SET) =
struct
  (* Whether every key of a is also in b *)
  fun isSubset (a : Set.set, b : Set.set) : bool =
    Set.size (Set.difference (a, b)) = 0

  (* Whether a and b have no keys in common *)
  fun isDisjoint (a : Set.set, b : Set.set) : bool =
    Set.size (Set.intersection (a, b)) = 0
end

Augmentation

Augmented Dictionaries

An augmented dictionary, an AUG_SORTED_DICT, is a sorted dictionary that can also report all of its values combined into one. Besides the key structure, MkTreapAugDict takes three parameters that say how values combine:

reducedVal d combines all the values of d with combine, in key order, and returns identity if d is empty. The dictionary keeps this combined value up to date as it is built, so reducedVal takes constant time. Since getRange takes logarithmic time, the two together give the combined value of any range of keys in logarithmic time, without visiting the values in the range.

Example: Shop Profits

The examples below ask different questions of one week of a shop's daily profit, keyed by day. The shop lost money on days 3 and 5:

val week = Seq.fromList [(1, 10), (2, 30), (3, ~25), (4, 30), (5, ~15)]    (* (day, profit) *)

Total profit. Addition is a monoid on integers: combine is op+, and its identity is 0, since adding 0 changes nothing. With these, reducedVal of a range is the total profit over those days:

structure Totals = MkTreapAugDict (structure Key = IntElt
                                   type value = int
                                   val combine = op+
                                   val identity = 0)

val totals = Totals.fromSeq week
val midweek = Totals.reducedVal (Totals.getRange totals (2, 5))    (* 30 - 25 + 30 = 35 *)

getRange d (lo, hi) includes lo but not hi, so the range (2, 5) holds days 2, 3 and 4.

The best day. Now suppose we want the most profitable day in a range. That takes two changes.

First, the answer is a day, but reducedVal only combines values; it never sees the keys. So each value has to carry its own day, as a pair (day, profit), and combine keeps whichever of its two pairs has the larger profit.

Second, combine needs an identity: a value that it always discards in favor of the other argument. That value is also what reducedVal returns for a range with no days in it, which has no best day. No pair can play this role, because whatever profit it held, a real day could do no better, and combine would then keep the identity instead of the day. So the values are options: SOME (day, profit) for a real day, and NONE, meaning "no day", as the identity:

(* The better of two days, each SOME (day, profit) or NONE for no day *)
fun better (NONE, b) = b
  | better (a, NONE) = a
  | better (a as SOME (_, p1), b as SOME (_, p2)) = if p2 > p1 then b else a

structure BestDays = MkTreapAugDict (structure Key = IntElt
                                     type value = (int * int) option
                                     val combine = better
                                     val identity = NONE)

(* Day d's entry: its profit p, together with d itself *)
fun withDay (d, p) = (d, SOME (d, p))

val days = BestDays.fromSeq (Seq.map withDay week)
val best = BestDays.reducedVal days                                  (* SOME (2, 30) *)
val bestLate = BestDays.reducedVal (BestDays.getRange days (3, 6))   (* SOME (4, 30) *)

withDay turns each day's profit into its value, SOME (day, profit). When two days made the same profit, better keeps its left argument, which is the earlier day: days 2 and 4 both made 30, and the best day of the week is day 2. Swapping the arguments of better can therefore change its answer, so it is not commutative. That is fine, because values are always combined in key order, so combine only needs to be associative.

Advanced Example: MCSS

Optional: This example is for interest only. No lab needs anything this complex.

The most the shop made over a run of consecutive days is the maximum contiguous subsequence sum (MCSS) of its profits, from MCSSLab. The best run of a range cannot be found from the best runs of its two halves alone, because it might cross the middle: a run ending at the left half's last day followed by a run starting at the right half's first day. So, as in the divide-and-conquer solution, each value summarizes a range of days with four numbers:

The last three count the empty run, which makes 0, so they are never negative. combineRuns combines the summaries of two adjacent ranges, left then right, and its identity noDays is the summary of no days at all, which is all zeros:

type summary = {total : int, prefix : int, suffix : int, best : int}

(* The summary of two adjacent ranges, left then right *)
fun combineRuns ({total = t1, prefix = p1, suffix = s1, best = b1},
                 {total = t2, prefix = p2, suffix = s2, best = b2}) : summary =
  {total = t1 + t2,
   prefix = Int.max (p1, t1 + p2),
   suffix = Int.max (s2, s1 + t2),
   best = Int.max (Int.max (b1, b2), s1 + p2)}

(* The summary of no days at all *)
val noDays : summary = {total = 0, prefix = 0, suffix = 0, best = 0}

structure BestRuns = MkTreapAugDict (structure Key = IntElt
                                     type value = summary
                                     val combine = combineRuns
                                     val identity = noDays)

(* Day d's entry: the summary of a range holding just that day *)
fun summarize (d, p) =
  let val q = Int.max (p, 0)
  in (d, {total = p, prefix = q, suffix = q, best = q}) end

val runs = BestRuns.fromSeq (Seq.map summarize week)
val bestRun = #best (BestRuns.reducedVal runs)                                 (* 45: days 1 to 4 *)
val bestLateRun = #best (BestRuns.reducedVal (BestRuns.getRange runs (3, 6)))  (* 30: day 4 *)

reducedVal combines values directly, so each day's value has to be a summary already: summarize gives each day the summary of a range holding just that day. The best run of the week makes 45, from day 1 through day 4, despite the loss on day 3.