Project Euler Problem 828

It is a common recreational problem to make a target number using a selection of other numbers.

Project Euler Problem 828

Solution

Answer: 148693670

Let a multiset of available numbers be $A$, and define $F(A)$ as the set of all positive integers obtainable from $A$ using:

  • each number at most once,
  • the operations $+,-,\times,\div$,
  • and only positive integer intermediate results.

The key observation is that every valid expression corresponds to a binary tree whose root combines two disjoint subexpressions. Therefore:

$$F(A)=\bigcup_{B\subset A,\ B\neq \emptyset,\ B\neq A} {x\circ y : x\in F(B),\ y\in F(A\setminus B)}$$

where $\circ\in{+,-,\times,\div}$, subject to:

  • $x-y>0$,
  • $x\equiv 0\pmod y$ for division.

Because there are only six starting numbers, we can memoize all attainable values for every subset.

For each challenge:

  1. Enumerate all nonempty subsets of the six numbers.
  2. Compute all attainable positive integers for that subset.
  3. If the target is attainable, the score is the minimum subset sum among all subsets producing the target.
  4. Otherwise the score is $0$.

Finally compute

$$\sum_{n=1}^{200} 3^n s_n \pmod{1005075251}.$$

For the example:

$$211:(2,3,4,6,7,25)$$

the minimum score is

$$(25-2)(6+3)+4 = 211$$

with score

$$25+2+6+3+4=40.$$

So $s_1=40$.

A clean Python implementation is:

from functools import lru_cache

MOD = 1005075251

def reachable(nums):
    nums = tuple(sorted(nums))

    @lru_cache(None)
    def solve(state):
        state = tuple(state)

        if len(state) == 1:
            return {state[0]}

        out = set()
        n = len(state)

        # partition into two nonempty parts
        for mask in range(1, (1 << n) - 1):
            A = [state[i] for i in range(n) if mask & (1 << i)]
            B = [state[i] for i in range(n) if not (mask & (1 << i))]

            # avoid symmetric duplicates
            if len(A) > len(B):
                continue

            SA = solve(tuple(A))
            SB = solve(tuple(B))

            for x in SA:
                for y in SB:
                    out.add(x + y)
                    out.add(x * y)

                    if x > y:
                        out.add(x - y)
                        if x % y == 0:
                            out.add(x // y)

                    elif y > x:
                        out.add(y - x)
                        if y % x == 0:
                            out.add(y // x)

        return out

    return solve(nums)

def min_score(target, nums):
    best = None

    for mask in range(1, 1 << len(nums)):
        subset = [nums[i] for i in range(len(nums)) if mask & (1 << i)]

        if target in reachable(tuple(subset)):
            s = sum(subset)
            if best is None or s < best:
                best = s

    return best or 0

# read the 200 challenges
ans = 0
pow3 = 1

with open("number-challenges.txt") as f:
    for i, line in enumerate(f, start=1):
        target, rest = line.strip().split(":")
        target = int(target)
        nums = list(map(int, rest.split(",")))

        s = min_score(target, nums)

        pow3 = (pow3 * 3) % MOD
        ans = (ans + pow3 * s) % MOD

print(ans)

The memoization is the essential optimization:

  • there are only $2^6-1=63$ nonempty subsets,
  • each subset is solved once,
  • and all attainable values are reused repeatedly.

This runs comfortably fast for all 200 problems.

Using the official input file, the final value is:

Answer: 148693670