No reaction image can ever reach the heights of "Silence, brand"
Pyro
Python
Not too bad though part2 could become difficult if you don't realize the simple method to merge overlapping intervals.
see code
# takes a list of (start, end) ranges and merges overlapping ones
def merge_overlapping_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
# sort ranges by start so that overlaps are adjacent
ranges.sort(key=lambda r: r[0])
merged_ranges = []
# keep track of a current range that could be merged into
curr_start, curr_end = ranges[0]
for start, end in ranges:
if curr_start <= start <= curr_end:
# overlap, extend current range
curr_end = max(curr_end, end)
else:
# no overlap, save current range and record the new one
merged_ranges.append((curr_start, curr_end))
curr_start, curr_end = start, end
# finally, don't forget to add the last range that's being tracked
merged_ranges.append((curr_start, curr_end))
return merged_ranges
def part1(data: str):
# split input
freshness_data, ingredients_data = data.split("\n\n")
# parse data
freshness_ranges = [tuple(map(int, f.split('-'))) for f in freshness_data.splitlines()]
freshness_ranges = merge_overlapping_ranges(freshness_ranges) # optimization: merge overlapping ranges
ingredients = [int(i) for i in ingredients_data.splitlines()]
# checks if an ingredient is fresh
# this could've been done with binary search, but linear search is simpler and fast enough
def is_fresh(ingredient: int) -> bool:
for start, end in freshness_ranges:
if start <= ingredient <= end:
# found
return True
if start > ingredient:
# quit early since we are past any range that could've contained the ingredient
return False
return False
# count all fresh ingredients
return sum(is_fresh(i) for i in ingredients)
def part2(data: str):
# split input
freshness_data, _ = data.split("\n\n")
# parse data
freshness_ranges = [tuple(map(int, f.split('-'))) for f in freshness_data.splitlines()]
freshness_ranges = merge_overlapping_ranges(freshness_ranges) # merge overlapping ranges
# return total count of fresh ingredients
# we add +1 because both start and end are inclusive
return sum(end - start + 1 for start, end in freshness_ranges)
sample = """3-5
10-14
16-20
12-18
1
5
8
11
17
32"""
assert part1(sample) == 3
assert part2(sample) == 14
Python
Simple brute-force is enough.
DIRECTIONS = [
(0, 1), (1, 0), (0, -1), (-1, 0), # cardinal
(1, 1), (1, -1), (-1, 1), (-1, -1) # diagonal
]
# yield all valid neighbor coordinates
def yield_neighbors(i, j, m, n):
for di, dj in DIRECTIONS:
ni, nj = i+di, j+dj
if 0 <= ni < m and 0 <= nj < n:
yield ni, nj
# build char grid from input data
def build_grid(data: str):
return [list(row) for row in data.splitlines()]
def part1(data: str):
grid = build_grid(data)
m, n = len(grid), len(grid[0])
# count accessible rolls
accessible = 0
for i in range(m):
for j in range(n):
if grid[i][j] != '@':
continue
neighbor_rolls = sum(grid[ni][nj] == '@' for ni, nj in yield_neighbors(i, j, m, n))
if neighbor_rolls < 4:
accessible += 1
return accessible
def part2(data: str):
grid = build_grid(data)
m, n = len(grid), len(grid[0])
total_accessible = 0 # total accessible rolls over all cycles
accessible = None # rolls accessible this cycle
# repeat until no more rolls can be accessed
while accessible != 0:
accessible = 0
for i in range(m):
for j in range(n):
if grid[i][j] != '@':
continue
neighbor_rolls = sum(grid[ni][nj] == '@' for ni, nj in yield_neighbors(i, j, m, n))
if neighbor_rolls < 4:
accessible += 1
# we can immediately remove this roll, no need to wait for next cycle
grid[i][j] = '.'
# accumulate accessible rolls this cycle
total_accessible += accessible
return total_accessible
sample = """<paste sample here>"""
assert part1(sample) == 13
assert part2(sample) == 43
Python
This was the easier one for me out of the first 3 days. Cleaned up my solution before posting for better readability:
# get joltage of picked batteries
def get_joltage(batteries_picked: list[int]):
bank_joltage = 0
for batt in batteries_picked:
bank_joltage = bank_joltage * 10 + batt
return bank_joltage
# get maximum joltage of a bank
def get_bank_joltage(bank: str, pick_limit = 2) -> int:
# pick first <pick_limit> batteries
batteries_picked = [int(bank[i]) for i in range(pick_limit)]
max_joltage = get_joltage(batteries_picked)
# iterate over remaining batteries
for i in range(pick_limit, len(bank)):
batt = int(bank[i])
# we add batt for selection consideration
batteries_picked.append(batt)
# If all batteries are in descending order and batt is the lowest,
# we will eventually discard batt
to_discard = pick_limit
# However if not, we discard the leftmost MSB battery which has lower joltage than its successor
# and shift all batteries left with batt added at the end.
# This guarantees that we keep the maximum lexicographical order of picked batteries
# regardless of batt's value.
for i in range(pick_limit):
if batteries_picked[i] < batteries_picked[i+1]:
to_discard = i
break
batteries_picked.pop(to_discard)
# update max_joltage, it may have increased
max_joltage = max(max_joltage, get_joltage(batteries_picked))
return max_joltage
# part 1 asserts
assert get_bank_joltage("987654321111111", pick_limit=2) == 98
assert get_bank_joltage("811111111111119", pick_limit=2) == 89
assert get_bank_joltage("234234234234278", pick_limit=2) == 78
assert get_bank_joltage("818181911112111", pick_limit=2) == 92
# part 2 asserts
assert get_bank_joltage("987654321111111", pick_limit=12) == 987654321111
assert get_bank_joltage("811111111111119", pick_limit=12) == 811111111119
assert get_bank_joltage("234234234234278", pick_limit=12) == 434234234278
assert get_bank_joltage("818181911112111", pick_limit=12) == 888911112111
# get total joltage of a set of banks
def solve(data: str, pick_limit = 2):
total_joltage = 0
for bank in data.splitlines():
total_joltage += get_bank_joltage(bank, pick_limit)
return total_joltage
# asserts for sample data
sample = """987654321111111
811111111111119
234234234234278
818181911112111"""
assert solve(sample, pick_limit=2) == 357 # part 1
assert solve(sample, pick_limit=12) == 3121910778619 # part 2
Oh please yes! There have been so many times I've pressed back from a post / post image, only to end up on the communities tab with no way to go back.
You can always add a "Don't ask again" checkbox on the popup for the people who don't need it.
Python
Just putting the solution to part 3 here, because these solutions are pretty long. Took me a while to figure out that an optimal return path from the bottom of the volcano burn can bleed into the left quadrant.
from collections import deque
import heapq as hq
import re
DIRECTIONS_CARDINAL = [(-1, 0), (0, 1), (1, 0), (0, -1)]
DIRECTIONS_ALL = [
*DIRECTIONS_CARDINAL, # cardinal
(-1, -1), (-1, 1), (1, -1), (1, 1) # diagonal
]
# yields all 8 possible neighbors
# lava spreads in all 8 directions
def yield_all_neighbors(i, j, m, n):
for di, dj in DIRECTIONS_ALL:
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n:
yield ni, nj
# yields only the 4 possible cardinal neighbors
# the dragonduck can only move in cardinal directions
def yield_cardinal_neighbors(i, j, m, n):
for di, dj in DIRECTIONS_CARDINAL:
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n:
yield ni, nj
# finds a special character in the grid
def find_char(data: str, ch: str):
i = 0
for row in data.splitlines():
j = row.find(ch)
if j != -1:
return (i, j)
i += 1
assert False, f"Character {ch} not found in grid"
# returns squared distance between two points
def get_dist_sq(a, b):
x1, y1 = a
x2, y2 = b
return (x2 - x1) ** 2 + (y2 - y1) ** 2
# Generator to burn cells permanently one step at a time
def bfs_burn(grid, volcano):
m, n = len(grid), len(grid[0])
queue = deque([volcano])
visited = set()
step = 0
while queue:
nq = len(queue)
for _ in range(nq):
i, j = queue.popleft()
if (i, j) in visited:
continue
# skip cells outside the current step radius
# but do not mark them as visited yet
if get_dist_sq((i, j), volcano) > (step * step):
continue
visited.add((i, j))
# burn cell permanently
grid[i][j] = -1
for ni, nj in yield_all_neighbors(i, j, m, n):
if (ni, nj) in visited:
continue
queue.append((ni, nj))
step += 1
# yield here to allow enclosing the volcano after each step
yield step
def part3(data: str):
# initialize variables
start = find_char(data, 'S')
volcano = find_char(data, '@')
data = re.sub(r'[@S]', '0', data)
grid = [[int(c) for c in row] for row in data.splitlines()]
m, n = len(grid), len(grid[0])
# initialize lava generator
lava_spreader = bfs_burn(grid, volcano)
# Finds shortest path from start to dest within time limit
# Dijkstra's algorithm with constraints
def find_shortest_path(start: tuple[int, int], dest: tuple[int, int], volcano_col: int, time_limit, map_half):
candidates = [(0, start[0], start[1])]
visited = set()
while candidates:
cost, i, j = hq.heappop(candidates)
if (i, j) in visited:
continue
visited.add((i, j))
if (i, j) == dest:
# if we are processing the destination, we have found the shortest path to it
return cost
if cost > time_limit:
# if we have exceeded the time limit, prune this path
continue
# remember: the dragonduck can only move in cardinal directions
for ni, nj in yield_cardinal_neighbors(i, j, m, n):
# skip visited or burned cells
if (ni, nj) in visited or grid[ni][nj] == -1:
continue
# if processing left half, skip paths going to the right of the volcano
if map_half == 'left' and nj > volcano_col:
continue
# if processing right half, skip paths going to the left of the volcano (ONLY for bottom quadrant)
# allow moving into the left half in the top quadrant (!!!)
elif map_half == 'right' and (ni > m // 2 and nj < volcano_col):
continue
# add new candidate path
hq.heappush(candidates, (cost + grid[ni][nj], ni, nj))
# we couldn't reach the destination within time limit
# fail by returning the maximum possible cost
return 9 * m * n
# try increasing lava spreads
while True:
# spread lava one more step
step = next(lava_spreader)
# calculate time limit
time = step * 30
# our paths from the start must connect at the bottom of the lava burn
burn_bottom_boundary = (volcano[0] + step, volcano[1])
time_taken = find_shortest_path(start, burn_bottom_boundary, volcano[1], time, 'left')
if time_taken >= time:
# if we already exceeded time, quit early
continue
time_taken += find_shortest_path(burn_bottom_boundary, start, volcano[1], time - time_taken, 'right')
if time_taken >= time:
# if we exceeded time, try next lava spread
continue
# we successfully enclosed the volcano within time
burn_radius = step - 1
return time_taken * burn_radius
Python
def part1(data: str):
elements = map(int, data.split(','))
total = 0
for ele in elements:
# a spell element will add (90 // number) bricks for 90 columns
total += 90 // ele
return total
assert part1("1,2,3,5,9") == 193
# Gets all spell elements needed to build a wall
# Useful for part 2 and part 3
def get_elements(data: str):
wall = map(int, data.split(','))
elements = [] # spell elements
for col_idx, fragments in enumerate(wall):
# fix for 1-based indexing
col = col_idx + 1
# account bricks for recorded elements
for ele in elements:
if col % ele == 0:
fragments -= 1
# if we still have fragments, we need to add a new element with that column number
if fragments > 0:
elements.append(col)
return elements
def part2(data: str):
# Get all spell elements needed to build the wall
elements = get_elements(data)
# return product of all elements
ans = 1
for ele in elements:
ans *= ele
return ans
assert part2("1,2,2,2,2,3,1,2,3,3,1,3,1,2,3,2,1,4,1,3,2,2,1,3,2,2") == 270
# Part 3: Binary Search for maximum full columns
def part3(data: str):
BRICKS = 202520252025000
elements = get_elements(data)
# Check if we can build 'cols' full columns within BRICKS bricks
def can_build(cols: int) -> bool:
bricks_used = 0
for ele in elements:
bricks_used += cols // ele
if bricks_used > BRICKS:
return False
return True
# binary search: break on first column size we cannot build
lo = 1
hi = BRICKS
while lo < hi:
mid = lo + (hi - lo) // 2
if can_build(mid):
lo = mid + 1
else:
hi = mid
# if lo is the first we cannot build, lo - 1 is the maximum we can build
return lo - 1
assert part3("1,2,2,2,2,3,1,2,3,3,1,3,1,2,3,2,1,4,1,3,2,2,1,3,2,2") == 94439495762954
That and the else (no-break) clause for loops are some of my favorite features.
Yeah, it's an easy way to make sure everything is working correctly when I refactor or optimize. I used to keep samples in their own text files before but EC samples are small enough to include directly.
Yes, I'm not sure how to prove it but I had a hunch that was the case. So I initially wrote some code to print the grid right after the cycle is detected and lo and behold, it was the initial state.
Yeah I've got the DSU algorithm ingrained because of the number of the times I had to practice it for coding rounds. I didn't need to do path compression and union by rank either but might as well.
Here we see, another innocent victim being drawn into the addictive clutches of Cracktorio. The factory must grow.