In lecture, we've been talking about the idea of a binary search tree,
a binary tree structure that obeys certain invariants and supports a kind of
efficient search similar to the binary search that we saw in the second week
of class. The key invariant of binary search trees is that for every node,
all the nodes in its left subtree are smaller, and all of the nodes in its
right subtree are larger. We think of binary search trees as supporting
search, insert, and delete operations similar to those supported by hash
tables, so we think of the comparisons as taking place between keys,
and we require comparisons to be strict so that the tree contains no duplicate
keys.
Here is an example of a binary search tree:
Note that the element labelled -3 in the tree must be both greater than -1
and less than -5 -- it's not enough just to be greater than -1. In the
first lecture on binary search trees, we wrote an is_bst
specification function that checked this weaker, local property, and would
accept certain binary trees that do not globally satisfy the right
invariants.
We can use the idea that each node's key has to be within a certain
range to write a stronger specification function. If we annotate
the tree above with the required range of each node, it's easy to see that
the tree satisfies the invariant we have in mind.
Moreover, if we violated the global invariant, the ranges would highlight
that fact, even if we preserved the local invariant. Suppose we replaced
-3 with 27: as we mentioned above this would be invalid, because although
27 is greater than -1, it's not less than 5 -- the global invariant
is violated.
There are at least three ways we could imagine turning the "range"
idea into a formal specification function:
The main obstacle to approach of passing a range is that we need to have
some sort of representation of negative and positive infinity -- values
strictly smaller or strictly greater than every other value. We can't just
pick the leftmost and rightmost values from the tree, since our comparisons
are strict, and the specification function would fail when it got to, e.g.,
the leftmost leaf. For similar reasons we don't want a domain-specific
solution like computing min_int and max_int, if our keys
are ints -- we may want to store elements with those keys in the
tree, and using them as bounds would exclude the possibility.
Recall that in order to complete the binary tree implementation, we said
that the client had to provide an elem type and a comparison
function bool compare(elem x, elem y). Leveraging the fact that
the elem type is always required to be a pointer, we can solve the
problem at hand by letting NULL represent the initial boundary
points. How can the same thing represent both positive and negative
infinity? Well, we make it the case when we interpret NULL in two
different contexts: when comparing it to something that should be greater
and when comparing it to something that should be smaller.
We produced code analogous to the following to check the strengthened
global binary search tree ordering invariant.
A couple of points to note about the code:
Tree rotations can be used to change the balance of a tree. There are two
kinds of single rotations: a left rotation and a right rotation. There are
also double rotations, but we did not discuss them in this
recitation.
As an example, if we applied a single left rotation to the example tree
above, we would obtain the following:
BST Invariants
In recitation, we explored the first of these possibilities.
bool is_ordered(tree T, elem min, elem max) {
if (T == NULL)
return true; // empty tree is trivially ordered
else if (T->data == NULL)
return false; // all tree nodes must have data
else {
// get the current node's key
//@assert T->data != NULL;
key k = elem_key(T->data);
return
// check that the current node is in range
(min == NULL || compare(elem_key(min), k) < 0)
&& (max == NULL || compare(k, elem_key(max)) < 0)
// check that the subtrees are also ordered
&& is_ordered(T->left, min, T->data)
&& is_ordered(T->right, T->data, max);
}
}
bool is_bst(bst B) {
return B != NULL && is_ordered(B->root, NULL, NULL);
}
There are of course many other ways to write the above code. We could, for
example, chain together several negated if statements in lieu of
the large compound boolean expression in the final return. Or we
could just pass keys as bounds rather than elems, but
additionally pass a pair of booleans, each describing whether its
corresponding key argument is valid.
Tree Rotations
| ====> |
|
Note that although the required ranges on the nodes labelled 5 and 42 changed, the new ranges are implied by the combined effect of the old ones: since 42 > 5, it follows that 5 < 42. The main interesting change is that the left child of the node labelled 42, i.e., the node labelled 17, becomes the right child of the node labelled 5. If we imagine grabbing the root and the right child of the root in the original tree and "dragging" them into their new positions, we see that the node labelled 17 couldn't possibly end up anywhere else. Examining the general case, we see that a left rotation transforms a tree as follows, where the triangles labelled A, B, and C represent arbitrary, possibly empty subtrees.
| ====> |
|
We can easily see that the binary search tree ordering invariant is preserved by a single rotation: the required ranges of the three arbitrary subtrees are identitcal to what they originally were, so if they held in the original tree, they still hold in the result tree, and the new ranges required of x and y follow from their old ones, as described above.
By following the picture, we can give good structure invariants for the function implementing a single left rotation. We recapitulate the code given in class below and augment it with some structural invariants:
tree tree_rotate_left(tree T)
//@requires T != NULL && T->right != NULL;
//@requires is_ordered(T, NULL, NULL);
//@ensures \result != NULL && \result->left != NULL;
//@ensures is_ordered(\result, NULL, NULL);
{
tree root = T->right;
T->right = root->left;
root->left = T;
return root;
}
The first pre-condition specifies the structure required of a tree to be
able to perform a right rotation: the tree must be non-empty and it must
have a non-empty right child. The first post-condition specifies the
analogous structure imposed on the result of the rotation. We avoid
writing invariants like //@ensures \result == T->right; which
specify the exact behavior of the code, since for one, they duplicate the
content of the implementation in a way that may tie our hands too much if
we wish to change the implementation in the future, and for two, such
invariants are not as useful to a client of the code as ones which specify
the basic structure, as in what may be NULL and what must not be.
Exercise: Prove that the code above satisfies the post-conditions given the pre-conditions. (Hint: Given the non-NULL-ness conditions, you can expand the calls to is_ordered a couple of levels. After doing so, your argument will be similar to the informal pictorial argument above.)
Another reasonable and useful post-condition might be that the function preserves the size of the tree -- no nodes are added or deleted, they're just rearranged. We could specify this using \old by saying //@ensures tree_size(\result) == \old(tree_size(T)); -- the size of the result is the same as the original value of tree_size(T). (Note that we wrap the entire expression tree_size(T) in \old(...) because it is the original value of that whole expression that we wish to capture. It would be incorrect to say **tree_size(\old(T)), because the value of the pointer T did not change, though its fields did.)
It's worth noting that the code for performing a single rotation follows a typical pattern found when manipulating state: we first save some value in a temporary; then having saved the value, we update it to some other value; we continue updating variables as soon as we've saved their values, but we take care never to update something before saving its value.
...
t = A;
A = B;
B = C;
...
We saw this pattern earlier in the semester in the code for swapping two
array elements, for example:
void swap(int[] A, int i, int j) {
int t = A[i];
A[i] = A[j];
A[j] = t;
}