Chapter 192
The Perfect Doodle Puzzle To Keep You Busy During Boring Meetings
Riddler Express
A centrifuge needs to be perfectly balanced along every axis before being run; otherwise, the torque will damage the internal rotor. If a centrifuge has equally spaced buckets, some of which you fill with samples of equal weight, for what values of can all the samples be spun safely? Hint: you can run seven samples in a -bucket centrifuge, but you cannot run ten in a -bucket centrifuge.
The Riddler, FiveThirtyEight, July 27, 2018(original post)
Solution
Place the buckets at the -th roots of unity in the complex plane: position is the point where , for . The centrifuge is balanced exactly when the centre of mass of the filled buckets sits at the origin, that is, when the chosen subset satisfies So the question is purely combinatorial: which subset sizes are realisable as a zero-sum multiset of -th roots of unity?
Two building blocks work straight away. First, if is a prime divisor of , the evenly spaced positions form a regular -gon centred at the origin, so samples balance. Second, if one subset of size balances and a disjoint subset of size balances, their union balances too, contributing samples. By picking different regular -gons (rotating them to avoid overlap, which is always possible when there is room) we can realise any sum of prime divisors of .
The converse is the heart of the matter and is a classical result on vanishing sums of roots of unity, due to Rédei and Lam-Leung: a multiset of -th roots of unity sums to zero if and only if it decomposes into regular -gons for primes . Counting samples, this is the same as saying is expressible as a non-negative integer combination of the prime divisors of . Apply the same criterion to the unfilled buckets to get the symmetric requirement on .
The two hint cases drop out: for , the prime divisors are , and with , so seven samples work. For , the prime divisors are , and although , the complement is not a non-negative combination of and , so ten samples cannot balance. Two edge cases also follow: and never balance (a single sample is off-centre by itself, and so is a single hole), and a prime admits no balanced load other than the trivial and .
The computation
Verify the criterion exhaustively against the geometric definition for all with : for each -subset of the roots of unity, check whether its centroid is at the origin, and compare with the prime-decomposition criterion.
import itertools
from cmath import exp, pi
def primes(n):
ps, m, d = set(), n, 2
while d * d <= m:
while m % d == 0:
ps.add(d); m //= d
d += 1
if m > 1: ps.add(m)
return sorted(ps)
def reachable(target, ps):
R = [False] * (target + 1); R[0] = True
for p in ps:
for v in range(p, target + 1):
if R[v - p]: R[v] = True
return R[target]
def by_criterion(N, K):
ps = primes(N)
return reachable(K, ps) and reachable(N - K, ps)
def by_brute(N, K):
if K in (0, N): return True
roots = [exp(2j * pi * k / N) for k in range(N)]
for S in itertools.combinations(range(N), K):
if abs(sum(roots[k] for k in S)) < 1e-9:
return True
return False
bad = 0
for N in range(2, 13):
for K in range(0, N + 1):
if by_criterion(N, K) != by_brute(N, K):
bad += 1
print(f"mismatches for N=2..12: {bad}")
print(f"(N=12, K=7) -> {by_criterion(12, 7)}")
print(f"(N=21, K=10) -> {by_criterion(21, 10)}")
The script prints mismatches, confirms the case as balanceable, and confirms the case as not.
Riddler Classic
Start with an empty grid of squares. Choose any square as your starting square. From any square you may move exactly three cells horizontally or vertically, or exactly two cells diagonally; you may not visit any cell twice; you may not step off the grid. You win if you visit all cells. Is it possible to win? If so, how many winning tours are there? And what is the shortest dead-end you can be forced into?
The Riddler, FiveThirtyEight, July 27, 2018(original post)
Solution
The legal moves from a square at are the eight offsets , , . A Hamiltonian path is a sequence of distinct squares connected by these moves that visits all cells. The state space is small enough to enumerate by depth-first search with backtracking. There are at most choices for the starting square and at most moves at each step, so the worst-case tree has size bounded above by , but most branches die quickly because of the no-revisit rule and the boundary.
A backtracking search returns The starting square matters: there are winning tours from each corner, from each edge midpoint not adjacent to a corner, or from the other boundary squares, from the second-ring corners, from the second-ring edge midpoints, and only from the centre. The centre is the worst start, with fewer tours than a corner.
Two structural features make the centre weak. First, both diagonal moves from the centre land on second-ring corners, which is where the moves concentrate, so the centre’s neighbourhood is small (six legal moves). Second, the move graph has a parity: colour the squares with , and the orthogonal move keeps the colour while the diagonal move flips it. A tour of all squares hits of one colour and of the other, and the parity sequence imposed by the move sequence has to match; starts in the centre give fewer parity-respecting completions.
A second backtracking pass tracks the shortest dead-end length, where a dead-end is a partial tour with no legal next move. The shortest dead-end has length , reached by distinct partial tours.
The computation
Two depth-first searches, one for the tour count and one for the shortest dead-end length.
For every starting square , depth-first-search all Hamiltonian paths, incrementing a global counter on each success.
Track, across all runs, the shortest partial tour length at which no legal next move exists.
N = 5
MOVES = [(3, 0), (-3, 0), (0, 3), (0, -3),
(2, 2), (2, -2), (-2, 2), (-2, -2)]
total = 0
per_start = {}
shortest_stuck = 25
def dfs(r, c, visited):
global total, shortest_stuck
if len(visited) == 25:
total += 1; return
moved = False
for dr, dc in MOVES:
nr, nc = r + dr, c + dc
if 0 <= nr < N and 0 <= nc < N and (nr, nc) not in visited:
moved = True
visited.add((nr, nc))
dfs(nr, nc, visited)
visited.remove((nr, nc))
if not moved and len(visited) < shortest_stuck:
shortest_stuck = len(visited)
for sr in range(N):
for sc in range(N):
before = total
dfs(sr, sc, {(sr, sc)})
per_start[(sr, sc)] = total - before
print(f"total Hamiltonian tours: {total}")
print(f"shortest dead-end length: {shortest_stuck}")
print("tours per start square (rows top->bottom):")
for sr in range(N):
print([per_start[(sr, sc)] for sc in range(N)])
The script prints tours total, shortest dead-end length , and the per-start table given above with at corners and in the centre.