Lecture 13:
Splay Trees

Splay Trees

We have studied in great detail the algorithms for red-black trees. We will now turn our attention to another type of self-balanced binary search trees: splay trees.

Unlike red-black trees, splay trees do not require any additional information to be stored in their nodes. Instead, splay trees rely on a sequence of simple transformations that are applied whenever one of their nodes it looked up. The fundamental goal of these is to transform the tree so that the just looked-up node "raises up" to become the root of the restructured tree. Of course, all transformations are defined so that the ordering invariants of a regular binary search tree are preserved.

The names of the various cases are directly derived from the structural relationships between the target node (the node that holds the key we are looking for) and its parent and grandparent (if a grandparent exists).

  1. The zig and zag cases. Note that, depending on whether the node we want to bring up to root is the left or right child of its parent, we transform the zig pattern into the zag pattern, or viceversa, respectively.
       zig           zag
    
        Y             X
       / \           / \
      X   c   <->   a   Y
     / \               / \
    a   b             b   c
    

    Note that a < X < b < Y < c, where - by a slight abuse of notation - a means "they key of all nodes in subtree a," and X means "the key of node X." The meaning of the other notations is analogous.

  2. The zig-zig and zag-zag cases transform into each other:
       zig-zig       zag-zag
    
          Z             X               
         / \           / \
        Y   d         a   Y
       / \      <->      / \ 
      X   c             b   Z
     / \                   / \
    a   b                 c   d
    

    Note that for these cases we have a < X < b < Y < c < Z < d.

  3. The zig-zag and zag-zig cases are also similar to each other, but their corresponding structural patterns do not transform into each other, as it was the case with the previous two case pairs.
        zig-zag                           zag-zig
    
           Z                Y                X
          / \              / \              / \
         X   d            /   \            a   Z
        / \         ->   X     Z    <-        / \
       a   Y            / \   / \            Y   d
          / \          a   b c   d          / \
         b   c                             b   c
    

    We have again chosen our notation so that a < X < b < Y < c < Z < d.

The transformations shown above achieve two goals, which both contribute to the excellent performance of splay trees:

  1. Every looked-up node ends up in the root of the tree.

    In many cases, they keys of a binary search tree are not access uniformly. In fact, it is typical for very skewed access frequency distributions to exist. Let us assume, for example, that we build a tree that holds mappings from symbolic web addresses (e.g. www.cnn.com) to actual IP addresses (e.g. 64.236.16.84). There are - probably - many orders of magnitude difference between the number of accessed to the web pages of a big news organization and the number of accesses to your personal web page (if this is not true, let me know - I want your autograph).

    For such skewed access patterns the nodes that are used more frequently end up closer to the root, on average, than rarely accessed nodes. This greatly improves the average lookup-time for a skewed distribution.

  2. The height of the tree is decreased.

    Take a look at the examples below to get a sense of how the height of a tree decreases due to the splaying transformations. Having trees of lower height is advantageous, because it reduces the average lookup time of a node, irrespective of the fact that the node is accessed frequently or not.

Let us assume that we start with a long chain of nodes and we look up the node at the bottom of the tree:

  Empty
G
    Empty
  F
      Empty
    E
        Empty
      D
          Empty
        C
            Empty
          B
              Empty
            A
              Empty

In the tree above each node stores a string (though we only show strings of lenght 1), and we employ the usual alphabetical ordering. After looking up the node with key "A" we get:

      Empty
    G
      Empty
  F
        Empty
      E
        Empty
    D
          Empty
        C
          Empty
      B
        Empty
A
  Empty

Note that the depth of the tree went down from 7 in the initial case to a depth of 4 (edges that end in an Empty node are counted).

If we now look up the node containing "C" we get the following tree:

      Empty
    G
      Empty
  F
        Empty
      E
        Empty
    D
      Empty
C
      Empty
    B
      Empty
  A
    Empty

The height of the tree decreased from 4 to 3 and the tree is much more balanced than it was initially.

An SML datatype for splay trees can be defined as follows:

datatype 'a stree = Empty | Node of 'a stree * 'a * 'a stree

We now provide one possible implementation of a functional splay tree lookup.

datatype direction = ZIG | ZAG

fun slookup (t: 'a stree) (v: 'a) (cmp: 'a * 'a -> order) = 
let

  fun lookup t a1 a2 a3=
  case t of
    Empty => ([],[],[])
  | Node(l, k, r) => (case cmp(v, k) of
                        GREATER => lookup r (ZAG::a1) (k::a2) (l::a3)
                      | LESS => lookup l (ZIG::a1) (k::a2) (r::a3)
                      | EQUAL => (a1, k::a2, l::r::a3))

  fun splay dirs nodes trees =
    case (dirs, nodes, trees)  of
      ([], [k], [l, r]) => Node(l, k, r)
    | (ZIG::ZIG::dtl, X::Y::Z::ntl, a::b::c::d::ttl) => 
	  splay dtl (X::ntl) (a::Node(b, Y, Node(c, Z, d))::ttl)
    | (ZAG::ZIG::dtl, Y::X::Z::ntl, a::b::c::d::ttl) =>
	  splay dtl (Y::ntl) (Node(c, X, a)::Node(b, Z, d)::ttl)
    | (ZIG::ZAG::dtl, Y::Z::X::ntl, a::b::c::d::ttl) =>
	  splay dtl (Y::ntl) (Node(d, X, a)::Node(b, Z, c)::ttl)
    | (ZAG::ZAG::dtl, Z::Y::X::ntl, a::b::c::d::ttl) =>
	  splay dtl (Z::ntl) (Node(Node(d, X, c), Y, a)::b::ttl)
    | ([ZIG], [X, Y], [a, b, c]) =>
	  Node(a, X, Node(b, Y, c))
    | ([ZAG], [Y, X], [a, b, c]) =>
	  Node(Node(c, X, a), Y, b)
    | _ => raise Fail "impossible case"

  val (dirs, nodes, trees) = lookup t [] [] []

