Powered by AppSignal & Oban Pro

Advent of code day 17

2023/livebooks/day-17.livemd

Advent of code day 17

Mix.install([
  {:kino, "~> 0.5.0"}
])

Setup input

example = Kino.Input.textarea("Please paste your input example:")
input = Kino.Input.textarea("Please paste your real input:")
defmodule PriorityQueue do
  @moduledoc """
  A stateless priority queue implementation basing on pairing heap.
  """

  @initial_serial -Bitwise.bsl(1, 64)

  defstruct size: 0,
            serial: @initial_serial,
            heap: nil,
            key_fun: nil

  @typep serial :: integer()
  @typep pairing_heap(key, value) :: nil | {{key, serial()}, value, [pairing_heap(key, value)]}
  @opaque t(key, value) :: %__MODULE__{
            size: non_neg_integer,
            serial: serial(),
            heap: pairing_heap(key, value),
            key_fun: (value -> key)
          }

  @doc """
  Create an empty priority queue.
  """
  @spec new((value -> key)) :: t(key, value) when key: any(), value: any()
  def new(key_fun \\ nil), do: %__MODULE__{key_fun: key_fun || (& &1)}

  @doc """
  Get the size of a priority queue.
  """
  @spec size(t(any, any)) :: non_neg_integer()
  def size(%__MODULE__{size: size}), do: size

  @doc """
  Put an item into a priority queue.
  Returns a new priority queue.
  """
  @spec push(t(key, value), value) :: t(key, value) when key: any(), value: any()
  def push(%__MODULE__{size: size, heap: heap, key_fun: key_fun, serial: serial} = pq, value) do
    %{pq | size: size + 1, heap: meld(heap, to_heap(value, key_fun, serial)), serial: serial + 1}
  end

  @doc """
  Get and delete the smallest item in the priority queue.
  If the queue is empty, returns `{:error, :empty}`,
  otherwise returns `{:ok, smallest_item, new_priority_queue}`.
  """
  @spec pop(t(key, value)) :: {:ok, value, t(key, value)} | {:error, :empty}
        when key: any(), value: any()
  def pop(%__MODULE__{size: 0}), do: {:error, :empty}

  def pop(%__MODULE__{size: size, heap: {_key, value, sub_heaps}} = pq) do
    {:ok, value, %{pq | size: size - 1, heap: pair(sub_heaps)}}
  end

  @doc """
  Get the smallest element in the priority queue.
  """
  @spec top(t(key, value)) :: {:ok, value} | {:error, :empty}
        when key: any(), value: any()
  def top(%__MODULE__{size: 0}), do: {:error, :empty}
  def top(%__MODULE__{heap: {_, value, _}}), do: {:ok, value}

  @doc """
  Merge two priority queues. Crashes if the given priority queues have different key functions.
  """
  @spec merge!(t(key, value), t(key, value)) :: t(key, value)
        when key: any(), value: any()
  def merge!(%__MODULE__{key_fun: f} = pq1, %__MODULE__{key_fun: f} = pq2) do
    %__MODULE__{
      key_fun: f,
      serial: max(pq1.serial, pq2.serial),
      size: pq1.size + pq2.size,
      heap: meld(pq1.heap, pq2.heap)
    }
  end

  def merge!(%__MODULE__{}, %__MODULE__{}) do
    raise ArgumentError, "Can't merge priority queues with different key functions."
  end

  defp to_heap(value, key_fun, serial) do
    {{key_fun.(value), serial}, value, []}
  end

  @spec meld(pairing_heap(key, value), pairing_heap(key, value)) :: pairing_heap(key, value)
        when key: any(), value: any()
  defp meld(nil, heap), do: heap
  defp meld(heap, nil), do: heap
  defp meld({k1, v1, sub1}, {k2, _v2, _sub2} = heap2) when k1 < k2, do: {k1, v1, [heap2 | sub1]}
  defp meld(heap1, {k2, v2, sub2}), do: {k2, v2, [heap1 | sub2]} 

  @spec pair([pairing_heap(key, value)]) :: pairing_heap(key, value)
        when key: any(), value: any()
  defp pair([]), do: nil
  defp pair([node]), do: node 
  defp pair([node1, node2 | rest]), do: meld(meld(node2, node1), pair(rest))
