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:
= namedtuple("Position", ["row", "column"])
Position
= lambda r, c : Position(r-1, c)
up = lambda r, c : Position(r+1, c)
down = lambda r, c : Position(r, c-1)
left = lambda r, c : Position(r, c+1)
right
= {'^': up,
movements 'v': down,
'<': left,
'>': right}
= {val: key for key, val in movements.items()} movements_keys
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:
= direction(*self.position)
p 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
= direction(*self.position)
p 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 False
A 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.column
A 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).
= None | MoveableItem | Wall
Item
def normal_grid(grid_data: list[str]) -> tuple[dict[Position, Item], Robot]:
= {}
grid = None
robot = Wall()
w for r, row in enumerate(grid_data):
for c, val in enumerate(row):
if val == '#':
= w
grid[Position(r, c)] elif val == 'O':
= Box(grid, r, c)
b = b
grid[Position(r, c)] elif val == '@':
assert robot is None
= Robot(grid, r, c)
robot = robot
grid[Position(r, c)] return grid, robot
def parse_input() -> tuple[dict[Position, Item], Robot, list[Callable]]:
with open(fname, 'r') as fp:
= fp.read()
data
= data.split('\n\n')
grid, instructions = grid.split('\n')
grid = parse_grid(grid)
grid, robot
= []
moves for s in instructions.replace('\n', ''):
moves.append(movements[s])return grid, robot, moves
Once we’ve done all of that, applying the moves becomes extremely easy and satisfying:
def apply_moves(grid: dict[Position, Item],
robot: Robot,list[Callable]) -> tuple[dict[Position, Item], Robot]:
moves: for d in moves:
robot.move(d)
return grid, robot
= parse_input()
grid, robot, moves = apply_moves(grid, robot, moves)
grid, robot = [b for b in grid.values() if type(b) == Box]
boxes 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:
= self.get_neighbours(direction)
neighbours 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
= self.get_neighbours(direction)
neighbours
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 True
We 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 = None
robot = Wall()
w for r, row in enumerate(map):
for c, val in enumerate(row):
if val == '#':
*2)] = w
grid[Position(r, c*2+1)] = w
grid[Position(r, celif val == 'O':
= LargeBox(grid, r, c*2)
b *2)] = b
grid[Position(r, c*2+1)] = b
grid[Position(r, celif val == '@':
assert robot is None
= Robot(grid, r, c*2)
robot *2)] = robot
grid[Position(r, creturn grid, robot
And with those modifications in place, we can now move the robot in the updated grid, and it works nicely.