代数快速傅立叶变换
原文:https://rje.li/24-07-03-alg-fft.html 作者:Eli Riggs 译者:Kurt Pan
FFT(“快速傅立叶变换”)是有史以来最伟大的算法之一,但大多数人只见过它的经典形式,即将时域中的离散样本转换为频域中的复数表示。它的同类算法 NTT(“数论变换”)对模数上的多项式插值,虽然更少为人所知,但仍在广泛使用,主要用于密码学。
然而,为了更特殊的目的、更陌生的领域和更不熟悉的性质,人们发明了更加奇异的“FFT”。在本文中,我将说明任何一种 FFT 背后的那一个单一算法,然后快速介绍这些奇异的 FFT,并展示如何通过在我们的共用算法中插入一小组参数来实现它们。
最终我们会看到,FFT 让我们可以在$N \log(N)$的时间内更改一个在$N$个点上定义的函数的基。朴素的方法是通过应用线性变换矩阵来改基,而矩阵乘法需要 ~$N^3$时间,因此这是一个巨大的优势。
在继续之前,先说几点:
本文是用 Sage 笔记本写的。我写了一些实用函数,它们的含义应该从名称中就能看出来。唯一奇怪的是bind
——它让我可以在定义之后向类添加方法,这样我就可以在文章中间创作了。
%display latex
from collections import namedtuple, defaultdict
from itertools import islice, chain
take = lambda n, xs: list(islice(xs, n))
interleave = lambda *xss: list(chain.from_iterable(zip(*xss)))
def iterate(x, f): yield x; yield from iterate(f(x), f)
powers = lambda base, start=None: iterate(start or base.parent().one(), lambda x: x*base)
dot = lambda *xss: sum(product(xs) for xs in zip(*xss))
rands = lambda f, n: [f.random_element() for _ in range(n)]
bind = lambda cls: lambda f: (setattr(cls, f.__name__, f), f)[1]
对于给定域$\mathbb{F}$和大小$N = 2^n$,一个对$\mathbb{F}$和$N$的 FFT 由其包含$2^n$个点的初始定义域$D_n$,和一个$n$层的序列: $\ell_n = [(\pi_n, tn), (\pi{n-1}, t_{n-1}), \ldots, (\pi_1, t_1)]$ 完全确定。
Layer = namedtuple('Layer', 'π t')
Fft = namedtuple('Fft', 'domain layers')
其中,要么$n = 0$,意味着$D_0$只有一个点,$\ell_0$为空,要么:
layers[1:]
。根据该定义,我们得到一个由$n + 1$个由映射$\pi n,\pi {n-1},...,\pi _1$和除 之外的每个定义域上的区分器(通常称为 twiddles)生成的定义域$D n,D{n-1},...,D _1$组成的序列,每个定义域的大小减半,
$D_n \xrightarrow{\pin} D{n-1} \xrightarrow{\pi_{n-1}} \cdots \xrightarrow{\pi_2} D_1 \xrightarrow{\pi_1} D_0$
$\downarrow tn$ $\downarrow t{n-1}$ $\downarrow t_1$
$\mathbb{F}$ $\mathbb{F}$ $\mathbb{F}$
显式地构造出逆映射$\pi ^{-1}n$将会很有用,该映射将$D{n-1}$中的每个输出点映射到其两个输入${x_0, x_1} \in D_n$上(又名其 纤维),以及$t(x_0)$和$t(x1)$的值。让我们为此向 Fft
添加一个方法,它还将检查我们的要求是否得到满足。返回的字典具有以下形式${\pi{n}(x) \to ((x{0}, t(x{0})), (x{1}, t(x{1})))}$,其所有键的集合恰好是$D_{n-1}$。
@bind(Fft)
def build_inverse_map(self: Fft):
π, t = self.layers[0]
inv_map = defaultdict(lambda: [])
for x in self.domain:
inv_map[π(x)].append((x, t(x)))
assert all(
len(fiber) == 2 and fiber[0][1] != fiber[1][1]
for fiber in inv_map.values()
)
return inv_map
如果满足这些要求,我们可以将 FFT 算法 应用于任意函数$f : Dn \rightarrow \mathbb{F}$来得到一个由$2^n$个系数组成的向量$\vec{c}$: $\mathrm{FFT}{D{n}, \ell{n}}(f) = \vec{c}$ 给定任意一个 FFT,我们可以导出其 基$\hat b_n$,它是$D_n \rightarrow \mathbb{F}$的 函数 向量。然后我们就可以理解系数$\vec{c}$的含义了:它们是基函数的 权重,而基的加权和将重构出我们的原始函数$f$。 $\sum{i} \overrightarrow{c}^{(i)} \cdot \hat{b}{n}^{(i)}(x) = f(x)$
嗯……如果你的 FFT 构成的基是具有足够的维度来重构原始函数的,那么它_应该_如此。我们假设这是真的,因为我们只对应用 FFT 算法感兴趣,而不是结果到底有多有用。
我将解释如何导出这个基,因为它是由 FFT 的结构形成的,但需要注意的是,基纯粹是概念性的:它告诉你如何解释得到的系数,但如果你只是想运行算法,它并不必要。
给定$f$($D_n$上的一个函数),FFT 算法有三个步骤:
在这些步骤中,分解是最容易解释的,但却最难说清楚。现在假设我们会编写一个函数decompose
,该函数输入逆映射和函数在$D_n$上的求值,返回$f_0$和$f1$在$D{n-1}$上的求值。
那么除decompose
之外,该算法可以完整描述为:
@bind(Fft)
def fft(self: Fft, f: dict):
if not self.layers:
return [f[list(self.domain)[0]]]
inv_map = self.build_inverse_map()
f0, f1 = decompose(inv_map, f)
next_fft = Fft(inv_map.keys(), self.layers[1:])
return interleave(next_fft.fft(f0), next_fft.fft(f1))
我知道这还没什么意义,我只是想把它写出来,因为它非常短。
我们会从头开始,使用上面列出的内容,尝试得出相同的解决方案。
我们的目标是取一个函数$f$并返回一个系数向量$\vec{c}$作为基函数$\hat b_n$的权重,使得加权和可以重构$f$。(在此过程中,我们还需要定义$\hat bn$)。所以我们需要定义这个等式的左边: $\sum{i} \overline{c}^{(i)} \cdot \hat{b}_{n}^{(i)}(x) = f(x)$
在$n = 0$的基础情况下,定义域中只有一个点$x_0$。$f$只是将$x_0$映射到一个单个值,因此可以用一个始终返回该值$f(x_0)$的常数函数来完整描述之。将我们的单个基函数定义为$\hat{b}_0^{(0)}(x) = 1$,可以返回$f(x_0)$作为该函数的系数或权重,从而满足我们的标准:$\sum_i \vec{c}^{(i)} \cdot \hat{b}_n^{(i)}(x) = \vec{c}^{(0)} \cdot \hat{b}_0^{(0)}(x) = f(x_0) \cdot 1 = f(x)$。
否则,继续对$n>0$进行操作。首先,将 decompose
函数应用于$f$,将其拆分为$f_0$和$f_1$,或者“依赖于$t(x)$的部分”和“不依赖于$t(x)$的部分”。
$\sum_i \hat{c}^{(i)} \cdot \hat{b}_n^{(i)}(x) = f_0(\pi_n(x)) + t_n(x) \cdot f_1(\pi_n(x))$
因此,我们从每个$x \in D_n$处的$f(x)$值开始,现在有了每个$x \in D_n$处的$f_0(\pi_n(x))$和$f_1(\pin(x))$值,或者等效地说,每个点$y \in D{n-1}$处的$f_0(y)$和$f_1(y)$值。
现在开始递归,但我们需要小心。我们是将对$f_0$和$f_1$应用下一个(更小的)FFT,而不是$f_0 \circ \pi_n$和$f_1 \circ \pi_n$。我的意思是,例如对$f0$,更小的 FFT 将返回由较小定义域的函数组成较小的基$\hat b{n-1}$的系数$\vec{c}_0$。为了将其代回我们的方程,必须移动内部的$\pi_n$以将定义域投影到更小的定义域,对于$f_1$也是如此: $\sum_i \vec{c}^{(i)} \cdot \hat{b}_n^{(i)}(x) = \left[ \sum_i \vec{c}0^{(i)} \cdot \hat{b}{n-1}^{(i)}(\pi_n(x)) \right] + t_n(x) \cdot \left[ \sum_i \vec{c}1^{(i)} \cdot \hat{b}{n-1}^{(i)}(\pi_n(x)) \right]$
再次地 - 我们将更小的 FFT 应用于$f_0$和$f_1$,为了使结果准确地重构$f_0(\pi_n(x))$,我们需要将更小的基函数应用于$\pi_n(x)$而不是$x$。
现在我们试着推导$\vec{c}$和$\hat b_n$的表达式,以使得等式始终成立。我们会试着将右侧表示为单个求和,因此首先将$tn(x)$移入内部: $\sum{i} \vec{c}^{(i)} \cdot \hat{b}{n}^{(i)}(x) = \sum{i} \vec{c}{0}^{(i)} \cdot \hat{b}{n-1}^{(i)}(\pi{n}(x)) + \sum{i} \vec{c}{1}^{(i)} \cdot t{n}(x) \cdot \hat{b}{n-1}^{(i)}(\pi{n}(x))$
但是……在一般情况下,我们能达到的也就只有这些了。我们有两个不同的内积式的求和,而构造$\vec{c}$和$\hat b_n$以使求和始终相等的唯一方法就是直接连接,设置$\vec{c} = \vec{c}_0||\vec{c}_1$,对于$\hat b_n$也类似,所以我们就是这么做的。
差不多吧。实际上,我们选择交错$\vec{c}_0$和$\vec{c}_1$的元素,但这种选择或任何其他选择都是任意的。我们通常选择交错的原因是$t_n$通常是 1 次函数,而$\pi_n$通常是 2 次函数。因此,当我们交错或将$f_0$中的项放入偶数索引并将$f_1$中的项放入奇数索引时: $(\vec{c}^{(i)}, \hat{b}_n^{(i)}(x)) = \begin{cases} (\vec{c}0^{(k)}, \hat{b}{n-1}^{(k)}(\pi_n(x))) & i = 2k \ (\vec{c}_1^{(k)}, tn(x) \cdot \hat{b}{n-1}^{(k)}(\pi_n(x))) & i = 2k+1 \end{cases}$`
于是索引将对应于基函数的次数,假设对$\hat b{n-1}$也是如此。换句话说,假设$\hat b{n-1}^{(k)}(x)$的次数为$k$,则$\hat b_{n-1}^{(k)}(\pi_n(x))$的次数将为$2k$,而$tn(x)·\hat b{n-1}^{(k)}(\pi_n(x))$的次数将为$2k + 1$,与我们的外部索引$i$完全对应。
掌握了以上所有数学知识后,你现在应该可以理解我们算法的最后两行了:
next_fft = Fft(inv_map.keys(), self.layers[1:])
return interleave(next_fft.fft(f0), next_fft.fft(f1))
我们还可以添加一种方法,将 FFT 的每个基函数应用于一个点,并将结果作为向量返回。这很方便,因为我们现在可以使用dot(coeffs, fft.basis(pt))
在任何点对系数进行求值。
@bind(Fft)
def basis(self: Fft, pt):
if not self.layers:
return [1]
domain, ((π, t), *next_layers) = self
next_fft = Fft({ π(x) for x in domain }, next_layers)
next_basis = next_fft.basis(π(pt))
return interleave(
next_basis,
[t(pt)*b for b in next_basis],
)
此时定义逆 FFT 也非常简单,它将系数转换为求值:
@bind(Fft)
def ifft(self: Fft, coeffs: list) -> dict:
if not self.layers:
return { x: coeffs[0] for x in self.domain }
domain, ((π, t), *next_layers) = self
next_fft = Fft({ π(x) for x in domain }, next_layers)
f0 = next_fft.ifft(coeffs[0::2])
f1 = next_fft.ifft(coeffs[1::2])
return {
x: f0[π(x)] + t(x) * f1[π(x)]
for x in domain
}
在 FFT 的每一层,我们将函数$f$(定义在$D_n$上)分解为两个函数$f_0$和$f1$,每个函数定义在$D{n-1}$上: $f(X) = f_0(\pi_n(X)) + t_n(X) \cdot f_1(\pi_n(X))$
为此我们需要利用$\pi_n$和$t_n$的特殊性质。考虑$Dn$中映射到$D{n-1}$中的同一点的两个点$x_0$和$x_1$,
$f(x_0) = f_0(\pi_n(x_0)) + t_n(x_0) \cdot f_1(\pi_n(x_0))$ (1)
$f(x_1) = f_0(\pi_n(x_1)) + t_n(x_1) \cdot f_1(\pi_n(x_1))$ (2)
我们知道$f(x_0)$和$f(x_1)$的值,因为$f$(函数或其所有求值的向量)是作为算法的输入给出的,并且我们知道$t_n(x_0)$和$t_n(x_1)$,因为$t_n$是我们 FFT 的一个参数。所以这里有 4 个未知数,这是无解的……但是等等!$x_0$和$x_1$映射到同一点,这意味着$\pi_n(x_0) = \pi_n(x_1)$,我们将其简单地写为$\pi_n(x)$。重写为: $f(x_0) = f_0(\pi_n(x)) + t_n(x_0) \cdot f_1(\pi_n(x))$ (3) $f(x_1) = f_0(\pi_n(x)) + t_n(x_1) \cdot f_1(\pi_n(x))$ (4)
现在我们有 2 个方程和 2 个未知数,可以求解了。如果你仔细看看,你可能会认出这是$y = mx + b$,其中$m = f_1(\pi_n(x))$和$b = f_0(\pi_n(x))$,所以你可能已经知道了如何手动求解之。在我看来,将其视为矩阵乘法会更容易,我们使用 2x2 求逆公式: $\begin{bmatrix} f(x_0) \ f(x_1) \end{bmatrix} = \begin{bmatrix} 1 & t_n(x_0) \ 1 & t_n(x_1) \end{bmatrix} \begin{bmatrix} f_0(\pi_n(x)) \ f_1(\pi_n(x)) \end{bmatrix}$ (5) $\begin{bmatrix} f_0(\pi_n(x)) \ f_1(\pi_n(x)) \end{bmatrix} = \begin{bmatrix} 1 & t_n(x_0) \ 1 & t_n(x_1) \end{bmatrix}^{-1} \begin{bmatrix} f(x_0) \ f(x_1) \end{bmatrix}$ (6) $= \left( \frac{1}{t_n(x_1) - t_n(x_0)} \right) \begin{bmatrix} t_n(x_1) & -t_n(x_0) \ -1 & 1 \end{bmatrix} \begin{bmatrix} f(x_0) \ f(x_1) \end{bmatrix}$ (7) $= \left( \frac{1}{t_n(x_1) - t_n(x_0)} \right) \begin{bmatrix} t_n(x_1)f(x_0) - t_n(x_0)f(x_1) \ f(x_1) - f(x_0) \end{bmatrix}$ (8) 这为我们提供了$f_0(\pi_n(x))$和$f_1(\pi_n(x))$的显式公式,仅和可以访问的$f$和$t_n$相关。你可以看到为什么我们需要$t_n$来区分$\pi_n$的纤维,从而保证$t_n(x_0) \neq t_n(x_1)$,否则我们的矩阵将不可逆,从而导致出现除以零。
或者我们也可以让 Sage 为我们完成这件事:
f_x0, f_x1, t_x0, t_x1 = SR.var('f_x0 f_x1 t_x0 t_x1')
(matrix([[1, t_x0],
[1, t_x1]]).inverse() * vector([f_x0, f_x1])).simplify_rational()
$\begin{pmatrix} f_{x_1}t_{x_0} - f_{x_0}t_{x_1} & f_{x_0} - f_{x_1} \\ t_{x_0} - t_{x_1} & t_{x_0} - t_{x_1} \end{pmatrix}$
注意到$f_1(\pi_n(x)) = \frac{f(x_1) - f(x_0)}{t_n(x_1) - t_n(x_0)}$的表达式对应于计算升降的“斜率”$m$,而$f_0(\pi_n(x))$的表达式对应于“y轴截距”$b$,如果你将其重新带入“$y = mx + b$”,则会得到该值。
如果我们预先计算定义域内所有点处标量的逆 ,就可以通过对的2 次乘法 + 2 *(1 次减法和 1 次乘以逆标量)= 4 次乘法和 2 次减法。
实践中,你会希望选择一个巧妙的twiddle $t_n$和巧妙的定义域来减少这种计算。例如,如果我们知道域中的所有点都有$\left( \frac{1}{t_n(x_1) - t_n(x0)} \right)$,就像在乘法的情况下一样,那么它的开销会低得多: `.substitute(t_x1 = -t_x0).simplifyrational() ` $\left( \frac{1}{2} f{x0} + \frac{1}{2} f{x1}, \frac{f{x0} - f{x1}}{2 t{x_0}} \right)$ 这可以通过 1 次加法、1 次减法和 2 次乘法来计算。如果将缩放1/2延迟到最后,则可以将其减少为 1 次乘法,再加上一次最终的缩放,最终开销更低($N$次总缩放乘法,而不是$N \log N$)。
因此,这里是decompose
函数:
def decompose(inv_map, f):
f0 = {
π_x: (f[x0] * t1 - f[x1] * t1) / (t1 - t0)
for π_x, ((x0, t0), (x1, t1)) in inv_map.items()
}
f1 = {
π_x: (f[x1] - f[x0]) / (t1 - t0)
for π_x, ((x0, t0), (x1, t1)) in inv_map.items()
}
return f0, f1
如果将我们的三个函数合并为一个,它看起来会是这样的。我发现对这段代码片段沉思会让人平静。将其打印出来并塞到枕头下面以求得好运。
@bind(Fft)
def fft(self: Fft, f: dict):
if not self.layers:
return [f[list(self.domain)[0]]]
domain, ((π, t), *next_layers) = self
inv_map = defaultdict(lambda: [])
for x in domain:
inv_map[π(x)].append(x)
f0 = {
π_x: (f[x0] * t(x1) - f[x1] * t(x0)) / (t(x1) - t(x0))
for π_x, (x0, x1) in inv_map.items()
}
f1 = {
π_x: (f[x1] - f[x0]) / (t(x1) - t(x0))
for π_x, (x0, x1) in inv_map.items()
}
next_fft = Fft(inv_map.keys(), next_layers)
return interleave(next_fft.fft(f0), next_fft.fft(f1))
在每一层,分解都是线性于$N$,然后我们对一半大小的问题进行两次递归。层数为$\log N$,因此总运行时间为$N \log N$。
由于应用 FFT 是在基变换,或函数的线性变换,因此可用应用于函数求值的矩阵来表示。矩阵乘法是$n^3$(量级)的,因此性能要差得多,但我们可以将这种表示用于其他目的,例如计算当用作一个线性码时 FFT 的最小距离。
我们矩阵的列将来自把 FFT 应用于“独热”向量$(0,...,1,...,0)$,可以将其视为将求值的拉格朗日基映射到 FFT 基。
one_hot = lambda n, i: vector([1 if j == i else 0 for j in range(n)])
@bind(Fft)
def fft_matrix(self: Fft):
domain = list(self.domain)
N = len(domain)
return matrix.column([
self.fft(dict(zip(domain, one_hot(N, i))))
for i in range(N)
])
每个这些例子都值得写一篇自己的文章,但目前我只想给出一个参考,并展示它们对于其$D$、$\pi$和$t$的选择是有效的。
def demo_fft(field, sizes, fft_factory, symbolic_point):
for n in sizes:
print(f'n={n}:')
fft = fft_factory(n)
print(f' basis:', fft.basis(symbolic_point))
set_random_seed(0)
evals = dict(zip(fft.domain, rands(field, 2^n)))
# FFT,然后在每个原始点进行求值,得出原始值
coeffs = fft.fft(evals)
evals2 = { x: dot(coeffs, fft.basis(x)) for x in fft.domain } assert evals == evals2
# IFFT 正常工作
evals3 = fft.ifft(coeffs)
assert evals == evals3
# FFT矩阵正确计算系数
M = fft.fft_matrix() assert M * vector([evals[x] for x in fft.domain]) == vector(coeffs)
print(' tests succeeded!')
IM = M.inverse().T[:2^(n-1)]
# 这里计算非常慢
dist = LinearCode(IM).minimum_distance()
print(' code minimum distance:', dist, f'(best possible is {2^(n-1)+1})')
经典 FFT 在$\mathbb{C}$的乘法子群中起作用,其中$e^{2\pi i/k}$是第$k$个单位根。由于精度问题,这实际上有点难展示。如果我们用浮点数,$\pi$映射就不是精确的二到一的,我们的结果也不会准确。因此,我将展示 NTT,它是完全相同的算法,但应用于有限域的乘法子群。
注意这里的基正是你所期望的那些。
SquaringLayer = Layer(
π = lambda x: x^2,
t = lambda x: x,
)
GF17 = GF(2^4 + 1)
def make_ntt(n):
gen = GF17.multiplicative_generator() ^ (2^(4-n))
return Fft(
domain = take(2^n, powers(gen)),
layers = [SquaringLayer] * n,
)
demo_fft(GF17, [2, 3], make_ntt, polygen(GF17, 'X'))
n=2:
basis: [1, X, X^2, X^3]
tests succeeded!
code minimum distance: 3 (best possible is 3)
n=3:
basis: [1, X, X^2, X^3, X^4, X^5, X^6, X^7]
tests succeeded!
code minimum distance: 5 (best possible is 5)
来自 Circle STARKs
CircleYLayer = Layer(
π = lambda xy: xy[0],
t = lambda xy: xy[1],
)
CircleXLayer = Layer(
π = lambda x: 2*x^2 - 1,
t = lambda x: x,
)
GF127 = GF(2^7 - 1)
C127.<i> = GF127.extension(polygen(GF127)^2 + 1)
circle_gen = C127.multiplicative_generator()^(GF127.order()-1)
def make_cfft(n):
gen = circle_gen^(2^(7-n-1))
return Fft(
domain = take(2^n, powers(gen^2, start=gen)),
layers = [CircleYLayer] + [CircleXLayer] * (n-1),
)
demo_fft(GF127, [2, 3], make_cfft, polygens(GF127, 'X,Y'))
n=2:
basis: [1, Y, X, X*Y]
tests succeeded!
code minimum distance: 2 (best possible is 3)
n=3:
basis: [1, Y, X, X*Y, 2*X^2 - 1, 2*X^2*Y - Y, 2*X^3 - X, 2*X^3*Y - X*Y]
tests succeeded!
code minimum distance: 4 (best possible is 5)
一开始出自 Novel Polynomial Basis and Its Application to Reed-Solomon Erasure Codes,这里我使用 FRI-Binius中的参数。
GF256 = GF(2^8, repr='int')
subspace = lambda n: [GF256.from_integer(i) for i in range(2^n)]
beta = lambda i: GF256.from_integer(2^i)
W = lambda i, x: product(x - u for u in subspace(i))
q = lambda i, x: (W(i, beta(i))^2 / W(i+1, beta(i+1))) * x * (x + 1)
def make_additive(n):
return Fft(
domain = subspace(n),
layers = [
Layer(
# i=i to work around lambda capture quirk
π = lambda x, i=i: q(i, x),
t = lambda x: x,
)
for i in list(range(n))
]
)
demo_fft(GF256, [2, 3], make_additive, polygen(GF256, 'X'))
n=2:
basis: [1, X, 122*X^2 + 122*X, 122*X^3 + 122*X^2]
tests succeeded!
code minimum distance: 3 (best possible is 3)
n=3:
basis: [1, X, 122*X^2 + 122*X, 122*X^3 + 122*X^2, 251*X^4 + 219*X^2 + 32*X, 251*X^5 + 219*X^3 + 32*X^2, 81*X^6 + 81*X^5 + 170*X^4 + 81*X^3 + 251*X^2, 81*X^7 + 81*X^6 + 170*X^5 + 81*X^4 + 251*X^3]
tests succeeded!
code minimum distance: 5 (best possible is 5)
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!