Advent Of Code 2022 - Day 11: Monkey in the Middle

Dec 11, 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 11: Monkey in the Middle

Summary: Monkeys are playing Keep Away with your stuff! There's a pattern in how they are throwing your items based on how worried you are about the item.

Example input:

Monkey 0:
  Starting items: 79, 98
  Operation: new = old * 19
  Test: divisible by 23
    If true: throw to monkey 2
    If false: throw to monkey 3

Monkey 1:
  Starting items: 54, 65, 75, 74
  Operation: new = old + 6
  Test: divisible by 19
    If true: throw to monkey 2
    If false: throw to monkey 0

Monkey 2:
  Starting items: 79, 60, 97
  Operation: new = old * old
  Test: divisible by 13
    If true: throw to monkey 1
    If false: throw to monkey 3

Monkey 3:
  Starting items: 74
  Operation: new = old + 3
  Test: divisible by 17
    If true: throw to monkey 0
    If false: throw to monkey 1

Every time a monkey inspects your item, your worry level for that item increases by the functions provided in the input. Your worry level is then divided by 3 because of your relief that the monkey did not damage the item.

In a turn a monkey inspects all of your items in order. For each item it adjusts the worry level and then throws it based on the test.

A monkey's turn ends if it has no (more) items.

A round consists of all monkeys taking one full turn in order.

The level of monkey business is the product of the amount of items that the two most active monkeys have inspected.

What is the level of monkey business after 20 rounds?

Read the full description here.

This is another one of those problems where parsing is quite a big part of the problem. We'll keep track of the monkeys in an array of Monkey.

type Monkey = {
    Items: int64 list
    Operation: int64 -> int64
    Test: int;
    IfTrue: int;
    IfFalse: int;
}

With operations such as old * 19 and old * old I expect numbers to become quite big, hence int64. We'll store operations as functions, so it's easy apply them.

Parsing an operatio is a little involved. We need the final two words of the line. Then we can match and determine the function to store.

let parseOperation (str: string) =
    let finalTwoElements =
        str |> String.split |> Array.rev |> Array.take 2
    match finalTwoElements with
    | [|num; op|] ->
        if num = "old" then (fun n -> n * n)
        elif op = "+" then ((+) (Int64.parse num))
        else ((*) (Int64.parse num))
    | _ -> failwith $"Failed to parse operation: {str}"

Parsing the rest of the monkey is also a little involved. There are three lines where we're interested in the last word, as an integer. We'll write a helper, lastAsInt, which does just that.

We're not interested in the first line of the monkey, so we skip it. Then we'll take the next five lines. The items line we can split on the colon, discard the first section, then split on comma and map the resulting array to an int64 list. The other fields are straight forward.

let parseMonkey (input: string list) =
    let lastAsInt str = str |> String.split |> Array.last |> Int32.parse
    match input |> List.skip 1 with
    | [items;operation;test;ifTrue;ifFalse] ->
        {
            Items = items |> String.splitOn [|':'|] |> Array.last |> String.splitOn [|','|] |> Array.map Int64.parse |> Array.toList;
            Operation = parseOperation operation;
            Test = lastAsInt test;
            IfTrue = lastAsInt ifTrue;
            IfFalse = lastAsInt ifFalse;
        }
    | _ -> failwith "Failed to parse monkey"

Now let's start inside out. A round consists of every monkey taking a turn. A monkey will adjust the worry level and then throw an item.

First we introduce a helper for throwing an item. It simply adds the item to the target monkey. It could also remove the item from the source monkey, but we can also simply set the monkey's item list to an empty list at the very end.

let throwItem n item monkeys =
    monkeys
    |> Array.updateAt n { monkeys[n] with Items = List.append monkeys[n].Items [item] }

Next we'll peform the entire turn. For every item we calculate the new worry level. Then we test if the item is divisible by the monkey's Test criterium. A number is divisible by n if number % n = 0. Finally we throw the item and update our array of monkeys.

let monkeysAfterThrowing =
    List.fold (fun monkeys item ->
        let worry =  (monkey.Operation item) / 3L

        if worry % int64 monkey.Test = 0 then throwItem monkey.IfTrue worry monkeys
        else throwItem monkey.IfFalse worry monkeys) monkeys monkey.Items

For the final bookkeeping we need to empty the monkey's item list and count how many items the monkey has inspected. We do the latter in a separate array called counts.

let turn index counts monkeys =
    let monkey = monkeys[index]

    // snip

    (counts |> Array.updateAt index (counts[index] + (int64)(List.length monkey.Items)),
    Array.updateAt index { monkeysAfterThrowing[index] with Items = [] } monkeysAfterThrowing)

Finally we can implement a round. Since we did not store monkey identifiers, we need the index of the monkey. Then we just fold over the indexed array, updating counts and monkeys as we go.

let rec round counts monkeys =
    monkeys |> Array.indexed
    |> Array.fold (fun (counts, monkeys) (n, _) -> turn n counts monkeys) (counts, monkeys)

With all the plumbing implemented we can put it all together. We'll run 20 rounds, get only the counts, sort them, take the top 2 and take their product.

[1..20]
|> List.fold (fun (counts, monkeys) _ -> round counts monkeys) (counts, monkeys)
|> fst
|> Array.sortDescending |> Array.take 2 |> Array.fold (*) 1L

Part 2

Summary: You are worried that you're never going to get your items back. So worried, in fact, that your worry level is no longer divided by 3! Your worry level will spiral out of control.

With this new rule, determine the level of monkey business after 10000 rounds.

Let's get the obvious out of the way first. Most if not all of your items are going to be in the hands of a monkey that will multiply the worry level by itself. 10000 times. That number will get so large that calculating the divisibility will be too slow. So now that we're no longer dividing the worry level by 3, we need another way to adjust it.

Other than that our code remains the same, though. We can take our solution to part 1, pass a new adjust function for the worry level and pass the amount of rounds and run with it.

let worry = worryControlFn <| monkey.Operation item
assert(worry >= 0L)

We'll also ensure that we don't get an overflow.

Maintaining a reasonable worry level

So how do we maintain a reasonably worry level?

In part 1 we used the fact that a number a is divisble by n if a % n = 0. That still applies here. If a is very large and subtract n from it one time that does not change the remainder. (a - n) % n = a % n. If we remove n so often that only the remainder remains, this is still true. (a % n) % n = a % n.

Unfortunately there's more than one monkey and their divisors are different. So we cannot reduce the worry level to worry % divisor.

For example: 12 % 5 = (12 - 5) % 5 = 2. 2 % 5 is still 2. But if another monkey has divisor m = 7, then 12 % 7 = 5 while (12 - 5) % 7 = 0.

It turns out we can solve this by storing the remainder of dividing by a number that is divisible by both 5 and 7. We can do this because if a number is divisble by a multiple of n, it's also divisible by n.

To get this number for all the monkeys, we need to multiply all of their divisors. We want to make this number as small as possible. The smallest number in the above example is indeed 5 * 7 = 35, but if the numbers were 2 and 8, then, since 8 is already a multiple of 2, that number would be 2. It turns out that in the input all divisors are prime, and so the lowest common multiple is, indeed, all of them multiplied together.

let product = monkeys |> Array.map (fun m -> int64 m.Test) |> Array.fold (*) 1L

Our worry level adjusting function then becomes worry = worry % product.

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

I wasn't feeling it today. Not even a little. Eventually I opened my laptop at about 23:00. Part 1 was relatively straight forward. For part 2 it took longer than I've liked to realise the modulo trick. It shows my rustiness in problem solving.

On to the next one!

The full code for the day is on GitHub.