Reading time:

Day 20

Im gonna briefly describe the problem here, but if you want to see the real thing go check it out https://adventofcode.com/2023/day/20.

I like it because it involves some simple electronic devices that are wired together and send pulses/signals to each other. In this problem you have to make sure to correctly propagate the signals and simulate the behaviour of the devices.

There are two devices that have a very distinct behaviour:

  • Flip flops: similar to a T flip-flop electronic device.
  • Conjunctions: similar to a NAND gate with memory on its inputs.
  • In this problem, Flip flops are initially off and whenever they reiceve a low pulse they toggle between on/off. Each time it toggles state it sends a pulse as an output. When turned off sends a low pulse, when turned on sends a high pulse.

    Conjunction modules remember the most recent pulse on each input. By default it remembers a low pulse for all inputs. When a pulse is received it updates the memory for that input. Then, if it remembers high pulses for all inputs, it sends a low pulse; otherwise, it sends a high pulse.

    There is also some "dummy" modules:

  • Broadcaster: has 1 input and N outputs. It replicates the input in all its outputs.
  • Button: when pressed sends a low pulse. The button is always connected as the broadcaster input. This is similar to a normally closed switch.
  • Test module: module that receive and process inputs but has no output.
  • One important thing to have in mind is that modules only send output pulses when they receive a pulse as input.

    Problem input

    The example input looks something like this:
    broadcaster -> a, b, c
    %a -> b
    %b -> c
    %c -> inv
    &inv -> a
    

    There will always be just one Broadcaster module called "broadcaster" that has the Button connected as input. In this case it has module's "a", "b" and "c" connected to its output.

    The arrow -> indicates what modules are connected to the output of the module to the left.

    Lines that start with % means the module is a Flip flop, for example: %a -> b indicates that there's a flip flop called "a" whose output is connected to module's "b" input.

    Lines that start with & means the module is a Conjunction, for example: &inv -> a indicates that there's a conjunction called "inv" whose output is connected to module's "a" input.

    Let's analyze how this circuit behaves once the button is pushed:
    button -0-> broadcaster
    broadcaster -0-> a
    broadcaster -0-> b
    broadcaster -0-> c
    a -1-> b
    b -1-> c
    c -1-> inv
    inv -0-> a
    a -0-> b
    b -0-> c
    c -0-> inv
    inv -1-> a
    
    In this example 8 low (0) pulses and 4 high (1) pulses are sent.

    Part 1

    To solve the first part we need to calculate the multiplication between high and low pulses sent between devices.

    In the previous example that would be 8*4=32.

    But this time we don't push the button only once, but we push it a 1000 times. Each time we push the button we wait until all signals propagate and the circuit settles into a state before pushing the button again.

    Solution

    First I started by modelling the devices as objects. Starting with a single base class that has most of the common behaviour.
    from abc import ABC
    measure_pulses = {0: 0, 1: 0}
    class Module(ABC):
        def __init__(self, name: str):
            self.name = name
            self.outputs = []
    
        def receive_pulse(self, mod: "Module", pulse: int) -> list[tuple["Module", int]]:
            measure_pulses[pulse] += 1
            print(f"{mod and mod.name} -{pulse}-> {self.name}")
            return self.process_pulse(mod, pulse)
    
        def connect_output(self, mod: "Module"):
            self.outputs.append(mod)
    
        def propagate_pulse(self, pulse: int):
            mods = []
            for m in self.outputs:
                mods.append((m, pulse))
            return mods
    
        def process_pulse(self, mod: "Module", pulse: int):
            raise NotImplementedError()
    
        def __str__(self) -> str:
            return f"{self.__class__.__name__}(name={self.name})"
    
        def __repr__(self) -> str:
            return str(self)
    

    What we see here is that we expect all modules to have a name and outputs. See __init__(), __str__(), __repr__() and connect_output().

    Each module can receive a pulse 0 or 1 from another module. See receive_pulse(). Each time we process a pulse we record it in a global dict called measure_pulses.

    Also we leave process_pulse() to be defined by each particular module type.

    We have a method that returns a list of all modules to which signals should be propagated. See propagate_pulse().

    Let's start by the easiest module type:
    class TestModule(Module):
        def process_pulse(self, mod: "Module", pulse: int):
            return []
    
    Give that it's a dummy module, it doesn't do anything when it receives an input.
    class Broadcaster(Module):
        def process_pulse(self, mod: "Module", pulse: int):
            return super().propagate_pulse(pulse)
    
    As expected the Broadcaster always propagates the received input to all its outputs.
    class FlipFlop(Module):
        def __init__(self, name: str):
            super().__init__(name)
            self.state = 0
    
        def process_pulse(self, mod: "Module", pulse: int):
            if pulse == 0:
                self.state = (self.state + 1) % 2
                return super().propagate_pulse(self.state)
            return []
    

    The flip flop start initially turned off. See self.state = 0 in __init__().

    In process_pulse() we implement the behaviour:

  • If receives a low pulse, toggles the state and sends a pulse equals to the state to all its outputs.
  • Otherwise it doesn't do anything.
  • class Conjunction(Module):
        def __init__(self, name: str):
            super().__init__(name)
            self.memory = {}
    
        def remember_input(self, mod: Module):
            self.memory[mod.name] = 0
    
        def process_pulse(self, mod: Module, pulse: int):
            self.memory[mod.name] = pulse
            if all(self.memory.values()):
                return self.propagate_pulse(0)
            return self.propagate_pulse(1)
    

    The conjunction initializes its memory as empty. See __init__().

    Each time a module is plugged in as an input it remembers it as OFF (0). See remember_input().

    The way it processes pulses is by first recording the pulse for the input in its memory. Then if all inputs are 1s it sends a 0 pulse to all its outputs.

    Otherwise it sends a 1 pulse to all its outputs.

    At this point we have all our building blocks for solving this problem. We only need to parse the input and something that pushes the button and makes sure signals are propagated to the end.

    Parsing modules is straightforward:
    def parse_modules(modules: list) -> dict[str, Module]:
        modules_by_name = {}
        outputs_by_name = {}
    
        # Parse all modules into their correspondig class and store
        # them in a dict.
        for m in modules:
            module_type = m[0]
            module_outputs = [o.strip() for o in m[1].split(",") if o.strip()]
            if module_type.startswith("broadcaster"):
                modules_by_name[module_type] = Broadcaster(module_type)
                outputs_by_name[module_type] = module_outputs
            elif module_type.startswith("%"):
                modules_by_name[module_type[1:]] = FlipFlop(module_type[1:])
                outputs_by_name[module_type[1:]] = module_outputs
            elif module_type.startswith("&"):
                modules_by_name[module_type[1:]] = Conjunction(module_type[1:])
                outputs_by_name[module_type[1:]] = module_outputs
        # Once all the modules are parsed use connect their outputs.
    
        # If the module doesn't exist at this point is a TestModule.
        # If the module is a Conjunction, call remember_input().
        for name, outputs in outputs_by_name.items():
            for mod_name in outputs:
                mod = modules_by_name.get(mod_name, TestModule(mod_name))
                modules_by_name[name].connect_output(mod)
                if isinstance(mod, Conjunction):
                    mod.remember_input(modules_by_name[name])
    
        return modules_by_name
    

    If we parse our example using that function we will receive a dictionary as its output. Keys are module names and values are the objects representing the module.

    If we parse the example we get something like this:

    example = """broadcaster -> a, b, c
    %a -> b
    %b -> c
    %c -> inv
    &inv -> a"""
    example_modules = [m.split(" -> ") for m in example.splitlines() if m.strip()]
    print(parse_modules(example_modules))
    
    # Output
    {
        'broadcaster': Broadcaster(name=broadcaster),
        'a': FlipFlop(name=a),
        'b': FlipFlop(name=b),
        'c': FlipFlop(name=c),
        'inv': Conjunction(name=inv)
    }
    
    Then we need a function that pushes the button and makes sure all signals are propagated:
    def push_button(modules_by_name: dict[str, Module]):
        broad = modules_by_name["broadcaster"]
        queue = [(broad, broad.receive_pulse(None, 0))]
        while queue:
            current, signals = queue.pop(0)
            for mod, pulse in signals:
                queue.append((mod, mod.receive_pulse(current, pulse)))
    

    Here, we lookup the broadcaster module by name. And send a pulse (note that we pass None as the module because we didn't implement a button class) to the broadcaster.

    We store the current module (broadcaster) along with all the propagated signals (return value from receive_pulse()) in a queue to be processed.

    While the signal queue to be processed is not empty we do the following:

  • Extract the first element of the queue.
  • Go trough all the signals that this element is sending.
  • Send the pulses to each corresponding module and store them in the queue to be processed.
  • This process will stop when all responses from receive_pulse() are empty and there are no more signals added to the queue.

    If we run this for our example:

    example_modules = parse_modules(example_modules)
    push_button(example_modules)
    
    # Output
    None -0-> broadcaster
    broadcaster -0-> a
    broadcaster -0-> b
    broadcaster -0-> c
    a -1-> b
    b -1-> c
    c -1-> inv
    inv -0-> a
    a -0-> b
    b -0-> c
    c -0-> inv
    inv -1-> a
    

    It looks the same as when we analyzed the example above!!

    We're ready for processing our problems input. (Remeber to comment out the print statement inside receive_pulse()).

    modules = open("input.txt", "r").read().strip()
    modules = [m.split(" -> ") for m in modules.splitlines() if m.strip()]
    modules = parse_modules(modules)
    
    for _ in range(1000):
        push_button(modules)
    
    print("result:", measure_pulses[0] * measure_pulses[1])
    
    # Output
    result: x
    
    Based on your problem input x will be the solution.

    Part 2

    This part as always is much trickier than the first part. It doesn't involve much code changes, just figuring out a way of avoiding large computations.

    For this part, the problem tells us that there's a module called rx. And we need to find out the lowest amount of button pulses that will make the rx module receive a low pulse.

    As I have learned troughout this entire challenge, just nahively letting it run and see when the rx module gets a low signal will get me nowhere. It will run forever.

    So, taking a look at the input and see what the rx module is actually connected to might provide some guidance.

    Following is for my case (I don't know if all problem inputs are the same). Looking up "rx" in the input I find a single line:
    ...
    &lv -> rx
    ...
    

    That means rx is a TestModule (a dummy module that has nothing connected to its output). And that has only one input: a Conjunction called lv.

    Ok, that feels like progress. Let's see what lv is connected to:
    ...
    &st -> lv
    &tn -> lv
    &hh -> lv
    &dt -> lv
    ...
    

    Other 4 Conjunctions are connected as inputs of lv. That's interesting. Because lv is a Conjuction it means that to send the low pulse required by rx it should receive all high pulses from its inputs.

    The solution from here is kind of intuitive at this point. If we figure out how many button pulses does it take for each of the input devices to send a 1 signal we can multiply them together and get the result.

    I'll explain better. Let's say st sends a 1 on every button push, tn sends a 1 every second button push (this means you have to press the button twice to get tn to send a 1 as an output), hh sends a 1 every fourth button push and dt sends a 1 every eighth button push.

    So it looks like this:
    module | pushes
    ---------------
      st   |   1
      tn   |   2
      hh   |   4
      dt   |   8
    

    In this example, if we push the button 8 times. They are all gonna send a high pulse. Because 8 is divisible by 1, 2, 4 and 8.

    If the table were different:
    module | pushes
    ---------------
      st   |   1
      tn   |   3
      hh   |   5
      dt   |   7
    

    In this case there's no obvious number of times we should push the button. But if we multiply the numbers together we get a number that is divisible by every number in the table. Pushing the button 1 * 3 * 5 * 7 = 105 times will make all the outputs send a 1, and consequently rx will receive a 0.

    Solution

    What we need to do then is to figure after out how many button presses we get a 1 on each of those modules.
    from collections import defaultdict
    
    # Store number of button presses in a global variable
    ITERATIONS = 0
    # Store high pulses for target modules
    OUT_PULSES = defaultdict(list)
    
    class Conjunction(Module):
        def __init__(self, name: str):
            super().__init__(name)
            self.memory = {}
    
        def remember_input(self, mod: Module):
            self.memory[mod.name] = 0
    
        def start_recording(self):
            self.recording = True
    
        def record(self):
            if hasattr(self, "recording"):
                OUT_PULSES[self.name].append(ITERATIONS)
    
        def process_pulse(self, mod: Module, pulse: int):
            self.memory[mod.name] = pulse
            if all(self.memory.values()):
                return self.propagate_pulse(0)
            self.record()
            return self.propagate_pulse(1)
    

    We introduced 2 new methods to the conjunction module: start_recording() and record(). The first just initializes a bool attribute. And the second makes sure to only record high pulses for objects that have been initialized (method start_recording() called).

    Also introduced 2 global variables: ITERATIONS to keep track of button pushes and OUT_SIGNALS to track each time one of the modules outputs a high pulse.

    Now we need to make those specific modules record their outputs:
    # Get the "lv" module by name
    lv = modules["lv"]
    lv_inputs = [modules[k] for k in lv.memory.keys()]
    for m in lv_inputs:
        m.start_recording()
    
    I wasn't sure if the cycle was always going to be the same, so just to be sure I did 100_000 button pushes and recorded all the "1" outputs for those modules.
    for i in range(100_000):
        ITERATIONS += 1
        push_button(modules)
    print(OUT_PULSES)
    
    # Output
    {'hh': [3769, 7538, 11307, 15076, 18845, 22614, 26383, 30152, 33921, 37690, 41459, 45228, 48997, 52766, 56535, 60304, 64073, 67842, 71611, 75380, 79149, 82918, 86687, 90456, 94225, 97994], 'tn': [3863, 7726, 11589, 15452, 19315, 23178, 27041, 30904, 34767, 38630, 42493, 46356, 50219, 54082, 57945, 61808, 65671, 69534, 73397, 77260, 81123, 84986, 88849, 92712, 96575], 'st': [3929, 7858, 11787, 15716, 19645, 23574, 27503, 31432, 35361, 39290, 43219, 47148, 51077, 55006, 58935, 62864, 66793, 70722, 74651, 78580, 82509, 86438, 90367, 94296, 98225], 'dt': [4079, 8158, 12237, 16316, 20395, 24474, 28553, 32632, 36711, 40790, 44869, 48948, 53027, 57106, 61185, 65264, 69343, 73422, 77501, 81580, 85659, 89738, 93817, 97896]}
    
    We can observe that for each module we have a periodicity given by:
    hh = n*3769
    tn = n*3863
    st = n*3929
    dt = n*4079
    

    This means we can just multiply the first element of each list for each module and we'll get our result.

    In my case it was:
    accum = 1
    for name, pulses in OUT_PULSES.items():
        accum *= pulses[0]
    print("result:", accum)
    
    # Output
    result: 233338595643977
    

    Edit: As some people have pointed out in the HN discussion, just multiplying the numbers together only works because the numbers are coprimes and the correct solution is to use LCM.

    Closing words

    This problem is my favourite because it has a few characteristics that I personally enjoy:

  • It's based on real world stuff. In this case electronic devices (which is also a plus because they're fun).
  • It can be easily translated to an OOP approach which makes it easy to implement and understand.
  • To solve the second part you need to look at the data and make a solution for your particular input.
  • It doesn't involve any Graph traversal or specific Math, Calculus or Algebra knowledge. Or any obscure CS algorithm.
  • In the end this is one of my favourites because to solve it you just have to understand the problem and understand the data.

    Link to my github project with the solutions https://github.com/mliezun/aoc.