Day 16: Reindeer Maze

Megathread guidelines

  • Keep top level comments as only solutions, if you want to say something other than a solution put it in a new post. (replies to comments can be whatever)
  • You can send code in code blocks by using three backticks, the code, and then three backticks or use something such as https://topaz.github.io/paste/ if you prefer sending it through a URL

FAQ

  • @Pyro@programming.dev
    link
    fedilink
    2
    edit-2
    22 hours ago

    Python

    Part 1: Run Dijkstra’s algorithm to find shortest path.

    I chose to represent nodes using the location (i, j) as well as the direction dir faced by the reindeer.
    Initially I tried creating the complete adjacency graph but that lead to max recursion so I ended up populating graph for only the nodes I was currently exploring.

    Part 2: Track paths while performing Dijkstra’s algorithm.

    First, I modified the algorithm to look through neighbors with equal cost along with the ones with lesser cost, so that it would go through all shortest paths.
    Then, I keep track of the list of previous nodes for every node explored.
    Finally, I use those lists to run through the paths backwards, taking note of all unique locations.

    Code:
    import os
    
    # paths
    here = os.path.dirname(os.path.abspath(__file__))
    filepath = os.path.join(here, "input.txt")
    
    # read input
    with open(filepath, mode="r", encoding="utf8") as f:
        data = f.read()
    
    from collections import defaultdict
    from dataclasses import dataclass
    import heapq as hq
    import math
    
    # up, right, down left
    DIRECTIONS = [(-1, 0), (0, 1), (1, 0), (0, -1)]
    
    
    # Represent a node using its location and the direction
    @dataclass(frozen=True)
    class Node:
        i: int
        j: int
        dir: int
    
    
    maze = data.splitlines()
    m, n = len(maze), len(maze[0])
    
    # we always start from bottom-left corner (facing east)
    start_node = Node(m - 2, 1, 1)
    # we always end in top-right corner (direction doesn't matter)
    end_node = Node(1, n - 2, -1)
    
    # the graph will be updated lazily because it is too much processing
    #   to completely populate it beforehand
    graph = defaultdict(list)
    # track nodes whose all edges have been explored
    visited = set()
    # heap to choose next node to explore
    # need to add id as middle tuple element so that nodes dont get compared
    min_heap = [(0, id(start_node), start_node)]
    # min distance from start_node to node so far
    # missing values are treated as math.inf
    min_dist = {}
    min_dist[start_node] = 0
    # keep track of all previous nodes for making path
    prev_nodes = defaultdict(list)
    
    
    # utility method for debugging (prints the map)
    def print_map(current_node, prev_nodes):
        pns = set((n.i, n.j) for n in prev_nodes)
        for i in range(m):
            for j in range(n):
                if i == current_node.i and j == current_node.j:
                    print("X", end="")
                elif (i, j) in pns:
                    print("O", end="")
                else:
                    print(maze[i][j], end="")
            print()
    
    
    # Run Dijkstra's algo
    while min_heap:
        cost_to_node, _, node = hq.heappop(min_heap)
        if node in visited:
            continue
        visited.add(node)
    
        # early exit in the case we have explored all paths to the finish
        if node.i == end_node.i and node.j == end_node.j:
            # assign end so that we know which direction end was reached by
            end_node = node
            break
    
        # update adjacency graph from current node
        di, dj = DIRECTIONS[node.dir]
        if maze[node.i + di][node.j + dj] != "#":
            moved_node = Node(node.i + di, node.j + dj, node.dir)
            graph[node].append((moved_node, 1))
        for x in range(3):
            rotated_node = Node(node.i, node.j, (node.dir + x + 1) % 4)
            graph[node].append((rotated_node, 1000))
    
        # explore edges
        for neighbor, cost in graph[node]:
            cost_to_neighbor = cost_to_node + cost
            # The following condition was changed from > to >= because we also want to explore
            #   paths with the same cost, not just better cost
            if min_dist.get(neighbor, math.inf) >= cost_to_neighbor:
                min_dist[neighbor] = cost_to_neighbor
                prev_nodes[neighbor].append(node)
                # need to add id as middle tuple element so that nodes dont get compared
                hq.heappush(min_heap, (cost_to_neighbor, id(neighbor), neighbor))
    
    print(f"Part 1: {min_dist[end_node]}")
    
    # PART II: Run through the path backwards, making note of all coords
    
    visited = set([start_node])
    path_locs = set([(start_node.i, start_node.j)])  # all unique locations in path
    stack = [end_node]
    
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
    
        path_locs.add((node.i, node.j))
    
        for prev_node in prev_nodes[node]:
            stack.append(prev_node)
    
    print(f"Part 2: {len(path_locs)}")
    
    
    • @Acters@lemmy.world
      link
      fedilink
      2
      edit-2
      6 hours ago

      only improvement I can think of is to implement a dead end finder to block for the search algorithm to skip all dead ends that do not have the end tile (“E”). by block I mean artificially add a wall to the entrance of the dead end. this should help make it so that it doesn’t go down dead ends. It would be improbable but there might be an input with a ridiculously long dead end.

      • @Pyro@programming.dev
        link
        fedilink
        13 hours ago

        Interesting, how would one write such a finder? I can only think of backtracking DFS, but that seems like it would outweigh the savings.

        • @Acters@lemmy.world
          link
          fedilink
          1
          edit-2
          7 minutes ago

          ah well, my idea is at high level view. Here is a naive approach that should accomplish this. Not sure how else I would accomplish this without more thought put in to make it faster:

          [ Paste ]

          edit: whoops, sorry had broke the regex string and had to check for E and S is not deleted lol

          This is how the first example would look like:

          ###############
          #...#####....E#
          #.#.#####.###.#
          #.....###...#.#
          #.###.#####.#.#
          #.###.......#.#
          #.#######.###.#
          #...........#.#
          ###.#.#####.#.#
          #...#.....#.#.#
          #.#.#.###.#.#.#
          #.....#...#.#.#
          #.###.#.#.#.#.#
          #S###.....#...#
          ###############
          

          This is how the second example would look like:

          #################
          #...#...#...#..E#
          #.#.#.#.#.#.#.#.#
          #.#.#.#...#...#.#
          #.#.#.#####.#.#.#
          #...#.###.....#.#
          #.#.#.###.#####.#
          #.#...###.#.....#
          #.#.#####.#.###.#
          #.#.###.....#...#
          #.#.###.#####.###
          #.#.#...###...###
          #.#.#.#####.#####
          #.#.#.......#####
          #.#.#.###########
          #S#...###########
          #################
          

          It has more noticeable improvement on larger maps, and especially effective if there are no loops! (i.e. one path) because it would just remove all paths that will lead to a dead end.

    • @hades@lemm.ee
      link
      fedilink
      319 hours ago

      prev_nodes[neighbor].append(node)

      I think you’re adding too many neighbours to the prev_nodes here potentially. At the time you explore the edge, you’re not yet sure if the path to the edge’s target via the current node will be the cheapest.

      • @Pyro@programming.dev
        link
        fedilink
        3
        edit-2
        11 hours ago

        Good catch! IIRC, only when a node is selected from the min heap can we guarantee that the cost to that node will not go any lower. This definitely seems like a bug, but I still got the correct answer for the samples and my input somehow ¯\_(ツ)_/¯