Some problems resist every clever idea you throw at them. Scheduling exams so no student has two at once, packing parcels into the fewest trucks, choosing the cheapest set of servers that covers every region: each has a brute-force answer that explodes with input size, and no known algorithm that is fast in the worst case. NP-completeness is the theory that explains why. It does not prove these problems are hard. It proves they are all equally hard, so a fast algorithm for any one of them would give a fast algorithm for every one. That is the practical value of recognising one: you stop hunting for an exact polynomial algorithm and choose a strategy that works.
This article builds the idea from first principles: decision problems, verifiers and reductions (including the direction people get backwards), a complete and tested 3-SAT to Independent Set reduction, the kinds of hardness, and the toolbox for when your problem turns out to be hard.
Decision problems and input size
Complexity classes are defined over decision problems, questions with a yes or no answer. "What is the shortest tour through these cities?" is an optimisation problem. Its decision version is "is there a tour of length at most L?" The two are closely tied. If you can answer the decision version quickly, you can binary-search L to find the optimum value, and with a little more work recover the tour itself. Decision versions are used because they make reductions clean: you only have to preserve one bit, yes or no.
Input size is measured in bits. That detail matters later. A number W written in binary takes about log2 W bits, so an algorithm that runs in time proportional to W is exponential in the size of its input, even if it looks polynomial on paper.
P, NP and verifiers
P is the class of decision problems solvable in time polynomial in the input size. NP is the class where every yes-instance has a certificate, a short piece of evidence that a polynomial-time verifier can check. The name means nondeterministic polynomial time. It does not mean "non-polynomial", and every problem in P is also in NP. For satisfiability (SAT) the certificate is a truth assignment, and checking it takes one pass over the formula:
def verify_sat(clauses, assignment):
"""clauses: list of tuples of non-zero ints (DIMACS style: 3 means x3, -3 means not x3).
assignment: dict var -> bool. Runs in O(total literals)."""
return all(any(assignment[abs(l)] == (l > 0) for l in clause) for clause in clauses)Finding a satisfying assignment is another matter: there are 2n candidates. Whether finding is ever fundamentally harder than checking is the P versus NP question. It is open, and most researchers believe P ≠ NP. A problem is NP-hard if every problem in NP reduces to it in polynomial time. It is NP-complete if it is NP-hard and also in NP. Stephen Cook (1971) and Leonid Levin, working independently, showed that SAT is NP-complete. Richard Karp (1972) then reduced SAT to 21 classic combinatorial problems, and that is when it became clear how many everyday problems belong to this class.
Reductions and their direction
A polynomial-time reduction from A to B, written A ≤p B, is an algorithm that converts any instance of A into an instance of B, in polynomial time, so that the answers match: yes maps to yes and no maps to no. If B has a fast algorithm, so does A. You convert, then solve.
To prove a new problem B is NP-hard, you reduce a known NP-complete problem A to B. The common mistake is the reverse: showing that your problem can be encoded as SAT (B ≤p SAT) proves only that B is in NP, which is usually obvious and says nothing about hardness. The full recipe for an NP-completeness proof has four steps:
- Show B is in NP: describe the certificate and a polynomial verifier.
- Pick a known NP-complete problem A that looks structurally similar.
- Give a polynomial-time transformation from instances of A to instances of B.
- Prove both directions: A-yes implies B-yes, and B-yes implies A-yes (equivalently, A-no implies B-no).
Worked reduction: 3-SAT to Independent Set
Independent Set asks: given a graph and a number k, is there a set of k vertices with no edge between any two of them? It is in NP, because the certificate is the set of vertices and checking it means testing every pair. For hardness, reduce from 3-SAT, whose instances are formulas in conjunctive normal form with three literals per clause.
The construction: create one vertex for each literal occurrence. Join the three vertices of each clause into a triangle. Join every pair of vertices that carry contradictory literals, such as x1 in one clause and ¬x1 in another. Set k to the number of clauses.
from itertools import combinations
def sat_to_independent_set(clauses):
"""3-CNF -> (vertices, edges, k). One vertex per literal occurrence."""
vertices = [(i, lit) for i, clause in enumerate(clauses) for lit in clause]
edges = set()
for u, v in combinations(range(len(vertices)), 2):
(ci, li), (cj, lj) = vertices[u], vertices[v]
if ci == cj or li == -lj: # same-clause triangle, or contradictory literals
edges.add((u, v))
return vertices, edges, len(clauses)
def independent_set_to_assignment(vertices, chosen, n_vars):
a = {x: False for x in range(1, n_vars + 1)} # unconstrained variables: any value works
for v in chosen:
_, lit = vertices[v]
a[abs(lit)] = lit > 0
return aRun it on φ = (x1 ∨ x2 ∨ ¬x3) ∧ (¬x1 ∨ x3 ∨ x4) ∧ (¬x2 ∨ ¬x3 ∨ ¬x4). The graph has 9 vertices and 14 edges: nine from the three triangles and five from contradictory pairs. k is 3. A brute-force search finds the independent set {x1 from clause 1, x3 from clause 2, ¬x2 from clause 3}. Mapping it back gives x1 = true, x2 = false, x3 = true, x4 = false, which satisfies φ under verify_sat. As a further check, the reduction was run on 300 random small formulas, and for every one the graph had an independent set of size k exactly when the formula was satisfiable.
Why it is correct. If φ is satisfiable, pick one true literal from each clause. These k vertices lie in different triangles, and no two of them contradict, because they are all true under one assignment. So they form an independent set. Conversely, an independent set of size k contains at most one vertex per triangle, so it has exactly one per clause. It contains no contradictory pair, so setting those literals true is consistent and satisfies every clause. The construction is O(m2) for m clauses, which is polynomial. Vertex Cover and Clique follow almost immediately: S is independent exactly when the remaining vertices form a cover, and exactly when S is a clique in the complement graph.
NP-hard versus NP-complete
NP-complete problems are decision problems. Their optimisation versions, such as "find the shortest tour" or "find the most valuable knapsack", are NP-hard. They are at least as hard as the decision versions, but they are not in NP, because their output is not a yes or no answer. Some NP-hard problems are much harder than anything in NP. The halting problem is NP-hard, since SAT reduces to it, but it is undecidable. When you read "TSP is NP-complete", read it as a claim about the decision version.
Weak versus strong NP-completeness
The 0/1 knapsack decision problem is NP-complete, yet the textbook dynamic program solves it in O(nW) time, where W is the capacity. This is not a contradiction. W is a number, and its input size is log W bits, so O(nW) is exponential in input size. It is called pseudo-polynomial. Problems that become easy when the numbers are small are weakly NP-complete: subset sum, partition and knapsack. The site's Partition Equal Subset Sum and Subset Sum articles show those dynamic programs in practice.
Strongly NP-complete problems stay hard even when every number is bounded by a polynomial in the input size. Examples are 3-partition, bin packing and TSP with integer distances. The practical consequence is direct: if your weights are small integers, such as minutes in a day or grams below a few thousand, a weakly NP-complete problem may be easy for you. If the problem is strongly NP-complete, small numbers do not help.
Where easy turns hard
The boundary between easy and hard is often thin, and it pays to know where it lies:
| Polynomial | NP-complete | What changes |
|---|---|---|
| 2-SAT (implication graph, SCCs) | 3-SAT | Two literals per clause become three |
| Eulerian path (every edge once) | Hamiltonian path (every vertex once) | Edges versus vertices |
| 2-colouring (bipartite test) | 3-colouring | Two colours become three |
| Shortest path | Longest simple path | Minimise versus maximise over simple paths |
| Independent set on trees or bipartite graphs | Independent set on general graphs | Structure of the input |
The Hamiltonian side is explored with a backtracking solver in Hamiltonian Path, in depth. When a problem looks hard, check whether your instances belong to an easy special case: trees, planar graphs, bounded treewidth, two choices per variable, or small integers.
Recognising it in real work
| What it looks like at work | The classic problem underneath |
|---|---|
| Exam or meeting timetables with conflicts | Graph colouring |
| Fewest servers covering every region or test cases covering every requirement | Set cover |
| Loading containers or VMs onto the fewest hosts | Bin packing |
| Delivery route through all stops | TSP |
| Feature flags or config options with constraints between them | SAT |
| Picking non-conflicting jobs to maximise value | Weighted independent set |
Warning signs: choosing a subset or labelling items under pairwise constraints, where every greedy rule you try has a counterexample.
What to do when your problem is hard
Proving hardness is the start of the engineering, not the end. There are five standard responses.
Exact solvers. Modern SAT, constraint-programming and integer-programming solvers routinely solve industrial instances with many thousands of variables, because real instances have structure that the worst case does not. Encode the problem, set a time limit, and treat "no answer within the limit" as a normal outcome.
Approximation with a guarantee. For minimum vertex cover, take both endpoints of every edge in a maximal matching. The result is at most twice the optimum, because any cover must contain at least one endpoint of each matched edge:
def vertex_cover_2approx(edges):
cover = set()
for u, v in edges:
if u not in cover and v not in cover: # edge not yet covered: take both ends
cover.update((u, v))
return cover # |cover| <= 2 * OPTGreedy set cover achieves a factor of about ln n. Metric TSP has Christofides-style constant-factor algorithms. Not every problem can be approximated well, though. Unless P = NP, general TSP without the triangle inequality has no constant-factor approximation, and graph colouring is hard to approximate.
Parameterised algorithms. If the answer is small, exploit that. Vertex cover of size k can be decided in O(2k · m) time by branching on each uncovered edge: one endpoint or the other must be in the cover.
def has_cover(edges, k):
if not edges:
return True
if k == 0:
return False
u, v = edges[0]
return (has_cover([e for e in edges if u not in e], k - 1) or
has_cover([e for e in edges if v not in e], k - 1))Heuristics and local search. Simulated annealing, tabu search and large-neighbourhood search find good solutions with no guarantee. Always report a lower bound alongside the result, for example from an LP relaxation, so you know how far from optimal you might be. Change the problem. Restrict the inputs, relax a constraint, or accept a pseudo-polynomial algorithm when the numbers are small. Colouring heuristics in this style are covered in Graph Coloring, in depth.
Failure modes
- Reducing in the wrong direction. Encoding your problem as SAT shows it is in NP, not that it is hard.
- Forgetting the reverse direction of the proof. A transformation that maps yes to yes but also maps some no-instances to yes is not a reduction.
- Confusing worst case with your case. NP-hardness of the general problem says nothing about your instances if they are small, structured or have small numbers.
- Pseudo-polynomial blowups. An O(nW) program that is instant on test data can run out of memory when W becomes a price in cents.
- Believing a polynomial algorithm you wrote for an NP-complete problem. It is almost certainly wrong on some input. Cross-check it against brute force on small random instances, as was done for the reduction above.
What to do next
- Write down the decision version of your problem and its certificate.
- Look for the classic problem underneath, using the table above. Check whether your instances fall into a polynomial special case.
- If you need a proof, reduce a known NP-complete problem to yours and prove both directions.
- Decide what you need: exact answers (solver with a time limit), a guarantee (approximation), a small parameter (FPT) or simply good answers (heuristic plus a lower bound).
- Check whether the numbers are small enough for a pseudo-polynomial algorithm.
- Test every solver against brute force on small random instances before trusting it.
- Practise on 2-SAT to see exactly where tractability ends.