Esta es la lección donde el curso cobra todas sus deudas. En el módulo 5 prometimos que el patrón "¿lo he visto ya?" con set sería el visitados de BFS; en el módulo 6 dijimos que el recorrido por niveles con deque "es un BFS que saltará a grafos"; y desde el módulo 3 sabemos que toda recursión esconde una pila. Aquí convergen las tres piezas: BFS (búsqueda en anchura) y DFS (búsqueda en profundidad), los dos recorridos fundamentales sobre grafos. Con ellos responderemos preguntas reales de TaskFlow: qué tareas se desbloquean al terminar una, si el proyecto tiene ciclos de dependencias, y en qué orden ejecutar las tareas. Trabajamos sobre la clase Grafo y el grafo dependencias de la lección anterior.
Contenido
- Por qué
visitadosahora es imprescindible - BFS: búsqueda en anchura con
deque - Traza de BFS sobre el grafo de dependencias
- DFS: búsqueda en profundidad, recursiva e iterativa
- Traza de DFS y comparación BFS/DFS
- Aplicación 1: ¿qué desbloquea terminar una tarea? (alcanzables)
- Aplicación 2: detección de ciclos con estados blanco/gris/negro
- Aplicación 3: orden topológico (algoritmo de Kahn)
- Componentes conexas
Por qué visitados ahora es imprescindible
En el recorrido por niveles de un árbol (módulo 6) no llevábamos ningún conjunto de visitados. No hacía falta: en un árbol cada nodo tiene un solo padre, así que solo se puede llegar a él por un camino y es imposible encolarlo dos veces. Los grafos rompen esa garantía dos veces:
- Varios padres: a
desplegar_apise llega desdemigrar_bdy desdeconfigurar_servidor. Sin control, la procesaríamos dos veces (y todo lo que hay detrás, cuatro; crecimiento exponencial). - Ciclos: en
A → B → C → A, un recorrido ingenuo no termina jamás.
La solución es el patrón del módulo 5: un set llamado visitados con pertenencia e inserción en O(1) medio. Regla de oro: marcar al encolar/apilar, no al desencolar — si esperas a marcar cuando procesas el vértice, puede colarse dos veces en la cola entre medias.
BFS: búsqueda en anchura con deque
BFS explora el grafo por capas de distancia: primero el origen (distancia 0), luego sus vecinos (distancia 1), luego los vecinos de estos (distancia 2)... Es literalmente el recorrido por niveles del módulo 6, con deque como cola (append por la derecha, popleft por la izquierda, ambos O(1), módulo 4), más el visitados:
from collections import deque
def bfs(grafo, origen):
"""Recorre el grafo desde origen por capas. Devuelve el orden de visita."""
visitados = {origen} # marcado YA, antes de entrar en la cola
cola = deque([origen])
orden = []
while cola:
actual = cola.popleft() # FIFO: sale el más antiguo -> por capas
orden.append(actual)
for vecino in grafo.vecinos(actual):
if vecino not in visitados: # el "¿lo he visto ya?" del módulo 5
visitados.add(vecino) # marcar al encolar
cola.append(vecino)
return ordenCada vértice entra en la cola como mucho una vez y cada arista se examina una vez: coste O(n + a) en tiempo y O(n) en espacio. Compáralo con el árbol del módulo 6: el código es casi idéntico; solo hemos añadido tres líneas de visitados. Esa es la continuidad prometida.
La propiedad estrella de BFS: como avanza por capas, la primera vez que alcanza un vértice lo hace por un camino con el mínimo número de aristas. En grafos no ponderados, BFS es el algoritmo de camino más corto (con pesos ya no bastará: lección 07-04).
Traza de BFS sobre el grafo de dependencias
Ejecutemos bfs(dependencias, "disenar_esquema") paso a paso:
graph LR
A[disenar_esquema] --> B[migrar_bd]
B --> C[desplegar_api]
S[configurar_servidor] --> C
C --> D[pruebas_integracion]
U[disenar_ui] --> I[implementar_ui]
I --> D
D --> L[lanzamiento]
| Paso | Sale de la cola | Vecinos nuevos que entran | Cola tras el paso | Visitados |
|---|---|---|---|---|
| 1 | disenar_esquema | migrar_bd | [migrar_bd] | {d_e, mig} |
| 2 | migrar_bd | desplegar_api | [desplegar_api] | + api |
| 3 | desplegar_api | pruebas_integracion | [pruebas_integracion] | + pru |
| 4 | pruebas_integracion | lanzamiento | [lanzamiento] | + lan |
| 5 | lanzamiento | — | [] | — |
Resultado: ['disenar_esquema', 'migrar_bd', 'desplegar_api', 'pruebas_integracion', 'lanzamiento']. Dos observaciones:
- BFS solo visita lo alcanzable siguiendo las flechas: ni
configurar_servidor, nidisenar_ui, niimplementar_uiaparecen, porque ninguna flecha lleva dedisenar_esquemaa ellas. En un grafo dirigido, "alcanzable" depende del origen. - Las capas son las distancias:
migrar_bda 1 arista,desplegar_apia 2, etc.
DFS: búsqueda en profundidad, recursiva e iterativa
DFS toma la decisión contraria: en lugar de agotar la capa actual, se lanza por un camino hasta el fondo y solo retrocede (backtracking) cuando no puede seguir. Versión recursiva, donde la "pila" es la pila de llamadas del módulo 3:
def dfs_recursivo(grafo, origen, visitados=None, orden=None):
if visitados is None:
visitados, orden = set(), []
visitados.add(origen)
orden.append(origen)
for vecino in grafo.vecinos(origen):
if vecino not in visitados:
dfs_recursivo(grafo, vecino, visitados, orden) # bajar un nivel
return ordenY la versión iterativa, aplicando la equivalencia recursión ↔ pila explícita del módulo 3 (útil cuando el grafo es profundo y la pila de llamadas de Python, limitada a ~1000 niveles, se queda corta):
def dfs_iterativo(grafo, origen):
visitados = set()
pila = [origen] # una list como pila: append/pop por el final
orden = []
while pila:
actual = pila.pop() # LIFO: sale el más RECIENTE -> profundidad
if actual in visitados: # puede haber entrado dos veces antes de salir
continue
visitados.add(actual)
orden.append(actual)
for vecino in grafo.vecinos(actual):
if vecino not in visitados:
pila.append(vecino)
return ordenCompara dfs_iterativo con bfs: son el mismo algoritmo cambiando la estructura de apoyo. Cola (FIFO) → anchura; pila (LIFO) → profundidad. Es la simetría pila↔DFS / cola↔BFS anunciada en el módulo 6, ahora en código real.
Traza de DFS y comparación BFS/DFS
dfs_recursivo(dependencias, "disenar_ui"): visita disenar_ui, baja a implementar_ui, baja a pruebas_integracion, baja a lanzamiento; sin vecinos nuevos, deshace llamadas hasta el origen. Resultado: ['disenar_ui', 'implementar_ui', 'pruebas_integracion', 'lanzamiento'] — un camino en picado, frente al avance por capas de BFS.
| Criterio | BFS | DFS |
|---|---|---|
| Estructura de apoyo | Cola (deque, módulo 4) |
Pila (llamadas o list, módulo 3) |
| Orden de exploración | Por capas de distancia | Un camino hasta el fondo, luego retrocede |
| Encuentra | El camino con menos aristas | Exploración exhaustiva; caminos, no necesariamente cortos |
| Memoria en el peor caso | O(n) (una capa ancha) |
O(n) (un camino largo) |
| Coste en tiempo | O(n + a) |
O(n + a) |
| Usos típicos | Distancias, "a N pasos de", niveles | Ciclos, orden topológico, backtracking |
Aplicación 1: ¿qué desbloquea terminar una tarea? (alcanzables)
Pregunta de TaskFlow: "si termino migrar_bd, ¿qué tareas se acercan a su desbloqueo?" Con nuestra convención (la flecha apunta a lo que se desbloquea), son exactamente los vértices alcanzables desde ella — BFS y quitar el origen:
def tareas_afectadas(grafo, id_tarea):
"""Tareas que dependen, directa o transitivamente, de id_tarea."""
return bfs(grafo, id_tarea)[1:] # todo lo alcanzable, menos ella misma
print(tareas_afectadas(dependencias, "migrar_bd"))
# ['desplegar_api', 'pruebas_integracion', 'lanzamiento']Retrasar migrar_bd retrasa potencialmente esas tres tareas: acabamos de implementar el análisis de impacto de un gestor de proyectos en cinco líneas.
Aplicación 2: detección de ciclos con estados blanco/gris/negro
En el módulo 2 detectamos ciclos en listas enlazadas con Floyd (dos punteros a distinta velocidad). Aquella técnica servía porque en una lista solo hay un camino posible; en un grafo con bifurcaciones necesitamos otra idea, pero el problema es el mismo: ¿puedo volver a un sitio donde ya estoy? La técnica clásica usa DFS con tres estados por vértice:
- Blanco: no visitado aún.
- Gris: en proceso — está en el camino actual de la recursión (en la pila de llamadas).
- Negro: terminado — él y todos sus descendientes, explorados.
La clave: si DFS encuentra una arista hacia un vértice gris, ha encontrado una arista que vuelve al camino actual: un ciclo. Llegar a uno negro no es ciclo, solo un cruce de caminos (varios padres).
def hay_ciclo(grafo):
BLANCO, GRIS, NEGRO = 0, 1, 2
color = {v: BLANCO for v in grafo.vertices()}
def visitar(v):
color[v] = GRIS
for vecino in grafo.vecinos(v):
if color[vecino] == GRIS: # arista hacia el camino actual
return True
if color[vecino] == BLANCO and visitar(vecino):
return True
color[v] = NEGRO # v cerrado: nada bajo él forma ciclo
return False
# el grafo puede no ser conexo: hay que intentar desde cada vértice blanco
return any(color[v] == BLANCO and visitar(v) for v in grafo.vertices())
print(hay_ciclo(dependencias)) # False: nuestro proyecto es un DAG
# Rompámoslo a propósito: "disenar_ui depende de pruebas_integracion"
dependencias.anadir_arista("pruebas_integracion", "disenar_ui")
print(hay_ciclo(dependencias)) # True
dependencias.eliminar_arista("pruebas_integracion", "disenar_ui") # ej. 2 de 07-02Esto es exactamente lo que TaskFlow debe ejecutar antes de aceptar una nueva dependencia: si al añadirla hay_ciclo devuelve True, se rechaza y se avisa al usuario. En los ejercicios de 07-07 iremos más lejos: no solo detectar el ciclo, sino listarlo para mostrárselo.
Aplicación 3: orden topológico (algoritmo de Kahn)
La gran pregunta del proyecto: ¿en qué orden ejecuto las tareas de forma que ninguna empiece antes que sus dependencias? Ese orden se llama orden topológico y solo existe en DAGs. El algoritmo de Kahn lo construye con dos piezas que ya tenemos: los grados_entrada() de 07-02 y una cola:
- Calcula el grado de entrada de cada vértice.
- Mete en la cola los de grado 0 (ejecutables ya).
- Saca uno, añádelo al orden y "termínalo": resta 1 al grado de cada vecino. Si alguno llega a 0, a la cola.
- Repite hasta vaciar la cola.
def orden_topologico(grafo):
grados = grafo.grados_entrada()
cola = deque(v for v, g in grados.items() if g == 0)
orden = []
while cola:
actual = cola.popleft()
orden.append(actual)
for vecino in grafo.vecinos(actual):
grados[vecino] -= 1 # una dependencia menos
if grados[vecino] == 0: # todas cumplidas: ejecutable
cola.append(vecino)
if len(orden) < len(grados): # quedaron vértices con grado > 0
raise ValueError("Hay un ciclo de dependencias: no existe orden válido")
return orden
print(orden_topologico(dependencias))
# ['disenar_esquema', 'configurar_servidor', 'disenar_ui', 'migrar_bd',
# 'implementar_ui', 'desplegar_api', 'pruebas_integracion', 'lanzamiento']Detalles importantes:
- Aquí no hay
visitados: el propio contador de grados hace su papel — un vértice solo entra en la cola cuando su grado llega exactamente a 0, y eso ocurre una sola vez. - La comprobación final regala una segunda detección de ciclos: si hay ciclo, sus vértices nunca bajan a grado 0 y el orden sale incompleto. Kahn detecta ciclos "de propina", sin colores.
- El orden no es único (los tres grados-0 iniciales podían salir en cualquier orden). En 07-07 desempataremos por
prioridadsustituyendo ladequepor elheapqdel módulo 4. - Coste:
O(n + a), como todo lo de hoy.
Componentes conexas
Última herramienta: detectar las "islas" del grafo. Para ello ignoramos el sentido de las flechas (dos tareas están relacionadas si comparten dependencias en cualquier dirección) y lanzamos BFS desde cada vértice aún sin visitar; cada lanzamiento descubre una componente entera:
def componentes_conexas(grafo):
# versión no dirigida del grafo: cada arista, en ambos sentidos
nd = Grafo(dirigido=False)
for v in grafo.vertices():
nd.anadir_vertice(v)
for destino in grafo.vecinos(v):
nd.anadir_arista(v, destino)
visitados, componentes = set(), []
for v in nd.vertices():
if v not in visitados:
comp = bfs(nd, v) # descubre toda la isla de v
visitados.update(comp)
componentes.append(comp)
return componentes
dependencias.anadir_arista("escribir_blog", "publicar_blog") # mini-proyecto aparte
print(len(componentes_conexas(dependencias))) # 2: el proyecto principal y el blogEn TaskFlow, cada componente es un subproyecto independiente: puede planificarse, asignarse y ejecutarse sin mirar a las demás. Volveremos sobre ello en los ejercicios (07-07) con "islas de tareas".
Errores Comunes y Consejos
- Marcar como visitado al desencolar en lugar de al encolar. El algoritmo termina igual, pero un mismo vértice puede entrar varias veces en la cola y el coste se dispara. En BFS, marca al encolar; en DFS iterativo, el
continuetras elpopcubre el caso equivalente. - Usar una
listcomo cola en BFS (pop(0)): esO(n)por extracción, como machacamos en el módulo 4.deque.popleft()esO(1). - Detectar ciclos comprobando "vecino ya visitado" con un solo
set. En grafos dirigidos, llegar a un vértice negro (terminado) por otro camino no es un ciclo, es un rombo de dependencias perfectamente legal. Sin el estado gris, darás falsos positivos. - Olvidar que el grafo puede no ser conexo.
hay_cicloycomponentes_conexasiteran sobre todos los vértices; si solo lanzas desde uno, las islas quedan sin explorar. - DFS recursivo sobre grafos enormes: la pila de llamadas de Python se agota (~1000 niveles, módulo 3). Para grafos profundos, la versión iterativa.
- Consejo: cuando dudes entre BFS y DFS, pregúntate qué buscas. ¿Distancias o "lo más cercano"? BFS. ¿Ciclos, órdenes, explorar todo sin importar el orden? DFS suele ser más natural.
Ejercicios
Ejercicio 1: BFS con distancias
Modifica bfs para que devuelva un dict vertice → distancia (número mínimo de aristas desde el origen). Úsalo para responder: ¿a cuántos "saltos" de disenar_esquema está lanzamiento?
Ejercicio 2: ¿de qué depende esta tarea?
tareas_afectadas mira hacia delante. Escribe prerequisitos(grafo, id_tarea) que devuelva todas las tareas de las que id_tarea depende, directa o transitivamente. Pista: o inviertes el grafo, o buscas desde cada vértice.
Ejercicio 3: traza de Kahn
Sin ejecutar código, traza el orden_topologico del grafo de dependencias en una tabla (cola, extraído, grados que cambian) y verifica que coincide con la salida mostrada en la lección.
Soluciones
Solución 1:
def bfs_distancias(grafo, origen):
distancias = {origen: 0} # hace también de visitados
cola = deque([origen])
while cola:
actual = cola.popleft()
for vecino in grafo.vecinos(actual):
if vecino not in distancias:
distancias[vecino] = distancias[actual] + 1
cola.append(vecino)
return distancias
print(bfs_distancias(dependencias, "disenar_esquema")["lanzamiento"]) # 4El dict de distancias sustituye al set de visitados: estar en él ya significa "visto". lanzamiento está a 4 saltos, como en la traza.
Solución 2:
def invertir(grafo):
inv = Grafo(dirigido=True)
for v in grafo.vertices():
inv.anadir_vertice(v)
for destino, peso in grafo.vecinos(v).items():
inv.anadir_arista(destino, v, peso) # la flecha, del revés
return inv
def prerequisitos(grafo, id_tarea):
return bfs(invertir(grafo), id_tarea)[1:]
print(prerequisitos(dependencias, "desplegar_api"))
# ['migrar_bd', 'configurar_servidor', 'disenar_esquema']Invertir el grafo cuesta O(n + a) y convierte "¿quién depende de mí?" en "¿de quién dependo?": el mismo BFS responde ambas preguntas según el sentido de las flechas.
Solución 3:
| Paso | Cola antes | Extraído | Grados que bajan | Nuevos grado-0 |
|---|---|---|---|---|
| 1 | [d_esquema, c_servidor, d_ui] | disenar_esquema | migrar_bd: 1→0 | migrar_bd |
| 2 | [c_servidor, d_ui, migrar_bd] | configurar_servidor | desplegar_api: 2→1 | — |
| 3 | [d_ui, migrar_bd] | disenar_ui | implementar_ui: 1→0 | implementar_ui |
| 4 | [migrar_bd, implementar_ui] | migrar_bd | desplegar_api: 1→0 | desplegar_api |
| 5 | [implementar_ui, desplegar_api] | implementar_ui | pruebas: 2→1 | — |
| 6 | [desplegar_api] | desplegar_api | pruebas: 1→0 | pruebas_integracion |
| 7 | [pruebas_integracion] | pruebas_integracion | lanzamiento: 1→0 | lanzamiento |
| 8 | [lanzamiento] | lanzamiento | — | — |
Orden final: el de la lección. Observa cómo ninguna tarea sale antes de que todas sus dependencias hayan salido: esa es la garantía del orden topológico.
Conclusión
BFS y DFS son los dos motores de exploración de grafos: misma maquinaria, distinta estructura de apoyo (cola → capas y distancias mínimas en aristas; pila → profundidad, ciclos y órdenes), ambos en O(n + a) gracias al visitados que el módulo 5 dejó preparado. Sobre ellos hemos construido las tres operaciones que TaskFlow necesitaba: análisis de impacto (alcanzables), rechazo de dependencias circulares (blanco/gris/negro, la hermana grafo de Floyd) y el orden de ejecución del proyecto (Kahn). Pero BFS mide caminos en número de aristas, y en la vida real las aristas no cuestan lo mismo: pasar por una tarea de 8 horas no es como pasar por una de 1. Cuando las aristas tienen peso, hace falta algo mejor — y ese algo, Dijkstra, reutiliza la cola de prioridad heapq del módulo 4. Es la próxima lección.
Curso de Estructuras de Datos
Módulo 1: Introducción a las Estructuras de Datos
- ¿Qué son las Estructuras de Datos?
- Importancia de las Estructuras de Datos en la Programación
- Tipos de Estructuras de Datos
- Complejidad Algorítmica y Notación Big O
- Arrays y Memoria: la Base de las Estructuras de Datos
Módulo 2: Listas
- Introducción a las Listas
- Listas Enlazadas
- Listas Doblemente Enlazadas
- Listas Circulares
- Ejercicios con Listas
Módulo 3: Pilas
- Introducción a las Pilas
- Operaciones Básicas con Pilas
- Implementación de Pilas
- Aplicaciones de las Pilas
- Ejercicios con Pilas
Módulo 4: Colas
- Introducción a las Colas
- Operaciones Básicas con Colas
- Colas Circulares
- Colas de Prioridad
- Colas Dobles (Deques)
- Ejercicios con Colas
Módulo 5: Tablas Hash y Diccionarios
- Introducción a las Tablas Hash
- Funciones Hash y Resolución de Colisiones
- Diccionarios y Conjuntos en la Práctica
- Ejercicios con Tablas Hash
Módulo 6: Árboles
- Introducción a los Árboles
- Árboles Binarios
- Recorridos de Árboles
- Árboles Binarios de Búsqueda
- Árboles AVL
- Árboles B
- Montículos (Heaps)
- Ejercicios con Árboles
Módulo 7: Grafos
- Introducción a los Grafos
- Representación de Grafos
- Algoritmos de Búsqueda en Grafos
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Aplicaciones de los Grafos
- Ejercicios con Grafos
