0Pricing
Coding Interview Prep · Aula

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 True

Exemplo 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'))  # True

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

Memoizaçã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 True

Verificaçã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

  1. Modelo de Retrocesso: Escolher, Explorar, Desfazer
  2. Subconjuntos e conjunto das partes
  3. Permutações e combinações
  4. N-rainhas e propagação de restrições
← Voltar para Coding Interview Prep