CrackedRuby CrackedRuby

Overview

Divide and conquer is an algorithm design paradigm that solves problems by recursively breaking them into smaller, independent subproblems until they become simple enough to solve directly. The solutions to subproblems are then combined to produce the solution to the original problem. This approach transforms complex problems into manageable units through systematic decomposition.

The paradigm originated in mathematical analysis and gained prominence in computer science through algorithms like merge sort and quick sort developed in the 1960s. John von Neumann's merge sort implementation in 1945 represents one of the earliest applications of divide and conquer in computing. The approach has since become fundamental to algorithm design, appearing in solutions ranging from sorting and searching to computational geometry and dynamic programming optimizations.

Divide and conquer differs from other problem-solving strategies through its emphasis on problem decomposition and independent subproblem solving. Unlike greedy algorithms that make locally optimal choices or dynamic programming that solves overlapping subproblems, divide and conquer partitions problems into distinct, non-overlapping subproblems. This independence enables parallel execution and often leads to efficient recursive implementations.

The paradigm applies most effectively to problems exhibiting optimal substructure—where optimal solutions to the original problem can be constructed from optimal solutions to subproblems. Problems like sorting arrays, searching ordered data, matrix multiplication, and finding closest pairs of points naturally fit this pattern.

# Simple divide and conquer structure
def divide_and_conquer(problem)
  return solve_directly(problem) if problem.simple?
  
  subproblems = divide(problem)
  subsolutions = subproblems.map { |sub| divide_and_conquer(sub) }
  combine(subsolutions)
end

Key Principles

Divide and conquer algorithms follow a three-step recursive pattern: divide the problem into subproblems, conquer by solving subproblems recursively, and combine subproblem solutions into the final solution. Each step plays a distinct role in the overall solution strategy.

Divide Step: The divide phase partitions the original problem into smaller subproblems of the same type. The division must create independent subproblems that can be solved without reference to each other. In binary search, this means splitting the search space in half. In merge sort, it means dividing the array into two roughly equal portions. The division continues recursively until reaching the base case.

Conquer Step: The conquer phase solves subproblems recursively using the same divide and conquer approach. When subproblems become small enough—typically containing one or zero elements—the algorithm solves them directly without further recursion. This base case termination prevents infinite recursion and provides the foundation for building up solutions.

Combine Step: The combine phase constructs the solution to the original problem from subproblem solutions. This step's complexity varies significantly across algorithms. Binary search requires no combination since finding the element in a subproblem solves the entire search. Merge sort requires linear time to merge sorted subarrays. Matrix multiplication requires quadratic time to add partial products.

Recursion Depth: The depth of recursive calls determines stack space requirements and influences performance characteristics. An algorithm dividing problems into two equal parts achieves logarithmic recursion depth. Unbalanced divisions, like worst-case quick sort repeatedly picking the smallest element as pivot, degrade to linear depth. Recursion depth directly impacts stack overflow risk and cache performance.

Base Cases: Every divide and conquer algorithm requires explicit base cases that halt recursion. Base cases typically handle problems too small to divide further—arrays with zero or one element, empty search spaces, single nodes in tree structures. Missing or incorrect base cases cause infinite recursion or incorrect results.

Subproblem Independence: Divide and conquer requires subproblems to be independent—solvable without information from other subproblems at the same recursion level. This independence distinguishes divide and conquer from dynamic programming, where subproblems overlap and solutions get reused. Independence enables parallel execution but prevents memoization optimizations.

Problem Size Reduction: Each division must reduce problem size to guarantee termination. Most algorithms reduce size by a constant factor (dividing by two) or by a constant amount (removing one element). Algorithms failing to reduce problem size at each step run indefinitely.

# Demonstrating the three phases
def merge_sort(array)
  # Base case - conquer phase for trivial problems
  return array if array.length <= 1
  
  # Divide phase
  mid = array.length / 2
  left = array[0...mid]
  right = array[mid..]
  
  # Recursive conquer phase
  sorted_left = merge_sort(left)
  sorted_right = merge_sort(right)
  
  # Combine phase
  merge(sorted_left, sorted_right)
end

