2024-12-06
This is Part 2 of my ongoing commentary of Advent of Code 2024. See Day 1-3 for the previous episode.
Day 1-3 were all about getting used to the format and to the general pipeline of solving these puzzles. Day 1 and 2 were very straigthforward, day 3 came with some RegEx difficulties, but nothing too exotic. Let’s move on to day 4.
Day 4 comes with a word puzzle. In a grid of letters such as:
MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX
Find all occurences of XMAS
, in any direction
(horizontal, vertical, diagonal, forward and backwards). The solution
for the first part should be the number of occurences (in a much larger
grid of 140x140 letters).
Because I was still in RegEx mode from the previous day, I started by
a simple method to count the number of occurences of XMAS
in a line:
def count_in_line(s: str) -> int:
return len(re.findall("XMAS", s))
My idea was then to try to frame the rest of the problem as: find
all the lines that we need to count “XMAS” in. For the
horizontal and vertical lines, that’s easy: we can just go row by row
and column by column, and test for each line and its reverse. To go row
by row, we just have to split the original block of text by
\n
. And then we can use the zip(*rows)
trick
to transpose and go column by column:
= data.split('\n')
horizontals = 0
total for l in horizontals:
+= count_in_line(l) + count_in_line(l[::-1]) total
We could go one-liner with something like:
= sum(count_in_line(l) + count_in_line(l[::-1]) for l in data.split('\n')) total_horizontal
But this is a case where I think readability is really improved by explicitly writing the steps. For the verticals:
= zip(*horizontals)
verticals for v in verticals:
= ''.join(v) # reform string from list of characters
s += count_in_line(s) + count_in_line(s[::-1]) total
And now, I want to add the diagonals. First, I pretend that I have a function to do that, just to finish the “summation” code:
for d in get_diagonals(horizontals):
+= count_in_line(d) + count_in_line(d[::-1]) total
But then we have to actually find those diagonals. It’s not particularly difficult per se, but I really struggled to find a satisfying solution (as in: a solution that doesn’t lead to disgusting spaghetti code). To cover all the diagonals, we need to go in four directions (plus the reverse).
From the top row, if we move “bottom-right” and “bottom-left”, we cover the following diagonals in the example:
XXXXXXXXXX
/XXXXXXXX\
//XXXXXX\\
///XXXX\\\
////XX\\\\
/////\\\\\
////..\\\\
///....\\\
//......\\
/........\
(This visualization already took more time to do than solving the puzzle I think)
To get the “missing” diagonals, we can then start again from the last row, this time going “top-right” and “top-left”, ignoring the first and last column, which we already covered. Alternatively (and that’s what I did in the code, but I noticed after doing the visualization, which I don’t want to remake!), we can do “top-right”, “bottom-right” from the first column, then “top-left”, “bottom-left” from the last column, ignoring the first and last row. That’s confusing, and in this case I fear my code will not help much, but here it is:
def collect_diagonal(grid: list[list[str]] | list[str],
int,
start_row: int,
start_col: int,
h_direction: int) -> str:
v_direction: = []
d = start_row
dridx = start_col
dcidx
d.append(grid[dridx][dcidx])while 0 <= (dcidx := dcidx + h_direction) < len(grid[0]) and \
0 <= (dridx := dridx + v_direction) < len(grid):
d.append(grid[dridx][dcidx])return ''.join(d)
def get_diagonals(horizontals: list[str]):
= []
diagonals
# starting from first col
for row_idx in range(len(horizontals)):
# down right
0, 1, 1))
diagonals.append(collect_diagonal(horizontals, row_idx, # top right
0, 1, -1))
diagonals.append(collect_diagonal(horizontals, row_idx,
# starting from last col, without first and last row
for row_idx in range(1, len(horizontals)-1):
# down left
len(horizontals[row_idx])-1, -1, 1))
diagonals.append(collect_diagonal(horizontals, row_idx, # top left
len(horizontals[row_idx])-1, -1, -1))
diagonals.append(collect_diagonal(horizontals, row_idx,
return [d for d in diagonals if len(d) > 4]
I’m sure there are more elegant solutions out there, but it works,
and I got to use the walrus operator
:=
, which is always a nice bonus.
This time, I couldn’t really re-use much for the second part. No more counting things in line, unfortunately, so I had to basically start from scratch. The goal this time was to find the pattern:
M.S
.A.
M.S
In the same grid, with the MAS
also in all directions.
This is, in my opinion, a much simpler problem to solve. We have in
total four possible patterns to find:
acceptable_patterns = [
['M.S',
'.A.',
'M.S'],
['S.S',
'.A.',
'M.M'],
['M.M',
'.A.',
'S.S'],
['S.M',
'.A.',
'S.M']
]
We can now easily go line-by-line, then column by column, through the
grid. Every time we find a triple of letters that matches the first row
of one of the patterns, we check if the 3x3 region matches the entire
pattern, and that’s it. Using the .
RegEx wildcard (that we
now know doesn’t work for newlines) in our pattern, and
re.match
, we can get the correct count.
= 0
count_matches
# go line by line, column by column, check if match
for i in range(len(horizontals)-2):
for j in range(len(horizontals[i])-2):
= [h[j:j+3] for h in horizontals[i:i+3]]
region for line_patterns in acceptable_patterns:
= True
is_match for l,p in zip(region, line_patterns):
if re.match(p, l) is None:
= False
is_match if is_match:
break
if is_match:
+= 1 count_matches
On to the next day.
Day 5 is all about following the rules and putting things in order. The input is a bit differently laid out than usual. Instead of a single grid, we have two parts separated by a blank line:
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
The first part defines the rules, which are of the form
A|B
, meaning “A
must come before
B
. The second part gives us the orders, which are supposed
to (but of course don’t) follow the rules.
Part 1 and Part 2 go together well this time around: part 1 is about finding which lines follow the rules, part 2 is about correcting those that don’t. To check that we solve the puzzle, we have to compute the sum of the middle numbers, first of all the correct lines, then of all the re-ordered incorrect lines.
The main thing that I worried about here was how to structure the
data in such a way that it would be easy to then check the rules. In the
end, I settled on a rulebook as a dict
, where the keys
would be the values present in the rules. To each value would be
associated two lists: a list of all the values that must come
before, and a list of all the values that must come after.
So for instance, given our example above, the number 47
appears six times in the rules:
47|53
97|47
47|13
75|47
47|61
47|29
In our dictionary, it will appear as:
{47: [[97, 75], [53, 13, 61, 29]]}
For that, we first load the data as a string, split in two based on the blank line, and parse the rules to construct the dictionary:
def get_rulebook_and_line(suffix: str) -> tuple[dict[int, list[list[int]]], list[list[int]]]:
= load_as_str('day5')
data
= data.split('\n\n')
rules, line
= [[int(p) for p in r.split('|')] for r in rules.split('\n')]
rules = [[int(p) for p in u.split(',')] for u in line.split('\n')]
line
return prepare_rulebook(rules), line
def prepare_rulebook(rules: list[list[int]]) -> dict[int, list[list[int]]]:
"""Put the rules in the form:
{P1 : [[P_BEFORE], [P_AFTER]]}
"""
= {}
rules_dict for before, after in rules:
if before not in rules_dict:
= [[], [after]]
rules_dict[before] else:
1].append(after)
rules_dict[before][if after not in rules_dict:
= [[before], []]
rules_dict[after] else:
0].append(before)
rules_dict[after][
return rules_dict
This makes the rest very easy. To check if a line follows the rule, we go item by item, get the rules for the current items, and check if there is some overlap between the previous items and the “must come after” list, or if there is some overlap between the next items and the “must come before” list. In either case, we mark the line as incorrect.
def check_no_overlap(src: list[int],
list[int]) -> bool:
ref: for v in src:
if v in ref:
return False
return True
def filter_line(line: list[int],
dict[int, list[list[int]]]) -> bool:
rulebook: for idx in range(len(line)):
= line[idx]
cur = line[:idx]
bef = line[idx+1:]
aft = rulebook[cur][0]
rbefore = rulebook[cur][1]
rafter if not (check_no_overlap(bef, rafter) and check_no_overlap(aft, rbefore)):
return False
return True
def filter_lines(lines: list[list[int]], rulebook: dict[int, list[list[int]]]) -> tuple[list[list[int]], list[list[int]]]:
"""Returns the list of correct and incorrect lines"""
= [(filter_line(line, rulebook), line) for line in lines]
flines return [line for is_correct, line in lines if is_correct], [line for is_correct, line in lines if not is_correct]
Then it’s just a matter of summing the middle values:
def add_middle(lines: list[list[int]]) -> int:
return sum([line[len(line)//2] for line in lines])
= get_rulebook_and_lines("_sample")
rules, lines = filter_lines(lines, rules)
correct, incorrect print(add_middle(correct))
For the second part, we go through the incorrect
list and we will iteratively swap any two items that are breaking the
rules until the list is ordered as we want. To do that, we need to
modify our check_no_overlap
method to also return the
position of the offending party:
def check_no_overlap(src: list[int], ref: list[int]) -> tuple[bool, int|None]:
for v in src:
if v in ref:
return (False, src.index(v))
return (True, None)
(We will have to slightly change filter_line
as well to
use check_no_overlap(bef, rafter)[0]
)
Our re-ordering code will be very similar to the filter, except that we add some recursivity this time. We separate our “before” and “after” check and, as soon as we find a problem, we swap the two values and re-call the function with the swapped values.
def reorder_line(line: list[int], rulebook: dict[int, list[list[int]]]) -> list[int]:
for idx in range(len(line)):
= line[idx]
cur = line[:idx]
bef = line[idx+1:]
aft = rulebook[cur][0]
rbefore = rulebook[cur][1]
rafter
= check_no_overlap(bef, rafter)
no_overlap_before, idx2 if not no_overlap_before:
assert idx2 is not None
# one of the number before must be after -> swap them
= line[idx2], line[idx]
line[idx], line[idx2] return reorder_line(line, rulebook)
= check_no_overlap(aft, rbefore)
no_overlap_after, idx2 if not no_overlap_after:
assert idx2 is not None
+= idx+1 # idx2 is in truncated list
idx2 # one of the number after must be before -> swap them
= line[idx2], line[idx]
line[idx], line[idx2] return reorder_line(line, rulebook)
return line
def reorder_lines(lines: list[list[int]], rulebook: dict[int, list[list[int]]]) -> list[list[int]]:
return [reorder_line(line, rulebook) for line in lines]
We can then get part 2’s answer easily:
= reorder_updates(incorrect, rules)
reordered print(add_middle(reordered))
We could of course merge the two parts to be more efficient, and at the same time compile the list of correct and re-ordered lines, but that’s too much refactoring for the kind of time I’m willing to give to this!
I think with day 4 I was stuck a little bit in a bad original choice.
Reducing the problem to a simple “find pattern in line” was not a
problem by itself, but then I didn’t really find an efficient way of
parsing the data to do things cleanly. Seeing the second part, maybe I
should have looked at it as a 2D pattern matching problem from the start
(which given my own background in image analysis should have
been something I considered). The XMAS
s diagonal patterns
can be enumerated in a 4x4 grid:
X... S... ...S ...X
.M.. .A.. ..A. ..M.
..A. ..M. .M.. .A..
...S ...X X... S...
Using a moving window approach and matching those patterns, like I did for part 2, should work, I think?
For day 5 I’m much happier with my result. I think the dictionary
approach to encode the rulebook made everything reasonably easy. I did
see a cool idea in Juhis’
solution, which is to use a custom sorter. That’s certainly a more
efficient way of reordering the lists. It makes me think that this could
also be done with a SortableItem
custom class, which would
have for each item its set of appropriate rules and implement the
__lt__(self, other)
method using the rulebook. I don’t want
to spend too much time on a particular puzzle, however, so I may come
back to this later.