Learning Python with Advent of Code Walkthroughs

Dazbo's Advent of Code solutions, written in Python

Stalactites

Advent of Code 2022 - Day 14

Day 14: Regolith Reservoir

Useful Links

Concepts and Packages Demonstrated

classessetslist comprehensionmap

Problem Intro

We’re in some sort of cavern, and we’re working within a vertical slice of this cavern. Within this vertical slice, there are lines of rock which are either horizontal or vertical.

Our input data describes these lines:

498,4 -> 498,6 -> 496,6
503,4 -> 502,4 -> 502,9 -> 494,9

Here, each row of input data represents a set of connected lines. Grains of sand are falling from the ceiling, from point (500,0). We’re told that the sand falls until it comes to rest. If a grain of sand can move directly down, diagonally down left, or diagonally down right, it will.

Part 1

How many units of sand come to rest before sand starts flowing into the abyss below?

Here’s my strategy:

Soln:

So first of all, the Point and Line classes:

@dataclass(frozen=True)
class Point():
    x: int
    y: int
    
@dataclass(frozen=True)
class Line():
    start: Point
    end: Point

They have both been defined as immutable (i.e. unmodifiable) dataclasses. We need them to be immutable,so that we can store them in our sets later.

Now the method that reads the input data:

def process_lines(data):
    lines = set()
    for input_line in data:
        point_coords = input_line.split(" -> ")
        points = [Point(*map(int, coord.split(","))) for coord in point_coords]
        for i in range(1, len(points)):
            lines.add(Line(points[i-1], points[i]))
    
    return lines

Here I:

Now it’s time to construct our Grid class:

class Grid():
    SAND_ORIGIN = Point(500,0)
    SAND_VECTORS = [Point(0,1), Point(-1, 1), Point(1, 1)] # down, diagonal left, diagonal right
    
    def __init__(self, lines: set[Line]) -> None:
        self.rock: set[Point] = self._get_rock(lines)
        self.sand = set()
        self.min_x = min(point.x for point in self.rock)
        self.max_x = max(point.x for point in self.rock)
        self.min_y = min(point.y for point in self.rock)
        self.max_y = max(point.y for point in self.rock) 
        
    def _get_rock(self, lines: set[Line]):
        """ Process lines of rock. For each point in those lines, add a rock point to the set. """
        rock = set()
        for line in lines:
            x_start = min(line.start.x, line.end.x)
            x_end = max(line.start.x, line.end.x)
            y_start = min(line.start.y, line.end.y)
            y_end = max(line.start.y, line.end.y)
            rock.update({Point(x,y) for x in range(x_start, x_end+1)
                                    for y in range(y_start, y_end+1)})
        
        return rock
    
    def _is_empty(self, point: Point) -> bool:
        """ If this point is not rock or sand, return True. """
        if point not in self.rock and point not in self.sand:
            return True
        
        return False
    
    def drop_sand(self) -> Point:
        """ Sand falls down until it reaches an obstacle.
        If it reaches an obstacle, it will they try to fall diagonally left, then diagonally right. """
        grain = Grid.SAND_ORIGIN
        falling = True
        while falling:
            for v in Grid.SAND_VECTORS:
                candidate = Point(grain.x + v.x, grain.y + v.y)
                if self._is_empty(candidate):
                    if candidate.x < self.min_x or candidate.x > self.max_x: # we've reached fall-through
                        return None
                    
                    grain = candidate
                    self.min_y = min(self.min_y, grain.y)

                    break  # move out of the vectors loop
            else: # Get here if all our fall positions are full
                falling = False

        self.sand.add(grain)                 
        return grain
    
    def __str__(self) -> str:
        rows = []
        for y in range(self.min_y, self.max_y+1):
            row = f"{y:3d} "
            
            # print 1 col to either side
            for x in range(self.min_x-1, self.max_x+2):
                point = Point(x,y)
                if point in self.rock:
                    row += "#"
                    continue
                if point in self.sand:
                    row += "o"
                    continue
                row += "."
            
            rows.append(row)
            
        return "\n".join(rows)

How this works:

At this point, if we were to print the grid using the sample data, the output looks like this:

  4 .....#...##.
  5 .....#...#..
  6 ...###...#..
  7 .........#..
  8 .........#..
  9 .#########..

We do that with this code:

    grid = Grid(lines)
    
    adding_sand = True
    while adding_sand:
        adding_sand = grid.drop_sand()
    
    print(f"\n{grid}")
    print(f"Part 1: resting grains={len(grid.sand)}")

At this point, if we print the grid with the sample data, the output looks like this:

  1 ............
  2 .......o....
  3 ......ooo...
  4 .....#ooo##.
  5 ....o#ooo#..
  6 ...###ooo#..
  7 .....oooo#..
  8 ..o.ooooo#..
  9 .#########..

Part 2

Now we have a floor, at location y+2 relative to lowest rock. Sand will keep falling until we’ve blocked the origin at (500,0).

