CSE143 Notes for Friday, 2/24/06

I began by discussing the next programming assignment. The idea is to construct a binary tree that has information about a number of different kinds of things. We use yes/no questions to distinguish between them.

Initially it constructs a tree with just one leaf node containing "computer":

                +---+      +------------+
    overallRoot | +-+-->   | "computer" |
                +---+      +------------+
In this particular program, leaf nodes contain the names of objects and branch nodes contain questions. Whenever we get to a leaf node, we have no choice left but to guess that particular thing. So if we use the tree above, we'd always start by asking whether the object happens to be computer. If the user says yes, then we've correctly guessed the object and we give the message:

        Great, I got it right!
If this isn't their object, then we expand our tree to incorporate this new kind of object as well. I asked the class for suggestions of what to think of and someone said "carrot". To incorporate this into our tree, we need to replace the leaf node with a branch node that has a question and we want that node to have two leaves: our old object ("computer") and this new object. We start by asking the user what their object is. I said "carrot." Then we ask the user for a question that distinguishes between their object and our object. We said, "Is it a vegetable?" The plan is to replace our old leaf node with a branch node containing this question and with the old and new objects as leaves. But we don't know which one to put to the left and which one to put to the right. To figure that out, we have to ask the user what the answer is for their object. We said that the answer for "carrot" is yes. I mention in the assignment writeup that we'll follow the convention that yes answers go to the left and no answers go to the right. So I'd replace the old root with this new tree:

                +---+      +----------------------+
    overallRoot | +-+-->   | "Is it a vegetable?" |
                +---+      +----------------------+
                                   /   \
                                  /     \
                       +----------+     +------------+
                       | "carrot" |     | "computer" |
                       +----------+     +------------+
Then the process starts again. I asked people to think of another object and someone said "orange". So the program began by asking if our object is a vegetable. We said no. So then it reached the leaf node with "computer" in it. Whenever the program reaches a leaf node, it has no choice but to make that guess. So it asked us whether our object is computer and we said no. So it asked for a question to distinguish the two. Someone said, "Can you eat it?". Then it asked what the answer is for "orange" and we said yes. So now the tree becomes:

               +---+      +---------------------+
   overallRoot | +-+-->   | "Is it a vegetable? |
               +---+      +---------------------+
                                   /   \
                                  /     \
                       +----------+     +-------------------+
                       | "carrot" |     | "Can you eat it?" |
                       +----------+     +-------------------+
                                                /   \
                                               /     \
                                    +----------+     +------------+
                                    | "orange" |     | "computer" |
                                    +----------+     +------------+
This process continues as long as the user wants to keep guessing. When the program finishes executing, you write out the contents of the tree to a file using a preorder traversal. That way, if the user wants to, they can start the next time with this as the initial tree. That would allow you to grow this tree bigger and bigger each time the game is played.

Then 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.

I also discussed 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. A find operation would be equally fast. If the value is there at all, then we should come across it as we descend 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. In particular, I wanted to talk about how to write a method that would insert a value into such a tree. The code in the handout uses generics, but to keep things simple, I said that we'd write code for the simpler "tree of ints" that we saw in Wednesday's lecture and in Thursday's section.

The insert method will have the usual 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(int next) {
           insert(next, overallRoot);
        }

        private void insert(int next, TreeNode root) {
            ...
        }
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(int next, TreeNode root) {
            if (root == null)
                root = new TreeNode(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(int next, TreeNode root) { if (root == null) root = new TreeNode(next); else if (next <= root.data) <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(int next, TreeNode root) {
            if (root == null)
                root = new TreeNode(next);
            else if (next <= root.data)
                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 approach 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(int next, TreeNode 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 TreeNode:

        private TreeNode insert(int next, TreeNode 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(int next) {
           insert(next, overallRoot);
        }
becomes:
        public void insert(int 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 TreeNode insert(int next, TreeNode root) {
            if (root == null)
                root = new TreeNode(next);
            else if (next <= root.data)
                root.left = insert(next, root.left);
            else
                root.right = insert(next, root.right);
            return root;
        }
I mentioned that this "x = change(x)" idea is strange enough that I'll discuss it more in Monday's lecture. I also pointed out that handout #24 has the generic version of this code written for any object that implements the Comparable<E> interface. I won't have time to describe the generic version in detail.


Stuart Reges
Last modified: Mon Feb 27 13:17:59 PST 2006