Advent of Code 2023

Yes, with Day 10 it’s getting more interesting.

I found it quite tedious determining the pipe type for the starting position. I don’t think there’s anything in the rules that say you can’t look at the file and determine what you need to get your program started. The brain can see it right away, whereas coding an algorithm is a pain. I thought about how to organize the data for a while and decided on a data array for each tile location. It primarily contained a lookup table where indices 0-3 represented inputs from N,S,E,W and the contents of the table were the output directions. So for ‘J’ the array would be [3,-,-,1]. I also included the pipe character and a flag to indicate whether the tile was part of the pipe loop. That came in handy for part 2 - no stray pipe parts. The program stepped along the pipe in both directions from the start untill they met, being careful to handle an even or odd number of tiles in the loop

For Part 2, I took my map from part one and scanned it row by row using a state machine to keep track of where I was in the loop: inside, outside or somewhere in between.

Day 11 is back to something potentially tricky (you should probably be able to guess the twist that part 2 will be) but also pretty straight forward. I used a trick I’ve learned from someone else (in the TWiT Discord) where you don’t make any actual array, you just use a hashmap to simulate it. That made doing the “expansion” pretty straight forward. The only mistake I made was in part 2 where I missed you needed to subtract one from the expansion constant.

I created a Universe class to keep track of the galaxies and it used a Galaxy record and a GalacticCoordinates record.

final class Universe
{
  public record Galaxy(int galaxyNumber) {};
  private record GalacticCoordinates(long x, long y) {};
  
  private final long height;
  private final long width;

  private final Map<Galaxy, GalacticCoordinates> galaxyLocations;
  private final int[] numGalaxyInRow;
  private final int[] numGalaxyInCol;
  
  public Universe(final long width, final long height, final Map<Galaxy, GalacticCoordinates> galaxyLocations)
  {
    this.height = height;
    this.width = width;
    this.galaxyLocations = galaxyLocations;

    // Since we don't use these after expansion, going to try to not address the issue.
    numGalaxyInRow = new int[0];
    numGalaxyInCol = new int[0];

    /*
    numGalaxyInRow = new int[height];
    numGalaxyInCol = new int[width];
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        if (galaxyLocations.containsValue(new GalacticCoordinates(x, y)))
        {
          numGalaxyInRow[y]++;
          numGalaxyInCol[x]++;
        }
      }
    }
    */
  }

  public Universe(final String[] day11InputLines)
  {
    galaxyLocations = new HashMap<>();
    
    height = day11InputLines.length;
    width  = day11InputLines[0].length();
    
    numGalaxyInRow = new int[(int) height];
    numGalaxyInCol = new int[(int) width];
    
    int galaxyNumber = 0;
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        final char c = day11InputLines[y].charAt(x);
        if (c == '#')
        {
          numGalaxyInRow[y]++;
          numGalaxyInCol[x]++;
          galaxyLocations.put(new Galaxy(galaxyNumber++), new GalacticCoordinates(x, y));
        }
      }
    }
  }

  public Universe expand(final long expansionConstant)
  {
    final Map<Galaxy, GalacticCoordinates> newGalaxyLocations = new HashMap<>();
    int galaxyNumber = 0;
    long yOffset = 0;
    long xOffset = 0;
    for (int y=0; y<height; y++)
    {
      if (numGalaxyInRow[y]==0)
      {
        yOffset += expansionConstant-1;
        continue;
      }
      xOffset = 0;
      for (int x=0; x<width; x++)
      {
        if (numGalaxyInCol[x]==0)
        {
          xOffset += expansionConstant-1;
        }
        else
        {
          if (galaxyLocations.containsValue(new GalacticCoordinates(x, y)))
          {
            newGalaxyLocations.put(new Galaxy(galaxyNumber++), new GalacticCoordinates(x+xOffset, y+yOffset));
          }
        }
      }
    }
    return new Universe(width+xOffset, height+yOffset, newGalaxyLocations);
  }

  public Galaxy[] getAllGalaxies()
  {
    final Galaxy[] galaxies = galaxyLocations.keySet().toArray(new Galaxy[0]);
    return galaxies;
  }

  public long getDistanceBetweenGalaxies(final Galaxy galaxy1, final Galaxy galaxy2)
  {
    final GalacticCoordinates g1 = galaxyLocations.get(galaxy1);
    final GalacticCoordinates g2 = galaxyLocations.get(galaxy2);

    final long xDistance = Math.abs(g1.x - g2.x);
    final long yDistance = Math.abs(g1.y - g2.y);
    return xDistance + yDistance;
  }
}

Then part 1 and part 2 are virtually identical, except the expansion constant is passed in for part 2 so I could use the test cases of 10 an 100 provided by the problem examples.

  public static Long part1(final String[] day11InputLines)
  {
    return expandUniverseAndSumDistancesBetweenEachGalaxy(day11InputLines, 2);
  }

  private static Long expandUniverseAndSumDistancesBetweenEachGalaxy(final String[] day11InputLines, final long expansionAmount)
  {
    final Universe u1 = new Universe(day11InputLines);
    final Universe u2 = u1.expand(expansionAmount);
    long sum = 0;
    final Galaxy[] galaxies = u2.getAllGalaxies();
    for (int i=0; i<galaxies.length-1; i++)
    {
      for (int j=i+1; j<galaxies.length; j++)
      {
        sum += u2.getDistanceBetweenGalaxies(galaxies[i], galaxies[j]);
      }
    }
    return sum;
  }

  public static Long part2(final String[] day11InputLines, final long expansionConstant)
  {
    return expandUniverseAndSumDistancesBetweenEachGalaxy(day11InputLines, expansionConstant);
  }

Day 11 was a satisfying puzzle. When I looked at how sparse the galaxies were, I decided to represent them with an array of coordinates. I was pleasantly surprised to see that was ideal for part 2. After locating the blank rows and columns, I made arrays of the increments to be applied to each row and column. When I first read “shortest path”, I was expecting something more complicated than the AoC favourite Manhattan distance. For part 2, I was mystified why my solution was too high. Without thinking I had also entered 1 million instead of one less.

Day 12 is a problem that you can initially brute force, but you will not be able to brute force part 2. My answer for part 2 was on the order of 25 trillion, so there very few iterative (or recursive) approaches that can run that many times without some for of caching or short circuiting. I will admit that I’m not the greatest fan of recursion, and was unable to find a creative way to use caching with my part 1 brute force recursive solution. In the end I had to learn from other solutions about the approach they took, called Dynamic Programming, to solve part 2.

For part 1, I chose to take the most obvious approach, just produce all possible versions of the springs map, recursive replacing every ‘?’ with a ‘.’ and then a ‘#’. Then when you have a completed string with no more question marks, see if it is a valid solution against the numbers. This worked well, but was a little slow.

I created a class named SpringArrangmentCounter to wrap this approach:

final class SpringArrangmentCounter
{
  //input format: ???.### 1,1,3
  final Pattern SPRING_ARRANGEMENTS_PATTERN = Pattern.compile("([?.#]+) +([1234567890,]+)"); 
  public long getNumberOfPossibleArrangements(final String inputLine)
  {
    final Matcher m = SPRING_ARRANGEMENTS_PATTERN.matcher(inputLine);
    if (!m.matches())  throw new IllegalStateException("Couldn't parse: " + inputLine);
    
    final String springs = m.group(1);
    final String numbersString = m.group(2);
    final String[] numbersStrings = numbersString.split(",");
    final int[] numbers = new int[numbersStrings.length];
    for (int i=0; i<numbers.length; i++)  numbers[i] = Integer.parseInt(numbersStrings[i]);
    
    return numberOfArrangements(0, springs, numbers, 0);
  }

  private long numberOfArrangements(final int currentPos, final String springs, final int[] numbers, final long numFoundSoFar)
  {
    if (currentPos >= springs.length())
    {
      if (numbersMatch(springs, numbers)) return numFoundSoFar+1;
      return numFoundSoFar;
    }
    final char c = springs.charAt(currentPos);
    if (c != '?') return numberOfArrangements(currentPos+1, springs, numbers, numFoundSoFar);
    final long numFoundSoFar1 = numberOfArrangements(currentPos+1, springs.substring(0, currentPos) + "." + springs.substring(currentPos+1), numbers, numFoundSoFar);
    final long numFoundSoFar2 = numberOfArrangements(currentPos+1, springs.substring(0, currentPos) + "#" + springs.substring(currentPos+1), numbers, numFoundSoFar1);
    return numFoundSoFar2;
  }

