快速沃尔什变换 / FWT

· · 算法·理论

快速沃尔什变换 / FWT

下文中为行文方便,所有序列的长度为 n = 2^m。另外,本文可能反复混用二进制数和集合,如将交并说成与或等,当然这通常是无歧义的。另外,x_i \in \set{0,1} 通常表示 x 在二进制表示下的第 i 位,至于是从高位还是从低位是无所谓的。

Faster Convolution

FWT (快速沃尔什变换) 用于解决这样一类位运算卷积问题:

C_k=\sum_{i \circ j = k} A_i B_j

其中 \circ 运算表示一类位运算,如 \cap \cup \oplus (\text{xor}) 等。朴素算法的复杂度显然是 \mathcal O(n^2) 的,而 FWT 算法可以在 \mathcal O(n \log n) 内完成。

FWT 的核心思想是构造一种变换 \mathcal F,使得:

C_k=\sum_{i \circ j = k} A_i B_j \iff \mathcal F[C]_k = \mathcal F[A]_k \cdot \mathcal F[B]_k

在变换 \mathcal F 下,将位运算卷积展平成了逐点相乘(Hadamard 积),这使得乘法的复杂度立刻下降。只要找到了满足条件的变换 \mathcal F 和其逆变换 \mathcal F^{-1},整个问题就迎刃而解了。

Transform & Matrix

变换 \mathcal F 可以写成一个矩阵 P,也就是 \mathcal F[A] = AP (行向量右乘矩阵)。由于:

\mathcal F[C]_k = \mathcal F[A]_k \cdot \mathcal F[B]_k

因此:

(CP)_k=(AP)_k \cdot (BP)_k

即:

\begin{align} \sum_{i=0}^{n-1} C_iP_{k,i} &= \bigg(\sum_{i=0}^{n-1} A_iP_{k,i}\bigg) \cdot \bigg(\sum_{i=0}^{n-1} B_iP_{k,i}\bigg) \\ &= \sum_{i=0}^{n-1} \sum_{j=0}^{n-1} A_i B_j P_{k,i} P_{k,j} \end{align}

带入 C_i 的定义:

\sum_{i=0}^{n-1} \sum_{j=0}^{n-1} A_i B_j P_{k,i \circ j} = \sum_{i=0}^{n-1} \sum_{j=0}^{n-1} A_i B_j P_{k,i} P_{k,j}

为了对于任意的 A,B 都要成立,这必须要求:

\boxed{P_{k,i \circ j} = P_{k,i} P_{k,j}}

上式给出了矩阵 P 成立的充要条件。

一个很平凡的 P 是全 0 或者全为一个值,但是平凡的构造通常有一个平凡的效果:想想,如果全 0,那么 \mathcal F 得到的所有结果都是零向量!他们当然是永远相等的,但这显然不是我们想要的效果。

究其原因,是因为这样的 P 不存在逆,就像基督教失去了耶路撒冷。不存在逆矩阵,也就不存在逆变换 \mathcal F^{-1},问题也就没有最终解决。

在着手构造矩阵前,还有一个问题需要面对:P 的规模是 n \times n 的,这大大提高了构造的难度,而且跟 n 相关的代价是,我们必须为每个 n 构造不同的 P!这是不可接受的。

'Bitwise' Operation

迄今为止,我们对 FWT 的探索还没有利用到一个关键前提:\circ 是一种运算。不要小瞧了这一性质,这决定这为什么加法卷积(即 a \circ b = a + b)比位运算卷积复杂得多。位运算意味着可以按位计算(废话),也就是:

(a \circ b)_k=a_k \circ b_k

其中 a_k \in \set{0,1} 表示 a 二进制下的第 k 位。因此,\circ 仅需对 \set{0,1} 定义。

这启示我们,能否对 P 做缩减?事实上,P 也可以按位构造。具体地,我们可以定义 2 \times 2位矩阵 D,并且定义:

P_{i,j}=\prod_{t=0}^{m-1} D_{i_t,j_t}

这样就生成了完整的 P。这样生成的 P 有如下结论:

结论 1:

D_{k,i \circ j} = D_{k,i} D_{k,j} \implies P_{k,i \circ j} = P_{k,i} P_{k,j}

证明:

\begin{align} P_{k, i \circ j} &= \prod_{t=0}^{m-1} D_{k_t,(i \circ j)_t} \\ &= \prod_{t=0}^{m-1} D_{k_t,i_t \circ j_t} \\ &= \prod_{t=0}^{m-1} D_{k_t,i_t} D_{k_t,j_t} \\ &= P_{k,i} P_{k,j} \end{align}

结论 2:

D \, \text{存在逆} \implies P \, \text{存在逆}

事实上,P 的逆 P^{-1} 由如下方式给出:

P^{-1}_{i,j} = \prod_{t=0}^{m-1} D^{-1}_{i_t,j_t}

证明:

\begin{align} (PP^{-1})_{i,j} &= \sum_{k=0}^{n-1} P_{i,k} P^{-1}_{k,j} \\ &= \sum_{k=0}^{n-1} \prod_{t=0}^{m-1} D_{i_t,k_t} D^{-1}_{k_t,j_t} \\ &= \sum_{k_0=0}^1 \sum_{k_1=0}^1 \cdots \sum_{k_{m-1}=0}^1 \prod_{t=0}^{m-1} D_{i_t,k_t} D^{-1}_{k_t,j_t} \\ &= \prod_{t=0}^{m-1} \sum_{k_t=0}^1 D_{i_t,k_t} D^{-1}_{k_t,j_t} \\ &= \prod_{t=0}^{m-1} [i_t = j_t] \\ &= [i=j] \end{align}

为了理解第三步,你需要充分发挥你的想象力,因为枚举子集相当于枚举每个元素是否在子集内,这两者是等价的。另外,第四步运用了简单的求和法则的扩展:

\sum_i \sum_j a_i b_j = \bigg(\sum_i a_i \bigg) \bigg(\sum_j b_j \bigg)

以上两条性质说明:一个合法的 D 就意味着无数个合法的 P。这样,问题的规模就大大缩减了,仅需构造一个满足条件的小矩阵即可。

The End...?

问题得到解决了吗?我们已经构造出了 P,只需要给序列乘上 P,就得到了变换之后的结果。将结果向量逐点相乘,再乘上 P^{-1},最终得到了卷积之后的答案……吗?

可惜,P 的规模是 n \times n 的,也就是说,为了得到变换之后的序列,我们需要 \mathcal O(n^2) 的时间复杂度。但是暴力的复杂度不就是 \mathcal O(n^2) 的吗?做了个寂寞……

这就是整个算法最巧妙的地方。我们考察 P 的生成方式:

P_{i,j} = \prod_{t=0}^{m-1} D_{i_t,j_t}

如果设 i 去掉第0位的数字为 i',那么就有:

P_{i,j}=P_{i',j'} D_{i_0,j_0}

这是很有效的,因为直接将规模缩小了一半。由此:

\begin{align} \mathcal F[A]_{k} &= \sum_{i=0}^{n-1} A_i P_{k,i} \\ &= \sum_{i=0}^{n-1} A_i P_{k',i'} D_{k_0,i_0} \end{align}

这里 P_{k,i} 的规模已经小了一半,我们还需要将 A_i 的规模缩小,这启示用 i_0 分组:

\begin{align} \mathcal F[A]_{k} &= \sum_{i=0}^{n/2-1} A_i P_{k',i'} D_{k_0,i_0} + \sum_{i=n/2}^{n-1} A_i P_{k',i'} D_{k_0,i_0}\\ &= D_{k_0,0} \sum_{i=0}^{n/2-1} A_i P_{k',i'} + D_{k_0,1} \sum_{i=n/2}^{n-1} A_i P_{k',i'} \end{align}

这样子问题就非常明显了。只需将 A_ii_0 划分为 A_0A_1,那么:

\begin{align} \mathcal F[A]_k &= D_{k_0,0} \sum_{i=0}^{n/2-1} {A_0}_{i'} P_{k',i'} + D_{k_0,1} \sum_{i=0}^{n/2-1} {A_1}_{i'} P_{k',i'} \\ &= D_{k_0,0} \mathcal F[A_0]_{k'} + D_{k_0,1} \mathcal F[A_1]_{k'} \end{align}

这样做的目的是将 i 同一固定为 i' 。就可以直接递归下去解决问题了。设计算长度为 n\mathcal F[A] 复杂度为 T(n),那么就有:

T(n) = 2T(\frac n 2) + \mathcal O(1)

因此:

T(n) = \mathcal O(n\log n)

对于 \mathcal F^{-1} 来说,仅需将 P_{i,j} 换为 P^{-1}_{i,j} 即可。由于两个矩阵的构造方式都是一样的(通过位矩阵按位乘),因此同样可以解决。这样,我们就在 \mathcal O(n \log n) 的复杂度彻底解决了这个问题。

最后,观察式子 \mathcal F[A]_k = D_{k_0,0} \mathcal F[A_0]_{k'} + D_{k_0,1} \mathcal F[A_1]_{k'},可以将 \mathcal F[A] 继续用 k_0 来分组为 \mathcal F[A]_0\mathcal F[A]_1,这样就可以写成:

{\mathcal F[A]_0}_k = D_{0,0} \mathcal F[A_0]_{k} + D_{0,1} \mathcal F[A_1]_{k} \\ {\mathcal F[A]_1}_k = D_{1,0} \mathcal F[A_0]_{k} + D_{1,1} \mathcal F[A_1]_{k}

这里的 k' 也简化为了 k。上式很像矩阵乘法的模式,我们可以简化地写为:

\boxed {[\mathcal F[A]_0, \mathcal F[A]_1] = [\mathcal F[A_0], \mathcal F[A_1]] \times D}

这里对于一个数乘一个序列的定义是 (\lambda A)_k = \lambda A_k,即向量数乘。

附:一些常见运算的矩阵构造

与(\cap):

D = \begin{bmatrix} 1 & 1 \\ 0 & 1 \end{bmatrix} \quad \quad D^{-1}= \begin{bmatrix} 1 & -1 \\ 0 & 1 \end{bmatrix}

或(\cup):

D = \begin{bmatrix} 1 & 0 \\ 1 & 1 \end{bmatrix} \quad \quad D^{-1}= \begin{bmatrix} 1 & 0 \\ -1 & 1 \end{bmatrix}

异或(\oplus):

D = \begin{bmatrix} 1 & -1 \\ 1 & 1 \end{bmatrix} \quad \quad D^{-1}= \begin{bmatrix} \frac{1}{2} & \frac{1}{2} \\ -\frac{1}{2} & \frac{1}{2} \end{bmatrix}

由于只需按行满足 D_{k,i \circ j} = D_{k,i} D_{k,j},因此交换两行不会有影响(有用的废话)。