Advent Of Code 2022 - Day 09: Rope Bridge

Dec 09, 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 09: Rope Bridge

Summary: Given a list of moves containing cardinal directions and number of steps, move a rope over a grid. The rope has two knots, marked head and tail.

The head follows the steps given in the input. The tail can never be more than a single tile behind and will move closer to the head. The specific rules for this are in the full problem statement.

The head and tail both start at (0, 0).

After simulating the entire set of moves one step at a time, how many different tiles did the tail touch?

Example input:

U 5
L 2
R 1
D 1

Read the full description here.

At first I solved the wrong problem! The problem statement specifies that the tail moves closer to the head after every single step. Each instruction line can contain more than one step. U 5 for example, is five steps.

This directive did not register in my mind and I implemented each line as a single instruction. Lesson learned. Read closely.

First we parse the instructions and turn them into sets of moves.

let parseLine (line: string) =
    match line.Split() with
    | [|"U"; n|] -> Seq.init (n |> Int32.parse) (fun _ -> (0, 1))
    | [|"D"; n|] -> Seq.init (n |> Int32.parse) (fun _ -> (0, -1))
    | [|"L"; n|] -> Seq.init (n |> Int32.parse) (fun _ -> (-1, 0))
    | [|"R"; n|] -> Seq.init (n |> Int32.parse) (fun _ -> (1, 0))
    | _ -> failwith "Failed to parse line"

Each line turns into a sequence of single-step moves. Each move is represented by a tuple containing (delta-of-x, delta-of-y)

Like most days so far I've started off solving today's problem using an imperative style. We can walk over each individual step using a simple for-loop. Updating the head is simple as it just follows the steps. Each step we also update the tail and then add its new position to a set in order to keep track of the answer.

let mutable head = (0, 0)
let mutable tail = (0, 0)
let mutable visited = Set.empty
for line in input do
    let moves = parseLine line
    for (dx, dy) in moves do
        head <- (fst head + dx, snd head + dy)
        tail <- updateTail head tail
        visited <- Set.add tail visited

updateTail can be solved with maths, but throughout the year I don't use those skills enough to solve this problem quickly, so my initial solution is to just hardcode all the possibilities:

let updateTail (hx, hy) (tx, ty) =
    let dx = hx - tx
    let dy = hy - ty

    match (dx, dy) with
    | (0, 0) | (0, 1) | (0, -1) | (1, 0) | (-1, 0)
    | (1, 1) | (1, -1) | (-1, 1) | (-1, -1) -> (tx, ty)

    | (0, 2) | (1, 2) | (-1, 2) -> (hx, hy - 1)
    | (0, -2) | (1, -2) | (-1, -2) -> (hx, hy + 1)

    | (2, 0) | (2, 1) | (2, -1) -> (hx - 1, hy)
    | (-2, 0) | (-2, 1) | (-2, -1) -> (hx + 1, hy)

    | _ -> failwith $"Failed to update: (({hx}, {hy}) - ({tx}, {ty}) -> {dx}, {dy})"

If the absolute deltas of both x and y are no larger than 1 we don't have to move the tail. If the tail is two tiles below the head then it has to move up to directly below the head and in the same column. If it's two tiles above the head it has to move directly above and in the same column.

Similarly if it's two tiles to the left then it has to move directly to the left and in the same row, and if it's two tles to the right then it has to move directly to the right and in the same row.

Any other set of delta's ought to be impossible in the problem and therefore doesn't have to be covered.

Since we've been keeping track of all positions visited by the tail in our loop, all we have to return is the size of visited. As it turns out, Set does not contain a length function. Set implements Seq, though, so we can still easily get the number of items in visited.

Part 2

Summary: Rather than two knots, our rope has ten knots. We still have to determine how many tiles the tail (tenth knot) has touched.

We can no longer represent our rope as two tuples. Instead we should represent our rope as a list of n tuples.

let rope = List.init n (fun _ -> (0, 0))

Yesterday I discovered the magic of List.scan. scan acts like fold, but rather than returning a single state, it returns all the returned states. Example:

let numbers = [1; 2; 3; 4; 5]
List.scan (fun factorial n -> factorial * n) 1 numbers

The code above returns [1; 1; 2; 6; 24; 120]. Every state encountered. This is useful if we want to transform a list into another list, based on some previous value or state that depends on an earlier part of the list.

