Day 17: Two Steps Forward

author: Harshvardhan Pandit

license: MIT

link to problem statement

You're trying to access a secure vault protected by a 4x4 grid of small rooms connected by doors. You start in the top-left room (marked S), and you can access the vault (marked V) once you reach the bottom-right room:

#########
#S| | | #
#-#-#-#-#
# | | | #
#-#-#-#-#
# | | | #
#-#-#-#-#
# | | |  
####### V

Fixed walls are marked with #, and doors are marked with - or |.

The doors in your current room are either open or closed (and locked) based on the hexadecimal MD5 hash of a passcode (your puzzle input) followed by a sequence of uppercase characters representing the path you have taken so far (U for up, D for down, L for left, and R for right).

Only the first four characters of the hash are used; they represent, respectively, the doors up, down, left, and right from your current position. Any b, c, d, e, or f means that the corresponding door is open; any other character (any number or a) means that the corresponding door is closed and locked.

To access the vault, all you need to do is reach the bottom-right room; reaching this room opens the vault and all doors in the maze.

For example, suppose the passcode is hijkl. Initially, you have taken no steps, and so your path is empty: you simply find the MD5 hash of hijkl alone. The first four characters of this hash are ced9, which indicate that up is open (c), down is open (e), left is open (d), and right is closed and locked (9). Because you start in the top-left corner, there are no "up" or "left" doors to be open, so your only choice is down.

