Yesterday’s quest to find the Chief Historian didn’t succeed so it’s time to continue the search. While we’re looking for him at the Red-Nosed Reindeer nuclear fusion/fission plant, engineers come to seek our aid.

We need to analyse reports from the Red-Nosed reactor. Our input data is a list of reports, one report per line:

7 6 4 2 1
1 2 7 8 9
9 7 6 2 1
1 3 2 4 5
8 6 4 4 1
1 3 6 7 9

Reading input

Each line contains numbers separated by a whitespace. We’ll split the lines by that whitespace and cast each number into an integer:

def map_fn(line):
    """Splits each line of input by whitespace and maps
    each value to integer.
 
    Example line:
    7 6 4 2 1
    """
    return [int(number) for number in line.split()]

If you’re new to Python, you have witnessed a list comprehension. These are like little for loops in one line. But even more than just a for loop, they return a new list.

This list comprehension above achieves the same result as this for loop:

numbers = []
for number in line.split():
  numbers.append(int(number))
return numbers

This form of list comprehension is equivalent to a map function (which also exists in Python but pythonistas tend to prefer list comprehensions).

You can also do an equivalent of a filter:

odd_numbers = [number for number in numbers if number %2 != 0]

List comprehensions are a somewhat divisive topic, especially with people who move to Python from an another language. I like them, especially for simple cases where it’s either a map, filter or a combination of the two. Once you start to do more or combine multiple for loops into one comprehension, the readability often suffers.

Part 1: Are these reports good?

The engineers explain to us that a report is safe if it satisfies two rules:

  • The levels are either all increasing or all decreasing.
  • Any two adjacent levels differ by at least one and at most three.

To check for the first one, I wrote a Santa’s little helper function to check the sign of a difference of two numbers:

def calc_sign(a, b):
    """Returns:
    0 if a == b
    -1 if a < b
    1 if a > b
    """
    diff = a - b
    if diff == 0:
        return 0
    return diff / abs(diff)

Then I wrote a function to check if an individual report is safe:

def is_report_safe(report):
    """Checks if a given list of numbers satisfies
    following rules:
 
    1. Numbers are either increasing or decreasing consistently
    2. Difference between two consecutive numbers is 0 < number < 4
    """
    sign = calc_sign(report[0], report[1])
    for prev, next in pairwise(report):
        diff = abs(prev - next)
        if diff <= 0 or diff > 3:
            return False
        if sign != calc_sign(prev, next):
            return False
    return True

Here we use the wonderful method itertools.pairwise which turns a list into pairs of two.

To show how that method works, let’s take a look at an easier example:

from itertools import pairwise
 
 
for a, b in pairwise([1,2,3,4]):
  print(a, b)
 
# prints
# 1 2
# 2 3
# 3 4

It’s a nice way to traverse a list two items at a time without having to worry about indices and when to stop and so on. I’ve been using it quite often in Advent of Code - so much that it deserved a spot on my 2022 Prep for Advent of Code blog post.

To provide an answer to the part 1, we need to count how many reports are safe:

def part_1():
    reports = read_input(2, map_fn)
    valid_reports = 0
    for report in reports:
        if is_report_safe(report):
            valid_reports += 1
    print(f"Part 1: {valid_reports}")

We could have used a list comprehension’s sibling generator comprehension here:

def part_1():
    reports = read_input(2, map_fn)
    valid_reports = sum(is_report_safe(report) for report in reports)
    print(f"Part 1: {valid_reports}")

But in this case I chose the earlier because I find it a tad bit easier to follow.

⭐️ Third star achieved!

Part 2: Problem Dampener

The reactor is more fault-tolerant than we first thought. It’s okay with a single level (number) in a report being incorrect.

So we need to calculate the amount of safe reports again but this time take into account the problem dampener feature.

def check_report_with_dampener(report):
    """Checks if a given list of numbers satisfies following rules:
 
    1. Numbers are either increasing or decreasing consistently
    2. Difference between two consecutive numbers is 0 < number < 4
 
    If it fails a rule, it retries by removing one number each time.
    If any of the shorter lists satisfies the rules, returns True.
    """
    if is_report_safe(report):
        return True
 
    for idx, _ in enumerate(report):
        if is_report_safe(report[:idx] + report[idx + 1 :]):
            return True
 
    return False

To do that, we wrap the safe check into a new function. If the report is safe right out of the box, we return True like we did in part 1.

If it is unsafe, we need to check if there is a safe configuration by ignoring one level at a time until a safe one is found.

report[:idx] + report[idx + 1 :]

is called list slicing. Square brackets ([ ]) after a list can do quite a lot in Python.

  • You can get a value by index with report[index]
  • You can get everything until an index (not including the indexed item) with report[:index]
  • You can get everything from index onward with report[index:]
  • You can get everything between two indices with report[start:end]
  • You can get every nth item with report[::2]

To calculate the final amount of safe reports for part 2, we do the same as in part 1 but with our new dampener solution:

def part_2():
    reports = read_input(2, map_fn)
    valid_reports = 0
    for report in reports:
        if check_report_with_dampener(report):
            valid_reports += 1
    print(f"Part 2: {valid_reports}")

⭐️ Four out of four!