Ask Your Question
1

Algorithm for listing minimal directed cuts and minimal dijoins

asked 2025-08-08 19:39:32 +0200

Homer gravatar image

I'd like to share an algorithm in Sage to compute minimal directed cuts and minimal dijoins of a digraph and to ask for feedback regarding its correctness and efficiency.

I firstly recall some basic definitions.

Definition. Let $G = (V(G),E(G))$ be a directed graph (abbreviated to digraph throughout).

  • A non-empty set of edges $C^* \subseteq E(G)$ is called a cut if there is a partition $V_0 \sqcup V_1 = V(G)$ of the vertex set such that $C^*$ is the set of edges connecting a vertex of $V_0$ and a vertex of $V_1$.

  • A cut $C^* $ is directed if either each edge of $C^* $ leads from $V_0$ to $V_1$, or each edge of $C^*$ leads from $V_1$ to $V_0$.

  • A cut $C^* $ is minimal if there does not exist any cut properly contained in $C^* $.
  • A dijoin is a subset $E$ of the edge set $E(G)$ such that $E\cap K\neq \emptyset$, for every directed cut $K$ of $G$.
  • A dijoin $E$ is minimal if there does not exist any dijoin properly contained in $E$.

The code. The Sage code (with an example) can be found here:

from itertools import chain, combinations

def all_subsets(edges):
    """Returns all subsets of the set of edges."""
    edges_list = list(edges)
    return [set(comb) for r in range(1, len(edges_list)+1) for comb in combinations(edges_list, r)]


def directed_edge_cuts(D):
    """
    Given a directed graph D, returns the list of minimal directed cuts.
    Each cut is a list of directed edges that disconnect the graph into two
    parts, with all edges going in one direction between them.
    """
    from itertools import combinations

    # Convert the directed graph to an undirected one
    G = D.to_undirected()
    edges = list(G.edges(labels=False))
    n = len(edges)

    minimal_cuts = []

    for r in range(1, n + 1):
        for subset in combinations(edges, r):
            subset_set = set(subset)

        # Skip supersets of already found cuts
        if any(set(cut).issubset(subset_set) for cut in minimal_cuts):
            continue

        # Remove edges from a copy of the undirected graph
        H = G.copy()
        H.delete_edges(subset)

        if not H.is_connected():
            # Get components
            components = H.connected_components()
            if len(components) != 2:
                continue  # Only consider binary cuts

            from_set = set(components[0])
            to_set = set(components[1])

            # Classify directed edges in the subset
            forward_cut = []
            backward_cut = []
            other = []

            for (u, v) in subset:
                if D.has_edge(u, v) and u in from_set and v in to_set:
                    forward_cut.append((u, v))
                elif D.has_edge(u, v) and u in to_set and v in from_set:
                    backward_cut.append((u, v))
                else:
                    other.append((u, v))  # not a valid directed edge

            # Keep only pure forward or pure backward cuts
            if len(forward_cut) == len(subset):
                minimal_cuts.append(forward_cut)
            elif len(backward_cut) == len(subset):
                minimal_cuts.append(backward_cut)

return minimal_cuts


def find_dijoins(D):
    """
    Finds all minimal-cardinality dijoins in a digraph D.
    A dijoin is a set of edges that intersects all minimal directed cuts.
    """
    E = set(D.edges(labels=False))
    S = all_subsets(E)
    C = [set(cut) for cut in directed_edge_cuts(D)]

    L = []
    Visited = []

    for Si in S:
        # Skip supersets already visited
        if any(v.issubset(Si) for v in Visited):
            continue

        # Check if Si intersects all cuts
        test = all(len(Si & c) > 0 for c in C)
        if test:
            L.append(Si)
            Visited.append(Si)

# Convert to list for manipulation
L = [list(subset) for subset in L]

if not L:
    return []

min_size = min(len(x) for x in L)
FinalList = [x for x in L if len(x) == min_size]

return FinalList


#############################################

# Build the directed graph
D = DiGraph([
    (1,4), (1,5),
    (2,4), (2,5), (2,6),
    (3,4), (3,5), (3,6)
])

cuts = directed_edge_cuts(D)

# Print the minimal directed cuts
for i, cut in enumerate(cuts, 1):
    print(f"Directed Cut {i}: {cut}")

# Find the minimal dijoins
dijoins = find_dijoins(D)
print("Minimal dijoins:")
for i, dj in enumerate(dijoins, 1):
    print(f"Dijoin {i}: {dj}")

Question. This algorithm is quite slow; for example, it took about 30 minutes to process the following graph before I stopped it:

D = DiGraph([
    (1,13), (1,14),
    (2,13), (2,14), (2,15),
    (3,10), (3,11), (3,12), (3,13), (3,14), (3,15), (3,17), (3,19), (3,20),
    (4,10), (4,11), (4,12), (4,13), (4,14), (4,15), (4,17), (4,19), (4,20),
    (5,19), (5,20), 
    (6,13), (6,14), (6,16), (6,18),
    (7,10), (7,11), (7,12),  
    (8,13), (8,14), (8,16), (8,18),
])

Can the current implementation be significantly optimised to improve computational efficiency and runtime?

edit retag flag offensive close merge delete

Comments

please fix indentation errors

David Coudert gravatar imageDavid Coudert ( 2025-08-09 14:22:12 +0200 )edit

The problem I have is that I cannot share link because I don't have enough karma :( In that case, I would share the link of SageCell. I'm really sorry for that

Homer gravatar imageHomer ( 2025-08-09 16:54:45 +0200 )edit

1 Answer

Sort by ยป oldest newest most voted
1

answered 2025-08-09 18:18:57 +0200

Consider the following method that uses integer linear programming to enumerate the minimal directed cuts.

def directed_edge_cuts_MIP(D):
    r"""
    Return an iterator over the directed cuts of ``D``.

    This method uses mixed integer linear programming to enumerate the
    minimal directed cuts of ``D``.
    """
    int_to_vertex = list(D)
    vertex_to_int = {u: i for i, u in enumerate(int_to_vertex)}
    H = D.relabel(perm=vertex_to_int, inplace=False)

    def good_edges(edges):
        return [(int_to_vertex[u], int_to_vertex[v]) for u, v in edges]

    def hedge(u, v):
        return (u, v) if u < v else (v, u) 

    from sage.numerical.mip import MixedIntegerLinearProgram
    p = MixedIntegerLinearProgram(constraint_generation=True, maximization=False)
    x = p.new_variable(binary=True)
    y = p.new_variable(binary=True)
    z = p.new_variable(binary=True)

    # At least one vertex must be 0 and at least one 1
    p.add_constraint(p.sum(x[u] for u in H), min=1, max=H.order() - 1)

    # underlying edge (u, v) is in the cut if u != v
    for u, v in H.edge_iterator(labels=False):
        p.add_constraint(x[v] - x[u] <= y[hedge(u, v)])
        p.add_constraint(x[u] - x[v] <= y[hedge(u, v)])

    # an edge of H is in the cut iff x[u] == 0 and x[v] == 1
    for u, v in H.edge_iterator(labels=False):
        p.add_constraint(x[v] - x[u] <= z[u, v])
        p.add_constraint(z[u, v] <= x[v])
        p.add_constraint(z[u, v] <= 1 - x[u])
        p.add_constraint(y[hedge(u,v)] <= z[u, v])

    p.set_objective(p.sum(z[u, v] for u, v in  H.edge_iterator(labels=False)))

    # get trivial cuts when a vertex has no incoming or outgoing edge
    for u in H:
        cut = []
        if not H.in_degree(u):
            cut = H.outgoing_edges(u, labels=False)
        elif not H.out_degree(u):
            cut = H.incoming_edges(u, labels=False)
        if cut:
            # yield the cut
            yield good_edges(cut)
            # and add a constraint to prevent finding it again
            p.add_constraint(p.sum(z[u, v] for u, v in cut) <= len(cut) - 1)

    # Now search for non trivial cuts
    while True:
        try:
            p.solve()
        except:
            # no more solution
            return

        # Extract the cut
        zz = p.get_values(z, convert=bool, tolerance=1e-3)
        cut = [e for e, b in zz.items() if b]
        # yield it
        yield good_edges(cut)
        # and add a constraint to prevent finding it again
        p.add_constraint(p.sum(z[u, v] for u, v in cut) <= len(cut) - 1)

    return

We can use a frontend method that exploits the digraph of the strongly connected components. Recall that we cannot find a directed cut in a strongly connected graph.

def directed_edge_cuts(D):
    r"""
    Return an iterator over the minimal directed edge cuts of ``D``.
    """
    H = D.strongly_connected_components_digraph()

    def good_cut(cut):
        """
        Turn a cut in H to a cut in D
        """
        return [e for u, v in cut for e in D.edge_boundary(u, v, labels=False)]

    # We compute the 2 edge-connected components of the underlying graph
    # Each block of order 2 corresponds to a minimal directed cut in D
    for block in H.blocks_and_cut_vertices()[0]:
        if len(block) == 2:
            u, v = block
            if not H.has_edge(u, v):
                u, v = v, u
            # yield the cut and remove it from H
            yield good_cut([(u, v)])
            H.delete_edge(u, v)

    # We now search for cuts in the connected components
    for g in H.connected_components_subgraphs():
        if g.order() > 1:
            for cut in directed_edge_cuts_MIP(g):
                yield good_cut(cut)

