Advent of Code 2025 - Day 2: Gift Shop
niels jaspers • december 2, 2025
This is part two of an ongoing series of posts about my experiences solving the Advent of Code 2025 puzzles. This year, I'm doing a showdown with my good friend Levi. Since AoC is twelve days long this year, we'll be solving the puzzles in twelve different languages.
Day 2 of Advent of Code 2025 had me learning Lua from scratch while discovering that sometimes the best optimization isn't faster code. Sometimes it's just realizing you're solving the wrong problem entirely.
The Problem
We're given ranges of "ID numbers" and need to find all "invalid IDs" within them. An invalid ID is one that consists of a repeating pattern.
Part 1: Numbers where the left half equals the right half. Think 1212, 100100, 77777777.
Part 2: Numbers where any pattern repeats two or more times. Now 111, 565656, and 824824824 all count too.
The catch? Some ranges span millions of numbers. Checking each one individually would take forever, especially in Lua.
Starting Simple
My first approach was the obvious one: iterate through every number, convert to string, split in half, compare.
for i = s, e do
local str = tostring(i)
if #str % 2 == 0 then
local mid = #str / 2
local left = string.sub(str, 1, mid)
local right = string.sub(str, mid + 1)
if left == right then
table.insert(numbers, i)
end
end
end
This worked fine for small ranges. Then I hit a range like 7777742220-7777814718 and watched my terminal hang.
Runtime: ~850ms for the test data. Not great.
Learning Lua's Quirks Along the Way
Before I could optimize anything, I had to wrestle with Lua itself. Some highlights:
- No built-in split function. You're on your own.
- Tables are everything. Arrays? Tables with numeric indices. Maps? Also tables. Printing a table? You get a memory address like
table: 0x10122f8c0. Thanks, Lua. - Not-equal is
~=, not!=. As I eloquently put it in my notes: "fuck you lua". - Functions must be defined before they're called. Coming from any other modern language, this felt like stepping back in time.
The Insight: Generate, Don't Check
The turning point came when I stopped trying to speed up the checking and started asking: can I generate only the numbers that match the pattern?
Numbers like 11, 1212, 100100 follow a pattern. For a number with an even number of digits (say, 2k digits where k is half), if the left half equals the right half, it can be expressed as:
n × (10^k + 1)
Where n is any k-digit number.
Why does this formula work?
The expression 10^k + 1 creates a "repeater" multiplier. When you multiply n by this:
n × 10^kshiftsnleft by k digits- Adding
nplaces the original number in the rightmost positions
So n × (10^k + 1) = n × 10^k + n, which concatenates n with itself.
Concrete example: For n = 12 and k = 2:
12 × (10² + 1) = 12 × 101 = 12 × 100 + 12 × 1 = 1200 + 12 = 1212
The pattern for different digit counts
- 2 digits (k=1):
n × 11where n = 1 to 9 → produces 11, 22, 33, ..., 99 - 4 digits (k=2):
n × 101where n = 10 to 99 → produces 1010, 1111, 1212, ..., 9999 - 6 digits (k=3):
n × 1001where n = 100 to 999 → produces 100100, 101101, ..., 999999
Instead of checking millions of numbers, I only need to generate all k-digit patterns and multiply. For 4-digit results, that's just 90 multiplications (10-99). For 10-digit results, still only 90,000 multiplications. That's way better than checking millions.
Tight bounds: only generate what's in range
There's another optimization hiding here. Instead of generating all possible patterns and filtering to our range, we can compute exactly which patterns will produce results in range [s, e] (where s and e are the start and end of the range).
Since candidate = n × multiplier, we need s ≤ n × multiplier ≤ e.
Rearranging: s/multiplier ≤ n ≤ e/multiplier
We clamp this to the valid n range to avoid generating out-of-range numbers:
local start_n = math.max(min_n, math.ceil(s / multiplier))
local end_n = math.min(max_n, math.floor(e / multiplier))
for n = start_n, end_n do
total = total + (n * multiplier)
end
No conditional checks needed inside the loop - every generated number is guaranteed to be in range.
Part 2: Any Repeating Pattern
Part 2 expanded the definition: any repeating pattern counts, not just "left half equals right half". Now 111 (1 repeated 3 times), 565656 (56 repeated 3 times), and 824824824 (824 repeated 3 times) are all invalid.
The approach is similar, but we need to consider all valid chunk sizes, not just half.
The algorithm
Step 1: Figure out which digit counts could appear in the range [s, e].
Example: range 95-115 contains 2-digit numbers (95-99) and 3-digit numbers (100-115), so we check d = 2 and d = 3.
Step 2: For each digit count d, find all ways to split it into repeating chunks.
A chunk size k is valid if:
kdividesdevenly (no remainder)d/k >= 2(need at least 2 repetitions)
Example for d=6:
- k=1: 6/1 = 6 repetitions (pattern "1" → 111111) ✓
- k=2: 6/2 = 3 repetitions (pattern "10" → 101010) ✓
- k=3: 6/3 = 2 repetitions (pattern "100" → 100100) ✓
- k=4: 6/4 = 1.5 repetitions - doesn't divide evenly ✗
Step 3: For each valid chunk size k, generate all possible k-digit patterns.
Example for k=2: generate all 2-digit numbers from 10 to 99. These are the "chunks" we'll repeat.
Step 4: For each pattern p, use the formula to create the full repeating number.
The generalized formula:
p × (10^d - 1) / (10^k - 1)
Why does this formula work?
Let's break down (10^d - 1) / (10^k - 1) for d=6, k=2:
(10⁶ - 1) / (10² - 1) = 999999 / 99 = 10101
What is 10101? It's 10000 + 100 + 1, or equivalently 10^4 + 10^2 + 10^0.
When you multiply a 2-digit pattern by 10101:
56 × 10101 = 56 × (10000 + 100 + 1)
= 560000 + 5600 + 56
= 565656
The multiplier places the pattern at each "slot" position. For 3 repetitions of a 2-digit chunk, those slots are at positions 0, 2, and 4.
Step 5: Check if the generated number is in range and hasn't been added yet.
The Duplicate Bug
My Part 2 solution initially gave the wrong answer: 4174823709 instead of 4174379265.
The problem? Numbers like 222222 match multiple patterns:
- Chunk size 1: "2" repeated 6 times
- Chunk size 2: "22" repeated 3 times
- Chunk size 3: "222" repeated 2 times
I was counting it three times. The fix was simple though, just track what we've already added:
local seen = {}
for p = start_p, end_p do
local candidate = p * multiplier
if not seen[candidate] then
seen[candidate] = true
total = total + candidate
end
end
The Final Optimized Solution
After all the iterations, the final code is clean and fast:
local pow10_cache = {}
local function pow10(n)
if not pow10_cache[n] then
pow10_cache[n] = math.pow(10, n)
end
return pow10_cache[n]
end
local total = 0
local seen = {}
for s_str, e_str in line:gmatch("(%d+)-(%d+)") do
local s = tonumber(s_str)
local e = tonumber(e_str)
local min_digits = #s_str
local max_digits = #e_str
for d = min_digits, max_digits do
for k = 1, math.floor(d / 2) do
if d % k == 0 then
local multiplier = (pow10(d) - 1) / (pow10(k) - 1)
local start_p = math.max(pow10(k-1), math.ceil(s / multiplier))
local end_p = math.min(pow10(k)-1, math.floor(e / multiplier))
for p = start_p, end_p do
local candidate = p * multiplier
if not seen[candidate] then
seen[candidate] = true
total = total + candidate
end
end
end
end
end
end
Runtime: sub-millisecond. From 850ms to essentially instant.
Optimizations That Mattered
In order of impact:
- Generate instead of check. The algorithmic change from O(M) to O(D × 10^(D/2)) where M is the range size and D is digit count.
- Tight bounds calculation. Instead of generating all patterns and filtering, compute exactly which patterns produce in-range results.
- Sum as you go. No intermediate storage, no second loop. Just accumulate directly.
- Inline parsing. Replaced a custom split function with a single
gmatchpattern:"(%d+)-(%d+)"captures both numbers directly. - Cache pow10. Minor but measurable. Avoids redundant
math.powcalls.
Lessons Learned
The best optimization is often algorithmic. All the micro-optimizations in the world (caching, inlining, avoiding string operations) wouldn't have saved my 850ms solution. The biggest speedup came from recognizing that the numbers I wanted had a mathematical structure I could exploit.
Constraints are features. Using "slow" Lua forced me to think about efficiency in a way that Python might not have. The discomfort was productive.
Duplicates will get you. Any time you're generating candidates from multiple sources (different chunk sizes, different ranges), think about whether the same result can appear twice.
Math is your friend. Once I understood why n × (10^k + 1) produces repeating numbers, extending it to arbitrary repetitions was straightforward. It's not magic, it's just place value arithmetic.
On to Day 3.