Divide and Conquer¶
Divide and conquer follows three stages:
Divide the problem into smaller subproblems.
Conquer the subproblems.
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
Give the divide/conquer/combine steps.
Implement and test.
Worst and mean complexity.
Correction
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
quicksortfunction 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.# 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
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
Give the divide/conquer/combine steps.
Implement and test.
Complexity. If sorting a list of 1 000 elements takes 2 s, how long does it take for 1 000 000 elements?
Correction
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.
- 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
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.
Give the divide/conquer/combine steps. (trick question)
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
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.
# 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