Next, having gone only one step (down, or D), you find the hash of hijklD. This produces f2bc, which indicates that you can go back up, left (but that's a wall), or right. Going right means hashing hijklDR to get 5745 - all doors closed and locked. However, going up instead is worthwhile: even though it returns you to the room you started in, your path would then be DU, opening a different set of doors.

After going DU (and then hashing hijklDU to get 528e), only the right door is open; after going DUR, all doors lock. (Fortunately, your actual passcode is not hijkl).

Passcodes actually used by Easter Bunny Vault Security do allow access to the vault if you know the right path. For example:

  • If your passcode were ihgpwlah, the shortest path would be DDRRRD.
  • With kglvqrro, the shortest path would be DDUDRLRRUDRD.
  • With ulqzkmiv, the shortest would be DRURDRUDDLLDLUURRDULRLDUUDDDRR.

Given your vault's passcode, what is the shortest path (the actual path, not just the length) to reach the vault?

Solution logic

This is quite similar to Day 13, where we had to find a short path through the maze. This time around, whether the next point is valid or not (its door is open or closed) depends on the path taken to reach there (so like life!).

At any point, whether the doors are open or not is dependant on the path taken to reach that room. In specific, the MD5 checksum of the input string suffixed by the path - as directions abbreviated:

U    UP
D    DOWN
L    LEFT
R    RIGHT

Therefore, the strategy to solve this must be the same as that used on Day 13, where we use a queue to store the paths we have traversed. There is a case that must be stated before we begin - this time, if we move back to a previous point, the doors that are open might change as the path itself has changed. So instead of a queue, we'll use a priority heap to store that paths and work only on those paths that have not been iterated yet.

The priority will be the length of the path - this way, the agorithm will try to iterate over all the shorter paths first - of do a BFS (breadth-first-search). Another option to use instead of a heap is to use a sorted list. Python has the bisect module that can maintain a sorted list.

import bisect
items = []
bisect.insort(items, element)
items.pop(0) <-- smallest element

Algorithm:

  • initialize priority heap with the starting state
  • while heap has states:
    • pop state <-- this will be the one with the most 'priority'
    • check if state is end state:
      • if yes, print answer and exit
    • get all open doors:
      • if next point is not valid, discard it
      • append the direction to the current path
      • push it back on the heap

Input and Test data


In [1]:
with open('../inputs/day17.txt', 'r') as f:
    path_string = f.readline().strip()

TEST_DATA = (
    'ihgpwlah',
    'kglvqrro',
    'ulqzkmiv'
)

Point

As with before, having a namedtuple makes the code cleaner and easier to read.


In [2]:
from collections import namedtuple
Point = namedtuple('Point', ('x', 'y', 'path'))

Checksum

Calculating the checksum is simply taking the MD5 hash of it.


In [3]:
import hashlib

def md5(string):
    md5 = hashlib.md5()
    md5.update(string.encode('ascii'))
    return md5.hexdigest()

Directions

We maintain a tuple of points for directions. This allows the direction to match the specific character in the checksum.

1st character signals open door --> 1st element in tuple gives direction

In [4]:
directions = (
    Point(0, -1, 'U'),
    Point(0, 1, 'D'),
    Point(-1, 0, 'L'),
    Point(1, 0, 'R')
)

Start and End states

It is very important to know what exactly is the starting and ending state of the algorithm. In this case, the ending state is the room at the very bottom right, with the co-ordinates (3, 3). Since we do not know its path, and it is not required to declare it, we use None instead.


In [5]:
start_point = Point(0, 0, path_string)
end_point = Point(3, 3, None)

Validating points / moves

A point is valid if it stays within the boundaries of the 4 x 4 matrix.


In [6]:
def valid_point(point):
    if 0 <= point.x < 4 and 0 <= point.y < 4:
        return True

Checking for open doors

We take the first four characters of the checksum, compare them against the given condition of being less than 10 or 'a' which signlas a closed door. Return only directions that are valid - open doors.

'a' or less --> closed
others      --> open

In [7]:
def open_doors(checksum):
    def is_open(character):
        if character > 'a':
            return True
        return False
    
    return [
        directions[index] 
        for index, character in enumerate(checksum[:4]) 
        if is_open(character)]

Heap

We declare a states list for holding the heap. The heap contains only the starting point at this time. While the length of the path string at this point is the length of the input, it is not a valid answer nor reflects the final path string. I chose to use the length of the entire string so as to avoid calculating the difference between the input string and the direction string everytime. It also makes code easier to read, IMHO.


In [8]:
from heapq import heappush, heappop

states = []
heappush(states, (len(start_point.path), start_point))

Run the algorithm


In [9]:
while states:
    _, current_state = heappop(states)
    if current_state.x == end_point.x and current_state.y == end_point.y:
        print('answer', current_state.path[len(path_string):])
        break
    moves = open_doors(md5(current_state.path))
    for move in moves:
        new_path = Point(
            current_state.x + move.x,
            current_state.y + move.y,
            current_state.path + move.path)
        if not valid_point(new_path):
            continue
        heappush(states, (len(new_path.path), new_path))


answer RDRLDRDURD

Part Two

You're curious how robust this security solution really is, and so you decide to find longer and longer paths which still provide access to the vault. You remember that paths always end the first time they reach the bottom-right room (that is, they can never pass through it, only end in it).

For example:

  • If your passcode were ihgpwlah, the longest path would take 370 steps.
  • With kglvqrro, the longest path would be 492 steps long.
  • With ulqzkmiv, the longest path would be 830 steps long.

What is the length of the longest path that reaches the vault?

Solution logic

Now instead of finding the shortest path, we have to find the longest path. While earlier we used BFS, which was great for iterating over shorter path lengths, this time, we must use DFS (depth-first-search) to iterate over the longer branches first. This won't make much of a difference, as we have to iterate all paths anyways.

In depth first search, instead of a heap or queue, we use a stack where we insert and remove elements from the end. This allows consecutive iteration of the last state - hence giving it depth.

We store the lengths of all paths reaching the end point, and later retrieve the largest of them.

Note: The paths stored are prefixed with the input, therefore for the final answer, it is necessary to subtract the length of the input string from it.


In [10]:
states = [(0, start_point)]
paths = []

while states:
    _, current_state = states.pop()
    if current_state.x == end_point.x and current_state.y == end_point.y:
        # print(len(current_state.path))
        paths.append(len(current_state.path))
        continue
    moves = open_doors(md5(current_state.path))
    for move in moves:
        new_path = Point(
            current_state.x + move.x,
            current_state.y + move.y,
            current_state.path + move.path)
        if not valid_point(new_path):
            continue
        states.append((len(new_path.path), new_path))

print('answer', max(paths) - len(path_string))


answer 596

== END ==