CS 312 Lecture 15
Splay Trees, Amortized Analysis

A splay tree is an efficient implementation of binary search trees that takes advantage of locality in the incoming lookup requests. Locality in this context is a tendency to look for the same element multiple times. A stream of requests exhibits no locality if every element is equally likely to be accessed at each point. For many applications, there is locality, and elements tend to be accessed repeatedly. A good example of an application with this property is a network router. Routers must decide on which outgoing wire to route the incoming packets, based on the IP address in the packets. The router needs a big table (a map) that can be used to look up an IP address and find out which outgoing connection to use. If an IP address has been used once, it is likely to be used again, perhaps many times. Splay trees are designed to provide good performance in this situation.

In addition, splay trees offer amortized O(lg n) performance. That is, a sequence of M operations on an n-node splay tree takes O(M lg n) time.

A splay tree is a binary search tree. It has one interesting difference, however: whenever an element is looked up in the tree, the splay tree reorganizes to move that element to the root of the tree, without breaking the binary search tree invariant. If the next lookup request is for the same element, it can be returned immediately. In general, if a small number of elements are being heavily used, they will tend to be found near the top of the tree and are thus found quickly.

We have already seen a way to move an element upward in a binary search tree: tree rotation. When an element is accessed in a splay tree, tree rotations are used to move it to the top of the tree. This simple algorithm can result in extremely good performance in practice. Notice that the algorithm requires that we be able to update the tree in place, but the abstract view of the set of elements represented by the tree does not change and the rep invariant is maintained. This is an example of a benevolent side effect: a side effect that does not change the abstract view of the value represented.

There are three kinds of tree rotations that are used to move elements upward in the tree. These rotations have two important effects: they move the node being splayed upward in the tree, and they also shorten the path to any nodes along the path to the splayed node. This latter effect means that splaying operations tend to make the tree more balanced.

Rotation 1: Simple rotation

The simple tree rotation used in AVL trees and treaps is also applied at the root of the splay tree, moving the splayed node x up to become the new tree root. Here we have A < x < B < y < C, and the splayed node is either x or y depending on which direction the rotation is. It is highlighted in red.

    y             x
   / \           / \
  x   C   <->   A   y
 / \               / \
A   B             B   C

Rotation 2: Zig-Zig and Zag-Zag

Lower down in the tree rotations are performed in pairs so that nodes on the path from the splayed node to the root move closer to the root on average. In the "zig-zig" case, the splayed node is the left child of a left child or the right child of a right child ("zag-zag").

      z             x               
     / \           / \
    y   D         A   y
   / \      <->      / \                (A < x < B < y < C < z < D)
  x   C             B   z
 / \                   / \
A   B                 C   D

Rotation 3: Zig-Zag

In the "zig-zag" case, the splayed node is the left child of a right child or vice-versa. The rotations produce a subtree whose height is less than that of the original tree. Thus, this rotation improves the balance of the tree. In each of the two cases shown, y is the splayed node:

       z                x                y
      / \              / \              / \
     y   D            /   \            A   z          (A < y < B < x < z < D)
    / \         ->   y     z    <-        / \
   A   x            / \   / \            x   D
      / \          A   B C   D          / \
     B   C                             B   C

See this page for a nice visualization of splay tree rotations and a demonstration that these rotations tend to make the tree more balanced while also moving frequently accessed elements to the top of the tree.

Here is SML code for splay trees. The key function is splay, which takes a non-leaf node and a key k to look for, and returns a node that is the new top of the tree. The element whose key is k, if it was present in the tree, is the value of the returned node. If it was not present in the tree, a nearby value is in the node.

functor SplayTree(structure Params : ORDERED_SET_PARAMS)
  :> ORDERED_FUNCTIONAL_SET where type key = Params.key and
                                  type elem = Params.elem =
