Skip to content

Every Christmas, we muster up the motivation to try

Notifications You must be signed in to change notification settings

rtshkmr/Advent-of-Code

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 

Repository files navigation

🎄 Advent_of_code 🎄

🎄 Every Christmas, we muster up the motivation to do AOC again 🎄

Table of Contents

Year 2024

Tooling Info for this year’s advent of code

I’m just going to use org-babel for this, intending to just use the new python knowledge gained from using the “Fluent Python” book.

Fetching the day’s inputs

I have an input downloader script at ./io/input_fetcher.sh. It looks something like this:

#!/bin/bash

# Check if the correct number of arguments is provided
if [ "$#" -ne 4 ]; then
    echo "Usage: $0 <year> <day> <session_cookie> <output_directory>"
    exit 1
fi

YEAR=$1
DAY=$2
SESSION_COOKIE=$3
OUTPUT_DIR=$4

# Create the output directory if it doesn't exist
mkdir -p "$OUTPUT_DIR"

# Download the input file for the specified year and day
curl "https://adventofcode.com/$YEAR/day/$DAY/input" \
  --cookie "session=$SESSION_COOKIE" \
  -o "$OUTPUT_DIR/${YEAR}_day${DAY}_input.txt"

if [ $? -eq 0 ]; then
    echo "Input for Day $DAY of Year $YEAR downloaded successfully to $OUTPUT_DIR."
else
    echo "Failed to download input for Day $DAY of Year $YEAR."
fi

Here’s how to use it:

pwd;
./io/input_fetcher.sh 2024 1 <your_session_cookie> ./io/

Python Efficient File Reading

Python has a bunch of ways to read files:

  1. bufferred reading
  2. chunking
  3. using a mmap
    • will handle huge files, even those that can’t be mapped onto the RAM as well
    • here’s some ways to setup the mmap (e.g. with file-locking)
  4. iter over file obj
    • good for text files
class Reader:
    def __init__(self, filename):
        self.filename = filename
        self.url = f"./io/{filename}"
        return

    def apply_fn_to_lines(self, fn):
        with open(self.url) as f:
            return [fn(line) for line in f]

Day 1: Historian Hysteria – Edit Distance and Similarity Score

[2024-12-02 Mon] Part 1: get edit distance This is about a very primitive edit-distance metric. It’s all to do with numbers.

Part 2: get similarity score, similarly primitive.

I think this is a good example of python’s expressiveness as a language.

from collections import Counter
class Solution:
    def reader(self, url):
        with open(url) as f:
            data = f.read()
            split_lines = (line.split() for line in data.split("\n"))
            tups = ((int(line[0]), int(line[1])) for line in split_lines if line)
            left, right = [], []
            for left_val, right_val in tups:
                left.append(left_val)
                right.append(right_val)

            return left, right


    # only to be used for the examples
    def parse_input(self, input):
        split_lines = (line.split() for line in input.split("\n"))

        return ((int(left), int(right)) for left, right in split_lines)

    def transform_input(self, input):
        left, right = [], []
        lines = self.parse_input(input)
        for left_val, right_val in lines:
            left.append(left_val)
            right.append(right_val)

        return left, right

    def get_edit_distance(self, left_vals, right_vals):
        distances = [abs(left - right) for left, right in zip(sorted(left_vals), sorted(right_vals))]

        return sum(distances)

    def get_similarity_score(self, left_vals, right_vals):
        right_counts = Counter(right_vals)
        scores = (val * right_counts[val]  for val in left_vals)

        return sum(scores)

input = \
"""3   4
4   3
2   5
1   3
3   9
3   3"""
url = "./io/2024_day1_input.txt"
s = Solution()
# test small inputs:
small_input = s.transform_input(input)
print(s.get_edit_distance(*small_input))
print(s.get_similarity_score(*small_input))

left, right = s.reader(url)
ans_part_1 = s.get_edit_distance(left, right)
ans_part_2 = s.get_similarity_score(left, right)

