Apuntes DAM

Kruskal (árbol de expansión mínima)

Conecta todos los nodos de un grafo con el menor coste total: ordena las aristas por peso y coge cada una si no cierra un ciclo, algo que se comprueba con conjuntos disjuntos (union-find).

nivel avanzadoTambién: Kruskal, árbol de expansión mínima, minimum spanning tree, MST, union-find, conjuntos disjuntos, Prim

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Kruskal

Escribe las aristas con su peso: Kruskal construye el árbol de expansión mínima cogiendo siempre la arista más barata que no cierre un ciclo.

Como «A-B:4, A-C:2»: pesos de 0 a 99
  • arista pendiente

Paso 1

Se ordenan las 11 aristas de menor a mayor peso. Cada nodo empieza en su propio conjunto: un bosque de 7 árboles de un solo nodo.

1record Arista(String de, String a, int peso) {}
2
3static List<Arista> kruskal(List<String> nodos, List<Arista> aristas) {
4    aristas.sort(Comparator.comparingInt(Arista::peso));  // en el árbol = 0 de 6, peso total = 0
5    Map<String, String> padre = new HashMap<>();
6    for (String n : nodos) padre.put(n, n);
7    List<Arista> arbol = new ArrayList<>();
8    for (Arista a : aristas) {
9        String ra = raiz(padre, a.de()), rb = raiz(padre, a.a());
10        if (ra.equals(rb)) continue;
11        padre.put(ra, rb);
12        arbol.add(a);
13    }
14    return arbol;
15}
16
17static String raiz(Map<String, String> padre, String x) {
18    while (!padre.get(x).equals(x)) x = padre.get(x);
19    return x;
20}

Variables

en el árbol
0 de 6
peso total
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Un árbol de expansión de un grafo conexo es un conjunto de aristas que conecta todos los nodos sin formar ningún ciclo; con n nodos tiene siempre n − 1 aristas. El de expansión mínima (MST, *minimum spanning tree*) es el de menor peso total: la forma más barata de cablear unas oficinas, de unir pueblos con carreteras o de conectar los puntos de una red.

Kruskal es un algoritmo voraz que, en este problema, siempre acierta: ordena las aristas de menor a mayor peso y las recorre. Cada una entra en el árbol si une dos trozos distintos y se descarta si sus dos extremos ya estaban conectados, porque cerraría un ciclo. Al principio cada nodo es un trozo; al final queda uno solo.

La pregunta «¿ya están conectados?» se responde con conjuntos disjuntos (*union-find*): cada nodo apunta a un padre y el que se apunta a sí mismo es la raíz, el representante de su grupo. Dos nodos están conectados si tienen la misma raíz, y unir dos grupos es hacer que una raíz apunte a la otra. Con dos mejoras (comprimir los caminos al buscar la raíz y colgar el grupo pequeño del grande) cada operación es prácticamente O(1).

La alternativa clásica es Prim: en vez de juntar trozos sueltos, hace crecer un único árbol desde un nodo, añadiendo cada vez la arista más barata que sale de él (con una cola de prioridad, como Dijkstra). Los dos dan un árbol con el mismo peso mínimo; Kruskal suele ir mejor con grafos dispersos y Prim con densos.

Cuándo usarlo

  • Diseñar redes con el mínimo de cable, tubería o carretera: telecomunicaciones, electricidad, agua.
  • Agrupar datos (*clustering*): se calcula el árbol mínimo y se quitan sus aristas más largas; los trozos que quedan son los grupos.
  • Aproximar problemas difíciles como el del viajante: el árbol mínimo da una cota y una ruta de partida.
  • Union-find por sí solo: saber si dos elementos están en el mismo grupo mientras se van uniendo (redes, píxeles, cuentas duplicadas).

Cuándo no

  • Para el camino más corto entre dos nodos: el árbol mínimo no lo da (eso es Dijkstra).
  • En grafos dirigidos: el equivalente (arborescencia mínima) necesita otro algoritmo, el de Chu-Liu/Edmonds.
  • Si el grafo no es conexo no hay árbol, sino un bosque de expansión mínima: Kruskal lo calcula igual, pero hay que tenerlo en cuenta.

Paso a paso

  1. Ordenar. Ordena las aristas de menor a mayor peso.
  2. Cada nodo, un grupo. Inicializa los conjuntos disjuntos: cada nodo es la raíz de su propio grupo.
  3. Probar cada arista. Si las raíces de sus extremos son distintas, la arista entra en el árbol y se unen los dos grupos; si son la misma, se descarta porque cerraría un ciclo.
  4. Parar. Cuando el árbol tiene n − 1 aristas ya está completo: el resto solo puede formar ciclos.

