Day 5/2025 - Cafeteria Ingredients

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:

  1. Sort ranges by start value
  2. For each range, check if any previous range extends past our start
  3. If so, adjust our start to avoid double-counting
  4. 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

  1. Interval merging is everywhere: This pattern shows up constantly in real-world scheduling problems. Worth having a solid implementation in your toolkit.

  2. Off-by-one errors are the enemy: The +1 in previousMax + 1 and the inclusive range counting (max - min + 1) are easy to get wrong. Work through examples on paper first.

  3. some() short-circuits: When checking if any condition matches, some() is both cleaner and more efficient than a manual loop with early return.

  4. toSorted() preserves the original: Unlike sort(), 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.