← Todas las estructuras

Minimum Spanning Tree

Grafos · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Graphs

El problema

Conectar un conjunto de sitios en una única red — cada sitio alcanzable desde todos los demás — casi nunca requiere construir todos los enlaces posibles. Lo que se necesita es el subconjunto más barato de enlaces candidatos que aun así conecte todo, sin incluir ningún enlace redundante (que forme un ciclo). Probar todos los subconjuntos posibles de enlaces es combinatoriamente inviable pasado un puñado de sitios.

La solución

Ordena todas las aristas candidatas de forma ascendente por peso, y luego recorre la lista ordenada de manera voraz: agrega una arista solo si sus dos extremos aún no están conectados por aristas ya agregadas hasta el momento. Si no, sáltala — agregarla solo cerraría un ciclo, lo cual nunca puede hacer un árbol de expansión más barato, solo le añadiría una arista redundante. "¿Ya conectados?" es exactamente la pregunta que el propio módulo Union-Find de este repositorio existe para responder en O(1) amortizado casi constante, lo que es lo que mantiene el costo total de este algoritmo dominado por el ordenamiento, en lugar de por las verificaciones de conectividad.

flowchart LR
    A((A)) -- 1 --> B((B))
    B -- 2 --> C((C))
    A -. 3 .-> C
    C -- 4 --> D((D))

En el diagrama anterior, la arista discontinua A-C (peso 3) se omite: para cuando el algoritmo de Kruskal la considera, A y C ya están conectados a través de B, así que agregarla solo crearía un ciclo.

Operación Costo Por qué
computeMst O(E log E) dominado por el ordenamiento de la lista de aristas; la verificación de ciclo mediante union-find en cada arista es casi O(1) amortizado

Ejemplo clásico

classic/KruskalMinimumSpanningTree implementa el algoritmo de Kruskal desde cero, dependiendo del propio módulo graphs:union-find de este repositorio (UnionFind, con compresión de caminos y unión por rango) para la verificación de ciclo en cada arista candidata — una dependencia real de proyecto Gradle (implementation(project(":graphs:union-find")) en el build.gradle.kts de este módulo), no una copia duplicada de esa lógica. El resultado (classic/MinimumSpanningTreeResult) informa si el grafo de entrada estaba totalmente conectado: un grafo de entrada desconectado igual obtiene el bosque más barato posible, solo que no un único árbol, ya que no existe arista alguna para conectar los componentes separados. KruskalMinimumSpanningTreeTest cubre un grafo vacío, un único nodo sin aristas, una arista omitida por cerrar un ciclo, y un grafo de entrada genuinamente desconectado.

Ejemplo aplicado: planificación de backhaul de torres celulares

applied/CellTowerBackhaulPlanner (en un operador de telecomunicaciones) encuentra el conjunto de enlaces de backhaul de costo mínimo que conecta cada torre celular de un despliegue en una sola red — donde el peso de cada enlace candidato es su costo de backhaul (distancia de zanjeo para fibra, equipo de enlace de microondas, condiciones de arrendamiento — lo que domine para ese par) — sin evaluar por fuerza bruta cada topología de red posible. CellTowerBackhaulPlannerTest cubre la elección de la ruta más barata entre dos rutas redundantes entre las mismas torres, y una torre registrada antes de su estudio de backhaul (aún sin enlaces candidatos), lo que correctamente deja esa torre fuera del alcance de la red planificada.

Benchmark

./gradlew :graphs:minimum-spanning-tree:jmh

Ejecución real (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 iteraciones de medición, 1 fork). El tamaño del pool de nodos escala de forma aproximada con la cantidad de aristas (para que el grafo no se vuelva absurdamente denso); la cantidad de aristas es la variable que realmente está bajo prueba:

Costo de computeMst aristas=1,000 aristas=10,000 aristas=100,000
ns/op 196,015.16 ns 2,801,892.07 ns 59,000,563.29 ns

Pasar de 1,000 a 10,000 aristas (10x los datos) cuesta cerca de 14.3x más tiempo — cerca del ~13.3x que predice un ordenamiento O(E log E) para ese salto (10,000·log₂(10,000) ÷ 1,000·log₂(1,000)). Pasar de 10,000 a 100,000 aristas cuesta cerca de 21.1x más, algo por encima del ~12.5x que predice la misma fórmula para ese paso — el pool de nodos también crece junto con la cantidad de aristas en este benchmark, así que un array de union-find más grande y más trabajo de asignación de ArrayList/HashMap suman una sobrecarga real, ajena al ordenamiento, en el tamaño más grande. La forma dominante sigue siendo inconfundiblemente el O(E log E) del ordenamiento, no las verificaciones de union-find casi-O(1) amortizado que lo acompañan.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

