#!/usr/bin/env python # coding: utf-8 #
Peter Norvig
Nov 2019
revised May 2021
# # # Riddler Lottery # # The 538 Riddler [poses](https://fivethirtyeight.com/features/can-you-decode-the-riddler-lottery/) this problem: # # > Five friends are playing the [Riddler Lottery](https://fivethirtyeight.com/features/can-you-decode-the-riddler-lottery/), in which each player selects exactly five integers from 1 to 70. After they all picked their numbers, # 1. The first player states that no number was selected by two or more players. # 2. The second player observes that all the selected numbers are composite (i.e., not prime). # 3. The third player points out that each selected number has at least two distinct prime factors. # 4. The fourth player notes that each player's 5 numbers multiply to the same product. # 5. The fifth player is left speechless. # > # > That leads to two questions: # > - What is the unique product, $P$, that each player's 5 numbers multiply together to? # > - In how many different ways could the selections be made so that the statements above are true? # # # Analysis # # As an example, consider a version of the problem where each player selects 2 numbers, not 5. Here is one solution: # # # |Player |Selection| Product |Prime Factors| # |-|-|-|-| # |1 | 6, 60 | 360 | {2, 3}, {2, 3, 5} # |2 | 10, 36 | 360 | {2, 5}, {2, 3} # |3 | 12, 30 | 360 | {2, 3}, {2, 3, 5} # |4 | 15, 24 | 360 | {3, 5}, {2, 3} # |5 | 18, 20 | 360 | {2, 3}, {2, 5} # # # # The key concepts: # # - **Numbers**: The integers from 1 to 70. # - **Selection**: A sorted tuple of numbers, e.g. `(6, 60)` for 2 numbers, or `(12, 15, 20, 28, 30)` for 5. # - **Candidate**: A set of 5 selections e.g. `{(6, 60), (10, 36), (12, 30), (15, 24), (18, 21)}`. # - **Solution**: A candidate that satisfies statements 1–4. # - **Distinct prime factors**: `factors(20) == {2, 5}` and `factors(9) == {3}`, so 20 is valid but not 9. # - **Product**: the result of multiplying numbers together, e.g. `prod((6, 60)) == 360`. # # An implementation of the key concepts: # # In[1]: from typing import * numbers = range(1, 71) Selection = Tuple[int, ...] # 5 ints in the full puzzle Candidate = Set[Selection] Solution = Set[Selection] def factors(n) -> Set[int]: """The set of distinct prime factors of n.""" if n == 1: return set() else: p = next(i for i in range(2, n + 1) if n % i == 0) return {p, *factors(n // p)} def prod(numbers: Iterable[int]) -> int: """The product of a collection of numbers.""" result = 1 for n in numbers: result *= n return result # # Brute force solution? # # Could we generate every possible candidate, and test each one? There are (70 choose 5)5 or [about](https://www.google.com/search?q=%2870+choose+5%29^5) $10^{35}$ candidate solutions, so: **NO**. # # We'll have to be more clever. I have three ideas; I will consider only: # # - Numbers with at least 2 prime factors. # - Factors with at least 5 valid numbers. # - Sets of 25 numbers whose product is a fifth power. # # Numbers with at least 2 prime factors # # Every valid numbers must have at least two distinct prime factors (by statement 3): # In[2]: numbers = [n for n in numbers if len(factors(n)) >= 2] len(numbers) # Good! We got it down from 70 to 41 valid numbers. # # # Factors with at least 5 valid numbers # # All five players make a selection with the same product (by statement 4). Therefore, if one player selects a number that has the factor $p$, then that player's product will be divisible by $p$, and therefore every player must select some number that has the factor $p$, otherwise their product would be different. # # For example, the valid numbers with 11 as a factor are {22, 33, 44, 55, 66}, so if one player selects one of those numbers, then the other players must select the others. # # The valid numbers with 13 as a factor are {26, 39, 52, 65}, so no player can select any of those numbers, because there aren't enough of them for all five players. # # So let's count how many valid numbers each prime factor appears in: # In[3]: Counter(p for n in numbers for p in factors(n)) # Only the prime factors `{2, 3, 5, 7, 11}` have a count of at least 5. Let's update the set of valid numbers to contain only numbers made from valid factors: # In[4]: valid_factors = {2, 3, 5, 7, 11} numbers = [n for n in numbers if factors(n).issubset(valid_factors)] len(numbers) # Great! Now we're down to 28 numbers! # # # Sets of 25 numbers whose product is a fifth power # # The five players will together select 25 of these 28 valid numbers, and since there are only (28 choose 25) = 3,276 combinations of 25 numbers, we can quickly check to see which ones might lead to solutions, according to this reasoning: # - Every player's selection of 5 numbers has the same product, which we are calling $P$. # - Therefore the product of all 25 numbers in a solution must be $P^5$ (although we don't yet know what $P$ is). # - A set of 25 numbers whose product is not a perfect fifth power **cannot** lead to any solutions. # - A set of 25 numbers whose product is a perfect fifth power **might** lead to solution(s). # # We can check which combinations of 25 numbers multiply to a fifth power: # In[5]: from itertools import combinations def is_fifth_power(i: int) -> bool: return i == round(i ** (1/5)) ** 5 result = [c for c in combinations(numbers, 25) if is_fifth_power(prod(c))] len(result) # There's only one combination of 25 numbers that works! # # That's good news; we can use the `result` to update `numbers` and compute $P$: # In[6]: numbers = result[0] P = round(prod(numbers) ** (1/5)) # # Answer to the first question # In[7]: P # **19,958,400** is "the unique product $P$ that each player's 5 numbers multiply to." # # At this point we know the 25 numbers that will form any solution: # In[8]: print(*numbers) # However, we haven't found any solutions yet and we don't know how many solutions there are. # # Finding solutions # # `solutions(numbers, P, r)` will search through combinations of `numbers` to generate all solutions such that every selection in the solution consists of `r` numbers with product $P$. # # `solutions` yields the empty solution if there are no numbers remaining to choose from. Otherwise it considers each way to combine a `first` selection with a set of `rest` selections, where the `first` is any `r` numbers and the `rest` is any set of selections formed from the numbers that # were not used in `first`, and are lexicographically greater than `first` (so that we don't generate multiple permutations of the same solution). # In[9]: def solutions(numbers, P, r=5) -> Iterator[Solution]: """Yield solutions that are selections of `r` `numbers` with product `P`.""" if not numbers: yield set() else: yield from ({first, *rest} for first in combinations(numbers, r) if prod(first) == P for rest in solutions([n for n in numbers if n > first[0] and n not in first], P, r)) # We can very quickly find a solution: # In[10]: get_ipython().run_line_magic('time', 'next(solutions(numbers, P))') # But it takes longer (about 6 seconds) to find all the solutions: # In[11]: get_ipython().run_line_magic('time', 'all_solutions = list(solutions(numbers, P))') # # Answer to the second question # In[12]: len(all_solutions) # **12,781** is "how many different ways could the selections be made so that the statements are true." # # (There is some ambiguity about what "different" means: 12,781 is the answer if you think of a set of players each choosing a set of numbers. If the ordering of the players matters, multiply by $5! = 120$, and if the ordering of the numbers in each selection matters, multiply by $5!^5$. # # To do # # - You could explore solutions with different numbers of players (default 5), selected numbers per player (default 5), range of valid numbers (default 1 to 70), and minimum number of distinct factors for selected numbers (default 2). # # # - You could answer the trivia question "why didn't I just import [`numpy.prod`](https://numpy.org/doc/stable/reference/generated/numpy.prod.html)?" # # # - If 6 seconds is too long to wait, you could speed up `solutions` by changing from the generate-and-test approach of looking at all `combinations(numbers, r)` and then checking to see if the product is `P`, to an incremental approach that only considers partial results that could lead to a product `P`. # # # - You could implement a completely different approach to solving the problem (actually the one I thought of first): # 1. Get the valid numbers down to 28. # 2. Consider all (28 choose 5) = 98,280 possible selections of 5 numbers. # 3. Group selections by their products, e.g., a dict: `{19958400: [(6, 15, 56, 60, 66), ...]}`. # 4. For each product in the dict that has at least 5 selections in its list: # - Find all combinations of 5 selections that consist of distinct numbers. # # # - You could add more unit tests to the following meager test suite: # In[13]: def is_solution(candidate) -> bool: """Is this a valid solution?""" numbers = [n for selection in candidate for n in selection] products = {prod(selection) for selection in candidate} return (len(numbers) == len(set(numbers)) # Statement 1 and all(len(factors(n)) >= 2 for n in numbers) # Statement 3 and len(products) == 1) # Statement 4 assert factors(9) == {3} assert factors(10) == {2, 5} assert factors(42) == {2, 3, 7} assert factors(41) == {41} assert factors(1) == set() assert factors(168000) == {2, 3, 5, 7} assert prod([2, 3, 7]) == 42 assert prod([41]) == 41 assert prod([]) == 1 assert prod([2, 3, 2, 5, 2, 5, 2, 5, 2, 7, 2]) == 168000 assert is_fifth_power(100000) assert is_fifth_power(1234567890 ** 5) assert not is_fifth_power(99999) assert not is_fifth_power(1234567890 ** 5 + 1) assert P == 19958400 assert numbers == (6, 10, 12, 14, 15, 18, 20, 21, 22, 24, 28, 30, 33, 36, 40, 42, 44, 45, 48, 50, 54, 55, 56, 60, 66) assert len(all_solutions) == 12781 assert all(is_solution(s) for s in all_solutions) assert is_solution({(6, 60), (10, 36), (12, 30), (15, 24), (18, 20)}) assert not is_solution({(6, 60), (10, 60), (12, 30), (15, 24), (18, 20)}) #1 assert not is_solution({(9, 40), (10, 36), (12, 30), (15, 24), (18, 20)}) #3 assert not is_solution({(7, 60), (10, 36), (12, 30), (15, 24), (18, 20)}) #4