DSA DSA In Depth
0%
build build-labtarjan-scc2-sat

Build Lab: Tarjan's SCC Condensation DAG & Linear-Time 2-SAT Constraint Solver

7 min read Module 5.1: Graph Representations, Traversals, Connectivity & Topological Order Updated Oct 23, 2026
📄 Cheat Sheet
Graph Theory Tutorial from a Google Engineer — SCCs & Graph Algorithms
freeCodeCamp.org (WilliamFiset) Graph Theory Tutorial from a Google Engineer — SCCs & Graph Algorithms Watch on YouTube ↗

Walks through encoding boolean constraints into an implication graph and extracting truth assignments from Tarjan SCC IDs.

0:00 / --:--

Engineering Blueprint: Literal Encoding & Constraint Gadgets

Given $N$ boolean variables $x_0, x_1, \dots, x_{N-1}$, we allocate $2N$ vertices in the implication graph. For each variable $i \in [0, N-1]$:

  • Vertex $2i$ represents the positive literal $x_i$ (isTrue = true).
  • Vertex $2i + 1$ represents the negated literal $\neg x_i$ (isTrue = false).
  • Negating any literal vertex $u \in [0, 2N-1]$ is a single branchless bitwise XOR: $\neg u = u \oplus 1$.
Formal Gadget Dictionary — Reducing Boolean Constraints to 2-SAT Clauses

Every binary boolean constraint over literals $a, b$ reduces to one or more base clauses $(p \lor q)$, each adding two directed edges $(\neg p \to q)$ and $(\neg q \to p)$:

  • At least one true ($a \lor b$): addClause(a, b) $\implies (\neg a \to b), (\neg b \to a)$.
  • Implication ($a \Rightarrow b$): Equivalent to $(\neg a \lor b) \implies$ addClause(a ^ 1, b).
  • Mutual Exclusion / At most one true ($\neg(a \land b)$): Equivalent to $(\neg a \lor \neg b) \implies$ addClause(a ^ 1, b ^ 1).
  • XOR / Exactly one true ($a \oplus b$): Combine $(a \lor b) \land (\neg a \lor \neg b)$.
  • Equality ($a \iff b$): Combine $(\neg a \lor b) \land (a \lor \neg b)$.
  • Force $a$ to be True (Unit Clause): Equivalent to $(a \lor a) \implies$ single edge $(\neg a \to a)$.
2-SAT Vertex Indexing (2i / 2i^1) & Condensation DAG Assignment Bitwise Literal Encoding var x_i (True) → u = 2*i var ~x_i (False)→ u = 2*i + 1 negate(u) → u ^ 1 (O(1) ALU) Tarjan Component ID Resolution If comp[2*i] == comp[2*i+1]: UNSAT If comp[2*i] < comp[2*i+1]: x_i = true If comp[2*i] > comp[2*i+1]: x_i = false
flowchart LR Encode["Encode Clauses into 2N-Vertex Graph"] --> RunSCC["Run Tarjan's Single-Pass SCC"] RunSCC --> LoopVars["For i = 0 .. N-1: compare comp[2i] vs comp[2i+1]"] LoopVars --> Equal{"comp[2i] == comp[2i+1]?"} Equal -- "Yes" --> FalseRet["Return UNSAT"] Equal -- "No" --> Assign["assignment[i] = (comp[2i] < comp[2i+1])"]
0-1 BFS Deque, DFS Edge Classification & Tarjan tin/low Bridges & SCCs
O(V + E) Single-Pass Traversal

Step through 0-1 BFS deque relaxation (push_front for w=0, push_back for w=1), Tarjan’s undirected bridge/articulation criterion (low[v] > tin[u] / low[v] ≥ tin[u]), and Tarjan’s single-pass SCC condensation.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

DFS reaches sink v5 (tin=6, low=6). Since low[v5] == tin[v5], pop v5 as SCC #1 = {v5} in reverse topological order.
back cross-SCC back v0 1/1 v1 2/1 v2 3/1 v3 4/4 v4 5/4 v5 6/6 ROOT
Active Tarjan Stack
[v0, v1, v2, v3, v4]
Extracted SCCs
SCC #1: {v5}
Root Invariant
low[u] == tin[u]
[Step 1] DFS reaches sink v5 (tin=6, low=6). Since low[v5] == tin[v5], pop v5 as SCC #1 = {v5} in reverse topological order.
Hardware Reality — Prefix-Optimization for "At Most One" Over $K$ Variables

