Skip to content

Commit 2e6be70

Browse files
committed
Fixes problems with algorithm
1) The heuristic distance was applied multiple times 2) The loop didn't exit when solution found
1 parent a1b4a6f commit 2e6be70

File tree

5 files changed

+73
-33
lines changed

5 files changed

+73
-33
lines changed

README.md

+54-22
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,32 @@ This is different because I refactored all the logic in propositional layers. Th
1818

1919
It comes with a `maze_solving_example.py` snippet to test the algorithm in a simple weighted maze ( with random weights ). If you run it you will obtain something like this:
2020

21-
<p align="center">
22-
<img src="./doc/sample.png">
23-
</p>
21+
```
22+
$ python examples/maze_solving_example.py
23+
↓ ↓ ← ← ← ← ← ← ← .
24+
↓ ↓ ← ← ← ↑ ↑ ← ← .
25+
↓ ↓ ← ← ← . . ↑ ← .
26+
↓ ↓ ← ##########. . . ↑ .
27+
→ S ← ##########. . . ↑ .
28+
↑ ↑ ← ##########. . . ↑ .
29+
↑ ↑ ← ##########. . → ↑ .
30+
↑ ↑ ← ##########. . ↑ . .
31+
↑ ↑ ← ##########. . E . .
32+
↑ ↑ ← ##########. . . . .
33+
34+
5 4 5 6 7 8 9 10 11 .
35+
4 3 4 5 10 10 10 11 12 .
36+
3 2 3 4 11 . . 12 13 .
37+
2 1 2 ##########. . . 14 .
38+
1 S 1 ##########. . . 15 .
39+
2 1 2 ##########. . . 16 .
40+
3 2 3 ##########. . 18 17 .
41+
4 3 4 ##########. . 19 . .
42+
5 4 5 ##########. . E . .
43+
6 5 6 ##########. . . . .
44+
45+
[...]
46+
```
2447

2548
The first diagram represents where each point came from. Starting in the E ( standing for ENDING POINT) we backtrack each arrow untill we reach the S ( standing for STARTING POINT). This path is the shortest path. And you know what?
2649

@@ -30,6 +53,8 @@ The arcane forces of A* make that if you start in a visited point and backtrack
3053

3154
The second diagram is the cost to reach each point from the START (S).
3255

56+
A third diagram, not shown here, will also be printed. This diagram shows the estimated total cost from START (S) to END (E) at each point, which is central to the efficiency of the A* algoritm as explained below.
57+
3358
# Can you EXPLAIN the algorithm?
3459

