Advent of Code 2025 - Day 3: Lobby
niels jaspers • december 3, 2025
This is part three 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 3 of Advent of Code 2025 was less about algorithmic breakthroughs and more about squeezing every last millisecond out of Python. Levi had a 1.4ms solution on part 1. I was determined to beat it.
The Problem
We're given lines of digits (like 987654321111111) and need to form the largest possible number by selecting digits, but we can't rearrange them. They must stay in their original order.
Part 1: Select exactly 2 digits to form a two-digit number. Find the maximum.
Part 2: Select exactly 12 digits to form a twelve-digit number. Find the maximum.
Part 1: Finding Two Digits
The naive approach would be to check every pair of positions, but that's O(n²). Instead, we can do it in a single pass.
The insight: we want the largest possible first digit, and then the largest possible second digit that comes after it. As we scan left to right, we track:
num1: the best first digit we've seennum2: the best second digit that comes after our currentnum1
When we find a new larger first digit, we reset num2 because any previous second digit candidates are now invalid.
The solution I eventually came up with was:
def sortDigits(line: str) -> int:
num1 = line[0]
num2 = '0'
for digit in line[1:-1]:
if num1 == '9' and num2 == '9':
return 99
if num1 < digit:
num1 = digit
num2 = '0'
elif num1 == digit or num2 < digit:
num2 = digit
digit = line[-1]
if num1 != '9' or num2 != '9':
if num2 < digit:
num2 = digit
return (ord(num1) - 48) * 10 + (ord(num2) - 48)
But getting here took a journey through failed optimizations and counterintuitive discoveries about Python.
Slow Start
Starting Point: ~3.2ms
My first working solution used a list comprehension to convert the string to integers, then processed the list:
digits = [int(char) for char in line]
for i, digit in enumerate(digits):
# ... logic
It worked, but Levi's solution ran in 1.4ms. Challenge accepted.
Failed Optimization #1: Working with Strings Directly
I was suggested to skip the list entirely and use ord(line[i]) - 48 to convert characters to integers on the fly. Surely avoiding list allocation would be faster?
Result: ~4-5ms. Slower than before.
Turns out Python's list comprehensions are highly optimized C code. The overhead of repeated ord() calls and indexing was worse than just building the list once.
Failed Optimization #2: Inlining the Function
Maybe function call overhead was the issue? I inlined everything into the main loop.
Result: ~3-4ms. Also slower...?
Apparently, in Python, function-local variable lookups are actually faster than global lookups because of how the bytecode works. Huh, interesting.
What Actually Worked
Optimization 1: Character Comparison
My speedup mostly came from realizing that in Python, characters compare lexicographically: '0' < '1' < '2' < ... < '9'. So instead of converting to integers, I could compare characters directly and only convert at the very end:
num1 = line[0] # Keep as character
num2 = '0' # Keep as character
if num1 < digit: # Character comparison works!
num1 = digit
num2 = '0'
# Only convert at the end
return (ord(num1) - 48) * 10 + (ord(num2) - 48)
This eliminated ~100 int() calls per line. int() is expensive.
Result: ~1.45ms. Now we're getting somewhere.
Optimization 2: String Slice Iteration
The final trick was changing how I iterated:
# Before: range + indexing
for i in range(1, n - 1):
digit = line[i]
# After: direct slice iteration
for digit in line[1:-1]:
line[1:-1] creates a new substring. Iterating directly over it avoids:
- Creating a
rangeobject - Incrementing an integer counter each iteration
- Looking up
line[i]each iteration
Result: ~1.3ms. Beat Levi's 1.4ms.
The Winning Micro-Optimizations
The final solution also includes:
Early exit when we hit 99:
if num1 == '9' and num2 == '9':
return 99
Can't do better than 99, so stop searching.
Handle last digit separately:
The last digit can only ever be a candidate for num2, never num1 (there's nothing after it). By processing it outside the main loop, we avoid checking i != n - 1 on every single iteration:
for digit in line[1:-1]: # Process all except last
# ... main logic
digit = line[-1] # Handle last digit separately
if num2 < digit:
num2 = digit
Combined conditions:
When updating num2, there are two cases that both result in num2 = digit:
- When
num1 == digit: same digit twice is valid (like 77) - When
num2 < digit: we found a better second digit
Instead of two separate elif branches, we combine them:
elif num1 == digit or num2 < digit:
num2 = digit
One branch instead of two.
Part 2: Selecting 12 Digits
Part 2 is a different beast. Now we need to pick 12 digits from a 100-digit string to form the largest possible 12-digit number. With 200 lines of input, efficiency matters.
Instead of trying every possible combination of 12 digits (which would be insane. C(100, 12) is astronomical! (Further down, I'll explain why. For now, just trust me.)), we use a greedy approach: pick the best digit at each step, left to right.
The Greedy Insight
To maximize a number, you want the leftmost digit to be as big as possible. Then the second digit. Then the third. So we just greedily pick the max each time.
But we can't just pick from anywhere:
- If we pick too far right, we won't have enough digits left to fill k slots.
- If we pick too early, we might miss a bigger digit that could've fit.
The Algorithm
- For each of the
kdigits we need to pick, figure out the valid search window. The window is[start, end]where:start= position right after our previous pick (or 0 for the first pick)end=n - (k - i), whereiis how many digits we've already picked, andnis the total number of digits.
(k - i)digits after this pick. So the latest we can pick from is positionn - (k - i), leaving exactly enough room for the remaining digits. - Find the maximum digit in that window and lock it in. Move our start position past where we picked.
- Repeat until we have
kdigits.
Worked Example
Here's an example from the test data: "234234234234278" with k=12 (length n=15).
The string has 15 digits, but we only want 12. So we're dropping 3 digits. Which 3 do we drop to get the biggest number? Let's walk through it.
position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
digit: 2 3 4 2 3 4 2 3 4 2 3 4 2 7 8
i=0: Picking digit 1 of 12. We still need 12 digits after this.
That means we can't pick past position 15 - 12 = 3 (or we'd run out of room).
Search window [0, 3]: '2', '3', '4', '2'
Best choice: '4' at position 2
Result so far: "4"
i=1: Picking digit 2 of 12. We still need 11 more.
Start after position 2, so start = 3. Can't pick past position 15 - 11 = 4.
Search window [3, 4]: '2', '3'
Best choice: '3' at position 4
Result so far: "43"
i=2: Picking digit 3 of 12. We still need 10 more.
start = 5, can't pick past 15 - 10 = 5.
Search window [5, 5]: just '4'
No choice here, take '4'.
Result so far: "434"
From here on, the window is always size 1 (start == end), so we just take each remaining digit in order: '2', '3', '4', '2', '3', '4', '2', '7', '8'.
Final result: "434234234278"
Notice: we skipped the '2' at position 0, '3' at position 1, and '2' at position 3. Those were the "worst" digits we could afford to drop while maximizing the result.
One More Optimization
If we find a '9' in the window, we can stop searching immediately. Can't do better than 9, so why keep looking?
for j in range(start, end + 1):
if line[j] > max_char:
max_char = line[j]
max_pos = j
if max_char == '9':
break
Time Complexity
This picks k digits in O(n × k) worst case, instead of checking all C(n, k) combinations. For n=100 and k=12, that's 1,200 operations vs... a lot. Way faster.
For those who are curious, C(n, k) is the binomial coefficient, which is calculated as n! / (k! × (n - k)!).
In this case, C(100, 12) = 1,731,030,945,644,000,000.
That's a lot of operations.
Lessons Learned
Python's performance intuition is often wrong. Inlining functions made things slower. List comprehensions beat manual loops. Always benchmark.
Character comparison is free. '5' < '7' works in Python, and it's faster than int('5') < int('7'). Only convert when you actually need the numeric value.
Iterate over slices, not indices. for x in line[1:-1] is faster than for i in range(1, n-1) because you avoid range object creation and repeated indexing.
The 99 early exit matters. When you know the answer can't get better, stop computing.
Greedy works when order is constrained. When you can't rearrange, picking the best available option at each step often gives the optimal result.
From 3.2ms to 1.3ms. A 2.5x speedup from micro-optimizations alone. And yes, I did beat Levi's 1.4ms. By 0.1ms, but it counts.
On to Day 4.