@xavdid does Advent of Code

Seven Segment Search

Published: 2021-12-09 Original Prompt

Part 1

This is a lot of reading, but not actually too bad. First it describes a “Seven Segment Display” where, using up to 7 segments, you can make any digit 0-9. Then, it assigns letters to those segments. Now, you can represent any digit with a set of letters. In the diagram, segments c and f are the right side verticals. So cf -> 1. If you add the top (a), you get a 7 instead (acf -> 7). This also means that each digit has a known number of segments (1 is always made of 2 letters, 7 is always made of 3, etc). It’ll be important to understand that concept, but not until part 2.

For now, we only have to worry about the right side of the input (after the |). The prompt tell us that the digits 1, 4, 7, and 8 have unique numbers of segments. This is true! If you have a 3-letter digit (such as acf), it has to map to 7. Part 1 is asking how many of those pre-defined digits are found on the right side of the input. Or, put another way: “how many items on the right have a length of 2, 3, 4, or 7?” (the number of segments for each of the 4 numbers discussed above). When you put it that way, this part isn’t too bad:

# hardcoded unique segment lengths
UNIQUE_SIZES = {2, 3, 4, 7}
total = 0
for line in self.input:
_, wires = line.split(" | ")
total += sum([1 for x in wires.split() if len(x) in UNIQUE_SIZES])
return total

This solution combines a couple of neat tricks:

Anyway, I’ve got a feeling part 2 is going to have a lot more steps…

Part 2

And so it does. Now we have to decode the numbers on each line. We’ll be doing this via process of elimination and working our way up.

For each line in our input, we’ll need to create a mapping of the actual number (1) to its scrambled result (ab). I thought we’d have to solve for individual segments, but we actually don’t need to- we can instead start with some freebies and compare our way home.

We’ll be doing a lot of filtering list comprehensions here. I wrote a little helper to validate assumptions:

from typing import List
def validate(i: List[str]):
assert len(i) == 1
return i[0]
result[1] = validate([x for x in digits if len(x) == 2])

For each comprehension, I’m assuming I’ve filtered the list down to a single item. While solving, I was taking a different approach, using an iterator and the next function:

result[1] = next(x for x in digits if len(x) == 2)

That gives me the first item in the iterator. If my logic is sound, then they work the same way. But, if I have a bug (and I did), then I’ll get the first of multiple possible options, leading to a bug. So, I made sure to start validating assumptions as I went.

Anyway, let’s get to the solve.

As we solve this, we’ll be using a lot of sets. This is because order doesn’t matter (so we don’t need a list). We actually want to specifically ignore the order. We’d be sad if the input was ba and we were checking against ab. Plus, they make some of this math very easy.

We start with a loop and our freebies from part 1:

total = 0
for line in self.input:
digits, number = line.split(" | ")
result: Dict[int, str] = {}
digits = digits.split()
result[1] = validate([x for x in digits if len(x) == 2])
result[4] = validate([x for x in digits if len(x) == 4])
result[7] = validate([x for x in digits if len(x) == 3])
result[8] = validate([x for x in digits if len(x) == 7])
# using one-liner sample input:
# {1: 'ab', 4: 'eafb', 7: 'dab', 8: 'acedgfb'}

Easy enough- we knew about those from part one.

Now, there are 6 numbers remaining. They fall into two groups: 5 segments (2, 3, 5) and 6 segments (0, 6, 9). In each group, there’s an odd number out:

We can write that in Python code just like we did before:

for line in self.input:
...
result[6] = validate(
[x for x in digits if len(x) == 6 and not set(result[1]) < set(x)]
)
result[3] = validate(
[x for x in digits if len(x) == 5 and set(result[1]) < set(x)]
)

Now we can clear out used numbers:

for line in self.input:
...
found = set(result.values())
digits = [x for x in digits if x not in found]

There are two groups left, each with two items. We can identify 9 within its group because it’s a strict superset of 4 (that is, it contains everything in 4); the same cannot be said of 0. So we can spot 9 and 0 is the other one with the same length:

for line in self.input:
...
result[9] = validate(
[x for x in digits if len(x) == 6 and set(result[4]) < set(x)]
)
result[0] = validate([x for x in digits if len(x) == 6 and x != result[9]])

One more clear out to make our last couple of lines simpler.

for line in self.input:
...
found = set(result.values())
digits = [x for x in digits if x not in found]

For this last part, I solved for a segment. We know that 8 and 9 share nearly all of their letters. The only one that 9 lacks is the bottom left segment. Conveniently, that letter only appears in 2:

...
bottom_left = (set(result[8]) - set(result[9])).pop()
result[2] = validate([x for x in digits if bottom_left in x])
result[5] = validate([x for x in digits if x != result[2]])

That’s all our numbers! Now we have to reverse our result so we can plug the input back into it:

for line in self.input:
...
decoded = {frozenset(v): str(k) for k, v in result.items()}

Here we use a dict comprehension (instead of the list) one we’ve been doing before. It’s a similar setup, but the output is a dict instead of a list. The same can be done with sets, which can be useful. Also note that we’re using a frozenset as the key instead of a regular set. That’s because dict keys in Python have to be hashable; to be hashable, a variable must be immutable (never changing). Sets and lists can change (using .add and .append respectively), so they throw errors when used as keys. A frozenset promises that it and all of its members will never change, so we can use them as keys to look up our digit.

Last, we add to our total:

for line in self.input:
...
total += int("".join([decoded[frozenset(x)] for x in number.split()]))
return total

It looked intimidating and took a bit of logic, but we got there! I liked that one.