Divide & Conquer + Master Theorem
Break the problem in half, solve each half recursively, merge the results. This paradigm gives us $O(n\log n)$ sorting, $O(n\log n)$ convex hull, $O(n)$ median finding, and $O(n^{2.81})$ matrix multiplication. The Master Theorem tells us the runtime without drawing the recursion tree.
The Divide and Conquer Paradigm
Every divide-and-conquer algorithm follows the same skeleton: given a problem of size $n$, divide it into $a$ subproblems each of size $n/b$, solve each recursively, then merge the results in $f(n)$ time. The total runtime satisfies:
The art is in choosing the right division and designing an efficient merge. The merge step is usually what differentiates a clever algorithm from a naive one — compare $O(n^2)$ merge (naive convex hull) with $O(n)$ merge (divide-and-conquer convex hull).
Divide — split the input into subproblems. Usually in half: $n/2$ or two halves by sorted order.
Conquer — solve each subproblem recursively. The base case handles small inputs directly.
Combine — merge the subproblem solutions into the final answer. This is where the work happens.
Problem 1 — Interval Scheduling and Weighted Interval Scheduling
Before reaching divide and conquer, L1 grounds the course in two scheduling problems that illustrate a key theme: very similar problems can have very different complexity.
Interval Scheduling: given $n$ requests with start times $s(i)$ and finish times $f(i)$, select the maximum number of non-overlapping requests. The greedy algorithm — always pick the request with earliest finish time — solves this in $O(n\log n)$.
Weighted Interval Scheduling: add weight $w(i)$ to each request; maximise total weight of non-overlapping subset. Greedy fails — a short, low-weight interval can block a heavy one. This requires dynamic programming:
The jump from greedy (unweighted) to DP (weighted) illustrates that small problem changes can require fundamentally different algorithmic approaches.
Problem 2 — Convex Hull
Given $n$ points in the plane, find the smallest convex polygon containing all of them. Represented as the boundary vertices in clockwise order.
Brute force: test every line segment — $O(n^2)$ segments, $O(n)$ tests each = $O(n^3)$. Can we do better?
Divide and conquer: sort points by $x$-coordinate once ($O(n\log n)$). Split into left half $A$ and right half $B$. Compute $CH(A)$ and $CH(B)$ recursively. Merge by finding the upper and lower tangent lines between the two hulls.
Move fingers: if $y(i, j+1) > y(i,j)$, advance $j$ clockwise; if $y(i-1, j) > y(i,j)$, advance $i$ anticlockwise; else $(a_i, b_j)$ is the upper tangent. Each step strictly increases $y$, so terminates in $O(n)$ steps.
Recurrence: $T(n) = 2T(n/2) + \Theta(n) = \Theta(n\log n)$.
Problem 3 — Median Finding in Linear Time
Find the element of rank $\lfloor(n+1)/2\rfloor$ in an unsorted array. Sorting works in $\Theta(n\log n)$. Can we do it in $\Theta(n)$?
Yes — but only if we pick the pivot cleverly. The naive Select (random pivot) achieves $O(n)$ expected time. The Median of Medians algorithm achieves $O(n)$ worst-case:
1. Arrange $S$ into groups of 5. Sort each group. ($O(n)$ total.)
2. Find the median of the $\lceil n/5 \rceil$ group medians recursively: $T(\lceil n/5 \rceil)$.
3. Use this median $x$ as pivot. Partition into $B = \{y < x\}$ and $C = \{y > x\}$.
4. Recurse on the appropriate side.
Key guarantee: at least $3(\lceil n/10 \rceil - 2)$ elements are $> x$ and $< x$, so both $|B|$ and $|C|$ are at most $\frac{7n}{10} + 6$.
Recurrence: $T(n) = T(\lceil n/5 \rceil) + T(7n/10 + 6) + \Theta(n)$
Solution: $T(n) = \Theta(n)$ (proved by substitution: assume $T(n) \leq cn$, show it holds).
import bisect
# ── WEIGHTED INTERVAL SCHEDULING ── O(n log n) ──────────────────────
def weighted_interval_scheduling(intervals):
"""
intervals: list of (start, finish, weight)
Returns maximum total weight of non-overlapping intervals.
"""
intervals.sort(key=lambda x: x[1]) # sort by finish time
n = len(intervals)
finish_times = [iv[1] for iv in intervals]
def p(j):
"""Largest i < j with finish[i] <= start[j]."""
return bisect.bisect_right(finish_times, intervals[j][0], 0, j) - 1
M = [0] * (n + 1) # M[0] = 0 (base case)
for j in range(n):
pj = p(j)
M[j + 1] = max(intervals[j][2] + M[pj + 1], M[j])
return M[n]
# ── MEDIAN OF MEDIANS ── O(n) worst case ────────────────────────────
def median_of_medians(S, i):
"""
Returns the i-th smallest element (1-indexed) of S.
O(n) deterministic — no randomness needed.
"""
if len(S) <= 5:
return sorted(S)[i - 1]
# Step 1: split into groups of 5, find each group's median
chunks = [S[j:j+5] for j in range(0, len(S), 5)]
medians = [sorted(c)[len(c) // 2] for c in chunks]
# Step 2: find median of medians recursively
pivot = median_of_medians(medians, len(medians) // 2 + 1)
# Step 3: partition around pivot
low = [x for x in S if x < pivot]
high = [x for x in S if x > pivot]
k = len(low) + 1 # rank of pivot in S
# Step 4: recurse on correct side
if i == k: return pivot
elif i < k: return median_of_medians(low, i)
else: return median_of_medians(high, i - k)#include <vector>
#include <algorithm>
using namespace std;
// Weighted Interval Scheduling — O(n log n)
struct Interval { int s, f, w; };
int weighted_interval(vector<Interval>& ivs) {
sort(ivs.begin(), ivs.end(), [](auto& a, auto& b){ return a.f < b.f; });
int n = ivs.size();
vector<int> M(n + 1, 0);
for (int j = 0; j < n; ++j) {
// binary search for p(j)
int lo = 0, hi = j;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (ivs[mid-1].f <= ivs[j].s) lo = mid;
else hi = mid - 1;
}
M[j+1] = max(ivs[j].w + M[lo], M[j]);
}
return M[n];
}
// Median of Medians — O(n) deterministic
int mom(vector<int> S, int i) {
if ((int)S.size() <= 5) {
sort(S.begin(), S.end());
return S[i - 1];
}
vector<int> medians;
for (int j = 0; j < (int)S.size(); j += 5) {
auto chunk = vector<int>(S.begin()+j,
S.begin()+min(j+5,(int)S.size()));
sort(chunk.begin(), chunk.end());
medians.push_back(chunk[chunk.size()/2]);
}
int pivot = mom(medians, medians.size()/2 + 1);
vector<int> lo, hi;
for (int x : S) { if (x < pivot) lo.push_back(x);
else if (x > pivot) hi.push_back(x); }
int k = lo.size() + 1;
if (i == k) return pivot;
if (i < k) return mom(lo, i);
return mom(hi, i - k);
}Problem 4 — Strassen’s Matrix Multiplication
Naive matrix multiplication of two $n \times n$ matrices costs $\Theta(n^3)$ — $n^2$ entries in $C$, each requiring $n$ multiplications. Strassen’s key insight: you can compute the four $n/2 \times n/2$ submatrix products using only 7 multiplications (not 8) at the cost of 18 additions.
Recurrence: $T(n) = 7T(n/2) + \Theta(n^2)$. By Master Theorem: $T(n) = \Theta(n^{\log_2 7}) \approx \Theta(n^{2.807})$.
import numpy as np
def strassen(A, B):
"""
Strassen matrix multiplication. A, B must be n x n with n a power of 2.
Returns C = A @ B in O(n^2.807) recursive multiplications.
For small n, falls back to naive to avoid overhead.
"""
n = len(A)
if n <= 64: # base case: numpy is fastest here
return A @ B
half = n // 2
# Partition into 2x2 blocks of submatrices
A11, A12 = A[:half, :half], A[:half, half:]
A21, A22 = A[half:, :half], A[half:, half:]
B11, B12 = B[:half, :half], B[:half, half:]
B21, B22 = B[half:, :half], B[half:, half:]
# 7 recursive multiplications (not 8)
M1 = strassen(A11 + A22, B11 + B22)
M2 = strassen(A21 + A22, B11)
M3 = strassen(A11, B12 - B22)
M4 = strassen(A22, B21 - B11)
M5 = strassen(A11 + A12, B22)
M6 = strassen(A21 - A11, B11 + B12)
M7 = strassen(A12 - A22, B21 + B22)
# 18 additions to assemble C
C11 = M1 + M4 - M5 + M7
C12 = M3 + M5
C21 = M2 + M4
C22 = M1 - M2 + M3 + M6
C = np.zeros((n, n))
C[:half, :half], C[:half, half:] = C11, C12
C[half:, :half], C[half:, half:] = C21, C22
return C// Strassen matrix multiplication — O(n^log2(7)) ≈ O(n^2.807)
// Matrices stored as vector<vector<double>>
#include <vector>
using namespace std;
using Mat = vector<vector<double>>;
Mat add(const Mat& A, const Mat& B, int sign=1) {
int n = A.size();
Mat C(n, vector<double>(n));
for (int i=0;i<n;i++) for (int j=0;j<n;j++)
C[i][j] = A[i][j] + sign*B[i][j];
return C;
}
Mat strassen(const Mat& A, const Mat& B) {
int n = A.size();
if (n <= 64) { // base: naive for small n
Mat C(n, vector<double>(n, 0));
for(int i=0;i<n;i++) for(int k=0;k<n;k++) for(int j=0;j<n;j++)
C[i][j] += A[i][k]*B[k][j];
return C;
}
int h = n/2;
// Extract submatrices
auto sub = [&](const Mat& M, int r0, int c0) {
Mat S(h, vector<double>(h));
for(int i=0;i<h;i++) for(int j=0;j<h;j++) S[i][j]=M[r0+i][c0+j];
return S;
};
auto A11=sub(A,0,0), A12=sub(A,0,h), A21=sub(A,h,0), A22=sub(A,h,h);
auto B11=sub(B,0,0), B12=sub(B,0,h), B21=sub(B,h,0), B22=sub(B,h,h);
auto M1=strassen(add(A11,A22), add(B11,B22));
auto M2=strassen(add(A21,A22), B11);
auto M3=strassen(A11, add(B12,B22,-1));
auto M4=strassen(A22, add(B21,B11,-1));
auto M5=strassen(add(A11,A12), B22);
auto M6=strassen(add(A21,A11,-1),add(B11,B12));
auto M7=strassen(add(A12,A22,-1),add(B21,B22));
// Assemble C from 7 products
Mat C(n, vector<double>(n));
for(int i=0;i<h;i++) for(int j=0;j<h;j++) {
C[i][j] = M1[i][j]+M4[i][j]-M5[i][j]+M7[i][j];
C[i][j+h] = M3[i][j]+M5[i][j];
C[i+h][j] = M2[i][j]+M4[i][j];
C[i+h][j+h] = M1[i][j]-M2[i][j]+M3[i][j]+M6[i][j];
}
return C;
}The Master Theorem
The Master Theorem solves recurrences of the form $T(n) = aT(n/b) + f(n)$ where $a \geq 1$, $b > 1$. Compare $f(n)$ with $n^{\log_b a}$ (the “critical exponent”):
Case 1: $f(n) = O(n^{\log_b a - \varepsilon})$ for some $\varepsilon > 0$ (leaves dominate) → $T(n) = \Theta(n^{\log_b a})$
Case 2: $f(n) = \Theta(n^{\log_b a} \log^k n)$ for $k \geq 0$ (tied) → $T(n) = \Theta(n^{\log_b a} \log^{k+1} n)$
Case 3: $f(n) = \Omega(n^{\log_b a + \varepsilon})$ and $af(n/b) \leq cf(n)$ for $c<1$ (root dominates) → $T(n) = \Theta(f(n))$
If $n^{\log_b a}$ is greater than $f(n)$ but not polynomially greater, the Master Theorem does not apply (e.g., $T(n) = 2T(n/2) + \Theta(n/\log n)$).
| Recurrence | $a$, $b$, $f(n)$ | Case | Solution |
|---|---|---|---|
| $T = 2T(n/2)+\Theta(n)$ | 2, 2, $n$ | Case 2 ($k=0$) | $\Theta(n\log n)$ |
| $T = 7T(n/2)+\Theta(n^2)$ | 7, 2, $n^2$ | Case 1 ($n^{\log_27}\approx n^{2.807}$) | $\Theta(n^{\log_2 7})$ |
| $T = 4T(n/2)+\Theta(n^2)$ | 4, 2, $n^2$ | Case 2 ($k=0$) | $\Theta(n^2\log n)$ |
| $T = 4T(n/2)+\Theta(n^3)$ | 4, 2, $n^3$ | Case 3 ($n^3 \gg n^2$) | $\Theta(n^3)$ |
| $T = T(n/5)+T(7n/10)+\Theta(n)$ | — | Doesn’t apply; use substitution | $\Theta(n)$ |
def convex_hull_dc(points):
"""
Divide-and-conquer convex hull. O(n log n).
points: list of (x, y). Returns upper hull vertices left-to-right.
(Full hull = upper hull + lower hull, both in O(n log n).)
"""
pts = sorted(set(points)) # sort by x, deduplicate
n = len(pts)
if n <= 3:
return pts # base case
mid = n // 2
left_hull = convex_hull_dc(pts[:mid])
right_hull = convex_hull_dc(pts[mid:])
return merge_hulls(left_hull, right_hull)
def cross(O, A, B):
"""Cross product of OA and OB. Positive = left turn."""
return (A[0]-O[0])*(B[1]-O[1]) - (A[1]-O[1])*(B[0]-O[0])
def merge_hulls(left, right):
"""
Merge two convex hulls by finding upper and lower tangents. O(n).
Returns the merged upper hull (simplified for clarity).
"""
# Find upper tangent: rightmost of left, leftmost of right
i = left.index(max(left)) # rightmost point of left hull
j = 0 # leftmost point of right hull
while True:
changed = False
# Move right finger clockwise until it can't improve
while cross(left[i], right[j], right[(j+1) % len(right)]) > 0:
j = (j + 1) % len(right)
changed = True
# Move left finger anticlockwise until it can't improve
while cross(right[j], left[i], left[(i-1) % len(left)]) < 0:
i = (i - 1) % len(left)
changed = True
if not changed:
break
return left[:i+1] + right[j:] # merge along tangent#include <vector>
#include <algorithm>
using namespace std;
struct P { long long x, y; };
long long cross(P O, P A, P B) {
return (A.x-O.x)*(B.y-O.y)-(A.y-O.y)*(B.x-O.x);
}
// Upper hull in O(n) via Graham scan merge
vector<P> upper_hull(vector<P> pts) {
sort(pts.begin(), pts.end(), [](P a, P b){
return a.x < b.x || (a.x==b.x && a.y < b.y);
});
vector<P> h;
for (auto& p : pts) {
while (h.size() >= 2 &&
cross(h[h.size()-2], h.back(), p) >= 0)
h.pop_back();
h.push_back(p);
}
return h;
}
// Full convex hull (upper + lower) — O(n log n) with sort
vector<P> convex_hull(vector<P> pts) {
sort(pts.begin(), pts.end(), [](P a, P b){
return a.x < b.x || (a.x==b.x && a.y < b.y);
});
int n = pts.size(); if (n < 2) return pts;
vector<P> h;
// Upper hull
for (auto& p : pts) {
while (h.size()>=2 && cross(h[h.size()-2],h.back(),p)>=0) h.pop_back();
h.push_back(p);
}
// Lower hull
int upper_size = h.size();
for (int i=n-2; i>=0; --i) {
while ((int)h.size()>upper_size &&
cross(h[h.size()-2],h.back(),pts[i])>=0) h.pop_back();
h.push_back(pts[i]);
}
h.pop_back();
return h;
}TTT runs multiple option strategies. Each strategy occupies a margin block during $[s_i, f_i]$ and generates expected PnL $w_i$. Only one strategy can hold margin at a time. Maximise total expected PnL.
Strategies = intervals $[s_i, f_i]$ with weight $w_i$ (expected PnL). Non-overlap constraint = margin constraint. Apply the $O(n\log n)$ DP.
strategies = [
(9, 17, 4200), # NIFTY straddle, 9am-5pm, ₹4200 expected PnL
(9, 11, 1800), # BankNifty scalp, 9am-11am
(11, 17, 2900), # NIFTY spread, 11am-5pm
(9, 13, 3100), # NIFTY butterfly, 9am-1pm
(13, 17, 1600), # Afternoon hedge, 1pm-5pm
]
# As (start, finish, weight) tuples
result = weighted_interval_scheduling(strategies)
print(result) # 4700 = BankNifty (1800) + NIFTY spread (2900)
What is the solution to $T(n) = 4T(n/2) + \Theta(n^2)$?
$a=4$, $b=2$, so $n^{\log_b a} = n^{\log_2 4} = n^2$. Since $f(n) = \Theta(n^2) = \Theta(n^{\log_b a} \cdot \log^0 n)$, this is Case 2 with $k=0$: $T(n) = \Theta(n^2 \log^{0+1} n) = \Theta(n^2 \log n)$.
This recurrence appears when merge sort is applied to a 2D problem where the merge step costs $O(n^2)$ per level. There are $\log n$ levels, each doing $O(n^2)$ total work — hence the $\log$ factor.
Strassen uses 7 multiplications instead of 8 per recursion. Why is this the key insight, and what would happen if someone found a way to use only 6?
The recurrence is $T(n) = mT(n/2) + \Theta(n^2)$ where $m$ is the number of multiplications. By Case 1 of the Master Theorem (as long as $m > 4 = n^{\log_2 4}$), the solution is $\Theta(n^{\log_2 m})$. So $m=8 \Rightarrow n^3$, $m=7 \Rightarrow n^{2.807}$, $m=6 \Rightarrow n^{2.585}$. Each multiplication saved shaves the exponent. The current world record is approximately $n^{2.371}$ (Vassilevska Williams et al., 2024). Whether the true lower bound is $\omega = 2$ is one of the biggest open problems in theoretical computer science.
Greedy (earliest finish time) solves unweighted interval scheduling optimally. Why does it fail for weighted interval scheduling?
Concrete counterexample: three intervals $[1,3]$ weight 1, $[2,5]$ weight 10, $[4,6]$ weight 10. Earliest finish picks $[1,3]$ first (finish=3), then $[4,6]$ (finish=6) for total weight 11. But $[2,5]$ and $[4,6]$ overlap — we can’t take both. Optimal is $[1,3]+[4,6]=11$ or $[2,5]=10$, so greedy gets 11 here. Better example: $[1,4]$ weight 3, $[2,3]$ weight 1, $[3,5]$ weight 3. Greedy takes $[2,3]$ first (earliest finish), then $[3,5]$, total 4. Optimal is $[1,4]=3$... Actually greedy gets 4 here too. The canonical counterexample: $[1,6]$ weight 10 vs $[1,3]$ weight 3 and $[4,6]$ weight 3. Greedy takes $[1,3]$ then $[4,6]$ = 6. Optimal is just $[1,6]$ = 10. Greedy fails.
Median of Medians uses groups of 5. Why 5, and not 3 or 7?
With groups of size $g$, the pivot eliminates at least a $\frac{g-1}{2g}$ fraction of elements. For $g=3$: fraction $\frac{1}{3}$, so recurrence is $T(n) = T(n/3) + T(2n/3) + \Theta(n)$. The $T(n/3)+T(2n/3)$ part sums to $T(n)$ times $1/3+2/3=1$, giving $T(n) = \Omega(n\log n)$. Not linear!
For $g=5$: fraction $3/10$, recurrence $T(n/5)+T(7n/10)+\Theta(n)$. Now $1/5+7/10=9/10 < 1$, giving $T(n)=O(n)$ by substitution. For $g=7$: even better fraction, but sorting groups of 7 costs more. The constant in $O(n)$ grows. $g=5$ is the smallest group size that makes the algorithm linear — the sweet spot.
Work through independently. Q1–3 direct application. Q4–7 synthesis. Q8–10 careful argument.