Graph algorithms · is this flowgraph well-behaved

Reducibility and loop detection

A loop with exactly one way in is easy to reason about. A loop with two is not — and three different ways to tell them apart, two of which agree on a tiny two-node counter-example used throughout.

Where this shows up in reverse engineering

Before trusting any tool's promise to turn an arbitrary control-flow graph into clean nested IF/WHILE blocks, it is worth knowing whether the method or paragraph in front of it is even reducible at all.

The problem

A loop with one entry — one statement execution always passes through to get into it — is a natural loop, the kind dominance and reachability already finds the body of. A loop with two or more separate entries, reachable without going through each other first, has no single header to call "the start of the loop" — nothing dominates the whole thing. A flowgraph built entirely out of natural loops, nested or side by side, is reducible. One with a multi-entry loop anywhere inside it is irreducible.

Reducibility matters because most of what a structuring tool wants to say — "this is a loop, here is its body, here is what comes after it" — assumes a single entry. An irreducible region cannot be described that way at all without first rewriting it, which is a much bigger job than detecting it.

Two examples

Two small graphs, reused across every method on this page. First, a loop with one entry — reducible by inspection, used to confirm each method says yes:

flowchart TD
    T --> X1
    T --> L1
    X1 --> X2
    L1 --> L2
    L2 -.->|back edge| T

Second, the smallest graph that has two: a branch straight into both nodes of a two-node cycle, so neither node is reachable only through the other.

flowchart TD
    Entry --> A
    Entry --> B
    A --> B
    B --> A

Nothing dominates both A and B except Entry itself — there is no single header inside the cycle, because either one can be reached first.

Collapsing the graph: T1 and T2

One way to test reducibility, due to Hecht and Ullman:[1] shrink the graph by two rules, repeated until nothing shrinks any further.

T1: a statement with an edge to itself — remove the edge.
T2: a statement with exactly one predecessor — fold it into that predecessor
  (the predecessor inherits its outgoing edges; the statement disappears).
Repeat T1 then T2, over every statement, until a full round changes nothing.

If the whole graph has shrunk to one statement, every loop in it had exactly one entry: reducible. If it gets stuck above one statement, some region had more than one way in: irreducible.

Running it on the good loop

Diagrams below mark the statement a step just folded into, or the self-loop it just removed, in amber.

Collapsing the one-entry loop (6 steps, 3 rounds)
  1. Starting shape: five statements.

    flowchart TD
        T --> X1
        T --> L1
        X1 --> X2
        L1 --> L2
        L2 -.->|back edge| T
    
  2. Round 1 — X2 has exactly one predecessor, X1. Fold it in: X1 inherits X2's outgoing edges (none), X2 disappears.

    flowchart TD
        T --> X1
        T --> L1
        L1 --> L2
        L2 -.->|back edge| T
        class X1 changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    
  3. Round 1 — X1 now has exactly one predecessor, T. Fold it in: T inherits nothing new, X1 disappears.

    flowchart TD
        T --> L1
        L1 --> L2
        L2 -.->|back edge| T
        class T changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    
  4. Round 1 — L2 has exactly one predecessor, L1. Fold it in: L1 inherits L2's edge to T, L2 disappears.

    flowchart TD
        T --> L1
        L1 -.->|back edge| T
        class L1 changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    
  5. Round 1 — L1 now has exactly one predecessor, T. Fold it in: T inherits L1's edge to T — which is an edge from T to itself. L1 disappears.

    flowchart TD
        T -.->|self-loop| T
        class T changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    
  6. Round 2 — T1 fires on the self-loop just created: remove the edge. One statement left, no edges. Round 3 confirms: nothing left to fold, nothing left to remove. Done.

    flowchart TD
        T["T (no edges)"]
        class T changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

Five statements down to one. Reducible.

Running it on the bad one

Trying to collapse the two-entry cycle (1 round, nothing moves)
  1. Starting shape: three statements.

    flowchart TD
        Entry --> A
        Entry --> B
        A --> B
        B --> A
    
  2. T1: no statement has an edge to itself. Nothing to do.

    flowchart TD
        Entry --> A
        Entry --> B
        A --> B
        B --> A
    

    (unchanged)

  3. T2: A's predecessors are {Entry, B} — two, not one. B's predecessors are {Entry, A} — also two. Neither qualifies. Entry has no predecessor at all, and is the root besides. Nothing folds.

    flowchart TD
        Entry --> A
        Entry --> B
        A --> B
        B --> A
        class A,B stuck
        classDef stuck stroke:#f87171,stroke-width:3px,fill:transparent
    

    (round produced no change — stuck)

