Skip to content

Advent of Code 2025 Day 0: Preparations

Day 0

At some point in my Advent of Code activities, I noticed Peter Norvig created a set of helper functions to be reused for each year's contest. I liked the idea, so I used some of his functions, and created others of my own. The 2025 version is below. The parse function is particularly handy for dealing with a wide variety of input files.

Source

from attrs import frozen
from collections import Counter, defaultdict, namedtuple, deque
from dataclasses import dataclass, field
from functools import lru_cache, cmp_to_key, reduce, cache
from heapq import heappop, heappush
from itertools import (
    permutations,
    combinations,
    chain,
    count as count_from,
    product as cross_product,
    groupby,
)
from math import ceil, inf, prod, remainder, gcd, floor
from statistics import mean, median
from sys import setrecursionlimit
from typing import Any, Callable, Iterable, Sequence, TypeVar
import datetime as dt
import matplotlib.pyplot as plt
import networkx as nx  # type: ignore
import numpy as np
import re
import timeit


T = TypeVar('T')
U = TypeVar('U')
V = TypeVar('V')


def binary_search(predicate: Callable[[Any], bool], low: int, high: int) -> int | None:
    """
    Return the minimum index, n, for which predicate(n) is True.
    Return None if not found
    """
    found = False

    while high - low > 1:
        n = int((low + high) / 2)  # int() truncates toward zero

        if predicate(n):
            found = True
            high = n
        else:
            low = n

    return high if found else None


def bool_list_to_decimal(lst: list[int]) -> int:
    """
    Convert a list of boolean integers to the decimal equivalent.
    [1, 0, 1, 1] -> 11
    """
    return int("".join([str(n) for n in lst]), 2)


def compose(f: Callable[[U], T], g: Callable[[V], U]) -> Callable[[V], T]:
    """
    Compose two functions
    compose(f, g)(x) == f(g(x))
    """
    return lambda x: f(g(x))


def decimal_to_bool_list(n: int) -> list[int]:
    """
    Convert a decimal number to a list of boolean numbers.
    """
    return [int(digit) for digit in bin(n)[2:]]


def file_to_lines(fname: str) -> list[str]:
    """Read a file and return a list of the lines in the file."""
    with open(fname) as myfile:
        return myfile.read().splitlines()


def findf(pred: Callable[[T], bool], seq: Sequence[T]):
    """
    Return the first element of a sequence satisfying the predicate,
    or None, if none is found.
    """
    return next((x for x in seq if pred(x)), None)


def grid_to_hash(
    lines: list[str],
    elem_filter=lambda x: True,
    elem_transform=lambda x: x,
    row_filter=lambda x: True,
    row_transform=lambda x: x,
) -> dict:
    """
    Grids are very common in Advent of Code puzzles. This function makes it
    easier to convert a puzzles textual input into a dict representation of
    a grid. Allowing transforms and filters of both rows and elements provides
    a lot of flexibility.

    See tests for examples of use.
    """
    return {
        complex(col, row): elem_transform(elem)
        for row, line in enumerate(lines)
        if row_filter(line)
        for col, elem in enumerate(row_transform(line))
        if elem_filter(elem)
    }


def grid_word_search(
    grid: Sequence[Sequence[str]],
    word: str,
    dirs: tuple[complex, ...] = (1 + 0j, 1 + 1j, 1j, -1 + 1j, -1 + 0j, -1 - 1j, -1j, 1 - 1j),
    offset: int = 0,
):
    """
    Generate (x, y, direction) tuples for words in the grid. The
    dirs parameter specifies the allowable orientations, and the
    offset parameter allows shifting the word. Consider a 3-letter
    word. Using an offset of 0 would be typical and match words
    starting at (x, y). Using an offset of -1 would match words
    with the 2nd letter at (x, y).
    """
    width = len(grid[0])
    height = len(grid)
    word_list = list(word)

    def get(c):
        x, y = int(c.real), int(c.imag)
        return grid[y][x] if 0 <= x < width and 0 <= y < height else None

    for dir in dirs:
        for x in range(width):
            for y in range(height):
                if word_list == [get(complex(x, y) + (n + offset) * dir) for n in range(len(word))]:
                    yield (x, y, dir)


