Day 12: Hot Springs

 4th January 2024 at 11:50pm

I've took some time to improve my approach to this problem, all the while diving into what dynamic programming really is. I've seen some solutions in the subreddit. Even when people bruteforced it, most did it while producing only the possible combinations. So that's an improvement I could do.

Also, when I hear of dynamic programming, I expect some sort of memoization to be present, but while some solutions implement it (most do!), the memoized patterns seem to be used by chance only — as in, from the get-go it's sort of impossible to see which function calls will be reused later. In the Fibonacci sequence algorithm, memoization is key because every result will be used eventually (except for the last); in this case, it's much more unreliable.

During this process, I finally learned how to approach a problem by tabulation, which I find much more interesting, and this user used that approach (and here; but I'm a bit tired, so I ended up relying heavily on this. There are also other interesting approaches, like using finite automata, and I'd like to get more information on that too.

Part 1

This seems like a fun exercise! The numbers to the right specify the number of sections (sections are comma-separated), with the number inside being the amount of # in each section. The ? omits information, and one must calculate all arrangement possibilities that match the rules for each line. I'll call the spring map on the left smap.

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

However, I don't have much of an idea on where to start. This seems like one of those problems where trying to perfect the logic takes much more — and is maybe more error prone — to just attempting a bruteforce.

On the first example, it is clear that the only problem to solve is fitting two sections of 1 (1, 1) into ???; there's only one way. But to fit a section there must necessarily be a delimiter before and after the sequence of #, and a delimiter can either be the beginning and end of line or a ..

(I also suppose one could match the 3 to ###, reducing the problem to ???. with 1, 1...but I'm not sure whether this would make my life easier in any way.)

(I wonder how smart people solve this. I know this will take me longer, and will be a little more frustrating, than it is to them; but I'm curious to see how speedrunners tackled it.)

In any case, I'll start with the input parsing function, so I can make some tests.


After parsing the input and having done some basic checker functions, it seems like this should be a recursive function. I'm just trying to wrap my head around the conditions for recursion.

Suppose the case for ?#?#?#?#?#?#?#? 1,3,1,6.

I'm starting left to right. I don't think there's any obvious advantage to doing either way.

I'd put a # in the first ?, and it would be invalid, because then the first section would have length 2. Then a . would be valid, because the section would have length of minimum 1. A # on the second ? would be invalid, as the

and I'll necessarily be dividing each smap into different sections, and then ———


Well, I just went ahead and bruteforced it to see how it would fare. The code takes some 10 seconds to execute, but does the job. So I suppose this is done?

Tests

class Test202312_01(unittest.TestCase):
    def test_can_get_data_from_input(self):
        info = get_data_from_input(TEST_DATA)
        self.assertEqual(
                info[0],
                ['???.###', (1, 1, 3)]
                )

    def test_can_check_valid_arrangement(self):
        self.assertEqual(
            is_valid_arrangement('##..###', (1, 1, 3)),
            False
        )
        self.assertEqual(
            is_valid_arrangement('#.#.###', (1, 1, 3)),
            True
        )

    def test_generate_possible_arrangements(self):
        self.assertEqual(
            len(generate_possible_arrangements('????.###')),
            16
        )

    def test_can_count_possible_arrangements(self):
        self.assertEqual(
            count_possible_arrangements(*['????.###', (4, 3)]),
            1
        )
        self.assertEqual(
            count_possible_arrangements(*['?#?#?#?#?#?#?#?', (1,3,1,6)]),
            1
        self.assertEqual(
            count_possible_arrangements('?###????????', (3, 2, 1)),
            10
        )

    def test_can_sum_possible_arrangements(self):
        info = get_data_from_input(TEST_DATA)
        self.assertEqual(
            sum_count_of_possible_arrangements(info),
            21
        )

Code

from functools import reduce


def get_data_from_input(data: str) -> list[str]:
    return list(map(lambda t: [t[0],
                               tuple(map(int, t[1].split(',')))],
                    map(lambda line: line.split(),
                        data.strip().splitlines())))


def is_valid_arrangement(smap: str, sections: tuple[int]) -> bool:
    # check first whether the number of sections is matching and then
    # whether the length of each section is matching
    return (len(smap_sections := [s for s in smap.split('.') if len(s) > 0])
            == len(sections) and
            all(map(lambda t: t[0] == t[1],
                    zip(map(len, smap_sections), sections))))


def generate_possible_arrangements(
        smap: str,
        arrangements: list = []
        ) -> list[str]:
    if smap.count('?') == 0:
        return [smap]
    i = smap.find('?')
    return (reduce(lambda x, y: x + y,
                   map(lambda symb: generate_possible_arrangements(
                       smap[:i] + symb + smap[i + 1:],
                       arrangements),
                       ['#', '.']),
                   []))


def count_possible_arrangements(
        smap: str,
        sections: tuple[int]
        ) -> int:
    return len(list(filter(lambda s: is_valid_arrangement(s, sections),
               generate_possible_arrangements(smap))))


def sum_count_of_possible_arrangements(
        smaps_and_sections: list[str, tuple[int]],
        ) -> int:
    return sum(count_possible_arrangements(*saps)
               for saps in smaps_and_sections)


if __name__ == '__main__':
    with open('input', 'r') as f:
        info = get_data_from_input(f.read())
        print(sum_count_of_possible_arrangements(info))

Part 2

It doesn't even cross my mind to calculate Part 2 with the expanded input data — just as it didn't on the previous exercise. Adding a ? to the end of every input, and then making it 5 times the length, would be no big deal if the ? didn't sometimes unlock new combinations.

Tests

Code