def merge(left, right)
  result = []
  i = j = 0
  
  while i < left.length && j < right.length
    if left[i] <= right[j]
      result << left[i]
      i += 1
    else
      result << right[j]
      j += 1
    end
  end
  
  result + left[i..] + right[j..]
end

Ruby Implementation

Ruby's support for recursion, first-class functions, and array manipulation makes it well-suited for implementing divide and conquer algorithms. The language's automatic memory management handles recursive call stacks without manual intervention, though stack depth limits still apply.

Recursive Method Definitions: Ruby implements divide and conquer algorithms through recursive methods that call themselves with smaller inputs. The language handles recursive calls identically to regular method calls, maintaining separate stack frames for each invocation. Ruby's default stack size allows approximately 10,000-15,000 recursive calls before raising a SystemStackError.

# Binary search implementation
def binary_search(array, target, left = 0, right = array.length - 1)
  return -1 if left > right
  
  mid = (left + right) / 2
  
  return mid if array[mid] == target
  
  if array[mid] > target
    binary_search(array, target, left, mid - 1)
  else
    binary_search(array, target, mid + 1, right)
  end
end

sorted = [1, 3, 5, 7, 9, 11, 13, 15]
binary_search(sorted, 7)  # => 3
binary_search(sorted, 8)  # => -1

Array Slicing: Ruby's array slicing operators support natural problem division. The range operator creates subarrays without copying elements until modification (copy-on-write semantics in some implementations), though programmers should consider memory implications of creating many intermediate arrays.

# Quick sort using array slicing
def quick_sort(array)
  return array if array.length <= 1
  
  pivot = array[array.length / 2]
  
  # Divide into three partitions
  less = array.select { |x| x < pivot }
  equal = array.select { |x| x == pivot }
  greater = array.select { |x| x > pivot }
  
  # Combine sorted partitions
  quick_sort(less) + equal + quick_sort(greater)
end

quick_sort([3, 1, 4, 1, 5, 9, 2, 6, 5])
# => [1, 1, 2, 3, 4, 5, 5, 6, 9]

In-Place Modifications: Some divide and conquer algorithms operate in-place to reduce memory usage. Ruby supports this through index-based operations rather than array slicing, trading conciseness for efficiency.

# In-place quick sort
def quick_sort_inplace(array, left = 0, right = array.length - 1)
  return if left >= right
  
  pivot_index = partition(array, left, right)
  quick_sort_inplace(array, left, pivot_index - 1)
  quick_sort_inplace(array, pivot_index + 1, right)
  array
end

def partition(array, left, right)
  pivot = array[right]
  i = left
  
  (left...right).each do |j|
    if array[j] <= pivot
      array[i], array[j] = array[j], array[i]
      i += 1
    end
  end
  
  array[i], array[right] = array[right], array[i]
  i
end

Block-Based Recursion: Ruby blocks enable passing comparison logic into divide and conquer algorithms, creating generic implementations that work with any comparable type.

# Generic merge sort with custom comparator
def merge_sort_generic(array, &comparator)
  comparator ||= ->(a, b) { a <=> b }
  return array if array.length <= 1
  
  mid = array.length / 2
  left = merge_sort_generic(array[0...mid], &comparator)
  right = merge_sort_generic(array[mid..], &comparator)
  
  merge_with_comparator(left, right, &comparator)
end

def merge_with_comparator(left, right, &comparator)
  result = []
  i = j = 0
  
  while i < left.length && j < right.length
    if comparator.call(left[i], right[j]) <= 0
      result << left[i]
      i += 1
    else
      result << right[j]
      j += 1
    end
  end
  
  result + left[i..] + right[j..]
end

# Sort strings by length
words = ["cat", "elephant", "dog", "a", "horse"]
merge_sort_generic(words) { |a, b| a.length <=> b.length }
# => ["a", "cat", "dog", "horse", "elephant"]

Enumerable Methods: Ruby's Enumerable module provides built-in methods that use divide and conquer internally. The sort method typically uses an optimized merge sort or intro sort algorithm, and partition methods facilitate quick sort implementations.

