Project Euler / programming problems

Due to real-life scheduling constraints, I didn’t start working on Day 17 until this morning, and I just finished both parts this evening. It was tricky enough that I took a break to solve Day 18 before I finished even part 1 of Day 17.

Discussion of Day 17 solution

A problem like this might have been more effectively solved with specific path finding algorithms. However, I’m not familiar with the nuances of those, so I approached it with general dynamic programming concepts. There are some similar, but simpler, Project Euler problems which I had solved with similar DP approaches, and I’ve discussed those problems and shared my code in earlier posts: Project Euler / programming problems - #185 by yebellz and Project Euler / programming problems - #244 by yebellz.

The Day 17 AoC problem is made significantly trickier by constraints on the shape of paths allowed. Thus, rather than just tracking the minimum cost (heat loss) needed to reach each intermediate point, one also needs to be aware of the movement constraints imposed by the corresponding paths. I handled this by tracking a list of the “best” paths for each intermediate point in the map, where the concept of “best” means keeping the dominant paths with pareto efficient combinations of cost and movement restrictions.

With this algorithm, tackling the entire 13x13 test case at once worked very quickly. However, it became way too slow when scaling it up to the 141x141 problem input. So, I modified the code by adding an outer loop that progressively grows the area under consideration. It starts with the 21x21 top-left region and very quickly figures out the best paths to the bottom and right edges of that region. Then, the region is expanded by 5 more rows and columns and the optimization is started again, but just from those leading edges. Using this divide-and-conquer approach, the part 1 code runs in about 6.3 seconds on my machine. This could probably be sped up much further by applying some better heuristics to more aggressively prune bad paths, and maybe even tuning the divide-and-conquer expansion schedule.

Part 1 Code
import numpy as np

map = []
for line in open("17input", 'r').readlines():
#for line in open("17test", 'r').readlines():
    map.append([int(c) for c in line.strip()])

map = np.asarray(map)
max_h, max_w = map.shape
print(max_h, max_w)
worst = np.sum(np.diagonal(map)) + np.sum(np.diagonal(map, 1))
print(worst)

paths = [[ [] for i in range(max_w) ] for j in range(max_h)]

def tail(path):
    last = path[-1]
    if path.endswith(last * 3):
        return 3
    if path.endswith(last * 2):
        return 2
    return 1

def dominates(cost_a, path_a, cost_b, path_b, strict=False):
    if path_a[-1] != path_b[-1]:
        return False
    if strict and tail(path_a) == tail(path_b) and cost_a == cost_b:
        return False
    if tail(path_a) <= tail(path_b) and cost_a <= cost_b:
        return True

def update(new_cost, new_path, position):
    if position == (0, 0):
        return
    new_cost += map[position[0], position[1]]
    current_paths = paths[position[0]][position[1]]

    for cost, path in current_paths:
        if dominates(cost, path, new_cost, new_path):
            return
    current_paths[:] = [(cost, path) for cost, path in current_paths if not dominates(new_cost, new_path, cost, path)]
    current_paths.append((new_cost, new_path))

    queue.append((position, new_cost, new_path))

queue = [((0, 0), 0, "")]
steps = 0

h = w = 21

while h < max_h:
    h += 5
    w += 5

    while queue:
        position, cost, path = queue[0]
        queue = queue[1:]
        if position == (h - 1, w - 1):
            continue

        if (not path.endswith('RRR')) and (not path.endswith('L')) and position[1] < (w - 1):
            new_path = path + "R"
            next_position = (position[0], position[1] + 1)
            update(cost, new_path, next_position)

        if (not path.endswith('DDD')) and (not path.endswith('U')) and position[0] < (h - 1):
            new_path = path + "D"
            next_position = (position[0] + 1, position[1])
            update(cost, new_path, next_position)

        if (not path.endswith('LLL')) and (not path.endswith('R')) and position[1] > 0 and position[0] > 0 and position[0] < (h - 1):
            new_path = path + "L"
            next_position = (position[0], position[1] - 1)
            update(cost, new_path, next_position)

        if (not path.endswith('UUU')) and (not path.endswith('D')) and position[0] > 0 and position[1] > 0 and position[1] < (w - 1):
            new_path = path + "U"
            next_position = (position[0] - 1, position[1])
            update(cost, new_path, next_position)

        steps += 1
        if steps % 10000 == 0:
            print(h, steps, len(queue))

    for i in range(h):
        for cost, path in paths[h-1][i]:
            queue.append(( (h-1, i), cost, path))
        for cost, path in paths[i][h-1]:
            queue.append(( (i, h-1), cost, path))

