Today’s puzzle was my favourite type of Advent of Code puzzles: pure programming and data structure manipulation and no need to know any specific tricks. It was also quite a long one, took me a good five and a half hours to get through.

Read input

Today’s input is two-parter.

First section is a map:

##########
#..O..O.O#
#......O.#
#.OO..O.O#
#..O@..O.#
#O#..O...#
#O..O..O.#
#.OO.O.OO#
#....O...#
##########

where # is a wall, O is a box, @ is a robot and . is an empty space.

Second section is a sequence of movements:

<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^<<^

The movements are split to multiple lines for readability but they are interpreted as one continuous set of movements.

map_data, movement_data = read_multisection_input(15, [str, str])
grid = create_grid(map_data.split("\n"), predicate=lambda x: x != ".")
 
position = [key for key, value in grid.items() if value == "@"][0]
del grid[position]
 
movements = "".join(movement_data.split("\n"))

We read the sections as strings, then create a sparse grid where empty spaces are ignored. I then find the starting position (@) and delete that from the grid as we don’t need to keep the robot in the field.

Movements we combine into a single string.

Part 1

In the first part, we need to simulate the movements of the robot. Every time a robot is next to a box (O) and is moving towards it, it tries to push it. If there’s empty space behind it (or a line of boxes), each box in the sequence gets moved.

For example:

Move >:
########
#..@OO.#
##..O..#
#...O..#
#.#.O..#
#...O..#
#......#
########

moves both top row boxes one step to right:

########
#...@OO#
##..O..#
#...O..#
#.#.O..#
#...O..#
#......#
########

We start by defining a few helpers:

class Delta(NamedTuple):
    dx: int
    dy: int
 
 
MapData = list[str]
Grid = dict[Coordinate, str]
Direction = Literal["^", ">", "<", "v"]
 
directions = {
    "^": Delta(0, -1),
    ">": Delta(1, 0),
    "v": Delta(0, 1),
    "<": Delta(-1, 0),
}

Delta is a namedtuple that is used to calculate new positions. MapData, Grid and Direction are type aliases to better communicate what types of values are passed in and out of functions.

directions is a map that tells us how we calculate the next location based on the direction.

First, we find the next position and its contents from the current position:

def move(
    position: Coordinate,
    direction: Literal["^", ">", "<", "v"],
    grid: Grid,
) -> Tuple[Coordinate, Grid]:
 
	delta = directions[direction]
	next_position = Coordinate(position.x + delta.dx, position.y + delta.dy)
    next_spot = grid.get(next_position, None)

If we run into an empty space, we move to that new location:

if next_spot is None:
        return next_position, grid

If we run into a wall, we don’t do anything:

elif next_spot == "#":
        return position, grid

The main logic happens when we run into a box. Here we keep track of all the positions that need to move (to_move) and keep calculating those positions for as long as there is another box.

Once we’ve run out of boxes, we check if we hit the wall and if we did, don’t move anything.

If we didn’t hit a wall, we move every item (starting from the last) one position and delete the old location. After that, we update the robot’s position and return both the new position and the grid.

elif next_spot == "O":
	to_move = [next_position]
	next_next_position = Coordinate(
		next_position.x + delta.dx, next_position.y + delta.dy
	)
	next_spot = grid.get(next_next_position, None)
 
	while next_spot == "O":
		to_move.append(next_next_position)
		next_next_position = Coordinate(
			next_next_position.x + delta.dx, next_next_position.y + delta.dy
		)
		next_spot = grid.get(next_next_position, None)
 
	if next_spot == "#":
		return position, grid
 
	for coord in to_move[::-1]:
		n = Coordinate(coord.x + delta.dx, coord.y + delta.dy)
		grid[n] = "O"
		del grid[coord]
 
	position = next_position
 
return position, grid

The desired output is a sum of each box’s GPS (Goods Positioning System) value. It’s calculated as 100 * distance from left edge + distance from top edge. In our coordinate system terms, it’s 100 * x + y.

def part_1():
    map_data, movement_data = read_multisection_input(15, [str, str])
    grid = create_grid(map_data.split("\n"), predicate=lambda x: x != ".")
 
    position = [key for key, value in grid.items() if value == "@"][0]
    del grid[position]
 
    movements = "".join(movement_data.split("\n"))
    for movement in movements:
        position, grid = move(position, movement, grid)
 
    gps = 0
    for coord, value in grid.items():
        if value == "O":
            gps += coord.y * 100 + coord.x
    print(f"Part 1: {gps}")

