Ir al contenido
wannacode

Árbol de expansión mínima

En resumen: Un árbol de expansión mínima conecta todos los vértices de un grafo con el menor coste total; el algoritmo de Kruskal toma las aristas por coste y salta cada arista que cerraría un ciclo.

Problema

La red más barata

La empresa de reparto tiene seis oficinas en la ciudad y quiere conectarlas con fibra. Cada oficina debe llegar a todas las demás, directamente o a través de otras oficinas. Cavar es caro: cada cable posible tiene un precio en miles de euros (mil €).

const offices = ["Centro", "Norte", "Sur", "Este", "Oeste", "Aeropuerto"];
const cables = [
{ from: "Centro", to: "Norte", cost: 4 },
{ from: "Centro", to: "Sur", cost: 6 },
{ from: "Centro", to: "Este", cost: 8 },
{ from: "Centro", to: "Oeste", cost: 5 },
{ from: "Norte", to: "Este", cost: 9 },
{ from: "Norte", to: "Oeste", cost: 3 },
{ from: "Sur", to: "Oeste", cost: 7 },
{ from: "Sur", to: "Aeropuerto", cost: 11 },
{ from: "Este", to: "Aeropuerto", cost: 2 },
];

Tender los nueve cables cuesta 55 mil €. Pero no hacen falta todos. Si tres oficinas están conectadas en anillo, un cable del anillo sobra: quítalo y todo sigue conectado. La red más barata no tiene ningún ciclo. Un grafo conexo sin ciclos es un árbol, y un árbol con V vértices tiene exactamente V − 1 aristas: aquí 5 cables.

¿Cuáles cinco? Buscas el árbol que abarca todas las oficinas al menor precio total. Eso es un árbol de expansión mínima (MST).

La idea

El cable más barato que no cierra un ciclo

El algoritmo de Kruskal (Joseph Kruskal, 1956):

  1. Ordena los cables por precio, el más barato primero.
  2. Para cada cable: si sus dos extremos aún no están conectados, tiéndelo. Si ya están conectados por cables tendidos, el cable cerraría un ciclo: sáltalo.
  3. Para cuando haya V − 1 cables.

Al principio cada oficina es un grupo aparte. Cada cable tendido fusiona dos grupos en uno. La pregunta «¿están conectados los dos extremos?» es exactamente la pregunta «¿están en un mismo grupo?», y hay una estructura para eso: Union-Find. union(a, b) fusiona los grupos y devuelve false si a y b ya están en un mismo grupo. Una comprobación en vez de una búsqueda por el mapa.

Míralo

Kruskal por dentro

La clase UnionFind es la misma que en la lección de Union-Find, sin cambios. Un cable es un objeto con from, to y cost, e index convierte el nombre de una oficina en un número, porque Union-Find trabaja con números.

function kruskal(offices, cables) {
const index = new Map(offices.map((o, i) => [o, i]));
const sorted = [...cables].sort((x, y) => x.cost - y.cost);
const groups = new UnionFind(offices.length);
const tree = [];
for (const c of sorted) {
const a = index.get(c.from);
const b = index.get(c.to);
if (!groups.union(a, b)) continue; // un ciclo
tree.push(c);
if (tree.length === offices.length - 1) break;
}
return tree;
}
sort ordena el arreglo en el sitio, así que primero copias cables con [...cables]. La comparación devuelve un número negativo si x es más barato.
let total = 0;
for (const c of kruskal(offices, cables)) {
console.log(`${c.from} – ${c.to}: ${c.cost} mil €`);
total += c.cost;
}
console.log(`Total: ${total} mil €`);
Salida
Este – Aeropuerto: 2 mil €
Norte – Oeste: 3 mil €
Centro – Norte: 4 mil €
Centro – Sur: 6 mil €
Centro – Este: 8 mil €
Total: 23 mil €

function kruskal(offices, cables) {
const index = new Map(offices.map((o, i) => [o, i]));
const sorted = [...cables].sort((x, y) => x.cost - y.cost);
const groups = new UnionFind(offices.length);
const tree = [];
for (const c of sorted) {
const a = index.get(c.from);
const b = index.get(c.to);
if (!groups.union(a, b)) continue; // un ciclo
tree.push(c);
if (tree.length === offices.length - 1) break;
}
return tree;
}
Paso a paso, en palabras
  1. Seis oficinas, nueve cables posibles, cada uno con un precio en miles de euros. La red tiene que conectar todas las oficinas lo más barato posible. A la derecha los cables se ordenan por precio: Kruskal los mira empezando por el más barato. Al principio cada oficina es un grupo aparte.
  2. Este – Aeropuerto, 2: grupos distintos. El cable se tiende y union fusiona los dos grupos en uno (el mismo color).
  3. Norte – Oeste, 3: otra vez grupos distintos. Aparece un segundo grupo, aparte: Kruskal construye varias piezas a la vez, no un único árbol que crece.
  4. Centro – Norte, 4: la oficina Centro entra en el grupo de Norte y Oeste.
  5. Centro – Oeste, 5: los dos extremos ya están en un mismo grupo, a través de Norte. Este cable cerraría un ciclo: union devuelve false y el cable se rechaza.
  6. Centro – Sur, 6: la oficina Sur está sola, así que el cable se tiende.
  7. Sur – Oeste, 7: Sur y Oeste ya están conectadas a través de Centro. Rechazado.
  8. Centro – Este, 8: el cable más barato entre los dos grupos. Los fusiona, y ya son cinco cables: seis oficinas, cinco cables, todo está conectado.
  9. El bucle termina: Norte – Este y Sur – Aeropuerto ni siquiera se miran. Total 23 mil €, frente a 55 de los nueve cables.

