0Pricing
DSA Interview Prep · Lección

Plantilla de backtracking: elegir, explorar y deshacer

Implemente el esqueleto de retroceso de tres pasos, sígalo en un ejemplo pequeño e identifique dónde encajan las condiciones de poda.

Plantilla de backtracking: elegir, explorar y deshacer es una lección gratuita de DSA Interview Prep en CoddyKit. Esta es la lección 1 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de DSA Interview Prep, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de DSA Interview Prep incluye 4 lecciones en total.

¿Qué es el backtracking?

El backtracking es un método sistemático para encontrar todas (o algunas) las soluciones. Explora cada candidato de forma incremental y abandona (poda) una rama tan pronto como determina que no puede producir una solución válida. Es el algoritmo que se utiliza para resolver Sudoku, generar permutaciones y encontrar todas las combinaciones válidas. Considérelo como una búsqueda en profundidad sobre un árbol de decisiones.

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

La plantilla de tres pasos

Toda función de backtracking sigue tres pasos: Elegir: seleccione el siguiente candidato entre las opciones disponibles. Explorar: realice una llamada recursiva con esa elección y avance un nivel en el árbol de decisiones. Deshacer: revierta la elección después de regresar de la recursión para restaurar el estado antes de probar el siguiente candidato. Este patrón también se denomina añadir/recursión/eliminar o marcar/recursión/desmarcar, según el 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

Ejemplo más sencillo: todos los subconjuntos

Genere todos los subconjuntos de [1, 2, 3]. En cada índice, elegimos incluir o excluir el elemento. El índice inicial avanza después de cada llamada para no volver a visitar los elementos anteriores. No se necesita comprobar ninguna restricción: todo estado parcial es válido. Esto produce 2ⁿ subconjuntos. El paso de deshacer es path.pop() después de la llamada 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]]

Identificación de la condición de poda

La ventaja del backtracking frente a la fuerza bruta reside en la poda: reconocer pronto que un camino parcial no puede conducir a una solución válida. En la suma de combinaciones (suma objetivo con un presupuesto), cuando la suma acumulada supera el objetivo, cualquier rama más profunda solo aumentará; pode la rama regresando inmediatamente. En el problema de las N reinas, si una reina ataca a las reinas existentes, omita esa columna. La poda convierte los árboles exponenciales en búsquedas manejables.

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]]

La restauración del estado es fundamental

Un error común en el backtracking es no restaurar completamente el estado antes de la siguiente iteración. Si utiliza una estructura de datos mutable (lista, conjunto o cuadrícula), toda modificación realizada durante Elegir debe revertirse durante Deshacer. Por ejemplo, al modificar una cuadrícula (como en Sudoku o Word Search), establezca la celda como vacía después de la llamada recursiva. Olvidarlo deja el estado corrupto para las ramas hermanas.

# 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

Seguimiento del árbol de decisiones

Para la suma de combinaciones con [2, 3, 6, 7] y objetivo 7, siga el árbol: en la raíz, pruebe 2. Desde 2, pruebe 2 de nuevo (remaining=3). Desde 2+2, pruebe 2 otra vez (remaining=1). 2>1, así que pode la rama. Pruebe 3: 3>1, pode la rama. Retroceda. Desde 2+2, pruebe 3 (remaining=3). 3 coincide con remaining: registre [2,2,3]. Retroceda y continúe. Este recorrido muestra cómo la poda elimina ramas antes de que produzcan resultados no vá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)

Backtracking frente a fuerza bruta

La fuerza bruta prueba todas las soluciones completas posibles y después valida cada una. El backtracking poda durante la construcción y nunca completa los caminos no válidos. Para N=8 en el problema de las N reinas, la fuerza bruta comprueba 8^8 = 16 millones de colocaciones. El backtracking reduce esta cifra a unas 2.057 llamadas recursivas. La diferencia aumenta drásticamente con N: para N=12, la fuerza bruta prueba 8.900 millones de colocaciones, mientras que el backtracking explora solo una fracción del árbol.

# 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]}')

Recopilar frente a devolver de inmediato

