Перейти до основного вмісту

Дискретний логарифм

Дискретний логарифм — це ціле число xx, що задовольняє рівняння

axb(modm)a^x \equiv b \pmod m

для заданих цілих чисел aa, bb і mm.

Дискретний логарифм існує не завжди, наприклад, рівняння 2x3(mod7)2^x \equiv 3 \pmod 7 не має розв'язку. Простої умови, яка б визначала, чи існує дискретний логарифм, немає.

У цій статті ми опишемо алгоритм «крок немовляти — крок велетня» (baby-step giant-step), запропонований Шенксом у 1971 році для обчислення дискретного логарифма, який має часову складність O(m)O(\sqrt{m}). Це алгоритм типу «зустріч посередині» (meet-in-the-middle), оскільки він використовує прийом розбиття задачі навпіл.

Коли підходить цей алгоритм?
  • Чи потрібно знайти показник xx у рівнянні axb(modm)a^x \equiv b \pmod m (а не сам степінь)? (якщо навпаки треба обчислити axmodma^x \bmod mБінарне піднесення до степеня)
  • Чи модуль mm достатньо малий, щоб O(m)O(\sqrt{m}) кроків і пам'ять під хеш-таблицю вкладалися в ліміти (зазвичай m1012m \lesssim 10^{12}101410^{14})?
  • Якщо це рівняння xka(modn)x^k \equiv a \pmod n відносно основи, а не показника — (→ Дискретний корінь)?

Алгоритм

Розглянемо рівняння:

axb(modm),a^x \equiv b \pmod m,

де aa і mm взаємно прості.

Нехай x=npqx = np - q, де nn — деяка наперед обрана стала (як її обирати, ми розповімо згодом). pp називають кроком велетня (giant step), оскільки збільшення його на одиницю збільшує xx на nn. Аналогічно, qq називають кроком немовляти (baby step).

Очевидно, будь-яке число xx з проміжку [0;m)[0; m) можна подати в такій формі, де p[1;mn]p \in [1; \lceil \frac{m}{n} \rceil ] і q[0;n]q \in [0; n].

Тоді рівняння набуває вигляду:

anpqb(modm).a^{np - q} \equiv b \pmod m.

Користуючись тим, що aa і mm взаємно прості, отримуємо:

anpbaq(modm)a^{np} \equiv ba^q \pmod m

Це нове рівняння можна переписати у спрощеній формі:

f1(p)=f2(q).f_1(p) = f_2(q).

Цю задачу можна розв'язати методом «зустріч посередині» так:

  • Обчислити f1f_1 для всіх можливих аргументів pp. Відсортувати масив пар «значення — аргумент».
  • Для всіх можливих аргументів qq обчислити f2f_2 і шукати відповідний pp у відсортованому масиві за допомогою бінарного пошуку.

Складність

Ми можемо обчислити f1(p)f_1(p) за O(logm)O(\log m) за допомогою алгоритму бінарного піднесення до степеня. Аналогічно і для f2(q)f_2(q).

На першому кроці алгоритму нам потрібно обчислити f1f_1 для кожного можливого аргументу pp, а потім відсортувати значення. Отже, цей крок має складність:

O(mn(logm+logmn))=O(mnlogm)O\left(\left\lceil \frac{m}{n} \right\rceil \left(\log m + \log \left\lceil \frac{m}{n} \right\rceil \right)\right) = O\left( \left\lceil \frac {m}{n} \right\rceil \log m\right)

На другому кроці алгоритму нам потрібно обчислити f2(q)f_2(q) для кожного можливого аргументу qq, а потім виконати бінарний пошук у масиві значень f1f_1, тож цей крок має складність:

O(n(logm+logmn))=O(nlogm).O\left(n \left(\log m + \log \frac{m}{n} \right) \right) = O\left(n \log m\right).

Тепер, коли ми додаємо ці дві складності, отримуємо logm\log m, помножений на суму nn і m/nm/n, яка є мінімальною за n=m/nn = m/n, тобто, щоб досягти оптимальної швидкодії, nn слід обирати так, щоб:

n=m.n = \sqrt{m}.

Тоді складність алгоритму стає:

O(mlogm).O(\sqrt {m} \log m).

Реалізація

Найпростіша реалізація

У наведеному нижче коді функція powmod обчислює ab(modm)a^b \pmod m, а функція solve дає коректний розв'язок задачі. Вона повертає 1-1, якщо розв'язку немає, і повертає один із можливих розв'язків в іншому разі.

int powmod(int a, int b, int m) {
int res = 1;
while (b > 0) {
if (b & 1) {
res = (res * 1ll * a) % m;
}
a = (a * 1ll * a) % m;
b >>= 1;
}
return res;
}

int solve(int a, int b, int m) {
a %= m, b %= m;
int n = sqrt(m) + 1;
map<int, int> vals;
for (int p = 1; p <= n; ++p)
vals[powmod(a, p * n, m)] = p;
for (int q = 0; q <= n; ++q) {
int cur = (powmod(a, q, m) * 1ll * b) % m;
if (vals.count(cur)) {
int ans = vals[cur] * n - q;
return ans;
}
}
return -1;
}