  private boolean numbersMatch(final String springs, final int[] numbers)
  {
    int curNum=-1;
    boolean counting=false;
    int count=0;
    for (int i=0; i<springs.length(); i++)
    {
      if (springs.charAt(i) == '.')
      {
        if (counting)
        {
          counting = false;
          if (curNum >= numbers.length) return false;
          if (count != numbers[curNum]) return false;
        }
      }
      else
      {
        if (!counting)
        {
          curNum++;
          counting = true;
          count = 1;
        }
        else
        {
          count++;
        }
      }
    }
    if (curNum != numbers.length-1) return false;
    if (counting)
    {
      if (count != numbers[curNum]) return false;
    }
    return true;
  }

And the part 1 driver looks like:

  public static Long part1(final String[] day12InputLines)
  {
    final SpringArrangmentCounter sac = new SpringArrangmentCounter();
    long sum = 0;
    for (final String inputLine : day12InputLines)
    {
      sum += sac.getNumberOfPossibleArrangements(inputLine);
    }
    return sum;
  }

I made the obvious changes to all of this for part 2, but it became clear that it was going to run forever, still, for completeness, here’s what I wrote:

// Added this to the SpringArrangmentCounter class

  public long getNumberOfPossibleArrangements2(final String inputLine)
  {
    final Matcher m = SPRING_ARRANGEMENTS_PATTERN.matcher(inputLine);
    if (!m.matches())  throw new IllegalStateException("Couldn't parse: " + inputLine);
    
    final String springs = m.group(1);
    final String numbersString = m.group(2);

    final String newLine = springs + "?" + springs + "?" +springs + "?" +springs + "?" +springs + " " +
        numbersString + "," + numbersString + "," + numbersString + "," + numbersString + "," + numbersString;

    return getNumberOfPossibleArrangements(newLine);
  }

// Used this as part 2 method

  public static Long part2(final String[] day12InputLines)
  {
    final SpringArrangmentCounter sac = new SpringArrangmentCounter();
    long sum = 0;
    for (final String inputLine : day12InputLines)
    {
      sum += sac.getNumberOfPossibleArrangements2(inputLine);
   }
    return sum;
  }

When this didn’t work out… I started trying to learn from other solutions. I left all my code in place, but changed what getNumberOfPossibleArrangements() did and it relied on calling a method in a new class which I named SpringDynamicProgramming:

final class SpringDynamicProgramming
{
  public long dynprog(final String springs, final int[] numbers)
  {
    final int springsLen = springs.length();
    final int numbersLen = numbers.length;

    // Append springsLen+1 to the end of the numbers
    final int[] newNumbers = new int[numbersLen+1];
    for (int i=0; i<numbersLen; i++) newNumbers[i]=numbers[i];
    newNumbers[numbersLen]=springsLen+1;

    final long[][][] accumulatedSum = new long[springsLen+1][numbersLen+2][springsLen+2];
    accumulatedSum[0][0][0] = 1;

    for (int springsIndex = 0; springsIndex<springsLen; springsIndex++)
    {
      for (int numbersIndex = 0; numbersIndex<numbersLen+1; numbersIndex++)
      {
        for (int springsSecondPassIndex = 0; springsSecondPassIndex<springsLen+1; springsSecondPassIndex++)
        {
          long curSum = accumulatedSum[springsIndex][numbersIndex][springsSecondPassIndex];
          if (curSum == 0) continue;
          final char springChar = springs.charAt(springsIndex); 
          if (springChar == '.' || springChar == '?')
          {
            final int numIdx = (numbersIndex==0)?newNumbers.length-1:numbersIndex-1;  //wrap around on -1
            if (springsSecondPassIndex == 0 || springsSecondPassIndex == newNumbers[numIdx])
            {
              accumulatedSum[springsIndex + 1][numbersIndex][0] += curSum;
            }
          }
          if (springChar == '#' || springChar == '?')
          {
            final int x = springsSecondPassIndex==0?1:0;
            accumulatedSum[springsIndex + 1][numbersIndex + x][springsSecondPassIndex + 1] += curSum;
          }
        }
      }
    }
    return accumulatedSum[springsLen][numbersLen][0];
  }
}

And I changed SpringDynamicProgramming () to call this like so:

// at the top I added a new member to reference the SpringDynamicProgramming  class

  final SpringDynamicProgramming sdp = new SpringDynamicProgramming();

// at the bottom of SpringDynamicProgramming () I commented out the current return and added a new one

    // return numberOfArrangements(0, springs, numbers, 0);
    return sdp.dynprog(springs+".", numbers);

this meant the new approach was now providing the answers for part 1, which didn’t change, as well as for part 2.

Day 13 was tricky for me for part 2. It seems to me I fell into a number of traps, including not clearly reading the instructions where it indicated to look past the part 1 answer. I also somehow had a couple of odd typos (where I used X instead Y, for example) that somehow didn’t break part 1 but really messed up part 2.

To manage the solution finding I created a class called ReflectionDetector. I used the technique of representing the preset # chars in a hashmap. This trick allows you to not worry about edge conditions on a map, because you’re using a record to represent the coordinates, so any valid coordinate record is a valid contains()check on a hashmap.

Because of the problems I had with part 2, I did end up cutting a corner for speed. It’s related to a potentially bad decision I made initially. I thought maybe it might be possible to have simultaneous horizontal and vertical mirrors because he never said it was impossible and I figured that might be something he’d use to trip us up. Turns out that was a waste of time, but it did have an impact on my design, and I “reused” the coordinate record to be able to return both an x and a y result from a method. I probably should have taken the time to make a different record structure for this for clarity in the code, but I was still trying to be a little speedy so I did not. I regretted this later as I battled bugs, but I STILL resisted simply fixing that… until I squished my bugs, and then it wasn’t worth messing with a working result.

I’m include the parts for part 2in the code as well, as I think all the really tricky bits (say, for example, using a bitfield to compare lines easily) are present in part 1. Part 2 just had me copy a few functions and change them to take an extra parameter to detect when to keep going because you need to find another mirror, not the first one.

final class ReflectionDetector
{
  private record Coordinates(int x, int y) {};
  private final Set<Coordinates> occupiedLocations = new HashSet<>();
  
  private int maxY;
  private int maxX;
  
  public void addLine(final String lineToAdd)
  {
    if (lineToAdd.length() > maxX)  maxX=lineToAdd.length();
    for (int x=0; x<lineToAdd.length(); x++)
    {
      if (lineToAdd.charAt(x) == '#') occupiedLocations.add(new Coordinates(x, maxY));
    }
    maxY++;
  }

  public Coordinates getReflectionCoordinates()
  {
    final int x = findHorizonalReflection();
    final int y = findVerticalReflection();
    return new Coordinates(x, y);
  }

  public Coordinates getReflectionCoordinates(final Coordinates c)  // Part 2
  {
    final int x = findHorizonalReflection(c);
    final int y = findVerticalReflection(c);
    return new Coordinates(x, y);
  }

  private long coordinatesToValue(final Coordinates c)
  {
    long result = 0;
    if (c.x>0) result +=c.x;
    if (c.y>0) result +=100*c.y;
    return result;
  }

  public long getReflectionValue()
  {
    final Coordinates c = getReflectionCoordinates();
    return coordinatesToValue(c);
  }

  private int findHorizonalReflection()
  {
    for (int xSplit=0; xSplit<maxX-1; xSplit++)
    {
      boolean isMirrorPoint = true;
      for (int x1=xSplit, x2=xSplit+1; x2<maxX; x1--, x2++)
      {
        if (x1<0)  break;
        if (verticalValue(x1)!=verticalValue(x2))
        {
          isMirrorPoint=false;
          break;
        }
      }
      if (isMirrorPoint) return xSplit+1;
    }
    return -1;
  }

  private int findHorizonalReflection(final Coordinates c)  // Part 2
  {
    for (int xSplit=0; xSplit<maxX-1; xSplit++)
    {
      boolean isMirrorPoint = true;
      for (int x1=xSplit, x2=xSplit+1; x2<maxX; x1--, x2++)
      {
        if (x1<0)  break;
        if (verticalValue(x1)!=verticalValue(x2))
        {
          isMirrorPoint=false;
          break;
        }
      }
      if (isMirrorPoint)
      {
        if (c.x != xSplit+1)  return xSplit+1;
      }
    }
    return -1;
  }

