I started with some basic terminology. I drew a crude picture of a tree and pointed out that we refer to the base of the tree as its root and we talk about branches of the tree with leaves on the branches. Computer scientists view the world upside down, so we draw a tree with the root a the top and the leaves at the bottom. For example, we might draw this tree:
12 / \ / \ 18 7 / \ / \ 4 13Just as with linked lists, we refer to each different value as a node. At the top of this tree we have the root node storing the value 12. We would refer to the nodes with 12 and 7 as branch nodes of the tree because they have values stored under them. The nodes with values 18, 4 and 13 are leaf nodes of the tree because each one has nothing under it.
Another set of terms we use is parent and child. The root node is the ultimate ancestor of every other node. It is the parent of the nodes 18 and 7. Similarly, we'd say that the parents of 18 and 7 are the root node 12. We also sometimes refer to 18 and 7 as siblings.
I then gave a recursive definition of a tree. I said that a tree is either:
+-----------+ | root node | +-----------+ / \ / \ / \ /\ /\ / \ / \ / \ / \ / left \ / right\ / subtree\ / subtree\ +----------+ +----------+That will be a useful way to think about trees as we write binary tree code. Using our recursive definition, we discussed how you could form various kinds of trees. The simplest kind of tree is an empty tree, which can't really be drawn because it's empty.
Once you have an empty tree, you can use the second part of the definition to make a new kind of tree that is composed of a root node with left and right subtrees that are both empty. In other words, that would be a single leaf node, which we could represent with a star:
*Now that we have this as an option, we can use our recursive rule to say that a tree could be a root node with a left tree that is empty and a right tree that is a leaf:
* \ *Or we can have an empty right and a leaf to the left:
* / *Or we could have leaf on either side:
* / \ * *These now become possibilities to use for our recursive definition, allowing us to construct even more kinds of trees, as in:
* * * * / \ / \ / \ / \ * * * * * * * * / \ / \ / \ \ / \ \ * * * * * * * * * *Then I showed people the node class we'll be using for a simple binary tree of ints:
public class IntTreeNode { public int data; public IntTreeNode left; public IntTreeNode right; public IntTreeNode(int data) { this(data, null, null); } public IntTreeNode(int data, IntTreeNode left, IntTreeNode right) { this.data = data; this.left = left; this.right = right; } }As with our linked list node, this node is very simple in that it has just some public data fields and a few constructors. The node has a data field of type int and two links of type IntTreeNode for the left and right subtrees. The first constructor constructs a leaf node (using null for left and right). The second constructor would be appropriate for a branch node where you want to specify the left and right subtrees.
This node class is "messy" in the sense that it is not well encapsulated. We did something similar with the linked list nodes and I made the analogy that they are like the cans of paint that a contractor might use in painting your house. We want a cleaner interface for dealing with a client, so I mentioned that we'll have a second object for storing a tree. Any external client will deal with the IntTree object and won't ever see these tree node objects.
We need only one data field in the tree class: a reference to the root of the tree:
public class IntTree { private IntTreeNode overallRoot; ... }I pointed out that I'm purposely using the name "overallRoot" to distinguish this root from all of the other roots. There is only one overall root. But each subtree is itself the root of a tree and in our recursive methods we'll often use the parameter name "root" as a way to indicate that it can be any of the roots.
I then spent time discussing the idea of tree traversals. The idea is to "traverse" the tree in such a way that you visit each node exactly once. There are many different ways to do this. We generally prefer recursive approaches, so we want to traverse the entire left subtree without dealing with anything from the right and in a separate operation, traverse the entire right subtree without dealing with anything from the left. That leads to the classic binary tree traversals. We have a Western bias that we traverse the left subtree before the right subtree. The question becomes, where do you deal with the root of the tree?
There are three possible answers you might give. You can process the root before you traverse either subtree, after you traverse both subtrees or in between traversing the two subtrees. These three approaches are known as preorder, inorder and postorder traversals.
+------------------+ +-----------<-------------+ process the root +------------->-----------+ | +--------+---------+ | | | | | | | V +-----------------------+ V +------------------------+ V pre | traverse left subtree | in | traverse right subtree | post +-----------------------+ +------------------------+For example, given the following tree:
+---+ | 2 | +---+ / \ / \ +---+ +---+ | 0 | | 3 | +---+ +---+ / / \ / / \ +---+ +---+ +---+ | 7 | | 1 | | 9 | +---+ +---+ +---+ / \ / \ / \ / \ +---+ +---+ +---+ +---+ | 6 | | 5 | | 8 | | 4 | +---+ +---+ +---+ +---+The traversals would be as follows:
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 8Imagine 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 12If you just add lines to this, you'll see the tree:
1 / \ / \ 2 3 / \ / \ 4 5 6 7 / \ / \ / 8 9 10 11 12To 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 we will learn on Monday. Stuart Reges Last modified: Fri Nov 15 17:41:39 PST 2013