rugyoga

rugyoga

Advent of Code 2023 - Day 17

Fairly straightforward Dijkstra’s algorithm

import AOC

aoc 2023, 17 do
  def compute(input, candidates) do
    {{max_row, max_col}, items} = Grid.parse(input)
    heat_map =
      items
      |> Enum.map(fn {coord, number} -> {coord, String.to_integer(number)} end)
      |> Map.new
    Heap.new()
    |> Heap.push({0, [{{0,0}, :east}]})
    |> Heap.push({0, [{{0,0}, :south}]})
    |> search({max_row-1, max_col-1}, heat_map, MapSet.new, candidates)
  end

  def p1(input), do: compute(input, &candidates_simple/1)
  def p2(input), do: compute(input, &candidates_ultra/1)

  def search(heap, {row_t, col_t} = target, heat_map, seen, candidates) do
    {{cost, last_3}, heap} = Heap.pop(heap)
    row_col = last_3 |> hd |> elem(0)
    cond do
      row_col == target -> cost
      MapSet.member?(seen, last_3) -> search(heap, target, heat_map, seen, candidates)
      true ->
        seen = MapSet.put(seen, last_3)
        last_3
        |> then(candidates)
        |> Enum.filter(fn [{{row, col}, _} | _] -> 0 <= row and row <= row_t and 0 <= col and col <= col_t end)
        |> Enum.reduce(heap, fn last_3, heap -> Heap.push(heap, {cost+heat_map[last_3 |> hd |> elem(0)], last_3}) end)
        |> search(target, heat_map, seen, candidates)
    end
  end

  def candidates_simple([x, _, _]), do: [[go(:left, x)], [go(:right, x)]]
  def candidates_simple([x | rest]), do: [[go(:straight, x), x | rest], [go(:left, x)], [go(:right, x)]]

  def candidates_ultra(moves) do
    cond do
    length(moves) < 4 -> [[go(:straight, hd(moves)) | moves]]
    length(moves) == 10 -> [[go(:left, hd(moves))], [go(:right, hd(moves))]]
    true -> [[go(:straight, hd(moves)) | moves], [go(:left, hd(moves))], [go(:right, hd(moves))]]
    end
  end

  @spec go(any(), {{any(), any()}, any()}) :: {{any(), any()}, :east | :north | :south | :west}
  def go(which_way, {row_col, dir}), do: next(row_col, dirs()[dir][which_way])

  def dirs() do
   %{west:  %{left: :south, straight: :west, right: :north},
     north: %{left: :west, straight: :north, right: :east},
     east:  %{left: :south, straight: :east, right: :north},
     south: %{left: :east, straight: :south, right: :west}}
  end

  def next({row, col}, :west), do: {{row, col-1}, :west}
  def next({row, col}, :east), do: {{row, col+1}, :east}
  def next({row, col}, :north), do: {{row-1, col}, :north}
  def next({row, col}, :south), do: {{row+1, col}, :south}
end

Most Liked

bjorng

bjorng

Erlang Core Team

Dijkstra’s algorithm using gb_sets as priority queue. It solves both parts in 2.7 seconds on my computer.

https://github.com/bjorng/advent-of-code-2023/blob/main/day17/lib/day17.ex

EDIT:

I realized that all elements inserted into the gb_sets are guaranteed to be unique, which means that it is safe to use gb_sets:insert/2 instead of gb_sets:add/2. That reduces the time for my solution from 2.7 seconds to 2.2 seconds.

exists

exists

Also used Dijkstra, but through libgraph. It turns out that creating graphs this big in it incurs a massive overhead, 50 seconds for part one and 130 seconds for part two (num_vertices: 39763, num_edges 529036). Well, TIL. The Dijkstra itself is then fast.
For me the interesting part was to realise that I can enforce the direction changes by having “two layers” of the grid, with top-to-bottom ony vertical direction arrows, and bottom-to-top only horizontal direction arrows.

code
Mix.install([{:libgraph, "~> 0.16.0"}])