in

  if null(dirs) then (print "null\n"; t) else splay dirs nodes trees

end

Note: we did not provide types for the arguments of functions splay and lookup above, nor for the functions themselves. After you understand the algorithm, try to determine these types yourselves.

The direction datatype is used to encode the direction that we take as we go down into the tree on the path from the root to the looked-up node. The six cases (presented as three pairs above) can be identified by pattern-matching on sequences of such directions. For reasons that will become clear shortly, the case name must be constructed by taking the direction values in opposite order to that encountered in patterns. For example, the ZIG:ZAG::... pattern corresponds to the zag-zig case.

The slookup function first calls the helper function lookup. Lookup follows the normal path for looking up a node in a binary search tree, but it retains the details of this path in a triple that is returns to the caller. The first element of the triple collects the directions followed during the descent to the target node. The second element of the triple records the nodes visited.

A key observation is that each node that we visit, except for the target node (we assume a successful search for the target), has one child that will be on the path toward the target node, and - a possibly Empty node - that is a root of a subtree that contains no node on the path from the root to the target node. The third element of the triple returned by lookup retains the list of these subtrees. The target node has two subtrees; both of these are added to the third component of the triple. The right subtree is added first, followed by the left subtree (so the left subtree ends up at the head of the list).

To make this more specific, let us consider a lookup in a small tree:

       _ R _        R = root
      /     \       I = intermediate node
     I       X      T = target node
    / \     / \     []= Empty nodes
   []  T   [] []
      / \
     [] []       

For this simple tree, the triple returned by lookup will be the following: ([ZAG, ZIG], [R, I, T], [Empty, Empty, Empty, Node(X, Empty, Empty)]) (the first Empty is the left subtree of T, the second one is the right subtree of T, and the third one is the left subtree of I). Note that because we always add to the beginning of the tree, the elements at the head of the list are those encountered last. Here the letters R, I, T, and X represent key values, not the characters themselves.

We also note that if the key we are looking for is not in the tree, then lookup returns a triple composed of three empty lists.

Assuming that the target node is in the tree (i.e. we found a node with the key we are looking for), function splay will rebuild a splayed tree from the components that have been provided by lookup. As it has been the case before, pattern matching provides a very intuitive way of formulating this part of the algorithm.

The key insight is that each of the six cases involves n nodes and n + 1 subtrees, where n is equal to 2 for cases zig and zag, and it is equal to 3 for all the other cases.

Keeping in mind that the lists returned by lookup are reversed (elements "seen" later come first) we can identify the cases by pattern matching on the directions that we took in the tree. Note that the patterns for zig and zag come after the patterns for the more complicated cases; otherwise these complex cases would never be selected (we could not even run the program, since SML would report redundant patterns).

At each step we select the nodes and subtrees needed for each case, and we combine them into a (sub)tree that has the target node at the root. This is the basic idea, but we don't actually build the subtree that results from a splaying transformation. Instead, we re-insert the target node in the list of nodes encountered on the path from the root, and we insert its (possibly newly built) left and right subtrees into the list of subtrees in the order left_subtree::right_subtree::rest_of_the_list. After this transformation occured we are left with datastructures that lookup could have generated if the target node would have been higher in the tree at the time of the initial lookup step. We can now repeat this procedure recursively; we stop when the only node in the path from the root to the target node is the target node itself (or - equivalently - the list of directions to follow from the root to the target tree becomes empty).

The examples we used to illustrate the splaying transformations have been generated using the code below:

val t0 = Node(
	   Node(
	     Node(
	       Node(
		 Node(
		   Node(
		     Node(
		       Empty,
		       "A",
		       Empty),
		     "B",
		     Empty),
		   "C",
		   Empty),
		 "D",
		 Empty),
	       "E",
	       Empty),
	     "F",
	     Empty),
           "G",
           Empty)

printTree t0 (fn s => print s)

val t1 = slookup t0 "A" String.compare

printTree t1 (fn s => print s)

val t2 = slookup t1 "C" String.compare

printTree t2 (fn s => print s)

Function printTree is the - by now - well known tree printing function. Keep in mind that it is best to read these trees by tilting your head 90 degrees to the left (I can't do much more than 45-50 degrees - can you?). As a reminder, we provide this function below:

fun printTree (t: 'a stree) (p: 'a -> unit): unit = 
  let
    fun spaces (n: int): string = if n = 0 then "" else " " ^ (spaces (n - 1))
    fun helper (t: 'a stree) (p: 'a -> unit) (n: int): unit =
      case t of
        Empty => print ((spaces n) ^ "Empty\n")
      | Node(l, k, r) => (helper r p (n + 2);   
                          print (spaces n); p k; print "\n";
                          helper l p (n + 2))
   in
     helper t p 0
end

If you visit this web site you will see an applet implementing (a version of) the splay-tree algorithms.