CrackedRuby CrackedRuby

Overview

Backtracking is a general algorithmic approach for finding solutions to computational problems that involve searching through a space of possible solutions. The algorithm builds candidates incrementally and abandons a candidate ("backtracks") as soon as it determines that the candidate cannot lead to a valid solution.

The technique applies to problems where the solution consists of a sequence of choices, and each choice has a finite set of options. Backtracking explores the decision tree by making a choice, recursively exploring the consequences, and undoing the choice if it leads to failure. This systematic exploration continues until a solution is found or all possibilities are exhausted.

Common applications include solving puzzles (Sudoku, N-Queens, crosswords), constraint satisfaction problems, combinatorial optimization, parsing, and pathfinding in graphs. The algorithm serves as the foundation for many problem-solving techniques in artificial intelligence, operations research, and computer science.

Backtracking differs from brute force by pruning branches of the search tree early when constraints are violated, making it more efficient than exhaustive enumeration. The algorithm maintains state during exploration and restores it when backtracking, which distinguishes it from simple trial-and-error approaches.

# Simple backtracking template
def backtrack(state, choices)
  return state if goal_reached?(state)
  
  choices.each do |choice|
    if valid?(state, choice)
      make_choice(state, choice)
      result = backtrack(state, remaining_choices)
      return result if result
      undo_choice(state, choice)
    end
  end
  
  nil
end

Key Principles

Backtracking operates on three fundamental principles: incremental construction, constraint checking, and systematic exploration.

Incremental Construction builds solutions piece by piece rather than generating complete candidates upfront. At each step, the algorithm extends a partial solution with one additional component. This partial solution represents a node in the search tree, and each possible extension creates a child node. The incremental approach allows the algorithm to detect failures early without constructing the entire solution.

Constraint Checking evaluates whether a partial solution can lead to a complete valid solution. The algorithm applies constraints at each step to prune branches that cannot succeed. Early constraint checking is critical for performance—detecting violations immediately prevents exploring the entire subtree rooted at an invalid partial solution. Constraints can be explicit (hard rules that must be satisfied) or implicit (heuristics that guide the search).

Systematic Exploration ensures all valid solutions can be found through depth-first traversal of the solution space. The algorithm explores one branch completely before moving to the next, maintaining a stack (either explicitly or through recursion) of decision points. When a branch fails, the algorithm backtracks to the most recent decision point and tries the next option.

The backtracking process follows a specific pattern:

  1. Choose an unassigned component of the solution
  2. Try each possible value for that component
  3. For each value, check if it violates any constraints
  4. If valid, recursively attempt to complete the solution
  5. If recursion succeeds, return the solution
  6. If recursion fails, undo the choice and try the next value
  7. If all values fail, backtrack to the previous decision
# Backtracking state management
class BacktrackState
  attr_reader :path, :visited
  
  def initialize
    @path = []
    @visited = Set.new
  end
  
  def add(item)
    @path << item
    @visited.add(item)
  end
  
  def remove
    item = @path.pop
    @visited.delete(item)
    item
  end
  
  def valid?(item)
    !@visited.include?(item)
  end
end

The efficiency of backtracking depends on the order of choices and the effectiveness of constraint checking. Choosing variables with fewer legal values first (most constrained variable heuristic) reduces the branching factor. Similarly, ordering values from most to least likely to succeed (least constraining value heuristic) increases the probability of finding solutions quickly.

Backtracking naturally handles problems with multiple solutions by continuing the search after finding the first solution. The algorithm can collect all solutions, find the optimal solution according to some criterion, or stop at the first valid solution depending on requirements.

Ruby Implementation

Ruby's support for blocks, recursion, and dynamic arrays makes it well-suited for implementing backtracking algorithms. The language's expressiveness allows clean separation of the backtracking framework from problem-specific logic.

Basic Recursive Backtracking

class Backtracker
  def solve(initial_state, &block)
    backtrack(initial_state, &block)
  end
  
  private
  
  def backtrack(state, &block)
    return state if block.call(:goal?, state)
    
    choices = block.call(:choices, state)
    choices.each do |choice|
      if block.call(:valid?, state, choice)
        block.call(:apply, state, choice)
        result = backtrack(state, &block)
        return result if result
        block.call(:revert, state, choice)
      end
    end
    
    nil
  end
end

# Usage example: Finding a path in a graph
graph = {
  'A' => ['B', 'C'],
  'B' => ['D', 'E'],
  'C' => ['F'],
  'D' => [],
  'E' => ['F'],
  'F' => []
}

solver = Backtracker.new
start, target = 'A', 'F'

result = solver.solve([start]) do |action, state, choice = nil|
  case action
  when :goal?
    state.last == target
  when :choices
    graph[state.last] || []
  when :valid?
    !state.include?(choice)
  when :apply
    state << choice
  when :revert
    state.pop
  end
end

# => ["A", "C", "F"]

Iterative Backtracking with Explicit Stack

Ruby can implement backtracking iteratively using an explicit stack, which avoids recursion depth limits and provides more control over the search process.

class IterativeBacktracker
  def solve(initial_state)
    stack = [[initial_state, choices_for(initial_state), 0]]
    
    until stack.empty?
      state, choices, index = stack.last
      
      if goal_reached?(state)
        return state
      end
      
      if index >= choices.size
        stack.pop
        next
      end
      
      choice = choices[index]
      stack.last[2] += 1
      
      if valid_choice?(state, choice)
        new_state = apply_choice(state, choice)
        new_choices = choices_for(new_state)
        stack << [new_state, new_choices, 0]
      end
    end
    
    nil
  end
  
  def choices_for(state)
    raise NotImplementedError
  end
  
  def goal_reached?(state)
    raise NotImplementedError
  end
  
  def valid_choice?(state, choice)
    raise NotImplementedError
  end
  
  def apply_choice(state, choice)
    raise NotImplementedError
  end
end

Generator-Based Solution Enumeration

Ruby's Enumerator class enables lazy generation of all solutions without storing them in memory simultaneously.

class SolutionGenerator
  def all_solutions(state)
    Enumerator.new do |yielder|
      generate_solutions(state, yielder)
    end
  end
  
  private
  
  def generate_solutions(state, yielder)
    if complete?(state)
      yielder << state.dup
      return
    end
    
    next_choices(state).each do |choice|
      if valid?(state, choice)
        apply(state, choice)
        generate_solutions(state, yielder)
        revert(state, choice)
      end
    end
  end
  
  # Problem-specific methods
  def complete?(state); raise NotImplementedError; end
  def next_choices(state); raise NotImplementedError; end
  def valid?(state, choice); raise NotImplementedError; end
  def apply(state, choice); raise NotImplementedError; end
  def revert(state, choice); raise NotImplementedError; end
end

# Example: Generate all permutations
class PermutationGenerator < SolutionGenerator
  def initialize(elements)
    @elements = elements
  end
  
  def complete?(state)
    state.size == @elements.size
  end
  
  def next_choices(state)
    @elements - state
  end
  
  def valid?(state, choice)
    !state.include?(choice)
  end
  
  def apply(state, choice)
    state << choice
  end
  
  def revert(state, choice)
    state.pop
  end
end

gen = PermutationGenerator.new([1, 2, 3])
gen.all_solutions([]).first(6)
# => [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

Constraint Propagation Integration

Advanced backtracking implementations combine constraint checking with constraint propagation to reduce the search space further.

class ConstraintBacktracker
  attr_reader :domains
  
  def initialize(variables, initial_domains)
    @variables = variables
    @domains = initial_domains.dup
    @assignments = {}
  end
  
  def solve
    backtrack_with_propagation
  end
  
  private
  
  def backtrack_with_propagation
    return @assignments.dup if complete?
    
    var = select_unassigned_variable
    ordered_values(var).each do |value|
      if consistent?(var, value)
        assign(var, value)
        saved_domains = save_domains
        
        if propagate_constraints(var, value)
          result = backtrack_with_propagation
          return result if result
        end
        
        restore_domains(saved_domains)
        unassign(var)
      end
    end
    
    nil
  end
  
  def complete?
    @assignments.size == @variables.size
  end
  
  def select_unassigned_variable
    # Most constrained variable heuristic
    (@variables - @assignments.keys).min_by { |v| @domains[v].size }
  end
  
  def ordered_values(var)
    # Least constraining value heuristic
    @domains[var].sort_by { |val| count_conflicts(var, val) }
  end
  
  def assign(var, value)
    @assignments[var] = value
    @domains[var] = [value]
  end
  
  def unassign(var)
    @assignments.delete(var)
  end
  
  def save_domains
    @domains.transform_values(&:dup)
  end
  
  def restore_domains(saved)
    @domains = saved
  end
  
  def propagate_constraints(var, value)
    # Problem-specific constraint propagation
    true
  end
  
  def consistent?(var, value)
    # Problem-specific consistency check
    true
  end
  
  def count_conflicts(var, value)
    # Problem-specific conflict counting
    0
  end
end

Practical Examples

N-Queens Problem

The N-Queens problem places N chess queens on an N×N chessboard such that no two queens attack each other. This classic backtracking problem demonstrates constraint checking and state management.

class NQueens
  def initialize(n)
    @n = n
    @solutions = []
  end
  
  def solve
    solve_recursive([], 0)
    @solutions
  end
  
  private
  
  def solve_recursive(queens, row)
    if row == @n
      @solutions << queens.dup
      return
    end
    
    @n.times do |col|
      if safe?(queens, row, col)
        queens << col
        solve_recursive(queens, row + 1)
        queens.pop
      end
    end
  end
  
  def safe?(queens, row, col)
    queens.each_with_index do |queen_col, queen_row|
      # Same column
      return false if queen_col == col
      
      # Diagonal
      return false if (row - queen_row).abs == (col - queen_col).abs
    end
    true
  end
end

solver = NQueens.new(4)
solutions = solver.solve
# => [[1, 3, 0, 2], [2, 0, 3, 1]]
# Two solutions: queens at columns [1,3,0,2] and [2,0,3,1] for rows 0-3

# Visualize first solution
def print_board(solution)
  n = solution.size
  solution.each do |col|
    row = Array.new(n, '.')
    row[col] = 'Q'
    puts row.join(' ')
  end
end

print_board(solutions.first)
# . Q . .
# . . . Q
# Q . . .
# . . Q .

Sudoku Solver

Sudoku solving demonstrates backtracking with multiple constraint types and cell selection strategies.

class SudokuSolver
  def initialize(board)
    @board = board.map(&:dup)
    @size = 9
    @box_size = 3
  end
  
  def solve
    return @board if solve_recursive
    nil
  end
  
  private
  
  def solve_recursive
    row, col = find_empty_cell
    return true unless row
    
    (1..@size).each do |num|
      if valid_placement?(row, col, num)
        @board[row][col] = num
        return true if solve_recursive
        @board[row][col] = 0
      end
    end
    
    false
  end
  
  def find_empty_cell
    # Most constrained cell first
    best_cell = nil
    min_options = @size + 1
    
    @size.times do |row|
      @size.times do |col|
        next if @board[row][col] != 0
        
        options = count_valid_options(row, col)
        if options < min_options
          min_options = options
          best_cell = [row, col]
          return best_cell if options == 1  # Only one option, use it
        end
      end
    end
    
    best_cell
  end
  
  def count_valid_options(row, col)
    (1..@size).count { |num| valid_placement?(row, col, num) }
  end
  
  def valid_placement?(row, col, num)
    # Check row
    return false if @board[row].include?(num)
    
    # Check column
    return false if @board.any? { |r| r[col] == num }
    
    # Check 3x3 box
    box_row = (row / @box_size) * @box_size
    box_col = (col / @box_size) * @box_size
    
    @box_size.times do |i|
      @box_size.times do |j|
        return false if @board[box_row + i][box_col + j] == num
      end
    end
    
    true
  end
end

# Example puzzle (0 represents empty cell)
puzzle = [
  [5, 3, 0, 0, 7, 0, 0, 0, 0],
  [6, 0, 0, 1, 9, 5, 0, 0, 0],
  [0, 9, 8, 0, 0, 0, 0, 6, 0],
  [8, 0, 0, 0, 6, 0, 0, 0, 3],
  [4, 0, 0, 8, 0, 3, 0, 0, 1],
  [7, 0, 0, 0, 2, 0, 0, 0, 6],
  [0, 6, 0, 0, 0, 0, 2, 8, 0],
  [0, 0, 0, 4, 1, 9, 0, 0, 5],
  [0, 0, 0, 0, 8, 0, 0, 7, 9]
]

solver = SudokuSolver.new(puzzle)
solution = solver.solve

Subset Sum Problem

Finding subsets of numbers that sum to a target value demonstrates backtracking with numeric constraints and optimization opportunities.

class SubsetSum
  def initialize(numbers, target)
    @numbers = numbers.sort.reverse  # Larger numbers first
    @target = target
    @solutions = []
  end
  
  def find_all
    backtrack([], 0, 0)
    @solutions
  end
  
  def find_one
    backtrack_first([], 0, 0)
  end
  
  private
  
  def backtrack(subset, sum, start_idx)
    if sum == @target
      @solutions << subset.dup
      return
    end
    
    return if sum > @target  # Prune: exceeded target
    
    start_idx.upto(@numbers.size - 1) do |i|
      num = @numbers[i]
      
      # Prune: remaining numbers too small
      remaining = @numbers[i..-1].sum
      break if sum + remaining < @target
      
      subset << num
      backtrack(subset, sum + num, i + 1)
      subset.pop
    end
  end
  
  def backtrack_first(subset, sum, start_idx)
    return subset.dup if sum == @target
    return nil if sum > @target
    
    start_idx.upto(@numbers.size - 1) do |i|
      num = @numbers[i]
      
      subset << num
      result = backtrack_first(subset, sum + num, i + 1)
      return result if result
      subset.pop
    end
    
    nil
  end
end

solver = SubsetSum.new([3, 34, 4, 12, 5, 2], 9)
solver.find_all
# => [[4, 5], [4, 3, 2], [7, 2]]

# With optimization: finding subset closest to target
class SubsetSumClosest < SubsetSum
  def find_closest
    @best = nil
    @best_diff = Float::INFINITY
    backtrack_closest([], 0, 0)
    @best
  end
  
  private
  
  def backtrack_closest(subset, sum, start_idx)
    diff = (@target - sum).abs
    if diff < @best_diff
      @best_diff = diff
      @best = subset.dup
    end
    
    return if sum >= @target  # Don't exceed target
    
    start_idx.upto(@numbers.size - 1) do |i|
      subset << @numbers[i]
      backtrack_closest(subset, sum + @numbers[i], i + 1)
      subset.pop
    end
  end
end

Word Break Problem

Determining if a string can be segmented into dictionary words demonstrates backtracking with memoization for overlapping subproblems.

class WordBreak
  def initialize(dictionary)
    @dictionary = Set.new(dictionary)
    @max_length = dictionary.map(&:length).max
  end
  
  def segment(text)
    backtrack(text, 0, [])
  end
  
  def all_segments(text)
    solutions = []
    backtrack_all(text, 0, [], solutions)
    solutions
  end
  
  private
  
  def backtrack(text, start, words)
    return words if start == text.length
    
    # Try all possible next words
    (start + 1).upto([start + @max_length, text.length].min) do |end_pos|
      word = text[start...end_pos]
      if @dictionary.include?(word)
        words << word
        result = backtrack(text, end_pos, words)
        return result if result
        words.pop
      end
    end
    
    nil
  end
  
  def backtrack_all(text, start, words, solutions)
    if start == text.length
      solutions << words.dup
      return
    end
    
    (start + 1).upto([start + @max_length, text.length].min) do |end_pos|
      word = text[start...end_pos]
      if @dictionary.include?(word)
        words << word
        backtrack_all(text, end_pos, words, solutions)
        words.pop
      end
    end
  end
end

dict = ['cat', 'cats', 'and', 'sand', 'dog']
solver = WordBreak.new(dict)

solver.segment('catsanddog')
# => ['cats', 'and', 'dog']

solver.all_segments('catsanddog')
# => [['cats', 'and', 'dog'], ['cat', 'sand', 'dog']]

Common Patterns

Choice Point Management

Managing decision points requires careful state handling. The choice point pattern encapsulates the state before making a decision, enabling rollback.

class ChoicePoint
  attr_reader :variable, :value, :state_snapshot
  
  def initialize(variable, value, state_snapshot)
    @variable = variable
    @value = value
    @state_snapshot = state_snapshot
  end
end

class StatefulBacktracker
  def initialize
    @choice_stack = []
    @state = {}
  end
  
  def make_choice(variable, value)
    snapshot = @state.dup
    choice = ChoicePoint.new(variable, value, snapshot)
    @choice_stack << choice
    @state[variable] = value
  end
  
  def undo_last_choice
    return nil if @choice_stack.empty?
    
    choice = @choice_stack.pop
    @state = choice.state_snapshot
    choice
  end
  
  def current_state
    @state.dup
  end
end

Pruning Strategies

Effective pruning eliminates branches early based on domain knowledge or heuristics.

module PruningStrategies
  # Bound-based pruning for optimization problems
  def prune_by_bound?(partial_solution, best_solution)
    return false unless best_solution
    
    lower_bound = calculate_lower_bound(partial_solution)
    lower_bound >= objective_value(best_solution)
  end
  
  # Feasibility pruning
  def prune_infeasible?(partial_solution)
    !satisfies_mandatory_constraints?(partial_solution)
  end
  
  # Dominance pruning
  def prune_dominated?(partial_solution, existing_solutions)
    existing_solutions.any? { |sol| dominates?(sol, partial_solution) }
  end
  
  # Symmetry pruning
  def prune_symmetric?(partial_solution, seen_canonical)
    canonical = canonicalize(partial_solution)
    seen_canonical.include?(canonical)
  end
end

class OptimizingBacktracker
  include PruningStrategies
  
  def find_optimal(initial_state)
    @best_solution = nil
    @best_value = Float::INFINITY
    
    backtrack_optimize(initial_state)
    @best_solution
  end
  
  private
  
  def backtrack_optimize(state)
    if complete?(state)
      value = objective_value(state)
      if value < @best_value
        @best_value = value
        @best_solution = state.dup
      end
      return
    end
    
    return if prune_by_bound?(state, @best_solution)
    return if prune_infeasible?(state)
    
    next_choices(state).each do |choice|
      if valid?(state, choice)
        apply(state, choice)
        backtrack_optimize(state)
        revert(state, choice)
      end
    end
  end
