Transformada Rápida de Fourier (FFT)
En este artículo discutiremos un algoritmo que permite multiplicar dos polinomios de longitud en tiempo , lo cual es mejor que la multiplicación trivial que toma tiempo . Obviamente, multiplicar dos números largos también se puede reducir a multiplicar polinomios, así que dos números largos también se pueden multiplicar en tiempo (donde es la cantidad de dígitos de los números).
El descubrimiento de la transformada rápida de Fourier (FFT) se atribuye a Cooley y Tukey, que publicaron un algoritmo en 1965. Pero de hecho la FFT se había descubierto repetidamente antes, aunque su importancia no se entendió hasta la invención de las computadoras modernas. Algunos investigadores atribuyen el descubrimiento de la FFT a Runge y König en 1924. Pero en realidad Gauss ya desarrolló un método de este tipo en 1805, aunque nunca lo publicó.
Nótese que el algoritmo de FFT que se presenta aquí corre en tiempo , pero no sirve para multiplicar polinomios arbitrariamente grandes con coeficientes arbitrariamente grandes, ni para multiplicar enteros arbitrariamente grandes. Puede manejar sin problema polinomios de tamaño con coeficientes pequeños, o multiplicar dos números de tamaño , lo cual suele ser suficiente para resolver problemas de programación competitiva. Más allá de la escala de multiplicar números de bits, el rango y la precisión de los números de punto flotante usados durante el cálculo no serán suficientes para dar resultados finales exactos, aunque existen variantes más complejas que pueden realizar multiplicaciones de polinomios/enteros arbitrariamente grandes. Por ejemplo, en 1971 Schönhage y Strasser desarrollaron una variante para multiplicar números arbitrariamente grandes que aplica la FFT de forma recursiva en estructuras de anillos y corre en . Y recientemente (en 2019) Harvey y van der Hoeven publicaron un algoritmo que corre en verdadero .
Transformada discreta de Fourier
Sea un polinomio de grado :
Sin pérdida de generalidad asumimos que — la cantidad de coeficientes — es una potencia de . Si no es una potencia de , simplemente agregamos los términos faltantes y ponemos los coeficientes en .
La teoría de los números complejos nos dice que la ecuación tiene soluciones complejas (llamadas las raíces -ésimas de la unidad), y las soluciones son de la forma con . Además, estos números complejos tienen algunas propiedades muy interesantes: por ejemplo, la raíz -ésima principal se puede usar para describir todas las demás raíces -ésimas: .
La transformada discreta de Fourier (DFT) del polinomio (o, equivalentemente, del vector de coeficientes ) se define como los valores del polinomio en los puntos , es decir, es el vector:
De forma similar se define la transformada discreta de Fourier inversa: la DFT inversa de los valores del polinomio son los coeficientes del polinomio .
Así, si una DFT directa calcula los valores del polinomio en los puntos de las raíces -ésimas, la DFT inversa puede restaurar los coeficientes del polinomio usando esos valores.
Aplicación de la DFT: multiplicación rápida de polinomios
Sean dos polinomios y . Calculamos la DFT de cada uno: y .
¿Qué ocurre si multiplicamos estos polinomios? Obviamente, en cada punto los valores simplemente se multiplican, es decir
Esto significa que si multiplicamos los vectores y — multiplicando cada elemento de un vector por el elemento correspondiente del otro — entonces obtenemos nada menos que la DFT del polinomio :
Finalmente, aplicando la DFT inversa, obtenemos:
En el lado derecho, el producto de las dos DFT se entiende como el producto componente a componente de los elementos de los vectores. Esto se puede calcular en tiempo . Si podemos calcular la DFT y la DFT inversa en , entonces podemos calcular el producto de los dos polinomios (y, en consecuencia, también de dos números largos) con la misma complejidad temporal.
Hay que notar que los dos polinomios deben tener el mismo grado. De lo contrario, los dos vectores resultado de la DFT tienen distinta longitud. Podemos lograrlo agregando coeficientes con valor .
Y además, como el resultado del producto de dos polinomios es un polinomio de grado , hay que duplicar los grados de cada polinomio (otra vez rellenando con s). A partir de un vector con valores no se puede reconstruir el polinomio deseado con coeficientes.
Transformada rápida de Fourier
La transformada rápida de Fourier es un método que permite calcular la DFT en tiempo . La idea básica de la FFT es aplicar divide y vencerás. Dividimos el vector de coeficientes del polinomio en dos vectores, calculamos recursivamente la DFT de cada uno y combinamos los resultados para calcular la DFT del polinomio completo.
Así, sea un polinomio de grado , donde es una potencia de y :
Lo dividimos en dos polinomios más pequeños, uno que contiene solo los coeficientes de las posiciones pares y otro que contiene los coeficientes de las posiciones impares:
Es fácil ver que
Los polinomios y tienen solo la mitad de coeficientes que el polinomio . Si podemos calcular en tiempo lineal usando y , entonces obtenemos la recurrencia para la complejidad temporal, que resulta en por el teorema maestro.
Veamos cómo se puede lograr eso.
Supongamos que ya calculamos los vectores {k=0}^{n/2-1} = \text{DFT}(A_0) y {k=0}^{n/2-1} = \text{DFT}(A_1). Busquemos una expresión para .
Para los primeros valores podemos usar simplemente la ecuación ya mencionada :
Sin embargo, para los segundos valores necesitamos encontrar una expresión un poco distinta:
Aquí usamos de nuevo y las dos identidades y .
Por lo tanto obtenemos las fórmulas deseadas para calcular el vector completo :
(Este patrón y a veces se llama mariposa o butterfly.)
Así aprendimos a calcular la DFT en tiempo .
FFT inversa
Sea el vector — los valores del polinomio de grado en los puntos — dado. Queremos restaurar los coeficientes del polinomio. Este problema conocido se llama interpolación, y hay algoritmos generales para resolverlo. Pero en este caso especial (ya que conocemos los valores del polinomio en las raíces de la unidad) podemos obtener un algoritmo mucho más simple (que es prácticamente el mismo que la FFT directa).
Podemos escribir la DFT, según su definición, en forma matricial:
=
Esta matriz se llama matriz de Vandermonde.
Así, podemos calcular el vector multiplicando el vector por la izquierda por la inversa de la matriz:
= ^{-1}
Una comprobación rápida verifica que la inversa de la matriz tiene la siguiente forma:
Así obtenemos la fórmula:
Comparando esto con la fórmula para
notamos que estos problemas son casi los mismos, así que los coeficientes se pueden encontrar con el mismo algoritmo de divide y vencerás, al igual que la FFT directa, solo que en lugar de hay que usar , y al final hay que dividir los coeficientes resultantes por .
Así, el cálculo de la DFT inversa es casi el mismo que el de la DFT directa, y también se puede realizar en tiempo .
Implementación
Aquí presentamos una implementación recursiva simple de la FFT y de la FFT inversa, ambas en una sola función, porque la diferencia entre la FFT directa y la inversa es mínima.
Para almacenar los números complejos usamos el tipo complex de la STL de C++.
using cd = complex<double>;
const double PI = acos(-1);
void fft(vector<cd> & a, bool invert) {
int n = a.size();
if (n == 1)
return;
vector<cd> a0(n / 2), a1(n / 2);
for (int i = 0; 2 * i < n; i++) {
a0[i] = a[2*i];
a1[i] = a[2*i+1];
}
fft(a0, invert);
fft(a1, invert);
double ang = 2 * PI / n * (invert ? -1 : 1);
cd w(1), wn(cos(ang), sin(ang));
for (int i = 0; 2 * i < n; i++) {
a[i] = a0[i] + w * a1[i];
a[i + n/2] = a0[i] - w * a1[i];
if (invert) {
a[i] /= 2;
a[i + n/2] /= 2;
}
w *= wn;
}
}A la función se le pasa un vector de coeficientes, y la función calculará la DFT o la DFT inversa y guardará el resultado otra vez en este vector. El argumento indica si hay que calcular la DFT directa o la inversa. Dentro de la función primero comprobamos si la longitud del vector es igual a uno; si es el caso, no hay que hacer nada. En caso contrario, dividimos el vector en dos vectores y y calculamos la DFT de ambos de forma recursiva. Luego inicializamos el valor y una variable , que contendrá la potencia actual de . Después se calculan los valores de la DFT resultante usando las fórmulas de arriba.
Si el flag está activado, reemplazamos por , y cada uno de los valores del resultado se divide por (como esto se hace en cada nivel de la recursión, al final los valores quedarán divididos por ).
Usando esta función podemos crear una función para multiplicar dos polinomios:
vector<int> multiply(vector<int> const& a, vector<int> const& b) {
vector<cd> fa(a.begin(), a.end()), fb(b.begin(), b.end());
int n = 1;
while (n < a.size() + b.size())
n <<= 1;
fa.resize(n);
fb.resize(n);
fft(fa, false);
fft(fb, false);
for (int i = 0; i < n; i++)
fa[i] *= fb[i];
fft(fa, true);
vector<int> result(n);
for (int i = 0; i < n; i++)
result[i] = round(fa[i].real());
return result;
}Esta función trabaja con polinomios de coeficientes enteros, aunque también se puede adaptar para otros tipos. Como hay cierto error al trabajar con números complejos, hay que redondear los coeficientes resultantes al final.
Finalmente, la función para multiplicar dos números largos prácticamente no se diferencia de la función para multiplicar polinomios. Lo único que hay que hacer después es normalizar el número:
int carry = 0;
for (int i = 0; i < n; i++)
result[i] += carry;
carry = result[i] / 10;
result[i] %= 10;
}Como la longitud del producto de dos números nunca supera la longitud total de ambos números, el tamaño del vector alcanza para realizar todas las operaciones de acarreo.
Implementación mejorada: cálculo in-place
Para aumentar la eficiencia pasaremos de la implementación recursiva a una iterativa. En la implementación recursiva de arriba separamos explícitamente el vector en dos vectores: los elementos en posiciones pares se asignaban a un vector temporal, y los de posiciones impares a otro. Sin embargo, si reordenamos los elementos de cierta forma, no hace falta crear esos vectores temporales (es decir, todos los cálculos se pueden hacer “in-place”, justo en el propio vector ).
Nótese que en el primer nivel de recursión, los elementos cuyo bit más bajo de la posición era cero se asignaron al vector , y los que tenían un uno como bit más bajo de la posición se asignaron a . En el segundo nivel de recursión ocurre lo mismo, pero con el segundo bit más bajo, etc. Por lo tanto, si invertimos los bits de la posición de cada coeficiente y los ordenamos por esos valores invertidos, obtenemos el orden deseado (se llama permutación de inversión de bits, bit-reversal permutation).
Por ejemplo, el orden deseado para tiene la forma:
En efecto, en el primer nivel de recursión (rodeado por llaves), el vector se divide en dos partes y . Como se ve, en la permutación de inversión de bits esto corresponde simplemente a dividir el vector en dos mitades: los primeros elementos y los últimos elementos. Luego hay una llamada recursiva para cada mitad. Sean las DFT resultantes de cada una devueltas en el lugar de los propios elementos (es decir, la primera mitad y la segunda mitad del vector , respectivamente).
Ahora queremos combinar las dos DFT en una para el vector completo. El orden de los elementos es ideal, y también podemos realizar la unión directamente en este vector. Podemos tomar los elementos e y aplicar la transformada mariposa. El lugar de los dos valores resultantes es el mismo que el de los dos valores iniciales, así que obtenemos:
De forma similar podemos calcular la transformada mariposa de e y poner los resultados en su lugar, y así sucesivamente. Como resultado obtenemos:
Así calculamos la DFT requerida a partir del vector .
Aquí describimos el proceso de calcular la DFT solo en el primer nivel de recursión, pero lo mismo funciona obviamente también para todos los demás niveles. Así, después de aplicar la permutación de inversión de bits, podemos calcular la DFT in-place, sin memoria adicional.
Esto además nos permite deshacernos de la recursión. Simplemente empezamos en el nivel más bajo, es decir, dividimos el vector en pares y les aplicamos la transformada mariposa. Esto deja el vector con el trabajo del último nivel aplicado. En el paso siguiente dividimos el vector en vectores de tamaño , y otra vez aplicamos la transformada mariposa, lo que nos da la DFT de cada bloque de tamaño . Y así sucesivamente. Finalmente, en el último paso obtenemos el resultado de las DFT de ambas mitades de , y aplicando la transformada mariposa obtenemos la DFT del vector completo.
using cd = complex<double>;
const double PI = acos(-1);
int reverse(int num, int lg_n) {
int res = 0;
for (int i = 0; i < lg_n; i++) {
if (num & (1 << i))
res |= 1 << (lg_n - 1 - i);
}
return res;
}
void fft(vector<cd> & a, bool invert) {
int n = a.size();
int lg_n = 0;
while ((1 << lg_n) < n)
lg_n++;
for (int i = 0; i < n; i++) {
if (i < reverse(i, lg_n))
swap(a[i], a[reverse(i, lg_n)]);
}
for (int len = 2; len <= n; len <<= 1) {
double ang = 2 * PI / len * (invert ? -1 : 1);
cd wlen(cos(ang), sin(ang));
for (int i = 0; i < n; i += len) {
cd w(1);
for (int j = 0; j < len / 2; j++) {
cd u = a[i+j], v = a[i+j+len/2] * w;
a[i+j] = u + v;
a[i+j+len/2] = u - v;
w *= wlen;
}
}
}
if (invert) {
for (cd & x : a)
x /= n;
}
}Primero aplicamos la permutación de inversión de bits intercambiando cada elemento con el elemento de la posición invertida. Luego, en los estados del algoritmo, calculamos la DFT de cada bloque del tamaño correspondiente . Para todos esos bloques tenemos la misma raíz de la unidad . Iteramos todos los bloques y aplicamos la transformada mariposa en cada uno.
Podemos optimizar aún más la inversión de bits. En la implementación anterior iterábamos todos los bits del índice y creábamos el índice con los bits invertidos. Sin embargo, podemos invertir los bits de otra forma.
Supongamos que ya contiene el reverso de . Entonces, para pasar a , hay que incrementar , y también hay que incrementar , pero en un sistema numérico “invertido”. Sumar uno en el sistema binario convencional equivale a invertir todos los unos de cola en ceros e invertir el cero justo antes de ellos en un uno. De forma equivalente, en el sistema numérico “invertido” invertimos todos los unos iniciales y también el siguiente cero.
Así obtenemos la siguiente implementación:
using cd = complex<double>;
const double PI = acos(-1);
void fft(vector<cd> & a, bool invert) {
int n = a.size();
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j)
swap(a[i], a[j]);
}
for (int len = 2; len <= n; len <<= 1) {
double ang = 2 * PI / len * (invert ? -1 : 1);
cd wlen(cos(ang), sin(ang));
for (int i = 0; i < n; i += len) {
cd w(1);
for (int j = 0; j < len / 2; j++) {
cd u = a[i+j], v = a[i+j+len/2] * w;
a[i+j] = u + v;
a[i+j+len/2] = u - v;
w *= wlen;
}
}
}
if (invert) {
for (cd & x : a)
x /= n;
}
}Además, podemos precomputar de antemano la permutación de inversión de bits. Esto es especialmente útil cuando el tamaño es el mismo para todas las llamadas. Pero incluso cuando solo tenemos tres llamadas (que son las necesarias para multiplicar dos polinomios), el efecto se nota. También podemos precomputar todas las raíces de la unidad y sus potencias.
Transformada teórica de números
Ahora cambiamos un poco el objetivo. Seguimos queriendo multiplicar dos polinomios en tiempo , pero esta vez queremos calcular los coeficientes módulo algún número primo . Por supuesto, para esta tarea podemos usar la DFT normal y aplicar el operador módulo al resultado. Sin embargo, hacer eso puede llevar a errores de redondeo, sobre todo al tratar con números grandes. La transformada teórica de números (NTT) tiene la ventaja de que solo trabaja con enteros y, por lo tanto, se garantiza que los resultados son correctos.
La transformada discreta de Fourier se basa en números complejos y en las raíces -ésimas de la unidad. Para calcularla de forma eficiente usamos de manera extensa propiedades de las raíces (por ejemplo, que hay una raíz que genera todas las demás por exponenciación).
Pero las mismas propiedades valen para las raíces -ésimas de la unidad en aritmética modular. Una raíz -ésima de la unidad en un cuerpo primo es un número que cumple:
Las otras raíces se pueden obtener como potencias de la raíz .
Para aplicarlo en el algoritmo de la transformada rápida de Fourier, necesitamos que exista una raíz para algún que sea potencia de , y también para todas las potencias menores. Podemos notar la siguiente propiedad interesante:
Así, si es una raíz -ésima de la unidad, entonces es una raíz -ésima de la unidad. Y en consecuencia, para todas las potencias de dos menores existen raíces del grado requerido, y se pueden calcular usando .
Para calcular la DFT inversa necesitamos el inverso de . Pero para un módulo primo el inverso siempre existe.
Así, todas las propiedades que necesitamos de las raíces complejas también están disponibles en aritmética modular, siempre que tengamos un módulo lo bastante grande para el cual exista una raíz -ésima de la unidad.
Por ejemplo, podemos tomar los siguientes valores: módulo , . Si este módulo no alcanza, hay que encontrar otro par. Podemos usar el hecho de que para módulos de la forma (y primo) siempre existe la raíz -ésima de la unidad. Se puede demostrar que es una raíz -ésima de la unidad de ese tipo, donde es una raíz primitiva de .
const int mod = 7340033;
const int root = 5;
const int root_1 = 4404020;
const int root_pw = 1 << 20;
void fft(vector<int> & a, bool invert) {
int n = a.size();
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j)
swap(a[i], a[j]);
}
for (int len = 2; len <= n; len <<= 1) {
int wlen = invert ? root_1 : root;
for (int i = len; i < root_pw; i <<= 1)
wlen = (int)(1LL * wlen * wlen % mod);
for (int i = 0; i < n; i += len) {
int w = 1;
for (int j = 0; j < len / 2; j++) {
int u = a[i+j], v = (int)(1LL * a[i+j+len/2] * w % mod);
a[i+j] = u + v < mod ? u + v : u + v - mod;
a[i+j+len/2] = u - v >= 0 ? u - v : u - v + mod;
w = (int)(1LL * w * wlen % mod);
}
}
}
if (invert) {
int n_1 = inverse(n, mod);
for (int & x : a)
x = (int)(1LL * x * n_1 % mod);
}
}Aquí la función inverse calcula el inverso modular (véase Inverso multiplicativo modular).
Las constantes mod, root, root_pw determinan el módulo y la raíz, y root_1 es el inverso de root módulo mod.
En la práctica esta implementación es más lenta que la que usa números complejos (debido a la enorme cantidad de operaciones de módulo), pero tiene algunas ventajas como menor uso de memoria y ausencia de errores de redondeo.
Multiplicación con módulo arbitrario
Aquí queremos lograr el mismo objetivo que en la sección anterior. Multiplicar dos polinomios y , y calcular los coeficientes módulo algún número . La transformada teórica de números solo funciona para ciertos números primos. ¿Qué pasa cuando el módulo no tiene la forma deseada?
Una opción sería realizar varias transformadas teóricas de números con distintos números primos de la forma , y luego aplicar el Teorema Chino del Resto para calcular los coeficientes finales.
Otra opción es repartir los polinomios y en dos polinomios más pequeños cada uno
con .
Entonces el producto de y se puede representar como:
Los polinomios , , y contienen solo coeficientes menores que ; por lo tanto, los coeficientes de todos los productos que aparecen son menores que , lo cual suele ser lo bastante pequeño para manejarlo con tipos típicos de punto flotante.
Este enfoque, por lo tanto, requiere calcular los productos de polinomios con coeficientes más pequeños (usando la FFT normal y la FFT inversa), y luego el producto original se puede restaurar usando suma y multiplicación modular en tiempo .
Aplicaciones
La DFT se puede usar en una enorme variedad de otros problemas que, a primera vista, no tienen nada que ver con multiplicar polinomios.
Todas las sumas posibles
Se nos dan dos arreglos y . Hay que encontrar todas las sumas posibles y, para cada suma, contar cuántas veces aparece.
Por ejemplo, para y obtenemos: la suma se puede obtener de forma, la suma también de forma, de , de , de .
Construimos para los arreglos y dos polinomios y . Los números del arreglo actuarán como los exponentes en el polinomio (); y los coeficientes de ese término serán cuántas veces aparece el número en el arreglo.
Luego, al multiplicar estos dos polinomios en tiempo , obtenemos un polinomio , donde los exponentes nos dirán qué sumas se pueden obtener, y los coeficientes nos dirán cuántas veces. Para demostrarlo en el ejemplo:
Todos los productos escalares posibles
Se nos dan dos arreglos y de longitud . Hay que calcular los productos de con cada desplazamiento cíclico de .
Generamos dos arreglos nuevos de tamaño : Invertimos y le agregamos ceros. Y simplemente concatenamos consigo mismo. Cuando multiplicamos estos dos arreglos como polinomios, y miramos los coeficientes del producto , obtenemos:
Y como todos los elementos para :
Es fácil ver que esta suma es justamente el producto escalar del vector con el -ésimo desplazamiento cíclico a la izquierda de . Así, estos coeficientes son la respuesta al problema, y aún así pudimos obtenerla en tiempo . Nótese aquí que también nos da el -ésimo desplazamiento cíclico, pero ese es el mismo que el -ésimo desplazamiento cíclico, así que no hace falta considerarlo por separado en la respuesta.
Dos franjas
Se nos dan dos franjas booleanas (arreglos cíclicos de valores y ) y . Queremos encontrar todas las formas de pegar la primera franja a la segunda de modo que en ninguna posición tengamos un de la primera franja al lado de un de la segunda.
El problema en realidad no se diferencia mucho del anterior. Pegar dos franjas significa simplemente que aplicamos un desplazamiento cíclico al segundo arreglo, y podemos pegar las dos franjas si el producto escalar de los dos arreglos es .
Matching de strings
Se nos dan dos strings, un texto y un patrón , formados por letras minúsculas. Hay que calcular todas las ocurrencias del patrón en el texto.
Creamos un polinomio para cada string ( y son números entre y correspondientes a las letras del alfabeto):
con
Y
con
Nótese que con la expresión se invierte explícitamente el patrón.
Los coeficientes -ésimos del producto de los dos polinomios nos dirán si el patrón aparece en el texto en la posición .
con y
Si hay un match, entonces , y por lo tanto . Esto da (usando la identidad trigonométrica de Pitágoras):
Si no hay un match, entonces al menos un carácter es distinto, lo que hace que uno de los productos no sea igual a , y eso lleva a que el coeficiente .
Matching de strings con comodines
Esta es una extensión del problema anterior. Esta vez permitimos que el patrón contenga el carácter comodín , que puede coincidir con cualquier letra posible. Por ejemplo, el patrón aparece en el texto en exactamente tres posiciones, en el índice , el índice y el índice .
Creamos exactamente los mismos polinomios, excepto que ponemos si . Si es la cantidad de comodines en , entonces habrá un match de en en el índice si .
Problemas de práctica
- SPOJ - POLYMUL
- SPOJ - MAXMATCH
- SPOJ - ADAMATCH
- Codeforces - Yet Another String Matching Problem
- Codeforces - Lightsabers (hard)
- Codeforces - Running Competition
- Kattis - A+B Problem
- Kattis - K-Inversions
- Codeforces - Dasha and cyclic table
- CodeChef - Expected Number of Customers
- CodeChef - Power Sum
- Codeforces - Centroid Probabilities