Sean McLemon | Advent of Code

Home | Czech | Blog | GitHub | Advent Of Code | Notes


2019-12-18 - Many-Worlds Interpretation

(original .ipynb)

NOTE: Incomplete!!!

Day 18 puzzle input is a maze (mine is here) where capital letters represent doors, that require keys (lower case letters) to open (i.e. you need to collect "a" if you want to traverse through door "A"). Part 1 involves finding the fastest route through this maze to collect all keys. Part 2 involves splitting the maze into quadrants and having four different things moving through the maze. Ugh.

I kind of hated this one too - it's like travelling salesman if after visiting a node the graph changed :-/

from itertools import combinations


def create_node_namer():
    n = 0
    while True:
        yield f"_{n}"
        n += 1
        
def is_space(s):
    return s == "."

def is_wall(s):
    return s == "#"

def is_connecting(s):
    return s[0] == "_"

def is_key(s):
    if (len(s) > 1):
        return False
    
    return ord(s) >= ord("a") and ord(s) <= ord("z")
    
def is_door(s):
    if (len(s) > 1):
        return False
    
    return ord(s) >= ord("A") and ord(s) <= ord("Z")

def graph_delete(graph, node, ignore_error=False):
    if not node in graph:
        if ignore_error:
            return
        raise Exception(f"Node {node} was not found in graph, can't delete")
    
    neighbours = graph[node]
    new_edges = list(combinations([n for n in neighbours], 2))
    
    for edge in new_edges:
        node_1, node_2 = edge
        new_distance = neighbours[node_1] + neighbours[node_2]
        
        # we can add this edge if:
        #   1. the nodes weren't already connected
        #   2. the nodes were connected, but this edge is cheaper
        if (not node_2 in graph[node_1]) or graph[node_1][node_2] > new_distance:
            graph[node_1][node_2] = new_distance
            graph[node_2][node_1] = new_distance

    # finally clean up all references to our old node
    for neighbour in neighbours:
        del graph[neighbour][node]
    del graph[node]
    
    
def cheapest_next_key(graph, from_node):
    neighbours = graph[from_node]
    neighbouring_keys = [ n for n in neighbours if is_key(n) ]
    sorted_neighbouring_keys = sorted(neighbouring_keys, key=lambda n: neighbours[n])
    return sorted_neighbouring_keys[0]

def shake_graph(graph):
    should_delete_door = lambda node: is_door(node) and len(graph[node]) == 1
    should_delete_connector = lambda node: is_connecting(node) and len(graph[node]) < 3
    nodes_to_delete = [ node for node in graph if should_delete_door(node) or should_delete_connector(node) ]
            
    while nodes_to_delete:
        for node in nodes_to_delete:
            graph_delete(graph, node)
        nodes_to_delete = [ node for node in graph if should_delete_door(node) or should_delete_connector(node) ]

