Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Advent of Code - 2023

This is a solution to Day 12 of Advent of Code 2023.

Day 12: Hot Springs

You finally reach the hot springs! You can see steam rising from secluded areas attached to the primary, ornate building.

As you turn to enter, the researcher stops you. "Wait - I thought you were looking for the hot springs, weren't you?" You indicate that this definitely looks like hot springs to you.

"Oh, sorry, common mistake! This is actually the onsen! The hot springs are next door."

You look in the direction the researcher is pointing and suddenly notice the massive metal helixes towering overhead. "This way!"

It only takes you a few more steps to reach the main gate of the massive fenced-off area containing the springs. You go through the gate and into a small administrative building.

"Hello! What brings you to the hot springs today? Sorry they're not very hot right now; we're having a lava shortage at the moment." You ask about the missing machine parts for Desert Island.

"Oh, all of Gear Island is currently offline! Nothing is being manufactured at the moment, not until we get more lava to heat our forges. And our springs. The springs aren't very springy unless they're hot!"

"Say, could you go up and see why the lava stopped flowing? The springs are too cold for normal operation, but we should be able to find one springy enough to launch you up there!"

There's just one problem - many of the springs have fallen into disrepair, so they're not actually sure which springs would even be safe to use! Worse yet, their condition records of which springs are damaged (your puzzle input) are also damaged! You'll need to help them repair the damaged records.

