2024-12-15
[Go to index] [Advent of Code website] [My code on Gitlab]
Day 15: aaaaargh. That was painfully long. But the end result is really fast and, I think, not too stupid. Day 15 is not about clever math, and I don’t think there really is a “trick” to making it work. Anyway, let’s get into it.
We have another map, with a robot and a set of instructions. This time the robot is a box-pusher (so we also have boxes). And the instructions are simply a list of directions. So the example input is:
##########
#..O..O.O#
#......O.#
#.OO..O.O#
#..O@..O.#
#O#..O...#
#O..O..O.#
#.OO.O.OO#
#....O...#
##########
<vv>^<v^>v>^vv^v>v<>v^v<v<^vv<<<^><<><>>v<vvv<>^v^>^<<<><<v<<<v^vv^v>^
vvv<<^>^v^^><<>>><>^<<><^vv^^<>vvv<>><^^v>^>vv<>v<<<<v<^v>^<^^>>>^<v<v
><>vv>v^v^<>><>>>><^^>vv>v<^^^>>v^v^<^^>v^^>v^<^v>v<>>v^v^<v>v^^<^^vv<
<<v<^>>^^^^>>>v^<>vvv^><v<<<>^^^vv^<vvv>^>v<^^^^v<>^>vvvv><>>v^<<^^^^^
^><^><>>><>^^<<^^v>>><^<v>^<vv>>v>>>^v><>^v><<<<v>>v<v<v>vvv>^<><<>^><
^>><>^v<><^vvv<^^<><v<<<<<><^v<<<><<<^^<v<^^^><^>>^<v^><<<^>>^v<v^v<v^
>^>>^v>vv>^<<^v<>><<><<v<<v><>v<^vv<<<>^^v^>^^>>><<^v>>v^v><^^>>^<>vv^
<><^^>^^^<><vvvvv^v<v<<>^v<v>v<<^><<><<><<<^^<<<^<<>><<><^^^>^^<>^>v<>
^^>vv<^v^v<vv>^<><v<^v>^^^>>>^^vvv^>vvv<>>>^<^>>>>>^<<^v>^vvv<>^<><<v>
v^^>>><<^^<>>^v^<v^vv<>v^<<>^<^v^v><^<<<><<^<v><v<>vv>>v><v^<vv<>v^<<^
Where @ is the robot, # are walls,
0 are boxes and . are empty spaces. Then we
have the list of movements in order. We have to determine where all the
boxes will be, with the rules being:
I’m not going to go through all my attempts at making this work (you can check my commits if you’re into that kind of things), so let’s directly explain the version that did work nicely. I went object-oriented for this one, as it seemed like the most natural way of implementing things.
First, some helper code to handle positions and directions:
Position = namedtuple("Position", ["row", "column"])
up = lambda r, c : Position(r-1, c)
down = lambda r, c : Position(r+1, c)
left = lambda r, c : Position(r, c-1)
right = lambda r, c : Position(r, c+1)
movements = {'^': up,
'v': down,
'<': left,
'>': right}
movements_keys = {val: key for key, val in movements.items()}I’ve done similar things in previous rounds: that way we can pass
“direction” as lambda functions to other methods, which
makes the rest of the code a lot more neat as we avoid systematically
having to lookup a string-to-callable dictionary. We only need that
dictionary when parsing the string from the input.
The most important class that we define is the
MoveableItem. It keeps track of the position of an item,
and has two important methods: moveable, which checks if
the item can move in a given direction, and move, which
performs the actual movement. An item is moveable in a
direction if the grid in that direction is empty or if it
contains an object which is also moveable in that direction. To
perform the move, it first has to push any item that is in the way by
calling their move method, then it has to update the grid
to remove itself from the current position, updating its positions, and
adding itself back to the grid.
class MoveableItem:
def __init__(self, grid: dict[Position, Box], row: int, column: int):
self.grid = grid
self.row = row
self.column = column
self.symbol = '?'
@property
def position(self) -> Position:
return Position(self.row, self.column)
def moveable(self, direction: Callable) -> bool:
p = direction(*self.position)
if self.grid.get(p) is None:
return True
return self.grid[p].moveable(direction)
def move(self, direction: Callable) -> bool:
if not self.moveable(direction):
return False
p = direction(*self.position)
if self.grid.get(p) is not None:
self.grid[p].move(direction)
self.grid[self.position] = None
self.row, self.column = p.row, p.column
self.grid[p] = self
return True
def __repr__(self) -> str:
return self.symbol + f"({self.row}, {self.column})"A wall, meanwhile, cannot move, so we implement a very simple
Wall class, which tells anyone who asks that no, they are
not moveable:
class Wall:
def __init__(self):
self.symbol = '#'
def moveable(self, direction: Callable) -> bool:
return FalseA box is a moveable item with the box symbol, and an addition function to compute the “GPS” coordinates which are to be summed to get the puzzle answer.
class Box(MoveableItem):
def __init__(self, grid: dict[Position, Box], row: int, column: int):
super().__init__(grid, row, column)
self.symbol = 'O'
@property
def gps(self) -> int:
return self.row*100 + self.columnA robot is also a moveable item:
class Robot(MoveableItem):
def __init__(self, grid, row: int, column: int):
super().__init__(grid, row, column)
self.symbol = '@'Now we have to initialize the grid and get the list of movements by
parsing the input file. Since walls only need to tell that they are not
moveable, we actually only need one instance of Wall, which
we will point to from each position in the grid where there’s a wall.
Otherwise, each time we find a box or the robot, we instanciate the
relevant class and add a reference to the grid, which will be a
dictionary mapping Position to MoveableItem or
Wall (or None if there’s nothing).
Item = None | MoveableItem | Wall
def normal_grid(grid_data: list[str]) -> tuple[dict[Position, Item], Robot]:
grid = {}
robot = None
w = Wall()
for r, row in enumerate(grid_data):
for c, val in enumerate(row):
if val == '#':
grid[Position(r, c)] = w
elif val == 'O':
b = Box(grid, r, c)
grid[Position(r, c)] = b
elif val == '@':
assert robot is None
robot = Robot(grid, r, c)
grid[Position(r, c)] = robot
return grid, robot
def parse_input() -> tuple[dict[Position, Item], Robot, list[Callable]]:
with open(fname, 'r') as fp:
data = fp.read()
grid, instructions = data.split('\n\n')
grid = grid.split('\n')
grid, robot = parse_grid(grid)
moves = []
for s in instructions.replace('\n', ''):
moves.append(movements[s])
return grid, robot, movesOnce we’ve done all of that, applying the moves becomes extremely easy and satisfying:
def apply_moves(grid: dict[Position, Item],
robot: Robot,
moves: list[Callable]) -> tuple[dict[Position, Item], Robot]:
for d in moves:
robot.move(d)
return grid, robot
grid, robot, moves = parse_input()
grid, robot = apply_moves(grid, robot, moves)
boxes = [b for b in grid.values() if type(b) == Box]
print(sum(set(b.gps for b in boxes)))That’s it: just move the robot, and the interactions between the
classes will take care of the rest. robot.move(d) will
automatically “push” any box in the way.
For part two, we make everything wider. We take the same map, but double the width of the walls and the boxes, with only the robot staying the same. So the example map looks like:
####################
##....[]....[]..[]##
##............[]..##
##..[][]....[]..[]##
##....[]@.....[]..##
##[]##....[]......##
##[]....[]....[]..##
##..[][]..[]..[][]##
##........[]......##
####################
Where [] shows the wide boxes. This has a pretty big
impact on the problem:
##############
##......##..##
##..........##
##...[][]...##
##....[]....##
##.....@....##
##############
Move ^:
##############
##......##..##
##...[][]...##
##....[]....##
##.....@....##
##..........##
##############
The good thing is that, at least with my implementation, it doesn’t
change anything for the walls and the robot. But we need a new class for
the large boxes to implement these new behaviours. I’ve gone through
many possible solutions, but eventually I settled on a
LargeBox class that inherits Box and modifies
it like this:
LargeBox has a main position (the left part) which
is the position from Box, but it also has a
positions property which returns both cells that it
occupies.get_neighbours helper function which uses
different checks depending on the direction. If we go left, we can check
left of the main direction. If we go right, we have to check right of
the second position. If we go up or down, we have to check up or down
from both. We use a set to return the neighbours, otherwise
it may return the same LargeBox twice if it is aligned with
the current one… and that will screw up everything, as I’ve made the
experience…moveable if all neighbours in the
target direction are empty or moveable.LargeBox
instance, one in each of the cells it occupies.class LargeBox(Box):
def __init__(self, grid: dict[Position, Box], row: int, column: int):
super().__init__(grid, row, column)
@property
def positions(self) -> list[Position]:
return [Position(self.row, self.column), Position(self.row, self.column+1)]
def get_neighbours(self, direction: Callable) -> set[None | Box | LargeBox | Wall | Robot]:
if direction == left:
return set([self.grid.get(Position(self.row, self.column-1))])
if direction == right:
return set([self.grid.get(Position(self.row, self.column+2))])
if direction == up:
return set([self.grid.get(Position(self.row-1, self.column)), self.grid.get(Position(self.row-1, self.column+1))])
if direction == down:
return set([self.grid.get(Position(self.row+1, self.column)), self.grid.get(Position(self.row+1, self.column+1))])
def _moveable_to(self, p: Position, direction: Callable) -> bool:
if self.grid.get(p) is None:
return True
return self.grid[p].moveable(direction)
def moveable(self, direction: Callable) -> bool:
neighbours = self.get_neighbours(direction)
for n in neighbours:
if n is not None and not n.moveable(direction):
return False
return True
def move(self, direction: Callable) -> bool:
if not self.moveable(direction):
return False
neighbours = self.get_neighbours(direction)
for n in neighbours:
if n is not None:
n.move(direction)
for p in self.positions:
self.grid[p] = None
self.row, self.column = direction(*self.position)
for p in self.positions:
self.grid[p] = self
return TrueWe also need a new grid parsing function, which is the same as the
old one but we double the column position to get the right “enlarged”
coordinates, and we put two walls or a LargeBox wherever
they are needed.
def wide_grid(map: list[str]) -> tuple[dict[Position, Item], Robot]:
grid = {}
robot = None
w = Wall()
for r, row in enumerate(map):
for c, val in enumerate(row):
if val == '#':
grid[Position(r, c*2)] = w
grid[Position(r, c*2+1)] = w
elif val == 'O':
b = LargeBox(grid, r, c*2)
grid[Position(r, c*2)] = b
grid[Position(r, c*2+1)] = b
elif val == '@':
assert robot is None
robot = Robot(grid, r, c*2)
grid[Position(r, c*2)] = robot
return grid, robotAnd with those modifications in place, we can now move the robot in the updated grid, and it works nicely.