Emtix

Búsqueda Combinatoria y Backtracking

Publicado el 1 de diciembre de 2025

Búsqueda Combinatoria y Optimización de Algoritmos

La búsqueda combinatoria permite resolver problemas complejos mediante exploración sistemática del espacio de soluciones. Aunque computacionalmente costosa, con las técnicas de optimización correctas puede resolver problemas sorprendentemente grandes.


1. Definición: Búsqueda Combinatoria

Búsqueda Combinatoria

Método sistemático para explorar todas las posibles configuraciones dentro de un espacio de búsqueda.

Objetivo: Encontrar soluciones óptimas o enumerar todas las soluciones posibles mediante búsqueda exhaustiva controlada.

Aplicaciones:

  • Problemas NP-completos (TSP, Coloración de grafos)
  • Puzzles lógicos (Sudoku, N-reinas)
  • Scheduling y planificación
  • Optimización combinatoria

Modelo de Solución

Representación como Vector

Una solución se modela como un vector a=(a1,a2,...,an)a = (a_1, a_2, ..., a_n) donde:

  • Cada elemento aia_i se selecciona de un conjunto finito SiS_i
  • El vector representa una configuración completa
  • Ejemplos:
    • Permutación: SiS_i = elementos restantes
    • Subconjunto: SiS_i = {incluir, excluir}
    • Asignación: SiS_i = valores posibles

Tipos de Problemas Combinatorios

TipoDescripciónEjemploNúmero de Configuraciones
PermutacionesOrdenar nn elementosOrdenar [1,2,3]n!n!
SubconjuntosSeleccionar elementosSubconjuntos de {1,2,3}2n2^n
CombinacionesElegir kk de nnElegir 2 de 5(nk)\binom{n}{k}
AsignacionesAsignar valoresColorear grafoknk^n

2. Backtracking: Búsqueda Sistemática

Algoritmo de Backtracking

Backtracking (retroceso) es un procedimiento que construye soluciones incrementalmente y retrocede cuando detecta que la solución parcial no puede completarse.

Estrategia: DFS (Depth-First Search) sobre el árbol implícito de soluciones parciales.

Ventaja sobre BFS: Usa espacio O(h)O(h) (altura) vs O(bh)O(b^h) (anchura), donde bb = factor de ramificación.

Algoritmo Genérico

Estructura general de backtracking
def backtrack(a, k, input_data):
    """
    Algoritmo genérico de backtracking
    
    Args:
        a: solución parcial (vector)
        k: posición actual en el vector
        input_data: datos del problema
    """
    # Caso base: ¿solución completa?
    if is_a_solution(a, k, input_data):
        process_solution(a, k, input_data)
    else:
        # Generar candidatos para la posición k
        candidates = construct_candidates(a, k, input_data)
        
        # Probar cada candidato
        for candidate in candidates:
            a[k] = candidate
            make_move(a, k, input_data)  # Opcional: actualizar estado
            
            # Recursión: extender solución
            backtrack(a, k + 1, input_data)
            
            unmake_move(a, k, input_data)  # Opcional: deshacer cambios
    
    # Backtrack implícito al terminar función

# Componentes a implementar para cada problema:

def is_a_solution(a, k, input_data):
"""¿La solución parcial de k elementos está completa?"""
pass

def construct_candidates(a, k, input_data):
"""Generar candidatos válidos para la posición k"""
pass

def process_solution(a, k, input_data):
"""Manejar la solución completa (imprimir, contar, guardar)"""
pass

def make_move(a, k, input_data):
"""Actualizar estructuras de datos (opcional)"""
pass

def unmake_move(a, k, input_data):
"""Deshacer cambios (opcional)"""
pass

Árbol de Backtracking

Visualización del árbol de búsqueda
Ejemplo: Generar subconjuntos de {1, 2, 3}

                        Nivel 0: []
                        /        \
                  incluir          excluir
                    /                  \
          Nivel 1: [1]                 []
                  /    \              /    \
        Nivel 2: [1,2]  [1]        [2]     []
                /   \   /  \      /  \    /  \
    Nivel 3: [1,2,3][1,2][1,3][1][2,3][2][3][]

Soluciones encontradas (hojas):
  {1,2,3}, {1,2}, {1,3}, {1}, {2,3}, {2}, {3}, {}

Complejidad: O(2^n) nodos, O(n) altura

3. Ejemplo: Generación de Subconjuntos

Problema: Todos los Subconjuntos

Entrada: Conjunto de nn elementos Salida: Todos los 2n2^n subconjuntos posibles

Modelo:

  • Vector booleano aa de tamaño nn
  • a[i]a[i] = true → elemento ii está incluido
  • a[i]a[i] = false → elemento ii está excluido
Generación de subconjuntos con backtracking
class SubsetGenerator:
    def __init__(self, elements):
        self.elements = elements
        self.n = len(elements)
        self.solutions = []

    def generate_all_subsets(self):
        """Generar todos los subconjuntos"""
        a = [False] * self.n
        self.backtrack(a, 0)
        return self.solutions

    def backtrack(self, a, k):
        """Backtracking recursivo"""
        if self.is_a_solution(a, k):
            self.process_solution(a, k)
        else:
            candidates = self.construct_candidates(a, k)

            for candidate in candidates:
                a[k] = candidate
                self.backtrack(a, k + 1)

    def is_a_solution(self, a, k):
        """¿Hemos decidido sobre todos los elementos?"""
        return k == self.n

    def construct_candidates(self, a, k):
        """Candidatos: incluir (True) o excluir (False)"""
        return [True, False]

    def process_solution(self, a, k):
        """Construir y guardar el subconjunto"""
        subset = [self.elements[i] for i in range(self.n) if a[i]]
        self.solutions.append(subset)

# Ejemplo de uso
print("="*60)
print("GENERACIÓN DE SUBCONJUNTOS CON BACKTRACKING")
print("="*60 + "\n")

conjunto = [1, 2, 3]
generator = SubsetGenerator(conjunto)
subconjuntos = generator.generate_all_subsets()

print(f"Conjunto original: {conjunto}")
print(f"Número de subconjuntos: {len(subconjuntos)}")
print(f"\nTodos los subconjuntos:")

for i, subset in enumerate(subconjuntos, 1):
    print(f"  {i:2d}. {{{', '.join(map(str, subset))}}}") if subset else print(f"  {i:2d}. {{}}")

print("\n" + "="*60)
print("ANÁLISIS:")
print("="*60)
print(f"Elementos: n = {len(conjunto)}")
print(f"Subconjuntos: 2^n = 2^{len(conjunto)} = {2**len(conjunto)}")
print(f"Altura del árbol: {len(conjunto)}")
print(f"Factor de ramificación: 2 (incluir/excluir)")
Quiz

¿Por qué el backtracking usa DFS en lugar de BFS?


4. Ejemplo: Problema de las N-Reinas

Problema Clásico

N-Reinas: Colocar nn reinas en un tablero de ajedrez n×nn \times n de modo que ninguna ataque a otra.

Restricciones:

  • No dos reinas en la misma fila
  • No dos reinas en la misma columna
  • No dos reinas en la misma diagonal
Solución del problema de N-Reinas
class NQueens:
    def __init__(self, n):
        self.n = n
        self.solutions = []
        self.solution_count = 0
    
    def solve(self):
        """Resolver N-Reinas con backtracking"""
        a = [-1] * self.n  # a[i] = columna de la reina en fila i
        self.backtrack(a, 0)
        return self.solutions
    
    def backtrack(self, a, k):
        """Colocar reina en fila k"""
        if self.is_a_solution(a, k):
            self.process_solution(a, k)
        else:
            candidates = self.construct_candidates(a, k)
            
            for col in candidates:
                a[k] = col
                self.backtrack(a, k + 1)
    
    def is_a_solution(self, a, k):
        """¿Hemos colocado todas las reinas?"""
        return k == self.n
    
    def construct_candidates(self, a, k):
        """Columnas válidas para la reina en fila k"""
        candidates = []
        
        for col in range(self.n):
            if self.is_safe(a, k, col):
                candidates.append(col)
        
        return candidates
    
    def is_safe(self, a, row, col):
        """¿Es seguro colocar reina en (row, col)?"""
        # Verificar reinas anteriores
        for i in range(row):
            # Misma columna
            if a[i] == col:
                return False
            
            # Diagonal principal (\)
            if abs(row - i) == abs(col - a[i]):
                return False
        
        return True
    
    def process_solution(self, a, k):
        """Guardar solución"""
        self.solution_count += 1
        self.solutions.append(a[:])  # Copia
    
    def print_board(self, solution):
        """Visualizar tablero"""
        for row in range(self.n):
            line = ""
            for col in range(self.n):
                if solution[row] == col:
                    line += "♛ "
                else:
                    line += "· "
            print(line)

# Ejemplo de uso

print("="*60)
print("PROBLEMA DE LAS N-REINAS")
print("="*60 + "\n")

for n in [4, 5, 6, 8]:
queens = NQueens(n)
solutions = queens.solve()

    print(f"\n{n}-Reinas:")
    print(f"  Soluciones encontradas: {len(solutions)}")

    if n <= 6 and solutions:
        print(f"\n  Primera solución para {n}-Reinas:")
        queens.print_board(solutions[0])

print("\n" + "="*60)
print("COMPLEJIDAD:")
print("="*60)
print("""
Teórica (sin poda): O(n^n) - probar todas las posiciones
Con poda inteligente: O(n!) - mucho mejor

- Solo columnas válidas
- Verificación de diagonales

Número de soluciones para N-Reinas:
n=1: 1 n=5: 10 n=9: 352
n=2: 0 n=6: 4 n=10: 724
n=3: 0 n=7: 40 n=11: 2,680
n=4: 2 n=8: 92 n=12: 14,200
""")

Árbol de búsqueda para 4-Reinas (primera solución):

