Advent of Code Day20: Race Condition

Took a while to get this done today. I started late and my solutions for both parts took around 20 seconds each. Good enough to get my answers, then I refactored it to get a better solution that ran both parts in less than 1s

We have another maze (1)https://adventofcode.com/2024/day/20 problem, with the ability of cutting through walls to give shorter paths.

Part 1 #

Using GameplayKit which I seem to have taken to as my grid walking method of choice Unfortunately, this was not to last past today. I created a grid. First I went through the nodes and found those that were walls. And also those that could be candidates for tunnelling through. I picked those as the ones that have three neighbours that are path points.

I then remove the nodes that are walls, which leaves me with a graph created by GameplayKit. And I use it’s methods to generate the path (2)We are helpfully told that there is only one path. from start to end.

  func bruteForce(from rows: [[Character]], target: Int) -> Int {
    let graph = GKGridGraph(
      fromGridStartingAt: vector_int2(0, 0),
      width: Int32(rows[0].count),
      height: Int32(rows.count),
      diagonalsAllowed: false,
      nodeClass: Node.self
    )

    var walls: [Node] = []
    var candidates: [[Node]] = []
    var start: Node = graph.nodes!.first! as! Node
    var end: Node = graph.nodes!.first! as! Node
    for node in graph.nodes! {
      let node = node as! Node
      let position = node.gridPosition
      let value = rows[Int(position.y)][Int(position.x)]
      switch value {
      case "#":
        walls.append(node)
      case "S":
        start = node
        continue
      case "E":
        end = node
        continue
      default:
        continue
      }
      let neighbours = (node.connectedNodes as! [Node]).filter { n in
        rows[Int(n.gridPosition.y)][Int(n.gridPosition.x)] != "#"
      }
      if neighbours.count > 1 {
        candidates.append(neighbours)
      }
    }

    graph.remove(walls)
    let path = graph.findPath(from: start, to: end) as! [Node]

    let shortened = candidates.filter { candidates in
      guard let s = candidates.first,
            let e = candidates.last,
            let sIndex = path.firstIndex(of: s),
            let eIndex = path.firstIndex(of: e)
      else { return false }
      return abs(eIndex - sIndex) >= target + 2
    }

    return shortened.count
  }

I then go through the list of nodes that are separated by walls and see if the first and last ones are the requisite distance apart. Yes, I can see that there is a bug in that, I put up to three Nodes in each element, but I only check two I am fortunate that I got the right answer.

This ran, but it took around 20s for part 1. Not too bothered, because I like to get the first answer quickly and tidy it up during the second part.

Part 2 #

Now having more time to think about it, I came up with a better way of defining the solution. The parts I am showing use my own Grid class from my utilities package (3)https://github.com/Abizern/AoCCommon which is just a wrapper around rows of Characters. I first used the same algorithm with GameplayKit, but it took over 20s for each part which I didn’t think was acceptable, and writing out my path search directly gave me much better performance.

First I created a found the path from the start to the end point and represented that in a dictionary. The keys of this dictionary are each Cell along the path, and the value is the distance from the start along the path.

  func track(_ rows: [[Character]]) -> [Cell: Int] {
    let grid = Grid(rows: rows)

    let start = grid.firstCell(for: "S")!
    let end = grid.firstCell(for: "E")!

    var path: [Cell] = []
    var queue: Deque<[Cell]> = [[start]]
    var seen: Set<Cell> = []

    while !queue.isEmpty && path.isEmpty {
      let currentPath = queue.removeFirst()
      let head = currentPath.last!
      if head == end {
        path = currentPath
        continue
      }
      if seen.contains(head) {
        continue
      }
      else {
        seen.insert(head)
      }
      let neighbours = grid.neighbours(head, includeDiagonals: false)
      for neighbour in neighbours {
        guard grid.element(neighbour) != "#",
              !seen.contains(neighbour)
        else {
          continue
        }
        queue.append(currentPath + [neighbour])
      }
    }

    var dict = [Cell: Int]()
    for (distance, position) in path.enumerated() {
      dict[position] = distance
    }

    return dict
  }

Then I created a method to return all Cells that are a given distance from a Cell, along with the number of steps it takes to get there. For Part 1 this will be 2, for Part 2 this will be 20.

  func targets(from: Cell, distance: Int) -> [(Cell, Int)] {
    var cells: [(Cell, Int)] = []
    for r in -distance ... distance {
      for c in -distance ... distance {
        guard abs(r) + abs(c) <= distance
        else {
          continue
        }
        cells.append((Cell(from.row + r, from.col + c), abs(r) + abs(c)))
      }
    }
    return cells
  }

Now the actual solution method:

  func countCheats(_ track: [Cell: Int], radius: Int, minReduction reduction: Int) -> Int {
    let path = track.sorted { $0.value < $1.value }
    return path.map { cell, distance in
      var candidates: Set<Cell> = []
      let targets = targets(from: cell, distance: radius)
      for (c, d) in targets {
        guard let dd = track[c],
              !candidates.contains(c),
              dd >= distance + d + reduction
        else {
          continue
        }
        candidates.insert(c)
      }
      return candidates.count

    }.reduce(0, +)
  }

I sort the keys by the path length, so I’m going forwards along the points that make up the path.

  let path = track.sorted { $0.value < $1.value }

I map this array of cells and distances. So that I am examining each point along the route

I create a set to hold possible cheat points. I use a set because cheats are identified by their start and end point, not the route they took.

I then generate the candidates, all the cells within the radius that I can tunnel through to.

  guard let dd = track[c],
        !candidates.contains(c),
        dd >= distance + d + reduction
  else {
    continue
  }

I get the distance of the current point from the start and compare it to the distance from the start of any point than I can tunnel through to. If the difference in length is greater than the target of 100, accounting for the time it takes to tunnel through to that point, then I add it as a possible cheat point.

I then count the number of cheat point for each point, and sum them to get the answer. The same function works for both parts.

  func part1() async throws -> Int {
    countCheats(track, radius: 2, minReduction: 100)
  }

  func part2() async throws -> Int {
    countCheats(track, radius: 20, minReduction: 100)
  }

Final Thoughts #

For context, the full code is on Github (4)https://github.com/Abizern/aoc-swift-2024/blob/main/Sources/Day20.swift .

I’m a little sad I couldn’t get the best out of GameplayKit. I assume that it’s able to do far more things than these simple puzzles require it to do, so the overhead swamps any performance I can squeeze out by writing very specific code.

I’m also surprised that I’ve managed to get to day 20 only missing 3 stars.

>