# Using partition for quick sort style division
def quick_select(array, k)
  return nil if k < 0 || k >= array.length
  return array[0] if array.length == 1
  
  pivot = array[array.length / 2]
  less, greater_or_equal = array.partition { |x| x < pivot }
  
  if k < less.length
    quick_select(less, k)
  else
    quick_select(greater_or_equal, k - less.length)
  end
end

# Find 3rd smallest element
quick_select([3, 1, 4, 1, 5, 9, 2, 6], 2)  # => 2

Practical Examples

Finding Maximum Element: The maximum element problem demonstrates the simplest divide and conquer pattern. Divide the array in half, find the maximum in each half recursively, and return the larger of the two maxima.

def find_max(array, left = 0, right = array.length - 1)
  # Base case: single element
  return array[left] if left == right
  
  # Base case: two elements
  if left == right - 1
    return array[left] > array[right] ? array[left] : array[right]
  end
  
  # Divide
  mid = (left + right) / 2
  
  # Conquer
  max_left = find_max(array, left, mid)
  max_right = find_max(array, mid + 1, right)
  
  # Combine
  max_left > max_right ? max_left : max_right
end

find_max([3, 1, 4, 1, 5, 9, 2, 6, 5])  # => 9

Count Inversions: Counting inversions—pairs of elements where a larger element precedes a smaller one—extends merge sort to track additional information during the combine phase. This demonstrates how divide and conquer algorithms can solve multiple related problems simultaneously.

def count_inversions(array)
  return [array, 0] if array.length <= 1
  
  mid = array.length / 2
  left, left_inv = count_inversions(array[0...mid])
  right, right_inv = count_inversions(array[mid..])
  
  merged, split_inv = merge_and_count(left, right)
  
  [merged, left_inv + right_inv + split_inv]
end

def merge_and_count(left, right)
  result = []
  inversions = 0
  i = j = 0
  
  while i < left.length && j < right.length
    if left[i] <= right[j]
      result << left[i]
      i += 1
    else
      result << right[j]
      # All remaining elements in left are inversions with right[j]
      inversions += left.length - i
      j += 1
    end
  end
  
  result += left[i..] if i < left.length
  result += right[j..] if j < right.length
  
  [result, inversions]
end

sorted, count = count_inversions([3, 1, 4, 1, 5])
# sorted => [1, 1, 3, 4, 5]
# count => 4 (pairs: 3-1, 3-1, 4-1, 3-1)

Closest Pair of Points: Finding the closest pair of points in a 2D plane combines geometric insights with divide and conquer. The algorithm divides points by x-coordinate, recursively finds closest pairs in each half, then checks points near the dividing line that might form closer pairs.

Point = Struct.new(:x, :y) do
  def distance(other)
    Math.sqrt((x - other.x)**2 + (y - other.y)**2)
  end
end

def closest_pair(points)
  return Float::INFINITY if points.length < 2
  
  # Sort by x-coordinate once
  points_x = points.sort_by(&:x)
  points_y = points.sort_by(&:y)
  
  closest_pair_recursive(points_x, points_y)
end

def closest_pair_recursive(points_x, points_y)
  n = points_x.length
  
  # Base case: brute force for small inputs
  return brute_force_closest(points_x) if n <= 3
  
  # Divide
  mid = n / 2
  midpoint = points_x[mid]
  
  left_x = points_x[0...mid]
  right_x = points_x[mid..]
  
  left_y = points_y.select { |p| p.x <= midpoint.x }
  right_y = points_y.select { |p| p.x > midpoint.x }
  
  # Conquer
  left_min = closest_pair_recursive(left_x, left_y)
  right_min = closest_pair_recursive(right_x, right_y)
  
  # Combine
  delta = [left_min, right_min].min
  
  # Check points in strip of width 2*delta
  strip = points_y.select { |p| (p.x - midpoint.x).abs < delta }
  strip_min = closest_in_strip(strip, delta)
  
  [delta, strip_min].min
end

def brute_force_closest(points)
  min_dist = Float::INFINITY
  
  points.combination(2) do |p1, p2|
    dist = p1.distance(p2)
    min_dist = dist if dist < min_dist
  end
  
  min_dist
end

def closest_in_strip(strip, delta)
  min_dist = delta
  
  strip.each_with_index do |p1, i|
    # Only check next 7 points (proven sufficient)
    (i + 1...[i + 8, strip.length].min).each do |j|
      break if strip[j].y - p1.y >= min_dist
      
      dist = p1.distance(strip[j])
      min_dist = dist if dist < min_dist
    end
  end
  
  min_dist
end

Karatsuba Multiplication: Multiplying large numbers using Karatsuba's algorithm reduces three recursive multiplications to perform integer multiplication faster than the standard grade-school algorithm.

def karatsuba(x, y)
  # Base case: single digit
  return x * y if x < 10 || y < 10
  
  # Determine number of digits
  n = [x.to_s.length, y.to_s.length].max
  m = n / 2
  
  # Divide
  power = 10**m
  a = x / power
  b = x % power
  c = y / power
  d = y % power
  
  # Conquer: three multiplications instead of four
  ac = karatsuba(a, c)
  bd = karatsuba(b, d)
  ad_plus_bc = karatsuba(a + b, c + d) - ac - bd
  
  # Combine
  ac * (10**(2 * m)) + ad_plus_bc * power + bd
end

karatsuba(1234, 5678)  # => 7006652

Implementation Approaches

Top-Down Recursion: The standard divide and conquer approach implements algorithms as recursive functions that divide problems until reaching base cases. This approach maps naturally to the conceptual framework and produces clear, maintainable code. Ruby's call stack manages recursion state automatically, but stack depth limits constrain problem sizes to those requiring roughly 10,000 or fewer recursive calls.

# Standard recursive approach
def power(base, exp)
  return 1 if exp == 0
  return base if exp == 1
  
  half = power(base, exp / 2)
  
  if exp.even?
    half * half
  else
    base * half * half
  end
end

power(2, 10)  # => 1024

Bottom-Up Iteration: Converting recursive divide and conquer into iterative loops eliminates stack overflow risk and may improve cache performance. This transformation works best for algorithms with regular recursion patterns like binary search. Irregular patterns like tree traversal prove harder to convert cleanly.

# Iterative binary search
def binary_search_iterative(array, target)
  left = 0
  right = array.length - 1
  
  while left <= right
    mid = (left + right) / 2
    
    return mid if array[mid] == target
    
    if array[mid] < target
      left = mid + 1
    else
      right = mid - 1
    end
  end
  
  -1
end

Tail Recursion Optimization: Some divide and conquer algorithms can be restructured into tail-recursive form where the recursive call is the last operation. Ruby does not optimize tail recursion by default, but enabling it through RubyVM compile options can eliminate stack growth for tail-recursive functions.

# Tail-recursive binary search
def binary_search_tail(array, target, left = 0, right = array.length - 1)
  return -1 if left > right
  
  mid = (left + right) / 2
  
  return mid if array[mid] == target
  
  if array[mid] < target
    binary_search_tail(array, target, mid + 1, right)
  else
    binary_search_tail(array, target, left, mid - 1)
  end
end

Hybrid Approaches: Many production implementations combine divide and conquer with other strategies. Switching to insertion sort for small subarrays improves merge sort and quick sort performance by avoiding recursion overhead and improving cache behavior. Threshold sizes typically range from 10 to 50 elements depending on data characteristics.

# Hybrid merge sort
INSERTION_THRESHOLD = 15

def merge_sort_hybrid(array)
  return insertion_sort(array) if array.length <= INSERTION_THRESHOLD
  
  mid = array.length / 2
  left = merge_sort_hybrid(array[0...mid])
  right = merge_sort_hybrid(array[mid..])
  
  merge(left, right)
end

def insertion_sort(array)
  (1...array.length).each do |i|
    key = array[i]
    j = i - 1
    
    while j >= 0 && array[j] > key
      array[j + 1] = array[j]
      j -= 1
    end
    
    array[j + 1] = key
  end
  
  array
end

Parallel Execution: Divide and conquer's independent subproblems enable parallel execution. Ruby's parallel gem or concurrent-ruby library can execute subproblems on separate threads or processes. The overhead of parallelization only pays off for sufficiently large problems—typically millions of elements for sorting.

require 'concurrent'

