Advent of Code 2023 - Day 20

This one lost me at the beginning, but after a little inspection of the input, there is a clear path to the answer.

Explainations in part_two/1

defmodule AdventOfCode.Y23.Day20 do
  alias AoC.Input, warn: false

  def read_file(file, _part) do
    Input.stream!(file, trim: true)
  end

  def parse_input(input, _part) do
    {modules, out_from} = Enum.map_reduce(input, _out_from = %{}, &parse_line/2)

    # now for each inverter we need to initialize state will all its possible inputs
    modules =
      modules
      |> Enum.map(fn
        {key, {:conj, :uninitialized, outs}} ->
          state = Map.new(Map.fetch!(out_from, key), fn k -> {k, 0} end)
          {key, {:conj, state, outs}}

        other ->
          other
      end)
      |> Map.new()

    {modules, out_from}
  end

  defp parse_line(line, out_from) do
    [name, outs] = String.split(line, " -> ")

    {name, kind, state} =
      case name do
        "broadcaster" -> {"broadcaster", :bcast, nil}
        "%" <> name -> {name, :flip, :off}
        "&" <> name -> {name, :conj, :uninitialized}
      end

    outs = String.split(outs, ", ")
    out_from = Enum.reduce(outs, out_from, fn out, acc -> Map.update(acc, out, [name], &[name | &1]) end)

    module = {name, {kind, state, outs}}
    {module, out_from}
  end

  def part_one({modules, _}) do
    {count_low, count_high, _} =
      Enum.reduce(1..1000, {0, 0, modules}, fn _, {count_low, count_high, modules} ->
        {count_low_add, count_high_add, modules} = push_button(modules)
        {count_low + count_low_add, count_high + count_high_add, modules}
      end)

    count_low * count_high
  end

  def part_two({modules, out_from}) do
    # modules =
    #   Map.new(modules, fn
    #     {key, {:flip, _, _}} -> {key, :flip}
    #     {key, {:conj, _, _}} -> {key, :conj}
    #     {key, {:bcast, _, _}} -> {key, :bcast}
    #   end)

    # Rule:
    #
    #     &jm -> rx
    #
    # For rx to receive a low pulse, &jm must remember a high pulse for all its
    # inputs
    #
    # Then we have that:
    #
    #     &sg -> jm
    #     &lm -> jm
    #     &dh -> jm
    #     &db -> jm
    #
    # So we need all of them to send a high input in the same cycle.
    #
    # The parents are those. Note that sg, lm, dh and db have each one a single
    # input, so they are actually regular not gates, or "%" modules.
    #
    #     &bc -> _, _, _, _, dh, _, _
    #     &bx -> _, _, db
    #     &qq -> lm, _, _, _, _, _, _
    #     &gj -> _, _, sg, _
    #
    # For sg, lm, dh and db to send a high pulse in the same time, we need bc,
    # bx, qq and qj to send a low pulse in the same time.
    #
    # So we count how much cycles it takes for each one to send a low pulse, and
    # the LCM of those cycle numbers is the answer.
    #
    # Though I have a feeling that the input is very tailored for that solution
    # because any input would not guarantee that if bc, bx, qq and qj send a low
    # pulse after N first cycles, they would acutally send a low pulse every
    # other N cycles.
    #

    cyclics = Enum.flat_map(["rx"], &Map.fetch!(out_from, &1))
    cyclics = Enum.flat_map(cyclics, &Map.fetch!(out_from, &1))
    cyclics = Enum.flat_map(cyclics, &Map.fetch!(out_from, &1))

    counts = count_cycles_until_low_pulse(modules, cyclics)
    counts |> Map.values() |> Enum.reduce(fn a, b -> trunc(lcm(a, b)) end)
  end

  defp count_cycles_until_low_pulse(modules, watch_list) do
    infinite_ints = Stream.iterate(1, &(&1 + 1))

    cycle_counts = Map.new(watch_list, &{&1, false})

    Enum.reduce(infinite_ints, {modules, cycle_counts}, fn i, {modules, cycle_counts} ->
      # We cannot inspect the states after the button is pushed because the
      # modules we are looking for are resetting before the modules are
      # returned.
      #
      # So we need to inspect the emitted pulses and return from that.
      {modules, cycle_counts} =
        push_button(modules, cycle_counts, fn pulses, counts ->
          Enum.reduce(pulses, counts, fn
            {_, 1, _}, counts ->
              counts

            {from, 0, _}, counts ->
              case Map.get(counts, from) do
                false -> Map.put(counts, from, i)
                _ -> counts
              end
          end)
        end)

      if Enum.all?(cycle_counts, fn {_, count} -> count end) do
        throw({:counts, cycle_counts})
      end

      {modules, cycle_counts}
    end)
  catch
    {:counts, counts} -> counts
  end

  defp push_button(modules) do
    init_pulse = {"button", 0, "broadcaster"}
    {_count_low, _count_high, _modules} = reduce([init_pulse], modules, 0, 0)
  end

  defp reduce([], modules, count_low, count_high) do
    {count_low, count_high, modules}
  end

  defp reduce(pulses, modules, count_low, count_high) do
    {count_low, count_high} = count_pulses(pulses, count_low, count_high)

    {new_pulses, new_modules} =
      Enum.flat_map_reduce(pulses, modules, fn {_, _, to} = p, modules ->
        case Map.fetch(modules, to) do
          {:ok, module} ->
            {next_pulses, new_module} = handle_pulse(p, module)
            modules = Map.put(modules, to, new_module)
            {next_pulses, modules}

          :error ->
            {[], modules}
        end
      end)

    reduce(new_pulses, new_modules, count_low, count_high)
  end

  defp push_button(modules, acc, f) do
    init_pulse = {"button", 0, "broadcaster"}
    {_modules, _acc} = run([init_pulse], modules, acc, f)
  end

  defp run([], modules, acc, _f) do
    {modules, acc}
  end

  defp run(pulses, modules, acc, f) do
    {new_pulses, new_modules} =
      Enum.flat_map_reduce(pulses, modules, fn {_, _, to} = p, modules ->
        case Map.fetch(modules, to) do
          {:ok, module} ->
            {next_pulses, new_module} = handle_pulse(p, module)
            modules = Map.put(modules, to, new_module)
            {next_pulses, modules}

          :error ->
            {[], modules}
        end
      end)

    new_acc = f.(new_pulses, acc)

    run(new_pulses, new_modules, new_acc, f)
  end

  defp handle_pulse({_, kind, me}, {:bcast, _, outs} = this) do
    # There is a single broadcast module (named broadcaster). When it receives a
    # pulse, it sends the same pulse to all of its destination modules.
    sends = send_all(outs, me, kind)
    {sends, this}
  end

  defp handle_pulse({_, 0, me}, {:flip, state, outs}) do
    # if a flip-flop module receives a low pulse, it flips between on and off.
    # If it was off, it turns on and sends a high pulse. If it was on, it turns
    # off and sends a low pulse.
    {new_state, send_kind} =
      case state do
        :off -> {:on, 1}
        :on -> {:off, 0}
      end

    sends = send_all(outs, me, send_kind)
    this = {:flip, new_state, outs}
    {sends, this}
  end

  defp handle_pulse({_, 1, _}, {:flip, _, _} = this) do
    # If a flip-flop module receives a high pulse, it is ignored and nothing
    # happens.
    {[], this}
  end

  defp handle_pulse({from, kind, me}, {:conj, state, outs}) do
    # Conjunction modules (prefix &) remember the type of the most recent pulse
    # received from each of their connected input modules; they initially
    # default to remembering a low pulse for each input. When a pulse is
    # received, the conjunction module first updates its memory for that input.
    # Then, if it remembers high pulses for all inputs, it sends a low pulse;
    # otherwise, it sends a high pulse.

    state = Map.replace!(state, from, kind)
    send_kind = if all_high?(state), do: 0, else: 1
    sends = send_all(outs, me, send_kind)
    this = {:conj, state, outs}
    {sends, this}
  end

  defp all_high?(map) do
    Enum.all?(map, fn
      {_, 1} -> true
      _ -> false
    end)
  end

  defp send_all(outs, me, kind) do
    Enum.map(outs, &{me, kind, &1})
  end

  defp count_pulses([{_, 0, _} | pulses], count_low, count_high) do
    count_pulses(pulses, count_low + 1, count_high)
  end

  defp count_pulses([{_, 1, _} | pulses], count_low, count_high) do
    count_pulses(pulses, count_low, count_high + 1)
  end

  defp count_pulses([], count_low, count_high) do
    {count_low, count_high}
  end

  defp lcm(0, 0), do: 0
  defp lcm(a, b), do: a * b / Integer.gcd(a, b)
