Advent of Code - 2021
This is a solution to Day 15 of Advent of Code 2021.
Day 15 - Chiton
You've almost reached the exit of the cave, but the walls are getting closer together. Your submarine can barely still fit, though; the main problem is that the walls of the cave are covered in chitons, and it would be best not to bump any of them.
The cavern is large, but has a very low ceiling, restricting your motion to two dimensions. The shape of the cavern resembles a square; a quick scan of chiton density produces a map of risk level throughout the cave (your puzzle input). For example:
1163751742 1381373672 2136511328 3694931569 7463417111 1319128137 1359912421 3125421639 1293138521 2311944581
You start in the top left position, your destination is the bottom right position, and you cannot move diagonally. The number at each position is its risk level; to determine the total risk of an entire path, add up the risk levels of each position you enter (that is, don't count the risk level of your starting position unless you enter it; leaving it adds no risk to your total).
Your goal is to find a path with the lowest total risk. In this example, a path with the lowest total risk is highlighted here:
1163751742 1381373672 2136511328 3694931569 7463417111 1319128137 1359912421 3125421639 1293138521 2311944581
The total risk of this path is 40 (the starting position is never entered, so its risk is not counted).
Read input
from utils import read_input
def transformer(line):
return [int(v) for v in list(line)]
grid = read_input(15, transformer)
Helper functions to calculate score and to print the grid with path walked
def score_path(path, grid):
return sum(grid[y][x] for (x, y) in path if (x,y) != start_position)
def print_grid(grid, path):
m = len(grid)
for row in range(m):
n = len(grid[row])
for col in range(n):
if (col, row) in path:
print(f'({grid[row][col]})',end="")
else:
print(f' {grid[row][col]} ', end="")
print()
Once again we're getting neighbors and I feel like every time my solution is slightly different.
Maybe I should extract this into a utility function one day soon.
def get_neighbors(position, grid):
max_x = len(grid[0])
max_y = len(grid)
x, y = position
all_neighbors = [
(x, y+1),
(x, y-1),
(x-1, y),
(x+1, y)
]
if x-1 < 0:
all_neighbors.remove((x-1, y))
if x+1 >= max_x:
all_neighbors.remove((x+1, y))
if y-1 < 0:
all_neighbors.remove((x, y-1))
if y+1 >= max_y:
all_neighbors.remove((x, y+1))
return all_neighbors
I was expecting to see a path finding puzzle soon in Advent of Code and today we got one.
A* search algorithm is what I remember from algorithm courses and Youtube' game dev log videos so let's use that!
I struggled quite a bit with a few things but for some reason, my main bug was not in my implementation of A* like I imagined but how I used PriorityQueue.
Reading through the A* pseudocode, we need two helpers here, denoted d
and h
in the Wikipedia algorithm linked above.
Our d
function is just the risk value of the position so let's build get_risk
.
Our heuristic function h
is Manhattan distance which is the sum of |x1 - x2|
and |y1 - y2|
in 2D space.
def get_risk(position, grid):
x, y = position
return grid[y][x]
def manhattan(pos1, pos2):
return sum((abs(pos1[0] - pos2[0]), abs(pos1[1] - pos2[1])))
The A* function a_star
is just an implementation from Wikipedia since I'm nowhere smart enough to figure this stuff out myself.
This was my first time using PriorityQueue
which is a queue which returns the smallest value when called .get()
. We store (risk, position)
combinations there and .get()
uses the first value in the tuple to determine priority.
from collections import defaultdict
from queue import PriorityQueue
import math
def reconstruct_path(path, current):
total_path = [current]
while current in path:
current = path[current]
total_path.append(current)
total_path.reverse()
return total_path
def a_star(grid, start, goal, h, d):
open_set = PriorityQueue()
open_set.put((0, start))
path = {}
g_score = defaultdict(lambda: math.inf)
g_score[start] = 0
f_score = defaultdict(lambda: math.inf)
f_score[start] = h(start, goal)
while not open_set.empty():
risk, current = open_set.get()
if current == goal:
return reconstruct_path(path, current)
for neighbor in get_neighbors(current, grid):
tentative_g_score = g_score[current] + d(neighbor, grid)
if tentative_g_score < g_score[neighbor]:
path[neighbor] = current
g_score[neighbor] = tentative_g_score
f_score[neighbor] = tentative_g_score + h(neighbor, goal)
if neighbor not in open_set.queue:
open_set.put((tentative_g_score + h(neighbor, goal), neighbor))
return False
What is the lowest total risk of any path from the top left to the bottom right?
start_position = (0,0)
end_position = (len(grid[0]) - 1, len(grid) - 1)
path = a_star(grid, start_position, end_position, manhattan, get_risk)
score = score_path(path, grid)
print(f'Solution: {score}')
assert score == 741
Solution: 741
Part 2
Now that you know how to find low-risk paths in the cave, you can try to find your way out.
The entire cave is actually five times larger in both dimensions than you thought; the area you originally scanned is just one tile in a 5x5 tile area that forms the full map. Your original map tile repeats to the right and downward; each time the tile repeats to the right or downward, all of its risk levels are 1 higher than the tile immediately up or left of it. However, risk levels above 9 wrap back around to 1. So, if your original map had some position with a risk level of 8, then that same position on each of the 25 total tiles would be as follows:
8 9 1 2 3 9 1 2 3 4 1 2 3 4 5 2 3 4 5 6 3 4 5 6 7
Each single digit above corresponds to the example position with a value of 8 on the top-left tile. Because the full map is actually five times larger in both dimensions, that position appears a total of 25 times, once in each duplicated tile, with the values shown above.
I originally had this brilliant idea that I don't need to actually generate the missing pieces of the grid, just adjust how d
and get_neighbors
work. But I kept getting wrong answer after wrong answer.
And then I realized, I had jumped into conclusions without reading (again).
I had missed this:
However, risk levels above 9 wrap back around to 1.
So my solution was moot.
Then I caved in (pun intended) and just created a new, expanded grid.
It turned out to be the better solution regardless because it keeps everything else the same.
def expand_grid(grid, n=5):
new_grid = []
l = len(grid)
for y in range(l * n):
new_grid.append([])
for x in range(l * n):
orig_x = x % l
orig_y = y % l
orig_value = grid[orig_y][orig_x]
new_value = (orig_value + x // l + y // l)
if new_value > 9:
new_value = new_value - 9
new_grid[y].append(new_value)
return new_grid
Using the full map, what is the lowest total risk of any path from the top left to the bottom right?
new_grid = expand_grid(grid)
end_position = (len(new_grid[0]) - 1, len(new_grid) - 1)
path = a_star(new_grid, start_position, end_position, manhattan, get_risk)
score = score_path(path, new_grid)
print(f'Solution: {score}')
assert score == 2976
Solution: 2976
Appendix A: Debug path printer
Since the real grids are too big to deal with visually, I want to show what my path printer looks like with the example input.
It's not easy to visualize grids with just ASCII, especially if you want to display any kind of extra information like in this case the path. I chose to surround visited cells with parenthesis and non-visited with empty space.
For boolean grids, I've earlier used a variation of '#" for True
or visited and either .
or
for False
or non-visited cells. Both options have their pros and cons.
I added a neat example
flag to my input reader so I can easily get the example data in.
example_grid = read_input(15, transformer, example=True)
end_position = (len(example_grid[0]) - 1, len(example_grid) - 1)
path = a_star(example_grid, start_position, end_position, manhattan, get_risk)
print_grid(example_grid, path)
(1) 1 6 3 7 5 1 7 4 2
(1) 3 8 1 3 7 3 6 7 2
(2)(1)(3)(6)(5)(1)(1) 3 2 8
3 6 9 4 9 3 (1)(5) 6 9
7 4 6 3 4 1 7 (1) 1 1
1 3 1 9 1 2 8 (1)(3) 7
1 3 5 9 9 1 2 4 (2) 1
3 1 2 5 4 2 1 6 (3) 9
1 2 9 3 1 3 8 5 (2)(1)
2 3 1 1 9 4 4 5 8 (1)