Problem 757: Stealthy Numbers

View on Project Euler

Project Euler Problem 757 Solution

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

Problem Summary A positive integer \(N\) is called stealthy if there exist positive integers \(a,b,c,d\) such that $$ab=cd=N,\qquad a+b=c+d+1.$$ The task is to count how many distinct stealthy numbers satisfy \(N\le L\). The key fact used by the implementation is that stealthy numbers are exactly the products of two pronic numbers. Mathematical Approach Define the pronic function $$P(t)=t(t+1).$$ The whole problem becomes manageable once we show that every stealthy number can be written as \(P(x)P(y)\), and then enumerate those products without double-counting repeats. Step 1: Convert the Definition into a Product Formula Take two factor pairs of the same stealthy number, and let \((a,b)\) be the pair with the larger sum. After ordering the factors, the other pair lies strictly between them, so we may write $$c=a+k,\qquad d=b-k-1,\qquad k\ge 1.$$ The condition \(ab=cd\) becomes $$ab=(a+k)(b-k-1)=ab+k(b-a-k-1)-a.$$ Hence $$a=k(b-a-k-1).$$ So \(k\) divides \(a\). Write $$a=ky,\qquad y\ge 1.$$ Substituting back gives $$b=(k+1)(y+1),\qquad c=k(y+1),\qquad d=(k+1)y.$$ Therefore $$N=ab=k(k+1)y(y+1).$$ Renaming \(k\) as \(x\), we obtain the standard parametrization $$\boxed{N=x(x+1)y(y+1).}$$ Step 2: Prove the Converse and Remove the Symmetry The formula above is not only necessary but also sufficient....

Detailed mathematical approach

Problem Summary

A positive integer \(N\) is called stealthy if there exist positive integers \(a,b,c,d\) such that

$$ab=cd=N,\qquad a+b=c+d+1.$$

The task is to count how many distinct stealthy numbers satisfy \(N\le L\). The key fact used by the implementation is that stealthy numbers are exactly the products of two pronic numbers.

Mathematical Approach

Define the pronic function

$$P(t)=t(t+1).$$

The whole problem becomes manageable once we show that every stealthy number can be written as \(P(x)P(y)\), and then enumerate those products without double-counting repeats.

Step 1: Convert the Definition into a Product Formula

Take two factor pairs of the same stealthy number, and let \((a,b)\) be the pair with the larger sum. After ordering the factors, the other pair lies strictly between them, so we may write

$$c=a+k,\qquad d=b-k-1,\qquad k\ge 1.$$

The condition \(ab=cd\) becomes

$$ab=(a+k)(b-k-1)=ab+k(b-a-k-1)-a.$$

Hence

$$a=k(b-a-k-1).$$

So \(k\) divides \(a\). Write

$$a=ky,\qquad y\ge 1.$$

Substituting back gives

$$b=(k+1)(y+1),\qquad c=k(y+1),\qquad d=(k+1)y.$$

Therefore

$$N=ab=k(k+1)y(y+1).$$

Renaming \(k\) as \(x\), we obtain the standard parametrization

$$\boxed{N=x(x+1)y(y+1).}$$

Step 2: Prove the Converse and Remove the Symmetry

The formula above is not only necessary but also sufficient. For any positive integers \(x\) and \(y\), consider the two factor pairs

$$xy\cdot (x+1)(y+1),\qquad x(y+1)\cdot y(x+1).$$

Both products equal

$$x(x+1)y(y+1),$$

and their sums differ by exactly \(1\):

$$xy+(x+1)(y+1)=x(y+1)+y(x+1)+1.$$

So every number of the form \(x(x+1)y(y+1)\) is stealthy. The counting problem is therefore equivalent to counting distinct products

$$P(x)P(y),\qquad x,y\ge 1.$$

Because \(P(x)P(y)=P(y)P(x)\), we may impose

$$1\le x\le y$$

to remove the obvious symmetry.

Step 3: Bound the Valid Search Region

For a fixed \(x\), admissible values of \(y\) satisfy

$$P(x)P(y)\le L,$$

or equivalently

$$P(y)\le \left\lfloor\frac{L}{P(x)}\right\rfloor.$$

If we define

$$T_x=\left\lfloor\frac{L}{P(x)}\right\rfloor,$$

then \(y\) must solve

$$y(y+1)\le T_x.$$

The positive root of \(y^2+y-T_x=0\) is

$$\frac{-1+\sqrt{1+4T_x}}{2},$$

so the largest valid integer is

$$y_{\max}(x)=\left\lfloor\frac{\sqrt{1+4T_x}-1}{2}\right\rfloor.$$

There is also a bound on \(x\) itself. Since \(y\ge x\), the smallest product in the \(x\)-row is \(P(x)^2\), so that row is active only when

$$P(x)^2\le L.$$

This determines the final range of rows that must be explored.

Step 4: View the Candidates as Sorted Lists

For each active \(x\), define the list

$$V_x=\{P(x)P(y): y=x,x+1,\dots,y_{\max}(x)\}.$$

Because \(P(y)\) is strictly increasing, every \(V_x\) is strictly increasing as well. The full set of candidates is the union of these sorted lists. That means the problem is no longer a brute-force scan over all pairs; it is a k-way merge problem over many already sorted sequences.

Step 5: Merge the Lists and Deduplicate Online

A min-heap stores the current first unseen value from each list \(V_x\). Repeatedly removing the smallest heap element produces the global candidate stream in increasing order. When a value from one row is consumed, the next product from the same row is inserted.

Duplicates can occur because the same stealthy number may have more than one parametrization. Since the merged stream is sorted, equal products appear consecutively, so it is enough to compare the current extracted value with the previously extracted one:

$$v_{\mathrm{current}}\ne v_{\mathrm{previous}}.$$

This is why the implementation can count distinct stealthy numbers without storing every product in a global set.

Worked Example: \(L=200\)

First compute the relevant pronic numbers:

$$P(1)=2,\quad P(2)=6,\quad P(3)=12,\quad P(4)=20.$$

Since \(P(4)^2=400>200\), only the rows \(x=1,2,3\) are active.

For \(x=1\), we need \(2P(y)\le 200\), so \(P(y)\le 100\) and \(y_{\max}(1)=9\). This row is

$$4,12,24,40,60,84,112,144,180.$$

For \(x=2\), we need \(6P(y)\le 200\), so \(P(y)\le 33\) and \(y_{\max}(2)=5\). This row is

$$36,72,120,180.$$

For \(x=3\), only \(y=3\) works, giving

$$144.$$

Merging and deduplicating yields

$$4,12,24,36,40,60,72,84,112,120,144,180,$$

so there are \(12\) stealthy numbers up to \(200\). The repeated values \(144\) and \(180\) show why deduplication is necessary.

How the Code Works

The C++, Python, and Java implementations first build a table of pronic numbers up to the global limit \(P(y)\le L\). They then determine which starting rows are active by checking whether \(P(x)^2\le L\).

For every active row, the implementation computes the exact upper bound \(y_{\max}(x)\) using the inverse-pronic formula above and inserts the first value \(P(x)^2\) into a priority queue. Each queue entry stores enough information to generate the next product from the same row.

The main loop repeatedly removes the smallest current product. If it differs from the previous extracted value, the answer is increased by \(1\). Then the row that produced that product is advanced from \(y\) to \(y+1\), and the new product is inserted if it still lies within the valid range. The C++ version maintains its own binary heap, while the Python and Java versions rely on standard library priority queues, but the algorithmic idea is the same in all three languages.

Complexity Analysis

Let

$$K=\#\{x:P(x)^2\le L\},\qquad M=\sum_{x=1}^{K}\bigl(y_{\max}(x)-x+1\bigr).$$

Building the pronic table up to the global bound costs \(O(\sqrt{L})\) time and \(O(\sqrt{L})\) memory, because the largest needed index satisfies \(P(y)\le L\). The priority-queue phase performs one extract operation and at most one insert operation per generated pair, so it costs \(O(M\log K)\) time and \(O(K)\) additional memory for the heap.

In particular, the implementation never stores all \(M\) products at once. It keeps only one current candidate per active row, which is the essential reason the method remains practical for very large limits.

Footnotes and References

  1. Problem page: https://projecteuler.net/problem=757
  2. Pronic number: Wikipedia — Pronic number
  3. Integer square root: Wikipedia — Integer square root
  4. Priority queue: Wikipedia — Priority queue
  5. K-way merge algorithm: Wikipedia — K-way merge algorithm

Problem 757 source code

C++

#include <cassert>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <vector>

