拿 C++ 写量子计算模拟器,是我这几年做过最烧脑又最过瘾的项目。很多人觉得量子计算离普通开发者很远,但实际上,一台笔记本就足够模拟十几个量子比特的完整演化,而让这件事跑得动的技术栈,恰好是 C++ 的强项。这篇文章我会完整拆解一个“从零手写多比特量子态模拟器”的项目,讲清楚量子计算模拟里的核心数据结构、门操作原理、内存模型、性能优化,以及我在实际调试中踩过的坑。适合已有 C++ 基础、想用工程方式理解量子计算的朋友,也适合想练手现代 C++(容器、算法、模板、并发)的开发者。
1. 量子模拟的思路拆解:为什么偏偏是 C++
1.1 量子态的本质:一个复数数组就能描述
先别被“量子”两个字吓住。在量子计算模拟里,我们并不是真的在操控微观粒子,而是在用线性代数描述量子力学的状态。一个量子比特(qubit)的状态可以写成:
|ψ> = a|0> + b|1>其中 a 和 b 是复数振幅,|a|² 和 |b|² 分别表示测量到 0 和 1 的概率,两者加起来必须等于 1。这个状态用数学表示就是一个长度为 2 的复数向量[a, b]。多个量子比特呢?n 个 qubit 的状态是 2n个基态的叠加,所以用一个长度为 2n的复数向量就能完整描述。比如两个 qubit 的状态是:
|ψ> = c00|00> + c01|01> + c10|10> + c11|11>这就是量子计算模拟的基本思路:状态向量法。整个模拟过程,就是在反复做“控制大量复数的线性变换”。
这个“线性变换”本质上是矩阵乘向量。单比特门就是 2×2 酉矩阵,双比特门是 4×4 酉矩阵,整个电路相当于一个巨大的稀疏酉矩阵作用在态向量上。工程上能不能模拟得大、模拟得快,全看我们怎么组织这个向量、怎么快速完成矩阵乘法。
1.2 指数级增长的内存和计算量
状态向量法的瓶颈在于指数级膨胀。每增加一个量子比特,状态空间的维数翻倍。
| 量子比特数 n | 基态数量 2^n | std::complex 内存估算 |
|---|---|---|
| 10 | 1,024 | 约 16 KB |
| 16 | 65,536 | 约 1 MB |
| 20 | 1,048,576 | 约 16 MB |
| 24 | 16,777,216 | 约 256 MB |
| 30 | 1,073,741,824 | 约 16 GB |
上面是按每个复数 16 字节(double 实部 + double 虚部)估算的,实际加上 vector 开销和临时缓冲会更多。n=30 已经需要 16 GB 内存,单台普通机器基本跑不动;n=40 就需要 16 TB,这已经不是个人电脑能解决的问题。
所以“模拟量子计算”并不是万能的。但反过来看,n=20 到 n=24 这个区间,恰恰是 C++ 能很好发挥的地带。Python 虽然在 numpy 下也能做矩阵运算,但一旦涉及逐振幅操作、自定义门逻辑、内存复用,性能和灵活性都会打折扣。C++ 能提供精确控制:我可以直接管理连续内存、用 STL 容器表达数据结构、用 OpenMP 或 std::thread 并行化,还可以用模板把不同精度的复数类型做成通用代码。
1.3 方案选型:现代 C++ 而不是 C
我在最开始写这个项目时,一度想用纯 C 结构体加 malloc 来做,但很快就放弃了。原因不是 C 做不到,而是 C++ 在表达“量子门”“态向量”这类抽象时,安全和效率能同时保住。
std::vector<std::complex<double>>是一块连续内存,访问模式对缓存友好,同时自带 RAII,不用担心手动释放。std::array<Complex, 4>表示 2×2 门矩阵,紧凑且能在编译期确定大小。- 用
std::complex处理复数运算,读起来比手动造 complex struct 更直观。 - 后续做并行化时,OpenMP 对普通 C++ 循环的侵入很小。
最终我的项目采用了 C++17 标准,核心文件就三个:状态头文件、门操作头文件、主程序。没有引入任何第三方依赖,这让代码在任何支持 C++17 的环境里都能编译运行。
2. 核心数据结构与门操作的底层实现
2.1 态矢的存储与索引位序
最核心的数据结构只有一行:
#include <complex> #include <vector> using Complex = std::complex<double>; using QState = std::vector<Complex>;初始化 n 个 qubit 的态矢量,默认状态是所有振幅为 0,只有 |0...0> 的振幅为 1:
QState createState(std::size_t n) { QState state(1ULL << n, Complex(0.0, 0.0)); state[0] = Complex(1.0, 0.0); return state; }我采用“小端位序”的索引约定:n 个 qubit 编号为 0 到 n-1,态向量索引 index 的二进制位中,第 k 位表示第 k 个 qubit 的值。比如 n=2 时:
- index 0 二进制
00,对应 |0>|0> - index 1 二进制
01,对应第 0 个 qubit 为 1,状态 |0>|1> - index 2 二进制
10,对应第 1 个 qubit 为 1,状态 |1>|0> - index 3 二进制
11,对应 |1>|1>
为什么这个约定重要?因为对一个 qubit 做门操作时,我们要找所有“成对的索引”。第 k 个 qubit 状态从 0 变 1,反映在索引上就是相差1 << k。这个“步长”直接决定循环结构。
2.2 单比特门操作的高效循环
单比特门本质是 2×2 矩阵作用在两个振幅上。假设对第 k 个 qubit 作用矩阵:
[ g00 g01 ] [ g10 g11 ]对于索引i0(该位为 0)和i1 = i0 + (1 << k)(该位为 1),新的振幅是:
state[i0]' = g00 * state[i0] + g01 * state[i1] state[i1]' = g10 * state[i0] + g11 * state[i1]要遍历所有这样的配对,不能傻傻地检查每个索引,而是用分段跳跃:
void applySingleGate(QState& state, int qubit, const std::array<Complex, 4>& gate) { std::size_t n = state.size(); std::size_t stride = 1ULL << qubit; for (std::size_t base = 0; base < n; base += 2 * stride) { for (std::size_t offset = 0; offset < stride; ++offset) { std::size_t i0 = base + offset; std::size_t i1 = base + stride + offset; Complex a = state[i0]; Complex b = state[i1]; state[i0] = gate[0] * a + gate[1] * b; state[i1] = gate[2] * a + gate[3] * b; } } }这个双层循环的好处是:最内层连续访问state[i0]和state[i1],对 CPU 缓存相对友好。当 stride 很小(比如 qubit 0 或 1)时,内存访问高度局部化;当 stride 很大时,依然比逐位判断快很多。
有朋友会问,为什么不用 2^n × 2^n 的大矩阵整体相乘?因为整体矩阵绝大部分是稀疏阵,直接乘会浪费大量无效计算,内存也撑不住。上面这种“按 qubit 配对处理”才是状态向量模拟器的标准姿势。
实现 X、H 等常见门时,只需要传递正确的矩阵:
void applyX(QState& state, int qubit) { applySingleGate(state, qubit, {0, 1, 1, 0}); } void applyH(QState& state, int qubit) { const Complex h = Complex(1.0 / std::sqrt(2.0)); applySingleGate(state, qubit, {h, h, h, -h}); }如果想把一个门连续作用多次,比如 Ut,不要直接循环 t 次,而是可以用快速幂思想:预先算好矩阵的幂次,然后用类似二进制分解的方式决定哪些振幅对需要变换。这个和“快速幂算法”是同一套思路,在模拟某些带重复次数的电路(如 Grover 搜索中的翻转算子)时非常实用。
2.3 受控门与测量的实现
双比特门里最基础的是 CNOT 门:控制位为 1 时,翻转目标位。在态向量中,本质是交换两个振幅:
void applyCNOT(QState& state, int control, int target) { std::size_t n = state.size(); std::size_t controlBit = 1ULL << control; std::size_t targetBit = 1ULL << target; for (std::size_t i = 0; i < n; ++i) { if ((i & controlBit) && ((i & targetBit) == 0)) { std::size_t j = i | targetBit; std::swap(state[i], state[j]); } } }上面的循环会遍历所有振幅,用位判断找出需要交换的配对。对于 20 多个 qubit 来说,这个线性扫描已经足够快。如果还想优化,可以改成按 target 位的 stride 分段循环,并用 controlBit 提前判断哪一段需要交换,减少分支。
测量是整个模拟里最容易写错的部分。测量单个 qubit 时,先计算这个 qubit 分别为 0 和 1 的概率,然后生成随机数决定结果,最后把态矢量投影到对应子空间,并重新归一化:
double measureProb0(const QState& state, int qubit) { double p0 = 0.0; std::size_t bit = 1ULL << qubit; for (std::size_t i = 0; i < state.size(); ++i) { if ((i & bit) == 0) { p0 += std::norm(state[i]); } } return p0; } int measureBit(QState& state, int qubit, std::mt19937& rng) { double p0 = measureProb0(state, qubit); double p1 = 1.0 - p0; std::uniform_real_distribution<double> dist(0.0, 1.0); int outcome = (dist(rng) < p0) ? 0 : 1; double norm = outcome == 0 ? std::sqrt(p0) : std::sqrt(p1); std::size_t bit = 1ULL << qubit; for (auto& amp : state) { // 这里需要按索引重新处理,不能只靠引用 } // 推荐显式按索引循环 for (std::size_t i = 0; i < state.size(); ++i) { int bitVal = ((i & bit) == 0) ? 0 : 1; if (bitVal == outcome) { state[i] /= norm; } else { state[i] = 0.0; } } return outcome; }关键点在于:测量后必须把未选中的振幅全部清零,选中的振幅统一除以概率的平方根。很多人会忘记除以 norm,导致后续态的概率和不再是 1。还有一点,测量后再对这个 qubit 做操作就不应该产生原来的干涉效果了,所以清零非常重要。
3. 实操:手写一个 Bell 态模拟器
3.1 工程结构和环境配置
我用的是最朴素的工程结构:
quantum_sim/ ├── quantum.hpp // 状态定义和门操作 ├── main.cpp // 示例电路 └── CMakeLists.txt // 构建配置如果你在 VSCode 里写 C++,需要确保 C++ 编译器和 C++17 标准正确配置。我常用的 CMakeLists.txt 是这样:
cmake_minimum_required(VERSION 3.16) project(QuantumSim CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(quantum_sim main.cpp)VSCode 里配置 includePath 时,不需要额外引入第三方目录,标准库路径由编译器插件自动处理。如果遇到“找不到 ”的报错,大概率是编译器没选对,或者在 tasks.json 里漏了-std=c++17。这类问题占了新手排查时间的六成以上。
3.2 核心代码实战
我把门操作和主程序放在一起,方便你直接跑通。下面是完整可运行的 main.cpp:
#include <bits/stdc++.h> using namespace std; using Complex = complex<double>; using QState = vector<Complex>; void applySingleGate(QState& state, int qubit, const array<Complex, 4>& gate) { size_t n = state.size(); size_t stride = 1ULL << qubit; for (size_t base = 0; base < n; base += 2 * stride) { for (size_t offset = 0; offset < stride; ++offset) { size_t i0 = base + offset; size_t i1 = base + stride + offset; Complex a = state[i0]; Complex b = state[i1]; state[i0] = gate[0] * a + gate[1] * b; state[i1] = gate[2] * a + gate[3] * b; } } } void applyX(QState& state, int qubit) { applySingleGate(state, qubit, {0, 1, 1, 0}); } void applyH(QState& state, int qubit) { const Complex h = Complex(1.0 / sqrt(2.0)); applySingleGate(state, qubit, {h, h, h, -h}); } void applyCNOT(QState& state, int control, int target) { size_t n = state.size(); size_t controlBit = 1ULL << control; size_t targetBit = 1ULL << target; for (size_t i = 0; i < n; ++i) { if ((i & controlBit) && ((i & targetBit) == 0)) { size_t j = i | targetBit; swap(state[i], state[j]); } } } string binaryString(size_t index, int n) { string s(n, '0'); for (int k = 0; k < n; ++k) { if ((index >> k) & 1) s[n - 1 - k] = '1'; } return s; } void printState(const QState& state, int n) { for (size_t i = 0; i < state.size(); ++i) { double prob = norm(state[i]); if (prob > 1e-12) { cout << "|" << binaryString(i, n) << "> amplitude = " << state[i] << ", p = " << prob << "\n"; } } } int main() { int n = 2; QState state = createState(n); state[0] = Complex(1.0, 0.0); cout << "初始态:\n"; printState(state, n); applyH(state, 0); applyCNOT(state, 0, 1); cout << "\nBell态:\n"; printState(state, n); return 0; }注意createState函数我在上面给过定义,实际放在quantum.hpp里,或者直接在 main 上方补上:
QState createState(size_t n) { QState state(1ULL << n, Complex(0.0, 0.0)); state[0] = Complex(1.0, 0.0); return state; }跑完的输出应该是:
初始态: |00> amplitude = (1,0), p = 1 Bell态: |00> amplitude = (0.707107,0), p = 0.5 |11> amplitude = (0.707107,0), p = 0.5|10> 和 |01> 的概率都为 0,这是标准的 |Φ+> 贝尔态。看到这个输出,说明 H 门和 CNOT 门都工作正常,态矢量法核心逻辑跑通了。
3.3 验证:手动算一遍 Bell 态的生成过程
用数学验证代码,是最靠谱的调试方法。初始 |00>:
- 对 qubit 0 做 H 门:|00> 变为 (|00> + |10>) / √2。注意这里索引规则是第 0 位 qubit,所以第二个基态是 |10> 而不是 |01>。
- 再做 CNOT,控制 qubit 0,目标 qubit 1:|10> 变为 |11>。
- 最终就是 (|00> + |11>) / √2。
如果输出多出 |01> 项,说明你的位序约定和门函数对不上。我自己在这上面栽过不止一次,后面 4.1 会详细讲。
3.4 性能调优:给模拟器加上并行
当 n 达到 24 个 qubit 时,每一次单比特门要处理 800 多万个振幅(实际上因为是成对操作,循环次数是 2^(n-1))。如果不开优化,速度确实感人。我做的第一件优化是使用 OpenMP 并行化外层循环:
void applySingleGateOpenMP(QState& state, int qubit, const std::array<Complex, 4>& gate) { size_t n = state.size(); size_t stride = 1ULL << qubit; #pragma omp parallel for for (long long base = 0; base < (long long)n; base += 2 * stride) { for (size_t offset = 0; offset < stride; ++offset) { size_t i0 = base + offset; size_t i1 = base + stride + offset; Complex a = state[i0]; Complex b = state[i1]; state[i0] = gate[0] * a + gate[1] * b; state[i1] = gate[2] * a + gate[3] * b; } } }这里要注意,base已经改成了long long,避免 OpenMP 对无符号类型的不爽。每个 base 处理的振幅对互不重叠,所以并行没有数据竞争。
另外两个重要优化点:
- 编译时开
-O2或-O3,再考虑-march=native,让编译器生成 SIMD 向量化指令。 - 避免在循环内部创建临时 complex 对象。上面代码用局部变量 a、b 缓存旧值,再写回,这个习惯很关键。如果直接写
state[i0] = gate[0]*state[i0] + gate[1]*state[i1],读改写顺序在某些编译器下也能优化,但显式缓存更稳。
实际测试下来,24 qubit 的 Hadamard 门,单线程加 O3 大约需要几百毫秒;开 8 线程后可能降到几十毫秒。不过也别指望线性加速,因为整个循环卡在内存带宽上的比例很高。
4. 排坑实录:我在这条路上踩过的雷
4.1 位序和 stride:最隐蔽的 bug 来源
我第一次写完applySingleGate后,测试单比特 X 门,结果怎么都不对。后来发现我在初始化时把第 0 个 qubit 放在二进制高位,而applySingleGate用的是低位步长。两种约定混用,导致所有门都作用错了对象。
解决办法:在一开始就统一约定,并且在你自己的文档里写清楚。比如我固定使用“第 k 个 qubit 对应索引的第 k 位”。这样:
1ULL << k就是第 k 位的掩码;1ULL << k也是操作的最低步长;- 控制位判断直接用
(index & controlBit)。
另一个经典坑是stride循环边界。外层循环base += 2 * stride,漏乘 2 会导致同一组振幅被处理两次,甚至越界。我刚写时少写乘 2,结果 Bell 态概率变成了 1.25,一查就是这个原因。
建议你像我一样,写一个验证函数:对已知状态的 X 门做单元测试。比如 n=2 时,初始 |01>(索引 1)作用于第 0 位 X 门,应该得到 |00>(索引 0);作用于第 1 位 X 门,应该得到 |11>(索引 3)。这类固定样本测试能快速定位位序错误。
4.2 浮点精度和归一化:别等到概率错才后悔
量子模拟中,浮点是双刃剑。门矩阵里的1/sqrt(2)是无理数,double 只能近似;多步门操作后,所有振幅的模平方和可能从 1 漂移到 1.0000000001 或 0.9999999999。在只打印概率的情况下,这点误差肉眼看不出来,但一旦做测量判断,p0 + p1可能略大于 1,导致随机分支的边界出错。
我的习惯是:在测量函数里先归一化再算概率,或者至少使用clamp(p0, 0.0, 1.0)保证随机数落在合理区间。另外,如果在模拟里用了std::conj这种操作,要注意复数的共轭和复数乘法顺序,C = a * b和C = b * a在std::complex里结果一样,但如果自己实现复数乘法就要小心。
还有一种数值问题是全局相位。量子力学里,整体乘以一个单位复数 e^{iθ} 不影响任何测量概率,但会影响干涉结果。所以有两个层面的注意:如果你只关心概率,全局相位可忽略;但当你把两个量子线路组合在一起做相位对比时,必须保留相位。模拟器里我会保留所有复振幅,打印时才只看概率。
4.3 内存爆炸:从 30 个 qubit 开始失控
很多朋友问我怎么模拟 30+ qubit。实话说,状态向量法在普通电脑上 30 qubit 就到 16 GB 了,加上系统和其他程序,很容易触发std::bad_alloc。我自己试过在 16 GB 内存的机器上跑 30 qubit,malloc 直接失败,程序崩溃。
建议的做法:
- 估算时不只算 2^n × sizeof(Complex),还要算临时分配和 OpenMP 各线程的私有栈。实际峰值内存可能比理论高 20% 左右。
- 用
try { QState state(...); } catch (const std::bad_alloc& e) { ... }做保护,而不是任由程序崩溃。 - 如果一定要模拟更多 qubit,可以考虑以下方案:
- 用
std::complex<float>减少一半内存,但精度明显下降; - 使用稀疏状态表示,只保留非零振幅。很多实际电路(比如只做少量 CNOT)的振幅稀疏度很高,但通用模拟器不行;
- 上 GPU,用 CUDA 把振幅计算搬到显存里。这里推荐去看 CUDA C++ 编程指南,里面关于内存布局和线程映射的内容,和量子模拟的并行访问模式非常契合。
- 用
4.4 性能排查:其实卡在内存带宽,不是 CPU
我一开始天真地以为模拟慢是 CPU 乘加不够快,于是疯狂优化复数乘法,甚至手写 SIMD 内联汇编。后来用perf stat一看,内存带宽有效利用率极高,CPU 的算力根本没有吃满。原因很简单:每做一次单比特门,需要读取两个复数、写回两个复数,运算量只有 6 次复数乘法,读改写比例接近 1:1,内存系统才是瓶颈。
所以后来的优化重点变成:
- 减少数据拷贝,避免把整个 state
const传递再复制; - 把多个连续 qubit 的门合并成一个大门的等效矩阵,减少遍历次数;
- 尽量保证 stride 较小的门放在并行度好的循环段;
- 如果迭代很多电路层,尽量用指针引用操作,避免不必要的 shared_ptr 或 value 传递。
这个认知扭转很重要。如果你遇到“为什么我 20 qubit 跑起来还是慢”,先看是不是内存分配和拷贝太多,而不是一门心思堆 CPU 时钟。
5. 扩展方向:继续让模拟器变强大
5.1 增加通用门与自定义电路解析
目前的模拟器只实现了 H、X、CNOT,但它们不是完备的“通用门集”。实用量子算法还需要相位门 S、T 门、任意旋转门 Rx/Ry/Rz。实现思路也一样,都是把门的 2×2 矩阵塞进applySingleGate。
更通用的是实现一个函数:
void applyUnitary(QState& state, const vector<int>& qubits, const vector<vector<Complex>>& matrix);这个函数接受一个作用于 m 个 qubit 的 2m× 2m酉矩阵,然后用“振幅对遍历”或“矩阵分块”处理。虽然比固定门慢,但方便你快速验证算法。我后来就基于它接了一个简单文本解析器,把量子线路描述成字符串,例如:
H 0 CNOT 0 1 MEASURE 0这样就能自动构造电路。如果你想加分,还可以用 RAII 或对象池管理大块振幅内存,避免在多次模拟之间反复 malloc。
5.2 稀疏表示与更高阶模拟
做量子算法时,很多线路不会立刻让所有振幅都非零,尤其是浅电路。稀疏表示用哈希表或有序 map 只存储非零振幅。比如 n=20 的浅层电路,可能只有几千个非零振幅,内存和性能都能大幅提升。代价是实现受控门时需要频繁处理“插入零振幅后再变换”的边界,代码复杂度高不少。
我的建议是:先把稠密状态向量模拟器做扎实,理解清楚振幅对之间的关系,再上稀疏版本。否则你很可能在“map 遍历顺序”“插入导致迭代器失效”这些问题上消耗大量精力。
5.3 结合 CUDA 做 GPU 并行
当你想冲击 30 qubit 以上,CPU 已经不太现实。CUDA 的线程模型非常契合态向量模拟:把每个基态振幅分给不同线程,单比特门让相邻两个线程协作。不过这需要重新设计内存布局,比如保证 bank conflict 少、让共享内存存门矩阵。这块如果感兴趣,可以先从 16 qubit 的 CUDA 版本开始,体会一下和 CPU 版本的差异。C++ 和 CUDA 的语法体系是兼容的,迁移比 Python 要顺滑很多。
我自己跑到最后,最大的体会是:写量子计算模拟器,表面上是写线性代数和 C++ 循环,实际上是在不断训练一种“从量子比特寄存器视角看问题”的思维方式。每一次门操作、每一次测量,我都得想清楚索引怎么映射、振幅怎么变换。这种训练对理解量子算法本身帮助很大。
如果你想把这个项目继续扩展,我建议下一个目标不是堆功能和 qubit 数,而是把现有模拟器重构得更朴素、更可读。把门矩阵统一成类型、把测量结果封装成统计对象、把打印和可视化拆出去。等基本功扎实了,再碰 GPU 和分布式,会顺手很多。