Pyro

joined 2 years ago
[โ€“] Pyro@programming.dev 15 points 1 week ago (1 children)

Here we see, another innocent victim being drawn into the addictive clutches of Cracktorio. The factory must grow.

[โ€“] Pyro@programming.dev 11 points 1 week ago* (last edited 1 week ago) (1 children)

No reaction image can ever reach the heights of "Silence, brand"

[โ€“] Pyro@programming.dev 2 points 1 week ago

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

[โ€“] Pyro@programming.dev 4 points 1 week ago* (last edited 1 week ago)

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
[โ€“] Pyro@programming.dev 4 points 1 week ago* (last edited 1 week ago)

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

[โ€“] Pyro@programming.dev 30 points 1 week ago* (last edited 1 week ago) (2 children)

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.

[โ€“] Pyro@programming.dev 1 points 2 weeks ago (1 children)

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
[โ€“] Pyro@programming.dev 2 points 2 weeks ago

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
[โ€“] Pyro@programming.dev 2 points 2 weeks ago

That and the else (no-break) clause for loops are some of my favorite features.

[โ€“] Pyro@programming.dev 2 points 2 weeks ago* (last edited 2 weeks ago)

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.

[โ€“] Pyro@programming.dev 2 points 2 weeks ago

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.

[โ€“] Pyro@programming.dev 1 points 2 weeks ago (1 children)

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.

view more: โ€น prev next โ€บ