Преобразование Уолша — Адамара и xor-and-or-свертки
Это старая версия статьи. Новая и улучшенная версия с меньшим количеством математики доступна здесь.
Быстрое преобразование Фурье сворачивает два массива, отправляя $a[i], b[j] \to c[i + j]$, что соответствует тому, что $x^i \cdot x^j = x^{i + j}$.
Сейчас мы рассмотрим случаи, когда $x^i \cdot x^j = x^{i \oplus j}$, $x^i \cdot x^j = x^{i | j}$ и $x^i \cdot x^j = x^{i \& j}$.
Преобразование Фурье выглядело примерно таким образом: была матрица
$$M = \begin{pmatrix} \omega_0^0 & \omega_0^1 & \omega_0^2 & \ldots & \omega_0^{n - 1}\\ \omega_1^0 & \omega_1^1 & \omega_1^2 & \ldots & \omega_1^{n - 1}\\ \vdots & \vdots & \vdots & \ddots & \vdots\\ \omega_{n - 1}^0 & \omega_{n - 1}^1 & \omega_{n - 1}^2 & \ldots & \omega_{n - 1}^{n - 1} \end{pmatrix} $$
Были векторы $A$ и $B$. Мы применяли матрицу $M$ к $A$ и $B$, после чего перемножали $MA$ и $MB$ поэлементно, а затем применяли $M^{-1}$ к $MA \cdot MB$.
xor-свертка
Здесь мы хотим сделать ровно то же самое, надо лишь поменять матрицу.
Давайте зададим матрицы $H_{2^k}$ рекурсивно. $H_1 = \begin{pmatrix} 1 \end{pmatrix} $ и $H_{2^k} = \frac{1}{\sqrt{2}} \begin{pmatrix} H_{2^{k - 1}} & H_{2^{k - 1}}\\ H_{2^{k - 1}} & -H_{2^{k - 1}} \end{pmatrix} $
Корень из двух — это не очень удобное число, давайте попытаемся от него избавиться. Определим матрицы без этого корня: $G_1 = \begin{pmatrix} 1 \end{pmatrix} $ и $G_{2^k} = \begin{pmatrix} G_{2^{k - 1}} & G_{2^{k - 1}}\\ G_{2^{k - 1}} & -G_{2^{k - 1}} \end{pmatrix} $
Тогда легко заметить, что $\sqrt{n} \cdot H_n = G_n$. Мы будем считать именно $G_n$.
Обозначение: $E_n$ — это матрица тождественного преобразования размера $n \times n$, то есть матрица, у которой на главной диагонали стоят единицы, а в остальных ячейках нули.
Из-за такого рекурсивного задания сразу видно, как написать быстрое преобразование Уолша-Адамара, то есть как посчитать $G_{2^k} \cdot A$. На нижнем уровне рекурсии не нужно менять ничего, потому что $G_1 = E_1$, а на других уровнях надо сначала запуститься от двух половин, а потом сделать замену $x, y \to x + y, x - y$. Напишем код:
void hadamard(vector<int> &a, int l, int r) {
if (l + 1 == r) {
return;
}
int mid = (r + l) / 2;
hadamard(a, l, mid);
hadamard(a, mid, r);
for (int i = l; i < mid; i++) {
int u = a[i], v = a[mid + (i - l)];
a[i] = u + v;
a[mid + (i - l)] = u - v;
}
}
Лемма:
Удивительным образом получается, что $H_{2^k}^{-1} = H_{2^k}$.
Доказательство: Докажем это по индукции. Для $H_1$ это очевидно, потому что $H_1 = E_1$. Докажем для $H_{2^k}$, если уже известно для $H_{2^{k - 1}}$.
$$H_{2^k} \cdot H_{2^k} = \frac{1}{\sqrt{2}} \begin{pmatrix} H_{2^{k - 1}} & H_{2^{k - 1}}\\ H_{2^{k - 1}} & -H_{2^{k - 1}} \end{pmatrix} \cdot \frac{1}{\sqrt{2}} \begin{pmatrix} H_{2^{k - 1}} & H_{2^{k - 1}}\\ H_{2^{k - 1}} & -H_{2^{k - 1}} \end{pmatrix} =$$
$$ = \frac{1}{2} \begin{pmatrix} 2H_{2^{k - 1}}^2 & 0\\ 0 & 2H_{2^{k - 1}}^2 \end{pmatrix} = \frac{1}{2} \begin{pmatrix} 2E_{2^{k - 1}} & 0\\ 0 & 2E_{2^{k - 1}} \end{pmatrix} $$
Мы получили, что $H_n \cdot H_n = E_n$. Но мы знаем, что $H_n = \frac{G_n}{\sqrt{n}}$, поэтому $G_n \cdot G_n = n \cdot E_n$. Так что вместо того, чтобы домножать на $H$, можно домножать на $G$, но в конце просто поделить на $n$ после обратного преобразования. Таким образом мы научились делать xor-умножение многочленов без использования вещественных или комплексных чисел:
// we assume that a.size() == b.size() == 2^k
vector<int> xormult(vector<int> a, vector<int> b) {
hadamard(a, 0, (int)a.size());
hadamard(b, 0, (int)b.size());
for (size_t i = 0; i < a.size(); i++) {
a[i] *= b[i];
}
hadamard(a, 0, a.size()); // inverse convolution is the same as direct
for (size_t i = 0; i < a.size(); i++) {
a[i] /= (int)a.size();
}
return a;
}
Если вы внимательно следили за происходящим, то могли заметить, что я вас немного обманул. Я доказал кучу всего, но никак не объяснил, почему все это даст нам xor-свертку.
Обозначим xor-свертку за $\$$, то есть
$$(a_0, a_1, \ldots, a_n) \$ (b_0, b_1, \ldots, b_n) = (\sum \limits_{k = 0}^{n} a_k \cdot b_{0 \oplus k}, \sum \limits_{k = 0}^{n} a_k \cdot b_{1 \oplus k}, \ldots, \sum \limits_{k = 0}^{n} a_k \cdot b_{n \oplus k})$$
Тогда нам необходимо доказать, что $(Ga) \cdot (Gb) = G (a \$ b)$. Для $G_1$ это очевидно, потому что свертка — это просто умножение чисел. Для $G_2$ необходимо написать условия:
$$\begin{pmatrix} a_0\\ a_1 \end{pmatrix} \$ \begin{pmatrix} b_0\\ b_1 \end{pmatrix} = \begin{pmatrix} a_0 \cdot b_0 + a_1 \cdot b_1\\ a_0 \cdot b_1 + a_1 \cdot b_0 \end{pmatrix} $$
$$G_2a = \begin{pmatrix} a_0 + a_1\\ a_0 - a_1 \end{pmatrix} $$
$$G_2b = \begin{pmatrix} b_0 + b_1\\ b_0 - b_1 \end{pmatrix} $$
$$(G_2a) \cdot (G_2b) = \begin{pmatrix} (a_0 + a_1) \cdot (b_0 + b_1)\\ (a_0 - a_1) \cdot (b_0 - b_1) \end{pmatrix} = G (a \$ b)$$
Для бóльших размерностей все доказывается по индукции. Таким образом можно было бы изначально искать матрицу $G$, предположив, что $G_2 = \begin{pmatrix} c_0 & c_1 \\ c_2 & c_3 \end{pmatrix} $, решить систему уравнений.
and-свертка
and-свертка делается аналогично, надо только поменять матрицу. $T_1 = \begin{pmatrix} 1\\ \end{pmatrix} $, $ T_{2^k} = \begin{pmatrix} T_{2^{k - 1}} & T_{2^{k - 1}}\\ 0 & T_{2^{k - 1}} \end{pmatrix} $.
К сожалению, в отличие от xor-свертки, здесь обратная матрица не совпадает с прямой, но зато нет корней из двойки, поэтому не придется в конце делить на $n$.
Обратные матрицы: $T_1^{-1} = \begin{pmatrix} 1\\ \end{pmatrix} $, $ T_{2^k}^{-1} = \begin{pmatrix} T_{2^{k - 1}}^{-1} & -T_{2^{k - 1}}^{-1}\\ 0 & T_{2^{k - 1}}^{-1} \end{pmatrix} $.
Из-за рекуррентного задания матриц, мы опять же легко можем написать алгоритм:
void and_convolution(vector<int> &a, int l, int r) {
if (l + 1 == r) {
return;
}
int mid = (r + l) / 2;
and_convolution(a, l, mid);
and_convolution(a, mid, r);
for (int i = l; i < mid; i++) {
a[i] += a[mid + (i - l)];
}
}
void rev_and_convolution(vector<int> &a, int l, int r) {
if (l + 1 == r) {
return;
}
int mid = (r + l) / 2;
rev_and_convolution(a, l, mid);
rev_and_convolution(a, mid, r);
for (int i = l; i < mid; i++) {
a[i] -= a[mid + (i - l)];
}
}
vector<int> andmult(vector<int> a, vector<int> b) {
and_convolution(a, 0, a.size());
and_convolution(b, 0, b.size());
for (size_t i = 0; i < a.size(); i++) {
a[i] *= b[i];
}
rev_and_convolution(a, 0, a.size());
return a;
}
Проверка правильности выбранной матрицы и ее обратной остается читателю в качестве упражнения.
or-свертка
Все аналогично.
$Q_1 = \begin{pmatrix} 1\\ \end{pmatrix} $, $ Q_{2^k} = \begin{pmatrix} Q_{2^{k - 1}} & 0\\ Q_{2^{K - 1}} & Q_{2^{k - 1}} \end{pmatrix} $.
Обратные матрицы:
$Q_1^{-1} = \begin{pmatrix} 1\\ \end{pmatrix} $, $ Q_{2^k}^{-1} = \begin{pmatrix} Q_{2^{k - 1}}^{-1} & 0\\ -Q_{2^{K - 1}}^{-1} & Q_{2^{k - 1}}^{-1} \end{pmatrix} $.
Из-за рекуррентного задания матриц, мы опять же легко можем написать алгоритм:
void or_convolution(vector<int> &a, int l, int r) {
if (l + 1 == r) {
return;
}
int mid = (r + l) / 2;
or_convolution(a, l, mid);
or_convolution(a, mid, r);
for (int i = l; i < mid; i++) {
a[mid + (i - l)] += a[i];
}
}
void rev_or_convolution(vector<int> &a, int l, int r) {
if (l + 1 == r) {
return;
}
int mid = (r + l) / 2;
rev_or_convolution(a, l, mid);
rev_or_convolution(a, mid, r);
for (int i = l; i < mid; i++) {
a[mid + (i - l)] -= a[i];
}
}
vector<int> ormult(vector<int> a, vector<int> b) {
or_convolution(a, 0, a.size());
or_convolution(b, 0, b.size());
for (size_t i = 0; i < a.size(); i++) {
a[i] *= b[i];
}
rev_or_convolution(a, 0, a.size());
return a;
}
Проверка правильности выбранной матрицы и ее обратной остается читателю в качестве упражнения.
Замечание: Заметим, что $a | b = \overline{\overline{a} \& \overline{b}}$, где $\overline{x}$ — замена всех единичных битов числа на нулевые, а нулевых на единичные. Поэтому or-свертку можно написать через $and$-свертку (и наоборот).
Оптимизации
Убрать рекурсию здесь проще, чем в FFT. Рекурсия головная, поэтому раскрывается очевидным образом. Также прямые и обратные отображения очень похожи, так что их можно объединить в одну функцию.
void hadamard(vector<int> &a) {
for (size_t len = 1; len < a.size(); len *= 2) {
for (size_t i = 0; i < a.size(); i += 2 * len) {
for (size_t j = 0; j < len; j++) {
int u = a[i + j], v = a[i + len + j];
a[i + j] = u + v;
a[i + len + j] = u - v;
}
}
}
}
void and_convolution(vector<int>& a, bool inv) {
for (size_t len = 1; len < a.size(); len *= 2) {
for (size_t i = 0; i < a.size(); i += 2 * len) {
for (size_t j = 0; j < len; j++) {
if (inv) {
a[i + j] -= a[i + len + j];
} else {
a[i + j] += a[i + len + j];
}
}
}
}
}
void or_convolution(vector<int>& a, bool inv) {
for (size_t len = 1; len < a.size(); len *= 2) {
for (size_t i = 0; i < a.size(); i += 2 * len) {
for (int j = 0; j < len; j++) {
if (inv) {
a[i + len + j] -= a[i + j];
} else {
a[i + len + j] += a[i + j];
}
}
}
}
}
Альтернативный взгляд на and-or-свертки
Представленные выше алгебраические рассуждения на самом деле необходимы только для xor-свертки. Для and- и or-сверток можно обойтись без этого. Давайте докажем иначе, что or-свертка будет выглядеть так.
Для and-свертки это будет аналогично из-за замечания выше об их эквивалентности, так как операции and и or отличаются заменой нулей на единицы, а единиц на нули.
Для альтернативного доказательства работы алгоритма or-свертки давайте введем понятие массива сумм по подмножествам (эта техника также известна как SOS-DP (sum over subsets)).
Определение: Пусть дан массив $A$. Давайте назовем массивом сумм по подмножествам массива $A$ такой массив $B$, что
$$ B_i = \sum_{j : j \subset i} A_j$$
При этом условие $j \subset i$ в данном случае воспринимается как содержание подмножеств единичных битов. То есть если бы мы воспринимали числа как маски. На языке битовых операций это условие можно записать как i | j = i
, то есть $j$ — это число $i$, в котором занулили некоторые единичные биты.
Это называется массивом сумм по подмножествам именно потому, что если все индексы лежат в промежутке $[0, 2^k)$, то их стоит воспринимать как маски подмножеств множества $\{0, 1, 2, \ldots, k - 1\}$.
Давайте покажем, что or-свертка как раз таки насчитывает массив сумм по подмножествам.
Давайте считать динамику по битам. dp[mask][ind]
— это сумма по тем подмножествам маски $mask$, в которых изменялись только первые $ind$ битов. Если $ind = 0$, то мы не меняли никаких битов, и это просто $A_{mask}$. Далее при переходе от $ind - 1$ к $ind$ нужно, возможно, поменять текущий бит, либо не менять его. То есть если бит $ind$ в $mask$ равен нулю, то мы ничего поменять не можем:
dp[mask][ind] = dp[mask][ind - 1]
А если он равен единице, то мы его можем занулить:
dp[mask][ind] = dp[mask][ind - 1] + dp[mask ^ (1 << ind)][ind - 1]
Теперь если мы уберем вторую координату динамики и будем насчитывать ее in-place, то как раз таки получим or-свертку. Алгоритм перебирает бит, после чего рассматривает все пары чисел с битом и без и прибавляет меньшее к большему. Соответственно, обратная or-свертка — это обратное преобразование: по массиву сумм по подмножествам получаем изначальный массив.
Тогда остается заметить, что с точки зрения массивов сумм по подмножествам or-свертка — это просто поэлементное умножение, потому что если or
двух масок — это подмаска $mask$, то это равносильно просто тому, что обе эти маски — это подмаски $mask$.
Что и требовалось доказать.
Из такого объяснения вытекает немного другой способ написания той же or-свертки, который может показаться вам более простым:
void or_convolution(vector<int>& a, bool inv) {
for (size_t len = 1; len < a.size(); len *= 2) {
for (size_t i = 0; i < a.size(); i++) {
if (i & len == 0) {
if (inv) {
a[i ^ len] -= a[i];
} else {
a[i ^ len] += a[i];
}
}
}
}
}