Problem 247: Squares Under a Hyperbola
View on Project EulerProject Euler Problem 247 Solution
EulerSolve provides an optimized solution for Project Euler Problem 247, Squares Under a Hyperbola, with C++, Python, Java, and a step-by-step mathematical explanation.
Problem Summary Problem 247 builds an infinite family of axis-aligned squares inside the region below the rectangular hyperbola \(y=1/x\), starting from the corner \((1,0)\). From any exposed lower-left corner \((x,y)\), the next square is defined to be the unique largest square anchored at that corner whose upper-right corner still lies on the curve. Each square receives a label \((\text{left},\text{below})\). Moving to the right child increases \(\text{left}\) by 1, and moving to the upper child increases \(\text{below}\) by 1. All squares are then ranked globally by decreasing side length, not by tree depth. The actual target in this problem is the last square labeled \((3,3)\), so the task is to determine where the 20th such square appears in that global size order. Mathematical Approach The geometry gives a closed formula for the next square, and the recursive construction turns naturally into a best-first search on exposed corners. The crucial point is that the code never tries to list squares in depth-first or breadth-first order; it always extracts the currently largest available square. The maximal square anchored at a corner Fix a corner \((x,y)\) with \(x > 0\) and \(0 \le y < 1/x\). Let \(t=t(x,y)\) be the side length of the largest square whose lower-left corner is \((x,y)\) and whose sides remain parallel to the axes....
Detailed mathematical approach
Problem Summary
Problem 247 builds an infinite family of axis-aligned squares inside the region below the rectangular hyperbola \(y=1/x\), starting from the corner \((1,0)\). From any exposed lower-left corner \((x,y)\), the next square is defined to be the unique largest square anchored at that corner whose upper-right corner still lies on the curve.
Each square receives a label \((\text{left},\text{below})\). Moving to the right child increases \(\text{left}\) by 1, and moving to the upper child increases \(\text{below}\) by 1. All squares are then ranked globally by decreasing side length, not by tree depth. The actual target in this problem is the last square labeled \((3,3)\), so the task is to determine where the 20th such square appears in that global size order.
Mathematical Approach
The geometry gives a closed formula for the next square, and the recursive construction turns naturally into a best-first search on exposed corners. The crucial point is that the code never tries to list squares in depth-first or breadth-first order; it always extracts the currently largest available square.
The maximal square anchored at a corner
Fix a corner \((x,y)\) with \(x > 0\) and \(0 \le y < 1/x\). Let \(t=t(x,y)\) be the side length of the largest square whose lower-left corner is \((x,y)\) and whose sides remain parallel to the axes. The square occupies \([x,x+t]\times[y,y+t]\), so maximality occurs exactly when its upper-right corner touches the hyperbola:
$$y+t=\frac{1}{x+t}.$$
Multiplying through gives a quadratic equation:
$$t^2+(x+y)t+(xy-1)=0.$$
The positive root is
$$t(x,y)=\frac{\sqrt{(x-y)^2+4}-(x+y)}{2}.$$
This is the quantity computed in all three implementations. Because every generated corner stays strictly below the curve, the expression under the square root is positive and the chosen root is the unique feasible side length.
The recursive state space of exposed corners
Once the square of side \(t\) has been placed at \((x,y)\), two new exposed lower-left corners matter for the continuation:
$$\text{right child}: (x+t,y),\qquad \text{upper child}: (x,y+t).$$
Those two children correspond exactly to the two ways a later square can first touch the placed square: along its right side or along its top side. If a square has label \((L,B)\), then its children have labels
$$ (L+1,B)\qquad\text{and}\qquad (L,B+1). $$
So the whole construction is a binary tree of corners. The geometric data of a node are the corner \((x,y)\) and the induced side \(t(x,y)\); the combinatorial data are the two label counters \((L,B)\).
Why the priority queue gives the true global ordering
The ranking required by the problem is global: at every step we need the largest square among all squares that could ever appear later, not just among the children of the most recently processed node. The key monotonicity fact is that moving right or upward can only shrink the next square. If \(x_1 \ge x_0\) and \(y_1 \ge y_0\), with at least one inequality strict, then the feasible region under \(y=1/x\) above \((x_1,y_1)\) is smaller than the one above \((x_0,y_0)\), hence
$$t(x_1,y_1) < t(x_0,y_0).$$
Therefore every descendant of a node is strictly smaller than that node. At any moment, every unseen square is a descendant of one of the corners already sitting on the frontier, so the largest unseen square must already be present on that frontier. A max-heap over side lengths is therefore enough to reproduce the exact global order demanded by the problem.
Counting how many target squares must be seen
The label recurrence is purely combinatorial. To reach \((L,B)\), one must take \(L\) right moves and \(B\) up moves in some order. If \(N(L,B)\) is the number of nodes with that label, then the tree structure gives Pascal's recurrence
$$N(L,B)=N(L-1,B)+N(L,B-1),$$
with boundary values \(N(L,0)=N(0,B)=1\). Hence
$$N(L,B)=\binom{L+B}{L}.$$
For the actual target \((3,3)\), this means
$$N(3,3)=\binom{6}{3}=20.$$
So the correct stopping rule is not “stop at the first \((3,3)\) square”. The algorithm must continue until the 20th such square has been popped from the heap, because that pop index is the required answer.
Worked example: the two squares labeled \((1,1)\)
The root corner is \((1,0)\), so the first square has side
$$t(1,0)=\frac{\sqrt{5}-1}{2}\approx 0.6180339887.$$
Its right child is anchored at \((1+t(1,0),0)\approx(1.61803,0)\), with side
$$t(1.61803,0)\approx 0.4772599965.$$
Its upper child is anchored at \((1,t(1,0))\approx(1,0.61803)\), with side
$$t(1,0.61803)\approx 0.2090569265.$$
Now look at label \((1,1)\). There are two paths to it, \(RU\) and \(UR\), so there are two distinct squares. Their sides are approximately
$$t_{RU}\approx 0.1035877034,\qquad t_{UR}\approx 0.1292042862.$$
The labels match, but the sizes do not. The \(UR\) square appears earlier in the global ranking because it is larger. This small example is exactly why the code counts occurrences of a target label instead of stopping at the first match.
How the Code Works
The C++, Python, and Java implementations all follow the same best-first search, differing only in language syntax and numeric types.
State kept for each candidate square
Each heap entry stores four pieces of information: the current label \((\text{left},\text{below})\), the geometric corner \((x,y)\), and the side length obtained from the closed formula above. The priority key is simply the side length, ordered so that the largest pending square is removed first.
One pop, one rank, two pushes
The search starts with the root corner \((1,0)\). Whenever the implementation pops the current maximum from the heap, that pop number is exactly the square's global index. If the popped label is \((3,3)\), the counter of seen target squares is incremented. Then the implementation computes the right and upper child corners, evaluates their side lengths with the same hyperbola formula, and pushes both children into the heap.
Why the stopping rule is exact
Because there are exactly \(\binom{6}{3}=20\) squares labeled \((3,3)\), the loop stops when the 20th one is popped. The stored pop counter at that moment is the desired index. No post-processing is needed: the heap order is already the ranking order defined by the problem statement.
Complexity Analysis
Let \(A(L,B)\) denote the index of the last square with label \((L,B)\) in the global size order. To solve the target \((L,B)\), the implementation must perform exactly \(A(L,B)\) heap pops. Each pop is paired with two pushes, and each heap operation costs \(O(\log M)\) when the heap currently contains \(M\) elements.
Therefore the running time is \(O(A(L,B)\log A(L,B))\). After \(M\) pops the heap contains \(M+1\) nodes, so the memory usage is \(O(A(L,B))\). For the concrete Project Euler target \((3,3)\), this is easily practical.
Footnotes and References
- Project Euler problem page: Project Euler 247 - Squares under a hyperbola
- Rectangular hyperbola: Wikipedia - Rectangular hyperbola
- Quadratic equation: Wikipedia - Quadratic equation
- Binomial coefficient: Wikipedia - Binomial coefficient
- Priority queue: Wikipedia - Priority queue
Problem 247 source code
C++
#include <cmath>
#include <cstdint>
#include <functional>
#include <iostream>
#include <queue>
#include <string>
namespace {
using u64 = std::uint64_t;
struct Options {
int target_left = 3;
int target_below = 3;
bool run_checkpoints = 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;
}
int parsed = 0;
for (char c : tail) {
if (c < '0' || c > '9') {
return false;
}
parsed = parsed * 10 + static_cast<int>(c - '0');
}
value = parsed;
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_int_after_prefix(arg, "--left=", options.target_left) ||
parse_int_after_prefix(arg, "--below=", options.target_below)) {
continue;
}
std::cerr << "Unknown argument: " << arg << '\n';
return false;
}
return options.target_left >= 0 && options.target_below >= 0;
}
struct Node {
int left = 0;
int below = 0;
long double x = 1.0L;
long double y = 0.0L;
long double side = 0.0L;
};
long double largest_side(const long double x, const long double y) {
// Solve y + t = 1/(x + t) for t>0.
return (std::sqrt((x - y) * (x - y) + 4.0L) - (x + y)) / 2.0L;
}
u64 binom(const int n, const int k) {
if (k < 0 || k > n) {
return 0;
}
if (k == 0 || k == n) {
return 1;
}
u64 numer = 1;
u64 denom = 1;
const int kk = std::min(k, n - k);
for (int i = 1; i <= kk; ++i) {
numer *= static_cast<u64>(n - kk + i);
denom *= static_cast<u64>(i);
}
return numer / denom;
}
u64 solve(const int target_left, const int target_below) {
const u64 target_occurrences = binom(target_left + target_below, target_left);
auto cmp = [](const Node& a, const Node& b) {
return a.side < b.side;
};
std::priority_queue<Node, std::vector<Node>, decltype(cmp)> pq(cmp);
Node root;
root.left = 0;
root.below = 0;
root.x = 1.0L;
root.y = 0.0L;
root.side = largest_side(root.x, root.y);
pq.push(root);
u64 index = 0;
u64 seen = 0;
u64 answer = 0;
while (seen < target_occurrences) {
const Node cur = pq.top();
pq.pop();
++index;
if (cur.left == target_left && cur.below == target_below) {
++seen;
answer = index;
}
Node right;
right.left = cur.left + 1;
right.below = cur.below;
right.x = cur.x + cur.side;
right.y = cur.y;
right.side = largest_side(right.x, right.y);
pq.push(right);
Node up;
up.left = cur.left;
up.below = cur.below + 1;
up.x = cur.x;
up.y = cur.y + cur.side;
up.side = largest_side(up.x, up.y);
pq.push(up);
}
return answer;
}
bool run_checkpoints() {
if (solve(1, 1) != 50ULL) {
std::cerr << "Checkpoint failed for index (1,1)" << '\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.target_left, options.target_below) << '\n';
return 0;
}
Python
import math
import heapq
def largest_side(x, y):
# Solve y + t = 1/(x + t) for t > 0
return (math.sqrt((x - y)**2 + 4.0) - (x + y)) / 2.0
def binom(n, k):
if k < 0 or k > n: return 0
if k == 0 or k == n: return 1
numer = 1
denom = 1
kk = min(k, n - k)
for i in range(1, kk + 1):
numer *= (n - kk + i)
denom *= i
return numer // denom
class Node:
def __init__(self, left, below, x, y, side):
self.left = left
self.below = below
self.x = x
self.y = y
self.side = side
def __lt__(self, other):
# We want a max-heap based on side, so return True if self.side > other.side
return self.side > other.side
def solve(target_left=3, target_below=3):
target_occurrences = binom(target_left + target_below, target_left)
pq = []
root_x = 1.0
root_y = 0.0
root_side = largest_side(root_x, root_y)
root = Node(0, 0, root_x, root_y, root_side)
heapq.heappush(pq, root)
index = 0
seen = 0
answer = 0
while seen < target_occurrences:
cur = heapq.heappop(pq)
index += 1
if cur.left == target_left and cur.below == target_below:
seen += 1
answer = index
right_x = cur.x + cur.side
right_y = cur.y
right_side = largest_side(right_x, right_y)
right = Node(cur.left + 1, cur.below, right_x, right_y, right_side)
heapq.heappush(pq, right)
up_x = cur.x
up_y = cur.y + cur.side
up_side = largest_side(up_x, up_y)
up = Node(cur.left, cur.below + 1, up_x, up_y, up_side)
heapq.heappush(pq, up)
return str(answer)
if __name__ == '__main__':
print(solve())
Java
import java.util.PriorityQueue;
public class Euler247 {
static class Node implements Comparable<Node> {
int left, below;
double x, y, side;
Node(int left, int below, double x, double y, double side) {
this.left = left;
this.below = below;
this.x = x;
this.y = y;
this.side = side;
}
@Override
public int compareTo(Node other) {
// Max-heap based on side lengths mapping to highest priority
return Double.compare(other.side, this.side);
}
}
static double largestSide(double x, double y) {
return (Math.sqrt((x - y) * (x - y) + 4.0) - (x + y)) / 2.0;
}
static long binom(int n, int k) {
if (k < 0 || k > n)
return 0;
if (k == 0 || k == n)
return 1;
long numer = 1;
long denom = 1;
int kk = Math.min(k, n - k);
for (int i = 1; i <= kk; ++i) {
numer *= (n - kk + i);
denom *= i;
}
return numer / denom;
}
public static String solve() {
int targetLeft = 3;
int targetBelow = 3;
long targetOccurrences = binom(targetLeft + targetBelow, targetLeft);
PriorityQueue<Node> pq = new PriorityQueue<>();
double rootX = 1.0;
double rootY = 0.0;
double rootSide = largestSide(rootX, rootY);
pq.add(new Node(0, 0, rootX, rootY, rootSide));
long index = 0;
long seen = 0;
long answer = 0;
while (seen < targetOccurrences) {
Node cur = pq.poll();
index++;
if (cur.left == targetLeft && cur.below == targetBelow) {
seen++;
answer = index;
}
double rightX = cur.x + cur.side;
double rightY = cur.y;
double rightSide = largestSide(rightX, rightY);
pq.add(new Node(cur.left + 1, cur.below, rightX, rightY, rightSide));
double upX = cur.x;
double upY = cur.y + cur.side;
double upSide = largestSide(upX, upY);
pq.add(new Node(cur.left, cur.below + 1, upX, upY, upSide));
}
return String.valueOf(answer);
}
public static void main(String[] args) {
System.out.println(solve());
}
}