Day 10: Pipe Maze

Completed in ~2 hours, 53 minutes.
3,953ʳᵈ worldwide.
2023-12-10

You use the hang glider to ride the hot air from Desert Island all the way up to the floating metal island. This island is surprisingly cold and there definitely aren't any thermals to glide on, so you leave your hang glider behind.

You wander around for a while, but you don't find any people or animals. However, you do occasionally find signposts labeled "Hot Springs" pointing in a seemingly consistent direction; maybe you can find someone at the hot springs and ask them where the desert-machine parts are made.

The landscape here is alien; even the flowers and trees are made of metal. As you stop to admire some metal grass, you notice something metallic scurry away in your peripheral vision and jump into a big pipe! It didn't look like any animal you've ever seen; if you want a better look, you'll need to get ahead of it.

Scanning the area, you discover that the entire field you're standing on is densely packed with pipes; it was hard to tell at first because they're the same metallic silver color as the "ground". You make a quick sketch of all of the surface pipes you can see (your puzzle input).

The pipes are arranged in a two-dimensional grid of tiles:

  • | 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.
  • . is ground; there is no pipe in this tile.
  • S is the starting position of the animal; there is a pipe on this tile, but your sketch doesn't show what shape the pipe has.

Based on the acoustics of the animal's scurrying, you're confident the pipe that contains the animal is one large, continuous loop.

For example, here is a square loop of pipe:

1
2
3
4
5
..... .F-7. .|.|. .L-J. .....

If the animal had entered this loop in the northwest corner, the sketch would instead look like this:

1
2
3
4
5
..... .S-7. .|.|. .L-J. .....

In the above diagram, the S tile is still a 90-degree F bend: you can tell because of how the adjacent pipes connect to it.

Unfortunately, there are also many pipes that aren't connected to the loop! This sketch shows the same loop as above:

1
2
3
4
5
-L|F7 7S-7| L|7|| -L-J| L|-JF

In the above diagram, you can still figure out which pipes form the main loop: they're the ones connected to S, pipes those pipes connect to, pipes those pipes connect to, and so on. Every pipe in the main loop connects to its two neighbors (including S, which will have exactly two pipes connecting to it, and which is assumed to connect back to those two pipes).

Here is a sketch that contains a slightly more complex main loop:

1
2
3
4
5
..F7. .FJ|. SJ.L7 |F--J LJ...

Here's the same example sketch with the extra, non-main-loop pipe tiles also shown:

1
2
3
4
5
7-F7- .FJ|7 SJLL7 |F--J LJ.LJ

If you want to get out ahead of the animal, you should find the tile in the loop that is farthest from the starting position. Because the animal is in the pipe, it doesn't make sense to measure this by direct distance. Instead, you need to find the tile that would take the longest number of steps along the loop to reach from the starting point - regardless of which way around the loop the animal went.

In the first example with the square loop:

1
2
3
4
5
..... .S-7. .|.|. .L-J. .....

You can count the distance each tile in the loop is from the starting point like this:

1
2
3
4
5
..... .012. .1.3. .234. .....

In this example, the farthest point from the start is 4 steps away.

Here's the more complex loop again:

1
2
3
4
5
..F7. .FJ|. SJ.L7 |F--J LJ...

Here are the distances for each tile on that loop:

1
2
3
4
5
..45. .236. 01.78 14567 23...

Find the single giant loop starting at S.How many steps along the loop does it take to get from the starting position to the point farthest from the starting position?

  1. Find the start position
  2. Find connected pipe to the start position
  3. Follow the pipe until we get back to the start position, counting the number of tiles in the pipe as we go
    • This makes heavy usage of the OFFSET_MAP
  4. Return the length of the pipe / 2

"Why length of the pipe / 2?"

Because the pipe always connects back to the start position, it must always have an even number of segments.

