Advent Of Code 2022 - Day 18: Boiling Boulders

Dec 18, 2022

It's December. Time for snow, slippery roads, hot chocolate and cozy fire places. Also time for Advent of Code. An advent calendar with small, daily programming puzzles, growing progressively more difficult.

Every year I participate in a programming language I did not use for Advent of Code before, in order to learn new ways of doing things and to challenge myself. This year, that language is F#.

Day 18: Boiling Boulders

Summary: Given a list of locations of 1x1x1 cubes on a 3D grid, how many sides are exposed?

Example input:

2,2,2
1,2,2
3,2,2
2,1,2
2,3,2
2,2,1
2,2,3
2,2,4
2,2,6
1,2,5
3,2,5
2,1,5
2,3,5

Read the full problem statement here.

After the details and trickiness of the past few days, this is a breath of fresh air. Two cubes are adjacent if one coordinate differs by at most one from another. If two cubes are adjacent that means that for both of them, one side is not exposed. Cubes have six sides. So for the example input, if taken only the first two cubes, 10 sides are exposed since they are adjacent.

let isConnected (x1, y1, z1) (x2, y2, z2) =
    (abs (x1 - x2)) + (abs (y1 - y2)) + (abs (z1 - z2)) = 1

We'll create a helper to generate all unique pairs in a single list. For each element in the list, loop over the remaining elements and return their combination as a pair. Then recurse on the remaining elements.

let rec pairs list = seq {
    match list with
    | x::xs -> for e in xs do yield (x, e)
               yield! pairs xs
    | _ -> ()
}

To determine how many sides are exposed, we count all pairs of connected cubes using our countWhere helper. We multiply that number by 2 because when two cubes are adjacent, a side is blocked on both of them. Then we subtract that number from the total number of sides, which is the amount of cubes times 6.

let solve1 (input: string list) =
    let blockedSides =
        input
        |> List.map parseLine
        |> List.pairs
        |> Seq.countWhere (fun (p1, p2) -> isConnected p1 p2)

    List.length input * 6 - blockedSides * 2

Part 2

Summary: The small 1x1x1 cubes form a bigger shape (a lava droplet) with some empty space within. How many sides are exposed on the outside of the shape.

Read the full problem statement here (only if you solved part 1).

To figure out how many sides are exposed on the outside of the shape we can use a flood fill algorithm. What that does is look at all nodes in a graph that are reachable from a starting point.

If we draw an imaginary larger cube around our shape then we can flood fill that box and every time a 1x1x1 cube blocks our passage we've seen a side that is exposed on the outside.

We'll start by putting all the points of the lava droplet in a Set. We'll offset them by 1 on each axis so that our surrounding cube can start at (0, 0, 0).

let lava =
    input |> List.map parseLine
    |> List.map (fun (x, y, z) -> PackedPoint.make (x + 1) (y + 1) (z + 1))
    |> Set.ofSeq

To determine the neighbours of any point we'll just hardcode a list with adjacent points. To determine which ones are in our cube we could look at our set and determine the max coordinates, but we can also take a sneak peak at our input and see that it's smaller than 25x25x25. Then we just hardcode that.

let neighbours (x, y, z) = [
    (x + 1, y, z); (x - 1, y, z);
    (x, y + 1, z); (x, y - 1, z);
    (x, y, z + 1); (x, y, z - 1);
]

let neighboursInRange point =
    point |> neighbours
    |> List.filter (fun (x, y, z) ->
        0 <= x && x < 25 && 0 < y && y < 25 && 0 < z && z < 25)

With that we can write our flood fill. When we look at a cell, we check it's neighbours. Cells that are not part of the lava droplet and that we haven't flooded yet are air cells. Each part of the lava droplet that's a neighbour of the current point, is a side on the surface of that droplet and so we need to count it.

Then we fold over the air cubes, recursively calling our loop in order to check the rest of our box. Any cells we look at are added to seen so that we don't look at them twice.

let floodFill start =
    let seen = HashSet.empty |> HashSet.add start
    let rec loop at cellsSeen lavaSeenCount =
        let neighbours = neighboursInRange at
        let air = neighbours |> List.reject (fun pt -> HashSet.contains pt lava)
        let lavaCount = List.length neighbours - List.length air

        let (lavaSeenAtNeighbours, cellsSeenAtNeighbours) =
            List.fold (fun (lavaSeenCount, cellsSeen) nextCell ->
                if HashSet.contains nextCell cellsSeen then (lavaSeenCount, cellsSeen)
                else loop nextCell <| HashSet.add nextCell cellsSeen <| lavaSeenCount) (lavaSeenCount + lavaCount, cellsSeen) air

        (lavaSeenAtNeighbours, cellsSeenAtNeighbours)
    loop start seen 0

We then call it using (0, 0, 0) as the starting point to find the solution.

floodFill (0, 0, 0) |> fst

Improvements

I've noticed that in many posts the improvements are pretty much the same. I hack together a solution using for-loops and mutation and then refactor the mutation away, changing the for-loop into a fold or a scan.

In most cases I can also extract the solution to part 1, make one or two things slightly configurable and pass those in both parts.

I'll leave these kinds of improvements out of this section for now and just immediately describe them as they've ended up after refactoring. If I learn something new then it will still end up in this section.

Reflection

After the last two days, today's problem was a breath of fresh air. A nice and easy problem, simple to code up and far fewer details to keep in my head than the two days before. Not too bad for a sunday.

The full code for the day is on GitHub.