Divide and Conquer, Design and Analysis of Algorithms

Harsh Mishra - Aug 2 - - Dev Community

Introduction to Divide and Conquer

Definition and Key Concepts

Divide and Conquer:
Divide and conquer is an algorithm design paradigm that works by recursively breaking down a problem into smaller subproblems, solving each subproblem independently, and then combining their solutions to solve the original problem. This method is particularly useful for problems that can be naturally divided into smaller instances of the same problem.

Key Concepts:

  1. Divide: Split the problem into smaller subproblems.
  2. Conquer: Solve each subproblem recursively.
  3. Combine: Merge the solutions of the subproblems to solve the original problem.

Divide and Conquer Approach and Characteristics

Divide and Conquer Approach:

  • Divide: The first step involves dividing the original problem into smaller, more manageable subproblems. Each subproblem should ideally be a smaller instance of the same type of problem.
  • Conquer: Solve each subproblem independently, usually by recursive calls. If a subproblem is small enough, solve it as a base case without further division.
  • Combine: After solving the subproblems, combine their solutions to form a solution for the original problem. This step involves merging or combining the results obtained from the subproblems.

Characteristics:

  • Recursive Nature: Divide and conquer algorithms are inherently recursive as they involve solving smaller instances of the problem.
  • Base Case: Every recursive algorithm must have a base case to terminate the recursion.
  • Combination Step: The key challenge is to correctly combine the solutions of the subproblems to form a correct solution to the original problem.

Differences between Divide and Conquer and Other Paradigms

Divide and Conquer vs. Greedy Algorithms:

  • Divide and Conquer: Breaks the problem into smaller subproblems, solves each recursively, and combines their solutions. This approach is useful when the problem can be decomposed into independent subproblems.
  • Greedy Algorithms: Make a series of choices, each of which looks best at the moment. The choices are not reconsidered, and the solution is built up by making local optimal choices. Greedy algorithms are used when a problem exhibits the greedy choice property and optimal substructure.

Example:

  • Divide and Conquer: Merge sort for sorting an array.
  • Greedy: Huffman coding for data compression.

Divide and Conquer vs. Dynamic Programming:

  • Divide and Conquer: Solves the problem by dividing it into smaller subproblems and combining their solutions. It is suitable when subproblems are independent.
  • Dynamic Programming: Solves the problem by breaking it into overlapping subproblems and storing their solutions to avoid redundant computations. It is suitable when subproblems overlap and share subsolutions.

Example:

  • Divide and Conquer: Quick sort for sorting an array.
  • Dynamic Programming: 0/1 knapsack problem.

Mathematical Foundation for Divide and Conquer

Recurrence Relations

A recurrence relation is an equation that defines a sequence of values using previous terms in the sequence. In the context of divide and conquer algorithms, recurrence relations are used to express the time complexity of recursive algorithms. They describe how the runtime of an algorithm depends on the runtime of smaller subproblems.

Key Concepts:

  1. Form of Recurrence Relations: Typically, a recurrence relation for a divide and conquer algorithm is expressed as:

    • T(n) = aT(n/b) + f(n) where:
      • T(n) is the time complexity for a problem of size n.
      • a is the number of subproblems.
      • n/b is the size of each subproblem.
      • f(n) is the cost outside the recursive calls (e.g., combining solutions).
  2. Base Case: The recurrence relation must include a base case, which defines the time complexity for the smallest problem size where recursion stops.

Master Theorem

The Master Theorem provides a straightforward way to analyze the time complexity of divide and conquer algorithms by solving recurrence relations of the form:

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

The Master Theorem involves comparing the function f(n) to n^log_b(a), where:

  • a is the number of subproblems in the recurrence.
  • b is the factor by which the problem size is divided.
  • f(n) is the cost outside the recursive calls.

Theorem Cases:

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

    • T(n) = Θ(n^log_b(a))
  2. Case 2: If f(n) = Θ(n^c) where c = log_b(a), then:

    • T(n) = Θ(n^c * log^k(n)) where k >= 0
  3. 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, then:

    • T(n) = Θ(f(n))

Examples of Solving Recurrences

Example 1: Merge Sort

  • Recurrence Relation: T(n) = 2T(n/2) + O(n)
  • Applying the Master Theorem:
    • Here, a = 2, b = 2, and f(n) = O(n).
    • Compute log_b(a) = log_2(2) = 1.
    • Compare f(n) with n^log_b(a): f(n) = O(n) and n^log_b(a) = n^1.
    • By Case 2 of the Master Theorem, since f(n) = Θ(n^c) where c = log_b(a), the time complexity is:
    • T(n) = Θ(n * log^k(n)) where k >= 0, which simplifies to Θ(n log n).

Example 2: Binary Search

  • Recurrence Relation: T(n) = T(n/2) + O(1)
  • Applying the Master Theorem:
    • Here, a = 1, b = 2, and f(n) = O(1).
    • Compute log_b(a) = log_2(1) = 0.
    • Compare f(n) with n^log_b(a): f(n) = O(1) and n^log_b(a) = n^0.
    • By Case 1 of the Master Theorem, since f(n) = O(n^c) where c < log_b(a), the time complexity is:
    • T(n) = Θ(n^log_b(a)), which simplifies to Θ(log n).

Examples for Divide and Conquer Algorithms

Divide and conquer is a fundamental algorithmic paradigm that works by recursively breaking down a problem into smaller subproblems, solving each subproblem independently, and then combining their solutions to solve the original problem. Here are examples of common divide and conquer algorithms in Python:

Binary Search

Binary search efficiently finds the position of a target value within a sorted array by repeatedly dividing the search interval in half.

Here’s a Python implementation:

def binary_search(arr, target):
    low, high = 0, len(arr) - 1

    while low <= high:
        mid = (low + high) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            low = mid + 1
        else:
            high = mid - 1

    return -1

# Testing binary search
arr = [1, 3, 5, 7, 9, 11]
target = 7
index = binary_search(arr, target)
print(f"Index of {target} in the array is: {index}")
Enter fullscreen mode Exit fullscreen mode

Merge Sort

Merge sort is a sorting algorithm that divides the array into halves, recursively sorts each half, and then merges the sorted halves.

Here’s a Python implementation:

def merge_sort(arr):
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left_half = merge_sort(arr[:mid])
    right_half = merge_sort(arr[mid:])

    return merge(left_half, right_half)

def merge(left, right):
    sorted_arr = []
    i = j = 0

    while i < len(left) and j < len(right):
        if left[i] < right[j]:
            sorted_arr.append(left[i])
            i += 1
        else:
            sorted_arr.append(right[j])
            j += 1

    sorted_arr.extend(left[i:])
    sorted_arr.extend(right[j:])

    return sorted_arr

# Testing merge sort
arr = [38, 27, 43, 3, 9, 82, 10]
sorted_arr = merge_sort(arr)
print("Sorted array:", sorted_arr)
Enter fullscreen mode Exit fullscreen mode

Quick Sort

Quick sort is a sorting algorithm that selects a pivot element and partitions the array into elements less than and greater than the pivot, then recursively sorts the partitions.

Here’s a Python implementation:

def quick_sort(arr):
    if len(arr) <= 1:
        return arr

    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]

    return quick_sort(left) + middle + quick_sort(right)

# Testing quick sort
arr = [33, 10, 59, 27, 46, 81]
sorted_arr = quick_sort(arr)
print("Sorted array:", sorted_arr)
Enter fullscreen mode Exit fullscreen mode

Strassen Matrix Multiplication

Strassen’s algorithm is a fast matrix multiplication algorithm that reduces the number of multiplications required.

Here’s a Python implementation:

import numpy as np

def add_matrices(A, B):
    return np.add(A, B)

def subtract_matrices(A, B):
    return np.subtract(A, B)

def split_matrix(M):
    n, m = M.shape
    mid_n, mid_m = n // 2, m // 2
    A11 = M[:mid_n, :mid_m]
    A12 = M[:mid_n, mid_m:]
    A21 = M[mid_n:, :mid_m]
    A22 = M[mid_n:, mid_m:]
    return A11, A12, A21, A22

def merge_matrices(C11, C12, C21, C22):
    top = np.hstack((C11, C12))
    bottom = np.hstack((C21, C22))
    return np.vstack((top, bottom))

def brute_force(A, B):
    return np.dot(A, B)

def strassen(A, B):
    if len(A) <= 2:
        return brute_force(A, B)

    a, b, c, d = split_matrix(A)
    e, f, g, h = split_matrix(B)

    p1 = strassen(add_matrices(a, d), add_matrices(e, h))
    p2 = strassen(d, subtract_matrices(g, e))
    p3 = strassen(add_matrices(a, b), h)
    p4 = strassen(b, subtract_matrices(g, h))
    p5 = strassen(a, subtract_matrices(f, h))
    p6 = strassen(add_matrices(c, d), e)
    p7 = strassen(subtract_matrices(a, c), add_matrices(e, f))

    C11 = add_matrices(subtract_matrices(add_matrices(p1, p2), p3), p4)
    C12 = add_matrices(p5, p3)
    C21 = add_matrices(p6, p2)
    C22 = add_matrices(subtract_matrices(add_matrices(p1, p5), p6), p7)

    return merge_matrices(C11, C12, C21, C22)

# Testing Strassen matrix multiplication
A = np.array([[1, 2, 3, 4],
              [5, 6, 7, 8]])

B = np.array([[16, 15, 14],
              [12, 11, 10],
              [8, 7, 6],
              [4, 3, 2]])

C = strassen(A, B)
print("Result of Strassen's matrix multiplication:")
print(C)
Enter fullscreen mode Exit fullscreen mode

Karatsuba Fast Multiplication

Karatsuba’s algorithm is a fast multiplication algorithm that reduces the number of multiplications required to multiply large numbers.

Here’s a Python implementation:

def karatsuba(x, y):
    if x < 10 or y < 10:
        return x * y
    else:
        n = max(len(str(x)), len(str(y)))
        half = n // 2
        a = x // (10 ** half)  # left part of x
        b = x % (10 ** half)   # right part of x
        c = y // (10 ** half)  # left part of y
        d = y % (10 ** half)   # right part of y

        ac = karatsuba(a, c)
        bd = karatsuba(b, d)
        ad_plus_bc = karatsuba(a + b, c + d) - ac - bd

        return ac * (10 ** (2 * half)) + (ad_plus_bc * (10 ** half)) + bd

# Testing the method
x = 12476
y = 12343
result = karatsuba(x, y)
print(f"The result of {x} * {y} using Karatsuba algorithm is: {result}")
Enter fullscreen mode Exit fullscreen mode

These Python implementations of divide and conquer algorithms demonstrate how this paradigm can be applied to various types of problems, including searching, sorting, matrix multiplication, and multiplication of large numbers.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .