Pattern then chốt cho bài "liệt kê mọi khả năng". Một template duy nhất — "chọn — đệ quy — bỏ chọn" — giải được Permutations, Combinations, Subsets, N-Queens, Sudoku.
Cây quyết định cho Permutations của [1,2,3] — mỗi nhánh là một lựa chọn, đệ quy đến lá
Backtracking khác DFS thường ở chỗ "undo" — sau khi thử nhánh trái, khôi phục trạng thái rồi thử nhánh phải.
def backtrack(state, path):
if is_solution(state):
result.append(path[:]) # COPY path
return
for choice in get_choices(state):
if not is_valid(choice, state):
continue # PRUNING
# CHOOSE
path.append(choice)
update_state(state, choice)
# RECURSE
backtrack(state, path)
# UNCHOOSE — KHÔI PHỤC
path.pop()
revert_state(state, choice)
append/add phải có pop/remove tương ứng sau khi đệ quy.
Quên = bug khó tìm nhất trong backtracking.
| Cụm từ trong đề | Pattern |
|---|---|
| "liệt kê tất cả", "all permutations" | Backtracking permutations |
| "all combinations", "combination sum" | Backtracking combinations |
| "all subsets", "power set" | Backtracking subsets |
| "có thể đặt được không", "valid arrangement" | Constraint satisfaction (N-Queens, Sudoku) |
| "tìm đường thoả điều kiện trên grid" | Grid backtracking (Word Search) |
| "phân chia chuỗi", "partition" | Partition backtracking (Palindrome Partitioning) |
| "constraint n ≤ 20" | Khả năng cao là backtracking O(2ⁿ) hoặc O(n!) |
Khi đề có constraint nhỏ bất thường → đó là dấu hiệu cho phép thuật toán chậm.
Backtracking là DFS trên cây quyết định. Time complexity = (số node) × (chi phí mỗi node).
| Bài toán | Số nghiệm (lá cây) | Time |
|---|---|---|
| Subsets của n phần tử | 2ⁿ | O(n × 2ⁿ) |
| Permutations của n phần tử | n! | O(n × n!) |
| Combinations C(n, k) | C(n, k) | O(k × C(n, k)) |
| N-Queens với n × n board | ≤ n! | O(n!) với pruning mạnh |
| Sudoku 9 × 9 | 1 | Worst case mũ; thực tế < 1s nhờ pruning |
Yếu tố "n × ..." trong time là chi phí copy path khi tìm thấy solution. Nếu chỉ đếm (không liệt kê), có thể giảm xuống.
nums. Liệt kê tất cả 2ⁿ subsets.
Khoá học khuyến nghị approach 2 — tổng quát hơn (dùng cho Combinations, Combination Sum).
def subsets(nums):
res = []
def backtrack(start, path):
res.append(path[:]) # subset hiện tại là 1 nghiệm
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path) # i+1 để không lấy lại
path.pop()
backtrack(0, [])
return res
nums = [1, 2, 3]. Kỳ vọng 8 subsets.
| Bước | start | path khi enter | Hành động |
|---|---|---|---|
| 1 | 0 | [] | Add [] |
| 2 | 1 | [1] | Add [1] |
| 3 | 2 | [1,2] | Add [1,2] |
| 4 | 3 | [1,2,3] | Add [1,2,3]; loop kết thúc; pop 3 |
| 5 | 3 | [1,2] | Loop kết thúc; pop 2 |
| 6 | 2 | [1,3] | Add [1,3]; pop 3 → [1]; pop 1 → [] |
| 7 | 1 | [2] | Add [2] |
| 8 | 2 | [2,3] | Add [2,3]; pop 3, pop 2 |
| 9 | 1 | [3] | Add [3]; pop 3 |
Kết quả: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]. Time O(n × 2ⁿ).
Đề (LeetCode #90): Như #78 nhưng nums có thể trùng. Không cho phép subset trùng.
Ví dụ: [1, 2, 2] → [[], [1], [1,2], [1,2,2], [2], [2,2]].
def subsetsWithDup(nums):
nums.sort() # quan trọng — đẩy duplicate cạnh nhau
res = []
def backtrack(start, path):
res.append(path[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i-1]:
continue # skip duplicate Ở CÙNG level
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return res
i > start chứ không phải i > 0:Permutations quan tâm thứ tự: [1,2] và [2,1] là 2 nghiệm khác nhau. Subsets thì không.
Hệ quả: tại mỗi bước, được chọn từ mọi phần tử chưa dùng, không chỉ phần tử sau index hiện tại.
def permute(nums):
res = []
def backtrack(path, used):
if len(path) == len(nums):
res.append(path[:])
return
for i in range(len(nums)):
if used[i]: continue
used[i] = True
path.append(nums[i])
backtrack(path, used)
path.pop()
used[i] = False # UNCHOOSE
backtrack([], [False] * len(nums))
return res
Time O(n × n!).
Đã có code ở 2.4. Dưới đây là biến thể không cần used array, dùng swap in-place:
def permute(nums):
res = []
def backtrack(start):
if start == len(nums):
res.append(nums[:])
return
for i in range(start, len(nums)):
nums[start], nums[i] = nums[i], nums[start]
backtrack(start + 1)
nums[start], nums[i] = nums[i], nums[start] # undo swap
backtrack(0)
return res
Tiết kiệm space O(n) cho used array. Idiom kinh điển khi viết permutations.
| Approach | Space phụ | Đọc dễ? |
|---|---|---|
| Used array | O(n) | Dễ hơn |
| Swap in-place | O(1) | Khó hơn (phải hiểu swap-undo) |
Trong phỏng vấn, dùng "used array" — rõ ràng hơn. Swap là tối ưu khi cần.
Đề (LeetCode #47): Như #46 nhưng nums có duplicate. Không cho phép permutation trùng.
Ví dụ: [1, 1, 2] → [[1,1,2], [1,2,1], [2,1,1]] (3 thay vì 6).
def permuteUnique(nums):
nums.sort()
res = []
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
res.append(path[:])
return
for i in range(len(nums)):
if used[i]: continue
# Skip duplicate ở cùng level KHI phần tử trước cùng giá trị CHƯA dùng
if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return res
not used[i-1] tinh tế:used[i-1] = False, nghĩa là phần tử trước (cùng giá trị) chưa được dùng → ta đang ở "level" khác → skip để tránh trùng.
def combine(n, k):
res = []
def backtrack(start, path):
if len(path) == k:
res.append(path[:])
return
for i in range(start, n + 1):
path.append(i)
backtrack(i + 1, path)
path.pop()
backtrack(1, [])
return res
Có thể prune khi "không còn đủ phần tử để hoàn thành":
# Số phần tử còn lại = (n - i + 1); cần thêm = (k - len(path))
# Nếu n - i + 1 < k - len(path) → impossible → break
for i in range(start, n - (k - len(path)) + 2):
Pruning này có thể giảm time đáng kể với k nhỏ và n lớn.
Đề: Trả mọi tổ hợp C(n, k) từ [1..n].
Đã có code ở 2.7. Trace với n=4, k=2:
| Bước | start | path | Add? |
|---|---|---|---|
| 1 | 1 | [1] | — |
| 2 | 2 | [1,2] | Add |
| 3 | 2 | [1,3] | Add |
| 4 | 2 | [1,4] | Add |
| 5 | 1 | [2] | — |
| 6 | 3 | [2,3] | Add |
| 7 | 3 | [2,4] | Add |
| 8 | 1 | [3] | — |
| 9 | 4 | [3,4] | Add |
Kết quả: 6 combinations. C(4,2) = 6 ✓.
Đề: Mảng candidates phân biệt, mỗi phần tử có thể dùng nhiều lần. Tìm mọi tổ hợp có tổng = target.
def combinationSum(candidates, target):
res = []
candidates.sort() # cho phép early break
def backtrack(start, path, remain):
if remain == 0:
res.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remain:
break # PRUNING: sort + break
path.append(candidates[i])
backtrack(i, path, remain - candidates[i]) # i (không phải i+1) — cho phép dùng lại
path.pop()
backtrack(0, [], target)
return res
backtrack(i, ...) chứ không phải i + 1: phần tử dùng được nhiều lần.break khi candidates[i] > remain: vì sort tăng dần, các phần tử sau cũng lớn hơn → vô ích tiếp tục.backtrack(i+1); Permutations dùng backtrack với loop từ 0 + check used.i > start, không phải i > 0?i > start đảm bảo điều này.backtrack(i, path, ...) (không phải i+1) có ý nghĩa gì?i+1 sẽ chuyển sang phần tử kế; i giữ nguyên vị trí, cho phép thử candidates[i] tiếp lần nữa.Khác Grid BFS/DFS (Tuần 10) ở chỗ: cần "undo" mark visited để có thể dùng lại ô ở đường đi khác.
def has_path(grid, word):
m, n = len(grid), len(grid[0])
def dfs(r, c, idx):
if idx == len(word): return True
if r < 0 or r >= m or c < 0 or c >= n: return False
if grid[r][c] != word[idx]: return False
# CHOOSE
original = grid[r][c]
grid[r][c] = '#' # mark visited
# RECURSE 4 directions
found = (dfs(r+1, c, idx+1) or dfs(r-1, c, idx+1)
or dfs(r, c+1, idx+1) or dfs(r, c-1, idx+1))
# UNCHOOSE
grid[r][c] = original
return found
for r in range(m):
for c in range(n):
if dfs(r, c, 0): return True
return False
Đây là tinh thần Number of Islands (Tuần 10) cộng thêm undo mark.
Đề: Lưới ký tự + word. Trả True nếu word có thể tạo bằng đường đi liên tiếp 4 hướng (không lặp ô).
Code ở 3.1 chính là lời giải. Phân tích:
grid[r][c] != word[idx] dừng nhánh ngay khi ký tự không khớp.'#' thay set — tiết kiệm O(L) bộ nhớ.Worst case O(m × n × 4^L) với L = len(word). Mỗi ô khởi đầu, mỗi bước có 4 hướng và đi L bước.
Thực tế nhanh hơn nhiều nhờ pruning (đa số nhánh dừng sớm khi ký tự không khớp).
Đề (LeetCode #51): Đặt n quân hậu trên bàn n×n sao cho không có 2 quân nào tấn công nhau (không cùng hàng/cột/đường chéo).
Một nghiệm 4-Queens: hậu ở (0,1), (1,3), (2,0), (3,2)
Đặt từng hàng một (vì mỗi hàng chỉ có 1 hậu). Tại hàng r, thử mỗi cột c:
kiểm tra không xung đột với các hậu đã đặt → đệ quy hàng r+1.
Để check xung đột O(1), dùng 3 set:
cols: cột đã chiếm.diag1: đường chéo "/" (giá trị r + c cùng).diag2: đường chéo "\" (giá trị r - c cùng).def solveNQueens(n):
res = []
cols = set(); d1 = set(); d2 = set()
queens = [-1] * n # queens[r] = c
def backtrack(r):
if r == n:
board = ['.'*c + 'Q' + '.'*(n-c-1) for c in queens]
res.append(board)
return
for c in range(n):
if c in cols or (r+c) in d1 or (r-c) in d2:
continue # PRUNING — xung đột
cols.add(c); d1.add(r+c); d2.add(r-c)
queens[r] = c
backtrack(r + 1)
cols.discard(c); d1.discard(r+c); d2.discard(r-c)
backtrack(0)
return res
Time O(n!) với pruning mạnh. n = 8 → ~92 nghiệm; chạy < 100 ms.
Worst case không pruning: n^n (mỗi hàng thử n cột). Với pruning bằng 3 set:
Tổng số node được thăm thực tế xấp xỉ O(n!) — còn rất nhiều so với n^n.
Không pruning, n=8 sẽ là 8^8 ≈ 16 triệu nhánh. Có pruning, chỉ ~2,000 nhánh được thăm. Đây là minh chứng pruning quyết định tất cả.
Đề: Hoàn thành lưới Sudoku 9×9 hợp lệ. Mỗi hàng/cột/khối 3×3 chứa các chữ số 1-9 không trùng.
def solveSudoku(board):
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
empties = []
for r in range(9):
for c in range(9):
if board[r][c] == '.':
empties.append((r, c))
else:
d = board[r][c]
rows[r].add(d); cols[c].add(d); boxes[(r//3)*3 + c//3].add(d)
def backtrack(idx):
if idx == len(empties): return True
r, c = empties[idx]
b = (r//3)*3 + c//3
for d in '123456789':
if d in rows[r] or d in cols[c] or d in boxes[b]:
continue
rows[r].add(d); cols[c].add(d); boxes[b].add(d)
board[r][c] = d
if backtrack(idx + 1): return True
rows[r].discard(d); cols[c].discard(d); boxes[b].discard(d)
board[r][c] = '.'
return False
backtrack(0)
Time worst case mũ; pruning thực tế giải < 1s.
r+c bằng nhau (constant). Tương tự "\". Lưu các hằng số này trong set → check trùng O(1). Đây là kỹ thuật chuyển 2D constraint thành 1D lookup.Đề (LeetCode #131): Cho string s. Phân chia thành các substring sao cho mỗi substring là palindrome. Trả về mọi cách phân chia.
Ví dụ: "aab" → [["a","a","b"], ["aa","b"]].
def partition(s):
res = []
def is_pal(l, r):
while l < r:
if s[l] != s[r]: return False
l += 1; r -= 1
return True
def backtrack(start, path):
if start == len(s):
res.append(path[:])
return
for end in range(start, len(s)):
if is_pal(start, end):
path.append(s[start:end+1])
backtrack(end + 1, path)
path.pop()
backtrack(0, [])
return res
Time O(n × 2ⁿ) worst case (mọi cách phân + check palindrome).
start, thử mọi end sao cho substring [start..end] thoả điều kiện. Đệ quy với start = end + 1. Pattern xuất hiện ở Restore IP Addresses, Word Break II.
n - i + 1 ≥ k - len(path)).if i > start and nums[i] == nums[i-1]: continue — tránh đếm trùng.Khi backtracking thử cùng (start, remaining) qua nhiều đường khác nhau. Đó là dấu hiệu chuyển sang DP — chủ đề chính của Tuần 12.
i > start đảm bảo không skip phần tử đầu vòng. Permutation phức tạp hơn vì cần check phần tử trùng đã được dùng chưa.| Tín hiệu trong đề | Pattern |
|---|---|
| "all subsets / power set" | Subsets (start = i+1) |
| "all permutations" | Permutations (used array hoặc swap) |
| "all combinations C(n,k)" | Combinations với early stop |
| "combination sum to target" | Combination Sum (backtrack(i,...)) |
| "all paths in grid" | Grid backtracking với undo mark |
| "valid arrangement không xung đột" | Constraint satisfaction (N-Queens) |
| "phân chia chuỗi thoả điều kiện" | Partition (backtrack(start, ...) với end loop) |
| Mảng có duplicate, không cho phép trùng | Sort + skip ở cùng level |
| Cùng state xuất hiện nhiều lần | Chuyển sang DP (Tuần 12) |
backtrack(i, ...) cho phép dùng lại; sort + early break để prune.backtrack(start, ...), end loop.Dynamic Programming — 1D DP, Knapsack, Coin Change
Pattern then chốt cho phỏng vấn senior. Bắt đầu từ memoisation (top-down) qua tabulation (bottom-up). Học các state designs cơ bản: Fibonacci, House Robber, Coin Change, Climbing Stairs, Longest Increasing Subsequence. Tuần 13 sẽ là 2D DP với Edit Distance, LCS, Knapsack.
Nhấn T hoặc Esc để đóng