Imports

Code
import dataclasses
import functools
import itertools
import math
import operator
import os
import re
import sys
from collections import defaultdict, deque, namedtuple
from queue import PriorityQueue

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

sys.path.insert(1, "..")

import utils

load = utils.year_load(2025)

Day 1: Secret Entrance

Part 1

We’re working in numbers mod 100. Left turns correspond to subtraction, and right turns correspond to addition. To find the position of the dial after a set of turns we just take the cumulative sum of the additions and subtractions (mod 100). Then we just check how many times this position is zero

Code
data = np.array([50] + [int(x.replace("L", "-").replace("R", "")) for x in load(1)])
(np.cumsum(data) % 100 == 0).sum()

Part 2

To find the number of times we’ve crossed zero, we can just look at how many times the hundreds place of the running sum has changed - every time that happens, we’ve crossed the value 0 on the dial. This is almost the right answer but needs to be corrected for the fact that sometimes we just kiss zero and don’t cross it. For example, if the dial went 10 -> 0 -> 10, the divisor test wouldn’t show a zero crossing. Similarly, if we went 90 -> 0 -> 90, the divisor test would show two crossings instead of one.

A bit of thinking shows that the relevant points are those where

  1. The total sum is zero after a move
  2. The dial moves in the opposite way in the next move.

We can find both of those cases using numpy magic, and the necessary expression then becomes

Code
sum(
    abs(np.diff(np.cumsum(data) // 100))
    + ((np.cumsum(data) % 100 == 0)[:-1] * np.diff(np.sign(data)) // 2)
)

Day 2: Gift Shop

Part 1

There’s probably a clever approach here, but I’m not seeing it right now – I’m guessing that comes with adventing at 9pm after a long day at work. I’ll just iterate over the ranges, and for each range, iterate over the numbers in the range. For every number in the range, I’ll check whether str(number) == str(number)[:length//2] * 2. It’s slow, but it works

Code
ranges = [[int(y) for y in x.split("-")] for x in load(2)[0].split(",")]

def invalid_ids(part=1):
    total = 0
    for val in itertools.chain(*[range(start, end + 1) for start, end in ranges]):
        length = len(str(val))
        repeats = [2] if part == 1 else proper_factors(length)
        if any(str(val)[: length // repeat] * repeat == str(val) for repeat in repeats):
            total += val
    return total

invalid_ids()

Part 2

The advantage of organising part 1 like that is that part 2 can be included in the same function with a small flag to switch between the two code paths. The only thing that’s left to do is to implement a factorisation function and we are done.

Code
@functools.cache
def proper_factors(n):
    f = [list(set((i, n // i))) for i in range(2, int(math.sqrt(n)) + 1) if n % i == 0]
    return sorted([x for pair in f for x in pair] + ([n] if n != 1 else []))

invalid_ids(part=2)