print(f'answer for part 1: {ans_part_1}')
print(f'answer for part 2: {ans_part_2}')

Day 2: Red-Nosed Reports – Monotonicity

[2024-12-02 Mon] Notes:

  1. python’s walrus operator to set aliases is convenient!
    • ref:
    • e.g. used in the solution below:
      part_2_ans = len([outcome for report in reports if (outcome := s.is_report_tolerably_safe(report))])
              
class Solution:
    def read_small(self):
        small_input = [[7,6,4,2,1], [1,2,7,8,9], [9,7,6,2,1], [1,3,2,4,5], [8,6,4,4,1], [1,3,6,7,9] ]
        return small_input

    def read(self, url):
        with open(url) as f:
            data = f.read()
            split_lines = (line.split() for line in data.split("\n"))
            numbered_reports = []
            for line in split_lines:
                numbered_reports.append([int(level) for level in line])

            return numbered_reports

    def get_first_faulty_level_in_report(self, report):
        num_levels = len(report)
        if num_levels == 1:
            return num_levels # indicates that all levels have been swept

        prev_direction = None
        for i in range(1, len(report)):
            jump = report[i] - report[i - 1]
            is_legal_jump = abs(jump) >= 1 and abs(jump) <= 3
            if not is_legal_jump:
                return i
            if jump == 0: # not monotonically increasing, is a plateau
                return i
            is_same_direction = (jump >= 0) == (prev_direction >= 0) if prev_direction else True
            if not is_same_direction:
                return i
            prev_direction = jump

        return num_levels

    def is_report_safe(self, report):
        if not report:
            return False
        faulty_idx = self.get_first_faulty_level_in_report(report)
        if faulty_idx == len(report):
            return True

        return False

    def is_report_tolerably_safe(self, report):
        if self.is_report_safe(report):
            return True
        for skip_idx in range(len(report)):
            edited_report = report[:skip_idx] + report[skip_idx + 1:]
            if self.is_report_safe(edited_report):
                return True
        return False

s = Solution()
small_input =  s.read_small()
num_safe_reports = len([outcome for report in small_input if (outcome := s.is_report_safe(report))])
print(f"small input ans 1: {num_safe_reports}")
print(f"small input ans 2: {len([outcome for report in small_input if (outcome := s.is_report_tolerably_safe(report))])}")


reports = s.read("./io/2024_day2_input.txt")
part_1_ans = len([outcome for report in reports if (outcome := s.is_report_safe(report))])
print(f"part 1 ans: {part_1_ans}")

part_2_ans = len([outcome for report in reports if (outcome := s.is_report_tolerably_safe(report))])
print(f"part 2 ans: {part_2_ans}")

Day 3: Mull It Over – Regex-based Parsing

Completed [2024-12-03 Tue]

Approach:

  1. define the correct regex, define capture groups and use captured values for doing the math operations.
  2. I have 2 ways of doing it: A) original and menial way of defining segment buffers and operating on them and B) using a single pass regex named groups A) Original Versionvalid segments are determined by <POSITIVE><VALID_SEGMENT><POSITIVE/NEGATIVE> where:
    • POSITIVE: “do”
    • NEGATIVE: “don’t”

    After extracting out valid segments, parse them as though they are separate inputs to get partial sums then combine them.

    B) use named groups in the regex pattern: pattern =r"(?P<do>do\(\))|(?P<dont>don't\(\))|mul\((?P<x>\d{1,3}),(?P<y>\d{1,3})\)"

Notes:

  1. named regex groups make life easy see named capture groups
  2. backreferences are a good regex capability as well: see backrefs
import re