defmodule Main do
  def run() do
    get_input()
    |> Enum.map(&String.to_charlist/1)
    # |> solve(1,3) # part1
    |> solve(4,10)  # part2
	end

  def get_input() do
    # "testinput17"
    "input17"
    |> File.read!()
    |> String.trim()
    |> String.split("\n")
  end

  def mkgrid(ls) do
    for {row, r} <- Enum.with_index(ls),
        {val, c} <- Enum.with_index(row),
        into: %{},
        do: {{r,c}, val-?0}
  end

  def calc_weight_straight({fr,fc},{tr,tc},grid) do
    (for r <- fr..tr, c <- fc..tc, do: grid[{r,c}])
    |> Enum.sum() |> Kernel.-(grid[{fr,fc}])
  end

  def cond_add_edge(g,{fr,fc,fl},{tr,tc,tl},grid) do
    if {tr,tc} in Map.keys(grid) do
      wt = calc_weight_straight({fr,fc},{tr,tc},grid)
      Graph.add_edge(g, {fr,fc,fl}, {tr,tc,tl}, weight: wt)
    else g end
  end

  @st {-1,-1,:t}
  @ed {200,200,:t}

  def mkgraph(grid,mn,mx) do
    rmax = Map.keys(grid) |> Enum.map(&elem(&1,0)) |> Enum.max()
    cmax = Map.keys(grid) |> Enum.map(&elem(&1,1)) |> Enum.max()
    for {r,c} <- Map.keys(grid), reduce: Graph.new(type: :directed) do 
      g ->
        mn..mx |> Enum.reduce(g, fn d, gacc ->
            gacc |> cond_add_edge({r,c,:t}, {r+d,c,:b}, grid)
                 |> cond_add_edge({r,c,:t}, {r-d,c,:b}, grid)
                 |> cond_add_edge({r,c,:b}, {r,c+d,:t}, grid)
                 |> cond_add_edge({r,c,:b}, {r,c-d,:t}, grid)
          end)
    end
    |> Graph.add_edge(@st,{0,0,:t},weight: 1)
    |> Graph.add_edge(@st,{0,0,:b},weight: 1)
    |> Graph.add_edge({rmax,cmax,:t},@ed,weight: 1)
    |> Graph.add_edge({rmax,cmax,:b},@ed,weight: 1)
  end

  def path_length([a,b|rest],g,sum) do
    wt = g |> Graph.edge(a,b) |> Map.get(:weight,0)
    path_length([b|rest], g, sum+wt)
  end
  def path_length([_vtx],_g,sum), do: sum

  def solve(ls,mn,mx) do
    grid = ls |> mkgrid()
    gr = grid |> mkgraph(mn,mx)
    Graph.get_shortest_path(gr,@st,@ed)
    |> path_length(gr,0)
    |> Kernel.-(2)
  end
end

:timer.tc(&Main.run/0)
|> IO.inspect(charlists: :as_lists)

I should probably try to rewrite this with just digraph to see how it compares, although digraph does not do edge weights directly.

(sorry, hit the wrong “reply” button…)

midouest

midouest

Took me a while to implement Dijkstra’s Algorithm and then I got stuck because I was hung up on using x-y coordinates for the distance/previous keys. I rewrote it as a depth-first search and ran it on my desktop computer with 16GB of RAM to find the answer to part 1 in about 10 minutes! I tried the same approach for part 2, but the program consumed all of my RAM + lots of paging to disk. I restarted it a few times with the best result from the previous iteration, but it never found the answer. I eventually went back to my original implementation and finally figured out the trick. This was a nice dive into the Erlang docs to learn about :gb_sets.

