Group theory
Cyclic groups, Cayley tables, dihedral groups, generators and subgroups, Lagrange's theorem, permutation composition, homomorphism kernels, quotient groups, subgroup lattices and orbit-stabilizer.
This page lists 12 examples. The code is imported from corpus/examples/math-group/; the screenshots are end states rendered on real machines.
Cyclic group C4: four rotations of a square return to the start
Four squares in a row with a red dot marking the tracked corner: each 90° counter-clockwise turn moves the dot to the next corner; R90 twice lands exactly on R180 and one more turn after R270 returns to R0.

"""循环群 C4:正方形旋转 4 次回到原样
@expect 四个正方形排成一行,红点标记「被跟踪的那个角」:每次逆时针转 90°,红点依次走到四个角;R90 连着做两次正好落在 R180 上,R270 再转一次回到 R0。
@note 讲解要点:C4 = {e, r, r², r³} 里真正独立的只有 4 个元素,r⁴ = e。这就是「有限群」的样子——做下去不会跑出有限的几个状态,而是绕回原点。
"""
from manim import *
import math
class CyclicRotationGroupScene(Scene):
def construct(self):
side = 1.7
half = side / 2
corner = [half, half * 0.35]
xs = [-4.7, -1.57, 1.57, 4.7]
names = ["R0 = e", "R90", "R180", "R270"]
headings = VGroup(
*[Text(n, font_size=26, color=YELLOW).move_to([x, 2.6, 0]) for x, n in zip(xs, names)]
)
states = VGroup()
marks = []
for idx, x in enumerate(xs):
ang = idx * math.pi / 2
square = Square(side_length=side, color=BLUE).move_to([x, 1.2, 0])
dx = corner[0] * math.cos(ang) - corner[1] * math.sin(ang)
dy = corner[0] * math.sin(ang) + corner[1] * math.cos(ang)
arm = Line([x, 1.2, 0], [x + dx, 1.2 + dy, 0], color=GRAY_A, stroke_width=2)
mark = Dot([x + dx, 1.2 + dy, 0], radius=0.11, color=RED)
states.add(VGroup(square, arm, mark))
marks.append(mark) # 要单独点名某个物体时,另存一份普通 list,别靠 VGroup 下标
steps = VGroup()
for i in range(3):
steps.add(Arrow([xs[i] + half + 0.12, 1.2, 0], [xs[i + 1] - half - 0.12, 1.2, 0], color=GREEN, buff=0))
step_label = Text("再转 90°", font_size=22, color=GREEN).move_to([0, 0.0, 0])
wrap = VGroup(
Line([xs[3], 0.2, 0], [xs[3], -0.75, 0], color=GREEN, stroke_width=2.5),
Line([xs[3], -0.75, 0], [xs[0], -0.75, 0], color=GREEN, stroke_width=2.5),
)
wrap_arrow = Arrow([-1.4, -0.75, 0], [xs[0], -0.75, 0], color=GREEN, buff=0)
wrap_label = Text("转满 4 次回到 e", font_size=22, color=GREEN).move_to([0, -1.35, 0])
formula = MathTex(r"C_4 = \{e,\ r,\ r^2,\ r^3\},\qquad r^4 = e", font_size=46)
formula.to_edge(UP, buff=0.3)
self.play(Write(formula), run_time=0.8)
self.play(LaggedStart(*[FadeIn(s) for s in states], lag_ratio=0.18), run_time=1.6)
self.play(Write(headings), run_time=0.7)
self.play(LaggedStart(*[Create(a) for a in steps], lag_ratio=0.25), Write(step_label), run_time=1.2)
self.play(Create(wrap), Create(wrap_arrow), Write(wrap_label), run_time=1.0)
# 强调某一态用 Flash(只往画面上加临时线,不碰物体本身)——
# Indicate 会把组里的描边/填充改残(实测 R180 的方框被冲白、红点消失)
self.play(Flash(marks[2], color=RED, line_length=0.32), run_time=0.7)
self.wait(0.4)Cayley table: one table from which the three group axioms read off
The 4×4 addition table of (Z/4, +) filled in: the first row (the identity e=0 row) matches the header exactly, and the paired 1↔3, 2↔2 near the main diagonal are mutual inverses — no empty cell, so the operation is closed.