print(paths[h-1][w-1])

lowest = 5000
for cost, path in paths[h-1][w-1]:
    lowest = min(lowest, cost)
print(lowest)

The part 2 code is not too substantially different in approach, as the main change is to just capture the differences in movement constraints. One wrinkle is that minimum movement constraint leads to the potential paths overshooting outside of the limiting region. As a consequence of these difference, this part 2 code runs noticeably slower (taking about 100 seconds), even after changing the divide-and-conquer schedule. It could probably benefit significantly from further tweaks and application of heuristics.

Part 2 Code
import numpy as np

map = []
for line in open("17input", 'r').readlines():
#for line in open("17test", 'r').readlines():
    map.append([int(c) for c in line.strip()])

map = np.asarray(map)
max_h, max_w = map.shape
print(max_h, max_w)
worst = np.sum(np.diagonal(map)) + np.sum(np.diagonal(map, 1))
print(worst)

paths = [[ [] for i in range(max_w) ] for j in range(max_h)]

def tail(path):
    if len(path) == 0:
        return 0
    last = path[-1]
    i = 0
    while path.endswith(last):
        i += 1
        path = path[:-1]
    return i

def dominates(cost_a, path_a, cost_b, path_b,):
    if path_a[-1] != path_b[-1]:
        return False
    if tail(path_a) != tail(path_b):
        return False
    if cost_a <= cost_b:
        return True

def update(new_cost, new_path, position):
    if position == (0, 0):
        return
    if position[0] < 0 or position[0] >= max_h or position[1] < 0 or position[1] >= max_w:
        return
    if position[0] == max_h - 1 and new_path[-1] == "D" and tail(new_path) < 4:
        return
    if position[1] == max_w - 1 and new_path[-1] == "R" and tail(new_path) < 4:
        return
    new_cost += map[position[0], position[1]]
    current_paths = paths[position[0]][position[1]]

    for cost, path in current_paths:
        if dominates(cost, path, new_cost, new_path):
            return
    current_paths[:] = [(cost, path) for cost, path in current_paths if not dominates(new_cost, new_path, cost, path)]
    current_paths.append((new_cost, new_path))

    queue.append((position, new_cost, new_path))

queue = [((0, 0), 0, "")]
steps = 0

h = w = 21

while h < max_h:
    h += 2
    w += 2

    while queue:
        position, cost, path = queue[0]
        queue = queue[1:]
        if position == (max_h - 1, max_w - 1):
            continue

        if tail(path) > 0 and tail(path) < 4:
            new_path = path + path[-1]
            if new_path[-1] == "R":
                next_position = (position[0], position[1] + 1)
            if new_path[-1] == "D":
                next_position = (position[0] + 1, position[1])
            if new_path[-1] == "L":
                next_position = (position[0], position[1] - 1)
            if new_path[-1] == "U":
                next_position = (position[0] - 1, position[1])
            update(cost, new_path, next_position)

        else:
            if (not path.endswith('R' * 10)) and (not path.endswith('L')) and position[1] < (w - 1):
                new_path = path + "R"
                next_position = (position[0], position[1] + 1)
                update(cost, new_path, next_position)

            if (not path.endswith('D' * 10)) and (not path.endswith('U')) and position[0] < (h - 1):
                new_path = path + "D"
                next_position = (position[0] + 1, position[1])
                update(cost, new_path, next_position)

            if (not path.endswith('L' * 10)) and (not path.endswith('R')) and position[1] > 0 and position[0] > 0 and position[0] < (h - 1):
                new_path = path + "L"
                next_position = (position[0], position[1] - 1)
                update(cost, new_path, next_position)

            if (not path.endswith('U' * 10)) and (not path.endswith('D')) and position[0] > 0 and position[1] > 0 and position[1] < (w - 1):
                new_path = path + "U"
                next_position = (position[0] - 1, position[1])
                update(cost, new_path, next_position)

        steps += 1
        if steps % 10000 == 0:
            print(h, steps, len(queue))

    for i in range(max_h):
        for j in range(max_w):
            if i >= h - 1 or j >= h - 1:
                for cost, path in paths[i][j]:
                    queue.append(( (i, j), cost, path))

print(paths[h-1][w-1])

lowest = 5000
for cost, path in paths[h-1][w-1]:
    lowest = min(lowest, cost)
print(lowest)
2 Likes
Day 12

Here are my solutions, they’re not very elegant but finish relatively quickly.
Part 1
Solved with a recursive function that iterates over possible placements for the first group of defect springs, and calls itself for determining the possibilities for the rest string and groups.

def recursive(condition_record_part, groups) -> int:
    if len(groups) == 0:
        return 0 if '#' in condition_record_part else 1
    
    group = groups[0]
    isLast = (len(groups) == 1)
    restGroupLength = sum([groups[i] for i in range(1, len(groups))]) + (0 if isLast else len(groups) - 1)

    if (group + restGroupLength) > len(condition_record_part):
        # can't fit the groups at all
        return 0
    
    result = 0
    for i in range(1 + len(condition_record_part) - restGroupLength - group):
        if ('#' in condition_record_part[:i]) or ('.' in condition_record_part[i:i + group]) or (not isLast and condition_record_part[i + group] == '#'):
            # invalid group placement
            continue

        result += recursive(condition_record_part[i + group + (0 if isLast else 1):], groups[1:])

    return result

part1Result = 0
with open('input.txt', encoding="utf-8") as f:
    for line in f.readlines():
        split = line.split()
        condition_record, damaged_groups = split[0], list(map(lambda x: int(x), split[1].split(',')))
        possibilities = recursive(condition_record, damaged_groups)
        part1Result += possibilities

print('part 1 result: ', part1Result)

Part 2
Here I discarded my part 1 solution, because I suspected that the performance would blow up. So my part 2 solution is still recursive but stretched over multiple functions. My approach is to split the string in the middle if possible, then solve the smaller problems. I added a dictionary to memorise past results, which makes it a lot faster. Now it finishes in less than 10 seconds.
I struggled a lot with pythons slicing, because I’m not used to it, but I wanted to learn it.

import re
import math
import functools
import operator

operationalPattern = re.compile("[.]+")
damagedPattern = re.compile("[#]+")

dictionary = {}

def find_central_match(s, pattern) -> (int, int):
    length = len(s)
    middle = length // 2
    current_best = None
    dist = None
    index = 0

    while index < length:
        match = re.search(pattern, s[index:])
        if match is None:
            break

        (a, b) = match.span()
        (start, end) = (a + index, b + index)

        match_dist = min(abs(start - middle), abs(end - middle))

        if current_best == None or dist > match_dist:
            current_best = (start, end)
            dist = match_dist
        
        index = end

    return current_best

def binomial(n, k) -> int:
    return functools.reduce(operator.mul, range(n, n - k, -1), 1) / math.factorial(k)

# checks if dictionary contains a saved result for this
# calls the correct function for the job
def controller_function(record, groups) -> int:
    key = record + ''.join(str(x) + ',' for x in groups)
    savedResult = dictionary.get(key)

    if not savedResult == None:
        return savedResult

    if '.' in record:
        result = splitter_function(record, groups)
        dictionary[key] = result
        return result
    
    if '#' in record:
        result = assigner_function(record, groups)
        dictionary[key] = result
        return result
    
    result = multiplier_function(record, groups)
    dictionary[key] = result
    return result

# splits at one sequence of '...'
def splitter_function(record, groups) -> int:
    (split_start, split_end) = find_central_match(record, operationalPattern)

    result = 0
    for i in range(len(groups) + 1):

        result_left = controller_function(record[:split_start], groups[:i])
        if result_left == 0:
            continue

        result += result_left * controller_function(record[split_end:], groups[i:])

    return result

# chooses one sequence of consecutive '#', assigns a group to it and splits the problem
def assigner_function(record, groups) -> int:
    result = 0
    (start, end) = find_central_match(record, damagedPattern)

    for i, group in enumerate(groups):
        if group <= len(record) and group >= end - start:
            for pos_start in range(end - group, start + 1):
                if pos_start < 0 or pos_start + group > len(record):
                    continue

                if (pos_start > 0 and record[pos_start - 1] == '#') or (pos_start + group < len(record) and record[pos_start + group] == '#'):
                    continue

                left_result = controller_function(record[:max(0, pos_start - 1)], groups[:i])

                if left_result == 0:
                    continue

                result += left_result * controller_function(record[pos_start + group + 1:], groups[i + 1:])

    return result

# should only get called with consecutive '???...'
def multiplier_function(record, groups) -> int:
    free_space = len(record) - minRequiredSpace(groups)
    if free_space < 0:
        return 0
    
    groups_of_free_space = len(groups) + 1
    return binomial(free_space + groups_of_free_space - 1, groups_of_free_space - 1)

