Modelo de Retrocesso: Escolher, Explorar, Desfazer
Implemente o esqueleto de retrocesso em três etapas, percorra-o com um exemplo pequeno e identifique onde inserir as condições de poda.
Modelo de Retrocesso: Escolher, Explorar, Desfazer é uma aula grátis de Coding Interview Prep no CoddyKit. Esta é a aula 1 de 4. Você pode ler a aula completa abaixo gratuitamente — depois pratica ao vivo no navegador com um editor de código integrado e um tutor de IA 24/7. Faz parte do caminho de aprendizado de Coding Interview Prep, e seu progresso é sincronizado entre a web e o app CoddyKit. O curso de Coding Interview Prep inclui 4 aulas no total.
O que é retrocesso?
Retrocesso é um método sistemático para encontrar todas as soluções (ou algumas delas), explorando cada candidata gradualmente e abandonando (podando) um ramo assim que se determina que ele não pode produzir uma solução válida. É o algoritmo usado para resolver Sudoku, gerar permutações e encontrar todas as combinações válidas. Pense nele como uma busca em profundidade em uma árvore de decisões.
# 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')O modelo de três etapas
Toda função de retrocesso segue três etapas: Escolher — selecione a próxima candidata entre as opções disponíveis. Explorar — faça uma chamada recursiva com essa escolha, avançando um nível na árvore de decisões. Desfazer — desfaça a escolha depois de retornar da recursão, restaurando o estado para a próxima candidata. Esse padrão também é chamado de adicionar/recursão/remover ou marcar/recursão/desmarcar, dependendo do contexto.
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 TrueExemplo mais simples: todos os subconjuntos
Gere todos os subconjuntos de [1, 2, 3]. Em cada índice, escolha incluir ou excluir o elemento. O índice inicial avança depois de cada chamada, para que não revisitemos elementos anteriores. Nenhuma verificação de restrição é necessária — todo estado parcial é válido. Isso produz 2ⁿ subconjuntos. A etapa de desfazer é path.pop() depois da chamada recursiva.
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]]Identificando a condição de poda
O poder do retrocesso em relação à força bruta está na poda: reconhecer antecipadamente que um caminho parcial não pode levar a uma solução válida. Para a soma de combinações (soma-alvo com um limite), assim que a soma corrente exceder o alvo, qualquer ramo mais profundo ficará apenas maior — faça a poda retornando imediatamente. Para N-rainhas, se uma rainha atacar rainhas já existentes, ignore essa coluna. A poda transforma árvores exponenciais em buscas gerenciáveis.
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]]A restauração do estado é essencial
Um erro comum no retrocesso é não restaurar completamente o estado antes da próxima iteração. Se você usar uma estrutura de dados mutável (lista, conjunto, grade), toda modificação feita durante Escolher deverá ser desfeita em Desfazer. Por exemplo, ao modificar uma grade (como em Sudoku ou na busca de palavras), defina a célula como vazia depois da chamada recursiva. Esquecer disso deixa o estado corrompido para os ramos irmãos.
# 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')) # TrueRastreando a árvore de decisões
Para a soma de combinações com [2, 3, 6, 7] e alvo 7, percorra a árvore: na raiz, tente 2. A partir de 2, tente 2 novamente (restante=3). A partir de 2+2, tente 2 novamente (restante=1). Como 2>1, faça a poda. Tente 3: como 3>1, faça a poda. Faça o retrocesso. A partir de 2+2, tente 3 (restante=3). Como 3 corresponde ao restante, registre [2,2,3]. Faça o retrocesso e continue. Esse percurso mostra como a poda elimina ramos antes que eles produzam resultados inválidos.
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)Retrocesso versus força bruta
A força bruta tenta todas as soluções completas possíveis e depois valida cada uma. O retrocesso faz a poda durante a construção, sem nunca completar caminhos inválidos. Para N=8 no problema das N-rainhas, a força bruta verifica 8^8 = 16 milhões de posicionamentos. O retrocesso reduz isso a cerca de 2.057 chamadas recursivas. A diferença cresce muito com N: para N=12, a força bruta tenta 8,9 bilhões de posicionamentos, enquanto o retrocesso explora apenas uma fração da árvore.
# 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]}')Coletar versus retornar imediatamente
Os problemas de retrocesso se dividem em duas categorias: enumerar todas as soluções (coletar cada caminho completo) ou encontrar uma única solução (retornar True assim que um caminho tiver sucesso). Para a enumeração, sempre anexe o resultado a uma lista de resultados. Para encontrar qualquer solução, retorne True imediatamente da chamada recursiva e propague esse valor para cima. Retornar any(backtrack(...)) ou if backtrack(...): return True implementa o comportamento de curto-circuito.
# 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), pathMemoização com retrocesso
O retrocesso puro explora todos os caminhos sem armazenar resultados, o que é adequado quando todas as soluções são necessárias. No entanto, alguns problemas de retrocesso têm subproblemas sobrepostos. Por exemplo, a Quebra de palavras II pode ser resolvida com retrocesso e memoização: armazene em cache a lista de frases possíveis a partir de cada índice inicial. Isso transforma o retrocesso exponencial no pior caso em um algoritmo de tempo polinomial. Reconheça quando os subproblemas se repetem para aplicar essa combinação.
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']Complexidade de tempo do retrocesso
A complexidade de tempo do retrocesso depende do número de folhas da árvore de decisões multiplicado pelo trabalho por nó. Para subconjuntos: O(n × 2ⁿ). Para permutações: O(n × n!). Para a soma de combinações: O(alvo/candidato_mínimo ^ n) no pior caso. A poda reduz a constante, mas não o limite assintótico. Quando lhe pedirem a complexidade em uma entrevista, informe o tamanho da árvore no pior caso e mencione que a poda normalmente torna o algoritmo muito mais rápido na prática.
# 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')Identificando problemas de retrocesso
Sinais de que um problema precisa de retrocesso: (1) encontrar todos ou gerar todas as combinações, permutações ou subconjuntos; (2) o problema envolve posicionar itens ou pessoas sob restrições (N-rainhas, Sudoku); (3) o espaço de soluções é exponencial, mas as restrições eliminam a maioria dos ramos logo no início; (4) é necessário explorar caminhos em um grafo ou uma grade que podem revisitar estados. Ao identificar esses sinais, recorra ao modelo escolher-explorar-desfazer.
# 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 TrueVerificação rápida
Teste seus conhecimentos sobre os conceitos de Estruturas de Dados & Algoritmos — Preparação para Entrevistas de Programação desta lição.
Recapitulação da lição
Nesta lição, você aprendeu: o modelo de retrocesso tem três etapas — escolher, explorar e desfazer — que correspondem a adicionar uma escolha, fazer uma chamada recursiva e removê-la, as condições de poda eliminam ramos antecipadamente e são o que torna o retrocesso viável em comparação com a força bruta e o estado deve ser totalmente restaurado depois de cada chamada recursiva para evitar a corrupção dos ramos irmãos. Em seguida, aplicaremos o modelo para gerar todos os subconjuntos e o conjunto das partes.
Perguntas Frequentes
A aula “Modelo de Retrocesso: Escolher, Explorar, Desfazer” é grátis?
Sim — o texto completo de “Modelo de Retrocesso: Escolher, Explorar, Desfazer” é grátis para ler aqui na web. Para praticá-la interativamente (um editor de código integrado e um tutor de IA 24/7) e desbloquear o restante do curso de Coding Interview Prep, atualize para CoddyKit PRO. O curso de Coding Interview Prep inclui 4 aulas no total.
O que vou aprender em “Modelo de Retrocesso: Escolher, Explorar, Desfazer”?
Implemente o esqueleto de retrocesso em três etapas, percorra-o com um exemplo pequeno e identifique onde inserir as condições de poda. Você pratica Coding Interview Prep com código prático que executa diretamente no navegador, e um tutor de IA 24/7 responde suas dúvidas enquanto trabalha na aula.
Preciso ter experiência prévia para começar Coding Interview Prep?
Nenhuma experiência prévia é necessária. Coding Interview Prep no CoddyKit é estruturado para alunos iniciantes até avançados, então você pode começar aqui ou desde o início e aprender no seu ritmo. Esta é a aula 1 de 4.
Quanto tempo leva a aula “Modelo de Retrocesso: Escolher, Explorar, Desfazer”?
A maioria das aulas CoddyKit leva cerca de 5–10 minutos. Cada uma é compacta e interativa, então você faz progresso constante e retoma exatamente de onde parou entre web e app.
Posso escrever e executar código nesta aula de Coding Interview Prep?
Sim. Cada aula de Coding Interview Prep inclui um editor de código integrado, então você escreve e executa código real direto no navegador e recebe feedback de IA instantaneamente — nenhuma configuração local necessária.
Todas as aulas deste curso
- Modelo de Retrocesso: Escolher, Explorar, Desfazer
- Subconjuntos e conjunto das partes
- Permutações e combinações
- N-rainhas e propagação de restrições