Explainer 1 of 11

Visiting a graph: BFS, DFS and reverse postorder

Three ways to walk the statements of a program, and why one of them hands every statement its input complete.

Where this shows up in reverse engineering

Before anything else can be asked about an unfamiliar program, something has to walk it. The order chosen is not incidental — it decides whether a later analysis finishes in one pass or many.

The problem

A program's statements form a graph: an arrow from each statement to every statement that can run next. An IF has two arrows out, one per arm. The statement after the IF has two arrows in. A loop has an arrow from the end of its body back to its test.

An analysis visits the statements one at a time, and at each one it needs to know what happened before it — on every arrow coming in. If it visits a statement before one of the statements that lead into it, it has to come back to it later, when that input arrives. So the order of visiting decides how much work is repeated. There are a few standard orders, and they have names.

The example

An IF whose then-arm is three statements long and whose else-arm is one, followed by a statement both arms lead into.

           IF WS-FLAG = 'Y'                  A
               MOVE 'YES ' TO WS-A           B1
               ADD 1 TO WS-N                 B2
               MOVE WS-N TO WS-M             B3
           ELSE
               MOVE 'NO  ' TO WS-A           C
           END-IF.
           MOVE WS-A TO WS-B.                D
           STOP RUN.                         E

As a graph, with the arrows written out. When a statement has two arrows out, the first one listed is the one a walk tries first.

        A
       / \
     B1   C
      |   |
     B2   |
      |   |
     B3   |
       \ /
        D
        |
        E

A → B1, C     B1 → B2
B2 → B3       B3 → D
C → D         D → E

The statement to watch is D. It has two predecessors, B3 and C, and a good order visits both of them before it.

Breadth-first: nearest first

Breadth-first search (BFS) sorts statements by how many arrows it takes to reach them from the start, and visits the nearest first.

Some statements can be reached in more than one way. D is two arrows away through the else-arm (A → C → D) and four through the then-arm (A → B1 → B2 → B3 → D). BFS counts the shortest. It finds each statement once, the first time any path reaches it, and the shortest path always gets there first; when a longer path arrives later, the statement is already placed and the longer path is ignored. So D counts as two arrows away, and E, just after it, as three.

Grouped by that shortest count:

0 arrows:  A
1 arrow:   B1  C     A → B1
                     A → C