def minRequiredSpace(groups) -> int:
    length = len(groups)

    if length == 0:
        return 0
    
    return sum(groups) + length - 1

part2Result = 0
with open('input.txt', encoding="utf-8") as f:
    for line in f.readlines():
        split = line.split()
        base_record, base_groups = split[0], list(map(lambda x: int(x), split[1].split(',')))
        unfold_record = (base_record + '?') * 4 + base_record
        unfold_groups = base_groups * 5
        possibilities = controller_function(unfold_record, unfold_groups)
        part2Result += possibilities

print('part 2 result: ', part2Result)
1 Like

Merry Christmas everyone! I finally found some time this evening to finish 10p2 so I can go back and look at your hidden comments, and if you think you have an ugly solution, you should see mine!

I stubbornly stuck with my beloved single-pass algorithm, which works kind of like this:
  1. You identify groups as they appear
  2. If you later find that two groups are connected, fix up the data and keep going

Step 2 was pretty easy in part 1 - basically just add the lengths of the pipes.

But for part 2, I was using that same integral area formula that @antonTobi mentioned. You notice how he said this so casually?

Building up these groups piece by piece, I never got to know if I was walking them in the wrong direction, or which side was inside and outside. And it’s not just a sign flip because of the width of the pipe. So I had to keep track of two types of areas for each group in some giant arrays. And also some guess about whether I was “inside” or “outside” at each point.

So then part 2 becomes a mess of logic about how the “inner” and “outer” areas add together depending on whether I think I’m inside or outside each group. What a mess. I wasn’t even sure it was possible, but I forced my way through few example cases and eventually the thing worked.

The core mess:

# get rid of left_group and add its area to up_group
# (easier said than done)

if not up_inside[left_group] and up_inside[up_group]:
    new_ia = outer_area[left_group] + inner_area[up_group]
    new_oa = inner_area[left_group] + outer_area[up_group]
    down_inside[up_group] = not down_inside[left_group]
elif up_inside[left_group] and not up_inside[up_group]:
    new_ia = inner_area[left_group] + outer_area[up_group]
    new_oa = outer_area[left_group] + inner_area[up_group]
    up_inside[up_group] = True
    down_inside[up_group] = down_inside[left_group]
elif not up_inside[left_group] and not up_inside[up_group]:
    new_ia = inner_area[left_group] + inner_area[up_group]
    new_oa = outer_area[left_group] + outer_area[up_group]
    down_inside[up_group] = down_inside[left_group]
else:
    # inside both can't happen for a closed group, I think
    pass

inner_area[up_group] = new_ia
outer_area[up_group] = new_oa

It’s so asymmetric and amazing that it works.


Anyway, I may be behind, but I’ve got this cool menu system for running my programs:

4 Likes
Day 17

The problem can be modeled as a shortest path problem in a directed graph. Then there are multiple algorithms that can be applied. One of the most well-known (in my experience) is Dijkstras Algorithm.

Model
The graph should contain nodes for each position on the grid, but the position alone is not enough for determining where we can go next. So in this model, the graph contains nodes for combinations of the position and (what I call) forbidden direction. From one such node, we can move to nearby nodes whose position is not in the forbidden direction(s) of our current node.

I made the simplifying assumption that all nodes have forbidden direction(s) (except the start node). At each node we decide not only which direction to go to, but also how many steps before turning again.
For example for part 1, a node at position (x, y) with forbidden direction “right” is connected in the direction “down” to nodes (x, y + 1), (x, y + 2) and (x, y +3), where each of these destination nodes has forbidden direction “down”. Connections in other directions are analogous, except for the forbidden direction “right” which has no connections.
Note that the crucibles can’t turn around either, so it is sufficient to consider two forbidden directions (up&down, left&right).

Algorithm
I implemented a variation of Dijkstras Algorithm, and I’d describe it as Breadth-first Search with an ordered (priority-) queue.

Code
Part 1 (~1s)

from sortedcontainers import SortedList

# input

heat_map = []

with open('input.txt', encoding="utf-8") as f:
    for line in f.readlines():
        heat_map.append([int(c) for c in line.strip()])

# preliminaries

len_x = len(heat_map[0])
len_y = len(heat_map)

def in_range(x, y) -> bool:
    return 0 <= x and x < len_x and 0 <= y and y < len_y

