← Blog

Cayley Tables: Group Algebra in Python Code

Christopher Villamarín

How do you know whether a set with an operation forms a group? The axioms (closure, associativity, identity, inverses) can be checked by hand when the set is small, but manual verification is slow and error-prone. The compact answer fits in a table: the Cayley table, the "multiplication table" of the group.

This is the script I used to verify the alternating group A₄ — the 12 even permutations of {1, 2, 3, 4} — using nothing but Python dicts, tuples and sets. Along the way it covers switching from right-to-left permutation composition (the f ∘ g = f(g(x)) convention) to the course's convention: σ first, then τ.

What is a Cayley table?

A Cayley table shows every possible result of combining two elements of the group:

  • Rows and columns are the elements of the group.
  • Cell (i, j) holds the result of row ∘ column.
  • If the set is a group, no element repeats within any row or any column.

That last property has a name of its own: it is a Latin square. Every element appears exactly once per row and once per column, and it is exactly what the script verifies at the end.

The group A₄: 12 permutations of 4 elements

Each permutation is represented as the tuple of destinations of the numbers 1, 2, 3 and 4. For instance, (2 3 4) means 1→1, 2→3, 3→4, 4→2, so it is written (1, 3, 4, 2).

elementos = {
"ι": (1, 2, 3, 4), # identity (1)(2)(3)(4)
"α": (1, 3, 4, 2), # (2 3 4)
"α²": (1, 4, 2, 3), # (2 4 3)
"β": (2, 1, 4, 3), # (1 2)(3 4)
"γ": (4, 3, 2, 1), # (1 4)(2 3)
"βγ": (3, 4, 1, 2), # (1 3)(2 4)
"αβ": (3, 1, 2, 4), # (1 3 2)
"αγ": (2, 4, 3, 1), # (1 2 4)
"αβγ": (4, 2, 1, 3), # (1 4 3)
"α²β": (4, 1, 3, 2), # (1 4 2)
"α²γ": (3, 2, 4, 1), # (1 3 4)
"α²βγ": (2, 3, 1, 4), # (1 2 3)
}
# Inverse dictionary: look up the name from the resulting tuple
nombre_elemento = {valor: nombre for nombre, valor in elementos.items()}

Composition: postfix notation (σ first, then τ)

Here lies the conceptual core of this post. I originally composed with the classic convention f ∘ g = f(g(x)): the rightmost factor acts first. But the course uses the order of application, so the product is defined as (σ ∘ τ)(x) = τ(σ(x)): apply σ first, then τ.

The formal name of this convention is right operator notation: instead of writing σ(x) you write xσ, and the product evaluates as x ↦ (xσ)τ. It appears in classic texts such as Wielandt's Finite Permutation Groups; it is also known as diagrammatic order. The fun part: switching conventions touched exactly one line of code.

# Before (right → left): f(g(x))
def componer(f, g):
return tuple(f[g[i-1] - 1] for i in (1, 2, 3, 4))
# After (left → right): g(f(x))
def componer(f, g):
return tuple(g[f[i-1] - 1] for i in (1, 2, 3, 4))

The logic of the second version: for each position i, first f[i-1] tells where f sends the numberi, and that result becomes the index into g.

Concrete example: α ∘ α = α²

Following the chain element by element with α = (1, 3, 4, 2):

xα(x)α(α(x))
111
234
342
423

The result (1, 4, 2, 3) is exactly α² = (2 4 3): 2 goes to 3 under the first α, then 3 goes to 4 under the second one. ✓

Building the full table: 12 × 12 = 144 compositions

With the convention settled, the table is just two nested loops. If any composition produced something outside A₄, the inverse dictionary would raise a KeyError: closure gets verified for free.

