N or P
Contents Glossary
35

When one dimension is small

NP-hard does not mean uniformly hard. Real problems often have a small dimension you can exploit.

Many NP-hard problems become polynomial when one structural parameter of the input is small, even if the rest is huge. Vertex cover on a graph with millions of nodes is fast when the optimal cover is small (work grows exponentially only in the cover size, not the graph size). Problems on graphs with bounded treewidth (a measure of how tree-like the graph is) are polynomial via dynamic programming on a tree decomposition — and most NP-complete graph problems collapse this way. This area is called parameterized complexity. In practice, real business problems often have small structural parameters that the team can exploit: small kernel size after preprocessing, small treewidth, small number of conflicting items, small number of distinct values. Identifying the small parameter is the engineering win.

  1. 01
    Fixed-parameter vertex cover — branch on each uncovered edge, recurse with k − 1. Worst case 2ᵏ branches, polynomial in n. For a graph with millions of nodes and a target cover of size 30, the work is feasible.
    fn vertex_cover_fpt(edges: &[(usize, usize)], k: i32) -> Option<Vec<(usize, usize)>> {
        if k < 0 { return None; }
        let uncovered = edges.iter().copied().next();
        let Some((u, v)) = uncovered else { return Some(vec![]); };
        let without_u: Vec<_> = edges.iter().filter(|e| e.0 != u && e.1 != u).copied().collect();
        if let Some(mut sol) = vertex_cover_fpt(&without_u, k - 1) {
            sol.push((u, u));
            return Some(sol);
        }
        let without_v: Vec<_> = edges.iter().filter(|e| e.0 != v && e.1 != v).copied().collect();
        if let Some(mut sol) = vertex_cover_fpt(&without_v, k - 1) {
            sol.push((v, v));
            return Some(sol);
        }
        None
    }
  2. 02
    let Some((u, v)) = uncovered else { return Some(vec![]); }; is the let-else pattern (Rust 1.65+). Shape: let PATTERN = EXPRESSION else { DIVERGING_BLOCK };. If the pattern matches the right-hand side, the bindings come into scope normally. If it does not match, the else block runs — and that block must diverge: return, break, continue, panic!, or a function returning ! (the never-type). The point is that the bindings stay in scope for the rest of the enclosing function, no nesting required. Compare with if let Some(x) = … { /* large body using x */ } else { return; } — the let-else version keeps the body at the same indentation level and the early-exit terse. On page 35 the algorithm walks edges recursively; uncovered is the first edge that still needs covering, Option<(usize, usize)>. Either there is one (the function continues with u and v in scope), or there isn't, in which case an empty cover is already a valid answer and the else branch returns it immediately.
    // let-else — bind on success, diverge on failure.
    //
    //   let PATTERN = EXPRESSION else {
    //       /* diverging block: return, break, continue, panic, etc. */
    //   };
    //   /* PATTERN bindings are in scope from here for the rest of the fn */
    
    
    // Page 35's specific line:
    let uncovered: Option<(usize, usize)> = edges.iter().copied().next();
    
    let Some((u, v)) = uncovered else {
        return Some(vec![]);                  // no edges left → empty cover wins
    };
    // u and v are now in scope for the rest of the function.
    
    
    // Same effect with if-let (page 28) — more nesting:
    if let Some((u, v)) = edges.iter().copied().next() {
        // … large body using u and v …
    } else {
        return Some(vec![]);
    }
    
    
    // Same effect with match — even more typing:
    let (u, v) = match edges.iter().copied().next() {
        Some(pair) => pair,
        None       => return Some(vec![]),
    };
    
    
    // The else block is REQUIRED to diverge.  All of these compile:
    let Some(x) = maybe else { return; };
    let Some(x) = maybe else { break; };
    let Some(x) = maybe else { continue; };
    let Some(x) = maybe else { panic!("bug"); };
    let Some(x) = maybe else { std::process::exit(1); };
    
    // Rejected — else must NOT fall through:
    let Some(x) = maybe else { println!("oops"); };   // error: expected divergence
    
    
    // When let-else shines vs if-let:
    //
    //   if-let     equally weighted branches; both have real work to do.
    //
    //   let-else   one happy path, one early-exit.
    //              You want the happy path at the top indentation level.
    //
    // Most "guard clause" early-returns are cleaner as let-else.
Downey, R., Fellows, M. (1999) Parameterized Complexity. Cygan, M. et al. (2015) Parameterized Algorithms. Source →
In plain terms

Classical complexity is binary: a problem is in P or it is not, where "in P" means polynomial-time as a function of total input size. Real business problems have more structure than that. A network may have millions of nodes but a small "feedback" — a small set of nodes whose removal eliminates all cycles. A scheduling problem may have many jobs but few machines. A constraint problem may have many variables but small interaction depth.

Parameterized complexity exploits these structural facts. You identify a parameter — call it k — that captures the relevant smallness. You then ask whether the problem can be solved in time polynomial in the total input size, with all the exponential cost confined to k. If yes, the problem is "fixed-parameter tractable" in k, and you can solve it efficiently as long as k stays small.

Vertex cover is the textbook example. Branching algorithm: pick any uncovered edge, branch on which endpoint to include in the cover, recurse with k decreased by one. Worst case branches 2^k times. Combined with kernelization rules — preprocessing that removes parts of the graph that obviously must or must not be in the cover — the algorithm runs in roughly 1.27^k times the graph size. For a graph with millions of nodes and a target cover of size 30, this is fast.

Treewidth is the structural parameter for graphs. It measures how "tree-like" a graph is. Trees have treewidth 1; series-parallel graphs have treewidth 2. Most NP-complete graph problems — vertex cover, independent set, Hamiltonian cycle, graph coloring — become polynomial on graphs of bounded treewidth via dynamic programming on a "tree decomposition." Courcelle's theorem from 1990 generalizes this: any graph property expressible in a specific logical language is solvable in linear time on bounded-treewidth graphs.

The teaching for business planning is to look for the small parameter before treating an NP-hard problem as hopeless. Real graphs often have small treewidth. Real scheduling problems have few machines. Real constraint systems have low interaction depth. When you find the parameter, the work shrinks dramatically.

In Rust, parameterized algorithms are typically hand-rolled — the ecosystem for treewidth and kernelization is thinner than for SAT or MIP. But the algorithms are usually short (branching plus bookkeeping) and the win is real.

Hardness is not uniform. Find the small dimension. Exploit it.

← 34 The approximation hierarchy — how good can you get
35/39
36 When everything else fails — heuristics →