Backtracking & Search/Constraint Search: N-Queens & Sudoku

Lesson 9.24,542 words

Constraint Search: N-Queens & Sudoku

Many hard puzzles are constraint satisfaction problems: assign each variable a value from its domain so that every constraint holds. Backtracking solves them by assigning variables one at a time and rejecting a partial assignment the instant a constraint breaks.

╌╌╌╌

The previous lesson built backtracking as a general tool: explore the tree of partial solutions depth-first, extend a partial solution one choice at a time, and abandon (backtrack) the moment the partial solution cannot possibly be completed. This lesson specializes that machinery to its main application, the constraint satisfaction problem (CSP), and asks the question that decides whether the search returns in milliseconds or never: how cheaply, and how early, can we detect that a partial assignment cannot be completed?

A CSP is three things:1

  • a set of variables ;
  • for each variable a domain of values it may take;
  • a set of constraints, each forbidding certain combinations of values on some subset of the variables.

A solution assigns every variable a value from its domain so that all constraints hold. Backtracking treats the variables as levels of a search tree: at level we try each value in , check the constraints that involve and the already-assigned variables, and recurse only if none is violated. The single most important design decision is to check constraints incrementally, the moment we place , not after a full assignment, so that a violated constraint prunes an entire subtree of would-be assignments at once. A cheap, early constraint check plus a smart variable ordering is what makes exponential search terminate.

N-Queens: the archetype

Place queens on an board so that no two attack each other. A queen attacks along its row, its column, and both diagonals. The first pruning insight is structural: since no two queens may share a row, place exactly one queen per row and let the variable be the column of the queen in row . That choice bakes the row constraint into the encoding: we never even consider two-in-a-row.

What remains is to check, when placing a queen at , that it shares no column and no diagonal with an earlier queen. The two diagonal families each carry a constant label.

So three boolean sets, one for occupied columns, one for occupied diagonals, one for occupied diagonals, give an conflict check and an update.2

conflict check via column & diagonal sets (, ); the conflict is highlighted

The shaded squares are exactly those attacked by the placed queen: its column, its row, and its two diagonals. The marked square (, in accent) lies on that queen's diagonal: it has the same value, so the lookup rejects it in and the column is pruned before we ever descend to the next row.

Algorithm:Queens(r)\textsc{Queens}(r) — place one queen per row using column \& diagonal sets
  1. 1
    if r=nr = n then
  2. 2
    record a solution; return
  3. 3
    for c0c \gets 0 to n1n-1 do
  4. 4
    if ccolsc \in cols or (r+c)diag+(r+c) \in diag_{+} or (rc)diag(r-c) \in diag_{-} then
  5. 5
    continue
    conflict — prune
  6. 6
    add cc to colscols; add r+cr+c to diag+diag_{+}; add rcr-c to diagdiag_{-}
  7. 7
    place[r]cplace[r] \gets c
  8. 8
    Queens(r+1)\textsc{Queens}(r+1)
  9. 9
    remove cc from colscols, r+cr+c from diag+diag_{+}, rcr-c from diagdiag_{-}
    undo

Each node does work over the columns with per column, and the recursion is deep. The number of solutions grows fast and irregularly ( for , for , for , for ) with no closed form; counting them is the problem known as N-Queens II. The board's symmetries (rotations and reflections form a group of order ) let a solver explore only a fundamental region and multiply, a standard constant-factor speedup. The asymptotic cost is still exponential in the worst case; the constraint sets buy a large constant factor and prune most of the tree, but they do not change the complexity class, and no fast exact algorithm is known.

For example, follow the sets through the winning line. Starting the search from column in row and taking the first surviving branch at each row builds the solution :

Row try col ???verdict
01nononoplace; , ,
10no yesreject (diag with row-0 queen)
13nononoplace; , ,
20no no noplace; , ,
32no no noplace; board full — solution

Every rejection is a single set-membership test; the queen at row , column is rejected because already sits in from the row- queen, which shares its diagonal. The whole search fits in one picture for . Each level commits the next row's queen to a column, and a clash with the column or a diagonal set prunes the branch immediately. Two of the four opening columns ( and ) die in conflicts before the board fills; the other two each lead, by a single surviving path, to one of the board's two solutions, and .