Dos grupos crecen por separado: el azul (Este, Aeropuerto) y el verde, que parte de Norte y Oeste. El cable Centro – Este es el más barato entre ellos y los fusiona. Los cables Norte – Este (9) y Sur – Aeropuerto (11) nunca se miran.

Pruébalo

Predice la salida

La autopista al aeropuerto resulta ser un problema: el cable Este – Aeropuerto ahora cuesta 12. ¿Cuánto cuesta la nueva red?

const changed = [...cables.slice(0, -1), { from: "Este", to: "Aeropuerto", cost: 12 }];
const total = kruskal(offices, changed).reduce((sum, c) => sum + c.cost, 0);
console.log(`Total: ${total} mil €`);
Respuesta

Pruébalo

Completa

tree.push(c);
if (tree.length === ) break;

La idea

Por qué lo voraz acierta aquí

Kruskal es un algoritmo voraz: en cada paso toma la mejor opción del momento (el cable más barato que encaja) y nunca vuelve atrás. Lo voraz a menudo engaña: mira cuándo funciona lo voraz y cuándo falla. Aquí funciona, y la razón es la propiedad del corte.

Divide las oficinas en dos partes de cualquier manera, por ejemplo el grupo Este y todo lo demás. Los cables que van entre las dos partes son el corte. El cable más barato del corte está en el árbol mínimo. ¿Por qué? El árbol necesita al menos un cable que cruce el corte, o las dos partes no están conectadas. Si ese cable es más caro, cámbialo por el más barato: la red sigue conectada y el precio baja.

Cada cable que tiende Kruskal es el más barato del corte entre el grupo de uno de sus extremos y todo lo demás: todos los cables más baratos ya pasaron y están dentro de grupos. Centro – Este (8) es el más barato de Centro – Este, Norte – Este (9) y Sur – Aeropuerto (11), los cables entre los dos grupos.

Pruébalo

Encuentra el error

Un compañero decidió que union sobra y solo pregunta con find si los dos extremos están en un mismo grupo:

function kruskal(offices, cables) {
const index = new Map(offices.map((o, i) => [o, i]));
const sorted = [...cables].sort((x, y) => x.cost - y.cost);
const groups = new UnionFind(offices.length);
const tree = [];
for (const c of sorted) {
const a = index.get(c.from);
const b = index.get(c.to);
if (groups.find(a) === groups.find(b)) continue;
tree.push(c);
if (tree.length === offices.length - 1) break;
}
return tree;
}
let total = 0;
for (const c of kruskal(offices, cables)) {
console.log(`${c.from} – ${c.to}: ${c.cost} mil €`);
total += c.cost;
}
console.log(`Total: ${total} mil €`);
Salida
Este – Aeropuerto: 2 mil €
Norte – Oeste: 3 mil €
Centro – Norte: 4 mil €
Centro – Oeste: 5 mil €
Centro – Sur: 6 mil €
Total: 20 mil €

20 mil € en vez de 23, pero Este y Aeropuerto quedan aislados, y Centro, Norte y Oeste forman un anillo. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Lo que cuesta

Ordenar E cables cuesta O(E log E). Después cada comprobación con Union-Find es casi O(1) (mira la lección de Union-Find). En total O(E log E): la ordenación es la parte cara.

Sin Union-Find, «¿están conectados?» se pregunta con la búsqueda en profundidad sobre los cables tendidos hasta ahora:

function connected(net, from, to) {
const stack = [from];
const seen = new Set([from]);
while (stack.length > 0) {
const office = stack.pop();
if (office === to) return true;
for (const next of net.get(office)) {
if (!seen.has(next)) {
seen.add(next);
stack.push(next);
}
}
}
return false;
}
function kruskalSlow(offices, cables) {
const net = new Map(offices.map((office) => [office, []]));
const sorted = [...cables].sort((x, y) => x.cost - y.cost);
const tree = [];
for (const c of sorted) {
if (connected(net, c.from, c.to)) continue;
net.get(c.from).push(c.to);
net.get(c.to).push(c.from);
tree.push(c);
if (tree.length === offices.length - 1) break;
}
return tree;
}