3560
Yeah! The idea of the A* algorithm is that starting from the start point we visit the points that are cheaper to visit. The cost of visiting a neighbor point depends on how costly is to go from the current point to a neighbor. So we check for all the points what is the neighbor that is cheaper to visit and we visit it.
@@ -52,22 +77,25 @@ def a_star_search(graph, start, end):
5277
5378
"""
5479

55-
frontier = DijkstraHeap( Node(cost = 0, point = start, came_from = None) )
80+
frontier = DijkstraHeap( Node(cost_estimate=heuristic(start, end), point=start, came_from=None) )
5681

5782
while frontier:
5883

5984
current_node = frontier.pop()
6085

61-
if not current_node or current_node.point == end:
86+
if current_node is None:
87+
raise ValueError("No path exists")
88+
if current_node.point == end:
6289
return frontier
6390

6491
for neighbor in graph.neighbors( current_node.point ):
6592

66-
new_cost = ( current_node.cost
93+
cost_so_far = current_node.cost_estimate - heuristic(current_node.point, end)
94+
new_cost = ( cost_so_far
6795
+ graph.cost(current_node.point, neighbor)
68-
+ heuristic( neighbor, end) )
96+
+ heuristic(neighbor, end) )
6997

70-
new_node = Node(cost = new_cost, point = neighbor, came_from=current_node.point)
98+
new_node = Node(cost_estimate=new_cost, point=neighbor, came_from=current_node.point)
7199

72100
frontier.insert(new_node)
73101

@@ -76,7 +104,7 @@ def a_star_search(graph, start, end):
76104
Lets go line by line:
77105

78106
```python
79-
frontier = DijkstraHeap( Node(0, start, None) )
107+
frontier = DijkstraHeap( Node(cost_estimate=heuristic(start, end), point=start, came_from=None) )
80108
```
81109

82110
This line creates a DijkstraHeap object and puts the starting point in it. We will see later how this can be implemented but the best part is that....This is not part of the algorithm! What is a DijkstraHeap then? This is a **cost queue** that has the following properties:
@@ -86,25 +114,26 @@ This line creates a DijkstraHeap object and puts the starting point in it. We wi
86114

87115
Cool! So this DijkstraHeap knows the visiting order of the elements. Its **like a heap but never pops an already visited element**.
88116

89-
By the way, a Node object is a tuple of the form ( cost_so_far, point, point_from_we_came ).
117+
By the way, a Node object is a tuple of the form ( total_cost_estimate, point, point_from_we_came ).
90118

91119
```python
92-
while frontier:
120+
while True:
93121
```
94122

95-
We loop while we have elements in the queue.
96-
97-
98-
At this point maybe you are asking yourself why the name `frontier`? Well, this is because when you are at the starting point and you visit neighbors, the queue of the nodes to be visited is like a expanding frontier (imagine a closed curve that becomes bigger and bigger in size). From which sides this frontier will expand first depends on the weights of the nodes among other things (like the distance to the ending point...etc).
123+
We loop until we have found a path, or failed to find one by exhausting all elements in the queue.
99124

100125
```python
101126
current_node = frontier.pop()
102127
```
103128

104129
Each iteration we pop an element from the DijkstraHeap. This element always has the lowest cost element because the DijkstraHeap has this property ( because is a heap and heaps are awesome ).
105130

131+
At this point maybe you are asking yourself why the name `frontier`? Well, this is because when you are at the starting point and you visit neighbors, the queue of the nodes to be visited is like a expanding frontier (imagine a closed curve that becomes bigger and bigger in size). From which sides this frontier will expand first depends on the weights of the nodes among other things (like the distance to the ending point...etc).
132+
106133
```python
107-
if not current_node or current_node.point == end:
134+
if current_node is None:
135+
raise ValueError("No path exists")
136+
if current_node.point == end:
108137
return frontier
109138
```
110139

@@ -117,25 +146,28 @@ for neighbor in graph.neighbors( current_node.point ):
117146
We get each of the current point neighbors
118147

119148
```python
120-
new_cost = ( current_node.cost
121-
+ graph.cost(current_node.point, neighbor)
122-
+ heuristic( neighbor, end) )
149+
cost_so_far = current_node.cost_estimate - heuristic(current_node.point, end)
150+
new_cost = ( cost_so_far
151+
+ graph.cost(current_node.point, neighbor)
152+
+ heuristic(neighbor, end) )
123153

124-
new_node = Node(cost = new_cost, point = neighbor, came_from=current_node.point)
154+
new_node = Node(cost_estimate=new_cost, point=neighbor, came_from=current_node.point)
125155

126156
frontier.insert(new_node)
127157

128158
```
129159

130160
For each neighbor we calculate the new cost of reaching this neighbor from the current point. This cost is formed by three quantities:
131161

132-
1. The current cost of reaching the current point.
162+
1. The cost of reaching the current point, which is the stored cost estimate minus the heuristic distance at that point (explained below).
133163
2. The cost of going from the current point to the neighbor.
134164
3. The distance of the neighbor to the end point that we are looking.
135165

136166
Why this 3rd cost? Because we want to explore first the points that are near the end destination and expend less time in the points that are far from it. So if we artificially give the point a higher cost if the point is far from the destination it will be visited later.
137167

138-
When we have calculated this new cost we insert the point in the cost queue.
168+
The new cost is thus an estimate of the total cost, without knowing what lies ahead. It grows along the path as we encounter obstacles or higher-cost steps. It is essential that the heuristic never overestimates the remaining distance, otherwise the path is not necessarily optimal since the best path may not be visited before we find the end (and terminate).
169+
170+
When we have calculated this new cost estimate we insert the point in the cost queue.
139171

140172
## But what about the MISTERIOUS DijkstraHeap?
141173

a_star/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'Pablo Galindo Salgado'
22

3-
from .a_star import a_star_search,Node,DijkstraHeap
3+
from .a_star import a_star_search,heuristic,Node,DijkstraHeap

a_star/a_star.py

+10-7
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,13 @@ def pop(self):
5959
if self:
6060
next_elem = heapq.heappop(self)
6161
self.visited[next_elem.point] = next_elem.came_from
62-
self.costs[next_elem.point] = next_elem.cost
62+
self.costs[next_elem.point] = next_elem.cost_estimate
6363
return next_elem
6464

6565

6666

6767

68-
Node = collections.namedtuple("Node","cost point came_from")
68+
Node = collections.namedtuple("Node","cost_estimate point came_from")
6969

7070
def a_star_search(graph, start, end):
7171
"""
@@ -82,20 +82,23 @@ def a_star_search(graph, start, end):
8282
8383
"""
8484

85-
frontier = DijkstraHeap( Node(0, start, None) )
85+
frontier = DijkstraHeap( Node(heuristic(start, end), start, None) )
8686

87-
while frontier:
87+
while True:
8888

8989
current_node = frontier.pop()
9090

91-
if not current_node: #or current_node.point == end:
91+
if not current_node:
92+
raise ValueError("No path from start to end")
93+
if current_node.point == end:
9294
return frontier
9395

9496
for neighbor in graph.neighbors( current_node.point ):
9597

96-
new_cost = ( current_node.cost
98+
cost_so_far = current_node.cost_estimate - heuristic(current_node.point, end)
99+
new_cost = ( cost_so_far
97100
+ graph.cost(current_node.point, neighbor)
98-
+ heuristic( neighbor, end) )
101+
+ heuristic(neighbor, end) )
99102

100103
new_node = Node(new_cost, neighbor, current_node.point)
101104

examples/maze_solving_example.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ def from_id_width(point, width):
4747

4848
graph.draw(width=5, point_to = frontier.visited, start=(1, 4), goal=(7, 8))
4949

50-
print()
50+
print("[costs]")
5151

52-
graph.draw(width=5, number = frontier.costs, start=(1, 4), goal=(7, 8))
52+
costs_so_far = { k: v - a_star.heuristic(k, (7, 8)) for k,v in frontier.costs.items() }
53+
graph.draw(width=5, number = costs_so_far, start=(1, 4), goal=(7, 8))
54+
55+
print("[total cost estimates]")
56+
57+
graph.draw(width=5, number = frontier.costs, start=(1, 4), goal=(7, 8)) # cost estimates

tests/test_maze.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_maze_1(self):
2121
maze.walls = walls
2222
weights = {(1,0):20,(3,0) : 2}
2323
maze.weights = weights
24-
my_solution = [(3,0),(3,1),(3,2),(3,3),(2,3),(1,3),(1,2),(0,2)]
24+
my_solution = [(3,0),(3,1),(3,2),(3,3),(2,3),(1,3),(0,3),(0,2)]
2525
end = (3,0)
2626
start = (0,2)
2727

0 commit comments

Comments
 (0)