That was fun!

Part 2

The second part adds a bit of complexity but without increasing the computational complexity in any meaningful way.

To do that, we need to expand the map input, making everything but the robot twice as wide:

  1. # becomes ##
  2. . becomes ..
  3. O becomes []
  4. @ becomes @.

The twist of this part is that boxes now take up two spaces so calculating how to push them up and down changes significantly.

To create this new expanded grid, we do:

def create_expanded_grid(data: MapData) -> Tuple[Grid, Coordinate]:
    """Creates a grid based on the expansion rules:
 
    1. . -> ..
    2. # -> ##
    3. O -> []
    4. @ -> @.
 
    :returns Grid
    """
    grid = {}
    start_position = None
    for y, row in enumerate(data):
        x = 0
        for _, cell in enumerate(row):
            if cell == "#":
                grid[Coordinate(x, y)] = cell
                grid[Coordinate(x + 1, y)] = cell
            elif cell == "O":
                grid[Coordinate(x, y)] = "["
                grid[Coordinate(x + 1, y)] = "]"
            elif cell == "@":
                start_position = Coordinate(x, y)
            x += 2
    return grid, start_position

We move 2 x positions for each cell in the input and expand according to the rules.

The new move function starts as previously:

def move_expanded(
    position: Coordinate,
    direction: Direction,
    grid: Grid,
) -> Tuple[Coordinate, Grid]:
    delta = directions[direction]
    next_position = Coordinate(position.x + delta.dx, position.y + delta.dy)
    next_spot = grid.get(next_position, None)
 
    if next_spot is None:
        return next_position, grid
    elif next_spot == "#":
        return position, grid

If we encounter a wall or an empty spot, we either move to it or stop.

If the spot is part of a box ([ or ]), we need new logic:

elif next_spot in "[]":
	if direction in "<>":
		return push_horizontally(position, direction, grid)
	if direction in "^v":
		return push_vertically(position, direction, grid)

Since going left/right is very different from going up/down, I split those two in separate functions.

Left/right is the easier. Compared to our first part’s solution, the only difference is that now each box is two coordinates wide.

def push_horizontally(position: Coordinate, direction: Direction, grid: Grid):
    delta = directions[direction]
    next_position = Coordinate(position.x + delta.dx, position.y + delta.dy)
    next_spot = grid.get(next_position, None)
    x = next_position.x
    # Go to the end of a line of boxes
    while next_spot in "[]":
        next_spot = grid.get(Coordinate(x + delta.dx, next_position.y), ".")
        x += delta.dx
    # If there's a wall at the end, move nothing
    if next_spot == "#":
        return position, grid
    # If there's an empty spot
    if next_spot == ".":
        # Walk back from the empty spot
        while x != next_position.x:
            # Replace spot with its neighbour
            grid[Coordinate(x, next_position.y)] = grid[
                Coordinate(x - delta.dx, next_position.y)
            ]
            x -= delta.dx
        # Remove the first moved item so we don't duplicate
        del grid[next_position]
        return next_position, grid
 

We continue walking along the x axis until we find something that is not part of a box (either [ or ]). If it’s a wall, we do nothing. If it’s an empty space, we move all the items to that direction one at a time and finally we return robot’s new location and the modified grid.

The real challenge comes in the vertical push. I feel like my solution is overly complex and it does some double work but it helped me build it step-by-step and after it took so long, I didn’t feel like refactoring it today.

def push_vertically(position: Coordinate, direction: Direction, grid: Grid):
    delta = directions[direction]
    # Check if we can push the full stack
    if not can_push_up_or_down(position, direction, grid):
        return position, grid

First we check if the stack can be pushed to the desired direction.

def can_push_up_or_down(position: Coordinate, direction: Direction, grid: Grid) -> bool:
    """Determines recursively if a stack of [] boxes can be moved to given up/down direction"""
    delta = directions[direction]
    above_or_below = grid.get((position.x, position.y + delta.dy))
 
    if above_or_below is None:
        return True
 
    coordinates: list[Coordinate] = []
 
    if above_or_below == "[":
        coordinates.append(Coordinate(position.x, position.y + delta.dy))
        coordinates.append(Coordinate(position.x + 1, position.y + delta.dy))
    elif above_or_below == "]":
        coordinates.append(Coordinate(position.x, position.y + delta.dy))
        coordinates.append(Coordinate(position.x - 1, position.y + delta.dy))
    elif above_or_below == "#":
        return False
 
    return all(
        can_push_up_or_down(coordinate, direction, grid) for coordinate in coordinates
    )

