Ir al contenido
wannacode

Exponenciación rápida

En resumen: La exponenciación rápida calcula aⁿ con O(log n) multiplicaciones elevando al cuadrado y leyendo los dígitos binarios de n; tomar el resto tras cada paso da la exponenciación modular.

Problema

La clave de una conexión HTTPS

Cuando el navegador abre una página HTTPS, él y el servidor acuerdan una clave secreta compartida sin enviarla por la red. En la versión clásica (Diffie–Hellman) cada lado calcula una potencia modular: gᵃ mod p, donde a es un número secreto de decenas de dígitos. El mismo cálculo está en las firmas RSA y en el hash de un texto, que multiplica por potencias de una base.

La forma ingenua es un bucle que multiplica n veces:

function powerLoop(a, n, m) {
let result = 1n;
for (let i = 0n; i < n; i++) {
result = (result * a) % m;
}
return result;
}

Con un exponente de 77 dígitos (por ejemplo 2²⁵⁵ − 19) son unas 6 · 10⁷⁶ multiplicaciones. A mil millones de multiplicaciones por segundo el bucle se ejecutaría durante 10⁶⁷ segundos, y el universo tiene unos 4 · 10¹⁷ segundos de edad. Sin embargo, el servidor hace miles de conexiones así por segundo.

La idea

El cuadrado de la mitad

3¹³ no necesita 13 multiplicaciones. 3¹³ = 3⁶ · 3⁶ · 3, y 3⁶ = 3³ · 3³. Cada paso divide el exponente por la mitad:

  • aⁿ = (aⁿᐟ²)², si n es par;
  • aⁿ = (aⁿᐟ²)² · a, si n es impar (n / 2 es división entera);
  • a⁰ = 1: este es el caso base.
function power(a, n, m) {
if (n === 0n) {
return 1n;
}
const half = power(a, n / 2n, m);
const square = (half * half) % m;
if (n % 2n === 1n) {
return (square * a) % m;
}
return square;
}
console.log(String(power(3n, 13n, 1_000_000_007n)));
console.log(String(power(5n, 1_000_000_000n, 1_000_000_007n)));
Salida
1594323
142848001

El resto mod m después de cada multiplicación mantiene los números pequeños: (x · y) mod m es lo mismo que ((x mod m) · (y mod m)) mod m. Aquí m = 1 000 000 007, un número primo y un límite habitual en los hashes y en los concursos de programación.

¿Cuánto cuesta? Cada llamada divide n entre 2, igual que la búsqueda binaria, así que la profundidad es de unos log₂ n: para n = 10⁹ son 30 marcos. La llamada no es de cola (elevar al cuadrado viene después), pero desde la primera lección sabes que 30 marcos no son nada para la pila. El trabajo es como máximo dos multiplicaciones por nivel: O(log n) en lugar de O(n).

La idea

Sin recursión: los dígitos binarios de n

Un bucle puede hacer lo mismo. 13 en binario es 1101: 13 = 8 + 4 + 1, así que 3¹³ = 3⁸ · 3⁴ · 3¹. El bucle lee los dígitos de n de derecha a izquierda (n % 2 es el último, y n /= 2 lo quita) y mantiene un a elevado al cuadrado: 3¹, 3², 3⁴, 3⁸… Con un dígito 1 el a actual entra en el resultado.

function power(a, n, m) {
let result = 1n;
a %= m;
while (n > 0n) {
if (n % 2n === 1n) {
result = (result * a) % m;
}
a = (a * a) % m;
n /= 2n;
}
return result;
}
console.log(String(power(3n, 13n, 1_000_000_007n)));
console.log(String(power(5n, 1_000_000_000n, 1_000_000_007n)));
Salida
1594323
142848001

Las multiplicaciones son tantas como dígitos tiene n (una elevación al cuadrado por dígito) más los unos que contiene (una multiplicación del resultado cada uno). Esta es la versión que usan las bibliotecas: sin recursión, y sin depender del tamaño de la pila.

Míralo

Los cuadrados entran en el resultado

