DSA DSA In Depth
0%
build build-labdinicmax-flow

Build Lab: Dinic's Maximum Flow, Min-Cut Partitioning & Bipartite Matching Engine

7 min read Module 5.3: Network Flow, Min-Cut & Bipartite Matching Updated Oct 23, 2026
📄 Cheat Sheet
Maximum Bipartite Matching via Network Flow & Augmenting Paths
WilliamFiset Maximum Bipartite Matching via Network Flow & Augmenting Paths Watch on YouTube ↗

Demonstrates how a single Dinic engine powers max-flow, s-t min-cut partition extraction, and bipartite matching.

0:00 / --:--

Architecture of the Unified Dinic Flow & Matching Engine

Our production engine combines four essential engineering techniques:

  1. Paired Arc Storage (arcs[2k], arcs[2k+1]): Every addEdge(u, v, cap) returns the forward arc index 2k, allowing callers to inspect the exact final flow on any edge via getEdgeFlow(edgeId) in $O(1)$ time.
  2. Multi-Branch Blocking Flow DFS: Pushes multiple bottleneck branches from a single DFS invocation using the current-arc reference int& cid = ptr[u].
  3. Zero-Cost Min-Cut Extraction from Final level[]: Because maxFlow(s, t) terminates precisely when bfs(s, t) fails to reach $t$, the level[u] array at termination already marks the exact residual reachability set $S = \{u : \text{level}[u] \ne -1\}$!
  4. Bipartite Matching Adapter: Wraps $L \cup R$ into a unit network on $n_L + n_R + 2$ vertices and reads matched pairs directly from saturated $(u, v)$ arcs.
flowchart LR Add["addEdge(u, v, cap) -> returns arcId = 2k"] --> Solve["maxFlow(s, t): BFS level[] + DFS ptr[]"] Solve --> FlowVal["getEdgeFlow(arcId): arcs[arcId ^ 1].cap"] Solve --> Cut["inSourcePartition(u): level[u] != -1"] Solve --> Match["extractBipartitePairs(): scan arcIds where flow == 1"]
Dinic’s Max-Flow: BFS Level Graph, Blocking Flow, S-T Min-Cut & Hopcroft-Karp
O(V²E) Dinic · O(E√V) Unit / Matching

Visualize Dinic’s BFS level graph L_G and current-arc DFS blocking flows alongside backward residual arcs (v→u), extract the saturated S-T minimum cut, or step through Hopcroft-Karp disjoint shortest augmenting paths.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Phase 1 Blocking Flow: Pushed 4 along S→A→C→T and 10 along S→{A,B}→D→T. Edges S→A, A→C, D→T saturated! Total flow = 14.
10/10 4/10 4/4 6/8 4/9 4/10 0/6 10/10 S lvl=0 A lvl=1 B lvl=1 C lvl=2 D lvl=2 T lvl=3
Total Max-Flow |f|
14
Dinic Phase
Phase 1 Blocking Flow (level[T] = 3)
S-T Min-Cut Capacity
c(S→A)=10 + c(B→D)=9 = 19
[Step 1] Phase 1 Blocking Flow: Pushed 4 along S→A→C→T and 10 along S→{A,B}→D→T. Edges S→A, A→C, D→T saturated! Total flow = 14.
Engineering Invariant — Reading Edge Flow from the Reverse Arc

When we store only residual capacity cap on each Arc (saving 8 bytes per arc by omitting flow), how do we know the net flow $f(e)$ carried by original directed edge id = 2k? Since its reverse residual arc id ^ 1 = 2k + 1 was initialized with capacity $0$, its residual capacity arcs[id ^ 1].cap is identically equal to the current flow $f(e)$ on the forward edge!

Hardware & Cache Reality — 16-Byte Arc Structs & Single-Allocation Pool

By storing only int32_t to, int32_t next, and int64_t cap inside each Arc, every residual arc occupies exactly 16 bytes — fitting 4 arcs per 64-byte L1 cache line. Pre-reserving arcs.reserve(2 * M) upfront eliminates vector reallocations during graph construction and guarantees that forward arc 2k and reverse arc 2k + 1 always reside in the same or adjacent L1 cache line during path augmentation.

Engine MethodTime ComplexityMemory Footprint
addEdge(u, v, cap)$O(1)$ amortized$32\text{ bytes}$ per directed edge pair
maxFlow(s, t)$O(V^2 E)$ / $O(E \sqrt{V})$ unitReuses pre-allocated level, ptr, q
getMinCutEdges()$O(E)$Reads level[u] != -1 without an extra BFS
getEdgeFlow(edgeId)$O(1)$Returns arcs[edgeId ^ 1].cap

Complete Production C++20 Implementation: Unified Dinic Flow, Min-Cut & Matching Engine

