|
1 | 1 | """
|
2 |
| - Project Euler Problem 074: https://projecteuler.net/problem=74 |
| 2 | +Project Euler Problem 074: https://projecteuler.net/problem=74 |
3 | 3 |
|
4 |
| - Starting from any positive integer number |
5 |
| - it is possible to attain another one summing the factorial of its digits. |
| 4 | +The number 145 is well known for the property that the sum of the factorial of its |
| 5 | +digits is equal to 145: |
6 | 6 |
|
7 |
| - Repeating this step, we can build chains of numbers. |
8 |
| - It is not difficult to prove that EVERY starting number |
9 |
| - will eventually get stuck in a loop. |
| 7 | +1! + 4! + 5! = 1 + 24 + 120 = 145 |
10 | 8 |
|
11 |
| - The request is to find how many numbers less than one million |
12 |
| - produce a chain with exactly 60 non repeating items. |
| 9 | +Perhaps less well known is 169, in that it produces the longest chain of numbers that |
| 10 | +link back to 169; it turns out that there are only three such loops that exist: |
13 | 11 |
|
14 |
| - Solution approach: |
15 |
| - This solution simply consists in a loop that generates |
16 |
| - the chains of non repeating items. |
17 |
| - The generation of the chain stops before a repeating item |
18 |
| - or if the size of the chain is greater then the desired one. |
19 |
| - After generating each chain, the length is checked and the |
20 |
| - counter increases. |
21 |
| -""" |
| 12 | +169 → 363601 → 1454 → 169 |
| 13 | +871 → 45361 → 871 |
| 14 | +872 → 45362 → 872 |
22 | 15 |
|
23 |
| -factorial_cache: dict[int, int] = {} |
24 |
| -factorial_sum_cache: dict[int, int] = {} |
| 16 | +It is not difficult to prove that EVERY starting number will eventually get stuck in a |
| 17 | +loop. For example, |
25 | 18 |
|
| 19 | +69 → 363600 → 1454 → 169 → 363601 (→ 1454) |
| 20 | +78 → 45360 → 871 → 45361 (→ 871) |
| 21 | +540 → 145 (→ 145) |
26 | 22 |
|
27 |
| -def factorial(a: int) -> int: |
28 |
| - """Returns the factorial of the input a |
29 |
| - >>> factorial(5) |
30 |
| - 120 |
| 23 | +Starting with 69 produces a chain of five non-repeating terms, but the longest |
| 24 | +non-repeating chain with a starting number below one million is sixty terms. |
31 | 25 |
|
32 |
| - >>> factorial(6) |
33 |
| - 720 |
| 26 | +How many chains, with a starting number below one million, contain exactly sixty |
| 27 | +non-repeating terms? |
34 | 28 |
|
35 |
| - >>> factorial(0) |
36 |
| - 1 |
37 |
| - """ |
38 |
| - |
39 |
| - # The factorial function is not defined for negative numbers |
40 |
| - if a < 0: |
41 |
| - raise ValueError("Invalid negative input!", a) |
| 29 | +Solution approach: |
| 30 | +This solution simply consists in a loop that generates the chains of non repeating |
| 31 | +items using the cached sizes of the previous chains. |
| 32 | +The generation of the chain stops before a repeating item or if the size of the chain |
| 33 | +is greater then the desired one. |
| 34 | +After generating each chain, the length is checked and the counter increases. |
| 35 | +""" |
| 36 | +from math import factorial |
42 | 37 |
|
43 |
| - if a in factorial_cache: |
44 |
| - return factorial_cache[a] |
| 38 | +DIGIT_FACTORIAL: dict[str, int] = {str(digit): factorial(digit) for digit in range(10)} |
45 | 39 |
|
46 |
| - # The case of 0! is handled separately |
47 |
| - if a == 0: |
48 |
| - factorial_cache[a] = 1 |
49 |
| - else: |
50 |
| - # use a temporary support variable to store the computation |
51 |
| - temporary_number = a |
52 |
| - temporary_computation = 1 |
53 | 40 |
|
54 |
| - while temporary_number > 0: |
55 |
| - temporary_computation *= temporary_number |
56 |
| - temporary_number -= 1 |
| 41 | +def digit_factorial_sum(number: int) -> int: |
| 42 | + """ |
| 43 | + Function to perform the sum of the factorial of all the digits in number |
57 | 44 |
|
58 |
| - factorial_cache[a] = temporary_computation |
59 |
| - return factorial_cache[a] |
| 45 | + >>> digit_factorial_sum(69.0) |
| 46 | + Traceback (most recent call last): |
| 47 | + ... |
| 48 | + TypeError: Parameter number must be int |
60 | 49 |
|
| 50 | + >>> digit_factorial_sum(-1) |
| 51 | + Traceback (most recent call last): |
| 52 | + ... |
| 53 | + ValueError: Parameter number must be greater than or equal to 0 |
61 | 54 |
|
62 |
| -def factorial_sum(a: int) -> int: |
63 |
| - """Function to perform the sum of the factorial |
64 |
| - of all the digits in a |
| 55 | + >>> digit_factorial_sum(0) |
| 56 | + 1 |
65 | 57 |
|
66 |
| - >>> factorial_sum(69) |
| 58 | + >>> digit_factorial_sum(69) |
67 | 59 | 363600
|
68 | 60 | """
|
69 |
| - if a in factorial_sum_cache: |
70 |
| - return factorial_sum_cache[a] |
71 |
| - # Prepare a variable to hold the computation |
72 |
| - fact_sum = 0 |
73 |
| - |
74 |
| - """ Convert a in string to iterate on its digits |
75 |
| - convert the digit back into an int |
76 |
| - and add its factorial to fact_sum. |
77 |
| - """ |
78 |
| - for i in str(a): |
79 |
| - fact_sum += factorial(int(i)) |
80 |
| - factorial_sum_cache[a] = fact_sum |
81 |
| - return fact_sum |
| 61 | + if not isinstance(number, int): |
| 62 | + raise TypeError("Parameter number must be int") |
| 63 | + |
| 64 | + if number < 0: |
| 65 | + raise ValueError("Parameter number must be greater than or equal to 0") |
| 66 | + |
| 67 | + # Converts number in string to iterate on its digits and adds its factorial. |
| 68 | + return sum(DIGIT_FACTORIAL[digit] for digit in str(number)) |
82 | 69 |
|
83 | 70 |
|
84 | 71 | def solution(chain_length: int = 60, number_limit: int = 1000000) -> int:
|
85 |
| - """Returns the number of numbers that produce |
86 |
| - chains with exactly 60 non repeating elements. |
87 |
| - >>> solution(10, 1000) |
88 |
| - 26 |
89 | 72 | """
|
| 73 | + Returns the number of numbers below number_limit that produce chains with exactly |
| 74 | + chain_length non repeating elements. |
90 | 75 |
|
91 |
| - # the counter for the chains with the exact desired length |
92 |
| - chain_counter = 0 |
93 |
| - |
94 |
| - for i in range(1, number_limit + 1): |
| 76 | + >>> solution(10.0, 1000) |
| 77 | + Traceback (most recent call last): |
| 78 | + ... |
| 79 | + TypeError: Parameters chain_length and number_limit must be int |
95 | 80 |
|
96 |
| - # The temporary list will contain the elements of the chain |
97 |
| - chain_set = {i} |
98 |
| - len_chain_set = 1 |
99 |
| - last_chain_element = i |
| 81 | + >>> solution(10, 1000.0) |
| 82 | + Traceback (most recent call last): |
| 83 | + ... |
| 84 | + TypeError: Parameters chain_length and number_limit must be int |
100 | 85 |
|
101 |
| - # The new element of the chain |
102 |
| - new_chain_element = factorial_sum(last_chain_element) |
| 86 | + >>> solution(0, 1000) |
| 87 | + Traceback (most recent call last): |
| 88 | + ... |
| 89 | + ValueError: Parameters chain_length and number_limit must be greater than 0 |
103 | 90 |
|
104 |
| - # Stop computing the chain when you find a repeating item |
105 |
| - # or the length it greater then the desired one. |
| 91 | + >>> solution(10, 0) |
| 92 | + Traceback (most recent call last): |
| 93 | + ... |
| 94 | + ValueError: Parameters chain_length and number_limit must be greater than 0 |
106 | 95 |
|
107 |
| - while new_chain_element not in chain_set and len_chain_set <= chain_length: |
108 |
| - chain_set.add(new_chain_element) |
| 96 | + >>> solution(10, 1000) |
| 97 | + 26 |
| 98 | + """ |
109 | 99 |
|
110 |
| - len_chain_set += 1 |
111 |
| - last_chain_element = new_chain_element |
112 |
| - new_chain_element = factorial_sum(last_chain_element) |
| 100 | + if not isinstance(chain_length, int) or not isinstance(number_limit, int): |
| 101 | + raise TypeError("Parameters chain_length and number_limit must be int") |
113 | 102 |
|
114 |
| - # If the while exited because the chain list contains the exact amount |
115 |
| - # of elements increase the counter |
116 |
| - if len_chain_set == chain_length: |
117 |
| - chain_counter += 1 |
| 103 | + if chain_length <= 0 or number_limit <= 0: |
| 104 | + raise ValueError( |
| 105 | + "Parameters chain_length and number_limit must be greater than 0" |
| 106 | + ) |
118 | 107 |
|
119 |
| - return chain_counter |
| 108 | + # the counter for the chains with the exact desired length |
| 109 | + chains_counter = 0 |
| 110 | + # the cached sizes of the previous chains |
| 111 | + chain_sets_lengths: dict[int, int] = {} |
| 112 | + |
| 113 | + for start_chain_element in range(1, number_limit): |
| 114 | + |
| 115 | + # The temporary set will contain the elements of the chain |
| 116 | + chain_set = set() |
| 117 | + chain_set_length = 0 |
| 118 | + |
| 119 | + # Stop computing the chain when you find a cached size, a repeating item or the |
| 120 | + # length is greater then the desired one. |
| 121 | + chain_element = start_chain_element |
| 122 | + while ( |
| 123 | + chain_element not in chain_sets_lengths |
| 124 | + and chain_element not in chain_set |
| 125 | + and chain_set_length <= chain_length |
| 126 | + ): |
| 127 | + chain_set.add(chain_element) |
| 128 | + chain_set_length += 1 |
| 129 | + chain_element = digit_factorial_sum(chain_element) |
| 130 | + |
| 131 | + if chain_element in chain_sets_lengths: |
| 132 | + chain_set_length += chain_sets_lengths[chain_element] |
| 133 | + |
| 134 | + chain_sets_lengths[start_chain_element] = chain_set_length |
| 135 | + |
| 136 | + # If chain contains the exact amount of elements increase the counter |
| 137 | + if chain_set_length == chain_length: |
| 138 | + chains_counter += 1 |
| 139 | + |
| 140 | + return chains_counter |
120 | 141 |
|
121 | 142 |
|
122 | 143 | if __name__ == "__main__":
|
|
0 commit comments