end
2 Likes

Part 1 was essentially just parsing and implementing the instructions from the question.
I must say that my solution to part 2 is not quite a general solution, but depends on the shape of the input graph…

Spoilers

I used dot from graphviz to plot the input graph, and I noticed that there are 4 distinct components: branching directly from the broadcaster, and merging into a single “conjunction” vertex than then feeds into rx. Each of the components by themselves can be processed by just running the same thing as in part 1 - for me they produce the desired signal as their output in about ~3900 steps. Then the desired number is just the least common multiple of those (curiously, all 4 were primes in my case, so I could just multiply them).

Code
defmodule Main do
  def run() do
    get_input()
    |> parse() |> add_sinks() |> calc_incoming()
    # |> solve1()
    # |> solve2()
    |> mess_around()
	end

  def get_input() do
    # "testinput20.1"
    # "testinput20.2"
    "input20"
    |> File.read!() |> String.trim() |> String.split("\n")
  end

 # parse input into %{}, a "graph"
  def parse(ls) do
    for l <- ls, into: %{} do
      [labl,tos] = String.split(l," -> ")
      case labl |> String.to_charlist() |> Enum.at(0) do
        ?b -> {"B", {nil, tos |> String.split(", ")} }
        c  -> {String.slice(labl,1..-1), {c, tos |> String.split(", ")} }
      end
    end
  end

  # prep: for each "vertex", remember also the ones that point to it
  def calc_incoming(gr) do
    edges = for {l,{_,outedges}} <- gr, e <- outedges, into: [], do: {l,e}
    for {l,{t,outs}} <- gr, into: %{}, do: {l,{t,outs, Enum.filter(edges,&(elem(&1,1)==l))|>Enum.map(&elem(&1,0))}}
  end

  # prep: add vertices that have no outgoing arrows
  def add_sinks(gr) do
    incs = gr |> Map.values() |> Enum.map(&elem(&1,1)) |> Enum.concat()
    missing = MapSet.difference(MapSet.new(incs), MapSet.new(Map.keys(gr)))
    for m <- missing, into: gr, do: {m,{nil,[]}}
  end

  # generate %{} representing the "base" state before anything happens
  def base_state(gr) do
    for {l,{t,_,incs}} <- gr, into: %{} do
      case t do
        ?% -> {l, false}
        ?& -> {l, incs |> Enum.map(fn i -> {i,false} end) |> Enum.into(%{}) }
        _  -> {l, nil}
      end
    end
  end

  # implementing the pulse rules (single pulse)
  def send(lvl,from,outs), do: outs |> Enum.map(fn oo -> {lvl,from,oo} end)
  def do_one_node({nil,outs,_},{lvl,_,"B"},   _), do: {nil,send(lvl,"B",outs)}
  def do_one_node({nil, _ ,_}, {_  ,_, _ },   _), do: {nil,[]}
  def do_one_node({?%,  _ ,_}, {true, _,_}, stt), do: {stt,[]}
  def do_one_node({?%,outs,_}, {false,_,i}, stt), do: {not stt,send(not stt,i,outs)}
  def do_one_node({?&,outs,_}, {lvl,  o,i}, stt) do
    nstt = Map.put(stt,o,lvl)
    if Map.values(nstt)|>Enum.all?, do: {nstt,send(false,i,outs)}, else: {nstt,send(true,i,outs)}
  end

  # implementing pulse rules (one whole "push button" run)
  def run_pulses(st, [], _, {hs,ls}), do: {st, hs, ls}
  def run_pulses(st, [p|ps], gr, {hs,ls}) do
    {lvl,_,i} = p
    {nlst, nps} = do_one_node(gr[i],p,st[i])
    run_pulses(Map.put(st,i,nlst), ps++nps, gr, (if lvl, do: {hs+1,ls}, else: {hs,ls+1}))
  end

  def solve1(gr) do
    bst = gr |> base_state()
    res = 1..1000 |> Enum.reduce({bst,0,0}, fn _,{st,hs,ls} ->
                run_pulses(st,[{false,nil,"B"}],gr,{hs,ls}) end)
    elem(res,1)*elem(res,2)
  end

  def run_pulses2(st, [], _, _, f), do: {st, f}
  def run_pulses2(st, [p|ps], gr, tst, f) do
    {lvl,_,i} = p
    {nlst, nps} = do_one_node(gr[i],p,st[i])
    nf = if {lvl,i} == tst, do: f+1, else: f
    run_pulses2(Map.put(st,i,nlst), ps++nps, gr, tst, nf)
  end

  def solve2(_) do
    "sorry, no general solution"
  end
  
  def find_first(cond,gr) do
    bst = gr |> base_state()
    1..100_000 |> Enum.reduce_while({bst,0}, fn i, {st,f} ->
                    {nst,nf} = run_pulses2(st,[{false,nil,"B"}],gr,cond,f)
                    if nf>0, do: {:halt, i}, else: {:cont, {nst,nf}}
                  end)
  end

  def lcm(ls), do: Enum.reduce(ls, Enum.at(ls,0), fn n, lcm -> div(n*lcm, Integer.gcd(n,lcm)) end)

  def mess_around(gr) do
    # OK so the way I did this is that I used "dot" to draw the graph, and noticed
    # a structure: the vtx just before rx (&vd) has 4 inputs, and each of those come
    # from independent components, branched right from the broadcaster.
    # So: did each of the components separately. and then did lcm of them
    # (interestingly, each of them 4 were primes, in the range of ~3900.)

    watchers = for vtx1 <- elem(gr["rx"],2), vtx2 <- elem(gr[vtx1],2), into: [], do: vtx2
    watchers |> Enum.map(&find_first({false,&1},gr)) |> lcm()
  end
 
