Skip to content

Commit 0b57cf3

Browse files
Update
1 parent e2a78d4 commit 0b57cf3

File tree

1 file changed

+146
-0
lines changed

1 file changed

+146
-0
lines changed

searches/a_search.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)