class Solution:
    def read_small(self):
        input = "xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5))"
        return input
    def read_small_2(self):
        input = "xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))"
        return input

    def read(self, url="./io/2024_day3_input.txt"):
        with open(url) as f:
            data = f.read()

            return data

    def parse_input(self, input):
        pattern = r"mul\((\d{1,3}),(\d{1,3})\)"
        matches = re.findall(pattern, input)
        partial_multiples = (int(x) * int(y) for x, y in matches)

        return sum(partial_multiples)

    # single-pass, uses named regex capture groups:
    def parse_valid_segments(self, input: str) -> int:
        # Combined regex to match mul instructions and do/don't commands
        pattern = r"(?P<do>do\(\))|(?P<dont>don't\(\))|mul\((?P<x>\d{1,3}),(?P<y>\d{1,3})\)"

        segments = []
        is_enabled = True  # Start with multiplications enabled

        for match in re.finditer(pattern, input):
            if match.group("do"):
                is_enabled = True
            elif match.group("dont"):
                is_enabled = False
            elif match.group("x") and match.group("y"):  # Check if it's a mul instruction
                if is_enabled:
                    x = match.group("x")
                    y = match.group("y")
                    segments.append((x, y))  # Capture x and y

        # Calculate partial sums from valid segments
        partial_sums = (int(x) * int(y) for x, y in segments)
        return sum(partial_sums)


    # convoluted version:
    def parse_valid_segments_(self, input):
        input_len = len(input)
        do_or_dont_pattern = r"do\(\)|don\'t\(\)"

        # gather valid segments:
        segments = []
        curr_segment_start = 0
        is_ignoring_current_segment = False
        for match in (matches := re.finditer(do_or_dont_pattern, input)):
            match_start, match_end = match.span()

            matched_do = match.group() == "do()"
            matched_dont = match.group() == "don't()"

            if matched_do and not is_ignoring_current_segment:
                segments.append((curr_segment_start, match_start))
                curr_segment_start = match_end
            if matched_do and is_ignoring_current_segment:
                curr_segment_start = match_end
                is_ignoring_current_segment = False
            if matched_dont and not is_ignoring_current_segment:
                segments.append((curr_segment_start, match_start))
                is_ignoring_current_segment = True

        # remember possible last part of the buffer:
        should_consider_remaining_end_of_buffer = not is_ignoring_current_segment and curr_segment_start < input_len - 1
        if should_consider_remaining_end_of_buffer:
            segments.append((curr_segment_start, input_len))

        valid_segments = (input[start:end] for start, end in segments)
        partial_sums = (self.parse_input(segment) for segment in valid_segments)

        return sum(partial_sums)

s = Solution()
small_input = s.read_small()
print(f"small input: { small_input }")
print(f"small input ans: {s.parse_input(small_input)}")

actual_input = s.read()
part_1_ans = s.parse_input(actual_input)
print(f"Part 1 ans: {part_1_ans}")

small_input_2 = s.read_small_2()
small_part_2 = s.parse_valid_segments(small_input_2)
part_2_ans = s.parse_valid_segments(actual_input)
print(f"Part 2 ans: {part_2_ans}")

Day 4: Ceres Search - String Searching

Correct Solution

Learnings:

  1. for directions, it’s easier to define the unit vectors rather than fixed-length vectors and then rely on the correct slicing logic. Should have been more plastic in my solution, and have moved away from the slicing version earlier (harder to debug).
