Advent of Code 2025 - Python - Day 6
Today, we've fallen down a garbage chute and are trapped in a garbage compressor with no way out. Luckily, a family of cephalopods is here too, and is pretty sure they can get the door open. While we wait, we're going to help the youngest with their math homework.
Cephalopod math homework has a strange format (at least to those of us with only two arms):
123 328 51 64
45 64 387 23
6 98 215 314
* + * +
The format here is that each column is a set of values to operate on, and the operator in the bottom row is what you do to all the values. So the first column is 123 * 45 * 6, the second is 328 + 64 + 98, and so on.
The value we're trying to compute is the grand total of adding together all the individual problems.
Part 1
This seems pretty straightforward, at first glance. It seems like we should be able to read all the values into a list-of-lists, where each row is a row of text and each item in each row is a "cell". Then we would loop over a range from zero to the first row's extent, retrieve the operator from the last row, and perform the operation.
So let's get to it!
# Read data from file
rawLines = []
with open("data/6.short", "r") as f:
rawLines = [line.strip() for line in f.readlines()]
# Split lines into cells
parsedText = [line.split() for line in rawLines]
This gives us:
[['123', '328', '51', '64'], ['45', '64', '387', '23'], ['6', '98', '215', '314'], ['*', '+', '*', '+']]
Manually reformatting that printout a litte:
[['123', '328', '51', '64'],
['45', '64', '387', '23'],
['6', '98', '215', '314'],
['*', '+', '*', '+']]
Perfect! Normally we would cast these values to integers at this point, to make the computation loop a little easier, but since the last row isn't numeric I think it's easier to hold off on casts until we're at the stage where we're actually using the numbers.
One interesting thing about the <string>.split() method is that with no arguments it splits on all whitespace - combining adjacent tabs, spaces, and newlines into one consumed whitespace block - but there's no way to replicate that behavior if you pass any arguments. That is, you can automatically split on all whitespace but you can't manually do so. Weird API!
With our data in the shape we want it, processing is now as described above:
# Process
acc = 0
for i in range(len(parsed_text[0])):
# Retrieve operation
op = parsed_text[-1][i]
# Collect operands
current_operands = [int(row[i]) for row in parsed_text[:-1]]
# Perform work
if op == "*":
acc += math.prod(current_operands)
elif op == "+":
acc += sum(current_operands)
It's cool to see my code getting more subjectively Pythonic as we get deeper into this Advent of Code. Here we're collecting the current column we're working on into current_operands using a list comprehension, we're using our "-1 index trick" in a couple places, and utilizing Python builtins to automatically add or multiply together our list.
math.prod() is the multiplication equivalent of sum(). To use it, we had to import math at the top of our Python script. This gives us the correct result for the short input.
I have some experience making emulators, and this setup reminds me a lot of the way a CPU will have values in a set of registers and then use the opcode to decide what to do with those values. One way to make an emulator do that in a language like C is to use function pointers to simplify the code. Python doesn't have those, exactly, but we can pretty much get there because functions are first-class objects:
Functions are first-class objects. A “def” statement executed inside a function definition defines a local function that can be returned or passed around.
By using just the name of the function - without parentheses or passing arguments - we effectively get a function pointer. If we could get function pointers to math.prod and sum in a way that we could easily pass current_operands to it, we could remove our if statement in our processing loop. This has basically no bearing on performance, but it's a cool way to mix in a little branchless programming. Additionally, if we theoretically needed to support more operations, this would be an easy way to handle it.
Because we have op in string form and want to relate it to the function pointer, a dict with the operation and the two functions seems like it would work well:
operations = {"*": math.prod, "+": sum}
And thus our processing code becomes:
# Process
acc = 0
operations = {"*": math.prod, "+": sum}
for i in range(len(parsed_text[0])):
# Retrieve operation
op = parsed_text[-1][i]
# Collect operands
current_operands = [int(row[i]) for row in parsed_text[:-1]]
# Perform work
acc += operations[op](current_operands)
It's very cool that we can do this, and reduce our "real work" code to just three lines.
We now have our full Part 1 solution:
import math
# Read data from file
raw_lines = []
with open("data/6.short", "r") as f:
raw_lines = [line.strip() for line in f.readlines()]
# Split lines into cells
parsed_text = [line.split() for line in raw_lines]
# Process
acc = 0
operations = {"*": math.prod, "+": sum}
for i in range(len(parsed_text[0])):
# Retrieve operation
op = parsed_text[-1][i]
# Collect operands
current_operands = [int(row[i]) for row in parsed_text[:-1]]
# Perform work
acc += operations[op](current_operands)
print(acc)
This gives us the correct result of 4277556 for the short input, and 6299564383938 for my long input. Let's see what twist awaits us in...
Part 2
The adult cephalopods come to check on us and see that our sums don't match what they expected. They realize that the didn't explain how to read their numbers, which are of course written in single-digit columns with their most significant digit at the top and least significant at the bottom.
That is, the rightmost math problem isn't actually 64 + 23 + 314 like we thought, it's 4 + 431 + 623 (note that the 4 is on its own with only spaces above it, the 431 is vertically aligned, etc). The operations are still performed how we had done them - with a * in the rightmost column instead of a +, we'd instead do 4 * 431 * 623.
This makes things harder. There are probably a few ways to tackle this complication, but what comes to me first is to do some string-padding magic on our operands to normalize their lengths before taking characters from each cell one at a time.
Unfortunately, I don't think that can work. Because our initial parsing splits the lines of text on all whitespace, and there's no specific rules governing whether values in a column are right- or left-aligned (note how in the first column there's a right-aligned two-digit value and in the second column there are left-aligned two-digit value), after we compute parsed_text we don't actually have the information needed to solve the problem anymore. We need to know where each value is aligned within its column, even though when we begin parsing we don't know how many columns are present or how large they are.
For similar reasons, we also can't take fixed-length columns starting whenever we see a digit - digits are not necessarily left-aligned. In fact, manual inspection of the long input file reveals that columns are not fixed-width at all.
So we have columns of varying sizes, defined by the maximum digit count of the values inside of it. We can't use any particular row of digits as a "key" to where columns start, and we need to know the alignment of values within the columns.
There's probably a way to do this without using the bottom row - which only has operations - but we're going to take the route that I'm pretty sure will work instead of trying to do something based on digit count.
Luckily for us, the last row lists operations which are exactly aligned with the starting character of each column. Additionally, we're told by the cephalopods that each problem is separated by a full column of spaces. So, if we could retrieve the offsets of the operations in the last row, we could use that information to retrieve full cells in text form - just like I initially wanted, except we don't have to do any string-padding magic!
Finding where operations are in the last row should be as easy as looping through and checking each character. Because we want to know the character offset at which the first row starts and the length of the first row, let's store this information as a list of tuples. At list index zero, we'll store data for the leftmost column. At list index one, the next one over, and so on. Each tuple will have the format (offset, length). We'll then use this information to do some string manipulation to collect columns of single characters into integers, format those integers into a operand list like we did in Part 1, and we'll be off to the races.
First, we need to find where the operands are:
# Record operation location
offsets = []
for offset, c in enumerate(raw_lines[-1]):
if c == "*" or c == "+":
offsets.append((offset, 0))
Note the funny format of what we're appending - tuples are described by (val, val) in Python's syntax, so to pass a hardcoded tuple as the only parameter to a function we end up with the silly-looking append((val, val)) syntax.
We were going to put the lengths of each column in the second position in the tuple, so let's tackle that now. It doesn't seem easy to compute these in the same pass as finding the offsets, because the length of the last column needs special handling, but it should be straightforward now that we have the offsets recorded:
# Record column lengths
for i in range(len(offsets) - 1):
offsets[i] = (offsets[i][0], offsets[i+1][0] - offsets[i][0] - 1)
# Record last column length
offsets[-1] = (offsets[-1][0], len(raw_lines[0]) - offsets[-1][0])
This somewhat clunky formatting is necessary because Python tuples are immutable - we can't directly assign to offsets[i][1]. Lists are mutable, though, so we can fully overwrite the list entry with a tuple that has what we need.
Because the final column doesn't reference another offset value to determine its size - it has to check the overall string length - we have to handle it separately. The math is also slightly different: when computing width using the next offset, we have to subtract one to account for the single column of whitespace between each math problem. When computing the final column, though, len tells us how many total characters are in the line. These isn't an additional whitespace column after the last problem, and where offsets[i+1][0] pointed at the character after the whitespace column's character, len(raw_lines[0]) points at the final character in our column itself.
With all this, we now know the positions and sizes of each of our columns:
[(0, 3), (4, 3), (8, 3), (12, 3)]
The last piece of our puzzle is the processing loop. Let's tackle this in parts. First, we need to read the raw column text from each line:
# Compute
acc = 0
operators = raw_lines[-1].split()
operations = {"*": math.prod, "+": sum}
for idx, op in enumerate(operators):
offset = offsets[idx][0]
width = offsets[idx][1]
column_text = [line[offset:offset+width] for line in raw_lines[:-1]]
This doesn't quite work; printing out column_text gives us:
['123', '45 ', '6 9']
['328', '4 ', ' 2']
[' 51', '87 ', '5 3']
['64', '3', '4']
Looking at the first row, we can see that 123 is correct, 45 seems to be off by one character too far to the right (the 45 should be right-aligned, not left-aligned), and 6 9 is consuming both the whitespace column and the first digit of the next problem. It's almost as if we're accidentally stripping out whitespace on the ends of the line...
with open("data/6.short", "r") as f:
raw_lines = [line.strip() for line in f.readlines()]
Gotcha. By using strip() to remove starting and ending whitespace, mostly just to get rid of the newline character and because it's fairly standard practice as the whitespace usually isn't significant, we've made our input unusable the very first time we interact with it. Since all we actually want to do is remove the newline characters, we can modify our comprehension:
with open("data/6.short", "r") as f:
raw_lines = [line.replace("\n", "") for line in f.readlines()]
This removes only the newlines (replacing them with nothing amounts to removing them). Now, if we print out column_text from our processing loop:
['123', ' 45', ' 6']
['328', '64 ', '98 ']
[' 51', '387', '215']
['64 ', '23 ', '314']
Perfect! We can now add the next part of our computation loop. Let's try collecting the columns of each problem into actual integers we can do math on:
# Compute
acc = 0
operators = raw_lines[-1].split()
operations = {"*": math.prod, "+": sum}
for idx, op in enumerate(operators):
offset = offsets[idx][0]
width = offsets[idx][1]
column_text = [line[offset:offset+width] for line in raw_lines[:-1]]
values = []
for i in range(width):
this_value = [cell[i] for cell in column_text]
values.append(int("".join(this_value)))
Looping from 0 to the width of our column, we grab the ith character from each cell in the column. This gives us a list of single-character strings, which we collapse into a multi-character string with "".join() - which I think is sort of a clunky way to do it, but that's Python for you. We then cast the collected string to an integer - which implicitly discards leading and trailing space characters, as if we had called strip() on the string before casting - and we're left with the value as read top-to-bottom:
[1, 24, 356]
[369, 248, 8]
[32, 581, 175]
[623, 431, 4]
At this point, we're basically done. All this work was to get back to the point where our data looks more or less like it did in Part 1 - we have a list of values, an operand, and a dictionary with function "pointers". In fact, we only need one more line of code and it can be copied directly from our Part 1 solution:
acc += operations[op](values)
This gives us the full Part 2 solution:
import math
# Read data from file
raw_lines = []
with open("data/6.long", "r") as f:
raw_lines = [line.replace("\n", "") for line in f.readlines()]
# Record operation location
offsets = []
for offset, c in enumerate(raw_lines[-1]):
if c == "*" or c == "+":
offsets.append((offset, 0))
# Record column lengths
for i in range(len(offsets) - 1):
offsets[i] = (offsets[i][0], offsets[i+1][0] - offsets[i][0] - 1)
# Record last column length
offsets[-1] = (offsets[-1][0], len(raw_lines[0]) - offsets[-1][0])
# Compute
acc = 0
operators = raw_lines[-1].split()
operations = {"*": math.prod, "+": sum}
for idx, op in enumerate(operators):
offset = offsets[idx][0]
width = offsets[idx][1]
column_text = [line[offset:offset+width] for line in raw_lines[:-1]]
values = []
for i in range(width):
this_value = [cell[i] for cell in column_text]
values.append(int("".join(this_value)))
acc += operations[op](values)
print(acc)
With this, we get the correct short-input answer of 3263827 and the correct long-input answer, for me, of 11950004808442. The script runs in 0.019 seconds.
Improvements
This is all well and good, but we can probably make this more Pythonic. Let's start with the basics; I've used the "define an empty list, open the file in a with context, read into that list with a comprehension" pattern hundreds of times. But there is another way:
from pathlib import Path
raw_lines = Path("data/6.short").read_text().splitlines()
This accomplishes the exact same thing - Path() grabs a handle to the file, read_text() retrieves the contents as a giant string, and splitlines() is equivalent to split(\n) except it can handle \r\n as well, or whatever the Windows line ending is. Very neat - I'll definitely be using this in the future. It's more compact, doesn't create unnecessary scope, and I never liked the pattern of needing to initialize a blank list before filling it with something inside a scope.
Next, let's tackle recording the location of the operators. I'm currently doing this by initializing an empty offsets list, looping through every character in the last line, and appending a tuple with the offset if the character is a * or +. Because this is a single, non-nested loop, we should be able to replace this with a non-nested list comprehension:
operator_line = raw_lines[-1]
positions = [i for i, c in enumerate(operator_line) if c in "*+"]
This is some ugly syntax that I'm not really a fan of - c is coming from the enumerate(), but is used both on the right and left side of the enumerate() call. I think this is just the way Python ternary statements are formatted, but that doesn't make it any prettier. Cool that I can do it in one line, though.
Similarly, we can use a single comprehension to compute the column widths:
widths = [b - a - 1 for a, b in zip(positions, positions[1:])]
widths.append(len(operator_line) - positions[-1])
The code here is much clearer because I dropped the pretense of using a tuple. If I can't compute both values at the same time, perhaps a tuple isn't the right datatype.
Note that the code is still basically structured the same as it was before - we compute the position of the operators in a "for loop" (condensed to a comprehension), then we compute the column widths for all but the final column in a "for loop" (condensed to a comprehension), then we compute the final column's width using a slightly different formula than for the other columns. This is functionally the same as my original code, just condensed and using more builtins.
I haven't mentioned the zip function yet in this series. It packages up disparate iterable objects into a single one which can then be unpacked for looping over. In this case, it's taking the list of positions, and the list of positions without the first item and packing them into, basically, a series of tuples. a is then taken from the original position list, and b is taken from the shortened one. This is very similar to what enumerate does - enumerate takes one iterable and gives you its index and corresponding value, while zip takes two iterables and gives you corresponding values from each. It's a very handy way to iterate through multiple things at once without needing to use a counter variable.
I'm sure there's more to improve - the main computation loop is looking a little imperative - but I'll stop here. Even though I think the "initialize then enter a smaller scope" pattern is ugly, I understand it and it is ultimately benign.
The final version of the code is:
import math
from pathlib import Path
# Read data from file
raw_lines = Path("data/6.long").read_text().splitlines()
operator_line = raw_lines[-1]
# Locate operators and compute column widths
positions = [i for i, c in enumerate(operator_line) if c in "*+"]
widths = [b - a - 1 for a, b in zip(positions, positions[1:])]
widths.append(len(operator_line) - positions[-1])
operators = operator_line.split()
operations = {"*": math.prod, "+": sum}
# Compute
acc = 0
for idx, op in enumerate(operators):
offset = positions[idx]
width = widths[idx]
column_text = [line[offset:offset+width] for line in raw_lines[:-1]]
values = []
for i in range(width):
this_value = [cell[i] for cell in column_text]
values.append(int("".join(this_value)))
acc += operations[op](values)
print(acc)
This version still gets the right answers, and runs in 0.021 seconds - close enough that you wouldn't want to draw any conclusions on performance.
On to Day 7!