Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Advent of Code - 2023

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

Day 11: Cosmic Expansion

You continue following signs for "Hot Springs" and eventually come across an observatory. The Elf within turns out to be a researcher studying cosmic expansion using the giant telescope here.

He doesn't know anything about the missing machine parts; he's only visiting for this research project. However, he confirms that the hot springs are the next-closest area likely to have people; he'll even take you straight there once he's done with today's observation analysis.

Maybe you can help him with the analysis to speed things up?

The researcher has collected a bunch of data and compiled the data into a single giant image (your puzzle input). The image includes empty space (.) and galaxies (#). For example:

...#......
.......#..
#.........
..........
......#...
.#........
.........#
..........
.......#..
#...#.....

The researcher is trying to figure out the sum of the lengths of the shortest path between every pair of galaxies. However, there's a catch: the universe expanded in the time it took the light from those galaxies to reach the observatory.

Due to something involving gravitational effects, only some space expands. In fact, the result is that any rows or columns that contain no galaxies should all actually be twice as big.

In the above example, three columns and two rows contain no galaxies:

  v  v  v
...#......
.......#..
#.........
>..........<
......#...
.#........
.........#
>..........<
.......#..
#...#.....
  ^  ^  ^

These rows and columns need to be twice as big; the result of cosmic expansion therefore looks like this:

....#........
.........#...
#............
.............
.............
........#....
.#...........
............#
.............
.............
.........#...
#....#.......

Equipped with this expanded universe, the shortest path between every pair of galaxies can be found. It can help to assign every galaxy a unique number:

....1........
.........2...
3............
.............
.............
........4....
.5...........
............6
.............
.............
.........7...
8....9.......

In these 9 galaxies, there are 36 pairs. Only count each pair once; order within the pair doesn't matter. For each pair, find any shortest path between the two galaxies using only steps that move up, down, left, or right exactly one . or # at a time. (The shortest path between two galaxies is allowed to pass through another galaxy.)

For example, here is one of the shortest paths between galaxies 5 and 9:

....1........
.........2...
3............
.............
.............
........4....
.5...........
.##.........6
..##.........
...##........
....##...7...
8....9.......

This path has length 9 because it takes a minimum of nine steps to get from galaxy 5 to galaxy 9 (the eight locations marked # plus the step onto galaxy 9 itself). Here are some other example shortest path lengths:

  • Between galaxy 1 and galaxy 7: 15
  • Between galaxy 3 and galaxy 6: 17
  • Between galaxy 8 and galaxy 9: 5

In this example, after expanding the universe, the sum of the shortest path between all 36 pairs of galaxies is 374.

Expand the universe, then find the length of the shortest path between every pair of galaxies. What is the sum of these lengths?

Coordinates in space! I get to continue with the beloved Complex Number Coordinate System with dictionaries and show the true power of them in this puzzle.

In hindsight, this might have been the easiest one for me so far this year, mainly because the System carries all the challenging parts on its shoulders like it's nobody's business. After yesterday, this feels amazing.

Read input

To create the galaxy, I read all the input cells and if the cell is a #, I calculate its position in Complex Number Coordinate System with the formula of x + y * -1j and use that as a key in our dictionary.

You may already see why this is very efficient in this puzzle. I don't care about the empty space: no memory is allocated to that so it can expand as much as it wants.

from utils import read_input

GALAXY = '#'

raw_space = read_input(11)

space = {}
for y, row in enumerate(raw_space):
    for x, cell in enumerate(row):
        if cell == GALAXY:
            space[x + y * -1j] = GALAXY

To expand the space, I loop through each row and each column and check if they have any galaxies. If they don't , I mark that as an expandable row or column.

To do the actual expansion then, I go through each galaxy, see how many expandable rows and columns are before it and update its position with an addition of old coordinate and how much we need to shift. Then I mark a new galaxy there on a new space dictionary.

def expand_space(space):
    min_y = int(min(coord.imag for coord in space))
    max_x = int(max(coord.real for coord in space))

    should_expand_rows = []
    for y in range(0, min_y, -1):
        has_galaxies = any(cell for cell in space if int(cell.imag) == y)
        if not has_galaxies:
            should_expand_rows.append(y)

    should_expand_columns = []
    for x in range(max_x):
        has_galaxies = any(cell for cell in space if int(cell.real) == x)
        if not has_galaxies:
            should_expand_columns.append(x)

    new_space = {}
    for galaxy in space:
        shift_right = len([column for column in should_expand_columns if galaxy.real > column])
        shift_down = len([row for row in should_expand_rows if galaxy.imag < row])
        new_space[galaxy + (shift_right + shift_down * -1j)] = GALAXY

    return new_space

Calculating the shortest distance uses Manhattan distance, also known as taxicab geometry. Since we are in a grid where you can only move to orthogonal spaces, the distance between two coordinates is the sum of the absolute values of each coordinate subtracted from each other: |x1 - x2| + |y1 - y2| and in my system, real part is x and imag part is y. I need to convert it to int at the end because real and imag return floats.

def calculate_distance(one, another):
    return int(abs(one.real - another.real) + abs(one.imag - another.imag))

To calculate the sum of all shortest distances, I use itertools.combinations to create all unordered, non-repeated 2-item pairs from the space (when using a dictionary like this, it returns the keys).

from itertools import combinations

new_space = expand_space(space)

part_1 = 0
for one, another in combinations(new_space, 2):
    distance = calculate_distance(one, another)
    part_1 += distance

print(f'Solution: {part_1}')
assert part_1 == 9724940

Solution: 9724940

Part 2

The galaxies are much older (and thus much farther apart) than the researcher initially estimated.

Now, instead of the expansion you did before, make each empty row or column one million times larger. That is, each empty row should be replaced with 1000000 empty rows, and each empty column should be replaced with 1000000 empty columns.

(In the example above, if each empty row or column were merely 10 times larger, the sum of the shortest paths between every pair of galaxies would be 1030. If each empty row or column were merely 100 times larger, the sum of the shortest paths between every pair of galaxies would be 8410. However, your universe will need to expand far beyond these values.)

Starting with the same initial image, expand the universe according to these new rules, then find the length of the shortest path between every pair of galaxies. What is the sum of these lengths?

I see what you tried to do there trick master. But your tricks have no power here as I don't store any empty space.

The only change I had to make to expand_space to make it expand_space_more was to accept a multiplier argument and use that to multiply how much we shift right or down. We subtract 1 from the multiplier because one space already exists.

def expand_space_more(space, multiplier):
    min_y = int(min(coord.imag for coord in space))
    max_x = int(max(coord.real for coord in space))

    should_expand_rows = []
    for y in range(0, min_y, -1):
        has_galaxies = any(cell for cell in space if int(cell.imag) == y)
        if not has_galaxies:
            should_expand_rows.append(y)

    should_expand_columns = []
    for x in range(max_x):
        has_galaxies = any(cell for cell in space if int(cell.real) == x)
        if not has_galaxies:
            should_expand_columns.append(x)

    new_space = {}
    for galaxy in space:
        shift_right = len([column for column in should_expand_columns if galaxy.real > column]) * (multiplier - 1)
        shift_down = len([row for row in should_expand_rows if galaxy.imag < row]) * (multiplier -1)
        new_space[galaxy + (shift_right + shift_down * -1j)] = GALAXY

    return new_space

The actual solution then is exactly the same as the first part, just with a "bigger space" which in our case, means the keys in our dictionary are larger numbers and nothing else. There's effectively zero performance penalty from growing the multiplier, as long as the resulting numbers fit the space complex numbers can handle.

For once, my loops are not dependent on the problem space size.

from itertools import combinations

bigger_space = expand_space_more(space, 1_000_000)

part_2 = 0
for one, another in combinations(bigger_space, 2):
    distance = calculate_distance(one, another)
    part_2 += distance

print(f'Solution: {part_2}')
assert part_2 == 569052586852

Solution: 569052586852

Helper printer function

To make debugging easier, I created a print_space function but did not end up needing it since the puzzle was a one hit wonder.

def print_space(space):
    for y in range(0, min(int(c.imag) for c in space) - 1, -1):
        for x in range(max(int(c.real) for c in space) + 1):
            if (x + y*1j) in space:
                print(GALAXY, end="")
            else:
                print('.', end="")
        print()

Two stars

Loved today's puzzle. Especially when the second part was revealed and I noticed my system handles it with no problems. We are now at 22/50 stars. It's beginning to look a lot like Christmas 🎶