Stuck at three statements on the very first round. Irreducible — and this method says so without having to point at which two nodes are the problem, only that something, somewhere, is.

A quicker check for where: strongly connected components

A cheaper question, answerable without folding anything: find every maximal set of statements that can all reach each other — a strongly connected component — and look at how it is entered from outside. A loop with one entry has every outside edge landing on the same statement. A loop with two or more has outside edges landing on different statements inside the same component.

Checking the two-entry cycle's one strongly connected component (2 steps)
  1. A and B reach each other (A → B → A); Entry reaches both but neither reaches Entry. One strongly connected component: {A, B}.

    flowchart TD
        Entry --> A
        Entry --> B
        A --> B
        B --> A
        class A,B changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    
  2. Edges entering {A, B} from outside: Entry → A and Entry → B — two edges, landing on two different statements inside the component.

    flowchart TD
        Entry --> A
        Entry --> B
        A --> B
        B --> A
        class A,B changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
        linkStyle 0 stroke:#f5b041,stroke-width:3px
        linkStyle 1 stroke:#f5b041,stroke-width:3px
    

    Entries into {A, B}

    Entry → AEntry → B

Two distinct entry points into one component: improper, by inspection, with no folding at all. Checked the same way on the good loop, the component is {T, L1, L2} and its only outside edge is the one edge T already has coming from wherever calls into the method — one entry, proper.

A third way: reusing dominance

A third method, from Sreedhar, Gao and Lee's paper on identifying loops with DJ graphs,[2] answers the same question using the machinery dominance and reachability already built, rather than folding the graph or hunting components. Compute dominators for the two-entry cycle:

statementdominated by
Entry{Entry}
A{Entry, A}
B{Entry, B}

The dominator tree is just Entry → A and Entry → B — two children, nothing nested. Every edge in the original graph that is not a dominator-tree edge gets classified by one question: does the edge's target dominate its source?

extra edgedoes target dominate source?classified as
A → Bdoes B dominate A? dom(A) = {Entry, A} — nocross-join
B → Adoes A dominate B? dom(B) = {Entry, B} — nocross-join
target dominates source → back-join: a legitimate loop back-edge, climbing to a real header
target does not dominate source → cross-join: reaching sideways, into a region
  this edge's source was never shown to be inside

A graph is reducible exactly when none of its back-edges — found by a fresh depth-first walk over this reclassified graph, not the original — turn out to be cross-join edges. Walking from Entry here, B → A is found going to a statement still on the current path: a back edge. It was classified cross-join above. One cross-join back edge is enough: irreducible, the same answer as the other two methods, from a different question — "does this edge ever reach into a region sideways, rather than climbing to something that was shown to dominate it" — asked once per edge instead of once per fold or once per component.

The dominator-tree edges plus this reclassified set, together, are called a DJ graph — the "D" for dominator, the "J" for the join edges just classified. This page only asks it yes-or-no questions; DJ graphs and natural loop bodies uses the same graph to find the exact statement set of every loop's body, nested ones included.

What goes wrong

Where this is used

Before translating a flowgraph with arbitrary jumps into structured code, it has to be known which parts, if any, do not reduce — those are the parts a straight translation into IF/WHILE cannot express without first restructuring them. The three methods above answer "is it reducible at all" and "roughly where" cheaply. Turning that into the exact statement set of every loop body, including loops nested inside other loops, is the DJ graph's other job — see DJ graphs and natural loop bodies.

References

  1. M. S. Hecht and J. D. Ullman. "Flow Graph Reducibility." SIAM Journal on Computing, 1(2), 1972, pp. 188–202. The T1/T2 collapsing rules.
  2. V. C. Sreedhar, G. R. Gao, and Y.-F. Lee. "Identifying Loops Using DJ Graphs." ACM Transactions on Programming Languages and Systems, 18(6), November 1996, pp. 649–658. The dominator/back-join/cross-join reducibility test; see also DJ graphs and natural loop bodies.