Skip to content

Commit 9391f54

Browse files
perf(project_euler): replace problem_092/sol1 with digit DP (~1500x speedup) (#15141)
* perf(project_euler): replace problem_092 sol1 with digit DP The previous solution iterated all 10,000,000 values in a Python-level for-loop (~6 s on GitHub Actions CI). The new approach uses digit DP over the decimal digits of (number-1): counts in O(k * 568 * 10) ~ 40k ops how many integers in [0, number-1] have each digit-square sum, then multiplies by a precomputed lookup of whether each sum eventually reaches 89. Running time on the default input drops from ~6 s to ~0.004 s (~1500x). Closes #8594 * perf(project_euler): replace problem_092/sol1 with digit DP * chore: remove workflow artifact (.oss-upstream)
1 parent 7421e02 commit 9391f54

1 file changed

Lines changed: 78 additions & 76 deletions

File tree

project_euler/problem_092/sol1.py

Lines changed: 78 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,93 +9,95 @@
99
Therefore any chain that arrives at 1 or 89 will become stuck in an endless loop.
1010
What is most amazing is that EVERY starting number will eventually arrive at 1 or 89.
1111
How many starting numbers below ten million will arrive at 89?
12-
"""
13-
14-
DIGITS_SQUARED = [sum(int(c, 10) ** 2 for c in i.__str__()) for i in range(100000)]
15-
16-
17-
def next_number(number: int) -> int:
18-
"""
19-
Returns the next number of the chain by adding the square of each digit
20-
to form a new number.
21-
For example, if number = 12, next_number() will return 1^2 + 2^2 = 5.
22-
Therefore, 5 is the next number of the chain.
23-
>>> next_number(44)
24-
32
25-
>>> next_number(10)
26-
1
27-
>>> next_number(32)
28-
13
29-
"""
30-
31-
sum_of_digits_squared = 0
32-
while number:
33-
# Increased Speed Slightly by checking every 5 digits together.
34-
sum_of_digits_squared += DIGITS_SQUARED[number % 100000]
35-
number //= 100000
36-
37-
return sum_of_digits_squared
38-
39-
40-
# There are 2 Chains made,
41-
# One ends with 89 with the chain member 58 being the one which when declared first,
42-
# there will be the least number of iterations for all the members to be checked.
43-
44-
# The other one ends with 1 and has only one element 1.
4512
46-
# So 58 and 1 are chosen to be declared at the starting.
47-
48-
# Changed dictionary to an array to quicken the solution
49-
CHAINS: list[bool | None] = [None] * 10000000
50-
CHAINS[0] = True
51-
CHAINS[57] = False
13+
References:
14+
- https://en.wikipedia.org/wiki/Digital_root
15+
- https://en.wikipedia.org/wiki/Digit_DP
16+
"""
5217

5318

54-
def chain(number: int) -> bool:
55-
"""
56-
The function generates the chain of numbers until the next number is 1 or 89.
57-
For example, if starting number is 44, then the function generates the
58-
following chain of numbers:
59-
44 → 32 → 13 → 10 → 1 → 1.
60-
Once the next number generated is 1 or 89, the function returns whether
61-
or not the next number generated by next_number() is 1.
62-
>>> chain(10)
63-
True
64-
>>> chain(58)
65-
False
66-
>>> chain(1)
67-
True
19+
def solution(number: int = 10_000_000) -> int:
6820
"""
21+
Returns how many starting numbers below `number` will arrive at 89
22+
in the digit-square chain.
6923
70-
if CHAINS[number - 1] is not None:
71-
return CHAINS[number - 1] # type: ignore[return-value]
72-
73-
number_chain = chain(next_number(number))
74-
CHAINS[number - 1] = number_chain
75-
76-
while number < 10000000:
77-
CHAINS[number - 1] = number_chain
78-
number *= 10
24+
Uses digit DP so the count is computed in O(k * d_max * 10) time —
25+
roughly 40 000 operations for number = 10^7 — instead of iterating
26+
all `number` values explicitly.
7927
80-
return number_chain
81-
82-
83-
def solution(number: int = 10000000) -> int:
84-
"""
85-
The function returns the number of integers that end up being 89 in each chain.
86-
The function accepts a range number and the function checks all the values
87-
under value number.
28+
Key observations:
29+
1. For any n < number, digit_square_sum(n) ≤ num_digits * 81,
30+
so chain endpoints can be precomputed for that small range only.
31+
2. A digit DP over the decimal digits of (number - 1) counts how many
32+
integers in [0, number-1] have each possible digit-square sum,
33+
grouping by whether the prefix is still bounded ("tight") or free.
34+
Integers whose digit-square sum equals 0 are exactly 0 itself.
8835
8936
>>> solution(100)
9037
80
91-
>>> solution(10000000)
38+
39+
>>> solution(10_000_000)
9240
8581146
9341
"""
94-
for i in range(1, number):
95-
if CHAINS[i] is None:
96-
chain(i + 1)
97-
98-
return CHAINS[:number].count(False)
42+
num_digits = len(str(number - 1)) if number > 1 else 1
43+
limit = num_digits * 81 + 1 # max possible digit-square sum + 1
44+
45+
def digit_square_sum(n: int) -> int:
46+
total = 0
47+
while n:
48+
total += (n % 10) ** 2
49+
n //= 10
50+
return total
51+
52+
# Precompute whether each value 1..limit-1 eventually reaches 89.
53+
# All intermediate chain values stay below limit because the digit-square
54+
# sum of any k-digit number is at most k * 81 = limit - 1.
55+
ends_at_89 = bytearray(limit)
56+
for i in range(1, limit):
57+
n = i
58+
while n not in (1, 89):
59+
n = digit_square_sum(n)
60+
ends_at_89[i] = n == 89
61+
62+
# Digit DP over the decimal digits of (number - 1).
63+
# Treating shorter numbers as zero-padded strings (e.g. 7 → "0000007")
64+
# is safe because 0^2 = 0 contributes nothing to the digit-square sum.
65+
# dp_tight[s] / dp_free[s] = count of digit sequences whose running
66+
# digit-square sum is s and whose prefix is still ≤ / already < the
67+
# corresponding prefix of (number - 1).
68+
digits = [int(d) for d in str(number - 1)] if number > 1 else [0]
69+
70+
dp_tight: dict[int, int] = {0: 1}
71+
dp_free: dict[int, int] = {}
72+
73+
for lim in digits:
74+
new_tight: dict[int, int] = {}
75+
new_free: dict[int, int] = {}
76+
77+
for dss, cnt in dp_tight.items():
78+
for d in range(lim + 1):
79+
new_val = dss + d * d
80+
if new_val < limit:
81+
if d == lim:
82+
new_tight[new_val] = new_tight.get(new_val, 0) + cnt
83+
else:
84+
new_free[new_val] = new_free.get(new_val, 0) + cnt
85+
86+
for dss, cnt in dp_free.items():
87+
for d in range(10):
88+
new_val = dss + d * d
89+
if new_val < limit:
90+
new_free[new_val] = new_free.get(new_val, 0) + cnt
91+
92+
dp_tight, dp_free = new_tight, new_free
93+
94+
# Sum counts for all digit-square sums that end at 89.
95+
# dss == 0 corresponds to the number 0, which is excluded.
96+
return sum(
97+
cnt
98+
for dss, cnt in (*dp_tight.items(), *dp_free.items())
99+
if 0 < dss < limit and ends_at_89[dss]
100+
)
99101

100102

101103
if __name__ == "__main__":

0 commit comments

Comments
 (0)