def parallel_merge_sort(array, threshold = 10_000)
  return array.sort if array.length <= threshold
  
  mid = array.length / 2
  
  # Execute left and right sorts in parallel
  future_left = Concurrent::Future.execute { parallel_merge_sort(array[0...mid], threshold) }
  future_right = Concurrent::Future.execute { parallel_merge_sort(array[mid..], threshold) }
  
  left = future_left.value
  right = future_right.value
  
  merge(left, right)
end

Performance Considerations

Time Complexity Analysis: Divide and conquer algorithms exhibit time complexity determined by the recurrence relation describing recursive calls. The Master Theorem provides a framework for analyzing these recurrences. For a recurrence T(n) = aT(n/b) + f(n), where a represents the number of subproblems, b represents the division factor, and f(n) represents the work to divide and combine:

  • If f(n) = O(n^log_b(a-ε)) for some ε > 0, then T(n) = Θ(n^log_b(a))
  • If f(n) = Θ(n^log_b(a)), then T(n) = Θ(n^log_b(a) * log n)
  • If f(n) = Ω(n^log_b(a+ε)) for some ε > 0 and af(n/b) ≤ cf(n) for some c < 1, then T(n) = Θ(f(n))

Binary search splits the problem in half (b = 2) with one recursive call (a = 1) and constant work (f(n) = O(1)). This gives T(n) = T(n/2) + O(1) = O(log n). Merge sort splits in half (b = 2) with two recursive calls (a = 2) and linear merge work (f(n) = O(n)). This gives T(n) = 2T(n/2) + O(n) = O(n log n).

# Demonstrating different complexity classes
def binary_search_demo(n)
  array = (1..n).to_a
  start = Time.now
  1000.times { binary_search(array, rand(n)) }
  Time.now - start
end

# O(log n) - grows very slowly
binary_search_demo(1_000)      # ~0.001s
binary_search_demo(1_000_000)  # ~0.002s (only 2x slower for 1000x data)

Space Complexity: Recursive divide and conquer algorithms consume stack space proportional to recursion depth. Algorithms dividing problems in half achieve O(log n) space complexity for the call stack. Additional space for combining results varies by algorithm. Merge sort requires O(n) auxiliary space for merging, while quick sort achieves O(log n) space when implemented in-place.

Stack overflow occurs when recursion depth exceeds available stack space. Ruby's default stack size supports roughly 10,000-15,000 recursive calls. Algorithms with O(log n) depth handle inputs with millions of elements safely. Algorithms with O(n) depth in worst cases—like unbalanced quick sort—risk stack overflow on large inputs.

Cache Performance: Divide and conquer algorithms accessing data sequentially benefit from CPU cache prefetching. Merge sort's sequential merge operation exhibits excellent cache performance. Quick sort's partitioning scans arrays sequentially, though cache performance degrades with poor pivot selection causing many cache misses during partitioning.

Recursion depth affects cache performance through working set size. Shallow recursion keeps more data in cache simultaneously. Deep recursion causes cache thrashing as recursive calls each access different memory regions. Hybrid approaches switching to iterative algorithms for small subproblems improve cache behavior.

Branch Prediction: Modern CPUs predict conditional branches to maintain instruction pipeline flow. Divide and conquer algorithms with predictable branching patterns—like binary search always branching based on comparison results—benefit from accurate branch prediction. Algorithms with data-dependent branching—like quick sort partitioning—may suffer branch misprediction penalties.

Memory Allocation: Creating many small intermediate arrays during recursion stresses garbage collection. Ruby's generational garbage collector handles short-lived objects efficiently, but excessive allocation still impacts performance. In-place algorithms avoiding intermediate arrays reduce memory pressure and improve performance for large inputs.

# Memory-efficient in-place partitioning
def median_of_three(array, left, right)
  mid = (left + right) / 2
  
  if array[left] > array[mid]
    array[left], array[mid] = array[mid], array[left]
  end
  
  if array[left] > array[right]
    array[left], array[right] = array[right], array[left]
  end
  
  if array[mid] > array[right]
    array[mid], array[right] = array[right], array[mid]
  end
  
  mid
end

Common Patterns

Binary Division Pattern: Dividing problems into two roughly equal halves represents the most common divide and conquer pattern. This pattern achieves logarithmic recursion depth and applies to searching, sorting, and many computational geometry problems. The division point often falls at the middle index, median value, or geometric midpoint.