class Solution:
    # it's easier to define directions as unit vectors
    directions = [
        (0, 1),   # right
        (0, -1),  # left
        (1, 0),   # down
        (-1, 0),  # up
        (1, 1),   # down-right
        (-1, -1), # up-left
        (1, -1),  # down-left
        (-1, 1)   # up-right
    ]
    diagonals = [[
            (-1, -1), # up-left
            (1, 1),   # down-right
        ],
        [
            (1, -1),  # down-left
            (-1, 1)   # up-right
        ]

    ]

    def read_small(self):
        input = """MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX"""
        matrix = [list(line) for line in input.split("\n") if line]
        return matrix

    def read_input(self, url="./io/2024_day4_input.txt"):
        with open(url) as f:
            input = f.read()
            matrix = [list(line) for line in input.split("\n") if line]
            num_rows, num_cols = len(matrix), len(matrix[0])
            print(f"Read a matrix of shape {num_rows}x{num_cols}\n")

            return matrix

    def is_coord_bounded_in_matrix(self, coord, matrix):
        r, c = coord
        num_rows, num_cols = len(matrix), len(matrix[0]) return 0 <= r < num_rows and 0 <= c < num_cols

    def count_xmas_word_hits(self, coord, matrix):
        target = "XMAS"
        r, c = coord
        hits = 0

        # Check each direction for the word "XMAS", iteratively collect chars
        for dx, dy in self.directions:
            chars = []
            for i in range(len(target)):
                new_r = r + ( i * dx )
                new_c = c + ( i * dy )

                if not self.is_coord_bounded_in_matrix((new_r, new_c), matrix):
                    break

                chars.append(matrix[new_r][new_c])

            if ''.join(chars) == target:
                hits += 1

        return hits

    def is_x_mas_hit(self, coord, matrix):
        target = "MAS"
        reversed_target = "".join(list(reversed(target)))
        r, c = coord
        is_hit = True
        for vectors in self.diagonals:
            coords = [(r + dx, c + dy)for dx, dy in vectors]

            is_invalid_center_candidate = any([not self.is_coord_bounded_in_matrix(coord , matrix) for coord in coords])
            if is_invalid_center_candidate:
                return False

            letters = [matrix[r][c] for r, c in coords]
            word = f"{letters[0]}A{letters[1]}"
            is_mas = ( word == target ) or ( word == reversed_target )
            is_hit = is_hit and is_mas

        return is_hit

    def solve_part_1(self, matrix):
        num_rows, num_cols = len(matrix), len(matrix[0])
        total_hits = sum(self.count_xmas_word_hits((r, c), matrix)
                         for r in range(num_rows)
                         for c in range(num_cols)
                         if matrix[r][c] == 'X')

        return total_hits

    def solve_part_2(self, matrix):
        num_rows, num_cols = len(matrix), len(matrix[0])
        hits = [True for r in range(num_rows) for c in range(num_cols)
                if matrix[r][c] == 'A' and
                self.is_x_mas_hit((r, c), matrix)]

        return len(hits)



s = Solution()
small_input_matrix = s.read_small()
small_ans_part_1 = s.solve_part_1(small_input_matrix)
print(f"Small ans part 1: {small_ans_part_1}")  # Expected output: 18
print(f"Small ans part 2: {s.solve_part_2(small_input_matrix)}")

actual_input = s.read_input()
print(f"Part 1 Ans: {s.solve_part_1(actual_input)}")
print(f"Part 2 Ans: {s.solve_part_2(actual_input)}")

Incorrect attempt for part 1

This version did some undercounting. It would work on the small input example but not on the actual text input.

So this version is unnecessarily complicated because:

  1. it relied heavily on correct slicing logic. On hindsight, it’s easier to just rely on unit vectors for direction and iteratively collect the slice.
  2. the backward slice was likely to be the cause of the undercounting
