Problem 765: Trillionaire

View on Project Euler

Project Euler Problem 765 Solution

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

Problem Summary A player starts with wealth \(1\) and may bet through \(N=1000\) independent rounds. Each round is won with probability \(p=0.6\), and the goal is to finish with wealth at least \(T=10^{12}\). Because the bets are priced fairly at each step, the right way to analyze the problem is not to enumerate betting rules directly, but to study which terminal states are worth buying under a fixed pricing budget. The implementations therefore reduce the gambling problem to an optimization problem between two probability measures: the true measure \(P\), where wins occur with probability \(0.6\), and a fair reference measure \(Q\), where each branch has probability \(1/2\). The answer is the largest \(P\)-probability of a success set whose \(Q\)-cost does not exceed \(1/T\). Mathematical Approach Let \(\omega\) denote a complete win-loss path of length \(N\), and let \(X(\omega)\) be the terminal wealth delivered on that path. The complete binomial tree lets us price terminal payoffs path by path, so the whole problem becomes a measure-theoretic budget allocation problem. Step 1: Convert the betting problem into terminal-state payoffs Under fair even-money pricing, each one-step branch has price \(1/2\)....

Detailed mathematical approach

Problem Summary

A player starts with wealth \(1\) and may bet through \(N=1000\) independent rounds. Each round is won with probability \(p=0.6\), and the goal is to finish with wealth at least \(T=10^{12}\). Because the bets are priced fairly at each step, the right way to analyze the problem is not to enumerate betting rules directly, but to study which terminal states are worth buying under a fixed pricing budget.

The implementations therefore reduce the gambling problem to an optimization problem between two probability measures: the true measure \(P\), where wins occur with probability \(0.6\), and a fair reference measure \(Q\), where each branch has probability \(1/2\). The answer is the largest \(P\)-probability of a success set whose \(Q\)-cost does not exceed \(1/T\).

Mathematical Approach

Let \(\omega\) denote a complete win-loss path of length \(N\), and let \(X(\omega)\) be the terminal wealth delivered on that path. The complete binomial tree lets us price terminal payoffs path by path, so the whole problem becomes a measure-theoretic budget allocation problem.

Step 1: Convert the betting problem into terminal-state payoffs

Under fair even-money pricing, each one-step branch has price \(1/2\). Therefore every full path \(\omega\) has fair price

$$Q(\{\omega\}) = 2^{-N}.$$

By linearity, any nonnegative terminal payoff \(X\) has initial cost

$$\text{cost}(X) = E_Q[X].$$

Since the player starts with capital \(1\), every feasible strategy must satisfy

$$E_Q[X] \le 1.$$

This is the key structural simplification: the space of adaptive betting strategies can be replaced by the space of terminal payoffs with a single linear budget constraint.

Step 2: Turn the target into a \(Q\)-budget constraint

Let

$$A = \{\omega : X(\omega) \ge T\}$$

be the success event. On that event we have \(X(\omega) \ge T\), so

$$1 \ge E_Q[X] \ge E_Q[T \cdot 1_A] = T \, Q(A).$$

Hence every feasible strategy obeys

$$Q(A) \le \frac{1}{T}.$$

Conversely, if an event \(A\) satisfies \(Q(A) \le 1/T\), then the digital payoff

$$X(\omega) = T \cdot 1_A(\omega)$$

has cost \(TQ(A) \le 1\), so it is feasible and succeeds exactly on \(A\). Therefore the original problem is exactly equivalent to

$$\max_A P(A) \quad \text{subject to} \quad Q(A) \le \frac{1}{T}.$$

This is a Neyman-Pearson type optimization problem.

Step 3: Collapse the state space by the number of wins

Let \(W\) be the number of wins along the path. Under the fair measure \(Q\),

$$q_w = Q(W=w) = \binom{N}{w} 2^{-N}.$$

Under the true measure \(P\),

$$p_w = P(W=w) = \binom{N}{w} p^w (1-p)^{N-w}.$$

All paths with the same \(w\) have the same price under \(Q\) and the same probability under \(P\). So instead of choosing individual paths, we only need to choose how much of each win-count layer \(w\) to include.