"""Cayley 表:一张表就把群的三条公理读出来
@expect 4×4 的加法表填满 (Z/4, +):第一行(单位元 e=0 那一行)与表头完全一致,主对角线附近成对出现的 1↔3、2↔2 互为逆元——表里没有空格,说明运算封闭。
@note 讲解要点:Cayley 表是群的「乘法口诀」。只要表是封闭的、每行每列都是表头的一个排列、且有一行与表头相同,这个集合就自动满足群的四条公理。
"""
from manim import *
class CayleyTableScene(Scene):
def construct(self):
n = 4
cell = 0.85
total = (n + 1) * cell
center = [0, -0.3, 0]
top = center[1] + total / 2
left = center[0] - total / 2
def pos(row, col):
return [left + cell * (col + 0.5), top - cell * (row + 0.5), 0]
cells = VGroup()
nums = VGroup()
for row in range(n + 1):
for col in range(n + 1):
box = Square(side_length=cell, color=GRAY_B, stroke_width=1.5)
box.move_to(pos(row, col))
cells.add(box)
headers = VGroup()
for col in range(n):
headers.add(Text(str(col), font_size=28, color=YELLOW).move_to(pos(0, col + 1)))
headers.add(Text(str(col), font_size=28, color=YELLOW).move_to(pos(col + 1, 0)))
identity_row = VGroup()
for col in range(n):
value = col
num = Text(str(value), font_size=28, color=GREEN).move_to(pos(1, col + 1))
identity_row.add(num)
nums.add(num)
# (1,3) 与 (3,1) 互为逆元:直接把这两格的数字染红,不要再叠一层字
inverse_keys = [(1, 3), (3, 1)]
for row in range(1, n):
for col in range(n):
value = (row + col) % n
if (row, col) in inverse_keys:
color = RED
elif value == 0:
color = WHITE
else:
color = GRAY_A
nums.add(Text(str(value), font_size=28, color=color).move_to(pos(row + 1, col + 1)))
inverse_sym = Text("1 + 3 = 3 + 1 = 0,互为逆元;2 + 2 = 0,2 自己是自己的逆", font_size=24, color=RED)
inverse_sym.move_to([0, -3.4, 0])
corner = Text("+", font_size=30, color=GRAY_A).move_to(pos(0, 0))
formula = MathTex(r"(G, +) = (Z/4, +)", font_size=44).to_edge(UP, buff=0.3)
caption = Text("第一行 = 表头(e 行),每条对角线都成逆元对,表里无空格 = 封闭", font_size=24, color=GRAY_A)
caption.move_to([0, 2.35, 0])
self.play(Write(formula), run_time=0.7)
self.play(Create(cells), Write(headers), Write(corner), run_time=1.0)
self.play(LaggedStart(*[FadeIn(x) for x in nums], lag_ratio=0.03), run_time=1.6)
self.play(Indicate(identity_row, color=GREEN, scale_factor=1.06), run_time=0.8)
self.play(Write(inverse_sym), run_time=0.7)
self.play(Write(caption), run_time=0.8)
self.wait(0.4)Dihedral group D4: the 8 symmetries of a square (4 rotations + 4 reflections)
The top row shows rotations by 0° / 90° / 180° / 270°, the bottom row the reflections across the vertical axis, the horizontal axis and the two diagonals; the marked point lands on 8 distinct positions, proving the 8 transformations are pairwise different.

"""二面体群 D4:正方形的 8 个对称(4 旋转 + 4 镜像)
@expect 上排 4 个正方形分别是转 0° / 90° / 180° / 270°,下排 4 个是沿竖轴、横轴、两条对角线的镜像;标记点 8 次落在 8 个互不相同的位置,说明这 8 个变换两两不等。
@note 讲解要点:D4 的大小不是 4 而是 8——除了旋转还必须数上镜像。正方形有 8 个对称,正 n 边形有 2n 个;这也解释了为什么「转一下」和「翻一下」在某些顺序下不能交换(非交换群的最小样本)。
"""
from manim import *
import math
class DihedralSymmetryScene(Scene):
def construct(self):
half = 0.62
mark_home = [half, half * 0.3]
xs_top = [-4.8, -1.6, 1.6, 4.8]
xs_bot = [-4.8, -1.6, 1.6, 4.8]
y_top = 1.2
y_bot = -1.7
def place(center, dx, dy, axis=None):
square = Square(side_length=half * 2, color=BLUE).move_to(center)
arm = Line(center, [center[0] + dx, center[1] + dy, 0], color=GRAY_A, stroke_width=2)
mark = Dot([center[0] + dx, center[1] + dy, 0], radius=0.09, color=RED)
group = VGroup(square, arm, mark)
if axis == "v":
group.add(DashedLine([center[0], center[1] - half, 0], [center[0], center[1] + half, 0], color=YELLOW))
elif axis == "h":
group.add(DashedLine([center[0] - half, center[1], 0], [center[0] + half, center[1], 0], color=YELLOW))
elif axis == "d":
group.add(
DashedLine([center[0] - half, center[1] - half, 0], [center[0] + half, center[1] + half, 0], color=YELLOW)
)
elif axis == "a":
group.add(
DashedLine([center[0] - half, center[1] + half, 0], [center[0] + half, center[1] - half, 0], color=YELLOW)
)
return group
rotations = VGroup()
rot_names = VGroup()
for idx, x in enumerate(xs_top):
ang = idx * math.pi / 2
dx = mark_home[0] * math.cos(ang) - mark_home[1] * math.sin(ang)
dy = mark_home[0] * math.sin(ang) + mark_home[1] * math.cos(ang)
rotations.add(place([x, y_top, 0], dx, dy))
rot_names.add(Text(f"旋转 {idx * 90}°", font_size=22, color=GREEN).move_to([x, y_top + 1.15, 0]))
reflections = VGroup()
ref_names = VGroup()
mx, my = mark_home
# 反射后的标记坐标:竖轴 (-x, y)、横轴 (x, -y)、对角 y=x 取 (y, x)、反对角 y=-x 取 (-y, -x)
specs = [("v", -mx, my, "竖轴镜像"), ("h", mx, -my, "横轴镜像"),
("d", my, mx, "对角镜像"), ("a", -my, -mx, "反对角镜像")]
for x, (axis, dx, dy, label) in zip(xs_bot, specs):
reflections.add(place([x, y_bot, 0], dx, dy, axis=axis))
ref_names.add(Text(label, font_size=22, color=YELLOW).move_to([x, y_bot - 1.15, 0]))
formula = MathTex(r"|D_4| = 2 \times 4 = 8", font_size=46).move_to([0, 3.4, 0])
caption = Text("正 n 边形有 2n 个对称:n 个旋转 + n 个镜像", font_size=25, color=GRAY_A)
caption.move_to([0, 2.9, 0])
self.play(Write(formula), Write(caption), run_time=0.8)
self.play(LaggedStart(*[FadeIn(g) for g in rotations], lag_ratio=0.2), run_time=1.4)
self.play(Write(rot_names), run_time=0.7)
self.play(LaggedStart(*[FadeIn(g) for g in reflections], lag_ratio=0.2), run_time=1.4)
self.play(Write(ref_names), run_time=0.7)
self.wait(0.4)Multiplicative group (Z/7)*: generators and orders at a glance
Two circles carry the same six elements 1–6. The left connects arrows by multiply by 2 mod 7 and walks out two disconnected small cycles; the right connects by multiply by 3 mod 7 and one line threads all six elements back to the start.

