Back to Graph Theory Series

Chu-Liu/Edmonds Algorithm

September 27, 2026 Wasil Zafar 29 min read

Choose the cheapest incoming edge for every non-root vertex. If those choices form a cycle, compress the cycle, price each possible entrance by what it must replace, and solve the smaller graph.

Contents

  1. A Bit of History
  2. What We Are Building
  3. Algorithm Overview
  4. Why Cycles Appear
  5. Contraction & Reweighting
  6. Visual Walkthrough
  7. Reconstructing Edges
  8. Correctness Intuition
  9. Implementation
  10. Complexity Analysis
  11. Directed vs. Undirected MST
  12. Real-World Applications
  13. Exercises
  14. Limitations

A Bit of History

One problem, independent discoveries

1965Yoeng-Jin Chu and Tseng-Hong Liu publish a solution for the optimum directed spanning tree.
1967Jack Edmonds independently publishes an equivalent contraction algorithm.
1977Robert Tarjan develops a faster implementation using more sophisticated data structures.

The result is known by several names: Chu–Liu/Edmonds, Edmonds’ branching algorithm, or the directed minimum spanning tree algorithm. “Arborescence” is the most precise description of its output.

What Are We Building?

Given a directed weighted graph $G=(V,E)$ and root $r$, an outward rooted arborescence contains exactly $V-1$ edges, gives every $v\neq r$ exactly one incoming edge, gives $r$ none, and makes every vertex reachable from $r$. The goal is to minimize total edge weight.

Fixed root

Direction starts at one chosen root; changing it can change feasibility and cost.

One parent

Each non-root vertex receives exactly one selected incoming edge.

Global reachability

Local parent choices must combine into root-to-vertex paths, not closed cycles.

Management-chart analogy. Every employee chooses the cheapest possible manager. That minimizes each local reporting cost—but a group may choose managers only within the group, forming a loop with no path to the CEO. Cycle contraction prices the cheapest way to connect that loop to the rest of the company.

Algorithm Overview

  1. Check candidates. For every non-root vertex $v$, choose its minimum-weight incoming edge $p(v)\to v$. If any such vertex has none, no rooted arborescence exists.
  2. Look for directed cycles. Following chosen parent edges either reaches the root or repeats a vertex. If no cycle exists, the chosen edges already form the optimum.
  3. Contract every cycle. Replace each selected cycle by a super-vertex and assign component IDs to vertices outside cycles.
  4. Reweight crossing edges. For an edge $u\to v$ whose endpoints contract to different components, use $w'(u,v)=w(u,v)-w(p(v),v)$.
  5. Solve and expand. Repeat on the smaller graph. To reconstruct edges, expand contractions in reverse order and break each cycle where the chosen external edge enters.

Why the Greedy Edges Can Form a Cycle

Choosing the cheapest incoming edge is unavoidable locally: every non-root vertex needs one parent. But those individually cheapest choices can point around a directed loop. A loop satisfies indegree one for its members while failing the root-reachability requirement.

Cheapest incoming edges create a directed cycle Vertices A, B, and C choose minimum incoming edges that form a cycle disconnected from root R. Vertex D chooses an edge from C. Select the cheapest incoming edge at each non-root vertex SELECTED CYCLE 6752122 RACBD Local test A gets B → A (2) B gets C → B (1) C gets A → C (2) D gets C → D (2) Global test Every non-root vertex has one parent—but R cannot reach A, B, or C. CONTRACT THE CYCLE
The greedy obstruction. Each teal edge is locally cheapest at its destination. The choices around $A,B,C$ nevertheless form a closed directed cycle, so they cannot all appear in an arborescence rooted at $R$.

Contraction and the Meaning of Reweighting

Suppose an outside edge $u\to v$ enters cycle $C$. To use it, the final arborescence keeps the chosen cycle edges except $p(v)\to v$, which must be removed to break the cycle. The true extra cost of entering at $v$ is therefore:

$$w'(u,C)=w(u,v)-w(p(v),v)$$

original entry costminus displaced cycle edgeequals marginal entry cost

Edges leaving the cycle simply leave the super-vertex. For the iterative cost algorithm, every crossing edge is uniformly written as $w'(u,v)=w(u,v)-\operatorname{in}[v]$ after the selected incoming costs have been added to the answer.

