N or P
Contents Glossary
28

Coloring — the two-versus-three cliff

Two-coloring is cheap. Three-coloring is famously expensive. The boundary appears in scheduling, register allocation, and frequency assignment.

Graph coloring asks: can every item be assigned one of k categories such that related items get different categories? Two categories is bipartite testing and is in the cheap category. Three or more is NP-complete. Business uses: register allocation in compilers, frequency assignment in wireless networks, exam scheduling (no student takes two exams at the same time), conflict-free room assignment, manufacturing line scheduling. When the constraint graph is sparse enough to be two-colorable, the work is linear. When it is not, you are in the expensive category. The right responses to a three-or-more coloring problem are: model as a SAT problem and use an industrial solver; model as integer programming via lp" data-term="good_lp">good_lp; use the DSATUR heuristic which works well in practice on many real graphs; or redesign the requirement to need fewer categories.

  1. 01
    petgraph's is_bipartite_undirected answers the two-color question in linear time — a BFS that two-colors as it walks. For three or more colors, encode "vertex v gets color c" as Boolean variables and hand the formula to splr.
    use petgraph::algo::is_bipartite_undirected;
    use petgraph::graph::UnGraph;
    
    fn two_colorable(g: &UnGraph<&str, ()>) -> bool {
        if let Some(start) = g.node_indices().next() {
            is_bipartite_undirected(g, start)
        } else { true }
    }
    
    // Three or more colors: build CNF and dispatch to splr (page 23).
    // At least one color per vertex, at most one color per vertex,
    // adjacent vertices differ.  Linear in the encoding, exponential
    // in the search — but solvers handle real instances at scale.
  2. 02
    if let is matching" data-term="pattern matching">pattern matching used as a conditional. Read the syntax as: "if the value on the right matches the pattern on the left, bind the names and run the if block; otherwise run the else block (which is optional)." It is sugar for a match with one explicit arm and a wildcard fallthrough — same logic, less ceremony. On this page, g.node_indices().next() returns Option<NodeIndex> — None if the graph has no nodes, Some(first) otherwise. The if let Some(start) = … form unwraps the Some directly and binds start for the body of the if; the else branch handles the empty-graph case (returns true vacuously, since a graph with no nodes is trivially 2-colorable). The same control flow with match would be three more lines and a leftover wildcard arm.
    // if let — pattern match as a conditional.
    //
    //   if let PATTERN = EXPRESSION {
    //       /* runs when PATTERN matches; bindings are in scope here */
    //   } else {
    //       /* runs otherwise (else is optional) */
    //   }
    
    // Equivalent match expression:
    //   match EXPRESSION {
    //       PATTERN => { /* matched branch */ },
    //       _       => { /* fallthrough */ },
    //   }
    
    
    // Common case: peel apart an Option.
    let maybe_name: Option<&str> = Some("Alice");
    
    if let Some(name) = maybe_name {
        println!("hello, {}", name);
    } else {
        println!("no name");
    }
    
    // Same logic with match — more lines, more brackets:
    match maybe_name {
        Some(name) => println!("hello, {}", name),
        None       => println!("no name"),
    }
    
    
    // Works for any pattern, not just Option/Result:
    enum Event { Click { x: i32, y: i32 }, Scroll(i32), Quit }
    
    let e = Event::Click { x: 10, y: 20 };
    
    if let Event::Click { x, y } = e {
        println!("click at ({}, {})", x, y);
    }
    // (no else — the other variants are silently ignored.)
    
    
    // Companion forms in the same family:
    
    // let-else (Rust 1.65+) — eager unwrap with mandatory diverging branch.
    let Some(name) = maybe_name else {
        return;   // or break, continue, panic, ...
    };
    println!("hello, {}", name);            // `name` is in scope from here
    
    // while let — loop variant.  Iterate as long as the pattern matches.
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("{}", top);
    }
    // prints 3, 2, 1; stops automatically when pop() returns None.
    
    
    // Page 28's specific use — handle the empty-graph case:
    fn two_colorable(g: &UnGraph<&str, ()>) -> bool {
        if let Some(start) = g.node_indices().next() {
            is_bipartite_undirected(g, start)         // graph has nodes — check it
        } else {
            true                                       // empty graph — vacuously 2-colorable
        }
    }
    
    // Without if-let, the same logic looks like this:
    fn two_colorable_match(g: &UnGraph<&str, ()>) -> bool {
        match g.node_indices().next() {
            Some(start) => is_bipartite_undirected(g, start),
            None        => true,
        }
    }
    // Same behavior — if-let is just the shorter, idiomatic spelling when
    // only one variant carries interesting work.
Karp, R. (1972) proved 3-coloring NP-complete. Stockmeyer, L. (1973) extended to planar graphs. Source →
In plain terms

Coloring problems show up in scheduling, allocation, and configuration — anywhere you need to assign things to categories such that conflicting things get different categories. Examination scheduling: students cannot take two exams at the same time. Wireless frequency assignment: nearby towers cannot use the same frequency. Compiler register allocation: variables alive at the same time need different registers. Operating-room scheduling: conflicting surgeries need different rooms.

The cliff in difficulty is between two categories and three. With two categories, the problem is bipartite testing — does the conflict graph have any odd cycles? This is linear time. Your team ships the feature in days.

With three or more categories, the problem becomes NP-complete. No polynomial-time algorithm is known. In practice, three responses work:

First, use a SAT solver. Encode "vertex v gets color c" as a Boolean variable, add clauses enforcing exactly-one-color-per-vertex and different-colors-on-edge, dispatch to splr. Modern SAT solvers handle graphs with thousands of vertices and dozens of colors.

Second, model as integer programming. Binary variables for vertex-color pairs, constraints for exclusivity and conflict. Call HiGHS via lp" data-term="good_lp">good_lp. Comparable scale.

Third, accept a heuristic. The DSATUR algorithm (degree-of-saturation) works well in practice: at each step, color the vertex that has the most distinct colors among its neighbors, choosing the smallest available color. Not guaranteed optimal but often produces optimal or near-optimal colorings on real graphs.

The fourth response, often the right one, is to redesign the requirement. Two categories cover a surprising number of conflict structures. When you find yourself with a coloring problem that has three or more categories, ask whether the requirement can be relaxed. Can the exams be scheduled in two slots (morning and afternoon)? Can the frequency band be split into two bands per region? If you can collapse to two categories, you collapse to linear time.

When you cannot, the problem is genuinely in the expensive category. Use the solvers. Plan accordingly.

← 27 Picking the right subset under constraints
28/39
29 The Hamilton trap →