Advent of Code 2023

Published November 30, 2023

Tags: codeadvent python pyscript

Where does the time go?? Surely it can't be time for another Advent of Code challenge? Alas, it's once again December, which means 25 more mini coding challenges for the next twenty five days. This will be my sixth year particpating in advent of code, and my fourth writing about it - you can check out my previous years' Advent of Code posts as well, if you like.

As in years before, I'll be writing my solutions to run in PyScript right here in the browser window. Feel free to enter your own AoC input and test your answers right here on this page! All the computations run in your own browser window, so no AoC data is transmitted to me or any server, and there's zero overhead on my end, so test as many times as you like.

Personally, I have three goals in presenting these solutions: learn form the challenges themselves, dogfood the use of PyScript to learn where our gaps in functionality are, and demonstrate the use of Python in the browser. Here's hoping we can achieve all three.

Table of Contents

Day 0: Testing the Machinery

As with past years, I'm making use of Hugo's templating system to allow me to quickly write and share each day's code. The setup will look much like this, with a brief explanation here, the code below, and an option to run live demos. The get_input function handles getting input from the textarea or file upload.

  • day0.py
1
2
3
4
5
6
from pyscript import display
from utils import get_input

def main_day0(*args):
    data = get_input("day0")
    display(data, target="day0-output")

Day 1: Trebuchet?! (Part 1)

In most years, the first day's problems aren't too complicated - they tend to focus on reading input and parsing strings, and this year's is no exception.

I opted to pursue a solution purely using regex, since it's always good to stretch those muscles early on in Advent of Code. I couldn't quite find the right pattern to match both the case where there are are multiple numerals in the input line or only one, so I cheated a bit and split them into two separate cases.

Ultimately, this solution probably could have looked more like the solution to part 2 - using re.finditer() to find all the digits in a given line, then sorting that collection to find the earliest and latest digits, but it felt good to refresh on regex matching.

  • day1_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from pyscript import display
from utils import get_input

import re

def main_day1_1(*args):
    data = get_input("day1_1").split("\n")
    
    sum = 0
    pattern = re.compile("[a-zA-Z]*(?P<first>\d)\w*(?P<last>\d)[a-zA-Z]*")
    single_digit_pattern = re.compile("[a-zA-Z]*(?P<first>\d)\w*")

    for line in data:
        match = re.match(pattern, line)
        if not match: match = re.match(single_digit_pattern, line)
        number = 10 * (first:= int(match.group("first"))) + (int(match.group("last")) if "last" in match.groupdict() else int(first))
        sum += number

    display(sum, target="day1_1-output")     
Scroll to see complete code

Day 1: Trebuchet?! (Part 2)

"The strings which represent a given numeral" feels like something that should be included in Python's 'batteries included', but as far as I can tell, it isn't. Rather than pull something in from PYPI, I created a tiny dictionary matching numerals to strings. After finding all the literal numerals and 'spelled-out' numerals in each line, we find the earliest and latest match, grab their value with the tiny get_value() function, and concatenate them to get the value of that line.

  • day1_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pyscript import display
from utils import get_input

import re

patterns = {
    "one": 1,
    "two": 2,
    "three": 3,
    "four": 4,
    "five": 5,
    "six": 6,
    "seven": 7,
    "eight": 8,
    "nine": 9,
    }

digit_pattern = re.compile('\d')

def get_value(s):
    try:
        return int(s)
    except ValueError:
        return patterns[s]

def main_day1_2(*args):
    data = get_input("day1_2").split("\n")

    sum = 0
    
    for line in data:
        line_matches = []

        for pattern in patterns:
            line_matches.extend(re.finditer(pattern, line))

        line_matches.extend(re.finditer(digit_pattern, line))
        line_matches.sort(key = lambda m: m.start())
        sum += 10 * get_value(line_matches[0].group(0)) + get_value(line_matches[-1].group(0))

    display(sum, target="day1_2-output")  

import sys
if not 'js' in sys.modules:
    main_day1_2()
Scroll to see complete code

Day 2: Cube Conundrum (Part 1)

Happy once again that I picked up the regex track early this year! The specific syntax around things like named group, non-capturing groups, findall vs finditer is easy to forget if you haven't touched it in a few months.

Today's challenge was pretty simple - iterating over lines of input text and, based on some condition, adding a sentinel number to a given total. There's probably a cleaner way to break out of that nest-inner-loop than using a flag, but it works.

  • day2_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    print("Getting local input")    
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            dict.pop('target')
        print(*args, **kwargs)

import re
    

def main_day2_1(*args):
    data = get_input("day2_1").split("\n")

    game_pattern = re.compile("Game (?P<day_number>\d+)")
    color_pattern = re.compile("(?P<quantity>\d+) (?P<color>(?:red)|(?:green)|(?:blue))")
    
    sum = 0
    for line in data:
        id_value = int(re.match(game_pattern, line).group("day_number"))
        pulls = line.split(";")
        valid_line = True
        for p in pulls:
            if not valid_line: break
            colors = re.finditer(color_pattern, p)
            for c in colors:
                color, quant = c.group('color'), int(c.group('quantity'))
                if (color == 'red' and quant > 12) or (color == 'green' and quant > 13) or (color == 'blue' and quant > 14): 
                    valid_line = False
                    id_value = 0
                    break                    
        sum += id_value
    
    display(sum, target="day2_1-output")

# Only runs if not running in the browser
import sys
if not 'js' in sys.modules: 
    main_day2_1()
Scroll to see complete code

Day 2: Cube Conundrum (Part 2)

The second part of today's challenge isn't too different from the first; we iterate over all the parts of each line of the input and, for each line, calculate the appropriate product of the minmum number of cubes of each color.

In both parts of today's challenge, I've found it useful to run my code locally for testing before popping it into PyScript. You can see my strategy for this in both parts of today's solution - similar to what Pyodide recommends, we can check if the js module is in sys.modules. If it is, we can assume we're running in PyScript/in the Browser; if not, we're likely in "normal" desktop python.

The need to override display() was the original reason I had implemented the output attribute of PyScript tags in previous releases of PyScript... perhaps I'll want to do that again.

  • day2_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re
    
def main_day2_2(*args):
    data = get_input("day2_2").split("\n")
    color_pattern = re.compile("(?P<quantity>\d+) (?P<color>(?:red)|(?:green)|(?:blue))")
    
    sum = 0
    for line in data:
        max_red, max_green, max_blue = 0, 0, 0

        colors = re.finditer(color_pattern, line)
        for c in colors:
            color, quant = c.group('color'), int(c.group('quantity'))
            if color == 'red' and quant > max_red: max_red = quant
            if color == 'green' and quant > max_green: max_green = quant
            if color == 'blue' and quant > max_blue: max_blue = quant     
        power = max_red * max_green * max_blue
        sum += power
    
    display(sum, target="day2_2-output")

# Only runs if not running in the browser
import sys
if not 'js' in sys.modules:    
    main_day2_2()
Scroll to see complete code

Day 3: Gear Ratios (Part 1)

Advent of Code has a history of requiring the parsing of data in grids, and this year we're starting early. While it's tempting to build a data structure that represents each position in the grid and is querable, for sparsely populated grid I've ususally found it's easier and faster to build a set of the relevant points and query for presence in that set. This handles a lot of edge cases automatically - like in this case, a part number being adjacent to the edge of the grid.

Once we have a collection of the symbols and numbers ('parts') in the input, we can iterate over each number and determine whether any of the coordinates corresponding to 'symbols' are adjacent to their positions, then sum them up.

  • day3_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            dict.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass
from collections import namedtuple
import re
from typing import List

position = namedtuple('position', ['line', 'char'])

@dataclass
class Number:
    value: int
    line: int
    start: int
    end: int
    
def main_day3_1(*args):
    data = get_input("day3_1").split("\n")

    symbols = set()
    part_numbers: List[Number] = []

    symbol_pattern = re.compile("[^0123456789.]")
    number_pattern = re.compile("(\d+)")

    for line_index, line in enumerate(data):
        for s in re.finditer(symbol_pattern, line):
            symbols.add((line_index, s.start()))
        for n in re.finditer(number_pattern, line):
            part_numbers.append(Number(
                    value=int(n.group()),
                    line = line_index,
                    start = n.start(),
                    end = n.end()))
    
    sum = 0
    for number in part_numbers:
        if (number.line, number.start-1) in symbols or \
        (number.line, number.end) in symbols:
            sum += number.value
            continue

        for index in range(number.start-1, number.end+1):
            if (number.line-1, index) in symbols or\
            (number.line+1, index) in symbols:
                sum += number.value
                continue
    display(sum, target="day3_1-output")

# Only runs if not running in the browser
import sys
if not 'js' in sys.modules:   
    main_day3_1()
Scroll to see complete code

Day 3: Gear Ratios (Part 2)

I do like dataclasses - having autocomplete on the handful of elements in a simple "data-like" object is an easy way to prevent confusion and typos. You'll see on this day's problem I've actually combined both Dataclasses and namedtuples; the latter being easier to use as the keys of a dictionary. But if there's any Mutable data - in my case, a flag which helps track whether a specific gear has seen zero, one, or two or more times already - dataclass is quite useful.

With that change to the parsing, the solution is to again iterate over all of the numbers, find which correspond to the given criteria, and sum them up.

  • day3_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            dict.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass
from collections import namedtuple
import re
from typing import List, Dict

position = namedtuple('position', ['line', 'char'])

@dataclass
class Gear:
    value: int = 0
    seen_once: bool = False 

@dataclass
class Number:
    value: int
    line: int
    start: int
    end: int

def processGear(g: Gear, part_number: int):
    """_summary_

    Args:
        g (Gear): The gear currently being processed
        part_number (int): The part number being processed

    Returns: The value to be added to the running sum, if any
    """
    if g.seen_once:
        g.value *= part_number.value
        g.seen_once = False # Seen twice
        return g.value
    else:
        if g.value == 0: 
            g.value = part_number.value
            g.seen_once = True

        return 0

def main_day3_2(*args):
    data = get_input("day3_2").split("\n")

    gears: Dict[position: Gear] = {}
    part_numbers: List[Number] = []

    gear_pattern = re.compile("[*]")
    number_pattern = re.compile("(\d+)")

    for line_index, line in enumerate(data):
        for s in re.finditer(gear_pattern, line):
            gears[position(line=line_index, char=s.start())] = Gear()
        for n in re.finditer(number_pattern, line):
            part_numbers.append(Number(
                    value=int(n.group()),
                    line = line_index,
                    start = n.start(),
                    end = n.end()))
    
    sum = 0

    for number in part_numbers:
        for pos in [(number.line, number.start-1), # left of number
                    (number.line, number.end), # right of number
                    *((number.line-1, index) for index in range(number.start-1, number.end+1)), # above number
                    *((number.line+1, index) for index in range(number.start-1, number.end+1))  # below number
                    ]:
            if pos in gears:
                sum += processGear(gears[pos], part_number=number)
    
    display(sum, target="day3_2-output")


# Only runs if not running in the browser
import sys
if not 'js' in sys.modules:  
    main_day3_2()
Scroll to see complete code

Day 4 (Part 1): Scratchcards

Python's set operations really shine here - one we've parsed each line of input into its winning numbers and scratchcard numbers, we can use isdisjoint() to determine if there's any overlap between the two sets, and and winning_numbers & my_numbers to find it if it is. From there, we calculate 2 to the power of the length of this set of numbers, and sum up those values.

  • day4_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re

card_pattern = re.compile("Card (\d+)")
number_pattern = re.compile("\d+")

def main_day4_1(*args):
    data = get_input("day4_1").split('\n')

    sum = 0
    for line in data:
        _, numbers_section = line.split(":")
        winning_numbers = set(m.group(0) for m in re.finditer(number_pattern, numbers_section.split("|")[0]))
        my_numbers = set(m.group(0) for m in re.finditer(number_pattern, numbers_section.split("|")[1]))
        value = 0 if winning_numbers.isdisjoint(my_numbers) else 2 ** (len(winning_numbers & my_numbers) - 1)
        sum += value

    display(sum, target="day4_1-output")

import sys
if not 'js' in sys.modules:
    main_day4_1()
Scroll to see complete code

Day 4 (Part 2): Scratchcards

I like to look at the Advent of Code subreddit after finishing a day's problems, to look at alternate solutions and see what pitfulls I shared/avoided. In this case, most of Day 4 Part 2's solutions talked about using recurssion, which honestly haden't occured to me. Since each Scratchcard can only propogate copies to higher indices, my impulse was to just walk the data once and pile up the additional scratchcards with each additional index processed, in a functional-programming way. I do think this is faster.

  • day4_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re

card_pattern = re.compile("Card (\d+)")
number_pattern = re.compile("\d+")

def main_day4_2(*args):
    data = get_input("day4_2").split('\n')
    card_counts = [1] * len(data)

    for i, line in enumerate(data):
        _, numbers_section = line.split(":")
        winning_numbers = set(m.group(0) for m in re.finditer(number_pattern, numbers_section.split("|")[0]))
        my_numbers = set(m.group(0) for m in re.finditer(number_pattern, numbers_section.split("|")[1]))
        for m in range(1, len(winning_numbers & my_numbers)+1):
            card_counts[i + m] += card_counts[i]

    display(sum(card_counts), target="day4_2-output")

import sys
if not 'js' in sys.modules:
    main_day4_2()
Scroll to see complete code

Day 5 (Part 1): If You Give A Seed A Fertilizer

If programming is thinking-written-down, I find a significant amount of my thinking occurs in devising the data structure and object format to encapsulate a given problem. In this case, the almanac_op function which applies a mapping rule to a given number. I tend to write out full docstrings for these 'problem-critical' code sections, and not worry about them for more procedural code like a main method.

One of the opporunties to stub your toe on a challenge like Day 5 Part 1 is remembering that, once you've applied a mapping rule successfully to a given location, you need to skip the remainder of the rules and cary on to the next set of mappings immediately. Otherwise, you risk applying multiple rules from a single set, which is incorrect.

  • day5_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re
from typing import Tuple

def almanac_op(given: int, dest_start: int, source_start: int, length: int) -> int:
    """Takes an index of a location and applies the specified mapping rule to it

    Args:
        given (int): The starting location
        dest_start (int): The starting index of the destination range
        source_start (int): The starting index of the source range
        length (int): The length of both ranges

    Returns:
        int: The new location after the mapping
    """
    if given >= source_start and given <= (source_start + length - 1):
        return (given - source_start) + dest_start
    else:
        return given


def main_day5_1(*args):
    data = get_input("day5_1")

    seeds = [int(s.group()) for s in re.finditer("\d+", data.split("\n")[0].split(":")[1])]
    map_sets = [[l.split(" ") for l in s.split('\n')[1:]] for s in data.split("\n\n")]
   
    min = float('inf')

    for seed in seeds:
        for map_set in map_sets:
            for op in map_set:
                result = almanac_op(seed, int(op[0]), int(op[1]), int(op[2]))
                if result != seed:
                    seed = result
                    break
            
        if seed < min: min = seed

    display("FINAL:", min, target="day5_1-output")

import sys
if not 'js' in sys.modules:  
    main_day5_1()
Scroll to see complete code

Day 5 (Part 2): If You Give A Seed A Fertilizer

I fell prey to a classic Advent of Code trick: "We see your Part 1 solution works for single numbers... now make it work for MILLIONS OF NUMBERS!" Still, not too complicated of a solution, even if it did take me some time to work out the logic around which relationships of sources/destinations/ranges cause which effects to happen to the resulting ranges.

There's also the possibility here to get stuck in the track of modifying a collection (say, the current list of ranges) while also iterating over that collection. The old new_collection += result, collection = new_collection pattern helps here.

I'm also leaving my assert startments in at the end of this code, as they were hugely helpful in debugging my primary algorithm.

  • day5_2.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from operator import itemgetter
import re
from typing import Tuple

def almanac_op_range(seed_ranges, dest_start: int, source_start: int, length:int) -> Tuple[Tuple[Tuple[int, int]], Tuple[Tuple[int, int]]]:
    """Given a list of ranges as input, and a single mapping given by a starting
    destination index, starting source index, and length, return a list of ranges
    that have not been touched by this rule and a list of ranges that have been modified
    by this rule

    Args:
        seed_ranges (_type_): _description_
        dest_start (int): _description_
        source_start (int): _description_
        length (int): _description_

    Raises:
        ValueError: A troubleshooting step, to validate that the rules for processing
        ranges cover all possible cases

    Returns:
        List[Tuple[int, int]], List[Tuple[int, int]]: A list of untouched ranges, followe
        by a list of ranges that had been moved
    """
    source_end = source_start + length - 1
    dest_end = dest_start + length - 1

    untouched = []
    moved = []
    for given in seed_ranges:
        given_start, given_end = given[0], given[1]
        #print(f"given = {given_start, given_end}, dest={dest_start, dest_end}, source={source_start, source_end}")

        if given_end < source_start or given_start > source_end: #outside of source range
            untouched.append(given)
        
        elif given_start >= source_start and given_end <= source_end: # given entirely within source range
            moved.append(((given_start - source_start + dest_start), (given_end - source_start + dest_start)))
        
        elif given_start < source_start and given_end > source_end: # given entirely surrounds source range
            untouched.append((given_start, source_start-1))
            untouched.append((source_end + 1, given_end))
            moved.append((dest_start, dest_end))
        
        elif given_start >= source_start and given_end > source_end: # starts is within source, end isn't
            moved.append((given_start - source_start + dest_start,source_end-source_start+dest_start))
            untouched.append((source_end+1, given_end))

        elif given_start < source_start and given_end <= source_end: # starts before source, ends inside
            untouched.append((given_start, source_start -1))
            moved.append((dest_start,given_end-source_start+dest_start))
        else:
            raise ValueError("Should never be here")
        
    return untouched, moved

def main_day5_2(*args):
    data = get_input("day5_2")

    seed_ranges = [(int(s.group(1)), int(s.group(1)) + int(s.group(2)) - 1) for s in re.finditer(r"(\d+) (\d+)", data.split("\n")[0].split(":")[1])]
    map_sets = [[l.split(" ") for l in s.split('\n')[1:]] for s in data.split("\n\n")][1:]

    untouched = seed_ranges
    for map_set in map_sets:
        moved = []  
        for op in map_set:
            untouched, new_moved = almanac_op_range(untouched, int(op[0]), int(op[1]), int(op[2]))
            moved.extend(new_moved)
            if not untouched: break #If there's no numbers that haven't been mapped, no need to keep processing rules
        untouched.extend(moved)
        untouched = list(set(untouched))

    minimum = min(r[0] for r in untouched)

    display(minimum, target="day5_2-output")

import sys
if not 'js' in sys.modules:
    main_day5_2()

    # Assertions used during testing, left in for interest
    assert almanac_op_range(((1,5),), 50, 20, 5) == ([(1,5)],[]) #outside of source range
    assert almanac_op_range([(100,105)], 50, 20, 5) == ([(100,105)],[]) #outside of source range
    assert almanac_op_range([(10, 20)], 50, 15, 2) ==  ([(10,14), (17,20)], [(50, 51)]) # given entirely surrounds source range
    assert almanac_op_range([(10, 20)], 50, 5, 30) == ([],[(55, 65)]) # given entirely within source range
    assert almanac_op_range([(10, 20)], 50, 15, 10) == ([(10, 14)], [(50, 55)]) # starts is within source, end isn't
    assert almanac_op_range([(10, 20)], 50, 5, 10) == ([(15, 20)],[(55,59)]) # starts before source, ends inside
Scroll to see complete code

Day 6 (Part 1): Day 6: Wait For It

The utility of grabbing numbers out of a list of text is now fully evident. It gracefully handles line endings, spaces, comma, etc. Not that I won't be back to (and indeed, already am using) split() and friends, but the time regexes have saved on input so far has been enlightening.

  • day6_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re

def main_day6_1(*args):
    data = get_input("day6_1").split("\n")
    races = zip(
        (int(d.group()) for d in re.finditer("\d+", data[0])), # times
        (int(d.group()) for d in re.finditer("\d+", data[1]))  # distances
        )

    prod = 1
    for race in races:
        count = 0
        total_time, distance_goal = race
        for hold_time in range(total_time):
            if (total_time - hold_time) * hold_time > distance_goal: count +=1 
        prod *= count

    display(prod, target="day6_1-output")

import sys
if not 'js' in sys.modules:
    main_day6_1()
Scroll to see complete code

Day 6 (Part 2): Day 6: Wait For It

Having been bit yesterday, I was pretty confident that part 2 of day 6 would have a similar explosion in time/space requirements. And it did... only it doesn't seem to have mattered, as the 'naive' solution here still runs in about a second. Just lucky I guess.

  • day6_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

import re

def main_day6_2(*args):
    data = get_input("day6_2").split("\n")
    total_time = int(''.join(d.group() for d in re.finditer('\d', data[0])))
    distance_goal = int(''.join(d.group() for d in re.finditer('\d', data[1])))

    count = 0
    for hold_time in range(total_time):
        if (total_time - hold_time) * hold_time > distance_goal: count +=1 

    display(count, target="day6_2-output")

import sys
if not 'js' in sys.modules:
    main_day6_2()
Scroll to see complete code

Day 7 (Part 1): Camel Cards

The complexity of the problems is ramping up at this point, if not quite the difficulty. Since today is ultimately a sorting problem, I was looking for a way to transform the input into a sortable form; by tranforming the "face card" labels ("AKQJT") into Hexadeical characters with the matching rank, then evaluating the hand's strength using collections.Counter and appending that as another Hex character to the front, the sorting is then straightforward.

  • day7_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import Counter
from typing import Iterable

def score_lines_7_1(lines: Iterable[str]):
    return sum(int(line.split(" ")[1]) * (index+1) for index, line in enumerate(lines))

def sort_lines_7_1(lines: Iterable[str]):
    return sorted(lines, key = rank_7_1)

def rank_7_1(line):
    hand = line.split(" ")[0]
    counts = Counter(hand)
    vals = tuple(sorted((counts.values()), reverse=True))
    if vals == (5,): rank = "F" # 5 of a kind
    elif vals == (4,1): rank = "E" # 4 of a kind
    elif vals == (3,2): rank = "D" # Full House
    elif vals == (3,1,1): rank = "C" # Three of a kind
    elif vals == (2,2,1): rank = "B" # Two Pair
    elif vals == (2,1,1,1): rank = "A"
    else:rank = "9"

    hand_as_hex = rank+remap_card_names_7_1(hand)
    return int(hand_as_hex, base=16)

def remap_card_names_7_1(hand):
    trans = str.maketrans("AKQJT", "FEDCB")
    return hand.translate(trans)

def main_day7_1(*args):
    data = get_input("day7_1").split("\n")

    result = score_lines_7_1(sort_lines_7_1(data))
    
    display(result, target="day7_1-output")

import sys
if not 'js' in sys.modules:
    main_day7_1()
Scroll to see complete code

Day 7 (Part 2): Camel Cards

A relatively benign change to part one, invovling adjusting the ranking algorithm to incorporate Jokers and adjusting the translation table. One of the (minor) pain points in using PyScript is that if you're using main-thread tags, you get exactly on interpreter and one global namespace, so name collisions across multiple problems are a given. Hence the clunky names in both parts of this problem.

Perhaps I should switch to worker tags...

  • day7_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import Counter
from operator import itemgetter
import re
from typing import Iterable

pat = re.compile("\d+")

def score_lines_7_2(lines: Iterable[str]):
    """Take in a list of input sorted by rank, and return
    the score required by the pizzle

    Args:
        lines (Iterable[str]): A sorted Iterable of input lines

    Returns:
        _type_: _description_
    """
    return sum(int(line.split(" ")[1]) * (index+1) for index, line in enumerate(lines))

def sort_lines_7_2(lines: Iterable[str]):
    return sorted(lines, key = rank_7_2)

def rank_7_2(line):
    hand = line.split(" ")[0]
    if hand == "JJJJJ": return int("F11111", base=16)

    counts = Counter(hand)
    vals = [list(s) for s in sorted(counts.items(), key=itemgetter(1), reverse=True)]

    # Move count of jokers to the next-most-common card
    if vals[0][0] == 'J': vals[1][1] += vals[0][1]
    else: vals[0][1] += counts["J"]

    # Find the count that corresponds to jokers, and remove it
    j_items = [i for i in vals if i[0] == "J"]
    if j_items:
        vals.remove(j_items[0])
    
    vals = tuple(v[1] for v in vals)


    if vals == (5,): rank = "F" # 5 of a kind
    elif vals == (4,1): rank = "E" # 4 of a kind
    elif vals == (3,2): rank = "D" # Full House
    elif vals == (3,1,1): rank = "C" # Three of a kind
    elif vals == (2,2,1): rank = "B" # Two Pair
    elif vals == (2,1,1,1): rank = "A"
    else:rank = "9"
    
    new_card = rank + remap_card_names_7_2(hand)
    return int(new_card, base=16)

def remap_card_names_7_2(hand):
    trans = str.maketrans("AKQJT", "FED1B")
    return hand.translate(trans)

def main_day7_2(*args):
    data = get_input("day7_2").split("\n")

    data = sort_lines_7_2(data)
    for line in data:
        print(line)
    result = score_lines_7_2(data)

    display(result, target="day7_2-output")

import sys
if not 'js' in sys.modules:
    main_day7_2()
Scroll to see complete code

Day 8 (Part 1): Haunted Wasteland

We've seen challenges like this in previous years' advents of code, and I suspect we'll see more like it this year. The input is a list of transformations on some input point, as indexed by some other part of the index. The only real trick is to make sure you're starting and ending points correctly identified; with Python's str.startswith() and str.endswith(), this is pretty trivial.

  • day8_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass
from itertools import cycle
import re

@dataclass
class Instruction:
    left: str
    right: str

def main_day8_1(*args):
    data = get_input("day8_1").split("\n")

    pattern = re.compile(r"([A-Z]{3}) = \(([A-Z]{3}), ([A-Z]{3})\)")

    turns = cycle(data[0].strip())
    node_data = {re.match(pattern, line).group(1):
                 Instruction(left=m.group(2), right=m.group(3)) for line in data[2:] if (m := re.match(pattern, line))}
    
    steps = 0
    loc = 'AAA'

    while(True):
        if loc == 'ZZZ': break
        next_step = node_data[loc]
        direction = next(turns)
        if direction == 'L': loc = next_step.left
        elif direction == 'R': loc = next_step.right
        else: raise ValueError("Direction must be L or R")

        steps+=1

    result = steps
    display(result, target="day8_1-output")

import sys
if not 'js' in sys.modules:
    main_day8_1()
Scroll to see complete code

Day 8 (Part 2): Haunted Wasteland

The niave solution here would be to simply set up an array of locations to track, and let them all run for a long time until they all simultaneously were on locations that end in 'Z'. However, with the ghosts having periods that are large and co-prime, the resulting overall period can (and in my case, is) over 14 digits long.

The solution is to recognize that you can identify when each ghost has reached it's 'destination' (location that ends in 'Z') to find the period of that ghost. Then, the overall period is the least-common-multiple of all the ghosts' periods. Again, thanks to Python 3.9's addition of math.lcm, this is straightforward.

  • day8_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass
from itertools import cycle
from math import prod, lcm
import re

@dataclass
class Instruction:
    left: str
    right: str

def main_day8_2(*args):
    data = get_input("day8_2").split("\n")

    pattern = re.compile(r"([A-Z]{3}) = \(([A-Z]{3}), ([A-Z0-9]{3})\)")

    turns = cycle(data[0].strip())
    node_data = {re.match(pattern, line).group(1):
                 Instruction(left=m.group(2), right=m.group(3)) for line in data[2:] if (m := re.match(pattern, line))}
    
    steps = 0
    ghosts = [loc for loc in node_data.keys() if loc.endswith('A')]
    periods = [-1 for _ in range(len(ghosts))]
    print(ghosts)

    while(True):
        if all(p != -1 for p in periods): break
        direction = next(turns)

        for index, ghost in enumerate(ghosts):
            if ghost.endswith("Z"): periods[index] = steps
            next_step = node_data[ghost]
            if direction == 'L': ghosts[index] = next_step.left
            elif direction == 'R': ghosts[index] = next_step.right
            else: raise ValueError("Direction must be L or R")

        steps+=1
        #print(ghosts)

    result = lcm(*periods)
    display(result, target="day8_2-output")

import sys
if not 'js' in sys.modules:
    main_day8_2()
Scroll to see complete code

Day 9 (Part 1): Mirage Maintenance

I'm sure there have been earlier days that could have been solved recursively. Indeed, my first catastrophic attempt for Day 5 Part 2 used some lazy recursion. But today's problem - repeatedly doing the same opeartion until hitting a base case - just screams for recurssion.

In truth, the problem description essentially lays out the algorithm itself, step by step. All that's left is implementation.

  • day9_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import pairwise
import re
import sys
from typing import Iterable

def get_next_number_9_1(l: Iterable[int]):
    if all(n == 0 for n in l): return 0

    diff_sequence = [a[1] - a[0] for a in pairwise(l)]
    diff_sequence.append(get_next_number_9_1(diff_sequence))
    result = l[-1] + diff_sequence[-1] 

    return result


def main_day9_1(*args):
    data = [[int(n.group()) for n in re.finditer(r"-?\d+", line)] for line in get_input("day9_1").split("\n")]
    
    result = sum(get_next_number_9_1(d) for d in data)

    display(result, target="day9_1-output")

if 'js' not in sys.modules:
    main_day9_1()
Scroll to see complete code

Day 9 (Part 2): Mirage Maintenance

Part 2's solution is very similar to part 1's, with a change of indices, and appending to the start of our list-of-differences instead of the end.

On most day's, there's a seductively easy solution to part 1 (usually brute force) that blows up for part 2. But I'm struggling to see how the solutions to part 1 and part 2 would be significantly different... perhaps for a different choice of language, inserting a number at the beginning of a list is significantly more challenging?

  • day9_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import pairwise
import re
import sys
from typing import Iterable

def get_previous_number(seq: Iterable[int]):
    if all(n == 0 for n in seq): return 0

    diff_sequence = [a[1] - a[0] for a in pairwise(seq)]
    diff_sequence.insert(0, get_previous_number(diff_sequence))
    result = seq[0] - diff_sequence[0] 

    return result


def main_day9_2(*args):
    data = [[int(n.group()) for n in re.finditer(r"-?\d+", line)] for line in get_input("day9_2").split("\n")]
    
    result = sum(get_previous_number(d) for d in data)

    display(result, target="day9_2-output")

if 'js' not in sys.modules:
    main_day9_2()
Scroll to see complete code

Day 10 (Part 1): Pipe Maze

Ah, back to processing input on a grid. As with earlier puzzles, I usually find it easier to create a running dictionary of data[(row, column)] instead of tested dicts data[row][column], since then a single (x, y) in data suffices to check for precence.

  • day10_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import defaultdict
import sys
from typing import List

import rich

def print_pipes(data, current, seen):
    for l, line in enumerate(data):
        for c, char in enumerate(line):
            if (l, c) == current: rich.print(f"[red]{char}[/red]", end="")
            elif (l, c) in seen: rich.print((f"[green]{char}[/green]"), end="")
            elif char == '.' : rich.print(f"[gray]{char}[/gray]", end="")
            else: rich.print(f"[white]{char}[/white]", end="")
        print("")

def main_day10_1(*args):
    data: List[str] = get_input("day10_1").split('\n')

    _map_data = {(line_num, char_num): char for line_num, line in enumerate(data) for char_num, char in enumerate(line)}
    map_data = defaultdict(lambda: None, _map_data)

    start_line, _start_line_data = [(index, line)for index, line in enumerate(data) if 'S' in line][0]
    start_char = _start_line_data.index('S')
    #print_pipes(data, [], [])

    north_entry = ('N', (1,0))
    east_entry = ('E', (0, -1))
    south_entry = ('S', (-1, 0))
    west_entry = ('W', (0, 1))

    instructions = {
        '|': {
            'N': north_entry,
            'S': south_entry
        },
        '-': {
            'E': east_entry,
            'W': west_entry
        },
        'L': {
            'N' : west_entry,
            'E' : south_entry
        },
        'J': {
            'N' : east_entry,
            'W' : south_entry
        },
        '7': {
            'S' : east_entry,
            'W' : north_entry,
        },
        'F': {
            'S' : west_entry,
            'E' : north_entry
        }
    }

    # Find starting direction:
    adjacent_dirs = [('W', (start_line, start_char + 1)) if map_data[(start_line, start_char + 1)] in ('-', 'J', '7') else "",
                     ('E', (start_line, start_char - 1)) if map_data[(start_line, start_char - 1)] in ('-', 'L', 'F') else "", 
                     ('N', (start_line + 1, start_char)) if map_data[(start_line + 1, start_char)] in ('|', 'L', 'J') else "", 
                     ('S', (start_line - 1, start_char)) if map_data[(start_line - 1, start_char)] in ('|', '7', 'F') else ""]

    entry_dir, position = [a for a in adjacent_dirs if a][0]
    
    count = 1
    seen = [(start_line, start_char), position]
    
    while (current_space:= map_data[position]) != 'S':
        count += 1
        entry_dir, position_delta = instructions[current_space][entry_dir]
        position = (position[0] + position_delta[0], position[1] + position_delta[1])
        seen.append(position)
        #print_pipes(data, position, seen)

    result = count / 2
    display(result, target="day10_1-output")

if not 'js' in sys.modules:
    main_day10_1()
Scroll to see complete code

Day 10 (Part 2): Pipe Maze

Part two is a bit of a curveball, in that it asks you to find an entirely new property of the curve/data from part one. My initial impulse was to use something like a flood fill algorith, but the second set of example problems show that some "outside" areas might be trapped and "unfloodable".

I ultimately used a version of the Raycasting/Jordan Curve agorithm, iterating along each line and determining whether a given position was "inside" or "outside" the curve. Whenenver a | is crossed, one passes from inside to outside; corners are a little harder: "L----7" and "F----J" count as crossings, but "L----J" and "F----7" do not. Finally, the original starting point S has to be replaced with the appropriate symbol before processing, or everything after it on that line may go sideways.

  • day10_2.py
  • day10_2-viz.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import defaultdict
import sys
from typing import List

import rich

def r(char: str):
    table = str.maketrans("|-JLF7", "│─┘└┌┐")
    return char.translate(table)

def replace_char(s, index, char):
    return s[:index] + char + s[index+1:]

def print_pipes_10_2(data, current, seen):
    for l, line in enumerate(data):
        for c, char in enumerate(line):
            if (l, c) == current: rich.print(f"[red]{r(char)}[/red]", end="")
            elif (l, c) in seen: rich.print((f"[green]{r(char)}[/green]"), end="")
            elif char == '.' : rich.print(f"[gray]{r(char)}[/gray]", end="")
            else: rich.print(f"[white]{r(char)}[/white]", end="")
        print("")

def count_inner_cells(data: List[str], loop: List[tuple[int, int]]):
    """Count the number of cells contained within the loop. Starting
    from the beginning of each line, and with a boolean is_inside set to false, 
    each time we cross the loop, invert that boolean, and add any non-loop 
    space to a running count. "|" spaces are an obvious crossing; corners
    need to be handled specially.

    Args:
        data List[str]: The original data,  split into lines
        loop List[tuple[int, int]]: A list of (line, char_index) pairs comprising the loop.

    Returns:
        int: The number of cells inside the loop
    """
    count = 0
    for l, line in enumerate(data):
        is_inside = False
        crossing_start = ""
        for c, char in enumerate(line):
            if (l, c) in loop:
                if char == "|": is_inside = not is_inside
                elif char in ("L", "F"): crossing_start = char
                elif char in ("7", "J") and crossing_start != "":
                    if char == "7" and crossing_start == "L": is_inside = not is_inside
                    elif char == "J" and crossing_start == "F": is_inside = not is_inside
                    crossing_start = ""
                if is_inside: color = "white"
                else: color = "white"
                #rich.print(f"[{color}]{r(char)}[/{color}]", end = "")
            else:
                if is_inside: 
                    count += 1
                    #rich.print(f"[white on green]{char}[/white on green]", end = "")
                else:
                    pass
                    #rich.print(f"[red]{char}[/red]", end = "")
        #print("")

    return count


def main_day10_2(*args):
    data: List[str] = get_input("day10_2").split('\n')

    _map_data = {(line_num, char_num): char for line_num, line in enumerate(data) for char_num, char in enumerate(line)}
    map_data = defaultdict(lambda: None, _map_data)

    start_line, _start_line_data = [(index, line)for index, line in enumerate(data) if 'S' in line][0]
    start_char = _start_line_data.index('S')

    north_entry = ('N', (1,0))
    east_entry = ('E', (0, -1))
    south_entry = ('S', (-1, 0))
    west_entry = ('W', (0, 1))

    instructions = {
        '|': {
            'N': north_entry,
            'S': south_entry
        },
        '-': {
            'E': east_entry,
            'W': west_entry
        },
        'L': {
            'N' : west_entry,
            'E' : south_entry
        },
        'J': {
            'N' : east_entry,
            'W' : south_entry
        },
        '7': {
            'S' : east_entry,
            'W' : north_entry,
        },
        'F': {
            'S' : west_entry,
            'E' : north_entry
        }
    }

    # Find starting direction:
    adjacent_dirs = [('W', (start_line, start_char + 1)) if map_data[(start_line, start_char + 1)] in ('-', 'J', '7') else "",
                     ('E', (start_line, start_char - 1)) if map_data[(start_line, start_char - 1)] in ('-', 'L', 'F') else "", 
                     ('N', (start_line + 1, start_char)) if map_data[(start_line + 1, start_char)] in ('|', 'L', 'J') else "", 
                     ('S', (start_line - 1, start_char)) if map_data[(start_line - 1, start_char)] in ('|', '7', 'F') else ""]

    # Pick an arbitrary starting direction
    entry_dir, position = [a for a in adjacent_dirs if a][0]
    
    count = 1
    loop = [(start_line, start_char), position]
    
    # Find all the cells in the loop
    while (current_space:= map_data[position]) != 'S':
        count += 1
        entry_dir, position_delta = instructions[current_space][entry_dir]
        position = (position[0] + position_delta[0], position[1] + position_delta[1])
        loop.append(position)

    # Replace the starting 'S' with the appropriate symbol to make the next algorithm simpler
    starting_directions = tuple(a[0] for a in adjacent_dirs if a)
    if starting_directions == ('W', 'E'): data[start_line] = replace_char(data[start_line], start_char, "-")
    elif starting_directions == ('N', 'S'): data[start_line] = replace_char(data[start_line], start_char, "|")
    elif starting_directions == ('W', 'N'): data[start_line] = replace_char(data[start_line], start_char, "F")
    elif starting_directions == ('W', 'S'): data[start_line] = replace_char(data[start_line], start_char, "L")
    elif starting_directions == ('E', 'N'): data[start_line] = replace_char(data[start_line], start_char, "7")
    elif starting_directions == ('E', 'S'): data[start_line] = replace_char(data[start_line], start_char, "J")

    result = count_inner_cells(data, loop)

    display(result, target="day10_2-output")

if not 'js' in sys.modules:
    main_day10_2()
Scroll to see complete code
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import sys
if 'js' in sys.modules:
    from pyscript import document
    from utils import get_input, output_redirect_to_xterm
    from pyodide.ffi.wrappers import add_event_listener
else:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)


from collections import defaultdict
import sys
from typing import List

import rich
from rich.console import Console

rich._console = Console(color_system='256')

def r(char: str):
    table = str.maketrans("|-JLF7", "│─┘└┌┐")
    return char.translate(table)

def replace_char_viz(s, index, char):
    return s[:index] + char + s[index+1:]

def print_pipes_10_2_viz(data, current, seen):
    for l, line in enumerate(data):
        for c, char in enumerate(line):
            if (l, c) == current: rich.print(f"[red]{r(char)}[/red]", end="")
            elif (l, c) in seen: rich.print((f"[green]{r(char)}[/green]"), end="")
            elif char == '.' : rich.print(f"[gray]{r(char)}[/gray]", end="")
            else: rich.print(f"[white]{r(char)}[/white]", end="")
        print("")

def count_inner_cells_viz(data: List[str], loop: List[tuple[int, int]]):
    """Count the number of cells contained within the loop. Starting
    from the beginning of each line, and with a boolean is_inside set to false, 
    each time we cross the loop, invert that boolean, and add any non-loop 
    space to a running count. "|" spaces are an obvious crossing; corners
    need to be handled specially.

    Args:
        data List[str]: The original data,  split into lines
        loop List[tuple[int, int]]: A list of (line, char_index) pairs comprising the loop.

    Returns:
        int: The number of cells inside the loop
    """
    count = 0
    for l, line in enumerate(data):
        is_inside = False
        crossing_start = ""
        for c, char in enumerate(line):
            if (l, c) in loop:
                if char == "|": is_inside = not is_inside
                elif char in ("L", "F"): crossing_start = char
                elif char in ("7", "J") and crossing_start != "":
                    if char == "7" and crossing_start == "L": is_inside = not is_inside
                    elif char == "J" and crossing_start == "F": is_inside = not is_inside
                    crossing_start = ""
                if is_inside: color = "white"
                else: color = "white"
                rich.print(f"[{color}]{r(char)}[/{color}]", end = "")
            else:
                if is_inside: 
                    count += 1
                    rich.print(f"[white on green]{char}[/white on green]", end = "")
                else:
                    pass
                    rich.print(f"[red]{char}[/red]", end = "")
        print("")

    return count

def viz_day10_2(*args):
    with output_redirect_to_xterm('day10_2-viz'):
        _viz_day10_2()

def _viz_day10_2(*args):
    data: List[str] = get_input("day10_2").split('\n')

    _map_data = {(line_num, char_num): char for line_num, line in enumerate(data) for char_num, char in enumerate(line)}
    map_data = defaultdict(lambda: None, _map_data)

    start_line, _start_line_data = [(index, line)for index, line in enumerate(data) if 'S' in line][0]
    start_char = _start_line_data.index('S')

    north_entry = ('N', (1,0))
    east_entry = ('E', (0, -1))
    south_entry = ('S', (-1, 0))
    west_entry = ('W', (0, 1))

    instructions = {
        '|': {
            'N': north_entry,
            'S': south_entry
        },
        '-': {
            'E': east_entry,
            'W': west_entry
        },
        'L': {
            'N' : west_entry,
            'E' : south_entry
        },
        'J': {
            'N' : east_entry,
            'W' : south_entry
        },
        '7': {
            'S' : east_entry,
            'W' : north_entry,
        },
        'F': {
            'S' : west_entry,
            'E' : north_entry
        }
    }

    # Find starting direction:
    adjacent_dirs = [('W', (start_line, start_char + 1)) if map_data[(start_line, start_char + 1)] in ('-', 'J', '7') else "",
                     ('E', (start_line, start_char - 1)) if map_data[(start_line, start_char - 1)] in ('-', 'L', 'F') else "", 
                     ('N', (start_line + 1, start_char)) if map_data[(start_line + 1, start_char)] in ('|', 'L', 'J') else "", 
                     ('S', (start_line - 1, start_char)) if map_data[(start_line - 1, start_char)] in ('|', '7', 'F') else ""]

    # Pick an arbitrary starting direction
    entry_dir, position = [a for a in adjacent_dirs if a][0]
    
    count = 1
    loop = [(start_line, start_char), position]
    
    # Find all the cells in the loop
    while (current_space:= map_data[position]) != 'S':
        count += 1
        entry_dir, position_delta = instructions[current_space][entry_dir]
        position = (position[0] + position_delta[0], position[1] + position_delta[1])
        loop.append(position)

    # Replace the starting 'S' with the appropriate symbol to make the next algorithm simpler
    starting_directions = tuple(a[0] for a in adjacent_dirs if a)
    if starting_directions == ('W', 'E'): data[start_line] = replace_char_viz(data[start_line], start_char, "-")
    elif starting_directions == ('N', 'S'): data[start_line] = replace_char_viz(data[start_line], start_char, "|")
    elif starting_directions == ('W', 'N'): data[start_line] = replace_char_viz(data[start_line], start_char, "F")
    elif starting_directions == ('W', 'S'): data[start_line] = replace_char_viz(data[start_line], start_char, "L")
    elif starting_directions == ('E', 'N'): data[start_line] = replace_char_viz(data[start_line], start_char, "7")
    elif starting_directions == ('E', 'S'): data[start_line] = replace_char_viz(data[start_line], start_char, "J")

    result = count_inner_cells_viz(data, loop)

    display(result, target="day10_2-output")

if not 'js' in sys.modules:
    with output_redirect_to_xterm('day10_2-viz'):
        viz_day10_2()
else:
    add_event_listener(document.getElementById("day10_2-viz-btn"), "click", _viz_day10_2)
Scroll to see complete code

Day 11 (Part 1): Cosmic Expansion

What I've taken away from the first chunk of Advent of Code this year is:

  • Inserting additional features into your original data structure is often a mistake, as insertions don't scale well.
  • When faced with a task that involves a grid, think carefully about whether you need to store the full structure of the grid, or just some important coordinates.

  • day11_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import combinations
import sys

def main_day11_1(*args):
    data = get_input("day11_1").split("\n")

    expanding_row_indices = [i for i, line in enumerate(data) if all(char == '.' for char in line)]
    expanding_col_indices = [i for i in range(len(data)) if all(line[i] == '.' for line in data)]
    stars =  [(line_index, char_index) for line_index, line in enumerate(data) for char_index, char in enumerate(line) if char == '#']

    total = 0
    for s1, s2, in combinations(stars, 2):
        manhattan_dist = abs(s2[0] - s1[0]) + abs(s2[1] - s1[1])
        
        # expanding rows; stars are in row order, so s2[0] >= s1[0]
        row_total = len([r for r in expanding_row_indices if s1[0] < r < s2[0]])

        # expanding columns, not necessarily ordered
        if s2[1] == s1[1]: 
            col_total = 0
        elif s2[1] > s1[1]:
            col_total = len([c for c in expanding_col_indices if s1[1] < c < s2[1]])
        else: # s2[1] < s1[1] 
            col_total = len([c for c in expanding_col_indices if s2[1] < c < s1[1]])

        total += manhattan_dist + row_total + col_total
    
    result = total
    display(result, target="day11_1-output")

if not 'js' in sys.modules:
    main_day11_1()
Scroll to see complete code

Day 11 (Part 2): Cosmic Expansion

The only critical hint I'd give about part to is: beware of off-by-one errors. Otherwise, the solution to part 2 is very similar to part 1.

  • day11_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import combinations
import sys

def main_day11_2(*args):
    data = get_input("day11_2").split("\n")

    expanding_row_indices = [i for i, line in enumerate(data) if all(char == '.' for char in line)]
    expanding_col_indices = [i for i in range(len(data)) if all(line[i] == '.' for line in data)]
    stars =  [(line_index, char_index) for line_index, line in enumerate(data) for char_index, char in enumerate(line) if char == '#']

    total = 0
    for s1, s2, in combinations(stars, 2):
        manhattan_dist = abs(s2[0] - s1[0]) + abs(s2[1] - s1[1])
        
        # expanding rows; stars are in row order, so s2[0] >= s1[0]
        row_total = len([r for r in expanding_row_indices if s1[0] < r < s2[0]])

        # expanding columns, not necessarily ordered
        if s2[1] == s1[1]: 
            col_total = 0
        elif s2[1] > s1[1]:
            col_total = len([c for c in expanding_col_indices if s1[1] < c < s2[1]])
        else: # s2[1] < s1[1] 
            col_total = len([c for c in expanding_col_indices if s2[1] < c < s1[1]])

        total += manhattan_dist + (row_total + col_total) * 999_999
    
    result = total
    display(result, target="day11_2-output")

if not 'js' in sys.modules:
    main_day11_2()
Scroll to see complete code

Day 13 (Part 1): Point of Incidence

Today's problem involves finding lines of symmetry in the given data, either horizontally or vertically. In cases like this, I find Python's shortcutting any() builtin quite easy - when comparing if the two halves of data across a potential mirror line don't match in any position, immediately move on.

  • day13_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                
import sys
from typing import List

# Vertical Mirrors - index 0 is between first_line[0] and first_line[1]
def find_vertical_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern[0])-1):
        good_seam = True # sentinel to allow shortcutting out of the loop
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern[0]))):
            if any(line[lower] != line[upper] for line in pattern):
                good_seam = False
                break
        if good_seam: return seam_index + 1 # Counted in original problem is one different from this index

def find_horizontal_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern)-1):
        good_seam = True
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern))):
            if any(pattern[lower][i] != pattern[upper][i] for i in range(len(pattern[0]))):
                good_seam = False
                break
        if good_seam: return seam_index + 1



def main_day13_1(*args):
    patterns = get_input("day13_1").split("\n\n")

    total = 0
    for pattern in patterns:
        if mirror_index := find_vertical_mirror(pattern):
            total += mirror_index
        elif mirror_index := find_horizontal_mirror(pattern):
            total += 100 * mirror_index

    display(total, target="day13_1-output")

if not 'js' in sys.modules:
    main_day13_1()
Scroll to see complete code

Day 13 (Part 2): Point of Incidence

Having established a general logic for part 1, part two is not too terribly different - but sadly, we can't immediately bail out when any single position between the two "mirrored" halves differ. Rather, we track in how many positions they differ - if it's ever more than one, we can bail out immediately; otherwise, if we get to the end of a potential mirror line and exactly one position differs, we've found our mirror.

  • day13_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                
import sys
from typing import List

# Vertical Mirrors - index 0 is between first_line[0] and first_line[1]
def find_vertical_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern[0])-1):
        good_seam = True # sentinel to allow shortcutting out of the loop
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern[0]))):
            if any(line[lower] != line[upper] for line in pattern):
                good_seam = False
                break
        if good_seam: return seam_index + 1 # Counted in original problem is one different from this index

def find_horizontal_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern)-1):
        good_seam = True
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern))):
            if any(pattern[lower][i] != pattern[upper][i] for i in range(len(pattern[0]))):
                good_seam = False
                break
        if good_seam: return seam_index + 1

# Vertical Mirrors - index 0 is between first_line[0] and first_line[1]
def find_smudged_vertical_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern[0])-1):
        smudge_count = 0
        good_seam = True # sentinel to allow shortcutting out of the loop
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern[0]))):
            smudge_count += [line[lower] == line[upper] for line in pattern].count(False)
            if smudge_count > 1: #If count is to high, exist immediately
                good_seam = False
                break
        if good_seam and smudge_count == 1: return seam_index + 1 # Counted in original problem is one different from this index

def find_smudged_horizontal_mirror(pattern: List[str]) -> int | None:
    pattern = pattern.split("\n")
    for seam_index in range(len(pattern)-1):
        smudge_count = 0
        good_seam = True # sentinel to allow shortcutting out of the loop
        for lower, upper in zip(range(seam_index, -1, -1), range(seam_index+1, len(pattern))):
            smudge_count += [pattern[lower][i] == pattern[upper][i] for i in range(len(pattern[0]))].count(False)
            if smudge_count > 1:
                good_seam = False
                break
        if good_seam and smudge_count == 1: return seam_index + 1 # Counted in original problem is one different from this index



def main_day13_2(*args):
    patterns = get_input("day13_2").split("\n\n")

    total = 0
    for index, pattern in enumerate(patterns): 
        if smudge_index := find_smudged_vertical_mirror(pattern):
            if smudge_index != find_vertical_mirror(pattern):
                total += smudge_index
        elif smudge_index := find_smudged_horizontal_mirror(pattern):
            if smudge_index != find_horizontal_mirror(pattern):
                total += 100 * smudge_index
        else:
            print(f"No smudge found in pattern {index} (line {sum(len(pattern.split("\n")) for pattern in patterns[:index])})")

    display(total, target="day13_1-output")

if not 'js' in sys.modules:
    main_day13_2()
Scroll to see complete code

Day 14 (Part 1): Parabolic Reflector Dish

A rolling stone gathers no moss, and today's stones are no exception. For part 1, we can iterate over a list of strings an compare them, then "roll" the stone characters down each list as necessary.

In both part 1 and part 2 of today's challenge, I started with a more complex data structure to hold the state of the grid, before reverting to simply using a string as-is. But in the switch, I forgot that some of the operations I was doing - like comparisons across multiple indices, and doing a deepcopy of the data each time, we unnecessary with (immutable) strings. Whoops! That's some free performance gain, I suppose.

  • day14_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import pairwise      
import sys

def rollNorth(data: str) -> str:
    pattern = [list(line) for line in data.split("\n")]
    for first, second in pairwise(pattern):
        for char_index in range(len(first)):
            if first[char_index] == '.' and second[char_index] == 'O':
                first[char_index] = 'O'
                second[char_index] = '.'
    
    return '\n'.join(''.join(line) for line in pattern)

def main_day14_1(*args):
    data = get_input("day14_1")

    new_data = rollNorth(data)
    while new_data != data:
        data = new_data
        new_data = rollNorth(data)

    load = 0
    for row_index, row in enumerate(new_data.split("\n")):
        row_value = len(new_data.split("\n")) - row_index
        for char in row:
            if char == 'O': load += row_value

    result = load
    display(result, target="day14_1-output")

if not 'js' in sys.modules:
    main_day14_1()
Scroll to see complete code

Day 14 (Part 2): Parabolic Reflector Dish

Yet another Advent of Code challenge that takes a quantity in part 1 (roll once), and amps it way up in part 2 (roll a billion times).

The key insight is to glean (hope) that the stones will fall into a repeating pattern at some point; after which, we can calculate where in that repeating cycle the stones will be after a billion steps, and calculate the load at that point. For me, after 124 cycles of North/West/South/East movement, the stones enter a repeating pattern of 26 cycles.

  • day14_2.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from itertools import pairwise, count      
import sys

def cycle(data: str) -> str:
    data = completely(data, rollNorth)
    data = completely(data, rollWest)
    data = completely(data, rollSouth)
    return completely(data, rollEast)

def completely(data: str, f: [str, str]) -> str:
    new_data = f(data)
    while new_data != data:
        data = new_data
        new_data = f(data)
    return new_data

def rollNorth(data: str) -> str:
    pattern = [list(line) for line in data.split("\n")]
    for first, second in pairwise(pattern):
        for char_index in range(len(first)):
            if first[char_index] == '.' and second[char_index] == 'O':
                first[char_index] = 'O'
                second[char_index] = '.'
    
    return '\n'.join(''.join(line) for line in pattern)

def rollSouth(data: str) -> str:
    pattern = [list(line) for line in data.split("\n")]
    for first, second in pairwise(reversed(pattern)):
        for char_index in range(len(first)):
            if first[char_index] == '.' and second[char_index] == 'O':
                first[char_index] = 'O'
                second[char_index] = '.'
    
    return '\n'.join(''.join(line) for line in pattern)

def rollEast(data:str) -> str:
    pattern = [list(line) for line in data.split("\n")]
    for first_index, second_index in pairwise(range(len(pattern[0]))):
        for line in pattern:
            if line[second_index] == '.' and line[first_index] == 'O':
                line[second_index] = 'O'
                line[first_index] = '.'

    return '\n'.join(''.join(line) for line in pattern)

def rollWest(data:str) -> str:
    pattern = [list(line) for line in data.split("\n")]
    for first_index, second_index in pairwise(reversed(range(len(pattern[0])))):
        for line in pattern:
            if line[second_index] == '.' and line[first_index] == 'O':
                line[second_index] = 'O'
                line[first_index] = '.'

    return '\n'.join(''.join(line) for line in pattern)

def print_pattern(data:str):
    for line in data.split("\n"):
        print(line)

def get_load(data: str) -> int:
    load = 0
    for row_index, row in enumerate(data.split("\n")):
        row_value = len(data.split("\n")) - row_index
        for char in row:
            if char == 'O': load += row_value
    return load

def main_day14_2(*args):
    data = get_input("day14_2")
    configs = []

    counter = count()
    while data not in configs:
        print(loop_point:= counter.__next__(), "\t", get_load(data) )
        configs.append(data)
        data = cycle(data)

    loop_point += 1
    first_occurance = configs.index(data)
    print(f"After {loop_point=} iterations, the stones were in the same place as after {first_occurance}")
    loop_length = loop_point - first_occurance
    target = 1_000_000_000
    last_loop = (int((target - first_occurance)/loop_length)) * loop_length + first_occurance
    difference = target - last_loop

    for _ in range(difference):
        data = cycle(data)

    result = get_load(data)
    display(result, target="day14_2-output")

if not 'js' in sys.modules:
    main_day14_2()
Scroll to see complete code

Day 15 (Part 1): Lens Library

A description for this part of the day's problem is coming soon.

  • day15_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                

import sys

def hash(s: str) -> int:
    value = 0
    for char in s:
        value += ord(char)
        value *= 17
        value %= 256
    return value

def main_day15_1(*args):
    data = get_input("day15_1")

    result = sum(hash(s) for s in data.split(','))
    display(result, target="day15_1-output")

if not 'js' in sys.modules:
    main_day15_1()
Scroll to see complete code

Day 15 (Part 2): Lens Library

A description for this part of the day's problem is coming soon.

  • day15_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                

from dataclasses import dataclass
import re
import sys

def hash(s: str) -> int:
    value = 0
    for char in s:
        value += ord(char)
        value *= 17
        value %= 256
    return value

@dataclass
class Lens:
    label: str
    focal_length: str

    def __eq__(self, other):
        return self.label == other or (hasattr(other, "label") and self.label == other.label)

def main_day15_2(*args):
    data = get_input("day15_2")

    boxes = [[] for _ in range(256)]
    for command in data.split(','):
        print(f"Processing {command}")
        m = re.match(r"(?P<label>[a-zA-Z]+)(?P<inst>[-]|=)(?P<num>\d)?", command)
        label, inst = m.group("label"), m.group("inst")
        box = boxes[hash(label)]
        print(f"{label=} {inst=}")
        if inst == "-":
            if label in box: box.remove(label)
        elif inst == "=":
            focal_length = m.group("num")
            if label in box:
                location = box.index(label)
                box[location] = Lens(label=label, focal_length=focal_length)
            else:
                box.append(Lens(label=label, focal_length=focal_length))
        else:
            raise ValueError(f"Inst must be '-' or '=', not {inst}")

    for i, b in enumerate(boxes):
        if boxes:
            pass# print(f"{i}\t{b}")

    total = 0
    for i, b in enumerate(boxes):
        for l, lens in enumerate(b):
            total += (i + 1) * (l + 1) * (int(lens.focal_length))

    display(total, target="day15_2-output")

if not 'js' in sys.modules:
    main_day15_2()
Scroll to see complete code

Day 16 (Part 1): The Floor Will Be Lava

A description for this part of the day's problem is coming soon.

  • day16_1.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                
from copy import deepcopy
import rich
import sys
from typing import Iterable


LEFT = (0, -1)
RIGHT = (0, 1)
UP = (-1, 0)
DOWN = (1, 0)

def get_next_tiles(coords: tuple[int, int], direction: tuple[int, int], data, dims: tuple[int, int]) -> Iterable[tuple[int, int]]:
    # Off edge of map
    if coords[0] < 0 or coords [0] >= dims[0] or \
        coords[1] < 0 or coords [1] >= dims[1]:
        return set()
    
    value = data[(coords[0], coords[1])]

    # Move through empty space and parallel splitters
    if value == '.' or \
        (direction in (LEFT, RIGHT) and value == '-') or \
        (direction in (UP, DOWN) and value == "|"):
        return [((coords[0] + direction[0], coords[1] + direction[1]), direction)]
    
    # Vertical Splitter
    if direction in (LEFT, RIGHT) and value == "|":
        return [((coords[0]-1, coords[1]), UP),
                 ((coords[0]+1, coords[1]), DOWN)]
    

    # Horizontal Splitter
    if direction in (UP, DOWN) and value == "-":
        return [((coords[0], coords[1]-1), LEFT),
                 ((coords[0], coords[1]+1), RIGHT)]
    
    # Bouncing Down
    if (direction == RIGHT and value == "\\") or \
        (direction == LEFT and value == "/"):
        return [((coords[0] + 1, coords[1]), DOWN)]
    
    # Bouncing Up
    if (direction == RIGHT and value == "/") or \
        (direction == LEFT and value == "\\"):
        return [((coords[0] - 1 , coords[1]), UP)]
    
    # Bouncing Left
    if (direction == DOWN and value == "/") or \
        (direction == UP and value == "\\"):
        return [((coords[0], coords[1] - 1), LEFT)]
    
    # Bouncing Right
    if (direction == DOWN and value == "\\") or \
        (direction == UP and value == "/"):
        return [((coords[0], coords[1] + 1), RIGHT)]
    
    raise ValueError("Unhandled Instruction")   

def printGrid(lasers, inp, illuminated_spaces):
    for line_index, line in enumerate(inp):
        for char_index, char in enumerate(line):
            lasers_here = [(laser[0][0], laser[0][1]) for laser in lasers if laser[0][0] == line_index and laser[0][1] == char_index]
            if lasers_here:
                rich.print('[red]X[/red]', end="")
            elif (line_index, char_index) in illuminated_spaces:
                rich.print(f'[blue]{char}[/blue]', end="")
            else: print(char, end = "")
        print("")
    



def main_day16_1(*args):
    inp = get_input("day16_1").split("\n")
    data = {(line_index, char): value for line_index, line in enumerate(inp) for char, value in enumerate(line)}

    lasers = set([((0,0), RIGHT),])
    illuminated_spaces = set([(0,0),])
    old_lasers = set()


    dims = (len(inp), len(inp[0]))

    while lasers:

        new_lasers = set()
        for laser in lasers:
            new_lasers |= set(get_next_tiles(laser[0], laser[1], data, dims))
        if new_lasers <= old_lasers: break
        old_lasers |= new_lasers
        lasers = new_lasers
        illuminated_spaces = illuminated_spaces.union(set((laser[0][0], laser[0][1]) for laser in lasers))

        #printGrid(lasers, inp, illuminated_spaces)
        #rich.print(f'[red]{lasers}[/red]')
        #rich.print(f"[blue]{illuminated_spaces}[/blue]")
        #print("")

    result = len([s for s in illuminated_spaces if (0 <= s[0] < dims[0]) and (0 <= s[1] < dims[1])])
    display(result, target="day16_1-output")

if not 'js' in sys.modules:
    main_day16_1()
Scroll to see complete code

Day 16 (Part 2): The Floor Will Be Lava

A description for this part of the day's problem is coming soon.

  • day16_2.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                
from copy import deepcopy
import rich
import sys
from typing import Iterable


LEFT = (0, -1)
RIGHT = (0, 1)
UP = (-1, 0)
DOWN = (1, 0)

def get_next_tiles(coords: tuple[int, int], direction: tuple[int, int], data, dims: tuple[int, int]) -> Iterable[tuple[int, int]]:
    # Off edge of map
    if coords[0] < 0 or coords [0] >= dims[0] or \
        coords[1] < 0 or coords [1] >= dims[1]:
        return set()
    
    value = data[(coords[0], coords[1])]

    # Move through empty space and parallel splitters
    if value == '.' or \
        (direction in (LEFT, RIGHT) and value == '-') or \
        (direction in (UP, DOWN) and value == "|"):
        return [((coords[0] + direction[0], coords[1] + direction[1]), direction)]
    
    # Vertical Splitter
    if direction in (LEFT, RIGHT) and value == "|":
        return [((coords[0]-1, coords[1]), UP),
                 ((coords[0]+1, coords[1]), DOWN)]
    

    # Horizontal Splitter
    if direction in (UP, DOWN) and value == "-":
        return [((coords[0], coords[1]-1), LEFT),
                 ((coords[0], coords[1]+1), RIGHT)]
    
    # Bouncing Down
    if (direction == RIGHT and value == "\\") or \
        (direction == LEFT and value == "/"):
        return [((coords[0] + 1, coords[1]), DOWN)]
    
    # Bouncing Up
    if (direction == RIGHT and value == "/") or \
        (direction == LEFT and value == "\\"):
        return [((coords[0] - 1 , coords[1]), UP)]
    
    # Bouncing Left
    if (direction == DOWN and value == "/") or \
        (direction == UP and value == "\\"):
        return [((coords[0], coords[1] - 1), LEFT)]
    
    # Bouncing Right
    if (direction == DOWN and value == "\\") or \
        (direction == UP and value == "/"):
        return [((coords[0], coords[1] + 1), RIGHT)]
    
    raise ValueError("Unhandled Instruction")   

def printGrid(lasers, inp, illuminated_spaces):
    for line_index, line in enumerate(inp):
        for char_index, char in enumerate(line):
            lasers_here = [(laser[0][0], laser[0][1]) for laser in lasers if laser[0][0] == line_index and laser[0][1] == char_index]
            if lasers_here:
                rich.print('[red]X[/red]', end="")
            elif (line_index, char_index) in illuminated_spaces:
                rich.print(f'[blue]#[/blue]', end="")
            else: print(char, end = "")
        print("")

def score_16_2(start_space: tuple[int, int], start_dir: tuple[int, int], inp) -> int:
    data = {(line_index, char): value for line_index, line in enumerate(inp) for char, value in enumerate(line)}

    lasers = set([(start_space, start_dir),])
    illuminated_spaces = set([start_space,])
    old_lasers = set()

    dims = (len(inp), len(inp[0]))

    while lasers:
        new_lasers = set()
        for laser in lasers:
            new_lasers |= set(get_next_tiles(laser[0], laser[1], data, dims))
        if new_lasers <= old_lasers: break
        old_lasers |= new_lasers
        lasers = new_lasers
        illuminated_spaces = illuminated_spaces.union(set((laser[0][0], laser[0][1]) for laser in lasers))

    return len([s for s in illuminated_spaces if (0 <= s[0] < dims[0]) and (0 <= s[1] < dims[1])])

def main_day16_2(*args):
    inp = get_input("day16_2").split("\n")
    max_score = -1

    # Left and Right Sides
    for i in range(len(inp)):
        max_score = max(max_score, score_16_2((i, 0), RIGHT, inp))
        max_score = max(max_score, score_16_2((i, len(inp[0])-1), LEFT, inp))

    # Top and Bottom
    for i in range(len(inp[0])):
        max_score = max(max_score, score_16_2((0, i), DOWN, inp))
        max_score = max(max_score, score_16_2((len(inp)-1, i), UP, inp))
    
    display(max_score, target="day16_2-output")

if not 'js' in sys.modules:
    main_day16_2()
Scroll to see complete code

Day 18 (Part 1): Lavaduct Lagoon

A description for this part of the day's problem is coming soon.

  • day18_1.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import deque
from dataclasses import dataclass
from itertools import pairwise
import re    
import sys

@dataclass(frozen=True)
class Location:
    x: int
    y: int

inst = {
    "R": Location(x = 1, y = 0),
    "L": Location(x = -1, y = 0),
    "U": Location(x = 0, y = -1),
    "D": Location(x = 0, y = 1)
}

def score_shoelace(data):
    loc = Location(x = 0, y = 0)
    trench = [Location(0,0)]

    for line in data:
        m = re.match(r"(?P<dir>[RDLU]) (?P<num>\d)", line)
        dir, num = m.group("dir"), m.group("num")
        for _ in range(int(num)):
            loc = Location(loc.x + inst[dir].x, loc.y + inst[dir].y)
        trench.append(loc)
    
    for (one, two) in pairwise(trench):
        print(one, two)
    return sum((one.x * two.y) - (two.x * one.y) for (one, two) in pairwise(trench))

def get_trench(data):
    loc = Location(x = 0, y = 0)
    trench = set([Location(0,0)])

    for line in data:
        m = re.match(r"(?P<dir>[RDLU]) (?P<num>\d+)", line)
        dir, num = m.group("dir"), m.group("num")
        for _ in range(int(num)):
            loc = Location(loc.x + inst[dir].x, loc.y + inst[dir].y)
            trench.add(loc)
    assert loc == Location(0,0)
    return trench

def score_flood_fill(trench):
    left = min(loc.x for loc in trench)
    right = max(loc.x for loc in trench)
    top = min(loc.y for loc in trench)
    bottom = max(loc.y for loc in trench)

    print(f"{left=} {right=} {top=} {bottom=}")

    nodes = set([Location(left-1, top-1)])
    seen = set(nodes)

    while nodes:
        current = nodes.pop()
        seen.add(current)
        for new_node in (Location(current.x, current.y - 1),
                         Location(current.x, current.y + 1),
                         Location(current.x - 1, current.y),
                         Location(current.x + 1, current.y)):
            if new_node.x >= left - 1 and \
                new_node.x <= right + 1 and \
                new_node.y >= top - 1 and \
                new_node.y <= bottom + 1 and \
                new_node not in trench and \
                new_node not in seen:
                nodes.add(new_node)   
        print(len(nodes))

    with open("viz.txt", "w") as f:
        for y in range(top-1, bottom+2):
            for x in range(left-1, right+2):
                if Location(x, y) in seen: print("#",end = "" , file = f)
                else: print(".", end = "", file = f)
            print("",file = f)

    area = (right-left+3) * (bottom-top+3)
    print(f"Total area: {area=}")
    print(f"Flooded exterior = {len(seen)}")
    print(f"Interior = {area - len(seen)}")


def main_day18_1(*args):
    data = get_input("day18_1").split('\n')

    #result = score_shoelace(data)
    result = score_flood_fill(get_trench(data))

    display(result, target="day18_1-output")

if not 'js' in sys.modules:
    main_day18_1()
Scroll to see complete code

Day 18 (Part 2): Lavaduct Lagoon

A description for this part of the day's problem is coming soon.

  • day18_2.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from collections import deque
from dataclasses import dataclass
from itertools import pairwise
import re    
import sys

@dataclass(frozen=True)
class Location:
    x: int
    y: int

inst = {
    "R": Location(x = 1, y = 0),
    "L": Location(x = -1, y = 0),
    "U": Location(x = 0, y = -1),
    "D": Location(x = 0, y = 1)
}

def score_shoelace(trench, length):
    for (one, two) in pairwise(trench):
        print(one, two)
    return .5 * sum((one.x * two.y) - (two.x * one.y) for (one, two) in pairwise(trench)) + .5 * length + 1

def get_trench_2(data):
    loc = Location(x = 0, y = 0)
    trench = [Location(0,0)]
    length = 0 

    for line in data:
        m = re.search(r"(?P<num>[0-9a-f]{5})(?P<dir>[0-3])", line)
        num = int(m.group("num"), base=16)
        dir = str.translate(m.group("dir"), str.maketrans("0123", "RDLU"))
        loc = Location(loc.x + (inst[dir].x * num), loc.y + (inst[dir].y * num))
        length += num
        trench.append(loc)
    assert loc == Location(0,0)
    return trench, length

def main_day18_2(*args):
    data = get_input("day18_2").split('\n')

    result = score_shoelace(*get_trench_2(data))

    display(result, target="day18_2-output")

if not 'js' in sys.modules:
    main_day18_2()
Scroll to see complete code

Day 19 (Part 1): Aplenty

A description for this part of the day's problem is coming soon.

  • day19_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass      
import operator
import re
import sys
from typing import Iterable

@dataclass
class Part:
    x: int
    m: int
    a: int
    s: int
    status: str = "InProgress"
    rule: str = "in"

    pattern = r"(?P<attribute>[xmas])(?P<op>[<>])(?P<num>\d+)"

    def test_rule(self, rule: str) -> bool:
        m = re.match(self.pattern, rule)

        if m.group("op") == "<": op = operator.lt
        elif m.group("op") == ">": op = operator.gt
        else: raise ValueError(f"Op must be < or > not {m.group('op')}")

        return op(getattr(self, m.group("attribute")), int(m.group("num")))
    
    def apply_flow(self, flow: Iterable[str]):
        #print(f"{flow} to part {self}")
        for rule in flow:
            if rule == "A":
                self.status = "A"
                return
            if rule == "R":
                self.status = "R"
                return
            
            if not any(char in rule for char in ("<", ">")):
                #print(f"Jumping to new rule {rule}")
                self.rule = rule
                return
            
            prep, new_rule = rule.split(":")
            if self.test_rule(prep):
                self.rule = new_rule
                if self.rule == "A":
                    self.status = "A"
                    return
                if self.rule == "R":
                    self.status = "R"
                    return
                
                #print(f"Updated rule to {self.rule}")
                return
        
        raise BufferError(f"Should have found a way out of flow {flow}")
        


def main_day19_1(*args):
    raw_flows, raw_parts = get_input("day19_1").split("\n\n")

    part_pattern = r"\{x=(?P<x>\d+),m=(?P<m>\d+),a=(?P<a>\d+),s=(?P<s>\d+)\}"
    parts = []
    for line in raw_parts.split('\n'):
        m = re.match(part_pattern, line)
        parts.append(Part(x=int(m.group("x")), m=int(m.group("m")), a=int(m.group("a")), s=int(m.group("s"))))

    flow_pattern = r"(?P<name>[a-z]+)\{(?P<rest>.+)\}"
    flows = {}
    for line in raw_flows.split("\n"):
        m = re.match(flow_pattern, line)
        flows[m.group("name")] = m.group("rest").split(",")

    for part in parts:
        while part.status not in ("A", "R"):
            #print(f"Applying '{part.rule}'", end = "")
            part.apply_flow(flows[part.rule])
        print(f"Part has status {part.status}")

    result = sum(sum([p.x, p.m, p.a, p.s]) for p in parts if p.status == "A")
    display(result, target="day19_1-output")

if not 'js' in sys.modules:
    main_day19_1()
Scroll to see complete code

Day 19 (Part 2): Aplenty

A description for this part of the day's problem is coming soon.

  • day19_2.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass      
from math import prod
import re
import sys
from typing import Iterable, Self

@dataclass
class PartRange:
    x_min: int
    x_max: int
    m_min: int
    m_max: int
    a_min: int
    a_max: int
    s_min: int
    s_max: int
    status: str = "InProgress"
    rule: str = "in"

    pattern = r"(?P<attribute>[xmas])(?P<op>[<>])(?P<num>\d+)"

    @staticmethod
    def fromPartRange(other, x_min=None, x_max=None, m_min = None, m_max = None, a_min = None, a_max = None, s_min = None, s_max = None, rule=None):
        return PartRange(x_min=x_min or other.x_min, x_max=x_max or other.x_max,
                         m_min=m_min or other.m_min, m_max=m_max or other.m_max,
                         a_min=a_min or other.a_min, a_max=a_max or other.a_max,
                         s_min=s_min or other.s_min, s_max=s_max or other.s_max,
                         rule = rule or other.rule)

    def count(self):
        return prod([(self.x_max - self.x_min + 1),(self.m_max - self.m_min + 1),(self.a_max - self.a_min + 1),(self.s_max - self.s_min + 1)])
    
    def apply_flow(self, flow: str) -> Iterable[Self]:
        outgoing = []
        for rule in flow.split(","):
            if rule == "A": 
                self.status = "A"
                outgoing.append(self)
                return outgoing
            if rule == "R":
                self.status = "R"
                return outgoing
        
            if not any(char in rule for char in ("<", ">")):
                self.rule = rule
                outgoing.append(self)
                return outgoing
            
            prep, new_rule = rule.split(":")
            m = re.match(self.pattern, prep)

            attr = m.group("attribute")
            num = int(m.group("num"))

            if m.group("op") == "<":
                if int(getattr(self, attr+"_min")) <= num:
                    first_kwargs = {attr+ "_max": num-1}
                    outgoing.append(self.fromPartRange(self, rule=new_rule, **first_kwargs))
                    setattr(self, attr+"_min", num)
                    continue

            elif m.group("op") == ">":
                if int(getattr(self, attr+"_max")) >= num:
                    first_kwargs = {attr+ "_min": num+1}
                    outgoing.append(self.fromPartRange(self, rule=new_rule, **first_kwargs))
                    setattr(self, attr+"_max", num)
                    continue

            raise ValueError(f"Rule {rule} did not match anything")                
      

def main_day19_2(*args):
    raw_flows, raw_parts = get_input("day19_2").split("\n\n")

    flow_pattern = r"(?P<name>[a-z]+)\{(?P<rest>.+)\}"
    flows = {}
    for line in raw_flows.split("\n"):
        m = re.match(flow_pattern, line)
        flows[m.group("name")] = m.group("rest")

    ranges = [PartRange(x_min=1, x_max=4000,m_min=1, m_max=4000,a_min=1, a_max=4000,s_min=1, s_max=4000)]

    accepted = []
    while ranges:
        r = ranges.pop()
        if r.status == "A" or r.rule == "A":
            accepted.append(r)
        elif r.status == "R" or r.rule == "R":
            pass
        else:
            ranges.extend(r.apply_flow(flows[r.rule]))

    print(sum(r.count() for r in accepted))

    result = None
    display(result, target="day19_2-output")

if not 'js' in sys.modules:
    main_day19_2()
Scroll to see complete code

Day 20 (Part 1): Pulse Propagation

A description for this part of the day's problem is coming soon.

  • day20_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from math import prod
from operator import attrgetter
import re
import sys
from typing import Iterable, Mapping    

from pulse import pulse_list, Pulse, Level
from module import Broadcaster, FlipFlop, Conjunction, Dummy, Module

def push_button(modules: Mapping[str, Module]):
    #button = Broadcaster([modules['broadcaster']])
    pulse_list.append(Pulse(_from='button', to='broadcaster', level=Level.LO))
    
    while(pulse_list):
        p: Pulse = pulse_list.popleft()
        modules[p.to].receive_pulse(p)

def main_day20_1(*args):
    data = get_input("day20_1").split("\n")

    modules: dict[str, Module] = {}
    pattern = r"(?P<name>((?P<flag>[%&])(?P<label>[a-z]+))|broadcaster) -> (?P<destinations>[a-z ,]+)"
    # Build network
    for line in data:
        m = re.match(pattern, line)
        # Handle case with dummy 'output' destination
        if 'output' in m.group("destinations"):
            modules['output'] = Broadcaster(label="output", destinations=[])

        if m.group("flag") == "%":
            modules[m.group("label")] = FlipFlop(label=m.group("label"))
        elif m.group("flag") == "&":
            modules[m.group("label")] = Conjunction(label=m.group("label"))
        elif m.group("name") == "broadcaster":
            modules['broadcaster'] = Broadcaster(label="broadcaster")
        else:
            raise ValueError(f"Flag must be % or & or name must be 'broadcaster'; line was {line}")
    
    # Wire up modules
    for line in data:
        m = re.match(pattern, line)
        dests = [d.strip() for d in m.group("destinations").split(",")]
        if (label:= m.group("label")): modules[label].destinations = dests
        elif (name:= m.group("name")) == 'broadcaster': modules['broadcaster'].destinations = dests
        else: raise ValueError(f"Line was not a valid label or 'broadcaster': {line}")
        
        #Set up conjunction module inputs
        for d in dests:
            name = m.group("label") or 'broadcaster'
            if not d in modules: modules[d] = Dummy(label=name)
            if type(modules[d]) == Conjunction: modules[d].last_pulse[name] = Level.LO


    for i in range(1000):
        push_button(modules)

    print(f"{pulse_list.count=}")
    print(f"{prod(pulse_list.count.values())}")

    

    result = None
    display(result, target="day20_1-output")

if not 'js' in sys.modules:
    main_day20_1()
Scroll to see complete code

Day 21 (Part 1): Step Counter

A description for this part of the day's problem is coming soon.

  • day21_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)
                
import sys

def main_day21_1(*args):
    data = get_input("day21_1").split("\n")

    empties = set((line_index, char_index) for line_index, line in enumerate(data) for char_index, char in enumerate(line) if char == "." or char == "S")
    steps = set([(line_index, char_index) for line_index, line in enumerate(data) for char_index, char in enumerate(line) if char == 'S'])
    width = len(data[0])
    height = len(data)
    
    for _ in range(64):
        new_steps = set()
        for step in steps:
            new_steps |= set([(step[0]+1, step[1]),
                             (step[0]-1, step[1]),
                             (step[0], step[1]+1), 
                             (step[0], step[1]-1)])
        steps = new_steps &empties  

    print(steps)

    result = len(steps)

    display(result, target="day21_1-output")

if not 'js' in sys.modules:
    main_day21_1()
Scroll to see complete code

Day 24 (Part 1): Never Tell Me The Odds

A description for this part of the day's problem is coming soon.

  • day24_1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
try:
    from pyscript import document
    from utils import get_input
except ImportError:
    def get_input(*args):
        with open("input.txt") as f:
            return f.read()
        
    def display(*args, **kwargs):
        if 'target' in kwargs:
            kwargs.pop('target')
        print(*args, **kwargs)

from dataclasses import dataclass
from itertools import combinations
import re
import sys

@dataclass(frozen=True, slots=True)
class Stone:
    x: int
    y: int
    z: int
    vx: int
    vy: int
    vz: int
    slope_2d:int = None

def sign(x):
    if x == 0: return 0
    return abs(x)/x

def main_day24_1(*args):
    data = get_input("day24_1").split("\n")

    pattern = r"(-?\d+),\s+(-?\d+),\s+(-?\d+) @\s+(-?\d+),\s+(-?\d+),\s+(-?\d+)"

    stone_data = (re.match(pattern, line) for line in data)
    stones = [Stone(x=int(m.group(1)),
                    y=int(m.group(2)),
                    z=int(m.group(3)),
                    vx=int(m.group(4)),
                    vy=int(m.group(5)),
                    vz=int(m.group(6)),
                    slope_2d=int(m.group(5))/int(m.group(4)))
                    for m in stone_data]
    
    count = 0
    bounds = (200000000000000, 400000000000000)
    for a, b in combinations(stones, 2):
        if a.slope_2d == b.slope_2d: continue # parallel
        x_cross = (a.slope_2d * a.x - b.slope_2d * b.x - a.y + b.y) / (a.slope_2d - b.slope_2d)
        y_cross = a.slope_2d * (x_cross - a.x) + a.y
        if bounds[0] <= x_cross <= bounds[1] and bounds[0] <= y_cross <= bounds[1] and \
            sign(x_cross - a.x) == sign(a.vx) and sign(x_cross - b.x) == sign(b.vx):
            count += 1

    display(count, target="day24_1-output")

if not 'js' in sys.modules:
    main_day24_1()
Scroll to see complete code
packages=['rich'] [js_modules.main] "https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm" = 'xterm' "https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" = 'xterm' [files] "./utils.py" = "./utils.py"