def parse_maze(maze_str):
    # 1. split into lines, and then lines into list of strings: ("a...b" => [ "a", ".", ".", ".", "c"])
    maze_lines = []
    for line_str in maze_str.split("\n"):
        maze_lines.append([c for c in line_str])

    # 2. find the spaces that connect two levels vertically, and swap in a string with a name that
    #    doesn't clash like "_1"
    connecting_tiles = []
    for line_idx, line in enumerate(maze_lines):
        for tile_idx, tile in enumerate(line):
            # we don't want to replace if it's a wall or non-space
            if is_wall(tile) or not is_space(tile):
                continue
                
            should_replace = False
            if line_idx > 0:
                tile_above = maze_lines[line_idx - 1][tile_idx]
                if not is_wall(tile_above):
                    should_replace = True

            if line_idx < len(maze_lines):
                tile_below = maze_lines[line_idx + 1][tile_idx]
                if not is_wall(tile_below):
                    should_replace = True

            if should_replace:
                connecting_tiles.append((line_idx, tile_idx))

    namer = create_node_namer()
    for (line_idx, tile_idx) in connecting_tiles:
        maze_lines[line_idx][tile_idx] =  next(namer)


    # 3. start creating the graph by finding horizontal edges
    #    so for "a...b" we'll add {"a": {"b": 4}, "b": {"a": 4}}
    from collections import defaultdict
    graph = defaultdict(dict)
    for line_idx, line in enumerate(maze_lines):
        current_node = None
        current_count = 0
        for tile_idx, tile in enumerate(line):
            current_count += 1

            # if we hit a wall, we need to reset current_node/count
            if is_wall(tile):
                current_node = None
                current_count = 0
                continue

            if not is_space(tile):
                if current_node:
                    graph[current_node][tile] = current_count
                    graph[tile][current_node] = current_count
                current_node = tile
                current_count = 0

    # 4. add the vertical edges (they'll reach DOWN until they find a non-space node)
    for line_idx, line in enumerate(maze_lines):
        for tile_idx, tile in enumerate(line):
            if is_wall(tile) or is_space(tile):
                continue

            count = 0

            next_line_idx = line_idx + 1
            while next_line_idx < len(maze_lines):
                count += 1
                next_tile = maze_lines[next_line_idx][tile_idx]
                
                if is_space(next_tile):
                    next_line_idx += 1
                    continue
                    
                if is_wall(next_tile):
                    break
                
                graph[tile][next_tile] = count
                graph[next_tile][tile] = count
                break

    # 5. finally we'll make a pass to remove:
    #    - doors that have only one neighbour (dead end)
    #    - connectors unless they're a real choice (>2 neighbours)
    #    and update graph accordingly. we'll need to do this until we return no nodes to delete
    should_delete_door = lambda node: is_door(node) and len(graph[node]) == 1
#     should_delete_connector = lambda node: is_connecting(node) and len(graph[node]) < 3
    should_delete_connector = lambda node: is_connecting(node) 

    nodes_to_delete = [ node for node in graph if should_delete_door(node) or should_delete_connector(node) ]
            
    while nodes_to_delete:
        for node in nodes_to_delete:
            graph_delete(graph, node)
        nodes_to_delete = [ node for node in graph if should_delete_door(node) or should_delete_connector(node) ]
    
    return graph
from copy import deepcopy
import math

class SearchState:
    def __init__(self, graph, current, visited, route, steps=0):
        self.graph = graph
        self.current = current
        self.visited = visited
        self.route = route
        self.steps = steps

    def move(self, node):
        neighbours = self.graph[self.current]
        
        if not (node in neighbours):
            raise Exception(f"cannot move from {self.current} to {node}. valid moves: {neighbours.keys}")
                
        visited = self.visited.union(set([node]))
        route = self.route + [node]
        steps = self.steps + neighbours[node]
        
        new_graph = deepcopy(self.graph)
        graph_delete(new_graph, self.current)
        if is_key(node):
            # ignore_error=True, in case there's no door for the key
            graph_delete(new_graph, node.upper(), True)
        
#         if all_keys.issubset(visited):
#             solutions.append(steps)
#             return None
        
        should_delete_door = lambda node: is_door(node) and len(new_graph[node]) == 1
        should_delete_connector = lambda node: is_connecting(node) and len(new_graph[node]) < 3
        nodes_to_delete = [ node for node in new_graph if should_delete_door(node) or should_delete_connector(node) ]

        while nodes_to_delete:
            for node in nodes_to_delete:
                graph_delete(new_graph, node)
            nodes_to_delete = [ node for node in new_graph if should_delete_door(node) or should_delete_connector(node) ]
        
        
        return SearchState(new_graph, node, visited, route, steps)
    
    def __repr__(self):
        return "->".join(self.route)    
    
    def __str__(self):
        return "->".join(self.route)
                            
def step(state):
    to_visit = [ node for node in state.graph[state.current] if not is_door(node) ]
    new_states = [ state.move(node) for node in to_visit ]
    return [ s for s in new_states if s ]

def step_all(states):
    new_states = []
    for state in states:
        new_states += step(state)
    # assume that anything outside the quickest 2048 so far are probably not the fastest solutions
    return sorted(new_states, key=lambda state: state.steps)[:10000]

