Guest User

Untitled

a guest
Jan 20th, 2019
117
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 10.46 KB | None | 0 0
  1. """Solution to the 2018 infi Christmas puzzle.
  2.  
  3. Usage: python infi.py mazepath
  4. mazepath is the path to a text file containing the maze
  5.  
  6. Prints the solution to part1 followed by the solution to part 2, each on separate lines. There are no checks for
  7. invalid input."""
  8.  
  9. import sys
  10. from collections import defaultdict
  11. import copy
  12.  
  13.  
  14. class Maze(object):
  15. """A maze consisting of shiftable tiles, represented as a graph.
  16.  
  17. Public attributes:
  18. shape: tuple (width, height)
  19.  
  20. nodes: dict of all tiles in the Maze. Key: coordinates of tile as a tuple (x, y). Value: a _MazeNode. It allows
  21. lookup of the _MazeNode at at particular position. Conversely, looking up the position of a particular
  22. _MazeNode (after 1 or more shifts of the Maze) can be done using the pos attribute of _MazeNode.
  23.  
  24. edges: dict of all connections between all tiles in the Maze. Key: coordinates of tile as 2 tuple (x, y).
  25. Value: set of all connected positions as a set of (x, y) tuples.
  26.  
  27. previously_shifted_tiles: set containing each _MazeNode that shifted in the previous shift (see the shift_maze
  28. method for more info).
  29.  
  30. shape, nodes, and edges are not intended to be modified by the user of this class, and neither are the values
  31. inside their respective tuples and dicts."""
  32.  
  33. class _MazeNode(object):
  34. """Data-only class representing a tile/node in a Maze
  35.  
  36. Attributes:
  37. tile: Unicode character representing the kind of tile.
  38. pos: tuple (x, y) representing the position inside the Maze. x runs left-to-right and y runs top-to-bottom
  39.  
  40. A named tuple would not suffice here, since we need to be able to update the position."""
  41.  
  42. def __init__(self, tile, pos):
  43. self.tile = tile
  44. self.pos = pos
  45.  
  46. def __init__(self, maze_txt):
  47. """Init Maze with maze_txt, where maze_txt is a Unicode string describing the maze."""
  48. self.nodes = dict()
  49.  
  50. for y, line in enumerate(maze_txt.splitlines()):
  51. for x, tile in enumerate(line):
  52. pos = (x, y)
  53. self.nodes[pos] = self._MazeNode(tile, pos)
  54.  
  55. height = max([y for x, y in self.nodes]) + 1
  56. width = max([x for x, y in self.nodes]) + 1
  57. self.shape = (width, height)
  58.  
  59. # edges dict. Key: coordinates tuple. Value: Set of coordinate tuples of connected positions.
  60. self.edges = defaultdict(set)
  61. for node_pos in self.nodes:
  62. self._set_edges(node_pos)
  63.  
  64. self._num_shifts = 0
  65. self.previously_shifted_nodes = set()
  66.  
  67. def __repr__(self): # Not required for solving the puzzle, but helps (manual) testing
  68. txt = []
  69.  
  70. for y in range(self.shape[1]):
  71. for x in range(self.shape[0]):
  72. txt.append(self.nodes[(x, y)].tile)
  73. txt.append('\n')
  74.  
  75. return ''.join(txt)
  76.  
  77. def shift_maze(self):
  78. """Shift the tiles in the maze according to the next move in a fixed pattern.
  79.  
  80. The moves in the pattern are as follows:
  81. 1. rotate the first row to the right
  82. 2. rotate the second column downwards
  83. 3. rotate the third row to the right
  84. 4. rotate the fourth column downward
  85. etc. After the final row/column it repeats from the first row/column (whichever's turn it is).
  86.  
  87. Args: No arguments. Returns: None."""
  88. # rotate the correct strip at the correct index, updating nodes and edges
  89. axis, strip_index = self._select_next_rotation()
  90. self._rotate_strip(axis, strip_index)
  91. self._num_shifts += 1
  92.  
  93. # private methods
  94. def _set_edges(self, node_pos):
  95. u"""(Re)set the edges of the node at position node_pos by comparing its tile with surrounding tiles.
  96.  
  97. For example, a tile ╠ will be connected to a tile ╗ to its right, but not to a tile ╬ to its left.
  98.  
  99. Args:
  100. node_pos: tuple (x, y)
  101.  
  102. Returns: None"""
  103. x, y = node_pos
  104. exits = self._exits(self.nodes[node_pos].tile)
  105. self.edges[node_pos] = set()
  106.  
  107. if 'u' in exits and (x, y-1) in self.nodes and 'd' in self._exits(self.nodes[(x, y - 1)].tile):
  108. self.edges[(x, y)].add((x, y - 1))
  109. if 'd' in exits and (x, y+1) in self.nodes and 'u' in self._exits(self.nodes[(x, y + 1)].tile):
  110. self.edges[(x, y)].add((x, y + 1))
  111. if 'r' in exits and (x+1, y) in self.nodes and 'l' in self._exits(self.nodes[(x + 1, y)].tile):
  112. self.edges[(x, y)].add((x + 1, y))
  113. if 'l' in exits and (x-1, y) in self.nodes and 'r' in self._exits(self.nodes[(x - 1, y)].tile):
  114. self.edges[(x, y)].add((x - 1, y))
  115.  
  116. def _select_next_rotation(self):
  117. # axis == 0: rows, axis == 1: columns
  118.  
  119. axis = self._num_shifts % 2
  120. strip_index = self._num_shifts % (self.shape[axis])
  121.  
  122. return axis, strip_index
  123.  
  124. def _rotate_strip(self, axis, strip_index):
  125. self.previously_shifted_nodes = set()
  126.  
  127. # shift all nodes
  128. strip = self._get_1d_strip(axis, strip_index)
  129. positions = [node.pos for node in strip] # This copy is required because positions change during iteration
  130. for idx in range(len(strip)):
  131. pos = positions[idx]
  132. self.nodes[pos] = strip[(idx - 1) % self.shape[axis]]
  133. self.nodes[pos].pos = pos
  134. self.previously_shifted_nodes.add(self.nodes[pos])
  135.  
  136. # Redetermine all edges in the shifted row/column, and in the rows/columns directly adjacent to it. Edges in
  137. # other rows/columns are still valid and don't need to be updated.
  138. for idx in range(max(strip_index - 1, 0), min(strip_index + 2, self.shape[axis])):
  139. self._set_edges_along_strip(axis, idx)
  140.  
  141. def _get_1d_strip(self, axis, strip_index):
  142. strip = []
  143. if axis == 0:
  144. for idx in range(self.shape[axis]):
  145. strip.append(self.nodes[(idx, strip_index)])
  146. elif axis == 1:
  147. for idx in range(self.shape[axis]):
  148. strip.append(self.nodes[(strip_index, idx)])
  149.  
  150. return strip
  151.  
  152. def _set_edges_along_strip(self, axis, strip_index):
  153. for node in self._get_1d_strip(axis, strip_index):
  154. self._set_edges(node.pos)
  155.  
  156. @staticmethod
  157. def _exits(tile):
  158. if tile == '║':
  159. return {'u', 'd'}
  160. if tile == '╔':
  161. return {'r', 'd'}
  162. if tile == '╗':
  163. return {'l', 'd'}
  164. if tile == '╠':
  165. return {'u', 'd', 'r'}
  166. if tile == '╦':
  167. return {'r', 'l', 'd'}
  168. if tile == '╚':
  169. return {'u', 'r'}
  170. if tile == '╝':
  171. return {'u', 'l'}
  172. if tile == '╬':
  173. return {'u', 'd', 'l', 'r'}
  174. if tile == '╩':
  175. return {'r', 'l', 'u'}
  176. if tile == '═':
  177. return {'r', 'l'}
  178. if tile == '╣':
  179. return {'l', 'u', 'd'}
  180.  
  181.  
  182. def shortest_path_bfs(maze, start_pos, end_pos):
  183. """Return the length of the shortest path in maze between start_pos and end_pos using breadth-first search.
  184.  
  185. Arguments:
  186. maze: a Maze object
  187. start_pos: start position of the shortest path as an (x, y) tuple.
  188. end_pos: end position of the shortest path as an (x, y) tuple.
  189.  
  190. Returns: Integer representing the minimum number of steps required to go from start_pos to end_pos"""
  191. if start_pos == end_pos:
  192. return 0
  193.  
  194. visited = set()
  195. reachable = {start_pos}
  196. steps = 0
  197. while True:
  198. steps += 1
  199. prev_reachable = reachable.copy()
  200. reachable = set()
  201.  
  202. for source in prev_reachable:
  203. for destination in maze.edges[source]:
  204. if destination in visited:
  205. continue
  206.  
  207. if destination == end_pos:
  208. return steps
  209.  
  210. reachable.add(destination)
  211.  
  212. visited.update(prev_reachable)
  213.  
  214.  
  215. def shortest_path_shifting_tiles(maze, start_pos, end_pos, shifting_tiles=True):
  216. """Return the length of the shortest path in maze between start_pos and end_pos, allowing the maze to shift.
  217.  
  218. The algorithm is somewhat similar to breadth-first search, with the difference that it does not exclude visited
  219. nodes in the remainder of the search, causing every node to be (potentially) visited multiple times. This is
  220. required because due to the shifting tiles we can no longer rely on the property that the shortest path from A to B
  221. along C starts with the shortest path from A to C, which is the property allowing breadth-first search to exclude
  222. visited nodes. The reason this property no longer holds is that the next time a node is visited, it might have
  223. different edges (or its neighbours may have different edges or its neighbours neighbours, etc.).
  224.  
  225. Arguments:
  226. maze: a Maze object
  227. start_pos: start position of the shortest path as an (x, y) tuple.
  228. end_pos: end position of the shortest path as an (x, y) tuple.
  229. shifting_tiles: Boolean indicating whether the maze shifts its tiles after every step. If False, this function
  230. returns the same result as shortest_path_bfs, but will be less efficient.
  231.  
  232. Returns: Integer representing the minimum number of steps required to go from start_pos to end_pos"""
  233. if start_pos == end_pos:
  234. return 0
  235.  
  236. maze = copy.deepcopy(maze)
  237. reachable = {maze.nodes[start_pos]} # set of all nodes reachable in "steps" steps
  238. steps = 0
  239.  
  240. while True:
  241. steps += 1
  242. prev_reachable = reachable.copy()
  243. reachable = set()
  244. for source in prev_reachable:
  245. for destination_pos in maze.edges[source.pos]:
  246. if destination_pos == end_pos:
  247. return steps
  248.  
  249. reachable.add(maze.nodes[destination_pos])
  250.  
  251. if shifting_tiles: # allows testing: without shifting tiles, result should be identical to bfs.
  252. maze.shift_maze()
  253.  
  254. # Because Santa moves with the tiles, we need to check if any reachable tile shifted to the exit
  255. for node in maze.previously_shifted_nodes:
  256. if node in reachable and node.pos == end_pos:
  257. return steps
  258.  
  259.  
  260. if __name__ == '__main__':
  261. with open(sys.argv[1]) as f:
  262. maze = Maze(f.read())
  263.  
  264. start_pos = (0, 0)
  265. end_pos = (maze.shape[0] - 1, maze.shape[1] - 1)
  266.  
  267. # Part 1 using breadth-first search
  268. print(shortest_path_bfs(maze, start_pos, end_pos))
  269.  
  270. # Part 2 using a similar algorithm that works for shifting mazes
  271. print(shortest_path_shifting_tiles(maze, start_pos, end_pos, shifting_tiles=True))
Add Comment
Please, Sign In to add comment