Why raw entry weights are unfair

An edge of weight 6 entering a vertex whose selected edge costs 2 adds only 4 beyond the cycle baseline. An edge of weight 5 entering a vertex whose selected edge also costs 2 adds 3. The contracted graph must compare 4 with 3—not 6 with 5—because one selected edge will be refunded.

Visual Walkthrough: Contract, Solve, Expand

Cycle contraction, reweighting, and expansion A selected cycle on A, B, and C is contracted to X. Three root-entry edges receive adjusted costs. The cheapest adjusted entry targets C, so expansion removes A to C and keeps the other cycle edges. 1 · cycle baseline2 · contracted graph3 · expand winner ACB A → C = 2C → B = 1B → A = 2 baseline = 2 + 1 + 2 = 5 R X{A,B,C} to A: 6−2 = 4to B: 7−1 = 6to C: 5−2 = 3 choose entry at C R ACB keep R → C (5)drop A → C; keep C → B and B → A entry 5 + kept cycle 3 = 8 The contracted cost 3 plus baseline 5 equals the expanded cost 8.
Marginal-cost preservation. The contracted graph chooses the adjusted entry cost $3$. Expansion restores the original edge $R\to C$ of weight $5$, removes the selected edge entering $C$, and keeps the other two cycle edges.

Reconstructing the Actual Edges

The simple iterative implementation can return the optimum cost without retaining the arborescence itself. To recover edges, record a contraction frame containing the chosen incoming edge for every old vertex, the component mapping, and—most importantly—the original edge represented by every new crossing edge.

  1. Store frames while contracting, including nested cycles.
  2. Solve the smallest acyclic instance.
  3. Expand frames in reverse order.
  4. If a selected contracted edge enters a cycle through original vertex $v$, restore that external edge, omit the cycle-selected edge $p(v)\to v$, and keep all other selected edges in the cycle.

That provenance bookkeeping—not the optimal-cost recurrence—is the most error-prone part of production implementations.

Core intuition: pay for every vertex’s cheapest parent up front. A contracted edge then pays only the extra cost of replacing one of those prepaid choices.

Correctness Intuition

If the cheapest-incoming selection is acyclic, it is feasible and no arborescence can improve any vertex’s incoming choice independently, so it is optimal. If it contains a cycle $C$, every feasible rooted arborescence must enter $C$ from outside at least once. Given one chosen entry at $v$, keeping all other cheapest cycle edges is optimal inside $C$; only $p(v)\to v$ must be removed. Reweighting records exactly that exchange cost, establishing a cost-preserving correspondence between solutions before and after contraction. Repeating the argument proves correctness inductively.

Feasibility first

A rooted arborescence exists exactly when every vertex is reachable from the chosen root. The algorithm detects infeasibility when some non-root contracted vertex has no incoming edge, but a preliminary directed reachability check often gives a clearer early error.

Implementation

These complete $O(VE)$ listings compute the minimum total cost. They support negative weights and parallel edges, ignore self-loops, and return no result when the chosen root cannot span the graph. They do not reconstruct the edge set; use the contraction-frame method above when parent edges are required.

from math import inf

def directed_mst_cost(n, edges, root):
    """Return minimum rooted arborescence cost, or None if infeasible."""
    if not 0 <= root < n:
        raise ValueError("root is outside the graph")

    total = 0
    while True:
        # Cheapest incoming edge for every current non-root vertex.
        incoming = [inf] * n
        parent = [-1] * n
        for u, v, weight in edges:
            if u != v and v != root and weight < incoming[v]:
                incoming[v] = weight
                parent[v] = u
        incoming[root] = 0

        if any(weight == inf for weight in incoming):
            return None
        total += sum(incoming)

        # Find cycles in the chosen-parent graph.
        component = [-1] * n
        seen_from = [-1] * n
        cycle_count = 0
        for start in range(n):
            v = start
            while seen_from[v] != start and component[v] == -1 and v != root:
                seen_from[v] = start
                v = parent[v]
            if v != root and component[v] == -1:
                u = parent[v]
                while u != v:
                    component[u] = cycle_count
                    u = parent[u]
                component[v] = cycle_count
                cycle_count += 1

        if cycle_count == 0:
            return total

        # Non-cycle vertices become singleton components.
        next_component = cycle_count
        for v in range(n):
            if component[v] == -1:
                component[v] = next_component
                next_component += 1

        contracted = []
        for u, v, weight in edges:
            cu, cv = component[u], component[v]
            if cu != cv:
                contracted.append((cu, cv, weight - incoming[v]))

        root = component[root]
        n = next_component
        edges = contracted