La respuesta es la misma. Pero cada búsqueda puede recorrer todo el grupo, O(V), y se ejecuta para cada cable: O(E · V).

Ejecuta las dos versiones con oficinas en una cuadrícula: cada oficina puede unirse con su vecina de la derecha y la de abajo, a un precio de 1 a 1000. Se cuenta cada comparación de dos precios al ordenar, cada vecino que mira la búsqueda en profundidad y cada paso a un padre en find.

  • Kruskal con DFS: una búsqueda por los cables tendidos O(n²)
  • Kruskal con union-find O(n log n)
Algoritmon = 100n = 1000n = 10.000n = 100.000
Kruskal con DFS: una búsqueda por los cables tendidos4945677.351≈ 67.735.100≈ 6.773.510.000
Kruskal con union-find146023.827321.1713.884.598
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
function kruskal(offices, cables) {
const index = new Map(offices.map((o, i) => [o, i]));
const sorted = [...cables].sort((x, y) => x.cost - y.cost);
const groups = new UnionFind(offices.length);
const tree = [];
for (const c of sorted) {
const a = index.get(c.from);
const b = index.get(c.to);
if (!groups.union(a, b)) continue; // un ciclo
tree.push(c);
if (tree.length === offices.length - 1) break;
}
return tree;
}

Con 1000 oficinas la búsqueda en profundidad da 677 351 pasos, Union-Find 23 827. Con 100 000 oficinas Union-Find hace 3 884 598, y la búsqueda en profundidad haría miles de millones: Bench no las ejecuta, sino que las estima a partir de la curva.

La idea

No es el camino más corto

En el árbol mínimo el camino de Sur a Aeropuerto es Sur – Centro – Este – Aeropuerto: 6 + 8 + 2 = 16. El cable directo Sur – Aeropuerto cuesta 11, pero no está en la red.

Un árbol de expansión mínima ahorra en el precio total de la red. No promete nada sobre el camino entre dos oficinas. Si el precio es el retraso y quieres el camino más rápido entre dos oficinas, es otro problema: Dijkstra.

Más precisamente, para curiosos

Prim es el otro algoritmo clásico para un árbol de expansión mínima. Parte de una oficina y cada vez añade el cable más barato del árbol a una oficina de fuera. La cola es una cola de prioridad, como en Dijkstra, solo que la prioridad es el precio del cable, no el camino desde el inicio. Con un montículo (heap) binario es O(E log V). Kruskal es más simple cuando los cables son una lista, Prim cuando el grafo viene dado por vecinos y es denso.

Si algunos cables tienen el mismo precio, puede haber varios árboles mínimos, todos con el mismo precio total. Kruskal devuelve uno de ellos, según el orden de los iguales.

Dónde más: redes eléctricas y de agua, carreteras entre pueblos, placas de circuitos. Si detienes Kruskal antes de que los grupos se vuelvan uno (digamos, con k grupos), obtienes un agrupamiento (clustering): los puntos de un grupo están cerca, y los grupos están lejos unos de otros.

Ejercicio

Un cable ya tendido

Resuélvelo en tu editor.

Un cable viejo. Ya hay un cable entre Norte y Este: no cuesta nada. Escribe un método que reciba también los cables tendidos y encuentre cuáles nuevos añadir. Imprime los cables tendidos y los nuevos, y el precio de los nuevos:

Salida
Ya tendido: Norte – Este
Este – Aeropuerto: 2 mil €
Norte – Oeste: 3 mil €
Centro – Norte: 4 mil €
Centro – Sur: 6 mil €
Cables nuevos: 15 mil €

Pista: antes del bucle, fusiona en Union-Find los extremos de los cables tendidos. Para cuando quede un solo grupo.

Más tareas

  • Los rechazados. Imprime también los cables rechazados, cada uno con el motivo: qué ciclo cerraría.
  • Dos redes. La empresa quiere dos redes separadas. Detén Kruskal cuando haya dos grupos. ¿Qué oficinas comparten red?
  • Imposible. Quita todos los cables al Aeropuerto. ¿Qué devuelve Kruskal? ¿Cómo notas que la red no conecta todas las oficinas?

Test

Ponte a prueba

1. Una red conecta 6 oficinas de la forma más barata posible. ¿Cuántos cables tiene?
2. Kruskal llega a un cable cuyos dos extremos ya están en un mismo grupo. ¿Qué hace?
3. ¿Por qué Kruskal usa Union-Find en vez de una búsqueda (en profundidad) sobre los cables tendidos?
4. En el árbol de expansión mínima el camino de Sur a Aeropuerto pasa por Centro y Este, 16 mil €. El cable directo Sur – Aeropuerto cuesta 11. ¿Significa que el árbol está mal?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·