Day 10: Pipe Maze

 2nd January 2024 at 3:20pm

Part 1

Funny puzzle! After starting the work on it, I was a little thrown off by an apparent excess of information; only after some work did I realise this was much simpler than what I expected, since I was working under the assumption that one had to ensure the pipes can be followed. This is not the case, except for the initial case of finding S; after finding a possible pipe on S, it is ensured that the pipes have a correct disposition.

I haven't checked any other code, but I suppose it is not necessarily more sensible to try and follow the two possible paths from S and meet halfway, rather than just follow the whole loop and divide the number of steps by two (+ 1 if steps is odd).

For further investigation

Again I came across a recursion limit. I'd like to further investigate how these are being triggered, and if there is anyway to have better control over the algorithmic load of my code (other than just arbitrarily set up higher limits until it doesn't break anymore).

Tests

import unittest
from d1001 import (
    get_data_from_input,
    get_pipe_instructions,
    get_pipe_symbol,
    find_starting_location,
    get_valid_adjacent_locations,
    get_new_state_after_following_pipe,
    follow_pipe_loop,
    solve_part_one
        )

TEST_DATA_1 = """
-L|F7
7S-7|
L|7||
-L-J|
L|-JF
"""

TEST_DATA_2 = """
..F7.
.FJ|.
SJ.L7
|F--J
LJ...
"""

PIPE_INSTR = """
| is a vertical pipe connecting north and south.
- is a horizontal pipe connecting east and west.
L is a 90-degree bend connecting north and east.
J is a 90-degree bend connecting north and west.
7 is a 90-degree bend connecting south and west.
F is a 90-degree bend connecting south and east.
"""


class Test20231001(unittest.TestCase):
    def test_can_get_data_from_input(self):
        self.assertEqual(
            get_data_from_input(TEST_DATA_1),
            ['-L|F7', '7S-7|', 'L|7||', '-L-J|', 'L|-JF']
            )
        self.assertEqual(
            get_data_from_input(TEST_DATA_2),
            ['..F7.', '.FJ|.', 'SJ.L7', '|F--J', 'LJ...']
        )

    def test_can_parse_pipe_instructions(self):
        self.assertEqual(
            get_pipe_instructions(PIPE_INSTR),
            {
                '-': ['e', 'w'],
                '7': ['s', 'w'],
                'F': ['s', 'e'],
                'J': ['n', 'w'],
                'L': ['n', 'e'],
                '|': ['n', 's']
            }
        )

    def test_get_pipe_symbol_from_map_coordinates(self):
        pipemap = get_data_from_input(TEST_DATA_1)
        self.assertEqual(
                get_pipe_symbol(pipemap, (2, 1)), '|')
        self.assertEqual(get_pipe_symbol(pipemap, (3, 1)), 'L')

    # first step in solving the problem is
    # to find the coordinates of S
    def test_finding_location_of_S(self):
        for i, m in enumerate([TEST_DATA_1, TEST_DATA_2]):
            pipemap = get_data_from_input(m)
            self.assertEqual(
                    find_starting_location(pipemap), [(1, 1), (2, 0)][i]
            )

    # and then, to look up what pipes are adjacent to it
    # and could represent a start to the path
    # both the pipe symbol and the orientation relative to S are needed
    def test_get_valid_adjacent_pipes_with_orientation(self):
        pipemap = get_data_from_input(TEST_DATA_1)
        start = find_starting_location(pipemap)
        self.assertEqual(
                list(get_valid_adjacent_locations(pipemap, start)),
                [['s', (2, 1)], ['e', (1, 2)]]
                )
        pipemap = get_data_from_input(TEST_DATA_2)
        start = find_starting_location(pipemap)
        self.assertEqual(
                list(get_valid_adjacent_locations(pipemap, start)),
                [['s', (3, 0)], ['e', (2, 1)]]
                )

    # suppose a state for the problem, consisting of
    # [current coordinates, orientation from which we came, steps taken]
    def test_follow_pipe(self):
        pipemap = get_data_from_input(TEST_DATA_1)
        # at '|'; came from above
        state = [(2, 1), 'n', 1]
        self.assertEqual(
                get_new_state_after_following_pipe(
                    pipemap,
                    *state
                ),
                [(3, 1), 'n', 2]
            )

    def test_follow_pipe_until_loop(self):
        pipemap = get_data_from_input(TEST_DATA_1)
        # at '|'; came from above
        state = [(2, 1), 'n', 1]
        self.assertEqual(
                follow_pipe_loop(
                    pipemap,
                    *state
                ),
                8
            )
        pipemap = get_data_from_input(TEST_DATA_2)
        state = [(3, 0), 'n', 1]
        self.assertEqual(
                follow_pipe_loop(
                    pipemap,
                    *state
                ),
                16
            )

    def test_solve_part_one(self):
        self.assertEqual(
                solve_part_one(get_data_from_input(TEST_DATA_1)),
                4)
        self.assertEqual(
                solve_part_one(get_data_from_input(TEST_DATA_2)),
                8)