n_queens.pypython
def solve_n_queens(size: int) -> list[list[int]]:
  """
    Every placement of `size` non-attacking queens, each as a list whose\n
    r-th entry is the column of the queen in row r. The list is empty when\n
    no placement exists.\n
  """
  # accumulated solutions and the column-per-row board being built.
  solutions: list[list[int]] = []
  placement: list[int] = [-1 for _ in range(size)]

  # occupancy sets: columns, "/" diagonals (r + c), "\" diagonals (r - c).
  columns: set[int] = set()
  rising_diagonals: set[int] = set()
  falling_diagonals: set[int] = set()

  def place_row(row: int) -> None:

    # a full board is a solution.
    if row == size:
      solutions.append(placement.copy())
      return

    for column in range(size):

      # O(1) conflict check against the three occupancy sets.
      if (
        column in columns
        or (row + column) in rising_diagonals
        or (row - column) in falling_diagonals
      ):
        continue

      # place the queen and mark its column and both diagonals.
      columns.add(column)
      rising_diagonals.add(row + column)
      falling_diagonals.add(row - column)
      placement[row] = column

      place_row(row + 1)

      # undo the placement before trying the next column.
      columns.remove(column)
      rising_diagonals.remove(row + column)
      falling_diagonals.remove(row - column)
      placement[row] = -1

  place_row(0)
  return solutions

def count_n_queens(size: int) -> int:
  """
    The number of distinct non-attacking placements of `size` queens\n
    (the problem known as N-Queens II), counted without storing boards.\n
  """
  # the same three occupancy sets, with no board materialized.
  columns: set[int] = set()
  rising_diagonals: set[int] = set()
  falling_diagonals: set[int] = set()

  def count_from(row: int) -> int:

    # a full board contributes one placement.
    if row == size:
      return 1

    total: int = 0
    for column in range(size):

      # skip any column that conflicts on column or either diagonal.
      if (
        column in columns
        or (row + column) in rising_diagonals
        or (row - column) in falling_diagonals
      ):
        continue

      # mark, recurse to tally the subtree, then unmark.
      columns.add(column)
      rising_diagonals.add(row + column)
      falling_diagonals.add(row - column)
      total += count_from(row + 1)
      columns.remove(column)
      rising_diagonals.remove(row + column)
      falling_diagonals.remove(row - column)

    return total

  return count_from(0)

def is_valid_placement(placement: list[int]) -> bool:
  """
    Whether `placement` (a column per row) has no two queens attacking\n
    along a column or a diagonal. Used to verify candidate boards.\n
  """
  # every pair of rows must differ in column and avoid a shared diagonal.
  size: int = len(placement)
  for row in range(size):
    for other in range(row + 1, size):
      if placement[row] == placement[other]:
        return False
      if abs(placement[row] - placement[other]) == abs(row - other):
        return False
  return True
The complete search tree drawn as partial boards — each level places the next row's queen; a conflict prunes the branch (), and only two paths fill the board (in acc)

Sudoku: propagation and the most-constrained variable

A Sudoku is a CSP with variables (the cells), each with domain , and constraints that the nine cells of every row, column, and box are all distinct. Naive backtracking already works: pick an empty cell, try each digit consistent with its row, column, and box, recurse, and undo on failure. As with queens, keep a boolean set per row, per column, and per box so the consistency check and update are .

But naive ordering is slow; two ideas improve it:

  • Constraint propagation. Before branching, repeatedly fill every cell whose candidate set has collapsed to a single value (a naked single), and remove that value from its peers' candidate sets. One forced fill often triggers a cascade, solving easy puzzles with no search at all and shrinking the tree dramatically on hard ones.
  • Most-constrained-variable (MRV) heuristic. When you must branch, branch on the empty cell with the fewest remaining candidates. Branching on a cell with two options instead of nine cuts the fan-out where it matters, and it fails fast: a cell that has been narrowed to zero candidates is discovered immediately, pruning that branch at the top instead of after a deep fruitless descent. MRV is the single biggest practical speedup for Sudoku.
Propagation cascade: filling a naked single () strikes from a peer, collapsing it to a new naked single () — one forced fill triggers the next
MRV branches on the empty cell with the smallest candidate set (, in acc), minimizing fan-out and failing fast

Branching on the two-candidate cell forks the search only two ways instead of four or nine; and if propagation later narrows a cell to , MRV reaches it first and prunes that branch at the top.

Together these collapse a search that is hopeless under naive row-major ordering into one that finishes quickly on every newspaper puzzle. The algorithm (backtracking) is unchanged; the ordering and the propagation are what make it tractable.

sudoku_solver.pypython
from typing import Optional

Grid = list[list[int]]   # 9x9; 0 marks an empty cell