  private int findVerticalReflection()
  {
    for (int ySplit=0; ySplit<maxY-1; ySplit++)
    {
      boolean isMirrorPoint = true;
      for (int y1=ySplit, y2=ySplit+1; y2<maxY; y1--, y2++)
      {
        if (y1<0)  break;
        if (horizontalValue(y1)!=horizontalValue(y2))
        {
          isMirrorPoint=false;
          break;
        }
      }
      if (isMirrorPoint) return ySplit+1;
    }
    return -1;
  }

  private int findVerticalReflection(final Coordinates c)  // Part 2
  {
    for (int ySplit=0; ySplit<maxY-1; ySplit++)
    {
      boolean isMirrorPoint = true;
      for (int y1=ySplit, y2=ySplit+1; y2<maxY; y1--, y2++)
      {
        if (y1<0)  break;
        if (horizontalValue(y1)!=horizontalValue(y2))
        {
          isMirrorPoint=false;
          break;
        }
      }
      if (isMirrorPoint)
      {
        if (c.y!=ySplit+1)  return ySplit+1;
      }
    }
    return -1;
  }

  private long verticalValue(final int xVal)
  {
    long result = 0;
    int v=1;
    for(int y=0; y<maxY; y++)
    {
      if (occupiedLocations.contains(new Coordinates(xVal, y)))  result += v;
      v<<=1;
    }
    return result;
  }

  private long horizontalValue(final int yVal)
  {
    long result = 0;
    int v=1;
    for(int x=0; x<maxX; x++)
    {
      if (occupiedLocations.contains(new Coordinates(x, yVal)))  result += v;
      v<<=1;
    }
    return result;
  }

  public long getSmudgeReflectionValue()  // Part 2
  {
    final Coordinates orig = getReflectionCoordinates();
    for (int y=0; y<maxY; y++)
    {
      for (int x=0; x<maxX; x++)
      {
        final Coordinates c = new Coordinates(x, y);
        if (occupiedLocations.contains(c))
        {
          occupiedLocations.remove(c);
          final Coordinates newC = getReflectionCoordinates(orig);
          occupiedLocations.add(c);
          if (newC.x > 0)
          {
            if (orig.x != newC.x)
            {
              return newC.x;
            }
          }
          if (newC.y > 0)
          {
            if (orig.y != newC.y)
            {
              return newC.y*100;
            }
          }
        }
        else
        {
          occupiedLocations.add(c);
          final Coordinates newC = getReflectionCoordinates(orig);
          occupiedLocations.remove(c);
          if (newC.x > 0)
          {
            if (orig.x != newC.x)
            {
              return newC.x;
            }
          }
          if (newC.y > 0)
          {
            if (orig.y != newC.y)
            {
              return newC.y*100;
            }
          }
        }
      }
    }
    throw new IllegalStateException("Couldn't find a new relection in getSmudgeReflectionValue()");
  }
}

The part 1 and part 2 solvers are basically identical as well. The only trick is that I added a terminating indicator to the input ("END"), which is mostly related to a quirk in Java where converting strings to arrays ignores the terminating blank line(s.) I should have refactored part 1 to save copying so much code for part 2, but in the end I took the speedier route because I was initially unsure if the same approach would work in part 2 but wanted to pursue it.

  public static Long part1(final String[] day13InputLines)
  {
    boolean done = false;
    int currentLine = 0;
    long sum = 0;
    while(!done)
    {
      final ReflectionDetector rd = new ReflectionDetector();
      boolean endOfSection = false;
      while (!endOfSection)
      {
        if (day13InputLines[currentLine].startsWith("END"))
        {
          endOfSection = true;
          done = true;
        }
        else if (day13InputLines[currentLine].isEmpty())
        {
          endOfSection = true;
        }
        else
        {
          rd.addLine(day13InputLines[currentLine]);
        }
        currentLine++;
      }
      sum += rd.getReflectionValue();
    }
    return sum;
  }

  public static Long part2(final String[] day13InputLines)
  {
    boolean done = false;
    int currentLine = 0;
    long sum = 0;
    while(!done)
    {
      final ReflectionDetector rd = new ReflectionDetector();
      boolean endOfSection = false;
      while (!endOfSection)
      {
        if (day13InputLines[currentLine].startsWith("END"))
        {
          endOfSection = true;
          done = true;
        }
        else if (day13InputLines[currentLine].isEmpty())
        {
          endOfSection = true;
        }
        else
        {
          rd.addLine(day13InputLines[currentLine]);
        }
        currentLine++;
      }
      final long result = rd.getSmudgeReflectionValue();
      sum += result;
    }
    return sum;
  }
}