Nivel 0: Reina en fila 0
  ┌─ Col 0: ♛ · · · [Válido]
  │         · · · ·
  │         · · · ·
  │         · · · ·

  └─ Nivel 1: Reina en fila 1
      ┌─ Col 0: ✗ (misma columna)
      ├─ Col 1: ✗ (diagonal)
      ├─ Col 2: ♛ · · · [Válido]
      │         · · ♛ ·
      │         · · · ·
      │         · · · ·

      └─ Nivel 2: Reina en fila 2
          ┌─ Col 0: ✗ (diagonal)
          ├─ Col 1: ✗ (diagonal)
          ├─ Col 2: ✗ (misma columna)
          └─ Col 3: ✗ (diagonal)

          ¡BACKTRACK! Ninguna columna válida

      ├─ Col 3: ♛ · · ·  [Válido]
      │         · · · ♛
      │         · · · ·
      │         · · · ·

      └─ Nivel 2: Reina en fila 2
          ├─ Col 0: ✗ (diagonal)
          ├─ Col 1: ♛ · · ·  [Válido]
          │         · · · ♛
          │         · ♛ · ·
          │         · · · ·

          └─ Nivel 3: Reina en fila 3
              ┌─ Col 0: ✗ (diagonal)
              ├─ Col 1: ✗ (misma columna)
              ├─ Col 2: ♛ · · ·  [¡SOLUCIÓN!]
              │         · · · ♛
              │         · ♛ · ·
              │         ♛ · · ·

Poda efectiva:

  • Sin poda: 444^4 = 256 configuraciones
  • Con poda: ~20 nodos visitados
  • Reducción: 92% del espacio de búsqueda eliminado

5. Poda de Búsqueda (Pruning)

La Técnica Más Importante

Poda (Pruning): Abandonar una dirección de búsqueda cuando se determina que no puede llevar a una solución válida u óptima.

Impacto: Puede reducir el tiempo de ejecución por factores de 100x, 1000x o más.

Tipos de poda:

  • Restricciones de validez (N-Reinas)
  • Límites de optimización (Branch and Bound)
  • Simetrías (TSP)

Estrategias de Poda

Técnicas de Poda Efectivas

1. Verificación Temprana

  • Verificar validez antes de extender
  • Ejemplo: Verificar ataques en N-Reinas

2. Límites de Costo

  • Si costo parcial ≥ mejor solución completa → podar
  • Aplicable en problemas de optimización

3. Ordenamiento Inteligente

  • Probar candidatos más prometedores primero
  • Falla rápido si no hay solución

4. Explotación de Simetrías

  • Eliminar configuraciones equivalentes
  • TSP: fijar primer vértice (ahorra factor nn)

5. Look Ahead

  • Anticipar consecuencias de decisiones
  • Ejemplo: Sudoku sin candidatos restantes

6. Ejemplo Avanzado: Sudoku

Sudoku como Problema de Backtracking

Objetivo: Llenar cuadrícula 9×99 \times 9 con dígitos 1-9 tal que:

  • Cada fila contiene 1-9 exactamente una vez
  • Cada columna contiene 1-9 exactamente una vez
  • Cada bloque 3×33 \times 3 contiene 1-9 exactamente una vez
