Data Structures & Algorithms (DSA) Module 20 – Randomisation and hard problems
Coping with NP-hard problems
Exact search made faster with meet in the middle and branch and bound, approximation algorithms with proven factors, and local search measured honestly.
What you will learn
- Improve exponential searches with meet in the middle and branch and bound
- Apply approximation algorithms with proven guarantees to vertex cover and set cover
- Use heuristics and local search with honest expectations
- State which guarantee an answer has, exact, within a factor or none
Before you start
What you’ll learn · Before you start
What you will learn
- Improve exponential searches with meet in the middle and branch and bound
- Apply approximation algorithms with proven guarantees to vertex cover and set cover
- Use heuristics and local search with honest expectations
- State which guarantee an answer has, exact, within a factor or none
Before you start
On this page
Coping with an NP-hard problem means giving up one of three things, because no known algorithm is exact, fast on every input and general at the same time. Keep the exact answer and accept exponential time on small inputs: meet in the middle and branch and bound push that limit far past plain brute force. Keep the exact answer and the speed for the inputs you really have, when they have structure such as a tree, a DAG or small numbers. Or keep the speed and give up the optimum: an approximation algorithm proves that its answer is within a stated factor of the best, such as twice the smallest vertex cover, while a heuristic such as local search usually finds good answers and proves nothing. The worked example takes the first route, subset sum over 36 numbers, with 524,288 half-sums instead of 68,719,476,736 subsets. Whichever route you take, state the guarantee you have.
Three routes, and what each one guarantees
The first question is always whether n is small enough for exponential work: 2²⁰ is about a million, 2⁴⁰ about a trillion. The next is whether your inputs are a special case with a polynomial algorithm. Only then do you trade the optimum for speed, with or without a proof:
Choosing how to cope with an NP-hard problem
Text description of the diagram
The questions run down the left in order; each yes leads to the route on its right, and each no to the next question.
- Is n small enough for 2ⁿ or 2ⁿᐟ² steps? Yes: exact search, such as bitmask dynamic programming, meet in the middle or branch and bound, which returns the optimum.
- If not, do the inputs have special structure, such as a tree, a DAG, a bipartite graph or small numbers? Yes: an algorithm for that structure, which returns the optimum in polynomial or pseudo-polynomial time.
- If not, must the answer come with a proven bound? Yes: an approximation algorithm, whose answer is within a stated factor of the optimum.
- If not: a heuristic or local search, which guarantees nothing, so its answers are measured against a lower bound.
| Route and what it promises | When it fits |
|---|---|
| Exact search: the optimum, in exponential time at worst | n up to about 20 to 50, or easy inputs |
| Approximation algorithm: within a proven factor of the optimum, in polynomial time | A bound on the answer must be promised |
| Heuristic or local search: nothing proved in advance | Large inputs, with each answer measured against a lower bound |
The tools of this lesson, one sentence each:
- Meet in the middle lists the subset sums of each half of the input and pairs them, so 2ⁿ work becomes about 2ⁿᐟ² for each half.
- Branch and bound backtracks like a plain search but drops any branch whose optimistic bound cannot beat the best answer found so far.
- Dynamic programming over subsets, such as the Held–Karp algorithm, solves the travelling salesperson problem exactly in O(2ⁿ n²) time.
- The matching algorithm for vertex cover takes both ends of every edge still uncovered, and its cover is never more than twice the smallest.
- Greedy set cover keeps taking the set that covers the most uncovered elements, and stays within about ln n + 1 times the optimum.
- 2-opt removes two edges of a tour and reconnects the two pieces the shorter way, again and again, until no such move helps.
- Integer programming states a problem as linear constraints on whole-number variables and leaves the search to a general solver.
Exact search on 36 numbers: meet in the middle
Subset sum asks for the subset of n numbers whose total comes closest to a target. Deciding whether some subset hits the target exactly is NP-complete, and the O(nW) table of dynamic programming helps only when the numbers are small: with 36 numbers near a billion, W, the target, is close to eight billion. Trying every subset costs 2ⁿ sums. Meet in the middle, from Ellis Horowitz and Sartaj Sahni, splits the numbers into two halves of n/2, lists the 2ⁿᐟ² subset sums of each half, and sorts one list. For every sum s of the other half, the best partner is the sorted sum closest to target − s, which a binary search finds. The program runs both methods at n = 20, where they must agree, and meet in the middle alone at n = 36:
# Subset sum over 36 numbers near a billion: which subset sum comes closest to a target? Trying every subset means
# 2^36 sums. Meet in the middle lists the 2^18 sums of each half, sorts one list, and for every sum of the other
# half looks up the best partner with a binary search. At n = 20 both methods run, and they must agree.
import random
from bisect import bisect_left
def all_sums(items):
"""The sums of all 2^len(items) subsets; bit j of an index says whether item j is in that subset."""
sums = [0]
for x in items:
sums += [s + x for s in sums]
return sums
def better(s, best, target):
"""Closer to the target wins; on a tie, the smaller sum."""
return best is None or (abs(s - target), s) < (abs(best - target), best)
def every_subset(nums, target):
best = None
for s in all_sums(nums):
if better(s, best, target):
best = s
return best, 2 ** len(nums)
def meet_in_the_middle(nums, target):
half = len(nums) // 2
left, right = all_sums(nums[:half]), sorted(all_sums(nums[half:]))
best = None
for s in left:
i = bisect_left(right, target - s) # the first right sum that reaches the target with s
for j in (i - 1, i): # the best partner is just below or at that point
if 0 <= j < len(right) and better(s + right[j], best, target):
best = s + right[j]
return best, len(left) + len(right)
rng = random.Random(36)
nums = [rng.randrange(100_000_000, 1_000_000_000) for _ in range(36)]
for n in (20, 36):
part = nums[:n]
target = sum(part) * 2 // 5
print(f"n = {n}, target {target:,}")
best, listed = meet_in_the_middle(part, target)
print(f" meet in the middle: {listed:,} half-sums listed, closest sum {best:,}, off by {abs(best - target):,}")
if n <= 20:
best, tried = every_subset(part, target)
print(f" every subset: {tried:,} sums tried, closest sum {best:,}, off by {abs(best - target):,}")
else:
print(f" every subset would need {2 ** n:,} sums") Output
n = 20, target 4,122,734,372 meet in the middle: 2,048 half-sums listed, closest sum 4,122,734,277, off by 95 every subset: 1,048,576 sums tried, closest sum 4,122,734,277, off by 95 n = 36, target 7,773,804,694 meet in the middle: 524,288 half-sums listed, closest sum 7,773,804,694, off by 0 every subset would need 68,719,476,736 sums
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subset_sum.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
At n = 20, every subset meant 1,048,576 sums, and meet in the middle listed 2,048 half-sums; both found the same closest sum, 95 below the target. At n = 36, meet in the middle listed 524,288 half-sums and hit the target exactly, where trying every subset would need 68,719,476,736 sums. The cost is O(2ⁿᐟ² n) time for the listing and the sort, and memory for 2ⁿᐟ² sums, which is what limits the method: about 40 to 50 numbers, against about 25 for every subset.
Practise: the closest subset sum
Write meet in the middle for the closest subset sum. The sample tests include 34 numbers near a trillion, too many for a search of every subset, and compare your answers with a slow search on 300 small lists.
Exercise · Medium · Python, JavaScript
The subset sum closest to a target, by meet in the middle
Write closest_subset_sum(nums, target) in closest.py (JavaScript: closestSubsetSum(nums, target) in closest.mjs). nums holds up to 40 positive whole numbers, each below 10¹², and target is a whole number of 0 or more. Return the sum of the subset of nums that comes closest to target. The empty subset counts, with sum 0. When two sums are equally close, return the smaller one.
Trying all 2ⁿ subsets is too slow for 34 numbers, which the sample tests include. Split the numbers into two halves, list the sums of all subsets of each half, sort one list, and for every sum of the other half find its best partner with a binary search. The sample tests also compare your answers with a slow search on 300 small random lists.
Python · Starter code · closest.py
from bisect import bisect_left
def closest_subset_sum(nums, target):
"""The sum of some subset of nums (the empty subset counts) closest to target; on a tie, the smaller sum."""
# Write meet in the middle here.
return 0 The sample tests · test_closest.py
import random
from itertools import combinations
from closest import closest_subset_sum
BIG = [
493113784116, 132580534659, 352730161662, 133924420423, 528990724597, 172794715421, 474977204024, 740356142812,
203744176332, 479134929061, 102622444117, 270838240370, 678758273429, 980839394515, 503989920585, 912844967050,
875464764886, 168027315632, 528639733939, 284399052225, 442036977465, 517865774538, 333276218569, 594806016691,
295572531361, 244342064118, 888440858074, 698684003136, 359518668886, 852686342057, 223760290864, 836934969835,
184820004716, 588817237807,
]
def slow(nums, target):
best = None
for size in range(len(nums) + 1):
for subset in combinations(nums, size):
s = sum(subset)
if best is None or (abs(s - target), s) < (abs(best - target), best):
best = s
return best
def test_small_cases():
"""small lists: exact hits, everything, nothing, and the empty list"""
assert closest_subset_sum([3, 34, 4, 12, 5, 2], 9) == 9
assert closest_subset_sum([3, 34, 4, 12, 5, 2], 100) == 60
assert closest_subset_sum([1, 2, 4, 8, 16, 32, 64], 77) == 77
assert closest_subset_sum([], 7) == 0
assert closest_subset_sum([10], 6) == 10
def test_ties_go_to_the_smaller_sum():
"""when two sums are equally close, the smaller one wins"""
assert closest_subset_sum([3, 34, 4, 12, 5, 2], 1) == 0
assert closest_subset_sum([7, 9], 8) == 7
assert closest_subset_sum([10], 5) == 0
def test_against_a_slow_search():
"""300 random lists of up to 12 numbers give the same answer as trying every subset"""
rng = random.Random(5)
for _ in range(300):
nums = [rng.randint(1, 60) for _ in range(rng.randint(0, 12))]
target = rng.randint(0, 400)
assert closest_subset_sum(nums, target) == slow(nums, target), (nums, target)
def test_thirty_four_big_numbers():
"""34 numbers near a trillion: 2^34 subsets are too many, so this needs meet in the middle"""
assert closest_subset_sum(BIG, 5360110952657) == 5360110952810
assert closest_subset_sum(BIG, sum(BIG) + 5) == sum(BIG)
assert closest_subset_sum(BIG, 0) == 0 JavaScript · Starter code · closest.mjs
/** The sum of some subset of nums (the empty subset counts) closest to target; on a tie, the smaller sum. */
export function closestSubsetSum(nums, target) {
// Write meet in the middle here.
return 0;
} The sample tests · closest.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { closestSubsetSum } from './closest.mjs';
const BIG = [
493113784116, 132580534659, 352730161662, 133924420423, 528990724597, 172794715421, 474977204024, 740356142812,
203744176332, 479134929061, 102622444117, 270838240370, 678758273429, 980839394515, 503989920585, 912844967050,
875464764886, 168027315632, 528639733939, 284399052225, 442036977465, 517865774538, 333276218569, 594806016691,
295572531361, 244342064118, 888440858074, 698684003136, 359518668886, 852686342057, 223760290864, 836934969835,
184820004716, 588817237807,
];
function slow(nums, target) {
let best = null;
for (let mask = 0; mask < 1 << nums.length; mask++) {
let s = 0;
for (let i = 0; i < nums.length; i++) if (mask & (1 << i)) s += nums[i];
if (best === null || Math.abs(s - target) < Math.abs(best - target) || (Math.abs(s - target) === Math.abs(best - target) && s < best)) best = s;
}
return best;
}
test('small lists: exact hits, everything, nothing, and the empty list', () => {
assert.equal(closestSubsetSum([3, 34, 4, 12, 5, 2], 9), 9);
assert.equal(closestSubsetSum([3, 34, 4, 12, 5, 2], 100), 60);
assert.equal(closestSubsetSum([1, 2, 4, 8, 16, 32, 64], 77), 77);
assert.equal(closestSubsetSum([], 7), 0);
assert.equal(closestSubsetSum([10], 6), 10);
});
test('when two sums are equally close, the smaller one wins', () => {
assert.equal(closestSubsetSum([3, 34, 4, 12, 5, 2], 1), 0);
assert.equal(closestSubsetSum([7, 9], 8), 7);
assert.equal(closestSubsetSum([10], 5), 0);
});
test('300 random lists of up to 12 numbers give the same answer as trying every subset', () => {
let seed = 5;
const next = (n) => {
seed = (seed * 48271) % 2147483647;
return seed % n;
};
for (let k = 0; k < 300; k++) {
const nums = Array.from({ length: next(13) }, () => 1 + next(60));
const target = next(401);
assert.equal(closestSubsetSum(nums, target), slow(nums, target), JSON.stringify([nums, target]));
}
});
test('34 numbers near a trillion: 2^34 subsets are too many, so this needs meet in the middle', () => {
const total = BIG.reduce((a, b) => a + b, 0);
assert.equal(closestSubsetSum(BIG, 5360110952657), 5360110952810);
assert.equal(closestSubsetSum(BIG, total + 5), total);
assert.equal(closestSubsetSum(BIG, 0), 0);
}); A hint
For a left sum s, the best partner from the sorted right sums is next to the position where target - s would be inserted: check the right sum just before that position and the one at it (Python: bisect_left(right, target - s); JavaScript: write the binary search yourself). In JavaScript, sort numbers with (a, b) => a - b: the default sort compares them as text, so 10 would come before 9.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Branch and bound measured, and the inputs where its bound fails Pro: opens with a Pro, Premium or Ultimate pass
Branch and bound is backtracking with a test that throws hopeless branches away early. This part counts what that test saves on 0/1 knapsack, proves that it never throws away the optimum, reproduces the inputs on which it stops helping, and compares the exact methods by what limits each one.
In this part:
- Branch and bound on 0/1 knapsack, counted node by node
- Why the fractional bound never drops the optimum
- A failure reproduced: when every item has the same value per kilogram
- Choosing an exact method by n and by the numbers
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Approximation with proofs: vertex cover and set cover, measured Pro: opens with a Pro, Premium or Ultimate pass
An approximation algorithm comes with a proof, and the proof is what you are paying for. This part proves the factor 2 of the matching algorithm, measures it against the exact optimum on 300 graphs, shows that a rule that looks better has no such promise, and runs greedy set cover against its bound.
In this part:
- The matching algorithm, and the proof of its factor 2
- Measured on 300 graphs against the exact optimum
- A guarantee is not typical quality: greedy by degree
- Greedy set cover within H(d), and the input that stretches it
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Local search with honest expectations: 2-opt on 200 points Pro: opens with a Pro, Premium or Ultimate pass
Local search solves most large instances in practice, and on its own it proves nothing. This part runs 2-opt on 200 points from 21 starting tours, adds a lower bound that turns its answer into a checked claim, and ends with the one-line statement of guarantee for every method of the lesson.
In this part:
- 2-opt from 21 starting tours, measured
- A lower bound that turns a heuristic answer into a checked one
- Restarts, time limits and when to stop
- Stating the guarantee you have, in one line
- Integer programming: modelling instead of writing a search
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What is the difference between an approximation algorithm and a heuristic?
An approximation algorithm runs in polynomial time and comes with a proof that its answer is within a stated factor of the optimum on every input: the matching algorithm for vertex cover never returns more than twice the smallest cover. A heuristic comes with no such proof. It may be excellent on typical inputs, as 2-opt usually is for tours, but some inputs can make it arbitrarily bad, and you learn how good an answer was only by comparing it with a lower bound or with the optimum. So the difference is the promise, not the quality you happen to observe: report a heuristic's answer with the bound you measured, and an approximation's with its factor.
-
Mid to senior
Given 40 positive integers up to 10¹² and a target, decide within a few seconds whether some subset sums exactly to the target. Which algorithm do you use, what does it cost, and what breaks first as n grows?
-
Mid to staff
A dispatch team wants the best delivery route through 150 stops every morning, computed in under 10 seconds on one machine. What do you build, and what do you promise them about the route?
-
Mid to senior
A warehouse wants cameras at junctions so that every corridor is watched, using as few cameras as possible. The operations head wants a promise about how many cameras the plan might waste. What do you offer?
Key takeaways
- An NP-hard problem forces a choice: exact but exponential, fast for a special structure, or fast with only an approximate or heuristic answer.
- Meet in the middle turns 2ⁿ subsets into two lists of 2ⁿᐟ² sums: 524,288 half-sums instead of 68,719,476,736 subsets for 36 numbers.
- Branch and bound, approximation algorithms and local search each come with a different kind of promise: the optimum, a factor, or nothing in advance.
- State the guarantee with every answer: exact, within a proven factor, or measured against a lower bound.
References
- Computing partitions with applications to the knapsack problem (E. Horowitz and S. Sahni, Journal of the ACM 21(2)) (Association for Computing Machinery)
- An automatic method of solving discrete programming problems (A. H. Land and A. G. Doig, Econometrica 28(3)) (The Econometric Society)
- Discrete-variable extremum problems (G. B. Dantzig, Operations Research 5(2)) (INFORMS)
- A greedy heuristic for the set-covering problem (V. Chvátal, Mathematics of Operations Research 4(3)) (INFORMS)
- Approximation algorithms for combinatorial problems (D. S. Johnson, Journal of Computer and System Sciences 9(3)) (Elsevier)
- A threshold of ln n for approximating set cover (U. Feige, Journal of the ACM 45(4)) (Association for Computing Machinery)
- A method for solving traveling-salesman problems (G. A. Croes, Operations Research 6(6)) (INFORMS)
- Worst-case analysis of a new heuristic for the travelling salesman problem (N. Christofides, Operations Research Forum 3) (Springer)
- A dynamic programming approach to sequencing problems (M. Held and R. M. Karp, Journal of SIAM 10(1)) (Society for Industrial and Applied Mathematics)
- Reducibility among combinatorial problems (R. M. Karp, in Complexity of Computer Computations) (Springer)
- A new polynomial-time algorithm for linear programming (N. Karmarkar, Combinatorica 4(4)) (Springer)
- branch and bound (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- approximation algorithm (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- heuristic (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- local optimum (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- set cover (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- integer linear program (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- ACM/IEEE-CS/AAAI Computer Science Curricula 2023: Algorithmic Foundations (AL) (ACM, IEEE Computer Society and AAAI)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress