Skip to Content

Logaritmo discreto

El logaritmo discreto es un entero xx que satisface la ecuación

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

para enteros dados aa, bb y mm.

El logaritmo discreto no siempre existe; por ejemplo, no hay solución de 2x3(mod7)2^x \equiv 3 \pmod 7. No hay una condición sencilla para determinar si el logaritmo discreto existe.

En este artículo describimos el algoritmo Baby-step giant-step (pasos de bebé y de gigante), un algoritmo para calcular el logaritmo discreto propuesto por Shanks en 1971, que tiene complejidad temporal O(m)O(\sqrt{m}). Es un algoritmo de meet-in-the-middle porque usa la técnica de separar las tareas por la mitad.

Algoritmo

Consideremos la ecuación:

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

donde aa y mm son coprimos.

Sea x=npqx = np - q, donde nn es alguna constante preseleccionada (más adelante describiremos cómo elegir nn). pp se conoce como paso gigante (giant step), ya que incrementarlo en uno incrementa xx en nn. De forma análoga, qq se conoce como paso bebé (baby step).

Obviamente, cualquier número xx en el intervalo [0;m)[0; m) se puede representar de esta forma, donde p[1;mn]p \in [1; \lceil \frac{m}{n} \rceil ] y q[0;n]q \in [0; n].

Entonces la ecuación queda:

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

Usando el hecho de que aa y mm son coprimos, obtenemos:

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

Esta nueva ecuación se puede reescribir de forma simplificada:

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

Este problema se puede resolver con el método meet-in-the-middle de la siguiente manera:

  • Calcular f1f_1 para todos los argumentos posibles pp. Ordenar el arreglo de pares valor-argumento.
  • Para todos los argumentos posibles qq, calcular f2f_2 y buscar el pp correspondiente en el arreglo ordenado usando búsqueda binaria.

Complejidad

Podemos calcular f1(p)f_1(p) en O(logm)O(\log m) usando el algoritmo de exponenciación binaria. De forma análoga para f2(q)f_2(q).

En el primer paso del algoritmo, hay que calcular f1f_1 para cada argumento posible pp y luego ordenar los valores. Así, este paso tiene complejidad:

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)

En el segundo paso del algoritmo, hay que calcular f2(q)f_2(q) para cada argumento posible qq y luego hacer una búsqueda binaria sobre el arreglo de valores de f1f_1; así, este paso tiene complejidad:

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

Ahora, al sumar estas dos complejidades, obtenemos logm\log m multiplicado por la suma de nn y m/nm/n, que es mínima cuando n=m/nn = m/n, lo que significa que, para alcanzar el rendimiento óptimo, nn debe elegirse de modo que:

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

Entonces la complejidad del algoritmo queda:

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

Implementación

La implementación más simple

En el siguiente código, la función powmod calcula ab(modm)a^b \pmod m y la función solve produce una solución adecuada del problema. Devuelve 1-1 si no hay solución y, en caso contrario, devuelve una de las soluciones posibles.

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; }

En este código usamos map de la biblioteca estándar de C++ para almacenar los valores de f1f_1. Internamente, map usa un árbol rojo-negro para guardar los valores. Así, este código es un poco más lento que si hubiéramos usado un arreglo y búsqueda binaria, pero es mucho más fácil de escribir.

Nótese que nuestro código asume 00=10^0 = 1, es decir, el código calculará 00 como solución de la ecuación 0x1(modm)0^x \equiv 1 \pmod m y también como solución de 0x0(mod1)0^x \equiv 0 \pmod 1. Esta es una convención usada a menudo en álgebra, pero no está universalmente aceptada en todas las áreas. A veces 000^0 simplemente no está definido. Si no se comparte esa convención, hay que tratar el caso a=0a=0 por separado:

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

Otra cosa a notar es que, si hay varios argumentos pp que corresponden al mismo valor de f1f_1, solo almacenamos uno de esos argumentos. Esto funciona en este caso porque solo queremos devolver una solución posible. Si necesitamos devolver todas las soluciones posibles, hay que cambiar map<int, int> por, por ejemplo, map<int, vector<int>>. También hay que cambiar el segundo paso de forma correspondiente.

Implementación mejorada

Una posible mejora es deshacerse de la exponenciación binaria. Esto se puede hacer manteniendo una variable que se multiplica por aa cada vez que incrementamos qq y una variable que se multiplica por ana^n cada vez que incrementamos pp. Con este cambio, la complejidad del algoritmo sigue siendo la misma, pero ahora el factor log\log es solo por el map. En lugar de un map, también podemos usar una tabla hash (unordered_map en C++), que tiene complejidad temporal promedio O(1)O(1) para insertar y buscar.

Los problemas a menudo piden el mínimo xx que satisface la solución.
Es posible obtener todas las respuestas y tomar el mínimo, o reducir la primera respuesta encontrada usando el teorema de Euler, pero podemos ser inteligentes con el orden en que calculamos los valores y asegurar que la primera respuesta que encontremos sea la mínima.

// Devuelve el mínimo x para el cual a ^ x % m = b % m; a y m son coprimos. 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; }

La complejidad es O(m)O(\sqrt{m}) usando unordered_map.

Cuando aa y mm no son coprimos { data-toc-label=‘Cuando a y m no son coprimos’ }

Sea g=gcd(a,m)g = \gcd(a, m), y g>1g > 1. Claramente axmodma^x \bmod m para todo x1x \ge 1 será divisible por gg.

Si gbg \nmid b, no hay solución para xx.

Si gbg \mid b, sean a=gα,b=gβ,m=gνa = g \alpha, b = g \beta, m = g \nu.

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

El algoritmo baby-step giant-step se puede extender fácilmente para resolver kaxb(modm)ka^{x} \equiv b \pmod m para xx.

// Devuelve el mínimo x para el cual 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; }

La complejidad temporal sigue siendo O(m)O(\sqrt{m}) como antes, ya que la reducción inicial a aa y mm coprimos se hace en O(log2m)O(\log^2 m).

Problemas de práctica

Referencias