Skip to content

Commit ac17665

Browse files
committed
Add BiDirectional AStar
I switched to using asserts rather than exceptions for things that should be impossible, since exceptions should at least be partly caused by the parameters * docs/Pathfinding.rst - Add BiDirectionalAStar. Functions identically to onedirectional astar, as the differences are solely in performance characteristics * pygorithm/pathfinding/astar.py - Minor documentation tweaks for consistency and improve performance of reversing the list for one directonal astar (avoid shifting elements so much). Fix referencing the loop variable 'i' instead of 'found' and remove related TODO Add BiDirectionalAStar. I think there is some room to reduce the length * tests/test_pathing.py - Add the basic test (passing)
1 parent eafc4f0 commit ac17665

File tree

3 files changed

+304
-10
lines changed

3 files changed

+304
-10
lines changed

docs/Pathfinding.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Features
4141
* Algorithms available:
4242
- Dijkstra (dijkstra)
4343
- Unidirectional AStar (astar)
44+
- BiDirectional AStar (astar)
4445

4546

4647
* To see all the available functions in a module there is a `modules()` function available. For example,
@@ -83,6 +84,19 @@ Unidirectional AStar
8384

8485
.. function:: astar.OneDirectionalAStar.find_path(pygorithm.data_structures.WeightedUndirectedGraph, vertex, vertex, function)
8586

87+
- **pygorithm.data_structures.WeightedUndirectedGraph** : acts like an object with `graph` and `get_edge_weight` (see WeightedUndirectedGraph)
88+
- **vertex** : any hashable type for the start of the path
89+
- **vertex** : any hashable type for the end of the path
90+
- **function** : `function(graph, vertex, vertex)` returns numeric - a heuristic function for distance between two vertices
91+
- **Return Value** : returns a `List` of vertexes (of the same type of the graph) starting from from and going to to. This algorithm respects weights, but is only guarranteed to be optimal if the heuristic is admissable. An admissable function will never *overestimate* the cost from one node to another (in other words, it is optimistic).
92+
93+
BiDirectional AStar
94+
-------------------
95+
96+
* Functions and their uses
97+
98+
.. function:: astar.BiDirectionalAStar.find_path(pygorithm.data_structures.WeightedUndirectedGraph, vertex, vertex, function)
99+
86100
- **pygorithm.data_structures.WeightedUndirectedGraph** : acts like an object with `graph` and `get_edge_weight` (see WeightedUndirectedGraph)
87101
- **vertex** : any hashable type for the start of the path
88102
- **vertex** : any hashable type for the end of the path

pygorithm/pathfinding/astar.py

Lines changed: 279 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
import heapq
1515
import inspect
1616

17+
from enum import Enum
18+
1719

1820
class OneDirectionalAStar(object):
19-
"""AStar object
20-
Finds the optimal path between two nodes on
21-
a graph while taking into account weights.
21+
"""OneDirectionalAStar object
22+
Finds the optimal path between two nodes on a graph while taking
23+
into account weights. Expands the start node first until it finds
24+
the end node.
2225
"""
2326

2427
# Some miscellaneous notes:
@@ -70,8 +73,9 @@ def reverse_path(node):
7073
"""
7174
result = []
7275
while node is not None:
73-
result.insert(0, node['vertex'])
76+
result.append(node['vertex'])
7477
node = node['parent']
78+
result.reverse()
7579
return result
7680

7781
def find_path(self, graph, start, end, heuristic_fn):
@@ -191,14 +195,11 @@ def find_path(self, graph, start, end, heuristic_fn):
191195
if _open[i][2] == neighbor:
192196
found = i
193197
break
194-
if found is None:
195-
raise Exception('A vertex is in the _open lookup but not in _open. '
196-
'This is impossible, please submit an issue + include the graph!')
198+
assert(found is not None)
197199
# TODO: I'm not certain about the performance characteristics of doing this with heapq, nor if
198200
# TODO: it would be better to delete heapify and push or rather than replace
199201