El código

Kruskal con union-find completo

Seis sedes y once posibles cables con su coste. Los conjuntos disjuntos usan compresión de caminos y unión por tamaño; en cuanto hay n − 1 cables, se para.

Java
1import java.util.*;
2
3public class Main {
4    record Cable(String a, String b, int coste) { }
5
6    /** Conjuntos disjuntos con las dos mejoras: compresión de caminos y unión por tamaño. */
7    static class Conjuntos {
8        private final Map<String, String> padre = new HashMap<>();
9        private final Map<String, Integer> tam = new HashMap<>();
10
11        void crear(String x) {
12            padre.put(x, x);
13            tam.put(x, 1);
14        }
15
16        String raiz(String x) {
17            String r = x;
18            while (!padre.get(r).equals(r)) r = padre.get(r);
19            while (!padre.get(x).equals(r)) {                  // compresión: el camino apunta ya a la raíz
20                String sig = padre.get(x);
21                padre.put(x, r);
22                x = sig;
23            }
24            return r;
25        }
26
27        boolean unir(String x, String y) {
28            String rx = raiz(x), ry = raiz(y);
29            if (rx.equals(ry)) return false;                   // ya estaban juntos: cerraría un ciclo
30            if (tam.get(rx) < tam.get(ry)) {
31                String t = rx;
32                rx = ry;
33                ry = t;
34            }
35            padre.put(ry, rx);                                 // el grupo pequeño cuelga del grande
36            tam.put(rx, tam.get(rx) + tam.get(ry));
37            return true;
38        }
39    }
40
41    public static void main(String[] args) {
42        List<String> sedes = List.of("Central", "Norte", "Sur", "Este", "Oeste", "Puerto");
43        List<Cable> cables = new ArrayList<>(List.of(
44                new Cable("Central", "Norte", 12), new Cable("Central", "Sur", 9), new Cable("Central", "Este", 15),
45                new Cable("Central", "Oeste", 10), new Cable("Norte", "Este", 7), new Cable("Norte", "Oeste", 18),
46                new Cable("Sur", "Oeste", 11), new Cable("Sur", "Puerto", 6), new Cable("Este", "Puerto", 20),
47                new Cable("Oeste", "Puerto", 13), new Cable("Norte", "Sur", 16)));
48        cables.sort(Comparator.comparingInt(Cable::coste));
49
50        Conjuntos grupos = new Conjuntos();
51        for (String s : sedes) grupos.crear(s);
52        System.out.println("Cables de más barato a más caro:");
53        int total = 0, puestos = 0;
54        for (Cable c : cables) {
55            if (grupos.unir(c.a(), c.b())) {
56                total += c.coste();
57                puestos++;
58                System.out.println("  sí  " + c.a() + " - " + c.b() + " (" + c.coste() + ")");
59                if (puestos == sedes.size() - 1) break;        // n − 1 cables: ya está todo conectado
60            } else {
61                System.out.println("  no  " + c.a() + " - " + c.b() + " (" + c.coste() + "): cerraría un ciclo");
62            }
63        }
64        System.out.println("Coste total: " + total + " con " + puestos + " cables para " + sedes.size() + " sedes");
65    }
66}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Cables de más barato a más caro:
  sí  Sur - Puerto (6)
  sí  Norte - Este (7)
  sí  Central - Sur (9)
  sí  Central - Oeste (10)
  no  Sur - Oeste (11): cerraría un ciclo
  sí  Central - Norte (12)
Coste total: 44 con 5 cables para 6 sedes

Prim: el árbol crece desde un nodo

Las mismas sedes: Prim parte de Central y añade siempre el cable más barato que sale del árbol. Elige los mismos cables, en otro orden, con el mismo total.

