Advent of Code 2021: Day 14

Search for a command to run...

No comments yet. Be the first to comment.
In this series we solve programming challenges from the Advent of Code 2021 using JavaScript.
The Advent of Code is an Advent calendar with programming challenges. It is a wonderful way to keep your skills sharp, learn a new programming language, prepare for interviews, or have something to look forward to each day leading up to Christmas. Th...
This blog post is thirteenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 13. All solutions are in this GitHub repository. Solving Part One Given a list of x, y coordinates representing dots ...

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, ...

This blog post is eleventh in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 11. This day had many similarities to Day 9, and I solved the challenge using similar methods. All solutions are in thi...

This blog post is the tenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 10. All solutions are in this GitHub repository. Solving Part One Given a set of lines with opening and closing bracke...

This blog post is fourteenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 14. This challenge was about optimizing an algorithm that generates exponentially larger and larger strings. I found it tricky and had to try multiple solutions until I reached the optimized solution described in this blog post.
All solutions are in this GitHub repository.
Given a template of polymers represented through letters and a set of rules, we must insert a new polymer between each pair of the initial template according to the rules. This insertion represents a single first step that produces the template for the next step. Part one requires running the insertion algorithm ten times, whereas part two is 40 times. Then, we must determine the least and most common polymer and subtract their occurrence number.
My initial solution was manipulating arrays of characters to insert new elements according to the set of given rules. However, this solution only worked for part one and was not optimized for exponentially growing arrays (sized 2^40) which eventually exceeded the limits (2^32). After attempting a recursive algorithm on each initial pair, I got a similar result.
The strategy that solved this challenge was to store pairs in an occurrence hash map. Each insertion step would determine the occurrence of new pairs based on the previous occurrence map. Finally, we can derive the polymer occurrence from the pair occurrence.
We can break down the solution as follows:
The template and pair insertion rules are separated by an empty line, thus we can the split function with the \n\n separator to get these two sections from the input.
const [template, pairInsertionRulesData] = data.split('\n\n')
We process each line from the input and add the corresponding rule to the pairInsertionRules hash map. We use the pair as key and polymer to be inserted as value.
const pairInsertionRules = {}
pairInsertionRulesData.split('\n').forEach((line) => {
const [pair, result] = line.split(' -> ')
pairInsertionRules[pair] = result
})
}
The initial template from the example is NNCB and has the following pair occurrence hash map:
{
'NN': 1,
'NC': 1,
'CB': 1
}
We can create the pair occurrence map using the following algorithm:
const getPairOccurenceMap = (template) => {
const pairs = {}
for (let i = 0; i < template.length - 1; ++i) {
addToOccurenceMap(pairs, `${template.charAt(i)}${template.charAt(i + 1)}`)
}
return pairs
}
I extracted the logic for adding a key / value to an occurrence map into addToOccurenceMap function. This is useful in some of the next steps.
const addToOccurenceMap = (occurenceMap, key, value = 1) => {
if (occurenceMap[key]) {
occurenceMap[key] += value
} else {
occurenceMap[key] = value
}
}
As mentioned earlier in this article, the strategy is to update the pair occurrence map rather than build a very large array or string exceeding the maximum limits. Thus, for part one we update 10 times and for part two 40 times.
for (let i = 0; i < 10; ++i) {
pairOccurenceMap = updatePairOccurenceMap(pairOccurenceMap, pairInsertionRules)
}
To build the new pair occurrence map, we iterate through each pair using the Object.keys function. Using the pair as an index, we can determine the pair's occurrences from pairOccurenceMap and the new polymer to insert from the pairInsertionRules.
If a rule exists for the pair, then the new pair occurrence map will contain two new pairs rather than the original pair. For example, if the rule NC -> B for the pair NC exists, the new pair occurrence map will have pairs NB and CB instead of NC. Otherwise, if a rule does not exist, we add the original pair to the new occurrence map.
To build the new pairs we can use the string template literals ${pair.charAt(0)}${polymer} for the left side pair and ${polymer}${pair.charAt(1)} for the right side pair.
We add the value representing the occurrences of the original pair to each new pair.
const updatePairOccurenceMap = (pairOccurenceMap, pairInsertionRules) => {
const newPairOccurenceMap = {}
Object.keys(pairOccurenceMap).forEach((pair) => {
const occurences = pairOccurenceMap[pair]
const polymer = pairInsertionRules[pair]
if (polymer) {
addToOccurenceMap(newPairOccurenceMap, `${pair.charAt(0)}${polymer}`, occurences)
addToOccurenceMap(newPairOccurenceMap, `${polymer}${pair.charAt(1)}`, occurences)
} else {
addToOccurenceMap(newPairOccurenceMap, pair, occurences)
}
})
return newPairOccurenceMap
}
We can determine the polymer occurrence map based on the pair occurrence map and the first and last letter of the initial template. Each polymer, except the first and last one in the initial template, is counted twice in pairs. Thus, we must add the first and last polymers one more time, then divide each polymer occurrence by 2 to determine the correct polymer occurrence.
onst getPolymerOccurenceMap = (pairOccurenceMap, template) => {
const occurenceMap = {}
Object.keys(pairOccurenceMap).forEach(pair => {
addToOccurenceMap(occurenceMap, pair.charAt(0), pairOccurenceMap[pair])
addToOccurenceMap(occurenceMap, pair.charAt(1), pairOccurenceMap[pair])
})
occurenceMap[template.charAt(0)]++
occurenceMap[template.charAt(template.length - 1)]++
Object.keys(occurenceMap).forEach(key => {
occurenceMap[key] = Math.floor(occurenceMap[key] / 2)
})
return occurenceMap
}
We can calculate the least and most common polymers by iterating through the keys of the polymer occurence map.
const getLeastAndMostCommon = (occurenceMap) => {
let leastCommon
let mostCommon
Object.keys(occurenceMap).forEach((polymer) => {
if (!leastCommon || occurenceMap[leastCommon] > occurenceMap[polymer]) {
leastCommon = polymer
}
if (!mostCommon || occurenceMap[mostCommon] < occurenceMap[polymer]) {
mostCommon = polymer
}
})
return { leastCommon, mostCommon }
}
Then we can subtract the occurence of the least most common polymers:
polymerOccurenceMap[mostCommon] - polymerOccurenceMap[leastCommon]
The final solution for Day 14 is in my GitHub repository.
Day 14 was tricky and required optimally determining the least and most common letter occurrences in an exponentially growing string. An occurrence hash map storing pairs helped define the next set of occurrences, prevented storing a massive string and made it possible to solve the challenge.