Back to portfolio

Full Python source

Speedy Armstrong Calculator

Contest-winning UW cohort implementation using digit-count combinations instead of brute force.

#!/usr/bin/env python3

import argparse
import time


MIN_NUMBER = 10
MAX_NUMBER = 100_000_000


def parse_args():
    parser = argparse.ArgumentParser(
        description="Find Armstrong numbers quickly using digit multisets instead of brute force."
    )
    parser.add_argument("-n", "--number", type=int, default=MAX_NUMBER, help="maximum number to search to")
    return parser.parse_args()


def validate_limit(limit):
    if limit < MIN_NUMBER:
        raise ValueError("Maximum number must be at least 10.")


def build_power_table(max_digits):
    return [[digit ** digit_count for digit in range(10)] for digit_count in range(max_digits + 1)]


def digit_counts(number):
    counts = [0] * 10
    for digit in str(number):
        counts[ord(digit) - ord("0")] += 1
    return tuple(counts)


def generate_digit_count_sets(total_digits, digit=0, remaining=None, counts=None):
    if remaining is None:
        remaining = total_digits
    if counts is None:
        counts = [0] * 10

    if digit == 9:
        counts[digit] = remaining
        yield tuple(counts)
        counts[digit] = 0
        return

    for count in range(remaining + 1):
        counts[digit] = count
        yield from generate_digit_count_sets(total_digits, digit + 1, remaining - count, counts)
    counts[digit] = 0


def sum_for_counts(counts, powers):
    total = 0
    for digit, count in enumerate(counts):
        if count:
            total += count * powers[digit]
    return total


def find_armstrong_numbers(limit):
    max_digits = len(str(limit))
    power_table = build_power_table(max_digits)
    found = set()

    for digit_count in range(2, max_digits + 1):
        lower_bound = 10 ** (digit_count - 1)
        upper_bound = min(limit, 10**digit_count - 1)

        if lower_bound > limit:
            break

        powers = power_table[digit_count]
        for counts in generate_digit_count_sets(digit_count):
            candidate = sum_for_counts(counts, powers)

            if candidate < lower_bound or candidate > upper_bound:
                continue

            if digit_counts(candidate) == counts:
                found.add(candidate)

    return sorted(found)


def main():
    args = parse_args()

    try:
        validate_limit(args.number)
    except ValueError as error:
        print(f"Error: {error}")
        return 1

    start_time = time.perf_counter()
    armstrong_numbers = find_armstrong_numbers(args.number)
    elapsed_ms = int((time.perf_counter() - start_time) * 1000)

    print(f"It took {elapsed_ms} milliseconds to complete the task.")
    print("Armstrong numbers found: " + ", ".join(str(number) for number in armstrong_numbers))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())