Advent of Code 2024 - Day 15

I found today a bit tedious: advent-of-code-2024/lib/advent_of_code2024/day15.ex at main · ibarakaiev/advent-of-code-2024 · GitHub.

For part 2, I had a mixed feeling about Elixir. The good part is that immutability and persistent data structures eliminate the need for backtracking. The bad part is that there’s no early return in Elixir, so I had to throw everywhere. The stupid thing is, I accidentally deleted my code again!

Oh I’d hate to accidentally delete my code for this one… I usually use reduce_while/3 if I need to return early. Tons of it in this problem.

2 Likes

Another fun puzzle.

I thought it was going to be tedious at first but in the end, just a couple modifications from part one are enough. This runs in 15ms:

1 Like

After refactoring in part 2 I ended up with a recursive movable() function to return a list of movable positions or nil if blocked. I update the map in a second step.

I can’t get the solution for part 2. Using the example input, my grid does not look like the one in the puzzle after executing the movements. I even visualized the movements, but can’t figure out where my player does something wrong.

I guess, it is something with pushing the boxes, but I do handle the case where one box pushes two boxes simultaneously :man_shrugging:

Here are the first 30 seconds visualized (video took longer than 30 seconds, so the full run does not fit in one gif)

part2

This is my grid after all movements:

####################
##[][]........[][]##
##[]..........[][]##
##...@..........[]##
##.............[].##
##..##[][]....[][]##
##...[]..[]...[]..##
##.....[]..[].[][]##
##........[]......##
####################

EDIT: I figured it out now. I did not consider that the player can push boxes that are diagonally placed above / under the player.

I guess, I made part 2 more complicated than it is, but might come back to optimizing it. My source code:

Day n of failing to quit Advent of Code…

This one killed me. Part 2 took me hours. It wasn’t helped by the fact I had a sneaky bug and ended up stepping through the “bigger example” frame by frame to find the edge case. At least the whole thing runs in less than 10ms.

Probably the most interesting thing I did was store the directions as anonymous functions that I then applied to the coordinates.

moves =
  for <<char::binary-1 <- String.replace(raw_moves, "\n", "")>> do
    case char do
      "^" -> fn {x, y} -> {x, y - 1} end
      "v" -> fn {x, y} -> {x, y + 1} end
      "<" -> fn {x, y} -> {x - 1, y} end
      ">" -> fn {x, y} -> {x + 1, y} end
    end
  end

It actually made debugging more difficult though, because #Function<13.8856859/1 in Day15.input/0> doesn’t tell you which direction it’s going without running the function… :sweat_drops:

I don’t know what you did but it sounds very different to what I did, which was store the boxes/walls in a map, and assume everything else was free space. Only needed to update any state when the boxes moved.

Here is my solution. I’m pretty happy with it, I find it rather neat :

defmodule Y2024.D15.Guards do
  defguard is_vertical(step) when step == "^" or step == "v"
  defguard is_horizontal(step) when step == "<" or step == ">"
  defguard is_box(cell) when cell == "[" or cell == "]"
end

