Advent of Code 2021: Day 12

Advent of Code 2021: Day 12

This blog post is twelfth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described on Day 12.

All solutions are in this GitHub repository.

Solving Part One

Given a set of connections between underground caves, we must find all possible paths between the start and end caves and output the number of found paths. We can visit large caves (named using uppercase letters) multiple times, whereas we can only visit small caves (names using lowercase letters) once.

The strategy for solving part one was a recursive algorithm that returns the number of paths from a given cave to the end cave. At each recursion step, we visit each linked cave of the current cave and recursively return the number of paths from the linked cave to the end cave.

In terms of data structure, I opted for representing the cave graph using an Adjacency List. In other words, a key-value map, where the keys are the cave names and each value is an array corresponding to linked caves. For example, the input from the problem description would produce the following key-value map:

{
  start: [ 'A', 'b' ],
  A: [ 'start', 'c', 'b', 'end' ],
  b: [ 'start', 'A', 'd', 'end' ],
  c: [ 'A' ],
  d: [ 'b' ],
  end: [ 'A', 'b' ]
}

This data structure works well for iterating through adjacent caves.

I broke down the solution as follows:

  • build the adjacency list from the input file
  • get number of paths from the start cave recursively
  • prevent visiting small caves twice

Build the adjacency list from the input file

The first step to solving part one is reading each line representing a connection between two caves and adding this information to the adjacency list.

const links = {}
const lines = data.split('\n')
lines.forEach((line) => {
  const [caveA, caveB] = line.split('-')
  addLink(links, caveA, caveB)
  addLink(links, caveB, caveA)
})

The addLink function adds a connected cave (e.g. caveB) to the corresponding list (e.g. caveA list) or creates a new list if the cave does not exist.

const addLink = (links, caveA, caveB) => {
  if (links[caveA]) {
    links[caveA].push(caveB)
  } else {
    links[caveA] = [caveB]
  }
}

Get number of paths from the start cave recursively

The first call of the recursive getPaths function begins from the start cave. It passes the following parameters:

  • the adjacent list links used for finding connected caves
  • an array of visited caves containing the start cave
  • the cave from which to count paths to the end cave
getPaths(links, ["start"], "start")

The outline for getting the number of paths recursively is the following:

const getPaths = (links, visitedSmallCaves, cave) => {
  if (cave === "end") {
    return 1
  }

  let generatedPaths = 0
  links[cave].forEach(linkedCave => {
    // TODO: if small cave not visited 
    generatedPaths += getPaths(links, visitedSmallCaves, linkedCave)
  })
  return generatedPaths
}

The recursion stop condition is when we reach the end cave. Thus we can return 1 representing a path found. Alternatively, we might return 0 if we reach a dead-end (e.g. a small cave connected to already visited small caves).

Each recursion step, we iterate through the connected caves and sum the possible paths to the end cave.

Keep in mind that the outlined algorithm doesn't yet consider visited small caves. Thus, if you run it as is, it will go back and forth between the same two caves until the program throws a stack overflow exception. We'll take a look at visited caves next.

Prevent visiting small caves twice

To check whether a cave is small we need to verify whether it has lowercase characters

const isSmallCave = (cave) => cave.toLowerCase() === cave

Then we can update the visitedSmallCaves like so:

const newVisitedArray = isSmallCave(linkedCave) 
  ? [...visitedSmallCaves, linkedCave]
  : visitedSmallCaves

We are then able to check whether we have visited a small cave using the include function like so:

visitedSmallCaves.includes(linkedCave)

Thus, the updated getPath function contains the following:

links[cave].forEach(linkedCave => {
  if (!visitedSmallCaves.includes(linkedCave)) {
    const newVisitedArray = isSmallCave(linkedCave) ? [...visitedSmallCaves, linkedCave] : visitedSmallCaves
    generatedPaths += getPaths(links, newVisitedArray, linkedCave)
  }
})

The final solution for part one is in my GitHub repository.

Solving Part Two

Part two changes the visiting rules by allowing one to visit a single small cave twice, except the start and end caves, which one can only visit once.

The strategy for solving part two was adding an item called twice to the visitedSmallCaves array after visiting a small cave twice.

I updated the recursive getPath function in order to separate the visiting logic to two separate functions: hasVisitedCave and getVisitedCaves.

  links[cave].forEach(linkedCave => {
    if (!hasVisitedCave(visitedSmallCaves, linkedCave)) {
      generatedPaths += getPaths(links, getVisitedCaves(visitedSmallCaves, linkedCave), linkedCave)
    }
  })

The hasVisitedCave function includes the new rules and allows visiting a single small cave that are not names start twice.

const hasVisitedCave = (visited, cave) => {
  if (cave === "start") {
    return true
  }
  return visited.includes(cave) && visited.includes("twice")
}

The getVisitedCaves function skips adding large caves, adds the twice element to the array once a small cave has been visited twice and adds small caves that have not been visited before.

const getVisitedCaves = (visited, cave) => {
  const isSmall = isSmallCave(cave)
  if (!isSmall) {
    return visited
  }
  if (visited.includes(cave)) {
    return [...visited, "twice"]
  }
  return [...visited, cave]
}

The final solution for part two is in my GitHub repository.

Conclusion

Day 12 was about finding all paths between two caves recursively. A graph implemented using an Adjacency List helped store connected caves.