CSE143X Notes for Monday, 11/20/24

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);
                System.out.println("p = " + p);
                p = new Point(-7, -14);
                System.out.println("now 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=5,y=13]
        now 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 works well for binary tree code. To understand it, I first spent some time returning to our cell phone analogy for references. Suppose that I ask you to get out your cell phone and look up the number of someone you call often. You tell me the number and I write it down like this:

        (206) 555-1234
Because I have the number written down, I can call that person and talk to them. If I call your friend, then the next time you talk to the friend, they are likely to mention that I called. In other words, I can change your friend's "state." This is like knowing the reference to an object and using it to call methods like the Point object's translate method.

But suppose that I cross off that number and write down a new number like this:

        (206) 555-5555
Writing this on my piece of paper has no effect on your cell phone. I had a copy of your friend's phone number, so changing my copy doesn't change your original version of the number stored in your cell phone. This is like what we saw with the main program's variable x that was unchanged even though the parameter p was changed. The parameter p is a copy, so changing it doesn't change the original.

Now think about the two of us as being Java methods. You are one method calling Stuart (another method). You give information to Stuart using a parameter. How does Stuart get information back to you? We discussed this in 142. We use a return value. Let's make that change in the change method:

        public Point change(Point p) {
            p.translate(3, 5);
            p = new Point(-7, -14);
            System.out.println("p = " + p);
            return p;
        }
Two things changed here. We added a return at the end and we changed the return type of the method. So does this cause it to now update the variable x in main? No. Remember that it isn't enough for a method to return a value. We have to change the call on the method so that it does something with the value being passed back. This is the line that calls the method:

        change(x);
The method is passing back a new value, but we have to assign it somewhere. The idea is to update what x is referring to, so we assign it back into x:

        x = change(x);
We refer to this technique as the "x=change(x)" approach. With these changes, the program produces the following output:

        x = java.awt.Point[x=2,y=8]
        p = java.awt.Point[x=5,y=13]
        now 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.

The next step was to use the x=change(x) approach to make fix the add method of our binary search tree. 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;
        }
With these changes, the method worked properly which we were able to verify with some client code that added various values to a tree and then performed an inorder traversal to print them out in sorted order.

Then I discussed a specific example. Suppose that we have the following three values stored in our tree:

                       +----+
        overallRoot--> | 12 |
                       +----+
                      /      \
                 +----+      +----+
                 |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
                       | 11 |
                       +----+
Think of what happens when we call the add method passing it a value of 10. The public method calls the private method passing overallRoot to the parameter called root. So the first recursive invocation has a value it calls root that refers to the top of the tree. In the picture below, I have labeled it as "root1" instead of "root" to indicate that it's the value of root for the first invocation.

                       +----+
        overallRoot--> | 12 |
        root1--------> +----+
                      /      \
                 +----+      +----+
                 |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
                       | 11 |
                       +----+
That first invocation sees that it's value of root is not null. It tests value against root.data (12) and finds that it is less than root.data, so it makes a recursive call passing root.left as a parameter. This sets up a second version of root that I have labeled root2:
                       +----+
        overallRoot--> | 12 |
        root1--------> +----+
                      /      \
                 +----+      +----+
        root2--> |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
                       | 11 |
                       +----+
This invocation sees that its version of root is not null and that the value of 10 that we are trying to insert is larger than its root.data (8), so it makes a recursive call passing its root.right. That sets up yet another root parameter that I have labeled as root3:

                       +----+
        overallRoot--> | 12 |
        root1--------> +----+
                      /      \
                 +----+      +----+
        root2--> |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
              root3--> | 11 |
                       +----+

This invocation sees that its version of root is not null and that the value of
10 that we are trying to insert is less than its root.data (11), so it makes a
recursive call passing its root.left.  That sets up yet another root parameter
that I have labeled as root3:

+----+ overallRoot--> | 12 | root1--------> +----+ / \ +----+ +----+ root2--> | 8 | | 18 | +----+ +----+ \ +----+ root3--> | 11 | +----+ root4 is null