function power(a, n, m) {
let result = 1n;
a %= m;
while (n > 0n) {
if (n % 2n === 1n) {
result = (result * a) % m;
}
a = (a * a) % m;
n /= 2n;
}
return result;
}
console.log(String(power(3n, 13n, 1_000_000_007n))); // 1594323
Paso a paso, en palabras
  1. result empieza en 1. n = 13, en binario 1101: cada dígito binario decide si a entra en el resultado.
  2. n = 13 es impar: result = 1 · 3 = 3. Después a pasa a ser 3² = 9 y n pasa a ser 6.
  3. n = 6 es par: result se queda en 3. Solo a se eleva al cuadrado: 9 · 9 = 81 = 3⁴, y n = 3.
  4. n = 3: result = 3 · 81 = 243 = 3⁵. a = 6561 = 3⁸, n = 1.
  5. n = 1: result = 243 · 6561 = 1594323 = 3¹³, porque 13 = 8 + 4 + 1. El bucle aún eleva a al cuadrado una vez más: 3¹⁶ es una multiplicación que nadie usa.
  6. n = 0 y el bucle se detiene: 7 multiplicaciones en lugar de 13. Con n = 10⁹ son 43 en lugar de mil millones.

Míralo

Contar las multiplicaciones

Las dos versiones con un contador, para exponentes de 10 a 10 000 000, y la rápida también para mil millones:

let multiplications = 0;
function powerLoop(a, n, m) {
let result = 1n;
for (let i = 0n; i < n; i++) {
multiplications++;
result = (result * a) % m;
}
return result;
}
function power(a, n, m) {
let result = 1n;
a %= m;
while (n > 0n) {
if (n % 2n === 1n) {
multiplications++;
result = (result * a) % m;
}
multiplications++;
a = (a * a) % m;
n /= 2n;
}
return result;
}
const m = 1_000_000_007n;
for (const n of [10n, 1_000n, 100_000n, 10_000_000n]) {
multiplications = 0;
powerLoop(5n, n, m);
const slow = multiplications;
multiplications = 0;
power(5n, n, m);
console.log(`n = ${n}: bucle ${slow}, rápido ${multiplications} multiplicaciones`);
}
multiplications = 0;
power(5n, 1_000_000_000n, m);
console.log(`n = 1000000000: rápido ${multiplications} multiplicaciones`);
Salida
n = 10: bucle 10, rápido 6 multiplicaciones
n = 1000: bucle 1000, rápido 16 multiplicaciones
n = 100000: bucle 100000, rápido 23 multiplicaciones
n = 10000000: bucle 10000000, rápido 32 multiplicaciones
n = 1000000000: rápido 43 multiplicaciones

Ejecútalas también aquí. Bench cuenta con la misma regla (una multiplicación mod m es una operación), así que los números son los de la salida de arriba. No ejecutamos mil millones con el bucle ingenuo: Bench lo estima a partir de la curva.

  • Bucle: n multiplicaciones O(n)
  • Elevar al cuadrado O(log n)
Algoritmon = 10n = 1000n = 100.000n = 10.000.000n = 1.000.000.000
Bucle: n multiplicaciones101000100.00010.000.000≈ 1.000.000.000
Elevar al cuadrado616233243
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
function power(a, n, m) {
let result = 1n;
a %= m;
while (n > 0n) {
if (n % 2n === 1n) {
result = (result * a) % m;
}
a = (a * a) % m;
n /= 2n;
}
return result;
}

El bucle ingenuo crece como n, el rápido como el número de dígitos de n: una n diez veces mayor añade unos 3,3 dígitos binarios, es decir, de 3 a 7 multiplicaciones.

Pruébalo

15 o 16

¿Qué exponente necesita más multiplicaciones?

let multiplications = 0;
function power(a, n, m) {
let result = 1n;
a %= m;
while (n > 0n) {
if (n % 2n === 1n) {
multiplications++;
result = (result * a) % m;
}
multiplications++;
a = (a * a) % m;
n /= 2n;
}
return result;
}
for (const n of [15n, 16n]) {
multiplications = 0;
power(3n, n, 1_000_000_007n);
console.log(`${n}: ${multiplications} multiplicaciones`);
}
Respuesta

La idea

Cuando el producto no cabe

El resto mantiene los números por debajo de m, pero el producto de dos números así es de hasta m². Si m² no cabe en el tipo, el cálculo es incorrecto, y en silencio. Aquí está la misma potencia con un módulo mayor:

function powerNumber(a, n, m) {
let result = 1;
a %= m;
while (n > 0) {
if (n % 2 === 1) {
result = (result * a) % m;
}
a = (a * a) % m;
n = Math.floor(n / 2);
}
return result;
}
console.log("Number:", powerNumber(5, 1_000_000_000, 1_000_000_007));
console.log("BigInt: " + power(5n, 1_000_000_000n, 1_000_000_007n));
Salida
Number: 150925072
BigInt: 142848001
Number es un double: guarda enteros exactos solo hasta 2⁵³ ≈ 9 · 10¹⁵. El producto de dos números por debajo de 1 000 000 007 es de hasta 10¹⁸, pierde sus últimos dígitos, y la respuesta es incorrecta, sin error. Por eso los números de la lección son BigInt (con n al final: 5n), que crecen según haga falta. Los dos tipos no se mezclan: 5n * 2 lanza TypeError.

