Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Advent of Code - 2023

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

Day 18: Lavaduct Lagoon

Thanks to your efforts, the machine parts factory is one of the first factories up and running since the lavafall came back. However, to catch up with the large backlog of parts requests, the factory will also need a large supply of lava for a while; the Elves have already started creating a large lagoon nearby for this purpose.

However, they aren't sure the lagoon will be big enough; they've asked you to take a look at the dig plan (your puzzle input). For example:

R 6 (#70c710)
D 5 (#0dc571)
L 2 (#5713f0)
D 2 (#d2c081)
R 2 (#59c680)
D 2 (#411b91)
L 5 (#8ceee2)
U 2 (#caa173)
L 1 (#1b58a2)
U 2 (#caa171)
R 2 (#7807d2)
U 3 (#a77fa3)
L 2 (#015232)
U 2 (#7a21e3)

The digger starts in a 1 meter cube hole in the ground. They then dig the specified number of meters up (U), down (D), left (L), or right (R), clearing full 1 meter cubes as they go. The directions are given as seen from above, so if "up" were north, then "right" would be east, and so on. Each trench is also listed with the color that the edge of the trench should be painted as an RGB hexadecimal color code.

When viewed from above, the above example dig plan would result in the following loop of trench (#) having been dug out from otherwise ground-level terrain (.):

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

At this point, the trench could contain 38 cubic meters of lava. However, this is just the edge of the lagoon; the next step is to dig out the interior so that it is one meter deep as well:

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

Now, the lagoon can contain a much more respectable 62 cubic meters of lava. While the interior is dug out, the edges are also painted according to the color codes in the dig plan.

The Elves are concerned the lagoon won't be large enough; if they follow their dig plan, how many cubic meters of lava could it hold?

All right, we're dealing with loops and areas contained within them again, a throwback to day 10.

Read input

Each line has three parts: first, either a L, R, U or D followed by a space, followed by a number and finally followed by a hex number in parentheses. That means regular expression makes a good solution here. For each line, I match that pattern and return its findings with distance casted to integer.

from utils import read_input
import re

def transformer(line):
    direction, distance, color = re.findall(r'(L|R|U|D) (\d+) \((.*)\)', line)[0]
    return direction, int(distance), color

instructions = read_input(18, transformer)

To dig the original trench, based on the instructions, I keep track of every coordinate we run into in a set.

class Direction:
    LEFT = 'L'
    RIGHT = 'R'
    UP = 'U'
    DOWN = 'D'

class Tile:
    DUG = '#'
    CLEAR = '.'
def dig(start, instructions):
    digged = set([start])

    current = start
    for direction, distance, _ in instructions:
        for _ in range(distance):
            match direction:
                case Direction.LEFT:
                    current -= 1
                case Direction.RIGHT:
                    current += 1
                case Direction.UP:
                    current += 1j
                case Direction.DOWN:
                    current -= 1j
            digged.add(current)
    return digged

After digging the boundaries, I create a grid with dug positions as # and non-dug positions as ..

def create_grid(boundaries):
    grid = {}
    min_x = int(min(coord.real for coord in boundaries))
    max_x = int(max(coord.real for coord in boundaries))
    min_y = int(min(coord.imag for coord in boundaries))
    max_y = int(max(coord.imag for coord in boundaries))

    for y in range(max_y, min_y-1, -1):
        for x in range(min_x, max_x+1):
            pos = x + y * 1j
            if pos in boundaries:
                grid[pos] = Tile.DUG
            else:
                grid[pos] = Tile.CLEAR
    return grid

I had once again trouble figuring out which positions are inside and which are outside the area. To shortcut this a bit, I'm looking at the directions of the first and last instruction.

Since these instructions create a loop, I can use these to determine at least one tile that's inside the area. It works with 75% of the possible combos as I didn't want to figure out how to determine which side is inside if the start position is a flat.

Turns out, with my input, the start was a corner so this worked.

Generally, I hate doing these kind of things that rely on anything being a specific in the input. But today I'm lenient to myself because this stuff is hard, m'kay.

def find_first_interior_position(instructions):
    match (instructions[0][0], instructions[-1][0]):
        case (Direction.RIGHT,Direction.UP):
            return (1-1j)
        case (Direction.RIGHT,Direction.DOWN):
            return (1+1j)
        case (Direction.RIGHT,Direction.RIGHT):
            raise NotImplemented('Right + Right')

        case (Direction.LEFT, Direction.DOWN):
            return -1+1j
        case (Direction.LEFT, Direction.UP):
            return -1-1j
        case (Direction.LEFT, Direction.LEFT):
            raise NotImplemented('Left + Left')

        case (Direction.DOWN, Direction.DOWN):
            raise NotImplemented('Down + Down')
        case (Direction.DOWN, Direction.RIGHT):
            return -1-1j
        case (Direction.DOWN, Direction.LEFT):
            return 1-1j

        case (Direction.UP, Direction.UP):
            raise NotImplemented('Up + Up')
        case (Direction.UP, Direction.RIGHT):
            return 1-1j
        case (Direction.UP, Direction.LEFT):
            return -1-1j

        case _:
            raise ValueError('Unknown combination of directions')

I also wrote a debug function to print my grids before and after filling them to see where issues were.

def debug(grid):
    min_x = int(min(coord.real for coord in boundaries))
    max_x = int(max(coord.real for coord in boundaries))
    min_y = int(min(coord.imag for coord in boundaries))
    max_y = int(max(coord.imag for coord in boundaries))
    for y, row in enumerate(range(max_y, min_y -1, -1)):
        for x, cell in enumerate(range(min_x, max_x+1)):
            print(grid[x + y * -1j], end="")
        print()

To find out which tiles should be dug and which not, I start from a position I know is inside the grid and then go through all the neighbors, skipping those that are part of the boundary or already visited and digging holes in the rest.

def dig_the_rest(start, boundaries, grid):
    new_grid = grid.copy()
    unfilled = set([start])
    visited = set()
    while unfilled:
        start = unfilled.pop()
        neighbors = [start + 1, start - 1, start + 1j, start - 1j]
        for n in neighbors:
            if n in boundaries or n in visited:
                continue
            unfilled.add(n)
        visited.add(start)
        new_grid[start] = Tile.DUG
    return new_grid

To calculate the final result, I dig the boundaries, create the grid, find the first known spot inside the loop and dig the rest. I then use Counter to calculate how many of the tiles in the grid are dug.

from collections import Counter

boundaries = dig(0, instructions)
grid = create_grid(boundaries)
first_interior = find_first_interior_position(instructions)

filled = dig_the_rest(first_interior, boundaries, grid)
part_1 = Counter(filled.values())[Tile.DUG]

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

Solution: 62500

Part 2

The Elves were right to be concerned; the planned lagoon would be much too small.

After a few minutes, someone realizes what happened; someone swapped the color and instruction parameters when producing the dig plan. They don't have time to fix the bug; one of them asks if you can extract the correct instructions from the hexadecimal codes.

Each hexadecimal code is six hexadecimal digits long. The first five hexadecimal digits encode the distance in meters as a five-digit hexadecimal number. The last hexadecimal digit encodes the direction to dig: 0 means R, 1 means D, 2 means L, and 3 means U.

So, in the above example, the hexadecimal codes can be converted into the true instructions:

  • #70c710 = R 461937
  • #0dc571 = D 56407
  • #5713f0 = R 356671
  • #d2c081 = D 863240
  • #59c680 = R 367720
  • #411b91 = D 266681
  • #8ceee2 = L 577262
  • #caa173 = U 829975
  • #1b58a2 = L 112010
  • #caa171 = D 829975
  • #7807d2 = L 491645
  • #a77fa3 = U 686074
  • #015232 = L 5411
  • #7a21e3 = U 500254

Digging out this loop and its interior produces a lagoon that can hold an impressive 952408144115 cubic meters of lava.

Convert the hexadecimal color codes into the correct instructions; if the Elves follow this new dig plan, how many cubic meters of lava could the lagoon hold?

Those are some big numbers out there.

The part that I was able to figure out was how to convert the old instructions to the correct ones.

For each color value in each instruction, I skip the #, convert the next 5 numbers to a decimal with int(num, 16) and convert the last digit to a direction.

def get_correct_instructions(instructions):
    correct_instructions = []
    directions = {
        0: 'R',
        1: 'D',
        2: 'L',
        3: 'U'
    }

    for _, _, hexa in instructions:
        distance = int(hexa[1:-1], 16)
        direction = directions[int(hexa[-1])]
        correct_instructions.append((direction, distance))
    return correct_instructions

After that, my math skills stop. I know from the size of this thing that looping over stuff isn't the way to go or we'll be here waiting for the results when next Christmas rolls over.

I think it could be figurable using ranges but after some tinkering, I wasn't able to confidently get correct results.

Shoelace algorithm

I managed to solve the second part after getting a bit of hint. A friend mentioned the phrase shoelace which enabled me to work it out.

First, a little helper to calculate the next point. This time I'm using regular (x,y) coordinates to make the matrix math easier.

from functools import cache

@cache
def get_next_point(current, instruction):
    direction, distance = instruction
    y, x = current
    match direction:
        case Direction.UP:
            return y - distance, x
        case Direction.RIGHT:
            return y, x + distance
        case Direction.LEFT:
            return y, x - distance
        case Direction.DOWN:
            return y + distance, x

To solve the second part, I first converted the instructions to be the correct ones.

I then created a list of all the corner points of the dig.

For each pair of start -> end lines, I calculate the matrix determinant as instructed in the Wikipedia article. I then round it up to an integer, take the absolute value (since area can't really be negative) and divide by two to get the shoelace area.

I then add half of the boundaries + 1 to compensate for something.

import numpy as np
from itertools import pairwise

p2_instructions = get_correct_instructions(instructions)

current = (0, 0)
points = [current]
boundary_size = sum(distance for direction, distance in p2_instructions)
for instruction in p2_instructions:
    prev = current
    current = get_next_point(current, instruction)
    points.append(current)

area = 0
for start, end in pairwise(points):
    ar = np.array([start, end])
    det = np.linalg.det(ar)
    area += det

shoelace_area = abs(round(area)) / 2
part_2 = int(shoelace_area + boundary_size / 2 + 1)

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

Solution: 122109860712709

One star today Make that two!

One star ain't bad. The best alcholic drink is a 1-star Jallu so I'm always happy with one.

After bit of help, I got the second star as well and am now at 34.