CSE143 Notes for Wednesday, 11/16/05

I started a new topic: binary search trees. To make this easier to understand, I used the computer to run a program that demonstrates how binary search trees work. The program is available from the class web page as Tree.jar from the handouts page (handout #25).

In running the sample program, we could see a certain process repeated over and over. As values were inserted into the tree, we kept finding ourselves positioned at a particular node of the tree and we'd ask ourselves, "Is the value that we are inserting alphabetically less than the value in this node or alphabetically greater?" If it was alphabetically less, then it was inserted to the left. If it was alphabetically greater, it was inserted to the right.

At one point someone asked what would happen if we inserted a duplicate value. You can adopt one of several different conventions. For example, you might decide that duplicates aren't allowed. In my sample program I allowed duplicates and I decided that they would be inserted into the left subtree. I could just as well have decided that duplicates go into the right subtree. The key thing is to pick a convention and be consistent.

I also pointed out that the sample program will perform all three traversals on the tree. This is a useful way to practice the traversals, which will be on the final exam. When we asked for an inorder traversal, we got an interesting result. The list of names appeared in alphabetical order. This is one of the aspects of a binary search tree that makes it so interesting.

This isn't terribly surprising when you think about how the tree was constructed. We constructed the tree so that values that are alphabetically less than or equal to the root appear in the left subtree and values alphabetically greater appear in the right subtree:

                     +-----------+
                     | root data |
                     +-----------+
                         /   \
                       /       \
                     /           \
        +----------------+   +---------------+
        | values <= data |   | values > data |
        +----------------+   +---------------+
An inorder traversal traverses the left subtree first, then the root, then the right subtree. So if we were to print these values, we would get them in this order:

        (values <= data)   root data   (values > data)
        \------1st-----/   \--2nd--/   \-----3rd-----/
That means that the root data is printed at just the right point in time (after the values that are alphabetically less than it and before the values that are alphabetically greater than it). But we repeated this pattern throughout the tree when we constructed it, so every node has this property. That means that the data stored in each node is printed in the correct position relative to the values that come below it. The overall result is that each data value is printed at just the right point in time relative to the rest of the data.

I then spent a few minutes talking about the complexity of this operation. First think about how many values can be stored in a binary tree.

                      .
                    /   \
                 /         \
              .               .
             / \             / \
           /     \         /     \
          .       .       .       .
         / \     / \     / \     / \
        .   .   .   .   .   .   .   .
If you think in terms of the different levels of this tree, there is 1 node at the top level (the root), two nodes at the next level (children of the root), 4 nodes at the next level (grandchildren of the root), and so on. At each level, the number of nodes doubles. So I asked people how many nodes there are in a tree with n levels. In this case, there are 4 levels, and there are 15 nodes. In general, there are (2^n - 1) nodes possible in a tree with n levels.

So then I turned the question around. Suppose we were trying to put n different nodes into a tree. How many levels would it take to include up to n different nodes? That depends a lot on how full the tree is. If we can assume it's a very full tree, like the one above, then to figure out the number of levels, we'd be trying to figure out what value has the property that 2 carried to that power gives you n (2^? = n). By definition, that is the log to the base 2 of n (? = log2 n).

Think about what that means. Suppose you wanted to put a million values into a tree like this. We know that the log2 of one million is around 20 (because 10^3 is approximately equal to 2^10, which means 10^6 is approximately 2^20). So if we were to construct a binary search tree with one million nodes in it, we could conceivably do so with just a 20-level tree.

Keep in mind that when we went to insert something into the binary search tree, the number of operations involved had to do with the number of levels of the tree. If the tree has a height of 20, then it will take approximately 20 steps to insert a new value into the tree. This means that the binary search tree has a very fast insert operation. Even if the tree has a million values in it, it is likely to take just 20 steps to insert something new into the tree.

Of course, the binary search tree is not guaranteed to be balanced. I asked people whether they could think of a really bad sequence of values to be given for making a binary search tree. Someone mentioned that if you get the values in sorted order, that's bad. You'd end up with something that doesn't look like a tree at all. It would look more like a linked list all pointing to the right. That's what we call a degenerate tree. Reverse sorted order would also be bad, giving us a tree that would all go to the left. So a binary search tree isn't guaranteed to behave well. But the basic idea is quite good.

If you take upper-division computer science courses, you'll study techniques for guaranteeing that the tree remains balanced. Java's TreeMap and TreeSet classes, for example, use a particular kind of binary search tree known as a "red/black tree" that is guaranteed to stay balanced. As a result, these structures are guaranteed to do insert, delete and search in O(log n) time, which is much better than what we saw with our SortedIntList at the beginning of the quarter. It had O(log n) search because of the binary search operation, but insert and delete were potentially O(n) operations because we had to shift values in the array.

After looking at the Tree.jar program, I moved to the overhead to talk about the code that implements this strategy. The code is included in handout #25. I showed the sample log of execution and pointed out that in the sample client code, I construct two different binary search trees. The first has textual data and the second has integers. In other words, we are going to define the tree generically. Instead of just implementing a SearchTree class, we will implement a SearchTree<E> class where E represents an element type. I pointed out in the client code for SearchTreeTest.java that at one point I construct a SearchTree<String> and at another point I construct a SearchTree<Integer>.

Of course, we won't be able to write a class that can be used for every Java type. To construct a binary search tree, you have to be able to test whether a value is less than or greater than the root value. That means that we can only handle values that can be compared for order. Remember that Java has a special interface called the Comparable<E> interface for such types and it has a single method called compareTo that returns an int:

        public interface Comparable<E> {
            // returns a value < 0 if this < other, returns 0 if this == other,
            // returns a value > 0 if this > other
            public int compareTo(E other);
        }
Both the String and Integer classes implement the Comparable interface.

To make our SearchTree<E> class, we need a node class. It follows the general binary tree node pattern of a data field with left and right links along with some constructors:

        public class SearchTreeNode<E> {
            public E data;                // data stored in this node
            public SearchTreeNode<E> left;   // reference to left subtree
            public SearchTreeNode<E> right;  // reference to right subtree
        
            // post: constructs a SearchTreeNode as a leaf with given data
            public SearchTreeNode(E data) {
                this(data, null, null);
            }
        
            // post: constructs a SearchTreeNode with the given data and links
            public SearchTreeNode(E data, SearchTreeNode<E> left,
                                  SearchTreeNode<E> right) {
                this.data = data;
                this.left = left;
                this.right = right;
            }
        }
I told people not to worry too much about the <E> notation. We are using it to declare the tree in a generic manner (i.e., to work for more than one type). I think it's important to show some examples of generic classes, but this is confusing enough that I won't ask you to define your own generic classes.

Our SearchTree class, then, will look like this:

        public class SearchTree<E> {
            private SearchTreeNode<E> overallRoot;

            ...
        }
For the SearchTree class, I decided to use the name "overallRoot" for the variable that keeps track of the top of the tree. That way I can use the name "root" in the methods that I write and it won't be as easily confused with this root. The "overallRoot" keeps track of just one node: the one at the very top of the tree. In our recursive methods, we'll want to refer to the root of any tree (the overall tree or its subtrees or its subsubtrees, and so on).

I showed that in the client I was calling only two methods besides the constructor. I have an inset method that takes a value to insert into the tree and a print method that prints the tree in sorted order.

The print method is similar to one we wrote in the previous lecture. It involves a pair of methods. There is a public method that is called by the client code without mentioning any tree nodes and there is a private recursive method that takes a reference to a root node as a parameter. So the public method passes the overall root to get the process started:

        public void print() {
            printInorder(overallRoot);
        }
We will make printInorder a private method. From the public method we pass the overall root, but in general we want to be able to print every subtree of the overall tree. In writing the private method, you want to think about the different cases. Our recursive definition of a tree says that a tree is either empty or a root node with left and right subtrees. If we're asked to print an empty tree, that's very easy because there's nothing to print. We only have something to print when we have a nonempty tree. So our recursive printing method will look like this:

        private void printInorder(SearchTreeNode<E> root) {
            if (root != null) {
                ...
            }
So how do we print a nonempty tree? We are performing an inorder traversal, so we want to print the left subtree in a preorder manner, then print the data from the root node, then print the right subtree in a preorder manner. If we are thinking recursively, we'll realize that the printInorder method itself can handle the left and right subtrees and a simple println will handle the data from the root. So we can fill this in as:

        private void printInorder(SearchTreeNode<E> root) {
            if (root != null) {
                printInorder(root.left);
                System.out.println(root.data);
                printInorder(root.right);
            }
The insert method will have a similar structure of a public method that the client calls with no mention of tree nodes and a private recursive method that takes a node as a parameter and that does the actual work. So our pair of methods will look like this:

        public void insert(E next) {
           insert(next, overallRoot);
        }

        private void insert(E next, SearchTreeNode<E> root) {
            ...
        }
The parameter type for the value to insert is "E" to match the fact that this is a SearchTree<E>. Similarly, in our private method, we have a parameter of type SearchTreeNode<E> because we need a node that matches whatever type of tree we have been asked to use. For example, when the client code asks for a SearchTree<String>, we will end up making nodes of type SearchTreeNode<String>. When the client asks for a SearchTree<Integer>, we will end up making nodes of type SearchTreeNode<Integer>.

I again said to think in terms of the definition of a tree. A binary tree is either empty or it is a root node with left and right subtrees. If it is empty, then we want to insert the value here. For example, initially the overall tree is empty and we insert the first value at the top of the tree (replacing the "null" value with a reference to a new leaf node with the given value in it). So the private insert method would look like this:

        private void insert(E next, SearchTreeNode<E> root) {
            if (root == null)
                root = new SearchTreeNode<E>(next);
            ...
        }
But what if it's not an empty tree? Remember that in Tree.jar we compared the value at the root with the value we are inserting and we either went left or went right depending upon how that comparison went. So we want something like this:

private void insert(E next, SearchTreeNode&lt;E&gt; root) { if (root == null) root = new SearchTreeNode&lt;E&gt;(next); else if (root.data >= value) <insert left> else <insert right> } Unfortunately, we can't just ask whether (root.data >= value). We're comparing objects, not primitive data. This is where we need to incorporate the Comparable<E> interface. We need to call the compareTo method to perform the actual comparison:

private void insert(E next, SearchTreeNode&lt;E&gt; root) { if (root == null) root = new SearchTreeNode&lt;E&gt;(next); else if (root.data.compareTo(value) >= 0) <insert left> else <insert right> } Even this isn't quite right because the Java compiler won't know that the class E implements the Comparable<E> interface without an explicit cast:

private void insert(E next, SearchTreeNode&lt;E&gt; root) { if (root == null) root = new SearchTreeNode&lt;E&gt;(next); else if (((Comparable&lt;E&gt;) root.data).compareTo(value) >= 0) <insert left> else <insert right> } This is the general structure that we want. So how do we insert left or insert right? If we're thinking recursively, we'll realize that it's another insertion task into either the left subtree or the right subtree. So we can call the insert method itself:

        private void insert(E next, SearchTreeNode<E> root) {
            if (root == null)
                root = new SearchTreeNode<E>(next);
            else if (((Comparable<E>) root.data).compareTo(value) >= 0)
                insert(next, root.left);
            else
                insert(next, root.right);
        }
The logic of this code is almost correct. Unfortunately, in this form the tree is always empty. The insert method never inserts a single value. The problem has to do with the parameter called "root". The parameter "root" will store a copy of whatever is passed into it. As a result, when we reassign root, it has no effect on the value passed into it.

There are many ways to try to fix this, but there is a particular trick that I refer to as the "x = change(x)" idiom that solves this rather nicely. Currently the insert method has a return type of void:

        private void insert(E next, SearchTreeNode<E> root) {
            ...
        }
We can change it so that the last thing we do in the method is to return the value of root, which means we have to change the return type to SearchTreeNode<E>:

        private SearchTreeNode<E> insert(E next, SearchTreeNode<E> root) {
            ...
            return root;
        }
Then we change every call on insert to match the "x = change(x)" form. For example, our old public method:

        public void insert(E next) {
           insert(next, overallRoot);
        }
becomes:
        public void insert(E next) {
           overallRoot = insert(next, overallRoot);
        }
The idea is that we pass in the value of overallRoot to the insert method and it passes back the value of the parameter, which might be the old value or it might be a new value. We reassign overallRoot to this value passed back by insert. That way, if the method changes the value of the parameter, then overallRoot gets updated to that new value. If it doesn't change the value of overallRoot, then we are simply assigning a variable to the value it already has (effectively saying "x = x"), which has no effect.

There are two other calls on insert inside the method itself that need to be updated in a similar manner:

        private void insert(E next, SearchTreeNode<E> root) {
            if (root == null)
                root = new SearchTreeNode<E>(next);
            else if (((Comparable<E>) root.data).compareTo(value) >= 0)
                root.left = insert(next, root.left);
            else
                root.right = insert(next, root.right);
        }
I mentioned that this "x = change(x)" idea is strange enough that I'll ask the TAs to discuss it in section.


Stuart Reges
Last modified: Sun Dec 4 17:16:15 PST 2005