From 901a1cb342700ce50096191703bd8201d3b00be0 Mon Sep 17 00:00:00 2001 From: Microindole <1513979779@qq.com> Date: Mon, 13 Oct 2025 19:08:51 +0800 Subject: [PATCH 1/2] feat(graph): Add Edmonds's algorithm for minimum spanning arborescence --- .../java/com/thealgorithms/graph/Edmonds.java | 201 ++++++++++++++++++ .../com/thealgorithms/graph/EdmondsTest.java | 121 +++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/main/java/com/thealgorithms/graph/Edmonds.java create mode 100644 src/test/java/com/thealgorithms/graph/EdmondsTest.java diff --git a/src/main/java/com/thealgorithms/graph/Edmonds.java b/src/main/java/com/thealgorithms/graph/Edmonds.java new file mode 100644 index 000000000000..92ee74d92dd1 --- /dev/null +++ b/src/main/java/com/thealgorithms/graph/Edmonds.java @@ -0,0 +1,201 @@ +package com.thealgorithms.graph; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * An implementation of Edmonds's algorithm (also known as the Chu–Liu/Edmonds algorithm) + * for finding a Minimum Spanning Arborescence (MSA). + * + *

An MSA is a directed graph equivalent of a Minimum Spanning Tree. It is a tree rooted + * at a specific vertex 'r' that reaches all other vertices, such that the sum of the + * weights of its edges is minimized. + * + *

The algorithm works recursively: + *

    + *
  1. For each vertex other than the root, select the incoming edge with the minimum weight.
  2. + *
  3. If the selected edges form a spanning arborescence, it is the MSA.
  4. + *
  5. If cycles are formed, contract each cycle into a new "supernode".
  6. + *
  7. Modify the weights of edges entering the new supernode.
  8. + *
  9. Recursively call the algorithm on the contracted graph.
  10. + *
  11. The final cost is the sum of the initial edge selections and the result of the recursive call.
  12. + *
+ * + *

Time Complexity: O(E * V) where E is the number of edges and V is the number of vertices. + * + *

References: + *

