The eleventh day of Advent of Code was yesterday 11/12/2024! If you’re here for the first time today, don’t forget to read the posts about Day 1, Day 2, Day 3, Day 4, Day 5, Day 6, Day 7, Day 8, Day 9 and Day 10.

I classified the eleventh problem as easy (part 1) and medium (part 2).

Tips#

  • Both part 1 and part 2 use the same rules to generate stones.
  • A stone 0 becomes a stone 1
  • A stone with an even number of digits becomes two stones
  • If it doesn’t fall into any of these conditions, the stone value is multiplied by 2024.
  • Stone generation in part 1 is quite interesting and easy to implement.
  • The problem provides several test values that you should use to validate your functions
  • You can use any of the answers for part 1.
  • Part 2 is complex because there isn’t enough space to store so many stones. There are hundreds of trillions of stones, storing each one in a list element isn’t practical.
  • In part 2, remember that the goal is to calculate the number of stones, not the value of the stones themselves.
  • When a stone has an even number of digits, it generates two stones. All other values generate only one stone. If you consider this property, you can increment the stone counter without storing all stones.

How much Python do you need to know?#

The chapters refer to my book Introduction to Programming with Python.

  • Lists, dictionaries - Chapter 6
  • Strings - Chapter 7
  • Recursive functions - Chapter 8
  • Part 2 is more complex, requiring an understanding of computational cost to optimize the solution.

Answer#

Today’s problem has several possible solutions, see the difference between them, especially the execution time in the comments below. They also have very different memory requirements.

  • The dictionary solution is the most efficient (92ms).
  • The recursive solution with cache is the second fastest (270ms).
  • The sum_stones function can calculate the final result, but takes 27 minutes.
  • The naive solution is the least efficient and doesn’t run in part 2.

The dictionary solution is only possible because the stone generation pattern is repetitive, generating a maximum of 3881 keys after 81 cycles. This way, the dictionary doesn’t grow indefinitely and can be used to store the number of stones.

from functools import cache
from collections import defaultdict


TEST_INPUT = "125 17"
INPUT_1 = "773 79858 0 71 213357 2937 1 3998391"

# Values for stone generation tests
TEST_INPUTS = [
    [253000, 1, 7],
    [253, 0, 2024, 14168],
    [512072, 1, 20, 24, 28676032],
    [512, 72, 2024, 2, 0, 2, 4, 2867, 6032],
    [1036288, 7, 2, 20, 24, 4048, 1, 4048, 8096, 28, 67, 60, 32],
    [2097446912, 14168, 4048, 2, 0, 2, 4, 40, 48, 2024, 40, 48, 80, 96, 2, 8, 6, 7, 6, 0, 3, 2],
]


def string_to_int_list(input):
    return [int(x) for x in input.split()]


# Simple or naive version
# Uses lists to simulate the stone generation process
# But gets very slow after 30 or 40 throws (can't be used in part 2)
def simulate(input: list[int]) -> list[int]:
    output = []
    for i in input:
        if i == 0:
            i = 1
        elif (ls := len(s := str(i))) % 2 == 0:
            ls //= 2
            output += [int(s[0:ls]), int(s[ls:])]
            continue
        else:
            i *= 2024
        output.append(i)

    return output


# Naive version, but without using append.
# Uses a fixed list with double the size of the input
# Faster than the naive version, but still too slow for 75 throws
def simulate_without_append(input: list[int]) -> list[int]:
    output = [0] * len(input) * 2
    position = 0
    for i in input:
        if i == 0:
            i = 1
        elif (ls := len(s := str(i))) % 2 == 0:
            ls //= 2
            output[position] = int(s[0:ls])
            output[position + 1] = int(s[ls:])
            position += 2
            continue
        else:
            i *= 2024
        output[position] = i
        position += 1
    return output[:position]


# Solutions for part 2


# Checks the stone and calculates resulting stones
@cache
def multiply_stone(stone: int) -> list[int]:
    if stone == 0:
        return [1]
    elif (ls := len(s := str(stone))) % 2 == 0:
        ls //= 2
        return [int(s[0:ls]), int(s[ls:])]
    else:
        return [stone * 2024]


# Simulates the stone generation process, using cache to generate stones
# Doesn't solve the problem, as it still uses lists to simulate the process
def simulate_with_cache(input: list[int]) -> list[int]:
    output = [0] * len(input) * 2
    position = 0
    for i in input:
        for stone in multiply_stone(i):
            output[position] = stone
            position += 1
    return output[:position]


# Creates a cache with stones.
@cache
def multiply_stone_throws(stone: int, throws: int) -> list[int]:
    stones = [stone]
    for _ in range(throws):
        stones = simulate_with_cache(stones)
    return stones


# Sums stones using cache. Does a depth-first search
# Runs in approximately 27 minutes with 75 throws!
# Works, but far from efficient
def sum_stones(stones: list[int], throws: int) -> int:
    STEP = 25  # Number of throws to simulate per call
    if throws == 0:
        return len(stones)
    total = 0
    for stone in stones:
        new_stones = multiply_stone_throws(stone, STEP)
        if throws - STEP > 0:
            total += sum_stones(new_stones, throws - STEP)
        else:
            total += len(new_stones)
    return total


# Best solution for part 2
# Uses a dictionary to store stones and their quantities
# Runs in approximately 92ms with 75 throws
def sum_stones_with_dictionary(input: list[int], throws: int) -> int:
    d = defaultdict(int)
    for v in input:
        d[v] += 1
    for _ in range(throws):
        new_d = defaultdict(int)
        for stone in d.keys():
            if stone == 0:
                new_d[1] += d[0]
            elif (ls := len(s := str(stone))) % 2 == 0:
                ls //= 2
                new_d[int(s[0:ls])] += d[stone]
                new_d[int(s[ls:])] += d[stone]
            else:
                new_d[stone * 2024] += d[stone]
        d = new_d
    return sum(d.values())


# Second best solution for part 2
# Uses a recursive solution with cache to calculate stones
# Runs in approximately 270ms with 75 throws
@cache
def solve_recursive(stone, throws) -> int:
    if throws == 0:
        return 1

    if stone == 0:
        return solve_recursive(1, throws - 1)

    if len(str(stone)) % 2 == 0:
        sst = str(stone)
        a, b = sst[: len(sst) // 2], sst[len(sst) // 2 :]
        return solve_recursive(int(a), throws - 1) + solve_recursive(
            int(b), throws - 1
        )

    return solve_recursive(stone * 2024, throws - 1)


def simulate_blinks(input, blinks) -> int:
    for x in range(blinks):
        input = simulate(input)
    return len(input)


# Part 1 - Test
input = string_to_int_list(TEST_INPUT)
print("Tests [stones] number of stones")
for blink in range(6):
    print(blink + 1, input := simulate(input), len(input))
    assert TEST_INPUTS[blink] == input

input = string_to_int_list(TEST_INPUT)
assert simulate_blinks(input, 6) == 22

input = string_to_int_list(TEST_INPUT)
assert simulate_blinks(input, 25) == 55312
print()

# Part 1 - Input 1
input_part_1 = string_to_int_list(INPUT_1)
print("Part 1: ", total := simulate_blinks(input_part_1, 25))
assert total == 199982

# Test of recursive solution with sum_stones
assert sum_stones(string_to_int_list(INPUT_1), 25) == 199982

# The solution with sum_stones works for 75 throws, but takes over 27 minutes to run!

# Part 2 - Dictionary solution +/-92ms
print(
    "Part 2 with dictionary:",
    total := sum_stones_with_dictionary(string_to_int_list(INPUT_1), 75),
)
assert total == 237149922829154
# Part 2 - Recursive solution +/- 270ms
print(
    "Part 2 - recursive:",
    total := sum(
        [solve_recursive(s, 75) for s in string_to_int_list(INPUT_1)]
    ),
)
assert total == 237149922829154

Acknowledgments#

Thanks to Tyrone Damasceno who shared the dictionary solution and the solve_recursive function for part 2. His github can be accessed here.