Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
Реализуйте трёхшаговый каркас метода перебора с возвратом, проследите его работу на небольшом примере и определите, где добавляются условия отсечения
«Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.
Что такое поиск с возвратом?
Поиск с возвратом — это систематический метод поиска всех (или некоторых) решений: он последовательно исследует каждого кандидата и отбрасывает (отсекает) ветвь, как только становится ясно, что она не может привести к допустимому решению. Это алгоритм, лежащий в основе решения судоку, генерации перестановок и поиска всех допустимых комбинаций. Представляйте его как поиск в глубину по дереву решений.
# 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')Шаблон из трёх шагов
Каждая функция поиска с возвратом выполняет три шага: Выбрать — выбрать следующего кандидата из доступных вариантов. Исследовать — выполнить рекурсивный вызов с этим выбором и перейти на один уровень глубже в дереве решений. Отменить выбор — после возврата из рекурсии отменить выбор, чтобы восстановить состояние для следующего кандидата. В разных контекстах этот шаблон также называют добавить/рекурсивно вызвать/удалить или пометить/рекурсивно вызвать/снять пометку.
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Простейший пример: все подмножества
Сгенерируем все подмножества [1, 2, 3]. На каждом индексе мы выбираем: включить элемент или исключить его. После каждого вызова индекс начала увеличивается, поэтому предыдущие элементы не рассматриваются повторно. Проверка ограничений не нужна: каждое частичное состояние допустимо. В результате получаются 2ⁿ подмножеств. Шаг отмены выбора — это path.pop() после рекурсивного вызова.
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]]Определение условия отсечения
Преимущество поиска с возвратом перед полным перебором заключается в отсечении: необходимо заранее распознать, что частичный путь не может привести к допустимому решению. В задаче о сумме комбинаций (целевая сумма с ограничением), как только текущая сумма превышает цель, любая более глубокая ветвь будет только увеличиваться — отсеките её немедленным возвратом. В задаче о N ферзях, если ферзь атакует уже размещённых ферзей, пропустите этот столбец. Отсечение превращает экспоненциальные деревья в выполнимые поисковые задачи.
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]]Критическая важность восстановления состояния
Распространённая ошибка в поиске с возвратом — неполное восстановление состояния перед следующей итерацией. Если используется изменяемая структура данных (список, множество, сетка), каждое изменение, выполненное на шаге Выбрать, должно быть отменено на шаге Отменить выбор. Например, при изменении сетки (как в судоку или поиске слова) после рекурсивного вызова верните ячейке пустое значение. Если забыть об этом, состояние останется повреждённым для соседних ветвей.
# 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Трассировка дерева решений
Для задачи о сумме комбинаций с [2, 3, 6, 7] и целью 7 проследим дерево: в корне попробуем 2. После 2 снова попробуем 2 (remaining=3). После 2+2 снова попробуем 2 (remaining=1). Условие 2>1 выполняется, поэтому ветвь отсекается. Попробуем 3: условие 3>1 выполняется, ветвь отсекается. Вернёмся назад. После 2+2 попробуем 3 (remaining=3). 3 совпадает с оставшимся значением: запишем [2,2,3]. Вернёмся назад и продолжим. Эта трассировка показывает, как отсечение устраняет ветви ещё до того, как они приведут к недопустимым результатам.
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)Поиск с возвратом и полный перебор
Полный перебор строит все возможные полные решения, а затем проверяет каждое из них. Поиск с возвратом выполняет отсечение во время построения и никогда не завершает недопустимые пути. Для задачи о N ферзях при N=8 полный перебор проверяет 8^8 = 16 миллионов размещений. Поиск с возвратом сокращает их число примерно до 2 057 рекурсивных вызовов. С ростом N разница становится огромной: при N=12 полный перебор проверяет 8,9 миллиарда размещений, тогда как поиск с возвратом исследует лишь небольшую часть дерева.
# 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]}')Сбор решений и ранний возврат
Задачи на поиск с возвратом делятся на две категории: перечислить все решения (собрать каждый полный путь) или найти любое одно решение (вернуть True, как только путь приводит к успеху). При перечислении всегда добавляйте найденные решения в список результатов. При поиске любого решения немедленно возвращайте True из рекурсивного вызова и передавайте это значение вверх по цепочке. Возврат any(backtrack(...)) или конструкция if backtrack(...): return True реализуют поведение с коротким замыканием.
# 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Мемоизация в поиске с возвратом
Чистый поиск с возвратом исследует каждый путь без кэширования, что подходит, когда нужны все решения. Однако в некоторых задачах поиска с возвратом встречаются перекрывающиеся подзадачи. Например, задачу «Разбиение слов II» можно решить с помощью поиска с возвратом и мемоизации: кэшируйте список предложений, возможных для каждого начального индекса. Это превращает поиск с возвратом с экспоненциальной сложностью в алгоритм с полиномиальным временем работы. Распознавайте повторяющиеся подзадачи, чтобы применять такой комбинированный подход.
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']Временная сложность поиска с возвратом
Временная сложность поиска с возвратом зависит от числа листьев в дереве решений, умноженного на объём работы в одной вершине. Для подмножеств: O(n × 2ⁿ). Для перестановок: O(n × n!). Для задачи о сумме комбинаций: O(target/min_candidate ^ n) в худшем случае. Отсечение уменьшает константный множитель, но не асимптотическую оценку. Если на собеседовании Вас спрашивают о сложности, назовите размер дерева в худшем случае и упомяните, что на практике отсечение обычно значительно ускоряет работу.
# 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')Распознавание задач на поиск с возвратом
Признаки того, что задаче нужен поиск с возвратом: (1) найти все или сгенерировать все комбинации, перестановки или подмножества; (2) задача включает размещение предметов или людей с соблюдением ограничений (N ферзей, судоку); (3) пространство решений экспоненциально, но ограничения рано устраняют большинство ветвей; (4) необходимо исследовать пути в графе или сетке, где состояния могут посещаться повторно. Увидев такие признаки, применяйте шаблон «выбрать — исследовать — отменить выбор».
# 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Быстрая проверка
Проверьте понимание концепций «Структуры данных — подготовка к техническому собеседованию» из этого урока.
Итоги урока
В этом уроке Вы узнали: шаблон поиска с возвратом состоит из трёх шагов — выбрать, исследовать, отменить выбор, которым соответствуют добавление выбора, рекурсивный вызов и его удаление, условия отсечения рано устраняют ветви и именно они делают поиск с возвратом практически применимым по сравнению с полным перебором, а состояние необходимо полностью восстанавливать после каждого рекурсивного вызова, чтобы не повредить соседние ветви. Далее мы применим этот шаблон для генерации всех подмножеств и булеана.
Часто задаваемые вопросы
Урок «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» бесплатный?
Да — полный текст урока «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор»?
Реализуйте трёхшаговый каркас метода перебора с возвратом, проследите его работу на небольшом примере и определите, где добавляются условия отсечения Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать DSA Interview Prep?
Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.
Сколько времени занимает урок «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке DSA Interview Prep?
Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
- Подмножества и множество всех подмножеств
- Перестановки и сочетания
- Задача о N ферзях и распространение ограничений