Algorithms · Lesson 6 of 17

Divide and Conquer

Understand divide and conquer algorithms in Python: split, solve and combine, with counting inversions, quickselect, fast power and the Master Theorem.

  • Intermediate
  • 17 min read
  • 4 objectives

Before this lessonLesson 5: Recursion

What you will learn

  • Describe the split, solve, combine structure
  • Analyse running time with recurrences and the Master Theorem
  • Implement inversion counting, quickselect and fast power
  • Tell divide and conquer apart from dynamic programming

Your Progress

0 of 17 lessons 0%

  • Lessons0 / 17
  • Completed0
  • Est. time left~ 5 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Divide and conquer is a way of designing algorithms, not a single algorithm. You divide a problem into smaller pieces of the same kind, conquer each piece recursively, and combine the answers. You have already met two examples: binary search (divide in half, keep one side) and merge sort (divide in half, sort both, merge). This lesson makes the pattern explicit and shows how to predict its running time.

The reason it is so effective is that halving is powerful. A problem of size one million only needs about 20 levels of halving to reach size one. If each level does linear work in total, the whole thing is O(n log n), which is dramatically faster than the O(n2) brute force for most problems.

The three steps

  • Base case: a problem small enough to answer directly (often size 0 or 1).
  • Divide: split into subproblems, usually two halves.
  • Combine: merge the sub-answers into the answer for the whole. This step is where the cleverness lives.

Here is the simplest possible example: finding the maximum of a list by splitting it. It is not faster than a loop, but it shows the shape clearly.

def max_dc(nums, lo, hi):
    if lo == hi:                     # base case: one element
        return nums[lo]
    mid = (lo + hi) // 2
    left = max_dc(nums, lo, mid)     # conquer left half
    right = max_dc(nums, mid + 1, hi)
    return max(left, right)          # combine

orders = [14, 3, 27, 8, 19, 22, 5]
print(max_dc(orders, 0, len(orders) - 1))
Output
27

Measuring cost with recurrences

Divide and conquer running times are written as a recurrence: the cost of size n in terms of the cost of smaller sizes. For merge sort, sorting n items means two sorts of n/2 plus O(n) merging: T(n) = 2T(n/2) + O(n). The Master Theorem gives the answer for recurrences of the form T(n) = a T(n/b) + O(nd), where a is the number of subproblems, b the shrink factor and d the exponent of the combine work:

  • If d > logb a: the combine step dominates, T(n) = O(nd).
  • If d = logb a: every level costs the same, T(n) = O(nd log n). Merge sort: a=2, b=2, d=1, so O(n log n).
  • If d < logb a: the leaves dominate, T(n) = O(nlogb a).

Binary search is a=1, b=2, d=0, which falls in the middle case: O(log n). You do not need to memorise proofs; knowing these three cases lets you estimate almost any recursive split in an interview.

Counting inversions: a smarter combine step

An inversion is a pair i < j with a[i] > a[j]. It measures how unsorted a list is, and recommendation systems use it to compare two people's rankings. Checking every pair is O(n2). Instead, piggyback on merge sort: while merging two sorted halves, whenever you take an element from the right half, it is smaller than every element still waiting in the left half, so all of those form inversions at once.

def sort_and_count(a):
    if len(a) <= 1:
        return a, 0
    mid = len(a) // 2
    left, inv_l = sort_and_count(a[:mid])
    right, inv_r = sort_and_count(a[mid:])
    merged, inv = [], inv_l + inv_r
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            merged.append(left[i]); i += 1
        else:
            merged.append(right[j]); j += 1
            inv += len(left) - i         # all remaining left items are bigger
    merged += left[i:] + right[j:]
    return merged, inv

ranking = [3, 1, 5, 2, 4]
print(sort_and_count(ranking))
print(sort_and_count(list(range(10, 0, -1)))[1])
Output
([1, 2, 3, 4, 5], 4)
45

A fully reversed list of 10 items has 10*9/2 = 45 inversions, which matches. Same recurrence as merge sort: time O(n log n), space O(n) for the merged lists.

Quickselect: the k-th smallest in average O(n)

To find the median, you could sort (O(n log n)). Quickselect is quicksort's lazy cousin: partition around a pivot, then recurse into only the side that contains position k. On average the sizes go n, n/2, n/4, ..., which sums to about 2n.

import random

def quickselect(nums, k):
    # k is 0-based: k=0 gives the smallest
    pivot = random.choice(nums)
    lows = [x for x in nums if x < pivot]
    pivots = [x for x in nums if x == pivot]
    highs = [x for x in nums if x > pivot]
    if k < len(lows):
        return quickselect(lows, k)
    if k < len(lows) + len(pivots):
        return pivot
    return quickselect(highs, k - len(lows) - len(pivots))

latencies = [120, 45, 300, 88, 45, 210, 97, 150, 60]
print("median:", quickselect(latencies, len(latencies) // 2))
print("3rd smallest:", quickselect(latencies, 2))
Output
median: 97
3rd smallest: 60

Average time O(n), worst case O(n2) if pivots are always terrible; random pivots make that astronomically unlikely. This version uses O(n) extra space for clarity; an in-place partition brings it to O(1) plus recursion. In real Python code, heapq.nsmallest or statistics.median are the practical choices.

Fast exponentiation

Computing xn by multiplying n times is O(n). But xn = (xn/2)2, so you only need to solve one half-size problem and square it. That is a=1, b=2, d=0: O(log n). This is how cryptography computes huge powers modulo a prime.

def power(x, n, mod):
    if n == 0:
        return 1
    half = power(x, n // 2, mod)
    result = half * half % mod
    if n % 2 == 1:
        result = result * x % mod
    return result

print(power(3, 13, 1000))
print(power(2, 10**18, 1_000_000_007) == pow(2, 10**18, 1_000_000_007))
Output
323
True

Python's built-in three-argument pow(x, n, mod) does exactly this, and you should use it in real code.

Recap

  • Divide and conquer: base case, split into same-shaped subproblems, combine the results.
  • Running time follows from a recurrence; the Master Theorem compares combine work with the number of subproblems.
  • Counting inversions adds one line to merge sort's merge step and stays O(n log n).
  • Quickselect recurses into one side only and averages O(n).
  • Fast power halves the exponent each call, giving O(log n) multiplications.
# Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 7Greedy AlgorithmsMake the locally best choice at each step, and know when that is enough.