# Advent of Code 2023, Day 3

This problem asks us to process a multi-line string representing the engine of a gondola.
The input is made up of digits and symbols with `.`

s representing empty space in the engine.
This input is given as an example.

```
467..114..
...*......
..35..633.
......#...
617*......
.....+.58.
..592.....
......755.
...$.*....
.664.598..
```

For part 1, we need to sum all of the numbers that have a symbol adjacent to them. For part 2,
we need to sum the product of all numbers adjacent to a `"*"`

if the `"*"`

has exactly two numbers
adjacent to it.

### Part 1

I didn’t need to do much to the puzzle input in order to parse it for the information I needed. I just split it up into a list by newlines and iterated over the values. When I encountered a digit I began feeding those digits into a buffer until a non-digit value was encountered.

```
def part_1(engine_rows: list[str], symbols: set[str]) -> int:
engine_dimensions = (len(engine_rows), len(engine_rows[0]))
reading_number = False
subject_buffer = set()
number_buffer = ""
total = 0
for row, vals in enumerate(engine_rows):
for col, char in enumerate(vals):
if char in digits:
subject_buffer.add((row, col))
number_buffer += char
reading_number = True
else:
if reading_number:
if has_symbol(
engine_rows, border(subject_buffer, engine_dimensions), symbols
):
total += int(number_buffer)
subject_buffer.clear()
number_buffer = ""
reading_number = False
return total
```

This approach is one I’ve used before when parsing in data and trying to grab chunks of it. There is some global toggle that determines if the data should be read into the buffer. When a piece of data is encountered that ends the chunk that should be passed in, in this case a non-digit, the toggle is switched off, the chunk is stored from the buffer, and the buffer is cleared.

In this implementation, when the end of the number is reached, I add the number to the running total if the bordering characters include symbols.

I do this in two parts. First I find the set of locations in the engine which border the number, then I check those locations to see if they include a symbol.

```
#[...]
if has_symbol(
engine_rows, border(subject_buffer, engine_dimensions), symbols
):
total += int(number_buffer)
#[...]
def border(
subject: set[tuple[int, int]], engine_dimensions: tuple[int, int]
) -> set[tuple[int, int]]:
height, width = engine_dimensions
borders = set()
for cell in subject:
row, col = cell[0], cell[1]
# Iterate clockwise around the location
if row > 0 and col > 0:
borders.add((row - 1, col - 1)) # above left
if row > 0:
borders.add((row - 1, col)) # above center
if row > 0 and col < width - 1:
borders.add((row - 1, col + 1)) # above right
if col < width - 1:
borders.add((row, col + 1)) # center right
if row < height - 1 and col < width - 1:
borders.add((row + 1, col + 1)) # below right
if row < height - 1:
borders.add((row + 1, col)) # below center
if row < height - 1 and col > 0:
borders.add((row + 1, col - 1)) # below left
if col > 0:
borders.add((row, col - 1)) # center left
return borders - subject
def has_symbol(
engine_rows: list[str], locations: set[tuple[int, int]], symbols: set[str]
) -> bool:
for loc in locations:
if engine_rows[loc[0]][loc[1]] in symbols:
return True
return False
```

I was particularly happy with the `border`

function because I took an approach I hadn’t thought of when I did a similar
problem several years ago. I took the 8 cells that surround each digit in the number as a set and then subtracted from that
the values that make up the number. This leads to a fairly clean implementation that would work generally on any shape.

### Part 2

I took the wrong approach initially with part 2. I first went looking for all of the gears in the puzzle, then used my border finding code to grab digits adjacent to the gears. The problem here is that I had to add a lot of edge-case logic for handling if an adjacent digit was part of a number in another adjacent digit or if it represented a separate number. I got deep into some globbing of numbers by iterating back to the start of the number and then forward from the center of the number. It was a mess.

I took some time away from the problem and decided to approach it in the opposite manner. I created a class called `PartNumber`

that stores the full number and the cells that make up that number. A cell here is just a location in the engine. I call this same
concept multiple things in the code depending on when I wrote it.

```
class PartNumber:
def __init__(self, value: int, cells: set[tuple[int, int]]):
self.value = value
self.cells = cells
def __repr__(self) -> str:
return f"{self.value}\t{self.cells=}"
```

This class allowed me to store the numerical value and the full location of every part number. This was all I needed to then map the location of every gear in the engine to its adjacent numbers. This allowed me to reuse the shape of my solution to part 1 and avoided all the nasty number globbing.

```
def part_2(engine_rows: list[str]) -> int:
engine_dimensions = (len(engine_rows), len(engine_rows[0]))
reading_number = False
subject_buffer = set()
number_buffer = ""
numbers = []
for row, vals in enumerate(engine_rows):
for col, char in enumerate(vals):
if char in digits:
subject_buffer.add((row, col))
number_buffer += char
reading_number = True
else:
if reading_number:
numbers.append(
PartNumber(int(number_buffer), subject_buffer.copy())
)
subject_buffer.clear()
number_buffer = ""
reading_number = False
gears = {}
for number in numbers:
for neighbor in border(number.cells, engine_dimensions):
if engine_rows[neighbor[0]][neighbor[1]] == "*":
if neighbor in gears.keys():
gears[neighbor].append(number)
else:
gears[neighbor] = [number]
total = 0
for _, numbers in gears.items():
if len(numbers) == 2:
total += numbers[0].value * numbers[1].value
return total
```

To get the answer, I iterated over all of the gears summed the product of numbers for gears that were adjacent to exactly two numbers.