En la lección anterior aprendimos a movernos por la red de Rutalia al mínimo coste. Esta lección plantea un problema distinto que suena parecido y se confunde constantemente con él: no queremos ir de un sitio a otro, queremos conectarlo todo gastando lo mínimo. Rutalia va a desplegar una red de casilleros inteligentes en las 9 zonas de la ciudad, unidos por fibra óptica tendida a lo largo de las calles: ¿qué tramos cablear para que todos los casilleros queden comunicados con el menor coste total de obra? La respuesta es el árbol de expansión mínima (MST), y trae dos regalos: la segunda semilla de 01-04 —el union-find que sostiene a Kruskal— y una lección profunda sobre algoritmos voraces: aquí, a diferencia de los contraejemplos de 02-02, la avaricia es demostrablemente óptima.

Contenido

  1. Árboles de expansión: definición y por qué el mínimo importa
  2. La propiedad de corte: por qué lo voraz aquí SÍ es óptimo
  3. Kruskal: ordenar aristas y cobrar la semilla del union-find
  4. Prim: hacer crecer el árbol con un heap
  5. Kruskal vs Prim: tabla y criterio de elección
  6. MST vs caminos mínimos: el árbol de Dijkstra NO es el MST

Árboles de expansión: definición y por qué el mínimo importa

Un árbol de expansión (spanning tree) de un grafo conexo G = (V, E) es un subconjunto de aristas que conecta todos los vértices y no contiene ciclos. Como todo árbol, tiene exactamente n − 1 aristas: ni una más (habría ciclo) ni una menos (quedaría algo suelto). Un grafo tiene en general muchísimos árboles de expansión; el árbol de expansión mínima (MST) es el de menor suma de pesos.

¿Por qué "árbol"? Piensa en la fibra de los casilleros: si el cableado formara un ciclo, podrías quitar cualquier arista del ciclo y todo seguiría conectado — habrías pagado una zanja inútil. La solución óptima de un problema de conexión pura nunca contiene ciclos, luego es un árbol.

Para la red de casilleros reutilizamos el grafo canónico de 03-01 reinterpretando los pesos: donde antes leíamos "minutos de furgoneta", ahora leemos "coste de obra en miles de euros" (proporcional a la longitud del tramo, así que los mismos números sirven). La pregunta: ¿qué 8 tramos de los 15 se cablean?

Aplicaciones del mismo patrón, más allá de la fibra: redes eléctricas y de agua, diseño de circuitos, agrupamiento de datos (el clustering jerárquico de 05-05 tiene al MST escondido dentro), y aproximaciones al TSP (el famoso 2-aproximado se construye paseando por un MST).

La propiedad de corte: por qué lo voraz aquí SÍ es óptimo

En 02-02 vimos algoritmos voraces estrellarse: el "vecino más cercano" del TSP daba 43,1 km frente al óptimo de 35,22, y la mochila por valor/peso fallaba con contraejemplos de tres objetos. La pregunta honesta es: ¿por qué aquí va a funcionar elegir "la arista más barata"? La respuesta tiene nombre propio:

Propiedad de corte. Divide los vértices en dos grupos no vacíos (S, V∖S) — un corte. Entre todas las aristas que cruzan el corte, sea e la de peso mínimo (supón pesos distintos para simplificar). Entonces e pertenece a todo MST.

Demostración por intercambio (la técnica clásica para justificar voraces): supón un árbol de expansión T que no contiene a e = {u, v}. Como T conecta u con v, contiene un camino entre ambos, y ese camino cruza el corte por al menos una arista f (≠ e, y por definición de e: peso(f) > peso(e)). Quita f y añade e: el resultado T′ sigue conectando todo (e re-empalma las dos mitades que separó quitar f), sigue teniendo n−1 aristas, y pesa menos que T. Luego T no era mínimo. ∎

graph LR
    subgraph S
        u((u)); a((·))
    end
    subgraph "V - S"
        v((v)); b((·))
    end
    u -.e mínima.- v
    a -. f, más cara .- b

La moraleja general, que vale su peso en oro: lo voraz no es fiable ni sospechoso por defecto; es correcto exactamente cuando el problema tiene una estructura de intercambio que lo garantiza. El MST la tiene (los matemáticos dicen que las aristas sin ciclos forman una matroide); el TSP y la mochila 0/1, no. Dijkstra (03-03) era el otro voraz con certificado: su invariante era también un argumento de intercambio disfrazado.

Ambos algoritmos de esta lección son aplicaciones directas de la propiedad de corte; solo cambia qué cortes miran.

Kruskal: ordenar aristas y cobrar la semilla del union-find

Estrategia de Kruskal: recorrer las aristas de más barata a más cara, aceptando cada una salvo que forme ciclo con las ya aceptadas. La propiedad de corte lo respalda: cuando una arista {u, v} es aceptada, es la más barata que cruza el corte "componente de u frente al resto" (todas las más baratas ya se procesaron y no separaban esas mitades).

¿Y cómo se detecta "forma ciclo" eficientemente? Una arista forma ciclo si sus dos extremos ya están en la misma componente. Es decir: necesitamos pertenencia a componentes con aristas que van llegando una a una — exactamente el caso que en 03-02 dijimos que BFS resuelve mal y union-find resuelve de maravilla. La semilla de 01-04, cobrada:

class UnionFind:
    """Union-find con compresión de caminos y unión por rango (01-04)."""
    def __init__(self, elementos):
        self.padre = {x: x for x in elementos}
        self.rango = {x: 0 for x in elementos}

    def find(self, x):
        if self.padre[x] != x:
            self.padre[x] = self.find(self.padre[x])   # compresión de caminos
        return self.padre[x]

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False                # ya conectados: la arista formaría ciclo
        if self.rango[rx] < self.rango[ry]:
            rx, ry = ry, rx             # cuelga el árbol bajo del alto
        self.padre[ry] = rx
        if self.rango[rx] == self.rango[ry]:
            self.rango[rx] += 1
        return True                     # unión efectuada

def kruskal(nodos, aristas):
    """aristas: lista de (peso, u, v). Devuelve (coste_total, aristas_del_MST)."""
    uf = UnionFind(nodos)
    mst, total = [], 0
    for peso, u, v in sorted(aristas):          # de más barata a más cara
        if uf.union(u, v):                      # no forma ciclo: aceptar
            mst.append((u, v, peso))
            total += peso
            if len(mst) == len(nodos) - 1:      # árbol completo: parar
                break
    return total, mst

NODOS = ["ALM", "MER", "EST", "UNI", "RIO", "CEN", "IND", "HOS", "PAR"]
ARISTAS = [
    (3, "ALM", "RIO"), (4, "ALM", "MER"), (7, "ALM", "EST"), (12, "ALM", "CEN"),
    (5, "MER", "CEN"), (6, "MER", "UNI"), (3, "EST", "UNI"), (9, "EST", "IND"),
    (8, "UNI", "CEN"), (5, "UNI", "HOS"), (6, "RIO", "CEN"), (8, "RIO", "PAR"),
    (4, "CEN", "HOS"), (6, "IND", "HOS"), (7, "PAR", "HOS"),
]

total, mst = kruskal(NODOS, ARISTAS)
print(total)   # 37
print(mst)
# [('ALM','RIO',3), ('EST','UNI',3), ('ALM','MER',4), ('CEN','HOS',4),
#  ('MER','CEN',5), ('UNI','HOS',5), ('IND','HOS',6), ('PAR','HOS',7)]

Traza completa sobre la red de casilleros — el "vídeo" del algoritmo:

Arista (coste) ¿Ciclo? Decisión Componentes tras la decisión
ALM–RIO (3) No ✓ acepta {ALM,RIO} y 7 sueltos
EST–UNI (3) No ✓ acepta {ALM,RIO} {EST,UNI} …
ALM–MER (4) No ✓ acepta {ALM,RIO,MER} {EST,UNI} …
CEN–HOS (4) No ✓ acepta {ALM,RIO,MER} {EST,UNI} {CEN,HOS} …
MER–CEN (5) No ✓ acepta {ALM,RIO,MER,CEN,HOS} {EST,UNI} …
UNI–HOS (5) No ✓ acepta {ALM,RIO,MER,CEN,HOS,EST,UNI} {IND} {PAR}
MER–UNI (6) ✗ rechaza sin cambios
RIO–CEN (6) ✗ rechaza sin cambios
IND–HOS (6) No ✓ acepta falta solo PAR
ALM–EST (7) ✗ rechaza sin cambios
PAR–HOS (7) No ✓ acepta árbol completo: 8 aristas, coste 37

Coste total de la obra: 37.000 €, frente a los 87.000 que costaría cablear los 15 tramos. Nótese qué rechaza Kruskal: justo las aristas "redundantes" que cerrarían anillos — incluida la avenida ALM–CEN de 12, que ni llega a examinarse.

Complejidad: ordenar domina, O(m log m) = O(m log n); las 2m operaciones de union-find cuestan O(m · α(n)) — esa α, la inversa de Ackermann que vimos en 01-04, es ≤ 4 para cualquier grafo del mundo físico. En la práctica: "ordenar + casi gratis".

Prim: hacer crecer el árbol con un heap

Prim aplica la misma propiedad de corte con otra táctica: mantener un único árbol que crece desde un nodo inicial, añadiendo en cada paso la arista más barata que sale del árbol hacia fuera (el corte es siempre "árbol actual frente al resto"). ¿Y quién entrega "la arista más barata de la frontera"? Otra vez el heap de 01-04 — el mismo motor que Dijkstra, con una diferencia crucial en la prioridad:

import heapq

def prim(grafo, inicio):
    """grafo: {nodo: {vecino: peso}}. Devuelve (coste_total, aristas_del_MST)."""
    en_arbol = {inicio}
    mst, total = [], 0
    # prioridad = PESO DE LA ARISTA (no distancia acumulada: eso era Dijkstra)
    heap = [(peso, inicio, v) for v, peso in grafo[inicio].items()]
    heapq.heapify(heap)

    while heap and len(en_arbol) < len(grafo):
        peso, u, v = heapq.heappop(heap)
        if v in en_arbol:
            continue                    # entrada obsoleta: v ya fue capturado
        en_arbol.add(v)
        mst.append((u, v, peso))
        total += peso
        for w, p in grafo[v].items():   # nuevas aristas de frontera
            if w not in en_arbol:
                heapq.heappush(heap, (p, v, w))
    return total, mst

RED = {u: {} for u in NODOS}
for p, u, v in ARISTAS:
    RED[u][v] = p
    RED[v][u] = p

total, mst = prim(RED, "ALM")
print(total)   # 37 — el mismo árbol que Kruskal

Compara mentalmente con el Dijkstra de 03-03: mismo esqueleto (heap, cerrados, borrado perezoso), pero la tupla del heap lleva peso_de_la_arista en vez de distancia_acumulada. Ese único cambio transforma "estar cerca del origen" en "estar cerca del árbol". De nuevo la gran familia: cola→BFS, pila→DFS, heap por distancia→Dijkstra, heap por arista→Prim.

Complejidad con heap binario: cada arista entra a lo sumo una vez al heap ⇒ O(m log n), como Kruskal. Con matriz de adyacencia y sin heap existe una variante O(n²), que en grafos densos (m ≈ n²) es de hecho mejor.

Kruskal vs Prim: tabla y criterio de elección

Kruskal Prim
Idea Aristas globales de barata a cara Un árbol que crece desde un nodo
Estructura clave Union-find (01-04) Heap (01-04)
Estado intermedio Bosque (varios fragmentos) Siempre un único árbol conexo
Coste O(m log n) O(m log n) heap; O(n²) matriz
Brilla en… Grafos dispersos; aristas ya ordenadas o en streaming Grafos densos (variante O(n²)); cuando ya tienes lista de adyacencia
Extra Con las aristas preordenadas queda casi lineal; da clustering si lo paras antes No necesita ver todas las aristas si el heap se gestiona bien

Para la red de casilleros (dispersa: 15 aristas, 9 nodos) ambos son instantáneos e idénticos en resultado; a escala de ciudad, Kruskal suele ser el más cómodo en grafos viarios dispersos, y Prim O(n²) gana en grafos densos tipo "matriz de distancias todo-con-todo". Si los pesos son todos distintos, el MST es único y ambos devuelven exactamente el mismo árbol; con empates pueden diferir en aristas, nunca en el coste total.

MST vs caminos mínimos: el árbol de Dijkstra NO es el MST

Dijkstra también produce un árbol (el de padres, con n−1 aristas). Es tentador creer que ese árbol es el MST: no lo es, y confundirlos es un error de diseño caro. Optimizan cosas distintas:

  • Árbol de Dijkstra desde s: minimiza la distancia de s a cada nodo. Sirve al repartidor que sale del almacén.
  • MST: minimiza la suma total de las aristas elegidas. Sirve al que paga las zanjas de la fibra.

Contraejemplo mínimo, tres zonas:

graph LR
    A((A)) ---|1| B((B))
    B ---|1| C((C))
    A ---|1.5| C
  • MST: {A–B, B–C}, coste total 2 (descarta la arista de 1,5).
  • Árbol de Dijkstra desde A: hasta C conviene la directa (1,5 < 1+1), así que {A–B, A–C}, coste total 2,5.

Ninguno "se equivoca": en el MST, ir de A a C cuesta 2 (rodeando por B) aunque la red entera salga más barata; en el árbol de Dijkstra, C está a 1,5 pero la infraestructura total es más cara. Y en la red de Rutalia la diferencia es escandalosa: el árbol de Dijkstra desde ALM pesa 46 (usa ALM–EST 7, MER–UNI 6, RIO–PAR 8, EST–IND 9), mientras el MST pesa 37 (prefiere EST–UNI 3, UNI–HOS 5, IND–HOS 6, PAR–HOS 7). Un 24% más caro por optimizar la métrica equivocada.

Árbol de Dijkstra MST
Minimiza Distancia origen→cada nodo Suma total de aristas
Depende del origen No
Camino dentro del árbol Óptimo desde el origen Puede ser arbitrariamente malo
Pregunta de negocio "¿Cómo reparto desde el almacén?" "¿Qué infraestructura construyo?"

Errores Comunes y Consejos

  • Usar el árbol de Dijkstra como red de infraestructura (o el MST como tabla de rutas): el contraejemplo de tres nodos debería bastar; en Rutalia la confusión cuesta un 24% de sobrecoste o rutas absurdas. Primero decide qué se minimiza, luego elige algoritmo.
  • Detectar ciclos en Kruskal con BFS/DFS sobre las aristas aceptadas: correcto pero O(n) por arista ⇒ O(n·m) total. El union-find lo deja en α(n) por consulta; para eso lo sembramos en 01-04.
  • Olvidar la compresión de caminos o la unión por rango: el union-find degenera en listas y find se vuelve O(n). Son cuatro líneas; escríbelas siempre.
  • No detenerse al llegar a n−1 aristas en Kruskal: el resultado sigue siendo correcto (todo lo demás se rechazaría), pero se procesan aristas caras inútilmente. El break es gratis.
  • Aplicar MST a un grafo no conexo: no existe árbol de expansión; Kruskal devuelve silenciosamente un bosque de expansión mínima. Comprueba len(mst) == n - 1 al terminar y decide qué significa el déficit en tu problema (en Rutalia: zonas sin fibra).
  • Consejo: cuando un voraz te tiente, busca el argumento de intercambio ("si la solución óptima no usara mi elección, la cambio y no empeora"). Si lo encuentras, tienes algoritmo y demostración; si no aparece, sospecha — recuerda el vecino más cercano del TSP.

Ejercicios

  1. La fibra que faltaba. El ayuntamiento prohíbe abrir zanja en el tramo UNI–HOS (coste 5). Recalcula el MST con Kruskal sin esa arista. ¿Cuánto encarece la prohibición? ¿Qué arista entra en sustitución y por qué esa, a la luz de la propiedad de corte?
  2. Arista segura a mano. Sin ejecutar ningún algoritmo, usa la propiedad de corte con S = {IND} para demostrar qué arista incidente a IND está en todo MST de la red de casilleros. Verifica contra la traza de Kruskal.
  3. Kruskal como agrupador. Si detienes Kruskal cuando quedan exactamente 3 componentes, obtienes una partición de las zonas en 3 grupos "naturalmente cercanos" (así funciona el clustering de enlace simple, que reaparecerá en 05-05). Modifica kruskal para aceptar un parámetro k_componentes y calcula los 3 grupos de la red de casilleros.