La idea

Lo incorporado, y un exponente de 77 dígitos

En código real una potencia modular no se escribe a mano:

const secret = 2n ** 255n - 19n;
const p = 2n ** 127n - 1n;
const bits = secret.toString(2);
console.log("Dígitos en el exponente: " + secret.toString().length);
console.log("Multiplicaciones al elevar al cuadrado: " + (bits.length + bits.replaceAll("0", "").length));
console.log("5^secret mod p = " + power(5n, secret, p));
Salida
Dígitos en el exponente: 77
Multiplicaciones al elevar al cuadrado: 508
5^secret mod p = 97285997395055909576908201857106207893

JavaScript no tiene nada incorporado, así que aquí hace el trabajo power de la lección, con BigInt. secret.toString(2) da los dígitos binarios. 508 multiplicaciones de números de 39 dígitos en lugar de 6 · 10⁷⁶: por eso la clave se acuerda en milisegundos. Las bibliotecas necesitan aún menos, porque miran varios dígitos a la vez, pero la idea es la misma.

Pruébalo

Encuentra el error

Esta versión devuelve el resultado correcto. Pero para n = 1 000 000 hace 2.048.575 multiplicaciones en lugar de 27. ¿Qué línea tiene la culpa?

Elige la línea con el error.

Pruébalo

Completa

¿Cuándo entra el a actual en el resultado?

function power(a, n, m) {
  let result = 1n;
  a %= m;
  while (n > 0n) {
    if () {
      result = (result * a) % m;
    }
    a = (a * a) % m;
    n /= 2n;
  }
  return result;
}

Ejercicio

Intereses al céntimo

Resuélvelo en tu editor.

A cuánto crece el depósito. Pones 1000 € al 1 % de interés mensual, que se suma al importe. Lee el número de meses e imprime el importe redondeado al céntimo. Calcúlalo de forma exacta, con enteros: 1000 € son 100 000 céntimos, y tras n meses el importe es 100 000 · 101ⁿ / 100ⁿ céntimos. Escribe tu propia exponenciación rápida con BigInt, sin módulo, y redondea medio céntimo hacia arriba.

Entrada
120
Salida
Tras 120 meses: 3300,39 €

Con 120 meses las multiplicaciones son pocas incluso con el bucle ingenuo. Aquí, sin embargo, los números crecen (101¹²⁰ tiene 241 dígitos), así que cada multiplicación siguiente cuesta más, y ahorrar multiplicaciones importa aún más.

Más tareas

  • Interés diario durante 30 años. Lo mismo con un 0,01 % diario durante 10 950 días. Cuenta las multiplicaciones de las dos versiones.
  • Los últimos dígitos. Imprime los últimos 6 dígitos de 2¹⁰⁰⁰⁰⁰⁰⁰⁰⁰ (módulo 1 000 000), sin construir el número entero.

Más precisamente

Más sobre potencias

Más precisamente, para curiosos

Números de Fibonacci en O(log n). La misma elevación al cuadrado funciona con una matriz de 2 × 2 en lugar de un número: el n-ésimo número de Fibonacci sale de la n-ésima potencia de [[1, 1], [1, 0]], con unas 2 log₂ n multiplicaciones de matrices.

Curvas elípticas. Hoy TLS suele acordar la clave con una curva elíptica (X25519). Allí, en lugar de la multiplicación hay «sumar puntos», y en lugar de elevar al cuadrado hay «duplicar», pero el algoritmo es el mismo: duplicar y sumar sobre los dígitos binarios del número secreto.

Tiempo constante. El bucle de la lección multiplica el resultado solo con un dígito 1. Quien mide el tiempo puede adivinar cuántos unos tiene el número secreto. Por eso las bibliotecas criptográficas hacen el mismo trabajo para cada dígito (la escalera de Montgomery), y por eso la criptografía no se escribe a mano, sino que se toma de una biblioteca.

  • JavaScript: 2n ** 255n es una potencia BigInt sin módulo. Math.pow y ** con Number son para double.

Test

Ponte a prueba

1. ¿Cuántas multiplicaciones cuesta elevar al cuadrado para n = 1 000 000?
2. ¿Por qué el código toma el resto mod m después de cada multiplicación y no solo al final?
3. ¿Cuál es la exponenciación modular rápida incorporada?
4. En la versión recursiva la mitad se calcula con dos llamadas recursivas en lugar de una. ¿Qué pasa?
5. ¿Cuántos marcos necesita la versión recursiva para n = 1 000 000 000?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·