How many units of sand come to rest?

My code changes as follows:

The final code looks like this:

from dataclasses import dataclass
from pathlib import Path
import time

SCRIPT_DIR = Path(__file__).parent
# INPUT_FILE = Path(SCRIPT_DIR, "input/sample_input.txt")
INPUT_FILE = Path(SCRIPT_DIR, "input/input.txt")

@dataclass(frozen=True)
class Point():
    x: int
    y: int
    
@dataclass(frozen=True)
class Line():
    start: Point
    end: Point
    
class Grid():
    SAND_ORIGIN = Point(500,0)
    SAND_VECTORS = [Point(0,1), Point(-1, 1), Point(1, 1)] # down, diagonal left, diagonal right
    
    def __init__(self, lines: set[Line], floor=False) -> None:
        self.rock: set[Point] = self._get_rock(lines)
        self.sand = set()
        self.min_x = min(point.x for point in self.rock)
        self.max_x = max(point.x for point in self.rock)
        self.min_y = min(point.y for point in self.rock)
        self.max_y = max(point.y for point in self.rock)
        self._set_floor(floor)

    def _set_floor(self, floor: bool):
        self._floor = floor
        self._floor_y = self.max_y + 2
        self.max_y = self._floor_y        
        
    def _get_rock(self, lines: set[Line]):
        """ Process lines of rock. For each point in those lines, add a rock point to the set. """
        rock = set()
        for line in lines:
            x_start = min(line.start.x, line.end.x)
            x_end = max(line.start.x, line.end.x)
            y_start = min(line.start.y, line.end.y)
            y_end = max(line.start.y, line.end.y)
            rock.update({Point(x,y) for x in range(x_start, x_end+1)
                                    for y in range(y_start, y_end+1)})
        
        return rock
    
    def _is_empty(self, point: Point) -> bool:
        """ If this point is not rock or sand, return True. """
        if point not in self.rock and point not in self.sand:
            if self._floor:
                if point.y == self._floor_y:
                    return False
            return True
        
        return False
    
    def drop_sand(self) -> Point:
        """ Sand falls down until it reaches an obstacle.
        If it reaches an obstacle, it will they try to fall diagonally left, then diagonally right. """
        grain = Grid.SAND_ORIGIN
        falling = True
        while falling:
            for v in Grid.SAND_VECTORS:
                candidate = Point(grain.x + v.x, grain.y + v.y)
                if self._is_empty(candidate):
                    if not self._floor and candidate.y == self._floor_y: # we've reached fall-through
                        return None
                    else: # there is a floor; expand the grid
                        self.min_x = min(self.min_x, grain.x - 1)
                        self.max_x = max(self.max_x, grain.x + 1)
                        self.min_y = min(self.min_y, grain.y)
                    
                    grain = candidate
                    self.min_y = min(self.min_y, grain.y)

                    break  # move out of the vectors loop
            else: # Get here if all our fall positions are full
                falling = False

        self.sand.add(grain)
        if grain == Grid.SAND_ORIGIN:
            return None                  
        
        return grain
    
    def __str__(self) -> str:
        rows = []
        for y in range(self.min_y, self.max_y+1):
            row = f"{y:3d} "
            
            # print 1 col to either side
            for x in range(self.min_x-1, self.max_x+2):
                point = Point(x,y)
                if point in self.rock:
                    row += "#"
                    continue
                if self._floor and y == self._floor_y:
                    row += "#"
                    continue
                if point in self.sand:
                    row += "o"
                    continue
                row += "."
            
            rows.append(row)
            
        return "\n".join(rows)

def main():
    with open(INPUT_FILE, mode="rt") as f:
        data = f.read().splitlines()
        
    lines = process_lines(data)
    
    # Part 1
    grid = Grid(lines)
    
    adding_sand = True
    while adding_sand:
        adding_sand = grid.drop_sand()
        # print(f"\n{grid}")
    
    print(f"Part 1: resting grains={len(grid.sand)}")
    
    # Part 2
    grid = Grid(lines, floor=True)
    adding_sand = True    
    while adding_sand:
        adding_sand = grid.drop_sand()
        # print(f"\n{grid}")
        
    print(f"Part 2: resting grains={len(grid.sand)}")        
        
def process_lines(data):
    lines = set()
    for input_line in data:
        point_coords = input_line.split(" -> ")
        points = [Point(*map(int, coord.split(","))) for coord in point_coords]
        for i in range(1, len(points)):
            lines.add(Line(points[i-1], points[i]))
    
    return lines

if __name__ == "__main__":
    t1 = time.perf_counter()
    main()
    t2 = time.perf_counter()
    print(f"Execution time: {t2 - t1:0.4f} seconds")

Results

The final output looks like this:

Part 1: resting grains=888
Part 2: resting grains=26461
Execution time: 6.2650 seconds

It’s a bit slow. I’m sure there are optimisations I could make. But I’m knackered, so I’m not going to!