Day 14 is pretty much Day 17 of 2022 ( Advent of Code 2022 - #75 by PHolder ) all over again. Basically you’ve got to simulate some activity (rolling rocks this year, letting tetris pieces fall last year) and report on some configuration value in part 1 and then in part 2 you’re going to be asked to simulate running a whole lot of activity. You will not be able to brute force part 2, so you need to consider if there is some other way to short circuit the problem.

The trick for part 2 is that the pattern will run for a while until it hits a configuration that is a start of a cycle. You then need to figure out how big the cycle is, and then you will be able to calculate how many full cycle repeats fit into the desired number of runs, and what the remainder will be. Then you basically tell your simulation to run the initial number until start of the cycle, and then run the remainder and you will be in the same state as having run the really big number of iterations. For example, say your cycle starts at move number 76 and the cycle is 53 moves long and your desired number of moves is 500_000. You would do (500_000-76)/53=9432 (integer division). Then you would find the remainder by doing 500_000-76-9432*53=28. So you would tell your simulation to run 76+28 times and find your result.

I created a helper class which I creatively called RockAndRoll:

final class RockAndRoll
{
  private record Coordinates(int x, int y) {}
  
  private final Set<Coordinates> rollingRockPositions = new HashSet<>();
  private final Set<Coordinates> stationaryRockPositions = new HashSet<>();
  
  private final int height;
  private final int width;
  
  public RockAndRoll(final String[] day14InputLines)
  {
    height = day14InputLines.length;
    width = day14InputLines[0].length();
    
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        final char c = day14InputLines[y].charAt(x);
        if (c == 'O')
        {
          rollingRockPositions.add(new Coordinates(x, y));
        }
        else if (c == '#')
        {
          stationaryRockPositions.add(new Coordinates(x, y));
        }
      }
    }
  }

  public void rollToNorth()
  {
    boolean somethingMoved = false;
    do
    {
      somethingMoved = false;
      for (int y=1; y<height; y++)
      {
        for (int x=0; x<width; x++)
        {
          final Coordinates c = new Coordinates(x, y);
          if (rollingRockPositions.contains(c))
          {
            final Coordinates potentialNewCoordiantes = new Coordinates(x, y-1);
            if (isFreePosition(potentialNewCoordiantes))
            {
              rollingRockPositions.remove(c);
              rollingRockPositions.add(potentialNewCoordiantes);
              somethingMoved = true;
            }
          }
        }
      }
    } while (somethingMoved);
  }

  public void rollToWest()  // Part 2
  {
    boolean somethingMoved = false;
    do
    {
      somethingMoved = false;
      for (int x=1; x<width; x++)
      {
        for (int y=0; y<height; y++)
        {
          final Coordinates c = new Coordinates(x, y);
          if (rollingRockPositions.contains(c))
          {
            final Coordinates potentialNewCoordiantes = new Coordinates(x-1, y);
            if (isFreePosition(potentialNewCoordiantes))
            {
              rollingRockPositions.remove(c);
              rollingRockPositions.add(potentialNewCoordiantes);
              somethingMoved = true;
            }
          }
        }
      }
    } while (somethingMoved);
  }
  
  public void rollToSouth()  // Part 2
  {
    boolean somethingMoved = false;
    do
    {
      somethingMoved = false;
      for (int y=height-2; y>=0; y--)
      {
        for (int x=0; x<width; x++)
        {
          final Coordinates c = new Coordinates(x, y);
          if (rollingRockPositions.contains(c))
          {
            final Coordinates potentialNewCoordiantes = new Coordinates(x, y+1);
            if (isFreePosition(potentialNewCoordiantes))
            {
              rollingRockPositions.remove(c);
              rollingRockPositions.add(potentialNewCoordiantes);
              somethingMoved = true;
            }
          }
        }
      }
    } while (somethingMoved);
  }

  public void rollToEast()  // Part 2
  {
    boolean somethingMoved = false;
    do
    {
      somethingMoved = false;
      for (int x=width-2; x>=0; x--)
      {
        for (int y=0; y<height; y++)
        {
          final Coordinates c = new Coordinates(x, y);
          if (rollingRockPositions.contains(c))
          {
            final Coordinates potentialNewCoordiantes = new Coordinates(x+1, y);
            if (isFreePosition(potentialNewCoordiantes))
            {
              rollingRockPositions.remove(c);
              rollingRockPositions.add(potentialNewCoordiantes);
              somethingMoved = true;
            }
          }
        }
      }
    } while (somethingMoved);
  }

  private boolean isFreePosition(final Coordinates coordiantes)
  {
    if (rollingRockPositions.contains(coordiantes))  return false;
    if (stationaryRockPositions.contains(coordiantes))  return false;
    return true;
  }

  public long getLoad()
  {
    long result = 0;
    for (int y=0; y<height; y++)
    {
      final int loadFactor = height-y;
      for (int x=0; x<width; x++)
      {
        final Coordinates c = new Coordinates(x, y);
        if (rollingRockPositions.contains(c))  result += loadFactor;
      }
    }
    return result;
  }

  // The string representation is used in Part 2 to detect when the
  // cycle repeats
  @Override
  public String toString()
  {
    final StringBuilder sb = new StringBuilder();
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        final Coordinates c = new Coordinates(x, y);
        if (rollingRockPositions.contains(c))
        {
          sb.append('O');
        }
        else if (stationaryRockPositions.contains(c))
        {
          sb.append('#');
        }
        else
        {
          sb.append('.');
        }
      }
      sb.append("\n");
    }
    return sb.toString();
  }

  private void doOneCycle()  // part 2
  {
    rollToNorth();
    rollToWest();
    rollToSouth();
    rollToEast();
  }

  // Call once for part 2 to determine the values for the cycle length and cycle start
  public long cycleUntilRepeat()  
  {
    final Map<String, Integer> pastCycles = new HashMap<>();
    
    int count = 0;
    doOneCycle();
    String config = toString();
    int numCycles = 0;
    int cycleStart = -1;
    int cycleLength = -1;
    int startOfCycle = -1;

    while (numCycles < 4)
    {
      count++;
      if (!pastCycles.containsKey(config))
      {
        pastCycles.put(config, count);
      }
      else
      {
        final int matchVal = pastCycles.get(config);
        if (matchVal == cycleStart)
        {
          if (startOfCycle>0)
          {
            if (cycleLength < 0)
            {
              cycleLength = count - startOfCycle;
            }
            else
            {
              if (cycleLength != count - startOfCycle)
              {
                throw new IllegalStateException("Cycle length mismatch");
              }
            }
          }
          numCycles++;
          startOfCycle = count;
        }
        
        if (cycleStart < 0) cycleStart = matchVal;
      }
      doOneCycle();
      config = toString(); 
    }

    System.out.println("cycleStart = " + cycleStart);
    System.out.println("cycleLength = " + cycleLength);
    return count;
  }

  public void cycleNTimes(final int numCycles)  // part 2
  {
    for (int i=0; i<numCycles; i++)  doOneCycle();
  }
}

This is the part 1 and part 2 methods. Part 2 is to be called with cycleStart and cycleLength (as well as numCyclesToRun which is 1_000_000_000.) Call it with all zeros to have it calculate those values.

  public static Long part1(final String[] day14InputLines)
  {
    final RockAndRoll rar = new RockAndRoll(day14InputLines);
    rar.rollToNorth();
    return rar.getLoad();
  }

  public static Long part2(final String[] day14InputLines, final int cycleStart, final int cycleLength, final int numCyclesToRun)
  {
    final RockAndRoll rar = new RockAndRoll(day14InputLines);

    // Run it one time with zero inputs to get the values for cycleStart and cycleLength
    if (cycleLength == 0)
    {
      rar.cycleUntilRepeat();
      return 0L;
    }

    rar.cycleNTimes(cycleStart);
    final int numFullCycles = (numCyclesToRun-cycleStart)/cycleLength;
    final int remainderCycles = numCyclesToRun - cycleStart - numFullCycles*cycleLength;
    rar.cycleNTimes(remainderCycles);
    
    return rar.getLoad();
  }

Not shown here is how I called the part2() method with the input and three zero parameters first time to get the cycle start and length, and then replaced that call’s zeros with those new values after having determined them.

Day 15 looks kinda complicated, but it’s not really, you just have to read and follow the steps as indicated. I did get tripped up on part 2 because the example only uses 2 character labels and I though so would the input… which of course it did not.

Part 1 is very quick and easy. Loop over the chars and do the instructions. I created a class Hasher to contain the hash() method:

final class Hasher
{
   public static int hash(final String input)
   {
     int hash = 0;
     for (final char c: input.toCharArray())
     {
       hash += c;
       hash *= 17;
       hash %= 256;
     }
     return hash;
   }
}

Then the part 1 method manages breaking up the input into the require parts and accumulating the sum:

  public static Long part1(final String day15Input)
  {
    final Hasher h = new Hasher();
    final String[] parts = day15Input.split(",");
    long sum = 0;
    for (final String part: parts)
    {
      sum += h.hash(part);
    }
    return sum;
  }

For part 2 I added a LensArranger class which does all the work of parsing the commands coming in from the input and managing all the lens boxes and what’s in them. It uses a record for the Lens and a record for the LensBox. The LensBox record has added methods to manage the insertion and removal/replacement of lenses. I used a regex to parse apart the command.

final class LensArranger
{
  private record Lens(String name, int focalLength) {}
  private record LensBox(List<Lens> lensList)
  {
    public void removeLensByLabel(final String label)
    {
      int index = -1;
      for (int i=0; i< lensList.size(); i++)
      {
        final Lens lens = lensList.get(i);
        if (lens.name.equals(label))
        {
          index = i;
          break;
        }
      }
      if (index>= 0) lensList.remove(index);
    }

    public void replaceOrAdd(final String label, final int focalLength)
    {
      int index = -1;
      for (int i=0; i< lensList.size(); i++)
      {
        final Lens lens = lensList.get(i);
        if (lens.name.equals(label))
        {
          index = i;
          break;
        }
      }
      if (index>= 0)
      {
        lensList.set(index, new Lens(label, focalLength));
      }
      else
      {
        lensList.add(new Lens(label, focalLength));
      }
    }
  }
  private static final LensBox EMPTY_LENS_BOX = new LensBox(new ArrayList<>());
  
  private final LensBox[] lensBoxes = new LensBox[256];
  private final Hasher hasher = new Hasher();
  
  LensArranger()
  {
    for (int i=0; i<256; i++) lensBoxes[i] = EMPTY_LENS_BOX;
  }
  
  private static final Pattern COMMAND_PATTERN = Pattern.compile("(.+)([=-])([0-9]+)?");
  public void makeArrangement(final String command)
  {
    final Matcher m = COMMAND_PATTERN.matcher(command);
    if (!m.matches())  throw new IllegalArgumentException("Couldn't parse command: " + command);

    final String label = m.group(1);
    final char operationChar = m.group(2).charAt(0);
    final int boxNum = hasher.hash(label);
    final int focalLength;
    if (operationChar == '=')
    {
      focalLength = Integer.parseInt(m.group(3));
    }
    else
    {
      focalLength = -1;
    }
    
    if (operationChar == '-')
    {
      final LensBox lb = lensBoxes[boxNum];
      if (lb != EMPTY_LENS_BOX)
      {
        lb.removeLensByLabel(label);
      }
    }
    
    if (operationChar == '=')
    {
      LensBox lb = lensBoxes[boxNum];
      if (lb == EMPTY_LENS_BOX)
      {
        lb = new LensBox(new ArrayList<>());
        lensBoxes[boxNum] = lb; 
      }
      lb.replaceOrAdd(label, focalLength);
    }
  }