def solve2(graph):
    global solutions
    global all_keys
    
    solutions = []
    all_keys = set([ node for node in graph if is_key(node) ])
    
    states = [
        SearchState(graph, "@", set(), [])
    ]
    
    while len(solutions) < 1:
        pprint(states)
        states = step_all(states)
        
    print(sorted(solutions)[0])
    print("----------------------------")

    
def solve_inner(state, indent=""):
    global all_keys
    global total
    global best

    # if we're already more than the limit, give up
    if best < state.steps:
        return math.inf
    
#     print(f"{indent}{state.current}")
    if all_keys.issubset(state.visited):
        total += 1
        
        if state.steps < best:
            best = state.steps
            print(f"new best: {best}")

        if total % 100 == 0:
            print(f"found {total} solutions")
        
        # print(f"new solution: {state.steps} (best is {best})")
        return state.steps

    neighbours = [ n for n in state.graph[state.current] if not is_door(n) ]
    
    if not neighbours:
        return math.inf
    
    return min([ solve_inner(state.move(n), indent+" ") for n in neighbours])


best = math.inf
total = 0
def solve(graph):
    global all_keys
    global best
    global total
    pprint(graph)
    
    total = 0
    all_keys = set([ node for node in graph if is_key(node) ])    
    best = math.inf
    ans = solve_inner(SearchState(graph, "@", set(), []))
    print(ans)
    print("------------------------")
    return ans
from pprint import pprint

solutions = []
all_keys = None

maze_str_0 = """#########
#b.A.@.a#
#########"""
graph_0 = parse_maze(maze_str_0)


maze_str_1 = """########################
#f.D.E.e.C.b.A.@.a.B.c.#
######################.#
#d.....................#
########################"""
graph_1 = parse_maze(maze_str_1)

maze_str_2 = """########################
#...............b.C.D.f#
#.######################
#.....@.a.B.c.d.A.e.F.g#
########################"""
graph_2 = parse_maze(maze_str_2)

maze_str_3 = """#################
#i.G..c...e..H.p#
########.########
#j.A..b...f..D.o#
########@########
#k.E..a...g..B.n#
########.########
#l.F..d...h..C.m#
#################"""
graph_3 = parse_maze(maze_str_3)

maze_str_4 = """########################
#@..............ac.GI.b#
###d#e#f################
###A#B#C################
###g#h#i################
########################"""
graph_4 = parse_maze(maze_str_4)


solve(graph_0)
solve(graph_1)
solve(graph_2)
solve(graph_3)
solve(graph_4)
defaultdict(,
            {'@': {'A': 2, 'a': 2},
             'A': {'@': 2, 'b': 2},
             'a': {'@': 2},
             'b': {'A': 2}})
new best: 8
8
------------------------
defaultdict(,
            {'@': {'A': 2, 'a': 2},
             'A': {'@': 2, 'b': 2},
             'B': {'a': 2, 'c': 2},
             'C': {'b': 2, 'e': 2},
             'D': {'E': 2, 'f': 2},
             'E': {'D': 2, 'e': 2},
             'a': {'@': 2, 'B': 2},
             'b': {'A': 2, 'C': 2},
             'c': {'B': 2, 'd': 24},
             'd': {'c': 24},
             'e': {'C': 2, 'E': 2},
             'f': {'D': 2}})
new best: 86
86
------------------------
defaultdict(,
            {'@': {'a': 2, 'b': 22},
             'A': {'d': 2, 'e': 2},
             'B': {'a': 2, 'c': 2},
             'C': {'D': 2, 'b': 2},
             'D': {'C': 2, 'f': 2},
             'F': {'e': 2, 'g': 2},
             'a': {'@': 2, 'B': 2},
             'b': {'@': 22, 'C': 2},
             'c': {'B': 2, 'd': 2},
             'd': {'A': 2, 'c': 2},
             'e': {'A': 2, 'F': 2},
             'f': {'D': 2},
             'g': {'F': 2}})
