Imports

Code
import functools
import hashlib
import itertools
import os
import re
import sys
from collections import defaultdict, deque
from pathlib import Path
from queue import PriorityQueue

import more_itertools
import numpy as np
import pandas as pd
import scipy

sys.path.insert(1, os.path.join(sys.path[0], ".."))

import utils

load = utils.year_load(2016)

Day 1: No Time for a Taxicab

Part 1

How many blocks away is Easter Bunny HQ?

Code
instructions = load(1)[0].split(", ")
position = np.array([0, 0])
direction = np.array([0, 1])
rotations = {
    "R": np.array([[0, 1], [-1, 0]], dtype=int),
    "L": np.array([[0, -1], [1, 0]], dtype=int),
}
for instruction in instructions:
    direction = rotations[instruction[0]] @ direction
    position += direction * int(instruction[1:])
sum(abs(position))

Part 2

Then, you notice the instructions continue on the back of the Recruiting Document. Easter Bunny HQ is actually at the first location you visit twice.

Code
position = np.array([0, 0])
direction = np.array([0, 1])
seen = {tuple(position): True}
for instruction in instructions:
    direction = rotations[instruction[0]] @ direction
    for i in range(int(instruction[1:])):
        position += direction
        if tuple(position) in seen:
            break
        seen[tuple(position)] = True
    else:
        continue
    break
sum(abs(position))

Day 2: Bathroom Security

Part 1