To get the dijoins, we can also use integer linear programming.

def dijoins(D):
    r"""
    Return an iterator over the minimal cardinality dijoins in ``D``.

    A dijoin is a set of edges that intersects all minimal directed cuts.
    """
    from sage.numerical.mip import MixedIntegerLinearProgram
    p = MixedIntegerLinearProgram(constraint_generation=True, maximization=False)
    x = p.new_variable(binary=True)

    # A dijoin contains at least one edge per cut
    for cut in directed_edge_cuts(D):
        p.add_constraint(p.sum(x[e] for e in cut), min=1)

    p.set_objective(p.sum(x[e] for e in D.edge_iterator(labels=False)))

    size = 0

    while True:
        try:
            p.solve()
        except:
            return

        # Extract the dijoin
        xx = p.get_values(x, convert=bool, tolerance=1e-3)
        dijoin = [e for e, b in xx.items() if b]
        if not size:
            size = len(dijoin)
        elif size < len(dijoin):
            # no more minimal cardinality dijoin
            return

        # yield it
        yield dijoin
        # and add a constraint to prevent finding it again
        p.add_constraint(p.sum(x[e] for e in dijoin) <= len(dijoin) - 1)

    return

all these methods are iterators.

sage: D = DiGraph([
....:     (1,4), (1,5),
....:     (2,4), (2,5), (2,6),
....:     (3,4), (3,5), (3,6)
....: ])
sage: print("Minimal directed cuts:")
....: for i, cut in enumerate(directed_edge_cuts(D), 1):
....:     print(f"Directed Cut {i}: {cut}")
Minimal directed cuts:
Directed Cut 1: [(3, 5), (2, 5), (1, 5)]
Directed Cut 2: [(3, 5), (3, 6), (3, 4)]
Directed Cut 3: [(3, 6), (2, 6)]
Directed Cut 4: [(2, 5), (2, 6), (2, 4)]
Directed Cut 5: [(3, 4), (2, 4), (1, 4)]
Directed Cut 6: [(1, 5), (1, 4)]
Directed Cut 7: [(3, 5), (3, 4), (2, 5), (2, 4)]
sage: print("Minimal dijoins:")
....: for i, dj in enumerate(dijoins(D), 1):
....:     print(f"Dijoin {i}: {dj}")
Minimal dijoins:
Dijoin 1: [(3, 5), (2, 6), (1, 4)]
Dijoin 2: [(1, 5), (3, 4), (2, 6)]
Dijoin 3: [(2, 5), (3, 6), (1, 4)]
Dijoin 4: [(1, 5), (3, 6), (2, 4)]

This is consistent with your solutions. I let you check the next example.

