# Divide and Conquer

## Introduction

Divide and Conquer is a fundamental algorithm design paradigm that recursively breaks down a problem into two or more sub-problems of the same or related type, until these become simple enough to be solved directly. The solutions to the sub-problems are then combined to give a solution to the original problem.

**Core Principles:**

1. **Divide**: Break the problem into smaller sub-problems that are similar to the original problem
2. **Conquer**: Solve the sub-problems recursively. If the sub-problem sizes are small enough, solve them directly
3. **Combine**: Merge the solutions of the sub-problems to create a solution to the original problem

## Key Characteristics

### Problem Decomposition

* Problems must be decomposable into independent sub-problems
* Sub-problems should be of the same type as the original problem
* Sub-problems should be independent of each other (no overlapping sub-problems)

### Optimal Substructure

* The optimal solution to the problem can be constructed from optimal solutions of its sub-problems
* This property is essential for correctness of divide and conquer algorithms

### Recursion

* Divide and conquer naturally lends itself to recursive implementations
* Base cases are needed to terminate recursion
* Recursive calls work on progressively smaller problem instances

## Time Complexity Analysis

### Recurrence Relations

Divide and conquer algorithms are analyzed using recurrence relations. The general form is:

```
T(n) = aT(n/b) + f(n)
```

Where:

* `n` is the size of the problem
* `a` is the number of sub-problems
* `n/b` is the size of each sub-problem (assuming equal division)
* `f(n)` is the cost of dividing the problem and combining the results

### Master Theorem

The Master Theorem provides a cookbook method for solving recurrences of the form:

```
T(n) = aT(n/b) + f(n)
```

Where `a ≥ 1`, `b > 1`, and `f(n)` is asymptotically positive.

**Case 1:** If `f(n) = O(n^c)` where `c < log_b(a)`, then:

```
T(n) = Θ(n^(log_b(a)))
```

**Case 2:** If `f(n) = Θ(n^c * log^k(n))` where `c = log_b(a)` and `k ≥ 0`, then:

```
T(n) = Θ(n^c * log^(k+1)(n))
```

**Case 3:** If `f(n) = Ω(n^c)` where `c > log_b(a)`, and if `a*f(n/b) ≤ k*f(n)` for some `k < 1` and sufficiently large `n`, then:

```
T(n) = Θ(f(n))
```

### Conceptual Approach to Master Theorem

For those who prefer conceptual understanding over memorizing formulas, here's an intuitive way to analyze recurrences covered by the Master Theorem. This approach focuses on understanding where the runtime comes from rather than applying arbitrary formulas.

**Core Insight: Two Sources of Work**

The runtime of a recurrence conceptually comes from two sources:

1. **Work due to subproblem proliferation**: How much total work exists across all subproblems
2. **Work done to merge subproblem results**: The postprocessing work at each level of recursion

For example, in `T(n) = 4T(n/2) + O(n)`:

* The `4T(n/2)` term characterizes subproblem proliferation
* The `O(n)` term characterizes the merge work at each level

#### Part 1: Understanding Subproblem Proliferation

The work from subproblem proliferation depends on how the number of subproblems compares to how much smaller they become:

**Linear proliferation** (same total work):

```
T(n) = 2T(n/2)     → O(n)    [2 subproblems, half the size]
T(n) = 5T(n/5)     → O(n)    [5 subproblems, 1/5 the size]
T(n) = 8T(n/8)     → O(n)    [8 subproblems, 1/8 the size]
```

Problems got smaller, but same total work remains.

**Quadratic proliferation** (work grows quadratically):

```
T(n) = 4T(n/2)     → O(n²)   [4 subproblems, half the size]
T(n) = 9T(n/3)     → O(n²)   [9 subproblems, 1/3 the size]
T(n) = 100T(n/10)  → O(n²)   [100 subproblems, 1/10 the size]
```

Problems got smaller, but ended up with many more of them.

**Cubic proliferation** (work grows cubically):

```
T(n) = 8T(n/2)     → O(n³)   [8 subproblems, half the size]
T(n) = 64T(n/4)    → O(n³)   [64 subproblems, 1/4 the size]
T(n) = 125T(n/5)   → O(n³)   [125 subproblems, 1/5 the size]
```

Problems got smaller, but ended up with way more of them.

**Non-integer exponents** (need logarithms):

```
T(n) = 4T(n/2)     → O(n²)
T(n) = 7T(n/2)     → O(n^log₂7) ≈ O(n^2.8)
T(n) = 8T(n/2)     → O(n³)
```

For `T(n) = 7T(n/2)`, the exponent is `log₂7` because that's the power that turns 2 into 7.

This works even when subproblems decrease faster than they proliferate:

```
T(n) = 2T(n/3)     → O(n^log₃2) ≈ O(n^0.631)
```

**Minimal proliferation** (single subproblem):

```
T(n) = T(n/2)      → O(1)     [only 1 subproblem, so no growth]
```

#### Part 2: Comparing the Two Sources of Work

Once you determine the work from subproblem proliferation, compare it with the merge work. Three outcomes are possible:

1. **Subproblem proliferation dominates**: Most work occurs at the lowest level of recursion
   * Keep the proliferation runtime as the overall runtime
2. **Merge work dominates**: Most work occurs at the highest level of recursion
   * Keep the merge runtime as the overall runtime
3. **They tie**: Each of the `O(log n)` levels of recursion does equal work
   * Multiply the per-level work by `log n` to get overall runtime

#### Complete Examples