"""乘法群 (Z/7)*:谁是生成元,一眼看出阶
@expect 左右两个圆圈上同样摆着 1~6 六个元素。左边按「乘 2 取模 7」连箭头,走出两个互不相通的小环;右边按「乘 3 取模 7」连箭头,一根线串起全部六个元素最后回到起点。
@note 讲解要点:ord(2)=3、ord(3)=6——3 是本原根,(Z/7)* 由它单独生成。群论里「生成元」不是「能参与运算的元素」,而是「一个人就能把全群跑一遍的元素」,数量往往远少于群的大小。
"""
from manim import *
import math
class ModularMultiplicationScene(Scene):
def construct(self):
values = [1, 2, 3, 4, 5, 6]
def position(center, v):
ang = math.pi / 2 - (v - 1) * math.pi / 3
return [center[0] + 1.95 * math.cos(ang), center[1] + 1.95 * math.sin(ang), 0]
def build(center, gen, color):
ring = Circle(radius=1.95, color=GRAY_B, stroke_width=2).move_to(center)
nodes = VGroup(*[Dot(position(center, v), radius=0.09, color=WHITE) for v in values])
labels = VGroup(
*[
Text(str(v), font_size=26, color=color).move_to(
[
center[0] + 2.5 * math.cos(math.pi / 2 - (v - 1) * math.pi / 3),
center[1] + 2.5 * math.sin(math.pi / 2 - (v - 1) * math.pi / 3),
0,
]
)
for v in values
]
)
arrows = VGroup(
*[
Arrow(
position(center, v),
position(center, (v * gen) % 7),
color=color,
buff=0.18,
stroke_width=3,
max_tip_length_to_length_ratio=0.09,
)
for v in values
]
)
return VGroup(ring, arrows, nodes, labels)
left = build([-3.6, -0.45, 0], 2, BLUE)
right = build([3.6, -0.45, 0], 3, YELLOW)
left_note = Text("×2 mod 7:两个 3-循环,ord(2)=3", font_size=25, color=BLUE)
left_note.move_to([-3.6, -3.35, 0])
right_note = Text("×3 mod 7:单个 6-循环,ord(3)=6", font_size=25, color=YELLOW)
right_note.move_to([3.6, -3.35, 0])
title = Text("生成元:一个人就能把整个群跑一遍", font_size=30).to_edge(UP, buff=0.3)
caption = Text("3 是本原根——它的幂依次踩过 1~6 每一个非零元", font_size=25, color=GRAY_A)
caption.move_to([0, 2.6, 0])
self.play(Write(title), Write(caption), run_time=0.9)
self.play(FadeIn(left[0]), FadeIn(left[2]), FadeIn(right[0]), FadeIn(right[2]), run_time=0.8)
self.play(Write(left[3]), Write(right[3]), run_time=0.8)
self.play(LaggedStart(*[Create(a) for a in left[1]], lag_ratio=0.12), run_time=1.3)
self.play(Create(left_note), run_time=0.6)
self.play(LaggedStart(*[Create(a) for a in right[1]], lag_ratio=0.12), run_time=1.6)
self.play(Create(right_note), run_time=0.6)
self.wait(0.4)Subgroups generated by a generator: the order always divides the group order
The top row of 12 dots is all of (Z/12, +); the four rows below light up the dots reachable by ⟨1⟩ ⟨2⟩ ⟨3⟩ ⟨4⟩ — 12, 6, 4 and 3 dots in turn, the rest staying dim.

"""由生成元「带出来」的子群,阶永远整除全群
@expect 最上一行 12 个点代表 (Z/12, +) 的全部元素;下面四行分别把 ⟨1⟩ ⟨2⟩ ⟨3⟩ ⟨4⟩ 能到达的点点亮——依次点亮 12、6、4、3 个点,其余保持暗淡。
@note 讲解要点:重复加 g 只能踩到「间距为 gcd(12,g) 的那一组点」,所以 ⟨g⟩ 的大小是 12/gcd(12,g),而它必然整除 12。这正是拉格朗日定理在循环群上的具体样子。
"""
from manim import *
import math
class GeneratorSubgroupsScene(Scene):
def construct(self):
n = 12
gap = 0.8
xs = [-4.4 + i * gap for i in range(n)]
def make_row(y, gen):
hit = {0}
cur = 0
while True:
cur = (cur + gen) % n
if cur in hit:
break
hit.add(cur)
reached = VGroup()
unreached = VGroup()
for i in range(n):
dot = Dot([xs[i], y, 0], radius=0.11)
if i in hit:
dot.set_color(YELLOW)
reached.add(dot)
else:
dot.set_color(GRAY_D)
unreached.add(dot)
return unreached, reached, len(hit)
top_unreached, top_reached, _ = make_row(2.15, 1)
tick_labels = VGroup(
*[Text(str(i), font_size=20, color=GRAY_A).move_to([xs[i], 2.62, 0]) for i in range(n)]
)
rows = VGroup()
labels = VGroup()
for y, gen in [(1.45, 1), (0.75, 2), (0.05, 3), (-0.65, 4)]:
unreached, reached, size = make_row(y, gen)
rows.add(VGroup(unreached, reached))
labels.add(Text(f"⟨{gen}⟩ → {size} 个", font_size=23, color=YELLOW).move_to([5.85, y, 0]))
group_label = Text("G = Z/12:12 个元素", font_size=25, color=WHITE).move_to([0, 3.05, 0])
formula = MathTex(r"|\langle g\rangle| = \frac{12}{\gcd(12,\, g)}", font_size=48)
formula.move_to([0, -1.75, 0])
caption = Text("12 / 6 / 4 / 3 —— 全都整除 12,一个例外都没有", font_size=25, color=GREEN)
caption.move_to([0, -2.75, 0])
self.play(Write(group_label), run_time=0.6)
self.play(FadeIn(top_unreached), FadeIn(top_reached), Write(tick_labels), run_time=1.0)
self.play(LaggedStart(*[FadeIn(r) for r in rows], lag_ratio=0.3), run_time=1.8)
self.play(Write(labels), run_time=0.9)
self.play(Write(formula), run_time=0.8)
self.play(Write(caption), run_time=0.7)
self.wait(0.4)Lagrange's theorem: cosets partition the group, blocks × block size = group size
Twelve elements in a row coloured green / yellow / blue by coset — exactly 4 per colour, 12 across three colours with no overlap; the colours alternate along the row (0 green 1 yellow 2 blue 3 green…), showing cosets interleave without ever sharing an element.

"""拉格朗日定理:子群把群等分成若干块,块数 × 块大小 = 群大小
@expect 12 个元素排成一行,按所属陪集染成绿 / 黄 / 蓝三种颜色——同色恰好 4 个、三种颜色共 12 个且互不重叠;配色在行内是交错的(0 绿 1 黄 2 蓝 3 绿…),说明陪集互相穿插却从不共用元素。
@note 讲解要点:陪集 aH 就是「把 H 整体平移 a」。平移是双射,所以每块和 H 一样大;互不相交又恰好铺满 G,于是 |G| = 块数 × |H|(拉格朗日定理),子群阶必然整除群阶。
"""
from manim import *
class LagrangeTheoremScene(Scene):
def construct(self):
n = 12
gap = 0.78
xs = [-4.3 + i * gap for i in range(n)]
y = 0.5
coset_colors = [GREEN, YELLOW, BLUE]
dots = VGroup()
# 点要按陪集改色:放进普通 Python 列表再按下标取(VGroup 的非字面量下标取不到子物体)
dot_list = []
numbers = VGroup()
for i in range(n):
dot = Dot([xs[i], y, 0], radius=0.14, color=WHITE)
dots.add(dot)
dot_list.append(dot)
numbers.add(Text(str(i), font_size=22, color=GRAY_A).move_to([xs[i], y + 0.42, 0]))
for i in range(n):
dot_list[i].set_color(coset_colors[i % 3])
h_label = Text("H = ⟨3⟩ = {0, 3, 6, 9}", font_size=25, color=GREEN).move_to([-5.3, 2.4, 0])
group_label = Text("G = Z/12", font_size=25, color=WHITE).move_to([5.3, 2.4, 0])
legend = VGroup(
Text("0 + H", font_size=26, color=GREEN).move_to([-3.0, -0.9, 0]),
Text("1 + H", font_size=26, color=YELLOW).move_to([0.0, -0.9, 0]),
Text("2 + H", font_size=26, color=BLUE).move_to([3.0, -0.9, 0]),
)
legend_note = Text("同色 = 同一陪集(12 个元素被切成 3 块,每块 4 个)", font_size=24, color=GRAY_A)
legend_note.move_to([0, -1.55, 0])
formula = MathTex(
r"|G| = [G : H] \times |H| \qquad 12 = 3 \times 4",
font_size=46,
).move_to([0, -2.55, 0])
caption = Text("每块都和 H 一样大,三块拼满全群——所以 4 必须整除 12", font_size=25, color=GRAY_A)
caption.move_to([0, -3.45, 0])
self.play(FadeIn(dots), Write(numbers), run_time=1.0)
self.play(Write(group_label), Write(h_label), run_time=0.6)
self.play(Write(legend), run_time=0.7)
self.play(Write(legend_note), run_time=0.7)
self.play(Write(formula), run_time=0.8)
self.play(Write(caption), run_time=0.7)
self.wait(0.4)Composing permutations: order matters
Four rows of three ball positions: the top two rows work out σ then τ step by step, the bottom row swaps to τ then σ; the two final colour orders differ visibly — same set, same operation, different order, different answer.

