Problem 887: Bounded Binary Search

View on Project Euler

Project Euler Problem 887 Solution

EulerSolve provides an optimized solution for Project Euler Problem 887, Bounded Binary Search, with C++, Python, Java, and a step-by-step mathematical explanation.

Problem Summary For the bound \(L=7^{10}\), the bounded binary-search model is encoded by a capped capacity function \(T(m,d)\). Here \(m\) is the remaining number of search steps and \(d\) is the current bound parameter. For each fixed \(d\), the quantity of interest is the smallest \(m\) that lets the search distinguish at least \(N\) possibilities. Define that inverse threshold by $$R_d(N)=\min\{m\ge 0: T(m,d)\ge N\}.$$ The required total is $$A=\sum_{N=1}^{L}R_0(N)+\sum_{d=1}^{7}\sum_{N=1}^{L}R_d(N),\qquad L=7^{10}.$$ The difficulty is that \(T(m,d)\) is recursive, but the implementations exploit three facts: every value is capped at \(L\), the sequence is monotone in \(m\), and the inverse thresholds can be summed by jump intervals rather than one query at a time. Mathematical Approach The implementations work with the following capped recurrence: $$T(m,d)=\min\left(L,\ T(m-1,d-1)+T\left(m-1,\ T(m-1,d-1)+d-1\right)\right),$$ together with boundary rules that make the recursion finite and allow an efficient inversion. Step 1: Define the Capped Search Capacity The boundary conditions are $$T(0,d)=1\quad(d\ge -1),\qquad T(m,-1)=1,\qquad T(m,d)=0\quad(d<-1).$$ These rules say that once there is no remaining search depth, or once the parameter has reached the terminal boundary \(-1\), only one outcome is still distinguishable....

Detailed mathematical approach

Problem Summary

For the bound \(L=7^{10}\), the bounded binary-search model is encoded by a capped capacity function \(T(m,d)\). Here \(m\) is the remaining number of search steps and \(d\) is the current bound parameter. For each fixed \(d\), the quantity of interest is the smallest \(m\) that lets the search distinguish at least \(N\) possibilities.

Define that inverse threshold by

$$R_d(N)=\min\{m\ge 0: T(m,d)\ge N\}.$$

The required total is

$$A=\sum_{N=1}^{L}R_0(N)+\sum_{d=1}^{7}\sum_{N=1}^{L}R_d(N),\qquad L=7^{10}.$$

The difficulty is that \(T(m,d)\) is recursive, but the implementations exploit three facts: every value is capped at \(L\), the sequence is monotone in \(m\), and the inverse thresholds can be summed by jump intervals rather than one query at a time.

Mathematical Approach

The implementations work with the following capped recurrence:

$$T(m,d)=\min\left(L,\ T(m-1,d-1)+T\left(m-1,\ T(m-1,d-1)+d-1\right)\right),$$

together with boundary rules that make the recursion finite and allow an efficient inversion.

Step 1: Define the Capped Search Capacity

The boundary conditions are

$$T(0,d)=1\quad(d\ge -1),\qquad T(m,-1)=1,\qquad T(m,d)=0\quad(d<-1).$$

These rules say that once there is no remaining search depth, or once the parameter has reached the terminal boundary \(-1\), only one outcome is still distinguishable. If the parameter falls below that boundary, the branch contributes nothing.

Every value is truncated at \(L\). This cap is mathematically safe because the final sum only asks about thresholds \(1\le N\le L\), so any larger capacity is indistinguishable from \(L\).

Step 2: Recognize the Ordinary Binary-Search Regime

When the bound parameter is already large compared with the remaining depth, the restriction disappears and the search becomes a full binary tree. In that regime,

$$T(m,d)=\min(2^m,L),\qquad d\ge m-1.$$

This shortcut is one of the key optimizations. Since

$$2^{29}=536{,}870{,}912>7^{10}=282{,}475{,}249,$$

any completely unrestricted branch reaches the cap by depth \(29\).

Step 3: Invert the Monotone Capacity Function

For fixed \(d\), the sequence \(T(0,d),T(1,d),T(2,d),\dots\) is nondecreasing in \(m\). Intuitively, allowing one more search step cannot reduce the number of distinguishable outcomes, and the recurrence only combines previously computed nonnegative capacities.

Therefore the inverse threshold

$$R_d(N)=\min\{m\ge 0:T(m,d)\ge N\}$$

is well defined for every \(1\le N\le L\). The problem is thus a sum of inverse threshold values, not a sum of the capacities themselves.

Step 4: Simplify the Special Case \(d=0\)

Substituting \(d=0\) into the recurrence gives

$$T(m,0)=T(m-1,-1)+T(m-1,0)=1+T(m-1,0),$$

with initial value \(T(0,0)=1\). Hence

$$T(m,0)=m+1.$$

Inverting this linear sequence yields

$$R_0(N)=N-1.$$

That is why the \(d=0\) contribution is handled separately as the arithmetic series

$$\sum_{N=1}^{L}(N-1)=\frac{L(L-1)}{2}.$$

Step 5: Turn the Inverse Sum into a Jump Histogram

Suppose

$$T(m-1,d)<N\le T(m,d).$$

Then the smallest admissible depth for that \(N\) is exactly \(m\), so every \(N\) in the jump interval \((T(m-1,d),T(m,d)]\) contributes \(m\) to the total. If we define \(T(-1,d)=0\), then

$$\sum_{N=1}^{L}R_d(N)=\sum_{m\ge 0} m\bigl(T(m,d)-T(m-1,d)\bigr).$$

This identity is the main computational trick. Instead of asking for \(R_d(N)\) separately for every \(N\), one scans \(m=0,1,2,\dots\) until \(T(m,d)=L\) and adds one weighted jump per step.

Worked Example

The first two nontrivial parameters already illustrate the pattern.

For \(d=1\), the values are

$$\begin{aligned} T(0,1)&=1,\\ T(1,1)&=2,\\ T(2,1)&=4,\\ T(3,1)&=T(2,0)+T(2,3)=3+4=7,\\ T(4,1)&=T(3,0)+T(3,4)=4+8=12. \end{aligned}$$

So the thresholds break into intervals

$$\begin{aligned} (0,1]&\Rightarrow R_1(N)=0,\\ (1,2]&\Rightarrow R_1(N)=1,\\ (2,4]&\Rightarrow R_1(N)=2,\\ (4,7]&\Rightarrow R_1(N)=3. \end{aligned}$$

In particular, \(R_1(7)=3\).

For \(d=2\), once \(m\ge 4\), the second recursive branch is already in the unrestricted regime, so

$$T(m,2)=T(m-1,1)+2^{m-1}\qquad(m\ge 4).$$

Using \(T(9,1)=265\), we get

$$T(10,2)=T(9,1)+2^9=265+512=777,$$

hence \(R_2(777)=10\), matching the checkpoint used by the implementations.

How the Code Works

The C++, Python, and Java implementations memoize each previously computed state, indexed by the current depth and the effective bound parameter. Each lookup first applies the boundary rules above. If the unrestricted branch condition \(d\ge m-1\) is met, the value is returned immediately as \(\min(2^m,L)\); otherwise the program evaluates the two smaller recursive states, adds them, and caps the sum at \(L\).

After that memoized engine is available, the implementation handles \(d=0\) through the closed form \(\frac{L(L-1)}2\). For each \(d=1,\dots,7\), it increases \(m\) from \(0\) upward, keeps the previous capacity value, and accumulates

$$m\bigl(T(m,d)-T(m-1,d)\bigr)$$

until the cap \(L\) is reached. The final accumulation uses integer types large enough to avoid overflow in every language.

Complexity Analysis

Fix a parameter \(d\), and let \(M_d\) be the first depth for which \(T(M_d,d)=L\). Let \(S_d\) be the set of distinct memoized states visited while evaluating the capacities for depths \(0\) through \(M_d\). Because each state is computed at most once and later reused, the running time for that \(d\) is \(O(|S_d|)\), and the memo storage is also \(O(|S_d|)\).

Therefore the full program costs

$$O\left(\sum_{d=1}^{7}|S_d|\right)$$

time and

$$O\left(\max_{1\le d\le 7}|S_d|\right)$$

auxiliary memory, plus negligible overhead for the final big-integer style additions. The cap at \(L\) and the closed binary-search branch keep the explored state space far smaller than the naive recursion tree.

Footnotes and References

  1. Problem page: https://projecteuler.net/problem=887
  2. Binary search: Wikipedia - Binary search algorithm
  3. Memoization: Wikipedia - Memoization
  4. Monotonic function: Wikipedia - Monotonic function
  5. Recurrence relation: Wikipedia - Recurrence relation

Problem 887 source code

C++

#include <algorithm>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>

using u64 = std::uint64_t;
using u128 = unsigned __int128;

struct BoundedSearch {
    u64 limit;
    std::vector<std::unordered_map<std::uint32_t, u64>> memo;

    explicit BoundedSearch(u64 lim) : limit(lim), memo(128) {
        for (auto& mp : memo) mp.reserve(4096);
    }

    u64 F(int m, long long d) {
        if (d < -1) return 0;
        if (d > static_cast<long long>(limit)) d = static_cast<long long>(limit);
        if (m == 0) return 1;
        if (d == -1) return 1;
        if (d >= m - 1) {
            if (m >= 63) return limit;
            u64 full = 1ULL << m;
            return std::min(full, limit);
        }

        std::uint32_t key = static_cast<std::uint32_t>(d + 1);
        auto& mp = memo[m];
        auto it = mp.find(key);
        if (it != mp.end()) return it->second;

        u64 y = F(m - 1, d - 1);
        long long d2 = static_cast<long long>(y) + d - 1;
        u64 z = F(m - 1, d2);

        u64 ans = y + z;
        if (ans > limit) ans = limit;
        mp[key] = ans;
        return ans;
    }

    int Q(u64 N, int d) {
        if (d == 0) return static_cast<int>(N - 1);
        int m = 0;
        while (F(m, d) < N) ++m;
        return m;
    }
};

static std::string to_string_u128(u128 x) {
    if (x == 0) return "0";
    std::string s;
    while (x > 0) {
        int digit = static_cast<int>(x % 10);
        s.push_back(static_cast<char>('0' + digit));
        x /= 10;
    }
    std::reverse(s.begin(), s.end());
    return s;
}

int main() {
    u64 L = 1;
    for (int i = 0; i < 10; ++i) L *= 7ULL;

    BoundedSearch bs(L);

    assert(bs.Q(7, 1) == 3);
    assert(bs.Q(777, 2) == 10);
    for (u64 n = 1; n <= 20; ++n) {
        assert(bs.Q(n, 0) == static_cast<int>(n - 1));
    }

    u128 ans = static_cast<u128>(L) * static_cast<u128>(L - 1) / 2;
    for (int d = 1; d <= 7; ++d) {
        u64 prev = 0;
        int m = 0;
        while (prev < L) {
            u64 cur = bs.F(m, d);
            u64 upto = std::min(cur, L);
            ans += static_cast<u128>(m) * static_cast<u128>(upto - prev);
            prev = upto;
            ++m;
        }
    }

    std::cout << to_string_u128(ans) << '\n';
    return 0;
}

Python

import sys
sys.setrecursionlimit(20000)

class BoundedSearch:
    def __init__(self, limit):
        self.limit = limit
        self.memo = [{} for _ in range(128)]

    def F(self, m, d):
        if d < -1:
            return 0
        if d > self.limit:
            d = self.limit
            
        if m == 0:
            return 1
        if d == -1:
            return 1
            
        if d >= m - 1:
            if m >= 63:
                return self.limit
            full = 1 << m
            return min(full, self.limit)

        key = d + 1
        
        if key in self.memo[m]:
            return self.memo[m][key]

        y = self.F(m - 1, d - 1)
        d2 = y + d - 1
        z = self.F(m - 1, d2)

        ans = y + z
        if ans > self.limit:
            ans = self.limit
            
        self.memo[m][key] = ans
        return ans

    def Q(self, N, d):
        if d == 0:
            return N - 1
        m = 0
        while self.F(m, d) < N:
            m += 1
        return m

def solve():
    L = 7**10
    bs = BoundedSearch(L)

    ans = L * (L - 1) // 2
    for d in range(1, 8):
        prev = 0
        m = 0
        while prev < L:
            cur = bs.F(m, d)
            upto = min(cur, L)
            ans += m * (upto - prev)
            prev = upto
            m += 1

    return str(ans)

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

Java

import java.util.HashMap;

public class Euler887 {

    static class BoundedSearch {
        long limit;
        HashMap<Long, Long>[] memo;

        @SuppressWarnings("unchecked")
        BoundedSearch(long limit) {
            this.limit = limit;
            memo = new HashMap[128];
            for (int i = 0; i < 128; i++) {
                memo[i] = new HashMap<>(4096);
            }
        }

        long F(int m, long d) {
            if (d < -1)
                return 0;
            if (d > limit)
                d = limit;
            if (m == 0)
                return 1;
            if (d == -1)
                return 1;
            if (d >= m - 1) {
                if (m >= 63)
                    return limit;
                long full = 1L << m;
                return Math.min(full, limit);
            }

            long key = d + 1;
            if (memo[m].containsKey(key)) {
                return memo[m].get(key);
            }

            long y = F(m - 1, d - 1);
            long d2 = y + d - 1;
            long z = F(m - 1, d2);

            long ans = y + z;
            if (ans > limit || ans < 0)
                ans = limit; // check overflow
            memo[m].put(key, ans);
            return ans;
        }

        int Q(long n, int d) {
            if (d == 0)
                return (int) (n - 1);
            int m = 0;
            while (F(m, d) < n)
                ++m;
            return m;
        }
    }

    public static String solve() {
        long L = 1;
        for (int i = 0; i < 10; ++i)
            L *= 7L;

        BoundedSearch bs = new BoundedSearch(L);

        // Using BigInteger for ans to avoid overflow
        java.math.BigInteger bL = java.math.BigInteger.valueOf(L);
        java.math.BigInteger ans = bL.multiply(bL.subtract(java.math.BigInteger.ONE))
                .divide(java.math.BigInteger.valueOf(2));

        for (int d = 1; d <= 7; ++d) {
            long prev = 0;
            int m = 0;
            while (prev < L) {
                long cur = bs.F(m, d);
                long upto = Math.min(cur, L);

                java.math.BigInteger bUpto = java.math.BigInteger.valueOf(upto);
                java.math.BigInteger bPrev = java.math.BigInteger.valueOf(prev);
                java.math.BigInteger bM = java.math.BigInteger.valueOf(m);

                ans = ans.add(bM.multiply(bUpto.subtract(bPrev)));

                prev = upto;
                ++m;
            }
        }

        return ans.toString();
    }

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