Solver de Sudoku con poda inteligente
class SudokuSolver:
    def __init__(self, board):
        """
        board: matriz 9x9 con 0 para celdas vacías
        """
        self.board = [row[:] for row in board]
        self.n = 9
        self.solutions = []

    def solve(self):
        """Resolver Sudoku con backtracking optimizado"""
        if self.backtrack():
            self.solutions.append([row[:] for row in self.board])
            return True
        return False

    def backtrack(self):
        """Backtracking con selección inteligente"""
        # Encontrar celda vacía con menos candidatos (MRV)
        cell = self.select_most_restricted_cell()

        if cell is None:
            # No hay celdas vacías → solución completa
            return True

        row, col = cell
        candidates = self.get_candidates(row, col)

        # Look ahead: si no hay candidatos, backtrack
        if not candidates:
            return False

        for digit in candidates:
            self.board[row][col] = digit

            # Look ahead: verificar que no bloqueamos otras celdas
            if self.look_ahead(row, col):
                if self.backtrack():
                    return True

            # Unmake move
            self.board[row][col] = 0

        return False

    def select_most_restricted_cell(self):
        """
        MRV (Minimum Remaining Values):
        Seleccionar celda vacía con menos candidatos
        """
        min_candidates = 10
        best_cell = None

        for i in range(self.n):
            for j in range(self.n):
                if self.board[i][j] == 0:
                    candidates = self.get_candidates(i, j)
                    num_candidates = len(candidates)

                    if num_candidates < min_candidates:
                        min_candidates = num_candidates
                        best_cell = (i, j)

                    # Si encontramos celda sin candidatos, retornar
                    if num_candidates == 0:
                        return best_cell

        return best_cell

    def get_candidates(self, row, col):
        """Obtener dígitos válidos para (row, col)"""
        if self.board[row][col] != 0:
            return []

        candidates = set(range(1, 10))

        # Eliminar dígitos en la fila
        candidates -= set(self.board[row])

        # Eliminar dígitos en la columna
        candidates -= set(self.board[i][col] for i in range(self.n))

        # Eliminar dígitos en el bloque 3x3
        block_row, block_col = 3 * (row // 3), 3 * (col // 3)
        for i in range(block_row, block_row + 3):
            for j in range(block_col, block_col + 3):
                candidates.discard(self.board[i][j])

        return sorted(candidates)

    def look_ahead(self, row, col):
        """
        Verificar que la asignación actual no bloquea otras celdas
        Retorna False si alguna celda vacía queda sin candidatos
        """
        for i in range(self.n):
            for j in range(self.n):
                if self.board[i][j] == 0:
                    if len(self.get_candidates(i, j)) == 0:
                        return False
        return True

    def print_board(self):
        """Imprimir tablero formateado"""
        for i in range(self.n):
            if i % 3 == 0 and i != 0:
                print("-" * 21)

            line = ""
            for j in range(self.n):
                if j % 3 == 0 and j != 0:
                    line += "| "

                digit = self.board[i][j]
                line += str(digit) if digit != 0 else "."
                line += " "

            print(line)

# Ejemplo de uso
print("="*60)
print("SUDOKU SOLVER CON PODA INTELIGENTE")
print("="*60 + "\n")

# Sudoku difícil (requiere muchas decisiones)
sudoku_hard = [
    [0, 0, 0, 0, 0, 0, 6, 8, 0],
    [0, 0, 0, 0, 7, 3, 0, 0, 9],
    [3, 0, 9, 0, 0, 0, 0, 4, 5],
    [4, 9, 0, 0, 0, 0, 0, 0, 0],
    [8, 0, 3, 0, 5, 0, 9, 0, 2],
    [0, 0, 0, 0, 0, 0, 0, 3, 6],
    [9, 6, 0, 0, 0, 0, 3, 0, 8],
    [7, 0, 0, 6, 8, 0, 0, 0, 0],
    [0, 2, 8, 0, 0, 0, 0, 0, 0],
]

print("Tablero inicial:")
solver = SudokuSolver(sudoku_hard)
solver.print_board()

print("\n" + "="*60)
print("Resolviendo...")
print("="*60 + "\n")

import time
start = time.time()
solved = solver.solve()
elapsed = time.time() - start

if solved:
    print("✓ Solución encontrada!\n")
    solver.print_board()
    print(f"\nTiempo: {elapsed:.4f} segundos")
else:
    print("✗ No hay solución")

print("\n" + "="*60)
print("TÉCNICAS DE PODA APLICADAS:")
print("="*60)
print("""
1. MRV (Minimum Remaining Values):
  - Seleccionar celda con menos candidatos
  - Reduce factor de ramificación dramáticamente
  - Probabilidad de éxito: 50% con 2 candidatos vs 11% con 9

2. Look Ahead:
  - Verificar que otras celdas tengan candidatos
  - Detectar fallos temprano
  - Evita explorar ramas condenadas

3. Ordenamiento de Candidatos:
  - Probar valores más prometedores primero
  - Falla rápido si no hay solución

IMPACTO:
  Sin poda: ~1 hora
  Con poda: < 1 segundo
  Factor de mejora: > 1200x
""")
Quiz

¿Cuál es la técnica de poda más efectiva en Sudoku?


7. Branch and Bound

Búsqueda del Mejor Primero

Branch and Bound: Estrategia de búsqueda que mantiene una cola de prioridad de soluciones parciales, expandiendo siempre la más prometedora.

También conocido como: Best-First Search

Aplicación: Problemas de optimización (mínimo/máximo)

Concepto Clave: Límite Inferior

Límite Inferior (Lower Bound)

Para garantizar encontrar el óptimo global, la función de costo de una solución parcial debe ser un límite inferior del costo de cualquier solución completa derivada de ella.

Fórmula: costo(parcial) \leq costo(cualquier extensión completa)

Consecuencia: Si una solución parcial ya tiene costo ≥ mejor solución completa conocida, puede podarse.

Branch and Bound para TSP
import heapq

class TSP_BranchAndBound:
def **init**(self, distances):
"""
distances: matriz de distancias entre ciudades
"""
self.distances = distances
self.n = len(distances)
self.best_cost = float('inf')
self.best_path = None
self.nodes_explored = 0

    def solve(self, start=0):
        """Resolver TSP con Branch and Bound"""
        # Cola de prioridad: (lower_bound, cost, path, visited)
        initial_state = (0, 0, [start], {start})
        pq = [initial_state]

        while pq:
            lb, cost, path, visited = heapq.heappop(pq)
            self.nodes_explored += 1

            # Poda: si el límite inferior ya es peor, ignorar
            if lb >= self.best_cost:
                continue

            # ¿Solución completa?
            if len(path) == self.n:
                # Agregar retorno al inicio
                total_cost = cost + self.distances[path[-1]][start]

                if total_cost < self.best_cost:
                    self.best_cost = total_cost
                    self.best_path = path + [start]

                continue

            # Expandir: agregar siguientes ciudades
            current = path[-1]

            for next_city in range(self.n):
                if next_city not in visited:
                    new_cost = cost + self.distances[current][next_city]
                    new_path = path + [next_city]
                    new_visited = visited | {next_city}

                    # Calcular límite inferior
                    new_lb = self.lower_bound(new_cost, new_visited)

                    # Solo agregar si es prometedor
                    if new_lb < self.best_cost:
                        heapq.heappush(pq, (new_lb, new_cost, new_path, new_visited))

        return self.best_path, self.best_cost

    def lower_bound(self, current_cost, visited):
        """
        Calcular límite inferior del costo total
        = costo actual + estimación de ciudades restantes
        """
        # Estimación simple: suma de aristas mínimas salientes
        # de ciudades no visitadas
        remaining_cost = 0
        unvisited = [i for i in range(self.n) if i not in visited]

        if not unvisited:
            return current_cost

        # Para cada ciudad no visitada, encontrar arista mínima
        for city in unvisited:
            min_edge = min(self.distances[city][j]
                          for j in range(self.n) if j != city)
            remaining_cost += min_edge

        return current_cost + remaining_cost

# Ejemplo de uso

print("="*60)
print("TSP CON BRANCH AND BOUND")
print("="*60 + "\n")

# Distancias entre 5 ciudades

distances = [
[0, 10, 15, 20, 25],
[10, 0, 35, 25, 30],
[15, 35, 0, 30, 20],
[20, 25, 30, 0, 15],
[25, 30, 20, 15, 0],
]

print("Matriz de distancias:")
print(" ", end="")
for i in range(len(distances)):
print(f" {i}", end="")
print()

for i, row in enumerate(distances):
print(f" {i}:", end="")
for dist in row:
print(f"{dist:3d}", end=" ")
print()

print("\n" + "="*60)
print("Resolviendo con Branch and Bound...")
print("="*60 + "\n")

solver = TSP_BranchAndBound(distances)
best_path, best_cost = solver.solve(start=0)

print(f"Mejor camino: {' → '.join(map(str, best_path))}")
print(f"Costo total: {best_cost}")
print(f"Nodos explorados: {solver.nodes_explored}")

print("\n" + "="*60)
print("COMPARACIÓN:")
print("="*60)
print(f"Backtracking (DFS): exploraría (n-1)! = {5*4*3*2*1} caminos")
print(f"Branch and Bound: exploró {solver.nodes_explored} nodos")
print(f"Reducción: {100\*(1 - solver.nodes_explored/120):.1f}%")

Backtracking (DFS) vs Branch and Bound:

BACKTRACKING (DFS):
  - Explora profundidad completa antes de backtrack
  - Orden: 0→1→2→3→4→0, 0→1→2→4→3→0, ...
  - Encuentra primera solución completa rápido
  - Pero puede explorar muchas ramas malas antes del óptimo

Árbol de exploración:
    0
    ├─1 (explorar completo)
    │ ├─2 (explorar completo)
    │ │ ├─3→4→0: costo=80
    │ │ └─4→3→0: costo=85
    │ └─3 (explorar completo)
    │   └─...
    └─2 (explorar completo)
      └─...

BRANCH AND BOUND (Best-First):
  - Cola de prioridad: expande lo más prometedor
  - Poda agresiva: ignora ramas con lb > mejor conocido
  - Converge al óptimo más rápido

Cola de prioridad (lb, path):
  1. (0, [0])
  2. (40, [0,1]) ← expandir
  3. (45, [0,2])
  4. (50, [0,3])
  5. (65, [0,1,2]) ← expandir
     ...
  X. (95, [0,3,4,1,...]) ← PODAR (lb > mejor=80)

Ventajas de Branch and Bound:

  • ✅ Encuentra óptimo potencialmente antes
  • ✅ Poda más agresiva

Desventajas:

  • ❌ Usa más memoria (cola completa)
  • ❌ Overhead de heap

8. Heurística A*

A* = Branch and Bound + Heurística Inteligente

A* mejora Branch and Bound usando un límite inferior más informado:

f(n)=g(n)+h(n)f(n) = g(n) + h(n)

Donde:

  • g(n)g(n) = costo real desde inicio hasta nn
  • h(n)h(n) = estimación heurística del costo restante
  • f(n)f(n) = estimación del costo total

Requisito: h(n)h(n) debe ser admisible (nunca sobreestimar el costo real)

A* para Caminos Más Cortos

A* para encontrar camino más corto
import heapq
import math

class AStar:
    def __init__(self, graph, coordinates):
        """
        graph: grafo ponderado {u: [(v, peso), ...]}
        coordinates: {u: (x, y)} para heurística de distancia
        """
        self.graph = graph
        self.coordinates = coordinates

    def heuristic(self, u, v):
        """
        Heurística: distancia euclidiana (as-the-crow-flies)
        Admisible porque la distancia real nunca es menor
        """
        x1, y1 = self.coordinates[u]
        x2, y2 = self.coordinates[v]
        return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)

    def find_path(self, start, goal):
        """Encontrar camino más corto con A*"""
        # Cola de prioridad: (f_score, g_score, node, path)
        pq = [(0, 0, start, [start])]
        visited = set()
        nodes_explored = 0

        while pq:
            f, g, current, path = heapq.heappop(pq)
            nodes_explored += 1

            if current in visited:
                continue

            visited.add(current)

            # ¿Llegamos al objetivo?
            if current == goal:
                return path, g, nodes_explored

            # Expandir vecinos
            for neighbor, weight in self.graph.get(current, []):
                if neighbor not in visited:
                    new_g = g + weight
                    new_h = self.heuristic(neighbor, goal)
                    new_f = new_g + new_h
                    new_path = path + [neighbor]

                    heapq.heappush(pq, (new_f, new_g, neighbor, new_path))

        return None, float('inf'), nodes_explored

