Day 7: Bridge Repair
Megathread guidelines
- 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
FAQ
- What is this?: Here is a post with a large amount of details: https://programming.dev/post/6637268
- Where do I participate?: https://adventofcode.com/
- 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
Iβm way behind, but Iβm trying to learn F#.
Iβm using the library Combinatorics in dotnet, which Iβve used in the past, generate in this case every duplicating possibility of the operations. I the only optimization that I did was to use a function to concatenate numbers without converting to strings, but that didnβt actually help much.
I have parser helpers that use ReadOnlySpans over strings to prevent unnecessary allocations. However, here Iβm adding to a C# mutable list and then converting to an FSharp (linked) list, which this language is more familiar with. Not optimal, but runtime was pretty good.
Iβm not terribly good with F#, but I think I did ok for this challenge.
F#
// in another file:
let concatenateLong (a:Int64) (b:Int64) : Int64 =
let rec countDigits (n:int64) =
if n = 0 then 0
else 1 + countDigits (n / (int64 10))
let bDigits = if b = 0 then 1 else countDigits b
let multiplier = pown 10 bDigits |> int64
a * multiplier + b
// challenge file
type Operation = {Total:Int64; Inputs:Int64 list }
let parse (s:ReadOnlySpan<char>) : Operation =
let sep = s.IndexOf(':')
let total = Int64.Parse(s.Slice(0, sep))
let inputs = System.Collections.Generic.List<Int64>()
let right:ReadOnlySpan<char> = s.Slice(sep + 1).Trim()
// because the Split function on a span returns a SpanSplitEnumerator, which is a ref-struct and can only live on the stack,
// I can't use the F# list syntax here
for range in right.Split(" ") do
inputs.Add(Int64.Parse(sliceRange right range))
{Total = total; Inputs = List.ofSeq(inputs) }
let part1Ops = [(+); (*)]
let execute ops input =
input
|> PSeq.choose (fun op ->
let total = op.Total
let inputs = op.Inputs
let variations = Variations(ops, inputs.Length - 1, GenerateOption.WithRepetition)
variations
|> Seq.tryFind (fun v ->
let calcTotal = (inputs[0], inputs[1..], List.ofSeq(v)) |||> List.fold2 (fun acc n f -> f acc n)
calcTotal = total
)
|> Option.map(fun _ -> total)
)
|> PSeq.fold (fun acc n -> acc + n) 0L
let part1 input =
(read input parse)
|> execute part1Ops
let part2Ops = [(+); (*); concatenateLong]
let part2 input = (read input parse) |> execute part2Ops
The Gen0 garbage collection looks absurd, but Gen0 is generally considered βfreeβ.
Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
---|---|---|---|---|---|---|
Part1 | 19.20 ms | 0.372 ms | 0.545 ms | 17843.7500 | 156.2500 | 106.55 MB |
Part2 | 17.94 ms | 0.355 ms | 0.878 ms | 17843.7500 | 156.2500 | 106.55 MB |
V2 - concatenate numbers did little for the runtime, but did help with Gen1 garbage, but not the overall allocation.
Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
---|---|---|---|---|---|---|
Part1 | 17.34 ms | 0.342 ms | 0.336 ms | 17843.7500 | 125.0000 | 106.55 MB |
Part2 | 17.24 ms | 0.323 ms | 0.270 ms | 17843.7500 | 93.7500 | 106.55 MB |
Uiua
This turned out to be reasonably easy in Uiua, though this solution relies on macros which maybe slow it down.
(edit: removing one macro sped it up quite a bit)
(edit2: Letting Uiua build up an n-dimensional array turned out to be the solution, though sadly my mind only works in 3 dimensions. Now runs against the live data in around 0.3 seconds.)
Data β β(β‘βββΈ(Β¬β": "))βΈβ @\n "190: 10 19\n3267: 81 40 27\n83: 17 5\n156: 15 6\n7290: 6 8 6 15\n161011: 16 10 13\n192: 17 8 14\n21037: 9 7 18 13\n292: 11 6 16 20"
Calib! β β‘ββ’β½βΈβ‘β(ββ/[^0]:Β°β) # Calibration targets which can be constructed from their values.
&p/+Calib!β(+|Γ)Data
&p/+Calib!β(+|Γ|+ΓβΏ:10+1ββββ,)Data
Thanks to your solution I learned more about how to use reduce
:D
My solution did work for the example input but not for the actual one. When I went here and saw this tiny code block and you saying
This turned out to be reasonably easy
I was quite taken aback. And itβs so much better performance-wise too :D (well, until part 2 comes along in my case. Whatever this black magic is you used there is too high for my fried brain atm)
Haha, sorry about that, it does seem quite smug :-) I went into it expecting it to be a nightmare of boxes and dimensions, but finding it something I could deal with was a massive relief. Of course once I had a working solution I reversed it back into a multi-dimensional nightmare. Thatβs where the performance gains came from: about 10x speedup from letting Uiua build up as many dimensions as it needed before doing a final deshaping.
I enjoyed reading a different approach to this, and thanks for reminding me that β$"__"
exists, thatβs a great idiom to have up your sleeve.
Let me know if thereβs any bits of my solution that youβd like me to talk you through.
No worries, it does seem a lot less difficult in hindsight now, my mind just blanked at what I expected to be a lot more code :))
That performance improvement is amazing, Iβll definitely take a look at how that works in detail later. Just gotta recover from the mental stretch gymnastics trying to remember the state of the stack at different code positions
Haskell
A surprisingly gentle one for the weekend! Avoiding string operations for concatenate
got the runtime down below one second on my machine.
import Control.Arrow
import Control.Monad
import Data.List
import Data.Maybe
readInput :: String -> [(Int, [Int])]
readInput = lines >>> map (break (== ':') >>> (read *** map read . words . tail))
equatable :: [Int -> Int -> Int] -> (Int, [Int]) -> Bool
equatable ops (x, y : ys) = elem x $ foldM apply y ys
where
apply a y = (\op -> a `op` y) <$> ops
concatenate :: Int -> Int -> Int
concatenate x y = x * mag y + y
where
mag z = fromJust $ find (> z) $ iterate (* 10) 10
main = do
input <- readInput <$> readFile "input07"
mapM_
(print . sum . map fst . (`filter` input) . equatable)
[ [(+), (*)],
[(+), (*), concatenate]
]
Since all operations increase the accumulator, I tried putting a guard (a <= x)
in apply
, but it doesnβt actually help all that much (0.65s -> 0.43s).
0.65 -> 0.43 sounds pretty strong, isnβt that a one-fourth speedup?
Edit: I was able to achieve a 30% speed improvement using this on my solution
I wanted to this the way yo did, by repeatedly applying functions, but I didnβt dare to because I like to mess up and spend some minutes debugging signatures, may I ask what your IDE setup is for the LSP-Hints with Haskell?
Setting up on my PC was a little bit of a pain because it needed matching ghc
and ghcide
versions, so I hadnβt bothered doing it on my Laptop yet.
I use neovim with haskell-tools.nvim
plugin. For ghc
, haskell-language-server
and others I use nix
which, among other benefits makes my development environment reproducible and all haskellPackages are built on the same version so there are no missmatches.
But, as much as I love nix
, there are probably easier ways to setup your environment.
I just checked and I have haskell-tools.nvim on my PC but it somehow crashes the default config of the autocompletion for me, which I am too inexperienced to debug. Iβll try it nonetheless, since I donβt have autocompletion on the laptop anyways, thank you for the suggestion!
Ah, well, I have a bit of a weird setup. GHC is 9.8.4, built from git. Iβm using HLS version 2.9.0.1 (again built from git) under Emacs with the LSP and flycheck packages. There are probably much easier ways of getting it to work :)
Haskell
import Control.Arrow
import Data.Char
import Text.ParserCombinators.ReadP
numP = read <$> munch1 isDigit
parse = endBy ((,) <$> (numP <* string ": ") <*> sepBy numP (char ' ')) (char '\n')
valid n [m] = m == n
valid n (x : xs) = n > 0 && valid (n - x) xs || (n `mod` x) == 0 && valid (n `div` x) xs
part1 = sum . fmap fst . filter (uncurry valid . second reverse)
concatNum r = (+r) . (* 10 ^ digits r)
where
digits = succ . floor . logBase 10 . fromIntegral
allPossible [n] = [n]
allPossible (x:xs) = ((x+) <$> rest) ++ ((x*) <$> rest) ++ (concatNum x <$> rest)
where
rest = allPossible xs
part2 = sum . fmap fst . filter (uncurry elem . second (allPossible . reverse))
main = getContents >>= print . (part1 &&& part2) . fst . last . readP_to_S parse
Python
It is a tree search
def parse_input(path):
with path.open("r") as fp:
lines = fp.read().splitlines()
roots = [int(line.split(':')[0]) for line in lines]
node_lists = [[int(x) for x in line.split(':')[1][1:].split(' ')] for line in lines]
return roots, node_lists
def construct_tree(root, nodes, include_concat):
levels = [[] for _ in range(len(nodes)+1)]
levels[0] = [(str(root), "")]
# level nodes are tuples of the form (val, operation) where both are str
# val can be numerical or empty string
# operation can be *, +, || or empty string
for indl, level in enumerate(levels[1:], start=1):
node = nodes[indl-1]
for elem in levels[indl-1]:
if elem[0]=='':
continue
if elem[0][-len(str(node)):] == str(node) and include_concat:
levels[indl].append((elem[0][:-len(str(node))], "||"))
if (a:=int(elem[0]))%(b:=int(node))==0:
levels[indl].append((str(int(a/b)), '*'))
if (a:=int(elem[0])) - (b:=int(node))>0:
levels[indl].append((str(a - b), "+"))
return levels[-1]
def solve_problem(file_name, include_concat):
roots, node_lists = parse_input(Path(cwd, file_name))
valid_roots = []
for root, nodes in zip(roots, node_lists):
top = construct_tree(root, nodes[::-1], include_concat)
if any((x[0]=='1' and x[1]=='*') or (x[0]=='0' and x[1]=='+') or
(x[0]=='' and x[1]=='||') for x in top):
valid_roots.append(root)
return sum(valid_roots)
I asked ChatGPT to explain your code and mentioned you said it was a binary search. idk why, but it output a matter of fact response that claims you were wrong. lmao, I still donβt understand how your code works
This code doesnβt perform a classic binary search. Instead, it uses each input node to generate new possible states or βbranches,β forming a tree of transformations. At each level, it tries up to three operations on the current value (remove digits, divide, subtract). These expansions create multiple paths, and the code checks which paths end in a successful condition. While the author may have described it as a βbinary search,β itβs more accurately a state-space search over a tree of possible outcomes, not a binary search over a sorted data structure.
I understand it now! I took your solution and made it faster. it is now like 36 milliseconds faster for me. which is interesting because if you look at the code. I dont process the entire list of integers. I sometimes stop prematurely before the next level, clear it, and add the root. I donβt know why but it just works for my input and the test input.
code
def main(input_data):
input_data = input_data.replace('\r', '')
parsed_data = {int(line[0]): [int(i) for i in line[1].split()[::-1]] for line in [l.split(': ') for l in input_data.splitlines()]}
part1 = 0
part2 = 0
for item in parsed_data.items():
root, num_array = item
part_1_branches = [set() for _ in range(len(num_array)+1)]
part_2_branches = [set() for _ in range(len(num_array)+1)]
part_1_branches[0].add(root)
part_2_branches[0].add(root)
for level,i in enumerate(num_array):
if len(part_1_branches[level]) == 0 and len(part_2_branches[level]) == 0:
break
for branch in part_1_branches[level]:
if branch == i:
part_1_branches[level+1] = set() # clear next level to prevent adding root again
part1 += root
break
if branch % i == 0:
part_1_branches[level+1].add(branch//i)
if branch - i > 0:
part_1_branches[level+1].add(branch-i)
for branch in part_2_branches[level]:
if branch == i or str(branch) == str(i):
part_2_branches[level+1] = set() # clear next level to prevent adding root again
part2 += root
break
if branch % i == 0:
part_2_branches[level+1].add(branch//i)
if branch - i > 0:
part_2_branches[level+1].add(branch-i)
if str(i) == str(branch)[-len(str(i)):]:
part_2_branches[level+1].add(int(str(branch)[:-len(str(i))]))
print("Part 1:", part1, "\nPart 2:", part2)
return [part1, part2]
if __name__ == "__main__":
with open('input', 'r') as f:
main(f.read())
however what I notice is that the parse_input causes it to be the reason why it is slower by 20+ milliseconds. I find that even if I edited your solution like so to be slightly faster, it is still 10 milliseconds slower than mine:
code
def parse_input():
with open('input',"r") as fp:
lines = fp.read().splitlines()
roots = [int(line.split(':')[0]) for line in lines]
node_lists = [[int(x) for x in line.split(': ')[1].split(' ')] for line in lines]
return roots, node_lists
def construct_tree(root, nodes):
levels = [[] for _ in range(len(nodes)+1)]
levels[0] = [(root, "")]
# level nodes are tuples of the form (val, operation) where both are str
# val can be numerical or empty string
# operation can be *, +, || or empty string
for indl, level in enumerate(levels[1:], start=1):
node = nodes[indl-1]
for elem in levels[indl-1]:
if elem[0]=='':
continue
if (a:=elem[0])%(b:=node)==0:
levels[indl].append((a/b, '*'))
if (a:=elem[0]) - (b:=node)>0:
levels[indl].append((a - b, "+"))
return levels[-1]
def construct_tree_concat(root, nodes):
levels = [[] for _ in range(len(nodes)+1)]
levels[0] = [(str(root), "")]
# level nodes are tuples of the form (val, operation) where both are str
# val can be numerical or empty string
# operation can be *, +, || or empty string
for indl, level in enumerate(levels[1:], start=1):
node = nodes[indl-1]
for elem in levels[indl-1]:
if elem[0]=='':
continue
if elem[0][-len(str(node)):] == str(node):
levels[indl].append((elem[0][:-len(str(node))], "||"))
if (a:=int(elem[0]))%(b:=int(node))==0:
levels[indl].append((str(int(a/b)), '*'))
if (a:=int(elem[0])) - (b:=int(node))>0:
levels[indl].append((str(a - b), "+"))
return levels[-1]
def solve_problem():
roots, node_lists = parse_input()
valid_roots_part1 = []
valid_roots_part2 = []
for root, nodes in zip(roots, node_lists):
top = construct_tree(root, nodes[::-1])
if any((x[0]==1 and x[1]=='*') or (x[0]==0 and x[1]=='+') for x in top):
valid_roots_part1.append(root)
top = construct_tree_concat(root, nodes[::-1])
if any((x[0]=='1' and x[1]=='*') or (x[0]=='0' and x[1]=='+') or (x[0]=='' and x[1]=='||') for x in top):
valid_roots_part2.append(root)
return sum(valid_roots_part1),sum(valid_roots_part2)
if __name__ == "__main__":
print(solve_problem())
Wow I got thrashed by chatgpt. Strictly speaking that is correct, it is more akin to Tree Search. But even then not strictly because in tree search you are searching through a list of objects that is known, you build a tree out of it and based on some conditions eliminate half of the remaining tree each time. Here I have some state space (as chatgpt claims!) and again based on applying certain conditions, I eliminate some portion of the search space successively (so I dont have to evaluate that part of the tree anymore). To me both are very similar in spirit as both methods avoid evaluating some function on all the possible inputs and successively chops off a fraction of the search space. To be more correct I will atleast replace it with tree search though, thanks. And thanks for taking a close look at my solution and improving it.
idk why my gpt decided to be like that. I expected a long winded response with a little bit of ai hallucinations. I was flabbergasted, and just had to post it.
I seemingly realized that working forward through the list of integers was inefficient for me to do, and I was using multiprocessing to do it too! which my old solution took less than 15 seconds for my input. your solution to reverse the operations and eliminate paths was brilliant and made it solve it in milliseconds without spinning up my fans, lol