Problem 398: Cutting Rope

View on Project Euler

Project Euler Problem 398 Solution

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

Problem Summary Choose uniformly from all ordered compositions of \(n\) into \(m\) positive integers: $$\mathcal{C}_{n,m}=\left\{(x_1,\dots,x_m)\in \mathbb{Z}_{>0}^m : x_1+\cdots+x_m=n\right\}.$$ After sorting one sampled composition into nondecreasing order, write $$X_{(1)}\le X_{(2)}\le \cdots \le X_{(m)}.$$ The task solved by the code is to compute the expected value of the second shortest piece, namely \(\mathbb{E}[X_{(2)}]\), for the Project Euler parameters \(n=10^7\) and \(m=100\). Mathematical Approach 1) Total number of equally likely cuts By the standard stars-and-bars argument, the number of ordered compositions of \(n\) into \(m\) positive parts is $$\left|\mathcal{C}_{n,m}\right|=\binom{n-1}{m-1}.$$ So every probability in the program is a composition count divided by \(\binom{n-1}{m-1}\). 2) Tail-sum identity for an integer-valued random variable Because \(X_{(2)}\) is a positive integer, we may use the tail-sum formula $$\mathbb{E}[X_{(2)}]=\sum_{k\ge 1}\Pr(X_{(2)}\ge k).$$ This reduces the whole problem to counting, for each \(k\), how many compositions have second smallest part at least \(k\). 3) Interpret the event \(X_{(2)}\ge k\) The condition \(X_{(2)}\ge k\) means that at most one part is smaller than \(k\). Equivalently, the composition belongs to one of two disjoint cases: Case A: all \(m\) parts are at least \(k\)....

Detailed mathematical approach

Problem Summary

Choose uniformly from all ordered compositions of \(n\) into \(m\) positive integers:

$$\mathcal{C}_{n,m}=\left\{(x_1,\dots,x_m)\in \mathbb{Z}_{>0}^m : x_1+\cdots+x_m=n\right\}.$$

After sorting one sampled composition into nondecreasing order, write

$$X_{(1)}\le X_{(2)}\le \cdots \le X_{(m)}.$$

The task solved by the code is to compute the expected value of the second shortest piece, namely \(\mathbb{E}[X_{(2)}]\), for the Project Euler parameters \(n=10^7\) and \(m=100\).

Mathematical Approach

1) Total number of equally likely cuts

By the standard stars-and-bars argument, the number of ordered compositions of \(n\) into \(m\) positive parts is

$$\left|\mathcal{C}_{n,m}\right|=\binom{n-1}{m-1}.$$

So every probability in the program is a composition count divided by \(\binom{n-1}{m-1}\).

2) Tail-sum identity for an integer-valued random variable

Because \(X_{(2)}\) is a positive integer, we may use the tail-sum formula

$$\mathbb{E}[X_{(2)}]=\sum_{k\ge 1}\Pr(X_{(2)}\ge k).$$

This reduces the whole problem to counting, for each \(k\), how many compositions have second smallest part at least \(k\).

3) Interpret the event \(X_{(2)}\ge k\)

The condition \(X_{(2)}\ge k\) means that at most one part is smaller than \(k\). Equivalently, the composition belongs to one of two disjoint cases:

Case A: all \(m\) parts are at least \(k\).

Case B: exactly one part is in \(\{1,2,\dots,k-1\}\), and the remaining \(m-1\) parts are at least \(k\).

4) Count Case A: every part is at least \(k\)

Write \(x_i=y_i+(k-1)\) for every part. Then each \(y_i\ge 1\) and

$$y_1+\cdots+y_m=n-m(k-1).$$

Therefore the number of compositions in Case A is

$$A_k=\binom{n-m(k-1)-1}{m-1}.$$

As usual, this is understood to be \(0\) whenever the top entry is smaller than \(m-1\).

5) Count Case B: exactly one short part

Choose the short position in \(m\) ways. Let that short part have length \(t\), where \(1\le t\le k-1\). The other \(m-1\) parts are at least \(k\), so after subtracting \(k-1\) from each of them we get

$$z_1+\cdots+z_{m-1}=n-t-(m-1)(k-1),\qquad z_i\ge 1.$$

For fixed \(t\), the number of such compositions is

$$\binom{n-t-(m-1)(k-1)-1}{m-2}.$$

Summing over \(t\) gives

$$B_k=m\sum_{t=1}^{k-1}\binom{n-t-(m-1)(k-1)-1}{m-2}.$$

Now apply the hockey-stick identity:

$$\sum_{t=1}^{k-1}\binom{n-t-(m-1)(k-1)-1}{m-2}=\binom{n-(m-1)(k-1)-1}{m-1}-\binom{n-m(k-1)-1}{m-1}.$$

Hence

$$B_k=m\left(\binom{n-(m-1)(k-1)-1}{m-1}-\binom{n-m(k-1)-1}{m-1}\right).$$

6) Closed formula for the tail probability

Adding the two disjoint cases yields

$$A_k+B_k=m\binom{n-(m-1)(k-1)-1}{m-1}-(m-1)\binom{n-m(k-1)-1}{m-1}.$$

Dividing by the total number of compositions gives the exact tail used by the solver:

$$\Pr(X_{(2)}\ge k)=\frac{m\binom{n-(m-1)(k-1)-1}{m-1}-(m-1)\binom{n-m(k-1)-1}{m-1}}{\binom{n-1}{m-1}}.$$

Substituting this into the tail-sum identity gives the full expectation. This is the formula implemented in C++, Python, and Java.

7) Small checkpoints from the source code

The C++ program verifies two test cases before printing the final value. For \((n,m)=(3,2)\), the only compositions are \((1,2)\) and \((2,1)\), so the second shortest piece is always \(2\), hence \(\mathbb{E}[X_{(2)}]=2\).

For \((n,m)=(8,3)\), the denominator is \(\binom{7}{2}=21\). The nonzero tail terms are

$$\Pr(X_{(2)}\ge 1)=1,$$

$$\Pr(X_{(2)}\ge 2)=\frac{3\binom{5}{2}-2\binom{4}{2}}{\binom{7}{2}}=\frac{18}{21}=\frac{6}{7},$$

$$\Pr(X_{(2)}\ge 3)=\frac{3\binom{3}{2}-2\binom{1}{2}}{\binom{7}{2}}=\frac{9}{21}=\frac{3}{7}.$$

All later terms are zero, so

$$\mathbb{E}[X_{(2)}]=1+\frac{6}{7}+\frac{3}{7}=\frac{16}{7},$$

which matches the checkpoint in the repository.

How the Code Works

The implementations avoid constructing huge binomial coefficients directly. They set

$$r=m-1,$$

$$R_k=\frac{\binom{n-(m-1)(k-1)-1}{r}}{\binom{n-1}{r}},\qquad S_k=\frac{\binom{n-m(k-1)-1}{r}}{\binom{n-1}{r}}.$$

Then the tail probability becomes

$$\Pr(X_{(2)}\ge k)=mR_k-(m-1)S_k.$$

If the current binomial top is \(a\) and the next step decreases it by \(d\), the ratio update is

$$\frac{\binom{a-d}{r}}{\binom{a}{r}}=\prod_{j=0}^{r-1}\frac{a-d-j}{a-j}.$$

This telescoping product is exactly what ratio_binom_drop computes. The variables a_r and a_s track the current top entries for the two numerator terms, while ratio_r and ratio_s hold the normalized values \(R_k\) and \(S_k\). Once a top entry falls below \(r\), that term is set to zero and the loop terminates naturally.

Complexity Analysis

Each loop iteration updates two products of length \(r=m-1\), so one iteration costs \(O(m)\) time. If \(T\) tail levels are nonzero, the full computation costs \(O(Tm)\) time and \(O(1)\) additional memory. Since the top parameters decrease by \(m-1\) or \(m\) at every step, \(T\) is on the order of \(n/(m-1)\). The method is therefore compact in memory and efficient enough for the Project Euler input.

References

  1. Problem page: https://projecteuler.net/problem=398
  2. Order statistics: Wikipedia — Order statistic
  3. Compositions and stars-and-bars: Wikipedia — Stars and bars
  4. Binomial coefficient identities: Wikipedia — Binomial coefficient

Problem 398 source code

C++

#include <cmath>
#include <iomanip>
#include <iostream>
#include <string>

namespace {

struct Options {
    long long n = 10000000;
    int m = 100;
    bool run_checkpoints = true;
};

bool parse_ll_after_prefix(const std::string& arg, const std::string& prefix, long long& value) {
    if (arg.rfind(prefix, 0U) != 0U) {
        return false;
    }
    const std::string tail = arg.substr(prefix.size());
    if (tail.empty()) {
        return false;
    }
    try {
        value = std::stoll(tail);
    } catch (...) {
        return false;
    }
    return true;
}

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;
    }
    try {
        value = std::stoi(tail);
    } catch (...) {
        return false;
    }
    return true;
}

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

long double ratio_binom_drop(long long a, int drop, int choose_r) {
    // Computes C(a-drop, r) / C(a, r) by telescoping product.
    long double ratio = 1.0L;
    for (int j = 0; j < choose_r; ++j) {
        ratio *= static_cast<long double>(a - drop - j) / static_cast<long double>(a - j);
    }
    return ratio;
}

long double expected_second_shortest(long long n, int m) {
    // P(X_(2) >= k) = [ m*C(n-(m-1)(k-1)-1,m-1) - (m-1)*C(n-m(k-1)-1,m-1) ] / C(n-1,m-1).
    // Then E[X_(2)] = sum_{k>=1} P(X_(2) >= k).
    const int r = m - 1;

    long long a_r = n - 1;  // numerator top for first binomial ratio.
    long long a_s = n - 1;  // numerator top for second binomial ratio.
    long double ratio_r = 1.0L;
    long double ratio_s = 1.0L;

    long double answer = 0.0L;
    while (true) {
        const long double tail = static_cast<long double>(m) * ratio_r -
                                 static_cast<long double>(m - 1) * ratio_s;
        answer += tail;

        bool has_next = false;
        if (a_r - (m - 1) >= r) {
            ratio_r *= ratio_binom_drop(a_r, m - 1, r);
            a_r -= (m - 1);
            has_next = true;
        } else {
            ratio_r = 0.0L;
        }

        if (a_s - m >= r) {
            ratio_s *= ratio_binom_drop(a_s, m, r);
            a_s -= m;
            has_next = true;
        } else {
            ratio_s = 0.0L;
        }

        if (!has_next) {
            break;
        }
    }

    return answer;
}

bool nearly_equal(long double a, long double b, long double rel_tol = 1e-12L) {
    const long double scale = std::max(std::fabsl(a), std::fabsl(b));
    if (scale == 0.0L) {
        return true;
    }
    return std::fabsl(a - b) <= rel_tol * scale;
}

bool run_checkpoints() {
    if (!nearly_equal(expected_second_shortest(3, 2), 2.0L, 1e-14L)) {
        std::cerr << "Checkpoint failed: E(3,2)\n";
        return false;
    }

    const long double e83 = expected_second_shortest(8, 3);
    if (!nearly_equal(e83, 16.0L / 7.0L, 1e-13L)) {
        std::cerr << "Checkpoint failed: E(8,3)\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;
    }

    const long double answer = expected_second_shortest(options.n, options.m);
    std::cout << std::fixed << std::setprecision(5) << static_cast<double>(answer) << '\n';
    return 0;
}

Python

def ratio_binom_drop(a, drop, choose_r):
    ratio = 1.0
    for j in range(choose_r):
        ratio *= float(a - drop - j) / float(a - j)
    return ratio

def expected_second_shortest(n, m):
    r = m - 1
    a_r = n - 1
    a_s = n - 1
    ratio_r = 1.0
    ratio_s = 1.0
    
    answer = 0.0
    while True:
        tail = float(m) * ratio_r - float(m - 1) * ratio_s
        answer += tail
        
        has_next = False
        if a_r - (m - 1) >= r:
            ratio_r *= ratio_binom_drop(a_r, m - 1, r)
            a_r -= (m - 1)
            has_next = True
        else:
            ratio_r = 0.0
            
        if a_s - m >= r:
            ratio_s *= ratio_binom_drop(a_s, m, r)
            a_s -= m
            has_next = True
        else:
            ratio_s = 0.0
            
        if not has_next:
            break
            
    return answer

def solve():
    n = 10000000
    m = 100
    ans = expected_second_shortest(n, m)
    return "{:.5f}".format(ans)

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

Java

public class Euler398 {
    private static double ratioBinomDrop(long a, int drop, int chooseR) {
        double ratio = 1.0;
        for (int j = 0; j < chooseR; ++j) {
            ratio *= (double) (a - drop - j) / (double) (a - j);
        }
        return ratio;
    }

    private static double expectedSecondShortest(long n, int m) {
        int r = m - 1;
        long aR = n - 1;
        long aS = n - 1;
        double ratioR = 1.0;
        double ratioS = 1.0;

        double answer = 0.0;
        while (true) {
            double tail = (double) (m) * ratioR - (double) (m - 1) * ratioS;
            answer += tail;

            boolean hasNext = false;
            if (aR - (m - 1) >= r) {
                ratioR *= ratioBinomDrop(aR, m - 1, r);
                aR -= (m - 1);
                hasNext = true;
            } else {
                ratioR = 0.0;
            }

            if (aS - m >= r) {
                ratioS *= ratioBinomDrop(aS, m, r);
                aS -= m;
                hasNext = true;
            } else {
                ratioS = 0.0;
            }

            if (!hasNext) {
                break;
            }
        }
        return answer;
    }

    public static String solve() {
        long n = 10000000L;
        int m = 100;
        return String.format("%.5f", expectedSecondShortest(n, m)).replace(',', '.');
    }

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