Code
commands = {"U": -1j, "D": 1j, "L": -1, "R": 1}
grid = {(x % 3) + 1j * (x // 3): x + 1 for x in range(9)}


def update(point, grid, instructions):
    for instruction in instructions:
        new_point = point + commands[instruction]
        if new_point in grid:
            point = new_point
    return point


point = 1 + 1j
password = ""
instructions = load(2)
for line in instructions:
    point = update(point, grid, line)
    password += str(grid[point])
password

Part 2

Code
grid = {
    0j + 2: 1,
    1j + 1: 2,
    1j + 2: 3,
    1j + 3: 4,
    2j: 5,
    2j + 1: 6,
    2j + 2: 7,
    2j + 3: 8,
    2j + 4: 9,
    3j + 1: "A",
    3j + 2: "B",
    3j + 3: "C",
    4j + 2: "D",
}
point = 2j
password = ""
for line in instructions:
    point = update(point, grid, line)
    password += str(grid[point])
password

Day 3: Squares with three sides

Part 1

Code
data = np.array(load(3, "int"), dtype=int)


def is_valid(triangle):
    x, y, z = triangle
    return x + y > z and x + z > y and y + z > x


sum(map(is_valid, data))

Part 2

Code
sum(map(is_valid, data.T.ravel().reshape(-1, 3)))

Day 4: Security Through Obscurity

Part 1

Code
def parse_line(room):
    checksum = room[-6:-1]
    sector_id = int(room[:-7].split("-")[-1])
    name = "-".join(room.split("-")[:-1])
    return name, sector_id, checksum


def calculate_checksum(name):
    occurrences = list(zip(*np.unique(list(name.replace("-", "")), return_counts=True)))
    return "".join(x[0] for x in sorted(occurrences, key=lambda x: [-x[1], x[0]])[:5])


data = [parse_line(l) for l in load(4)]
sum(
    sector_id
    for name, sector_id, checksum in data
    if calculate_checksum(name) == checksum
)

Part 2

Code
real_rooms = [room[:2] for room in data if calculate_checksum(room[0]) == room[2]]


def decrypt(name, offset):
    alphabet = "abcdefghijklmnopqrstuvwxyz"
    shifted_alphabet = "".join(x for x in np.roll(list(alphabet), -offset % 26))
    return name.translate(str.maketrans(alphabet, shifted_alphabet)), offset


[answer for room in real_rooms if "north" in (answer := decrypt(*room))[0]]

Day 5: How About a Nice Game of Chess?

Part 1

Code
import hashlib

h = hashlib.md5()
prefix = "wtnhxymk"
password = ""
i = 0
while len(password) < 8:
    s = hashlib.md5((prefix + str(i)).encode(encoding="UTF-8")).hexdigest()
    if s[:5] == "0" * 5:
        password = password + s[5]
    i += 1
password

Part 2

Code
password = [None] * 8
i = 0
while any([x is None for x in password]):
    s = hashlib.md5((prefix + str(i)).encode(encoding="UTF-8")).hexdigest()
    if s[:5] == "0" * 5 and s[5] in "01234567" and password[int(s[5])] is None:
        password[int(s[5])] = s[6]
    i += 1
"".join(password)

Day 6: Signals and Noise

Part 1

Code
messages = load(6)
"".join(pd.DataFrame([list(x) for x in messages]).mode().values[0])

Part 2

Code
foo = np.array([list(x) for x in messages])
s = ""
for i in range(foo.shape[1]):
    letters, counts = np.unique(foo[:, i], return_counts=True)
    s += letters[counts.argmin()]
s

Day 7: Internet Protocol Version 7

Part 1

Code
data = load(7)
abba = re.compile(r"(.)(?!\1)(.)\2\1")
bracketed_abba = re.compile(r"\[[^]]*(.)(?!\1)(.)\2\1.*?\]")


def supports_tls(haystack):
    return bool(re.search(abba, haystack)) and not bool(
        re.search(bracketed_abba, haystack)
    )


sum(supports_tls(line) for line in data)

Part 2

Part two is more regex wrangling, except the patterns can overlap now. We could spend time figuring out exactly how to account for that, or we can import the third party regex module which does it for us automagically.

Code
import regex


def supports_ssl(haystack):
    aba = regex.compile(r"(.)(?!\1)(.)\1")
    bracket_split = [x.split("[") for x in haystack.split("]")]
    outside, inside = itertools.zip_longest(*bracket_split, fillvalue="")
    abas = [
        match
        for fragment in outside
        for match in regex.findall(aba, fragment, overlapped=True)
    ]
    for a, b in abas:
        bab = f"{b}{a}{b}"
        if any(bab in fragment for fragment in inside):
            return True
    return False


sum(supports_ssl(line) for line in data)

Day 8: Two-Factor Authentication

Part 1

Code
array = np.zeros((6, 50), dtype=int)
lines = [x.split() for x in load(8)]
for instructions in lines:
    if instructions[0] == "rect":
        row, col = [int(a) for a in instructions[1].split("x")]
        array[:col, :row] = 1
        continue
    row = int(instructions[2].split("=")[1])
    amount = int(instructions[-1])
    if instructions[1] == "column":
        array = array.T
    array[row] = np.roll(array[row], amount)
    if instructions[1] == "column":
        array = array.T
array.sum()

Part 2

Code
[["".join("█" if char else " " for char in line)] for line in array]

Day 9: Explosives in Cyberspace

Part 1

Code
data = load(9)[0]
part1 = data


def count(s, part2=False):
    total = 0
    while s:
        if s[0] != "(":
            total += 1
            s = s[1:]
            continue
        end = s.index(")")
        chars, repeat = map(int, s[1:end].split("x"))
        s = s[end + 1 :]
        if part2:
            total += repeat * count(s[:chars], True)
        else:
            total += repeat * chars
        s = s[chars:]
    return total


count(data)

Part 2

Code
count(data, part2=True)

Day 10: Balance Bots

Part 1

Code
data = load(10)
wiring = {}
state = defaultdict(list)
for line in data:
    command = re.findall("(bot|value|output) (\d+)", line)
    numbers = [int(x[1]) for x in command]
    names = [x[0] for x in command]
    if len(command) == 2:
        state[numbers[1]].append(numbers[0])
    else:
        wiring[numbers[0]] = [x for x in zip(names[1:], numbers[1:])]

queue = deque([x for x in start if len(state[x]) == 2])
output = [0] * 21


def step():
    current = queue.popleft()
    values = sorted(state[current])
    state[current] = []
    left, right = wiring[current]
    for idx, (name, value) in enumerate(wiring[current]):
        if name == "bot":
            state[value].append(values[idx])
            if len(state[value]) == 2:
                queue.append(value)
        else:
            output[value] = values[idx]
    return current, values


while True:
    current, values = step()
    if values == [17, 61]:
        break
current

Part 2

With Part 1 out of the way, part 2 is just

Code
while queue:
    step()
np.product(output[:3])

Day 11: Radioisotope Thermoelectric Generators

Part 1

This one looks difficult, but I don’t think it is too tricky. Given that we are in floor \(n\), the valid next positions are us at floor \(n+1\) or \(n - 1\), with up to two items moved; with the items moved being subject to the puzzle constraints.

So I think the way to go is A*.

Code
from more_itertools import grouper

n_floors = 4


def distance_estimate(state, end):
    items = state[1]
    return sum((val / 2) * (n_floors - i - 1) for i, val in enumerate(items))


def is_valid(items):
    generators, chips = state[::2], state[1::2]
    return all(
        (chip == generator) or (chip not in generators)
        for chip, generator in zip(chips, generators)
    )


def normalize(items):
    return tuple(x for pair in sorted(list(grouper(items, 2))) for x in pair)


def constrained_neighbors(state):
    floor, items = state
    active_indices = [index for index, val in enumerate(items) if val == floor]
    neighbors = set()
    for new_floor in [floor + 1, floor - 1]:
        if not (0 <= new_floor < n_floors):
            continue
        moves = [[x] for x in active_indices]
        if new_floor == floor + 1:
            moves = itertools.chain(moves, itertools.combinations(active_indices, 2))
        for move in moves:
            new_items = list(items)
            for index in move:
                new_items[index] = new_floor
            if is_valid(new_items):
                neighbors.add((new_floor, normalize(new_items)))
    return neighbors


state = 0, (0, 0, 0, 0, 1, 1, 1, 1, 1, 2)
target = 3, (3,) * len(state[1])
utils.astar(state, target, constrained_neighbors, distance_estimate)

Part 2

Extending this to part 2 without changing anything is possible, but the whole thing takes about a minute and a half to run. When I have time, I’ll come back and look at it again.

Reducing the search space by only letting the elevator move down with one item at a time reduced the runtime to about half. I’m not 100% convinced the restriction is always valid, but it did work in this case.

Code
state = 0, (0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2)
target = 3, (3,) * len(state[1])
utils.astar(state, target, constrained_neighbors, distance_estimate)

Day 12: Leonardo’s Monorail

Part 1

This is a fairly straightforward implementation of the problem description, with no particular cleverness going on. We have two types of instructions - ones that take two operands, and ones that take only one, and we can treat those together.

Code
def run(program, registers=None):
    if registers is None:
        registers = defaultdict(int)
    ip = 0
    while ip < len(program):
        instruction = program[ip]
        operator, operands = instruction[0], instruction[1:]
        if operator in ["cpy", "jnz"]:
            source, destination = operands
            value = int(source) if source not in "abcd" else registers[source]
            if operator == "cpy":
                registers[destination] = value
            if operator == "jnz" and value != 0:
                ip += int(destination) - 1
        elif operator in ["inc", "dec"]:
            registers[operands[0]] += 2 * (operator == "inc") - 1
        ip += 1
    return registers["a"]


data = [line.split(" ") for line in load(12)]
run(data)

Part 2

Code
registers = defaultdict(int)
registers["c"] = 1
run(data, registers)

Day 13: A Maze of Twisty Little Cubicles

Part 1

Code
from utils import astar


def is_valid(x, y, secret=1362):
    if x < 0 or y < 0:
        return False
    val = x * x + 3 * x + 2 * x * y + y + y * y + secret
    ones = f"{val:b}".count("1")
    return (ones % 2) == 0


def neighbors(state):
    x, y = state
    candidates = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]
    return [candidate for candidate in candidates if is_valid(*candidate)]


def distance_function(point, target):
    return abs(point[0] - target[0]) + abs(point[1] - target[1])


start = (1, 1)
target = 31, 39
utils.astar(start, target, neighbors, distance_function)

Part 2

Code
len(utils.bfs((1, 1), lambda cost, state: cost > 50, neighbors, return_visited=True))

Day 14: One-Time Pad

Part 1

Code
import hashlib


def infinite_triples(prefix, part=1):
    r1 = r"(.)\1\1"
    r2 = r"(.)\1\1\1\1"
    n = 1
    while True:
        s = hashlib.md5((prefix + str(n)).encode()).hexdigest()
        if part == 2:
            for i in range(2016):
                s = hashlib.md5(s.encode()).hexdigest()
        if r := re.search(r1, s):
            yield (r.groups(1)[0], re.findall(r2, s))
        else:
            yield False
        n += 1


def nth_key_index(prefix, n=64, part=1):
    triples = filter(lambda x: x[1], enumerate(infinite_triples(prefix, part)))
    window = [next(triples)]
    current = 0
    while current < n:
        idx, (triple, _) = window.pop(0)
        while not window or window[-1][0] < idx + 1000:
            window.append(next(triples))
        active_quints = [char for triple in window[:-1] for char in triple[1][1]]
        if triple in active_quints:
            current += 1
    return idx + 1


nth_key_index("yjdafjpo")

Part 2

I was a little uncertain about how to write this cleanly – all of the logic from part one is the same, the only difference is how the hash is generated. In the end, I made a toggle in the infinite_triples function, which is why part 2 can be solved by writing just:

Code
nth_key_index("yjdafjpo", part=2)

Day 15: Timing is Everything

Part 1

Another round of the chinese remainder theorem.

Code
from utils import crt

data = [[int(x) for x in re.findall(r"\d+", line)] for line in load(15)]
remainders = [(x[1], -(x[-1] + x[0])) for x in data]
crt(remainders)

Part 2

Code
remainders.append([11, -(len(remainders) + 1)])
crt(remainders)

Day 16: Dragon Checksum

Part 1

Code
start = [1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1]
length = 272


def solve(prefix, length):
    while len(prefix) < length:
        prefix = prefix + [0] + [1 ^ x for x in prefix[::-1]]
    s = prefix[:length]
    while len(s) % 2 == 0:
        s = abs(np.diff(s))[::2] ^ 1
    return s


print(*solve(start, length), sep="")

Part 2

Code
print(*solve(start, 35651584), sep="")

Day 17: Two Steps Forward

Part 1

BFS to the rescue. I wanted to do A*, but the “distance from 3,3” heuristic didn’t seem like it would give much. Then I dropped to Dijkstra, but realised that if all steps cost the same, that’s just BFS.

Code
start = (0, "bwnlcvfs")


def neighbors(position, path):
    chars = hashlib.md5(path.encode()).hexdigest()[:4]
    directions = "UDLR"
    deltas = -1j, 1j, -1, 1
    candidates = [
        (position + delta, path + direction)
        for delta, direction, char in zip(deltas, directions, chars)
        if char in "bcdef"
    ]
    return [
        candidate
        for candidate in candidates
        if 0 <= candidate[0].real < 4 and 0 <= candidate[0].imag < 4
    ]


q = deque([start])
while q:
    position, path = q.popleft()
    if position == 3 + 3j:
        result = path[len(start[1]) :]
        break
    q += deque(neighbors(position, path))
result

Part 2

Code
q = deque([start])
i = 0
while q:
    position, path = q.popleft()
    if position == 3 + 3j:
        result = len(path) - len(start[1])
        continue
    q += deque(neighbors(position, path))
    i += 1
result

Day 18: Like a Rogue

Part 2

Code
data = np.array([1 if char == "^" else 0 for char in load(18)[0]], dtype=int)
left_right = [1, 0, 1]
rows = []
for i in range(40):
    rows.append(data)
    data = (scipy.ndimage.convolve(data, left_right, mode="constant") == 1).astype(int)
(np.array(rows) == 0).sum()

Part 2

For part 2 I should probably check to see if I ever hit a row that I’ve seen before, and then use the repeated cycle to avoid having to calculate that many rows. Or I can just brute force it and not care:

Code
for i in range(400000 - 40):
    rows.append(data)
    data = (scipy.ndimage.convolve(data, left_right, mode="constant") == 1).astype(int)
(np.array(rows) == 0).sum()

Day 19: An Elephant Named Joseph

Part 1

This problem – with rotations by 1 and deletions only of neighboring elves is definitely calling for a deque:

Code
limit = 100
numbers = list(range(1, limit + 1))
queue = deque(numbers)
while queue:
    queue.rotate(-1)
    s = queue.popleft()
s

Part 2

Unfortunately, the same approach won’t work here, since the rotations to the middle of the queue really ruin everything.

What we can do instead is notice that the pattern of deletions correspond to leaving every third elf alive, starting just after halfway around the circle. To avoid interference from potentially dead elves, we can play the game in rounds, with one round ending whenever an elf at the start of the line would have died. In each round then, a number of elves at the start of the line get to take presents, a number in the middle do nothing, and a number at the end are eliminated from the game. What each of these lists looks like is not too hard to determine:

Code
def one_round(mylist):

    l = len(mylist)
    n = int((l + 2) / 3)
    middle = mylist[int(l / 2) + 2 - l % 2 :: 3]
    return mylist[n : int(l / 2)] + middle + mylist[:n]


i = 3005290
x = list(range(1, i + 1))
while len(x) > 1:
    x = one_round(x)
x[0]

Day 20: Firewall Rules

Part 1

Code
data = sorted(
    [[int(x) for x in line.split("-")] for line in load(20)], key=lambda x: x[0]
)
low, high = data[0]
for new_low, new_high in data[1:]:
    if high + 1 < new_low:
        break
    else:
        high = max(high, new_high)
high + 1

Part 2

We’ll start by merging the overlapping banned ranges together. Then, the high point of one range and the low point of the next range define a range of allowed values (endpoints not included). We can sum the length of these to get the total number of allowed values.

Code
def merge_ranges(data):
    result = []
    initial, final = data[0]
    for low, high in data[1:]:
        if final + 1 >= low:
            final = max(high, final)
        else:
            result += [initial, final]
            initial, final = low, high
    result += [initial, final]
    return result


(
    0
    - ranges[0]
    + sum([high - low - 1 for low, high in zip(ranges[1::2], ranges[2::2])])
    + 4294967295
    - ranges[-1]
)

Day 21: Scrambled Letters and Hash

Part 1

Code
data = [x.split() for x in load(21)]
s = "abcdefgh"


def update(s, line, part=1):
    operands = line[2], line[-1]
    if line[0] == "reverse":
        l, r = sorted(map(int, operands))
        s = s[:l] + s[l : r + 1][::-1] + s[r + 1 :]
    elif line[0] == "swap":
        if line[1] == "letter":
            l, r = map(lambda x: s.index(x), operands)
        else:
            l, r = map(int, operands)
        l, r = sorted([l, r])
        s = s[:l] + s[r] + s[l + 1 : r] + s[l] + s[r + 1 :]
    elif line[0] == "rotate":
        if line[1] == "left":
            rotation = -int(operands[0])
        elif line[1] == "right":
            rotation = int(operands[0])
        else:
            if part == 1:
                index = s.index(operands[1])
                rotation = 1 + index + (index >= 4)
            if part == 2:
                rotation = reverse_rotation(s, operands[1])
        if part == 2:
            rotation = -rotation
        rotation = rotation % len(s)
        s = s[-rotation:] + s[:-rotation]
    elif line[0] == "move":
        source, dest = map(int, operands)
        if part == 2:
            source, dest = dest, source
        tmp = s[:source] + s[source + 1 :]
        s = tmp[:dest] + s[source] + tmp[dest:]
    return s


for line in data:
    s = update(s, line)

s

Part 2

Ouch. move, swap and reverse should be easy to do backwards, since they’re self-inverses (potentially with the arguments swapped). The issue is rotate. When we have to go left/right a fixed number of steps there’s no problem, since we just go the other way. For the last one the issue is that the amount we have to rotate by depends on what the state was prior to the rotation. Luckily there aren’t that many possible rotations, so the best approach seems to be to just to see which potential preimage string gives the correct answer when rotated.

Code
def rotate_based_on(s, char):
    index = s.index(char)
    rotation = 1 + index + (index >= 4)
    rotation = rotation % len(s)
    return s[-rotation:] + s[:-rotation]


def reverse_rotation(s, char):
    for i, original_string in [(i, s[-i:] + s[:-i]) for i in range(len(s))]:
        if rotate_based_on(original_string, char) == s:
            return -i


s = "fbgdceah"
for line in data[::-1]:
    s = update(s, line, part=2)
s

Day 22: Grid Computing

Part 1

Code
data = load(22, "int")
data.sort(key=lambda x: x[-2])


def binary_search(key, haystack):
    key = key[3]
    left, right = 0, len(haystack)
    while (right - left) > 1:
        midpoint = int((left + right) / 2)
        if haystack[midpoint][-2] >= key:
            right = midpoint
        else:
            left = midpoint
    return right


result = 0
for idx1, val in enumerate(data):
    if val[3] == 0:
        continue
    idx2 = binary_search(val, data)
    result += len(data[idx2:]) + (idx2 <= idx1)
result

Part 2

Just another graph search. There’s only one empty space, so we can uniquely define the current state by the location of the empty space, and the location of the data we’re trying to move.

Code
size = np.array(data)[:, :2].max(axis=0)
grid = np.ones(size + 1, dtype=int)
data = np.array(data)
grid[tuple(data[np.where(data[:, 2] > 200)][:, :2].T)] = -1
source = tuple(data[-1][:2])
grid[source] = 0
target = size[0], 0


def heuristic(x, y):
    to_data = abs(x[0] - y[0]) + abs(x[1] - y[1])
    return 4 * (abs(y[0]) + abs(y[1]) - 1) + to_data - 1


def neighbors(x, y):
    new_states = []
    for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
        new_x = x[0] + dx, x[1] + dy
        if (
            new_x[0] < 0
            or new_x[1] < 0
            or new_x[0] > size[0]
            or new_x[1] > size[1]
            or grid[new_x] == -1
        ):
            continue
        if new_x == y:
            new_states.append((new_x, (x[0], x[1])))
        else:
            new_states.append((new_x, y))
    return new_states


queue = PriorityQueue()
queue.put((0, source, target))
costs = defaultdict(lambda: np.inf)
costs[source, target] = 0
i = 0
while queue.qsize() > 0:
    i += 1
    _, x, y = queue.get()
    if y == (0, 0):
        result = costs[x, y]
        break

    for a, b in neighbors(x, y):
        current_cost = costs[x, y] + 1
        if current_cost < costs[a, b]:
            costs[a, b] = current_cost
            queue.put((current_cost + heuristic(a, b), a, b))
result

Day 23: Safe Cracking

Part 1

Code
toggle_map = {"inc": "dec", "tgl": "inc", "dec": "inc", "cpy": "jnz", "jnz": "cpy"}


def run(program, registers=None):
    if registers is None:
        registers = {"a": 0, "b": 0, "c": 0, "d": 0}

    def extract_operands(ip):
        instruction = program[ip]
        return instruction[0], instruction[1:]

    def evaluate_operand(x):
        return int(x) if x not in "abcd" else registers[x]

    ip = 0
    i = 0
    while ip < len(program):
        i += 1
        operator, operands = extract_operands(ip)
        if operator in ["cpy", "jnz"]:
            source, destination = operands
            value = evaluate_operand(source)
            if operator == "cpy":
                registers[destination] = value
            if operator == "jnz" and value != 0:
                ip += evaluate_operand(destination) - 1
        elif operator in ["inc", "dec"]:
            registers[operands[0]] += 2 * (operator == "inc") - 1
        elif operator == "tgl":
            destination = ip + evaluate_operand(operands[0])
            try:
                operator, operands = extract_operands(destination)
                operator = toggle_map[operator]
                program[destination] = [operator] + operands
            except IndexError:
                pass
        ip += 1
    return registers["a"]


data = [line.split(" ") for line in load("23")]
registers = {"a": 7, "b": 0, "c": 0, "d": 0}
run(data, registers)

Part 2

Just setting the registers to registers = {"a": 12, "b": 0, "c": 0, "d": 0} didn’t work, since the code was running incredibly slowly. I ended up analysing my input script instead. The first line copied a to b, and lines 2-16 multiplied a by (b - 1), decreased b by one and set c to 2b (ish). Then came the toggle instruction, and the two instructions after that sent us back to line 2.

Some things that stood out here were that c was an even number, decreasing by 2 each iteration so tgl c tried to toggle every other instruction, starting at the end of the program and moving back towards the tgl instruction itself. That means that the loop before the toggle instruction is unaffected for a long time, and so after the ith iteration we have a = n * (n - 1) * (n - 2) * … * (n - i). This continues until b = 2, when the tgl instruction finally toggles the jnz on line 17. At that point we have a = factorial(n). The (now-toggled) last section of the program then just adds the product of the numeric arguments on line 21 and 22.

And that’s the final answer.

Day 24: Air Duct Spelunking

Part 1

This was a bit of a roller coaster. I originally used my pre-existing bfs code to search the maze, and it was unbelievably slow. Instead of investigating I decided to try and transform the maze, and found a conceptual approach which was horribly over-engineered, but probably would have worked. Before I finished implementing it, I thought of trying another BFS, less general and hence faster, and it ran more than fast enough. Oh well.

I still liked the original approach though. The idea was to simplify the graph of the maze via the following transformations:

  1. Delete all empty (non-goal) nodes with only two neighbors by directly connecting their neighbors with a single edge, with a weight \(w = w_1 + w_2\)
  2. Recursively remove all empty dead ends.
  3. Identify bottlenecks in the graph, i.e. nodes whose removal would disconnect the graph. From there, generate the block-cut tree of the graph, and simplify each component of the block-cut to just the cut vertices and the goal nodes. This basically means that the problem of finding the shortest path X->Y is reduced to finding the shortest path to and from a given bottleneck, and then stitching the paths together.

It was fun to think about even though I didn’t use it in the end.

Code
from scipy.cluster.hierarchy import DisjointSet

parse = lambda x: -2 if x == "#" else -1 if x == "." else int(x)
data = np.array([[parse(char) for char in line] for line in load(24)])


def encode(mask):
    return set([x + 1j * y for x, y in zip(*np.where(mask))])


nodes = np.where(data >= 0)
order = data[nodes].argsort()
nodes = [x + 1j * y for x, y in zip(*[index[order] for index in nodes])]
distances = np.ones((len(nodes), len(nodes))) * np.inf
node_indices = {n: idx for idx, n in enumerate(nodes)}
open_squares = encode(data > -2)
graph = defaultdict(list)

for square in open_squares:
    for delta in (1, 1j):
        if square + delta in open_squares:
            graph[square].append(square + delta)
            graph[square + delta].append(square)

for node in nodes:
    idx = node_indices[node]
    queue = deque([(0, node)])
    visited = set()
    while queue and (distances[idx] == np.inf).any():
        cost, state = queue.popleft()
        if state in visited:
            continue
        visited.add(state)
        if state in nodes:
            new_idx = node_indices[state]
            distances[idx, new_idx] = cost
            distances[new_idx, idx] = cost
        for neighbor in graph[state]:
            if neighbor not in visited:
                queue.append((cost + 1, neighbor))

minval = np.inf
for permutation in itertools.permutations(range(1, len(nodes))):
    indices = (0,) + permutation[:-1], permutation
    if (total := sum(distances[indices])) < minval:
        minval = total
int(minval)

Part 2

Code
minval = np.inf
for permutation in itertools.permutations(range(1, len(nodes))):
    indices = (0,) + permutation, permutation + (0,)
    if (total := sum(distances[indices])) < minval:
        minval = total
int(minval)

Day 25: Clock Signal

Part 1

This is an interesting problem, because it requires more thinking and less coding. Blindly running the code provided in my input leads to infinite loops, and the question is then how to analyse these. In particular, we’re asked for an input that matches an infinite sequence of alternating ones and zeros, and we don’t really have any way of knowing that a sequence that looks promising doesn’t start to diverge after 100, 1000 or even 1,000,000 terms. Instead, I decided to analyse the provided code and understand what it was doing. After a bit of conversion, I found it to be equivalent to the following snippet:

def clock(start):
    a = 0
    while True:
        if a == 0:
            a = start + 2550
        yield a % 2
        a = a // 2

But that’s just the binary representation of (start + 2550) reversed and repeated endlessly. So we’re looking for the smallest number \(n\) such that n + 2550 has a binary representation of the form \(101010\ldots\)

Code
x = 2
while x < 2550:
    x = 4 * x + 2
x - 2550