数论变换(NTT)

数论变换(NTT)


目录

  1. [为什么需要 NTT:从多项式乘法说起](#为什么需要 NTT:从多项式乘法说起)
  2. [从 FFT 到 NTT:动机与直觉](#从 FFT 到 NTT:动机与直觉)
  3. 数学基础:模运算、原根与单位根
  4. [NTT 核心算法:蝴蝶变换详解](#NTT 核心算法:蝴蝶变换详解)
  5. 完整代码实现
  6. [关键技巧:模数选择与任意模数 NTT](#关键技巧:模数选择与任意模数 NTT)
  7. 复杂度分析
  8. 实际应用场景
  9. 知识全景图(思维导图)
  10. 总结

1. 为什么需要 NTT:从多项式乘法说起

假设我们有两个多项式:

A ( x ) = a 0 + a 1 x + a 2 x 2 + ⋯ + a n − 1 x n − 1 A(x) = a_0 + a_1x + a_2x^2 + \cdots + a_{n-1}x^{n-1} A(x)=a0+a1x+a2x2+⋯+an−1xn−1

B ( x ) = b 0 + b 1 x + b 2 x 2 + ⋯ + b n − 1 x n − 1 B(x) = b_0 + b_1x + b_2x^2 + \cdots + b_{n-1}x^{n-1} B(x)=b0+b1x+b2x2+⋯+bn−1xn−1

它们的乘积 C ( x ) = A ( x ) ⋅ B ( x ) C(x) = A(x) \cdot B(x) C(x)=A(x)⋅B(x) 的系数为卷积

c k = ∑ i + j = k a i b j c_k = \sum_{i+j=k} a_i b_j ck=i+j=k∑aibj

如果直接按定义计算,复杂度是 O ( n 2 ) O(n^2) O(n2)。当 n n n 达到 10 5 10^5 105 甚至 10 6 10^6 106 级别时,这个复杂度是无法接受的。

关键洞察:多项式有两种等价表示方式------

  • 系数表示 : ( a 0 , a 1 , ... , a n − 1 ) (a_0, a_1, \dots, a_{n-1}) (a0,a1,...,an−1)
  • 点值表示 :在 n n n 个不同点上的取值 ( A ( x 0 ) , A ( x 1 ) , ... , A ( x n − 1 ) ) (A(x_0), A(x_1), \dots, A(x_{n-1})) (A(x0),A(x1),...,A(xn−1))

在点值表示下,两个多项式相乘只需要逐点相乘 ,复杂度是 O ( n ) O(n) O(n)!

C ( x i ) = A ( x i ) ⋅ B ( x i ) C(x_i) = A(x_i) \cdot B(x_i) C(xi)=A(xi)⋅B(xi)

于是问题转化为:如何快速地在"系数表示"与"点值表示"之间转换?这正是 FFT/NTT 要解决的问题。

复制代码
系数表示 A, B  --[快速变换]-->  点值表示 A(x_i), B(x_i)
                                        |
                                   逐点相乘 O(n)
                                        |
系数表示 C     <--[快速逆变换]--  点值表示 C(x_i)

2. 从 FFT 到 NTT:动机与直觉

FFT(快速傅里叶变换)选取的求值点是复数单位根 ω n = e 2 π i / n \omega_n = e^{2\pi i/n} ωn=e2πi/n,利用其优美的对称性质,把 O ( n 2 ) O(n^2) O(n2) 的求值过程降到 O ( n log ⁡ n ) O(n\log n) O(nlogn)。

但 FFT 存在两个现实问题:

  1. 浮点误差 :复数运算涉及三角函数与浮点数,当 n n n 很大或系数很大时,精度损失会导致结果出错。
  2. 无法直接用于模意义下的精确计算:很多场景(如竞赛、密码学)要求结果对某个质数取模,FFT 的浮点误差在这里是不可接受的。

NTT 的思路 :把复数单位根替换成"模意义下的单位根",所有运算都在有限域 Z p \mathbb{Z}_p Zp( p p p 为质数)中进行整数运算,从而做到完全精确

这就是 NTT 的核心动机------用有限域上的代数结构,模拟复数域上 FFT 的对称性


3. 数学基础:模运算、原根与单位根

3.1 为什么选质数模数

NTT 要求在模 p p p 意义下存在类似"单位根"的元素,这依赖于原根的存在性,而原根的存在性与模数的乘法群结构密切相关。为方便起见,通常选择:

p = c ⋅ 2 k + 1 p = c \cdot 2^k + 1 p=c⋅2k+1

这类质数的乘法群 Z p ∗ \mathbb{Z}_p^* Zp∗ 阶为 p − 1 = c ⋅ 2 k p-1 = c \cdot 2^k p−1=c⋅2k,包含足够大的 2 的幂次因子,能支持长度为 2 k 2^k 2k 的变换。

常用的 NTT 友好模数:

模数 p p p 分解形式 原根 g g g
998244353 119 × 2 23 + 1 119 \times 2^{23} + 1 119×223+1 3
1004535809 479 × 2 21 + 1 479 \times 2^{21} + 1 479×221+1 3
469762049 7 × 2 26 + 1 7 \times 2^{26} + 1 7×226+1 3

3.2 原根(Primitive Root)

若 g g g 满足其在模 p p p 下的 恰好为 p − 1 p-1 p−1(即 g p − 1 ≡ 1 g^{p-1} \equiv 1 gp−1≡1,且没有更小的正整数指数使其成立),则称 g g g 为模 p p p 的一个原根

原根的存在性由数论定理保证:每个质数 p p p 都存在原根

求原根的常规方法:

  1. 分解 p − 1 p-1 p−1 的所有质因子 q 1 , q 2 , ... , q m q_1, q_2, \dots, q_m q1,q2,...,qm
  2. 枚举候选 g = 2 , 3 , 4 , ... g = 2, 3, 4, \dots g=2,3,4,...
  3. 检验对每个 q i q_i qi,是否满足 g ( p − 1 ) / q i ≢ 1 ( m o d p ) g^{(p-1)/q_i} \not\equiv 1 \pmod p g(p−1)/qi≡1(modp)
  4. 若全部满足,则 g g g 是原根

3.3 n 次单位根

有了原根 g g g,我们定义模 p p p 下的 n n n 次单位根:

ω n = g p − 1 n   m o d   p \omega_n = g^{\frac{p-1}{n}} \bmod p ωn=gnp−1modp

它满足与复数单位根完全类似的性质:

  • 周期性 : ω n n ≡ 1 ( m o d p ) \omega_n^n \equiv 1 \pmod p ωnn≡1(modp)
  • 消去性质 : ω d n d k = ω n k \omega_{dn}^{dk} = \omega_n^k ωdndk=ωnk
  • 折半性质 : ω n n / 2 ≡ − 1 ( m o d p ) \omega_n^{n/2} \equiv -1 \pmod p ωnn/2≡−1(modp)(这是分治合并的关键)
  • 单位根反演 : ∑ k = 0 n − 1 ω n i k = { n i ≡ 0 ( m o d n ) 0 otherwise \sum_{k=0}^{n-1} \omega_n^{ik} = \begin{cases} n & i \equiv 0 \pmod n \\ 0 & \text{otherwise} \end{cases} ∑k=0n−1ωnik={n0i≡0(modn)otherwise

这些性质与 FFT 中复数单位根完全对应,正是它们保证了分治算法可以照搬到 NTT 上。


4. NTT 核心算法:蝴蝶变换详解

4.1 分治思想(Cooley-Tukey)

设多项式次数为 n n n( n n n 为 2 的幂),将其按下标奇偶拆分:

A ( x ) = A 0 ( x 2 ) + x ⋅ A 1 ( x 2 ) A(x) = A_0(x^2) + x \cdot A_1(x^2) A(x)=A0(x2)+x⋅A1(x2)

其中 A 0 A_0 A0 由偶数项组成, A 1 A_1 A1 由奇数项组成。

对于单位根 ω n k \omega_n^k ωnk 与 ω n k + n / 2 \omega_n^{k+n/2} ωnk+n/2,利用折半性质:

A ( ω n k ) = A 0 ( ω n / 2 k ) + ω n k ⋅ A 1 ( ω n / 2 k ) A(\omega_n^k) = A_0(\omega_{n/2}^k) + \omega_n^k \cdot A_1(\omega_{n/2}^k) A(ωnk)=A0(ωn/2k)+ωnk⋅A1(ωn/2k)

A ( ω n k + n / 2 ) = A 0 ( ω n / 2 k ) − ω n k ⋅ A 1 ( ω n / 2 k ) A(\omega_n^{k+n/2}) = A_0(\omega_{n/2}^k) - \omega_n^k \cdot A_1(\omega_{n/2}^k) A(ωnk+n/2)=A0(ωn/2k)−ωnk⋅A1(ωn/2k)

这一对公式就是蝴蝶操作(Butterfly Operation) :只需计算一次 A 0 ( ω n / 2 k ) A_0(\omega_{n/2}^k) A0(ωn/2k) 和 A 1 ( ω n / 2 k ) A_1(\omega_{n/2}^k) A1(ωn/2k) 的乘积项,就能同时得到两个位置的结果。

复制代码
        A0(ω^k) ──────┬────────► A(ω^k)      = A0 + ω^k · A1
                       │
        A1(ω^k)──[×ω^k]┴────────► A(ω^(k+n/2)) = A0 - ω^k · A1

这就是"蝴蝶"这个名字的来源------图形状如蝴蝶展翅。

4.2 位逆序置换(Bit-Reversal)

递归分治的迭代实现中,需要先将数组按二进制位逆序 重新排列。例如 n = 8 n=8 n=8 时:

原下标(二进制) 逆序后 十进制
000 000 0
001 100 4
010 010 2
011 110 6
100 001 1
101 101 5
110 011 3
111 111 7

这样排列后,就可以从底层(步长为1)开始自底向上迭代合并,避免递归带来的函数调用开销。

4.3 逆变换 INTT

逆变换的公式与正变换几乎一致,只需将单位根替换为其逆元 ω n − 1 \omega_n^{-1} ωn−1,并在最后将每个结果除以 n n n(即乘以 n − 1   m o d   p n^{-1} \bmod p n−1modp):

a i = 1 n ∑ k = 0 n − 1 A ( ω n − k ) ⋅ ω n − i k a_i = \frac{1}{n} \sum_{k=0}^{n-1} A(\omega_n^{-k}) \cdot \omega_n^{-ik} ai=n1k=0∑n−1A(ωn−k)⋅ωn−ik


5. 完整代码实现

以下是一份清晰完整的 C++ 实现(迭代版,998244353 为模数):

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll MOD = 998244353;
const ll G = 3; // 原根

ll qpow(ll a, ll b, ll mod) {
    ll res = 1; a %= mod;
    while (b > 0) {
        if (b & 1) res = res * a % mod;
        a = a * a % mod;
        b >>= 1;
    }
    return res;
}

void ntt(vector<ll>& 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) {
        ll w = qpow(G, (MOD - 1) / len, MOD);
        if (invert) w = qpow(w, MOD - 2, MOD); // 逆变换用逆元

        for (int i = 0; i < n; i += len) {
            ll wn = 1;
            for (int j = 0; j < len / 2; j++) {
                ll u = a[i + j];
                ll v = a[i + j + len / 2] * wn % MOD;
                a[i + j] = (u + v) % MOD;
                a[i + j + len / 2] = (u - v + MOD) % MOD;
                wn = wn * w % MOD;
            }
        }
    }

    if (invert) {
        ll n_inv = qpow(n, MOD - 2, MOD);
        for (ll& x : a) x = x * n_inv % MOD;
    }
}

vector<ll> multiply(vector<ll> a, vector<ll> b) {
    int sz = 1;
    while (sz < (int)(a.size() + b.size())) sz <<= 1;
    a.resize(sz); b.resize(sz);

    ntt(a, false);
    ntt(b, false);
    for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
    ntt(a, true);

    return a;
}

使用示例 :计算 ( 1 + 2 x ) × ( 3 + 4 x ) (1+2x) \times (3+4x) (1+2x)×(3+4x)

cpp 复制代码
int main() {
    vector<ll> A = {1, 2};
    vector<ll> B = {3, 4};
    vector<ll> C = multiply(A, B);
    // 期望结果:3 + 10x + 8x^2
    for (int i = 0; i < 3; i++) cout << C[i] << " ";
}

6. 关键技巧:模数选择与任意模数 NTT

6.1 为什么不能任选模数

标准 NTT 要求模数 p p p 满足 p − 1 p-1 p−1 含有足够大的 2 的幂次因子。如果题目要求对任意模数 (比如 10 9 + 7 10^9+7 109+7,虽然它恰好可用,但很多模数如 10 6 + 3 10^6+3 106+3 并不满足条件)取模,就需要额外技巧。

6.2 三模数 NTT + CRT

思路 :选取三个 NTT 友好的大质数 p 1 , p 2 , p 3 p_1, p_2, p_3 p1,p2,p3(乘积远超过结果的最大可能值),分别在这三个模数下做 NTT 卷积,得到三组结果,再用**中国剩余定理(CRT)**合并出真实结果(此时结果是一个不超过 p 1 p 2 p 3 p_1p_2p_3 p1p2p3 的大整数),最后对目标模数取模。

复制代码
原始数据 ──┬──► NTT (mod p1) ──► 结果1
           ├──► NTT (mod p2) ──► 结果2
           └──► NTT (mod p3) ──► 结果3
                                    │
                          CRT合并 ──► 真实大整数结果
                                    │
                         对目标模数取模 ──► 最终答案

6.3 MTT(拆系数)方法

另一种思路是把每个系数拆成高位和低位两部分(例如按 p \sqrt{p} p 拆分),转化为若干次可以在单一模数下完成的 NTT,从而避免使用多模数 CRT,代码更简洁但常数稍大。


7. 复杂度分析

方法 时间复杂度 空间复杂度
暴力卷积 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n)
FFT O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n ) O(n) O(n)
NTT O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n ) O(n) O(n)
三模数NTT+CRT O ( n log ⁡ n ) O(n\log n) O(nlogn)(常数为3倍) O ( n ) O(n) O(n)

NTT 相比 FFT,虽然渐进复杂度相同,但由于是纯整数运算(模乘、模加),在很多场景下常数因子更优,且完全没有精度问题。


8. 实际应用场景

8.1 大整数乘法(高精度乘法)

将大整数看作多项式(每一位是一个系数),乘法转化为多项式乘法,利用 NTT 加速,可以把高精度乘法从 O ( n 2 ) O(n^2) O(n2) 降到 O ( n log ⁡ n ) O(n\log n) O(nlogn),这是 Java BigInteger、Python 大整数乘法等底层实现常用的手段(结合 Karatsuba/Toom-Cook 用于中等规模,NTT/FFT 用于超大规模)。

8.2 多项式相关运算

NTT 是现代多项式算法的基石,可以进一步实现:

  • 多项式求逆:利用 Newton 迭代 + NTT 加速
  • 多项式开根、exp、ln:竞赛中"多项式全家桶"的核心工具
  • 多点求值与快速插值:结合分治 NTT

8.3 组合数学与生成函数

很多组合计数问题可以转化为生成函数的卷积运算,例如背包类 DP 的优化、EGF(指数生成函数)的合并等,都依赖 NTT 加速卷积过程。

8.4 字符串算法

某些字符串匹配、编辑距离类问题可以转化为卷积形式(如带通配符的字符串匹配),借助 NTT 在 O ( n log ⁡ n ) O(n\log n) O(nlogn) 内求解。

8.5 密码学:格密码与同态加密

在现代密码学中,尤其是格密码(Lattice-based Cryptography)同态加密领域(如 NIST 后量子标准 Kyber、Dilithium 等),底层运算大量依赖多项式环上的乘法,NTT 是这些方案实现高效运算的核心技术,直接影响加解密速度。


9. 知识全景图(思维导图)

为了方便你梳理 NTT 的整体知识体系,我整理了一份思维导图(见左侧/上方 Artifact 面板),涵盖从数学基础到应用场景的完整脉络:

  • 起源与动机:多项式乘法、FFT 局限、NTT 优势
  • 数学基础:模运算、原根、单位根性质
  • 核心算法:蝴蝶变换、位逆序、递归/迭代实现、逆变换
  • 关键技巧:模数选择、任意模数 NTT、优化手段
  • 应用场景:大整数乘法、多项式运算、组合数学、字符串算法、密码学
  • 复杂度分析:与暴力法、FFT 的对比

建议按照"是什么 → 为什么 → 怎么做 → 用在哪"的顺序阅读,逐步建立完整认知。


10. 总结

NTT 本质上是FFT 在有限域上的一个"整数版实现" :它借用了原根构造出的单位根,完美复刻了复数单位根的对称性质,从而把 O ( n 2 ) O(n^2) O(n2) 的多项式卷积降到 O ( n log ⁡ n ) O(n\log n) O(nlogn),同时规避了浮点误差,实现了模意义下的精确计算。

掌握 NTT 需要理解三个层次:

  1. 数学层:为什么原根能扮演单位根的角色,其背后是有限域乘法群的循环结构
  2. 算法层 :蝴蝶变换如何通过分治把求值过程从 O ( n 2 ) O(n^2) O(n2) 降到 O ( n log ⁡ n ) O(n\log n) O(nlogn)
  3. 工程层:如何应对模数限制(CRT、拆系数),如何进一步优化常数

无论是算法竞赛、大数运算库开发,还是前沿的后量子密码学,NTT 都是一个绕不开的核心工具。希望这篇文章能帮你建立起清晰而扎实的理解。

相关推荐
_olone1 小时前
AtCoder Beginner Contest 465 D - X to Y
c++·算法
青山木1 小时前
Hot 100 --- LRU 缓存
java·数据结构·算法·leetcode·链表·缓存·哈希
“码”力全开1 小时前
ONVIF摄像头接入项目实战记录
人工智能·算法·边缘计算
星夜夏空992 小时前
C++学习(3) —— C++输入输出流
c++·学习
CAU界编程小白2 小时前
CAU抢课脚本
c++·脚本
MOONICK2 小时前
windows原生条件变量支持
c++·windows
AI科技星2 小时前
公理化数学化学|48小时确权终稿(完整投产包)
人工智能·数学·算法·重构·拓扑学·乖乖数学·全域数学
汉克老师2 小时前
GESP2026年6月认证C++二级( 第三部分编程题(1、完全平方数计数))精讲
c++·循环·枚举算法·gesp2级·平方数·逆向枚举·区间判断
wuminyu2 小时前
markword在高并发场景下变化剖析
java·linux·c语言·jvm·c++