  public long getSumOfFocusingPower()
  {
    long sum = 0;
    for (int i=0; i<256; i++)
    {
      final LensBox lb = lensBoxes[i];
      if ((lb != EMPTY_LENS_BOX) && (!lb.lensList.isEmpty()))
      {
        for (int j=0; j<lb.lensList.size(); j++)
        {
          final Lens l = lb.lensList.get(j);
          final long focusingPower = (i+1)*(j+1)*(l.focalLength);
          sum += focusingPower;
        }
      }
    }
    return sum;
  }
}

And the part 2 method just creates the class and breaks up the input to pass into it to update the state, followed by asking for the final calculation:

  public static Long part2(final String day15Input)
  {
    final LensArranger la = new LensArranger();
    final String[] parts = day15Input.split(",");
    for (final String part: parts)
    {
      la.makeArrangement(part);
    }
    return la.getSumOfFocusingPower();
  }

Day 16 is simulating beams as they bounce and split inside an “arena” filled with “contraptions”. This one is a big of work simply because you have to figure out how you’re going to handle the beams splitting and make sure you have all of your beam manipulations correct. You need to have cases for beams coming in from each direction for each contraption. You will also need to figure out how to detect and eliminate beams that get into a loop. While doing all that, you also need to keep track of which spots the beam passes through. There’s plenty going on… and plenty of chances to make a mistake, including one that isn’t detected by the sample input but occurs in your actual input. (Ask me how I know :wink: I wasted a good 30 minutes trying to find the problem that caused my first attempt at part 2 to produce a very low answer like 144. )

EDIT: Oh I almost forgot one other tricky thing… your input will have \ in it and that is an escape character in many languages (C/C++ and Java for sure, but probably others too.) You’ll need to load your input into a text editor and search and replace all the \ for \\ if you want to use it as “hard coded” Strings. Of course, you could always read it from a file if that’s easier, I guess.

I created a helper class named LavaBeamContraption. It manages the “arena” and all the beams. I used a couple of enums for BeamDirection and Contraption and a couple of records for Coordinates and Beams.

final class LavaBeamContraption
{
  public  enum BeamDirection
  {
    DEFUNCT,
    NORTH,
    EAST,
    SOUTH,
    WEST;
  }
  public record Coordinates(int x, int y) {}
  private static final Coordinates INVALID_COORDINATES = new Coordinates(-1, -1);
  
  public record Beam(Coordinates coordinates, BeamDirection direction) {}
  private static final Beam DEFUNCT_BEAM = new Beam(INVALID_COORDINATES, BeamDirection.DEFUNCT);
  
  private enum Contraption
  {
    FORWARD_SLASH('/'),
    BACKWARD_SLASH('\\'),
    PIPE('|'),
    DASH('-');
    
    private final char contraptionChar;
    private Contraption(final char contraptionChar)
    {
      this.contraptionChar = contraptionChar;
    }
    
    public char getContraptionChar()
    {
      return contraptionChar;
    }
  }
  
  private final int height;
  private final int width;
  
  private final Map<Coordinates,Contraption>  contraptionCoordinates = new HashMap<>();
  private final List<Beam> beams = new ArrayList<>();
  private Set<Coordinates> energizedCoordinates = new HashSet<>();
  private Set<Beam> loopDetection = new HashSet<>();
  
  public LavaBeamContraption(int height, int width)
  {
    this.height = height;
    this.width = width;
  }
  public void loadInContraption(final String[] day16InputLines)
  {
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        final char contraptionChar = day16InputLines[y].charAt(x);
        final Coordinates coordinates = new Coordinates(x, y);
        switch (contraptionChar)
        {
          case '.':
          {
            // empty space, do nothing
          }
          break;
          case '/':
          {
            contraptionCoordinates.put(coordinates, Contraption.FORWARD_SLASH);
          }
          break;
          case '\\':
          {
            contraptionCoordinates.put(coordinates, Contraption.BACKWARD_SLASH);
          }
          break;
          case '|':
          {
            contraptionCoordinates.put(coordinates, Contraption.PIPE);
          }
          break;
          case '-':
          {
            contraptionCoordinates.put(coordinates, Contraption.DASH);
          }
          break;
          default:
            throw new IllegalStateException("Unexpected char in input: " + contraptionChar);
        }
      }
    }
  }

  public void addBeam(final Beam beam)
  {
    beams.add(beam);
  }

  public void releaseBeams()
  {
    //final Scanner scanner = new Scanner(System.in);
    
    while (!beams.isEmpty())
    {
      final List<Beam> newBeamsToAddAtEndOfCycle = new ArrayList<>();
      for (int i=0; i<beams.size(); i++)
      {
        final Beam beam = beams.get(i);
        if (loopDetection.contains(beam))
        {
          // this beam is looping, so kill it now
          beams.set(i, DEFUNCT_BEAM);
          continue;
        }
        if (beam == DEFUNCT_BEAM)  continue;
        loopDetection.add(beam);
        final Coordinates coordinates = beam.coordinates;
        energizedCoordinates.add(coordinates);
        if (!contraptionCoordinates.containsKey(coordinates))
        {
          final Coordinates newBeamCoordinates = moveBeam(beam);
          if (!areCoordinatesInArea(newBeamCoordinates))
          {
            beams.set(i, DEFUNCT_BEAM);
          }
          else
          {
            beams.set(i, new Beam(newBeamCoordinates, beam.direction));
          }
        }
        else
        {
          final Contraption contraption = contraptionCoordinates.get(coordinates);
          final Beam[] newBeams = moveBeamThroughContraption(beam, contraption);
          if (newBeams.length == 1)
          {
            final Coordinates newBeamCoordinates = newBeams[0].coordinates;
            if (!areCoordinatesInArea(newBeamCoordinates))
            {
              beams.set(i, DEFUNCT_BEAM);
            }
            else
            {
              beams.set(i, newBeams[0]);
            }
          }
          else
          {
            beams.set(i, DEFUNCT_BEAM);
            for (final Beam newBeam : newBeams)
            {
              if (areCoordinatesInArea(newBeam.coordinates))
              {
                newBeamsToAddAtEndOfCycle.add(newBeam);
              }
            }
          }
        }
      }
      
      // Clean up all defunct beams (so the isEmpty() will work)
      // Walk backward to avoid problems with indexes shifting as they're deleted
      for (int i=beams.size()-1; i>=0; i--)
      {
        if (beams.get(i) == DEFUNCT_BEAM) beams.remove(i);
      }

      // Add any new beams generated in cycle (can't add in real time
      // or they'll end up processed in same cycle added)
      beams.addAll(newBeamsToAddAtEndOfCycle);
    }
  }

  private Beam[] moveBeamThroughContraption(final Beam beam, final Contraption contraption)
  {
    final List<Beam> newBeams = new ArrayList<>();
    final Coordinates coordinates = beam.coordinates;
    switch (contraption)
    {
      case BACKWARD_SLASH:
      {
        switch (beam.direction)
        {
          case NORTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x-1, coordinates.y), BeamDirection.WEST));
            break;
          case EAST:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y+1), BeamDirection.SOUTH));
            break;
          case SOUTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x+1, coordinates.y), BeamDirection.EAST));
            break;
          case WEST:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y-1), BeamDirection.NORTH));
            break;
          default:
            throw new IllegalStateException("BACKWARD_SLASH beam.direction was " + beam.direction);
        }
      }
      break;

      case FORWARD_SLASH:
      {
        switch (beam.direction)
        {
          case NORTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x+1, coordinates.y), BeamDirection.EAST));
            break;
          case EAST:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y-1), BeamDirection.NORTH));
            break;
          case SOUTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x-1, coordinates.y), BeamDirection.WEST));
            break;
          case WEST:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y+1), BeamDirection.SOUTH));
            break;
          default:
            throw new IllegalStateException("FORWARD_SLASH beam.direction was " + beam.direction);
        }
      }
      break;

      case DASH:
      {
        switch (beam.direction)
        {
          case NORTH:
          case SOUTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x+1, coordinates.y), BeamDirection.EAST));
            newBeams.add(new Beam(new Coordinates(coordinates.x-1, coordinates.y), BeamDirection.WEST));
            break;
          case EAST:
            newBeams.add(new Beam(new Coordinates(coordinates.x+1, coordinates.y), BeamDirection.EAST));
            break;
          case WEST:
            newBeams.add(new Beam(new Coordinates(coordinates.x-1, coordinates.y), BeamDirection.WEST));
            break;
          default:
            throw new IllegalStateException("DASH beam.direction was " + beam.direction);
        }
      }
      break;

      case PIPE:
      {
        switch (beam.direction)
        {
          case NORTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y-1), BeamDirection.NORTH));
            break;
          case SOUTH:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y+1), BeamDirection.SOUTH));
            break;
          case EAST:
          case WEST:
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y-1), BeamDirection.NORTH));
            newBeams.add(new Beam(new Coordinates(coordinates.x, coordinates.y+1), BeamDirection.SOUTH));
            break;
          default:
            throw new IllegalStateException("DASH beam.direction was " + beam.direction);
        }
      }
      break;
      
      default:
        throw new IllegalStateException("Unexpected Contration: " + contraption);
    }
    return newBeams.toArray(new Beam[0]);
  }

  private boolean areCoordinatesInArea(final Coordinates coordinates)
  {
    if (coordinates.x < 0) return false;
    if (coordinates.y < 0) return false;
    if (coordinates.x >= width) return false;
    if (coordinates.y >= height) return false;
    return true;
  }

  private Coordinates moveBeam(final Beam beam)
  {
    switch (beam.direction)
    {
      case NORTH:
        return new Coordinates (beam.coordinates.x, beam.coordinates.y-1);
      case EAST:
        return new Coordinates (beam.coordinates.x+1, beam.coordinates.y);
      case SOUTH:
        return new Coordinates (beam.coordinates.x, beam.coordinates.y+1);
      case WEST:
        return new Coordinates (beam.coordinates.x-1, beam.coordinates.y);
      default:
        throw new IllegalStateException("beam.direction was " + beam.direction);
    }
  }

  public long getEnergizedCount()
  {
    return energizedCoordinates.size();
  }
}

