DEV Community

rkeeves
rkeeves

Posted on

Advent of Code: ?sexob evom ot woH

Advent of Code is an annual coding puzzle.

We're going to deal with AoC 2022 Day 05: Supply Stacks.

The straightforward solution is a stack-based simulation.

On some inputs - which we'll see later - this approach is quite slow:

part-1

Disclaimer: It wasn't ever supposed to be run on this input.

Tenet is a Hollywood movie about super-determinism.

Can this Hollywood movie help us?

First, the straightforward solution

The puzzle is about modeling stacks of crates, and moving them around:

the puzzle

That's just the syntax level though. The semantical meaning is what matters.

Commands are like pops and pushes on stacks:

command semantics

A straightforward solution is "do literally the pushes and pops to the stacks, end of story":

straightforward

The straightforward solution works well on basic inputs.

The problem

But our input isn't always that easy...

Our actual input:

  • has a lot of lines,
  • commands moving thousands of crates at once.

Due to its sheer size, it takes a LOT OF TIME for the computer to chug through that huge input:

the problem

There's nothing wrong with the algorithm.

Simply the algorithm didn't expect that the input would look like Juarez.

Can you think of a better way?

No idea yet?

How about, now?

The idea is: You are not shooting the bullet; You are catching it.

A different approach

Let's start thinking backwards!

First, let's create a coordinate system which describes our problem:

coordinate system

If you think of the problem as simply mapping points to points, it becomes a geometry problem.

What we want to do is start at the end and trace back only the necessary crates to the beginning:

backwards

We can do this, because each command is a bijection, aka it has an inverse function. But what is the inverse function in this case?

bijection

Can we make a pure mathematical inverse function which just maps points to points, without stacks?

Inverse function

Before diving head in, let's plan out what cases must the inverse function have:

inverse overview

There are 4 cases, and we'll go over each case separately, to keep it simple.

Case: Those which don't change

no change

Case: Those which slide down

slide down

Case: Those which slide up

slide up

Case: Those which switch stacks

change stacks

Together

Together these cases form a pure function which maps a (x, y) coordinate to a (x', y') coordinate.

the inverse

But, at the end we must figure out the letter for that coordinate from the initial state...

And it is a bit problematic because we just assumed that at the end each stack will be non-empty.

What happens when our assumption is wrong?

What happens if we trace back a non-existing point?

empty stacks

If an end position after mapping does not correspond to a letter, it just means that end position will not exist.

So, in the final lookup, just filter out points which don't map to anything in the input hashmap/array. This way they won't even make it to the newspapers in El Paso.

Implementation in Python

A Python implemenation can be something like:

#!/usr/bin/env python3

# Just turn the top part of the text input into stacks
def parse_stacks(text: str):
    lines = text.splitlines()[:-1]  # drop numbering
    return [
        "".join(
            line[i] for line in lines
            if i < len(line) and line[i].isalpha()
        )
        for i in range(1, len(lines[-1]), 4)
    ]

# Just turn the bottom part of the text input into (n, i, j) tuples
def parse_commands(body: str):
    def to_command(line):
        parts = line.split()
        n = int(parts[1])
        i = int(parts[3]) - 1
        j = int(parts[-1]) - 1
        return (n, i, j)
    return [to_command(line) for line in body.splitlines()]

# Our inverse function
def inverse(n: int, i: int, j: int, p: tuple[int, int]):
    x, y = p
    if x != i and x != j:
        return (x, y)
    if x == i:
        return (x, y + n)
    if x == j and y >= n:
        return (x, y - n)
    return (i, n - y - 1)

def main():
    with open("input.txt") as f:
        content = f.read()
    state_txt, commands_txt = content.split("\n\n")
    stacks = parse_stacks(state_txt)
    commands = parse_commands(commands_txt)
    result = ""
    # Loop over each stack
    for x0 in range(len(stacks)):
        xy = (x0, 0) # (x0, 0) is the top of the x0 stack
        # Loop backwards over all commands and apply their inverses
        for (n, i, j) in reversed(commands):
            xy = inverse(n, i, j, xy)
        x, y = xy
        stack_x = stacks[x]
        result += stack_x[y] if len(stack_x) > y else ""
    print(result)
