The sixth day of Advent of Code was yesterday 06/12/2024! If you’re here for the first time today, don’t forget to read the posts about Day 1, Day 2, Day 3, Day 4, and Day 5.

I classified the sixth problem as medium (part 1) and difficult (part 2).

Tips#

  • Again, a maze problem where each character can be seen as an element in a matrix.

  • Since you’ll constantly need to check maze boundaries, valid positions, and obstacles, I recommend creating a class to represent the maze.

  • In the first part of the answer, you’ll find a class to represent the path and the maze itself. You can use them to visualize the path, as shown in the problem.

  • A class to work with 2D vectors also greatly simplifies the implementation. If you visualize the current position as a vector (y, x), the new position can be calculated by adding another vector with the movement direction (dy, dx)

  • Part 1 of the problem is relatively easy but requires you to traverse the matrix following precise instructions and keeping track of the path.

  • You can implement direction changes (90 degrees) at each obstacle using a list of tuples representing directions. Using modulo properties, you can ensure the position is always valid in the list.

  • Part 2 is more complicated because you must generate obstacles and see how the program behaves. Since the goal is to put the guard in an infinite loop, you need to detect when a cycle is formed (or your program won’t terminate!). In a graph, a cycle can be seen when the path returns to visit a previously visited node. As in our problem we have a maze and each position can be visited in different directions, to detect a cycle, you need to check if the position has been visited before with the same passing direction.

  • To generate obstacles, you must “speculate” that a new obstacle can be generated at each position of the guard’s normal path, as long as it’s not the initial position and isn’t already occupied by an obstacle.

  • You can create a new maze with the new obstacle and check if the path enters a loop.

  • The complexity of part 2 is high, something like O(m x n), taking about 200 seconds to complete on my computer.

How much Python do you need to know?#

The chapters refer to my book Introduction to Programming with Python.

  • Modulo properties - Chapter 2
  • Lists, Tuples, Sets, and Dictionaries - Chapter 6
  • Recursive functions - Chapter 8
  • Graphs

Answer#

from aux import string_to_matrix, read_file_without_blank_lines, Vector2d

INPUT = """
....#.....
.........#
..........
..#.......
.......#..
..........
.#..^.....
........#.
#.........
......#...
"""

GUARD = "^"
OBSTACLE = "#"

DIRECTIONS = [Vector2d(*v) for v in [(-1, 0), (0, 1), (1, 0), (0, -1)]]


class Path:
    def __init__(self):
        self.path = {}

    def __len__(self):
        return len(self.path)

    def visited(self, position: Vector2d, direction: int) -> bool:
        if position in self.path and direction in self.path[position]:
            return True
        return False

    def add(self, position: Vector2d, direction: int):
        if position in self.path:
            self.path[position].add(direction)
        else:
            self.path[position] = {direction}


class Maze:
    def __init__(self, maze):
        self.max_y = len(maze)
        self.max_x = len(maze[0])
        self.orig_maze = maze
        self.maze = [list(line) for line in maze]

    def copy(self):
        return Maze(self.orig_maze)

    def find_guard(self) -> Vector2d:
        for y, line in enumerate(self.maze):
            if "^" in line:
                x = line.index(GUARD)
                return Vector2d(y, x)
        raise ValueError("No guard found in maze")

    def valid_position(self, position: Vector2d) -> bool:
        return 0 <= position.y < self.max_y and 0 <= position.x < self.max_x

    def draw(self, path, character="X"):
        for position, directions in path.items():
            if directions:
                h = {1, 3} & directions
                v = {0, 2} & directions
                dchar = "+" if h and v else "-" if h else "|"
                self.maze[position.y][position.x] = dchar
            else:
                self.maze[position.y][position.x] = character

    def __str__(self):
        return "\n".join(["".join(line) for line in self.maze])

    def __setitem__(self, yx, value):
        self.maze[yx[0]][yx[1]] = value

    def __getitem__(self, yx):
        return self.maze[yx[0]][yx[1]]


def do_patrol(maze: Maze) -> tuple[Path, bool]:
    path_taken = Path()
    current_direction = 0
    position = maze.find_guard()
    while maze.valid_position(new_position := position + DIRECTIONS[current_direction]):
        path_taken.add(position, current_direction)
        if maze[new_position.y, new_position.x] == OBSTACLE:
            current_direction = (current_direction + 1) % 4
        else:
            position = new_position
            if path_taken.visited(position, current_direction):
                return path_taken, True
            path_taken.add(position, current_direction)
    return path_taken, False


def find_possible_obstacles(maze: Maze):
    current_direction = 0
    possible_obstacles = set()
    position = initial_position = maze.find_guard()
    while maze.valid_position(new_position := position + DIRECTIONS[current_direction]):
        if maze[new_position.y, new_position.x] == OBSTACLE:
            current_direction = (current_direction + 1) % 4
        else:
            position = new_position
            if position != initial_position:
                new_maze = maze.copy()
                new_maze[position.y, position.x] = OBSTACLE
                _, cycle_detected = do_patrol(new_maze)
                if cycle_detected:
                    possible_obstacles.add(position)
    return possible_obstacles


# Part 1
# Test vector
test_maze = string_to_matrix(INPUT)

path, _ = do_patrol(Maze(test_maze))
print("Unique positions (test):", len(path))
assert len(path) == 41

input_maze = string_to_matrix(read_file_without_blank_lines("06/input.txt"))
path, _ = do_patrol(Maze(input_maze))
print("Unique positions (part 1):", len(path))
assert len(path) == 4696

# Part 2

possible_obstacles = find_possible_obstacles(Maze(test_maze))
print("Possible obstacles (test part 2):", len(possible_obstacles))
assert len(possible_obstacles) == 6

possible_obstacles = find_possible_obstacles(Maze(input_maze))
print("Possible obstacles (part 2):", len(possible_obstacles))
assert len(possible_obstacles) >= 1443

# Output
# Unique positions (test): 41
# Unique positions (part 1): 4696
# Possible obstacles (test part 2): 6
# Possible obstacles (part 2): 1443

Module aux.py#

Partial listing, to be added to other helper functions published in other articles.

class Vector2d:
    def __init__(self, y, x):
        self.x = x
        self.y = y

    def copy(self):
        return Vector2d(self.y, self.x)

    def __add__(self, other):
        return Vector2d(self.y + other.y, self.x + other.x)

    def __str__(self):
        return f"({self.y}, {self.x})"

    def __repr__(self):
        return f"Vector2d({self.y}, {self.x})"

    def __hash__(self):
        return hash(f"({self.y}, {self.x})")

    def __eq__(self, other):        
        if isinstance(other, Vector2d):
            return self.x == other.x and self.y == other.y
        return False