Примитивные корни и дискретный логарифм
Мотивация и контекст
Представьте задачу: дано простое число , числа и . Найти такое, что . Это классическая задача дискретного логарифма — она встречается в олимпиадных задачах, криптографии (протоколы Диффи-Хеллмана, ElGamal, DSA), и теории чисел. В отличие от обычного логарифма, вычислить дискретный логарифм вычислительно сложно, и именно на этой сложности основана безопасность большинства криптографических систем.
Но прежде чем перейти к алгоритмам поиска дискретного логарифма, необходимо понять структуру мультипликативной группы . Ключевым инструментом здесь служат примитивные корни — генераторы этой группы. Понимание примитивных корней открывает путь к изоморфизму , что радикально упрощает работу с степенями и логарифмами по модулю.
В соревновательном программировании дискретный логарифм применяется в задачах на:
- Нахождение -го члена линейной рекуррентности с помощью теоремы Эйлера-Ферма
- Подсчёт количества решений уравнений вида
- Задачи о порядках элементов и структуре групп
- Криптографические конструкции в задачах CTF-стиля
Теория
Порядок элемента в группе
Пусть — простое число. Рассмотрим мультипликативную группу с операцией умножения по модулю .
Определение. Порядком элемента называется наименьшее натуральное число такое, что .
Теорема 10.1 (основные свойства порядка).
- тогда и только тогда, когда .
- (следствие малой теоремы Ферма).
- .
Доказательство (п.1). Пусть , где . Тогда . Значит, тогда и только тогда, когда . По минимальности порядка это возможно лишь при , то есть .
Доказательство (п.2). Из малой теоремы Ферма: . По п.1 получаем .
Доказательство (п.3). Обозначим и . Нужно найти наименьшее такое, что , то есть , то есть . Это равносильно . Поскольку , получаем . Наименьшее такое равно .
Примитивный корень
Определение. Элемент называется примитивным корнем (или генератором) по модулю , если , то есть порождает всю группу .
Теорема 10.2 (существование примитивного корня). Для любого простого числа существует примитивный корень по модулю .
Доказательство. — циклическая группа порядка . Докажем это.
Пусть — факторизация. Для каждого уравнение имеет не более решений (многочлен степени над полем ). Значит, существует элемент , для которого .
Из свойств порядка: , но . Следовательно, .
Рассмотрим . Можно показать (используя теорему о порядке произведения независимых элементов), что . Таким образом, — примитивный корень.
Следствие. Примитивных корней по модулю ровно .
Доказательство. Если — примитивный корень, то все элементы имеют вид , . По теореме 10.1(3): . Это равно тогда и только тогда, когда . Таких ровно .
Нахождение примитивного корня
Алгоритм. Перебираем и для каждого проверяем: является ли примитивным корнем? Проверка:
Нужно убедиться, что для каждого простого делителя числа .
Обоснование: является примитивным корнем тогда и только тогда, когда для каждого простого выполняется . Действительно, если , то для некоторого простого , и тогда .
Сложность. Факторизация требует операций. Количество простых делителей — это . Каждая проверка — бинарное возведение в степень . Итого проверка одного кандидата: .
Эмпирически наименьший примитивный корень очень мал (чаще всего для простых до ), поэтому на практике алгоритм работает быстро.
Задача дискретного логарифма
Задача DLP (Discrete Logarithm Problem). Дано простое , примитивный корень и . Найти такое, что .
Это число называется индексом (или дискретным логарифмом) числа по основанию и обозначается или .
Вычислительная сложность. Лучшие известные алгоритмы (индексное исчисление) работают за субэкспоненциальное время для простого . Для произвольных (2048-битных) задача на практике неразрешима. Для малых (до в задачах) применяется BSGS за .
Алгоритм Baby-step Giant-step (BSGS)
Рассмотрим задачу: найти такое, что , где .
Идея. Запишем , где , , . Тогда:
Алгоритм:
- Baby steps: для вычислить и сохранить в хэш-таблицу: значение .
- Giant steps: для вычислить и искать в хэш-таблице.
- При нахождении совпадения: .
Корректность. Если решение существует, то для можно представить с , (нужно убедиться, что все покрыты). При каждое имеет разложение с и : берём , .
Сложность: времени и памяти.
Детали реализации:
- Хранить в map:
(b * pow(a, j)) % p -> j - При нескольких совпадениях брать наименьший
- Обработать случай отдельно (ответ )
Обобщённый BSGS (для составного модуля)
Задача: найти такое, что , где может быть составным.
Трудность: при составном элемент может не иметь обратного, и стандартный BSGS неприменим.
Идея. На первых шагах может расти. Сведём задачу к случаю, когда .
Пусть . Если , решений нет (т.к. для , значит ). Иначе разделим: задача эквивалентна .
Повторяем, пока . После шагов получаем: где , , и . Теперь применяем стандартный BSGS к уравнению .
Индексное исчисление (идея)
Для больших простых BSGS за слишком медлен. Используется метод индексного исчисления:
-
Выбор факторной базы. Фиксируем набор малых простых. Вычисляем для каждого .
-
Сбор соотношений. Для случайных вычисляем и проверяем, является ли результат -гладким (все простые делители ). Если , то .
-
Линейная алгебра. Решаем систему уравнений над .
-
Вычисление конкретного логарифма. Для данного ищем такое, что — -гладкое.
Сложность: для выбора оптимального . Современные алгоритмы (NFS) дают .
Ключевые формулы (сводная таблица)
| Объект | Формула / Свойство |
|---|---|
| Порядок элемента | |
| Порядок степени | |
| Число примитивных корней | |
| BSGS шаг | |
| BSGS разложение | , |
| BSGS сложность | (с unordered_map) |
| Обобщённый BSGS | Сводим к за шагов |
| Число решений | 1 (если ), 0 если |
| Решения | : |
Реализация на C++20 (с анализом сложности)
#include <bits/stdc++.h> using namespace std; // ─── Бинарное возведение в степень ──────────────────────────────────────── long long power(long long base, long long exp, long long mod) { long long result = 1; base %= mod; while (exp > 0) { if (exp & 1) result = (__int128)result * base % mod; base = (__int128)base * base % mod; exp >>= 1; } return result; } // ─── Нахождение примитивного корня по модулю p ──────────────────────────── // Сложность: O(sqrt(p) + phi_factors * log p) long long primitive_root(long long p) { // Факторизация p - 1 long long n = p - 1; vector<long long> factors; { long long tmp = n; for (long long i = 2; i * i <= tmp; i++) { if (tmp % i == 0) { factors.push_back(i); while (tmp % i == 0) tmp /= i; } } if (tmp > 1) factors.push_back(tmp); } // Перебираем кандидатов for (long long g = 2; g <= p; g++) { bool is_root = true; for (long long q : factors) { // Проверяем g^((p-1)/q) ≢ 1 (mod p) if (power(g, n / q, p) == 1) { is_root = false; break; } } if (is_root) return g; } return -1; // не должно достигаться } // ─── BSGS: a^x ≡ b (mod p), p — простое, gcd(a,p)=1 ────────────────────── // Возвращает наименьший x >= 0, или -1 если решений нет // Сложность: O(sqrt(p)) время и память long long bsgs(long long a, long long b, long long p) { a %= p; b %= p; if (b == 1) return 0; // a^0 = 1 if (a == 0) return b == 0 ? 1 : -1; long long m = (long long)ceil(sqrt((double)p)) + 1; // Baby steps: таблица b * a^j -> j unordered_map<long long, long long> table; table.reserve(m + 5); long long cur = b; for (long long j = 0; j < m; j++) { table[cur] = j; cur = (__int128)cur * a % p; } // Giant steps: вычисляем (a^m)^i long long am = power(a, m, p); cur = am; for (long long i = 1; i <= m + 1; i++) { auto it = table.find(cur); if (it != table.end()) { long long x = i * m - it->second; if (x >= 0) return x; } cur = (__int128)cur * am % p; } return -1; } // ─── Обобщённый BSGS: a^x ≡ b (mod m), m — произвольное ────────────────── // Находит наименьший x >= 0 // Сложность: O(log^2(m) + sqrt(m)) long long extended_gcd(long long a, long long b, long long &x, long long &y) { if (b == 0) { x = 1; y = 0; return a; } long long x1, y1; long long g = extended_gcd(b, a % b, x1, y1); x = y1; y = x1 - (a / b) * y1; return g; } long long mod_inverse(long long a, long long m) { long long x, y; long long g = extended_gcd(a, m, x, y); if (g != 1) return -1; return (x % m + m) % m; } long long generalized_bsgs(long long a, long long b, long long m) { a %= m; b %= m; if (b == 1 || m == 1) return 0; // Приводим к случаю gcd(a, m) = 1 long long k = 0; long long D = 1; // накопленный делитель long long cur_mod = m; // Накапливаем: умножаем обе части на a, пока gcd(a, cur_mod) == 1 while (true) { long long g = __gcd(a, cur_mod); if (g == 1) break; if (b % g != 0) return -1; b /= g; cur_mod /= g; D = (__int128)D * (a / g) % cur_mod; k++; if (D == b % cur_mod) return k; // a^k ≡ b (mod m) — проверяем } // Теперь gcd(a, cur_mod) = 1 // Решаем a^y ≡ b * D^{-1} (mod cur_mod) long long inv_D = mod_inverse(D, cur_mod); if (inv_D == -1) return -1; long long b_new = (__int128)b * inv_D % cur_mod; long long step = (long long)ceil(sqrt((double)cur_mod)) + 1; unordered_map<long long, long long> table; table.reserve(step + 5); long long val = b_new; for (long long j = 0; j < step; j++) { table[val] = j; val = (__int128)val * a % cur_mod; } long long am = power(a, step, cur_mod); val = am; for (long long i = 1; i <= step + 1; i++) { auto it = table.find(val); if (it != table.end()) { long long y = i * step - it->second; if (y >= 0) return y + k; } val = (__int128)val * am % cur_mod; } return -1; } // ─── Вычисление порядка элемента a в Z*_p ───────────────────────────────── // Сложность: O(sqrt(p) * log p) long long element_order(long long a, long long p) { // Порядок делит p-1, перебираем делители long long n = p - 1; long long ord = n; // Факторизуем n vector<long long> factors; { long long tmp = n; for (long long i = 2; i * i <= tmp; i++) { if (tmp % i == 0) { factors.push_back(i); while (tmp % i == 0) tmp /= i; } } if (tmp > 1) factors.push_back(tmp); } // Делим ord на простые, пока можно for (long long q : factors) { while (ord % q == 0 && power(a, ord / q, p) == 1) { ord /= q; } } return ord; } // ─── Демонстрация ────────────────────────────────────────────────────────── int main() { ios_base::sync_with_stdio(false); cin.tie(nullptr); // Пример 1: нахождение примитивного корня long long p = 1000000007LL; long long g = primitive_root(p); cout << "Primitive root mod " << p << ": " << g << "\n"; // Пример 2: дискретный логарифм // Найти x: 3^x ≡ 100 (mod 10^9+7) long long a = 3, b = 100; long long x = bsgs(a, b, p); cout << "3^x ≡ 100 (mod " << p << "): x = " << x << "\n"; if (x != -1) { cout << "Verification: 3^" << x << " = " << power(a, x, p) << "\n"; } // Пример 3: обобщённый BSGS (составной модуль) // 2^x ≡ 3 (mod 100) long long x2 = generalized_bsgs(2, 3, 100); if (x2 == -1) cout << "No solution for 2^x ≡ 3 (mod 100)\n"; else { cout << "2^" << x2 << " ≡ " << power(2, x2, 100) << " (mod 100)\n"; } // Пример 4: порядок элемента cout << "ord(3) mod " << p << " = " << element_order(3, p) << "\n"; return 0; }
Анализ сложности:
| Операция | Время | Память |
|---|---|---|
| Примитивный корень | ||
| BSGS (простой mod) | ср. | |
| BSGS (составной mod) | ||
| Порядок элемента |
Разбор задачи 1 (простая)
Задача. Дано простое и число (). Найти порядок в группе .
Источник: Codeforces, базовый уровень.
Решение. Порядок элемента делит . Факторизуем и применяем следующий алгоритм: начинаем с и пытаемся как можно больше «урезать» его. Для каждого простого : пока , делаем .
#include <bits/stdc++.h> using namespace std; typedef long long ll; ll power(ll base, ll exp, ll mod) { ll res = 1; base %= mod; for (; exp; exp >>= 1) { if (exp & 1) res = (__int128)res * base % mod; base = (__int128)base * base % mod; } return res; } int main() { ll p, a; cin >> p >> a; ll n = p - 1; // Факторизация p-1 vector<ll> primes; for (ll q = 2; q * q <= n; q++) { if (n % q == 0) { primes.push_back(q); while (n % q == 0) n /= q; } } if (n > 1) primes.push_back(n); ll ord = p - 1; for (ll q : primes) { while (ord % q == 0 && power(a, ord / q, p) == 1) ord /= q; } cout << ord << "\n"; return 0; }
Сложность: , где — число различных простых делителей.
Разбор задачи 2 (средняя)
Задача. Codeforces 17E / подобная задача: дано , , (простое, ). Найти наименьшее неотрицательное такое, что , или сообщить, что решений нет.
Решение. Прямое применение BSGS. Разберём тонкие случаи:
- : решение только если (при ) или (осторожно: неопределено, поэтому обычно ).
- : решение только если , иначе нет (если ).
- при составном : нужен обобщённый BSGS.
#include <bits/stdc++.h> using namespace std; typedef long long ll; ll power(ll b, ll e, ll m) { ll r = 1; b %= m; for (; e; e >>= 1, b = (__int128)b*b%m) if (e & 1) r = (__int128)r*b%m; return r; } // BSGS: a^x ≡ b (mod p), p простое, 0 ≤ x < p ll solve(ll a, ll b, ll p) { if (p == 1) return 0; b %= p; // Частные случаи if (power(a, 0, p) == b) return 0; // x=0: 1 ≡ b ll m = (ll)ceil(sqrt((double)p)) + 1; unordered_map<ll, ll> T; T.reserve(1 << 20); T.max_load_factor(0.25); ll baj = b; for (ll j = 0; j < m; j++) { T[baj] = j; baj = (__int128)baj * a % p; } ll am = power(a, m, p), cur = am; for (ll i = 1; i <= m + 1; i++) { auto it = T.find(cur); if (it != T.end()) { ll x = i * m - it->second; return x; } cur = (__int128)cur * am % p; } return -1; } int main() { ll a, b, p; cin >> p >> a >> b; ll ans = solve(a % p, b % p, p); if (ans == -1) cout << "no solution\n"; else cout << ans << "\n"; }
Подводные камни:
unordered_mapможет быть медленным из-за коллизий хэшей. Настройкаreserveиmax_load_factorускоряет работу.- При и решений нет (так как при ).
Разбор задачи 3 (сложная — Div.1 D / ICPC-уровень)
Задача. Дано и простое . Найти количество таких, что . А также для заданного подсчитать количество решений .
Источник: Codeforces 1033E (упрощённый вариант), ICPC-региональные 2019.
Решение.
Сначала заметим: всегда является решением и не является решением при .
Для : запишем , где — примитивный корень. Тогда равносильно (где ), то есть .
Количество решений : уравнение имеет решения тогда и только тогда, когда . При этом оно имеет ровно решений по модулю .
#include <bits/stdc++.h> using namespace std; typedef long long ll; ll power(ll b, ll e, ll m) { ll r=1; b%=m; for(;e;e>>=1,b=(__int128)b*b%m) if(e&1) r=(__int128)r*b%m; return r; } ll gcd(ll a, ll b) { return b ? gcd(b, a%b) : a; } // Расширенный НОД ll extgcd(ll a, ll b, ll &x, ll &y) { if (!b) { x=1; y=0; return a; } ll x1,y1, g=extgcd(b,a%b,x1,y1); x=y1; y=x1-(a/b)*y1; return g; } ll primitive_root(ll p) { ll n = p-1; vector<ll> fs; for(ll i=2;i*i<=n;i++) if(n%i==0){fs.push_back(i);while(n%i==0)n/=i;} if(n>1) fs.push_back(n); for(ll g=2;;g++){ bool ok=true; for(ll q:fs) if(power(g,(p-1)/q,p)==1){ok=false;break;} if(ok) return g; } } // discrete_log: найти ind_g(b) (дискретный логарифм b по основанию g mod p) // Возвращает -1 если b не в <g> ll discrete_log(ll g, ll b, ll p) { if (b == 1) return 0; ll m = (ll)ceil(sqrt((double)(p-1)))+1; unordered_map<ll,ll> T; T.reserve(m*2); T.max_load_factor(0.25); ll cur = b; for(ll j=0;j<m;j++){ T[cur]=j; cur=(__int128)cur*g%p; } ll gm=power(g,m,p); cur=gm; for(ll i=1;i<=m+1;i++){ auto it=T.find(cur); if(it!=T.end()) return i*m - it->second; cur=(__int128)cur*gm%p; } return -1; } int main(){ ll p, n; cin >> p >> n; ll g = primitive_root(p); ll d = gcd(n % (p-1), p-1); // Количество x: x^n ≡ x (mod p) // x=0: 1 решение // x≠0: x^(n-1) ≡ 1 (mod p) <=> ord(x) | gcd(n-1, p-1) ll d2 = gcd((n-1) % (p-1), p-1); cout << "x^n ≡ x solutions: " << d2 + 1 << "\n"; // Количество решений x^n ≡ c для данного c ll c; cin >> c; if (c == 0) { cout << "x^n ≡ 0: 1 solution (x=0)\n"; } else { ll m = discrete_log(g, c, p); // kn ≡ m (mod p-1): имеет d=gcd(n,p-1) решений если d|m if (m % d != 0) { cout << "x^n ≡ c: 0 solutions\n"; } else { cout << "x^n ≡ c: " << d << " solutions\n"; } } return 0; }
Ключевые наблюдения:
- Изоморфизм (через примитивный корень) сводит степенное уравнение к линейному.
- Количество решений линейного сравнения равно или 0.
- Дискретный логарифм позволяет перейти в «логарифмическое пространство».
Задачи для самостоятельного решения
| Задача | Источник | Сложность |
|---|---|---|
| Discrete Logarithm | Codeforces 1033E | ★★☆ |
| Primitive Root | Codeforces 71E | ★★☆ |
| BSGS с составным модулем | SPOJ DIOP4 | ★★★ |
| Enumeration (x^k ≡ c) | Codeforces 478D | ★★★ |
| Hard DLog (p до 10^12) | Library Checker: Discrete Log | ★★★ |
| Порядок матрицы | ICPC 2017 WF K | ★★★★ |
| DLog в группе точек эллиптической кривой | CTF задачи | ★★★★★ |
Типичные ошибки
- Переполнение при умножении. При произведение двух чисел может достигать , что переполняет
long long. Используйте__int128или__int128_tдля промежуточных вычислений:(__int128)a * b % p. - Ошибка в покрытии BSGS. При и нужно убедиться, что доходит до включительно, иначе некоторые могут быть пропущены.
- Игнорирование . BSGS ищет . Всегда проверяйте отдельно.
- Хэш-коллизии.
unordered_mapсint-ключами подвержен хэш-атакам. Используйте собственный хэш илиreserve+max_load_factor. - BSGS для . Если , то никогда. Если , то для всех .
- Обобщённый BSGS: знак . Ответ может оказаться отрицательным или нулём — нужно проверять .
Совет профессионала
Оптимизация хэш-таблицы. В соревнованиях unordered_map часто является узким местом BSGS. Рассмотрите альтернативы:
// Закрытая хэш-таблица с открытой адресацией — в 3-5 раз быстрее struct HashMap { static const int MOD = (1 << 21); // степень двойки static const int MASK = MOD - 1; pair<long long, int> table[MOD]; HashMap() { fill(begin(table), end(table), make_pair(-1LL, -1)); } void insert(long long key, int val) { int h = key & MASK; while (table[h].first != -1 && table[h].first != key) h = (h + 1) & MASK; table[h] = {key, val}; } int find(long long key) { int h = key & MASK; while (table[h].first != -1 && table[h].first != key) h = (h + 1) & MASK; return table[h].first == key ? table[h].second : -1; } };
Эта реализация даёт ускорение в 3–5 раз по сравнению со стандартным unordered_map.
Итог
Мы изучили структуру мультипликативной группы через призму примитивных корней. Основные результаты:
- Примитивный корень существует для любого простого , их ровно .
- Нахождение примитивного корня достигается перебором кандидатов с проверкой для простых .
- Алгоритм BSGS решает задачу дискретного логарифма за времени и памяти — это оптимальный алгоритм для малых (до ).
- Обобщённый BSGS обрабатывает составные модули, сводя задачу к случаю .
- Изоморфизм позволяет сводить мультипликативные задачи к аддитивным.
Дискретный логарифм — один из фундаментальных инструментов не только олимпиадной математики, но и современной криптографии. Освоив BSGS, вы получаете мощный инструмент для задач на подсчёт корней степенных уравнений, анализ структуры групп и решение задач с периодическими последовательностями.