Advent Of Code 2022 - Day 12: Hill Climbing Algorithm
Dec 12, 2022
It's December. Time for snow, slippery roads, hot chocolate and cozy fire places. Also time for Advent of Code. An advent calendar with small, daily programming puzzles, growing progressively more difficult.
Every year I participate in a programming language I did not use for Advent of Code before, in order to learn new ways of doing things and to challenge myself. This year, that language is F#.
Day 12: Hill Climbing Algorithm
Summary: Given a hill, represented as a grid of characters where a
represents
height 0 and z
height 26, what is the shortest path from start (height a
,
represented by S
) finish (height z
, represented by E
). You cannot climb
up a height distance greater than 1.
Example input:
Sabqponm abcryxxl accszExk acctuvwj abdefghi
Read the full problem statement here.
Despite the name and description of today's problem, we don't actually have to implement a hill climbing algorithm.
There are various ways to find the shortest path from A to B. One way is the
breadth first search, or BFS. The limitation of using BFS for efficiently
finding the shortest path is that all paths between neighbouring locations must
have the same length. In this case that's true. They all have length 1
.
Explaning Breadth First Search
Imagine a tree structure. It has a root and some children.
A / \ B C / \ / \ D E F G
A breadth first search will search this tree one level at a time. First we
examine A
. Then we examine B
and C
. Then we examine D
, E
, F
and
G
. If E
is what we're looking for, we'll find it at a depth of 2. So the
shortest (and in this case only) path from A
to E
is 2.
Our grid is not a tree structure. However, we can represent it as one. We
call the top left corner where S
is in the sample input (0,0)
. That's also
our root. It's neighbours are the children. Their neighbours are their children,
and so on.
(0,0) /-----/ \------\ (0,1) (1,0) / | \ / | \ (0,0)(0,2)(1,1) (0,0)(2,0)(1,1)
As you can see from this example there are some duplicates. That also means that
there are multiple paths from the start to those points. In this short example
all paths are possible, but if we were to extend the tree with one more level
we'd end up with a path from (2,0)
to (3,0)
, but the height difference is
too large for that path to be taken.
What that means is that we don't have to examine every path in this tree. We can
discard invalid paths and paths to points we've already seen. After all, there
exists a path from (0,0)
to (0,0)
that take 2 steps, but there's also one
that takes 0 steps (not moving at all!)
Implementing BFS
The way we implement BFS is by using a queue. A queue is a data structure that lets you get elements out of it in the same order you put them in.
Start by queueing (0,0)
. Then, as long as the queue isn't empty, take the next
node off the queue. (0,0)
in this case. Examine its children. If we want to
process them, put them in the queue. The queue may now look like (0,1);(1,0)
. Take the
next element off the queue: (0,1)
. Examine its children and put the ones we want
to process on the queue. We've already seen (0,0)
so we don't care about that
one. The others are new so we put them on the queue: (1,0);(0,2);(1,1)
. When
we take the next element off the queue we're still looking at the points
directly connected to (0,0)
. We keep searching until we find the
destination. If the queue is empty then no more nodes have to be examined.
In order to determine how far away the destination is we need to queue a
combination of both the distance and the point. For example: (0,(0,0))
. Then
when we put the children on the queue we increment the distance, so the queue
becomes: (1,(0,1));(1,(1,0))
. When we look at (1,(0,1))
and put its
children on the queue, the queue then becomes (1,(1,0));(2,(0,2));(2,(1,1))
.
In F#, without mutation, here's how that can look:
let bfs start finishFn validFn (grid: 'a array array) = let rec loop queue seen = if Queue.isEmpty queue then None else let (dist, (p1, p2)) = Queue.front queue if finishFn ((p1, p2), grid.[p1].[p2]) then Some dist else let (newQ, newSeen) = Seq.fold (fun (newQ, newSeen) npos -> if not <| Set.contains npos newSeen && validFn ((p1, p2), grid.[p1].[p2]) (npos, grid.[fst npos].[snd npos]) then (Queue.enqueue (dist + 1, npos) newQ, Set.add npos newSeen) else (newQ, newSeen)) <| (Queue.dequeue queue, seen) <| (Array.neighbours <| p1 <| p2 <| grid) loop newQ newSeen let queue = Queue.enqueue (0, start) Queue.empty let seen = Set.add start Set.empty loop queue seen
The function bfs
takes four arguments. start
is our starting
location. finishFn
is a function that we'll call to check if we're
done. validFn
is a fcuntion we'll call to check if a neighbour is one we can
go to. grid
is our grid.
The loop
function is the meat. It implements the algorithm described above. If
the queue is empty we return None
to indicate that no answer was found. This
isn't always what you want but for today's problem it is. This is a case we
shouldn't reach in today's problem because a path is guaranteed, but it's good
to be complete so we can reuse this later.
If the queue is not empty then we'll look at the first element on it. We run it
against finishFn
. If that returns true then we return Some dist
, the
distance from the starting node. Otherwise we build a new queue, folding over
the neighbours. If we've not seen the neighbour before and it's a valid
destination then we mark it as seen and put it on the queue. FInally we call
loop
recursively with the new queue and seen collections.
Array.neighbours
is a helper function that I'm not sure I've placed in the
correct module, but it returns the indices of the neighbours.
let neighbours index1 index2 (array: 'a array array) = seq { let deltas = [(0, 1); (0, -1); (1, 0); (-1, 0)] for (di1, di2) in deltas do let newI1 = index1 + di1 let newI2 = index2 + di2 if newI1 >= 0 && newI1 < Array.length array && newI2 >= 0 && newI2 < Array.length array[newI1] then yield (newI1, newI2) }
Solving the problem
Algorithm understood and implementation done is the hard part in solving the problem.
Parsing consists of two parts. First we convert the grid to heights.
let rec parseChr = function | 'S' -> parseChr 'a' | 'E' -> parseChr 'z' | c -> Convert.ToInt32(c) - Convert.ToInt32('a') let parseLine line = line |> Array.map parseChr
We also need the starting position. In F#, finding the index in an array of
arrays is a bit clunky, so I wrote a helper. Even though breaks and early
returns aren't a thing in F#, this function will exit early because Seq.pick
only evaluates the sequence until the first time it finds a Some
value.
let findIndex2D predicate (array: 'a array array) = Seq.pick id <| seq { for y = 0 to Array.length array - 1 do for x = 0 to Array.length array[y] - 1 do if predicate array.[y].[x] then yield Some (y, x) else yield None }
We can then use the helper to find S
and E
in the input.
let parse (input: string list) = let asArray = input |> List.map Seq.toArray |> List.toArray (Array.findIndex2D ((=) 'S') asArray, Array.findIndex2D ((=) 'E') asArray, asArray |> Array.map parseLine)
With all that prep work we can now solve the problem in just a few lines. We
parse the input and call our bfs
function. We start at start
. finishFn
checks that we've reached the finish. validFn
checks that we never climb up
more than 1 hight difference.
let solve1 (input: string list) = let (start, finish, grid) = parse input bfs start (fun (pos, _) -> pos = finish) (fun (_, h) (_, h2) -> h2 - h <= 1) grid |> Option.defaultValue -1
Part 2
Summary: Find the shortest path from any point with height 0 to the end.
There are two ways we can solve this problem. One way is to pass all points
with height 0 as starting points. The fact that we never look at any point twice
guarantees that we will find the shortest path to the end. That, however, would
require us to change our bfs
function.
What we can do instead is find the reverse path. We start at finish
and stop
at the first position with height 0. We also have to reverse the height
difference check, because we're checking in the wrong direction.
let solve2 (input: string list) = let (start, finish, grid) = parse input bfs finish (fun (_, h) -> h = 0) (fun (_, h2) (_, h) -> h2 - h <= 1) grid |> Option.defaultValue -1
Improvements
I've noticed that in many posts the improvements are pretty much the same. I
hack together a solution using for
-loops and mutation and then refactor the
mutation away, changing the for
-loop into a fold
or a scan
.
In most cases I can also extract the solution to part 1, make one or two things slightly configurable and pass those in both parts.
I'll leave these kinds of improvements out of this section for now and just immediately describe them as they've ended up after refactoring. If I learn something new then it will still end up in this section.
Reflection
We've reached this Advent of Code's first shortest path problem. If I write BFS in my favourite competitive programming language then I can do it in a few minutes. In my Advent of Code choices I tend to spend significantly more time. Sometimes hours. This time I finished in under 30 minutes, which I was very happy about.
On to the next one!
The full code for the day is on GitHub.