Java
1import java.util.*;
2
3public class Main {
4    record Cable(String a, String b, int coste) { }
5
6    public static void main(String[] args) {
7        List<Cable> cables = List.of(
8                new Cable("Central", "Norte", 12), new Cable("Central", "Sur", 9), new Cable("Central", "Este", 15),
9                new Cable("Central", "Oeste", 10), new Cable("Norte", "Este", 7), new Cable("Norte", "Oeste", 18),
10                new Cable("Sur", "Oeste", 11), new Cable("Sur", "Puerto", 6), new Cable("Este", "Puerto", 20),
11                new Cable("Oeste", "Puerto", 13), new Cable("Norte", "Sur", 16));
12        Map<String, List<Cable>> red = new LinkedHashMap<>();
13        for (Cable c : cables) {                               // cada cable, en los dos sentidos
14            red.computeIfAbsent(c.a(), k -> new ArrayList<>()).add(c);
15            red.computeIfAbsent(c.b(), k -> new ArrayList<>()).add(new Cable(c.b(), c.a(), c.coste()));
16        }
17        Set<String> dentro = new HashSet<>(List.of("Central"));
18        PriorityQueue<Cable> borde = new PriorityQueue<>(Comparator.comparingInt(Cable::coste));
19        borde.addAll(red.get("Central"));
20        int total = 0;
21        System.out.println("Prim desde Central:");
22        while (!borde.isEmpty() && dentro.size() < red.size()) {
23            Cable c = borde.poll();                            // el cable más barato que sale del árbol
24            if (dentro.contains(c.b())) continue;              // los dos extremos ya dentro: ciclo
25            dentro.add(c.b());
26            total += c.coste();
27            System.out.println("  + " + c.a() + " - " + c.b() + " (" + c.coste() + ")");
28            for (Cable s : red.get(c.b()))
29                if (!dentro.contains(s.b())) borde.add(s);
30        }
31        System.out.println("Coste total: " + total + ", el mismo que con Kruskal");
32    }
33}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Prim desde Central:
  + Central - Sur (9)
  + Sur - Puerto (6)
  + Central - Oeste (10)
  + Central - Norte (12)
  + Norte - Este (7)
Coste total: 44, el mismo que con Kruskal

Traza: Kruskal con el grafo del visualizador

AristaPesoRaíces de los extremosDecisiónPeso del árbol
A-D5A y Dentra5
C-E5C y Eentra10
D-F6D y Fentra16
A-B7F y Bentra23
B-E7B y Eentra30
B-C8E y Ese descarta: ciclo30
E-F8E y Ese descarta: ciclo30
B-D9E y Ese descarta: ciclo30
E-G9E y Gentra39
F-G11G y Gse descarta: ciclo39
D-E15G y Gse descarta: ciclo39

Con E-G ya hay 6 aristas para 7 nodos: el árbol está completo y todas las que quedan cierran ciclos. Peso mínimo: 39.

Complejidad

Algoritmo u operaciónCosteNota
KruskalO(E log E)Lo que cuesta es ordenar las aristas
Prim con montículoO(E log V)Como Dijkstra
Prim con matriz de adyacenciaO(V²)Mejor en grafos muy densos
union-find con las dos mejorasO(α(n)) amortizadoα crece tan despacio que no pasa de 4 para ningún tamaño real
union-find sin mejorasO(n) en el peor casoLos árboles pueden degenerar en listas

α es la inversa de la función de Ackermann: en la práctica, una constante.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(n log n)
  • Caso medio: O(n log n)
  • Peor caso: O(n log n)

O(E log E): manda ordenar las aristas. La unión-búsqueda con compresión de caminos es casi O(1) por operación; la versión corta de aquí, sin ella, puede llegar a O(V) por consulta. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Diseño de redes físicas: fibra entre edificios, tendido eléctrico, tuberías.
  • El protocolo STP de los switches construye un árbol de expansión de la red para evitar bucles (no usa Kruskal, pero es la misma idea).
  • La segmentación de imágenes y el *clustering* jerárquico de enlace simple se basan en el árbol mínimo.
  • Union-find aparece en compiladores (inferencia de tipos), en simulaciones de percolación y en la detección de cuentas duplicadas.
  • Kruskal con pesos aleatorios genera laberintos perfectos: un único camino entre cada par de casillas.

Errores típicos

  • Comprobar si hay ciclo con un recorrido completo (DFS) por cada arista: funciona, pero es O(E · V); para eso está union-find.
  • Comparar padre[a] == padre[b] en vez de las raíces: dos nodos del mismo grupo pueden tener padres distintos.
  • Unir los nodos (padre[a] = b) en vez de sus raíces: se rompen los grupos.
  • No comprobar al final si hay n − 1 aristas: con un grafo no conexo el resultado es un bosque, no un árbol.
  • Confundir el árbol de expansión mínima con los caminos más cortos: el camino entre dos nodos dentro del árbol no tiene por qué ser el más corto.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Conjuntos disjuntos