# Comparación: Dijkstra vs A*
class Dijkstra:
    def __init__(self, graph):
        self.graph = graph

    def find_path(self, start, goal):
        """Dijkstra = A* con h(n) = 0"""
        pq = [(0, start, [start])]
        visited = set()
        nodes_explored = 0

        while pq:
            cost, current, path = heapq.heappop(pq)
            nodes_explored += 1

            if current in visited:
                continue

            visited.add(current)

            if current == goal:
                return path, cost, nodes_explored

            for neighbor, weight in self.graph.get(current, []):
                if neighbor not in visited:
                    new_cost = cost + weight
                    new_path = path + [neighbor]
                    heapq.heappush(pq, (new_cost, neighbor, new_path))

        return None, float('inf'), nodes_explored

# Ejemplo: Red de ciudades
print("="*60)
print("COMPARACIÓN: DIJKSTRA VS A*")
print("="*60 + "\n")

# Grafo de ciudades
graph = {
    'A': [('B', 5), ('C', 10)],
    'B': [('D', 3), ('E', 8)],
    'C': [('E', 2)],
    'D': [('F', 7)],
    'E': [('F', 1)],
    'F': [],
}

# Coordenadas para heurística
coordinates = {
    'A': (0, 0),
    'B': (3, 1),
    'C': (2, 4),
    'D': (6, 2),
    'E': (5, 5),
    'F': (9, 4),
}