# Binary division template
def binary_divide_pattern(data, left, right)
  return base_case(data, left, right) if left >= right
  
  mid = (left + right) / 2
  
  left_result = binary_divide_pattern(data, left, mid)
  right_result = binary_divide_pattern(data, mid + 1, right)
  
  combine(left_result, right_result)
end

Multi-Way Division Pattern: Some problems benefit from dividing into more than two subproblems. Matrix multiplication can divide matrices into four quadrants. String matching algorithms may divide patterns into multiple parts. The increased branching factor trades deeper recursion for simpler combination logic.

# Strassen matrix multiplication (simplified)
def matrix_multiply_strassen(a, b)
  n = a.length
  return [[a[0][0] * b[0][0]]] if n == 1
  
  # Divide into quadrants
  mid = n / 2
  a11, a12, a21, a22 = divide_matrix(a, mid)
  b11, b12, b21, b22 = divide_matrix(b, mid)
  
  # Seven multiplications instead of eight
  m1 = matrix_multiply_strassen(add_matrix(a11, a22), add_matrix(b11, b22))
  m2 = matrix_multiply_strassen(add_matrix(a21, a22), b11)
  m3 = matrix_multiply_strassen(a11, sub_matrix(b12, b22))
  m4 = matrix_multiply_strassen(a22, sub_matrix(b21, b11))
  m5 = matrix_multiply_strassen(add_matrix(a11, a12), b22)
  m6 = matrix_multiply_strassen(sub_matrix(a21, a11), add_matrix(b11, b12))
  m7 = matrix_multiply_strassen(sub_matrix(a12, a22), add_matrix(b21, b22))
  
  # Combine quadrants
  c11 = add_matrix(sub_matrix(add_matrix(m1, m4), m5), m7)
  c12 = add_matrix(m3, m5)
  c21 = add_matrix(m2, m4)
  c22 = add_matrix(sub_matrix(add_matrix(m1, m3), m2), m6)
  
  combine_matrix(c11, c12, c21, c22)
end

Decrease and Conquer Pattern: A variant that reduces problem size by a constant amount rather than a constant factor. This pattern appears in algorithms removing one element per recursion level. While technically distinct from divide and conquer, the implementation patterns overlap.

# Selection problem - find kth smallest element
def quick_select_pattern(array, k)
  return array[0] if array.length == 1
  
  pivot = array.sample
  less = array.select { |x| x < pivot }
  equal = array.select { |x| x == pivot }
  greater = array.select { |x| x > pivot }
  
  if k < less.length
    quick_select_pattern(less, k)
  elsif k < less.length + equal.length
    pivot
  else
    quick_select_pattern(greater, k - less.length - equal.length)
  end
end

Transform and Conquer Pattern: Converting the problem into a different representation before applying divide and conquer. Fast Fourier Transform converts time-domain signals to frequency domain, applies divide and conquer multiplication, then converts back. This pattern often achieves better asymptotic complexity than direct approaches.

# Simplified FFT pattern (conceptual)
def fft_pattern(coefficients)
  n = coefficients.length
  return coefficients if n == 1
  
  # Divide into even and odd coefficients
  even = coefficients.select.with_index { |_, i| i.even? }
  odd = coefficients.select.with_index { |_, i| i.odd? }
  
  # Recursive FFT on half-sized problems
  y_even = fft_pattern(even)
  y_odd = fft_pattern(odd)
  
  # Combine using roots of unity
  y = Array.new(n)
  (n / 2).times do |k|
    omega = Complex.polar(1, -2 * Math::PI * k / n)
    t = omega * y_odd[k]
    y[k] = y_even[k] + t
    y[k + n/2] = y_even[k] - t
  end
  
  y
end

Prune and Search Pattern: Eliminating portions of the search space without examining them. This pattern appears in algorithms like binary search that discard half the data at each step. The key insight is proving that the discarded portion cannot contain the solution.

# Finding peak element (local maximum)
def find_peak(array)
  find_peak_recursive(array, 0, array.length - 1)
end

