Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

day-11

advent_of_code_2022/day11.livemd

day-11

Mix.install([
  {:kino, "~> 0.7.0"}
])

Examples

The one example here is the one used by both part 1 and 2.

defmodule Examples do
  def example_1 do
    ~S"""
    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
    """
  end
end

Core

Not really necessary, but I started off with it, so decided to keep it.

defmodule Monkey do
  defstruct [:id, :items, :operation, :test, :pass, :fail]

  def new(%{id: id, items: items, operation: operation, test: test, pass: pass, fail: fail})
      when is_integer(id) and is_list(items) and is_tuple(operation) and is_integer(test) and
             is_integer(pass) and is_integer(fail) do
    %__MODULE__{id: id, items: items, operation: operation, test: test, pass: pass, fail: fail}
  end
end
defmodule MonkeyParser do
  @doc """

  ## Examples

    iex> MonkeyParser.parse(Examples.example_1)
    [
      %Monkey{id: 0, items: [79, 98], operation: {:old, :*, 19}, test: 23, pass: 2, fail: 3},
      %Monkey{id: 1, items: [54,65,75,74], operation: {:old, :+, 6}, test: 19, pass: 2, fail: 0},
      %Monkey{id: 2, items: [79,60,97], operation: {:old, :*, :old}, test: 13, pass: 1, fail: 3},
      %Monkey{id: 3, items: [74], operation: {:old, :+, 3}, test: 17, pass: 0, fail: 1}
    ]
  """
  def parse(input) do
    input
    |> String.split("\n\n", trim: true)
    |> Enum.map(&String.split(&1, "\n", trim: true))
    |> Enum.map(fn [
                     id_row,
                     items_row,
                     operation_row,
                     test_row,
                     test_true_row,
                     test_false_row
                   ] ->
      Monkey.new(%{
        id: parse_id_row(id_row),
        items: parse_items_row(items_row),
        operation: parse_operation_row(operation_row),
        test: parse_test_row(test_row),
        pass: parse_test_outcome_row(test_true_row),
        fail: parse_test_outcome_row(test_false_row)
      })
    end)
  end

  defp parse_id_row(id_row) do
    [_, id_str, ""] = String.split(id_row, [" ", ":"])
    String.to_integer(id_str)
  end

  defp parse_items_row(items_row) do
    items_row
    |> String.split(": ")
    |> Enum.at(-1)
    |> String.split(", ")
    |> Enum.map(&String.to_integer/1)
  end

  defp parse_operation_row(operation_row) do
    [left, op, right] =
      operation_row
      |> String.split(" = ")
      |> Enum.at(-1)
      |> String.split(" ")

    {parse_operand(left), parse_operation(op), parse_operand(right)}
  end

  defp parse_operand("old"), do: :old
  defp parse_operand(v), do: String.to_integer(v)
  defp parse_operation("+"), do: :+
  defp parse_operation("*"), do: :*

  defp parse_test_row(test_row) do
    test_row
    |> String.split(": ")
    |> Enum.at(-1)
    |> String.split(" by ")
    |> Enum.at(-1)
    |> String.to_integer()
  end

  defp parse_test_outcome_row(row) do
    row
    |> String.split(" ")
    |> Enum.at(-1)
    |> String.to_integer()
  end
end

Input

input = Kino.Input.textarea("Input")

Part 1

Difficulty here was understanding that items passed to the next monkey in this round will be inspected by that monkey before the round ends.

We need a separate module for passing logic in part 1 vs part 2, so this is how we approach it.

defmodule MonkeyPasser do
  require Logger

  @doc """
  Converting monkeys to a map speeds up lookup and the overall process
  """
  def build_state(monkeys) do
    monkeys
    |> Enum.map(fn %Monkey{} = monkey ->
      {
        monkey.id,
        %{
          id: monkey.id,
          operation: monkey.operation,
          test: monkey.test,
          pass: monkey.pass,
          fail: monkey.fail,
          items: monkey.items,
          checks: 0
        }
      }
    end)
    |> Map.new()
  end

  @doc """
  Takes monkeys and number of rounds. 
  Returns a map of monkeys by id, containing all the data 
  about each monkey after all the rounds have been executed.

  ## Examples    
    iex> Examples.example_1() 
    ...> |> MonkeyParser.parse() 
    ...> |> MonkeyPasser.pass(20) 
    ...> |> Map.values()
    ...> |> Enum.map(&{&1.id, &1.checks})
    ...> |> Map.new()
    %{0 => 101, 1 => 95, 2 => 7, 3 => 105}
  """

  def pass(monkeys, rounds) do
    Enum.reduce(1..rounds, build_state(monkeys), fn round, state ->
      Logger.info("Round #{round}")
      pass_round(state)
    end)
  end

  # performs one round of the process
  defp pass_round(state) do
    # outer reduce goes thrug monkeys and updates state
    Enum.reduce(Map.keys(state), state, fn monkey_id, state ->
      # inner reduce operates on a monkey and goes through it's items
      monkey = state[monkey_id]

      Enum.reduce(monkey.items, state, fn item, state ->
        monkey = state[monkey_id]

        # incrase, then decrease worry according to rules
        item = item |> worry(monkey.operation) |> div(3)

        # update the current monkey
        monkey = %{
          monkey
          | items: Enum.drop(monkey.items, 1),
            checks: monkey.checks + 1
        }

        # pass item to other monkey
        pass_to_id = if rem(item, monkey.test) == 0, do: monkey.pass, else: monkey.fail
        other = state[pass_to_id]
        other_items = Enum.concat(other.items, [item])
        other = %{other | items: other_items}

        state
        |> Map.put(monkey_id, monkey)
        |> Map.put(pass_to_id, other)
      end)
    end)
  end

  # first two clauses here normalize to the final clause
  defp worry(item, {:old, operation, right}) do
    worry(item, {item, operation, right})
  end

  defp worry(item, {left, operation, :old}) do
    worry(item, {left, operation, item})
  end

  # encoding the operation as `:+` or `:*` allows us to just `apply` it
  defp worry(_, {left, operation, right}) do
    apply(Kernel, operation, [left, right])
  end