print("Red de ciudades:")
for u, neighbors in graph.items():
    x, y = coordinates[u]
    print(f"  {u} ({x},{y}) → {neighbors}")

start, goal = 'A', 'F'

print(f"\nBuscar camino: {start}{goal}")
print("\n" + "="*60)

# Dijkstra
dijkstra = Dijkstra(graph)
path_d, cost_d, nodes_d = dijkstra.find_path(start, goal)

print("DIJKSTRA:")
print(f"  Camino: {' → '.join(path_d)}")
print(f"  Costo: {cost_d}")
print(f"  Nodos explorados: {nodes_d}")

# A*
astar = AStar(graph, coordinates)
path_a, cost_a, nodes_a = astar.find_path(start, goal)

print("\nA* (con heurística euclidiana):")
print(f"  Camino: {' → '.join(path_a)}")
print(f"  Costo: {cost_a}")
print(f"  Nodos explorados: {nodes_a}")

print("\n" + "="*60)
print("ANÁLISIS:")
print("="*60)
print(f"Reducción de nodos: {nodes_d - nodes_a} menos")
print(f"Mejora: {100*(nodes_d - nodes_a)/nodes_d:.1f}%")
print("""
¿Por qué A* es mejor?
- Dijkstra expande como un disco uniforme
- A* dirige la búsqueda hacia el objetivo
- La heurística elimina direcciones inútiles
- Crucial para grafos grandes (mapas, GPS)
""")
Quiz

¿Qué diferencia a A* de Branch and Bound básico?


9. Advertencia Crítica: Memoria vs Tiempo

El Problema del Espacio

ADVERTENCIA CRÍTICA: En búsqueda combinatoria, la memoria suele ser el factor limitante, no el tiempo.

Comparación de uso de memoria:

MétodoEspacioEjemplo (TSP, n=11)
Backtracking (DFS)O(h)O(h) altura~11 nodos en pila
Branch and BoundO(bh)O(b^h) nodos generados>200,000 nodos en cola

Consecuencia: Branch and Bound puede quedarse sin memoria mucho antes de quedarse sin tiempo.

Análisis de Complejidad Espacial

Comparación de memoria: DFS vs Best-First
import sys