2 arrows:  B2  D     A → B1 → B2
                     A → C → D
                     (D's 4-arrow path ignored)
3 arrows:  B3  E     A → B1 → B2 → B3
                     A → C → D → E
                     (E's 5-arrow path ignored)

BFS reads the groups top to bottom: A B1 C B2 D B3 E.

Look where D landed: in the 2-arrow group, placed by its short path. But its other predecessor, B3, is in the 3-arrow group, so BFS visits D before B3. An analysis walking in this order would work out D from the else-arm alone, then have to redo it when the then-arm's input arrives.

The trouble is that distance from the start says nothing about whether everything leading into a statement has been visited. A statement with a short path and a long path into it is reached by the short one first.

How BFS produces that order: it keeps a queue — a line of statements that have been found but not yet visited. It always takes the statement at the front of the line, and puts each newly found statement at the back. A statement it has seen before is not added again.

BFS on the example, one queue step at a time (7 steps)
  1. Only the start has been found.

    Queue, front first

    A

    Seen so far: A

  2. Take A from the front. It leads to B1 and C; neither has been seen, so both go on the back, in that order.

    Queue, front first

    B1C

    Seen so far: A B1 C

  3. Take B1. It leads to B2, not seen yet: on the back, behind C.

    Queue, front first

    CB2

    Seen so far: A B1 C B2

  4. Take C. It leads to D, not seen yet: on the back. This is the moment D is found — by the 2-arrow path through C.

    Queue, front first

    B2D

    Seen so far: A B1 C B2 D

  5. Take B2. It leads to B3, not seen yet: on the back, behind D.

    Queue, front first

    DB3

    Seen so far: A B1 C B2 D B3

  6. Take D — before B3, because D joined the queue first. It leads to E, not seen yet: on the back.

    Queue, front first

    B3E

    Seen so far: A B1 C B2 D B3 E

  7. Take B3. It leads to D, but D has been seen already, so nothing is added. This is the 4-arrow path to D arriving, and being ignored.

    Queue, front first

    E

    Seen so far: A B1 C B2 D B3 E

  8. Take E. It leads nowhere. The queue is empty: every statement has been visited.

    Queue, front first

    empty

    Seen so far: A B1 C B2 D B3 E

The statements were taken in the order A B1 C B2 D B3 E — the groups above, read top to bottom. That is not a coincidence. Everything found from a 1-arrow statement joins the back of the line behind every 1-arrow statement still waiting, so the whole 1-arrow group is taken before any of the 2-arrow group, and so on down. And a statement is placed when it is first seen, which is by its shortest path: step 6 is the longer path to D showing up after D already has its place.

Depth-first: as far as you can, then back up

Depth-first search (DFS) walks a single path from the start, making it as long as it can before trying anything else. It repeats one rule, always looking at the last statement on the path:

Every statement joins the path once and leaves it once. That gives two orders:

"Not seen yet" means DFS has to remember what it has seen. The two lists are that memory. Everything in the preorder list has joined the path at some point, so it has been seen. Everything in the postorder list has also left. So when DFS looks at an arrow, its target is in one of three states:

Watch both lists grow (amber: just written):

DFS on the example, one step at a time (14 steps)
  1. Start: A joins the path.

    Path, start first

    A

    Preorder (written when a statement joins the path)

    A

    Postorder (written when a statement leaves the path)

    nothing yet
  2. A has two arrows; take the first, to B1. B1 is new, so it joins the path.

    Path, start first

    AB1

    Preorder (written when a statement joins the path)

    AB1

    Postorder (written when a statement leaves the path)

    nothing yet
  3. B1 leads to B2, which is new: it joins the path.

    Path, start first

    AB1B2

    Preorder (written when a statement joins the path)

    AB1B2

    Postorder (written when a statement leaves the path)

    nothing yet
  4. B2 leads to B3, new: it joins the path.

    Path, start first

    AB1B2B3

    Preorder (written when a statement joins the path)

    AB1B2B3

    Postorder (written when a statement leaves the path)

    nothing yet
  5. B3 leads to D, new: it joins the path.

    Path, start first

    AB1B2B3D

    Preorder (written when a statement joins the path)

    AB1B2B3D

    Postorder (written when a statement leaves the path)

    nothing yet
  6. D leads to E, new: it joins the path.

    Path, start first

    AB1B2B3DE

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    nothing yet
  7. E leads nowhere, so DFS is done with it: E leaves the path, and DFS is back at D.

    Path, start first

    AB1B2B3D

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    E
  8. D's only arrow, to E, has been followed. Nothing left to try: D leaves the path.

    Path, start first

    AB1B2B3

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    ED
  9. Same for B3: its arrow has been followed. It leaves.

    Path, start first

    AB1B2

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    EDB3
  10. And B2. It leaves.

    Path, start first

    AB1

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    EDB3B2
  11. And B1. It leaves, and DFS is back at A.

    Path, start first

    A

    Preorder (written when a statement joins the path)

    AB1B2B3DE

    Postorder (written when a statement leaves the path)

    EDB3B2B1
  12. A has a second arrow, to C, not tried yet. C is new: it joins the path.

    Path, start first

    AC

    Preorder (written when a statement joins the path)

    AB1B2B3DEC

    Postorder (written when a statement leaves the path)

    EDB3B2B1
  13. C leads to D — but D is in the postorder list: it has already left the path, so DFS does not go there again. C has nothing left to try: it leaves.

    Path, start first

    A

    Preorder (written when a statement joins the path)

    AB1B2B3DEC

    Postorder (written when a statement leaves the path)

    EDB3B2B1C
  14. A has followed both its arrows. It leaves. The path is empty: DFS is finished.

    Path, start first

    nothing yet

    Preorder (written when a statement joins the path)

    AB1B2B3DEC

    Postorder (written when a statement leaves the path)

    EDB3B2B1CA

Preorder: A B1 B2 B3 D E C. That is no better than BFS: D comes before its predecessor C.

Postorder: E D B3 B2 B1 C A. Every statement comes after everything it leads to — backwards from what an analysis wants.

Reverse postorder

So turn postorder around. Reverse postorder: A C B1 B2 B3 D E.

Now D comes after both B3 and C, and E after D. Every statement comes after every statement that leads into it. An analysis walking in this order finds each statement's input complete the first time it gets there, so it never redoes anything.

Why it always works, in a graph without loops: take any arrow X → Y. X cannot leave the path before Y has left it. Either Y had already left when X tried the arrow — as D had when C tried C → D — or Y joins the path after X, and being further along the path, leaves first. Either way Y leaves first, so Y is earlier in postorder and X is earlier in reverse postorder. That holds for every arrow at once. In the example, C left second to last, just before A, so turning the list round puts it second — ahead of D, as the arrow C → D requires.

With a loop

A loop breaks the guarantee, because a loop's statements lead into each other in a circle: no order can put each of them after all its predecessors. Here is the loop from the fixpoints page:

           PERFORM LOOP-PARA UNTIL WS-N > 3.   T    (the loop's test)
           MOVE WS-B TO WS-C.                  X1
           STOP RUN.                           X2
       LOOP-PARA.
           MOVE WS-A TO WS-B.                  L1
           ADD 1 TO WS-N.                      L2
       T <---+
      / \    |
    X1   L1  |
     |    |  |
    X2   L2 -+

T → X1, L1    L1 → L2
L2 → T        X1 → X2

DFS from T, taking T's arrows in the order listed: X1 first. The rule is the same as before; step 8 is the new situation.

DFS on the loop, taking X1 first (11 steps)
  1. Start: T joins the path.

    Path, start first

    T

    Preorder (joined)

    T

    Postorder (left)

    nothing yet
  2. T has two arrows; take the first, to X1. New: it joins the path.

    Path, start first

    TX1

    Preorder (joined)

    TX1

    Postorder (left)

    nothing yet
  3. X1 leads to X2. New: it joins.

    Path, start first

    TX1X2

    Preorder (joined)

    TX1X2

    Postorder (left)

    nothing yet
  4. X2 leads nowhere: it leaves the path.

    Path, start first

    TX1

    Preorder (joined)

    TX1X2

    Postorder (left)

    X2
  5. X1 has nothing left to try: it leaves. DFS is back at T.

    Path, start first

    T

    Preorder (joined)

    TX1X2

    Postorder (left)

    X2X1
  6. T's second arrow, to L1. New: it joins.

    Path, start first

    TL1

    Preorder (joined)

    TX1X2L1

    Postorder (left)

    X2X1
  7. L1 leads to L2. New: it joins.

    Path, start first

    TL1L2

    Preorder (joined)

    TX1X2L1L2

    Postorder (left)

    X2X1
  8. L2 leads to T. T is in the preorder list but not the postorder list: it is still on the path. Following the arrow would go round the loop and arrive back where DFS already is, so DFS does not. This arrow is a back edge.

    Path, start first

    TL1L2

    Preorder (joined)

    TX1X2L1L2

    Postorder (left)

    X2X1
  9. L2 has nothing left to try: it leaves.

    Path, start first

    TL1

    Preorder (joined)

    TX1X2L1L2

    Postorder (left)

    X2X1L2
  10. L1 has nothing left: it leaves.

    Path, start first

    T

    Preorder (joined)

    TX1X2L1L2

    Postorder (left)

    X2X1L2L1
  11. T has tried both arrows: it leaves. DFS is finished.

    Path, start first

    nothing yet

    Preorder (joined)

    TX1X2L1L2

    Postorder (left)

    X2X1L2L1T

That label belongs to one run of DFS, not to the graph: try the arrows in another order and a different arrow of a loop can be the back edge. What every run guarantees is that each loop contains at least one, so a graph has a back edge exactly when it has a loop. That is the one thing cobble asks of them. (Compiler textbooks also use "back edge" for a narrower idea built on dominators. Cobble does not use it, and nothing in these pages needs it.)

Postorder: X2 X1 L2 L1 T. Reverse postorder: T L1 L2 X1 X2.

Every arrow still points forward in this order except the back edge L2 → T. That is the general result: in reverse postorder, the only arrows pointing backwards are the back edges that close loops. So the only redo an analysis needs is a trip round each loop.

One thing reverse postorder does not fix: where the code after the loop lands. Run DFS again, but take T's arrow to L1 first:

DFS on the loop, taking L1 first (11 steps)
  1. Start: T joins the path.

    Path, start first

    T

    Preorder (joined)

    T

    Postorder (left)

    nothing yet
  2. Take T's arrow to L1 first this time. New: it joins.

    Path, start first

    TL1

    Preorder (joined)

    TL1

    Postorder (left)

    nothing yet
  3. L1 leads to L2. New: it joins.

    Path, start first

    TL1L2

    Preorder (joined)

    TL1L2

    Postorder (left)

    nothing yet
  4. L2 leads to T. T is in the preorder list but not the postorder list: it is still on the path. Following the arrow would go round the loop and arrive back where DFS already is, so DFS does not. This arrow is a back edge.

    Path, start first

    TL1L2

    Preorder (joined)

    TL1L2

    Postorder (left)

    nothing yet
  5. L2 has nothing left: it leaves.

    Path, start first

    TL1

    Preorder (joined)

    TL1L2

    Postorder (left)

    L2
  6. L1 has nothing left: it leaves. DFS is back at T.

    Path, start first

    T

    Preorder (joined)

    TL1L2

    Postorder (left)

    L2L1
  7. T's other arrow, to X1. New: it joins.

    Path, start first

    TX1

    Preorder (joined)

    TL1L2X1

    Postorder (left)

    L2L1
  8. X1 leads to X2. New: it joins.

    Path, start first

    TX1X2

    Preorder (joined)

    TL1L2X1X2

    Postorder (left)

    L2L1
  9. X2 leads nowhere: it leaves.

    Path, start first

    TX1

    Preorder (joined)

    TL1L2X1X2

    Postorder (left)

    L2L1X2
  10. X1 has nothing left: it leaves.

    Path, start first

    T

    Preorder (joined)

    TL1L2X1X2

    Postorder (left)

    L2L1X2X1
  11. T has tried both arrows: it leaves. DFS is finished.

    Path, start first

    nothing yet

    Preorder (joined)

    TL1L2X1X2

    Postorder (left)

    L2L1X2X1T

Postorder: L2 L1 X2 X1 T. Reverse postorder: T X1 X2 L1 L2. That is also valid — every arrow but the back edge still points forward — but it puts the code after the loop before the loop body. An analysis using it visits X1 and X2 before the loop has settled, and has to visit them again once it does. Which arrow DFS tries first decides which of the two orders you get.

In short

orderexampleevery statement after its predecessors?
BFSA B1 C B2 D B3 Eno — D before B3
DFS preorderA B1 B2 B3 D E Cno — D before C
DFS postorderE D B3 B2 B1 C Athe opposite: every statement before its predecessors
reverse postorderA C B1 B2 B3 D Eyes, except across a back edge

In cobble

Cobble's fixpoint stage orders its walks by this depth-first search, its reverse postorder and its back edges. It tries a statement's arrows in the order the control-flow graph lists them. The search keeps its own stack rather than calling itself, because a real program's paths are thousands of statements long and Python allows a thousand nested calls.