if __name__ == '__main__':
    unittest.main()

Code

import re
import sys
sys.setrecursionlimit(20000)

OPP = {'e': 'w', 'w': 'e', 's': 'n', 'n': 's'}

COORD_INSTR = {'e': (0, 1), 'w': (0, -1), 's': (1, 0), 'n': (-1, 0)}

PIPE_INSTR = {
    '-': ['e', 'w'],
    '7': ['s', 'w'],
    'F': ['s', 'e'],
    'J': ['n', 'w'],
    'L': ['n', 'e'],
    '|': ['n', 's']
}


def get_data_from_input(data: str) -> list[str]:
    return data.strip().splitlines()


def get_pipe_instructions(data: str) -> dict[str]:
    pattern = r"(.).* (\w)\w+ and (\w)"
    pipes = {}
    list(map(lambda line: list(map(lambda r: pipes.update(
                                        {r[0]: [r[1], r[2]]}
                                   ),
                                   re.findall(pattern, line))),
             data.splitlines()))
    return pipes


def find_starting_location(data: list[str]):
    # t is a tuple of (index, line)
    i, line = next(filter(lambda t: 'S' in t[1],
                          enumerate(data)))
    return i, line.find('S')


def get_pipe_symbol(data: list[str], location: tuple[int, int]):
    return data[location[0]][location[1]]


# also ensures valid locations only
# ie. adjacent to S and compatible to follow from S
def get_valid_adjacent_locations(
        data: list[str],
        location: tuple[int, int]
        ) -> list[str, tuple[int, int]]:

    # gets a list of tuples of (orientation, coordinates)
    # where orientation is where the pipe is relative to S
    # ie. the J in 'SJ' corresponds to 'w' orientation
    # it also filters out of bound locations
    surrounding_pipes = filter(lambda t: (0 <= t[1][0] < len(data) and
                                          0 <= t[1][1] < len(data[0]) and
                                          get_pipe_symbol(data, t[1]) != '.'),
                               [
                                   ['n', (location[0] - 1, location[1])],
                                   ['s', (location[0] + 1, location[1])],
                                   ['w', (location[0], location[1] - 1)],
                                   ['e', (location[0], location[1] + 1)],
                               ])

    # get a generator of pipes that are compatible with S;
    # J is compatible with S because as J is 'w' of S;
    # then, as opp('w') == 'e' and J connects 'e' to 'n', then J is compatible
    return filter(lambda pt: OPP[pt[0]] in PIPE_INSTR[get_pipe_symbol(data,
                                                                      pt[1])],
                  surrounding_pipes)


def get_new_state_after_following_pipe(
        data: list[str],
        location: tuple[int, int],
        prev: str,
        steps: int
        ) -> tuple[int, int]:
    # takes the remaining connection of the pipe symbol
    # when not considering the current one
    dest = [symb for symb in PIPE_INSTR[get_pipe_symbol(data, location)]
            if symb != prev][0]
    new_coordinates = tuple(map(lambda x: x[0] + x[1],
                                zip(location, COORD_INSTR[dest])))
    return [new_coordinates, OPP[dest], steps + 1]


def follow_pipe_loop(
        data: list[str],
        location: tuple[int, int],
        prev: str,
        steps: int
        ) -> tuple[int, int]:
    if get_pipe_symbol(data, location) == 'S':
        return steps
    return follow_pipe_loop(data,
                            *get_new_state_after_following_pipe(
                                data,
                                location,
                                prev,
                                steps))


def solve_part_one(pipemap: list[str]) -> int:
    orientation, coordinates = (
            next(get_valid_adjacent_locations(
                pipemap,
                find_starting_location(pipemap))))
    loop = follow_pipe_loop(pipemap, coordinates, OPP[orientation], 1)
    # if loop has odd number of steps, it means that one of the paths
    # is one unit longer
    return loop // 2 + loop % 2


if __name__ == '__main__':
    with open('input') as f:
        print(solve_part_one(get_data_from_input(f.read())))

Part 2

Huh. Not only is Part 2 a very different exercise from Part 1, it is likely the most difficult so far. If only the normal grid were considered, it would be a more trivial exercise: something like

a) take all coordinates that are not part of the loop;

b) do a flood-fill algorithm to check surroundings and create different areas;

c) if an area is enclosed at any point by the map limits, then it is open; otherwise, it's enclosed in the loop.

In fact, the same strategy could be followed, except that checking the surroundings is a little more complicated, since pipes can be squeezed into. In the original case, checking the surroundings means the 8 adjacent tiles; now, however, it depends on the pipes composition around a given tile. Interesting.


It's been a couple of days since I solved this. In the meantime, a friend let me know about the Point in Polygon algorithm, which is a raycasting strategy to solving the problem. There are, however, other ways to solve it. One that is closer to my initial thought approach would be to zoom the map, allowing for space between the corners.

. . .     . . .
. 7 .  -> - 7 .
. . .     . | .

Then, a flood-fill would solve it.

But other programmers found a mathematical way to solve it, and I feel this is the easiest, quickest solution (and I just want to move along!).

First, I'll transform the step-counting function from the previous part to a coordinate-accumulation function.

Having all the coordinates, the shoelace formula can be implemented, and then the Pick's Theorem will solve it.

Tests

My code actually fails for the second example in the second part - the result is negative, so I suppose there is an edgecase I'm failing to account - but it passes the puzzle. Which I suppose is quite lucky for me, but I'll take it!

    def test_shoelace_formula_for_area(self):
        coords = [(1, 6), (3, 1), (7, 2), (4, 4), (8, 5)]
        self.assertEqual(
                get_area_of_loop_enclosure(coords),
                16.5)

    def test_getting_number_of_points_inside_loop(self):
         coords = [(0, 2), (2, 1), (4, 0), (4, 1), (4, 2), (3, 3), (2, 4), (1, 5)]
         self.assertEqual(
                 get_number_of_points_inside_loop(coords),
                 7
                 )

    def test_solve_part_two(self):
         self.assertEqual(
                 solve_part_two(TEST_DATA_3),
                 4
                 )
         # self.assertEqual(
                 # solve_part_two(TEST_DATA_4),
                 # 10
                 # )

Code

def get_new_state_after_following_pipe(
        data: list[str],
        location: tuple[int, int],
        prev: str,
        past_coordinates: list[tuple[int, int]]
        ) -> tuple[int, int]:
    # takes the remaining connection of the pipe symbol
    # when not considering the current one
    dest = [symb for symb in PIPE_INSTR[get_pipe_symbol(data, location)]
            if symb != prev][0]
    new_coordinates = tuple(map(lambda x: x[0] + x[1],
                                zip(location, COORD_INSTR[dest])))
    return [new_coordinates,
            OPP[dest],
            past_coordinates + [location]]


def follow_pipe_loop(
        data: list[str],
        location: tuple[int, int],
        prev: str,
        past_coordinates: list[tuple[int, int]]
        ) -> tuple[int, int]:
    if get_pipe_symbol(data, location) == 'S':
        return past_coordinates + [location]
    return follow_pipe_loop(data,
                            *get_new_state_after_following_pipe(
                                data,
                                location,
                                prev,
                                past_coordinates))


def get_all_loop_coordinates(
        pipemap: list[str],
        location: tuple[int, int]
        ) -> list[tuple[int, int]]:
    orientation, coordinates = (
            next(get_valid_adjacent_locations(
                pipemap,
                find_starting_location(pipemap))))
    loop_coords = follow_pipe_loop(pipemap, coordinates,
                                   OPP[orientation], [])
    return loop_coords


def get_area_of_loop_enclosure(coords: list[tuple[int, int]]) -> float:
    x = list(map(lambda c: c[0], coords))
    y = list(map(lambda c: c[1], coords))
    pos = sum(map(lambda op: op[0] * op[1], zip(x, y[1:] + [y[0]])))
    neg = sum(map(lambda op: op[0] * op[1], zip(y, x[1:] + [x[0]])))
    return (pos - neg) / 2


def get_number_of_points_inside_loop(coords: list[tuple[int, int]]) -> float:
    area = get_area_of_loop_enclosure(coords)
    return int(area - len(coords)/2 + 1)


def solve_part_two(data: str) -> int:
    pipemap = get_data_from_input(data)
    coords = get_all_loop_coordinates(pipemap,
                                      find_starting_location(pipemap))
    return get_number_of_points_inside_loop(coords)