La web de RutaBus quiere publicar la tabla "minutos mínimos entre cualquier par de paradas": el problema todos-con-todos (all-pairs shortest paths). Dijkstra (04-05) lo resuelve por fuerza de repetición — un origen cada vez —, pero existe un algoritmo que ataca la matriz completa de una sola pieza, tolera pesos negativos y cabe en cinco líneas: Floyd-Warshall. Es la promesa que dejamos firmada en 03-03: programación dinámica sobre grafos — un subproblema ingenioso ("camino mínimo usando solo las primeras k paradas como intermedias"), una tabla que se rellena, y la solución que emerge al completarla. En esta lección lo implementaremos y trazaremos matriz a matriz, aprenderemos a detectar ciclos negativos mirando una diagonal y a reconstruir caminos con una segunda matriz; y, como es la última parada del módulo, cerraremos con el mapa completo de los seis clásicos y el puente hacia la optimización.

Contenido

  1. El problema todos-con-todos: ¿repetir Dijkstra o algo mejor?
  2. La idea de programación dinámica: intermedias permitidas
  3. Representación: de diccionario a matriz de adyacencia
  4. Implementación: tres bucles y una desigualdad
  5. Traza matriz a matriz sobre una red de 4 paradas
  6. Ciclos negativos: la diagonal delatora
  7. Complejidad y reconstrucción de caminos
  8. Cierre del módulo: los seis clásicos en una tabla

El problema todos-con-todos: ¿repetir Dijkstra o algo mejor?

La opción obvia: ejecutar dijkstra(red, origen) con cada una de las V paradas como origen. Es perfectamente legítima — y a veces la mejor. Comparemos costes (V paradas, E tramos):

Dijkstra × V Floyd-Warshall
Tiempo O(V · (V+E) · log V) O(V³)
En grafo disperso (E ≈ V) ≈ O(V² log V) ← gana O(V³)
En grafo denso (E ≈ V²) ≈ O(V³ log V) O(V³) ← gana
¿Pesos negativos? No (04-05) (sin ciclos negativos)
Código Heap, obsoletas, V ejecuciones Tres bucles y un if
Espacio O(V²) para guardar el resultado O(V²)

Lectura práctica: para la red urbana de RutaBus (dispersa, pesos positivos), Dijkstra repetido es asintóticamente mejor. Floyd-Warshall gana cuando el grafo es denso, cuando hay pesos negativos (donde Dijkstra ni juega), o cuando V es moderado (unas 400 paradas → 400³ = 6,4·10⁷ operaciones simplísimas: milisegundos) y se valora un código a prueba de bugs. Como siempre desde 02-03: no hay campeón, hay contexto.

La idea de programación dinámica: intermedias permitidas

Aplicar programación dinámica (03-03) exige encontrar el subproblema correcto. El de Floyd-Warshall es una idea brillante. Numeramos las paradas 0, 1, ..., V−1 y definimos:

D_k[i][j] = coste del camino más corto de i a j usando como paradas intermedias únicamente las de la lista {0, 1, ..., k−1}.

  • Caso base, D_0: sin intermedias permitidas, solo valen los tramos directos — la matriz de adyacencia tal cual.
  • Paso recursivo: al autorizar una intermedia nueva, la parada k, cada camino i→j tiene dos opciones exhaustivas y excluyentes: o no usa k (y entonces su coste sigue siendo D_k[i][j]) o usa k exactamente una vez — va de i a k y de k a j, ambos tramos sin usar k como intermedia, es decir, D_k[i][k] + D_k[k][j]. El mínimo de ambas:
D_{k+1}[i][j] = min( D_k[i][j],  D_k[i][k] + D_k[k][j] )
  • Respuesta final: D_V, con todas las paradas autorizadas como intermedias: el camino mínimo sin restricciones.

Es la firma de la programación dinámica de 03-03, punto por punto: subproblemas solapados (los D_k[i][k] se reutilizan en toda la fila de actualizaciones), subestructura óptima (el mejor camino vía k se compone de dos mejores caminos parciales), y tabulación de abajo arriba (de D_0 a D_V). Donde coste_minimo_tab avanzaba parada a parada por la L2, aquí avanzamos permiso a permiso: cada iteración de k no explora caminos, autoriza un punto de paso y deja que la tabla se recombine.

Representación: de diccionario a matriz de adyacencia

El diccionario de adyacencia de 03-04/04-05 es ideal para recorrer vecinas; Floyd-Warshall, en cambio, consulta y actualiza pares (i, j) arbitrarios sin parar, así que le conviene la matriz de adyacencia: una tabla V×V donde la celda [i][j] guarda el peso del tramo directo i→j (∞ si no existe, 0 en la diagonal).

Diccionario de adyacencia (04-05) Matriz de adyacencia (aquí)
Espacio O(V + E): solo lo que existe O(V²): todas las celdas, existan o no
¿Hay tramo i→j? Buscar en red[i] O(1): leer la celda
Recorrer vecinas de i O(grado): directo O(V): barrer la fila entera
Ideal para Grafos dispersos, Dijkstra, BFS Grafos densos, algoritmos "todos los pares"

Tomemos la subred de 4 paradas de 03-04 con los minutos de 04-05 — índices 0 = Plaza Mayor, 1 = Estación Norte, 2 = Hospital Central, 3 = Parque del Río:

INF = float("inf")
#        PM    EN    HC    PR
D = [
    [   0,    4,  INF,    2],   # Plaza Mayor
    [   4,    0,    5,  INF],   # Estación Norte
    [ INF,    5,    0,    8],   # Hospital Central
    [   2,  INF,    8,    0],   # Parque del Río
]

La matriz es simétrica porque el grafo es no dirigido; con tramos de sentido único dejaría de serlo y nada más cambiaría.

Implementación: tres bucles y una desigualdad

def floyd_warshall(D):
    """Recibe la matriz de adyacencia; devuelve la matriz de distancias mínimas."""
    n = len(D)
    dist = [fila[:] for fila in D]              # copia: no pisamos la entrada
    for k in range(n):                          # intermedia que se AUTORIZA
        for i in range(n):                      # origen
            for j in range(n):                  # destino
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]   # mejor vía k
    return dist

La actualización interior es la relajación de 04-05 con otro ropaje: "¿ir de i a j pasando por k mejora lo que tengo?". Detalles importantes:

  • El orden de los bucles no es negociable: k debe ser el externo. Cada vuelta de k completa la transición D_k → D_{k+1} para toda la matriz; poner k dentro rompe la definición del subproblema y produce distancias incorrectas (es el bug clásico de este algoritmo).
  • ¿Y no habría que guardar D_k y D_{k+1} en matrices separadas? Sorprendentemente no: en la vuelta k, las celdas dist[i][k] y dist[k][j] no cambian (la mejora vía k de un camino que acaba o empieza en k pasaría dos veces por k, y eso nunca mejora sin ciclos negativos), así que actualizar sobre la propia matriz es seguro. Ahorro de O(V²) a coste cero — un regalo que la tabulación de 03-03 no siempre hace.
  • La copia inicial ([fila[:] for fila in D]) evita el clásico alias de listas anidadas: dist = D no copiaría nada, y dist = D[:] copiaría la lista externa pero compartiría las filas.

Traza matriz a matriz sobre una red de 4 paradas

Ejecutemos sobre la matriz anterior, mostrando la matriz después de cada valor de k. Cambios en negrita.

k = 0 (se autoriza Plaza Mayor como intermedia). ¿Qué pares mejoran pasando por PM? EN↔PR: 4 + 2 = 6 < ∞. Nada más (los demás ya tienen directo mejor o involucran a PM como extremo):

D₁ PM EN HC PR
PM 0 4 2
EN 4 0 5 6
HC 5 0 8
PR 2 6 8 0

k = 1 (se autoriza Estación Norte). PM↔HC: 4 + 5 = 9 < ∞ ✔. PR↔HC vía EN: 6 + 5 = 11, no mejora el directo 8 ✘:

D₂ PM EN HC PR
PM 0 4 9 2
EN 4 0 5 6
HC 9 5 0 8
PR 2 6 8 0

k = 2 (Hospital Central) y k = 3 (Parque del Río). Se comprueban todas las celdas y... nada mejora: por ejemplo PM→PR vía HC costaría 9 + 8 = 17 contra el 2 directo, y PM→HC vía PR costaría 2 + 8 = 10 contra el 9 vigente. D₄ = D₂: es la matriz final.

Verificación cruzada: la fila de Plaza Mayor — 0, 4, 9, 2 — coincide exactamente con lo que Dijkstra calculó desde Plaza Mayor en 04-05. Dos algoritmos, dos estrategias madre, una misma verdad; ejecutar ambos y comparar es, de paso, una técnica de testeo excelente.

Obsérvese lo que ha pasado en la traza: el camino PM→HC = 9 (que en Dijkstra requirió descubrir 10 y corregir a 9) aquí simplemente apareció cuando se autorizó la intermedia adecuada (EN, en k = 1). Floyd-Warshall no explora caminos: deja que la tabla los componga.

Ciclos negativos: la diagonal delatora

Floyd-Warshall admite pesos negativos — la relajación no "consolida" nada irrevocablemente, así que el argumento que tumbaba a Dijkstra (04-05) no le afecta. Su límite es otro: los ciclos de coste total negativo. Si existe un ciclo cuya suma de pesos es < 0, dar vueltas por él reduce el coste indefinidamente y "el camino más corto" deja de existir (tiende a −∞).

Lo elegante es cómo se detecta: al terminar, basta mirar la diagonal. dist[i][i] empieza en 0 (ir de i a i sin moverse); si el algoritmo encuentra dist[i][i] < 0, es que hay un camino de i a i con coste negativo — un ciclo negativo alcanzable desde i:

def hay_ciclo_negativo(dist):
    return any(dist[i][i] < 0 for i in range(len(dist)))

En RutaBus los minutos no son negativos y esto no aplica; pero si los pesos modelaran coste económico con subvenciones por tramo, un ciclo negativo significaría "ruta circular que genera dinero infinito" — señal inequívoca de datos corruptos o de un modelo mal planteado, y este chequeo de una línea lo delata.

Complejidad y reconstrucción de caminos

Tiempo: Θ(V³). Tres bucles anidados de V vueltas con trabajo O(1) dentro — el análisis línea a línea de 02-01 en su forma más pura. Sin mejores ni peores casos: la estructura del grafo no cambia el recuento (a lo sumo cambia cuántos if disparan la asignación). Para la tabla web con V = 200 paradas: 8·10⁶ operaciones, trivial. Con V = 100.000... 10¹⁵: jamás. El cubo pone la frontera en "miles de nodos, no cientos de miles" — la jerarquía de 01-03 mandando otra vez.

Espacio: Θ(V²). La matriz misma (gracias a la actualización in-place, no hay copia por iteración). Es también el tamaño de la salida — la tabla todos-con-todos que RutaBus quería publicar —, así que en este problema el espacio cuadrático es irreducible.

Reconstruir los caminos. Como en Dijkstra (y como en la mochila de 03-03), el "cuánto" no basta: hace falta el "por dónde". La miga de pan aquí es una matriz de siguientes: sig[i][j] = primera parada a la que moverse en el camino óptimo de i a j. Se inicializa con sig[i][j] = j para cada tramo directo, y cada vez que la relajación mejora un camino vía k, el primer paso hacia j pasa a ser el primer paso hacia k:

def floyd_warshall_con_caminos(D):
    n = len(D)
    dist = [fila[:] for fila in D]
    sig = [[j if D[i][j] != INF else None for j in range(n)] for i in range(n)]
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
                    sig[i][j] = sig[i][k]        # ir hacia j empieza yendo hacia k
    return dist, sig

def camino(sig, i, j):
    if sig[i][j] is None:
        return []                                # no hay camino
    ruta = [i]
    while i != j:
        i = sig[i][j]                            # saltar a la siguiente parada
        ruta.append(i)
    return ruta

# camino(sig, 0, 2) → [0, 1, 2]: Plaza Mayor → Estación Norte → Hospital Central

Frente al diccionario previa de Dijkstra (una miga por parada, hacia atrás), aquí la matriz guarda una miga por par y se recorre hacia delante. O(V²) migas para O(V²) caminos: proporcionado.

Cierre del módulo: los seis clásicos en una tabla

Seis lecciones, seis algoritmos, y ninguna pieza suelta: cada clásico es la encarnación de una estrategia del Módulo 3, analizada con las herramientas del Módulo 2.

Algoritmo Estrategia madre Tiempo (peor / promedio) Espacio aux. Cuándo usarlo en RutaBus
Búsqueda binaria (04-01) Divide y vencerás (descarta mitad) O(log n) O(1) Buscar en el catálogo ordenado de paradas
Inserción (04-02) Incremental (+ mejor caso greedy del orden) O(n²) / O(n²); O(n) si casi ordenado O(1) Paneles pequeños o casi ordenados; flujo online
Merge sort (04-03) Divide y vencerás (trabajo al combinar) O(n log n) garantizado O(n) Históricos enormes, ordenación externa, estabilidad
Quick sort (04-04) Divide y vencerás (trabajo al dividir) O(n²) / O(n log n) O(log n) Ordenación general en memoria (con pivote domado)
Dijkstra (04-05) Greedy (el que sí es óptimo) O((V+E) log V) O(V+E) Tiempos desde una parada; red dispersa, pesos ≥ 0
Floyd-Warshall (04-06) Programación dinámica Θ(V³) O(V²) Matriz completa de tiempos; tolera pesos negativos

(El cuarto palo de 03-04, backtracking, no tiene representante aquí: su territorio son los problemas sin estructura explotable, donde no nació ningún "clásico eficiente" — esa ausencia también es una lección.)

Errores Comunes y Consejos

  • Poner k como bucle interno. El error canónico: el código corre, devuelve una matriz con buena pinta... con distancias incorrectas (subestima rutas que necesitan intermedias "futuras"). k autoriza intermedias y debe ser el bucle externo. Test rápido: en nuestra red, si PM→HC no sale 9, los bucles están mal.
  • Inicializar mal la matriz: olvidar los 0 de la diagonal, o usar un "infinito" falso como 999999 que al sumarse dos veces desborda la lógica de comparación con pesos grandes. float("inf") suma y compara correctamente (inf + 5 == inf); úsalo. Y si hay tramos duplicados en los datos, la celda debe quedarse con el mínimo.
  • Copiar la matriz con alias: dist = D o dist = D[:] comparten las filas (listas anidadas), y el algoritmo corromperá la matriz de entrada. La copia correcta es por filas: [fila[:] for fila in D] — coste oculto de Python que ya conocemos de 02-01.
  • Usarlo por comodidad en grafos enormes y dispersos: V³ con V = 50.000 son 1,25·10¹⁴ operaciones. Para la red nacional de paradas, Dijkstra desde cada origen (o solo desde los que se consultan) es la opción; Floyd-Warshall es para V moderado o denso. La tabla del apartado 1 es la chuleta.
  • Consejo: verifica tu Floyd-Warshall contrastando una fila de su matriz con un Dijkstra desde esa parada (si los pesos son ≥ 0). Dos implementaciones independientes que coinciden valen más que veinte lecturas del propio código.

Ejercicios

Ejercicio 1

Añade a la matriz de 4 paradas un tramo de sentido único Hospital Central → Plaza Mayor de 3 minutos (la matriz deja de ser simétrica: solo cambia D[2][0] = 3). Ejecuta la traza k = 0..3 y da la matriz final. ¿Qué par de paradas mejora gracias al nuevo tramo y por qué el par inverso no?

Ejercicio 2

Con la matriz dist y la matriz sig del apartado 7 sobre la red original de 4 paradas: reconstruye paso a paso camino(sig, 3, 1) (Parque del Río → Estación Norte), indicando el valor de sig consultado en cada salto y verificando que los minutos suman dist[3][1].

Ejercicio 3

RutaBus estudia dos despliegues: (a) la red urbana con V = 300 paradas y E ≈ 900 tramos, matriz completa recalculada cada noche; (b) la red metropolitana con V = 20.000 y E ≈ 60.000, donde solo el 1 % de los pares se consulta realmente. Para cada caso, elige entre Floyd-Warshall y Dijkstra-repetido y justifica con números aproximados (usa log₂ V ≈ 8 para 300 y ≈ 14 para 20.000).

Soluciones

Solución 1

