Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close