def step(x, y, dir) -> (int, int):
    if dir == 0:
        # up
        return (x, y - 1)
    
    if dir == 1:
        # right
        return (x + 1, y)
    
    if dir == 2:
        #down
        return (x, y + 1)
    
    if dir == 3:
        #left
        return (x - 1, y)
    
class Crucible_Search_State:
    def __init__(self, x, y, heat_loss, forbidden_dir):
        self.x = x
        self.y = y
        self.heat_loss = heat_loss
        self.forbidden_dir = forbidden_dir

# computation

day1_result = None
start_state = Crucible_Search_State(0, 0, 0, None)
queue = SortedList([start_state], key=lambda x: x.heat_loss)
visited_map = [[[False, False] for x in row] for row in heat_map]

while(len(queue) > 0):
    state = queue.pop(0)

    if not state.forbidden_dir is None:
        if visited_map[state.y][state.x][state.forbidden_dir] == True:
            continue

        visited_map[state.y][state.x][state.forbidden_dir] = True

    if (state.x == len_x - 1) and (state.y == len_y - 1):
        day1_result = state.heat_loss
        break

    for direction in range(4):
        if direction % 2 == state.forbidden_dir:
            continue
    
        (x, y, heat_loss) = (state.x, state.y, state.heat_loss)

        for steps in range(1, 4):
            (x, y) = step(x, y, direction)

            if not in_range(x, y):
                break

            heat_loss += heat_map[y][x]

            if not visited_map[y][x][direction % 2] == True:
                queue.add(Crucible_Search_State(x, y, heat_loss, direction % 2))

print('day 1 result: ', day1_result)

Part 2 (~2s) is almost the same as part 1, but taking steps in [4, 10] instead of [1, 3]

from sortedcontainers import SortedList

# input

heat_map = []

with open('input.txt', encoding="utf-8") as f:
    for line in f.readlines():
        heat_map.append([int(c) for c in line.strip()])

# preliminaries

len_x = len(heat_map[0])
len_y = len(heat_map)

def in_range(x, y) -> bool:
    return 0 <= x and x < len_x and 0 <= y and y < len_y

def step(x, y, dir) -> (int, int):
    if dir == 0:
        # up
        return (x, y - 1)
    
    if dir == 1:
        # right
        return (x + 1, y)
    
    if dir == 2:
        #down
        return (x, y + 1)
    
    if dir == 3:
        #left
        return (x - 1, y)
    
class Crucible_Search_State:
    def __init__(self, x, y, heat_loss, forbidden_dir):
        self.x = x
        self.y = y
        self.heat_loss = heat_loss
        self.forbidden_dir = forbidden_dir

# computation

day2_result = None
start_state = Crucible_Search_State(0, 0, 0, None)
queue = SortedList([start_state], key=lambda x: x.heat_loss)
visited_map = [[[False, False] for x in row] for row in heat_map]

while(len(queue) > 0):
    state = queue.pop(0)

    if not state.forbidden_dir is None:
        if visited_map[state.y][state.x][state.forbidden_dir] == True:
            continue

        visited_map[state.y][state.x][state.forbidden_dir] = True

    if (state.x == len_x - 1) and (state.y == len_y - 1):
        day2_result = state.heat_loss
        break

    for direction in range(4):
        if direction % 2 == state.forbidden_dir:
            continue
    
        (x, y, heat_loss) = (state.x, state.y, state.heat_loss)

        for steps in range(1, 11):
            (x, y) = step(x, y, direction)

            if not in_range(x, y):
                break

            heat_loss += heat_map[y][x]

            if steps < 4:
                continue

            if not visited_map[y][x][direction % 2] == True:
                queue.add(Crucible_Search_State(x, y, heat_loss, direction % 2))

print('day 2 result: ', day2_result)
2 Likes

I’m amazed that works too, I would never have the stubborn mindedness or dedication to keep going with that approach @Feijoa. The ascii flood fill method from day10p2 comes in very useful for a later day. I’m going to slow down now and probably finish the last few puzzles in the new year. Need to brush up on some algorithms, graphs, dynamic memoization etc. to solve them. You should definitely get some C on that Pico and ditch the micropython :stuck_out_tongue_winking_eye:

3 Likes

:gift: :christmas_tree: :snowflake:
I completed the 2023 Advent of Code!
:snowman_with_snow: :elf: :technologist:t2:

7 Likes
Day 25