Part 1
defmodule Part1 do
  def parse(input) do
    for line <- String.split(input, "\n", trim: true) do
      for char <- String.graphemes(line) do
        String.to_integer(char)
      end
    end
  end

  def print(map, path) do
    for {line, y1} <- Enum.with_index(map) do
      for {loss, x1} <- Enum.with_index(line) do
        index = Enum.find_index(path, fn pos -> pos == {y1, x1} end)

        char =
          if index != nil and index > 0 do
            {y0, x0} = Enum.at(path, index - 1)
            dy = y1 - y0
            dx = x1 - x0

            case {dy, dx} do
              {1, 0} -> "v"
              {0, 1} -> ">"
              {-1, 0} -> "^"
              {0, -1} -> "<"
            end
          else
            Integer.to_string(loss)
          end

        IO.write(char)
      end

      IO.puts("")
    end
  end

  def total_loss(map, path) do
    path
    |> Enum.drop(1)
    |> Enum.map(fn {y, x} -> map |> Enum.at(y) |> Enum.at(x) end)
    |> Enum.sum()
  end

  def reconstruct(prev, state), do: reconstruct(prev, state, [])
  def reconstruct(_, nil, path), do: path

  def reconstruct(prev, {pos, _, _} = state, path),
    do: reconstruct(prev, prev[state], [pos | path])

  @deltas [{1, 0}, {0, 1}, {-1, 0}, {0, -1}]

  def search(map) do
    goal = length(map) - 1
    start = {0, 0}
    dist = %{{start, nil, 0} => 0}
    prev = %{}
    state = {0, 0, start, nil}
    queue = :gb_sets.empty()
    queue = :gb_sets.insert(state, queue)
    search(map, goal, dist, prev, queue)
  end

  def search(map, goal, dist, prev, queue) do
    {curr_loss, curr_rep, {curr_y, curr_x} = curr_pos, curr_delta} =
      curr_state = :gb_sets.smallest(queue)

    queue = :gb_sets.delete(curr_state, queue)

    if curr_y == goal and curr_x == goal do
      reconstruct(prev, {curr_pos, curr_delta, curr_rep})
    else
      {dist, prev, queue} =
        @deltas
        |> Stream.map(fn {next_dy, next_dx} = next_delta ->
          next_pos = {curr_y + next_dy, curr_x + next_dx}
          next_rep = if next_delta == curr_delta, do: curr_rep + 1, else: 1
          {next_pos, next_delta, next_rep}
        end)
        |> Stream.reject(fn {{next_y, next_x}, {next_dy, next_dx}, next_rep} ->
          next_y < 0 or next_x < 0 or next_y > goal or next_x > goal or next_rep > 3 or
            (curr_delta != nil and
               {next_dy, next_dx} == {elem(curr_delta, 0) * -1, elem(curr_delta, 1) * -1})
        end)
        |> Enum.reduce(
          {dist, prev, queue},
          fn {{next_y, next_x} = next_pos, next_delta, next_rep}, {dist, prev, queue} ->
            next_loss = curr_loss + (map |> Enum.at(next_y) |> Enum.at(next_x))

            if next_loss >= dist[{next_pos, next_delta, next_rep}] do
              {dist, prev, queue}
            else
              dist = Map.put(dist, {next_pos, next_delta, next_rep}, next_loss)

              prev =
                Map.put(prev, {next_pos, next_delta, next_rep}, {curr_pos, curr_delta, curr_rep})

              next_state = {next_loss, next_rep, next_pos, next_delta}
              queue = :gb_sets.insert(next_state, queue)

              {dist, prev, queue}
            end
          end
        )

      search(map, goal, dist, prev, queue)
    end
  end
end