end

:timer.tc(&Main.run/0)
|> IO.inspect(charlists: :as_lists)
1 Like

It took me a while to wrap my head abut the rules for the modules in part 1 and do the actual implementation.

For part 2 I suspected that it was possible to somehow divide-and-concur the problem, but I found that some modules were involved in when I tried to convert the input to a tree. It turns out that the cycles don’t matter, because the input can still be cleanly partitioned into separate parts, which each has a broadcaster with a single destination. By partitioning the input data into separate parts I could re-use most of my code from part 1 for part 2.

2 Likes

Very tricky day and it took me a while to clean up the code to remove the use of ETS entirely.

Key Observations for Part 2
  1. There exists only one conjunction connected to rx
  2. For this conjunction &c, it has inputs from m other conjunctions
  3. To find when &c sends a low pulse to rx, we need to find the LCM of the first times that every connected conjunction sends a high pulse to &c since that means all of them sent a high pulse at once, causing &c to send a low pulse to rx

Maybe not the cleanest but I’m happy it works:

Edit I fixed this by calling the protocol method directly, will share more when finished. I can’t delete this post.

Apologies if this isn’t the right place to ask for help, but since everyone here is already familiar with the problem, I figured it would be the best place to ask.

I wanted to have each node type (broadcaster, flipflop, conjunction) etc as its own module and store them together in a map (code attached). The issue I’m running into is that the map is a homogeneous collection of nodes, so how can I call the underlying send_signal method for the correct nodes? I thought implementing the same protocol for each would help, but I’m missing something. Here’s my attached code with comments to clarify my question:

