☰
用C语言模拟三维海森堡模型:蒙特卡洛方法与铁磁相变
2026/10/19 3:09:45 网站建设 项目流程

简介:基于C语言的经典各向同性三维海森堡模型蒙特卡罗模拟项目,完整呈现随机自旋系统的数值研究方法,面向计算物理与统计物理方向的学生、研究者以及需要完成课程设计或顶点项目的人员。压缩包共42个文件,大小约2.47MB,包括11个C源码文件、8个PDF论文与报告、2个TeX排版源文件,以及15个JPG、3个PNG、1个GIF等图片表格,另有README说明与许可证文件。项目源自南康涅狄格州立大学PHY 471高级实验室顶点工作,从模型构建、蒙特卡罗算法实现,到常微分方程数值解与热力学量统计输出,形成完整流程;内容涉及统计力学、磁学与固态物理交叉主题,读者可结合平均能量、平均磁化强度和晶格构型等图表结果,对照C源代码复原模拟逻辑与相变行为。已有599人学习下载,适合作为海森堡模型模拟入门范例,也可用于课程设计、代码复现、结果对比和论文撰写参考。

1. 经典海森堡模型模拟:用C语言重现三维铁磁相变

研究磁性材料的人都有一个直觉:温度一升高,磁铁会渐渐失去磁性。但“渐渐”背后隐藏了一个临界点,也就是居里温度。要在这个临界点附近看清楚自旋的涨落,最直接的办法就是跑一个经典海森堡模型。这个“3d.heisenberg.model”项目就是用C语言实现的经典、各向同性、三维海森堡模型模拟器。它通过Metropolis蒙特卡洛方法,计算不同温度下的能量、磁化强度、比热和磁化率,输出数据后用脚本画图就能看清铁磁到顺磁的相变过程。它不需要第三方依赖库,编译和运行非常简单,适合作为计算物理的课程项目,也适合快速验证磁性系统的想法。下面从程序结构、核心代码到踩坑记录,完整拆一遍。

2. 技术选型与目录设计:从MC算法到数据输出的整体结构

2.1 为什么用C语言写格点模拟

在计算物理里,格点自旋模拟的代码风格往往直接取决于性能要求。三维经典海森堡模型的格点数量N=L³,以L=32计算就是32768个自旋。每个Metropolis更新需要做6次邻居点积,每个温度点少则几万、多则几十万个sweep。在这种计算密度下,C语言编译出的指令序列和内存访问模式是Python难以企及的。C的优势除了速度,还有对随机数状态、浮点精度和内存布局的完全控制。这也是这个项目选择C语言的根本原因。

模型本身很简单:哈密顿量 H = -J Σ⟨i,j⟩ Sᵢ·Sⱼ,Sᵢ是三维单位向量。各向同性意味着三个方向的交换耦合完全一致,所以只有一个参数J。把J设为1后,温度以约化单位表示,临界温度在简单立方格点系上大约在1.44附近。这个项目所有代码都围绕这个哈密顿量转,没有外场,没有次近邻,干净到可以当作模板。

2.2 目录结构:一段可维护的分层代码

模拟程序虽然不大,但如果不分层,随着功能增加会越来越难改。这个项目的目录设计很典型:

lattice.h # 格点索引、自旋定义、邻居表 montecarlo.c # Metropolis更新、扫描、热化 observables.c # 能量、磁化强度、比热统计 main.c # 命令行参数、温度循环、数据输出 Makefile # 编译配置 data/ # 输出结果目录

lattice.h是“结构层”,只管自旋和邻居;montecarlo.c是“动力学层”,只关心如何更新自旋;observables.c是“测量层”,从构型提取物理量;main.c是“控制层”,把所有东西串起来。这个分层不复杂但很实用。你改各向异性模型时,只需动montecarlo.c中的能量差;想加外场,加一个参数即可;想换晶格类型,重写lattice.h。如果全部揉在main.c里,任何一个改动都会牵扯到不相干的代码,后期维护非常痛苦。

用Makefile编译时,建议加上-Wall和-O3。我一般还会加-g方便调试,但生产计算时去掉-g。这里Makefile可以写成:

CC = cc CFLAGS = -O3 -Wall heisenberg: main.o montecarlo.o observables.o $(CC) -o $@ $^

注意,如果要用OpenMP扩展,需要加-fopenmp,后面会讲到。

2.3 数据流:温度循环内外的三个动作

程序从命令行接收参数:L(边长)、温度下界、温度上界、温度步长、热化步数、采样步数。main函数对每个温度重复三个动作:热化、采样、输出。采样过程中每sweep后测量一次,计算累计量。最后输出“温度 平均能量 平均磁化强度 比热 磁化率”这样一行,同时也可以输出接受率。

这里有一个重要选择:每个温度点是从随机初始构型重新开始,还是沿用上一个温度结束时的构型。这个项目如果为了省事,标准做法是从随机初始构型重新开始。但这样在临界温度附近热化时间会很长,因为随机初始态在高关联体系里需要更多步才能平衡。更聪明的做法是降温扫描:从高温端开始,每个温度用上一个温度的末构型作为初态,这样热化时间大幅缩短。这个技巧在后面的避坑部分还会细说。

2.4 随机数源的选择是一种玄学

通用C语言的标准库rand()在一些平台上周期只有2³¹左右,对长期蒙特卡洛模拟来说,很可能导致可观察的相关性。这个项目如果直接用rand(),是一个潜在坑。我一般会自己实现一个PCG或者嵌入一个梅森旋转,只暴露random_double()接口。这样在换取长周期的同时,也方便以后用OpenMP时为每个线程维护独立状态。随机数源的好坏在短跑里看不出差别,但跑到几百万步之后,差的生成器会让磁化强度出现奇怪的周期涨落——这不是物理,是伪随机数的伪周期。判断方法很简单:固定种子跑两次,每次生成序列前100个值,肉眼确认没有明显规律;更严格的话可以跑一次自相关测试。

2.5 数据格式的兼容性也是设计点

输出是纯文本,一行一个温度点,字段用空格分隔。这种格式的好处是,任何工具都能读:awk一行就能求平均,gnuplot直接生成曲线,Python的numpy.loadtxt几行搞定。程序不产生二进制文件,调试时方便直接看尾部。数据文件通常不大,L=32跑100个温度点也就几万行,不用考虑压缩。配合一个plot.py脚本,从data.csv读取列,画两幅图:磁化强度随温度变化和比热随温度变化。比热峰的位置就是估计的临界温度。程序本身不做图,是一个正确的决定:绘图需求因人而异,散点图、误差棒切换也不该重编译C程序。

3. 核心模块拆解:晶格初始化、能量计算与Metropolis采样

3.1 格点表示与周期性边界

自旋是一个三维单位向量,常用三个double存。整个系统是一个长度为3N的数组。为了快速取到某个自旋,按一维索引存储:spins[3i]是第i个格点的x分量,3i+1是y,3*i+2是z。

周期边界条件通过索引函数实现,也可以预存邻居表。我倾向于在结构初始化时构建邻居表,因为邻居表在每次更新中会被访问6次,提前计算可以避免每个循环都做一次带取模的三坐标映射。邻居表初始化代码通常长这样:

for (int i = 0; i < L; ++i) for (int j = 0; j < L; ++j) for (int k = 0; k < L; ++k) { int site = idx(i, j, k, L); neighbors[6*site + 0] = idx(i+1, j, k, L); neighbors[6*site + 1] = idx(i-1, j, k, L); neighbors[6*site + 2] = idx(i, j+1, k, L); neighbors[6*site + 3] = idx(i, j-1, k, L); neighbors[6*site + 4] = idx(i, j, k+1, L); neighbors[6*site + 5] = idx(i, j, k-1, L); }

邻居表的顺序固定为+x、-x、+y、-y、+z、-z,方便后续计算能量时只取一半来避免重复计数。周期性边界条件的核心就在idx函数里:

static inline int idx(int x, int y, int z, int L) { x = (x + L) % L; y = (y + L) % L; z = (z + L) % L; return (x * L + y) * L + z; }

这里的取模是针对负数设计的,如果直接写x%L,在C语言里会得到负余数,那索引就飞到数组外面去了。第一次写这个函数时翻车的人不在少数。