map = Part1.parse(input)
path = Part1.search(map)
Part1.print(map, path)
Part1.total_loss(map, path)
Part 2
defmodule Part2 do
  @deltas [{1, 0}, {0, 1}, {-1, 0}, {0, -1}]

  def search(map) do
    goal = {length(map) - 1, length(hd(map)) - 1}
    start = {0, 0}
    dist = %{{start, nil, 0} => 0}
    prev = %{}
    state = {0, 0, start, nil}
    queue = :gb_sets.empty()
    queue = :gb_sets.insert(state, queue)
    search(map, goal, dist, prev, queue)
  end

  def search(map, {goal_y, goal_x} = goal, dist, prev, queue) do
    {curr_loss, curr_rep, {curr_y, curr_x} = curr_pos, curr_delta} =
      curr_state = :gb_sets.smallest(queue)

    queue = :gb_sets.delete(curr_state, queue)

    if curr_y == goal_y and curr_x == goal_x do
      if curr_rep < 4 do
        search(map, goal, dist, prev, queue)
      else
        Part1.reconstruct(prev, {curr_pos, curr_delta, curr_rep})
      end
    else
      {dist, prev, queue} =
        @deltas
        |> Stream.map(fn {next_dy, next_dx} = next_delta ->
          next_pos = {curr_y + next_dy, curr_x + next_dx}
          next_rep = if next_delta == curr_delta, do: curr_rep + 1, else: 1
          {next_pos, next_delta, next_rep}
        end)
        |> Stream.reject(fn {{next_y, next_x}, {next_dy, next_dx} = next_delta, next_rep} ->
          case curr_delta do
            nil ->
              false

            {curr_dy, curr_dx} ->
              next_y < 0 or next_x < 0 or next_y > goal_y or next_x > goal_x or
                (next_delta != curr_delta and curr_rep < 4) or
                (next_delta == curr_delta and next_rep > 10) or
                (next_dy == curr_dy * -1 and next_dx == curr_dx * -1)
          end
        end)
        |> Enum.reduce(
          {dist, prev, queue},
          fn {{next_y, next_x} = next_pos, next_delta, next_rep}, {dist, prev, queue} ->
            next_loss = curr_loss + (map |> Enum.at(next_y) |> Enum.at(next_x))

            if next_loss >= dist[{next_pos, next_delta, next_rep}] do
              {dist, prev, queue}
            else
              dist = Map.put(dist, {next_pos, next_delta, next_rep}, next_loss)

              prev =
                Map.put(prev, {next_pos, next_delta, next_rep}, {curr_pos, curr_delta, curr_rep})

              next_state = {next_loss, next_rep, next_pos, next_delta}
              queue = :gb_sets.insert(next_state, queue)

              {dist, prev, queue}
            end
          end
        )

      search(map, goal, dist, prev, queue)
    end
  end
end

map = Part1.parse(input)
path = Part2.search(map)
Part1.print(map, path)
Part1.total_loss(map, path)

Where Next?

Popular in Challenges Top

LostKobrakai
This topic is about Day 9 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/le...
New
cblavier
Hey there :wave: No magic or algorithmic finesse today, I just finished the challenge and I my code is quite slow (1sec for part1, 3se...
New
LostKobrakai
This one has been quite the ride. Struggled at first to find a good data format to suite the problem. I really like how that turned out b...
New
bjorng
This topic is about Day 18 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/l...
New
kwando
Phew, this one took a while to get right. My naive attempts was way to slow so I reached for Dijkstras shortest path algorithm.. and that...
New
bjorng
Note: This topic is to talk about Day 18 of the Advent of Code 2019. There is a private leaderboard for elixirforum members. You can joi...
New
antoine-duchenet
Everything went smoothly today. Nothing to change to solve part 2 because I already used memoization for part 1 (it looked like an AoC e...
New
bjorng
This topic is about Day 2 of the Advent of Code 2021. We have a private leaderboard (shared with users of Erlang Forums): https://adven...
New
rugyoga
part 1: https://github.com/rugyoga/aoc2021/blob/main/day8.exs part 2: https://github.com/rugyoga/aoc2021/blob/main/day8b.exs
New
seeplusplus
This one was much easier for me than yesterday’s. Part 1 runs in 22ms and part 2 runs in ~3s. defmodule BridgeRepair do def parse_inpu...
New

Other popular topics Top

marius95
Hello everyone, I try to use an Javascript Event Handler in my root.html.leex file. Therefore I created a function in the app.js file: ...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 53690 245
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 47930 226
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" =&gt; #BSON.ObjectId&lt;58eb1a7a9ad169198c3dXXXX&gt;, "email" =&gt; ...
New

We're in Beta

About us Mission Statement