У цьому коді ми використали map зі стандартної бібліотеки C++ для зберігання значень f1f_1. Усередині map використовує червоно-чорне дерево для зберігання значень. Тож цей код трохи повільніший, ніж якби ми використали масив і бінарний пошук, але його значно простіше написати.

Зауважте, що наш код припускає 00=10^0 = 1, тобто код обчислить 00 як розв'язок рівняння 0x1(modm)0^x \equiv 1 \pmod m, а також як розв'язок 0x0(mod1)0^x \equiv 0 \pmod 1. Це часто вживана угода в алгебрі, але вона не є загальноприйнятою в усіх галузях. Іноді 000^0 просто вважають невизначеним. Якщо вам не подобається наша угода, тоді вам потрібно обробити випадок a=0a=0 окремо:

if (a == 0)
return b == 0 ? 1 : -1;

Ще одна річ, на яку варто звернути увагу: якщо є кілька аргументів pp, що відображаються в одне й те саме значення f1f_1, ми зберігаємо лише один такий аргумент. У цьому випадку це працює, бо ми хочемо повернути лише один можливий розв'язок. Якщо ж нам потрібно повернути всі можливі розв'язки, ми маємо змінити map<int, int> на, скажімо, map<int, vector<int>>. Також нам потрібно відповідно змінити другий крок.

Покращена реалізація

Можливе покращення — позбутися бінарного піднесення до степеня. Це можна зробити, тримаючи змінну, яку множимо на aa щоразу, коли збільшуємо qq, і змінну, яку множимо на ana^n щоразу, коли збільшуємо pp. З цією зміною складність алгоритму залишається тією самою, але тепер множник log\log припадає лише на map. Замість map ми можемо також використати хеш-таблицю (unordered_map у C++), яка має середню часову складність O(1)O(1) для вставки та пошуку.

Задачі часто просять знайти мінімальне xx, що задовольняє розв'язок.
Можна отримати всі відповіді й узяти мінімум, або зменшити першу знайдену відповідь за допомогою теореми Ейлера, але ми можемо діяти розумніше з порядком, у якому обчислюємо значення, і гарантувати, що перша знайдена відповідь буде мінімальною.

// Повертає мінімальне x, для якого a ^ x % m = b % m, де a і m взаємно прості.
int solve(int a, int b, int m) {
a %= m, b %= m;
int n = sqrt(m) + 1;

int an = 1;
for (int i = 0; i < n; ++i)
an = (an * 1ll * a) % m;

unordered_map<int, int> vals;
for (int q = 0, cur = b; q <= n; ++q) {
vals[cur] = q;
cur = (cur * 1ll * a) % m;
}

for (int p = 1, cur = 1; p <= n; ++p) {
cur = (cur * 1ll * an) % m;
if (vals.count(cur)) {
int ans = n * p - vals[cur];
return ans;
}
}
return -1;
}

Складність дорівнює O(m)O(\sqrt{m}) при використанні unordered_map.

Коли aa і mm не взаємно прості

Нехай g=gcd(a,m)g = \gcd(a, m), і g>1g > 1. Очевидно, axmodma^x \bmod m для кожного x1x \ge 1 буде ділитися на gg.

Якщо gbg \nmid b, то розв'язку для xx немає.

Якщо gbg \mid b, нехай a=gα,b=gβ,m=gνa = g \alpha, b = g \beta, m = g \nu.

axbmodm (gα)ax1gβmodgν αax1βmodν\begin{aligned} a^x & \equiv b \mod m \\\ (g \alpha) a^{x - 1} & \equiv g \beta \mod g \nu \\\ \alpha a^{x-1} & \equiv \beta \mod \nu \end{aligned}

Алгоритм «крок немовляти — крок велетня» (BSGS) можна легко розширити, щоб розв'язувати kaxb(modm)ka^{x} \equiv b \pmod m відносно xx.

// Повертає мінімальне x, для якого a ^ x % m = b % m.
int solve(int a, int b, int m) {
a %= m, b %= m;
int k = 1, add = 0, g;
while ((g = gcd(a, m)) > 1) {
if (b == k)
return add;
if (b % g)
return -1;
b /= g, m /= g, ++add;
k = (k * 1ll * a / g) % m;
}

int n = sqrt(m) + 1;
int an = 1;
for (int i = 0; i < n; ++i)
an = (an * 1ll * a) % m;

unordered_map<int, int> vals;
for (int q = 0, cur = b; q <= n; ++q) {
vals[cur] = q;
cur = (cur * 1ll * a) % m;
}

for (int p = 1, cur = k; p <= n; ++p) {
cur = (cur * 1ll * an) % m;
if (vals.count(cur)) {
int ans = n * p - vals[cur] + add;
return ans;
}
}
return -1;
}

Часова складність залишається O(m)O(\sqrt{m}), як і раніше, оскільки початкове зведення до взаємно простих aa і mm виконується за O(log2m)O(\log^2 m).

Задачі для практики

Джерела

Відеоматеріали