Loading content...
In the previous page, we learned to convert problems into graphs. But what happens when the graph is too large to construct? Consider a Rubik's Cube solver: there are over 43 quintillion (43 × 10¹⁸) possible configurations. You cannot build an adjacency list with 43 quintillion vertices.
Yet we can still solve these problems—by never building the graph at all. Instead, we define the graph implicitly through rules that generate neighbors on demand. This is the power of state space graphs and implicit graph traversal.
By the end of this page, you will understand implicit graphs—graphs defined by rules rather than explicit storage. You'll learn to design state representations, implement neighbor generation functions, and apply graph algorithms to enormous state spaces without running out of memory.
An implicit graph (also called a state space graph) is a graph where:
The key insight is that graph algorithms like BFS and DFS don't actually require the full graph in memory. They only need:
Think of the problem as a universe of possible 'states.' Each state is a vertex. Valid transitions between states are edges. You start at an initial state and search for a goal state. This frame—called state space search—is foundational in AI, game solving, and planning algorithms.
Every implicit graph problem requires you to define three components:
Component 1: State Representation
How do you encode a 'state' (vertex) of the system? This must:
Component 2: Transition Function (Neighbor Generator)
Given a state, what states can you reach in one step? This function:
Component 3: Goal Condition
How do you know when you've found the answer? This is a predicate:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
from collections import dequefrom typing import TypeVar, Callable, Iterable, Set, Optional State = TypeVar('State') def implicit_bfs( start: State, get_neighbors: Callable[[State], Iterable[State]], is_goal: Callable[[State], bool], state_to_hashable: Callable[[State], any] = lambda x: x) -> Optional[int]: """ Generic BFS on an implicit graph. Args: start: The initial state get_neighbors: Function that generates neighboring states is_goal: Function that returns True if state is a goal state_to_hashable: Convert state to hashable form for visited set Returns: Minimum steps to reach a goal state, or None if unreachable """ if is_goal(start): return 0 queue = deque([(start, 0)]) # (state, distance) visited: Set = {state_to_hashable(start)} while queue: current_state, distance = queue.popleft() # Generate neighbors on-demand (implicit graph!) for neighbor_state in get_neighbors(current_state): neighbor_hash = state_to_hashable(neighbor_state) if neighbor_hash not in visited: if is_goal(neighbor_state): return distance + 1 visited.add(neighbor_hash) queue.append((neighbor_state, distance + 1)) return None # Goal not reachableNotice how the BFS template doesn't know anything about the specific problem. It only knows about states, neighbors, and goals. This separation of concerns is powerful—the same template works for puzzles, path finding, game solving, and countless other problems.
The classic 8-puzzle (a 3×3 sliding tile puzzle) is a perfect example of implicit graph search:
The Problem:
Given a 3×3 board with tiles numbered 1-8 and one empty space, slide tiles to reach the goal state. Find the minimum number of moves.
State Space Analysis:
This is small enough that we could build the explicit graph, but it's more instructive to treat it implicitly.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
from collections import dequefrom typing import List, Tuple, Optional def solve_8_puzzle(initial: List[List[int]]) -> Optional[int]: """ Solve the 8-puzzle using BFS on implicit state space graph. State Representation: tuple of tuples (hashable) Transitions: Swap empty space with adjacent tile Goal: [[1,2,3],[4,5,6],[7,8,0]] where 0 is empty Time Complexity: O(V) where V = reachable states (≤ 181,440) Space Complexity: O(V) for visited set and queue """ # Define goal state GOAL = ((1, 2, 3), (4, 5, 6), (7, 8, 0)) # Convert initial board to hashable tuple def to_tuple(board: List[List[int]]) -> Tuple[Tuple[int, ...], ...]: return tuple(tuple(row) for row in board) # Find position of empty space (0) def find_empty(state: Tuple[Tuple[int, ...], ...]) -> Tuple[int, int]: for i in range(3): for j in range(3): if state[i][j] == 0: return (i, j) return (-1, -1) # Should never happen # Generate all valid neighbor states (THE TRANSITION FUNCTION) def get_neighbors(state: Tuple[Tuple[int, ...], ...]): """ Generate neighbor states by moving the empty space. This is the core of the implicit graph definition. """ empty_row, empty_col = find_empty(state) neighbors = [] # Four possible moves: up, down, left, right directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] for dr, dc in directions: new_row, new_col = empty_row + dr, empty_col + dc # Check bounds if 0 <= new_row < 3 and 0 <= new_col < 3: # Create new state with swapped tiles # Convert to list of lists for mutation board = [list(row) for row in state] # Swap empty with adjacent tile board[empty_row][empty_col] = board[new_row][new_col] board[new_row][new_col] = 0 # Convert back to tuple for hashing neighbors.append(tuple(tuple(row) for row in board)) return neighbors # BFS on the implicit graph start = to_tuple(initial) if start == GOAL: return 0 queue = deque([(start, 0)]) visited = {start} while queue: current, moves = queue.popleft() for neighbor in get_neighbors(current): if neighbor not in visited: if neighbor == GOAL: return moves + 1 visited.add(neighbor) queue.append((neighbor, moves + 1)) return None # Unsolvable (wrong parity) # Example: Solve a shuffled puzzleinitial_board = [ [1, 2, 3], [4, 0, 6], [7, 5, 8]]result = solve_8_puzzle(initial_board)print(f"Minimum moves: {result}") # Output: 2 (move 5 up, then 6 left)Key Observations:
get_neighbors functionFor harder puzzles (15-puzzle has 10^13 states), implicit graphs become essential—you cannot possibly store the full graph.
Let's examine another classic implicit graph problem:
The Problem:
A lock has 4 circular wheels, each displaying a digit 0-9. Starting from '0000', you can turn any wheel one slot up or down. Given a list of 'deadends' (forbidden combinations) and a target, find the minimum turns to reach the target.
State Space Analysis:
This is a classic BFS on an implicit graph with forbidden vertices.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
from collections import dequefrom typing import List, Set def open_lock(deadends: List[str], target: str) -> int: """ Find minimum wheel turns to reach target from '0000'. State: 4-character string representing wheel positions Transitions: Turn any wheel up or down by one Forbidden states: deadends Time Complexity: O(10^4 × 8) = O(80,000) worst case Space Complexity: O(10^4) for visited set """ # Convert deadends to set for O(1) lookup forbidden: Set[str] = set(deadends) # Edge case: start or target is a deadend if '0000' in forbidden: return -1 if target == '0000': return 0 def get_neighbors(state: str) -> List[str]: """ Generate all states reachable by one wheel turn. Each wheel can go up or down, wrapping around (9->0, 0->9). """ neighbors = [] state_list = list(state) for wheel in range(4): original_digit = int(state_list[wheel]) # Turn wheel up (0->1, 1->2, ..., 9->0) state_list[wheel] = str((original_digit + 1) % 10) neighbors.append(''.join(state_list)) # Turn wheel down (0->9, 1->0, ..., 9->8) state_list[wheel] = str((original_digit - 1) % 10) neighbors.append(''.join(state_list)) # Restore original digit for next wheel state_list[wheel] = str(original_digit) return neighbors # BFS from '0000' queue = deque([('0000', 0)]) visited: Set[str] = {'0000'} while queue: current, turns = queue.popleft() for neighbor in get_neighbors(current): # Skip forbidden states and already visited if neighbor in forbidden or neighbor in visited: continue # Goal check if neighbor == target: return turns + 1 visited.add(neighbor) queue.append((neighbor, turns + 1)) return -1 # Target unreachable # Exampledeadends = ["0201", "0101", "0102", "1212", "2002"]target = "0202"result = open_lock(deadends, target)print(f"Minimum turns: {result}") # Output: 6For problems like this, bidirectional BFS can dramatically reduce the search space. Instead of searching from start to goal (exploring all states at distance d), you search from both ends simultaneously. When the frontiers meet, you've found the shortest path. This can reduce visited states from O(b^d) to O(2 × b^{d/2}).
A critical challenge with implicit graphs is state space explosion—the state space grows exponentially with problem size:
| Problem | State Space Size | Feasibility |
|---|---|---|
| 8-puzzle (3×3) | 9! = 362,880 | Easily solvable |
| 15-puzzle (4×4) | 16! ≈ 2×10^13 | Needs heuristics (A*) |
| 24-puzzle (5×5) | 25! ≈ 1.5×10^25 | Only with advanced techniques |
| Rubik's Cube | ~4.3×10^19 | Requires pattern databases |
Unguided BFS/DFS cannot handle these large spaces. We need smarter search strategies.
BFS guarantees the shortest path but uses O(b^d) memory where b is branching factor and d is solution depth. For deep solutions, you may run out of memory before finding the answer. Iterative deepening A* (IDA*) trades time for memory—it uses only O(d) memory but may revisit states.
The efficiency of implicit graph search heavily depends on state representation. A well-designed state:
1. Is Minimal
Include only information needed for transitions and goal checking. Extra data wastes memory and slows hashing.
2. Is Efficiently Hashable
States must be stored in a visited set. Prefer immutable types (tuples, frozensets, strings) over mutable ones (lists, dicts).
3. Enables Fast Neighbor Generation
Operations on the state should be efficient. If generating neighbors requires expensive computation, the search slows dramatically.
4. Canonicalizes Equivalent States
If multiple representations encode the same 'real' state, you'll revisit unnecessarily. Use canonical forms.
| Problem | Naive Representation | Optimized Representation | Improvement |
|---|---|---|---|
| Puzzle board | List of lists | Tuple of tuples | Hashable, immutable |
| Set of visited nodes | Set of node objects | Frozen set of node IDs | Memory efficient |
| Path taken | List of steps | Just current position + distance | Constant space state |
| Resource tracking | Separate variables | Single bitmask integer | Fast comparison, less memory |
| Board position | 2D array | 1D tuple (row-major) | Simpler hashing |
123456789101112131415161718192021222324252627282930313233343536
# Example: Representing a set of collected keys in a maze # NAIVE: Using a set (mutable, needs conversion for hashing)class NaiveState: def __init__(self, pos, keys): self.pos = pos self.keys = set(keys) # Mutable set def __hash__(self): return hash((self.pos, frozenset(self.keys))) # Conversion needed def __eq__(self, other): return self.pos == other.pos and self.keys == other.keys # OPTIMIZED: Using a bitmask (immutable integer)class OptimizedState: def __init__(self, pos, keys_bitmask): self.pos = pos self.keys = keys_bitmask # Integer: bit i = 1 if key i collected def __hash__(self): return hash((self.pos, self.keys)) # Direct hash, no conversion def __eq__(self, other): return self.pos == other.pos and self.keys == other.keys def has_key(self, key_id): return (self.keys >> key_id) & 1 == 1 def with_key(self, key_id): return OptimizedState(self.pos, self.keys | (1 << key_id)) # The bitmask version is:# - Faster to hash (single integer vs set conversion)# - Uses less memory (one int vs set object overhead)# - Faster to copy (just copy the int)Implicit graph search isn't just for puzzles—it's a fundamental technique in computer science and engineering:
Much of classical AI is implicit graph search. The field evolved techniques like A*, minimax, and planning algorithms—all operating on implicitly defined state spaces. Understanding implicit graphs gives you a foundation in AI problem-solving.
Implicit graphs unlock the ability to solve problems with enormous—even infinite—state spaces. Let's consolidate the key concepts:
What's Next:
One of the most common implicit graph patterns is the grid as graph—treating 2D grids (mazes, game boards, images) as graphs for traversal. The next page explores this pervasive pattern in depth.
You now understand implicit graphs and state space search. This technique handles problems with state spaces far too large for explicit storage, enabling solutions to puzzles, planning problems, and optimization tasks. Next, we'll explore the specific pattern of modeling grids as graphs.