For the part 1 method, I eventually refactored out most of it for part 2, but it basically loads up the arena and defines the beam and then lets it loose, finally asking how many spots got energized.

  public static Long part1(final String[] day16InputLines)
  {
    return getEnergizedCountForABeam(day16InputLines, new Beam(new Coordinates(0,0), BeamDirection.EAST));
  }

  private static long getEnergizedCountForABeam(final String[] day16InputLines, final Beam beam)
  {
    final LavaBeamContraption lbc = new LavaBeamContraption(day16InputLines.length, day16InputLines[0].length());
    lbc.loadInContraption(day16InputLines);
    lbc.addBeam(beam);
    lbc.releaseBeams();
    
    return lbc.getEnergizedCount();
  }

I had everything I needed already in the helper class for the part 2 method, so it just handles finding the requested maximum.

  public static Long part2(final String[] day16InputLines)
  {
    final int height = day16InputLines.length;
    final int width = day16InputLines[0].length();
    long bestResult = -1;
    for (int x=0; x<width; x++)
    {
      final long res1 = getEnergizedCountForABeam(day16InputLines, new Beam(new Coordinates(x, 0), BeamDirection.SOUTH));
      if (res1 > bestResult)  bestResult = res1;
      final long res2 = getEnergizedCountForABeam(day16InputLines, new Beam(new Coordinates(x, height-1), BeamDirection.NORTH));
      if (res2 > bestResult)  bestResult = res2;
    }
    for (int y=0; y<height; y++)
    {
      final long res1 = getEnergizedCountForABeam(day16InputLines, new Beam(new Coordinates(0, y), BeamDirection.EAST));
      if (res1 > bestResult)  bestResult = res1;
      final long res2 = getEnergizedCountForABeam(day16InputLines, new Beam(new Coordinates(width-1,y), BeamDirection.WEST));
      if (res2 > bestResult)  bestResult = res2;
    }
    return bestResult;
  }

Day 12: (I haven’t been able to work on the puzzles for a few days so I have some catching up to do.) I needed help with Day 12 Part 2 and ended up learning about the use of @functools.lru_cache() and recursion in Python. With the cache, the solution took only 6 seconds - hard to believe. I’d guess thousands of hours without the cache..

Day 13: This puzzle is fairly straightforward other than having to keep track of all the array indices. I had to step through my code several times to get it right. For Part 2 I flipped each ‘.’ or ‘#’ one by one and had to determine which way the mirror was facing to make sure the smudge was in the image side (I verified separately that every pattern had an odd number of rows and columns, so the mirror could never be exactly in the centre.)

Day 17 feels familiar to a puzzle from 2021… day 15. Advent of Code 2021 - #48 by PHolder I struggled with that one, and I struggled again with this one. I looked at some other people’s Python solutions to get a hint about how to proceed. The approach I came up with uses a Heap (or a PriorityQueue in Java.)

I created a helper class HeatLossCalculator. It starts out solving by putting the start position on the heap twice, once for each possible departure (right or down.) Then as it pulls entries of the heap, it checks if it should keep going in the current direction, or check one of the other two. (Note how it gets the other two directions by flipping DX and DY and negates one of each.) As the values get higher, the heap presents them in lowest value first, and a minimizing (best fit) approach keeps track of the best values we’ve seen as we go.

final class HeatLossCalculator
{
  private record Coordinates(int x, int y) {}

  private final int height;
  private final int width;

  private final Map<Coordinates, Integer> heatLossValues = new HashMap<>();
  private final Coordinates goalCoordinates;
  
  public HeatLossCalculator(final int height, final int width)
  {
    this.height = height;
    this.width = width;
    goalCoordinates = new Coordinates(width-1, height-1);
  }

  public void loadHeatLossValues(final String[] day17InputLines)
  {
    for (int y=0; y<height; y++)
    {
      for (int x=0; x<width; x++)
      {
        final Coordinates c = new Coordinates(x, y);
        heatLossValues.put(c, day17InputLines[y].charAt(x)-'0');
      }
    }
  }

  private record Dir(int dx, int dy) {}
  private record Node(Coordinates coordinates, int numInDir, Dir dir) {}
  private record NodeAndHeatLoss(int heatLoss, Node node) {}
  public long findLowestHeatLoss(final int minInDirection, final int maxInDirection)
  {
    final PriorityQueue<NodeAndHeatLoss> heap = new PriorityQueue<>(new Comparator<NodeAndHeatLoss>()
      {
        @Override
        public int compare(final NodeAndHeatLoss o1, final NodeAndHeatLoss o2)
        {
          return o1.heatLoss - o2.heatLoss;
        }
      });
    final Coordinates startCoordinates = new Coordinates(0, 0);
    final Set<Node> visited = new HashSet<>();
    
    Node reachedTarget = new Node(new Coordinates(-1,-1), -1, new Dir(0,0));

    final Node start1 = new Node(startCoordinates, 0, new Dir(1,0));
    final Node start2 = new Node(startCoordinates, 0, new Dir(0,1));
    heap.add(new NodeAndHeatLoss(0, start1));
    heap.add(new NodeAndHeatLoss(0, start2));
    final Map<Node, Integer> minHeatLoss = new HashMap<>();
    minHeatLoss.put(start1, 0);
    minHeatLoss.put(start2, 0);
    while (!heap.isEmpty())
    {
      final NodeAndHeatLoss nodeAndHeatloss = heap.remove();
      final Node node = nodeAndHeatloss.node;
      if (visited.contains(node))  continue;
      visited.add(node);
      if (node.coordinates.equals(goalCoordinates) && (node.numInDir() > minInDirection-1))
      {
        reachedTarget = node;
        break;
      }
      final NodeAndHeatLoss[] paths = probeNeighbours(node, minInDirection, maxInDirection);
      for (final NodeAndHeatLoss pathNode : paths)
      {
        if (visited.contains(pathNode.node))  continue;
        final int newHeatLoss = minHeatLoss.get(node) + pathNode.heatLoss;
        if (!minHeatLoss.containsKey(pathNode.node) || (newHeatLoss < minHeatLoss.get(pathNode.node)))
        {
          minHeatLoss.put(pathNode.node, newHeatLoss);
          heap.add(new NodeAndHeatLoss(newHeatLoss, pathNode.node));
        }
      }
    }
    if (reachedTarget.numInDir < 0) throw new IllegalStateException("Did not resolve an answer");
    
    return minHeatLoss.get(reachedTarget);
  }

  private NodeAndHeatLoss[] probeNeighbours(final Node node, final int minInDirection, final int maxInDirection)
  {
    final List<NodeAndHeatLoss> result = new ArrayList<>();
    final int x = node.coordinates.x;
    final int y = node.coordinates.y;
    final Dir dir = node.dir;
    final int numInDir = node.numInDir;
    final int dx = dir.dx;
    final int dy = dir.dy;
    
    final Dir dir1 = new Dir(-dy, dx);
    final Dir dir2 = new Dir(dy, -dx);
    if ((numInDir < maxInDirection) && onGrid(x+dx, y+dy))
    {
      final Coordinates newCoordinates = new Coordinates(x+dx, y+dy);
      final Node newNode = new Node(newCoordinates, numInDir+1, dir);
      result.add(new NodeAndHeatLoss(heatLossValues.get(newCoordinates), newNode));
    }

    if (numInDir > minInDirection-1)
    {
      if (onGrid(x+dir1.dx, y+dir1.dy))
      {
        final Coordinates newCoordinates = new Coordinates(x+dir1.dx, y+dir1.dy);
        final Node newNode = new Node(newCoordinates, 1, dir1);
        result.add(new NodeAndHeatLoss(heatLossValues.get(newCoordinates), newNode));
      }
      if (onGrid(x+dir2.dx, y+dir2.dy))
      {
        final Coordinates newCoordinates = new Coordinates(x+dir2.dx, y+dir2.dy);
        final Node newNode = new Node(newCoordinates, 1, dir2);
        result.add(new NodeAndHeatLoss(heatLossValues.get(newCoordinates), newNode));
      }
    }
    return result.toArray(new NodeAndHeatLoss[0]);
  }

  private boolean onGrid(final int x, final int y)
  {
    if (x<0) return false;
    if (y<0) return false;
    if (x>width-1)  return false;
    if (y>height-1)  return false;
    return true;
  }
}

Which made the part 1 and part 2 methods very easy to write, especially since I had the foresight to include minimum and maximum values:

  public static Long part1(final String[] day17InputLines)
  {
    final HeatLossCalculator hlc = new HeatLossCalculator(day17InputLines.length, day17InputLines[0].length());
    hlc.loadHeatLossValues(day17InputLines);
    return hlc.findLowestHeatLoss(0, 3);
  }

  public static Long part2(final String[] day17InputLines)
  {
    final HeatLossCalculator hlc = new HeatLossCalculator(day17InputLines.length, day17InputLines[0].length());
    hlc.loadHeatLossValues(day17InputLines);
    return hlc.findLowestHeatLoss(4, 10);
  }

Day 18 is actually similar to Day 10 Advent of Code 2023 - #19 by PHolder , even though I didn’t initially know it. I learned some new math to solve day 18 and while reading I learned the same/similar math could have been applied to back in day 10. I think I may actually try and go back and apply it for practice.

I initially took the plan of creating the outline as coordinates in a HashMap (as usual by now) and then doing a flood fill. I actually wrote all that out and used it for part 1, but when I realized it would never work for part 2 and I had to learn a new approach, I threw it away and started over. Thus my helper class is called LavaPit2.

final class LavaPit2
{
  private record Coordinates(long x, long y) {}

  private long x=0;
  private long y=0;
  private long perimeter = 0;

  private final List<Coordinates> verticies = new ArrayList<>();
  
  LavaPit2()
  {
    verticies.add(new Coordinates(x, y));
  }
  
  // Example: R 6 (#70c710)
  private static final Pattern DIG_INSTRUCTION = Pattern.compile("([RUDL]) ([0-9]+) [(][#](.*)[)]");
  public void processDigInstruction(String digIstruction)
  {
    final Matcher m = DIG_INSTRUCTION.matcher(digIstruction);
    if (!m.matches())  throw new IllegalArgumentException("Unable to parse dig instruction: " + digIstruction);
    final int distance = Integer.parseInt(m.group(2));
    perimeter += distance;
    switch(m.group(1))
    {
      case "U": y-=distance; break;
      case "D": y+=distance; break;
      case "L": x-=distance; break;
      case "R": x+=distance; break;
      default:  throw new IllegalStateException("Can't process move direction: " + m.group(1));
    }
    verticies.add(new Coordinates(x, y));
  }

  public void processDigInstruction2(String digIstruction)
  {
    final Matcher m = DIG_INSTRUCTION.matcher(digIstruction);
    if (!m.matches())  throw new IllegalArgumentException("Unable to parse dig instruction: " + digIstruction);
    
    final long distance = getHexValue(m.group(3).substring(0, 5));
    perimeter += distance;
    switch(m.group(3).substring(5))
    {
      case "3": y-=distance; break;
      case "1": y+=distance; break;
      case "2": x-=distance; break;
      case "0": x+=distance; break;
      default:  throw new IllegalStateException("Can't process move direction: " + m.group(3).substring(5));
    }
    verticies.add(new Coordinates(x, y));
  }

  private long getHexValue(final String hexString)
  {
    return Long.parseLong(hexString, 16);
  }

  public long getFilledPitSize()
  {
    return countFilled();
  }

  // Use the https://en.wikipedia.org/wiki/Shoelace_formula#Triangle_formula to get the area inside,
  // and the add the half of perimeter your didn't get
  // Saw a Reddit post that said:
  // Used Green's theorem
  // For each step, you can use the formula area+=x*dy to determine the inner area (only includes roughly half
  // the area of the perimeter), so we just add perimeter//2+1 to the end to return the total area. 
  private long countFilled()
  {
    long area = 0;
    for (int i=0; i<verticies.size(); i++)
    {
      final Coordinates vertex1 = verticies.get(i);
      final Coordinates vertex2;
      if (i==verticies.size()-1)
      {
        vertex2 = verticies.get(0);
      }
      else
      {
        vertex2 = verticies.get(i+1);
      }
      long x1 = vertex1.x;
      long y1 = vertex1.y;
      long x2 = vertex2.x;
      long y2 = vertex2.y;
      area += x1*y2 - x2*y1;
    }
    return area/2 + perimeter/2 + 1;
  }
}

My helper class needed some slight changes (a new method of parsing) for part 2, but I included it above already.

The part 1 and part 2 methods are pretty much both identical, just invoking different parsing.

  public static Long part1(final String[] day18InputLines)
  {
    final LavaPit2 lavaPit = new LavaPit2();
    for (final String digIstruction : day18InputLines)
    {
      lavaPit.processDigInstruction(digIstruction);
    }
    return lavaPit.getFilledPitSize();
  }

  public static Long part2(final String[] day18InputLines)
  {
    final LavaPit2 lavaPit = new LavaPit2();
    for (final String digIstruction : day18InputLines)
    {
      lavaPit.processDigInstruction2(digIstruction);
    }
    return lavaPit.getFilledPitSize();
  }

In Day 16 Part 2 should the range in the following be (width-1, y) ?

Good catch! I guess my solution was not on that side/direction :wink: I edited the post, but I will need to go fix my code too :wink:

Day 19 part 1 is mostly a parsing and understanding and following the instructions problem. I presume if you succeed in understanding what you need to do, other than any typos, it’s likely you would get the correct answer without much hassle. Part 2, on the other hand, requires some recursive tree walking and creative use of a range data structure. (Or at least did the way I implemented it.) With 4000x4000x4000x4000 = 256,000,000,000,000 there seems no way to brute force it.

I created a helper class named WorkflowManager to store and index the workflows by name and to do the processing for part 1 and then again for part 2.

One change of note for this day is that there are two separate parts to the input, and I manually separated them into two String arrays in my code, so save time writing the code to do otherwise.

This is the part of WorkflowManager used in part 1:

final class WorkflowManager
{
  private enum Variable{X, M, A, S, REJECT, ACCEPT, GOTO}
  private enum Comparison{LT,GT, REJECT, ACCEPT, GOTO}
  private record WorkflowRule(Variable variable, Comparison comparison, long value, String workflowName) {}
  private final Map<String, WorkflowRule[]> allWorkflows = new HashMap<>();
  private long sum;
  
  public WorkflowManager(final String[] workflows)
  {
    for (final String workflow: workflows)
    {
      addWorkflow(workflow);
    }
  }

