Day three of AoC 2025 (available at https://adventofcode.com/2025/day/3) has us fixing an escalator by selecting batteries from banks to maximize joltage. Each line of input represents a bank of single-digit batteries, and we need to select digits to form the largest possible number.
Parsing the Input
Each line is a sequence of digits representing a battery bank:
type Bank = number[];
const processInput = (day: number): Bank[] => {
const lines = readInputLines(day);
const banks = lines.map(line =>
line.split("").map(c => Number(c))
);
return banks;
};
We split each line into individual characters and convert to numbers. Using a Bank type alias makes the code more semantic than number[][].
Part One: Two Batteries
For part one, we need to select exactly two batteries from each bank to form a two-digit number, maximizing the result.
The greedy insight: pick the largest digit that's not in the last position (so we have room for a second digit), then pick the largest digit after it.
Why greedy works: 91 is always larger than 89, regardless of what other digits are available. The first digit dominates.
const getBankValue = (bank: Bank): number => {
let first = 0;
let firstIndex = -1;
// Find biggest digit not in last place
for (let index = 0; index < bank.length - 1; index++) {
if (bank[index] > first) {
first = bank[index];
firstIndex = index;
}
}
// Find biggest digit after the first
let second = 0;
for (let index = firstIndex + 1; index < bank.length; index++) {
if (bank[index] > second) {
second = bank[index];
}
}
return 10 * first + second;
};
One subtlety: we use strictly greater than (>) when searching. If there are multiple 9s, we want the first one — that leaves more options for the second digit.
Part Two: Twelve Batteries
Part two scales up: now we need 12 digits to form a 12-digit number.
The algorithm is the same, just repeated. The first digit must leave room for 11 more, the second must leave room for 10 more, and so on.
The Ugly First Attempt
My first implementation was... not pretty:
const [first, i1] = getNextBiggest(bank, 0, 11);
const [second, i2] = getNextBiggest(bank, i1 + 1, 10);
const [third, i3] = getNextBiggest(bank, i2 + 1, 9);
const [fourth, i4] = getNextBiggest(bank, i3 + 1, 8);
// ... eight more lines ...
const [twelfth, i12] = getNextBiggest(bank, i11 + 1, 0);
This works. It's correct. Copilot happily generated all 12 lines. But it's ugly and doesn't scale — what if we needed 20 digits?
The Refactored Version
After getting the star, I went back and fixed it:
const getNextBiggest = (bank: Bank, startIndex: number, endOffset: number): [number, number] => {
let biggest = 0;
let biggestIndex = startIndex;
for (let index = startIndex; index < bank.length - endOffset; index++) {
if (bank[index] > biggest) {
biggest = bank[index];
biggestIndex = index;
}
}
return [biggest, biggestIndex];
};
const getBankValueTwo = (bank: Bank): number => {
let result = 0;
let startIndex = 0;
for (let remaining = 11; remaining >= 0; remaining--) {
const [biggest, index] = getNextBiggest(bank, startIndex, remaining);
result = result * 10 + biggest;
startIndex = index + 1;
}
return result;
};
The endOffset parameter controls how many positions we need to reserve for remaining digits. Starting at 11 (need room for 11 more), decreasing to 0 (last digit, can pick from anywhere remaining).
Performance
Both parts run in 1-2 milliseconds. The greedy approach is O(n) per bank, and we have a fixed number of banks.
Takeaways
Greedy works when the first choice dominates: In positional number systems, earlier digits matter exponentially more than later ones. This makes greedy selection optimal.
Don't leave ugly code behind: The 12-line copy-paste version worked, but it was embarrassing. Taking five minutes to refactor it into a loop made the code maintainable and scalable.
Get it working, then make it right: There's value in getting a working solution first, especially in a timed context like AoC. But don't commit the ugly version — refactor before you move on.
Helper functions with clear contracts:
getNextBiggest(bank, startIndex, endOffset)encapsulates the search logic cleanly. The caller just needs to track where to start and how many positions to reserve.
The full solution is available in the repository.