Visitar URL original
Fix false negative in minimum and maximum spanning arborescence (fixes #8964) by Roshanraj2580 · Pull Request #8979 · networkx/networkx · GitHub
Skip to content

Fix false negative in minimum and maximum spanning arborescence (fixes #8964) - #8979

Open
Roshanraj2580 wants to merge 2 commits into
networkx:mainfrom
Roshanraj2580:fix/spanning-arborescence-weight-shift
Open

Roshanraj2580 wants to merge 2 commits into
networkx:mainfrom
Roshanraj2580:fix/spanning-arborescence-weight-shift

Conversation

@Roshanraj2580

@Roshanraj2580 Roshanraj2580 commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

Fixes #8964.

In networkx.minimum_spanning_arborescence (via minimal_branching) and networkx.maximum_spanning_arborescence, edge weights were shifted by:

  • minimal_branching: C = 2 * max_weight - min_weight + 1
  • maximum_spanning_arborescence: C = max_weight - 2 * min_weight + 1

Under this constant shift, maximum_branching optimizes the shifted weights $\sum_{e \in B} (C - w_e)$ or $\sum_{e \in B} (w_e + C)$. However, if dropping an edge to yield a non-spanning branching saves more in original weight than $???$, maximum_branching selects a non-spanning branching over the spanning arborescence. When this occurs, is_arborescence(B) returns False, causing NetworkX to falsely raise:
NetworkXException: No minimum/maximum spanning arborescence in G even when valid spanning arborescences exist.

Solution

The weight shift must guarantee that any spanning arborescence ( $???-1$ edges) strictly outscores any non-spanning branching ($\le n - 2$ edges).

  1. For minimal_branching:
    $\text{weight difference} \le (n - 1) \cdot \max(w_{\max}, 0) - (n - 2) \cdot \min(w_{\min}, 0)$
    We set:
shift = max(
   max_weight + 1 + (max_weight - min_weight),
   (n - 1) * max(max_weight, 0) - (n - 2) * min(min_weight, 0) + 1 + (max_weight - min_weight)
)
  1. For maximum_spanning_arborescence:
    $\text{weight difference} \le (n - 2) \cdot \max(w_{\max}, 0) - (n - 1) \cdot \min(w_{\min}, 0)$
    We set:
    shift = max(
        1 - min_weight + (max_weight - min_weight),
        (n - 2) * max(max_weight, 0) - (n - 1) * min(min_weight, 0) + 1
    )

Also handled empty edge graphs cleanly.

Testing

…orkx#8964)

Signed-off-by: Roshanraj2580 <roshanraj2580@gmail.com>
@Roshanraj2580
Roshanraj2580 force-pushed the fix/spanning-arborescence-weight-shift branch from 42cb7ff to 260016b Compare November 7, 2026 04:40

@dschult dschult left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

I have a few questions below. I'd like to understand the formulas a little better.

Comment on lines +664 to +666
if not G.edges:
return maximum_branching(G, attr, default, preserve_attrs, partition)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I understand this. Is it checking for the case of no edges? If so, how can we be looking for a maximum branching?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When G has no edges, min_weight is INF and max_weight is -INF, which causes the subsequent weight arithmetic (max_weight - min_weight) to produce NaN and crash.

For an edgeless graph, the only branching is the empty graph (0 edges), which is trivially both minimal and maximal. Calling maximum_branching(G, ...) was just a convenient way to get an empty graph of the appropriate type/nodes/attrs.

If preferred, I can change this to directly return G.copy() (or type(G)() with nodes added) to make it clearer that it's just returning an empty branching.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer returning G.copy(). Otherwise each time I read this part of the code I will need to go look up what maximum_branching does with an empty_graph. :}
Thanks!

Comment on lines +677 to +679
# over any non-spanning branching (<= n - 2 edges). The shift must exceed the maximum
# possible weight difference between a spanning and a non-spanning branching,
# bounded by (n - 1) * max(max_weight, 0) - (n - 2) * min(min_weight, 0).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why this formula works?
Where do these values come from in terms of a spanning and a non-spanning branching?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In minimal_branching, we transform weights as $w'(e) = S - w(e)$ and run maximum_branching.

For any branching $B$, its transformed weight is:
$\sum_{e \in B} w'(e) = |B| \cdot S - \sum_{e \in B} w(e)$

A spanning arborescence $T$ has exactly $n - 1$ edges, while any non-spanning branching $B$ has $|B| \le n - 2$ edges. For $T$ to be preferred over $B$ by maximum_branching, we need $w'(T) &gt; w'(B)$:
$((n - 1) - |B|) \cdot S &gt; w(T) - w(B)$

Since $(n - 1) - |B| \ge 1$, this holds if $S &gt; w(T) - w(B)$.

To bound the maximum possible gap $w(T) - w(B)$:

  • In the worst case, $w(T)$ could sum $n - 1$ edges of weight $\max(w_{\max}, 0)$.
  • $w(B)$ could sum at most $n - 2$ edges of weight $\min(w_{\min}, 0)$ (if negative).

So the gap is at most $(n - 1) \cdot \max(w_{\max}, 0) - (n - 2) \cdot \min(w_{\min}, 0)$. Setting $S$ greater than this guarantees that every spanning arborescence beats every non-spanning branching. (The extra max_weight + 1 + (max_weight - min_weight) term keeps the legacy shift so transformed weights also stay positive).

Alternatively, an even simpler upper bound on $w(T) - w(B)$ is $2 \sum |w| + 1$ if you prefer that over tracking $n$.

@dschult dschult Nov 10, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this description! Very helpful.

In the end, I think the simpler (though probably bigger than needed) upper bound is a better choice -- easier to read anyway... Is it also faster to compute? Would it be even better to use
2 * n * (max_weight - min_weight) + 1 (and do we need to max when negative and min when positive? We can be very generous with our upper bound estimate so long as it gets bigger... any way to help that simplify the expression and logic here? )

Comment thread networkx/algorithms/tree/branchings.py Outdated
Comment on lines +729 to +732
shift = max(
1 - min_weight + (max_weight - min_weight),
(n - 2) * max(max_weight, 0) - (n - 1) * min(min_weight, 0) + 1,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why this formula is correct?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as in minimal_branching, but in reverse: here we shift weights $w'(e) = w(e) + S$ and run maximum_branching.

For a spanning arborescence $T$ ($n - 1$ edges) and a non-spanning branching $B$ ($\le n - 2$ edges):
$w'(T) &gt; w'(B) \iff ((n - 1) - |B|) \cdot S &gt; w(B) - w(T)$

Since $(n - 1) - |B| \ge 1$, we need $S &gt; w(B) - w(T)$.

The worst-case advantage $B$ could have over $T$ in raw weights is:

  • $B$ has at most $n - 2$ edges, each at most $\max(w_{\max}, 0)$.
  • $T$ has $n - 1$ edges, each at least $\min(w_{\min}, 0)$ (which can be negative).

So $w(B) - w(T) \le (n - 2) \cdot \max(w_{\max}, 0) - (n - 1) \cdot \min(w_{\min}, 0)$.

Setting $S$ above that ensures maximum_branching never drops an edge needed to span the graph. The first term 1 - min_weight + (max_weight - min_weight) is the existing legacy shift to ensure positivity during Edmonds' contraction.

@woahwhattheheck

Copy link
Copy Markdown

A cardinality-first alternative to the global shift is available here: woahwhattheheck/networkx@main...fix/arborescence-20261113 . It retains #8964 and Roshanraj2580's work here as the existing carrier.

Spanning mode first maximizes edge count within Edmonds' algorithm, then optimizes weight. It avoids modifying/restoring the caller's weights. Finite built-in floats and integers use a common exact binary scale for contraction; selected output weights come from the originals. This covers the reported weight-31 tree, finite weights near 1e308 and cancellation near 1e16. Ordinary maximum branching still optimizes weight without a cardinality preference; iterator queue priorities and returned-weight summation retain their existing arithmetic.

The complete branching module passed 45 native and 45 loopback cases with these changes, including the reported graph, input preservation and extreme-weight regressions. Formatting and whitespace checks passed. This is offered on the existing discussion rather than opening a duplicate PR.

@Roshanraj2580

Copy link
Copy Markdown
Author

Thanks @woahwhattheheck, this cardinality-first formulation is much cleaner and avoids the weight mutation and precision edge cases with extreme floats entirely.

I've pulled your commit into this PR branch (84bcf34). All 46 tests in test_branchings.py pass cleanly locally.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

minimum_spanning_arborescence / maximum_spanning_arborescence raise "No ... spanning arborescence in G" on graphs that have one (regression since 3.2)

3 participants