12
votes
Day 12: Passage Pathing
Today's problem description: https://adventofcode.com/2021/day/12
Please post your solutions in your own top-level comment. Here's a template you can copy-paste into your comment to format it nicely, with the code collapsed by default inside an expandable section with syntax highlighting (you can replace python
with any of the "short names" listed in this page of supported languages):
<details>
<summary>Part 1</summary>
```python
Your code here.
```
</details>
This was a neat maze. It took a while to grok the problem, but I quite liked part 1. Part 2 takes 14 seconds to run on my machine but it has minimal changes from part 1, so eh.
Part 1
Part 2
This is one of those recursive problems that can sometimes get me into trouble in languages like Ruby that like to pass collections by mutable references. I rarely want recursive calls to be able to mutate lists/sets that are still being used further up the stack, but in Ruby you have to be careful to call
.clone
to ensure that won't happen. I always forget this at least once during AoC, and it always causes me some headaches.Part 1 Ruby
Part 2 Ruby
This set-oriented approach I tend to lean towards is easy to reason about, but performance in this case is definitely on the lower end (~2.5s on my machine). Alternatives could be tracking the current path and counting occurrences, or just keeping a flag for whether we've passed any small cave twice so far. In that vein, I think it'd also probably be cleaner to combine the idea of the
visit_counts
andclosed_caves
into just having a set of visited small caves and the flag.Part 1, Python(-ish)
Part 2, Python(-ish)
Caching gives a big speedup if you do a recursive graph search like I did. With caching, part 2 only takes ~ 105 ms on my laptop (or 210 ms if you include interpreter startup time).
I legit stared at this problem for 15 minutes before I wrote any code. Eventually I solved part A and moved on to B. Getting a working test case for B didn't take too long, but when I went to run the real solution it just didn't stop calculating. Eventually I stopped waiting and wrote a new algorithm. I finished the new algorithm and ran it a few minutes before the old one finally ran out of RAM and locked up Python. All in all a bit of a ride.
Day 12 Part A - Python
Mapped out the connections each way and then walked through them recursively from the start. If a path ran into a duplicate lower case entry I abandoned it.
Day 12 Part B - Python
I modified the recursive function to allow for one letter to be found twice, then I ran it for each letter and removed all the duplicate paths. It was extremely fast.
Day 12 Part B - Deeply Cursed
This looks for all paths that contain 2 or less of each small cavern. Then it takes that list and removes any entries that have more than 1 lower case cavern visited twice. It worked on the test case and hit 22GB of RAM before it locked up my machine when I ran it for realsies.
As you can see from my variable naming, I knew it was cursed when I wrote it. I just didn't know HOW cursed
Tips
If your code seems like it's taking too long to run, it probably is. This probably has the ability to get WAY out of control with computational complexity
For part B you want ONLY ONE small cavern to get visited twice and also you want every possible small cavern to get visited twice. This means for some iterations you need to avoid taking earlier duplicate moves so that they're available later
Day 12 (Rust)
Data structures
This one felt like a parsing puzzle; after parsing everything, the solution is (again) a Breadth-First-Search with counting (sometimes known as 'BFS reach'), same as day 9.
Parsing
This is the annoying part.
Solving
As stated above, this is again a Breadth-First-Search, though with a slightly obnoxious state transition condition.
Data Structure
Despite naming all my data structure creation procedures
graph
today's is actually a graph using the old work-horse hash table mapping cave -> set of caves. Since it's undirected every input line"a-b"
needs 2 edges -a -> b
&b -> a
.Part 1
I wrote 2 separate but nearly identical path counts when I submitted my answers but it's refactored a bit here.
Now I use a bouncer (
single-visit
) to stop people re-visiting the caves.count-paths
is a dfs that ends when the caveend
is visited.Originally I accumulated the current path and a set of all the paths discovered as the search progressed because it was
usefulnecessary information, but they really aren't needed.Now I just increment a counter accumulated over recursive calls to
dfs
- one recursive call for each cave that can be visited from the current cave.Part 2
For part 2 instead of counting how many times a cave is visited, I visit my "special" cave (
double
) and hired a new bouncer.It seems obvious, but I messed up the conditions for excluding a cave for a second visit so many times it's actually funny.
Performance update:
Concerned about the performance of part 2 I made some changes. Latest version in the repo.
let over lambda
but there's a neat library that providesdefine/memo
that's just so convenient. It useseq?
(object identity) for key comparison. So...eq?
. This also means the graph is ahasheq
now instead of regularhash
which is also speed-up. I added a single call to check for a small cave and pass the result as a variable.set
used to store and check visited caves with a list,path
, that gets built up. I originally ditched this idea because we're counting paths and not using them. The logic I use means only small caves are added so it isn't true path either. In theory adding & membership lookup ofset
are both O(log n) andlist
isO(1) / O(n)
. That doesn't seem like an improvement butn
is small so the practical performance ofO(1) / O(n)
vs2 O(log n)
turns out to be a good speed boost. It pays to measure. For part 2 the runtime is down from about 320-340ms to 60-70ms. I'm always a little jealous of the C++/Rust solutions but I'll take the relative improvement. Part 1 has a similar factor increase too, so I'm satisfied.That's an awesome solution! I need to use pure functions (and higher-order functions) more...
Spoiler
I didn't even consider using a DFS instead of a BrFS, because in my experience it is so rarely useful; the code difference is minor (a stack vs a queue), but for this problem, DFS brings my runtime down from 20ms to 14ms.
I guessed it was OK because we know there are paths and Racket has tail call optimisation.
I hope my (unwitting) 6ms gift is useful later on!
Python
Like others, I also stared at this problem for many minutes before writing any code. In fact, I thought about it so long, I fell asleep and decided to tackle it today instead.
I'm not great at recursive functions, so imagine my surprise when the problem circumvented my expectations (and the reason I parsed the upper-cased-ness of the letters into a boolean flag in part 1) which led to my business side coding kicking in, and implementing a horrible "exclusion rule" which is used both in the selection criteria, then again in the actual recursion step in part 2. Hey, at least it worked. ¯\_(ツ)_/¯
Part 1
Part 2
How's the run time (especially for part 2) in JS?
Really bad. Like, over a minute? But, I didn't try it with all the console.log statements removed.
Rust
Reasonably happy with my code cleanliness, but this runs really slowly: 3.03 sec on my machine.
Edit: Combined Start/End into my cave enum to clean up the
match
arms.Edit 2: Got a slight speed increase by switching to a
HashMap
ofCave
rather than aVec
ofCave
Rust (refactored)
Rust (old solution)
Elixir
I really struggled with debugging my part 2 solution, and in the end it came down to me not updating my visited counts at the right time. I wasn't adding the current cave to the visited counts before consulting them to determine which of the next caves were visitable, which meant if the current cave is a small one being visited the second time, and is the first instance of a small cave being visited a second time, a cave right after it might get its second visit as well. Subtle and maddening. 😵💫
Besides that... I'm happy with my approach, which reuses the same recursive function to count paths, with parts 1 and 2 just passing it a different filter function to determine which of a cave's connections are visitable based on the current visited counts.
There's some ugly and repetitive binary pattern matching (e.g.
<<first_char, _rest::binary>>
paired with the guardwhen first_char in ?a..?z
) to determine whether a cave is small, but when I tried replacing it with asmall_cave?/1
function call, the run time tripled from ~.5 sec to 1.5! I think I could do more up-front work to tag the different types of caves when parsing the input, but I'm calling it quits for now.Both parts
I have fallen behind because life, but I will keep going! This one was not too bad, actually, once I figured out in part 2 that I was calling my function from part 1 on recursion, which obviously gave the wrong answer. With that figured out, I ran my solution for about 30 seconds before I gave up and recompiled with release instead of debug. It now runs in about 5.5 seconds, which isn't amazing compared to some of the solutions here, but I'm happy to wait that long.
Part 1
Part 2 diff