Problem 61 — Cyclical Figurate Numbers

solved Tue, 10 Mar 2020, 15:56 · level 2 · difficulty 7% projecteuler.net github

Triangle, square, pentagonal, hexagonal, heptagonal, and octagonal numbers are all figurate (polygonal) numbers and are generated by the following formulae:

Triangle   $P_{3,n}=n(n+1)/2$   $1, 3, 6, 10, 15, \dots$
Square   $P_{4,n}=n^2$   $1, 4, 9, 16, 25, \dots$
Pentagonal   $P_{5,n}=n(3n-1)/2$   $1, 5, 12, 22, 35, \dots$
Hexagonal   $P_{6,n}=n(2n-1)$   $1, 6, 15, 28, 45, \dots$
Heptagonal   $P_{7,n}=n(5n-3)/2$   $1, 7, 18, 34, 55, \dots$
Octagonal   $P_{8,n}=n(3n-2)$   $1, 8, 21, 40, 65, \dots$

The ordered set of three $4$-digit numbers: $8128$, $2882$, $8281$, has three interesting properties.

  1. The set is cyclic, in that the last two digits of each number is the first two digits of the next number (including the last number with the first).
  2. Each polygonal type: triangle ($P_{3,127}=8128$), square ($P_{4,91}=8281$), and pentagonal ($P_{5,44}=2882$), is represented by a different number in the set.
  3. This is the only set of $4$-digit numbers with this property.

Find the sum of the only ordered set of six cyclic $4$-digit numbers for which each polygonal type: triangle, square, pentagonal, hexagonal, heptagonal, and octagonal, is represented by a different number in the set.


Problem Analysis

The surface of this problem is number theory: figurate (polygonal) numbers generated by simple closed-form formulas such as the triangle numbers n(n+1)/2 and the octagonal numbers n(3n-2). But the real structure is combinatorial. I need an ordered set of six 4-digit numbers, one drawn from each of six polygon families, in which the last two digits of each number equal the first two digits of the next, and the chain closes back on itself. The phrase "last two digits link to the first two digits" is the decisive clue: this is a graph problem in disguise. If I treat each 4-digit figurate number as a node and draw a directed edge from A to B whenever A mod 100 == B div 100, then a valid answer is a cycle of length six in this graph whose members collectively cover all six polygon types.

Two observations make the problem tractable. First, the candidate set is tiny: each family contributes only a few dozen values between 1000 and 9999, so the whole graph has fewer than 200 nodes and the average out-degree (the number of successors per node) is small. Second, because each node carries one or more polygon-type labels and I want exactly one node per type, the search is naturally bounded to paths of length six. A depth-first search (DFS) that extends paths node by node, pruning when it would revisit a node, suffices. The worst-case bound is O(VL) for paths of length L in a graph of V nodes, but the short successor lists collapse this to a near-instant search in practice - the recorded times are 0.0009 s in C and 0.0233 s in Python on the full six-family case. The one subtlety that demands care is that some numbers belong to more than one polygon family (every hexagonal number is also triangular, for instance), so the type-coverage check is not simply "six distinct types appear."

Solution Approach

Graph-Based DFS with Polygon-Type Verification (C and Python)

Both files implement the same algorithm. I describe it once and note the language-specific differences afterwards.

Step 1 - generate figurate numbers. For each active polygon type I enumerate every 4-digit value its formula produces. In C I iterate n from 1, skip forward until the value reaches 1000, then collect values until they exceed 9999. In Python I do the same but seed the loop at the right index by inverting the formula: for triangle numbers, P(n) = n(n+1)/2 inverts to n = (-1 + sqrt(1+8P))/2, so I begin the generator at the floor of that value rather than from 1. The ranges are so small that the inverse is a refinement of style rather than a necessity, but it is the principled way to start a monotone sequence at a threshold.

Step 2 - build the successor graph. I collect all distinct 4-digit figurate values into a node list, then add a directed edge from node A to node B whenever A % 100 == B / 100. I additionally require the shared prefix to be at least 10: a two-digit prefix below 10 (like 05) cannot be the first two digits of a genuine 4-digit number, so those links would be spurious. C stores the graph as a fixed-size 2D array successors[i][] with a parallel succ_counts[] array; Python uses a dict mapping each number to a list of successors.

Step 3 - DFS for closed cycles. Starting from every node in turn, I walk the graph depth-first, maintaining the current path and a visited marker. When the path reaches the target length I check whether the final node links back to the start (cur % 100 == start / 100); if so I have a closed cycle. The visited marker guarantees all members of the path are distinct, which the problem requires. The marker is set on entry and cleared on exit - the standard backtracking discipline.

Step 4 - verify type coverage. A candidate cycle is valid only if each polygon type is represented by exactly one member, accounting for numbers that belong to several families. My check first counts, for each type, how many cycle members belong to it, and rejects the cycle if any type has zero members. Then it builds the reverse map - for each cycle member, which types it belongs to - and flags a conflict if any multi-type number is the sole representative of every one of its types simultaneously (which would mean those types cannot all be filled). The C version expresses this with small explicit loops; the Python version builds two dictionaries (type-to-members and member-to-types) and applies the same test.

Step 5 - deduplicate and report. Because the DFS starts from every node, the same unordered set is discovered once per starting point. I canonicalise each cycle by sorting its members, then store only unique sorted sets - Python uses a set of sorted tuples for O(1) deduplication, while C sorts each candidate with an insertion sort and linearly scans previously stored results. The final output reports how many valid sets were found and their sums.

Language differences. Python leans on generators, an enum for the polygon types, a decorator for input validation, and dict/set comprehensions, all of which make the code expressive and self-documenting. C uses static arrays sized by #define constants and an array of function pointers to drive a single generation loop over all six formulas. The algorithms are identical, so the roughly 25x speed gap (0.0009 s versus 0.0233 s on the main case) is pure interpreter overhead.

workbench

Test cases

categoryinputanswer
dev length = 2 [1, 8181]
dev length = 3 [1, 19291]
dev length = 4 [2, [12524, 16160]]
dev length = 5 [5, [12019, 15857, 18685, 19493, 26058]]
main length = 6 [1, 28684]

Results

solutioncategoryanswerverdict avg seconds cavg seconds py
p0061_s0 dev [1, 8181] correct 0.000037 (63) 0.000841 (63)
p0061_s0 dev [1, 19291] correct 0.000074 (63) 0.001596 (63)
p0061_s0 dev [2, [12524, 16160]] correct 0.000082 (63) 0.002217 (63)
p0061_s0 dev [5, [12019, 15857, 18685, 19493, 26058]] correct 0.000247 (63) 0.007352 (63)
p0061_s0 main [1, 28684] correct 0.001299 (63) 0.026140 (63)

Files

Tags & blogs