Lesson 20 / 23
Hamiltonian Paths
Framing an existence question as a decision problem and counting decision, search, counting, and verification separately: a path exists on 25 of 40 graphs, and deciding spends 1526 steps while counting spends 29,270, a ratio of 19.18. On all 15 graphs where no path exists, the decision step count is exactly equal to the counting step count, both 2538. The second pool gives the same structure, with 23 and 17 graphs. Verifying a found path takes 7 steps per graph, searching for one takes an average of 61. The names of complexity classes are left to the Theory of Computation course.
Contents
The last row of the knight’s tour opened a distinction: on the four-by-four board there was no tour, and both orderings visited exactly the same number of nodes. A yes answer could become cheap; a no answer could not. This lesson makes that distinction the topic’s central question.
A Hamiltonian path is a path that passes through every node of a graph exactly once. The knight’s tour is a special case of this: squares are nodes, knight moves are edges. The question here is no longer “which is best” but “does one exist.” This is a decision problem, and its answer is a single word. Two more questions stand alongside it: the search problem asks for a path, the counting problem asks how many there are. All three questions look at the same input, and the cost of all three is measured separately.
- CP50. Graphs come from the shared definition’s generator: 8 nodes, each pair of nodes connected with a 38 percent share, undirected. Seed 20260218, second pool 20260219, 40 graphs per pool.
- CP51. The decision problem: does a path passing through every node exist. The procedure halts as soon as it finds the first path; if none exists, it scans the entire search space.
- CP52. The search problem: if such a path exists, which one. In this lesson, the decision procedure already produces the path; the step count of search and decision are the same for this problem, and this is a measured result, not a general rule.
- CP53. The counting problem: how many are there. The procedure never halts; it scans the entire space. Since every path is traversed from both ends, the count includes every path twice; what is measured is steps, not the count itself.
- CP54. Verification: whether a given candidate path is genuinely a Hamiltonian path is tested. A step is checking whether two consecutive nodes are adjacent.
- CP55. The oracle is the counting procedure: it scans the space completely, so its answer is correct by definition. The decision’s answer is compared against the oracle’s count on every graph.
- CP56. The measure is steps; a step is a node visit in the search tree.
- CP57. Yes graphs and no graphs are totaled separately. Combining them into a single average would erase the difference this lesson measures.
- CP58. The names of complexity classes are not written in this lesson. How the difference between verification and search is named belongs to the Theory of Computation course.
- CP59. The cheap test checks only necessary conditions. Its wrong-answer count is measured against the oracle; coming out zero is not assumed, it is counted.
Three Questions, One Input
The block below runs decision and counting separately on every graph, verifies any path found, and totals the results separately for yes and no graphs.
SEED = 20260218 N = 8 def generator(seed): d = seed def next_val(n): nonlocal d d = (d * 1103515245 + 12345) % 2147483648 return d % n return next_val def graphs(seed=SEED, samples=40, share=38, n=N): """Undirected graph: each pair connected with probability `share` percent.""" r = generator(seed) pool = [] for _ in range(samples): neighbors = {i: set() for i in range(n)} for i in range(n): for j in range(i + 1, n): if r(100) < share: neighbors[i].add(j) neighbors[j].add(i) pool.append(neighbors) return pool class Counter: def __init__(self): self.steps = 0 def count(self): self.steps += 1 def search(neighbors, s, stop, n=N): """stop=True: halts at the first Hamiltonian path. stop=False: counts them all.""" found, count = [], 0 def walk(v, path): nonlocal count s.count() if len(path) == n: count += 1 if not found: found.append(tuple(path)) return True for k in sorted(neighbors[v]): if k not in path: path.append(k) done = walk(k, path) path.pop() if done and stop: return True return False for start in range(n): if walk(start, [start]) and stop: break return (found[0] if found else None), count def verify(neighbors, path, n=N): """Oracle: does the candidate path have n nodes, are consecutive pairs adjacent.""" steps = 0 if path is None or len(set(path)) != n: return False, steps for a, b in zip(path, path[1:]): steps += 1 if b not in neighbors[a]: return False, steps return True, steps for seed in (20260218, 20260219): yes = {"n": 0, "decision": 0, "counting": 0, "verification": 0} no = {"n": 0, "decision": 0, "counting": 0} equal = 0 for neighbors in graphs(seed): s1, s2 = Counter(), Counter() path, _ = search(neighbors, s1, True) _, count = search(neighbors, s2, False) if path is not None: ok, d = verify(neighbors, path) yes["n"] += 1 yes["decision"] += s1.steps yes["counting"] += s2.steps yes["verification"] += d else: no["n"] += 1 no["decision"] += s1.steps no["counting"] += s2.steps if s1.steps == s2.steps: equal += 1 print("seed", seed) print(" yes ", yes, "ratio", round(yes["counting"] / yes["decision"], 2)) print(" no ", no, "graphs with decision=counting:", equal, "/", no["n"])
seed 20260218
yes {'n': 25, 'decision': 1526, 'counting': 29270, 'verification': 175} ratio 19.18
no {'n': 15, 'decision': 2538, 'counting': 2538} graphs with decision=counting: 15 / 15
seed 20260219
yes {'n': 23, 'decision': 915, 'counting': 26792, 'verification': 161} ratio 29.28
no {'n': 17, 'decision': 4390, 'counting': 4390} graphs with decision=counting: 17 / 17
Why a No Answer Accepts No Discount
On the first pool, a path exists on 25 of 40 graphs, and none on 15. On the yes graphs, decision spends a total of 1526 steps, counting 29,270 steps; a ratio of 19.18. The decision procedure halts the moment it finds the first path and never enters the rest of the space.
On no graphs, the two numbers are identical: 2538 and 2538. Moreover, this equality does not just hold in total — it holds individually on all 15 of the 15 graphs. The reason is not procedural but logical. To say “no,” the decision procedure must see that not a single branch gives a path; if even one branch remains unseen, its answer is a guess. When there is no path, no early-stopping point ever arises, so the decision procedure traverses exactly the tree the counting procedure traverses.
The second pool gives the same structure: 23 yes, 17 no, ratio 29.28, and the decision step count equals the counting step count on 17 of the 17 no graphs. The ratio on yes graphs rises from 19.18 to 29.28, meaning the size of the ratio depends on the pool; but the exact equality on no graphs holds without exception on both pools. One is a measured value; the other is a consequence of the procedure’s structure, and the two are not written in the same sentence.
This lets the previous lesson’s knight’s-tour row be reread. Both orderings visiting 29,976 nodes on the four-by-four board was not a coincidence: because the answer was no, there was no early-stopping point, and ordering could only have paid off by pulling that stop earlier.
A Cheap Test That Works One Way
A no answer being expensive does not mean every no answer is expensive. On some graphs, the absence of a path can be seen without entering the search at all: if the graph is disconnected, no path can reach every node, and if more than two nodes have only a single neighbor, they cannot all be an endpoint of the path. These are necessary conditions: if unmet, there is no path; if met, they say nothing.
from collections import deque SEED, N = 20260218, 8 def generator(seed): d = seed def next_val(n): nonlocal d d = (d * 1103515245 + 12345) % 2147483648 return d % n return next_val def graphs(seed=SEED, samples=40, share=38, n=N): r = generator(seed) pool = [] for _ in range(samples): neighbors = {i: set() for i in range(n)} for i in range(n): for j in range(i + 1, n): if r(100) < share: neighbors[i].add(j) neighbors[j].add(i) pool.append(neighbors) return pool class Counter: def __init__(self): self.steps = 0 def count(self): self.steps += 1 def cheap_test(neighbors, s, n=N): """Tests necessary conditions. Can say 'no'; cannot say 'yes'.""" queue, seen = deque([0]), {0} while queue: v = queue.popleft() for k in neighbors[v]: s.count() if k not in seen: seen.add(k) queue.append(k) if len(seen) < n: return "no" lone = 0 for v in range(n): s.count() if len(neighbors[v]) <= 1: lone += 1 return "no" if lone > 2 else "unknown" def decide(neighbors, s, n=N): def walk(v, path): s.count() if len(path) == n: return True for k in sorted(neighbors[v]): if k not in path: path.append(k) if walk(k, path): return True path.pop() return False return any(walk(start, [start]) for start in range(n)) for seed in (20260218, 20260219): cheap_no, cheap_steps, wrong = 0, 0, 0 remaining_no, remaining_steps = 0, 0 for neighbors in graphs(seed): s1, s2 = Counter(), Counter() y = cheap_test(neighbors, s1) exists = decide(neighbors, s2) cheap_steps += s1.steps if y == "no": cheap_no += 1 if exists: wrong += 1 elif not exists: remaining_no += 1 remaining_steps += s2.steps print("seed", seed, "| cheap test said 'no':", cheap_no, "| wrong:", wrong, "| cheap total steps:", cheap_steps) print(" no graphs the cheap test could not resolve:", remaining_no, "| full-scan steps:", remaining_steps)
seed 20260218 | cheap test said 'no': 11 | wrong: 0 | cheap total steps: 1092 no graphs the cheap test could not resolve: 4 | full-scan steps: 676 seed 20260219 | cheap test said 'no': 13 | wrong: 0 | cheap total steps: 1102 no graphs the cheap test could not resolve: 4 | full-scan steps: 1638
On the first pool, 11 of the 15 no graphs are resolved by the cheap test, with zero wrong answers: the test never says “no” to a graph that has a path. 4 graphs remain, and for those a full scan is unavoidable, 676 steps. On the second pool, 13 of the 17 no graphs are resolved, again zero wrong, again 4 graphs remaining, this time at 1638 steps.
The 1092 steps the test spends across all forty graphs is below the 2538 steps a full scan spends on no graphs. But the entire saving is one-directional. The test can never say “yes” on any graph; of the 29 graphs it calls “unknown,” 25 have a path and 4 do not, and telling the two apart again requires a full scan. A cheap proof always works in one direction, and its number cannot be read without saying which direction that is.
Searching Versus Verifying
The fourth number is verification. On yes graphs, the total is 175 steps, that is, 7 steps per graph: seven consecutive pairs in an eight-node path. On the same graphs, finding the path spends an average of 61 steps, 8.7 times verification.
The gap between these two numbers is small on a single graph in this lesson, but what the two numbers do with scale is not the same. Verification is node-count-minus-one steps and grows linearly with node count. Search’s step count, on the other hand, depends on the size of the tree traversed. The block below sweeps node count and places the two growths side by side.
SEED = 20260218 def generator(seed): d = seed def next_val(n): nonlocal d d = (d * 1103515245 + 12345) % 2147483648 return d % n return next_val def graphs(n, share, seed=SEED, samples=20): r = generator(seed) pool = [] for _ in range(samples): neighbors = {i: set() for i in range(n)} for i in range(n): for j in range(i + 1, n): if r(100) < share: neighbors[i].add(j) neighbors[j].add(i) pool.append(neighbors) return pool class Counter: def __init__(self): self.steps = 0 def count(self): self.steps += 1 def decide(neighbors, n, s): """Halts at the first Hamiltonian path; if none exists, the whole space is scanned.""" found = [] def walk(v, path): s.count() if len(path) == n: found.append(tuple(path)) return True for k in sorted(neighbors[v]): if k not in path: path.append(k) done = walk(k, path) path.pop() if done: return True return False for start in range(n): if walk(start, [start]): return found[0] return None print(" n | yes | yes avg steps | no avg steps | verification steps") for n, share in ((6, 30), (8, 30), (10, 30), (12, 30)): e = h = ea = ha = 0 for neighbors in graphs(n, share): s = Counter() y = decide(neighbors, n, s) if y is None: h, ha = h + 1, ha + s.steps else: e, ea = e + 1, ea + s.steps print(f"{n:2d} | {e:4d} | {(ea // e if e else 0):18d} |" f" {(ha // h if h else 0):19d} | {n - 1:15d}")
n | yes | yes avg steps | no avg steps | verification steps 6 | 2 | 8 | 23 | 5 8 | 6 | 121 | 92 | 7 10 | 9 | 164 | 757 | 9 12 | 10 | 1054 | 11601 | 11
The three columns show three different kinds of growth. Verification is 5, 7, 9, 11: increasing by one with node count. On yes graphs, decision is 8, 121, 164, 1054. On no graphs, decision is 23, 92, 757, 11,601; going from six nodes to twelve, it grows by more than five hundred times. At the eight-node row, the no average falls below the yes average; this single-row reversal comes from the scarcity of no graphs in that pool and does not generalize.
What is measured is this: verifying an answer and finding it are not the same order of magnitude, and the gap widens with node count. At twelve nodes, testing a candidate path is 11 steps; showing that no such path exists averages 11,601 steps. The same graph, the same question, a thousandfold difference.
Where the Naming Belongs
Four numbers have been measured so far: decision, search, counting, verification. One thing remains unmeasured, and it is left out on purpose. Problems that appear cheap to verify but expensive to find have a shared name; there is a procedure for transforming these problems into one another, and a theory of the difference between “appears expensive” and “is expensive.”
None of this is built in this course. In the travelling salesman lesson, the “why is it hard” question was referred to the Theory of Computation course; the naming question here goes to the same place. What this course does is measure the difference that course will speak about and put it on the table: 11 steps against 11,601 steps, and that this is an observation, not a proof.
This distinction leaves behind a useful habit. Before calling a problem hard, it must be written which question of that problem is expensive. For the same set of graphs, decision spent 1526 steps, counting 29,270, verification 175. The sentence “the Hamiltonian path is expensive” says nothing unless it states which of these three numbers it means.
Summary
- An existence question is framed as a decision problem; search and counting problems stand alongside it, and all three costs are measured separately on the same input.
- On the 25 graphs where a path is found, decision spends 1526 steps, counting 29,270; the ratio is 19.18, and 29.28 on the second pool, meaning the size of the ratio depends on the pool.
- On every graph where no path is found, decision steps exactly equal counting steps: 15 of 15 on the first pool, 17 of 17 on the second. A no answer leaves no early-stopping point.
- Verification is 7 steps per graph, search averages 61; when node count is swept, verification rises one at a time while the cost of a no answer climbs from 23 to 11,601.
- This measured difference is an observation; naming and proving it belongs to the Theory of Computation course and is not done in this lesson.
Next Step
The Classical Problems topic followed the same procedure across six problems: build the oracle, measure the pattern, count the split inputs. The next topic makes this procedure independent of the problem. Practice Discipline turns inferring which pattern fits a problem’s step budget from its constraints, and comparing a solution against an oracle, into a habit. The first question is: given an input size, how is which pattern fits the budget found by counting, rather than guessed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.