Soluciones

Ejercicio 1:

sin_uni_hos = [(p, u, v) for p, u, v in ARISTAS if {u, v} != {"UNI", "HOS"}]
total2, mst2 = kruskal(NODOS, sin_uni_hos)
print(total2)   # 38  (antes 37): la prohibición cuesta 1.000 EUR

Entra MER–UNI (6) en lugar de UNI–HOS (5). Explicación por corte: al eliminar UNI–HOS, en el momento en que Kruskal tiene los fragmentos {ALM,RIO,MER,CEN,HOS} y {EST,UNI}, las aristas que cruzan ese corte son MER–UNI (6), RIO–CEN… no —RIO y CEN están del mismo lado—; cruzan MER–UNI (6), UNI–CEN (8) y ALM–EST (7): la mínima disponible pasa a ser MER–UNI, y la propiedad de corte la convierte en obligatoria. El MST solo empeora en la diferencia 6 − 5 = 1.

Ejercicio 2:

Con S = {IND}, las aristas que cruzan el corte son todas las incidentes a IND: EST–IND (9) e IND–HOS (6). La mínima es IND–HOS (6), luego pertenece a todo MST — sin ejecutar nada. La traza de Kruskal lo confirma (IND–HOS aceptada; EST–IND ni se considera antes de completar el árbol). Este truco del "corte de un solo nodo" da gratis una arista segura por cada vértice: la más barata de cada nodo siempre está en el MST (con pesos distintos).

Ejercicio 3:

def kruskal_clusters(nodos, aristas, k_componentes):
    uf = UnionFind(nodos)
    aceptadas = 0
    for peso, u, v in sorted(aristas):
        if len(nodos) - aceptadas == k_componentes:
            break                        # ya hay exactamente k grupos
        if uf.union(u, v):
            aceptadas += 1
    grupos = {}
    for n in nodos:
        grupos.setdefault(uf.find(n), set()).add(n)
    return list(grupos.values())

print(kruskal_clusters(NODOS, ARISTAS, 3))
# [{'ALM', 'RIO', 'MER', 'CEN', 'HOS', 'EST', 'UNI'}, {'IND'}, {'PAR'}]

Cada unión reduce el número de componentes en 1 (empezamos con n = 9); por eso basta contar aceptadas. Los grupos resultantes son elocuentes: el núcleo urbano densamente conectado por un lado, y el Polígono y el Parque —las zonas periféricas de grado 2 que venimos señalando desde 03-01— como satélites. Kruskal no solo construye redes: revela estructura.

Conclusión

El MST responde a la pregunta de infraestructura —conectarlo todo al mínimo coste total— y su solución voraz no es un golpe de suerte: la propiedad de corte, demostrada por intercambio, certifica que la arista más barata de cualquier corte es obligatoria. Kruskal la explota ordenando aristas y vigilando ciclos con el union-find de 01-04 (segunda semilla cobrada: las dos promesas de aquella lección están saldadas); Prim la explota haciendo crecer un árbol con el mismo heap de Dijkstra pero priorizando el peso de la arista, no la distancia acumulada. Y hemos deslindado con un contraejemplo lo que el MST no es: el árbol de Dijkstra optimiza distancias desde un origen, el MST optimiza la suma — confundirlos costaría a Rutalia un 24% en zanjas. Hasta aquí, nuestras aristas medían coste de atravesarlas. La próxima lección les da un significado nuevo: capacidad — cuántos paquetes por hora caben por cada calle. Con ello entramos en los problemas de flujo máximo (03-05): cuál es el caudal máximo de reparto entre el almacén y un barrio en hora punta, y cuál es el cuello de botella que lo limita.

© Copyright 2026. Todos los derechos reservados