In this problem we can use it to calculate the position of every knot. The position of a knot depends on the position of the knot preceding it. We can easily calculate the new position of the head, and then pass that as the first state to scan with the remaining knots as the list to process.

let (h::t) = rope
let nh = (fst h + dx, snd h + dy)
rope <- List.scan updateTail nh t
visited <- Set.add (List.last rope) visited

Finally it turns out that, because knots that are not the head can move diagonally, updateTail now misses a few cases.

let updateTail (hx, hy) (tx, ty) =
    let dx = hx - tx
    let dy = hy - ty

    match (dx, dy) with
    // ... snip ...
    | (2, 2) -> (hx - 1, hy - 1)
    | (-2, 2) -> (hx + 1, hy - 1)
    | (2, -2) -> (hx - 1, hy + 1)
    | (-2, -2) -> (hx + 1, hy + 1)

Improvements

First things first. Like every day I want to get rid of the imperative style. First we create a function for executing a single step. It takes a rope and the move and returns the new rope position. We also get rid of a warning about incomplete pattern matching that we introduced with the line let (h::t) = rope. This warning is fantastic for production quality code, but sometimes unfortunate when competing.

let executeMove rope (dx, dy) =
    match rope with
    | (hx, hy)::tail -> List.scan updateTail (hx + dx, hy + dy) tail
    | _ -> rope

The next step is to get rid of the for-loops. We can get rid of the outer for loop by generating a flat sequence of moves.

input
|> List.map parseLine |> Seq.concat

In order to generate all of the rope positions we can use the magic of scan once again. We can't use map because the next rope position depends on the previous rope position. We could use fold but then we have to keep a relatively complex state of both the rope and the visited set, which is also more difficult to pipe into the next function.

|> Seq.scan executeMove rope

Once we have the sequence of all rope positions we can map that to a sequence of tail positions, convert that sequence to a set and get its size.

|> Seq.map List.last
|> Set.ofSeq |> Seq.length

Applying some maths

As mentioned before we don't have to hardcode a ton of cases in updateTail. We can also apply some maths and some logic. The logic is stated in the problem: The next knot moves to its predecessor if it's not directly connected. A knot is not directly connected to its predecessor if its at least 2 tiles away on either axis.

If it's not at least 2 removed on either axis it doesn't have to move.

If it is, since we move the knots after every movement, it's never further than 2 tiles away on any axis and therefore never has to move more than 1 tile. Let's explore three cases.

.H.    .H.
... -> .T.
.T.    ...

In this case T's x axis does not have to change. On the y axis it has to move up 1.

We'll first introduce a function: sign. sign x returns -1 if x is negative, 0 if x is 0 and 1 if x is positive.

The delta (difference) of x coordinates here is 0, and so the sign is 0. The delta of y coordinates is 2 (as H is higher on the y axis than T). sign 2 is 1, which is what T has to move up.

So for this case we can change the position of T by (sign dx, sign dy).

.H.    .H.
... -> .T.
T..    ...

In this case we need to move T's x axis by 1 and y also by 1. dx is 1, so sign dx is also 1. dy and sign dy remain the same as in the previous example.

For this case we can also change the position of T by (sign dx, sign dy).

..H    ..H
... -> .T.
T..    ...

As in the above case we have to move T by 1 on the x axis and 1 on the y axis. Both deltas are 2 now, and sign reduces them to 1.

For this case we can also change the position of T by (sign dx, sign dy).

These three cases can be mirrored and turned to form all other possibilities where a knot's position has to be modified.

let updateTail (hx, hy) (tx, ty) =
    let dx = hx - tx
    let dy = hy - ty
    if abs dx > 1 || abs dy > 1 then
        (tx + sign dx, ty + sign dy)
    else
        (tx, ty)

Reflection

A fun little problem today which made clear the case for careful reading. In my first attempt at solving the problem I missed the part where we have to break each larger step into steps of 1.

I learned about scan, which feels like a piece of magic I've been missing in my toolset ever since my first attempt at an Advent of Code in OCaml several year ago.

I'm happy that updateTail was small enough that it could be handled with some pattern matching, because my skills are way too rusty for the generalized version.

The problems are definitely getting a bit more difficult compared to the start. That makes for a nice change of pace.

On to the next one!

The full code for the day is on GitHub.