Step 4: Order the layers by likelihood ratio

The relevant ranking statistic is the likelihood ratio

$$\frac{p_w}{q_w} = (2p)^w \bigl(2(1-p)\bigr)^{N-w}.$$

For this problem \(p=0.6\), so \(p/(1-p)=1.5>1\). As \(w\) increases by \(1\), the ratio is multiplied by

$$\frac{p}{1-p} > 1.$$

Therefore \(\frac{p_w}{q_w}\) is strictly increasing in \(w\). The optimal success set must thus take the largest \(w\)-layers first: all states with \(W=N\), then \(W=N-1\), and so on, because those layers give the most true probability per unit of fair-cost budget.

Step 5: Use a boundary fraction to fill the budget exactly

Let

$$B = \frac{1}{T}.$$

We accumulate \(Q\)-mass from \(w=N\) downward until the next full layer would exceed \(B\). Suppose \(w_*\) is the boundary layer. Then every layer with \(w > w_*\) is taken completely, and only a fraction

$$\eta = \frac{B - \sum_{w=w_*+1}^{N} q_w}{q_{w_*}}$$

of layer \(w_*\) is needed, with \(0 \le \eta \le 1\). The optimal success probability is therefore

$$\sum_{w=w_*+1}^{N} p_w + \eta p_{w_*}.$$

This is exactly the form implemented by the program.

Worked Example: \(N=2\), \(p=0.6\), \(T=2\)

Here the budget is \(B=1/2\). Under the fair measure \(Q\),

$$q_2 = \frac{1}{4}, \qquad q_1 = \frac{1}{2}, \qquad q_0 = \frac{1}{4}.$$

Under the true measure \(P\),

$$p_2 = 0.6^2 = 0.36, \qquad p_1 = 2(0.6)(0.4) = 0.48, \qquad p_0 = 0.4^2 = 0.16.$$

We take the \(w=2\) layer completely, using \(Q\)-mass \(1/4\). The remaining budget is \(1/4\), so we take

$$\eta = \frac{1/2 - 1/4}{1/2} = \frac{1}{2}$$

of the \(w=1\) layer. Hence the optimal success probability is

$$0.36 + \frac{1}{2}\cdot 0.48 = 0.60.$$

This matches the small validation case embedded in the implementations.

How the Code Works

The implementations first handle the trivial case \(T \le 1\), where success is guaranteed. Otherwise they set the fair-cost budget to \(1/T\) and build the entire fair layer distribution \((q_0,\dots,q_N)\) using the stable binomial recurrence

$$q_0 = 2^{-N}, \qquad q_{w+1} = q_w \frac{N-w}{w+1}.$$

Next they recover the true distribution \((p_0,\dots,p_N)\) from the same layers via

$$p_w = q_w \bigl(2(1-p)\bigr)^N \left(\frac{p}{1-p}\right)^w,$$

which avoids direct factorials and keeps the arithmetic numerically stable. Finally they scan from \(w=N\) down to \(0\), add whole layers while the \(Q\)-budget allows it, identify the first layer that would overflow the budget, and then add the needed boundary fraction from that layer. The C++, Python, and Java implementations all follow this same logic, differing only in their numeric types.

Complexity Analysis

Building the two arrays of layer masses takes \(O(N)\) time, and the final downward scan also takes \(O(N)\) time. Because the implementations store the \(Q\)-distribution and the \(P\)-distribution explicitly for all \(N+1\) win counts, the memory usage is \(O(N)\). For the actual instance \(N=1000\), this is easily manageable.

Footnotes and References

  1. Problem page: https://projecteuler.net/problem=765
  2. Neyman-Pearson lemma: Wikipedia — Neyman-Pearson lemma
  3. Binomial distribution: Wikipedia — Binomial distribution
  4. Likelihood-ratio test: Wikipedia — Likelihood-ratio test
  5. Risk-neutral measure: Wikipedia — Risk-neutral measure

Problem 765 source code

C++

#include <algorithm>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <vector>