Suppose a constraint requires at most one of $K$ literals $\{l_0, l_1, \dots, l_{K-1}\}$ to be true. Naive pairwise mutex adds $\binom{K}{2} = O(K^2)$ edges! Instead, introduce $K$ auxiliary prefix variables $p_0, \dots, p_{K-1}$ where $p_j$ means "$\bigvee_{r=0}^j l_r$ is true", adding implications $l_j \Rightarrow p_j$, $p_{j-1} \Rightarrow p_j$, and $p_{j-1} \Rightarrow \neg l_j$. This reduces the edge count from $O(K^2)$ to strictly $3K = O(K)$ linear edges!

Complexity Matrix

Operation / Feature Naive / Backtracking Tarjan 2-SAT Engine Memory Footprint
Solve $N$ Vars, $M$ Clauses O(2^N) worst-case O(N + M) O(N + M)
At-Most-One of $K$ Literals O(K²) pairwise clauses O(K) with prefix vars O(K) auxiliary literals
Condensation DAG Construction O(V²) dense matrix O(V + E) deduplicated O(V + E)

Complete Implementation: Linear-Time 2-SAT Solver & Condensation DAG Builder

#include <vector>
#include <algorithm>
#include <cstdint>

class TwoSatSolver {
public:
    explicit TwoSatSolver(int numVars)
        : n(numVars), adj(2 * numVars), tin(2 * numVars, 0),
          low(2 * numVars, 0), comp(2 * numVars, -1),
          onStack(2 * numVars, 0), assignment(numVars, false) {
        st.reserve(2 * numVars);
    }

    static int lit(int var, bool isTrue) noexcept {
        return 2 * var + (isTrue ? 0 : 1);
    }

    // Adds (u OR v) where u, v are literal indices in [0, 2n-1]
    void addClause(int u, int v) {
        adj[u ^ 1].push_back(v);
        adj[v ^ 1].push_back(u);
    }

    void addXor(int u, int v) {
        addClause(u, v);
        addClause(u ^ 1, v ^ 1);
    }

    void addEq(int u, int v) {
        addClause(u ^ 1, v);
        addClause(u, v ^ 1);
    }

    bool solve() {
        for (int i = 0; i < 2 * n; ++i) {
            if (tin[i] == 0) dfs(i);
        }
        for (int i = 0; i < n; ++i) {
            if (comp[2 * i] == comp[2 * i + 1]) return false;
            // In Tarjan's SCC, smaller component ID means closer to a sink in the DAG
            assignment[i] = (comp[2 * i] < comp[2 * i + 1]);
        }
        return true;
    }

    const std::vector<bool>& values() const noexcept { return assignment; }

private:
    int n, timer = 0, sccCount = 0;
    std::vector<std::vector<int>> adj;
    std::vector<int> tin, low, comp, st;
    std::vector<uint8_t> onStack;
    std::vector<bool> assignment;

    void dfs(int u) {
        tin[u] = low[u] = ++timer;
        st.push_back(u);
        onStack[u] = 1;
        for (int v : adj[u]) {
            if (tin[v] == 0) {
                dfs(v);
                low[u] = std::min(low[u], low[v]);
            } else if (onStack[v]) {
                low[u] = std::min(low[u], tin[v]);
            }
        }
        if (low[u] == tin[u]) {
            while (true) {
                int w = st.back();
                st.pop_back();
                onStack[w] = 0;
                comp[w] = sccCount;
                if (w == u) break;
            }
            ++sccCount;
        }
    }
};
Failure Mode — Inverting < vs > Between Tarjan and Kosaraju

In Tarjan's algorithm, SCCs are popped in post-order (sinks get ID $0$, sources get ID $K-1$), so the sink-most literal satisfies $\text{comp}[2i] < \text{comp}[2i+1]$. In Kosaraju's algorithm, the second pass discovers sources first (sources get ID $0$, sinks get ID $K-1$), so the inequality reverses to $\text{comp}[2i] > \text{comp}[2i+1]$. Mixing the two conventions sets every implied variable to False instead of True!

Check yourself Question 1 of 4
Concept Check Bitwise Negation of 2-SAT Literal Indices recall
Section: Literal Indexing

In a 2-SAT solver where variable $x_i$ ($0 \le i < N$) is mapped to vertex $2i$ (positive literal) and $\neg x_i$ is mapped to vertex $2i + 1$ (negative literal), which expression computes the negated literal vertex for any vertex index $u \in [0, 2N-1]$?

Key Takeaways

Representing positive literal x_i as 2i and negative literal ~x_i as 2i + 1 enables O(1) branchless negation via bitwise XOR (u ^ 1).
Every binary boolean operator (OR, IMPLIES, NAND, XOR, EQ) reduces to at most two 2-SAT clauses (four directed implication edges).
Prefix variable encoding reduces 'at most one of K literals' constraints from O(K^2) edges to O(K) edges.
In Tarjan's SCC numbering, sinks receive smaller component IDs than sources, so x_i = (comp[2i] < comp[2i+1]) yields a valid truth assignment whenever comp[2i] != comp[2i+1].
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue