learn.aathan.in

Issues in Search Techniques: How Search Fails

Combinatorial explosion, cycles and redundant paths, local maxima, plateaux and ridges, inadmissible and inconsistent heuristics, and the four classic design questions — with a diagnostic table for telling which failure you are actually looking at.

Every algorithm on these pages is a page of pseudocode that works perfectly on the example. What nobody writes down is the list of ways it fails on your problem — and those failures look alike from the outside. Your search hangs. Was it a cycle, an explosion, or a plateau? Your search returns an answer that’s worse than one you found by hand. Was the heuristic inadmissible, or did you stop at the wrong moment?

This page is that list. Each failure gets its cause, its symptom, and its fix.


The failure map

What is the search doing wrong? Never finishes Wrong answer Out of memory ● a cycle in the state space ● combinatorial explosion ● plateau — no move improves ● no solution exists at all ● inadmissible heuristic ● goal-tested on push, not pop ● stuck in a local maximum ● a closed set hid a cheaper path ● frontier grows as bᵈ ● explored set holds all states ● copying the path into nodes ● storing states, not deltas → visited set / cycle check → add a heuristic, or approximate → sideways moves, random restart → relax the problem to get h → test on pop for UCS and A* → simulated annealing, restarts → iterative deepening / IDA* → bounded transposition table → parent pointers Symptom → likely cause → fix. Most "my search is broken" reports are one of these twelve.

1. Combinatorial explosion

The base failure, and the one you can’t engineer around — only avoid.

A search tree with branching factor bb and depth dd has O(bd)O(b^d) nodes. Adding one level multiplies the work by bb. That’s not a constant factor you can win back with better code.

ProblembbDepth for a hard instanceNodes
8-puzzle~2.7311013 without pruning
15-puzzle~3801038
Chess~3580 plies10123
Go~25015010360
TSP, 20 cities6 × 1016 tours

The only real responses, in order of how much they buy you:

  1. Change the representation. Going from “choose 8 of 64 squares” to “a permutation” cut N-queens by 110,000× before a single line of search ran. Always try this first — it’s free.
  2. Add a heuristic. Manhattan distance on the 8-puzzle cuts a depth-24 search from 127,931 nodes to 1,660, a factor of 77.
  3. Prune provably. Alpha-beta on tic-tac-toe cuts 549,946 nodes to 18,297 with an identical answer. Branch-and-bound does the same for TSP.
  4. Give up on optimality. Nearest-neighbour + 2-opt gets within ~5% of the best TSP tour in O(n2)O(n^2). Five percent today beats optimal in two years.

The diagnostic: print your frontier size every 1,000 expansions. If it is growing roughly geometrically, you have an explosion and no amount of micro-optimisation will save you — you need one of the four above.


2. Cycles and redundant paths

Two different problems that people lump together.

A cycle is a path that returns to a state it already contains: A → B → A. Without a check, DFS follows one forever. This is what makes the search never terminate.

A redundant path is a different route to the same state: A → B → D and A → C → D. It terminates, but you do the work below D twice — and that subtree is where all the nodes are.

A CYCLE the search never terminates ABC A → B → C → A → B → … fix: a visited set (graph search) A REDUNDANT PATH it terminates — but does the work twice A BC DD fix: the same visited set — the whole D subtree is explored once

The fix for both is the explored set from graph search — but the cost differs, and so does what you can get away with:

SituationMinimum you need
The space is a genuine tree (no repeats possible)nothing
Cycles exist, memory is tightcheck only the current path (what iterative deepening does)
Cycles exist, memory is finea full visited set
Costs vary (UCS, A*)best_g[state] — a plain set is wrong, see below

The classic mistake. In BFS, mark a state visited when you push it, not when you pop it. Marking on pop lets the same state be queued dozens of times before its first expansion, and your memory blows up for no reason. In A*, the opposite: you must let a state back in if you reach it more cheaply.


3. The local search failures: maxima, plateaux and ridges

Hill climbing keeps only the current state and moves to a better neighbour. It uses no memory and scales to a million variables — and it fails in three distinct ways, which look identical from inside the loop.

objective state space LOCAL MAXIMUM every neighbour is worse, so hill climbing stops here PLATEAU no move changes the value — the search has no gradient to follow RIDGE up needs two axes at once; single-axis moves go downhill GLOBAL MAX start All three look the same to the algorithm: "no neighbour is better." The fixes are different.

Local maximum

Every neighbour is worse, but you’re not at the global best. Hill climbing halts and reports success. This is the failure people mean when they say hill climbing “gets stuck”.

Fixes: random-restart hill climbing (run it 20 times from random starts, keep the best); simulated annealing (accept a worse move with probability eΔ/Te^{-\Delta/T}, cooling TT over time); beam search (keep the best kk states rather than one).

Plateau