namespace {

long double optimal_success_probability(int rounds, long double p_win, long double target) {
    if (rounds < 0) {
        throw std::invalid_argument("rounds must be non-negative");
    }
    if (!(p_win > 0.0L && p_win < 1.0L)) {
        throw std::invalid_argument("p_win must be in (0, 1)");
    }
    if (target <= 1.0L) {
        return 1.0L;
    }

    const long double budget_q = 1.0L / target;

    // q[w] = Q(W = w), where Q is the fair-coin measure (0.5 / 0.5).
    std::vector<long double> q(rounds + 1, 0.0L);
    q[0] = std::ldexp(1.0L, -rounds);
    for (int w = 0; w < rounds; ++w) {
        q[w + 1] = q[w] * static_cast<long double>(rounds - w) / static_cast<long double>(w + 1);
    }

    // p[w] = P(W = w), where P uses the true win probability p_win.
    // Using p[w] = q[w] * (2(1-p))^rounds * (p/(1-p))^w keeps numbers stable.
    std::vector<long double> p(rounds + 1, 0.0L);
    const long double base = std::powl(2.0L * (1.0L - p_win), rounds);
    const long double ratio = p_win / (1.0L - p_win);
    long double ratio_pow = 1.0L;
    for (int w = 0; w <= rounds; ++w) {
        p[w] = q[w] * base * ratio_pow;
        ratio_pow *= ratio;
    }

    // Neyman-Pearson on leaf likelihood ratios: include all largest w, then possibly
    // a fractional part of one boundary layer to use Q-budget exactly.
    long double used_q = 0.0L;
    long double success_p = 0.0L;
    int boundary_w = -1;

    for (int w = rounds; w >= 0; --w) {
        const long double candidate_q = used_q + q[w];
        if (candidate_q <= budget_q + 1e-30L) {
            used_q = candidate_q;
            success_p += p[w];
        } else {
            boundary_w = w;
            break;
        }
    }

    if (boundary_w == -1) {
        return 1.0L;
    }

    long double frac = (budget_q - used_q) / q[boundary_w];
    frac = std::clamp(frac, 0.0L, 1.0L);
    success_p += frac * p[boundary_w];

    return success_p;
}

void validate() {
    const long double tol = 1e-15L;

    // 1 round, target 2: all-in gives success exactly with one win.
    {
        long double got = optimal_success_probability(1, 0.6L, 2.0L);
        long double want = 0.6L;
        if (std::fabsl(got - want) > tol) {
            throw std::runtime_error("Validation failed: N=1, T=2");
        }
    }

    // 2 rounds, target 4: need two net doublings, probability p^2.
    {
        long double got = optimal_success_probability(2, 0.6L, 4.0L);
        long double want = 0.36L;
        if (std::fabsl(got - want) > tol) {
            throw std::runtime_error("Validation failed: N=2, T=4");
        }
    }

    // If target <= starting capital, success is guaranteed.
    {
        long double got = optimal_success_probability(1000, 0.6L, 1.0L);
        long double want = 1.0L;
        if (std::fabsl(got - want) > tol) {
            throw std::runtime_error("Validation failed: T<=1 guarantee");
        }
    }
}

}  // namespace

int main() {
    validate();

    const int rounds = 1000;
    const long double p_win = 0.6L;
    const long double target = 1.0e12L;

    long double ans = optimal_success_probability(rounds, p_win, target);

    std::cout << std::fixed << std::setprecision(10)
              << static_cast<double>(ans) << '\n';
    return 0;
}

Python

import decimal