In the giant field just outside, the springs are arranged into rows. For each row, the condition records show every spring and whether it is operational (.) or damaged (#). This is the part of the condition records that is itself damaged; for some springs, it is simply unknown (?) whether the spring is operational or damaged.

However, the engineer that produced the condition records also duplicated some of this information in a different format! After the list of springs for a given row, the size of each contiguous group of damaged springs is listed in the order those groups appear in the row. This list always accounts for every damaged spring, and each number is the entire size of its contiguous group (that is, groups are always separated by at least one operational spring: #### would always be 4, never 2,2).

So, condition records with no unknown spring conditions might look like this:

#.#.### 1,1,3
.#...#....###. 1,1,3
.#.###.#.###### 1,3,1,6
####.#...#... 4,1,1
#....######..#####. 1,6,5
.###.##....# 3,2,1

However, the condition records are partially damaged; some of the springs' conditions are actually unknown (?). For example:

???.### 1,1,3
.??..??...?##. 1,1,3
?#?#?#?#?#?#?#? 1,3,1,6
????.#...#... 4,1,1
????.######..#####. 1,6,5
?###???????? 3,2,1

Equipped with this information, it is your job to figure out how many different arrangements of operational and broken springs fit the given criteria in each row.

In the first line (???.### 1,1,3), there is exactly one way separate groups of one, one, and three broken springs (in that order) can appear in that row: the first three unknown springs must be broken, then operational, then broken (#.#), making the whole row #.#.###.

The second line is more interesting: .??..??...?##. 1,1,3 could be a total of four different arrangements. The last ? must always be broken (to satisfy the final contiguous group of three broken springs), and each ?? must hide exactly one of the two broken springs. (Neither ?? could be both broken springs or they would form a single contiguous group of two; if that were true, the numbers afterward would have been 2,3 instead.) Since each ?? can either be #. or .#, there are four possible arrangements of springs.

The last line is actually consistent with ten different arrangements! Because the first number is 3, the first and second ? must both be . (if either were #, the first number would have to be 4 or higher). However, the remaining run of unknown spring conditions have many different ways they could hold groups of two and one broken springs:

?###???????? 3,2,1
.###.##.#...
.###.##..#..
.###.##...#.
.###.##....#
.###..##.#..
.###..##..#.
.###..##...#
.###...##.#.
.###...##..#
.###....##.#

In this example, the number of possible arrangements for each row is:

  • ???.### 1,1,3 - 1 arrangement
  • .??..??...?##. 1,1,3 - 4 arrangements
  • ?#?#?#?#?#?#?#? 1,3,1,6 - 1 arrangement
  • ????.#...#... 4,1,1 - 1 arrangement
  • ????.######..#####. 1,6,5 - 4 arrangements
  • ?###???????? 3,2,1 - 10 arrangements

Adding all of the possible arrangement counts together produces a total of 21 arrangements.

For each row, count all of the different arrangements of operational and broken springs that meet the given criteria. What is the sum of those counts?

Nonograms! Well, kind of almost. I love solving nonogram puzzles but I have no clue how to solve them programmatically. Let's see how today's puzzle goes.

Read input

The input is two space separated sections: row of springs and notes of what kind of groups of damaged springs are included in each row. The conversion of notes to a tuple is required for caching in part 2.

from utils import read_input


def transformer(line):
    springs, notes = line.split(' ')
    notes = [int(v) for v in notes.split(',')]
    return springs, tuple(notes)

hot_springs = read_input(12, transformer)

Part 1

I started with a "let's try every possible combination and see how many are valid".

An order of springs is valid if the lengths of damaged groups matches the group sizes in our notes.

For each row, I check how many unknowns there are and generate all possible ways to replace them with OPERATIONAL (.) and DAMAGED (#) springs. To do this, I use itertools.product that here takes an iterable and how long outputs we want.

Then I check if this new grouping is valid.

(I'm using class Spring instead of individual constants because of how Python's pattern matching (that I use in part 2) functions.)

from itertools import product


class Spring:
    OPERATIONAL = '.'
    DAMAGED = '#'
    UNKNOWN = '?'

def is_valid_order(springs, damaged):
    damaged_springs = [len(s) for s in springs.split(Spring.OPERATIONAL) if s]
    return damaged_springs == list(damaged)

def find_valid_ones(springs, notes):
    valid_ones = 0
    unknowns = springs.count(Spring.UNKNOWN)
    options = product(Spring.OPERATIONAL + Spring.DAMAGED, repeat=unknowns)
    for opt in options:
        new_springs = springs
        for char in opt:
            new_springs = new_springs.replace(Spring.UNKNOWN, char, 1)
        if is_valid_order(new_springs, notes):
            valid_ones += 1
    return valid_ones

This approach took roughly 15-20 seconds. Which was slow but decent enough to get the first star. I wanted to keep this approach here for the sake of it being the right answer.

part_1 = 0
for springs, notes in hot_springs:
    part_1 += find_valid_ones(springs, notes)

print(f'Solution: {part_1}')
assert part_1 == 7286

Solution: 7286

Part 2

As you look out at the field of springs, you feel like there are way more springs than the condition records list. When you examine the records, you discover that they were actually folded up this whole time!

To unfold the records, on each row, replace the list of spring conditions with five copies of itself (separated by ?) and replace the list of contiguous groups of damaged springs with five copies of itself (separated by ,).

So, this row:

.# 1

Would become:

.#?.#?.#?.#?.# 1,1,1,1,1

The first line of the above example would become:

???.###????.###????.###????.###????.### 1,1,3,1,1,3,1,1,3,1,1,3,1,1,3

In the above example, after unfolding, the number of possible arrangements for some rows is now much larger:

  • ???.### 1,1,3 - 1 arrangement
  • .??..??...?##. 1,1,3 - 16384 arrangements
  • ?#?#?#?#?#?#?#? 1,3,1,6 - 1 arrangement
  • ????.#...#... 4,1,1 - 16 arrangements
  • ????.######..#####. 1,6,5 - 2500 arrangements
  • ?###???????? 3,2,1 - 506250 arrangements

After unfolding, adding all of the possible arrangement counts together produces 525152.

Unfold your condition records; what is the new sum of possible arrangement counts?

Given how slow my initial solution was, there was no way to use it for this second part.

Instead, I was pointed towards a recursive solution. To speed up the solution significantly, I use functools.cache. It works by memoizing the result for a set of arguments. This is why we needed to use tuple instead of list when parsing the input as list is not cacheable.

The function itself has a lot of branching paths.

Base cases

Its first two base cases happen when there's no more springs to consider. If we finish them while we are within a group of damaged springs, the result is 1 if there's only one group note left and it matches our current count. Alternatively, if we finish and we are outside of damaged springs, the result is 1 if there's no more notes left.

The third base case is if our current count is higher than the next noted one (or if we ran out of notes). In this case, it's not a valid one so we return 0.

Others based on next spring

If none of the base cases match, we move forward based on what the next spring is. For that, we have three options: an operational (.), a damaged (#) or an unknown (?) spring.

operational: If we are in the middle of a count (meaning at least the previous was damaged and our count is over 0), we check if the count matches the first note. If it doesn't, we return 0 since that cannot be a valid arraignment. If it does, we move forward with that note removed. If we weren't in the middle of a count, we continue with the rest of the springs.

damaged: This means we are either starting a new count or are in the middle of a count. Regardless, we drop the first spring and increment our count with 1.

unknown: This one has the most complex cases.

Our first case is when we no longer have notes or the current count matches the first group. If this happens, we continue with the rest of the springs and drop the first note. Effectively this means the spring is treated as ..

The next case is when we are in the middle of a count. This treats the spring as #.

The last case is when we are a potential start of a count. In this, spring could be either . or #. For this, we count both options.

Finally, I have a case of invalid spring. Technically this is not required in Python but because I learned my pattern matching originally with Rust where you need to exhaust all options with pattern matching branches, I have a habit of doing it with Python as well. I think it's a good option because if I make a mistake and forget one branch, I get an error.

from functools import cache


@cache
def how_many_valid_arraignments(springs, notes, count=0):
    # All springs accounted for
    if not springs and count > 0: # Last spring was damaged
        if len(notes) == 1 and count == notes[0]:
            return 1
        else:
            return 0
    if not springs and count == 0: # Last spring was operational
        if len(notes) == 0:
            return 1
        else:
            return 0

    # We saw more damaged springs in a row than there
    # should be according to the notes so it is not valid
    if count > 0 and (not notes or count > notes[0]):
        return 0

    # So far everything's good but we have more springs to see
    first, rest = springs[0], springs[1:]
    match first:
        case Spring.OPERATIONAL:
            if count > 0: # We finished a run of damaged springs
                if count != notes[0]:
                    return 0
                else: # Last spring was also operational
                    notes = notes[1:]
            return how_many_valid_arraignments(rest, notes, 0)
        case Spring.DAMAGED:
            # Increase damage count
            return how_many_valid_arraignments(rest, notes, count + 1)
        case Spring.UNKNOWN:
            if not notes or count == notes[0]: # We finished a run of damaged springs
                return how_many_valid_arraignments(rest, notes[1:], 0)
            else:
                if count > 0:
                    # We are in the middle of a run of damaged springs
                    return how_many_valid_arraignments(rest, notes, count + 1)
                else:
                    # This unknown could be a . or # so let's count both options
                    return (how_many_valid_arraignments(rest, notes, count + 1) +
                            how_many_valid_arraignments(rest, notes, count))
        case _:
            raise ValueError(f'{first} is an invalid spring')

For the second part, we need to modify the input.

Springs are repeated 5 times with ? in between each set. Notes are repeated five times.

The calculations then are the same, this time using our much faster solution.

from itertools import product


part_2 = 0
for springs, notes in hot_springs:
    springs = '?'.join([springs] * 5)
    notes = notes * 5
    part_2 += how_many_valid_arraignments(springs, notes)

print(f'Solution: {part_2}')
assert part_2 == 25470469710341

Solution: 25470469710341

Two stars

Today was quite a challenge. If I didn't write these explanations, I would have maybe gotten a right result but with a way worse understanding of why it works the way it does.