快速傅里叶/数论变换学习简记
多项式部分是 OI 中一个比较 math 和 naive 的部分,各种多项式题声(chou)名(ming)远(zhao)扬(zhu)。
因为作者数学较弱,因此这篇文章会比较注重数学部分的理解。
同时感谢所有提供借鉴的博文和帮助作者的人!
接下来进入正文
1.多项式乘法
现在我们有两个多项式
时间复杂度为
这是由系数表示法引出来的计算方法。
这种方法由于每个点的系数固定,没有什么想法优化。
考虑初中的时候,我们用三个点的坐标求二次函数的表达式,受此启发,我们用
该多项式被
其中
当我们知道
我们发现这简直太快了,因此我们需要找到一个将系数转换为点值,再重新转换为系数的算法。
这就是 FFT。
FFT 分为两个部分:a.系数转点值(DFT)b.点值转系数(IDFT)
在介绍 FFT 前,我们先介绍以下复数。
2.复数
DFT 的关键之处在于,只要点值足够,代入是可以自己决定的。
我们在实数范围内似乎找不到
接下来是关于复数部分的论述。
前置知识:
- 圆的弧度制
- 向量的运算法则
引出复数定义:设 ,形如 的数称为复数。其中 为虚数单位。
复平面直角坐标系: 轴表示实数, 轴(除原点)代表虚数, 表示 。
模长: 。
幅角:以逆时针为正方向,从 轴正半轴到已知向量的转角的有向角。
共轭复数:实部相同,虚部相反。
运算法则:
加法:在复平面中,复数可以表示为向量,因此等同于向量。
乘法:模长相乘,幅角相加。
除法:分子分母同时乘分母的共轭复数。
单位根:
在复平面上,以原点为中心,
以原点为起点,圆的
单位根的定义非常重要
这个公式直接导致了下文我们选择求单位根的方法。
3.多项式乘法加速版本
于是先得出一个结论:单位根
以下是推导:
先假设
设多项式
按下标奇偶性分类:
设
将两式用
给
似乎
分治惯用套路,将
我们欣喜地发现,
也就是说,当我们知道
于是问题的规模缩小了一半。
接下来的问题在于怎么快速求出
嘶~似乎
那我们继续递归?
因为
实际情况下,一般通过高次补
至此我们证明了 FFT 的实现是如同线段树一样的递归实现,时间复杂度
现在我们可以开始写代码了。
4.FFT的代码实现
4.1 复数的四则运算
STL 中封装了 complex
类模板,但实现较为复杂,常数巨大。
因此考虑手写。
struct complex {
double x, y;
complex () {}
complex (double _x, double _y) : x(_x), y(_y) {}
complex operator+ (const complex& _r) noexcept { return complex(x + _r.x, y + _r.y); }
complex operator- (const complex& _r) noexcept { return complex(x - _r.x, y - _r.y); }
complex operator* (const complex& _r) noexcept {
return complex(x * _r.x - y * _r.y, x * _r.y + y * _r.x);
}
complex operator/ (const complex& _r) noexcept {
double t = _r.x * _r.x + _r.y * _r.y;
return complex((x * _r.x + y * _r.y) / t, (y * _r.x - x * _r.y) / t);
}
};
均由上述复数运算法则推导。
4.2 预处理单位根
在上文我们已经讲出了
complex unit(cos(2*Pi/n), sin(2*Pi/n)), cur(1, 0);
vector<complex> w(n);
for (int i = 0; i < n; ++i) {
w[i] = cur; cur = cur * unit;
}
4.3 常数超大的写法
我们在上文用的是递归方式思考的 FFT,那么递归自然是一种写法
void dft(complex *f, int n) { // dft -> 系数转点值
if (n == 1) return;
complex *f1 = f, *f2 = f + (n >> 1);
vector<complex> g(n);
// 指针指向的是数组头
for (int k = 0; k < n; ++k) g[k] = f[k];
for (int k = 0; k < (n >> 1); ++k)
f1[k] = g[k << 1], f2[k] = g[k << 1 | 1];
fft(f1, n >> 1); fft(f2, n >> 1);
// 递归求解
// 每次的单位根都是重新对圆进行划分,所以无法预处理
complex unit(cos(2 * Pi / n), sin(2 * Pi / n)), cur(1, 0);
for (int k = 0; k < (n >> 1); ++k) {
g[k] = f1[k] + cur * f2[k];
g[k + (n >> 1)] = f1[k] - cur * f2[k];
cur = cur * unit;
}
for (int i = 0; i < n; ++i) f[i] = g[i];
}
接下来的问题是,目前我们得到的是点值表示,但我们要求的是系数,所以要用 IDFT。
4.4 IDFT
将 DFT 中
这个的证明很有价值,可以推出单位根反演。
我们现在已经知道了点值的各种性质,而 DFT 是求点值,因此我们只需要知道怎么将点值还原即可。
设
即
回忆我们如何求点值
先给出结论:
这个式子可以将点值转换为系数。
证明:
右边
将
对
贡献 ,假设 ,贡献
由等比数列求和公式: ,这一部分没有贡献。
于是我们就证明了结论。
再看这个式子,发现其实和我们求点值的表达式非常像,因此相当于将
但是我们需要求的东西变成了
然后
这一段和 DFT 长的特别像。
void idft(Complex *f, int n) {
if (n == 1) return;
Complex *f1 = f, *f2 = f + (n >> 1);
vector<Complex> g(n);
for (int k = 0; k < n; ++k) g[k] = f[k];
for (int k = 0; k < (n >> 1); ++k)
f1[k] = g[k << 1], f2[k] = g[k << 1 | 1];
idft(f1, n >> 1); idft(f2, n >> 1);
Complex unit(cos(2 * Pi / n), -sin(2 * Pi / n)), cur(1, 0);
// 和上文相比,我们只有这里一个符号有区别,这为我们化简代码提供了思路。
for (int k = 0; k < (n >> 1); ++k) {
g[k] = f1[k] + cur * f2[k];
g[k + (n >> 1)] = f1[k] - cur * f2[k];
cur = cur * unit;
}
for (int i = 0; i < n; ++i) f[i] = g[i];
}
但是我们都知道,递归和数组复制的常数超级大,我们需要一个常数更小的办法。
4.5 基于二进制的优化策略
我们对分治的数组下标进行如下考虑。
这是原来的数组
0 1 2 3 4 5 6 7
变换一次后
0 2 4 6|1 3 5 7
变换两次后
0 4|2 6|1 5|3 7
变换三次后
0|4|2|6|1|5|3|7
我们将最后的数列和初始数组下标转换成二进制。
000 001 010 011 100 101 110 111
000 100 010 110 001 101 011 111
嗯?好像最后每个位置上的数是原位置的二进制翻转?
但是怎么快速求?常规办法
那我们尝试一下同样使用与递推有关的方式解决?
考虑一个类似 DP 的转移,当前二进制数的翻转等于将最后一位数提取出来放在开头,和剩下的数的翻转合并在一起。
考虑 r[i]
表示
for (int i = 0;i < n; ++i)
r[i] = (r[i >> 1] >> 1) | ((i & 1) ? n >> 1 : 0);
这里有一个细节,我们在处理 i>>1
的时候人为地为 r[i >> 1]
后补了 i>>1
补全位数,此时我们要剔除这个
然后我们可以采用某种迭代的方式避免递归。
所以我们拥有了一个飞快的 FFT。
#include <bits/stdc++.h>
using namespace std;
const int N = 1.5e6 + 5;
struct Complex {
double x, y;
Complex () {}
Complex (double _x, double _y) : x(_x), y(_y) {}
Complex operator+ (const Complex& _r) noexcept { return Complex(x + _r.x, y + _r.y); }
Complex operator- (const Complex& _r) noexcept { return Complex(x - _r.x, y - _r.y); }
Complex operator* (const Complex& _r) noexcept {
return Complex(x * _r.x - y * _r.y, x * _r.y + y * _r.x);
}
Complex operator/ (const Complex& _r) noexcept {
double t = _r.x * _r.x + _r.y * _r.y;
return Complex((x * _r.x + y * _r.y) / t, (y * _r.x - x * _r.y) / t);
}
}f[N << 1], g[N << 1];
const double Pi = acos(-1.0);
int n, m, r[N << 1], lim = 1;
void fft(Complex* f, int flag) {
for (int i = 0; i < lim; ++i) if (i < r[i]) swap(f[i], f[r[i]]);
for (int p = 2; p <= lim; p <<= 1) {
// 按照递归,我们当前从小区间合并到大区间
int len = p >> 1;
Complex unit(cos(2 * Pi / p), flag * sin(2 * Pi / p));
for (int k = 0; k < lim; k += p) {
// 当前区间为 k ~ k + p - 1
// 左半部分为 k ~ k + len - 1,右半部分为 k + len ~ k + p - 1
Complex cur(1, 0);
for (int l = k; l < k + len; ++l) {
Complex t = cur * f[len + l];
f[l + len] = f[l] - t;
f[l] = f[l] + t;
cur = cur * unit;
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin >> n >> m;
for (int i = 0; i <= n; ++i) cin >> f[i].x;
for (int i = 0; i <= m; ++i) cin >> g[i].x;
while (lim <= (n + m)) lim <<= 1;
for (int i = 0; i < lim; ++i) r[i] = (r[i >> 1] >> 1) | ((i & 1) ? lim >> 1 : 0);
fft(f, 1); fft(g, 1);
for (int i = 0; i < lim; ++i) f[i] = f[i] * g[i];
fft(f, -1);
for (int i = 0; i <= n + m; ++i) cout << (int)(f[i].x / lim + 0.5) << " ";
return 0;
}
至此,FFT的部分结束。多项式的基础部分代码不是很繁琐。
以上是快速傅里叶变换内容,但是复数的精度收到限制,且不好取模。
那么在一些多项式题目中 what should we do?
于是一个新东西出来了。数论变换是离散傅里叶变换(DFT)在数论基础上的实现。快速数论变换是快速傅里叶变换在数论基础上的实现。
NTT 解决的是多项式乘法带模数的情况。
接下来介绍前置知识:
- 离散傅里叶变换
见上文 - 生成子群
- 原根
- 离散对数
这一段其实挺离谱的,因为我真的找不到什么特别大的关系。
我们定义
原根有以下与单位根相同的性质:
1.
2.
3.
4.
于是我们有一个结论:
于是 FFT 中的
常用
接下来的部分同 FFT 啦。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?