507 lines
19 KiB
Python
507 lines
19 KiB
Python
import collections
|
|
import enum
|
|
import itertools
|
|
import re
|
|
import sys
|
|
import networkx
|
|
from typing import Dict, List, Tuple, Optional
|
|
|
|
|
|
# Halt indicates that the assembled program should terminate
|
|
class Halt(Exception):
|
|
pass
|
|
|
|
|
|
class Memory(collections.OrderedDict):
|
|
def __missing__(self, address):
|
|
if address < 0:
|
|
raise KeyError("Address cannot be < 0")
|
|
return 0
|
|
|
|
|
|
# Operation represents an operation that the intcode computer should do
|
|
class Operation:
|
|
OPCODE_TERMINATE = 99
|
|
OPCODE_ADD = 1
|
|
OPCODE_MULTIPLY = 2
|
|
OPCODE_INPUT = 3
|
|
OPCODE_OUTPUT = 4
|
|
OPCODE_JUMP_IF_TRUE = 5
|
|
OPCODE_JUMP_IF_FALSE = 6
|
|
OPCODE_LESS_THAN = 7
|
|
OPCODE_EQUALS = 8
|
|
OPCODE_SET_REL_BASE = 9
|
|
MODE_POSITION = 0
|
|
MODE_IMMEDIATE = 1
|
|
MODE_RELATIVE = 2
|
|
ALL_OPCODES = (OPCODE_TERMINATE, OPCODE_ADD, OPCODE_MULTIPLY, OPCODE_INPUT, OPCODE_OUTPUT,
|
|
OPCODE_JUMP_IF_TRUE, OPCODE_JUMP_IF_FALSE, OPCODE_LESS_THAN, OPCODE_EQUALS, OPCODE_SET_REL_BASE)
|
|
# Opcodes that write to memory as their last parameter
|
|
MEMORY_OPCODES = (OPCODE_ADD, OPCODE_MULTIPLY, OPCODE_INPUT, OPCODE_LESS_THAN, OPCODE_EQUALS)
|
|
|
|
def __init__(self, instruction: int, rel_base: int = 0):
|
|
# The opcode is the first two digits of the number, the rest are parameter modes
|
|
self.opcode: int = instruction % 100
|
|
if self.opcode not in Operation.ALL_OPCODES:
|
|
raise ValueError(f"Bad opcode: {self.opcode}")
|
|
self.modes: Tuple[int, ...] = self._extract_parameter_modes(instruction//100)
|
|
self.output = None
|
|
self.rel_base = rel_base
|
|
|
|
def _extract_parameter_modes(self, raw_modes) -> Tuple[int, ...]:
|
|
PARAMETER_COUNTS = {
|
|
Operation.OPCODE_TERMINATE: 0,
|
|
Operation.OPCODE_ADD: 3,
|
|
Operation.OPCODE_MULTIPLY: 3,
|
|
Operation.OPCODE_INPUT: 1,
|
|
Operation.OPCODE_OUTPUT: 1,
|
|
Operation.OPCODE_JUMP_IF_TRUE: 2,
|
|
Operation.OPCODE_JUMP_IF_FALSE: 2,
|
|
Operation.OPCODE_LESS_THAN: 3,
|
|
Operation.OPCODE_EQUALS: 3,
|
|
Operation.OPCODE_SET_REL_BASE: 1,
|
|
}
|
|
|
|
num_parameters = PARAMETER_COUNTS[self.opcode]
|
|
modes = [Operation.MODE_POSITION for i in range(num_parameters)]
|
|
mode_str = str(raw_modes)
|
|
# Iterate over the modes digits backwards, assigning them to the parameter list until we exhaust the modes
|
|
# The rest must be leading zeroes
|
|
for mode_index, digit in zip(range(num_parameters), reversed(mode_str)):
|
|
modes[mode_index] = int(digit)
|
|
|
|
return tuple(modes)
|
|
|
|
# Run the given operation, starting at the given instruction pointer
|
|
# Returns the address that the instruction pointer should become
|
|
def run(self, memory: Memory, instruction_pointer: int, program_input: Optional[int] = None) -> int:
|
|
OPERATION_FUNCS = {
|
|
# nop for terminate
|
|
Operation.OPCODE_TERMINATE: Operation.terminate,
|
|
Operation.OPCODE_ADD: Operation.add,
|
|
Operation.OPCODE_MULTIPLY: Operation.multiply,
|
|
Operation.OPCODE_INPUT: Operation.input,
|
|
Operation.OPCODE_OUTPUT: Operation.output,
|
|
Operation.OPCODE_JUMP_IF_TRUE: Operation.jump_if_true,
|
|
Operation.OPCODE_JUMP_IF_FALSE: Operation.jump_if_false,
|
|
Operation.OPCODE_LESS_THAN: Operation.less_than,
|
|
Operation.OPCODE_EQUALS: Operation.equals,
|
|
Operation.OPCODE_SET_REL_BASE: Operation.set_rel_base
|
|
}
|
|
|
|
# Reset the output and rel base of a previous run
|
|
self.output = None
|
|
|
|
args = []
|
|
for i, mode in enumerate(self.modes):
|
|
# Add 1 to move past the opcode itself
|
|
pointer = instruction_pointer + i + 1
|
|
arg = memory[pointer]
|
|
# The last argument (the address parameter) must always act as an immediate
|
|
# The problem statement is misleading in this regard. You do NOT want to get an address to store the value
|
|
# at from another address.
|
|
if mode != self.MODE_IMMEDIATE and i == len(self.modes) - 1 and self.opcode in Operation.MEMORY_OPCODES:
|
|
if mode == Operation.MODE_RELATIVE:
|
|
arg = self.rel_base + arg
|
|
# Position mode is already handled since it would be arg = arg here.
|
|
elif mode == Operation.MODE_POSITION:
|
|
arg = memory[arg]
|
|
elif mode == Operation.MODE_RELATIVE:
|
|
arg = memory[self.rel_base + arg]
|
|
elif mode != Operation.MODE_IMMEDIATE:
|
|
raise ValueError(f"Invalid parameter mode {mode}")
|
|
|
|
args.append(arg)
|
|
|
|
func = OPERATION_FUNCS[self.opcode]
|
|
if program_input is None:
|
|
jump_addr = func(self, memory, *args)
|
|
else:
|
|
jump_addr = func(self, memory, program_input, *args)
|
|
|
|
out_addr = instruction_pointer + len(self.modes) + 1
|
|
if jump_addr is not None:
|
|
out_addr = jump_addr
|
|
|
|
return out_addr
|
|
|
|
def terminate(self, memory: Memory) -> None:
|
|
raise Halt("catch fire")
|
|
|
|
def add(self, memory: Memory, a: int, b: int, loc: int) -> None:
|
|
memory[loc] = a + b
|
|
|
|
def multiply(self, memory: Memory, a: int, b: int, loc: int) -> None:
|
|
memory[loc] = a * b
|
|
|
|
def input(self, memory: Memory, program_input: int, loc: int) -> None:
|
|
memory[loc] = program_input
|
|
|
|
def output(self, memory: Memory, value: int) -> None:
|
|
self.output = value
|
|
|
|
def jump_if_true(self, memory: Memory, test_value: int, new_instruction_pointer: int) -> Optional[int]:
|
|
return new_instruction_pointer if test_value != 0 else None
|
|
|
|
def jump_if_false(self, memory: Memory, test_value: int, new_instruction_pointer: int) -> Optional[int]:
|
|
return new_instruction_pointer if test_value == 0 else None
|
|
|
|
def less_than(self, memory: Memory, a: int, b: int, loc: int) -> None:
|
|
memory[loc] = int(a < b)
|
|
|
|
def equals(self, memory: Memory, a: int, b: int, loc: int) -> None:
|
|
memory[loc] = int(a == b)
|
|
|
|
def set_rel_base(self, memory: Memory, base_delta: int) -> None:
|
|
self.rel_base += base_delta
|
|
|
|
|
|
# Executes the program, returning the instruction pointer to continue at (if the program paused), the relative base,
|
|
# and a list of all outputs that occurred during the program's execution
|
|
def execute_program(memory: Memory, program_inputs: List[int], initial_instruction_pointer: int = 0, initial_rel_base: int = 0) -> Tuple[Optional[int], int, List[int]]:
|
|
i = initial_instruction_pointer
|
|
input_cursor = 0
|
|
outputs = []
|
|
rel_base = initial_rel_base
|
|
# Go up to the maximum address, not the number of addresses
|
|
while i < max(memory.keys()):
|
|
operation = Operation(memory[i], rel_base)
|
|
program_input = None
|
|
# If we're looking for input
|
|
if operation.opcode == Operation.OPCODE_INPUT:
|
|
# If we are out of input, don't fail out, but rather just pause execution
|
|
if input_cursor >= len(program_inputs):
|
|
return i, rel_base, outputs
|
|
program_input = program_inputs[input_cursor]
|
|
input_cursor += 1
|
|
|
|
try:
|
|
i = operation.run(memory, i, program_input)
|
|
output = operation.output
|
|
rel_base = operation.rel_base
|
|
except Halt:
|
|
break
|
|
|
|
if output is not None:
|
|
outputs.append(output)
|
|
|
|
# The program is finished, and we are saying there is no instruction pointer
|
|
return None, rel_base, outputs
|
|
|
|
|
|
# Problem specific code starts here
|
|
SCAFFOLD_CHAR = ord('#')
|
|
ROBOT_CHAR = ord('^')
|
|
|
|
|
|
class TurnDirection(enum.Enum):
|
|
LEFT = 'L'
|
|
RIGHT = 'R'
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
|
|
class Direction(enum.IntEnum):
|
|
NORTH = 1
|
|
EAST = 2
|
|
SOUTH = 3
|
|
WEST = 4
|
|
|
|
# Given the direction we currently are, get the direction we need to turn to face this coordinate.
|
|
# Returns None if these are the same coordinate.
|
|
@staticmethod
|
|
def get_direction_to_coordinate(current_pos: Tuple[int, int], next_pos: Tuple[int, int]) -> Optional['Direction']:
|
|
current_row, current_col = current_pos
|
|
next_row, next_col = next_pos
|
|
direction_required = None
|
|
if next_col == current_col and next_row < current_row:
|
|
direction_required = Direction.NORTH
|
|
elif next_col == current_col and next_row > current_row:
|
|
direction_required = Direction.SOUTH
|
|
elif next_row == current_row and next_col < current_col:
|
|
direction_required = Direction.WEST
|
|
elif next_row == current_row and next_col > current_col:
|
|
direction_required = Direction.EAST
|
|
|
|
return direction_required
|
|
|
|
# Gets the direction we need to turn for fix.
|
|
# Returns None if we are currently facing the right direction
|
|
def get_turn_to_direction(self, new_direction: 'Direction') -> Optional[Tuple['TurnDirection', ...]]:
|
|
turn_distance = (self - new_direction) % 4
|
|
# if turn_distance == 2:
|
|
# breakpoint()
|
|
if turn_distance == 0:
|
|
return None
|
|
elif turn_distance == 3:
|
|
return (TurnDirection.RIGHT,)
|
|
else:
|
|
return (TurnDirection.LEFT,) * turn_distance
|
|
|
|
def move_coords_in_direction(self, pos: Tuple[int, int]) -> Tuple[int, int]:
|
|
D_ROWS = {
|
|
Direction.NORTH: -1,
|
|
Direction.SOUTH: 1,
|
|
Direction.EAST: 0,
|
|
Direction.WEST: 0
|
|
}
|
|
|
|
D_COLS = {
|
|
Direction.NORTH: 0,
|
|
Direction.SOUTH: 0,
|
|
Direction.EAST: 1,
|
|
Direction.WEST: -1
|
|
}
|
|
|
|
return (pos[0] + D_ROWS[self], pos[1] + D_COLS[self])
|
|
|
|
|
|
# Return a graph of all of the scaffolding, with a tuple represeting the robot's starting position
|
|
def build_graph_from_program(initial_memory_state: Memory) -> (networkx.Graph, Tuple[int, int]):
|
|
program_memory = initial_memory_state.copy()
|
|
|
|
# Add all edges that are directly above/below or directly left/right of our cursor
|
|
def add_adjacent_edges(scaffold_graph: networkx.Graph, row_cursor: int, col_cursor: int):
|
|
scaffold_graph.add_node((row_cursor, col_cursor))
|
|
for d_row in range(-1, 1):
|
|
for d_col in range(-1, 1):
|
|
if d_row == 0 and d_col == 0 or 0 not in (d_row, d_col):
|
|
continue
|
|
adjacent_coord = (row_cursor + d_row, col_cursor + d_col)
|
|
if adjacent_coord in scaffold_graph:
|
|
scaffold_graph.add_edge((row_cursor, col_cursor), adjacent_coord)
|
|
|
|
_, _, outputs = execute_program(program_memory, [])
|
|
scaffold_graph = networkx.Graph()
|
|
row_cursor = 0
|
|
col_cursor = 0
|
|
robot_pos = None
|
|
for item in outputs:
|
|
if item == ord('\n'):
|
|
row_cursor += 1
|
|
col_cursor = 0
|
|
continue
|
|
elif item == SCAFFOLD_CHAR:
|
|
add_adjacent_edges(scaffold_graph, row_cursor, col_cursor)
|
|
elif item == ROBOT_CHAR:
|
|
add_adjacent_edges(scaffold_graph, row_cursor, col_cursor)
|
|
robot_pos = (row_cursor, col_cursor)
|
|
|
|
col_cursor += 1
|
|
|
|
return scaffold_graph, robot_pos
|
|
|
|
|
|
# Make a path by being as greedy as possible - go forward until we can't anymore.
|
|
def make_greedy_path(scaffold_graph: networkx.Graph, start_pos: Tuple[int, int]) -> str:
|
|
path_components = []
|
|
forward_count = 0
|
|
robot_direction = Direction.NORTH
|
|
visited = set()
|
|
node_cursor = start_pos
|
|
while visited != set(scaffold_graph.nodes):
|
|
next_pos = robot_direction.move_coords_in_direction(node_cursor)
|
|
if next_pos not in scaffold_graph:
|
|
possible_points = set(scaffold_graph.neighbors(node_cursor)) - set(visited)
|
|
next_pos = min(possible_points, key=lambda x: (x[0], x[1]))
|
|
new_direction = Direction.get_direction_to_coordinate(node_cursor, next_pos)
|
|
turns_needed = robot_direction.get_turn_to_direction(new_direction)
|
|
robot_direction = new_direction
|
|
|
|
if forward_count > 0:
|
|
path_components.append(str(forward_count))
|
|
path_components += [str(turn) for turn in turns_needed]
|
|
forward_count = 0
|
|
|
|
visited.add(node_cursor)
|
|
visited.add(next_pos)
|
|
node_cursor = next_pos
|
|
forward_count += 1
|
|
if forward_count > 0:
|
|
path_components.append(str(forward_count))
|
|
|
|
return ','.join(path_components)
|
|
|
|
|
|
# Find a component of the string that occurs more than once, starting at the given position and checking forwards/backwards
|
|
# based on the given offset
|
|
def find_path_component(path: str, start: int, offset: int) -> str:
|
|
component = path[start:offset] if offset > 0 else path[start + offset:]
|
|
if (offset > 0 and component[-1] != ',') or (offset < 0 and component[0] != ','):
|
|
return None
|
|
elif path.count(component) == 1:
|
|
return None
|
|
|
|
return component.strip(',')
|
|
|
|
|
|
# Get all substrings of s without the given component
|
|
def get_substrings_without_str(s: str, component: str) -> str:
|
|
locations = [(match.start(), match.end()) for match in re.finditer(re.escape(component), s)]
|
|
locations.insert(0, (None, None))
|
|
locations.append((None, None))
|
|
|
|
substrings = [s[location1[1]:location2[0]].strip(',') for location1, location2 in zip(locations, locations[1:])]
|
|
|
|
return [substring for substring in substrings if len(substring) > 0]
|
|
|
|
|
|
# If a path string is a duplicate itself, strip it down to its base component
|
|
def dedup_path_string(s: str) -> str:
|
|
normalized_s = s
|
|
# Need to join the two components with a comma so that we actually can spot the repetition (the string may not have a trailing comma)
|
|
if s[-1] != ',':
|
|
normalized_s = s + ','
|
|
|
|
# Find if the string is only composed of a portion of itself
|
|
repeat_index = (normalized_s + normalized_s).find(normalized_s, 1, -1)
|
|
if repeat_index == -1:
|
|
return s
|
|
else:
|
|
return s[:repeat_index].rstrip(',')
|
|
|
|
|
|
# Given the two other components, see if there's one final component left in the string
|
|
def get_last_path_component(path: str, component1: str, component2: str) -> Optional[str]:
|
|
path_without_component1 = get_substrings_without_str(path, component1)
|
|
# Get the path without component 1 or component 2
|
|
remaining_comonents = []
|
|
for substring in path_without_component1:
|
|
remaining_comonents += get_substrings_without_str(substring, component2)
|
|
|
|
# Make sure the last component we have is unique
|
|
if len(set(remaining_comonents)) > 1:
|
|
return None
|
|
|
|
last_component = dedup_path_string(remaining_comonents[0])
|
|
if len(last_component) > 20:
|
|
return None
|
|
|
|
return last_component
|
|
|
|
|
|
# Remove the given comopnent from the front and back of a string
|
|
def remove_prefix_and_suffix(s: str, component: str) -> str:
|
|
res = s
|
|
if res.startswith(component):
|
|
res = res[len(component):].lstrip(',')
|
|
if res.endswith(component):
|
|
res = res[:-len(component)].rstrip(',')
|
|
|
|
return res
|
|
|
|
|
|
# Find the three compressible components of the path
|
|
# This is NOT pretty. This could be generalized by searching for all substrings, but that would be longer running
|
|
def find_compressable_path_components(path: str) -> Tuple[str, str, str]:
|
|
MAX_LENGTH = 20
|
|
for i in range(MAX_LENGTH + 1):
|
|
path_candidate = path
|
|
# Find the first component at the start of the string
|
|
component1 = find_path_component(path_candidate, 0, i)
|
|
if component1 is None:
|
|
continue
|
|
|
|
# Remove it from both ends
|
|
path_candidate = remove_prefix_and_suffix(path_candidate, component1)
|
|
for j in range(MAX_LENGTH + 1):
|
|
trimmed_candidate = path_candidate
|
|
# We know there must be another unique component at the end of the string
|
|
component2 = find_path_component(trimmed_candidate, len(trimmed_candidate) - 1, -j)
|
|
if component2 is None:
|
|
continue
|
|
|
|
# Remove it from both ends
|
|
trimmed_candidate = remove_prefix_and_suffix(trimmed_candidate, component2)
|
|
component3 = get_last_path_component(trimmed_candidate, component1, component2)
|
|
if component3 is None:
|
|
continue
|
|
|
|
return component1, component2, component3
|
|
else:
|
|
raise Exception("path is not compressible into three functions")
|
|
|
|
|
|
# Convert a path into a list of functions based on the given function defintiions
|
|
def make_function_nav_string(path: str, functions: Dict[str, str]) -> str:
|
|
i = 0
|
|
function_nav_string = []
|
|
while i < len(path):
|
|
for function_name, function in sorted(functions.items(), key=lambda x: len(x[1]), reverse=True):
|
|
if path[i:i+len(function)] == function:
|
|
function_nav_string.append(function_name)
|
|
# +1 for the comma
|
|
i += len(function) + 1
|
|
break
|
|
else:
|
|
raise ValueError('Functions not in path')
|
|
|
|
return ','.join(function_nav_string)
|
|
|
|
|
|
def part1(scaffold_graph: networkx.Graph) -> int:
|
|
return sum(row * col for row, col in scaffold_graph.nodes if scaffold_graph.degree((row, col)) > 2)
|
|
|
|
|
|
def part2(initial_memory_state: Memory, scaffold_graph: networkx.Graph, robot_pos: Tuple[int, int]):
|
|
def make_ascii_input(s: str) -> str:
|
|
return [ord(char) for char in s]
|
|
|
|
nav_string = make_greedy_path(scaffold_graph, robot_pos)
|
|
functions = find_compressable_path_components(nav_string)
|
|
named_functions = {name: function for name, function in zip(['A', 'B', 'C'], functions)}
|
|
function_nav_string = make_function_nav_string(nav_string, named_functions)
|
|
|
|
# Start the sequence of the interacitve mode
|
|
program_memory = initial_memory_state.copy()
|
|
program_memory[0] = 2
|
|
_, _, outputs = execute_program(program_memory, [
|
|
*make_ascii_input(function_nav_string + '\n'),
|
|
*make_ascii_input(named_functions['A'] + '\n'),
|
|
*make_ascii_input(named_functions['B'] + '\n'),
|
|
*make_ascii_input(named_functions['C'] + '\n'),
|
|
*make_ascii_input('n\n')
|
|
])
|
|
|
|
return outputs[-1]
|
|
|
|
|
|
# A debug method to print the entire graph
|
|
def print_scaffold_graph(scaffold_graph: networkx.Graph) -> None:
|
|
print(' ', end='')
|
|
max_row = max(node[0] for node in scaffold_graph.nodes) + 1
|
|
max_col = max(node[1] for node in scaffold_graph.nodes) + 1
|
|
|
|
for i in range(max_col):
|
|
print(i // 10 if i // 10 > 0 else ' ', end='')
|
|
print('')
|
|
print(' ', end='')
|
|
for i in range(max_col):
|
|
print(i % 10, end='')
|
|
print('')
|
|
for i in range(max_row):
|
|
print(f'{i:2} ', end='')
|
|
for j in range(max_col):
|
|
if (i, j) in scaffold_graph.nodes:
|
|
print('#', end='')
|
|
else:
|
|
print('.', end='')
|
|
print('')
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("Usage: ./main.py in_file")
|
|
sys.exit(1)
|
|
|
|
memory = Memory()
|
|
with open(sys.argv[1]) as f:
|
|
for i, item in enumerate(f.read().rstrip().split(",")):
|
|
memory[i] = int(item)
|
|
|
|
scaffold_graph, robot_pos = build_graph_from_program(memory)
|
|
print_scaffold_graph(scaffold_graph)
|
|
print(part1(scaffold_graph))
|
|
print(part2(memory, scaffold_graph, robot_pos))
|