3.2 局部能量差公式与实现

局部能量差是Metropolis算法的核心。当某个自旋从S_old变为S_new时,能量差:

ΔE = -J * (S_new - S_old) · Σ_neighbors S_neighbor

注意这里不需要算所有键,只要与当前格点相关的6条键。在代码中先计算邻居自旋的矢量和sum_neigh,再点乘新方向与旧方向的差。

static double energy_diff(int site, const double *new_dir, const double *spins, const int *neighbors, double J) { int nn[6]; for (int d = 0; d < 6; d++) nn[d] = neighbors[6*site + d]; double sx = spins[3*site], sy = spins[3*site+1], sz = spins[3*site+2]; double sumx = 0, sumy = 0, sumz = 0; for (int d = 0; d < 6; d++) { sumx += spins[3*nn[d]]; sumy += spins[3*nn[d] + 1]; sumz += spins[3*nn[d] + 2]; } return -J * ((new_dir[0] - sx) * sumx + (new_dir[1] - sy) * sumy + (new_dir[2] - sz) * sumz); }

这个函数使用静态内联或宏会更高效。参数site是当前格点编号,new_dir是提议方向,spins是全局自旋数组,neighbors是邻居表,J是交换积分。返回值单位与J一致。

这里有个细节:sum_neigh中如果某个邻居恰好是之前更新过的,那是正常Metropolis动力学的一部分,系统最终依然收敛到平衡态,只是动态路径不同。

3.3 均匀球面随机方向生成

每个Metropolis更新都需要产生新方向。经典海森堡模型是连续自旋,提议分布必须满足对称性,也就是说从方向A到B的概率要等于从B到A。最简单的对称提议就是“在单位球面上均匀随机取一个方向”。

生成均匀随机单位向量的一个经典方法是Marsaglia拒绝采样。原理是先采样单位圆内均匀分布的点(x,y),再通过变换映射到球面。代码补充如下:

void random_unit(double v[3], unsigned long long *state) { double x, y, s; do { x = 2.0 * random_double(state) - 1.0; y = 2.0 * random_double(state) - 1.0; s = x * x + y * y; } while (s >= 1.0 || s == 0.0); double t = 2.0 * sqrt(1.0 - s); v[0] = t * x; v[1] = t * y; v[2] = 1.0 - 2.0 * s; }

这个函数每个方向平均消耗约2.5个随机数,没有三角函数和正态分布,非常适合Metropolis循环。注意拒绝条件中的s==0.0是为了避免除零风险。如果你觉得这个生成器不够随机,也可以在映射前后加入扰动,但实际使用中Marsaglia已经足够。

3.4 完整扫描与测量节奏

一个“sweep”定义为对整个格点做N次随机格点选择并执行更新。为什么是随机选择而不是按序?因为随机顺序能减少磁化取向在空间中传播的方向性。但随机选择也有缺点:一个sweep中某些格点可能被重复选中,某些一直没选到。这是允许的,马尔可夫链仍然不可约且非周期。如果要严格保证每个格点每sweep至少访问一次,可以生成一个随机排列后按序更新,但那会引入额外开销。

测量节奏上,最简单的是每sweep测一次。如果采样步数设为10万,会得到10万个能量和磁矩样本。但相邻sweep的样本强烈自关联,独立样本数远小于10万。误差分析时如果直接把10万个当独立样本,会把误差低估。改进方法是每间隔10个sweep测一次,或者在后期用自关联时间修正。如果这个项目不需要严谨误差棒,每sweep测一次就够。

4. 关键代码解析:一条自旋更新的完整生命周期

4.1 初始化自旋构型

初始化可以有两种方案:随机态或铁磁态。随机态直接用random_unit对每个格点赋值,铁磁态则给所有spins赋为(0,0,1)。如果是研究相变,通常建议高温模拟用随机态,低温模拟用铁磁态。但无论从哪个态开始,热化足够长应该达到同样的平衡。在临界温度附近,系统关联长度很大,从不同初始态出发,即使热化相同步数,也可能观察到稍微不同的统计量,这不是代码bug,而是临界慢化导致的采样不足。解决方法是多跑几次取平均。

