\( \newcommand{\NOT}{\neg} \newcommand{\AND}{\wedge} \newcommand{\OR}{\vee} \newcommand{\XOR}{\oplus} \newcommand{\IMP}{\Rightarrow} \newcommand{\IFF}{\Leftrightarrow} \newcommand{\TRUE}{\text{True}\xspace} \newcommand{\FALSE}{\text{False}\xspace} \newcommand{\IN}{\,{\in}\,} \newcommand{\NOTIN}{\,{\notin}\,} \newcommand{\TO}{\rightarrow} \newcommand{\DIV}{\mid} \newcommand{\NDIV}{\nmid} \newcommand{\MOD}[1]{\pmod{#1}} \newcommand{\MODS}[1]{\ (\text{mod}\ #1)} \newcommand{\N}{\mathbb N} \newcommand{\Z}{\mathbb Z} \newcommand{\Q}{\mathbb Q} \newcommand{\R}{\mathbb R} \newcommand{\C}{\mathbb C} \newcommand{\cA}{\mathcal A} \newcommand{\cB}{\mathcal B} \newcommand{\cC}{\mathcal C} \newcommand{\cD}{\mathcal D} \newcommand{\cE}{\mathcal E} \newcommand{\cF}{\mathcal F} \newcommand{\cG}{\mathcal G} \newcommand{\cH}{\mathcal H} \newcommand{\cI}{\mathcal I} \newcommand{\cJ}{\mathcal J} \newcommand{\cL}{\mathcal L} \newcommand{\cK}{\mathcal K} \newcommand{\cN}{\mathcal N} \newcommand{\cO}{\mathcal O} \newcommand{\cP}{\mathcal P} \newcommand{\cQ}{\mathcal Q} \newcommand{\cS}{\mathcal S} \newcommand{\cT}{\mathcal T} \newcommand{\cV}{\mathcal V} \newcommand{\cW}{\mathcal W} \newcommand{\cZ}{\mathcal Z} \newcommand{\emp}{\emptyset} \newcommand{\bs}{\backslash} \newcommand{\floor}[1]{\left \lfloor #1 \right \rfloor} \newcommand{\ceil}[1]{\left \lceil #1 \right \rceil} \newcommand{\abs}[1]{\left | #1 \right |} \newcommand{\xspace}{} \newcommand{\proofheader}[1]{\underline{\textbf{#1}}} \)

CSC111 Lecture 10A: Recursive Sorting Algorithms, Part 1

Exercise 1: In-place partitioning

We’re going to study how to implement an in-place version of _partition that mutates lst directly, without creating any new lists. This is the key step to implementing an in-place version of quicksort.

def _in_place_partition(lst: list) -> None:
    """Mutate lst so that it is partitioned with pivot lst[0].

    Let pivot = lst[0]. The elements of lst are moved around so that lst looks like

        [x1, x2, ... x_m, pivot, y1, y2, ... y_n],

    where each of the x's is <= pivot, and each of the y's is > pivot.

    >>> lst = [10, 3, 20, 5, 16, 30, 7, 100]
    >>> _in_place_partition(lst)  # pivot is 10
    >>> lst[3]  # the 10 is now at index 3
    10
    >>> set(lst[:3]) == {3, 5, 7}
    True
    >>> set(lst[4:]) == {16, 20, 30, 100}
    True
    """

For your reference, there are the diagrams of the example we saw in lecture:

Partition example (0)

Partition example (1)

Partition example (2)

Partition example (3)

Partition example (4)

Partition example (5)

Partition example (6)

Partition example (7)

Partition example (8)

  1. The key idea to implement this function is to divide up lst into three parts using indexes small_i and big_i:

    • lst[1:small_i] contains the elements that are known to be <= lst[0] (the “smaller partition”)

    • lst[big_i:] contains the elements that are known to be > lst[0] (the “bigger partition”)

    • lst[small_i:big_i] contains the elements that have not yet been compared to the pivot (the “unsorted section”)

    _in_place_partition uses a loop to go through the elements of the list and compare each one to the pivot, building up either the “smaller” or the “larger partition” and shrinking the “unsorted section.”

    1. When we first begin the algorithm, we know that lst[0] is the pivot, but have not yet compared it against any other list element. What should the initial values of small_i and big_i be?

    2. We need to check the items one at a time, until every item has been compared against the pivot. What is the relationship between small_i and big_i when we have finished checking every item?

    3. On each loop iteration, the element lst[small_i] is compared to the pivot.

      1. Suppose lst[small_i] is less than or equal to the pivot, as in the diagram below.

        In-place partition, when lst[small_i] is <= the pivot.

        Which partition should lst[small_i] belong to? Write the code to update small_i to add this element to the correct the partition.

      2. Now suppose lst[small_i] is greater than the pivot, as in the diagram below.

        In-place partition, when lst[small_i] is > the pivot.

        Which partition should lst[small_i] belong to? Write the code to swap this element and update small_i or big_i to add this element to the correct partition.

  2. Next, implement _in_place_partition. Make sure you’re confident in your answers to the previous questions before writing the main loop!

    def _in_place_partition(lst: list) -> None:
        # Initialize variables
        pivot = lst[0]
    
        small_i =
    
        big_i =
    
    
        # The main loop
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
        # At this point, all elements have been compared.
        # The final step is to move the pivot into its correct position in the list
        # (after the "smaller" partition, but before the "bigger" partition).
    
    
  3. We’re going to need to make this helper a bit more general in order to use it in our a modified quicksort algorithm. If you still have time, complete the following modifications to your implementation of _in_place_partition.

    1. Modify your implementation so that it returns the new index of the pivot in the list (so that when _in_place_partition is called in quicksort, we know where the partitions start and end).

    2. Modify your implementation so that it takes in two additional parameters b and e, so that the function only partitions the range lst[b:e] rather than the entire list.

Exercise 2: Running-time analysis for mergesort

Here is our mergesort implementation.

def mergesort(lst: list) -> list:
    if len(lst) < 2:
        return lst.copy()  # Use the list.copy method to return a new list object
    else:
        # Divide the list into two parts, and sort them recursively.
        mid = len(lst) // 2
        left_sorted = mergesort(lst[:mid])
        right_sorted = mergesort(lst[mid:])

        # Merge the two sorted halves. Using a helper here!
        return _merge(left_sorted, right_sorted)

We saw in lecture that the recursive call tree for mergesort is a binary tree like the following:

Recusion diagram for mergesort

In this exercise, you’ll complete a running-time analysis for mergesort.

  1. Suppose we call mergesort on a list of length 8. Draw the corresponding recursion diagram, and inside each node write down the non-recursive running time of the call, which we count as equal to the size of the input list for that call.

    (For example, the root of the tree should contain an “8”, and its two children should be “4”s.)

  2. Compute the total of the numbers in your above diagram. This gives you the total running time for mergesort on a list of length 8.

  3. Now suppose we have a list of length \(n\), where \(n\) is a power of 2. Fill in the recursion diagram below with the corresponding non-recursive running times. The root node should be filled in with \(n\).

    Recusion diagram for mergesort
  4. Compute the total of the numbers in your above diagram, again assuming \(n\) is a power of 2.

    Hint: consider the sum of the numbers in each level of the tree.

Exercise 3: Quicksort running time and uneven partitions

Now consider the quicksort algorithm.

def quicksort(lst: list) -> list:
    if len(lst) < 2:
        return lst.copy()
    else:
        pivot = lst[0]
        smaller, bigger = _partition(lst[1:], pivot)

        smaller_sorted = quicksort(smaller)
        bigger_sorted = quicksort(bigger)

        return smaller_sorted + [pivot] + bigger_sorted

Its recursive step also makes two recursive calls, but unlike mergesort, the input list lengths are not necessarily always the same size.

  1. Suppose we call quicksort([0, 10, 20, 30, 40]). After the _partition call, what are smaller and bigger?

  2. Draw a recursion tree showing the inputs to each recursive call, when we call quicksort([0, 10, 20, 30, 40]). We’ve started the first two levels for you below. The node containing [] represents a recursive call on an empty list.

    Start of quicksort tree

  3. Now redraw the recursion tree, but with the non-recursive running time in each node.

    • For an empty list, the non-recursive running time is 1.
    • For a non-empty list, the non-recursive running time is the length of the list.
  4. Add up all of the numbers in the above diagram.

  5. Generalize the above calculation for a call to quicksort with a list of length \(n\), when the chosen pivot is always the smallest element in the list.

Additional exercises

  1. To practice the in-place partition algorithms code we covered today, try implementing the following variations.

    1. Implement _in_place_partition, but choosing the last element list (lst[-1]) as the pivot instead.
    2. Implement _in_place_partition_indexed, but choosing the last element of the range (lst[e - 1]) as the pivot instead.
  2. Here is the implementation of the helper function _partition used by quicksort.

    def _partition(lst: list, pivot: Any) -> tuple[list, list]:
        smaller = []
        bigger = []
    
        for item in lst:
            if item <= pivot:
                smaller.append(item)
            else:
                bigger.append(item)
    
        return smaller, bigger
    1. Analyze the running time of _partition in terms of \(n\), the length of its input list.

    2. How would your analysis change if each item were inserted at the front of the relevant list , for example:
      smaller.insert(0, item)?

  3. Analyze the running time of your _in_place_partition implementation.

  4. In lecture, we saw that our implementation of quicksort (always choosing the first element to be the pivot) has a \(\Theta(n^2)\) running time when given an already-sorted list \([0, 1, \dots, n-1]\). This is because one of the partitions is always an empty list, at every recursive call.

    1. Find another list that always results in the most unequal partitions (i.e., one of the partitions is always an empty list at every recursive call) with our implementation of quicksort.
    2. (harder) For a given \(n \in \N^+\) where \(n \geq 2\), find a formula for the number of lists of length \(n\) that result in the most unequal partitions with our implementation of quicksort. Note: this number is \(> 2\).