Day five of AoC 2025 (available at https://adventofcode.com/2025/day/5) takes us to a cafeteria where we need to check ingredient freshness. No idea what dish they're preparing, but we're here to help.
The input has two sections: ranges defining "fresh" ingredient IDs, and a list of ingredient IDs to check.
Parsing the Input
The input format is slightly awkward — ranges first, blank line, then ingredients:
type Range = [number, number];
type Input = {
ranges: Range[];
ingredients: number[];
};
const processInput = (day: number): Input => {
const lines = readInputLines(day);
const ranges: Range[] = [];
const ingredients: number[] = [];
let line = lines.shift();
while (line !== "") {
const match = line!.match(/(\d+)-(\d+)/);
if (match) {
ranges.push([parseInt(match[1], 10), parseInt(match[2], 10)]);
}
line = lines.shift();
}
ingredients.push(...lines.map(l => parseInt(l, 10)));
return { ranges, ingredients };
};
A simple loop-and-shift approach. We consume lines until we hit the empty separator, parsing ranges as we go. Everything after becomes ingredients.
One gotcha: parseInt as a direct callback to map doesn't work as expected because map passes three arguments (value, index, array) and parseInt interprets the second as the radix. Always wrap it: line => parseInt(line, 10).
Part One: Fresh or Spoiled?
For part one, we check each ingredient against the ranges. If it falls within any range, it's fresh.
const isInRange = (ingredient: number, [min, max]: Range): boolean => {
return ingredient >= min && ingredient <= max;
};
const partOne = (input: Input, debug: boolean) => {
const { ranges, ingredients } = input;
let freshCount = 0;
for (const ingredient of ingredients) {
const isFresh = ranges.some(range => isInRange(ingredient, range));
if (isFresh) {
freshCount++;
}
}
return freshCount;
};
Using some() here is important — it short-circuits on the first match. If an ingredient matches the first range out of 10,000, we don't waste time checking the other 9,999.
Part Two: Counting All Fresh IDs
Part two changes the question: how many total ingredient IDs are considered fresh? We need to count all integers covered by at least one range.
This is the classic interval merging problem. It appears everywhere — scheduling, time zones, resource allocation. It's conceptually simple but notoriously easy to get wrong with off-by-one errors.
The algorithm:
- Sort ranges by start value
- For each range, check if any previous range extends past our start
- If so, adjust our start to avoid double-counting
- Sum up the lengths
const partTwo = (input: Input, debug: boolean) => {
const { ranges } = input;
const sortedRanges = ranges
.map(range => range.slice() as Range)
.toSorted((a, b) => a[0] - b[0]);
let totalCount = 0;
for (let index = 0; index < sortedRanges.length; index++) {
const currentRange = sortedRanges[index];
// Find the maximum end value of all previous ranges
const previousMaximums = sortedRanges
.slice(0, index)
.map(([_, max]) => max);
const previousMax = Math.max(-Infinity, ...previousMaximums);
// If previous ranges overlap, adjust our start
if (previousMax >= currentRange[0]) {
currentRange[0] = previousMax + 1;
}
// Only count if we have a valid range left
if (currentRange[0] <= currentRange[1]) {
totalCount += currentRange[1] - currentRange[0] + 1;
}
}
return totalCount;
};
The key insight: by sorting first, we guarantee all previous ranges started before (or at) our start. We only need to check if any of them end after our start — that's the overlap.
When we find an overlap, we adjust our start to previousMax + 1. If that pushes our start past our end, the range is fully subsumed and contributes zero.
Why This Works
Consider ranges [3,5], [10,14], [12,18], [16,20]:
[3,5]: No previous ranges. Count = 3 (IDs 3,4,5)[10,14]: Previous max is 5 < 10. No overlap. Count = 5[12,18]: Previous max is 14 ≥ 12. Adjust to[15,18]. Count = 4[16,20]: Previous max is 18 ≥ 16. Adjust to[19,20]. Count = 2
Total: 14 fresh ingredient IDs.
Performance
Part one is O(n×m) where n is ingredients and m is ranges. Part two is O(m² log m) due to the nested slice operations inside the loop. For the puzzle input sizes, both run in milliseconds.
A more efficient part two would track previousMax as a running maximum rather than recalculating from scratch each iteration. But premature optimization, etc.
Takeaways
Interval merging is everywhere: This pattern shows up constantly in real-world scheduling problems. Worth having a solid implementation in your toolkit.
Off-by-one errors are the enemy: The
+1inpreviousMax + 1and the inclusive range counting (max - min + 1) are easy to get wrong. Work through examples on paper first.some()short-circuits: When checking if any condition matches,some()is both cleaner and more efficient than a manual loop with early return.toSorted()preserves the original: Unlikesort(),toSorted()returns a new array. Essential when you need the original order elsewhere (or just want to avoid mutation surprises).
The full solution is available in the repository.