A Bitwise Convolution Tutorial

1 October 2018

Prerequisites

An introduction

It is an important and popular fact that the things that we classify as FFT on code-drills, are not always actual fast fourier transforms (if we're being overly-technical). Suppose you are given two arrays AA and BB and an operation *. We will call the * convolution of AA and BB the array CC, such that:

Cp=(AB)p=ij=pAiBjC_p = (A*B)_p = \sum_{i*j=p}{A_i B_j}

In the case of actual FFT, * is addition, but it might as well be something else, like AND, OR, XOR, or something like gcd or lcm that require techniques far less advanced than the ones presented in this article, intended to introduce the (*sighs* rare) reader to techniques that allow us to efficiently compute such convolutions by linear algebraic means. I will also present the solutions to some problems that involve these techniques such as Random Nim Generator.

Throwback to FFT

As you should know, FFT is basically a divide and conquer algorithm designed to reduce the complexity of the multiplication of a vector with a specific Vandermonde matrix:

Vn=[ω00ω01ω02ω0nω10ω11ω12ω1nωn0ωn1ωn2ωnn],ω=e2πinV_n = \begin{bmatrix} \omega^{0 \cdot 0} & \omega^{0 \cdot 1} & \omega^{0 \cdot 2} & \dots & \omega^{0 \cdot n} \\ \omega^{1 \cdot 0} & \omega^{1 \cdot 1} & \omega^{1 \cdot 2} & \dots & \omega^{1 \cdot n} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ \omega^{n \cdot 0} & \omega^{n \cdot 1} & \omega^{n \cdot 2} & \dots & \omega^{n \cdot n} \end{bmatrix} \large{ , \omega=e^{ \frac{2\pi i}{n} } }

Let's recap a bit the FFT process:

  • We define the product operation * on two vectors as the following: given two vectors AA and BB, AB=CA*B=C, where C=A+B1|C|=|A|+|B|-1 and

    Cp=k=0pAkBpkC_p = \sum_{k=0}^{p}{A_k B_{p-k}}

    * Note: Despite the horrific notation colision, in the reminder of the article V|V| will denote the number of elements in the corresponding array of vector VV.

  • Using the definition, the operation uses O(AB)O(|A| |B|) time, so we resize our vectors to the smallest power of two larger than A+B|A|+|B| (let that power of two be n=2some exponentn=2^\text{some exponent}). And multiply each of these vectors with VnV_n to obtain AA' and BB'. We define C=ABC'=A' \odot B', where \odot denotes element by element multiplication (e.g.: (AB)i=AiBi(A \odot B)_i = A_i \cdot B_i ), and we multiply CC' with Vn1V_n^{-1} to obtain the desired CC matrix. In short:

    C=Vn1((VnA)(VnB))C=V_n^{-1}((V_n A) \odot (V_n B))

    where AA and BB have already been resized to nn. This works because our * operation is equivalent to polynomial multiplication, and multiplying a vector P=[P0,P1,,Pn]P=[P_0, P_1, \dots, P_n] with VnV_n esentially evaluates a polynomial at the roots of unity:

    (VnP)t=k=0nPkωtk(V_nP)_t = \sum_{k=0}^{n}{P_k \omega^{tk}}

    So ((VnA)(VnB))k((V_n A) \odot (V_n B))_k is equal to the product of the corresponding polynomials of AA and BB evaluated at ωk\omega^k and multiplying the ((VnA)(VnB))((V_n A) \odot (V_n B)) vector with Vn1V_n^{-1} basically interpolates the polynomial from the values evaluated at all these roots of unity.

Now let's see how we multiply such a vector with VnV_n fast, or because FFT is among the prerequisites, why the algorithm works from a linear algebraic point of view.

Basically, after applying a permutation to the initial vector, at each level (value of step), it divides the sequence into buckets of length step*2 and multiplies the vector SS correspondent to that bucket with a matrix MstepM_\text{step} that looks like this:

Mstep=[10000ω00000010000ω10000010000ω20000010000ω30000010000ωstep110000ω00000010000ω10000010000ω20000010000ω30000010000ωstep1],ω=e2πistepM_\text{step} = \begin{bmatrix} 1 & 0 & 0 & 0 & \dots & 0 & \omega^{0} & 0 & 0 & 0 & \dots & 0 \\ 0 & 1 & 0 & 0 & \dots & 0 & 0 & \omega^{1} & 0 & 0 & \dots & 0 \\ 0 & 0 & 1 & 0 & \dots & 0 & 0 & 0 & \omega^{2} & 0 & \dots & 0 \\ 0 & 0 & 0 & 1 & \dots & 0 & 0 & 0 & 0 &\omega^{3} & \dots & 0 \\ \vdots & \vdots & \vdots & \vdots &\ddots & \vdots & \vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & 0 & \dots & 1 & 0 & 0 & 0 & 0 & \dots & \omega^{\text{step} - 1} \\ 1 & 0 & 0 & 0 & \dots & 0 & -\omega^{0} & 0 & 0 & 0 & \dots & 0 \\ 0 & 1 & 0 & 0 & \dots & 0 & 0 & -\omega^{1} & 0 & 0 & \dots & 0 \\ 0 & 0 & 1 & 0 & \dots & 0 & 0 & 0 & -\omega^{2} & 0 & \dots & 0 \\ 0 & 0 & 0 & 1 & \dots & 0 & 0 & 0 & 0 & -\omega^{3} & \dots & 0 \\ \vdots & \vdots & \vdots & \vdots &\ddots & \vdots & \vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & 0 & \dots & 1 & 0 & 0 & 0 & 0 & \dots & -\omega^{\text{step} - 1} \\ \end{bmatrix} \large{ , \omega=e^{\frac{2\pi i}{\text{step}}} }

Multiplying MstepM_\text{step} with SS takes O(step)O(\text{step}) time, because the matrix is sparse. This multiplication is found in the for (int pos = 0; ...) loop. Now as you might expect, FFT basically decomposes VnV_n into a product of these matrices and a permutation matrix. So

Vn=(I2(logn)1M1)(I2(logn)2M2)(I21Mn4)Mn2("The permutation matrix")V_n = (I_2^{\otimes (\log{n})-1} \otimes M_1) (I_2^{\otimes (\log{n})-2} \otimes M_2) \dots (I_2^{\otimes 1} \otimes M_\frac{n}{4}) M_\frac{n}{2} (\text{"The permutation matrix"})

Where '\otimes' denotes the Kronecker product, a key operation troughout the whole of this article and you must know at least its definition to grasp the remainder of this article. In this particular case, you may interpret it as MstepM_\text{step} is applied on all the buckets (i.e. the Kroneker product distributes the MstepM_\text{step} multiplication on each of the buckets). The proof of the equivalence of this decomposition to VnV_n is based on induction and quite beyond the scope of this article.

int rev(int x, int l) { // mirror the bits (e.g. 01011 -> 11010) int res = 0; for (int bit = 0; bit < l; ++bit) { res*= 2; res|= x & 1; x/= 2; } return res; } void fft(vector<Complex> &pol, int inverse = 1) { int n = size(pol); for (int i = 0; i < n; ++i) if (i < rev(i, log2[n])) swap(pol[i], pol[rev(i, log2[n])]); for (int step = 1; step < n; step*= 2) { Complex root; root.r = cos(inverse * PI / step); // real part root.i = sin(inverse * PI / step); // imaginary part for (int pos = 0; pos < n; pos+= step * 2) { Complex omega = 1; for (int i = 0; i < step; ++i) { Complex x = pol[pos + i]; Complex y = pol[pos + i + step] * omega; omega*= root; pol[pos + i] = x + y; pol[pos + i + step] = x - y; } } if (inverse == -1) for (int i = 0; i < n; ++i) pol[i]*= 0.5; } }
int rev(int x, int l) { // mirror the bits (e.g. 01011 -> 11010) int res = 0; for (int bit = 0; bit < l; ++bit) { res*= 2; res|= x & 1; x/= 2; } return res; } void fft(vector<Complex> &pol, int inverse = 1) { int n = size(pol); for (int i = 0; i < n; ++i) if (i < rev(i, log2[n])) swap(pol[i], pol[rev(i, log2[n])]); for (int step = 1; step < n; step*= 2) { Complex root; root.r = cos(inverse * PI / step); // real part root.i = sin(inverse * PI / step); // imaginary part for (int pos = 0; pos < n; pos+= step * 2) { Complex omega = 1; for (int i = 0; i < step; ++i) { Complex x = pol[pos + i]; Complex y = pol[pos + i + step] * omega; omega*= root; pol[pos + i] = x + y; pol[pos + i + step] = x - y; } } if (inverse == -1) for (int i = 0; i < n; ++i) pol[i]*= 0.5; } }

The Bitwise Convolutions Matrices

Now that there we're done with the short "classical FFT" recap, we may dive into XOR, AND and OR covolutions, and there is good news! They are quite easier to understand, don't require complex numbers or roots of unity in some field, run much faster and are shorter to code. All of that matrix decomposition maths was there to point out the fact that most convolution algorithms (but not gcd convolution for example) are based on such matrix decompositions and present a familiar example, even though it is one of the most complex ones from this perspective. Remember the C=Vn1((VnA)(VnB))C=V_n^{-1}((V_n A) \odot (V_n B)) relation from the beginning of the article? Let's change VnV_n to TnT_n (for "transformation") to avoid confusion. Now the meaning of C=Tn1((TnA)(TnB))C=T_n^{-1}((T_n A) \odot (T_n B)) is that we apply some transformation to each of the vectors, obtain another vector trough one by one multiplication ('\odot') and then reverse the transformation in order to get the result of the convolution. Looking at this equation from above, TnT_n (the transformation) is the unknown. In the case of "classical FFT", the vandermonde matrix over the roots of unity worked, but now we must find another that yelds the XOR convolution for example. Let's see how we find out the values of TnT_n in that case! We will use \oplus as the operator symbol of XOR, as it is shorter and looks fancier and \$$ as the XOR convolution symbol. Also, n$ will be a power of two to ensure that everything is contained. Let's see how this convolution would work for vectors of size 2. By definition:

(A$B)p=k=0n1AkBkp(A\$B)_p=\sum_{k=0}^{n - 1}{A_k B_{k \oplus p}}

Additionally:

(A$B)p=Tn1((TnA) (TnB))(A\$B)_p=T_n^{-1}((T_nA) \odot \ (T_nB))

And we'll set

A=[a0a1],B=[b0b1],T2=[wxyz]A= \begin{bmatrix} a_0 \\ a_1 \\ \end{bmatrix}, B= \begin{bmatrix} b_0 \\ b_1 \\ \end{bmatrix}, T_2= \begin{bmatrix} w & x \\ y & z \\ \end{bmatrix}

so\text{so}

A$B=[a0b0+a1b1a0b1+a1b0]A\$B= \begin{bmatrix} a_0b_0 + a_1b_1 \\ a_0b_1 + a_1b_0 \\ \end{bmatrix}

So now we can start solving for T2T_2.

(A$B)p=Tn1((TnA) (TnB))    Tn(A$B)=(TnA) (TnB)    (A\$B)_p = T_n^{-1}((T_nA) \odot \ (T_nB)) \implies T_n (A\$B) = (T_nA) \odot \ (T_nB) \implies [wxyz][a0b0+a1b1a0b1+a1b0]=[wxyz][a0a1][wxyz][b0b1]    \begin{bmatrix} w & x \\ y & z \\ \end{bmatrix} \begin{bmatrix} a_0b_0 + a_1b_1 \\ a_0b_1 + a_1b_0 \\ \end{bmatrix} = \begin{bmatrix} w & x \\ y & z \\ \end{bmatrix} \begin{bmatrix} a_0 \\ a_1 \\ \end{bmatrix} \odot \begin{bmatrix} w & x \\ y & z \\ \end{bmatrix} \begin{bmatrix} b_0 \\ b_1 \\ \end{bmatrix} \implies [w(a0b0+a1b1)+x(a0b1+a1b0)y(a0b0+a1b1)+z(a0b1+a1b0)]=[wa0+xa1ya0+za1][wb0+xb1yb0+zb1]    \begin{bmatrix} w (a_0b_0 + a_1b_1) + x (a_0b_1 + a_1b_0) \\ y (a_0b_0 + a_1b_1) + z (a_0b_1 + a_1b_0) \\ \end{bmatrix} = \begin{bmatrix} w a_0 + x a_1 \\ y a_0 + z a_1 \\ \end{bmatrix} \odot \begin{bmatrix} w b_0 + x b_1 \\ y b_0 + z b_1 \\ \end{bmatrix} \implies [w(a0b0+a1b1)+x(a0b1+a1b0)y(a0b0+a1b1)+z(a0b1+a1b0)]=[(wa0+xa1)(wb0+xb1)(ya0+za1)(yb0+zb1)]    \begin{bmatrix} w (a_0b_0 + a_1b_1) + x (a_0b_1 + a_1b_0) \\ y (a_0b_0 + a_1b_1) + z (a_0b_1 + a_1b_0) \\ \end{bmatrix} = \begin{bmatrix} (w a_0 + x a_1) (w b_0 + x b_1) \\ (y a_0 + z a_1) (y b_0 + z b_1) \\ \end{bmatrix} \implies [w(a0b0+a1b1)+x(a0b1+a1b0)y(a0b0+a1b1)+z(a0b1+a1b0)]=[w2a0b0+x2a1b1+wx(a0b1+a1b0)y2a0b0+z2a1b1+wx(a0b1+a1b0)]\begin{bmatrix} w (a_0b_0 + a_1b_1) + x (a_0b_1 + a_1b_0) \\ y (a_0b_0 + a_1b_1) + z (a_0b_1 + a_1b_0) \\ \end{bmatrix} = \begin{bmatrix} w^2 a_0b_0 + x^2 a_1b_1 + wx (a_0b_1 + a_1b_0) \\ y^2 a_0b_0 + z^2 a_1b_1 + wx (a_0b_1 + a_1b_0) \\ \end{bmatrix}

Now, because a0,a1,b0,b1a_0, a_1, b_0, b_1 are variables, this gives us a system of equations:

{w2=w,x2=w,x=wxy2=y,z2=y,z=yz\begin{cases} w^2=w, x^2=w, x=wx \\ y^2=y, z^2=y, z=yz \\ \end{cases}

Which has the following solutions:

(w,x){(0,0),(1,1),(1,1)}(y,z){(0,0),(1,1),(1,1)}(w,x) \in \{(0, 0), (1, 1), (1, -1) \} \\ \land \\ (y,z) \in \{(0, 0), (1, 1), (1, -1)\}

But there is a catch, T2T_2 must be invertible. That is detT20\det{T_2} \neq 0, which leaves us with these solutions

T2{[1111],[1111]}T_2 \in \left \{ \begin{bmatrix} 1 & 1 \\ 1 & -1 \\ \end{bmatrix}, \begin{bmatrix} 1 & -1 \\ 1 & 1 \\ \end{bmatrix} \right \}

And indeed, these are the the matrices whose correspondend transformation gives us the XOR convolution for arrays of length n=2n=2. So we'd better figure out a way to extend this to larger powers of 2, let's say n=2kn=2^k. Then, the transformation matrix would be Tn=T2kT_n=T_2^{\otimes k}, which in our case is the Hadamard Matrix. The proof of this, again is based on induction (and actually not that hard) and will appear in case of popular demand. But the main principle behind it is that for XOR, OR and AND, the bits are independent and the Kroenenker product has a distributive multiplication efect, as in the case of "classical FFT", on the sequence partitioned in buckets of size 2, then 4, then 8 etc. There's just one more thing to point out about the kroneker product:

(Mn)1=(M1)n(M^{\otimes n})^{-1} = (M^{-1})^{\otimes n}

Let's see how we efficiently multiply these kroneker products with vectors if

T2=[wxyz]    T21=1wzxy[zxyw]T_2= \begin{bmatrix} w & x \\ y & z \end{bmatrix} \implies T_2^{-1}= \frac{1}{wz-xy} \begin{bmatrix} z & -x \\ -y & w \\ \end{bmatrix}

And in case you want a XOR convolution, just set w,x,y,zw, x, y, z to the values from the specific XOR transformation matrix.

void transform(vector<double> &pol) { int n = size(pol); for (int step = 1; step < n; step*= 2) { for (int pos = 0; pos < n; pos+= step * 2) { for (int i = 0; i < len; ++i) { // replace values pol[pos + i] pol[pos + 1 + step] with their product with T_2 double a = pol[pos + i]; double b = pol[pos + i + step]; pol[pos + i] = w * a + x * b; pol[pos + i + step] = y * a + z * b; } } } } void inverse_transform(vector<double> &pol) { const double determinant = w * z - x * y; int n = size(pol); for (int step = 1; step < n; step*= 2) { for (int pos = 0; pos < n; pos+= step * 2) { for (int i = 0; i < len; ++i) { // replace values pol[pos + i] pol[pos + 1 + step] with their product with the inverse of T_2 double a = pol[pos + i]; double b = pol[pos + i + step]; pol[pos + i] = (z * a - y * b) / determinant; pol[pos + i + step] = (w * b - x * a) / determinant; } } } }
void transform(vector<double> &pol) { int n = size(pol); for (int step = 1; step < n; step*= 2) { for (int pos = 0; pos < n; pos+= step * 2) { for (int i = 0; i < len; ++i) { // replace values pol[pos + i] pol[pos + 1 + step] with their product with T_2 double a = pol[pos + i]; double b = pol[pos + i + step]; pol[pos + i] = w * a + x * b; pol[pos + i + step] = y * a + z * b; } } } } void inverse_transform(vector<double> &pol) { const double determinant = w * z - x * y; int n = size(pol); for (int step = 1; step < n; step*= 2) { for (int pos = 0; pos < n; pos+= step * 2) { for (int i = 0; i < len; ++i) { // replace values pol[pos + i] pol[pos + 1 + step] with their product with the inverse of T_2 double a = pol[pos + i]; double b = pol[pos + i + step]; pol[pos + i] = (z * a - y * b) / determinant; pol[pos + i + step] = (w * b - x * a) / determinant; } } } }

Some Pen and Paper Exercises

  • Find T2T_2 for OR and AND transforms
  • Given an operation that "ORs" the first bit, "ANDs" the second bit, "XORs" the third one and then repeats, find the transformation matrix for such a convolution on arrays of kk bits (of length n=2kn=2^k).
  • Find a transformation matrix where the operation is XOR but in base 3 (addition with no carry).

Random Nim Generator

"How many sequences of length nn containing numbers from 00 to kk have their total XOR sum greater than 00?" Let's see a dp approach!

dp[a][b]=i=0kdp[a1][ib]\text{dp[a][b]} = \sum_{i=0}^{k}{dp[a - 1][i \oplus b]}

where a\text{a} is the number of elements taken so far and b\text{b} is their current XOR sum. And the dp is initialized with dp[1][i]=1,i[0..k] and 0 in rest\text{dp[1][i]}=1, \forall i \in [0..k] \text{ and } 0 \text{ in rest}. Let's define K=[1,1,1,...,1,0,0,...,0]K=[1, 1, 1, ..., 1, 0, 0, ..., 0] (the first k + 1 values are 11 and the rest are 00, the array's length being the smallest power of 22 greater or equal to kk). Now we can write the recurrence as

dp[a]=dp[a-1]$K \text{dp[a]} = \text{dp[a-1]}\$K  and dp[1]=K \text{ and } \text{dp[1]}=K  therefore dp[a]=K$K$K$$K (a times) \text{ therefore } \text{dp[a]} = K\$K\$K\$ \dots \$K \text{ (a times)}

So you just have to use the XOR transform on KK convolute it with itself nn times and output the sum of the values of the non-zero positions of the resulting vector. Here is my source code.

AND closure

Just apply a "self-AND-convolution" to the frequency array log times and output the positions with non-zero corresponding values. Here is my source code.