From df413562a4fc27063f932c231e72a04843850dbb Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Tue, 2 Apr 2024 11:37:46 -0700 Subject: [PATCH] CodingQuest: use indexed days; split 2023 into its own file --- 2023/12.py | 4 +- 2023/16.py | 4 + 2023/17.py | 2 +- 2023/notes.md | 93 ++++++++++- codingquest/2022/solve.py | 263 +----------------------------- codingquest/2023/solve.py | 329 ++++++++++++++++++++++++++++++++++++++ codingquest/2024/solve.py | 49 +++--- pylib/parsers.py | 1 + 8 files changed, 452 insertions(+), 293 deletions(-) create mode 100755 codingquest/2023/solve.py diff --git a/2023/12.py b/2023/12.py index ff18abb..66e88f3 100755 --- a/2023/12.py +++ b/2023/12.py @@ -43,6 +43,8 @@ def ways_to_fit(self, springs: tuple[str, ...], numbers: tuple[int, ...]) -> int # The first group is too small but must match. This is not a match. return 0 + first_group = springs[0] + first_num = numbers[0] count = 0 # Count possible matches with the first ? being a . if springs[0].startswith("?"): @@ -50,8 +52,6 @@ def ways_to_fit(self, springs: tuple[str, ...], numbers: tuple[int, ...]) -> int count += self.ways_to_fit(skip_start, numbers) # Count possible matches with the first ? being a # - first_group = springs[0] - first_num = numbers[0] if len(first_group) == first_num: count += self.ways_to_fit(springs[1:], numbers[1:]) elif len(first_group) > first_num and first_group[first_num] == "?": diff --git a/2023/16.py b/2023/16.py index cd22d2b..2f2e84d 100755 --- a/2023/16.py +++ b/2023/16.py @@ -81,18 +81,22 @@ def part2(self, parsed_input: dict[complex, str]) -> int: min_x, min_y, max_x, max_y = aoc.bounding_coords(parsed_input) return max( + # Left edge. max( self.energized(parsed_input, complex(min_x - 1, y), RIGHT) for y in range(min_y, max_y + 1) ), + # Right edge. max( self.energized(parsed_input, complex(max_x + 1, y), LEFT) for y in range(min_y, max_y + 1) ), + # Top. max( self.energized(parsed_input, complex(x, min_y - 1), DOWN) for x in range(min_x, max_x + 1) ), + # Bottom. max( self.energized(parsed_input, complex(x, max_y + 1), UP) for x in range(min_x, max_x + 1) diff --git a/2023/17.py b/2023/17.py index 5a1a13f..0b46187 100755 --- a/2023/17.py +++ b/2023/17.py @@ -39,7 +39,7 @@ class Day17(aoc.Challenge): aoc.TestCase(inputs=SAMPLE[1], part=2, want=71), ] PARAMETERIZED_INPUTS = [False, True] - INPUT_PARSER = aoc.parse_ascii_char_map(int) + INPUT_PARSER = aoc.int_map def solver(self, parsed_input: InputType, param: bool) -> int: """Return the minimum heat loss from start to end.""" diff --git a/2023/notes.md b/2023/notes.md index 0aaeeb8..5b949b5 100644 --- a/2023/notes.md +++ b/2023/notes.md @@ -382,13 +382,30 @@ Once I resolved the off-by-one, this was pretty straight forward. Exercise: count how many ways the unknowns can be resolved into a spring or gap to make the needed groups. Part two: the input is larger. +## Approach + +For every `?`, count the ways things can fit assuming the `?` is a `#` plus the ways things can fit assuming it is a `.`. +Memoization helps a lot here; thanks, `functools`! + +Pruning groups and failing fast helps a lot here. +* When there are no numbers left and no `#` left, there is exactly one fit. +* When there are no numbers left and there are `#` left, there is no way to fit. +* When there are no springs left (but there are still numbers), there is no way to fit. +* Leading groups of `???` with lengths smaller than the first spring group size can be dropped. +* Leading groups containing a `#` but smaller than the first spring group size means there is no way to fit. +* If the leading group length matches the first group number, there is no need to swap just one `?` for a `#`; pair the first group with the first number. + +## Notes + I explored a number of approaches the proved futile. I didn't solve this until five days later on my third approach. My failed approaches tried too hard to be too clever and preemptively prune. They were all riddled with errors and the pruning proved unneeded. See the git history for all the ways I failed to solve this. -## Approach +When I did finally get this working, my code still had a bunch of premature optimizations that didn't actually help and later got removed. + +I wrote a helper `@printio` decorator to print the inputs and outputs of function calls. # Day 13 @@ -450,14 +467,32 @@ It would be nice to find a fast way to dedupe that code. # Day 15 +Part one: implement a hashing algorithm. +Part two: use the hash to track per-bucket collections of values with updates. + +## Notes + Reading and understanding the ask today was a challenge. +Solving it, not as much. +Python's ordered dict helps a lot here. # Day 16 +Part one: compute the path(s) of a beam of light as it bounces and splits around a maze of mirrors. +Part two: compute the starting location which yields the longest path. + +## Approach + +I solved this using a `dict[complex, str]`. +I used some rather lengthly `if-elif` lookup blocks to handle all the cases, which makes for messy and bug prone code, but runs decently fast. +Part two was decently easy, but runs slowly (8s), by brute forcing things along. + +## Notes + My part 1 solution was decently fast to pass the example. However, it took me the longest time to figure out why it failed the real input. -I seeded my data with a beam at `(0, 0) RIGHT` then loop where I examine the next time. -This was fine in the example with `(0, 0)` is empty but the real data has a reflector at `(0, 0)` which I skipped. +I seeded my data with a beam at `(0, 0) RIGHT` then loop where I examine the next cell. +This was fine in the example where `(0, 0)` is empty but the real data has a reflector at `(0, 0)` which my logic skipped right over. The massive logic block could be changed to a dictionary lookup but that bumps runtime from 8s to 11s. @@ -475,27 +510,71 @@ DIR_IN_TO_OUT = { It might be possible to speed things up by memoizing energized cells starting from a specific element but I had difficulties making that work. +This input made me update my template to better handle `\` chars. + # Day 17 +Part one: solve for the minimum cost path (heat loss) from start to end. +There are fun constraints on which branches may be taken. +Part two: those constraints change a bit and are slightly more complex. + +## Approach + +This is basically a "implement shortest path". +The constraints translate into pruning steps and require additional tracking data (steps since last turn). +I solved this with Djisktra. +I initially used A\* with the Manhatten distance as the heuristic but it seems to not do much better than Djisktra. + +## Notes + * `PriorityQueue` and `complex` don't mix so I had to scramble slightly to rewrite everything from `complex` to `tuple[int, int]`. -* I initially used A\* with the Manhatten distance as the heuristic but it seems to not actually help. # Day 18 +Part one: given directions and distances used to build a loop, compute the loop-enclosed area. +Part two: the loop is much larger. + +## Approach + For part 1, I used a flood fill for the interior and added up the perimeter as I explored it. +I seeded this with `(1, 1)` which I assume is always inside the loop. + +Note, the shoelace formula can help here. + +This is one of the few days where I used regex for parsing (in a follow up change). ## Part 2 I realized pretty quickly that I'd need a scanline here. + I thought I could order by start/end y-coordinates and collect active ranges, but I confused myself then decided not to bother. -Instead, I went the slower route of computing all the y-changes and, for each y-value, I computed which lines are relevant. +Instead, I initially went the slower route of computing all the y-changes and, for each y-value, I computed which lines are relevant. The line count was small enough that this was fine. -I got most the way to the end but got stuck for a good long while trying to figure out how to properly account for the perimeter. +I did eventually go back and fix this up. + +I got most the way to the end but got stuck for a good long while trying to figure out how to properly account for the perimeter/block edge overlaps. + +Once I got part two working, I was able to use the same solution for both parts. # Day 19 -I had a silly mistake in part two that took me a good half hour or so to figure out. +Exercise: parse and walk rule trees. +Part one: for each item, evalute the rules and count the items accepted vs rejected. +Part two: determine the total number of items that the rules accept vs reject. + +## Approach + +Part one is simply a matter of walking the rule tree for each items and checking if an item is accepted or not. +Part two took me a bit of time to wrap my head around the fact that each rule sub-divides the remaining accepted block into two pieces. +Once I figured that out, combined with recursion, I was able to generate 4-dimensional interval blocks which are accepted. + +## Notes + +The accepted constraints generated by part two can be used to check items in part one. +However, both parts run fast so that isn't needed. + +I had a silly mistake in part two that took me a good half hour or so to figure out. Compare, ```python diff --git a/codingquest/2022/solve.py b/codingquest/2022/solve.py index fa4ae9e..eaf2b3e 100755 --- a/codingquest/2022/solve.py +++ b/codingquest/2022/solve.py @@ -479,253 +479,6 @@ def huffman_decode(data: str) -> str: return "".join(out) -def inventory_check(data: str) -> int: - """18: Sum up inventory values. """ - counts: dict[str, int] = collections.defaultdict(int) - for line in data.splitlines(): - _, count, category = line.split() - counts[category] += int(count) - return math.prod(count % 100 for count in counts.values()) - - -def navigation_sensor(data: str) -> int: - """19: Apply parity checks.""" - parity_mask = 1 << 15 # 0x8000 - value_mask = parity_mask - 1 # 0x7FFF - - values = [] - for line in data.splitlines(): - number = int(line) - parity, value = bool(parity_mask & number), (value_mask & number) - if value.bit_count() % 2 == parity: - values.append(value) - return round(statistics.mean(values)) - - -def tic_tac_toe(data: str) -> int: - """20: Score tic tac toe. 20938290.""" - # Game configuration. - player_count, size = 2, 3 - # All possible ways to win. - lines = [ # Horizontal - set(range(i, i + size * size, size)) for i in range(size) - ] - lines.extend( # Vertical - set(range(i, i + size)) for i in range(0, size * size, size) - ) - lines.append(set(range(0, size * size, size + 1))) # Diagonal - lines.append(set(range(size - 1, size * size - 1, size - 1))) # Diagonal - # Outcome counters. - wins = {player: 0 for player in range(player_count)} - draws = 0 - # Game logic. - for line in data.splitlines(): - positions: dict[int, set[int]] = {player: set() for player in range(player_count)} - for turn, move in enumerate(line.split()): - positions[turn % player_count].add(int(move) - 1) - if any(positions[turn % player_count].issuperset(line) for line in lines): - wins[turn % player_count] += 1 - break - else: - draws += 1 - return math.prod(wins.values()) * draws - - -def packet_parsing(data: str) -> str: - """21: Parsing a byte stream.""" - packets = collections.defaultdict(list) - for line in data.splitlines(): - stream = iter(line) - - def read_n(n: int) -> int: - return int("".join(next(stream) for _ in range(2 * n)), 16) - - header = read_n(2) - if header != 0x5555: - continue - sender = read_n(4) - sequence_number = read_n(1) - checksum = read_n(1) - message = [read_n(1) for _ in range(24)] - if checksum != sum(message) % 256: - continue - packets[sender].append((sequence_number, message)) - - message = [c for _, segment in sorted(list(packets.values())[0]) for c in segment] - return "".join(chr(i) for i in message) - - -def overlapping_rectangles(data: str) -> str: - """22: Toggle rectangles on and off, brute force.""" - grid = [[True] * 50 for _ in range(10)] - for line in data.splitlines(): - xstart, ystart, width, height = (int(i) for i in line.split()) - for ypos in range(ystart, ystart + height): - for xpos in range(xstart, xstart + width): - grid[ypos][xpos] = not grid[ypos][xpos] - return "\n".join( - "".join(COLOR_EMPTY if col else COLOR_SOLID for col in row) - for row in grid - ) - - -def astroid_field(data: str) -> str: - """23: Find the gap in the astroid field.""" - filled = set() - size, offset, duration = 100, 60 * 60, 60 - for line in data.splitlines(): - xstart, ystart, xspeed, yspeed = (float(i) for i in line.split()) - for steps in range(duration): - seconds = offset + steps - xpos = int(xstart + xspeed * seconds) - ypos = int(ystart + yspeed * seconds) - if 0 <= xpos < size and 0 <= ypos < size: - filled.add((xpos, ypos)) - for xpos in range(size): - for ypos in range(size): - if (xpos, ypos) not in filled: - return f"{xpos}:{ypos}" - raise RuntimeError("Not solved.") - - -def snake(data: str) -> int: - """24: Play the snake game.""" - # Parse the inputs, set up data. - lines = data.splitlines() - fruit_line, move_line = lines[1], lines[3] - directions = {"U": complex(0, -1), "D": complex(0, 1), "L": complex(-1, 0), "R": complex(1, 0)} - moves = (directions[i] for i in move_line) - - def to_complex(pair: str) -> complex: - x, y = pair.split(",") - return complex(int(x), int(y)) - - fruits = (to_complex(pair) for pair in fruit_line.split()) - # Initialize game state. - head = complex(0, 0) - cur_fruit = next(fruits) - body: collections.deque[complex] = collections.deque() - body.append(head) - score = 0 - # Play the game. - for move in moves: - head = head + move - if head in body or any(not 0 <= i < 20 for i in (head.real, head.imag)): - return score - score += 1 - body.append(head) - if head == cur_fruit: - score += 100 - cur_fruit = next(fruits) - else: - body.popleft() - raise RuntimeError("No move moves left.") - - -def traveling_salesman(data: str) -> int: - """25: Brute force the traveling salesman.""" - # Create the cost matrix. - distances = { - start: { - end: int(value) - for end, value in enumerate(line.split()) - } - for start, line in enumerate(data.splitlines()) - } - - @functools.cache - def solve(current, to_visit): - """Return the minimum cost of visiting all nodes starting at a given node.""" - # Add the cost to return to the start after all nodes are visited. - if len(to_visit) == 0: - return distances[current][0] - # Return the minimum cost of visiting all nodes with each option as a candidate. - return min( - distances[current][candidate] + solve(candidate, frozenset(to_visit - {candidate})) - for candidate in to_visit - ) - - return solve(0, frozenset(set(distances) - {0})) - - -def binary_tree_shape(data: str) -> int: - """26: Compute the shape of a binary tree.""" - - @dataclasses.dataclass - class BTSNode: - """Binary Tree Shape node.""" - - value: int - children: list[Optional[BTSNode]] = dataclasses.field(default_factory=lambda: [None] * 2) - - def insert(self, value: int) -> None: - """Insert a value into the tree.""" - side = 0 if value < self.value else 1 - node = self[side] - if node is None: - self[side] = value - else: - node.insert(value) - - def depth(self) -> int: - """Return the depth of the tree.""" - return 1 + max( - 0 if child is None else child.depth() - for child in self.children - ) - - def __getitem__(self, key: int) -> Optional[BTSNode]: - return self.children[key] - - def __setitem__(self, key: int, value: int) -> None: - self.children[key] = BTSNode(value) - - def __hash__(self) -> int: - return hash(self.value) - - # Initialize the tree with a 0 value root then fill it. - tree = BTSNode(0) - for line in data.splitlines(): - tree.insert(int(line, 16)) - - # BFS to get the width at each level. - nodes = {tree} - max_width = 0 - while nodes: - max_width = max(max_width, len(nodes)) - nodes = {i for n in nodes for i in n.children if i} - - return (tree.depth() - 1) * max_width - - -def shortest_path(data: str) -> int: - """27: Find the shortest path through a graph.""" - wait_time = 600 - distances = {} - for line in data.splitlines(): - src, _, *dests = line.split() - distances[src] = { - dest_dist[:3]: int(dest_dist[4:]) - for dest_dist in dests - } - - todo: queue.PriorityQueue[tuple[int, str]] = queue.PriorityQueue() - min_at = {"TYC": 0} - todo.put((0, "TYC")) - while not todo.empty(): - duration, position = todo.get() - if position == "EAR": - break - for dest, dist in distances[position].items(): - cost = duration + wait_time + dist - if dest not in min_at or cost < min_at[dest]: - todo.put((cost, dest)) - min_at[dest] = cost - - # No need to wait at EAR - return duration - wait_time - - FUNCS = { 1: rolling_average, 2: lotto_winnings, @@ -742,24 +495,16 @@ def shortest_path(data: str) -> int: 15: astroid_sizes, 16: checksums, 17: huffman_decode, - 18: inventory_check, - 19: navigation_sensor, - 20: tic_tac_toe, - 21: packet_parsing, - 22: overlapping_rectangles, - 23: astroid_field, - 24: snake, - 25: traveling_salesman, - 26: binary_tree_shape, - 27: shortest_path, } @click.command() -@click.option("--day", type=int, required=True) +@click.option("--day", type=int, required=False) @click.option("--data", type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path)) -def main(day: int, data: Optional[pathlib.Path]): +def main(day: int | None, data: Optional[pathlib.Path]): """Run the solver for a specific day.""" + if not day: + day = max(FUNCS) if day not in FUNCS: print(f"Day {day:02} not solved.") return diff --git a/codingquest/2023/solve.py b/codingquest/2023/solve.py new file mode 100755 index 0000000..fb5df8c --- /dev/null +++ b/codingquest/2023/solve.py @@ -0,0 +1,329 @@ +#!/bin/python +"""CodingQuest.io solver.""" +from __future__ import annotations + +import collections +import dataclasses +import functools +import math +import queue +import pathlib +import re +import statistics +import time +from typing import Optional + +import click +import requests # type: ignore + +DAY_OFFSET = 17 +# Regex used to detect an interger (positive or negative). +NUM_RE = re.compile("-?[0-9]+") +INPUT_ENDPOINT = "https://codingquest.io/api/puzzledata" +COLOR_SOLID = '█' +COLOR_EMPTY = ' ' + + +def inventory_check(data: str) -> int: + """1: Sum up inventory values. """ + counts: dict[str, int] = collections.defaultdict(int) + for line in data.splitlines(): + _, count, category = line.split() + counts[category] += int(count) + return math.prod(count % 100 for count in counts.values()) + + +def navigation_sensor(data: str) -> int: + """2: Apply parity checks.""" + parity_mask = 1 << 15 # 0x8000 + value_mask = parity_mask - 1 # 0x7FFF + + values = [] + for line in data.splitlines(): + number = int(line) + parity, value = bool(parity_mask & number), (value_mask & number) + if value.bit_count() % 2 == parity: + values.append(value) + return round(statistics.mean(values)) + + +def tic_tac_toe(data: str) -> int: + """3: Score tic tac toe. 20938290.""" + # Game configuration. + player_count, size = 2, 3 + # All possible ways to win. + lines = [ # Horizontal + set(range(i, i + size * size, size)) for i in range(size) + ] + lines.extend( # Vertical + set(range(i, i + size)) for i in range(0, size * size, size) + ) + lines.append(set(range(0, size * size, size + 1))) # Diagonal + lines.append(set(range(size - 1, size * size - 1, size - 1))) # Diagonal + # Outcome counters. + wins = {player: 0 for player in range(player_count)} + draws = 0 + # Game logic. + for line in data.splitlines(): + positions: dict[int, set[int]] = {player: set() for player in range(player_count)} + for turn, move in enumerate(line.split()): + positions[turn % player_count].add(int(move) - 1) + if any(positions[turn % player_count].issuperset(line) for line in lines): + wins[turn % player_count] += 1 + break + else: + draws += 1 + return math.prod(wins.values()) * draws + + +def packet_parsing(data: str) -> str: + """4: Parsing a byte stream.""" + packets = collections.defaultdict(list) + for line in data.splitlines(): + stream = iter(line) + + def read_n(n: int) -> int: + return int("".join(next(stream) for _ in range(2 * n)), 16) + + header = read_n(2) + if header != 0x5555: + continue + sender = read_n(4) + sequence_number = read_n(1) + checksum = read_n(1) + message = [read_n(1) for _ in range(24)] + if checksum != sum(message) % 256: + continue + packets[sender].append((sequence_number, message)) + + message = [c for _, segment in sorted(list(packets.values())[0]) for c in segment] + return "".join(chr(i) for i in message) + + +def overlapping_rectangles(data: str) -> str: + """5: Toggle rectangles on and off, brute force.""" + grid = [[True] * 50 for _ in range(10)] + for line in data.splitlines(): + xstart, ystart, width, height = (int(i) for i in line.split()) + for ypos in range(ystart, ystart + height): + for xpos in range(xstart, xstart + width): + grid[ypos][xpos] = not grid[ypos][xpos] + return "\n".join( + "".join(COLOR_EMPTY if col else COLOR_SOLID for col in row) + for row in grid + ) + + +def astroid_field(data: str) -> str: + """6: Find the gap in the astroid field.""" + filled = set() + size, offset, duration = 100, 60 * 60, 60 + for line in data.splitlines(): + xstart, ystart, xspeed, yspeed = (float(i) for i in line.split()) + for steps in range(duration): + seconds = offset + steps + xpos = int(xstart + xspeed * seconds) + ypos = int(ystart + yspeed * seconds) + if 0 <= xpos < size and 0 <= ypos < size: + filled.add((xpos, ypos)) + for xpos in range(size): + for ypos in range(size): + if (xpos, ypos) not in filled: + return f"{xpos}:{ypos}" + raise RuntimeError("Not solved.") + + +def snake(data: str) -> int: + """7: Play the snake game.""" + # Parse the inputs, set up data. + lines = data.splitlines() + fruit_line, move_line = lines[1], lines[3] + directions = {"U": complex(0, -1), "D": complex(0, 1), "L": complex(-1, 0), "R": complex(1, 0)} + moves = (directions[i] for i in move_line) + + def to_complex(pair: str) -> complex: + x, y = pair.split(",") + return complex(int(x), int(y)) + + fruits = (to_complex(pair) for pair in fruit_line.split()) + # Initialize game state. + head = complex(0, 0) + cur_fruit = next(fruits) + body: collections.deque[complex] = collections.deque() + body.append(head) + score = 0 + # Play the game. + for move in moves: + head = head + move + if head in body or any(not 0 <= i < 20 for i in (head.real, head.imag)): + return score + score += 1 + body.append(head) + if head == cur_fruit: + score += 100 + cur_fruit = next(fruits) + else: + body.popleft() + raise RuntimeError("No move moves left.") + + +def traveling_salesman(data: str) -> int: + """8: Brute force the traveling salesman.""" + # Create the cost matrix. + distances = { + start: { + end: int(value) + for end, value in enumerate(line.split()) + } + for start, line in enumerate(data.splitlines()) + } + + @functools.cache + def solve(current, to_visit): + """Return the minimum cost of visiting all nodes starting at a given node.""" + # Add the cost to return to the start after all nodes are visited. + if len(to_visit) == 0: + return distances[current][0] + # Return the minimum cost of visiting all nodes with each option as a candidate. + return min( + distances[current][candidate] + solve(candidate, frozenset(to_visit - {candidate})) + for candidate in to_visit + ) + + return solve(0, frozenset(set(distances) - {0})) + + +def binary_tree_shape(data: str) -> int: + """9: Compute the shape of a binary tree.""" + + @dataclasses.dataclass + class BTSNode: + """Binary Tree Shape node.""" + + value: int + children: list[Optional[BTSNode]] = dataclasses.field(default_factory=lambda: [None] * 2) + + def insert(self, value: int) -> None: + """Insert a value into the tree.""" + side = 0 if value < self.value else 1 + node = self[side] + if node is None: + self[side] = value + else: + node.insert(value) + + def depth(self) -> int: + """Return the depth of the tree.""" + return 1 + max( + 0 if child is None else child.depth() + for child in self.children + ) + + def __getitem__(self, key: int) -> Optional[BTSNode]: + return self.children[key] + + def __setitem__(self, key: int, value: int) -> None: + self.children[key] = BTSNode(value) + + def __hash__(self) -> int: + return hash(self.value) + + # Initialize the tree with a 0 value root then fill it. + tree = BTSNode(0) + for line in data.splitlines(): + tree.insert(int(line, 16)) + + # BFS to get the width at each level. + nodes = {tree} + max_width = 0 + while nodes: + max_width = max(max_width, len(nodes)) + nodes = {i for n in nodes for i in n.children if i} + + return (tree.depth() - 1) * max_width + + +def shortest_path(data: str) -> int: + """10: Find the shortest path through a graph.""" + wait_time = 600 + distances = {} + for line in data.splitlines(): + src, _, *dests = line.split() + distances[src] = { + dest_dist[:3]: int(dest_dist[4:]) + for dest_dist in dests + } + + todo: queue.PriorityQueue[tuple[int, str]] = queue.PriorityQueue() + min_at = {"TYC": 0} + todo.put((0, "TYC")) + while not todo.empty(): + duration, position = todo.get() + if position == "EAR": + break + for dest, dist in distances[position].items(): + cost = duration + wait_time + dist + if dest not in min_at or cost < min_at[dest]: + todo.put((cost, dest)) + min_at[dest] = cost + + # No need to wait at EAR + return duration - wait_time + + +FUNCS = [ + inventory_check, + navigation_sensor, + tic_tac_toe, + packet_parsing, + overlapping_rectangles, + astroid_field, + snake, + traveling_salesman, + binary_tree_shape, + shortest_path, +] + + +@click.command() +@click.option("--day", type=int, required=False) +@click.option("--data", type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path)) +def main(day: int | None, data: Optional[pathlib.Path]): + """Run the solver for a specific day.""" + if not day: + day = len(FUNCS) + if day > len(FUNCS): + print(f"Day {day:02} not solved.") + return + if data: + files = [data] + else: + input_file = pathlib.Path(f"input/{day:02}.txt") + if not input_file.exists(): + response = requests.get(INPUT_ENDPOINT, params={"puzzle": f"{day + DAY_OFFSET:02}"}) + response.raise_for_status() + input_file.write_text(response.text) + files = [input_file] + for file in files: + if not file.exists(): + print(f"{file} does not exist.") + continue + start = time.perf_counter_ns() + got = FUNCS[day - 1](file.read_text().rstrip()) + if isinstance(got, str) and "\n" in got: + got = "\n" + got + end = time.perf_counter_ns() + delta = end - start + units = ["ns", "us", "ms", "s"] + unit = "" + for i in units: + unit = i + if delta < 1000: + break + delta //= 1000 + + print(f"Day {day:02} ({delta:4}{unit:<2}, {str(file):15}): {got}") + + +if __name__ == "__main__": + main() diff --git a/codingquest/2024/solve.py b/codingquest/2024/solve.py index d20bd61..ba6bccc 100755 --- a/codingquest/2024/solve.py +++ b/codingquest/2024/solve.py @@ -17,6 +17,7 @@ import more_itertools import requests # type: ignore +DAY_OFFSET = 27 # Regex used to detect an interger (positive or negative). NUM_RE = re.compile("-?[0-9]+") INPUT_ENDPOINT = "https://codingquest.io/api/puzzledata" @@ -25,7 +26,7 @@ def purchase_tickets(data: str) -> int: - """Day 28: Return the cheapest airline cost.""" + """Day 1: Return the cheapest airline cost.""" pattern = re.compile(r"(\S+): (\S+) (\d+)") negative = ("Rebate", "Discount") costs: dict[str, int] = collections.defaultdict(int) @@ -38,7 +39,7 @@ def purchase_tickets(data: str) -> int: def broken_firewall(data: str) -> str: - """Day 29.""" + """Day 2.""" ranges = ( (ipaddress.IPv4Address("192.168.0.0"), ipaddress.IPv4Address("192.168.254.254")), (ipaddress.IPv4Address("10.0.0.0"), ipaddress.IPv4Address("10.0.254.254")), @@ -58,7 +59,7 @@ def broken_firewall(data: str) -> str: def hotel_door_code(data: str) -> str: - """Day 30.""" + """Day 3.""" pixels = [ pixel for pixel, chunk in zip(itertools.cycle([COLOR_EMPTY, COLOR_SOLID]), data.split()) @@ -68,7 +69,7 @@ def hotel_door_code(data: str) -> str: def closest_star_systems(data: str) -> float: - """Day 31.""" + """Day 4.""" coords = { tuple(float(i) for i in line.split()[-3:]) for line in data.splitlines()[1:] @@ -82,7 +83,7 @@ def closest_star_systems(data: str) -> float: def busy_moon_rovers(data: str) -> int: - """Day 32: Compute total distance travelled.""" + """Day 5: Compute total distance travelled.""" chart, log = (i.splitlines() for i in data.split("\n\n")) dsts = chart[0].split() graph = {} @@ -100,7 +101,7 @@ def busy_moon_rovers(data: str) -> int: def playfair(data: str) -> str: - """Day 33: Playfair Cipher.""" + """Day 6: Playfair Cipher.""" key, message = (chunk.split(": ")[1] for chunk in data.split("\n\n")) key += string.ascii_lowercase # Set up the cipher block @@ -137,7 +138,7 @@ def playfair(data: str) -> str: def the_purge(data: str) -> int: - """Day 34: count space freed upon file deletion.""" + """Day 7: count space freed upon file deletion.""" # Initialize variables. folder_size: dict[int, int] = collections.defaultdict(int) file_deletion: dict[int, int] = collections.defaultdict(int) @@ -184,7 +185,7 @@ def the_purge(data: str) -> int: def connecting_cities(data: str) -> int: - """Day 35 (2024/8): compute the number of permutations which sums to a target.""" + """Day 8: compute the number of permutations which sums to a target.""" sample = "sample" in data options = {3, 2, 1} if sample else {40, 12, 2, 1} target = 5 if sample else 856 @@ -199,7 +200,7 @@ def solve(target: int) -> int: def mining_tunnels(data: str) -> int: - """Day 36 (2024/9): return the shortest distance through a maze.""" + """Day 9: return the shortest distance through a maze.""" # Extract spaces and elevator 3D coordinates. spaces, elevators = ( { @@ -236,17 +237,17 @@ def mining_tunnels(data: str) -> int: raise RuntimeError("no solution found") -FUNCS = { - 28: purchase_tickets, - 29: broken_firewall, - 30: hotel_door_code, - 31: closest_star_systems, - 32: busy_moon_rovers, - 33: playfair, - 34: the_purge, - 35: connecting_cities, - 36: mining_tunnels, -} +FUNCS = [ + purchase_tickets, + broken_firewall, + hotel_door_code, + closest_star_systems, + busy_moon_rovers, + playfair, + the_purge, + connecting_cities, + mining_tunnels, +] @click.command() @@ -255,8 +256,8 @@ def mining_tunnels(data: str) -> int: def main(day: int | None, data: Optional[pathlib.Path]): """Run the solver for a specific day.""" if not day: - day = max(FUNCS) - if day not in FUNCS: + day = len(FUNCS) + if day > len(FUNCS): print(f"Day {day:02} not solved.") return if data: @@ -264,7 +265,7 @@ def main(day: int | None, data: Optional[pathlib.Path]): else: input_file = pathlib.Path(f"input/{day:02}.txt") if not input_file.exists(): - response = requests.get(INPUT_ENDPOINT, params={"puzzle": f"{day:02}"}) + response = requests.get(INPUT_ENDPOINT, params={"puzzle": f"{day + DAY_OFFSET:02}"}) response.raise_for_status() input_file.write_text(response.text) files = [pathlib.Path(f"input/{day:02}.sample"), input_file] @@ -273,7 +274,7 @@ def main(day: int | None, data: Optional[pathlib.Path]): print(f"{file} does not exist.") continue start = time.perf_counter_ns() - got = FUNCS[day](file.read_text().rstrip()) + got = FUNCS[day - 1](file.read_text().rstrip()) if isinstance(got, str) and "\n" in got: got = "\n" + got end = time.perf_counter_ns() diff --git a/pylib/parsers.py b/pylib/parsers.py index 27afc97..bc1d0d0 100644 --- a/pylib/parsers.py +++ b/pylib/parsers.py @@ -255,3 +255,4 @@ def parse(self, puzzle_input: str) -> tuple[tuple[int, int], set[complex], ...]: parse_ascii_bool_map = AsciiBoolMapParser parse_ascii_char_map = ParseCharMap char_map = ParseCharMap(str) +int_map = ParseCharMap(int)