struct
  type key = Params.key
  type elem = Params.elem
  val compare = Params.compare
  val keyOf = Params.keyOf
  datatype tree =
    Empty
  | Node of tree * elem * tree
  type node = tree * elem * tree
  (* Representation invariant:
   * All values in the left subtree are less than "value", and
   * all values in the right subtree are greater than "value".
   *)
  type set = int * (tree ref)
  (* Representation invariant: size is the number of elements in
   * the referenced tree. *)

  fun empty() = (0, ref Empty)

  (* splay(n,k) is a BST node n' where n' contains all the
   * elements that n does, and if an element keyed by k is in under n',
   * #value n is that element.
   * Requires: n satisfies the BST invariant.
   *)
  fun splay((L, V, R), k: key): node =
    case compare(k, keyOf(V))
      of EQUAL => (L, V, R)
       | LESS =>
        (case L
           of Empty => (L, V, R) (* not found *)
            | Node (LL, LV, LR) =>
             case compare(k, keyOf(LV))
               of EQUAL => (LL, LV, Node(LR, V, R)) (* 1: zig *)
                | LESS =>
                 (case LL
                    of Empty => (LL, LV, Node(LR, V, R)) (* not found *)
                     | Node n => (* 2: zig-zig *)
                      let val (LLL, LLV, LLR) = splay(n,k) in
                        (LLL,LLV,Node(LLR,LV,Node(LR,V,R)))
                      end)
                | GREATER =>
                    (case LR
                       of Empty => (LL, LV, Node(LR, V, R))
                        | Node n =>  (* 3: zig-zag *)
                         let val (RLL, RLV, RLR) = splay(n,k) in
                           (Node(LL,LV,RLL),RLV,Node(RLR,V,R))
                         end))
       | GREATER =>
           (case R
              of Empty => (L, V, R) (* not found *)
               | Node (RL, RV, RR) =>
                case compare(k, keyOf(RV))
                  of EQUAL => (Node(L,V,RL),RV,RR) (* 1: zag *)
                   | GREATER =>
                    (case RR
                       of Empty => (Node(L,V,RL),RV,RR) (* not found *)
                        | Node n => (* 3: zag-zag *)
                         let val (RRL, RRV, RRR) = splay(n,k) in
                           (Node(Node(L,V,RL),RV,RRL),RRV,RRR)
                         end)
                   | LESS =>
                    (case RL
                       of Empty => (Node(L,V,RL),RV,RR) (* not found *)
                        | Node n => (* 2: zag-zig *)
                         let val (LRL, LRV, LRR) = splay(n,k) in
                           (Node(L,V,LRL),LRV,Node(LRR,RV,RR))
                         end))

  fun lookup((size,tr),k) =
    case !tr of
      Empty => NONE
    | Node n =>
        let val n' as (L,V,R) = splay(n,k) in
          tr := Node n';
          if compare(k, keyOf(V)) = EQUAL then SOME(V)
          else NONE
        end

  fun add((size,tr):set, e:elem) = let
    val (t', b) = add_tree(!tr, e)
    val t'': node = splay(t', keyOf(e))
    val size' = if b then size else size+1
  in
    ((size', ref (Node(t''))),b)
  end
  and add_tree(t: tree, e: elem): node * bool =
    case t
      of Empty => ((Empty, e, Empty), false)
       | Node (L,V,R) =>
        (case compare (keyOf(V),keyOf(e))
           of EQUAL => ((L,e,R),true)
            | GREATER => let val (n',b) = add_tree(L, e) in
                           ((Node(n'),V,R),b)
                         end
            | LESS =>    let val (n',b) = add_tree(R, e) in
                           ((L,V,Node(n')),b)
                         end)

  ...
end

Amortized analysis

To show that splay trees deliver the promised amortized performance, we define a potential function to keep track of the extra time that can be consumed by later operations on the tree. The idea is that for any given tree T, there is a function Φ(T) that is that tree's potential. We then define the amortized time taken by a single tree operation that changes the tree from T to T' as the actual time t, plus the change in potential Φ(T')-Φ(T). Now consider a sequence of M operations on a tree, taking actual times t1, t2, t3, ..., tM and producing trees T1, T2, ... TM. The amortized time taken by the operations is the sum of the actual times for each operation plus the sum of the changes in potential: t1 + t2 + ... tM + (Φ(T2)−Φ(T1)) + (Φ(T3) − Φ(T2)) + ... + (Φ(TM) − Φ(TM-1)) = t1 + t2 + ... tM + Φ(TM) − Φ(T1). Therefore the amortized time for a sequence of operations is an underestimate of the actual time by the amount of the drop in the potential function Φ(TM) − Φ(T1) seen over the whole sequence of operations.

The key to amortized analysis is to define the right potential function. Given a node x in a binary tree, let size(x) be the number of nodes below x (including x). Let rank(x) be the binary logarithm of size(x). Then the potential Φ(T) of a tree T is the sum of the ranks of all of the nodes in the tree. Note that if a tree has n nodes in it, the maximum rank of any node is lg n, and therefore the maximum potential of a tree is n lg n. This means that over a sequence of operations on the tree, its potential can decrease by at most n lg n. So the correction factor to amortized time is at most lg n, which is good.

Now, let us consider the amortized time of an operation. The basic operation of splay trees is splaying; it turns out that for a tree t, any splaying operation on a node x takes at most amortized time 3*rank(t) + 1. Since the rank of the tree is at most lg(n), the splaying operation takes O(lg n) amortized time. Therefore, the actual time taken by a sequence of n operations on a tree of size n is at most O(lg n) per operation.

To obtain the amortized time bound for splaying, we consider each of the possible rotation operations, which take a node x and move it to a new location. We consider that the rotation operation itself takes time t=1. Let r(x) be the rank of x before the rotation, and r'(x) the rank of node x after the rotation. We will show that simple rotation takes amortized time at most 3(r'(x) − r(x))) + 1, and that the other two rotations take amortized time 3(r'(x) − r(x)). There can be only one simple rotation (at the top of the tree), so when the amortized time of all the rotations performed during one splaying is added, all the intermediate terms r(x) and r'(x) cancel out and we are left with 3(r(t) − r(x)) + 1. In the worst case where x is a leaf and has rank 0, this is equal to 3*r(t) + 1

Simple rotation

The only two nodes that change rank are x and y. So the cost is 1 + r'(x) − r(x) + r'(y) − r(y). Since y decreases in rank, this is at most 1 + r'(x) − r(x). Since x increases in rank, r'(x) − r(x) is positive and this is bounded by 1  + 3(r'(x) − r(x)).

Zig-Zig rotation

Only the nodes x, y, and z change in rank. Since this is a double rotation, we assume it has actual cost 2 and the amortized time is

2 + r'(x) − r(x) + r'(y) − r(y) + r'(z) − r(z)

Since the new rank of x is the same as the old rank of z, this is equal to

2 − r(x) + r'(y) − r(y) + r'(z)

The new rank of x is greater than the new rank of y, and the old rank of x is less than the old rank y, so this is at most

2 − r(x) + r'(x) − r(x) + r'(z)    =    2 + r'(x) − 2r(x) + r'(z)

Now, let s(x) be the old size of x and let s'(x) be the new size of x. Consider the term 2r'(x) − r(x) − r'(z). This must be at least 2 because it is equal to lg(s'(x)/s(x)) + lg(s'(x)/s'(z)). Notice that this is the sum of two ratios where s'(x) is on top. Now, because s'(x) ≥ s(x) + s'(z), the way to make the sum of the two logarithms as small as possible is to choose s(x) = s(z) = s'(x)/2. But in this case the sum of the logs is 1 + 1 = 2. Therefore the term 2r'(x) − r(x) − r'(z) must be at least 2. Substituting it for the red 2 above, we see that the amortized time is at most

(2r'(x) − r(x) − r'(z)) + r'(x) − 2r(x) + r'(z)   =   3(r'(x) − r(x))

as required.

Zig-Zag rotation

Again, the amortized time is

2 + r'(x) − r(x) + r'(y) − r(y) + r'(z) − r(z)

Because the new rank of x is the same as the old rank of z, and the old rank of x is less than the old rank of y, this is

2 − r(x) + r'(y) − r(y) + r'(z)
≤   2 − 2 r(x) + r'(y) + r'(z)

Now consider the term 2r'(x) − r'(y) − r'(z). By the same argument as before, this must be at least 2, so we can replace the constant 2 above while maintaining a bound on amortized time:

≤   (2r'(x) − r'(y) − r'(z)) − 2 r(x) + r'(y) + r'(z)   =   2(r'(x) − r(x))

Therefore amortized run time in this case too is bounded by 3(r'(x) − r(x)), and this completes the proof of the amortized complexity of splay tree operations.