Divide and Conquer

Divide and conquer follows three stages:

  1. Divide the problem into smaller subproblems.

  2. Conquer the subproblems.

  3. Combine their solutions.

The strategy is particularly effective when subproblems are independent. The difference with the previous exercises in the Recursion section, is that instead of reducing the problem size by “one” (for example computing \(N!\) from \((N-1)! \cdot N\)), a divide and conquer algorithm will often try to create two independent subproblems of size \(N/2\).

Exercise (intermediate)

The quicksort algorithm is:

  • choosing a pivot

  • partitioning elements smaller and greater than the pivot

  • recursively sorting the partitions

  • combining the results

  1. Give the divide/conquer/combine steps.

  2. Implement and test.

  3. Worst and mean complexity.

Correction
  1. The divide step is the partitioning in two sequences: one with elements smaller than the pivot, one with elements greater than the pivot.

    The conquer step corresponds to applying the strategy down to the base case: an empty sequence or a singleton is already “sorted”.

    The combine step is almost trivial. The ouput of quicksort function is a sorted sequence. Thus, after the partitioning, the smaller element sequence is sorted, so is the larger element sequence, the combine step is just reassembling these two sorted sequence with the pivot in between.

  2. # do not focus on the type hinting
    
    from typing import Protocol, TypeVar
    
    
    T_contra = TypeVar("T_contra", contravariant=True)
    
    
    class SupportsLessThan(Protocol[T_contra]):
        def __lt__(self, other: T_contra, /) -> bool: ...
    
    
    U = TypeVar("U", bound=SupportsLessThan)
    
    src
    1def quicksort(seq: list[U]) -> list[U]:
    2    """conquer the base cases and combine sorted subsequences"""
    3    if len(seq) <= 1:
    4        return seq
    5
    6    left, pivot, right = partition(seq)
    7    return quicksort(left) + pivot + quicksort(right)
    
    src
     1def partition(seq: list[U]) -> tuple[list[U], ...]:
     2    """creates three subsequences,
     3    + elements strictly lower than a pivot
     4    + the pivot
     5    + the rest"""
     6    pivot = seq.pop()
     7    left: list[U] = []
     8    right: list[U] = []
     9    for elt in seq:
    10        if elt < pivot:
    11            left.append(elt)
    12        else:
    13            right.append(elt)
    14
    15    return left, [pivot], right
    
    tests
    1def test_quicksort() -> None:
    2    seq: list[float] = [3, -1, 5, 2, -7, 9]
    3    expected: list[float] = sorted(seq)
    4    assert quicksort(seq) == expected
    
  3. The costly operation is the comparaison.

    The worst case is when the last element for the partition is always the greatest, in this case the left subsequence contains all the elements except the last.

    It means \(n-1\) comparisons, then \(n-2\), \(n-3\), …

    For a total of \(n^2\).

    However, imagine that the pivot always split the sequence in exactly half. The number of required splitting levels is \(\propto \ln(n)\).

    For a total of \(n \ln(n)\).

    The mean expected splitting ratio is between half and one, which gives a \(n \ln(n)\) complexity too.

Exercise (intermediate)

The merge sort algorithm is:

  • repeatedly divides a sequence into approximately equal halves until subproblems contain one element

  • merges pairs of sorted sequences

  1. Give the divide/conquer/combine steps.

  2. Implement and test.

  3. Complexity. If sorting a list of 1 000 elements takes 2 s, how long does it take for 1 000 000 elements?

