CSE341 Notes for Friday, 1/12/07

We spent most of the lecture writing a function called qsort that uses the quicksort algorithm to sort a list. Initially we limited ourselves to sorting lists of ints. We began with our usual question about an easy list to work with and someone said we should have a case for empty list, so we began with:

        fun qsort([]) = []
        |   qsort(x::xs) = ?
Then I asked if anyone remembered how quicksort works. Someone mentioned that the main steps are to:
  • Pick a value from the list that we refer to as the pivot.

  • Split the list into 2 parts: values less than the pivot and values greater than the pivot. I said that this step is often referred to as partitioning the list and the two parts are often referred to as partitions. I mentioned that we have to include the possibility of values equal to the pivot, although it doesn't matter which partition we put them into.

  • Quicksort the two partitions.

  • Put the pieces together.

    One nice thing about quicksort is that putting the pieces together isn't difficult. We know that the values in the first partition all come before the values in the second partition, so it's just a matter of gluing the pieces together.

    I said that we'd keep things simple by using the first value in the list as our pivot. This isn't an ideal choice, especially if the list is already sorted, but it will work well for the randomized lists we want to work with. So I changed the variable names in our code to reflect this choice:

            fun qsort([]) = []
            |   qsort(pivot::rest) = ?
    
    The first step in solving this is to partition the list, so I asked people how to implement this in ML and people seemed to be stumped. That's not surprising because we're just starting to learn ML and this is a nontrivial computation. I said that you don't have to abandon your programming instincts from procedural programming. So how would you solve it in a language like Java if you were asked to work with a linked list?

    By thinking through that, we developed the following pseudocode for partitioning the list:

             partition 1 = empty list
             partition 2 = empty list
             while (current is not null) {
                if (first value belongs in partition 1) {
                    add it to partition 1
                } else {
                    add it to partition 2
                }
                remove first value
              }
              finish up
    
    I said that you can convert this iterative solution to a recursive solution without much trouble. With a loop you use a set of local variables to keep track of the current state of your computation. Here there are three such local variables: the list that we are partitioning, the first partition, and the second partition. Local variables like these become parameters to a helper function. Our helper function is supposed to split the list, so we decided to call it "split". Since the loop involves three variables, two of which are initialized to be empty lists, we write split as a helper function of three parameters and we include empty lists for two of the parameters in the initial call:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split(xs, ys, zs) = ?
                    in split(rest, [], [])
                    end;
    
    Notice how the call after the word "in" exactly parallels the situation before our loop begins executing. Our three state variables are the list of values to split and two variables for storing the two partitions which are initially empty.

    In our pseudocode we continue until the overall list becomes empty, at which time we "finish up" the computation. We can include this as one of the cases for our helper function:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = finish up
                        |   split(xs, ys, zs) = ?
                    in split(rest, [], [])
                    end;
    
    Compare this initial case for split versus the initial call for split. We go from having these three values for our computation:

            (original list, [], [])
    
    to having this set of values for our computation:

            ([], partition 1, partition 2)
    
    In other words, we go from having all of the values stored in the original list and having two empty partitions to having an empty list of values and two partitions that have been filled in. Now we just have to describe how we go from one to the other. In our pseudocode, each time through the loop we handled one element of the original list, either moving it to partition 1 or moving it to partition 2. We can do the same thing with our helper function. So first we need to indicate that in the second case for split, we want to process one value from the original list. We do so by replacing the "xs" above with "x::xs":

    .

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = finish up
                        |   split(x::xs, ys, zs) = ?
                    in split(rest, [], [])
                    end;
    
    We had an if/else in the loop and we can use an if/else here. I asked people if they wanted to include values equal to the pivot in the first or second partition and the first person to answer said to put them in the first partition, so we expanded this to:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = finish up
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then (x goes in 1st partition)
                                else (x goes in 2nd partition)
                    in split(rest, [], [])
                    end;
    
    If x belongs in the first partition, then we want to go from having this set of values:

            (x::xs, ys, zs)
    
    to having this set of values:

            (xs, x::ys, zs)
    
    In other words, we move x from the list of values to be processed into the first partition. In the loop, we'd then come around the loop for the next iteration. In a recursive solution, we simply make a recursive call with those new values:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = finish up
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then split(xs, x::ys, zs)
                                else (x goes in 2nd partition)
                    in split(rest, [], [])
                    end;
    
    In the second case we do something similar, moving x into the second partition and using a recursive call to continue the computation:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = finish up
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then split(xs, x::ys, zs)
                                else split(xs, ys, x::zs)
                    in split(rest, [], [])
                    end;
    
    The only thing we had left to fill in was the "finish up" part. Someone suggested that split should return an empty list when the original list becomes empty. While that might be an appropriate choice for a splitting utility, it's not what we want here. Remember that reaching that point in the code is like finishing the loop in our pseudocode. We started out by saying that we need to quicksort the two partitions. That needs to be part of what we do in the "finish up" part:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = need to: qsort(ys), qsort(zs)
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then split(xs, x::ys, zs)
                                else split(xs, ys, x::zs)
                    in split(rest, [], [])
                    end;
    
    The qsort function returns a list, so what do we do with the two sorted lists that come back from these recursive calls? We glue them together with append:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = qsort(ys) @ qsort(zs)
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then split(xs, x::ys, zs)
                                else split(xs, ys, x::zs)
                    in split(rest, [], [])
                    end;
    
    This is not quite right, though, because we haven't accounted for the pivot. We didn't add it to either of our partitions. That's, in general, a good thing, because it guarantees that each of the partitions we recursively pass to qsort will be smaller than the original list. But it means we have to manually place the pivot into the result. It belongs right in the middle of the two partitions. We could use a cons operator ("::") to indicate this, but I think it's a little clearer in this case to show that we are gluing three pieces together, one of which is the pivot itself:

            fun qsort([]) = []
            |   qsort(pivot::rest) =
                    let fun split([], ys, zs) = qsort(ys) @ [pivot] @ qsort(zs)
                        |   split(x::xs, ys, zs) =
                                if x <= pivot then split(xs, x::ys, zs)
                                else split(xs, ys, x::zs)
                    in split(rest, [], [])
                    end;
    
    At that point we were done. This is a working version of quicksort. I loaded it in the ML interpreter and we managed to sort some short lists. I then loaded a file that has some utility functions in it. One of them produces random lists:

        fun randList(n) =
                let val time = IntInf.toInt(Time.toSeconds(Time.now()) mod 100000)
                    val r = Random.rand(time, 42)
                    fun build(0) = []
                    |   build(m) = Random.randInt(r)::build(m - 1)
                in build(n)
                end;
    
    This function calls some built-in utilities for finding out the current time and using that to seed a random number generator. I pointed out that under the "ML resources" section of the class web page I have a link to the documentation for what is known as the "standard basis library" that contains many such useful utilities. This is similar to the way that Java has packages that you can access. As in Java, you can use the dot notation, as I have in the code above. You also have the option to "open" one of these structures, which is similar to doing an import in Java.

    Using this function, we generated some lists of varying length, including a list of 100 thousand ints, and we were able to sort them using our qsort function.

    I had only ten minutes left at that point, so I turned to the new topic that is the main point of the lecture: higher-order functions. In ML, functions are first class data values just like ints, strings, reals, and lists. That means that you can pass functions as arguments to other functions. A function that takes another function as an argument is called a higher-order function.

    So I turned back to our qsort function. I first asked why ML interpreted it as a function acting on ints. We never mentioned int anywhere in the function definition. Someone correctly pointed out that it's the "<=" comparison, which defaults to type int. So I modified the function so that it takes a comparison function as an argument and I replaced the "<=" comparison with a call on the function passed as an argument. This required several changes because each recursive call and each pattern had to be updated:

        fun qsort(f, []) = []
        |   qsort(f, pivot::rest) =
                let fun split([], ys, zs) = qsort(f, ys) @ [pivot] @ qsort(f, zs)
                    |   split(x::xs, ys, zs) =
                            if f(x, pivot) then split(xs, x::ys, zs)
                            else split(xs, ys, x::zs)
                in split(rest, [], [])
                end;
    
    I asked people to predict what ML would say the type of this function is. I first asked ML what the type of the <= operator is by using the "op" keyword in the interpreter:

            op <=;
    
    ML reported its type as "int * int -> bool". In the new quicksort, ML will not default to type int. Instead it will be polymorphic:

            val qsort = fn : ('a * 'a -> bool) * 'a list -> 'a list
    
    This indicates that qsort takes a comparison function and a list of 'a values and that it returns a list of 'a values. The comparison function takes a tuple of two 'a values and returns a bool.

    We were still able to do the original sorting by saying:

            val x = randList(100);
            qsort(op <=, x);
    
    But now we had the flexibility to sort it backwards:

            qsort(op >=, x);
    
    And to define our own comparison function that sorts on magnitude:

            fun lessMagnitude(x, y) = abs(x) < abs(y);
            qsort(lessMagnitude, x);
    
    So we can easily change the definition of ordering to have this sort in a different way, but we can also sort different kinds of lists, as in:

            qsort(op <=, [3.4, 2.1, 4.7, 19.8, ~17.4]);
    
    In this case ML sees that the second argument is a list of real values, so instead of defaulting to integers for the "<=" operator, it uses the version for real values. Similarly, we were able to sort a list of characters:

            qsort(op <=, explode("stuart reges"));
    
    In other words, we have written a powerful, general-purpose sorting utility that we can use to sort lists of any kind. Java has similar utilities, but the syntactic overhead is high. You have to define an object that implements the Comparator interface. In ML it is much simpler. Functions can be passed easily as arguments to other functions.

    I then mentioned that we will be talking about three of the most important higher-order functions: map, filter, and reduce. I didn't have time to discuss them in detail, although they're discussed in detail in section 5.4 of the Ullman book. We first looked at map:

            fun map(f, []) = []
            |   map(f, x::xs) = f(x)::map(f, xs);
    
    This function applies a function to every element of a list. I introduced another utility function to make it easier to explore these functions. It's a variation of the range function from the first homework that is defined as an infix operator:

            infix --;
            fun m--n =
                    if m > n then []
                    else m::(m + 1--n);
    
    This allows you to ask for a list of integer in a particular range. Here are a few examples from the interpreter:

    - 1--10;
    val it = [1,2,3,4,5,6,7,8,9,10] : int list
    - 1--100;
    val it =
      [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,
       29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,
       54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,
       79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100]
      : int list
    
    Using this function and map, I converted the integers 1 through 100 to real numbers:

            - map(real, 1--10);
            val it = [1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0] : real list
    
    and I used this list to compute the square roots of these:

            - map(Math.sqrt, map(real, 1--10));
            val it =
              [1.0,1.41421356237,1.73205080757,2.0,2.2360679775,2.44948974278,
               2.64575131106,2.82842712475,3.0,3.16227766017] : real list
    
    Then I showed the filter function, which takes a predicate function (a boolean test) and a list as arguments and that returns the list of values that satisfy the given predicate. We write it this way:

            fun filter(f, []) = []
            |    filter(f, x::xs) =
                    if f(x) then x::filter(f, xs)
            	else filter(f, xs);
    
    Given this function and the prime function we wrote last time, I wrote this expression to request a list of all primes up to 1000:

    - filter(prime, 1--1000);
    val it =
      [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,
       103,107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,
       197,199,211,223,227,229,233,239,241,251,257,263,269,271,277,281,283,293,
       307,311,313,317,331,337,347,349,353,359,367,373,379,383,389,397,401,409,
       419,421,431,433,439,443,449,457,461,463,467,479,487,491,499,503,509,521,
       523,541,547,557,563,569,571,577,587,593,599,601,607,613,617,619,631,641,
       643,647,653,659,661,673,677,683,691,701,709,719,727,733,739,743,751,757,
       761,769,773,787,797,809,811,821,823,827,829,839,853,857,859,863,877,881,
       883,887,907,911,919,929,937,941,947,953,967,971,977,983,991,997] : int list
    
    I didn't have time to discuss reduce, so I suggested that people read over the section in the book. I also mentioned that these aren't the standard definitions of these functions. We won't see the "real" versions of these until we've had a chance to discuss what are known as curried functions, which is the next section of the Ullman book.

    For those who are interested, I included the various utilities from this lecture (qsort, randList, --, prime, map, filter, reduce) in a file called samp2.sml that I have posted as handout #7.


    Stuart Reges
    Last modified: Sun Jan 21 10:23:03 PST 2007