chore: create new project structure and aoc.py runner script

This commit is contained in:
2025-08-04 16:23:06 +02:00
parent f76375d835
commit e2964c6c36
91 changed files with 177 additions and 113 deletions

View File

@@ -0,0 +1,48 @@
def part1(lines):
res = []
for line in lines:
digits = [c for c in line if c.isnumeric()]
res.append(int(digits[0] + digits[-1]))
print(f"Part 1: {sum(res)}")
spelled_digits = { "one": "o1e", "two": "t2o", "three": "t3e", "four": "f4r", "five": "f5e", "six": "s6x", "seven": "s7n", "eight": "e8t", "nine": "n9e" }
def substitute_digits(s, trans):
res = s
for word, digit in trans.items():
res = res.replace(word, digit)
return res
def part2(lines):
res = []
for line in lines:
line = line.rstrip()
res_line = []
nline = ""
for c in line:
nline += c
nline = substitute_digits(nline, spelled_digits)
digits = [c for c in nline if c.isnumeric()]
r = int(digits[0] + digits[-1])
print(f"{line} => {nline}, {r}")
res.append(r)
print(f"Part 2: {sum(res)}")
if __name__ == "__main__":
import sys
infile = sys.argv[1]
with open(infile) as f:
lines = f.readlines()
part1(lines)
part2(lines)

View File

@@ -0,0 +1,158 @@
from typing import Dict, List, Set, Tuple
from dataclasses import dataclass
from collections import defaultdict
from enum import Enum
@dataclass(frozen=True)
class Vec2d:
x: int
y: int
def __add__(self, other):
return Vec2d(self.x + other.x, self.y + other.y)
class Direction(Enum):
NORTH = Vec2d(0, -1) # [0, 0] is the top-left corner, so y increases going downwards
SOUTH = Vec2d(0, 1)
EAST = Vec2d(1, 0)
WEST = Vec2d(-1, 0)
"""
| 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.
"""
PIPES = {
"|": (Direction.SOUTH, Direction.NORTH),
"-": (Direction.EAST, Direction.WEST),
"L": (Direction.NORTH, Direction.EAST),
"J": (Direction.NORTH, Direction.WEST),
"7": (Direction.SOUTH, Direction.WEST),
"F": (Direction.SOUTH,Direction.EAST),
}
PIPES_REVERSE_LOOKUP = {v: k for k,v in PIPES.items()}
def find_start_position(grid: List[str]) -> Vec2d:
for y, row in enumerate(grid):
for x, c in enumerate(row):
if c == "S":
return Vec2d(x, y)
raise RuntimeError("The start position was not found")
def update_start_symbol(grid: List[str], start_pos: Vec2d):
"""
Updates the map by replacing the start symbol "S" with its actual corresponding pipe
"""
# check which neighbors are connected to the start position
connections = []
north = start_pos + Direction.NORTH.value
south = start_pos + Direction.SOUTH.value
east = start_pos + Direction.EAST.value
west = start_pos + Direction.WEST.value
if grid[north.y][north.x] in "|7F":
connections.append(Direction.NORTH)
if grid[south.y][south.x] in "|LJ":
connections.append(Direction.SOUTH)
if grid[east.y][east.x] in "-7J":
connections.append(Direction.EAST)
if grid[west.y][west.x] in "-LF":
connections.append(Direction.WEST)
print("Start symbol has the following connections: ", connections)
assert len(connections) == 2, "start symbol has invalid connections"
pipe = PIPES_REVERSE_LOOKUP[tuple(connections)]
print(f"Start symbol is a {pipe} pipe")
# replace it in the grid accordingly
grid[start_pos.y] = grid[start_pos.y].replace("S", pipe)
def parse_graph(grid: List[str]) -> Dict[Vec2d, List[Vec2d]]:
graph = defaultdict(list)
for y, row in enumerate(grid):
for x, pipe in enumerate(row):
pos = Vec2d(x, y)
if pipe in PIPES:
for direction in PIPES[pipe]:
next_pos = pos + direction.value
graph[pos].append(next_pos)
return graph
def traverse_graph(graph, start_pos) -> Tuple[int, Set[Vec2d]]:
"""
traverse the graph using BFS, return the path and the
find the length of the longest path in the graph
"""
queue = [(start_pos, 0)] # (pos, distance from start)
max_dist = 0
visited = {start_pos}
while queue != []:
cur, dist = queue.pop(0)
max_dist = max(max_dist, dist)
for next_pos in graph[cur]:
if next_pos not in visited:
visited.add(next_pos)
queue.append((next_pos, dist+1))
return max_dist, visited
def count_enclosed_tiles(grid, edges):
"""
count the number of enclosed tiles in the loop by casting a ray on each row
and counting the number of intersections with the edges of the loop
"""
enclosed_count = 0
for y, row in enumerate(grid):
crossings = 0
for x, pipe in enumerate(row):
pos = Vec2d(x, y)
if pos in edges:
if pipe in "L|J":
crossings += 1
elif crossings % 2 == 1:
enclosed_count += 1
return enclosed_count
def main(grid):
rows, cols = len(grid), len(grid[0])
start_pos = find_start_position(grid)
print("Start pos ", start_pos)
update_start_symbol(grid, start_pos)
graph = parse_graph(grid)
max_dist, visited = traverse_graph(graph, start_pos)
print("Part 1: ", max_dist)
# visited edges are the ones that are part of the loop
inside_count = count_enclosed_tiles(grid, visited)
print("Part 2: ", inside_count)
if __name__ == "__main__":
import sys
infile = sys.argv[1]
with open(infile) as f:
lines = [l.rstrip() for l in f.readlines()]
main(lines)