Don't believe me? Try to draw a continuous pipe with an odd number of segments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
from dataclasses import dataclass from pathlib import Path import pytest @dataclass class Position: x: int y: int # (character, x-offset, y-offset) -> (x-offset, y-offset) OFFSET_MAP: dict[tuple[str, int, int], Position] = { ("|", 0, 1): Position(x=0, y=1), ("|", 0, -1): Position(x=0, y=-1), ("-", 1, 0): Position(x=1, y=0), ("-", -1, 0): Position(x=-1, y=0), ("L", 0, 1): Position(x=1, y=0), ("L", -1, 0): Position(x=0, y=-1), ("J", 1, 0): Position(x=0, y=-1), ("J", 0, 1): Position(x=-1, y=0), ("7", 1, 0): Position(x=0, y=1), ("7", 0, -1): Position(x=-1, y=0), ("F", -1, 0): Position(x=0, y=1), ("F", 0, -1): Position(x=1, y=0), } @dataclass class Map: start: Position current: Position positions: list[Position] characters: list[list[str]] def get_character(self, x: int, y: int) -> str | None: if 0 <= x < len(self.characters[0]) and 0 <= y <= len(self.characters): return self.characters[y][x] return None def run(self) -> int: steps = 0 # Move one position manually next_offset: Position | None = None for x_offset, y_offset in [(0, 1), (0, -1), (1, 0), (-1, 0)]: next_character = self.get_character( x=self.current.x + x_offset, y=self.current.y + y_offset, ) if OFFSET_MAP.get((next_character, x_offset, y_offset)): next_offset = OFFSET_MAP[(next_character, x_offset, y_offset)] self.current = Position( x=self.current.x + x_offset, y=self.current.y + y_offset ) steps += 1 break while self.current != self.start: # Get the character at the next position next_character = self.get_character( x=self.current.x + next_offset.x, y=self.current.y + next_offset.y, ) self.positions.append(self.current) self.current = Position( x=self.current.x + next_offset.x, y=self.current.y + next_offset.y ) steps += 1 next_offset = OFFSET_MAP.get((next_character, next_offset.x, next_offset.y)) return steps // 2 def runner(document: list[str]) -> int: character_map: list[list[str]] = [] position = Position(x=0, y=0) for y_index, line in enumerate(document): character_map.append(list(line)) for x_index, character in enumerate(line): if character == "S": position = Position(x=x_index, y=y_index) pipe_map = Map( start=position, current=position, positions=[position], characters=character_map, ) return pipe_map.run() @pytest.mark.parametrize( "filename,output", [ ("example-1.txt", 8), ("example-5.txt", 6968), # Very slow ], ) def test_runner(filename: str, output: int) -> None: with open(Path(__file__).with_name(filename)) as file: result = runner(file.read().splitlines()) assert result == output

Answer: 6,968

You quickly reach the farthest point of the loop, but the animal never emerges. Maybe its nest is within the area enclosed by the loop?

To determine whether it's even worth taking the time to search for such a nest, you should calculate how many tiles are contained within the loop. For example:

1
2
3
4
5
6
7
8
9
........... .S-------7. .|F-----7|. .||.....||. .||.....||. .|L-7.F-J|. .|..|.|..|. .L--J.L--J. ...........

The above loop encloses merely four tiles - the two pairs of . in the southwest and southeast (marked I below). The middle . tiles (marked O below) are not in the loop. Here is the same loop again with those regions marked:

1
2
3
4
5
6
7
8
9
........... .S-------7. .|F-----7|. .||OOOOO||. .||OOOOO||. .|L-7OF-J|. .|II|O|II|. .L--JOL--J. .....O.....

In fact, there doesn't even need to be a full tile path to the outside for tiles to count as outside the loop - squeezing between pipes is also allowed! Here, I is still within the loop and O is still outside the loop:

1
2
3
4
5
6
7
8
9
.......... .S------7. .|F----7|. .||OOOO||. .||OOOO||. .|L-7F-J|. .|II||II|. .L--JL--J. ..........

In both of the above examples, 4 tiles are enclosed by the loop.

Here's a larger example:

1
2
3
4
5
6
7
8
9
10
.F----7F7F7F7F-7.... .|F--7||||||||FJ.... .||.FJ||||||||L7.... FJL7L7LJLJ||LJ.L-7.. L--J.L7...LJS7F-7L7. ....F-J..F7FJ|L7L7L7 ....L7.F7||L7|.L7L7| .....|FJLJ|FJ|F7|.LJ ....FJL-7.||.||||... ....L---J.LJ.LJLJ...

The above sketch has many random bits of ground, some of which are in the loop (I) and some of which are outside it (O):

1
2
3
4
5
6
7
8
9
10
OF----7F7F7F7F-7OOOO O|F--7||||||||FJOOOO O||OFJ||||||||L7OOOO FJL7L7LJLJ||LJIL-7OO L--JOL7IIILJS7F-7L7O OOOOF-JIIF7FJ|L7L7L7 OOOOL7IF7||L7|IL7L7| OOOOO|FJLJ|FJ|F7|OLJ OOOOFJL-7O||O||||OOO OOOOL---JOLJOLJLJOOO

In this larger example, 8 tiles are enclosed by the loop.

Any tile that isn't part of the main loop can count as being enclosed by the loop. Here's another example with many bits of junk pipe lying around that aren't connected to the main loop at all:

1
2
3
4
5
6
7
8
9
10
FF7FSF7F7F7F7F7F---7 L|LJ||||||||||||F--J FL-7LJLJ||||||LJL-77 F--JF--7||LJLJ7F7FJ- L---JF-JLJ.||-FJLJJ7 |F|F-JF---7F7-L7L|7| |FFJF7L7F-JF7|JL---7 7-L-JL7||F7|L7F-7F7| L.L7LFJ|||||FJL7||LJ L7JLJL-JLJLJL--JLJ.L

Here are just the tiles that are enclosed by the loop marked with I:

1
2
3
4
5
6
7
8
9
10
FF7FSF7F7F7F7F7F---7 L|LJ||||||||||||F--J FL-7LJLJ||||||LJL-77 F--JF--7||LJLJIF7FJ- L---JF-JLJIIIIFJLJJ7 |F|F-JF---7IIIL7L|7| |FFJF7L7F-JF7IIL---7 7-L-JL7||F7|L7F-7F7| L.L7LFJ|||||FJL7||LJ L7JLJL-JLJLJL--JLJ.L

In this last example, 10 tiles are enclosed by the loop.

Figure out whether you have time to search for the nest by calculating the area within the loop.How many tiles are enclosed by the loop?

There are two ways I could think of solving this.

  1. Flood fill
  2. Point in polygon