def find_peak_recursive(array, left, right)
  return left if left == right
  
  mid = (left + right) / 2
  
  # If mid is a peak, return it
  if (mid == 0 || array[mid] >= array[mid - 1]) &&
     (mid == array.length - 1 || array[mid] >= array[mid + 1])
    return mid
  end
  
  # Search the side with larger neighbor
  if mid > 0 && array[mid - 1] > array[mid]
    find_peak_recursive(array, left, mid - 1)
  else
    find_peak_recursive(array, mid + 1, right)
  end
end

Reference

Complexity Analysis

Pattern Recurrence Time Complexity Space Complexity Examples
Binary division, constant combine T(n) = T(n/2) + O(1) O(log n) O(log n) Binary search
Binary division, linear combine T(n) = 2T(n/2) + O(n) O(n log n) O(n) Merge sort
Binary division, quadratic combine T(n) = 2T(n/2) + O(n²) O(n²) O(log n) Naive matrix multiplication
Unbalanced division T(n) = T(n-1) + O(n) O(n²) O(n) Worst-case quick sort
Three-way division T(n) = 3T(n/2) + O(n) O(n^1.58) O(log n) Karatsuba multiplication
Four-way division T(n) = 4T(n/2) + O(n) O(n²) O(log n) Standard matrix multiplication

Algorithm Classification

Algorithm Division Strategy Combine Complexity Best/Average/Worst Stable In-Place
Binary search Midpoint O(1) O(log n) all cases N/A Yes
Merge sort Midpoint O(n) O(n log n) all cases Yes No
Quick sort Pivot partition O(1) O(n log n) / O(n log n) / O(n²) No Yes
Heap sort Binary tree O(log n) O(n log n) all cases No Yes
Quick select Pivot partition O(1) O(n) / O(n) / O(n²) N/A Yes
Strassen multiplication Quadrants O(n²) O(n^2.807) all cases N/A No
Closest pair Spatial partition O(n) O(n log n) all cases N/A No

Common Recurrence Relations

Form Solution Application
T(n) = T(n/2) + O(1) O(log n) Binary search, peak finding
T(n) = 2T(n/2) + O(1) O(n) Tree traversal
T(n) = 2T(n/2) + O(n) O(n log n) Merge sort, multiplication
T(n) = T(n/2) + O(n) O(n) Linear-time selection
T(n) = 3T(n/2) + O(n) O(n^1.58) Karatsuba
T(n) = 4T(n/2) + O(n²) O(n²) Matrix operations
T(n) = 7T(n/2) + O(n²) O(n^2.807) Strassen multiplication
T(n) = T(n-1) + O(n) O(n²) Poor quick sort pivot

Implementation Checklist

Consideration Check Notes
Base case defined All recursion paths terminate Handle n=0, n=1 explicitly
Problem size reduction Each recursion reduces size Prevents infinite recursion
Subproblem independence No shared state between subproblems Enables parallelization
Combination logic Correctly merges subproblem results Often most complex part
Stack depth Maximum depth under limits Ruby default ~10k-15k calls
Memory allocation Minimize intermediate arrays Consider in-place alternatives
Edge cases Empty input, single element Often require special handling
Type consistency Input and output types match Especially for generic code

Ruby-Specific Considerations

Feature Usage Performance Impact
Array slicing array[0...mid] Creates new array, O(n) space
Range operators array[start..end] Inclusive/exclusive range semantics
Partition method array.partition Returns two arrays, O(n) time and space
Select method array.select { block } Creates new array with matching elements
Sort method array.sort Typically uses intro sort, O(n log n)
Combination array.combination(2) Generates all pairs, O(n²) space
Recursion limit SystemStackError Default ~10k-15k recursive calls
Tail call optimization RubyVM compile flag Not enabled by default

Selection Guide

Problem Type Algorithm When To Use
Sorted search Binary search O(log n) search in sorted data
General sorting Merge sort Stable sort, predictable performance
Fast average sort Quick sort In-place, good cache behavior
Selection Quick select Find kth element without full sort
Counting Modified merge sort Count inversions, smaller elements
Geometry Closest pair Points in 2D/3D space
Multiplication Karatsuba Large integer multiplication
Matrix operations Strassen Dense matrix multiplication
Range queries Segment trees Cumulative operations over ranges