View File

@@ -0,0 +1,45 @@
from math import prod
def part1(lines):
result = set()
for index, line in enumerate(lines):
game_id = index + 1
_, line = line.split(": ")
result.add(int(game_id))
hands = line.rstrip().split("; ")
for hand in hands:
colors = {"red": 0, "green": 0, "blue": 0}
cubes = hand.split(", ")
for cube in cubes:
n, color = cube.split()
colors[color] = int(n)
if colors["red"] > 12 or colors["green"] > 13 or colors["blue"] > 14:
# impossible configuration, remove this game_id from the result (if present)
result.discard(int(game_id))
print(f"Part 1: {sum(result)}")
def part2(lines):
result = []
for line in lines:
colors = {"red": 0, "green": 0, "blue": 0}
_, line = line.split(": ")
hands = line.rstrip().split("; ")
for hand in hands:
cubes = hand.split(", ")
for cube in cubes:
n, color = cube.split()
colors[color] = max(colors[color], int(n))
result.append(prod(colors.values()))
print(f"Part 2: {sum(result)}")
if __name__ == "__main__":
import sys
infile = sys.argv[1]
with open(infile) as f:
lines = f.readlines()
part1(lines)
part2(lines)

View File

@@ -0,0 +1,116 @@
from typing import Tuple, List, Set
from dataclasses import dataclass
@dataclass(frozen=True)
class Item:
pos: Tuple[int, int]
symbol: str
def browse_schema(schema):
total_parts = 0
buf = []
max_row, max_col = len(schema), len(schema[0])
symbols: List[Tuple[Item, Set[Item]]] = []
numbers: List[Item] = []
for y in range(max_row):
for x in range(max_col):
item = schema[y][x]
if item.isnumeric():
# continue parsing full number
buf.append(item)
else:
neighbors = get_neighbors_of((x, y), schema)
symbols.append((Item((x, y), item), set(neighbors)))
if buf and not item.isnumeric():
# end of a number, do the engine part check
number = "".join(buf)
neighbors = get_neighbors((x, y), len(buf), schema)
start_pos = (x-len(number), y)
symbols.append((Item((x, y), number), get_neighbors_of((x, y), schema)))
numbers.append(Item(start_pos, number))
if is_engine_part(neighbors):
total_parts += int(number)
buf.clear() # reached end of a number, clear buffer
print(f"Part 1, sum of the parts numbers = {total_parts}")
part2(symbols, numbers)
def part2(symbols, numbers):
total_gears = 0
stars = [(s, neighbors) for s, neighbors in symbols if s.symbol == "*"]
for _, neighbors in stars:
corresponding_numbers = set()
digits = [n for n in neighbors if n.symbol.isdigit()]
for digit in digits:
# find full number (number.start_pos < digit.pos < number.end_pos)
for number in numbers:
if number.pos[1] - 1 <= digit.pos[1] <= number.pos[1] + 1 and number.pos[0] <= digit.pos[0] <= number.pos[0]+len(number.symbol):
corresponding_numbers.add(number.symbol)
if len(corresponding_numbers) == 2:
a, b = corresponding_numbers
total_gears += int(a) * int(b)
#print(f"star: {star.pos} {corresponding_numbers}")
print(f"Part 2, sum of gear ratios = {total_gears}")
def is_engine_part(neighbors: List[Item]) -> bool:
# get list of symbols (not '.', \n or a number)
symbols = filter(lambda x: not x.symbol.isnumeric() and not x.symbol in (".", "\n"), neighbors)
return next(symbols, None) is not None
def get_neighbors(pos: Tuple[int, int], length: int, schema: List[List[str]]) -> List[Item]:
x, y = pos
start_x = x - length
neighbors = [get_neighbors_of((x, y), schema) for x in range(start_x, x)]
neighbors = [item for sublist in neighbors for item in sublist] # flatten list of list
return neighbors
def get_neighbors_of(pos: Tuple[int, int], schema: List[List[str]]) -> List[Item]:
max_row, max_col = len(schema), len(schema[0])
x, y = pos
neighbors: List[Item] = []
# top
if y-1 >= 0:
neighbors.append(Item((x, y-1), schema[y-1][x]))
# bottom:
if y+1 < max_row:
neighbors.append(Item((x, y+1), schema[y+1][x]))
# left
if x-1 >= 0:
neighbors.append(Item((x-1, y), schema[y][x-1]))
# right
if x+1 < max_col:
neighbors.append(Item((x+1, y), schema[y][x+1]))
# top-left
if y-1 >= 0 and x-1 >= 0:
neighbors.append(Item((x-1, y-1), schema[y-1][x-1]))
# top-right
if y-1 >= 0 and x+1 < max_col:
neighbors.append(Item((x+1, y-1), schema[y-1][x+1]))
# bottom-left
if y+1 < max_row and x-1 >= 0:
neighbors.append(Item((x-1, y+1), schema[y+1][x-1]))
# bottom-right
if y+1 < max_row and x+1 < max_col:
neighbors.append(Item((x+1, y+1), schema[y+1][x+1]))
return neighbors
if __name__ == "__main__":
import sys
infile = sys.argv[1]
with open(infile) as f:
schema = [[c for c in line] for line in f.readlines()]
browse_schema(schema)

View File

@@ -0,0 +1,44 @@
def parse_tickets(lines):
tickets = []
for line in lines:
_, nums = line.rstrip().split(": ")
winning, played = nums.split(" | ")
winning, played = set(winning.split()), set(played.split())
tickets.append((winning, played))
return tickets
def part1(tickets):
total = 0
for ticket in tickets:
winning, played = ticket
num_wins = len(winning.intersection(played))
points = 0 if num_wins == 0 else 2**(num_wins-1)
total += points
print(f"part 1, total={total}")
def part2(tickets):
tickets = [[1, t] for t in tickets]
for index, ticket in enumerate(tickets):
mult = ticket[0]
winning, played = ticket[1]
num_wins = len(winning.intersection(played))
for i in range(index+1, index+1+num_wins):
tickets[i][0] += mult
num_tickets = sum(n for n, _ in tickets)
print(f"part 2, number of tickets: {num_tickets}")
def main(f):
tickets = parse_tickets(f)
part1(tickets)
part2(tickets)
if __name__ == "__main__":
import sys
infile = sys.argv[1]
with open(infile) as f:
main(f)

View File

@@ -0,0 +1,70 @@
from itertools import zip_longest
def grouper(n, iterable):
"grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
args = [iter(iterable)] * n
return zip_longest(*args)
def part1(sections):
# consume seed section
_, seeds = next(sections)
seeds = [int(s) for s in seeds]
for _, mapping in sections:
seeds = [apply_mapping(s, mapping) for s in seeds]
print(f"Part 1, lowest location number = {min(seeds)}")
def part2(sections):
# consume seed section
_, seeds = next(sections)
seeds = [int(s) for s in seeds]
min_seed = 2**128
for start_seed, length in grouper(2, seeds):
subseeds = range(start_seed, start_seed + length)
print(f"calculate_for_subseeds(subseeds len={len(subseeds)})")
res = calculate_for_subseeds(subseeds, sections)
mini = min(res)
if mini < min_seed:
min_seed = mini
print(f"Part 2 {min_seed}")
def calculate_for_subseeds(seeds, sections):
new_seeds = seeds
for _, mapping in sections:
new_seeds = [apply_mapping(s, mapping) for s in new_seeds]
return new_seeds
def apply_mapping(seed, mapping):
for dst, src, length in grouper(3, mapping):
src, length, dst = int(src), int(length), int(dst)
end = src + length
if src <= seed < end:
return seed + (dst-src)
return seed
def parse_input(infile):
with open(infile) as f:
sections = f.read().split("\n\n")
sections = ((title, numbers) for title, numbers in (s.split(":") for s in sections))
sections = ((title, numbers.split()) for title, numbers in sections)
return sections
if __name__ == "__main__":
import sys
import os
SCRIPTPATH = os.path.dirname(os.path.realpath(__file__))
infile = next(iter(sys.argv[1:]), None)
sections = parse_input(infile or os.path.join(SCRIPTPATH, "example.txt"))
part1(sections)
sections = parse_input(infile or os.path.join(SCRIPTPATH, "example.txt"))
part2(sections)