##############################################################################

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The difference between the stack-based and the backtracing solution is not a clear cut asymptotical O(N^3) vs O(N) thing...

What's the order of N?

N is the number of stacks. Each stack is labeled by a single base-10 digit:

    [D]
[N] [C]
[Z] [M] [P]
 1   2   3
Enter fullscreen mode Exit fullscreen mode

So N is at most 10, easy peasy.

M is the number of crates moved in a single command.

M is made up of digits in base 10.

Q: How many digits?

A: Yes.

If we use this backtracing approach we'll get a slightly lower runtime on that specific input, because the backtrace's time complexity is different from the straightforward solution's:

time complexity

Essentially we just abuse the fact that there's a huge difference between N and M's upper bounds.

The stack-based solution simply choked on a ton of move 38413 from 1 to 6-like commands.

There's a Part 2 of this problem too, where we put back the picked up elements in the same order.

I'm quite sure you'd be able to do Part 2 too.

Or maybe you were the one who solved it for me?

Github runner

There's a Netflix dev who makes funny videos.

What's important is that he has a YouTube channel ThePrimeagen, where he posted a hilarious video about Github runner sleeps.

He argued that the below code is a bad way to implement a sleep:

SECONDS=0
while [[ $SECONDS != $1 ]]; do
    :
done
Enter fullscreen mode Exit fullscreen mode

But he also wrote the stack based solution in Rust, where he did a much more clever sleep:

rust-bash-script-sleep

The sleep is organically weaved into the algorithm itself. It does not rely on shell magic, as the sleep is a natural part of the fabric of Rust.

Compared to the Python, Rust does a better sleep:

daddy-sleep

The sleep is still adjustable, scalable, according to demand:

subscriptions

Silver Tier isn't available due to a one-off error. I'm not good with numbers neither with Python.

Ok, in reality, the Rust author is on one hand funny, but more importantly: he has good intentions, and he's probably a good engineer (I cannot judge as I am not).

He's pretty adamant about his ways though, so he would probably first invert our inverse function:

invert-invert

Then he'd probably see that the original stack based function was impure, because it mutated the stacks:

mutate

On the other hand his handy new move function is pure:

immutable

So he'd take advantage of it, by splitting it into separate parallel workloads. For simplicity's sake below I split them by stacks:

fork

Each i-th thread needs just a single array to hold the boxes of the i-th stack.

But this is just one thing. Another thing is that the function is pure, so it can be tabulated:

tabulation

The moved crates' order is not switched in Part 2 for example, this is why there's no reordering in the illustration.

The truth of the matter is: we are just permuting stuff.

So, given his experience, he would even be able to do more interesting stuff, or might decide that the added overhead of backtrace/parallel/tabulation is not worth it if he gets reliable information about the expected input size, but for us, this is enough. (For backtrace you'd need to be either an offline algorithm, or get the input backwards.)

Back to the original sleep topic though...

I quite liked that engineer's Rust. But the more important thing is that he shared his code. So we are able to see it.

This engineer said that the Github runner's sleep is deranged. One could say that... that shell sleep from the video has shining, just like my Python from Slopistan. But that's shining code which you can see.

How about a suite of software which:

  • you cannot see,
  • is deranged,
  • costs you monthly,
  • you subscribe by being born,
  • you cannot migrate to a different vendor?

Government agencies have code and you are billed by IRS.

All in all:

The original Rust code of that Netflix dev had a function call:

crane_time_me_daddy(file)?;
Enter fullscreen mode Exit fullscreen mode

This kicked off the whole simulation, which - for actual, normal, sane inputs - resulted in good wall-clock times.

Given all of this pure function nonsense we got into...

A slightly more important thing is this, because Netflix daddy dev already made us halfway happy.

Top comments (0)