defmodule Y2024.D15 do
  use Day, input: "2024/15", part1: ~c"c", part2: ~c"c"

  import Y2024.D15.Guards

  defp partX(input, transform \\ & &1) do
    {grid, steps} = parse_input(input)

    map =
      grid
      |> transform.()
      |> to_map()

    {start_rc, "@"} = Enum.find(map, &(elem(&1, 1) == "@"))

    steps
    |> Enum.reduce({map, start_rc}, &try/2)
    |> elem(0)
    |> locate()
  end

  defp part1(input), do: partX(input)

  defp part2(input), do: partX(input, &widen/1)

  defp try(step, {map, rc}) do
    if can?(map, rc, step) do
      {act(map, rc, step), to(rc, step)}
    else
      {map, rc}
    end
  end

  defp can?(_, _, ".", _, _), do: true
  defp can?(_, _, "#", _, _), do: false

  defp can?(map, rc, "[", step, expand?) when is_vertical(step) do
    can?(map, to(rc, step), step) and (not expand? or can?(map, to(rc, ">"), step, false))
  end

  defp can?(map, rc, "]", step, expand?) when is_vertical(step) do
    can?(map, to(rc, step), step) and (not expand? or can?(map, to(rc, "<"), step, false))
  end

  defp can?(map, rc, _, step, _), do: can?(map, to(rc, step), step)
  defp can?(map, rc, step, expand? \\ true), do: can?(map, rc, Map.get(map, rc), step, expand?)

  defp act(map, _, ".", _, _), do: map

  defp act(map, rc, cell, step, expand?) when is_box(cell) and is_vertical(step) do
    map
    |> act(to(rc, step), step)
    |> Map.replace(to(rc, step), cell)
    |> Map.replace(rc, ".")
    |> then(fn m ->
      if expand? do
        act(m, rc |> to(expand_dir(cell)), step, false)
      else
        m
      end
    end)
  end

  defp act(map, rc, cell, step, _) do
    map
    |> act(to(rc, step), step)
    |> Map.replace(to(rc, step), cell)
    |> Map.replace(rc, ".")
  end

  defp act(map, rc, step, expand? \\ true), do: act(map, rc, Map.get(map, rc), step, expand?)

  defp locate({{r, c}, "O"}), do: r * 100 + c
  defp locate({{r, c}, "["}), do: r * 100 + c
  defp locate({_, _}), do: 0

  defp locate(map) do
    map
    |> Enum.map(&locate/1)
    |> Enum.sum()
  end

  defp widen(grid) do
    Enum.map(grid, fn row ->
      Enum.flat_map(row, fn
        "." -> [".", "."]
        "#" -> ["#", "#"]
        "O" -> ["[", "]"]
        "@" -> ["@", "."]
      end)
    end)
  end

  defp to({r, c}, {dr, dc}), do: {r + dr, c + dc}
  defp to(rc, step), do: to(rc, dir(step))

  defp expand_dir("]"), do: dir("<")
  defp expand_dir("["), do: dir(">")

  defp dir("^"), do: {-1, 0}
  defp dir("v"), do: {+1, 0}
  defp dir("<"), do: {0, -1}
  defp dir(">"), do: {0, +1}

  defp to_map(grid) do
    grid
    |> Enum.with_index()
    |> Enum.flat_map(fn {row, r} ->
      row
      |> Enum.with_index()
      |> Enum.map(fn {cell, c} -> {{r, c}, cell} end)
    end)
    |> Enum.into(%{})
  end

  defp parse_input(input) do
    [map_chunck, steps_chunk] = input

    {parse_grid(map_chunck), parse_steps(steps_chunk)}
  end

  defp parse_grid(grid_chunk), do: Enum.map(grid_chunk, &Utils.splitrim/1)

  defp parse_steps(steps_chunk) do
    steps_chunk
    |> Enum.join("")
    |> Utils.splitrim("")
  end
end

I have been disturbed by this wording :

For these larger boxes, distances are measured from the edge of the map to the closest edge of the box in question.

It made me believe that we had to add mechanics with the right and bottom edges for a while.

I was worried about that too, but carefully looking at the example showed that it wasn’t necessary. I think the x + 100y requirement is just a simple way to convert a bunch of coordinates to a single number for the purposes of submitting an answer.

I do find it funny how AoC sometimes ties itself in knots explaining a very simple problem like getting an x,y coordinate, while at the same time writing problems where the optimal solution requires prior knowledge such as modulo, quadratic formula, Chinese remainder theorem, graph theory, shortest path algorithm, etc. etc.

part 1 was easy … didnt bother sharing it yesterday … plus its been awful amount of tile puzzles :smiley:

but part 2… thats a fun puzzle … so in the end after endless refactoring, throwing away, and repeat, i just said eff it … there is no rule i cant just parse the input and replace all single #, O and . with two of them … then it became awful lot easier to parse the new map … plus i decided to add ids to the tiles, so i can cross reference it later …

link: advent_of_code/solutions/2024/day_15/lib/Part2.ex at master · jarlah/advent_of_code · GitHub

now ill maybe go actually solve the problem of gaming programming … :stuck_out_tongue:

For example, in the following case

######
#...##
#[][]#
#.[].#
#.@..#
######

^

In this case, I try to push the box on the 4th row upward, which in requires pushing the left box on the 3rd row upward (ok), then the right box on the 3rd row upward (failed). In traditional programming language, you need to pull back the left box (backtracking), but in Elixir, you just need to use the state before pushing.

1 Like

This was fun, like playing a game. But I’m not getting the right answer for part 2 even though the test passes. I watched the animation for half an hour and couldn’t see a mistake. :sob: Guess I’ll have to try another approach. @Aetherus gave me an idea not to store dots. I’m missing a step in my approach where I don’t look for simplification before I begin. :bulb:

Also and again: I hate when the example passes but input doesn’t. I feel AoC could be better if they provided more examples with all possible edge cases, so that if you’re able to pass them you practically know it will work for real input. They could provide another file with just examples and solutions. Because debugging on real input is tedious to say the least.

I think he wants you to find the edge cases by yourself ! Otherwise it would be just an optimization game…

Do you want to explain your approach so we can spot something missing ?

1 Like

I have struggled so hard with this part2 that i have actually decided to stop doing new days until i have done this.

Several learnings.

  1. i have been stupid. And the yes master yes master AI is actually helpful when you provide input and output and tell it that the output is this and should be this.
  2. i should have used more debugging and stepping in vs code and shouldnt have used AI.
  3. i should stop use AI.

Oh did i forget to say i should stop using AI?

touché
The thing is I “observed” the robot… must have missed another misbehaviour. I’m just complaining, this one is actually debuggable.
I’ll give it another go tomorrow, then if I give up, I’ll complain further. :call_me_hand:

1 Like

