0Pricing
Coding Interview Prep · Урок

Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор

Реализуйте трёхшаговый каркас метода перебора с возвратом, проследите его работу на небольшом примере и определите, где добавляются условия отсечения

«Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding 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

Быстрая проверка

Проверьте понимание концепций «Структуры данных &mdash; подготовка к техническому собеседованию» из этого урока.

Итоги урока

В этом уроке Вы узнали: шаблон поиска с возвратом состоит из трёх шагов — выбрать, исследовать, отменить выбор, которым соответствуют добавление выбора, рекурсивный вызов и его удаление, условия отсечения рано устраняют ветви и именно они делают поиск с возвратом практически применимым по сравнению с полным перебором, а состояние необходимо полностью восстанавливать после каждого рекурсивного вызова, чтобы не повредить соседние ветви. Далее мы применим этот шаблон для генерации всех подмножеств и булеана.

Часто задаваемые вопросы

Урок «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» бесплатный?

Да — полный текст урока «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор»?

Реализуйте трёхшаговый каркас метода перебора с возвратом, проследите его работу на небольшом примере и определите, где добавляются условия отсечения Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать Coding Interview Prep?

Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.

Сколько времени занимает урок «Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке Coding Interview Prep?

Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
  2. Подмножества и множество всех подмножеств
  3. Перестановки и сочетания
  4. Задача о N ферзях и распространение ограничений
← Назад к Coding Interview Prep