From 562cf31a9a9d448b761cdc30df03fb7b526966d9 Mon Sep 17 00:00:00 2001 From: Maxim Smolskiy Date: Thu, 12 May 2022 06:48:04 +0300 Subject: [PATCH] Improve Project Euler problem 074 solution 2 (#5803) * Fix statement * Improve solution * Fix * Add tests --- project_euler/problem_074/sol2.py | 195 +++++++++++++++++------------- 1 file changed, 108 insertions(+), 87 deletions(-) diff --git a/project_euler/problem_074/sol2.py b/project_euler/problem_074/sol2.py index 55e67c6b9..d76bb014d 100644 --- a/project_euler/problem_074/sol2.py +++ b/project_euler/problem_074/sol2.py @@ -1,122 +1,143 @@ """ - Project Euler Problem 074: https://projecteuler.net/problem=74 +Project Euler Problem 074: https://projecteuler.net/problem=74 - Starting from any positive integer number - it is possible to attain another one summing the factorial of its digits. +The number 145 is well known for the property that the sum of the factorial of its +digits is equal to 145: - Repeating this step, we can build chains of numbers. - It is not difficult to prove that EVERY starting number - will eventually get stuck in a loop. +1! + 4! + 5! = 1 + 24 + 120 = 145 - The request is to find how many numbers less than one million - produce a chain with exactly 60 non repeating items. +Perhaps less well known is 169, in that it produces the longest chain of numbers that +link back to 169; it turns out that there are only three such loops that exist: - Solution approach: - This solution simply consists in a loop that generates - the chains of non repeating items. - The generation of the chain stops before a repeating item - or if the size of the chain is greater then the desired one. - After generating each chain, the length is checked and the - counter increases. +169 → 363601 → 1454 → 169 +871 → 45361 → 871 +872 → 45362 → 872 + +It is not difficult to prove that EVERY starting number will eventually get stuck in a +loop. For example, + +69 → 363600 → 1454 → 169 → 363601 (→ 1454) +78 → 45360 → 871 → 45361 (→ 871) +540 → 145 (→ 145) + +Starting with 69 produces a chain of five non-repeating terms, but the longest +non-repeating chain with a starting number below one million is sixty terms. + +How many chains, with a starting number below one million, contain exactly sixty +non-repeating terms? + +Solution approach: +This solution simply consists in a loop that generates the chains of non repeating +items using the cached sizes of the previous chains. +The generation of the chain stops before a repeating item or if the size of the chain +is greater then the desired one. +After generating each chain, the length is checked and the counter increases. """ +from math import factorial -factorial_cache: dict[int, int] = {} -factorial_sum_cache: dict[int, int] = {} +DIGIT_FACTORIAL: dict[str, int] = {str(digit): factorial(digit) for digit in range(10)} -def factorial(a: int) -> int: - """Returns the factorial of the input a - >>> factorial(5) - 120 - - >>> factorial(6) - 720 - - >>> factorial(0) - 1 +def digit_factorial_sum(number: int) -> int: """ + Function to perform the sum of the factorial of all the digits in number - # The factorial function is not defined for negative numbers - if a < 0: - raise ValueError("Invalid negative input!", a) + >>> digit_factorial_sum(69.0) + Traceback (most recent call last): + ... + TypeError: Parameter number must be int - if a in factorial_cache: - return factorial_cache[a] + >>> digit_factorial_sum(-1) + Traceback (most recent call last): + ... + ValueError: Parameter number must be greater than or equal to 0 - # The case of 0! is handled separately - if a == 0: - factorial_cache[a] = 1 - else: - # use a temporary support variable to store the computation - temporary_number = a - temporary_computation = 1 + >>> digit_factorial_sum(0) + 1 - while temporary_number > 0: - temporary_computation *= temporary_number - temporary_number -= 1 - - factorial_cache[a] = temporary_computation - return factorial_cache[a] - - -def factorial_sum(a: int) -> int: - """Function to perform the sum of the factorial - of all the digits in a - - >>> factorial_sum(69) + >>> digit_factorial_sum(69) 363600 """ - if a in factorial_sum_cache: - return factorial_sum_cache[a] - # Prepare a variable to hold the computation - fact_sum = 0 + if not isinstance(number, int): + raise TypeError("Parameter number must be int") - """ Convert a in string to iterate on its digits - convert the digit back into an int - and add its factorial to fact_sum. - """ - for i in str(a): - fact_sum += factorial(int(i)) - factorial_sum_cache[a] = fact_sum - return fact_sum + if number < 0: + raise ValueError("Parameter number must be greater than or equal to 0") + + # Converts number in string to iterate on its digits and adds its factorial. + return sum(DIGIT_FACTORIAL[digit] for digit in str(number)) def solution(chain_length: int = 60, number_limit: int = 1000000) -> int: - """Returns the number of numbers that produce - chains with exactly 60 non repeating elements. + """ + Returns the number of numbers below number_limit that produce chains with exactly + chain_length non repeating elements. + + >>> solution(10.0, 1000) + Traceback (most recent call last): + ... + TypeError: Parameters chain_length and number_limit must be int + + >>> solution(10, 1000.0) + Traceback (most recent call last): + ... + TypeError: Parameters chain_length and number_limit must be int + + >>> solution(0, 1000) + Traceback (most recent call last): + ... + ValueError: Parameters chain_length and number_limit must be greater than 0 + + >>> solution(10, 0) + Traceback (most recent call last): + ... + ValueError: Parameters chain_length and number_limit must be greater than 0 + >>> solution(10, 1000) 26 """ + if not isinstance(chain_length, int) or not isinstance(number_limit, int): + raise TypeError("Parameters chain_length and number_limit must be int") + + if chain_length <= 0 or number_limit <= 0: + raise ValueError( + "Parameters chain_length and number_limit must be greater than 0" + ) + # the counter for the chains with the exact desired length - chain_counter = 0 + chains_counter = 0 + # the cached sizes of the previous chains + chain_sets_lengths: dict[int, int] = {} - for i in range(1, number_limit + 1): + for start_chain_element in range(1, number_limit): - # The temporary list will contain the elements of the chain - chain_set = {i} - len_chain_set = 1 - last_chain_element = i + # The temporary set will contain the elements of the chain + chain_set = set() + chain_set_length = 0 - # The new element of the chain - new_chain_element = factorial_sum(last_chain_element) + # Stop computing the chain when you find a cached size, a repeating item or the + # length is greater then the desired one. + chain_element = start_chain_element + while ( + chain_element not in chain_sets_lengths + and chain_element not in chain_set + and chain_set_length <= chain_length + ): + chain_set.add(chain_element) + chain_set_length += 1 + chain_element = digit_factorial_sum(chain_element) - # Stop computing the chain when you find a repeating item - # or the length it greater then the desired one. + if chain_element in chain_sets_lengths: + chain_set_length += chain_sets_lengths[chain_element] - while new_chain_element not in chain_set and len_chain_set <= chain_length: - chain_set.add(new_chain_element) + chain_sets_lengths[start_chain_element] = chain_set_length - len_chain_set += 1 - last_chain_element = new_chain_element - new_chain_element = factorial_sum(last_chain_element) + # If chain contains the exact amount of elements increase the counter + if chain_set_length == chain_length: + chains_counter += 1 - # If the while exited because the chain list contains the exact amount - # of elements increase the counter - if len_chain_set == chain_length: - chain_counter += 1 - - return chain_counter + return chains_counter if __name__ == "__main__":