+ */ +public final class Edmonds { + + private Edmonds() { + } + + /** + * Represents a directed weighted edge in the graph. + */ + public static class Edge { + final int from; + final int to; + final long weight; + + /** + * Constructs a directed edge. + * + * @param from source vertex + * @param to destination vertex + * @param weight edge weight + */ + public Edge(int from, int to, long weight) { + this.from = from; + this.to = to; + this.weight = weight; + } + } + + /** + * Computes the total weight of the Minimum Spanning Arborescence of a directed, + * weighted graph from a given root. + * + * @param numVertices the number of vertices, labeled {@code 0..numVertices-1} + * @param edges list of directed edges in the graph + * @param root the root vertex + * @return the total weight of the MSA. Returns -1 if not all vertices are reachable + * from the root or if a valid arborescence cannot be formed. + * @throws IllegalArgumentException if {@code numVertices <= 0} or {@code root} is out of range. + */ + public static long findMinimumSpanningArborescence(int numVertices, List edges, int root) { + if (numVertices <= 0 || root < 0 || root >= numVertices) { + throw new IllegalArgumentException("Invalid number of vertices or root"); + } + if (numVertices == 1) { + return 0; + } + + return findMSARecursive(numVertices, edges, root); + } + + /** + * Recursive helper method for finding MSA. + */ + private static long findMSARecursive(int n, List edges, int root) { + long[] minWeightEdge = new long[n]; + int[] predecessor = new int[n]; + Arrays.fill(minWeightEdge, Long.MAX_VALUE); + Arrays.fill(predecessor, -1); + + for (Edge edge : edges) { + if (edge.to != root && edge.weight < minWeightEdge[edge.to]) { + minWeightEdge[edge.to] = edge.weight; + predecessor[edge.to] = edge.from; + } + } + // Check if all non-root nodes are reachable + for (int i = 0; i < n; i++) { + if (i != root && minWeightEdge[i] == Long.MAX_VALUE) { + return -1; // No spanning arborescence exists + } + } + int[] cycleId = new int[n]; + Arrays.fill(cycleId, -1); + boolean[] visited = new boolean[n]; + int cycleCount = 0; + + for (int i = 0; i < n; i++) { + if (visited[i]) { + continue; + } + + List path = new ArrayList<>(); + int curr = i; + + // Follow predecessor chain + while (curr != -1 && !visited[curr]) { + visited[curr] = true; + path.add(curr); + curr = predecessor[curr]; + } + + // If we hit a visited node, check if it forms a cycle + if (curr != -1) { + boolean inCycle = false; + for (int node : path) { + if (node == curr) { + inCycle = true; + } + if (inCycle) { + cycleId[node] = cycleCount; + } + } + if (inCycle) { + cycleCount++; + } + } + } + if (cycleCount == 0) { + long totalWeight = 0; + for (int i = 0; i < n; i++) { + if (i != root) { + totalWeight += minWeightEdge[i]; + } + } + return totalWeight; + } + long cycleWeightSum = 0; + for (int i = 0; i < n; i++) { + if (cycleId[i] >= 0) { + cycleWeightSum += minWeightEdge[i]; + } + } + + // Map old nodes to new nodes (cycles become supernodes) + int[] newNodeMap = new int[n]; + int[] cycleToNewNode = new int[cycleCount]; + int newN = 0; + + // Assign new node IDs to cycles first + for (int i = 0; i < cycleCount; i++) { + cycleToNewNode[i] = newN++; + } + + // Assign new node IDs to non-cycle nodes + for (int i = 0; i < n; i++) { + if (cycleId[i] == -1) { + newNodeMap[i] = newN++; + } else { + newNodeMap[i] = cycleToNewNode[cycleId[i]]; + } + } + + int newRoot = newNodeMap[root]; + + // Build contracted graph + List newEdges = new ArrayList<>(); + for (Edge edge : edges) { + int uCycleId = cycleId[edge.from]; + int vCycleId = cycleId[edge.to]; + + // Skip edges internal to a cycle + if (uCycleId >= 0 && uCycleId == vCycleId) { + continue; + } + + int newU = newNodeMap[edge.from]; + int newV = newNodeMap[edge.to]; + + long newWeight = edge.weight; + // Adjust weight for edges entering a cycle + if (vCycleId >= 0) { + newWeight -= minWeightEdge[edge.to]; + } + + if (newU != newV) { + newEdges.add(new Edge(newU, newV, newWeight)); + } + } + return cycleWeightSum + findMSARecursive(newN, newEdges, newRoot); + } +} \ No newline at end of file diff --git a/src/test/java/com/thealgorithms/graph/EdmondsTest.java b/src/test/java/com/thealgorithms/graph/EdmondsTest.java new file mode 100644 index 000000000000..99484ba04d4a --- /dev/null +++ b/src/test/java/com/thealgorithms/graph/EdmondsTest.java @@ -0,0 +1,121 @@ +package com.thealgorithms.graph; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class EdmondsTest { + + @Test + void testSimpleGraphNoCycle() { + int n = 4; + int root = 0; + List edges = new ArrayList<>(); + edges.add(new Edmonds.Edge(0, 1, 10)); + edges.add(new Edmonds.Edge(0, 2, 1)); + edges.add(new Edmonds.Edge(2, 1, 2)); + edges.add(new Edmonds.Edge(2, 3, 5)); + + // Expected arborescence edges: (0,2), (2,1), (2,3) + // Weights: 1 + 2 + 5 = 8 + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(8, result); + } + + @Test + void testGraphWithOneCycle() { + int n = 4; + int root = 0; + List edges = new ArrayList<>(); + edges.add(new Edmonds.Edge(0, 1, 10)); + edges.add(new Edmonds.Edge(2, 1, 4)); + edges.add(new Edmonds.Edge(1, 2, 5)); + edges.add(new Edmonds.Edge(2, 3, 6)); + + // Min edges: (2,1, w=4), (1,2, w=5), (2,3, w=6) + // Cycle: 1 -> 2 -> 1, cost = 4 + 5 = 9 + // Contract {1,2} to C. + // New edge (0,C) with w = 10 - min_in(1) = 10 - 4 = 6 + // New edge (C,3) with w = 6 + // Contracted MSA cost = 6 + 6 = 12 + // Total cost = cycle_cost + contracted_msa_cost = 9 + 12 = 21 + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(21, result); + } + + @Test + void testComplexGraphWithCycle() { + int n = 6; + int root = 0; + List edges = new ArrayList<>(); + edges.add(new Edmonds.Edge(0, 1, 10)); + edges.add(new Edmonds.Edge(0, 2, 20)); + edges.add(new Edmonds.Edge(1, 2, 5)); + edges.add(new Edmonds.Edge(2, 3, 10)); + edges.add(new Edmonds.Edge(3, 1, 3)); + edges.add(new Edmonds.Edge(1, 4, 7)); + edges.add(new Edmonds.Edge(3, 4, 2)); + edges.add(new Edmonds.Edge(4, 5, 5)); + + // Min edges: (3,1,3), (1,2,5), (2,3,10), (3,4,2), (4,5,5) + // Cycle: 1->2->3->1, cost = 5+10+3=18 + // Contract {1,2,3} to C. + // Edge (0,1,10) -> (0,C), w = 10-3=7 + // Edge (0,2,20) -> (0,C), w = 20-5=15. Min is 7. + // Edge (1,4,7) -> (C,4,7) + // Edge (3,4,2) -> (C,4,2). Min is 2. + // Edge (4,5,5) -> (4,5,5) + // Contracted MSA: (0,C,7), (C,4,2), (4,5,5). Cost = 7+2+5=14 + // Total cost = 18 + 14 = 32 + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(32, result); + } + + @Test + void testUnreachableNode() { + int n = 4; + int root = 0; + List edges = new ArrayList<>(); + edges.add(new Edmonds.Edge(0, 1, 10)); + edges.add(new Edmonds.Edge(2, 3, 5)); // Node 2 and 3 are unreachable from root 0 + + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(-1, result); + } + + @Test + void testNoEdgesToNonRootNodes() { + int n = 3; + int root = 0; + List edges = new ArrayList<>(); + edges.add(new Edmonds.Edge(0, 1, 10)); // Node 2 is unreachable + + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(-1, result); + } + + @Test + void testSingleNode() { + int n = 1; + int root = 0; + List edges = new ArrayList<>(); + + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(0, result); + } + + @Test + void testInvalidInputThrowsException() { + List edges = new ArrayList<>(); + + assertThrows(IllegalArgumentException.class, + () -> Edmonds.findMinimumSpanningArborescence(0, edges, 0)); + assertThrows(IllegalArgumentException.class, + () -> Edmonds.findMinimumSpanningArborescence(5, edges, -1)); + assertThrows(IllegalArgumentException.class, + () -> Edmonds.findMinimumSpanningArborescence(5, edges, 5)); + } +} \ No newline at end of file From dbc80bb2f8014b6de59e7066a581a367bb04d240 Mon Sep 17 00:00:00 2001 From: Microindole <1513979779@qq.com> Date: Mon, 13 Oct 2025 19:47:15 +0800 Subject: [PATCH 2/2] test: Add test cases to achieve 100% coverage --- .../java/com/thealgorithms/graph/Edmonds.java | 4 +- .../com/thealgorithms/graph/EdmondsTest.java | 65 +++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/thealgorithms/graph/Edmonds.java b/src/main/java/com/thealgorithms/graph/Edmonds.java index 92ee74d92dd1..4ddb8f9ff544 100644 --- a/src/main/java/com/thealgorithms/graph/Edmonds.java +++ b/src/main/java/com/thealgorithms/graph/Edmonds.java @@ -68,7 +68,7 @@ public Edge(int from, int to, long weight) { * @throws IllegalArgumentException if {@code numVertices <= 0} or {@code root} is out of range. */ public static long findMinimumSpanningArborescence(int numVertices, List edges, int root) { - if (numVertices <= 0 || root < 0 || root >= numVertices) { + if (root < 0 || root >= numVertices) { throw new IllegalArgumentException("Invalid number of vertices or root"); } if (numVertices == 1) { @@ -198,4 +198,4 @@ private static long findMSARecursive(int n, List edges, int root) { } return cycleWeightSum + findMSARecursive(newN, newEdges, newRoot); } -} \ No newline at end of file +} diff --git a/src/test/java/com/thealgorithms/graph/EdmondsTest.java b/src/test/java/com/thealgorithms/graph/EdmondsTest.java index 99484ba04d4a..ab5740c94217 100644 --- a/src/test/java/com/thealgorithms/graph/EdmondsTest.java +++ b/src/test/java/com/thealgorithms/graph/EdmondsTest.java @@ -111,11 +111,62 @@ void testSingleNode() { void testInvalidInputThrowsException() { List edges = new ArrayList<>(); - assertThrows(IllegalArgumentException.class, - () -> Edmonds.findMinimumSpanningArborescence(0, edges, 0)); - assertThrows(IllegalArgumentException.class, - () -> Edmonds.findMinimumSpanningArborescence(5, edges, -1)); - assertThrows(IllegalArgumentException.class, - () -> Edmonds.findMinimumSpanningArborescence(5, edges, 5)); + assertThrows(IllegalArgumentException.class, () -> Edmonds.findMinimumSpanningArborescence(0, edges, 0)); + assertThrows(IllegalArgumentException.class, () -> Edmonds.findMinimumSpanningArborescence(5, edges, -1)); + assertThrows(IllegalArgumentException.class, () -> Edmonds.findMinimumSpanningArborescence(5, edges, 5)); } -} \ No newline at end of file + + @Test + void testCoverageForEdgeSelectionLogic() { + int n = 3; + int root = 0; + List edges = new ArrayList<>(); + + // This will cover the `edge.weight < minWeightEdge[edge.to]` being false. + edges.add(new Edmonds.Edge(0, 1, 10)); + edges.add(new Edmonds.Edge(2, 1, 20)); + + // This will cover the `edge.to != root` being false. + edges.add(new Edmonds.Edge(1, 0, 100)); + + // A regular edge to make the graph complete + edges.add(new Edmonds.Edge(0, 2, 5)); + + // Expected MSA: (0,1, w=10) and (0,2, w=5). Total weight = 15. + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(15, result); + } + + @Test + void testCoverageForContractedSelfLoop() { + int n = 4; + int root = 0; + List edges = new ArrayList<>(); + + // Connect root to the cycle components + edges.add(new Edmonds.Edge(0, 1, 20)); + + // Create a cycle 1 -> 2 -> 1 + edges.add(new Edmonds.Edge(1, 2, 5)); + edges.add(new Edmonds.Edge(2, 1, 5)); + + // This is the CRITICAL edge for coverage: + // It connects two nodes (1 and 2) that are part of the SAME cycle. + // After contracting cycle {1, 2} into a supernode C, this edge becomes (C, C), + // which means newU == newV. This will trigger the `false` branch of the `if`. + edges.add(new Edmonds.Edge(1, 1, 100)); // Also a self-loop on a cycle node. + + // Add another edge to ensure node 3 is reachable + edges.add(new Edmonds.Edge(1, 3, 10)); + + // Cycle {1,2} has cost 5+5=10. + // Contract {1,2} to supernode C. + // Edge (0,1,20) becomes (0,C, w=20-5=15). + // Edge (1,3,10) becomes (C,3, w=10). + // Edge (1,1,100) is discarded because newU == newV. + // Cost of contracted graph = 15 + 10 = 25. + // Total cost = cycle cost + contracted cost = 10 + 25 = 35. + long result = Edmonds.findMinimumSpanningArborescence(n, edges, root); + assertEquals(35, result); + } +}