Problem 500: Problem 500!!!

View on Project Euler

Project Euler Problem 500 Solution

EulerSolve provides an optimized solution for Project Euler Problem 500, Problem 500!!!, with C++, Python, Java, and a step-by-step mathematical explanation.

Problem Summary We want the smallest positive integer \(n\) whose number of divisors is exactly \(2^K\), where \(K=500{,}500\), and we only need the final value modulo \(M=500{,}500{,}507\). The minimum itself is astronomically large, so the solution never tries to build \(n\) explicitly. Instead, it minimizes the prime-power decisions that define \(n\). Mathematical Approach Write the prime factorization of \(n\) as $$n=\prod_{i\ge 1} p_i^{a_i},\qquad p_1\lt p_2\lt p_3\lt \dots.$$ The divisor formula gives $$d(n)=\prod_{i\ge 1}(a_i+1).$$ So the entire problem is controlled by how the factors \(a_i+1\) multiply to \(2^K\). Step 1: Turn the divisor condition into powers of two Because \(2^K\) has no odd prime factor, every factor \(a_i+1\) in the product must itself be a power of two. Hence there are integers \(b_i\ge 0\) such that $$a_i+1=2^{b_i},\qquad a_i=2^{b_i}-1,$$ and the divisor requirement becomes $$\sum_{i\ge 1} b_i=K.$$ So we may think of the problem as distributing \(K\) identical units among the primes. If prime \(p_i\) receives \(b_i\) units, its final exponent is \(2^{b_i}-1\). Step 2: One extra unit creates one multiplicative cost Suppose a prime currently has exponent \(2^t-1\)....

Detailed mathematical approach

Problem Summary

We want the smallest positive integer \(n\) whose number of divisors is exactly \(2^K\), where \(K=500{,}500\), and we only need the final value modulo \(M=500{,}500{,}507\). The minimum itself is astronomically large, so the solution never tries to build \(n\) explicitly. Instead, it minimizes the prime-power decisions that define \(n\).

Mathematical Approach

Write the prime factorization of \(n\) as

$$n=\prod_{i\ge 1} p_i^{a_i},\qquad p_1\lt p_2\lt p_3\lt \dots.$$

The divisor formula gives

$$d(n)=\prod_{i\ge 1}(a_i+1).$$

So the entire problem is controlled by how the factors \(a_i+1\) multiply to \(2^K\).

Step 1: Turn the divisor condition into powers of two

Because \(2^K\) has no odd prime factor, every factor \(a_i+1\) in the product must itself be a power of two. Hence there are integers \(b_i\ge 0\) such that

$$a_i+1=2^{b_i},\qquad a_i=2^{b_i}-1,$$

and the divisor requirement becomes

$$\sum_{i\ge 1} b_i=K.$$

So we may think of the problem as distributing \(K\) identical units among the primes. If prime \(p_i\) receives \(b_i\) units, its final exponent is \(2^{b_i}-1\).

Step 2: One extra unit creates one multiplicative cost

Suppose a prime currently has exponent \(2^t-1\). Giving that same prime one more unit changes the exponent to

$$2^{t+1}-1=(2^t-1)+2^t,$$

so \(n\) is multiplied by

$$p^{2^t}.$$

Thus each prime contributes an increasing sequence of possible elementary multipliers:

$$p,\ p^2,\ p^4,\ p^8,\ \dots.$$

If we take the first \(r\) terms from that sequence, their product is

$$p^{1+2+4+\dots+2^{r-1}}=p^{2^r-1},$$

which matches the exponent pattern required by the divisor formula.

Step 3: The optimum is the product of the \(K\) smallest admissible multipliers

Combine all prime sequences into one infinite multiset

$$\mathcal{U}=\left\{p^{2^t}: p \text{ prime},\ t\ge 0\right\}.$$

Any valid solution corresponds to choosing exactly \(K\) elements from \(\mathcal{U}\), with one important restriction: within the sequence for each fixed prime, we must choose a prefix. The final integer \(n\) is exactly the product of the chosen elements.

Now note that every sequence \(p,p^2,p^4,\dots\) is strictly increasing. Therefore, if some term \(p^{2^t}\) is among the first \(K\) smallest elements of \(\mathcal{U}\), then all earlier terms in that same sequence are even smaller and must also be among the first \(K\) smallest. So the first \(K\) smallest elements automatically satisfy the prefix rule.