Because its verson of root is null, it constructs a new node with 10 in it and has root refer to it:

                       +----+
        overallRoot--> | 12 |
        root1--------> +----+
                      /      \
                 +----+      +----+
        root2--> |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
              root3--> | 11 |
                       +----+

                 +----+
        root4--> | 10 |
                 +----+
The problem is that this new node is not linked into the tree in any way. But then the method finishes executing and returns root4. The second invocation was executing this line of code:

                root.left = add(root.left, value);
The reference to the newly constructed node being returned by the recursive call is used to change the value of root.left. This is the crucial moment when this code actually links the new node into the tree. We end up with:

                       +----+
        overallRoot--> | 12 |
        root1--------> +----+
                      /      \
                 +----+      +----+
        root2--> |  8 |      | 18 |
                 +----+      +----+
                       \
                       +----+
              root3--> | 11 |
                       +----+
                      /
                 +----+
                 | 10 |
                 +----+
At this point we don't need to do anything more, but we still have to unwind the various recursive calls and because we have been using the x=change(x) pattern, there are more assignments that will take place. The third invocaion is done at this point, so it returns root3. The previous method was executing this line of code:
                root.right = add(root.right, value);
The value being returned is the same value that was passed in for root.right. As a result, we are setting root.right to the value it already has, which is like executing this line of code:

        x = x;
It isn't dangerous to do that. Then the second invocation is done at this point, so it returns root2. The previous method was executing this line of code:

                root.left = add(root.left, value);
Again, it is setting a field to the value it already had, so it has no effect. Then the first recursive call is done and it returns the value root1. That goes back to the call made from the public method:

           overallRoot = add(overallRoot, value);
We reset overallRoot to the value it has already. Then the method is done executing. Notice that any given call on add will end up changing only one real link, the last one. In all of the other cases, as the recursion unwinds it is simply setting links to what they are already.

Some people wonder why we don't "stop one early" the way we did with linked lists. That would be a lookahead strategy. We could do that, but with binary tree code, we'd have to do the exact same check three different times. We'd have to check for overallRoot and for root.left and for root.right. That leads to highly redundant code. And it might not be something as simple as an if. We might need to introduce a while loop for all three. Using the "x = change(x)" idiom greatly simplifies a lot of binary tree code.

I also briefly discussed the idea of searching for a value in a binary search tree. A section problem involved writing a contains method that would return whether a binary tree contains a particular value and we came up with this solution:

        public boolean contains(int value) {
            return contains(overallRoot, value);
        }
        
        private boolean contains(IntTreeNode root, int value) {
            if (root == null) {
                return false;
            } else if (root.data == value) {
                return true;
            } else {
                return contains(root.left, value) || contains(root.right, value);
            }
        }
This potentially searches the entire tree and will search all n nodes of a tree for a failed search. The built-in structure called TreeSet in Java uses a binary search tree and we want it to have a fast version of contains. We can do a more efficient search when we are working with a binary search tree. We take advantage of the binary search tree property to figure out whether a value is in the left subtree or the right subtree. That allows us to skip processing the other subtree. So the code becomes:
        public boolean contains(int value) {
            return contains(overallRoot, value);
        }   
    
        private boolean contains(IntTreeNode root, int value) {
            if (root == null) {
                return false;
            } else if (value == root.data) {
                return true;
            } else if (value < root.data) {
                return contains(root.left, value);
            } else {  // value > root.data
                return contains(root.right, value);
            }
        }
For this version of the code, the number of nodes examined will be at most the height of the tree. Binary search trees can become unbalanced and even what we would call "degenerate." For example, if you add values to the tree in sorted order, then there will be no branching at all (everything will be inserted to the right). But when we can achieve some degree of balance for the tree, the height will be related to log(n) where n is the number of nodes in the tree.


Stuart Reges
Last modified: Thu Nov 21 09:36:17 PST 2024