edges = [(0,1,4), (0,2,8), (1,2,2), (2,1,1),
         (1,3,5), (2,3,3)]
print(directed_mst_cost(4, edges, 0))  # 9
#include <iostream>
#include <limits>
#include <optional>
#include <utility>
#include <vector>
using namespace std;

struct Edge { int from, to; long long weight; };

optional<long long> directedMstCost(int n, int root, vector<Edge> edges) {
    const long long INF = numeric_limits<long long>::max() / 4;
    long long total = 0;

    while (true) {
        vector<long long> incoming(n, INF);
        vector<int> parent(n, -1);
        for (const Edge& e : edges) {
            if (e.from != e.to && e.to != root && e.weight < incoming[e.to]) {
                incoming[e.to] = e.weight;
                parent[e.to] = e.from;
            }
        }
        incoming[root] = 0;
        for (long long w : incoming) if (w == INF) return nullopt;
        for (long long w : incoming) total += w;

        vector<int> component(n, -1), seenFrom(n, -1);
        int cycleCount = 0;
        for (int start = 0; start < n; ++start) {
            int v = start;
            while (seenFrom[v] != start && component[v] == -1 && v != root) {
                seenFrom[v] = start;
                v = parent[v];
            }
            if (v != root && component[v] == -1) {
                for (int u = parent[v]; u != v; u = parent[u])
                    component[u] = cycleCount;
                component[v] = cycleCount++;
            }
        }
        if (cycleCount == 0) return total;

        int nextComponent = cycleCount;
        for (int v = 0; v < n; ++v)
            if (component[v] == -1) component[v] = nextComponent++;

        vector<Edge> contracted;
        for (const Edge& e : edges) {
            int a = component[e.from], b = component[e.to];
            if (a != b) contracted.push_back({a, b, e.weight - incoming[e.to]});
        }
        root = component[root];
        n = nextComponent;
        edges = move(contracted);
    }
}

int main() {
    vector<Edge> edges = {
        {0,1,4}, {0,2,8}, {1,2,2},
        {2,1,1}, {1,3,5}, {2,3,3}
    };
    auto answer = directedMstCost(4, 0, edges);
    if (answer) cout << *answer << '\n';  // 9
}
import java.util.*;

class ChuLiuEdmonds {
    static class Edge {
        int from, to;
        long weight;
        Edge(int from, int to, long weight) {
            this.from = from; this.to = to; this.weight = weight;
        }
    }

    static OptionalLong directedMstCost(int n, int root, List<Edge> edges) {
        final long INF = Long.MAX_VALUE / 4;
        long total = 0;

        while (true) {
            long[] incoming = new long[n];
            int[] parent = new int[n];
            Arrays.fill(incoming, INF);
            Arrays.fill(parent, -1);
            for (Edge e : edges) {
                if (e.from != e.to && e.to != root && e.weight < incoming[e.to]) {
                    incoming[e.to] = e.weight;
                    parent[e.to] = e.from;
                }
            }
            incoming[root] = 0;
            for (long w : incoming) if (w == INF) return OptionalLong.empty();
            for (long w : incoming) total += w;

            int[] component = new int[n];
            int[] seenFrom = new int[n];
            Arrays.fill(component, -1);
            Arrays.fill(seenFrom, -1);
            int cycleCount = 0;

            for (int start = 0; start < n; start++) {
                int v = start;
                while (seenFrom[v] != start && component[v] == -1 && v != root) {
                    seenFrom[v] = start;
                    v = parent[v];
                }
                if (v != root && component[v] == -1) {
                    for (int u = parent[v]; u != v; u = parent[u])
                        component[u] = cycleCount;
                    component[v] = cycleCount++;
                }
            }
            if (cycleCount == 0) return OptionalLong.of(total);

            int nextComponent = cycleCount;
            for (int v = 0; v < n; v++)
                if (component[v] == -1) component[v] = nextComponent++;

            List<Edge> contracted = new ArrayList<>();
            for (Edge e : edges) {
                int a = component[e.from], b = component[e.to];
                if (a != b) contracted.add(new Edge(a, b, e.weight - incoming[e.to]));
            }
            root = component[root];
            n = nextComponent;
            edges = contracted;
        }
    }

    public static void main(String[] args) {
        List<Edge> edges = Arrays.asList(
            new Edge(0,1,4), new Edge(0,2,8), new Edge(1,2,2),
            new Edge(2,1,1), new Edge(1,3,5), new Edge(2,3,3)
        );
        OptionalLong answer = directedMstCost(4, 0, edges);
        System.out.println(answer.isPresent() ? answer.getAsLong() : "infeasible"); // 9
    }
}

Input and tie-handling checklist

CaseBehaviorPractical note
Negative weightsSupported; the algorithm compares and subtracts weights without Dijkstra-style assumptions.Use a wide signed numeric type.
Parallel edgesSupported; keep each candidate edge and its identity.Identity is essential during reconstruction.
Self-loopsIgnored because they can never help a rooted arborescence.Filter them early.
Equal weightsAny consistent minimum may be chosen.Specify input ordering or an edge-ID tie-breaker for reproducibility.
Maximum versionNegate scores and solve the minimum problem.Avoid overflow when negating the minimum integer.

Complexity Analysis

In the basic implementation, each contraction round scans all edges to choose incoming minima and rebuild the graph. Every round contracts at least one directed cycle, reducing the number of current vertices:

$$T(V,E)=O(VE), \qquad S(V,E)=O(V+E)$$

More sophisticated implementations using meldable heaps, disjoint sets, and careful lazy weight adjustments improve the bound, including Tarjan-style $O(E\log V)$ variants. They also make reconstruction bookkeeping more intricate. For many moderate graphs, the transparent $O(VE)$ version is the safer engineering choice.

Directed Arborescence vs. Undirected MST

QuestionUndirected MSTChu–Liu/Edmonds
Edge orientationEdges may be traversed either way.Every selected edge keeps its direction away from a chosen root.
Local ruleCut/cycle properties justify Kruskal or Prim directly.Cheapest incoming edges may cycle and require contraction.
RootNo root is needed to define the optimum tree.The root is part of the problem specification.
FeasibilityA connected graph has a spanning tree.Every vertex must be directed-reachable from the root.
Typical outputOne undirected tree of $V-1$ edges.One outward branching with indegree one at each non-root vertex.

Real-World Applications

Dependency parsing

Choose a maximum-scoring directed dependency tree rooted at an artificial sentence root.

Broadcast design

Select a cheapest outward distribution structure when links are asymmetric.

Directed hierarchies

Infer one-parent structures from asymmetric influence, dependency, or transition scores.

In score-maximization settings, the graph may be dense because every possible parent–child pair receives a model score. Domain constraints—such as forbidden parents, a single designated root, or required edges—must be encoded before running the algorithm.

Exercises

Practice the exchange argument

  1. In the contraction SVG, recompute the adjusted costs for entries at $A$, $B$, and $C$.
  2. If $R\to A$ drops from 6 to 4, which entry wins after reweighting, and which selected cycle edge is removed?
  3. Construct three vertices whose cheapest incoming choices are acyclic. Explain why the algorithm stops immediately.
  4. Add a vertex unreachable from $R$. Identify the iteration and condition that report infeasibility.
  5. Challenge: augment the Python version with edge IDs and contraction frames, then reconstruct the exact parent edge of every vertex.

Hint: when expanding a cycle, the destination of the chosen external edge tells you exactly which internal selected edge to omit.

Limitations

Model and implementation boundaries

  • Root dependence: changing the root can change the optimum or make the instance infeasible.
  • No multiple parents: ordinary arborescences impose exactly one incoming selected edge per non-root vertex.
  • No cycles in output: applications that genuinely require feedback structures need a different model.
  • Reconstruction complexity: nested contractions require exact edge provenance and reverse-order expansion.
  • Numeric range: repeated subtraction and total accumulation can overflow narrow integer types.

Use a well-tested implementation when the edge set itself—not only its cost—is operationally important. Validate the returned parents by checking root indegree zero, every other indegree one, exactly $V-1$ edges, total cost, and reachability from the root.