Distress Signal
2022-12-13 Original Prompt Part 1
Rather than start on the input parsing, I decided to eat the frog and write the is_ordered, which will hold most of the logic. There’s nothing too technical - we’re carefully following the instructions in the prompt.
It takes an int, a list of int, or a list of list of int, or… you get the idea. Python supports recursive types, so we can type it like so:
Packet = int | list["Packet"]Knowing that each of the two arguments to this function lets us add our first logic branch:
...
# note - this bool | None syntax requires Python 3.10+def is_ordered(left: Packet, right: Packet) -> bool | None: if isinstance(left, int) and isinstance(right, int): if left == right: return None return left < rightFor our purposes, we’ll use None for ties, meaning whatever is calling should continue to the next case, if available.
There are 3 other cases to consider with 2 arguments:
- 2 lists
- left
list, rightint - left
int, rightlist
The logic on the first option is tricky, so we’ll defer that. We can fill in the rest though:
...
def is_ordered(left: Packet, right: Packet) -> bool | None: if isinstance(left, int) and isinstance(right, int): ...
if isinstance(left, list) and isinstance(right, list): # TODO
if isinstance(left, list): # list, int return is_ordered(left, [right])
# int, list return is_ordered([left], right)That covers our main branches. The last thing is to handle lists. We need to compare the first element of left with the first element of right, second element of each, etc. Sounds like a job for zip! But, like day 5, we need to use functools.zip_longest so that we know when one of the lists has ended. The potential to pass None as left or right requires an update to our branches:
def is_ordered(left: Packet | None, right: Packet | None) -> bool | None: # right is smaller, not sorted if right is None: return False # left is smaller, sorted if left is None: return True
...
if isinstance(left, list) and isinstance(right, list): for l, r in zip_longest(left, right): if (res := is_ordered(l, r)) is not None: return res return None
...We look at each item and return a bool if we got one. If we didn’t, we keep going, eventually returning None if the tie was never broken. And that’ll do it!
Next, input parsing. AoC was kind enough to structure each line of input as valid Python, so we can use the eval family of functions to parse it as such. Normally, this is terribly unsafe; anything in the input gets executed in you program context. It could read secret variables, change program state, anything. Don’t use the global eval function.
Instead, Python supplies the ast.literal_eval function. The docs note that:
Evaluate an expression node or a string containing only a Python literal or container display. The string or node provided may only consist of the following Python literal structures: strings, bytes, numbers, tuples, lists, dicts, sets, booleans, None and Ellipsis. This can be used for evaluating strings containing Python values without the need to parse the values oneself.
Because it only recognizes basic Python primitives (lists, numbers, etc), it’s saf-er to use. Still probably worth scrutinizing your input closely, but you’re less likely to let someone totally hack you this way (like this meme suggests).1 So, we can run a line through literal_eval and get a Python-native version of the input! Combine that with totaling up the indexes if is_ordered is truthy, and we’re done with part 1.
class Solution(StrSplitSolution): separator = "\n\n"
def part_1(self) -> int: total = 0 for index, block in enumerate(self.input): left, right = map(literal_eval, block.split("\n")) if is_ordered(left, right): total += index + 1
return totalPart 2
While part 1 verified that our basic algorithm was correct, part 2 has us actually using it. To start, we’ll parse our input into a single flat list (plus the 2 additional Packets):
class Solution(StrSplitSolution): ...
def part_2(self) -> int: divider_1: Packet = [[2]] divider_2: Packet = [[6]] flat_packets: list[Packet] = [divider_1, divider_2] for block in self.input: for packet in block.split("\n"): flat_packets.append(literal_eval(packet))
# TODO: sort the list
return (flat_packets.index(divider_1) + 1) * (flat_packets.index(divider_2) + 1)As luck would have it, Python is great at sorting lists. All it needs is for us to tell it how to do so (something I touched on briefly yesterday). Now, we’ll take a closer look at what exactly goes into the key= kwarg when sorting a list.
Ultimately, to sort a list, Python needs a sortable object. An object is sortable if it implements __lt__, allowing it to be compared to other similar objects. The function you use in key should return an object that is sortable. Sometimes, that’s picking a specific int out of an object, but for our (potentially deeply nested) list, that’s not so easy. We could write a class for Packet which implements this, but there’s an easier way.
In older versions of Python, you used to write custom sorts using comparator functions. They took 2 elements (a and b) and would return:
-1ifaandbare correctly ordered (a < b)0ifaandbare equal and don’t need to be swapped (a == b)1ifaandbare in the wrong order and do need to be swapped (b < a)
Sound familiar? If we can turn calls to is_ordered into a sortable object, we can use it as our key function! And, best of all, Python provides this in functools.cmp_to_key. All we need is a little function that translates our bool outputs to 1 or -1 (the lists in input are never equal). We can wrap it all in a little lambda:
class Solution(StrSplitSolution): ...
def part_2(self) -> int: ...
flat_packets.sort(key=cmp_to_key(lambda l, r: -1 if is_ordered(l, r) else 1))
return (flat_packets.index(divider_1) + 1) * (flat_packets.index(divider_2) + 1)And that’ll do it!