写量子电路模拟器的时候,我干过一件蠢事:往一个4维态矢量上作用了一个Hadamard门,结果算出来的概率分布和教科书对不上。我盯着屏幕看了十分钟,以为是矩阵写错了,后来才发现问题根本不在矩阵,而在索引配对没配明白。那次之后,我把“单量子门作用后到底应该更新哪些分量”这件事彻底梳理了一遍,才发现这套规则的核心其实就是一句话:在态矢量上应用单量子门,永远不会把某个振幅单独改掉,而是成对地、用同一个2×2矩阵去更新两个振幅。
这句话,我后来和做模拟器的朋友聊起来,都管它叫“索引对更新法则”。它看起来简单,实际上决定了整份态矢量在内存里怎么被读写、怎么被遍历、怎么被并行化。这篇内容适合两类人看:一类是刚开始手写量子电路模拟器、想搞懂底层原理的开发者;另一类是把量子计算当工具用、经常在云平台跑电路但好奇后端到底怎么算的人。读完你不仅能写出一个能跑通的基础模拟器,还能在结果对不上时知道该往哪里找病根。
1. 单量子门在模拟器里的真实面目:一个小矩阵如何搅动整个态矢量
1.1 态矢量里藏了哪些信息
n个量子比特的纯态,在理想无噪声的前提下,可以表示成一个长度为2^n的复数向量。这个向量里的每一项,叫做某个计算基态下的振幅。比如2比特系统,有四个基态映射到索引上:
- 索引0对应 |00⟩
- 索引1对应 |01⟩
- 索引2对应 |10⟩
- 索引3对应 |11⟩
这里的索引规则是:二进制的最低位对应第0号量子比特,二进制从右往左读就是q0、q1、q2……这个约定并不是什么物理定律,纯粹是模拟器实现时的统一口径。但不同模拟器可能采用不同约定,索引更新逻辑会跟着反过来,这是后话,先按下不表。
态矢量里的振幅是复数,因为量子相位在算法中会起作用,比如相对相位影响干涉结果。你用float数组去存振幅,等于在一开始就把这个可能性删掉了。这个事情在测试只有H门、X门的电路时看不出来,一旦遇到S门、T门,浮点数组立刻出问题。所以,只要涉及量子态模拟,一开始就用complex类型,别犹豫。
1.2 一个2×2矩阵在n比特系统里做什么
单量子门在教科书里通常给成2×2的酉矩阵。比如Hadamard门、Pauli-X门、相位门,形式大家都熟。
在n比特系统里,一个只作用在第k号比特上的单量子门,理论上可以写成一个2^n × 2^n的大矩阵,也就是把第k个位置替换成那个2×2门,其余位置填上单位矩阵,再做张量积。但实际模拟器不会真的构造这个大矩阵,因为内存和计算量都浪费在大量无关的0元素上。我们需要搞清楚的是:这个大矩阵作用到态矢量上,到底动了哪些分量、哪两个分量之间会发生线性组合。
举一个很直观的例子:对2比特系统的第0号比特应用Hadamard门。因为门只作用在最低位上,所有高位为0的基态会和对应高位为1的基态成对发生混合。也就是说,更新只发生在(0,1)和(2,3)这两对索引上。索引1和3是唯一和第0位相关的“另一分支”。其他索引之间的振幅不会互相串门。
这个规律抽象出来,就是人们常说的“索引对更新法则”:作用在第k号比特上的单量子门,等价于把态矢量里所有第k位为0的索引i,和对应第k位为1的索引j = i + 2^k组成配对,然后对每一对用同一个2×2矩阵做线性变换。
1.3 索引对更新法则解决了什么问题
如果不用索引对更新法则,最直观的做法是把整个大矩阵构造出来再乘向量。2比特系统只有4×4矩阵,还能忍;10比特就是1024×1024;20比特就是一千万乘一千万的矩阵,内存直接爆炸。而真实模拟的目标往往在20比特以上,所以必须利用门算符的结构,把“大矩阵乘法”拆成“许多个2×2的小变换”。
这也解释了为什么单量子门是量子模拟里最基础的积木。任何一个量子电路,不管最后要跑什么算法,绝大部分操作都是单量子门。只要把这一层索引更新逻辑吃透,后面加双比特门、加测量、加噪声模型,都是在同样的骨架上做扩展。很多人在模拟器里跑不出正确结果,不是不懂量子力学,而是没搞清“门该更新哪些下标”这个纯计算机问题。
2. 索引对更新法则的推演:从二进制索引到2×2更新
2.1 索引的二进制拆解
要准确说出哪两个索引配对,最稳的办法不是靠肉眼观察,而是把索引写成二进制。假设我们处理第k号比特,那么任意整数索引x都可以拆成三段:
- 高于k位的部分:决定这个索引属于哪个“高位块”
- 第k位本身:0或1
- 低于k位的部分:决定它在块内的偏移
举个例子,3比特系统,索引5的二进制是101。如果看第1号比特(也就是从左往右第二位,实际上低位算起是第二位),它的值是从右往左第1位,等于0。那么索引5对应的高位块是第四块,块内偏移是1。它配对的那个索引,应该是第1位变成1,其他位不变,也就是111,十进制7。
所以配对的本质是:两个索引的唯一区别就是目标比特位上一个是0、一个是1,其余所有比特完全相同。这是单量子门作用的直接结果,因为U矩阵在计算基底下只能改变被作用比特的状态,不能把其他比特也带进纠缠。
2.2 配对公式和振幅更新公式
如果第k号比特的权重是2^k,那么所有第k位为0的索引i,配对的索引就是j = i + 2^k。为什么能直接加?因为i在那一位上是0,加上2^k恰好把那一位变成1,而不会向更高位进位。反过来,如果i的第k位已经为1,它一定会在另一个索引为0的配对里被遍历到,不应该重复处理。
设单量子门的矩阵为:
U = [[a, b], [c, d]]它作用在第k号比特上时,先取一对索引(i, j),其中i的第k位为0,j的第k位为1。更新规则为:
new_i = a * old_i + b * old_j new_j = c * old_i + d * old_j这里的old_i、old_j是更新前的两个振幅。很多人会把这个公式记成“行的方向是a、b,列的方向是c、d”,其实只要记住U左乘态矢量,U的第0行负责生成目标位为0的振幅,第1行负责生成目标位为1的振幅,就不会弄反。
2.3 不同比特位下的配对表
最直观的理解方式,是把不同目标位的配对表列出来。以2比特系统和3比特系统为例:
| 系统比特数 | 目标比特 | step=2^target | 完整配对表 |
|---|---|---|---|
| 2 | 0 | 1 | (0,1), (2,3) |
| 2 | 1 | 2 | (0,2), (1,3) |
| 3 | 0 | 1 | (0,1), (2,3), (4,5), (6,7) |
| 3 | 1 | 2 | (0,2), (1,3), (4,6), (5,7) |
| 3 | 2 | 4 | (0,4), (1,5), (2,6), (3,7) |
我建议手写模拟器的时候把这个表打印出来当对照。特别是处理3比特以上系统时,遍历逻辑写错一两个边界条件,配对表就会多出不该有的组合,或者少掉一些组合。对着这张表看,错误通常一眼就能找出来。
3. 把法则写进代码:朴素循环、批量更新与单元测试
3.1 朴素双层循环实现
最容易理解、也最适合用来教学的开源模拟器写法,是双层循环。外层循环负责遍历高位块,内层循环负责块内的偏移。假设state是一个长度为2^n的复数numpy数组,target是目标比特编号,gate是2×2复数矩阵:
import numpy as np def apply_single_qubit_gate(state, gate, target): size = state.shape[0] # size = 2^n step = 1 << target # step = 2^target for block in range(0, size, 2 * step): for offset in range(block, block + step): i = offset # 第target位为0 j = offset + step # 第target位为1 a = state[i] b = state[j] state[i] = gate[0, 0] * a + gate[0, 1] * b state[j] = gate[1, 0] * a + gate[1, 1] * b return state这段代码的关键在于block的步长是2*step。它保证offset从每个块的起点开始,最多走到block+step-1,也就是目标位确定是0的区域。配对的j永远落在后面的step个位置里。对于step=1的情况,block就是0、2、4、6,正好形成相邻两两配对;对于step=2的情况,block是0、4、8,配对是(0,2)、(1,3)、(4,6)、(5,7),和前面表格完全一致。
这个双层循环的时间复杂度是O(n×2^n)?不对,准确说每一对访问一次,总共2^(n-1)对,每次更新两个元素,所以整体O(2^n),这才是单量子门在态矢量模拟里的标准代价。
3.2 原地更新必须保存旧值
我一开始犯的错误,就是在循环里把上面两行合并成一行写:
state[i] = gate[0, 0] * state[i] + gate[0, 1] * state[j] state[j] = gate[1, 0] * state[i] + gate[1, 1] * state[j]这在数学上是错的。第一行执行完后,state[i]已经被覆盖成新值,第二行计算state[j]时用到的state[i]就不是原来的振幅了。整个量子态会被污染。正确做法是如上面代码所示,先把一对旧值存到局部变量a和b里,再统一写回。这个坑几乎每个手写模拟器的人都会踩一次,踩完就记住了。
顺便说一句,量子线路里两个连续的单量子门如果作用在不同比特上,是可以并行处理索引对的,但它们仍然共享同一个态矢量数组,所以在并发操作时也需要避免读写冲突。
3.3 向量化批量更新
Python的双层循环在量子比特数超过15之后会变得很慢,因为每更新一对索引都要跑一遍Python字节码,开销极大。更实际的做法是用numpy的布尔索引,一次抽取出所有低位索引和高位索引,再批量做线性组合。
def apply_single_qubit_gate_vec(state, gate, target): mask = 1 << target indices = np.arange(state.shape[0]) low_idx = indices[(indices & mask) == 0] # 目标位是0的所有索引 high_idx = low_idx | mask # 配对的索引 old_low = state[low_idx].copy() old_high = state[high_idx].copy() new_low = gate[0, 0] * old_low + gate[0, 1] * old_high new_high = gate[1, 0] * old_low + gate[1, 1] * old_high state[low_idx] = new_low state[high_idx] = new_high return state注意这里必须copy一份旧值。因为state[low_idx]和state[high_idx]是两段不同的内存,但old_low和old_high会在两条更新语句里同时被用到,如果不先存旧值,第二条语句的右侧会引用到已经更新过的new_low,结果和3.2节一样错。
向量化写法把Python循环压到了numpy底层,性能提升非常可观。在我的实践里,15比特以上的系统基本只有向量化方案能跑出现实可用的速度。不过它也带来一个问题:low_idx和high_idx这两个索引数组每次都要重新创建,如果对一个目标比特反复应用很多层门,可以考虑把索引数组缓存下来复用,用空间换时间。
3.4 用标准门做单元测试
代码写完后,不要急着跑复杂电路,先用几个已知结果的单门做测试。第一个测试是:2比特初始态|00⟩,对第0号比特应用Hadamard门,应该得到(|00⟩ + |01⟩)/√2,也就是态矢量变成[1/√2, 1/√2, 0, 0]。
state = np.zeros(4, dtype=complex) state[0] = 1.0 H = np.array([[1, 1], [1, -1]], dtype=complex) / np.sqrt(2) apply_single_qubit_gate(state, H, 0) print(state) # 预期: [0.70710678+0.j, 0.70710678+0.j, 0. +0.j, 0. +0.j] print(np.sum(np.abs(state) ** 2)) # 预期:1.0第二个测试是Pauli-X门翻转比特。初始态|01⟩(索引1),对第0号比特应用X门,应该变成|00⟩(索引0):
X = np.array([[0, 1], [1, 0]], dtype=complex) state = np.zeros(4, dtype=complex) state[1] = 1.0 apply_single_qubit_gate(state, X, 0) print(state) # 预期: [1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j]如果这两个测试都能过,说明配对索引和2×2更新方向基本没问题。测试通过不等于完全正确,因为H门和X门都是自伴门,对矩阵的方向不敏感,你哪怕把U和U†搞混了,它们的结果也一样。真正要检验方向,得用T门这类非自伴门去测相对相位,这也是下一节排查思路里的一环。
4. 模拟结果对不上理论时,我按什么顺序排查
4.1 第一步先打印配对表
结果不对的时候,我从来不会先怀疑数学公式,而是先打印实际更新了哪些索引对。这比看任何log都管用。做法是把apply_single_qubit_gate循环里那一行每次的(i, j)打印出来,和手工配对表对比。
有一个典型错误是遍历循环里把step写成了1 << (target+1),相当于目标位偏移整整一倍。在3比特系统里,对第1号比特作用门,错误代码会更新(0,4)、(1,5)、(2,6)、(3,7),而正确配对是(0,2)、(1,3)、(4,6)、(5,7)。打印配对表之后,这个错误是藏不住的。
还有一类错误是遍历范围写成了range(0, size, step),这会导致每个索引被访问两次,一次作为低位索引、一次作为高位索引,结果等同于先更新一组配对、再在同一轮里用新值更新另一组无关配对,态矢量当场报废。这也是只有打印配对表才能发现的。
4.2 第二步检查矩阵方向和比特序
配对索引没问题,但结果还是不对,那就看矩阵方向。单量子门作用在态矢量的左边是U,不是U†。很多人写模拟器时喜欢参考一些数学推导,结果把行和列写反,或者把门矩阵转置了一下。
检查方向最简单的方法是找一个非自伴门来测相位。比如T门,它对|1⟩的振幅乘上e^(iπ/4)。初始态取|01⟩,对第0号比特作用T门,结果应该是振幅为e^(iπ/4),也就是复数部分为正。如果得到e^(-iπ/4),那说明你用的矩阵实际上是T门或T门的共轭转置,方向写反了。自伴门测不出这个问题,所以千万别只用H门、X门做测试。
还要检查比特序约定。前面所有讨论都默认索引最低位是q0。有的模拟器为了读取方便,把索引最高位当q0,那么配对公式里的target也要做镜像映射。这个坑最隐蔽,因为两种约定在n=2时都可能给出正确结果,只是配对不同。做一个全矩阵参考对照,是找出约定错误的最快手段。
4.3 第三步准备一个“全矩阵参考”
为了让模拟器在开发期能自检,我建议保留一个“土办法”:对小比特数系统,直接构造完整的2^n×2^n矩阵,用标准矩阵乘法验证索引更新逻辑。2比特系统完全没必要用优化代码,直接算就好。
例如,对2比特系统:
- 目标比特0(低位):完整矩阵是 np.kron(I2, U)
- 目标比特1(高位):完整矩阵是 np.kron(U, I2)
def full_matrix_ref(state, gate, target): n = int(np.log2(state.shape[0])) I2 = np.eye(2, dtype=complex) full = I2 if n == 1 else np.kron(I2, I2) # 从高到低构造张量积 op = np.eye(1, dtype=complex) for k in range(n - 1, -1, -1): g = gate if k == target else np.eye(2, dtype=complex) op = np.kron(g, op) return op @ state这段代码里有个关键点:张量积的构造顺序是从最高位(n-1)往最低位(0)推进,这样最终矩阵的最右侧是目标比特为0时的门。如果不小心搞反,结果相当于在镜像比特序下应用门,和快速实现一比就能发现问题。
只要随机初始一个态矢量,分别用apply_single_qubit_gate和full_matrix_ref作用同一个门,再对比两个结果是否足够接近,就能验证索引更新逻辑在整个态空间上是否正确。这个小工具建议保留在项目里,后续加双比特门、三比特门时也能继续当裁判。
4.4 忘记complex类型是隐蔽杀手
最后一个高频坑是数据类型。我在1.1节提过,但还是要单独拿出来说一次。用float数组存量子态,很多电路看起来能跑,因为H门和X门产生的是实振幅。可一旦引入相位门,比如S门乘i、T门乘e^(iπ/4),浮点数组会把虚部静默丢弃,结果测量概率还是对的,但所有相对相位都错了。这在单门测试里很容易被漏掉。
建议在模拟器入口处加一个断言:
assert np.iscomplexobj(state), "state must be complex array" assert np.iscomplexobj(gate), "gate must be complex array"别小看这几行,它能帮你把一类极其隐蔽的错误挡在门外。
5. 索引对更新法则还能撑起多少玩法
5.1 双比特门:从“配对更新”到“条件块更新”
单量子门的索引对更新法则,可以自然推广到双比特门。区别在于,双比特门不是对单一比特位做0/1配对,而是要根据控制比特的状态来决定是否更新目标比特。
以CNOT门为例,控制比特是q0,目标比特是q1。在2比特态矢量[c00, c01, c10, c11]里,CNOT做的操作是把索引2和索引3的振幅互换,因为只有q0=1的基态|10⟩和|11⟩会触发翻转。这就是典型的“条件块更新”:把控制比特位为1的那些索引对挑出来,在目标比特位上做交换。如果换成受控相位门,则不交换振幅,而是在控制比特满足条件时给目标振幅乘一个相位。
更一般的双比特酉门,不是交换或单边乘相位,而是需要在索引的四个组合中做一次4×4线性变换。这时候触发更新的索引变成了四元组,但底层思想仍然是“通过检查若干个比特位的状态,把相关索引聚成块,再进行小块矩阵运算”。理解了索引对更新法则,再看双比特门的实现,几乎不用重新学一套体系。
5.2 性能优化:缓存局部性、向量化和GPU思维
索引对更新法则还给性能优化提供了明确方向。因为单量子门本质是大量成对的2×2线性变换,非常适合并行化和SIMD风格处理。
在CPU上,朴素双层循环要注意缓存局部性。外层块按2*step递增,内层offset连续走step个位置,这样每次访问的i都是连续内存,j则是隔了一段距离的连续内存,对CPU缓存比较友好。如果反过来先遍历低索引再跳跃,缓存命中率会下降,但正确性不会变,性能会变差。
向量化之后,代码变成一次numpy批量操作,对中小规模比特数已经足够。如果想进一步上强度,可以用GPU写kernel,每个线程负责一对索引。这时候几乎不需要改数学公式,只要把low_idx、high_idx两个数组分配给线程即可。GPU的显存带宽决定了20比特以上的模拟能跑多快,而索引对更新法则保证了每个线程只需要自己的两段输入,不存在跨线程竞争,天然适合大规模并行。
5.3 稀疏态矢量和密度矩阵的推广
很多实际算法在运行过程中,态矢量的非零振幅数量远小于2^n。这时候没人会用完整数组存态,而是用字典保存{索引: 振幅}。索引对更新法则在这里会变得更清爽:当作用单量子门时,先找出字典里所有第target位为0的键,再看它的配对键是否存在。
如果配对键存在,就按2×2矩阵更新两个振幅,并把结果写回字典;如果配对键不存在,说明配对的振幅为0,直接把原振幅按矩阵的第一列和第二列拆分成两个新键。整个过程和完整数组版本唯一的区别是:更新范围从“所有索引对”缩小到“当前非零振幅所涉及的索引对”。在稀疏模拟器里,这一条规则就是加速的核心逻辑。
密度矩阵模拟也有对应的推广。密度矩阵是2^n×2^n的矩阵,单量子门作用相当于在左侧左乘U、右侧左乘U†,或者写成p → U p U†。实现时不需要构造完整张量积,而是分别对“行索引”和“列索引”应用索引对更新法则,本质上是同一套配对规则的二维版本。
我个人在实际操作中的体会是:索引对更新法则看起来只是一个实现细节,但它把量子门作用这个抽象数学操作,落地成了非常具体的数据搬运逻辑。搞懂它以后,再去看各种开源模拟器的源码,会发现那些花里胡哨的优化,底层全是这套朴素配对规则。最后分享一个开发实用技巧:给模拟器加新增门时,先把所有配对索引打印到一个文件,和手工推演的配对表做diff,这比任何单元测试都更能防止你把索引规则改出一个“看起来对”的破洞。