0Pricing
Coding Interview Prep · Lekcja

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 True

Najprostszy 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), path

Memoizacja 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 True

Szybkie 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

  1. Szablon backtrackingu: wybierz, zbadaj, cofnij wybór
  2. Podzbiory i zbiór potęgowy
  3. Permutacje i kombinacje
  4. Problem N hetmanów i propagacja ograniczeń
← Powrót do Coding Interview Prep