Spoiler alert
This is a full solution for Advent of Code 2025, day 5 puzzles with explanations.
If you want to try to solve it yourself first, head over to https://adventofcode.com/2025/day/5 and give it a go and then come back and compare notes.
You can find the full code at https://github.com/Hamatti/adventofcode-2025/blob/main/src/day_5.py
Tech can be complicated, we all know that. And the Elves know that too because they got lost with their inventory management system and can’t figure out which produce is fresh and which is spoiled. Let’s help them out, shall we.
Read input
Today is our first multisection input where different parts of the input need to be parsed differently. Since it’s not the first time we’re doing this, I have a helper function for that specifically.
def read_multisection_input[T](
day: int, map_fns: List[Callable[[str], T]] = None, example: bool = False
) -> List[T]:Like regular read_input, it takes the day and whether we want to use example data or not but instead of a single map function, it takes a list of them and applies one to each section.
The upper section is a list of ranges:
3-5
10-14
16-20
12-18
So we create a Range namedtuple, split the section by lines and split each line by - and turn the individual values into integers.
from collections import namedtuple
Range = namedtuple('Range', ['start', 'end'])
def map_upper_section(section: str) -> List[Range]:
"""Takes a section of ranges and turns them into
Range tuples"""
lines = section.split('\n')
ranges = []
for line in lines:
start, end = [int(num) for num in line.split('-')]
ranges.append(Range(start, end))
return rangesThe lower half is lines of numbers
1
5
8
11
17
32
so we split to lines and cast each to integer
def map_lower_section(section: str) -> List[int]:
"""Splits input with new lines and turns every
line into an integer"""
return [int(num) for num in section.split('\n')]Part 1
In the first part, we need to check which ingredients (the lower half of the input) are fresh: meaning they are included in any of the ranges in the top half.
First, a naive solution where we loop each of the ranges until we find one where the id goes into:
def is_fresh(ingredient: int, ranges: List[Range]) -> bool:
"""Checks if ingredient is in any of the provided ranges"""
for range in ranges:
if range.start <= ingredient <= range.end:
return True
return FalseUnline many other languages, in Python we can use 0 < x < 5 style comparisons! In other languages, this would turn into (0 < x) < 5 → True < 5 and not work properly but in Python it does. That’s one more reason why Python is lovely.
We then go through all ingredients and count how many are fresh:
def part_1() -> int:
"""Calculate how many ingredients in the second half are
included in any of the ranges from the first half"""
ranges, ingredients = read_multisection_input(5, [map_upper_section, map_lower_section])
fresh_count = 0
for ingredient in ingredients:
if is_fresh(ingredient, ranges):
fresh_count += 1
return fresh_countI was fully expecting this to be correct but slow but it was pretty much instantaneous so I didn’t bother optimising it.
Part 2
In the second part, we don’t care about individual ingredients anymore. Now we just want to know how many ingredient ids there are in those ranges total.
Here’s where a brute-force “let’s loop all of them and count” doesn’t work anymore because the ranges get so big. Instead, we need to use math. In my case, I chose to combine all the overlapping ranges and then calculate the length of each range and sum them up.
def try_to_combine(first: Range, second: Range) -> Range|Tuple[Range, Range]:
"""Tries to combine two ranges. If ranges overlap, returns a single Range.
If they don't overlap, return both Ranges."""
if first.end >= second.start:
end = first.end if first.end > second.end else second.end
return Range(start=first.start, end=end)
else:
return (first, second)This function takes two ranges and returns either a new range (if they overlapped) or both of them back as a tuple. If they overlap, I take the start of the first one and the larger of the ends (I had a bug here initially where I forgot the corner case of the second range being completely included in first as I used the second.end as the end of the new range).
def part_2() -> int:
"""Calculate how many ids are included in all the ranges"""
ranges, _ = read_multisection_input(5, [map_upper_section, lambda x: x])
# Sort ranges by start value so they can be combined
ranges = sorted(ranges, key=lambda x: x.start)
index = 0
while True:
try:
combo = try_to_combine(ranges[index], ranges[index+1])
if isinstance(combo, Range):
ranges[index] = combo
ranges.remove(ranges[index+1])
else:
index += 1
except IndexError:
break
return sum(end - start + 1 for start, end in ranges)Since we’re only interested in the first half of the puzzle input, I’m using two Python constructs to kind of ignore the second.
First, I’m giving the lower half a variable name of _. In Python, that’s a convention for telling the reader “we don’t care about this bit”. To the computer, it’s all the same but for the human reader it’s a valuable indication.
Second, I’m using lambda x: x as the map function for the lower half. This is how we do anonymous functions in Python. It’s the same as:
def func(x):
return xIt doesn’t do anything to the parameter, just returns it.
Once we get our ranges, we sort them. I’m using another anonymous function here to tell sorted function I want to sort by whatever this function returns, in this case the start of the Range.
I then loop, going through two ranges and combining them if I can. If I can, I put the new one back in place and remove the latter one used in the combining. If not, I move on.
I’m using a try/except structure here to figure out when to stop the loop. I try to access ranges[index+1] but if index is already at the last position, it can’t access it, raising an IndexError which tells the algorithm we’re done.
Finally, I calculate the length of each range as end - start + 1 (the +1 is important here because our range is inclusive, ie. the last id is counted too) and sum them up for the final result and two ⭐️s for the day.
Addendum A: A much cleaner combo
After submitting this, I realized I made the function too complicated. I didn’t actually use the Tuple[Range, Range] return value in any way so I can replace that with None if they weren’t merged:
def try_to_combine(first: Range, second: Range) -> Range | None:
"""Tries to combine two ranges. If ranges overlap, returns a single Range.
If they don't overlap, return both Ranges."""
if first.end >= second.start:
end = first.end if first.end > second.end else second.end
return Range(start=first.start, end=end)
return Nonethis then leads to cleaner code in the caller side:
if combo := try_to_combine(ranges[index], ranges[index+1]):
ranges[index] = combo
ranges.remove(ranges[index+1])
else:
index += 1Here, I’m using a walrus operator (:=) which can be used in if / while clauses to simultaneously capture and check the value. First we make the function call to try_to_combine and store the result in combo. We then check if that combo is truthy (in this case, not None) or not.
Get in touch
If you want to comment on these solutions, I share them in Mastodon where you can look for the post for the right date or you can start a discussion over email with juhamattisantala@gmail.com.
Follow via RSS
I have created a custom RSS feed for these solutions so you can follow along and not miss any of them!