lines = [np.array([ord(y) for y in x]) for x in load(2)]twos =sum([2in np.unique(list(line), return_counts=True)[1] for line in lines])twos *sum([3in np.unique(list(line), return_counts=True)[1] for line in lines])
Part 2
Code
for s1 in lines:for s2 in lines:iflen(s2) !=len(s1): continueif (s2 - s1 !=0).sum() ==1: result =''.join(chr(x) for x in s1[np.where(s1 == s2)])breakelse:continuebreakresult
num = re.compile(r"\d+")data = np.array([[int(x) for x in re.findall(num, line)] for line in load(3)])field = np.zeros([1000, 1000])for pid, x, y, w, h in data: field[x : x + w, y : y + h] +=1(field >1).sum()
Part 2
Code
for pid, x, y, w, h in data:if (field[x : x + w, y : y + h] ==1).all():breakpid
s = load(5)[0]defreduce(s): l =len(s)for char in string.ascii_lowercase: s = s.replace(f"{char + char.swapcase()}", "") s = s.replace(f"{char.swapcase() + char}", "")return l if l ==len(s) elsereduce(s)reduce(s)
Part 2
Code
min(reduce(s.replace(c, "").replace(c.upper(), "")) for c in string.ascii_lowercase)
The numbers involved are small enough that brute force is a viable approach. It’s ugly, but it works. The question is basically asking for the voronoi diagram of the initial points using the L1 metric, but I’m too slow to see an efficient way of calculating that. The approach would have to be something like determining the boundary line between each pair of points, and then intersecting all of those half planes to get the voronoi cell.
Code
data = load(6)coordinates = np.array([list(map(int, re.findall("\d+", line))) for line in data])xmax, ymax = coordinates.max(axis=0)board = np.zeros([xmax, ymax], dtype=int)for x, y in itertools.product(range(xmax), range(ymax)): distances = (np.abs(coordinates - np.array([x, y]))).sum(axis=1) values, counts = np.unique(distances, return_counts=True) board[x, y] = distances.argmin() if counts[0] ==1else-1infinite = functools.reduce(lambda x, y: set(x) |set(y), [board[0], board[:, 0], board[-1], board[:, -1]])max( [ (board == seed).sum() if seed notin infinite else0for seed inrange(len(coordinates)) ])
Part 2
Code
board = np.zeros([xmax, ymax], dtype=int)for x, y in itertools.product(range(xmax), range(ymax)): board[x, y] = (np.abs(coordinates - np.array([x, y]))).sum()(board <10000).sum()
Bonus
I haven’t figured out the cleanest way of solving part 1, but here’s an approach that’s slightly better than brute force. We can basically flood fill the grid, starting with the seed locations given in the input, and then expanding one step at a time. That way we end up considering the effect of at most four (and usually only one or two) seeds on each location, and we avoid having to calculate the distance from the point to every single seed.
Code
import matplotlib.pyplot as pltboard = np.zeros([xmax +1, ymax +1], dtype=int)def expand_one(cells, idx, to_paint): new_cells = []for neighbor in get_neighbors(cells):if board[neighbor] ==0:if neighbor in to_paint:del to_paint[neighbor] board[neighbor] =-1else: to_paint[neighbor] = idx +1 new_cells.append(neighbor)return new_cellsdef get_neighbors(cells): neighbors = []for x, y in cells: candidates = [(x -1, y), (x +1, y), (x, y -1), (x, y +1)] neighbors += [ (x, y) for x, y in candidates if (0<= x <= xmax) and (0<= y <= ymax) ]returnset(neighbors)
We can animate the process of expanding each seed
Code
to_paint = {tuple(x): idx +1for idx, x inenumerate(coordinates)}system = [[x] for x in to_paint.keys()]boards = []while to_paint:for key in to_paint: board[key] = to_paint[key] to_paint = {}for idx, cells inenumerate(system): system[idx] = expand_one(cells, idx, to_paint) image = board.astype(float).copy() image[image ==0] = np.nan boards.append(image)import matplotlib.animation as animations =3.0fig = plt.figure(figsize=(s, s * ymax / xmax))l =len(boards)i =0im = plt.imshow(boards[0], animated=True, cmap="inferno")plt.xticks([])plt.yticks([])def updatefig(*args):global iif i <len(boards) -1: i +=1else: i =0 im.set_array(boards[i])return (im,)a = animation.FuncAnimation(fig, updatefig, blit=True, frames=len(boards), interval=10)plt.close(fig)HTML(a.to_jshtml())
active = []n_workers =5part2 = constraints.copy()time =-1while part2: new_active = []for key, count in active:if count: new_active += [[key, count -1]]else: pop_node(key, part2) active = new_active available =sorted(set(key for key in part2 ifnot part2[key][1]) -set(x[0] for x in active) )while available andlen(active) < n_workers: key = available.pop(0) active += [[key, ord(key) -ord("A") +60]] time +=1time
def value(node): children = node["children"]ifnot children:returnsum(node["metadata"])returnsum( value(children[idx -1]) for idx in node["metadata"] if idx <=len(children) )value(tree)
s =8772board = np.zeros((300, 300), dtype=int)for row, col in itertools.product(range(300), range(300)): score = ((row +1+10) * (col +1) + s) * (row +1+10) board[row, col] = (score //100) %10board -=5best =0for row, col in itertools.product(range(300-2), range(300-2)): total = board[row : row +3, col : col +3].sum()if total > best: best = total result = row +1, col +1print(",".join(str(x) for x in result))
Part 2
Brute force over all sizes is slow, but works
Code
best =0for i inrange(3, 301):for row, col in itertools.product(range(301- i), range(301- i)): total = board[row : row + i, col : col + i].sum()if total > best: best = total result = row +1, col +1, iprint(",".join(str(x) for x in result))
data = load(12)lookup = {".": 0, "#": 1}generations =20initial_state = [lookup[char] for char in data[0] if char in lookup]state = np.pad(initial_state, generations)rules = [line.split(" => ") for line in data[2:]]alive = np.array( [[lookup[x] for x in rule[0]] for rule in rules if lookup[rule[1]] ==1])def update(cell_neighbors):return1* (notabs(np.array(alive) - cell_neighbors).sum(axis=1).min())states = [state.copy()]for i inrange(generations): state = scipy.ndimage.generic_filter( state, update, footprint=np.ones(5), mode="constant" ) states.append(state.copy())indices = np.arange(state.shape[0]) - generations(indices * state).sum()
Part 2
Simulating the 50 billion generations is impossible, so something cleverer is needed. My first attempt was to see how the total number of plants changed as the generations progressed, and I noticed that after comparatively gew generations the number was constant. Looking at how the pattern of plants changed after that period made extrapolation to 50 billion generations easy. An off-by-one and an off-by-a-factor-of-ten error later, and the problem was solved.
This was a slog. Lots of small pieces to keep track of
Code
class Unit:def__init__(self, kind, power=3):self.kind = kindself.hit_points =200self.attack_power = powerdef attack(self, other): other.hit_points -=self.attack_power@propertydef is_dead(self):returnself.hit_points <=0class Board:def__init__(self, rock, elves, goblins, elf_power=3):self.state = {}self.ymax =max(rock + elves + goblins, key=lambda x: x[0])[0]self.xmax =max(rock + elves + goblins, key=lambda x: x[1])[1]self.board = np.zeros((self.ymax +1, self.xmax +1), dtype=np.byte)for coord in rock:self.board[coord] =1for coord in elves:self.state[coord] = Unit("elf", elf_power)self.board[coord] =2for coord in goblins:self.state[coord] = Unit("goblin")self.board[coord] =3def__str__(self): char_map = {0: " ", 1: "█", 2: "E", 3: "G"}return"\n".join("".join(char_map[char] for char in line) for line inself.board )def combat(self): n =0whileself.any_alive("goblin") andself.any_alive("elf"): x =self.one_round() n +=1return (n + x), sum(unit.hit_points for unit inself.state.values())def any_alive(self, kind):for unit inself.state.values():if unit.kind == kind:returnTruereturnFalsedef count(self, kind):returnsum(x.kind == kind for x inself.state.values())def one_round(self): alive_count = {key: self.count(key) for key in ["elf", "goblin"]} unit_positions =sorted(zip(*np.where(self.board >1)))for unit_position in unit_positions:ifany(x ==0for x in alive_count.values()):return-1try: unit =self.state[unit_position]exceptKeyError: # Unit was killed earlier in the roundcontinue target_position =self.find_target(unit_position)if target_position isNone: new_position =self.find_move(unit_position)if new_position isNone:continueself.board[unit_position] =0delself.state[unit_position]self.state[new_position] = unitself.board[new_position] =2if unit.kind =="elf"else3 target_position =self.find_target(new_position)if target_position isNone:continue target =self.state[target_position] unit.attack(target)if target.is_dead: alive_count[target.kind] -=1delself.state[target_position]self.board[target_position] =0return0def find_move(self, position): paths = deque([(0, [], position)]) seen =set() target_kind =3if (self.board[position] ==2) else2 mask = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] target_mask = scipy.ndimage.convolve(self.board == target_kind, mask, mode="constant" ) & (self.board ==0) targets =sorted(zip(*np.where(target_mask))) candidates = [] target_distance = np.infwhile paths: distance, first_move, position = paths.popleft()if distance > target_distance:breakif position in seen:continueif position in targets: candidates.append((position, first_move)) target_distance = distance seen.add(position) y, x = positionfor neighbor in [(y -1, x), (y, x -1), (y, x +1), (y +1, x)]:ifself.board[neighbor] ==0and neighbor notin seen: move = neighbor ifnot first_move else first_move paths.append((distance +1, move, neighbor))ifnot candidates:returnNonereturnsorted(candidates, key=lambda x: x[0])[0][1]def find_target(self, position): y, x = position target_kind =2+ (self.board[position] ==2) targets = []for neighbor in [(y -1, x), (y, x -1), (y, x +1), (y +1, x)]:ifself.board[neighbor] == target_kind: targets.append((self.state[neighbor].hit_points, *neighbor))returnsorted(targets)[0][1:] if targets elseNonelookup = {".": 0, "#": 1, "E": 2, "G": 3}board = np.array([[lookup[char] for char in line] for line in load(15)])goblins =sorted(zip(*np.where(board ==3)))elves =sorted(zip(*np.where(board ==2)))rock =sorted(zip(*np.where(board ==1)))board = Board(rock, elves, goblins)np.product(board.combat())
Part 2
For the longest time I missed the requirement that when two targets were equally far away, the first one in reading order should be picked, so my units weren’t targetting correctly. Annoyingly, this error didn’t show up in any of the test cases
This is fairly straightforward. We have seven different operations, with two or three different addressing modes for each. We’ll start by building a dictionary of each operation, and then one of the valid addressing modes for each operation. From that, we can get a set of all the valid tuples of (operation, addressing mode 1, addressing mode 2).
We can then scan through the header lines of the input, and for each (before, command, after) triple, we can loop over the valid tuples, and check which ones convert before to after.
Code
import operatorfrom more_itertools import chunkedregisters = [0, 0, 0, 0]ops = {"add": operator.add,"mul": operator.mul,"ban": operator.iand,"bor": operator.ior,"set": lambda a, b: a,"gt": operator.gt,"eq": operator.eq,}# 1 is register, 0 is immediatevalid_modes = defaultdict(lambda: [(1, 0), (1, 1)])valid_modes["set"] = [(0, 0), (1, 0)]valid_modes["gt"] = [(0, 1), (1, 0), (1, 1)]valid_modes["eq"] = [(0, 1), (1, 0), (1, 1)]valid_ops = {(op,) + mode for op in ops for mode in valid_modes[op]}def get_operands(modes, operands, registers): result = []for mode, operand inzip(modes, operands): result.append(registers[operand] if mode else operand)return resultsplit =3298values = load(16, "int", footer=split)total =0for state, operation, new_state in chunked(values, 3): count =0 operands = operation[1:-1] result = new_state[operation[-1]]for op, *mode in valid_ops: a, b = get_operands(mode, operands, state)if ops[op](a, b) == result: count +=1if count >=3: total +=1total
Part 2
With that out of the way, we can intersect all the potentially valid assignments for each test case, and use that to figure out which opcode corresponds to what. Running the program after that is fairly straightforward.
Code
op_ids = defaultdict(lambda: valid_ops.copy())op_assignments = {}for state, operation, new_state in chunked(values, 3): op_number = operation[0]if op_number in op_assignments:continue operands = operation[1:-1] result = new_state[operation[-1]] candidate_ops =set()for op, *modes in valid_ops: a, b = get_operands(modes, operands, state)if ops[op](a, b) == result: candidate_ops.add((op,) +tuple(modes)) op_ids[op_number] &= candidate_opsiflen(op_ids[op_number]) ==1: assignment = op_ids[op_number].pop() op_assignments[op_number] = assignmentfor i inrange(16): op_ids[i].discard(assignment)state = [0, 0, 0, 0]program = load(16, "int", header=split)i =0for op_id, a, b, c in program: op, *modes = op_assignments[op_id] a, b = get_operands(modes, (a, b), state) state[c] = ops[op](a, b)state[0]
basic_ops = ["add", "mul", "ban", "bor"]name_to_op = { basic_op + mode: (basic_op, 1, int(mode =="r"))for basic_op in basic_opsfor mode in"ir"}name_to_op.update(**{"set"+ mode: ("set", int(mode =="r"), 0) for mode in"ir"})name_to_op.update(**{ comparison+ mode_pair: (comparison, int(mode_pair[0] =="r"), int(mode_pair[1] =="r"))for comparison in ["gt", "eq"]for mode_pair in ["ir", "ri", "rr"] })def interpret(op_name): name, *modes = name_to_op[op_name]return [ops[name], modes]def run(program, registers, ip_register): ip =0while ip <len(program): registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1return registers[0]data = load(19)registers = [0, 0, 0, 0, 0, 0]ip, program = data[0], data[1:]ip_register =int(re.findall(r"-?\d+", ip)[0])program = [x.split() for x in program]program = [interpret(line[0]) + [int(x) for x in line[1:]] for line in program]run(program, registers, ip_register)
Part 2
This is another one of those where changing the value in the first register causes the code to go through a different path in the program, and greatly increases the runtime.
Analysing the execution path shows that the code starts off by jumping to a setup section at the end, which has the main effect of placing a value in register 5. It then jumps back to two nested loops, which go through a lot of busywork, and store their results in register 0. Finally, after a long time, it hits the exit condition of both loops, and the program ends.
But thats equivalent to x0 += x2 if x5 % x2 == 0 else 0. The outer loop just runs over all values of x2 from 1 to x5. So what this code is really doing is calculating the sum of divisors function of whatever horrible mess is placed in x5 by the setup. We’ll get that by running through the code until the setup is over, and then calculate the sum of divisors:
Code
def sum_of_divisors(n): total = n +1for i inrange(2, n):if n % i ==0: total += ireturn totalregisters = [1, 0, 0, 0, 0, 0]ip =0while ip !=1: registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1sum_of_divisors(registers[5])
Based on this, we can parse the string from start to finish by tracking a list of current positions. If a direction is encountered, each of the positions is updated. Whenever an opening bracket is encountered, the matching close bracket is found, the subexpression is split into options, and each of those paths is parsed. The visited edges are tracked along the way in a global dictionary. It’s not super elegant, but it works.
Code
s = load(20)[0][1:-1]directions = {"N": 1j, "E": 1, "S": -1j, "W": -1}def find_closing_paren(s): count =0for idx, char inenumerate(s): count +=1if char =="("else-1if char ==")"else0if count ==0:return idxdef split_into_options(s): count =0 result = [] current =""for char in s:if char =="|"and count ==0: result.append(current) current =""else: current += char count +=1if char =="("else-1if char ==")"else0 result.append(current)return resultedges = defaultdict(bool)def endpoints(s, positions=None): i =0if positions isNone: positions = {0}else: positions = positions.copy()while i <len(s): char = s[i]if char =="(": delta = find_closing_paren(s[i:]) substring = s[i +1 : i + delta] options = split_into_options(substring) positions = {point for x in options for point in endpoints(x, positions)} i += deltaelse: direction = directions[char] positions = {x + direction for x in positions}for position in positions: edges[2* position - direction] =True i +=1return positionspoints = endpoints(s)def edge_to_nodes(x):return ( (x.real - x.real %2+1j* (x.imag - x.imag %2)) /2, (x.real + x.real %2+1j* (x.imag + x.imag %2)) /2, )nodes =len({node for edge in edges.keys() for node in edge_to_nodes(edge)})
With all that out of the way, the furthest room can be found with a BFS:
Code
def neighbors(state):return [ state + directionfor direction in directions.values()if edges[2* state + direction] ]utils.bfs(0, None, neighbors)
Part 2
And finding how many rooms require at least 1000 steps can be found with the same BFS, but ending whenever we get a cost greater than 1000
Analysing the structure of the program, we can see that the value of register 0 is only relevant in one place, namely as a comparison towards the end, where the program exits if x0 == x3. So for the first part, we just run the code until we hit that comparison the first time, and see what the value of x3 is; that must be the answer to the problem.
Code
data = load(21)registers = [0, 0, 0, 0, 0, 0]ip, program = data[0], data[1:]ip_register =int(re.findall(r"-?\d+", ip)[0])ip =0program = [x.split() for x in program]program = [interpret(line[0]) + [int(x) for x in line[1:]] for line in program]count =0whileTrue: registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1if ip ==28: count +=1if count ==2:breakregisters[3]
Part 2
For part 2, we need to do two things:
Analyse the provided script so that we can run it in pure python rather than the assembly it’s given in
Think about why there would even be a largest amount of iterations the code can make and still halt.
For part 1, it turns out that the code is repeatedly applying a fairly simple transformation to register 3, and then checking that against register 0. The only way that there could be a largest set of iterations is if the code eventualy produces the same value in register 3 as it did some number of iterations ago. The value we’re looking for is then the last value found in register 3 before the repeated value:
The next part is signifcantly more tricky. Blindly iterating through every point and asking “how many are you in range of” is a non-starter, due to the sizes of the numbers involved. SImilarly, going the other way and generating all the covered points for each nanobot and adding those up runs into the same problem.
A first thing to realise is that the sets of points covered by any given nanobot is an axis-aligned octahedron, centered at the nanobot’s position. The eight faces that define the octahedron have normal vectors [±1, ±1, ±1]. We can group these faces into four pairs of parallel faces, and each octahedron can be thought of as the intersection of the infinte regions between those pairs of planes. That means we can represent an octahedron as four sets of intervals, which I will call the \((s, t, u, v)\) basis. To intersect two octahedra we can simply intersect the corresponding intervals of each octahedron.
With the above considerations in hand, we could create a list of (region, number of intersections) pairs, with initial state [(all space, 0)]. We could then loop over all the nanobots, and for each one, calculate all the intersections with the existing list, and if they are non-zero, append them to the list, with updated intersection count.
That would build up a map of all the intersections, and the desired answer would be represented by the pair with greatest intersection count. That would look something like this:
Unfortunately, the above approach has a slight flaw: It ends up explicitly creating all \(\sim2^n\) possible intersections. With 1000 nanobots, that’s just not feasible.
If we focus on just one set of planes we can quite easily find where the biggest intersection is in that direction by scanning through from \(-\infty\) to \(\infty\), adding one for every left plane we encounter, and subtracting one for every right plane.
Code
openings =sorted(c[:, 0, 0])[::-1]closings =sorted(c[:, 0, -1])[::-1]current_closing, current_opening = closings.pop(), openings.pop()total =0edges = {}while openings:if current_opening < current_closing: total +=1 edges[current_opening] = total current_opening = openings.pop()else: total -=1 edges[current_closing] = total current_closing = closings.pop()max(edges.values())
But extending this to all four planes is non-obvious. Analysing the inputs shows that all the points overlap, apart from very few emitters. That means we can identify these, and the relevant points are just the intersection of all the other emitters, which can easily be found in the basis we’ve chosen.
This feels slightly like cheating, since it easily fails for other inputs.
A better approach would be to iteratively zoom in on promising areas of space. That is, starting with a bounding box containing all the points of interest, iteratively split the box into 16 sub-boxes, and for each of these boxes ask how many of the beacons it intersects with. Expanding the boxes in the order:
Most beacons in range first
Larger boxes before smaller boxes
Boxes closer to the origin before boxes further away
Means that by the time we see a box of size 1, we know that it is the optimal box, since
All regions of space which could potentially contain more beacons have already been examined by criterion 1
All larger regions of space which can see the same number of beacons have already been examined by criterion 2
There are no closer boxes of the same size which can see the same number of beacons by criterion 3
All we need to implement is thus a procedure for determining whether a box and a beacon intersect. But in the \((s, t, u, v)\) basis, that’s just determining whether the intervals that make up the box and the intervals that make up the beacon intersect. And it’s not too difficult to verify that two half-open intervals \(\left[ a, b\right)\) and \(\left[x, y\right)\) intersect if \(x \leq a < y\) or \(a \leq x < b\). Putting everything together gives
Code
def score_box(corner, length, c=c): x = c[:, :, 0] y = c[:, :, 1] left = (x <= corner) & (corner < y) right = (corner <= x) & (x - length < corner)return-(left | right).all(axis=1).sum()l =2** (int(np.ceil(np.log2(abs(c).max()))))corner =-l, -l, -l, -llength =2* lq = PriorityQueue()initial_state = score_box(corner, length), -length, 0, cornerq.put(initial_state)i =0while q.qsize() >0and i <1000: i +=1 s, neg_length, position, corner = q.get() l =-neg_lengthif l ==1:breakfor new_corner in ( np.array(list(itertools.product([0, 1], repeat=4))) * l //2+ corner ): score = score_box(new_corner, l //2) position =0if l >2elsemax(abs(new_corner)) q.put((score, -l //2, position, tuple(new_corner)))position
This was a bit of a slog, with a fair bit of attention needed to make sure that the requirements were implemented correctly.
Code
class Group:def__init__(self, n_units, hit_points, damage, initiative, damage_type, weaknesses=[], immunities=[], ):self.n_units = n_unitsself.hit_points = hit_pointsself.damage = damageself.initiative = initiativeself.damage_type = damage_typeself.weaknesses = weaknessesself.immunities = immunitiesdef__repr__(self):return (f"Group({self.n_units}, {self.hit_points}, {self.damage}, "f"{self.initiative}, {self.damage_type})" )@propertydef effective_power(self):returnself.damage *self.n_units@propertydef is_alive(self):returnself.n_units >0def attack(self, other): other.defend(self.calculate_damage(other))def calculate_damage(self, other):ifself.damage_type in other.immunities:return0elifself.damage_type in other.weaknesses:return2*self.effective_powerreturnself.effective_powerdef defend(self, damage):self.n_units =self.n_units - damage //self.hit_pointsself.n_units =max(self.n_units, 0)def selection_order(self):return (-self.effective_power, -self.initiative)def select_target(self, others): max_damage = (0, 0, 0)for other in others: damage =self.calculate_damage(other) key = (damage, other.effective_power, other.initiative)if key > max_damage: max_damage = key result = otherif max_damage[0] >0:return resultelse:returnNonedef select_targets(attackers, defenders): targets = [] defenders = defenders.copy()for group insorted(attackers, key=lambda x: x.selection_order()): target = group.select_target(defenders)if target isnotNone: targets.append((group, target)) defenders.remove(target)return targetsdef one_round(infection, immune): matchup = select_targets(infection, immune) + select_targets(immune, infection)for match insorted(matchup, key=lambda x: -x[0].initiative): match[0].attack(match[1])return [x for x in infection if x.is_alive], [x for x in immune if x.is_alive]data = [x.split("\n")[1:] for x in load(24, "raw")[:-1].split("\n\n")]def parse(line): n_units, hit_points, damage, initiative = [int(x) for x in re.findall("-?\d+", line) ] damage_type = re.search("(\w*) damage", line).groups()[0] immunity_match = re.search("\((.*)\)", line)ifnot immunity_match: weaknesses, immunities = [], []else: weaknesses, immunities = parse_immunities(immunity_match.groups()[0])return Group( n_units, hit_points, damage, initiative, damage_type, weaknesses, immunities )def parse_immunities(exp): weaknesses = [] immunities = []for sequence in exp.split("; "): first, middle, *rest = sequence.split()if first =="weak": kind = weaknesseselse: kind = immunities kind +="".join(rest).split(",")return weaknesses, immunitiesimmune, infection = [[parse(line) for line in block] for block in data]while immune and infection: immune, infection = one_round(immune, infection)result = immune if immune else infectionsum(x.n_units for x in result)
Part 2
I did a binary search for this one. The players can get stuck in a draw, so we need to take that into account.
Code
def winner(boost): immune, infection = [[parse(line) for line in block] for block in data]for group in immune: group.damage += boostwhile immune and infection: total =sum(x.n_units for x in immune + infection) immune, infection = one_round(immune, infection)if total ==sum(x.n_units for x in immune + infection):return0returnsum(x.n_units for x in immune)high =1whilenot winner(high): high *=2low = high //2while high - low >1: mid = (high + low) //2if winner(mid): high = midelse: low = midwinner(high)
I could write this by hand. Or I could realise that the problem description is perfectly suited for a union-find/disjoint set data structure:
Code
from scipy.cluster.hierarchy import DisjointSetpoints = [tuple(x) for x in load(25, "int")]disjoint_set = DisjointSet(points)for x inrange(len(points)): deltas = np.abs(np.array(points) - np.array(points[x])).sum(axis=1)for y inrange(x +1, len(points)):if deltas[y] <=3: disjoint_set.merge(points[x], points[y])disjoint_set.n_subsets
Source Code
---title: 2018 Solutions---# Imports```{python}# | eval: true# | output: falseimport functoolsimport itertoolsimport osimport reimport stringimport sysfrom collections import defaultdict, dequefrom pathlib import Pathfrom queue import PriorityQueueimport more_itertoolsimport numpy as npimport pandas as pdimport scipyfrom IPython.display import HTMLsys.path.insert(1, os.path.join(sys.path[0], ".."))import utilsload = utils.year_load(2018)```# [Day 1: Chronal Calibration](https://adventofcode.com/2018/day/1)## Part 1```{python}data = load(1, "np")data.sum()```## Part 2```{python}i =0value =0seen = {}whileTrue:if value in seen:break seen[value] =1 value += data[i %len(data)] i +=1value```# [Day 2: Inventory Managment System](https://adventofcode.com/2018/day/2)## Part 1```{python}lines = [np.array([ord(y) for y in x]) for x in load(2)]twos =sum([2in np.unique(list(line), return_counts=True)[1] for line in lines])twos *sum([3in np.unique(list(line), return_counts=True)[1] for line in lines])```## Part 2```{python}for s1 in lines:for s2 in lines:iflen(s2) !=len(s1): continueif (s2 - s1 !=0).sum() ==1: result =''.join(chr(x) for x in s1[np.where(s1 == s2)])breakelse:continuebreakresult```# [Day 3: No Matter How You Slice It](https://adventofcode.com/2018/day/3)## Part 1```{python}num = re.compile(r"\d+")data = np.array([[int(x) for x in re.findall(num, line)] for line in load(3)])field = np.zeros([1000, 1000])for pid, x, y, w, h in data: field[x : x + w, y : y + h] +=1(field >1).sum()```## Part 2```{python}for pid, x, y, w, h in data:if (field[x : x + w, y : y + h] ==1).all():breakpid```# [Day 4: Repose Record](https://adventofcode.com/2018/day/4)## Part 1```{python}from time import strptimeevents = [event[1:].split("] ") for event in load(4)]date_format ="%Y-%m-%d %H:%M"events =sorted(events, key=lambda event: strptime(event[0], date_format))guards = {}while events: event = events.pop(0)if"Guard"in event[1]: active_guard = event[1][7:-13]if active_guard notin guards: guards[active_guard] = np.zeros(60)continue end = events.pop(0) guards[active_guard][int(event[0][-2:]) : int(end[0][-2:])] +=1sleepiest_guard =sorted(guards.keys(), key=lambda x: -guards[x].sum())[0]int(sleepiest_guard) * guards[sleepiest_guard].argmax()```## Part 2```{python}sleepiest_guard =sorted(guards.keys(), key=lambda x: -max(guards[x]))[0]int(sleepiest_guard) * guards[sleepiest_guard].argmax()```# [Day 5: Alchemical Reduction](https://adventofcode.com/2018/day/5)## Part 1```{python}s = load(5)[0]defreduce(s): l =len(s)for char in string.ascii_lowercase: s = s.replace(f"{char + char.swapcase()}", "") s = s.replace(f"{char.swapcase() + char}", "")return l if l ==len(s) elsereduce(s)reduce(s)```## Part 2```{python}min(reduce(s.replace(c, "").replace(c.upper(), "")) for c in string.ascii_lowercase)```# [Day 6: Chronal Coordinates](https://adventofcode.com/2018/day/6)## Part 1The numbers involved are small enough that brute force is a viable approach. It's ugly, but it works. The question is basically asking for the voronoi diagram of the initial points using the L1 metric, but I'm too slow to see an efficient way of calculating that. The approach would have to be something like determining the boundary line between each pair of points, and then intersecting all of those half planes to get the voronoi cell.```{python}# | eval: true# | output: falsedata = load(6)coordinates = np.array([list(map(int, re.findall("\d+", line))) for line in data])xmax, ymax = coordinates.max(axis=0)board = np.zeros([xmax, ymax], dtype=int)for x, y in itertools.product(range(xmax), range(ymax)): distances = (np.abs(coordinates - np.array([x, y]))).sum(axis=1) values, counts = np.unique(distances, return_counts=True) board[x, y] = distances.argmin() if counts[0] ==1else-1infinite = functools.reduce(lambda x, y: set(x) |set(y), [board[0], board[:, 0], board[-1], board[:, -1]])max( [ (board == seed).sum() if seed notin infinite else0for seed inrange(len(coordinates)) ])```## Part 2```{python}# | eval: true# | output: falseboard = np.zeros([xmax, ymax], dtype=int)for x, y in itertools.product(range(xmax), range(ymax)): board[x, y] = (np.abs(coordinates - np.array([x, y]))).sum()(board <10000).sum()```## BonusI haven't figured out the cleanest way of solving part 1, but here's an approach that's slightly better than brute force. We can basically flood fill the grid, starting with the seed locations given in the input, and then expanding one step at a time. That way we end up considering the effect of at most four (and usually only one or two) seeds on each location, and we avoid having to calculate the distance from the point to every single seed.```{python}# | eval: true# | output: falseimport matplotlib.pyplot as pltboard = np.zeros([xmax +1, ymax +1], dtype=int)def expand_one(cells, idx, to_paint): new_cells = []for neighbor in get_neighbors(cells):if board[neighbor] ==0:if neighbor in to_paint:del to_paint[neighbor] board[neighbor] =-1else: to_paint[neighbor] = idx +1 new_cells.append(neighbor)return new_cellsdef get_neighbors(cells): neighbors = []for x, y in cells: candidates = [(x -1, y), (x +1, y), (x, y -1), (x, y +1)] neighbors += [ (x, y) for x, y in candidates if (0<= x <= xmax) and (0<= y <= ymax) ]returnset(neighbors)```We can animate the process of expanding each seed```{python}# | eval: true# | fig-cap: How each seed expands to fill its own areato_paint = {tuple(x): idx +1for idx, x inenumerate(coordinates)}system = [[x] for x in to_paint.keys()]boards = []while to_paint:for key in to_paint: board[key] = to_paint[key] to_paint = {}for idx, cells inenumerate(system): system[idx] = expand_one(cells, idx, to_paint) image = board.astype(float).copy() image[image ==0] = np.nan boards.append(image)import matplotlib.animation as animations =3.0fig = plt.figure(figsize=(s, s * ymax / xmax))l =len(boards)i =0im = plt.imshow(boards[0], animated=True, cmap="inferno")plt.xticks([])plt.yticks([])def updatefig(*args):global iif i <len(boards) -1: i +=1else: i =0 im.set_array(boards[i])return (im,)a = animation.FuncAnimation(fig, updatefig, blit=True, frames=len(boards), interval=10)plt.close(fig)HTML(a.to_jshtml())```# [Day 7: The Sum of Its Parts](https://adventofcode.com/2018/day/7)## Part 1```{python}constraints = {}lines = load(7)for tokens inmap(str.split, lines): parent, child = tokens[1], tokens[-3]if parent notin constraints: constraints[parent] = ["", ""]if child notin constraints: constraints[child] = ["", ""] constraints[parent][0] += child constraints[child][1] += parentexecuted =""available = []def pop_node(node, ordering):for child in ordering[node][0]: idx = ordering[child][1].index(node) ordering[child] = [ ordering[child][0], ordering[child][1][:idx] + ordering[child][1][idx +1 :], ]del ordering[node]part1 = constraints.copy()while part1: available =sorted(set(available + [key for key in part1 ifnot part1[key][1]])) current = available.pop(0) executed += current pop_node(current, part1)executed```## Part 2```{python}active = []n_workers =5part2 = constraints.copy()time =-1while part2: new_active = []for key, count in active:if count: new_active += [[key, count -1]]else: pop_node(key, part2) active = new_active available =sorted(set(key for key in part2 ifnot part2[key][1]) -set(x[0] for x in active) )while available andlen(active) < n_workers: key = available.pop(0) active += [[key, ord(key) -ord("A") +60]] time +=1time```# [Day 8: Memory Maneuver](https://adventofcode.com/2018/day/8)## Part 1```{python}data = load(8, "int")[0]def parse(tree_list): result = {"children": []} n_children, n_metadata = tree_list[:2] tree_list = tree_list[2:]for _ inrange(n_children): tree_list, child = parse(tree_list) result["children"] += [child] result["metadata"] = tree_list[:n_metadata]return tree_list[n_metadata:], resultdef weigh(tree):ifnot tree["children"]:returnsum(tree["metadata"])returnsum(tree["metadata"]) +sum(map(weigh, tree["children"]))tree = parse(data)[1]weigh(tree)```## Part 2```{python}def value(node): children = node["children"]ifnot children:returnsum(node["metadata"])returnsum( value(children[idx -1]) for idx in node["metadata"] if idx <=len(children) )value(tree)```# [Day 9: Marble Mania](https://adventofcode.com/2018/day/9)## Part 1```{python}n_players =419n_marbles =72164def run(n_players, n_marbles): scores = defaultdict(int) circle = deque([0])for marble inrange(1, n_marbles +1):if marble %23==0: circle.rotate(7) scores[marble % n_players] += marble + circle.pop() circle.rotate(-1)else: circle.rotate(-1) circle.append(marble)returnmax(scores.values())run(n_players, n_marbles)```## Part 2```{python}run(n_players, n_marbles *100)```# [Day 10: The Stars Align](https://adventofcode.com/2018/day/10)## Part 1```{python}array = np.array(load(10, "int"))positions = array[:, :2].copy()velocities = array[:, 2:]bounding_box = np.product(positions.max(axis=0) - positions.min(axis=0))old_bounding_box = np.infwhile bounding_box < old_bounding_box: positions += velocities old_bounding_box = bounding_box bounding_box = np.product(positions.max(axis=0) - positions.min(axis=0))positions -= velocitiesboard = np.zeros(positions.max(axis=0) - positions.min(axis=0) +1)board[ (positions[:, 0] - positions[:, 0].min(), positions[:, 1] - positions[:, 1].min())] =1print("\n".join(["".join("█"if char else" "for char in line) for line in board.T]))```## Part 2```{python}int(((positions[0] - array[0, :2]) / velocities[0])[0])```# [Day 11: Chronal Charge](https://adventofcode.com/2018/day/11)## Part 1```{python}s =8772board = np.zeros((300, 300), dtype=int)for row, col in itertools.product(range(300), range(300)): score = ((row +1+10) * (col +1) + s) * (row +1+10) board[row, col] = (score //100) %10board -=5best =0for row, col in itertools.product(range(300-2), range(300-2)): total = board[row : row +3, col : col +3].sum()if total > best: best = total result = row +1, col +1print(",".join(str(x) for x in result))```## Part 2Brute force over all sizes is slow, but works```{python}best =0for i inrange(3, 301):for row, col in itertools.product(range(301- i), range(301- i)): total = board[row : row + i, col : col + i].sum()if total > best: best = total result = row +1, col +1, iprint(",".join(str(x) for x in result))```# [Day 12: Subterranean Sustainability](https://adventofcode.com/2018/day/12)## Part 1```{python}data = load(12)lookup = {".": 0, "#": 1}generations =20initial_state = [lookup[char] for char in data[0] if char in lookup]state = np.pad(initial_state, generations)rules = [line.split(" => ") for line in data[2:]]alive = np.array( [[lookup[x] for x in rule[0]] for rule in rules if lookup[rule[1]] ==1])def update(cell_neighbors):return1* (notabs(np.array(alive) - cell_neighbors).sum(axis=1).min())states = [state.copy()]for i inrange(generations): state = scipy.ndimage.generic_filter( state, update, footprint=np.ones(5), mode="constant" ) states.append(state.copy())indices = np.arange(state.shape[0]) - generations(indices * state).sum()```## Part 2Simulating the 50 billion generations is impossible, so something cleverer is needed. My first attempt was to see how the total number of plants changed as the generations progressed, and I noticed that after comparatively gew generations the number was constant. Looking at how the pattern of plants changed after that period made extrapolation to 50 billion generations easy. An off-by-one and an off-by-a-factor-of-ten error later, and the problem was solved.```{python}generations =150state = np.pad(initial_state, generations)states = [state.copy()]for i inrange(1, generations): new_state = scipy.ndimage.generic_filter( state, update, footprint=np.ones(5), mode="constant" ) states.append(new_state.copy())if (new_state == np.roll(state, 1)).all():break state = new_state( ((np.arange(new_state.shape[0]) - generations) + (50_000_000_000- i)) * new_state).sum()```# [Day 13: Mine Cart Madness](https://adventofcode.com/2018/day/13)## Part 1[Mine Cart Madness](https://adventofcode.com/2018/day/13)```{python}characters =r" |-/\+><v^"cart_labels = {">": ("-", 1), "<": ("-", -1), "v": ("|", -1j), "^": ("|", 1j)}graph = {}carts = []carts_part2 = []for y, line inenumerate(load(13)):for x, char inenumerate(line): position = x -1j* yif char in cart_labels: char, direction = cart_labels[char] carts.append([position, direction, itertools.cycle([1j, 1, -1j])]) carts_part2.append([position, direction, itertools.cycle([1j, 1, -1j])]) graph[position] = characters.index(char)i =0whileTrue:for cart in carts: new_position = cart[0] + cart[1]if new_position in [x[0] for x in carts]: result =int(new_position.real), -int(new_position.imag)break cart[0] = new_position tile = graph[new_position]if tile ==3: cart[1] = cart[1].imag +1j* cart[1].realelif tile ==4: cart[1] =-(cart[1].imag +1j* cart[1].real)elif tile ==5: cart[1] = cart[1] *next(cart[2])else: i +=1continuebreakprint(result)```## Part 2```{python}carts = carts_part2carts.sort(key=lambda x: (-x[0].imag, x[0].real))whilelen(carts) >1: is_crashed = [False] *len(carts)for idx, cart inenumerate(carts):if is_crashed[idx]:continue new_position = cart[0] + cart[1] crashes = [ ifor i, cart2 inenumerate(carts)if new_position == cart2[0] andnot is_crashed[i] ]for crash in crashes: is_crashed[idx] =True is_crashed[crash] =Truecontinue cart[0] = new_position tile = graph[new_position]if tile ==3: cart[1] = cart[1].imag +1j* cart[1].realelif tile ==4: cart[1] =-(cart[1].imag +1j* cart[1].real)elif tile ==5: cart[1] = cart[1] *next(cart[2]) carts = [cart for (crash, cart) inzip(is_crashed, carts) ifnot crash] carts.sort(key=lambda x: (-x[0].imag, x[0].real))print(int(carts[0][0].real), int(-carts[0][0].imag), sep=",")```# [Day 14: Chocolate Charts](https://adventofcode.com/2018/day/14)## Part 1```{python}def solve(n): e1, e2 =0, 1 recipes = [3, 7]whilelen(recipes) < n +10: v1, v2 = recipes[e1], recipes[e2] tens, units =divmod(v1 + v2, 10) recipes += [tens, units] if tens else [units] l =len(recipes) e1, e2 = (e1 + v1 +1) % l, (e2 + v2 +1) % l# print(recipes)return functools.reduce(lambda x, y: 10* x + y, recipes[n : n +10])solve(157901)```## Part 2```{python}def solve(n): seq = [int(x) for x instr(n)] s =len(seq) e1, e2 =0, 1 recipes = [3, 7]while recipes[-s:] != seq and recipes[-s -1 : -1] != seq: v1, v2 = recipes[e1], recipes[e2] tens, units =divmod(v1 + v2, 10) recipes += [tens, units] if tens else [units] l =len(recipes) e1, e2 = (e1 + v1 +1) % l, (e2 + v2 +1) % l delta =0if recipes[-s:] == seq else1return l - s - deltasolve("157901")```# [Day 15: Beverage Bandits](https://adventofcode.com/2018/day/15)## Part 1This was a slog. Lots of small pieces to keep track of```{python}class Unit:def__init__(self, kind, power=3):self.kind = kindself.hit_points =200self.attack_power = powerdef attack(self, other): other.hit_points -=self.attack_power@propertydef is_dead(self):returnself.hit_points <=0class Board:def__init__(self, rock, elves, goblins, elf_power=3):self.state = {}self.ymax =max(rock + elves + goblins, key=lambda x: x[0])[0]self.xmax =max(rock + elves + goblins, key=lambda x: x[1])[1]self.board = np.zeros((self.ymax +1, self.xmax +1), dtype=np.byte)for coord in rock:self.board[coord] =1for coord in elves:self.state[coord] = Unit("elf", elf_power)self.board[coord] =2for coord in goblins:self.state[coord] = Unit("goblin")self.board[coord] =3def__str__(self): char_map = {0: " ", 1: "█", 2: "E", 3: "G"}return"\n".join("".join(char_map[char] for char in line) for line inself.board )def combat(self): n =0whileself.any_alive("goblin") andself.any_alive("elf"): x =self.one_round() n +=1return (n + x), sum(unit.hit_points for unit inself.state.values())def any_alive(self, kind):for unit inself.state.values():if unit.kind == kind:returnTruereturnFalsedef count(self, kind):returnsum(x.kind == kind for x inself.state.values())def one_round(self): alive_count = {key: self.count(key) for key in ["elf", "goblin"]} unit_positions =sorted(zip(*np.where(self.board >1)))for unit_position in unit_positions:ifany(x ==0for x in alive_count.values()):return-1try: unit =self.state[unit_position]exceptKeyError: # Unit was killed earlier in the roundcontinue target_position =self.find_target(unit_position)if target_position isNone: new_position =self.find_move(unit_position)if new_position isNone:continueself.board[unit_position] =0delself.state[unit_position]self.state[new_position] = unitself.board[new_position] =2if unit.kind =="elf"else3 target_position =self.find_target(new_position)if target_position isNone:continue target =self.state[target_position] unit.attack(target)if target.is_dead: alive_count[target.kind] -=1delself.state[target_position]self.board[target_position] =0return0def find_move(self, position): paths = deque([(0, [], position)]) seen =set() target_kind =3if (self.board[position] ==2) else2 mask = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] target_mask = scipy.ndimage.convolve(self.board == target_kind, mask, mode="constant" ) & (self.board ==0) targets =sorted(zip(*np.where(target_mask))) candidates = [] target_distance = np.infwhile paths: distance, first_move, position = paths.popleft()if distance > target_distance:breakif position in seen:continueif position in targets: candidates.append((position, first_move)) target_distance = distance seen.add(position) y, x = positionfor neighbor in [(y -1, x), (y, x -1), (y, x +1), (y +1, x)]:ifself.board[neighbor] ==0and neighbor notin seen: move = neighbor ifnot first_move else first_move paths.append((distance +1, move, neighbor))ifnot candidates:returnNonereturnsorted(candidates, key=lambda x: x[0])[0][1]def find_target(self, position): y, x = position target_kind =2+ (self.board[position] ==2) targets = []for neighbor in [(y -1, x), (y, x -1), (y, x +1), (y +1, x)]:ifself.board[neighbor] == target_kind: targets.append((self.state[neighbor].hit_points, *neighbor))returnsorted(targets)[0][1:] if targets elseNonelookup = {".": 0, "#": 1, "E": 2, "G": 3}board = np.array([[lookup[char] for char in line] for line in load(15)])goblins =sorted(zip(*np.where(board ==3)))elves =sorted(zip(*np.where(board ==2)))rock =sorted(zip(*np.where(board ==1)))board = Board(rock, elves, goblins)np.product(board.combat())```## Part 2For the longest time I missed the requirement that when two targets were equally far away, the first one in reading order should be picked, so my units weren't targetting correctly. Annoyingly, this error didn't show up in any of the test cases```{python}def test(power): board = Board(rock, elves, goblins, elf_power=power)while board.any_alive("goblin"): board.one_round()if board.count("elf") !=len(elves):returnFalsereturnTruefor power inrange(3, 200):if test(power):breakboard = Board(rock, elves, goblins, power)x = board.combat()print(np.product(x))```# [Day 16: Chronal Classification](https://adventofcode.com/2018/day/16)## Part 1This is fairly straightforward. We have seven different operations, with two or three different addressing modes for each. We'll start by building a dictionary of each operation, and then one of the valid addressing modes for each operation. From that, we can get a set of all the valid tuples of (operation, addressing mode 1, addressing mode 2).We can then scan through the header lines of the input, and for each (before, command, after) triple, we can loop over the valid tuples, and check which ones convert before to after.```{python}import operatorfrom more_itertools import chunkedregisters = [0, 0, 0, 0]ops = {"add": operator.add,"mul": operator.mul,"ban": operator.iand,"bor": operator.ior,"set": lambda a, b: a,"gt": operator.gt,"eq": operator.eq,}# 1 is register, 0 is immediatevalid_modes = defaultdict(lambda: [(1, 0), (1, 1)])valid_modes["set"] = [(0, 0), (1, 0)]valid_modes["gt"] = [(0, 1), (1, 0), (1, 1)]valid_modes["eq"] = [(0, 1), (1, 0), (1, 1)]valid_ops = {(op,) + mode for op in ops for mode in valid_modes[op]}def get_operands(modes, operands, registers): result = []for mode, operand inzip(modes, operands): result.append(registers[operand] if mode else operand)return resultsplit =3298values = load(16, "int", footer=split)total =0for state, operation, new_state in chunked(values, 3): count =0 operands = operation[1:-1] result = new_state[operation[-1]]for op, *mode in valid_ops: a, b = get_operands(mode, operands, state)if ops[op](a, b) == result: count +=1if count >=3: total +=1total```## Part 2With that out of the way, we can intersect all the potentially valid assignments for each test case, and use that to figure out which opcode corresponds to what. Running the program after that is fairly straightforward.```{python}op_ids = defaultdict(lambda: valid_ops.copy())op_assignments = {}for state, operation, new_state in chunked(values, 3): op_number = operation[0]if op_number in op_assignments:continue operands = operation[1:-1] result = new_state[operation[-1]] candidate_ops =set()for op, *modes in valid_ops: a, b = get_operands(modes, operands, state)if ops[op](a, b) == result: candidate_ops.add((op,) +tuple(modes)) op_ids[op_number] &= candidate_opsiflen(op_ids[op_number]) ==1: assignment = op_ids[op_number].pop() op_assignments[op_number] = assignmentfor i inrange(16): op_ids[i].discard(assignment)state = [0, 0, 0, 0]program = load(16, "int", header=split)i =0for op_id, a, b, c in program: op, *modes = op_assignments[op_id] a, b = get_operands(modes, (a, b), state) state[c] = ops[op](a, b)state[0]```# [Day 17: Reservoir Research](https://adventofcode.com/2018/day/17)## Part 1```{python}data = load(17, "int")variables = [line[0] for line in load(17)]xmin =min(line[0] if v =="x"else line[1] for v, line inzip(variables, data))xmax =max(line[0] if v =="x"else line[2] for v, line inzip(variables, data))ymin =min(line[0] if v =="y"else line[1] for v, line inzip(variables, data))ymax =max(line[0] if v =="y"else line[2] for v, line inzip(variables, data))r, a, f, s =ord("#"), ord("."), ord("|"), ord("~")board = np.zeros((ymax - ymin +1, xmax - xmin +3), dtype=int) + afor v, line inzip(variables, data):if v =="x": board[line[1] - ymin : line[2] - ymin +1, line[0] - xmin +1] = relse: board[line[0] - ymin, line[1] - xmin +1 : line[2] - xmin +2] = rsource = (-1, 500- xmin +1)tips = deque([source])while tips: y, x = tips.popleft() window = board[y +1 :, x] solid = (window == r) | (window == s)ifnot solid.any(): board[y +1 :, x] = felse: first_rock = solid.argmax() board[y +1 : y +1+ first_rock, x] = f y += first_rock r_platform = ((board[y +1, x:] != r) & (board[y +1, x:] != s)).argmax() l_platform = ((board[y +1, :x] != r) & (board[y +1, :x] != s))[::-1].argmax() r_wall = mask.argmax() if (mask := (board[y, x:] == r)).any() else np.inf l_wall = mask.argmax() if (mask := (board[y, :x] == r)[::-1]).any() else np.inf fill = s to_add = []if l_wall > l_platform: fill = f to_add += [(y, x - l_platform -1)] left = x - l_platform -1else: left = x - l_wallif r_wall > r_platform: fill = f to_add += [(y, x + r_platform)] right = x + r_platform +1else: right = x + r_wallif fill == s: to_add += [(y -1, x)]if (board[y, left:right] != fill).any(): board[y, left:right] = fillfor element in to_add: tips.append(element)((board == f) | (board == s)).sum()```## Part 2This was a very weird part 2, since I basically solved it already in part 1```{python}(board == s).sum()```# [Day 18: Settlers of The North Pole](https://adventofcode.com/2018/day/18)## Part 1```{python}state_map = {".": 0, "|": 1, "#": 2}reverse_map = {v: k for k, v in state_map.items()}state = np.array([[state_map[char] for char in line] for line in load(18)])weights = np.ones((3, 3))weights[1, 1] =0seen = {}for i inrange(10): seen[tuple(state.flatten())] = i tree_nb = scipy.ndimage.convolve(1* (state ==1), weights, mode="constant") lumber_nb = scipy.ndimage.convolve(1* (state ==2), weights, mode="constant") change = ( ((state ==0) & (tree_nb >=3))| ((state ==1) & (lumber_nb >=3))| ((state ==2) & ((tree_nb ==0) | (lumber_nb ==0))) ) state = (state + change) %3(state ==1).sum() * (state ==2).sum()```## Part 2There's no way we can run the simulation for that long. Hopefully we'll get a repeat before then```{python}target =1000000000for i inrange(10, target):iftuple(state.flatten()) in seen: start = seen[tuple(state.flatten())] reversed_dict = {v: k for k, v in seen.items()} state = np.array(reversed_dict[start + (target - start) % (i - start)])break seen[tuple(state.flatten())] = i tree_nb = scipy.ndimage.convolve(1* (state ==1), weights, mode="constant") lumber_nb = scipy.ndimage.convolve(1* (state ==2), weights, mode="constant") change = ( ((state ==0) & (tree_nb >=3))| ((state ==1) & (lumber_nb >=3))| ((state ==2) & ((tree_nb ==0) | (lumber_nb ==0))) ) state = (state + change) %3(state ==1).sum() * (state ==2).sum()```# [Day 19: Go With The Flow](https://adventofcode.com/2018/day/19)## Part 1```{python}basic_ops = ["add", "mul", "ban", "bor"]name_to_op = { basic_op + mode: (basic_op, 1, int(mode =="r"))for basic_op in basic_opsfor mode in"ir"}name_to_op.update(**{"set"+ mode: ("set", int(mode =="r"), 0) for mode in"ir"})name_to_op.update(**{ comparison+ mode_pair: (comparison, int(mode_pair[0] =="r"), int(mode_pair[1] =="r"))for comparison in ["gt", "eq"]for mode_pair in ["ir", "ri", "rr"] })def interpret(op_name): name, *modes = name_to_op[op_name]return [ops[name], modes]def run(program, registers, ip_register): ip =0while ip <len(program): registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1return registers[0]data = load(19)registers = [0, 0, 0, 0, 0, 0]ip, program = data[0], data[1:]ip_register =int(re.findall(r"-?\d+", ip)[0])program = [x.split() for x in program]program = [interpret(line[0]) + [int(x) for x in line[1:]] for line in program]run(program, registers, ip_register)```## Part 2This is another one of those where changing the value in the first register causes the code to go through a different path in the program, and greatly increases the runtime.Analysing the execution path shows that the code starts off by jumping to a setup section at the end, which has the main effect of placing a value in register 5. It then jumps back to two nested loops, which go through a lot of busywork, and store their results in register 0. Finally, after a long time, it hits the exit condition of both loops, and the program ends.Looking at the inner loop, it does the following``` pythonfor x4 inrange(1, x5 +1):if x4 * x2 == x5: x0 += x2```But thats equivalent to `x0 += x2 if x5 % x2 == 0 else 0`. The outer loop just runs over all values of x2 from 1 to x5. So what this code is really doing is calculating the sum of divisors function of whatever horrible mess is placed in x5 by the setup. We'll get that by running through the code until the setup is over, and then calculate the sum of divisors:```{python}def sum_of_divisors(n): total = n +1for i inrange(2, n):if n % i ==0: total += ireturn totalregisters = [1, 0, 0, 0, 0, 0]ip =0while ip !=1: registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1sum_of_divisors(registers[5])```# [Day 20: A Regular Map](https://adventofcode.com/2018/day/20)## Part 1The hard part of this problem is moving from the regex representation of the map to a more sensible one. A pseudo-ebnf of the grammar is:- `path = direction, path | bracketed_path, path | options`- `direction = n|e|w|s`- `bracketed_path = (, path, )`- `options = (path, |)*, path?`Based on this, we can parse the string from start to finish by tracking a list of current positions. If a direction is encountered, each of the positions is updated. Whenever an opening bracket is encountered, the matching close bracket is found, the subexpression is split into options, and each of those paths is parsed. The visited edges are tracked along the way in a global dictionary. It's not super elegant, but it works.```{python}s = load(20)[0][1:-1]directions = {"N": 1j, "E": 1, "S": -1j, "W": -1}def find_closing_paren(s): count =0for idx, char inenumerate(s): count +=1if char =="("else-1if char ==")"else0if count ==0:return idxdef split_into_options(s): count =0 result = [] current =""for char in s:if char =="|"and count ==0: result.append(current) current =""else: current += char count +=1if char =="("else-1if char ==")"else0 result.append(current)return resultedges = defaultdict(bool)def endpoints(s, positions=None): i =0if positions isNone: positions = {0}else: positions = positions.copy()while i <len(s): char = s[i]if char =="(": delta = find_closing_paren(s[i:]) substring = s[i +1 : i + delta] options = split_into_options(substring) positions = {point for x in options for point in endpoints(x, positions)} i += deltaelse: direction = directions[char] positions = {x + direction for x in positions}for position in positions: edges[2* position - direction] =True i +=1return positionspoints = endpoints(s)def edge_to_nodes(x):return ( (x.real - x.real %2+1j* (x.imag - x.imag %2)) /2, (x.real + x.real %2+1j* (x.imag + x.imag %2)) /2, )nodes =len({node for edge in edges.keys() for node in edge_to_nodes(edge)})```With all that out of the way, the furthest room can be found with a BFS:```{python}def neighbors(state):return [ state + directionfor direction in directions.values()if edges[2* state + direction] ]utils.bfs(0, None, neighbors)```## Part 2And finding how many rooms require at least 1000 steps can be found with the same BFS, but ending whenever we get a cost greater than 1000```{python}end_condition =lambda cost, state: cost >=1000nodes -len(utils.bfs(0, end_condition, neighbors, return_visited=True))```# [Day 21: Chronal Conversion](https://adventofcode.com/2018/day/21)## Part 1Analysing the structure of the program, we can see that the value of register 0 is only relevant in one place, namely as a comparison towards the end, where the program exits if `x0 == x3`. So for the first part, we just run the code until we hit that comparison the first time, and see what the value of x3 is; that must be the answer to the problem.```{python}data = load(21)registers = [0, 0, 0, 0, 0, 0]ip, program = data[0], data[1:]ip_register =int(re.findall(r"-?\d+", ip)[0])ip =0program = [x.split() for x in program]program = [interpret(line[0]) + [int(x) for x in line[1:]] for line in program]count =0whileTrue: registers[ip_register] = ip op, modes, a, b, c = program[ip] a, b = get_operands(modes, (a, b), registers) registers[c] = op(a, b) ip = registers[ip_register] +1if ip ==28: count +=1if count ==2:breakregisters[3]```## Part 2For part 2, we need to do two things:1. Analyse the provided script so that we can run it in pure python rather than the assembly it's given in2. Think about why there would even be a largest amount of iterations the code can make and still halt.For part 1, it turns out that the code is repeatedly applying a fairly simple transformation to register 3, and then checking that against register 0. The only way that there could be a largest set of iterations is if the code eventualy produces the same value in register 3 as it did some number of iterations ago. The value we're looking for is then the last value found in register 3 before the repeated value:```{python}x1, x3 =0, 0seen = []while x3 notin seen: seen.append(x3) x1 = x3 |65536 x3 =4921097while x1 >=1: x3 = ((x3 + (x1 %256)) *65899) %16777216 x1 = x1 //256seen[-1]```# [Day 22: Mode Maze](https://adventofcode.com/2018/day/22)## Part 1```{python}d =7863target_x, target_y =14, 760base =20183@functools.cachedef geologic_index(x, y):if y ==0:return (x *16807) % baseif x ==0:return (y *48271) % basereturn ((geologic_index(x -1, y) + d) * (geologic_index(x, y -1) + d)) % base@functools.cachedef terrain_type(x, y):if x == target_x and y == target_y:return0return ((geologic_index(x, y) + d) % base) %3board = np.zeros((target_x +1, target_y +1), dtype=int)for i inrange(target_x +1):for j inrange(target_y +1): board[i, j] = terrain_type(i, j)board.sum()```## Part 2This calls for a path finding algorithm. A\* to the rescue! We'll need functions that1. Find the neighboring states of the current state2. Find the cost of getting to each neighbor3. Estimate the cost of getting to the end from any given location```{python}def neighbors(state): x, y, equipment = state candidates = [ (x +1, y, equipment), (x -1, y, equipment), (x, y -1, equipment), (x, y +1, equipment), (x, y, (equipment +1) %3), (x, y, (equipment +2) %3), ]return [ candidatefor candidate in candidatesif candidate[0] >=0and candidate[1] >=0and candidate[2] != terrain_type(candidate[0], candidate[1]) ]def weights(s1, s2):return1if s1[-1] == s2[-1] else7def heuristic(s1, s2):returnabs(s1[0] - s2[0]) +abs(s1[1] - s2[1]) +7* (s1[-1] != s2[-1])initial_state =0, 0, 1target = target_x, target_y, 1utils.astar(initial_state, target, neighbors, heuristic, weights)```# [Day 23: Experimental Emergency Teleportation](https://adventofcode.com/2018/day/23)## Part 1The first part is pretty simple```{python}data = np.array(load(23, "int"))row = data[np.argmax(data[:, -1])](np.abs((data - row)[:, :-1]).sum(axis=1) <= row[-1]).sum()```## Part 2The next part is signifcantly more tricky. Blindly iterating through every point and asking "how many are you in range of" is a non-starter, due to the sizes of the numbers involved. SImilarly, going the other way and generating all the covered points for each nanobot and adding those up runs into the same problem.A first thing to realise is that the sets of points covered by any given nanobot is an axis-aligned octahedron, centered at the nanobot's position. The eight faces that define the octahedron have normal vectors \[±1, ±1, ±1\]. We can group these faces into four pairs of parallel faces, and each octahedron can be thought of as the intersection of the infinte regions between those pairs of planes. That means we can represent an octahedron as four sets of intervals, which I will call the $(s, t, u, v)$ basis. To intersect two octahedra we can simply intersect the corresponding intervals of each octahedron.With the above considerations in hand, we could create a list of (region, number of intersections) pairs, with initial state \[(all space, 0)\]. We could then loop over all the nanobots, and for each one, calculate all the intersections with the existing list, and if they are non-zero, append them to the list, with updated intersection count.That would build up a map of all the intersections, and the desired answer would be represented by the pair with greatest intersection count. That would look something like this:```{python}normal_vectors = np.array([[1, 1, 1], [1, 1, -1], [1, -1, 1], [1, -1, -1]])centers = data[:, :-1] @ normal_vectors.Tradii = data[:, -1].reshape(-1, 1)c = np.transpose(np.stack([centers - radii, centers + radii +1]), axes=[1, 2, 0])intersections = {}for idx, row inenumerate(c): current =frozenset([idx]) to_add = {current: row}for i in intersections: new_i = np.hstack( [ np.maximum(intersections[i], row)[:, :1], np.minimum(intersections[i], row)[:, 1:], ] )if (np.diff(new_i) >0).all(): to_add[current.union(i)] = new_i intersections = intersections | to_addbreak```Unfortunately, the above approach has a slight flaw: It ends up explicitly creating all $\sim2^n$ possible intersections. With 1000 nanobots, that's just not feasible.If we focus on just one set of planes we can quite easily find where the biggest intersection is in that direction by scanning through from $-\infty$ to $\infty$, adding one for every left plane we encounter, and subtracting one for every right plane.```{python}openings =sorted(c[:, 0, 0])[::-1]closings =sorted(c[:, 0, -1])[::-1]current_closing, current_opening = closings.pop(), openings.pop()total =0edges = {}while openings:if current_opening < current_closing: total +=1 edges[current_opening] = total current_opening = openings.pop()else: total -=1 edges[current_closing] = total current_closing = closings.pop()max(edges.values())```But extending this to all four planes is non-obvious. Analysing the inputs shows that all the points overlap, apart from very few emitters. That means we can identify these, and the relevant points are just the intersection of all the other emitters, which can easily be found in the basis we've chosen.```{python}adjacency_matrix = np.zeros((len(c), len(c)))for i, pi inenumerate(c):for j, pj inenumerate(c): new = np.hstack([np.maximum(pi, pj)[:, :1], np.minimum(pi, pj)[:, 1:]])if (np.diff(new) >0).all(): adjacency_matrix[i, j] =1mask = (adjacency_matrix.sum(axis =0) >= np.median(adjacency_matrix.sum(axis =0)) /2)indices = np.where(mask)[0]c[indices, :, :].max(axis=0)[0, 0]```This feels slightly like cheating, since it easily fails for other inputs.A better approach would be to iteratively zoom in on promising areas of space. That is, starting with a bounding box containing all the points of interest, iteratively split the box into 16 sub-boxes, and for each of these boxes ask how many of the beacons it intersects with. Expanding the boxes in the order:1. Most beacons in range first2. Larger boxes before smaller boxes3. Boxes closer to the origin before boxes further awayMeans that by the time we see a box of size 1, we know that it is the optimal box, since- All regions of space which could potentially contain more beacons have already been examined by criterion 1- All larger regions of space which can see the same number of beacons have already been examined by criterion 2- There are no closer boxes of the same size which can see the same number of beacons by criterion 3All we need to implement is thus a procedure for determining whether a box and a beacon intersect. But in the $(s, t, u, v)$ basis, that's just determining whether the intervals that make up the box and the intervals that make up the beacon intersect. And it's not too difficult to verify that two half-open intervals $\left[ a, b\right)$ and $\left[x, y\right)$ intersect if $x \leq a < y$ or $a \leq x < b$. Putting everything together gives```{python}def score_box(corner, length, c=c): x = c[:, :, 0] y = c[:, :, 1] left = (x <= corner) & (corner < y) right = (corner <= x) & (x - length < corner)return-(left | right).all(axis=1).sum()l =2** (int(np.ceil(np.log2(abs(c).max()))))corner =-l, -l, -l, -llength =2* lq = PriorityQueue()initial_state = score_box(corner, length), -length, 0, cornerq.put(initial_state)i =0while q.qsize() >0and i <1000: i +=1 s, neg_length, position, corner = q.get() l =-neg_lengthif l ==1:breakfor new_corner in ( np.array(list(itertools.product([0, 1], repeat=4))) * l //2+ corner ): score = score_box(new_corner, l //2) position =0if l >2elsemax(abs(new_corner)) q.put((score, -l //2, position, tuple(new_corner)))position```# [Day 24: Immune System Simulator 20XX](https://adventofcode.com/2018/day/24)## Part 1This was a bit of a slog, with a fair bit of attention needed to make sure that the requirements were implemented correctly.```{python}class Group:def__init__(self, n_units, hit_points, damage, initiative, damage_type, weaknesses=[], immunities=[], ):self.n_units = n_unitsself.hit_points = hit_pointsself.damage = damageself.initiative = initiativeself.damage_type = damage_typeself.weaknesses = weaknessesself.immunities = immunitiesdef__repr__(self):return (f"Group({self.n_units}, {self.hit_points}, {self.damage}, "f"{self.initiative}, {self.damage_type})" )@propertydef effective_power(self):returnself.damage *self.n_units@propertydef is_alive(self):returnself.n_units >0def attack(self, other): other.defend(self.calculate_damage(other))def calculate_damage(self, other):ifself.damage_type in other.immunities:return0elifself.damage_type in other.weaknesses:return2*self.effective_powerreturnself.effective_powerdef defend(self, damage):self.n_units =self.n_units - damage //self.hit_pointsself.n_units =max(self.n_units, 0)def selection_order(self):return (-self.effective_power, -self.initiative)def select_target(self, others): max_damage = (0, 0, 0)for other in others: damage =self.calculate_damage(other) key = (damage, other.effective_power, other.initiative)if key > max_damage: max_damage = key result = otherif max_damage[0] >0:return resultelse:returnNonedef select_targets(attackers, defenders): targets = [] defenders = defenders.copy()for group insorted(attackers, key=lambda x: x.selection_order()): target = group.select_target(defenders)if target isnotNone: targets.append((group, target)) defenders.remove(target)return targetsdef one_round(infection, immune): matchup = select_targets(infection, immune) + select_targets(immune, infection)for match insorted(matchup, key=lambda x: -x[0].initiative): match[0].attack(match[1])return [x for x in infection if x.is_alive], [x for x in immune if x.is_alive]data = [x.split("\n")[1:] for x in load(24, "raw")[:-1].split("\n\n")]def parse(line): n_units, hit_points, damage, initiative = [int(x) for x in re.findall("-?\d+", line) ] damage_type = re.search("(\w*) damage", line).groups()[0] immunity_match = re.search("\((.*)\)", line)ifnot immunity_match: weaknesses, immunities = [], []else: weaknesses, immunities = parse_immunities(immunity_match.groups()[0])return Group( n_units, hit_points, damage, initiative, damage_type, weaknesses, immunities )def parse_immunities(exp): weaknesses = [] immunities = []for sequence in exp.split("; "): first, middle, *rest = sequence.split()if first =="weak": kind = weaknesseselse: kind = immunities kind +="".join(rest).split(",")return weaknesses, immunitiesimmune, infection = [[parse(line) for line in block] for block in data]while immune and infection: immune, infection = one_round(immune, infection)result = immune if immune else infectionsum(x.n_units for x in result)```## Part 2I did a binary search for this one. The players can get stuck in a draw, so we need to take that into account.```{python}def winner(boost): immune, infection = [[parse(line) for line in block] for block in data]for group in immune: group.damage += boostwhile immune and infection: total =sum(x.n_units for x in immune + infection) immune, infection = one_round(immune, infection)if total ==sum(x.n_units for x in immune + infection):return0returnsum(x.n_units for x in immune)high =1whilenot winner(high): high *=2low = high //2while high - low >1: mid = (high + low) //2if winner(mid): high = midelse: low = midwinner(high)```# [Day 25: Four-Dimensional Adventure](https://adventofcode.com/2018/day/25)## Part 1I could write this by hand. Or I could realise that the problem description is perfectly suited for a union-find/disjoint set data structure:```{python}from scipy.cluster.hierarchy import DisjointSetpoints = [tuple(x) for x in load(25, "int")]disjoint_set = DisjointSet(points)for x inrange(len(points)): deltas = np.abs(np.array(points) - np.array(points[x])).sum(axis=1)for y inrange(x +1, len(points)):if deltas[y] <=3: disjoint_set.merge(points[x], points[y])disjoint_set.n_subsets```