**Example 1:** `T(n) = 8T(n/2) + O(n²)`

* Subproblem proliferation: `O(n³)`
* Merge work: `O(n²)`
* Proliferation dominates → **Overall: O(n³)**

**Example 2:** `T(n) = 3T(n/4) + O(n)`

* Subproblem proliferation: `O(n^log₄3) ≈ O(n^0.792)`
* Merge work: `O(n)`
* Merge dominates → **Overall: O(n)**

**Example 3:** `T(n) = 25T(n/5) + O(n²)`

* Subproblem proliferation: `O(n²)` (since 25 = 5²)
* Merge work: `O(n²)`
* They tie → **Overall: O(n² log n)**

**Example 4:** `T(n) = 2T(n/2) + O(n)`

* Subproblem proliferation: `O(n)`
* Merge work: `O(n)`
* They tie → **Overall: O(n log n)** \[Merge Sort]

This conceptual approach directly corresponds to the three cases of the Master Theorem:

* Case 1: Subproblem proliferation dominates
* Case 2: They tie (multiply by log n)
* Case 3: Merge work dominates

### Common Recurrence Examples

```
T(n) = 2T(n/2) + O(n)     → O(n log n)  [Merge Sort, Binary Tree operations]
T(n) = T(n/2) + O(1)      → O(log n)    [Binary Search]
T(n) = 2T(n/2) + O(n²)    → O(n²)       [Naive divide-and-conquer algorithms]
T(n) = 7T(n/2) + O(n²)    → O(n^log₂7)  [Strassen's Matrix Multiplication]
```

## Advantages and Disadvantages

### Advantages

1. **Efficiency**: Often produces algorithms with better asymptotic complexity than naive approaches
2. **Parallelization**: Sub-problems are independent and can be solved in parallel
3. **Optimal Solutions**: Works well for problems with optimal substructure
4. **Clear Structure**: Recursive structure makes algorithms easier to understand and prove correct

### Disadvantages

1. **Overhead**: Recursion introduces function call overhead
2. **Stack Space**: Deep recursion can cause stack overflow
3. **Not Always Optimal**: Some problems have overlapping sub-problems (better suited for dynamic programming)
4. **Base Case Handling**: Need to carefully handle base cases and problem division

## Essential Components of a D\&C Solution

A well-constructed divide and conquer solution typically consists of three key components:

### Algorithm Description

**Explaining "how" the solution works:**

The algorithm should be described using clear narrative prose:

* Bulleted steps work well for organization, but avoid pseudocode or line-by-line code translations
* Over-reliance on deeply nested bullets often indicates the description is too code-focused rather than conceptual
* Include all algorithmic steps: the divide phase, recursive calls, combine logic, base cases, and return values
* When adapting known algorithms, explicitly describe what modifications were made

### Correctness Justification

**Explaining "why" the algorithm works:**

Understanding why a solution is correct is as important as the solution itself:

* An informal, intuitive explanation is typically sufficient (formal proofs are usually unnecessary)
* For modified algorithms, explain how the changes preserve correctness while solving the intended problem
* Address key correctness concerns relevant to the problem

### Runtime Analysis

**Analyzing algorithmic complexity:**

Complexity analysis demonstrates the efficiency of the approach:

* Focus on worst-case Big-O notation
* Constant-time operations (O(1)) generally don't require detailed justification
* Known, unmodified algorithm runtimes can be stated directly
* Modified algorithms require analysis of how changes impact runtime
* Conclude with an overall runtime expressed in simplified Big-O notation

## Problem-Solving Approach for D\&C

When developing a divide and conquer solution, consider following this systematic approach:

### Step 1: Recognize the Pattern

Begin by identifying the problem's characteristics and determining if it maps to known divide and conquer patterns:

* **Sorted data structures** often benefit from Binary Search approaches
* **Unsorted data** frequently calls for Merge Sort or selection algorithms like FastSelect
* **Polynomial operations, convolution, or multiplication** problems may leverage FFT (Fast Fourier Transform)

### Step 2: Choose or Adapt an Algorithm

Decide whether to use a known algorithm directly or adapt it to your needs:

* Known algorithms can be used as-is when they fit the problem perfectly
* Algorithm modifications involve keeping the core pattern but adjusting specific steps
* When adapting an algorithm, identify which parts are changing (divide step, base case, combine step, etc.)
* Consider how inputs and outputs may differ from the standard algorithm

### Step 3: Describe the Algorithm

Articulate the solution clearly using narrative description:

* Cover all phases: divide, conquer (recursive calls), and combine
* Include base case handling and return values
* Use conceptual language rather than code-like pseudocode
* For adapted algorithms, explain the modifications explicitly

### Step 4: Justify Correctness

Develop an intuitive argument for why the algorithm works:

* Informal reasoning is appropriate; formal proofs are typically unnecessary
* Consider addressing questions such as:
  * Why is this approach suitable for the problem's structure?
  * What properties of the input make this divide and conquer strategy effective?
  * For modifications: Why do the changes preserve correctness?
  * How does the problem size decrease with each recursive step?
  * Why are the base cases correctly handled?
  * For branching algorithms: What drives the decision to explore one path over another?

### Step 5: Analyze Complexity

Evaluate the time complexity systematically:

* Write the recurrence relation that describes the algorithm
* Apply the Master Theorem if applicable, or use other analysis techniques
* Focus on worst-case behavior
* For known algorithms used as black boxes, their standard runtimes can be referenced
* For modifications, assess the runtime impact
* Conclude with the overall complexity in simplified Big-O notation