./gradlew :graphs:minimum-spanning-tree:jacocoTestReport

Informe en graphs/minimum-spanning-tree/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

src/test/java/com/datastructures/graphs/minimumspanningtree/classic/KruskalMinimumSpanningTreeTest.java
package com.datastructures.graphs.minimumspanningtree.classic;

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class KruskalMinimumSpanningTreeTest {

    private final KruskalMinimumSpanningTree<String> kruskal = new KruskalMinimumSpanningTree<>();

    @Test
    void anEmptyGraphProducesAnEmptyResultThatDoesNotClaimToSpanAnything() {
        MinimumSpanningTreeResult<String> result = kruskal.computeMst(Set.of(), List.of());

        assertThat(result.edges()).isEmpty();
        assertThat(result.totalWeight()).isZero();
        assertThat(result.spansAllNodes()).isFalse();
    }

    @Test
    void aSingleNodeWithNoEdgesTriviallySpansItself() {
        MinimumSpanningTreeResult<String> result = kruskal.computeMst(Set.of("A"), List.of());

        assertThat(result.edges()).isEmpty();
        assertThat(result.totalWeight()).isZero();
        assertThat(result.spansAllNodes()).isTrue();
    }

    @Test
    void kruskalSkipsAnEdgeThatWouldCreateACycleAndKeepsTheCheaperOnes() {
        Set<String> nodes = Set.of("A", "B", "C");
        List<WeightedEdge<String>> edges = List.of(
                new WeightedEdge<>("A", "B", 1),
                new WeightedEdge<>("B", "C", 2),
                new WeightedEdge<>("A", "C", 3) // would close a cycle once A-B and B-C are in
        );

        MinimumSpanningTreeResult<String> result = kruskal.computeMst(nodes, edges);

        assertThat(result.edges()).containsExactly(
                new WeightedEdge<>("A", "B", 1),
                new WeightedEdge<>("B", "C", 2));
        assertThat(result.totalWeight()).isEqualTo(3);
        assertThat(result.spansAllNodes()).isTrue();
    }

    @Test
    void aDisconnectedInputGraphProducesTheCheapestForestInsteadOfATree() {
        Set<String> nodes = Set.of("A", "B", "C", "D");
        List<WeightedEdge<String>> edges = List.of(
                new WeightedEdge<>("A", "B", 5),
                new WeightedEdge<>("C", "D", 7)
                // no edge connects {A,B} to {C,D}: the input graph itself is disconnected
        );

        MinimumSpanningTreeResult<String> result = kruskal.computeMst(nodes, edges);

        assertThat(result.edges()).hasSize(2);
        assertThat(result.totalWeight()).isEqualTo(12);
        assertThat(result.spansAllNodes()).isFalse();
    }
}
src/test/java/com/datastructures/graphs/minimumspanningtree/applied/CellTowerBackhaulPlannerTest.java
package com.datastructures.graphs.minimumspanningtree.applied;

import com.datastructures.graphs.minimumspanningtree.classic.MinimumSpanningTreeResult;
import com.datastructures.graphs.minimumspanningtree.classic.WeightedEdge;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class CellTowerBackhaulPlannerTest {

    @Test
    void picksTheCheapestSetOfLinksThatConnectsEveryTower() {
        CellTowerBackhaulPlanner planner = new CellTowerBackhaulPlanner();
        planner.registerCandidateLink("tower-north", "tower-central", 400);
        planner.registerCandidateLink("tower-central", "tower-south", 250);
        planner.registerCandidateLink("tower-north", "tower-south", 900); // pricier redundant link

        MinimumSpanningTreeResult<String> plan = planner.planCheapestBackhaul();

        assertThat(plan.edges()).containsExactlyInAnyOrder(
                new WeightedEdge<>("tower-north", "tower-central", 400),
                new WeightedEdge<>("tower-central", "tower-south", 250));
        assertThat(plan.totalWeight()).isEqualTo(650);
        assertThat(plan.spansAllNodes()).isTrue();
    }

    @Test
    void aTowerAwaitingASurveyWithNoCandidateLinksLeavesTheNetworkUnspanned() {
        CellTowerBackhaulPlanner planner = new CellTowerBackhaulPlanner();
        planner.registerCandidateLink("tower-north", "tower-central", 400);
        planner.registerTower("tower-remote"); // no backhaul survey done yet: no candidate links

        MinimumSpanningTreeResult<String> plan = planner.planCheapestBackhaul();

        assertThat(plan.spansAllNodes()).isFalse();
    }
}

Ver informe completo de cobertura JaCoCo →