Correction
  1. The divide step is the successive division in half until singleton.

    The conquer step is the base case: a singleton is already sorted.

    All the leg work lies in the combine step: merging efficiently two sorted sequence. The trick is to first create the merged sequence in decreasing sorting order. To do so, append the element in an empty merged sequence and iterate from the right in one of the two sorted sequence where the current element is the largest, then at the end reverse the merged list.

  2. src
     1def merge_sort(seq: list[U]) -> list[U]:
     2    """divide in two subsequences,
     3    conquer the base case,
     4    combine two sorted subsequences"""
     5    nb_elts: int = len(seq)
     6    if nb_elts <= 1:
     7        return seq
     8
     9    median: int = nb_elts // 2
    10    left: list[U] = merge_sort(seq[:median])
    11    right: list[U] = merge_sort(seq[median:])
    12    # split recursively the seq
    13
    14    # left and right are results of merge_sort hence are sorted
    15    joined: list[U] = []
    16    # joins the remaining part of left or right
    17    while left and right:
    18        # while both sequences are not empty
    19        if left[-1] < right[-1]:
    20            joined.append(right.pop())
    21        else:
    22            joined.append(left.pop())
    23    # put joined in the ascending order
    24    joined.reverse()
    25    remains: list[U] = left or right
    26    # joins the remaining part of left or right
    27    # this remain is sorted in ascending order
    28    # and is smaller than every elements of joined
    29    return remains + joined
    
    tests
    1def test_merge_sort() -> None:
    2    seq: list[float] = [3, -1, 5, 2, -7, 9]
    3    expected: list[float] = sorted(seq)
    4    assert merge_sort(seq) == expected
    
  3. Splitting in half at every level leads to \(\propto \ln(n)\) levels. The combining step makes \(\approx n\) comparisons at every level. For a total of \(n \ln(n)\).

    \(2 \times \cfrac{1\,000\,000 \ln(1\,000\,000)}{1\,000 \ln(1\,000)}\approx 1\) h

Note

Comparing quicksort and merge sort shows two opposite solution in terms of where the heavy work is done. For quicksort, the hard work is the partition, when every element of the sequence is compared to the pivot to create the two subsequences of lower and larger elements. Then the combine step is trivial.

Conversely, the merge sort chooses a divide step that accomplish almost nothing except creating smaller and smaller sequences. The hard work is performed when two sorted subsequences are merged into a sorted sequence.

Exercise (intermediate)

The bisection insertion operates on a sorted collection. It indicates where to insert an element keeping the sequence sorted.

The algorithm:

  • examines the middle element;

  • discards one half if the target must lie in the other half;

  • repeats on the remaining interval.

  1. Give the divide/conquer/combine steps. (trick question)

  2. Implement and test.

Hint

It is much easier to work with two indexes: for each slice, the index left indicates the location of the first element of the slice in the original sequence, the index right indicates the index following the last element of the slice in the original sequence.

The function bisect.bisect_left does exactly the job which is asked.

Correction
  1. The divide step is splitting the sequence in half.

    The conquer step is getting rid of the half from which the element to insert can not belong and repeat the process.

    The combine step is inexistant because the incorrect half sequence has been discarded, so there is nothing to combine with.

  2. # do not focus on the type hinting
    
    from typing import Protocol, TypeVar
    
    
    T_contra = TypeVar("T_contra", contravariant=True)
    
    class SupportsGreaterOrEqualAndLowerThan(Protocol[T_contra]):
        def __ge__(self, other: T_contra, /) -> bool: ...
        def __lt__(self, other: T_contra, /) -> bool: ...
    
    
    V = TypeVar("V", bound=SupportsGreaterOrEqualAndLowerThan)
    
    src
     1def bisect(sorted_haystack: list[V], needle: V) -> int:
     2    """find the insertion index to keep the order of a sorted sequence:
     3    divide the sorted sequence in two and keep only one viable subsequence,
     4    conquer the base case, no combine required the job is done"""
     5
     6    def bisect_nested(left: int, right: int) -> int:
     7        """left index is the index of the first element of the subsequence,
     8        right is the index following the last element of the subsequence."""
     9        width: int = right - left
    10
    11        # single remaining elt, decide if insert before or after
    12        if width == 1:
    13            if sorted_haystack[left] >= needle:
    14                return left
    15
    16            return right
    17
    18        # cut in half and keep right ...
    19        middle: int = (right + left) // 2
    20        if sorted_haystack[middle] < needle:
    21            return bisect_nested(middle, right)
    22
    23        # ... or keep left
    24        return bisect_nested(left, middle)
    25
    26    if not sorted_haystack:
    27        return 0
    28    return bisect_nested(0, len(sorted_haystack))
    
    tests
    1def test_bisect() -> None:
    2    nb_trials: int = 100
    3    nb_elts: int = 100
    4    for _ in range(nb_trials):
    5        seq = sorted([randint(0, 2 * nb_elts) for _ in range(nb_elts)])
    6        obtained: int = bisect_(seq, nb_elts)
    7        expected: int = bisect.bisect_left(seq, nb_elts)
    8        assert obtained == expected