Búsqueda y heurística

"La búsqueda es el problema central de la IA — todo problema puede formularse como 'encontrar el camino correcto en un espacio de posibilidades'." — Stuart Russell.

Qué vas a aprender en este capítulo

Antes del aprendizaje automático, la IA clásica resolvía problemas buscando sistemáticamente entre todas las soluciones posibles. Este capítulo enseña cómo formular problemas como búsquedas y cómo los algoritmos heurísticos (especialmente A*) encuentran soluciones óptimas eficientemente — la misma base de Google Maps y los videojuegos.


1.1 Problemas de búsqueda en espacio de estados

💡 Intuición

Imaginate que estás perdido en un laberinto. ¿Cómo salís? Probás un camino. Si llega a un callejón sin salida, volvés atrás y probás otro. Eso es búsqueda.

Formalmente, cualquier problema puede representarse como:

  • Un estado inicial (dónde estamos).
  • Un estado objetivo (adónde queremos llegar).
  • Acciones posibles desde cada estado.
  • Un costo de cada acción.

📐 Fundamento

Formalización de un problema de búsqueda:

class ProblemaBusqueda:
    def estado_inicial(self) -> Estado: ...
    def es_objetivo(self, estado: Estado) -> bool: ...
    def acciones(self, estado: Estado) -> list[Accion]: ...
    def resultado(self, estado: Estado, accion: Accion) -> Estado: ...
    def costo(self, estado: Estado, accion: Accion) -> float: ...

Ejemplo — La Esquina: repartidor de delivery

Estado inicial: local San Miguel Centro
Estado objetivo: casa del cliente (Colonia Las Flores)
Acciones: avanzar por cada calle disponible
Costo: distancia en km de cada segmento
Espacio de estados: el mapa de San Miguel (grafo de calles)

Criterios para comparar algoritmos:

Criterio Definición
Completitud ¿Encuentra siempre una solución si existe?
Optimalidad ¿Encuentra la solución de menor costo?
Complejidad en tiempo ¿Cuántos estados explora?
Complejidad en espacio ¿Cuántos estados guarda en memoria?

b = factor de ramificación (acciones promedio por estado)
d = profundidad de la solución más superficial
m = profundidad máxima del árbol


1.2 Búsqueda no informada

💡 Intuición

Los algoritmos no informados exploran sin ninguna pista sobre dónde está el objetivo. Son como buscar las llaves del carro revisando sistemáticamente cada cajón, sin saber en cuál están.

BFS (por amplitud): revisa primero todos los estados a distancia 1, luego todos a distancia 2, etc. Garantiza encontrar el camino más corto (en número de pasos).

DFS (por profundidad): sigue un camino hasta el final antes de backtrack. Usa poca memoria, pero puede perderse en un camino infinito.

📐 Fundamento

BFS — Breadth-First Search:

from collections import deque

def bfs(problema):
    nodo_inicial = Nodo(problema.estado_inicial())
    if problema.es_objetivo(nodo_inicial.estado):
        return nodo_inicial
    
    frontera = deque([nodo_inicial])  # cola FIFO
    explorados = set()
    
    while frontera:
        nodo = frontera.popleft()
        explorados.add(nodo.estado)
        
        for accion in problema.acciones(nodo.estado):
            hijo = expandir(problema, nodo, accion)
            if hijo.estado not in explorados and hijo not in frontera:
                if problema.es_objetivo(hijo.estado):
                    return hijo  # solución encontrada
                frontera.append(hijo)
    
    return None  # sin solución

# Propiedades:
# Completitud: Sí (si b y d son finitos)
# Optimalidad: Sí (si todos los costos son iguales)
# Tiempo: O(b^d)
# Espacio: O(b^d) — guarda todos los nodos en la frontera

DFS — Depth-First Search:

def dfs(problema):
    frontera = [Nodo(problema.estado_inicial())]  # pila LIFO
    explorados = set()
    
    while frontera:
        nodo = frontera.pop()  # toma el último
        if problema.es_objetivo(nodo.estado):
            return nodo
        explorados.add(nodo.estado)
        for accion in problema.acciones(nodo.estado):
            hijo = expandir(problema, nodo, accion)
            if hijo.estado not in explorados:
                frontera.append(hijo)
    return None

# Propiedades:
# Completitud: No (puede perderse en ramas infinitas)
# Optimalidad: No
# Tiempo: O(b^m)
# Espacio: O(b*m) — solo guarda el camino actual

Comparación:

Algoritmo Completitud Óptimo Tiempo Espacio
BFS Sí (costo uniforme) O(b^d) O(b^d)
DFS No No O(b^m) O(b·m)
UCS (Dijkstra) O(b^(C*/ε)) O(b^(C*/ε))

UCS = Uniform Cost Search: BFS pero expandiendo el nodo de menor costo acumulado.


1.3 Búsqueda heurística — A*

💡 Intuición

¿Por qué explorar todos los caminos si podemos adivinar cuáles son prometedores? Una heurística es una estimación del costo restante hasta el objetivo.

Si estás en San Miguel buscando llegar a la Plaza Central, la distancia en línea recta es una buena heurística: es rápida de calcular y siempre subestima (porque las calles reales son más largas que la línea recta).

A* combina el costo ya pagado (g) con la estimación del costo restante (h):

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

Siempre expande el nodo con menor f — el que parece ser el camino más prometedor.

📐 Fundamento

Algoritmo A:*

import heapq

def a_star(problema, heuristica):
    nodo_inicial = Nodo(problema.estado_inicial(), g=0)
    frontera = [(0, nodo_inicial)]  # (f, nodo) — cola de prioridad
    explorados = {}  # estado → costo g conocido
    
    while frontera:
        f, nodo = heapq.heappop(frontera)
        
        if problema.es_objetivo(nodo.estado):
            return reconstruir_camino(nodo)
        
        if nodo.estado in explorados and explorados[nodo.estado] <= nodo.g:
            continue  # ya encontramos un camino mejor
        
        explorados[nodo.estado] = nodo.g
        
        for accion in problema.acciones(nodo.estado):
            hijo = expandir(problema, nodo, accion)
            h = heuristica(hijo.estado)
            f_hijo = hijo.g + h
            heapq.heappush(frontera, (f_hijo, hijo))
    
    return None

# Propiedades (si h es admisible):
# Completitud: Sí
# Optimalidad: Sí
# Tiempo: Depende de la heurística
# Espacio: O(b^d) en el peor caso

Heurística admisible: nunca sobreestima el costo real.

h(n)h(n)h(n) \leq h^*(n)

donde h(n)h^(n) es el costo real al objetivo. Si sobreestima, A puede no encontrar la solución óptima.

Heurística consistente (monótona):

h(n)c(n,n)+h(n)h(n) \leq c(n, n') + h(n')

Para cada nodo n y sucesor n'. Garantiza que A* nunca necesita revisitar nodos.

Ejemplos de heurísticas:

Problema Heurística admisible
Laberinto / Mapa Distancia euclidiana (línea recta)
Cuadrícula sin diagonales Distancia Manhattan: |x1-x2| + |y1-y2|
Puzzle 8 piezas Número de piezas fuera de su posición
Puzzle 8 piezas (mejor) Suma de distancias Manhattan de cada pieza
Ruta en ciudad Distancia en línea recta / velocidad máxima

Efectividad de la heurística — factor de ramificación efectivo (b):*

Cuanto más cerca esté b* de 1, más eficiente es la heurística. Una heurística perfecta (h = h*) tiene b* = 1 — nunca explora nodos innecesarios.


1.4 Búsqueda local — Hill Climbing

💡 Intuición

A veces no necesitamos el camino — solo la solución. Si queremos maximizar las ventas de La Esquina ajustando precios, no nos importa cómo llegamos a los precios óptimos — solo queremos encontrarlos.

Hill climbing es como caminar en la niebla hacia arriba: desde donde estás, das un paso en la dirección que sube más. Rápido, pero puede quedarse atascado en una colina local que no es la más alta.

📐 Fundamento

def hill_climbing(problema, heuristica_maximizar):
    estado = problema.estado_inicial()
    
    while True:
        vecinos = problema.acciones(estado)
        mejor_vecino = max(vecinos, key=lambda a: heuristica_maximizar(
            problema.resultado(estado, a)
        ))
        
        if heuristica_maximizar(mejor_vecino) <= heuristica_maximizar(estado):
            return estado  # máximo local encontrado
        
        estado = mejor_vecino

# Problema: se queda en máximos locales, mesetas, o crestas
# Solución: Random restart — correr hill climbing desde múltiples puntos iniciales

Variantes:

  • Simulated Annealing: Acepta movimientos "hacia abajo" con probabilidad decreciente. Puede escapar de máximos locales.
  • Algoritmos genéticos: Poblaciones de soluciones que se combinan y mutan. Buenos para espacios de búsqueda muy grandes.

🛠️ En la práctica

Repartidor de La Esquina: A para entrega de delivery*

import heapq
import math

# Grafo simplificado de San Miguel (nodo: (lat, lon))
GRAFO = {
    'local_centro': [('colonia_a', 1.2), ('barrio_b', 0.8)],
    'colonia_a':    [('local_centro', 1.2), ('destino', 2.1)],
    'barrio_b':     [('local_centro', 0.8), ('destino', 1.5)],
    'destino':      []
}

COORDENADAS = {
    'local_centro': (13.480, -88.179),
    'colonia_a':    (13.485, -88.170),
    'barrio_b':     (13.475, -88.185),
    'destino':      (13.490, -88.165)
}

def distancia_recta(a, b):
    lat1, lon1 = COORDENADAS[a]
    lat2, lon2 = COORDENADAS[b]
    return math.sqrt((lat2 - lat1)**2 + (lon2 - lon1)**2) * 111  # km aprox

def ruta_delivery(origen, destino):
    def h(nodo):
        return distancia_recta(nodo, destino)
    
    frontera = [(0 + h(origen), 0, origen, [origen])]
    visitados = set()
    
    while frontera:
        f, g, nodo, camino = heapq.heappop(frontera)
        
        if nodo == destino:
            return camino, g
        
        if nodo in visitados:
            continue
        visitados.add(nodo)
        
        for vecino, costo in GRAFO.get(nodo, []):
            if vecino not in visitados:
                nuevo_g = g + costo
                nuevo_f = nuevo_g + h(vecino)
                heapq.heappush(frontera, (nuevo_f, nuevo_g, vecino, camino + [vecino]))
    
    return None, float('inf')

camino, distancia = ruta_delivery('local_centro', 'destino')
print(f"Ruta: {' → '.join(camino)}")
print(f"Distancia total: {distancia:.1f} km")

1.5 Ejercicios

✏️ Ejercicio 1.1 — Formular un problema

Formulá el siguiente problema como un problema de búsqueda en espacio de estados:

Problema: Un repartidor de La Esquina tiene 4 pedidos que entregar en distintas colonias. Quiere encontrar el orden de entrega que minimice la distancia total recorrida (empezando y terminando en el local).

Define: estado inicial, estado objetivo, acciones posibles, costo de cada acción.

✏️ Ejercicio 1.2 — Diseñar heurística

Para el problema del Puzzle 8 (un tablero 3×3 con 8 piezas numeradas y un espacio vacío que hay que ordenar), compará las siguientes heurísticas:

h1: Número de piezas fuera de su posición correcta.

h2: Suma de distancias Manhattan de cada pieza a su posición correcta.

a. ¿Son ambas admisibles? Justificá. b. ¿Cuál es más eficiente? ¿Por qué? c. Para el estado [1,2,3, 4,_,6, 7,5,8] (objetivo: [1,2,3, 4,5,6, 7,8,_]), calculá h1 y h2.


1.6 Para profundizar


Definiciones nuevas: espacio de estados, búsqueda en amplitud (BFS), búsqueda en profundidad (DFS), búsqueda de costo uniforme, heurística, admisibilidad, consistencia, A, hill climbing, simulated annealing, factor de ramificación efectivo.*