namespace {

using u64 = std::uint64_t;
using u32 = std::uint32_t;

u64 isqrt_u64(const u64 n) {
    long double d = static_cast<long double>(n);
    u64 x = static_cast<u64>(std::sqrt(d));
    while ((x + 1ULL) * (x + 1ULL) <= n) {
        ++x;
    }
    while (x * x > n) {
        --x;
    }
    return x;
}

u64 max_y_with_pronic_leq(const u64 t) {
    const u64 s = isqrt_u64(1ULL + 4ULL * t);
    return (s - 1ULL) / 2ULL;
}

u64 count_stealthy(const u64 limit) {
    const u64 y_global_max = max_y_with_pronic_leq(limit);
    std::vector<u64> pronic(static_cast<std::size_t>(y_global_max + 1ULL), 0ULL);
    for (u64 y = 1ULL; y <= y_global_max; ++y) {
        pronic[static_cast<std::size_t>(y)] = y * (y + 1ULL);
    }

    u64 x_max = 0ULL;
    for (u64 x = 1ULL; x <= y_global_max; ++x) {
        const u64 p = pronic[static_cast<std::size_t>(x)];
        if (p > limit / p) {
            break;
        }
        x_max = x;
    }

    struct Node {
        u64 value;
        u64 px;
        u32 y;
        u32 y_max;
    };

    std::vector<Node> heap;
    heap.reserve(static_cast<std::size_t>(x_max));

    auto sift_up = [&](std::size_t idx) {
        while (idx > 0U) {
            const std::size_t parent = (idx - 1U) >> 1U;
            if (heap[parent].value <= heap[idx].value) {
                break;
            }
            std::swap(heap[parent], heap[idx]);
            idx = parent;
        }
    };

    auto sift_down = [&](std::size_t idx) {
        const std::size_t n = heap.size();
        while (true) {
            std::size_t left = (idx << 1U) + 1U;
            if (left >= n) {
                break;
            }
            std::size_t right = left + 1U;
            std::size_t best = left;
            if (right < n && heap[right].value < heap[left].value) {
                best = right;
            }
            if (heap[idx].value <= heap[best].value) {
                break;
            }
            std::swap(heap[idx], heap[best]);
            idx = best;
        }
    };

    for (u64 x = 1ULL; x <= x_max; ++x) {
        const u64 px = pronic[static_cast<std::size_t>(x)];
        const u64 max_t = limit / px;
        const u64 y_lim = max_y_with_pronic_leq(max_t);
        if (y_lim < x) {
            continue;
        }
        heap.push_back(Node{px * px, px, static_cast<u32>(x), static_cast<u32>(y_lim)});
        sift_up(heap.size() - 1U);
    }

    u64 count = 0ULL;
    u64 last = static_cast<u64>(-1);

    while (!heap.empty()) {
        Node cur = heap[0];

        if (cur.value != last) {
            ++count;
            last = cur.value;
        }

        if (cur.y < cur.y_max) {
            ++cur.y;
            cur.value = cur.px * pronic[static_cast<std::size_t>(cur.y)];
            heap[0] = cur;
            sift_down(0U);
        } else {
            heap[0] = heap.back();
            heap.pop_back();
            if (!heap.empty()) {
                sift_down(0U);
            }
        }
    }

    return count;
}

}  // namespace

int main() {
    assert(count_stealthy(1'000'000ULL) == 2'851ULL);
    std::cout << count_stealthy(100'000'000'000'000ULL) << '\n';
    return 0;
}

Python

import math
import heapq

def solve():
    LIMIT = 100_000_000_000_000

    def isqrt(n):
        return math.isqrt(n)

    def max_y_pronic_leq(t):
        s = isqrt(1 + 4 * t)
        return (s - 1) // 2

    y_global_max = max_y_pronic_leq(LIMIT)
    pronic = [y * (y + 1) for y in range(y_global_max + 1)]

    x_max = 0
    for x in range(1, y_global_max + 1):
        p = pronic[x]
        if p > LIMIT // p: break
        x_max = x

    # Build heap: (value, px, y, y_max)
    heap = []
    for x in range(1, x_max + 1):
        px = pronic[x]
        max_t = LIMIT // px
        y_lim = max_y_pronic_leq(max_t)
        if y_lim < x: continue
        heapq.heappush(heap, (px * px, px, x, y_lim))

    count = 0
    last = -1

    while heap:
        val, px, y, y_max = heapq.heappop(heap)
        if val != last:
            count += 1
            last = val
        if y < y_max:
            ny = y + 1
            nval = px * pronic[ny]
            heapq.heappush(heap, (nval, px, ny, y_max))

    return str(count)

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

Java

import java.util.PriorityQueue;

public class Euler757 {
    static long isqrtU64(long n) {
        if (n == 0)
            return 0;
        long x = (long) Math.sqrt(n);
        while ((x + 1L) * (x + 1L) <= n)
            ++x;
        while (x * x > n)
            --x;
        return x;
    }

    static long maxYWithPronicLeq(long t) {
        long s = isqrtU64(1L + 4L * t);
        return (s - 1L) / 2L;
    }

    static class Node implements Comparable<Node> {
        long value;
        long px;
        int y;
        int yMax;

        Node(long value, long px, int y, int yMax) {
            this.value = value;
            this.px = px;
            this.y = y;
            this.yMax = yMax;
        }

        @Override
        public int compareTo(Node other) {
            return Long.compare(this.value, other.value);
        }
    }

    static long countStealthy(long limit) {
        long yGlobalMax = maxYWithPronicLeq(limit);
        long[] pronic = new long[(int) (yGlobalMax + 1)];
        for (int y = 1; y <= yGlobalMax; ++y) {
            pronic[y] = (long) y * (y + 1L);
        }

        int xMax = 0;
        for (int x = 1; x <= yGlobalMax; ++x) {
            long p = pronic[x];
            if (p > limit / p)
                break;
            xMax = x;
        }

        PriorityQueue<Node> pq = new PriorityQueue<>(xMax);

        for (int x = 1; x <= xMax; ++x) {
            long px = pronic[x];
            long maxT = limit / px;
            long yLim = maxYWithPronicLeq(maxT);
            if (yLim < x)
                continue;

            pq.add(new Node(px * px, px, x, (int) yLim));
        }

        long count = 0L;
        long last = -1L;

        while (!pq.isEmpty()) {
            Node cur = pq.poll();

            if (cur.value != last) {
                count++;
                last = cur.value;
            }

            if (cur.y < cur.yMax) {
                cur.y++;
                cur.value = cur.px * pronic[cur.y];
                pq.add(cur);
            }
        }

        return count;
    }

    public static String solve() {
        return Long.toString(countStealthy(100000000000000L));
    }

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