FFT (Fast Fourier Transform)
FFT (Fast Fourier Transform — Biến đổi Fourier nhanh) là thuật toán nhân hai đa thức bậc trong thay vì của cách nhân từng cặp hệ số. Trong lập trình thi đấu, FFT (và biến thể số học NTT) được dùng chủ yếu để nhân đa thức và tính tích chập (convolution) — chẳng hạn "với mỗi , đếm số cặp sao cho ". Bất kỳ bài nào có dạng
đều là tích chập và có thể tăng tốc bằng FFT/NTT.
Ý tưởng / Trực giác
Tại sao nhân đa thức lại tốn ? Vì biểu diễn theo hệ số buộc ta nhân chéo mọi cặp hệ số.
Mấu chốt: một đa thức bậc được xác định duy nhất bởi giá trị của nó tại điểm phân biệt (giống như 2 điểm xác định 1 đường thẳng, 3 điểm xác định 1 parabol). Nếu ta biết giá trị của và tại cùng một bộ điểm , thì tích tại mỗi điểm chỉ là phép nhân hai số:
Tức là ở "miền giá trị", nhân đa thức chỉ tốn . Vậy chiến lược 3 bước:
- Evaluate (chuyển hệ số → giá trị): tính , tại điểm.
- Pointwise multiply: nhân từng cặp giá trị — .
- Interpolate (chuyển giá trị → hệ số): khôi phục đa thức tích từ các giá trị.
Nếu chọn điểm tùy tiện thì bước 1 và 3 vẫn tốn . Phép màu của FFT là chọn điểm thông minh: các căn đơn vị (roots of unity) . Chúng có tính chất , nên khi chia đa thức thành phần chẵn và lẻ:
giá trị tại và tại dùng chung , . Đây chính là chia để trị: mỗi tầng gộp 2 nửa trong , có tầng, ra . Bước interpolate (IFFT) giống hệt FFT nhưng dùng rồi chia cho .
NTT (Number Theoretic Transform) thay căn đơn vị phức bằng căn nguyên thủy trong trường . Nó cần là số nguyên tố dạng để tồn tại "căn bậc của đơn vị". Lợi ích: chỉ dùng số nguyên, không có sai số dấu phẩy động, và trả kết quả sẵn theo modulo — nên trong thi đấu thường ưu tiên NTT.
Ví dụ chạy tay: nhân hai đa thức
Nhân với . Kết quả đúng (nhân tay):
Hình dung quá trình theo 3 bước (ở đây minh họa bằng "điểm" tổng quát; FFT thực tế chọn điểm là căn đơn vị, nhưng nguyên lý y hệt). Tích có bậc nên cần ít nhất điểm; ta lấy luôn :
Hệ số: A = [1, 2, 3, 0] B = [4, 5, 0, 0]
^padding cho đủ n=4 (lũy thừa của 2)
Bước 1 — EVALUATE tại 4 điểm x0..x3 (FFT):
A(x0) A(x1) A(x2) A(x3)
A → [ vA0 vA1 vA2 vA3 ]
B → [ vB0 vB1 vB2 vB3 ]
Bước 2 — POINTWISE MULTIPLY (nhân từng cột):
C → [vA0*vB0, vA1*vB1, vA2*vB2, vA3*vB3]
^ cột đang nhân: chỉ O(1) mỗi cột
Bước 3 — INTERPOLATE (IFFT) → quay lại hệ số:
C → [4, 13, 22, 15]
c0 c1 c2 c3
Kiểm chứng từng hệ số bằng định nghĩa tích chập :
| các cặp với | ||
|---|---|---|
| 0 | ||
| 1 | ||
| 2 | ||
| 3 |
Khớp với .
Cài đặt
Dưới đây là NTT in-place (Cooley–Tukey, kiểu lặp) với mod thông dụng , căn nguyên thủy (hỗ trợ độ dài tới ).
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 998244353;
const long long g = 3; // căn nguyên thủy của MOD
long long power(long long a, long long b, long long mod) {
long long res = 1; a %= mod;
for (; b; b >>= 1) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
}
return res;
}
// NTT in-place. a: vector hệ số (độ dài là lũy thừa của 2).
// invert = false: thuận (hệ số -> giá trị); true: nghịch (giá trị -> hệ số).
void ntt(vector<long long>& a, bool invert) {
int n = a.size();
// Bước 1: hoán vị bit-reversal để biến đệ quy thành lặp tại chỗ.
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]);
}
// Bước 2: gộp dần các đoạn độ dài 2, 4, 8, ... (butterfly)
for (int len = 2; len <= n; len <<= 1) {
// w = căn bậc len của đơn vị (hoặc nghịch đảo nếu invert)
long long w = invert ? power(g, MOD - 1 - (MOD - 1) / len, MOD)
: power(g, (MOD - 1) / len, MOD);
for (int i = 0; i < n; i += len) {
long long wn = 1; // wn = w^j chạy trong đoạn
for (int j = 0; j < len / 2; j++) {
long long u = a[i + j];
long long v = a[i + j + len/2] * wn % MOD;
a[i + j] = (u + v) % MOD; // butterfly trên
a[i + j + len/2] = (u - v + MOD) % MOD; // butterfly dưới (+MOD tránh âm)
wn = wn * w % MOD;
}
}
}
// Bước 3: nếu nghịch, chia tất cả cho n (nhân nghịch đảo modulo)
if (invert) {
long long n_inv = power(n, MOD - 2, MOD); // n^{-1} theo Fermat
for (auto& x : a) x = x * n_inv % MOD;
}
}
// Nhân hai đa thức a, b (kết quả lấy theo modulo MOD)
vector<long long> multiply(vector<long long> a, vector<long long> b) {
int result_size = (int)a.size() + (int)b.size() - 1;
int n = 1;
while (n < result_size) n <<= 1; // làm tròn lên lũy thừa của 2
a.resize(n); b.resize(n);
ntt(a, false); ntt(b, false); // sang miền giá trị
for (int i = 0; i < n; i++) a[i] = a[i] * b[i] % MOD; // nhân điểm-điểm
ntt(a, true); // quay lại hệ số
a.resize(result_size); // bỏ phần padding thừa
return a;
}
Dùng cho ví dụ trên:
vector<long long> a = {1, 2, 3}; // 1 + 2x + 3x^2
vector<long long> b = {4, 5}; // 4 + 5x
auto c = multiply(a, b); // c = {4, 13, 22, 15}
Tích chập đếm cặp (ví dụ đếm số cặp với ): đặt = số lần giá trị xuất hiện ở mảng thứ nhất, tương tự cho mảng thứ hai, rồi lấy multiply(f, g); hệ số chính là số cặp có tổng .
vector<long long> f(MAXV, 0), gg(MAXV, 0);
for (int x : A) f[x]++;
for (int x : B) gg[x]++;
auto res = multiply(f, gg); // res[k] = số cặp (i, j) với i + j = k
Độ phức tạp
| Thao tác | Thời gian | Vì sao |
|---|---|---|
| Một lần NTT/FFT | tầng butterfly, mỗi tầng duyệt toàn bộ phần tử một lần | |
| Nhân hai đa thức bậc | 3 lần biến đổi + một vòng nhân điểm-điểm | |
| Bộ nhớ | giữ vài mảng độ dài (lũy thừa của 2 gần nhất kích thước tích) |
Lưu ý ở đây là kích thước sau khi làm tròn lên lũy thừa của 2: nếu tích có hệ số thì nhảy lên . So với cách ngây thơ phép tính (quá chậm), FFT/NTT chỉ tốn cỡ — đó là lý do nó bắt buộc khi tới hàng trăm nghìn.
⚠️ Lỗi thường gặp
- Tràn số khi nhân: trong NTT ta liên tục tính
a * b % MOD. Với , tícha * bxấp xỉ — vượtintrất xa. Phải dùnglong longcho mọi biến tham gia nhân (kể cả biến trung gianu,v,wn). Quênlong long→ kết quả sai âm thầm, không báo lỗi. - Kết quả modulo bị âm: ở bước butterfly dưới
(u - v)có thể âm. Bắt buộc(u - v + MOD) % MOD. Quên+ MODthì có hệ số ra số âm và mọi phép sau hỏng theo. - Không làm tròn lên lũy thừa của 2: NTT/FFT chỉ chạy đúng khi độ dài là lũy thừa của 2. Nếu để bằng đúng
a.size()+b.size()-1(thường không phải lũy thừa của 2) thì hoán vị bit-reversal và các tầng gộp đều sai. Luônwhile (n < result_size) n <<= 1. - Cấp phát quá nhỏ: tích của hai đa thức bậc có bậc , tức hệ số. Nếu chỉ resize bằng kích thước hai đầu vào, các hệ số bậc cao bị "cuộn vòng" (do FFT làm việc theo modulo ) và cộng nhầm vào hệ số bậc thấp. Phải lấy tổng độ dài.
- Nghịch đảo modulo khi MOD không nguyên tố: cài
n_inv = power(n, MOD-2, MOD)dựa trên định lý Fermat, chỉ đúng khi MOD là số nguyên tố. Với NTT mod thì ổn; nhưng nếu bài yêu cầu trả kết quả theo (không phải dạng , không có căn nguyên thủy bậc lớn) thì không thể dùng NTT trực tiếp — phải dùng FFT số phức rồi quy về , hoặc NTT nhiều mod + CRT. - Sai số FFT số phức: nếu dùng FFT với
complex<double>thay vì NTT, hệ số tích phải làm trònllround. Khi hệ số lớn (vd nhân hai mảng giá trị tới ) sai sốdoublecó thể lệch và làm tròn sai. Khi cần độ chính xác tuyệt đối theo modulo, hãy chọn NTT.
Biến thể / Mở rộng
- FFT số phức: dùng khi không cần modulo (vd nhân số nguyên lớn, đếm thực). Thay căn nguyên thủy bằng
exp(2*PI*i/n), cuối cùngllroundphần thực. - NTT nhiều modulo + CRT: khi bài bắt modulo , chạy NTT với 2–3 mod NTT-friendly khác nhau rồi ghép bằng định lý số dư Trung Hoa để khôi phục kết quả đúng.
- Liên hệ tích chập khác: XOR-convolution / AND / OR convolution dùng biến đổi Walsh–Hadamard (SOS DP), không phải FFT, nhưng cùng tư tưởng "biến đổi → nhân điểm-điểm → biến đổi ngược".
Bài tập luyện
- Convoluted Intervals (convintv) — (Advanced) Đếm với mỗi số cặp đoạn có : chính là tích chập phân bố đầu mút, bài nhập môn kinh điển để nhìn ra cấu trúc convolution.
- Xử Lý Tín Hiệu (sigproc) — (Expert) Trượt mặt nạ qua tín hiệu rồi lấy tổng tích — đúng định nghĩa tích chập; với tới buộc phải dùng FFT/NTT thay vì nhân trực tiếp .