The problem is closely related to Minimum Cut Problems, and via a Theorem from Optimisation theory to the Maximum Flow Problem.
So my approach is to find a max flow for some start- and end-nodes. Then the min cut between them can be found via a flood-fill in the so-called Residual Graph (where edge capacities are updated with respect to the existing flow).
As to which start and end node to choose … I just tried some different choices until I found two with the correct max-flow value :sweat_smile:

Also – Yay I finished Advent of Code 2023 :smiley:

class LabeledNode:
    def __init__(self, label, id):
        self.label = label
        self.neighbours = []
        self.id = id

    def connect(self, node):
        self.neighbours.append(node)
        node.neighbours.append(self)

nodes = []

with open('input.txt', encoding="utf-8") as f:
    for line in f.readlines():
        line_split = line.strip().split(' ')
        main_label = line_split[0][:-1]
        neighbour_labels = line_split[1:]

        if any(True for node in nodes if node.label == main_label):
            node = next(node for node in nodes if node.label == main_label)
        else:
            node = LabeledNode(main_label, len(nodes))
            nodes.append(node)

        for label in neighbour_labels:

            if any(True for node in nodes if node.label == label):
                neighbour = next(node for node in nodes if node.label == label)
            else:
                neighbour = LabeledNode(label, len(nodes))
                nodes.append(neighbour)

            if not neighbour in node.neighbours:
                node.connect(neighbour)

nodes.sort(key=lambda n: n.id)

# Edge Capacity
C = [[(1 if n in node.neighbours else 0) for n in nodes] for node in nodes]

def get_residual_capacity(capacities, flow, i, j):
    return capacities[j][i] - flow[j][i] + flow[i][j]

def find_max_flow(nodes, capacities, start, end):
    # Flow
    F = [[0 for n in nodes] for node in nodes]
    
    increased_flow = True
    flow_value = 0

    while increased_flow:
        increased_flow = False

        Visited = [False for n in nodes]
        Visited[start.id] = True
        queue = [(start, [start])]

        # search for a start-end path to increase flow on
        while len(queue) > 0:
            node, path = queue.pop()

            # did we find a path?
            if node == end:
                bottleneck = min(get_residual_capacity(capacities, F, path[i].id, path[i + 1].id) for i in range(len(path) - 1))

                # increase flow
                for i in range(len(path) - 1):
                    F[path[i + 1].id][path[i].id] += bottleneck

                flow_value += bottleneck
                increased_flow = True
                break

            for neighbour in node.neighbours:
                if Visited[neighbour.id]:
                    continue

                residual_cap = get_residual_capacity(capacities, F, node.id, neighbour.id)

                if residual_cap > 0:
                    extended_path = path + [neighbour]
                    queue.append((neighbour, extended_path))
                    Visited[neighbour.id] = True

    return (F, flow_value)

def find_flow_cut(capacities, flow, start):
    queue = [start]
    cut = [start]

    while len(queue) > 0:
        node = queue.pop()

        for neighbour in node.neighbours:
            if (not neighbour in cut) and get_residual_capacity(capacities, flow, node.id, neighbour.id) > 0:
                cut.append(neighbour)
                queue.append(neighbour)

    edges = []
    for node in cut:
        for neighbour in node.neighbours:
            if not neighbour in cut:
                edges.append([node, neighbour])

    return cut, edges

start = next(node for node in nodes if node.label == 'zvk')
end = next(node for node in nodes if node.label == 'rcj')

F, flow_value = find_max_flow(nodes, C, start, end)

cut, edges = find_flow_cut(C, F, start)

print(len(cut) * (len(nodes) - len(cut)))
print(len(edges))
2 Likes
Day 25 Discussion

The version of the min-cut problem that you describe is also known as the “s-t cut” problem, where there is a given pair of start and end nodes that must be separated by the cut.

The version of the problem where one just needs to cut to the graph into two parts is also known as the “global cut” problem, and there is an algorithm that directly tackles that type of problem: Karger's algorithm - Wikipedia

Day 25 is directly the global min-cut problem, and even the graph is provided in a convenient adjacency list format.

Actually, I did not know any of the details of how to solve the min-cut problem, so I just read the above linked Wikipedia page and implemented the described Karger’s algorithm:

import random

lines = open("25input", 'r').readlines()
#lines = open("25test", 'r').readlines()

edges = {}

def reset_edges():
    global edges
    edges = {}
    for line in lines:
        node, rest = line.strip().split(": ")
        edges[node] = rest.split(" ")

def num_edges():
    total = 0
    for node in edges:
        total += len(edges[node])
    return total

def num_nodes():
    return len(edges.keys())