def memory_analysis():
"""Analizar uso de memoria en búsqueda combinatoria"""

    print("="*60)
    print("ANÁLISIS DE MEMORIA: BACKTRACKING VS BRANCH AND BOUND")
    print("="*60 + "\n")

    # Tamaño típico de un nodo de búsqueda
    node_size_bytes = sys.getsizeof([]) + sys.getsizeof(0) * 15  # ~200 bytes

    print(f"Tamaño aproximado por nodo: {node_size_bytes} bytes\n")

    problems = [
        ("TSP n=10", 10, 10, 10),
        ("TSP n=15", 15, 15, 15),
        ("Subconjuntos n=20", 20, 20, 20),
        ("N-Reinas n=12", 12, 12, 12),
    ]

    print(f"{'Problema':<20} {'DFS (KB)':<15} {'BFS (MB)':<15} {'Diferencia':<15}")
    print("-" * 70)

    for name, height, branching, n in problems:
        # DFS: pila de altura h
        dfs_nodes = height
        dfs_memory = dfs_nodes * node_size_bytes / 1024  # KB

        # BFS/Branch and Bound: todos los nodos de nivel máximo
        # Aproximación: b^(h/2) en promedio
        bfs_nodes = branching ** (height // 2)
        bfs_memory = bfs_nodes * node_size_bytes / (1024 * 1024)  # MB

        if bfs_memory < 1:
            bfs_str = f"{bfs_memory * 1024:.1f} KB"
        else:
            bfs_str = f"{bfs_memory:.1f} MB"

        ratio = bfs_memory * 1024 / dfs_memory if dfs_memory > 0 else float('inf')

        print(f"{name:<20} {dfs_memory:.1f} KB{'':<8} {bfs_str:<15} {ratio:.0f}x")

    print("\n" + "="*60)
    print("CONCLUSIONES:")
    print("="*60)
    print("""

1. Backtracking (DFS):
  ✓ Memoria = O(altura) = muy manejable
  ✓ Puede explorar árboles enormes
  ✗ Puede explorar ramas malas completamente

2. Branch and Bound / A\*:
  ✓ Encuentra óptimo potencialmente más rápido
  ✓ Poda más agresiva con buenos límites
  ✗ Memoria = O(nodos generados) = exponencial
  ✗ Puede agotar RAM antes de terminar

3. Regla práctica:
  - Problemas pequeños (n < 15): Branch and Bound/A\*
  - Problemas grandes: Backtracking con poda inteligente
  - Muy grandes: Heurísticas aproximadas (no exactas)
    """)

memory_analysis()

Ejemplo concreto: TSP con 15 ciudades

Backtracking (DFS):

Pila máxima: 15 nodos (uno por nivel)
Memoria por nodo: ~200 bytes
Memoria total: 15 × 200 = 3 KB

✓ Totalmente manejable

Branch and Bound:

Nodos en cola de prioridad: estimado 10^7 nodos
Memoria por nodo: ~200 bytes
Memoria total: 10^7 × 200 = 2 GB

✗ Puede exceder RAM disponible

¿Qué sucede cuando se agota la memoria?

  1. Sistema empieza a usar swap (disco duro)
  2. Rendimiento cae drásticamente (1000x más lento)
  3. Eventualmente: Out of Memory Error
  4. Programa termina sin solución

Solución:

  • Usar backtracking con poda inteligente
  • Implementar Iterative Deepening A* (IDA*)
  • Usar límites de memoria en Branch and Bound
  • Considerar heurísticas aproximadas

10. Resumen y Estrategias

Guía de Selección de Métodos

¿Qué método usar?

Backtracking (DFS):

  • ✅ Enumeración completa (subconjuntos, permutaciones)
  • ✅ Problemas de satisfacción (Sudoku, N-Reinas)
  • ✅ Cuando la memoria es limitada
  • ✅ Con poda inteligente (MRV, look-ahead)

Branch and Bound:

  • ✅ Problemas de optimización pequeños (n < 15)
  • ✅ Cuando el límite inferior es fuerte
  • ✅ Cuando necesitas garantía de optimalidad
  • ❌ Evitar si el espacio de búsqueda es muy grande

A*:

  • ✅ Caminos más cortos en grafos grandes
  • ✅ Cuando hay buena heurística disponible
  • ✅ Navegación, mapas, GPS
  • ✅ Puzzles con función de evaluación

Heurísticas Aproximadas:

  • ✅ Problemas muy grandes (n > 20)
  • ✅ Cuando no necesitas garantía de optimalidad
  • ✅ Restricciones de tiempo estrictas

Tabla Comparativa Final

AspectoBacktrackingBranch & BoundA*
TipoDFSBest-FirstBest-First + Heurística
MemoriaO(h)O(h)O(bh)O(b^h)O(bh)O(b^h)
Optimalidad✓ (enumera todo)✓ (con lb correcto)✓ (con h admisible)
VelocidadLenta sin podaRápida con lb fuerteMuy rápida con h buena
Mejor paraSatisfacciónOptimización pequeñaCaminos/Puzzles

Principios de Diseño

Checklist para búsqueda combinatoria
"""
CHECKLIST PARA BÚSQUEDA COMBINATORIA EFICIENTE

1. MODELADO:
  ☐ Definir vector de solución a = (a₁, a₂, ..., aₙ)
  ☐ Definir conjuntos de candidatos Sᵢ para cada posición
  ☐ Definir criterio de solución completa

2. PODA (¡LO MÁS IMPORTANTE!):
  ☐ Verificar validez tempranamente
  ☐ Calcular límites inferiores/superiores
  ☐ Ordenar candidatos inteligentemente (MRV, etc.)
  ☐ Look-ahead: anticipar consecuencias
  ☐ Explotar simetrías

3. SELECCIÓN DE MÉTODO:
  ☐ ¿Cuántos nodos? (n < 15 → B&B, n > 15 → DFS)
  ☐ ¿Hay límite inferior? (sí → B&B, no → DFS)
  ☐ ¿Hay heurística? (sí → A*, no → B&B)
  ☐ ¿Restricción de memoria? (sí → DFS)

4. OPTIMIZACIÓN:
  ☐ Usar estructuras de datos eficientes
  ☐ Evitar copias innecesarias (make/unmake move)
  ☐ Implementar detección temprana de fallos
  ☐ Mantener estadísticas (nodos visitados)

5. TESTING:
  ☐ Probar con casos pequeños conocidos
  ☐ Verificar que encuentra todas las soluciones
  ☐ Medir nodos explorados con/sin poda
  ☐ Monitorear uso de memoria
"""
Quiz

¿Cuál es el factor más importante para la eficiencia en búsqueda combinatoria?


11. Cierre: Lecciones Fundamentales

Conclusiones Clave

1. La Poda es Rey

La eliminación temprana de ramas sin futuro es el factor de optimización más importante. Una poda inteligente puede mejorar el rendimiento por factores de 1000x o más.

2. Diseñar Antes de Implementar

  • Modelar el problema correctamente
  • Identificar restricciones y simetrías
  • Elegir representación adecuada
  • Solo entonces implementar

3. Memoria vs Tiempo

En búsqueda combinatoria, la memoria suele ser el cuello de botella, no el tiempo. DFS usa O(h)O(h), Best-First usa O(bh)O(b^h).

4. No Hay Bala de Plata

  • Backtracking: versátil pero lento sin poda
  • Branch and Bound: rápido pero hambriento de memoria
  • A*: excelente con buena heurística
  • Heurísticas: rápidas pero no óptimas

5. La Corrección Tiene un Precio

La búsqueda exhaustiva garantiza corrección, pero crece exponencialmente. Para problemas grandes, considerar aproximaciones.

Problemas Clásicos de Búsqueda Combinatoria:

1. Coloración de Grafos

  • Asignar colores a vértices sin adyacentes del mismo color
  • Encontrar número cromático mínimo
  • Aplicación: Asignación de frecuencias, scheduling

2. Problema de la Mochila (Knapsack)

  • Seleccionar items con máximo valor sin exceder capacidad
  • Variante 0/1: Branch and Bound
  • Variante fraccional: Greedy funciona

3. Satisfacibilidad Booleana (SAT)

  • Asignar valores true/false a variables
  • Satisfacer cláusulas lógicas
  • NP-completo, base de verificación formal

4. Problema del Corte Mínimo

  • Particionar grafo minimizando aristas cortadas
  • Aplicación: VLSI design, clustering

5. Steiner Tree

  • Conectar subconjunto de vértices con costo mínimo
  • Generalización de MST
  • Aplicación: Redes de telecomunicaciones

6. Job Shop Scheduling

  • Asignar trabajos a máquinas minimizando tiempo
  • Restricciones de precedencia
  • Aplicación: Manufactura, logística

7. Puzzle Sliding (15-Puzzle)

  • Mover piezas para alcanzar configuración objetivo
  • Ideal para A* con heurística Manhattan
  • Aplicación: IA en juegos

8. Problema del Caballo (Knight’s Tour)

  • Mover caballo por tablero visitando cada casilla una vez
  • Backtracking con heurística Warnsdorff

Resumen Final

Conceptos Dominados

✓ Búsqueda Combinatoria:

  • Exploración sistemática del espacio de soluciones
  • Modelado como vector de decisiones
  • Crecimiento exponencial del espacio

✓ Backtracking:

  • DFS sobre árbol implícito de soluciones
  • Construcción incremental + retroceso
  • Espacio O(h)O(h) - eficiente en memoria
  • Componentes: is_solution, candidates, process

✓ Poda (Pruning):

  • La técnica más importante
  • MRV: Minimum Remaining Values
  • Look-ahead: Anticipar fallos
  • Simetrías: Eliminar equivalencias
  • Impacto: mejoras de 100x-1000x

✓ Branch and Bound:

  • Best-First Search con cola de prioridad
  • Límites inferiores para poda
  • Garantiza optimalidad
  • Memoria O(bh)O(b^h) - puede ser prohibitiva

✓ Heurística A*:

  • Branch and Bound + heurística admisible
  • f(n)=g(n)+h(n)f(n) = g(n) + h(n)
  • Dirige búsqueda hacia objetivo
  • Excelente para caminos en grafos grandes

✓ Trade-offs:

  • Memoria vs Tiempo
  • Optimalidad vs Velocidad
  • Corrección vs Aproximación

¡Felicitaciones!

Has completado búsqueda combinatoria y backtracking. Ahora dominas:

  • Modelar problemas como búsqueda en espacio de estados
  • Implementar backtracking eficiente
  • Aplicar técnicas de poda inteligentes
  • Usar Branch and Bound para optimización
  • Aplicar A* con heurísticas admisibles
  • Entender trade-offs memoria vs tiempo
  • Seleccionar el método apropiado según el problema

Próximo paso: Explorar algoritmos aproximados, heurísticas metaheurísticas (algoritmos genéticos, simulated annealing), y técnicas de inteligencia artificial para problemas donde la búsqueda exhaustiva es impracticable.