end


defmodule Dijkstra do
  def solve(grid, destination, part) when is_map(grid) do
    pq = PriorityQueue.new() |> PriorityQueue.push({0, {0, 0}, {0, 0}, 0})
    shortest_path(grid, pq, destination, MapSet.new(), part)
  end

  defp shortest_path(grid, pq, destination, seen, part) do
    case PriorityQueue.pop(pq) do
      {:error, :empty} ->
        {:error, :no_path}

      {:ok, {hl, {r, c}, {dr, dc}, n}, pq} ->
        cond do
          {r, c} == destination and can_stop?(n, part) ->
            {:ok, hl}

          MapSet.member?(seen, {r, c, dr, dc, n}) ->
            shortest_path(grid, pq, destination, seen, part)

          true ->
            seen = MapSet.put(seen, {r, c, dr, dc, n})

            pq =
              generate_neighbors(r, c, dr, dc, n, grid, part)
              |> Enum.reduce(pq, fn {new_hl, nr, nc, ndr, ndc, new_n}, acc ->
                PriorityQueue.push(acc, {hl + new_hl, {nr, nc}, {ndr, ndc}, new_n})
              end)

            shortest_path(grid, pq, destination, seen, part)
        end
    end
  end

  defp can_stop?(n, :part1), do: n > 0
  defp can_stop?(n, :part2), do: n >= 4

  defp generate_neighbors(r, c, dr, dc, n, grid, :part2) do
    neighbors =
      if n == 0 do
        for {ndr, ndc} <- [{0, 1}, {1, 0}, {0, -1}, {-1, 0}] do
          {r + ndr, c + ndc, ndr, ndc, 1}
        end
      else
        turns =
          if n >= 4 do
            [
              # Turn left
              {r + dc, c - dr, dc, -dr, 1},
              # Turn right
              {r - dc, c + dr, -dc, dr, 1}
            ]
          else
            []
          end
        if n < 10 do
          [{r + dr, c + dc, dr, dc, n + 1} | turns]
        else
          turns
        end
      end

    for {nr, nc, ndr, ndc, new_n} <- neighbors,
        (new_cost = grid[{nr, nc}]) != nil do
      {new_cost, nr, nc, ndr, ndc, new_n}
    end
  end

  defp generate_neighbors(r, c, dr, dc, n, grid, :part1) do
    # Continue straight if n < 3 and not at starting position
    straight =
      if n < 3 and {dr, dc} != {0, 0} do
        nr = r + dr
        nc = c + dc

        case grid[{nr, nc}] do
          nil -> []
          cost -> [{cost, nr, nc, dr, dc, n + 1}]
        end
      else
        []
      end

    turns =
      for {ndr, ndc} <- [{0, 1}, {1, 0}, {0, -1}, {-1, 0}],
          {ndr, ndc} != {dr, dc},
          {ndr, ndc} != {-dr, -dc},
          nr = r + ndr,
          nc = c + ndc,
          cost = grid[{nr, nc}],
          cost != nil do
        {cost, nr, nc, ndr, ndc, 1}
      end

    straight ++ turns
  end
end
parsed =
  example
  |> Kino.Input.read()
  |> String.split("\n", trim: true)
  |> Enum.map(&amp;(String.split(&amp;1, "", trim: true) |> List.to_tuple()))
  |> List.to_tuple()

lines = tuple_size(parsed) - 1
cols = tuple_size(elem(parsed, 0)) - 1

grid =
  for l <- 0..lines, c <- 0..cols, into: %{} do
    {{l, c}, elem(elem(parsed, l), c) |> String.to_integer()}
  end

Part 01

Dijkstra.solve(grid, {lines, cols}, :part1)

Part 02

Dijkstra.solve(grid, {lines, cols}, :part2)