This is not a traditional maze where you find the shortest path (like the gif on the left). In order to solve it, you need to visit every available node before reaching the end, where once a node is visited it turns into a wall (like the gif on the right).
My current solution works quickly for smaller mazes or ones with a lot of walls, such as this, usually finding a path within a couple seconds. But it takes a lot longer as the size of the maze increases or has more open space, such as this (nearly 5 minutes to find a path). Ideally I would like solve mazes up to a 15x20 size in ~30 seconds.
Here is an overview:
Input the
maze
(2D list ofTile
objects),start
node , andend
node to theMazeSolver
class.A neighboring node is chosen (up, right, down, left).
If that node
is_open()
, then check if itis_safe()
to visit. A node is safe if visiting it will not obstruct our path to any other open node in the maze. This involves an A* search from that node to every other open node, to quickly check if the path exists (every node returned in the path can be skipped for its own search to reduce the number of A* searches).If it
is_safe()
, visit the node and link theirnext
andprev
attributes.If the node is not open or not safe, add it to the
closed
list.If all 4 neighbors are in
closed
, backtrack to the previous node.Repeat 2-6 until
end
is reached, return the path if found.
At this point I am unsure how to improve the algorithm. I am aware of techniques like cython
to speed up the execution time of your code, but my real goal is to add some logic to make the solution smarter and faster. (Although feel free to recommend these techniques as well, I don't imagine multiprocessing could work here?).
I believe adding some logic as to how a neighbor is chosen may be the next step. Currently, a direction is picked from the list MazeSolver.map
, and is used until the neighbor in that direction is not open. Then the next one in the list is chosen, and it just cycles through in order. So there is no intelligent decision making for choosing the neighbor.
Many path finding algorithms assign weights and scores, but how can I tell if one neighbor is more important now than another? The start and end positions can be anywhere in the maze, and you have to visit every node, so the distance to the end node seems insignificant. Or is there a way to predict that a node is not safe without having to do an A* search with each other node? Perhaps separating the maze into smaller chunks and then combining them afterwards would make a difference? All suggestions are welcome, even an entirely new method of solving.
Here is the code.
class Tile:
def __init__(self, row, column):
self.position = (row, column)
self.mode = 1 # 1 = open, 0 = closed (wall)
self.next = self.prev = None
self.closed = []
def __add__(self, other):
return (self.position[0] + other[0], self.position[1] + other[1])
class MazeSolver:
def __init__(self, maze, start, end):
self.maze = maze
self.h, self.w = len(maze) - 1, len(maze[0]) - 1
self.start = maze[start[0]][start[1]]
self.end = maze[end[0]][end[1]]
self.current = self.start
self.map = [(-1, 0), (0, 1), (1, 0), (0, -1)] # Up, right, down, left
def solve(self):
i = 0
while self.current != self.end:
node = self.current + self.map[i]
if self.is_open(node):
if self.is_safe(node):
# Link two nodes like a Linked List
self.current.next = self.maze[node[0]][node[1]]
self.current.next.prev = self.current
self.current.mode -= 1
self.current = self.current.next
continue
else:
self.current.closed.append(node)
else:
i += 1 if i < 3 else -3 # Cycle through indexes in self.map
if len(self.current.closed) == 4:
if self.current == self.start:
# No where to go from starting node, no path exists.
return 0
self.current.closed = []
self.current = self.current.prev
self.current.mode += 1
self.current.closed.append(self.current.next.position)
return self.get_path()
def is_open(self, node):
'''Check if node is open (mode = 1)'''
if node in self.current.closed:
return 0
elif any([node[0]>self.h, node[0]<0, node[1]>self.w, node[1]<0]):
# Node is out of bounds
self.current.closed.append(node)
return 0
elif self.maze[node[0]][node[1]].mode == 0:
self.current.closed.append(node)
return self.maze[node[0]][node[1]].mode
def is_safe(self, node):
'''Check if path is obstructed when node is visitied'''
nodes = [t.position for row in self.maze for t in row if t.mode > 0]
nodes.remove(self.current.position)
# Sorting positions by greatest manhattan distance (which reduces calls to astar)
# decreases solve time for the small maze but increases it for the large maze.
# Thus at some point the cost of sorting outweighs the benefit of fewer A* searches.
# So I have left it commented out:
#nodes.sort(reverse=True, key=lambda x: abs(node[0] - x[0]) + abs(node[1] - x[1]))
board = [[tile.mode for tile in row] for row in self.maze]
board[self.current.position[0]][self.current.position[1]] = 0
checked = []
for goal in nodes:
if goal in checked:
continue
sub_maze = self.astar(board, node, goal)
if not sub_maze:
return False
else:
checked = list(set(checked + sub_maze))
return True
def astar(self, maze, start, end):
'''An implementation of the A* search algorithm'''
start_node = Node(None, start)
end_node = Node(None, end)
open_list = [start_node]
closed_list = []
while len(open_list) > 0:
current_node = open_list[0]
current_index = 0
for index, item in enumerate(open_list):
if item.f < current_node.f:
current_node = item
current_index = index
open_list.pop(current_index)
closed_list.append(current_node)
if current_node == end_node:
path = []
current = current_node
while current is not None:
path.append(current.position)
current = current.parent
return path
children = []
for new_position in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
node_position = (current_node.position[0] + new_position[0], current_node.position[1] + new_position[1])
if node_position[0] > (len(maze) - 1) or node_position[0] < 0 or node_position[1] > (len(maze[0]) -1) or node_position[1] < 0:
continue
if maze[node_position[0]][node_position[1]] == 0:
continue
new_node = Node(current_node, node_position)
children.append(new_node)
for child in children:
if child in closed_list:
continue
child.g = current_node.g + 1
child.h = ((child.position[0] - end_node.position[0])**2) + ((child.position[1] - end_node.position[1])**2)
child.f = child.g + child.h
if child in open_list:
if child.g > open_list[open_list.index(child)].g:
continue
open_list.append(child)
return []
def get_path(self):
path = []
pointer = self.start
while pointer is not None:
path.append(pointer.position)
pointer = pointer.next
return path
class Node:
'''Only used by the MazeSolver.astar() function'''
def __init__(self, parent=None, position=None):
self.parent = parent
self.position = position
self.g = self.h = self.f = 0
def __eq__(self, other):
return self.position == other.position
If you would like to run the mazes from the pictures I linked above (Small, Large):
import time
def small_maze():
maze = [[Tile(r, c) for c in range(11)] for r in range(4)]
maze[1][1].mode = 0
for i in range(11):
if i not in [3, 8]:
maze[3][i].mode = 0
return maze
def large_maze():
maze = [[Tile(r, c) for c in range(15)] for r in range(10)]
for i in range(5, 8):
maze[4][i].mode = 0
maze[5][5].mode = maze[5][7].mode = maze[6][5].mode = 0
return maze
C = MazeSolver(small_maze(), (3, 8), (3, 3)) #(large_maze(), (2, 2), (5, 6))
t = time.time()
path = C.solve()
print(round(time.time() - t, 2), f'seconds\n\n{path}')
# The end node should always have some walls around it to avoid
# the need to check if it was reached prematurely.
Edit:
Thanks to trentcl for pointing out that this problem is a Hamiltonian path. I have rewritten the code to represent the maze as an adjacency matrix, and implemented the Hamiltonian path algorithm based on this tutorial. The performance on the small maze was identical to my original method, but was much slower on the large maze (I interrupted the execution after 15 minutes).
class MazeSolver:
def __init__(self, maze, start):
graph = self.to_graph(maze)
self.n = len(graph)
vertices = list(graph.keys())
self.key_map = {vertices[i]: i for i in range(self.n)}
self.graph = self.to_matrix(graph)
self.start = self.key_map[start]
def to_graph(self, maze):
'''Convert maze to graph with the structure {position: [neighbor positions]}'''
graph = {(r, c): [] for c in range(len(maze[0])) for r in range(len(maze)) if maze[r][c]}
for r, c in graph.keys():
for pos in [(r-1, c), (r+1, c), (r, c-1), (r, c+1)]:
try:
if maze[pos[0]][pos[1]] and pos[0] >= 0 and pos[1] >= 0:
graph[(r, c)].append(pos)
except IndexError:
pass
return graph
def to_matrix(self, graph):
'''Convert graph to adjacency matrix.
matrix[i][j] == 1 indicates there is an edge from i to j'''
edges = [(vertex, neighbor) for vertex in graph for neighbor in graph[vertex]]
edge_numbers = [(self.key_map[edge[0]], self.key_map[edge[1]]) for edge in edges]
matrix = [[0] * self.n for i in range(self.n)]
for edge in edge_numbers:
matrix[edge[0]][edge[1]] = 1
return matrix
def solve(self):
path = [-1] * self.n
path[0] = self.start
if self.hamiltonian_path(path, 1):
index_map = {v: k for k, v in self.key_map.items()}
return [index_map[vertex] for vertex in path]
else:
return 0
def hamiltonian_path(self, path, pos):
if pos == self.n:
return True
for v in range(self.n):
if self.is_safe(v, pos, path):
path[pos] = v
if self.hamiltonian_path(path, pos+1):
return True
path[pos] = -1
return False
def is_safe(self, v, pos, path):
if self.graph[path[pos-1]][v] == 0:
return False
return all(vertex != v for vertex in path)
def small_maze():
maze = [[1] * 11 for i in range(4)]
maze[1][1] = 0
for i in range(11):
if i not in [3, 8]:
maze[3][i] = 0
return maze
def large_maze():
maze = [[1] * 15 for i in range(10)]
for i in range(5, 8):
maze[4][i] = 0
maze[5][5] = maze[5][7] = maze[6][5] = 0
return maze
C = MazeSolver(small_maze(), (3, 8)) #(large_maze(), (2, 2))
t = time.time()
path = C.solve()
print(round(time.time() - t, 2), f'seconds\n\n{path}')
Another tutorial discusses a faster method using dynamic programming, called the Held-Karp algorithm. However, it seems to perform even worse (I had to interrupt the execution for the small maze). I don't know if I implemented it incorrectly because it worked with the tiny maze I used in the gifs.
def check_using_dp(self):
n = self.n
dp = [[False] * (1 << n) for i in range(n)]
for i in range(n):
dp[i][1 << i] = True
for i in range(1 << n):
for j in range(n):
if i & (1 << j):
for k in range(n):
if j != k and (i & (1 << k)) and self.graph[k][j] and dp[k][i ^ (1 << j)]:
dp[j][i] = True
break
for i in range(n):
if dp[i][(1 << n) - 1]:
return True
return False
maze = [[0, 0, 1, 1, 0],
[1, 1, 1, 1, 1],
[1, 0, 0, 0, 0]]
C = MazeSolver(maze, (1, 4))
C.check_using_dp()
I don't understand how it could be faster when it contains a loop of 2**n
iterations (in the case of the small maze this is 17 billion). Can someone explain to me how to implement this algorithm properly?
The most time consuming calculations in my original method are the A* searches. Realizing that an open node only accessible from 1 direction is automatically not safe (except for the end node), I added a check in is_safe()
before any calls to astar()
to return False if any open node has less than two open neighbors. This resulted in a huge improvement in performance, solving the large maze in 14 seconds! Is it possible to achieve this level of performance with Hamiltonian path algorithms?