|
9 | 9 | Therefore any chain that arrives at 1 or 89 will become stuck in an endless loop. |
10 | 10 | What is most amazing is that EVERY starting number will eventually arrive at 1 or 89. |
11 | 11 | 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. |
45 | 12 |
|
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 | +""" |
52 | 17 |
|
53 | 18 |
|
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: |
68 | 20 | """ |
| 21 | + Returns how many starting numbers below `number` will arrive at 89 |
| 22 | + in the digit-square chain. |
69 | 23 |
|
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. |
79 | 27 |
|
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. |
88 | 35 |
|
89 | 36 | >>> solution(100) |
90 | 37 | 80 |
91 | | - >>> solution(10000000) |
| 38 | +
|
| 39 | + >>> solution(10_000_000) |
92 | 40 | 8581146 |
93 | 41 | """ |
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 | + ) |
99 | 101 |
|
100 | 102 |
|
101 | 103 | if __name__ == "__main__": |
|
0 commit comments