CSE341 Notes for Wednesday, 1/24/07

I spent some time talking about the next programming assignment. I won't repeat that here since the assignment writeup contains the essential information.

Then we continued our discussion of the intTree example. I reviewed some of the code we wrote, pointing out that you'll be writing similar code for the programming assignment.

As another example, I asked people how we could convert the binary search tree into a sorted list of ints. This took people a few minutes to figure out, but eventually we came to the realization that an inorder traversal of the tree will produce the values in sorted order, so we simply need to collapse it using an inorder traversal.

The easy case is to collapse an empty tree, which gives you an empty list:

        fun collapse(Empty) = []
As usual, we'll need a case for the nonempty tree:

        fun collapse(Empty) = []
        |   collapse(Node(data, left, right)) =
                ...
In this case we want to recursively collapse the left and right subtrees and glue the two pieces together with the root data in the middle. Someone suggested doing it this way:

        fun collapse(Empty) = []
        |   collapse(Node(data, left, right)) =
                collapse(left) @ data::collapse(right);
This code works well, but I have a slight preference in this case for expressing it as the appending of three different lists:

        fun collapse(Empty) = []
        |   collapse(Node(data, left, right)) =
                collapse(left) @ [data] @ collapse(right);
The first version is better in the sense that it demonstrates an understanding of the difference between the cons operator(::) and the append operator(@). But I prefer the second because I conceive of the problem as putting together three different things. Both are perfectly fine ways to write the code.

I pointed out to people that I should have called the "depth" function "height". It is common to confuse those terms and I did so. The better terminology is to think of each node as having a particular depth in the tree and the overall tree has a height which is the maximum depth.

We had a bit of an argument about how to compute this. I admitted that I'm in the minority because most of the standard texts use a slightly different definition. Consider a tree like this:

      18
        \
         27
        /
       9
What is the depth of the node with 9 in it? We compute it by finding the length of the path from the root to the node, but there are two things we could count: nodes or edges. If you're an edge counter (which is the current standard definition), you would say it has a depth of 2. if you're a node counter like me, you'd say it has a depth of 3. At least I have Don Knuth on my side. I'm a node counter because I want to have a good answer to the question, "What is the height of the empty tree?" For me, the answer is 0. For edge counters, it's either -1 or undefined or "I'm not sure."

In any event, I said that we'd implement it using my definition. I renamed the old depth function to be called height:

        fun height(Empty) = 0
        |   height(Node(root, left, right)) = 1 + Int.max(height(left),
                                                          height(right));
And then I asked how we'd write a function to find the depth of a given value in a search tree. I said that for variety's sake, let's start with the nonempty tree case:

        fun depthOf(n, Node(root, left, right)) =
            ...
If the value stored at the root is n, then this value has a depth of 1 (it appears in level 1 of the tree):

        fun depthOf(n, Node(root, left, right)) =
            if root = n then 1
            ...
What if it's not at the root? It's a binary search tree, so to be efficient, we should either search the left subtree or the right subtree, but not both:

        fun depthOf(n, Node(root, left, right)) =
            if root = n then 1
            else if n < root then 1 + depthOf(n, left)
            else 1 + depthOf(n, right);
I tried loading this definition into the interpreter and it worked fairly well:

        - val t = insertAll([3, 8, 2, 19, 7]);
        val t =
          Node
            (7,Node (2,Empty,Node (3,Empty,Empty)),
             Node (19,Node (8,Empty,Empty),Empty)) : intTree
        - depthOf(2, t);
        val it = 2 : int
        - depthOf(3, t);
        val it = 3 : int
        - depthOf(8, t);
        val it = 3 : int
But we ran into problems when we asked about a value not in the tree:

        - depthOf(1, t);

        uncaught exception Match [nonexhaustive match failure]
This isn't surprising, because we never included a case to handle the empty tree. So what do we return in this case? Someone suggested that we return -1. That would be a way to say, "We didn't find it." Someone else suggested that we raise an exception. So we could imagine that we have a precondition on the function that they shouldn't call it if the value isn't in the tree. That doesn't seem like a very friendly thing to do, though. Someone said they could call contains, but that means they have to search the tree twice: once to see if it's there, and a second time to find its depth.

ML offers a good alternative. This is a good place to use the option type. Remember that an option is appropriate when the answer is "0 or 1 of" something. That's true in this case. Sometimes there is an answer, sometimes there isn't. So we added the following base case:

        fun depthOf(n, empty) = NONE
        |   depthOf(n, Node(root, left, right)) =
                if root = n then 1
                else if n < root then 1 + depthOf(n, left)
                else 1 + depthOf(n, right);
Unfortunately, ML complained that we were inconsistent. We can't return an option in one case and not the other. The option type has two possibilities:

        datatype 'a option = NONE | SOME of 'a;
So we need to use the SOME constructor for our second case. I tried wrapping our expressions with SOME, but that didn't work because now our recursive calls return int options. Someone suggested wrapping the entire if/else in SOME, but that has the same problem. Someone else suggested that we use the valOf function to extract the value returned by the recursive calls, but that doesn't work either because sometimes the recursive calls return NONE.

I said that I'd take some time to figure out a good way to do this and I'd include it in the notes. There is a good fix to this. We can write the function in a tail recursive way with a helper function that computes the depth as we descend the tree. That way there are just two places where we indicate a return value and those are the places to use NONE or SOME:

        fun depthOf(n, t) =
                let fun explore(d, Empty) = NONE
                    |   explore(d, Node(root, left, right)) =
                            if root = n then SOME d
                            else if n < root then explore(d + 1, left)
                            else explore(d + 1, right)
                in explore(1, t)
                end;
I mentioned that I'm asking you to use an option type for one of the problems in the homework.

I also briefly mentioned that I'm asking people to use the built-in type known as "order". It's like Java's compareto method. It has three different values:

        - LESS;
        val it = LESS : order
        - EQUAL;
        val it = EQUAL : order
        - GREATER;
        val it = GREATER : order
I said that this gives you a more complete way to describe an ordering than the kind of simple boolean functions we were using with the qsort function. I said we'd discuss this more in section.


Stuart Reges
Last modified: Fri Jan 26 08:21:21 PST 2007