Problem 275: Balanced Sculptures
View on Project EulerProject Euler Problem 275 Solution
EulerSolve provides an optimized solution for Project Euler Problem 275, Balanced Sculptures, with C++, Python, Java, and a step-by-step mathematical explanation.
Problem Summary We count lattice sculptures made of \(n\) unit blocks, attached to a single support at \((0,0)\). The support lies on the vertical axis, so mechanical balance means the center of mass must remain above that axis. Sculptures are considered up to mirror reflection \((x,y)\mapsto(-x,y)\). Mathematical Approach 1) Finite search domain The support itself is not counted as a block. The first real block is forced to be \((0,1)\), and every other block lies in the half-plane \(y>0\). Connectivity is by 4-neighborhood adjacency. If a block sits at \((x,y)\), any path from the support to that block needs at least \(y+|x|\) steps through occupied cells. Since only \(n\) blocks are available, we must have $$y+|x|\le n.$$ Therefore every possible block lies inside the finite diamond $$0<y\le n,\qquad |x|\le n-y.$$ This is exactly the domain precomputed in buildDomain() . 2) Balance is the zero-torque condition Each block has equal mass, so the horizontal moment about the support axis is proportional to the sum of the \(x\)-coordinates of all occupied blocks. Hence balance is equivalent to $$\sum_{(x,y)\in S} x = 0.$$ The DFS maintains this as an integer sumx . Terminal states are accepted only when sumx == 0 . 3) Canonical connected generation via frontier and forbidden sets The difficulty is not checking balance; it is enumerating every connected sculpture exactly once....
Detailed mathematical approach
Problem Summary
We count lattice sculptures made of \(n\) unit blocks, attached to a single support at \((0,0)\). The support lies on the vertical axis, so mechanical balance means the center of mass must remain above that axis. Sculptures are considered up to mirror reflection \((x,y)\mapsto(-x,y)\).
Mathematical Approach
1) Finite search domain
The support itself is not counted as a block. The first real block is forced to be \((0,1)\), and every other block lies in the half-plane \(y>0\). Connectivity is by 4-neighborhood adjacency.
If a block sits at \((x,y)\), any path from the support to that block needs at least \(y+|x|\) steps through occupied cells. Since only \(n\) blocks are available, we must have
$$y+|x|\le n.$$
Therefore every possible block lies inside the finite diamond
$$0<y\le n,\qquad |x|\le n-y.$$
This is exactly the domain precomputed in buildDomain().
2) Balance is the zero-torque condition
Each block has equal mass, so the horizontal moment about the support axis is proportional to the sum of the \(x\)-coordinates of all occupied blocks. Hence balance is equivalent to
$$\sum_{(x,y)\in S} x = 0.$$
The DFS maintains this as an integer sumx. Terminal states are accepted only when sumx == 0.
3) Canonical connected generation via frontier and forbidden sets
The difficulty is not checking balance; it is enumerating every connected sculpture exactly once. The solver uses a Redelmeier-style canonical DFS.
Occupied set. occ stores the support and currently chosen blocks.
Frontier. U stores block cells adjacent to the occupied set that are still available to be
added.
Forbidden set. forb stores cells that this branch has decided not to use.
At each step the algorithm removes one frontier cell \(u\), recursively explores the branch that includes it, then marks \(u\) as forbidden before continuing. This single decision order is what prevents duplicates from different insertion orders.
After the mandatory first block \((0,1)\), the initial frontier is just
$$\{(-1,1),(1,1),(0,2)\}.$$
If one branch chooses not to use \((1,1)\), that cell becomes forbidden in that branch, so the same final sculpture cannot later be rebuilt by first taking \((0,2)\) and then returning to \((1,1)\).
4) Why the pruning bound is valid
Suppose the current horizontal sum is sumx and \(r\) blocks remain to be placed. Even in the most
favorable continuation, the total correction we can still apply is limited.
The code builds a list of all positive-domain \(x\)-coordinates, sorts it in descending order, and defines
$$B(r)=\text{sum of the }r\text{ largest }x\text{-values in the domain}.$$
Because the domain is symmetric, \(B(r)\) is also the largest possible magnitude of a correction in the negative direction. Therefore, if
$$|\text{sumx}| > B(r),$$
then not even an unrealistically optimal choice of the remaining blocks could restore balance. The branch is safely pruned.
This bound ignores connectivity and already-occupied cells, so it is optimistic. That is exactly why it is safe: if even the optimistic bound cannot recover, the real branch certainly cannot.
5) Mirror identification and Burnside's lemma
The DFS counts anchored sculptures in a fixed coordinate system, so a sculpture and its mirror are both generated unless the shape is itself symmetric.
Let
$$N=\text{all balanced anchored sculptures},\qquad S=\text{those fixed by }(x,y)\mapsto(-x,y).$$
The symmetry group has two elements: identity and reflection. Burnside's lemma gives the number of distinct sculptures up to reflection as
$$\frac{N+S}{2}.$$
The method isSymmetric() checks exactly whether every occupied cell is paired with its mirror image.
6) Small example and validation points
A simple balanced sculpture for \(n=6\) is
$$S=\{(0,1),(0,2),(0,3),(0,4),(-1,1),(1,1)\}.$$
It is connected, symmetric, and satisfies
$$0+0+0+0-1+1=0.$$
The source includes several validation checkpoints:
$$f(6)=18,\qquad f(10)=964,\qquad f(15)=360505,\qquad f(18)=15030564.$$
These are especially useful because they verify the canonical generation, pruning, and symmetry quotient all at once.
How the Code Works
Domain construction. buildDomain() creates all cells in the diamond
\(|x|\le n-y\), \(0\le y\le n\), precomputes 4-neighbors, and stores the mirror index of each cell.
Initial state. initialState() forbids all other ground cells \(y=0\), places the support
and the mandatory block \((0,1)\), and initializes the first frontier.
Main DFS. dfs() performs the include/forbid recursion, updates sumx, and
applies the maxCorr pruning test before exploring deeper.
Symmetry count. At a terminal state with exactly \(n\) blocks and sumx == 0, the code
increments total and, if mirrored occupancy also holds, increments sym.
Thread splitting. generateTasks() expands the top few levels of the search tree into
independent tasks, and worker threads process those tasks in parallel. This changes performance, not the underlying
mathematics.
Complexity Analysis
The worst-case search is exponential in \(n\), as is standard for polyomino-type enumeration. The practical runtime is much smaller because three reductions work together:
1. canonical frontier generation removes permutation duplicates;
2. forbidden cells prevent the same connected set from reappearing in another order;
3. the balance bound cuts branches that cannot possibly return to \(\sum x=0\).
Memory usage is \(O(n+|U|)\) along each recursion path, with additional storage for the precomputed domain and mirror tables.
Further Reading
- Problem page: https://projecteuler.net/problem=275
- Polyomino enumeration: https://en.wikipedia.org/wiki/Polyomino
- Burnside's lemma: https://en.wikipedia.org/wiki/Burnside%27s_lemma
Problem 275 source code
C++
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <thread>
#include <unordered_map>
#include <utility>
#include <vector>
using std::array;
using std::cout;
using std::cerr;
using std::max;
using std::size_t;
using std::string;
using std::uint64_t;
using std::vector;
struct Bitset {
static constexpr int WORDS = 6;
uint64_t w[WORDS]{};
inline void set(int i) { w[i >> 6] |= (1ULL << (i & 63)); }
inline void reset(int i) { w[i >> 6] &= ~(1ULL << (i & 63)); }
inline bool test(int i) const { return (w[i >> 6] >> (i & 63)) & 1ULL; }
};
struct Cell {
int x = 0;
int y = 0;
int deg = 0;
int nbr[4]{};
int mirror = -1;
};
struct Task {
Bitset occ, forb, inU;
array<int, 128> U{};
int uCount = 0;
array<int, 32> placed{};
int k = 0;
int sumx = 0;
};
class Solver {
public:
explicit Solver(int n) : n(n) {
buildDomain();
buildMaxCorr();
}
uint64_t solve(int threads) {
Task st = initialState();
vector<Task> tasks;
int splitDepth = (n >= 18 ? 8 : max(2, n / 2));
generateTasks(st, splitDepth, tasks);
std::atomic<size_t> idx{0};
vector<std::thread> pool;
vector<uint64_t> total(threads, 0), sym(threads, 0);
for (int t = 0; t < threads; ++t) {
pool.emplace_back([&, t]() {
while (true) {
size_t i = idx.fetch_add(1, std::memory_order_relaxed);
if (i >= tasks.size()) break;
Task local = tasks[i];
dfs(local, total[t], sym[t]);
}
});
}
for (auto& th : pool) th.join();
uint64_t N = 0, S = 0;
for (int t = 0; t < threads; ++t) {
N += total[t];
S += sym[t];
}
return (N + S) / 2;
}
private:
int n;
vector<Cell> dom;
int root = -1;
vector<int> maxCorr;
inline bool isBlockCell(int idx) const { return dom[idx].y > 0; }
void buildDomain() {
vector<std::pair<int, int>> coords;
coords.reserve((n + 1) * (n + 1));
for (int y = 0; y <= n; ++y) {
int lim = n - y;
for (int x = -lim; x <= lim; ++x) coords.emplace_back(x, y);
}
std::unordered_map<long long, int> id;
id.reserve(coords.size() * 2);
auto key = [](int x, int y) -> long long {
return (static_cast<long long>(static_cast<uint32_t>(x)) << 32) ^
static_cast<uint32_t>(y);
};
dom.resize(coords.size());
for (int i = 0; i < static_cast<int>(coords.size()); ++i) {
auto [x, y] = coords[i];
dom[i].x = x;
dom[i].y = y;
id[key(x, y)] = i;
}
for (int i = 0; i < static_cast<int>(dom.size()); ++i) {
int x = dom[i].x, y = dom[i].y;
auto addNbr = [&](int nx, int ny) {
auto it = id.find(key(nx, ny));
if (it == id.end()) return;
dom[i].nbr[dom[i].deg++] = it->second;
};
addNbr(x + 1, y);
addNbr(x - 1, y);
addNbr(x, y + 1);
addNbr(x, y - 1);
dom[i].mirror = id[key(-x, y)];
if (x == 0 && y == 0) root = i;
}
if (root < 0) throw std::runtime_error("Root not found");
}
void buildMaxCorr() {
vector<int> xs;
xs.reserve(dom.size());
for (const auto& c : dom) {
if (c.y > 0) xs.push_back(c.x);
}
std::sort(xs.begin(), xs.end(), std::greater<int>());
maxCorr.assign(n + 1, 0);
long long pref = 0;
for (int r = 1; r <= n; ++r) {
pref += xs[r - 1];
maxCorr[r] = static_cast<int>(pref);
}
}
Task initialState() const {
Task st;
for (int i = 0; i < static_cast<int>(dom.size()); ++i) {
if (dom[i].y == 0 && i != root) st.forb.set(i);
}
int firstBlock = -1;
for (int ni = 0; ni < dom[root].deg; ++ni) {
int v = dom[root].nbr[ni];
if (dom[v].x == 0 && dom[v].y == 1) firstBlock = v;
}
if (firstBlock < 0) throw std::runtime_error("Missing (0,1)");
st.occ.set(root);
st.occ.set(firstBlock);
st.placed[0] = root;
st.placed[1] = firstBlock;
st.k = 1;
st.sumx = 0;
for (int ni = 0; ni < dom[firstBlock].deg; ++ni) {
int v = dom[firstBlock].nbr[ni];
if (!isBlockCell(v)) continue;
if (st.occ.test(v) || st.forb.test(v) || st.inU.test(v)) continue;
st.U[st.uCount++] = v;
st.inU.set(v);
}
return st;
}
inline bool isSymmetric(const Task& st) const {
for (int i = 0; i <= st.k; ++i) {
int idx = st.placed[i];
if (!st.occ.test(dom[idx].mirror)) return false;
}
return true;
}
void dfs(Task& st, uint64_t& total, uint64_t& sym) {
int remaining = n - st.k;
if (std::abs(st.sumx) > maxCorr[remaining]) return;
if (st.k == n) {
if (st.sumx == 0) {
total++;
if (isSymmetric(st)) sym++;
}
return;
}
int processedCount = 0;
int processed[128];
while (st.uCount > 0) {
int u = st.U[--st.uCount];
st.inU.reset(u);
st.occ.set(u);
st.placed[st.k + 1] = u;
int addedStart = st.uCount;
for (int ni = 0; ni < dom[u].deg; ++ni) {
int v = dom[u].nbr[ni];
if (!isBlockCell(v)) continue;
if (st.occ.test(v) || st.forb.test(v) || st.inU.test(v)) continue;
st.U[st.uCount++] = v;
st.inU.set(v);
}
st.k++;
st.sumx += dom[u].x;
dfs(st, total, sym);
st.sumx -= dom[u].x;
st.k--;
while (st.uCount > addedStart) {
int v = st.U[--st.uCount];
st.inU.reset(v);
}
st.occ.reset(u);
st.forb.set(u);
processed[processedCount++] = u;
}
for (int i = processedCount - 1; i >= 0; --i) {
int u = processed[i];
st.forb.reset(u);
st.U[st.uCount++] = u;
st.inU.set(u);
}
}
void generateTasks(Task& st, int splitDepth, vector<Task>& out) {
int remaining = n - st.k;
if (std::abs(st.sumx) > maxCorr[remaining]) return;
if (st.k >= splitDepth) {
out.push_back(st);
return;
}
int processedCount = 0;
int processed[128];
while (st.uCount > 0) {
int u = st.U[--st.uCount];
st.inU.reset(u);
st.occ.set(u);
st.placed[st.k + 1] = u;
int addedStart = st.uCount;
for (int ni = 0; ni < dom[u].deg; ++ni) {
int v = dom[u].nbr[ni];
if (!isBlockCell(v)) continue;
if (st.occ.test(v) || st.forb.test(v) || st.inU.test(v)) continue;
st.U[st.uCount++] = v;
st.inU.set(v);
}
st.k++;
st.sumx += dom[u].x;
generateTasks(st, splitDepth, out);
st.sumx -= dom[u].x;
st.k--;
while (st.uCount > addedStart) {
int v = st.U[--st.uCount];
st.inU.reset(v);
}
st.occ.reset(u);
st.forb.set(u);
processed[processedCount++] = u;
}
for (int i = processedCount - 1; i >= 0; --i) {
int u = processed[i];
st.forb.reset(u);
st.U[st.uCount++] = u;
st.inU.set(u);
}
}
};
static void usage(const char* argv0) {
cerr << "Usage: " << argv0 << " [-n N] [-t THREADS] [--validate]\n";
}
int main(int argc, char** argv) {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n = 18;
int threads = static_cast<int>(std::thread::hardware_concurrency());
if (threads <= 0) threads = 1;
bool validate = false;
for (int i = 1; i < argc; ++i) {
string a = argv[i];
if (a == "-n" && i + 1 < argc) {
n = std::stoi(argv[++i]);
} else if (a == "-t" && i + 1 < argc) {
threads = max(1, std::stoi(argv[++i]));
} else if (a == "--validate") {
validate = true;
} else {
usage(argv[0]);
return 1;
}
}
auto run = [&](int nn) -> uint64_t {
Solver s(nn);
return s.solve(threads);
};
if (validate) {
struct Check {
int n;
uint64_t expected;
};
vector<Check> checks = {
{6, 18},
{10, 964},
{15, 360505},
{18, 15030564},
};
for (const auto& c : checks) {
uint64_t got = run(c.n);
if (got != c.expected) {
cerr << "Validation failed for n=" << c.n << " expected=" << c.expected
<< " got=" << got << "\n";
return 2;
}
}
}
cout << run(n) << "\n";
return 0;
}
Python
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
ANSWER_RE = re.compile(r"answer\s*:\s*(.+)$", re.IGNORECASE)
EQUAL_RE = re.compile(r"=\s*(.+)$")
def parse_output(stdout: str) -> str:
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
if not lines:
return ""
answer_candidates = []
equal_candidates = []
for line in lines:
m1 = ANSWER_RE.search(line)
if m1:
answer_candidates.append(m1.group(1).strip())
m2 = EQUAL_RE.search(line)
if m2:
equal_candidates.append(m2.group(1).strip())
if answer_candidates:
return answer_candidates[-1]
if equal_candidates:
return equal_candidates[-1]
return lines[-1]
def solve() -> str:
problem_id = __file__.split("Euler")[-1].split(".")[0]
root = Path(__file__).resolve().parent.parent
src = root / "solutionsCpp" / f"Euler{problem_id}.cpp"
binary = root / "solutionsCpp" / f".euler{problem_id}_py_bridge"
if not binary.exists() or src.stat().st_mtime > binary.stat().st_mtime:
compiler = shutil.which("clang++") or shutil.which("g++")
if not compiler:
raise RuntimeError("No C++ compiler found (clang++/g++).")
subprocess.check_call([compiler, "-std=c++17", "-O2", str(src), "-o", str(binary)])
output = subprocess.check_output([str(binary)], text=True)
parsed = parse_output(output)
if not parsed:
raise RuntimeError(f"Euler{problem_id} bridge produced empty output.")
return parsed
if __name__ == "__main__":
print(solve())
Java
import java.nio.file.*;
import java.util.*;
import java.util.regex.*;
public class Euler275 {
private static final Pattern ANSWER_RE = Pattern.compile("answer\\s*:\\s*(.+)$", Pattern.CASE_INSENSITIVE);
private static final Pattern EQUAL_RE = Pattern.compile("=\\s*(.+)$");
private static String parseOutput(String stdout) {
String[] lines = stdout.split("\\R");
List<String> nonEmpty = new ArrayList<>();
for (String line : lines) {
String t = line.trim();
if (!t.isEmpty()) {
nonEmpty.add(t);
}
}
if (nonEmpty.isEmpty()) {
return "";
}
List<String> answers = new ArrayList<>();
List<String> equals = new ArrayList<>();
for (String line : nonEmpty) {
Matcher m1 = ANSWER_RE.matcher(line);
if (m1.find()) {
answers.add(m1.group(1).trim());
}
Matcher m2 = EQUAL_RE.matcher(line);
if (m2.find()) {
equals.add(m2.group(1).trim());
}
}
if (!answers.isEmpty()) {
return answers.get(answers.size() - 1);
}
if (!equals.isEmpty()) {
return equals.get(equals.size() - 1);
}
return nonEmpty.get(nonEmpty.size() - 1);
}
private static String pickCompiler() throws Exception {
for (String compiler : List.of("clang++", "g++")) {
Process probe = new ProcessBuilder("bash", "-lc", "command -v " + compiler)
.redirectErrorStream(true)
.start();
String out = new String(probe.getInputStream().readAllBytes());
int rc = probe.waitFor();
if (rc == 0 && !out.trim().isEmpty()) {
return compiler;
}
}
throw new RuntimeException("No C++ compiler found (clang++/g++).");
}
private static Path ensureBridgeBinary() throws Exception {
Path root = Paths.get(System.getProperty("user.dir"));
Path src = root.resolve("solutionsCpp").resolve("Euler275.cpp");
Path bin = root.resolve("solutionsCpp").resolve(".euler275_java_bridge");
boolean rebuild = Files.notExists(bin)
|| Files.getLastModifiedTime(src).compareTo(Files.getLastModifiedTime(bin)) > 0;
if (rebuild) {
String compiler = pickCompiler();
Process compile = new ProcessBuilder(
compiler,
"-std=c++17",
"-O2",
src.toString(),
"-o",
bin.toString())
.inheritIO()
.start();
if (compile.waitFor() != 0) {
throw new RuntimeException("Failed to compile Euler275 C++ bridge.");
}
}
return bin;
}
private static String solveViaCppBridge() throws Exception {
Path bin = ensureBridgeBinary();
Process run = new ProcessBuilder(bin.toString())
.redirectErrorStream(true)
.start();
String out = new String(run.getInputStream().readAllBytes());
int rc = run.waitFor();
if (rc != 0) {
throw new RuntimeException("Euler275 C++ bridge failed.\n" + out);
}
String parsed = parseOutput(out);
if (parsed.isEmpty()) {
throw new RuntimeException("Euler275 C++ bridge produced empty output.");
}
return parsed;
}
public static void main(String[] args) throws Exception {
System.out.println(solveViaCppBridge());
}
}