def optimal_success_probability(rounds, p_win, target):
    decimal.getcontext().prec = 50
    
    if target <= 1:
        return decimal.Decimal(1)
        
    budget_q = decimal.Decimal(1) / decimal.Decimal(str(target))
    
    q = [decimal.Decimal(0)] * (rounds + 1)
    q[0] = decimal.Decimal(1) / (decimal.Decimal(2) ** rounds)
    for w in range(rounds):
        q[w + 1] = q[w] * decimal.Decimal(rounds - w) / decimal.Decimal(w + 1)
        
    p = [decimal.Decimal(0)] * (rounds + 1)
    dp_win = decimal.Decimal(str(p_win))
    one_minus_p = decimal.Decimal(1) - dp_win
    base = (decimal.Decimal(2) * one_minus_p) ** rounds
    ratio = dp_win / one_minus_p
    ratio_pow = decimal.Decimal(1)
    
    for w in range(rounds + 1):
        p[w] = q[w] * base * ratio_pow
        ratio_pow *= ratio
        
    used_q = decimal.Decimal(0)
    success_p = decimal.Decimal(0)
    boundary_w = -1
    eps = decimal.Decimal('1e-30')
    
    for w in range(rounds, -1, -1):
        candidate_q = used_q + q[w]
        if candidate_q <= budget_q + eps:
            used_q = candidate_q
            success_p += p[w]
        else:
            boundary_w = w
            break
            
    if boundary_w == -1:
        return decimal.Decimal(1)
        
    frac = (budget_q - used_q) / q[boundary_w]
    if frac < decimal.Decimal(0): frac = decimal.Decimal(0)
    if frac > decimal.Decimal(1): frac = decimal.Decimal(1)
    
    success_p += frac * p[boundary_w]
    return success_p

def solve():
    ans = optimal_success_probability(1000, 0.6, 1000000000000.0)
    return f"{ans:.10f}"

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

Java

import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;

public class Euler765 {
    static final MathContext MC = new MathContext(50, RoundingMode.HALF_UP);

    static BigDecimal optimalSuccessProbability(int rounds, double pWin, double target) {
        if (target <= 1.0) {
            return BigDecimal.ONE;
        }

        BigDecimal budgetQ = BigDecimal.ONE.divide(new BigDecimal(Double.toString(target)), MC);

        BigDecimal[] q = new BigDecimal[rounds + 1];
        BigDecimal two = new BigDecimal("2");
        q[0] = BigDecimal.ONE.divide(two.pow(rounds, MC), MC);

        for (int w = 0; w < rounds; ++w) {
            BigDecimal rw = new BigDecimal(rounds - w);
            BigDecimal w1 = new BigDecimal(w + 1);
            q[w + 1] = q[w].multiply(rw, MC).divide(w1, MC);
        }

        BigDecimal pWinDec = new BigDecimal(Double.toString(pWin));
        BigDecimal oneMinusP = BigDecimal.ONE.subtract(pWinDec, MC);
        BigDecimal base = two.multiply(oneMinusP, MC).pow(rounds, MC);
        BigDecimal ratio = pWinDec.divide(oneMinusP, MC);

        BigDecimal[] p = new BigDecimal[rounds + 1];
        BigDecimal ratioPow = BigDecimal.ONE;
        for (int w = 0; w <= rounds; ++w) {
            p[w] = q[w].multiply(base, MC).multiply(ratioPow, MC);
            ratioPow = ratioPow.multiply(ratio, MC);
        }

        BigDecimal usedQ = BigDecimal.ZERO;
        BigDecimal successP = BigDecimal.ZERO;
        int boundaryW = -1;
        BigDecimal eps = new BigDecimal("1e-30");

        for (int w = rounds; w >= 0; --w) {
            BigDecimal candidateQ = usedQ.add(q[w], MC);
            if (candidateQ.compareTo(budgetQ.add(eps, MC)) <= 0) {
                usedQ = candidateQ;
                successP = successP.add(p[w], MC);
            } else {
                boundaryW = w;
                break;
            }
        }

        if (boundaryW == -1) {
            return BigDecimal.ONE;
        }

        BigDecimal frac = budgetQ.subtract(usedQ, MC).divide(q[boundaryW], MC);
        if (frac.compareTo(BigDecimal.ZERO) < 0)
            frac = BigDecimal.ZERO;
        if (frac.compareTo(BigDecimal.ONE) > 0)
            frac = BigDecimal.ONE;

        successP = successP.add(frac.multiply(p[boundaryW], MC), MC);
        return successP;
    }

    public static String solve() {
        BigDecimal ans = optimalSuccessProbability(1000, 0.6, 1000000000000.0);
        return ans.setScale(10, RoundingMode.HALF_UP).toPlainString();
    }

    public static void main(String[] args) {
        System.out.println(solve());
    }
}