Skip to content

群论 ​

循环群、Cayley 表、二面体群、生成元与子群、拉格朗日、置换复合、同态核、商群、子群格与轨道-稳定子。

本页 12 条示例。代码引自 corpus/examples/math-group/,截图为真机渲染的终态。

循环群 C4:正方形旋转 4 次回到原样 ​

四个正方形排成一行,红点标记「被跟踪的那个角」:每次逆时针转 90°,红点依次走到四个角;R90 连着做两次正好落在 R180 上,R270 再转一次回到 R0。

循环群 C4:正方形旋转 4 次回到原样

py
"""循环群 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 表:一张表就把群的三条公理读出来 ​

4×4 的加法表填满 (Z/4, +):第一行(单位元 e=0 那一行)与表头完全一致,主对角线附近成对出现的 1↔3、2↔2 互为逆元——表里没有空格,说明运算封闭。

Cayley 表:一张表就把群的三条公理读出来

py
"""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)

二面体群 D4:正方形的 8 个对称(4 旋转 + 4 镜像) ​

上排 4 个正方形分别是转 0° / 90° / 180° / 270°,下排 4 个是沿竖轴、横轴、两条对角线的镜像;标记点 8 次落在 8 个互不相同的位置,说明这 8 个变换两两不等。

二面体群 D4:正方形的 8 个对称(4 旋转 + 4 镜像)

py
"""二面体群 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)

乘法群 (Z/7)*:谁是生成元,一眼看出阶 ​

左右两个圆圈上同样摆着 1~6 六个元素。左边按「乘 2 取模 7」连箭头,走出两个互不相通的小环;右边按「乘 3 取模 7」连箭头,一根线串起全部六个元素最后回到起点。

乘法群 (Z/7)*:谁是生成元,一眼看出阶

py
"""乘法群 (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)

由生成元「带出来」的子群,阶永远整除全群 ​

最上一行 12 个点代表 (Z/12, +) 的全部元素;下面四行分别把 ⟨1⟩ ⟨2⟩ ⟨3⟩ ⟨4⟩ 能到达的点点亮——依次点亮 12、6、4、3 个点,其余保持暗淡。

由生成元「带出来」的子群,阶永远整除全群

py
"""由生成元「带出来」的子群,阶永远整除全群

@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)

拉格朗日定理:子群把群等分成若干块,块数 × 块大小 = 群大小 ​

12 个元素排成一行,按所属陪集染成绿 / 黄 / 蓝三种颜色——同色恰好 4 个、三种颜色共 12 个且互不重叠;配色在行内是交错的(0 绿 1 黄 2 蓝 3 绿…),说明陪集互相穿插却从不共用元素。

拉格朗日定理:子群把群等分成若干块,块数 × 块大小 = 群大小

py
"""拉格朗日定理:子群把群等分成若干块,块数 × 块大小 = 群大小

@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)

置换的复合:先做谁、后做谁,结果不一样 ​

四行三格的球位,上两行把「先 σ 后 τ」一步步做出来,最下一行换成「先 τ 后 σ」;两行终态的颜色排列明显不同——同一个集合、同一个运算,交换顺序就换了答案。

置换的复合:先做谁、后做谁,结果不一样

py
"""置换的复合:先做谁、后做谁,结果不一样

@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)

同态:核有多大,像就有多小 ​

左列 Z/6 的六个元素各引出一条箭头指向右列 Z/3 的像;0 和 3 两条红色箭头撞到同一个点 0 上——这两个元素构成核,正好是 2 个;右列三个点全被覆盖,像是 3 个。

同态:核有多大,像就有多小

py
"""同态:核有多大,像就有多小

@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)

商群 G/N:把整个陪集「粘成」一个新元素 ​

左边 Z/6 按列分成三组,每组就是一个陪集;三组各自收缩成下面一个粗点,得到只有 3 个元素的 G/N。右边新的加法表说明这 3 个新元素自己又构成一个群,结构正是 Z/3。

商群 G/N:把整个陪集「粘成」一个新元素

py
"""商群 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)

子群格:Z/12 的全部子群连成一张偏序图 ​

六个节点按「包含关系」上下排列——最上是整个 Z/12,最下是 {0},中间按阶数 6、4、3、2 依次铺开;连线只在「直接包含」的节点之间出现,构成一张格图。

子群格:Z/12 的全部子群连成一张偏序图

py
"""子群格: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)

对称群的阶按 n! 爆炸 ​

六根柱子的高度只按 log10(n!) 等比缩放,所以画面上「看起来」只是平缓上升;但每根柱子顶端标的真实数值从 2 一路跳到 5040,涨幅完全不成比例。

对称群的阶按 n! 爆炸

py
"""对称群的阶按 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)

轨道与稳定子:一个点被群推到哪些地方,谁又让它原地不动 ​

左边正方形上,顶点 1 被群作用推到 2、3、4 三个位置(三条绿色箭头,顶点为黄色)——轨道有 4 个点;右下两个小正方形画的是让顶点 1 不动的两个对称(恒等、沿对角线镜像)。轨道数 × 稳定子数正好等于群的大小。

轨道与稳定子:一个点被群推到哪些地方,谁又让它原地不动

py
"""轨道与稳定子:一个点被群推到哪些地方,谁又让它原地不动

@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)

MIT licensed · Manim 与 Pyodide 各依其自身许可(见「安装」)