class Solution:
    directions = [ #inclusive range
            (-3, 0), # top
            (3, 0), # bottom
            (0, 3), # right
            (0, -3), # left
            (-3, -3), # top left
            (3, -3), # bottom left
            (-3, 3), # top right
            (3, 3), # bottom right
    ]

    def read_small(self):
        input = """MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX"""
        matrix = [list(line) for line in input.split("\n") if line]
        num_rows, num_cols = len(matrix), len(matrix[0])
        print(f"Read a matrix of shape {num_rows}x{num_cols}\n")

        return matrix

    def read_input(self, url="./io/2024_day4_input.txt"):
        with open(url) as f:
            input = f.read()
            matrix = [list(line) for line in input.split("\n") if line]
            num_rows, num_cols = len(matrix), len(matrix[0])
            print(f"Read a matrix of shape {num_rows}x{num_cols}\n")

            return matrix

    def is_coord_bounded_in_matrix(self, coord, matrix):
        r, c = coord
        num_rows, num_cols = len(matrix), len(matrix[0])

        return r >= 0 and r < num_rows and c >= 0 and c < num_cols


    def count_xmas_word_hits(self, coord, matrix):
        target ="XMAS"
        r, c = coord
        hits = 0

        for direction in self.directions:
            dx, dy = direction
            end_coord = (r + dx, c + dy)
            if not self.is_coord_bounded_in_matrix(end_coord, matrix):
                continue

            match direction:
                case (x, 0): # it's a column slice
                    rows_slice = matrix[r: r + dx + 1] if dx > 0 else matrix[r: r + dx - 1: -1]
                    slice = "".join((row[c] for row in rows_slice))
                    # print(f"==> COLUMN SLICE {direction} \n\
                    # sliced out {len(rows_slice)} rows for column slicing\n\
                    # slice = {slice}")
                    hits += 1 if slice == target else 0

                case (0, y): # it's a row slice:
                    row = matrix[r]
                    slice = "".join(row[c: c + dy + 1] if dy > 0 else row[c:c + dy - 1:-1])
                    # print(f"==> ROW SLICE {direction} \n\
                    # slice = {slice}")
                    hits += 1 if slice == target else 0

                case _: # it's a diagonal slice:
                    rows_slice = matrix[r: r + dx + 1] if dx > 0 else matrix[r: r + dx - 1: -1]
                    dy_direction = 1 if dy > 0 else -1

                    curr_col = c
                    cells = []
                    for row in rows_slice:
                        # print(f"cell: ({row}, {curr_col}) {row[curr_col]}")
                        cells.append(row[curr_col])
                        curr_col += dy_direction
                    slice = "".join(cells)

                    # print(f"==> DIAGONAL SLICE {direction} \n\
                    # sliced out {len(rows_slice)} rows for column slicing\n\
                    # slice = {slice}")

                    hits += 1 if slice == target else 0

        return hits

    def solve_part_1(self, matrix):
        num_rows, num_cols = len(matrix), len(matrix[0])
        print(f"SEE ME: num rows = {num_rows}, num_cols={num_cols}")
        possible_hits = [self.count_xmas_word_hits((r, c), matrix) for r in range(num_rows) for c in range(num_cols) if matrix[r][c] == "X"]

        print(f"cells investigated: {len(list(possible_hits))}")
        return sum(possible_hits)


s = Solution()
small_input_matrix = s.read_small()
[print(r) for r in small_input_matrix]
small_ans_part_1 = s.solve_part_1(small_input_matrix)
print(f"Small ans part 1 {small_ans_part_1}")

actual_input = s.read_input()
ans_part_1 = s.solve_part_1(actual_input)
print(f"Part 1 {ans_part_1}")

Day 5: Print Queue - Sorting

[2024-12-09 Mon]

[Learnings:]

  1. python ordering in the modern version is always key-based. So it’s an accessor pattern that the key arg consumes. The older way used to be to provide a comparator function (Java-style).

    So, to use the old way, use functools.cmp_to_key. When defining the comparator, remember that default sorting is always in ascending order. So if x comes after y, then comparator should return 1, if x comes before y comparator should return -1; 0 otherwise

[Solution Approach:] The first part has inputs that contain 2 info:

  1. the ordering rules
  2. multiple ordering configuration

The rules define before and after, and my intial thought was to use a topo-sort approach on this. Realised that it wouldn’t be that helpful to do quicker lookups since it’s going to require us to iterate through a path of the toposort graph.

So, for part 1 the approach shall be:

  1. ingest the rules definition and create a rules map. We keep rules using a map of sets. For each number e.g. 12,
    • we have to keep numbers that come after it => use the key = 12
    • we have to keep numbers that come before it ==> use the key = -12
  2. When checking if the order is correct, we check if union or not. for each element, i in the order:
    • every element before it should appear in the prefix set for that element
    • every element after it should appear in the suffix set for that element
    • every element after it should NOT appear in the prefix set
    • every element before it should NOT appear in the suffix set

