4
\$\begingroup\$

Part 1:

The task involves analyzing a calibration document containing lines of text. Each line represents a calibration value that needs to be recovered by extracting the first and last digits and combining them into a two-digit number. The goal is to find the sum of all these calibration values.

For example:

1abc2
pqr3stu8vwx
a1b2c3d4e5f
treb7uchet

In this example, the calibration values of these four lines are 12, 38, 15, and 77. Adding these together produces 142.

#!/usr/bin/env python3

import sys
import re


def main():
    if len(sys.argv) != 2:
        print("Error - No file provided.", file=sys.stderr)

    total  = 0
    
    with open(sys.argv[1], "r") as f:
        for line in f:
            start, end = (
                re.search(r"\d", line).group(),
                re.search(r"\d", line[::-1]).group(),
            )
            total += int(start) * 10 + int(end)

    print(f"Final value: {total}")


if __name__ == "__main__":
    main()

Part 2:

In problem two, the calibration document may include digits spelled out with letters (e.g., "one," "two," etc.). The task is to identify the actual first and last digits in each line, taking into account both numerical digits and spelled-out numbers. The overall objective remains the same: find the sum of the calibration values obtained from combining the first and last digits of each line.

For example:

two1nine
eightwothree
abcone2threexyz
xtwone3four
4nineeightseven2
zoneight234
7pqrstsixteen

In this example, the calibration values are 29, 83, 13, 24, 42, 14, and 76. Adding these together produces 281.

#!/usr/bin/env python3

import sys


def main():
    if len(sys.argv) != 2:
        print("Error - No file provided.", file=sys.stderr)

    digits = {
        "one": 1,
        "two": 2,
        "three": 3,
        "four": 4,
        "five": 5,
        "six": 6,
        "seven": 7,
        "eight": 8,
        "nine": 9,
    }

    total = 0

    with open(sys.argv[1], "r") as f:
        for line in f:
            start, end = 0, 0

            for idx, ch in enumerate(line):
                if ch.isdigit():
                    if not start:
                        start = int(ch)
                    end = int(ch)
                else:
                    for digit in digits:
                        if line[idx:].startswith(digit):
                            if not start:
                                start = digits[digit]
                            end = digits[digit]
                            break

            total += start * 10 + end

    print(f"Final value: {total}")


if __name__ == "__main__":
    main()

What is a simpler way to solve the problem? How do I reduce the nesting in part 2?

\$\endgroup\$
3
  • \$\begingroup\$ Your code will consider "0" as a valid first/last digit, but will not consider "zero" as a spelt-out version of "0". Does the problem statement (ahem) spell out whether or not "0" or "zero" will appear in the input? \$\endgroup\$
    – AJNeufeld
    Dec 7, 2023 at 15:34
  • \$\begingroup\$ @AJNeufeld No, the problem statement does not spell out that "0" or "zero" would appear in the input, that's why I didn't give any thought to it. But I forgot to mention that in the question, or add a link to the page: adventofcode.com/2023/day/1 . Nevertheless, I changed the code accordingly. Thank you. \$\endgroup\$
    – Harith
    Dec 7, 2023 at 18:31
  • \$\begingroup\$ Thank-you for adding the link to the problem description in your post. Note however: the "Part 2" description is not available for those who have not registered with the site and submitted a solution for "Part 1". This is a high bar of effort for reviews of your code. We can't tell if the "0"/"zero" issue is a bug or just something which the problem states will never be seen. Ensure your problem description includes all of the problems requirements, including assumptions and limits. \$\endgroup\$
    – AJNeufeld
    Dec 7, 2023 at 21:41

1 Answer 1

2
\$\begingroup\$

Bug

"1zero" is probably supposed to evaluate to 10. You don't handle the digit "zero".

Reversing the string

Your first approach relies on line[::-1] in order to search for the "last" digit, by pretending it is the first in the reversed string.

Instead, you could easily search for the last digit with a slight modification of the regular expression:

re.search(r".*(\d)", line).group(1)

The .* is greedy, so it will match as many characters as possible, and then backtrack to find a digit. This may or may not be as clear for single digits. Without a doubt, it will be clearer than searching for (say) "evif" in a reversed string, so is probably a better approach.

Matching digits/word-digits

You can expand your regex pattern to match "word-digits" as well as digits:

pattern = r"(\d|zero|one|two|three|four|five|six|seven|eight|nine)"
...
re.search(pattern, line)[1]

Searching for the last can be done by tacking .* to the front of the pattern as well.

To ints

You use int(digit) or digits[digit] to convert digit/strings into numbers. If you mapped the digit words to the digit characters in the dictionary, then you could look up the character (or word) in the dictionary, (defaulting to the value being looked up, if not present), and then convert than to an integer:

    value = int(digits.get(token, token))

Code Organization

You should break your code into reusable functions. If in "Part 1", you had a calibration_total(lines) function, which summed the calibration_value(line) for each line, and calibration_value(line) called first_digit(line) and last_digit(line) functions to get the first and last digit values ...

def first_digit(line: str) -> int:
    ...

def last_digit(line: str) -> int:
    ...

def calibration_value(line: str) -> int:
    return 10 * first_digit(line) + last_digit(line)

def calibration_total(lines: Iterable[str]):
    return sum(map(calibration_value, lines))

def main():
    if len(sys.argv) != 2:
        print("Error - No file provided.", file=sys.stderr)

    with open(sys.argv[1], "r") as f:
        total = calibration_total(f)

    print(f"Final value: {total}")

if __name__ == "__main__":
    main()

then for "Part 2", you'd just be adjusting the first_digit and last_digit functions. The "driving portion" of the program would not have needed to be changed.

Eg:

import re
from typing import Iterable

DIGIT_NAMES = "zero one two three four five six seven eight nine".split()
DIGIT = {key: i for i, key in enumerate(DIGIT_NAMES)}
DIGIT_RE = rf'(\d|{"|".join(DIGIT_NAMES)})'
FIRST_DIGIT_PATTERN = re.compile(DIGIT_RE)
LAST_DIGIT_PATTERN = re.compile(".*" + DIGIT_RE)

def digit_to_int(digit: str) -> int:
    return int(DIGIT.get(digit, digit))

def first_digit(line: str) -> int:
    return digit_to_int(FIRST_DIGIT_PATTERN.search(line)[1])

def last_digit(line: str) -> int:
    return digit_to_int(LAST_DIGIT_PATTERN.search(line)[1])
\$\endgroup\$
3
  • \$\begingroup\$ ""1zero" is probably supposed to evaluate to 10": No, zero doesn't count as a valid number. \$\endgroup\$
    – InSync
    Dec 7, 2023 at 9:02
  • \$\begingroup\$ @InSync In that case, both versions of the OP’s code will have bugs. The first will find "0"’s with \d, and the second with ch.isdigit() \$\endgroup\$
    – AJNeufeld
    Dec 7, 2023 at 13:48
  • \$\begingroup\$ There is no 0/zero in the input, so OP's code is fine. \$\endgroup\$
    – InSync
    Dec 7, 2023 at 14:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.