Python patterns I learned during 2023's Advent of Code

Last year, I used python for Advent of Code and loved it. It felt like there was always a beautiful built-in way of doing whatever it was I was trying to do. I wanted to count something? Counter? Compare it against something else? It just works. Create a set? Easy. Navigate in 2d space? Imaginary numbers to the rescue. I doubt any of this will be useful for someone who writes python day to day or who wants to use python to solve problems that don't involve feisty elves, but if you're thinking about exploring python for Advent of Code, some of these tricks might help:

  1. Split on \n\n to make processing data easier. This is an Advent of Code specific technique, but there were will often be multiple sections to the input, and splitting on \n\n is an easy way to break up those sections for processing.

  2. Speaking of parsing the input, pulling the numbers out of sentences comes up over and over again:

    import re
    map(int, re.findall("-?\d+", "Move 2 paces to the right. Then -3 paces up."))
    
  3. When a regular expression doesn't make sense, "20".isdigit() is worth knowing about. (This will not handle negative numbers)

  4. One year when tackling Advent of Code in JS, I typed out the alphabet for a problem that needed it, and it took me a long time to realize I was missing an R. You can grab an alphabet pretty easily from string: from string import ascii_lowercase, but you can also use ord and chr to move back and forth between an ordinal number and a character.

    "".join(chr(n) for n in range(ord("A"), ord("A") + 26))
    
  5. When a problem requires navigating in 2D space, I love using imaginary numbers because they make it easy to add a vector to a point to get a new point. It's a small thing, but the less code I need to write, the fewer mistakes I can make:

    curr = 0
    for d in ((1, -1, 1j, -1j)):
        do_thing(curr + d)
    # compared to
    curr = (0, 0)
    for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
        do_thing((curr[0] + dx, curr[1] + dy))
    
  6. I use imaginary numbers rather than tuples for navigating 2D space, but I still love tuples. One thing I didn't realize at first is that you can use tuples as keys to objects without needing to convert them to strings first.

  7. Speaking of data structures, all of the data structures in collections are fantastic.

    a. Counter does exactly what it says on the tin. Counter("aaabbc") can even be compared against another Counter: Counter("aaa") > Counter("aa") # True.

    b. defaultdict is essential when setting up data structures. defaultdict(list) will let you avoid writing code like my_dict["key"] = my_dict.get("key", []) to make sure that a key exists before accessing it.

  8. heapq ("heap queue") is also worth knowing if you need to implement Djikstra's or anything else that needs a heap. In JS-land, manually implementing a heap to solve a problem is a bummer. heapq's usage feels a bit odd: you start with a list, and then call heapq methods on it that use that list as the storage medium for the heap. It's not its own data structure:

    h = []
    heappush(h, (5, 'santa'))
    heappush(h, (7, 'reindeer'))
    heappush(h, (1, 'elf'))
    heappush(h, (3, 'sled'))
    heappop(h) # (1, 'elf')
    

    heapq won't be able to compare complex numbers, so if you're implementing Djiksta's with complex numbers forming points, you may need to add a second parameter (like random.random() or an i that increments for tie-breaks) that you can ignore: heapq.heappush(h, (distance, random.random(), 1 + 1j))

  9. itertools makes it simple to iterate through all combinations or permutations of a set: k for k in permutations("🐉🥕😊") # ('🐉', '🥕', '😊'), ('🐉', '😊', '🥕'), ('🥕', '🐉', '😊'), ('🥕', '😊', '🐉'), ('😊', '🐉', '🥕'), ('😊', '🥕', '🐉').

  10. zip can let you transpose a matrix (zip(*matrix)) or pair two arrays (zip(a, b)).

    zip also makes it easy to pair up items from an array. Say you had an array of numbers [1, 2, 3, 4, 5, 6] and wanted to multiply each pair before adding them: sum(a * b for a, b in zip(nums[::2], nums[1::2])).

    Similarly, you might want to see whether an array is in ascending order: all(b > a for a, b in zip(nums, nums[1:]))

  11. You can use True/False to index into arrays or to increment a counter. Should you do this in real code? No. Should you do this in Advent of Code code? Also, no.

    for thing in things:
        counter[thing["type"]] += thing["field"] == "my_field"
    
  12. Structural pattern matching is fantastic! Being able to write quick case statements about the shape of something leads to some tight code:

    def compare (left, right):
        match left, right:
            case int(), int(): return (left < right) - (right < left)
            case int(), list(): return compare([left], right)
            case list(), int(): return compare(left, [right])
            case list(), list():
                for result in map(compare, left, right):
                    if result: return result
                return compare(len(left), len(right))
    
  13. functools.cache makes it simple to memoize a function:

    import functools
    @functools.cache
    def this_has_a_cache (a):
        return a + 1
    

Lessons from 2024