Module II — Divide and Conquer Algorithms and Graph Search
Course: MAT5EJ302(1) — Data Structures and Algorithms
Hours: 12 | Textbook Sections: 2.1, 2.2, 2.3, 3.1–3.3 (Dasgupta et al.)
1. The Divide and Conquer Paradigm
Many efficient algorithms follow a three-step template:
- Divide: Break the input into smaller subproblems of the same type.
- Conquer: Solve each subproblem recursively. (Base case: when the input is small enough, solve directly.)
- Combine: Merge the subproblem solutions into a solution for the original problem.
The power of this approach comes from the fact that a problem of size $n$ is often solvable by combining solutions to problems of size $n/2$, leading to running times like $O(n \log n)$ rather than $O(n^2)$.
2. Fast Integer Multiplication (Karatsuba’s Algorithm)
2.1 The Baseline: Grade-School Multiplication
For two $n$-bit integers $x$ and $y$, grade-school multiplication requires $O(n^2)$ bit operations (as shown in Module I). Can we do better?
2.2 A First Attempt
Split $x$ and $y$ at the halfway point:
x = x_L · 2^(n/2) + x_R [left and right halves of x]
y = y_L · 2^(n/2) + y_R
Then:
x · y = (x_L · 2^(n/2) + x_R)(y_L · 2^(n/2) + y_R)
= x_L · y_L · 2^n + (x_L · y_R + x_R · y_L) · 2^(n/2) + x_R · y_R
This requires 4 multiplications of $(n/2)$-bit numbers, giving recurrence:
T(n) = 4T(n/2) + O(n)
By the Master Theorem (see Section 3), this gives $T(n) = O(n^2)$ — no improvement!
2.3 Karatsuba’s Trick
Observe that $x_L \cdot y_R + x_R \cdot y_L$ can be computed with just one extra multiplication:
(x_L + x_R)(y_L + y_R) = x_L·y_L + x_L·y_R + x_R·y_L + x_R·y_R
So:
x_L·y_R + x_R·y_L = (x_L + x_R)(y_L + y_R) - x_L·y_L - x_R·y_R
We already compute $x_L \cdot y_L$ and $x_R \cdot y_R$. So we only need 3 multiplications total:
Algorithm: Karatsuba(x, y)
n = max(bits of x, bits of y)
if n = 1: return x · y [base case]
Split: x_L = x >> n/2, x_R = x mod 2^(n/2)
y_L = y >> n/2, y_R = y mod 2^(n/2)
p = Karatsuba(x_L, y_L)
q = Karatsuba(x_R, y_R)
r = Karatsuba(x_L + x_R, y_L + y_R)
return p · 2^n + (r - p - q) · 2^(n/2) + q
Recurrence:
T(n) = 3T(n/2) + O(n)
By the Master Theorem: $T(n) = O(n^{\log_2 3}) = O(n^{1.585})$.
This is a significant improvement over $O(n^2)$ for large $n$. Modern fast multiplication algorithms achieve even better — $O(n \log n \log\log n)$ — but Karatsuba’s is the easiest to understand.
3. Solving Recurrence Relations
Many divide-and-conquer algorithms lead to recurrences of the form:
T(n) = aT(n/b) + O(nᵈ)
where:
- $a$ = number of subproblems
- $n/b$ = size of each subproblem
- $O(n^d)$ = work done outside recursive calls
3.1 The Master Theorem
Theorem: For $T(n) = aT(n/b) + O(n^d)$ with $a \geq 1$, $b > 1$, $d \geq 0$:
| Condition | Solution |
|---|---|
| $d > \log_b a$ | $T(n) = O(n^d)$ — the merge work dominates |
| $d = \log_b a$ | $T(n) = O(n^d \log n)$ — balanced |
| $d < \log_b a$ | $T(n) = O(n^{\log_b a})$ — the recursion dominates |
Intuition: Think about the recursion tree.
- At level $k$ ($k$ levels deep), there are $a^k$ subproblems, each of size $n/b^k$, each doing $(n/b^k)^d$ work.
- Work at level $k$ = $a^k \cdot (n/b^k)^d = n^d \cdot (a/b^d)^k$.
- This is a geometric series with ratio $a/b^d$.
- If $a/b^d < 1$: the first level dominates → $O(n^d)$.
- If $a/b^d = 1$: all levels equal → $\log n$ levels × $n^d$ each → $O(n^d \log n)$.
- If $a/b^d > 1$: the deepest level dominates → $O(n^{\log_b a})$.
3.2 Worked Examples
Example 1: Merge sort: $T(n) = 2T(n/2) + O(n)$
- $a = 2$, $b = 2$, $d = 1$. Check: $\log_2 2 = 1 = d$. Case 2 → $T(n) = O(n \log n)$.
Example 2: Binary search: $T(n) = T(n/2) + O(1)$
- $a = 1$, $b = 2$, $d = 0$. Check: $\log_2 1 = 0 = d$. Case 2 → $T(n) = O(\log n)$.
Example 3: Karatsuba: $T(n) = 3T(n/2) + O(n)$
- $a = 3$, $b = 2$, $d = 1$. Check: $\log_2 3 \approx 1.585 > 1 = d$. Case 3 → $T(n) = O(n^{\log_2 3}) = O(n^{1.585})$.
Example 4: Naive 4-subproblem split: $T(n) = 4T(n/2) + O(n)$
- $a = 4$, $b = 2$, $d = 1$. Check: $\log_2 4 = 2 > 1 = d$. Case 3 → $T(n) = O(n^2)$.
4. Binary Search
Problem: Given a sorted array $A[1..n]$ and a target value $v$, find the index of $v$ in $A$ (or report that it is absent).
4.1 Algorithm
Algorithm: BinarySearch(A, v, lo, hi)
if lo > hi: return NOT FOUND
mid = (lo + hi) / 2
if A[mid] = v: return mid
if A[mid] > v: return BinarySearch(A, v, lo, mid - 1)
else: return BinarySearch(A, v, mid + 1, hi)
Call initially as BinarySearch(A, v, 1, n).
4.2 Why It Works
The key invariant: at every call, if $v$ exists in $A$, then $v \in A[\text{lo}..\text{hi}]$.
- Initially this is trivially true ($\text{lo}=1$, $\text{hi}=n$).
- At each step, we compare $v$ with $A[\text{mid}]$ and narrow the search range by half.
- The invariant is preserved throughout.
- The range shrinks by half each step, so we terminate in $O(\log n)$ steps.
4.3 Worked Example
Search for $v = 23$ in $A = [2, 5, 8, 12, 16, 23, 38, 56]$:
Step 1: lo=1, hi=8, mid=4, A[4]=12. 23 > 12 → search right half.
Step 2: lo=5, hi=8, mid=6, A[6]=23. Found at index 6!
Time complexity: $T(n) = T(n/2) + O(1)$ → $O(\log n)$.
5. Merge Sort
Problem: Sort an array $A[1..n]$ of $n$ numbers.
5.1 Algorithm
Algorithm: MergeSort(A, lo, hi)
if lo ≥ hi: return [base case: single element or empty]
mid = (lo + hi) / 2
MergeSort(A, lo, mid) [sort left half]
MergeSort(A, mid+1, hi) [sort right half]
Merge(A, lo, mid, hi) [merge the two sorted halves]
Algorithm: Merge(A, lo, mid, hi)
L = A[lo..mid], R = A[mid+1..hi] [copy the two halves]
i = 1, j = 1, k = lo
while i ≤ len(L) and j ≤ len(R):
if L[i] ≤ R[j]:
A[k] = L[i]; i++
else:
A[k] = R[j]; j++
k++
copy remaining elements of L or R into A
5.2 Trace on Example
Sort $A = [5, 2, 8, 1, 9, 3]$:
Split: [5, 2, 8] [1, 9, 3]
Split: [5] [2, 8] [1] [9, 3]
Split: [2] [8] [9] [3]
Merge: [2, 8] [3, 9]
Merge: [2, 5, 8] [1, 3, 9]
Merge: [1, 2, 3, 5, 8, 9]
5.3 Time Complexity
Merge sort divides into 2 halves and merges in $O(n)$ time:
T(n) = 2T(n/2) + O(n) → T(n) = O(n log n)
The Merge step visits each element once → $O(n)$. There are $\log n$ levels of recursion. Total work = $n \times \log n$ = $O(n \log n)$.
5.4 Comparison with Other Sorting Algorithms
| Algorithm | Best | Average | Worst | Space | Stable? |
|---|---|---|---|---|---|
| Merge sort | $O(n \log n)$ | $O(n \log n)$ | $O(n \log n)$ | $O(n)$ | Yes |
| Quick sort | $O(n \log n)$ | $O(n \log n)$ | $O(n^2)$ | $O(\log n)$ | No |
| Insertion sort | $O(n)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | Yes |
| Selection sort | $O(n^2)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | No |
Merge sort is optimal for comparison-based sorting: any comparison-based sort requires $\Omega(n \log n)$ comparisons in the worst case.
6. Graphs
6.1 What Is a Graph?
A graph $G = (V, E)$ consists of:
- A set of vertices (or nodes) $V$
- A set of edges $E$, where each edge connects two vertices
Types:
- Undirected graph: edges have no direction; ${u, v} = {v, u}$
- Directed graph (digraph): edges have direction; $(u, v) \neq (v, u)$
- Weighted graph: each edge has a numerical weight/cost
Basic terminology:
- Degree of a vertex $v$ = number of edges incident to $v$
- Path = sequence of distinct vertices connected by edges
- Cycle = path that starts and ends at the same vertex
- Connected graph = any two vertices are connected by some path
- Tree = connected undirected graph with no cycles (has $n-1$ edges for $n$ vertices)
6.2 Graph Representations
Two standard ways to store a graph in memory:
Adjacency Matrix
An $n \times n$ matrix $A$ where $A[i][j] = 1$ if there is an edge from $i$ to $j$, 0 otherwise (or the weight for weighted graphs).
Graph: 1-2, 1-3, 2-3, 3-4
Matrix:
1 2 3 4
1 [ 0 1 1 0 ]
2 [ 1 0 1 0 ]
3 [ 1 1 0 1 ]
4 [ 0 0 1 0 ]
Space: $O(|V|^2)$ Check if edge $(u,v)$ exists: $O(1)$ Find all neighbours of $v$: $O(|V|)$
Adjacency List
For each vertex $v$, store the list of its neighbours.
1 → [2, 3]
2 → [1, 3]
3 → [1, 2, 4]
4 → [3]
Space: $O(|V| + |E|)$ Check if edge $(u,v)$ exists: $O(\text{degree}(u))$ Find all neighbours of $v$: $O(\text{degree}(v))$
Comparison
| Operation | Adjacency Matrix | Adjacency List |
|---|---|---|
| Space | $O(V^2)$ | $O(V + E)$ |
| Edge lookup | $O(1)$ | $O(\text{degree})$ |
| Iterate all edges | $O(V^2)$ | $O(V + E)$ |
| Best for | Dense graphs ($E \approx V^2$) | Sparse graphs ($E \ll V^2$) |
Most real-world graphs are sparse (social networks, road networks, web graphs), so adjacency lists are used more often.
7. Depth-First Search (DFS)
DFS is one of the most fundamental graph algorithms. It explores as deep as possible along each branch before backtracking.
7.1 DFS on Undirected Graphs
Algorithm: DFS(G)
for each vertex v ∈ V:
visited[v] = False
pre[v] = post[v] = -1
clock = 1
for each vertex v ∈ V:
if not visited[v]:
Explore(G, v)
Algorithm: Explore(G, v)
visited[v] = True
pre[v] = clock; clock++
for each edge (v, u) ∈ E:
if not visited[u]:
Explore(G, u)
post[v] = clock; clock++
pre[v] = the time when $v$ is first visited (“pre-visit number”).
post[v] = the time when $v$’s exploration is completed (“post-visit number”).
| Time complexity: Every vertex and edge is visited at most twice → **$O( | V | + | E | )$**. |
7.2 Trace Example — Undirected Graph
Graph: vertices ${A, B, C, D, E}$, edges ${A\text{-}B, A\text{-}C, B\text{-}D, C\text{-}D, D\text{-}E}$
Starting DFS from A:
Visit A (pre=1)
Visit B (pre=2)
Visit D (pre=3)
Visit C (pre=4)
C's unvisited neighbour: none (A visited, D visited)
post[C] = 5
Visit E (pre=6)
post[E] = 7
post[D] = 8
post[B] = 9
post[A] = 10
Pre-order: $A(1), B(2), D(3), C(4), E(6)$ Post-order (completion): $C(5), E(7), D(8), B(9), A(10)$
7.3 DFS on Directed Graphs
The same algorithm applies. For directed graphs, we distinguish edge types based on the DFS tree structure:
| Edge Type | Description | Pre/Post Relationship |
|---|---|---|
| Tree edge | Edge in the DFS tree (went from u to unvisited v) | $\text{pre}[u] < \text{pre}[v] < \text{post}[v] < \text{post}[u]$ |
| Back edge | Edge to an ancestor in DFS tree (creates a cycle) | $\text{pre}[v] < \text{pre}[u] < \text{post}[u] < \text{post}[v]$ |
| Forward edge | Edge to a descendant (not tree edge) | $\text{pre}[u] < \text{pre}[v] < \text{post}[v] < \text{post}[u]$ |
| Cross edge | All other edges | $\text{pre}[v] < \text{post}[v] < \text{pre}[u] < \text{post}[u]$ |
Important property: A directed graph has a cycle $\leftrightarrow$ DFS finds a back edge.
7.4 Applications of DFS
- Cycle detection: Look for back edges during DFS
- Topological sort: Order vertices so all edges go forward (post-order reversed)
- Connectivity: Check if all vertices are reachable from a source
- Strongly connected components: (covered in Module III)
8. Summary
| Topic | Key Point | Complexity |
|---|---|---|
| Karatsuba multiplication | 3 recursive calls vs. 4 → faster than $O(n^2)$ | $O(n^{1.585})$ |
| Master Theorem | Solves $T(n) = aT(n/b) + n^d$ in 3 cases | — |
| Binary search | Halve search space each step | $O(\log n)$ |
| Merge sort | Divide + merge; $O(n \log n)$ optimal sort | $O(n \log n)$ |
| Adjacency matrix | Constant edge lookup, $O(V^2)$ space | $O(V^2)$ space |
| Adjacency list | Efficient for sparse graphs, $O(V+E)$ space | $O(V+E)$ space |
| DFS | Explore deep before wide; pre/post times | $O(V+E)$ |
| Back edges in DFS | Indicate cycles in directed graphs | — |
9. Practice Problems
- Apply the Master Theorem to solve:
- a) $T(n) = 2T(n/4) + \sqrt{n}$
- b) $T(n) = 2T(n/2) + n^2$
- c) $T(n) = 4T(n/2) + n^2$
- d) $T(n) = 8T(n/2) + n^2$
-
Trace Karatsuba’s algorithm on $x = 1234$, $y = 5678$ (use base 100 splitting: $x_L = 12$, $x_R = 34$, $y_L = 56$, $y_R = 78$).
-
Trace binary search for $v = 38$ in $A = [2, 5, 8, 12, 16, 23, 38, 56]$. Show each step.
-
Trace merge sort on $A = [3, 1, 4, 1, 5, 9, 2, 6]$. Show the recursion tree and all merge steps.
-
Prove that the lower bound for comparison-based sorting is $\Omega(n \log n)$. (Hint: count the number of leaves in a decision tree.)
-
For a graph with 5 vertices and edges ${(1,2),(1,3),(2,3),(3,4),(4,5),(5,1)}$: draw the adjacency matrix and adjacency list representations.
-
Run DFS on the directed graph: $V = {1,2,3,4}$, $E = {(1,2),(2,3),(3,1),(1,4)}$. Record pre and post times. Identify the back edge.
-
Show that in a DFS on an undirected graph, every edge is either a tree edge or a back edge (no forward or cross edges exist).
-
Explain why DFS runs in $O( V + E )$ time when using adjacency lists. - Challenge: Implement merge sort in Python and verify it gives the correct output for A = [9, 4, 7, 2, 5, 1, 8, 3, 6].