new best: 144
new best: 136
new best: 132
132
------------------------
defaultdict(,
            {'@': {'a': 3,
                   'b': 3,
                   'c': 5,
                   'd': 5,
                   'e': 5,
                   'f': 3,
                   'g': 3,
                   'h': 5},
             'A': {'b': 3, 'j': 2},
             'B': {'g': 3, 'n': 2},
             'C': {'h': 3, 'm': 2},
             'D': {'f': 3, 'o': 2},
             'E': {'a': 3, 'k': 2},
             'F': {'d': 3, 'l': 2},
             'G': {'c': 3, 'i': 2},
             'H': {'e': 3, 'p': 2},
             'a': {'@': 3, 'E': 3, 'd': 6, 'g': 4, 'h': 6},
             'b': {'@': 3, 'A': 3, 'c': 6, 'e': 6, 'f': 4},
             'c': {'@': 5, 'G': 3, 'b': 6, 'e': 4, 'f': 6},
             'd': {'@': 5, 'F': 3, 'a': 6, 'g': 6, 'h': 4},
             'e': {'@': 5, 'H': 3, 'b': 6, 'c': 4, 'f': 6},
             'f': {'@': 3, 'D': 3, 'b': 4, 'c': 6, 'e': 6},
             'g': {'@': 3, 'B': 3, 'a': 4, 'd': 6, 'h': 6},
             'h': {'@': 5, 'C': 3, 'a': 6, 'd': 4, 'g': 6},
             'i': {'G': 2},
             'j': {'A': 2},
             'k': {'E': 2},
             'l': {'F': 2},
             'm': {'C': 2},
             'n': {'B': 2},
             'o': {'D': 2},
             'p': {'H': 2}})
new best: 168
new best: 166
new best: 164
new best: 162
new best: 158
new best: 156
new best: 154
new best: 152
new best: 150
new best: 148
found 100 solutions
new best: 146
new best: 144
new best: 142
new best: 140
new best: 138
new best: 136
found 200 solutions
puzzle_input_str = open("puzzle_input/day18.txt", "r").read()
puzzle_input = parse_maze(puzzle_input_str)

# doors = [ n for n in puzzle_input if is_door(n) ]

