CSE143 Notes for Wednesday, 5/18/11

I started a new topic: binary search trees. They are a particular kind of binary tree that have a special property that is sometimes referred to as the binary search tree property. In particular, we want to guarantee that for every subtree, the following relationship holds:

                     +-----------+
                     | root data |
                     +-----------+
                         /   \
                       /       \
                     /           \
        +----------------+   +---------------+
        | values <= data |   | values > data |
        +----------------+   +---------------+
In other words, the tree is structured so that values that appear in the left subtree are all less than or equal to the root data and values that appear in the right subtree are all greater than the root data. And remember that this property is preserved throughout the tree, not just for the overall root.

I talked a little about the issue of duplicate values. You can adopt one of several different conventions. For example, you might decide that duplicates aren't allowed. In my the examples I am using, I allow duplicates and I decided that they should 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 asked people to give me several names and we used them to construct a binary search tree. As we inserted values 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.

When we performed an inorder traversal on the final tree, 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:

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.

Then I talked about how to write code to implement this. For our purposes, we considered the problem of adding values to the IntTree class we discussed in Monday's lecture. We want to write client code like the following:

        Scanner console = new Scanner(System.in);
        IntTree numbers = new IntTree();
        System.out.print("Next int (0 to quit)? ");
        int number = console.nextInt();
        while (number != 0) {
            numbers.add(number);
            System.out.print("Next int (0 to quit)? ");
            number = console.nextInt();
        }
To make this work, we have to add a zero-argument constructor that constructs and empty tree:

        public IntTree() {
            overallRoot = null;
        }
Then we have to write the add method. It needs to do what we saw the program doing: inserting each new value into the tree so as to preserve the binary search tree property.

The add 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 add(int value) {
            add(overallRoot, value);
        }

        private void add(IntTreeNode root, int value) {
            ...
        }
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 add method would look like this:

        private void add(IntTreeNode root, int value) {
            if (root == null) {
                root = new IntTreeNode(value);
            } ...
        }
But what if it's not an empty tree? Remember that when we built up the tree ourselves, 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 add(IntTreeNode root, int value) {
            if (root == null) {
                root = new IntTreeNode(value);
            } else if (value <= root.data) {
                // add to left
            } else {
                // add to right
            }
        }
This is the general structure that we want. So how do we add to the left or add to the right? Some novices find themselves wanting to test things about the left or right subtree, as in:

        private void add(IntTreeNode root, int value) {
            if (root == null) {
                root = new IntTreeNode(value);
            } else if (value <= root.data) {
                if (root.left == null) {
                    root.left = new IntTreeNode(value);
                } else { // even more stuff
            } else {
                // add to right
            }
        }
This is not a good way to approach the problem. If you're thinking recursively, you'll realize that it's another insertion task into either the left subtree or the right subtree. So you can call the add method itself:

        private void add(IntTreeNode root, int value) {
            if (root == null) {
                root = new IntTreeNode(value);
            } else if (value <= root.data) {
                add(root.left, value);
            } else {
                add(root.right, value);
            }
        }
The logic of this code is almost correct. Unfortunately, in this form the tree is always empty. The add 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.

To explore this further, I asked people to consider the following short program:

        import java.awt.*;
        
        public class PointTest {
            public static void main(String[] args) {
                Point x = new Point(2, 8);
                System.out.println("x = " + x);
                change(x);
                System.out.println("now x = " + x);
            }
        
            public static void change(Point p) {
                p.translate(3, 5);
                p = new Point(-7, -14);
                System.out.println("p = " + p);
            }
        }
The code in main constructs a Point object and passes it as a parameter. Inside the method we translate the coordinates of the Point object. The question is whether this change is reflected in the original Point object. The answer is yes. This happens because when we pass an object as a parameter, the parameter gets a copy of the reference to the object.

But what about the final line of the method that constructs a new point? Does that change the main method's variable x? The answer is no. The overall output for this version of the program is:

        x = java.awt.Point[x=2,y=8]
        p = java.awt.Point[x=-7,y=-14]
        now x = java.awt.Point[x=5,y=13]
One of the changes has an effect (translating the coordinates), but not the other (constructing a new Point). This happens because when we pass an object as a parameter, the parameter gets a copy of the reference to the object. So when we first call the change method, the variable p is set up as a copy of the variable x:

                   +--------------------+
          +---+    |   +----+    +----+ |
        x | +-+--> | x |  2 |  y |  8 | |
          +---+    |   +----+    +----+ |
                   +--------------------+
                              ^
           +---+              |
         p | --+--------------+ 
           +---+
Using the cell phone analogy I've been using, both x and p are calling the same cell phone (talking to the same object). That's why the method is able to change the object that x refers to when it executes these this line of code:
        p.translate(3, 5);
We have two different variables both referring to the same object, so either variable is capable of changing the object:

                   +--------------------+
          +---+    |   +----+    +----+ |
        x | +-+--> | x |  5 |  y | 13 | |
          +---+    |   +----+    +----+ |
                   +--------------------+
                              ^
           +---+              |
         p | --+--------------+ 
           +---+
But that doesn't mean that p has the power to change x itself. It can change the object that x refers to (the Point), but it can't change the value that is stored in x (the reference or pointer or arrow or cell phone number). So when we change p with the following line of code, it has no effect on x:

        p = new Point(-7, -14);
This gives a new value to p, but not to x:

                   +--------------------+
          +---+    |   +----+    +----+ |
        x | +-+--> | x |  5 |  y | 13 | |
          +---+    |   +----+    +----+ |
                   +--------------------+

                    +--------------------+
           +---+    |   +----+    +----+ |
         p | +-+--> | x | -7 |  y |-14 | |
           +---+    |   +----+    +----+ |
                    +--------------------+
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. We change the method so that it returns the value of the parameter just before exiting. This also requires changing the return type for the method:

        public Point change(Point p) {
            p.translate(3, 5);
            p = new Point(-7, -14);
            System.out.println("p = " + p);
            return p;
        }
Then we change the call on the method to the "x = change(x)" form:

        x = change(x);
With these changes, the program produces the following output:

        x = java.awt.Point[x=2,y=8]
        p = java.awt.Point[x=-7,y=-14]
        now x = java.awt.Point[x=-7,y=-14]
Now the change that occurs in the method propagates back to the main method.

Then I turned back to the binary tree code to see how we could apply this technique there. Currently the add method has a return type of void:

        private void add(IntTreeNode root, int value) {
            ...
        }
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 IntTreeNode:

        private IntTreeNode add(IntTreeNode root, int value) {
            ...
            return root;
        }
Then we change every call on add to match the "x = change(x)" form. For example, our old public method:

        public void add(int value) {
           add(overallRoot, value);
        }
becomes:
        public void add(int value) {
           overallRoot = add(overallRoot, value);
        }
The idea is that we pass in the value of overallRoot to the add 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 add. 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 add inside the method itself that need to be updated in a similar manner:

        private IntTreeNode add(IntTreeNode root, int value) {
            if (root == null) {
                root = new IntTreeNode(value);
            } else if (value <= root.data) {
                root.left = add(root.left, value);
            } else {
                root.right = add(root.right, value);
            }

            return root;
        }
I mentioned that this "x = change(x)" idiom is very powerful and simplifies a lot of binary tree code, so it is important to learn how to use it well.


Stuart Reges
Last modified: Wed May 18 15:54:50 PDT 2011