  //px{a<2006:qkq,m>2090:A,rfg}
  private static final Pattern WORKFLOW_PATTERN = Pattern.compile("([a-z]+)[{](.*)[}]");
  private void addWorkflow(final String workflow)
  {
    final Matcher m = WORKFLOW_PATTERN.matcher(workflow);
    if (!m.matches()) throw new IllegalArgumentException("Couldn't parse workflow: " + workflow);
    final String workflowName = m.group(1);
    final String parts[] = m.group(2).split(",");
    final List<WorkflowRule> workflows = new ArrayList<>();
    for(final String part: parts)
    {
      if (part.contains("<"))
      {
        final Variable variable =
        switch (part.charAt(0))
        {
          case 'x' -> Variable.X;
          case 'm' -> Variable.M;
          case 'a' -> Variable.A;
          case 's' -> Variable.S;
          default -> throw new IllegalStateException("Couldn't decode variable: " + part);
        };
        final String[] lastParts = part.substring(2).split(":");
        workflows.add(new WorkflowRule(variable, Comparison.LT, Long.parseLong(lastParts[0]), lastParts[1]));
      }
      else if (part.contains(">"))
      {
        final Variable variable =
        switch (part.charAt(0))
        {
          case 'x' -> Variable.X;
          case 'm' -> Variable.M;
          case 'a' -> Variable.A;
          case 's' -> Variable.S;
          default -> throw new IllegalStateException("Couldn't decode variable: " + part);
        };
        final String[] lastParts = part.substring(2).split(":");
        workflows.add(new WorkflowRule(variable, Comparison.GT, Long.parseLong(lastParts[0]), lastParts[1]));
      }
      else if (part.equals("R"))
      {
        workflows.add(new WorkflowRule(Variable.REJECT, Comparison.REJECT, -1, ""));
      }
      else if (part.equals("A"))
      {
        workflows.add(new WorkflowRule(Variable.ACCEPT, Comparison.ACCEPT, -1, ""));
      }
      else
      {
        workflows.add(new WorkflowRule(Variable.GOTO, Comparison.GOTO, -1, part));
      }
    }
    final WorkflowRule[] rules = workflows.toArray(new WorkflowRule[0]); 
    allWorkflows.put(workflowName, rules);
  }

  //{x=3115,m=1584,a=357,s=1250}
  private static final Pattern PART_PATTERN = Pattern.compile("[{]x=(\\d+),m=(\\d+),a=(\\d+),s=(\\d+)[}]");
  public void inspectPart(final String part)
  {
    final Matcher matcher = PART_PATTERN.matcher(part);
    if (!matcher.matches())  throw new IllegalArgumentException("Couldn't parse part: " + part);

    final long x = Long.parseLong(matcher.group(1));
    final long m = Long.parseLong(matcher.group(2));
    final long a = Long.parseLong(matcher.group(3));
    final long s = Long.parseLong(matcher.group(4));
    
    String currentRule = "in";
    boolean isAccepted = false;
    boolean isRejected = false;
    while (!isAccepted && !isRejected)
    {
      final WorkflowRule[] rules = allWorkflows.get(currentRule);
      boolean isGoto = false;
      for (final WorkflowRule rule: rules)
      {
        switch (rule.comparison)
        {
          case ACCEPT:
          {
            isAccepted = true;
          }
          break;
          
          case REJECT:
          {
            isRejected = true;
          }
          break;
          
          case GOTO:
          {
            if (rule.workflowName.equals("A"))
              isAccepted = true;
            else if (rule.workflowName.equals("R"))
              isRejected = true;
            else
            {
              currentRule = rule.workflowName;
              isGoto = true;
            }
          }
          break;
          
          case GT:
          {
            boolean isConditionTrue = false;
            switch (rule.variable)
            {
              case X: if (x>rule.value) isConditionTrue = true;  break;
              case M: if (m>rule.value) isConditionTrue = true;  break;
              case A: if (a>rule.value) isConditionTrue = true;  break;
              case S: if (s>rule.value) isConditionTrue = true;  break;
            }
            if (isConditionTrue)
            {
              if (rule.workflowName.equals("A"))
                isAccepted = true;
              else if (rule.workflowName.equals("R"))
                isRejected = true;
              else
              {
                currentRule = rule.workflowName;
                isGoto = true;
              }
            }
          }
          break;

          case LT:
          {
            boolean isConditionTrue = false;
            switch (rule.variable)
            {
              case X: if (x<rule.value) isConditionTrue = true;  break;
              case M: if (m<rule.value) isConditionTrue = true;  break;
              case A: if (a<rule.value) isConditionTrue = true;  break;
              case S: if (s<rule.value) isConditionTrue = true;  break;
            }
            if (isConditionTrue)
            {
              if (rule.workflowName.equals("A"))
                isAccepted = true;
              else if (rule.workflowName.equals("R"))
                isRejected = true;
              else
              {
                currentRule = rule.workflowName;
                isGoto = true;
              }
            }
          }
          break;
          
          default:  throw new IllegalStateException(""+rule);
        }
        if (isAccepted || isRejected || isGoto)  break;
      }
    }
    if (isAccepted)
    {
      sum += x+m+a+s;
    }
  }

  public long getFinalInspectResult()
  {
    return sum;
  }
}

And the part 1 method is obvious enough I don’t really think a spoiler protection is required:

  public static Long part1(final String[] workflows, final String[] parts)
  {
    final WorkflowManager wm = new WorkflowManager(workflows);
    for (final String part : parts)
    {
      wm.inspectPart(part);
    }
    return wm.getFinalInspectResult();
  }

For part 2, I added the recursive tree walker, reused the sum variable from part 1, and reused a Range class I wrote a couple years ago (I also used it in last year’s Advent of Code too.)

  public long calculatePart2()
  {
    sum = 0;
    recur("in", new Range[] {new Range(1, 4000), new Range(1, 4000), new Range(1, 4000), new Range(1, 4000)});
    return sum;
  }

  private void recur(final String ruleName, final Range[] ranges)
  {
    if (ruleName.equals("A"))
    {
      addToSum(ranges);
      return;
    }
    if (ruleName.equals("R"))  return;
    
    if (!allWorkflows.containsKey(ruleName)) throw new IllegalStateException("No rule found: " + ruleName);
    final WorkflowRule[] rules = allWorkflows.get(ruleName);
    for (final WorkflowRule rule: rules)
    {
      if (rule.comparison.equals(Comparison.GOTO))
      {
        recur(rule.workflowName, ranges.clone());
        return;
      }
      if (rule.comparison.equals(Comparison.ACCEPT))
      {
        addToSum(ranges);
        return;
      }
      if (rule.comparison.equals(Comparison.REJECT))
      {
        return;
      }
      final int index =
      switch(rule.variable)
      {
        case X -> 0;
        case M -> 1;
        case A -> 2;
        case S -> 3;
        default -> throw new IllegalArgumentException("Unexpected value: " + rule.variable);
      };
      final Range varRange = ranges[index];
      final Range lower = new Range(1, (int)rule.value); 
      final Range upper = new Range((int)rule.value, 4000);
      final Range subtractRange;
      if (rule.comparison.equals(Comparison.LT))
      {
        subtractRange = varRange.removeRangeSpecial(upper);
      }
      else // if (rule.comparison.equals(Comparison.GT))
      {
        subtractRange = varRange.removeRangeSpecial(lower);
      }
      ranges[index] = varRange.removeRangeSpecial(subtractRange);
      final Range[] newRanges = ranges.clone();
      newRanges[index] = subtractRange;
      recur(rule.workflowName, newRanges);
    }
  }

  private void addToSum(final Range[] ranges)
  {
    final long xVal = ranges[0].getSize();
    final long mVal = ranges[1].getSize();
    final long aVal = ranges[2].getSize();
    final long sVal = ranges[3].getSize();
    
    sum += (xVal*mVal*aVal*sVal);
  }

And the code for the part 2 method is similar but even simpler than for part 1 because we’re no using the second part of the data.

  public static Long part2(final String[] workflows)
  {
    final WorkflowManager wm = new WorkflowManager(workflows);
    return wm.calculatePart2();
  }

@Paul_F I fixed my Day 16 code that you mentioned and of course the output didn’t change… but I do prefer to have theoretically correct code for any input, so thanks for noticing and letting me know.