初始化代码很简单:

for (int i = 0; i < N; i++) { random_unit(&spins[3*i], &rng); }

4.2 邻居表初始化与内存分配

在main函数开头,分配spins和neighbors:

double *spins = malloc(3 * N * sizeof(double)); int *neighbors = malloc(6 * N * sizeof(int)); init_neighbors(neighbors, L, idx);

注意检查malloc返回NULL,虽然小模拟很少失败,但养成习惯是好的。邻居表只需要初始化一次,因为格点拓扑不随温度变化。如果你真想极端优化,还可以把邻居表改成short类型来省内存,但实际收益很小。

4.3 完整的Metropolis单步实现

前面已经展示了energy_diff和random_unit,把它们拼接到一个mc_update中:

void mc_update(double *spins, int *neighbors, int L, double beta, double J, unsigned long long *rng) { int N = L * L * L; int site = (int)(random_double(rng) * N); double newdir[3]; random_unit(newdir, rng); double delta = energy_diff(site, newdir, spins, neighbors, J); if (delta <= 0.0 || exp(-beta * delta) > random_double(rng)) { spins[3*site + 0] = newdir[0]; spins[3*site + 1] = newdir[1]; spins[3*site + 2] = newdir[2]; } }

这里每次更新消耗的随机数数量不定:random_unit内部可能拒绝多次。但没关系,只要随机数生成器状态在调用之间正确推进即可。需要注意,不要在一次更新里多次调用random_double来获取同一个随机数,否则接受概率会被算错。

4.4 扫描与采样骨架

整个温度循环的骨架可以写成这样,配合真实代码:

for (int t = 0; t < n_thermal; t++) { mc_sweep(spins, neighbors, L, beta, J, &rng); } for (int s = 0; s < n_sample; s++) { mc_sweep(spins, neighbors, L, beta, J, &rng); measure(spins, neighbors, L, J, &E, &M); E2 += E*E; E_sum += E; M2 += M*M; M_sum += M; } double E_avg = E_sum / n_sample; double M_avg = M_sum / n_sample; double Cv = (E2/n_sample - E_avg*E_avg) * beta * beta; double chi = (M2/n_sample - M_avg*M_avg) * N * beta; printf("%.6f %.6f %.6f %.6f %.6f\n", T, E_avg, M_avg, Cv, chi);

逻辑说明:Cv是总热容,使用能量涨落公式。这里的E是总能量,不是每自旋能量,所以公式里的beta²直接乘以涨落方差。磁化强度M是已经归一化的每自旋磁矩,要得到磁化率χ,需要乘以N和beta。如果你把E和M都按每自旋平均,那Cv和χ的系数要相应调整,这是最容易算错的地方。

4.5 输出与性能基线

每个温度点结束后,把一行数据追加到data/result.dat。整段跑完,可以用time命令测量运行时长。性能瓶颈通常集中在random_unit和energy_diff。一个简单的性能基线:L=32,1000个sweep,用-O3编译,在常见桌面CPU上应该几秒钟内完成。如果这个项目跑得很慢,先检查是不是编译器没有开优化,或者把double累加换成了long double,那会拖慢很多。

5. 运行避坑指南:编译参数、边界条件与热化时间那些事

5.1 编译优化没开,速度慢到怀疑人生

现象:相同的L和步数,跑出来时间是别人的几十倍。原因:用gcc默认的-00编译,或者Makefile里没有写CFLAGS。解决:Makefile里至少写CFLAGS = -O3 -Wall。如果使用gcc,可以加-march=native让编译器用CPU特定指令。但注意不要开-ffast-math,否则涉及能量差和exp的精度可能受损,结果出现奇怪的尖刺。

5.2 周期性边界条件的索引翻车

现象:L=4时磁化强度在低温下接近1,但能量比预期高,或者自旋出现“边缘”结构。原因:idx函数对负数的取模错误。C语言里(-3)%4结果是-3,只有(-3+4)%4才是1。解决:写一个debug函数,打印idx(i,j,k)的值,并检查i取-1时是否映射到L-1。我习惯用L=4跑一遍,把所有邻居索引和期待值对比,能秒杀大多数索引错误。

