|
| 1 | +""" |
| 2 | +Project Euler Problem 60: https://projecteuler.net/problem=60 |
| 3 | +
|
| 4 | +# Problem Statement: |
| 5 | +
|
| 6 | +The primes 3, 7, 109, and 673 are quite remarkable. By taking any two primes |
| 7 | +and concatenating them in any order the result will always be prime. |
| 8 | +For example, taking 7 and 109, both 7109 and 1097 are prime. |
| 9 | +The sum of these four primes, 792, represents the lowest sum for a set of four primes |
| 10 | +with this property. |
| 11 | +Find the lowest sum for a set of five primes for which any two primes concatenate |
| 12 | +to produce another prime. |
| 13 | +
|
| 14 | +# Solution Explanation: |
| 15 | +
|
| 16 | +The brute force approach would be to check all combinations of 5 primes and check |
| 17 | +if they satisfy the concatenation property. However, this is computationally |
| 18 | +expensive. Instead, we can use a backtracking approach to build sets of primes |
| 19 | +that satisfy the concatenation property. We can further optimize by using property |
| 20 | +of divisibility by 3 to eliminate certain candidates and memoization to avoid |
| 21 | +redundant prime checks. |
| 22 | +Throughout the code, we have used a parameter flag to indicate whether |
| 23 | +we are working with primes that are congruent to 1 or 2 modulo 3. |
| 24 | +This helps in reducing the search space. |
| 25 | +
|
| 26 | +## Eliminating candidates using divisibility by 3: |
| 27 | +Consider any 2 primes p1 and p2 that are not divisible by 3. If p1 divided by 3 |
| 28 | +gives a remainder of 1 and p2 divided by 3 gives a remainder of 2, then |
| 29 | +the concatenated number p1p2 will be divisible by 3 and hence not prime. |
| 30 | +This can be easily proven using the property of modular arithmetic. |
| 31 | + Consider p1 ≡ 1 (mod 3) and p2 ≡ 2 (mod 3). Define a1 = p1, b1 = 1, a2 = p2, b2 = 2. |
| 32 | + concat(p1, p2) = (p1 * 10^k + p2) where k is the number of digits in p2. |
| 33 | + Now, (p1 * 10^k + p2) mod 3 = ((p1 * 10^k) + p2) mod 3 |
| 34 | + As 10^k mod 3 = 1, we have (p1 * 1 + p2) mod 3 (ka mod 3 = kb mod 3) |
| 35 | + Which implies (p1 + p2) mod 3 = (1 + 2) mod 3 = 0 (a1 + a2 mod 3 = b1 + b2 mod 3) |
| 36 | +
|
| 37 | +Thus, we can eliminate such pairs from our search space and reach the solution faster. |
| 38 | +The solution uses this property to divide the primes into two lists based on their |
| 39 | +remainder when divided by 3. This way, we only need to check combinations within |
| 40 | +either list, reducing the number of checks significantly. |
| 41 | +
|
| 42 | +## Memoization: |
| 43 | +We can use a dictionary to store the results of prime checks for concatenated numbers. |
| 44 | +This way, if we encounter the same concatenated number again, we can simply look up |
| 45 | +the result instead of recalculating it. |
| 46 | +
|
| 47 | +## Backtracking: |
| 48 | +We can use a recursive function to build sets of primes. Starting with an empty set, |
| 49 | +we can add primes one by one, checking at each step if the current set satisfies |
| 50 | +the concatenation property. If it does, we can continue adding more primes. |
| 51 | +If we reach a set of 5 primes, we can check if their sum is the lowest |
| 52 | +
|
| 53 | +References: |
| 54 | +- [Modular Arithmetic Explanation](https://en.wikipedia.org/wiki/Modular_arithmetic) |
| 55 | +- [Project Euler Forum Discussion](https://projecteuler.net/problem=60) |
| 56 | +- [Prime Checking Optimization](https://en.wikipedia.org/wiki/Primality_test) |
| 57 | +- [Backtracking Algorithm](https://en.wikipedia.org/wiki/Backtracking) |
| 58 | +""" |
| 59 | + |
| 60 | +from functools import cache |
| 61 | + |
| 62 | +prime_mod_3_is_1_list: list[int] = [3, 7, 13, 19] |
| 63 | +prime_mod_3_is_2_list: list[int] = [3, 5, 11, 17] |
| 64 | + |
| 65 | +prime_pairs: dict[tuple, bool] = {} |
| 66 | + |
| 67 | + |
| 68 | +@cache |
| 69 | +def is_prime(num: int) -> bool: |
| 70 | + """ |
| 71 | + Efficient primality check using 6k ± 1 optimization. |
| 72 | +
|
| 73 | + >>> is_prime(0) |
| 74 | + False |
| 75 | + >>> is_prime(1) |
| 76 | + False |
| 77 | + >>> is_prime(2) |
| 78 | + True |
| 79 | + >>> is_prime(3) |
| 80 | + True |
| 81 | + >>> is_prime(77) |
| 82 | + False |
| 83 | + >>> is_prime(673) |
| 84 | + True |
| 85 | + >>> is_prime(1097) |
| 86 | + True |
| 87 | + >>> is_prime(7109) |
| 88 | + True |
| 89 | + """ |
| 90 | + |
| 91 | + if num < 2: |
| 92 | + return False |
| 93 | + if num in (2, 3): |
| 94 | + return True |
| 95 | + if num % 2 == 0 or num % 3 == 0: |
| 96 | + return False |
| 97 | + # Check divisibility up to sqrt(num) |
| 98 | + n_sqrt = int(num**0.5) |
| 99 | + for i in range(5, n_sqrt + 1, 6): |
| 100 | + if num % i == 0 or num % (i + 2) == 0: |
| 101 | + return False |
| 102 | + return True |
| 103 | + |
| 104 | + |
| 105 | +def sum_digits(num: int) -> int: |
| 106 | + """ |
| 107 | + Returns the sum of digits of num. If the sum is greater than 10, |
| 108 | + it recursively sums the digits of the result until a single digit is obtained. |
| 109 | +
|
| 110 | + >>> sum_digits(-18) |
| 111 | + Traceback (most recent call last): |
| 112 | + ... |
| 113 | + ValueError: num must be non-negative |
| 114 | + >>> sum_digits(0) |
| 115 | + 0 |
| 116 | + >>> sum_digits(5) |
| 117 | + 5 |
| 118 | + >>> sum_digits(79) |
| 119 | + 7 |
| 120 | + >>> sum_digits(999) |
| 121 | + 9 |
| 122 | + """ |
| 123 | + if num < 0: |
| 124 | + raise ValueError("num must be non-negative") |
| 125 | + if num < 10: |
| 126 | + return num |
| 127 | + return sum_digits(sum(map(int, str(num)))) |
| 128 | + |
| 129 | + |
| 130 | +def is_concat(num1: int, num2: int) -> bool: |
| 131 | + """ |
| 132 | + Check if concatenations of num1+num2 and num2+num1 are both prime. |
| 133 | + Uses memoization to store previously computed results in prime_pairs dictionary. |
| 134 | + Effects: Updates the prime_pairs dictionary with the result. |
| 135 | + Only stores (min(num1, num2), max(num1, num2)) as key to avoid duplicates. |
| 136 | +
|
| 137 | + >>> is_concat(3, 7) |
| 138 | + True |
| 139 | + >>> is_concat(1, 6) |
| 140 | + False |
| 141 | + >>> is_concat(7, 109) |
| 142 | + True |
| 143 | + >>> is_concat(13, 31) |
| 144 | + False |
| 145 | + """ |
| 146 | + if num1 > num2: |
| 147 | + num1, num2 = num2, num1 |
| 148 | + key = (num1, num2) |
| 149 | + if key in prime_pairs: |
| 150 | + return prime_pairs[key] |
| 151 | + concat1 = int(f"{num1}{num2}") |
| 152 | + concat2 = int(f"{num2}{num1}") |
| 153 | + result = is_prime(concat1) and is_prime(concat2) |
| 154 | + prime_pairs[key] = result |
| 155 | + return result |
| 156 | + |
| 157 | + |
| 158 | +def add_prime(primes: list[int]) -> list[int]: |
| 159 | + """ |
| 160 | + Add a new prime number to the input list of primes based on its modulo 3 value. |
| 161 | + Effects: Modifies the input list by appending a new prime number. |
| 162 | +
|
| 163 | + >>> add_prime([3, 7, 13, 19]) |
| 164 | + [3, 7, 13, 19, 31] |
| 165 | + >>> add_prime([3, 5, 11, 17]) |
| 166 | + [3, 5, 11, 17, 23] |
| 167 | + >>> add_prime([3, 7, 13, 19, 31]) |
| 168 | + [3, 7, 13, 19, 31, 37] |
| 169 | + """ |
| 170 | + |
| 171 | + next_num = primes[-1] + 3 # using modular arithmetic to get similar primes |
| 172 | + while not is_prime(next_num): |
| 173 | + next_num += 3 |
| 174 | + primes.append(next_num) |
| 175 | + return primes |
| 176 | + |
| 177 | + |
| 178 | +def generate_primes(n: int, flag: int = 1) -> list[int]: |
| 179 | + """ |
| 180 | + Ensure we have at least n primes in the selected list. |
| 181 | +
|
| 182 | + >>> generate_primes(5, 1) |
| 183 | + [3, 7, 13, 19, 31] |
| 184 | + >>> generate_primes(5, 2) |
| 185 | + [3, 5, 11, 17, 23] |
| 186 | + """ |
| 187 | + primes = prime_mod_3_is_1_list if flag == 1 else prime_mod_3_is_2_list |
| 188 | + while len(primes) < n: |
| 189 | + primes = add_prime(primes) |
| 190 | + return primes |
| 191 | + |
| 192 | + |
| 193 | +def solution( |
| 194 | + target_size: int = 5, prime_limit: int = 1000, flag: int = 1 |
| 195 | +) -> int | None: |
| 196 | + """ |
| 197 | + Search for a set of primes with the concat-prime property. |
| 198 | + Returns the sum of the lowest such set found else returns None. |
| 199 | +
|
| 200 | + >>> solution(3, 100, None) |
| 201 | + Traceback (most recent call last): |
| 202 | + ... |
| 203 | + ValueError: flag must be either 1 or 2 |
| 204 | + >>> solution(4, 100, 1) |
| 205 | + 792 |
| 206 | + >>> solution(3, 100, 2) |
| 207 | + 715 |
| 208 | + >>> solution(5, 1000, 1) |
| 209 | + 26033 |
| 210 | + """ |
| 211 | + if flag not in (1, 2): |
| 212 | + raise ValueError("flag must be either 1 or 2") |
| 213 | + primes = generate_primes(prime_limit, flag) |
| 214 | + |
| 215 | + def search(chain): |
| 216 | + """ |
| 217 | + Recursive backtracking search to find a valid set of primes. |
| 218 | + A threshold is used to ensure we don't exceed the smallest sum. |
| 219 | + Returns the valid set if found, else None. |
| 220 | + """ |
| 221 | + if len(chain) == target_size: |
| 222 | + return chain |
| 223 | + for p in primes: |
| 224 | + if p <= chain[-1]: |
| 225 | + continue |
| 226 | + if all(is_concat(p, c) for c in chain): |
| 227 | + result = search((*chain, p)) |
| 228 | + if result: |
| 229 | + return result |
| 230 | + return None |
| 231 | + |
| 232 | + for _, p in enumerate(primes): |
| 233 | + result = search((p,)) |
| 234 | + if result and len(result) == target_size: |
| 235 | + return sum(result) |
| 236 | + |
| 237 | + return None # No valid set found |
| 238 | + |
| 239 | + |
| 240 | +if __name__ == "__main__": |
| 241 | + print(f"{solution() = }") |
0 commit comments