sage: D = DiGraph([
....:     (1,13), (1,14),
....:     (2,13), (2,14), (2,15),
....:     (3,10), (3,11), (3,12), (3,13), (3,14), (3,15), (3,17), (3,19), (3,20),
....:     (4,10), (4,11), (4,12), (4,13), (4,14), (4,15), (4,17), (4,19), (4,20),
....:     (5,19), (5,20),
....:     (6,13), (6,14), (6,16), (6,18),
....:     (7,10), (7,11), (7,12),
....:     (8,13), (8,14), (8,16), (8,18),
....: ])
sage: print("Minimal directed cuts:")
....: for i, cut in enumerate(directed_edge_cuts(D), 1):
....:     print(f"Directed Cut {i}: {cut}")
....: 
Minimal directed cuts:
Directed Cut 1: [(6, 13), (2, 13), (1, 13), (8, 13), (4, 13), (3, 13)]
Directed Cut 2: [(6, 13), (6, 16), (6, 18), (6, 14)]
Directed Cut 3: [(6, 16), (8, 16)]
Directed Cut 4: [(8, 13), (8, 16), (8, 18), (8, 14)]
Directed Cut 5: [(6, 18), (8, 18)]
Directed Cut 6: [(6, 14), (2, 14), (1, 14), (8, 14), (4, 14), (3, 14)]
Directed Cut 7: [(4, 13), (4, 15), (4, 14), (4, 10), (4, 11), (4, 12), (4, 17), (4, 20), (4, 19)]
Directed Cut 8: [(4, 10), (3, 10), (7, 10)]
Directed Cut 9: [(3, 13), (3, 15), (3, 14), (3, 10), (3, 11), (3, 12), (3, 17), (3, 20), (3, 19)]
Directed Cut 10: [(4, 11), (3, 11), (7, 11)]
Directed Cut 11: [(7, 10), (7, 11), (7, 12)]
Directed Cut 12: [(4, 12), (3, 12), (7, 12)]
Directed Cut 13: [(4, 17), (3, 17)]
Directed Cut 14: [(4, 20), (3, 20), (5, 20)]
Directed Cut 15: [(5, 20), (5, 19)]
Directed Cut 16: [(4, 19), (3, 19), (5, 19)]
Directed Cut 17: [(2, 15), (4, 15), (3, 15)]
Directed Cut 18: [(2, 13), (2, 15), (2, 14)]
Directed Cut 19: [(1, 13), (1, 14)]
Directed Cut 20: [(4, 20), (4, 19), (3, 20), (3, 19)]
Directed Cut 21: [(6, 13), (6, 14), (8, 13), (8, 14)]
Directed Cut 22: [(4, 13), (4, 15), (4, 14), (3, 13), (3, 15), (3, 14)]
Directed Cut 23: [(4, 13), (4, 14), (3, 13), (3, 14), (2, 13), (2, 14)]
Directed Cut 24: [(4, 10), (4, 11), (4, 12), (3, 10), (3, 11), (3, 12)]
sage: %time A = list(directed_edge_cuts(D))
CPU times: user 48.2 ms, sys: 1.88 ms, total: 50.1 ms
Wall time: 48.5 ms
sage:
sage: print("Minimal dijoins:")
....: for i, dj in enumerate(dijoins(D), 1):
....:     print(f"Dijoin {i}: {dj}")
....: 
Minimal dijoins:
Dijoin 1: [(1, 13), (4, 13), (6, 18), (6, 14), (8, 16), (4, 12), (4, 17), (4, 20), (3, 10), (7, 11), (5, 19), (2, 15)]
Dijoin 2: [(1, 13), (4, 13), (6, 18), (6, 14), (8, 16), (4, 12), (4, 20), (3, 10), (3, 17), (7, 11), (5, 19), (2, 15)]
Dijoin 3: [(6, 13), (4, 13), (6, 18), (8, 16), (1, 14), (4, 12), (4, 17), (4, 20), (3, 10), (7, 11), (5, 19), (2, 15)]
Dijoin 4: [(6, 13), (4, 13), (6, 16), (8, 18), (1, 14), (4, 12), (4, 17), (4, 20), (3, 10), (7, 11), (5, 19), (2, 15)]
Dijoin 5: [(6, 13), (4, 13), (6, 16), (8, 18), (1, 14), (4, 12), (4, 17), (4, 20), (7, 10), (3, 11), (5, 19), (2, 15)]
Dijoin 6: [(6, 13), (4, 13), (6, 18), (8, 16), (1, 14), (4, 12), (4, 20), (3, 10), (3, 17), (7, 11), (5, 19), (2, 15)]
Dijoin 7: [(6, 13), (4, 13), (6, 16), (8, 18), (1, 14), (4, 12), (4, 20), (3, 10), (3, 17), (7, 11), (5, 19), (2, 15)]
Dijoin 8: [(6, 13), (4, 13), (6, 16), (8, 18), (1, 14), (4, 10), (4, 12), (4, 20), (3, 17), (7, 11), (5, 19), (2, 15)]
Dijoin 9: [(6, 13), (4, 13), (6, 16), (8, 18), (1, 14), (4, 10), (4, 11), (4, 20), (3, 17), (7, 12), (5, 19), (2, 15)]
Dijoin 10: [(2, 13), (1, 13), (6, 18), (6, 14), (8, 16), (4, 15), (4, 12), (4, 17), (4, 20), (3, 10), (7, 11), (5, 19)]
.....

I don't know how many dijoins we get for this graph. It's a large number...

edit flag offensive delete link more

Comments

I tested it and it is consistent with the output that I obtained for all the other digraphs, that I tested so far. Thank you very much! It could be added also in the Graph Theory in SageMath, because it is really efficient and it could be really useful for other people! :)

Homer gravatar imageHomer ( 2025-08-09 20:39:37 +0200 )edit

Your Answer

Please start posting anonymously - your entry will be published after you log in or create a new account.

Add Answer

Question Tools

1 follower

Stats

Asked: 2025-08-08 19:39:32 +0200

Seen: 1,385 times

Last updated: Aug 09