My solution below uses flood fill, but I initially solved this problem with a (slow) point in polygon implementation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
from dataclasses import dataclass, field from pathlib import Path import pytest # Doesn't matter what these characters are... # As long as they don't conflict with existing characters in the map. # These choices print with nice colors for debugging. OPEN_CHARACTER = "\033[94m█\033[0m" ENCLOSED_CHARACTER = "\033[31m█\033[0m" @dataclass class Position: x: int y: int # (character, x-offset, y-offset) -> (x-offset, y-offset) OFFSET_MAP: dict[tuple[str, int, int], Position] = { ("|", 0, 1): Position(x=0, y=1), ("|", 0, -1): Position(x=0, y=-1), ("-", 1, 0): Position(x=1, y=0), ("-", -1, 0): Position(x=-1, y=0), ("L", 0, 1): Position(x=1, y=0), ("L", -1, 0): Position(x=0, y=-1), ("J", 1, 0): Position(x=0, y=-1), ("J", 0, 1): Position(x=-1, y=0), ("7", 1, 0): Position(x=0, y=1), ("7", 0, -1): Position(x=-1, y=0), ("F", -1, 0): Position(x=0, y=1), ("F", 0, -1): Position(x=1, y=0), } EXPAND_MAP: dict[str, tuple[list[str], list[str], list[str]]] = { OPEN_CHARACTER: ( [OPEN_CHARACTER, OPEN_CHARACTER, OPEN_CHARACTER], [OPEN_CHARACTER, OPEN_CHARACTER, OPEN_CHARACTER], [OPEN_CHARACTER, OPEN_CHARACTER, OPEN_CHARACTER], ), ENCLOSED_CHARACTER: ( [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ), "S": ( ["S", "S", "S"], ["S", "S", "S"], ["S", "S", "S"], ), "|": ( [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], ), "-": ( [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ["-", "-", "-"], [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ), "L": ( [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, "L", "-"], [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ), "J": ( [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], ["-", "J", ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ), "7": ( [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], ["-", "7", ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], ), "F": ( [ENCLOSED_CHARACTER, ENCLOSED_CHARACTER, ENCLOSED_CHARACTER], [ENCLOSED_CHARACTER, "F", "-"], [ENCLOSED_CHARACTER, "|", ENCLOSED_CHARACTER], ), } @dataclass class Map: start: Position current: Position positions: list[Position] characters: list[list[str]] expanded_characters: list[list[str]] = field(default_factory=list) def get_character(self, x: int, y: int) -> str | None: if 0 <= x < len(self.characters[0]) and 0 <= y <= len(self.characters): return self.characters[y][x] return None def run(self) -> int: steps = 0 # Move one position manually next_offset: Position | None = None for x_offset, y_offset in [(0, 1), (0, -1), (1, 0), (-1, 0)]: next_character = self.get_character( x=self.current.x + x_offset, y=self.current.y + y_offset, ) if OFFSET_MAP.get((next_character, x_offset, y_offset)): next_offset = OFFSET_MAP[(next_character, x_offset, y_offset)] self.current = Position( x=self.current.x + x_offset, y=self.current.y + y_offset ) steps += 1 break while self.current != self.start: # Get the character at the next position next_character = self.get_character( x=self.current.x + next_offset.x, y=self.current.y + next_offset.y, ) self.positions.append(self.current) self.current = Position( x=self.current.x + next_offset.x, y=self.current.y + next_offset.y ) steps += 1 next_offset = OFFSET_MAP.get((next_character, next_offset.x, next_offset.y)) return steps // 2 def is_in_loop(self, x: int, y: int) -> bool: return any(position.x == x and position.y == y for position in self.positions) def get_enclosed_positions(self) -> int: enclosed = 0 for y_index in range(len(self.characters)): for x_index in range(len(self.characters[0])): if not self.is_in_loop(x_index, y_index): crosses = 0 # Offset by 0.5 to account for corners, points, etc. y_position = y_index + 0.5 # For each point pair in the loop, see if we intersect it for p_index in range(len(self.positions) - 1): a = self.positions[p_index] b = self.positions[p_index + 1] if a.y == b.y: continue if ( (a.y > y_position > b.y or a.y < y_position < b.y) and a.x > x_index and b.x > x_index ): crosses += 1 if crosses % 2 == 1: self.characters[y_index][x_index] = ENCLOSED_CHARACTER enclosed += 1 return enclosed def cleanup(self) -> None: for y_index in range(len(self.characters)): for x_index in range(len(self.characters[0])): if not self.is_in_loop(x_index, y_index): if ( y_index == 0 or y_index == len(self.characters) - 1 or (x_index == 0 or x_index == len(self.characters[0]) - 1) ): self.characters[y_index][x_index] = OPEN_CHARACTER else: self.characters[y_index][x_index] = ENCLOSED_CHARACTER def expand(self) -> None: # 3x the height of characters self.expanded_characters = [[] for _ in range(len(self.characters) * 3)] for y_index in range(len(self.characters)): for x_index in range(len(self.characters[0])): character = self.characters[y_index][x_index] first_line, second_line, third_line = EXPAND_MAP[character] self.expanded_characters[y_index * 3] += first_line self.expanded_characters[y_index * 3 + 1] += second_line self.expanded_characters[y_index * 3 + 2] += third_line def flood_fill(self) -> None: # {(x, y)} positions_to_check: set[tuple[int, int]] = set() # Fill positions to check with initial data of all Os for y_index in range(len(self.expanded_characters)): for x_index in range(len(self.expanded_characters[0])): character = self.expanded_characters[y_index][x_index] if character == OPEN_CHARACTER: positions_to_check.add((x_index, y_index)) while positions_to_check: x_index, y_index = positions_to_check.pop() # Check in cardinal directions for x_offset, y_offset in [(-1, 0), (1, 0), (0, -1), (0, 1)]: try: character = self.expanded_characters[y_index + y_offset][ x_index + x_offset ] if character == ENCLOSED_CHARACTER: self.expanded_characters[y_index + y_offset][ x_index + x_offset ] = OPEN_CHARACTER positions_to_check.add((x_index + x_offset, y_index + y_offset)) except IndexError: pass def shrink(self) -> None: for y_index in range(0, len(self.expanded_characters), 3): for x_index in range(0, len(self.expanded_characters[0]), 3): # Get the center of each 3x3 grid character = self.expanded_characters[y_index - 2][x_index - 2] self.characters[y_index // 3 - 1][x_index // 3 - 1] = character def count(self, character: str) -> int: total = 0 for y_index in range(len(self.characters)): for x_index in range(len(self.characters[0])): if self.characters[y_index][x_index] == character: total += 1 return total def runner(document: list[str]) -> int: character_map: list[list[str]] = [] position = Position(x=0, y=0) for y_index, line in enumerate(document): character_map.append(list(line)) for x_index, character in enumerate(line): if character == "S": position = Position(x=x_index, y=y_index) pipe_map = Map( start=position, current=position, positions=[position], characters=character_map, ) pipe_map.run() pipe_map.cleanup() pipe_map.expand() pipe_map.flood_fill() pipe_map.shrink() return pipe_map.count(ENCLOSED_CHARACTER) @pytest.mark.parametrize( "filename,output", [ ("example-2.txt", 4), ("example-3.txt", 4), ("example-4.txt", 2), ("example-5.txt", 413), # Very slow ], ) def test_runner(filename: str, output: int) -> None: with open(Path(__file__).with_name(filename)) as file: result = runner(file.read().splitlines()) assert result == output

Answer: 413

DayPart 1 TimePart 1 RankPart 2 TimePart 2 Rank
1001:13:295,22702:53:153,953

Red rectangles represent the enclosed characters within the map.

Green rectangles represent the characters within the map that appear to be enclosed but actually aren't.

Large - Enclosed

Pipe Maze Empty

Pipe Maze Empty

Large - Enclosed + Escaped

Pipe Maze Filled

Pipe Maze Filled