A flat region: every neighbour scores the same. There’s no gradient to follow, so the search has no information about which way to go. A shoulder is a plateau with an uphill exit somewhere along it; a flat local maximum has none.

Fix: allow sideways moves — accept equal-scoring neighbours — but cap them, or you’ll loop on a flat maximum forever. Measured over 2,000 random 8-queens starts:

Sideways moves allowedSolvedMean steps
013.8%4.2
up to 10094.0%23.0

Seven times the success rate for five times the steps — an outstanding trade.

But it only works with random tie-breaking. If you always take the first best-scoring neighbour, sideways moves just oscillate between two equal states until the cap trips, and the success rate lands at about 32% instead of 94%. The fix is one line: collect every neighbour tied for the best score and random.choice among them. This is the sort of detail that silently costs you two-thirds of an algorithm’s value.

Ridge

The nastiest. The landscape rises along a diagonal, but your operators only move along the axes. Every single-axis move steps off the ridge and downhill, so you oscillate from side to side making almost no upward progress.

Fix: this is an operator problem, not a search problem. Add compound moves that change two variables at once, or use a method that estimates a direction (gradient methods, or 2-opt style paired swaps in TSP).

import random, math

def hill_climb(initial, neighbours, score, max_sideways=100):
    """Returns (state, why_it_stopped). `score` is to be MAXIMISED."""
    current, sideways = initial, 0
    while True:
        cands = list(neighbours(current))
        if not cands:
            return current, 'no neighbours'
        best_score = max(score(c) for c in cands)
        # random tie-breaking is NOT optional — see the note above
        best = random.choice([c for c in cands if score(c) == best_score])

        if best_score < score(current):
            return current, 'local maximum'
        if best_score == score(current):
            sideways += 1
            if sideways > max_sideways:
                return current, 'plateau (sideways limit hit)'
        else:
            sideways = 0
        current = best


def simulated_annealing(initial, neighbours, score, T0=1.0, cooling=0.997):
    """Escapes local maxima by sometimes accepting a worse state."""
    current, best, T = initial, initial, T0
    while T > 1e-4:
        nxt = random.choice(list(neighbours(current)))
        delta = score(nxt) - score(current)
        if delta > 0 or random.random() < math.exp(delta / T):
            current = nxt
            if score(current) > score(best):
                best = current
        T *= cooling
    return best

Why annealing works. At high TT, eΔ/T1e^{-\Delta/T} \approx 1 and you accept almost anything — a random walk that explores freely. As T0T \to 0 you accept only improvements — pure hill climbing. The schedule anneals from one to the other. With a slow enough cooling schedule it provably finds the global optimum; “slow enough” is usually slower than you can afford, which is why annealing is a good heuristic rather than a guarantee.


4. Heuristic failures

A heuristic can be wrong in two specific ways, and they have different symptoms.

Inadmissible: overestimates, so A* returns a suboptimal path

h(n)h(n) is admissible if it never exceeds the true remaining cost. Break that and A* can pop a goal while a cheaper path is still in the frontier — because that cheaper path’s inflated ff pushed it behind.

S P Q G 55 34 h(P) = 5 ✓ h(Q) = 9 ✗ true cost is 4 Upper path S→P→G costs 10 Lower path S→Q→G costs 7 f(P) = 5 + 5 = 10 f(Q) = 3 + 9 = 12 12 > 10, so A* expands P first, reaches G, and returns the 10-cost path. Wrong by 3. One overestimate, anywhere in the graph, loses optimality. Nothing warns you.

Symptom: A* returns a path, it looks plausible, and it’s quietly worse than the best one. Nothing crashes.

The diagnostic — run this once on small instances:

def check_admissible(states, h, true_cost):
    """true_cost(s) = exact optimal cost from s (get it from BFS/UCS)."""
    bad = [(s, h(s), true_cost(s)) for s in states if h(s) > true_cost(s)]
    for s, hv, tv in bad[:10]:
        print(f'INADMISSIBLE at {s}: h={hv} > true={tv}')
    return not bad

The reliable way to get admissibility: derive hh by relaxing the problem — remove a constraint, solve the easier version exactly, use that cost. Relaxation provably yields an admissible heuristic. Manhattan distance is “let tiles pass through each other”; the MST bound for TSP is “you need to connect the remaining cities somehow”. Guessed heuristics are where inadmissibility creeps in.

Inconsistent: valid answer, wasted work

hh is consistent (monotonic) if h(n)c(n,n)+h(n)h(n) \le c(n, n') + h(n') for every edge. Consistency implies admissibility but is stronger.

With an inconsistent-but-admissible heuristic, A* still returns the optimal path — but it may re-expand nodes it already closed, because a cheaper route to them shows up later. In the worst case that’s exponential re-expansion.