View File

@@ -0,0 +1,40 @@
def parse_part1(path):
with open(path) as f:
time, distance = f.readlines()
time = [int(x) for x in time.split()[1:]]
distance = [int(x) for x in distance.split()[1:]]
return zip(time, distance)
def calculate_wins(data):
total = 1
for time, record in data:
ways = 0
for n in range(0, time+1):
speed = n
distance = (time-n) * speed
if distance > record:
ways += 1
total *= ways
return total
def parse_part2(path):
with open(path) as f:
time, distance = f.readlines()
time = time.split(":")[1].replace(" ", "").rstrip()
distance = distance.split(":")[1].replace(" ", "").rstrip()
return int(time), int(distance)
if __name__ == "__main__":
assert calculate_wins(zip(*[[7, 15, 30], [9, 40, 200]])) == 288 # part 1 example
assert calculate_wins([[71530, 940200]]) == 71503 # part 2 example
import sys
if len(sys.argv) == 2:
data = parse_part1(sys.argv[1])
res = calculate_wins(data)
print(f"Part 1, res={res}")
data = parse_part2(sys.argv[1])
res = calculate_wins([data])
print(f"Part 2, res={res}")

View File

@@ -0,0 +1,70 @@
from collections import Counter
def calculate_rank(hand, part2=False):
card_ranks = {'2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, 'T': 9, 'J': 10, 'Q': 11, 'K': 12, 'A': 13}
if part2:
# in part2, jokers are the weakest card
card_ranks['J'] = 0
# substitute cards with their ranks to make them sortable
hand = [card_ranks[c] for c in hand]
cnt = Counter(hand)
if part2 and cnt[0] != 5: # edge case if hand == 'JJJJJ'
# substitute jokers with the most common card that isn't a joker
most_common_card = cnt.most_common(2)[0][0] if cnt.most_common(2)[0][0] != 0 else cnt.most_common(2)[1][0]
new_hand = [most_common_card if c == 0 else c for c in hand]
cnt = Counter(new_hand)
rank = 0
match sorted(cnt.values()):
case [5]: rank = 7
case [1, 4]: rank = 6
case [2, 3]: rank = 5
case [1, 1, 3]: rank = 4
case [1, 2, 2]: rank = 3
case [1, 1, 1, 2]: rank = 2
case [1, 1, 1, 1, 1]: rank = 1
# return rank, and hand as a tiebreaker
return (rank, hand)
def parse_input(inp):
hands = [l.strip().split() for l in inp.strip().split("\n")]
return hands
def calculate_wins(hands, part2=False):
total = 0
hands = sorted(hands, key=lambda hb: calculate_rank(hb[0], part2=part2))
for rank, hand in enumerate(hands):
hand, bid = hand
total += (rank + 1) * int(bid)
return total
if __name__ == "__main__":
sample_input = """
32T3K 765
T55J5 684
KK677 28
KTJJT 220
QQQJA 483
"""
res = calculate_wins(parse_input(sample_input))
print(f"part 1 example: {res}")
assert res == 6440
res = calculate_wins(parse_input(sample_input), part2=True)
print(f"part 2 example: {res}")
assert res == 5905
import sys
if len(sys.argv) == 2:
with open(sys.argv[1]) as f:
inp = parse_input(f.read())
res = calculate_wins(inp)
print(f"Part 1, res={res}")
res = calculate_wins(inp, part2=True)
print(f"Part 2, res={res}")

View File

@@ -0,0 +1,75 @@
import re
import math
from itertools import cycle
def parse_input(infile):
with open(infile) as f:
content = f.read().rstrip()
directions, nodes = content.split("\n\n")
directions = directions.strip()
nodes = nodes.split("\n")
nodes = [n.split(" = ") for n in nodes]
nodes = {k: re.findall(r"\w{3}", v) for k, v in nodes}
return directions, nodes
def part1(directions, nodes):
iterations = 0
current_node = "AAA"
for d in cycle(directions):
if current_node == "ZZZ":
break
iterations += 1
if d == "L":
current_node = nodes[current_node][0]
else:
current_node = nodes[current_node][1]
print(f"Part 1: reached 'ZZZ' in {iterations} iterations")
def part2(directions, nodes):
current_nodes = [k for k in nodes.keys() if k.endswith("A")]
# keep track of iterations number for each visited node
# (the number will stop to beeing incremented once the node n_i value reached the target value 'xxZ')
iterations = [0] * len(current_nodes)
for d in cycle(directions):
if all(c.endswith("Z") for c in current_nodes):
break
if d == "L":
new_nodes = []
for i, n in enumerate(current_nodes):
if n.endswith("Z"): # end condition already reached for this node
new_nodes.append(n)
else:
new_nodes.append(nodes[n][0])
iterations[i] += 1
current_nodes = new_nodes
else:
new_nodes = []
for i, n in enumerate(current_nodes):
if n.endswith("Z"): # end condition already reached for this node
new_nodes.append(n)
else:
new_nodes.append(nodes[n][1])
iterations[i] += 1
current_nodes = new_nodes
# the result is the lowest common multiple between the number of iterations
# for each node
result = math.lcm(*iterations)
print(f"Part 2: reached all nodes such that 'xxZ' in {result} iterations")
if __name__ == "__main__":
import sys
import os
SCRIPTPATH = os.path.dirname(os.path.realpath(__file__))
infile = sys.argv[1] if len(sys.argv) == 2 else "example.txt"
directions, nodes = parse_input(os.path.join(SCRIPTPATH, infile))
part1(directions, nodes)
directions, nodes = parse_input(os.path.join(SCRIPTPATH, infile))
part2(directions, nodes)

View File

@@ -0,0 +1,33 @@
def parse_input(infile):
with open(infile) as f:
return [[int(x) for x in l.strip().split()] for l in f.readlines()]
def process_line(line):
if set(line) == {0}:
return 0
else:
next_line = [cur - next for next, cur in zip(line, line[1:])]
return line[-1] + process_line(next_line)
def process_line_back(line):
if set(line) == {0}:
return 0
else:
next_line = [cur - next for next, cur in zip(line, line[1:])]
return line[0] - process_line_back(next_line)
def solve(data):
print(f"Part 1: {sum(process_line(l) for l in data)}")
print(f"Part 2: {sum(process_line_back(l) for l in data)}")
if __name__ == "__main__":
import sys
import os
SCRIPTPATH = os.path.dirname(os.path.realpath(__file__))
infile = sys.argv[1] if len(sys.argv) == 2 else "example.txt"
data = parse_input(os.path.join(SCRIPTPATH, infile))
solve(data)