end

Variable Ordering Heuristics

The order in which variables are assigned significantly impacts performance. Common heuristics include most constrained variable and minimum remaining values.

class VariableOrderer
  # Most constrained variable: choose variable with smallest domain
  def most_constrained(variables, domains)
    variables.min_by { |var| domains[var].size }
  end
  
  # Most constraining variable: affects most other variables
  def most_constraining(variables, constraints)
    variables.max_by { |var| count_constraints(var, constraints) }
  end
  
  # Degree heuristic: most connections to unassigned variables
  def highest_degree(variables, graph, assigned)
    unassigned = variables - assigned.keys
    unassigned.max_by { |var| graph[var].count { |neighbor| !assigned.key?(neighbor) } }
  end
  
  # Combined heuristic: break ties
  def select_variable(variables, domains, constraints, assigned)
    unassigned = variables - assigned.keys
    
    # Primary: most constrained
    min_domain_size = unassigned.map { |v| domains[v].size }.min
    candidates = unassigned.select { |v| domains[v].size == min_domain_size }
    
    return candidates.first if candidates.size == 1
    
    # Secondary: most constraining
    candidates.max_by { |var| count_constraints(var, constraints) }
  end
  
  private
  
  def count_constraints(var, constraints)
    constraints.count { |c| c.involves?(var) }
  end
end

Value Ordering Heuristics

After selecting a variable, ordering its possible values affects the likelihood of finding solutions quickly.

class ValueOrderer
  # Least constraining value: leaves most options for other variables
  def least_constraining(variable, domain, state, constraints)
    domain.sort_by do |value|
      test_state = state.dup
      test_state[variable] = value
      count_eliminated_values(test_state, variable, constraints)
    end
  end
  
  # Prefer values that satisfy more constraints
  def most_satisfying(variable, domain, state, constraints)
    domain.sort_by do |value|
      test_state = state.dup
      test_state[variable] = value
      -count_satisfied_constraints(test_state, constraints)
    end
  end
  
  # Random ordering for randomized algorithms
  def randomized(domain)
    domain.shuffle
  end
  
  private
  
  def count_eliminated_values(state, variable, constraints)
    count = 0
    constraints.each do |constraint|
      next unless constraint.involves?(variable)
      
      constraint.affected_variables(variable).each do |other_var|
        next if state.key?(other_var)
        
        # Count how many values become invalid for other_var
        count += count_invalid_values(other_var, state, constraint)
      end
    end
    count
  end
  
  def count_satisfied_constraints(state, constraints)
    constraints.count { |c| c.satisfied?(state) }
  end
end

Constraint Recording

Recording conflicts and learned constraints speeds up backtracking by avoiding repeated failures.

class ConstraintRecorder
  def initialize
    @conflict_cache = {}
    @learned_constraints = []
  end
  
  def record_conflict(state, variable, value)
    key = conflict_key(state, variable)
    @conflict_cache[key] ||= Set.new
    @conflict_cache[key] << value
  end
  
  def is_known_conflict?(state, variable, value)
    key = conflict_key(state, variable)
    @conflict_cache[key]&.include?(value) || false
  end
  
  def learn_constraint(variables, forbidden_combination)
    @learned_constraints << {
      variables: variables,
      forbidden: forbidden_combination
    }
  end
  
  def violates_learned?(state)
    @learned_constraints.any? do |constraint|
      vars = constraint[:variables]
      next unless vars.all? { |v| state.key?(v) }
      
      combination = vars.map { |v| state[v] }
      combination == constraint[:forbidden]
    end
  end
  
  private
  
  def conflict_key(state, variable)
    # Create key from assigned variables, excluding current variable
    assigned = state.reject { |k, v| k == variable }
    assigned.sort.to_h.hash
  end
end

Performance Considerations

Backtracking performance varies dramatically based on problem size, constraint tightness, and implementation choices. Understanding these factors enables optimization for specific use cases.

Time Complexity Analysis

The worst-case time complexity of backtracking is exponential: O(b^d) where b is the branching factor (average number of choices per decision) and d is the maximum depth of the search tree. For N-Queens with N=8, the search tree has up to 8! = 40,320 leaf nodes, though constraint checking prunes most branches early.

Constraint propagation reduces the effective branching factor. In Sudoku, each cell has 9 possible values initially, but constraint propagation typically reduces this to 2-3 options per cell, dramatically shrinking the search space.

