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.
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); }
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:
====> |
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; }