Keep top level comments as only solutions, if you want to say something other than a solution put it in a new post. (replies to comments can be whatever)
You can send code in code blocks by using three backticks, the code, and then three backticks or use something such as https://topaz.github.io/paste/ if you prefer sending it through a URL
Is there a leaderboard for the community?: We have a programming.dev leaderboard with the info on how to join in this post: https://programming.dev/post/6631465
Approach: Recursive memoized backtracking with a Trie
I get to use one of my favorite data structures here, a Trie! It helps us figure out whether a prefix of the design is a valid pattern in linear time.
I use backtracking to choose potential component patterns (using the Trie), kicking off matching the rest of the design down the stack. We can continue matching longer patterns immediately after the recursion stack unwinds.
In addition, I use global memoization to keep track of the feasibility (part 1) or the number of combinations (part 2) for designs and sub-designs. This way, work done for earlier designs can help speed up later ones too.
I ended up combining part 1 and 2 solutions into a single function because part 1 is a simpler variant of part 2 where we count all designs with the number of possible pattern combinations > 0.
Reading Input
import os
here = os.path.dirname(os.path.abspath(__file__))
# read input
def read_data(filename: str):
global here
filepath = os.path.join(here, filename)
with open(filepath, mode="r", encoding="utf8") as f:
return f.read()
Trie Implementation
class Trie:
class TrieNode:
def __init__(self) -> None:
self.children = {} # connections to other TrieNode
self.end = False # whether this node indicates an end of a pattern
def __init__(self) -> None:
self.root = Trie.TrieNode()
def add(self, pattern: str):
node = self.root
# add the pattern to the trie, one character at a time
for color in pattern:
if color not in node.children:
node.children[color] = Trie.TrieNode()
node = node.children[color]
# mark the node as the end of a pattern
node.end = True
Solution
def soln(filename: str):
data = read_data(filename)
patterns, design_data = data.split("\n\n")
# build the Trie
trie = Trie()
for pattern in patterns.split(", "):
trie.add(pattern)
designs = design_data.splitlines()
# saves the design / sub-design -> number of component pattern combinations
memo = {}
def backtrack(design: str):
nonlocal trie
# if design is empty, we have successfully
# matched the caller design / sub-design
if design == "":
return 1
# use memo if available
if design in memo:
return memo[design]
# start matching a new pattern from here
node = trie.root
# number of pattern combinations for this design
pattern_comb_count = 0
for i in range(len(design)):
# if design[0 : i+1] is not a valid pattern,
# we are done matching characters
if design[i] not in node.children:
break
# move along the pattern
node = node.children[design[i]]
# we reached the end of a pattern
if node.end:
# get the pattern combinations count for the rest of the design / sub-design
# all of them count for this design / sub-design
pattern_comb_count += backtrack(design[i + 1 :])
# save the pattern combinations count for this design / sub-design
memo[design] = pattern_comb_count
return pattern_comb_count
pattern_comb_counts = []
for design in designs:
pattern_comb_counts.append(backtrack(design))
return pattern_comb_counts
assert sum(1 for dc in soln("sample.txt") if dc > 0) == 6
print("Part 1:", sum(1 for dc in soln("input.txt") if dc > 0))
assert sum(soln("sample.txt")) == 16
print("Part 2:", sum(soln("input.txt")))
Thanks to this useful post for reminding me that dynamic programming exists (and for linking to a source to help me remember how it works as it always makes my head spin :-)
I guessed that part 2 would require counting solutions, so that helped too.
Solves live data in about 40ms.
import 'package:collection/collection.dart';
import 'package:more/more.dart';
int countTarget(String target, Set<String> towels) {
int n = target.length;
List<int> ret = List.filled(n + 1, 0)..[0] = 1;
for (int e in 1.to(n + 1)) {
for (int s in 0.to(e)) {
if (towels.contains(target.substring(s, e))) ret[e] += ret[s];
}
}
return ret[n];
}
List<int> allCounts(List<String> lines) {
var towels = lines.first.split(', ').toSet();
return lines.skip(2).map((p) => countTarget(p, towels)).toList();
}
part1(List<String> lines) => allCounts(lines).where((e) => e > 0).length;
part2(List<String> lines) => allCounts(lines).sum;
My naive solution was taking ages until I tried matching from right to left instead :3
In the end the cache required for part two solved the problem more effectively.
import Control.Arrow
import Control.Monad.State
import Data.List
import Data.List.Split
import Data.Map (Map)
import Data.Map qualified as Map
arrangements :: [String] -> String -> Int
arrangements atoms = (`evalState` Map.empty) . go
where
go "" = return 1
go molecule =
let computed = do
c <- sum <$> mapM (\atom -> maybe (return 0) go $ stripPrefix atom molecule) atoms
modify (Map.insert molecule c)
return c
in gets (Map.!? molecule) >>= maybe computed return
main = do
(atoms, molecules) <- (lines >>> (splitOn ", " . head &&& drop 2)) <$> readFile "input19"
let result = map (arrangements atoms) molecules
print . length $ filter (> 0) result
print . sum $ result
until I tried matching from right to left instead :3
My intuition nudged me there but I couldn't reason how that would change things. You still have to test the remaining string, and its remaining string, backtrack, etc right?
It was just a hunch that the inputs were generated to be difficult to parse using a naive algorithm (ie the towels have a lot of shared prefixes). In general I don't think there's any reason to suppose that one direction is better than the other, at least for random inputs.
arrangements :: [String] -> String -> Int
arrangements atoms molecule = head counts
where
counts = zipWith go (tails molecule) (tails counts)
go [] _ = 1
go m cs = sum $ map (\a -> if a `isPrefixOf` m then cs !! length a else 0) atoms
I figured that Part 2 would want something to do with unique paths, so I tried to generate all paths in Part 1, which took too long. So I then decided to go with dynamic programming. In Part 1, I stored a cache of whether a given state can lead to the solution. In Part 2, I updated it to store how many options are possible from a given state.
I had several strategy switches from brute-force to pathfinding (when doing part1 input instead of example) because It simply wouldn't finish.
My solution only found the first path to the design, which is why I rewrote to only count how many towels there are for each prefix I have already built. Do that until there is either only one entry with the total combinations count or no entry and it's impossible to build the design.
I like the final solution, its small (unlike my other solutions) and runs fast.
π
import Control.Arrow
import Data.Map (Map)
import qualified Data.List as List
import qualified Data.Map as Map
parse :: String -> ([String], [String])
parse = lines . init
>>> (map (takeWhile (/= ',')) . words . head &&& drop 2)
countDesignPaths :: [String] -> String -> Map Int Int -> Int
countDesignPaths ts d es
| Map.null es = 0
| ml == length d = mc
| otherwise = countDesignPaths ts d es''
where
((ml, mc), es') = Map.deleteFindMin es
ns = List.filter (flip List.isPrefixOf (List.drop ml d))
>>> List.map length
>>> List.map (ml +)
$ ts
es'' = List.foldl (\ m l' -> Map.insertWith (+) l' mc m) es'
$ ns
solve (ts, ds) = List.map (flip (countDesignPaths ts) (Map.singleton 0 1))
>>> (List.length . List.filter (/= 0) &&& List.sum)
$ ds
main = getContents
>>= print
. solve
. parse
Solver uses trie just like the other python solve here, I try to not look at solve threads until after it is solved. yet, I somehow got a solve that only takes ~7 ms. previously I would rebuild the trie every time and made it take ~55 ms.
Code
from collections import deque
def profiler(method):
from time import perf_counter_ns
def wrapper_method(*args: any, **kwargs: any) -> any:
start_time = perf_counter_ns()
ret = method(*args, **kwargs)
stop_time = perf_counter_ns() - start_time
time_len = min(9, ((len(str(stop_time))-1)//3)*3)
time_conversion = {9: 'seconds', 6: 'milliseconds', 3: 'microseconds', 0: 'nanoseconds'}
print(f"Method {method.__name__} took : {stop_time / (10**time_len)} {time_conversion[time_len]}")
return ret
return wrapper_method
def build_aho_corasick(towels):
# Each node: edges dict, failure link, output (which towels end here)
trie = [{'fail': 0, 'out': []}]
# Build trie of towels
for index, word in enumerate(towels):
node = 0
for char in word:
if char not in trie[node]:
trie[node][char] = len(trie)
trie.append({'fail': 0, 'out': []})
node = trie[node][char]
trie[node]['out'].append(len(word)) # store length of matched towel
# Build fail links (BFS)
queue = deque()
# Initialize depth-1 fail links
for c in trie[0]:
if c not in ('fail', 'out'):
nxt = trie[0][c]
trie[nxt]['fail'] = 0
queue.append(nxt)
# BFS build deeper fail links
while queue:
r = queue.popleft()
for c in trie[r]:
if c in ('fail', 'out'):
continue
nxt = trie[r][c]
queue.append(nxt)
f = trie[r]['fail']
while f and c not in trie[f]:
f = trie[f]['fail']
trie[nxt]['fail'] = trie[f][c] if c in trie[f] else 0
trie[nxt]['out'] += trie[trie[nxt]['fail']]['out']
return trie
def count_possible_designs_aho(trie, design):
n = len(design)
dp = [0] * (n + 1)
dp[0] = 1
# State in the trie automaton
state = 0
for i, char in enumerate(design, 1):
# Advance in trie
while state and char not in trie[state]:
state = trie[state]['fail']
if char in trie[state]:
state = trie[state][char]
else:
state = 0
# For every towel match that ends here:
for length_matched in trie[state]['out']:
dp[i] += dp[i - length_matched]
return dp[n]
@profiler
def main(input_data):
towels,desired_patterns = ( sorted(x.split(), key=len, reverse=True) for x in input_data.replace(',', ' ').split('\n\n') )
part1 = 0
part2 = 0
# Build Aho-Corasick structure
trie = build_aho_corasick(towels)
for pattern in desired_patterns:
count = count_possible_designs_aho(trie, pattern)
if count:
part1 += 1
part2 += count
return part1,part2
if __name__ == "__main__":
with open('input', 'r') as f:
input_data = f.read().replace('\r', '').strip()
result = main(input_data)
print("Part 1:", result[0], "\nPart 2:", result[1])
Part 2 was pretty much the same as Part 2 except we can't short-circuit when we find the first match. So, implement a cache of each sub-pattern and the number of ways to form it from the towels, and things get much faster.
using System.Collections.Immutable;
using System.Diagnostics;
using Common;
namespace Day19;
static class Program
{
static void Main()
{
var start = Stopwatch.GetTimestamp();
var sampleInput = ReceiveInput("sample.txt");
var programInput = ReceiveInput("input.txt");
Console.WriteLine($"Part 1 sample: {Part1(sampleInput)}");
Console.WriteLine($"Part 1 input: {Part1(programInput)}");
Console.WriteLine($"Part 2 sample: {Part2(sampleInput)}");
Console.WriteLine($"Part 2 input: {Part2(programInput)}");
Console.WriteLine($"That took about {Stopwatch.GetElapsedTime(start)}");
}
static object Part1(Input input)
{
return input.Patterns
.Select(p => AnyTowelMatches(p, input.Towels) ? 1 : 0)
.Sum();
}
static object Part2(Input input)
{
var matchCache = new Dictionary<string, long>();
return input.Patterns
.Select(p => CountTowelMatches(p, input.Towels, matchCache))
.Sum();
}
private static bool AnyTowelMatches(
string pattern,
ImmutableArray<string> towels)
{
return towels
.Where(t => t.Length <= pattern.Length)
.Select(t =>
!pattern.StartsWith(t) ? false :
(pattern.Length == t.Length) ? true :
AnyTowelMatches(pattern.Substring(t.Length), towels))
.Any(r => r);
}
private static long CountTowelMatches(
string pattern,
ImmutableArray<string> towels,
Dictionary<string, long> matchCache)
{
if (matchCache.TryGetValue(pattern, out var count)) return count;
count = towels
.Where(t => t.Length <= pattern.Length)
.Select(t =>
!pattern.StartsWith(t) ? 0 :
(pattern.Length == t.Length) ? 1 :
CountTowelMatches(pattern.Substring(t.Length), towels, matchCache))
.Sum();
matchCache[pattern] = count;
return count;
}
static Input ReceiveInput(string file)
{
using var reader = new StreamReader(file);
var towels = reader.ReadLine()!.SplitAndTrim(',').ToImmutableArray();
var patterns = new List<string>();
reader.ReadLine();
var line = reader.ReadLine();
while (line is not null)
{
patterns.Add(line);
line = reader.ReadLine();
}
return new Input()
{
Towels = towels,
Patterns = [..patterns],
};
}
public class Input
{
public required ImmutableArray<string> Towels { get; init; }
public required ImmutableArray<string> Patterns { get; init; }
}
}
First part is solved by making a regex of the available towels, like ^(r|wr|bg|bwu|rb|gb|br)*$ for the example. If a design matches it, then it can be made. This didn't work for the second part, which is done using recursion and memoization instead. Again, it was quite surprising to see such a high solution number. 32 bits were not enough (thanks, debug mode overflow detection).
Solution
use regex::Regex;
use rustc_hash::FxHashMap;
fn parse(input: &str) -> (Vec<&str>, Vec<&str>) {
let (towels, designs) = input.split_once("\n\n").unwrap();
(towels.split(", ").collect(), designs.lines().collect())
}
fn part1(input: String) {
let (towels, designs) = parse(&input);
let pat = format!("^({})*$", towels.join("|"));
let re = Regex::new(&pat).unwrap();
let count = designs.iter().filter(|d| re.is_match(d)).count();
println!("{count}");
}
fn n_arrangements<'a>(
design: &'a str,
towels: &[&str],
cache: &mut FxHashMap<&'a str, u64>,
) -> u64 {
if design.is_empty() {
return 1;
}
if let Some(n) = cache.get(design) {
return *n;
}
let n = towels
.iter()
.filter(|t| design.starts_with(*t))
.map(|t| n_arrangements(&design[t.len()..], towels, cache))
.sum();
cache.insert(design, n);
n
}
fn part2(input: String) {
let (towels, designs) = parse(&input);
let sum: u64 = designs
.iter()
.map(|d| n_arrangements(d, &towels, &mut FxHashMap::default()))
.sum();
println!("{sum}");
}
util::aoc_main!();
I don't know much about Rust but I assume the HashMap<String, i64> requires hashing on insertion and lookup, right? I realized that, for every design, all the strings you'll see are substrings of that design from different starting positions, so I made my lookup table int pos -> int count. The table is reset after every design.
That does mean that if two or more strings end with the same substring, you'd recalculate those substrings? Would be a faster lookup cost though, clever.
My code ran in 120ms, so its pretty damn fast as is, especially compared to the non-memoised version
edit: Tried the array of lengths method, shaved about 20ms off. Not bad, but probably not my main issue either
Definitely not AhoβCorasick cool or genius, but does get ~11ms on both parts. Gains over the other Rust solves are partly a less naΓ―ve approach to towel checking and partly getting it to work with 0 String allocations, which involves both an instructive lifetime annotation and the clever use of a niche standard library function.
Code
pub fn day19(input: &str) -> (u64, u64) {
fn recur_helper<'a>(pat: &'a str, towels: &[&str], map: &mut HashMap<&'a str, u64>) -> u64 {
let mut hits = 0;
let mut towel_subset = towels;
for l in 1..=pat.len() {
let test_pat = &pat[0..l];
match towel_subset.binary_search(&test_pat) {
Ok(idx) => {
if pat[l..].is_empty() {
return hits + 1;
} else if let Some(&v) = map.get(&pat[l..]) {
hits += v;
} else {
let v = recur_helper(&pat[l..], towels, map);
map.insert(&pat[l..], v);
hits += v;
}
towel_subset = &towel_subset[idx..];
let end = towel_subset.partition_point(|&x| x.starts_with(test_pat));
towel_subset = &towel_subset[..end];
if towel_subset.is_empty() {
return hits;
}
}
Err(idx) => {
towel_subset = &towel_subset[idx..];
let end = towel_subset.partition_point(|&x| x.starts_with(test_pat));
towel_subset = &towel_subset[..end];
if towel_subset.is_empty() {
return hits;
}
}
}
}
hits
}
let mut part1 = 0;
let mut part2 = 0;
let mut lines = input.lines();
let mut towels = lines.next().unwrap().split(", ").collect::<Vec<_>>();
towels.sort();
let mut map = HashMap::new();
for pat in lines.skip(1) {
let tmp = recur_helper(pat, &towels, &mut map);
part2 += tmp;
if tmp > 0 {
part1 += 1;
}
}
(part1, part2)
}
nice job! now do it without recursion. something I always attempt to shy away from as I think it is dirty to do, makes me feel like it is the "lazy man's" loop.
Aho-Corasick is definitely meant for this kind of challenge that requires finding all occurrences of multiple patterns, something worth reading up on! If you are someone who builds up a util library for future AoC or other projects then that would likely come in to use sometimes.
have to remember we are all discovering new interesting ways to solve similar or same challenges. I am still learning lots, hope to see you around here and next year!
Runs in 115 ms. Today's pretty straight forward. Memoization feels like magic sometimes!
Code
import Control.Monad.Memo
import Data.List
splitX :: Eq a => [a] -> [a] -> [[a]]
splitX xs = go
where
go [] = [[]]
go ys@(y : ys') = case stripPrefix xs ys of
Just ys'' -> [] : go ys''
Nothing -> let (zs : zss) = go ys' in (y : zs) : zss
parse :: String -> ([String], [String])
parse s =
let (patterns : _ : designs) = lines s
in (splitX ", " patterns, takeWhile (not . null) designs)
countPatterns :: (Eq a, Ord a) => [[a]] -> [a] -> Memo [a] Int Int
countPatterns yss = go
where
go [] = return 1
go xs = sum <$> sequence
[memo go xs' | Just xs' <- map (\ys -> stripPrefix ys xs) yss]
main :: IO ()
main = do
(patterns, designs) <- parse <$> getContents
let ns = startEvalMemo $ mapM (countPatterns patterns) designs
print $ length $ filter (> 0) ns
print $ sum ns