Henry Heim

Advent of Code 2025 - Python - Day 3

Today, we're trying to use some North Pole escalators. Unfortunately, their power is out. They have banks of batteries, which look like so:

987654321111111
811111111111119
234234234234278
818181911112111

Part 1

In the first part of this puzzle, we need to scan through the banks of batteries (the rows of digits) and determine what combination of digits, when read left to right, makes the largest number. That is, for the first bank in the example above we'd have 89, the first two digits. For the second bank we'd have 89, the first and last digits. For the third bank, 78 (the last two digits), and for the fourth 92.

To me this seems like a problem adjacent to search - we don't necessarily want to sort the digits in a bank from highest to lowest, but it's something close.

At first glance, it looks like we want the highest digit in the first n-1 digits of the bank, and then the highest digit to the right of that one (which, if we chose the second-to-last digit as our first, would be whatever is left). This is because no matter what our second digit is, having a higher digit in the tens place always leads to a larger value than having a lower one.

With that in mind, our solution seems straightforward. I'll handle text parsing the same way I did in previous days, convert the digits to integers for easier comparison, put each bank in its own list, and search through those lists as described in the previous paragraph. Our goal is to add up the values ("joltage") we get from each bank; for the sample input shown above, we should get 357:

# Read data
rawLines = []
with open("data/3.short", "r") as f:
    rawLines = f.readlines()

# Parse into a list of lists, where the inner lists have each joltage
# separately in integer form
parsedLines = []
for line in rawLines:
    parsedLines.append([int(c) for c in line.strip()])

# Find the highest joltage combination in each bank
joltageSum = 0
for bank in parsedLines:
    # Find the highest joltage in the bank, excluding the last value
    firstDigit = 0
    firstDigitIndex = 0
    for index, joltage in enumerate(bank[:-1]):
        if joltage > firstDigit:
            firstDigit = joltage
            firstDigitIndex = index

    # Find the highest joltage to the right of the one we just found
    secondDigit = 0
    for joltage in bank[firstDigitIndex+1:]:
        if joltage > secondDigit:
            secondDigit = joltage

    # Add to accumulator variable
    thisJoltage = firstDigit * 10 + secondDigit
    joltageSum += thisJoltage

print(joltageSum)

And, indeed, we get 357! For the longer input (which is what the site is actually testing me on, and which I won't share because it's just a big text file of values) I get 17087 which is also correct.

There are three features of my solution I'd like to discuss in more detail. Firstly, the enumerate() function. This is a very helpful Python construct which lets you track an index along with your for loops - it's basically equivalent to keeping a separate tracker that you increment every time you go through the loop, but done automatically. It returns a tuple, which we decode with the index, joltage syntax (i.e., the tuple is (index, joltage) and we unpack it into those two variables).

Second, I use the colon : a couple times when addressing list indices, and a -1. Python has some syntactic sugar here - if the colon is prefixing an index or variable, it means "all the values up until this index, not inclusive", and if it's postfixing an index or variable, it means "all the values after this index". The -1 is using Python's ability to index from the back of the list, where -1 is the index of the last value, -2 is the index of the second-to-last value, and so on. So bank[:-1] means "the slice of the list from the start up until the last element, not inclusive". That accomplishes exactly what we're trying to - search through the list, but excluding the final value.

Third, the parsing is a comprehension inside a for loop. I could have used nested comprehensions, but I have trouble parsing those mentally. To me, this is the clearest way to handle the situation. The comprehension (the bit inside the square brackets []) takes every element c from line.strip() (.strip() removes the newline \n at the end of each line), runs int on that element, then collects them all into a list. That list is then appended to the end of parsedLines, then we move on to the next line in rawLines. The end result is that we have the list of lists we wanted.

Python list comprehensions are pretty much a 1:1 replacement for simple "perform an operation on each element of a list and put the result in a new list" loops. Functional programmers would call this a "map", probably, but I've never touched Haskell so I wouldn't know. Because list comprehensions can replace those sorts of loops 1:1, we could also have written our code with a nested list. It could also, as mentioned, have been a nested list comprehension. I like list comprehensions a lot - they save a lot of boilerplate and I just think they're a clever piece of syntactic sugar.

I'll also note that, while the above script is my genuine first attempt, it could have been done much better:

# Find the highest joltage combination in each bank
joltageSum = 0
for bank in parsedLines:
    # Find the highest joltage in the bank, excluding the last value
    firstDigit = max(bank[:-1])
    firstDigitIndex = bank.index(firstDigit)

    # Find the highest joltage to the right of the one we just found
    secondDigit = max(bank[firstDigitIndex+1:])

    # Add to accumulator variable
    joltageSum += firstDigit * 10 + secondDigit

This solution leverages the max and index built-in functions to basically do what I did manually with for loops before. This is definitely more "Pythonic" - you can tell that I'm most at home in a no-batteries-included language like C where the idea of a language having builtins that can do such powerful things like "find the largest value in a list of values" is unfathomable. But, when in Rome, so I should get used to using Python builtins if I want to write good Python.

My original solution takes 0.014 seconds to run, and if I replace my computation loop with this more Pythonic one it takes 0.019 seconds to run. With values this low and this close, there's no reason to prefer one over the other (if I time them multiple times, I get like 50% variance). Depending on your persuasion, the fact that the two solutions run the same shows:

Part 2

In the second part, we learn that two batteries in each bank isn't enough. Instead, we now need to turn on twelve batteries from each bank.