Every time we see part of a box in the next row, we add both of its coordinates into the next row to be checked. If at any point, we run into a wall, the stack cannot be moved. If all paths lead to empty slots, we know we can move the stack.

    # Find coordinates for each item to be pushed
    to_move = get_coordinates_for_vertical_push(direction, grid, set([position])) - set(
        [position]
    )  # Remove starting position as that gets moved separately

To move the stack, we then find all the coordinates for the box pieces that are part of that stack. Here we do a lot of repeated work that likely could be combined with the above:

def get_coordinates_for_vertical_push(
    direction: Direction,
    grid: Grid,
    coordinates: set[Coordinate],
) -> list[Coordinate]:
    delta = directions[direction]
 
    # If we have coordinates and all of the spots
    # above or below them are empty, return all current coordinates
    if coordinates and all(
        grid.get((c.x, c.y + delta.dy)) is None for c in coordinates
    ):
        return coordinates
 
    # Find the next row to check
    next_row = set()
    # For every coordinate in the previous row
    for coord in coordinates:
        above_or_below = grid.get((coord.x, coord.y + delta.dy))
        # If left side of the box, add it and its right neighbour
        if above_or_below == "[":
            next_row.add(Coordinate(coord.x, coord.y + delta.dy))
            next_row.add(Coordinate(coord.x + 1, coord.y + delta.dy))
        # If right side of the box, add it and its left neighbour
        elif above_or_below == "]":
            next_row.add(Coordinate(coord.x, coord.y + delta.dy))
            next_row.add(Coordinate(coord.x - 1, coord.y + delta.dy))
 
    # Recusively go through every position
    return coordinates | get_coordinates_for_vertical_push(direction, grid, next_row)

If all the coordinates above/below the current row are empty, we’ve reached the end of the stack. Otherwise we go through each of those above/below coordinates and if we find more boxes, we add them to the next check.

Then we move all of those box pieces to their correct next spots:

# Sort them by y axis so we're always moving from the end first
reverse = not (direction == "^")
to_move = sorted(to_move, key=lambda x: x.y, reverse=reverse)
 
for coordinate in to_move:
	# Move each item to their next position
	grid[Coordinate(coordinate.x, coordinate.y + delta.dy)] = grid[coordinate]
	# Delete moved item
	del grid[coordinate]

We sort the coordinates by y axis so that I don’t accidentally overwrite some coordinates.

Finally, we return the new position for our robot and the updated grid:

return Coordinate(position.x, position.y + delta.dy), grid

Second part’s wrapper is then almost the same as first one:

def part_2():
    map_data, movement_data = read_multisection_input(15, [str, str])
    movements = "".join(movement_data.split("\n"))
 
    grid, position = create_expanded_grid(map_data.split("\n"))
    for movement in movements:
        position, grid = move_expanded(position, movement, grid)
 
    gps = 0
    for coord, value in grid.items():
        if value == "[":
            gps += coord.y * 100 + coord.x
    print(f"Part 2: {gps}")

I managed to get it to work with the test input but it failed with my full input. (The above code is my working final code.)

Given that there are 20 000 steps and a grid of 99x49, finding the mistake manually was not gonna get me through it and I got quite frustrated. All the corner cases I was able to come up with passed and I had a hard time finding what the issue could be.

Luckily, I’m not the only one solving these puzzles.

Toni offered his code so I can print the grids for each step with my malfunctioning code and his correct code. So I did and saved both to text files:

python day_15.py > incorrect
python tonis_solution.py > correct

I then compared them line by line to find the first occasion where the mistake happened using cmp:

cmp incorrect correct

and it immediately found the mistake. On line 330 735. Good thing I didn’t try to go through them manually:

correct incorret differ: char 32170234, line 330735

I searched the output file for the given line, found the issue and created a reproduction to test:

0 ####################
1 ##................##
2 ##................##
3 ##......[][]......##
4 ##........[]......##
5 ##.......[].......##
6 ##.......@........##
7 ##................##
8 ##................##
9 ####################

It turned out, my original code moved the top left box. I found the issue, fixed the code and got to the right outcome.

Team work makes the dream work!