|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +""" |
| 4 | +Pure Python implementation of the A* (A-star) pathfinding algorithm. |
| 5 | +
|
| 6 | +For doctests run: |
| 7 | + python3 -m doctest -v astar.py |
| 8 | +
|
| 9 | +For manual testing run: |
| 10 | + python3 astar.py |
| 11 | +""" |
| 12 | + |
| 13 | +import heapq |
| 14 | +from typing import Callable, Iterable, Tuple, List, Dict, Optional, Set |
| 15 | + |
| 16 | +# Type aliases for readability |
| 17 | +Node = Tuple[int, int] |
| 18 | +NeighborsFn = Callable[[Node], Iterable[Tuple[Node, float]]] |
| 19 | +HeuristicFn = Callable[[Node, Node], float] |
| 20 | + |
| 21 | + |
| 22 | +def astar( |
| 23 | + start: Node, goal: Node, neighbors: NeighborsFn, h: HeuristicFn |
| 24 | +) -> Optional[List[Node]]: |
| 25 | + """ |
| 26 | + A* algorithm for pathfinding on a graph defined by a neighbor function. |
| 27 | +
|
| 28 | + A* maintains: |
| 29 | + -> g[n]: cost from start to node n (best known so far) |
| 30 | + -> f[n] = g[n] + h(n, goal): estimated total cost of a path through n to goal |
| 31 | + -> open_list: min-heap of candidate nodes prioritized by smallest f-score |
| 32 | + -> closed_list: set of nodes already expanded (best path to them fixed) |
| 33 | +
|
| 34 | + :param start: starting node |
| 35 | + :param goal: target node |
| 36 | + :param neighbors: function returning (neighbor, step_cost) pairs for a node |
| 37 | + :param h: admissible heuristic h(n, goal) estimating remaining cost |
| 38 | + :return: list of nodes from start to goal (inclusive), or None if no path |
| 39 | +
|
| 40 | + Examples: |
| 41 | + >>> def _h(a, b): # Manhattan distance |
| 42 | + ... (x1, y1), (x2, y2) = a, b |
| 43 | + ... return abs(x1 - x2) + abs(y1 - y2) |
| 44 | + >>> def _nbrs(p): # 4-connected grid, unit costs, unbounded |
| 45 | + ... x, y = p |
| 46 | + ... return [((x + 1, y), 1), ((x - 1, y), 1), ((x, y + 1), 1), ((x, y - 1), 1)] |
| 47 | + >>> astar((0, 0), (2, 2), _nbrs, _h)[-1] |
| 48 | + (2, 2) |
| 49 | + """ |
| 50 | + # Min-heap of (f_score, node). We only store (priority, node) to keep it simple; |
| 51 | + # if your nodes aren't directly comparable, add a tiebreaker counter to the tuple. |
| 52 | + open_list: List[Tuple[float, Node]] = [] |
| 53 | + |
| 54 | + # Nodes we've fully explored (their best path is finalized). |
| 55 | + closed_list: Set[Node] = set() |
| 56 | + |
| 57 | + # g-scores: best known cost to reach each node from start |
| 58 | + g: Dict[Node, float] = {start: 0.0} |
| 59 | + |
| 60 | + # Parent map to reconstruct the path once we reach the goal |
| 61 | + parent: Dict[Node, Optional[Node]] = {start: None} |
| 62 | + |
| 63 | + # Initialize the frontier with the start node (f = h(start, goal)) |
| 64 | + heapq.heappush(open_list, (h(start, goal), start)) |
| 65 | + |
| 66 | + while open_list: |
| 67 | + # Pop the node with the smallest f-score (best promising path so far) |
| 68 | + _, current = heapq.heappop(open_list) |
| 69 | + |
| 70 | + # If we've already expanded this node via a better path, skip it |
| 71 | + if current in closed_list: |
| 72 | + continue |
| 73 | + closed_list.add(current) |
| 74 | + |
| 75 | + # Goal check: reconstruct the path by following parents backward |
| 76 | + if current == goal: |
| 77 | + path: List[Node] = [] |
| 78 | + while current is not None: |
| 79 | + path.append(current) |
| 80 | + current = parent[current] |
| 81 | + return path[::-1] # reverse to (start ... goal) |
| 82 | + |
| 83 | + # Explore current's neighbors |
| 84 | + for neighbor, cost in neighbors(current): |
| 85 | + # If neighbor was already finalized, ignore |
| 86 | + if neighbor in closed_list: |
| 87 | + continue |
| 88 | + |
| 89 | + # Tentative g-score via current |
| 90 | + tentative_g = g[current] + cost |
| 91 | + |
| 92 | + # If this is the first time we see neighbor, or we found a cheaper path to it |
| 93 | + if neighbor not in g or tentative_g < g[neighbor]: |
| 94 | + g[neighbor] = tentative_g |
| 95 | + parent[neighbor] = current |
| 96 | + f_score = tentative_g + h(neighbor, goal) |
| 97 | + heapq.heappush(open_list, (f_score, neighbor)) |
| 98 | + |
| 99 | + # If the frontier empties without reaching the goal, no path exists |
| 100 | + return None |
| 101 | + |
| 102 | + |
| 103 | +def heuristic(n: Node, goal: Node) -> float: |
| 104 | + """ |
| 105 | + Manhattan (L1) distance heuristic for 4-connected grid movement with unit costs. |
| 106 | + Admissible and consistent for axis-aligned moves. |
| 107 | +
|
| 108 | + :param n: current node |
| 109 | + :param goal: target node |
| 110 | + :return: |x1 - x2| + |y1 - y2| |
| 111 | + """ |
| 112 | + x1, y1 = n |
| 113 | + x2, y2 = goal |
| 114 | + return abs(x1 - x2) + abs(y1 - y2) |
| 115 | + |
| 116 | + |
| 117 | +def neighbors(node: Node) -> Iterable[Tuple[Node, float]]: |
| 118 | + """ |
| 119 | + 4-neighborhood on an unbounded grid with unit edge costs. |
| 120 | +
|
| 121 | + Replace/extend this for: |
| 122 | + -> bounded grids (check bounds before yielding) |
| 123 | + -> obstacles (skip blocked cells) |
| 124 | + -> diagonal moves (add the 4 diagonals with cost sqrt(2) and switch heuristic) |
| 125 | +
|
| 126 | + :param node: (x, y) coordinate |
| 127 | + :return: iterable of ((nx, ny), step_cost) |
| 128 | + """ |
| 129 | + x, y = node |
| 130 | + return [ |
| 131 | + ((x + 1, y), 1), |
| 132 | + ((x - 1, y), 1), |
| 133 | + ((x, y + 1), 1), |
| 134 | + ((x, y - 1), 1), |
| 135 | + ] |
| 136 | + |
| 137 | + |
| 138 | +if __name__ == "__main__": |
| 139 | + # Example usage / manual test |
| 140 | + start: Node = (0, 0) |
| 141 | + goal: Node = (5, 5) |
| 142 | + path = astar(start, goal, neighbors, heuristic) |
| 143 | + print("Path found:", path) |
| 144 | + # Expected (one optimal path; yours may differ but length should be 10 moves + start): |
| 145 | + # Path found: [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), |
| 146 | + # (1, 5), (2, 5), (3, 5), (4, 5), (5, 5)] |
0 commit comments