The best-case scenario finds a solution on the first path explored, achieving O(d) time complexity. Heuristics that order variables and values intelligently push performance toward best-case behavior.

# Measuring backtracking performance
class PerformanceTracker
  attr_reader :nodes_visited, :backtracks, :solutions_found
  
  def initialize
    reset
  end
  
  def reset
    @nodes_visited = 0
    @backtracks = 0
    @solutions_found = 0
    @start_time = nil
  end
  
  def start
    @start_time = Time.now
  end
  
  def visit_node
    @nodes_visited += 1
  end
  
  def backtrack
    @backtracks += 1
  end
  
  def found_solution
    @solutions_found += 1
  end
  
  def stats
    elapsed = Time.now - @start_time
    {
      nodes: @nodes_visited,
      backtracks: @backtracks,
      solutions: @solutions_found,
      time: elapsed,
      nodes_per_second: @nodes_visited / elapsed
    }
  end
end

Space Complexity

Recursive backtracking uses O(d) space for the call stack, where d is the maximum depth. Each stack frame stores the current state and remaining choices. For problems with large state representations, this becomes significant.

Iterative backtracking with explicit stacks has similar space requirements but provides control over stack size and state representation. Incremental state updates (adding/removing one element rather than copying entire states) reduce space overhead.

# Space-efficient state management
class IncrementalState
  def initialize
    @assignments = {}
    @history = []
  end
  
  def assign(var, value)
    old_value = @assignments[var]
    @assignments[var] = value
    @history << [var, old_value]
  end
  
  def backtrack
    return if @history.empty?
    
    var, old_value = @history.pop
    if old_value.nil?
      @assignments.delete(var)
    else
      @assignments[var] = old_value
    end
  end
  
  def [](var)
    @assignments[var]
  end
  
  def key?(var)
    @assignments.key?(var)
  end
  
  # No full state copies needed
  def memory_footprint
    @assignments.size + @history.size
  end
end

Pruning Effectiveness

Early pruning has multiplicative impact on performance. If pruning eliminates 90% of branches at each level, a depth-10 search tree shrinks from 10^10 nodes to 10^9 nodes, a 10x speedup.

Forward checking, which checks constraints on unassigned variables after each assignment, provides cheap but effective pruning. Arc consistency algorithms like AC-3 perform deeper constraint propagation at higher computational cost per node but with better pruning.

# Comparing pruning strategies
class PruningComparison
  def solve_no_pruning(problem)
    tracker = PerformanceTracker.new
    tracker.start
    
    result = backtrack_basic(problem, tracker)
    
    { result: result, stats: tracker.stats, strategy: 'none' }
  end
  
  def solve_with_forward_checking(problem)
    tracker = PerformanceTracker.new
    tracker.start
    
    result = backtrack_forward_check(problem, tracker)
    
    { result: result, stats: tracker.stats, strategy: 'forward_checking' }
  end
  
  def solve_with_arc_consistency(problem)
    tracker = PerformanceTracker.new
    tracker.start
    
    result = backtrack_arc_consistency(problem, tracker)
    
    { result: result, stats: tracker.stats, strategy: 'arc_consistency' }
  end
  
  def compare(problem)
    [
      solve_no_pruning(problem),
      solve_with_forward_checking(problem),
      solve_with_arc_consistency(problem)
    ]
  end
end

Caching and Memoization

Caching subproblem results avoids redundant computation when the same partial state appears multiple times. This works when subproblems overlap, converting exponential algorithms to polynomial in some cases.

class MemoizedBacktracker
  def initialize
    @cache = {}
  end
  
  def solve(state)
    key = cache_key(state)
    return @cache[key] if @cache.key?(key)
    
    result = if goal?(state)
      state
    else
      attempt_solve(state)
    end
    
    @cache[key] = result
    result
  end
  
  private
  
  def cache_key(state)
    # Create canonical representation for caching
    # Order-independent if problem has symmetries
    state.sort.to_h.hash
  end
  
  def attempt_solve(state)
    choices(state).each do |choice|
      if valid?(state, choice)
        new_state = apply(state, choice)
        result = solve(new_state)
        return result if result
      end
    end
    nil
  end
end

Parallelization Opportunities

Backtracking parallelizes naturally by exploring different branches concurrently. The first few levels of the search tree provide independent subproblems that threads or processes can solve simultaneously.

require 'concurrent-ruby'