5.3 热化不足出现假相变

现象:同一温度升降两个方向的磁化强度曲线不重合,甚至在T=3时还有明显磁矩。原因:从随机态开始,只热化了100步,系统还停留在初始高能无序态。解决:将热化步数从100提到5000以上,尤其在温度接近临界值附近。更稳的办法是“高温热化再降温”:先在T=5跑500步,然后用末构型作为目标温度的起点,这样能大幅加速平衡。

5.4 随机数种子固定导致结果完全可复现但无法统计

现象:每次运行结果一模一样,误差棒全是零。原因:main里用固定种子初始化rng,没有提供参数去改变。解决:增加一个命令行参数seed,默认用time(NULL)决定;每次运行独立种子,把输出文件和种子写在一起。这样既能复现某个run,也能计算多run之间的误差。如果担心种子重复,可以用系统熵源。

5.5 提议方向生成不均匀导致磁矩虚高

现象:自旋更新后系统倾向于沿z轴排列,磁性有虚高。原因:random_unit实现错误,比如用x,y,z三个独立均匀随机数然后归一化,得到的不是球面均匀分布,而是在对角线聚集。解决:使用Marsaglia方法,并做一次数值测试:生成10万方向,统计cosθ分布应该是均匀的。如果cosθ集中在±1,说明生成器有问题。

6. 二次开发思路:改成各向异性、加外场、并行加速的实践路径

6.1 加外磁场:塞曼项的实现

原代码的哈密顿量只有交换相互作用,加上外场后变成 H = -J Σ Sᵢ·Sⱼ - h·Σ Sᵢ。在能量差函数中,需要额外项 -h·(S_new - S_old)。修改很简单,在energy_diff的return语句中加:

return -J * (...) - hx*(new_dir[0]-sx) - hy*(new_dir[1]-sy) - hz*(new_dir[2]-sz);

然后main里传入h向量。你要扫磁滞回线的话,只要固定低温,扫描h从正到负再回到正。注意外场会打破各向同性,但周期边界条件不受影响。

6.2 改成各向异性:交换张量与单轴各向异性

各向异性可以体现在J有方向差异,例如Jx≠Jy≠Jz。哈密顿量就变成三项分别乘Jx,Jy,Jz。例如:

double dx = (new_dir[0]-sx) * Jx * sumx; double dy = (new_dir[1]-sy) * Jy * sumy; double dz = (new_dir[2]-sz) * Jz * sumz; return -(dx+dy+dz);

如果进一步加单轴各向异性项 DS_z²,能量差里加 D(new_z² - old_z²) 即可。生成提议方向还是球面均匀,但接受率会因为D而调整。这些改动都不影响邻居表和扫描循环,扩展起来非常顺手。

6.3 用OpenMP并行温度点

不同温度点的模拟完全独立,这是最天然的并行维度。用OpenMP在温度循环上加#pragma omp parallel for,每个线程内部维护各自的spins和rng状态。注意不要共享rng,否则数据竞争会导致结果错误。一个简单写法:

#pragma omp parallel for schedule(dynamic) for (int i = 0; i < nT; i++) { double T = Tmin + i * dT; double *local_spins = malloc(3 * N * sizeof(double)); init_rng(seed + i * 1000); simulate_one(T, local_spins, ...); free(local_spins); }

每个线程输出到不同文件或者用critical段聚合写入。并行度在多个温度点时几乎线性扩展。

6.4 一个验证改造是否正确的清单

每次改造后,先跑小尺寸L=4、L=8各一个温度点,和已知结果对比:无外场时高温磁化强度应该在0附近随机涨落,低温铁磁态M接近1,低温能量接近-3J(每个键贡献-J,平均每自旋键数3)。比热峰对应临界温度,可以用已知Tc检查。如果加了外场,T→0极限下自旋应完全沿外场方向。

第一次写这类模拟的时候,我直接在main.c里改来改去,后来想回退某个改动只能靠git reset,非常狼狈。从那以后,每次扩展功能都会先在纸上列出改动涉及的文件,照着文件清单动手,跑完验证清单再提交。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询