Load the data, sort the two columns, find the absolute difference between them and sum the result.
Code
data = np.sort(load(1, "int"), axis=0)abs(np.diff(data)).sum()
Part 2
numpy has a handy method to find the unique values in an array, and their counts. Let’s use that to make a dictionary of value -> count pairs for the right list with a default value of zero, and then just look up that value for each element of the left list.
Code
lookup = defaultdict(int)lookup.update( {value: count for value, count inzip(*np.unique(data[:, 1], return_counts=True))})sum(x * lookup[x] for x in data[:, 0])
Nothing too complicated going on here: load the data, and find the difference between successive values. To account for decreasing sequences, multiply by the sign of the first difference and then require that all the differences be greater than one and less than or equal to three.
Code
data = load(2, "int")def is_safe(line): diff = np.diff(line) diff = diff * np.sign(diff[0])returnint(((diff >0) & (diff <=3)).all())sum(is_safe(line) for line in data)
Part 2
I spent a bit of time trying to see if there was a neat way of incorporating the “is valid if any one number is deleted” requirement, but I couldn’t immediately see it, so I ended up just iterating over all the possible deletions instead.
Code
total =0for line in data:for idx inrange(len(line)):if is_safe(line[:idx] + line[idx +1 :]): total +=1breaktotal
We get to play with regex! Well, I do – there might be better ways of tackling this. The format for a valid mul instruction is quite strict, encoding that as a regex is fairly straightforward. Once we have that, we can use re.findall to find all the occurrences and extract the integers.
Code
data = load(3, "raw")mul =r"mul\((\d{1,3}),(\d{1,3})\)"sum(int(pair[0]) *int(pair[1]) for pair in re.findall(mul, data))
Part 2:
I’m pretty happy with my part two, which I managed to do fairly elegantly. We can ignore all the sections immediately after a don't() instruction by splitting the string on those, and then discarding the start of each substring up to the first do() instruction. Concatenating all the substrings gives us a clean string with just the segments we are interested in, and we can proceed as before.
Code
clean ="".join( [segment[segment.find("do()") :] for segment in ("do()"+ data).split("don't()")])sum(int(pair[0]) *int(pair[1]) for pair in re.findall(mul, clean))
data = np.array([[ord(char) for char in line] for line in load(4)])mask = np.array([ord(char) for char in"XMAS"])def xmas(chararray):return (chararray == mask).all() or (chararray == mask[::-1]).all()footprints = [np.eye(4), np.fliplr(np.eye(4)), [[1, 1, 1, 1]], [[1], [1], [1], [1]]]sum( scipy.ndimage.generic_filter(data, xmas, footprint=footprint, mode="constant").sum()for footprint in footprints)
Part 2
Code
masks = ["MMASS", "SMASM", "MSAMS", "SSAMM"]encoded_masks = [np.array([ord(char) for char in mask]) for mask in masks]footprint = [[1, 0, 1], [0, 1, 0], [1, 0, 1]]def x_mas(chararray):for mask in encoded_masks:if (chararray == mask).all():return1return0scipy.ndimage.generic_filter(data, x_mas, footprint=footprint, mode="constant").sum()
This problem screams topological sort, so that’s what I’m going with. For part one, to account for the case where multiple orderings of a line might obey the rules, the we’ll use a function to check whether the line order is compatible with the order given in the update.
That’s not too tricky: just iterate through the update, and for each item in the update
Check that it’s not blocked by any later items
Remove any blocks that the current item is placing on later items, since it’s successfully been placed
If we get through the whole update without finding a blocked item, then the order is valid and we should include it in the sum.
Part 1
Code
rules, updates = [x.split("\n") for x in load(5, "raw").split("\n\n")]updates = [[int(x) for x in line.split(",")] for line in updates]ancestors = defaultdict(list)descendents = defaultdict(list)for rule in rules: first, last =map(int, rule.split("|")) ancestors[last].append(first) descendents[first].append(last)def restriction(rules, keys):return {key: [x for x in rules[key] if x in keys] for key in keys}def is_sorted(keys): keys = keys.copy() pre = restriction(ancestors, keys) post = restriction(descendents, keys)while keys: current = keys.pop(0)if pre[current]:returnFalsedel pre[current]for item in post[current]: pre[item] = [x for x in pre[item] if x != current]returnTruesum(update[len(update) //2] for update in updates if is_sorted(update))
Part 2
For part 2 we’ll actually implement the topological sort. I vaguely remembered how to do this, but it took a couple of iterations to get right.
The idea is that we start by scanning the rules dictionary for items which are eligible for immediate placement, and remove them from the list.
We then iteratively place these items, and for each item we place, we remove any blocks that they might have placed on later items. That might mean that new items are eligible for placement, and in this way we iterate through the entire list and output an order compatible with the rules.
Code
def topological_sort(keys): pre = restriction(ancestors, keys) post = restriction(descendents, keys) result = [] to_delete = [key for key in pre ifnot pre[key]]for key in to_delete:del pre[key]while to_delete: n = to_delete.pop() result.append(n)for item in post[n]: pre[item] = [x for x in pre[item] if x != n]ifnot pre[item]: to_delete.append(item)del pre[item]return resultsum(topological_sort(line)[len(line) //2] for line in updates ifnot is_sorted(line))
obstacles =set()for y, line inenumerate(load(6)):for x, char inenumerate(line.strip()):if char =="^": position = x +1j* yif char =="#": obstacles.add(x +1j* y)ymax, xmax = y, xdef is_valid(position): x, y = position.real, position.imagreturn0< y < ymax and0< x < xmaxdirection =-1jpath = [(position, direction)]while is_valid(position):while position + direction in obstacles: direction *=1j position += direction path.append((position, direction))len(set(x[0] for x in path))
Part 2
For part two, we should realise that only obstacles placed on the path have any chance of affecting what the guard does, so the relevant search space is significantly smaller than “every vacant square on the board”. There’s also no need to start the guard from the initial position for every new obstacle; we can just start her on the path right in front of the new obstacle. Similarly, if she ever gets back on her original path before the obstacle, we know she must be in a loop, so we can start with a visited set that covers the whole pwth right up to the new obstruction.
Code
attempted_positions =set([path[0][0]])total =0for idx inrange(1, len(path)): obstacle, _ = path[idx]if obstacle in attempted_positions:continue position, direction = path[idx -1] obstacles.add(obstacle) seen =set(path[:idx -1])while is_valid(position): seen.add((position, direction))while position + direction in obstacles: direction *=1jwhile is_valid(position) and position + direction notin obstacles: position += directionif (position, direction) in seen: total +=1break attempted_positions.add(obstacle) obstacles.remove(obstacle)total
data = load(7)data = [ [int((parts := line.split(":"))[0]), [int(i) for i in parts[1].split()]]for line in data]operators = [operator.mul, operator.add]def possible_values(operands, current=None, ops=operators):if current isNone: current = operands[0] operands = operands[1:]ifnot operands:yield currentelse:for op in ops:yieldfrom possible_values(operands[1:], op(current, operands[0]), ops)sum( resultfor result, operands in dataifany(value == result for value in possible_values(operands)))
Part 2
This is horribly slow, but has the advantage of being an incredibly fast modification of part 1. A bit of thought shows that backtracking would make things run much faster, since it would let us prune the state space much faster, so that’s one easy option for speeding things up.
Code
def concat(x, y):returnint(str(x) +str(y))ops = [operator.mul, operator.add, concat]sum( resultfor result, operands in dataifany(value == result for value in possible_values(operands, ops=ops)))
Nothing too complicated going on here. For every symbol on the map that’s not a ., we find all its locations, and then we use itertools.combinations to loop over all pairs of symbols.
Code
data = load(8, "chararray")result =set()for value in np.unique(data):if value ==".":continue points = np.array(np.where(data == value)).Tfor p1, p2 in itertools.combinations(points, 2): result.add(tuple(2* p1 - p2)) result.add(tuple(2* p2 - p1))sum(1for x in result if0<= x[0] < data.shape[0] and0<= x[1] < data.shape[1])
Part 2
In python, the int function automatically rounds towards zero, so we can explicitly work out the number of steps we can take in the x and y directions before going off the map.
Code
result =set()for value in np.unique(data):if value ==".":continue points = np.array(np.where(data == value)).Tfor p1, p2 in itertools.combinations(points, 2): dx = (p2 - p1)[1] dy = (p2 - p1)[0] Δ = np.array([dy, dx]) ymin, ymax =-np.inf, np.inf xmin, xmax =-np.inf, np.infif dy !=0: ymin, ymax =sorted([int((y - p1[0]) / dy) for y in [0, data.shape[0] -1]])if dx !=0: xmin, xmax =sorted([int((x - p1[1]) / dx) for x in [0, data.shape[1] -1]]) minval =max(ymin, xmin) maxval =min(ymax, xmax) points = [tuple(p1 + k * Δ) for k inrange(minval, maxval +1)] result |=set(points)len(result)
data = np.array([[int(char) for char in line] for line in load(10)])def neighbors(y, x): result = [] val = data[y, x]for dy, dx in [[0, 1], [0, -1], [1, 0], [-1, 0]]: new_y, new_x = y + dy, x + dxif (0<= new_y < data.shape[0]and0<= new_x < data.shape[1]and data[new_y, new_x] == val +1 ): result.append((new_y, new_x))return result@functools.cachedef p1(y, x): r =set()if data[y, x] ==9:returnset([(y, x)])return r.union(*[p1(*neighbor) for neighbor in neighbors(y, x)])starts = np.array(np.where(data ==0)).Tsum(len(p1(y, x)) for y, x in starts)
Part 2
Code
@functools.cachedef p2(y, x):if data[y, x] ==9:return1returnsum(p2(*neighbor) for neighbor in neighbors(y, x))sum(p2(y, x) for y, x in starts)
Source Code
---title: 2024 Solutions---# Imports```{python}# | eval: true# | output: falseimport dataclassesimport functoolsimport itertoolsimport osimport reimport sysfrom collections import defaultdict, deque, namedtuplefrom queue import PriorityQueueimport more_itertoolsimport numpy as npimport operatorimport pandas as pdimport scipysys.path.insert(1, "..")import utilsload = utils.year_load(2024)```# [Day 1: Historian Hysteria](https://adventofcode.com/2024/day/1)## Part 1Load the data, sort the two columns, find the absolute difference between them and sum the result.```{python}data = np.sort(load(1, "int"), axis=0)abs(np.diff(data)).sum()```## Part 2`numpy` has a handy method to find the unique values in an array, and their counts. Let's use that to make a dictionary of `value -> count` pairs for the right list with a default value of zero, and then just look up that value for each element of the left list.```{python}lookup = defaultdict(int)lookup.update( {value: count for value, count inzip(*np.unique(data[:, 1], return_counts=True))})sum(x * lookup[x] for x in data[:, 0])```# [Day 2: Red-Nosed Reports](https://adventofcode.com/2024/day/2)## Part 1Nothing too complicated going on here: load the data, and find the difference between successive values. To account for decreasing sequences, multiply by the sign of the first difference and then require that all the differences be greater than one and less than or equal to three.```{python}data = load(2, "int")def is_safe(line): diff = np.diff(line) diff = diff * np.sign(diff[0])returnint(((diff >0) & (diff <=3)).all())sum(is_safe(line) for line in data)```## Part 2I spent a bit of time trying to see if there was a neat way of incorporating the "is valid if any one number is deleted" requirement, but I couldn't immediately see it, so I ended up just iterating over all the possible deletions instead.```{python}total =0for line in data:for idx inrange(len(line)):if is_safe(line[:idx] + line[idx +1 :]): total +=1breaktotal```# [Day 3: Mull It Over](https://adventofcode.com/2024/day/3)## Part 1We get to play with `regex`! Well, I do – there might be better ways of tackling this. The format for a valid `mul` instruction is quite strict, encoding that as a regex is fairly straightforward. Once we have that, we can use `re.findall` to find all the occurrences and extract the integers.```{python}data = load(3, "raw")mul =r"mul\((\d{1,3}),(\d{1,3})\)"sum(int(pair[0]) *int(pair[1]) for pair in re.findall(mul, data))```## Part 2:I'm pretty happy with my part two, which I managed to do fairly elegantly. We can ignore all the sections immediately after a `don't()` instruction by splitting the string on those, and then discarding the start of each substring up to the first `do()` instruction. Concatenating all the substrings gives us a clean string with just the segments we are interested in, and we can proceed as before.```{python}clean ="".join( [segment[segment.find("do()") :] for segment in ("do()"+ data).split("don't()")])sum(int(pair[0]) *int(pair[1]) for pair in re.findall(mul, clean))```# [Day 4: Ceres Search](https://adventofcode.com/2024/day/4)## Part 1```{python}data = np.array([[ord(char) for char in line] for line in load(4)])mask = np.array([ord(char) for char in"XMAS"])def xmas(chararray):return (chararray == mask).all() or (chararray == mask[::-1]).all()footprints = [np.eye(4), np.fliplr(np.eye(4)), [[1, 1, 1, 1]], [[1], [1], [1], [1]]]sum( scipy.ndimage.generic_filter(data, xmas, footprint=footprint, mode="constant").sum()for footprint in footprints)```## Part 2```{python}masks = ["MMASS", "SMASM", "MSAMS", "SSAMM"]encoded_masks = [np.array([ord(char) for char in mask]) for mask in masks]footprint = [[1, 0, 1], [0, 1, 0], [1, 0, 1]]def x_mas(chararray):for mask in encoded_masks:if (chararray == mask).all():return1return0scipy.ndimage.generic_filter(data, x_mas, footprint=footprint, mode="constant").sum()```# [Day 5: Print Queue](https://adventofcode.com/2024/day/5)This problem screams topological sort, so that's what I'm going with. For part one, to account for the case where multiple orderings of a line might obey the rules, the we'll use a function to check whether the line order is compatible with the order given in the update.That's not too tricky: just iterate through the update, and for each item in the update1. Check that it's not blocked by any later items2. Remove any blocks that the current item is placing on later items, since it's successfully been placedIf we get through the whole update without finding a blocked item, then the order is valid and we should include it in the sum.## Part 1```{python}rules, updates = [x.split("\n") for x in load(5, "raw").split("\n\n")]updates = [[int(x) for x in line.split(",")] for line in updates]ancestors = defaultdict(list)descendents = defaultdict(list)for rule in rules: first, last =map(int, rule.split("|")) ancestors[last].append(first) descendents[first].append(last)def restriction(rules, keys):return {key: [x for x in rules[key] if x in keys] for key in keys}def is_sorted(keys): keys = keys.copy() pre = restriction(ancestors, keys) post = restriction(descendents, keys)while keys: current = keys.pop(0)if pre[current]:returnFalsedel pre[current]for item in post[current]: pre[item] = [x for x in pre[item] if x != current]returnTruesum(update[len(update) //2] for update in updates if is_sorted(update))```## Part 2For part 2 we'll actually implement the topological sort. I vaguely remembered how to do this, but it took a couple of iterations to get right.The idea is that we start by scanning the rules dictionary for items which are eligible for immediate placement, and remove them from the list.We then iteratively place these items, and for each item we place, we remove any blocks that they might have placed on later items. That might mean that new items are eligible for placement, and in this way we iterate through the entire list and output an order compatible with the rules.```{python}def topological_sort(keys): pre = restriction(ancestors, keys) post = restriction(descendents, keys) result = [] to_delete = [key for key in pre ifnot pre[key]]for key in to_delete:del pre[key]while to_delete: n = to_delete.pop() result.append(n)for item in post[n]: pre[item] = [x for x in pre[item] if x != n]ifnot pre[item]: to_delete.append(item)del pre[item]return resultsum(topological_sort(line)[len(line) //2] for line in updates ifnot is_sorted(line))```# [Day 6: Guard Gallivant](https://adventofcode.com/2024/day/6)## Part 1```{python}obstacles =set()for y, line inenumerate(load(6)):for x, char inenumerate(line.strip()):if char =="^": position = x +1j* yif char =="#": obstacles.add(x +1j* y)ymax, xmax = y, xdef is_valid(position): x, y = position.real, position.imagreturn0< y < ymax and0< x < xmaxdirection =-1jpath = [(position, direction)]while is_valid(position):while position + direction in obstacles: direction *=1j position += direction path.append((position, direction))len(set(x[0] for x in path))```## Part 2For part two, we should realise that only obstacles placed on the path have any chance of affecting what the guard does, so the relevant search space is significantly smaller than "every vacant square on the board". There's also no need to start the guard from the initial position for every new obstacle; we can just start her on the path right in front of the new obstacle. Similarly, if she ever gets back on her original path before the obstacle, we know she must be in a loop, so we can start with a visited set that covers the whole pwth right up to the new obstruction.```{python}attempted_positions =set([path[0][0]])total =0for idx inrange(1, len(path)): obstacle, _ = path[idx]if obstacle in attempted_positions:continue position, direction = path[idx -1] obstacles.add(obstacle) seen =set(path[:idx -1])while is_valid(position): seen.add((position, direction))while position + direction in obstacles: direction *=1jwhile is_valid(position) and position + direction notin obstacles: position += directionif (position, direction) in seen: total +=1break attempted_positions.add(obstacle) obstacles.remove(obstacle)total```# [Day 7: Bridge Repair](https://adventofcode.com/2024/day/7)## Part 1```{python}data = load(7)data = [ [int((parts := line.split(":"))[0]), [int(i) for i in parts[1].split()]]for line in data]operators = [operator.mul, operator.add]def possible_values(operands, current=None, ops=operators):if current isNone: current = operands[0] operands = operands[1:]ifnot operands:yield currentelse:for op in ops:yieldfrom possible_values(operands[1:], op(current, operands[0]), ops)sum( resultfor result, operands in dataifany(value == result for value in possible_values(operands)))```## Part 2This is horribly slow, but has the advantage of being an incredibly fast modification of part 1. A bit of thought shows that backtracking would make things run much faster, since it would let us prune the state space much faster, so that's one easy option for speeding things up.```{python}def concat(x, y):returnint(str(x) +str(y))ops = [operator.mul, operator.add, concat]sum( resultfor result, operands in dataifany(value == result for value in possible_values(operands, ops=ops)))```# [Day 8: Resonant Collinearity](https://adventofcode.com/2024/day/8)## Part 1Nothing too complicated going on here. For every symbol on the map that's not a `.`, we find all its locations, and then we use `itertools.combinations` to loop over all pairs of symbols.```{python}data = load(8, "chararray")result =set()for value in np.unique(data):if value ==".":continue points = np.array(np.where(data == value)).Tfor p1, p2 in itertools.combinations(points, 2): result.add(tuple(2* p1 - p2)) result.add(tuple(2* p2 - p1))sum(1for x in result if0<= x[0] < data.shape[0] and0<= x[1] < data.shape[1])```## Part 2In python, the `int` function automatically rounds towards zero, so we can explicitly work out the number of steps we can take in the `x` and `y` directions before going off the map.```{python}result =set()for value in np.unique(data):if value ==".":continue points = np.array(np.where(data == value)).Tfor p1, p2 in itertools.combinations(points, 2): dx = (p2 - p1)[1] dy = (p2 - p1)[0] Δ = np.array([dy, dx]) ymin, ymax =-np.inf, np.inf xmin, xmax =-np.inf, np.infif dy !=0: ymin, ymax =sorted([int((y - p1[0]) / dy) for y in [0, data.shape[0] -1]])if dx !=0: xmin, xmax =sorted([int((x - p1[1]) / dx) for x in [0, data.shape[1] -1]]) minval =max(ymin, xmin) maxval =min(ymax, xmax) points = [tuple(p1 + k * Δ) for k inrange(minval, maxval +1)] result |=set(points)len(result)```# [Day 10: Hoof It](https://adventofcode.com/2024/day/10)## Part 1```{python}data = np.array([[int(char) for char in line] for line in load(10)])def neighbors(y, x): result = [] val = data[y, x]for dy, dx in [[0, 1], [0, -1], [1, 0], [-1, 0]]: new_y, new_x = y + dy, x + dxif (0<= new_y < data.shape[0]and0<= new_x < data.shape[1]and data[new_y, new_x] == val +1 ): result.append((new_y, new_x))return result@functools.cachedef p1(y, x): r =set()if data[y, x] ==9:returnset([(y, x)])return r.union(*[p1(*neighbor) for neighbor in neighbors(y, x)])starts = np.array(np.where(data ==0)).Tsum(len(p1(y, x)) for y, x in starts)```## Part 2```{python}@functools.cachedef p2(y, x):if data[y, x] ==9:return1returnsum(p2(*neighbor) for neighbor in neighbors(y, x))sum(p2(y, x) for y, x in starts)```