class ParallelBacktracker
  def initialize(thread_count = 4)
    @thread_pool = Concurrent::FixedThreadPool.new(thread_count)
  end
  
  def solve(initial_state, depth_to_parallelize = 2)
    # Generate work items by expanding tree to specified depth
    work_items = generate_work_items(initial_state, depth_to_parallelize)
    
    # Solve each branch in parallel
    futures = work_items.map do |partial_state|
      Concurrent::Future.execute(executor: @thread_pool) do
        solve_sequential(partial_state)
      end
    end
    
    # Return first solution found
    futures.each do |future|
      result = future.value
      return result if result
    end
    
    nil
  ensure
    @thread_pool.shutdown
  end
  
  private
  
  def generate_work_items(state, depth)
    return [state] if depth == 0 || goal?(state)
    
    items = []
    choices(state).each do |choice|
      if valid?(state, choice)
        new_state = apply(state, choice)
        items.concat(generate_work_items(new_state, depth - 1))
      end
    end
    items
  end
  
  def solve_sequential(state)
    # Standard sequential backtracking for branch
    return state if goal?(state)
    
    choices(state).each do |choice|
      if valid?(state, choice)
        result = solve_sequential(apply(state, choice))
        return result if result
      end
    end
    nil
  end
end

Implementation Approaches

Recursive vs Iterative

Recursive implementations express backtracking naturally through the call stack but face stack depth limitations. Ruby's default stack size handles depths up to several thousand levels, sufficient for most problems.

Iterative implementations using explicit stacks avoid recursion limits and provide more control over search traversal. The stack stores tuples of (state, choices, index) rather than relying on call frames.

# Recursive approach
def solve_recursive(state)
  return state if complete?(state)
  
  next_choices(state).each do |choice|
    if valid?(state, choice)
      apply!(state, choice)
      result = solve_recursive(state)
      return result if result
      revert!(state, choice)
    end
  end
  
  nil
end

# Iterative equivalent
def solve_iterative(initial_state)
  stack = [[initial_state, next_choices(initial_state), 0]]
  
  while stack.any?
    state, choices, idx = stack.pop
    
    return state if complete?(state)
    
    if idx < choices.size
      choice = choices[idx]
      stack.push([state, choices, idx + 1])  # Save point for next choice
      
      if valid?(state, choice)
        new_state = apply(state, choice)
        stack.push([new_state, next_choices(new_state), 0])
      end
    end
  end
  
  nil
end

Depth-First vs Breadth-First Search

Standard backtracking uses depth-first search (DFS), exploring one path completely before trying alternatives. DFS uses minimal memory (O(depth)) and finds solutions quickly when they exist at depth.

Breadth-first search (BFS) explores all nodes at one depth before moving deeper, guaranteeing the shallowest solution but requiring O(branching_factor^depth) memory. BFS makes sense when solution depth varies widely and shallow solutions are preferable.

# DFS backtracking (standard)
def dfs_backtrack(state)
  return state if goal?(state)
  
  choices(state).each do |choice|
    if valid?(state, choice)
      result = dfs_backtrack(apply(state, choice))
      return result if result
    end
  end
  nil
end

# BFS adaptation
def bfs_backtrack(initial_state)
  queue = [initial_state]
  
  until queue.empty?
    state = queue.shift
    return state if goal?(state)
    
    choices(state).each do |choice|
      queue << apply(state, choice) if valid?(state, choice)
    end
  end
  
  nil
end

Iterative Deepening

Iterative deepening depth-first search (IDDFS) combines DFS memory efficiency with BFS optimality. The algorithm performs depth-limited DFS repeatedly with increasing depth limits.

class IterativeDeepeningBacktracker
  def solve(initial_state, max_depth = Float::INFINITY)
    depth = 0
    
    while depth <= max_depth
      result = depth_limited_search(initial_state, depth)
      return result if result
      depth += 1
    end
    
    nil
  end
  
  private
  
  def depth_limited_search(state, limit, depth = 0)
    return state if goal?(state)
    return nil if depth >= limit
    
    choices(state).each do |choice|
      if valid?(state, choice)
        result = depth_limited_search(
          apply(state, choice),
          limit,
          depth + 1
        )
        return result if result
      end
    end
    
    nil
  end
end

Constraint Satisfaction Problem Framework

Implementing backtracking as a generic CSP solver separates the algorithm from problem-specific logic, enabling reuse across different problems.

class CSP
  attr_reader :variables, :domains, :constraints
  
  def initialize(variables, domains, constraints)
    @variables = variables
    @domains = domains
    @constraints = constraints
  end
  
  def solve
    BacktrackingCSPSolver.new(self).solve
  end
end