That means the minimum possible \(n\) is simply the product of the \(K\) smallest elements of \(\mathcal{U}\). Replacing any chosen multiplier by a larger available one can only increase the final product.

Step 4: Why only the first \(K\) primes matter

Every distinct prime used in \(n\) consumes at least one unit, so an optimal solution can involve at most \(K\) different primes. If a larger prime were used while a smaller prime remained unused, replacing the larger prime by the smaller one would keep the divisor count unchanged and strictly decrease \(n\).

Equivalently, if \(p_{K+1}\) is the \((K+1)\)-st prime, then its first possible multiplier is \(p_{K+1}\), but there are already \(K\) smaller initial multipliers

$$p_1,p_2,\dots,p_K.$$

So no term from primes beyond \(p_K\) can belong to the first \(K\) chosen multipliers.

Step 5: Enumerate the multipliers with a min-heap

Start with the first \(K\) primes in a min-heap, each represented by its current offer. Repeatedly remove the smallest current offer \(q\), multiply the running answer by \(q\), and insert the next offer from the same prime, namely \(q^2\). After exactly \(K\) extractions, we have multiplied together the \(K\) smallest admissible multipliers.

If the current offer is \(q=p^{2^t}\), then the next offer from that prime is indeed

$$p^{2^{t+1}}=q^2.$$

The implementations compare offers by \(\log q\) rather than by the enormous integer \(q\) itself, because

$$q_1\lt q_2 \iff \log q_1\lt \log q_2.$$

At the same time, they keep the same offer reduced modulo \(M\), so the final result can be accumulated mod \(M\) without ever materializing the full minimum.

Worked Example: \(K=4\)

For \(K=4\), we need exactly \(2^4=16\) divisors. The initial heap offers are \(2,3,5,7\).

Greedy selection proceeds as follows:

$$\begin{aligned} \text{pick }2 &\Rightarrow \text{product }=2,\ \text{next offer }=4,\\ \text{pick }3 &\Rightarrow \text{product }=6,\ \text{next offer }=9,\\ \text{pick }4 &\Rightarrow \text{product }=24,\ \text{next offer }=16,\\ \text{pick }5 &\Rightarrow \text{product }=120,\ \text{next offer }=25. \end{aligned}$$

So the chosen multipliers are \(2,3,4,5\), and hence

$$n=2\cdot 3\cdot 4\cdot 5=2^3\cdot 3\cdot 5=120.$$

Its divisor count is

$$d(120)=(3+1)(1+1)(1+1)=16=2^4,$$

which matches the small checkpoint used by the implementations.

How the Code Works

The C++, Python, and Java implementations all follow the same structure. First they generate the first \(K\) primes using a sieve together with a standard upper-bound estimate for the \(K\)-th prime. If the first estimate is too small, the bound is enlarged and the sieve is repeated.

They then build a min-heap whose entries store two views of the same current offer \(q\): a logarithmic key \(\log q\) for ordering, and the reduced residue \(q\bmod M\) for modular arithmetic. Initially the heap contains one offer for each of the first \(K\) primes.

Each of the \(K\) iterations removes the smallest offer, multiplies the running answer by its residue modulo \(M\), squares that residue modulo \(M\), doubles the logarithmic key, and pushes the updated offer back into the heap. This exactly enumerates the \(K\) smallest admissible multipliers in order. The C++ implementation also includes tiny checkpoint tests on small divisor powers to validate the greedy construction.

Complexity Analysis

Let \(P_K\) be the \(K\)-th prime. Generating the first \(K\) primes with an ordinary sieve up to \(P_K\) costs \(O(P_K\log\log P_K)\) time and \(O(P_K)\) memory. The heap contains \(K\) entries, and the \(K\) pop-push iterations cost \(O(K\log K)\) time and \(O(K)\) extra memory.

Since \(P_K=\Theta(K\log K)\), the total running time is roughly \(O(K\log K\log\log K)\), and the total memory usage is \(O(P_K+K)\). For the fixed value \(K=500{,}500\), this is comfortably practical.

Footnotes and References

  1. Problem page: https://projecteuler.net/problem=500
  2. Divisor function: Wikipedia — Divisor function
  3. Prime number theorem: Wikipedia — Prime number theorem
  4. Priority queue: Wikipedia — Priority queue
  5. Greedy algorithm: Wikipedia — Greedy algorithm

