CSE143X Notes for Monday, 11/19/18

I turned back to our sample IntTreeNode and IntTree classes and talked about how we could write some basic methods for the class. In particular, I wanted to write a public method for the IntTree class that would print the tree values using a preorder traversal. So our public method will look like this:

        public void printPreorder() {
            ...
        }
I mentioned that it will almost always be the case when you go to write such a method that you actually have to write a pair of methods to solve the problem. The issue is that from the client's point of view, they want to print the entire tree. But to solve this problem recursively, we need a method that works on every subtree, not just the overall tree. We need a method that takes a IntTreeNode as a parameter, so we'll create a private method:

        private void printPreorder(IntTreeNode root) {
            ...
        }
We'll start the recursive process by passing it the overall root. We can also include code to print text at the beginning and end of the line of output we want to produce:

        public void printPreorder() {
            System.out.print("preorder:");
            printPreorder(overallRoot);
            System.out.println();
        }
But how do we write the private method? I told people that it's good to go back to the basic definition of a binary tree. Remember that it is either an empty tree or it is a root node with left and right subtrees. If it's an empty tree, then there isn't anything to print. That means we could begin our private method this way:

        private void printPreorder(IntTreeNode root) {
            if (root == null)
               // do nothing
            ...
        }
But since we have nothing to do in this case, it's better to test the negation of this:

        private void printPreorder(IntTreeNode root) {
            if (root != null) {
                ...
            }
        }
So what do we do in the case where node is not null? That would mean we have a root node that has some data in it and we have left and right subtrees that need to be printed. The way a preorder traversal works is that we handle the root node first, which means we'd print out the data for the root. We include a space to separate it from other values on the line of output:

        private void printPreorder(IntTreeNode root) {
            if (root != null) {
                System.out.println(" " + root.data);
                ...
            }
        }
What do we do after print the data for this node? We want to print the left subtree in a preorder manner and then print the right subtree in a preorder manner. If you're thinking recursively, you'll think, "If only I had a method to print a subtree in a preorder manner...but I do have such a method...the one I'm writing." So this becomes:

        private void printPreorder(IntTreeNode root) {
            if (root != null) {
                System.out.println(" " + root.data);
                printPreorder(root.left);
                printPreorder(root.right);
            }
        }
That completes the method. I then asked people how to modify the code to print the tree in an inorder manner and people said to put the println in between the two recursive calls. What about a postorder traversal? You put the println after the two recursive calls.

I had the TAs pass out a handout with the a version of the class that has three methods for these three different traversals. It is important to understand why the seemingly small change of moving the println relative to the recursive calls generates such different behavior. That's because each recursive call potentially processes a large amount of data (an entire subtree).

I also pointed out that the sample program has two other interesting recursive methods. The first is called printSideways and prints the tree with indentation to show the structure of the tree. In the sample output it produces the following:

        7
    3
        6
            12
1
            11
        5
            10
    2
            9
        4
            8
Imagine rotating this 90 degrees in a clockwise fashion (or rotating the page it is printed on). Then you get:

               1
       2               3
   4        5      6       7
 8   9  10  11  12
If you just add lines to this, you'll see the tree:

               1
            /    \
         /          \
       2               3
    /    \          /    \   
   4       5       6       7
 /  \    /  \    /
 8   9  10  11  12
To do this, I had to use an inorder traversal that goes right-to-left because in the original non-rotated output, the top lines of the output are for the right subtree and the bottom of the output is the left subtree. This is easy to accomplish in the recursive method by switching the order of the recursive calls on the left and right subtrees.

I also pointed out that the constructor builds a very particular binary tree. It builds a tree whose nodes are numbered sequentially as you read it level by level (overall root of 1, next level of 2, 3, next level of 4, 5, 6, 7, and so on). This tree is easy to build because for each node storing the value n, its children store the values 2n and 2n+1. The recursive method that builds the tree has extra parameters that tell it the maximum node number to construct and the value to store at the next subtree's root.

Then 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: Mon Nov 19 17:01:34 PST 2018