Part 2 thoroughly stumped me. I finally cracked it by rendering the graph and the internal flip flop states with Kino.Mermaid.

After looking at how the flip-flop states were changing over several iterations, it became clear that the machine consisted of 4 independent 12-bit number channels. Each 12-bit number channel had an arbitrary number of bits connected to a NAND gate that fed to both rx and the lowest bit. I figured out the order of the bits and which ones were connected to rx and then used that to find the LCMs of each channel. Then I found the LCM of the 4 channels!

Part 1
defmodule Part1 do
  def parse(input) do
    for line <- String.split(input, "\n", trim: true),
        reduce: {%{}, %{}} do
      {inputs, outputs} ->
        [input, output] = String.split(line, " -> ")
        output = String.split(output, ", ")

        {type, input} =
          case input do
            "broadcaster" -> {:broadcast, input}
            <<"%", name::binary>> -> {:flip_flop, name}
            <<"&", name::binary>> -> {:conjunction, name}
          end

        inputs =
          output
          |> Enum.reduce(inputs, fn output, inputs ->
            Map.update(inputs, output, [input], fn existing -> [input | existing] end)
          end)

        outputs = Map.put(outputs, input, {type, output})
        {inputs, outputs}
    end
  end

  def graph(inputs, outputs, state \\ %{}) do
    outputs = Map.put(outputs, "button", {:button, ["broadcaster"]})
    graph(inputs, outputs, state, ["button"], MapSet.new(), [])
  end

  def graph(_, outputs, state, [], _, edges) do
    meta =
      Enum.map(outputs, fn {sender, {type, _}} ->
        "    #{sender}[\"#{sender} (#{type})\"]"
      end)

    styles =
      Enum.flat_map(outputs, fn {name, {type, _}} ->
        case type do
          :flip_flop ->
            internal = Map.get(state, name, 0)
            fill = if internal == 1, do: "#0f0", else: "#f00"
            ["    style #{name} fill:#{fill}"]

          _ ->
            []
        end
      end)

    lines = ["graph LR"] ++ meta ++ edges ++ styles

    graph =
      lines
      |> Enum.uniq()
      |> Enum.join("\n")

    Kino.Mermaid.new(graph)
  end

  def graph(inputs, outputs, state, [sender | rest], seen, acc) do
    {_, receivers} = Map.get(outputs, sender, {nil, []})
    seen = MapSet.put(seen, sender)

    edges =
      receivers
      |> Enum.map(fn receiver ->
        "    #{sender} --> #{receiver}"
      end)

    next =
      receivers
      |> Enum.reject(fn receiver -> MapSet.member?(seen, receiver) end)

    acc = acc ++ edges
    rest = rest ++ next
    graph(inputs, outputs, state, rest, seen, acc)
  end

  def run(inputs, outputs, opts \\ []) do
    initial = Keyword.get(opts, :initial, %{})
    debug = Keyword.get(opts, :debug, false)
    render = Keyword.get(opts, :render, false)
    frame = if render, do: Kino.Frame.new() |> Kino.render(), else: nil

    run(%{
      inputs: inputs,
      outputs: outputs,
      state: initial,
      debug: debug,
      frame: frame,
      lows: 1,
      highs: 0,
      pulses: [{"button", "broadcaster", 0}]
    })
  end

  def run(%{
        inputs: inputs,
        outputs: outputs,
        state: state,
        lows: lows,
        highs: highs,
        frame: frame,
        pulses: []
      }) do
    if frame do
      term = graph(inputs, outputs, state)
      Kino.Frame.render(frame, term)
    end

    {lows, highs, state}
  end

  def run(
        %{
          inputs: inputs,
          outputs: outputs,
          state: state,
          debug: debug,
          lows: lows,
          highs: highs,
          pulses: [{sender, receiver, pulse} | rest]
        } = proc
      ) do
    {type, receivers} = Map.get(outputs, receiver, {nil, []})

    {pulse, state} =
      recv(%{
        inputs: inputs,
        sender: sender,
        receiver: receiver,
        pulse: pulse,
        type: type,
        state: state
      })

    {lows, highs, pulses} =
      case pulse do
        nil ->
          {lows, highs, []}

        0 ->
          {lows + length(receivers), highs, Enum.map(receivers, &{receiver, &1, 0})}

        1 ->
          {lows, highs + length(receivers), Enum.map(receivers, &{receiver, &1, 1})}
      end

    if debug do
      for {sender, receiver, pulse} <- pulses do
        pulse = if pulse == 0, do: "low", else: "high"
        IO.puts("#{sender} -#{pulse}-> #{receiver}")
      end
    end

    run(%{proc | state: state, pulses: rest ++ pulses, lows: lows, highs: highs})
  end

  def recv(%{type: nil, receiver: receiver, pulse: pulse, state: state}),
    do: {nil, Map.put(state, receiver, pulse)}

  def recv(%{
        type: :broadcast,
        pulse: pulse,
        state: state
      }),
      do: {pulse, state}

  def recv(%{type: :flip_flop, pulse: 1, state: state}), do: {nil, state}

  def recv(%{
        type: :flip_flop,
        receiver: receiver,
        pulse: 0,
        state: state
      }) do
    prev = Map.get(state, receiver, 0)
    next = 1 - prev
    state = Map.put(state, receiver, next)
    {next, state}
  end

  def recv(%{
        type: :conjunction,
        inputs: inputs,
        sender: sender,
        receiver: receiver,
        pulse: pulse,
        state: state
      }) do
    prev = Map.get(state, receiver, %{})
    next = Map.put(prev, sender, pulse)
    pulse = if Enum.all?(inputs[receiver], fn input -> next[input] == 1 end), do: 0, else: 1
    state = Map.put(state, receiver, next)
    {pulse, state}
  end
end

{inputs, outputs} = Part1.parse(input)

{lows, highs, _} =
  for _ <- 1..1000, reduce: {0, 0, %{}} do
    {lows, highs, state} ->
      {new_lows, new_highs, state} = Part1.run(inputs, outputs, initial: state)
      {lows + new_lows, highs + new_highs, state}
  end

lows * highs
Part 2

You can see I used the for-loop here to explore the graph at different iterations.

state =
  for _ <- 1..0//1, reduce: %{} do
    state ->
      {_, _, state} = Part1.run(inputs, outputs, initial: state)
      state
  end

{_, _, state} = Part1.run(inputs, outputs, initial: state, render: true)

Part 1: Implementing a working queue system for the machine and press it 1000 times.

Part 2: Press the button as long as the grand children (conjunction) modules of rx each sent a high. Then get the LCM of these highs. Some hints from Reddit and rendering the graph really helped…