diff --git a/docs/2024/puzzles/day19.md b/docs/2024/puzzles/day19.md index 5a7242562..f25b5478b 100644 --- a/docs/2024/puzzles/day19.md +++ b/docs/2024/puzzles/day19.md @@ -2,10 +2,197 @@ import Solver from "../../../../../website/src/components/Solver.js" # Day 19: Linen Layout +by [Paweł Cembaluk](https://github.com/AvaPL) + ## Puzzle description https://adventofcode.com/2024/day/19 +## Solution summary + +The puzzle involves arranging towels to match specified patterns. Each towel has a predefined stripe sequence, and the +task is to determine: + +- **Part 1**: How many patterns can be formed using the available towels? +- **Part 2**: For each pattern, how many unique ways exist to form it using the towels? + +The solution leverages regular expressions to validate patterns in Part 1 and employs recursion with memoization for +efficient counting in Part 2. + +## Part 1 + +### Parsing the input + +The input consists of two sections: + +- **Towels**: A comma-separated list of towels (e.g., `r, wr, b, g`). +- **Desired Patterns**: A list of patterns to match, each on a new line. + +To parse the input, we split it into two parts: towels and desired patterns. Towels are extracted as a comma-separated +list, while patterns are read line by line after a blank line. We also introduce type aliases `Towel` and `Pattern` for +clarity in representing these inputs. + +Here’s the code for parsing: + +```scala 3 +type Towel = String +type Pattern = String + +def parse(input: String): (List[Towel], List[Pattern]) = + val Array(towelsString, patternsString) = input.split("\n\n") + val towels = towelsString.split(", ").toList + val patterns = patternsString.split("\n").toList + (towels, patterns) +``` + +### Solution + +To determine if a pattern can be formed, we use a regular expression. While this could be done manually by checking +combinations, the tools in the standard library make it exceptionally easy. The regex matches sequences formed by +repeating any combination of the available towels: + +```scala 3 +def isPossible(towels: List[Towel])(pattern: Pattern): Boolean = + val regex = towels.mkString("^(", "|", ")*$").r + regex.matches(pattern) +``` + +`towels.mkString("^(", "|", ")*$")` builds a regex like `^(r|wr|b|g)*$`. Here’s how it works: + +- `^`: Ensures the match starts at the beginning of the string. +- `(` and `)`: Groups the towel patterns so they can be alternated. +- `|`: Acts as a logical OR between different towels. +- `*`: Matches zero or more repetitions of the group. +- `$`: Ensures the match ends at the string’s end. + +This approach is simplified because we know the towels contain only letters. If the input could be any string, we would +need to use `Regex.quote` to handle special characters properly. + +Finally, using the `isPossible` function, we filter and count the patterns that can be formed: + +```scala 3 +def part1(input: String): Int = + val (towels, patterns) = parse(input) + patterns.count(isPossible(towels)) +``` + +## Part 2 + +To count all unique ways to form a pattern, we start with a base algorithm that recursively matches towels from the +start of the pattern. For each match, we remove the matched part and solve for the remaining pattern. This ensures we +explore all possible combinations of towels. Since the numbers involved can grow significantly, we use `Long` to handle +the large values resulting from these calculations. + +Here’s the code for the base algorithm: + +```scala 3 +def countOptions(towels: List[Towel], pattern: Pattern): Long = + towels + .collect { + case towel if pattern.startsWith(towel) => // Match the towel at the beginning of the pattern + pattern.drop(towel.length) // Remove the matched towel + } + .map { remainingPattern => + if (remainingPattern.isEmpty) 1 // The pattern is fully matched + else countOptions(towels, remainingPattern) // Recursively solve the remaining pattern + } + .sum // Sum the results for all possible towels +``` + +That's not enough though. The above algorithm will repeatedly solve the same sub-patterns quite often, making it +inefficient. To optimize it, we introduce memoization. Memoization stores results for previously solved sub-patterns, +eliminating redundant computations. We also pass all the patterns to the function to fully utilize the memoization +cache. + +Here's the code with additional cache for already calculated sub-patterns: + +```scala 3 +def countOptions(towels: List[Towel], patterns: List[Pattern]): Long = + val cache = mutable.Map.empty[Pattern, Long] + + def loop(pattern: Pattern): Long = + cache.getOrElseUpdate( // Get the result from the cache + pattern, + // Calculate the result if it's not in the cache + towels + .collect { + case towel if pattern.startsWith(towel) => // Match the towel at the beginning of the pattern + pattern.drop(towel.length) // Remove the matched towel + } + .map { remainingPattern => + if (remainingPattern.isEmpty) 1 // The pattern is fully matched + else loop(remainingPattern) // Recursively solve the remaining pattern + } + .sum // Sum the results for all possible towels + ) + + patterns.map(loop).sum // Sum the results for all patterns +``` + +Now, we just have to pass the input to the `countOptions` function to get the final result: + +```scala 3 +def part2(input: String): Long = + val (towels, patterns) = parse(input) + countOptions(towels, patterns) +``` + +## Final code + +```scala 3 +type Towel = String +type Pattern = String + +def parse(input: String): (List[Towel], List[Pattern]) = + val Array(towelsString, patternsString) = input.split("\n\n") + val towels = towelsString.split(", ").toList + val patterns = patternsString.split("\n").toList + (towels, patterns) + +def part1(input: String): Int = + val (towels, patterns) = parse(input) + val possiblePatterns = patterns.filter(isPossible(towels)) + possiblePatterns.size + +def isPossible(towels: List[Towel])(pattern: Pattern): Boolean = + val regex = towels.mkString("^(", "|", ")*$").r + regex.matches(pattern) + +def part2(input: String): Long = + val (towels, patterns) = parse(input) + countOptions(towels, patterns) + +def countOptions(towels: List[Towel], patterns: List[Pattern]): Long = + val cache = mutable.Map.empty[Pattern, Long] + + def loop(pattern: Pattern): Long = + cache.getOrElseUpdate( + pattern, + towels + .collect { + case towel if pattern.startsWith(towel) => + pattern.drop(towel.length) + } + .map { remainingPattern => + if (remainingPattern.isEmpty) 1 + else loop(remainingPattern) + } + .sum + ) + + patterns.map(loop).sum +``` + +## Run it in the browser + +### Part 1 + + + +### Part 2 + + + ## Solutions from the community - [Solution](https://github.com/nikiforo/aoc24/blob/main/src/main/scala/io/github/nikiforo/aoc24/D19T2.scala) by [Artem Nikiforov](https://github.com/nikiforo) diff --git a/solutions b/solutions index ff8014db4..3c45b7a5c 160000 --- a/solutions +++ b/solutions @@ -1 +1 @@ -Subproject commit ff8014db46148c4f11aa5ee4d33dfbbc3ef27fcc +Subproject commit 3c45b7a5c93f1fa683902a72d0e5779e3352af97 diff --git a/solver/src/main/scala/adventofcode/Solver.scala b/solver/src/main/scala/adventofcode/Solver.scala index 2cdba0801..d29da132c 100644 --- a/solver/src/main/scala/adventofcode/Solver.scala +++ b/solver/src/main/scala/adventofcode/Solver.scala @@ -11,6 +11,8 @@ object Solver: Map( "day13-part1" -> day13.part1, "day13-part2" -> day13.part2, + "day19-part1" -> day19.part1, + "day19-part2" -> day19.part2, "day14-part1" -> day14.part1, "day14-part2" -> day14.part2, "day21-part1" -> day21.part1,