200-
# TODO: Local variable 'i' could be referenced before assignment
201-
_open[i] = (pred_total_dist_through_neighbor_to_end, counter, neighbor)
202+
_open[found] = (pred_total_dist_through_neighbor_to_end, counter, neighbor)
202203
counter += 1
203204
heapq.heapify(_open)
204205
_open_lookup[neighbor] = {'vertex': neighbor,
@@ -226,3 +227,272 @@ def get_code():
226227
returns the code for the current class
227228
"""
228229
return inspect.getsource(OneDirectionalAStar)
230+
231+
class BiDirectionalAStar(object):
232+
"""BiDirectionalAStar object
233+
Finds the optimal path between two nodes on a graph while taking
234+
account weights. Expands from the start node and the end node
235+
simultaneously
236+
"""
237+
238+
class NodeSource(Enum):
239+
"""NodeSource enum
240+
Used to distinguish how a node was located
241+
"""
242+
243+
BY_START = 1,
244+
BY_END = 2
245+
246+
def __init__(self):
247+
pass
248+
249+
@staticmethod
250+
def reverse_path(node_from_start, node_from_end):
251+
"""
252+
Reconstructs the path formed by walking from
253+
node_from_start backward to start and combining
254+
it with the path formed by walking from
255+
node_from_end to end. Both the start and end are
256+
detected where 'parent' is None.
257+
:param node_from_start: dict containing { 'vertex': any hashable, 'parent': dict or None }
258+
:param node_from_end: dict containing { 'vertex' any hashable, 'parent': dict or None }
259+
:return: list of vertices starting at the start and ending at the end
260+
"""
261+
list_from_start = []
262+
current = node_from_start
263+
while current is not None:
264+
list_from_start.append(current['vertex'])
265+
current = current['parent']
266+
list_from_start.reverse()
267+
268+
list_from_end = []
269+
current = node_from_end
270+
while current is not None:
271+
list_from_end.append(current['vertex'])
272+
current = current['parent']
273+
274+
return list_from_start + list_from_end
275+
276+
def find_path(self, graph, start, end, heuristic_fn):
277+
"""
278+
Calculates the optimal path from the start to the end. The
279+
search occurs from both the start and end at the same rate,
280+
which makes this algorithm have more consistent performance
281+
if you regularly are trying to find paths where the destination
282+
is unreachable and in a small room.
283+
284+
The heuristic requirements are the same as in unidirectional A*
285+
(it must be admissable).
286+
287+
:param graph: the graph with 'graph' and 'get_edge_weight' (see WeightedUndirectedGraph)
288+
:param start: the start vertex (must be hashable and same type as the graph)
289+
:param end: the end vertex (must be hashable and same type as the graph)
290+
:param heuristic_fn: an admissable heuristic. signature: function(graph, start, end) returns numeric
291+
:return: a list of vertices starting at start ending at end or None
292+
"""
293+
294+
# This algorithm is really just repeating unidirectional A* twice,
295+
# but unfortunately it's just different enough that it requires
296+
# even more work to try to make a single function that can be called
297+
# twice.
298+
299+
300+
# Note: The nodes in by_start will have heuristic distance to the end,
301+
# whereas the nodes in by_end will have heuristic distance to the start.
302+
# This means that the total predicted distance for the exact same node
303+
# might not match depending on which side we found it from. However,
304+
# it won't make a difference since as soon as we evaluate the same node
305+
# on both sides we've finished.
306+
#
307+
# This also means that we can use the same lookup table for both.
308+
309+
open_by_start = []
310+
open_by_end = []
311+
open_lookup = {}
312+
313+
closed = set()
314+
315+
# used to avoid hashing the dict.
316+
counter_arr = [0]
317+
318+
total_heur_distance = heuristic_fn(graph, start, end)
319+
heapq.heappush(open_by_start, (total_heur_distance, counter_arr[0], start))
320+
counter_arr[0] += 1
321+
open_lookup[start] = { 'vertex': start,
322+
'parent': None,
323+
'source': self.NodeSource.BY_START,
324+
'dist_start_to_here': 0,
325+
'pred_dist_here_to_end': total_heur_distance,
326+
'pred_total_dist': total_heur_distance }
327+
328+
heapq.heappush(open_by_end, (total_heur_distance, counter_arr, end))
329+
counter_arr[0] += 1
330+
open_lookup[end] = { 'vertex': end,
331+
'parent': None,
332+
'source': self.NodeSource.BY_END,
333+
'dist_end_to_here': 0,
334+
'pred_dist_here_to_start': total_heur_distance,
335+
'pred_total_dist': total_heur_distance }
336+
337+
# If the start runs out then the start is in a closed room,
338+
# if the end runs out then the end is in a closed room,
339+
# either way there is no path from start to end.
340+
while len(open_by_start) > 0 and len(open_by_end) > 0:
341+
result = self._evaluate_from_start(graph, start, end, heuristic_fn, open_by_start, open_by_end, open_lookup, closed, counter_arr)
342+
if result is not None:
343+
return result
344+
345+
result = self._evaluate_from_end(graph, start, end, heuristic_fn, open_by_start, open_by_end, open_lookup, closed, counter_arr)
346+
if result is not None:
347+
return result
348+
349+
return None
350+
351+
def _evaluate_from_start(self, graph, start, end, heuristic_fn, open_by_start, open_by_end, open_lookup, closed, counter_arr):
352+
"""
353+
Intended for internal use only. Expands one node from the open_by_start list.
354+
355+
:param graph: the graph (see WeightedUndirectedGraph)
356+
:param start: the start node
357+
:param end: the end node
358+
:heuristic_fn: the heuristic function (signature function(graph, start, end) returns numeric)
359+
:open_by_start: the open vertices from the start
360+
:open_by_end: the open vertices from the end
361+
:open_lookup: dictionary of vertices -> dicts
362+
:closed: the already expanded vertices (set)
363+
:counter_arr: arr of one integer (counter)
364+
"""
365+
current = heapq.heappop(open_by_start)
366+
current_vertex = current[2]
367+
current_dict = open_lookup[current_vertex]
368+
del open_lookup[current_vertex]
369+
closed.update(current_vertex)
370+
371+
neighbors = graph.graph[current_vertex]
372+
for neighbor in neighbors:
373+
if neighbor in closed:
374+
continue
375+
376+
neighbor_dict = open_lookup.get(neighbor, None)
377+
if neighbor_dict is not None and neighbor_dict['source'] is self.NodeSource.BY_END:
378+
return self.reverse_path(current_dict, neighbor_dict)
379+
380+
dist_to_neighb_through_curr_from_start = current_dict['dist_start_to_here'] \
381+
+ graph.get_edge_weight(current_vertex, neighbor)
382+
383+
if neighbor_dict is not None:
384+
assert(neighbor_dict['source'] is self.NodeSource.BY_START)
385+
386+
if neighbor_dict['dist_start_to_here'] <= dist_to_neighb_through_curr_from_start:
387+
continue
388+
389+
pred_dist_neighbor_to_end = neighbor_dict['pred_dist_here_to_end']
390+
pred_total_dist_through_neighbor = dist_to_neighb_through_curr_from_start + pred_dist_neighbor_to_end
391+
open_lookup[neighbor] = { 'vertex': neighbor,
392+
'parent': current_dict,
393+
'source': self.NodeSource.BY_START,
394+
'dist_start_to_here': dist_to_neighb_through_curr_from_start,
395+
'pred_dist_here_to_end': pred_dist_neighbor_to_end,
396+
'pred_total_dist': pred_total_dist_through_neighbor }
397+
398+
# TODO: I'm pretty sure theres a faster way to do this
399+
found = None
400+
for i in range(0, len(open_by_start)):
401+
if open_by_start[i][2] == neighbor:
402+
found = i
403+
break
404+
assert(found is not None)
405+
406+
open_by_start[found] = (pred_total_dist_through_neighbor, counter_arr[0], neighbor)
407+
counter_arr[0] += 1
408+
heapq.heapify(open_by_start)
409+
continue
410+
411+
pred_dist_neighbor_to_end = heuristic_fn(graph, neighbor, end)
412+
pred_total_dist_through_neighbor = dist_to_neighb_through_curr_from_start + pred_dist_neighbor_to_end
413+
open_lookup[neighbor] = { 'vertex': neighbor,
414+
'parent': current_dict,
415+
'source': self.NodeSource.BY_START,
416+
'dist_start_to_here': dist_to_neighb_through_curr_from_start,
417+
'pred_dist_here_to_end': pred_dist_neighbor_to_end,
418+
'pred_total_dist': pred_total_dist_through_neighbor }
419+
heapq.heappush(open_by_start, (pred_total_dist_through_neighbor, counter_arr[0], neighbor))
420+
counter_arr[0] += 1
421+
422+
def _evaluate_from_end(self, graph, start, end, heuristic_fn, open_by_start, open_by_end, open_lookup, closed, counter_arr):
423+
"""
424+
Intended for internal use only. Expands one node from the open_by_end list.
425+
426+
:param graph: the graph (see WeightedUndirectedGraph)
427+
:param start: the start node
428+
:param end: the end node
429+
:heuristic_fn: the heuristic function (signature function(graph, start, end) returns numeric)
430+
:open_by_start: the open vertices from the start
431+
:open_by_end: the open vertices from the end
432+
:open_lookup: dictionary of vertices -> dicts
433+
:closed: the already expanded vertices (set)
434+
:counter_arr: arr of one integer (counter)
435+
"""
436+
current = heapq.heappop(open_by_end)
437+
current_vertex = current[2]
438+
current_dict = open_lookup[current_vertex]
439+
del open_lookup[current_vertex]
440+
closed.update(current_vertex)
441+
442+
neighbors = graph.graph[current_vertex]
443+
for neighbor in neighbors:
444+
if neighbor in closed:
445+
continue
446+
447+
neighbor_dict = open_lookup.get(neighbor, None)
448+
if neighbor_dict is not None and neighbor_dict['source'] is self.NodeSource.BY_START:
449+
return self.reverse_path(neighbor_dict, current_dict)
450+
451+
dist_to_neighb_through_curr_from_end = current_dict['dist_end_to_here'] \
452+
+ graph.get_edge_weight(current_vertex, neighbor)
453+
454+
if neighbor_dict is not None:
455+
assert(neighbor_dict['source'] is self.NodeSource.BY_END)
456+
457+
if neighbor_dict['dist_end_to_here'] <= dist_to_neighb_through_curr_from_end:
458+
continue
459+
460+
pred_dist_neighbor_to_start = neighbor_dict['pred_dist_here_to_start']
461+
pred_total_dist_through_neighbor = dist_to_neighb_through_curr_from_end + pred_dist_neighbor_to_start
462+
open_lookup[neighbor] = { 'vertex': neighbor,
463+
'parent': current_dict,
464+
'source': self.NodeSource.BY_END,
465+
'dist_end_to_here': dist_to_neighb_through_curr_from_end,
466+
'pred_dist_here_to_start': pred_dist_neighbor_to_start,
467+
'pred_total_dist': pred_total_dist_through_neighbor }
468+
469+
# TODO: I'm pretty sure theres a faster way to do this
470+
found = None
471+
for i in range(0, len(open_by_end)):
472+
if open_by_end[i][2] == neighbor:
473+
found = i
474+
break
475+
assert(found is not None)
476+
477+
open_by_end[found] = (pred_total_dist_through_neighbor, counter_arr[0], neighbor)
478+
counter_arr[0] += 1
479+
heapq.heapify(open_by_end)
480+
continue
481+
482+
pred_dist_neighbor_to_start = heuristic_fn(graph, neighbor, start)
483+
pred_total_dist_through_neighbor = dist_to_neighb_through_curr_from_end + pred_dist_neighbor_to_start
484+
open_lookup[neighbor] = { 'vertex': neighbor,
485+
'parent': current_dict,
486+
'source': self.NodeSource.BY_END,
487+
'dist_end_to_here': dist_to_neighb_through_curr_from_end,
488+
'pred_dist_here_to_start': pred_dist_neighbor_to_start,
489+
'pred_total_dist': pred_total_dist_through_neighbor }
490+
heapq.heappush(open_by_end, (pred_total_dist_through_neighbor, counter_arr[0], neighbor))
491+
counter_arr[0] += 1
492+
493+
@staticmethod
494+
def get_code():
495+
"""
496+
returns the code for the current class
497+
"""
498+
return inspect.getsource(BiDirectionalAStar)

tests/test_pathing.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,14 @@ def my_heuristic(graph, v1, v2):
5858
return math.sqrt(dx * dx + dy * dy)
5959

6060
return my_pathfinder.find_path(my_graph, v1, v2, my_heuristic)
61-
61+
62+
class TestAStarBiDirectionalTimed(SimplePathfindingTestCaseTimed):
63+
def find_path(self, my_graph, v1, v2):
64+
my_pathfinder = astar.BiDirectionalAStar()
65+
66+
def my_heuristic(graph, v1, v2):
67+
dx = v2[0] - v1[0]
68+
dy = v2[1] - v1[1]
69+
return math.sqrt(dx * dx + dy * dy)
70+
71+
return my_pathfinder.find_path(my_graph, v1, v2, my_heuristic)

0 commit comments

Comments
 (0)