def rand_edge():
    index = random.randrange(0, num_edges())
    for node in edges:
        if index < len(edges[node]):
            return node, edges[node][index]
        index -= len(edges[node])

def contract_edge(a, b):
    new_node = a + b
    new_edges = []
    if a in edges:
        new_edges += edges[a]
        del edges[a]
    if b in edges:
        new_edges += edges[b]
        del edges[b]
    while a in new_edges:
        new_edges.remove(a)
    while b in new_edges:
        new_edges.remove(b)
    for node in edges:
        while a in edges[node]:
            edges[node].remove(a)
            edges[node].append(new_node)
        while b in edges[node]:
            edges[node].remove(b)
            edges[node].append(new_node)
    edges[new_node] = new_edges

reset_edges()
print(num_edges())
print(num_nodes())

count = 0
while num_edges() > 3:
    count += 1
    print("starting:", count)
    reset_edges()
    while num_nodes() > 2:
        a, b = rand_edge()
        contract_edge(a, b)
        # print(num_nodes(), num_edges())

value = [len(key) / 3 for key in edges]
print(value[0] * value[1])

Since Day 25, tells us that the min-cut is three, this randomized algorithm can simply be run until that is found. There is a Karger-Stein refinement of this algorithm (mentioned on the Wikipedia page) that runs a bit faster, but the current code ran fast enough that I got an answer, while still beginning to think about how to modify in order to implement the Karger-Stein version.

1 Like

Ugh, I really don’t ever want to go back to programming in C (except as necessary to submit patches to MicroPython).

Re: problem 12 part 2

I don’t know if mine is elegant, but I didn’t have stack space for recursion and instead of memoization (which I think means you had to get a final number before saving it?) I kept track of a small stack of all current patterns under consideration, together with multiplicity numbers. If I found a duplicate I just added to the multiplicity number instead of adding it as a new entry to the stack. Also, to keep the stack as small as possible, I reduced all runs of .... to a single . and eliminated impossible patterns as early as possible.

Like this:

def matches(pattern, groups):
    dots = re.compile('\.+')
    patterns = [dots.sub('.',pattern)]
    muls = [1]
    
    m = 0
    while len(patterns) > 0:
        #print(str(m)+" "+str(patterns))
        #print(str(muls))
        p = patterns.pop(0)
        mul = muls.pop(0)
        
        i = p.find('?')
        if i == -1: # no more choices
            if possible(p, groups):
                m += mul
            continue

        s1 = dots.sub('.', p[:i] + '.' + p[i+1:])
        s2 = (p[:i] + '#' + p[i+1:])
        
        if possible(s1, groups):
            if s1 in patterns:
                muls[patterns.index(s1)] += mul
            else:
                patterns.append(s1)
                muls.append(mul)
        if possible(s2, groups):
            if s2 in patterns:
                muls[patterns.index(s2)] += mul
            else:
                patterns.append(s2)
                muls.append(mul)
                
    return m

In part 1 I was able to eliminate possibilities using regular expressions, but unfortunately the size of the patterns required for part 2 caused MicroPython to crash in some pretty extreme way. So I filed a bug report and rewrote the testing using my own simpler pattern-matching code.

Ultimately it took about an hour to run.

3 Likes
Further problem 12 discussion

Is this purely because of your limited hardware environment? How much recursion does that support? In my code, the recursion is depth is no more than the number of “?” symbols in the input (or the number of groups), which seems fairly manageable. An hour seems quite long, even for the limited hardware. How fast does it run on a normal PC?

My approach is essentially like a depth first search over the possibilities, except when backtracking, notes are kept on the solutions for each encountered trailing pattern and remaining groups. Thus, on subsequent searches, these notes can be consulted, cutting short the depth of search. I think this leads to quadratic complexity, and here are the timings that I get when scaling up to even larger repeats:

repeat = 1 (part 1) is 0.03 seconds
repeat = 5 (part 2) is 0.25 seconds
repeat = 10 is 1.08 seconds
repeat = 20 is 6.2 seconds
repeat = 30 is 19.5 seconds

I don’t really understand your code. How do the multiplicity numbers work?

2 Likes
From a quick test, it appears that

recursion is limited to 32 levels on the RP2040:

def f(i):
    print(i)
    f(i+1)
f(0)

...
29
30
31
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
...
  File "<stdin>", line 2, in f
RuntimeError: maximum recursion depth exceeded

That’s plenty for iterative code, but with one function call per ?, even the example input is too much to handle. I guess you might hope to be saved by some kind of tail recursion optimization but I’m not sure how that could work in a situation with branching like this.