So for part 2, it’s a natural extension of part 1:

  1. for the bad updates, try to fix them

So this requires a sorting to be done, but using a custom comparator. The custom comparator part requires a functool (ref python docs)

from collections import defaultdict
from functools import cmp_to_key

class Solution:
    def read_input(self, url="./io/2024_day5_input.txt"):
        with open(url) as f:
            input = f.read()
            return input

    def parse_input_data(self, data="""47|53
97|13
97|61
97|47
75|29
61|13
75|53
29|13
97|29
53|29
61|53
97|53
61|29
47|13
75|47
97|75
47|61
75|61
47|29
75|13
53|13

75,47,61,53,29
97,61,53,29,13
75,29,13
75,97,47,61,53
61,13,29
97,13,75,29,47"""):
        data = data.strip()
        rules_def, orders_def = data.split("\n\n")
        rules = rules_def.split("\n")
        orders = orders_def.split("\n")

        rules_map = defaultdict(set)
        for rule_def in rules:
            before, after = (int(x) for x in rule_def.split("|"))
            rules_map[before].add(after)
            rules_map[-after].add(before)

        parsed_orders = []
        for order_def in orders:
            parsed_orders.append([int(n) for n in order_def.split(",")])

        return rules_map, parsed_orders

    def is_order_legal(self, order, rules):
        is_legal_order = True

        for idx, page in enumerate(order):
            preceding_pages = order[:idx]
            following_pages = order[idx + 1:]

            is_preceding_pages_legal = all((before_page in rules[-page] and before_page not in rules[page] for before_page in preceding_pages))

            is_following_pages_legal = all((after_page in rules[page] and after_page not in rules[-page] for after_page in following_pages))

            is_legal_order = is_legal_order and is_preceding_pages_legal and is_following_pages_legal

        return is_legal_order

    def solve_part_1(self, rules, orders):
        partial_vals = []

        # ASSUMPTION: there are no even_length lists?
        return sum(order[len(order) // 2]
                   for order in orders
                   if self.is_order_legal(order, rules))

    def solve_part_2(self, rules, orders):
        erroneous_orders = (order for order in orders if not self.is_order_legal(order, rules))

        def comparator(x, y):
            # NOTE: default order for sorting is in ascending order
            # So if x comes after y ==> it's 1
            # if x comes before y ==> it's -1
            # if tie then 0
            is_x_after_y = x in rules[y]
            if is_x_after_y: #
                return 1

            is_x_before_y = x in rules[-y]
            if is_x_before_y:
                return -1

            return 0

        corrected_orders = (sorted(order, key=cmp_to_key(comparator) ) for order in erroneous_orders)

        return sum(order[len(order) // 2] for order in corrected_orders)



s = Solution()
small_input = s.parse_input_data()
actual_input = s.parse_input_data(s.read_input())
print(f"part 1 example ans: {s.solve_part_1(*small_input)}")
print(f"part 1 ans: {s.solve_part_1(*actual_input)}")
print(f"part 2 example ans: {s.solve_part_2(*small_input)}")
print(f"part 2 ans: {s.solve_part_2(*actual_input)}")

[incomplete part 2] Day 6: Guard Gallivant - TurtleBot Path Tracking

last attempted [2024-12-16 Mon], might come back to this eventually, let’s skip this

Notes: [Part 1]

  1. from input we need to grok the following: a. guard_start_coord b. obstacle_coords c. map dimensions we don’t need to keep track of the entire cartesian plane, just the obstacles and boundaries
  2. auxiliary info needed:
    1. visited_coord set –> so that you don’t double count the positions
    2. visited_positions counter

edge cases / mistakes:

  • though the input doesn’t include it, the traversal algo should detect cycles and avoid infinite recursion.
  • I had a silly off-by-one-error because of my is_bounded() fn.

[Part 2]

  1. it’s about creating cycles. We could improve our current path simulation to add in cycle-detection To do cycle detection, we can keep a count of duplicate cells encountered. If num_duplicates > total number of cells then it’s a cycle
  2. For the possible places, we can have candidates for where we can add the obstruction, and we just simulate to check if a cycle is detected, assuming that obstruction is pushed into my list of obstacles

^ following this is basically a brute-force approach and is it some ungodly polynomial time (actually tried it, took about 15mins of running the brute-force version), so we explore improvements:

Some observations:

  1. we can likely do a single pass, because at any one point, if there was a new obstacle infront of a current cell, then the guard would turn and keep going until he goes back to a similar loop as before.
  2. from 1, only cummulative history matters
  3. rules for valid “new obstacle”,
    • doesn’t have any original obstacle between the current cell and it
    • can create loop with history so far

    Note that to find a loop, we don’t just check immediate adjacent cell from current, but all the cells in the vertical/horizontal

from collections import defaultdict

class Solution():
    next_direction = {
            "^":">",
            "v": "<",
            "<": "^",
            ">": "v"
    }

    def read_input(self, url="./io/2024_day6_input.txt"):
        with open(url) as f:
            input = f.read()
            return input


    def parse_input(self, input="""....#.....
.........#
..........
..#.......
.......#..
..........
.#..^.....
........#.
#.........
......#..."""):
        lines = input.strip().split("\n")

        guard_start_info = None
        obstacles = []

        num_rows = len(lines)
        for row_idx, row in enumerate(lines):
            num_cols = len(row)
            for col_idx, cell_val in enumerate(row):
                coord = (row_idx, col_idx)
                match cell_val:
                    case "#":
                        obstacles.append((row_idx, col_idx))
                    case "^" | "<" | ">" | "v":
                        guard_start_info = ((row_idx, col_idx), cell_val) # coordinate and direction
                    case ".":
                        continue

        map_dims = (num_rows, num_cols)

        return {
            "map_dims": map_dims,
            "obstacles": obstacles,
            "guard_start_info": guard_start_info
        }

    def is_coord_in_map(self, coord, map_dims):
        num_rows, num_cols = map_dims
        r, c = coord

        return r >=0 and r < num_rows and c >= 0 and c < num_cols

    def get_next_cell(self, curr_cell, direction):
        curr_row, curr_col = curr_cell
        match direction:
             case "^":
                 next_cell = (curr_row - 1, curr_col)
             case "v":
                 next_cell = (curr_row + 1, curr_col)
             case "<":
                 next_cell = (curr_row, curr_col - 1)
             case ">":
                 next_cell = (curr_row, curr_col + 1)

        return next_cell

    def turn(self, direction):
        return self.next_direction[direction]

    def is_point_between(self, a, b, test):
        """
        Check if the point 'test' is within the bounding box defined by points 'a' and 'b'.
        """
        x1, y1 = a
        x2, y2 = b
        test_x, test_y = test

        return (min(x1, x2) <= test_x <= max(x1, x2)) and (min(y1, y2) <= test_y <= max(y1, y2))

    def can_create_loop(self, current_cell, current_direction, obstacles, visited):
        """
        Given the current cell, it would have a current direction and the next cell in that direction may be a candidate for a new obstacle.

        Suppose we turned (and hence are facing direction = turned_direction), then to create a loop, we just have to find from within the visited history, a cell in that same direction.

        from current cell, we have a row_idx, col_idx, for the current direction.

        Based on the direction, we just need a match:
        if ^ then have to be same col and row_idx less than curr
        if v then have to be same col and row_idx more than curr
        if < then have to be same row and col_idx is less than
        if > then have to be same row and col_idx is more than

        There may be NO obstacles in the middle of the two
        """
        obstacle_candidate_coord = self.get_next_cell(current_cell, current_direction)

        if (obstacle_candidate_coord in obstacles):
            return False

        turned_direction = self.next_direction[current_direction]
        row_idx, col_idx = current_cell

        has_obstacle_in_the_middle = lambda curr_coord, matching_coord: any((True for obstacle in obstacles if self.is_point_between(matching_coord, current_cell, obstacle)))

        match_fn = {
            "^": lambda r, c: c == col_idx and r < row_idx,
            "v": lambda r, c: c == col_idx and r > row_idx,
            "<": lambda r, c: r == row_idx and c < col_idx,
            ">": lambda r, c: r == row_idx and c > col_idx
        }

        matches = [
            matching_coord for matching_coord in visited.keys()
                if match_fn[turned_direction](*matching_coord) and
                        turned_direction in visited[matching_coord] and
                        not has_obstacle_in_the_middle(current_cell, matching_coord)
             ]

        return len(matches) > 0

    def simulate_guard_path(self, input_info):

        map_dims = input_info.get("map_dims")
        total_cells = map_dims[0] * map_dims[1]

        obstacles = input_info.get("obstacles")

        guard_start_coord, current_direction = input_info.get("guard_start_info")
        guard_coord = guard_start_coord

        visited = defaultdict(set)
        new_obstacle_candidates = set()
        num_turns = 0
        repeat_visits = 0
        has_detected_cycle = False

        while is_guard_on_map:= self.is_coord_in_map(guard_coord, map_dims):
            if (is_repeat_visit:= len(visited[guard_coord]) > 0):
                repeat_visits += 1

            if (has_detected_cycle:= repeat_visits > total_cells + 1):
                print("CYCLE FOUND")
                break

            next_cell = self.get_next_cell(guard_coord, current_direction)
            turned_direction = self.next_direction[current_direction]
            turned_next_cell = self.get_next_cell(guard_coord, turned_direction)


            # mark cell as visited:
            visited[guard_coord].add(current_direction)

            if (self.is_coord_in_map(next_cell, map_dims) and
                next_cell != guard_start_coord and
                self.can_create_loop(guard_coord, current_direction, obstacles, visited)):

                new_obstacle_candidates.add(next_cell)

            if (is_blocked_by_obstacle:= next_cell in obstacles):
                current_direction = turned_direction
                next_cell = turned_next_cell
                num_turns += 1

            guard_coord = next_cell

        return has_detected_cycle, visited, new_obstacle_candidates

s = Solution()
actual_input = s.read_input()
example_parsed_info = s.parse_input()
parsed_info = s.parse_input(actual_input)


is_cyclic, visited, obstacle_candidates = s.simulate_guard_path(example_parsed_info)
print(f"example part 1: is_cyclic_simulation? {is_cyclic} # visited = {len([k for k, v in visited.items() if v])} # obstacle candidates: {len(obstacle_candidates)}")

is_cyclic, visited, obstacle_candidates = s.simulate_guard_path(parsed_info)
print(f"actual ans: is_cyclic = {is_cyclic}, # visited = {len(visited)} # obstacle candidates: {len(obstacle_candidates)}")

Here’s the brute-force approach to part 2:

# brute-forced, my initial answer was 1909 from this, somehow it's incorrect, says that ans applies for a different input(?)
def get_obstruction_locations(self, input_info):
    print(input_info.keys())
    num_rows, num_cols = map_dims = input_info.get("map_dims")
    guard_start_coord, _= guard_start_info = input_info.get("guard_start_info")
    obstacles = input_info.get("obstacles")

    reserved_locations = set(obstacles)
    reserved_locations.add(guard_start_coord)
    obstruction_candidates = ((row_idx, col_idx) for row_idx in range(num_rows) for col_idx in range(num_cols) if (row_idx, col_idx) not in reserved_locations)

    valid_locations = [candidate for candidate in obstruction_candidates if (is_cyclic:= self.simulate_guard_path({
        "map_dims": map_dims,
        "obstacles": obstacles + [candidate],
        "guard_start_info": guard_start_info
    })[0])]

    return valid_locations

Releases

No releases published

Packages

No packages published