Абстрактная алгебра: группы, кольца, поля и моноиды в коде
Абстрактная алгебра — единственный раздел математики, который программист использует каждый день, даже не зная его названия. Когда вы пишете reduce, вы пользуетесь моноидом. Когда СУБД выполняет SUM(...) в восемь потоков и склеивает частичные суммы — она полагается на ассоциативность. Когда AES шифрует байт — он умножает элементы конечного поля из 256 элементов. Когда Ceph восстанавливает данные с трёх выживших дисков из пяти — он решает линейную систему над GF(256).
Идея абстрактной алгебры радикально простая: вместо того чтобы изучать конкретные объекты, мы изучаем правила, которым подчиняются операции над ними. Доказали теорему про «любое множество с ассоциативной операцией и нейтральным элементом» — и она мгновенно верна для чисел, строк, списков, множеств, матриц, функций, git-коммитов, счётчиков в распределённой системе и планов запроса. Это не эстетика: это переиспользование доказательств вместо переиспользования кода.
Эта статья опирается на Теорию множеств (что такое множество и функция) и на Логику и доказательства (как читать «для любого a существует b»). Знание линейной алгебры не обязательно, но полезно — векторное пространство определяется именно над полем.
Зачем это программисту: четыре сцены
Сцена 1. Ваш reduce даёт разные ответы. Тесты падают раз в двадцать прогонов. Причина — операция комбинирования не ассоциативна (классика: усреднение, вычитание, сложение float). При однопоточном проходе порядок фиксирован, при параллельном — нет. Алгебра ровно про это: «какие законы должна выполнять операция, чтобы порядок не влиял».
Сцена 2. Инкрементальные вычисления. Хочется пересчитывать агрегат при добавлении события, не пробегая всю историю. Это возможно ровно тогда, когда агрегат — моноид: f(данные + новое) = f(данные) * f(новое). Не моноид — придётся хранить всю историю (или сдаться и считать приблизительно).
Сцена 3. Криптография. Diffie–Hellman, RSA, ECDSA, AES — всё это заявления о группах и полях. Без языка «порядок элемента», «циклическая группа», «конечное поле» криптографию невозможно даже прочитать, не то что оценить корректность реализации.
Сцена 4. Конфликты в распределённой системе. Два узла независимо изменили состояние. Слияние без координации возможно тогда, когда операция слияния коммутативна, ассоциативна и идемпотентна — это полурешётка, и на ней стоят все CRDT.
Каждая сцена — про один и тот же вопрос: какие законы выполняет моя операция. Начнём строить лестницу законов снизу.
Лестница структур: одно множество, одна операция
Определение (бинарная операция). Бинарная операция на множестве S — это функция *: S × S -> S. Ключевое требование уже здесь: результат обязан лежать в S (замкнутость). деление не является бинарной операцией на Z (2/3 не целое), а вычитание — является.
Дальше добавляем аксиомы по одной:
Замкнутость: для всех a, b из S: a * b лежит в S
Ассоциативность: (a * b) * c = a * (b * c)
Нейтральный (e): e * a = a * e = a
Обратный: для каждого a есть a' с a * a' = a' * a = e
Коммутативность: a * b = b * a
Названия структур получаются накоплением этих аксиом:
| Структура | Замкн. | Ассоц. | Нейтр. | Обрат. | Комм. | Пример |
|---|---|---|---|---|---|---|
| Магма | + | (Z, -) — вычитание |
||||
| Полугруппа | + | + | непустые строки с ++; (Z, max) |
|||
| Моноид | + | + | + | строки с ++ и ""; (N, +, 0) |
||
| Группа | + | + | + | + | S_n (перестановки), GL(n, R) |
|
| Абелева группа | + | + | + | + | + | (Z, +, 0), (Z/n, +, 0) |
Правило чтения картинки: чем сильнее аксиомы, тем больше теорем достаётся бесплатно, но тем меньше объектов подходит. Это тот же trade-off, что между Iterable и RandomAccess в коде: узкий интерфейс — много реализаций и мало гарантий.
Моноид: самая полезная структура для инженера
Определение. Моноид — это тройка (M, *, e), где *: M × M -> M ассоциативна и e — нейтральный элемент: e * a = a * e = a для всех a.
Интуиция «на пальцах»: моноид — это всё, что можно склеивать в любом порядке скобок, и есть «пустышка», которая ничего не меняет. Строки и пустая строка. Списки и пустой список. Числа и ноль. Множества и пустое множество. Функции A -> A и identity. Матрицы n×n и единичная матрица.
Ассоциативность — это разрешение на параллелизм
Ассоциативность означает, что выражение a1 * a2 * ... * an имеет единственное значение независимо от расстановки скобок (строгое доказательство — индукцией по n, обобщённый закон ассоциативности). Практический вывод:
последовательно: ((((a1*a2)*a3)*a4)*a5) — O(n) шагов, глубина n
деревом: ((a1*a2)*(a3*a4))*a5 — O(n) работы, глубина log n
Работа та же, а глубина падает с n до log n — значит, свёртку можно разложить по потокам, ядрам GPU или узлам кластера. Именно это делает reduce в MapReduce/Spark, std::reduce в C++17 (в отличие от std::accumulate, который обязан идти слева направо) и параллельная агрегация в PostgreSQL.
Обратите внимание: для параллельной свёртки достаточно ассоциативности, коммутативность не нужна — порядок элементов сохраняется, меняется только группировка. Коммутативность нужна отдельно, когда порядок прихода данных не гарантирован (объединение результатов от узлов, которые ответили в произвольном порядке).
from functools import reduce
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, TypeVar, Iterable
T = TypeVar("T")
def fold(op: Callable[[T, T], T], unit: T, xs: Iterable[T]) -> T:
"""Последовательная свёртка моноида: O(n) времени, O(1) доп. памяти."""
return reduce(op, xs, unit)
def par_fold(op: Callable[[T, T], T], unit: T, xs: list[T], workers: int = 4) -> T:
"""Параллельная свёртка. Корректна ТОЛЬКО если op ассоциативна.
Время: O(n/p + log p), доп. память: O(p)."""
if not xs:
return unit
chunk = max(1, len(xs) // workers)
parts = [xs[i:i + chunk] for i in range(0, len(xs), chunk)]
with ThreadPoolExecutor(workers) as pool:
partial = list(pool.map(lambda p: fold(op, unit, p), parts))
return fold(op, unit, partial) # склейка частичных результатов
# Моноид (str, +, "") — ассоциативен, но НЕ коммутативен: порядок частей важен.
data = [f"[{i}]" for i in range(12)]
assert fold(str.__add__, "", data) == par_fold(str.__add__, "", data)
# (int, +, 0) — коммутативный моноид
assert par_fold(int.__add__, 0, list(range(1000))) == 499500
Составные моноиды: то, ради чего всё затевалось
Моноиды композируются, и это главный практический приём: если A и B — моноиды, то A × B — моноид покомпонентно. Значит, сложные агрегаты собираются из простых без доказательств заново.
Классический пример — среднее. «Среднее» само по себе не моноид (нельзя усреднить два средних, не зная весов), но пара (сумма, количество) — моноид, а среднее берётся в самом конце.
from dataclasses import dataclass
@dataclass(frozen=True)
class Stats:
"""Моноид статистики: покомпонентная композиция четырёх моноидов —
(+,0), (+,0), (min,+inf), (max,-inf)."""
count: int = 0
total: float = 0.0
lo: float = float("inf")
hi: float = float("-inf")
@staticmethod
def unit() -> "Stats":
return Stats()
@staticmethod
def of(x: float) -> "Stats":
return Stats(1, x, x, x)
def __mul__(self, other: "Stats") -> "Stats":
return Stats(
self.count + other.count,
self.total + other.total,
min(self.lo, other.lo),
max(self.hi, other.hi),
)
@property
def mean(self) -> float:
return self.total / self.count if self.count else float("nan")
xs = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]
s = fold(Stats.__mul__, Stats.unit(), [Stats.of(x) for x in xs])
print(s.count, s.total, s.lo, s.hi, round(s.mean, 3)) # 8 31.0 1.0 9.0 3.875
Тот же приём в проде: HyperLogLog (моноид по объединению регистров), Count-Min Sketch (моноид по сложению матриц), t-digest, гистограммы Prometheus, bloom filter (моноид по побитовому ИЛИ). Все они популярны именно потому, что моноиды: их можно считать на шардах и сливать без пересчёта.
Как это выражается в системе типов
В Haskell это Semigroup/Monoid из base (see Data.Monoid), в Scala — cats.Monoid (документация Typelevel Cats), в TypeScript — fp-ts. В языках без тайпклассов (Go, C#, Python) моноид живёт как соглашение: интерфейс с Combine и Empty плюс property-based тесты на законы. Подробнее о том, как эти интерфейсы устроены категорно, — в статье Теория категорий в программировании.
Законы нужно тестировать, а не декларировать. Компилятор не проверит ассоциативность:
from hypothesis import given, strategies as st
@given(st.text(), st.text(), st.text())
def test_string_monoid_assoc(a, b, c):
assert (a + b) + c == a + (b + c)
@given(st.text())
def test_string_monoid_identity(a):
assert "" + a == a and a + "" == a
@given(st.floats(allow_nan=False, allow_infinity=False),
st.floats(allow_nan=False, allow_infinity=False),
st.floats(allow_nan=False, allow_infinity=False))
def test_float_add_is_not_associative(a, b, c):
# Этот тест ПАДАЕТ — и это правильно: float-сложение не ассоциативно.
assert (a + b) + c == a + (b + c)
Последний тест — не педантизм. (1e16 + 1.0) - 1e16 = 0.0, а 1e16 + (1.0 - 1e16) = 1.0. Подробный разбор — в статье Численные методы; практический вывод: параллельная сумма float даёт другой (часто более точный!) ответ, и требовать побитового совпадения с однопоточной версией нельзя.
Группа: моноид, в котором всё обратимо
Определение. Группа — это моноид (G, *, e), в котором у каждого a есть обратный a': a * a' = a' * a = e. Если дополнительно a * b = b * a — группа абелева (коммутативная).
Интуиция: группа — это множество обратимых преобразований. Повороты кубика Рубика, перестановки списка, сдвиги по модулю, обратимые матрицы, симметрии молекулы, undo/redo-операции. Ключевое слово — «можно откатить».
Что следует сразу из аксиом (и это надо уметь выводить руками)
Единственность нейтрального. Пусть e и f — оба нейтральные. Тогда e = e * f (потому что f нейтральный) = f (потому что e нейтральный). Значит e = f.
Единственность обратного. Пусть b и c — оба обратные к a. Тогда:
b = b * e = b * (a * c) = (b * a) * c = e * c = c
Обратите внимание: ассоциативность использована в третьем переходе и без неё доказательство рушится. Именно поэтому «обратный элемент» осмысленен только начиная с полугруппы.
Закон сокращения. Из a * x = a * y следует x = y (домножьте слева на a'). Следствие: в таблице Кэли конечной группы каждый элемент встречается в каждой строке и каждом столбце ровно один раз — это латинский квадрат. Полезный тест на реализацию.
(a * b)' = b' * a' — обратный к композиции переворачивает порядок. Ровно поэтому в графике (R · T)^{-1} = T^{-1} · R^{-1}, а не наоборот.
Некоммутативность — не экзотика, а норма
Самая частая ошибка новичка — считать, что группы «как числа». Большинство интересных групп неабелевы. Канонический пример — D4, группа симметрий квадрата: 4 поворота и 4 отражения, всего 8 элементов.
Отражение, затем поворот — не то же самое, что поворот, затем отражение. То же самое верно для матриц, для кватернионов вращения, для последовательности миграций БД и для порядка middleware в HTTP-пайплайне. Некоммутативность — причина, по которой «поменять две строчки местами» в 3D-коде ломает сцену.
Симметрическая группа руками
S_n — группа всех биекций множества {0..n-1} относительно композиции. |S_n| = n!. Любая конечная группа вкладывается в некоторый S_n (теорема Кэли) — то есть группы это и есть симметрии, других не бывает.
from itertools import permutations
def compose(p, q):
"""(p ∘ q)(i) = p(q(i)) — сначала q, потом p."""
return tuple(p[q[i]] for i in range(len(q)))
def inverse(p):
inv = [0] * len(p)
for i, v in enumerate(p):
inv[v] = i
return tuple(inv)
n = 3
e = tuple(range(n))
S3 = list(permutations(range(n)))
# Проверяем аксиомы группы перебором: O(|G|^3) для ассоциативности.
assert all(compose(a, e) == a == compose(e, a) for a in S3)
assert all(compose(a, inverse(a)) == e for a in S3)
assert all(compose(compose(a, b), c) == compose(a, compose(b, c))
for a in S3 for b in S3 for c in S3)
# Неабелева: находим контрпример
bad = [(a, b) for a in S3 for b in S3 if compose(a, b) != compose(b, a)]
print(len(S3), "элементов,", len(bad), "некоммутирующих пар") # 6 элементов, 18 пар
Порядок элемента, циклические группы и теорема Лагранжа
Определение (порядок элемента). Порядок a — наименьшее k > 0 с a^k = e (в конечной группе всегда существует). Порядок группы — это |G|.
Определение (подгруппа). H ⊆ G — подгруппа, если H сама группа относительно той же операции (замкнута, содержит e, замкнута относительно обратных).
Теорема Лагранжа. Если G конечна и H — подгруппа, то |H| делит |G|.
Идея доказательства: смежные классы aH = {a*h : h из H} разбивают G на непересекающиеся куски одинакового размера |H|. Значит |G| = [G:H] · |H|.
Следствия, которые буквально работают в криптографии:
1. Порядок любого элемента делит |G|.
2. a^|G| = e для любого a из G.
3. (Эйлер) a^φ(n) ≡ 1 (mod n), если gcd(a, n) = 1 — здесь G = U(n), |G| = φ(n)
4. (Ферма) a^(p-1) ≡ 1 (mod p), если p простое, p не делит a
5. Группа простого порядка обязательно циклическая и не имеет нетривиальных подгрупп.
Пункт 3 — это ровно то, на чём стоит корректность RSA: расшифровка возвращает исходное сообщение, потому что m^(ed) = m^(1 + k·φ(n)) = m · (m^φ(n))^k = m.
from math import gcd
from sympy import totient # или считайте φ вручную
def order(a: int, n: int) -> int:
"""Порядок a в мультипликативной группе U(n). Требует gcd(a, n) = 1."""
assert gcd(a, n) == 1
x, k = a % n, 1
while x != 1:
x, k = x * a % n, k + 1
return k
U12 = [a for a in range(12) if gcd(a, 12) == 1]
print(U12) # [1, 5, 7, 11] — φ(12) = 4
print({a: order(a, 12) for a in U12}) # {1: 1, 5: 2, 7: 2, 11: 2}
Разберём этот вывод, он поучителен. U(12) — абелева группа из 4 элементов, но не циклическая: ни один элемент не имеет порядка 4, все нетривиальные элементы имеют порядок 2. Это группа Клейна Z/2 × Z/2. А вот U(7):
print({a: order(a, 7) for a in range(1, 7)})
# {1: 1, 2: 3, 3: 6, 4: 3, 5: 6, 6: 2}
Здесь есть элементы порядка 6 = |U(7)|: тройка и пятёрка — образующие, U(7) циклическая. Общий факт: U(p) циклична для любого простого p, и её образующая называется первообразным корнем. Именно первообразный корень выбирают параметром g в Diffie–Hellman.
Заметьте, что все порядки в обоих примерах делят порядок группы (4 и 6 соответственно) — Лагранж в действии.
Быстрое возведение в степень работает в любом моноиде (обратные не нужны) за O(log k) операций:
def monoid_pow(op, unit, a, k: int):
"""a^k в любом моноиде за O(log k) операций. Время O(log k), память O(1)."""
result, base = unit, a
while k > 0:
if k & 1:
result = op(result, base)
base = op(base, base)
k >>= 1
return result
# работает и для чисел по модулю, и для матриц (Фибоначчи за O(log n)),
# и для перестановок, и для конкатенации строк
mat_mul = lambda A, B: tuple(tuple(sum(A[i][k]*B[k][j] for k in range(2)) for j in range(2)) for i in range(2))
I2 = ((1, 0), (0, 1))
F = ((1, 1), (1, 0))
print(monoid_pow(mat_mul, I2, F, 10)[0][1]) # 55 = F(10)
Это один из самых доходных практических выводов всей статьи: любой алгоритм «применить операцию k раз» ускоряется до O(log k), если операция ассоциативна. Матричные степени, степени в кольце вычетов, транзитивное замыкание, линейные рекурренты.
Гомоморфизмы: структурно-совместимые отображения
Определение. Отображение f: G -> H между группами — гомоморфизм, если f(a * b) = f(a) · f(b) для всех a, b. Биективный гомоморфизм — изоморфизм (структуры «одинаковы с точностью до переименования»).
Интуиция: гомоморфизм — это «сжатие с сохранением структуры», ровно то, что делает хорошая хеш-функция или проекция. Примеры:
exp: (R, +, 0) -> (R>0, ×, 1), exp(a+b) = exp(a)·exp(b) — изоморфизм
det: (GL(n,R), ×) -> (R\{0}, ×), det(AB) = det(A)·det(B)
mod: (Z, +) -> (Z/n, +), (a+b) mod n = (a mod n + b mod n) mod n
len: (списки, ++) -> (N, +), len(xs ++ ys) = len(xs) + len(ys) — моноидный гомоморфизм
Последний пример — практический шаблон: если ваша агрегация является гомоморфизмом моноидов, её можно считать инкрементально и распределённо. len, sum, count, max, bloom-фильтр от объединения множеств — гомоморфизмы. median — нет, поэтому все распределённые перцентили приближённые.
Ядро. ker f = {a из G : f(a) = e_H}. Ядро всегда нормальная подгруппа, и f инъективен тогда и только тогда, когда ker f = {e}. Для хеш-функции ядро — это множество сообщений, хешируемых в ноль; для линейного отображения — нуль-пространство (см. матрицы и линейные отображения).
Фактор-группа. G / ker f изоморфна образу f — первая теорема об изоморфизме. Конкретно: Z / nZ ≅ Z/n. Вся модульная арифметика — это фактор-группа целых чисел по подгруппе кратных n. Когда вы пишете hash % buckets, вы буквально работаете в фактор-группе.
Как проверить, что ваша структура — то, чем вы её считаете
нет исключений, нет None?"} B -- нет --> Z["Это не бинарная операция.
Расширьте S или сузите домен"] B -- да --> C{"op ассоциативна?
проверьте property-тестом"} C -- нет --> D["Магма. Порядок вычислений важен.
Параллелить нельзя"] C -- да --> E{"Есть нейтральный e?"} E -- нет --> F["Полугруппа. Свёртка работает,
но только для непустых данных"] E -- да --> G{"У каждого есть обратный?"} G -- нет --> H{"op идемпотентна?
op(a,a) = a"} H -- да --> I["Полурешётка: основа CRDT,
слияние без координации"] H -- нет --> J["Моноид: параллельная свёртка,
инкрементальность, sketch-структуры"] G -- да --> K{"op коммутативна?"} K -- нет --> L["Группа. Есть undo,
но порядок операций важен"] K -- да --> M["Абелева группа: undo + любой порядок.
Основа колец и векторных пространств"]
Кольца: когда операций две
Определение. Кольцо (R, +, ×, 0, 1) — это множество, где:
(R, +, 0) — абелева группа (есть вычитание)
(R, ×, 1) — моноид (умножение ассоциативно, есть единица)
дистрибутивность: a×(b+c) = a×b + a×c и (b+c)×a = b×a + c×a
Умножение не обязано быть коммутативным и не обязано быть обратимым. Примеры:
Z— коммутативное кольцо; обратимы только1и-1.Z/n— кольцо вычетов; конечное, и в нём начинаются странности.R[x]— многочлены; основа CRC, кодов Рида–Соломона, полиномиальных коммитментов.- Матрицы
n×n— некоммутативное кольцо; есть делители нуля. - Функции
X -> Rс поточечными операциями.
Делители нуля. В Z/12: 3 × 4 = 12 = 0, хотя ни 3, ни 4 не нули. Такие элементы — делители нуля, и они ломают привычную логику: из a×b = 0 больше не следует a = 0 или b = 0; из a×x = a×y не следует x = y. Кольцо без делителей нуля называется областью целостности.
Практическое следствие: переполнение int32 — это арифметика в Z/2^32, кольце с чудовищным количеством делителей нуля. Поэтому x * 2 == y * 2 вовсе не означает x == y для 32-битных чисел, а компиляторы законно оптимизируют знаковое переполнение как UB, потому что для знаковых типов стандарт C/C++ не обещает арифметику кольца вычетов вовсе.
# Кольцо Z/12: находим все делители нуля
n = 12
zd = [a for a in range(1, n) if any(a * b % n == 0 for b in range(1, n))]
print(zd) # [2, 3, 4, 6, 8, 9, 10] — обратимы только 1, 5, 7, 11
Поля: кольца, где можно делить
Определение. Поле — коммутативное кольцо, в котором 0 ≠ 1 и каждый ненулевой элемент обратим по умножению. Эквивалентно: (F, +, 0) — абелева группа, (F \ {0}, ×, 1) — абелева группа, плюс дистрибутивность.
Примеры полей: Q, R, C, F_p = Z/p для простого p, GF(p^k) — конечные поля. Не поля: Z (нет обратных), Z/4, матрицы, кватернионы (умножение некоммутативно — это тело, а не поле).
Теорема. Z/n — поле тогда и только тогда, когда n простое.
Разбор в обе стороны, это ровно тот случай, где доказательство короче интуиции:
- Если
n = a·bсоставное (1 < a, b < n): тогдаa·b ≡ 0, значитa— делитель нуля. Если бы уaбыл обратныйa', тоb = (a'·a)·b = a'·(a·b) = a'·0 = 0— противоречие. Значитaнеобратим, и поля нет. - Если
pпростое иa ≢ 0: тогдаgcd(a, p) = 1, и по соотношению Безу существуютu, vсu·a + v·p = 1, то естьu·a ≡ 1 (mod p). Обратный найден, причём конструктивно — расширенным алгоритмом Евклида заO(log p).
def egcd(a: int, b: int) -> tuple[int, int, int]:
"""Расширенный Евклид: возвращает (g, u, v) с u*a + v*b = g = gcd(a,b).
Время O(log min(a,b)), память O(1)."""
old_r, r = a, b
old_u, u = 1, 0
old_v, v = 0, 1
while r != 0:
q = old_r // r
old_r, r = r, old_r - q * r
old_u, u = u, old_u - q * u
old_v, v = v, old_v - q * v
return old_r, old_u, old_v
def inv_mod(a: int, m: int) -> int:
g, u, _ = egcd(a % m, m)
if g != 1:
raise ValueError(f"{a} необратим по модулю {m}")
return u % m
print(inv_mod(3, 7)) # 5, потому что 3*5 = 15 ≡ 1 (mod 7)
print([inv_mod(a, 7) for a in range(1, 7)]) # [1, 4, 5, 2, 3, 6]
# inv_mod(3, 12) -> ValueError: 3 необратим по модулю 12
Конечные поля GF(p^k) и почему AES считает по модулю многочлена
Конечное поле существует ровно для порядков p^k (p простое) и единственно с точностью до изоморфизма — фундаментальный факт, доказанный Галуа. При k > 1 это не Z/(p^k) (там есть делители нуля), а кольцо многочленов над F_p, факторизованное по неприводимому многочлену степени k.
GF(2^8) — 256 элементов, каждый байт. Элемент 0x57 — это многочлен x^6 + x^4 + x^2 + x + 1 (единичные биты = коэффициенты). Сложение — XOR (потому что коэффициенты по модулю 2). Умножение — умножение многочленов с приведением по модулю неприводимого многочлена AES:
m(x) = x^8 + x^4 + x^3 + x + 1 = 0x11B
def gf_mul(a: int, b: int) -> int:
"""Умножение в GF(2^8) поля AES (Rijndael), модуль 0x11B.
Метод «русского крестьянина»: O(8) операций, без таблиц."""
p = 0
for _ in range(8):
if b & 1:
p ^= a # сложение в поле = XOR
carry = a & 0x80
a = (a << 1) & 0xFF # умножение на x
if carry:
a ^= 0x1B # приведение по m(x); старший бит уже отброшен
b >>= 1
return p
assert gf_mul(0x57, 0x83) == 0xC1 # канонический пример из FIPS-197
assert gf_mul(0x57, 0x13) == 0xFE
# Проверим аксиомы поля перебором — 256 элементов, это дёшево
assert all(gf_mul(a, 1) == a for a in range(256))
assert all(gf_mul(a, b) == gf_mul(b, a) for a in range(256) for b in range(256))
# каждый ненулевой элемент обратим:
inv = {a: next(b for b in range(1, 256) if gf_mul(a, b) == 1) for a in range(1, 256)}
assert len(inv) == 255
print(hex(inv[0x53])) # 0xca
Зачем AES это нужно: MixColumns — умножение вектора байт на фиксированную матрицу над GF(2^8), а S-box строится как взятие мультипликативного обратного в GF(2^8) с последующим аффинным преобразованием. Обратимость шифра держится ровно на том, что это поле и что матрица над ним обратима. Спецификация: NIST FIPS 197, раздел 4.
Рид–Соломон и erasure-кодирование: поле как страховка от потери диска
Над полем работает линейная алгебра: матрицы обратимы, системы решаются, любые k линейно независимых уравнений однозначно определяют k неизвестных. Отсюда — коды Рида–Соломона.
Идея: k блоков данных считаем коэффициентами многочлена степени k-1 над GF(256); вычисляем его в n > k различных точках; храним n значений на n дисках. Любые k значений задают систему с матрицей Вандермонда, которая над полем всегда обратима (различные узлы ⇒ ненулевой определитель) — значит, n - k дисков могут умереть, и данные восстановимы.
RS(10, 4): 10 блоков данных + 4 блока чётности = 14 дисков.
Переживает потерю ЛЮБЫХ 4 дисков. Накладные расходы 40% вместо 200% у трёхкратной репликации.
Так устроены erasure-профили в Ceph, HDFS EC, Backblaze Vaults, QR-коды и CD/DVD. Отличный разбор с кодом — пост Backblaze про Reed-Solomon; теория — Lidl & Niederreiter, «Introduction to Finite Fields and Their Applications».
CRC — это остаток в кольце многочленов
CRC32 не «хеш» в криптографическом смысле, а сообщение(x) mod g(x) в F_2[x]. Отсюда все его свойства: линейность (CRC(a XOR b) = CRC(a) XOR CRC(b)), гарантированное обнаружение любой одиночной серии ошибок длиной меньше степени g, и — критично — полная непригодность как MAC: зная CRC, атакующий вычисляет изменение данных, оставляющее CRC прежним, решая линейное уравнение. Это ровно та ошибка, которая убила WEP.
Полукольца: кольца без вычитания, и почему они везде в алгоритмах
Определение. Полукольцо — как кольцо, но (R, +, 0) только коммутативный моноид (вычитания нет). Формально: (R, +, 0) — коммутативный моноид, (R, ×, 1) — моноид, умножение дистрибутивно, и 0 × a = a × 0 = 0.
Звучит как ослабление ради ослабления — но именно это открывает потрясающий трюк: один алгоритм, много семантик.
| Полукольцо | «+» | «×» | 0 | 1 | Что считает произведение матриц |
|---|---|---|---|---|---|
| Обычное | + |
· |
0 | 1 | число путей длины k |
| Булево | или |
и |
false | true | достижимость (транзитивное замыкание) |
| Тропическое (min-plus) | min |
+ |
+inf |
0 | кратчайший путь |
| Max-plus | max |
+ |
-inf |
0 | критический путь / longest path |
| Viterbi (max-prod) | max |
· |
0 | 1 | наиболее вероятная последовательность |
| Log-semiring | logaddexp |
+ |
-inf |
0 | суммирование вероятностей в лог-шкале |
Флойд–Уоршелл — это в точности возведение матрицы смежности в степень n в тропическом полукольце; алгоритм Уоршелла для транзитивного замыкания — то же самое в булевом. Одна реализация, разные типы:
import numpy as np
def semiring_matmul(A, B, plus, times, zero):
"""«Умножение» матриц над произвольным полукольцом. O(n^3) времени, O(n^2) памяти."""
n, m, p = len(A), len(B), len(B[0])
C = [[zero] * p for _ in range(n)]
for i in range(n):
for j in range(p):
acc = zero
for k in range(m):
acc = plus(acc, times(A[i][k], B[k][j]))
C[i][j] = acc
return C
INF = float("inf")
W = [[0, 3, INF, 7],
[8, 0, 2, INF],
[5, INF, 0, 1],
[2, INF, INF, 0]]
# Тропическое полукольцо: (min, +). W^n = матрица кратчайших расстояний.
D = W
for _ in range(2): # достаточно log2(n) возведений в квадрат
D = semiring_matmul(D, D, min, lambda a, b: a + b, INF)
print(D[0]) # [0, 3, 5, 6] — кратчайшие пути из вершины 0
Подробнее про графовые алгоритмы — в статье Теория графов. На той же идее построен стандарт GraphBLAS (графовые алгоритмы как разреженная линейная алгебра над полукольцами) и оптимизаторы запросов, которые считают агрегаты в полукольцевой семантике.
Ещё одно место, где полукольца буквально в проде: анализ потоков данных в компиляторах. Решётка значений + монотонные передаточные функции + операция встречи (meet) — это ограниченная полурешётка, и завершаемость итеративного алгоритма доказывается через конечность высоты решётки. См. классику: Nielson, Nielson & Hankin, «Principles of Program Analysis».
Где алгебра всплывает в реальном коде: краткий каталог
Криптография с открытым ключом. Diffie–Hellman — это заявление о циклической группе: обе стороны вычисляют один и тот же элемент, потому что (g^a)^b = g^(ab) = (g^b)^a. Безопасность — предположение о сложности дискретного логарифма в этой конкретной группе.
Чтобы получить g^(ab), надо решить
дискретный логарифм — предположительно трудно
Важная деталь, которую пропускают: безопасность зависит от выбора группы. В (Z/p)* дискретный логарифм решается решетом числового поля за субэкспоненциальное время, поэтому нужны 2048–3072 бита. В группе точек эллиптической кривой лучшая известная атака — O(sqrt(q)) (rho Полларда), поэтому хватает 256 бит. Одна и та же алгебраическая конструкция, разные группы, разница в размере ключа на порядок. См. RFC 7748 (Curve25519) и Handbook of Applied Cryptography, главы 2–3 — свободно доступен.
Ещё деталь: проверка порядка подгруппы. Если реализация не проверяет, что полученная точка/элемент лежит в подгруппе нужного простого порядка, атакующий подсовывает элемент маленького порядка и по остаткам восстанавливает секрет (small subgroup confinement attack). Это буквально теорема Лагранжа, использованная против вас.
CRDT и распределённое состояние. Слияние реплик без координации возможно, если операция слияния образует ограниченную полурешётку: ассоциативна, коммутативна, идемпотентна. Тогда результат не зависит ни от порядка, ни от повторов сообщений — а значит, at-least-once доставки достаточно.
from collections import defaultdict
class GCounter:
"""Grow-only счётчик: коммутативный идемпотентный моноид (полурешётка)
по покомпонентному max. Слияние: O(число реплик)."""
def __init__(self):
self.counts = defaultdict(int)
def inc(self, replica: str, by: int = 1):
self.counts[replica] += by
def merge(self, other: "GCounter") -> "GCounter":
out = GCounter()
for k in set(self.counts) | set(other.counts):
out.counts[k] = max(self.counts[k], other.counts[k])
return out
def value(self) -> int:
return sum(self.counts.values())
a, b = GCounter(), GCounter()
a.inc("n1", 3); b.inc("n2", 5); b.inc("n1", 1)
# слияние в любом порядке, любое число раз — один результат
assert a.merge(b).value() == b.merge(a).value() == a.merge(b).merge(b).value() == 8
Каноническая работа — Shapiro, Preguiça, Baquero, Zawirski, «A comprehensive study of Convergent and Commutative Replicated Data Types» (INRIA RR-7506, 2011). На этом стоят Riak, Redis CRDT, Automerge, Yjs.
Базы данных. Параллельная агрегация в PostgreSQL требует от пользовательского агрегата функции COMBINEFUNC, склеивающей частичные состояния — то есть требует, чтобы состояние было коммутативным моноидом (документация CREATE AGGREGATE). Материализованные представления с инкрементальным обновлением возможны для моноидных агрегатов (SUM, COUNT, MIN при вставках) и невозможны для немоноидных (MEDIAN, PERCENTILE, DISTINCT COUNT точно). Ровно та же граница определяет, что можно посчитать в оконном фрейме за O(1) на сдвиг.
Merkle-деревья и git. Хеш поддерева — это свёртка хешей детей. Дерево работает как индекс над «почти моноидом»: комбинирование ассоциативно только при фиксированной форме дерева, поэтому формат дерева — часть протокола. Git-объекты, Bitcoin-блоки, IPFS, sparse Merkle trees в блокчейнах, дедупликация в резервных копиях — все они полагаются на эту структуру.
Машинное обучение. Softmax-нормализация в лог-полукольце (logsumexp) — стандартная численная практика. Attention с онлайн-нормализацией во FlashAttention корректна именно потому, что «softmax-состояние» (max, sum_exp) — моноид, который можно сливать по блокам. Групповая свёртка (group-equivariant CNN, Cohen & Welling) строит слои, эквивариантные к действию группы симметрий — прямое использование теории представлений.
Типы и языки. Semigroup/Monoid в Haskell, cats.Monoid в Scala, моноидные аннотации в системах эффектов. Хорошее введение с уклоном в практику — Brent Yorgey, «Monoids: Theme and Variations» (Haskell Symposium 2012). Связь с категориями — в статьях Основы теории категорий и Теория категорий в программировании.
Типичные заблуждения
«Float-сложение — моноид». Нет: не ассоциативно из-за округления. (1e16 + 1) - 1e16 = 0, 1e16 + (1 - 1e16) = 1. Нейтральный элемент тоже с оговорками: 0.0 + (-0.0) даёт 0.0, а NaN ломает всё. Практика: параллельные суммы float допустимы, но требовать битовой воспроизводимости нельзя — либо фиксируйте порядок, либо используйте компенсированное суммирование (Кэхэн), либо считайте в целых/decimal.
«Для параллельной свёртки нужна коммутативность». Нет, достаточно ассоциативности: reduce меняет группировку, а не порядок. Коммутативность нужна отдельно, когда порядок прихода данных недетерминирован (сбор результатов по мере готовности).
«Z/n — всегда поле». Только для простого n. В Z/12 элемент 3 необратим. Отсюда: делить по модулю составного числа нельзя, a/b mod n не определено в общем случае.
«Вычитание — моноид». (8 - 3) - 2 = 3, 8 - (3 - 2) = 7. Не ассоциативно, значит даже не полугруппа. То же самое с делением ((8/4)/2 = 1, 8/(4/2) = 4), возведением в степень и «средним двух средних» (avg(avg(0,0), 10) = 5, avg(0, avg(0,10)) = 2.5).
«Группа обязана быть коммутативной». Наоборот: неабелевы группы — правило, а не исключение (S_n при n >= 3, матрицы, повороты в 3D, кватернионы).
«Моноид обязан быть коммутативным». Строки с конкатенацией — моноид, но "ab" != "ba". Матричное умножение — моноид, но некоммутативный.
«max без нейтрального — не моноид, значит свёртка невозможна». Это полугруппа, и свёртка непустой последовательности работает прекрасно. Нейтральный можно добавить искусственно: Option[T] с None как нейтральным превращает любую полугруппу в моноид (это стандартный приём «свободное добавление единицы»).
«Дискретный логарифм доказано труден». Нет. Это предположение. Доказательства нижней границы нет ни для DL, ни для факторизации — см. Теорию сложности. Более того, алгоритм Шора решает обе задачи за полиномиальное время на квантовом компьютере, поэтому и идёт переход к постквантовым схемам на решётках.
«Абстракция моноида — это оверинжиниринг». Иногда да. Критерий пользы ниже.
Trade-offs: когда алгебраическая абстракция окупается
Окупается, когда:
- нужен параллелизм или распределённость — законы дают право менять порядок вычислений;
- нужна инкрементальность (стриминг, материализованные вьюхи, оконные агрегаты);
- нужен откат/undo или обратимость — это буквально запрос «дайте мне группу»;
- один алгоритм должен работать с разной семантикой (полукольца: один Флойд–Уоршелл на пять задач);
- корректность критична и её надо доказывать (криптография, коды коррекции, консенсус).
Не окупается, когда:
- операция одна, кода на десять строк, параллелизма не будет — введение интерфейса
Monoid<T>добавит слоёв, но ничего не докажет; - законы не выполняются, но их «почти» выполнение объявляют достаточным — это худший вариант: вы получаете ложную уверенность и плавающие баги;
- команда не готова поддерживать property-based тесты — необеспеченный законами интерфейс
Monoidбесполезен, он не проверяется компилятором.
Практическая цена. Абстракция в Python/Go стоит вызовов методов и аллокаций; в Haskell/Rust мономорфизация обычно съедает накладные расходы полностью. Измеряйте: обобщённая semiring_matmul выше в 50+ раз медленнее специализированного numpy-кода. Правильный паттерн — абстракция на уровне архитектуры и специализация в горячем цикле.
Мини-практикум: проверьте себя
- Операция
join(a, b) = a + "," + b. Полугруппа или моноид? (Полугруппа: ассоциативность выполняется — обе расстановки скобок дают"a,b,c". Но моноида нет: нейтральногоeне существует, так какjoin(a, e)всегда длиннееaхотя бы на символ.) - Сколько элементов в
U(15)? Циклична ли эта группа? (φ(15) = 8; не циклична —U(15) ≅ Z/2 × Z/4.) - Почему
(Z/6, +, ×)— кольцо, но не поле? Найдите все делители нуля. (2, 3, 4.) - Постройте моноид для «последнего непустого значения» (
Last). Является ли он коммутативным? Идемпотентным? - Докажите, что в группе из чётного числа элементов обязательно есть элемент порядка 2. (Подсказка: элементы, не равные своему обратному, разбиваются на пары.)
- Почему медиана не является гомоморфизмом моноидов и какой моноид её приближает? (t-digest / KLL-скетч.)
Для экспериментов с конечными группами удобны GAP и SageMath — в них уже есть таблицы Кэли, решётки подгрупп и классификация групп малого порядка.
Итог
- Магма → полугруппа → моноид → группа → абелева группа — лестница аксиом. Каждая ступень даёт новые теоремы и сужает круг объектов.
- Ассоциативность = право на параллелизм. Нейтральный элемент = право свернуть пустое. Идемпотентность + коммутативность = право сливать реплики без координации.
- Гомоморфизм — критерий инкрементальности: если агрегат гомоморфен, его считают распределённо; если нет — только приближённо.
- Кольцо — две операции с дистрибутивностью, деление не гарантировано; поле — деление есть у всех, кроме нуля.
Z/n— поле ровно для простогоn. - Конечные поля
GF(p^k)— рабочая лошадка криптографии и кодов коррекции ошибок: AES, Рид–Соломон, erasure-кодирование, разделение секрета Шамира. - Полукольца превращают один алгоритм в семейство: Флойд–Уоршелл в тропическом, транзитивное замыкание в булевом, Витерби в max-prod.
- Законы проверяются тестами, а не декларациями: property-based тесты на ассоциативность и нейтральность — обязательная часть любой моноидной абстракции.
Источники
- Charles C. Pinter, «A Book of Abstract Algebra», Dover — самое доступное введение, с задачами и без лишней тяжести.
- David Dummit, Richard Foote, «Abstract Algebra», 3rd ed. — стандартный справочник; главы 1–7 (группы) и 7–9 (кольца).
- Michael Artin, «Algebra», 2nd ed. — сильный уклон в линейную алгебру и группы симметрий.
- Rudolf Lidl, Harald Niederreiter, «Introduction to Finite Fields and Their Applications» — конечные поля и коды.
- Menezes, van Oorschot, Vanstone, «Handbook of Applied Cryptography» — свободно доступен: https://cacr.uwaterloo.ca/hac/
- NIST FIPS 197 (AES), спецификация арифметики GF(2^8): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197-upd1.pdf
- Shapiro et al., «A comprehensive study of Convergent and Commutative Replicated Data Types»: https://inria.hal.science/inria-00555588
- Brent Yorgey, «Monoids: Theme and Variations»: https://ozark.hendrix.edu/~yorgey/pub/monoid-pearl.pdf
- Документация PostgreSQL,
CREATE AGGREGATE(параллельная агрегация): https://www.postgresql.org/docs/current/sql-createaggregate.html - Typelevel Cats, тайпклассы
Semigroup/Monoid: https://typelevel.org/cats/typeclasses/monoid.html - GraphBLAS — графовые алгоритмы над полукольцами: https://graphblas.org/
- RFC 7748, «Elliptic Curves for Security» (Curve25519): https://www.rfc-editor.org/rfc/rfc7748
Что дальше
Мы научились смотреть на объекты через операции над ними. Следующий шаг — сменить фокус ещё раз: перестать смотреть внутрь объектов вообще и изучать только связи между ними. Группы, кольца и моноиды окажутся частными случаями куда более общей картины, а «гомоморфизм» превратится в «морфизм».
Теория категорий: объекты, морфизмы, функторы, естественные преобразования