Also, I don’t think I’d have much luck with memoization even iteratively since for some examples I’d need to store thousands of different patterns in the very limited RAM.

My stack approach uses a breadth-first search, pushing all possibilities under consideration in a list and expanding them in FIFO order. So for this input

.??. 1

It expands it iteratively like this:

['.??.']
['.?.', '.#?.']
['.#?.', '.#.']
['.#.']

In the last line, instead of storing ['.#.', '.#.'], it recognizes the duplicate and assigns the single entry multiplicity two. This generally seems to keep the stack to a reasonable size. For example, with this input:

.??????????. 1,1

It starts encountering duplicates on the sixth iteration, when it finds two different ways to reach .#.???????., and the stack only ever grows to a maximum size of six. On the actual puzzle input, it seems to usually be in the range of 20-50 entries.

I hadn’t tried MicroPython on a PC before, but to answer your question, I built it just now and on my laptop (Ryzen 7 7730U, WSL Ubuntu), the same code (with display routines swapped out for print) runs in just 1.5s. Looks like with CPython 3 it takes about 7s. I’m actually a little surprised since I would have expected just a factor of a hundred or so from the difference in processors, but my RP2040 is apparently thousands of times slower than a PC. I suppose that to build and destroy all these lists over and over, it has to do a lot of memory management types of tasks that can be handled much more efficiently on a 64-bit multi-core PC.

2 Likes

There was a problem I saw on the physics board and it was something like finding a probability distribution P(x) and the rules were

Pick an x in (0,1) and let d=min(x,1-x)

Flip a coin and if heads move to x+d and if tails move to x-d

If you land on 1 you win, if you land on 0 you lose.

If you haven’t reached 0 or 1 then repeat the process

x n+1= x n ± d n depending on the coin flip,

dn+1 = min(xn+1, 1-x n+1).

And so you have to find P(x) the chance of winning, reaching 1, for a given x in (0,1).


Ideas:

My feeling after a few minutes was that P(x)=x could be a reasonable candidate.

I think you can show that P(1/2)=1/2, P(1/4)=1/4, P(3/4)= 3/4 and continue to extend it to n/2^k with n odd.

Maybe you can show that the probability of winning right of 1/2 is the same as losing to the left of 1/2. That is P(1-x) = 1 - P(x). Maybe.

If it turns out that the probability doesn’t go wild when you move to non dyadic rationals then maybe you can argue something about how it behaves for irrationals.

I didn’t really go beyond that, I had finished eating and we started learning to play mahjong instead. I would be curious if someone already knows the problem, and the distribution.

It could always be something wild like Minkowski's question-mark function - Wikipedia


It’s probably also something one can simulate by choosing random numbers and iterating a tonne of times to generate an approximate distribution numerically.

4 Likes

I randomly came across this problem in my youtube recommendations just the other day!

(this video contains some different viewer-submitted approaches to solving the problem)

This other problem from the same channel is also a good fit for this thread:

(this is just a short video introducing the problem, no spoilers for the solution)

3 Likes

The dice video reminded me of this:

2 Likes

I’m guessing that’s why someone wrote it up then, they just came across some popular video. I’ll watch the links, read the comments in a while :slight_smile:

Edit: also the circle business on a 1d interval seemed a bit pointless to me, so that’s why I cut it out of my description :slight_smile:

I had not seen this thread before, Go and Competitive Programming - what a crossover!

Anyway assuming people in this thread already like coding solutions to programming challenges but might not be aware of competitive programming in general, I would strongly recommend to check up codeforces and atcoder. There are many other competitive programming online judges, but these are some of the most well knowns and are great. I would even argue that Codeforces has de facto become “the main community hub / forum / discussion-point” for the Competitive Programming community (despite its very weird interface).

Mind that Project Euler has deliberately “math-style” problems, while these other sites are on average more algorithmic/computer-science problems, although you could totally find intersection between both.

2 Likes

Thanks all for reminding me about Euler. After a long break I started on it again today and was immediately rewarded with making it to level 8 by solving the tricky #836 A Bold Proposition - Project Euler. :slight_smile:

4 Likes

Advent of Code 2024 just started:

https://adventofcode.com/2024

I was planning not to get sucked in again but I already started. Not sure how much I’ll do this time, but if you want to join the private OGS leaderboard let me know!

6 Likes

Ooh can I join??

3 Likes

Of course! I sent you the code.

2 Likes