"""置换的复合:先做谁、后做谁,结果不一样
@expect 四行三格的球位,上两行把「先 σ 后 τ」一步步做出来,最下一行换成「先 τ 后 σ」;两行终态的颜色排列明显不同——同一个集合、同一个运算,交换顺序就换了答案。
@note 讲解要点:置换群 S3 是最小的非交换群。σ 是三轮换、τ 是对换,τ∘σ ≠ σ∘τ,这就是「不对称(非交换)」的入门例子,也是矩阵乘法不可交换的同源现象。
"""
from manim import *
class PermutationCompositionScene(Scene):
def construct(self):
slots = [-2.2, 0.0, 2.2]
ball_r = 0.34
rows = [2.0, 0.8, -0.45, -2.1]
def make_row(y, palette, row_label, highlight=False):
group = VGroup()
for x, color in zip(slots, palette):
ball = Circle(radius=ball_r, color=WHITE if not highlight else YELLOW)
ball.set_fill(color, opacity=0.85)
ball.move_to([x, y, 0])
group.add(ball)
tag = Text(row_label, font_size=24, color=YELLOW if highlight else GRAY_A)
tag.move_to([-4.9, y, 0])
return VGroup(group, tag)
start = [RED, GREEN, BLUE]
after_sigma = [BLUE, RED, GREEN]
tau_then_sigma = [RED, BLUE, GREEN]
sigma_then_tau = [BLUE, GREEN, RED]
row1 = make_row(rows[0], start, "起始")
row2 = make_row(rows[1], after_sigma, "先做 σ(三轮换)")
row3 = make_row(rows[2], tau_then_sigma, "再做 τ(对换 1↔2)", highlight=True)
row4 = make_row(rows[3], sigma_then_tau, "改成先 τ 后 σ", highlight=True)
# 公式定位用绝对坐标,不用 to_edge——MathTex 的包围盒要等 KaTeX 量出来,
# 量出来之前 to_edge / next_to 会按 NaN 算,整条被挪出画框(干跑查不出,退出码还是 0)
formula = MathTex(
r"\tau \circ \sigma \neq \sigma \circ \tau",
font_size=44,
color=RED,
).move_to([0, 3.35, 0])
caption = Text("结果不同 → S3 是交换群吗?不是。这是最小的非交换群", font_size=24, color=GRAY_A)
caption.move_to([0, 2.62, 0])
compare = Text("两行终态不一样", font_size=26, color=RED).move_to([5.1, rows[2], 0])
compare2 = Text("(交换律失效)", font_size=24, color=GRAY_A).move_to([5.1, rows[3], 0])
self.play(Write(formula), Write(caption), run_time=0.9)
self.play(FadeIn(row1), run_time=0.7)
self.play(FadeIn(row2), run_time=0.7)
self.play(FadeIn(row3), run_time=0.7)
self.play(FadeIn(row4), run_time=0.7)
self.play(Write(compare), Write(compare2), run_time=0.8)
self.wait(0.4)Homomorphisms: the bigger the kernel, the smaller the image
Each of the six elements of Z/6 on the left draws an arrow to its image in Z/3 on the right; the two red arrows from 0 and 3 hit the same point 0 — those two elements form the kernel, exactly 2; all three points on the right are covered, so the image has 3 elements.

"""同态:核有多大,像就有多小
@expect 左列 Z/6 的六个元素各引出一条箭头指向右列 Z/3 的像;0 和 3 两条红色箭头撞到同一个点 0 上——这两个元素构成核,正好是 2 个;右列三个点全被覆盖,像是 3 个。
@note 讲解要点:同态不是随便的映射,它要保住运算结构。一旦「保住结构」,被压扁成单位元的那部分(核)就一定是一个子群,而且每个像的原像数量完全相同——所以 |G| = |ker φ| × |Im φ|,6 = 2 × 3。
"""
from manim import *
class HomomorphismKernelScene(Scene):
def construct(self):
left_x = -3.6
right_x = 3.6
left_ys = [2.3 - i * 0.92 for i in range(6)]
right_ys = [1.15, 0.0, -1.15]
left_dots = VGroup()
left_nums = VGroup()
for i, y in enumerate(left_ys):
left_dots.add(Dot([left_x, y, 0], radius=0.13, color=WHITE))
left_nums.add(Text(str(i), font_size=26, color=YELLOW).move_to([left_x - 0.55, y, 0]))
right_dots = VGroup()
right_nums = VGroup()
for j, y in enumerate(right_ys):
right_dots.add(Dot([right_x, y, 0], radius=0.13, color=WHITE))
right_nums.add(Text(str(j), font_size=26, color=GREEN).move_to([right_x + 0.55, y, 0]))
kernel_branches = VGroup()
image_branches = VGroup()
for i, y in enumerate(left_ys):
target = right_ys[i % 3]
arrow = Arrow([left_x + 0.2, y, 0], [right_x - 0.2, target, 0], buff=0, stroke_width=2.4)
if i % 3 == 0:
arrow.set_color(RED)
kernel_branches.add(arrow)
else:
arrow.set_color(GRAY_B)
image_branches.add(arrow)
# SurroundingRectangle / VGroup(*) 会「吃掉」被量的那组——量副本
kernel_box = SurroundingRectangle(VGroup(left_dots[0], left_dots[3]).copy())
kernel_box.set_color(RED)
kernel_box.set_stroke(width=3)
kernel_label = Text("ker φ = {0, 3},2 个", font_size=25, color=RED).move_to([-5.6, -1.6, 0])
image_box = SurroundingRectangle(right_dots.copy())
image_box.set_color(GREEN)
image_box.set_stroke(width=3)
image_label = Text("Im φ = Z/3,3 个", font_size=25, color=GREEN).move_to([5.7, 1.9, 0])
heads = VGroup(
Text("G = Z/6", font_size=27, color=YELLOW).move_to([left_x, 3.2, 0]),
Text("G' = Z/3", font_size=27, color=GREEN).move_to([right_x, 3.2, 0]),
)
phi = MathTex(r"\varphi(k) = k \bmod 3", font_size=40).move_to([0, 3.2, 0])
formula = MathTex(r"|G| = |\ker \varphi| \times |\mathrm{Im}\, \varphi| \qquad 6 = 2 \times 3", font_size=40)
formula.move_to([0, -3.0, 0])
self.play(Write(heads), Write(phi), run_time=0.8)
self.play(FadeIn(left_dots), Write(left_nums), FadeIn(right_dots), Write(right_nums), run_time=1.0)
self.play(LaggedStart(*[Create(a) for a in image_branches], lag_ratio=0.1), run_time=1.2)
self.play(LaggedStart(*[Create(a) for a in kernel_branches], lag_ratio=0.2), run_time=0.8)
self.play(Create(kernel_box), Write(kernel_label), run_time=0.8)
self.play(Create(image_box), Write(image_label), run_time=0.8)
self.play(Write(formula), run_time=0.8)
self.wait(0.4)Quotient group G/N: gluing each coset into one new element
On the left, Z/6 splits into three columns, each a coset; each column contracts into one thick dot below, giving a G/N of only 3 elements. The new addition table on the right shows these 3 elements form a group themselves, with the structure of Z/3.

"""商群 G/N:把整个陪集「粘成」一个新元素
@expect 左边 Z/6 按列分成三组,每组就是一个陪集;三组各自收缩成下面一个粗点,得到只有 3 个元素的 G/N。右边新的加法表说明这 3 个新元素自己又构成一个群,结构正是 Z/3。
@note 讲解要点:商群不是「从群里挑出几个元素」,而是把每一整块(陪集)当作一个原子来运算。要做这一步,被商掉的 N 必须是正规子群——交换群里这条自动满足。
"""
from manim import *
class QuotientGroupScene(Scene):
def construct(self):
cols = [-6.0, -4.2, -2.4]
rows = [1.9, 1.0]
values = [[0, 1, 2], [3, 4, 5]]
dots = VGroup()
nums = VGroup()
for r, y in enumerate(rows):
for c, x in enumerate(cols):
dots.add(Dot([x, y, 0], radius=0.13, color=WHITE))
nums.add(Text(str(values[r][c]), font_size=24, color=YELLOW).move_to([x - 0.42, y, 0]))
# 包框用原始坐标手算:把已有物体再装进 VGroup 会把它们从原组偷走、丢掉填充
column_boxes = VGroup(
*[Rectangle(width=0.66, height=1.4, color=GREEN).move_to([x, 1.45, 0]) for x in cols]
)
arrows = VGroup(
*[Arrow([x, 0.55, 0], [x, -0.35, 0], color=GRAY_A, buff=0, stroke_width=2.5) for x in cols]
)
quotient_dots = VGroup()
quotient_labels = VGroup()
for idx, x in enumerate(cols):
big = Circle(radius=0.28, color=BLUE)
big.set_fill(BLUE, opacity=0.8)
big.move_to([x, -1.0, 0])
quotient_dots.add(big)
quotient_labels.add(
Text(f"[{idx}]", font_size=24, color=BLUE).move_to([x, -1.8, 0])
)
left_head = Text("G = Z/6,N = {0,3}", font_size=26, color=YELLOW).move_to([-4.2, 3.0, 0])
collapse_note = Text("每个陪集收缩成一个元素", font_size=24, color=GRAY_A).move_to([-4.2, -2.6, 0])
table_cell = 0.78
table_center = [3.7, 0.0]
table_origin_x = table_center[0] - table_cell * 2
table_origin_y = table_center[1] + table_cell * 2
table_cells = VGroup()
table_nums = VGroup()
for i in range(4):
for j in range(4):
cx = table_origin_x + table_cell * (j + 0.5)
cy = table_origin_y - table_cell * (i + 0.5)
box = Square(side_length=table_cell, color=GRAY_B, stroke_width=1.5).move_to([cx, cy, 0])
table_cells.add(box)
if i == 0 and j == 0:
continue
if i == 0:
table_nums.add(Text(f"[{j - 1}]", font_size=20, color=YELLOW).move_to([cx, cy, 0]))
elif j == 0:
table_nums.add(Text(f"[{i - 1}]", font_size=20, color=YELLOW).move_to([cx, cy, 0]))
else:
table_nums.add(
Text(f"[{(i - 1 + j - 1) % 3}]", font_size=20, color=WHITE).move_to([cx, cy, 0])
)
right_head = Text("商群 G/N 的加法表", font_size=26, color=BLUE).move_to([3.7, 2.5, 0])
result = MathTex(r"G/N \cong Z/3", font_size=46).move_to([3.7, -2.6, 0])
self.play(Write(left_head), run_time=0.6)
self.play(FadeIn(dots), Write(nums), run_time=0.8)
self.play(Create(column_boxes), run_time=0.8)
self.play(LaggedStart(*[Create(a) for a in arrows], lag_ratio=0.2), run_time=0.9)
self.play(FadeIn(quotient_dots), Write(quotient_labels), run_time=0.8)
self.play(Write(collapse_note), run_time=0.6)
self.play(Create(table_cells), Write(right_head), run_time=0.8)
self.play(LaggedStart(*[FadeIn(t) for t in table_nums], lag_ratio=0.04), run_time=1.4)
self.play(Write(result), run_time=0.8)
self.wait(0.4)Subgroup lattice: all subgroups of Z/12 as a poset diagram
Six nodes ordered by inclusion — all of Z/12 at the top, {0} at the bottom, orders 6, 4, 3, 2 in between; edges appear only between directly-including nodes, forming a lattice.