Problem 500 source code

C++

#include <cmath>
#include <cstdint>
#include <iostream>
#include <queue>
#include <string>
#include <vector>

namespace {

using u64 = std::uint64_t;
using u128 = __uint128_t;

struct Options {
    int power = 500'500;
    u64 mod = 500'500'507ULL;
    bool run_checkpoints = true;
};

struct HeapNode {
    long double log_value;
    u64 mod_value;
};

struct HeapCmp {
    bool operator()(const HeapNode& lhs, const HeapNode& rhs) const {
        return lhs.log_value > rhs.log_value;
    }
};

bool parse_int_after_prefix(const std::string& arg, const std::string& prefix, int& value) {
    if (arg.rfind(prefix, 0U) != 0U) {
        return false;
    }
    const std::string tail = arg.substr(prefix.size());
    if (tail.empty()) {
        return false;
    }
    int parsed = 0;
    for (char ch : tail) {
        if (ch < '0' || ch > '9') {
            return false;
        }
        parsed = parsed * 10 + static_cast<int>(ch - '0');
    }
    value = parsed;
    return true;
}

bool parse_u64_after_prefix(const std::string& arg, const std::string& prefix, u64& value) {
    if (arg.rfind(prefix, 0U) != 0U) {
        return false;
    }
    const std::string tail = arg.substr(prefix.size());
    if (tail.empty()) {
        return false;
    }
    u64 parsed = 0ULL;
    for (char ch : tail) {
        if (ch < '0' || ch > '9') {
            return false;
        }
        parsed = parsed * 10ULL + static_cast<u64>(ch - '0');
    }
    value = parsed;
    return true;
}

bool parse_arguments(int argc, char** argv, Options& options) {
    for (int i = 1; i < argc; ++i) {
        std::string arg(argv[i]);
        if (arg == "--skip-checkpoints") {
            options.run_checkpoints = false;
            continue;
        }
        if (parse_int_after_prefix(arg, "--power=", options.power) ||
            parse_u64_after_prefix(arg, "--mod=", options.mod)) {
            continue;
        }
        std::cerr << "Unknown argument: " << arg << '\n';
        return false;
    }
    return options.power >= 1 && options.mod >= 2ULL;
}

int nth_prime_upper_bound(const int n) {
    if (n < 6) {
        return 20;
    }
    const long double x = static_cast<long double>(n);
    const long double estimate = x * (std::log(x) + std::log(std::log(x))) + 10.0L;
    return static_cast<int>(estimate) + 100;
}

std::vector<int> first_n_primes(const int n) {
    int limit = nth_prime_upper_bound(n);
    while (true) {
        std::vector<bool> is_composite(static_cast<std::size_t>(limit + 1), false);
        std::vector<int> primes;
        primes.reserve(static_cast<std::size_t>(n + 10));
        for (int i = 2; i <= limit; ++i) {
            if (!is_composite[static_cast<std::size_t>(i)]) {
                primes.push_back(i);
                if (i <= limit / i) {
                    for (int j = i * i; j <= limit; j += i) {
                        is_composite[static_cast<std::size_t>(j)] = true;
                    }
                }
            }
        }
        if (static_cast<int>(primes.size()) >= n) {
            primes.resize(static_cast<std::size_t>(n));
            return primes;
        }
        limit *= 2;
    }
}

u64 solve(const int power, const u64 mod) {
    const std::vector<int> primes = first_n_primes(power);
    std::priority_queue<HeapNode, std::vector<HeapNode>, HeapCmp> heap;
    heap = {};
    for (int p : primes) {
        heap.push(HeapNode{std::log(static_cast<long double>(p)), static_cast<u64>(p) % mod});
    }

    u64 ans = 1ULL;
    for (int step = 0; step < power; ++step) {
        HeapNode cur = heap.top();
        heap.pop();
        ans = static_cast<u64>((static_cast<u128>(ans) * cur.mod_value) % mod);
        const u64 squared_mod = static_cast<u64>((static_cast<u128>(cur.mod_value) * cur.mod_value) % mod);
        heap.push(HeapNode{cur.log_value * 2.0L, squared_mod});
    }
    return ans;
}

u64 brute_smallest_with_two_power_divisors(const int power) {
    const u64 target = 1ULL << power;
    for (u64 n = 1ULL;; ++n) {
        u64 x = n;
        u64 dcount = 1ULL;
        for (u64 p = 2ULL; p * p <= x; ++p) {
            if (x % p != 0ULL) {
                continue;
            }
            int e = 0;
            while (x % p == 0ULL) {
                x /= p;
                ++e;
            }
            dcount *= static_cast<u64>(e + 1);
        }
        if (x > 1ULL) {
            dcount *= 2ULL;
        }
        if (dcount == target) {
            return n;
        }
    }
}

bool run_checkpoints() {
    if (solve(4, 1'000'000'007ULL) != 120ULL) {
        std::cerr << "Checkpoint failed: smallest with 16 divisors is 120" << '\n';
        return false;
    }
    if (solve(8, 1'000'000'007ULL) != brute_smallest_with_two_power_divisors(8)) {
        std::cerr << "Checkpoint failed: brute-force cross-check for power=8" << '\n';
        return false;
    }
    return true;
}

}  // namespace

int main(int argc, char** argv) {
    Options options;
    if (!parse_arguments(argc, argv, options)) {
        return 1;
    }
    if (options.run_checkpoints && !run_checkpoints()) {
        return 2;
    }
    std::cout << solve(options.power, options.mod) << '\n';
    return 0;
}

Python

import math
import heapq

def solve():
    POWER = 500500
    MOD = 500500507

    # Generate first POWER primes
    def first_n_primes(n):
        limit = max(20, int(n * (math.log(n) + math.log(math.log(n)))) + 100)
        while True:
            sieve = bytearray([1]) * (limit + 1)
            sieve[0] = sieve[1] = 0
            for p in range(2, int(limit**0.5) + 1):
                if sieve[p]:
                    for q in range(p*p, limit+1, p):
                        sieve[q] = 0
            primes = [i for i in range(2, limit+1) if sieve[i]]
            if len(primes) >= n:
                return primes[:n]
            limit *= 2

    primes = first_n_primes(POWER)
    # Min-heap: (log_value, mod_value)
    heap = [(math.log(p), p % MOD) for p in primes]
    heapq.heapify(heap)

    ans = 1
    for _ in range(POWER):
        log_val, mod_val = heapq.heappop(heap)
        ans = ans * mod_val % MOD
        heapq.heappush(heap, (log_val * 2.0, mod_val * mod_val % MOD))

    return str(ans)

if __name__ == '__main__':
    print(solve())

Java

import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;

public class Euler500 {

    static class HeapNode implements Comparable<HeapNode> {
        double logValue;
        long modValue;

        HeapNode(double logValue, long modValue) {
            this.logValue = logValue;
            this.modValue = modValue;
        }

        @Override
        public int compareTo(HeapNode other) {
            return Double.compare(this.logValue, other.logValue);
        }
    }

    private static int nthPrimeUpperBound(int n) {
        if (n < 6)
            return 20;
        double x = n;
        double estimate = x * (Math.log(x) + Math.log(Math.log(x))) + 10.0;
        return (int) estimate + 100;
    }

    private static List<Integer> firstNPrimes(int n) {
        int limit = nthPrimeUpperBound(n);
        while (true) {
            boolean[] isComposite = new boolean[limit + 1];
            List<Integer> primes = new ArrayList<>();
            for (int i = 2; i <= limit; ++i) {
                if (!isComposite[i]) {
                    primes.add(i);
                    if ((long) i * i <= limit) {
                        for (int j = i * i; j <= limit; j += i) {
                            isComposite[j] = true;
                        }
                    }
                }
            }
            if (primes.size() >= n) {
                return primes.subList(0, n);
            }
            limit *= 2;
        }
    }

    public static void main(String[] args) {
        int power = 500500;
        long mod = 500500507L;

        List<Integer> primes = firstNPrimes(power);
        PriorityQueue<HeapNode> heap = new PriorityQueue<>();

        for (int p : primes) {
            heap.add(new HeapNode(Math.log(p), p % mod));
        }

        long ans = 1L;
        for (int step = 0; step < power; ++step) {
            HeapNode cur = heap.poll();
            ans = (ans * cur.modValue) % mod;
            long squaredMod = (cur.modValue * cur.modValue) % mod;
            heap.add(new HeapNode(cur.logValue * 2.0, squaredMod));
        }

        System.out.println(ans);
    }
}