def iterate(fun: Callable[[T], T], arg: T, n: int) -> T:
    """Return the result of repeatedly applying fun to arg n times."""
    for _ in range(n):
        arg = fun(arg)

    return arg


def partition(seq: Sequence[T], pred: Callable[[T], bool]) -> tuple[list[T], list[T]]:
    """
    Similar to filter, except a 2-tuple is returned: the items for which
    pred returns True, and the items for which pred returns False.
    """
    yes, no = [], []

    for item in seq:
        (yes if pred(item) else no).append(item)

    return yes, no


# Code from Peter Norvig's Advent of Code utilities


def atom(text: str) -> float | int | str:
    """Parse text into a single float or int or str."""
    try:
        x = float(text)
        return round(x) if round(x) == x else x
    except ValueError:
        return text


def atoms(text: str) -> tuple[float | int | str, ...]:
    """A tuple of all the atoms (numbers or symbol names) in text."""
    return mapt(atom, re.findall(r'[a-zA-Z_0-9.+-]+', text))


def digits(text: str) -> tuple[int, ...]:
    """A tuple of all the digits in text (as ints 0-9), ignoring non-digit characters."""
    return mapt(int, re.findall(r'[0-9]', text))


def ints(text) -> tuple[int, ...]:
    """A tuple of all the integers in text, ignoring non-number characters."""
    return mapt(int, re.findall(r'-?[0-9]+', text))


def mapt(fn, *args):
    """map(fn, *args) and return the result as a tuple."""
    return tuple(map(fn, *args))


def parse(
    day: int,
    parser: Callable[[str], Any] = str,
    sep: str = '\n',
    print_lines: int | None = None,
    root: str = 'app',
) -> tuple:
    """
    Split the day's input file into entries separated by
    `sep`, and apply `parser` to each.
    """
    fname = f'{root}/day{day:02}.txt'
    the_file = open(fname, "r")
    text = the_file.read()
    the_file.close()
    entries = mapt(parser, text.rstrip().split(sep))

    if print_lines:
        all_lines = text.splitlines()
        lines = all_lines[:print_lines]
        head = f'{fname}{len(text)} chars, {len(all_lines)} lines; first {len(lines)} lines:'
        dash = "-" * 100

        print(f'{dash}\n{head}\n{dash}')

        for line in lines:
            print(trunc(line))

        print(
            f'{dash}\nparse({day}) ➜ {len(entries)} entries:\n'
            f'{dash}\n{trunc(str(entries))}\n{dash}'
        )

    return entries


def quantify(iterable: Iterable, pred: Callable[[Any], bool] = bool) -> int:
    """Count the number of items in iterable for which pred is true."""
    return sum(1 for item in iterable if pred(item))


def trunc(s: str, left: int = 70, right: int = 25, dots: str = ' ... ') -> str:
    """All of string s if it fits; else left and right ends of s with dots in the middle."""
    return s if len(s) <= left + right + len(dots) else s[:left] + dots + s[-right:]


def words(text) -> list[str]:
    """A list of all the alphabetic words in text, ignoring non-letters."""
    return re.findall(r'[a-zA-Z]+', text)

Tests

from app.advent import (
    atom,
    atoms,
    binary_search,
    bool_list_to_decimal,
    compose,
    decimal_to_bool_list,
    digits,
    file_to_lines,
    findf,
    grid_to_hash,
    grid_word_search,
    ints,
    iterate,
    mapt,
    parse,
    partition,
    quantify,
    trunc,
    words,
)


def test_binary_search():
    lst = [0, 1, 2, 3, 4, 5, 6, 7, 8]
    idx = binary_search(lambda n: lst[n] >= 3, 0, len(lst) - 1)
    assert idx == 3

    lst = ['apple', 'banana', 'cherry', 'date', 'kiwi', 'strawberry']
    idx = binary_search(lambda n: lst[n] >= 'cherry', 0, len(lst) - 1)
    assert idx == 2


def test_bool_list_to_decimal():
    assert bool_list_to_decimal([1, 0, 1, 1]) == 11


def test_compose():
    def square(n: int) -> int:
        return n * n

    def double(n: int) -> int:
        return 2 * n

    def cast(s: str) -> int:
        return int(s)

    square_double = compose(square, double)
    assert square_double(7) == 196

    square_double_str = compose(square_double, cast)
    assert square_double_str('77') == 23716


def test_decimal_to_bool_list():
    assert decimal_to_bool_list(11) == [1, 0, 1, 1]
    assert decimal_to_bool_list(65535) == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]


def test_file_to_lines():
    lines = file_to_lines('tests/test_advent.py')
    assert len(lines) > 150
    assert 'def test_file_to_lines():' in lines


def test_findf():
    lst = [-3, -4, -7, 5, 4, 3]
    first_positive = findf(lambda n: n > 0, lst)
    assert first_positive == 5
    assert findf(lambda n: n > 10, lst) is None


def test_grid_to_hash():
    #  ..#...#.
    #  ...#....
    #  .#.....#
    #  ##...#..
    lines = ["..#...#.", "...#....", ".#.....#", "##...#.."]
    hsh = grid_to_hash(lines)
    st = set(hsh.keys())

    assert len(st) == 32  # entire 8 x 4 grid
    assert 2 + 2j in st
    assert hsh[3 + 1j] == '#'

    hsh = grid_to_hash(lines, elem_filter=lambda col: col == '#')
    keys = hsh.keys()

    assert len(keys) == 8
    assert (1 + 2j) in hsh
    assert not (2 + 1j) in hsh

    hsh = grid_to_hash(lines, elem_transform=lambda c: "land" if c == '#' else "water")

    assert hsh[7 + 2j] == "land"
    assert hsh[7 + 3j] == "water"

    hsh = grid_to_hash(
        lines, row_transform=reversed, elem_transform=lambda c: "land" if c == '#' else "water"
    )

    assert hsh[+2j] == "land"
    assert hsh[1 + 2j] == "water"


def test_grid_word_search():
    # " xmas "
    # "smm sx"
    # " a am "
    # " smas "
    # " xsx  "
    grid = (" xmas ", "smm sx", " a am ", " smas ", " xsx  ")
    assert list(grid_word_search(grid, "xmas")) == [
        (1, 0, 1),
        (1, 0, (1 + 1j)),
        (1, 0, 1j),
        (5, 1, (-1 + 1j)),
        (3, 4, (-1 - 1j)),
        (1, 4, (1 - 1j)),
    ]


def test_iterate():
    thrice_squared = iterate(lambda n: n * n, 7, 3)
    assert thrice_squared == 5764801


def test_partition():
    pos, neg = partition([1, 4, -7, 5, -9, -1], lambda n: n >= 0)
    assert pos == [1, 4, 5]
    assert neg == [-7, -9, -1]


# Peter Norvig's functions


def test_atom():
    assert atom(" 7 ") == 7
    assert atom(" 7.3 ") == 7.3
    assert atom(" s ") == " s "


def test_atoms():
    assert atoms(" 7,foo   bar: 9.8 ") == (7, "foo", "bar", 9.8)


def test_digits():
    assert digits("3.14 is pi, 2.718 is e") == (3, 1, 4, 2, 7, 1, 8)


def test_ints():
    assert ints("3.14 is pi, 2.718 is e") == (3, 14, 2, 718)


def test_mapt():
    squares = mapt(lambda n: n * n, [1, 2, 3])
    assert squares == (1, 4, 9)


def test_parse():
    """
    See tests/day01.txt and tests/day05.txt for sample data.
    """
    left, right = zip(*parse(1, ints, root='tests'))
    assert left == (95228, 77437, 33685, 99368)
    assert right == (20832, 82573, 76537, 67870)

    rules, updates = parse(5, lambda s: mapt(ints, str.split(s)), sep='\n\n', root='tests')
    assert rules == ((96, 54), (79, 47), (79, 13))
    assert updates == (
        (78, 18, 57, 52, 59, 14, 87, 53, 15, 28, 94),
        (55, 88, 31, 49, 93, 59, 53, 13, 46, 57, 86, 43, 15, 18, 78, 94, 52, 27, 14),
        (33, 19, 35, 67, 62, 21, 47),
    )


def test_quantify():
    assert quantify([1, 2, 3, 4, 5, 6, 8], lambda n: (n % 2) != 0) == 3


def test_trunc():
    assert trunc("beginning to the end", left=3, right=3) == "beg ... end"


def test_words():
    assert words("one, two...three MixedCase") == ["one", "two", "three", "MixedCase"]

End