Backtracking (Quay lui)Backtracking
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Backtracking như một DFS trên cây quyết định
- Template tổng quát: choose / explore / un-choose
- Backtracking so với brute force và DFS
- Khái niệm chính
- Subset — tập lũy thừa
- Hoán vị
- N-queens — cắt tỉa làm đổi cả số mũ
- Sudoku — backtracking cộng constraint propagation
- Word search trên lưới — backtracking trên graph ngầm định
- Phân tích độ phức tạp của backtracking
- Cắt tỉa và constraint propagation
- Quan hệ với DFS, brute force và quy hoạch động
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- Backtracking as DFS over a decision tree
- The general template: choose / explore / un-choose
- Backtracking versus brute force versus DFS
- Key Concepts
- Subsets — the power set
- Permutations
- N-queens — pruning that changes the exponent
- Sudoku — backtracking plus constraint propagation
- Word search on a grid — backtracking on an implicit graph
- Complexity analysis of backtracking
- Pruning and constraint propagation
- Relationship to DFS, brute force, and dynamic programming
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Backtracking là kỹ thuật dành cho những bài toán mà lời giải là một chuỗi các quyết định phải cùng lúc thỏa mãn một số ràng buộc. Bạn dựng lời giải từng quyết định một; khi nào lời giải dở dang không còn khả năng hoàn thiện thành lời giải hợp lệ, bạn hoàn tác quyết định gần nhất và thử phương án kế tiếp. Chính thao tác hoàn tác đó — “quay lui” — đặt tên cho kỹ thuật này.
Về mặt cấu trúc, backtracking là một depth-first search trên cây quyết định ngầm định. Cây không bao giờ được dựng thật trong memory; nó chỉ tồn tại dưới dạng chuỗi các lời gọi đệ quy. Mỗi node là một lời giải dở dang, mỗi cạnh là một quyết định, và mỗi lá hoặc là một lời giải hoàn chỉnh hoặc là một ngõ cụt. Thứ tách backtracking khỏi brute force thuần túy chính là chữ ngầm định: brute force liệt kê cả kⁿ lá, còn backtracking vứt bỏ nguyên một subtree ngay khoảnh khắc biết gốc của nó bất khả thi. Với bài 8 quân hậu, brute force trên mọi cách đặt 8 quân hậu lên 64 ô là 4,4 tỷ ứng viên; backtracking với ràng buộc hàng/cột/đường chéo thăm khoảng 2 000 node.
Việc cắt tỉa đó là toàn bộ giá trị của kỹ thuật, và cũng là giới hạn trung thực của nó. Backtracking vẫn là hàm mũ ở worst case — nếu các ràng buộc không bao giờ kích hoạt, bạn vẫn phải duyệt cả cây. Nó là kỹ thuật đúng khi các ràng buộc đủ mạnh để cắt phần lớn không gian từ sớm, và là kỹ thuật sai khi chúng không đủ mạnh (khi đó bạn cần quy hoạch động, nếu các subproblem chồng lấn, hoặc một thuật toán xấp xỉ, nếu không).
Phần thưởng của việc học nó là cả một họ bài toán rất lớn thu gọn về một template mười dòng. Subset, hoán vị, tổ hợp, N-queens, Sudoku, tô màu đồ thị, word search, điền ô chữ, regex matching, giải SAT, và constraint programming đều là cùng một đoạn code với một predicate is_valid khác nhau.
Kiến thức nền tảng
Backtracking như một DFS trên cây quyết định
Xét việc sinh mọi subset của [1, 2, 3]. Quyết định ở mỗi mức là “tôi có lấy phần tử i không?” — hai nhánh mỗi mức, ba mức, 2³ = 8 lá:
[] <- mức 0: quyết định về 1
/ \
lấy 1 bỏ 1
/ \
[1] [] <- mức 1: quyết định về 2
/ \ / \
[1,2] [1] [2] [] <- mức 2: quyết định về 3
/ \ / \ / \ / \
[1,2,3] [1,2] [1,3] [1] [2,3] [2] [3] [] <- 8 lá = 8 subset
Đệ quy duyệt cây này theo chiều sâu. path là tiền tố từ gốc tới node hiện tại, và nó được biến đổi trên đường đi xuống rồi khôi phục trên đường đi lên — đó chính xác là lý do cùng một list object có thể phục vụ mọi node mà không cần copy ở mỗi bước.
Mô hình tư duy then chốt: call stack chứa đường đi từ gốc tới node hiện tại. Ở độ sâu d có đúng d frame đang sống, mỗi frame nhớ nó đang khám phá nhánh nào. Việc hoàn tác trên đường đi lên khôi phục trạng thái sao cho nhánh anh em nhìn thấy đúng thế giới mà nhánh hiện tại đã thấy khi bắt đầu. Sai ở khâu khôi phục đó — quên một dòng undo — và bug sẽ biểu hiện thành các phần tử lạ xuất hiện trong những kết quả không liên quan, cực kỳ khó debug.
Template tổng quát: choose / explore / un-choose
Mọi lời giải backtracking đều có hình dạng này:
def backtrack(state, path, results):
if is_solution(state, path):
results.append(path[:]) # COPY — path vẫn tiếp tục thay đổi sau dòng này
return # (hoặc không return, nếu lời giải dài hơn cũng tính)
for choice in candidates(state, path):
if not is_valid(state, path, choice):
continue # PRUNE — bỏ qua nguyên subtree này
make(state, path, choice) # 1. CHOOSE — áp dụng quyết định
backtrack(state, path, results) # 2. EXPLORE — đệ quy xuống sâu một mức
undo(state, path, choice) # 3. UN-CHOOSE — khôi phục đúng những gì CHOOSE đã đổi
Năm quyết định định nghĩa nên mọi thể hiện cụ thể:
| Vị trí | Câu hỏi nó trả lời | Ví dụ (N-queens) |
|---|---|---|
is_solution | Khi nào path hoàn chỉnh? | Cả n hàng đều có quân hậu |
candidates | Các phương án tại node này là gì? | Mọi cột trong hàng hiện tại |
is_valid | Phương án nào cắt được ngay lập tức? | Cột/đường chéo chưa bị tấn công |
make / undo | State nào thay đổi, và hoàn tác thế nào? | Thêm/bớt cột và hai đường chéo |
| Xử lý kết quả | Thu một lời giải, tất cả, hay tốt nhất? | Cả 92 lời giải với n = 8 |
Ba quy tắc ngăn được gần như mọi bug backtracking:
undophải là nghịch đảo chính xác củamake. Mỗi phép thay đổi trongmakecần một dòng trongundo. Nếumakethêm vào ba set,undophải xóa khỏi ba set. Viết hai đoạn đó cạnh nhau và đọc chúng như một cặp là kỷ luật cần có.- Copy path khi ghi lại một lời giải.
results.append(path)chỉ thêm một tham chiếu; list vẫn tiếp tục biến đổi và bạn kết thúc vớinlist rỗng giống hệt nhau.path[:](hoặclist(path), hoặctuple(path)) là bắt buộc. - Cắt tỉa trước khi đệ quy, không phải sau. Kiểm tra tính hợp lệ ở đầu lời gọi con cũng chạy được, nhưng kiểm tra trong vòng lặp của node cha tránh được một lời gọi hàm cho mỗi nhánh chết — và quan trọng hơn, đó là chỗ mà một luật cắt tỉa đặc thù của bài toán thuộc về một cách tự nhiên.
Backtracking so với brute force và DFS
| Brute force | Backtracking | DFS trên graph | |
|---|---|---|---|
| Không gian tìm kiếm | Liệt kê đầy đủ | Liệt kê có cắt subtree | Cho sẵn dưới dạng node và cạnh |
| Cây/graph | Ngầm định, thăm mọi lá | Ngầm định, dựng trong lúc đệ quy | Cấu trúc dữ liệu tường minh |
Cần tập visited không | Không | Không — cây không có cycle | Có — graph có cycle |
| Hoàn tác trên đường lên | Không áp dụng | Có, thiết yếu | Thường không (visited là vĩnh viễn) |
| Chi phí điển hình | Θ(kⁿ) | Θ(kⁿ) worst case, thực tế ít hơn nhiều | Θ(V + E) |
Điểm khác biệt về tập visited đáng dừng lại, vì đây là nhầm lẫn khái niệm phổ biến nhất. Trong DFS trên graph bạn đánh dấu một node là đã thăm vĩnh viễn — bạn không bao giờ muốn vào lại nó, từ bất kỳ đường nào. Trong backtracking bạn đánh dấu state là đã dùng rồi bỏ đánh dấu trên đường quay lại, vì một tiền tố khác có thể cần dùng nó một cách hợp lệ. Ví dụ word search bên dưới thể hiện cả hai hành vi trong một hàm: một ô được đánh dấu khi nó nằm trên path hiện tại (để path không tự cắt chính nó) và bỏ đánh dấu sau đó (để một path khác có thể dùng nó).
Khái niệm chính
Subset — tập lũy thừa
def subsets(nums):
"""Toàn bộ 2^n subset. Theta(n · 2^n) time (2^n subset, O(n) để copy mỗi cái),
Theta(n) bộ nhớ phụ cho đệ quy + path."""
results, path = [], []
def backtrack(start):
results.append(path[:]) # MỌI node đều là một subset hợp lệ, không chỉ lá
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1) # explore — i+1 cấm dùng lại và ép thứ tự tăng
path.pop() # un-choose
backtrack(0)
return results
print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Tham số start là toàn bộ mẹo cho các bài toán kiểu tổ hợp: bằng cách chỉ xét index ≥ start, mỗi subset được sinh đúng một lần theo thứ tự index tăng dần, nên [1, 3] và [3, 1] không bao giờ cùng xuất hiện. Bỏ nó đi thì sẽ sinh ra cỡ n! thứ tự thay vì vậy.
Xử lý phần tử trùng lặp cần thêm một dòng. Sort trước, rồi bỏ qua ứng viên bằng phần tử liền trước ở cùng một mức cây:
def subsets_with_dups(nums):
"""Các subset phân biệt khi nums có thể trùng. Vẫn Theta(n · 2^n) ở worst case."""
results, path = [], []
nums = sorted(nums) # các phần tử trùng phải kề nhau thì phép skip mới đúng
def backtrack(start):
results.append(path[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue # giá trị này đã thử ở CHÍNH mức này -> cắt
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return results
print(subsets_with_dups([1, 2, 2])) # [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
i > start là điều kiện chịu lực: nó bỏ qua các phần tử trùng nhau giữa các node anh em (vốn sinh ra subtree giống hệt nhau) trong khi vẫn cho phép một giá trị trùng được dùng sâu hơn trong path ([2, 2] là subset hợp lệ). Đây là ví dụ thật đầu tiên về cắt tỉa theo đối xứng — hai nhánh sinh ra cùng một subtree, nên chỉ cần khám phá một.
Hoán vị
def permutations(nums):
"""Toàn bộ n! thứ tự. Theta(n · n!) time, Theta(n) bộ nhớ phụ."""
results, path = [], []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums): # is_solution: mọi phần tử đã được đặt
results.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue # cắt: đã nằm trong path hiện tại
used[i] = True # choose
path.append(nums[i])
backtrack() # explore
path.pop() # un-choose (cả hai nửa!)
used[i] = False
backtrack()
return results
print(len(permutations([1, 2, 3, 4]))) # 24
Chú ý khác biệt so với subsets: hoán vị quét toàn bộ index mỗi lần (thứ tự có ý nghĩa, nên cả [1, 2] lẫn [2, 1] đều cần) và dùng mảng used thay cho index start. Đó là sự phân biệt tổng quát — start cho tổ hợp, used cho hoán vị.
Cận Θ(n · n!) là chặt và tàn nhẫn: n = 11 đã là 40 triệu hoán vị, n = 13 là 6 tỷ. Bất kỳ bài toán nào mà cách phát biểu duy nhất là “thử mọi hoán vị” đều không giải nổi khi vượt khoảng n = 12, đó là lý do bài toán người bán hàng cần hoặc DP (Θ(2ⁿ · n²) Held–Karp, dùng được tới n ≈ 20) hoặc một heuristic.
N-queens — cắt tỉa làm đổi cả số mũ
Đặt n quân hậu lên bàn cờ n × n sao cho không quân nào tấn công quân nào. Không gian ứng viên ngây thơ — chọn n ô trong n² ô — là C(64, 8) ≈ 4,4 × 10⁹ với n = 8. Hai nhận xét thu nhỏ nó cực kỳ mạnh:
- Đúng một quân hậu mỗi hàng. Vậy quyết định ở độ sâu
rchỉ là “cột nào trong hàngr”, giảm không gian xuốngnⁿ, và kèm ràng buộc cột thì cònn!= 40 320. - Đường chéo có một đại lượng index bất biến. Với đường chéo
\,row − collà hằng số; với đường chéo/,row + collà hằng số. Nên kiểm tra xung đột đường chéo chỉ là một lần tra setO(1)thay vì quét ngượcO(n)qua bàn cờ.
row - col (các đường chéo "\") row + col (các đường chéo "/")
c0 c1 c2 c3 c0 c1 c2 c3
r0 [ 0 -1 -2 -3 ] r0 [ 0 1 2 3 ]
r1 [ 1 0 -1 -2 ] r1 [ 1 2 3 4 ]
r2 [ 2 1 0 -1 ] r2 [ 2 3 4 5 ]
r3 [ 3 2 1 0 ] r3 [ 3 4 5 6 ]
mỗi đường chéo "\" chung một giá trị mỗi đường chéo "/" chung một giá trị
def solve_n_queens(n):
"""Mọi lời giải bài n quân hậu. Ba set O(1) khiến mỗi phép kiểm tra là hằng số."""
solutions = []
placement = [] # placement[r] = cột của quân hậu ở hàng r
cols = set() # các cột đã bị chiếm
diag = set() # các đường chéo "\" đã chiếm, khóa là row - col
anti = set() # các đường chéo "/" đã chiếm, khóa là row + col
def backtrack(row):
if row == n: # is_solution: mọi hàng đã điền
solutions.append(placement[:])
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti:
continue # CẮT: ô này đang bị tấn công
cols.add(col) # choose — ba phép thay đổi
diag.add(row - col)
anti.add(row + col)
placement.append(col)
backtrack(row + 1) # explore
placement.pop() # un-choose — đúng ba phép nghịch đảo
cols.remove(col)
diag.remove(row - col)
anti.remove(row + col)
backtrack(0)
return solutions
def render(placement):
n = len(placement)
return "\n".join("".join("Q" if c == placement[r] else "." for c in range(n))
for r in range(n))
sols = solve_n_queens(8)
print(len(sols)) # 92
print(render(sols[0]))
n | Số lời giải | Số node duyệt (xấp xỉ) | Không gian n! đầy đủ |
|---|---|---|---|
| 6 | 4 | ~150 | 720 |
| 8 | 92 | ~2 000 | 40 320 |
| 10 | 724 | ~35 000 | 3 628 800 |
| 12 | 14 200 | ~1 triệu | 479 001 600 |
| 14 | 365 596 | ~50 triệu | 8,7 × 10¹⁰ |
Backtracking duyệt khoảng 2 % không gian n! ở n = 12. Đó là khoản tiết kiệm lớn về hằng số nhưng không phải thay đổi lớp tiệm cận — mức tăng trưởng vẫn là siêu hàm mũ, đó là lý do n = 20 (39 tỷ lời giải) nằm ngoài tầm với. Cắt tỉa mua cho bạn thêm vài đơn vị n, không phải một lượng không giới hạn. Biết sự phân biệt đó là thứ ngăn bạn hứa với stakeholder rằng “chỉ cần thêm pruning là được.”
Một tối ưu nữa đáng nhắc: phá vỡ đối xứng (symmetry breaking). Bàn cờ có đối xứng bậc 8 (4 phép quay × phép phản chiếu), nên giới hạn quân hậu đầu tiên vào nửa trái của hàng 0 rồi nhân số lượng lên là hợp lệ, giảm khoảng một nửa không gian tìm kiếm. Phá vỡ đối xứng là một trong những kỹ thuật cắt tỉa hiệu quả nhất trong các bài toán ràng buộc nói chung.
Sudoku — backtracking cộng constraint propagation
Backtracking Sudoku ngây thơ — duyệt ô theo thứ tự đọc, thử 1–9 ở mỗi ô trống — giải ngay lập tức các câu dễ và tắc nghẽn ở các câu khó. Cách sửa là constraint propagation: thay vì chọn ô tiếp theo tùy tiện, hãy tính tập ứng viên của mỗi ô trống rồi chọn ô có ít ứng viên nhất. Đây là heuristic MRV (minimum remaining values), và là bổ sung hiệu quả nhất cho một solver backtracking.
def solve_sudoku(board):
"""Giải Sudoku 9x9 tại chỗ. board dùng 0 cho ô trống. Trả về True nếu giải được.
Hai ý tưởng vượt ngoài backtracking thuần:
- candidates(): constraint propagation, tính các chữ số hợp lệ cho một ô
- MRV: luôn phân nhánh ở ô bị ràng buộc nhiều nhất, để cây hẹp nhất ở phía trên
"""
def candidates(r, c):
used = set(board[r]) # ràng buộc hàng
used |= {board[i][c] for i in range(9)} # ràng buộc cột
br, bc = 3 * (r // 3), 3 * (c // 3) # ràng buộc ô vuông 3x3
used |= {board[br + i][bc + j] for i in range(3) for j in range(3)}
return [d for d in range(1, 10) if d not in used]
# MRV: tìm ô trống có ít chữ số hợp lệ nhất
target, best = None, None
for r in range(9):
for c in range(9):
if board[r][c] == 0:
cands = candidates(r, c)
if not cands:
return False # ngõ cụt — cắt nguyên nhánh
if best is None or len(cands) < len(best):
target, best = (r, c), cands
if len(cands) == 1:
break # nước đi bắt buộc — ngừng quét hàng này
if target is None:
return True # không còn ô trống: đã giải xong
r, c = target
for digit in best:
board[r][c] = digit # choose
if solve_sudoku(board): # explore
return True
board[r][c] = 0 # un-choose
return False
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],
]
print(solve_sudoku(puzzle)) # True
print(puzzle[0]) # [5, 3, 4, 6, 7, 8, 9, 1, 2]
Ba điều đoạn code này minh họa và tổng quát được cho mọi bài toán constraint satisfaction:
- Phát hiện thất bại sớm.
if not cands: return Falsetừ bỏ nhánh ngay khoảnh khắc bất kỳ ô nào không còn giá trị hợp lệ, không cần chờ tới lượt ô đó. Kỹ thuật này gọi là forward checking, và thường cắt cây đi cả một bậc độ lớn. - Thứ tự chọn biến (MRV). Phân nhánh ở ô có 2 ứng viên thay vì 7 làm cây hẹp lại ở phía trên, nơi việc thu hẹp có giá trị nhất. Một ô có đúng 1 ứng viên là nước đi bắt buộc, hoàn toàn không phân nhánh.
- State chính là bàn cờ.
makeghi một chữ số,undoghi lại0. Không cần cấu trúc ghi sổ riêng, nhưng kỷ luật ghép cặp hai dòng đó vẫn áp dụng.
Worst case lý thuyết là Θ(9^m) với m ô trống, nhưng có propagation thì một câu đố chuẩn 17 gợi ý giải xong trong vài mili giây. Khoảng cách giữa worst case và thực tế này là đặc trưng định danh của backtracking, và là lý do kỹ thuật này được dùng trong các SAT solver và constraint solver thật bất chấp cận worst case tồi tệ. Các solver production mở rộng cùng ý tưởng đó bằng arc consistency (AC-3), lan truyền ràng buộc giữa các cặp ô cho tới khi không suy diễn được gì thêm, và conflict-driven clause learning (CDCL), ghi nhớ lý do một nhánh thất bại để không bao giờ phát hiện lại cùng một thất bại.
Word search trên lưới — backtracking trên graph ngầm định
Cho một lưới ký tự và một từ, xác định xem từ đó có thể vẽ được qua các ô kề nhau theo bốn hướng mà không dùng lại ô nào không.
def word_search(board, word):
"""Theta(R · C · 3^L) ở worst case, với L = len(word).
Là 3 chứ không phải 4, vì sau bước đầu bạn không bao giờ quay lại ô vừa đến từ đó.
Theta(L) bộ nhớ cho đệ quy; dấu visited được lưu NGAY TRONG board."""
if not word:
return True
rows, cols = len(board), len(board[0])
def backtrack(r, c, i):
if i == len(word):
return True # is_solution: khớp trọn cả từ
if r < 0 or r >= rows or c < 0 or c >= cols:
return False # cắt: ra ngoài lưới
if board[r][c] != word[i]:
return False # cắt: ký tự không khớp
saved = board[r][c]
board[r][c] = "#" # choose: đánh dấu đang nằm trên path hiện tại
found = (backtrack(r + 1, c, i + 1) or backtrack(r - 1, c, i + 1) or
backtrack(r, c + 1, i + 1) or backtrack(r, c - 1, i + 1))
board[r][c] = saved # un-choose: ô này lại tự do cho path khác
return found
return any(backtrack(r, c, 0) for r in range(rows) for c in range(cols))
grid = [list("ABCE"), list("SFCS"), list("ADEE")]
print(word_search(grid, "ABCCED")) # True
print(word_search(grid, "ABCB")) # False — 'B' sẽ phải dùng lại
Cặp board[r][c] = "#" / board[r][c] = saved là minh họa rõ ràng nhất cho việc backtracking khác DFS trên graph ở chỗ nào. Một tập visited vĩnh viễn sẽ sai ở đây: ô (0,1) không dùng được khi nó đang nằm trên path hiện tại, nhưng hoàn toàn dùng được bởi một lần tìm kiếm xuất phát từ chỗ khác. Đánh dấu tại chỗ cũng tránh cấp phát một tập visited cho mỗi vị trí xuất phát, điều này quan trọng khi hàm được gọi cho hàng nghìn từ.
Hai mở rộng hữu ích:
- Kiểm tra trước số lượng ký tự. Nếu từ chứa nhiều chữ
Ehơn lưới có, trả vềFalsetrước khi tìm kiếm — một phép kiểm traO(RC)có thể bỏ qua cả một cuộc tìm kiếm hàm mũ. - Tìm nhiều từ bằng trie. Tìm 10 000 từ lần lượt từng cái sẽ lặp lại đúng những đường đi trên lưới. Đặt từ điển vào một trie và duyệt lưới một lần, đồng thời đi xuống trie song song, biến
Θ(W · R · C · 3^L)thành khoảngΘ(R · C · 3^L). Đây là lời giải chuẩn cho bài “Word Search II” và là ví dụ tốt về việc chọn đúng cấu trúc phụ trợ để cắt tỉa.
Phân tích độ phức tạp của backtracking
Cận tổng quát là (số node trong cây tìm kiếm) × (công việc mỗi node), trong đó số node bị chặn bởi hệ số phân nhánh b lũy thừa độ sâu d:
số node <= 1 + b + b² + ... + b^d = O(b^d)
| Bài toán | Phân nhánh b | Độ sâu d | Cận worst case | Tầm thực tế |
|---|---|---|---|---|
| Subset | 2 | n | Θ(n · 2ⁿ) | n ≤ 25 |
| Subset có phần tử trùng | ≤ 2 | n | Θ(n · 2ⁿ) | n ≤ 25 |
| Hoán vị | n → 1 | n | Θ(n · n!) | n ≤ 11 |
Tổ hợp C(n,k) | ≤ n | k | Θ(k · C(n,k)) | tùy C(n,k) |
| N-queens | n | n | O(n!), ít hơn nhiều nếu cắt tỉa | n ≤ 15 để lấy mọi lời giải |
| Sudoku (9×9) | ≤ 9 | ≤ 81 | O(9^m), m = số ô trống | mọi câu đố thực tế |
| Word search | 3 | L | Θ(R · C · 3^L) | từ điển thông thường |
Tô màu graph k màu | k | V | O(k^V) | graph nhỏ/thưa |
Ba lưu ý về những con số này:
- Space là
Θ(d), không phảiΘ(b^d). Cây không bao giờ được lưu; chỉ có path từ gốc tới node hiện tại sống trên call stack. Đây là lợi thế bộ nhớ lớn của tìm kiếm dựa trên DFS so với dựa trên BFS, và là lý do backtracking có thể khám phá một cây2¹⁰⁰node trong 100 stack frame. Hãy để ý giới hạn độ sâu đệ quy nếudcó thể vượt ~1000. - Bản thân output có thể chiếm ưu thế. Sinh cả
2ⁿsubset làΩ(2ⁿ)bất kể bạn thông minh cỡ nào — bạn buộc phải ghi chúng ra. Khi output là hàm mũ, không thuật toán nào là đa thức được, và phàn nàn về cận là vô nghĩa. Đó là lý do các bài này được gọi là bài toán output-sensitive. - Cận worst case thường bi quan quá mức. Cận của N-queens là
O(n!)nhưng cắt tỉa chỉ thăm ~2 % của nó; cận của Sudoku làO(9⁵¹)nhưng câu đố thật giải trong micro giây. Hãy đo, đừng ngoại suy từ cận. Ngược lại, đừng bao giờ dựa vào việc cắt tỉa vẫn hiệu quả với input đối kháng.
Cắt tỉa và constraint propagation
Cắt tỉa là ranh giới giữa một kỹ thuật thật và một món đồ chơi. Danh mục, đại khái theo thứ tự mức độ hiệu quả:
- Cắt theo tính khả thi (kiểm tra ràng buộc). Từ chối ngay một lựa chọn vi phạm ràng buộc, trước khi đệ quy. Đây là
is_validtrong template, và nó bắt buộc — không có nó thì bạn chỉ có brute force với vài bước thừa. - Forward checking / phát hiện thất bại sớm. Sau khi thực hiện một lựa chọn, kiểm tra xem có biến tương lai nào giờ có miền giá trị rỗng không. Nếu có, nhánh này đã chết dù chưa ràng buộc nào bị vi phạm.
if not cands: return Falsecủa Sudoku chính là điều này. - Thứ tự chọn biến (MRV / ràng buộc nhiều nhất trước). Phân nhánh ở quyết định có ít phương án nhất. Cách này thu hẹp cây ở nơi nó rộng nhất và thường đáng giá hơn mọi heuristic đơn lẻ khác.
- Thứ tự chọn giá trị (least-constraining-value). Trong các giá trị hợp lệ của biến đã chọn, thử trước giá trị loại bỏ ít phương án nhất cho các biến còn lại. Hữu ích khi bạn chỉ cần một lời giải; không liên quan khi bạn cần tất cả.
- Phá vỡ đối xứng. Nếu hai nhánh sinh ra subtree đẳng cấu, chỉ khám phá một. Dòng
i > start and nums[i] == nums[i-1]trongsubsets_with_dupsvà mẹo đặt quân hậu đầu ở nửa trái đều là những thể hiện của nó. - Cắt theo cận (branch and bound). Dành cho bài toán tối ưu chứ không phải thỏa mãn: tính một cận lạc quan cho giá trị tốt nhất mà bất kỳ cách hoàn thiện nào của lời giải dở dang hiện tại có thể đạt, rồi bỏ nhánh nếu nó không thể vượt lời giải tốt nhất đã tìm được. Điều này biến backtracking thành branch and bound và là thứ khiến các solver TSP chính xác dùng được tới vài trăm thành phố. Xem ./20-brute-force-greedy-and-randomised-algorithms.md.
- Memoize các state thất bại. Nếu cùng một state có thể tới được bằng các thứ tự quyết định khác nhau, hãy cache lại việc nó đã thất bại. Đây là điểm mà backtracking bắt đầu biến thành quy hoạch động.
Constraint propagation là thuật ngữ bao trùm cho mục 2–4: sau mỗi quyết định, suy diễn được càng nhiều càng tốt về các biến còn lại trước khi phân nhánh tiếp. Phiên bản cực đoan — lan truyền tới điểm bất động bằng arc consistency — giải được nhiều câu đố mà không cần tìm kiếm gì cả. Các SAT solver và CP solver hiện đại dành phần lớn công sức kỹ thuật cho propagation chứ không phải cho vòng lặp tìm kiếm, chính xác vì một quyết định tránh được đáng giá bằng nguyên một subtree.
Quan hệ với DFS, brute force và quy hoạch động
brute force backtracking quy hoạch động
----------- ------------ ---------------
liệt kê MỌI +cắt-> liệt kê, từ bỏ +memo-> liệt kê mỗi state
ứng viên các subtree PHÂN BIỆT một lần
bất khả thi
O(k^n) luôn luôn O(k^n) worst case, đa thức khi không gian
thường ít hơn nhiều state là đa thức
- Backtracking là brute force cộng cắt tỉa. Cùng phép liệt kê, cùng worst case tiệm cận, hành vi thực tế khác nhau triệt để. Nếu bạn gỡ mọi phép kiểm tra
is_validkhỏi một lời giải backtracking, bạn nhận lại brute force. - Backtracking là DFS trên một cây ngầm định. Cùng thứ tự duyệt, cùng
O(depth)space. Khác biệt là cây được sinh trong lúc chạy chứ không được lưu, và state được hoàn tác trên đường đi lên chứ không đánh dấu vĩnh viễn. DFS trên graph cần tậpvisitedvì graph có cycle; cây quyết định thì không. - Backtracking và DP giải hai nửa chồng lấn của cùng một không gian. Chúng rẽ nhánh ở chỗ state có lặp lại hay không. Nếu hai chuỗi quyết định khác nhau dẫn tới cùng một state và đáp án chỉ phụ thuộc state, bạn đang tính lại — hãy memoize, và bạn có quy hoạch động. Nếu mọi đường đều dẫn tới state khác nhau (như trong hoán vị, nơi path chính là đáp án), thì không có gì để memoize và backtracking là lựa chọn duy nhất.
Bài toán subset sum cho thấy ranh giới đó rất sắc nét. Backtracking trên “lấy hay bỏ mỗi phần tử” là O(2ⁿ). Nhưng state thực sự quan trọng chỉ là (index, tổng_còn_lại) — nhiều subset khác nhau dẫn tới cùng tổng còn lại — nên memoize theo cặp đó cho ra DP giả đa thức O(n · target). Khi bạn nén được path thành một state nhỏ, hãy chuyển sang DP. Khi không nén được, hãy backtrack và cắt tỉa thật mạnh.
Một kỹ thuật liên quan đáng biết: iterative deepening chạy backtracking có giới hạn độ sâu với ngưỡng 1, rồi 2, rồi 3, và cứ thế. Nó giữ bộ nhớ O(d) của DFS trong khi vẫn có đảm bảo của BFS là tìm ra lời giải nông nhất trước, đổi lại phải duyệt lại phần trên của cây — điều này rẻ, vì mức cuối của một cây chứa phần lớn số node của nó. IDA* (iterative deepening kèm heuristic A*) là cách chuẩn để giải tối ưu bài 15-puzzle và Rubik; xem ./14-shortest-path-algorithms.md cho nửa A* của câu chuyện.
Best Practices
- Viết template trước, rồi điền vào năm vị trí.
is_solution,candidates,is_valid,make/undo, và cách xử lý kết quả. Mọi bài backtracking đều là năm quyết định này; xuất phát từ template giúp bạn không phải phát minh lại luồng điều khiển mỗi lần. - Ghép mỗi
makevớiundotương ứng trên các dòng kề nhau, và đọc chúng cùng nhau. Thiếu một undo là bug backtracking phổ biến nhất, và triệu chứng của nó — state rò rỉ sang các nhánh anh em — trông chẳng giống nguyên nhân chút nào. - Copy path khi ghi lại một lời giải.
results.append(path)lưu một tham chiếu tới list vẫn đang bị biến đổi. Dùngpath[:],list(path), hoặctuple(path). - Cắt tỉa trong vòng lặp của node cha, trước khi đệ quy. Nó tránh một lời gọi cho mỗi nhánh chết và đặt luật đặc thù của bài toán vào chỗ nó đọc tự nhiên nhất.
- Thêm heuristic ràng buộc-nhiều-nhất-trước trước khi micro-optimize bất cứ thứ gì khác. Chọn biến nào để phân nhánh đáng giá hơn mọi cải thiện hằng số nào cho vòng lặp bên trong. Trong Sudoku, riêng MRV đã là khác biệt giữa vài giây và vài mili giây.
- Phát hiện ngõ cụt sớm nhất có thể tính được. Kiểm tra sau một lựa chọn xem có biến còn lại nào bị rỗng miền giá trị không (forward checking) thường cắt cây đi 10 lần hoặc hơn.
- Phá vỡ đối xứng khi các nhánh đẳng cấu. Sort input rồi bỏ qua các node anh em bằng nhau, hoặc cố định quyết định đầu tiên theo đối xứng, cắt bỏ phần việc trùng lặp mà không kỹ thuật nào khác bắt được.
- Ghi độ phức tạp vào docstring, kèm lý do. Chỉ
Theta(n · 2^n)thì không hữu ích;2^n subset, O(n) để copy mỗi cáimới hữu ích. Nó cho người đọc sau biết phải tấn công vào đâu nếu code quá chậm. - Chặn kích thước input tường minh. Backtracking là hàm mũ, nên một endpoint có kích thước input do người dùng điều khiển cần một mức trần cứng và một thông báo lỗi rõ ràng. “Thực tế nó nhanh” không phải là một thuộc tính bảo mật.
- Để ý độ sâu đệ quy. Độ sâu bằng số quyết định. Với các bài toán lưới hoặc chuỗi, độ sâu có thể tiệm cận kích thước input, và giới hạn 1000 frame mặc định của Python là có thật — xem ./19-recursion-and-divide-and-conquer.md.
- Kiểm tra xem state có lặp lại không trước khi tối ưu phần cắt tỉa. Nếu hai đường quyết định có thể tới cùng một state, hãy memoize và bạn có DP — một thuật toán đa thức thắng mọi mức độ cắt tỉa trên một thuật toán hàm mũ.
- Ưu tiên biến đổi một
pathdùng chung hơn là truyền bản copy xuống.backtrack(path + [x])dễ đọc hơn nhưng cấp phátO(n)mỗi node, biếnΘ(2ⁿ)thànhΘ(n · 2ⁿ)về mặt cấp phát. Hãy biến đổi rồi hoàn tác; đó là lý do template có bước un-choose. - Test bằng brute force. Sinh input nhỏ ngẫu nhiên, chạy cả bản có cắt tỉa lẫn bản không, rồi so kết quả. Bug cắt tỉa tạo ra các lời giải bị thiếu, và không lượng nhìn bằng mắt nào phát hiện ra điều đó.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Backtracking — Wikipedia
- Eight queens puzzle — Wikipedia
- Sudoku solving algorithms — Wikipedia
- Constraint satisfaction problem — Wikipedia
- Constraint propagation — Wikipedia
- AC-3 algorithm — Wikipedia
- Branch and bound — Wikipedia
- Iterative deepening depth-first search — Wikipedia
- Knuth’s Dancing Links (DLX) — arXiv
- Peter Norvig — Solving Every Sudoku Puzzle
- CLRS — Introduction to Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
itertools - cp-algorithms — Algorithms index
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Backtracking is the technique for problems where a solution is a sequence of decisions that must jointly satisfy some constraints. You build the solution one decision at a time; whenever the partial solution can no longer be completed into a valid one, you undo the most recent decision and try the next alternative. That undo — the “backtrack” — is what gives the technique its name.
Structurally, backtracking is a depth-first search over an implicit decision tree. The tree is never materialised in memory; it exists only as the sequence of recursive calls. Each node is a partial solution, each edge is one decision, and each leaf is either a complete solution or a dead end. What separates backtracking from plain brute force is the word implicit: brute force enumerates all kⁿ leaves, while backtracking abandons an entire subtree the instant its root is known to be infeasible. On the 8-queens puzzle, brute force over all placements of 8 queens on 64 squares is 4.4 billion candidates; backtracking with the row/column/diagonal constraints visits about 2 000 nodes.
That pruning is the whole value proposition, and it is also the honest limitation. Backtracking is still exponential in the worst case — if the constraints never fire, you enumerate the full tree. It is the right technique when the constraints are strong enough to cut most of the space early, and the wrong one when they are not (in which case you want dynamic programming, if the subproblems overlap, or an approximation, if they do not).
The payoff for learning it is that a very large family of problems collapses into a single ten-line template. Subsets, permutations, combinations, N-queens, Sudoku, graph colouring, word search, crossword filling, regex matching, SAT solving, and constraint programming are all the same code with a different is_valid predicate.
Fundamentals
Backtracking as DFS over a decision tree
Consider generating all subsets of [1, 2, 3]. The decision at each level is “do I include element i?” — two branches per level, three levels, 2³ = 8 leaves:
[] <- level 0: decide about 1
/ \
include 1 exclude 1
/ \
[1] [] <- level 1: decide about 2
/ \ / \
[1,2] [1] [2] [] <- level 2: decide about 3
/ \ / \ / \ / \
[1,2,3] [1,2] [1,3] [1] [2,3] [2] [3] [] <- 8 leaves = 8 subsets
The recursion walks this tree depth-first. path is the current root-to-node prefix, and it is mutated on the way down and restored on the way up — which is precisely why the same list object can serve every node without copying it at each step.
The critical mental model: the call stack holds the path from the root to the current node. At depth d, there are exactly d frames alive, each remembering which branch it is currently exploring. Un-choosing on the way back up restores the state so that the sibling branch sees the same world the current branch saw when it started. Get that restoration wrong — forget one line of undo — and the bug manifests as extra elements appearing in unrelated results, which is maddening to debug.
The general template: choose / explore / un-choose
Every backtracking solution is this shape:
def backtrack(state, path, results):
if is_solution(state, path):
results.append(path[:]) # COPY — path keeps mutating after this
return # (or don't return, if longer solutions also count)
for choice in candidates(state, path):
if not is_valid(state, path, choice):
continue # PRUNE — skip this entire subtree
make(state, path, choice) # 1. CHOOSE — apply the decision
backtrack(state, path, results) # 2. EXPLORE — recurse one level deeper
undo(state, path, choice) # 3. UN-CHOOSE — restore exactly what CHOOSE changed
Five decisions define any concrete instance:
| Slot | Question it answers | Example (N-queens) |
|---|---|---|
is_solution | When is the path complete? | All n rows have a queen |
candidates | What are the options at this node? | Every column in the current row |
is_valid | Which options can be pruned immediately? | Column/diagonal not already attacked |
make / undo | What state changes, and how is it reversed? | Add/remove the column and both diagonals |
| Result handling | Collect one solution, all of them, or the best? | All 92 for n = 8 |
Three rules that prevent almost all backtracking bugs:
undomust be the exact inverse ofmake. Every mutation inmakeneeds a line inundo. Ifmakeadds to three sets,undoremoves from three sets. Writing the two lines adjacently and reading them as a pair is the discipline.- Copy the path when you record a solution.
results.append(path)appends a reference; the list keeps mutating and you end up withnidentical empty lists.path[:](orlist(path), ortuple(path)) is mandatory. - Prune before you recurse, not after. Checking validity at the top of the child call also works, but checking it in the parent’s loop avoids a function call per dead branch — and, more importantly, it is where a domain-specific pruning rule naturally belongs.
Backtracking versus brute force versus DFS
| Brute force | Backtracking | DFS on a graph | |
|---|---|---|---|
| Search space | Enumerated fully | Enumerated with subtrees pruned | Given explicitly as nodes and edges |
| Tree/graph | Implicit, all leaves visited | Implicit, built during recursion | Explicit data structure |
Needs a visited set | No | No — the tree has no cycles | Yes — graphs have cycles |
| Undo on the way up | N/A | Yes, essential | Usually not (visited is permanent) |
| Typical cost | Θ(kⁿ) | Θ(kⁿ) worst case, far less typically | Θ(V + E) |
The visited-set distinction is worth dwelling on, because it is the most common conceptual confusion. In graph DFS you mark a node visited permanently — you never want to enter it again, from any path. In backtracking you mark state as used and then un-mark it on the way back, because a different prefix may legitimately need it. The word-search example below shows both behaviours in one function: a cell is marked while it is on the current path (so the path cannot self-intersect) and unmarked afterwards (so another path may use it).
Key Concepts
Subsets — the power set
def subsets(nums):
"""All 2^n subsets. Theta(n · 2^n) time (2^n subsets, O(n) to copy each),
Theta(n) auxiliary space for the recursion + path."""
results, path = [], []
def backtrack(start):
results.append(path[:]) # EVERY node is a valid subset, not just leaves
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1) # explore — i+1 forbids reuse and enforces order
path.pop() # un-choose
backtrack(0)
return results
print(subsets([1, 2, 3]))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
The start parameter is the whole trick for combination-style problems: by only considering indices ≥ start, each subset is generated exactly once in increasing index order, so [1, 3] and [3, 1] never both appear. Removing it would generate all n!-ish orderings instead.
Handling duplicates requires one more line. Sort first, then skip a candidate that equals its predecessor at the same tree level:
def subsets_with_dups(nums):
"""Distinct subsets when nums may repeat. Still Theta(n · 2^n) worst case."""
results, path = [], []
nums = sorted(nums) # duplicates must be adjacent for the skip to work
def backtrack(start):
results.append(path[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue # same value already tried at THIS level -> prune
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return results
print(subsets_with_dups([1, 2, 2])) # [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
i > start is the load-bearing condition: it skips duplicates among siblings (which produce identical subtrees) while still allowing a duplicate to be used deeper in the path ([2, 2] is a legitimate subset). This is the first real example of pruning by symmetry — two branches that generate the same subtree, so only one needs exploring.
Permutations
def permutations(nums):
"""All n! orderings. Theta(n · n!) time, Theta(n) auxiliary space."""
results, path = [], []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums): # is_solution: every element placed
results.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue # prune: already in the current path
used[i] = True # choose
path.append(nums[i])
backtrack() # explore
path.pop() # un-choose (both halves!)
used[i] = False
backtrack()
return results
print(len(permutations([1, 2, 3, 4]))) # 24
Note the difference from subsets: permutations scan all indices every time (order matters, so [1, 2] and [2, 1] are both wanted) and use a used array instead of a start index. That is the general distinction — start for combinations, used for permutations.
The Θ(n · n!) bound is tight and brutal: n = 11 is already 40 million permutations, n = 13 is 6 billion. Any problem whose only formulation is “try every permutation” is unsolvable past about n = 12, which is why the travelling salesperson problem needs either DP (Θ(2ⁿ · n²) Held–Karp, usable to n ≈ 20) or a heuristic.
N-queens — pruning that changes the exponent
Place n queens on an n × n board so no two attack each other. The naive candidate space — choose n squares out of n² — is C(64, 8) ≈ 4.4 × 10⁹ for n = 8. Two observations shrink it enormously:
- Exactly one queen per row. So the decision at depth
ris only “which column in rowr”, reducing the space tonⁿ, and with the column constraint ton!= 40 320. - Diagonals have a constant-index identity. For a
\diagonal,row − colis constant; for a/diagonal,row + colis constant. So checking a diagonal conflict is anO(1)set lookup rather than anO(n)scan back through the board.
row - col (the "\" diagonals) row + col (the "/" diagonals)
c0 c1 c2 c3 c0 c1 c2 c3
r0 [ 0 -1 -2 -3 ] r0 [ 0 1 2 3 ]
r1 [ 1 0 -1 -2 ] r1 [ 1 2 3 4 ]
r2 [ 2 1 0 -1 ] r2 [ 2 3 4 5 ]
r3 [ 3 2 1 0 ] r3 [ 3 4 5 6 ]
every "\" diagonal shares one value every "/" diagonal shares one value
def solve_n_queens(n):
"""All solutions to the n-queens puzzle. Three O(1) sets make each check constant time."""
solutions = []
placement = [] # placement[r] = column of the queen in row r
cols = set() # occupied columns
diag = set() # occupied "\" diagonals, keyed by row - col
anti = set() # occupied "/" diagonals, keyed by row + col
def backtrack(row):
if row == n: # is_solution: all rows filled
solutions.append(placement[:])
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti:
continue # PRUNE: this square is attacked
cols.add(col) # choose — three mutations
diag.add(row - col)
anti.add(row + col)
placement.append(col)
backtrack(row + 1) # explore
placement.pop() # un-choose — exactly three inverses
cols.remove(col)
diag.remove(row - col)
anti.remove(row + col)
backtrack(0)
return solutions
def render(placement):
n = len(placement)
return "\n".join("".join("Q" if c == placement[r] else "." for c in range(n))
for r in range(n))
sols = solve_n_queens(8)
print(len(sols)) # 92
print(render(sols[0]))
n | Solutions | Nodes explored (approx.) | Full n! space |
|---|---|---|---|
| 6 | 4 | ~150 | 720 |
| 8 | 92 | ~2 000 | 40 320 |
| 10 | 724 | ~35 000 | 3 628 800 |
| 12 | 14 200 | ~1 million | 479 001 600 |
| 14 | 365 596 | ~50 million | 8.7 × 10¹⁰ |
Backtracking explores roughly 2 % of the n! space at n = 12. That is a large constant-factor saving but not a change in asymptotic class — the growth is still super-exponential, which is why n = 20 (39 billion solutions) is out of reach. Pruning buys you a handful of extra n, not an unbounded amount. Knowing that distinction is what stops you from promising a stakeholder that “we’ll just add more pruning.”
A further optimization worth mentioning: symmetry breaking. The board has 8-fold symmetry (4 rotations × reflection), so restricting the first queen to the left half of row 0 and multiplying the count works, roughly halving the search. Symmetry breaking is one of the highest-leverage pruning techniques in constraint problems generally.
Sudoku — backtracking plus constraint propagation
Naive Sudoku backtracking — walk cells in reading order, try 1–9 in each empty cell — solves easy puzzles instantly and chokes on hard ones. The fix is constraint propagation: instead of picking the next cell arbitrarily, compute each empty cell’s candidate set and choose the cell with the fewest candidates. This is the MRV (minimum remaining values) heuristic, and it is the single most effective addition to a backtracking solver.
def solve_sudoku(board):
"""Solve a 9x9 Sudoku in place. board uses 0 for empty. Returns True if solvable.
Two ideas beyond plain backtracking:
- candidates(): constraint propagation, computing the legal digits for a cell
- MRV: always branch on the most constrained cell, so the tree is narrowest at the top
"""
def candidates(r, c):
used = set(board[r]) # row constraint
used |= {board[i][c] for i in range(9)} # column constraint
br, bc = 3 * (r // 3), 3 * (c // 3) # 3x3 box constraint
used |= {board[br + i][bc + j] for i in range(3) for j in range(3)}
return [d for d in range(1, 10) if d not in used]
# MRV: find the empty cell with the fewest legal digits
target, best = None, None
for r in range(9):
for c in range(9):
if board[r][c] == 0:
cands = candidates(r, c)
if not cands:
return False # dead end — prune the whole branch
if best is None or len(cands) < len(best):
target, best = (r, c), cands
if len(cands) == 1:
break # forced move — stop scanning this row
if target is None:
return True # no empty cells: solved
r, c = target
for digit in best:
board[r][c] = digit # choose
if solve_sudoku(board): # explore
return True
board[r][c] = 0 # un-choose
return False
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],
]
print(solve_sudoku(puzzle)) # True
print(puzzle[0]) # [5, 3, 4, 6, 7, 8, 9, 1, 2]
Three things this demonstrates that generalise to every constraint-satisfaction problem:
- Early failure detection.
if not cands: return Falseabandons the branch the moment any cell has no legal value, without waiting to reach that cell. This is called forward checking, and it typically cuts the tree by an order of magnitude. - Variable ordering (MRV). Branching on a cell with 2 candidates instead of 7 makes the tree narrow at the top, where narrowing matters most. A cell with exactly 1 candidate is a forced move with no branching at all — which is why the
breakshort-circuits the search for a better cell. - The state is the board itself.
makewrites a digit,undowrites back0. No separate bookkeeping structure is needed, but the discipline of pairing the two lines still applies.
The theoretical worst case is Θ(9^m) for m empty cells, but with propagation, a standard 17-clue puzzle solves in milliseconds. This gap between worst case and practice is the defining characteristic of backtracking, and it is why the technique is used in real SAT and constraint solvers despite its terrible worst-case bound. Production solvers extend the same idea with arc consistency (AC-3), which propagates constraints between pairs of cells until nothing more can be deduced, and conflict-driven clause learning (CDCL), which remembers why a branch failed so the same failure is never rediscovered.
Word search on a grid — backtracking on an implicit graph
Given a grid of characters and a word, decide whether the word can be traced through orthogonally adjacent cells without reusing a cell.
def word_search(board, word):
"""Theta(R · C · 3^L) worst case, where L = len(word).
3, not 4, because after the first step you never revisit the cell you came from.
Theta(L) space for the recursion; the visited mark is stored IN the board."""
if not word:
return True
rows, cols = len(board), len(board[0])
def backtrack(r, c, i):
if i == len(word):
return True # is_solution: whole word matched
if r < 0 or r >= rows or c < 0 or c >= cols:
return False # prune: off the grid
if board[r][c] != word[i]:
return False # prune: character mismatch
saved = board[r][c]
board[r][c] = "#" # choose: mark as on the current path
found = (backtrack(r + 1, c, i + 1) or backtrack(r - 1, c, i + 1) or
backtrack(r, c + 1, i + 1) or backtrack(r, c - 1, i + 1))
board[r][c] = saved # un-choose: the cell is free for other paths
return found
return any(backtrack(r, c, 0) for r in range(rows) for c in range(cols))
grid = [list("ABCE"), list("SFCS"), list("ADEE")]
print(word_search(grid, "ABCCED")) # True
print(word_search(grid, "ABCB")) # False — 'B' would need to be reused
The board[r][c] = "#" / board[r][c] = saved pair is the clearest illustration of why backtracking differs from graph DFS. A permanent visited set would be wrong here: cell (0,1) is unusable while it is on the current path, but perfectly usable by a search that starts elsewhere. Marking in place also avoids allocating a visited set per start position, which matters when this is called for thousands of words.
Two useful extensions:
- Character-count pre-check. If the word contains more
Es than the grid does, returnFalsebefore searching at all — anO(RC)check that can skip an exponential search. - Trie-based multi-word search. Searching for 10 000 words one at a time repeats the same grid walks. Putting the dictionary in a trie and walking the grid once, descending the trie in lockstep, turns
Θ(W · R · C · 3^L)into roughlyΘ(R · C · 3^L). This is the standard “Word Search II” solution and a good example of choosing the right auxiliary structure to prune.
Complexity analysis of backtracking
The generic bound is (number of nodes in the search tree) × (work per node), where the node count is bounded by the branching factor b raised to the depth d:
nodes <= 1 + b + b² + ... + b^d = O(b^d)
| Problem | Branching b | Depth d | Worst-case bound | Practical reach |
|---|---|---|---|---|
| Subsets | 2 | n | Θ(n · 2ⁿ) | n ≤ 25 |
| Subsets with duplicates | ≤ 2 | n | Θ(n · 2ⁿ) | n ≤ 25 |
| Permutations | n → 1 | n | Θ(n · n!) | n ≤ 11 |
Combinations C(n,k) | ≤ n | k | Θ(k · C(n,k)) | depends on C(n,k) |
| N-queens | n | n | O(n!), far less with pruning | n ≤ 15 for all solutions |
| Sudoku (9×9) | ≤ 9 | ≤ 81 | O(9^m), m = empty cells | any real puzzle |
| Word search | 3 | L | Θ(R · C · 3^L) | typical dictionary words |
Graph k-colouring | k | V | O(k^V) | small/sparse graphs |
Three caveats about these numbers:
- Space is
Θ(d), notΘ(b^d). The tree is never stored; only the current root-to-node path lives on the call stack. This is the great memory advantage of DFS-based search over BFS-based search, and it is why backtracking can explore a2¹⁰⁰-node tree in 100 stack frames. Watch the recursion depth limit ifdcan exceed ~1000. - The output can dominate. Generating all
2ⁿsubsets isΩ(2ⁿ)no matter how clever you are — you have to write them down. When the output is exponential, no algorithm is polynomial, and complaining about the bound is meaningless. This is why these are called output-sensitive problems. - The worst-case bound is often wildly pessimistic. N-queens’ bound is
O(n!)but pruning visits ~2 % of it; Sudoku’s bound isO(9⁵¹)but real puzzles solve in microseconds. Measure, do not extrapolate from the bound. Conversely, never rely on pruning holding up for adversarial input.
Pruning and constraint propagation
Pruning is the difference between a technique and a toy. The catalogue, roughly in order of how much they typically buy:
- Feasibility pruning (constraint checking). Reject a choice that violates a constraint immediately, before recursing. This is
is_validin the template, and it is mandatory — without it you have brute force with extra steps. - Forward checking / early failure detection. After making a choice, check whether any future variable now has an empty domain. If so, this branch is dead even though no constraint is violated yet. Sudoku’s
if not cands: return Falseis exactly this. - Variable ordering (MRV / most-constrained-first). Branch on the decision with the fewest options. This narrows the tree where it is widest and is usually worth more than any other single heuristic.
- Value ordering (least-constraining-value). Among the legal values for the chosen variable, try first the one that eliminates the fewest options for the remaining variables. Helps when you only need one solution; irrelevant when you need all of them.
- Symmetry breaking. If two branches generate isomorphic subtrees, explore one. The
i > start and nums[i] == nums[i-1]line insubsets_with_dupsand the first-queen-in-the-left-half trick are both instances. - Bound pruning (branch and bound). For optimization rather than satisfaction: compute an optimistic bound on the best value any completion of the current partial solution could achieve, and abandon the branch if it cannot beat the best solution found so far. This turns backtracking into branch and bound and is what makes exact TSP solvers practical up to a few hundred cities. See ./20-brute-force-greedy-and-randomised-algorithms.md.
- Memoization of failed states. If the same state can be reached by different decision orders, cache the fact that it failed. This is the point where backtracking starts turning into dynamic programming.
Constraint propagation is the umbrella term for 2–4: after each decision, deduce as much as possible about the remaining variables before branching again. The extreme version — propagate until a fixed point, using arc consistency — solves many puzzle instances without any search at all. Modern SAT and CP solvers spend most of their engineering effort on propagation, not on the search loop, precisely because a decision avoided is worth an entire subtree.
Relationship to DFS, brute force, and dynamic programming
brute force backtracking dynamic programming
----------- ------------ -------------------
enumerate ALL +prune-> enumerate, +memoize-> enumerate each
candidates abandoning DISTINCT state once
infeasible subtrees
O(k^n) always O(k^n) worst case, polynomial when the
usually far less state space is polynomial
- Backtracking is brute force plus pruning. Same enumeration, same asymptotic worst case, radically different practical behaviour. If you remove every
is_validcheck from a backtracking solution, you get the brute force. - Backtracking is DFS on an implicit tree. Same traversal order, same
O(depth)space. The differences are that the tree is generated on the fly rather than stored, and that state is undone on the way back up rather than marked permanently. Graph DFS needs avisitedset because graphs have cycles; a decision tree does not. - Backtracking and DP solve overlapping halves of the same space. They diverge on whether states repeat. If two different decision sequences lead to the same state and the answer depends only on the state, you are recomputing — memoize, and you have dynamic programming. If every path leads to a distinct state (as in permutations, where the path is the answer), there is nothing to memoize and backtracking is the only option.
The subset-sum problem shows the boundary sharply. Backtracking over “include or exclude each element” is O(2ⁿ). But the state that actually matters is only (index, remaining_sum) — many different subsets reach the same remaining sum — so memoizing on that pair gives O(n · target) pseudo-polynomial DP. When you can compress the path into a small state, switch to DP. When you cannot, backtrack and prune hard.
A related technique worth knowing: iterative deepening runs a depth-limited backtracking search with limit 1, then 2, then 3, and so on. It keeps DFS’s O(d) memory while getting BFS’s guarantee of finding the shallowest solution first, at the cost of re-exploring the top of the tree — which is cheap, because a tree’s last level contains most of its nodes. IDA* (iterative deepening with an A* heuristic) is the standard way to solve the 15-puzzle and Rubik’s cube optimally; see ./14-shortest-path-algorithms.md for the A* half of that.
Best Practices
- Write the template first, then fill in the five slots.
is_solution,candidates,is_valid,make/undo, and result handling. Every backtracking problem is these five decisions; starting from the template stops you from reinventing the control flow each time. - Pair every
makewith itsundoon adjacent lines, and read them together. A missing undo is the single most common backtracking bug, and its symptom — state leaking into sibling branches — looks nothing like its cause. - Copy the path when you record a solution.
results.append(path)stores a reference to a list that is still being mutated. Usepath[:],list(path), ortuple(path). - Prune in the parent loop, before recursing. It avoids a call per dead branch and puts the domain-specific rule where it reads naturally.
- Add the most-constrained-first heuristic before micro-optimizing anything else. Choosing which variable to branch on is worth more than any constant-factor improvement to the inner loop. In Sudoku, MRV alone is the difference between seconds and milliseconds.
- Detect dead ends as early as you can compute them. Checking after a choice whether any remaining variable has an empty domain (forward checking) routinely cuts the tree by 10× or more.
- Break symmetry when branches are isomorphic. Sorting the input and skipping equal siblings, or fixing the first decision up to symmetry, prunes duplicate work that no other technique will catch.
- State the complexity in the docstring, with the reason.
Theta(n · 2^n)alone is not useful;2^n subsets, O(n) to copy eachis. It tells the next reader what to attack if it is too slow. - Bound the input size explicitly. Backtracking is exponential, so an endpoint whose input size is user-controlled needs a hard cap and a clear error. “It’s fast in practice” is not a security property.
- Watch the recursion depth. Depth equals the number of decisions. For grid or string problems the depth can approach the input size, and Python’s default 1000-frame limit is real — see ./19-recursion-and-divide-and-conquer.md.
- Check whether states repeat before optimizing the pruning. If two decision paths can reach the same state, memoize and you have DP — a polynomial algorithm beats any amount of pruning on an exponential one.
- Prefer mutating a shared
pathover passing copies down.backtrack(path + [x])is easier to read and allocatesO(n)per node, turningΘ(2ⁿ)intoΘ(n · 2ⁿ)in allocations. Mutate and undo; it is why the template has an un-choose step. - Test with the brute force. Generate small random inputs, run both the pruned and unpruned versions, and compare results. Pruning bugs produce missing solutions, which no amount of eyeballing will reveal.
References
- roadmap.sh — Data Structures & Algorithms
- Backtracking — Wikipedia
- Eight queens puzzle — Wikipedia
- Sudoku solving algorithms — Wikipedia
- Constraint satisfaction problem — Wikipedia
- Constraint propagation — Wikipedia
- AC-3 algorithm — Wikipedia
- Branch and bound — Wikipedia
- Iterative deepening depth-first search — Wikipedia
- Knuth’s Dancing Links (DLX) — arXiv
- Peter Norvig — Solving Every Sudoku Puzzle
- CLRS — Introduction to Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
itertools - cp-algorithms — Algorithms index