Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Analysis II: Recurrences, Amortisation & Randomness  ›  Lesson

Divide & Conquer Beyond Sorting

Divide and Conquer 32 minSplit a problem, solve the pieces recursively, and win by making the combine cheap or the calls few
You're building a piece ofTunebox — the cost lab
This piece — best_streak(): Finds a track's best run of days by splitting the history in half.
Scenario A label asks when their single peaked: which stretch of days gained the most listeners overall. Tunebox answers by splitting the history in half, over and over, and checking the stretch that crosses each middle.
Your task
Build best_streak(deltas). deltas is a track's non-empty list of daily changes in listeners. Return the largest total of any run of one or more consecutive days, using divide and conquer: the best run in the left half, in the right half, or crossing the middle. Example: best_streak([3, -5, 4, -1, 2, 6, -7, 2]) → 11.

Divide & Conquer Beyond Sorting

In short: divide and conquer isn't only for sorting. Split the input, solve each half recursively, then combine the two answers — and the combine step is where the whole technique either pays off or doesn't. It wins when the combine is cheap (two half-size streak searches plus one linear crossing sweep) or when a clever combine needs fewer recursive calls than the obvious split (Karatsuba's three multiplies instead of four). Write the recurrence before you write the code: if the combine costs as much as solving the problem directly, or the halves overlap, divide and conquer won't help.

An 8-day array split at the middle: the best streak can live entirely left, entirely right, or cross the middle as a left tail plus a right head, 3 + 8 = 11

Merge sort taught you the pattern: divide the input, conquer each piece recursively, combine the answers. Lesson 1 gave you the tool to price it: T(n)=a T(n/b)+f(n)T(n) = a\,T(n/b) + f(n). This lesson uses the pattern on problems that have nothing to do with putting things in order, and uses the recurrence to predict, before you write a line, whether splitting will pay.

The rule of thumb from the master theorem: divide and conquer wins when the combine is cheaper than solving the whole problem directly, or when you can get away with fewer recursive calls than the obvious split needs.

Fewer calls: multiplying big numbers

Tunebox's royalty ledger multiplies very large integers. The schoolbook method multiplies every digit of one number by every digit of the other: n² single-digit multiplications for two n-digit numbers.

Divide and conquer splits each number into a high half and a low half, x=xh⋅10m+xlx = x_h \cdot 10^m + x_l, and expands the product:

x⋅y=xhyh⋅102m+(xhyl+xlyh)⋅10m+xlylx \cdot y = x_h y_h \cdot 10^{2m} + (x_h y_l + x_l y_h) \cdot 10^m + x_l y_l

That is four half-size multiplications: T(n)=4T(n/2)+nT(n) = 4T(n/2) + n, which is still Θ(n2)\Theta(n^2). No gain. The trick, found by Karatsuba, is that the middle term can be had from one multiplication, reusing the other two:

xhyl+xlyh=(xh+xl)(yh+yl)−xhyh−xlylx_h y_l + x_l y_h = (x_h + x_l)(y_h + y_l) - x_h y_h - x_l y_l

Step through it on two 8-digit numbers. The array is the digits of x then y:

watch it move · array

Three half-size multiplies instead of four. One saved multiply per level compounds into a smaller exponent.

Split both numbers at m = 4 digits. The high halves are 1234 and 8765. 16 cells.

1 / 7

The recurrence becomes T(n)=3T(n/2)+nT(n) = 3T(n/2) + n. Three calls on halves, the leaves dominate, and the cost is nlog⁡23≈n1.585n^{\log_2 3} \approx n^{1.585}. Count it:

python

At 64 digits it uses under a third of the schoolbook count. The sums xh+xlx_h + x_l can carry into an extra digit, which inflates the small cases, but look at the ratios: each doubling of the digits multiplies Karatsuba's count by about 3 and the schoolbook's by 4.

❓ Cross-question — "Does Python do this?" Yes. CPython's integer multiply switches to Karatsuba once both numbers are several hundred digits long. Below that, the schoolbook loop's lower overhead wins. The numbers for that trade-off come next.

Karatsuba does more additions and bookkeeping per level than the schoolbook loop, so its real cost is roughly c⋅n1.585c \cdot n^{1.585} for some overhead c against n2n^2. At 1,000 digits that's c × 56,870 against 1,000,000:

overhead c Karatsuba schoolbook cheaper
10 568,706 1,000,000 Karatsuba
17 966,800 1,000,000 Karatsuba
18 1,023,670 1,000,000 schoolbook
30 1,706,117 1,000,000 schoolbook

Karatsuba wins only while c is below about 17.6. A better exponent always wins eventually, but the constant decides where "eventually" starts, which is why real libraries switch methods at a threshold.

Cheaper combine: a track's best streak

Tunebox keeps each track's daily change in listeners: +3 means three more listeners than the day before, -5 means five fewer. The label wants the best streak, the run of consecutive days with the largest total gain.

Checking every start and end is O(n2)O(n^2) runs. Divide and conquer splits the days in half. The best streak is in one of three places:

  1. entirely in the left half, which the left recursive call finds;
  2. entirely in the right half, which the right call finds;
  3. crossing the middle: it ends somewhere in the left half's tail and starts somewhere in the right half's head.

The crossing case is the combine step, and it is linear: grow a run leftwards from the middle keeping the best sum, grow one rightwards the same way, and add the two. So T(n)=2T(n/2)+nT(n) = 2T(n/2) + n, the merge sort recurrence, O(nlog⁡n)O(n \log n).

Here is the crossing case on its own. Step through the left sweep:

step through it
1d = [3, -5, 4, -1, 2, 6, -7, 2]
2mid = 4
3total, best_left = 0, None
4for i in range(mid - 1, -1, -1):
5 total += d[i]
6 if best_left is None or total > best_left:
7 best_left = total
8print(best_left)

Before running the next one, work out what the crossing run is worth:

What does this print?
d = [3, -5, 4, -1, 2, 6, -7, 2]
mid = 4

total, best_left = 0, None
for i in range(mid - 1, -1, -1):
    total += d[i]
    best_left = total if best_left is None else max(best_left, total)

total, best_right = 0, None
for j in range(mid, len(d)):
    total += d[j]
    best_right = total if best_right is None else max(best_right, total)

print(best_left + best_right)

Now watch your own divide and conquer run on all eight days. Each frame after a split shows the range being combined, the middle pointer, and the best crossing run in green. Change the deltas, or try an all-negative history:

your code, drawn · array

Your best-streak search. The crossing run is the only new work each call does, and it is linear in the range.

Leave out the crossing case and the algorithm is still recursive, still fast, and wrong:

broken — fix it

This should print 11, the streak 4, -1, 2, 6. It prints 6: with no crossing case, the only runs it can ever return are single days.

Halving the exponent: fast power

Divide and conquer also works on a single number. To compute xnx^n, don't multiply n times. Split the exponent: xn=(xn/2)2x^n = (x^{n/2})^2 when n is even, and x⋅xn−1x \cdot x^{n-1} when it is odd. One call on half the problem, constant combine: T(n)=T(n/2)+1T(n) = T(n/2) + 1, O(log⁡n)O(\log n).

What does this print?
mults = 0

def power(x, n):
    global mults
    if n == 0:
        return 1
    if n == 1:
        return x
    if n % 2 == 0:
        half = power(x, n // 2)
        mults += 1
        return half * half
    mults += 1
    return x * power(x, n - 1)

power(3, 1000)
print(mults)

The same trick works on anything you can multiply associatively, including matrices. A graph's adjacency matrix raised to the power k counts the walks of exactly k hops between every pair of nodes, and fast power computes it with O(log⁡k)O(\log k) matrix products. That is the capstone exercise.

When dividing does not help

Divide and conquer is not free. Before reaching for it, write the recurrence:

  • The combine is as expensive as the whole problem. Splitting a list to find its maximum gives T(n)=2T(n/2)+1T(n) = 2T(n/2) + 1, which is O(n)O(n): no better than a loop, with recursion overhead on top.
  • The pieces overlap. Naive Fibonacci splits fib(n) into fib(n-1) and fib(n-2), which share almost everything. When subproblems repeat, you need a memo, which is dynamic programming from section 6, not divide and conquer.
  • A simpler scan exists. The best streak has a one-pass O(n)O(n) solution: keep the best run ending at today, and either extend it or start fresh. Divide and conquer is worth knowing here because its shape generalises to cases the scan cannot handle, like combining streak summaries from many servers.
python

Idioms & real-world patterns

Pass bounds, not slices

Every recursive function in this lesson takes lo and hi rather than slicing. Slices copy, and a copy in the combine step can turn a +1 into a +n and change the whole recurrence.

pow(x, n, mod) is fast power

Python's three-argument pow does exactly the squaring trick, keeping every intermediate value below mod. pow(3, 10**18, 1_000_000_007) returns instantly.

⚡ Advanced — the same idea, other places

The fast Fourier transform multiplies polynomials in O(nlog⁡n)O(n \log n) by splitting into even and odd coefficients. Strassen's method multiplies matrices with 7 half-size products instead of 8, giving nlog⁡27≈n2.81n^{\log_2 7} \approx n^{2.81}. In each case, the win is one fewer recursive call, compounded over log n levels.


Quick practice

What does this print?
mults = 0

def power(x, n):
    global mults
    if n == 0:
        return 1
    if n == 1:
        return x
    if n % 2 == 0:
        half = power(x, n // 2)
        mults += 1
        return half * half
    mults += 1
    return x * power(x, n - 1)

print(power(2, 13), mults)
broken — fix it

2⁵ is 32. This prints 1, because halving odd exponents with no "and multiply by x once more" case throws away a factor of x at every odd level.


🎯 Your turn

Write best_streak(deltas): the largest total of any run of one or more consecutive days. deltas is never empty. Use divide and conquer: left half, right half, and the run that crosses the middle.

  • best_streak([3, -5, 4, -1, 2, 6, -7, 2]) → 11
  • best_streak([-4, -2, -9]) → -2, a streak must contain at least one day

Hint — write a helper on lo and hi. Its base case is one day. Its combine is the best left tail plus the best right head, as in the trace above.

Then press ▶ Run, tap the Live App try chips, and hit ✓ Check.

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCount the squarings

Fast power computes x**n like this: n = 0 returns 1 and n = 1 returns x (no multiplications); an even n squares power(x, n // 2) (one multiplication); an odd n returns x * power(x, n - 1) (one multiplication). Write power_mults(n) returning how many multiplications that makes. power_mults(8) → 3.

divide-and-conquerrecurrence
DrillKaratsuba, counted

Write karatsuba_count(x, y) for non-negative integers, returning [product, digit_mults]. If x < 10 or y < 10, multiply directly: that is 1 digit multiplication. Otherwise let m = max(len(str(x)), len(str(y))) // 2, split with divmod(…, 10 ** m), and make exactly three recursive calls: high × high, low × low, and (high + low) × (high + low). digit_mults is the total over all base cases. karatsuba_count(12, 34) → [408, 3].

karatsubadivide-and-conquer
BuildThe track that dominates

A listener's play log is a list of titles. Write majority_track(plays) returning the title played in more than half of the entries, or None if there isn't one. Use divide and conquer: a majority of the whole list must be a majority of at least one half.

divide-and-conquer
BossWho falls below me?

The weekly chart lists each track's plays from position 1 down. For every position, Tunebox wants to know how many tracks further down the chart have strictly fewer plays. Write fewer_after(plays) returning that list, in O(n log n) — by counting during a merge sort, not by checking every pair. fewer_after([5, 2, 6, 1]) → [2, 1, 1, 0].

divide-and-conquermerge-sort
CapstoneHops at scale

Tunebox's follow graph is an adjacency matrix: adj[i][j] is 1 if listener i follows listener j. A walk of k hops may revisit listeners. Write walks_count(adj, k) returning the number of walks of exactly k hops from listener 0 to the last listener, modulo 1_000_000_007. k can be as large as 10**12, so k matrix products is far too many.

matrix-exponentiationdivide-and-conquergraphs
ExplainWhy the crossing run is needed, and cheap

In best_streak, each call returns the best of three things: the left half's answer, the right half's answer, and the best run crossing the middle. Explain why the crossing run cannot be left out, and why computing it keeps the whole algorithm at O(n log n).

best_streak.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
best_streak(deltas) → intThe largest total of any run of consecutive days.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.