end
input
|> Kino.Input.read()
|> MonkeyParser.parse()
|> MonkeyPasser.pass(20)
|> Map.values()
|> Enum.map(& &1.checks)
|> Enum.sort()
|> Enum.take(-2)
|> Enum.reduce(&(&1 * &2))

Part 2

defmodule MonkeyPasserV2 do
  require Logger

  @doc """
  Takes monkeys and number of rounds. 
  Returns a map of monkeys by id, containing all the data 
  about each monkey after all the rounds have been executed.

  ## Examples
    iex> Examples.example_1() 
    ...> |> MonkeyParser.parse() 
    ...> |> MonkeyPasserV2.pass(1) 
    ...> |> Map.values()
    ...> |> Enum.map(&{&1.id, &1.checks})
    ...> |> Map.new()
    %{0 => 2, 1 => 4, 2 => 3, 3 => 6}
    
    iex> Examples.example_1() 
    ...> |> MonkeyParser.parse() 
    ...> |> MonkeyPasserV2.pass(20) 
    ...> |> Map.values()
    ...> |> Enum.map(&{&1.id, &1.checks})
    ...> |> Map.new()
    %{0 => 99, 1 => 97, 2 => 8, 3 => 103}

    iex> Examples.example_1() 
    ...> |> MonkeyParser.parse() 
    ...> |> MonkeyPasserV2.pass(1000) 
    ...> |> Map.values()
    ...> |> Enum.map(&{&1.id, &1.checks})
    ...> |> Map.new()
    %{0 => 5204, 1 => 4792, 2 => 199, 3 => 5192}
  """
  def pass(monkeys, rounds) do
    Enum.reduce(1..rounds, MonkeyPasser.build_state(monkeys), fn round, state ->
      Logger.info("Round #{round}")
      pass_round(state)
    end)
  end

  defp pass_round(state) do
    # outer reduce goes through all the monkeys and passes state to inner reduce
    Enum.reduce(Map.keys(state), state, fn monkey_id, state ->
      # inner reduce goes through all items of a single monkey, 
      # passing the same state along and updating it
      monkey = state[monkey_id]

      Enum.reduce(monkey.items, state, fn item, state ->
        monkey = state[monkey_id]

        # worry function does not compute in V2;
        # just appends to a list
        item = worry(item, monkey.operation)

        # the current monkey is updated in the same way as in V1
        monkey = %{
          monkey
          | items: Enum.drop(monkey.items, 1),
            checks: monkey.checks + 1
        }

        # this is when we compute the rem to check which monkey to pass to
        pass_to_id =
          case compute_rem(item, monkey.test) do
            0 -> monkey.pass
            _ -> monkey.fail
          end

        # pass to monkey, same as before
        other = state[pass_to_id]
        # Special note here: 
        # Using `Enum.concat` instead causes the livebook to disconnect
        # at around 3000 items, give or take.
        other_items = [item | Enum.reverse(other.items)] |> Enum.reverse()
        other = %{other | items: other_items}

        state
        |> Map.put(monkey_id, monkey)
        |> Map.put(pass_to_id, other)
      end)
    end)
  end

  # worry function in V2 simply appends the instruction to 
  # a list
  defp worry(item, {:old, operation, right}) do
    [{operation, right} | item |> List.wrap() |> Enum.reverse()] |> Enum.reverse()
  end

  # We compute_rem while keeping numbers small by relying on 
  # a property of the modul operand:
  # `(a + b) % c === ((a % c) + (b % c)) % c`
  # `(a * b) % c === ((a % c) * (b % c)) % c`
  defp compute_rem([initial | rest], test) do
    rest
    |> Enum.reduce(initial, fn
      {:+, :old}, acc -> acc + rem(acc, test)
      {:+, right}, acc -> acc + rem(right, test)
      {:*, :old}, acc -> acc * rem(acc, test)
      {:*, right}, acc -> acc * rem(right, test)
    end)
    |> rem(test)
  end
end
# takes sometimes up to an hour to execute, but executes
input
|> Kino.Input.read()
|> MonkeyParser.parse()
|> MonkeyPasserV2.pass(10000)
|> Map.values()
|> Enum.map(& &1.checks)
|> Enum.sort()
|> Enum.take(-2)
|> Enum.reduce(&(&1 * &2))