PropertyGuaranteesViolating it costs you
Admissibleoptimal solutioncorrectness
Consistentno node is re-expandedtime

If you use a plain closed set (never revisiting), an inconsistent heuristic costs you correctness too. That’s the trap in the note on graph search.

And the third heuristic failure: it’s admissible but useless

h(n)=0h(n) = 0 is perfectly admissible. It’s also just Dijkstra. A heuristic that always returns a tiny number is safe and does nothing.

The measure worth tracking is the effective branching factor bb^* — the branching factor a uniform tree would need to hold NN generated nodes at depth dd:

N=1+b+(b)2++(b)dN = 1 + b^* + (b^*)^2 + \dots + (b^*)^d

Measured on the 8-puzzle (12 random boards at each of depths 12, 16, 20, 24):

Heuristicbb^*Reading
none (uninformed)~2.7the raw branching factor
misplaced tiles, h₁1.43real pruning
Manhattan, h₂1.25near-perfect

A bb^* close to 1 means the search walks nearly straight to the goal; close to bb means the heuristic is doing nothing. The gap between 1.43 and 1.25 looks small and isn’t — it compounds over depth, which is exactly the 77× runtime difference at depth 24.

This is also the number that tells you a heuristic is worth improving. If bb^* is already 1.05, spend your time elsewhere.


5. Stopping at the wrong moment

A short section for a bug that produces confident wrong answers.

# WRONG for uniform-cost search and A*
for child in expand(node):
    if goal_test(child.state):
        return child            # <-- a cheaper goal may still be in the frontier
    frontier.push(child)

# RIGHT
node = frontier.pop()
if goal_test(node.state):       # <-- pop order guarantees minimum f
    return node

Testing on generation is correct for BFS with uniform step costs — the first goal you generate is at the shallowest depth. It is wrong the moment costs vary, because a goal generated early can be expensive while a cheaper one sits in the frontier waiting.

The general rule: a node’s value is only proven when you pop it. Everything in the frontier is still a guess.


6. The four design questions

Beyond the failure modes, four choices shape any search program. These are the classic “issues in the design of search programs”.

DirectionWhen it wins
Forward (start → goal)Few start states, many goal states; goals are described by a predicate
Backward (goal → start)One explicit goal, many possible starts; you can compute predecessors
BidirectionalOne of each, reversible operators, and memory to spare

The rule of thumb is to search in the direction with the lower branching factor. Medical diagnosis runs backward (few diseases, many symptom combinations); a maze runs forward. Backward search needs PREDECESSORS(s)\text{PREDECESSORS}(s), and for many problems — block world, most planning — that’s much harder to compute than successors.

How do you pick which rule to apply?

With six production rules you loop over all of them. With ten thousand, matching becomes the bottleneck, and you need indexing — the RETE algorithm was invented precisely for this and still runs production-rule engines today. The other half of the question is conflict resolution: when several rules match, which fires? By specificity, by recency, by an explicit priority?

How do you represent a node?

The trade-off is memory versus recomputation. Store the full state (fast comparisons, lots of memory) or store a delta from the parent (tiny, but you replay to reconstruct). Chess engines store an incremental Zobrist hash rather than the board. And whatever you choose, it must be hashable if you want an explored set — which is why every state on this site is a tuple or a frozenset, never a list.

What do you do at a dead end?

Chronological backtracking (undo the most recent choice) is simple and often wasteful — the real culprit may be twelve decisions back. Backjumping and conflict-directed backjumping jump straight to the decision that caused the failure. On constraint problems this is frequently an order-of-magnitude win, and it’s the idea that grew into clause learning in modern SAT solvers.


The diagnostic checklist

When a search misbehaves, work down this list in order — it’s roughly sorted by how often each one is the actual cause:

  1. Is the goal test a predicate or an equality? If the real goal is partial, state == goal never fires.
  2. Are states hashable and compared by value? A list, or a class without __eq__/__hash__, makes every visited-set lookup miss.
  3. Are you marking visited on push or on pop? Push for BFS; best_g for A*.
  4. Are the preconditions on your operators complete? A missing bounds check generates states that look plausible and aren’t reachable.
  5. Is the heuristic admissible? Test it against exact costs on small instances.
  6. Are you testing the goal on pop, for anything cost-based?
  7. Is the frontier growing geometrically? Then it’s explosion, not a bug.
  8. Does a solution exist at all? 4 missionaries and 4 cannibals with a two-seat boat has none; so does 3 gallons from a 4- and 6-gallon jug. Handle the “searched everything, found nothing” case explicitly instead of assuming a path exists.

Point 8 is worth dwelling on. A search that exhausts its frontier and returns None is not necessarily broken — it may be correctly telling you the problem is unsolvable. Where you can settle that question analytically, do it before searching: one gcd call decides every water-jug instance, and one parity check decides every 8-puzzle.


Where to go next