A C++ brute-force calculator for arbitrary degree Erdös-Ginzburg-Ziv constants in finite commutative unit rings.
-
$ℤ_n$ , for any natural$n$ . -
$ℤ_n^m$ , for any natural$n$ and$m$ . -
$𝔽_4$ . $\frac{ℤ_2[x]}{x^2}$ - Products of any two of the above, via
product<R, P>(e.g.Z_2xZ_2). -
$ℤ_n[x_1 \dots x_k]$ modulo one relation per variable, named directly on the command line. See Runtime rings.
--list-rings prints the instantiations the binary was built with; see
Adding a ring to expose more.
Requires a C++17 compiler. With CMake:
cmake -S . -B build
cmake --build buildThis produces build/egz-solver (build\egz-solver.exe on Windows). Or compile
directly, since the project is a single translation unit:
g++ -std=c++17 -O2 -DDEBUG main.cpp -o egz-solverDEBUG enables progress logging to stdout; it is on by default under CMake and
can be turned off with -DEGZ_DEBUG=OFF. The tables written to disk are the
same either way.
./build/egz-solver --ring Z_2 --m-max 20 --t-max 25| Option | Meaning |
|---|---|
--ring NAME |
Ring to compute (default Z_2). |
--m-min N |
First m to compute (default 1). |
--m-max N |
Exclusive upper bound on m. |
--t-max N |
Exclusive upper bound on t. |
--out-dir DIR |
Where to write the table (default Experimental tables). |
--skip EXPR |
Leave rows out of the table; repeatable. See Skipping rows. |
--max-work N |
Give up on a cell after N work units; 0 is unlimited. See Bounding the work per cell. |
--memo-cap N |
Hold at most N memoized e_m values, dropping one not read lately; 0 is unlimited, and the default is 8G. A K/M/G/T suffix reads N as bytes instead. See Bounding the memo. |
--no-file |
Print progress only; write nothing. |
--quiet |
Suppress per-value progress output. |
--method WHICH |
bottom-up (default) or top-down. See How the search works. |
--list-rings |
List supported ring names and exit. |
The output directory is relative to the working directory and is created if
missing, so run from the repository root to land in Experimental tables/.
Be aware that cost grows steeply in both m and t: full tables for the larger
rings take hours, and a single cell deep in a table can take minutes.
Besides the rings listed above, --ring takes a quotient of a polynomial ring
written out directly, so you can compute one without recompiling:
./build/egz-solver --ring 'Z_2[x]/(x^2+x+1)'
./build/egz-solver --ring 'Z_4[x]/(x^2+1)'
./build/egz-solver --ring 'Z_2[x,y]/(x^2,y^2)'
./build/egz-solver --ring Z_2xy_by_x2_and_y2 # the same ring, no quoting neededEach variable needs exactly one relation, in that variable alone. A relation
needs degree at least 1 and a leading coefficient invertible mod n; a zero
divisor there leaves a ring the solver cannot compute in, which it will warn about.
Coefficients may be written with -, and x^2 and x2 are both accepted.
A relation tying variables together — (xy-1), (x^2-y) — won't work. Those need
a Gröbner basis, which this does not have.
The ring has n^(d1 * ... * dk) elements, for relation degrees d1 .. dk, and
that is the number to watch: cost climbs steeply with it, and anything past 256
elements is refused as unsearchable. Z_2[x,y]/(x^2,y^2) is already 16.
The table lands in EGZ_<ring name>.tsv, and that name is itself a valid
--ring argument, so a file name can always be fed back to reproduce it.
Z_2[x]/(x^2) and Z_2[x]/(x^2+x+1) are the built-in Z_2x_by_x2 and F_4
written the other way round. They give identical results, down to byte-identical
EGZ tables, which tests/test_rings.py checks; a name matching a built-in still
selects the built-in, since the published tables were computed with it.
Some rows cost far more time and memory than their neighbours, and --skip
leaves them out without recompiling. It takes an expression on m:
| Expression | Skips |
|---|---|
powers |
m that are powers of the ring's order |
pow:K |
m that are powers of K (counting K^0 = 1) |
mod:K=R |
everything except m congruent to R modulo K |
list:a,b,c |
exactly those m |
none |
nothing |
--skip may be given several times; a row is skipped if any expression says so,
so two mod: rules keep only the m satisfying both.
./build/egz-solver --ring Z_3 --skip powers # omit m = 1, 3, 9, ...
./build/egz-solver --ring Z_2x_by_x2 --skip mod:4=1 # keep only m = 1 (mod 4)Skipped rows are omitted from the table, not written blank, so their absence
cannot be mistaken for "no EGZ constant exists". The m that were skipped are
reported on stderr.
For --skip powers leaves out exactly the m = n^k rows, which are both
the most expensive and the ones already settled in closed form (see
tests/README.md), so little is lost by skipping them.
--skip predicts which rows are expensive. --max-work N measures instead:
each EGZ(t, m) is given N units of work, and a cell that exceeds its budget
is abandoned. This is the option to reach for when you do not yet know where the
cost lies -- exploring m that are not prime powers, say.
./build/egz-solver --ring F_4 --max-work 200000A unit is one step of the search that had to recurse: an e_m evaluation that
missed the memo, or a call into the counterexample enumeration. Memo hits are
free, so the count tracks work actually done rather than cells visited. The
budget is per cell, so one abandoned cell does not penalise the next, and
whatever was memoised before an abort stays available.
Abandoned cells are written as ? and reported on stderr. They are never left
blank, which would be indistinguishable from "no EGZ constant exists". Here
--max-work 400 gave up on the five cells at the right-hand end of rows 3 and
4; the ? left of the diagonal are the ordinary never-computed ones.
1 2 3 4 5 6 7 8 9 10 11
1 2 2 2
2 ? 3 3 3 3 3 3
3 ? ? ? ? ?
4 ? ? ? 2 2 ? ?
A budget only costs you answers, it never changes them: any cell that finishes
under a budget holds exactly the value an unbudgeted run produces. Raising the
budget can only turn ? into a value, never alter one.
Both searches memoize every e_m they evaluate, and the reuse is most of the
speed. On a large ring that memo is where the memory goes, so it is capped at
8 GiB by default -- once it holds that many entries, each new entry evicts
one that has not been read lately. --memo-cap 0 lifts the cap entirely, which
is worth knowing before running one: nothing else bounds the memo but the ring,
and a large sweep will take every byte the machine has and then page.
Eviction is second-chance rather than exact LRU: a hit sets a bit on the entry,
and a hand sweeping the entries evicts the first it finds with the bit clear,
clearing the bits it passes. That costs one byte per hit where an exact order
cost a linked-list splice, and on a Z_7 sweep held to 200,000 entries it
matched exact LRU's hit rate to within a tenth of a percent while running 1.8x
faster.
./build/egz-solver --ring F_4 --memo-cap 20000000A bare N counts entries. With a K, M, G or T suffix it is bytes
instead -- binary, and KB and KiB both mean 1024 -- which the solver divides
by what one entry costs on the ring in hand:
./build/egz-solver --ring F_4 --memo-cap 8GThat division is close but not exact. Every entry really is the same size, since
a sequence always holds order ints, but what the allocator adds per block is
a guess, calibrated by measuring a full memo against its cap. Buckets are the
term that matters -- an unordered_map holds one per entry and MSVC spends two
list iterators on each -- which is why the memo is a single table keyed on m
and the sequence together rather than one table per m. Per-degree tables
ratchet: each grows its bucket array in turn and keeps it after eviction empties
it, reaching 16x the entry count on a Z_6 sweep and costing gigabytes that no
byte cap accounted for. Treat a byte cap as a target the memo aims at, not a ceiling the process
cannot cross, as the ring tables and the runtime are outside it.
Eviction never changes an answer -- a miss recomputes the value -- so a capped
run produces the same table as an uncapped one, only slower. The two budgets do
interact, though: a recomputed value is charged to --max-work again, so a
tight cap and a tight budget together can abandon a cell that either alone would
finish.
The cap is one number spanning every m rather than one per degree, and it is
read once, when the solver is built. There is a compiled-in default too,
-DEGZ_MEMO_CAP=, which the flag overrides at run time.
EGZ(t, m) is computed level by level. The solver finds every multiset of size
t with e_m = 0, then recurses, finding every multiset of size t + i + 1
containing a multiset of size t + i, and stops at the first size where every
multiset is covered. That size is the EGZ in question.
Each level is a single flat pass with no search and no backtracking, because
which S have e_m(S) = 0 is a function only of which e_m(S’) are zero in
sizes one smaller than S, so the values are computed once at level t and
never looked at again.
Worth knowing before running anything large:
Memory has a ceiling. Two levels are live at a time, one bit per multiset,
and a level of size l over a ring of order k holds C(l + k - 1, k - 1) of
them. Past EGZ_MAX_LEVEL the solver writes ? rather than trying to allocate.
The default admits about 500 MB per level, so 1 GB for the two live at once,
which is what an order-9 ring needs: its levels pass 200 MB by level 48, and a
ceiling that stops there turns computable cells into ?. The rest is allocated
for e_m. This is editable.
For most rings none of this binds: on a Z_7 sweep the levels came to 796 KiB
against 375 MiB of e_m memo, so the memo is what you actually pay for.
--method top-down selects a second, independent implementation
(egz_top_down.hpp), which was the one used in the original thesis. It takes
each candidate length l in turn and searches for a counterexample — a
sequence of length l with no zero-e_m subsequence of length t — and
returns the first l that has none.
It still exists because it is still useful for generating tests, as they share
only sequence and the ring; the e_m recurrence and the memo are written
separately in each, and the test suite holds them to
producing identical tables.
It is also slower, because each candidate length starts a fresh search over
ground the previous one covered. Sweeping m < 12, t < 24 with
tests/compare_methods.cpp, every cell agreeing:
| Ring | Top-down | Bottom-up |
|---|---|---|
Z_3 |
3 ms | 2 ms |
F_4 |
89 ms | 35 ms |
Z_2^2 |
307 ms | 45 ms |
Z_5 |
368 ms | 140 ms |
Z_7 |
77.1 s | 10.0 s |
Across 198 Z_7 cells there was no cell where bottom-up was slower, and the
worst ran 5.0 s against 0.35 s. Memory is near a tie: both are dominated by the
e_m memo, and bottom-up's is 3–5% larger because it evaluates e_m on every
multiset of size t rather than only the ones a search reaches.
What top-down still has is no ceiling — it never holds a level. The one cell found where bottom-up gives up due to its ceiling, though, is one top-down could not finish either.
One .tsv per ring, named EGZ_<ring>.tsv. Rows are m, columns are t, and
a cell holds EGZ(t, m) - t, not EGZ(t, m) itself. The first row is the t
header; the first column is m.
So a cell containing 6 at row m = 1, column t = 21 means EGZ(21, 1) = 27.
A blank cell means the solver computed that (t, m) and got that EGZ(t, m) is
infinite/no EGZ constant exists there. A blank is an answer. However, a ?
means no value was computed, for one of three reasons:
t < m— left of the diagonal, outside the row's range oft.tpast the upper bound the row was generated with.- the search was abandoned under
--max-work; see Bounding the work per cell.
The first two are fixed by (t, m) and the row's bounds, so they are easy to
tell from an abandoned cell, but the format does not distinguish the three.
A row left out by --skip is absent from the file entirely. An omitted row, a
? and a blank are therefore three distinguishable things.
Within a row's computed range, a
All 7,175 such cells across the committed tests/verify_against_literature.py checks it, along with the rule that everything
outside a computed range carries ?.
Every table is computed to the full width of its header, so the only ? in the
committed data are below the diagonal.
--t-max sets the last column computed, but it also has to exceed every element
multiplicity the search reaches — not just the largest t you want. A search
that reaches 30 copies of one element wants --t-max 31, whatever t is. Too
low on that count only costs speed, never correctness.
--m-max is the harder bound: a row past it is refused outright rather than
computed, so raise it before asking for one.
Both have compiled-in defaults, changeable with -DEGZ_M_MAX= / -DEGZ_T_MAX=,
and the CLI flags override them at run time.
cmake --build build
ctest --test-dir build --output-on-failureTen suites run, in a few seconds:
regressionandregression_bottom_upreplay 176 known-good values sampled fromExperimental tables/across all nine tables — once with each of the two searches.methods_agreeandmethods_agree_f4run both searches over every cell of a small sweep and fail on any disagreement.literature_verificationchecks the committed tables against the theorems proved in the accompanying thesis and classical zero-sum results from the wider literature.ringschecks the rings themselves: ring axioms, and that each quotient reproduces the built-in ring it generalises.skip_rules,max_workandmemo_capdrive those three options through the CLI.
See tests/README.md.
| File | Contents |
|---|---|
main.cpp |
CLI and the table writer. |
egz_bottom_up.hpp |
The search: e_m, the level sweep, EGZ(t, m). |
egz_top_down.hpp |
The other search, an independent second implementation. |
rings.hpp |
Ring implementations (Zn, Znp, F4, Z_2_over, product). |
quotient.hpp |
Parses and builds the Z_n[x]/(P) rings named at run time. |
ring_registry.hpp |
Maps a --ring name or spec to its ring. |
sequence.hpp |
Multiset of ring elements, with hashing for memoization. |
config.hpp |
Search bounds (M_MAX, T_MIN, T_MAX), the work budget, and the memo cap. |
memo_table.hpp |
The e_m memo shared by both searches, with second-chance eviction under --memo-cap. |
skip_rule.hpp |
Parses and applies the --skip expressions. |
conditional_file_stream.hpp |
Output stream that can be switched off. |
Everything but main.cpp is a header, so the project builds as one translation
unit. Each header is self-contained and guarded with #pragma once.
- Implement the ring in
rings.hpp. Useclass ringas the checklist of what is required: astatic constexpr int characteristic,order, andunit, a constructor fromint,operator+,operator*, and astatic string name(). Elements are identified with the integers0 .. order - 1, andvaluemust agree with that indexing. - Add the type to
AllRingsinring_registry.hpp. It is then selectable by itsname()via--ring. - Regenerate
tests/ring_goldens.tsvwith./build/dump_ring --all > …, so the new ring is covered by the axiom and stability checks intests/test_rings.py.
Rows are left out with --skip at run time, so a ring needs no hook for that.
A quotient of a polynomial ring needs none of this — see
Runtime rings. Relations mixing variables would mean a Gröbner
basis in buildBasis(); the rest of quotient.hpp is written not to care,
since it only ever asks for the basis and the products of basis monomials.
The code is under the MIT License; the computed tables in
Experimental tables/ are under
CC BY 4.0. Copyright (c) 2026 Iuri Grangeiro
Carvalho.
The split is deliberate: Creative Commons licenses are not intended for software, and a software license sits awkwardly on what is essentially mathematical data. In practice this means you may use the solver freely with attribution preserved in the source, and reuse or republish the tables provided you give credit. See Experimental tables/README.md for a suggested citation.