Solo cambia la celda D[2][0] = 3 (HC→PM); D[0][2] sigue siendo ∞ hasta que las intermedias hagan su trabajo. Traza de cambios: con k = 0 (PM): EN→PR = 6 y PR→EN = 6 como en la red original; HC→EN vía PM = 3+4 = 7 no mejora el 5 directo ✘; pero HC→PR vía PM = 3+2 = 5 mejora el 8 directo ✔. Con k = 1 (EN): PM→HC = 4+5 = 9 ✔; en cambio PR→HC vía EN = 6+5 = 11 no mejora su 8 directo ✘ (cuidado con la asimetría: HC→PR sí bajó a 5, pero PR→HC sigue en 8 — son celdas distintas). Con k = 2 (HC): EN→PM vía HC = 5+3 = 8 ✘ (4 directo), PR→PM vía HC = 8+3 = 11 ✘ (2 directo). k = 3: sin cambios. Matriz final:

PM EN HC PR
PM 0 4 9 2
EN 4 0 5 6
HC 3 5 0 5
PR 2 6 8 0

Mejora HC→PR (8 → 5, vía PM gracias al atajo HC→PM). El inverso PR→HC se queda en 8: el tramo nuevo es de sentido único y solo ayuda a los caminos que van hacia Plaza Mayor. En grafos dirigidos, cada sentido vive su vida.

Solución 2

Sobre la red original, dist[3][1] = 6 (PR→EN vía PM). Inicialmente sig[3][1] = None... hasta que en k = 0 la mejora vía PM asigna sig[3][1] = sig[3][0] = 0. Reconstrucción de camino(sig, 3, 1): ruta = [3]; consulta sig[3][1] = 0 → salta a PM, ruta = [3, 0]; consulta sig[0][1] = 1 (tramo directo) → salta a EN, ruta = [3, 0, 1]. Camino: Parque del Río → Plaza Mayor → Estación Norte; minutos: 2 + 4 = 6 = dist[3][1] ✔.

Solución 3

(a) V = 300: Floyd-Warshall cuesta 300³ = 2,7·10⁷ operaciones elementales — décimas de segundo, código mínimo, y la salida (la matriz de 9·10⁴ celdas) es exactamente lo que se quiere publicar. Dijkstra × 300 ≈ 300 · (300+900) · 8 ≈ 2,9·10⁶ — también trivial y algo menor, pero con más código y sin ventaja material. Cualquiera vale; Floyd-Warshall es defendible por simplicidad. (b) V = 20.000: Floyd-Warshall costaría 20.000³ = 8·10¹² operaciones (horas o días) y 4·10⁸ celdas de matriz (varios GB): inviable. Dijkstra desde un origen ≈ (20.000 + 60.000) · 14 ≈ 1,1·10⁶; incluso lanzándolo desde los 200 orígenes consultados (el 1 %), ≈ 2,2·10⁸: perfectamente asumible, y calculando solo lo que se consulta. Dijkstra bajo demanda, sin discusión. Moraleja: el todos-con-todos solo se materializa cuando de verdad se necesita todo.

Conclusión

Floyd-Warshall corona el módulo demostrando que la programación dinámica de 03-03 escala a grafos: el subproblema "camino mínimo usando solo las primeras k paradas como intermedias" convierte el todos-con-todos en una tabla V×V que tres bucles rellenan en Θ(V³), con actualización in-place, detección de ciclos negativos en la diagonal y una matriz de siguientes para reconstruir cualquier itinerario — todo ello tolerando los pesos negativos que a Dijkstra le estaban vedados, a cambio de un cubo que limita su uso a redes de tamaño moderado o denso. Con él se completa el repertorio: búsqueda binaria y los dos grandes ordenamientos como hijos de divide y vencerás, Dijkstra como el greedy demostradamente óptimo y Floyd-Warshall como la programación dinámica hecha matriz — seis clásicos que ya sabemos implementar, trazar, analizar por casos y, sobre todo, elegir según el problema de RutaBus que tengamos delante. Ya sabemos elegir el algoritmo correcto; el Módulo 5 empieza donde esa elección termina: aprender a exprimir su implementación — optimizar el código (05-01), la memoria (05-02) y repartir el trabajo entre varios núcleos (05-03) — porque entre un algoritmo bien elegido y un programa rápido todavía media un oficio, y ese oficio es el que viene ahora.

© Copyright 2026. Todos los derechos reservados