4-Queens and 8-Queens: Backtracking and Constraint Search
Place N queens on an N×N board so none attack each other. The full 4-queens backtracking trace, why the naive space is 16 million and the smart one is 40,320, plus hill climbing and min-conflicts.
Place queens on an chessboard so that no two attack each other. A queen attacks along its row, its column, and both diagonals.
This is the first problem on these pages where the goal is not a path. You don’t care how you got to a valid arrangement — only that you have one. That changes everything about how you search, and it introduces a family of techniques (constraint satisfaction) that runs half of modern scheduling and verification software.
The problem
The representation that shrinks the problem by 400×
Start with the obvious encoding: an grid of booleans, choose 8 squares. That gives
placements. Four billion. But almost all of them are trivially invalid, and you can eliminate whole classes of them by choosing a better representation rather than by searching harder.
Observation 1: exactly one queen per column. If two queens shared a column
they’d attack; and with queens on columns, the pigeonhole principle says
each column gets exactly one. So represent the board as an array where
board[col] = row:
board = [1, 3, 0, 2] # 4-queens: col 0 → row 1, col 1 → row 3, ...
Space drops to .
Observation 2: exactly one queen per row, too. Same argument. So the array must be a permutation of :
Space drops to .
This is the most transferable idea on the page. Before you optimise a search, ask whether the representation is throwing away work. Encoding a constraint into the data structure eliminates violations for free, at zero runtime cost. Searching harder can never beat not having to search.
With the permutation encoding, only diagonals remain to be checked. Two queens at columns and rows share a diagonal exactly when
def safe(board, col, row):
"""Can we put a queen at (col, row) given queens already in 0..col-1?"""
for c in range(col):
r = board[c]
if r == row or abs(col - c) == abs(row - r):
return False
return True
Backtracking, traced on 4-queens
Backtracking is depth-first search with one addition: check constraints as you go, and abandon a branch the moment it’s violated. Don’t build a complete board and then test it — test after every single placement.
The 4-queens board has permutations and exactly 2 solutions. Here’s the search finding the first one.
Notice step 3. With queens at (col 0, row 0) and (col 1, row 2), column 2 has no safe square: row 0 shares a row with the first queen, row 1 is on a diagonal from it, row 2 shares a row with the second, and row 3 is on a diagonal from the second. All four ruled out. The search doesn’t need to place a fourth queen to discover that this branch is doomed — it can tell after two placements.
That early detection is the entire value of backtracking. Generate-and-test would build all 24 permutations and test each; backtracking on 4-queens examines just 8 partial boards.
The backtracking solver
def solve_n_queens(n, all_solutions=True):
"""board[col] = row. Returns a list of solutions."""
solutions = []
board = [-1] * n
def safe(col, row):
for c in range(col):
r = board[c]
if r == row or abs(col - c) == abs(row - r):
return False
return True
def place(col):
if col == n:
solutions.append(board.copy())
return not all_solutions # stop early if we only want one
for row in range(n):
if safe(col, row):
board[col] = row
if place(col + 1):
return True
board[col] = -1 # undo — this is the backtrack
return False
place(0)
return solutions
def render(board):
n = len(board)
for r in range(n):
print(' '.join('Q' if board[c] == r else '.' for c in range(n)))
if __name__ == '__main__':
for n in (4, 6, 8):
sols = solve_n_queens(n)
print(f'{n}-queens: {len(sols)} solutions')
print()
render(solve_n_queens(8)[0])
The faster version: O(1) safety checks
The safe function above is per check. You can make it by
tracking which rows and diagonals are already occupied in three sets.
The trick is the diagonal identity: on a \-diagonal, row - col is
constant; on a /-diagonal, row + col is constant.
def solve_fast(n):
"""Same result, O(1) constraint checks via three occupancy sets."""
solutions, board = [], []
rows, diag1, diag2 = set(), set(), set()
def place(col):
if col == n:
solutions.append(board.copy())
return
for row in range(n):
if row in rows or (row - col) in diag1 or (row + col) in diag2:
continue
board.append(row)
rows.add(row); diag1.add(row - col); diag2.add(row + col)
place(col + 1)
board.pop()
rows.remove(row); diag1.discard(row - col); diag2.discard(row + col)
place(0)
return solutions
The three lines after the recursive call are the backtrack. Every state change
you make going down must be exactly undone coming back up. A forgotten
discard is the classic N-queens bug: the solver works for , silently
misses solutions for , and you spend an evening finding it.
How many solutions are there?
| Solutions | Unique up to symmetry | Nodes explored | |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 0 | 0 | 3 |
| 3 | 0 | 0 | 6 |
| 4 | 2 | 1 | 17 |
| 5 | 10 | 2 | 54 |
| 6 | 4 | 1 | 153 |
| 7 | 40 | 6 | 552 |
| 8 | 92 | 12 | 2,057 |
| 10 | 724 | 92 | 35,539 |
| 12 | 14,200 | 1,787 | 856,189 |
and have no solutions at all — the board is too cramped for the diagonals to avoid each other. Every has at least one.
The “unique up to symmetry” column divides out the 8 symmetries of a square (4 rotations × 2 reflections). The 92 solutions for 8-queens are really 12 distinct patterns; one of them is symmetric under 180° rotation, so it only generates 4 copies instead of 8, which is why .
Note that the solution count grows but the node count grows faster — this is still exponential. Backtracking makes 8-queens instant and 20-queens hard.
The other approach: local search
Backtracking builds a solution incrementally. Local search does the opposite: start with a complete but broken board, and repair it.
Hill climbing with the min-conflicts heuristic
- Place one queen in every column, at random.
- Count conflicts — pairs of queens attacking each other. That’s your heuristic ; the goal is .
- Pick a queen that’s in conflict. Move it, within its column, to the row that minimises total conflicts.
- Repeat.
import random
def min_conflicts(n, max_steps=100_000):
"""Local search. Solves n = 1,000,000 in seconds; backtracking cannot."""
board = list(range(n))
random.shuffle(board)
def conflicts(col, row):
return sum(1 for c in range(n)
if c != col and (board[c] == row or abs(c - col) == abs(board[c] - row)))
for _ in range(max_steps):
clashing = [c for c in range(n) if conflicts(c, board[c]) > 0]
if not clashing:
return board # h = 0 — solved
col = random.choice(clashing)
counts = [conflicts(col, r) for r in range(n)]
best = min(counts)
board[col] = random.choice([r for r in range(n) if counts[r] == best])
return None
The scaling difference is enormous:
| Backtracking | Min-conflicts | |
|---|---|---|
| 8 | instant | instant |
| 30 | ~seconds | instant |
| 100 | impractical | instant |
| 1,000,000 | impossible | ~50 moves |
Min-conflicts solves million-queens in about 50 moves on average, almost independent of . But it has no completeness guarantee: on a problem with no solution it never terminates, and on a hard one it can stall in a local minimum where every single move makes things worse. That’s the trade — backtracking is complete and slow; local search is incomplete and fast.
Pick by what you need. If a solution definitely exists and you just want one, local search wins. If you need all solutions, or proof that none exists, you need backtracking.
N-queens as a constraint satisfaction problem
The general framing, which is where this technique becomes reusable:
- Variables — , one per column
- Domains — each (which row)
- Constraints — for all : and
Once written this way, generic CSP machinery applies:
| Technique | What it does |
|---|---|
| Forward checking | After each assignment, remove now-illegal values from the remaining domains. If any empties, backtrack immediately. |
| Arc consistency (AC-3) | Propagate constraints until every value has a consistent partner. Prunes far more than forward checking. |
| MRV heuristic | Assign the variable with the fewest remaining values first — fail fast. |
| LCV heuristic | Try the value that rules out the fewest options for other variables. |
| Conflict-directed backjumping | On failure, jump back to the variable that actually caused it, not just the previous one. |
Forward checking alone roughly halves the nodes explored on 8-queens. MRV plus forward checking makes practical for exact enumeration.
Why this problem matters
N-queens itself is a puzzle. Constraint satisfaction is an industry.
- Exam and shift timetabling — variables are events, domains are time slots, constraints are “no room double-booked”, “no student in two exams at once”. Literally the same solver.
- Sudoku — 81 variables, domains 1–9, constraints on rows, columns and boxes. Arc consistency alone solves most newspaper puzzles without search.
- Register allocation — variables are program values, domains are CPU registers, constraints are “two values live at the same time need different registers”. This is graph colouring, a close cousin.
- Hardware verification — SAT solvers (the industrial descendants of backtracking + constraint propagation) check billion-gate chip designs.
- Frequency assignment — cell towers whose ranges overlap need different channels.
The two lessons worth carrying away:
- Representation beats optimisation. Going from to was a 110,000× win, and it came from thinking, not from code.
- Complete and incomplete search solve different problems. Backtracking proves things; local search finds things. Know which one you need before you start writing.
Where to go next
- Problem Solving with AI: The State-Space Framework — the shared vocabulary
- Travelling Salesman Problem — the other combinatorial explosion
- The 8-Puzzle — path search with heuristics
- Block World — when goals interfere with each other