CSE413 Notes for Friday, 1/12/24

I first pointed out that in the second homework I am making available a number of utility functions that are automatically included because the file you are completing begins with a "use" directive that reads from that file. One of the functions it provides is an infix operator -- that is very convenient for producing lists of sequential int values, as in:
        # 5--15;;
        - : int list = [5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15]
I pointed out that students might find this helpful for testing the functions you have been asked to write for the second homework.

I started the lecture by writing a function called qsort that uses the quicksort algorithm to sort a list. 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:

        let rec qsort(lst) =
            match lst with
            | [] -> []
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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
    
    The first step in solving this is to partition the list, so I asked people how to implement this in OCaml and people seemed to be stumped. That's not surprising because we're just starting to learn OCaml 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:

            start with an empty lst1
            start with an empty lst2
            while (more values from original list)
                look at first element
                    if it is <= pivot, put it in lst1
                    else put it in lst2
              }
              finish up
    
    I said that you can convert this iterative solution to a recursive solution without much trouble. With a loop-based approach 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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) = ?
                     in split(rest, [], [])
    
    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 (which I have called "rest") 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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | [] -> finish up
                     in split(rest, [], [])
    
    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 including a pattern for lst1 with "x::xs":

    .

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> finish up
                         | x::xs -> ?
                     in split(rest, [], [])
    
    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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> finish up
                         | x::xs -> if x <= pivot then (x goes in 1st partition)
                                    else (x goes in 2nd partition)
                     in split(rest, [], [])
    
    If x belongs in the first partition, then we want to go from having this set of values:

            (x::xs, lst2, lst3)
    
    to having this set of values:

            (xs, x::lst2, lst3)
    
    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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> finish up
                         | x::xs -> if x <= pivot then split(xs, x::lst2, lst3)
                                    else (x goes in 2nd partition)
                     in split(rest, [], [])
    
    In the second case we do something similar, moving x into the second partition and using a recursive call to continue the computation:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> finish up
                         | x::xs -> if x <= pivot then split(xs, x::lst2, lst3)
                                    else split(xs, lst2, x::lst3)
                     in split(rest, [], [])
    
    The only thing we had left to fill in was the "finish up" part. We often return an empty list when the list we are processing 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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> need to: qsort(lst2), qsort(lst3)
                         | x::xs -> if x <= pivot then split(xs, x::lst2, lst3)
                                    else split(xs, lst2, x::lst3)
                     in split(rest, [], [])
    
    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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> qsort(lst2) @ qsort(lst3)
                         | x::xs -> if x <= pivot then split(xs, x::lst2, lst3)
                                    else split(xs, lst2, x::lst3)
                     in split(rest, [], [])
    
    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:

             let rec qsort(lst) =
                 match lst with
                 | []          -> []
                 | pivot::rest ->
                     let rec split(lst1, lst2, lst3) =
                         match lst1 with
                         | []    -> qsort(lst2) @ [pivot] @ qsort(lst3)
                         | x::xs -> if x <= pivot then split(xs, x::lst2, lst3)
                                    else split(xs, lst2, x::lst3)
                     in split(rest, [], [])
    
    At that point we were done. This is a working version of quicksort. I loaded it in the OCaml interpreter and we managed to sort some short lists. It is polymorphic, so it can be used to sort lists of any type.

    Then we developed a function that will give us a long list of random int values to test our qsort function. I pointed out that OCaml has a module called Random that provides functions for generating pseudo-random values. The "int" function will produce integer values between 0 (inclusive) and a provided maximum (exclusive), as in this request to produce values up to (but not including) a million:

            # Random.int(1000000);;
            - : int = 809344
    
    I asked how we can use this to write a function for producing a list of random values. This should seem pretty familiar now and is easily solved with pattern matching:

             let rec random_numbers(n) =
                 match n with
                 | 0 -> []
                 | n -> Random.int(1000000)::random_numbers(n - 1)
    
    Notice that you can refer to int constants like 0 in a pattern match. We used this to test qsort with 100 thousand values and it produced a result with only a slight pause:

    
            # let test = random_numbers(100000);;
            val test : int list =
              [355692; 967646; 102460; 468164; 957099; 253742; 995927; 236484; 757543;
               811880; 969298; 122720; 634383; 313271; 325315; 374557; 892792; 289007;
               821179; 613454; 20076; 519566; 572182; 285153; 38210; 196467; 958646;
               886025; 826978; 49151; 118716; 349436; 180137; 994924; 792677; 200466;
               942609; 313343; 848896; 209582; 597243; 281513; 484526; 10428; 635532;
               98948; 428993; 645765; 146386; 415626; 412811; 793064; 107925; 807628;
               380957; 6260; 787493; 849348; 675479; 756635; 311131; 682575; 129431;
               547965; 956470; 429603; 380012; 623958; 677737; 968165; 687377; 247009;
               884436; 405561; 942495; 227020; 153449; 506388; 904195; 31960; 150805;
               851119; 210552; 70365; 738034; 177976; 961833; 632166; 38450; 104921;
               562280; 318648; 635603; 880730; 522199; 350239; 397082; 78947; 809747;
               150366; 945491; 664443; 483422; 114429; 6651; 303492; 582516; 779362;
               69593; 944816; 786268; 155322; 810882; 312371; 936167; 992480; 46958;
               248684; 767615; 606209; 774354; 301070; 556053; 269199; 22659; 764737;
               505429; 770006; 140708; 639000; 644507; 115922; 436768; 701185; 987490;
               995469; 40224; 640025; 134192; 745537; 131016; 454950; 606390; 174079;
               72408; 261213; 504431; 366404; 599879; 499269; 586629; 627081; 680035;
               170737; 505881; 571411; 969472; 782228; 868755; 753014; 263278; 684307;
               645153; 65864; 813818; 73130; 290775; 636801; 981781; 674951; 229863;
               680571; 648804; 268351; 19578; 147268; 362849; 385320; 505151; 193071;
               537117; 723213; 643099; 387692; 117555; 702035; 470771; 370898; 629208;
               914362; 704692; 403413; 658250; 201072; 257479; 192285; 806171; 338044;
               154498; 925125; 640160; 119111; 603091; 148616; 703998; 863418; 725819;
               292333; 794991; 545689; 100656; 598590; 894838; 954399; 48343; 859865;
               767888; 185899; 550226; 621532; 266957; 952732; 224100; 798476; 776631;
               110773; 819314; 241996; 306191; 156164; 163294; 812324; 332131; 701295;
               92400; 938515; 828374; 688686; 81177; 257666; 934146; 26688; 562519; 7649;
               389234; 493763; 921150; 601580; 424433; 146709; 114813; 564167; 773681;
               998333; 503678; 827824; 528802; 901801; 56217; 765473; 35044; 37134;
               211161; 279157; 611529; 73976; 777919; 629017; 306987; 296398; 680522;
               166307; 761451; 403510; 248532; 444639; 920865; 225791; 294796; 948803;
               581980; 917508; 185788; 540714; 121123; 126148; 601161; 52975; 831597;
               18430; 902400; 941160; 381704; 526617; 577151; 509637; 7043; 159021;
               759132; ...]
            # qsort(test);;  
            - : int list =
            [21; 24; 26; 31; 34; 66; 97; 102; 105; 111; 118; 119; 123; 133; 147; 173;
             179; 187; 189; 192; 194; 202; 207; 210; 213; 228; 245; 255; 291; 294; 304;
             341; 343; 352; 365; 373; 378; 382; 385; 387; 405; 405; 417; 423; 425; 428;
             433; 443; 443; 447; 450; 450; 461; 467; 468; 468; 501; 502; 530; 544; 550;
             552; 564; 569; 586; 590; 593; 596; 597; 633; 635; 655; 659; 664; 680; 688;
             692; 694; 699; 701; 707; 724; 725; 750; 753; 753; 768; 777; 782; 787; 799;
             808; 822; 836; 848; 849; 860; 863; 867; 867; 900; 907; 909; 926; 948; 949;
             964; 971; 972; 973; 975; 978; 979; 996; 1000; 1002; 1029; 1045; 1061; 1062;
             1070; 1104; 1105; 1128; 1129; 1130; 1131; 1135; 1137; 1147; 1157; 1182;
             1183; 1198; 1202; 1204; 1206; 1210; 1215; 1215; 1224; 1234; 1243; 1244;
             1246; 1257; 1264; 1269; 1274; 1276; 1291; 1294; 1297; 1305; 1305; 1321;
             1323; 1330; 1335; 1337; 1344; 1363; 1366; 1377; 1382; 1388; 1400; 1402;
             1409; 1427; 1429; 1430; 1433; 1457; 1479; 1481; 1488; 1488; 1489; 1508;
             1522; 1527; 1555; 1584; 1596; 1598; 1612; 1634; 1642; 1648; 1650; 1655;
             1665; 1672; 1674; 1699; 1723; 1728; 1732; 1739; 1751; 1767; 1776; 1795;
             1808; 1810; 1813; 1819; 1828; 1846; 1851; 1858; 1866; 1873; 1874; 1884;
             1891; 1891; 1900; 1909; 1920; 1922; 1923; 1928; 1935; 1940; 1942; 1948;
             1955; 1961; 1966; 1968; 1987; 1990; 1995; 1995; 2013; 2015; 2016; 2027;
             2027; 2029; 2043; 2058; 2080; 2089; 2092; 2097; 2098; 2122; 2140; 2142;
             2161; 2177; 2185; 2196; 2202; 2210; 2212; 2223; 2246; 2258; 2268; 2268;
             2269; 2276; 2277; 2280; 2294; 2297; 2297; 2299; 2309; 2313; 2316; 2329;
             2330; 2348; 2351; 2364; 2368; 2408; 2426; 2444; 2449; 2455; 2457; 2460;
             2464; 2488; 2497; 2498; 2518; 2525; 2533; 2556; 2579; 2582; 2603; ...]
    
    I then introduced a new topic. Programmers have a notion of what we call first class citizens in a programming language. First class citizens are values that can be stored in variables, passed as parameters, and used as the return values.

    Everyone understands that numbers are first class citizens in most programming languages. You can store numbers, you can pass numbers as parameters to a function, and you can return numbers from a function. Novices find it a bit odd that boolean values are also first class citizens in most programming languages including Java, C, C++, Python, and so on. Novices often find it puzzling to think that a variable can store true or false and in intro Java courses students often find it challenging to write methods that have a boolean return type.

    In functional languages, functions themselves are first class citizens. We have seen that they can be stored in variables in the sense that we can use a let definition or expression to bind a name to a function. We're about to explore passing functions as parameters and returning functions from a function. We call functions that take other functions as parameters or that return them as their result as higher order functions.

    I then introduced what can be thought of as the "big three" of higher-order functions: map, filter, and reduce. I mentioned that I am going to begin by showing versions of these functions that take a pair (2-tuple) as parameters, like the other functions we've been writing, but in OCaml we normally use a different version of these functions that we'll discuss in future lectures.

    We first looked at map:

             let rec map(f, lst) =
                 match lst with
                 | []    -> []
                 | x::xs -> f(x)::map(f, xs)
    
    This function applies a function to every element of a list. I asked people how to convert from int to float and someone said the function is called float_of_int. We can map that over a list of int values obtained using our -- operator:

            # map(float_of_int, 1--10);;
            - : float list = [1.; 2.; 3.; 4.; 5.; 6.; 7.; 8.; 9.; 10.]
    
    I tried asking for the square root of a list of ints, but that failed:

            # map(sqrt, 1--10);;
            Error: This expression has type int list
                   but an expression was expected of type float list
                   Type int is not compatible with type float 
    
    The sqrt function requires a parameter of type float, so this didn't work. But we can fix it by first mapping float_of_int over the list and them mapping sqrt over that:

            # map(sqrt, map(float_of_int, 1--10));;
            - : float list =
            [1.; 1.41421356237309515; 1.73205080756887719; 2.; 2.23606797749979;
             2.44948974278317788; 2.64575131106459072; 2.82842712474619029; 3.;
             3.16227766016837952]
    
    I pointed out that in a procedural style program we tend to write command oriented functions like "convert this to double" and "compute the square root." In a functional language we tend to compose function calls, as in the example above (f(g(h(...)))).

    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:

             let rec filter(f, lst) =
                 match lst with
                 | []    -> []
                 | x::xs -> if f(x) then x::filter(f, xs)
                            else filter(f, xs)
    
    There is a repeated computation of filter(f, xs) that we could factor out by using a let expression to store the result in a local variable, but this doesn't introduce the kind of inefficiency problems we saw with the min function we wrote in an earlier lecture. Here the call on filter is computed only once in each branch.

    Using this definition and the function is_prime we wrote in the previous lecture, I was able to ask for primes up to 100:

            # filter(is_prime, 1--100);;
            - : int list =
            [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]
    
    The third higher-order function we looked at was reduce. The idea of reduce is that we often work with binary functions that take two values and combine them into one. This is true of simple arithmetic with addition, subtract, multiplication, etc. For example, given two numbers, we can find their sum. The reduce function takes a list of values and use a binary function to turn it into a single value, like the sum of a list.

    We intially wrote reduce this way:

             let rec reduce(f, lst) =
                 match lst with
                 | [x]   -> x
                 | x::xs -> f(x, reduce(f, xs))
    
    This produced the following warning:

            Warning 8: this pattern-matching is not exhaustive.
            Here is an example of a case that is not matched:
            []
    
    An empty list poses a problem for us. What is the sum of an empty list of numbers? It seems reasonable to say that it is 0. But what is the product of an empty list of numbers? We probably want it to be 1 because 1 is the multiplicative identity (we start variables at 1 when we compute a cumulative product).

    There are several ways to deal with this and we will explore all of them. For now, I called the invalid_arg function.

             let rec reduce(f, lst) =
                 match lst with
                 | []    -> invalid_arg("empty reduce")
                 | [x]   -> x
                 | x::xs -> f(x, reduce(f, xs))
    
    I wrote a function to add two numbers together and called reduce to add up the integers 1 through 10:

            # let sum(a, b) = a + b;;
            val sum : int * int -> int = <fun>
            # reduce(sum, 1--10);;
            - : int = 55
    
    Someone pointed out that a let expression would be a better choice in this case so that we don't introduce the sum function into the top level environment:

            # let sum(a, b) = a + b in reduce(sum, 1--10);;
            - : int = 55
    
    I mentioned that we'll see even nicer options in future lectures.


    Stuart Reges
    Last modified: Tue Feb 13 10:34:23 PST 2024