这篇文章不准备比谁更会把代码挤成一团。
如果允许
a=...;b=...;c=...;d=...;
这样无限往一行里塞语句,那么任何 MATLAB 程序理论上都可以写成“一行”,行数也就失去了意义。
所以先把规则说清楚。
解一个二维有限元问题:
取精确解
因此右端项为
挑战代码需要同时完成下面这些事情:
.m 文件复 制后即可运行。下面这段直接保存为 fem24.m 即可运行。
n=30;
[X,Y]=meshgrid(linspace(0,1,n+1));
p=[X(:),Y(:)];
t=delaunay(p(:,1),p(:,2));
N=size(p,1);
x=p(:,1);
y=p(:,2);
xt=x(t);
yt=y(t);
A=abs((xt(:,2)-xt(:,1)).*(yt(:,3)-yt(:,1))-(xt(:,3)-xt(:,1)).*(yt(:,2)-yt(:,1)))/2;
b=[yt(:,2)-yt(:,3),yt(:,3)-yt(:,1),yt(:,1)-yt(:,2)];
c=[xt(:,3)-xt(:,2),xt(:,1)-xt(:,3),xt(:,2)-xt(:,1)];
I=kron(t,ones(1,3));
J=repmat(t,1,3);
V=(kron(b,ones(1,3)).*repmat(b,1,3)+kron(c,ones(1,3)).*repmat(c,1,3))./(4*A);
K=sparse(I(:),J(:),V(:),N,N);
q=(p(t(:,1),:)+p(t(:,2),:)+p(t(:,3),:))/3;
f=2*pi^2*sin(pi*q(:,1)).*sin(pi*q(:,2));
F=accumarray(t(:),repmat(A.*f/3,3,1),[N,1]);
free=find(x>0 & x<1 & y>0 & y<1);
u=zeros(N,1);
u(free)=K(free,free)\F(free);
trisurf(t,x,y,u,'EdgeColor','none');
title(sprintf('max nodal error = %.3e',norm(u-sin(pi*x).*sin(pi*y),inf)));
24 行里,包含了每个个有限元最基本的环节。
当然,这不是工程代码。它更像是把一个有限元程序剥到只剩骨架,然后看看那些我们平时习惯写成几十个函数、几百行代码的东西,究竟哪些才是不可删除的核心。
有限元离散从弱形式开始。
对于任意 ,Poisson 方程写成
取三角剖分 ,令
离散问题就是求
最后得到熟悉的线性系统
24 行代码核心要做的事情,无非就是把这里的
[X,Y]=meshgrid(linspace(0,1,n+1));
p=[X(:),Y(:)];
t=delaunay(p(:,1),p(:,2));
有限元网格最核心的数据结构只有两样:
p:节点坐标;t:每个三角形由哪三个节点组成。如果 t(k,:)=[17,18,49],它的意思就是第
后面所有局部到全局的装配,本质上都在使用这个映射。
这一点很值得品味。
有限元程序看起来有形函数、积分、矩阵、边界条件一大堆概念,但真正进入计算机以后,拓扑关系最终就是一个整数矩阵 t。
设一个三角形
定义
以及
对线性三角形元,三个局部基函数的梯度在单元内部是常数,并且
所以局部刚度矩阵直接就是
代码里的
A=abs((xt(:,2)-xt(:,1)).*(yt(:,3)-yt(:,1))-(xt(:,3)-xt(:,1)).*(yt(:,2)-yt(:,1)))/2;
b=[yt(:,2)-yt(:,3),yt(:,3)-yt(:,1),yt(:,1)-yt(:,2)];
c=[xt(:,3)-xt(:,2),xt(:,1)-xt(:,3),xt(:,2)-xt(:,1)];
就是把所有三角形的
这里没有显式写出任何形函数。不是因为形函数被忽略了,而是因为对于 b 和 c。
❝短代码最有意思的地方,不是少敲几个字符,而是找到一个数据表达,使数学结构自动包含在数组里。
整段代码里,最重要的应该是下面三行:
I=kron(t,ones(1,3));
J=repmat(t,1,3);
V=(kron(b,ones(1,3)).*repmat(b,1,3)+kron(c,ones(1,3)).*repmat(c,1,3))./(4*A);
随后一句
K=sparse(I(:),J(:),V(:),N,N);
完成全局刚度矩阵装配。
为什么?
每个三角形有三个自由度,所以一个局部刚度矩阵有
假设某个单元的全局编号是
那么这 9 个条目需要加到全局位置
假设一个三角形单元的三个全局节点是:
它的
MATLAB 中 kron(A,B) 表示 Kronecker 积。这里I=kron(t,ones(1,3));会把每个节点编号重复 3 次。对 t=[5 8 12],得到 I=[5,5,5,8,8,8,12,12,12]。 而J=repmat(t,1,3);得到 J=[5,8,12,5,8,12,5,8,12]。 于是 (I,J) 正好列出局部
对于一般的单元数据t,kron(t,ones(1,3)) 负责生成所有行指标,其具体函数功能参见克罗内克张量积。repmat(t,1,3) 负责生成所有列指标。V 则一次性生成对应的所有局部刚度条目。
最关键的一点是,sparse 在遇到重复的 (I,J) 时会自动把对应数值相加。 而有限元所谓的 assembly,不就是“不同单元共享同一个全局自由度时,把贡献加起来”吗? 于是原本教科书里需要专门讲一节的局部到全局装配,在 MATLAB 里几乎变成了一次稀疏矩阵构造。
编者认为:这比单纯少写几行更值得看。它体现的是 MATLAB 这种数组语言和有限元数据结构之间非常自然的一次对接。是 MATLAB 向量化编程范式中非常具有美感的一个经典例子。
右端项为
这里为了保持代码简洁,在每个三角形上使用重心一点积分。
设
代码先算所有单元重心:
q=(p(t(:,1),:)+p(t(:,2),:)+p(t(:,3),:))/3;
再计算右端函数:
f=2*pi^2*sin(pi*q(:,1)).*sin(pi*q(:,2));
最后:
F=accumarray(t(:),repmat(A.*f/3,3,1),[N,1]);
accumarray 在这里干的事情和刚才 sparse 的重复索引累加非常像,只不过这里是一维的。
每个三角形给自己的三个顶点各贡献一份 sparse 装,右端载荷向量用 accumarray 装。下面的动态演示图直观地展示了 accumarray 的运行逻辑:
两个 MATLAB 原生函数,正好对应有限元里最频繁出现的“局部贡献汇总”。
对于齐次 Dirichlet 条件,边界节点上的解就是零。
于是没有必要先修改矩阵行列,再往对角线上塞 1。
直接找内部节点:
free=find(x>0 & x<1 & y>0 & y<1);
然后解
u(free)=K(free,free)\F(free);
就结束了。
数学上对应的其实就是把线性系统限制到自由自由度子空间。
这也是短代码里另一个很好的取舍:如果问题本身已经是齐次边界,就不要为了“通用写法”引入不需要的代码。
把代码重新拆开看,它其实就是五件事:
p t | | |
A b, c | | |
I J, V, sparse | | |
q f, accumarray | | |
free K\F | |
平时我们使用 FreeFEM、FEniCS、deal.II、MFEM,或者自己维护一个成熟 FEM 框架时,有限元的基本步骤很容易被封装层遮住。求解实际问题是方便了,但是对于初学者理解代码做了什么就比较困难。
把程序压缩到二十多行以后,反而看得很清楚:
有限元最基本的计算骨架就是这些。后面更复杂的方程,更复杂的有限元空间编程基本都是这个思路,万变不离其宗。
首先声明代码短并不自动等于代码好。 比如下面几种做法当然还能继续减少“物理行数”:
deal 把多个赋值合并;assemble();这些都能让代码看更短。但最后一条甚至已经不太算是在写一个真正的三角形有限元程序了,更像是“作弊”。
这次挑战想保留的是:
数学结构不丢,程序骨架不丢,然后再看 MATLAB 自己能帮我们省掉多少机械工作。所以我更愿意把 sparse、accumarray、向量化单元几何这些叫作“压缩”,而不是简单把十条命令挤到一行。
很多有限元入门代码都会写成
for elem=1:NT
...
Ke=...
K(nodes,nodes)=K(nodes,nodes)+Ke;
end
这个写法非常直观,教学上完全没有问题,甚至概念更加清晰便于理解。
但 MATLAB 里直接在循环中反复修改一个大稀疏矩阵,通常不是最舒服的做法。
更自然的思路是:
sparse。也就是说,不要把思路写成
“第一个单元装一次,第二个单元再装一次……”
而是改成
“把所有局部条目先列出来,然后一次汇总。”
这不只是为了少几行代码。它实际上把程序从“单元驱动的逐次更新”改成了“批量生成 COO 数据,再构造 CSR/CSC 稀疏矩阵”的思路。 规模真正上去以后,这种数据组织方式也更接近高性能有限元装配的基本思想。
需要指出,这份程序没有处理:
尤其是载荷项这里只用了三角形重心的一点积分。对于当前演示足够,但如果要写通用有限元程序,数值积分必须被认真设计。
成熟有限元软件之所以长,是因为它要处理通用性、鲁棒性、效率、可维护性和各种边界情况。 短代码的价值,是帮我们确认这些复杂性到底建立在什么最小核心之上。
加入非齐次 Dirichlet 条件|最少增加几行
把向量化装配改回单元循环|比较速度
把 P1 换成 P2|看看代码会在哪里变长
好的教学代码,不该把数学藏起来。
有限元代码一旦写到上万行,最容易忘掉的是矩阵里每一个非零元到底从哪里来。而在这 24 行里,这件事几乎没有可以删去的地方。 三角形给出局部梯度,局部梯度给出 9 个刚度条目,t 把局部编号映射成全局编号,sparse 把共享节点的贡献加起来。然后解方程。就这么多。
也正是在这种时候,才更容易体会到计算数学的魅力:那些写在公式里的抽象结构,并不是停留在纸面上的符号,它们可以一步步落到代码里,最后变成一个真正可以计算、可以观察、可以验证的数值结果。所以老师们总强调程序基本功,大概也不只是为了“把程序写出来”。更重要的是,当代码足够熟练之后,我们才有机会透过实现本身,重新看见背后的数学。
J. Alberty, C. Carstensen, S. A. Funken, Remarks around 50 lines of Matlab: short finite element implementation, Numerical Algorithms, 20, 117–137, 1999.
C. Carstensen, D. Gallistl, J. Hu, A discrete Helmholtz decomposition with Morley finite element functions and the optimality of adaptive finite element schemes, Computers & Mathematics with Applications, 68(12), 2167–2181, 2014. DOI: 10.1016/j.camwa.2014.07.019.
O. J. Sutton, The Virtual Element Method in 50 lines of MATLAB, Numerical Algorithms, 75, 1141–1159, 2017.
S. C. Brenner, L. R. Scott, The Mathematical Theory of Finite Element Methods, 3rd ed., Springer, 2008.