class BacktrackingCSPSolver
  def initialize(csp)
    @csp = csp
    @assignment = {}
  end
  
  def solve
    backtrack
  end
  
  private
  
  def backtrack
    return @assignment if complete?
    
    var = select_unassigned_variable
    order_domain_values(var).each do |value|
      if consistent?(var, value)
        assign(var, value)
        result = backtrack
        return result if result
        unassign(var)
      end
    end
    
    nil
  end
  
  def complete?
    @assignment.size == @csp.variables.size
  end
  
  def select_unassigned_variable
    # MRV heuristic
    unassigned = @csp.variables - @assignment.keys
    unassigned.min_by { |v| @csp.domains[v].size }
  end
  
  def order_domain_values(var)
    @csp.domains[var]
  end
  
  def consistent?(var, value)
    @csp.constraints.all? do |constraint|
      constraint.satisfied?(@assignment.merge(var => value))
    end
  end
  
  def assign(var, value)
    @assignment[var] = value
  end
  
  def unassign(var)
    @assignment.delete(var)
  end
end

# Using the framework for N-Queens
class QueensConstraint
  def satisfied?(assignment)
    assignment.each do |row1, col1|
      assignment.each do |row2, col2|
        next if row1 == row2
        
        # Check column and diagonal conflicts
        return false if col1 == col2
        return false if (row1 - row2).abs == (col1 - col2).abs
      end
    end
    true
  end
end

# Solve 4-Queens using CSP framework
n = 4
variables = (0...n).to_a
domains = variables.map { |v| [v, (0...n).to_a] }.to_h
constraints = [QueensConstraint.new]

csp = CSP.new(variables, domains, constraints)
solution = csp.solve

Reference

Backtracking Algorithm Template

Component Description Implementation
Base Case Check if goal reached Return solution if complete
Choice Generation Generate possible next choices Based on current state and constraints
Validity Check Verify choice satisfies constraints Prune invalid branches early
State Update Apply choice to state Modify state incrementally
Recursive Call Explore consequences of choice Continue search from new state
Backtrack Undo choice if failed Restore previous state

Common Problem Types

Problem Type Characteristics Example Applications
Permutation Arrange elements in order Task scheduling, route planning
Combination Select subset of elements Subset sum, knapsack variants
Partition Divide elements into groups Graph coloring, bin packing
Assignment Map values to variables Sudoku, crossword puzzles
Path Finding Find sequence through graph Maze solving, puzzle navigation
Optimization Find best solution Traveling salesman, job scheduling

Pruning Techniques

Technique When to Apply Effectiveness
Constraint Checking Every choice High - mandatory for correctness
Bound Pruning Optimization problems High - eliminates suboptimal branches
Forward Checking After each assignment Medium - reduces future domain sizes
Arc Consistency CSPs with tight constraints High - strong pruning, higher cost
Symmetry Breaking Symmetric solution spaces High - eliminates duplicate work
Dominance Pruning Multiple solutions possible Medium - removes dominated candidates

Variable Ordering Heuristics

Heuristic Selection Criterion Use Case
Most Constrained Variable Smallest remaining domain General CSPs, early failure detection
Most Constraining Variable Highest degree in constraint graph Dense constraint networks
Least Constraining Value Preserves most options for others Finding any solution quickly
Random No particular order Randomized algorithms, Monte Carlo

Performance Characteristics

Aspect Best Case Average Case Worst Case
Time Complexity O(d) O(b^d/2) O(b^d)
Space Complexity O(d) O(d) O(d)
Solution Quality Optimal if found first Depends on ordering All solutions explored
Branching Factor Heavily pruned Moderate pruning No pruning

Note: d = depth, b = branching factor

State Management Operations

Operation Purpose Time Cost Space Cost
Copy State Full duplication O(n) O(n)
Incremental Update Add/remove one element O(1) O(1)
Snapshot Stack Save state for backtracking O(n) O(d*n)
History List Record changes only O(1) O(d)

Ruby-Specific Optimizations

Optimization Technique Benefit
Array Operations Use push/pop for stack Constant time operations
Set for Membership Use Set instead of Array include? O(1) vs O(n) lookup
Lazy Enumerators Generate solutions on demand Memory efficiency
Freeze Objects Freeze immutable state Prevent accidental mutation
Symbol Keys Use symbols for hash keys Faster comparisons
Block Passing Use blocks for callbacks Clean separation of concerns

Constraint Types

Constraint Definition Example
Unary Restricts single variable Cell value in 1..9 for Sudoku
Binary Relates two variables Two queens not on same diagonal
Global Involves multiple variables All different constraint
Hard Must be satisfied Physical constraints, rules
Soft Preferable but optional Optimization criteria

Termination Conditions

Condition Meaning Action
Goal Reached Valid solution found Return solution
All Choices Tried No valid choices remain Backtrack to previous level
Constraint Violated Current path infeasible Prune branch immediately
Bound Exceeded Cannot improve best solution Skip optimization branch
Depth Limit Maximum depth reached Stop this path (IDDFS)
Timeout Time budget exhausted Return best so far