You try contacting the Elves using your handheld device, but the river you're following must be too low to get a decent signal.
You ask the device for a heightmap of the surrounding area (your puzzle input). The heightmap shows the local area from above broken into a grid; the elevation of each square of the grid is given by a single lowercase letter, where a
is the lowest elevation, b
is the next-lowest, and so on up to the highest elevation, z
.
Also included on the heightmap are marks for your current position (S
) and the location that should get the best signal (E
). Your current position (S
) has elevation a, and the location that should get the best signal (
E) has elevation
z`.
You'd like to reach E
, but to save energy, you should do it in as few steps as possible. During each step, you can move exactly one square up, down, left, or right. To avoid needing to get out your climbing gear, the elevation of the destination square can be at most one higher than the elevation of your current square; that is, if your current elevation is m
, you could step to elevation n
, but not to elevation o
. (This also means that the elevation of the destination square can be much lower than the elevation of your current square.)
For example:
Sabqponm
abcryxxl
accszExk
acctuvwj
abdefghi
Here, you start in the top-left corner; your goal is near the middle. You could start by moving down or right, but eventually you'll need to head toward the e at the bottom. From there, you can spiral around to the goal:
v..v<<<<
>v.vv<<^
.>vv>E^^
..v>>>^^
..>>>>>^
In the above diagram, the symbols indicate whether the path exits each square moving up (^
), down (v
), left (<
), or right (>
). The location that should get the best signal is still E
, and .
marks unvisited squares.
This path reaches the goal in 31 steps, the fewest possible.
What is the fewest steps required to move from your current position to the location that should get the best signal?
# Python imports
import networkx as nx
import numpy as np
from pathlib import Path
from typing import Generator
test = Path("data/day12_test.txt") # test data
data = Path("data/day12_data.txt") # puzzle data
def parse_input(fpath: Path) -> tuple[np.array, tuple[int, int], tuple[int, int]]:
"""Parse puzzle input into a heightmap array and start/endpoint tuples
:param fpath: path to puzzle input
"""
with fpath.open() as ifh:
data = []
for line in [_.strip() for _ in ifh.readlines()]:
data.append([ord(_) for _ in line])
data = np.array(data, dtype=int)
startpos = tuple(map(int, np.where(data == 83)))
endpos = tuple(map(int, np.where(data == 69)))
return data, startpos, endpos
def heightmap_to_graph(hmap: np.array) -> nx.DiGraph:
"""Convert heightmap array to NetworkX graph
:param hmap: heightmap as numpy array
Iterate over elements in the hmap, joining NSEW elements if their
relative values correspond to the puzzle rules.
I guess this is 'cheating' to the extent that I'm not coding A*
or Dijkstra myself, and relying on NetworkX's implementation to
find the shortest path.
"""
gph = nx.DiGraph() # all valid paths are stored as a DiGraph
for ridx in range(hmap.shape[0]): # Iterate over all positions in the map
for cidx in range(hmap.shape[1]):
if (ridx, cidx) not in gph.nodes: # add current position if not seen yet
gph.add_node((ridx, cidx))
for rpos, cpos in [(ridx, cidx + 1), (ridx + 1, cidx),
(ridx - 1, cidx), (ridx, cidx - 1)]: # check NSEW positions
if rpos < 0 or cpos < 0 or rpos > hmap.shape[0]-1 or cpos > hmap.shape[1]-1:
continue # target node out of bounds
else: # target node in bounds
if (rpos, cpos) not in gph.nodes: # add target node if not seen yet
gph.add_node((rpos, cpos))
if hmap[ridx][cidx] == 83 and hmap[rpos][cpos] == ord("a"): # start
gph.add_edge((ridx, cidx), (rpos, cpos)) # step from start
elif hmap[ridx][cidx] == ord("z") and hmap[rpos][cpos] == 69: # end
gph.add_edge((ridx, cidx), (rpos, cpos)) # step to end
elif (hmap[rpos][cpos] - hmap[ridx][cidx] < 2) and \
(hmap[rpos][cpos] not in (69, 83)) and \
(hmap[ridx][cidx] not in (69, 83)):
gph.add_edge((ridx, cidx), (rpos, cpos))
return gph
def part1(fpath: Path) -> int:
"""Solve the puzzle
:param fpath: path to puzzle input
"""
heightmap, startpos, endpos = parse_input(fpath)
heightgraph = heightmap_to_graph(heightmap)
path = nx.shortest_path(heightgraph, startpos, endpos)
# Return number of edges in path, not number of nodes (so subtract 1)
return len(path) - 1
part1(test)
31
part1(data)
449
As you walk up the hill, you suspect that the Elves will want to turn this into a hiking trail. The beginning isn't very scenic, though; perhaps you can find a better starting point.
To maximize exercise while hiking, the trail should start as low as possible: elevation a
. The goal is still the square marked E
. However, the trail should still be direct, taking the fewest steps to reach its goal. So, you'll need to find the shortest path from any square at elevation a
to the square marked E
.
Again consider the example from above:
Sabqponm
abcryxxl
accszExk
acctuvwj
abdefghi
Now, there are six choices for starting position (five marked a
, plus the square marked S
that counts as being at elevation a
). If you start at the bottom-left square, you can reach the goal most quickly:
...v<<<<
...vv<<^
...v>E^^
.>v>>>^^
>^>>>>>^
This path reaches the goal in only 29 steps, the fewest possible.
What is the fewest steps required to move starting from any square with elevation a to the location that should get the best signal?
def get_lowest_positions(heightmap: np.array) -> Generator[tuple[int, int], None, None]:
"""Return generator of lowest point position tuples
:param heightmap: numpy array of landscape heights
"""
return zip(*np.where((heightmap == 83 ) | (heightmap == ord("a"))))
def part2(fpath: Path) -> int:
"""Solve the puzzle
:param fpath: path to puzzle input
"""
heightmap, startpos, endpos = parse_input(fpath)
heightgraph = heightmap_to_graph(heightmap)
pathlens = []
for startpos in get_lowest_positions(heightmap): # iterate over all lowest positions
try:
pathlens.append(len(nx.shortest_path(heightgraph, startpos, endpos)) - 1)
except nx.exception.NetworkXNoPath: # some start points are in isolated lacunae
continue
return min(pathlens)
part2(test)
29
part2(data)
443