{ "cells": [ { "cell_type": "markdown", "id": "bd491a25-2d77-441b-8c64-e4871329b43c", "metadata": {}, "source": [ "
Peter Norvig, December 2025
\n", "\n", "# Advent of Code 2025: The AI LLM Edition\n", "\n", "*This notebook shows some solutions by Gemini, Claude, and ChatGPT, three AI Large Language Models (LLMs), for the 2025 [**Advent of Code**](https://adventofcode.com/) (AoC) programming puzzles. In order to understand each puzzle, you'll have to look at the problem descriptions at [**Advent of Code**](https://adventofcode.com/2025) for each [**Day**](https://adventofcode.com/2025/day/1), and you can also look at [**my solutions**](Advent2025.ipynb), which I did before asking the LLMs for theirs.*\n", "\n", "*All the code in this notebook is written by an LLM (except for the one line where I call the LLM's code for each puzzle). My comments (like this one) are in italics, and my prompts given to the LLMs are in **bold italics**. Sometimes I quote the LLM's responses; those are in* regular roman font.\n", "\n", "*Each day I'll choose a model and give it the prompt* \"***Write code to solve the following problem:***\" *followed by the full text of the AoC Part 1 problem description. Then I'll prompt again with* \"***There is a change to the specification:***\" *followed by the AoC Part 2 description.*\n", "\n", "# Conclusions\n", "\n", "*Now that the 12 days are finished, here are my conclusions:*\n", "\n", "- *Overall, the LLMs did **very well**, producing code that gives the correct answer to every puzzle.*\n", "- *I'm beginning to think I should use an LLM as an assistant for all my coding, not just as an experiment like this.*\n", "- *This is a huge improvement over just one year ago, when LLMs could not perform anywhere near this level.*\n", "- *The three LLMS seemed to be roughly equal in quality.*\n", "- *I neglected to track the time it took them to produce the code, but it was a lot faster than me–maybe 20 times faster.*\n", "- *The LLMs knew the things you would want an experienced software engineer to know:*\n", " - *How to see through the story about elves and christmas trees, etc. and get to the real programming issues*\n", " - *Standard Python syntax, builtin types, and basic modules (e.g. `collections`, `functools`, `typing`, `numpy`)*\n", " - *using the `re` module and/or `str.split` to parse input, even when it is in a somewhat tricky format*\n", " - *modular arithmetic*\n", " - *memoization / caching / dynamic programming*\n", " - *O*(*n*) *reasoning (such as knowing that enumerating up to √n is better than up to n)*\n", " - *stacks and queues*\n", " - *sorting and merging*\n", " - *when to use sets versus lists*\n", " - *handling a 2D grid of points with 4 or 8 directional neighbors*\n", " - *accumulating sums in a defaultdict or Counter*\n", " - *advanced esoteric data structures such as Union-Find and dancing links*\n", " - *computational geometry algorithms including scan lines, flood fill, and ray-casting*\n", " - *recognizing an integer linear programming problem and knowing how to call a package*\n", " - *depth-first search, meet-in-the-middle search, and recognizing search properties such as commutativity of actions*\n", " - *data classes*\n", " - *sometimes type annotations (on about 1/3 of the solutions)*\n", " - *sometimes good doc strings and comments (but not always, and sometimes too many comments).*\n", "- *Problems 9.2 and 12.1 had a \"trick\" that allowed for a simpler, faster solution. For these I gave ChatGPT my input file, so it would have a chance of finding the trick. It didn't, but perhaps it was trying to solve the general problem over all possible inputs, whereas I understood that if AoC persents a trick input to me, they will present the same trick input to everyone.*\n", "- *Much of the code (from all 3 LLMs) could be improved stylistically. In many cases the code was one long function that has the parsing of input, the intermediate processing, and the return of the final result all intertwined, clearly violating Robert Maartin's maxim of \"Functions should do one thing.\"*\n", "- *The run time were reasonably fast, all under half a second. However, this was about 3 times slower than my code. (I'm not counting 12.1, which had a run time of about 2 minutes, but missed the \"trick\" that no search is actually required. It would be unfair to compare it to my code, which ran much faster, but would fail if a search was required.)*\n", "- *The LLMs' code is about five times more verbose than mine. There are a few reasons:*\n", " - *I benefited from a few key utility functions to do things like \"return a tuple of all the integers in a text string.\" For most problems I could parse the input in 1 or 2 lines of code, while the LLM would take 20.*\n", " - *The LLMs were being extra robust in doing error checking, while I recognized that within the confines of an AoC puzzle the input will always follow the prescribed format exactly.*\n", " - *I use a functional style; the LLMs were imperative. So I did `sum(int(max_joltage(bank)) for bank in banks)` whereas ChatGPT did:*\n", "\n", " total = 0\n", " for line in input_text.strip().splitlines():\n", " line = line.strip()\n", " if not line:\n", " continue\n", " total += max_joltage_for_bank(line)\n", " return total\n", "\n", "\n", "***Note:*** *For brevity, I have removed some of the LLM output, such as:*\n", "- *Usage examples on how to run the program on the test input*.\n", "- *Prose analysis of the problem, descriptions of the programs, and chain of thought thinking.*\n", " - *In most cases these were accurate and thorough! Great job! But they were too long to include here.*\n", "- *The \"`#!/usr/bin/env python3`\" and \"`__main__`\" idioms for command line execution.*\n", " - *In retrospect, I should have specified in the prompt that \"**You can get the input text as a single string with get_text(day)\"***\n", "\n", "# Day 0: Human\n", "\n", "*On Day 0 I load my [**AdventUtils.ipynb**](AdventUtils.ipynb) so I can access two of my utilities:*\n", "- *`get_text(day)` returns the complete text of the input file for `current_year` and `day`*\n", "- *`answer` verifies whether the LLM got the right answer to the problem, and records the run time.*" ] }, { "cell_type": "code", "execution_count": 1, "id": "34563e0b-09c5-4600-a455-3ff0f31b81a0", "metadata": {}, "outputs": [], "source": [ "%run AdventUtils.ipynb\n", "current_year = 2025" ] }, { "cell_type": "markdown", "id": "39657fac-2a12-43d5-ab79-024ebceeadc4", "metadata": {}, "source": [ "# Day 1: Gemini 3 Pro\n", "\n", "*The [**Day 1 Part 1**](https://adventofcode.com/2025/day/1) puzzle is about turning the dial on a safe and counting how many times the pointer ends up at 0.*\n", "\n", "*I started with the Gemini 3 Pro Fast model, which produced this code:*" ] }, { "cell_type": "code", "execution_count": 2, "id": "a03354cc-951c-4609-80e1-a7e1a6d0c2bd", "metadata": {}, "outputs": [], "source": [ "def solve_safe_password(input_str):\n", " \"\"\"\n", " Simulates the safe dial rotations and counts how many times the dial\n", " lands on 0.\n", "\n", " Args:\n", " rotations: A list of strings, each representing a rotation (e.g., \"L68\").\n", "\n", " Returns:\n", " The total number of times the dial points at 0 after a rotation.\n", " \"\"\"\n", " \n", " # Split the single string into individual lines\n", " rotations = input_str.strip().split('\\n')\n", " \n", " # The dial starts at 50\n", " current_position = 50\n", " zero_count = 0\n", " \n", " # The dial has numbers 0 through 99, so all math is modulo 100\n", " MODULO = 100\n", "\n", " for rotation in rotations:\n", " # 1. Parse the instruction\n", " direction = rotation[0]\n", " # Distance is the rest of the string converted to an integer\n", " distance = int(rotation[1:])\n", "\n", " # 2. Determine the rotation value\n", " if direction == 'R':\n", " rotation_value = distance\n", " elif direction == 'L':\n", " # Left rotations subtract from the current position\n", " rotation_value = -distance\n", " else:\n", " # Should not happen based on problem description\n", " print(f\"Error: Unknown direction '{direction}' in rotation: {rotation}\")\n", " continue\n", "\n", " # 3. Calculate the new position and apply Modulo 100\n", " # The standard Python % operator handles negative numbers correctly\n", " # for a positive modulus (e.g., (-1 - 99) % 100 == 0).\n", " new_position = (current_position + rotation_value) % MODULO\n", "\n", " # 4. Check for Zero\n", " if new_position == 0:\n", " zero_count += 1\n", " \n", " # 5. Update Position\n", " current_position = new_position\n", " \n", " # Optional: Print step-by-step for debugging/verification\n", " # print(f\"Rotation {rotation}: new position = {new_position}, zero_count = {zero_count}\")\n", "\n", " return zero_count" ] }, { "cell_type": "markdown", "id": "c5219309-4f2f-49b3-8208-8c7a3526e328", "metadata": {}, "source": [ "*A bit verbose and over-commented (and under-type-annotated), but pretty nice. Below I fetch my Day 1 data with `get_text(1)`, pass it to `solve_safe_password`, and verify that Gemini's answer is correct:*" ] }, { "cell_type": "code", "execution_count": 3, "id": "c3ff875a-6267-47b8-a032-115a40bfd246", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 1.1: 0.7 msec, correct answer: 1182 " ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "rotations = get_text(1)\n", "\n", "answer(puzzle=1.1, solution=1182, code=lambda: \n", " solve_safe_password(rotations))" ] }, { "cell_type": "markdown", "id": "47a04265-2dab-4a4c-9a24-e1c4c1b93c6b", "metadata": {}, "source": [ "*For **Part 2** I gave Gemini Fast the updated instructions (that we should count a zero crossing in the middle of a rotation), and again it had a nice analysis and the following code:*" ] }, { "cell_type": "code", "execution_count": 4, "id": "a1cf0e58-4567-4713-9d4b-e57f5c958800", "metadata": {}, "outputs": [], "source": [ "import math\n", "\n", "def solve_new_safe_password(input_str):\n", " \"\"\"\n", " Simulates the safe dial rotations and counts every time the dial\n", " points at 0, including during the rotation movement.\n", "\n", " Args:\n", " rotations: A list of strings, each representing a rotation (e.g., \"L68\").\n", "\n", " Returns:\n", " The total number of times the dial points at 0.\n", " \"\"\"\n", " \n", " # Split the single string into individual lines\n", " rotations = input_str.strip().split('\\n')\n", " \n", " # The dial starts at 50\n", " current_position = 50\n", " total_zero_crossings = 0\n", " MODULO = 100\n", "\n", " for rotation in rotations:\n", " direction = rotation[0]\n", " distance = int(rotation[1:])\n", " \n", " S = current_position # Start Position\n", " D = distance # Distance\n", " \n", " crossings_for_this_rotation = 0\n", "\n", " if direction == 'L':\n", " # --- LEFT ROTATION ---\n", " # Dial moves from S -> S-1 -> ... -> 0 -> 99 -> ...\n", " \n", " # 1. Distance from S to the first 0: S clicks\n", " # If D is large enough to cover this distance, the first 0 is crossed.\n", " \n", " if D >= S:\n", " # The first crossing (at position 0) is achieved\n", " crossings_for_this_rotation += 1\n", " \n", " # 2. Remaining distance after first 0 crossing\n", " remaining_distance = D - S\n", " \n", " # 3. Subsequent crossings: The dial crosses 0 every 100 clicks\n", " # after the first one.\n", " crossings_for_this_rotation += remaining_distance // MODULO\n", " \n", " elif direction == 'R':\n", " # --- RIGHT ROTATION ---\n", " # Dial moves from S -> S+1 -> ... -> 99 -> 0 -> 1 -> ...\n", " \n", " # 1. Distance from S to the first 0: 100 - S clicks\n", " # (e.g., 50 to 0 is 50 clicks; 99 to 0 is 1 click)\n", " distance_to_first_zero = MODULO - S\n", " \n", " if D >= distance_to_first_zero:\n", " # The first crossing (at position 0) is achieved\n", " crossings_for_this_rotation += 1\n", " \n", " # 2. Remaining distance after first 0 crossing\n", " remaining_distance = D - distance_to_first_zero\n", " \n", " # 3. Subsequent crossings: The dial crosses 0 every 100 clicks\n", " crossings_for_this_rotation += remaining_distance // MODULO\n", " \n", " # Add the crossings for this rotation to the total\n", " total_zero_crossings += crossings_for_this_rotation\n", "\n", " # Calculate the final position to set up for the next rotation\n", " if direction == 'R':\n", " rotation_value = distance\n", " else: # 'L'\n", " rotation_value = -distance\n", " \n", " current_position = (current_position + rotation_value) % MODULO\n", "\n", " return total_zero_crossings" ] }, { "cell_type": "markdown", "id": "f8e02f20-87b1-4bd1-9bdb-2bd187fc42cb", "metadata": {}, "source": [ "*Note that the code does `import math` but then never uses the math module. (I've done that, but I try to eventually clean things up. Maybe that's the different between Gemini \"Fast\" and Gemini \"Thinking\" models.)*\n", "\n", "*Here I check whether the code is correct:*" ] }, { "cell_type": "code", "execution_count": 5, "id": "a0b3e0fc-e044-4c1e-b4b6-6bc735145f57", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 1.2: 0.8 msec, WRONG!! answer: 7509 ; EXPECTED: 6907" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(1.2, 6907, lambda: \n", " solve_new_safe_password(rotations))" ] }, { "cell_type": "markdown", "id": "d640d8e4-6940-416d-93af-b55d82482a35", "metadata": {}, "source": [ "*Too bad; the code has a **bug**. I switched to the Gemini 3 Pro \"Thinking\" model and gave the prompt \"**That's not quite right**.\" It responded with*\n", "\n", " It seems I made a subtle logic error in my previous calculation for the Right (R) rotation.\n", " When the dial is at 0, the distance to the *next* zero is **100**, not 0.\n", "\n", "*which is exactly the error I made on *my* first attempt! Gemini produced the following updated code:*" ] }, { "cell_type": "code", "execution_count": 6, "id": "196a6a93-e318-4a45-a023-9997fa0fb0f5", "metadata": {}, "outputs": [], "source": [ "def solve_safe_password_method_2(input_str):\n", " \"\"\"\n", " Simulates the safe dial rotations and counts every time the dial\n", " points at 0 (Method 0x434C49434B).\n", " \n", " Correctly handles cases where the dial starts at 0.\n", " \"\"\"\n", " \n", " # Split the single string into individual lines\n", " rotations = input_str.strip().split('\\n')\n", " \n", " current_position = 50\n", " total_zero_crossings = 0\n", " MODULO = 100\n", "\n", " for rotation in rotations:\n", " direction = rotation[0]\n", " distance = int(rotation[1:])\n", " \n", " # Determine distance required to hit the *next* 0\n", " if direction == 'L':\n", " if current_position == 0:\n", " dist_to_first_zero = 100\n", " else:\n", " dist_to_first_zero = current_position\n", " \n", " elif direction == 'R':\n", " if current_position == 0:\n", " dist_to_first_zero = 100\n", " else:\n", " dist_to_first_zero = 100 - current_position\n", "\n", " # Check if the rotation distance is enough to reach the first zero\n", " if distance >= dist_to_first_zero:\n", " # We hit 0 at least once\n", " count = 1\n", " \n", " # Remove the distance traveled to the first zero\n", " remaining_distance = distance - dist_to_first_zero\n", " \n", " # Add any subsequent full rotations (every 100 clicks hits 0 again)\n", " count += remaining_distance // 100\n", " \n", " total_zero_crossings += count\n", "\n", " # Update position for next step\n", " if direction == 'R':\n", " current_position = (current_position + distance) % 100\n", " else:\n", " current_position = (current_position - distance) % 100\n", "\n", " return total_zero_crossings" ] }, { "cell_type": "markdown", "id": "e05db80b-c56d-4e1f-988c-a09be8ca5a3e", "metadata": {}, "source": [ "*This second try is indeed correct:*" ] }, { "cell_type": "code", "execution_count": 7, "id": "021043ed-2ac5-4aaf-8171-985f0f5911f2", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 1.2: 0.8 msec, correct answer: 6907 " ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(1.2, 6907, lambda: \n", " solve_safe_password_method_2(rotations))" ] }, { "cell_type": "markdown", "id": "82fb1dca-1619-4ad7-9155-52fb4804470e", "metadata": {}, "source": [ "# Day 2: Claude Opus 4.5\n", "\n", "*For [**Day 2 Part 1**](https://adventofcode.com/2025/day/2) Claude wrote code that produces the correct answer but prints a lot of unneccessary debugging output along the way. I prompted it to \"**Change the code to not print anything, just return the answer**\" and got this:*" ] }, { "cell_type": "code", "execution_count": 8, "id": "8eac98f3-b884-4d95-b38b-ea4365ec3004", "metadata": {}, "outputs": [], "source": [ "def find_invalid_ids_in_range(start, end):\n", " invalid_ids = []\n", " min_digits = len(str(start))\n", " max_digits = len(str(end))\n", " \n", " for total_digits in range(min_digits, max_digits + 1):\n", " if total_digits % 2 != 0:\n", " continue\n", " \n", " half_digits = total_digits // 2\n", " pattern_start = 1 if half_digits == 1 else 10 ** (half_digits - 1)\n", " pattern_end = 10 ** half_digits - 1\n", " for pattern in range(pattern_start, pattern_end + 1):\n", " pattern_str = str(pattern)\n", " invalid_id = int(pattern_str + pattern_str)\n", " if start <= invalid_id <= end:\n", " invalid_ids.append(invalid_id)\n", " \n", " return invalid_ids\n", "\n", "\n", "def solve(input_str):\n", " input_str = input_str.strip().replace('\\n', '').replace(' ', '').rstrip(',')\n", " total = 0\n", " for r in input_str.split(','):\n", " if r:\n", " start, end = map(int, r.split('-'))\n", " total += sum(find_invalid_ids_in_range(start, end))\n", " return total" ] }, { "cell_type": "markdown", "id": "2bd0db00-952b-47e5-b787-b3887b7539f1", "metadata": {}, "source": [ "*This code is overall rather nice, but conspicously lacks comments and doc strings. (If you can't tell what the code is doing without comments, the goal is to count the number of invalid IDs, which are numbers that consist of the same digit string repeated twice, like \"100100\", within some ID ranges.) It uses the more efficient \"enumerate over the first half of the digit string\" strategy, but is not precise in narrowing down the range it enumerates over. For example, for the range \"999000-109000\", this code will enumerate the range (100, 1000), when it could enumerate just the range (999, 1000).*\n", "\n", "*I verified that the code gives the correct answer:*" ] }, { "cell_type": "code", "execution_count": 9, "id": "a91845ec-ace7-482e-b0b5-8a620ef3461f", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 2.1: 36.7 msec, correct answer: 23560874270 " ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_str = get_text(2)\n", "\n", "answer(2.1, 23560874270, lambda:\n", " solve(input_str))" ] }, { "cell_type": "markdown", "id": "a31d006f-8cf2-4e4c-92d3-d7b7def22227", "metadata": {}, "source": [ "*When given the **Part 2** instructions (where invalid IDs now can be any number of repetitions, like \"100100100\"), Claude wrote:*" ] }, { "cell_type": "code", "execution_count": 10, "id": "f0dc176b-dd85-40a4-ac5c-dfa936a6a524", "metadata": {}, "outputs": [], "source": [ "def find_invalid_ids_in_range(start, end):\n", " invalid_ids = set()\n", " min_digits = len(str(start))\n", " max_digits = len(str(end))\n", " \n", " for total_digits in range(min_digits, max_digits + 1):\n", " # Pattern length can be 1 to total_digits//2 (need at least 2 repetitions)\n", " for pattern_len in range(1, total_digits // 2 + 1):\n", " if total_digits % pattern_len != 0:\n", " continue\n", " \n", " repetitions = total_digits // pattern_len\n", " if repetitions < 2:\n", " continue\n", " \n", " pattern_start = 1 if pattern_len == 1 else 10 ** (pattern_len - 1)\n", " pattern_end = 10 ** pattern_len - 1\n", " \n", " for pattern in range(pattern_start, pattern_end + 1):\n", " pattern_str = str(pattern)\n", " invalid_id = int(pattern_str * repetitions)\n", " if start <= invalid_id <= end:\n", " invalid_ids.add(invalid_id)\n", " \n", " return invalid_ids\n", "\n", "\n", "def solve(input_str):\n", " input_str = input_str.strip().replace('\\n', '').replace(' ', '').rstrip(',')\n", " total = 0\n", " for r in input_str.split(','):\n", " if r:\n", " start, end = map(int, r.split('-'))\n", " total += sum(find_invalid_ids_in_range(start, end))\n", " return total" ] }, { "cell_type": "markdown", "id": "287ed190-e8c3-4aa8-a01b-b4ecd97327b5", "metadata": {}, "source": [ "*Claude's code produces the correct answer:*" ] }, { "cell_type": "code", "execution_count": 11, "id": "9c0049e6-a992-4aa8-a2d7-3ea748e361a6", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 2.2: 38.2 msec, correct answer: 44143124633 " ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(2.2, 44143124633, lambda:\n", " solve(input_str))" ] }, { "cell_type": "markdown", "id": "d3533d6a-d12f-4dbf-b0e8-9d878c9bc283", "metadata": {}, "source": [ "# Day 3: ChatGPT 5.1 Auto\n", "\n", "*The [**Day 3**](https://adventofcode.com/2025/day/3) puzzle is to pick the biggest two-digit number from a string of digits, like \"89\" from \"8675309\". The task is to compute the sum of biggest two-digit numbers over all the input digit strings.*\n", "\n", "*For **Part 1** ChatGPT gave a very brief analysis of the problem and produced this code (conspicuously lacking comments or doc strings):*" ] }, { "cell_type": "code", "execution_count": 12, "id": "3aa266f3-50d0-4d8d-a464-4c74c52daa69", "metadata": {}, "outputs": [], "source": [ "def total_output_joltage(input_text: str) -> int:\n", " def max_joltage_for_bank(bank: str) -> int:\n", " max_first = -1\n", " max_pair = 0\n", " for ch in bank:\n", " if not ch.isdigit():\n", " continue\n", " d = int(ch)\n", " if max_first != -1:\n", " pair = 10 * max_first + d\n", " if pair > max_pair:\n", " max_pair = pair\n", " if d > max_first:\n", " max_first = d\n", " return max_pair\n", "\n", " total = 0\n", " for line in input_text.strip().splitlines():\n", " line = line.strip()\n", " if line:\n", " total += max_joltage_for_bank(line)\n", " return total" ] }, { "cell_type": "markdown", "id": "6c2da1ae-d915-4f37-9494-bbe427ef87ad", "metadata": {}, "source": [ "*I wish it had made a separate function to parse the input, and I don't know why it made `max_joltage_for_bank` an inner function (remember: `import this` says flat is better than nested), but I verified the code is correct:*" ] }, { "cell_type": "code", "execution_count": 13, "id": "09bf306b-8762-4346-aff9-bcff33639c71", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 3.1: 1.7 msec, correct answer: 17085 " ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_text = get_text(3)\n", "\n", "answer(3.1, 17085, lambda:\n", " total_output_joltage(input_text))" ] }, { "cell_type": "markdown", "id": "4a07f37f-c5e3-4484-a7b1-2cae0ff5bd01", "metadata": {}, "source": [ "*For **Part 2** *we want a 12-digit number, not 2-digit.**\n", "\n", "*ChatGPT did well (and for some reason included comments and doc strings, which were missing the first time):*" ] }, { "cell_type": "code", "execution_count": 14, "id": "bdb8b4e4-bed0-48dc-a045-47cd4c6002fd", "metadata": {}, "outputs": [], "source": [ "def total_output_joltage(input_text: str, k: int = 12) -> int:\n", " \"\"\"\n", " Given the full puzzle input as a string, return the total maximum joltage\n", " when, for each line (bank), you must choose exactly k digits in order to\n", " form the largest possible number.\n", " \"\"\"\n", "\n", " def max_joltage_for_bank(bank: str, k: int) -> int:\n", " # Extract digits from the line\n", " digits = [int(ch) for ch in bank if ch.isdigit()]\n", " n = len(digits)\n", " if k > n:\n", " raise ValueError(f\"Bank has only {n} digits, cannot pick {k}.\")\n", "\n", " # We will remove exactly n - k digits to maximize the resulting sequence\n", " removals = n - k\n", " stack = []\n", "\n", " for d in digits:\n", " # While we can still remove digits, and the last digit in the stack\n", " # is smaller than the current digit, pop it to make the number larger.\n", " while stack and removals > 0 and stack[-1] < d:\n", " stack.pop()\n", " removals -= 1\n", " stack.append(d)\n", "\n", " # If we still have removals left, remove from the end\n", " if removals > 0:\n", " stack = stack[:-removals]\n", "\n", " # Now stack should have length n - original_removals == k\n", " chosen = stack[:k]\n", " return int(\"\".join(map(str, chosen)))\n", "\n", " total = 0\n", " for line in input_text.strip().splitlines():\n", " line = line.strip()\n", " if not line:\n", " continue\n", " total += max_joltage_for_bank(line, k)\n", "\n", " return total" ] }, { "cell_type": "code", "execution_count": 15, "id": "70bde9b9-beb1-4e9d-bef6-0f20fb958891", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 3.2: 2.6 msec, correct answer: 169408143086082" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(3.2, 169408143086082, lambda:\n", " total_output_joltage(input_text))" ] }, { "cell_type": "markdown", "id": "00625b83-f56f-4fff-8d87-1e9cdbc02847", "metadata": {}, "source": [ "# Day 4: Gemini 3 Pro\n", "\n", "*In [**Day 4**](https://adventofcode.com/2025/day/4) we are given a 2D map and asked how many squares have a \"@\" that is surrounded by fewer than 4 other \"@\" (out of the 8 orthogonal or diagonal neighbors).*\n", "\n", "*Gemini produced a solution to **Part 1** that is straightforward and efficient, although perhaps could use better modularization (e.g. if they had a function to count neighbors, they wouldn't need the `continue` in the main loop).*" ] }, { "cell_type": "code", "execution_count": 16, "id": "35bf1f30-07c7-4842-a6e3-e33fb874e779", "metadata": {}, "outputs": [], "source": [ "def count_accessible_rolls(grid_str):\n", " \"\"\"\n", " Parses a grid string and counts paper rolls (@) that have\n", " fewer than 4 neighbors of the same type.\n", " \"\"\"\n", " # Parse input into a list of lists (2D grid)\n", " # .strip() removes leading/trailing whitespace to handle clean input\n", " lines = grid_str.strip().split('\\n')\n", " grid = [list(line) for line in lines]\n", " \n", " rows = len(grid)\n", " cols = len(grid[0])\n", " \n", " accessible_count = 0\n", " \n", " # Directions for 8 neighbors: (row_offset, col_offset)\n", " directions = [\n", " (-1, -1), (-1, 0), (-1, 1),\n", " (0, -1), (0, 1),\n", " (1, -1), (1, 0), (1, 1)\n", " ]\n", " \n", " for r in range(rows):\n", " for c in range(cols):\n", " # We only care if the current spot is a paper roll\n", " if grid[r][c] != '@':\n", " continue\n", " \n", " neighbor_rolls = 0\n", " \n", " # Check all 8 neighbors\n", " for dr, dc in directions:\n", " nr, nc = r + dr, c + dc\n", " \n", " # Check boundaries\n", " if 0 <= nr < rows and 0 <= nc < cols:\n", " if grid[nr][nc] == '@':\n", " neighbor_rolls += 1\n", " \n", " # The Condition: fewer than 4 adjacent rolls\n", " if neighbor_rolls < 4:\n", " accessible_count += 1\n", " \n", " return accessible_count" ] }, { "cell_type": "markdown", "id": "1e12bc4c-8cc8-4c01-b4ad-5392b49642e6", "metadata": {}, "source": [ "*I really like that the 8 `directions` are laid out in a 2D pattern that corresponds to where the directions actually go.*\n", "\n", "*Here I verify that the code does produce the correct answer:*" ] }, { "cell_type": "code", "execution_count": 17, "id": "5b54c152-ce26-4baf-8b51-d4a166c6c2e7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 4.1: 8.5 msec, correct answer: 1569 " ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "grid_str = get_text(4)\n", "\n", "answer(4.1, 1569, lambda:\n", " count_accessible_rolls(grid_str))" ] }, { "cell_type": "markdown", "id": "76845e18-6209-4995-a825-568782b79d0d", "metadata": {}, "source": [ "*Now we're asked if we repeatedly remove \"@\" with fewer than 4 neighbors, how many are left in the end?*\n", "\n", "*For **Part 2**, Gemini chose the \"repeatedly remove acessible rolls\" approach rather than the more efficient (and arguable more elegant) \"remove a roll and check its neighbors\" approach.*" ] }, { "cell_type": "code", "execution_count": 18, "id": "16a1a0db-7501-41fd-a606-87fbb79273bd", "metadata": {}, "outputs": [], "source": [ "def count_total_removable_rolls(grid_str):\n", " \"\"\"\n", " Parses a grid string and iteratively removes paper rolls (@) \n", " that have fewer than 4 neighbors of the same type.\n", " Returns the total number of rolls removed.\n", " \"\"\"\n", " # Parse input into a list of lists (2D grid)\n", " lines = grid_str.strip().split('\\n')\n", " grid = [list(line) for line in lines]\n", " \n", " rows = len(grid)\n", " cols = len(grid[0])\n", " \n", " total_removed_count = 0\n", " \n", " # Directions for 8 neighbors\n", " directions = [\n", " (-1, -1), (-1, 0), (-1, 1),\n", " (0, -1), (0, 1),\n", " (1, -1), (1, 0), (1, 1)\n", " ]\n", " \n", " while True:\n", " rolls_to_remove = []\n", " \n", " # 1. Identify all rolls that can be removed in this pass\n", " for r in range(rows):\n", " for c in range(cols):\n", " if grid[r][c] != '@':\n", " continue\n", " \n", " neighbor_rolls = 0\n", " for dr, dc in directions:\n", " nr, nc = r + dr, c + dc\n", " if 0 <= nr < rows and 0 <= nc < cols:\n", " if grid[nr][nc] == '@':\n", " neighbor_rolls += 1\n", " \n", " # Condition: fewer than 4 adjacent rolls\n", " if neighbor_rolls < 4:\n", " rolls_to_remove.append((r, c))\n", " \n", " # 2. If nothing to remove, we are done\n", " if not rolls_to_remove:\n", " break\n", " \n", " # 3. Remove them (update grid and count)\n", " total_removed_count += len(rolls_to_remove)\n", " for r, c in rolls_to_remove:\n", " grid[r][c] = '.' # Mark as empty space\n", " \n", " return total_removed_count" ] }, { "cell_type": "code", "execution_count": 19, "id": "b47c2e05-978a-4b22-aafc-e31ee1825387", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 4.2: 200.3 msec, correct answer: 9280 " ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(4.2, 9280, lambda:\n", " count_total_removable_rolls(grid_str))" ] }, { "cell_type": "markdown", "id": "81d88b41-9dec-404d-9bca-9e3ba5caa14d", "metadata": {}, "source": [ "*I asked Gemini \"Can you do it more efficiently\" and it replied*\n", "\n", " The previous implementation scanned the entire grid repeatedly (`O(Rows * Cols * Iterations)`). I've replaced it with a Worklist/Queue algorithm that checks neighbors only when necessary. This reduces the complexity to be roughly proportional to the number of rolls, which is significantly faster for large grids.\n", "\n", "*This is the same approach that I used in my solution.*" ] }, { "cell_type": "code", "execution_count": 20, "id": "71bfe887-fbd4-4378-b37f-d0b88f9fa3e7", "metadata": {}, "outputs": [], "source": [ "def count_total_removable_rolls(grid_str):\n", " \"\"\"\n", " Parses a grid string and iteratively removes paper rolls (@) \n", " that have fewer than 4 neighbors of the same type.\n", " Returns the total number of rolls removed.\n", " \n", " Optimized to use a worklist (queue) approach instead of full grid scans.\n", " \"\"\"\n", " # Parse input into a list of lists (2D grid)\n", " lines = grid_str.strip().split('\\n')\n", " grid = [list(line) for line in lines]\n", " \n", " rows = len(grid)\n", " cols = len(grid[0])\n", " \n", " total_removed_count = 0\n", " \n", " # Directions for 8 neighbors\n", " directions = [\n", " (-1, -1), (-1, 0), (-1, 1),\n", " (0, -1), (0, 1),\n", " (1, -1), (1, 0), (1, 1)\n", " ]\n", " \n", " def count_neighbors(r, c):\n", " count = 0\n", " for dr, dc in directions:\n", " nr, nc = r + dr, c + dc\n", " if 0 <= nr < rows and 0 <= nc < cols:\n", " if grid[nr][nc] == '@':\n", " count += 1\n", " return count\n", "\n", " # 1. Initial scan: Find all rolls that are immediately removable\n", " queue = []\n", " for r in range(rows):\n", " for c in range(cols):\n", " if grid[r][c] == '@':\n", " if count_neighbors(r, c) < 4:\n", " queue.append((r, c))\n", " \n", " # 2. Process the queue\n", " # Using a list as a queue. Duplicates are handled by checking grid state.\n", " head = 0\n", " while head < len(queue):\n", " r, c = queue[head]\n", " head += 1\n", " \n", " # If already removed (e.g., added to queue multiple times), skip\n", " if grid[r][c] != '@':\n", " continue\n", " \n", " # Remove the roll\n", " grid[r][c] = '.'\n", " total_removed_count += 1\n", " \n", " # Check its neighbors\n", " for dr, dc in directions:\n", " nr, nc = r + dr, c + dc\n", " if 0 <= nr < rows and 0 <= nc < cols:\n", " # If neighbor is a roll, check if it became removable\n", " if grid[nr][nc] == '@':\n", " if count_neighbors(nr, nc) < 4:\n", " queue.append((nr, nc))\n", " \n", " return total_removed_count" ] }, { "cell_type": "code", "execution_count": 21, "id": "f370ee38-67af-42a6-9ad3-cdeec2019ff3", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 4.2: 33.8 msec, correct answer: 9280 " ] }, "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(4.2, 9280, lambda:\n", " count_total_removable_rolls(grid_str))" ] }, { "cell_type": "markdown", "id": "78434cfe-d728-453c-8f45-fc6b5fea18c3", "metadata": {}, "source": [ "# Day 5: Claude Opus 4.5\n", "\n", "*In [**Day 5**](https://adventofcode.com/2025/day/5) we are asked how many ingredient IDs from a list of IDs are fresh, according to a list of fresh ID ranges.*\n", "\n", "*Claude produces a straightforward program that solves **Part 1** just fine and demonstrates good use of modularization. This time it has nice doc strings; for Day 2 it had none. Go figure.*" ] }, { "cell_type": "code", "execution_count": 22, "id": "e7ab7dac-9686-4a76-b83f-6779275c3519", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the Fresh Ingredients puzzle.\n", "Parses a database with fresh ingredient ID ranges and available ingredient IDs,\n", "then counts how many available ingredients are fresh.\n", "\"\"\"\n", "\n", "def parse_input(input_text):\n", " \"\"\"Parse the input into ranges and ingredient IDs.\"\"\"\n", " parts = input_text.strip().split('\\n\\n')\n", " \n", " # Parse fresh ranges\n", " ranges = []\n", " for line in parts[0].strip().split('\\n'):\n", " start, end = map(int, line.split('-'))\n", " ranges.append((start, end))\n", " \n", " # Parse available ingredient IDs\n", " ingredient_ids = []\n", " for line in parts[1].strip().split('\\n'):\n", " ingredient_ids.append(int(line))\n", " \n", " return ranges, ingredient_ids\n", "\n", "\n", "def is_fresh(ingredient_id, ranges):\n", " \"\"\"Check if an ingredient ID falls within any fresh range.\"\"\"\n", " for start, end in ranges:\n", " if start <= ingredient_id <= end:\n", " return True\n", " return False\n", "\n", "\n", "def count_fresh_ingredients(ranges, ingredient_ids):\n", " \"\"\"Count how many ingredient IDs are fresh.\"\"\"\n", " count = 0\n", " for ingredient_id in ingredient_ids:\n", " if is_fresh(ingredient_id, ranges):\n", " count += 1\n", " return count\n", "\n", "\n", "def solve(input_text):\n", " \"\"\"Solve the puzzle and return the count of fresh ingredients.\"\"\"\n", " ranges, ingredient_ids = parse_input(input_text)\n", " return count_fresh_ingredients(ranges, ingredient_ids)" ] }, { "cell_type": "markdown", "id": "40fc662b-ecee-4abe-a23a-ca7786edd438", "metadata": {}, "source": [ "*Here I feed the input to `solve` and verify that the answer is correct:*" ] }, { "cell_type": "code", "execution_count": 23, "id": "49f0bb9c-00c0-4983-ab26-d1cec1e692ac", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 5.1: 3.1 msec, correct answer: 635 " ] }, "execution_count": 23, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_text = get_text(5)\n", "\n", "answer(5.1, 635, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "dabe3fbc-0fa6-46d0-adfb-f6413f3a63d5", "metadata": {}, "source": [ "*For **Part 2**, we are asked how many possible fresh IDs are there, whether they are in inventory or not. Claude chose to sort ranges (good choice), then merge them, then count the total of the sizes of the ranges. That's a reasonable approach–it involves an extra step of merging ranges (instead of just counting on the fly) but it could be useful to have the merged ranges.*" ] }, { "cell_type": "code", "execution_count": 24, "id": "b70269fb-3a0b-4728-9d60-421e3b35a877", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the Fresh Ingredients puzzle - Part 2.\n", "Count all unique ingredient IDs that are considered fresh by any range.\n", "\"\"\"\n", "\n", "def parse_input(input_text):\n", " \"\"\"Parse the input to get fresh ranges (ignore ingredient IDs section).\"\"\"\n", " parts = input_text.strip().split('\\n\\n')\n", " \n", " # Parse fresh ranges (only first section matters now)\n", " ranges = []\n", " for line in parts[0].strip().split('\\n'):\n", " start, end = map(int, line.split('-'))\n", " ranges.append((start, end))\n", " \n", " return ranges\n", "\n", "\n", "def merge_ranges(ranges):\n", " \"\"\"Merge overlapping ranges to count unique IDs efficiently.\"\"\"\n", " if not ranges:\n", " return []\n", " \n", " # Sort ranges by start value\n", " sorted_ranges = sorted(ranges)\n", " \n", " merged = [sorted_ranges[0]]\n", " \n", " for current_start, current_end in sorted_ranges[1:]:\n", " last_start, last_end = merged[-1]\n", " \n", " # Check if current range overlaps or is adjacent to the last merged range\n", " if current_start <= last_end + 1:\n", " # Merge by extending the end if needed\n", " merged[-1] = (last_start, max(last_end, current_end))\n", " else:\n", " # No overlap, add as new range\n", " merged.append((current_start, current_end))\n", " \n", " return merged\n", "\n", "\n", "def count_fresh_ids(ranges):\n", " \"\"\"Count total unique fresh ingredient IDs.\"\"\"\n", " merged = merge_ranges(ranges)\n", " \n", " total = 0\n", " for start, end in merged:\n", " # Count is inclusive: end - start + 1\n", " total += end - start + 1\n", " \n", " return total\n", "\n", "\n", "def solve(input_text):\n", " \"\"\"Solve part 2 and return the count of all fresh ingredient IDs.\"\"\"\n", " ranges = parse_input(input_text)\n", " return count_fresh_ids(ranges)" ] }, { "cell_type": "code", "execution_count": 25, "id": "bc525b8f-8c03-4ac1-a52d-1ba2d8c1e5bb", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 5.2: 0.1 msec, correct answer: 369761800782619" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(5.2, 369761800782619, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "b1503029-3a5f-4949-8502-75b051f78a23", "metadata": {}, "source": [ "# Day 6: ChatGPT 5.1 Auto\n", "\n", "*For [**Day 6**](https://adventofcode.com/2025/day/6) we are asked to solve some math problems written in an unusal format (vertical instead of horizontal, with some special rules).*\n", "\n", "*For **Part 1** ChatGPT produced a program that is correct, but has poor modularization, with one long 63-line function. (It also contains a pet peeve of mine: in lines 17–20 the pattern \"`if some_boolean: True else: False`\" can always be replaced with \"`some_boolean`\".) And it would have been easier to replace the six lines with one: `sep = {c for c in range(width) if all(grid[r][c] == ' ' for r in range(h))}`." ] }, { "cell_type": "code", "execution_count": 26, "id": "fc4e8201-532f-47e8-aa95-e5dad13b8f4f", "metadata": {}, "outputs": [], "source": [ "import re\n", "from functools import reduce\n", "from operator import add, mul\n", "\n", "def solve(lines):\n", " if not lines:\n", " return 0\n", "\n", " # Preserve internal spaces; pad all lines to same width\n", " width = max(len(line) for line in lines)\n", " grid = [line.rstrip('\\n').ljust(width) for line in lines]\n", " h = len(grid)\n", "\n", " # Identify separator columns: columns that are all spaces\n", " sep = []\n", " for c in range(width):\n", " if all(grid[r][c] == ' ' for r in range(h)):\n", " sep.append(True)\n", " else:\n", " sep.append(False)\n", "\n", " # Find ranges of columns that form individual problems\n", " problem_ranges = []\n", " in_problem = False\n", " start = 0\n", " for c in range(width):\n", " if not sep[c]:\n", " if not in_problem:\n", " in_problem = True\n", " start = c\n", " else:\n", " if in_problem:\n", " in_problem = False\n", " problem_ranges.append((start, c - 1))\n", " if in_problem:\n", " problem_ranges.append((start, width - 1))\n", "\n", " grand_total = 0\n", "\n", " # Process each problem\n", " for c_start, c_end in problem_ranges:\n", " numbers = []\n", "\n", " # All rows except the last contain numbers (potentially)\n", " for r in range(h - 1):\n", " segment = grid[r][c_start:c_end + 1]\n", " for match in re.finditer(r'\\d+', segment):\n", " numbers.append(int(match.group(0)))\n", "\n", " # Last row contains the operator (+ or *)\n", " bottom_segment = grid[h - 1][c_start:c_end + 1]\n", " if '+' in bottom_segment:\n", " op = add\n", " elif '*' in bottom_segment:\n", " op = mul\n", " else:\n", " # No valid operator found; skip this malformed problem\n", " continue\n", "\n", " if not numbers:\n", " continue\n", "\n", " # Fold numbers using the chosen operator\n", " result = reduce(op, numbers)\n", " grand_total += result\n", "\n", " return grand_total" ] }, { "cell_type": "markdown", "id": "54e76b17-4372-4074-b7f9-9439c44bc3c4", "metadata": {}, "source": [ "*Here I interface my input data to ChatGPT's function and verify it is correct:*" ] }, { "cell_type": "code", "execution_count": 27, "id": "d6beae51-7205-4cae-b4d7-495da2953baf", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 6.1: 2.9 msec, correct answer: 5877594983578 " ] }, "execution_count": 27, "metadata": {}, "output_type": "execute_result" } ], "source": [ "lines = get_text(6).splitlines()\n", "\n", "answer(6.1, 5877594983578, lambda:\n", " solve(lines))" ] }, { "cell_type": "markdown", "id": "28de0612-03f3-4587-88cb-18ef9b10ee00", "metadata": {}, "source": [ "*For **Part 2** we learn that the details of the format are even more ideosyncratic: the exact placement of spaces is crucial. (Here, as in each day's problem, see [my other notebook](Advent-2025.ipynb) or [the official AoC site](https://adventofcode.com/2025/day/6) for details*.)\n", "\n", "*ChatGPT writes an even-longer function, with even more complications. For example in \"Determine the operator\"; the 14 lines could be replaced with `op = (add if '+' in segment else mul)`. I think ChatGPT ws trying to be robust against errors in the input.*" ] }, { "cell_type": "code", "execution_count": 28, "id": "053c3971-9aa5-490a-a016-f303b391c5d8", "metadata": {}, "outputs": [], "source": [ "import re\n", "from functools import reduce\n", "from operator import add, mul\n", "\n", "def solve(lines):\n", " if not lines:\n", " return 0\n", "\n", " # Normalize lines to a rectangle, preserving internal spaces\n", " width = max(len(line) for line in lines)\n", " grid = [line.rstrip('\\n').ljust(width) for line in lines]\n", " h = len(grid)\n", "\n", " # A separator column is all spaces -> separates problems\n", " sep = []\n", " for c in range(width):\n", " if all(grid[r][c] == ' ' for r in range(h)):\n", " sep.append(True)\n", " else:\n", " sep.append(False)\n", "\n", " # Find contiguous ranges of non-separator columns (each is a problem)\n", " problem_ranges = []\n", " in_problem = False\n", " start = 0\n", " for c in range(width):\n", " if not sep[c]:\n", " if not in_problem:\n", " in_problem = True\n", " start = c\n", " else:\n", " if in_problem:\n", " in_problem = False\n", " problem_ranges.append((start, c - 1))\n", " if in_problem:\n", " problem_ranges.append((start, width - 1))\n", "\n", " grand_total = 0\n", "\n", " for c_start, c_end in problem_ranges:\n", " # Find the operator row for THIS problem slice:\n", " # bottom-most row with + or * in [c_start, c_end]\n", " op_row = None\n", " for r in range(h):\n", " segment = grid[r][c_start:c_end + 1]\n", " if '+' in segment or '*' in segment:\n", " op_row = r\n", "\n", " if op_row is None:\n", " # No operator for this vertical slice -> skip\n", " continue\n", "\n", " # Determine the operator\n", " op_segment = grid[op_row][c_start:c_end + 1]\n", " if '+' in op_segment and '*' in op_segment:\n", " # If both appear (shouldn't in valid input), choose the leftmost\n", " plus_idx = op_segment.find('+') if '+' in op_segment else float('inf')\n", " mul_idx = op_segment.find('*') if '*' in op_segment else float('inf')\n", " op = add if plus_idx < mul_idx else mul\n", " elif '+' in op_segment:\n", " op = add\n", " elif '*' in op_segment:\n", " op = mul\n", " else:\n", " # No operator actually present\n", " continue\n", "\n", " # Build numbers column-by-column (each column is one number),\n", " # reading digits from top to just above op_row\n", " numbers = []\n", " for c in range(c_start, c_end + 1):\n", " digits = []\n", " for r in range(op_row):\n", " ch = grid[r][c]\n", " if ch.isdigit():\n", " digits.append(ch)\n", " if digits:\n", " num = int(''.join(digits))\n", " numbers.append(num)\n", "\n", " if not numbers:\n", " continue\n", "\n", " result = reduce(op, numbers)\n", " grand_total += result\n", "\n", " return grand_total" ] }, { "cell_type": "markdown", "id": "799fdb8a-0564-42f6-9d6a-f6604666afad", "metadata": {}, "source": [ "*The program is correct:*" ] }, { "cell_type": "code", "execution_count": 29, "id": "e37018e7-0c82-4501-830e-dfe55efc3ad6", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 6.2: 2.3 msec, correct answer: 11159825706149 " ] }, "execution_count": 29, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(6.2, 11159825706149, lambda:\n", " solve(lines))" ] }, { "cell_type": "markdown", "id": "110a8177-d4d8-4a61-9f74-1ed6444ec38f", "metadata": {}, "source": [ "# Day 7: Gemini 3 Pro\n", "\n", "*In [**Day 7**](https://adventofcode.com/2025/day/7) we are given a 2D grid of characters where a beam enters at the top and moves downward, but is split to both sides by a \"`^`\" character. We need to compute the total number of split beams at the bottom.*\n", "\n", "*Gemini's code for **Part 1** is a bit verbose, but gets the job done.*" ] }, { "cell_type": "code", "execution_count": 30, "id": "adbbd7bc-1eca-4638-a551-470d27edae0d", "metadata": {}, "outputs": [], "source": [ "def solve_tachyon_manifold(puzzle_input):\n", " # 1. Parse the Grid\n", " grid = puzzle_input.splitlines()\n", " grid = [line for line in grid if line] # Remove empty lines\n", " \n", " if not grid:\n", " return 0\n", "\n", " height = len(grid)\n", " width = max(len(row) for row in grid)\n", " \n", " # Pad rows to ensure uniform width\n", " grid = [row.ljust(width, '.') for row in grid]\n", "\n", " # 2. Find Start Position 'S'\n", " # We scan the first row (or all rows if S is buried, but usually it's top)\n", " active_beams = set()\n", " for y in range(height):\n", " if 'S' in grid[y]:\n", " active_beams.add(grid[y].index('S'))\n", " # We assume beams start flowing from this row downwards\n", " # so we break and start the simulation from this row\n", " start_row = y\n", " break\n", " else:\n", " return 0 # No start found\n", "\n", " split_count = 0\n", "\n", " # 3. Simulate Row by Row\n", " # We iterate through the rows starting from the one containing 'S'\n", " for y in range(start_row, height):\n", " next_beams = set()\n", " \n", " # Check every column that currently has a beam\n", " for x in active_beams:\n", " cell = grid[y][x]\n", " \n", " if cell == '^':\n", " # SPLITTER: \n", " # 1. Increment total split count\n", " split_count += 1\n", " \n", " # 2. Create new beams left and right\n", " # Check boundaries to ensure we don't wrap around or error\n", " if x - 1 >= 0:\n", " next_beams.add(x - 1)\n", " if x + 1 < width:\n", " next_beams.add(x + 1)\n", " \n", " else:\n", " # EMPTY SPACE (.) or START (S):\n", " # The beam continues straight down\n", " next_beams.add(x)\n", " \n", " # Update the active beams for the next row iteration\n", " active_beams = next_beams\n", " \n", " # Optimization: If no beams are left, stop early\n", " if not active_beams:\n", " break\n", "\n", " return split_count" ] }, { "cell_type": "markdown", "id": "f8de5310-3556-4150-a050-5303cd10702e", "metadata": {}, "source": [ "*The solution is correct:*" ] }, { "cell_type": "code", "execution_count": 31, "id": "9ef96af9-87b7-4e9b-a5fb-e310bc8e8163", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 7.1: 0.4 msec, correct answer: 1681 " ] }, "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ "puzzle_input = get_text(7)\n", "\n", "answer(7.1, 1681, lambda:\n", " solve_tachyon_manifold(puzzle_input))" ] }, { "cell_type": "markdown", "id": "c56012a6-b9e7-4053-84c1-79fa457436b1", "metadata": {}, "source": [ "*For **Part 2**, we are asked how many ways can the beams get to the bottom.*\n", "\n", "*Gemini gets the key ideas (although it is a bit old-fashioned to use a `defaultdict(int)` instead of a `Counter`).*" ] }, { "cell_type": "code", "execution_count": 32, "id": "3e05450f-0cdc-401f-99bc-17d7dfb83143", "metadata": {}, "outputs": [], "source": [ "from collections import defaultdict\n", "\n", "def solve_quantum_tachyon_manifold(puzzle_input):\n", " # 1. Parse the Grid\n", " grid = puzzle_input.splitlines()\n", " grid = [line for line in grid if line]\n", " \n", " if not grid:\n", " return 0\n", "\n", " height = len(grid)\n", " width = max(len(row) for row in grid)\n", " # Pad rows to ensure uniform width\n", " grid = [row.ljust(width, '.') for row in grid]\n", "\n", " # 2. Find Start Position 'S'\n", " # 'counts' maps column_index -> number_of_timelines_at_this_column\n", " counts = defaultdict(int)\n", " start_row = 0\n", " found = False\n", " \n", " for y in range(height):\n", " if 'S' in grid[y]:\n", " start_x = grid[y].index('S')\n", " counts[start_x] = 1 # We start with 1 timeline\n", " start_row = y\n", " found = True\n", " break\n", " \n", " if not found:\n", " return 0\n", "\n", " # 3. Simulate Row by Row\n", " # We process the grid from the start row down to the bottom.\n", " # Each iteration calculates where the particles end up in the *next* row.\n", " for y in range(start_row, height):\n", " next_counts = defaultdict(int)\n", " \n", " for x, num_timelines in counts.items():\n", " # Determine what is at this position\n", " # If the timeline has drifted out of bounds, treat it as empty space (.)\n", " if 0 <= x < width:\n", " char = grid[y][x]\n", " else:\n", " char = '.'\n", "\n", " if char == '^':\n", " # SPLITTER:\n", " # The timelines here split. The count moves to both left and right.\n", " # If 5 timelines hit this splitter, 5 go left AND 5 go right.\n", " next_counts[x - 1] += num_timelines\n", " next_counts[x + 1] += num_timelines\n", " else:\n", " # EMPTY SPACE (.) or START (S):\n", " # The timelines pass straight through.\n", " next_counts[x] += num_timelines\n", " \n", " # Move to the next row\n", " counts = next_counts\n", "\n", " # 4. Sum Total Timelines\n", " # After processing the final row, 'counts' holds the number of timelines \n", " # exiting the bottom of the manifold at each column.\n", " return sum(counts.values())" ] }, { "cell_type": "markdown", "id": "dc634316-8e9d-46d7-9380-f50345bf6149", "metadata": {}, "source": [ "*The answer is correct:*" ] }, { "cell_type": "code", "execution_count": 33, "id": "555b979d-77a8-4bda-b59c-85ce82df2230", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 7.2: 0.8 msec, correct answer: 422102272495018" ] }, "execution_count": 33, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(7.2, 422102272495018, lambda:\n", " solve_quantum_tachyon_manifold(puzzle_input))" ] }, { "cell_type": "markdown", "id": "7320594a-1284-4b9a-9a22-c6d2829b5e03", "metadata": {}, "source": [ "*My friend Barry Hayes suggested I ask Gemini to compare its code to mine. So I asked \"**How would you compare your code to this:\"***" ] }, { "cell_type": "code", "execution_count": 34, "id": "99d90004-938d-42ac-a5a5-86185129fe4f", "metadata": {}, "outputs": [], "source": [ "def count_timelines(manifold: list[str]) -> int:\n", " \"\"\"How many possible paths are there to the final line of the manifold?\"\"\"\n", " start = manifold[0].index('S')\n", " beams = Counter({start: 1})\n", " for line in manifold:\n", " for b, n in list(beams.items()):\n", " if line[b] == '^':\n", " beams[b] -= n\n", " beams[b - 1] += n\n", " beams[b + 1] += n\n", " return sum(beams.values())" ] }, { "cell_type": "markdown", "id": "7a39ec7d-a27b-40ec-9d32-13f922cc764e", "metadata": {}, "source": [ "*where this is what I wrote for 7.2. Here is Gemini's reply:*\n", "\n", "This is a great comparison. Your code is much more concise and \"Pythonic,\" using collections.Counter and in-place updates to reduce verbosity. It strips away a lot of the boilerplate that my solution included for robustness.\n", "\n", "- **Your Code**: Assumes 'S' is always on the first line (manifold[0]). If 'S' is buried in the middle of the grid, your code crashes or misses it.\n", "- **My Code**: Scans the whole grid for 'S', which handles cases where the entry point isn't at the very top.\n", "- **Your Code**: Relies on Python's robust dictionary handling to avoid index errors (if b-1 is negative, it just adds a key to the dict). This is clever, but effectively allows particles to wrap around or exist in \"negative space\" if not careful (though typically harmless for this specific puzzle input).\n", "\n", "***My rebuttal**: To me, the instructions make it clear that the 'S' must be on the top line. (If there is no 'S' on the top line, my code will return 0, not crash.) Similarly, the puzzle implies that beams can't go outside the borders, but Gemini is right that if the input file **did** have a splitter in the first or last column then there could be a problem that I don't check for.*\n", "\n", "*Next I asked Gemini: **Do you think your code could be improved with some more modularity/abstraction, such as having a separate function to get input?** It replied:*\n", "\n", "Yes, absolutely. Breaking the code down makes it much easier to read, test, and debug. If the input format changes (e.g., reading from a file vs. a raw string) or the simulation rules change (e.g., different splitter types), you only have to modify one specific function.\n", "\n", "Here is a refactored version that separates Input Parsing, State Initialization, and Simulation Logic." ] }, { "cell_type": "code", "execution_count": 35, "id": "624796aa-510a-4a6a-8c79-50395f654ee9", "metadata": {}, "outputs": [], "source": [ "from collections import Counter\n", "from typing import List, Tuple\n", "\n", "def parse_manifold(input_text: str) -> List[str]:\n", " \"\"\"\n", " Cleans and converts raw input text into a grid of strings.\n", " Handles stripping empty lines and padding if necessary.\n", " \"\"\"\n", " lines = [line for line in input_text.splitlines() if line]\n", " if not lines:\n", " return []\n", " \n", " # Optional: Pad lines to uniform width for safety\n", " width = max(len(line) for line in lines)\n", " return [line.ljust(width, '.') for line in lines]\n", "\n", "def find_start_position(grid: List[str]) -> Tuple[int, int]:\n", " \"\"\"\n", " Locates the 'S' character in the grid.\n", " Returns (row_index, col_index).\n", " \"\"\"\n", " for r, row in enumerate(grid):\n", " if 'S' in row:\n", " return r, row.index('S')\n", " raise ValueError(\"Start position 'S' not found in manifold.\")\n", "\n", "def process_row(beams: Counter, row_string: str) -> None:\n", " \"\"\"\n", " Updates the beam positions for a single row of the manifold.\n", " Modifies the 'beams' Counter in-place using the subtraction logic.\n", " \"\"\"\n", " # Create a snapshot of items to iterate over, since we modify 'beams' in the loop\n", " current_states = list(beams.items())\n", " \n", " for col, count in current_states:\n", " # Boundary check ensures we don't access outside the string\n", " if col < 0 or col >= len(row_string):\n", " continue\n", " \n", " char = row_string[col]\n", " \n", " if char == '^':\n", " # Splitter logic: Remove current stack, add to left and right\n", " beams[col] -= count\n", " beams[col - 1] += count\n", " beams[col + 1] += count\n", " # Else: beam passes through (no change needed for '.')\n", "\n", "def solve_quantum_manifold(input_text: str) -> int:\n", " \"\"\"\n", " Main orchestrator function.\n", " \"\"\"\n", " # 1. Get Input\n", " grid = parse_manifold(input_text)\n", " \n", " # 2. Initialize State\n", " try:\n", " start_row, start_col = find_start_position(grid)\n", " except ValueError:\n", " return 0\n", "\n", " beams = Counter({start_col: 1})\n", "\n", " # 3. Run Simulation\n", " # We slice the grid to start from the row containing 'S'\n", " for row in grid[start_row:]:\n", " process_row(beams, row)\n", "\n", " # 4. Output Result\n", " return sum(beams.values())" ] }, { "cell_type": "markdown", "id": "23e17286-9534-4da1-8ec9-26a4333b8c70", "metadata": {}, "source": [ "*I like this refactored version better (although the \"`#1 #2 #3 #4`\" comments don't add much). This version produces the correct answer:*" ] }, { "cell_type": "code", "execution_count": 36, "id": "a4a0b5b5-00a0-4488-8588-cb6f137be239", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 7.2: 1.0 msec, correct answer: 422102272495018" ] }, "execution_count": 36, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(7.2, 422102272495018, lambda:\n", " solve_quantum_manifold(puzzle_input))" ] }, { "cell_type": "markdown", "id": "518c135a-6b5e-4602-b37f-c79c0573a9a1", "metadata": {}, "source": [ "# Day 8: Claude Opus 4.5\n", "\n", "*In the [**Day 8**](https://adventofcode.com/2025/day/8) puzzle there is a collection of junction boxes with coordinates in 3D space. We are asked to connect them together, conneting the nerarest two first, and continuing for 1000 connections, and then report the product of the sizes of the three largest connected circuits.*\n", "\n", "*Here's Claude's code for **Part 1**:*" ] }, { "cell_type": "code", "execution_count": 37, "id": "fce446d7-b72f-4ade-b783-b40f4212b43b", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the Junction Box puzzle.\n", "\n", "Connect the 1000 pairs of junction boxes which are closest together.\n", "Then multiply together the sizes of the three largest circuits.\n", "\"\"\"\n", "\n", "from collections import defaultdict\n", "\n", "def parse_input(text):\n", " \"\"\"Parse the input into a list of (x, y, z) coordinates.\"\"\"\n", " boxes = []\n", " for line in text.strip().split('\\n'):\n", " if line.strip():\n", " x, y, z = map(int, line.strip().split(','))\n", " boxes.append((x, y, z))\n", " return boxes\n", "\n", "def distance_squared(p1, p2):\n", " \"\"\"Calculate squared Euclidean distance between two points.\"\"\"\n", " return (p1[0] - p2[0])**2 + (p1[1] - p2[1])**2 + (p1[2] - p2[2])**2\n", "\n", "class UnionFind:\n", " \"\"\"Union-Find data structure for tracking connected components.\"\"\"\n", " def __init__(self, n):\n", " self.parent = list(range(n))\n", " self.rank = [0] * n\n", " self.size = [1] * n\n", " \n", " def find(self, x):\n", " if self.parent[x] != x:\n", " self.parent[x] = self.find(self.parent[x])\n", " return self.parent[x]\n", " \n", " def union(self, x, y):\n", " px, py = self.find(x), self.find(y)\n", " if px == py:\n", " return False # Already in same circuit\n", " if self.rank[px] < self.rank[py]:\n", " px, py = py, px\n", " self.parent[py] = px\n", " self.size[px] += self.size[py]\n", " if self.rank[px] == self.rank[py]:\n", " self.rank[px] += 1\n", " return True\n", " \n", " def get_component_sizes(self):\n", " \"\"\"Get sizes of all connected components.\"\"\"\n", " components = defaultdict(int)\n", " for i in range(len(self.parent)):\n", " root = self.find(i)\n", " components[root] = self.size[root]\n", " return list(components.values())\n", "\n", "def solve(boxes, num_connections=1000):\n", " \"\"\"Connect the num_connections closest pairs and return product of 3 largest circuit sizes.\"\"\"\n", " n = len(boxes)\n", " \n", " # Calculate all pairwise distances\n", " distances = []\n", " for i in range(n):\n", " for j in range(i + 1, n):\n", " dist_sq = distance_squared(boxes[i], boxes[j])\n", " distances.append((dist_sq, i, j))\n", " \n", " # Sort by distance\n", " distances.sort()\n", " \n", " # Use Union-Find to track circuits\n", " uf = UnionFind(n)\n", " \n", " # Make the specified number of connections\n", " connections_made = 0\n", " for dist_sq, i, j in distances:\n", " if connections_made >= num_connections:\n", " break\n", " # Attempt to connect (even if already in same circuit, it counts as a connection attempt)\n", " uf.union(i, j)\n", " connections_made += 1\n", " \n", " # Get component sizes and find the three largest\n", " sizes = uf.get_component_sizes()\n", " sizes.sort(reverse=True)\n", " \n", " # Multiply the three largest\n", " result = 1\n", " for i in range(min(3, len(sizes))):\n", " result *= sizes[i]\n", " \n", " return result" ] }, { "cell_type": "markdown", "id": "0a3f11c5-e193-41ad-b53c-4ca3aac11fc3", "metadata": {}, "source": [ "*Claude's function computes the correct answer:*" ] }, { "cell_type": "code", "execution_count": 38, "id": "dd99ffbf-3304-49f5-8cfa-d5428edf0db7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 8.1: 293.3 msec, correct answer: 24360 " ] }, "execution_count": 38, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_text = get_text(8)\n", "\n", "answer(8.1, 24360, lambda:\n", " solve(parse_input(input_text), 1000))" ] }, { "cell_type": "markdown", "id": "2b1df990-73d9-4cb1-aeca-3bad52c86c8a", "metadata": {}, "source": [ "*Here is Claude's **Part 2** code, in which we're asked about the X coordinates of the last two connections:*" ] }, { "cell_type": "code", "execution_count": 39, "id": "9ed8312b-4455-4f2d-bef6-67097898aefd", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the Junction Box puzzle - Part 2.\n", "\n", "Connect junction boxes until they're all in one circuit.\n", "Find the last two junction boxes connected and multiply their X coordinates.\n", "\"\"\"\n", "\n", "from collections import defaultdict\n", "\n", "def parse_input(text):\n", " \"\"\"Parse the input into a list of (x, y, z) coordinates.\"\"\"\n", " boxes = []\n", " for line in text.strip().split('\\n'):\n", " if line.strip():\n", " x, y, z = map(int, line.strip().split(','))\n", " boxes.append((x, y, z))\n", " return boxes\n", "\n", "def distance_squared(p1, p2):\n", " \"\"\"Calculate squared Euclidean distance between two points.\"\"\"\n", " return (p1[0] - p2[0])**2 + (p1[1] - p2[1])**2 + (p1[2] - p2[2])**2\n", "\n", "class UnionFind:\n", " \"\"\"Union-Find data structure for tracking connected components.\"\"\"\n", " def __init__(self, n):\n", " self.parent = list(range(n))\n", " self.rank = [0] * n\n", " self.size = [1] * n\n", " self.num_components = n\n", " \n", " def find(self, x):\n", " if self.parent[x] != x:\n", " self.parent[x] = self.find(self.parent[x])\n", " return self.parent[x]\n", " \n", " def union(self, x, y):\n", " px, py = self.find(x), self.find(y)\n", " if px == py:\n", " return False # Already in same circuit\n", " if self.rank[px] < self.rank[py]:\n", " px, py = py, px\n", " self.parent[py] = px\n", " self.size[px] += self.size[py]\n", " if self.rank[px] == self.rank[py]:\n", " self.rank[px] += 1\n", " self.num_components -= 1\n", " return True\n", "\n", "def solve_part2(boxes):\n", " \"\"\"Connect boxes until all in one circuit, return product of X coords of last connection.\"\"\"\n", " n = len(boxes)\n", " \n", " # Calculate all pairwise distances\n", " distances = []\n", " for i in range(n):\n", " for j in range(i + 1, n):\n", " dist_sq = distance_squared(boxes[i], boxes[j])\n", " distances.append((dist_sq, i, j))\n", " \n", " # Sort by distance\n", " distances.sort()\n", " \n", " # Use Union-Find to track circuits\n", " uf = UnionFind(n)\n", " \n", " # Keep connecting until all in one circuit\n", " last_i, last_j = None, None\n", " for dist_sq, i, j in distances:\n", " if uf.union(i, j):\n", " # This connection actually merged two circuits\n", " last_i, last_j = i, j\n", " if uf.num_components == 1:\n", " # All boxes are now in one circuit!\n", " break\n", " \n", " # Return product of X coordinates\n", " x1 = boxes[last_i][0]\n", " x2 = boxes[last_j][0]\n", " print(f\"Last connection: {boxes[last_i]} and {boxes[last_j]}\")\n", " print(f\"X coordinates: {x1} and {x2}\")\n", " return x1 * x2" ] }, { "cell_type": "markdown", "id": "fa637a95-9774-4d76-b2a4-c0f929f13b25", "metadata": {}, "source": [ "*This code is correct. It does some extraneous printing, which is unneccessary, but not strictly incorrect. It is to Claude's credit that it recognized this as a Union-Find problem, but its implementation actually runs slower than my keep-it-simple approach in \n", "[**my notebook**](Advent-2025.ipynb).*" ] }, { "cell_type": "code", "execution_count": 40, "id": "64309157-e1d3-48fc-ba06-e5e66ba23026", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Last connection: (53148, 96207, 9295) and (41127, 88410, 10248)\n", "X coordinates: 53148 and 41127\n" ] }, { "data": { "text/plain": [ "Puzzle 8.2: 284.5 msec, correct answer: 2185817796 " ] }, "execution_count": 40, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(8.2, 2185817796, lambda:\n", " solve_part2(parse_input(input_text)))" ] }, { "cell_type": "markdown", "id": "c6db8a6e-47bf-490f-a54c-6472b4f935a0", "metadata": {}, "source": [ "# Day 9: ChatGPT 5.1 Auto\n", "\n", "*In [**Day 9**](https://adventofcode.com/2025/day/9) we are given the (x, y) coordinates of a collection of red tiles on the floor, and asked what is the largest rectangle with two red tiles as corners.*\n", "\n", "*For **Part 1**, I was getting tired of all the programs that have a `main` that reads from input and prints the answer, so I told ChatGPT: **Refactor to have a function that takes the points as input and returns the area** and got this:*" ] }, { "cell_type": "code", "execution_count": 41, "id": "aaf6b9df-b55d-4416-b806-167ad3b70fd5", "metadata": {}, "outputs": [], "source": [ "def largest_rectangle_area(points):\n", " \"\"\"\n", " Given a list of (x, y) points representing red tiles,\n", " return the largest possible rectangle area using any\n", " two points as opposite corners.\n", " \"\"\"\n", " max_area = 0\n", " n = len(points)\n", "\n", " for i in range(n):\n", " x1, y1 = points[i]\n", " for j in range(i + 1, n):\n", " x2, y2 = points[j]\n", "\n", " width = abs(x2 - x1) + 1\n", " height = abs(y2 - y1) + 1\n", " area = width * height\n", "\n", " if area > max_area:\n", " max_area = area\n", "\n", " return max_area\n", "\n", "def parse_points(text: str):\n", " pts = []\n", " for line in text.splitlines():\n", " line = line.strip()\n", " if not line:\n", " continue\n", " x_str, y_str = line.split(\",\")\n", " pts.append((int(x_str), int(y_str)))\n", " return pts" ] }, { "cell_type": "markdown", "id": "0e3c4d8b-1e85-4e26-8c0d-4255c25a474f", "metadata": {}, "source": [ "*This is correct*:" ] }, { "cell_type": "code", "execution_count": 42, "id": "352cb801-67d7-4aaf-b0a1-1a59c1990c22", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 9.1: 9.3 msec, correct answer: 4772103936 " ] }, "execution_count": 42, "metadata": {}, "output_type": "execute_result" } ], "source": [ "text = get_text(9)\n", "\n", "answer(9.1, 4772103936, lambda:\n", " largest_rectangle_area(parse_points(text)))" ] }, { "cell_type": "markdown", "id": "5f8a09ab-0d49-4a6b-b8ea-33e4219af1eb", "metadata": {}, "source": [ "*For **Part 2**, we're told to consider the sequence of tiles as the vertexes of a polygon, and find the largest rectangle inside the polygon.*\n", "\n", "*ChatGPT considered a [scanline algorithm](https://en.wikipedia.org/wiki/Scanline_rendering), and then went for a [flood fill](https://en.wikipedia.org/wiki/Flood_fill). It assumed \"I'm working with a grid that can have dimensions up to 2k x 2k, or 4 million cells, which seems manageable.\" ChatGPT is correct that it would be manageable under those asumptions, however the assumptions are wrong. I told it **The grid can have dimensions up to 100,000 x 100,000 and there are about 500 red tile points**. I also gave it my input file, so that it would have a chance of discovering the structure of thChatGPT then came up with a [ray casting](https://en.wikipedia.org/wiki/Ray_casting) approach using [grid compression](https://stackoverflow.com/questions/29528934/coordinate-compression): define a grid where each red tile is in a separate cell, where the x and y extent of that cell spans an integer range (which must be recorded elsewhere). Now the grid is only of size roughly 5002 instead of 100,0002.*" ] }, { "cell_type": "code", "execution_count": 43, "id": "16aa5c15-e5d5-4652-83d8-d99da9196617", "metadata": {}, "outputs": [], "source": [ "from typing import List, Tuple, Dict\n", "\n", "Point = Tuple[int, int]\n", "Edge = Tuple[int, int, int, int]\n", "\n", "\n", "def build_edges(points: List[Point]) -> List[Edge]:\n", " \"\"\"\n", " Build polygon edges from the red points in loop order.\n", " Each edge is (x1, y1, x2, y2) between consecutive points (wrapped).\n", " \"\"\"\n", " n = len(points)\n", " edges: List[Edge] = []\n", " for i in range(n):\n", " x1, y1 = points[i]\n", " x2, y2 = points[(i + 1) % n]\n", " edges.append((x1, y1, x2, y2))\n", " return edges\n", "\n", "\n", "def point_in_or_on_polygon(x: int, y: int, edges: List[Edge]) -> bool:\n", " \"\"\"\n", " Return True if lattice point (x, y) is inside the polygon or on its boundary.\n", " Polygon is rectilinear (axis-aligned), edges given by build_edges.\n", " Uses a ray-casting test along +x, counting intersections with vertical edges.\n", " \"\"\"\n", " inside = False\n", "\n", " for (x1, y1, x2, y2) in edges:\n", " # Boundary check: point lies on this edge\n", " if x1 == x2: # vertical edge\n", " if x == x1 and min(y1, y2) <= y <= max(y1, y2):\n", " return True\n", " # For ray cast, only vertical edges can intersect ray to +x\n", " # Use y-interval [min, max) to avoid double-counting vertices\n", " if (y1 > y) != (y2 > y) and x1 > x:\n", " inside = not inside\n", " else: # horizontal edge\n", " if y == y1 and min(x1, x2) <= x <= max(x1, x2):\n", " return True\n", " # horizontal edges do not contribute to this vertical ray count\n", "\n", " return inside\n", "\n", "\n", "def rect_crosses_polygon(\n", " xmin: int, xmax: int, ymin: int, ymax: int, edges: List[Edge]\n", ") -> bool:\n", " \"\"\"\n", " For a non-degenerate rectangle [xmin,xmax] x [ymin,ymax] (width>1,height>1),\n", " check if any polygon edge passes through the *interior* of that rectangle.\n", " Colinear edges on the boundary are allowed; only crossings inside are bad.\n", " \"\"\"\n", " for (x1, y1, x2, y2) in edges:\n", " if x1 == x2:\n", " # vertical edge at x = x1\n", " x0 = x1\n", " if xmin < x0 < xmax:\n", " lo = max(min(y1, y2), ymin)\n", " hi = min(max(y1, y2), ymax)\n", " # If overlap has positive length, edge crosses interior\n", " if hi > lo:\n", " return True\n", " else:\n", " # horizontal edge at y = y1\n", " y0 = y1\n", " if ymin < y0 < ymax:\n", " lo = max(min(x1, x2), xmin)\n", " hi = min(max(x1, x2), xmax)\n", " if hi > lo:\n", " return True\n", " return False\n", "\n", "\n", "def largest_rectangle_area_red_green(points: List[Point]) -> int:\n", " \"\"\"\n", " Given the red tiles as vertices of an axis-aligned loop (in order),\n", " return the largest rectangle area such that:\n", "\n", " - Two opposite corners are red tiles (from `points`).\n", " - Every tile inside the rectangle is red or green, i.e., inside/on the loop.\n", "\n", " This works without building the full grid; it uses polygon geometry instead.\n", " \"\"\"\n", " n = len(points)\n", " if n < 2:\n", " return 0\n", "\n", " edges = build_edges(points)\n", "\n", " # Cache point-in-polygon results so each lattice point is tested at most once\n", " point_cache: Dict[Point, bool] = {}\n", "\n", " def is_inside(x: int, y: int) -> bool:\n", " key = (x, y)\n", " if key in point_cache:\n", " return point_cache[key]\n", " val = point_in_or_on_polygon(x, y, edges)\n", " point_cache[key] = val\n", " return val\n", "\n", " max_area = 0\n", "\n", " for i in range(n):\n", " x1, y1 = points[i]\n", " for j in range(i + 1, n):\n", " x2, y2 = points[j]\n", "\n", " xmin, xmax = (x1, x2) if x1 <= x2 else (x2, x1)\n", " ymin, ymax = (y1, y2) if y1 <= y2 else (y2, y1)\n", "\n", " width = xmax - xmin + 1 # tiles\n", " height = ymax - ymin + 1 # tiles\n", " area = width * height\n", "\n", " if area <= max_area:\n", " continue\n", "\n", " # Thin rectangles (one row or one column): treat as 1D segments.\n", " if width == 1 or height == 1:\n", " if width == 1:\n", " # Vertical segment at x = xmin from ymin..ymax\n", " x = xmin\n", " ok = True\n", " for yy in range(ymin, ymax + 1):\n", " if not is_inside(x, yy):\n", " ok = False\n", " break\n", " else:\n", " # Horizontal segment at y = ymin from xmin..xmax\n", " y = ymin\n", " ok = True\n", " for xx in range(xmin, xmax + 1):\n", " if not is_inside(xx, y):\n", " ok = False\n", " break\n", "\n", " if ok:\n", " max_area = area\n", " continue\n", "\n", " # Proper 2D rectangle: width>1 and height>1\n", "\n", " # The two given red corners are on the boundary by definition.\n", " # The other two corners must be inside/on polygon as well.\n", " if not is_inside(x1, y2):\n", " continue\n", " if not is_inside(x2, y1):\n", " continue\n", "\n", " # Ensure polygon boundary does not cut through the interior\n", " if rect_crosses_polygon(xmin, xmax, ymin, ymax, edges):\n", " continue\n", "\n", " max_area = area\n", "\n", " return max_area" ] }, { "cell_type": "markdown", "id": "538ed6ec-b4ad-4735-815d-49dcdf6400b3", "metadata": {}, "source": [ "*This approach works:*" ] }, { "cell_type": "code", "execution_count": 44, "id": "3388880d-6df9-4453-8b54-43175e3077e2", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 9.2: 447.0 msec, correct answer: 1529675217 " ] }, "execution_count": 44, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(9.2, 1529675217, lambda:\n", " largest_rectangle_area_red_green(parse_points(text)))" ] }, { "cell_type": "markdown", "id": "8e7b6f1b-0ab8-43ef-8b15-764473117b3a", "metadata": {}, "source": [ "# Day 10: Gemini 3 Pro\n", "\n", "*For [**Day 10**](https://adventofcode.com/2025/day/10) we are given some descriptions of machines. See [AoC](https://adventofcode.com/2025/day/10) or [my other notebook](Advent-2025.ipynb) for details, but the description:*\n", "\n", " [#....] (0,2,3) (0,2,3,4) (2,3) (0,1,2) (0,3,4) (3) (1,2) {75,18,60,71,39}\n", "\n", "*means that the machine has 5 lights, and the goal is to turn the first one on (`#....`), by pushing buttons. There are 7 buttons, the first one toggles lights 0, 2, and 3. We want to know the minimal number of button presses. The last 5 numbers are used only in Part 2, where they are the desired joltage of each light.*\n", "\n", "\n", "\n", "*Gemini had no problem with **Part 1:***" ] }, { "cell_type": "code", "execution_count": 45, "id": "da67e5ff-d1f7-418c-8d55-7dacea3a51b6", "metadata": {}, "outputs": [], "source": [ "import re\n", "\n", "def solve(input_text):\n", " \n", " # If you have the input in a file, uncomment the lines below:\n", " # with open(\"input.txt\", \"r\") as f:\n", " # input_text = f.read()\n", "\n", " machines = input_text.strip().split('\\n')\n", " total_presses = 0\n", "\n", " print(f\"Processing {len(machines)} machines...\")\n", "\n", " for line_idx, line in enumerate(machines):\n", " if not line.strip():\n", " continue\n", "\n", " # --- 1. PARSING ---\n", " # Extract target state inside brackets [...]\n", " target_match = re.search(r'\\[([.#]+)\\]', line)\n", " if not target_match:\n", " continue\n", " \n", " target_str = target_match.group(1)\n", " \n", " # Create integer bitmask for target (Bit 0 = Index 0)\n", " target_mask = 0\n", " for i, char in enumerate(target_str):\n", " if char == '#':\n", " target_mask |= (1 << i)\n", "\n", " # Extract buttons inside parentheses (...)\n", " # We ignore the {...} joltage part completely\n", " button_matches = re.findall(r'\\(([\\d,]+)\\)', line)\n", " buttons = []\n", " \n", " for b_str in button_matches:\n", " indices = [int(x) for x in b_str.split(',')]\n", " b_mask = 0\n", " for idx in indices:\n", " b_mask |= (1 << idx)\n", " buttons.append(b_mask)\n", "\n", " # --- 2. SOLVER (Meet-in-the-Middle) ---\n", " # We need to find a subset of buttons that XOR to target_mask\n", " # minimizing the subset size.\n", " \n", " n = len(buttons)\n", " mid = n // 2\n", " \n", " left_buttons = buttons[:mid]\n", " right_buttons = buttons[mid:]\n", "\n", " # Helper to generate all reachable states and min cost to get there\n", " def get_reachable_states(btn_list):\n", " # Dictionary { mask_value : min_presses }\n", " states = {0: 0}\n", " \n", " for btn_mask in btn_list:\n", " # Create a copy of current states to iterate over\n", " # We update states by adding the current button to all existing states\n", " next_batch = {}\n", " for mask, count in states.items():\n", " new_mask = mask ^ btn_mask\n", " new_count = count + 1\n", " \n", " # If we found a new state, or a shorter way to reach an existing state\n", " if new_mask not in states or new_count < states[new_mask]:\n", " # Check if we already have a better way in the current batch processing\n", " if new_mask not in next_batch or new_count < next_batch[new_mask]:\n", " next_batch[new_mask] = new_count\n", " \n", " # Update main dictionary\n", " for m, c in next_batch.items():\n", " if m not in states or c < states[m]:\n", " states[m] = c\n", " return states\n", "\n", " # Generate maps for both halves\n", " left_map = get_reachable_states(left_buttons)\n", " right_map = get_reachable_states(right_buttons)\n", "\n", " # Find the combination of Left + Right that equals Target\n", " # Logic: Left_Mask ^ Right_Mask = Target => Left_Mask = Target ^ Right_Mask\n", " min_presses_for_machine = float('inf')\n", " found = False\n", "\n", " for r_mask, r_count in right_map.items():\n", " needed = target_mask ^ r_mask\n", " \n", " if needed in left_map:\n", " current_total = left_map[needed] + r_count\n", " if current_total < min_presses_for_machine:\n", " min_presses_for_machine = current_total\n", " found = True\n", "\n", " if found:\n", " total_presses += min_presses_for_machine\n", " else:\n", " print(f\"Warning: No solution found for machine {line_idx+1}\")\n", " return total_presses" ] }, { "cell_type": "markdown", "id": "6eaf05df-4d06-47e7-b75e-d153e1d3d42e", "metadata": {}, "source": [ "*It works:*" ] }, { "cell_type": "code", "execution_count": 46, "id": "6175af72-5ab6-4ca3-9825-613dbf1ad4f4", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Processing 165 machines...\n" ] }, { "data": { "text/plain": [ "Puzzle 10.1: 1.9 msec, correct answer: 441 " ] }, "execution_count": 46, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_text = get_text(10)\n", "\n", "answer(10.1, 441, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "f407a27f-f1ac-4c4a-bd46-649449c4dbf1", "metadata": {}, "source": [ "***Part 2*** *was trickier: now each button press increases the joltage of the each numbered light by 1 and we want to know the inimal number of presses to reach the joltage requirements. Gemini's first solution used z3 (it even gave nice instructions for how to `pip install z3`), but I responded with the prompt **Can you do it without using z3?** to which Gemini wrote its own Gaussian elimination code:*" ] }, { "cell_type": "code", "execution_count": 47, "id": "8326ffe4-c435-4823-a0c4-3c062c52a06f", "metadata": {}, "outputs": [], "source": [ "import re\n", "from fractions import Fraction\n", "\n", "def solve(input_text):\n", "\n", " # with open(\"input.txt\", \"r\") as f:\n", " # input_text = f.read()\n", "\n", " machines = input_text.strip().split('\\n')\n", " total_presses = 0\n", "\n", " for i, line in enumerate(machines):\n", " if not line.strip(): continue\n", "\n", " # --- 1. PARSING ---\n", " # Extract targets\n", " joltage_match = re.search(r'\\{([\\d,]+)\\}', line)\n", " if not joltage_match: continue\n", " b_vec = [int(x) for x in joltage_match.group(1).split(',')]\n", " num_eq = len(b_vec) # Number of counters (rows)\n", "\n", " # Extract buttons\n", " button_matches = re.findall(r'\\(([\\d,]+)\\)', line)\n", " buttons = []\n", " for b_str in button_matches:\n", " buttons.append([int(x) for x in b_str.split(',')])\n", " num_vars = len(buttons) # Number of buttons (columns)\n", "\n", " # Build Matrix A\n", " # A[row][col] = 1 if button col affects counter row\n", " matrix = [[Fraction(0) for _ in range(num_vars)] for _ in range(num_eq)]\n", " for col_idx, affected_rows in enumerate(buttons):\n", " for row_idx in affected_rows:\n", " matrix[row_idx][col_idx] = Fraction(1)\n", " \n", " # We also need the augmented vector B as fractions\n", " b_vec_frac = [Fraction(x) for x in b_vec]\n", "\n", " # --- 2. GAUSSIAN ELIMINATION ---\n", " # We perform row reduction to express Pivot variables in terms of Free variables.\n", " pivot_row = 0\n", " pivots = {} # Maps row_index -> col_index of the pivot\n", " pivot_cols = set()\n", "\n", " for col in range(num_vars):\n", " if pivot_row >= num_eq: break\n", "\n", " # Find a row with a non-zero entry in this column\n", " sel = -1\n", " for row in range(pivot_row, num_eq):\n", " if matrix[row][col] != 0:\n", " sel = row\n", " break\n", " \n", " if sel == -1: continue # This is a free variable column\n", "\n", " # Swap rows\n", " matrix[pivot_row], matrix[sel] = matrix[sel], matrix[pivot_row]\n", " b_vec_frac[pivot_row], b_vec_frac[sel] = b_vec_frac[sel], b_vec_frac[pivot_row]\n", "\n", " # Normalize pivot row\n", " scalar = matrix[pivot_row][col]\n", " for c in range(col, num_vars):\n", " matrix[pivot_row][c] /= scalar\n", " b_vec_frac[pivot_row] /= scalar\n", "\n", " # Eliminate other rows\n", " for row in range(num_eq):\n", " if row != pivot_row and matrix[row][col] != 0:\n", " factor = matrix[row][col]\n", " for c in range(col, num_vars):\n", " matrix[row][c] -= factor * matrix[pivot_row][c]\n", " b_vec_frac[row] -= factor * b_vec_frac[pivot_row]\n", "\n", " pivots[pivot_row] = col\n", " pivot_cols.add(col)\n", " pivot_row += 1\n", "\n", " # Identify Free Variables\n", " free_cols = [c for c in range(num_vars) if c not in pivot_cols]\n", " \n", " # --- 3. SEARCH / OPTIMIZATION ---\n", " \n", " # Pre-calculate upper bounds for free variables.\n", " # Since coefficients are all non-negative in the original problem,\n", " # a button cannot be pressed more times than the smallest target it affects.\n", " # (This is a heuristic bound to limit the search space).\n", " bounds = []\n", " for f_col in free_cols:\n", " limit = float('inf')\n", " # Check original button definition to find tightest constraint\n", " affected = buttons[f_col]\n", " if not affected: \n", " limit = 0 # Button does nothing\n", " else:\n", " for r in affected:\n", " limit = min(limit, b_vec[r])\n", " bounds.append(limit)\n", "\n", " min_total = float('inf')\n", " found_solution = False\n", "\n", " # Recursive search function for free variables\n", " def search(idx, current_free_vals):\n", " nonlocal min_total, found_solution\n", "\n", " # Base case: All free variables assigned\n", " if idx == len(free_cols):\n", " # Calculate Pivot variables based on these Free variables\n", " current_presses = list(current_free_vals) # stores just the free ones for now\n", " \n", " # We need to reconstruct the full 'x' vector to calculate sum\n", " # But calculating pivots is enough to validate.\n", " \n", " temp_pivots = {} # col -> value\n", " \n", " # Check each equation in RREF\n", " possible = True\n", " \n", " # RREF Equation: Pivot + sum(Coeff * Free) = Constant\n", " # Pivot = Constant - sum(Coeff * Free)\n", " \n", " # Calculate all Pivot values\n", " current_sum = sum(current_free_vals)\n", " \n", " for r in range(len(pivots)):\n", " p_col = pivots[r]\n", " val = b_vec_frac[r]\n", " for i, f_col in enumerate(free_cols):\n", " val -= matrix[r][f_col] * current_free_vals[i]\n", " \n", " # Validation: Must be integer and >= 0\n", " if val.denominator != 1 or val < 0:\n", " possible = False\n", " break\n", " temp_pivots[p_col] = int(val)\n", " current_sum += int(val)\n", "\n", " if possible:\n", " # Check any rows that were all-zero (redundant constraints)\n", " # to ensure 0 = B_value (consistency check)\n", " for r in range(len(pivots), num_eq):\n", " if b_vec_frac[r] != 0:\n", " possible = False\n", " break\n", " \n", " if possible:\n", " found_solution = True\n", " if current_sum < min_total:\n", " min_total = current_sum\n", " return\n", "\n", " # Recursive step: Iterate possible values for this free variable\n", " # Optimization: The cost function is linear, so we can try to be greedy or just bounds.\n", " # Given the problem size, full iteration 0..bound is safe.\n", " # (Usually bounds are small, like 0-10)\n", " f_col = free_cols[idx]\n", " limit = bounds[idx]\n", " \n", " # If current partial sum already exceeds best found, prune? \n", " # (Hard to prune without knowing pivots, but we can try)\n", " \n", " for val in range(limit + 1):\n", " search(idx + 1, current_free_vals + [val])\n", "\n", " # Start search\n", " if not free_cols:\n", " # unique solution or no solution\n", " search(0, [])\n", " else:\n", " search(0, [])\n", "\n", " if found_solution:\n", " total_presses += min_total\n", " else:\n", " print(f\"Machine {i+1}: No solution found.\")\n", "\n", " return total_presses" ] }, { "cell_type": "markdown", "id": "89366a12-507d-4730-9be9-df757bb999c6", "metadata": {}, "source": [ "*The part that says `if not free_cols: search(0, []) else: search(0, [])` is a bit unsettling, and I'm not a big fan of `nonlocal` in this context, but the code works; the only downside is that it takes about 3 seconds to run, a lot more than previous problems.*" ] }, { "cell_type": "code", "execution_count": 48, "id": "25b94f1e-98ad-4bda-9b75-716dd6a503d3", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 10.2: 3492.1 msec, correct answer: 18559 " ] }, "execution_count": 48, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(10.2, 18559, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "75e2cda3-01d7-47d1-bde5-3f554a9464bf", "metadata": {}, "source": [ "*My next prompt was **Can you solve it with scipy?** and Gemini told me:*\n", "\n", " Since SciPy version 1.9.0, there is a dedicated Mixed-Integer Linear Programming (MILP) solver called scipy.optimize.milp\n", " \n", "*which was something I had just figured out an hour or so earlier. The C++ milp solution is (unsurpringly) faster than the Python Gaussian elimination code:*" ] }, { "cell_type": "code", "execution_count": 49, "id": "3f666d8d-caa6-4ec4-a75d-d23f6bf2a21a", "metadata": {}, "outputs": [], "source": [ "import re\n", "import numpy as np\n", "from scipy.optimize import milp, LinearConstraint, Bounds\n", "\n", "def solve(input_text):\n", " \n", " machines = input_text.strip().split('\\n')\n", " total_presses = 0\n", "\n", " print(f\"Processing {len(machines)} machines using SciPy MILP...\")\n", "\n", " for i, line in enumerate(machines):\n", " if not line.strip(): continue\n", "\n", " # --- 1. PARSING ---\n", " \n", " # Targets (RHS of the equation: b)\n", " joltage_match = re.search(r'\\{([\\d,]+)\\}', line)\n", " if not joltage_match: continue\n", " targets = np.array([int(x) for x in joltage_match.group(1).split(',')])\n", " num_counters = len(targets)\n", "\n", " # Buttons (Columns of matrix A)\n", " button_matches = re.findall(r'\\(([\\d,]+)\\)', line)\n", " buttons = []\n", " for b_str in button_matches:\n", " buttons.append([int(x) for x in b_str.split(',')])\n", " num_buttons = len(buttons)\n", "\n", " # Build Matrix A\n", " # Rows = Counters, Cols = Buttons\n", " # A[r, c] = 1 if button c increments counter r\n", " A = np.zeros((num_counters, num_buttons))\n", " for col, affected_rows in enumerate(buttons):\n", " for row in affected_rows:\n", " if row < num_counters:\n", " A[row, col] = 1\n", "\n", " # --- 2. SOLVER CONFIGURATION ---\n", "\n", " # Objective: Minimize sum of button presses\n", " # We want to minimize c @ x, so c is a vector of all 1s.\n", " c = np.ones(num_buttons)\n", "\n", " # Constraints: A @ x == targets\n", " # SciPy uses bounds for constraints: lb <= A @ x <= ub\n", " # For equality, we set lb = ub = targets\n", " constraints = LinearConstraint(A, lb=targets, ub=targets)\n", "\n", " # Variable Bounds: 0 <= x <= infinity\n", " # (Cannot press a button negative times)\n", " bounds = Bounds(lb=0, ub=np.inf)\n", "\n", " # Integrality: Force all variables to be integers\n", " # 1 means integer, 0 means continuous\n", " integrality = np.ones(num_buttons)\n", "\n", " # --- 3. RUN MILP SOLVER ---\n", " res = milp(c=c, constraints=constraints, bounds=bounds, integrality=integrality)\n", "\n", " if res.success:\n", " # Result values might be floats like 2.999999 or 3.000001, so we round them\n", " solution = np.round(res.x).astype(int)\n", " min_presses = np.sum(solution)\n", " total_presses += min_presses\n", " # print(f\"Machine {i+1}: {min_presses} presses\")\n", " else:\n", " print(f\"Machine {i+1}: No integer solution found.\")\n", "\n", " return total_presses" ] }, { "cell_type": "code", "execution_count": 50, "id": "8e9d177e-d7b2-4887-a1be-4f4c5247924f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Processing 165 machines using SciPy MILP...\n" ] }, { "data": { "text/plain": [ "Puzzle 10.2: 45.3 msec, correct answer: 18559 " ] }, "execution_count": 50, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(10.2, 18559, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "f0fcbdff-1b95-4147-87c7-c6b4f0c08c80", "metadata": {}, "source": [ "*I had been unable to make a search-based approach feasible, and was wondering if Gemini had any good ideas, so I prompted **Can you solve it with search?** and Gemini gave a depth-first search function that correctly solves small machines. Gemini correctly identified the idea that operators are commutative, and applied the ordering of \"always press a button that affects the first light that is not at the goal.\" Gemini confidently states*\n", "\n", " This drastically cuts down the search tree, making it solvable in milliseconds for typical puzzle inputs.\n", "\n", "*and it is true that the idea cuts down the search tree, but it still takes seconds, not milliseconds for even moderate size machines, and would probably take hours for some of the machines in the input. So in the end neither I nor Gemini can solve this efficiently with search, but both of us can figure out how to call **milp** to solve it (and Gemini's z3 code probably also works).*" ] }, { "cell_type": "code", "execution_count": 51, "id": "3582f566-0d1f-4c65-85c8-f4bd1ba16d07", "metadata": {}, "outputs": [], "source": [ "import re\n", "\n", "# Increase recursion depth just in case the targets are high\n", "sys.setrecursionlimit(5000)\n", "\n", "def solve_machine_with_search(targets, buttons):\n", " \"\"\"\n", " Finds the minimum button presses to reduce 'targets' to all zeros\n", " using the available 'buttons'.\n", " \"\"\" \n", " # Memoization dictionary to store results of states we've already solved\n", " # Key: tuple(current_counts), Value: int(min_presses)\n", " memo = {}\n", " \n", " # Pre-calculate which buttons affect which indices to speed up the search\n", " # buttons_affecting[i] = list of button_indices that change counter i\n", " num_counters = len(targets)\n", " buttons_affecting = [[] for _ in range(num_counters)]\n", " for b_idx, b_indices in enumerate(buttons):\n", " for affected_counter in b_indices:\n", " if affected_counter < num_counters:\n", " buttons_affecting[affected_counter].append(b_idx)\n", "\n", " def dfs(current_state):\n", " # 1. Check Memo\n", " state_tuple = tuple(current_state)\n", " if state_tuple in memo:\n", " return memo[state_tuple]\n", " \n", " # 2. Base Case: If all counters are 0, we are done (0 cost)\n", " if all(x == 0 for x in current_state):\n", " return 0\n", " \n", " # 3. Optimization: Focus Strategy\n", " # Find the *first* counter that is non-zero. \n", " # We MUST press a button that affects this counter eventually.\n", " # By forcing the search to address counters in order, we reduce branching.\n", " first_nonzero_idx = -1\n", " for i, val in enumerate(current_state):\n", " if val > 0:\n", " first_nonzero_idx = i\n", " break\n", " \n", " # If somehow we have negative numbers (shouldn't happen with checks below), return infinity\n", " if first_nonzero_idx == -1:\n", " # This means all were 0 (handled above) or negative (invalid)\n", " return float('inf')\n", "\n", " # 4. Try all valid moves\n", " # Only try buttons that help reduce the `first_nonzero_idx`\n", " best_cost = float('inf')\n", " \n", " candidates = buttons_affecting[first_nonzero_idx]\n", " \n", " for b_idx in candidates:\n", " # Calculate new state if we press this button (subtract it)\n", " button_effect = buttons[b_idx]\n", " \n", " # Check if this move is valid (doesn't drop any counter below 0)\n", " valid_move = True\n", " new_state = list(current_state)\n", " \n", " for affected_idx in button_effect:\n", " new_state[affected_idx] -= 1\n", " if new_state[affected_idx] < 0:\n", " valid_move = False\n", " break\n", " \n", " if valid_move:\n", " # Recurse\n", " res = dfs(new_state)\n", " if res != float('inf'):\n", " best_cost = min(best_cost, 1 + res)\n", " \n", " # 5. Store and return\n", " memo[state_tuple] = best_cost\n", " return best_cost\n", "\n", " return dfs(targets)\n", "\n", "def main(input_text):\n", " \n", " machines = input_text.strip().split('\\n')\n", " total_presses = 0\n", "\n", " print(f\"Processing {len(machines)} machines using Search...\")\n", "\n", " for i, line in enumerate(machines):\n", " if not line.strip(): continue\n", "\n", " # --- Parsing ---\n", " # Get targets inside {}\n", " joltage_match = re.search(r'\\{([\\d,]+)\\}', line)\n", " if not joltage_match: continue\n", " targets = [int(x) for x in joltage_match.group(1).split(',')]\n", "\n", " # Get buttons inside ()\n", " button_matches = re.findall(r'\\(([\\d,]+)\\)', line)\n", " buttons = []\n", " for b_str in button_matches:\n", " buttons.append([int(x) for x in b_str.split(',')])\n", "\n", " # --- Solve ---\n", " min_presses = solve_machine_with_search(targets, buttons)\n", " if min_presses == float('inf'):\n", " print(f\"Machine {i+1}: No solution possible.\")\n", " else:\n", " print(f\"Machine {i+1}: {min_presses} presses\")\n", " total_presses += min_presses\n", "\n", " return total_presses" ] }, { "cell_type": "markdown", "id": "a23b652e-6250-4db1-8f1e-32d2cf77b4c5", "metadata": {}, "source": [ "# Day 11: Claude Opus 4.5\n", "\n", "*For [**Day 11**](https://adventofcode.com/2025/day/11) we are given inputs like `qxn: mow khk`, whihc means that device `qxn` outputs to `mow` and `khk`, and are asked how many distinct output paths there are from the device named `you` to the device named `out`.*\n", "\n", "*Claude had no trouble solving **Part 1**. It even volunteered two possible implementations of `count_paths`. One thing was strange:*\n", "\n", " # Convert to frozenset for caching\n", " graph_tuple = {k: tuple(v) for k, v in graph.items()}\n", "\n", "*I can see where this is coming from: lists mutable and thus not hashable, so you can't have them in a cache, but frozensets and tuples are immutable and cachable. Maybe Claude was first going to convert `v` to a frozenset and then decided on a tuple? However, a dict is not hashable, so this doesn't help. In the end, Claude decided to use the inner function `dp`, so the graph is not in the cache anyway, and this step is not needed. Maybe Claude changed its mind and forgot to update this part of the code.*" ] }, { "cell_type": "code", "execution_count": 52, "id": "3326781d-5a53-4379-a094-dc08b0e4f2aa", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the device path counting puzzle.\n", "Count all paths from 'you' to 'out' in a directed graph.\n", "\"\"\"\n", "\n", "from collections import defaultdict\n", "from functools import lru_cache\n", "\n", "def parse_input(text: str) -> dict:\n", " \"\"\"Parse the input into a graph (adjacency list).\"\"\"\n", " graph = defaultdict(list)\n", " \n", " for line in text.strip().split('\\n'):\n", " line = line.strip()\n", " if not line or ':' not in line:\n", " continue\n", " \n", " parts = line.split(':')\n", " source = parts[0].strip()\n", " targets = parts[1].strip().split()\n", " \n", " for target in targets:\n", " graph[source].append(target)\n", " \n", " return graph\n", "\n", "\n", "def count_paths(graph: dict, start: str, end: str) -> int:\n", " \"\"\"\n", " Count all paths from start to end using memoization.\n", " Since this is a DAG (data only flows forward), we can use dynamic programming.\n", " \"\"\"\n", " # Convert to frozenset for caching\n", " graph_tuple = {k: tuple(v) for k, v in graph.items()}\n", " \n", " @lru_cache(maxsize=None)\n", " def dp(node: str) -> int:\n", " if node == end:\n", " return 1\n", " \n", " if node not in graph_tuple:\n", " return 0\n", " \n", " total = 0\n", " for neighbor in graph_tuple[node]:\n", " total += dp(neighbor)\n", " \n", " return total\n", " \n", " return dp(start)\n", "\n", "\n", "def count_paths_iterative(graph: dict, start: str, end: str) -> int:\n", " \"\"\"\n", " Alternative: Count paths using DFS without memoization.\n", " Works for graphs with cycles too (though problem says data flows forward only).\n", " \"\"\"\n", " count = 0\n", " stack = [start]\n", " \n", " def dfs(node, visited):\n", " nonlocal count\n", " if node == end:\n", " count += 1\n", " return\n", " \n", " if node not in graph:\n", " return\n", " \n", " for neighbor in graph[node]:\n", " if neighbor not in visited:\n", " visited.add(neighbor)\n", " dfs(neighbor, visited)\n", " visited.remove(neighbor)\n", " \n", " dfs(start, {start})\n", " return count\n", "\n", "\n", "def solve(input_text: str) -> int:\n", " \"\"\"Solve the puzzle and return the number of paths.\"\"\"\n", " graph = parse_input(input_text)\n", " return count_paths(graph, 'you', 'out')" ] }, { "cell_type": "code", "execution_count": 53, "id": "91bd8c65-e7ee-4869-88bc-a6d0f00fcc4c", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 11.1: 0.3 msec, correct answer: 574 " ] }, "execution_count": 53, "metadata": {}, "output_type": "execute_result" } ], "source": [ "input_text = get_text(11)\n", "\n", "answer(11.1, 574, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "76301e25-9c6b-41dd-8390-3eac05f3da38", "metadata": {}, "source": [ "*For **Part 2** we are asked how many paths from `svr` to `out` visit both `dac` and `fft`, and Claude again gets it right:*" ] }, { "cell_type": "code", "execution_count": 54, "id": "507c3fbb-60f8-494f-bafb-c24cc6d159f3", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Solution for the device path counting puzzle (Part 2).\n", "Count all paths from 'svr' to 'out' that visit both 'dac' and 'fft'.\n", "\"\"\"\n", "\n", "from collections import defaultdict\n", "from functools import lru_cache\n", "\n", "def parse_input(text: str) -> dict:\n", " \"\"\"Parse the input into a graph (adjacency list).\"\"\"\n", " graph = defaultdict(list)\n", " \n", " for line in text.strip().split('\\n'):\n", " line = line.strip()\n", " if not line or ':' not in line:\n", " continue\n", " \n", " parts = line.split(':')\n", " source = parts[0].strip()\n", " targets = parts[1].strip().split()\n", " \n", " for target in targets:\n", " graph[source].append(target)\n", " \n", " return graph\n", "\n", "\n", "def count_paths_through_nodes(graph: dict, start: str, end: str, required: set) -> int:\n", " \"\"\"\n", " Count all paths from start to end that visit all required nodes.\n", " \n", " We use a state-based DP where the state includes which required nodes have been visited.\n", " State = (current_node, frozenset of visited required nodes)\n", " \"\"\"\n", " graph_tuple = {k: tuple(v) for k, v in graph.items()}\n", " required_frozenset = frozenset(required)\n", " \n", " @lru_cache(maxsize=None)\n", " def dp(node: str, visited_required: frozenset) -> int:\n", " # Update visited required nodes\n", " if node in required:\n", " visited_required = visited_required | {node}\n", " \n", " # If we reached the end\n", " if node == end:\n", " # Only count if we've visited all required nodes\n", " if visited_required == required_frozenset:\n", " return 1\n", " else:\n", " return 0\n", " \n", " # If node has no outgoing edges\n", " if node not in graph_tuple:\n", " return 0\n", " \n", " # Sum paths through all neighbors\n", " total = 0\n", " for neighbor in graph_tuple[node]:\n", " total += dp(neighbor, visited_required)\n", " \n", " return total\n", " \n", " # Start with empty set of visited required nodes (or with start if it's required)\n", " initial_visited = frozenset()\n", " if start in required:\n", " initial_visited = frozenset({start})\n", " \n", " return dp(start, initial_visited)\n", "\n", "\n", "def solve(input_text: str) -> int:\n", " \"\"\"Solve the puzzle and return the number of valid paths.\"\"\"\n", " graph = parse_input(input_text)\n", " required_nodes = {'dac', 'fft'}\n", " return count_paths_through_nodes(graph, 'svr', 'out', required_nodes)" ] }, { "cell_type": "code", "execution_count": 55, "id": "a1cc0ded-2607-4703-87b9-4c0f0eafe8e9", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Puzzle 11.2: 0.8 msec, correct answer: 306594217920240" ] }, "execution_count": 55, "metadata": {}, "output_type": "execute_result" } ], "source": [ "answer(11.2, 306594217920240, lambda:\n", " solve(input_text))" ] }, { "cell_type": "markdown", "id": "900226c6-ef8f-4be0-b3db-565d8f30c8b8", "metadata": {}, "source": [ "# Day 12: ChatGPT 5.2 Auto\n", "\n", "***Note:*** *ChatGPT has a new version today: 5.2. I don't notice a big difference from 5.1, but I only did this one interaction.*\n", "\n", "*For [**Day 12**](https://adventofcode.com/2025/day/12) we are given some 3x3 grids describing the shapes of some oddly-shaped Christmas presents, thena re given some regions with given width and length, and asked if a specified number of presents of each kind can fit in the region.*\n", "\n", "*In my prompt I included my actual input, because that is key to the shortcut for solving the problem (which I covered in [**my notebook**](Advent-2025.ipynb)). ChatGPT didn't detect the shortcut and wrote code to rotate the shapes and search through possible placements. ChatGPT did have the check for `total_area > W * H`, so it is able to instantly reject the regions with too many presents (about half of them). But for the regions where there is a trivial fit into 3x3 squares, ChatGPT's code still tries to pack them in tightly rather than doing the simple layout.* " ] }, { "cell_type": "code", "execution_count": 56, "id": "47b16612-aa3c-46fc-be3f-25f0172198b9", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "Polyomino packing checker (rectangular regions, grid-aligned, rotations+flips allowed).\n", "\n", "Input format (as described):\n", "- Shapes section: repeated blocks like\n", " 0:\n", " ### \n", " ##.\n", " ...\n", " separated by blank lines.\n", "- Then a blank line, then regions like:\n", " 12x5: 1 0 1 0 2 2\n", "\n", "Output: number of regions that can fit all requested presents.\n", "\"\"\"\n", "\n", "from dataclasses import dataclass\n", "from functools import lru_cache\n", "from typing import Dict, Iterable, List, Sequence, Tuple\n", "\n", "\n", "Coord = Tuple[int, int] # (x, y)\n", "\n", "\n", "@dataclass(frozen=True)\n", "class Shape:\n", " idx: int\n", " cells: Tuple[Coord, ...] # canonical cells for the \"base\" orientation\n", " orientations: Tuple[Tuple[Coord, ...], ...] # all unique orientations\n", " area: int\n", "\n", "\n", "def _normalize(cells: Iterable[Coord]) -> Tuple[Coord, ...]:\n", " cells = list(cells)\n", " min_x = min(x for x, _ in cells)\n", " min_y = min(y for _, y in cells)\n", " norm = sorted((x - min_x, y - min_y) for x, y in cells)\n", " return tuple(norm)\n", "\n", "\n", "def _rot90(cells: Iterable[Coord]) -> List[Coord]:\n", " # Rotate around origin: (x, y) -> (y, -x)\n", " return [(y, -x) for x, y in cells]\n", "\n", "\n", "def _flip_x(cells: Iterable[Coord]) -> List[Coord]:\n", " # Mirror across y-axis: (x, y) -> (-x, y)\n", " return [(-x, y) for x, y in cells]\n", "\n", "\n", "def _unique_orientations(cells: Tuple[Coord, ...]) -> Tuple[Tuple[Coord, ...], ...]:\n", " # Start from given cells (already normalized), generate 8 dihedral transforms.\n", " base = list(cells)\n", " seen = set()\n", " outs: List[Tuple[Coord, ...]] = []\n", "\n", " cur = base\n", " for _ in range(4):\n", " for variant in (cur, _flip_x(cur)):\n", " norm = _normalize(variant)\n", " if norm not in seen:\n", " seen.add(norm)\n", " outs.append(norm)\n", " cur = _rot90(cur)\n", "\n", " # Deterministic ordering: sort by lexicographic coords\n", " outs.sort()\n", " return tuple(outs)\n", "\n", "\n", "def parse_input(text: str) -> Tuple[List[Shape], List[Tuple[int, int, List[int]]]]:\n", " lines = [ln.rstrip(\"\\n\") for ln in text.splitlines()]\n", "\n", " shapes_raw: Dict[int, List[str]] = {}\n", " regions: List[Tuple[int, int, List[int]]] = []\n", "\n", " i = 0\n", " # Parse shapes until we encounter a region line (contains 'x' and ':')\n", " while i < len(lines):\n", " ln = lines[i].strip()\n", " if not ln:\n", " i += 1\n", " continue\n", " # Region lines look like \"12x5: ...\"\n", " if \"x\" in ln and \":\" in ln and ln.split(\":\", 1)[0].count(\"x\") == 1 and ln.split(\":\", 1)[0].replace(\"x\", \"\").isdigit() is False:\n", " # That heuristic is messy; instead: try parse as region\n", " pass\n", "\n", " # Prefer: shape header \"N:\"\n", " if ln.endswith(\":\") and ln[:-1].isdigit():\n", " idx = int(ln[:-1])\n", " i += 1\n", " grid: List[str] = []\n", " while i < len(lines) and lines[i].strip() != \"\":\n", " # Stop if we hit something that looks like a region (WxH:)\n", " if \":\" in lines[i] and \"x\" in lines[i].split(\":\", 1)[0]:\n", " break\n", " grid.append(lines[i].rstrip())\n", " i += 1\n", " shapes_raw[idx] = grid\n", " continue\n", "\n", " # If not a shape header, we assume we've reached regions section\n", " break\n", "\n", " # Parse regions from remaining lines\n", " while i < len(lines):\n", " ln = lines[i].strip()\n", " i += 1\n", " if not ln:\n", " continue\n", " if \":\" not in ln or \"x\" not in ln.split(\":\", 1)[0]:\n", " continue\n", " dim, rest = ln.split(\":\", 1)\n", " w_str, h_str = dim.split(\"x\", 1)\n", " w, h = int(w_str), int(h_str)\n", " counts = [int(x) for x in rest.strip().split()] if rest.strip() else []\n", " regions.append((w, h, counts))\n", "\n", " # Build Shape objects (ensure sorted by idx)\n", " shapes: List[Shape] = []\n", " for idx in sorted(shapes_raw.keys()):\n", " grid = shapes_raw[idx]\n", " cells: List[Coord] = []\n", " for y, row in enumerate(grid):\n", " for x, ch in enumerate(row):\n", " if ch == \"#\":\n", " cells.append((x, y))\n", " if not cells:\n", " raise ValueError(f\"Shape {idx} has no # cells\")\n", " canon = _normalize(cells)\n", " orients = _unique_orientations(canon)\n", " shapes.append(Shape(idx=idx, cells=canon, orientations=orients, area=len(canon)))\n", "\n", " return shapes, regions\n", "\n", "\n", "def build_placements(\n", " W: int, H: int, shape: Shape\n", ") -> List[int]:\n", " \"\"\"Return all placement bitmasks for this shape in a W x H rectangle.\"\"\"\n", " placements: List[int] = []\n", " for orient in shape.orientations:\n", " max_x = max(x for x, _ in orient)\n", " max_y = max(y for _, y in orient)\n", " for oy in range(H - max_y):\n", " for ox in range(W - max_x):\n", " mask = 0\n", " for x, y in orient:\n", " xx, yy = ox + x, oy + y\n", " bit = yy * W + xx\n", " mask |= 1 << bit\n", " placements.append(mask)\n", " return placements\n", "\n", "\n", "def can_pack_region(\n", " W: int,\n", " H: int,\n", " shapes: List[Shape],\n", " counts: List[int],\n", ") -> bool:\n", " n = len(shapes)\n", " if len(counts) < n:\n", " counts = counts + [0] * (n - len(counts))\n", " elif len(counts) > n:\n", " raise ValueError(\"Region counts list longer than number of shapes\")\n", "\n", " total_area = sum(counts[i] * shapes[i].area for i in range(n))\n", " if total_area > W * H:\n", " return False\n", "\n", " placements_by_type: List[List[int]] = [build_placements(W, H, shapes[i]) for i in range(n)]\n", "\n", " # Only keep types that are actually requested\n", " types = [i for i in range(n) if counts[i] > 0]\n", " if not types:\n", " return True\n", "\n", " # Precompute remaining-area contribution array for fast pruning\n", " areas = [shapes[i].area for i in range(n)]\n", "\n", " # Use a compact counts tuple only over requested types (stable order)\n", " type_to_pos = {t: p for p, t in enumerate(types)}\n", " init_ct = tuple(counts[t] for t in types)\n", "\n", " @lru_cache(maxsize=None)\n", " def dfs(occ: int, ct: Tuple[int, ...], remaining_area: int) -> bool:\n", " # Prune by area vs free cells\n", " free = W * H - bin(occ).count(\"1\")\n", " if remaining_area > free:\n", " return False\n", " if remaining_area == 0:\n", " return True\n", "\n", " # Choose next type with fewest available placements under current occupancy\n", " best_pos = -1\n", " best_type = -1\n", " best_moves: List[int] = []\n", " best_len = 10**18\n", "\n", " for pos, t in enumerate(types):\n", " k = ct[pos]\n", " if k <= 0:\n", " continue\n", " moves = [m for m in placements_by_type[t] if (m & occ) == 0]\n", " lm = len(moves)\n", " if lm == 0:\n", " return False\n", " if lm < best_len:\n", " best_len = lm\n", " best_pos = pos\n", " best_type = t\n", " best_moves = moves\n", " if lm == 1:\n", " break\n", "\n", " # Try each placement\n", " new_ct_base = list(ct)\n", " new_ct_base[best_pos] -= 1\n", " new_ct_base = tuple(new_ct_base)\n", " new_remaining_area = remaining_area - areas[best_type]\n", "\n", " for m in best_moves:\n", " if dfs(occ | m, new_ct_base, new_remaining_area):\n", " return True\n", " return False\n", "\n", " return dfs(0, init_ct, total_area)\n", "\n", "\n", "def solve(text: str) -> int:\n", " shapes, regions = parse_input(text)\n", " ok = 0\n", " for W, H, counts in regions:\n", " if can_pack_region(W, H, shapes, counts):\n", " ok += 1\n", " return ok" ] }, { "cell_type": "markdown", "id": "2cbca4d7-773c-4027-8f27-270887180ee1", "metadata": {}, "source": [ "*Kudos to ChatGPT for writing code that works, and for quickly rejecting regions where `total_area > W * H`. But by failing to immediately detect the cases where all the presents trivially fit into 3x3 boxes, the program takes two minutes to run, when it could have been done in under a millisecond. I'm not going to make you wait two minutes, but if you want to you can uncomment the code below:*" ] }, { "cell_type": "code", "execution_count": 57, "id": "90ecec67-fac0-4ad4-9047-0c7c9344b30e", "metadata": {}, "outputs": [], "source": [ "# text = get_text(12)\n", "# answer(12.1, 454, lambda:\n", "# solve(text))" ] }, { "cell_type": "markdown", "id": "9e431b20-0bdd-4d79-86d8-3fe4ede29044", "metadata": {}, "source": [ "# AoC Utilities\n", "\n", "*I showed this notebook at a [Hacker Dojo](https://hackerdojo.org/) meetup and one comment was that the lines-of-code comparison was unfair, since I used my utilities module, which saved a lot of lines, particularly with parsing the input. I completely agree with this comment, so I asked each of the LLMs **If you were going to do the Advent of Code programming contest, what set of utility functions would you define ahead of time?** In retrospect, I should have done this right from the start.*\n", "\n", "*They all were quite familiar with AoC and had ideas for utilities. All three came up with very similar functions for these four areas:*\n", "1) *Input parsing*\n", "2) *Manipulating a 2D grid and (x, y) coordinate points*\n", "3) *Graph search (Dijkstra and breadth-first search)*\n", "4) *Various math functions*\n", "\n", "*All three LLMs chose to implement grids as dicts rather than nested lists, perhaps because some AoC problems have grids of unbounded size. Since all three came up with similar code, I asked them **How did you come up with that?** and they each explained:*\n", "\n", "***Gemini*** *gave credit to [betaveros](https://github.com/betaveros), [ecnerwala](https://github.com/ecnerwala/aoc-2019), [hyperneutrino](https://github.com/hyperneutrino/aoc-header), [Jonathan Paulson](https://github.com/jonathanpaulson/AdventOfCode), and some guy named [Peter Norvig](https://github.com/norvig/pytudes/tree/main) (for the `ints` function, about which Gemini says* \"It is a brilliant \"lazy\" hack that saves 10 minutes of typing per day.\") *Gemini mentioned that some programmers at the top of the leaderboard use complex numbers for 2D coordinates, but that an `(x, y)` tuple is easier to read. Gemini also included a complicated function, `solve_cycle`, designed to simulate several generations of a process (like the Game of Life), find a repeated state, and then look up the state for a generation far in the future. Gemini said*: this is a lifesaver for problems that ask \"What will the grid look like after 1,000,000,000 steps?\"\n", "\n", "***Claude*** *said its inspiration came from* \"The AoC problem archetypes themselves\" *and* \"common frustrations: The `ints()` regex approach exists because so many inputs look like \"`Sensor at x=2, y=18: closest beacon is at x=-2, y=15`\" and manually parsing that is tedious.\"\n", "\n", "**ChatGPT** *had the most to say on how it developed its utilities:*\n", "\n", "Over the years, reading:\n", "- Reddit solution megathreads\n", "- Writeups by top solvers\n", "- GitHub repos after the contest\n", "\n", "…you see the same shapes again and again, even when code differs. Examples:\n", "- Everyone’s Dijkstra looks the same.\n", "- Everyone eventually has a DIRS4. *(Note: mine is called `directions4`)*\n", "- Everyone regrets not having ints() on Day 1.\n", "\n", "A more “actionable” mapping (day number → utilities I’d expect)\n", "\n", "- Day 1–3: lines, ints, Counter, simple loops\n", "- Day 4–7: tokenization (words, split_strip), sets/maps, maybe regex\n", "- Day 8–10: coordinate sets, manhattan, DIRS4, simple simulation\n", "- Day 11–14: grid regions ⇒ grid_*, neighbors, flood fill (BFS/DFS)\n", "- Day 15–17: shortest path ⇒ dijkstra (+ sometimes “state includes direction”)\n", "- Day 18–20: graph building, BFS/DFS, sometimes union-find (add when needed)\n", "- Day 21–24: DP/memoization ⇒ lru_cache, careful state encoding, sometimes modular math / cycle finding\n", "- Day 25: usually a “build the thing / find the structure” day; helpers depend on puzzle\n", "\n", "\n", "The template reflects those convergent solutions—but simplified to the core essence.\n", "\n", "This is not a library. It’s a memory prosthetic.\n", "\n", "*Below I merge the three utility libraries into one for the areas where they all wrote very similar code, and then I give the functions that were unique to each LLM:*" ] }, { "cell_type": "code", "execution_count": 58, "id": "3b3dbe00-a191-412b-b062-edefc9ab4013", "metadata": {}, "outputs": [], "source": [ "# =============================================================================\n", "# 1. INPUT PARSING\n", "# =============================================================================\n", "\n", "def read_input(day: int) -> str:\n", " \"\"\"Read raw input file.\"\"\"\n", " return open(f\"day{day:02d}.txt\").read().strip()\n", "\n", "def lines(s: str) -> list[str]:\n", " return s.strip().split('\\n')\n", "\n", "def paragraphs(s: str) -> list[str]:\n", " \"\"\"Split on blank lines (common for grouped input).\"\"\"\n", " return s.strip().split('\\n\\n')\n", "\n", "def ints(s: str) -> list[int]:\n", " \"\"\"Extract all integers from a string, including negatives.\"\"\"\n", " import re\n", " return list(map(int, re.findall(r'-?\\d+', s)))\n", "\n", "# =============================================================================\n", "# 2. GRID & COORDINATES\n", "# =============================================================================\n", "\n", "DIRS4 = [(0, 1), (1, 0), (0, -1), (-1, 0)] # right, down, left, up\n", "DIRS8 = DIRS4 + [(1, 1), (1, -1), (-1, 1), (-1, -1)]\n", "\n", "def grid(s: str) -> dict[tuple[int, int], str]:\n", " \"\"\"Parse a 2D grid into {(row, col): char} dict.\"\"\"\n", " return {(r, c): ch \n", " for r, line in enumerate(s.strip().split('\\n')) \n", " for c, ch in enumerate(line)}\n", "\n", "def neighbors4(r, c):\n", " return [(r + dr, c + dc) for dr, dc in DIRS4]\n", "\n", "def neighbors8(r, c):\n", " return [(r + dr, c + dc) for dr, dc in DIRS8]\n", "\n", "def in_bounds(r, c, rows, cols):\n", " return 0 <= r < rows and 0 <= c < cols\n", "\n", "def manhattan(p1, p2):\n", " \"\"\"\n", " Manhattan distance between two points (tuples).\n", " \"\"\"\n", " return sum(abs(a - b) for a, b in zip(p1, p2))\n", "\n", "def print_grid(grid_dict, default='.'):\n", " \"\"\"\n", " Visualizes a dictionary grid.\n", " \"\"\"\n", " if not grid_dict:\n", " return\n", " rs = [r for r, c in grid_dict.keys()]\n", " cs = [c for r, c in grid_dict.keys()]\n", " min_r, max_r = min(rs), max(rs)\n", " min_c, max_c = min(cs), max(cs)\n", "\n", " for r in range(min_r, max_r + 1):\n", " line = \"\"\n", " for c in range(min_c, max_c + 1):\n", " line += str(grid_dict.get((r, c), default))\n", " print(line)\n", "\n", "# =============================================================================\n", "# 3. GRAPH ALGORITHMS\n", "# =============================================================================\n", "\n", "def bfs(start, neighbors_fn, goal_fn=None):\n", " \"\"\"Generic BFS. Returns distances dict (and path to goal if goal_fn provided).\"\"\"\n", " dist = {start: 0}\n", " queue = deque([start])\n", " while queue:\n", " node = queue.popleft()\n", " if goal_fn and goal_fn(node):\n", " return dist[node], dist\n", " for neighbor in neighbors_fn(node):\n", " if neighbor not in dist:\n", " dist[neighbor] = dist[node] + 1\n", " queue.append(neighbor)\n", " return dist\n", "\n", "def dijkstra(start, neighbors_fn, goal_fn=None):\n", " \"\"\"neighbors_fn returns [(neighbor, cost), ...]\"\"\"\n", " dist = {start: 0}\n", " pq = [(0, start)]\n", " while pq:\n", " d, node = heappop(pq)\n", " if d > dist.get(node, float('inf')):\n", " continue\n", " if goal_fn and goal_fn(node):\n", " return d\n", " for neighbor, cost in neighbors_fn(node):\n", " nd = d + cost\n", " if nd < dist.get(neighbor, float('inf')):\n", " dist[neighbor] = nd\n", " heappush(pq, (nd, neighbor))\n", " return dist\n", "\n", "# =============================================================================\n", "# 4. MATH & HELPERS\n", "# =============================================================================\n", "\n", "def lcm(a, b):\n", " return a * b // gcd(a, b)\n", "\n", "def lcm_many(nums):\n", " return reduce(lcm, nums)\n", "\n", "def sign(x):\n", " return (x > 0) - (x < 0)\n", "\n", "# =============================================================================\n", "# Functions unique to Gemini\n", "# =============================================================================\n", "\n", "def digits(text):\n", " \"\"\"\n", " Extracts all single digits from a string.\n", " Example: \"a1b2\" -> [1, 2]\n", " \"\"\"\n", " return [int(x) for x in re.findall(r'\\d', text)]\n", "\n", "def solve_cycle(start_state, step_fn, steps_needed):\n", " \"\"\"\n", " Simulates a process until a cycle is found, then fast-forwards to the target step.\n", " \n", " :param start_state: The initial state (must be hashable, e.g., tuple or frozenset).\n", " :param step_fn: Function(state) -> next_state.\n", " :param steps_needed: The huge number (e.g., 1_000_000_000).\n", " :return: The state at `steps_needed`.\n", " \"\"\"\n", " seen = {}\n", " history = []\n", " curr = start_state\n", " \n", " for i in range(steps_needed):\n", " # If we have seen this state before, we found a cycle!\n", " if curr in seen:\n", " first_seen_index = seen[curr]\n", " cycle_len = i - first_seen_index\n", " remaining_steps = steps_needed - i\n", " \n", " # Calculate where we land in the cycle\n", " final_index = first_seen_index + (remaining_steps % cycle_len)\n", " return history[final_index]\n", " \n", " # Record state\n", " seen[curr] = i\n", " history.append(curr)\n", " \n", " # Advance\n", " curr = step_fn(curr)\n", " \n", " return curr\n", " \n", "# =============================================================================\n", "# Functions unique to Claude\n", "# =============================================================================\n", "\n", "def transpose(grid):\n", " return list(map(list, zip(*grid)))\n", "\n", "def flatten(nested):\n", " return [item for sublist in nested for item in sublist]\n", "\n", "# =============================================================================\n", "# Functions unique to ChatGPT\n", "# =============================================================================\n", "\n", "def words(s: str) -> list[str]:\n", " return re.findall(r\"[A-Za-z]+\", s)\n", "\n", "def split_strip(s: str, sep: str = \",\") -> list[str]:\n", " return [x.strip() for x in s.split(sep)]\n", "\n", "def csv(s, cast=str):\n", " return [cast(x) for x in s.split(\",\")]\n", "\n", "def windows(xs, n):\n", " for i in range(len(xs)-n+1):\n", " yield xs[i:i+n]\n", "\n", "def pairwise(xs):\n", " for a,b in zip(xs, xs[1:]):\n", " yield a,b" ] }, { "cell_type": "markdown", "id": "8aa26008-a652-4860-9c84-5ba4344d32f3", "metadata": {}, "source": [ "# Run Time Comparison\n", "\n", "\n", "*The human-written code is roughly **three times faster** than the LLM code (for both total and median times).*\n", "\n", "*(This is after throwing out 12.1, because the human interepreted it as \"solve my particular input\" and the LLM as \"solve any possible input\" so it is not fair to compare run times.)*\n", "\n", "\n", "\n", "
LLMHuman
\n",
    "Time in msecs: sum: 1215.3, mean: 55.2, median: 2.8\n",
    "\n",
    "Puzzle  1.1:    0.7 msec, answer: 1182           \n",
    "Puzzle  1.2:    0.8 msec, answer: 6907           \n",
    "Puzzle  2.1:   36.7 msec, answer: 23560874270    \n",
    "Puzzle  2.2:   38.2 msec, answer: 44143124633    \n",
    "Puzzle  3.1:    1.7 msec, answer: 17085          \n",
    "Puzzle  3.2:    2.6 msec, answer: 169408143086082\n",
    "Puzzle  4.1:    8.5 msec, answer: 1569           \n",
    "Puzzle  4.2:   33.8 msec, answer: 9280           \n",
    "Puzzle  5.1:    3.1 msec, answer: 635            \n",
    "Puzzle  5.2:    0.1 msec, answer: 369761800782619\n",
    "Puzzle  6.1:    2.9 msec, answer: 5877594983578  \n",
    "Puzzle  6.2:    2.3 msec, answer: 11159825706149 \n",
    "Puzzle  7.1:    0.4 msec, answer: 1681           \n",
    "Puzzle  7.2:    1.0 msec, answer: 422102272495018\n",
    "Puzzle  8.1:  293.3 msec, answer: 24360          \n",
    "Puzzle  8.2:  284.5 msec, answer: 2185817796     \n",
    "Puzzle  9.1:    9.3 msec, answer: 4772103936     \n",
    "Puzzle  9.2:  447.0 msec, answer: 1529675217     \n",
    "Puzzle 10.1:    1.9 msec, answer: 441            \n",
    "Puzzle 10.2:   45.3 msec, answer: 18559          \n",
    "Puzzle 11.1:    0.3 msec, answer: 574            \n",
    "Puzzle 11.2:    0.8 msec, answer: 306594217920240\n",
    "
\n", "
\n",
    "Time in msecs: sum: 366.2, mean: 15.9, median: 0.9\n",
    "\n",
    "Puzzle  1.1:    0.2 msec, answer: 1182           \n",
    "Puzzle  1.2:    0.4 msec, answer: 6907           \n",
    "Puzzle  2.1:    0.1 msec, answer: 23560874270    \n",
    "Puzzle  2.2:    0.2 msec, answer: 44143124633    \n",
    "Puzzle  3.1:    0.3 msec, answer: 17085          \n",
    "Puzzle  3.2:    0.8 msec, answer: 169408143086082\n",
    "Puzzle  4.1:   18.8 msec, answer: 1569           \n",
    "Puzzle  4.2:   50.1 msec, answer: 9280           \n",
    "Puzzle  5.1:    4.4 msec, answer: 635            \n",
    "Puzzle  5.2:    0.0 msec, answer: 369761800782619\n",
    "Puzzle  6.1:    0.9 msec, answer: 5877594983578  \n",
    "Puzzle  6.2:    1.3 msec, answer: 11159825706149 \n",
    "Puzzle  7.1:    0.4 msec, answer: 1681           \n",
    "Puzzle  7.2:    0.7 msec, answer: 422102272495018\n",
    "Puzzle  8.1:   93.0 msec, answer: 24360          \n",
    "Puzzle  8.2:  113.5 msec, answer: 2185817796     \n",
    "Puzzle  9.1:   11.7 msec, answer: 4772103936     \n",
    "Puzzle  9.2:    2.0 msec, answer: 1529675217     \n",
    "Puzzle 10.1:   21.5 msec, answer: 441            \n",
    "Puzzle 10.2:   44.3 msec, answer: 18559          \n",
    "Puzzle 11.1:    0.1 msec, answer: 574            \n",
    "Puzzle 11.2:    0.9 msec, answer: 306594217920240\n",
    "
\n", "
" ] }, { "cell_type": "markdown", "id": "ac68c9e7-5a96-45eb-bc16-d3a097f3afc4", "metadata": {}, "source": [ "# Lines of Code Comparison and Commentary\n", "\n", "*The human-written code is about **five times more concise** than the LLM code.*\n", "\n", "*The LOC numbers are total lines of code, including blank lines, comments, and doc strings.*" ] }, { "cell_type": "markdown", "id": "e72b00a0-cc8c-431e-95d2-151848777080", "metadata": {}, "source": [ " | DAY | LLM
Name | LLM
LOC | Human
LOC | Commentary |\n", " |---:|:------:|---:|-----:|---|\n", " | 1.1 | Gemini | 57 | 5 | Nice code, a little over-commented. | \n", " | 1.2 | Gemini | 80 | 12 | Both LLM and human erred on the distance from 0 to 0. | \n", " | 2.1 | Claude | 31 | 19 | No comments by Claude today | \n", " | 2.2 | Claude | 36 | 19 | Both LLM and human found the more efficient half-digits approach | \n", " | 3.1 | ChatGPT | 24 | 12 | Nice use of type annotations by ChatGPT today | \n", " | 3.2 | ChatGPT | 43 | 15 | | \n", " | 4.1 | Gemini | 46 | 10 | | \n", " | 4.2 | Gemini | 67| 7 | LLM chose the less efficient scan-whole-grid approach; when prompted, fixed it | \n", " | 5.1 | Claude | 47 | 12 | Nice modularization and doc strings; they were missing on Day 2 | \n", " | 5.2 | Claude | 59 | 10| | \n", " | 6.1 | ChatGPT | 69 | 8 | Poor modularization; bad “if x: True else: False” idiom by ChatGPT | \n", " | 6.2 | ChatGPT | 88 | 27 | LLM overly verbose | \n", " | 7.1 | Gemini | 65 | 14 | When prompted, Gemini added type annotations and nice modularization | \n", " | 7.2 | Gemini | 71 | 12 | | \n", " | 8.1 | Claude | 92 | 27 | | \n", " | 8.2 | Claude | 82 | 13| LLM's UnionFind class runs slower than my simpler code | \n", " | 9.1 | ChatGPT | 34 | 8 | ChatGPT had no type annotations for 9.1, then added them for 9.2| \n", " | 9.2 | ChatGPT | 158 | 48 | ChatGPT code a bit complicated; human uses “2 point” trick for speedup | \n", " | 10.1 | Gemini | 103 | 20| Poor modularization; one 100-line function| \n", " | 10.2 | Gemini | 71 | 14 | milp solutions similar; LLM offers other solutions | \n", " | 11.1 | Claude | 85 | 12 | Good modularization, but a bit of vestigial code | \n", " | 11.2 | Claude | 76 | 12 | Claude used type annotations today| \n", " | 12.1 | ChatGPT | 248 | 20 | Human used shortcut to avoid search; LLM wrote slow search (but with type annotations) | \n", " | **TOTAL** | | **1732** | **356** | | \n", " | **MEAN** | | **75** | **14.5** | |" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.3" } }, "nbformat": 4, "nbformat_minor": 5 }