{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Visions of Mario Bros\n", "\n", "- https://adventofcode.com/2023/day/10\n", "\n", "It's a pipe dream! Some things to realise:\n", "\n", "- You can map out, for any given symbol, what symbols to expect in any of the 4 compass directions. A vertical pipe can only connect to another vertical pipe on either side, or to a 7 or F to the north, for example. Any point that connects to just one other symbol this way is a dead end and not part of the loop.\n", "- We just have to follow the pipes leading from the four sides of the starting point (if there is a pipe that connects to the starting point), and keep track of the position for each and the distance travelled. Discard the pipe positions that are dead ends. Once you reach a pipe that connects to a spot you have been before, you know you have found the furthest point.\n" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# pyright: strict\n", "from __future__ import annotations\n", "\n", "import typing as t\n", "from enum import Enum\n", "from itertools import count\n", "\n", "type PipeChar = t.Literal[\"S\", \"|\", \"-\", \"L\", \"J\", \"7\", \"F\"]\n", "type MapChar = t.Literal[\".\"] | PipeChar\n", "type Connections = tuple[()] | tuple[PipeChar, PipeChar, PipeChar]\n", "type PosAndChar = tuple[complex, MapChar]\n", "_impassible = ()\n", "_n_conn = (\"|\", \"7\", \"F\", \"S\")\n", "_s_conn = (\"|\", \"L\", \"J\", \"S\")\n", "_w_conn = (\"-\", \"L\", \"F\", \"S\")\n", "_e_conn = (\"-\", \"J\", \"7\", \"S\")\n", "\n", "\n", "class PipePiece(Enum):\n", " # name = (character, north, east, south, west)\n", " ns_pipe = (\"|\", _n_conn, _impassible, _s_conn, _impassible)\n", " ew_pipe = (\"-\", _impassible, _e_conn, _impassible, _w_conn)\n", " ne_bend = (\"L\", _n_conn, _e_conn, _impassible, _impassible)\n", " nw_bend = (\"J\", _n_conn, _impassible, _impassible, _w_conn)\n", " sw_bend = (\"7\", _impassible, _impassible, _s_conn, _w_conn)\n", " se_bend = (\"F\", _impassible, _e_conn, _s_conn, _impassible)\n", " start = (\"S\", _n_conn, _e_conn, _s_conn, _w_conn)\n", "\n", " if t.TYPE_CHECKING:\n", " connected_to: tuple[Connections, Connections, Connections, Connections]\n", "\n", " else:\n", " # hide the extended new method from pyright, it'll otherwise\n", " # be confused about PipePiece(...) calls later on.\n", "\n", " def __new__(\n", " cls,\n", " char: PipeChar,\n", " n: Connections,\n", " e: Connections,\n", " s: Connections,\n", " w: Connections,\n", " ) -> PipePiece:\n", " obj = object.__new__(cls)\n", " obj._value_ = char\n", " obj.connected_to = (n, e, s, w)\n", " return obj\n", "\n", " def connects_to(self, *surrounded: PosAndChar) -> list[complex]:\n", " return [\n", " p for (p, char), conn in zip(surrounded, self.connected_to) if char in conn\n", " ]\n", "\n", "\n", "def _nesw(pos: complex) -> t.Iterator[complex]:\n", " for dxy in (-1j, 1, 1j, -1):\n", " yield pos + dxy\n", "\n", "\n", "class PipeMap:\n", " def __init__(self, mapdescr: str) -> None:\n", " self.map = mapdescr.splitlines()\n", " self.start = next(\n", " x + y * 1j\n", " for y, line in enumerate(self.map)\n", " for x, c in enumerate(line)\n", " if c == \"S\"\n", " )\n", " self.shape = len(self.map[0]), len(self.map)\n", "\n", " def __getitem__(self, pos: complex) -> MapChar:\n", " x, y = map(int, (pos.real, pos.imag))\n", " if not (0 <= x < self.shape[0] and 0 <= y < self.shape[1]):\n", " return \".\"\n", " return t.cast(MapChar, self.map[y][x])\n", "\n", " def longest_path(self) -> int:\n", " positions = PipePiece.start.connects_to(\n", " *((p, self[p]) for p in _nesw(self.start))\n", " )\n", " seen: set[complex] = {self.start, *positions}\n", " # follow the pipes\n", " for distance in count(1):\n", " new_positions: list[complex] = []\n", " for pos in positions:\n", " piece = PipePiece(self[pos])\n", " connected_to = piece.connects_to(*((p, self[p]) for p in _nesw(pos)))\n", " if len(connected_to) == 1:\n", " # dead end, drop this position\n", " continue\n", " new_pos = next((p for p in connected_to if p not in seen), None)\n", " if new_pos is None:\n", " # we found the furthest point, as both ends have been visited now\n", " return distance\n", " new_positions.append(new_pos)\n", " positions = new_positions\n", " seen.update(positions)\n", "\n", " raise AssertionError(\"All pipes were dead ends\")\n", "\n", "\n", "test_maps = {\n", " \"\"\"\\\n", "-L|F7\n", "7S-7|\n", "L|7||\n", "-L-J|\n", "L|-JF\n", "\"\"\": 4,\n", " \"\"\"\\\n", "7-F7-\n", ".FJ|7\n", "SJLL7\n", "|F--J\n", "LJ.LJ\n", "\"\"\": 8,\n", "}\n", "\n", "for test_map, expected in test_maps.items():\n", " assert PipeMap(test_map).longest_path() == expected" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Part 1: 7063\n" ] } ], "source": [ "import aocd\n", "\n", "maptext = aocd.get_data(day=10, year=2023)\n", "print(\"Part 1:\", PipeMap(maptext).longest_path())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Let's just flood the pipes\n", "\n", "To start with, we need to get a set of all positions that are part of the pipe loop. We do so by keeping the positions of each pipe we follow, separately, and discard the sets of positions for the dead ends.\n", "\n", "Next, we need to [flood fill](https://en.wikipedia.org/wiki/Flood_fill) each area of connected empty positions, and determine for each such area if it is inside or outside the pipe. You can do this with a [Point in polygon](https://en.wikipedia.org/wiki/Point_in_polygon) algorithm. Simply follow a straight line from the starting point and count how many times you cross a pipe; if it is an odd number of crossings you are inside the pipe loop, otherwise you are outside of it.\n", "\n", "Counting the number of times you cross is straightforward for any straight pipe sections that are orthogonal to the direction of the line, and only slightly more complicated when you encounter a bend in the pipe or a straight section in the same direction as your line. For the latter, you'll only ever encounter two bends and the straight section between them. If the two bends at the ends point in the same direction, you are not crossing the pipe there, only following along the same edge. If the two bends point in opposite directions, you are crossing the pipe. Just imagine the ends not connected to the straigt part as a single pipe section that's orthogonal to your crossing line.\n", "\n", "E.g. when scanning from the test position (marked by `T`) to the right edge, you could encounter this:\n", "\n", "```\n", "T |F--JL--J|\n", "```\n", "\n", "There are 3 pipe sections there, two `|` orthogonal pipe sections, and the `F--J` and `L--J` sections. The first ends in bends that go off in oppossite directions, and the second with bends that both go up. So the first section you are crossing a pipe (equivalent to a `|` section) and the second one you are not.\n", "\n", "In the implementation below, I just took a substring from the map line corresponding to the remainder past the test position, replacing all characters on it that are not part of the pipe with `.`, removing all `-` sections (they are always flanked by bends), replacing each pair of oposite bends with a single `|`, and then just counting the number of `|` sections. To avoid having to figure out what kind of pipe section the `S` starting position obscures, I just take the section _before_ the test position if the `S` starting position is directly to the right.\n" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "from collections import deque\n", "from itertools import product\n", "\n", "\n", "class FloodedPipeMap(PipeMap):\n", " def loop_positions(self) -> set[complex]:\n", " positions = PipePiece.start.connects_to(\n", " *((p, self[p]) for p in _nesw(self.start))\n", " )\n", " seen: set[complex] = {self.start, *positions}\n", " pipes: list[tuple[complex, set[complex]]] = [(pos, {pos}) for pos in positions]\n", "\n", " # follow the pipes\n", " while pipes:\n", " new_pipes: list[tuple[complex, set[complex]]] = []\n", " for pos, pipe in pipes:\n", " piece = PipePiece(self[pos])\n", " connected_to = piece.connects_to(*((p, self[p]) for p in _nesw(pos)))\n", " if len(connected_to) == 1:\n", " # dead end, drop this position, remove their pipe positions from\n", " # the seen set.\n", " seen -= pipe\n", " continue\n", " new_pos = next((p for p in connected_to if p not in seen), None)\n", " if new_pos is not None:\n", " new_pipes.append((new_pos, pipe | {new_pos}))\n", " seen.add(new_pos)\n", " pipes = new_pipes\n", "\n", " return seen\n", "\n", " def _inside(self, loop: set[complex], pos: complex) -> bool:\n", " px, py = map(int, (pos.real, pos.imag))\n", " sx, sy = map(int, (self.start.real, self.start.imag))\n", " # take the section of map line to the east of the position, unless that crosses\n", " # the start position, in which case we take the section before it.\n", " mapline = (\n", " enumerate(self.map[py][:px])\n", " if py == sy and px < sx\n", " else enumerate(self.map[py][px + 1 :], px + 1)\n", " )\n", " # only take sections of pipe that are part of the loop, skipping\n", " # east-west pipe sections and empty spaces.\n", " line = \"\".join([c for x, c in mapline if x + py * 1j in loop and c not in \"-.\"])\n", " # remove parallel pipes and sections that are just bend ends, and replace zig-zags\n", " # with straight orthogonal pipes\n", " line = (\n", " line.replace(\"F7\", \"\")\n", " .replace(\"LJ\", \"\")\n", " .replace(\"FJ\", \"|\")\n", " .replace(\"L7\", \"|\")\n", " )\n", " return line.count(\"|\") % 2 == 1\n", "\n", " def enclosed_area(self) -> int:\n", " loop = self.loop_positions()\n", " width, height = self.shape\n", " empty: set[complex] = {\n", " p\n", " for x, y in product(range(width), range(height))\n", " if (p := complex(x, y)) not in loop\n", " }\n", " total_area = 0\n", " while empty:\n", " pos = empty.pop()\n", " area: set[complex] = set()\n", " todo = deque([pos])\n", " while todo:\n", " pos = todo.popleft()\n", " if pos in area:\n", " continue\n", " area.add(pos)\n", " todo.extend(p for p in _nesw(pos) if p in empty)\n", " if self._inside(loop, pos):\n", " total_area += len(area)\n", " empty -= area\n", "\n", " return total_area\n", "\n", "\n", "test_maps = {\n", " \"\"\"\\\n", "...........\n", ".S-------7.\n", ".|F-----7|.\n", ".||.....||.\n", ".||.....||.\n", ".|L-7.F-J|.\n", ".|..|.|..|.\n", ".L--J.L--J.\n", "...........\n", "\"\"\": 4,\n", " \"\"\"\\\n", "..........\n", ".S------7.\n", ".|F----7|.\n", ".||OOOO||.\n", ".||OOOO||.\n", ".|L-7F-J|.\n", ".|II||II|.\n", ".L--JL--J.\n", "..........\n", "\"\"\": 4,\n", " \"\"\"\\\n", ".F----7F7F7F7F-7....\n", ".|F--7||||||||FJ....\n", ".||.FJ||||||||L7....\n", "FJL7L7LJLJ||LJ.L-7..\n", "L--J.L7...LJS7F-7L7.\n", "....F-J..F7FJ|L7L7L7\n", "....L7.F7||L7|.L7L7|\n", ".....|FJLJ|FJ|F7|.LJ\n", "....FJL-7.||.||||...\n", "....L---J.LJ.LJLJ...\n", "\"\"\": 8,\n", " \"\"\"\\\n", "FF7FSF7F7F7F7F7F---7\n", "L|LJ||||||||||||F--J\n", "FL-7LJLJ||||||LJL-77\n", "F--JF--7||LJLJ7F7FJ-\n", "L---JF-JLJ.||-FJLJJ7\n", "|F|F-JF---7F7-L7L|7|\n", "|FFJF7L7F-JF7|JL---7\n", "7-L-JL7||F7|L7F-7F7|\n", "L.L7LFJ|||||FJL7||LJ\n", "L7JLJL-JLJLJL--JLJ.L\n", "\"\"\": 10,\n", "}\n", "\n", "for test_map, expected in test_maps.items():\n", " assert FloodedPipeMap(test_map).enclosed_area() == expected" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Part 2: 589\n" ] } ], "source": [ "print(\"Part 2:\", FloodedPipeMap(maptext).enclosed_area())" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.1" } }, "nbformat": 4, "nbformat_minor": 4 }