Exponenciación binaria
La exponenciación binaria (también conocida como exponentiation by squaring) es un truco que permite calcular , donde es un entero no negativo, usando solo multiplicaciones (en lugar de las multiplicaciones que exige el enfoque ingenuo).
También tiene aplicaciones importantes en muchas tareas que no son aritméticas, ya que se puede usar con cualquier operación que tenga la propiedad de asociatividad:
Lo más evidente es que esto aplica a la multiplicación modular, a la multiplicación de matrices y a otros problemas que discutiremos más abajo.
Algoritmo
Elevar a la potencia se expresa de forma ingenua como multiplicar por un total de veces: . Sin embargo, este enfoque no es práctico para o grandes.
y .
La idea de la exponenciación binaria es partir el trabajo usando la representación binaria del exponente.
Escribamos en base 2, por ejemplo:
Como el número tiene exactamente dígitos en base 2, solo necesitamos hacer multiplicaciones, si conocemos las potencias .
Así que solo necesitamos una forma rápida de calcularlas. Por suerte es muy fácil, porque un elemento de la secuencia es simplemente el cuadrado del anterior.
Entonces, para obtener la respuesta final de , solo hay que multiplicar tres de ellas (saltando porque el bit correspondiente de no está prendido):
La complejidad final de este algoritmo es : hay que calcular potencias de , y luego hacer a lo sumo multiplicaciones para armar la respuesta final.
El siguiente enfoque recursivo expresa la misma idea:
Implementación
Primero el enfoque recursivo, que es una traducción directa de la fórmula recursiva:
long long binpow(long long a, long long b) {
if (b == 0)
return 1;
long long res = binpow(a, b / 2);
if (b % 2)
return res * res * a;
else
return res * res;
}El segundo enfoque resuelve la misma tarea sin recursión. Calcula todas las potencias en un bucle y multiplica las que corresponden a un bit prendido en . Aunque la complejidad de ambos enfoques es idéntica, este suele ser más rápido en la práctica porque no tiene el overhead de las llamadas recursivas.
long long binpow(long long a, long long b) {
long long res = 1;
while (b > 0) {
if (b & 1)
res = res * a;
a = a * a;
b >>= 1;
}
return res;
}Aplicaciones
Cálculo eficiente de exponentes grandes módulo un número
Problema: Calcular . Esta es una operación muy común. Por ejemplo se usa al calcular el inverso multiplicativo modular.
Solución: Como sabemos que el operador módulo no interfiere con las multiplicaciones (), podemos usar el mismo código y simplemente reemplazar cada multiplicación por una multiplicación modular:
long long binpow(long long a, long long b, long long m) {
a %= m;
long long res = 1;
while (b > 0) {
if (b & 1)
res = res * a % m;
a = a * a % m;
b >>= 1;
}
return res;
}Nota: Es posible acelerar este algoritmo cuando . Si es un número positivo y , entonces para primo, y para compuesto. Esto sigue directamente del pequeño teorema de Fermat y del teorema de Euler; ver el artículo sobre inversos modulares para más detalles.
Cálculo eficiente de números de Fibonacci
Problema: Calcular el -ésimo número de Fibonacci .
Solución: Para más detalles, ver el artículo de números de Fibonacci. Acá solo damos un panorama del algoritmo. Para calcular el siguiente número de Fibonacci solo hacen falta los dos anteriores, ya que . Podemos construir una matriz que describe esta transformación: el paso de y a y . Por ejemplo, aplicar esta transformación al par y lo convierte en y . Por lo tanto, podemos elevar esta matriz de transición a la -ésima potencia para encontrar en complejidad temporal .
Aplicar una permutación veces { data-toc-label=‘Applying a permutation times’ }
Problema: Se da una secuencia de longitud . Aplicarle una permutación dada veces.
Solución: Simplemente elevar la permutación a la -ésima potencia usando exponenciación binaria, y después aplicarla a la secuencia. Esto da una complejidad temporal de .
vector<int> applyPermutation(vector<int> sequence, vector<int> permutation) {
vector<int> newSequence(sequence.size());
for(int i = 0; i < sequence.size(); i++) {
newSequence[i] = sequence[permutation[i]];
}
return newSequence;
}
vector<int> permute(vector<int> sequence, vector<int> permutation, long long k) {
while (k > 0) {
if (k & 1) {
sequence = applyPermutation(sequence, permutation);
}
permutation = applyPermutation(permutation, permutation);
k >>= 1;
}
return sequence;
}Nota: Esta tarea se puede resolver de forma más eficiente en tiempo lineal construyendo el grafo de la permutación y considerando cada ciclo por separado. Después se puede calcular módulo el tamaño del ciclo y encontrar la posición final de cada número que pertenece a ese ciclo.
Aplicación rápida de un conjunto de operaciones geométricas a un conjunto de puntos
Problema: Dados puntos , aplicar transformaciones a cada uno de esos puntos. Cada transformación puede ser una traslación, un escalado o una rotación alrededor de un eje dado por un ángulo dado. También hay una operación de “loop” que aplica una lista dada de transformaciones veces (las operaciones “loop” pueden anidarse). Hay que aplicar todas las transformaciones más rápido que , donde es el número total de transformaciones a aplicar (después de desenrollar las operaciones “loop”).
Solución: Veamos cómo cambian las coordenadas los distintos tipos de transformaciones:
- Traslación: suma una constante distinta a cada una de las coordenadas.
- Escalado: multiplica cada una de las coordenadas por una constante distinta.
- Rotación: la transformación es más complicada (no entramos en detalles acá), pero cada una de las coordenadas nuevas todavía se puede representar como combinación lineal de las viejas.
Como se ve, cada transformación se puede representar como una operación lineal sobre las coordenadas. Así, una transformación se puede escribir como una matriz de la forma:
que, al multiplicarse por un vector con las coordenadas viejas y un uno, da un vector nuevo con las coordenadas nuevas y un uno:
\cdot = \begin{pmatrix} x' & y' & z' & 1 \end{pmatrix}
(¿Por qué introducir una cuarta coordenada ficticia? Esa es la belleza de las coordenadas homogéneas , que tienen gran aplicación en computación gráfica. Sin esto, no sería posible implementar operaciones afines como la traslación como una sola multiplicación de matrices, porque hace falta sumar una constante a las coordenadas. ¡La transformación afín se vuelve una transformación lineal en la dimensión superior!)
Algunos ejemplos de cómo se representan las transformaciones en forma matricial:
- Traslación: desplazar la coordenada en , la en y la en .
- Escalado: escalar la coordenada por y las otras dos por .
- Rotación: rotar grados alrededor del eje siguiendo la regla de la mano derecha (sentido antihorario).
Ahora, una vez que cada transformación está descrita como una matriz, la secuencia de transformaciones se describe como un producto de esas matrices, y un “loop” de repeticiones se describe como la matriz elevada a la potencia (que se puede calcular con exponenciación binaria en ). Así, la matriz que representa todas las transformaciones se puede calcular primero en , y después aplicarse a cada uno de los puntos en para una complejidad total de .
Número de caminos de longitud en un grafo { data-toc-label=‘Number of paths of length in a graph’ }
Problema: Dado un grafo dirigido no ponderado de vértices, encontrar la cantidad de caminos de longitud de cualquier vértice a cualquier otro vértice .
Solución: Este problema se trata con más detalle en un artículo separado. El algoritmo consiste en elevar la matriz de adyacencia del grafo (una matriz donde si hay una arista de a , o en caso contrario) a la -ésima potencia. Ahora será la cantidad de caminos de longitud de a . La complejidad temporal de esta solución es .
Nota: En ese mismo artículo se considera otra variante: cuando las aristas están ponderadas y hay que encontrar el camino de peso mínimo que contiene exactamente aristas. Como se muestra allí, este problema también se resuelve por exponenciación de la matriz de adyacencia. La matriz tendría el peso de la arista de a , o si no existe esa arista. En lugar de la operación usual de multiplicar dos matrices, hay que usar una modificada: en lugar de multiplicación se suman ambos valores, y en lugar de una sumatoria se toma un mínimo. Es decir: .
Variante de la exponenciación binaria: multiplicar dos números módulo { data-toc-label=‘Variation of binary exponentiation: multiplying two numbers modulo ’ }
Problema: Multiplicar dos números y módulo . y entran en los tipos de datos nativos, pero su producto es demasiado grande para entrar en un entero de 64 bits. La idea es calcular sin usar aritmética de bignums.
Solución: Simplemente aplicamos el algoritmo de construcción binaria descrito arriba, pero haciendo sumas en lugar de multiplicaciones. En otras palabras, “expandimos” la multiplicación de dos números a operaciones de suma y de multiplicación por dos (que, en esencia, es una suma).
Nota: Se puede resolver esta tarea de otra forma usando operaciones de punto flotante. Primero se calcula la expresión con números de punto flotante y se la castea a un entero sin signo . Se resta de usando aritmética de enteros sin signo y se toma módulo para encontrar la respuesta. Esta solución parece poco confiable, pero es muy rápida y muy fácil de implementar. Ver aquí para más información.
Problemas de práctica
- UVa 1230 - MODEX
- UVa 374 - Big Mod
- UVa 11029 - Leading and Trailing
- Codeforces - Parking Lot
- leetcode - Count good numbers
- Codechef - Chef and Riffles
- Codeforces - Decoding Genome
- Codeforces - Neural Network Country
- Codeforces - Magic Gems
- SPOJ - The last digit
- SPOJ - Locker
- LA - 3722 Jewel-eating Monsters
- SPOJ - Just add it
- Codeforces - Stairs and Lines