Skip to content

Improve Project Euler problem 074 solution 2 #5803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 109 additions & 88 deletions project_euler/problem_074/sol2.py
Original file line number Diff line number Diff line change
@@ -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

factorial_cache: dict[int, int] = {}
factorial_sum_cache: dict[int, int] = {}
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)

def factorial(a: int) -> int:
"""Returns the factorial of the input a
>>> factorial(5)
120
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.

>>> factorial(6)
720
How many chains, with a starting number below one million, contain exactly sixty
non-repeating terms?

>>> factorial(0)
1
"""

# The factorial function is not defined for negative numbers
if a < 0:
raise ValueError("Invalid negative input!", a)
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

if a in factorial_cache:
return factorial_cache[a]
DIGIT_FACTORIAL: dict[str, int] = {str(digit): factorial(digit) for digit in range(10)}

# 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

while temporary_number > 0:
temporary_computation *= temporary_number
temporary_number -= 1
def digit_factorial_sum(number: int) -> int:
"""
Function to perform the sum of the factorial of all the digits in number

factorial_cache[a] = temporary_computation
return factorial_cache[a]
>>> digit_factorial_sum(69.0)
Traceback (most recent call last):
...
TypeError: Parameter number must be int

>>> digit_factorial_sum(-1)
Traceback (most recent call last):
...
ValueError: Parameter number must be greater than or equal to 0

def factorial_sum(a: int) -> int:
"""Function to perform the sum of the factorial
of all the digits in a
>>> digit_factorial_sum(0)
1

>>> 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

""" 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 not isinstance(number, int):
raise TypeError("Parameter number must be int")

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.
>>> solution(10, 1000)
26
"""
Returns the number of numbers below number_limit that produce chains with exactly
chain_length non repeating elements.

# the counter for the chains with the exact desired length
chain_counter = 0

for i in range(1, number_limit + 1):
>>> solution(10.0, 1000)
Traceback (most recent call last):
...
TypeError: Parameters chain_length and number_limit must be int

# The temporary list will contain the elements of the chain
chain_set = {i}
len_chain_set = 1
last_chain_element = i
>>> solution(10, 1000.0)
Traceback (most recent call last):
...
TypeError: Parameters chain_length and number_limit must be int

# The new element of the chain
new_chain_element = factorial_sum(last_chain_element)
>>> solution(0, 1000)
Traceback (most recent call last):
...
ValueError: Parameters chain_length and number_limit must be greater than 0

# Stop computing the chain when you find a repeating item
# or the length it greater then the desired one.
>>> solution(10, 0)
Traceback (most recent call last):
...
ValueError: Parameters chain_length and number_limit must be greater than 0

while new_chain_element not in chain_set and len_chain_set <= chain_length:
chain_set.add(new_chain_element)
>>> solution(10, 1000)
26
"""

len_chain_set += 1
last_chain_element = new_chain_element
new_chain_element = factorial_sum(last_chain_element)
if not isinstance(chain_length, int) or not isinstance(number_limit, int):
raise TypeError("Parameters chain_length and number_limit must be int")

# 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
if chain_length <= 0 or number_limit <= 0:
raise ValueError(
"Parameters chain_length and number_limit must be greater than 0"
)

return chain_counter
# the counter for the chains with the exact desired length
chains_counter = 0
# the cached sizes of the previous chains
chain_sets_lengths: dict[int, int] = {}

for start_chain_element in range(1, number_limit):

# The temporary set will contain the elements of the chain
chain_set = set()
chain_set_length = 0

# 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)

if chain_element in chain_sets_lengths:
chain_set_length += chain_sets_lengths[chain_element]

chain_sets_lengths[start_chain_element] = chain_set_length

# If chain contains the exact amount of elements increase the counter
if chain_set_length == chain_length:
chains_counter += 1

return chains_counter


if __name__ == "__main__":
Expand Down