Hay n elementos numerados de 0 a n − 1, cada uno en su propio grupo. Las órdenes unen grupos o preguntan si dos elementos están en el mismo. El main ya lee las órdenes y lleva la cuenta de los grupos: completa raiz (con compresión de caminos) y unir (el grupo pequeño cuelga del grande).

  • Primera línea: n (si no es un número de 1 en adelante, Número de elementos no válido: «…»).
  • Órdenes: une 3 5 → 3 y 5 unidos (quedan 4 grupos) o 3 y 5 ya estaban en el mismo grupo; ? 3 5 → 3 y 5: mismo grupo o 3 y 5: grupos distintos; grupos → Hay 4 grupos.
  • Otra cosa (o un elemento fuera de rango): Orden no válida: «…».
JavaConjuntos disjuntosMedio

Ejemplo

Entrada (lo que se escribe por teclado)
6
une 0 1
une 2 3
? 1 0
? 1 2
une 1 3
? 0 2
une 0 2
grupos
Salida esperada
0 y 1 unidos (quedan 5 grupos)
2 y 3 unidos (quedan 4 grupos)
1 y 0: mismo grupo
1 y 2: grupos distintos
1 y 3 unidos (quedan 3 grupos)
0 y 2: mismo grupo
0 y 2 ya estaban en el mismo grupo
Hay 3 grupos
Test oculto #3
Test oculto #4
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static int[] padre, tam;
5
6    /** El representante del grupo de x, comprimiendo el camino: todo lo recorrido apunta ya a la raíz. */
7    static int raiz(int x) {
8        if (padre[x] != x) padre[x] = raiz(padre[x]);
9        return padre[x];
10    }
11
12    /** Junta los grupos de a y b (el pequeño cuelga del grande). Devuelve false si ya estaban juntos. */
13    static boolean unir(int a, int b) {
14        int ra = raiz(a), rb = raiz(b);
15        if (ra == rb) return false;
16        if (tam[ra] < tam[rb]) {
17            int t = ra;
18            ra = rb;
19            rb = t;
20        }
21        padre[rb] = ra;
22        tam[ra] += tam[rb];
23        return true;
24    }
25
26    public static void main(String[] args) {
27        Scanner sc = new Scanner(System.in);
28        String primera = sc.hasNextLine() ? sc.nextLine().trim() : "";
29        if (!primera.matches("\\d{1,6}") || Integer.parseInt(primera) < 1) {
30            System.out.println("Número de elementos no válido: «" + primera + "»");
31            return;
32        }
33        int n = Integer.parseInt(primera);
34        padre = new int[n];
35        tam = new int[n];
36        for (int i = 0; i < n; i++) {
37            padre[i] = i;
38            tam[i] = 1;
39        }
40        int grupos = n;
41        while (sc.hasNextLine()) {
42            String linea = sc.nextLine().trim();
43            if (linea.isEmpty()) continue;
44            String[] p = linea.split("\\s+");
45            if (p.length == 1 && p[0].equals("grupos")) {
46                System.out.println("Hay " + grupos + (grupos == 1 ? " grupo" : " grupos"));
47                continue;
48            }
49            if (p.length != 3 || !(p[0].equals("une") || p[0].equals("?")) || !p[1].matches("\\d{1,6}") || !p[2].matches("\\d{1,6}")
50                    || Integer.parseInt(p[1]) >= n || Integer.parseInt(p[2]) >= n) {
51                System.out.println("Orden no válida: «" + linea + "»");
52                continue;
53            }
54            int a = Integer.parseInt(p[1]), b = Integer.parseInt(p[2]);
55            if (p[0].equals("?")) System.out.println(a + " y " + b + (raiz(a) == raiz(b) ? ": mismo grupo" : ": grupos distintos"));
56            else if (unir(a, b)) {
57                grupos--;
58                System.out.println(a + " y " + b + " unidos (quedan " + grupos + (grupos == 1 ? " grupo)" : " grupos)"));
59            } else System.out.println(a + " y " + b + " ya estaban en el mismo grupo");
60        }
61    }
62}

Con las dos mejoras, la altura de los árboles se mantiene diminuta y cada operación es casi O(1): se pueden procesar millones de uniones.

Sin unión por tamaño, unir siempre en el mismo sentido puede crear una cadena de n elementos, y cada raiz sin compresión la recorrería entera.

2. La red de fibra más barata