after stepping 2-300 moves through the mace (i skipped obstacle collisions and whitespace) i found this

####################
##[]..[]......[][]##
##[]...........[].##
##...........@[][]##
##..........[].[].##
##..##[]..[].[]...##
##...[]...[]..[]..##
##.....[]..[].[][]##
##........[]......##
####################

it can’t push four levels of boxes down :slight_smile:

New test case … its in cases i like this i just «of course»

Finally solved it today. Simplifying the problem (not storing robot or empty spaces) definitely helped in figuring out the recursive part for part 2. Made it easier to reason, not worrying about unnecessary stuff and also made it less bug prone. I got to stop going head first. :cowboy_hat_face:

defmodule Aoc2024.Solutions.Y24.Day15 do
  alias AoC.Input

  @start if Mix.env() == :test, do: {4, 4}, else: {24, 24}
  @start_2 if Mix.env() == :test, do: {4, 8}, else: {24, 48}

  def parse(input, part) do
    [map, input] = input |> Input.read!() |> String.split("\n\n")
    {parse_map(map, part), parse_input(input)}
  end

  def part_one({map, input}) do
    input
    |> Enum.reduce({@start, map}, &move_robot(&2, &1))
    |> elem(1)
    |> Map.to_list()
    |> Enum.filter(fn {{_x, _y}, e} -> e == "O" end)
    |> Enum.reduce(0, fn {{x, y}, _}, acc ->
      acc + x * 100 + y
    end)
  end

  def part_two({map, input}) do
    input
    |> Enum.reduce({@start_2, map}, &move_robot(&2, &1))
    |> elem(1)
    |> Map.to_list()
    |> Enum.filter(fn {{_x, _y}, e} -> e == "[" end)
    |> Enum.reduce(0, fn {{x, y}, _}, acc ->
      acc + x * 100 + y
    end)
  end

  defp next_position({x, y}, symbol) do
    case symbol do
      ">" -> {x, y + 1}
      "<" -> {x, y - 1}
      "v" -> {x + 1, y}
      "^" -> {x - 1, y}
    end
  end

  defp move_robot({position, map}, symbol) do
    case check_boxes(position, symbol, map, []) do
      {:error, :unmovable} -> {position, map}
      {:ok, boxes} -> {next_position(position, symbol), update_map(map, boxes, symbol)}
    end
  end

  defp check_boxes(position, symbol, map, acc) do
    {x, y} = next_position = next_position(position, symbol)

    case Map.get(map, next_position) do
      nil -> {:ok, acc}
      "O" -> check_boxes(next_position, symbol, map, [next_position | acc])
      "[" -> check_pboxes({x, y}, {x, y + 1}, symbol, map, acc)
      "]" -> check_pboxes({x, y}, {x, y - 1}, symbol, map, acc)
      "#" -> {:error, :unmovable}
    end
  end

  defp check_pboxes(position, pposition, symbol, map, acc) when symbol in ["^", "v"] do
    with {:ok, acc_right} <- check_boxes(position, symbol, map, [position | acc]),
         {:ok, acc} <- check_boxes(pposition, symbol, map, [pposition | acc_right]) do
      {:ok, Enum.uniq(acc)}
    end
  end

  defp check_pboxes(position, _pposition, symbol, map, acc) do
    check_boxes(position, symbol, map, [position | acc])
  end

  defp update_map(map, boxes, symbol) do
    Enum.reduce(boxes, map, fn box_position, map ->
      {box, map} = Map.pop!(map, box_position)
      next_position = next_position(box_position, symbol)
      Map.put(map, next_position, box)
    end)
  end

  defp parse_input(input) do
    input
    |> String.split("\n")
    |> Enum.reduce([], fn line, acc ->
      Enum.reduce(String.codepoints(line), acc, fn e, acc -> [e | acc] end)
    end)
    |> Enum.reverse()
  end

  defp parse_map(map, part) do
    map
    |> String.split("\n")
    |> Stream.with_index()
    |> Enum.reduce(%{}, fn {line, x}, acc ->
      line
      |> String.codepoints()
      |> maybe_expand(part)
      |> Stream.with_index()
      |> Enum.reduce(acc, fn
        {e, _y}, acc when e in [".", "@"] -> acc
        {e, y}, acc -> Map.put(acc, {x, y}, e)
      end)
    end)
  end

  defp maybe_expand(list, :part_one), do: list

  defp maybe_expand(list, :part_two) do
    list
    |> Enum.map(fn
      "#" -> ["#", "#"]
      "O" -> ["[", "]"]
      "." -> [".", "."]
      "@" -> ["@", "."]
    end)
    |> List.flatten()
  end
end

me too! finally! it feels so good . .and ill never touch that code again … until next year …

my solution is a nested mess of recursive functions … but it works :slight_smile: no mutability … uni directional data flow <3

in my solution i simulate/test movement before i move . so its easy to debug

1 Like

haha … seriously … day 16 … i think i need a days break before i do the same again …

1 Like