|
| 1 | +{ |
| 2 | + "cells": [ |
| 3 | + { |
| 4 | + "cell_type": "markdown", |
| 5 | + "metadata": {}, |
| 6 | + "source": [ |
| 7 | + "# On the range\n", |
| 8 | + "\n", |
| 9 | + "https://adventofcode.com/2023/day/5\n", |
| 10 | + "\n", |
| 11 | + "Today's puzzle is all about ranges. Don't store each possible individual value, because the puzzle input uses the full 32 bit unsingned integer range!\n", |
| 12 | + "\n", |
| 13 | + "Instead, store just a tuple of the source, the length and the destination values. If you keep the list of ranges sorted my their starting points, you can then use [bisection](https://docs.python.org/3/library/bisect.html) to quickly find a matching source range and verify that the mapped value falls inside the range. If it does, map the value to the destination, if it doesn't, return the original value.\n", |
| 14 | + "\n", |
| 15 | + "The implementation for part one not only returns the mapped value, but also how many value remain in the range that was used to map the source value to the destination. This is used in part two.\n" |
| 16 | + ] |
| 17 | + }, |
| 18 | + { |
| 19 | + "cell_type": "code", |
| 20 | + "execution_count": 1, |
| 21 | + "metadata": {}, |
| 22 | + "outputs": [], |
| 23 | + "source": [ |
| 24 | + "import typing as t\n", |
| 25 | + "from bisect import bisect\n", |
| 26 | + "from dataclasses import dataclass\n", |
| 27 | + "from operator import itemgetter\n", |
| 28 | + "\n", |
| 29 | + "\n", |
| 30 | + "@dataclass\n", |
| 31 | + "class AlmanacMap:\n", |
| 32 | + " from_: str\n", |
| 33 | + " to_: str\n", |
| 34 | + " ranges: list[tuple[int, int, int]]\n", |
| 35 | + "\n", |
| 36 | + " @classmethod\n", |
| 37 | + " def from_entry(cls, entry: str) -> t.Self:\n", |
| 38 | + " first, *lines = entry.splitlines()\n", |
| 39 | + " from_, _, to_ = first.partition(\" \")[0].partition(\"-to-\")\n", |
| 40 | + " ranges = [\n", |
| 41 | + " (int(src), int(length), int(dst))\n", |
| 42 | + " for dst, src, length in map(str.split, lines)\n", |
| 43 | + " ]\n", |
| 44 | + " return cls(from_, to_, sorted(ranges, key=itemgetter(0)))\n", |
| 45 | + "\n", |
| 46 | + " def __getitem__(self, value: int) -> tuple[int, int | None]:\n", |
| 47 | + " \"\"\"Map a value through the almanac table\n", |
| 48 | + "\n", |
| 49 | + " Returns the new value, and the remaining length of the source section it\n", |
| 50 | + " was mapped through, or None if the value lies outside the maximum value\n", |
| 51 | + " of the table.\n", |
| 52 | + "\n", |
| 53 | + " \"\"\"\n", |
| 54 | + " if (idx := bisect(self.ranges, value, key=itemgetter(0))) > 0:\n", |
| 55 | + " src, length, dst = self.ranges[idx - 1]\n", |
| 56 | + " if (offset := value - src) < length:\n", |
| 57 | + " return dst + offset, length - offset\n", |
| 58 | + " if idx < len(self.ranges):\n", |
| 59 | + " return value, self.ranges[idx][0] - value\n", |
| 60 | + " return value, None\n", |
| 61 | + "\n", |
| 62 | + "\n", |
| 63 | + "@dataclass\n", |
| 64 | + "class Almanac:\n", |
| 65 | + " seeds: list[int]\n", |
| 66 | + " maps: dict[str, AlmanacMap]\n", |
| 67 | + "\n", |
| 68 | + " @classmethod\n", |
| 69 | + " def from_entries(cls, *entries: str) -> t.Self:\n", |
| 70 | + " seeds_line, *entries = entries\n", |
| 71 | + " seeds = [int(seed) for seed in seeds_line.partition(\": \")[-1].split()]\n", |
| 72 | + " maps = {map_.from_: map_ for map_ in map(AlmanacMap.from_entry, entries)}\n", |
| 73 | + " return cls(seeds, maps)\n", |
| 74 | + "\n", |
| 75 | + " def __getitem__(self, seed: int) -> int:\n", |
| 76 | + " current = \"seed\"\n", |
| 77 | + " value = seed\n", |
| 78 | + " while current != \"location\":\n", |
| 79 | + " map_ = self.maps[current]\n", |
| 80 | + " current = map_.to_\n", |
| 81 | + " value, _ = map_[value]\n", |
| 82 | + " return value\n", |
| 83 | + "\n", |
| 84 | + "\n", |
| 85 | + "test_almanac_text = \"\"\"\\\n", |
| 86 | + "seeds: 79 14 55 13\n", |
| 87 | + "\n", |
| 88 | + "seed-to-soil map:\n", |
| 89 | + "50 98 2\n", |
| 90 | + "52 50 48\n", |
| 91 | + "\n", |
| 92 | + "soil-to-fertilizer map:\n", |
| 93 | + "0 15 37\n", |
| 94 | + "37 52 2\n", |
| 95 | + "39 0 15\n", |
| 96 | + "\n", |
| 97 | + "fertilizer-to-water map:\n", |
| 98 | + "49 53 8\n", |
| 99 | + "0 11 42\n", |
| 100 | + "42 0 7\n", |
| 101 | + "57 7 4\n", |
| 102 | + "\n", |
| 103 | + "water-to-light map:\n", |
| 104 | + "88 18 7\n", |
| 105 | + "18 25 70\n", |
| 106 | + "\n", |
| 107 | + "light-to-temperature map:\n", |
| 108 | + "45 77 23\n", |
| 109 | + "81 45 19\n", |
| 110 | + "68 64 13\n", |
| 111 | + "\n", |
| 112 | + "temperature-to-humidity map:\n", |
| 113 | + "0 69 1\n", |
| 114 | + "1 0 69\n", |
| 115 | + "\n", |
| 116 | + "humidity-to-location map:\n", |
| 117 | + "60 56 37\n", |
| 118 | + "56 93 4\n", |
| 119 | + "\"\"\"\n", |
| 120 | + "test_almanac = Almanac.from_entries(*test_almanac_text.split(\"\\n\\n\"))\n", |
| 121 | + "assert min(test_almanac[seed] for seed in test_almanac.seeds) == 35" |
| 122 | + ] |
| 123 | + }, |
| 124 | + { |
| 125 | + "cell_type": "code", |
| 126 | + "execution_count": 2, |
| 127 | + "metadata": {}, |
| 128 | + "outputs": [ |
| 129 | + { |
| 130 | + "name": "stdout", |
| 131 | + "output_type": "stream", |
| 132 | + "text": [ |
| 133 | + "Part 1: 382895070\n" |
| 134 | + ] |
| 135 | + } |
| 136 | + ], |
| 137 | + "source": [ |
| 138 | + "import aocd\n", |
| 139 | + "\n", |
| 140 | + "almanac = Almanac.from_entries(*aocd.get_data(day=5, year=2023).split(\"\\n\\n\"))\n", |
| 141 | + "print(\"Part 1:\", min(almanac[seed] for seed in almanac.seeds))" |
| 142 | + ] |
| 143 | + }, |
| 144 | + { |
| 145 | + "cell_type": "markdown", |
| 146 | + "metadata": {}, |
| 147 | + "source": [ |
| 148 | + "# The green revolution is here!\n", |
| 149 | + "\n", |
| 150 | + "Part 2 just scales up part one. Luckily we are already using bisection to handle the lookups, it's the fastest way to handle any given seed lookup!\n", |
| 151 | + "\n", |
| 152 | + "However, there are still an _awful lot of seeds_ to process here. The total length of my puzzle input seed ranges covers more than 2 billion values. Even if you can map a given seed value to its location in 1 microsecond, it would still take about 40 minutes to map this much seed to locations.\n", |
| 153 | + "\n", |
| 154 | + "Instead of mapping individual values, we could map ranges; any given range might need to be split up by each map as they won't all be using the same mapping entries, but the splitting can be done entirely based on the lengths of the source ranges. This cuts down the amount of work significantly.\n", |
| 155 | + "\n", |
| 156 | + "I first refactored the code for part one to not only return the mapped value, but also the remaining length in the source range. The extra return value is not used anywhere else in part one, but in part two we can use this to then split up source ranges. The almanac then only has to return the start value of the smallest range after mapping.\n" |
| 157 | + ] |
| 158 | + }, |
| 159 | + { |
| 160 | + "cell_type": "code", |
| 161 | + "execution_count": 3, |
| 162 | + "metadata": {}, |
| 163 | + "outputs": [], |
| 164 | + "source": [ |
| 165 | + "from collections import deque\n", |
| 166 | + "\n", |
| 167 | + "\n", |
| 168 | + "class RangeAlmanacMap(AlmanacMap):\n", |
| 169 | + " def __getitem__(self, values: tuple[range, ...]) -> tuple[range, ...]:\n", |
| 170 | + " results = []\n", |
| 171 | + " queue = deque(values)\n", |
| 172 | + " while queue:\n", |
| 173 | + " value = queue.popleft()\n", |
| 174 | + " size = len(value)\n", |
| 175 | + " dst, remainder = super().__getitem__(value.start)\n", |
| 176 | + " if remainder and size > remainder:\n", |
| 177 | + " # process the section that doesn't fit\n", |
| 178 | + " queue.append(value[remainder:])\n", |
| 179 | + " size = remainder\n", |
| 180 | + " # map the part of the range that fits\n", |
| 181 | + " results.append(range(dst, dst + size))\n", |
| 182 | + " return tuple(results)\n", |
| 183 | + "\n", |
| 184 | + "\n", |
| 185 | + "@dataclass\n", |
| 186 | + "class RangeAlmanac(Almanac):\n", |
| 187 | + " maps: dict[str, RangeAlmanacMap]\n", |
| 188 | + "\n", |
| 189 | + " @classmethod\n", |
| 190 | + " def from_entries(cls, *entries: str) -> t.Self:\n", |
| 191 | + " inst = super().from_entries(*entries)\n", |
| 192 | + " inst.maps = {\n", |
| 193 | + " to_: RangeAlmanacMap(**vars(map_)) for to_, map_ in inst.maps.items()\n", |
| 194 | + " }\n", |
| 195 | + " return inst\n", |
| 196 | + "\n", |
| 197 | + " def __getitem__(self, values: tuple[range, ...]) -> int:\n", |
| 198 | + " current = \"seed\"\n", |
| 199 | + " while current != \"location\":\n", |
| 200 | + " map_ = self.maps[current]\n", |
| 201 | + " current = map_.to_\n", |
| 202 | + " values = map_[values]\n", |
| 203 | + " return min(v.start for v in values)\n", |
| 204 | + "\n", |
| 205 | + "\n", |
| 206 | + "def seed_ranges(*seeds: int) -> t.Iterator[range]:\n", |
| 207 | + " it = iter(seeds)\n", |
| 208 | + " for start, length in zip(it, it):\n", |
| 209 | + " yield range(start, start + length)\n", |
| 210 | + "\n", |
| 211 | + "\n", |
| 212 | + "test_almanac = RangeAlmanac.from_entries(*test_almanac_text.split(\"\\n\\n\"))\n", |
| 213 | + "assert test_almanac[tuple(seed_ranges(*test_almanac.seeds))] == 46" |
| 214 | + ] |
| 215 | + }, |
| 216 | + { |
| 217 | + "cell_type": "code", |
| 218 | + "execution_count": 4, |
| 219 | + "metadata": {}, |
| 220 | + "outputs": [ |
| 221 | + { |
| 222 | + "name": "stdout", |
| 223 | + "output_type": "stream", |
| 224 | + "text": [ |
| 225 | + "Part 2: 17729182\n" |
| 226 | + ] |
| 227 | + } |
| 228 | + ], |
| 229 | + "source": [ |
| 230 | + "almanac = RangeAlmanac.from_entries(*aocd.get_data(day=5, year=2023).split(\"\\n\\n\"))\n", |
| 231 | + "print(\"Part 2:\", almanac[tuple(seed_ranges(*almanac.seeds))])" |
| 232 | + ] |
| 233 | + } |
| 234 | + ], |
| 235 | + "metadata": { |
| 236 | + "kernelspec": { |
| 237 | + "display_name": "adventofcode-bRnAxXn--py3.12", |
| 238 | + "language": "python", |
| 239 | + "name": "python3" |
| 240 | + }, |
| 241 | + "language_info": { |
| 242 | + "codemirror_mode": { |
| 243 | + "name": "ipython", |
| 244 | + "version": 3 |
| 245 | + }, |
| 246 | + "file_extension": ".py", |
| 247 | + "mimetype": "text/x-python", |
| 248 | + "name": "python", |
| 249 | + "nbconvert_exporter": "python", |
| 250 | + "pygments_lexer": "ipython3", |
| 251 | + "version": "3.12.0" |
| 252 | + } |
| 253 | + }, |
| 254 | + "nbformat": 4, |
| 255 | + "nbformat_minor": 2 |
| 256 | +} |
0 commit comments