Advent of Code 2024 - Day 6

 {guard, grid} = :persistent_term.get(Day06) grid = Map.put(grid, pos, ?\#) 

@bjorng On my machine I have seen that if I modify the map it’s slower. I just carry the new obstacle pos around and return :obst arbitrarily when looking for that key.

I wonder if modifying the persistent term does a full copy of the term instead of having the rest of the map being pointed to the shared memory.

Edit: Hmmm I do not have this behaviour anymore. Should be caused by something else.

Anyway it runs under 1s at the expense of clarity, enough for today :smiley:

defmodule AdventOfCode.Solutions.Y24.Day06 do alias AdventOfCode.Grid alias AoC.Input def parse(input, _part) do grid = input |> Input.stream!() |> Grid.parse_stream(fn _, "#" -> {:ok, :obst} {x, y}, "^" -> Process.put(:start_pos, {x, y}) {:ok, :n} _, _ -> :ignore end) start = Process.get(:start_pos) Process.delete(:start_pos) {0, xo, 0, yo} = Grid.bounds(grid) {grid, start, :n, {xo, yo}} end def part_one({grid, start, dir, {xo, yo}}) do grid |> loop_until_quits(start, dir, {xo, yo}, %{start => true}) |> map_size() end defp loop_until_quits(grid, pos, dir, {xo, yo}, acc) do {x, y} = next_pos = Grid.translate(pos, dir, 1) case Map.get(grid, next_pos) do :obst -> loop_until_quits(grid, pos, rotate(dir), {xo, yo}, acc) nil when x in 0..xo//1 and y in 0..yo//1 -> loop_until_quits(grid, next_pos, dir, {xo, yo}, Map.put(acc, next_pos, true)) nil -> acc _ -> loop_until_quits(grid, next_pos, dir, {xo, yo}, Map.put(acc, next_pos, true)) end end defp rotate(:n), do: :e defp rotate(:e), do: :s defp rotate(:s), do: :w defp rotate(:w), do: :n def part_two({grid, start, dir, {xo, yo}}) do candidates = grid |> loop_until_quits(start, dir, {xo, yo}, %{}) |> Map.keys() :persistent_term.put(:grid, grid) parent = self() candidates |> Enum.map( &spawn_link(fn -> grid = Map.put(:persistent_term.get(:grid), &1, :obst) one_or_zero = loop_value(grid, start, dir, {xo, yo}) send(parent, one_or_zero) end) ) |> Enum.reduce(0, fn _, acc -> receive do n -> acc + n end end) end defp loop_value(grid, pos, dir, {xo, yo}) do {x, y} = next_pos = Grid.translate(pos, dir, 1) case Map.get(grid, next_pos) do :obst -> loop_value(grid, pos, rotate(dir), {xo, yo}) ^dir -> 1 _ when x in 0..xo//1 and y in 0..yo//1 -> loop_value(Map.put(grid, next_pos, dir), next_pos, dir, {xo, yo}) nil -> 0 end end end 

My first attempt worked but part 2 took more than 100 seconds. :joy:
So I got inspired by @Aetherus and @bjorng 's code and tips to rewrite it better. :muscle:
For instance:

putting obstacles only in the path actually walked by the guard.

And using MapSet but I reduced the streams. :cowboy_hat_face:

defmodule Aoc2024.Solutions.Y24.Day06 do alias AoC.Input def parse(input, _part) do stream = Input.stream!(input, trim: true) {start, obstacles} = stream |> Stream.with_index() |> Enum.reduce({nil, MapSet.new()}, fn {line, x}, {start, obstacles} -> line |> String.to_charlist() |> Stream.with_index() |> Enum.reduce({start, obstacles}, fn {?#, y}, {start, obstacles} -> {start, MapSet.put(obstacles, {x, y})} {?^, y}, {_start, obstacles} -> {{x, y}, obstacles} _, {start, obstacles} -> {start, obstacles} end) end) row_end = Enum.count(stream) - 1 col_end = length(String.to_charlist(Enum.at(stream, 0))) - 1 {start, obstacles, row_end, col_end} end def part_one({{x, y}, obstacles, row_end, col_end}) do path = build_path({?^, {x - 1, y}}, obstacles, MapSet.new([{x, y}]), row_end, col_end) Enum.count(path) end def part_two({{x, y}, obstacles, row_end, col_end}) do path = build_path({?^, {x - 1, y}}, obstacles, MapSet.new([{x, y}]), row_end, col_end) Enum.reduce(path, 0, fn new_obstacle, counter -> obstacles = MapSet.put(obstacles, new_obstacle) limit = round(row_end * col_end / 2) v = check_path({?^, {x - 1, y}}, obstacles, row_end, col_end, 0, limit) v + counter end) end defp build_path({direction, {x, y}}, obstacles, solution, row_end, col_end) do cond do x < 0 or x > row_end or y < 0 or y > col_end -> solution MapSet.member?(obstacles, {x, y}) -> position = direct(direction, {x, y}) build_path(position, obstacles, solution, row_end, col_end) true -> position = continue(direction, {x, y}) build_path(position, obstacles, MapSet.put(solution, {x, y}), row_end, col_end) end end defp check_path({direction, {x, y}}, obstacles, row_end, col_end, counter, limit) do cond do counter == limit -> 1 x < 0 or x > row_end or y < 0 or y > col_end -> 0 MapSet.member?(obstacles, {x, y}) -> position = direct(direction, {x, y}) check_path(position, obstacles, row_end, col_end, counter, limit) true -> position = continue(direction, {x, y}) check_path(position, obstacles, row_end, col_end, counter + 1, limit) end end defp direct(c, {x, y}) do case c do ?^ -> {?>, {x + 1, y + 1}} ?> -> {?v, {x + 1, y - 1}} ?v -> {?<, {x - 1, y - 1}} ?< -> {?^, {x - 1, y + 1}} end end defp continue(c, {x, y}) do case c do ?^ -> {c, {x - 1, y}} ?> -> {c, {x, y + 1}} ?v -> {c, {x + 1, y}} ?< -> {c, {x, y - 1}} end end end 

I tried, but I couldn’t figure out when to stop in check_path therefor limit :weary:

Solution for 2024 day 6 part_one: 4696 in 11.74ms part_two: 1443 in 1.39s 
2 Likes

As promised - here is my code for Day 6. Explanations as to why the string version is so much slower than the map version are welcome. I read the source for String.at and the only explanation I could come up with is that its routine for splitting the string and taking the first char in the second half might be doing two heap allocations to store the left and right halves of the string? I’m not sure if this is true, but it’s the only thing I could come up with.

1 Like

this is so fun . first time in advent of code, i have finally hit the inevitable “it will never complete before i go to bed” type of performance :rofl:

anyone with smarter ways to find “deadlocks” by checking if traversal count is larger than the total length of map ?? :joy: for ALL single characters … :joy:

anyway … its actually taking SECONDS to check each single character :smiley: thats so … fun … literally no improvements done on it whatsoever though :wink:

im going to throw all my Ryzen 1700 cores on the job … with Tasks … muahaha

:rofl: (PS its just a joke… im just doing this for fun)

1 Like

I spent much of the time tuning for speed. I’ve got a solution for part 2 that runs in 0.5 seconds on my Macbook Pro M1. One interesting thing I found is that pattern matching in the function arguments on structure properties, is significantly faster than getting them with dot syntax in the function body.

So

def step(%{obstructions: obstructions, location: location, facing: facing} = _state)) do 

is faster than

def step(state) do ... state.obstructions ... state.location ... state.facing end 
2 Likes

i will eat my hat before i begin thinking hard about performance … “Need the answer, you do. Seek within, the path will reveal.” … so tomorrow … :wave::laughing:

Here’s mine for today, not the best, but just under 1 second so I’ll take it. So todays lesson, READ THE DESCRIPTION, again, failed to count the starting position, which was fine for the test case as the path crosses the start position, but that’s not the case for my input data, so was 1 short of the answer.

Would love to get this down some more with anyones helpful suggestions, maybe an ETS table might be a good option as others have mentioned?

 defmodule Aoc2024.Solutions.Y24.Day06 do alias AoC.Input def parse(input, _part) do Input.read!(input) |> String.split("\n", trim: true) end def part_one(problem) do matrix = problem |> build_matrix() find_start_position(matrix) |> move(matrix, :up) |> Map.to_list() |> Enum.filter(fn {_, v} -> v in ["X", "^"] end) |> Enum.count() end def move(current_position, matrix, direction) do next_coord = next_coord(current_position, direction) case Map.get(matrix, next_coord) do nil -> matrix square -> {next_position, matrix, direction} = check_next_position(square, direction, matrix, next_coord, current_position) move(next_position, matrix, direction) end end def check_next_position(square, direction, matrix, next_position, _current_position) when square in [".", "X", "^"] do {next_position, Map.put(matrix, next_position, "X"), direction} end def check_next_position(_square, direction, matrix, _next_position, current_position) do {current_position, matrix, rotate_direction(direction)} end def find_start_position(matrix) do matrix |> Enum.to_list() |> Enum.find(fn {_, v} -> v == "^" end) |> elem(0) end def build_matrix(grid) do Enum.with_index(grid) |> Enum.reduce(%{}, fn row, acc -> build_matrix_row(row, acc) end) end def build_matrix_row({row, row_index}, acc) do row |> String.graphemes() |> Enum.with_index() |> Enum.reduce(acc, fn {char, col_index}, acc -> Map.put(acc, {col_index, row_index}, char) end) end def next_coord({current_x, current_y}, direction) do case direction do :up -> {current_x, current_y - 1} :down -> {current_x, current_y + 1} :left -> {current_x - 1, current_y} :right -> {current_x + 1, current_y} end end def rotate_direction(:up), do: :right def rotate_direction(:right), do: :down def rotate_direction(:down), do: :left def rotate_direction(:left), do: :up def set_square_visited(matrix, {x, y}) do Map.put(matrix, {x, y}, "X") end def part_two(problem) do matrix = problem |> build_matrix() start = find_start_position(matrix) :persistent_term.put(Matrix, matrix) options = find_start_position(matrix) |> move(matrix, :up) |> Map.to_list() |> Enum.filter(fn {_, v} -> v == "X" end) options |> split_into_chunks() |> Task.async_stream(fn options -> Enum.map(options, fn {{x, y}, _} -> # test_matrix = Map.put(matrix, {x, y}, "#") move_2(start, :up, MapSet.new(), {x, y}) end) end) |> merge_results_stream() |> Enum.filter(& &1) |> Enum.count() end defp split_into_chunks(options) do workers = :erlang.system_info(:schedulers_online) options_count = Enum.count(options) options_per_chunk = :erlang.ceil(options_count / workers) Enum.chunk_every(options, options_per_chunk) end defp merge_results_stream(results_stream) do Enum.reduce(results_stream, [], fn {:ok, worker_result}, acc -> acc ++ worker_result end) end def move_2(current_position, direction, previous, testing_position) do if check_been_here_before(current_position, direction, previous) do true else next_coord = next_coord(current_position, direction) matrix = :persistent_term.get(Matrix) case Map.get(matrix, next_coord) do nil -> false square -> {next_position, direction, backtrack} = check_next_position_2( square, direction, next_coord, current_position, testing_position ) if backtrack do move_2(current_position, direction, previous, testing_position) else move_2( next_position, direction, MapSet.put(previous, {current_position, direction}), testing_position ) end end end end def check_been_here_before(current_position, direction, previous) do MapSet.member?(previous, {current_position, direction}) end def check_next_position_2( _square, direction, next_x_y, current_position, testing_position ) when next_x_y == testing_position do {current_position, rotate_direction(direction), true} end def check_next_position_2( square, direction, next_position, _current_position, _testing_position ) when square in [".", "X", "^"] do {next_position, direction, false} end def check_next_position_2( _square, direction, _next_position, current_position, _testing_position ) do {current_position, rotate_direction(direction), true} end end 

My day 06 solution:

My day 6 solution⤵
I was getting wrong counts for the longest time, and finally realized that I had forgotten to account for corners with more than one obstacle; I’d only ever turn the guard once.

I brute-forced it, like most people. I did use the visited positions from part 1 as obstacle candidates though, and between that and using a MapSet for “seen” spaces, it runs in a little over 4 seconds, which I was happy with.

No, it is the call byte_size_remaining_at(string, position) in do_at/2 for calculating the number of bytes to the left of the character to be extracted that makes it slower.

This is calculation is necessary to correctly handle Unicode characters in the string, since the size of each code point varies from one to four bytes. For example, emoji characters are four bytes, and in order to skip over two emoji characters in the following example, it is necessary to skip over eight bytes:

iex> String.at("😀😃😎🥸", 2) "😎" 

If a string is known to only contain US ASCII characters (as is the case for all text from the Advent of Code web site), the faster :binary.at/2 BIF can safely be used instead of String.at/2. That will usually be slightly faster than using a map.

5 Likes

Thanks for your advice.

23s is not a typo.

Good to know the ETS table is allocated elsewhere than the process’s heap. I don’t know why changing in to MapSet.member?/2 has a notable effect, isn’t that Kernel.in/2 calls Enumerable.member?/2 which in turn calls MapSet.member/2?

2 Likes

I suppose that the overhead for the extra function calls is noticeable when in is called very frequently.

2 Likes

It was fun to wait aaaaall the way to the next morning only to find out

  1. answer was wrong
  2. I should really comvert a list of positions into a map

:rofl:

i looked at your code and used some of your ideas and incoroporated them into my own code for Part2.

My part1 worked without using map with coord keys. That worked perfectly with list of coords. This is my first AOC in 16 years since i started developing and i have NEVER gotten use of using maps for quick lookups. So its been digged way back into my memory as a go to solution … Been mostly frontend and backend, with “normal” problems. (ok, ok, maybe some few times, but seldom …)=\

it uses 17-18 seconds though on the full input (AMD Ryzen 1700, 8 cores, 16 virtual) … could be my struct usage and patter matching maybe. EDIT: actually on github workflow it ran in 8 seconds … so i guess its highly machine dependant

This was great knowledge, thanks! I tested it out and, indeed, using :binary.at was much faster than String.at. I get roughly equal runtime for part 2 with :binary.at as I do when I convert the input into a Map and use Map.get. Both of which are at least two orders of magnitude faster than String.at in this case.

1 Like

I got some sleep yesterday, but that means I’m playing catch-up.

Here’s pt1 for me:

#!/usr/bin/env elixir defmodule Day6.Part1 do @westward_facing_guard ~c"<" |> List.first() @eastward_facing_guard ~c">" |> List.first() @northward_facing_guard ~c"^" |> List.first() @southward_facing_guard ~c"v" |> List.first() @guard_chars [ @northward_facing_guard, @southward_facing_guard, @eastward_facing_guard, @westward_facing_guard ] @obstacle ~c"#" |> List.first() @open_space ~c"." |> List.first() @seen ~c"X" |> List.first() defp parse(str) do [row | _tail] = rows = str |> String.split("\n") |> Enum.map(&to_charlist/1) height = Enum.count(rows) length = Enum.count(row) {x, y} = guard_position = rows |> Enum.with_index() |> Enum.reduce_while( {nil, nil}, fn {charlist, y_index}, {nil, nil} -> x_index = charlist |> Enum.find_index(fn c -> c in @guard_chars end) if is_nil(x_index), do: {:cont, {nil, nil}}, else: {:halt, {x_index, y_index}} end ) guard_row = rows |> Enum.at(y) orientation = guard_row |> Enum.at(x) sanitized_guard_row = guard_row |> Enum.map(fn c -> if c in @guard_chars, do: @open_space, else: c end) rows = (rows |> Enum.take(y)) ++ [sanitized_guard_row] ++ (rows |> Enum.drop(y + 1)) %{ map: %{ rows: rows, height: height, length: length }, guard_position: guard_position, orientation: orientation } end @next_orientation %{ @northward_facing_guard => @eastward_facing_guard, @eastward_facing_guard => @southward_facing_guard, @southward_facing_guard => @westward_facing_guard, @westward_facing_guard => @northward_facing_guard } defp mark_map( %{ map: %{ rows: rows, height: height, length: length }, guard_position: {x, y} = guard_position, orientation: orientation } = state, acc ) do row_above = if y == 0, do: :off_the_map, else: y - 1 row_below = if y == height - 1, do: :off_the_map, else: y + 1 column_before = if x == 0, do: :off_the_map, else: x - 1 column_after = if x == length - 1, do: :off_the_map, else: x + 1 current_row = rows |> Enum.at(y) current_value = current_row |> Enum.at(x) marked_row = (current_row |> Enum.take(x)) ++ [@seen] ++ (current_row |> Enum.drop(x + 1)) rows = (rows |> Enum.take(y)) ++ [marked_row] ++ (rows |> Enum.drop(y + 1)) {guard_position, orientation} = case orientation do @northward_facing_guard -> next_is_obstacle? = if row_above == :off_the_map do false else rows |> Enum.at(row_above) |> Enum.at(x) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{x, row_above}, orientation} end @southward_facing_guard -> next_is_obstacle? = if row_below == :off_the_map do false else rows |> Enum.at(row_below) |> Enum.at(x) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{x, row_below}, orientation} end @eastward_facing_guard -> next_is_obstacle? = if column_after == :off_the_map do false else rows |> Enum.at(y) |> Enum.at(column_after) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{column_after, y}, orientation} end @westward_facing_guard -> next_is_obstacle? = if column_before == :off_the_map do false else rows |> Enum.at(y) |> Enum.at(column_before) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{column_before, y}, orientation} end end increase = if current_value == @seen, do: 0, else: 1 {%{ state | map: %{state.map | rows: rows}, guard_position: guard_position, orientation: orientation }, increase + acc} end defp count_newly_traversed(state) do Stream.repeatedly(fn -> nil end) |> Enum.reduce_while( {state, 0}, fn nil = _ignored, {%{guard_position: {x, y}} = state, acc} -> if x == :off_the_map or y == :off_the_map do {:halt, {state, acc}} else {:cont, mark_map(state, acc)} end end ) |> elem(1) end def solve() do File.read!("06/input.txt") |> parse() |> count_newly_traversed() |> IO.puts() end end Day6.Part1.solve() 

And part2:

#!/usr/bin/env elixir defmodule Day6.Part1 do @westward_facing_guard ~c"<" |> List.first() @eastward_facing_guard ~c">" |> List.first() @northward_facing_guard ~c"^" |> List.first() @southward_facing_guard ~c"v" |> List.first() @guard_chars [ @northward_facing_guard, @southward_facing_guard, @eastward_facing_guard, @westward_facing_guard ] @obstacle ~c"#" |> List.first() @open_space ~c"." |> List.first() defp parse(str) do [row | _tail] = rows = str |> String.split("\n") |> Enum.map(&to_charlist/1) height = Enum.count(rows) length = Enum.count(row) {x, y} = guard_position = rows |> Enum.with_index() |> Enum.reduce_while( {nil, nil}, fn {charlist, y_index}, {nil, nil} -> x_index = charlist |> Enum.find_index(fn c -> c in @guard_chars end) if is_nil(x_index), do: {:cont, {nil, nil}}, else: {:halt, {x_index, y_index}} end ) possible_spawn_points = for j <- 0..(height - 1) do for i <- 0..(length - 1) do if rows |> Enum.at(j) |> Enum.at(i) == @open_space, do: {i, j}, else: nil end |> Enum.filter(fn i -> not is_nil(i) end) end |> List.flatten() orientation = rows |> Enum.at(y) |> Enum.at(x) %{ initial_state: %{ map: %{ rows: rows, height: height, length: length }, guard_position: guard_position, orientation: orientation }, possible_spawn_points: possible_spawn_points } end @next_orientation %{ @northward_facing_guard => @eastward_facing_guard, @eastward_facing_guard => @southward_facing_guard, @southward_facing_guard => @westward_facing_guard, @westward_facing_guard => @northward_facing_guard } defp store( seen, %{ map: %{ rows: rows, height: height, length: length }, guard_position: {x, y} = guard_position, orientation: orientation } = state ) do row_above = if y == 0, do: :off_the_map, else: y - 1 row_below = if y == height - 1, do: :off_the_map, else: y + 1 column_before = if x == 0, do: :off_the_map, else: x - 1 column_after = if x == length - 1, do: :off_the_map, else: x + 1 seen = seen |> MapSet.put({guard_position, orientation}) {guard_position, orientation} = case orientation do @northward_facing_guard -> next_is_obstacle? = if row_above == :off_the_map do false else rows |> Enum.at(row_above) |> Enum.at(x) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{x, row_above}, orientation} end @southward_facing_guard -> next_is_obstacle? = if row_below == :off_the_map do false else rows |> Enum.at(row_below) |> Enum.at(x) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{x, row_below}, orientation} end @eastward_facing_guard -> next_is_obstacle? = if column_after == :off_the_map do false else rows |> Enum.at(y) |> Enum.at(column_after) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{column_after, y}, orientation} end @westward_facing_guard -> next_is_obstacle? = if column_before == :off_the_map do false else rows |> Enum.at(y) |> Enum.at(column_before) == @obstacle end if next_is_obstacle? do {guard_position, @next_orientation[orientation]} else {{column_before, y}, orientation} end end { %{ state | map: %{state.map | rows: rows}, guard_position: guard_position, orientation: orientation }, seen } end defp place_obstacle(%{map: %{rows: rows}} = state, x, y) do row = rows |> Enum.at(y) obstructed_row = (row |> Enum.take(x)) ++ [@obstacle] ++ (row |> Enum.drop(x + 1)) rows = (rows |> Enum.take(y)) ++ [obstructed_row] ++ (rows |> Enum.drop(y + 1)) %{state | map: %{state.map | rows: rows}} end defp count_if_loop(state) do Stream.repeatedly(fn -> nil end) |> Enum.reduce_while( {state, MapSet.new()}, fn nil = _ignored, {%{guard_position: {x, y} = guard_position, orientation: orientation} = state, seen} -> cond do x == :off_the_map or y == :off_the_map -> {:halt, 0} seen |> MapSet.member?({guard_position, orientation}) -> {:halt, 1} true -> {:cont, seen |> store(state)} end end ) end defp count_creatable_loops( %{initial_state: initial_state, possible_spawn_points: possible_spawn_points} ) do possible_spawn_points |> Enum.reduce( 0, fn {x, y}, acc -> acc + (initial_state |> place_obstacle(x, y) |> count_if_loop()) end ) end def solve() do File.read!("06/input.txt") |> parse() |> count_creatable_loops() |> IO.puts() end end Day6.Part1.solve() 

I may come back and try to improve perf here. Part 2 took about 1m25s to run on a Intel® Core™ i7-10510U CPU @ 1.80GHz × 8

Code golfing did not spark joy on this one so I took a crack at performance on Part 2 and got it down to the 300-400ms range:

Name ips average deviation median 99th % part_2_fast_async 2.65 377.78 ms ±0.66% 377.81 ms 381.36 ms part_2_fast 2.27 440.13 ms ±1.12% 439.49 ms 452.15 ms 
Code
defmodule Aoc2024.Day06Fast do def part2(file) do file |> main() |> Enum.reduce(0, &(&2 + cycle_count(&1))) end def part2_async(file) do file |> main() |> Task.async_stream(&cycle_count/1, ordered: false) |> Enum.reduce(0, fn {:ok, num}, sum -> sum + num end) end def main(file) do rows = file |> File.read!() |> String.trim() |> String.split("\n") |> Enum.map(&String.to_charlist/1) n = length(rows) grid = for {row, i} <- Enum.with_index(rows, 1), {val, j} <- Enum.with_index(row, 1), into: %{}, do: {{i, j}, val} h = for i <- 1..n, into: %{} do ranges = 1..n |> Enum.filter(&(Map.get(grid, {i, &1}) == ?#)) |> Enum.reduce([1..n], &ranges_split(&2, &1)) {i, ranges} end v = for j <- 1..n, into: %{} do ranges = 1..n |> Enum.filter(&(Map.get(grid, {&1, j}) == ?#)) |> Enum.reduce([1..n], &ranges_split(&2, &1)) {j, ranges} end ranges = {h, v} {position, direction} = Enum.find(grid, fn {_, val} -> val not in [?., ?#] end) path = steps(direction, position, ranges, grid) |> Stream.chunk_every(2, 1, :discard) |> Stream.flat_map(fn [{{a, b}, dir}, {{c, d}, _}] -> for i <- a..c, j <- b..d, do: {{i, j}, dir} end) grid = Map.put(grid, position, ?.) :persistent_term.put(__MODULE__, {grid, ranges}) path |> Stream.uniq_by(&elem(&1, 0)) |> Stream.chunk_every(2, 1, :discard) end def cycle_count([{turning, _}, {turning, _}]), do: 0 def cycle_count([{prev_position, prev_direction}, {{i, j} = position, _}]) do {grid, {h, v}} = :persistent_term.get(__MODULE__) grid = Map.put(grid, position, ?#) ranges = {Map.update!(h, i, &ranges_split(&1, j)), Map.update!(v, j, &ranges_split(&1, i))} steps(prev_direction, prev_position, ranges, grid) |> Stream.drop(1) |> Enum.reduce_while(MapSet.new(), fn x, visited -> if MapSet.member?(visited, x) do {:halt, :cycle} else {:cont, MapSet.put(visited, x)} end end) |> case do :cycle -> 1 _ -> 0 end end def turn_90(?^), do: ?> def turn_90(?>), do: ?v def turn_90(?v), do: ?< def turn_90(?<), do: ?^ def turn_270(?^), do: ?< def turn_270(?>), do: ?^ def turn_270(?v), do: ?> def turn_270(?<), do: ?v def step(?^, {row, col}, i), do: {row - i, col} def step(?>, {row, col}, i), do: {row, col + i} def step(?v, {row, col}, i), do: {row + i, col} def step(?<, {row, col}, i), do: {row, col - i} def find_range(d, {i, j}, {ranges, _}) when d in [?<, ?>] do Enum.find(Map.get(ranges, i), fn range -> j in range end) end def find_range(d, {i, j}, {_, ranges}) when d in [?^, ?v] do Enum.find(Map.get(ranges, j), fn range -> i in range end) end def steps(direction0, position0, ranges, grid) do Stream.unfold({true, position0, direction0}, fn {cont?, {i, j} = curr, direction} -> if cont? do %{first: first, last: last} = find_range(direction, curr, ranges) next = case direction do ?^ -> {first, j} ?> -> {i, last} ?v -> {last, j} ?< -> {i, first} end prev_direction = turn_270(direction) {{curr, direction}, {Map.has_key?(grid, step(prev_direction, curr, 1)), next, turn_90(direction)}} else nil end end) end def ranges_split(ranges, x) do {left, [range | right]} = Enum.split_while(ranges, &(x not in &1)) left ++ range_split(range, x) ++ right end def range_split(a..c//1, a), do: [(a + 1)..c] def range_split(a..c//1, c), do: [a..(c - 1)] def range_split(a..c//1, b) when a < b and b < c, do: [a..(b - 1), (b + 1)..c] end 

The code is a mess so I’ll highlight the important part.

The main approach is to use ranges to traverse the path instead of individual grid cells. Here’s the range representation of the grid for input 1:

# Row ranges %{1 => [1..4, 6..10], 2 => [1..9], ..., 10 => [1..6, 8..10]} # Col ranges %{1 => [1..8, 10..10], 2 => [1..6, 8..10], ..., 10 => [1..1, 3..10]} 

Each row/col is represented as a list of ranges of .s with gaps for #s. This way, we don’t need to traverse each dot when walking left/right on a row or up/down on a col. We can simply jump to the :first/:last of the appropriate range. That cuts down significantly on how much work we need to do.

I did try a Task.async + :persistent_term approach but it yielded very little speed up. I haven’t tried other optimizations yet. Looks like @bjorng’s 200ish ms solution is the one to beat. I may have a look through to see if I can steal anything :stuck_out_tongue:

2 Likes

Part one was much easier than I thought it was going to be at first. Mine runs in ~1.28ms on my machine.

I even tried tracking nodes as both a map and a list. Turns out it’s pretty similar, see benchmark at the end.

Part 1

 def day_6_1() do grid = File.read!("./day_6_input.txt") max_width = map_width(grid, 0) {x, y} = find_start(grid, {0, 0}) walk(grid, :up, {x, y}, max_width, [{x, y}]) end def map_width(<<@new_line, _::binary>>, count), do: count + 1 def map_width(<<_::binary-size(1), rest::binary>>, count), do: map_width(rest, count + 1) @block "#" defp walk(grid, direction, coords, max_width, visited) do new_coords = coord_for_direction(direction, coords) case move(grid, new_coords, max_width) do :exited_map -> length(Enum.uniq(visited)) :block -> walk(grid, turn_right(direction), coords, max_width, visited) :cont -> walk(grid, direction, new_coords, max_width, [new_coords | visited]) end end defp coord_for_direction(:up, {x, y}), do: {x, y - 1} defp coord_for_direction(:down, {x, y}), do: {x, y + 1} defp coord_for_direction(:left, {x, y}), do: {x - 1, y} defp coord_for_direction(:right, {x, y}), do: {x + 1, y} defp move(grid, {x, y}, max) do # The grid is square so these are the same (the input file gets saved with a new line at the end) if x < 0 || y < 0 || x > max - 2 || y > max - 2 do :exited_map else <<_::binary-size(x + max * y), rest::binary>> = grid case rest do <<@block, _::binary>> -> :block _ -> :cont end end end defp turn_right(:up), do: :right defp turn_right(:down), do: :left defp turn_right(:left), do: :up defp turn_right(:right), do: :down @caret "^" def find_start(<<@caret, _::binary>>, {x, y}), do: {x, y} def find_start(<<@new_line, rest::binary>>, {_, y}), do: find_start(rest, {0, y + 1}) def find_start(<<_::binary-size(1), rest::binary>>, {x, y}), do: find_start(rest, {x + 1, y}) 

The benchmark below shows my approach with visited nodes as a map and visited nodes as a list.
The map version did this instead:

 def day_6_1_map() do grid = File.read!("./day_6_input.txt") max_width = map_width(grid, 0) {x, y} = find_start(grid, {0, 0}) walk_map(grid, :up, {x, y}, max_width, %{ {x, y} => 1}) end defp walk_map(grid, direction, coords, max_width, visited) do new_coords = coord_for_direction(direction, coords) case move(grid, new_coords, max_width) do :exited_map -> map_size(visited) :block -> walk_map(grid, turn_right(direction), coords, max_width, visited) :cont -> walk_map(grid, direction, new_coords, max_width, Map.put_new(visited, new_coords, 1)) end end 
Benchmarking Day 6 1 LIST ... Benchmarking Day 6 1 MAP ... Calculating statistics... Formatting results... Name ips average deviation median 99th % Day 6 1 MAP 774.25 1.29 ms ±16.84% 1.26 ms 1.70 ms Day 6 1 LIST 763.53 1.31 ms ±7.03% 1.28 ms 1.56 ms Comparison: Day 6 1 MAP 774.25 Day 6 1 LIST 763.53 - 1.01x slower +0.0181 ms Memory usage statistics: Name Memory usage Day 6 1 MAP 2.26 MB Day 6 1 LIST 2.41 MB - 1.07x memory usage +0.157 MB **All measurements for memory usage were the same** Reduction count statistics: Name Reduction count Day 6 1 MAP 63.96 K Day 6 1 LIST 74.58 K - 1.17x reduction count +10.63 K **All measurements for reduction count were the same** 

I ended up with a bunch of duplicated code between part 1 and part 2. The biggest challenge was figuring out what I was doing wrong that was making part 2 really slow. It turned out to be how I was checking for loops by creating a list of the visited nodes and searching it at each node. Changing that to a MapSet got me to a solution.

defmodule Aoc2024.Day6 do @moduledoc false @guard_up "^" @guard_right ">" @guard_down "v" @guard_left "<" @obstruction "#" @visited "X" defp get_input(file) do File.read!(file) |> String.split("\n") |> Enum.filter(fn line_data -> line_data != "" end) |> Enum.map(&String.split(&1, "", trim: true)) end defp visit(grid, {x, y}) do List.update_at(grid, y, fn row -> List.replace_at(row, x, @visited) end) end defp patrol(map, obstructions) do width = length(List.first(map)) # Find the guard, then patrol. indexed = map |> List.flatten() |> Enum.with_index() {guard, i} = List.keyfind(indexed, @guard_up, 0) || List.keyfind(indexed, @guard_down, 0) || List.keyfind(indexed, @guard_left, 0) || List.keyfind(indexed, @guard_right, 0) facing = case guard do @guard_up -> :up @guard_down -> :down @guard_left -> :left @guard_right -> :right end start = {Integer.mod(i, width), Integer.floor_div(i, width), facing} bounds = {length(List.first(map)) - 1, length(map) - 1} patrol(map, obstructions, bounds, start) end defp patrol(map, obstructions, bounds = {last_x, last_y}, loc = {x, y, _facing}, route \\ []) do map = visit(map, {x, y}) next = {next_x, next_y, _next_facing} = look(obstructions, loc, bounds) route = route ++ [loc] if next_x < 0 or next_y < 0 or next_x > last_x or next_y > last_y do {map, route} else patrol(map, obstructions, bounds, next, route) end end defp walk(obstructions, loc, bounds = {last_x, last_y}, visited \\ MapSet.new()) do next = {next_x, next_y, _next_facing} = look(obstructions, loc, bounds) cond do next_x < 0 or next_y < 0 or next_x > last_x or next_y > last_y -> 0 MapSet.member?(visited, loc) -> 1 true -> walk(obstructions, next, bounds, MapSet.put(visited, loc)) end end defp look(obstructions, {x, y, :up}, {last_x, last_y}) do ahead = {x, y - 1} if y > 0 and MapSet.member?(obstructions, ahead) do look(obstructions, {x, y, :right}, {last_x, last_y}) else Tuple.append(ahead, :up) end end defp look(obstructions, {x, y, :down}, {last_x, last_y}) do ahead = {x, y + 1} if y < last_y and MapSet.member?(obstructions, ahead) do look(obstructions, {x, y, :left}, {last_x, last_y}) else Tuple.append(ahead, :down) end end defp look(obstructions, {x, y, :left}, {last_x, last_y}) do ahead = {x - 1, y} if x > 0 and MapSet.member?(obstructions, ahead) do look(obstructions, {x, y, :up}, {last_x, last_y}) else Tuple.append(ahead, :left) end end defp look(obstructions, {x, y, :right}, {last_x, last_y}) do ahead = {x + 1, y} if x < last_x and MapSet.member?(obstructions, ahead) do look(obstructions, {x, y, :down}, {last_x, last_y}) else Tuple.append(ahead, :right) end end def part1(file) do map = get_input(file) width = length(List.first(map)) obstructions = map |> List.flatten() |> Enum.with_index() |> Enum.reduce([], fn {space, i}, acc -> if space == @obstruction do [{Integer.mod(i, width), Integer.floor_div(i, width)} | acc] else acc end end) |> MapSet.new() {map, _} = patrol(map, obstructions) map |> List.flatten() |> Enum.frequencies() |> Map.get(@visited) end def part2(file) do map = get_input(file) last_x = length(List.first(map)) - 1 last_y = length(map) - 1 width = length(List.first(map)) indexed = map |> List.flatten() |> Enum.with_index() {guard, guard_idx} = List.keyfind(indexed, @guard_up, 0) || List.keyfind(indexed, @guard_down, 0) || List.keyfind(indexed, @guard_left, 0) || List.keyfind(indexed, @guard_right, 0) facing = case guard do @guard_up -> :up @guard_down -> :down @guard_left -> :left @guard_right -> :right end start = {Integer.mod(guard_idx, width), Integer.floor_div(guard_idx, width), facing} obstructions = indexed |> Enum.reduce([], fn {space, i}, acc -> if space == @obstruction do [{Integer.mod(i, width), Integer.floor_div(i, width)} | acc] else acc end end) |> MapSet.new() {_, route} = patrol(map, obstructions) visited = route |> Enum.map(fn {x, y, _} -> {x, y} end) |> Enum.filter(fn loc -> loc != start end) |> Enum.uniq() # Put an obstruction at each visited location and see if it loops. visited |> Enum.map(fn loc -> MapSet.put(obstructions, loc) |> walk(start, {last_x, last_y}) end) |> Enum.sum() end end 
1 Like