"""子群格:Z/12 的全部子群连成一张偏序图
@expect 六个节点按「包含关系」上下排列——最上是整个 Z/12,最下是 {0},中间按阶数 6、4、3、2 依次铺开;连线只在「直接包含」的节点之间出现,构成一张格图。
@note 讲解要点:Z/n 的子群与 n 的正因子一一对应,d 对应唯一的 d 阶子群 ⟨n/d⟩。所以 12 有 6 个正因子,就恰好有 6 个子群——整数分解的算术,完全决定了这张图的形状。
"""
from manim import *
class SubgroupLatticeScene(Scene):
def construct(self):
nodes = {
"top": [-2.3, 2.5, "Z/12", 12],
"h6": [-4.1, 1.0, "⟨2⟩", 6],
"h4": [-0.5, 1.0, "⟨3⟩", 4],
"h3": [-4.9, -0.5, "⟨4⟩", 3],
"h2": [-1.4, -0.5, "⟨6⟩", 2],
"h1": [-2.9, -2.0, "⟨0⟩", 1],
}
edges = [
("top", "h6"),
("top", "h4"),
("h6", "h3"),
("h6", "h2"),
("h4", "h2"),
("h3", "h1"),
("h2", "h1"),
]
circles = VGroup()
labels = VGroup()
for key, (x, y, symbol, order) in nodes.items():
style = Circle(radius=0.54, color=GRAY_B)
style.set_fill(GRAY_E, opacity=0.5)
style.move_to([x, y, 0])
circles.add(style)
pair = VGroup(
Text(symbol, font_size=21, color=WHITE),
Text(f"阶 {order}", font_size=15, color=GREEN),
)
pair.arrange(DOWN, buff=0.06).move_to([x, y, 0])
labels.add(pair)
links = VGroup(
*[
Line(nodes[a][:2] + [0], nodes[b][:2] + [0], color=GRAY_C, stroke_width=2.5)
for a, b in edges
]
)
head = Text("Z/12 的全部子群只有这 6 个", font_size=27, color=YELLOW).move_to([-2.5, 3.35, 0])
right_notes = VGroup(
Text("阶 12 → 6 / 4 → 3 / 2 → 1", font_size=24, color=GREEN).move_to([3.9, 1.5, 0]),
Text("每条线都是「真包含」", font_size=23, color=GRAY_A).move_to([3.9, 0.75, 0]),
)
divisor_rule = MathTex(r"d \mid 12 \;\Longleftrightarrow\; \exists\, |H| = d", font_size=42)
divisor_rule.move_to([3.9, -0.9, 0])
caption = Text("子群个数 = 正因子个数 = 6", font_size=25, color=YELLOW).move_to([3.9, -2.2, 0])
self.play(Write(head), run_time=0.6)
self.play(Create(links), run_time=0.9)
self.play(LaggedStart(*[FadeIn(c) for c in circles], lag_ratio=0.12), run_time=1.2)
self.play(Write(labels), run_time=0.9)
self.play(Write(right_notes), run_time=0.8)
self.play(Write(divisor_rule), run_time=0.8)
self.play(Write(caption), run_time=0.7)
self.wait(0.4)The order of the symmetric group explodes as n!
The six bar heights scale only by log10(n!), so the picture looks like a gentle rise; but the true values printed on top jump from 2 all the way to 5040 — growth wildly out of proportion.

"""对称群的阶按 n! 爆炸
@expect 六根柱子的高度只按 log10(n!) 等比缩放,所以画面上「看起来」只是平缓上升;但每根柱子顶端标的真实数值从 2 一路跳到 5040,涨幅完全不成比例。
@note 讲解要点:|S_n| = n! 是指数级的爆炸——用对数轴才画得下。这解释了为什么 n 稍大一点,「把 n 个东西全排一遍」这种穷举做法就彻底不可行,也解释了群论里「找生成元」为什么远比「列表」重要。
"""
from manim import *
import math
class SymmetricGroupOrderScene(Scene):
def construct(self):
ns = [2, 3, 4, 5, 6, 7]
orders = [math.factorial(n) for n in ns]
logs = [math.log10(o) for o in orders]
peak = max(logs)
xs = [-5.0 + i * 2.0 for i in range(len(ns))]
base_y = -2.6
max_h = 4.0
bars = VGroup()
tops = VGroup()
bottoms = VGroup()
stem_labels = VGroup()
for x, n, order, lg in zip(xs, ns, orders, logs):
height = lg / peak * max_h
bar = Rectangle(width=1.1, height=height, color=BLUE)
bar.set_fill(BLUE, opacity=0.7)
bar.move_to([x, base_y + height / 2, 0])
bars.add(bar)
tops.add(Text(f"{order}", font_size=24, color=YELLOW).move_to([x, base_y + height + 0.35, 0]))
bottoms.add(Text(f"S{n}", font_size=24, color=GREEN).move_to([x, base_y - 0.42, 0]))
stem_labels.add(
Text(f"log10 = {lg:.2f}", font_size=15, color=GRAY_A).move_to([x, base_y + height / 2, 0])
)
baseline = Line([-6.2, base_y, 0], [6.2, base_y, 0], color=GRAY_B, stroke_width=2)
formula = MathTex(r"|S_n| = n!", font_size=52).to_edge(UP, buff=0.35)
caption = Text("4.5 倍 / 6.5 倍 / 14 倍……每一档都在加速", font_size=24, color=GRAY_A)
caption.move_to([0, 2.7, 0])
note = Text("柱子高度是按对数画的;标出来的是真实阶", font_size=24, color=YELLOW)
note.move_to([0, -3.35, 0])
self.play(Write(formula), Write(caption), run_time=0.8)
self.play(Create(baseline), run_time=0.4)
self.play(LaggedStart(*[FadeIn(b) for b in bars], lag_ratio=0.15), run_time=1.6)
self.play(Write(bottoms), run_time=0.6)
self.play(Write(tops), run_time=0.8)
self.play(FadeIn(stem_labels), run_time=0.6)
self.play(Write(note), run_time=0.7)
self.wait(0.4)Orbit and stabilizer: where a point goes, and what keeps it fixed
On the left square, vertex 1 is pushed by the group action to positions 2, 3, 4 (three green arrows, vertex in yellow) — an orbit of 4 points; the two small squares at the bottom right draw the two symmetries that keep vertex 1 fixed (identity, reflection across the diagonal). Orbit size times stabilizer size equals the group order.