This seems like a fairly simple evolution of our code - before, we were searching for two batteries in each bank, so the first battery needed to be chosen from the first n-1 options. If we need twelve batteries in each bank, the first battery needs to be chosen from the first n-11 options, the second battery between the first and the n-10th option, the third between the second and the n-9th option, and so on.

And so, the only difficult thing to do here is to make our processing loop a little more generic. First, let's get it working with n=2 batteries and check that we get the same result as before:

# Find the highest joltage combination in each bank
joltageSum = 0
batteries = 2
for bank in parsedLines:
    thisJoltage = 0
    startingIndex = 0
    # Iteratively find each battery we need
    for battery in range(batteries):
        batteriesIgnored = batteries - battery - 1
        searchEndIndex = len(bank) - batteriesIgnored
		
        thisDigit = max(bank[startingIndex:searchEndIndex])
        startingIndex = bank[startingIndex:searchEndIndex].index(thisDigit) + 1
		
        thisJoltage *= 10
        thisJoltage += thisDigit

    # Add to accumulator variable
    joltageSum += thisJoltage

Because we'll have to support finding up to 12 batteries, it seems best to me to make the number of batteries an "input" to our loop. We'll start with 2 and make sure we get the same result we did before, before moving on to 12 which will presumably have more edge cases.

Here, we first establish a tracker for the joltage we've accumulated in our bank so far. We also track what index we're starting each search at - we can't search for the 12th battery in the first couple indices, for example, so we need to know where our search can start.

Inside the loop, we first compute how many batteries at the end of our list we want to ignore. This is a function of how many we need to select from our list (batteries) and which battery we're current choosing (battery). The minus one is because Python's range function starts at zero and doesn't include the actual value you provide in its argument - so range(2) returns [0, 1]. As such we're subtracting from batteries a value that's one less than the "which battery are we choosing now". To make up for that, we subtract one.

We can then compute what index our search will end at by taking the length of the list we're searching through (len(bank)) and subtracting off the number of batteries we're ignoring. That is, if we have 20 batteries in our bank and we're ignoring 8 of them, the last index in our search will be 11 (which is the eight we're ignoring minus one to adjust for the zero-based indices).

Next, we can find the largest digit in the range between our computed start and end index. We initialize starting index to zero and will compute a new one as soon as we find the largest digit. To compute that new one, we have to find the index of the value we just found, just like before. To advance our index tracker, and avoid just finding the same value over and over again, we add one.

Lastly, we shift thisJoltage over one decimal place (by multiplying by 10), and add the value of our digit. This could be done in one line, but to me this two-step process makes the most sense.

This solution correctly gives us 357 for the case where we need two batteries. Unfortunately it doesn't work for the 12-battery case. What's going wrong?

Let's look more closely at how we're computing startingIndex:

startingIndex = bank[startingIndex:searchEndIndex].index(thisDigit) + 1

This works for the two-battery case but doesn't work for larger ones. Why?

The answer lies in what we're actually searching through to try and find thisDigit's index. In the two-battery case, we only do the search once - or, rather, we only care about the result the first time we run the computation, when startingIndex is zero. After we find the second digit we try to compute its index in a case where startingIndex is nonzero, but we don't actually care where the second digit is because it's the last digit we need to find.

On subsequent executions, startingIndex isn't zero. Which means we're not taking the index of thisDigit starting at the start of the list, we're taking the index of thisDigit starting at whatever startingIndex is! When Python performs operations like .index() on a list, it's (in effect) internally translating something like bank[startingIndex:searchEndIndex].index(... to something like [1, 2, 3, 4, 5].index(.... We've taken a slice of the list and have lost the context of where that slice came from. We need to manually take that into account, since we want startingIndex to be the offset of where our search should start relative to the start of the list, not relative to the start of whatever sub-list we were just looking at.

We can resolve our issue by computing startingIndex correctly:

startingIndex = bank[startingIndex:searchEndIndex].index(thisDigit) \
	+ startingIndex + 1

(\ is the line continuation character in Python - my linter was complaining that this line of code was too long)

Now that we properly handle setting the start point of searches after the second one, we get the correct result of 3121910778619 for the 12-battery case given the sample data listed at the top of this post. For my larger input, I get 169019504359949, which is correct. For the larger case, my solution takes 0.021 seconds - so close to the Part 1 solution that it's not worth commenting on.

My full code is listed below:

# Read data
rawLines = []
with open("data/3.long", "r") as f:
    rawLines = f.readlines()

# Parse into a list of lists, where the inner lists have each joltage
# separately in integer form
parsedLines = []
for line in rawLines:
    parsedLines.append([int(c) for c in line.strip()])

# Find the highest joltage combination in each bank
joltageSum = 0
batteries = 12
for bank in parsedLines:
    thisJoltage = 0
    startingIndex = 0
    # Iteratively find each battery we need
    for battery in range(batteries):
        batteriesIgnored = batteries - battery - 1
        searchEndIndex = len(bank) - batteriesIgnored

        thisDigit = max(bank[startingIndex:searchEndIndex])
        startingIndex = bank[startingIndex:searchEndIndex].index(thisDigit) \
            + startingIndex + 1

        thisJoltage *= 10
        thisJoltage += thisDigit

    # Add to accumulator variable
    joltageSum += thisJoltage

print(joltageSum)

This puzzle was more interesting than I thought it would be. I found the solutions straightforward conceptually, but making the processing generic over an arbitrary number of batteries was an interesting twist. I could have just expanded the two-battery solution to be an explicit 12-battery solution, hardcoding every calculation and "unrolling" the inner for loop my generic solution has, but my way was much more interesting, I think.

Onward to Day 4!