Tích chập là phép nhân hai đa thức. Tính thẳng tốn . FFT đổi đa thức sang dạng giá trị tại các căn đơn vị trong , nhân từng điểm, rồi đổi ngược; NTT làm đúng việc đó trên trường số nguyên tố, chính xác tuyệt đối. Rất nhiều bài đếm không có chữ “đa thức” trong đề: đếm cặp theo tổng, theo khoảng cách, so khớp xâu theo mọi độ dịch, đều là một tích chập.
Tích chập và NTT
Đa thức bậc nhỏ hơn được xác định bởi giá trị tại điểm. Chọn là lũy thừa của 2 và các điểm là lũy thừa của một căn đơn vị bậc : chia đa thức thành phần hệ số chẵn và lẻ, , và là căn bậc , nên hai nửa là hai bài cùng dạng kích thước một nửa. Phép “bướm”
cho . Cài không đệ quy: đảo bit chỉ số để đưa hệ số về đúng thứ tự lá, rồi gộp từng tầng độ dài 2, 4, 8, …
Theo mô-đun , căn nguyên thủy là 3, nên là căn đơn vị bậc với mọi là lũy thừa của 2 tới . Biến đổi ngược dùng căn nghịch đảo và chia cho . Để nhân, chọn là lũy thừa của 2 không nhỏ hơn .
Ví dụTích chập mô-đun
Đề. Hai dãy dài tới , hệ số theo mô-đun . In tích chập.
Lời giải. Tích chập có hệ số, nên chọn độ dài sz là lũy thừa của 2 không nhỏ hơn số đó, đệm 0 cho hai dãy. Đệm đủ dài là bắt buộc: nếu sz nhỏ hơn thì phần hệ số vượt quá sẽ cộng vòng về đầu dãy. NTT thuận đổi mỗi dãy sang dạng giá trị tại sz căn đơn vị; nhân từng điểm là nhân hai đa thức; NTT ngược đổi lại thành hệ số.
Bước 1: hoán vị đảo bit. j là i viết ngược các bit, cập nhật từ giá trị trước bằng phép “cộng 1 từ bên trái”: xóa các bit 1 cao nhất liên tiếp rồi bật bit 0 đầu tiên. Đổi chỗ khi i < j để mỗi cặp chỉ đổi một lần. Sau bước này các hệ số đứng đúng thứ tự lá của cách chia chẵn lẻ.
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 khối bằng phép bướm. len là độ dài khối đang gộp. w = 3^{(p-1)/len} là căn bậc len của đơn vị theo mô-đun , vì 3 là căn nguyên thủy của . Mỗi cặp cho u + v·wn ở nửa trên và u − v·wn ở nửa dưới.
for (int len = 2; len <= n; len <<= 1) {
ll w = power(3, (MOD - 1) / len);
if (inv) w = power(w, MOD - 2);
for (int i = 0; i < n; i += len) {
ll wn = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], 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;
}
}
}
Bước 3: biến đổi ngược. Dùng căn nghịch đảo w^{-1} (dòng if (inv) ở trên) rồi nhân mọi phần tử với .
if (inv) {
ll ni = power(n, MOD - 2);
for (int i = 0; i < n; i++) a[i] = a[i] * ni % MOD;
}
Bước 4: nhân. Chỉ in hệ số đầu; phần đệm phía sau bằng 0.
ntt(a, 0), ntt(b, 0);
for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, 1);
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
void ntt(vector<ll> &a, bool inv) {
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 = power(3, (MOD - 1) / len);
if (inv) w = power(w, MOD - 2);
for (int i = 0; i < n; i += len) {
ll wn = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], 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 (inv) {
ll ni = power(n, MOD - 2);
for (int i = 0; i < n; i++) a[i] = a[i] * ni % MOD;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
int sz = 1;
while (sz < n + m - 1) sz <<= 1;
vector<ll> a(sz), b(sz);
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < m; i++) cin >> b[i];
ntt(a, 0), ntt(b, 0);
for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, 1);
for (int i = 0; i < n + m - 1; i++) cout << a[i] << ' ';
cout << '\n';
}
2 2
12 32
33 76
396 1968 2432
Ba lần biến đổi, mỗi lần .
FFT số thực
Khi cần kết quả chính xác lớn hơn mô-đun (như số cặp, tới ), NTT theo một mô-đun không đủ. FFT trên số phức complex<double> cho kết quả gần đúng; làm tròn llround(x.real()) là đúng khi sai số dưới . Sai số tăng theo độ lớn hệ số và ; kinh nghiệm an toàn là tích các hệ số lớn nhất nhân độ dài không quá khoảng đến . Vượt mức đó thì tách hệ số làm hai nửa bit, hoặc dùng NTT theo hai, ba mô-đun rồi ghép bằng định lý thặng dư Trung Hoa.
Hỏi nhanh
Đếm cặp (táo, chuối) theo tổng trọng lượng với quả mỗi loại. Vì sao không dùng NTT mô-đun trực tiếp?
NTT tính đúng tích chập theo mô-đun. Khi đáp án thật lớn hơn mô-đun mà đề không lấy dư, phải dùng FFT số thực hoặc nhiều mô-đun.
Ví dụGhép cặp theo tổng khối lượng
Đề. táo và chuối, trọng lượng trong , . Với mỗi tổng , đếm số cặp táo, chuối có tổng trọng lượng .
Lời giải. Đặt với là số táo nặng , tương tự cho chuối. Số cặp có tổng là hệ số của . Một hệ số có thể tới , lớn hơn mô-đun của NTT, nên dùng FFT số phức rồi làm tròn. Hệ số đầu vào nhỏ (không quá ) nên sai số còn xa .
Bước 1: FFT số phức. Cùng khung với ntt của ví dụ trước. Căn đơn vị là số phức ; biến đổi ngược đổi dấu góc rồi chia cho . wn *= w tích lũy sai số qua len / 2 bước; với độ dài và hệ số cỡ này vẫn đủ.
double ang = 2 * PI / len * (inv ? -1 : 1);
cd w(cos(ang), sin(ang));
Bước 2: đếm và đọc kết quả. a[x] += 1 đếm số quả theo trọng lượng; sz đủ chứa bậc của tích. Kết quả lấy llround(a[w].real()), phần ảo chỉ là sai số.
fft(a, 0), fft(b, 0);
for (int i = 0; i < sz; i++) a[i] *= b[i];
fft(a, 1);
for (int w = 2; w <= 2 * k; w++) cout << llround(a[w].real()) << ' ';
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using cd = complex<double>;
const double PI = acos(-1);
void fft(vector<cd> &a, bool inv) {
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 * (inv ? -1 : 1);
cd w(cos(ang), sin(ang));
for (int i = 0; i < n; i += len) {
cd wn(1);
for (int j = 0; j < len / 2; j++) {
cd u = a[i + j], v = a[i + j + len / 2] * wn;
a[i + j] = u + v;
a[i + j + len / 2] = u - v;
wn *= w;
}
}
}
if (inv)
for (int i = 0; i < n; i++) a[i] /= n;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int k, n, m;
cin >> k >> n >> m;
int sz = 1;
while (sz < 2 * k + 1) sz <<= 1;
vector<cd> a(sz), b(sz);
for (int i = 0; i < n; i++) {
int x;
cin >> x;
a[x] += 1;
}
for (int i = 0; i < m; i++) {
int x;
cin >> x;
b[x] += 1;
}
fft(a, 0), fft(b, 0);
for (int i = 0; i < sz; i++) a[i] *= b[i];
fft(a, 1);
for (int w = 2; w <= 2 * k; w++) cout << llround(a[w].real()) << ' ';
cout << '\n';
}
6 4 5
2 5 4 5
4 1 2 5 5
0 1 1 1 4 4 1 4 4 0 0
.
Tương quan: đảo một dãy
Tích chập ghép các cặp có tổng chỉ số cố định. Muốn ghép theo hiệu chỉ số (khoảng cách, độ dịch), đảo một dãy: đặt thì
Vậy một tích chập cho kết quả của mọi độ dịch cùng lúc. So khớp xâu nhị phân: số vị trí khác nhau khi đặt tại là , hai tổng đầu lấy từ tổng tiền tố, tổng cuối là một tương quan. Với bảng chữ nhiều ký tự thì tương quan riêng từng ký tự, hoặc mã hóa thành số và dùng .
Ví dụKhoảng cách giữa các bit một
Đề. Xâu nhị phân dài . Với mỗi , đếm cặp với .
Lời giải. Số cặp với khoảng cách là , một tương quan của với chính nó. Đặt (dãy đảo) thì . Một tích chập cho đáp án của mọi cùng lúc. Số cặp không quá , nhỏ hơn mô-đun, nên NTT cho đúng giá trị thật. power, ntt giống hệt ví dụ đầu bài.
Bước 1: dãy và dãy đảo. Một dòng đặt cả hai; sz phải chứa hệ số của tích.
for (int i = 0; i < n; i++) a[i] = b[n - 1 - i] = s[i] - '0';
Bước 2: đọc kết quả. Đáp án của ở chỉ số ; chỉ số nhỏ hơn ứng với độ dịch âm, chỉ số là , đều không cần in.
for (int k = 1; k < n; k++) cout << a[n - 1 + k] << ' ';
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
void ntt(vector<ll> &a, bool inv) {
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 = power(3, (MOD - 1) / len);
if (inv) w = power(w, MOD - 2);
for (int i = 0; i < n; i += len) {
ll wn = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], 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 (inv) {
ll ni = power(n, MOD - 2);
for (int i = 0; i < n; i++) a[i] = a[i] * ni % MOD;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
string s;
cin >> s;
int n = s.size(), sz = 1;
while (sz < 2 * n - 1) sz <<= 1;
vector<ll> a(sz), b(sz);
for (int i = 0; i < n; i++) a[i] = b[n - 1 - i] = s[i] - '0';
ntt(a, 0), ntt(b, 0);
for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, 1);
for (int k = 1; k < n; k++) cout << a[n - 1 + k] << ' ';
cout << '\n';
}
101
0 1
.
Ví dụĐoạn nhị phân gần nhất
Đề. Hai xâu nhị phân . Đổi ít ký tự nhất của để nó trùng một đoạn liên tiếp của .
Lời giải. Đặt bắt đầu ở vị trí của . Với bit, đúng khi , và . Cộng lại trên cửa sổ, số chỗ khác là
Tổng đầu lấy từ tổng tiền tố, tổng thứ hai là hằng số, tổng cuối là tương quan, tính cho mọi bằng một tích chập với đảo như ví dụ trước. Độ dài tới nên NTT dài .
Bước 1: chuẩn bị. pre là tổng tiền tố số bit 1 của ; ones là số bit 1 của ; b là đảo.
for (int i = 0; i < n; i++) a[i] = s[i] - '0', pre[i + 1] = pre[i] + a[i];
for (int j = 0; j < m; j++) b[m - 1 - j] = t[j] - '0', ones += t[j] - '0';
Bước 2: thử mọi vị trí. Sau tích chập, a[p + m - 1] là . Tương quan không quá , nhỏ hơn mô-đun, nên giá trị sau NTT là giá trị thật.
for (int p = 0; p + m <= n; p++) best = min(best, pre[p + m] - pre[p] + ones - 2 * a[p + m - 1]);
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
void ntt(vector<ll> &a, bool inv) {
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 = power(3, (MOD - 1) / len);
if (inv) w = power(w, MOD - 2);
for (int i = 0; i < n; i += len) {
ll wn = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], 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 (inv) {
ll ni = power(n, MOD - 2);
for (int i = 0; i < n; i++) a[i] = a[i] * ni % MOD;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
string s, t;
cin >> s >> t;
int n = s.size(), m = t.size(), sz = 1;
while (sz < n + m - 1) sz <<= 1;
vector<ll> a(sz), b(sz);
vector<int> pre(n + 1);
int ones = 0;
for (int i = 0; i < n; i++) a[i] = s[i] - '0', pre[i + 1] = pre[i] + a[i];
for (int j = 0; j < m; j++) b[m - 1 - j] = t[j] - '0', ones += t[j] - '0';
ntt(a, 0), ntt(b, 0);
for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, 1);
ll best = LLONG_MAX;
for (int p = 0; p + m <= n; p++) best = min(best, pre[p + m] - pre[p] + ones - 2 * a[p + m - 1]);
cout << best << '\n';
}
001111
11
0
.
Lũy thừa đa thức ở dạng giá trị
Ở dạng giá trị, nhân đa thức là nhân từng điểm, nên lũy thừa cũng là lũy thừa từng điểm: một lần NTT thuận với độ dài đủ chứa bậc của kết quả, lũy thừa nhanh mỗi điểm, một lần NTT ngược, tổng thay vì của bình phương liên tiếp. Đếm số cách chọn phần tử có tổng từ một tập giá trị cho phép là hệ số của với là đa thức chỉ báo của tập.
Hỏi nhanh
Chữ số cho phép là , vé dài 4. Có bao nhiêu vé cân bằng (tổng hai chữ số đầu bằng tổng hai chữ số cuối)?
, . Số vé là , vì mỗi tổng của nửa đầu ghép với mọi nửa sau cùng tổng.
Ví dụVé cân bằng
Đề. Vé có chữ số ( chẵn) lấy từ một tập chữ số. Đếm số vé có tổng nửa đầu bằng tổng nửa sau, theo mô-đun .
Lời giải. trên các chữ số cho phép. Số cách viết nửa vé có tổng là hệ số của . Nửa đầu và nửa sau chọn độc lập, nên số vé cân bằng là .
Bước 1: độ dài đủ chứa . có bậc tới , nên sz là lũy thừa của 2 không nhỏ hơn , tức tới . Bậc của không vượt độ dài nên không có hệ số nào bị cộng vòng.
Bước 2: lũy thừa từng điểm. Ở dạng giá trị, nhân đa thức là nhân từng điểm, nên lũy thừa đa thức là lũy thừa từng điểm. Chỉ hai lần biến đổi thay vì lần bình phương đa thức.
ntt(p, 0);
for (int i = 0; i < sz; i++) p[i] = power(p[i], half);
ntt(p, 1);
Bước 3: cộng bình phương. ans cộng trên mọi , kể cả phần đệm (bằng 0).
for (int i = 0; i < sz; i++) ans = (ans + p[i] * p[i]) % MOD;
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
void ntt(vector<ll> &a, bool inv) {
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 = power(3, (MOD - 1) / len);
if (inv) w = power(w, MOD - 2);
for (int i = 0; i < n; i += len) {
ll wn = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], 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 (inv) {
ll ni = power(n, MOD - 2);
for (int i = 0; i < n; i++) a[i] = a[i] * ni % MOD;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, k;
cin >> n >> k;
int half = n / 2, sz = 1;
while (sz < 9 * half + 1) sz <<= 1;
vector<ll> p(sz);
for (int i = 0; i < k; i++) {
int d;
cin >> d;
p[d] = 1;
}
ntt(p, 0);
for (int i = 0; i < sz; i++) p[i] = power(p[i], half);
ntt(p, 1);
ll ans = 0;
for (int i = 0; i < sz; i++) ans = (ans + p[i] * p[i]) % MOD;
cout << ans << '\n';
}
4 2
0 1
6
với .
Khi đi thi
- Thấy tổng dạng hay trên mọi , : tích chập, đảo một dãy cho hiệu. Đếm cặp theo tổng giá trị: đa thức đếm theo giá trị.
- Có mô-đun : NTT. Kết quả thật lớn, không lấy dư: FFT số thực khi đủ chính xác, ngược lại tách hệ số hay nhiều mô-đun.
- Độ dài biến đổi là lũy thừa của 2 không nhỏ hơn bậc kết quả cộng 1; lũy thừa đa thức thì độ dài phải chứa cả bậc của lũy thừa.
Tóm tắt
- FFT, NTT: đổi sang giá trị tại căn đơn vị, nhân từng điểm, đổi ngược; .
- với căn nguyên thủy 3 cho NTT tới độ dài .
- Tương quan theo độ dịch là tích chập với một dãy đảo ngược.
- Lũy thừa đa thức: lũy thừa từng điểm ở dạng giá trị.
Bài tập
double.B và chỉ báo A.V với vị trí K cho các độ dịch xung đột; an toàn khi mọi bội của đều không xung đột.* là 0 và chữ là số dương; vị trí khớp khi , khai triển thành ba tích chập.Đọc thêm
- Biến đổi Fourier nhanhVNOI WikiFFT, NTT và ứng dụng bằng tiếng Việt.
- Fast Fourier transformcp-algorithmsCài đặt không đệ quy, NTT và các ứng dụng.
- Introduction to Fast Fourier TransformUSACO GuideBài luyện tích chập.
- 快速傅里叶变换OI WikiChứng minh và tối ưu.