18.06 第 26 讲:复矩阵与快速 Fourier 变换
- 118.06 第 1 讲:方程组的行图与列图
- 218.06 第 2 讲:用矩阵做消元
- 318.06 第 3 讲:矩阵乘法的四种看法与逆矩阵
- 418.06 第 4 讲:分解成 A = LU
- 518.06 第 5 讲:转置、置换与向量空间
- 618.06 第 6 讲:列空间与零空间
- 718.06 第 7 讲:解 Ax = 0,主变量与特解
- 818.06 第 8 讲:解 Ax = b,行最简形 R
- 918.06 第 9 讲:线性无关、基与维数
- 1018.06 第 10 讲:四个基本子空间
- 1118.06 第 11 讲:矩阵空间、秩 1 矩阵与小世界图
- 1218.06 第 12 讲:图、网络与关联矩阵
- 1318.06 第 13 讲:第一次测验复习
- 1418.06 第 14 讲:正交向量与正交子空间
- 1518.06 第 15 讲:投影到子空间
- 1618.06 第 16 讲:投影矩阵与最小二乘
- 1718.06 第 17 讲:正交矩阵与 Gram-Schmidt
- 1818.06 第 18 讲:行列式的性质
- 1918.06 第 19 讲:行列式公式与代数余子式
- 2018.06 第 20 讲:Cramer 法则、逆矩阵与体积
- 2118.06 第 21 讲:特征值与特征向量
- 2218.06 第 22 讲:对角化与矩阵的幂
- 2318.06 第 23 讲:微分方程与 exp(At)
- 2418.06 第 24 讲:Markov 矩阵与 Fourier 级数
- 2518.06 第 24b 讲:第二次测验复习
- 2618.06 第 25 讲:对称矩阵与正定性
- 2718.06 第 26 讲:复矩阵与快速 Fourier 变换本文
- 2818.06 第 27 讲:正定矩阵与极小值
- 2918.06 第 28 讲:相似矩阵与 Jordan 标准形
- 3018.06 第 29 讲:奇异值分解
- 3118.06 第 30 讲:线性变换及其矩阵
- 3218.06 第 31 讲:基变换与图像压缩
- 3318.06 第 32 讲:第三次测验复习
- 3418.06 第 33 讲:左逆、右逆与伪逆
- 3518.06 第 34 讲:期末复习
这一讲有两个想法。第一,向量一旦有了复数分量,凡是“转置”都要顺手取共轭, 就换成 。第二,Fourier 矩阵 是满矩阵,乘一次要 次乘法,但它能拆成两个一半大小的 加几个几乎全是零的因子。我手写了这个递归: 时, 次乘法被压到 5120 次,比值 204.8。Strang 在黑板上算 的时候说“大约 200 倍”,代码给的数是 204.8。
视频:Lecture 26: Complex matrices; fast fourier transform(OCW 页面与字幕) 教材:《Introduction to Linear Algebra》第 6 版 §6.4 Complex Numbers and Vectors and Matrices(FFT 在第 6 版目录里没有独立小节,我没有找到对应的页码,所以不标) 前置:第 25 讲:对称矩阵与正定性 一句话:复数世界里把“转置”换成“共轭转置”,Fourier 矩阵就成了酉矩阵,逆矩阵就是它的共轭转置;而把它拆成偶数项和奇数项两半,乘法次数从 降到 。
- 开头几分钟是动机:特征值可能是复数,所以复数迟早要进来。这一段字幕里讲得很直白,可以倍速。
- 他讲 为什么不行的那段(向量 的“长度”居然是 0)很短,值得认真听;Hermitian 矩阵和酉矩阵只是改了两个词,本篇第一节已经用代码算过。
- 他在黑板上写 、检查两列正交时当场卡了一下,然后想起要取共轭。这段看着有点乱,但它恰好是整讲最容易犯错的地方,别跳。
- 最后一段的 到 的分解是重点,建议先看完再回来读本篇第三、第四节;他最后算 的那几句,我在第四节用代码重算了一遍。
复向量:长度、内积与 Hermitian 矩阵
复数是被特征值带进这门课的:即使矩阵的元素全是实数,特征值也可能是复数,比如把平面旋转 的矩阵,特征值是 ,对应的特征向量也是复向量。他说迟早要正面看一次:向量和矩阵的元素变成复数以后,哪些定义要改,哪些不用改。答案是只改一件事,下面从长度开始。
复向量的长度怎么算? 他的做法是先给一个反例。取 ,如果照老办法算 ,得到 ,一个非零向量的长度平方居然是零。问题出在 。他的修正是:每个分量先取共轭再相乘,,这样每一项都不是负数。
所以规则只有一条:只要转置,就同时取共轭。他用一个符号 同时表示这两件事(取自 Hermite,他顺便说这位先生自己不发 H 的音):
内积一般是复数,交换两个向量的位置得到的是共轭:。
同样的规则改写了另外两个词。“对称”()对复矩阵不再合适,要换成 ,叫 Hermitian 矩阵。他的例子是
对角线必须是实数(翻过去再取共轭还得回到原位),非对角线上的 和 互为共轭。“正交矩阵”()换成 ,叫酉矩阵(unitary)。Hermitian 矩阵的特征值是实数,特征向量互相垂直,这是上一讲实对称矩阵那套结论的复数版本。
下面的脚本把这几件事都算一遍。NumPy 里 np.vdot(y, x) 正好是 (第一个参数取共轭),np.linalg.eigh 是专门给 Hermitian 矩阵用的特征值求解器:
import numpy as np
def main() -> None: np.set_printoptions(precision=4, suppress=True)
# 复向量的长度:z^T z 不行,要用 z^H z z = np.array([1.0, 1.0j]) print("z^T z =", z @ z) print("z^H z =", z.conj() @ z, ",长度 =", np.linalg.norm(z))
# 内积 y^H x 一般是复数,np.vdot 会对第一个参数取共轭 y = np.array([1.0 + 2.0j, 3.0]) x = np.array([2.0, 1.0 - 1.0j]) print("y^H x =", np.vdot(y, x), ",x^H y =", np.vdot(x, y))
# Hermitian 矩阵:对角线是实数,转置后取共轭等于自己 A = np.array([[2.0, 3.0 + 1.0j], [3.0 - 1.0j, 5.0]]) print("A^H = A:", np.allclose(A.conj().T, A)) lam, Q = np.linalg.eigh(A) print("特征值(实数):", np.round(lam, 6) + 0.0) print("Q^H Q = I:", np.allclose(Q.conj().T @ Q, np.eye(2))) print("A = Q Λ Q^H:", np.allclose(Q @ np.diag(lam) @ Q.conj().T, A)) print("两个特征向量的内积:", np.abs(np.vdot(Q[:, 0], Q[:, 1])))
if __name__ == "__main__": main()z^T z = 0jz^H z = (2+0j) ,长度 = 1.4142135623730951y^H x = (5-7j) ,x^H y = (5+7j)A^H = A: True特征值(实数): [0. 7.]Q^H Q = I: TrueA = Q Λ Q^H: True两个特征向量的内积: 1.457167719820518e-16三处输出对应三个结论。z^T z = 0j,而 z^H z = 2,长度是 ,和他在黑板上写的完全一致。,反过来 ,互为共轭。他的 Hermitian 矩阵 的特征值是 0 和 7:迹是 ,行列式是 ,两个数都对得上;eigh 给出的两个特征向量内积的模是 ,就是舍入误差。
这里有个我自己踩过的坑:写 a @ b 的时候,NumPy 不会替你取共轭。复向量的内积忘了 .conj(),程序不报错,只是答案不对。
Fourier 矩阵:满矩阵,却有正交的列
Fourier 矩阵是什么样的? 他先宣布一个约定:下标从 0 数起,因为电气系这么数(数学系从 1 开始;我是写代码的,毫无悬念站电气系)。 的 Fourier 矩阵第 项是 ,其中
落在复平面的单位圆上,是圆周的 分之一。他取 画了六个点,每个是上一个转 , 回到 1,它们是 1 的六个六次方根, 是第一个,叫本原的。取 更方便:,各次幂是 ,于是
指数就是行号乘列号。它是满矩阵,一个零都没有,乘一次向量要 16 次乘法;一般地是 次。
它的列为什么正交? 看第 1 列 与第 3 列 。他的第一反应是直接逐项相乘:,看上去不是零。然后他想起来要给其中一列取共轭,第 1 列变成 ,再逐项相乘:。这个“看上去不是零,其实是零”的瞬间,就是复内积和实内积的差别。每一列的长度平方是 4,所以除以 2 就是标准正交列: 是酉矩阵,,逆矩阵就是共轭转置除以 4。
我把这些全部交给 NumPy,同时也检查 的归一化版本 ,并确认它和 np.fft 的关系。后一点很实用:NumPy 的 fft 用的是 (负号),Strang 用的是正号,所以他的 等于 NumPy 的 n * ifft(x):
import numpy as np
def fourier(n: int) -> np.ndarray: """返回 Strang 约定的 Fourier 矩阵:F[j, k] = w^(j k),w = e^(2 pi i/n)。""" w = np.exp(2j * np.pi / n) j = np.arange(n) return w ** (np.outer(j, j) % n)
def main() -> None: np.set_printoptions(precision=4, suppress=True) F = fourier(4) print("F4 =") print(np.round(F, 4) + 0.0)
# 第 1 列和第 3 列:直接相乘不是 0,取共轭之后是 0 print("列 1 与列 3,不取共轭:", np.round(F[:, 1] @ F[:, 3], 10)) print("列 1 与列 3,取共轭:", np.round(np.vdot(F[:, 1], F[:, 3]), 10))
G = F.conj().T @ F print("F^H F =") print(np.round(G, 4) + 0.0)
U = F / 2.0 print("F4 / 2 是酉矩阵:", np.allclose(U.conj().T @ U, np.eye(4))) print("逆 = 共轭转置:", np.allclose(np.linalg.inv(F), F.conj().T / 4.0))
for n in [4, 16, 64, 256]: Un = fourier(n) / np.sqrt(n) err = np.linalg.norm(Un.conj().T @ Un - np.eye(n)) print(f"n = {n:3d},||U^H U - I|| = {err:.2e}")
# 与 np.fft 的关系:NumPy 的指数是负号,Strang 的是正号 rng = np.random.default_rng(1806) x = rng.standard_normal(8) + 1j * rng.standard_normal(8) print( "F8 @ x = n * ifft(x):", np.allclose(fourier(8) @ x, 8 * np.fft.ifft(x)), ) print( "conj(F8) @ x = fft(x):", np.allclose(fourier(8).conj() @ x, np.fft.fft(x)), )
if __name__ == "__main__": main()F4 =[[ 1.+0.j 1.+0.j 1.+0.j 1.+0.j] [ 1.+0.j 0.+1.j -1.+0.j 0.-1.j] [ 1.+0.j -1.+0.j 1.+0.j -1.+0.j] [ 1.+0.j 0.-1.j -1.+0.j 0.+1.j]]列 1 与列 3,不取共轭: (4-0j)列 1 与列 3,取共轭: 0jF^H F =[[4.+0.j 0.+0.j 0.+0.j 0.+0.j] [0.+0.j 4.+0.j 0.+0.j 0.+0.j] [0.+0.j 0.+0.j 4.+0.j 0.+0.j] [0.+0.j 0.+0.j 0.+0.j 4.+0.j]]F4 / 2 是酉矩阵: True逆 = 共轭转置: Truen = 4,||U^H U - I|| = 1.85e-16n = 16,||U^H U - I|| = 1.44e-15n = 64,||U^H U - I|| = 2.90e-14n = 256,||U^H U - I|| = 1.24e-13F8 @ x = n * ifft(x): Trueconj(F8) @ x = fft(x): True读输出:第 1 列与第 3 列不取共轭的内积是 4,取共轭之后是 0。 是 。四个尺寸的 从 慢慢涨到 ,矩阵越大累积的舍入误差越多,但始终在机器精度的几百倍以内。最后两行证实了符号约定:,。
(顺带一个我实际碰到的数字:我第一次写 w ** np.outer(j, j),没有对 取模, 时误差是 ,比现在大两个数量级。指数大到几万的时候,w 的乘方在浮点里已经不是精确的单位复数了。代码里的 % n 就是为这个加的。)
这就是为什么逆变换这么便宜:求逆矩阵通常要 ,这里什么都不用算,转置再取共轭就是逆。正变换用 ,逆变换用 ,两者几乎一样,他说它们很近,近到容易搞混。
把 F64 拆成两个 F32
快在哪里? 他从一个看似不相干的观察开始:把 平方,角度翻倍,正好得到 。所以 和 用的是同一族数,应该能连起来。连接的公式是
三个因子各管一件事。最右边的 是置换矩阵:把向量的偶数下标分量 排在前面,奇数下标分量 排在后面。中间是两份 ,分别处理偶数部分和奇数部分,中间两块零是关键。最左边是“修补”: 是对角矩阵,对角线是 ,上半部分用 合并,下半部分用 合并。
他说这个公式要检查,不过只是核对一堆求和,没有当场推。我补一下推导,只要三行。输出的第 项是 ,把 拆成偶数 和奇数 :
这里用了 。而对 与 来说,两个 的结果相同(因为 ), 只让奇数部分变号。所以前一半输出是“偶数 奇数”,后一半是“偶数 奇数”,这正是 做的事。
下面的脚本把三个因子真的造出来,对 验证乘积等于 ,并数一数每个因子里有多少个非零元:
import numpy as np
def fourier(n: int) -> np.ndarray: """返回 F[j, k] = w^(j k),w = e^(2 pi i/n)。""" w = np.exp(2j * np.pi / n) j = np.arange(n) return w ** (np.outer(j, j) % n)
def split_factors(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """返回 F_n = B @ blockdiag(F_m, F_m) @ P 中的 B、blockdiag 和 P。""" m = n // 2 w = np.exp(2j * np.pi / n) D = np.diag(w ** np.arange(m)) identity = np.eye(m) B = np.block([[identity, D], [identity, -D]]) Fm = fourier(m) zero = np.zeros((m, m)) blocks = np.block([[Fm, zero], [zero, Fm]]) # 置换:先取偶数下标,再取奇数下标 P = np.zeros((n, n)) P[np.arange(m), 2 * np.arange(m)] = 1.0 P[m + np.arange(m), 2 * np.arange(m) + 1] = 1.0 return B, blocks, P
def main() -> None: np.set_printoptions(precision=4, suppress=True)
B, blocks, P = split_factors(4) print("P4 =") print(P) print("D = diag", np.round(np.diag(B[:2, 2:]), 4) + 0.0) print("F4 = B @ blocks @ P:", np.allclose(B @ blocks @ P, fourier(4)))
for n in [8, 64]: B, blocks, P = split_factors(n) ok = np.allclose(B @ blocks @ P, fourier(n)) print(f"F{n} = B @ blocks @ P:", ok)
# 零的个数:B 和 P 几乎全是零,blocks 一半是零 n = 64 B, blocks, P = split_factors(n) for name, M in [("B", B), ("blocks", blocks), ("P", P)]: print(f"{name} 的非零元个数:{np.count_nonzero(M)} / {n * n}")
if __name__ == "__main__": main()P4 =[[1. 0. 0. 0.] [0. 0. 1. 0.] [0. 1. 0. 0.] [0. 0. 0. 1.]]D = diag [1.+0.j 0.+1.j]F4 = B @ blocks @ P: TrueF8 = B @ blocks @ P: TrueF64 = B @ blocks @ P: TrueB 的非零元个数:128 / 4096blocks 的非零元个数:2048 / 4096P 的非零元个数:64 / 4096时 , 把下标顺序 变成 。三个尺寸的分解都对得上。再看 的非零元: 只有 128 个, 只有 64 个,中间的两份 有 个,正好是 的一半。置换不用乘法, 里真正要乘的只有 的 32 个对角元,所以乘一次向量的代价从 4096 次降到 次,大约少了一半()。
一半还不算惊人。真正的收益在下一步。
一直拆下去:递归与 n log n
为什么不在这里停下? 因为中间那两个 也可以用同一个公式各自拆成两个 ,再拆成 、、、。他的原话大意是:递归是关键词。每拆一层,中间的块越来越小,而左边多出来的修补因子每层都只花 次乘法,右边多出来的只是置换。从 64 到 1 要拆 层,每层 32 次乘法,总数是 ,也就是 。
我在这一类问题里总会想起归并排序:一份工作切成两半,各自递归,再花线性的代价合并, 就是这么来的。(类比只用这一次,FFT 的合并步骤是乘 再加减,不是比大小。)
手写递归版本只要十来行。每层把偶数下标和奇数下标分别交给自己,拿回两半结果,乘上 的对角元再加减拼起来。我还在里面放了一个计数器,每次乘 就加 ,然后把 到 的计数列成表,和公式 对比:
import numpy as np
def fft(x: np.ndarray, counter: list[int]) -> np.ndarray: """递归 FFT(Strang 约定,指数为正号),counter[0] 累计对 D 的乘法。""" n = len(x) if n == 1: return x.astype(complex) even = fft(x[0::2], counter) odd = fft(x[1::2], counter) d = np.exp(2j * np.pi / n) ** np.arange(n // 2) counter[0] += n // 2 return np.concatenate([even + d * odd, even - d * odd])
def main() -> None: np.set_printoptions(precision=4, suppress=True) rng = np.random.default_rng(1806)
x = rng.standard_normal(64) counter = [0] y = fft(x, counter) print("与 n * ifft 一致:", np.allclose(y, 64 * np.fft.ifft(x))) print("最大误差:", f"{np.max(np.abs(y - 64 * np.fft.ifft(x))):.2e}")
print( f"{'n':>6} {'n^2':>9} {'递归计数':>8} {'n/2*log2(n)':>12} {'比值':>8}" ) for k in range(1, 11): n = 2**k counter = [0] fft(rng.standard_normal(n), counter) formula = n // 2 * k print( f"{n:6d} {n * n:9d} {counter[0]:8d} {formula:12d} " f"{n * n / counter[0]:8.1f}" )
if __name__ == "__main__": main()与 n * ifft 一致: True最大误差: 2.44e-14 n n^2 递归计数 n/2*log2(n) 比值 2 4 1 1 4.0 4 16 4 4 4.0 8 64 12 12 5.3 16 256 32 32 8.0 32 1024 80 80 12.8 64 4096 192 192 21.3 128 16384 448 448 36.6 256 65536 1024 1024 64.0 512 262144 2304 2304 113.8 1024 1048576 5120 5120 204.8第一行:手写的 FFT 与 64 * np.fft.ifft(x) 最大差 ,是同一个变换(符号约定同上)。表里“递归计数”与“”两列在十个尺寸上逐行相等,包括 时的 192。最后一列的比值 除以计数,是他在黑板上算的那个数: 时是 。他说的“减少约 200 倍”、“原来做一次变换的时间现在能做 200 次”,就是这行。比值还随 增长, 时 21.3, 时 204.8,尺寸每翻一倍,收益接近翻一倍。
需要说明两点,免得读者高估。第一,我数的是“乘 的次数”,沿用了他的口径: 的第一个元素是 1,其实不必乘;加减法也没算;真实库的常数因子(还有 radix-4、split-radix 这些变种)比这复杂。我只计数,没有计时,所以不敢说“实际快了 200 倍”。第二,这个递归要求 是 2 的幂; 这样的尺寸他提到过 与 的联系,但拆到 就拆不动了;据我所知,实际的库对这种尺寸另有办法,那是 FFT 实现的话题,这一讲没有展开。
回头看,整件事靠的是 的结构:它的元素全是同一个数的幂, 恰好是更小的 Fourier 矩阵里的数。他在最后一句说得很对,整个想法取决于一个“特别的矩阵”,而不是什么通用技巧。我猜这就是 FFT 能到处用的原因:它不改变任何结果,只是把同一个矩阵换了一种乘法。他顺便提到,据他所知高斯早就注意到了这个分解,只是没有强调(这是他的说法,我没有去查文献)。
下一讲回到实数世界:什么是正定矩阵,为什么应用里遇到的矩阵多半是它。接着往下读:18.06 第 27 讲:正定矩阵与极小值。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











