Advent of Code 2024

thoughts and solutions

 

Day 20

file:test/Day20Spec.jl
using AOC2024.Day20: read_input, Pos, walk_maze, find_cheats, trace_back, part2

let test_txt = """
    ###############
    #...#...#.....#
    #.#.#.#.#.###.#
    #S#...#.#.#...#
    #######.#.#.###
    #######.#.#...#
    #######.#.###.#
    ###..E#...#...#
    ###.#######.###
    #...###...#...#
    #.#####.#.###.#
    #.#...#.#.#...#
    #.#.#.#.#.#.###
    #...#...#...###
    ###############"""

    maze = read_input(IOBuffer(test_txt))
    @test maze.start_tile == Pos(2, 4)
    hist = part2(maze)
    hist_arr = sort(filter(((k, v),) -> k >= 50, hist)) |> stack
    target = [50  52  54  56  58  60  62  64  66  68  70  72  74  76;
              32  31  29  39  25  23  20  19  12  14  12  22   4   3]
    @test hist_arr == target
end
file:src/Day20.jl
module Day20

using DataStructures

const Pos = CartesianIndex{2}
const PosDir = CartesianIndex{3}

pos(pd::PosDir) = Pos(pd[1], pd[2])
dir(pd::PosDir) = pd[3]
posdir(p::Pos, d::Int) = CartesianIndex(p[1], p[2], d)

struct Maze
    map::Matrix{Char}
    start_tile::Pos
    end_tile::Pos
end

function read_input(io::IO)
    map = io |> readlines |> stack
    start_tile = findfirst(isequal('S'), map)
    end_tile = findfirst(isequal('E'), map)
    map[start_tile] = '.'
    map[end_tile] = '.'
    return Maze(map, start_tile, end_tile)
end

function grid_dijkstra(
    ::Type{T}, size::NTuple{Dim,Int},
    start::Vector{CartesianIndex{Dim}}, istarget::F1,
    neighbours::F2, dist_func::F3) where {T,Dim,F1,F2,F3}

    visited = falses(size...)
    distance = fill(typemax(T), size)
    queue = PriorityQueue{CartesianIndex{Dim},T}()
    prev = Array{CartesianIndex{Dim},Dim}(undef, size)

    for s in start
        distance[s] = zero(T)
        enqueue!(queue, s, zero(T))
    end
    current = nothing
    while !isempty(queue)
        current = dequeue!(queue)
        istarget(current) && return (distance=distance, route=prev, target=current)
        visited[current] && continue
        for loc in neighbours(current)
            visited[loc] && continue
            d = distance[current] + dist_func(current, loc)
            if d < distance[loc]
                distance[loc] = d
                prev[loc] = current
                queue[loc] = d
            end
        end
        visited[current] = true
    end
    return nothing
end

const PosDist = Tuple{Pos,Int}

function find_cheats(m::Maze, start::Pos)
    visited = falses(size(m.map))
    queue = Queue{PosDist}()
    enqueue!(queue, (start, 0))
    result = PosDist[]

    while !isempty(queue)
        p, d = dequeue!(queue)
        d += 1
        d > 20 && return result

        for nb in NB
            q = p + nb
            checkbounds(Bool, m.map, q) || continue
            visited[q] && continue
            visited[q] = true
            if m.map[q] == '.'
                push!(result, (q, d))
            end
            # q == m.end_tile && continue
            enqueue!(queue, (q, d))
        end
    end
    return result
end

function trace_back(prev, start, target)
  route = [target]
  current = target
  while current != start
    current = prev[current]
    push!(route, current)
  end
  reverse(route)
end

const NB = [Pos(1, 0), Pos(0, 1), Pos(-1, 0), Pos(0, -1)]

function walk_maze(m::Maze)
    start = [m.start_tile]
    is_target(p::Pos) = p == m.end_tile
    neighbours(p::Pos) = filter(q->m.map[q] != '#', p .+ NB)
    dist_func(p::Pos, q::Pos) = 1
    grid_dijkstra(Int, size(m.map), start, is_target, neighbours, dist_func)
end

function part2(maze::Maze)
    g = walk_maze(maze)
    route = trace_back(g.route, maze.start_tile, g.target)
    hist = Dict{Int, Int}()

    for pos in route
        cheats = find_cheats(maze, pos)
        for (q, d) in cheats
            saved = g.distance[q] - g.distance[pos] - d
            hist[saved] = get(hist, saved, 0) + 1
        end
    end
    return hist
end

function main(io::IO)
    maze = read_input(io)
    g = walk_maze(maze)
    route = trace_back(g.route, maze.start_tile, g.target)
    part1 = 0
    for pos in route
        for nb in NB
            checkbounds(Bool, maze.map, pos+2nb) || continue
            maze.map[pos+nb] == '#' || continue
            maze.map[pos+2nb] == '.' || continue
            g.distance[pos+2nb] - g.distance[pos] >= 102 || continue
            part1 += 1
        end
    end
    part2 = 0
    for pos in route
        cheats = find_cheats(maze, pos)
        for (q, d) in cheats
            g.distance[q] - g.distance[pos] - d >= 100 || continue
            part2 += 1
        end
    end

    return part1, part2
end

end