Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Advent of Code - 2023

This is a solution to Day 14 of Advent of Code 2023.

Day 14: Parabolic Reflector Dish

You reach the place where all of the mirrors were pointing: a massive parabolic reflector dish attached to the side of another large mountain.

The dish is made up of many small mirrors, but while the mirrors themselves are roughly in the shape of a parabolic reflector dish, each individual mirror seems to be pointing in slightly the wrong direction. If the dish is meant to focus light, all it's doing right now is sending it in a vague direction.

This system must be what provides the energy for the lava! If you focus the reflector dish, maybe you can go where it's pointing and use the light to fix the lava production.

Upon closer inspection, the individual mirrors each appear to be connected via an elaborate system of ropes and pulleys to a large metal platform below the dish. The platform is covered in large rocks of various shapes. Depending on their position, the weight of the rocks deforms the platform, and the shape of the platform controls which ropes move and ultimately the focus of the dish.

In short: if you move the rocks, you can focus the dish. The platform even has a control panel on the side that lets you tilt it in one of four directions! The rounded rocks (O) will roll when the platform is tilted, while the cube-shaped rocks (#) will stay in place. You note the positions of all of the empty spaces (.) and rocks (your puzzle input). For example:

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

Start by tilting the lever so all of the rocks will slide north as far as they will go:

OOOO.#.O..
OO..#....#
OO..O##..O
O..#.OO...
........#.
..#....#.#
..O..#.O.O
..O.......
#....###..
#....#....

You notice that the support beams along the north side of the platform are damaged; to ensure the platform doesn't collapse, you should calculate the total load on the north support beams.

The amount of load caused by a single rounded rock (O) is equal to the number of rows from the rock to the south edge of the platform, including the row the rock is on. (Cube-shaped rocks (#) don't contribute to load.) So, the amount of load caused by each rock in each row is as follows:

OOOO.#.O.. 10
OO..#....#  9
OO..O##..O  8
O..#.OO...  7
........#.  6
..#....#.#  5
..O..#.O.O  4
..O.......  3
#....###..  2
#....#....  1

The total load is the sum of the load caused by all of the rounded rocks. In this example, the total load is 136.

Tilt the platform so that the rounded rocks all roll north. Afterward, what is the total load on the north support beams?

Read input

We'll start by reading the input into a list of strings which is the default functionality in my read_input utility funtion.

For part 2, I convert the individual lines to lists as well.

from utils import read_input

p1_rocks = read_input(14)
p2_rocks = read_input(14, list)

Part 1

I wanted to try a solution where I don't need to change anything from the input: no shifting values around in lists, no changing coordinates.

One thing I almost always do at the beginning is to model the available different values into constants so I don't end up using magic numbers and to improve readability in general.

To calculate the load after one tilt north, my aptly named function transposes the entire rock formation so that each column becomes a string. This is to make use of str.rfind method.

I go through each column rock by rock. For each round rock (O), I find the closest round or cube rock to the left from its position. If neither is found (.rfind() returns -1 if it doesn't find one), the rock is moved all the to the top. If one is found, I calculate the distance it needs to move as its starting position minus the position where there is something minus 1 (since we want it on the next index) plus any movement that has happened since the last cube was faced.

The last addition, that I track with previously_moved is there because I don't actually move the rocks anywhere. So if one needed to move 2 spots to the top, it's still in the input data at its original spot and need to be taken into account.

Finally, the load of a given rock is its new position subtracted from the total length as the smallest indices bear the highest load.

class Rock:
    ROUND = 'O'
    CUBE = '#'
    CLEAR = '.'

def calculate_load_after_tilt_north(rocks):
    load = 0
    rotated_rocks = [''.join(row) for row in zip(*rocks)]
    for column in rotated_rocks:
        previously_moved = 0
        for index, rock in enumerate(column):
            match rock:
                case Rock.CUBE:
                    previously_moved = 0
                case Rock.ROUND:
                    closest_cube = column[:index].rfind(Rock.ROUND)
                    closest_round = column[:index].rfind(Rock.CUBE)
                    closest_stop = max(closest_cube, closest_round)
                    if closest_stop < 0:
                        step = index
                    else:
                        step = index - closest_stop - 1 + previously_moved
                    previously_moved = step
                    load += len(rotated_rocks) - (index - step)
                case _:
                    continue
    return load
part_1 = calculate_load_after_tilt_north(p1_rocks)
print(f'Solution: {part_1}')
assert part_1 == 105249

Solution: 105249

Part 2

The parabolic reflector dish deforms, but not in a way that focuses the beam. To do that, you'll need to move the rocks to the edges of the platform. Fortunately, a button on the side of the control panel labeled "spin cycle" attempts to do just that!

Each cycle tilts the platform four times so that the rounded rocks roll north, then west, then south, then east. After each tilt, the rounded rocks roll as far as they can before the platform tilts in the next direction. After one cycle, the platform will have finished rolling the rounded rocks in those four directions in that order.

Here's what happens in the example above after each of the first few cycles:

After 1 cycle:
.....#....
....#...O#
...OO##...
.OO#......
.....OOO#.
.O#...O#.#
....O#....
......OOOO
#...O###..
#..OO#....

After 2 cycles:
.....#....
....#...O#
.....##...
..O#......
.....OOO#.
.O#...O#.#
....O#...O
.......OOO
#..OO###..
#.OOO#...O

After 3 cycles:
.....#....
....#...O#
.....##...
..O#......
.....OOO#.
.O#...O#.#
....O#...O
.......OOO
#...O###.O
#.OOO#...O

This process should work if you leave it running long enough, but you're still worried about the north support beams. To make sure they'll survive for a while, you need to calculate the total load on the north support beams after 1000000000 cycles.

In the above example, after 1000000000 cycles, the total load on the north support beams is 64.

Run the spin cycle for 1000000000 cycles. Afterward, what is the total load on the north support beams?

I tried to be clever in the first part and avoid moving things around because I was expecting some gargantuan number in the second part. I was right that the numbers grew a ton but I have initially no clue if my solution in first part would be helpful at all.

My first instinct is that I need to figure out a way to calculate a rock's position after N cycles without actually going through the motions of calculating all the positions in each cycle.

To do this, we need to actually tilt the platform and move rocks around. The part 1 would have been way easier and cleaner if I wasn't trying to be a clever boy.

Tilting the platform

To tilt the platform, I position the columns in a way that they read left-to-right. It means that when tilting north and south, I transpose the grid and for south and east, I reverse the columns.

I then create a new rock grid and each column keep track of all the empty spaces that we can move to. If we run into a clear spot, we increase that count. If we run into a cube, we plot all the accumulated empty spots into the new grid and place a cube rock and reset the empty space counter. Finally, if we run into a round, we add it to the first open spot in the new grid.

Finally, we reverse the transformations we did in the start and return the new rock formation.

def tilt(rocks, direction):
    match direction:
        case 'north':
            rocks = list(zip(*rocks))
            column_direction = 1
        case 'south':
            rocks = list(zip(*rocks))
            column_direction = -1
        case 'east':
            column_direction = -1
        case 'west':
            column_direction = 1

    new_rocks = []
    for column in rocks:
        new_column = []
        empty_to_left = 0
        for rock in column[::column_direction]:
            match rock:
                case Rock.CLEAR:
                    empty_to_left += 1
                case Rock.CUBE:
                    new_column.extend([Rock.CLEAR] * empty_to_left + [rock])
                    empty_to_left = 0
                case Rock.ROUND:
                    new_column.append(rock)
        new_column.extend([Rock.CLEAR] * empty_to_left)
        new_rocks.append(new_column[::column_direction])

    if direction in {'north', 'south'}:
        return list(zip(*new_rocks))
    else:
        return new_rocks

For a complete spint, we go counter clockwise, tilting once north, then west, then south and finally east. At the end of a full spin, we return the rocks. The biggest mistake I originally made here was going clockwise because I made assumptions.

def spin(rocks):
    rocks = tilt(rocks, 'north')
    rocks = tilt(rocks, 'west')
    rocks = tilt(rocks, 'south')
    rocks = tilt(rocks, 'east')
    return rocks

To make sure my tilts were correct, I built a debug printer that printed the grid in a similar fashion than it was in the puzzle page for easier comparison.

def debug(rocks):
    for row in rocks:
        print(''.join(row))
    print()

To make it easy to compare a rock formation, I have a serialize function that turns the list of lists into a string.

def serialize(rocks):
    return ''.join([''.join(row) for row in rocks])

To find out the final position after 1,000,000,000 spins, I find the moment when the input is something we've seen before as with the deterministic tilts and spins, we know it will start to loop at that point.

So how does this cycle thing work

Let's take a simpler example to look at:

[1, 3, 7, 5, 4, 7, 5, 4, 7, 5, 4, 7, 5]

This is a collection of inputs that when entered through a function, generates the next value. So we'd start with 1, run func(1) -> 3, func(3) -> 7, and so on.

If we look at it from the bird's-eye view, we notice that once we get value 7, we end up in a loop of 7 -> 5 -> 4 that repeats until the end.

To find a cycle, we need to keep track of the inputs and their locations.

{ 1: 0 } # After first
{ 1: 0, 3: 1 } # After second
{ 1: 0, 3: 1, 7:2 }
{ 1: 0, 3: 1, 7:2, 5:3 }
{ 1: 0, 3: 1, 7:2, 5:3, 4:4 }

Now, when we encounter 7 again, we can see that it's already in our collection. We can get the start index of the loop with collection[7] which is 2. Our current iteration is 5 so the cycle size is 5-2 = 3.

Since not all things end cleanly matching the end of a cycle, we need to find out how much of the last broken cycle we need to execute.

      cycle    next             final
      start    cycle            cycle
       v        v                 v
[1, 3, 7, 5, 4, 7, 5, 4, 7, 5, 4, 7, 5]

We take the entire length of the cycle (in this case, 13), subtract how many we did before the cycle started (in our case cycle_start = 2 and modulo with the cycle size (3). (13 - 2) % 3 = 2.

So all in all, instead of running 13 iterations, we run 2 iterations at the start, then 3 as part of the loop and finally 2 at the end, total of 7.

def spin_1000000000(rocks):
    cycles = 1000000000
    seen_formations = {}
    cycle_count = 1
    while True:
        rocks = spin(rocks)
        if serialize(rocks) in seen_formations:
            cycle_start = seen_formations[serialize(rocks)]
            cycle_size = cycle_count - cycle_start
            to_spin = (cycles - cycle_count) % cycle_size
            for _ in range(to_spin):
                rocks = spin(rocks)
            return rocks
        seen_formations[serialize(rocks)] = cycle_count
        cycle_count += 1

To calculate the load, I go through each position in the rock formation and for each round rock, calculate the value by subtracting the current row index from the total length.

def calculate_load(rocks):
    load = 0
    for y, row in enumerate(rocks):
        for x, rock in enumerate(row):
            if rock == Rock.ROUND:
                load += len(rocks) - y
    return load
rocks = spin_1000000000(p2_rocks)

part_2 = calculate_load(rocks)
print(f'Solution: {part_2}')
assert part_2 == 88680

Solution: 88680

Part 1 revisited

With the new code written for part 2, part 1 becomes much cleaner.

rocks = tilt(p1_rocks, 'north')
assert calculate_load(rocks) == 105249

Two stars!

28/50. I'm very happy I haven't had to skip a single day yet. According to my previous years, around day 17 I've started to run into issues but let's see how this year pans out.