# door_neighbours = [ (d,len(puzzle_input[d])) for d in doors ]
# print(doors)
# print(len(puzzle_input.keys()))
# pprint(puzzle_input)
solve(puzzle_input)
defaultdict(,
            {'@': {'B': 131,
                   'E': 63,
                   'S': 27,
                   'T': 259,
                   'W': 239,
                   'Y': 275,
                   'e': 52,
                   'n': 194,
                   'o': 68,
                   's': 14,
                   'u': 32},
             'A': {'J': 26, 'a': 11, 'j': 7},
             'B': {'@': 131,
                   'E': 192,
                   'S': 156,
                   'T': 130,
                   'W': 370,
                   'Y': 146,
                   'e': 83,
                   'n': 65,
                   'o': 197,
                   's': 145,
                   'u': 161},
             'C': {'G': 90, 'K': 48, 'R': 72, 'Y': 26, 'a': 95, 'z': 3},
             'E': {'@': 63,
                   'B': 192,
                   'O': 18,
                   'S': 86,
                   'T': 320,
                   'W': 300,
                   'X': 34,
                   'Y': 336,
                   'e': 113,
                   'n': 255,
                   'o': 131,
                   's': 75,
                   'u': 95},
             'F': {'N': 36, 'Q': 54, 'm': 57},
             'G': {'C': 90, 'K': 44, 'R': 20, 'Y': 98, 'a': 11},
             'H': {'h': 9, 'l': 11},
             'J': {'A': 26, 'a': 27, 'h': 7},
             'K': {'C': 48, 'G': 44, 'R': 26, 'Y': 56, 'a': 49, 'r': 9},
             'N': {'F': 36, 'i': 11},
             'O': {'E': 18, 'X': 42},
             'P': {'p': 9, 'v': 9},
             'Q': {'F': 54, 'm': 61, 'o': 49, 'x': 29},
             'R': {'C': 72, 'G': 20, 'K': 26, 'Y': 80, 'a': 25, 'g': 9},
             'S': {'@': 27,
                   'B': 156,
                   'E': 86,
                   'T': 284,
                   'W': 264,
                   'Y': 300,
                   'e': 77,
                   'n': 219,
                   'o': 95,
                   's': 39,
                   'u': 59},
             'T': {'@': 259,
                   'B': 130,
                   'E': 320,
                   'S': 284,
                   'W': 498,
                   'Y': 30,
                   'e': 211,
                   'n': 73,
                   'o': 325,
                   's': 273,
                   'u': 289},
             'W': {'@': 239,
                   'B': 370,
                   'E': 300,
                   'S': 264,
                   'T': 498,
                   'Y': 514,
                   'e': 291,
                   'n': 433,
                   'o': 305,
                   's': 229,
                   't': 11,
                   'u': 269},
             'X': {'E': 34, 'O': 42, 'q': 9},
             'Y': {'@': 275,
                   'B': 146,
                   'C': 26,
                   'E': 336,
                   'G': 98,
                   'K': 56,
                   'R': 80,
                   'S': 300,
                   'T': 30,
                   'W': 514,
                   'a': 103,
                   'e': 227,
                   'n': 89,
                   'o': 341,
                   's': 289,
                   'u': 305},
             'a': {'A': 11,
                   'C': 95,
                   'G': 11,
                   'J': 27,
                   'K': 49,
                   'R': 25,
                   'Y': 103},
             'b': {'f': 52, 'q': 18},
             'c': {'i': 70, 'k': 32, 'w': 76},
             'd': {'s': 126},
             'e': {'@': 52,
                   'B': 83,
                   'E': 113,
                   'S': 77,
                   'T': 211,
                   'W': 291,
                   'Y': 227,
                   'n': 146,
                   'o': 118,
                   's': 66,
                   'u': 82},
             'f': {'b': 52, 'q': 54},
             'g': {'R': 9},
             'h': {'H': 9, 'J': 7},
             'i': {'N': 11, 'c': 70, 'w': 18},
             'j': {'A': 7},
             'k': {'c': 32},
             'l': {'H': 11, 'p': 16},
             'm': {'F': 57, 'Q': 61},
             'n': {'@': 194,
                   'B': 65,
                   'E': 255,
                   'S': 219,
                   'T': 73,
                   'W': 433,
                   'Y': 89,
                   'e': 146,
                   'o': 260,
                   's': 208,
                   'u': 224},
             'o': {'@': 68,
                   'B': 197,
                   'E': 131,
                   'Q': 49,
                   'S': 95,
                   'T': 325,
                   'W': 305,
                   'Y': 341,
                   'e': 118,
                   'n': 260,
                   's': 80,
                   'u': 92,
                   'x': 24},
             'p': {'P': 9, 'l': 16},
             'q': {'X': 9, 'b': 18, 'f': 54},
             'r': {'K': 9},
             's': {'@': 14,
                   'B': 145,
                   'E': 75,
                   'S': 39,
                   'T': 273,
                   'W': 229,
                   'Y': 289,
                   'd': 126,
                   'e': 66,
                   'n': 208,
                   'o': 80,
                   'u': 44},
             't': {'W': 11, 'y': 24},
             'u': {'@': 32,
                   'B': 161,
                   'E': 95,
                   'S': 59,
                   'T': 289,
                   'W': 269,
                   'Y': 305,
                   'e': 82,
                   'n': 224,
                   'o': 92,
                   's': 44},
             'v': {'P': 9},
             'w': {'c': 76, 'i': 18},
             'x': {'Q': 29, 'o': 24},
             'y': {'t': 24},
             'z': {'C': 3}})
new best: 3334
new best: 3274
new best: 3258
new best: 3198
new best: 3150
new best: 3102
new best: 3082
new best: 3078
new best: 3070
new best: 3050
found 100 solutions

My algorithm is extremely suboptimal so I just kept this running for 10 minutes or so before coming back and trying the latest "new best" value (which was 3048). This clearly won't work for Part 2 but I cannot motivate myself to revisit this and rewrite it from scratch.