Szablon backtrackingu: wybierz, zbadaj, cofnij wybór
Zaimplementować trójetapowy szkielet przeszukiwania z nawrotami, prześledzić jego działanie na małym przykładzie i wskazać, gdzie umieścić warunki przycinania
Szablon backtrackingu: wybierz, zbadaj, cofnij wybór to bezpłatna lekcja Coding Interview Prep na CoddyKit. To lekcja 1 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej Coding Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.
Czym jest backtracking?
Backtracking (przeszukiwanie z nawrotami) to systematyczna metoda znajdowania wszystkich (lub niektórych) rozwiązań przez stopniowe sprawdzanie każdego kandydata i porzucanie (czyli przycinanie) gałęzi, gdy tylko okaże się, że nie może ona doprowadzić do poprawnego rozwiązania. Jest to algorytm wykorzystywany między innymi do rozwiązywania Sudoku, generowania permutacji i znajdowania wszystkich poprawnych kombinacji. Można go postrzegać jako przeszukiwanie w głąb drzewa decyzyjnego.
# Mental model: backtracking explores a decision tree
# At each node you make a choice, go deeper, then undo it
#
# Tree for generating subsets of [1,2,3]:
# []
# / \
# [1] []
# / \ / \
# [1,2][1][2] []
# ...
# Every leaf is a potential solution
# Pruning cuts branches early based on constraints
print('Backtracking = DFS on decision tree with pruning')Szablon składający się z trzech kroków
Każda funkcja implementująca backtracking wykonuje trzy kroki: Wybór — wybranie następnego kandydata spośród dostępnych opcji. Eksploracja — wykonanie wywołania rekurencyjnego z dokonanym wyborem i przejście o jeden poziom głębiej w drzewie decyzyjnym. Cofnięcie wyboru — wycofanie wyboru po powrocie z rekurencji w celu przywrócenia stanu przed przejściem do następnego kandydata. W zależności od kontekstu wzorzec ten nazywa się również dodaj/wywołaj rekurencję/usuń lub oznacz/wywołaj rekurencję/cofnij oznaczenie.
def backtrack(current_state, choices, results):
# Base case: is current_state a complete solution?
if is_complete(current_state):
results.append(list(current_state)) # record solution
return
for choice in choices:
if is_valid(choice, current_state): # pruning condition
# 1. CHOOSE
current_state.append(choice)
# 2. EXPLORE
backtrack(current_state, choices, results)
# 3. UNCHOOSE (backtrack)
current_state.pop()
# Placeholder functions — filled per problem
def is_complete(state): return True
def is_valid(choice, state): return TrueNajprostszy przykład: wszystkie podzbiory
Celem jest wygenerowanie wszystkich podzbiorów [1, 2, 3]. Dla każdego indeksu podejmuje się decyzję o uwzględnieniu lub pominięciu elementu. Indeks początkowy zwiększa się po każdym wywołaniu, aby nie wracać do poprzednich elementów. Nie jest potrzebne sprawdzanie ograniczeń — każdy stan częściowy jest poprawny. W ten sposób powstaje 2ⁿ podzbiorów. Cofnięcie wyboru polega na wykonaniu path.pop() po wywołaniu rekurencyjnym.
def subsets(nums):
result = []
def backtrack(start, path):
result.append(list(path)) # every state is a valid subset
for i in range(start, len(nums)):
path.append(nums[i]) # CHOOSE
backtrack(i + 1, path) # EXPLORE
path.pop() # UNCHOOSE
backtrack(0, [])
return result
print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]Określanie warunku przycinania
Siła backtrackingu w porównaniu z brute force polega na przycinaniu: wczesnym rozpoznaniu, że częściowa ścieżka nie może doprowadzić do poprawnego rozwiązania. W przypadku problemu sumy kombinacji (sumy docelowej z ograniczeniem), gdy suma bieżąca przekroczy cel, każda głębsza gałąź będzie tylko większa — należy ją przyciąć, natychmiast wracając. W problemie N hetmanów, jeśli hetman atakuje już umieszczone hetmany, należy pominąć daną kolumnę. Przycinanie zmienia wykładnicze drzewa w przeszukiwania możliwe do wykonania.
def combination_sum(candidates, target):
result = []
candidates.sort() # sort enables early termination
def backtrack(start, path, remaining):
if remaining == 0:
result.append(list(path))
return
for i in range(start, len(candidates)):
c = candidates[i]
if c > remaining: break # PRUNE: sorted, so rest are bigger too
path.append(c) # CHOOSE
backtrack(i, path, remaining - c) # EXPLORE (reuse allowed)
path.pop() # UNCHOOSE
backtrack(0, [], target)
return result
print(combination_sum([2, 3, 6, 7], 7)) # [[2,2,3],[7]]Kluczowe znaczenie przywracania stanu
Częstym błędem w backtrackingu jest niepełne przywrócenie stanu przed następną iteracją. Jeśli używana jest mutowalna struktura danych (lista, zbiór, siatka), każdą modyfikację wprowadzoną podczas wyboru należy cofnąć podczas cofania wyboru. Na przykład podczas modyfikowania siatki (jak w Sudoku lub Word Search) po wywołaniu rekurencyjnym należy ponownie ustawić komórkę jako pustą. Pominięcie tego kroku pozostawia uszkodzony stan dla sąsiednich gałęzi.
# Bug: forgetting to unmark in word search
# Correct pattern for grid backtracking:
def word_search(board, word):
m, n = len(board), len(board[0])
def dfs(r, c, k):
if k == len(word): return True
if not (0<=r<m and 0<=c<n): return False
if board[r][c] != word[k]: return False
temp, board[r][c] = board[r][c], '#' # CHOOSE (mark visited)
found = any(dfs(r+dr, c+dc, k+1)
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)])
board[r][c] = temp # UNCHOOSE (restore cell)
return found
return any(dfs(r, c, 0) for r in range(m) for c in range(n))
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']]
print(word_search([row[:] for row in board], 'ABCCED')) # TrueŚledzenie drzewa decyzyjnego
Dla problemu sumy kombinacji z [2, 3, 6, 7] i celem 7 drzewo można prześledzić w następujący sposób: w korzeniu należy najpierw wybrać 2. Po wybraniu 2 należy ponownie wybrać 2 (remaining=3). Po wybraniu 2+2 należy wybrać jeszcze raz 2 (remaining=1). Ponieważ 2>1, gałąź zostaje przycięta. Należy wybrać 3: 3>1, więc tę gałąź również należy przyciąć. Następuje cofnięcie. Po wybraniu 2+2 należy wybrać 3 (remaining=3). Wartość 3 odpowiada pozostałej wartości, więc należy zapisać [2,2,3]. Następnie należy cofnąć wybór i kontynuować. Ten ślad pokazuje, jak przycinanie eliminuje gałęzie, zanim doprowadzą one do niepoprawnych wyników.
def combination_sum_trace(candidates, target):
result = []
candidates.sort()
def backtrack(start, path, remaining, depth):
indent = ' ' * depth
print(f'{indent}explore({path}, remaining={remaining})')
if remaining == 0:
result.append(list(path))
print(f'{indent}FOUND: {path}')
return
for i in range(start, len(candidates)):
c = candidates[i]
if c > remaining:
print(f'{indent}PRUNE at {c}')
break
path.append(c)
backtrack(i, path, remaining - c, depth + 1)
path.pop()
backtrack(0, [], target, 0)
return result
combination_sum_trace([2, 3, 6, 7], 7)Backtracking a brute force
Brute force sprawdza wszystkie możliwe kompletne rozwiązania, a następnie weryfikuje każde z nich. Backtracking przycina gałęzie w trakcie budowania rozwiązania, nigdy nie kończąc niepoprawnych ścieżek. Dla problemu N hetmanów przy N=8 brute force sprawdza 8^8 = 16 milionów rozmieszczeń. Backtracking zmniejsza tę liczbę do około 2 057 wywołań rekurencyjnych. Różnica rośnie gwałtownie wraz z N: dla N=12 brute force sprawdza 8,9 miliarda rozmieszczeń, podczas gdy backtracking przeszukuje tylko ułamek drzewa.
# Compare call counts: brute force vs backtracking for permutations
import sys
calls_brute = [0]
calls_back = [0]
def brute_force_perms(nums):
from itertools import permutations
return list(permutations(nums))
def backtrack_perms(nums):
result = []
used = [False] * len(nums)
def bt(path):
calls_back[0] += 1
if len(path) == len(nums):
result.append(list(path))
return
for i, n in enumerate(nums):
if not used[i]:
used[i] = True
path.append(n)
bt(path)
path.pop()
used[i] = False
bt([])
return result
backtrack_perms([1,2,3,4])
print(f'Backtrack calls for 4 items: {calls_back[0]}')Zbieranie wyników a wczesny zwrot
Problemy backtrackingowe dzielą się na dwie kategorie: wyliczanie wszystkich rozwiązań (gromadzenie każdej kompletnej ścieżki) lub znalezienie dowolnego jednego rozwiązania (zwrócenie wartości True natychmiast po pomyślnym zakończeniu ścieżki). W przypadku wyliczania rozwiązań należy zawsze dodawać wynik do listy wyników. W przypadku wyszukiwania dowolnego rozwiązania należy natychmiast zwracać True z wywołania rekurencyjnego i przekazywać tę wartość w górę. Konstrukcja any(backtrack(...)) lub if backtrack(...): return True implementuje mechanizm krótkiego spięcia.
# Enumerate all: collect in results list
def all_solutions(candidates):
results = []
def bt(path, remaining):
if remaining == 0:
results.append(list(path))
return
for c in candidates:
if c <= remaining:
path.append(c); bt(path, remaining - c); path.pop()
bt([], 5)
return results
# Find any one: return True on first success
def any_solution(candidates, target):
def bt(path, remaining):
if remaining == 0: return True
for c in candidates:
if c <= remaining:
path.append(c)
if bt(path, remaining - c): return True # short-circuit
path.pop()
return False
path = []
return bt(path, target), pathMemoizacja w backtrackingu
Czysty backtracking przeszukuje każdą ścieżkę bez buforowania wyników, co jest odpowiednie, gdy potrzebne są wszystkie rozwiązania. Niektóre problemy backtrackingowe zawierają jednak nakładające się podproblemy. Na przykład Word Break II można rozwiązać za pomocą backtrackingu i memoizacji: należy buforować listę zdań możliwych do utworzenia od każdego indeksu początkowego. Zmienia to wykładniczy w najgorszym przypadku backtracking w algorytm o czasie wielomianowym. Aby zastosować to połączenie, należy rozpoznawać powtarzające się podproblemy.
from functools import lru_cache
def word_break_all(s, wordDict):
words = set(wordDict)
@lru_cache(maxsize=None)
def bt(start):
if start == len(s): return [''] # empty suffix
result = []
for end in range(start + 1, len(s) + 1):
word = s[start:end]
if word in words:
for rest in bt(end):
result.append(word if not rest else word + ' ' + rest)
return result
return bt(0)
print(word_break_all('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']Złożoność czasowa backtrackingu
Złożoność czasowa backtrackingu zależy od liczby liści w drzewie decyzyjnym pomnożonej przez pracę wykonywaną w każdym węźle. Dla podzbiorów wynosi O(n × 2ⁿ). Dla permutacji wynosi O(n × n!). Dla sumy kombinacji w najgorszym przypadku wynosi O(target/min_candidate ^ n). Przycinanie zmniejsza stałą, ale nie zmienia granicy asymptotycznej. Podczas rozmowy rekrutacyjnej dotyczącej złożoności należy podać rozmiar drzewa w najgorszym przypadku i wspomnieć, że przycinanie zazwyczaj znacznie przyspiesza działanie w praktyce.
# Complexity quick reference:
# Subsets of n elements: O(n * 2^n) - 2^n subsets, each copied in O(n)
# Permutations of n: O(n * n!) - n! perms, each copied in O(n)
# Combination sum (target T): O(T^n / n!) worst case without pruning
# N-Queens: O(n!) - prune reduces practical count
# For n=10 permutations: 10! = 3,628,800 paths
import math
n = 10
print(f'n={n}: n!={math.factorial(n):,} paths')
print(f'n={n}: 2^n={2**n:,} subsets')Rozpoznawanie problemów backtrackingowych
Na potrzebę użycia backtrackingu wskazują następujące sygnały: (1) polecenie znalezienia wszystkich lub wygenerowania wszystkich kombinacji, permutacji albo podzbiorów; (2) problem polega na rozmieszczaniu elementów lub osób z zachowaniem ograniczeń (N hetmanów, Sudoku); (3) przestrzeń rozwiązań jest wykładnicza, ale ograniczenia wcześnie eliminują większość gałęzi; (4) potrzebna jest eksploracja ścieżek w grafie lub siatce, które mogą ponownie odwiedzać te same stany. Po rozpoznaniu tych sygnałów należy sięgnąć po schemat wyboru–eksploracji–cofnięcia wyboru.
# Common backtracking problem types:
# 1. Subsets / Power set
# 2. Permutations (with/without duplicates)
# 3. Combinations (k from n, combination sum)
# 4. Grid path finding (word search, unique paths with visited tracking)
# 5. Constraint satisfaction (N-queens, Sudoku solver)
# 6. String partitioning (palindrome partition, word break all)
# Template reminder:
def backtrack(start, path):
# base case: add to results or return True
for choice in get_choices(start):
if is_valid(choice, path): # prune
path.append(choice) # choose
backtrack(start+1, path) # explore
path.pop() # unchoose
def get_choices(start): return []
def is_valid(c, p): return TrueSzybkie sprawdzenie
Proszę sprawdzić swoją wiedzę na temat zagadnień Data Structures & Algorithms — Coding Interview Prep z tej lekcji.
Podsumowanie lekcji
W tej lekcji poznano: szablon backtrackingu składa się z trzech kroków — wyboru, eksploracji i cofnięcia wyboru, które odpowiadają dodaniu wyboru, wywołaniu rekurencji i usunięciu go, warunki przycinania wcześnie eliminują gałęzie i sprawiają, że backtracking jest praktyczny w porównaniu z brute force oraz stan musi być w pełni przywracany po każdym wywołaniu rekurencyjnym, aby nie uszkodzić sąsiednich gałęzi. Następnie zastosujemy ten schemat do generowania wszystkich podzbiorów i zbioru potęgowego.
Często zadawane pytania
Czy lekcja „Szablon backtrackingu: wybierz, zbadaj, cofnij wybór” jest bezpłatna?
Tak — pełny tekst „Szablon backtrackingu: wybierz, zbadaj, cofnij wybór” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu Coding Interview Prep, przejdź na CoddyKit PRO. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.
Co nauczysz się w „Szablon backtrackingu: wybierz, zbadaj, cofnij wybór”?
Zaimplementować trójetapowy szkielet przeszukiwania z nawrotami, prześledzić jego działanie na małym przykładzie i wskazać, gdzie umieścić warunki przycinania Ćwiczysz Coding Interview Prep z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.
Czy potrzebuję doświadczenia, aby zacząć Coding Interview Prep?
Nie wymagamy żadnego doświadczenia. Coding Interview Prep w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 1 z 4.
Ile czasu zajmuje lekcja „Szablon backtrackingu: wybierz, zbadaj, cofnij wybór”?
Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.
Czy mogę pisać i uruchamiać kod w tej lekcji Coding Interview Prep?
Tak. Każda lekcja Coding Interview Prep zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.
Wszystkie lekcje w tym kursie
- Szablon backtrackingu: wybierz, zbadaj, cofnij wybór
- Podzbiory i zbiór potęgowy
- Permutacje i kombinacje
- Problem N hetmanów i propagacja ograniczeń