nombres_ordenados = list(elementos.keys())
tabla_pitagorica = []
for fila_nombre in nombres_ordenados:
fila_resultados = []
f = elementos[fila_nombre]
for col_nombre in nombres_ordenados:
g = elementos[col_nombre]
resultado_tupla = componer(f, g)
# Closure property: if the result is not one of the 12
# elements, this line raises KeyError.
resultado_nombre = nombre_elemento[resultado_tupla]
fila_resultados.append(resultado_nombre)
tabla_pitagorica.append(fila_resultados)

Automatic verification: is it really a group?

The Latin square property is checked by turning every row and column into a set: if it has 12 elements, there are no repeats.

errores = 0
# Check rows
for i, fila in enumerate(tabla_pitagorica):
if len(set(fila)) != 12:
print(f"ERROR in row {nombres_ordenados[i]}: repeated entries.")
errores += 1
# Check columns
for j in range(12):
columna = [tabla_pitagorica[i][j] for i in range(12)]
if len(set(columna)) != 12:
print(f"ERROR in column {nombres_ordenados[j]}: repeated entries.")
errores += 1
if errores == 0:
print("✅ PERFECT LATIN SQUARE: closure holds, no repeats.")

Traceability: printing every composition in cycle notation

To hand in the assignment you must show how each cell was composed, not just the result. This function converts any tuple into cycle notation while omitting fixed points: (1, 3, 4, 2) → (2 3 4).

def perm_a_ciclos(perm):
visitado = [False] * 4
ciclos = []
for i in range(4):
if not visitado[i] and perm[i] != i + 1:
ciclo = []
j = i
while not visitado[j]:
visitado[j] = True
ciclo.append(j + 1)
j = perm[j] - 1
ciclos.append(" ".join(str(x) for x in ciclo))
return "(" + ")(".join(ciclos) + ")" if ciclos else "(1)"
def imprimir_composicion(f_nombre, g_nombre):
resultado = componer(elementos[f_nombre], elementos[g_nombre])
return (
f"{f_nombre} ∘ {g_nombre}: "
f"{perm_a_ciclos(elementos[f_nombre])} "
f"{perm_a_ciclos(elementos[g_nombre])} = "
f"{perm_a_ciclos(resultado)} => "
f"{nombre_elemento[resultado]}"
)
# α ∘ α: (2 3 4) (2 3 4) = (2 4 3) => α²

Exporting to Excel: pandas + openpyxl

The final deliverable was an Excel file with two sheets: the 12×12 table and the 144 detailed, numbered compositions.

import pandas as pd
df_tabla = pd.DataFrame(
tabla_pitagorica,
index=nombres_ordenados,
columns=nombres_ordenados,
)
df_composiciones = pd.DataFrame([
{"N°": n, "Composición": imprimir_composicion(f, c)}
for n, (f, c) in enumerate(
((fn, cn) for fn in nombres_ordenados for cn in nombres_ordenados),
start=1,
)
])
with pd.ExcelWriter("tabla_cayley_A4.xlsx", engine="openpyxl") as writer:
df_tabla.to_excel(writer, sheet_name="Tabla_Cayley")
df_composiciones.to_excel(writer, sheet_name="Composiciones", index=False)

Final result: A₄ verified as a group

  • Closure: every composition lands inside A₄ (no KeyError).
  • Latin square: no repeats per row or column, under either composition convention.
  • Identity: ι acts as the neutral element across the whole table.
  • Inverses: every element pairs with ι somewhere in its row.

A bonus insight from playing with the table: the row of ι together with the rows of β, γ and βγ form the Klein four-group V₄ inside A₄ — the famous normal subgroup that makes A₄ non-simple.

Stack used: minimal, no magic

  • dict + tuple: representing permutations.
  • The componer function: right operator notation.
  • set(): verifying the Latin square.
  • pandas + openpyxl: exporting to Excel.

No algebra libraries: pure Python and the standard library only.

What I take away from this exercise: a permutation is just a tuple of destinations; composing means chaining indexes in a single line; verifying a group means checking that set(row) has 12 elements; and postfix notation makes the writing order match the application order. The code doesn't just compute: it also verifies and explains every step.