Cada línea es un posible cable entre dos sedes con su coste. Elige los cables para conectar todas las sedes con el menor coste total (Kruskal) y escríbelos en el orden en que se eligen. El main lee los cables y escribe el resultado: completa elegir, con tu propio union-find.

  • Entrada: Madrid Toledo 70 (sede, sede, coste). Línea mal escrita o que une una sede consigo misma: Línea no válida: «…».
  • Salida: un cable elegido por línea (Madrid - Toledo: 70) y Coste total: 245 (4 cables para 5 sedes).
  • Si no se puede conectar todo: los cables elegidos y Imposible conectarlo todo: quedan 2 grupos sin unir.
  • Si dos cables cuestan lo mismo, se prueba antes el que aparece antes en la entrada (una ordenación estable lo respeta).
JavaLa red de fibra más barataDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
Central Norte 12
Central Sur 9
Central Este 15
Central Oeste 10
Norte Este 7
Norte Oeste 18
Sur Oeste 11
Sur Puerto 6
Este Puerto 20
Oeste Puerto 13
Norte Sur 16
Salida esperada
Sur - Puerto: 6
Norte - Este: 7
Central - Sur: 9
Central - Oeste: 10
Central - Norte: 12
Coste total: 44 (5 cables para 6 sedes)
Test oculto #3
Test oculto #4
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    record Cable(String a, String b, int coste) { }
5
6    static final List<Cable> cables = new ArrayList<>();      // en el orden de la entrada
7    static final Set<String> sedes = new TreeSet<>();
8
9    static final Map<String, String> padre = new HashMap<>();
10
11    static String raiz(String x) {
12        while (!padre.get(x).equals(x)) {
13            padre.put(x, padre.get(padre.get(x)));             // compresión a medias: salta de dos en dos
14            x = padre.get(x);
15        }
16        return x;
17    }
18
19    /** Kruskal: los cables del árbol de expansión mínima, en el orden en que se eligen. Si dos cuestan
20        lo mismo, se prueba antes el que aparece antes en la entrada. */
21    static List<Cable> elegir() {
22        for (String s : sedes) padre.put(s, s);
23        List<Cable> orden = new ArrayList<>(cables);
24        orden.sort(Comparator.comparingInt(Cable::coste));     // estable: los empates mantienen su orden
25        List<Cable> elegidos = new ArrayList<>();
26        for (Cable c : orden) {
27            String ra = raiz(c.a()), rb = raiz(c.b());
28            if (ra.equals(rb)) continue;                       // cerraría un ciclo
29            padre.put(ra, rb);
30            elegidos.add(c);
31        }
32        return elegidos;
33    }
34
35    public static void main(String[] args) {
36        Scanner sc = new Scanner(System.in);
37        while (sc.hasNextLine()) {
38            String linea = sc.nextLine().trim();
39            if (linea.isEmpty()) continue;
40            String[] p = linea.split("\\s+");
41            if (p.length != 3 || !p[2].matches("\\d{1,6}") || p[0].equals(p[1])) {
42                System.out.println("Línea no válida: «" + linea + "»");
43                continue;
44            }
45            cables.add(new Cable(p[0], p[1], Integer.parseInt(p[2])));
46            sedes.add(p[0]);
47            sedes.add(p[1]);
48        }
49        List<Cable> elegidos = elegir();
50        int total = 0;
51        for (Cable c : elegidos) {
52            System.out.println(c.a() + " - " + c.b() + ": " + c.coste());
53            total += c.coste();
54        }
55        int grupos = sedes.size() - elegidos.size();
56        if (grupos > 1) System.out.println("Imposible conectarlo todo: quedan " + grupos + " grupos sin unir");
57        else System.out.println("Coste total: " + total + " (" + elegidos.size() + " cables para " + sedes.size() + " sedes)");
58    }
59}

La regla de los empates no cambia el coste total (todos los árboles mínimos pesan lo mismo), pero sí qué cables se eligen; por eso hay que fijarla para que la salida sea única.

El número de grupos que quedan es el número de sedes menos el de cables elegidos: cada cable une dos grupos en uno.

Test

Test: Kruskal (árbol de expansión mínima)

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Cuántas aristas tiene un árbol de expansión de un grafo conexo con n nodos?

  2. 2.¿Cuándo descarta Kruskal una arista?

  3. 3.En union-find, ¿cuándo están a y b en el mismo grupo?

  4. 4.¿Qué domina el coste de Kruskal?

  5. 5.¿En qué se diferencia Prim de Kruskal?

Relacionado