#include <vector>
#include <queue>
#include <algorithm>
#include <limits>

class DinicEngine {
public:
    struct Arc {
        int from, to;
        long long cap;
    };

    explicit DinicEngine(int n) : n(n), adj(n), level(n, -1), ptr(n, 0) {}

    // Returns the forward arc index (even integer 2k)
    int addEdge(int u, int v, long long cap) {
        int id = static_cast<int>(arcs.size());
        adj[u].push_back(id);
        arcs.push_back({u, v, cap});
        adj[v].push_back(id + 1);
        arcs.push_back({v, u, 0});
        return id;
    }

    long long maxFlow(int s, int t) {
        long long totalFlow = 0;
        while (bfs(s, t)) {
            std::fill(ptr.begin(), ptr.end(), 0);
            while (long long pushed = dfs(s, t, std::numeric_limits<long long>::max())) {
                totalFlow += pushed;
            }
        }
        return totalFlow;
    }

    // Exact flow pushed along the edge returned by addEdge()
    long long getEdgeFlow(int edgeId) const noexcept {
        return arcs[edgeId ^ 1].cap;
    }

    // True iff vertex u belongs to the source side S of the minimum s-t cut
    bool inSourceSide(int u) const noexcept {
        return level[u] != -1;
    }

    std::vector<std::pair<int, int>> extractMinCutEdges() const {
        std::vector<std::pair<int, int>> cut;
        for (size_t id = 0; id < arcs.size(); id += 2) {
            const auto& e = arcs[id];
            if (level[e.from] != -1 && level[e.to] == -1 && (e.cap + arcs[id ^ 1].cap) > 0) {
                cut.emplace_back(e.from, e.to);
            }
        }
        return cut;
    }

private:
    int n;
    std::vector<Arc> arcs;
    std::vector<std::vector<int>> adj;
    std::vector<int> level, ptr;

    bool bfs(int s, int t) {
        std::fill(level.begin(), level.end(), -1);
        level[s] = 0;
        std::queue<int> q;
        q.push(s);
        while (!q.empty()) {
            int u = q.front();
            q.pop();
            for (int id : adj[u]) {
                int v = arcs[id].to;
                if (arcs[id].cap > 0 && level[v] == -1) {
                    level[v] = level[u] + 1;
                    q.push(v);
                }
            }
        }
        return level[t] != -1;
    }

    long long dfs(int u, int t, long long available) {
        if (available == 0 || u == t) return available;
        long long totalPushed = 0;
        for (int& cid = ptr[u]; cid < static_cast<int>(adj[u].size()); ++cid) {
            int id = adj[u][cid];
            int v = arcs[id].to;
            if (level[v] != level[u] + 1 || arcs[id].cap == 0) continue;

            long long tr = dfs(v, t, std::min(available - totalPushed, arcs[id].cap));
            if (tr == 0) continue;

            arcs[id].cap -= tr;
            arcs[id ^ 1].cap += tr;
            totalPushed += tr;
            if (totalPushed == available) return totalPushed;
        }
        return totalPushed;
    }
};
Failure Mode — Terminating BFS Early at $v == t$ When You Need the Min-Cut Partition

Some Dinic implementations optimize bfs(s, t) by returning true immediately when $t$ is pushed into the queue. While valid during intermediate phases, on the final unsuccessful BFS $t$ is never reached anyway—so level[u] != -1 always holds the exact complete source partition $S$ without needing an extra BFS pass!

Check yourself Question 1 of 4
Concept Check Reading Final Edge Flow from Reverse Arc Capacity recall
Section: Flow Inspection

In our `DinicEngine` where forward arc `edgeId = 2k` is initialized with capacity $C$ and reverse arc `edgeId ^ 1 = 2k + 1` is initialized with capacity $0$, why does `arcs[edgeId ^ 1].cap` equal the exact flow pushed along `edgeId`?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Suppose you have already run `dinic.maxFlow(s, t)` and now increase the capacity of an edge $(u, v)$ by $+\Delta$ (by calling `addEdge(u, v, Delta)`). How can you compute the new maximum flow without resetting existing flows?

Key Takeaways

The reverse residual arc `arcs[edgeId ^ 1].cap` stores the exact net flow carried by forward edge `edgeId`, avoiding a separate `flow` field.
Because `maxFlow(s, t)` ends on an unsuccessful BFS from s, `level[u] != -1` immediately identifies the source partition S of the minimum s-t cut.
Returning the arc index from `addEdge()` lets bipartite matching and circulation wrappers inspect individual edge flows in O(1) time.
When `totalPushed == available` in multi-branch DFS, returning before `++cid` ensures partially saturated arcs are not skipped.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue