Constraint Problems and Pruning
28 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Many problems come with rules: queens that must not attack, digits that must not repeat, a word that must not reuse a cell. The search stays the same, but you check the rule before every choice. A branch that breaks a rule is cut at once, and this is what makes backtracking practical.
Check before you choose
Pruning is skipping a branch of the search as soon as it cannot lead to a valid answer. The earlier the check runs, the more work it saves.
A valid bracket string never closes more than it opened. Test this before adding a bracket and bad paths die at once. Cutting a branch early like this is called pruning.
def brackets(n): result = [] def walk(path, opened, closed): if len(path) == 2 * n: result.append("".join(path)) if opened < n: path.append("(") walk(path, opened + 1, closed) path.pop() if closed < opened: path.append(")") walk(path, opened, closed + 1) path.pop() walk([], 0, 0) return resultExample 1 · Valid brackets for n = 3 and their count
def brackets(n): result = [] def walk(path, opened, closed): if len(path) == 2 * n: result.append("".join(path)) if opened < n: path.append("(") walk(path, opened + 1, closed) path.pop() if closed < opened: path.append(")") walk(path, opened, closed + 1) path.pop() walk([], 0, 0) return result
print(brackets(3))print([len(brackets(n)) for n in range(1, 6)])['((()))', '(()())', '(())()', '()(())', '()()()'] [1, 2, 5, 14, 42]
What this code does
- Five strings are valid for three pairs, and none of them is ever built in a wrong form.
- The counts 1, 2, 5, 14 and 42 grow fast, but far more slowly than 2 to the power 2n.
Example 2 · Brute force or pruned search
from itertools import product
def balanced(text): depth = 0 for ch in text: depth += 1 if ch == "(" else -1 if depth < 0: return False return depth == 0
def calls(n, opened=0, closed=0): if opened + closed == 2 * n: return 1 total = 1 if opened < n: total += calls(n, opened + 1, closed) if closed < opened: total += calls(n, opened, closed + 1) return total
strings = ["".join(p) for p in product("()", repeat=8)]print("brute force builds", len(strings), "strings,", sum(balanced(s) for s in strings), "are valid")print("pruned search makes", calls(4), "calls")brute force builds 256 strings, 14 are valid pruned search makes 64 calls
What this code does
- Brute force builds all 2 to the power 8 strings and checks each one at the end.
- The pruned search never extends a path that cannot be valid, so it makes far fewer calls.
- The gap widens quickly as n grows.
Two tests guard the choices: no more than n opening brackets, and a closing bracket only while one is still open. No wrong string is ever built.
N-Queens: one queen per row
Constraint is a rule every answer must obey, such as no two queens in one column or diagonal. The search checks it before each choice, so broken paths stop at once.
Place n queens on an n by n board so that none attacks another. Put one queen in each row. A set keeps the lines already used, and a square is safe if none of its three lines is taken.
def queens(n): taken = set() def place(row): if row == n: return 1 count = 0 for col in range(n): marks = {("c", col), ("a", row - col), ("b", row + col)} if marks & taken: continue taken.update(marks) # choose count += place(row + 1) taken.difference_update(marks) # undo return count return place(0)Example 3 · Four queens: all solutions
def solutions(n): taken, path, found = set(), [], []
def place(row): if row == n: found.append(path[:]) return for col in range(n): marks = {("c", col), ("a", row - col), ("b", row + col)} if marks & taken: continue taken.update(marks) path.append(col) place(row + 1) path.pop() taken.difference_update(marks)
place(0) return found
for cols in solutions(4): print(cols)first = solutions(4)[0]for col in first: print("." * col + "Q" + "." * (3 - col))[1, 3, 0, 2] [2, 0, 3, 1] .Q.. ...Q Q... ..Q.
What this code does
- Each solution lists the column of the queen in rows 0 to 3.
- Only two solutions exist, and they are mirror images of each other.
- The board is printed by repeating dots on either side of the Q.
Example 4 · Solutions as the board grows
def queens(n): taken = set() def place(row): if row == n: return 1 count = 0 for col in range(n): marks = {("c", col), ("a", row - col), ("b", row + col)} if marks & taken: continue taken.update(marks) # choose count += place(row + 1) taken.difference_update(marks) # undo return count return place(0)
for n in range(1, 9): print(n, queens(n))1 1 2 0 3 0 4 2 5 10 6 4 7 40 8 92
What this code does
- Some sizes have no solution at all: 2 and 3 print 0.
- Eight queens have 92 solutions, found in well under a second.
- The numbers do not grow in a smooth line, which is why you cannot guess them.
Example 5 · Pruning against brute force on six queens
from itertools import permutations
calls = [0]taken = set()
def place(row, n): calls[0] += 1 if row == n: return 1 count = 0 for col in range(n): marks = {("c", col), ("a", row - col), ("b", row + col)} if marks & taken: continue taken.update(marks) count += place(row + 1, n) taken.difference_update(marks) return count
def diagonals_ok(p): return len({r - c for r, c in enumerate(p)}) == 6 and len({r + c for r, c in enumerate(p)}) == 6
print("boards checked by brute force:", sum(1 for _ in permutations(range(6))))print("solutions:", sum(1 for p in permutations(range(6)) if diagonals_ok(p)))place(0, 6)print("calls made by the pruned search:", calls[0])boards checked by brute force: 720 solutions: 4 calls made by the pruned search: 153
What this code does
- Brute force already uses one queen per row and per column, then checks the diagonals of all 720 boards.
- The pruned search stops a branch the moment a queen is attacked, so it makes fewer calls and finds the same 4 solutions.
- Without the one-queen-per-row idea, brute force would have to try 6 to the power 6 boards.
A queen uses its column and two diagonals. Squares on one diagonal share row - col, and squares on the other share row + col. A set lookup makes each test take constant time.
Sudoku-style: fill the first empty cell
Dead end is a state where no choice left obeys the rules. The search returns to the previous level, undoes the choice made there and tries the next option.
A 4 by 4 puzzle uses the digits 1 to 4 once in every row, column and 2 by 2 box. Find the first empty cell, try each digit that fits and recurse. If none works, report failure.
def solve(grid): for r in range(4): for c in range(4): if grid[r][c] == 0: for d in range(1, 5): if fits(grid, r, c, d): grid[r][c] = d # choose if solve(grid): return True grid[r][c] = 0 # undo return False # no digit fits return True # no empty cell leftExample 6 · A 4 by 4 puzzle
def fits(grid, r, c, d): if d in grid[r] or d in [row[c] for row in grid]: return False br, bc = r // 2 * 2, c // 2 * 2 return all(grid[i][j] != d for i in range(br, br + 2) for j in range(bc, bc + 2))
def solve(grid): for r in range(4): for c in range(4): if grid[r][c] == 0: for d in range(1, 5): if fits(grid, r, c, d): grid[r][c] = d # choose if solve(grid): return True grid[r][c] = 0 # undo return False # no digit fits return True # no empty cell left
grid = [[2, 0, 0, 3], [0, 4, 1, 0], [1, 0, 0, 4], [0, 3, 2, 0]]if solve(grid): for row in grid: print("".join(str(d) for d in row))else: print("no solution")2143 3412 1234 4321
What this code does
- The search fills the first empty cell with a digit that fits and moves on; a dead end undoes the digit.
- The grid is changed in place, so after a successful solve it holds the answer.
- If a puzzle has no solution,
solvereturns False and the program prints a message instead.
fits checks the row, the column and the box. Setting the cell back to 0 is the undo, and return False tells the caller to change its own guess.
Word search on a grid
Does a word appear in a letter grid by moving up, down, left or right, using each cell once? Overwrite the cell with # while you are inside it, and put the letter back on the way out.
def exists(grid, word): rows, cols = len(grid), len(grid[0]) def walk(r, c, k): if grid[r][c] != word[k]: return False if k == len(word) - 1: return True saved, grid[r][c] = grid[r][c], "#" # choose found = any( 0 <= r + dr < rows and 0 <= c + dc < cols and walk(r + dr, c + dc, k + 1) for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1))) grid[r][c] = saved # undo return found return any(walk(r, c, 0) for r in range(rows) for c in range(cols))Example 7 · Words in a letter grid
def exists(grid, word): rows, cols = len(grid), len(grid[0]) def walk(r, c, k): if grid[r][c] != word[k]: return False if k == len(word) - 1: return True saved, grid[r][c] = grid[r][c], "#" # choose found = any( 0 <= r + dr < rows and 0 <= c + dc < cols and walk(r + dr, c + dc, k + 1) for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1))) grid[r][c] = saved # undo return found return any(walk(r, c, 0) for r in range(rows) for c in range(cols))
grid = ["CATS", "OREP", "DENT"]for word in ["CAT", "CORE", "DENT", "TEEN", "CATA"]: print(word, exists([list(row) for row in grid], word))CAT True CORE True DENT True TEEN False CATA False
What this code does
- Each word is searched on a fresh copy of the grid, with the rows turned into lists so cells can be changed.
- CATA is False because the A would have to be used twice.
- TEEN is False because no E touches the second E in the right order.
The # keeps a path from using one cell twice. The restore runs whether or not a path was found, so the grid is clean for the next start.
Stop at the first answer, or count them all
Sometimes one answer is enough. Return True when you find one and let or stop the rest. To count answers, add up what each child returns and keep searching. The return value must match the question.
def can_make(nums, i, left): if left == 0: return True if i == len(nums) or left < 0: return False return can_make(nums, i + 1, left - nums[i]) or can_make(nums, i + 1, left)Example 8 · One answer or a count
def can_make(nums, i, left): if left == 0: return True if i == len(nums) or left < 0: return False return can_make(nums, i + 1, left - nums[i]) or can_make(nums, i + 1, left)
def count_ways(nums, i, left): if left == 0: return 1 if i == len(nums) or left < 0: return 0 return count_ways(nums, i + 1, left - nums[i]) + count_ways(nums, i + 1, left)
print(can_make([2, 3, 5, 7], 0, 10))print(count_ways([2, 3, 5, 7], 0, 10))True 2
What this code does
can_makestops at the first success becauseorskips its right side.count_waysadds both branches, so it keeps searching after every success.- Two subsets reach 10: 3 + 7 and 2 + 3 + 5.
or does not run its right side once the left side is True. The test left < 0 prunes paths that already overshoot, as long as every number is positive.
Next step
Check your understanding
12 quick questions