"""轨道与稳定子:一个点被群推到哪些地方,谁又让它原地不动
@expect 左边正方形上,顶点 1 被群作用推到 2、3、4 三个位置(三条绿色箭头,顶点为黄色)——轨道有 4 个点;右下两个小正方形画的是让顶点 1 不动的两个对称(恒等、沿对角线镜像)。轨道数 × 稳定子数正好等于群的大小。
@note 讲解要点:轨道是「能被推到的地方」,稳定子是「推了也不动的变换」。同一个群作用在不同东西上,轨道会变、稳定子也会变。G 作用在 4 个顶点上时 |G| = 8 = 4 × 2;作用在中心上时轨道只有 1 个、稳定子是全群 8 个,乘积依然是 8。
"""
from manim import *
class OrbitStabilizerScene(Scene):
def construct(self):
side = 3.0
center = [-3.9, 0.35, 0]
half = side / 2
corner_offsets = [
[half, half, "1"],
[-half, half, "2"],
[-half, -half, "3"],
[half, -half, "4"],
]
corners = [
[center[0] + dx, center[1] + dy, 0] for dx, dy, _ in corner_offsets
]
square = Square(side_length=side, color=BLUE).move_to(center)
vertex_dots = VGroup(*[Dot(p, radius=0.13, color=YELLOW) for p in corners])
vertex_labels = VGroup(
*[
Text(name, font_size=24, color=YELLOW).move_to(
[p[0] + (0.32 if dx > 0 else -0.32), p[1] + (0.3 if dy > 0 else -0.32), 0]
)
for p, (dx, dy, name) in zip(corners, corner_offsets)
]
)
orbit_arrows = VGroup(
*[
Arrow(corners[0], corners[i], color=GREEN, buff=0.24, stroke_width=2.5,
max_tip_length_to_length_ratio=0.08)
for i in (1, 2, 3)
]
)
orbit_label = Text("轨道:{1,2,3,4},4 个点", font_size=25, color=GREEN).move_to([-3.9, -2.4, 0])
def mini_square(at, axis, caption):
s = 1.7
h = s / 2
box = Square(side_length=s, color=BLUE).move_to(at)
dots = VGroup(
*[
Dot([at[0] + dx, at[1] + dy, 0], radius=0.08, color=YELLOW)
for dx, dy in [(h, h), (-h, h), (-h, -h), (h, -h)]
]
)
fixed = Dot([at[0] + h, at[1] + h, 0], radius=0.16, color=RED)
line = None
if axis == "diag":
line = DashedLine([at[0] - h, at[1] - h, 0], [at[0] + h, at[1] + h, 0], color=YELLOW)
text = Text(caption, font_size=21, color=GRAY_A).move_to([at[0], at[1] - 1.45, 0])
group = VGroup(box, dots, fixed)
if line is not None:
group.add(line)
return VGroup(group, text)
stab1 = mini_square([1.2, 0.9, 0], "none", "恒等:什么都不动")
stab2 = mini_square([4.4, 0.9, 0], "diag", "沿对角线镜像:1 与 3 不动")
stab_label = Text("稳定子:让顶点 1 不动的有 2 个", font_size=24, color=RED).move_to([2.8, -1.35, 0])
formula = MathTex(
r"|G| = |\mathrm{Orb}(x)| \times |\mathrm{Stab}(x)| \qquad 8 = 4 \times 2",
font_size=38,
).move_to([0, 3.35, 0])
self.play(Write(formula), run_time=0.9)
self.play(Create(square), FadeIn(vertex_dots), Write(vertex_labels), run_time=0.9)
self.play(LaggedStart(*[Create(a) for a in orbit_arrows], lag_ratio=0.25), run_time=1.1)
self.play(Write(orbit_label), run_time=0.6)
self.play(FadeIn(stab1), FadeIn(stab2), run_time=0.9)
self.play(Write(stab_label), run_time=0.7)
self.wait(0.4)