Programación · Algoritmos
Árbol de expansión mínima explicado: algoritmos de Kruskal y Prim
Respuesta corta
Un árbol de expansión mínima (MST, del inglés minimum spanning tree) de un grafo conexo, no dirigido y con pesos es un conjunto de aristas que conecta todos los nodos sin ningún ciclo y con el menor peso total posible. El algoritmo de Kruskal ordena todas las aristas y añade la más barata que no cree un ciclo, usando union-find. El algoritmo de Prim hace crecer un árbol desde un nodo inicial, añadiendo siempre la arista más barata que sale del árbol, con una cola de prioridad. Ambos se ejecutan en tiempo O(E log E).
Qué problema resuelve un árbol de expansión mínima
Supón que debes conectar varios edificios con cable, varias ciudades con carreteras o varios servidores con enlaces de red, y que cada conexión posible tiene un coste. Quieres que todos los lugares queden conectados (directa o indirectamente) con el menor coste total. Las conexiones sobrantes que forman un bucle nunca hacen falta, así que la respuesta es un árbol: V nodos unidos por exactamente V − 1 aristas. Ese árbol es el árbol de expansión mínima.
Un MST no es un árbol de caminos más cortos. Minimiza el peso total de todas las aristas, no la distancia desde un nodo inicial hasta los demás.
Por qué funciona la decisión voraz: la propiedad del corte
Divide los nodos en dos grupos cualesquiera. Entre las aristas que cruzan de un grupo al otro, la más ligera pertenece a algún árbol de expansión mínima. Ambos algoritmos se apoyan en este hecho:
| Algoritmo | Regla voraz | Estructura de datos auxiliar |
|---|---|---|
| Kruskal (1956) | Toma las aristas de la más ligera a la más pesada; conserva una arista si une dos componentes distintas. | Union-find (conjuntos disjuntos) |
| Prim (1957; también Jarník, 1930) | Hace crecer un árbol; añade repetidamente la arista más ligera desde el árbol hasta un nodo exterior. | Cola de prioridad con montículo de mínimos |
Si todos los pesos de las aristas son distintos, el MST es único, así que ambos algoritmos devuelven el mismo árbol. Con pesos iguales puede haber varios MST con el mismo total.
Ejemplo resuelto, paso a paso
Grafo no dirigido con 5 nodos y las aristas A–B 4, A–C 3, B–C 1, B–D 2, C–D 4, D–E 2, C–E 5.
Algoritmo de Kruskal, con las aristas ordenadas por peso:
| Arista | Peso | Decisión | Componentes después |
|---|---|---|---|
| B–C | 1 | Añadir | {B, C} {A} {D} {E} |
| B–D | 2 | Añadir | {B, C, D} {A} {E} |
| D–E | 2 | Añadir | {B, C, D, E} {A} |
| A–C | 3 | Añadir | {A, B, C, D, E}: 4 aristas, terminado |
| A–B, C–D, C–E | 4, 4, 5 | No hacen falta (cada una crearía un ciclo) | — |
Algoritmo de Prim desde A: la arista más barata que sale de {A} es A–C (3); desde {A, C} es C–B (1); desde {A, B, C} es B–D (2); desde {A, B, C, D} es D–E (2). El mismo árbol, con peso total 3 + 1 + 2 + 2 = 8.
Algoritmo de Kruskal en Python
Union-find guarda un puntero al padre por cada nodo. find devuelve el representante de la componente de un nodo, y dos nodos están conectados exactamente cuando tienen el mismo representante.
def kruskal(nodes, edges):
parent = {n: n for n in nodes}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
tree, total = [], 0
for w, u, v in sorted(edges):
ru, rv = find(u), find(v)
if ru != rv: # different components: no cycle
parent[ru] = rv
tree.append((u, v, w))
total += w
return tree, total
nodes = "ABCDE"
edges = [(4, "A", "B"), (3, "A", "C"), (1, "B", "C"), (2, "B", "D"),
(4, "C", "D"), (2, "D", "E"), (5, "C", "E")]
print(kruskal(nodes, edges))
# ([('B', 'C', 1), ('B', 'D', 2), ('D', 'E', 2), ('A', 'C', 3)], 8)Un union-find completo también usa unión por rango o por tamaño, que mantiene los árboles poco profundos. Solo con la compresión de caminos por mitades ya es rápido en la práctica. Puedes detener el bucle antes de tiempo cuando el árbol tenga V − 1 aristas.
Algoritmo de Prim en Python
El algoritmo de Prim necesita una lista de adyacencia. Usa eliminación perezosa, como el algoritmo de Dijkstra: inserta cada arista que sale del árbol y omite las aristas cuyo extremo lejano ya está en el árbol.
import heapq
from collections import defaultdict
def prim(edges, start):
graph = defaultdict(list)
for w, u, v in edges:
graph[u].append((w, u, v))
graph[v].append((w, v, u))
in_tree = {start}
heap = list(graph[start])
heapq.heapify(heap)
tree, total = [], 0
while heap:
w, u, v = heapq.heappop(heap)
if v in in_tree:
continue
in_tree.add(v)
tree.append((u, v, w))
total += w
for edge in graph[v]:
if edge[2] not in in_tree:
heapq.heappush(heap, edge)
return tree, total
print(prim(edges, "A"))
# ([('A', 'C', 3), ('C', 'B', 1), ('B', 'D', 2), ('D', 'E', 2)], 8)Complejidad temporal y espacial
| Algoritmo | Tiempo | Espacio | Ideal para |
|---|---|---|---|
| Kruskal | O(E log E) | O(V + E) | Grafos dispersos, o aristas ya dadas como lista. La ordenación domina; union-find es casi O(1) por operación. |
| Prim con montículo binario | O(E log V) | O(V + E) | Grafos dados como listas de adyacencia. |
| Prim con arreglo (sin montículo) | O(V²) | O(V) | Grafos densos, por ejemplo todos los pares de puntos. |
Como E es como máximo V², log E es como máximo 2 log V, así que O(E log E) y O(E log V) son del mismo orden.
Variantes y errores comunes
- Grafo no conexo: no existe un árbol de expansión. Kruskal devuelve entonces un bosque de expansión mínima (un árbol por componente); Prim desde un solo inicio cubre únicamente la componente de ese inicio. Comprueba que el árbol tenga V − 1 aristas.
- Árbol de expansión máxima: ordena las aristas de mayor a menor (o cambia el signo de los pesos). Los pesos negativos no suponen problema para ninguno de los dos algoritmos.
- Los grafos dirigidos requieren otro algoritmo (arborescencia mínima, Chu-Liu/Edmonds); Kruskal y Prim no se aplican.
- Agrupamiento (clustering): ejecuta Kruskal y detente cuando queden k componentes. El resultado es un agrupamiento de enlace simple en k grupos.
- El algoritmo de Borůvka, de 1926, añade a la vez la arista más barata de cada componente y se paraleliza bien.
Cuándo usarlo y usos en el mundo real
- Diseño de redes: la forma más barata de conectar sedes con cable, tuberías o carreteras, cuando sirve cualquier trazado conexo.
- Algoritmos de aproximación: un MST da un recorrido de como máximo el doble de la longitud óptima para el problema del viajante métrico.
- Agrupamiento y segmentación de imágenes cortando las aristas más pesadas del MST.
- Problemas de entrevista: coste mínimo para conectar todos los puntos, conectar ciudades con coste mínimo, optimizar la distribución de agua en un pueblo.
Preguntas frecuentes
¿Cuál es la diferencia entre los algoritmos de Kruskal y Prim?
Kruskal procesa todas las aristas globalmente de la más ligera a la más pesada y fusiona componentes con union-find. Prim hace crecer un único árbol desde un nodo con una cola de prioridad. Ambos encuentran un árbol de expansión mínima.
¿Es único el árbol de expansión mínima?
Es único cuando todos los pesos de las aristas son distintos. Con pesos iguales puede haber varios árboles con el mismo peso total mínimo.
¿Es lo mismo un árbol de expansión mínima que un árbol de caminos más cortos?
No. El MST minimiza la suma de todas sus aristas, mientras que un árbol de caminos más cortos, como el del algoritmo de Dijkstra, minimiza la distancia desde un origen hasta cada nodo. Los dos árboles suelen ser distintos.
¿Cuántas aristas tiene un árbol de expansión?
Exactamente V − 1 en un grafo conexo con V nodos. Con menos aristas no se pueden conectar todos los nodos, y con más se crearía un ciclo.