# Hydrothermal Venture

`2021-12-05`

Original Prompt ## Part 1

While it may look like we need to reach for our grid from yesterday, it’s not actually the case. Really we’re just tracking segments, which are made of points (which are in turn an `(x, y)`

pair. While it’s not necessary, I have a feeling we’ll thank ourselves later if we do the parsing and filtering with a lot of structure. Let’s make some classes!

First is the `Point`

. We could use a regular class, but we’ll eventually want to use `Point`

instances as keys in a dict. You can only do that with a user-defined class if you define some special methods (specifically `__eq__`

and `__hash__`

, so Python knows how to store and compare them).

But, we’re not here to do extra work, let’s let the language handle that for us. Enter one of my favorite Python classes, the `dataclass`

. I’ve written about them at length, but the gist is that they’re a class that automatically creates a number of methods for you. And wouldn’t you guess, they’ll handle the aforementioned `__eq__`

and `__hash__`

! Here it is:

```
from dataclasses import dataclass
@dataclass(frozen=True, eq=True)class Point: x: int y: int
```

That’s all it takes! The `__init__`

function is also handled for us, so we’ll be able to call `Point(1, 2)`

like we’d expect. Truly :sparkles: magic.

Next is our `Segment`

. Each should have a `start`

and `stop`

. This should look familiar:

`@dataclassclass Segment: start: Point stop: Point`

Note that we didn’t need the extra arguments to the `dataclass`

decorator - we’re not doing anything fancy with this class, so the defaults are fine.

Next is input parsing. We need a list of `Segment`

s and a way to easily make `Points`

from a string:

```
class Point: ...
@staticmethod def from_input(input_: str) -> "Point": x, y = input_.split(",") return Point(int(x), int(y))
...
segments: List[Segment] = []for line in self.input: start, stop = line.split(" -> ") s = Segment(Point.from_input(start), Point.from_input(stop))
```

Because a dataclass’s `__init__`

takes only the attributes it expects (in this case, `(x, y)`

, we want a little helper to turn `"1,2"`

into a `Point`

. That `staticmethod`

is just that - a helper attached to a class (but bound to the class *itself* not instances; note the lack of `self`

).

At least for now, we only want to worry about the segments which are flat, so let’s add a check for that:

```
class Segment: ...
@property def is_flat(self) -> bool: """ Exactly vertical or horizontal """ return self.start.x == self.stop.x or self.start.y == self.stop.y
for line in self.input: ... if s.is_flat: segments.append(s)
```

Now that we have the groundwork, it’s time for the final touches to solve the puzzle. Ultimately, we need to count how many time we see each point and then return the number of points we see more than once. That sounds like a job for a `Counter`

!

Before we get there though, we need a way to turn a `Segment`

into a bunch of `Point`

s. This code firmly falls into the category of “ugly, but it works”:

```
class Segment: ...
@property def points(self) -> List[Point]: if self.start.x == self.stop.x: # vertical line low = min(self.start.y, self.stop.y) high = max(self.start.y, self.stop.y) return [Point(self.start.x, y) for y in range(low, high + 1)]
# horizontal line low = min(self.start.x, self.stop.x) high = max(self.start.x, self.stop.x) return [Point(x, self.start.y) for x in range(low, high + 1)]
```

Note `high + 1`

, since `range`

doesn’t include the end of the ragne.

Last is our counting:

```
from collections import Counter
c = Counter()for segment in segments: c.update(segment.points)
return len([v for v in c.values() if v > 1])
```

Because a `Counter`

is actually a `dict`

, all the methods we’re used to using still work.

That’s probably the most code we’ve written all week, but it’s hopefully easy enough to follow. Onwards!

## Part 2

I had a feeing we were going to get to diagonals, and here we are. The good news is, we set our self up for success! There are only a couple of things we need to tweak.

First, we need an `is_diagonal`

method. A quick trip to StackExchange reminds us that there’s a simple formula:

```
class Segment: ...
@property def is_diagonal(self) -> bool: """ Exactly a 45 degree angle """ return abs(self.start.x - self.stop.x) == abs(self.start.y - self.stop.y)
```

Next, our `.points`

property needs to account for diagonals. I’ve done some refactoring to help the code re-use. It’s still a little verbose, but I’m happier with it now:

```
class Segment: ...
@property def points(self) -> List[Point]: x_min = min(self.start.x, self.stop.x) x_max = max(self.start.x, self.stop.x) x_vals = range(x_min, x_max + 1)
y_min = min(self.start.y, self.stop.y) y_max = max(self.start.y, self.stop.y) y_vals = range(y_min, y_max + 1)
if self.is_flat: if self.start.x == self.stop.x: return [Point(self.start.x, y) for y in y_vals] return [Point(x, self.start.y) for x in x_vals]
if self.start.x > self.stop.x: x_vals = reversed(x_vals)
if self.start.y > self.stop.y: y_vals = reversed(y_vals)
return [Point(x, y) for x, y in zip(x_vals, y_vals)]
```

Nothing too surprising. The tough thing for me was working out the logic for the diagonal ranges when the `range`

function doesn’t handle `stop > start`

. Zipping them together gives us the exact right number of steps, and in the right increments. It actually ended up pretty clean.

Lastly is our solve loop. We can do both parts in one swing here:

```
# moved to a functiondef num_repeated(c: Counter) -> int: return len([v for v in c.values() if v > 1])
for line in self.input: ... if s.is_flat or s.is_diagonal: # changed! segments.append(s)
part_1 = Counter()part_2 = Counter()for segment in segments: part_2.update(segment.points) if segment.is_flat: part_1.update(segment.points)
return num_repeated(part_1), num_repeated(part_2)
```

Part 2 counts every segment, while part 1 only counts the flat ones. That should do it!

Fun trivia - this is the first day where running my solution isn’t instant on my computer (there’s a very short but perceptible pause). I don’t imagine this’ll be the last time that happens.

Upon further reflection, this solution is more memory-heavy than it needs to be. Rather than store all the objects and then count all the objects, we can count as we go. The final loop could be changed to be:

```
part_1 = Counter()part_2 = Counter()
for line in self.input: start, stop = line.split(" -> ") s = Segment(Point.from_input(start), Point.from_input(stop)) if s.is_flat: part_1.update(s.points) part_2.update(s.points) if s.is_diagonal: part_2.update(s.points)
return num_repeated(part_1), num_repeated(part_2)
```

I tested it and it ran… at the exact same speed. But it certainly used less memory. Doesn’t seem like it’s super easy to tell exactly how much less, but we can feel a little better about ourselves.