Los problemas de backtracking pertenecen a dos categorías: enumerar todas las soluciones (recopilar cada camino completo) o encontrar una sola solución (devolver True en cuanto un camino tiene éxito). Para enumerar, añada siempre el resultado a una lista de resultados. Para encontrar una cualquiera, devuelva True inmediatamente desde la llamada recursiva y propáguelo hacia arriba. Devolver any(backtrack(...)) o utilizar if backtrack(...): return True implementa el comportamiento de evaluación anticipada.

# 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

Memoización con backtracking

El backtracking puro explora cada camino sin almacenar resultados en caché, lo cual está bien cuando se necesitan todas las soluciones. Sin embargo, algunos problemas de backtracking tienen subproblemas superpuestos. Por ejemplo, Word Break II puede resolverse con backtracking y memoización: almacene en caché la lista de oraciones posibles desde cada índice inicial. Esto convierte el backtracking exponencial en el peor caso en un algoritmo de tiempo polinómico. Reconozca cuándo se repiten los subproblemas para aplicar este enfoque híbrido.

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']

Complejidad temporal del backtracking

La complejidad temporal del backtracking depende del número de hojas del árbol de decisiones multiplicado por el trabajo por nodo. Para los subconjuntos: O(n × 2ⁿ). Para las permutaciones: O(n × n!). Para la suma de combinaciones: O(target/min_candidate ^ n) en el peor caso. La poda reduce la constante, pero no el límite asintótico. Cuando le pregunten por la complejidad en una entrevista, indique el tamaño del árbol en el peor caso y mencione que la poda normalmente lo hace mucho más rápido en la práctica.

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

Identificación de problemas de backtracking

Algunas señales de que un problema necesita backtracking son: (1) encontrar todas o generar todas las combinaciones, permutaciones o subconjuntos; (2) colocar elementos o personas bajo ciertas restricciones (N reinas, Sudoku); (3) un espacio de soluciones exponencial, pero con restricciones que eliminan la mayoría de las ramas al principio; (4) explorar caminos en un grafo o una cuadrícula que podrían volver a visitar estados. Cuando detecte estas señales, recurra a la plantilla Elegir-Explorar-Deshacer.

# 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

Comprobación rápida

Ponga a prueba su comprensión de los conceptos de Data Structures & Algorithms — Coding Interview Prep de esta lección.

Resumen de la lección

En esta lección aprendió que: la plantilla de backtracking tiene tres pasos —elegir, explorar y deshacer—, que corresponden a añadir una elección, realizar una llamada recursiva y eliminarla; las condiciones de poda eliminan ramas al principio y son las que hacen que el backtracking sea práctico frente a la fuerza bruta; y el estado debe restaurarse por completo después de cada llamada recursiva para no corromper las ramas hermanas. A continuación aplicaremos la plantilla para generar todos los subconjuntos y el conjunto potencia.

Preguntas frecuentes

¿La lección «Plantilla de backtracking: elegir, explorar y deshacer» es gratis?

Sí — el texto completo de «Plantilla de backtracking: elegir, explorar y deshacer» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de DSA Interview Prep, actualiza a CoddyKit PRO. El curso de DSA Interview Prep incluye 4 lecciones en total.

¿Qué aprenderé en «Plantilla de backtracking: elegir, explorar y deshacer»?

Implemente el esqueleto de retroceso de tres pasos, sígalo en un ejemplo pequeño e identifique dónde encajan las condiciones de poda. Practicas DSA Interview Prep con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.

¿Necesito experiencia previa para empezar DSA Interview Prep?

No se requiere experiencia previa. DSA Interview Prep en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 1 de 4.

¿Cuánto tiempo toma la lección «Plantilla de backtracking: elegir, explorar y deshacer»?

La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.

¿Puedo escribir y ejecutar código en esta lección de DSA Interview Prep?

Sí. Cada lección de DSA Interview Prep incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.

Todas las lecciones de este curso

  1. Plantilla de backtracking: elegir, explorar y deshacer
  2. Subconjuntos y conjunto potencia
  3. Permutaciones y combinaciones
  4. N reinas y propagación de restricciones
← Volver a DSA Interview Prep