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 donde:
- Cada elemento se selecciona de un conjunto finito
- El vector representa una configuración completa
- Ejemplos:
- Permutación: = elementos restantes
- Subconjunto: = {incluir, excluir}
- Asignación: = valores posibles
Tipos de Problemas Combinatorios
| Tipo | Descripción | Ejemplo | Número de Configuraciones |
|---|---|---|---|
| Permutaciones | Ordenar elementos | Ordenar [1,2,3] | |
| Subconjuntos | Seleccionar elementos | Subconjuntos de {1,2,3} | |
| Combinaciones | Elegir de | Elegir 2 de 5 | |
| Asignaciones | Asignar valores | Colorear grafo |
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 (altura) vs (anchura), donde = factor de ramificación.
Algoritmo Genérico
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
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 elementos Salida: Todos los subconjuntos posibles
Modelo:
- Vector booleano de tamaño
- = true → elemento está incluido
- = false → elemento está excluido
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)") ¿Por qué el backtracking usa DFS en lugar de BFS?
4. Ejemplo: Problema de las N-Reinas
Problema Clásico
N-Reinas: Colocar reinas en un tablero de ajedrez 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
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: = 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 )
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 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 contiene 1-9 exactamente una vez
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
""") ¿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) costo(cualquier extensión completa)
Consecuencia: Si una solución parcial ya tiene costo ≥ mejor solución completa conocida, puede podarse.
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:
Donde:
- = costo real desde inicio hasta
- = estimación heurística del costo restante
- = estimación del costo total
Requisito: debe ser admisible (nunca sobreestimar el costo real)
A* para Caminos Más Cortos
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)
""") ¿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étodo | Espacio | Ejemplo (TSP, n=11) |
|---|---|---|
| Backtracking (DFS) | altura | ~11 nodos en pila |
| Branch and Bound | 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
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 manejableBranch 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?
- Sistema empieza a usar swap (disco duro)
- Rendimiento cae drásticamente (1000x más lento)
- Eventualmente: Out of Memory Error
- 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
| Aspecto | Backtracking | Branch & Bound | A* |
|---|---|---|---|
| Tipo | DFS | Best-First | Best-First + Heurística |
| Memoria | |||
| Optimalidad | ✓ (enumera todo) | ✓ (con lb correcto) | ✓ (con h admisible) |
| Velocidad | Lenta sin poda | Rápida con lb fuerte | Muy rápida con h buena |
| Mejor para | Satisfacción | Optimización pequeña | Caminos/Puzzles |
Principios de Diseño
"""
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
""" ¿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 , Best-First usa .
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 - 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 - puede ser prohibitiva
✓ Heurística A*:
- Branch and Bound + heurística admisible
- 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.