The stolen necklace problem presents itself as a simple problem: given a necklace with n beads, each of which can be one of k colors, how many distinct necklaces can be formed?
I found out about this problem while watching a video from 3blue1brown on the Borsuk-Ulam Theorem. The problem is a classic example of how topology can be applied to discrete problems, and it has a surprisingly elegant solution.

In Layman’s Terms
Given what we said above using the variables and , the number of distinct necklaces can be calculated using the formula:
Computationally, we can implement this using the following steps:
quite a long script, so I don’t expect you to read it all, but I will explain the main parts below
from collections import Counter, defaultdict
from typing import List, Dict, Tuple, Optional
Color = str
def validate_even_counts(beads: List[Color]) -> Tuple[bool, Dict[Color, int]]:
counts = Counter(beads)
for c, v in counts.items():
if v % 2 != 0:
return False, counts
return True, counts
def half_targets(counts: Dict[Color, int]) -> Dict[Color, int]:
return {c: v // 2 for c, v in counts.items()}
def prefix_counts(seq: List[Color], colors: List[Color]) -> List[Dict[Color, int]]:
pref = [defaultdict(int)]
cur = defaultdict(int)
for x in seq:
cur = cur.copy()
cur[x] += 1
pref.append(cur)
return [dict(d) for d in pref]
def window_counts(pref: List[Dict[Color, int]], l: int, r: int) -> Dict[Color, int]:
res = {}
for c in pref[0].keys() | pref[-1].keys():
res[c] = pref[r].get(c, 0) - pref[l].get(c, 0)
return res
def two_cut_solution(beads: List[Color], counts: Dict[Color, int]) -> Optional[Dict]:
n = len(beads)
colors = list(counts.keys())
targets = half_targets(counts)
doubled = beads + beads
pref = prefix_counts(doubled, colors)
for start in range(n):
for length in range(1, n):
end = start + length
wc = window_counts(pref, start, end)
if all(wc.get(c, 0) == targets.get(c, 0) for c in colors):
# Two cuts: between (start-1, start) and (end-1, end) mod n
cut1 = (start - 1) % n
cut2 = (end - 1) % n
arc_indices = [(i % n) for i in range(start, end)]
return {
"cuts": sorted({cut1, cut2}),
"assignment": "A gets arc [start,end) along the circle, B gets the complement",
"arc_start": start % n,
"arc_end": end % n,
"verification": wc,
}
return None
def suffix_count_array(beads: List[Color], colors: List[Color]) -> List[Dict[Color, int]]:
n = len(beads)
suf = [defaultdict(int) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
suf[i] = suf[i + 1].copy()
suf[i][beads[i]] += 1
return [dict(d) for d in suf]
def backtrack_min_cuts(
beads: List[Color],
counts: Dict[Color, int],
max_circle_cuts: int,
) -> Optional[Dict]:
n = len(beads)
colors = list(counts.keys())
targets = half_targets(counts)
suf = suffix_count_array(beads, colors)
def feasible_bound(i: int, assignedA: Dict[Color, int]) -> bool:
for c in colors:
if assignedA.get(c, 0) > targets[c]:
return False
if assignedA.get(c, 0) + suf[i].get(c, 0) < targets[c]:
return False
return True
best_solution = None
for line_cuts_cap in range(1, max(1, max_circle_cuts + 1)):
visited = set()
def dfs(i: int, cuts_used: int, ownerA: bool, assignedA: Dict[Color, int], cut_positions_line: List[int]):
nonlocal best_solution
if best_solution is not None:
return
# State pruning key
key = (i, cuts_used, ownerA, tuple(sorted(assignedA.items())))
if key in visited:
return
visited.add(key)
if not feasible_bound(i, assignedA):
return
if i == n:
if all(assignedA.get(c, 0) == targets[c] for c in colors):
circle_cuts = cuts_used + (0 if ownerA else 1) # need a cut at end if owner toggled
if circle_cuts <= max_circle_cuts:
# Build result
cuts_circle = set(cut_positions_line)
if not ownerA:
cuts_circle.add(n - 1) # cut between last and first to close
best_solution = {
"cuts": sorted((x % n) for x in cuts_circle),
"assignment": "A/B alternate segments starting at index 0 with A",
"line_cuts": cuts_used,
"circle_cuts": circle_cuts,
}
return
color_i = beads[i]
if ownerA:
assignedA[color_i] = assignedA.get(color_i, 0) + 1
dfs(i + 1, cuts_used, ownerA, assignedA, cut_positions_line)
if cuts_used < line_cuts_cap:
cut_pos = i # cut between i and i+1
dfs(i + 1, cuts_used + 1, not ownerA, assignedA, cut_positions_line + [cut_pos])
if ownerA:
assignedA[color_i] -= 1
if assignedA[color_i] == 0:
del assignedA[color_i]
dfs(0, 0, True, {}, [])
if best_solution is not None:
return best_solution
return None
def fair_split_beads(beads: List[Color], max_factor: int = 2) -> Dict:
if not beads:
raise ValueError("Beads list is empty")
ok, counts = validate_even_counts(beads)
if not ok:
raise ValueError(f"Impossible: some color counts are odd: {counts}")
k = len(counts)
# two cut
two = two_cut_solution(beads, counts)
if two is not None:
two["method"] = "two-cuts"
two["circle_cuts"] = 2
return two
cap = max(2, max_factor * k)
for C in range(2, cap + 1): # search increasing cut budgets
res = backtrack_min_cuts(beads, counts, max_circle_cuts=C)
if res is not None:
res["method"] = "backtracking"
return res
raise ValueError("No fair split found within the search limits. Consider increasing max_factor or check input size.")
if __name__ == "__main__":
try:
beads # type: ignore # noqa: F821
except NameError:
beads = [
"Ruby", "Emerald", "Sapphire", "Ruby", "Emerald", "Sapphire",
"Ruby", "Emerald", "Sapphire", "Ruby", "Emerald", "Sapphire",
"Ruby", "Emerald", "Sapphire", "Ruby", "Emerald", "Sapphire",
"Ruby", "Emerald", "Sapphire", "Ruby", "Emerald", "Sapphire",
]
print(f"Total beads: {len(beads)}")
counts = Counter(beads)
print("Counts per color:", dict(counts))
try:
result = fair_split_beads(beads)
print("Method:", result.get("method"))
print("Circle cuts:", result.get("circle_cuts"))
print("Cut positions (between i and i+1 mod n):", result.get("cuts"))
if result.get("method") == "two-cuts":
print("Arc start:", result.get("arc_start"), "Arc end:", result.get("arc_end"))
print("Assignment rule:", result.get("assignment"))
n = len(beads)
cuts = set(result.get("cuts", []))
ownerA = True
assignedA = Counter()
for i in range(n):
if ownerA:
assignedA[beads[i]] += 1
if i in cuts:
ownerA = not ownerA
if (n - 1) in cuts:
ownerA = not ownerA
print("Verification A counts:", dict(assignedA))
print("Target half counts:", half_targets(Counter(beads)))
ok = all(assignedA.get(c, 0) == counts[c] // 2 for c in counts)
print("Fair split verified:", ok)
except ValueError as e:
print("Error:", e)How it Works
- Sanity check: every color shows up an even number of times, otherwise a perfect split is impossible.
- Quick win: try a two‑cut solution by sliding an arc around the circle; if an arc contains exactly half of each color, we’re done.
- If that fails: run a bounded backtracking search that alternates ownership across segments and prunes branches that can’t possibly hit the half‑counts, increasing the cut budget until something fits.
- Output: cut indices (between i and i+1 mod n), method used, and a tiny verification that simulates a lap around the necklace.
But this computation isnt really following the Borsuk-Ulam theorem, which states that any continuous function from an n-sphere to R^n must have a pair of antipodal points that map to the same value. The necklace problem is a discrete version of this, where we are looking for pairs of points (cuts) that yield the same color distribution.
The Full Explanation
todo… will reference video and minimizing function… Borsuk-Ulam Theorem