_DIGITS: set[int] = set(range(1, 10))

class _SudokuState:
  """
    Mutable occupancy of a Sudoku in progress: the grid plus the set of\n
    digits already used in each row, each column, and each 3x3 box, so\n
    candidate lookups and updates are O(1).\n
  """

  def __init__(self, grid: Grid) -> None:
    # own copy of the grid plus empty occupancy sets for each region.
    self.grid: Grid = [row.copy() for row in grid]
    self.rows: list[set[int]] = [set() for _ in range(9)]
    self.columns: list[set[int]] = [set() for _ in range(9)]
    self.boxes: list[set[int]] = [set() for _ in range(9)]

    # seed the occupancy sets from the givens already on the grid.
    for row in range(9):
      for column in range(9):
        digit = self.grid[row][column]
        if digit != 0:
          self.rows[row].add(digit)
          self.columns[column].add(digit)
          self.boxes[self._box_index(row, column)].add(digit)

  @staticmethod
  def _box_index(row: int, column: int) -> int:
    """
      The 0..8 index of the 3x3 box that owns cell (row, column).\n
    """
    return (row // 3) * 3 + (column // 3)

  def candidates(self, row: int, column: int) -> set[int]:
    """
      The digits that may still legally fill cell (row, column).\n
    """
    used = self.rows[row] | self.columns[column] | self.boxes[
      self._box_index(row, column)
    ]
    return set(_DIGITS) - used

  def place(self, row: int, column: int, digit: int) -> None:
    """
      Write `digit` into the cell and record it in all three sets.\n
    """
    self.grid[row][column] = digit
    self.rows[row].add(digit)
    self.columns[column].add(digit)
    self.boxes[self._box_index(row, column)].add(digit)

  def remove(self, row: int, column: int, digit: int) -> None:
    """
      Clear the cell and retract `digit` from all three sets.\n
    """
    self.grid[row][column] = 0
    self.rows[row].discard(digit)
    self.columns[column].discard(digit)
    self.boxes[self._box_index(row, column)].discard(digit)

def _propagate(state: _SudokuState) -> Optional[list[tuple[int, int, int]]]:
  """
    Repeatedly fill naked singles (cells with exactly one candidate).\n
    Returns the list of (row, column, digit) fills made so they can be\n
    undone on backtrack, or None if some empty cell ran out of candidates\n
    (a dead state).\n
  """
  # fills made this pass, replayed until a full sweep adds nothing.
  filled: list[tuple[int, int, int]] = []
  progressed: bool = True
  while progressed:
    progressed = False

    # scan every empty cell for its current candidates.
    for row in range(9):
      for column in range(9):
        if state.grid[row][column] != 0:
          continue
        options = state.candidates(row, column)

        # no candidate is a dead end: undo this pass's fills and bail.
        if len(options) == 0:
          for filled_row, filled_column, filled_digit in reversed(filled):
            state.remove(filled_row, filled_column, filled_digit)
          return None

        # a lone candidate is a naked single: place it and keep going.
        if len(options) == 1:
          only = next(iter(options))
          state.place(row, column, only)
          filled.append((row, column, only))
          progressed = True
  return filled

def _select_cell(state: _SudokuState) -> Optional[tuple[int, int]]:
  """
    The empty cell with the fewest candidates (the MRV heuristic), or\n
    None if the grid is full.\n
  """
  # track the emptiest cell seen; start above any real candidate count.
  best_cell: Optional[tuple[int, int]] = None
  best_count: int = 10

  for row in range(9):
    for column in range(9):
      if state.grid[row][column] != 0:
        continue

      # keep the smallest candidate count; a single candidate can't be beat.
      count = len(state.candidates(row, column))
      if count < best_count:
        best_count = count
        best_cell = (row, column)
        if best_count == 1:
          return best_cell

  return best_cell

def solve_sudoku(grid: Grid) -> Optional[Grid]:
  """
    A completed grid satisfying every row/column/box constraint, or None\n
    if `grid` has no solution. The input is not modified.\n
  """
  state = _SudokuState(grid)

  def search() -> bool:

    # propagate naked singles first; an empty domain kills this branch.
    fills = _propagate(state)
    if fills is None:
      return False

    # no empty cell left means the grid is solved.
    cell = _select_cell(state)
    if cell is None:
      return True

    # branch on the most-constrained cell, trying each candidate digit.
    row, column = cell
    for digit in sorted(state.candidates(row, column)):
      state.place(row, column, digit)
      if search():
        return True
      state.remove(row, column, digit)

    # undo this branch's propagation fills before backtracking.
    for filled_row, filled_column, filled_digit in reversed(fills):
      state.remove(filled_row, filled_column, filled_digit)
    return False

  if search():
    return state.grid
  return None

def is_valid_sudoku(grid: Grid) -> bool:
  """
    Whether a completed grid breaks no row, column, or box constraint.\n
  """
  # every row and every column must hold each of 1..9 exactly once.
  for row in range(9):
    if {grid[row][column] for column in range(9)} != _DIGITS:
      return False
  for column in range(9):
    if {grid[row][column] for row in range(9)} != _DIGITS:
      return False

  # every 3x3 box must likewise hold the full digit set.
  for box_row in range(0, 9, 3):
    for box_column in range(0, 9, 3):
      cells = {
        grid[box_row + offset_row][box_column + offset_column]
        for offset_row in range(3)
        for offset_column in range(3)
      }
      if cells != _DIGITS:
        return False
  return True

Graph -coloring: the same skeleton again

Given a graph and colors, assign a color to each vertex so that adjacent vertices differ. This is a CSP whose variables are vertices, whose domains are the colors, and whose constraints are one inequality per edge. Backtracking colors vertices in some order, trying each color not already used by an assigned neighbor and backtracking when a vertex has no legal color: the same queens/Sudoku skeleton with a different constraint. Deciding whether a -coloring exists is NP-complete, so we again expect exponential worst case. The same speedups (order vertices by degree, a form of MRV, and propagate forced colors) are what make real instances solvable.3

-coloring as a CSP: vertex sees neighbors using all three colors , so its domain is empty — no legal color, backtrack

General CSP speedups

The techniques above are instances of a small, reusable toolkit. They share one goal: discover failure as early and as cheaply as possible, so that the search never descends into a subtree that cannot contain a solution. Every one of them is a sound prune — it cuts only branches a violated constraint has already doomed — so the backtracking search remains complete: no satisfying assignment is ever pruned away.

Concretely, forward checking acts on the domains themselves: assigning a value strikes it from every neighbor's remaining choices, and a domain that empties is the signal to backtrack.

Forward checking after : is struck from each neighbor's domain; collapses to , so the branch is dead before descending

The figure below shows forward checking pruning a branch the instant a choice empties a neighbor's domain, long before a constraint check on a complete assignment would have caught it.

forward checking prunes dead branches before descending; the surviving path is in accent

Setting leaves a neighbor with an empty domain, so forward checking cuts that branch immediately (); only keeps every neighbor non-empty, and the search descends along the accented path.

csp_solver.pypython
from __future__ import annotations

from collections import deque
from typing import Callable, Generic, Hashable, Iterable, Optional, TypeVar

Variable = TypeVar("Variable", bound=Hashable)
Value = TypeVar("Value", bound=Hashable)

# A binary constraint judges whether a value-pair on two variables is allowed.
Constraint = Callable[[Value, Value], bool]

class CSP(Generic[Variable, Value]):
  """
    A binary constraint-satisfaction problem.\n
    Each variable has a domain (the values it may take); each unordered\n
    pair of variables may carry a constraint predicate that the chosen\n
    values must satisfy. Constraints are stored symmetrically so the solver\n
    can reason about either direction of an arc.\n
  """

  def __init__(self, domains: dict[Variable, Iterable[Value]]) -> None:
    # own each variable's domain as a set; start with no neighbors or arcs.
    self.domains: dict[Variable, set[Value]] = {
      variable: set(values) for variable, values in domains.items()
    }
    self.neighbors: dict[Variable, set[Variable]] = {
      variable: set() for variable in domains
    }
    self._constraints: dict[
      tuple[Variable, Variable], Constraint[Value]
    ] = {}

  @property
  def constraints(
    self,
  ) -> dict[tuple[Variable, Variable], Constraint[Value]]:
    """
      The recorded binary constraints, keyed by ordered variable pair.\n
      Read-only access for callers that need to inspect or iterate the\n
      problem's arcs without reaching into private state.\n
    """
    return self._constraints

  def add_constraint(
    self,
    first: Variable,
    second: Variable,
    allowed: Constraint[Value],
  ) -> None:
    """
      Require that any values `a` on `first` and `b` on `second` satisfy\n
      `allowed(a, b)`. The constraint is recorded for both directions.\n
    """
    # link the two variables and store the predicate for both directions.
    self.neighbors[first].add(second)
    self.neighbors[second].add(first)
    self._constraints[(first, second)] = allowed
    self._constraints[(second, first)] = lambda b, a: allowed(a, b)

  def consistent(
    self,
    first: Variable,
    first_value: Value,
    second: Variable,
    second_value: Value,
  ) -> bool:
    """
      Whether assigning the two values violates no constraint between the\n
      variables. Variables with no shared constraint are always consistent.\n
    """
    allowed = self._constraints.get((first, second))
    if allowed is None:
      return True
    return allowed(first_value, second_value)

  def shadow(
    self,
    domains: dict[Variable, set[Value]],
  ) -> CSP[Variable, Value]:
    """
      A view of this problem over the supplied `domains`, sharing its\n
      constraints and neighbor structure. Lets AC-3 prune working domains\n
      without touching the original problem.\n
    """
    # a fresh instance over the given domains, sharing structure with self.
    view: CSP[Variable, Value] = type(self).__new__(type(self))
    view.domains = domains
    view.neighbors = self.neighbors
    view._constraints = self._constraints
    return view

def ac3(problem: CSP[Variable, Value]) -> bool:
  """
    Enforce arc consistency over `problem`, shrinking domains in place\n
    until every arc is consistent. Returns False if some domain empties\n
    (the problem is unsolvable), otherwise True.\n
  """
  # seed the queue with every directed arc in the constraint graph.
  arcs: deque[tuple[Variable, Variable]] = deque(
    (first, second)
    for first in problem.neighbors
    for second in problem.neighbors[first]
  )

  while arcs:
    first, second = arcs.popleft()

    # an empty domain after revision means the problem is unsolvable.
    if _revise(problem, first, second):
      if len(problem.domains[first]) == 0:
        return False

      # first lost a value, so its other neighbors must be rechecked.
      for third in problem.neighbors[first]:
        if third != second:
          arcs.append((third, first))

  return True

def _revise(
  problem: CSP[Variable, Value],
  first: Variable,
  second: Variable,
) -> bool:
  """
    Drop every value of `first` that has no compatible value in `second`.\n
    Returns whether any value was removed.\n
  """
  # discard any value of first with no support among second's values.
  removed: bool = False
  for value in set(problem.domains[first]):
    if not any(
      problem.consistent(first, value, second, other)
      for other in problem.domains[second]
    ):
      problem.domains[first].discard(value)
      removed = True
  return removed

def solve_csp(
  problem: CSP[Variable, Value],
) -> Optional[dict[Variable, Value]]:
  """
    A full assignment satisfying every constraint, or None if none exists.\n
    Runs an AC-3 preprocessing pass, then backtracks with MRV variable\n
    selection and forward checking. The problem's domains are not mutated.\n
  """
  # copy domains so AC-3 and search prune a private working set.
  working: dict[Variable, set[Value]] = {
    variable: set(values) for variable, values in problem.domains.items()
  }

  # an AC-3 wipeout before search begins means there is no solution.
  if not ac3(problem.shadow(working)):
    return None

  assignment: dict[Variable, Value] = {}

  def select_variable() -> Variable:
    """
      The unassigned variable with the smallest domain (MRV), breaking\n
      ties by the most constraints on other unassigned variables (degree).\n
    """
    unassigned = [
      variable for variable in working if variable not in assignment
    ]
    return min(
      unassigned,
      key=lambda variable: (
        len(working[variable]),
        -sum(
          1
          for neighbor in problem.neighbors[variable]
          if neighbor not in assignment
        ),
      ),
    )

  def backtrack() -> bool:

    # a complete assignment is a solution.
    if len(assignment) == len(working):
      return True

    # branch on the MRV-selected variable, trying its values in repr order.
    variable = select_variable()
    for value in sorted(working[variable], key=repr):

      # forward checking: provisionally strike `value`'s conflicts from
      # every unassigned neighbor, recording removals so we can undo them.
      removed: dict[Variable, set[Value]] = {}
      dead: bool = False
      for neighbor in problem.neighbors[variable]:
        if neighbor in assignment:
          continue
        losing = {
          other
          for other in working[neighbor]
          if not problem.consistent(variable, value, neighbor, other)
        }
        if losing:
          working[neighbor] -= losing
          removed[neighbor] = losing
          if len(working[neighbor]) == 0:
            dead = True
            break

      # if no neighbor emptied, commit the value and recurse.
      if not dead:
        assignment[variable] = value
        if backtrack():
          return True
        del assignment[variable]

      # undo this value's forward-checking removals.
      for neighbor, losing in removed.items():
        working[neighbor] |= losing
    return False

  if backtrack():
    return assignment
  return None

Word Search and Palindrome Partitioning

The same constraint-pruned backtracking drives grid and string puzzles where the constraint is a property of the partial path rather than a global relation.

  • Word Search asks whether a word can be traced through a grid by moving to adjacent cells without reusing a cell. The variables are the successive characters; the domain at each step is the four neighbors; the constraints are matches the next letter and not already visited. Mark a cell visited before recursing and unmark it on backtrack (the canonical make-move/undo-move pair), and prune the instant a neighbor's letter mismatches.
  • Palindrome Partitioning cuts a string into substrings that are all palindromes. The choice at each position is where to make the next cut; the constraint is that the prefix you cut off is a palindrome, checked before you recurse on the rest. Rejecting a non-palindromic prefix prunes every partition that would have started with it: early constraint checking, exactly as in the CSPs above.

The CSP toolbox that industry actually runs

The heuristics above — forward checking, MRV, AC-3 — are the textbook core of a much larger, and heavily deployed, constraint-programming stack.

Backjumping and learning. Plain backtracking undoes one decision at a time, even when the real culprit is many levels up. Conflict-directed backjumping (Prosser, 1993) records which earlier assignments caused a dead end and leaps straight back to the deepest one, skipping the irrelevant levels between — the CSP cousin of the non-chronological backjumping that makes SAT solvers fast.4 Combined with no-good learning (remember the conflicting partial assignment so it is never retried), it can prune large parts of the tree.

Local search: min-conflicts. For huge, loosely constrained instances, a different strategy wins outright. Min-conflicts (Minton et al., 1992) abandons tree search entirely: start from a complete but invalid assignment, then repeatedly pick a conflicted variable and reassign it to the value that violates the fewest constraints. It solves the million-queens problem in seconds — far past anything systematic backtracking reaches — though, being a hill-climber, it is incomplete and can stall in local minima.5 The same min-conflicts/random-restart idea underlies WalkSAT for satisfiability.

Two ways to attack a CSP. Systematic backtracking (left) walks a tree of partial assignments, complete but exponential; local search / min-conflicts (right) hops between complete assignments toward zero conflicts — fast but incomplete.

Where it ships. These techniques underlie production constraint solvers — Google's OR-Tools CP-SAT, IBM CP Optimizer, MiniZinc back-ends — which schedule airline crews, route delivery fleets, lay out silicon, and timetable tournaments. The newspaper Sudoku of this lesson is a small special case of the same prune-early principle.

Takeaways

  • A constraint satisfaction problem is variables + domains + constraints; backtracking assigns variables one at a time, checks constraints incrementally, and abandons a partial assignment the moment a constraint breaks, pruning an entire subtree at once.
  • N-Queens places one queen per row and tests safety with three boolean sets (columns, diagonals, diagonals) for an conflict check; solution counts grow fast and irregularly with no closed form.
  • Sudoku combines row/column/box consistency with constraint propagation (fill forced cells, narrow candidates) and the MRV heuristic (branch on the cell with the fewest candidates), the big practical speedup.
  • Graph -coloring is the same skeleton: one inequality constraint per edge, solved by the same ordering-and-propagation toolkit.
  • Forward checking, MRV + least-constraining-value ordering, and arc consistency / AC-3 all serve one end, detecting failure as early and as cheaply as possible, which is what lets exponential search finish.
  • Word Search and Palindrome Partitioning are grid/string backtracking with path constraints (visited cells; palindromic prefixes), pruned by the same early-check principle.

Footnotes

  1. Erickson, Ch. — Backtracking: CSPs as variables/domains/constraints solved by recursive, incrementally-checked assignment.
  2. Skiena, § — Combinatorial Search: N-Queens with column and diagonal occupancy sets for pruning, and pruning as the core of practical backtracking.
  3. Skiena, § — Combinatorial Search: graph coloring as a CSP and the role of vertex ordering and propagation.
  4. Prosser, P. (1993), Hybrid algorithms for the constraint satisfaction problem, Computational Intelligence 9(3), 268–299 — conflict-directed backjumping and its combination with forward checking.
  5. Minton, S., Johnston, M. D., Philips, A. B. & Laird, P. (1992), Minimizing conflicts: a heuristic repair method for constraint satisfaction and scheduling problems, Artificial Intelligence 58(1–3), 161–205 — the min-conflicts local-search heuristic solving million-queens instances.
Practice

╌╌ END ╌╌