← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202630 phút đọc25 min read

Backtracking (Quay lui)Backtracking

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Backtracking như một DFS trên cây quyết định
  4. Template tổng quát: choose / explore / un-choose
  5. Backtracking so với brute force và DFS
  6. Khái niệm chính
  7. Subset — tập lũy thừa
  8. Hoán vị
  9. N-queens — cắt tỉa làm đổi cả số mũ
  10. Sudoku — backtracking cộng constraint propagation
  11. Word search trên lưới — backtracking trên graph ngầm định
  12. Phân tích độ phức tạp của backtracking
  13. Cắt tỉa và constraint propagation
  14. Quan hệ với DFS, brute force và quy hoạch động
  15. Best Practices
  16. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Backtracking as DFS over a decision tree
  4. The general template: choose / explore / un-choose
  5. Backtracking versus brute force versus DFS
  6. Key Concepts
  7. Subsets — the power set
  8. Permutations
  9. N-queens — pruning that changes the exponent
  10. Sudoku — backtracking plus constraint propagation
  11. Word search on a grid — backtracking on an implicit graph
  12. Complexity analysis of backtracking
  13. Pruning and constraint propagation
  14. Relationship to DFS, brute force, and dynamic programming
  15. Best Practices
  16. 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ờiVí dụ (N-queens)
is_solutionKhi nào path hoàn chỉnh?Cả n hàng đều có quân hậu
candidatesCác phương án tại node này là gì?Mọi cột trong hàng hiện tại
is_validPhươ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 / undoState 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:

  1. undo phải là nghịch đảo chính xác của make. Mỗi phép thay đổi trong make cần một dòng trong undo. Nếu make thêm vào ba set, undo phả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ó.
  2. 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ới n list rỗng giống hệt nhau. path[:] (hoặc list(path), hoặc tuple(path)) là bắt buộc.
  3. 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 forceBacktrackingDFS trên graph
Không gian tìm kiếmLiệt kê đầy đủLiệt kê có cắt subtreeCho sẵn dưới dạng node và cạnh
Cây/graphNgầm định, thăm mọi láNgầm định, dựng trong lúc đệ quyCấu trúc dữ liệu tường minh
Cần tập visited khôngKhôngKhông — cây không có cycle — graph có cycle
Hoàn tác trên đường lênKhông áp dụngCó, thiết yếuThườ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][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 ô — là C(64, 8) ≈ 4,4 × 10⁹ với n = 8. Hai nhận xét thu nhỏ nó cực kỳ mạnh:

  1. Đúng một quân hậu mỗi hàng. Vậy quyết định ở độ sâu r chỉ là “cột nào trong hàng r”, giảm không gian xuống nⁿ, và kèm ràng buộc cột thì còn n! = 40 320.
  2. Đường chéo có một đại lượng index bất biến. Với đường chéo \, row − col là hằng số; với đường chéo /, row + col là hằng số. Nên kiểm tra xung đột đường chéo chỉ là một lần tra set O(1) thay vì quét ngược O(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]))
nSố lời giảiSố node duyệt (xấp xỉ)Không gian n! đầy đủ
64~150720
892~2 00040 320
10724~35 0003 628 800
1214 200~1 triệu479 001 600
14365 596~50 triệu8,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:

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:

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ánPhân nhánh bĐộ sâu dCận worst caseTầm thực tế
Subset2nΘ(n · 2ⁿ)n ≤ 25
Subset có phần tử trùng≤ 2nΘ(n · 2ⁿ)n ≤ 25
Hoán vịn → 1nΘ(n · n!)n ≤ 11
Tổ hợp C(n,k)nkΘ(k · C(n,k))tùy C(n,k)
N-queensnnO(n!), ít hơn nhiều nếu cắt tỉan ≤ 15 để lấy mọi lời giải
Sudoku (9×9)≤ 9≤ 81O(9^m), m = số ô trốngmọi câu đố thực tế
Word search3LΘ(R · C · 3^L)từ điển thông thường
Tô màu graph k màukVO(k^V)graph nhỏ/thưa

Ba lưu ý về những con số này:

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ả:

  1. 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_valid trong 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.
  2. 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 False của Sudoku chính là điều này.
  3. 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.
  4. 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ả.
  5. 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] trong subsets_with_dups và mẹo đặt quân hậu đầu ở nửa trái đều là những thể hiện của nó.
  6. 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.
  7. 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

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

Tài liệu tham khảo

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:

SlotQuestion it answersExample (N-queens)
is_solutionWhen is the path complete?All n rows have a queen
candidatesWhat are the options at this node?Every column in the current row
is_validWhich options can be pruned immediately?Column/diagonal not already attacked
make / undoWhat state changes, and how is it reversed?Add/remove the column and both diagonals
Result handlingCollect one solution, all of them, or the best?All 92 for n = 8

Three rules that prevent almost all backtracking bugs:

  1. undo must be the exact inverse of make. Every mutation in make needs a line in undo. If make adds to three sets, undo removes from three sets. Writing the two lines adjacently and reading them as a pair is the discipline.
  2. Copy the path when you record a solution. results.append(path) appends a reference; the list keeps mutating and you end up with n identical empty lists. path[:] (or list(path), or tuple(path)) is mandatory.
  3. 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 forceBacktrackingDFS on a graph
Search spaceEnumerated fullyEnumerated with subtrees prunedGiven explicitly as nodes and edges
Tree/graphImplicit, all leaves visitedImplicit, built during recursionExplicit data structure
Needs a visited setNoNo — the tree has no cyclesYes — graphs have cycles
Undo on the way upN/AYes, essentialUsually 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 — is C(64, 8) ≈ 4.4 × 10⁹ for n = 8. Two observations shrink it enormously:

  1. Exactly one queen per row. So the decision at depth r is only “which column in row r”, reducing the space to nⁿ, and with the column constraint to n! = 40 320.
  2. Diagonals have a constant-index identity. For a \ diagonal, row − col is constant; for a / diagonal, row + col is constant. So checking a diagonal conflict is an O(1) set lookup rather than an O(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]))
nSolutionsNodes explored (approx.)Full n! space
64~150720
892~2 00040 320
10724~35 0003 628 800
1214 200~1 million479 001 600
14365 596~50 million8.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:

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:

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)
ProblemBranching bDepth dWorst-case boundPractical reach
Subsets2nΘ(n · 2ⁿ)n ≤ 25
Subsets with duplicates≤ 2nΘ(n · 2ⁿ)n ≤ 25
Permutationsn → 1nΘ(n · n!)n ≤ 11
Combinations C(n,k)nkΘ(k · C(n,k))depends on C(n,k)
N-queensnnO(n!), far less with pruningn ≤ 15 for all solutions
Sudoku (9×9)≤ 9≤ 81O(9^m), m = empty cellsany real puzzle
Word search3LΘ(R · C · 3^L)typical dictionary words
Graph k-colouringkVO(k^V)small/sparse graphs

Three caveats about these numbers:

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:

  1. Feasibility pruning (constraint checking). Reject a choice that violates a constraint immediately, before recursing. This is is_valid in the template, and it is mandatory — without it you have brute force with extra steps.
  2. 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 False is exactly this.
  3. 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.
  4. 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.
  5. Symmetry breaking. If two branches generate isomorphic subtrees, explore one. The i > start and nums[i] == nums[i-1] line in subsets_with_dups and the first-queen-in-the-left-half trick are both instances.
  6. 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.
  7. 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

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

References