[{"content":"谈到编程，稍早一些的时候大家几乎都会提到 C 语言。本期我们就来试试之前番外中使用过的 C 语言吧！用它来跑调幅分解看看~\n为保持系列的统一，头图我们依旧选择了上期出现的，由 Neve_AI 绘制的 AI 爱丽丝。选曲则是最近（……）Ayase 上传到 B 站的 シネマ(CINEMA)，由初音未来献唱。很有 Ayase 味道的一首歌，也算是一代神曲了，希望您能喜欢~\n古老的传说…… 某种程度上，C 语言已经成为了传统编程的代名词了：复杂且庞大的代码库，庞杂的依赖项，强大的内存控制能力，以及各方 C 语言大神……不得不承认，C 语言即便不是那个最古老的高级编程语言，也是各个传统编程语言中最为人所知的那个。那么，为什么它如此出名呢？这就不得不提到现代计算机的操作系统了。\n一次失败的尝试 上世纪 60 年代的美国，计算机操作系统的发展如火如荼，当时由 Bell 实验室，MIT 和通用电气三方共同合作的大型操作系统项目 Multics (Multiplexed Information and Computer Services) 正在推进中1。Multics 项目有诸多创新之处：它是分时操作系统，实现了单层存储，且支持动态链接2 3 4。当时 Multics 计划使用一个尚未实现的语言，PL/I，来进行开发，且在 1969 年，Doug McIlroy 等人实现了 TMG，一款高级编程语言，并用它成功实现了 PL/I 的编译器。\n然而也许是因为项目管理混乱，又可能是当时的技术水平没法顺利推进这个项目，就在这一年，Bell 实验室的管理层和员工们觉得这个项目前景不足，纷纷退出该项目1 3 4，这个项目就这样失败了。其中一批退出该项目的员工有 Ken Thompson, Dennis Ritchie，Douglas McIlroy, Joe Ossanna 等人。他们转而尝试一个更加小型化的操作系统项目，为了与 Multics 相对，他们给新项目起名为 Unics，即 Uniplexed Information and Computing Service 3。相比起 Multics，Unics 并非分时操作系统，但也吸取了许多从 Multics 上获得的经验。随后，Unics 的名字被改成发音相似的 Unix2，这就是 Unix 的诞生。\nUnix 的诞生 在 1969 年的夏天，Ken Thompson 的妻子带着刚刚出生的宝宝去了祖父母家，而他也因此得以拥有了四周的时间。在这四周里，他在 PDP-7，一台老旧的设备上实现了内核，Shell，编辑器和汇编器，这就是第一代的 Unix 4。实现这些功能的过程也颇为传奇：他没有直接在 PDP-7 上编程，而是先在 GE-635 上通过交叉编译获得了能被 PDP-7 读取的纸带，纸带上记录的是能被 PDP-7 使用的汇编器。在这之后，就可以在 PDP-7 上使用汇编开发各种实用工具，随后的开发工作便得以直接在 PDP-7 上进行1。\n然而，PDP-7 不是一个好的平台，使用汇编开发也并非易事。在 PDP-7 的初次运行后没过多久，用来实现 PL/I 编译器的语言 TMG 被实现出来了，随后 Multics 使用的 PL/I 的编译器也被开发出来。但 PL/I 这门语言并不合 Unix 开发者的心意，而用它开发的 Multics 的失败让 Ken Thompson 下定决心，Unix 一定要有一款自己的系统编程语言1。\nB 语言，与 NB 在尝试使用 TMG 编译 Fortran 编译器未果后，Ken Thompson 在 BCPL 的基础上实现了一款更小型化的编程语言编译器，称为 B 语言。（据 Ritchie 所说，it\u0026rsquo;s BCPL squeezed into 8K bytes of memory and filtered through Thompson’s brain.）1 Ken Thompson 使用它来代替 PDP-7 上的汇编语言，成为了 Unix 的系统编程语言。B 和 BCPL 在很多地方都是相似的，比如它们都是无类型语言。但 B 语言中加入了一些好用的特性，比如 ++，--，+= 运算符等等，且 B 不允许在程序段（procedures，也就是现在的 函数）中定义新的程序段 1。\n然而 B 语言也有它的问题，特别是当他们尝试把 Unix 从 PDP-7 移植到 PDP-11 上的时候，这些问题就暴露出来了。PDP-11 的字长是 16 比特，而最初开发 Unix 的 PDP-7 是 18 比特的，这意味着两个机器一个字（word）的长度并不相等。而 B 语言是字地址（word-addressed）而非字节地址（byte-addressed）的，且所有的数据都是一个 cell，没有所谓的字符类型 (char)，只有被整存在一个 cell 中的字符串，这就导致 B 语言编译器就很难处理单个字符；另外，浮点计算在一些老机器上是刚好能放进一个字里的，因为字长比较长，但这不再适用于字长更短的 PDP-11；还有指针的问题，B 语言继承了 BCPL 的指针模型，它以字为单位移动，而非字节。这在以字为地址单元的机器上没有问题，但在 PDP-11 这个以字节为地址的机器上时，就会需要额外的操作来把字地址转换成字节地址1。\n总体上来讲，问题主要在于以 字 为基本单元的 B 语言不再适用于不同字长的处理器。为此，Dennis Ritchie 在 1971 年开始向 B 语言添加字符类型，且重写了 B 的编译器来在 PDP-11 上生成机器码，而非以前的 threaded code，一种需要进一步解释运行的压缩代码。Dennis Ritchie 将略微修改的 B 语言称为 NB，意为 new B1 5（意味深）。\n从 NB 到 C NB 后来又有了一些新的特性，比如有了类型系统（int，char，数组，指针等），再后来，在 Dennis Ritchie 的实验过程中，他发现这个版本的 NB 不方便创建复合数据结构，因此又引入了 struct 结构体；另外他又让 NB 拥有了完整的类型系统，支持指针的数组，数组的指针，函数等复杂的类型，而使用该类型的变量的方式正好与声明该变量的方式相同。\n在拥有了以上的一切新东西之后，Dennis Ritchie 决定给这个语言一个单字母的名称，这就是 C 语言的开端。在 1971 到 1973 年间，C 语言不断完善，可以被移植到其他的机器上，而在 1973 年，Unix 系统成功使用 C 语言重写，标志着 C 语言成功成为了 Unix 系统的系统编程语言，也让 Unix 操作系统成为了可移植的操作系统。后来，由 Dennis Ritchie 和 Brian Kernighan 编著的 The C Programming Language 在 1978 年出版，C 语言自此有了参考标准1 4 5。\nANSI C，及后来 随后，C 语言不断发展，ANSI（American National Standards Institute，美国国家标准局）在 1989 年发布了 ANSI C 标准，添加了 volatile，enum，void，const 等关键字，成为 C89 版本；ISO（国际标准化组织）也在 1990 年接受 ANSI C 标准，被称为 C90，此后所有的版本都以年份后两位命名。C95 中添加了宽字符支持，对流式 IO 做出一些变更，而 C99 则添加了许多新的特性，如 bool 类型，long long 类型，inline 修饰符，使用 // 开启注释等等，成为了所谓的 现代 C 语言，这也是支持最为广泛的 C 语言标准。\nC 语言至今仍然不断发展，最近的 C23 标准在弃用了许多特性（如 ctime）的同时，增加了许多新的语言特性，如属性标识（[[deprecated]]，[[noreturn]]），数位分隔符，新的预处理器指令，添加 nullptr 常量 和 nullptr_t 类型，让 true 和 false 成为关键字等等。\n与此同时，C 语言也伴随着 Unix 操作系统走遍了大江南北。想要进行 Unix 系统开发，就绝对绕不开 C 语言，而不进行系统开发的程序员们或多或少也都写过一点 C 代码，许多现代基础设施都使用 C 语言写成（如大名鼎鼎的 Python，其“标准解释器”就是用 C 语言写成的 CPython），而许许多多的大学生在本科期间也都要学一学 C 语言，美其名曰提高技能，学习程序思维。C 语言已经成为了现代计算机科学不得不品尝的一部分了。\nC 语言速览 本系列已经介绍了这么多的语言了，相比之下 C 语言的语法规则就像是这些语言的某种原型一样。甚至更进一步地说，后来的这些语言应该都或多或少地借鉴了 C 语言的语法特性。比如 JavaScript，它是明确且有意让语法靠近 C 语言的，而 C++ 就更不必多说，本就是从 C 拓展而来（尽管现在也很难说，它的新东西太多了），还有诸如 Java，Objective C，Swift 等等。\n那么，既然如此，为什么我们还要再介绍它的语法呢？正是因为 C 语言简单。相信下面的几个小例子就已经能让你快乐地写一些 C 代码了。\n注释 C 语言有两种注释方式，一种是使用 // 双斜杠开启行注释，注释掉这一行在它后面的内容，另一种则是用 /* ... */，块注释来注释掉中间的任意多内容，且支持跨行。灵活的注释可以让我们玩很多花活，但一般还是推荐在文件头使用块注释，而在代码内使用行注释。\n数据类型 C 的基础数据类型尤其简单。字符 char，整数 int，无符号整数 unsigned，单/双精度浮点 float 和 double，布尔类型（虽然是后来才有的）bool，在不考虑指明数据位数等的特殊（扩展）数据类型外，它们就是 C 语言几乎所有的基础数据类型，可以说是非常地简单，甚至简陋了。\n在基础数据类型之上，我们可以再拓展它们，比如 指针 和 数组，其中指针用来指向保存该类型变量的地址，数组则是一组同类型的，定长的数据。比如一个整数指针的类型可以写作 int*，而一个字符数组可以写成 char variable[N]。我们还可以添加一些修饰符，比如 const，volatile 等，用来向编译器提示该变量在代码运行过程中不会发生变化或者会被外部程序改变。通过它们的组合，您可以组合出非常复杂的数据类型。\n然而，过于复杂的数据类型会阻碍使用和阅读，所以 C 语言支持我们使用 结构体 struct 来创建复杂数据类型，或者使用 typedef 关键字来给复杂类型以新的名称。通过结构体我们可以更方便地管理复合数据，而 typedef 能让代码更清晰。不过您也当然可以手撕 C 语言的数据类型，如果您对这个话题感兴趣，欢迎查看以前的这篇文章：如何解析 C/C++（比较）复杂的类型？。\n您也许发现上面的这些数据类型中没有 字符串，这是因为在 C 语言中，字符串其实是以 \\0 结尾的字符指针。这是某种历史遗留问题，但换个角度来看，也是 C 语言的特色：存在许多特殊的规则，需要对它们熟悉才能更好地写 C 代码。我们后面会进一步了解。\n变量与函数 有了数据类型，我们自然想要知道怎么定义一个变量。其实前面的数据类型部分已经有了一些提醒，比如 char var[N]，就是一个长度为 N 的字符串数组，变量名为 var。尽管这么说有失偏颇，但总体来讲，C 语言的变量声明遵循 type name;，而在声明变量时也可以用等号直接进行初始化：type name=init_val;。如果要考虑指针、数组和复杂修饰符的话，就 C 语言的设计来讲，变量声明遵循 怎么使用就怎么声明。比如如果我们要声明指向整数的指针，我们就使用 int *var; 来声明这个指针，意思是如果我们后面使用 *var 的话，就会得到 int。\n而这个规则如果我们再加入函数，就显得更加明显了。在 C 语言中，我们可以前置声明一个函数，如 int my_func(int a, double b);，这样就声明了我们有一个函数名叫 my_func，它接受一个整数和一个双精度浮点作为参数，结束时返回一个整数值。如果我们的函数不返回任何值，则可以使用 void 来表明该函数没有返回值。在声明的同时，我们也可以不加分号结束该语句，而是使用大括号来开启一个代码块，用来定义该函数。由于 C 语言中的一切都按值传递，如果我们不想拷贝，或者希望函数能修改外部变量的值，我们可以让函数接受指针而非值作为参数，这样就可以在不修改地址的前提下，直接修改地址上的值，从而改变外部变量的值了。这一点我想也体现了 C 语言 利用一切已有工具 的思想吧。\n为什么说变量的声明规则和函数有关系呢？因为在 C 语言中调用函数，也是像声明函数那样使用它：my_func(42,11.4514)。可以看到通过这样的方式调用函数后，它将会返回，或者说得到，一个 int 值。另外，虽然函数和变量在 C 语言中有极为明显的差别，但我们可以声明指向函数的指针，如 int *p_func(int, double)，此时 p_func 就可以指向函数，*p_func 就相当于是被指函数的函数名，后面可以使用括号按照本来的方法调用该函数。从这个角度更是能说明 C 语言关于变量声明的规则。\n最后值得注意的是，C 语言的程序一定从一个特殊函数 main 开始执行，也可以说它是程序的入口。这一点是一些脚本语言，如 Python，JavaScript 等所不具有的。而且 C 语言中只能调用前面已经声明过的函数，这一点和 JavaScript 不同，JavaScript 允许先调用后声明/定义。\n控制语句 一个完整的程序语言，除了变量和函数定义以外，掌握控制流语句写法就相当于掌握了这门语言的基本用法了。这一点对于 C 而言尤为如此。在 C 语言中，循环可以使用 for 语句来实现：for(decl;cond;post){...} 就可以定义一个 for 循环，其中括号内是三个语句，分别是循环前声明，继续循环的条件，循环结束时的处理，括号中便是循环中要做的事。这一点很多编程语言都有这样的写法，很难不认为它们都借鉴了 C 语言的写法。除了 for 循环外，还有 while 和 do while 循环，它们的写法也类似很多别的语言，这里就不赘述了。\n除了循环，通常还需要判断语句。C 语言除了基础的 if (cond) {...} else if(cond2) {...} else {...} 的写法外，还有 switch case 语句以及三目运算符 cond?yes-do:no-do，不过现在大多不推荐使用后两种写法，因为可读性较弱且容易出问题。但是总体来讲，for 循环和 if else 语句就已经能解决 C 语言的循环与判断问题了，没有什么花里胡哨的迭代器之类的，从语法上讲非常清晰。\n其他 有了上面的内容，理论上讲我们已经可以写出想要的各种 C 语言代码了。但完整的 C 语言程序往往需要一些预处理指令，它们往往以 # 开头，最基础的比如#include 用来 复制粘贴 某个文件中的内容来替换这里的内容（其实大多数时候是用来包含系统或用户头文件），#define 用来定义宏命令等。宏在 C 语言编程中有很特殊的地位，它可以在预处理阶段进行文本替换，从而让一些复杂且重复的命令得以通过若干个宏来简单地替换它们，减轻负担，也可以被用作某种编译开关，用来启用一些语言特性或者功能。\n小结 啰嗦了这么多，总的来说，C 语言的语法规则并不繁琐，就是这样的几条最基础的规则，便可以让程序员实现诸多想要实现的目的。然而，上面只是最基础的语法规则，要使用这门语言还需要掌握许多的标准库函数使用方法，且由于 C 语言的抽象层级贴近硬件，还需要一些程序运行过程的知识才能更好地编写 C 语言的代码。这一切又让 C 语言变得很难精通。就笔者个人而言，C 语言作为程序编写入门来讲不是特别好的选择：有 Python 这样语法更亲民，使用更简便的语言，但想要写出好的程序，C 语言还是推荐去了解的，简单（简陋）的语法更能让人了解背后究竟发生了什么。\n回到本系列的主题，我们要怎么用 C 来跑调幅分解呢？这样一门古老的语言能让我们方便地实现调幅分解的模拟吗？\nC 的实现 答案自然是肯定的，且实际上我们在上一期就给出了答案：我们要使用 半隐式傅里叶谱方法（以下简称傅里叶谱法）来求解调幅分解问题。在上一期的番外：傅里叶全家桶 中，我们已经介绍了傅里叶级数、离散/连续傅里叶变换、快速傅里叶算法等内容，且我们自己实现了一个简单的快速傅里叶变换的蝶形算法。自然，我们是要在这节使用它的。\n不过，新方法，新气象，我们先在傅里叶谱方法的背景下来看看调幅分解问题吧。\n问题简述 依旧，我们有化简后的演化方程：\n$$ \\frac{\\partial c}{\\partial t} = M \\nabla^2\\left( 2Ac(1-c)(1-2c)-\\kappa\\nabla^2c\\right), $$如果您忘记了的话，我们要求解的变量是 $c$ 即浓度，而 $M$，$A$，$\\kappa$ 都是已知的材料相关常数，$t$ 自然是时间，$\\nabla^2$ 则是拉普拉斯算符，可以理解为对空间求二阶导。\n要求解这个偏微分方程，之前我们都使用了差分法来处理拉普拉斯算符，用向前欧拉法处理对时间的求导。而这次，我们则计划使用傅里叶谱方法求解这个问题，即先将方程变换到傅里叶空间中，把求导变换成傅里叶空间中的乘法，并在该空间进行四则运算得到代求变量的傅里叶空间中的表达式，再将方程变换回原空间中从而得到方程的解。这个方程有对时间的偏导和空间上的二阶导，这里我们选择依旧使用欧拉法处理时间求导（时域上我们没有特别好的办法），而让傅里叶变换处理空间求导过程。\n因此，我们有这样的计划：\n对方程两边做关于空间的傅里叶变换（变换后记为 $\\{\\cdot\\}_{\\mathbf{k}}$）: 左边因空间变换与时间无关，因此得到变换后的浓度对时间的偏导 $$\\frac{\\partial \\{c\\}_\\mathbf{k}}{\\partial t};$$ 方程右边，由于傅里叶变换的线性性，从外往内变换后 $\\nabla^2 F$ 被变换为 $\\mathbf{k}^2 \\{F\\}_\\mathbf{k}$： $$ - M \\mathbf{k}^2 \\left(\\{2Ac(1-c)(1-2c)\\}_\\mathbf{k} + \\kappa \\mathbf{k}^2 \\{c\\}_{\\mathbf{k}} \\right);$$ 重新排列方程，得到 $\\{c\\}_{\\mathbf{k}}$ 的表达式； 做傅里叶逆变换，得到原空间中的浓度结果。 这个计划还挺不错的，直到我们要处理 $2Ac(1-c)(1-2c)$ 的时候。这样一个乘法，在傅里叶变换下，会变成卷积。而卷积在计算时需要遍历整个网格，这样反而让计算变慢了。要怎么解决呢？这就是所谓 伪谱法 发挥作用的地方了。我们在计算傅里叶变换时，不将这个部分逐个做变换，而是在原空间中计算出一个整体的结果后再进行傅里叶变换通过这种方式就可以很好地解决 $2Ac(1-c)(1-2c)$ 变成卷积后不好算的问题。这样我们的第一步前面可以加上：\n计算 $2Ac(1-c)(1-2c)$，在做傅里叶变换时作为整体被变换到傅里叶空间。 这样我们就可以根据这个计划来写代码了！\n品尝傅里叶伪谱法 我们要用的傅里叶变换库除了鼎鼎大名 FFTW 之外，自然还有自己实现的 MyFFT 了。MyFFT 的代码放在了 这里，您可以下载下来自行编译。我们先来尝试使用自己写的版本，源码放在了这里：platform.h，create_directory.h 和 C_impl_fft_v1.c。\n诶？这次怎么有这么多文件？主要原因是我们希望其中一些 API 可以跨平台。因此有了 platform.h 这个文件，里面是一些和平台相关的代码。而更进一步地，由于打开文件夹这个操作在各个操作系统和桌面环境下都不太一样，因此我们再单独给它一个头文件，即 create_directory.h。这两个文件只提供了方便的两个工具，一个用来计时，另一个用来打开文件夹，并没有包含核心的计算逻辑。我们这里就不细究它们了。我们直接看主要逻辑。\n神秘文件头 1#include \u0026#34;platform.h\u0026#34; 2 3#include \u0026lt;math.h\u0026gt; 4#include \u0026lt;stdio.h\u0026gt; 5#include \u0026lt;stdlib.h\u0026gt; 6#include \u0026lt;string.h\u0026gt; 7#define M_PI 3.14159265358979323846 /* pi */ 8#define TRUNCATE_REAL 1e-6 9 10#define OUTPUT_VTK // whether output the vtk files 11#define MY_FFT_USE_RECURSIVE // whether use the recursive version of fft 12#include \u0026#34;C_my_fft.h\u0026#34; 13// my_fft_forward_2d(in,out,N0,N1) = fftw_plan_dft_2d(N0,N1,in,out,FFTW_FORWARD,_) 14// my_fft_backward_2d(in,out,N0,N1) = fftw_plan_dft_2d(N0,N1,in,out,FFTW_BACKWARD,_) / (N0*N1) 首先我们依旧包含一些头文件，其中 \u0026lt;math.h\u0026gt; 自然是数学函数库，\u0026lt;stdio.h\u0026gt; 当然是输入输出了。比较有趣的是 \u0026lt;stdlib.h\u0026gt; 和 \u0026lt;string.h\u0026gt; 两个头文件，前一个是为了使用 malloc、free 等内存操作，这也挺合理的，内存操作和 \u0026lt;stdlib.h\u0026gt; 的名字就很搭。但是有趣的是，\u0026lt;string.h\u0026gt; 也是为了管理内存而引入的。mem* 系列的函数，比如我们用到的 memcpy，就是包含在 \u0026lt;string.h\u0026gt; 里而不在 \u0026lt;stdlib.h\u0026gt; 里。\n为什么呢？这背后和 string 这个类型有关。C 语言并没有 string，人们常说的 string 实际上就是 C 语言中的一串字符，可以使用数组，也可以使用指向字符串开头的指针。因为在 C 中操作字符串就相当于操作一段连续内存，所以干脆就把操作一段连续内存的函数都放在了 \u0026lt;string.h\u0026gt; 中。不得不说，很有极客的风格。\n然后我们用 #define 定义了一些宏。最引人注目的也许是 M_PI，我们手动定义了 $\\pi$ 的值。可能看到这里有个很自然的问题：为什么 $\\pi$ 这么一个数学常数需要我们手动定义，而不是在 #include \u0026lt;math.h\u0026gt; 之后就自动导入呢？原因有点难评：\u0026lt;math.h\u0026gt; 的确有这些数学常数，甚至有很多个版本，涵盖双精度和单精度的。但是开启它们需要使用特性宏，也就是在 #include 它们之前就得先定义一些宏以打开某些功能。然而鸡肋的是，我们不需要那么多功能。我们只需要最基础的，不管是多老的版本都应该定义的那些老掉牙的数学函数，以及一个小小的 $\\pi$。那么既然如此，为什么我们不直接自己定义好呢？所以这个常数我就从 \u0026lt;math.h\u0026gt; 里抄了过来。不过某种角度上，正确的做法其实是在编译的时候通过编译器指令来定义特性宏，从而开启这些功能。但是这样做的话 IDE 又会抽风。算是一种妥协的做法吧。\n接下来我们定义了某个截断值。这个值是用来做数值截断用，当浓度出现异常，超出 $[0,1]$ 的区间时，我们就把它截断。暂时设了个 1e-6，相对来说是一个比较小的值。\n接下来我们就定义了我们自己的一些特性宏。特性宏作用在编译过程，如果某些宏打开了，编译后的产物就会有这些特性。宏的这一特性可以让我们做选择性的编译，尤其是一些版本选择的开关，选择如何具体地实现某些功能等。咱们这里也是现学现用了。最后我们引入自己实现的 FFT 头文件，头文件的设置就完成了。\n依旧先定义函数 一如既往地，我们要定义几个函数用来辅助后面的计算。首先自然是我们在前面的步骤中就提到的 $2Ac(1-c)(1-2c)$，我们让它成为函数 df_dc：\n1double df_dc(double A, double c) { 2 return 2.0 * A * c * (1.0 - c) * (1.0 - 2.0 * c); 3} 简直是最无聊的例子了。不过下面的 write_VTK 就显得更有趣一些。依旧我们计划将结果输出为 VTK 格式，不过因为我们要传入的数据是复数，因此我们需要做一些特殊的处理。这个函数的定义如下：\n1void write_VTK(my_complex *con, size_t N0, size_t N1, const char *folder_path, size_t istep, double dx) { 2 size_t N_full = N0 * N1; 3 4 size_t name_len = strlen(folder_path) + strlen(\u0026#34;step_\u0026#34;) + 6 + strlen(\u0026#34;.vtk\u0026#34;) + 1; 5 char *file_name = (char *)malloc(name_len); 6 if (!file_name) { 7 perror(\u0026#34;malloc failed\u0026#34;); 8 exit(1); 9 } 10 11 snprintf(file_name, name_len, \u0026#34;%sstep_%06zu.vtk\u0026#34;, folder_path, istep); 12 13 FILE *f = fopen(file_name, \u0026#34;w\u0026#34;); 14 free(file_name); 15 if (!f) { 16 perror(\u0026#34;fopen failed\u0026#34;); 17 exit(1); 18 } 19 20 fprintf(f, 21 \u0026#34;# vtk DataFile Version 3.0\\n\u0026#34; 22 \u0026#34;Spinodal Decomposition Step %zu\\n\u0026#34; 23 \u0026#34;ASCII\\n\u0026#34; 24 \u0026#34;DATASET STRUCTURED_GRID\\n\u0026#34; 25 \u0026#34;DIMENSIONS %zu %zu 1\\n\u0026#34; 26 \u0026#34;POINTS %zu float\\n\u0026#34;, 27 istep, N0, N1, N_full); 28 29 for (size_t j = 0; j \u0026lt; N1; j++) { 30 for (size_t i = 0; i \u0026lt; N0; i++) { 31 fprintf(f, \u0026#34;%04.1f\\t%04.1f\\t0\\n\u0026#34;, (double)i * dx, (double)j * dx); 32 } 33 } 34 35 fprintf(f, 36 \u0026#34;POINT_DATA %zu\\n\u0026#34; 37 \u0026#34;SCALARS CON float\\n\u0026#34; 38 \u0026#34;LOOKUP_TABLE default\\n\u0026#34;, 39 N_full); 40 41 for (size_t i = 0; i \u0026lt; N_full; i++) { 42 fprintf(f, \u0026#34;%07.5f\\n\u0026#34;, con[i][0]); 43 } 44 45 fclose(f); 46} 由于 C 缺乏现代的字符串处理函数，我们需要用比较原始的 strlen 来先计算字符串长度，然后再用 malloc 动态分配一个字符串用来承载文件的完整路径。值得注意的是我们的实现里出现了一些魔数：6 和 1 这些。其中的 6 是指我们会将时间步以总共 6 位的含 0 整数的形式输出，而 1 则是字符串末尾的 \\0，因为 strlen 不会计算字符串末尾的 \\0，但是合法的字符串后面又需要它。这一点也有很多 C 语言使用者吐槽：为什么非得用 \\0 表示字符串结束……我也表示不太理解。\n另外值得注意的是，我们在使用 malloc 之后立刻就使用了 if 逻辑来判断是否成功分配这块内存。这里 !file_name 是一种常见写法，当我们分配内存成功时，file_name 就应该是某个具体的地址值，此时给它求逻辑非的结果时会先将这个值转成布尔类型，即 true，然后再进行逻辑非。简单总结就是说，当成功分配内存到地址上时，指针名的隐式转换结果是 true。当这样的倒霉事情发生的时候，我们让系统抛出一个错误信息，然后使用 exit 来以某个错误码结束程序。注意当程序正常退出时将会返回 0，而不正常的运行结果将会返回非 0 的值，根据需要程序员可以自行定义退出码的含义，以便定位错误原因。\n随后我们要将字符串拼接起来。这里有几种方法可用，我们采用了 snprintf，通过格式化输出的方式将结果输出到字符串 file_name 中，同时也要指定写入的字符数目。snprintf 的优势在于可以使用 C 的字符串格式化，比如 %s 代表字符串，%06zu 代表以开头的 0 补足位数的无符号整数类型变量。\n在这些工作完成之后，我们就得到了可以使用的文件路径，此时就要借助 C 语言提供的文件抽象 FILE 了。FILE 是一个结构体，我们通常用它来打开一个文件，FILE 类型的指针就负责管理这个文件。打开文件时我们需要通过 fopen 的第二个参数来指定文件的打开方式，我们要写文件所以使用 \u0026quot;w\u0026quot;。在打开文件后我们立刻释放了不会再用的 file_name 变量，然后同样的技巧通过判断 f 这个指针的状态来判断文件是否成功打开。\n接下来就可以给文件写入内容了。fprintf 就负责这么将字符串输出到文件中。这里值得注意的是数字的格式输出，这里 %04.1f 代表输出结果一共 4 位，小数点后有 1 位，不足的部分用 0 补充。由于我们要输出的是浮点数，因此要先从 size_t 类型转换为 double 再进行操作。\n最后就是关闭文件了。这一步和 free 有类似的作用，不过 fclose 是专门为了文件设计的。\n那么至此，我们设置好了两个辅助函数，接下来就是主逻辑了。\n然后设置常数 白天想，夜里哭，终于是到了实现这个算法的时候了。不过依旧我们设置几个常数，几个待会儿要用的东西：\n1int main(void) { 2 const double total_time = 100., dt = 5. * 1e-3; // 100 seconds, compute per 0.005 seconds; 1e-2 is instable! 3 4 const size_t num_total_output = 100, // need 100 results 5 num_total_compute = (size_t)(total_time / dt), // auto-compute total computation steps 6 output_every = num_total_compute / num_total_output; // auto-compute output interval 7 8 const size_t N = 64, N_full = N * N; 9 10 const double dx = 1.0; 11 const double A = 1.0, M = 1.0, kappa = 0.5; 12 13 const double c_min = 0.395, c_max = 0.405; // c_0 = 0.4, delta_c = 0.005 14 15 const char *output_directory_path = \u0026#34;./output/v1/\u0026#34;; 16 if (create_directories(output_directory_path) != 0) { 17 perror(\u0026#34;failed when creating directory\u0026#34;); 18 exit(1); 19 } 20 /* ... */ 21} 不过这次在时间控制上我们做了一些小的修改。以往我们是通过设置总时间步数量以及时间步输出间隔的方式来控制总模拟时长的，但这里我们采用了更加科学的 $$ \\text{总时间} / \\text{时间步长} = \\text{总时间步数}$$ 的方式来控制整个计算过程，而输出方式也从规定每多少步输出一次变成我们要总共输出多少个结果。相信查阅了代码的您一定很快就理解了整个计算逻辑是什么样的。\n设置完模拟参数，选择好我们要输出的文件夹路径（由于我们函数的限制，必须要在最后加上 / 来与文件名分隔，就这样吧（）），然后用 create_directories 来创建这个文件夹，通过返回值判断是否创建成功，我们就完成了模拟输出的准备了。\n接下来我们初始化网格和计时器：\n1 /* ... */ 2 my_complex *con = alloc_complex(N_full); // alloc_complex is zero-initialize 3 for (size_t i = 0; i \u0026lt; N_full; i++) { 4 double uniform_rand_0_1 = (double)rand() / ((double)RAND_MAX); 5 con[i][0] = c_min + uniform_rand_0_1 * (c_max - c_min); 6 } 7 8 my_complex *con_trans = alloc_complex(N_full); 9 my_complex *mesh_df_dc = alloc_complex(N_full); 10 my_complex *mesh_df_dc_trans = alloc_complex(N_full); 11 12 bench_init(); 13 bench_time_t t_start, t_end; 14 bench_now(\u0026amp;t_start); 15 /* ... */ 我们有个方便的函数，可以快速分配需要长度的复数数组并对数组的每个元素实部和虚部都进行零初始化。然后就需要用 rand() 函数来生成噪音。关于 rand() 的问题，很多人表示它的随机数生成方式可能会和自己的需求有所出入，且有人提议采用拒绝采样方式来提高随机数的质量。然而我们只需要生成一个 $[0,1]$ 之间的实数即可，后续可以在这里生成的结果上继续操作，因此我们的问题就没那么复杂，直接除以 rand() 函数所可能取到的最大值即可，这样除完的结果就一定是从 $0$ 到 $1$ 均匀分布的实数来。\n后来我们还多定义来几个数组，它们是用来在后面参与计算作为中间变量的数组：con_trans 用来存储傅里叶变换过的浓度；mesh_df_dc 存储计算得到的 df_dc 的网格，而它的 *_trans 版本自然也是傅里叶变换后的结果了。\n在进入主循环之前，我们使用我们自己写的计时器来准备对计算过程进行计时。在这一切的繁文缛节结束后，我们终于要迎来真正的计算流程了。\n来吧！谱方法！ Talk is cheap, I\u0026rsquo;ll show you the code:\n1for (size_t istep = 0; istep \u0026lt;= num_total_compute; istep++) { 2 my_fft_forward_2d(con, con_trans, N, N); 3 // fill/refill mesh_df_dc 4 for (size_t i = 0; i \u0026lt; N_full; i++) { 5 mesh_df_dc[i][0] = df_dc(A, con[i][0]); 6 } 7 8 // get transformed mesh_df_dc 9 my_fft_forward_2d(mesh_df_dc, mesh_df_dc_trans, N, N); 10 11 for (size_t j = 0; j \u0026lt; N; j++) { 12 for (size_t i = 0; i \u0026lt; N; i++) { 13 size_t k_pos = i + j * N; 14 15 // Correct FFT frequency mapping (fftshift-equivalent) 16 double fi = (i \u0026lt; N / 2) ? (double)i : (double)i - (double)N; 17 double fj = (j \u0026lt; N / 2) ? (double)j : (double)j - (double)N; 18 19 double kx = 2.0 * M_PI * fi / ((double)N * dx); 20 double ky = 2.0 * M_PI * fj / ((double)N * dx); 21 double k2 = kx * kx + ky * ky; 22 double k4 = k2 * k2; 23 24 my_complex neg_k2_df_dc, kappa_neg_k4_c; 25 26 neg_k2_df_dc[0] = -1.0 * k2 * mesh_df_dc_trans[k_pos][0]; 27 neg_k2_df_dc[1] = -1.0 * k2 * mesh_df_dc_trans[k_pos][1]; 28 29 kappa_neg_k4_c[0] = -1.0 * kappa * k4 * con_trans[k_pos][0]; 30 kappa_neg_k4_c[1] = -1.0 * kappa * k4 * con_trans[k_pos][1]; 31 32 // con_trans += dt * (k2_term + k4_term) 33 con_trans[k_pos][0] += dt * M * (neg_k2_df_dc[0] + kappa_neg_k4_c[0]); 34 con_trans[k_pos][1] += dt * M * (neg_k2_df_dc[1] + kappa_neg_k4_c[1]); 35 } 36 } 37 38 my_fft_backward_2d(con_trans, con, N, N); 39 40 if (istep % output_every == 0 || istep == num_total_compute) { 41 printf(\u0026#34;output steps: %zu\\n\u0026#34;, istep); 42#ifdef OUTPUT_VTK 43 write_VTK(con, N, N, output_directory_path, istep, dx); 44#endif 45 } 46 } 首先可以看到主循环（时间循环）的设置和之前有所不同。之前我们的终止条件都使用了 istep \u0026lt; num_total_compute + 1，用来将最后一步也输出出来，这次我们使用更合适的 istep \u0026lt;= num_total_compute，一样的效果，但是语义上更明确。\n进入时间循环内，第一步便是用我们包装好的函数来计算傅里叶变换了：\n1 for (size_t istep = 0; istep \u0026lt;= num_total_compute; istep++) { 2 my_fft_forward_2d(con, con_trans, N, N); 3 // fill/refill mesh_df_dc 4 for (size_t i = 0; i \u0026lt; N_full; i++) { 5 mesh_df_dc[i][0] = df_dc(A, con[i][0]); 6 } 7 8 // get transformed mesh_df_dc 9 my_fft_forward_2d(mesh_df_dc, mesh_df_dc_trans, N, N); 10 11 for (size_t j = 0; j \u0026lt; N; j++) { 12 /* ... */ 13 } 14 /* ... */ 15 } 然而，如果您使用了 IDE 来打开这份 C 源码的话，您可能会发现这里我们使用的 my_fft_forward_2d 并没有被染成函数的颜色，而是 宏 的颜色。这是为什么呢？因为我们使用了宏来包装我们实际上写的函数。还记得最开始的那行 #define MY_FFT_USE_RECURSIVE 吗？这行宏让头文件 C_my_fft.h 中的函数调用都使用 _v1 后缀的函数，也就是使用递归算法来计算傅里叶变换。这样做的另一个“好处”是，我们可以隐藏起来函数的参数表（这是哪门子好处……）。\n另外值得注意的是，我们这次使用的是 my_fft_forward_2d，而不是 my_fft_forward 两次。其实也差不多是做两次一维变换，但一次是在 $x$ 方向进行变换，另一次则是在 $y$ 方向进行变换。\n可是，我们的 my_fft_forward 只能在矩阵的一个方向上进行变换。因此，在进行第二次变换的时候，我们需要对矩阵做一下转置：\n1/* From C_my_fft.c */ 2void my_fft_2d_v1( 3 my_complex *in, 4 my_complex *out, 5 size_t N0, 6 size_t N1, 7 int sign) { 8 9 // N0 and N1 must be power of 2 10 // row major: [i,j] = j+i*N0 11 size_t N_full = N0 * N1; 12 13 // transform row first 14 my_complex *row_transformed_in = (my_complex *)malloc(sizeof(my_complex) * N_full); 15 if (!row_transformed_in) { 16 free(row_transformed_in); 17 return; 18 } 19 for (size_t i = 0; i \u0026lt; N_full; i += N0) { 20 my_fft_1d_v1(\u0026amp;(in[i]), \u0026amp;(row_transformed_in[i]), N0, sign); 21 } 22 // then transform column 23 24 // perform inplace transpose from row-major to column-major 25 my_fft_util_transpose(row_transformed_in, row_transformed_in, N0, N1); 26 27 for (size_t i = 0; i \u0026lt; N_full; i += N1) { 28 my_fft_1d_v1(\u0026amp;(row_transformed_in[i]), \u0026amp;(out[i]), N1, sign); 29 } 30 31 my_fft_util_transpose(out, out, N1, N0); 32 33 free(row_transformed_in); 34 return; 35} 这样就可以对二维数据进行傅里叶变换了，别忘了最后再转置回来就是。\n接下来就是逐点处理了。然而在进入每一点之后，我们的第一个操作并不是计算，而是做了些判断和赋值：\n1 /* ... */ 2 for (size_t j = 0; j \u0026lt; N; j++) { 3 for (size_t i = 0; i \u0026lt; N; i++) { 4 size_t k_pos = i + j * N; 5 6 // Correct FFT frequency mapping (fftshift-equivalent) 7 double fi = (i \u0026lt; N / 2) ? (double)i : (double)i - (double)N; 8 double fj = (j \u0026lt; N / 2) ? (double)j : (double)j - (double)N; 9 10 double kx = 2.0 * M_PI * fi / ((double)N * dx); 11 double ky = 2.0 * M_PI * fj / ((double)N * dx); 12 double k2 = kx * kx + ky * ky; 13 double k4 = k2 * k2; 14 /* ... */ 15 } 16 /* ... */ 17 } 为什么呢？这也算是使用傅里叶变换的一个小小的坑点吧。FFT 的计算结果并不是按照频率从小到大的顺序存储的，而是有一种特殊的布局：\n1[ zero frequency, possitive frequency, negative frequency ] 而在每一段中都是从小到大的顺序。这样的计算结果是为了方便计算而设计的（Cooley-Tukey 算法自动会形成这样的数据布局），但是却不太适合我们做数值计算，因为它的结果对应的频率需要重新进行计算。不过好在这个计算过程非常简单：只需要重新把结果的布局变为：\n1[ negative frequency, zero frequency, possitive frequency ] 就可以了。如果是二维结果，那就在两个方向上都做这样的变换。这也就有了我们最开始的 fi 和 fj，它们是用来计算频率而将被使用的临时频率。\n然后计算频率的部分，我们根据公式：\n$$ \\mathbf{k} = \\frac{2\\pi \\mathbf{n}}{N * \\Delta x} $$就可以得到需要的频率，最后用它做点乘就得到了 $k^2$ 和 $k^4$，在代码中我们记为 k2 与 k4。需要注意的是，这里我们需要做的是点乘，而不是普通的乘法。这是因为在二维条件下，两个方向的频率是相互独立的，傅里叶变换得到的结果是一个向量而非一个普通的数。\n接下来只要把对应的部分组合起来就可以了：\n1/* ... */ 2my_complex neg_k2_df_dc, kappa_neg_k4_c; 3neg_k2_df_dc[0] = -1.0 * k2 * mesh_df_dc_trans[k_pos][0]; 4neg_k2_df_dc[1] = -1.0 * k2 * mesh_df_dc_trans[k_pos][1]; 5 6kappa_neg_k4_c[0] = -1.0 * kappa * k4 * con_trans[k_pos][0]; 7kappa_neg_k4_c[1] = -1.0 * kappa * k4 * con_trans[k_pos][1]; 8 9// con_trans += dt * (k2_term + k4_term) 10con_trans[k_pos][0] += dt * M * (neg_k2_df_dc[0] + kappa_neg_k4_c[0]); 11con_trans[k_pos][1] += dt * M * (neg_k2_df_dc[1] + kappa_neg_k4_c[1]); 12/* ... */ 这里为了方便了解各部分的组成，我们使用了两个临时变量，用来保存浓度变化表达式的前半部分和后半部分。这里的计算基本就是把公式翻译成代码而已，只不过要对实部和虚部分别进行计算就是了。\n在遍历每个格点的计算结束后，使用二维傅里叶逆变换将结果重新变换回原空间中，就完成了一个时间步的计算了。最后再根据时间和是否输出结果进行后处理后，计算便完成了：\n1 /* ... */ 2 my_fft_backward_2d(con_trans, con, N, N); 3 4 if (istep % output_every == 0 || istep == num_total_compute) { 5 printf(\u0026#34;output steps: %zu\\n\u0026#34;, istep); 6#ifdef OUTPUT_VTK 7 write_VTK(con, N, N, output_directory_path, istep, dx); 8#endif 9 } 10 /* ... */ 在程序的最后，我们将计时结果打印在屏幕上，然后释放所有使用到的资源，就完成了这个程序。所以结果如何？\n唉，丑态…… 我们跑了 20000 步，足足跑了 53 秒！这实在是太慢了……哦我们打开了 VTS 输出，关掉之后试试。说不定是文件 IO 太慢了呢？对吧？（心虚）\n难绷，我到底在期待些什么……为什么会这样呢？等一下，怎么跑了 20000 步？哦我们的计算步数是根据总时间与单步时间来控制的，那为什么把 dt 设的这么小？我们试试改成 1e-2 看看效果。这次应该要快一些吧？\n没错，计算时间降低到了 27 秒左右。我们再来看看结果：\n什……怎么计算失稳了？通过检查结果，我们可以看到从第 39 步开始，模拟中出现了神秘的条纹，而到了第 42 步，结果就爆炸了，浓度直接飙升到了 9.9e-23 这么个不可能的值。这明显是有问题吧。诶？那我们加上数值截断，在看到苗头不对的时候就立马掐断，让浓度始终保持在合理范围内，不就可以解决这个问题了？\nOK，我们塞入这么几行代码：\n1/* ... 2my_fft_backward_2d(con_trans, con, N, N); 3*/ 4 5for (size_t j = 0; j \u0026lt; N; j++) { 6 for (size_t i = 0; i \u0026lt; N; i++) { 7 int pos = j * N + i; 8 con[pos][0] = con[pos][0] \u0026gt; 1.0 - TRUNCATE_REAL ? 1.0 - TRUNCATE_REAL : con[pos][0]; 9 con[pos][0] = con[pos][0] \u0026lt; TRUNCATE_REAL ? TRUNCATE_REAL : con[pos][0]; 10 } 11} 12 13/* 14if (istep % output_every == 0 || istep == num_total_compute) { 15... */ 编译运行，结果变成了这样： 虽然这次数值没有崩溃导致计算无法正常进行，但这还是不太对吧……怎么一直有条纹出现，而且右边的总浓度怎么随时间变化越来越小了？不行，这个办法肯定不行。那就只能使用原来的时间步长，也就是 dt = 5. * 1e-3 了。那这套算法的优势究竟在哪里呢？\n哦对了，我们没有更换到更快的迭代算法，而是还在用老旧的递归算法。切到迭代算法试试吧！要更换为迭代算法很简单：只需要把前面的特性宏 MY_FFT_USE_RECURSIVE 改成 MY_FFT_USE_ITERATIVE 或者干脆删掉这个宏就行了，我们的这个库默认是使用更快的迭代算法的。我们将步长调回 dt = 5. * 1e-3，再看看结果。\n？！强强！？只用了 3 秒就跑完了！但是我们之前的那些算法比这个傅里叶谱方法要更快吧？归根结底还是因为我们的计算中每一步的步长太短，导致计算步数增加。而如果我们不用这么短的步长，计算又会崩溃……这实在是太烦人了。有没有什么办法能让它不要崩溃呢？\n半隐式算法 我们隆重介绍：半隐式算法！之前计算不稳定的根本原因，在于我们在时间尺度上的积分是显式算法。这种算法好实现，但是问题就在于它的稳定性并不好。要解决这个问题，我们必须从迭代公式下手。\n[!NOTE]\n下面的两节有比较麻烦的数雪内容，如果您只想看实现的细节以及结果，请跳过接下来的三个小节。\n解法的稳定性 然而要讨论稳定性，我们必须知道稳定性的定义。我们在进行数值计算的过程中，每一步的计算都会给系统引入一部分的误差。我们自然希望误差不要越计算越大，最起码不应该大过最开始的误差，这就引入了数值方法稳定性的最基本思想。我们这样定义稳定性：\n[!DEF]{数值方法的稳定}\n设采用一种数值方法在计算初值问题 $n$ 步之后得到的结果是 $y_n$，对 $y_n$ 增加一个扰动 $\\delta$。若该扰动对后来的计算造成的误差都小于 $\\delta$，则称这个数值方法是稳定的。\n稳定性理论上是受到数值方法、步长选取和问题本身三个方面影响的。为了只研究数值方法（以及步长选取）对稳定性的影响，我们可以取一个“参考问题”来应用多种数值解法。这个微分方程就是我们非常熟悉的：\n$$y'(t) = \\lambda y(t),$$ 其中的 $\\lambda$ 是复数，这也是为了后续推广到线性方程组问题。然而在这个语境下，有个特殊的名称：Dahlquist（达赫奎斯特）测试方程。它的解也挺好求，就是 $ y(t) = C \\mathrm{e}^{\\lambda t} $，其中 $C$ 是某个常数。\n您也许想问，为什么要选择这个函数？这是由于给出任意一个 非线性问题：\n$$ y'(t) = f(t,y(t)),$$ 我们都可以对右侧进行泰勒展开后进行线性化，得到 $u' = \\lambda u$ 的形式，这里的 $\\lambda$ 就是 $\\frac{\\partial f}{\\partial y}$ 在展开点的值。也就是说我们可以将非线性问题在局部进行线性化，从而套用 Dahlquist 测试方程得到的稳定性判定结果。如果问题是常微分方程组 $$\\mathbf{y}' = \\mathbf{f}(t,\\mathbf{y}),$$ 我们可以通过线性化得到 $$\\mathbf{y}' = \\mathbf{A} \\mathbf{y},$$ 其中 $\\mathbf{A}$ 是 $\\mathbf{f}$ 对 $\\mathbf{y}$ 的雅克比矩阵，照样可以套用 Dahlquist 测试方程的结果。\n那么我们就来看看之前的显式方法下 Dahlquist 测试方程的稳定性结果吧。$y'(t) = \\lambda y(t)$ 的显式欧拉公式为：\n$$ y_{n+1} = (1+h\\lambda)y_n,$$ 我们进而设在节点处的值 $y_n$ 上有一个扰动 $\\varepsilon_n$，它让输入的实际值变成了 $y_n^* = y_n + \\varepsilon_n$，它通过数值解法传播到 $n+1$ 步时造成的误差，或者扰动，为 $\\varepsilon_{n+1}$。假设显式欧拉法在这一步不会再引入任何新的误差，则扰动就满足 $$ \\varepsilon_{n+1} = (1+h\\lambda)\\varepsilon_{n}. $$可以看到，新的误差是旧误差的 $(1+h\\lambda)$ 倍。如果我们不希望误差增大，那么这个值就得小于等于 $1$，于是就有：\n$$ \\lvert 1+h\\lambda \\rvert \\leq 1.$$ 如果解法满足这个条件，我们就称这个方法是 绝对稳定 的，而满足这个条件的复数 $\\mu = h\\lambda$ 组成的复平面上的区域被称为 绝对稳定域，它与实轴的交称为 绝对稳定区间。\n从上面的结果来看，显式解法的稳定性并不好。如果 $\\lambda$ 碰巧是个绝对值很大的值，那 $h$ 就得选得很小。那么我们上一小节采用的算法，它的 $\\lambda$ 是多大呢？\n显式法干了 受到时间步长控制的主要是在最后的时间步更新，好消息是在这一步的时候我们可以认为就是在解一个常微分方程了。整理一下这一步的方程，有：\n$$ \\frac{\\partial \\{c\\}_{\\mathbf{k}}}{\\partial t} = - 2AM\\mathbf{k}^2 \\{c(1-c)(1-2c)\\}_{\\mathbf{k}} - \\kappa M \\mathbf{k}^4 \\{c\\}_{\\mathbf{k}} $$这里我们遇到了一点小困难：由于我们采用的是傅里叶伪谱法，$c(1-c)(1-2c)$ 是整体处理的，这反而对稳定性分析造成了一些困难。我们把它展开并记为 $g(c)$:\n$$ g(c) = c - 3c^2 + 2c^3 $$那么给它一个扰动 $\\varepsilon$ 后得到：\n$$ \\delta g = g(c+\\varepsilon) - g(c) = (1-6c + 6c^2) \\varepsilon = g'(c) \\varepsilon $$那么在傅里叶变换后，这个部分造成的扰动则为：\n$$ \\delta \\{g(c)\\}_{\\mathbf{k}} = \\{g'(c)\\}_{\\mathbf{k}} * \\{\\varepsilon\\}_{\\mathbf{k}} = \\sum_\\mathbf{q} \\{g'(c)\\}_{\\mathbf{k-q}} \\{\\varepsilon\\}_{\\mathbf{q}},$$其中 $\\mathbf{q}$ 也是傅里叶空间中的波矢。这个结果看起来更吓人了，但是好在一些傅里叶分析的结论可以告诉我们，这个误差在傅里叶空间的值绝对不会大过 $\\max_{\\mathbf{x}} \\lvert g'(c) \\rvert \\cdot \\lVert \\{\\varepsilon\\}_{\\mathbf{k}} \\rVert$，所以我们可以直接将 $g'(c)$ 固定为它的最大值。简单的计算可知它的最大值可以取到 $1$。这样一来，我们套用上面的稳定性分析过程，就有了：\n$$ \\{\\varepsilon\\}_{\\mathbf{k}}^{(n+1)} = (1 + (- 2AM\\mathbf{k}^2 - \\kappa M \\mathbf{k}^4)\\Delta t ) \\{\\varepsilon\\}_{\\mathbf{q}}^{(n)},$$那么这个方程在使用显式欧拉法的时候，就有：\n$$ \\lambda_{\\mathrm{max}} = -2AM\\mathbf{k}^2 - \\kappa M \\mathbf{k}^4. $$ 现在问题是怎么计算 $\\mathbf{k}^2$ 和 $\\mathbf{k}^4$ 的最大值。就这个问题，我们的实现中关于它们的代码如下：\n1for (size_t j = 0; j \u0026lt; N; j++) { 2 for (size_t i = 0; i \u0026lt; N; i++) { 3 size_t k_pos = i + j * N; 4 // Correct FFT frequency mapping (fftshift-equivalent) 5 double fi = (i \u0026lt; N / 2) ? (double)i : (double)i - (double)N; 6 double fj = (j \u0026lt; N / 2) ? (double)j : (double)j - (double)N; 7 8 double kx = 2.0 * M_PI * fi / ((double)N * dx); 9 double ky = 2.0 * M_PI * fj / ((double)N * dx); 10 double k2 = kx * kx + ky * ky; 11 double k4 = k2 * k2; 12 /* ... */ 13 } 14} 可以看到 fi 或 fj 的绝对值最大可以取到 N/2, 也就是 32。本算法中的 dx = 1.0，则 $\\mathbf{k}^2$ 和 $\\mathbf{k}^4$ 可能的最大值为：\n$$ \\mathbf{k}^2_{\\mathrm{max}} = 2 \\times (\\frac{2\\times\\pi \\times 32}{64\\times1.0})^2 = 2 \\pi^2, $$$$ \\mathbf{k}^4_{\\mathrm{max}} = (\\mathbf{k}^2_{\\mathrm{max}})^2 = 4 \\pi^4, $$那么 $\\lambda_{\\mathrm{max}} = -2AM\\mathbf{k}^2 - \\kappa M \\mathbf{k}^4_{\\mathrm{max}} = -2 \\times 2 \\pi^2 - 2 \\pi ^4 \\approx - 234.297$, 带入 $\\lvert 1+h\\lambda \\rvert \\leq 1$ 中进行计算，可以得到 $h$ 的合理取值范围为：\n$$ h \\leq \\frac{2}{\\lambda_{\\mathrm{max}}} = 8.536 \\times 10^{-3}.$$所以，我们最合理的取值应该在 $0.0085$ 以下，而以往取到的 $0.01$ 明显是大于这个值的。这里笔者也做了一些测试，实际上在 $h = 0.009$ 的时候，计算 100 秒的结果依旧是不失真的。实际上，它计算到 650 秒左右都是不会发生失真的，但是在这之后结果就会有问题了，条纹状图样会再次出现：\n这已经说明这步开始计算就失稳了。总的来说，0.005 是个非常保守的计算步长，我们实际上可以取到 0.008 左右也能保证计算是稳定的。\n那么，半隐式法能带给我们什么样的结果呢？\n半隐式能带来的是…… 在半隐式法中，我们将上面式子的第二项用下一步的结果替代上一步结果，也就是：\n$$ \\frac{\\{c\\}_{\\mathbf{k}}^{(n+1)} - \\{c\\}_{\\mathbf{k}}^{(n)}}{\\Delta t} = - 2AM\\mathbf{k}^2 \\{g(c)\\}_{\\mathbf{k}}^{(n)} - \\kappa M \\mathbf{k}^4 \\{c\\}_{\\mathbf{k}}^{(n+1)} $$重新整理式子之后会得到：\n$$ \\{c\\}_{\\mathbf{k}}^{(n+1)} = \\frac{ \\{c\\}_{\\mathbf{k}}^{(n)} - \\Delta t \\mathbf{k}^2 M \\{g(c)\\}_{\\mathbf{k}}^{(n)}}{1+\\Delta t \\mathbf{k}^4 M \\kappa} $$它的稳定性情况怎么判断呢？我们依旧采用之前对 $g(c)$ 的分析手法，这部分的误差被 $g'(c)$ 控制，其最大值为 $\\lvert g'(c) \\rvert \\varepsilon$，进而得到它的误差迭代公式为：\n$$ \\{\\varepsilon\\}_{\\mathbf{k}}^{(n+1)} = \\frac{ 1 - \\Delta t \\mathbf{k}^2 M \\lvert g'(c) \\rvert }{1+\\Delta t \\mathbf{k}^4 M \\kappa} \\{\\varepsilon\\}_{\\mathbf{k}}^{(n)} $$则我们得到了这样的结果：要想误差至少不扩大，则有\n$$ \\lvert E \\rvert = \\lvert \\frac{ 1 - \\Delta t \\mathbf{k}^2 M \\lvert g'(c) \\rvert }{1+\\Delta t \\mathbf{k}^4 M \\kappa} \\rvert \\leq 1, $$为方便讨论，我们记 $P_k = \\mathbf{k}^2 M$, $Q_k = \\mathbf{k}^4 M \\kappa$，$a = \\lvert g'(c) \\rvert$，$h = \\Delta t$，则上式变为：\n$$ \\lvert \\frac{ 1 - P_k a h }{1+ Q_k h} \\rvert \\leq 1 $$解这个关于 $h$ 的不等式，可以看到有两个条件：上界为 $ E \\leq 1$，即 $ a \\geq -Q_k/P_k $。这个结果中不含有 $h$，意味着这个条件和步长选取无关，仅与该问题有关。下界则为 $ E \\geq -1$，可以解得 $$ h \\leq \\frac{2}{P_k a - Q_k}$$。我们记这个分母为 $\\Phi (\\mathbf{k})$，则 $ h \\leq 2/\\Phi (\\mathbf{k})$。\n我们重点考察这个 $\\Phi$，最坏的情况下 $\\Phi$ 应该取到最大值。由于 $\\mathbf{k}^4 = (\\mathbf{k}^2)^2$，实际上 $\\Phi$ 是一个关于 $\\mathbf{k}^2$ 的开口向下的二次函数，其最大值取在 $\\mathbf{k}^2_* = (A a) / \\kappa$ 处，其值为：\n$$ \\Phi_{\\mathrm{max}} = \\frac{A^2 M a^2}{\\kappa} $$则我们只需要让 $a$ 也取到最大值，就能得到 $\\Phi$ 的最大值，而在之前的讨论中我们已经得到了 $a = \\lvert g'(c) \\rvert$ 的最大值为 $1$，带入其余的值，得到：\n$$Phi_{\\mathrm{max}} = \\frac{1^2 \\times 1\\times 1^2}{0.5} = 2,$$ 则保持数值解稳定的 $h = \\Delta t$ 的上界为：\n$$ \\Delta t = h \\leq \\frac{2}{\\Phi_{\\mathrm{max}}} = \\frac{2}{2} = 1.$$所以，我们可以取到的最大的 $\\Delta t$ 是……1 ！？没错，这就是半隐式方法的魅力：我们可以极大地放宽解法的步长，同时又能保持解的稳定。\n行了，我们扯了这么多，已经完全感受到半隐式的魅力了，所以究竟要怎么实现？其实也很简单，我们依旧翻译公式即可：\n半隐式算法的实现 半隐式算法的代码放在了 C_impl_fft_v2.c，另外因为使用了自实现的 libmyfft，因此需要用到之前用来编译这个库的代码。这份代码大部分和上一版是一样的，区别之处主要在于使用了默认的迭代快速傅立叶变换算法，替代了缓慢的递归算法；默认关闭了结果输出，因为结果没有区别；计算步长改为了 dt = 1e-1；采用了半隐式算法。\n为什么结果完全没有区别？不知道您是否注意到我们的几次运行结果都是一模一样的，这是由于我们没有设定随机数种子，因此每次的随机数种子都采用了默认种子 0，从而生成出一样的初始结构，最终给出一样的演化结果。这里将 dt 设为 0.1 也算是比较保守的做法，不过这样的计算结果应该也足够快了。我们把目光放到具体实现上。\n由于采用了半隐式方法，我们的迭代计算公式就发生了些许变化。其主循环逻辑如下：\n1/* ... */ 2// use semi-implicit method 3for (size_t j = 0; j \u0026lt; N; j++) { 4 for (size_t i = 0; i \u0026lt; N; i++) { 5 size_t k_pos = i + j * N; 6 7 // Correct FFT frequency mapping (fftshift-equivalent) 8 double fi = (i \u0026lt; N / 2) ? (double)i : (double)i - (double)N; 9 double fj = (j \u0026lt; N / 2) ? (double)j : (double)j - (double)N; 10 11 double kx = 2.0 * M_PI * fi / ((double)N * dx); 12 double ky = 2.0 * M_PI * fj / ((double)N * dx); 13 double k2 = kx * kx + ky * ky; 14 double k4 = k2 * k2; 15 16 // numerator = {c} - delta t k^2 M {df/dc} 17 my_complex numerator; 18 numerator[0] = con_trans[k_pos][0] - dt * k2 * M * mesh_df_dc_trans[k_pos][0]; 19 numerator[1] = con_trans[k_pos][1] - dt * k2 * M * mesh_df_dc_trans[k_pos][1]; 20 21 // denominator = 1 + delta t k^4 M kappa 22 double denominator = 1. + dt * k4 * M * kappa; 23 24 // con_trans = numerator / denominator 25 con_trans[k_pos][0] = numerator[0] / denominator; 26 con_trans[k_pos][1] = numerator[1] / denominator; 27 } 28} 29/* ... */ 在前面准备 k2 和 k4 的部分都是完全相同的，主要的不同点在后面的计算，我们首先计算得到分子 numerator，然后再计算得到分母 denominator，最后让下一步的浓度更新为这个分数的值就可以了。注意到分母并不是复数，而是一个实数，因此我们可以直接用一个 double 就好，也省去了麻烦的复数除法计算。\n真是朴实无华的算法呀，如果没有前面的稳定性分析这一部分真是足够无聊（心虚）。不过说了这么多，让我们赶快看看它的计算速度吧！\n！强强！不过也是可以预见的快就是了：我们比第一版程序的计算步数少了 20 倍，比使用差分法的计算步数少了 10 倍，它就应该有这样的计算效果！这是关掉结果输出的用时，我们可以打开结果输出来检查一下计算的情况：\n没啥问题，又快又好！不过如果采用我们自己实现的傅里叶变换算法都是这样的速度的话，那如果用上了真正的 FFTW，究竟会有多快呢？\n上 FFTW！ 我们在 番外 已经简单介绍过 FFTW 了，这里我们就不要多废话了，直接上代码吧。您同样可以从这里查看源码：C_impl_fft_v3.c。\n与第二版代码相比，这一版的主要变化就是从 libmyfft 迁移到了 libfftw。因此最开始我们要使用 libfftw 提供的头文件：\n1#include \u0026#34;platform.h\u0026#34; 2 3#include \u0026lt;math.h\u0026gt; 4#include \u0026lt;stdio.h\u0026gt; 5#include \u0026lt;stdlib.h\u0026gt; 6#include \u0026lt;string.h\u0026gt; 7#define M_PI 3.14159265358979323846 /* pi */ 8#define TRUNCATE_REAL 1e-6 9 10// #define OUTPUT_VTK // whether output the vtk files 11#ifdef _WIN32 12#include \u0026#34;include/windows/fftw3.h\u0026#34; 13#else 14#include \u0026#34;include/linux/fftw3.h\u0026#34; 15#endif 这里我们使用了 _WIN32 这个宏来判断面向的目标平台。如果是在 Windows 平台进行编译运行，则使用提供给 Windows 的头文件，否则我们默认是编译给 Linux 平台，并使用对应的头文件。对不起 MacOS 用户，但是主包手上没有搭载 MacOS 的电脑，也没折腾出来 MacOS 的虚拟机，就只能牺牲一下了……\n经过如出一辙的函数定义、变量初始化与文件夹创建后，我们就需要操作 FFTW 了：\n1/* ... */ 2fftw_complex *con = fftw_alloc_complex(N_full); // alloc_complex is zero-initialize 3fftw_complex *con_trans = fftw_alloc_complex(N_full); 4fftw_complex *mesh_df_dc = fftw_alloc_complex(N_full); 5 6fftw_plan con_2_con_trans = fftw_plan_dft_2d((int)N, (int)N, con, con_trans, FFTW_FORWARD, FFTW_PATIENT); 7fftw_plan con_trans_2_con = fftw_plan_dft_2d((int)N, (int)N, con_trans, con, FFTW_BACKWARD, FFTW_PATIENT); // need manually normalize 8fftw_plan trans_mesh_df_dc = fftw_plan_dft_2d((int)N, (int)N, mesh_df_dc, mesh_df_dc, FFTW_FORWARD, FFTW_PATIENT); 我们解释一下这里的做法。首先我们定义了待求解的初始浓度场 con，变换后的浓度场 con_trans 以及待计算的 mesh_df_dc。为什么没有对它们进行初始化？有这样几点原因。首先，fftw_alloc_complex 是默认零初始化的，另外就是在后面创建 fftw_plan 的时候会摧毁掉这些数组里的数据，因此现在进行初始化也是徒劳。另一个问题可能是为什么我们没有提供一个 trans_mesh_df_dc 的临时变量呢？这是因为我们的计算过程中只需要变换后的 mesh_df_dc，即在计算得到当前步的 mesh_df_dc 后会立刻进行傅里叶变换，此后就只使用傅里叶变换后的结果了。因此我们选择让变换后的结果就直接存储在 mesh_df_dc 中，方便节省内存，也有利于缓存命中。\n值得注意的是，我们当然也可以使用比较原始的 malloc 来分配内存，当然也可以使用我们自己的 alloc_complex 函数，因为说到底 fftw_complex 就是一个长度为 2 的数组，而数组的内存是保证连续的，因此只要想办法把足够长度的内存分配进去就可以了。\n接下来就是创建 fftw_plan 了，这里我们也有三个：con_2_con_trans 用来将原空间浓度转到傅里叶空间上；con_trans_2_con 来把傅里叶空间中的浓度转回原空间；trans_mesh_df_dc 用来对 mesh_df_dc 进行傅里叶变换。由于要进行的是二维傅里叶变换，我们需要使用 fftw_plan_dft_2d 这个函数，依次输入行数和列数，变换前的数组，变换后的目标数组，变换方向以及寻找最优变换的策略。我们给三个 fftw_plan 都设置了 FFTW_PATIENT 这个策略，它可以让 FFTW 多花费一些时间来寻找到计算傅里叶变换可能的最优方式。另一个值得注意的点是 FFTW 采用行优先数据排列，比如一个三行两列的 $3\\times 2$ 矩阵 $A$，它的第 1 行第 2 列的元素可以表示为 A[0*2 + 1] = A[1]，这里 2 是每行的元素个数（列数），0 代表第一行，1 代表第二行。坏消息（或者是好消息）是，我们的求解区域长和宽都是相同的，因此直接输入两个 N 就可以了。这里进行强制类型转换也是避免编译器瞎叫唤，fftw_* 系列的函数默认使用 int 而非 size_t，如果我们不自己强制类型转换编译器就会（在打开 -Wall 的时候）提醒我们。\n注意到这里的注释：// need manually normalize，这是因为 FFTW 并不提供自动归一化，因此在每次反变换后要除以归一化系数。我们稍后还会提到。\n创建好 fftw_plan 之后，经过浓度初始化，我们就进入了主循环：\n1for (size_t istep = 0; istep \u0026lt;= num_total_compute; istep++) { 2 // my_fft_forward_2d(con, con_trans, N, N); 3 fftw_execute(con_2_con_trans); 4 // fill/refill mesh_df_dc 5 for (size_t i = 0; i \u0026lt; N_full; i++) { 6 mesh_df_dc[i][0] = df_dc(A, con[i][0]); 7 mesh_df_dc[i][1] = 0.0; 8 } 9 10 // get transformed mesh_df_dc 11 // my_fft_forward_2d(mesh_df_dc, mesh_df_dc, N, N); 12 fftw_execute(trans_mesh_df_dc); 13 /* ... */ 14} 首先当然是将浓度 $c$ 和 $\\mathrm{d} f / \\mathrm{d} c$ 变换到傅里叶空间。浓度我们可以直接进行变换，因为已经初始化过了：fftw_execute 这个函数可以选择执行之前声明的 fftw_plan，这里直接执行 con_2_con_trans 即可。随后我们根据浓度计算填充好 mesh_df_dc 后就可以执行 trans_mesh_df_dc 这个 fftw_plan，将变换后的结果重新填回 mesh_df_dc。\n然后依旧是进行和上一版一样的计算流程，这里就不再赘述，在遍历过每个格点，计算得到下一步的 con_trans 后，我们执行 con_trans_2_con 将浓度变换回原空间，但是要注意的是我们还需要进行归一化：\n1/* ... */ 2// my_fft_backward_2d(con_trans, con, N, N); 3fftw_execute(con_trans_2_con); 4 5for (size_t i = 0; i \u0026lt; N_full; i++) { 6 con[i][0] /= (double)N_full; 7 // image part is zero, no need to normalize 8} 9/* ... */ 由于实值在经过傅里叶变换和反变换后依旧是实数值，因此我们可以从数学上保证 con 的虚部一定是 0，因此不需要对它进行归一化。而对于实部，我们直接让每一项都除以总长度即可，或者说，两次一维傅里叶变换均需要进行归一化，因此每次都要除以一维傅里叶变换的长度 N，则两次变换后就要除以 N*N = N_full 了。\n最后我们要销毁前面声明的指针。对于 fftw_plan，我们需要使用 fftw_destroy_plan 来销毁；对于使用 fftw_alloc_complex 的指针，我们则使用 fftw_free 进行销毁即可。笔者很不幸尝试过使用基础的 free 对二者进行销毁，结果是程序在退出时会出现奇怪的报错，这是因为没有正确选择销毁方法导致的库函数报错。唉，RAII，最想念你的一集！\n那么，它的速度如何？我们来编译运行试试看。\n等一下，怎么运行没有反应！？这是因为动态运行库发力了。您可能需要将 libfftw3-3.dll 或者 libfftw3-3.so 复制到构建产物的文件夹下，然后再运行。否则程序会因为找不到运行库而停止运行。我们复制到对应文件夹之后，编译运行，它的计算时间为：\n{width=\u0026ldquo;25%\u0026rdquo;}\n之前是我僭越了，不该挑衅 FFTW 大人的。FFTW 真的是太快了，只用了 0.0405 秒就计算结束了。当然这依旧是不进行文件 IO 的结果，我们打开文件 IO 之后，速度降低到了 0.2787 秒，不过这也是因为文件 IO 拖慢了整体的运行时间。结果就不放出来了，和之前的一模一样，没啥好看的。\n总的来说，如果使用了比较先进的傅里叶变换算法库（没错，就是 FFTW），在使用 半隐式傅里叶伪谱法 的情况下，能显著节约问题的计算时间。节约计算时间的方式是双向的：算法本身效率极高，保证计算稳定的时间步长也足够大。根据我们之前推出的最大稳定步长，我们甚至可以设为 1，我们这里就不尝试了。\n所以，数值截断在哪？ 最后我们解答一个之前遗留的问题：使用 TRUNCATE_REAL 这个宏进行数值截断到底为什么会引起这些问题？\n这是因为每当我们进行数值截断，就实际上抛弃了一部分的边界值。而傅里叶变换后的结果会将原空间中的所有结果都编码进每个频率上的值，因此对边界点的裁切在经过傅里叶变换与反变换之后会导致整个空间内的物质整体下降。我们在第三版程序中也插入这段截断代码，然后将模拟时间调整到 1000 秒，结果如下：\n可以看到，由于不断进行数据截断，模拟域内的浓度在经过一小段时间的平稳状态后很快就下降到了一个很低的水平。因此，在使用傅里叶谱方法的时候，我们不能对模拟域内的值进行数值截断。那么要怎么保证整个模拟过程中数值不会超过合理范围呢？\n其实没法保证。我们上面的计算过程中浓度的最大值并不是完美的 1，而是不断变化的。观察之前那些成功的模拟，右侧随时变化图的灰色区域代表着整个模拟区域的浓度变化，而它的上边界并不是平直的。好消息是，归功于双势阱的特点，平衡状态下的浓度总不会偏离 $0$ 或 $1$ 太远。那么我们要进行数值截断吗？我的看法是不需要，因为自由能已经对体系的浓度做出了良好的约束，不需要再额外添加一个浓度截断，显得画蛇添足。\n所以其实每个文件中的 TRUNCATE_REAL 是鄙人偷懒留下的。\n后记 其实本文用到的模拟代码在很早的时候就已经写好了。然而因为要写中间的番外篇就拖了很久很久，直到去了德国才完成了番外，而后就接着立刻开始了本篇，原计划在 6 月 10 日就能完成的稿子，结果是到了现在才刚刚写好。\nC 语言的考据部分真的令我受益匪浅，翻看这些论文和文档的过程也非常有趣。其实这几个人的记述上还是有一些小的差别的，主包也是一番比较之后选择了一个个人认为比较合理的故事线。另外，我也没想到真的有 B 语言这么个东西，不过由于太过古董，这里就不深入考古了。C 语言的发明和 Unix，或者说 Linux 的关系之密切也是我始料未及的，了解过这段往事后也是刷新了我对 Linux 的看法。希望您喜欢 C 语言的故事！\n傅里叶伪谱法这个方法就目前来看非常有潜力，它对周期性问题的处理能力极强，这可能主要因为它能降低计算过程中对数值稳定性的要求，从而使用较大的时间步长进行积分。另外就是使用先进的开源傅里叶变换库也从某种角度上极大地提高了代码的整体质量吧。这个方法也有一些潜在的问题，比如对非周期性边界的问题处理起来比较棘手，但这一点可以通过一些特殊的处理（比如补充一个镜像区域用来满足周期性）来解决这个问题，就是这样的做法会牺牲很多计算资源去计算重复的部分。\n另外想要提醒读者的是，这个 Github 仓库 里存放了这个系列用到的所有源代码。这一篇用到的代码在 4-C 中，里面有完整的 VS Code 工作流配置。如果您使用了这里的配置，那么在要编译的代码页面按下 F5 键后，VS Code 会询问您要使用 Release 还是 Debug 模式去构建项目，选择好之后就会调用放在 script 文件夹中的脚本进行编译，最后由 VS Code 的 launch.json 调用编译好的产物并运行。若您要编译 libmyfft，也可以如法炮制，会自动调用另一个脚本用来编译，无须担心。如果您的编译器设置之类和主包不一样，可能需要看一下脚本的配置，进行简单的修改。主包个人对这个流程还是比较满意的，一大原因也是这样能脱离 CMake 的束缚，充分利用 VS Code 本身的功能。这让我更加确信 VS Code 就是伪装成编辑器的 IDE 了。\n这篇完成之后，主包可能要休息一阵子。一来一直拖着的论文到底是没有完成，总不能一直拖到下个学期；二来主包想把写过的这些文章翻译成英文，也算是做一做 i18n，说不定有更多人会愿意看；再者这个系列靠着不同的编程语言驱动，而编程语言也需要学习掌握，每一期想要推出除了语言以外的新东西也需要一定的功夫。目前的候选语言有 Rust，Go，Fortran，C#，Java，这里面主包现在掌握了的个数是 0。而加新的东西，目前的想法是从自由能入手，或者是从能量耦合的角度，或者是模拟域的几何形状，又或者是模拟多晶中的调幅分解，这里面每一个都不是什么善茬。希望主包能打赢复活赛，继续连载这个系列吧。\n话虽如此，这个系列连更了四期（外加一期番外），就算读者您没有审美疲劳，主包也已经想换一个话题了。就比如继续去年的天坑：线性代数系列，或者聊一聊各个系统包管理器，又或者是开个新坑：晶体塑性有限元，之类的。关于晶体塑性，这也是主包去德国学习交流的主要内容，最近也计划整理学习的成果。下一篇很有可能就是这个了，敬请期待~ 当然如果更新了别的内容，也希望您能继续支持！\n那么最后，一如既往，祝您工作顺利，生活愉快，心想事成！\nRitchie, Dennis M. (January 1993). \u0026ldquo;The Development of the C Language\u0026rdquo;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nWikipedia: Multics #Novel ideas\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nWikipedia: Unix #History\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nMaster Thesis of Nils Fredrik Gjerull: 4.3 Multics, Unix and AT\u0026amp;T\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\ncppreference: History of C\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2026-06-22T00:00:00+08:00","image":"/images/Alice-2.png","permalink":"/zh/posts/pf_note/impl_spinodal/impl_spinodal_4/","title":"相场模拟，但是用很多语言 IV"},{"content":"前几篇博文中，我们都使用了有限差分法来离散网格并计算 Cahn-Hilliard 方程的结果。这样的写法确实简单有效，但是问题是就没有别的更好的方法了吗？有的，兄弟！有的！那就是今天要向各位介绍的 傅里叶谱法。在这个方法下，我们不需要再可怜兮兮地做网格差分了，而是从另一个神秘空间：谱空间 去求解。本篇就以番外的形式，聊聊这个神奇的方法，关于它的数学原理，使用事项，以及实现时需要注意的若干细节。\n为保持系列的统一，头图我们依旧选择了上期出现的，由 Neve_AI 绘制的 AI 爱丽丝。选曲则是上季度知名动画 Fate Strange Fake 的片尾曲 潜在的なアイ，由 13.3g 献唱，节奏明快，非常欢乐的一首歌！希望你也喜欢~\n傅里叶全家桶！ 相信当您看到 傅里叶 三个字（Joseph Fourier，也译作 傅立叶，旧译 福里叶）的时候，也许就已经秒变战斗脸了吧……作为通常高等数学教学的最后一部分，也是古典分析学最引以为傲的成果之一，这位伟大的法国数学家、物理学家的名字出现在了许多地方，傅里叶级数、傅里叶变换等一系列概念与技术都深刻地影响了现如今的各个科学技术领域，也是众多科普文章或视频绝佳的素材，而它的传热方程模型也在工程上解决了许多热传导相关问题。即便已有了许多对傅里叶级数等概念的非常好的介绍，我们这里也还是简单介绍一下“傅里叶全家桶”的基本概况。如果您对这方面的内容感兴趣，我搜集到了一些不错的文章和视频，对这些概念有一些不同层次与程度的介绍123。\n要了解傅里叶全家桶的情况，我们得从另一个您也许熟悉的名字开始讲起：泰勒（Brook Taylor，英国数学家）。\n从函数项级数开始：泰勒展开与泰勒级数 给定任意一个函数，我们有什么好的办法把它拆成乘积的和的形式吗？如果说一个复杂函数能够被分成许多份简单函数的组合，那会极大地方便我们研究该复杂函数，因为它的性质将会完全由这些小的简单函数的性质决定。我们能做到吗？当然可以！事实上，各位最早接触到的函数项级数应该就是大名鼎鼎的 泰勒级数 了。通过将函数在某一点处进行 泰勒展开，我们可以获得在这一点处附近函数的各阶导数，同时还能得到非常好的近似结果，用来在该点处做近似的函数还是被得到了相当透彻的研究的多项式。也许您曾经听说过，泰勒展开是古典微积分学的终极杀手锏，这一点几乎是毋庸置疑的，因为当我们需要研究函数在某一点附近的性态时，泰勒展开能够让我们解剖开这个复杂函数，并在需要的时候做对应的简化，省略掉高阶项从而低成本地表示该函数。\n然而上面的一切都有一个前提，那就是仅限在函数的 某一点 处。这个限制其实相当大，当把目光从古典微积分学研究某一点的问题转移到研究函数在某个较大区间内的性态时，泰勒级数就没那么好用了。每次展开我们都必须且只能选择某一个点进行展开，这让我们没法同时在区间上的每个点展开并研究。从下面的例子你也许能更清晰地感受到泰勒级数展开的局限性：\nLoading... Loading React component... 可以看到，$\\sin(x)$ 的泰勒展开在展开阶数不断上升时，有不错逼近结果的区间在快速拓宽；$\\mathrm{e}^{x}$ 在正半轴的逼近效果很不错，但在负半轴的逼近情况就比较困难了；$\\ln(1+x)$ 的逼近效果简直是灾难级别的，即便在零点处展开到 10 阶，效果依然不太好；另外当我们从另一个位置对函数展开时（这里以 $\\ln(3+x)$ 来模拟在 $x=2$ 处展开 $\\ln(1+x)$ 的情景），可以看到它的收敛区间比在 $0$ 处展开的效果稍微好一些，但很快就会受到自然定义域的限制（左侧 $x\\leq 3$ 处无定义）。总体上来说，泰勒展开的效果在确定范围内的函数逼近时很不如人意。那么要怎么解决这个问题呢？\n选择正确的函数：三角函数 问题其实在于多项式。当我们选定某个点时，我们可以快速地得到一组经过该点的多项式，而无需关心其他的点，而多项式的连续性能保证经过的那个点的附近性质是不会发生剧烈变化的。而要想将函数展开出来的级数每一项都对函数整个区间上的结果做出贡献，我们得找一组在整个区间上都分布均匀，但又各不相同的函数，来代替多项式。其中最简单的一组函数自然就是 三角函数 了。比如下面的这一组：\nLoading... Loading React component... 可以看到，$\\omega$ 为整数倍的不同频率的三角函数都近乎完美地均匀分布在 $-\\pi$ 到 $\\pi$ 的区间中，而且不同频率的函数也都相互之间有所区别，方便我们用它们自由组合出需要的函数。但是问题是，我们要怎么求出某个给定函数的组合系数呢？在泰勒级数中，我们只需要求导就可以了，因为求导可以 让多项式降阶，从而得到多项式中各个项的系数。但这在三角函数里行不通呀……\n函数内积与傅里叶级数 好消息是，三角函数有着很特殊的性质，如果两个正弦或者两个余弦函数的频率 $\\omega$ 不同，那么它们相乘后再在一个周期上积分之后会得到 $0$，而当它们的频率相同的时候，积分得到的结果才不为 $0$！具体的原因我们就不证明了，这样的特点让我们自然想到可以用不同频率的三角函数和这个函数求积分，得到的结果就能够来反求出这个频率下对应的系数了。在数学上，我们将 相乘后积分 的概念拓展到更一般的情景时，常常使用 内积 去称呼它，而如果两个函数内积后结果为 $0$，我们就称它们是 正交 的。我们对函数的内积做如下定义：\n[!DEF]{函数内积}\n设有定义在同一集合 $S\\in \\R^n$ 或 $S\\in \\mathbb{C}^n$ 上的两个函数 $f$ 与 $g$，定义 函数内积 为：\n$$\\langle f,g \\rangle = \\int_S f(\\mathbf{x}) \\bar{g}(\\mathbf{x}) \\,\\mathrm{d}\\mathbf{x},$$其中的 $\\bar{g}$ 表示函数 $g$ 的复共轭，即函数值的逐点共轭。可以验证该定义满足内积的一般定义。\n这里定义内积时我们有考虑让它的定义扩展到其他空间中，不过对于我们熟悉的实函数而言，就是简单的相乘后积分即可。三角函数系是我们常见的正交函数系之一，而除了三角函数外，也有一些别的函数系满足正交性，比如一些正交多项式系，这里我们就不展开了。总之借助内积计算，我们能够成功地将一个函数在某个区间上展开成不同 $\\omega$ 下 $\\sin(\\omega x)$ 和 $\\cos(\\omega x)$ 的系数和，它也是一个级数。我们称这样展开的级数为这个函数的 傅里叶级数：\n[!DEF]{傅里叶级数}\n若合适的函数4 $f(x)$ 的周期为 $2L$，记 $\\omega_n = n\\pi / L$，则它的 傅里叶级数 展开式为： $$ f(x) = \\frac{a_0}{2} + \\sum_{n=1}^\\infty \\left( a_n \\cos(\\omega_n x) + b_n \\sin(\\omega_n x) \\right),$$ 其中 $$ a_n = \\frac{1}{L} \\int_{-L}^{L} f(x) \\cos (\\omega_n x) \\,\\mathrm{d}x = \\frac{\\langle f(x), \\cos(\\omega_n x )\\rangle}{\\langle \\cos(\\omega_n x), \\cos(\\omega_n x)\\rangle} , $$ $$ b_n = \\frac{1}{L} \\int_{-L}^{L} f(x) \\sin (\\omega_n x) \\,\\mathrm{d}x = \\frac{\\langle f(x), \\sin(\\omega_n x )\\rangle}{\\langle \\sin(\\omega_n x), \\sin(\\omega_n x)\\rangle} . $$ 注意到我们这里要求 $f(x)$ 是一个周期为 $2L$ 的函数，原因我们稍后提到。另外，如果我们应用 欧拉公式 的话，我们可以将上面的式子改写成更简洁的形式：\n[!COROLLARY]{傅里叶级数的复数形式}\n若函数 $f(x)$ 的周期为 $2L$，设 $\\omega_n = n\\pi / L$，则它的傅里叶级数可以被展开为：\n$$ f(x) = \\sum_{-\\infty}^{\\infty} c_n \\mathrm{e}^{\\mathbf{i} \\omega_n x}, $$ 其中， $$ c_n = \\frac{1}{2L} \\int_{-L}^{L} f(x) \\mathrm{e}^{ - \\mathbf{i} \\omega_n x}.$$ 式中，$\\mathbf{i}$ 为虚数单位。若记 $K_n(x) = \\mathrm{e}^{ - \\mathbf{i} \\omega_n x}$，则 $c_n$ 亦可记为：\n$$ c_n = \\frac{\\langle f(x), K_n(x)\\rangle}{\\langle K_n(x),K_n(x) \\rangle}$$ 我们要求周期是 $2L$，一方面是为了能够将经典傅里叶级数原本的 $2\\pi$ 的周期扩展到任意的周期上，另一方面也是为了能更好地衔接后续的 傅里叶变换 与 离散傅里叶变换。另一个自然的问题是，非周期的函数在某个区间上可以进行这样的展开吗？答案是可以这么操作，但得到的就不是函数的展开了，而是在这个区间上的逼近。我们下面画一些函数在傅里叶级数下的逼近：\nLoading... Loading React component... 可以看到，在指定区间上，傅里叶级数可以很快地逼近目标函数，另外傅里叶级数给出的结果自然地是周期性的，这也说明了如果处理的函数真的是周期函数，那么在周期上去展开的傅里叶级数将能非常好的代替函数本身。\n另外，傅里叶级数每一项前面的系数也有特殊的含义，它们代表了函数中对应频率的正弦/余弦函数的“强度”。这对于信号处理而言是非常好的消息，因为可以区分出信号的频率，从而将时间上的连续信号转换为空间上的频率强度。而且观察高频正弦/余弦函数，它们在 $-\\pi$ 到 $\\pi$ 上有许多的“锯齿”，这说明它们主要会影响函数最终的“细节”。当我们对这些细节不太在意的时候，就可以抛弃这些高频率的正弦/余弦部分，只保留较低频率的正弦/余弦，函数的形状也不会有太大的失真。\n从频率走向频谱：傅里叶变换 傅里叶级数能将一个周期函数在其周期上用不同频率的三角函数叠加来表示，或者将一个非周期函数在指定区间上以不同三角函数的叠加实现逼近。但是这些方法里傅里叶系数始终是离散的点，就如同上面频率空间中显示的那样。而如果我们考虑让频率 连续变化，从离散的频率强度变成 频谱，那么我们就得到了所谓的 傅里叶变换，又称 傅里叶积分5。推导方法我们放在下面，如果您感兴趣可以点击查阅。\n从傅里叶级数到傅里叶积分 我们要怎么做呢？其实前面已经有了提示，我们让 $\\omega_n = n \\pi / L$，这里的 $\\omega_n$ 自然地成为了三角函数的频率。我们需要让频率连续变化，最简单的方式，也最自然的方式，就是让 $L$ 趋近于无穷，这样我们还顺带让“展开”这个过程不只是定义在周期函数上，不再限制函数的周期性（因为现在周期是无穷了）。\n我们将前面的傅里叶级数中 $a_n$ 和 $b_n$ 等带回，我们得到：\n$$ f(x) = \\frac{a_0}{2} + \\sum_{n=1}^\\infty \\left(\\left[ \\frac{1}{L}\\int_{-L}^{L} f(x) \\cos (\\omega_n x) \\,\\mathrm{d}x \\right] \\cos(\\omega_n x) + \\left[ \\frac{1}{L}\\int_{-L}^{L} f(x) \\sin (\\omega_n x) \\,\\mathrm{d}x \\right] \\sin(\\omega_n x) \\right), $$我们调整一下顺序：\n$$ f(x) = \\frac{1}{2L}\\int_{-L}^{L} f(x)\\,\\mathrm{d}x + \\sum_{n=1}^\\infty \\left[\\int_{-L}^{L} f(x) \\cos (\\omega_n x) \\,\\mathrm{d}x \\right] \\cos(\\omega_n x) \\frac{1}{L} + \\sum_{n=1}^\\infty \\left[ \\int_{-L}^{L} f(x) \\sin (\\omega_n x) \\,\\mathrm{d}x \\right] \\sin(\\omega_n x) \\frac{1}{L}, $$注意到我们这里有两个 $1/L$，它们可以被表示为 $(\\omega_n -\\omega_n-1)/\\pi = \\Delta \\omega / \\pi$ ，我们就有了： $$ \\sum_{n=1}^\\infty \\cdots \\frac{1}{L} \\Rightarrow \\frac{1}{\\pi}\\sum_{n=1}^\\infty \\cdots \\Delta \\omega. $$诶！那我们让 $L$ 趋近无穷的时候，$\\Delta \\omega$ 就成了 $\\mathrm{d}\\omega$，且求和就自然变成了积分呀！而且此时，$a_0$ 对应的积分中，如果函数 $f(x)$ 是绝对可积的，这个积分就自动变成了 $0$，我们就有了：\n$$ f(x) = \\int_{0}^\\infty \\left[\\int_{-\\infty}^{\\infty} f(x) \\cos (\\omega x) \\,\\mathrm{d}x \\right] \\cos(\\omega x)\\,\\mathrm{d}\\omega + \\int_{0}^\\infty\\left[\\int_{-\\infty}^{\\infty} f(x) \\sin (\\omega x) \\,\\mathrm{d}x \\right] \\sin(\\omega x) \\,\\mathrm{d}\\omega. $$ 最终，我们称下面的东西叫 傅里叶积分6：\n[!DEF]{傅里叶积分}\n称一个合适4的函数 $f(x)$ 的 傅里叶积分 表达为： $$ f(x) = \\int_{0}^{\\infty} A(\\omega) \\cos(\\omega x)\\,\\mathrm{d}\\omega + \\int_0^{\\infty} B(\\omega) \\sin(\\omega x) \\,\\mathrm{d}\\omega,$$ 其中 $$ \\begin{align*} A(\\omega) \u0026= \\frac{1}{\\pi}\\int_{-\\infty}^{\\infty} f(x) \\cos (\\omega x) \\,\\mathrm{d}x;\\\\ B(\\omega) \u0026= \\frac{1}{\\pi}\\int_{-\\infty}^{\\infty} f(x) \\sin (\\omega x) \\,\\mathrm{d}x; \\end{align*} $$ 当然，我们也可以将它表达为复数形式。我们称它的复数形式为 傅里叶变换6：\n[!DEF]{傅里叶变换}\n称一个合适的函数 $f(x)$ 的 傅里叶变换 为 $F(\\omega)$，$F(\\omega)$ 为 $f(x)$ 的傅里叶反变换，若二者满足： $$ F(\\omega) = \\int_{-\\infty}^\\infty f(x) \\mathrm{e}^{-\\mathbf{i}\\omega x}\\, \\mathrm{d} x ,$$ $$ f(x) = \\frac{1}{2\\pi}\\int_{-\\infty}^\\infty F(\\omega) \\mathrm{e}^{ \\mathbf{i}\\omega x}\\, \\mathrm{d} \\omega .$$ 二者的关系可以记作： $$\\begin{align*} F(\\omega) \u0026= \\mathscr{F}\\left\\{f(x)\\right\\},\\\\f(x) \u0026= \\mathscr{F}^{-1}\\left\\{F(\\omega)\\right\\}. \\end{align*}$$ 或表示为： $$ f(x) \\longleftrightarrow F(\\omega) $$ 其中，我们称 $\\mathrm{e}^{-\\mathbf{i}\\omega x}$ 为傅里叶（正）变换的 核函数（可以简称为核），相对应的 $\\mathrm{e}^{\\mathbf{i}\\omega x}$ 为傅里叶逆变换的核。让一个函数乘以某个核之后做积分，就是 积分变换 了。核函数的概念总是伴随着积分变换，二者形影不离；而使用 傅里叶核 进行的积分变换，就是我们这里所要讲的傅里叶变换。我们通常称变换前的函数处于 实空间 中，而变换后的函数则处于 谱空间 中。\n另外，关于傅里叶变换的符号，感兴趣的话可以点击展开下面的内容。\n关于傅里叶变换的符号 上面我们使用大写字母 $F$ 作为函数 $f$ 在进行傅里叶（正）变换后的结果。除了使用 $\\mathscr{F}\\left\\{f\\right\\}$ 以外，$\\hat{f}$ 或者 $\\left\\{f\\right\\}_k$ 也时常被用来来表示 $f$ 的傅里叶变换。且除了使用 $\\omega$ 角频率的记号外，有时人们也是用波数 $k$ 来表达傅里叶变换后的函数定义域，或是用 $\\xi$ 来表达 $x$ 的对偶变量从而体现 $\\hat{f}$ 是 $f$ 的对偶这一概念。\n使用不同的变量时，也会带来一些不同的傅里叶变换公式。当使用角频率 $\\omega$ 时，积分核通常为 $\\mathrm{e}^{-\\mathbf{i}\\omega x}$，变换公式也同上。而当使用波数 $k$ 或 $\\xi$ 时，二者的关系为 $\\omega = 2\\pi k$ 或 $\\omega = 2\\pi \\xi$。此时积分核变为：$\\mathrm{e}^{-2\\pi \\mathbf{i}k x}$ 或 $\\mathrm{e}^{-2\\pi \\mathbf{i}\\xi x}$，而变换公式也将成为：\n$$ \\hat{f}(\\xi) = \\int_{-\\infty}^\\infty f(x) \\mathrm{e}^{-2\\pi\\mathbf{i} \\xi x}\\,\\mathrm{d} x $$ $$ f(x) = \\int_{-\\infty}^\\infty \\hat{f}(\\xi) \\mathrm{e}^{-2\\pi\\mathbf{i} \\xi x}\\,\\mathrm{d} \\xi $$此时傅里叶变换公式的积分前系数均为 $1$。有时人们为了让角频率下的傅里叶变换也有使用波数定义时的对称性，会将 $\\frac{1}{2\\pi}$ 拆开为两个 $\\frac{1}{\\sqrt{2\\pi}}$ 并同时分给两个公式，从而保持对称性。除了这些以外，还有比如虚数单位是使用 $\\mathbf{i}$ 还是 $i$，$\\mathbf{j}$ 或 $j$，积分核内的符号排列顺序等，我们就不多提了。总的来讲，傅里叶变换就是这样的一种积分变换，根据需要的场景我们会选择合适的公式。在这个系列中，我们保持前面推导过程中使用的记号，并使用角频率 $\\omega$ 且保持积分前的系数不对称。\n这里我们讨论的是一维的情况，那么二维甚至更高维度下要怎么进行傅里叶变换呢？其实答案很简单：对每个方向依次做傅里叶变换就可以了。以二维为例，$f(x_1,x_2)$ 的变换结果为：\n$$\\begin{align*} F(\\omega_1,\\omega_2) \u0026= \\int_{-\\infty}^\\infty \\left[\\int_{-\\infty}^\\infty f(x_1,x_2) \\mathrm{e}^{-\\mathbf{i} \\omega_1 x_1}\\,\\mathrm{d} x_1\\right] \\mathrm{e}^{-\\mathbf{i} \\omega_2 x_2} \\,\\mathrm{d} x_2\\\\ \u0026= \\int_{-\\infty}^\\infty \\int_{-\\infty}^\\infty f(x_1,x_2) \\mathrm{e}^{-\\mathbf{i}(\\omega_1 x_1 + \\omega_2 x_2)}\\,\\mathrm{d} x_1 \\mathrm{d} x_2 \\end{align*} $$而反变换结果则为：\n$$\\begin{align*}f(x_1,x_2) \u0026= \\frac{1}{(2\\pi)^2}\\int_{-\\infty}^\\infty \\left[\\int_{-\\infty}^\\infty F(\\omega_1,\\omega_2) \\mathrm{e}^{\\mathbf{i} \\omega_1 x_1}\\,\\mathrm{d} \\omega_1\\right] \\mathrm{e}^{\\mathbf{i} \\omega_2 x_2} \\,\\mathrm{d} \\omega_1 \\\\ \u0026= \\frac{1}{4\\pi^2}\\int_{-\\infty}^\\infty \\int_{-\\infty}^\\infty F(\\omega_1,\\omega_2) \\mathrm{e}^{\\mathbf{i}(\\omega_1 x_1 + \\omega_2 x_2)}\\,\\mathrm{d} \\omega_1 \\mathrm{d} \\omega_2\\end{align*}$$对于更高维度的函数，我们只需要执行更多次积分就好。注意到每个空间方向只对应一个频率方向。逆变换也是类似的，将积分核的指数改变符号，然后在前面乘以 $1/2\\pi$ 的维度次方就好。\n傅里叶变换的性质 有了傅里叶变换之后，我们自然会考虑它有什么样的性质。首先，毋庸置疑的，由于傅里叶变换是积分变换，积分满足线性性，因此：\n[!LEMMA]{傅里叶变换是线性的}\n设有两个合适的函数 $f$ 和 $g$，以及复数 $a,b\\in \\mathbb{C}$ 则有：\n$$ \\mathscr{F}\\{af+bg\\} = a\\mathscr{F}\\{f\\} + b\\mathscr{F}\\{g\\},$$或记作\n$$ a f(x) + b g(x) \\longleftrightarrow aF(\\omega) + bG(\\omega), $$即该变换是线性的。\n我们就不证明了，这个性质从积分就能得到，且对于多元函数来说这条性质依然是成立的。除此之外的性质就没那么明显了，我们就需要将它们带入来看看。这里我们就不进行推导了，直接将几个性质列出来：\n[!PROP]{傅里叶变换的性质}\n设两个合适的一元函数 $f$ 和 $g$，以及复数 $a\\in \\mathbb{C}$，则：\n微分定理： $$ \\frac{\\mathrm{d}^n f(x)}{\\mathrm{d} x ^n } \\longleftrightarrow (\\mathbf{i} \\omega)^n F(\\omega);$$ 积分定理： $$ \\int_{x_0}^{x} f(x)\\,\\mathrm{d} x \\longleftrightarrow \\frac{F(\\omega)}{\\mathbf{i}\\omega};$$ 位移定理： $$ f(x+x_0) \\longleftrightarrow \\mathrm{e}^{\\mathbf{i}\\omega x_0} F(\\omega);$$ 卷积定理： $$ f(x)g(x) \\longleftrightarrow \\frac{1}{2\\pi} F*G(\\omega), $$ $$ (f*g)(x) \\longleftrightarrow F(\\omega) G(\\omega), $$其中 $*$ 为方程求卷积： $$ f*g(x) = \\int_{-\\infty}^\\infty f(t) g(t-x)\\,\\mathrm{d} t .$$ 可以看到，傅里叶变换有着神奇的性质：它能把求导变成简单的乘法，而让积分变成简单的除法！后两条性质则是为了完整性而添加上去的，但也能看到傅里叶变换的强大潜力。对于多元函数而言，我们最关心的第一条性质就变成：\n$$ \\left(\\frac{\\partial}{\\partial \\boldsymbol{x}}\\right)^\\alpha f(\\boldsymbol{x}) \\longleftrightarrow (\\mathbf{i}\\boldsymbol{\\omega})^\\alpha \\mathscr{F}\\{\\boldsymbol{\\omega}\\}, $$其中 $\\alpha$ 是多重指标：$\\alpha = \\left(\\alpha_1, \\alpha_2,\\dots\\right)$，它的规则是将多元成分的每个分量都作用到对应的指数，然后相乘。比如 $\\boldsymbol{x}^\\alpha$ 代表的即为 $x_1^{\\alpha_1}x_2^{\\alpha_2}\\cdots$。所以总体来讲，即便是多元函数，它的求导在进行傅里叶变换后，依旧还是让函数的傅里叶变换乘以角频率与虚数的若干次方，是一个十分简洁的关系！而我们想要介绍的 傅里叶谱方法，实际上也正是靠着傅里叶变换的这个神奇性质而得到了广泛的应用。\n傅里叶谱方法 相信从上面的介绍，您也可以猜到傅里叶谱方法具体要怎么操作了。通过将待解的偏微分方程两端同时做傅里叶变换，在谱空间中进行计算后，再返回实空间，就能得到偏微分方程的解了。这样的优点在于，我们可以把偏微分方程中难以处理的一些求导变成简单的乘法运算，不过也会有一些不好的地方就是了，比如原本简单的乘法会在谱空间中变成卷积。当然，相对应的，如果在实空间中发生的是卷积，那在谱空间中就会变成普通乘法就是了。这一点针对积分方程也是类似的，可以将实空间的积分变换到谱空间中的除法。\n不过，简单地变换到谱空间并不总是明智的选择，比如当偏微分方程中有变量明显依赖于别的变量时，我们可能不会同时变换所有的变量，仅变换那些被依赖的部分。举个最基础的例子，就比如下面的经典二阶偏微分方程：传热方程。\n经典传热方程的傅里叶谱法 [!EXAMPLE]{传热方程}\n设有一个含时函数 $T(x,t)$ 满足条件 $$ \\frac{\\partial T}{\\partial t} = a \\frac{\\partial^2 T}{\\partial x^2},$$ $$ T(x,0) = T_0(x), $$ 其中 $T_0(x)$ 是一个已知函数。\n那么我们可以怎么解它呢？从题目来看，$t=0$ 时刻的场分布是我们已知的，且等号左侧只有待求函数对时间的偏导。为了处理它，我们对方程两边在 $x$ 上做傅里叶变换，就得到这样的结果：\n$$ \\frac{\\partial \\mathscr{F}\\left\\{ T\\right\\}}{\\partial t} = a \\mathscr{F}\\left\\{\\frac{\\partial^2 T}{\\partial x^2}\\right\\}, $$根据傅里叶变换的性质，我们有：\n$$ \\mathscr{F}\\left\\{\\frac{\\partial^2 T}{\\partial x^2}\\right\\}= (\\mathbf{i}\\omega)^2 \\mathscr{F}\\{T\\};$$带回，就得到了关于 $\\mathscr{F}\\left\\{T\\right\\}$ 的一个只和时间显式相关的方程：\n$$ \\frac{\\partial \\mathscr{F}\\left\\{ T\\right\\}}{\\partial t} = - a \\omega^2 \\mathscr{F}\\{T\\}, $$接下来只需要直接在时间上求解即可，最后将得到的 $\\mathscr{F}\\{T\\}$ 反变换回 $T$，就得到了我们需要的解了。\n不过，操作虽然简单，它背后的数学逻辑其实要稍微复杂一些。我们这里介绍的傅里叶谱法，其实是 谱方法 这个大类下的一个最为人所熟知的方法。我们这里不对谱方法进行展开，但是值得了解的是，谱方法实际上是使用一系列 正交函数 对待求函数进行逼近的方法，这些正交函数被要求在要求解的空间上只有有限多个点为 $0$。傅里叶谱方法使用了三角函数（或者 $\\mathrm{e}^{\\mathbf{i}\\omega x}$）的性质，而除了傅里叶谱方法外，还有切比雪夫谱方法、勒让德谱方法等，它们借助的则是切比雪夫多项式和勒让德多项式这两种 正交多项式。此外，上面傅里叶谱方法展现出的特殊性质，其实源于 微分算子 的特殊性质：\n$$ \\frac{\\mathrm{d} }{\\mathrm{d} x }\\mathrm{e}^{\\mathbf{i} \\omega x} = \\mathbf{i}\\omega \\mathrm{e}^{\\mathbf{i} \\omega x},$$即 $\\mathrm{e}^{\\mathbf{i} \\omega x}$ 是微分算子的特征方程，而特征值正是 $\\mathbf{i} \\omega$。正是这样的特点，让傅里叶变换能够如此简单地将求导转换成普通的乘法。可惜的是，其他的谱方法就没有这么简单的对应关系了，因此也相对少见一些。\n傅里叶谱方法尽管强大，也依旧有一些自己的问题。其中一个问题就是，如果方程两边的函数不是简单的仅有加法和求导，比如含有乘法，指数，对数等，那即便是变换到谱空间，也很难简单地得到待求函数在谱空间的显式表达。\n好在，我们要做的是数值方法，并非从纯数学角度去求解。为了处理刚刚描述的问题，我们可以使用所谓的 伪谱法。\n傅里叶伪谱法 所谓伪谱法，其实就是只发挥傅里叶变换针对微分算符的强大功能，而将乘除等复杂运算的结果先在实空间中求出后，再变换到谱空间中以继续原本的计算。\n我们以我们这个系列的核心：Cahn-Hilliard 方程，并以最经典的单组分双势阱体能与梯度界面能为例：\n$$ \\frac{\\partial c}{\\partial t} = \\nabla \\cdot M \\nabla \\frac{\\delta F}{\\delta c} = M \\nabla^2\\left( 2Ac(1-c)(1-2c)-\\kappa\\nabla^2c\\right), $$我们对两边同时做傅里叶变换，得到：\n$$ \\frac{\\partial \\{c\\}_k}{\\partial t} = -\\omega^2 M ( 2A \\{c(1-c)(1-2c)\\}_k - \\omega^2 \\kappa \\{c\\}_k), $$可以看到，这里我们并没有对体能的部分进一步展开，而是让它们就保持在实空间中的状态，作为一个整体做傅里叶变换。在实现中，我们也会遵照这个模式，在实空间中进行体能计算后重新变换回谱空间并参与迭代，从而解决纯傅里叶谱法难以处理复杂变量依赖的问题。\n即便傅里叶谱和傅里叶伪谱法有如此强大的能力，但它依旧有很多的局限性。如它对周期性条件的问题有极强的计算能力，但对非周期性条件的问题就有些束手无策了，即便傅里叶变换能够支持定义在整个欧式空间上的函数。这一点限制可以有一些方法来绕过，但这样做的结果会带来大量的额外计算负担来处理边界问题，且得到的结果会不太准确。而且使用傅里叶谱方法时，它的收敛性需要一些额外的处理。\n不过谈了这么多，我们到底要怎么实现傅里叶谱方法呢？更重要的，怎么让计算机知道如何计算傅里叶变换呢？这就不得不提到传说中的 离散傅里叶变换 了。\n离散傅里叶变换 傅里叶变换怎么变得离散呢？其实我认为，离散傅里叶变换的 变换 两字是有点欺诈的嫌疑的，因为计算的过程是将某个 区间 上的离散变量作为输入并变换得到谱空间的点的，相比于傅里叶变换，其实更类似于傅里叶级数。所以，要理解离散傅里叶变换，我们其实反而可以从傅里叶级数出发。\n那么，傅里叶级数要怎么进一步离散呢？刚刚从傅里叶级数到傅里叶变换，是让函数的周期变成无穷大，进而让连续函数拥有了连续频谱；要想从傅里叶级数进一步离散，我们就得在函数本身下功夫，让 离散点拥有离散频谱。为了做到这一点，我们可以借用前面内积的观察，将连续的 函数内积 替换为 序列内积 即可。我们定义序列内积：\n[!DEF]{序列内积}\n设有两长度为 $N$ 的序列 $\\{a_n\\}$ 与 $\\{b_n\\}$，则其 序列内积 为：\n$$ \\langle \\{a_n\\},\\{b_n\\} \\rangle = \\sum_{k=1}^{N} a_k \\bar{b_k}.$$ 其中，$\\bar{b_k}$ 表示 $b_k$ 的共轭。\n同样，可以验证这里定义的序列内积是符合一般内积的定义的。事实上，序列内积在我们固定 $N$ 时，便就成为了 $N$ 维向量空间内的内积了。有了新定义的序列内积，接下来我们只需要用这个新的内积去替换原来的函数内积，就得到了离散傅里叶变换：\n[!DEF]{离散傅里叶变换}\n设定义在 $\\R$ 上的函数 $f(x)$，它在区间 $[0,kN]$ 上的 $N$ 个等距分布的点上的值形成序列 $\\{f_n\\}$。设\n$$ \\boldsymbol{\\phi}_k = \\mathrm{e}^{\\mathbf{i} k n 2 \\pi /N} $$ 则函数 $f(x)$ 在区间 $[a,b]$ 的 $N$ 个点上的离散傅里叶变换为：\n$$ \\{F_k\\} = \\frac{\\langle\\{f_n\\}, \\{\\boldsymbol{\\phi}_k\\} \\rangle}{\\langle \\{\\boldsymbol{\\phi}_k\\} ,\\{\\boldsymbol{\\phi}_k\\} \\rangle} = \\frac{1}{N} \\langle\\{f_n\\}, \\{\\boldsymbol{\\phi}_k\\} \\rangle$$ 没错，只需要将函数内积替换为序列内积，我们就自然得到了离散傅里叶变换的结果。而如果需要逆变换，则直接将 $\\phi_k$ 替换为它的共轭，然后做一下归一化就可以了。至此，我们就能够用我们的离散傅里叶变换来处理数值问题中被我们离散化的点，从而得到它们在谱空间中的结果。\n也许你会有疑问：离散傅里叶变换真的是傅里叶变换的离散形式吗？或者说，当我们使用离散傅里叶变换时，它还拥有我们之前聊到的那些连续傅里叶变换中拥有的优良性质吗？答案是肯定的：可以证明，在满足一定条件下，函数的序列在经过离散傅里叶变换后的结果，就是该函数连续傅里叶变换后按相同方式取点得到的点列。至于这里的条件，笔者没有深入探究，总体上说是要求所有点都必须均匀分布且完整覆盖函数的整个周期。否则会出现一些失真（英文是 Aliasing），比如函数的频谱中会有高频成分叠加到低频上。但好消息是，我们考虑的一开始就是模拟问题，而不是数字信号处理问题，所以在建模和求解时不需要过于关注这些问题。\n但是，即便是有了离散傅里叶变换，我们也还是停留在数学模型上。要怎么才能实现离散傅里叶变换的算法呢？最先想到的算法当然是老老实实按照定义计算了，但是这样的算法复杂度是 $O(N^2)$，是一个问题规模稍微大一些就很难以接受的算法复杂度。好在这样的算法依然有改进的空间，这也就是我们下面要介绍的 快速傅里叶变换 (FFT)。\n快速傅里叶变换 或许您早已听说过这个名字，不过这不妨碍我们再次在这里重新介绍这款算法。快速傅立叶变换（Fast Fourier Transform, FFT）并不是某个独立的数学变换，它只是一种计算（离散）傅里叶变换的快速算法而已。但是这个算法太出名了，以至于很多人也许会先听到 FFT，然后才了解到什么是傅里叶变换。那么这个著名的算法，它 快速 在哪呢？\n从 $O(N^2)$ 到 $O(N\\log N)$ 前面我们提到，老老实实按照定义计算得到的算法复杂度是 $O(N^2)$。我们看看这个算法是怎么工作的，或许能从中得到一些提示。不过在那之前，我们发扬 一切数据结构自己动手 的优良品质，简单实现一个和 fftw_complex 兼容的复数类型，就不用 \u0026lt;complex.h\u0026gt; 提供的复数类型了：\n1#include \u0026lt;math.h\u0026gt; 2#include \u0026lt;string.h\u0026gt; 3typedef double my_complex[2]; 4 5/* Allocate and zero-initialise N complex numbers. */ 6static inline my_complex *alloc_complex(size_t N) { 7 my_complex *p = calloc(N, sizeof(my_complex)); 8 if (!p) { 9 fprintf(stderr, \u0026#34;calloc failed\\n\u0026#34;); 10 exit(EXIT_FAILURE); 11 } 12 return p; 13} 14 15/* Deep-copy dest ← src (N elements). */ 16static inline void copy_complex(my_complex *dest, my_complex *src, size_t N) { 17 memcpy(dest, src, sizeof(my_complex) * N); 18} 19 20static inline void cassign(my_complex dest, my_complex src) { 21 dest[0] = src[0]; 22 dest[1] = src[1]; 23} 24 25static inline void cadd(my_complex a, my_complex b, my_complex out) { 26 out[0] = a[0] + b[0]; 27 out[1] = a[1] + b[1]; 28} 29 30static inline void csub(my_complex a, my_complex b, my_complex out) { 31 out[0] = a[0] - b[0]; 32 out[1] = a[1] - b[1]; 33} 34 35static inline void cmul(my_complex a, my_complex b, my_complex out) { 36 my_complex res; 37 res[0] = a[0] * b[0] - a[1] * b[1]; 38 res[1] = a[0] * b[1] + a[1] * b[0]; 39 cassign(out, res); 40} 41 42static inline void c_exp_pure_image(double theta, my_complex out) { 43 out[0] = cos(theta); 44 out[1] = sin(theta); 45} 可以看到我们的复数其实就是一个长度为 $2$ 的数组，第一个位置存储实部，第二个存储虚部，这和 fftw 的实现应该是完全一致的。计算方面我们主要要实现的是赋值、加法、减法和乘法，以及一个需要用到的指数 $\\mathrm{e}^{\\mathbf{i}\\theta}$。这里顺带写了两个工具函数，方便创建复数数组以及对数组深拷贝，或者叫批量赋值。\n这里其实有个值得注意的点：乘法的实现需要先将结果储存在临时变量中，最后统一赋值。不直接将 out 的值改为计算得到的结果的原因是这里没法保证 in 和 out 不是同一个变量，如果是同一个变量的话，直接向 out 中写入的操作会导致虚部计算出错。鄙人在这里栽过跟头，这里给大家也是给自己提个醒。\n老实的 $O(N^2)$ 算法 其实说实话，按照定义实现的算法很简单，只需要这么几行就可以了：\n1void my_fourier_transform( 2 my_complex *in, 3 my_complex *out, 4 size_t N, 5 int sign) { 6 7 my_complex *phi_k = alloc_complex(N); 8 for (size_t k = 0; k \u0026lt; N; k++) { 9 for (size_t n = 0; n \u0026lt; N; n++) { 10 double theta = (double)sign * 2.0 * M_PI * n * k / (double)N; 11 c_exp_pure_image(theta, phi_k[n]); 12 my_complex f_n_phi_n; 13 cmul(phi_k[n], in[n], f_n_phi_n); 14 cadd(out[k], f_n_phi_n, out[k]); 15 } 16 } 17 free(phi_k); 18} 简单来说，我们只需要创建两个循环，第一个循环用来遍历每个输出的位置，第二个循环内执行我们需要的内积（逐点相乘后累加），就完成了这个算法了。这里我们没有进行逆变换的归一化，为的是和 fftw 的结果保持一致。然而，明晃晃的二重 $N$ 次循环意味着这个算法是赤裸裸的 $O(N^2)$ 算法，一旦 $N$ 稍微大一些就会很慢。要从哪里优化这个算法呢？\n取巧的 $O(N\\log N)$ 算法 也许您已经从 theta 的计算过程看到了一些端倪：$n$ 和 $k$ 在遍历 $N$ 次的情况下只有 $N^2/2$个独立的值，也就是说有一半的计算是没必要的。这虽然不会立刻降低它 $O(N^2)$ 的复杂度，但能提醒我们傅里叶变换的结果是具有极强对称性的。在维基百科上搜索 Fast Fourier transform 词条，你会在 Algorithm 部分看到所谓的 Cooley-Tukey algorithm，且它拥有一个独立的词条页面。没错，实际上我们常说的 FFT 指的正是这个 Cooley-Tukey 算法。\n这个算法是怎么工作的呢？其实它利用了离散傅里叶变换的数学对称性。这里我们大致概括这个算法的思想，如果想要详细了解这个算法，您可以参考 Cooley-Tukey algorithm - Wikipedia。\n设我们要做变换的序列为 $x_n$，经过傅里叶变换后的结果为\n$$ X_k = \\sum_{n=0}^{N-1}x_n \\mathrm{e}^{-\\mathbf{i} k n 2\\pi / N}, $$我们可以将右边这个求和分成奇数项与偶数项，再利用一点 $\\mathrm{e}^{2n\\pi\\mathbf{i}}$ 的性质，我们可以发现，序列 $X_k$ 可以被分成前后两半，每一部分都可以用奇数项和偶数项的傅里叶变换独立地表示出来：\n$$\\begin{align*} X_k = E_k + W_k O_k,\\\\ X_{k+N/2} = E_k - W_k O_k,\\\\ \\end{align*}$$其中\n$$ \\begin{align*} W_k \u0026= \\mathrm{e}^{-\\mathbf{i} k 2\\pi / N};\\\\ E_k \u0026= \\sum_{m=0}^{N/2 -1} x_{2m}\\mathrm{e}^{-\\mathbf{i} k m 2\\pi / N};\\\\ O_k \u0026= \\sum_{m=0}^{N/2 -1} x_{2m+1}\\mathrm{e}^{-\\mathbf{i} k m 2\\pi / N}.\\\\ \\end{align*} $$就这样，我们只需要得到 $E_k$ 和 $O_k$ 两个 $N/2$ 长度的数列后，我们就能组合出原来的 $X_k$，从而成功地去掉一半的冗余计算。但是与此同时，可以看到 $E_k$ 和 $O_k$ 二者本身也是长度为 $N/2$ 的序列的傅里叶变换，因此它们也可以进行这样的拆分计算。我们总共可以进行 $\\log_2 N$（下面简记为 $\\log N$）次拆分，第 $k$ 次拆分会得到 $2^k$ 个序列，每个序列长度为 $N/(2^k)$。在每一次拆分的结果中我们都需要分出来 $E_k$ 和 $O_k$，并对它们进行乘法和加法。乘法次数为 $2^k/2 * N/(2^k) = N/2$ 次，这里序列个数除以 $2$ 是因为只需要对 $O_k$ 进行乘法；加法次数为 $2^k/2 * N/(2^k) * 2 = N$ 次，这里序列个数除以 $2$ 是因为加法/减法是成对计算的（对应的 $E_k$ 和 $O_k$），而最后乘以 $2$ 则是因为要计算两半（加法给出前半，减法给出后半）。因此，每次拆分得到的结果都需要 $3N / 2$ 次计算，总共进行 $\\log N$ 次拆分，就需要 $3N/2 \\log N$ 次计算，时间复杂度则为 $O(N \\log N)$ 了。这也是我们得到它的算法复杂度的一种方法。\n那么，怎么实现这个 Cooley-Tukey 算法呢？从上面的分析过程不难发现，我们需要反复将序列的傅里叶变换进行拆分，再从拆分得到的结果来自下而上地计算每一层的 $O_k$ 和 $E_k$。因此，这个任务是天然适合使用递归算法的。下面是它的实现：\n1void my_fft_1d_v1( 2 my_complex *in, 3 my_complex *out, 4 size_t N, 5 int sign) { 6 // N is assumed power of 2 7 if (N == 1) { 8 cassign(out[0], in[0]); 9 return; 10 } 11 my_complex *Ek = (my_complex *)malloc(sizeof(my_complex) * N / 2); 12 my_complex *Ek_out = (my_complex *)malloc(sizeof(my_complex) * N / 2); 13 my_complex *Ok = (my_complex *)malloc(sizeof(my_complex) * N / 2); 14 my_complex *Ok_out = (my_complex *)malloc(sizeof(my_complex) * N / 2); 15 if (!Ek || !Ek_out || !Ok || !Ok_out) { 16 free(Ek); 17 free(Ek_out); 18 free(Ok); 19 free(Ok_out); 20 fprintf(stderr, \u0026#34;%s\u0026#34;, \u0026#34;Allocation Failed!\u0026#34;); 21 return; 22 } 23 24 for (size_t i = 0; i \u0026lt; N / 2; ++i) { 25 cassign(Ek[i], in[2 * i]); 26 cassign(Ok[i], in[2 * i + 1]); 27 } 28 my_fft_1d_v1(Ek, Ek_out, N / 2, sign); 29 my_fft_1d_v1(Ok, Ok_out, N / 2, sign); 30 double theta = (double)sign * 2.0 * M_PI / (double)N; 31 for (size_t i = 0; i \u0026lt; N / 2; i++) { 32 my_complex Wk; 33 c_exp_pure_image((double)i * theta, Wk); 34 my_complex WOk_k; 35 cmul(Wk, Ok_out[i], WOk_k); 36 cadd(Ek_out[i], WOk_k, out[i]); 37 csub(Ek_out[i], WOk_k, out[i + N / 2]); 38 } 39 free(Ek); 40 free(Ek_out); 41 free(Ok); 42 free(Ok_out); 43 return; 44} 这里没有使用方便的 complex_alloc 而是用传统的 malloc 进行操作，所以代码显得更长了一些，不过核心算法很简单。首先当处理的序列长度为 $1$ 的时候递归达到终点，直接将拆分得到的部分返回给结果部分就好。当序列长度不为 $1$ 时，我们先区分出奇偶序列，然后将奇偶序列分别都进行傅里叶变换（送入递归逻辑）。在奇偶序列的傅里叶变换计算完成后，先计算要用的 $W_k$，然后分别计算 $E_k + W_k O_k$ 和 $E_k - W_k O_k$，最后将结果传到 out 中，就完成了递归逻辑的设计。\n我相信这套逻辑还是比较清晰的，因为它真的就像是把原本的算法翻译成公式。然而，递归在应用过程中总不算是一个特别好的方法，因为它需要重复调用函数，直到函数达到递归返回的位置时才能逐次完成函数调用，导致会有大量的函数停留在调用栈内等待调用完成。对于小规模的问题而言，递归也还够用，但是对大规模的问题来讲，递归会很容易造成栈溢出（Stack Overflow，也就是爆栈）。因此，很有必要想办法把递归算法优化成为迭代算法。\n从递归到迭代 从理论上，递归算法总能改写成迭代算法。对于 FFT 这个算法而言，我们将它从递归改写成迭代的思路其实很清晰：将本循环中某些序列的元素乘以需要的 $W_k$ 之后，再和对应的序列加起来，就得到了下一个循环要用的序列。但是问题是，哪些是 某些，哪些又是 对应的 序列。我们从一个 $N=8$ 的例子来看看，需要怎么实现这个算法。\n上面这张简图中，左侧是重排的过程，因为每次计算傅里叶变换时都需要先区分下标的奇偶性，因此最后排出来的结果是 0, 4, 2, 6, 1, 5, 3, 7。右侧的上标是为了区分第几个子序列，而下标则是子序列中的元素下标。左侧在将元素成功排列之后，就自然得到了右侧最下方一层的，奇偶交替的 $8$ 个长为 $1$ 的子序列。此时，由于我们已经排列好了所有的元素，我们只需要将相邻的两个子列（左侧为偶子列，右侧为奇子序列）做计算，就可以得到下一层的子列。其中加法得到新子列的前半部分，减法得到后半部分。我们这里举一个具体的例子，让 $x_n$ 就是从 $0$ 到 $7$ 的整数：\n上面的例子中，小数被约去了一些，它们应该是 $4\\mathbf{i}$ 与 $4\\sqrt{2}\\mathbf{i}$ 的加或减的结果。\n从上面的例子可以看到，要解决的问题有：如何生成这样的排列（左侧），以及怎么使用循环来动态处理当前的序列长度与序列个数（右侧）。\n比特反转 首先我们来看第一个问题，其实这个的答案很有趣：如果将数字表示为二进制数位，那初始排列和排列好的结果的关系正好是下标转换为二进制位后的镜像结果，又称比特反转/数位反转的结果：\n{width=\u0026ldquo;400\u0026rdquo;}\n这个结论我们不深究其来源，重点在于我们要怎么实现这样的排列。为了简单起见，我们考虑的采样点个数都是 $2$ 的幂次，因此我们可以用一种巧妙的方法来实现比特反转：自己实现加法，但是从左向右进位：\n1void bit_reverse_rearrange(my_complex* array, size_t N) { 2 // N is power of 2 3 for (size_t i = 1, j = 0; i \u0026lt; N; i++) { 4 size_t bit = N \u0026gt;\u0026gt; 1; 5 for (; j \u0026amp; bit; bit \u0026gt;\u0026gt;= 1) { 6 j ^= bit; 7 } 8 j ^= bit; 9 if (i \u0026lt; j) { 10 my_complex temp; 11 cassign(temp, array[j]); 12 cassign(array[j], array[i]); 13 cassign(array[i], temp); 14 } 15 } 16} 这里我们主要要做的是下标运算，得到需要交换的两个下标就可以了，因此使用 i 代表反转前的下标，j 代表 i 在反转后的比特位。我们的目标是从 $1$ 到 $N-1$ 依次反转它们的比特位，将反转后的结果放在 j 里。因为我们不需要反转 0，所以这里 i 就从 1 开始。\n为了实现这样的比特翻转，我们的计划是从数位的最左侧开始，依次向 j 的对应数位加 1，因而在每次循环内都设一个用来指向当前数位的变量 bit，初始化为最高位的位置（让这一位是 1，其余为 0）。随后我们在内部的小循环中检查进位情况，如果 j \u0026amp; bit 是 1 就说明 j 在 bit 这一位上已经有值了，此次加法就应该在这一位上进位，因此就利用 ^= 也就是异或来在该位做半加法，将进的位通过 bit 右移一位来记录下来（相比正常加法向左移位，我们这里向右移位）；如果 j \u0026amp; bit 的值是 0，则说明 j 在 bit 这一位上的值是 0，此时我们就不需要考虑进位问题了，直接跳出循环并让 j 加上 bit 的值（依旧用异或实现就是了）。\n值得注意的是，j 只在最开始的地方初始化为了 0，此后一直没有直接赋值而是不断更新，因此 j 的值和 i 的值总是互为比特翻转后的结果；在 j ^= bit; 这一行完成时我们就得到了 i 的比特翻转结果，这里的条件判断是避免重复调换顺序（只需要调换一次顺序即可）。\n笔者自己很喜欢这个有趣的算法，像是自己实现了一次加法。也是过了把计科人的瘾，嘿嘿。总之，就这样，我们实现了将数据排列成我们需要的顺序。\n动态循环 接下来就是 FFT 的第二个要解决的问题，怎么把递归算法改成循环。从前面的 FFT 实例框图中，也许您可以看到每当我们完成一次循环（一个横框的计算），要处理的每个 $O_k$ 和 $E_k$ 的长度就会增加一倍，而要处理的问题个数也变成了原来的一半。这提示我们可以设置一个用来代表当前 $O_k$/$E_k$ 长度的变量 len，有了长度以及整个问题的长度 N 之后，只需要让 N 除以 len 就能得到总共要执行乘法与加减法的次数。当执行完所有的乘法与加减法之后，就让 len 的长度变成原来的两倍，自然就进入了下一次循环。而当这个长度等于 N 的时候，就应该终止循环（因为 $O_k$ 和 $E_k$ 总是成对出现，len = N/2 就已经是最后一次循环了）。\n基于这个思路，我们就有了下面的代码：\n1void my_fft_1d_v2( 2 my_complex *in, 3 my_complex *out, 4 size_t N, 5 int sign) { 6 // reorder 7 copy_complex(out, in, N); 8 bit_reverse_rearrange(out, N); 9 10 for (size_t len = 1; len \u0026lt; N; len \u0026lt;\u0026lt;= 1) { 11 my_complex W_len; 12 double theta = (double)sign * 2.0 * M_PI / (2.0 * (double)len); 13 c_exp_pure_image(theta, W_len); 14 for (size_t i = 0; i \u0026lt; N; i += 2 * len) { 15 my_complex W = {1.0, 0.0}; 16 for (size_t j = 0; j \u0026lt; len; j++) { 17 size_t k_this = i + j; 18 size_t k_next = i + j + len; 19 my_complex WO; 20 cmul(W, out[k_next], WO); 21 22 my_complex out_this, out_next; 23 cadd(out[k_this], WO, out_this); 24 csub(out[k_this], WO, out_next); 25 26 cassign(out[k_this], out_this); 27 cassign(out[k_next], out_next); 28 cmul(W, W_len, W); 29 } 30 } 31 } 32} 来看看具体做法吧。就如同上面所说的那样，我们首先在最外侧的大循环中设置了每个子问题的长度 len，要求当它等于 N 的时候就结束循环。然后在大循环的开始处，我们可以计算待会儿要用到的 W_len，也就是随 len 变化的 $W_k$ 的值。它相当于是 $W_k$ 的旋转因子，值只由大循环决定。\n随后我们进入处理不同 $O_k$ 和 $E_k$ 的循环，这里用 i 代表是 $E_k$-$O_k$ 对中的第一个元素的下标，然后声明一个用来不断乘以 W_len 的复数 W，它将在与 W_len 计算得到 $W_k$ 后，和 O_k 一起组成 WO 来和 E_k 进行加减。放在这里是因为对每个 $E_k$-$O_k$ 对，都应该有一套独立的 W。\n最后我们进入 $E_k$ 和 $O_k$ 内部，使用 k_this 来标记 $E_k$ 中值的位置，使用 k_next 标记对应的 WO 中的值的位置，再将当前位置的 WO 算好，就只需要计算出加法和减法的值，再分别放回对应位置，就完成了一个 $E_k$-$O_k$ 对内一对值的计算。最后记得将 W 更新到下一个值，即乘以 W_len，就能完成循环了。\n这就是所谓的快速傅里叶变换 FFT 的动态循环算法，或者更规范地说，是 Cooley-Tukey 二分算法。它的优点在于不需要再依赖递归，这一点在计算大数据集的傅里叶变换时很重要，毕竟您也不想计算的过程中突然告诉你函数栈被塞爆了吧。我们自己实现的代码放在了这里：复数类 C_my_fft_complex.h，测试用例 C_my_fft_test_main.c，库源码 C_my_fft.c，库头文件 C_my_fft.h。请自由下载编译使用。\n但是，我们的实现依旧有诸多问题。最大的问题就是，当输入的数组长度不再是 $2$ 的次幂时，我们的算法就得想办法让它的长度变成 $2$ 的次幂。通常的做法是在数据最后补充 $0$，这样也不会影响我们关心长度的变换结果，但是这说实话是一个很坏的消息，尤其是在一些极端情况，比如数组长度是 $2$ 的次幂后加 1，我们就要补近乎同等长度的 $0$。这依旧给了内存很大的压力，且这些 $0$ 也只是为了算法运行而强行添加的值，没有太大的意义。\n然而，快速傅里叶变换这个算法提出了这么久，需要做快速傅里叶变换的工程问题这么多，肯定是有非常好用的库函数来处理该问题的，总归是轮不到我们自己手搓这种破烂轮子。就目前而言，全世界公认的快速傅里叶变换库中，最出名的就是 FFTW 项目了。伟大，无需多言，我们对这个库进行一个简单的介绍，毕竟以后会用它来跑相场模拟。\nFFTW FFTW，借用其 官网 的自我介绍，是 西方最快速傅里叶变换 的简称：\n\u0026hellip; Hence the name, \u0026ldquo;FFTW,\u0026rdquo; which stands for the somewhat whimsical title of \u0026ldquo;Fastest Fourier Transform in the West.\u0026rdquo;\n我不懂，但我大受震撼。\nFFTW 的有趣之处在于，它并非直接进行计算，而是有一套所谓的 plan 机制。据 FFTW 的文档介绍，fftw_plan 在创建时将会根据电脑配置、使用环境、问题大小等多种因素来选择最合适的傅里叶计算方法，并将该方法连带数据一同存储起来。当需要进行傅里叶变换计算时，只需要执行该 plan 就可以完成一次变换。由根据文档介绍，fftw_plan 有多种寻找近似最佳方案的策略，有时生成的算法人类也看不懂是怎么算的，但是依旧可以正确计算。这就很玄幻了，但是我愿意相信它。\n如此强大的工具，其基础使用方法反而出乎意料地简单。我们下面给一个例子：\n1#include \u0026lt;stdio.h\u0026gt; 2#include \u0026lt;math.h\u0026gt; 3#include \u0026lt;fftw3.h\u0026gt; 4 5int main(void) { 6 int N = 8; 7 8 /* Allocate input and output arrays. 9 fftw_complex is a double[2]: [0] = real part, [1] = imaginary part. */ 10 fftw_complex *in = fftw_malloc(sizeof(fftw_complex) * N); 11 fftw_complex *out = fftw_malloc(sizeof(fftw_complex) * N); 12 13 /* Create a plan BEFORE filling the input. 14 FFTW_FORWARD = forward transform (sign -1 in the exponent). 15 FFTW_ESTIMATE picks a plan quickly without timing measurements. */ 16 fftw_plan plan = fftw_plan_dft_1d(N, in, out, FFTW_FORWARD, FFTW_ESTIMATE); 17 18 /* Fill the input: a simple cosine wave of frequency 1. */ 19 for (int i = 0; i \u0026lt; N; i++) { 20 in[i][0] = cos(2.0 * M_PI * i / N); /* real part */ 21 in[i][1] = 0.0; /* imaginary part */ 22 } 23 24 /* Run the transform. */ 25 fftw_execute(plan); 26 27 /* Print the result. */ 28 for (int i = 0; i \u0026lt; N; i++) { 29 printf(\u0026#34;out[%d] = %7.3f + %7.3fi\\n\u0026#34;, i, out[i][0], out[i][1]); 30 } 31 32 /* Clean up. */ 33 fftw_destroy_plan(plan); 34 fftw_free(in); 35 fftw_free(out); 36 return 0; 37} （这个例子是让 AI 帮忙写的，原谅笔者的懒惰吧，示例代码这种活儿 AI 真的比笔者做的好得多）\n可以看到，在引入头文件后，我们首先用 fftw_malloc 来创建数组，它实际上就是 malloc 的 fftw 特化版。然后就可以创建 fftw_plan 了。值得注意的是，必须要先创建 fftw_plan 再向数组中填入适当的值，这是因为 fftw_plan 在创建过程中会向数组中写入一些值来尝试最快的计算方案，因此创建 fftw_plan 之后原数组的数据会被污染。因此正确的做法是在创建好 fftw_plan 之后再初始化数组。\n在向数组内填入一些值后，我们只需要用 fftw_execute 执行前面创建的 plan 就可以完成一次傅里叶变换了。另外，最后我们一定要使用正确的内存释放方式，采用 fftw_malloc 的就应该用 fftw_free 来释放，而创建的 fftw_plan 由于也是个指针，因此应该使用 fftw_destroy_plan 来释放。错误使用 free 的话会导致程序异常退出，fftw_plan 会滞留在内存中延迟销毁，总归算是个 bug。\n要编译运行上面的代码，需要注意我们使用了 \u0026lt;math.h\u0026gt; 这个库。比方说源文件是 main.c 的话，在编译时需要使用\n1\u0026gt; gcc main.c -lfftw3 -lm 才能正确编译，否则链接器会报错，说找不到一些符号的定义。尤其需要注意的是，-lfftw3 和 -lm 的顺序问题，由于 FFTW 使用了 \u0026lt;math.h\u0026gt;，因此要将我们自己的代码与 FFTW 的公共依赖放在后面传入，即先 -lfftw3 链接上 FFTW，再 -lm 链接公共依赖 \u0026lt;math.h\u0026gt;。这个顺序如果反过来就不行了。也算是一个小小的坑吧。\n除了上面的例子之外，其实 FFTW 的用法远不止一个一维傅里叶变换。实际上，它支持多种和傅里叶变换相关的离散变换，有我们用得上的多维傅里叶变换，也有我们用不上的一些所谓正弦变换和余弦变换，还有用起来稍微有点麻烦的实数组傅里叶变换。我们这里就不再赘述了，我们的下一篇内容就会用到它来实现使用傅里叶伪谱法的调幅分解模拟，敬请期待~\n后记 天哪，鬼知道我这篇博客拖了多久……本来计划在五一劳动节前就发出来的番外篇，结果硬是拖到了儿童节前！？这期间笔者经历了放假回家，收假打包行李，冲向德国进行学术交流，人生地不熟买不到不带气泡的饮用水，以及不稳定到可怕的网络与该死的鬼天气，最后还是成功地在一个百无聊赖的下午（天还没黑就是下午）把这篇写好了。\n即便是现在写完了，回看已有的内容，不免觉得有点啰嗦，有点既要又要：既想介绍傅里叶变换的数学背景，又想讲清楚离散傅里叶变换的具体实现，还想把 FFTW 这个好用的数学库介绍一遍。如果您的观感不佳，请轻喷，留点情面呜呜呜。在写数学背景的过程中笔者也算是查阅了大量资料了，我想我是搞清楚了这里面的一些门道，但不知表达出来的结果是否简单易懂。为了尽可能讲出我的想法，也是再次使用了 React 来做了一些可视化。再次感谢伟大群友 開源 lib 的支持！没有 React 的话，瓦塔西！\n关于 FFT 的实现（以及用我自己的破烂轮子和 FFTW 做调幅分解）的过程，说实话笔者受益匪浅。有些算法不是自己写一遍是根本不能明白它到底在做什么的，笔者也是在 AI 的帮助下，一边问一边自己写，最后写出来了 FFT 的 Cooley-Tukey 循环算法，以及用它实现二维调幅分解的傅里叶伪谱法模拟。而且，由于按照文章计划，是会用 C 语言 实现最新一期的调幅分解模拟，因此一不做二不休，直接用 C 写了 FFT 的实现，在由衷感慨 C 语言强大的同时，也感受到了 C 语言那种 With great power, comes much greater responsibility 的感觉，一不小心就忘记检查空指针（其实 AI 写的代码就忘记在 fftw_malloc 后检查空指针），忘记主动 free 指针，错误使用 free 去释放 fftw_plan……类似的问题数不胜数。进一步地，就感受到了 C++ 的优势：RAII 在很大程度上避免了这样的内存管理问题，反正符合 RAII 的类型在离开作用域时就会自动调用析构释放资源，真的是相当省心。除此之外，也是感受到了 C 程序员的智慧，在缺少一众现代语言特性的情况下，可以通过多种方式灵活巧妙地运用指针来实现自己的目的，确实称得上“好学”。\n那么，感谢您读到这里，希望您在阅读本文的过程中有不错的心情。下一篇的内容就是使用 C 语言和傅里叶伪谱法来模拟调幅分解了，这也是本番外的主要目的：给下一篇做铺垫，免得内容量太多太繁琐（虽然这篇已经很繁琐了），敬请期待！最后，一如既往，祝您身心健康，工作顺利，天天开心，能喝到便宜又解渴的水。\nhttps://www.bilibili.com/video/BV1pW411J7s8/ 3Blue1Brown 的一系列视频都谈及了傅里叶变换\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nhttps://www.bilibili.com/video/BV1eUHjzgEAd/ 漫士沉思录 也做了对傅里叶变换的介绍\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nhttps://numerical.recipes/book.html 它的第 12 章介绍了快速傅里叶变换的相关背景。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n这实在是一个非常复杂的话题。因为我们只是想用它作为一种方法，我们这里不深入了解傅里叶级数和傅里叶变换的充分条件。感兴趣可以参考一些高等数学或数学分析教材。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n这又是另一个非常复杂的话题。傅里叶积分和傅里叶变换，两个名字不可能完全代表同一个东西，它们到底有什么区别？感兴趣可以参考 Stack Overflow上的讨论。总的来说，傅里叶变换一般都以复数形式表示，而二者的区别总体而言有两种看法。一种认为傅立叶积分是三角函数形式的展开，而傅里叶变换则是复数形式的函数积分变换，另一种则认为傅里叶积分只是一种积分的形式，而傅里叶变换是在 $L^2$ 空间上的 线性等距同构。前者更偏向形式，一些教材会采用这种讲法，而后者更偏调和分析/傅里叶分析的说法。我们采取前者的说法。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n我们这里就应用了 积分=三角函数形式，变换=复数形式 的说法。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2026-05-30T00:00:00+08:00","image":"/images/Alice-2.png","permalink":"/zh/posts/pf_note/impl_spinodal/impl_spinodal_fourier/","title":"相场模拟，但是用很多语言——番外"},{"content":"我们已经用了 C++ 和 Python 来进行相场模拟，除了这种典型的“后端”语言之外，前端能不能跑相场模拟呢？答案是肯定的！我们这次就试试 鼎鼎大名的 JavaScript 和 TypeScript 吧~\n为保持系列的统一，头图我们依旧选择了上期出现的，由 Neve_AI 绘制的 AI 爱丽丝。选曲则是最近（怎么这么多最近）很喜欢的 ラプラスショコラ(Laplace Chocolate)，由 Kai 作词曲，初音未来献唱。活泼可爱，甚至某种程度有点切题（Laplace \u0026lt;-\u0026gt; Laplacian）？希望您也喜欢~\n从浏览器讲起 这次我们把目光放在 JavaScript 和 TypeScript 这两门语言上，因为基础的调幅分解的模拟相信在前两期中已经聊得很多了。而要讲 JavaScript 和 TypeScript，就不得不提我们早已熟悉的互联网入口：浏览器。\n非常好浏览器技术 我们的生活已经充满了各种各样的浏览器了。从大家了解的知名浏览器如 Google Chrome，Microsoft Edge，Mozilla Firefox，Apple Safari 和一些大家也许尝试过的 UC/QQ/360 浏览器，到藏在软件背后的浏览器，如众多的安卓软件，许多的看起来拥有现代 UI 风格的桌面端应用等等，甚至是平时吃饭点单用的小程序，它们都是各式各样的浏览器。笔者写这篇博客使用的 VS Code 就是用 Electron 这个桌面应用框架写成的。如果您的电脑上正好有 VS Code，您可以从 Help-\u0026gt;Toggle Developer Tools 来打开一个和 Google Chrome 的开发者工具别无二致的页面。\n唉，怎么现在什么服务都在千方百计让用户下载手机应用或者从微信小程序打开呢？真麻烦呀！如果有一个东西能够把这些东西全都统一起来，那该多好呀！（恭喜你重新发明了浏览器）\n为什么浏览器如此流行呢？我想这主要得益于浏览器的技术具有众多的优势：\n技术结构清晰。人们常说 前端三剑客（我们稍后会谈到什么是前端，以及对应的后端）：HTML，CSS 和 JavaScript，这三者分别提供了内容描述，界面样式以及交互逻辑，再加上网络与后端服务器的通信，这些让浏览器生态变得 近乎万能，让许多想法都得以在它上面实现。这样优秀的结构设计也方便了人们做网络开发，而这带来的第一个大优点便是： 好看。这几乎毋庸置疑，当前的浏览器页面近乎百花齐放，许多伟大的平面设计都在浏览器上得到了空前的发挥，而为了支持这些设计的发挥，浏览器前端技术也在持续不断地发展，以支持越来越多的效果。CSS 的神奇效果与 JavaScript 对页面元素近乎绝对的掌控能力让前端三剑客几乎可以实现任何能想到的效果，区别大概只在于难度与延迟。 移植性强。几乎没有某个桌面操作系统没法安装浏览器，而只要能安装浏览器，浏览器相关的那些技术就都可以借助诸如 Node.js 这样的本地 JavaScript 运行时和诸多 WebApp 框架来在桌面环境运行 JavaScript 代码，成为一个好看好用的应用程序。 生态丰富。这点就比较顺理成章了，当一个东西好用的时候，大家自然就都会涌向这个技术，为它添砖加瓦。这意味着，很多功能我们不需要自己去实现，可以用现有的工具去做，尤其是当我们设计 UI 的时候，同时也意味着前端开发的门槛正在逐步降低。再加上 AI 技术的加持，像我这样的小白也敢对着我的博客用 AI 进行魔改了。 太伟大了，浏览器！然而说的是天花乱坠，前端/后端究竟是什么呢？\n让我前后端旋转 因为笔者对网络开发并不算了解，这里只能浅谈自己的一点愚见，如有疏漏还望不吝赐教。在笔者看来，前端和后端是相对的，它们相互配合来向用户提供完整的服务。其中的前端代表的是 和用户交互的部分，一切用户能看到的，摸到的，直接交互的东西，都应该被划分到前端里。而在用户期望获取服务时，比如点击一个按钮之后，负责 处理按钮背后代表的业务逻辑 的则成为后端。\n在这样的理解下，实际上前端和后端应该是某种逻辑处理的职责模型，且这样的区分可以不局限于网络开发。比如 Python 桌面应用编程，我们可以用 PyQt 来描述应用有哪些组件而不关心它们要具体干什么，只留下一些接口，随后在别处实现点击按钮之后要执行的业务处理，不用关心这个功能要怎么出现在用户面前。我们也不必拘泥于桌面程序，比如设计一个用在命令行中的 TUI（文本用户界面）程序，那么命令行界面就是对应的前端；设计一个像 gcc 这样的 CLI（命令行界面）程序，那么如何合理地传入参数并解析就成为对应的前端问题。\n但是这很显然不符合我们对平时 前端开发 的印象。当我们说前端开发时，我们在谈什么呢？也许我们提到的主要是如何在浏览器上和用户进行交互。这包括如何设计页面的元素（出现什么文字，放入什么图片，有什么按钮文本框），元素应该出现在页面什么位置，以及按下按钮时应该做什么。\n这里你也许会问：前两点能理解，第三点是为什么？按下按钮的情况为什么还需要前端去考虑？那不是后端的问题吗？这就要引出在网络开发中后端是什么了。一般在按下某个按钮的时候，大概率有两种情况，一种是在页面中的操作，比如切换一下页面风格，跳转到某个位置之类，第二种则是要 与服务器进行通信 的操作。最常见的如用户注册，当一个用户要注册时，填写表单时前端负责将元素漂亮地展示出来，让用户理解应该做什么，并提供最基础的表单检查；而当用户按下 注册 的按钮时，前端要负责的事情则是：让用户知道它按下了 注册 的按钮（也许可以变灰或者什么样），以及 通知后端服务器登记这个条目。在这个情境下，后端则是一个数据库服务。\n一般来讲，前端就是 HTML，CSS 和 JavaScript 这三位了，\n那么，前端怎么执行诸如“按钮按下后该做什么”的逻辑呢？前端如何与后端通信呢？这就要请出今天的主角：JavaScript 与 TypeScript 了。\nJavaScript 与 TypeScript 不论是从历史角度还是从逻辑关系，我们都应该先来讲讲这个名字奇怪的 JavaScript。\n神秘的命名与成功的营销 相信许多不了解 JavaScript 的人都或多或少地因为在某些地方听说过 Java 而尝试将它和 Java 联系起来或者简称 JavaScript 为 Java。然而这实在是一个非常幽默且有趣的误会。\n在 1993 年，网景（Netscape）公司的创立人们开发了图形用户界面的浏览器 Mosaic，这款浏览器和其后继者 Navigator 大获成功，但很快人们对浏览器的需求就不仅限于“浏览”了。为了能够让浏览器页面在加载完成后有一些动态相应效果，1995 年网景雇佣了 Brendan Eich，要求他在浏览器中实现 Scheme，一门脚本语言 Lisp 的方言。然而与此同时，网景又计划着与开发了 Java 的太阳微系统（Sun Microsystems，后来被甲骨文 Oracle Corporation 收购）合作，将它们的 Java 嵌入到 Navigator 中，以此来实现网页的动态功能。两方对比竞争下，网景高层最终决定还是选择使用脚本语言来实现，让这门语言扮演“胶水”的功能，但需要有与 Java 相似的语法，且轻量，易用。Eich 在 1995 年 5 月花了 十天 时间完成了原型设计，并给了它 Mocha 这个名字。随后，网景的市场部门将名字改成了 LiveScript，在当年的十一月正式随 Navigator 推出，但在十二月时又改名为了 JavaScript，蹭上了当时如日中天的 Java 的名头1，从此就用着这个名字直到今天。\n所以，很难说 JavaScript 和 Java 一点关系都没有，但是这层关系大概也只到了 JavaScript 曾经参考过 Java，而且为了能够更好地实现商业化，JavaScript 有意地选择了这个名字吧。但是，目前为止似乎 JavaScript 是专供 Navigator 浏览器使用的脚本语言，但现在什么浏览器都在用这个语言，这中间又发生了什么？其实，有关 JavaScript 名字的故事依旧没有结束。\n也许你在某些地方看到过 ECMAScript 的名字，或者在 Windows 系统里看到过一个丑丑的图标。实际上，在 JavaScript 随着 Navigator 浏览器迅速风靡全球之后，在 1996 年 11 月网景公司便与欧洲计算机制造联合会（European Computer Manufactures Association, ECMA）举行了会议，着手对这个语言进行标准化，且被定义在标准文件 ECMA-262 中。随后该标准也通过 ISO 标准，成为 ISO-16262 2。\n作为一份标准，ECMAScript 标准只要求了如何实现这类脚本，因此应该说，如果要实现自己的符合 ECMAScript 标准的脚本引擎（比如 Firefox 的 SpiderMonkey 或者 Chrome 的 V8 引擎）时，我们才需要参考这份标准，而我们常用的 JavaScript 是 ECMAScript 的原型也是一种实现。而其他的实现中，微软实现的那一份叫做 JScript，就是那个丑丑的图标的文件；也有一些别的，比如 Adobe 手上的 ActionScript 等。不过总的来说，应用最广泛的还是 JavaScript，当大家提到 JS 的时候大概率也是提的那位老资历，JavaScript 了。\nTypeScript: JavaScript，但是静态类型 事实上，在人们发现什么地方都能塞个浏览器的时候，JavaScript 就已经自然地流行起来了。谁不想要美丽的 GUI 界面和炫酷的交互逻辑呢？这实在是太酷了！符合我对 21 世纪美丽互联网的想象。但是这带来了一个问题：想要写出一个灵活，好用，功能强大的基于 JavaScript 桌面程序，其代码量是可想而知的庞大的。但是我们亲爱的 JavaScript 是一门动态，弱类型的语言。我们看看这个著名的地狱绘图：\n很可怕吗？是的很可怕……JavaScript 会贴心地帮你做很多的类型转换，同时贴心地告诉你某两个写法完全一样的东西其实是不一样的。非常地伟大。\nBut, Y？JavaScript 为何？因为 JavaScript 本身就是一个轻量的快捷语言。这样等于或不等于的背后，其实是类型转换系统在发力。但是即便 JavaScript 背后真的有一套类型系统，在这样的神秘转换下，人们也很容易认为这门语言根本没有什么类型可言。这样的小缺点在处理简单逻辑和小型应用时还算 Okay，但要是考虑使用 JavaScript 去写什么特别复杂的应用逻辑，那我相信没有类型提示的结果就是 Bug 四面开花，神秘报错以及崩溃的秃头程序……\n但是我们可以想办法让它有类型呀！没错，微软就是这么想的。2012 年的 10 月 1 日，TypeScript 横空出世。它的目标很简单：让 JavaScript 拥有类型标注。它的做法很简单：写的 TypeScript 脚本将会被静态类型检查器会保证代码没有类型问题，随后就会被 编译 为对应的 JavaScript 代码，通过把所有的类型标注删掉。但是它的结果很强大：它解放了 JavaScript 缺乏静态检查的桎梏，让前端技术能够进一步应用在更大体量的程序中。\nNode.js，pnpm 与 React 太棒了。那么我怎么才能够运行 JavaScript 和 TypeScript 代码呢？既然它和浏览器有千丝万缕的联系，是不是我可以在浏览器里直接运行 JavaScript？没错，是这样的，但也不完全是。在浏览器中运行 JavaScript 代码的方式是在网页中插入 \u0026lt;script\u0026gt;\u0026lt;/script\u0026gt; 这样的标签，然后在里面运行。但这样的方式多少是有点别扭了。也可以通过开发者工具的 Debug Console 来写两句，但这应该更别扭了……有没有什么可以像 Python 解释器那样的工具来在电脑上不依赖浏览器地运行 JavaScript 代码呢？\n有的，兄弟！有的！向您介绍 Node.js，一款免费开源跨平台的 JavaScript 运行时环境。Node.js 让 JavaScript 得以独立于浏览器运行，赋予了 JavaScript 作为后端开发语言的能力。要用 Node.js 运行 JavaScript 代码，只需要 node hello_world.js 即可！就像 python hello_world.py 那样，甚至少写两个字母！\n此外，Node.js 还拥有现代包管理系统，npm，来为 Node.js 或者任何 JavaScript 项目提供包管理支持。npm 提供了现代的 file locking 机制，用以确定项目的依赖版本并允许通过 npm install 来自动识别并安装项目依赖。更重要的是，npm 是默认将依赖安装在项目文件夹而非全局的。这自动地提供了环境分隔，避免了依赖冲突，与必须手动创建虚拟环境的 Python 相比 Node.js 的做法更显优雅。\n不过，我们并不打算使用最传统的 npm，而是使用更先进的包管理器 pnpm，它提供了更优秀的包管理和依赖解析，让依赖的安装速度更快且支持通过符号链接节省包占据的空间。另外就是我因为某些我也忘了的原因安装了 pnpm，而 npm 则只装了一个 pnpm，那我们干脆就让他依旧单纯下去吧~\n而要运行 TypeScript 代码，我们采用 pnpm 来安装 typescript 包并安装 tsx 这个工具。tsx 可以直接运行 TypeScript 代码，不需要先“编译”为对应的 JavaScript 代码。最后，为了在本地能够打开临时端口观察渲染情况，我们再安装 vite 这个工具。\n我们前面聊了这么多前端与 JavaScript，那么用 JavaScript 跑出来的模拟结果肯定应该用漂亮的前端漂亮地展现出来漂亮的结果吧！就这么干！我们介绍 React，一款 JavaScript 前端组件库，用来搭建美丽的用户界面。React 允许用户使用可复用的组件来设计用户界面，并提供了多种钩子（Hook）来管理控件状态与副作用等。虽然笔者不会 React，但是笔者会让 AI 写 React 呀！再加上美丽群友 開源 lib 已有的 React 实例代码，我的评价是：\n前端，易如反掌口牙！！！\n行了，别废话了，赶快进入今天的正题吧！\nJavaScript 的实现 我们照旧，第一版就直接复刻 C++ 的初版程序，用来熟悉这门语言的基础写法吧！\n好用的语法真不错！ 首先我们还是设计一个周期网格循环的函数：\n1function mesh_periodic(u, ker_fun, Nx, Ny, dx, dy, A, kappa) { 2 let new_mesh = new Array(u.length).fill(0); 3 const inv_dx2 = 1 / (dx * dy); 4 5 for (let j = 0; j \u0026lt; Ny; j++) { 6 for (let i = 0; i \u0026lt; Nx; i++) { 7 const left = u[j * Nx + ((i - 1 + Nx) % Nx)]; 8 const right = u[j * Nx + ((i + 1) % Nx)]; 9 const down = u[((j - 1 + Ny) % Ny) * Nx + i]; 10 const up = u[((j + 1) % Ny) * Nx + i]; 11 const center = u[j * Nx + i]; 12 new_mesh[j * Nx + i] = ker_fun( 13 left, 14 right, 15 up, 16 down, 17 center, 18 inv_dx2, 19 A, 20 kappa, 21 ); 22 } 23 } 24 25 return new_mesh; 26} 可以看到我们声明函数的时候使用了 function 关键字。且依旧没有添加任何的类型。我偶尔还是挺喜欢这种不需要写类型的做法的，特别是清楚每个参数具体是在做什么的时候。而一个 function 关键字就能够很清晰地说明这是个函数，真不错。\n另外就是 JavaScript 的流程控制语句写法和 C/C++ 真的是一模一样！我很喜欢，这种写法结构清晰逻辑严密，非常好设计。而它进一步超越 C/C++ 的点在于：语句最后的分号 ; 不是必须的。这实在是非常好的消息，特别是对我这种经常忘记写分号的笨蛋。更好的消息是，VS Code 可以聪明地自己帮你把分号加上，只要在设置中打开这个开关即可，不过我这里就不开了，因为默认是不开的（）\n其次，在 JavaScript 中的数组使用的是 Array 关键字，且创建新数组需要使用 new 关键字，后带对象的构造函数列表。这一点我就不是特别喜欢了：这让我想到了 C/C++ 中的同名关键字 new，但在 C/C++ 中是用来在堆上创建对象，而在现代 C++ 中我们是不推荐使用 new 关键字而是使用更符合 RAII 的方式去管理资源。也许是因为 JavaScript 诞生的时候已经约定俗成这么写吧？不过问题不大。\n接下来值得注意的是声明变量使用的关键字。这里我们使用了 let 和 const 两种写法，第一种是一般的变量声明写法，而第二种则是常量。这么写的主要目的是方便让引擎做代码优化。然而，更推荐的其实是将 Array 声明为 const 变量。你也许会好奇为什么这样一个频繁变动的变量反而需要用 const 来定义，但是这里的 const 实际上表示的是这个 名称 不能被绑定在别的变量上，而并不影响变量内部的变动。也就是说，我们现在创建的这个 Array 如果用 let new_mesh 声明的话，我们下一行就可以让 new_mesh 变成 1 这么个数字；而如果我们用 const new_mesh 的话，new_mesh = 1 就会出错了，因为我们尝试将新的值绑定在这个名字上。\n最后我们利用了一些小巧思。这里可以看到我们没有用边界判断的语法，而是执行了一次取模运算，这是利用了取模的性质，让内部值取模后不变，右边界右侧的下标在取模后自动成为 0，而 -1 在加上网格长度后取模就得到右边界的值。最后我们依旧用核函数进行运算并返回运算的结果。\n计算 Laplacian 和能量求导的函数我们就不赘述了，我们看看怎么将结果输出到文件里。Node.js 为我们提供了 fs 这个模块，我们只需要 import fs from \u0026quot;node:fs\u0026quot; 就可以了。而 write_VTK 函数我们则这么写成：\n1function write_VTK(mesh, istep, Nx, Ny, dx, dy, folder_name) { 2 const vtkContent = `# vtk DataFile Version 3.0 3Spinodal Decomposition Step ${istep} 4ASCII 5DATASET STRUCTURED_GRID 6DIMENSIONS ${Nx} ${Ny} 1 7POINTS ${Nx * Ny} float 8${Array.from({ length: Ny }, (_, j) =\u0026gt; 9 Array.from({ length: Nx }, (_, i) =\u0026gt; `${i * dx} ${j * dy} 0`).join(\u0026#34;\\n\u0026#34;), 10 ).join(\u0026#34;\\n\u0026#34;)} 11POINT_DATA ${Nx * Ny} 12SCALARS CON float 13LOOKUP_TABLE default 14${mesh.join(\u0026#34;\\n\u0026#34;)} 15`; 16 17 fs.writeFileSync(`${folder_name}/step_${istep}.vtk`, vtkContent); 18} 你也许会惊讶，怎么这份代码这么短？其实是我们大量应用了 .join 函数来取代了传统的二重循环。在 JavaScript 中存在三种字符串写法，其中 ' 与 \u0026quot; 的效果相似，而当需要使用多行文本时可以 用 ` 来开启多行文本。而要是需要进行格式化输出，我们可以直接使用 ${}，在里面写入表达式。这里我们就把输出格点的逻辑用一个表达式写出：\n1Array.from({ length: Ny }, (_, j) =\u0026gt; 2 Array.from({ length: Nx }, (_, i) =\u0026gt; `${i * dx} ${j * dy} 0`).join(\u0026#34;\\n\u0026#34;), 3 ).join(\u0026#34;\\n\u0026#34;) 这一句乍一看有点难懂，是因为我们利用了 JavaScript 的两个有趣的特性。\n-（几乎）一切皆对象！\n首先我们介绍 Array 这个数据结构。你也许好奇，一个数组有什么好介绍的，但是在 JavaScript 里，数组也是从 对象 来的，是一类对象包装起来的语法糖。我们可以用 console.log(typeof([])); 来检验，它返回的是 object，而不是 List 或者 Array 之类的。JavaScript 里的对象是什么呢？而 Array 是什么样子的对象呢？\n在 JavaScript 里，对象其实就是一个字典。它有许多的键值对，每个键都是独一无二的。而 Array 则是 以 0 开始的整数为键，且拥有 length 属性的对象。所以，{length: 5} 就已经是一个没有被初始化的，长度为 5 的 Array 了。\n那 Array.from() 又是什么，为什么里面出现了 =\u0026gt; 这样的符号？\n一等公民：函数\n事实上，Array.from() 从字面意思能感觉出，它应该是从某个源头来创建的函数。查看它的帮助信息，可以看到：\n1(method) ArrayConstructor.from\u0026lt;any, string\u0026gt;(iterable: Iterable\u0026lt;any\u0026gt; | ArrayLike\u0026lt;any\u0026gt;, mapfn: (v: any, k: number) =\u0026gt; string, thisArg?: any): string[] (+3 overloads) 前面的 { length: Ny }，按照我们之前说的，应该是符合 ArrayLike\u0026lt;any\u0026gt; 的类型的那个 iterable，而后面的 mapfn 从名字来看应该是个函数。没错，这里我们使用了 JavaScript 的第二种函数声明方法，又称为 箭头，或者用另一个名字，Lambda 表达式。\n在 JavaScript 中，箭头这么定义：(param1, param2) =\u0026gt; {statement1; statement2; return value;}，另外还添加了一些语法糖，比如当参数列表只有一个非临时对象的参数时，可以不写括号（临时对象的话要写上括号来区分后面的大括号），或者当函数只需要执行一行计算并返回时，可以不写大括号和 return 关键字，直接写出值即可。JavaScript 作为一门支持并鼓励使用函数式编程的语言，箭头的使用远比目前您可能看到的更广泛。这里只是一个简单的示例。\n那么，Array.from() 这个函数应该怎么理解它的参数列表呢？实际上它接受的三个参数中，最后一个带了问号，说明是可选参数，我们这里用不到所以不管了；另外两个参数里，iterable 这个参数将会被调用 .map() 方法，而 .map() 方法接受的回调函数则是之类的 mapfn，而 mapfn 的类型要求接受 v 和 k，分别对应 iterable 中的值和这个值的键，最后这个回调函数需要返回一个字符串。\n也就是说，我们需要在 iterable 中填入我们生成 Array 的源，然后通过 mapfn 的回调函数定义从这个 iterable 的每个键值对生成新 Array 中的值的方法。大致逻辑如下图所示：\n回到我们的输出逻辑，可以看到这里的映射函数 mapfn 选择了从 (_,j) 映射到新 Array 的写法，而我们只需要返回这个新的 Array 所以没有写大括号和 return，直接用同样的方法创建了一个 Array，新 Array 中每个位置都是一个字符串，而字符串的内容则用到了两个下标 i，j和 dx 与 dy，最后我们通过 .join() 方法将每个位置的字符串用 \\n 换行符串起来，填入外层列表元素，再将外层的所有值再用 \\n 再串起来，就得到了一个巨大的字符串。\n这里 (_,j) 中 _ 的含义是我们不需要用这个值，因为我们主要关注的是列表的下标，或者说我们其实只是想生成从 0 开始到 Ny 前结束的整数序列罢了。另外这个写法并不是我自己写的，而是 AI 帮我做的。非常好写法，让我头脑旋转，也算是见识到了 JavaScript 回调的强大吧。\n事实上，JavaScript 是明确将函数对象作为 一等公民，与其他对象平起平坐的。这样的优点在于我们可以更自由地将函数作为参数传递给别的函数。其他语言也可以做到这一点，但是它们的应用方式或者语法限制方面还是不如 JavaScript 来的方便。\n我们回到正题，接下来便是写我们的计算逻辑了。这一步可聊的不多，主要在于 JavaScript 自带的数学库 Math 和性能检查工具 performance 便于我们实现随机数噪声与记录计算用时。这里我们的主网格 con_mesh 的定义再一次用到了回调函数：\n1let con_mesh = new Array(Nx * Ny) 2 .fill(0) 3 .map(() =\u0026gt; con_init + 2 * dcon * (Math.random() - 0.5)); 这里我们先 .fill(0) 的原因是要先填好这个网格后再考虑进行映射，而映射规则也很简单，就是不管传入的参数，直接填入数值即可。\n第一版的完整代码可以从 这里 得到，欢迎尝试运行。笔者使用的 Node.js 版本是 v24.14.0，计算时间大概是 1420 毫秒左右。可以看到这份代码的运行速度还是相当不错的。谢谢你，V8（Node.js 使用的是谷歌的 V8 引擎）！\n品尝面向对象 接下来我们品尝一下 JavaScript 的面向对象写法。JavaScript 中的类声明很特别，其中的所有方法都不用 function 关键字即可定义，且拥有一个特殊方法 constructor 作为构造函数。为类定义属性则需要使用 this.attribute 这样的写法。另外，我们这次打算做一点小改变，使用面向对象中的 继承，让某些类作为 基类 并派生出 子类，子类可以继承基类的属性和方法，也可以改写基类的方法。这样做的好处在于可以提取一些对象的公共部分，且在传入对象的时候可以限定只对某些公共性质做操作。\n在 JavaScript 里继承使用 extends 关键字，使用 super() 来调用基类的构造函数。我们就用边界条件来做示范：\n1class BoundaryCondition { 2 constructor(pos, type) { 3 this.type = type; 4 this.pos = pos; 5 } 6} 7 8class PeriodicBC extends BoundaryCondition { 9 constructor(pos) { 10 super(pos, \u0026#34;periodic\u0026#34;); 11 } 12 apply(mesh) { 13 const stride = mesh.Nx + 2; 14 if (this.pos === \u0026#34;north\u0026#34;) { 15 for (let idx = 0; idx \u0026lt; mesh.Nx; idx++) { 16 mesh.data[(mesh.Ny + 1) * stride + idx + 1] = 17 mesh.data[1 * stride + idx + 1]; 18 } 19 } 20 if (this.pos === \u0026#34;south\u0026#34;) { 21 for (let idx = 0; idx \u0026lt; mesh.Nx; idx++) { 22 mesh.data[idx + 1] = mesh.data[mesh.Ny * stride + idx + 1]; 23 } 24 } 25 if (this.pos === \u0026#34;east\u0026#34;) { 26 for (let idx = 0; idx \u0026lt; mesh.Ny; idx++) { 27 mesh.data[(idx + 1) * stride + (mesh.Nx + 1)] = 28 mesh.data[(idx + 1) * stride + 1]; 29 } 30 } 31 if (this.pos === \u0026#34;west\u0026#34;) { 32 for (let idx = 0; idx \u0026lt; mesh.Ny; idx++) { 33 mesh.data[(idx + 1) * stride] = mesh.data[(idx + 1) * stride + mesh.Nx]; 34 } 35 } 36 } 37} 可以看到，我们提取了边界条件最重要的性质，即我们要赋予的边界的方向，并且将边界类型也一并记录在 BoundaryCondition 内部，随后让 PeriodicBC 继承 BoundaryCondition，给它一个新的方法 apply()来赋予边界条件。注意到这里我们使用了 stride = mesh.Nx + 2，这是为了方便我们在网格内部计算 Laplacian。下面有一个示意图：\n可以看到，在主网格（绿色）部分的四周我们加上了蓝色的部分，这部分作为 边界 使用，在每次进行网格范围的计算之后单独按照对应规则进行赋值。这样的做法能保证在绿色部分的时候可以直接按照既定计算流程直接计算，避免了边界判断。不过坏处就是我们必须用 stride 这样的方式来跨行取点，且做循环的时候需要多考虑一点循环的起始点和结束条件，写起来稍微费一点功夫就是了。\n那么，我们能够再在这个 BoundaryCondition 的基础上派生出新的边界条件吗？当然可以！我们这里再实现一个固定边界条件，FixedBC，作为例子。\n1class FixedBC extends BoundaryCondition { 2 constructor(pos, value) { 3 super(pos, \u0026#34;fixed\u0026#34;); 4 this.value = value; 5 } 6 apply(mesh) { 7 const stride = mesh.Nx + 2; 8 if (this.pos === \u0026#34;north\u0026#34;) { 9 for (let idx = 0; idx \u0026lt; mesh.Nx; idx++) { 10 mesh.data[(mesh.Ny + 1) * stride + idx + 1] = this.value; 11 } 12 } 13 // Other three boundaries 14 } 15} 可以看到，这次我们的 consturctor 多了一个参数，value，用来存储边界的固定值。然而有趣的是，我们拥有同样只接受一个 mesh 的边界赋值函数 apply()，这保证了调用方式的统一。\n实际上，我们其实只是想定义一些赋予边界的函数，而这些函数有的不需要参数，有的需要一两个数字作为参数，有一些可能需要别的更复杂的类型作为参数。我们通过将它们打包成类并暴露统一的接口的方式，成功实现了先 设定边界条件，随后不用关心具体的边界条件，只需要用 统一方式 去应用边界条件即可。随后我们会再次见到这个设计模式。\n为了方便统一边界赋值，我们设计了 BoundaryConditions 这个类，用来装载边界条件并统一赋值。接下来我们将给网格赋予初始值的过程抽象为初始几何结构 InitGeometry，并从它派生出 RandomInit 的随机赋值方法，方式类似于上面的边界条件。再下来就是设计网格，它包含的信息其实和之前的大差不差。我们直接来看能量设计：\n1class BulkFreeEnergy { 2 constructor(type, params) { 3 this.type = type; 4 this.params = params; 5 } 6} 7 8class DoubleWellBulk extends BulkFreeEnergy { 9 constructor(A) { 10 super(\u0026#34;double_well\u0026#34;, { A }); 11 } 12 dbulk_dc() { 13 return (center_val) =\u0026gt; 14 2 * this.params.A * center_val * (1 - center_val) * (1 - 2 * center_val); 15 } 16} 还是一样，我们设计了基类 BulkFreeEnergy 并让 DoubleWellBulk 继承自它，在初始化时添加参数 A 并设计 dbulk_dc 函数作为统一接口。这里值得注意的是，dbulk_dc 返回的不是单纯的值，而是返回了一个 箭头。这么做的目的其实很简单，因为最后参与计算的应该是核函数，而我们如果直接传入 dbulk_dc 的话，就必须提前设计函数要接收的参数列表。通过让它返回一个函数对象（箭头），我们可以直接将这个函数的调用结果放在后面的求解器初始化步骤中。\n最后的求解器，我们就不再做进一步抽象了，CH_Solver 的定义如下：\n1class CH_Solver { 2 constructor(mesh, M, dbulk_dc, dint_dc, others = undefined, dt) { 3 this.mesh = mesh; 4 this.M = M; 5 this.dbulk_dc = dbulk_dc; 6 this.dint_dc = dint_dc; 7 if (others !== undefined) { 8 this.others = others; 9 } 10 this.dt = dt; 11 } 12 dF_dc() { 13 return this.mesh.output_mesh( 14 (left_val, right_val, up_val, down_val, center_val, inv_dxdy) =\u0026gt; { 15 const dbulk = this.dbulk_dc(center_val); 16 const dint = this.dint_dc(left_val,right_val,up_val,down_val, 17 center_val,inv_dxdy, 18 ); 19 let dF = dbulk + dint; 20 if (this.others !== undefined) { 21 dF += this.others(left_val,right_val,up_val,down_val, 22 center_val,inv_dxdy, 23 ); 24 } 25 return dF; 26 }, 27 ); 28 } 29 step() { 30 const dF = this.dF_dc(); 31 const dc = dF.output_mesh( 32 (left_val, right_val, up_val, down_val, center_val, inv_dxdy) =\u0026gt; 33 (left_val + right_val + up_val + down_val - 4 * center_val) * inv_dxdy, 34 ); 35 for (let j = 1; j \u0026lt;= this.mesh.Ny; j++) { 36 for (let i = 1; i \u0026lt;= this.mesh.Nx; i++) { 37 const idx = j * (this.mesh.Nx + 2) + i; 38 this.mesh.data[idx] += this.M * dc.data[idx] * this.dt; 39 } 40 } 41 this.mesh.boundCons.applyAll(this.mesh); 42 } 43} 可以看到我们设计了两个函数，一个是用来承接能量求解结果的 dF()，一个是用来进行单步计算的 step()。所有的计算信息都通过构造函数装载进求解器内部。\n到了最后的计算步骤，我们的初始化过程就很简单了：\n1 2const Nx = 64; 3// Define numbers ... 4 5const folder_name = \u0026#34;output\u0026#34;; 6fs.mkdir(folder_name, { recursive: true }, (err) =\u0026gt; { 7 if (err) console.error(\u0026#34;Error creating folder:\u0026#34;, err); 8 else console.log(`Folder \u0026#39;${folder_name}\u0026#39; is ready.`); 9}); 10 11const con_mesh = new Mesh(Nx, Ny, dx, dy); 12con_mesh.load_boundary_conditions([ 13 new PeriodicBC(\u0026#34;north\u0026#34;), 14 new PeriodicBC(\u0026#34;south\u0026#34;), 15 new PeriodicBC(\u0026#34;east\u0026#34;), 16 new PeriodicBC(\u0026#34;west\u0026#34;), 17]); 18con_mesh.init_geometry(new RandomInit(con_init, -dcon, dcon)); 19con_mesh.boundCons.applyAll(con_mesh); 20 21const bulk = new DoubleWellBulk(A); 22const interfacial = new GradientEnergy(kappa); 23 24const solver = new CH_Solver( 25 con_mesh, 26 M, 27 bulk.dbulk_dc(), 28 interfacial.dint_dc(), 29 undefined, 30 dt, 31); 计算过程更是简化为\n1for (let istep = 0; istep \u0026lt; Nstep + 1; istep++) { 2 solver.step(); 3 4 if (istep % 100 === 0) { 5 con_mesh.write_VTK(istep, folder_name); 6 if (istep % 500 === 0) { 7 console.log(`Step: ${istep}`); 8 } 9 } 10} 没错，就是让 solver 一直调用 step() 函数就可以了。所有的计算逻辑都被分层隐藏起来，到调用环节只需要使用 step() 来步进，这实在是太酷了，很符合……\n我们看看第二版的运行速度。平均下来竟然只需要 860 毫秒左右！？非常好 JavaScript，使我（）这版的程序您依旧可以从 这里 找到，欢迎尝试运行！\n从文件读取数据吧！ 然而，一个成熟的程序，肯定不能止步于从内部直接定义各类变量并运行。我们希望实现能够从外部以文件形式传入参数，并让程序读取文件后开始运行。为了实现这一点，我们需要设计解析文件的函数。好消息是，JavaScript 有一个非常标准，且您一定或多或少见过的对象文件格式：JSON，即 JavaScript Object Notation。它是开放的数据交换格式，也是许多配置文件会使用的格式之一。如果您和笔者一样使用 VS Code，那您一定见过 .vscode/settings.json 或者 .vscode/launch.json 这些文件，它们就是用来配置 VS Code 行为的一些配置文件。\n另外，因为笔者深受 Nan 所困，每次在计算结束后才发现程序往结果里偷偷塞 Nan，很讨厌，因此设计了个小函数用来检查计算结果有没有出现 Nan，出现的时候就会立刻停止运行并告诉我哪行哪列出现了问题。不过因为这个功能挺消耗性能的，因此我们选择性打开它，可以通过输入文件进行控制。\n其次，因为我们的程序变得越来越长（第二版有足足 330 多行），我们考虑将这个大的文件分写成三个部分，分别用来存储 数据读取，数据类型设计 和 实际运行 三个部分。第三版程序以及示例输入文件我们打包放在了 这里，欢迎解压后食用。（哦天哪就这点文本有必要压缩吗（））\n这个版本的代码跑起来性能波动有点严重，但基本不会超过 1.1 秒，也算是相当快的结果了。\nTypeScript 的实现 品鉴过 JavaScript 之后，能感觉到它的之处在于相对灵活的语法以及不用写明类型的美妙。然而正如我们之前所提到过的那样，当程序体量变大，使用类型检查能够帮助我们更好地写出健康的代码。因此我们下面就再来品鉴一下 TypeScript，看看它能不能给我们带来一些新东西。\n建立在 JavaScript 之上 我们第一版就直接复刻 JavaScript 的最后一版程序，且我们选择让 AI 代劳。AI 这么好用，且给代码标记上类型也不难，何乐而不为呢？我们这次就不分开写了，直接把代码放在 这里 吧，方便您查看并运行。\n对比 JavaScript 的实现，可以看到 TypeScript 的实现中有很多的 type 关键字定义的形如类定义或者运算的代码，比如\n1type BoundaryPosition = \u0026#34;north\u0026#34; | \u0026#34;south\u0026#34; | \u0026#34;east\u0026#34; | \u0026#34;west\u0026#34;; 2type BoundaryType = \u0026#34;periodic\u0026#34; | \u0026#34;fixed\u0026#34;; 3 4type ParsedBoundaryConfig = { 5 position?: unknown; 6 type?: unknown; 7 value?: unknown; 8}; 这实际上是在规定符合类型的内部应该有一些什么。这里 BoundaryPosition 就表示只接受四种字符串，否则类型检查会报错，而 ParsedBoundaryConfig 则要求这个类型的对象拥有三个可能存在的属性，且三个属性的类型，为了安全起见，都设置为了 unknown，用来在后面进行处理。\n此外，一些类内部也发生了变化。变化最大的也许就是构造函数了。我们以用来接收网格信息的 MeshConfig 类为例：\n1class MeshConfig { 2 constructor( 3 public readonly Nx: number, 4 public readonly Ny: number, 5 public readonly dx: number, 6 public readonly dy: number, 7 public readonly minVal: number, 8 public readonly maxVal: number, 9 public readonly boundaryConditions: ParsedBoundaryConfig[], 10 ) { } 11 // other methods... 12} 可以看到我们不再从参数列表里定义好参数之后，再在函数体内使用 this.attribute = attribute 这样的写法定义里面有什么属性，而是直接将它们写在参数列表里并让函数体里什么都没有，这样就已经完成了类的初始化了。如果要定义别的属性，可以参考后面的数据类 Mesh：\n1class Mesh { 2 readonly inv_dxdy: number; 3 readonly data: number[]; 4 readonly boundCons: BoundaryConditions; 5 6 constructor( 7 public readonly Nx: number, 8 public readonly Ny: number, 9 public readonly dx: number, 10 public readonly dy: number, 11 data?: number[], 12 boundCons?: BoundaryConditions, 13 ) { 14 this.inv_dxdy = 1 / (dx * dy); 15 this.data = data ?? new Array((Nx + 2) * (Ny + 2)).fill(0); 16 this.boundCons = boundCons ?? new BoundaryConditions(); 17 } 18 // other methods... 19} 可以看到，这里除了 constructor 的参数列表外，还有两个位置与之前不同。一个是在构造函数前面就声明了一些 readonly 的属性，它们则像往常那样用 this 定义在构造函数体内部用来赋值。总体而言，这样的设计方便我们进行访问控制，避免意外改变数据。\n最后值得一提的是，我们可以真的定义某个函数的类型！定义的方式则和箭头的写法类似：\n1type DBulkDC = (centerVal: number) =\u0026gt; number; 这里我们就定义了 DBulkDC 这个类型，它应该是接受一个数字并返回另一个数字的函数。这样的设计方便了我们将函数参数传入别的函数内部的操作。\n这版代码的运行速度如何呢？经过几次测试，运行速度稳定在约 1030 毫秒左右。这个结果我算是相当满意了，一秒多就跑完还要什么自行车是吧？\n受不了了，我要在浏览器跑它！ 结果，我们写了这么多版本的代码，竟然没有任何一个是在浏览器上运行的，都是在 Node.js 上跑的……这实在是和这些前端技术的应用场景不符呀。那么最后，我们就借助 Vite 的力量以及伟大群友 開源 lib 在我博客内嵌入的神秘力量，来实现它在浏览器的运行吧！\n我们选择的库是 React，它的优点我们之前也已经介绍过了。为了让 Vite 能够直接运行，我们让 AI 搓了一个 HTML 文件，在里面使用 \u0026lt;script type=\u0026quot;module\u0026quot; src=\u0026quot;./TS_impl_v2.ts\u0026quot;\u0026gt;\u0026lt;/script\u0026gt; 的方式来将我们的代码结果放在浏览器上。TypeScript 代码和对应的文件放在了 这个地方。要运行这里的代码，请在解压后使用 pnpm vite --open TS_impl_v2.html 来打开一个端口，点击终端显示的 localhost 链接就可以进入浏览器页面，看到代码的运行结果了！在浏览器中按下 F12 可以打开开发者工具来查看控制台输出，可以看到计算是实时进行的。\n当然，在神秘力量的加持下，您可以在下面查看运行结果（请左右拖动滑块哦）：\nLoading... Loading React component... 针不戳！从我自己的浏览器上跑出来的结果大概就是 0.85 秒左右的时间，我很满意。不过这里要说明的是，为了在浏览器上渲染，我们没有将结果输出到文件，而是直接存在数组中，在渲染时将数组中的结果按时间帧渲染出来，所以计时没有考虑文件 IO 的问题。然而即便如此，我依然觉得这实在是太酷……\n如果您有仔细看过这份代码的话，可以发现里面用到了 async 和 await 两个关键字，以及 Promise\u0026lt;\u0026gt; 这样的类型。这两个关键字是做什么的呢？这个类型又是什么呢？这其实是 JavaScript/TypeScript 实现的 协程 写法。当我们声明某个函数返回一个 Promise 的时候，是表示这个函数可能会较慢地 异步 执行，最后一定会返回 Promise 模板类中包裹的那个类型。比如 Promise\u0026lt;string\u0026gt; 的含义就是这个函数在执行到最后肯定会返回一个 string 类型。\n有了这个东西之后，我们就可以在某个更大的函数中标明 async，告诉引擎这个函数内有 Promise 被使用，而在这个函数内使用任何返回 Promise 的方法都会 立刻 执行，它们的执行时间我们可能不清楚，但它们不会阻塞后续程序的执行，且在稍后需要的时候一定通过 await 关键字来同步这些异步函数的执行结果，让 await 语句执行结束后保证所有的函数都返回了需要的值（或成功报错）。\n这样的做法能带来什么好处呢？其实很明显：我们可以让程序执行的过程中比较慢的 IO 提早执行，再在需要使用它们的时候通过 await 保证执行状态，从而提高程序的运行速度。就像是小学经典问题，传统编程就是先烧水后切菜，而异步编程就是烧水的时候直接去切菜，需要用水的时候就 await 到水烧开。\n然而笔者对 JavaScript/TypeScript 的异步编程，或者说异步编程本身依旧不够了解，里面其实有很多门道，利用得当的情况下会很好地提升运算效率，而利用不当则很容易搞处神秘 bug……这里也算是个协程/异步初体验了。\n总结与后记 这次因为是 几乎 从 0 开始学 JavaScript/TypeScript（其实写博客的时候遇到过一点点但是也不算多），所以我先写完了所有的代码，经过若干次迭代得到满意的版本之后，才来写这篇博客的。写的过程中又发觉其实很多地方的理解依旧不到位，如果有什么地方写的有问题，恳请不吝赐教。\n我很喜欢 JavaScript 诞生的故事，很有趣。特别是知道 JavaScript 的名字其实就是商业考量，而后续大家又这么一直用了下来的时候，感觉世界真的像个草台班子。而再考虑到当前前端如此繁荣的生态，又不自觉地感到即便是看上去的草台班子，大家的智慧结晶也一步步地把它捧到了如今的高度。在我看来，JavaScript 最终是实现了 Brendan Eich 和网景公司最初的想法，即它应该是一个简单易用的 胶水 语言，将好用的功能与特性捏在一起，并将大家的智慧结晶调用起来。同时也正是简单易用，让前端生态更加地繁荣丰富。\n实际上，最开始写这篇的时候，我非常想将 函数式编程 引进来，做一些介绍。但是在经过一些资料搜索与阅读后，我发现当我们考虑将函数作为对象传入参数时，就已经在进行某种程度上的函数式编程了。此外我对这个概念的理解还不算深入，也有更好的实现函数式编程的语言（如 Haskell），因此也许我以后在深入了解这个理念之后，再考虑向您展示我对它的理解。\n另外，有个很有趣的点：这三期文章下来，我开始进行代码实现的 行数 都是在我的编辑器的第 100 行左右。这实在是一个很神奇的数字，希望您不会觉得我前面聊的太过啰嗦。事实上，通过对不同语言的学习与比对，我对一些概念的理解又更深入了，比如说内存模型，变量生存周期等等。而说到这一点，我相信任何听说过 编程原神 —— Rust 大名的朋友都会津津乐道 Rust 特殊的变量规则。在不久的将来，我会尝试用 Rust 来跑一跑相场的代码。\n对了，我将博客中用到的代码放在了 Github 仓库 里，您可以那里获取我的源代码；我也给博客开了新的页面 Attachment，您也可以从那里获取我的代码。\n最后，感谢您能看我啰嗦到这里。本系列的下一期可能需要等一段时间，但是内容已经确定了：我们将回到 1970 年代，看看 Dennis Ritchie 设计实现的 C 语言 能怎么实现我们的相场模拟，届时我们也将对我们的模拟对象做一些调整，毕竟一直看一摊灰泥分成神秘红蓝色块总是有点无聊的。那么就是这样！感谢您的阅读，祝您身心健康，天天开心，happy coding！~\n来源自 Speaking JavaScript, Chapter 4. How JavaScript Was Created\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n来源自 JavaScript and the ECMAScript specification\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2026-04-10T00:00:00+08:00","image":"/images/Alice-2.png","permalink":"/zh/posts/pf_note/impl_spinodal/impl_spinodal_3/","title":"相场模拟，但是用很多语言 III"},{"content":"上一节我们尝试了使用 C++ 来跑相场模拟。除了 C++ 之外，科学计算的另一大热门则是 Python 这门非常火爆的语言。Python 能带给这个模拟什么有趣的特点呢？它的实现可以怎么做呢？一起来看看吧~\n为保持系列的统一，头图我们依旧选择了上期出现的，由 Neve_AI 绘制的 AI 爱丽丝。选曲则是最近迷上的，由 塞壬唱片（其实就是鹰角网络）发行的，2025 年夏日活动 《火山旅梦》的主题曲：Misty Memory。这首歌有三个版本：Day version, Night version 和 Instrumental，我们这里放的是 Day version。慵懒的声线非常适合一个人闲暇的时候赏听，希望您能喜欢！\n相场法与 Python 说到科学计算，相信绝大多数人都会第一时间想到 Python 这门语言，而事实也的确如此。我们这里依旧简单介绍一下这个语言。\nPython：神奇胶水与万能工具 Python 是一门高级程序语言。它诞生于 Guido van Rossem（人称龟叔）在 1980 年代的一个项目，为当时的一个操作系统 Amoeba 制作一款脚本语言，而后被社区大力发展，经历数次变革，最终成为如今的模样。Python 几乎广泛存在于所有的电脑上，就连 GNU 的调试工具 gdb 都会以 Python 作为依赖！许许多多的程序都或多或少地需要一点 Python 的功能，特别是很多计算模拟软件。它的内核可能是用别的语言完成的，但是很有可能它会提供一个定制版的 Python Shell 来方便一些自动化工作，就比如 Abaqus 和 Paraview。\n那么，是什么神奇的特性让 Python 有这么广泛的应用呢？这就要提到 Python 这门语言的特性了。\n首先，Python 的语法规则相对简单： 不需要（看起来）繁琐的规则，近乎所见即所得； 语法灵活，广泛应用鸭子类型（会叫会跑黄色鸟，那就是鸭子） 很多方便有趣的语法特性（List comprehension 等） 聪明的解释器可以不需要显式指明类型（即便真的有类型） 有很多好用的标准库，使用它们就已经可以做很多事 其次，Python 运行在 Python 虚拟机上，在运行时可以逐行解释，方便调试 相对应的，很多编译型语言就略显困难，比如 C/C++，需要对编译好的产物使用调试器来 介入 运行中的程序以进行调试 不过这里说的是 可以，有一些手段可以近似 编译 Python 脚本来生成运行更快的二进制产物 最新的 Python 3.15 版本正在推进 JIT（Just In Time，即时编译），相信它以后会更加好用 最后，Python 的成功离不开广大社区的支持 Python 生态及其丰富，在科学计算领域有许多极为优秀的开源库，如 Numpy，matplotlib，SciPy 等等，提供了许多方便好用的库； 除了科学计算，数据分析、统计、机器学习等领域也是极大量地使用 Python 作为基础语言，在它的基础上开发了许多的框架。著名项目 PyTorch 就提供了很不错的 Python 支持。 相比于其他的一些语言，Python 的 CUDA 支持更好用，更方便，程序员可以方便地借助显卡的力量进行大规模计算 上面的这一切生态离不开社区的友好氛围，以及众多出色的包管理。Python 除了有官方的包管理系统 pip 和 PyPI 之外，还有诸如 conda 这样的优秀包管理/环境管理。它们让配置开发环境变得更简单，更现代。 哦，天哪。Python 竟然如此优秀，也难怪 Python 在众多程序语言排行中屡夺头筹。但是看上面吹得天花乱坠的，这门语言有什么缺点吗？\n有的，兄弟，有的。最为人诟病的点大概就是其背靠虚拟机所导致的原生 Python 较差的性能，这也是在 HPC（高性能计算）领域 Python 并不常作为首选的原因。然而，如果我们不用原生 Python，不就好了？简单来说，就是用 Python 作为胶水，把众多实用的工具库 粘 起来，这个粘起来的操作并不会涉及什么高强度的计算，而高强度的计算一般都会放在这些被粘起来的工具里，这些工具则很多是编译好的库，这样一来也能很好地解决性能不足的问题。\n为了验证我们的想法，这次我们依旧会用两份代码来说明这个问题，顺便和上次的 C++ 实现做个比较。当然，由于我们用上了 Python 这么好的一款工具，我们的可视化工作就可以直接让 Python 代劳。不过在开始程序编写之前，为了让这个系列不是单调的重复模拟，我们这里再多聊聊相场（灌水\n相场与演化方程 尽管相场常用于材料学中的相变模拟，但剥离开其材料学背景，它也不过就是求解依靠某种能量的偏微分方程罢了。我们回忆 Cahn-Hilliard (CH) 方程：\n$$ \\frac{\\partial c_i}{\\partial t} = \\nabla \\cdot M_{ij} \\nabla \\frac{\\delta F}{\\delta c_j \\left( r,t \\right)}; $$按照体系自由能达到全局最小则体系处于热力学最稳定态的理论，如果自由能处于最低点时，自由能泛函对浓度的变分导数 $\\delta F/\\delta c_j$ 结果应该恒为 $0$ 才对。我们对这一点是有足够的把握的，但为什么能够又基于此构建出这么一个用于演化相场的动力学方程呢？\n弛豫假设 这源于一个物理学中的概念：弛豫假设（Relaxation Ansatz）。当我们知道一个体系的平衡态时，我们自然想要知道：一个体系从非平衡态演化到平衡态的过程是什么样的。然而这明显是一个动力学过程，我们必须得到能描述它的演化的动力学方程才可以，即一个含有时间的方程。但是，获得能够准确且完整描述整个体系运动的方程又谈何容易，尤其是当我们只了解这个体系的平衡状态是什么样的时候。\n然而好消息是，在绝大多数物理体系中，都有这样的情况：一个体系由某个物理量作为主驱动力，且整个体系的演化速率和这个主驱动力距离平衡态的“距离”之间有较简单的联系。对于一个热力学系统而言，它的主驱动力自然是自由能，它与平衡态的“距离”可以很好地被变分导数衡量；而体系演化速率和自由能的变化之间有什么联系呢？在众多可能的关系中，成正比 是最简单的那一个。由此我们便得到了 Allen-Cahn (AC) 方程：\n$$ \\frac{\\partial \\eta_p}{\\partial t} = -L_{pq}\\frac{\\delta F}{\\delta\\eta_q\\left( r,t \\right)}. $$那 CH 方程呢？从形式上看，它的最大特点在于不是直接的正比关系，而是套上了两层 $\\nabla$ 的壳。这些壳的出现其实和要演化的变量密切相关：对于保守场，我们需要体系内物质不要随便流出或流入，也就是说该场的散度应该为 $0$。那么从驱动力的角度能怎么实现这个约束呢？我们可以从物质流的角度来看。以浓度这个典型保守场为例，当总物质不增不减时，单位时间内物质的变化必须完全以物质流的形式表现体现出来，即：\n$$ \\frac{\\partial c }{\\partial t} + \\nabla\\cdot \\mathbf{J} = 0. $$这个约束保证了，任意某点的浓度变化都会导致产生一个与之抵消影响的物质流。这里的散度正是用来衡量流量大小的工具。因此我们将直接演化浓度的任务划归到了如何演化物质流。而物质流的大小，由经典动力学理论，又是直接与化学势梯度，即 $\\nabla (\\delta F/ \\delta c_j)$，成 正比 的。最后将这一切重新组合起来，就得到了 CH 方程了。\n可以看到，在最后的 经典动力学理论 中，我们再一次使用了弛豫假设。事实上，如果考虑到最经典的扩散理论，我们又能随处可见弛豫假设的身影，体系动力学速率总是与某个关键量成正比。\n演化方程与相场模型 从某种角度上说，相场的两个经典演化方程是可以这么得到的。查阅一些文献，可以看到由 Ingo Steinbach 等人提出的 多相场模型 就是从平衡态描述出发，辅以弛豫假设，最终得到演化方程的。CH 方程最初可能不是这么简单就获得的（应该是从晶体学的角度，辅以一些扩散理论），但弛豫假设依旧存在于理论的内部。\n事实上，就单纯地从动力系统的演化角度而言，有一篇 1977 年的综述文章很好地总结了凝聚态物理中临界态系统的动力学模型：Hohenberg and Halperin: Theory of dynamic critical phenomena，而我们相场中的 AC 方程则对应这篇论文的 System with no conservation laws: Model A，CH 方程则对应了 Conserved order parameter: Model B。很吓人的是，这篇综述里列出的相关模型足足有八大类，相场用到的仅仅是 A 和 B 这两类。不过这两个类别实际上也已经很丰富了，我们实际上可以从这两类模型发展出更多的模型，用来克服各种新的困难，解决新的问题。\n然而这里要指出一个点：演化方程不等于相场模型。相场模型严格来讲，是一个用来描述体系的偏微分方程组。这个方程组可以有一个形式化的 演化方程，但是这个形式化的方程还得有能填的进去的 具体能量表达形式。好在，对于相场法而言，能量的选择是较为宽泛的且方便的，因为各类具有物理背景的能量都是可以被直接填进这个能量泛函并发挥作用。因此在程序实现中，我们会考虑将演化方程和能量描述分开来实现，从而结构化地实现相场模拟。\n闲谈到此为止，我们开始着手用 Python 进行代码实现吧！\nPython 的实现 我们计划在这里使用两种方式来模拟上期中出现的调幅分解。首先我们尝试只是用原生 Python 和其标准库，来看看它的模拟效果如何。第二种方式则是借助一些外部库如 Numpy，观察计算效率有没有变化，并将二者与上期的结果进行比较。\n本文中使用的 Python 版本为 3.13.12 (MSC v.1944 64 bit (AMD64) on win 32)，由于 Python 解释器的实现在各个平台都是差不多的，我们就不在别的平台试了。\n原生 Python 第一版的代码其实说白了就是对之前的 CPP_impl_v1.cpp 直接使用 Python 改写罢了。不过即便如此，这个改写过程也颇为有趣。\n首先我们能发现，在 Python 的代码中，在仅依赖标准库的情况下实现我们在 C++ 版本中的功能只很少需要的库，分别是：\n提供文件夹创建的 os 库； 提供随机数的 random 库； 提供计时器的 time 库， 而其余的所有包括数据类型，函数对象等等都是自带的，不需要额外引入库，实在是非常方便（当然，也是有代价的，略显臃肿了）。按照顺序，我们首先考察 write_vtk 函数。\nwrite_vtk 与格式化字符串和循环 1def write_vtk(mesh, folder_path, time_step, Nx, Ny, dx): 2 os.makedirs(folder_path, exist_ok=True) 3 file_name = folder_path + \u0026#34;/step_\u0026#34; + str(time_step) + \u0026#34;.vtk\u0026#34; 4 with open(file_name, \u0026#34;w\u0026#34;) as f: 5 f.write( 6 f\u0026#34;\u0026#34;\u0026#34;# vtk DataFile Version 3.0 7{file_name} 8ASCII 9DATASET STRUCTURED_GRID 10DIMENSIONS {Nx} {Ny} 1 11POINTS {Nx*Ny*1} float 12\u0026#34;\u0026#34;\u0026#34; 13 ) 14 for j in range(Ny): 15 for i in range(Nx): 16 f.write(f\u0026#34;{float(i*dx)} {float(j*dx)} {1}\\n\u0026#34;) 17 18 f.write( 19 f\u0026#34;\u0026#34;\u0026#34;POINT_DATA {Nx*Ny*1} 20SCALARS CON float 21LOOKUP_TABLE default 22\u0026#34;\u0026#34;\u0026#34; 23 ) 24 for j in range(Ny): 25 for i in range(Nx): 26 f.write(f\u0026#34;{mesh[j][i]}\\n\u0026#34;) 27 28 f.close() Python 的函数定义只需要 def 关键字和参数列表即可，不需要指明函数的参数类型和返回结果，返回值类型也不需要注明。这正是所谓的自动推导类型系统和鸭子类型的强大所在：用户不需要关心那些可以被推导出的类型，也不需要指明某个参数是什么类型。只要符合在函数体内对这个类型所做出的操作，就可以不考虑其具体是什么。\n另外可以观察到，Python 在写文件时使用的是 open 函数，以后面的字符串代表打开文件的类型（是读还是写），最后 with open() as f 来给打开的内容以名称。这样的写法非常贴合英语的阅读语序，这也是 Python 受大家喜欢的一个原因。另外，这样的做法也保证了只有在文件对象 f 被打开时执行内部操作，而当文件关闭时则立刻停止执行。而写入的内容中，出现了这样的内容：\n1f\u0026#34;\u0026#34;\u0026#34;# vtk DataFile Version 3.0 2{file_name} 3ASCII 4DATASET STRUCTURED_GRID 5DIMENSIONS {Nx} {Ny} 1 6POINTS {Nx*Ny*1} float 7\u0026#34;\u0026#34;\u0026#34; 这是所谓的格式化字符串 f-string 以及 docstring 的结合产物，它可以在 \u0026quot;\u0026quot;\u0026quot; 内按字面意思存储字符串的格式信息，在这种情况下颇为好用，避免了我们在 C++ 实现中用流式输出一点点将变量和字符串混输的丑态。\n在这个函数中，还有个有趣的点在于 Python 的循环。在 Python 中，所有的 for 循环都通过迭代产生，因此当我们使用 for 循环时，必须有一个可供迭代的容器式的对象，而为了从 0 循环到 N-1，Python 提供了 range() 对象来解决这个问题。当使用 range(N) 时，它就自动创建了一个可遍历的迭代对象，依次从中取出 0，1，一直到 N-1。某种程度上，这种做法更加明确地区分了 for 循环和 while/do while 循环，让 for 循环看上去不再只是 while 循环的简写。\nmesh_periodic 与 Python 的变量 接下来的 mesh_periodic 函数再次体现了 Python 鸭子类型的优势：\n1def mesh_periodic(mesh, Nx, Ny, dx, ker_func): 2 new_mesh = [[0.0] * Nx for _ in range(Ny)] 3 # new_mesh = [[0.0] * Nx] * Ny 4 for j in range(Ny): 5 for i in range(Nx): 6 v_c = mesh[j][i] 7 v_l = mesh[j][i - 1] if i != 0 else mesh[j][-1] 8 v_r = mesh[j][i + 1] if i != Nx - 1 else mesh[j][0] 9 v_d = mesh[j - 1][i] if j != 0 else mesh[-1][i] 10 v_u = mesh[j + 1][i] if j != Ny - 1 else mesh[0][i] 11 new_mesh[j][i] = ker_func(v_c, v_l, v_r, v_u, v_d, dx) 12 13 return new_mesh 这里我们的函数参数不需要将类型明确写出，从而省去了用又臭又长的类型说明 ker_func 是某个明确的函数对象。另外，在创建临时网格的时候，也许您会注意到这里我们有两种写法：\n1new_mesh = [[0.0] * Nx for _ in range(Ny)] 2# new_mesh = [[0.0] * Nx] * Ny 在 Python 中，让一个 list 乘以一个整数的含义是让这个 list 中的数值重复整数倍，因此 [0.0] * Nx 的意思是创建一个长为 Nx 的，填满 0.0 的列表；而 [xxx for _ in range(Ny)] 则是 Python 的一个语言特性，名为 List comprehension（我想不到怎么翻译它 so bear with me pls）。它的作用是直接通过后面的表达式来填充列表，有点像是在原地生成列表中的元素，生成规则则写在 [] 里面。这里的这段意思就是将 [0.0] * Nx 这个作为一个代表元，生成 Ny 次。如此，我们便得到了这样一个列表，它的里面有 Ny 个元素，每个元素都是一个装着 Nx 个 0.0 的列表。\n但是既然如此，为什么不用 [[0.0] * Nx] * Ny 呢？我们来做个小实验。打开一个命令行，输入 python 后进入交互式环境，然后逐行输入下面的内容：\n1a = [[0.0]*2]*5 2a 3a[0][0] += 1.0 4a 小朋友，你是否有很多问号（）我们再试试这个：\n1b = [0.0]*2 2c = [b]*5 3c[0][0] += 1.0 4b 看到这个结果，也许你已经猜到是怎么回事了。上下两种写法在 Python 看来是一样的，即我们是从 c 这个保存了 b 的 引用 的列表中取出了某一个单独的引用 b，然后让对引用中的第一个元素加上了 1.0。因为 c 中存储的每个内容都是引用，对引用的变动会直接反映到变量自身，因此自然地，c 的每个元素的第一位（也就是 b 的第一位）就成为了 1.0。或者说，如果采用 new_mesh = [[0.0] * Nx] * Ny 的写法，我们并不是真的得到了 Nx * Ny 个 0.0，而是得到了 [0.0] * Nx 这个对象的 Ny 个引用。\n为什么会这样？为什么 b = [0.0] * 3 在 b[0] += 1.0 的时候不会得到 [1.0, 1.0, 1.0]，而 c[0][0] += 1.0 会让列表的每个元素第一位都加 1？这涉及到 Python 独特的变量管理策略。Python 的变量 不一定 是个像其他语言那样的单纯的，用来装一些数值的“盒子”。有一部分的 Python 对象被划分为 可变的（mutable），而其余的部分为 不可变的（immutable）。（一部分）整数，（一部分）字符串和一些数据结构是不可变的，如果“存储”它们的变量的值发生变化，那么将会创建一个新的对象来重新绑定到变量上，而不是直接操作到这些不可变的值上；相对应的，对那些可变的对象，引起它们变化的操作一般不会创建新的对象，而是在原对象上直接做变化。所以，对 Python 而言，对象是被绑定在变量名上的，而对变量名的操作其实都是在尝试对它们绑定上的值（对象）进行操作，再根据对象的类型决定是要重新绑定还是变化原有的对象。\n那么这些区别对 b 和 c 有什么影响呢？实际上这要再谈到 expr * N 这一操作的作用。在对列表用 * N 时，它会尝试对前面的表达式进行 一次计算，然后尝试把这个结果的引用绑定在列表的 N 个位置上。对于不可变对象而言，当对其中某个位置的不可变对象进行操作时，因为它 不可变，让这个位置发生变化的唯一方式是重新构造符合要求的对象；对于可变对象而言，由于每个位置绑定的都是前面计算出的 唯一的结果 的引用，对某个位置的变化会直接改变这个 唯一结果 的值（因为它可变），而 * N 只会复制引用，不会拷贝值，所以这个位置的变化会反映到这个列表中的所有位置。\n而 [[0.0] * Nx for _ in range(Ny)] 能实现我们需求的原因，也不过是 for 循环中会老实地把这个对象创建 Ny 次然后塞进这个列表中，其中每个长为 Nx 的列表都是相互独立的。我们绕了一大堆，希望您能理解 [[0.0] * Nx] * Ny 发生了什么，以及为什么需要改这个写法。这个问题困扰了笔者一段时间，最后也是在 Stack Overflow 以及 AI 的帮助下才解决了。令人惊喜的是，Python 官方文档的 Q\u0026amp;A 中就收录了 这个问题，感兴趣的可以看看。\n循环内部的操作其实很简单，不过是我们把条件判断写成了单行的形式，看起来更加 fancy 罢了。不过这种 expression1 if condition else expression2 的写法也是经过了若干次讨论之后，在 PEP 308 才敲定的，背后也有一些故事，我们这里就不再展开了。最后我们没有使用 Nx - 1 而是直接使用 -1 来取列表中的最后一个元素，这也是 Python 为我们提供的一些便利之处了。最后我们直接让 ker_func 按顺序吃满 6 个参数后把结果绑定在 new_mesh 的第 j 行第 i 列，就完成了一个点的计算。\nmain 函数与主循环 我们知道 Python 作为一门解释型语言，是不需要 main() 函数来作为程序入口的。然而这里我们依旧使用了 main。这也是为了体现 Python 的另一个特点，“文件即模块”。Python 会把每个脚本都当作模块，并给它们提供了几个变量作为模块的元信息。比如该实现最后用到的 __name__ 和 __main__。如果源代码被直接运行了，那么 __name__ 这个变量就会被赋值为 __main__，这也是我们最后写 if __name__ == \u0026quot;__main__\u0026quot; 的原因。当然，由于我们的实现里并没有别的文件/模块，实际上直接写 main() 的效果是一样的。这些特殊变量被称为 magic attributes，魔术属性，而这个双下划线也有对应的英文，名为 dunder，取 Double-Underscore 之意。\n让我们进入主循环，为了计时，我们使用 time 模块提供的 perf_counter 函数，它能提供高精度的计时时点。随后便是要给后续模拟中使用的参数赋值。在 Python 中，人们常用全大写的变量名来代表这是一个常量，但是由于 Python 没有强制性的常量，全大写仅仅是一种命名规范，并不是规则。另外，为了能让我们方便地批量定义变量，我们这里使用了 元组展开 的写法，把要用到的参数写在一个元组中，然后用对应数量的变量名去“接住”元组中的结果，避免我们有多少变量就要写多少行代码的窘境。\n1start = time.perf_counter() 2Nx, Ny, Nstep = (64, 64, 10000) 3dx, dt = (1.0, 0.01) 4kappa, M, A = (0.5, 1.0, 1.0) 5dcon, con_init = (0.05, 0.4) 在初始化结构这里，我们模仿之前在 C++ 实现中的写法，在用初始值填充好列表之后，用 random 这个库提供的 uniform 函数来方便地给出范围内的随机数，并赋给对应的点。\n1con_mesh = [[con_init] * Nx for _ in range(Ny)] 2for j in range(Ny): 3 for i in range(Nx): 4 con_mesh[j][i] += random.uniform(-dcon, dcon) 接下来我们提前准备好后面要用到的计算内核。这里依旧采用 Lambda 表达式，不过相比 C++ 的定义方式，Python 中要求使用 lambda 关键字来定义 Lambda 表达式。另外，Python 的 Lambda 表达式默认会捕捉可捕捉的所有变量，不需要像 C++ 那样明确要捕捉的变量。在 lambda 关键字后写好所有的变量并以冒号分割参数与返回值，lambda 表达式就写好了。\n1df_dc_ker = lambda v_c, v_l, v_r, v_u, v_d, dx: df_bulk_dc( 2 v_c, A 3) - kappa * laplacian(v_c, v_l, v_r, v_u, v_d, dx) 不过要注意的是，lambda 表达式的作用在于提供一个快速的，可抛弃的表达式，这里这个表达式实际上会被使用多次，且我们还给它明确定义了一个名字。根据 PEP 8，在这种情况下我们最好使用 def 关键字定义一个函数对象：\n1# def df_dc_ker(v_c, v_l, v_r, v_u, v_d, dx): 2# return df_bulk_dc(v_c, A) - kappa * laplacian(v_c, v_l, v_r, v_u, v_d, dx) 由于 def 关键字实际上定义的其实是函数对象，我们不需要专门把它拉到 main() 函数前面再定义，而是可以直接原地定义好它。\n接下来便是循环迭代，将结果通过 write_vtk() 函数写入文件，并统计时间：\n1for istep in range(Nstep + 1): 2 df_dc = mesh_periodic(con_mesh, Nx, Ny, dx, df_dc_ker) 3 dc = mesh_periodic(df_dc, Nx, Ny, dx, laplacian) 4 for j in range(Ny): 5 for i in range(Nx): 6 con_mesh[j][i] += dt * M * dc[j][i] 7 8 if istep % 100 == 0: 9 write_vtk(con_mesh, \u0026#34;./output\u0026#34;, istep, Nx, Ny, dx) 10 print(f\u0026#34;Result {istep} outputed\u0026#34;) 11 12end = time.perf_counter() 13print(f\u0026#34;Elapsed: {(end-start):.3f} seconds.\u0026#34;) 这里值得注意的是 print 函数。在 Python 中，我们终于有了一个十分好用的 print 函数，配合上格式化字符串来直接将结果方便地输出到控制台，而且它还会自动换行。当然，如果我们不希望它换行，我们可以指定 end 参数来改变打印结束后的末尾应该打印什么。它的默认值是 \u0026quot;\\n\u0026quot;，即换行符。\n至此，我们成功地使用 Python 完整实现了调幅分解。这一版的代码可以点击 这个链接 来浏览和下载。运行结果显示，在输出结果到文件的情况下需要约 19.7 秒完成计算，而在不将结果输出到文件的情况下，完成计算也需要大约 19.3 秒。这说明这个版本的 Python 程序完全不是因为文件 IO 而效率低，单纯是因为计算太慢。对比 C++ 的结果（详见上期评论区），当我们选择输出到文件时 5000 步耗时约 2.4 秒（10 000 步消耗约为 4.9 秒），而当不需要输出到文件时 5000 步需要耗时 0.29 秒（10 000 步消耗约为 0.5 秒）。\n那么，这份代码有没有什么可以优化的呢？使用 Numpy 等经典第三方库能帮助我们更快的计算吗？\nPython with Numpy 我们直接上手试一试！笔者安装的 Numpy 版本为 2.4.4，我们选择在使用 pip install numpy 安装好 numpy 后，单纯地用 numpy 的 numpy.array 来替换我们的网格，即：\n1# new_mesh = [[0.0] * Nx for _ in range(Ny)] 2new_mesh = np.array([[0.0] * Nx for _ in range(Ny)]) 顺带地我们可以像矩阵数乘和矩阵加法那样直接将计算得到的 dc 更新到 con_mesh 上：\n1con_mesh += dt * M * dc 2# for j in range(Ny): 3# for i in range(Nx): 4# con_mesh[j][i] = dt * M * dc[j][i] 结果如何呢？肉眼可见的慢！本来只需要不到 20 秒的计算，现在竟然用了 87 秒才完成……是哪里出问题了吗？\n啊，一定是因为我们没有在 np.array 里直接更新，而是重新创建新的变量的缘故！我们做一些修改，让 mesh_periodic 不再创建新的临时对象，而是直接将结果更新到参数列表中传入的变量里，然后在时间循环开始前就创建两个临时变量来承载待会儿要更新出的变量：\n1def mesh_periodic_update(mesh, updated_mesh, Nx, Ny, dx, ker_func): 2 for j in range(Ny): 3 for i in range(Nx): 4 v_c = mesh[j][i] 5 v_l = mesh[j][i - 1] if i != 0 else mesh[j][-1] 6 v_r = mesh[j][i + 1] if i != Nx - 1 else mesh[j][0] 7 v_d = mesh[j - 1][i] if j != 0 else mesh[-1][i] 8 v_u = mesh[j + 1][i] if j != Ny - 1 else mesh[0][i] 9 updated_mesh[j][i] = ker_func(v_c, v_l, v_r, v_u, v_d, dx) 10 11# ... 12 13def main(): 14 # ... 15 con_mesh = np.array([[con_init] * Nx for _ in range(Ny)]) 16 df_dc = con_mesh.copy() 17 dc = con_mesh.copy() 18 # ... 另外我们顺带把 Lambda 表达式换成用 def 关键字定义的函数：\n1def df_dc_ker(v_c, v_l, v_r, v_u, v_d, dx): 2 return df_bulk_dc(v_c, A) - kappa * laplacian(v_c, v_l, v_r, v_u, v_d, dx) 再试试！……不行。运行的那一瞬间就知道不对。每百步的输出一下一下地蹦出来，怎么看都不是很快的样子……还有什么，还有什么可以优化的地方？\n对了！要不要把 df_dc_ker 拆开？这样的计算核反而是没有能借助 Numpy 的批量计算功能。试试看：\n1# mesh_periodic_update(con_mesh, df_dc, Nx, Ny, dx, df_dc_ker) 2mesh_periodic_update(con_mesh, df_dc, Nx, Ny, dx, laplacian) 3df_dc = df_bulk_dc(con_mesh, A) - kappa * df_dc 如何？这次会不会快很多？别急，我们想到计算除法总是比计算乘法复杂一些，而除法在我们的代码里几乎只用在 Laplacian 计算过程中。我们完全可以提前计算好 $\\frac{1}{\\mathrm{d} x ^2}$ 然后再在后面直接乘上去。我们记这个新算法为 laplacian_inv_dx2：\n1def laplacian_inv_dx2(v_c, v_l, v_r, v_u, v_d, inv_dx2): 2 return ((v_l + v_r + v_u + v_d) - 4 * v_c) * inv_dx2 3# ... 4dx, dt = (1.0, 0.01) 5inv_dx2 = 1/(dx * dx) 6# ... 这次再试试。结果很棒！我们成功地降低了 10 秒！\n唉简直就是小丑……说好的 Numpy 很快呢？怎么用上 Numpy 之后反而变慢了！？？！不论如何，完整代码奉上：PY_impl_v2.py，欢迎阅读/下载/嘲笑（）\nNumpy 的真正实力！ 既然已经成了小丑，问问 AI 又能怎么样？问！\n这不问不知道，一问才明白，慢出来的时间主要全都消耗在 Python 和 Numpy 的 界面 上了。什么意思呢？本来我们如果用 Python 的原生列表，那就老老实实计算就完事儿了，速度比 C++ 慢是可以理解的，毕竟 Python 作为一门解释型语言，它的循环效率低于 C++ 也不奇怪。但是当我们把原生列表换成 Numpy 的 np.array 之后，事情就变了。\nNumpy 的 np.array 在创建时，实际上会创建一个类 C 语言数组那样的结构出来。而 Numpy 这个数学库高效的主要原因就在于它使用了许多前人总结的算法与好用的数据结构，就比如这里被包装起来的 np.array。可是坏消息是：我们并没有真正运用这个数据结构的方法，而是单纯拙劣地把它搬了出来，然后用了一下它的矩阵数乘和加法，仅此而已。而最消耗计算资源的 Laplacian 计算这里，我们依旧是用传统的手工活儿来执行。\n不，这甚至更糟糕：我们在对 con_mesh 这个 np.array 求 Laplacian 的时候，需要取某个位置的前后左右四个值还有中心的值。在取值的时候我们用了 [] 下标运算符来处理，而这反而进一步增加了计算消耗。我们用一个 Python 的写法告诉让 Numpy 让它从一个数组中的某个位置取出一个值，这个近乎翻译的过程，一次的时间消耗也许并不明显，但在进行 $64\\times 64\\times 5 \\times 2 \\times 10 000= 409 600 000$ 次调用之后，再怎么快的调用也会露出鸡脚的。另外，我们的算法完全没有发挥出 Numpy 的巨大优势：向量化。如果所有的数据组都能被批量，一次性地同时处理，而不是老老实实地一个个循环走过去，那计算效率肯定会发生质的提升！\n所以我们到底要怎么做，才能发挥 Numpy 的真正实力呢？鉴于我们的模拟使用的是周期性边界条件，而 Numpy 正好有个函数 np.roll 能够 转动 被操作的网格从而方便我们计算 Laplacian，我们这里就借助它的力量，定义一个新的函数 laplacian_np：\n1def laplacian_np(mesh, inv_dx2): 2 return ( 3 np.roll(mesh, 1, axis=1) # left 4 + np.roll(mesh, -1, axis=1) # right 5 + np.roll(mesh, 1, axis=0) # down 6 + np.roll(mesh, -1, axis=0) # up 7 - 4 * mesh 8 ) * inv_dx2 这个函数不是逐点计算得到每个点的 Laplacian 值的，而是一次性将整个网格的 Laplacian 计算出来，因此避免了 Python 的循环操作。我们用这个函数来实现时间循环中的计算步骤：\n1for istep in range(Nstep + 1): 2 3 lap_c = laplacian_np(con_mesh, inv_dx2) 4 df_dc = df_bulk_dc(con_mesh, A) - kappa * lap_c 5 dc = laplacian_np(df_dc, inv_dx2) 6 con_mesh += dt * M * dc 7 # ... 这次需要跑多久呢？10 000 步的计算（包括文件输出）总共用时为：1.15 秒！这甚至比我们之前的 C++ 实现还要快上很多！而当我们不选择输出到文件中时，结果约为 0.52 秒，与 C++ 的结果相当。\n所以，为什么会快这么多？一个关键点在于：我们不再需要让数据频繁跨越 Python 与 Numpy 之间的屏障，另外就是我们不再依赖 Python 的低效循环了，而是使用 Numpy 提供的高效数据结构自身的循环。\n你也许会问：不过是不用 Python 自己的循环了，让 Numpy 负责结构的内部循环怎么能快这么多？其实关键点在于，CPU 执行的时候也许也不是老老实实循环了，而是采用了 向量化 技术，对这里的 $64\\times 64$ 的数据 直接加减乘除，也就是说相比于老老实实循环，保守估计能快个 400 倍。实际上，向量化技术是现代 CPU 计算的重要技术，也是 HPC（高性能计算）的重点优化方向。如果一个计算过程能够被向量化，那么这个计算的速度就能得到极大的提升。\n另外就是内存是否能被迅速地加载进 CPU 核心中进行计算。现代计算机技术已经相当发达，加减乘除取模运算这些早就已经被优化到只需要若干时钟轮就能得到结果了，但是从内存中找到需要计算的对象却依旧相对较慢。为了解决这个问题，人们设计了 CPU 的多级缓存，如果计算数据能够被一次性加载到最快（同时也最小）的 L1d 缓存中的话，那计算速度会相当快（因为真的只需要算就好，不怎么需要寻找数据，也就是不怎么发生 cache miss，缓存未命中）。要是数据太多，那如果能被放在稍慢的 L2 中的话，那也不会好几次都没命中缓存；要是数据再大一些，或者计算流程设计的不好，那可能 CPU 没能把数据很好地加载到这两个位置，那计算可能就会很慢了。\n一般来说现代 CPU 都有 L1, L2, L3 三级缓存，其中 L1d 是给数据留下的空间，L2 和 L3 基本都归数据；想要查看自己 CPU 的各级缓存大小，Linux 用户可以使用 lscpu 命令，Windows 用户……我用 WSL（）下面是我用 WSL 中的 lscpu 帮我列出的我这本笔记本的 CPU 情况：\n1Caches (sum of all): 2 L1d: 768 KiB (16 instances) 3 L1i: 512 KiB (16 instances) 4 L2: 16 MiB (16 instances) 5 L3: 32 MiB (1 instance) 我的 L1d 缓存有足足 768 KiB！而 $64\\times 64$ 的网格数据如果全都存储 double 类型数据，若 double 类型数据大小为 64 比特 8 位，那这个网格占用的内存则为 32 KiB，完全可以放的进 L1d 缓存中。这也是为什么在用上 Numpy 的数组之后 在使用内部循环的条件下 计算如此之快的原因了。\n同样地，我们把代码贴在 这里，欢迎查阅、下载和取用~。\n其他的开源库呢？ 除了 Numpy 以外，实际上 Python 可用的开源库相当庞大。我们这里再给最后一版的程序加一些内容，以其展示 Python 的更多语言特性。就比如几乎与 Numpy 形影不离的 matplotlib 以及图形界面领域的老资历 Qt 的 Python 实现 PyQt6。\n最后一版的代码我们尝试进一步模块化，毕竟 Python 算是笔者熟悉的语言中最好搞花活儿的了，而模块化也许就算是笔者了解的花活儿之一吧。\n首先我们规定两个大类，一个用来存储我们的数据网格 Mesh，另一个则是专为本次模拟服务的 Solver_CahnHilliard 求解器（下简称为 Solver）。在数据网格中我们 只 记录与网格有关的所有信息，而在求解器中我们则 只 记录与求解有关的所有信息。\n因此，在 Mesh 类中，我们主要关心这些事：网格的大小，步长，该网格存储的数据，以及对这个网格的一些初始操作，比如赋值，创建，添加噪声，计算 Laplacian（这也许不是个好主意，但是我放在了这里，因为它和网格强相关）。而在 Solver 中，我们关心的则是：求解总步数，输出步数，时间步长，被求解网格，求解器相关参数，能量的偏导形式，而对这些数据的操作则可以简单概括为时间循环与结果输出。\n在定义好这两个类之后，我们要做的就很简单了：直接创建好网格，然后初始化求解器，最后求解，启动！就可以得到结果了。因为我们的案例实际上网格很小，完全可以被塞进内存里，因此我们可以在跑完所有结果之后根据需要来输出到文本中，或者借助 matplotlib + PyQt6 的力量，直接打开一个图形化窗口来观察结果：\n这份代码实际上还有很多可以优化之处，比如“求解器”究竟应不应该在内部定义主循环？如果要耦合多种能量的话，是不是最好把能量贡献放进一个 List 中然后遍历调用？是否应该将一些自由能的偏导形式内置在 Cahn-Hilliard 求解器的内部？\n然而这些问题我们暂时都不再深入考虑了。这份代码的具体内容放在 这里，欢迎下载运行尝试！注意要安装 numpy，matplotlib 和 pyqt6 三个库哦~关于运行效率问题，我的机器上完成模拟需要约 1.6 秒，这比我们之前的实现结果要差了不少，但是它的优点在于能炫技以及提前优化，也就是没有优点。不过如果你认为这份代码能帮你了解 Python 的别的语言特性，那即便它没有优点，也是有意义的（）\n总结与后记 Python 比我想象的要难很多。作为一门“工具语言”，我实际上对 Python 的了解并不多，很多东西写的时候都是网上现查的（当然也离不开 AI 的帮助）。我希望通过从代码结构，书写方式，运行效率等角度，把这里的四个案例和上一次的 C++ 两份代码做一些对比，来说明这样一个问题：代码的运行效率，归根结底还是落在 程序员能否写出高效的程序，而不是 语言是否高效。一门语言它也许本身比较慢，但这样的代价一定是换来了一些更好的结果的，而在 Python 这里，换来的是极强的易用性，以及甚至由于易用性而发展出来的庞大的用户群。我相信，也正是因为这庞大的用户群，让 Numpy，SciPy，matplotlib 等开源库愿意不断向社区做贡献，让这门语言的生态如此丰富。\n然而，一门语言自身是否“高效”，到底还是会影响人们在处理某些事务的时候首选的语言。比如 Python，很多人诟病的点就在于速度。然而如果我们使用上 Numpy 的数组 外加一些对库的了解，就能写出完全不输于 C/C++ 这类通常被认为 “更接近底层”，“更有效率”的语言。事实上，Numpy 高效的关键点在于它其实是编译出的，可供 Python 调用的二进制库，而在库中它使用了比如 BLAS 这类的专业数学处理工具库，因而相当于提前预料到用户的使用行为，从而更好地优化数据的计算过程。\n这样的预编译二进制操作不仅适用于 Numpy 这类计算库，同时也适用于很多机器学习库/框架。这也是为什么大家愿意用 Python：在合理使用库函数的前提下，既能保证效率，又能快速验证/实现自己的想法。这也正是 Python 这门语言最响亮的招牌：Life is short, I use Python!（人生苦短，我用 Python！）\n那么就是这样！希望您喜欢这次的内容。由于可爱群友 開源 lib（ 以迅雷不及掩耳盗铃儿响叮当之势为我的博客装载了 React 和 TailwindCSS 这些现代前端套件，我觉得没有理由把 JavaScript 版本的实现再向后推迟。本系列的下一期我们将尝试使用 JavaScript 实现调幅分解，同时也是给笔者一个机会来学习一下最新最潮流的 JavaScript~ 最后，感谢您的阅读，祝您身心健康，happy coding！~\n","date":"2026-04-01T00:00:00+08:00","image":"/images/Alice-2.png","permalink":"/zh/posts/pf_note/impl_spinodal/impl_spinodal_2/","title":"相场模拟，但是用很多语言 II"},{"content":"目前做相场的大家似乎都在用 C++ 或者 Python 来跑相场，可是明明程序语言这么多……对吧？Why not？本系列就来整个小活儿，用各种各样的语言来实现某个相场模拟~ 不过千里之行始于足下，我们就从最常用的 C++ 开始吧！~\n头图依旧是 Neve_AI 绘制的 AI 爱丽丝，可爱捏~ 我似乎越来越喜欢 AI 图了。选曲则是最近我很喜欢的 Duvet，它是动画《玲音》的主题曲，有点凄美的感觉，非常抓耳……分享给要看这篇充斥着程序计算的你~（依旧图曲文无关）\n可恶的wyy怎么要会员呜呜呜，不过 B 站也可以直接听：「玲音」主题曲 Duvet\n缘起：模拟与程序语言 某个阳光明媚的下午，一切都是那么的惬意，亲爱的群友 開源 lib（ 分享了他的最新选题：在浏览器中做个示波器（详见：【無線測距系統/下】AirTag 的物理碾壓和藍牙 6.0 的發力）！而且他也建议我可以把一些东西交互式地搬上浏览器，搬进博客里。\n我必须说：非常好提议，但是 HOW？要搬进浏览器，那怕不是要用 Javascript，但是 JS 又要怎么跑相场呢？跑出来的结果又要怎么办呢？一般来讲，相场跑出来的结果都会用 VTS 格式存储为一系列的文件，然后再用 Paraview 来可视化这些结果。那如果在浏览器里的话……想不到什么好办法。\n不过，这个点子狠狠地启发了我！虽然浏览器上做这些确实可能有点难，再加上我不太懂 JS/TS（本博客的程序部分 vibe 成分极高），用浏览器跑相场这事儿目前确实是有点难度了；但是，为什么我们总是选择用 C++ 和 Python 来做模拟呢？诚然，对 性能 的追求是很现实的需要，但是我们也可以试试用别的程序语言来实现模拟呀？说到底，相场模拟也不过是做一些数值计算嘛。\n所以，我们就从笔者最熟悉（也许）的 C++ 开始，试试用更多的程序语言实现相场模拟吧！\n相场模拟简介 不过，在实现相场模拟之前，你也许会问：什么是相场？\n简单来说，材料中有很多的相，做实验之后能看到材料的相结构，但是如果不用非常非常贵的原位实验手段的话，是很难看到相变进行的过程的。而相场法就是一个通过数值计算来模拟相变过程的计算方法。在模拟中，我们会划定一个小区域，在里面使用 $1$ 代表某个区域完全被某个相占据，$0$ 代表某个区域里完全没有这个相，而 $0$ 到 $1$ 之间的值就说明是在相边界的位置。驱动相场演化的主要因素是这个系统的能量，绝大部分情况都是由两个大块儿组成：体能和界面能。体能负责让相与相从混乱的状态不断分离，让体系完全变成热力学所描述的样子；界面能则起到相反的作用，它会帮助界面的生成，让相与相之间有一个 扩散 的界面，或者说让取值在 $0$ 到 $1$ 之间的区域变多一些。合理调配这两种对抗的能量贡献，就能让相场不断演化下去。\n而实际的演化过程则是由偏微分方程控制。根据变量的特点，我们会用两大类方程来推进相场演化：当变量是保守的，即这个变量在模拟区域的总量应该是固定不变的的时候，我们就用 Cahn-Hilliard 系列的方程来演化，最经典的就如浓度场；而当变量没有保守条件，我们则会用 Allen-Cahn 系列方程来演化这个场。两个方程都有个最基础的形式，其中 Cahn-Hilliard (CH) 方程为： $$ \\frac{\\partial c_i}{\\partial t} = \\nabla \\cdot M_{ij} \\nabla \\frac{\\delta F}{\\delta c_j \\left( r,t \\right)}; $$ 而 Allen-Cahn (AC) 方程则为： $$ \\frac{\\partial \\eta_p}{\\partial t} = -L_{pq}\\frac{\\delta F}{\\delta\\eta_q\\left( r,t \\right)}. $$ 两个方程里的 $F$ 是能量泛函，$M$ 和 $L$ 是两个方程的动力系数。\n在做相场模拟的时候，在构建好相场理论（能量描述，演化方程）之后，就需要设置求解相关的内容。首先需要对初始结构建模，换句话说也就是初值；然后需要有合适的边界条件来让场正常演化。一切准备就绪之后，就是写程序，模拟并输出结果了。\n那么，在这个系列中，我们要模拟什么好呢？要说起相场模拟，最早应该追溯到 Cahn 用它来模拟二元合金的调幅分解了，而这么经典的模拟，模型却意外地简单。在 S. Bulent Biner 所著的 Programming Phase-Field Modeling 中，它用到的第一个案例就是二元合金的调幅分解了。这本书中用的程序语言是 Matlab，一个我不太喜欢的语言（因为我不会），不过作为计算参考已经足够了。\n系列介绍和本文计划 这个系列计划是会把主流编程语言都试一遍，再试试有些非主流的语言和方法，不管笔者到底会不会这门语言。如果会，那就写；如果不会，那就学了再写。本文打算用笔者最熟悉的 C++ 和 Python 开始，再用程序老资历之一 C 和新生代最火的“编程原神”Rust 来实现一下这两个模拟。代码会贴在每一段的后面，在实现的前面会简单介绍一下这门语言，然后在实现之后给出结果和可能有的评论。后续的文章可能会考虑换一个模拟案例，大概就是仿照 Programming Phase-Field Modeling 这本书的案例了。\n另外，在实现这些计算的过程中，我们尽可能尝试突出这门语言的特点。这意味着某种语言的实现可能有若干个版本。\n调幅分解的相场理论 其实在 相场模拟学习笔记 IV 里就已经对调幅分解做了些介绍，但是为了内容的完整性，我们还是贴在这里。\n调幅分解简介 调幅分解是在自由能-成分曲线呈现双势阱状态时会体系可能会发生的一种相变，其主要特点为没有相变的型核过程，且体系具有双势阱型的自由能。它的自由能-成分曲线图和相图如图所示：\n根据热力学理论，若一个过程能让体系自由能下降，那么这个过程就很可能会自发进行。当成分位于势阱中间的位置（比较高且在两个曲线拐点以内）时，由于成分小幅度波动会让整个体系自发地发生自由能下降，进而影响周围的区域带动相分离。\n相场模型 我们自然是采用 CH 方程来演化这个体系，重要的是自由能的构成。为了模仿这样的双势阱，我们用一个很简单的函数来表示这样的自由能曲线：\n$$f_{\\mathrm{bulk}} = Ac^2(1-c)^2,$$其中 $A$ 是用来控制曲线高度的参数。而有了体能之后，我们需要有界面能来让体系形成扩散界面。我们使用最经典的梯度能模型：\n$$f_{\\mathrm{bound}} = \\frac{1}{2} \\kappa |\\nabla c|^2,$$这样的梯度能只会在某个点处上下左右成分不同的情况下才会有值，且差别越大这个值就越大，从而能成功避免界面太尖锐（左边是 0 右边是 1 这样）。\n经过一些（也许）不难的数学推导，我们很快得到我们要解的方程：\n$$\\frac{\\partial c}{\\partial t} = M \\nabla^2\\left( 2Ac(1-c)(1-2c)-\\kappa\\nabla^2c\\right)$$参数设置 为了计算简单不出错，我们直接用书中的参数。模型参数中，$A = 1.0$，$M = 1.0$，$\\kappa = 0.5$。建模方面，取初始浓度为 $c_0 = 0.4$, 并在每个点生成随机的浓度噪声，噪声最大值为 $\\delta c = 0.005$。然后考虑离散步长，取 $\\Delta t= 0.01$，$\\Delta x= 1.0$。模拟域设为 $64\\times 64$ 的正方形区域。边界条件设为周期性条件，即左边的点再向左走就取到最右边的值，上下同理。\nC++ 的实现 C++ 算是笔者做相场时第一个接触的语言了。用 C++ 做相场某种角度上是平衡了使用难度和运行效率的选择，吧？我们就从这个开始吧。\nC++ 的简单介绍 C++ 是斯特劳普 (Bjarne Stroustrup) 教授于 1979 年在贝尔实验室设计开发的高级编程语言。它某种角度上是对 C 语言的扩充和发展，但是也不是完全兼容 C 语言的（漫长的发展历程中二者逐渐分道扬镳了）。C++ 的特点在于引入了 类 用来组织和管理数据，且在发展过程中人们发现 C++ 能够支持 模板元编程 来让某种处理逻辑能够处理不同的数据类型。相比起 C 语言十分接近硬件的特点，C++ 的工具库更多，也更适合构建复杂的应用程序，如今广泛应用在游戏，高性能计算等领域。不过，经过长时间的发展，C++ 的功能越来越丰富，但是语法也越来越复杂，入门门槛也变得很高。从 C++17 标准开始的所谓 现代 C++ 与这之前的 C++ 写法风格上有很大的区别，甚至有人会认为这已经是另一门语言了。\n这里的实现会用一些现代 C++ 的特性以及很好用的新加入的标准库，比如一些用来生成随机数的，用来管理文件系统的标准库，不过这些代码应该也不会太难懂。笔者的环境是 Windows 10，最合理的选择自然是 MSVC，但是也会尝试保证代码能够正常运行在各个平台。\n我们开始吧。\nC++ 的一种实现 我们先做一些准备工作，然后就是设计算法并进行计算了。完整的代码会附在这一小节的最后。\n计算准备 首先我们先把各种常数定义下来。我们把它们放在最前面，以便后面使用。这种做法有一些争议（在需要引入变量的时候再引入，而不是在程序最开始就定义），但是统一定义在前面的好处主要在于这些都是控制模型/模拟过程的常数，分散在程序内部不方便统一管理。\n1constexpr int Nx = 64, Ny = 64, Nstep = 10000; 2constexpr float dx = 1.0f, dt = 0.01f; 3constexpr float M = 1.0f, A = 1.0f, kappa = 0.5f 4constexpr float dcon = 0.05f, con_init = 0.4f; 注意到这里我们用到了 constexpr，这是期望编译器进行编译期计算，不过在这儿效果约等于 const 就是了。另外，我们给浮点数的后面用 f 做后缀来告诉编译器它的确是一个 float 类型变量而不是从 double 变量强转过来的 float 类型。为什么用 float？答案很简单：我们的模拟不需要那么高的精度，变量短一点有利于计算效率。当然，这个程序里的所有浮点数部分都换成 double 也是不错的选择，反正这个计算挺快的。\n随后我们借助 \u0026lt;random\u0026gt; 库中的 std::random_device 类来生成一个随机数种子，再用 std::uniform_real_distribution\u0026lt;float\u0026gt;() 模板类来生成从 $-0.005$ 到 $0.005$ 的随机浓度波动：\n1std::random_device rd; 2std::uniform_real_distribution\u0026lt;float\u0026gt; dist(-dcon, dcon); 这个地方得到的类 dist 可以被当作一个函数，在后续通过接受 rd 这个随机数种子作为参数就能来生成一个在规定范围内的随机值。\n最后一步就是准备好我们的网格了。我们用二重 vector 来承载整个浓度网格 con_mesh。这里因为我们知道整个模拟区域内平均浓度为 $0.4$，因此我们直接在 $Nx\\times Ny$ 这么大的整个区域上的每个点都填上 $0.4$：\n1vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; con_mesh(64, vector\u0026lt;float\u0026gt;(64, 0.4)); 这里我们用到了匿名临时对象 vector\u0026lt;float\u0026gt;(64,0.4)，这也是调用了类的构造函数 vector\u0026lt;Type\u0026gt;(Num,Value)。随后我们遍历整个网格，给网格内的每个点都加上浓度的微小变动：\n1for (auto \u0026amp;row : con_mesh) { 2 for (auto \u0026amp;point : row) { 3 point += dist(rd); 4 } 5} 这里我们用到了迭代器循环：for (type value : container) 的语法能让具有迭代器的 container 被自动地从第一个值遍历到最后一个值。一般来讲这里的 type 用 auto 来让编译器自动推导类型，另外这里使用 auto \u0026amp; 来按引用取容器中的值，以保证值可以正确更新到网格内，否则只会把值更新在循环的临时变量里，不会正确地更新进容器内。由于我们的随机数一开始就是从 $-0.005$ 到 $0.005$ 的，所以这里直接放心加上去就可以了。\n至此，我们就做好了模型参数确定以及几何建模。下面就是迭代计算的算法了。\n迭代计算 在向前差分法中，我们的每一次计算都是针对于某一时间步进行的。为实现时间不断前进，我们自然需要用一个大的时间循环，而在每个时间循环内，我们都认为这里的事情是瞬间同时发生的。我们的时间循环写为：\n1for (int istep = 0; istep \u0026lt; Nstep + 1; istep++){ 2 // ... 3} 这里我们给总时间步数加了 $1$，因为我们认为第 $0$ 步是初始结构，还没有开始演化。或者说这也是为了后续方便进行文件输出（每 $100$ 步输出一次，加 $1$ 就能输出最后一步了）。\n下来自然是考虑实现计算核心的部分了。观察表达式，可以发现计算过程中有不涉及网格的部分（体能计算），也有涉及网格的部分（Laplacian）。由于函数是逐点定义的，不涉及网格的计算中，对某点的计算只需要该点的值即可；涉及网格的计算则需要获取网格点周边相邻点的信息。体能计算可以被写为一个函数：\n1float df_bulk_dc(float con, float A) { 2 return 2 * A * con * (1 - con) * (1 - 2 * con); 3} 上面的总求解式中共出现了两次 Laplacian 计算，而对 Laplacian 的计算则需要在每个点周围的 $3\\times 3$ 的网格里计算出中心点的 Laplacian 值。我们先不考虑取周围点的操作，将它们抽象为 4 个数值，算法则很简单了：\n1float laplacian(float v_c, float v_l, float v_r, float v_u, float v_d, float dx) { 2 return (v_l + v_r + v_u + v_d - 4 * v_c) / (dx * dx); 3} 最后，为了能放心大胆地做计算，我们先处理边界数值，然后对中心点做计算即可。而对于重复出现的网格依赖操作，我们干脆直接把带有边界条件的网格遍历过程打包成一个函数：\n1vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; mesh_periodic( 2 vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; mesh, int Nx, int Ny, float dx, 3 std::function\u0026lt;float(float, float, float, float, float, float)\u0026gt; kernel_func) { 4 5 float v_l, v_r, v_u, v_d, v_c; 6 vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; next_mesh(Nx, vector\u0026lt;float\u0026gt;(Ny)); 7 for (int j = 0; j \u0026lt; Nx; j++) { 8 for (int i = 0; i \u0026lt; Nx; i++) { 9 10 v_c = mesh.at(j).at(i); 11 // x-minus 12 if (i == 0) { 13 v_l = mesh.at(j).at(Nx - 1); 14 } else { 15 v_l = mesh.at(j).at(i - 1); 16 } 17 // x-plus 18 if (i == Nx - 1) { 19 v_r = mesh.at(j).at(0); 20 } else { 21 v_l = mesh.at(j).at(i + 1); 22 } 23 // y-minus 24 if (j == 0) { 25 v_d = mesh.at(Ny - 1).at(i); 26 } else { 27 v_l = mesh.at(j - 1).at(i); 28 } 29 // y-plus 30 if (j == 0) { 31 v_u = mesh.at(0).at(i); 32 } else { 33 v_l = mesh.at(j + 1).at(i); 34 } 35 36 next_mesh.at(i).at(j) = kernel_func(v_l, v_r, v_u, v_d, v_c, dx); 37 } 38 } 39 return next_mesh; 40} 我们聊一聊这个函数。首先，它接受某个二维网格作为待计算的网格，其次传入几何信息方便遍历以及网格依赖计算。最后，对于要计算的具体内容我们抽象为了一个函数对象 kernel_func。这个函数对象代表了这样一类函数：它接受 6 个 float 参数后返回一个 float 参数。函数对象的优势在于，我们可以暂时不用关心具体计算过程，把目光聚焦在怎么为这个计算准备合适的环境。\n随后，我们考虑周期性边界条件：当中心点位于边界上时，比如 $x$ 轴方向的左边界 (x = 0)，此时这个点的右侧是能自然取到值的，但左侧则没有结果。为了让右侧能取到值，我们要求此时取左侧值则取到求解区域的右边界值。这需要我们对每一个遍历到的位置的 i 和 j 都做两次判断（共4次），而为了做这样的边界处理，我们要把每个取到的值都存在临时变量内。\n最后，由于我们的计算核心逻辑都被抽象到了 kernel_func 中，我们只需要再取一个同样大小的网格，把结果更新到里面就好。这里我们采用函数返回网格的方式，我们也可以让函数把值更新到以引用方式传入的参数里。\n在准备好计算的逻辑组件后，我们需要将这些组件进行组合。由于要计算两次 Laplacian，我们就把上面带边界条件的遍历逻辑来应用两次，第一次得到的网格即为 $\\frac{\\delta F}{\\delta c} = 2Ac(1-c)(1-2c)-\\kappa\\nabla^2c$ 的结果，这里要把 laplacian 和 df_bulk_dc 等通过合适的组合后放入上面 mesh_periodic 函数中的 kernel_func 里，我们用一下 Lambda 表达式来组成匿名的临时函数：\n1vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; df_dc = mesh_periodic( 2 con_mesh, Nx, Ny, dx, 3 [kappa, A](float v_1, float v_2, float v_3, float v_4, float v_5, float v_6) { 4 return df_bulk_dc(v_1, A) - kappa * laplacian(v_1, v_2, v_3, v_4, v_5, v_6); 5 }); 我们解释一下最后一个作为函数对象的参数。Lambda 表达式是在 C++ 11 中加入的匿名函数，是现代编程语言的一个常见的语法特性。它通过前面的 [] 来捕捉上文的和函数有关但不作为参数传入的变量，通过 () 来确定函数的参数列表，最后在 {} 中定义函数。由于我们的函数对象要求以 6 个 float 变量作为输入，因此我们的 Lambda 表达式的参数列表要有 6 个 float；由于我们的结果要返回一个 float，我们在函数体内返回时也如此返回。注意到我们的计算中需要 kappa 和 A 两个常量参与计算，但它们又不能进入参数列表中（虽然参数列表用了不知所云的 v_*，但它们的实际意义则是前 5 个参数为中，左，右，上，下的网格值，最后一个参数为网格宽度。因此，我们把它们作为函数应该知道的上下文，通过 [] 列表告诉它。\n在进行完这个迭代后，我们需要进一步进行迭代，再次求 $\\frac{\\delta F}{\\delta c}$ 网格的 Laplacian 得到浓度的变化量：\n1vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; dc = mesh_periodic( 2 df_dc, Nx, Ny, dx, 3 laplacian); 这样我们就得到了浓度对时间的导数了。最后一步就是把浓度变化量乘以浓度的迁移率 $M$ 再更新浓度场：\n1for (int j = 0; j \u0026lt; Ny; j++) { 2 for (int i = 0; i \u0026lt; Nx; i++) { 3 con_mesh.at(j).at(i) += dt * M * dc.at(j).at(i); 4 } 5} 这样，我们就实现了一个时间步内的演化，直接把组合好的逻辑扔进时间步循环里就已经可以进行计算了。\n结果输出 但是我们的计算结果应该以某种方式输出出来。这里我们借助 C++17 引入的 filesystem 工具库来把网格结果以既定格式（VTK）输出到文件中，最后就可以用 Paraview 来可视化了。我们把输出逻辑打包为了一个函数：\n1void write_vtk(vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; mesh, string file_path, int time_step, float dx) { 2 fs::create_directory(file_path); 3 fs::path f_name{\u0026#34;step_\u0026#34; + std::to_string(time_step) + \u0026#34;.vtk\u0026#34;}; 4 f_name = file_path / f_name; 5 6 ofstream ofs{f_name}; 7 int Nx = static_cast\u0026lt;int\u0026gt;(mesh.size()), Ny = static_cast\u0026lt;int\u0026gt;(mesh.at(0).size()); 8 9 ofs \u0026lt;\u0026lt; \u0026#34;# vtk DataFile Version 3.0\\n\u0026#34;; 10 ofs \u0026lt;\u0026lt; f_name.string() \u0026lt;\u0026lt; endl; 11 ofs \u0026lt;\u0026lt; \u0026#34;ASCII\\n\u0026#34;; 12 13 ofs \u0026lt;\u0026lt; \u0026#34;DATASET STRUCTURED_GRID\\n\u0026#34;; 14 ofs \u0026lt;\u0026lt; \u0026#34;DIMENSIONS \u0026#34; \u0026lt;\u0026lt; Nx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; Ny \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; 15 ofs \u0026lt;\u0026lt; \u0026#34;POINTS \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; \u0026#34; float\\n\u0026#34;; 16 17 for (int j = 0; j \u0026lt; Ny; j++) { 18 for (int i = 0; i \u0026lt; Nx; i++) { 19 ofs \u0026lt;\u0026lt; (float)i * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; (float)j * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; endl; 20 } 21 } 22 ofs \u0026lt;\u0026lt; \u0026#34;POINT_DATA \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; endl; 23 24 ofs \u0026lt;\u0026lt; \u0026#34;SCALARS \u0026#34; \u0026lt;\u0026lt; \u0026#34;CON \u0026#34; \u0026lt;\u0026lt; \u0026#34;float 1\\n\u0026#34;; 25 ofs \u0026lt;\u0026lt; \u0026#34;LOOKUP_TABLE default\\n\u0026#34;; 26 for (int j = 0; j \u0026lt; Ny; j++) { 27 for (int i = 0; i \u0026lt; Nx; i++) { 28 ofs \u0026lt;\u0026lt; mesh.at(j).at(i) \u0026lt;\u0026lt; endl; 29 } 30 } 31 32 ofs.close(); 33} 这个函数首先接受要输出的网格作为参数；其次为了把文件输出到合适的位置并区分不同时间步，我们将文件路径和时间步作为参数输入函数；最后为了准确描述网格的几何情况，我们将网格步长输入。\n在函数内，我们先检测对应的文件夹路径是否存在。std::filesystem::create_directory 提供了很方便的方法，当文件夹存在则什么都不做，当文件夹不存在就创建个空的文件夹。之后我们以合适的规则为文件进行命名，得到文件名的完整路径后使用 \u0026lt;fstream\u0026gt; 提供的 std::ofstream 对象将内存中的值输出到文件里。这个对象可以以文件路径进行初始化，并且可以像平时 std::cout 把文字输出在屏幕那样的流式操作把文件内容按行输出出来。随后我们从网格本身的信息反推出 Nx 和 Ny 的大小。由于 vector.size() 方法返回的值类型为大小安全的 size_t，而我们清楚我们用不到这么大，且整个程序都使用了 int 作为整型，所以这里 需要进行一次数据类型转换。static_cast 是 C++ 引入的静态类型转换方式，方便我们安全地进行数据类型转换。\n接下来是 VTK 文件的格式规范。首先确定 VTK 的格式版本，再将文件自身的文件名输入第二行，最后规定文件的编码，完成文件头的定义。\n然后我们还需要规定文件描述的网格类型。由于我们采用的是横平竖直的，可以用笛卡尔坐标确定每个点位置的网格，这恰好是结构化网格最适合描述的。因此我们便声明 DATASET STRUCTURED_GRID；对于结构化网格，需要确定网格的大小和每个格点的位置（三维坐标）。因此我们先规定 DIMENSION NX NY NZ 描述网格点的数量，再显式提供网格点的总数和坐标的数据类型（这里是 float），最后把网格的坐标依次列出。\n最后，我们就需要描述每个点上的值了。VTK 格式允许我们添加多个数据集，让每个点上有多个性质。不过在这个案例里，我们只需要描述浓度一个信息就可以了。每个数据集以 POINT_DATA 和数据量开头，描述数据类型（标量或矢量），数据集名和数据的存储类型（这里依旧是 float），再定义查表方式 LOOKUP_TABLE（这里 default 即可），最后就把每个点的值依次列出就好了。\n在向一个文件内输出所有结果后，最好显式调用 ofstream.close() 来关掉这个文件流。当然不管也可以，由于 C++ RAII 的特性，满足 RAII 的类在离开作用域的时候会自动调用析构函数销毁自身。\n有这个文件输出函数之后，我们只需要在时间循环的过程中，选择合适的时间点调用这个函数进行结果输出即可。一个好方法是 if(istep %100 == 0){}，这个条件能自动地在每 $100$ 步的时候输出一次结果。\n另外我们还可以对计算过程进行计时。借用 C++17 提供的 \u0026lt;chrono\u0026gt; 标准库，我们可以方便地用 std::chrono::high_resolution_clock::now() 来获取程序运行这一语句时的时间点，再用 std::chrono::high_resolution_clock::duration_cast 来把两个时间点之差转化为合适的时间单位。我们有如下实现：\n1int main(int, char**){ 2 using hrc = std::chrono::high_resolution_clock; 3 namespace chrono = std::chrono; 4 const auto timepoint_start = hrc::now(); 5 6 // other things ... 7 8 const auto timepoint_stop = hrc::now(); 9 const auto time_cost = chrono::duration_cast\u0026lt;chrono::milliseconds\u0026gt;(timepoint_stop - timepoint_start); 10 cout \u0026lt;\u0026lt; \u0026#34;Calculation time cost: \u0026#34; \u0026lt;\u0026lt; static_cast\u0026lt;double\u0026gt;(time_cost.count()) / 1000.0 \u0026lt;\u0026lt; \u0026#34;seconds.\u0026#34; \u0026lt;\u0026lt; endl; 11} 上面我们用 using 声明了 std::chrono::high_resolution_clock 的类型别名，再用 namespace 为 std::chrono 声明了命名空间的别名，方便我们的手和眼睛。另外我们这里有一个小巧思：把时间转为用毫秒计算的格式，再转化为 double 类型后除以 $1000$ 来得到有 3 位小数点的秒数。直接转为 chrono::seconds 的话只会展示整秒数，而用这里的手法可以获得稍微更精确的时间。\n完整代码 最后，在补好所有的头文件并合理使用一些 std 命名空间内的名称后，我们得到了一个能够被三大主流编译器（GCC，Clang/LLVM，MSVC）编译运行的一份相场代码。它的运行结果和 相场模拟学习笔记 IV 的结果大同小异，这里就不贴出来了。你可以点击 这个链接 来浏览这个文件。\nC++ 的另一个版本 这次的这个版本相比于之前在笔记里写的而言，最突出的特点可能就在于使用了函数对象。函数对象的存在允许我们彻底地将一部分逻辑（方便地）抽离出来，从而逐步分层地实现计算逻辑。虽然不用函数对象也可以做到类似的事（via 函数指针），但是函数对象的方式更加简单，快速且安全。得益于模板类以及 C++ 标准库实现遵循的 RAII 与 Zero-overhead 原则，我们有理由相信编译器会帮我们处理好代码底下的小九九，让运行过程依旧足够高效，且在对象离开作用域时被 RAII 机制自动回收，避免到处指针造成的内存泄漏。\n不过，这个 C++ 的实现版本依旧没有体现 C++ 的面向对象特性。总体上看，我们也就只是用了一些已经打包好的工具类而已，并没有自己把数据组织起来并赋予操作数据的方法。然而，相场法天然的网格特性很适合用类进行打包。那么我们的下一个版本就可以考虑加上这个特性，看看用了类的版本会有什么不同。\n设计数据结构 相场法最核心的数据，毫无疑问就是用来参与计算的网格了。我们可以考虑这样一个用于计算的网格都需要有哪些属性：\n网格数据 网格大小 网格步长 边界条件 其中，第二个也许可以纳入网格数据内，毕竟大小是很快可以从实际存储数据的结构中用类似 .size() 的方法获得的，不过我们列在这里也是为了方便取用。如此，我们可以立刻得到这样的数据结构：\n1template \u0026lt;typename T, typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt;\u0026gt; 2class Mesh { 3private: 4 vector\u0026lt;vector\u0026lt;T\u0026gt;\u0026gt; mesh_data; 5 size_t Nx; 6 size_t Ny; 7 T dx, dy; 8 BoundFuncs\u0026lt;T\u0026gt; boundary_condition; 9 10public: 11 Mesh() = delete; 12 13 Mesh(size_t _Nx, size_t _Ny, T _dx, T _dy, BoundFuncs\u0026lt;T\u0026gt; _bound_funcs, 14 T _init_value = T()) 15 : Nx(_Nx), Ny(_Ny), dx(_dx), dy(_dy), boundary_condition(_bound_funcs) { 16 mesh_data = vector\u0026lt;vector\u0026lt;T\u0026gt;\u0026gt;(_Ny, vector\u0026lt;T\u0026gt;(_Nx, _init_value)); 17 } 18 19 Mesh(size_t _Nx, size_t _Ny, T _dx, T _dy, T _init_value = T()) 20 : Mesh(_Nx, _Ny, _dx, _dy, BoundFuncs\u0026lt;T\u0026gt;(), _init_value) {} 21 22 Mesh(size_t _Nx, size_t _Ny, T _d_mesh, T _init_value = T()) 23 : Mesh(_Nx, _Ny, _d_mesh, _d_mesh, BoundFuncs\u0026lt;T\u0026gt;(), _init_value) {} 24 25 Mesh(size_t _N_mesh, T _d_mesh, T _init_value = T()) 26 : Mesh(_N_mesh, _N_mesh, _d_mesh, _d_mesh, BoundFuncs\u0026lt;T\u0026gt;(), _init_value) {} 27 28 Mesh(T _dx, T _dy, BoundFuncs\u0026lt;T\u0026gt; _bounds, vector\u0026lt;vector\u0026lt;T\u0026gt;\u0026gt; \u0026amp;_data) : mesh_data(_data) { 29 Ny = mesh_data.size(); 30 Nx = mesh_data.at(0).size(); 31 dx = _dx; 32 dy = _dy; 33 boundary_condition = _bounds; 34 } 35 36 const size_t get_dim_x() const { 37 return Nx; 38 } 39 40 const size_t get_dim_y() const { 41 return Ny; 42 } 43 44 const T \u0026amp;get(size_t _x, size_t _y) const { 45 return mesh_data.at(_y).at(_x); 46 } 47 48 49 vector\u0026lt;vector\u0026lt;T\u0026gt;\u0026gt; \u0026amp;get_data() const { 50 return mesh_data; 51 } 52 53 // ... 54}; 可以注意到这里我们使用了 template 关键字，这是所谓的 模板，可以用来接受一些数据类型然后将类/函数以对应的数据类型实现。我们使用它的主要原因是希望能让这份代码适用于不同的浮点数精度。在不追求精度的时候可以使用 float 来实现这个模板，而在需要时则可以使用 double。由于我们只希望这类浮点数作为备选的数据类型，在模板类型名称的后面使用了 typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt; 的写法来保证它一定是一个可计算的浮点数。\n这个类主要数据包含：网格点数值，网格大小，网格步长以及网格的边界条件。我们设计了若干种方式来初始化一个网格，另外这里对边界的组织方式应该是这个版本最大的特点。考虑到我们的边界实际上只是规定当下标越界时，应该如何取值以继续计算，于是我们可以把这个任务抽象为给每个位置赋予一个函数，当下标越界时就调用该函数获得对应的值。我们稍后会提到它。\n另外由于我们把所有的数据都保护了起来，就还必须有选择地向外暴露数据。在实际计算中，我们发现经常需要取用某一点的值，或者需要将整个数据网格都取出来方便遍历；同时经常需要获得网格的长与宽。这样的需求让我们设计了四个对外公开的函数，功能也很简单，就是把值告诉外界。\n计算逻辑 接下来我们需要添加计算逻辑。很自然地，我们可以想到把之前的 mesh_periodic 挪过来，在类内进行操作。我们得到的新的计算函数如下：\n1Mesh iterate_mesh(std::function\u0026lt;float(float, float, float, float, float, float)\u0026gt; kernel_func) { 2 float v_l, v_r, v_u, v_d, v_c; 3 vector\u0026lt;vector\u0026lt;float\u0026gt;\u0026gt; next_mesh_data(Nx, vector\u0026lt;float\u0026gt;(Ny)); 4 5 for (int j = 0; j \u0026lt; Nx; j++) { 6 for (int i = 0; i \u0026lt; Nx; i++) { 7 v_c = mesh_data.at(j).at(i); 8 // x-minus 9 if (i == 0) { 10 v_l = boundary_condition.x_m-\u0026gt;bound_x_m(*this, j); 11 } else { 12 v_l = mesh_data.at(j).at(i - 1); 13 } 14 // x-plus 15 if (i == Nx - 1) { 16 v_r = boundary_condition.x_p-\u0026gt;bound_x_p(*this, j); 17 } else { 18 v_r = mesh_data.at(j).at(i + 1); 19 } 20 // y-minus 21 if (j == 0) { 22 v_d = boundary_condition.y_m-\u0026gt;bound_y_m(*this, i); 23 } else { 24 v_d = mesh_data.at(j - 1).at(i); 25 } 26 // y-plus 27 if (j == Ny - 1) { 28 v_u = boundary_condition.y_p-\u0026gt;bound_y_p(*this, i); 29 } else { 30 v_u = mesh_data.at(j + 1).at(i); 31 } 32 33 next_mesh_data.at(j).at(i) = kernel_func(v_c, v_l, v_r, v_u, v_d, dx); 34 } 35 } 36 Mesh next_mesh(dx, dy, boundary_condition, next_mesh_data); 37 return next_mesh; 38} 可以看到，因为很多数据都被类自然地涵盖了，我们不需要再把数据通过函数参数传入。另外，由于我们考虑使用不同的边界条件，这里统一使用 boundary_condition.{bd}-\u0026gt;{bd-value-func} 的模式来调用函数实现边界计算。这个函数因为会生成别的网格，因此我们还需要把计算得到的结果重新包装为 Mesh 后再传出。\n除了通过网格自身计算得到新的网格之外，我们还需要两个网格交互得到第三个网格，以及一个网格用另一个网格来更新自身。由于这个案例中我们主要是需要让浓度场计算后用新网格迭代自身，我们实现了后者。再考虑到有时我们需要根据某种标量来更新自身，我们这里也引入了一个重载的实现。结果如下：\n1void update(const Mesh \u0026amp;_mesh) { 2 for (size_t j = 0; j \u0026lt; Ny; j++) { 3 for (size_t i = 0; i \u0026lt; Nx; i++) { 4 mesh_data.at(j).at(i) += _mesh.get(i, j); 5 } 6 } 7} 8void update(std::function\u0026lt;float()\u0026gt; kernel_func) { 9 for (auto \u0026amp;row : mesh_data) { 10 for (auto \u0026amp;point : row) { 11 point += kernel_func(); 12 } 13 } 14} 最后，我们希望网格能够将结果输出到文件里，因此我们把之前的 write_vtk 稍作改造：\n1void write_vtk(string file_path, size_t time_step) { 2 const auto \u0026amp;mesh = mesh_data; 3 fs::create_directory(file_path); 4 fs::path f_name{\u0026#34;step_\u0026#34; + std::to_string(time_step) + \u0026#34;.vtk\u0026#34;}; 5 f_name = file_path / f_name; 6 ofstream ofs{f_name}; 7 ofs \u0026lt;\u0026lt; \u0026#34;# vtk DataFile Version 3.0\\n\u0026#34;; 8 ofs \u0026lt;\u0026lt; f_name.string() \u0026lt;\u0026lt; endl; 9 ofs \u0026lt;\u0026lt; \u0026#34;ASCII\\n\u0026#34;; 10 ofs \u0026lt;\u0026lt; \u0026#34;DATASET STRUCTURED_GRID\\n\u0026#34;; 11 ofs \u0026lt;\u0026lt; \u0026#34;DIMENSIONS \u0026#34; \u0026lt;\u0026lt; Nx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; Ny \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; 12 ofs \u0026lt;\u0026lt; \u0026#34;POINTS \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; \u0026#34; float\\n\u0026#34;; 13 for (size_t j = 0; j \u0026lt; Ny; j++) { 14 for (size_t i = 0; i \u0026lt; Nx; i++) { 15 ofs \u0026lt;\u0026lt; (float)i * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; (float)j * dy \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; endl; 16 } 17 } 18 ofs \u0026lt;\u0026lt; \u0026#34;POINT_DATA \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; endl; 19 ofs \u0026lt;\u0026lt; \u0026#34;SCALARS \u0026#34; \u0026lt;\u0026lt; \u0026#34;CON \u0026#34; \u0026lt;\u0026lt; \u0026#34;float 1\\n\u0026#34;; 20 ofs \u0026lt;\u0026lt; \u0026#34;LOOKUP_TABLE default\\n\u0026#34;; 21 for (size_t j = 0; j \u0026lt; Ny; j++) { 22 for (size_t i = 0; i \u0026lt; Nx; i++) { 23 ofs \u0026lt;\u0026lt; mesh.at(j).at(i) \u0026lt;\u0026lt; endl; 24 } 25 } 26 ofs.close(); 27} 至此我们完成了主网格的设计。\n更灵活的边界条件 接下来我们就需要设计如何实现多边界条件。因为我们可能会遇到多种多样的边界条件，而在二维模拟域中我们只有 4 条边界。因此，我们可以用一个类来统筹管理这四条边界，即为在前面网格中出现的 BoundFuncs\u0026lt;T\u0026gt;，而这四条边界究竟是什么样的，我们则需要根据实际需求在主函数中去告诉它。为了实现这一点，我们采用抽象类 AbstractBounds\u0026lt;T\u0026gt; 和可以被子类重载的虚函数 bound_x_m。\n1template \u0026lt;typename T, typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt;\u0026gt; 2struct AbstractBounds { 3 virtual ~AbstractBounds() = default; 4 virtual T bound_x_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t y) const = 0; 5 virtual T bound_x_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t y) const = 0; 6 virtual T bound_y_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t x) const = 0; 7 virtual T bound_y_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t x) const = 0; 8}; 9 10template \u0026lt;typename T, typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt;\u0026gt; 11struct BoundFuncs { 12 using BoundPtr = std::shared_ptr\u0026lt;const AbstractBounds\u0026lt;T\u0026gt;\u0026gt;; 13 14 BoundPtr x_m; 15 BoundPtr x_p; 16 BoundPtr y_m; 17 BoundPtr y_p; 18 19 // default: periodic boundary 20 21 BoundFuncs(BoundPtr _x_m, BoundPtr _x_p, BoundPtr _y_m, BoundPtr _y_p) 22 : x_m(std::move(_x_m)), x_p(std::move(_x_p)), y_m(std::move(_y_m)), y_p(std::move(_y_p)) { 23 if (!x_m || !x_p || !y_m || !y_p) { 24 throw std::invalid_argument(\u0026#34;Boundary condition pointers must not be null\u0026#34;); 25 } 26 } 27}; 这里 bound_x_m 等函数默认接收 const Mesh\u0026lt;T\u0026gt;$ 和 size_t 作为参数，因为这些内容在涉及到边界计算时是极为常见的，我们就不单独将二者作为子类的成员提出后单独赋值再计算，而是采用这种方式。在创建边界条件时，我们需要用 std::make_shared 来承接抽象类，且得到的 shared_ptr 可以被复制和共享。接下来是设计对应的边界条件，这里我们设计了两种：周期性边界条件与固定边界条件。实现如下：\n1template \u0026lt;typename T, typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt;\u0026gt; 2struct PeriodicBound : AbstractBounds\u0026lt;T\u0026gt; { 3 T bound_x_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t y) const override { 4 return mesh.get(mesh.get_dim_x() - 1, y); 5 } 6 T bound_x_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t y) const override { 7 return mesh.get(0, y); 8 } 9 T bound_y_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t x) const override { 10 return mesh.get(x, mesh.get_dim_y() - 1); 11 } 12 T bound_y_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;mesh, size_t x) const override { 13 return mesh.get(x, 0); 14 } 15}; 16template \u0026lt;typename T, typename = std::enable_if_t\u0026lt;std::is_floating_point_v\u0026lt;T\u0026gt;\u0026gt;\u0026gt; 17struct FixedBound : AbstractBounds\u0026lt;T\u0026gt; { 18 T fixed_val; 19 FixedBound(T _val) : fixed_val(_val) {} 20 T bound_x_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;, size_t) const override { 21 return fixed_val; 22 } 23 T bound_x_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;, size_t) const override { 24 return fixed_val; 25 } 26 T bound_y_m(const Mesh\u0026lt;T\u0026gt; \u0026amp;, size_t) const override { 27 return fixed_val; 28 } 29 T bound_y_p(const Mesh\u0026lt;T\u0026gt; \u0026amp;, size_t) const override { 30 return fixed_val; 31 } 32}; 如果需要别的边界条件，且新的边界条件需要依赖更复杂的参数时，就可以创建新的抽象类实现，将参数作为子类的成员加以描述。实际的代码中我们还让 BoundFucs\u0026lt;T\u0026gt; 默认取周期性边界条件，方便平时使用（很多模拟都偏爱周期条件）。\n有了上面重新整理的类，再加上之前的函数组件，我们就可以开始在 main() 函数组装整个计算逻辑了。\n主函数 最后我们看一下 main() 函数的写法：\n1int main(int, char **) { 2 3 const auto timepoint_start = hrc::now(); 4 5 // Parameters ... 6 7 auto dF_dc = [A, kappa](float v_1, float v_2, float v_3, float v_4, float v_5, float v_6) { 8 return df_bulk_dc(v_1, A) - kappa * laplacian(v_1, v_2, v_3, v_4, v_5, v_6); 9 }; 10 auto dc_dt = [M, dt](float v_1, float v_2, float v_3, float v_4, float v_5, float v_6) { 11 return dt * M * laplacian(v_1, v_2, v_3, v_4, v_5, v_6); 12 }; 13 auto add_noise = [dcon]() { 14 std::random_device rd; 15 std::uniform_real_distribution\u0026lt;float\u0026gt; dist(-dcon, dcon); 16 return dist(rd); 17 }; 18 19 using PB = PeriodicBound\u0026lt;float\u0026gt;; 20 BoundFuncs\u0026lt;float\u0026gt; boundary( 21 std::make_shared\u0026lt;PB\u0026gt;(), 22 std::make_shared\u0026lt;PB\u0026gt;(), 23 std::make_shared\u0026lt;PB\u0026gt;(), 24 std::make_shared\u0026lt;PB\u0026gt;()); 25 Mesh\u0026lt;float\u0026gt; con_mesh(Nx, Ny, dx, dx, boundary, con_init); 26 27 con_mesh.update(add_noise); 28 29 cout \u0026lt;\u0026lt; \u0026#34;Initialize complete\u0026#34; \u0026lt;\u0026lt; endl; 30 31 for (size_t istep = 0; istep \u0026lt; Nstep + 1; istep++) { 32 33 auto df_dc = con_mesh.iterate_mesh(dF_dc); 34 auto dc = df_dc.iterate_mesh(dc_dt); 35 con_mesh.update(dc); 36 37 // result output 38 if (istep % 100 == 0) { 39 con_mesh.write_vtk(\u0026#34;./output\u0026#34;, istep); 40 cout \u0026lt;\u0026lt; \u0026#34;Result \u0026#34; \u0026lt;\u0026lt; istep \u0026lt;\u0026lt; \u0026#34; outputed\u0026#34; \u0026lt;\u0026lt; endl; 41 } 42 } 43 44 const auto timepoint_stop = hrc::now(); 45 const auto time_cost = duration_cast\u0026lt;milliseconds\u0026gt;(timepoint_stop - timepoint_start); 46 cout \u0026lt;\u0026lt; \u0026#34;Calculation time cost: \u0026#34; \u0026lt;\u0026lt; static_cast\u0026lt;double\u0026gt;(time_cost.count()) / 1000.0 \u0026lt;\u0026lt; \u0026#34;seconds.\u0026#34; \u0026lt;\u0026lt; endl; 47} 可以看到这份代码结构更加清晰，main() 函数内完全被划分为三大块：模拟准备，时间循环 和 后处理。而且整体逻辑更清晰一些，完全围绕 con_mesh 以及其衍生网格结构发展，得到的结果也可以快速迭代回自身。借助 Mesh.update() 函数，我们可以让添加噪音的过程变成一次数值更新赋予网格中的每个点。而时间循环中真正实现了三步走：计算两个依赖网格的量，然后将结果迭代回原网格。\n这份代码同样上传到了这个博客里，你可以通过点击 这里 来浏览并下载这份源代码。\n总结与后记 其实一开始我是希望快速完成 C++ 以及 Python 的实现，然后快进至更有趣的一些语言的。然而在我开始动笔写 C++ 的实现时，我发现缺了很多东西：没有对相场的介绍，没有对模拟的介绍，也没有对语言的介绍。再将它们补充后，我又在想，C++ 的实现能不能再多一些有趣的，我之前没有试过的实现方式？这就成为了第一个案例。而在那之后，我发现 C++ 身为一个支持多范式编程（面向过程，面向对象，函数式，元编程等等）的语言，我竟然只草草地只是加了一些现代 C++ 的工具类之后就用面向过程的方式写完了。于是便有了第二个实现的结果。\n平心而论，第二种实现因为涉及到的虚函数，抽象类这些平时不怎么用的东西，我也是边学边写的，而且也因为如此，第二份代码的性能略低于第一份，有很大的 Over-engineering 的嫌疑。不过感谢 RAII，从计时器的结果来看二者相差并不大，顶多只有 1s 不到的差距。再者，这里的实现并没有过多地考虑计算效率，（也许）更多地希望代码简洁易懂，所以这儿并没有采用一些激进的计算优化措施，比如用更高效的 C++ 计算库，或者使用单 vector 来帮助编译器做向量化之类的。不过我们会在别的语言中尝试这些。\n这篇文章作为本系列的头阵，也许我们后续也会采用类似的方式来记录我是如何用别的语言实现这个模拟（或者其他模拟）的。如果这篇博客让您对相场法有了兴趣，愿意将代码在自己的机器上运行，并观察一下别的边界条件下的演化结果，那么这将是我莫大的荣幸。而若这篇博客的内容有什么错漏，也请海涵并在评论区指出，众多编程语言中我也就熟一点 C++，而且如您所见也写的不怎么好。若您对文章的内容有问题，意见或建议，也欢迎评论区交流~！\n那么，感谢您能看到这里，祝您生活愉快，happy coding!\n","date":"2026-03-24T00:00:00+08:00","image":"/images/Alice-2.png","permalink":"/zh/posts/pf_note/impl_spinodal/impl_spinodal_1/","title":"相场模拟，但是用很多语言 I"},{"content":"$$ % ===== ===== \\gdef \\vect #1{\\mathbf{#1}} % abstract vector \\gdef \\cvect #1{\\boldsymbol{#1}} \\gdef \\basis #1#2{\\mathcal{#1}_{#2}} % basis of vector space \\gdef \\basev #1#2#3{\\{\\vect{#1}_{#2}\\}_{#2=1}^{#3}} % base vector collection \\gdef \\cbasev #1#2#3{\\{\\cvect{#1}^{#2}\\}_{#2=1}^{#3}} % dual basis e^i \\gdef \\vrep #1#2{[\\vect{#1}]_{#2}} % coordinate representation [v]_B \\gdef \\rep #1{[\\vect{#1}]} \\gdef \\mrep #1#2#3{[{#1}]_{#2}^{#3}} % representation [L]_{C,B} % \\gdef \\iprod #1#2{\\langle #1, #2 \\rangle} % inner product \\gdef \\tran #1{\\vect{#1}^{\\mkern-1.5mu\\mathsf{T}}} \\gdef \\mat #1{\\mathbf{#1}} % matrix (representation) \\gdef \\field #1{\\mathbb{#1}} % \\gdef \\xto #1{\\xrightarrow{#1}} % arrow with label \\gdef \\xfrom #1{\\xleftarrow{#1}} % left arrow with label \\gdef \\Hom {\\operatorname{Hom}} % morphisms between A and B \\gdef \\Iso {\\operatorname{Iso}} \\gdef \\End {\\operatorname{End}} % \\gdef \\Aut {\\operatorname{Aut}} % \\gdef \\cat #1{\\mathsf{#1}} % category symbol: e.g., \\cat{Vect}, \\cat{Set} \\gdef \\Mat {\\operatorname{Mat}} \\gdef \\Bilin {\\operatorname{Bilin}} \\gdef \\t {^{\\mathsf{T}}} \\gdef \\id {\\mat{I}} % identity matrix \\gdef \\R {\\field{R}} % \\gdef \\C {\\field{C}} % \\gdef \\ot {\\otimes} % tensor product symbol \\gdef \\zero {\\vect{0}} % \\gdef \\one {\\vect{1}} % \\gdef \\idop {\\mathrm{id}} % identity morphism \\gdef \\comp {\\circ} % composition symbol \\gdef \\Set {\\cat{Set}} % category of sets \\gdef \\Vectk {\\cat{Vect}_{\\field{k}}} % category of vector spaces \\gdef \\Vect {\\cat{Vect}} % % \\gdef \\BaseB {\\basis{B}{}} \\gdef \\BaseC {\\basis{C}{}} \\gdef \\BaseBV {\\basis{B}{V}} \\gdef \\BaseCW {\\basis{C}{W}} \\gdef \\BaseE {\\basis{E}{}} \\gdef \\BaseH {\\basis{H}{}} $$书接上回，在对 $\\Hom(\\R,V)$ 以及 $\\Hom(V,\\R)$ 有了一定认识，且有了 对偶 这样特殊的对象后，我们终于可以出发研究线性映射空间 $\\Hom(V,W)$ 了。而矩阵空间 $\\Mat(m,n)$ 与线性映射空间之间又有什么样的联系呢？让我们就在这一章里探索一番吧！\n头图信息参考第一章，感谢~！本次选曲为 椎乃味醂 调教，初音未来与重音 Teto演唱，在 [VCCL 2025 夏] 上拿到第 7 名好成绩的 まだ知らない君がいる！/存在着我还不曾知晓的你！（B站链接）。非常强而有力的一首歌。本曲在网易云音乐需要会员，感兴趣可以至 B 站收听并观看精美 MV。原投稿链接（Niconico）：まだ知らない君がいる！ - 初音ミク･重音テト\n前言 上一章里，我们以 $\\Hom(\\R,V)$ 和 $V^* = \\Hom(V,\\R)$ 为例，简单看了看 $\\Hom(V,W)$ 这一类线性空间中的两个特殊的例子，以及它们和矩阵表达之间的关系。由于线性空间、对偶空间和双对偶空间之间的关系，我们可以把余向量看作向量到 $\\R$ 的函数，同时可以把向量看作它的二次对偶，从而成为余向量到 $\\R$ 的函数。从线性映射的矩阵表达，我们也能理解它们为什么可以写成列向量和行向量。\n然而，我们依旧没有完全阐明 $\\Hom(V,W)$ 的内部结构。它和矩阵空间 $\\Mat(m,n)$ 之间究竟是什么样的关系？这个空间的基是什么样的？它自己的对偶空间是是什么样的？本章我们就来着重研究这个更加一般的线性空间。\n不过在直接深入这个线性空间之前，我们先回到线性空间的基上，就这个重要的概念加一些说明。\n基：有限与无限的桥梁 在 线性代数笔记 I 中，我们首次引入了线性空间的基的概念，并且在研究后续提到的线性空间的过程中均或多或少地使用了这个概念。我们在这里简单回忆它的定义：\n[!NOTE]{线性空间的基}\n线性空间 $V$ 的基是它的一个子集 $\\BaseBV$，里面的向量称为基向量，这个子集可以张成这个线性空间，而里面的基向量又是两两线性独立的。\n由于线性张成以及线性无关的特点，在选定线性空间的基之后，空间中的所有向量都可以被唯一地表示为基向量的线性组合。\n再回忆我们之前使用基的情形，我们常常能把任意一个向量化为一组基以及它的线性组合，从而由对这个线性组合的操作来证明线性空间的一系列性质。线性空间中的元素（向量）从集合论的角度来讲应该是无穷多的，但是通过它的基，线性空间的 无穷性 被基这个概念给消解了，变成有限的，可处理的数学对象了。基就像是沟通有限（有限个系数）和无限（无限多的向量）的桥梁。\n具体地说，当我们本来要处理一个不知道具体情况的线性空间中的向量时，我们可以借由 线性空间总有一组基 1 这一点，来先选择一组基，进而根据 向量可以被一组基唯一地用一组系数表示出来 来将向量表达为一组数。随后经过对这组数的一些处理，得到我们需要的，这个向量 在这组基下的 结论后，我们可以在最后说明：这一组基是 任意 选取的，而得到这个结论的过程中不依赖特定基的选取，因此即便换一组基，我们依然能得到这个结论，从而成功地说明了这个任意选取的向量的一些结论。\n这一点在研究线性空间之间的同态（线性映射）时颇为关键：直接构建两个抽象线性空间之间的线性映射是不那么现实的，而当我们给线性映射左右两边的线性空间都所选择一组基后，线性映射似乎就完全变成了从基到基的映射了，空间中的一组基在映射后怎么用另一空间中的基表示出来。\n于是，我们似乎可以说：线性空间的性质全部凝结在它的基上，而研究线性映射的问题则在给两边选择好基后成为了研究线性表出系数关系的问题。我们会在后面的部分验证这句话是否合理。\n是时候出发了，将目光转向 $\\Hom(V,W)$ 吧。有了 $\\Hom(V,\\R)$ 和 $\\Hom(\\R,V)$ 的性质（特别是它们的基），相信您对 $\\Hom(V,W)$ 也有了一些直观上的认识。\n再探 $\\operatorname{Hom}(V,W)$ 我们在上一章中提到过矩阵和线性映射的关系，我们知道，线性映射在两端选择好基之后，线性映射可以被表达为一个矩阵。我们还指出了，这样的矩阵全体又能够构成一个线性空间，它的基为在某一个位置为 $1$，其余均为 $0$ 的矩阵的集合。如果这个线性空间中的每个矩阵是 $m\\times n$ 的，那么它的一组基一共有 $mn$ 个基向量。那么，如果在选定了线性映射两边空间的基后，所有可能的线性映射都可以被 唯一地 表达为一个矩阵，那么我们就能在线性映射的集合与矩阵集合之间建立一个双射；如果，进一步地，这个双射能够保留两边作为线性空间中定义的两种运算，那么我们就成功地将两个空间用同构联系在了一起。那么我们要怎么做呢？\n$\\operatorname{Hom}(V,W)$ 与 $\\operatorname{Mat}(m,n)$ 那么，要怎么证明唯一性呢？其实我们只需要检查线性映射被表达为一个矩阵的过程即可。回顾 线性映射的两种路径，在选择好基后，基向量 $\\vect{b}_j$ 在线性映射 $L$ 下的像 $\\vect{f}_j$ 是唯一的，$\\vect{f}_j$ 的线性表出也是唯一的，而后者的线性表出系数就是我们要的矩阵系数 $A^i{}_j$ 。因此，在选择好两个线性空间的基后，这两个线性空间之间的线性映射都能被唯一地表达为一个矩阵了。\n那么，这个双射能保留 $\\Hom(V,W)$ 与 $\\Mat(m,n)$ 上定义的加法与数乘吗？完全可以。实际上，因为我们给 $\\Hom(V,W)$ 的加法和数乘定义是逐点的，而我们给 $\\Mat(m,n)$ 的加法与数乘的定义也是逐点的，两边的这两种运算的保留是非常明显且直观的。比如，有两个线性映射 $T,S\\in\\Hom(V,W)$，它们的矩阵表达为 $A,B\\in\\Mat(m,n)$，则 $T+S$ 完全对应了 $A+B$：一个 $V$ 中的向量 $\\vect{v}$ 在 $T+S$ 的映射后，其在 $W$ 中的线性表出系数完全可以由矩阵 $A+B$ 左乘以 $\\vect{v}$ 的列矩阵（列向量）。数乘也是如此，我们就不再赘述。\n总之，当 给两边的线性空间选择好基之后，我们就可以把线性映射空间与矩阵空间联系起来，它们俩之间就有了同构。不过，这个条件有点扎眼：我们非得选择好基之后，才能给 $\\Hom(V,W)$ 与 $\\Mat(m,n)$ 之间用同构联系起来吗？换言之，这个同构是自然的吗？\n从 $\\Hom(V,\\R)$ 与 $V^*$ 的关系，也许大家也能猜到，这个同构不是自然的。简单来说，当我们给 $V$ 选定一组基之后，$W$ 的基的选择会影响 $V$ 的基向量在 $W$ 中的线性表出系数，进而影响这个线性映射的矩阵表示，从而最终影响它们之间的这个同构。\n在我们研究 $\\Hom(V,W)$ 的基之前，我们先给 $\\Mat(m,n)$ 的基一些记号。在上一章中，我们提到，$\\Mat(m,n)$ 可以有一组最简单的基，其基向量即为在某一行的某一列处取值 $1$ 而让其他位置的值均为 $0$ 的矩阵。我们希望用一套记号来表示这个基向量以及矩阵空间的基。\n[!NOTE]{矩阵空间的基向量和基的记号}\n对于矩阵空间 $\\Mat(m,n)$，我们记在第 $i$ 行 ($0\\leq i \\leq m$) 第 $j$ 列 ($0\\leq j \\leq n$) 的元素为 $1$ 而其余元素均为 $0$ 的矩阵为 $\\mat{E}_i{}^j$，所有这样的矩阵的集合成为 $\\Mat(m,n)$ 的一组基，成为其标准基 $\\basis{E}{}{}^m_n$。称 $\\mat{E}_i{}^j$ 为 $\\Mat(m,n)$ 的第 $(i,j)$ 个基向量。\n$\\operatorname{Hom}(V,W)$ 的基 那么，作为线性空间，它的基到底是什么样的？由于它与 $\\Mat(m,n)$ 存在同构，我们是否可以从 $\\Mat(m,n)$ 的基出发，来反推出 $\\Hom(V,W)$ 的基的含义？\n我们先选好 $V$ 和 $W$ 的基 $\\BaseBV$ $\\BaseCW$，此时考虑 $\\Mat(m,n)$ 的标准基，取其中第 $(i,j)$ 个标准基矩阵 $\\mat{E}_i{}^j$，它的第 $i$ 行第 $j$ 列的矩阵元为 $1$，其余均为 $0$。\n我们尝试将 $\\mat{E}_i{}^j$ 在 $\\Hom(V,W)$ 的框架下做出解释，即这个基向量是这样的一个线性映射，它将 $\\BaseBV$ 中的第 $j$ 个向量在映射到 $W$ 后得到了 $\\BaseCW$ 中的第 $i$ 个向量，而其余 $\\BaseBV$ 中的向量均被映射到 $W$ 的原点处。这个做法很像我们在 $\\Hom(V,\\R)$ 中见到的那样：将 $V$ 中的某个基向量映射到 $1$ 处，让其余的所有基向量都映射到 $0$ 上。只不过这次，因为 $W$ 中不只有一个维度，我们需要指定 $V$ 中的这个基向量应该被映射到哪个 $W$ 中的基向量上。\n我们形式化地把它的基向量写出来：记 $\\Hom(V,W)$ 的基为 $\\BaseH{}_{V}{}^W$，第 $(i,j)$ 个基向量记为 $ {H}_i{}^j$，则我们上面定义的基向量的形式化定义为：\n$$ {H}_i{}^j (\\vect{b}_k) = \\begin{cases} \\vect{c}_i \u0026\\text{if }\\ k=j\\\\ 0_W \u0026\\text{if }\\ k\\neq j\\\\ \\end{cases} $$这个形式中总是让人感到它暗含了 Kronecker delta。没错，它可以被表示为：\n$$ {H}_i{}^j (\\vect{b}_k) = \\delta^j{}_k \\vect{c}_i, $$其中 $0 \\vect{c}_i = 0_W$ 被隐含了。\n这样的线性映射能张成这个线性空间吗？答案是肯定的。我们只要凑够所有的（$mn$ 个）这样的基向量，就可以通过线性组合实现这样的事：一个 $\\Hom(V,W)$ 中的线性映射，先将 $V$ 中的第一个基向量映射到 $W$ 的第一个基向量上，得到一个分量，然后在将它映射到第二个基向量上，再是第三个，第四个……随后对 $V$ 的第二个基向量做同样的处理，第三个，第四个……直到所有 $V$ 中的基向量都在 $W$ 中成功表示出来，从而成为一个完整的，从 $V$ 到 $W$ 的线性映射。\n当然，一个线性空间中理应有无数多个基，我们姑且称刚刚构建出来的基为在 $\\BaseBV$ 与 $\\BaseCW$ 下的 诱导基，因为它便于解释，也方便进行后续的处理。我们称上面处理的那个基向量为第 $(i,j)$ 个基向量。另外值得注意的是，即便这里我们称之为 基向量，但它却是 $\\Hom(V,W)$，线性映射空间的基向量，它实际上是一个 $V$ 到 $W$ 的线性映射。由于语言的贫瘠，我们滥用一下定义，也称它为基向量，请注意区别。\n最后，我们给出这个基向量的定义：\n[!DEF]{$\\operatorname{Hom}(V,W)$ 的诱导基}\n向量空间 $\\Hom(V,W)$ 的 诱导基 是一组由 $V$ 到 $W$ 的，依赖于 $V$ 与 $W$ 的基的选取的线性映射。其中的基向量满足条件\n$$ {H}_i{}^j (\\vect{b}_k) = \\delta^j{}_k \\vect{c}_i,$$其中 $\\delta^j{}_k$ 为 Kronecker delta，$\\vect{b}_k$ 为 $V$ 中的第 $k$ 个基向量，$\\vect{c}_i$ 为 $W$ 中的第 $i$ 个基向量。称这个基向量为 $\\Hom(V,W)$ 的第 $(i,j)$ 个（诱导）基向量。\n$\\operatorname{Hom}(V,W)$ 的对偶 我们在得到一个线性空间的基本信息后，总是很难不去想这个线性空间的对偶空间是什么样的。根据对偶的定义，我们知道它的对偶是 $\\Hom(V,W)^* = \\Hom(\\Hom(V,W),\\R)$，即给每个从 $V$ 到 $W$ 的线性映射都赋予一个实数。这个对偶空间的基向量，根据我们在上一章聊过的对偶基的概念，如果作用在对应的基向量上则给出 $1$，而作用在其他的基向量上则给出 $0$。\n然而我们观察到，$V$ 与 $\\Hom(\\R,V)$ 之间存在自然同构，而 $V$ 的对偶空间是 $\\Hom(V,\\R)$，也就是说，$\\Hom(\\R,V)$ 的对偶空间是 $\\Hom(V,\\R)$。这是否暗示，$\\Hom(V,W)$ 的对偶空间是 $\\Hom(W,V)$？如果真的如此，那么 $\\Hom(W,V)$ 的基向量就成为 $\\Hom(V,W)$ 的对偶基向量了。为了确定这件事，我们还是先从对偶基开始，看看在使用对偶基的定义时，$\\Hom(V,W)$ 的基向量会给出什么样的对偶空间的基向量吧。\n$\\operatorname{Hom}(V,W)$ 的对偶基向量 依旧，我们先给 $V$ 和 $W$ 找好对应的基向量，然后掏出 $\\Hom(V,W)$ 在这两个基下的诱导基。取其中第 $(i,j)$ 个基向量，尝试定义它的对偶基向量。它的对偶向量应该也是一个线性映射，它在作用上 $\\Hom(V,W)$ 的第 $(i,j)$ 个基向量时得到 $1$，而作用在其余的基向量上时则得到 $0$。我们记这个对偶基向量为 $ {\\Theta}^m{}_n$，形式化地，根据对偶基的定义，则有：\n$$ {\\Theta}^m{}_n ({H}_i{}^j) = \\begin{cases} 1\u0026\\text{if }\\ m=i \\land n = j;\\\\ 0 \u0026\\text{if }\\ m\\neq i \\lor n \\neq j.\\\\ \\end{cases} $$要怎么解释这里的 $1$ 和 $0$ 呢？这样线性映射的复合应该得到另一个线性映射才对呀？不过我们也可以方便地解释这一点：当一个线性映射经过什么东西的作用后得到了数字 $1$ 和 $0$ 时，我们可以认为这实际上是得到了 恒等映射 以及 零映射。可是，这个恒等映射和零映射的定义域和陪域都是谁呢？我们可以从作用到一个具体元素的结果上着手考察这个问题。\n由于 $ {H}_i{}^j (\\vect{b}_k) = \\vect{c}_i \\delta^j{}_k $, 当我们尝试给这个定义中的式子再套上 $\\Theta^m{}_n$ 时，得到的结果应该是：\n$$ \\Theta^m{}_n (H_i{}^j(\\vect{b}_k)) = \\Theta^m{}_n (\\delta^j{}_k \\vect{c}_i) = \\begin{cases} \\vect{b}_k\u0026\\text{if }\\ m=i \\land n = j;\\\\ 0_V \u0026\\text{if }\\ m\\neq i \\lor n \\neq j.\\\\ \\end{cases} $$这下可以看到，$\\Theta^m{}_n$ 是一个从 $W$ 到 $V$ 的线性映射！那么作为这样一个线性映射，当与 $H_i{}^j$ 复合时得到了恒等映射，这是否能用 Kronecker delta 来表达呢？答案是肯定的：\n$$ \\Theta^m{}_n (H_i{}^j(\\vect{b}_k)) = \\delta^m{}_i\\delta^j{}_n\\vect{b}_k, $$注意到我们这里正巧利用了 0乘以任何数都是0 的特点，把 $m=i$ 且 $n=j$ 的条件用两个 Kronecker delta 来表达出来了。至此，我们正式给出 $\\Hom(V,W)$ 的对偶基向量定义：\n[!DEF]{$\\operatorname{Hom}(V,W)$ 的对偶基向量}\n我们将 $\\Hom(V,W)$ 的对偶基中的一个向量记作 $\\Theta^m{}_n$，其为一个由 $W$ 到 $V$ 的线性映射，满足条件： $$\\Theta^m{}_n (H_i{}^j) = \\delta^m{}_i\\delta^j{}_n.$$ 其中，$H_i{}^j$ 是 $\\Hom(V,W)$ 的第 $(i,j)$ 个诱导基向量。\n对偶基向量的其他解释 我们形式化地得到了 $\\Hom(V,W)$ 的对偶基向量，从而明白了它的对偶基情况，进而了解到，这个线性空间的对偶空间不是别的，就是 $\\Hom(W,V)$。然而，我们还可以从另一个角度去观察 $\\Hom(V,W)$ 的诱导基，它也可以给出对偶基的一些信息。我们简单描述一下。\n首先，我们从作用到基向量的情况出发，来考察对偶基向量的情况。当 $V$ 中的第 $j$ 基向量 $\\vect{b}_j$ 被映射到 $W$ 中的第 $i$ 个基向量 $\\vect{c}_i$ 上，而其他的 $V$ 中基向量被映射到 $W$ 中的零向量 $0_W$ 时，这个映射给出 $1$, 而若 $\\Hom(V,W)$ 中的其他的任何基向量作为输入，都只能得到 $0$。也就是说，将 $\\vect{b}_j$ 映射到其他 $W$ 中的其他基向量时，或者让 $V$ 中 的基向量 $\\vect{b}_k (k\\neq j)$ 映射到 $\\vect{c}_i$ 时，对偶基的映射都给出 $0$。\n我们观察上面的这些情形：当 $\\vect{b}_j$ 指向 $\\vect{c}_i$ 时，我们的对偶基向量给出 $1$；当 $\\vect{b}_j$ 不指向 $\\vect{c}_i$ 时，根据定义，它要么指向 $0_W$，要么指向别的 $\\vect{c}_l \\neq \\vect{c}_i$，而此时上面的对偶基向量都给出 $0$。\n不过，如果我们换一个角度呢？当 $\\vect{c}_i$ 和 $\\vect{b}_j$ 配对时，上述对偶基向量给出 $1$；当 $\\vect{c}_i$ 不与 $\\vect{b}_j$ 配对时，对偶基向量就给出 $0$。于是，这个对偶基向量实际上形成了一个选择：令 $W$ 中基向量 $\\vect{c}_i$ 对应到 $V$ 中基向量 $\\vect{b}_j$ 上，而其余的 $W$ 的基向量由于没有去处，就映射到 $0_V$ 上。这样的定义是完全符合 $\\Hom(W,V)$ 上第 $(j,i)$ 个诱导基向量的定义的。但总的来说，这个方法还是略显抽象。\n不过我们还有更简单的方法：从矩阵出发。首先，我们让 $\\Hom(V,W)$ 和矩阵空间 $\\Mat(m,n)$ 之间建立起由 $V$ 与 $W$ 的基诱导出的同构。然后我们考虑 $\\Mat(m,n)$ 的对偶基。它的对偶基应该是与原基相乘后得到 $1$ 的矩阵。如此一来，我们很快就明白了 $\\Mat(m,n)$ 的对偶空间应该是 $\\Mat(n,m)$，而给定条件下能满足需要的对偶线性空间中，最简单的就是 $\\Hom(W,V)$ 了。\n然而这个方法存在一些弊端，比如怎么确定就是 $\\Hom(W,V)$ 而不是 $\\Hom(W^*,V)$ 之类的线性空间，但它很好地给了我们一个方向。\n最后我们做一个总结，关于线性映射空间和它的对偶空间有这样的一个小结论：\n[!NOTE]{对偶与线性映射}\n对于线性映射空间 $\\Hom(V,W)$，其对偶空间为 $\\Hom(W,V)$，即交换前后两个空间的顺序，或者说让映射的箭头反向。\n注意到了吗？对偶和 让箭头调转方向 联系了起来。我们会在范畴论中见到很多对偶的概念，而它们中的大多数，到头来也就是让 箭头反向。也许我们还有机会见到这样的例子，不过这里就不多赘述了。毕竟，这个是线性代数的笔记，不是范畴论的笔记（）\n线性映射空间与矩阵空间的对偶 另一个自然而然且有趣的话题是，对偶空间内元素的矩阵表示是什么样的？然而在这个 $\\Hom(V,W)$ 的对偶空间中，结论是有点明显的。如果 $\\Hom(V,W)$ 这个线性空间对应的矩阵空间为 $\\Mat(m,n)$，那么 $\\Hom(V,W)^* = \\Hom(W,V)$ 对应的矩阵空间自然就是 $\\Mat(n,m)$ 了。\n然而尽管如此，想要知道每个元素的具体情况，我们依旧需要仔细考察。好消息是，对偶向量的概念能帮助我们观察对偶空间中的情况。要回顾 对偶向量 的概念，请参考这里。\n矩阵不止有一种乘法 这里我们取 $\\Mat(3,2)$ 中的一个矩阵 $\\mat{M}$：\n$$ \\mat{M} = \\begin{bmatrix} 1\u00262\\\\ 3\u00264\\\\ 5\u00266 \\end{bmatrix}, $$看看它的对偶向量是什么样的。为求得它，我们需要先得到 $\\Mat(3,2)$ 在基下的分解。我们这里就直接取 $\\Mat(m,n)$ 的标准基 $\\basis{E}{}{}_m^n = \\{\\vect{E}_{i}{}^j\\}_{1\\leq i\\leq m}^{1\\leq j\\leq n}$，得到的结果即为：\n$$\\mat{M} = 1\\vect{E}_{1}{}^1 + 2\\vect{E}_{1}{}^2 + 3\\vect{E}_{2}{}^1 + 4\\vect{E}_{2}{}^2 + 5\\vect{E}_{3}{}^1 + 6\\vect{E}_{3}{}^2.$$在此之后，我们需要保留这些线性表出系数，然后将每个基向量都换成对应的对偶基向量。根据对偶基向量的定义，我们有：\n$$ \\vect{F}^{i}{}_{j} \\vect{E}_{j}{}^{i} = 1, $$其中 $\\vect{F}^{i}{}_{j} \\in \\Mat(2,3)$。可是，这带来了一个问题：矩阵乘法不会给出一个数字。它给出的是一个矩阵，而且矩阵形状取决于两个矩阵和乘法方向！这该如何是好啊……\n好消息是，我们 没有定义矩阵空间中的乘法，也没有声明 只有一种矩阵乘法。事实上，矩阵乘法可以有很多种。而这里，为了得到一个值，我们 自定义一个乘法。乘法规则很简单，让“镜像位置”的分量相乘后，把所有的乘积相加。\n比如，一个 $m\\times n$ 的矩阵 $\\mat{A}$ 和 $n\\times m$ 的矩阵 $\\mat{B}$ 在我们的计算方法下相乘，得到的结果是：\n$$ \\mat{A} \\boxtimes \\mat{B} = \\sum_i^m \\sum_j^n A_{ij} B_{ji},$$其中 $\\boxtimes$ 是我们自定义的乘法，而 $A_{ij}$ 是矩阵 $\\mat{A}$ 的第 $(i,j)$ 个元素。这里我们只使用前后来表示矩阵元的位置。关于矩阵的记号我们后面会多聊一些。我们这里把这个乘法的定义写一下，并暂时给它一个名字。\n[!DEF]{矩阵的对偶乘法}\n定义 $\\Mat(m,n)$ 与 $\\Mat(n,m)$ 间的 对偶乘法 $\\boxtimes$ 为：\n$$\\begin{align*} \\boxtimes \u0026\\vcentcolon \\Mat(m,n) \\times \\Mat(n,m)\\to \\field{F} \\\\ \u0026\\quad \\mat{A}\\boxtimes\\mat{B} \\mapsto \\sum_i^m\\sum_j^n A_{ij} B_{ji},\\end{align*} $$其中 $A_{ij}$ 为 $\\mat{A}$ 的第 $(i,j)$ 个元素。\n顺带，由定义我们很容易知道，这个乘法的定义是符合交换律的，因为到头来也只是交换和式内部的乘法顺序，不会影响结果。另外，零矩阵会让乘积为 $0$，但为了得到 $0$ 的结果，两个矩阵可以不是零矩阵：在每个 对应 的位置上都出现一次 $0$ 就好了。\n矩阵空间的对偶空间\u0026amp;矩阵的对偶向量 那么，在我们的 $\\boxtimes$ 的定义之下，$\\Mat(m,n)$ 的矩阵就可以和 $\\Mat(n,m)$ 的矩阵相乘并得到一个数了。我们顺理成章地定义 $\\vect{F}^{i}{}_{j}$ 的形式，即只在第 $i$ 行第 $j$ 列的位置上元素为 $1$ 而其余矩阵元全为 $0$ 的矩阵。就这样，我们得到了矩阵空间 $\\Mat(m,n)$ 的对偶基，进而得到了矩阵空间的对偶。\n回到最开始的矩阵 $\\mat{M}$，经过在 $\\mat{M}$ 的分量表示中替换 $\\mat{E}_i{}^j$ 为对应的对偶基向量 $\\mat{F}^j{}_i$，我们得到这样的结果：\n$$ \\mat{M}^* = \\begin{bmatrix} 1\u00263\u00265\\\\ 2\u00264\u00266\\\\ \\end{bmatrix}. $$如此，我们得到了矩阵在矩阵空间中的对偶向量。把一个 矩阵 叫做 对偶向量 总是让人感觉怪怪的，而我们后面可能又会遇到更多奇怪的东西，它们各自也有自己的对偶。因此，自此以后，我们称呼这类事物为 对偶元素，在一般线性空间和向量的情况下，向量的对偶元素还是一个向量，又称对偶向量；在矩阵线性空间中，矩阵也有自己的对偶元素，也是一个矩阵；对线性映射而言，它也有自己的对偶元素，同样是一个线性映射。\n转置 虽然上面的结果我们称它为 矩阵的对偶，但是这个定义似乎太局限：我们依赖了一个自定义的乘法，才得到了这么一个结果，而从根本上来讲却又只是把元素重新排列一下。对于 纯矩阵 来讲，我们完全可以直接从元素排列的角度出发，把元素像上面那样重排来得到一个新的矩阵。这样的重排操作，由于非常实用（也非常单纯），被人们称为 转置。\n[!DEF]{矩阵转置}\n设一矩阵 $\\mat{A}$ 为矩阵集合 $\\Mat(m,n)$ 中的元素，其第 $(i,j)$ 个矩阵元记为 $A_{ij}$，则其 转置 的结果记作 $\\tran{A} \\in \\Mat(n,m)$，第 $(i,j)$ 个矩阵元为 $[\\tran{A}]_{ij} = A_{ji}$。\n矩阵转置是依赖且仅依赖矩阵元素排列顺序的。实际上我们最早可以在引入矩阵的同时就介绍矩阵的转置了。没有这么做的主要原因在于，没什么动机/动力去做这件事。一个矩阵可以被转置生成另一个矩阵，然后呢？希望这里的 对偶 能够回答一部分问题：线性映射的对偶元素，在将二者表达为矩阵时，二者之间只差一个转置操作。\n那么为什么不用矩阵的对偶来替代矩阵转置呢？原因其实在上面已经提过了：矩阵的对偶是严格依赖矩阵空间作为线性空间的事实以及上面所定义的 矩阵对偶乘法 的定义的。当我们提到矩阵的对偶元素时，必然要预设这些背景才可以进一步讨论。而矩阵转置则“轻量化”许多：它就只是单纯地重新排列一下矩阵元素。\n当然，我们也可以赋予矩阵转置一些性质，比如矩阵的转置是一种对偶。也许你可以说，转置也是掉换了元素排列方向，这和对偶调换箭头方向是相似的。后面在专门聊矩阵的时候，我们还会再次遇到矩阵转置。\n$\\operatorname{End}(V)$ 与 $\\operatorname{Mat}(n)$ 在了解过一般线性映射空间 $\\Hom(V,W)$ 之后，我们来考察一类特殊的线性映射空间 $\\End(V)$，即 $\\Hom(V,V)$，以及这个线性映射空间对应的矩阵空间 $\\Mat(n)$。\n在第一章中，我们提到过所有 $V$ 上的线性变换全体称为 $\\End(V)$，这里的 $\\End$ 是 Endomorphism 的缩写，意为 自同态。而这里的 $\\Mat(n)$ 是对 $\\Mat(n,n)$ 的简写，即 $n\\times n$ 的 方阵 集合。自同态带来了很多有趣的性质，最明显的一点即为同态的复合是封闭的，即：\n$$L(R(\\vect{v}))\\in V\\ \\forall L,R\\in \\End(V), $$或者，如果我们接受只对线性映射复合的运算的话，\n$$ L(R) \\in \\End(V)\\ \\forall L,R\\in \\End(V).$$对应到矩阵空间，就是方阵的乘法总是得到同型方阵：\n$$ \\mat{AB}\\in \\Mat(n)\\ \\forall A, B\\in \\Mat(n).$$我们来研究一下这些特殊的性质。\n自同态复合与双线性映射 我们从线性自同态入手，方阵由于是被线性自同态完全决定的，因此方阵的情况我们这里就不再赘述。\n线性自同态的复合由于满足封闭性，我们可以认为它是某种独立于加法和数乘的新运算。既然如此，我们就来看看，线性自同态的复合（以下简称映射复合）与原本定义的加法和数乘之间有什么联系，三者之间能给出什么样新的性质。\n首先是加法。我们知道，加法和数乘之间是有分配律的，直观上讲，数乘从外部作用到（缩放）一个计算好的向量，和把参与加法的每个向量缩放后得到的结果再相加，结果是一样的。这似乎对映射复合也是成立的：\n取 $\\vect{v}\\in V$ 以及 $L,R,S\\in\\End(V)$，我们有：\n$$ \\begin{align*} (L(R+S))(\\vect{v}) \u0026= L((R+S)(\\vect{v})) = L(R((\\vect{v})+S(\\vect{v}))) \\\\ \u0026= L(\\vect{u} + \\vect{w}) =L(\\vect{u}) + L(\\vect{w})\\\\ \u0026= L(R((\\vect{v}))+L(S(\\vect{v}))) =L(R)(\\vect{v}) + L(S)(\\vect{v})\\\\ \u0026= (L(R)+L(S)) (\\vect{v}) \\end{align*} $$我们给 $L(R+S)$ 这样一个映射复合作用上了一个向量 $\\vect{v}$，检查了这个向量的参与下这个线性映射的形式能改变成什么样。整个过程几乎就是反复利用线性映射的性质，中间在需要的时候用两个向量来代表 $\\vect{v}$ 的不同的像。这里得到了一个似乎与 $\\vect{v}$ 无关的，只和线性映射本身相关的结果。\n那么，翻过来又如何呢？我们试一试：\n$$\\begin{align*} (L+R)(S(\\vect{v})) \u0026= ((L+R)S)(\\vect{v}) \\\\ \u0026= (L+R)(\\vect{w}) = L(\\vect{w}) + R(\\vect{w})\\\\ \u0026= L(S(\\vect{v})) + R(S(\\vect{v}))\\\\ \u0026= L(S)(\\vect{v})+R(S)(\\vect{v})\\\\ \u0026= (L(S)+R(S))(\\vect{v}), \\end{align*} $$这里也是像上面那样不断地利用定义，最后得到了一个和 $\\vect{v}$ 似乎无关的结果。\n那除了加法，数乘又如何呢？我们依旧放入一个向量来测试一下：\n$$ \\begin{align*} (a\\cdot L(R))(\\vect{v}) \u0026=a\\cdot L(R(\\vect{v})) = a\\cdot L(\\vect{w}) \\\\ \u0026= (a\\cdot L)(\\vect{w}) = (a\\cdot L)(R)(\\vect{v}) \\\\ \u0026= L(a(R(\\vect{v}))) = L((a\\cdot R)(\\vect{v}))\\\\ \u0026= L(a\\cdot R)(\\vect{v}). \\end{align*} $$上面的操作令人有点摸不着头脑，只不过是重复应用线性映射的性质以及定义在线性空间 $\\End(V) = \\Hom(V)$ 中的加法和数乘的定义罢了。然而，当我们去掉三个式子最后的 $\\vect{v}$ 时，就有神奇的事发生了：\n$$L(R+S) = L(R) + L(S)$$ $$(L+R)S = L(S) + R(S)$$ $$ a\\cdot(L(R)) = (a\\cdot L)(R) = L(a\\cdot R)$$而当我们将 映射的复合 看成一种 乘法，且我们不再用括号代表映射复合，而是只用来代表运算顺序，直接将映射按顺序写好代表复合时，我们得到：\n$$L(R+S) = LR + LS$$ $$(L+R)S = LS + RS$$ $$ a\\cdot (LR) = (a\\cdot L)R = L(a\\cdot R)$$我们可以看到：这个 乘法 有左分配律，有右分配律，且对每个位置都是线性的。这样的乘法很特别，我们特别地将它称为 双线性映射，因为它对参与乘法的前后两个位置都满足线性性。我们给出双线性映射的定义：\n[!DEF]{双线性映射}\n设有 $\\field{k}$ 上的线性空间 $V,W,U$，且有 $\\vect{v}\\in V, \\vect{w}\\in W, \\vect{u}\\in U$，再设 $a,b\\in \\field{k}$；定义映射 $B$：\n$$ \\begin{align*} B\u0026\\vcentcolon V\\times W \\to U\\\\ \u0026\\quad (\\vect{v},\\vect{w})\\mapsto B(\\vect{v},\\vect{w})\\in U,\\end{align*} $$ 若它满足下列三条性质：\n$B(\\vect{v},\\vect{w}+\\vect{u})=B(\\vect{v},\\vect{w})+B(\\vect{v},\\vect{u})$; $B(\\vect{v}+\\vect{w},\\vect{u})=B(\\vect{v},\\vect{u})+B(\\vect{w},\\vect{u})$; $B(a\\vect{v},b\\vect{w})=ab B(\\vect{v},\\vect{w})$, 则此时我们称映射 $B$ 为 $\\field{k}$ 上的 双线性映射。\n等价地，若对于任何 $\\vect{w}\\in W$，映射 $$\\vect{v}\\mapsto B(\\vect{v},\\vect{w})$$ 是 $V$ 到 $U$ 的线性映射，且对任何 $\\vect{v}\\in V$，映射 $$\\vect{w}\\mapsto B(\\vect{v},\\vect{w})$$ 是 $W$ 到 $U$ 的线性映射，则称 $B$ 为 $\\field{k}$ 上的双线性映射。\n我们可以检验，当我们定义映射复合为一个从 $\\End(V)\\times \\End(V)$ 到 $\\End(V)$ 的二元乘法时，这个二元乘法是满足双线性映射的定义的。另外，我们还可以将这个概念不断拓展，得到所谓的 多线性映射。本笔记的目标之一，就是写到这个 多线性映射 为止，也就是所谓的 张量，但是现在，我们还有别的更有趣的对象值得研究。\n双线性这样的性质非常吸引数学家们的关注，尤其是当这种运算定义在一个线性空间上并回到它自身的时候。因此，在给线性空间附带了一个从自己到自己的双线性二元运算后，人们便给这个线性空间以新的名字：代数，或者更准确地讲，域上的代数。\n域上的代数 我们先给出它的定义。\n[!DEF]{域上的代数}\n设 $A$ 为一数域 $\\field{k}$ 上的线性空间，定义 $\\field{k}$-双线性二元乘法：\n$$\\begin{align*} \\cdot\u0026\\vcentcolon A\\times A \\to A\\\\ \u0026\\quad (\\vect{x},\\vect{y})\\mapsto \\vect{x}\\cdot\\vect{y}, \\end{align*} $$则称 $A$ 为 数域 $\\field{k}$ 上的代数。\n为什么人们称它为 代数 呢？明明这个学科已经叫代数学，而这个子类别更是叫 线性代数 了。这个嘛，我也不知道。不过这倒是能从另一种角度解释“线性代数研究什么”的问题了。如果采用 域上的代数 的角度，我们可以说，线性代数就是在研究线性的代数（笑）2。\n除了从线性空间出发，我们观察到，一个域 $\\field{k}$ 上的代数拥有三种计算：数乘，加法和乘法。如果我们去掉数乘，而只保留加法和乘法呢？我们得到的就是 环。因此，我们还可以从环的角度，用 环+额外的数乘结构 来定义域上的代数。我们暂时不会考虑环这个代数结构，因此这里就不再赘述。不过，得益于代数的多重身份，我们不仅可以利用线性代数的性质来研究它，也利用环的性质来研究它。\n比起线性自同态构成的代数，实际上人们更多地关心方阵在矩阵乘法下构成的代数，它被称为 矩阵代数。由于方阵和线性自同态之间的关系清晰且紧密，验证 所有 $n$ 阶方阵全体在加法、乘法与数乘下构成 $\\field{k}$ 上的代数 的命题就交给读者自己尝试了。理应比上面验证 $\\End(V)$ 是代数要简单的多（毕竟大家对矩阵还是熟悉一些吧）。\n最后我们指出：这里的 $\\End(V)$ 和 $\\Mat(n)$ 在乘法下形成的代数天然拥有 乘法结合律，因此更严谨地讲应该称其为 结合代数。而且，这两个代数结构在乘法下都有 幺元：恒等映射 以及 单位阵，因此这个代数也是 含幺代数。如果说，有某个代数的乘法更进一步地满足交换律，那么我们将称之为 交换代数。交换代数在一些领域占据中心地位，说的就是你，代数几何。笔者的愿望是，有生之年能学到交换代数，一窥代数几何的玄妙。当然，就当前的情况来看，有点痴人说梦了（）\n小结 本章我们的内容不算太多。可能大多数的内容，读者在读之前就已经从前面的章节中获得了提示，或者在线性代数的学习中早已了然于心了。不过，我们还是在这里总结一下。\n阐述了基在线性代数中的位置。线性空间的几乎所有性质都凝结在了它的基上； $\\Hom(V,W)$ 和 $\\Mat(m,n)$ 之间可以通过对 $V$ 和 $W$ 选择合适的基来将二者联系起来，形成一一对应（线性同构）； $\\Hom(V,W)$ 的基可以从 $\\Mat(m,n)$ 的 标准基 中得到：将某一个 $V$ 中基向量映射到 $W$ 中的另一个基向量，而将其余的 $V$ 中所有基向量全部映射到 $W$ 的零向量上； $\\Hom(V,W)$ 的对偶空间是 $\\Hom(W,V)$，可以通过研究对偶基向量得到； 可以定义矩阵的 对偶乘法，将 $\\Mat(m,n)$ 与 $\\Mat(n,m)$ 的元素相乘得到一个数，以此可以定义 $\\Mat(m,n)$ 的对偶空间； 矩阵可以定义 转置 操作，具体做法是让旧矩阵的第 $(i,j)$ 个元素变成新矩阵的第 $(j,i)$ 个元素； 转置和对偶元素之间有特殊的联系，但对向量取对偶依赖对偶本身的定义，而转置只关心元素的排列； 线性自同态有特殊的性质，通过复合这一双线性操作可以得到线性自同态间的乘法； $V$ 上的线性自同态全体构成数域 $\\field{k}$ 上的代数； 方阵在矩阵乘法下构成矩阵代数。 上一章节中，我们提到过 内积，且它是一种 双线性形式。而所谓 双线性形式 其实是一种特殊的双线性映射。这一章的 对偶乘法 从某种意义上再次提示我们有关内积的一些情况。然而受限于篇幅（以及笔者的脑仁儿），只能留在下一章详细介绍了。另外，提到内积，不得不提的便是（笔者）常常容易搞混的三个概念：内积、范数和度量。我们也许会在下一章中提到三者，对它们做一个区分；最后，在建立了线性映射和矩阵之间的关系之后，我们对线性映射的研究终于可以放在更具体的矩阵上进行了。本章只是为后续研究线性映射和矩阵搭建起来一个舞台而已，敬请期待后续的相关内容。\n这个结论是有一定的适用范围的，在有限维线性空间是完全适用的，但在处理无穷维线性空间时就不见得了。详情可以参考我的 另一篇博文\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n代数这个概念实际上非常宽泛。这里所提到的“代数”只是众多代数定义中的一种，即在线性空间上构建起来的一种数学结构。而广义上的代数则是指代了带有两种运算，满足一定条件的系统，注意是 两种。实际上，后者 才更有可能是 线性代数 的名称由来。而与之类似的，可以参考布尔代数，$\\sigma$-代数等。我们有机会可以聊聊这个小话题，挺有趣的。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2026-03-06T23:45:01+08:00","image":"/images/Alice.jpg","permalink":"/zh/posts/math_note/linear_algebra/la_3/","title":"线性代数笔记 III"},{"content":"从可爱群友 @0xa7973908的博客里了解到了数组指针和指针数组的类型区别，但是这种单纯的记号真的很难背，背后的逻辑究竟是什么样的呢？探究一下吧~\n本期的头图是从歌曲的 MV 中截下来的，是 MIMI 的 サイエンス (Science，科学)，非常好听，很适合科研狗（）绘制 MV 的是 3774. 太太，可爱铁头……\n指针数组？数组指针？ 可爱群友的博客里写了这样一些内容：\nint* a[2] 是一个存放指针的数组。\nint (*a)[2] 是指向数组的指针。注意，a 指向的是数组整体。\n同理，int (*a)[2][2] 指向的是 [2][2] 这个整体。\nint (**a)[2][2] 中 *a 指向 [2][2] 这个整体，而 a 指向 *a 。\n对于函数指针，我们可以用先右后左的思路看。\nint* (*a[3])(int*, int) 首先这是一个数组；数组里面放的是指针；这是函数指针；参数类型是一个 int 指针和一个 int；返回值是 int*。\nBravo! 总结地很到位，而且像这样 先右后左 的思路其实很多文章、教材等都是推荐的。然而，有这么一个问题：HYW（WHY）？为什么这个程序的设计者要这么设计 C/C++ 的类型系统？C/C++ 不是前置类型系统吗？怎么一会儿右看一会儿左看的，真麻烦……特别是数组指针和指针数组，怎么一个括号就让一组指针变成了一个指向数组的指针了？\n不过，bro 突然想到一个骚操作，可以完美地记住这两种情况，自此妈妈再也不用担心我分不清这两个东西了：\n帮 C/C++ 类型现代化 既然 C/C++ 不是彻底类型前置，那我们就让前置更加彻底！在下面这一部分，我们会忽略掉数组的大小，毕竟我们更关心的是数组本身而不是它的全部信息。\n首先，我们有：\n1int *a_of_p []; // an array contains several pointers, the array is *a_of_p* 2int (*p_to_a) []; // a pointer to an array *p_to_a* 既然我们说 C/C++ 前置不彻底，也就是不完全遵循 type name variable_name = value 的模式，那我们就直接把变量名挪到后面吧：\n1int *[] a_of_p; 2int [] (*p_to_a); 这里我们把括号的内容整体挪到后面，因为，嘛，括号嘛，我们都明白括号是怎么回事，处理带括号的东西的时候最好把括号整体做个操作。然后我们考虑把后一个的括号去掉，就得到了：\n1int *[] a_of_p; 2int []* p_to_a; 诶？这步是不是有点感觉了？那么接下来这一步会让你的感觉更明确一些！不过在引入这一步之前，我们先来介绍一个语法糖：在 C/C++ 中，我们可以把数组下标和数组名交换顺序：\n1array[2] == 2[array]; // true HYW？我们揣测一下，在编译器里其实它们都会被加工为 array 代表的头指针偏移一个量：\n1*(array+2) == *(2+array); //true 加法是交换的嘛，所以这两种写法编译器都认。其实吧编译器也许还有一些别的处理来让这个语法糖真正成立，毕竟在合适的抽象下，地址不应该等同于一个整数，而是一个特殊的对象。不过这么理解这个语法糖也许也没啥问题。\n那么？我们要对上面的类型做的事就很明显了。我们要把方括号前面的东西放进去：\n1[int*] a_of_p; 2[int]* p_to_a; 这么一来，经过我们彻底的 现代化 改造，我们让第一个变量声明从语义上就明确地是一个装有指针的数组 a_of_p，而第二个也很明确地是有一个数组，然后让 p_to_a 指向这个数组成为一个指针。\n不过……这个做法说实在的，没有特别大的道理。把 (*p_to_a) 整体挪到后面之后还能去掉括号，这个操作虽然很符合直觉，但是真没啥道理，而且最后用 语法糖 去类比类型系统，感觉也怪怪的。用来助记倒也是足够了。\n可是 C/C++ 的发明人们就没有什么说法吗？就这么随便地决定了吗？这不对吧？！？\n问问 AI？ 这都啥年代了，为什么不问问神奇的 AI 呢？我得到的答案是：C/C++ 是 不完全遵循 前置类型的规则的，类型不是只由变量名前面的内容决定，而是还得用后面的 declarator 去一同决定的。也就是说，*，[]，() 等等这些东西也是参与到变量定义中的。\n等一下，() 也是？可是这不是在定义函数吗？AI 还说，C 允许在一个类型名称后面定义很多个东西：\n1int *p, a[10], (*f)(void); 第一个是指向整型的指针，第二个是整型数组，而第三个是指向返回整型值的函数指针。再等一下，好像函数定义好之后，我们使用函数的过程……\n于是我让 AI 又简单列了几个复合变量类型，发现了神奇的规律：\n怎么用，就怎么声明！ 其实我想到了初学 C/C++ 指针的时候就有的一个小疑问：我们声明指针的时候是要用 int *p;，但是在给这个指针赋值/初始化的时候我们需要的是 int *p = \u0026amp;a 的写法，而 \u0026amp; 是取地址的写法，但是用指针的时候，要想得到 a 的值就又必须用 *p，而 p 这个变量存的是 a 的地址。\n上面扯这些废话，意在指出：我们在声明 int *p 的时候，变量确实是 p，但是用它的时候貌似总是要 *p 才能取到整型值。这样的例子有很多：int a[10] 的意思是一个长为 10 的数组，但 a 直接去用是不太行的，我们取数组中的内容的时候总是要 a[2] 这样；如果把函数也考虑进来，就更有趣了：当我们声明一个函数 int f() 的之后，我们在使用这个函数时，总是要 f() 来调用它，单纯的 f 是不行的。\n你有注意到些什么吗？我们 如何声明一个变量，后续就会 这样使用它。更确切地说，如果 用声明的方式用这个变量，就会 得到类型说明符这个类型的值。我们来做个实验，还是用上面的两个例子：\n1int *a_of_p [N]; // an array contains several pointers, the array is *a_of_p* 2int (*p_to_a) [N]; // a pointer to an array *p_to_a* 这两个声明要怎么去看呢？如果按照 后面怎么用这个变量得到对应类型的值 的说法来解释这两个值，那么第一个似乎就是：我们要先从 a_of_p 的某个位置取个东西（作用上 []），然后用 解引用 算符 * 来作用在取出来的东西上，就会得到一个整型值。这么来看，取出来的东西肯定是一个地址（因为可以被解引用），而“取”这个动作就说明了第一层包装的是一个数组。最后我们得到结论：它是一个指针数组，一个装了很多指针的数组。\n那么第二个呢？有了括号的存在，我们必须改变算符优先级，也就是说我们得先对 p_to_a 解引用，解引用后得到的东西是可以用下标算符来取个东西出来的，取出来的是一个整型值。所以我们第一步是在解引用，解引用出来的东西是数组，因此 p_to_a 是一个指向数组的数组指针。\n这样的做法能推广到别的情况吗？令人惊喜但不惊讶的是，没错，在 绝大部分情况下，都是完全没问题的。比如一个稍微更复杂的类型：\n1int (*a)[2][2]; 出现在可爱群友给的例子中，它要怎么分析呢？首先，a 要先被解引用，解引用出来的东西可以取两次下标，因此它一定是一个指向二维数组的指针！而这个：\n1int* (*a[3])(int*,int) 是什么呢？在可爱群友的解释中，是这样的：\nint* (*a[3])(int*, int) 首先这是一个数组；数组里面放的是指针；这是函数指针；参数类型是一个int指针和一个int；返回值是int*。\n我们尝试用自己的方法来分析一下这个东西。首先，这个名字是 a，按照算符优先级，我们得先从里面取个东西出来，取出来的东西得解引用，解完引用之后得用调用算符吃掉两个什么东西，最后得到的玩意儿还得再解一次引用才能得到一个整型值。诶？和可爱群友的结果正好合上了！真不错！而且更有趣的一点是，我没有用 先右后左 这样的思路，而是完全依靠算符优先级来确定这个解析顺序应该是什么样的。\n等一下，算符优先级真的是这样吗？\nC/C++ 算符优先级 我可以拍着胸脯说，没错，优先级是这样的。查阅 cppreference: C++ Operator Precedence，可以看到算符的全部优先级顺序。为方便查阅（也为了水字数），我们把它放过来：\nPrecedence Operator Description Associativity 1 a::b Scope resolution Left-to-Right → 2 a++ a--\ntype(a) type{a}\na()\na[]\na.b a-\u0026gt;b Suffix/postfix increment and decrement\nFunctional cast\nFunction call\nSubscript\nMember access Left-to-Right → 3 ++a --a\n+a -a\n!a ~a\n(type)a\n*a\n\u0026amp;a\nsizeof\nco_await\nnew new[]\ndelete delete[] Prefix increment and decrement\nUnary plus and minus\nLogical NOT and bitwise NOT\nC-style cast\nIndirection (dereference)\nAddress-of\nSize-of\nawait-expression (C++20)\nDynamic memory allocation\nDynamic memory deallocation Right-to-Left ← 4 a.*b a-\u0026gt;*b Pointer-to-member Left-to-Right → 5 a * b a / b a % b Multiplication, division, and remainder Left-to-Right → 6 a + b a - b Addition and subtraction Left-to-Right → 7 a \u0026lt;\u0026lt; b a \u0026gt;\u0026gt; b Bitwise left shift and right shift Left-to-Right → 8 a \u0026lt;=\u0026gt; b Three-way comparison operator (C++20) Left-to-Right → 9 a \u0026lt; b a \u0026lt;= b a \u0026gt; b a \u0026gt;= b Relational operators Left-to-Right → 10 a == b a != b Equality operators Left-to-Right → 11 a \u0026amp; b Bitwise AND Left-to-Right → 12 a ^ b Bitwise XOR (exclusive or) Left-to-Right → 13 a | b Bitwise OR (inclusive or) Left-to-Right → 14 a \u0026amp;\u0026amp; b Logical AND Left-to-Right → 15 a || b Logical OR Left-to-Right → 16 a ? b : c\nthrow\nco_yield\na = b\na += b a -= b\na *= b a /= b a %= b\na \u0026lt;\u0026lt;= b a \u0026gt;\u0026gt;= b\na \u0026amp;= b a ^= b a |= b Ternary conditional\nthrow operator\nyield-expression (C++20)\nDirect assignment\nCompound assignment by sum and difference\nCompound assignment by product, quotient, and remainder\nCompound assignment by bitwise left shift and right shift\nCompound assignment by bitwise AND, XOR, and OR Right-to-Left ← 17 a, b Comma operator Left-to-Right → （里面的链接都会链到 cppreference.com 上，请放心点开。）其中，最高优先级的是从命名空间中取内容的 :: 算符，而第二优先级的就是调用运算和下标运算了，取地址和解引用运算则在下一级。\n那就没问题了。int *a[N] 中，a 的确更先与 [] 相结合进行运算，而后由 * 解引用。我们还可以用这套逻辑来解释别的 大多数 复杂类型。比如把函数，数组和指针等混合在一起套好几层的那种，都可以这样分析出来结果。\n但是，const 呢？它不是运算符才对，这要怎么考虑？另外，\u0026amp; 取地址又要如何考虑？当取地址出现在变量声明里的时候，应该是在定义 C++ 的 引用 才对，这要怎么用算符优先级来解释？\n所以，算符优先级不能直接解释所有的复杂类型。不过我们可以尝试间接解释一些复杂类型。\nCV 限定与引用 我们目前遇到的主要问题是，const 在之前的这套系统里不太合适，另外就是 \u0026amp; 在用给变量的时候是 OK 的，用来取得变量的地址，但用在变量名上的时候却不太行，因为这在 C++ 里代表的是引用，这个和我们的系统也不太相容。不过我们可以对这套系统打点补丁。我们先来讨论 const（以及 volatile）吧。\nCV 限定符 虽然我们最常用的其实是 const，但是 const 和 volatile 这两个类型限定符经常一同出现在语法讨论里。我们这里简单介绍一下这两个东西。\nconst 我们会更熟悉一些，它的作用是告诉编译器，我用 const 修饰了的变量在程序运行期间是不会变的。因此如果代码中出现了对 const 变量的更改，编译器会拒绝编译。也就是说，这是对编译器的一种 承诺，承诺这个变量不会改变，如果代码行为上出现了改变它的值的行为，则一定是写错了。\n而 volatile 的作用也很类似，不过是从另一个方向来描述这个变量。当我们用 volatile 对一个变量修饰之后，我们相当于告诉编译器，这个变量可能会受到这份代码写出的内容之外的改动，因此针对这个变量的读写都得小心。我们举个例子，\n1volatile bool ready = false; 2while(!ready){} 这段代码一眼看过去，那不就是死循环了吗？没错，如果 只有这段代码运行，且 没有别的外部程序干扰这个进程 的时候，的确是一个死循环；如果我们的程序没有加 volatile，那么就一定是这么个情况，编译器很有可能会把这个代码优化为这样的东西：\n1bool ready = false; 2if(ready){ 3 for(;;){} 4} 也就是只检查一次 ready。但是，现在我们加了 volatile，编译器就不能这么考虑了。它必须按照原代码的形式执行读写操作，即每个循环（即便是空的）都要检查 ready 这个变量的情况。有了 volatile 限定符，我们就可以让外部操作在尝试读/写 ready 后改变上述代码的行为，而且会成功修改，因为 volatile 修饰的变量一定会老实执行读写。\n我们还可以把 const 和 volatile 两个限定符结合起来，成为 const volatile，告诉编译器这个变量 在这份代码里不会变，而编译器依旧要 执行所有的读写操作，保证获取最新的变量值。\nvolatile 限定符和 const 用法应该差不多，而关于别的限定符（比如只能用给对象指针的 restrict）我们这里就不提了。\nCV 限定符与指针 有了这两个限定符，我们可以这样解析含有 CV 限定符的变量声明。比如：\n1const int *p1; // pointer to constant int 2int * const p2; // constant pointer, pointing to an int 3int const *p3; // the same as p1 第一个我们从 p1 开始，它需要作用上解引用，得到的结果是一个 int，而这个 int 则是不可变的（const），因此它是一个指向常整型值的指针；\n第二个我们依旧从 p2 开始，首先碰到的是 const，则说明 p2 本身不能变，然后对这个不能变的 p2 我们可以解引用得到一个 int，因此它是一个永远指向一个整型变量的指针，这个指针不能指向别的东西。\n第三个呢？我们必须先解引用 p3，然后得到的东西它是不可变的，不可变对象的类型是一个整型值。因此它和 p1 是一样的。\n那么，怎么理解下面这个？\n1const int * const * p; // pointer to a const poionter, 2 // which again points to a const int 我们来分析：首先，它是一个指针，因为第一步得 *p 解引用；解引用之后得到的东西必须是常量；而这个常量又可以被进一步解引用，所以它是一个 指向常指针 的一个指针；最后，在层层解引用之后，我们得到的东西是一个整型值，这个整型值是不可变的。如此，p 是一个 指向整型常指针 的指针。\nCV 限定符与函数调用/数组下标 它和数组下标算符或者函数调用算符如何组合呢？比如这个例子：\n1int (* const fp)(int); // const pointer to a function which returns an int 我们这么来看。fp 首先必须是不可变的，在不可变的基础上它是可以被解引用的，解引用之后的结果可以有函数调用，调用结果是一个 int。因此，它是指向恒定的一个 接受 int 后返回 int 的函数 的 指针。\n既然如此，我们再试试更复杂的：\n1int (*(* const fpp)())() 我们还是从 fpp 出发。首先它不能变，然后它得能被解引用。解引用得到的结果我们记作 x，就有：\n1int (* x ())() x 是什么呢？它能被调用，必须是一个函数。这个函数什么参数都不用，返回的结果能被解引用，因此它的返回值是一个指针。解引用得到的东西又能被再调用，因此返回的指针指向的又是一个函数，而最外层的函数返回的值则为 int。\n因此，int (*(* const fpp)())() 里的 fpp，是恒指向一个函数的指针，这个函数指针指向可变，指向的函数要返回一个函数指针，而最后的这个函数指针指向的函数返回的是是一个整型值。\n[!NOTE]\n从上面的结果，我们可以观察到，每当有 * const 的时候，总得被解释为“指向不变的指针”。因为它总代表着，const 修饰的东西是不变的，而这个不变的东西可以被解引用，能被解引用代表是一个指针，而指向因为 const 的原因而不能变化指向。\n那么，数组又如何呢？其实也差不多，我们直接试一个复杂的：\n1int * const (* pap)[N]; 我们可以看到，a 是一个指针，指向的东西可以取下标，因此它指向数组，数组取下标得到的东西是 常指针，因此数组里装着的是常指针们，最后这些常指针指向的内容是整型值。自此我们解析完毕，a 是一个指向 装有指向整型值的常指针 的数组的指针。\n我们甚至可以尝试将函数调用和数组下标二者混合起来：\n1int (*(* const afpa[2])(const int * const))[3]; 还是依旧从 afpa 开始。afpa 首先可以取下标，取完下标得到的是常指针，因此它首先是装有两个常指针的数组。接下来，数组中的每个指针指向谁？我们记 (* const afpa[2]) 为 y，则有：\n1int (* y (const int * const))[3]; 那么 y 必须是一个函数，这个函数的返回结果可以被解引用，因此是一个返回指针的函数。这个函数的参数列表有什么呢？是一个指向常量的常指针。而它的返回的指针指向谁呢？我们记这个括号内 (* y (const int * const)) 的内容为 z，则有：\n1int z[3] 啊！这不就是一个数组吗？或者说，因为能取下标得到整型值，所以是存储了整型值的数组。现在我们把上面说的依次链接起来，就得到了 afpa 的真身：\nafpa 是一个装有两个常函数指针的数组，常函数指针指向的函数取常量常指针为参数，返回指向整型数组的指针。\n这样一来，有 CV 限定符、指针解引用、数组下标、函数调用的复合类型就能被顺利解读了。不过我们还剩下一个不好处理的东西：C++ 中引入的 引用。\n引用 C++ 中引入了 引用 这么个新鲜东西。它常被称为 变量的别名，实际上也的确如此。在声明别名并绑定变量之后，我们对别名的操作和我们对原变量的操作是完全一样的。这个东西的引入带给我们一些便利，特别是在 函数参数列表 与 类方法 中。\n可是，声明/定义引用时我们用的是取地址的算符 \u0026amp;，这还能符合我们之前的 怎么用就怎么声明 的逻辑吗？我们在使用引用的时候可不会再写一个 \u0026amp; 才对呀。\n好消息是，我们在解析声明内容时，依旧可以采用前面的那套系统。不过此时我们需要对 C++ 的一些规则有所了解。下面有一些例子：\n1int \u0026amp; ri = i; // reference to int 2int * \u0026amp; rp = p; // reference to pointer 3int \u0026amp; * pr; // pointer to reference, invalid 4 5int \u0026amp;ar[N]; // array of reference, invalid 6int (\u0026amp;ra)[N] = a; // reference to array 7 8int \u0026amp; fr(); // function return reference 9int (\u0026amp; rf)() = f; // reference to function 我们来逐个解析这些例子。\n第一个很明显，是变量 i 的一个引用，也是介绍引用时最常用的例子。此时我们对 ri 的操作就 等同于 对 i 的操作；第二个则复杂一些，不过我们依旧使用算符优先级来考虑，则 \u0026amp; 比 * 更早与 rp 结合，因此 \u0026amp;rp 定义了一个引用，引用的值可以被解引用得到整型值，因此引用的东西是一个指向整型的指针。\n而第三个就有趣了。根据算符优先级，首先 pr 是一个指针，指向的东西是一个引用，引用的东西则是一个整型值。因此 pr 是一个指向整型值引用的指针。然而这在 C++ 中是不被允许的。原因很简单：引用 不创造新的值，它真的就是别名而已，不能单独占用内存空间。而指针存储的正是变量的内存空间，因此 int \u0026amp; * pr 的写法是不合规的。\n第四个案例和第三个差不多，按照之前的解析方法得到的结果是说 ar 是一个装有引用的数组，而数组也是要存储有具体内容的值的内存空间，因此这个写法也是不合规的。\n不过，要是写成了第五个案例的形式，读起来就是 一个数组的引用，要先引用到对应变量上，再计算下标得到整型值，因此被引用的是一个数组，这是一个数组的引用。而这是完全 OK 的。\n第六、七个案例则都是合规的。首先第六个是说函数 fr 返回值是一个引用。这是允许的：只要返回的东西确有其“人”就行。而最后一个则又很明显了，是一个函数的引用。二者都是允许的。\n根据上面的例子，我们又可以创造很复杂的类型了。比如这个：\n1int (\u0026amp;(*pfr)())[5]; 我们依旧从 pfr 出发，它是一个指针，先和右边调用算符结合，说明指向的是一个函数。函数的返回值是什么呢？我们先把函数部分写成 x，就得到了：\n1int (\u0026amp; x) [5] 所以，我们要先对函数返回的结果引用回去，然后再取下标得到一个整型值。因此函数指针指向的函数会返回对整型数组的引用。所以实际上这一行我们定义了一个函数指针。\n自此，*，\u0026amp;，[]，()，const 这五大天王与变量名的排列组合解析应该是被我们完全解决了。\n类型系统，果真如此？ 我们好像是找到了一个百试百灵的屠龙宝刀，不过问题又来了：C/C++ 的类型系统，果真是这样的吗？上面的这个“规则”，真的是 C/C++ 的设计原则吗？这么去解析变量/函数声明，真的一定没问题吗？\n好消息是，大部分我们能遇到的，这么个 规则，或者更好的说法，思想模型，都是适用的，而且的确 C/C++ 的类型系统设计时，这部分内容的确是 怎么用就怎么声明。像上面比较 普通 的情况，这套分析方法都是没啥大问题的。\n可是坏消息是，C/C++ 的类型系统实在是太复杂了。我们这只是用了一些些的 type constructor 和 CV 限定符 (cv qualifier) 而已，属于 简单声明。实际上，声明系统根据 cppreference.com-Declarations 的结果来看，还包含函数定义、模板声明/实现/特化、命名空间定义、属性声明等等更多的东西，而简单声明里一共是有 10 种方式去做声明，我们一点没提；我们的讨论中没有涉及声明中会出现的属性（Attribute），初始化（Initialize），更多的限定符（Qualifier）等等要素；甚至就连基础类型我们都只敢用人畜无害的 int，没有涉及更复杂的类型，特别是自定义类型。\n没错，我们的讨论还完全抛开了枚举、联合体、结构体和类。实际上，它们也属于声明系统的一部分，或者说类型系统的一部分。即便是在讨论过的内容里，我们还有没涉及到的细节，比如所谓的 \u0026amp;\u0026amp;，右值引用声明，因为我还不够了解什么是右值引用，就不瞎讲了。\n不过我并不觉得这有什么问题，我们的讨论依旧是有意义的。也许这里的讨论成功窥探到了一些语言设计之初所构想的类型系统设计思路，而且也能方便以后遇到奇怪的复合类型时不会两眼一抹黑。\n后记 不得不说，一开始真的没有想到能写这么多，更是没有想到即便写了这么多，也似乎没能穷尽 C/C++ 类型系统的百分之一。在查阅大量的 cppreference 文档之后，我只感觉脑袋发胀，目光呆滞，但是看着自己能分析出来各种丑八怪类型，多少还是有点成就感的。\n这里要非常感谢可爱群友 @0xa793908 的笔记，没有看到那个笔记内容的话，我是肯定不会想到这么个有趣的问题，并且（也许是）深入到这里的吧。\n另外不得不感慨的是 AI 的强大……我的很多例子都是让 AI 举出来之后，我再尝试分析理解，最后得到这么一套解析方法的。虽然说有时候很累，AI 算是比较认死理（偶尔又会出幻觉）的，但是用 AI 去学新东西一定是一个很有前景的学习方法。\n感谢您能看到这里，这么啰嗦的一大篇能看下来也是很强大了。希望我写的这些东西能帮你解析遇到的奇怪类型，在“谭浩强”型考试中能多拿一点分数，哈哈。那么最后，一如既往地，祝您身心健康，工作顺利，天天开心~\n","date":"2026-01-15T19:35:50+08:00","image":"/images/Science.png","permalink":"/zh/posts/cpp_note/simple_type_parsing/","title":"如何解析 C/C++（比较）复杂的类型？"},{"content":"某个笨蛋错误地使用了 git reset --hard 命令，这是他发生的变化。\n头图是可爱的迷迭香！是由 Pixiv 的 赌上厨师生涯的拼好饭 创作的，这里有原图链接。选曲为最近听的很多的一首东方同人曲：来自 凋叶棕 的专辑 望 的第一首歌，encourager，曲调积极向上，阳光活泼，我说专辑封面金发的孩子真可爱，你能反驳吗！？\n引子 美妙而快乐的周五，应师兄的提议，我决定将自己用 AI 写出来的深度学习代码放在 Github 上的仓库里。Everything works great! 我的代码跑出来的结果看着海星，放在 Github 上和师兄们分享一下也挺好（就是不太能入米娜桑的法眼了，嘿嘿）。二话不说，先创建个 Git 仓库先，一会儿 Push 到远程就好了，Easy ~\n首先，自然是让 Git 接手这个仓库咯：\n1git init . 在我条件反射地写下：\n1git add .; git commit -m \u0026#34; 的时候，我突然意识到，这个项目之前是瞎搞的，搞成了就行，工作目录里乱七八糟的，还没有 README，这不得整理一下？于是我就把后面的 commit 部分删掉：\n1git add . 先把文件放进暂存区吧。但是好像加进去了很多没用且特别大的数据文件！？一看 VS Code 侧边栏的 Git 小蓝点，哇，200 多个文件，肯定不对。写个 .gitignore 文件，把 data 文件夹的内容忽略掉好了。接下来应该写个 README.md 来介绍一下这个项目在干嘛，怎么准备项目依赖，顺带抽空了解了一下 conda 怎么像 pip 那样 freeze：\n1conda list --export \u0026gt; requirements.txt 就行了。\n写好这些东西之后，再看一眼 Git 的小蓝点：怎么没啥大变化……对了！我记得要让 git 停止追踪某些文件，可以用 git reset --hard 来着？那么：\n1git reset --hard ……\n噩梦开始了。\nwoc，救一下呀，Git！ 看向我的目录，里面只剩下了几个在我 git init . 之后创建的文件，而在初始化 git 仓库之前的内容全都不见了。我突然意识到，git reset --hard 貌似不是撤回，而是将仓库状态重置到上一个提交，而我这个仓库从来没有提交过，结果就是我的仓库之前的内容都不见了。此时我也没有什么心思去搞清楚 git reset 究竟会干嘛了，我的目标此时只有一个：把我亲爱的文件找回来。那可是我让 AI 给我写了两周的心血呀！\n我的第一反应是用我曾经了解过的极为强大的 git reflog，印象中它会记录我在 Git 中做的所有操作。而使用 git reflog 之后我得到的结果是：\n1fatal: your current branch \u0026#39;main\u0026#39; does not have any commits yet 太坏了。必须得有提交记录才行，天哪……\n此时我又想到，这是在 WSL 里，也许我可以在回收站找到……想了一下，结果我都把自己逗笑了，这是 WSL，是 Linux 呀，有自己的独立虚拟磁盘 ext4.vhdx，而在这个环境下，Git 删除文件就是像自己用 rm 删除文件那样，没有回收站可以回收的。\n这下烂完了……我好像和 Git 一起干了波大的……\n怎么办，要恢复整块硬盘吗…… 2025 年了，遇到问题应该学会问 AI 了。我直接提问 AI，遇到这么个情况要怎么搞，他也是没含糊，好在我用的是 WSL，vhdx 硬盘文件可以直接备份一份，他让我找到硬盘文件并复制一份出来到别的位置。然而接下来的操作我傻眼了，需要用磁盘恢复程序什么的，而且我需要用别的环境挂载这个磁盘文件，然后安装一些软件包并尝试从磁盘里找出来还没被覆盖掉的那些数据。\n为什么能用磁盘恢复工具尝试找回这些文件呢？不是说 Linux 里删除就等于彻底删掉文件吗？其实“彻底删掉”的说法是不准确的，实际上的删除行为一般并不会抹掉数据，而是把数据从文件系统中断开连接。或者说，当我们在文件系统中删除某个文件的时候，文件系统不会抹掉这些文件存储区域的数据，而是单纯地不再保护这块存储区域，以后谁需要这块存储空间的时候会直接分配给别人。莫名想到 死亡不是人生的结束，遗忘才是，在文件系统里就是 删除不是文件的结束，覆盖才是。\n因此，只要我还没犯蠢再向文件系统中写入数据，这块位置就应该还没被覆盖掉，而我就还有机会把这些数据救回来：让文件系统重新链接并保护这块位置。然而这操作谈何容易，我对文件系统的了解也仅限于此（说不定理解还有问题，大佬轻喷呜呜呜），一想到美妙的周五下午就要被恢复数据占满毁掉，而恢复的东西里最重要的其实只有不到一成（毕竟丢失的大部分都是计算数据，是复制来 WSL 里的，重要的是代码），而且还都是 AI 写的……\n一想到这里，我就放弃了直接恢复整块硬盘的想法。和 AI 又聊了一下，说是让我从编辑器下手看看能恢复出来些什么东西不，我试了下也没啥用……难到就这样摆烂，想办法再让 AI 写一份代码吗？\n此时我想到，为什么不 Google 一下呢？（好吧其实是 DuckDuckGo，对不起 DuckDuckGo 真的很好用，再见 Google）我就搜 git reset --recover recover，结果就遇到了一篇伟大的 Stack Overflow 帖子：\n还得是 Stack Overflow 搜索结果第一个，这篇 Stack Overflow 帖子 的提问很直接，高赞回答和我想得一样，有提交历史的情况下可以 git reflog show 来救一下自己，有趣的是这个回答没有被选为题主认可的回答，反倒是第二个回答，赞数稍低一些些，却被题主选为了最佳回答。这个最初在 2011 年发布的回答讲到：\nPreviously staged changes (git add) should be recoverable from index objects, so if you did, use git fsck --lost-found to locate the objects related to it. (This writes the objects to the .git/lost-found/ directory; from there you can use git show \u0026lt;filename\u0026gt; to see the contents of each file.)\n诶！我好像确实 git add . 过！想到这里，我立刻采用了这个方法，结果的确如我想的那样，在 .git/lost-found/others 这个文件夹里我找到了我丢掉的所有文件。只不过所有的文件名都是乱码，而且绝大部分都是 XML 格式的数据文件，想找到最关键的文件得话好长时间，而 git show \u0026lt;filename\u0026gt; 结果也没有效果。不过不管怎么说，我都成功找回来这些内容了，着实是让我松了一口气，大不了把这些文件一个个地全都打开嘛。\n不过，有了如此进展，为何不再向 AI 问问怎么解决这个问题？\nAI 还是挺有用的，其实 AI 闻言，先是狠狠地恭喜一波，然后就给我指了条明路：file 命令可以检查文件类型，告诉我这些文件内容是什么。甚好！就地让 AI 写个 Bash 脚本，很快就把文件分类放在了不同的文件夹里。有趣的是，我顺带还发现，其实 Jupyter Notebook 用的 ipynb 文件是伪装的 Json 文件，而亲爱的 VS Code 在发现文件是 Json 文件，后缀是 ipynb 之后就会直接帮你渲染出来（甚至在 VS Code 里创建一个 ipynb 文件之后就会自动写上一些文件头并渲染好）。谢谢你，VS Code！\n找到最重要的 ipynb 文件和 Python 脚本之后，剩下的文件其实就可以删除掉了。我也算是成功地渡过了这次危机。感谢 Stack Overflow，感谢 AI，感谢 VS Code，感谢所有，我活过来了。\n所以，git-reset 究竟会干嘛？ 要想回答这个问题，最好的办法就是 RTFM，即 Read The Friendly Manual。如果是没有内置 man 这样的文档阅读器的情况，或者在 Windows 下输入 git reset --help，我们就会打开 git-reset 的帮助文档（没错 man 其实是轻量的 HTML 阅读器）。\n通过阅读（不那么友好，有点晦涩的 其实挺友好的 ）文档，可以了解到 git reset 就是设计好的，通过 重置 来实现 撤销 一些操作的一个命令。前面 SYNOPSIS 和 DESCRIPTION 部分说实在的有一点难懂，但是我们可以直接看 EXAMPLES 部分，详细地写了一些情况下使用 git reset 的实际案例。说实话，每个案例里的命令最后会有一些说明文字，像是读个小故事或者看主角内心历程，还挺有意思的。具体有些什么这里就不啰嗦了，内容其实相当多，感兴趣可以自己找来慢慢看。\n那么，git reset --hard 会做什么呢？答案是会彻底撤销到某个提交。当我们在后面填上提交的位置（用 HEAD 相对位置或者用 commit-hash 均可）时，我们会直接跳回这个提交并且 彻底删除 这个提交之后的修改。而如果我们不填提交位置，那就是默认 HEAD，会重置 Git 的头指针。被 Git 跟踪索引的文件的 变更 会被抛弃，而未被索引的文件则会直接被删掉。\n这样就能解释我的仓库里发生了什么。由于我在 git init . 之后 创建 了一些文件，它们会被直接索引，但又由于它们是新建的文件，不属于 变更，所以不会被删除而是原样留下；而在 git init . 之前的文件，由于还没来得及被 Git 跟踪索引，于是就被 git reset --hard 无情地删除了。原话是：\nAny untracked files or directories in the way of writing any tracked files are simply deleted.\n谢谢你，Git。以后再也不瞎用 git reset 了。\ngit-add 又做了什么？git-fsck 是什么？ 那么我为什么又能救回来我的文件们呢？原因在于 git add 的工作方式。在我们 git add 之后，被添加到暂存区的文件会被放在 .git/objects 文件夹里。我们来做个小实验，使用下面的代码：\n1mkdir test-folder 2cd test-folder 3echo hello \u0026gt; test.txt 4git init . 这样我们就创建了一个新文件夹，写了一个新文件，并且在有文件的情况下执行了 git init . 初始化了这个仓库。我们用 git status 看看：\n我们再看看 .git/object 文件夹：\n里面空空如也。我们执行下面的 Git 命令：\n1git add . 2eza --tree .git/objects 3# if you don\u0026#39;t have eza: 4# tree .git/objects 怎么确定他就是我们要的文件呢？Git 提供了它用来哈希的命令，我们执行 git hash-object ./test.txt，得到的结果是：\n1ce013625030ba8dba906f756967f9e9ca394464a 结果很有趣：Git 把哈希值的前两位取出来作为文件夹的名字，后面则作为文件的名称。\n此时我们复刻我干过的好事，然后再看看 .git/objects 这个文件夹：\n1git reset --hard 2ls -l 3eza --tree .git/objects 结果很神奇！工作目录里的文件确实被删除掉了，而 .git/objects 里的内容并没有被删掉。多亏了 git add . 这个命令，把结果暂存了下来。此时我们尝试 git fsck --lost-found：\n1git fsck --lost-found 2eza --tree .git/lost-found 3cat .git/lost-found/other/ce013625030ba8dba906f756967f9e9ca394464a 所以其实 git fsck --lost-found 大概就是把 git add 过的文件从 .git/objects 里找回来。那么 git fsck 是什么呢？查阅手册，我们看到：\ngit-fsck - Verifies the connectivity and validity of the objects in the database\n--lost-found\nWrite dangling objects into `.git/lost-found/commit/` or `.git/lost-found/other/`, depending on type. If the object is a blob, the contents are written into the file, rather than its object name. That\u0026rsquo;s it! fsck 应该是 filesystem check 的缩写，而 --lost-found 也如预料的一样，将 悬垂 的对象恢复到这两个文件夹里。\n总结 这次真的出了一身冷汗，还好习惯性地 git add . 了一次，不然真的是神仙难救呀。我可不想把美妙的周五时光全都放在文件系统恢复上。当然，最大的教训还是：不要随意用自己了解不充分的命令，特别是这种会对自己文件系统形成半不可逆更改的操作。我自认为对 Git 有点熟悉了，现在看来，根本就是太过自大，属于是半瓶子水晃荡，最后搬石头砸自己的脚了。还得好好学一下呀！\n不过，毫不谦虚地讲，这次经历我还是有做的好的地方的。起码没有彻底慌乱掉然后干出更加迷惑的操作，比如关机重启彻底删除这样失了智的行为。之前有一次好像也是东西丢了找不到，一时慌乱结果彻底找不回来了。还好那次的文件不算重要，丢了也就丢了。看来这次也算吸取了上次的一点教训，不要手忙脚乱，左右脑互搏。\n另外就是，DuckDuckGo 真的很好，Stack Overflow 真的很好，没有搜索引擎和这个伟大的论坛，我半个月的心血说不定真的就泡汤了（即便是让 AI 给我干苦力（））\n希望这次经历能帮到遇到了类似问题的你，或者给你也敲响一次警钟。那么，感谢你读到这里，祝你身心愉快，工作顺利，不出乌龙~\n","date":"2025-12-28T00:13:03+08:00","image":"/images/迷迭香.png","permalink":"/zh/posts/others/rescue_my_repository/","title":"记拯救用 git-reset 误删的某代码仓库"},{"content":"某人需要配置环境时做机器学习，这是他配置环境后电脑发生的变化……\n头图选择了可爱的小羊，来自 Ruziかねつカーぺット 太太的 ピンクの夢，粉粉的，可爱捏\n选曲为 STUDY WITH MIKU -part2- 改编的 さよならプリンセス（再见公主），原作是 P 主 Kai，由初音演唱。俏皮的音乐，好听……\n前情提要 参加完刚刚在西安举办的材料基因工程大会之后，某人深受触动：\n机器学习真是好东西！我必须立刻启动！\n于是他决定在自己的电脑上配置一些做机器学习要用的环境。然而他有一些洁癖……不希望因为工作用的环境影响自己的日常使用，于是他决定不在心爱的 Arch Linux 中安装这些环境，而是自己新安装一个 Ubuntu 的 WSL，后续的内容都在 WSL 里完成。\n刚刚他算是配好了，我整理了一下他安装的过程以及中间踩的坑，记录在这里，希望能帮到需要的人~\n（为了方便起见，后面还是用第一人称吧）\n环境状态 \u0026amp; 目标 下面的表格汇总了我目前的环境状态：\n项目 状态 OS Windows 11 Pro GPU RTX 5060 Laptop Terminal Windows Terminal Shell Powershell 7 WSL Arch Linux（不使用） IDE Microsoft VS Code1 硬盘 只有C盘 即便微软罪大恶极，经过一些调教之后，Win11 还是变得能用了起来；Windows Terminal 真的很好用，推荐每个人玩了命地用（）；很难想象不用美丽优雅 兼容部分 GNU/Linux 命令 的 Powershell 7（pwsh）的样子；我们默认打开科学上网且开启虚拟网卡模式，免得 Shell 笨笨地不走代理。\n我们的目标？自然是搞一个 Ubuntu 的 WSL，再在里面装上 Anaconda 以及 CUDA ，最后创建一个 conda 虚拟环境，在虚拟环境里装上测试用的 PyTorch。\n那我们开始吧~\n安装/配置 Ubuntu WSL Ubuntu 在某种角度，已经成为了 Linux（GNU/Linux）的代名词了：市占率广，知名度高，还算简单易用，基于 Debian 的特性让它也许也还算稳定。最重要的是，Windows 的 WSL2 使用的默认发行版就是 Ubuntu，而也许也正因如此，CUDA 关于 WSL 的支持是默认用户用的是 Ubuntu。\n说实在的，我不是很想在我已经有了一个配置地很不错的 Arch WSL 的前提下再大费周章地搞一个 Ubuntu WSL 来专门跑机器学习，而且 Arch Linux 实际上是有打包好的 CUDA 包的，可以用 pacman 方便地下载下来。然而考虑到我不希望复杂（冗杂）的 Anaconda 污染我的电脑，也不希望后面在跑程序的过程中因为 Arch 和 CUDA 之间的问题而反复调试，所以干脆就再开一个 Ubuntu 好了。\n安装 Ubuntu WSL 安装的过程还算简单，因为之前我的电脑已经有了 Arch WSL 了，安装 Ubuntu WSL 也不用等太久的 WSL 初始化过程。使用的命令也算简单：\n1wsl --install 没错，一行就可以了，而且甚至不用指明是哪个发行版（没错，因为默认）。当然，想要查询有哪些 WSL 上可用的 Linux 发行版，可以\n1wsl -l -o 2# 其实就是 wsl --list --online 然而我这里没有默认安装，本着能装新的就装新的原则，我选择安装了 Ubuntu 24.04 版本：\n1wsl --install Ubuntu-24.04 中间需要设置用户名和管理员密码，用户名只能是小写，而且 输入密码时你看不见输入的内容，最重要的是 请保管好你的管理员密码，不然就只能卸掉重装咯，那样就很亏了。不过行文至此，怎么卸载安装好的 WSL 发行版呢？很简单：\n1wsl --unregister Ubuntu-24.04 这行命令就会删掉你的 Ubuntu-24.04 发行版了。顺带，查询电脑上装了什么 WSL 的方式也很简单：\n1wsl --list --verbose 2# wsl -l -v 这个命令不止会列出已经安装的 WSL，还会显示它们的状态。想要彻底关闭运行中的 WSL（而不是简单地关掉 Shell），可以\n1wsl --shutdown （这个会关掉 WSL，也就是所有运行中的发行版都会被关掉）\n有点啰嗦了，不过这么几个简单的命令就能操作 WSL 的安装、卸载、查询之类的了。\n配置新到手的 Ubuntu WSL 刚到手的新系统，怎么能不先设置一下呢？工欲善其事，必先利其器嘛。我装了这些东西：\nzsh: 替代 bash fd: 替代 find rg: 替代 grep eza: 替代 ls bat: 替代 cat nvim cmake build-essential git gdb tldr: 方便快速了解命令用法 然后给 ZSH 装一些插件，插件管理选择了经典的 oh-my-zsh，一款基于 git clone 仓库到对应文件夹下的插件管理器（）安装的插件有：\nzsh-autosuggestions: 让你的 ZSH 拥有 FISH 那样的补全（既然如此，为什么不直接用 FISH 呢？别问……）； zsh-syntax-highlighting: 给正确/错误/特殊命令以多样的颜色，很方便，就像 FISH 那样（既然如此……）； zsh-completions: 让你的 ZSH 补全更聪明； conda-zsh-completion: 给你的 ZSH 加上 conda 命令的补全 zsh-vi-mode: 按下 ESC，使用 vi mode 来编辑你的命令行！偶尔会用到，但是要注意光标形状…… 我们给出用到的命令：\n1# Upgrade the source first 2sudo apt update \u0026amp;\u0026amp; sudo apt upgrade 3# fd has package name fd-find; rg has package name ripgrep; nvim has package name neovim; 4# tealdeer for tldr 5sudo apt install zsh fd-find ripgrep eza bat neovim cmake build-essential git gdb tealdeer 6# download oh-my-zsh, install and use it 7sh -c \u0026#34;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026#34; 8# several zsh plugins 9git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions 10git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting 11git clone https://github.com/zsh-users/zsh-completions.git ${ZSH_CUSTOM:-${ZSH:-~/.oh-my-zsh}/custom}/plugins/zsh-completions 12git clone https://github.com/conda-incubator/conda-zsh-completion ${ZSH_CUSTOM:=~/.oh-my-zsh/custom}/plugins/conda-zsh-completion 13git clone https://github.com/jeffreytse/zsh-vi-mode $ZSH_CUSTOM/plugins/zsh-vi-mode 14# Config for tldr, update the cache 15tldr --update 上面的命令可以复制下来贴在 Shell 里，过程中需要输入几次刚刚设置的密码或者回车确认一些东西，安装好 oh-my-zsh 之后会让你的 Shell 变成 zsh，请输入 exit 命令来退出当前的 zsh 以便继续安装下面的插件）。\n接着把之前我一直在用的 .zshrc 配置也同步到 Ubuntu WSL 里：\n1# $HOME/.zshrc 2export ZSH=\u0026#34;$HOME/.oh-my-zsh\u0026#34; 3 4ZSH_THEME=\u0026#34;robbyrussell\u0026#34; 5 6# Use zsh-completions plugin 7fpath+=${ZSH_CUSTOM:-${ZSH:-~/.oh-my-zsh}/custom}/plugins/zsh-completions/src 8autoload -U compinit \u0026amp;\u0026amp; compinit 9 10# Update when available 11zstyle \u0026#39;:omz:update\u0026#39; mode reminder 12 13plugins=( 14\tgit 15\tcolored-man-pages 16\tzsh-vi-mode 17\tzsh-autosuggestions 18\tzsh-syntax-highlighting 19\tconda-zsh-completion 20) 21 22source \u0026#34;$ZSH/oh-my-zsh.sh\u0026#34; 23 24# Source everything in the custom env folder 25if [ -d $ZSH_CUSTOM/env ]; then 26 for f in $ZSH_CUSTOM/env/*; do 27 source $f 28 done 29fi 30 31# Set ~win as the windows user home folder 32hash -d win=/mnt/c/Users/AMoment 33# Disable pipe override (\u0026gt;) 34set -o noclobber 这里的 .zshrc 文件是删掉了很多没用到的注释的。这些注释实际上是 oh-my-zsh 自动提供的，为的是方便用户自己调整，如果你有需要的话请只修改和添加上面的内容到特定的行就可以了。注意要保持顺序，特别是 zsh-completion 是特别要求要保持顺序的。\n最底下的两个小段是我自己常用的一些配置。第一段是我把所有和环境相关的内容都会以 程序名.zsh 脚本的形式放在 $ZSH_CUSTOM/env 这个文件夹里，这里就依次 source 应用一遍。第二段里第一行是方便去 Windows 宿主机下的家目录（没错我的家目录在这里），这样 ~win 就会被解析为这个路径，还是有点方便的；第二行是关掉了向有内容的文件里使用 \u0026gt; 重定向的操作，避免犯傻。\n最后就是添加几个好用的别名，我们可以直接写在 $ZSH_CUSTOM/aliases.zsh 这个文件里，我用到的是这些：\n1alias ls=\u0026#34;eza --icons=auto\u0026#34; 2alias ll=\u0026#34;eza -lh --icons=auto\u0026#34; 3alias la=\u0026#34;eza -A --icons=auto\u0026#34; 4alias la=\u0026#34;eza -lAh --icons=auto\u0026#34; 5alias vim=\u0026#34;nvim\u0026#34; 6alias fd=\u0026#34;fdfind\u0026#34; 7alias bat=\u0026#34;batcat\u0026#34; 修改好之后使用 exec zsh 来应用这些改动，就完成啦。不过 Windows 宿主机上还可以做一点简单改动。\nWindows 宿主机的简单配置 由于我使用的是 Windows Terminal，在安装好 Ubuntu WSL 之后关掉 Windows Terminal 再打开，Profiles 里面就出现刚刚设置好的 Ubuntu WSL 了：\n可能你的 Windows Terminal 里有很多别的选项，或者默认的 Shell 等不是 PowerShell，没关系，我们可以进入设置修改。比如让 PowerShell 成为默认 profile2，把常用的 profile 顺序提前等等。\n这里主要想聊一下下面这些设置，请注意要记得保存，用的 GUI 的话右下角有保存按钮，使用 JSON 文件的话要保存文件：\n调整 profile 顺序：实际上 Windows Terminal 会把所有的 Profile 信息都存在一个 JSON 文件里，可以从设置页面的左下角打开，我们手动改一下需要的 Profile 顺序就可以了； 隐藏 profile: 可以直接在 GUI 中修改，有个 Hide profile from dropdown，或者在 JSON 文件中找到对应的 profile，添加 \u0026quot;hidden\u0026quot;: true 即可； 如果你不希望在运行程序时 Shell 的标签页变成在执行程序的名字，请在底下的 Terminal Emulation 中选择 Suppress title changes。这样就可以一直显示 profile 的名称。在安装了若干个同类 profile（比如两个 Ubuntu）的时候会很好用。 大概就是这么些内容，实际上 Windows Terminal 的默认配置已经很不错了，上手只需要简单设置一点点东西就很好用了。这也是我喜欢它的原因。微软，你做得好口牙（赞赏）。\n那么到此，就配好了一个（我个人觉得）很好用的一个操作环境。接下来要做的自然就是进行安装了。虽然 CUDA 和 Anaconda 安装顺序也没那么严格，我们这里还是先装 Anaconda。\nAnaconda 在正式安装之前，我觉得还是可以：\nAnaconda 的简单介绍 所以，Anaconda 是什么？它是 Python 吗？Python 是什么？我已经有 Python 之后还需要装 Anaconda 吗？如果你了解这些概念，请直接跳过吧（）\n我们先来说说 Python 和包管理。Python 作为一门比较现代的语言，它使用解释器实现逐行的代码运行，且通过包管理器来管理代码中使用到的模块、库之类的东西。然而 Python 其本身只是一个语言，我们常说的“下载 Python”实际上是下载了包含 Python 的解释器、包管理器以及运行环境的一个发行版。我们从 官网 下载到的 Python 安装包实际上包括了使用 C 语言实现的 CPython 解释器，从 PyPI，即 Python Package Index 来下载安装对应的包的包管理器 pip，以及它们的运行所需的环境（如果你勾选了 IDLE 的选项，你还会安装一个小小的 Python IDE）。我们称这样下载到的是 Python 官方发行版。\n除了 Python 官方的发行版外，由于 CPython 采用的开源协议，它允许任何人自由地打包并发行自己基于 CPython 的 Python 发行版，实际上也确实有很多不同的基于 CPython 的发行版。其中一个非常出名的发行版即为本次的主角，Anaconda。Anaconda 的 Python 发行版包含了由自己打包的 CPython 解释器，不同于 pip 的另一款包管理器 conda，以及由 Anaconda 提供的，预包含了许多科学计算、数据统计库的运行环境。\nAnaconda 的特点在于 conda 这款包管理器，它可以选择对应的频道（channel）来安装不同来源的库，且更重要的是，它的库不局限于 Python 使用：它不仅管理 Python 使用的库，还会管理这些库所需要的可能的其他语言的库，进行必要的依赖求解。另外它还集成了虚拟环境管理，不似 Python 官方提供的 venv 这个环境管理工具，这让 conda 成为了一个 All In One 的管理工具。当然，它还直接地提供了一些做科学计算时常需要的库，如 Numpy, Matplotlib，Pandas 等。如果你只是想享受最纯粹的 conda，可以考虑安装 Miniconda，不过因为我们就是要做机器学习，所以干脆安装 Anaconda 就挺好的。\n所以，为什么我们需要 Anaconda？做机器学习没有说必须要用 Anaconda 呀？而且大部分 Python 包都会优先考虑使用 PyPI 分发，而不会先考虑 Anaconda 的包源。使用 Anaconda 的主要原因，除了我看的教程是用的 Anaconda 管理环境之外，还有很重要的一点就是 conda 管理的不只是包，还有包自身的依赖，以及能直接管理环境。这解决了一个很棘手的问题：依赖地狱。假如你要的包 A 和包 B 都依赖同一个库 C，而 A 要求用老版本的 C 库，B 要求用更新版本的 C 库，而你的电脑已经安装了老 C 库，这时候要怎么办？把这个老 C 库卸载，重装一个新的吗？conda 提供了一个优雅的方法：虚拟出一个环境，在这个环境中重新安装相容的 A, B 和 C。更重要的是，这个环境不会影响到其他的环境，不用担心装上什么软件之后会毁掉整个电脑上的库。事实上，在机器学习这个较新的，正在发展的领域，机器学习用到的库很有可能发生奇怪的依赖问题。而使用 conda 就能更方便地解决这类问题。\n听我啰嗦了这么多，是时候开始安装了。\n安装 Anaconda 其实安装它还是比较简单的。只需要获取到安装脚本，然后运行就好了。我们使用命令：\n1wget https://repo.anaconda.com/archive/Anaconda3-2025.06-1-Linux-x86_64.sh 就可以把它的安装脚本下载下来。说实在的这个脚本挺大的：它有 1G 的大小，因为脚本之外应该还集成了一些压缩包。下载好之后我们可以使用 sha256sum 来检查下载到的文件是否完整：\n1echo 82976426a2c91fe1453281def386f9ebebd8fdb45dc6c970b54cfef4e9120857 ./Anaconda3-2025.06-1-Linux-x86_64.sh | sha256sum --check 一般来讲都没什么问题，会显示 ./Anaconda3-2025.06-1-Linux-x86_64.sh: OK，此时我们就可以执行安装，即运行这个脚本了。但是此时这个脚本还不能直接执行，因为这个文件的所有执行权限都默认关闭了。我们使用 chmod +x 来给这个命令以执行权限并执行：\n1chmod +x ./Anaconda3-2025.06-1-Linux-x86_64.sh 2./Anaconda3-2025.06-1-Linux-x86_64.sh 这样就进入安装流程了，首先要先同意它们的协议，我们回车，它会讲我们得同意协议条款，此时我们得明确打出 yes 并回车，否则：\n接下来就是安装位置，默认是安装在家目录的 anaconda3 这个位置，我们可以选择接受这个位置，或者手打换一个位置安装，这里就直接默认了。然后就是一阵强力的安装，就进入了最后的环节，是否要将 conda 添加进 Shell 的配置中，每次进入 Shell 就自动启动 conda。默认是 no，我们接受默认，待会儿我们手动添加相关内容。\n这样就完成安装了。不过因为没有在 Shell 中初始化 conda，我们需要先执行初始化。我们安装好的 conda 位于 ~/anaconda3/bin/conda，因此我们可以使用命令：\n1$HOME/anaconda3/bin/conda init zsh 它会更改 .zshrc 的内容，在最后添上一段：\n1# \u0026gt;\u0026gt;\u0026gt; conda initialize \u0026gt;\u0026gt;\u0026gt; 2# !! Contents within this block are managed by \u0026#39;conda init\u0026#39; !! 3__conda_setup=\u0026#34;$(\u0026#39;/home/amoment/anaconda3/bin/conda\u0026#39; \u0026#39;shell.zsh\u0026#39; \u0026#39;hook\u0026#39; 2\u0026gt; /dev/null)\u0026#34; 4if [ $? -eq 0 ]; then 5 eval \u0026#34;$__conda_setup\u0026#34; 6else 7 if [ -f \u0026#34;/home/amoment/anaconda3/etc/profile.d/conda.sh\u0026#34; ]; then 8 . \u0026#34;/home/amoment/anaconda3/etc/profile.d/conda.sh\u0026#34; 9 else 10 export PATH=\u0026#34;/home/amoment/anaconda3/bin:$PATH\u0026#34; 11 fi 12fi 13unset __conda_setup 14# \u0026lt;\u0026lt;\u0026lt; conda initialize \u0026lt;\u0026lt;\u0026lt; 其实就是将 conda 加入环境变量 $PATH 中。我们可选地将这部分挪到我们留出来的 $ZSH_CUSTOM/env 中：\n1mkdir $ZSH_CUSTOM/env 2vim $ZSH_CUSTOM/env/conda_env.zsh 然后把刚刚的内容贴进去，然后重启 Shell，就好啦。你应该能看到 Shell 的显示中，左侧有一个 (base) 的字样，这样就说明我们已经成功启动 conda 的 base 环境了。\n我们先不介绍 Anaconda 的具体操作，因为对我们而言，我们还没有完全完成环境配置。接下来要配置的则是所谓的 CUDA。\nCUDA 我刚刚收到了一个坏消息：在我刚写好 CUDA 安装部分的瞬间，鬼使神差地，我看到了 CUDA 官网文档上关于 \u0026ldquo;conda\u0026rdquo; 的部分：Conda Installation\n何意味……在 conda activate pytorch-test（我的环境名叫 pytorch-test）之后使用 where nvcc，得到了这样的结果：\n1/home/amoment/.conda/envs/pytorch/bin/nvcc 2/usr/local/cuda-13.0/bin/nvcc 算了，写都写了，我们还是介绍一下怎么安装，以及怎么卸载吧……\nCUDA 的简单介绍 CUDA 是 Nvidia 推出的 GPU 计算框架，是 Compute Unified Device Architecture 的缩写，即 计算统一设备架构（神秘机翻）。这里的 Device 主要指的就是 Nvidia 家的各系显卡了。由于进行机器学习需要对大量（存疑）的数据进行计算，而计算需求也没有特别复杂（相较于 CPU 更适合执行复杂计算任务），使用显卡这样处理图像的设备进行加速计算几乎成了刚需。\n虽然说 Nvidia 家的显卡对 Linux 的支持也许没有 Windows 端那么好（F**k you Nvidia！），但是毕竟做计算这行 Linux 还是主力，微软与 Nvidia 也很识相的给普通开发者一个很不错的选项：使用 WSL 来借助 Windows 的显卡驱动进行 CUDA 开发，且有专门适配 WSL 的包与文档供下载使用，所以我们干脆就这么做，使用支持情况最好的 Ubuntu 来进行相应的开发。\n安装 CUDA 在开始之前，我们最好先确认我们的设备可以使用 CUDA。首先我们使用 nvidia-smi 命令，它会显示设备上的 Nvidia 显卡的信息：\n可以看到有一大堆的信息在这里，包括我这台设备的显卡，以及对应的 CUDA 版本。如果是比较老的显卡，需要去 CUDA GPU Compute Capability 这里查一下显卡的 Compute Capability，大于等于 3.0 的就是可以正常使用 CUDA 的了。一般来讲只要不是上古显卡都应该是没问题的吧（逃）\n接下来就是正式安装，根据 官方文档 的描述，第一步应该删掉老旧的 GPG 密钥：\n1sudo apt-key del 7fa2af80 接下来我们就可以直接下载 CUDA Toolkit 了。目前最新版本的 Toolkit 是 13.1 版本，下载页面实际上提供的是一串脚本：\n1wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-wsl-ubuntu.pin 2sudo mv cuda-wsl-ubuntu.pin /etc/apt/preferences.d/cuda-repository-pin-600 3wget https://developer.download.nvidia.com/compute/cuda/13.1.0/local_installers/cuda-repo-wsl-ubuntu-13-1-local_13.1.0-1_amd64.deb 4sudo dpkg -i cuda-repo-wsl-ubuntu-13-1-local_13.1.0-1_amd64.deb 5sudo cp /var/cuda-repo-wsl-ubuntu-13-1-local/cuda-*-keyring.gpg /usr/share/keyrings/ 6sudo apt-get update 7sudo apt-get -y install cuda-toolkit-13-1 从上面的脚本中可以看到，实际上是首先用 wget 下载好 cuda-wsl-ubuntu.pin 文件并挪到对应位置用来设置待会儿下载好的 CUDA-Toolkit 的优先级，然后下载 .deb 包并安装，再把密钥复制一份进系统的钥匙环之后更新 apt 源，最后安装 CUDA-Toolkit。\n在安装好之后，我们需要把 /usr/local/cuda-13.0/bin 加入环境变量 $PATH 中。我们可以在 .zshrc，或者 $ZSH_CUSTOM/env/cuda_env.zsh 中写上：\n1export PATH=/usr/local/cuda-13.0/bin:$PATH 2export LD_LIBRARY_PATH=/usr/local/cuda-13.0/lib64:$LD_LIBRARY_PATH 这样我们再刷新 Shell，就可以直接在命令行中使用 nvcc 了。\n卸载 CUDA 唉，刚刚安装好，就要说再见，我们卸载它也很简单，因为 apt-get 的包管理也很方便：\n1sudo apt-get purge \u0026#34;cuda-*\u0026#34; 2sudo apt-get autoremove 3sudo apt-get autoclean 这三行分别是让 apt-get 删掉以 cuda- 开头的所有包以及相关的配置文件，自动清理不需要的软件包依赖，自动清理本地的 .deb 包。\n然后把我们刚刚加进来的环境变量删掉，就算是彻底删除了。欢乐的时光总是短暂，再见，所有的 Global Installation。\n配置开发环境 最后一步其实相对是简单很多的。我们只需要用几行命令创建一个虚拟环境，安装需要的包，然后等待漫长的时间之后，就可以在 VS Code 里跑一段代码来看看结果了。\n我们先来创建虚拟环境。\n创建虚拟环境 一行命令就可以创建好基础环境了：\n1conda create -n pytorch-test python=3.12 这里 -n 代表的是我们要给这个环境起的名字；python=3.12 是我们让 conda 安装的第一个东西：python 解释器，并且要求它的版本是 3.12。为什么不用较新的 3.13 甚至 $\\pi$thon（3.14）呢？主要是因为 PyTorch 对较新版本的支持没有那么好。创建好环境之后应该能看到提示如何启用这个环境：\n1conda activate pytorch-test 我们进去之后就可以试试 python --version 了，结果大概是这样：\n1Python 3.12.12 然后我们继续用 conda 安装别的需要的库。我们主要需要的库就是 torch 了。我们需要它的基础款以及 GPU 款，在 conda 里分别叫 pytorch 和 pytorch-gpu：\n1conda install pytorch pytorch-gpu -c anaconda -c nvidia 这行命令里的 -c 即 --channel，意为指定下载安装渠道，我们要求首先搜索 anaconda 这个频道，然后搜索 nvidia 频道，最后保底还有 default 这个默认的频道。运行这个命令之后应该会看到这些频道会被列出来，并让你按回车确定。随后就是漫长的等待，而在等待过程中，我们还可以做点别的事：配置一个待会儿用来跑测试代码的 VS Code 环境。\n由于创建环境时就可以安装需要的包，我们实际上可以\n1conda create -n pytorch-test python=3.12 pytorch pytorch-gpu -c anaconda -c nvidia 然后在创建好之后 conda activate pytorch-test 就可以了。要检查有哪些环境可用，可以 conda env list 来列出所有的环境。而要删除环境，可以使用 conda env remove -n pytorch-test 或者 conda remove -n pytorch-test --all。\n配置 VS Code 环境 首先第一步便是进入 WSL 里启动 code 了。我们既可以在 Windows 下启动 VS Code 然后点左下角的按钮来连接到需要的 WSL 上，也可以在 WSL 的 Shell 中输入 code 来直接在 WSL 里启动 code。关键是插件的下载。\n这里我们推荐下载安装这些插件：\nJupyter: 这是一个扩展包，里面包含了 Jupyter 需要的所有插件； Python: 虽然这是一个单独的包，但是安装它也会自动安装上一系列的扩展，包括环境管理、代码调试、语法检查等； Black Formatter: 一个代码格式化工具，好用，不过有时候很倔…… autoDocstring：自动帮你生成 Docstring 来说明你的函数在干嘛 Even Better TOML: 由于 Python 使用 pyproject.toml 文件作为项目描述文件，而 VS Code 并没有 TOML 的官方支持，所以如果你要用/写 Python 为主的项目的话，你可能需要它 安装也不是很麻烦，点点点就是了。如果你比较懒，还可以使用 VS Code 比较新的 Profile（怎么又是这个词）功能。我们电极左下角那个很像设置的按钮，里面就有 Profile (Default)，我们选择里面的 Profiles 进入 Profile 设置页面，我们伟大的 Python 是有 Profile 模板的。我们可以直接应用这个模板。简单方便，基本就是上面我说的这些个插件。\n经过漫长的等待…… 你应该就下载好环境中需要的所有库了。此时我们打开刚刚配好的 VS Code，进入一个空的文件夹，Ctrl+Shift+P 调出 VS Code 命令界面，搜索 Jupyter，就会出现创建新 Notebook 的选项。点它，就会打开一个 Jupyter Notebook 的界面，我们可以在右上角选择要用的 Kernel，它会自动检测我们 WSL 里都有哪些虚拟环境，选择我们刚刚花大功夫配好的 pytorch-test，随后在代码块里测试一下环境配置情况：\n1import torch 2 3print(f\u0026#39;PyTorch version: {torch.__version__}\u0026#39;) 4print(\u0026#34;CUDA available:\u0026#34;, torch.cuda.is_available()) 5if torch.cuda.is_available(): 6 print(\u0026#34;GPU:\u0026#34;, torch.cuda.get_device_name(0)) 7 print(\u0026#34;CUDA version:\u0026#34;, torch.version.cuda) # type: ignore 如果成功运行，这几行代码会一五一十地告诉你当前使用的 PyTorch 版本，CUDA 状态，使用的 GPU 等。至此，大功告成。\n如果你收到了这样一个神秘的错误：\n1libtorch_cpu.so: undefined symbol: iJIT_NotifyEvent 不要惊慌：大概率是因为 mkl 这个库的版本太新以至于你用的 PyTorch 版本没匹配上对应的版本。可以用 conda search mkl 来查看有哪些 mkl 库可以安装，选择一个老一点的就行了。网上搜到的大多是叫安装 mkl=2024.0 这个版本，然而可能是 conda 版本问题，又可能是频道没选对，我搜索到的结果只有 mkl=2023.1 的版本，因此我安装的是这个版本，结果没啥问题，能正常使用。\n后记 这篇文章从开写到现在已经过了两周了。磨磨蹭蹭，最后还是写出来了，即便中间走了一些弯路就是了。从结果上来看，conda 是相当不错的一个包管理器：它可以直接用自己带的 cuda-toolkit，不用让用户自己配置 CUDA 环境。大胆一些，我们甚至可以让 conda 来管理 C/C++ 环境，因为我搜到 conda 是可以安装 g++ 的，搭配 安装 pytorch-gpu 所提供的 nvcc，也许真的能实现用一个包管理器来管理需要的开发环境。\n然而安装过程还是挺慢的，最大的问题在于下载时间。libtorch 相当大，没记错的话有 1.x GB，有需要的话可以给 conda 配置国内的镜像源，比如阿里源、清华源等。这里就不多提了。\n那么，感谢您看我啰嗦到这里，一如既往地，祝您身心健康，happy coding~\n我知道它不是 IDE，只是编辑器，但是配置一下之后真的就相当于 IDE 了，原谅我吧（）\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n实在是不知道怎么翻译这个词比较好……\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-11-29T14:28:17+08:00","image":"/images/ピンクの夢.png","permalink":"/zh/posts/others/wsl_ubuntu_config/","title":"机器学习用 Ubuntu WSL 配置记录"},{"content":"这篇文章算是他们三个人的工作的总结，发在了 J. Appl. Phys. 上，我其实很早就看过这篇，但是当时看得匆忙，一知半解，这次仔细看看\n图图还是 Neve_AI 绘制的 AI 图来的，歪头银发小妹妹真的好可爱呀~~\n$$ \\gdef\\misfit#1{\\varepsilon_{#1}^0(\\mathbf{r})} \\gdef\\eigen#1{\\varepsilon_{#1}^*0*(\\mathbf{r})} \\gdef\\avee#1{\\overline{\\varepsilon}_{#1}} \\gdef\\dd{\\mathrm{d}} \\gdef\\Cr#1{C_{#1}(\\mathbf{r})} $$背景介绍 其实和上一篇文章差不多，都是介绍了弹性力学中非均匀变形问题的解决困难大，而使用相场法与 KS 理论就可以巧妙地解决这个问题，实现对这类问题的数值求解。不过这篇文章对理论的阐述更加详细、且给出了使用傅里叶谱方法求解的方式。\n等效应力应变下的弹性非均匀系统的平衡方程 考虑均匀的各向异性弹性系统，它和非均匀弹性系统有同样的形状和大小，但是内部存在非均匀的错配无应力的应变（所谓的 本征应变 ）。本征应变在宏观上是非均匀的，但宏观上是均匀的。我们用 $\\varepsilon_{ij}^0(\\mathbf{r})$ 来标记这个应变。由 KS 理论可以给出用本征应变表达的弹性应变以及弹性能。\n弹性均匀系统中任意无应力应变分布对应的应变能泛函 由于宏观上本征应变是均匀的，我们可以用平均应变来表示：\n$$ \\overline\\varepsilon_{ij} = \\frac{1}{V}\\int_V \\varepsilon_{ij}(\\mathbf{r})\\ \\dd^3 {r}, $$弹性能由 KS 理论可得：\n$$ \\begin{align*} E^{\\mathrm{el}} = \u0026\\frac{1}{2}\\int_V C_{ijkl}^0 \\misfit{ij}\\misfit{kl}\\dd^3 r \\\\ \u0026- \\avee{ij}\\int_V C_{ijkl}^0 \\misfit{kl}\\dd^3 r + \\frac{V}{2}C_{ijkl}^0 \\avee{ij}\\avee{kl} \\\\ \u0026- \\frac{1}{2}\\tilde{\\int}\\frac{\\dd^3 k}{(2\\pi)^3}n_i \\tilde{\\sigma}_{ij}^0(\\mathbf{k})\\Omega_{jk}(\\mathbf{n})\\tilde{\\sigma}_{kl}^0(\\mathbf{k})^* n_l, \\end{align*} $$依旧，这里的 $\\tilde{\\int}$ 代表在去掉 $\\mathbf{k} = \\mathbf{0}$ 处 $(2\\pi)^3/V$ 的倒空间体积后，在倒空间中的积分，而 $\\mathbf{n} = \\mathbf{k}/k$ 还是倒空间中点的方向。而 $\\Omega_{ij}(\\mathbf{n})$ 是格林方程张量，是 $\\Omega^{-1}_{ij}(C^0_{ijkl}n_kn_l)$ 的逆张量。这里的 $C_{ijkl}^0$ 是弹性模量，可以把倒空间中的弹性应力和应变通过它连接起来，即 $\\tilde{\\sigma}_{ij}^{o}(\\mathbf{k}) = C_{ijkl}\\,\\tilde{\\varepsilon}_{kl}^{o}(\\mathbf{k})$。公式中倒空间应力的星号代表复共轭。这个形式的自由能适用于应变控制下的边界条件。\n在 KS 理论下的等效应变则可以用本征应变表达为：\n$$ \\begin{align*} \\varepsilon_{ij}(\\mathbf{r}) \u0026= \\overline{\\varepsilon}_{ij} + \\frac{1}{2}\\tilde\\int\\frac{d^3k}{(2\\pi)^3}\\,[\\,n_i\\Omega_{jk}(\\mathbf{n})+n_j\\Omega_{ik}(\\mathbf{n})\\,]\\, \\tilde{\\sigma}^0_{kl}(\\mathbf{k})\\,n_l\\,e^{i\\mathbf{k}\\cdot\\mathbf{r}}. \\end{align*} $$上面的能量泛函以及应变对宏观均匀体系都是使用的，而宏观尺度的体系一般都是显著大于用错配应变 $\\misfit{ij}$ 表征的介观尺度的。\n非均匀体系中的弹性平衡方程 接下来考虑各向异性的弹性非均匀系统。它的弹性模量，由于弹性非均匀性，表达为 $\\Cr{ijkl}$，结构非均匀性通过本征应变表示出来：$\\eigen{ij}$。而体系的模量可以分成弹性模量部分以及模量因为非均匀性发生的变化，即：\n$$ \\begin{align*} C_{ijkl}(\\mathbf{r}) \u0026= C^{0}_{ijkl} - \\Delta C_{ijkl}(\\mathbf{r}), \\end{align*} $$我们考虑体系被约束，其宏观变形是固定的且值为 $\\avee{ij}$，这样的约束就造成了应变场 $\\varepsilon_{ij}(\\mathbf{r})$，因为弹性和结构都是非均匀的，这个应变场也是非均匀的。此时，应变与应力依旧可以通过胡克定律联系起来：\n$$ \\begin{align*} \\sigma_{ij}(\\mathbf{r}) \u0026= C_{ijkl}(\\mathbf{r})\\big[\\varepsilon_{kl}(\\mathbf{r}) - \\varepsilon_{kl}^*(\\mathbf{r})\\big], \\end{align*} $$而且在体系静态时，满足静力平衡条件：\n$$ \\begin{align*} \\frac{\\partial \\sigma_{ij}(\\mathbf{r})}{\\partial r_j} \u0026= 0 \\end{align*} $$那么将上面的内容组合并整理，就得到了：\n$$ \\begin{align*} \u0026 C^{0}_{ijk\\ell}\\,\\frac{\\partial \\varepsilon_{kl}(\\mathbf{r})}{\\partial r_{j}} \\;=\\; \\frac{\\partial}{\\partial r_{j}}\\big\\{\\,C^{0}_{ijkl}\\,\\varepsilon^{*}_{kl}(\\mathbf{r}) +\\Delta C_{ijkl}(\\mathbf{r})\\,[\\,\\varepsilon_{kl}(\\mathbf{r})-\\varepsilon^{*}_{kl}(\\mathbf{r})\\,]\\,\\big\\}. \\end{align*} $$对于应变，我们故伎重施，可以分成均匀部分 $\\overline{\\varepsilon}_{ij}(\\mathbf{r})$ 和非均匀的部分 $e_{ij}(\\mathbf{r})$：\n$$ \\begin{align*} \\varepsilon_{ij}(\\mathbf{r}) \u0026= \\bar{\\varepsilon}_{ij} + e_{ij}(\\mathbf{r}), \\end{align*} $$而非均匀部分又可以通过位移向量 $\\mathbf{v(r)}$ 得到：\n$$ \\begin{align*} e_{ij}(\\mathbf{r}) \u0026= \\tfrac{1}{2}\\left[\\frac{\\partial v_i(\\mathbf{r})}{\\partial r_j} + \\frac{\\partial v_j(\\mathbf{r})}{\\partial r_i}\\right] \\end{align*} $$由于体系在宏观上被限制，在表面区域有 $\\mathbf{v}(\\mathbf{r}^S) = 0$，这在介观尺寸下是合理的：介观尺寸的大小可以认为是远小于整个理论考虑的宏观尺寸的大小的。对于宏观均匀受控体系它边界上的总位移为 $\\overline{\\varepsilon}_{ij}r^S_j$。\n将上面关于总应变的两个公式带入上面改写后的力学平衡表达式，可以得到：\n$$ \\begin{align*} C^{0}_{ijkl}\\,\\frac{\\partial^{2} v_{k}(\\mathbf r)}{\\partial r_{j}\\partial r_{l}} \u0026= \\frac{\\partial}{\\partial r_{j}}\\big\\{C^{0}_{ijkl}\\,\\varepsilon^{*}_{kl}(\\mathbf r)\\big\\} +\\Delta C_{ijkl}(\\mathbf r)\\big[\\varepsilon_{kl}(\\mathbf r)-\\varepsilon^{*}_{kl}(\\mathbf r)\\big]. \\end{align*} $$为简化模型，我们考虑体系表面处被包裹了一层无限小的材料，其模量为弹性模量 $C_{ijkl}^0$。这样的处理能够简化这个问题：原来边界上的材料模量、法向量等不需要单独描述，可以和材料体内部一同描述。另外我们进一步假设材料表面是没有结构不均匀存在，这样我们就有 $\\varepsilon^{*}_{ij}\\big(\\mathbf{r}^s\\big)=0$，即界面上没有本征应变。这样我们就能想办法求出位移场 $\\mathbf{v}(\\mathbf{r}^S)$ 来，具体地说，我们会把上面的公式做傅里叶变换，然后把边界上没有位移、没有本征应变、不改变弹性模量场的条件放进去，得到位移场的积分形式：\n$$ \\begin{align*} v_i(\\mathbf{r}) = \u0026\\tilde\\int \\frac{d^3k}{(2\\pi)^3}\\Bigg\\{ -i\\frac{1}{k}\\,\\Omega_{ij}(\\mathbf{n})\\Big[ C^0_{jklm}\\,\\varepsilon^*_{lm}(\\mathbf{r}) \\\\ \u0026+ \\Delta C_{jklm}(\\mathbf{r})\\big(\\varepsilon_{lm}(\\mathbf{r})-\\varepsilon^*_{lm}(\\mathbf{r})\\big)\\Big]_\\mathbf{k}\\,n_k \\Bigg\\}\\mathrm{e}^{i\\mathbf{k}\\cdot\\mathbf{r}}, \\end{align*} $$其中那个带下标 $\\mathbf{k}$ 的括号是表示括号里的内容经过傅里叶变换的结果。那么再对这个结果做一次积分，就得到了应变场：\n$$ \\begin{align*} \\varepsilon_{ij}(\\mathbf r) \u0026= \\bar{\\varepsilon}_{ij} + \\frac{1}{2}\\tilde\\int\\frac{d^{3}k}{(2\\pi)^{3}}\\,[n_{i}\\Omega_{jk}(\\mathbf n)+n_{j}\\Omega_{ik}(\\mathbf n)]\\\\ \u0026\\quad\\times\\{C^{0}_{klmn}\\,\\varepsilon^{*}_{mn}(\\mathbf r)+\\Delta C_{klmn}(\\mathbf r)[\\varepsilon_{mn}(\\mathbf r)-\\varepsilon^{*}_{mn}(\\mathbf r)]\\}_\\mathbf{k}n_l\\,e^{i\\mathbf{k}\\cdot\\mathbf{r}}. \\end{align*} $$上面的应变场积分公式是一个平衡公式，可以看到左边和右边都有应变 $\\varepsilon(\\mathbf{r})$ 在，这是用弹性和结构不均匀性用 $\\Delta C_{ijkl}(\\mathbf{r})$ 和 $\\varepsilon^*(\\mathbf{r})$ 表示出来的结果。这个公式的结果和我们之前得到的 KS 理论的结果非常像，只要我们把这个傅里叶变换的结果单提取出来，用一个特殊的应变表达它：\n$$ \\begin{align*} C_{ijkl}^{0}\\,\\varepsilon_{kl}^{0}(\\mathbf{r}) \u0026= C_{ijkl}^{0}\\,\\varepsilon_{kl}^{*}(\\mathbf{r}) + \\Delta C_{ijkl}(\\mathbf{r})\\big[\\varepsilon_{kl}(\\mathbf{r}) - \\varepsilon_{kl}^{*}(\\mathbf{r})\\big]. \\end{align*} $$那么上面的应变场公式就成为：\n$$ \\begin{align*} \\varepsilon_{ij}(\\mathbf{r}) \u0026= \\bar{\\varepsilon}_{ij}+\\tfrac{1}{2}\\int\\frac{\\mathrm{d}^3\\mathbf{k}}{(2\\pi)^3} \\big[ n_i\\Omega_{jk}(\\mathbf{n})+n_j\\Omega_{ik}(\\mathbf{n})\\big] \\\\ \u0026\\quad\\times C^0_{klmn}\\,\\tilde{\\varepsilon}^0_{mn}(\\mathbf{k})\\,n_l\\,e^{i\\mathbf{k}\\cdot\\mathbf{r}}\\,. \\end{align*} $$这个公式和前面的 KS 理论的结果只差在 $C^0_{klmn}\\tilde\\varepsilon^0_{mn}(\\mathbf{k})$ 上，而前面的这部分是 $\\tilde\\sigma_{kl}^0(\\mathbf{k})$，而这里使用的变量 $\\varepsilon_{kl}^{0}(\\mathbf{r})$ 实际上就是错配应变。这一点说明了，在选择合适的错配应变 $\\varepsilon_{ij}^0(\\mathbf{r})$ 之后，弹性-结构非均匀体系的应力和应变就等于弹性均匀体系的应力和应。事实上，我们可以从前面那个代换式子得到：\n$$ \\begin{align*} C^0_{ijkl}\\big[\\varepsilon_{kl}(\\mathbf{r})-\\varepsilon^0_{kl}(\\mathbf{r})\\big] \u0026= \\big[C^0_{ijkl}-\\Delta C_{ijkl}(\\mathbf{r})\\big]\\big[\\varepsilon_{kl}(\\mathbf{r})-\\varepsilon^*_{kl}(\\mathbf{r})\\big]. \\end{align*} $$注意到上面的式子左边是弹性均匀部分的弹性常数乘以全应变与错配应变的差，表示的是弹性均匀部分的应力，右边是原本的弹性-结构非均匀部分的应力，这两者是相等的。这样我们就说明了上面的应力部分相等的原因。\n而上面的式子我们还可以再做一些变化，比如利用本征应变、错配应变和全应变之间的关系，让上面的式子转而表达错配应变的平衡公式：\n$$ \\begin{align*} \u0026\\Delta S_{ijkl}(\\mathbf r)\\,C^0_{klmn}\\big[\\varepsilon^0_{mn}(\\mathbf r)-\\varepsilon^*_{mn}(\\mathbf r)\\big]+\\varepsilon^*_{ij}(\\mathbf r)\\\\ \u0026\\quad= \\bar\\varepsilon_{ij}+\\frac{1}{2}\\int\\frac{d^3k}{(2\\pi)^3}\\big[n_i\\Omega_{jk}(\\mathbf n)+n_j\\Omega_{ik}(\\mathbf n)\\big]\\\\ \u0026\\qquad\\times C^0_{klmn}\\,\\tilde\\varepsilon^0_{mn}(\\mathbf k)\\,n_l\\,e^{i\\mathbf k\\cdot\\mathbf r}\\,, \\end{align*} $$这样我们就能（？）通过迭代这个等效弹性均匀问题中的平衡公式来获得错配应变，进而由它求出应变场和应力场了。应变可以用那个积分式得到，而应力则直接可以用应变得到。\n变分法和其在任意非均匀系统中求解弹性平衡的应用 有了前面的理论，我们可以考虑如何解平衡方程了。首先还是先构筑能量泛函：\n弹性-结构非均匀体系的应变能泛函 弹性-结构非均匀体系的应变能，根据前面的理论，可以表达为等效弹性均匀体系的能量。弹性均匀系统能量可以表达为：\n$$ \\begin{align*} E^{\\mathrm{hom}} \u0026= \\tfrac{1}{2}\\int_V C^{0}_{ijkl}\\big[\\varepsilon_{ij}(\\mathbf r)-\\varepsilon_{ij}^0(\\mathbf r)\\big]\\big[\\varepsilon_{kl}(\\mathbf r)-\\varepsilon_{kl}^0(\\mathbf r)\\big]\\,\\mathrm{d}^3r \\end{align*} $$而原始的弹性-结构非均匀系统的弹性能应该表达为：\n$$ \\begin{align*} E^{\\mathrm{inhom}} \u0026= \\tfrac{1}{2}\\int_{V}\\big[C^{0}_{ijkl}-\\Delta C_{ijkl}(\\mathbf r)\\big]\\big[\\varepsilon_{ij}(\\mathbf r)-\\varepsilon^{*}_{ij}(\\mathbf r)\\big]\\\\ \u0026\\qquad\\qquad\\times\\big[\\varepsilon_{kl}(\\mathbf r)-\\varepsilon^{*}_{kl}(\\mathbf r)\\big]\\,\\mathrm{d}^{3}r. \\end{align*} $$那么二者之间的差就可以用前面错配应变与本征应变、全应变的关系来表达为：\n$$ \\begin{align*} \\Delta E \u0026= E^{\\text{inhom}}-E^{\\text{hom}} \\\\[6pt] \u0026= \\tfrac{1}{2}\\int_{V}\\Big[\\,C^{0}_{ijmn}\\,\\Delta S_{mnpq}(\\mathbf r)\\,C^{0}_{pqkl}-C^{0}_{ijkl}\\Big] \\\\ \u0026\\quad\\times\\big[\\varepsilon^{0}_{ij}(\\mathbf r)-\\varepsilon^{*}_{ij}(\\mathbf r)\\big]\\big[\\varepsilon^{0}_{kl}(\\mathbf r)-\\varepsilon^{*}_{kl}(\\mathbf r)\\big]\\,\\mathrm{d}^{3}r. \\end{align*} $$（这个式子不是很好推，可以尝试先把均匀能量前面的“应力”部分换成非均匀的表示方法，然后再把 $\\varepsilon (\\mathbf{r})$ 表达为 $\\varepsilon^0 (\\mathbf{r}) - \\varepsilon^* (\\mathbf{r})$ 的形式。）\n最最后，我们把 KS 理论给出的弹性能带入这里的均匀能量，就得到了：\n$$ \\begin{align*} E^{\\mathrm{inhom}} \u0026= \\frac{1}{2}\\int_{V}\\big[C^0_{ijmn}\\,\\Delta S_{mnpq}(\\mathbf r)\\,C^0_{pqkl}-C^0_{ijkl}\\big]\\\\ \u0026\\quad\\times[\\varepsilon^0_{ij}(\\mathbf r)-\\varepsilon^*_{ij}(\\mathbf r)]\\,[\\varepsilon^0_{kl}(\\mathbf r)-\\varepsilon^*_{kl}(\\mathbf r)]\\,\\mathrm{d}^3r\\\\ \u0026\\quad +\\frac{1}{2}\\int_{V}C^0_{ijkl}\\,\\varepsilon^0_{ij}(\\mathbf r)\\,\\varepsilon^0_{kl}(\\mathbf r)\\,\\mathrm{d}^3r\\\\ \u0026\\quad -\\bar{\\varepsilon}_{ij}\\int_{V}C^0_{ijkl}\\,\\varepsilon^0_{kl}(\\mathbf r)\\,\\mathrm{d}^3r+\\frac{V}{2}\\,C^0_{ijkl}\\,\\bar{\\varepsilon}_{ij}\\bar{\\varepsilon}_{kl}\\\\ \u0026\\quad -\\frac{1}{2}\\tilde\\int\\frac{\\mathrm{d}^3k}{(2\\pi)^3}\\,n_i\\,\\tilde{\\sigma}^0_{ij}(\\mathbf k)\\,\\Omega_{jk}(\\mathbf n)\\,\\tilde{\\sigma}^0_{kl}(\\mathbf k)^*\\,n_l\\ . \\end{align*} $$唉，恐怖又丑陋的式子……这么多积分，这么多张量缩并，真能算出来的吗？不管了……\n无应力应变平衡方程的变分法求解 根据经典变分法，当能量最低时我们有\n$$ \\begin{align*} \\frac{\\delta E^{\\mathrm{inhom}}}{\\delta \\varepsilon^0_{ij}(\\mathbf{r})} \u0026= 0. \\end{align*} $$这个方程的结果会给出上面关于错配应变的平衡方程。而这里要说明的是，这个变分方程的结果将会给出弹性-结构非均匀体系的平衡应力和平衡应变。原因也很简单，满足这个方程的应变值就是我们要寻找的错配应变，而有了这个错配应变我们就能重构出来体系的平衡应力和平衡应变。这里我们将错配应变用弛豫法让它变成弛豫变量，由于它的性质，我们可以采用随时 Ginzburg-Landau 方程，也就是 Allen-Cahn 方程来解这个东西：\n$$ \\begin{align*} \\frac{\\partial \\varepsilon_{ij}^0(\\mathbf{r},t)}{\\partial t} \u0026= -L_{ijkl}\\,\\frac{\\delta E^{\\mathrm{inhom}}}{\\delta \\varepsilon_{kl}^0(\\mathbf{r},t)}\\,, \\end{align*} $$然而要注意的是这里的“时间”是弛豫时间，因为我们需要的是力学的平衡解。这里 $L_{ijkl}$ 是动力系数，由于它是正定的且只控制收敛速率，我们不用太关心它的具体值，好用就成，最简单的形式就是\n$$ L_{ijkl} = L \\delta_{ik} \\delta_{jl}. $$我们把它非均匀体系的能量带到这里，就会得到：\n$$ \\begin{align*} \\frac{\\partial \\varepsilon^0_{ij}(\\mathbf r,t)}{\\partial t} \u0026= L C^0_{ijkl}\\Big\\{\\frac{1}{2}\\tilde\\int\\frac{\\mathrm{d}^3k}{(2\\pi)^3}\\big[n_k\\Omega_{lm}(\\mathbf n)\\\\ \u0026\\qquad+n_l\\Omega_{km}(\\mathbf n)\\big]\\widetilde\\sigma^0_{mn}(\\mathbf k)\\,n_n e^{i\\mathbf k\\cdot\\mathbf r}\\\\ \u0026\\qquad -\\Delta S_{klmn}(\\mathbf r)\\,C^0_{mnpq}\\big[\\varepsilon^0_{pq}(\\mathbf r)-\\varepsilon^*_{pq}(\\mathbf r)\\big]-\\varepsilon^*_{kl}(\\mathbf r)\\\\ \u0026\\qquad +\\varepsilon^0_{kl}+S^0_{klmn}\\sigma^{ex}_{mn}\\Big\\}\\,, \\end{align*} $$这里的 $\\bar{\\varepsilon}_{jk}^0 = (1/V)\\int_V\\varepsilon_{ij}^0(\\mathbf{r})\\mathrm{d}^3 r$，$S_{ijkl}^0$ 是弹性模量的逆张量：$C^0_{ijkl}{}^{-1}$，这个公式可以用应变来做边界条件，也可以用外应力来做边界条件。外应力可以表达为：\n$$ \\sigma^{\\mathrm{ex}}_{ij} = C_{ijkl}^0 (\\bar\\varepsilon_{kl} - \\bar\\varepsilon^0_{kl}) $$要处理结构不均匀性，可以设置他们的不均匀部分的弹性模量，比如说空位的地方，就可以将它们的弹性模量设为 0，即 $\\Delta C_{ijkl} = C^0_{ijkl}$。这样我们就可以把结构非均匀的体系转化为弹性非均匀体系处理了。\n相场法解弹性非均匀体系的弹性平衡 后面主要就是使用相场法去求解一些案例了。从结果可以看出这个模型的结果相当不错，在弹性-结构非均匀体系里解出来的几个结果都是比较符合实际的。我们这里就不深入了（）\n总结 这篇文章也是拖了一段时间，有很多原因……其中一点是我查了一下这系列文章中的“KS 理论”和“本征应变”的内容，结论上讲，多多少少是有一些结论的，了解到了一些很有趣的东西。在这里写一下吧（本来是想写在这里的，结果实在是太长了，现在看来还是算了吧（））\n依旧包含了许多哈人的方程与哈人的能量构造，感觉和他们 01 年写的文章路数如出一辙，只不过这篇文章的理论推导部分更多更详细一些，给人一种“我看懂了”的错觉……不过我有预感，很快就可以彻底理解这个部分了，只需要再多一点符号，多一点推导，少一点脑细胞……\n这篇文章的收获主要有：\n推导了结构-弹性非均匀系统的能量泛函构造方式 感谢三位，得到了求解本征应变需要的相场演化方程 我逐渐理解一切（指这部分的推导） 再次体会到了大佬的强大（） 还可以深入了解的点有：\nKS 理论，必须直面它了。不理解这个核心理论，感觉这些内容就都是空中楼阁。 自己写一写这个力学求解器，自己不写怎么知道它怎么起效果的呢？（虽然肉眼可见地难） 依旧，学习如何像大佬一样玩弄公式 论文阅读环节也许会暂时到此为止，因为下一篇大概是读书笔记（Toshio Mura 所著的 Micromechanics of Defects in Solids）。我大概也许能从中刨出所谓的 KS 理论的真身（又或许需要再搭配几篇文章）。然后可能我会考虑用亲爱的 Python 来实现这个算法。\n","date":"2025-11-15T23:25:19+08:00","image":"/images/Nev_Alice-2.jpg","permalink":"/zh/posts/pf_papers/wang-jin-khachaturyan2002/","title":"文献阅读 - Wang-Jin-Khachaturyan2002"},{"content":"Jin，Wang 和 Khachaturyan 等人在 01 年的时候于 Appl. Phys. Lett. 发表了这篇文章（快报），应该是自此开创了相场的微弹性力学理论。今天就读读它，（尝试）以此为起点学习这个理论吧。\n图图还是 Neve_AI 绘制的 AI 图来的，真的好可爱呀~~\n研究背景 这个理论建立的原因是因为在载荷下对包含裂纹、孔隙的材料进行力学求解时几乎没法得到数学解析解，这主要是因为很多材料都是各向异性的多晶，而材料里的各种复杂缺陷也很难描述，只能用数值计算的方法对材料的真实力学状态进行求解。而相场法正好能解决一些材料的单/多晶问题，如果能用它再解一下材料内部的力学状态就好了。这就引出了本文的主要内容。\n神秘 KS 理论 模型的建立主要依赖于 Khachaturyan-Shatalov (KS) 理论（也就是通讯作者（大佬）和另一个（一看就知道是）俄国大佬开创的理论），把裂纹/孔隙在加载应力 $\\sigma_{ij}^{\\mathrm{appl}}$ 下的问题转化成等效的另一个问题：连续弹性均匀 (Homogeneous) 基体中掺点儿宏观均匀，但介观不均匀 (Heterogeneous) 的、由晶格错配导致的无加载的应变 $\\epsilon_{ij}^o(\\mathbf{r})$（也就是大名鼎鼎的 本征应变 Eigenstrain）。\n均匀/非均匀应变 这里解释一下什么是均匀和非均匀的应变，如图1：\n均匀 Homogeneous 非均匀 Heterogeneous 吓人公式 这个理论给出了应变能泛函和应力分布的准确解，其中应力为：\n$$ \\begin{align*} \\sigma_{ij}(\\mathbf{r}) \u0026= C_{ijkl}\\Bigg[\\tilde\\int\\frac{d^{3}k}{(2\\pi)^{3}}\\,n_{k}\\,\\Omega_{lm}(\\mathbf{n})\\,\\tilde\\sigma^{o}_{mn}(\\mathbf{k})\\,n_{n}\\,e^{i\\mathbf{k}\\cdot\\mathbf{r}} -\\bar\\epsilon^{o}_{kl}(\\mathbf{r})\\Bigg]+ \\sigma^{\\mathrm{appl}}_{ij} \\end{align*} $$这个公式相当吓人，符号很多……其中的积分号带了一个小波浪线在上面，表示这是在倒空间 (Reciprocal Space) 里的积分（也就是对原本的空间进行一次傅立叶变换），积分区域为无穷区域挖掉 $\\mathbf{k} = 0$ 处的一个 $(2\\pi)^3/V$ 的体积的区域，$\\mathbf{n} = \\mathbf{k}/k$ 应该是倒空间中的法向量，$\\Omega_{ij}(\\mathbf{n})$ 是所谓的格林方程张量，是 $\\Omega^{-1}_{ij}(\\mathbf{n}) = C_{ijkl}n_kn_l$ 的逆张量，里面的 $C_{ijkl}$ 是弹性模量，$\\tilde{\\sigma}_{ij}^{o}(\\mathbf{k}) = C_{ijkl}\\,\\tilde{\\epsilon}_{kl}^{o}(\\mathbf{k})$ ，公式中的上标 $*$ 代表的是取复共轭，最后有 $$ \\begin{align*} \\tilde{\\epsilon}_{ij}^{0}(\\mathbf{k}) \u0026= \\int_{V} \\epsilon_{ij}^{0}(\\mathbf{r})\\, e^{-i\\mathbf{k}\\cdot\\mathbf{r}}\\, d^{3}r\\\\ \\epsilon^{o}_{ij} \u0026= \\frac{1}{V}\\int_{V}\\epsilon^{o}_{ij}(\\mathbf{r})\\,\\mathrm{d}^3r \\end{align*} $$我们来看这个公式，首先应力分布是一个标量场，自然是用弹性模量“乘以”材料本来就有的应变产生的张量（其实是缩并），再加上一个外加的应力。这里的 $C_{ijkl}$ 没啥问题，重点在于怎么计算后面的应变张量。可以看到这个应变由三个部分组成，一个复杂的倒空间积分结果，减掉上面讲的那个宏观一致而微观不一致的本征应变 $\\epsilon_{kl}^{o}(\\mathbf{r})$，再加上这个应变的平均值。\n再看积分，积分内部有一个所谓的格林方程张量，只跟方向有关，后面乘上了个 $\\tilde\\sigma^{o}_{mn}(\\mathbf{k})$，这个东西又是用弹性模量乘了一个应变，而这个应变是什么呢？是那个本征应变的傅里叶变换，最后又乘以反变换因子，换回正空间里。也就是说，组成这个应变的三个部分里，第一部分在倒空间里把本征应变揉揉捏捏了一把，应该是能得到某种“平均化”的性质，然后减掉不揉捏的部分得到非均匀的主项，最后再加上平均应变得到材料内部的应变分布场。\n这里的定性分析说实话我也是瞎猜的，以后得再找来看看原文献，了解一下这个所谓的 KS 理论到底是怎么建立的，为什么要用倒空间中的奇怪积分把那个应变揉揉捏捏一遍。\n能量最小条件 Anyway，我们得到了应力分布之后就可以把弹性能表示出来，弹性能对应变求变分导数就得到了：\n$$ \\begin{align*} \\frac{\\delta E^{\\mathrm{el}}}{\\delta \\epsilon_{ij}^{o}(\\mathbf r)} \u0026= -\\sigma_{ij}(\\mathbf r) \\end{align*} $$我们再进一步考虑这里这个本征应变，它应该只在连续体内有错配的区域内部不为0，而在其余的地方都应该是0才对，而如果本征应变场是能让弹性能的能量最小化的特殊分布 $\\epsilon^{oo}_{ij}(\\mathbf{r})$，那么就有：\n$$ \\begin{align*} \\left.\\frac{\\delta E^{\\mathrm{el}}}{\\delta \\epsilon^{o}_{ij}(\\mathbf{r})}\\right|_{\\epsilon^{o}_{ij}(\\mathbf{r})=\\epsilon^{oo}_{ij}(\\mathbf{r})}=0 \\end{align*} $$又因为本征应变只能在错配（畸变）区域内不为0，所以自然上面那个特殊的能量也满足这个条件。比较上面两个公式，就可以发现在错配区域内部应力场的值应该都是 0 才对。因此我们带回那个很麻烦的方程，就有了：\n$$ C_{ijkl}\\Bigg[ \\tilde\\int\\frac{d^3k}{(2\\pi)^3}\\, n_k\\,\\Omega_{lm}( \\mathbf{n})\\,\\tilde{\\sigma}^{oo}_{mn}(\\mathbf{k})\\,n_n\\,e^{i\\mathbf{k}\\cdot\\mathbf{r}_d}- \\epsilon^{oo}_{kl}(\\mathbf{r}_d) + \\bar{\\epsilon}^{oo}_{kl}\\Bigg] + \\sigma^{\\mathrm{appl}}_{ij} = 0 $$这就是通过能量最小得到的条件了。\n移出错配区域与等价问题 由于在平衡时错配区域内部没有应力，我们可以把它们移出来而不改变内部的应力状态，而移出它们就相当于创造了空隙和裂纹！就这样我们实现了这个神奇的问题转化过程，而这个问题在 Khachaturyan 以前关于弹性均匀基体中分布有错配生成应变问题的工作2里已经解决过了。前面的这一套就说明了 弹性均匀基体与错配生成应变的体系的弹性应变和应变能实际上和非连续的包含空隙裂纹的体系中的弹性应变与应变能是一样的，如果后者的错配应变能使体系弹性能最小。这就给我们使用相场法研究 能量最小化问题 提供了理论依据。\n相场模型 根据上面的情况，我们自然使用 Allen-Cahn 模型：\n$$ \\begin{align*} \\frac{\\partial \\epsilon^{o}_{ij}(\\mathbf{r},t)}{\\partial t} \u0026= -L_{ijkl}\\frac{\\delta E^{\\mathrm{el}}}{\\delta \\epsilon^{o}_{kl}(\\mathbf{r},t)} \\end{align*} $$其中的 $L_{ijkl}$ 是动力学因子，剩下的都解释过了。通过相场法解出来的结果和那些存在解析解的体系结果是吻合的。\n另一种方法 另外还有一种相场方程可以用来解决这个问题，不过我们需要改变本征应变的描述，这个描述下它具有不变平面应变3（？什么鬼）：\n$$ \\begin{align*} \\epsilon_{ij}^{o}(\\mathbf{r}) \u0026= \\sum_{\\alpha=1}^{p} h_i(\\alpha,\\mathbf{r})\\,H_j(\\alpha) \\end{align*} $$其中 $\\mathbf{H}(\\alpha)$ 是倒空间向量，表示了几种可能的解理面；$\\mathrm{h}(\\alpha,\\mathrm{r})$ 是裂纹开口向量，用来描述在 $\\alpha$ 型解理下位移不连续性。这两个量可以被看作长程非保守序参量，且能够描述具有任意混合裂纹的一般构型。不过它的应变能和本征应变还是利用 KS 理论给出的。\n为了描述抵抗裂纹开口的力，必须描述其应变能，这个能量可以用“粗晶”朗道能来描述：\n$$ \\begin{align*} E^{\\mathrm{ch}} \u0026= \\sum_{\\alpha=1}^{p} \\int_{V} f^{\\mathrm{ch}}\\bigl[\\,h(\\alpha,\\mathbf{r})\\,\\bigr]\\,\\mathrm{d}^{3}\\mathbf{r}. \\end{align*} $$这里 $f^{\\mathrm{ch}}[\\mathbf{h}(\\alpha)]$ 描述了开口过程中解理面上原子键连续断裂的效应。另外，用来表示裂纹表面的曲率的梯度能表示为：\n$$ \\begin{align*} E^{\\mathrm{grad}} \u0026= \\sum_{\\alpha=1}^{p}\\int_{V}\\frac{1}{2}\\,D_{ijkl}(\\alpha)\\,[\\mathbf{H}(\\alpha)\\times\\nabla]_i\\,h_j(\\alpha,\\mathbf{r})\\,[\\mathbf{H}(\\alpha)\\times\\nabla]_k\\,h_l(\\alpha,\\mathbf{r})\\,d^{3}r \\end{align*} $$又是个复杂的积分，还有叉乘！？这个能量会在平滑裂纹表面处为0，而在裂纹尖端会贡献一部分能量。最后我们就可以把总能构建出来，简单来讲就是把弹性能、化学键断裂能和这个梯度能加起来：\n$$ \\begin{align*} E \u0026= E^{\\mathrm{el}} + E^{\\mathrm{ch}} + E^{\\mathrm{grad}}. \\end{align*} $$假如把 Allen-Cahn 形式的方程里的能量和本征应变用新方法中的能量和裂纹开口向量表示，就能得到作者之前工作中用来描述马氏体相变的相场模型453。\n总结 哈人的方程与哈人的能量构造，但是这个路径却能神奇地将连续体的力学情况和非连续体的力学情况统一起来，实现一个能量到处都用，而且这个能量最小化的方式正正好能和相场法结合起来，这一点实在是太妙了。如果这个方法的自由能形式更简单一些的话，那就更好咯。\n这篇文章的收获主要有：\n对相场微弹性力学理论有一个初步认识 明白了为什么这个神奇公式可以和相场结合起来 知道了 KS 理论这个神奇的东西 感受大佬对公式的玩弄 还可以深入了解的点有：\nKS 理论到底怎么得到那个复杂到根本不想看的积分的 为什么应变能分成这么三个部分 本征应变应该还有更特殊的意义才对 倒空间是怎么个积分法 后面的“化学能”和“梯度能”到底是怎么构建的，为什么有这样的形式 学习如何像大佬一样玩弄公式 感觉微弹性理论还是有很多需要学的东西呀，本来想着靠这篇文章简单速通，结果没想到这么多看不懂的内容。既然如此，下次就继续了解这个所谓的 KS 理论吧，理解这个理论的话很多东西就应该水到渠成了。\n图片来自：Visualizing Strain\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nA. G. Khachaturyan, Fiz. Tverd. Tela 8, 2710 (1966); Sov. Phys. Solid State 8, 2163 (1967); A. G. Khachaturyan and G. A. Shatalov, Sov. Phys. JETP 29, 557 (1969); A. G. Khachaturyan, Theory of Structural Transformations in Solids (Wiley, New York, 1983).\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nY. M. Jin, Y. U. Wang, and A. G. Khachaturyan (unpublished).\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nY. Wang and A. G. Khachaturyan, Acta Mater. 45, 759 (1997).\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nY. M. Jin, A. Artemev, and A. G. Khachaturyan, Acta Mater. 49, 2309 (2001).\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-10-23T23:25:19+08:00","image":"/images/Nev_Alice.jpg","permalink":"/zh/posts/pf_papers/jin-wang-khachaturyan2001/","title":"文献阅读 - Jin-Wang-Khachaturyan2001"},{"content":"Nestler 在他 1999 年的这篇文章里进一步介绍了他在之前的文献里的模型情况，后面大家用的比较多的结果应该是界面能的部分，这里读（翻译）一下这篇文章，做个笔记。\n图图是 Neve_AI 绘制的 AI 图来的，真可爱呀~~\n研究背景 本文主要是在比较尖锐界面的渐进结果以及多相场模型的模拟结果。在前一篇文章 1 中已经建立了一个模型，通过使用广义 Cahn-Hoffman $\\xi$-向量公式在窄界面条件下反推出了界面处的 Gibbs-Thomson-Herring 方程以及各向异性力在多相交接点的守恒。在另一篇文章 2 里对多相 Allen-Cahn 方程做了渐进分析，确定了渐进奇异极限（大概就是网格多粗的时候公式就失效了）；另外在另一篇文章 3 里给出了这个模型的详细数值分析结果。\n简单来说这篇文章是这个新模型的一个部分，用来验证渐进行为的。大佬就是牛逼，全部自引用，大概看了一下，这篇文章是篇幅最短的，还好我从这儿看的（逃）\n文献模型 总能量 $$ \\begin{align*} \\mathcal{F} \u0026= \\int_V \\mathcal{L}(\\mathbf{\\phi},\\mathbf{\\nabla}\\mathbf{\\phi})\\,\\mathrm{d}V \\\\ \u0026= \\int_V \\sum_{\\beta=1}^N \\sum_{\\alpha=1}^\\beta [ 36 \\varepsilon_{\\alpha\\beta}\\,{\\gamma}_{\\alpha\\beta}^2(\\mathbf{r}_{\\alpha\\beta})+ \\frac{1}{4 \\varepsilon_{\\alpha\\beta}} \\, g_{\\alpha\\beta}(\\mathbf{\\phi})] \\\\ \u0026\\qquad\\qquad + \\sum_{\\alpha=1}^N h_{\\alpha}(\\mathbf{\\phi},T)\\,\\mathrm{d}V , \\end{align*} $$$\\mathbf{\\phi}$ 自然是序参量，希腊字母 $\\alpha,\\beta$ 代表的某个相，一共有 $N$ 个相；$\\mathcal{L}$ 是拉格朗日密度（？应该是能量密度，但是用的拉格朗日数乘过）；$\\varepsilon_{\\alpha\\beta}$ 与相互作用体势 $g_{\\alpha\\beta}(\\mathbf{\\phi})$ 的势垒高度成正比；$h_{\\alpha}(\\mathbf{\\phi},T)$ 代表了体相自由能密度以及它对热力学平衡态的偏移程度；$\\gamma_{\\alpha\\beta}$ 应该是界面能的各向异性参数，应该是假定的与方向矢量 $\\mathbf{r}_{\\alpha\\beta} = \\phi_\\alpha \\mathbf{\\nabla} \\phi_\\beta - \\phi_\\beta \\mathbf{\\nabla}\\phi_\\alpha$ 有关。\n序参量演化方程 $$ \\begin{align*} \\frac{\\partial\\phi_\\mu}{\\partial t} \u0026= -M\\frac{\\delta\\mathcal{F}}{\\delta\\phi_\\mu} = M\\left\\{\\mathbf{\\nabla}\\!\\left(\\frac{\\partial\\mathcal{L}}{\\partial\\mathbf{\\nabla}\\phi_\\mu}\\right)-\\frac{\\partial\\mathcal{L}}{\\partial\\phi_\\mu}\\right\\}, \\end{align*} $$这里用的是非常经典的 Allen-Cahn 方程。注意这里 Nestler 提到这个方程一定是要假设总能 $\\mathcal{F}$ 是随着时间单调下降的。由于移动性参数 $M$ 一般是各向异性的所以 $M= M(\\mathbf{\\nabla} \\mathbf{\\phi})$；\n广义 Cahn-Hoffman $\\xi$-向量 $$\\begin{align*} \\xi_{\\alpha\\beta}(\\mathrm{r}_{\\alpha\\beta}) \u0026= \\frac{\\partial \\gamma_{\\alpha\\beta}(\\mathbf{r}_{\\alpha\\beta})}{\\partial \\mathbf{r}_{\\alpha\\beta}} \\;=\\; \\mathbf{\\nabla}_{\\mathbf{r}_{\\alpha\\beta}} \\gamma_{\\alpha\\beta}(\\mathbf{r}_{\\alpha\\beta}) \\end{align*}$$这个向量在文献 1 中出现过，暂时不知道是干嘛的，看起来应该是各向异性的梯度。\n结果带入与讨论 将以上结果带入就有：\n$$ \\begin{align*} \\frac{1}{M}\\frac{\\partial\\phi_{\\mu}}{\\partial t} \u0026= \\sum_{\\alpha\\neq\\mu}^{N}\\Bigg[2\\varepsilon_{\\alpha\\mu}\\mathbf{\\nabla}\\!\\cdot\\!\\big(\\gamma_{\\alpha\\mu}\\boldsymbol{\\xi}_{\\alpha\\mu}\\phi_{\\alpha}\\big) +\\gamma_{\\alpha\\mu}\\boldsymbol{\\xi}_{\\alpha\\mu}\\!\\cdot\\!\\mathbf{\\nabla}\\phi_{\\alpha} -\\frac{1}{4\\varepsilon_{\\alpha\\mu}}\\frac{\\partial g_{\\alpha\\mu}}{\\partial\\phi_{\\mu}}\\Bigg] -\\frac{\\partial h_{\\mu}}{\\partial\\phi_{\\mu}}-\\lambda \\end{align*} $$（不得不佩服老前辈的数学功底，这公式，我实在懒得带进去算，改天吧）\n$\\lambda$ 自然是拉格朗日乘数，用来保证一点上所有相的序参量是归一的；1 和 2 中指出，这里可以让界面能 $\\varepsilon_{\\alpha\\beta}$ 趋于零来渐进到窄界面情况，从而得到各个界面上的 Gibbs-Thomson-Herring 方程。\n无散应力张量 $\\Xi$ 这里为了进一步研究多相交接点处的 leading order force balance （我猜是让主导地位的力保持平衡），搞出来了无散应力张量 $\\Xi$：\n$$\\Xi = \\sum_{\\mu=1}^{N} \\mathbf{\\nabla}_{\\phi_{\\mu}}\\phi_{\\mu}\\otimes\\frac{\\partial\\mathcal{L}}{\\partial\\mathbf{\\nabla}\\phi_{\\mu}}-\\mathcal{L}\\,I$$这里由于它无散，要求 $\\mathbf{\\nabla}\\cdot \\Xi = 0$（好复杂，看不懂）。把这个定义放到上面的多相场模型里，然后用散度定理**（应该就是散度换闭合曲面积分）**，取窄界面极限并计算拉格朗日量密度到主导项，**就得到了（？）**多相点的力学平衡方程：\n$$\\sum_{\\mu=0}^{m-1}\\mathbf{F}_{\\mu}=\\mathbf{l}\\times\\sum_{\\mu=0}^{m-1}\\,\\xi_{\\mu\\mu+1}=0,$$这里的 $\\mathbf{F}_{\\mu}$ 就是 $\\mu$ 和 $\\mu+1$ 面之间的总力，向量 $\\mathbf{l}$ 是与多相点平行的单位向量。假如把这个公式用球坐标写出来，可以看作杨氏定律结果再加上面自由能各向异性造成的额外剪切应力。\n数值解法 他们采用了有限差分以及所谓的 Neumann boundary conditions，应该就是周期边界，具体信息可以参考 3。\n能量选择 他们测试了一系列的体自由能 $g_{\\alpha\\beta}(\\mathbf{\\phi})$，最后选择了这个：\n$$ \\begin{align*} g(\\mathbf{\\phi}) \u0026= \\sum_{\\alpha,\\beta} g_{\\alpha\\beta}(\\mathbf{\\phi}) + \\sum_{\\alpha,\\beta,\\mu} g_{\\alpha\\beta\\mu}(\\mathbf{\\phi})\\\\ \u0026= \\sum_{\\alpha,\\beta} \\phi_{\\alpha}\\phi_{\\beta} + \\sum_{\\alpha,\\beta,\\mu} \\delta_{\\alpha\\beta\\mu}\\,\\phi_{\\alpha}\\phi_{\\beta}\\phi_{\\mu} \\end{align*} $$其中的 $\\delta_{\\alpha\\beta\\mu}$ 的选择是让沿 Gibbs 单纯形，连接两个 Gibbs 单纯形的曲线最短，这点保证了两相区没有第三相的出现。（啥是吉布斯单纯形？）\n计算方法和模拟结果 为了数值求解双势阱方程，他们先把微分方程光滑的部分解出来，然后在投影回 Gibbs 单纯形上 （？什么鬼）。接下来就是模拟结果，大概就是在三相界面里，如果两个界面能相等而一个和它们不一样，在自由能驱动下界面能相等的两个界面会变“平”而不太一样的那个会变成垂直的；另外，在晶粒生长的模拟过程中，这个模型也是符合冯诺依曼定律的：von Neumann 定律要求接邻晶粒数量小于六个的晶粒会缩小，而接邻数量大于六的则会生长。另外，界面能相等的三个相会最终形成 120° 夹角的界面，而不形成这个三相夹角的相会早早消失掉。\n总结 非常吓人。大佬的数学和物理背景果然不是盖的，公式我是看不懂一点，好在结论足够清晰易懂，这也许就是所谓的深入浅出吧。\n从文章中发现需要深入的点有：\n啥是吉布斯单纯形？什么光滑求解再投影…… 那个神奇的 Gibbs-Hoffman $\\xi$ 向量到底是什么？从后面的应力平衡公式里大概能看出它应该具有力学的含义； 那个无散应力张量是个什么东西？从张量积的形式以及减去了貌似乘了单位阵的东西来看，也许就是减去了那个静水压力张量的公式？值得进一步看看怎么回事； 自由能的构造应该再从一些老文献里查查，这些数字都是咋来的； 怎么带入的那个 $\\xi$ 向量？这个应该是很关键的步骤，以及它怎么通过界面能趋零来得到后面的结论的。界面能趋零之后应该剩下插值函数、拉格朗日乘数两个东西才对。 各向异性为什么要平方？也许需要从其他地方找到答案…… 总的来说就是继续读他 1998 年的这篇文章1，并且手动带入一下看看情况。\n当然也有收获：\n原来大于六就生长小于六就消失是冯诺依曼先发现的？神奇。 了解了界面能对模型的影响，以及 Nestler 这个（界面能）模型的优势，第三个相不会出现在两相界面处，这一点还是很不错的，数值稳定性好 领略了大佬的风采吧，太牛逼了…… 下次看看别的方向，看看力学方向微弹性力学的 Khachaturyan（这人名字真难记）发表的论文。\nB. Nestler, A.A. Wheeler, Phys. Rev. E 57 (3) (1998) 2602.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nH. Garcke, B. Nestler, B. Stoth, Physica D 115 (1998) 87.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nH. Garcke, B. Nestler, B. Stoth, SIAM J. Appl. Math. 60 (1) (1999) 295\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-10-22T18:25:19+08:00","image":"/images/Nev_Alice.jpg","permalink":"/zh/posts/pf_papers/nestler1999/","title":"文献阅读 - Nestler1999"},{"content":"书接上回，在搞清楚线性空间大概是个什么情况之后，我们来看看线性空间之间都有什么样子的联系吧~！\n头图信息请参考上一节，谢谢~ 选曲为 茶太 和 nayuta 合唱的 お茶ガール，青春阳光有活力，陪伴了我大二啃线性代数的时光……\n$$ % ===== ===== \\gdef \\vect #1{\\mathbf{#1}} % abstract vector \\gdef \\cvect #1{\\boldsymbol{#1}} \\gdef \\basis #1#2{\\mathcal{#1}_{#2}} % basis of vector space \\gdef \\basev #1#2#3{\\{\\vect{#1}_{#2}\\}_{#2=1}^{#3}} % base vector collection \\gdef \\cbasev #1#2#3{\\{\\cvect{#1}^{#2}\\}_{#2=1}^{#3}} % dual basis e^i \\gdef \\vrep #1#2{[\\vect{#1}]_{#2}} % coordinate representation [v]_B \\gdef \\rep #1{[\\vect{#1}]} \\gdef \\mrep #1#2#3{[{#1}]_{#2}^{#3}} % representation [L]_{C,B} % \\gdef \\iprod #1#2{\\langle #1, #2 \\rangle} % inner product \\gdef \\mat #1{\\mathbf{#1}} % matrix (representation) \\gdef \\field #1{\\mathbb{#1}} % \\gdef \\xto #1{\\xrightarrow{#1}} % arrow with label \\gdef \\xfrom #1{\\xleftarrow{#1}} % left arrow with label \\gdef \\Hom {\\operatorname{Hom}} % morphisms between A and B \\gdef \\Iso {\\operatorname{Iso}} \\gdef \\End {\\operatorname{End}} % \\gdef \\Aut {\\operatorname{Aut}} % \\gdef \\cat #1{\\mathsf{#1}} % category symbol: e.g., \\cat{Vect}, \\cat{Set} \\gdef \\Mat {\\operatorname{Mat}} \\gdef \\t {^{\\mathsf{T}}} \\gdef \\id {\\mat{I}} % identity matrix \\gdef \\R {\\field{R}} % \\gdef \\C {\\field{C}} % \\gdef \\ot {\\otimes} % tensor product symbol \\gdef \\zero {\\vect{0}} % \\gdef \\one {\\vect{1}} % \\gdef \\idop {\\mathrm{id}} % identity morphism \\gdef \\comp {\\circ} % composition symbol \\gdef \\Set {\\cat{Set}} % category of sets \\gdef \\Vectk {\\cat{Vect}_{\\field{k}}} % category of vector spaces \\gdef \\Vect {\\cat{Vect}} % % \\gdef \\BaseB {\\basis{B}{}} \\gdef \\BaseC {\\basis{C}{}} \\gdef \\BaseBV {\\basis{B}{V}} \\gdef \\BaseCW {\\basis{C}{W}} \\gdef \\BaseE {\\basis{E}{}} $$前言 在上一章我们讨论完线性空间的基本情况后，一个自然的问题在于怎么研究线性空间之间的映射。我们已经拥有了那些定义，但是具体地，一个向量会怎么通过线性映射得到另一个空间中的向量的方式，我们暂时还没有结论。所幸，线性空间的基给了我们一些指引，借助它，我们可以把线性空间中的几乎所有线性的对象全部表达为 矩阵 的形式。另外，线性空间之间的线性映射全体，本身也有极为特殊的性质，甚至这样的性质会给我们很多的启发，而其中的 对偶空间 更是后续内容所依赖的重要概念。本章我们就讨论线性映射、线性映射全体、矩阵和线性映射的关系，以及所谓的对偶。\n矩阵和矩阵空间 我们先来看看矩阵的基本情况吧：\n矩阵到底是什么？ 在我们不给矩阵赋予任何的数学意义前，它其实就是一个普通的 二维数表。我们可以从一个数域 $\\Bbbk$ 中取出一些数字，把它们排列成矩形，让它每行有相同数量的数，每列也有相同数量的数。这样的数表我们就称之为矩阵，具体地说，是定义在 $\\Bbbk$ 上的一个矩阵。如果一个矩阵有 $m$ 行 $n$ 列，我们就称这个矩阵为 $m\\times n$ 型矩阵。\n我们把它的第 $i$ 行第 $j$ 列的元素简称为它的第 $(i,j)$ 个元素（矩阵元），记为 $[\\mat{A}]^i{}_j = A^i{}_j$。它是 $\\Bbbk$ 中的一个元素（数字）。\n我们给所有 $\\Bbbk$ 上定义的 $m\\times n$ 型矩阵的集合记为 $\\Mat(m,n,\\Bbbk)$，在我们确定好数域时我们常常省略数域的标记，记作 $\\Mat(m,n)$。我们后续考虑的矩阵，没有特殊提及时都认为是 $\\R$ 上的。\n由于 $\\Mat(m,n)$ 中的每个矩阵的每一个位置上的元素都是 $\\R$ 上的数字，我们自然会考虑这样一件事：我们能不能给 $\\Mat(m,n)$ 定义一些代数结构，让他有特别的性质？这里不卖关子了，没错，我们可以给它赋予加法与数乘，让它成为一个线性空间。\n线性空间 $\\operatorname{Mat}(m,n)$ 我们给出 $m\\times n$ 矩阵全体 $\\Mat(m,n)$ 成为线性空间所需要的加法与数乘的定义。\n[!THM]{矩阵空间}\n在 $m\\times n$ 矩阵全体 $\\Mat(m,n)$ 这个集合上定义加法和数乘运算如下：\n$$\\begin{align*} +\u0026\\vcentcolon \\Mat(m,n)\\times \\Mat(m,n)\\to \\Mat(m,n)\\\\ \u0026\\quad [\\mat{A}+\\mat{B}]^i{}_j = A^i{}_j+B^i{}_j\\ \\ \\forall \\mat{A},\\mat{B}\\in \\Mat(m,n); \\\\[1.5ex] \\cdot\u0026\\vcentcolon \\R\\times\\Mat(m,n)\\to\\Mat(m,n)\\\\ \u0026\\quad [r\\cdot\\mat{A}]^i{}_j = rA^i{}_j \\ \\forall r\\in \\R, \\mat{A}\\in \\Mat(m,n). \\end{align*} $$在具备这两个运算后，$\\Mat(m,n)$ 成为线性空间，我们称其为矩阵空间。\n关于它是如何满足线性空间的八条公理的，我们这里不做完整叙述了，因为关于它的运算的性质实在是太简单了：就是 $\\R$ 上的运算，批量地搬到了 $m\\times n$ 的数表上而已。值得注意的是它的零向量被称为零矩阵 $\\zero$，即所有的元素都是 $0$ 的矩阵。\n我们自然很好奇，它作为线性空间，维数是多少？我们能给它选择怎样的一组基？这个问题也不难，因为我们立刻就能构造出一套基，它一共有 $mn$ 个矩阵，每个矩阵都只在一个位置上为 $1$，在其余的所有位置都是 $0$。这些矩阵之间是线性无关的甚至是非常显然的结论。由此，矩阵空间 $\\Mat(m,n)$ 的维数为 $\\dim \\Mat(m,n) = mn$。\n可以看到，同型矩阵集合能称为线性空间这件事还挺显然的，加法和数乘也都比较简单。它是一个很好的例子，我们后面会经常需要把一些奇怪的空间化归到矩阵空间上，来研究它们的性质。那么，矩阵就仅此而已了吗？\n线性映射与矩阵 矩阵当然不是这么单纯的一个东西，我们完全可以给矩阵赋予更丰富的含义。当我们将 矩阵 与 线性映射 结合起来的时候，它就具有了丰富的内涵。不过在此之前，我们先来研究一个向量经过线性映射后具体是怎么得到它的像的。\n线性映射的两种路径 我们考虑这样的两个线性空间，一个 $n$ 维线性空间 $V$ 和一个 $m$ 维线性空间 $W$，并考虑它们之间的一个线性映射 $L$。此时，设有向量 $\\vect{v}\\in V$，把它映射到 $W$ 中的向量 $\\vect{w}$ 就有两种路径：\n将整个向量直接放入线性映射 $L$ 下，得到 $W$ 中的向量：$L(\\vect{v}) = \\vect{w}$； 将这个过程通过线性映射的特点拆分为下面的步骤： 先将 $\\vect{v}$ 表达为 $V$ 中的基 $\\basis{B}{V} = \\basev{b}{j}{n}$ 的基向量的线性组合： $$\\sum_j^n v^j \\vect{b}_{j}$$ 借助线性映射的定义，有 $$L(\\vect{v}) = L(\\sum_j^n v^j \\vect{b}_j) = \\sum_j^n v^j L(\\vect{b}_j)$$ 将 $V$ 中的基向量映射到 $W$ 中后得到新向量： $$L(\\vect{b}_j) = \\vect{f}_{j} \\in W$$ 将得到的新向量 $\\vect{f}_{j}$ 表达为 $W$ 中的基向量 $\\basis{C}{W} = \\vect{c}_{i}$ 的线性组合： $$\\vect{f}_{j} = \\sum_i^m A^i{}_j\\vect{c}_{i}$$ 将上面的结果依次带回，得到： $$L(\\vect{v}) = \\sum_j^n v^j L(\\vect{b}_j) = \\sum_j^n v^j \\vect{f}_{j} = \\sum_j^n v^j \\sum_i^m A^i{}_j\\vect{c}_{i}$$ 经过整理，我们有 $$L(\\vect{v}) = = \\sum_j^n\\sum_i^m v^jA^i{}_j\\vect{c}_{i} = \\sum_i^m (\\sum_j^n A^i{}_jv^j)\\vect{c}_{i} = \\sum_i^m w^i \\vect{c}_{i} = \\vect{w}$$ 对比上下两种路径，我们发现：通过将向量表达为基的线性表示，我们将向量在线性映射下的像的问题，转换为了两种系数之间的运算：\n$$w^i = \\sum_j^n A^i{}_jv^j$$一个是 $\\vect{v}$ 在 $V$ 中基的组合系数 $v^j$，另一个则是 $\\vect{b}_j$ 在经过 $L$ 映射后在 $W$ 中基的组合系数。前者是我们一定能得到的，因为 $V$ 中有基则所有向量都可以被基线性表出，这恰好说明，$L$ 的所有性质全部依赖于后者，我们需要知道 $\\vect{b}_j$ 被映射后，在 $W$ 中的基被表达为了什么样的线性组合。\n由于 $V$ 有 $n$ 个基向量，每个基向量在 $W$ 的基下都被表达为 $m$ 个向量的线性组合，所以每个基向量都有 $m$ 个线性组合系数，则这个过程一共要确定 $mn$ 个线性组合系数，这也是 $A^i{}_j$ 这个记号的（一部分）由来。注意到我们使用了一个上标和一个下标来表示这个线性映射，其下标 $j$ 说明了 $\\BaseBV$ 中的第 $j$ 个基向量被映射过来，其上标 $i$ 则说明了被映射过来的新向量 $\\vect{f}_j$ 在基 $\\BaseCW$ 下表达后的第 $i$ 个分量。我们将两个指标区分出顺序，用来方便后续的处理。\n然而，$A^i{}_j$ 不是我们给矩阵的记号吗？竟能，如此相像？那为何不直接把一个线性映射在左右两端选好基之后表达为一个矩阵呢？现在我们给矩阵赋予线性映射下的意义。\n[!REM]{矩阵的线性映射意义}\n设有 $n$ 维线性空间 $V$ 和 $m$ 维线性空间 $W$，二者之间的线性映射 $L\\vcentcolon V\\to W$ 可以在 $V$ 的一组基 $\\basis{B}{V}$ 和 $W$ 的一组基 $\\basis{C}{W}$ 下表达为一个矩阵，我们将它记作 $\\mat{A} = \\mrep{L}{\\BaseBV}{\\BaseCW} = \\mrep{L}{\\BaseB}{\\BaseC}$，这个矩阵有 $m$ 行 $n$ 列，即 $\\mat{A}\\in\\Mat(m,n)$。该矩阵第 $(i,j)$ 个元素 $A^i{}_j$ 的含义是 $V$ 中第 $j$ 个基向量在经过线性映射 $L$ 后，它在 $W$ 的第 $i$ 个基向量上的分量。\n有了这样的表达方式后，我们能做一些什么呢？我们可以尝试计算两个线性映射的复合的矩阵表达。我们知道，线性映射的复合一定还是线性映射，而它也一定可以表达为一个矩阵。既然如此，我们就可以把两个矩阵的元素拿出来经过某种运算，得到新的矩阵，用来表达两个映射的复合。我们来看看这个过程。\n线性映射复合与矩阵 假设我们有线性空间 $U,V,W$，它们的维数分别为 $m,n,p$，且有 $T\\vcentcolon U\\to V$ 和 $S\\vcentcolon V\\to W$，则线性映射的复合 $S\\circ T = R$ 就是一个由 $U$ 到 $W$ 的线性映射。我们分别给 $U,V,W$ 选择基 $\\BaseB$，$\\BaseC$ 和 $\\basis{D}{}$，那么 $T$，$S$ 和 $R$ 就可以被表达为 $\\mat{A} = \\mrep{T}{\\BaseB}{\\BaseC}$，$\\mat{B} = \\mrep{S}{\\BaseC}{\\basis{D}{}}$ 和 $\\mat{C} = \\mrep{R}{\\BaseB}{\\basis{D}{}}$。\n现在取 $U$ 中的一个向量 $\\vect{u} = \\sum_i^m u^i \\vect{b}_i$，根据我们上面的步骤，就有：\n$$T(\\vect{u}) = \\sum_k^n \\sum_j^m A^k{}_j u^j \\vect{c}_k = \\sum_k^n v^k \\vect{c}_k = \\vect{v};$$ $$S(\\vect{v}) = \\sum_i^p \\sum_k^n B^i{}_k v^k \\vect{d}_i = \\sum_i^p w^i \\vect{d}_i = \\vect{w},$$把底下的结果带入上面，就得到了映射复合的结果：\n$$S(\\vect{v}) = S(T(\\vect{u})) = \\sum_i^p \\sum_k^n B^i{}_k v^k \\vect{d}_i = \\sum_i^p \\sum_k^n B^i{}_k \\sum_j^m A^k{}_j u^j \\vect{d}_i;$$而如果我们直接用 $R$ 来作用到 $\\vect{u}$ 上，可以得到：\n$$R(\\vect{u}) = \\sum_i^p \\sum_j^m C^i{}_j u^j \\vect{d}_i,$$比较一下，我们得到这样的结果：\n$$C^i{}_j = \\sum_k^n B^i{}_k A^k{}_j.$$所以，$R$ 的矩阵表达 $\\mat{C}$ 完全可以由 $\\mat{A}$ 和 $\\mat{B}$ 确定！我们把这种操作称为所谓 矩阵的乘法，而如此一来，我们就将线性映射的复合通过它们的对应的矩阵的乘法表达出来了。\n另外，上面的例子中，$\\mat{B}$ 是 $p\\times n$ 型矩阵，而 $\\mat{A}$ 是 $n\\times m$型矩阵，得到的结果 $\\mat{C}$ 是一个 $p\\times m$ 型矩阵。由此我们得知，只有第一个矩阵的行数等于第二个矩阵的列数的时候，才能让这个乘法成立。我们下面总结如下：\n[!REM]{矩阵乘法}\n两个矩阵 $\\mat{A}$ 和 $\\mat{B}$ 若分别为 $m\\times n$ 和 $n\\times p$ 型矩阵，则它们的乘积 $\\mat{AB}$ 为一个 $m\\times p$ 型矩阵。这个过程可以解释为线性映射的复合，其中：\n$\\mat{B}$ 代表一个 $p$ 维到 $n$ 维线性空间的线性映射； $\\mat{A}$ 代表一个 $n$ 维到 $m$ 维线性空间的线性映射； 它们的乘积 $\\mat{AB}$ 则代表它们的复合，一个从 $p$ 维到 $n$ 维线性空间的线性映射。 线性映射引出的结构 上面我们给矩阵赋予了线性映射的意义。只要在给线性空间选定基之后，它们之间的所有线性映射都是可以唯一地表达为一个矩阵的。然而我们已经知道了，矩阵空间 $\\Mat(m,n)$ 是一个线性空间，那么它能对应的 $n$ 维线性空间 $V$ 到 $m$ 维线性空间 $W$ 之间的线性映射全体 $\\Hom(V,W)$，是否也是一个线性空间呢？\n没错，它也是能在合理的加法和数乘下成为线性空间的。\n线性空间 $\\operatorname{Hom}(V,W)$ [!THM]{线性空间 $\\operatorname{Hom}(V,W)$}\n集合 $\\Hom(V,W)$ 上可以定义加法与数乘运算如下：\n$$\\begin{align*} +\u0026\\vcentcolon \\Hom(V,W)\\times \\Hom(V,W)\\to \\Hom(V,W)\\\\ \u0026\\quad (R+S)(\\vect{v}) = R(\\vect{v}) + S(\\vect{v})\\ \\ \\forall R, S\\in \\Hom(V,W),\\vect{v}\\in V; \\\\[1.5ex] \\cdot\u0026\\vcentcolon \\R\\times\\Hom(V,W)\\to\\Hom(V,W)\\\\ \u0026\\quad (r\\cdot L)(\\vect{v}) = r\\cdot L(\\vect{v})= L(r\\cdot \\vect{v}) \\ \\forall r\\in \\R, L\\in \\Hom(V,W),\\vect{v}\\in V. \\end{align*} $$在定义这两个运算后，$\\Hom(V,W)$ 成为线性空间。\n我们可以看到，上面的加法定义和数乘定义都是非常自然的：两个映射相加等于把它们每个点上的值都加起来作为这个点上的新值；映射与数的点乘就是把每个点的值都做个数乘。这个操作能实现也依赖了 $W$，映射的陪域，也是个线性空间的缘故。我们称这样定义运算的方法为所谓的 逐点定义。\n然而，我们上面只是声明了它是线性空间。它真的是吗？它满足线性空间的八条公理吗？我们需要证明一下。\n[!PROOF]{集合 $\\operatorname{Hom}(V,W)$ 是线性空间}\n要证明它是线性空间，我们需要验证它满足线性空间的八条公理。\n存在加法单位元 $O$，将任何实数都恒等映射到 $\\zero_W$ 上：$O(\\vect{v}) = \\zero_W$ 对于任意的 $\\vect{v}\\in V$ 都成立。如此，有 $$\\begin{align*}(O+L)(\\vect{v}) \u0026= O(\\vect{v}) + L(\\vect{v}) \\\\ \u0026= L(\\vect{v}) = L(\\vect{v}) + O(\\vect{v})\\\\ \u0026= (L+O)(\\vect{v})\\end{align*}$$ 对于任意的 $\\vect{v}\\in V$ 和 $L\\in\\Hom(V,W)$ 都成立；\n任意元素都有逆元。对任意的 $S\\in \\Hom(V,W)$ ，定义线性映射 $T\\in\\Hom(V,W)$，对任意 $\\vect{v}\\in V$ 都有 $T(\\vect{v}) = S(-\\vect{v})$。由线性映射的性质，$T(\\vect{v}) = -S(\\vect{v})$，则对任意 $\\vect{v}\\in V$，都有 $$\\begin{align*}(T+S)(\\vect{v}) \u0026= T(\\vect{v}) + S(\\vect{v})\\\\ \u0026= -S(\\vect{v})+S(\\vect{v}) = \\zero_W = S(\\vect{v}) + T(\\vect{v})\\\\ \u0026=(S+T)(\\vect{v})\\end{align*}$$ 成立。则 $T$ 确为 $S$ 的逆元；\n加法有结合律和交换律。对任意 $R,S,T\\in\\Hom(V,W)$，任取 $\\vect{v}\\in V$，则有 $$\\begin{align*}(R+(S+T))(\\vect{v}) \u0026= R(\\vect{v})+(S+T)(\\vect{v})\\\\ \u0026 = R(\\vect{v})+S(\\vect{v})+T(\\vect{v})= (R+S)(\\vect{v})+T(\\vect{v}) \\\\ \u0026= ((R+S)+T)(\\vect{v}) \\\\ \u0026= S(\\vect{v})+T(\\vect{v})+R(\\vect{v})\\\\ \u0026 = ((S+T)+R)(\\vect{v})\\end{align*}$$ 始终成立；\n取 $\\R$ 中单位元 $1$，有 $$(1\\cdot L)(\\vect{v}) = 1\\cdot_W L(\\vect{v}) =L(1\\cdot_V\\vect{v})= L(\\vect{v})$$ 对任何 $L\\in\\Hom(V,W),\\vect{v}\\in V$都成立，则数乘确有单位元；\n数乘对向量加法有分配率：对任意 $r\\in\\R, S, T\\in\\Hom(V,W)$ 和 $\\vect{v}\\in V$，下式始终成立：\n$$\\begin{align*}(r\\cdot( S+ T))(\\vect{v}) \u0026= ( S+ T)(r\\cdot\\vect{v})= S(r\\cdot\\vect{v})+ T(r\\cdot\\vect{v}) \\\\ \u0026= r\\cdot S(\\vect{v})+r\\cdot T(\\vect{v}) = (r\\cdot S)(\\vect{v})+(r\\cdot T)(\\vect{v})\\\\ \u0026= (r\\cdot( S+ T))(\\vect{v});\\end{align*}$$ 数量加法对数乘有分配率：对任意 $r,s\\in\\R, S\\in\\Hom(V,W)$ 和 $\\vect{v}\\in V$，下式始终成立：\n$$\\begin{align*}((r+s)\\cdot S)(\\vect{v}) \u0026= S((r+s)\\cdot\\vect{v})= S(r\\cdot\\vect{v}+s\\cdot\\vect{v}) \\\\ \u0026= r\\cdot S(\\vect{v})+s\\cdot S(\\vect{v}) = (r\\cdot S)(\\vect{v})+(s\\cdot S)(\\vect{v})\\\\ \u0026= (r\\cdot S+s\\cdot S)(\\vect{v});\\end{align*}$$ 数量乘法有交换律：对任意 $r,s\\in\\R, S\\in\\Hom(V,W)$ 和 $\\vect{v}\\in V$，下式始终成立：\n$$((rs)\\cdot S)(\\vect{v}) = S((rs)\\cdot\\vect{v}) = S((sr)\\cdot\\vect{v}) = ((sr)\\cdot S)(\\vect{v}).$$ 至此，我们验证完毕，$\\Hom(V,W)$ 的确在上述加法和数乘下成为一个向量空间。\n这个证明真是好长，但是实际上却又真的只是把所有的公理都按照定义和线性映射的性质验证一遍，唉，真是好累呀。后面再不证明这么繁琐的东西了，除非是一个很特殊的线性空间，它不那么显然地满足公理。\n那么现在，我们知道 $\\Mat(m,n)$，所有 $m\\times n$ 型矩阵的集合，它是一个 $mn$ 维线性空间；$\\Hom(V,W)$，所有从线性空间 $V$ 到 $W$ 的线性映射的集合，也是一个线性空间。而一个 $m\\times n$ 型矩阵可以被解释为一个 $n$ 维线性空间到 $m$ 维线性空间的一个线性映射（在给两边选定基后）。它们之间一定有某种特殊的联系！说不定，如果 $\\dim V = n, \\dim V = m$ 的话，$\\Mat(m,n)$ 和 $\\Hom(V,W)$ 是同构的！\n我们要怎么确认这个事呢？我们先来看两个特殊的例子：$\\Hom(\\R,V)$ 和 $\\Hom(V,\\R)$。\n$\\operatorname{Hom}(\\R,V)$ 与 $V$ 我们考虑 $\\Hom(\\R,V)$ 中的一个线性映射 $L$，根据线性映射和矩阵的关系，它在 $\\R$ 和 $V$ 的基下的表示矩阵为 $\\mat{A} = \\mrep{L}{\\BaseE}{\\BaseBV}$。根据我们上面的记号，我们知道这个线性映射的矩阵表示就有 $n$ 行，但是它只有 $1$ 列。\n我们先来看看 $\\R$，从上一章的讨论中我们已经知道它自身就是一个线性空间，它的典范基就是 $1$。再考虑这个线性映射 $L$，它 把一个向量唯一确定地映射到另一个空间中的一个向量，且保持线性。也就是说，这个线性映射把 $\\R$ 中的 $0$ 映射到 $V$ 中的 $\\zero$ 上，且每个数字都对应一个 $V$ 中的向量。\n由于我们给它们两个线性空间都选择了基，由上一章的内容，我们可以考虑它们作为自由线性空间：$\\{f\\ \\vert\\ f\\vcentcolon\\{1\\}\\to\\R\\}$ 和 $\\{g\\vert\\ g\\vcentcolon\\BaseBV\\to \\R\\}$，即它们俩都唯一地由它们的基 $\\BaseE = \\{1\\}$ 和 $\\BaseBV$ 确定，而这两个线性空间之间的映射，就可以看成是这样的过程：从 $V$ 里选择一个向量，让它对应到 $\\R$ 上的基 $\\{1\\}$ 上，这一点也能从它表示出来的矩阵的第 $(i,1)$ 个矩阵元的含义看出：它代表了从线性空间 $\\R$ 的基 $1$ 映射到 $V$ 之后，在 $V$ 的第 $i$ 个基上的分量。\n发现了吗？一个这样的线性映射，实际上就唯一地对应了一个 $V$ 上的向量。那么？所有这样的线性映射的集合呢？没错，就是 $V$ 本身了。这意味着，一个从 $\\R$ 到 $n$ 维线性空间 $V$ 的线性映射，实际上 就是 $V$ 中的一个向量！这也不难理解，为什么我们使用所谓的 列向量，或者一些地方所说的，列矩阵，来表示一个向量了。\n列向量 与 $\\R\\to V$ ：自然同构 事实上，我们可以给 $\\Hom(\\R,V)$ 和 $V$ 之间定义一个 自然同构：\n$$ \\eta\\vcentcolon\\Hom(\\R,V)\\to V,\\quad f \\mapsto f(1)\\ \\ \\forall f\\in\\Hom(\\R,V),$$其中 $1$ 就是我们熟悉的那个数字 $1$。也就是说，只需要我们让 $\\Hom(\\R,V)$ 中的每个映射都取到 $1$，我们就能得到 $V$ 上对应的向量。这个同构之所以是同构，是因为我们可以立刻定义它的逆映射：\n$$ \\phi\\vcentcolon V\\to\\Hom(\\R,V),\\quad (\\phi(\\vect{v}))(r) = r\\cdot\\vect{v}\\ \\ \\forall\\vect{v}\\in V, r\\in\\R, $$即我们把一个向量映射到一个从 $\\R$ 到 $V$ 的线性映射上。这个线性映射取 $\\R$ 中的一个数字后就把它数乘到这个向量上。可以证明这个东西是同构：\n[!PROOF]{映射 $\\eta$ 是线性空间同构}\n由于 $\\eta$ 两边都是线性空间，且我们已经构造了它的可能逆映射 $\\phi$。要验证同构，我们可以证明 $\\phi$ 确实是它的逆映射，即 $\\phi\\comp\\eta = \\idop_{\\Hom(\\R,V)}$ 和 $\\eta\\comp\\phi = \\idop_{V}$ 都是单位变换：\n对于任意 $f\\in \\Hom(\\R,V)$，对于任意的 $r\\in\\R$ 我们都能让 $f$ 转一圈回到自己，就能证明第一部分了，而我们有： $$ (\\phi\\comp\\eta)(f(r)) = (\\phi\\comp\\eta(f))(r) = \\phi(f(1))(r) = r\\cdot f(1) = f(r);$$ 对于任意的 $\\vect{v}\\in V$，我们有 $$\\eta\\comp\\phi(\\vect{v}) = \\eta(\\phi(\\vect{v})) = \\phi(\\vect{v})(1) = 1\\cdot \\vect{v} = \\vect{v}.$$ 两个式子的最后一个等号都用了线性映射和线性空间的性质，它们之前的等号则都是映射的复合。由此，我们得到它是一个同构。 那么，它为什么叫 自然 同构？这是因为我们再次借用了范畴论的语言，它的定义应该借助所谓的 函子，我们这里就不再展开（因为我是菜鸡）。可以这样理解所谓的 自然：如果这个同态的构造不依赖于任何额外的信息，只从定义域和值域的关于这个范畴的基本信息就可以了，而且这个同态中用到的对象不是特定的，这个范畴中的任何对象都是可以的。这里我们讨论的范畴是线性空间，那么“一个同态是自然的”就是在说，这个映射的构造只需要关于线性空间的基本性质，用来定义它的 $V$ 可以是任何一个合法的线性空间。\n为什么我们强调了 自然？因为当我们把向量表达为 列矩阵 时，通过上一节的描述，我们需要两组基来确定它；但是实际上，我们只需要 $V$ 上的一组基就可以表达了，这和我们对 向量需要一组基来表达为列向量 是相符的。我们不需要另一组 $\\R$ 的基的原因就出自这里的 自然 性了，这样的对应不需要选择特定的基，所以如果我们想表达为列矩阵，我们真的只需要 $V$ 上的基就可以。\n而这，也完美地保证了我们完全可以把向量在线性映射下的像的计算过程，化作矩阵与列向量之间的乘法运算，只需要运用矩阵乘法就可以了。一个运算，两种含义，针不戳！\n对偶空间 $V^*$ 我们再来考察 $\\Hom(V,\\R)$，在这之前，由于 $\\Hom(V,\\R)$ 有特殊的含义，它被定义为 $V$ 的所谓 对偶空间：\n[!DEF]{对偶空间}\n一个线性空间 $V$ 的对偶空间记作 $V^*$，它是通过在集合 $\\Hom(V,\\R)$ 上定义加法和数乘得到的，其加法和数乘分别定义为：\n$$\\begin{align*} +\u0026\\vcentcolon V^*\\times V^*\\to V^*\\\\ \u0026\\quad(\\cvect{\\varphi}+\\cvect{\\psi})(\\vect{v}) = \\cvect{\\varphi}(\\vect{v})+\\cvect{\\psi}(\\vect{v})\\ \\ \\forall \\cvect{\\varphi},\\cvect{\\psi}\\in V^*,\\vect{v}\\in V;\\\\[1.5ex] \\cdot\u0026\\vcentcolon \\R\\times V^*\\to V^*\\\\ \u0026\\quad(r\\cdot\\cvect{\\varphi})(\\vect{v}) = r\\cdot \\cvect{\\varphi}(\\vect{v}) = \\cvect{\\varphi}(r\\cdot\\vect{v})\\ \\ \\forall r\\in \\R, \\cvect{\\varphi}\\in V^*,\\vect{v}\\in V.\\end{align*}$$我们称原空间 $V$ 中的元素为 向量，称 $V^*$ 中的元素为 余向量。\n可以看到，$V^*$ 中的余向量，每一个都是从 $V$ 到 $\\R$ 的线性映射，在给定一个 $V$ 中的向量后它都给出一个实数。有了这样的定义之后，我们继续研究之前的问题：怎么把 $\\Hom(V,\\R)$，即 $V^*$，和 $V$ 以及矩阵表示三者联系起来。\n线性空间与对偶空间 我们还是给先给 $V$ 和 $\\R$ 两边选定它们的基，分别为 $\\BaseBV$ 和 $\\BaseE$，则线性映射 $T\\in V^*$ 可以表达为 $\\mat{B} = \\mrep{T}{\\BaseBV}{\\BaseE}$，这个矩阵它有 $1$ 行 $n$ 列，即所谓的 行矩阵 或者 行向量 了。\n下面我们尝试用之前 自由线性空间 的概念来研究它和 $V$ 之间的关系。照我们之前的做法，两个空间在选择好基之后即唯一确定了，那么它们两之间的映射就可以规约到 $\\BaseBV$ 到 $\\{1\\}$ 的映射。然后我们就可以……\n等一下，从任意集合 $S$ 到 单点集 $\\{\\bullet\\}$ 的映射有且只能有一个，就是恒等映射呀！？自由线性空间的路数看来是不大行了。那么，我们应该怎么考虑它和线性空间 $V$ 之间的关系？\n好在我们还有 $\\cvect{\\varphi}\\in V^* = \\Hom(V,\\R)$ 的矩阵表示。$\\cvect{\\varphi}\\in V^*$ 在表示为一个 $1\\times n$ 型矩阵后，它的第 $(1,j)$ 个矩阵元的含义就是 $V$ 中的第 $j$ 个基向量经过线性映射后在 $\\R$ 中的基 $\\{1\\}$ 的分量，或者说就是第 $j$ 个基向量在经过映射后的值。这说明，这个矩阵的 $n$ 个值对应了 $\\cvect{\\varphi}$ 在 $\\BaseBV$ 上的值。\n于是我们得到了一个三角形的关系：一个对偶空间中的余向量，在作为 $V\\to \\R$ 的线性映射时能唯一确定一个 $1\\times n$ 型矩阵，而这个矩阵中的 $n$ 个矩阵元分别代表了 $V$ 中的基向量在余向量映射下的值。这也就意味着，如果我们能确定 $V$ 中基向量在余向量下的值，我们就能唯一确定一个余向量了。\n然而，把向量表达为基向量的线性组合的时，也有这样的对应关系：在给线性空间选定一组基之后，只需要一组线性表出系数就可以唯一地确定一个向量了。这暗示着我们完全可以就将 $V$ 中的那些基向量在余向量下的值作为这个余向量的线性表出系数。那么问题来了，我们这次竟然是先有了余向量线性表出系数，那这组线性表出系数应该配以什么样的基来表达一个余向量呢？\n我们需要找到 $V^*$ 它的基。\n对偶空间的基 我们就取一个余向量 $\\cvect{\\varphi} \\in V^*$，它有线性表出系数 $\\varphi_1,\\varphi_2,\\dots,\\varphi_n$，我们虽然不知道 $V^*$ 的基具体是什么样的，那么我们就假设这组基为：$\\BaseB^* = \\cbasev{\\beta}{i}{n}$，于是 $\\cvect{\\varphi}$ 就可以表达为线性组合：\n$$\\cvect{\\varphi} = \\sum_i^n \\varphi_i\\cvect{\\beta}^i,$$我们的当务之急是找到 $\\cvect{\\beta}^i$ 的表达式，它也是 $V^*$ 中的一个余向量，即一个从 $V$ 到 $\\R$ 的线性映射。要确定一个映射，最好的方法就是给它喂一个定义域上的值，看看它的结果是什么样的。毫无悬念地，如果我们给它 $\\vect{v}\\in V$，得到的结果是：\n$$\\cvect{\\varphi}(\\vect{v}) = \\sum_i^n \\varphi_i\\cvect{\\beta}^i(\\vect{v}),$$然而 $\\vect{v}$ 也是可以表达为 $V$ 的基 $\\BaseB$ 的线性组合的，我们带入就有：\n$$\\cvect{\\varphi}(\\vect{v}) = \\sum_i^n \\varphi_i \\cvect{\\beta}^i(\\sum_j^n v^j\\vect{b}_j) = \\sum_i^n\\sum_j^n\\varphi_i v^j\\cvect{\\beta}^i(\\vect{b}_j),$$此时我们回忆这些“线性表出系数”的原本意义：$V$ 中的基向量被 $\\cvect{\\varphi}$ 映射后的值，即\n$$\\cvect{\\varphi}(\\vect{v}) = \\sum_i^n\\sum_j^n v^j\\cvect{\\beta}^i(\\vect{b}_j)\\cvect{\\varphi}(\\vect{b}_i),$$我们的 $\\cvect{\\varphi}$ 又一次出现了！更重要的是，$v^i$，$\\cvect{\\beta}^i(\\vect{b}_j)$ 都是 $\\R$ 中的实数，因此根据线性映射的性质，我们有\n$$\\cvect{\\varphi}(\\vect{v}) = \\cvect{\\varphi}(\\sum_j^n v^j \\sum_i^n \\cvect{\\beta}^i(\\vect{b}_j)\\vect{b}_i),$$又因为 $\\vect{v} = \\sum_j^n v^j \\vect{b}_j$，经过 比较，我们得到了：\n$$\\sum_i^n \\cvect{\\beta}^i(\\vect{b}_j)\\vect{b}_i = \\vect{b}_j,$$说明 $\\vect{b}_j$ 是基向量的线性组合。可是它本身就已经是基向量了，如果表示成线性组合，就只能是让对应的第 $j$ 个位置的系数为 $1$，其余全部为 $0$。也就是说：\n$$\\cvect{\\beta}^i(\\vect{b}_j) = \\begin{cases} 1 \u0026\\text{if }\\ i=j\\\\ 0 \u0026\\text{if }\\ i\\neq j\\\\ \\end{cases}$$我们构造出了一组 $V^*$ 的基！吗？\n$\\mathcal{B}^*$ 是基的证明 我们上面的构造有一个巨大的漏洞：$f(a) = f(b)$ 从来都不能说明 $a = b$，必须证明 $f$ 是单射才能有这样的结论。然而，我们没有证明上面的余向量 $\\cvect{\\varphi}$ 是单的。\n其实是能够证明所有 $V^*$ 中的余向量都是单的，可是坏消息是，这在我们现有的工具条件下是没法方便地证明的。难道我们的努力全都木大了吗？也不是。至少，结论是对的，它距离我们可以放心使用，差的其实不是严丝合缝地把它构造出来，而是证明它的确是一组基就行。那么，我们就来尝试证明下面这个命题（定理）吧。\n[!THM]{对偶空间的基}\n设向量空间 $V$ 有一组基 $\\BaseBV = \\basev{b}{i}{n}$，则其对偶空间 $V^*$ 有一组基 $\\cbasev{\\beta}{i}{n}$，定义为\n$$\\cvect{\\beta}^i(\\vect{b}_j) = \\delta^i{}_j,$$其中 $\\delta^i{}_j$ 称为 Kronecker delta（克罗内克 delta），定义为\n$$\\delta^i{}_j = \\begin{cases} 1 \u0026\\text{if }\\ i=j\\\\ 0 \u0026\\text{if }\\ i\\neq j\\\\\\end{cases}.$$ 这里值得注意的是这个 Kronecker delta 符号：\n[!REM]{Kronecker delta 的含义}\n这个符号之所以是被称为“符号”，是因为它的功能太简单，却又可以拥有多种解释。这个符号实际上代表了一个函数，它比较两个正整数值，如果它们相等则输出 $1$，如果不相等则输出 $0$。因此，我们可以说它是一个这样的函数：\n$$ \\delta \\vcentcolon \\field{N}\\times\\field{N}\\to \\R,$$然后用 $\\delta^i{}_j$ 来记录它取 $(i,j)$ 时候的值。然而这样做又有两个小问题：\n首先是，它的值域是否合适。倘若采用 $\\R$，可能又显得太大了；倘若采用 $\\{0,1\\}$，那它就丢失了和别的数相乘相加等算术运算。为了在我们这里能与乘法相容，我们还是采用了 $\\R$ 的定义； 另外就是它的定义域是否合适。这个问题主要源于这个符号实在是太简单了：判断两个东西是否相等。如果相等，那就输出 $1$，否则就输出 $0$。我们完全没必要限制在 $\\field{N}$ 上，而是可以扩充到 $\\field{Z}$，甚至更一般的任意集合上。不过我们这里还是不这么做了：限定在我们需要的指标集 $I = \\field{N}$ 上就可以了。 另外我们注意，在引入 爱因斯坦求和约定 以前，我们完全将 $\\delta^i{}_j$ 看作一个实数。即便它可以被解释为单位矩阵，它也是矩阵中的元素，而非真的是一个矩阵。\n我们接下来证明这个命题。\n[!PROOF]{对偶空间的基}\n为证明向量组 $\\BaseB^* = \\cbasev{\\beta}{i}{n}$ 是线性空间 $V^*$ 的一组基，我们需要证明它是线性无关向量组，以及它可以张成线性空间 $V^*$。\n线性无关的证明\n我们取任意一个向量 $\\vect{v}\\in V$，以及做 $\\BaseB^*$ 的线性组合：\n$$\\cvect{\\varphi}= \\sum_i^n \\varphi_i\\cvect{\\beta}^i,$$ 令其等于 $\\zero_{V^*}$，则有 $$\\begin{align*}\\cvect{\\varphi}(\\vect{v}) \u0026= \\sum_i^n\\varphi_i \\cvect{\\beta}^i(\\vect{v}) \\\\ \u0026= \\sum_i^n\\varphi_i\\sum_j^nv^j\\cvect{\\beta}^i(\\vect{b}_j) = \\sum_i^n\\sum_j^n\\varphi_iv^j\\delta^i{}_j \\\\ \u0026 =\\zero_{V^*}(\\vect{v}) = 0_\\R. \\end{align*}$$ 由 Kronecker delta 的定义，有 $$\\begin{align*}\\sum_i^n\\sum_j^n\\varphi_iv^j\\delta^i{}_j \u0026= \\sum_i^n\\varphi_i(\\sum_j^n v^j \\delta^i{}_j) \\\\ \u0026= \\sum_i^n\\varphi_i (v^1\\delta^i{}_1 + v^2\\delta^i{}_2 + \\dots + v^n\\delta^i{}_n) \\\\ \u0026= \\sum_i^n \\varphi_iv^i = 0_\\R. \\end{align*}$$ 由于上式对任意的向量 $\\vect{v}\\in V$ 都成立，必须有 $\\varphi_i = 0\\quad\\forall 1\\leq i \\leq n$，即向量组 $\\BaseB^*$ 的线性组合为 $\\zero_{V^*}$ 时，它的线性组合系数全为 $0$。这说明 $\\BaseB^*$ 是线性无关的。\n$\\BaseB^*$ 能张成 $V^*$ 的证明\n要证明它能线性张成 $V^*$，则 $V^*$ 中的任意余向量都能被这组向量组唯一地线性表出。我们任取 $\\cvect{\\varphi}\\in V^*$ 以及 $\\vect{v}\\in V$，在取 $V$ 中基 $\\BaseB$，则有 $$\\cvect{\\varphi}(\\vect{v}) = \\sum_i^nv^i\\cvect{\\varphi}(\\vect{b}_i).$$ 我们记 $\\cvect{\\varphi}(\\vect{b}_i) = a_i$，则 $$\\begin{align*} \\cvect{\\varphi}(\\vect{v}) \u0026= \\sum_i^n v^i a_i\\\\ \u0026= \\sum_i^n v^i \\sum_j^n a_j \\delta^j{}_i = \\sum_i^n v^i \\sum_j^n a_j \\cvect{\\beta}^j(\\vect{b}_i) \\\\ \u0026=\\sum_j^n a_j \\cvect{\\beta}^j (\\vect{v}) = (\\sum_i^n a_i \\cvect{\\beta}^i)(\\vect{v}).\\end{align*} $$ 由此我们成功证明了任意的 $\\cvect{\\varphi}\\in V^*$，都可以在我们定义好 $\\BaseB^*$ 后将它表达为一个线性组合。\n至此，我们成功证明了 $\\BaseB^*$ 是 $V^*$ 的一组基。\n其实证明思路也是翻来倒去，用线性映射的性质和对偶基向量的定义，并不是很难。从证明过程中可以看出，对偶基的定义是 完全 依赖于原空间的基的。假如我们想得到 $V^*$ 的一组基 $\\BaseB^*$，我们就必须得先从 $V$ 中选择一组基 $\\BaseB$，然后让 $\\BaseB^*$ 定义为让 $\\BaseB$ 中向量经过作用后得到 $1$ 或 $0$ 的余向量集合。也正因如此，有的教材定义对偶空间时是先从基的对偶开始，然后用这组对偶的余向量线性张成一个空间，最后称它是 $V^*$ 的。\n对偶与矩阵表示 我们在第一章的末尾部分提过，一个 $\\R$ 上的 $n$ 维线性空间在选择一组基之后总能构建一个到 $\\R^n$ 的同构，这个同构把该线性空间的基映射到 $\\R^n$ 的标准基上。从上一节的内容也能得到 $V^*$ 和 $V$ 的基里面的基向量数量相同（毕竟 $\\BaseB^*$ 的每个向量都是由 $\\BaseB$ 中的向量一个个定义出来的），根据维数的定义，它们具有相同的维数，故它们的确是有一个同构。而直接从对偶基的构造过程也能看到它们之间的同构。\n然而，这个同构，我们指出，并不是前面我们提出的 自然同构。它并不满足 自然 的要求，即这个同构不依赖与任何除了线性空间八大公理以外的任何特点，因为我们要是想描述这个同构，就必须得找到 $V$ 中的基向量，然后才能说明 $V^*$ 的情况。即便任意选择 $V$ 中的基都可以得到同一个 $V^*$，但是总归我们是依赖了基的选择的。因此，我们常将 $V$ 和 $V^*$ 看作关系密切但并不相同的两个线性空间，不像我们前面那样，直接把 $V$ 和 $\\Hom(\\R,V)$ 看作一个空间。\n但是当我们将给线性空间 $V$ 选择基并表达为 $\\Hom(\\R,V)$ 时，我们可以看到由于我们选择了 $V$ 的基，我们立刻就获得了 $V^*$ 的基了。而根据 $\\Hom(V,\\R)$ 的矩阵表示是一个行向量，我们得到行向量的每个矩阵元都是 $\\Hom(V,\\R)$ 的线性组合系数。\n考虑两个向量 $\\vect{u},\\vect{v}\\in V$，我们给 $V$ 找好一组基 $\\BaseB$ 之后，两个向量就可以表达为列向量了。现在我们做这样一件事：把列向量 $\\vrep{v}{\\BaseB}$ 变成行向量，结果记作 $[\\vect{v}^*]_{\\BaseB^*}$。具体做法是，把里面每个数字拿出来，然后把对应的基全都变成对偶基，最后再按原样做线性组合，变化后的矩阵表示只改变了数字的排列方向，从原来的纵向变成了横向排列。而根据余向量的含义，它是一个 $V\\to \\R$ 的线性映射，我们定义它为 对偶向量：\n[!DEF]{对偶向量}\n设线性空间 $V$ 中有一向量为 $\\vect{v}\\in V$，其在 $V$ 的一组基下的线性表出为 $\\sum_i^n v^i \\vect{b}_i$。将 $\\vect{b}_i$ 替换为 $V$ 的对偶空间对应的基向量 $\\cvect{\\beta}^i$ 后，用原线性表出系数组合得到的向量 $\\vect{v}^*$ 称为 $\\vect{v}$ 的 对偶向量： $$\\vect{v}^* = \\sum_i^n v_i \\cvect{\\beta}^i,$$其中 $v^i = v_i$。\n既然一个向量可以有对应的对偶向量，而这个对偶向量又正好是一个把向量映射到实数的线性映射，那一个向量的对偶向量作用到另一个向量上，会给出什么呢？\n这个计算结果其实挺简单的，就是将它们对应位置的分量相乘然后相加。但是这个意义并不简单：我们给一个线性空间赋予基，把两个向量表达为矩阵；挑选一个向量，把它通过线性空间与对偶空间的同构，唯一地确定了这个向量的对偶向量（余向量），它的矩阵表达只变化了矩阵元的排列方式，从纵向变为横向；我们再利用余向量作为线性映射的含义，将另一个向量映射到了一个数字。这个数字不是别的，就是两个向量的对应位置分量相乘后相加，从矩阵运算的角度来看就是一个行矩阵乘以一个列矩阵，给出了一个实数。\n我们观察这个过程，可以得到这样的结论：我们可以在给线性空间选定一组基之后，在它的上面定义这样的一个运算，取两个向量之后，让其中一个向量变成对偶向量，然后作用到另一个向量上，给出一个实数。实际上，这定义了所谓的 内积，将两个向量映射到一个实数上，而它是一个 双线性形式，对参与运算的两个位置都满足线性性。下一章我们会谈到这个所谓的内积。\n我们还指出，在选定线性空间的基之后，向量和它的对偶之间的区别在矩阵表示下完全就只是更改了矩阵元的排列方式。这一点我们后面专门讨论矩阵时会指出，这其实就是所谓的 矩阵转置，而且转置、对偶、线性映射之间又有一些丰富的关系。\n不止对偶 我们还可以提出一个有趣的问题：对偶空间作为线性空间，它也一定有一个自己的对偶。那这个空间是什么样的呢？\n双对偶空间 我们首先先给这个空间一个记号。$V^*$ 的对偶空间 $V^*$ 可以有自己的对偶空间，记为 $V^{**}$。我们完全可以用在研究 $V^*$ 是怎么从 $V$ 来的过程来研究 $V^{**}$ 是怎么从 $V^*$ 来的。因此，$V^{**}$ 也完全适用我们在 $V$ 与 $V^*$ 之间的结论，可以搬到 $V^*$ 和 $V^{**}$ 之间。问题是，$V$ 和 $V^{**}$ 之间有什么关系呢？虽然我们不重新叙述 $V^{**}$ 的所有细节，但是我们依旧先给出 双对偶 空间的基本情况。\n[!DEF]{双对偶空间}\n我们称线性空间 $V$ 对偶空间 $V^* = \\Hom(V,\\R)$ 的对偶空间 $V^{**} = \\Hom(V^*,\\R)$ 为 $V$ 的双对偶空间，记为 $V^{**}$。即：\n$$\\Hom(V^*,\\R) = \\Hom(\\Hom(V,\\R),\\R).$$ 我们立刻会想到一个问题：双对偶空间中的向量是什么样的？从这个空间的定义来看，从 $V^*$ 到 $\\R$ 上的一个线性映射，或者说给每个余向量都赋予一个实数。可是这样也没法给我们提供从 $V^{**}$ 到 $V$ 之间的关系的线索。不过既然 对偶 这个概念几乎离不开基向量的选择，我们就来看看 $V^{**}$ 的基是什么样的。\n根据我们前面对对偶空间的研究，$V^{**}$ 的一组基需要通过在 $V^*$ 中确定一组基后唯一地确定。我们可以类比前面定义 $V$ 的对偶基 $\\BaseB^*$ 的方法，来定义它的双对偶基 $\\BaseB^{**}= \\{\\cvect{\\beta}^*_i\\}_{i=1}^{n}$：\n$$ \\begin{align*} \\cvect{\\beta}_i^{*} \u0026\\vcentcolon V^*\\to \\R,\\\\ \u0026\\quad \\cvect{\\beta}_i^{*}(\\cvect{\\beta}^j) = \\delta_i{}^j, \\end{align*} $$这里 $\\delta_i{}^j$ 是什么？我们可以研究它的定义域、陪域和对应关系，可以发现它和 $\\delta^j{}_i$ 实际上是同一个函数，因为它们的输入相同，输出也相同，对应关系都是一样的，函数的三要素全都相等，则两个函数确实是相等的。\n我们这样的记号目前主要是出于形式化的考虑。多亏了这样的基的定义（以及我们之前在“一次”对偶部分的努力），我们已经可以着手描述 $V^{**}$ 中的向量的情况了。既然每个向量都是 $V^*$ 到 $\\R$ 的线性映射，我们就取 $\\cvect{\\xi}\\in V^*$ 来放进 $\\cvect{\\varphi}^*\\in V^{**}$ 试一试：\n$$\\cvect{\\varphi}^* (\\cvect{\\xi}) = \\sum_i^n\\sum_j^n\\varphi^{*i}\\xi_j\\cvect{\\beta}^*_i(\\cvect{\\beta^j}),$$根据我们对双对偶基的定义，我们有\n$$\\cvect{\\varphi}^* (\\cvect{\\xi}) = \\sum_i^n\\sum_j^n \\varphi^{*i}\\xi_j\\delta_i{}^j.$$然而，根据我们前面的评论，$\\delta_i{}^j$ 实际上等于 $\\delta^i{}_j$ 的。另外，$\\varphi^{*i}$ 和 $\\xi_j$ 都是 $V^{**}$ 中向量和余向量的线性表出系数，它们都是实数。我们进行一个大胆且神秘的操作：\n$$\\begin{align*} \\cvect{\\varphi}^* (\\cvect{\\xi}) \u0026= \\sum_i^n\\sum_j^n \\varphi^{*i}\\xi_j\\delta_i{}^j \\\\ \u0026\\overset{?}{=} \\sum_i^n\\sum_j^n\\xi_j\\varphi^{*i} \\delta^j{}_i \\\\ \u0026= \\sum_j^n\\sum_i^n\\xi_j\\varphi^{*i} \\cvect{\\beta}^j(\\vect{b}_i) \\\\ \u0026= \\sum_i^n\\varphi^{*i}\\cvect{\\xi}(\\vect{b}_i)\\\\ \u0026= \\cvect{\\xi}(\\sum_i^n \\varphi^{*i}\\vect{b}_i). \\end{align*}$$我们有一个等号标上了 $?$，它的前面是单纯的乘法交换律，后面则是我们声称的 两种 Kronecker delta 是同一个的两个形态 的替换。随后将它展开则是形式化地使用基、对偶基和 $\\delta^j{}_i$ 的关系，接着按照 $V^*$ 中的线性组合得到 $\\cvect{\\xi}$，最后是根据线性映射的性质把求和放进去。\n我们看这个求和，它是什么？有 $V$ 中的基，有 $V^{**}$ 中的线性组合系数，而这个系数作为实数又完美地可以与基进行组合。所以，最后，一行的结果是什么意思？$\\cvect{\\varphi}^*$ 在把 $\\cvect{\\xi}$ 线性地映射到实数，就像是用 $\\cvect{\\xi}$ 把一个向量 $\\vect{v}\\in V$ 映射到实数上，而它在 $V$ 的基 $\\BaseB = \\basev{b}{i}{n}$ 下的表示的系数是 $v^i = \\varphi^{*i}$ ！？\n线性空间和它的双对偶之间，绝对有深厚的关系。似乎我们不管在 $V^{**}$ 选什么基，一个 $\\cvect{\\varphi^*}\\in V^{**}$ 不论在这个基下表示成什么，我们都能在 $V$ 中 对应的基 下得到一模一样的表示。它们之间可以有 $\\Hom(\\R,V)$ 与 $V$ 之间那样的 自然同构 吗？\n双对偶与自然同构 没错。线性空间和它的双对偶空间之间是存在一个自然同构的，或者说，我们可以把 $V^{**}$ 就直接看成 $V$！为了说明这一点，我们需要直接定义一个从 $V$ 到 $V^{**}$ 的同构，这个同构不应该依赖 $V$ 和 $V^{**}$ 的任何信息。而这个同构的定义实际上在上面已经提醒过了。我们开始把。\n[!THM]{$V$ 与 $V^{**}$ 之间的自然同构}\n我们定义线性映射 $\\iota$ 如下：\n$$\\begin{align*} \\iota\\vcentcolon\u0026 V\\to V^{**},\\\\ \\quad \u0026 (\\iota(\\vect{v}))(\\cvect{\\varphi}) = \\cvect{\\varphi}(\\vect{v}) \\quad \\forall \\cvect{\\varphi}\\in V^*, \\end{align*}$$这个线性映射成为一个 自然同构（在这个语境下又称 求值映射）。如此我们可以不区分 $\\vect{v}$ 和 $\\iota{\\vect{v}}$。\n接下来我们证明它是同构，并且说明它是自然的。\n[!PROOF]{同态 $\\iota$ 是同构}\n由于向量空间中的每个向量都唯一地表达为基的线性组合，如果 $V$ 的基在经过 $\\iota$ 映射之后能唯一确定成为 $V^{**}$ 的基，即 $V$ 和 $V^{**}$ 的基之间有一一映射，则 $V$ 中的向量就能唯一地对应到 $V^{**}$ 的向量。而这一点我们之前已经讨论了，关于二者之间的基的映射，有：\n$$(\\iota(\\vect{b}_i))(\\cvect{\\beta}^j) = \\cvect{\\beta}^j(\\vect{b}_i) = \\delta^j{}_i ,$$则每个 $\\iota(\\vect{b}_i)$ 都可以成为一个基向量，进一步地，$\\{\\iota(\\vect{b_i})\\}_{i=1}^n$ 就成了 $V^{**}$ 的一组基。就此我们成功证明它是一个同构。\n它也是自然的，我们下面说明：\n[!REM]{同构 $\\iota$ 是自然同构}\n根据我们对 自然 的说明：其定义不依赖任何除了 $V$ 和 $V^{**}$ 是线性空间，以及 $V^{**}$ 的定义以外的任何额外的，二者内部的信息/性质。我们不需要为二者找到各自的一组基来定义这个线性同构，所以这个同构是自然同构。\n双对偶的美妙性质其实早在我们说明 $V^{**}$ 中元素的基和线性组合的时候就已经有所暗示了：它的基向量的的标记写在了下标，而分量写了上标，这完全是和 $V$ 中向量表达为线性组合时，基向量和系数的写法是一样的。而从向量空间到 $\\R$ 的线性映射是线性空间，它不管怎么套娃，都会回到 $V$ 和 $V^*$ 二者之一。我想这也是我们为什么只需要上下标就能确定是哪个线性空间的元素的理由吧。\n对偶与范畴论 然而对偶的概念不止于此。我们甚至可以把它上升到范畴论（没错，自然 这个概念也是从范畴论来的）：\n[!REM]{对偶函子与双对偶函子、自然变换}\n$(-)^*$ 是一个从范畴 $\\Vect$ 到它自己的 函子，它将其中的对象 $V$ 映射到另一个对象 $V^*$ ，并且把 $V$ 到 $W$ 的线性映射变换为从 $W^*$ 到 $V^*$ 的线性映射。即：\n由于线性映射在函子作用下变换了方向，故该函子为一个 反变函子。\n进一步地，我们可以定义 双对偶函子 $(-)^{**}$，它将 $V$ 映射到 $V^{**}$，将 $V$ 到 $W$ 的线性映射变换为 $V^{**}$ 到 $W^{**}$ 间的线性映射。此外，存在一个 自然变换，将单位函子 $\\operatorname{Id}$ 变换到双对偶函子 $(-)^{**}$。在这个自然变换下可以给每个线性空间定义 求值映射 $$\\eta_V\\vcentcolon V\\to V^{**},$$ 有下面交换图成立：\n我们这里只介绍所谓的函子和自然变换，不对上面的叙述做深入探究。\n所谓函子，是定义在范畴之间的映射，它把一个范畴的对象映射到另一个范畴里，并且把范畴里的态射（箭头）对应地映射到另一个范畴里。在这里，我们的对偶函子和双对偶函子都是从 $\\Vect$ 到自己的函子，因此它的作用是让一个线性空间变成另一个线性空间，且把定义域上的线性映射转换为对应的线性映射。\n而所谓的自然变换，实际上是定义在函子之间的。它把一个函子变换为另一个函子，在这里我们的自然变换是把单位函子（什么都不做的函子）变换到了双对偶函子。而这样的 自然变换 的存在就允许我们给 任意 的线性空间都 自然地 定义它的双对偶空间，这个线性映射称作求值映射。也许从这里你就能感受到为什么我们说 对偶 不是自然的，但是 双对偶 是自然的。因为双对偶可以单纯地从函子之间的自然变换得到，而对偶不行，必须进入线性空间内部去定义。\n小结 照例我们总结一下这章讲了什么。本章我们做了这些事：\n以二维数表的形式引入 矩阵，并在 逐点 加法与数乘下我们可以构造 矩阵空间 $\\Mat(m,n)$； 通过计算向量在线性映射下的结果，我们得到了 矩阵元 $A^i{}_j$ 的含义：$V$ 的第 $j$ 个基向量经映射在 $W$ 基下的第 $i$ 个分量； 再由矩阵元的含义，我们定义了线性映射的矩阵表示 $\\mrep{L}{\\BaseB}{\\BaseC}$，以及 矩阵乘法和线性映射复合的关系； 我们证明了 $\\Hom(V,W)$ 在逐点加法与数乘下是线性空间，而它与 $\\Mat(m,n)$ 是有很强的对应关系的； 通过 $\\Hom(\\R,V)$ 的例子，得到了它与 $V$ 的自然同构，这说明列向量的表示方法是完全合理的； 我们定义了对偶空间 $V^*=\\Hom(V,\\R)$，通过构造对偶基 $\\{\\cvect{\\beta}^i\\}$ 并证明其为基，以它为第二个例子得到了它与 $V$ 的关系； 简单讨论了一下向量与对偶的配对，对偶、转置与线性映射之间是有丰富联系的； 我们定义了双对偶 $V^{**}$ ，并给出所谓的自然同构（求值映射）$\\iota:V\\to V^{**}$，以此我们可以把 $V$ 和 $V^{**}$ 看成一个空间； 从范畴论的视角，说明了为什么单次对偶是不自然的，而双对偶是自然的； 单是线性映射本身就已经有非常丰富的性质了。我们这里只研究了 $\\Hom(V,W)$ 的两个特例而已，而它又带来了更多的问题：为什么矩阵有这么多的含义，而矩阵乘法我们经常就只定义这一种？对偶和矩阵转置之间究竟有什么关系？那些特别的线性映射又会带来什么样的新东西？我们留到下一章再讨论吧。\n","date":"2025-10-20T09:03:01+08:00","image":"/images/Alice.jpg","permalink":"/zh/posts/math_note/linear_algebra/la_2/","title":"线性代数笔记 II"},{"content":"一直感觉没有学透线性代数，尤其是学过一些抽象代数之后，更觉得线性代数并不是简单的矩阵运算了。借着需要学连续介质力学的机会，就整理一下自己知道的东西，斗胆谈谈自己对这门学科的理解吧~\n选曲是由 鹿乃 翻唱，收录在专辑 one 的歌曲 Calc.，原曲是我特别喜欢的一首上古 V 曲，由 ジミーサムP 制作并发布在 ニコニコ動画 上，链接：Hatsune Miku Original Song「Calc.」。其实应该算是情歌？但是 Calc. 也很有计算的感觉（）\n选图为 Neve_AI 绘制的 AI 图，说是爱丽丝来着？反正挺好看的（逃）\n$$ % ===== ===== \\gdef \\vect #1{\\mathbf{#1}} % abstract vector \\gdef \\cvect #1{\\boldsymbol{#1}} \\gdef \\basis #1#2{\\mathcal{#1}_{#2}} % basis of vector space \\gdef \\basev #1#2#3{\\{\\vect{#1}_{#2}\\}_{#2=1}^{#3}} % base vector collection \\gdef \\cbasev #1#2{\\mathbf{#1}^{#2}} % dual basis e^i \\gdef \\vrep #1#2{[\\vect{#1}]_{\\basis{#2}{}}} % coordinate representation [v]_B \\gdef \\rep #1{[\\vect{#1}]} \\gdef \\mrep #1#2#3{[{#1}]_{\\basis{#2}{}}^{\\basis{#3}{}}} % representation [L]_{C,B} % \\gdef \\iprod #1#2{\\langle #1, #2 \\rangle} % inner product \\gdef \\mat #1{\\mathbf{#1}} % matrix (representation) \\gdef \\field #1{\\mathbb{#1}} % \\gdef \\xto #1{\\xrightarrow{#1}} % arrow with label \\gdef \\xfrom #1{\\xleftarrow{#1}} % left arrow with label \\gdef \\Hom {\\operatorname{Hom}} % morphisms between A and B \\gdef \\Iso {\\operatorname{Iso}} \\gdef \\End {\\operatorname{End}} % \\gdef \\Aut {\\operatorname{Aut}} % \\gdef \\cat #1{\\mathsf{#1}} % category symbol: e.g., \\cat{Vect}, \\cat{Set} \\gdef \\t {^{\\mathsf{T}}} \\gdef \\id {\\mat{I}} % identity matrix \\gdef \\R {\\field{R}} % \\gdef \\C {\\field{C}} % \\gdef \\ot {\\otimes} % tensor product symbol \\gdef \\zero {\\vect{0}} % \\gdef \\one {\\vect{1}} % \\gdef \\idop {\\mathrm{id}} % identity morphism \\gdef \\comp {\\circ} % composition symbol \\gdef \\Set {\\cat{Set}} % category of sets \\gdef \\Vectk {\\cat{Vect}_{\\field{k}}} % category of vector spaces \\gdef \\Vect {\\cat{Vect}} % % \\gdef \\BaseB {\\basis{B}{}} \\gdef \\BaseC {\\basis{C}{}} \\gdef \\BaseBV {\\basis{B}{V}} \\gdef \\BaseCW {\\basis{C}{W}} \\gdef \\BaseE {\\basis{E}{}} $$前言 由于鄙人需要学习一些力学（连续介质力学）的内容，涉及张量代数和张量微积分的概念，而要想理解它们就不得不提传说中的神秘课程：线性代数。所以既然如此，干脆就从代数学的角度，以线性代数为起点开始整个旅途。由于本文的内容掺杂了（也许很多的）我的个人理解，所以如果有错漏，请不吝赐教。\n记号约定 既然从代数学出发，我们必须得约定一些符号。\n含义 字体/字形 使用字母 标量 常规字母 $a$, $b$, $c$, $v^i$, $\\varphi_j$, $A^i{}_j$ 数域 黑板粗体 $\\Bbbk$, $\\field{R}$, $\\field{C}$ 向量 粗正体字母 $\\vect{u}$, $\\vect{v}$, $\\vect{w}$, $\\vect{x}$, $\\vect{y}$, $\\vect{z}$ 余向量 粗体希腊字母 $\\cvect{\\beta}$，$\\cvect{\\varphi}$，$\\cvect{\\psi}$，$\\cvect{\\xi}$ 集合，向量空间 大写常规字母 $U$, $V$, $W$ 基 - $\\basis{B}{V} = \\basev{b}{i}{n}$, $\\basis{C}{W} = \\basev{c}{j}{m}$, $\\basis{D}{}$ 向量表示 - $\\vrep{v}{B} = (v^1,\\dots,v^n)\\t$, $[\\vect{u}]$ 线性映射 常规字母 $L$, $R$, $S$, $T$ 线性映射，同构，自同态，自同构集合 - $\\Hom(V,W)$, $\\Iso(V,W)$, $\\End(V)$, $\\Aut(V)$ 线性空间范畴 - $\\Vectk, \\Vect$ 矩阵 - $\\mat{A}$, $\\mat{B}$, $\\mat{C}$, $\\mat{P}$, $\\mat{Q}$, $\\mat{M}$ 简单来说，我们用黑体代表矩阵和向量，用普通小写字母代表标量，当普通字母带上了上下标则代表对应的矩阵/向量分量。我们符号上严格区分线性映射和矩阵。在线性代数部分我们暂且使用这一套符号。另外，我们不使用爱因斯坦求和约定，直到我们彻底进入张量代数的内容。写清楚具体的求和过程在推导中是有帮助的。\n有了这些铺垫，我们开始吧。\n线性空间的定义 线性空间，又称 向量空间，作为线性代数中最基础的研究对象，在这个学科中占据几乎最中心的地位了。所谓的矩阵、向量等等，都是依赖于这个最基础的定义的。因此，我们先介绍一个线性空间它都有哪些基本属性。\n实际上我们很早就已经接触过线性空间了，比如我们在物理中经常用到的 $3$ 维线性空间 $\\R^3$，在初中物理阶段学到的笛卡尔座标系也实际上定义了一个二维的线性空间。直观理解就是线性空间是一个装满了向量的空间，最后这些向量可以被表达为一系列的数字。而如果从纯数学角度来讲，线性空间就是元素们满足 线性 的空间。下面我们给出定义。\n[!DEF]{线性空间}\n给定一个数域 $\\field{k}$ 和一个集合 $V$，我们给集合 $V$ 定义两个运算，（向量）加法以及数乘（标量乘法）：\n$$\\begin{align*} \u0026+\\vcentcolon V \\times V \\to V, \\\\ \u0026(\\vect{u},\\vect{v})\\mapsto \\vect{u}+\\vect{v} \\ \\ (\\forall \\vect{u},\\vect{v} \\in V) \\\\ \u0026 \\cdot\\vcentcolon \\field{k} \\times V \\to V,\\\\ \u0026(a,\\vect{v}) \\mapsto a \\cdot \\vect{v} = a \\vect{v}\\ \\ (\\forall a \\in \\field{k}, \\vect{v} \\in V) \\end{align*}$$当 $V$ 和 $\\field{k}$ 满足下面的性质时，我们就称 $V$ 是一个 $\\field{k}$ 上的线性空间：\n加法存在单位元 $\\zero\\in V$ 满足 $\\zero + \\vect{v} = \\vect{v} + \\zero = \\vect{v}$ 对任意的 $\\vect{v} \\in V$ 都成立； 任何 $V$ 中元素 $\\vect{v}\\in V$ 都在加法下存在一个逆元，记为 $-\\vect{v}$，使得 $\\vect{v} + (-\\vect{v}) = (-\\vect{v})+\\vect{v} = \\zero;$ 加法满足结合律：对于任意的 $\\vect{u},\\vect{v},\\vect{w}\\in V$，有 $(\\vect{u}+\\vect{v})+\\vect{w} = \\vect{u}+(\\vect{v}+\\vect{w});$ 加法满足交换律：对任意的 $\\vect{u},\\vect{v}\\in V$，有 $\\vect{u}+\\vect{v} = \\vect{v}+\\vect{u};$ 数域的单位元在数乘中也是单位元：对于 $1_\\field{k} \\in \\field{k}$，满足 $1_\\field{k} \\vect{v} = \\vect{v}$ 对于任意的 $\\vect{v} \\in V$ 都成立； 数乘在向量加法上可分配：$a\\cdot(\\vect{u}+\\vect{v}) = a\\cdot \\vect{u} + a\\cdot \\vect{v}$ 对于任意的 $a\\in \\field{k}, \\vect{u},\\vect{v}\\in V$ 都成立； 数乘在域加法上也可分配：$(a+b)\\cdot \\vect{u} = a\\cdot \\vect{u} + b\\cdot \\vect{u}$ 对于任意的 $a,b\\in \\field{k}, \\vect{u}\\in V$ 都成立； 数乘和域乘法次序可以交换：$a\\cdot(b\\cdot \\vect{u}) = (a\\times b)\\cdot \\vect{u}$ 对于任意的 $a,b\\in \\field{k}, \\vect{u} \\in V$ 都成立。 我们称 $V$ 中的元素为向量，上面的定义中我们混用了向量加法和标量加法，因为二者定义域完全不同；我们用 $\\cdot$ 和 $\\times$ 区分向量的标量乘法以及标量在域内的乘法。一般我们不将乘法记号写出。\n我们也可以说，向量空间是在阿贝尔群的基础上赋予一个域上的标量乘法后形成的模。\n下文我们始终将大写字母 $U$，$V$ 和 $W$ 作为我们要使用的某个线性空间。如果没有特殊声明，我们讨论的线性空间皆为数域 $\\R$ 上的线性空间。另外我们就称域 $\\Bbbk$ 中的元素为 数，它的乘法就是数乘。\n线性空间的内部结构 空有一个线性空间的定义，不知道它的内部结构的话，我们几乎什么都做不到，但我们可以从它的定义出发，发展一些概念来帮助我们理解线性空间的内部结构。而由于线性空间的 线性，我们可以用数自由地数乘线性空间中向量们，再按照喜好加减它们。而如果我们选择合适的一组向量，我们可以把向量空间中的任何元素都表达为这组向量的数乘和加法的组合。我们下面将这个想法描述为数学语言，为此我们先引入线性组合和线性无关的概念。\n线性组合 对一组向量可以做 线性组合：给向量们数乘以某一些数字后相加。下面是定义：\n[!DEF]{线性组合}\n设 $\\field{k}$ 上的线性空间 $V$，由 $V$ 上的加法和数乘，我们定义 $V$ 的一个含有 $n$ 个元素（向量）的子集 $W$ 的 线性组合 为形如\n$$\\sum_i^n a_i \\vect{v}_i$$的 有限 和，其中 $a_i\\in \\field{k}$ 且 $\\vect{v}_i\\in W\\subset V$。我们称 $\\field{k}$ 中的标量为线性组合的系数。\n有了线性组合，我们定义线性相关和线性无关。\n线性相关与线性无关 线性相关和线性无关描述了一组向量之间的关系，是某种“独立性”的检验。\n[!DEF]{线性相关与线性无关}\n对 $\\field{k}$ 上的线性空间 $V$ 的一个有 $n$ 个元素的子集 $W$，如果它们的线性组合满足条件：\n$$\\sum_i^n a_i \\vect{v}_i = \\zero \\iff a_i = 0 \\ \\ \\forall \\vect{v}_i \\in W, 1\\leq i \\leq n,$$则我们称这个子集 $W$ 是线性无关的，否则称其为线性相关的。\n借助线性相关与线性无关的概念，我们可以判断一个向量能否用从一组向量线性表出，也可以判断一个向量组是否是相互独立的。\n线性子空间、线性张成 在拥有一个线性空间之后，我们自然想问：它作为集合来看，它的子集能否具有一些特别的性质？我们指出：可以，且有所谓的 线性子空间。如果一个线性空间 $V$ 的子集 $U\\subseteq V$ 在赋予了 $V$ 的加法和数乘之后依然满足线性空间八条公理，则我们称这个集合 $U$ 配以两种运算之后得到的是 $V$ 的一个线性子空间。\n[!DEF]{线性子空间}\n给定线性空间 $V$ 的一个子集 $W$，如果它在原线性空间 $V$ 中定义的加法和数乘下依然满足线性空间的性质，我们就称 $W$ 是 $V$ 的一个线性子空间。\n那么，我们有什么方法来生成向量空间的一个线性子空间呢？我们可以这么做：取向量空间的一个子集之后，用它里面的所有向量自由地进行线性组合：数乘以任何数域中的系数，然后进行有限多次相加。这样的操作我们称之为线性张成，而它就能生成线性空间的一个子空间。规范的表述是这样的：\n[!DEF]{线性张成}\n依旧考虑线性空间 $V$ 的一个子集 $G$，它可以 线性张成 或者 生成 一个线性子空间 $W$，方法是在 $V$ 的运算定义下对 $G$ 中元素进行线性组合所得到的所有结果。此时我们称 $W$ 是 $G$ 的线性张成，$G$ 是$W$ 的生成集或者张成列表。\n不过，一组向量能线性张成多大的子空间，我们还不清楚。由于线性相关的存在，如果一组向量中很多向量都是线性相关的，那么它们应该张成一个比较小的空间；反之，如果一组向量全都是线性无关的，那么它应该能张成一个比较大的空间。不过我们还没法合理衡量这个大小，为此我们需要使用线性空间的 维数 来表达这个想法，而定义它需要线性空间的 基。\n线性空间的基 如果向量组足够大，能够张成线性空间本身，那么它一定是一个特殊的集合，而如果我们再剔除掉里面多余的向量，让它们每个都是线性无关的且能正好不多不少地能张成这个线性空间，我们就称它为线性空间的一组 基。\n[!DEF]{基与维数}\n如果一个 $V$ 的子集 $B$ 即能张成 $V$ 又是线性独立的，我们就称集合 $B$ 是 $V$ 的一组 基，记为 $\\basis{B}{V}$，在线性空间已经明了的情况下我们表示为 $\\basis{B}{}$。它里面的向量被称为 基向量，在对基进行排序之后，我们用记号 $\\BaseBV = \\basev{b}{i}{n}$ 来表达这个关系。其中第 $i$ 个基向量记为 $\\vect{b}_i$。习惯上（以及因为奇妙的原因）我们使用下标区分基向量。\n我们记 $\\BaseB$ 的基数（集合的大小，即基向量的多少）为该线性空间的维数，记作 $\\dim V$。\n实际上线性子空间与基还有特殊的关系：如果线性空间 $V$ 有一组基 $\\basis{B}{V}$，我们从这个集合中分出两份来，一份为 $B_1$，另一份为 $B_2$，然后让它们俩再进行线性张成，得到的两个线性空间都是 $V$ 的线性子空间。而更特别的是，由于这两个线性子空间作为集合来看，只在零向量 $\\zero$ 处相交，所以我们可以定义所谓的 直和，并称 $V$ 可以分解为这两个子空间的直和。\n直和有很多有趣的性质，感兴趣可以参考我的另一篇博文，这里就不再赘述了。\n我们还有另一种不依赖线性张成地定义基的方法。一组线性无关的向量如果在添加空间内的任意一个别的向量后都会变得线性相关的的话，则称这组线性无关的向量为该线性空间的极大无关组。而线性空间的一个极大线性无关组就可以成为线性空间的一组基。这个定义和上面的定义方式是等价的，这里就不再赘述。\n有了基，我们就可以对线性空间进行更复杂的描述以及操作了。比如，根据基的定义，每个线性空间中的向量都可以被表达为基的线性组合。\n向量的表示 我们取 $n$ 维线性空间的一组基 $\\basis{B}{V} = \\basev{b}{i}{n}$ ，在这一组基下我们可以将线性空间中的每一个向量 唯一地 表示为一个数表，这个数表有 $n$ 个数字有序地构成，我们称这些数字为向量在第 $i$ 个基（方向）上的 分量。后续我们认为所有的基都是经过了排序的。我们把这 $n$ 个数字纵向依次排列（原因我们后续讨论），成为列向量；不致引起误会时，我们简称为向量。由此，向量有两重含义：抽象线性空间中的一个元素，或者在线性空间有一组基后的一个纵向排列的数表。我们记 $n$ 维向量空间 $V$ 中的向量 $\\vect{v}\\in V$ 第 $i$ 个分量为 $v^i$，则有\n$$ \\vect{v} = \\sum_i^n v^i \\vect{b}_{i}。 $$习惯上（以及，再一次，因为一些神秘的理由），我们使用上标来标记向量空间中的向量，与基向量的下标相对应。\n进一步地，由于这样的对应关系，我们可以考虑把 $\\field{k}$ 上的 $n$ 维线性空间与一个特殊的，我们很熟悉的线性空间 $\\field{k}^n$ 联系起来，在 $\\field{k} = \\R$ 的情况下我们得到了熟悉的 $\\R^n$ 空间。这个对应方法便是让向量在选择一组基后形成的列向量来表示 $\\R^n$ 中一个点的坐标。这个对应关系有特殊的含义，我们后面会提到。\n线性映射 一个线性空间本身并没有特别多可说的，而要想更深入研究线性空间，就必须将不同的线性空间联系起来。而这个联系不同线性空间的东西是所谓的 线性映射。\n线性映射首先是从线性空间到线性空间的 映射，随后再要求它具有 线性性。线性性即保持线性空间结构（加法和数乘）的性质，拥有这样性质的映射不会破坏线性空间的结构：一个线性空间在映射后一定是一个线性空间。这样的想法在严格化后成为所谓的线性映射，或者 线性同态。\n[!DEF]{线性映射（线性同态）}\n设 $V,W$ 为同一数域 $\\field{k}$ 上的线性空间。映射\n$$L\\vcentcolon V\\to W$$称为线性映射或线性同态，若对任意 $\\vect{u},\\vect{v}\\in V$ 与 $a\\in\\field{k}$，有\n$$L(\\vect{u}+\\vect{v})=L(\\vect{u})+L(\\vect{v}),\\qquad L(a \\vect{u})=a L(\\vect{u}).$$等价地，对任意有限和，\n$$L\\Bigl(\\sum_{i=1}^n a^i \\vect{v}_i\\Bigr)=\\sum_{i=1}^n a^i L(\\vect{v}_i).$$我们记全体线性映射为集合 $\\Hom(V,W)$。若 $L$ 为双射，则称 $L$ 为线性同构。\n总的来讲，从 $V$ 到 $W$ 的映射 $L$ 的线性性让下面的做法是完全可行的：我们可以直接将 $\\vect{v}\\in V$ 映射到 $\\vect{w} = L(\\vect{v}) \\in W$，也可以先将 $\\vect{v}$ 变为几个向量 $\\vect{v}_i$ 的线性组合，再把这些向量映射到 $\\vect{w}_i = L(\\vect{v}_i) \\in W$ 中，最后再对它们进行线性组合。这两条路径将给出完全相同的结果。我们称从线性空间 $V$ 到其自身的线性映射为 线性变换 或者 线性算子，$V$ 上全体线性变换形成的集合可以记为 $\\End(V)$。\n有了线性映射，我们就有了把不同的线性空间联系在一起的方法，因此线性映射也是线性代数中的重要研究对象之一。我们随后会进一步了解线性映射（们）丰富又有趣的性质。\n线性空间范畴 我们还可以综合地考虑线性空间和线性映射，毕竟线性空间可以用线性映射联系起来，而每个线性映射都有来源与目标。代数学中有一个绝佳的概念：范畴，可以用来描述这一类东西，而在线性代数中我们研究的范畴则是 线性空间范畴。\n$\\mathsf{Vect}_{\\mathbb{k}}$ 的基本情况 如果我们综合考虑所有的 $\\field{k}$ 上的线性空间以及它们之间的所有线性映射，我们就可以得到所谓的线性空间范畴。我们记这样的范畴为 $\\Vectk$。而当我们已经明确了定义线性空间使用的数域时，我们可以使用 $\\Vect$ 来简记。我们称线性空间是这个范畴中的一个对象，而线性空间之间的线性映射则是它们之间的同态。\n两个对象之间可以有许多种不同的同态，我们可以将这些同态收集起来，就得到了上面的 $\\Hom(V,W)$。如果两个对象之间具有保持结构的双射及逆映射，我们就称它们二者之间有一个同构，而这两个对象也成为同构的对象。我们还可以让一个对象和它自身形成同态，得到的就是上面的 $\\End(V)$；进一步地，考虑所有的该对象到自己的同构，我们得到的集合记作 $\\Aut(V)$。这些集合上面都有丰富的性质，我们以后会聊到。所以其实上面的记号是借用了范畴论的一些记号得到的。\n关于范畴的定义，我们这里不给出，感兴趣可以参考之前的一些文章，里面有对范畴进行详细严格的定义。我们这里介绍范畴的概念主要原因是为了方便后续使用交换图的语言来讨论一些问题，比如线性空间的对偶，双对偶，自然映射等等。\n交换图 所以什么是交换图？交换图是用来描述范畴中的性质的重要工具，它使用同态，或者说箭头，连接范畴中的若干对象，并声称某些同态/箭头的复合结果是相同的。比如若有 $g\\circ f = \\varphi$，我们就称下面的图是交换的：\n交换图的主要作用就是用来叙述态射复合结果的相等，而当态射复合的结果相同时，往往会得到 态射不依赖对象内部结构 的特殊结论。我们后面会发现这个叙述的作用（威力）。\n一个例子：$n$ 维线性空间 $\\R^n$ 最后，我们聊一个特殊的例子，$n$ 维线性空间 $\\Bbbk^n$。\n$\\R^n$ 的基本结构 $\\Bbbk^n$ 是一个特殊的线性空间，是数域 $\\Bbbk$ 与自身做笛卡尔积 $n$ 次之后得到的集合，然后再在上面赋予一个简单线性结构形成的。我们这里就令 $\\Bbbk = \\R$，来看看 $\\R^n$ 是什么样的。\n[!EX]{线性空间 $\\R^n$}\n我们取数域 $\\R$ 并对其做笛卡尔积 $n$ 次，得到集合 $\\R^n$。其元素为有序对 $(r_1,r_2,\\dots,r_n)$。我们为其定义 $\\R$ 上的数乘为：\n$$\\cdot_{\\R^n}\\vcentcolon\\R\\times\\R^n\\to\\R^n,\\quad (r,(r_1,\\dots,r_n))\\mapsto (rr_1,\\dots,rr_n) \\ \\ \\forall r\\in \\R, (r_1,\\dots,r_n) \\in \\R^n$$即数乘定义为用数字以 $\\R$ 上的乘法去乘每个分量。定义加法为：\n$$+_{\\R^n}\\vcentcolon\\R^n\\times\\R^n\\to\\R^n,\\quad ((r_1,\\dots,r_n),(s_1,\\dots,s_n))\\mapsto (r_1+s_1,\\dots,r_n+s_n) \\ \\ \\forall (s_1,\\dots,s_n),(r_1,\\dots,r_n) \\in \\R^n$$即让每个分量都以 $\\R$ 上的加法相加。我们很容易验证，这个集合确实在加法和数乘下成为一个向量空间。它自然有一组基，我们特别地标记为 $\\BaseE = \\basev{e}{i}{n}$，其中 $\\vect{e}_i$ 代表坐标 $(0,\\dots,1,\\dots,0)$，即在第 $i$ 个位置取 $1$ 其余全部取 $0$。我们称这组基为 $\\R^n$ 的 典范基 或者 标准基。\n为什么我们特别要讨论这个向量空间呢？它有很多有趣的地方。首先我们很熟悉它，因为它的三维情形就是我们熟悉的所谓“三维空间”（不严谨地说）；其次，我们可以借助它来研究一些有趣的观点，比如让任意一个线性空间联系到它上面。\n$\\R$ 上的 $n$ 维自由向量空间 这里首先感谢 MSE上的讨论，这部分的内容几乎是转述这里的讨论。\n设有 $n$ 个元素的集合：$\\{1,2,\\dots,n\\}$，以及它到 $\\R$ 上的所有映射：$f\\vcentcolon\\{1,2,\\dots,n\\}\\to\\R$。现在考虑其中的一个映射，它把定义域中的一个元素映射到 $\\R$ 中的一个数字上，或者说它给定义域里的 $n$ 个位置都赋予一个实数。不难发现，它其实和我们创造的有序对是差不多的，结果上都是第 $i$ 个位置有一个实数。我们把这些 $f$ 收集起来成为一个集合，然后给它定义加法和数乘。加法就定义为函数的逐点加法，而数乘同样的也定义为逐点数乘。可以验证这样的操作的确能让这个集合成为一个线性空间，而更加巧妙的是，实际上我们是以另一种方式定义了上面的 $\\R^n$！\n然而有趣的地方不止如此，原因在于，这个 $n$ 个元素的集合，我们完全可以不取这个从 $1$ 到 $n$ 的整数集合，而是取我们喜欢的任何一个集合。什么 $\\{\\text{🍎},\\text{🍑},\\text{🍐}\\}$，$\\{\\text{昨天},\\text{今天},\\text{明天}\\}$，$\\{\\text{童年},\\text{在人间},\\text{我的大学}\\}$ ……我们都可以建立一个从它们到 $\\R$ 的映射，然后让它成为一个线性空间，并且值得注意的是，这个集合中的每一个元素都将成为新的线性空间中的 基向量。\n我们说称这样一个从具有 $n$ 个元素的集合映射到 $\\R$ 的函数全体为所谓的 自由线性空间。并且，借助这个概念，我们还能得到两个事实：\n一个有限维线性空间可以完全地由它的基确定。因为在给出这个基之后，我们总能用上面的方式构造出这个线性空间。 所有的 $n$ 维线性空间都是同构的，因为我们总能选择出它们的一组基，然后从这组基出发，以上面的方式统一地定义线性空间的元素以及它们的加法和数乘，最后得到原来的线性空间；而这些基作为集合都是同构的，因为集合的同构只要求它们之间存在双射，或者说两个集合的基数（势）相同。 既然如此，我们就可以把任意一个 $\\R$ 上的 $n$ 维向量空间看作 $\\R^n$，只需要通过集合间的同构 $\\BaseBV \\to \\BaseE$ 把它们联系起来就可以了。这也许也解释了为什么我们会称一个向量空间为 数域 $\\Bbbk$ 上 的线性空间的原因了。另外，我们还可以把 $\\R$ 本身看作一个线性空间，它的基我们当然就可以直接取 $1$，挺方便的就。\n最后，我们指出：自由 其实是来自于范畴论的一个概念。自由大概是说，从一个集合出发来构造一个范畴中的对象，仅仅考虑让它满足成为这个对象的必要的公理，而不添加任何的额外结构。这样得到的对象就成为了一个最最单纯的代数结构了。由于作者水平有限，这里不过多做解释（呜呜呜）。不过可以指出的是，任何有限维的线性空间都可以在选择好一组基的条件下成为一个自由线性空间，而如果考虑给这个线性空间更换一组基，它就变成另一个自由线性空间了，因为基不一样了。所以，自由线性空间和线性空间还是不太一样的。且对于无限维线性空间而言，并不是所有的线性空间都是自由的，而是那些 有有限支撑 的线性空间是自由的，即它的基只有有限多个基向量。\n总之，作为一个特殊且重要的例子，$\\R^n$ 的作用非常大，这一点在我们后续的讨论中会体现出来。\n小结 我们这里简单做一个小结。本章中我们做了这些事：\n简单约定了这个系列的记号体系。我们区分抽象向量、矩阵与标量分量，并且暂不使用爱因斯坦求和； 定义了线性空间：满足八条公理即可，并引入线性组合、线性相关/无关； 定义线性子空间与线性张成，说明生成集与张成的关系； 定义了基与维数，注意所谓的“极大线性无关组”和基是等价的； 指出向量在一组基下具有唯一坐标表示，并借此建立了抽象向量空间与 $\\R^n$ 的同构联系； 简要地聊了一下线性空间和子空间的关系，即所谓的直和； 我们引入了线性映射/同构/算子等概念，它们是保持线性结构的映射； 聊了几句关于线性空间范畴的事，约定了一些记号（$\\Hom,\\End,\\Aut,\\Vect$），方便后续讨论； 简单研究了一下线性空间 $\\R^n$ 的构造与它的标准基； 从一个新视角：“自由线性空间”出发，指出“所有 $n$ 维线性空间彼此同构”。 这一章我们对线性空间应该有一个基本的认识了。从下章起我们将从深入地讨论线性映射，它自身的一些特点，以及它与线性代数中的重要工具，矩阵，之间有什么样的联系。\n","date":"2025-10-20T09:00:01+08:00","image":"/images/Alice.jpg","permalink":"/zh/posts/math_note/linear_algebra/la_1/","title":"线性代数笔记 I"},{"content":"线性空间的直积（积）和直和（余积）究竟有什么区别？它们区别的根源在哪里？这个困扰了我许久的问题终于在今天得到了答案，一起看看吧~\n选曲为最近拜托朋友从日本买下（还没送到）的 鹿乃 的专辑 two 的一首歌，同时也是我很喜欢的 P 主 電ポルP（Koyori，又称电杆P，因为曲绘经常是电线杆）在 14 年发布的由 v flower 演唱的 曖昧劣情Lover。实在是非常好听，我很喜欢。头图则选择了某神秘群友的群友 派佬 生成的 AI 图，是可爱的艾雅法拉，希望你喜欢~\n$$ \\gdef\\field#1{\\mathbb{#1}} \\gdef\\zero{\\mathbf{0}} \\gdef\\one{\\mathbf{1}} \\gdef\\cat#1{\\mathcal{#1}} \\gdef\\catname#1{\\mathsf{#1}} \\gdef\\Hom{\\operatorname{Hom}} \\gdef\\Ob{\\operatorname{Ob}} \\gdef\\Mor{\\operatorname{Mor}} $$前言 最近在写线性代数和张量代数相关的内容，突然就想起曾经困扰了我许久许久的问题：线性空间的直积和直和之间究竟有什么样的区别？这个问题有一个很简单的答案：有限维下直积和直和同构，但无限维下情况就不一样：直积就像集合的笛卡尔积那样就 OK，而直和则要求向量只能有有限多个非零元素。然而，这个答案依旧不让人满意：凭什么？为什么直积和直和区别这么大？这样看起来直和就成了直积的子集了？\n虽然强行接受也不是不行，但这个显得很草率的答案依旧很难让人满意。好在，在范畴论以及伟大的互联网的帮助下，我还是摸到了这个问题的一些门道，得到了一个自己满意的答案。这里就斗胆聊聊我对这个问题的看法。\n我们这里默认读者知道什么是线性空间、线性无关、基等概念。知道大概是什么即可，我们后面会再提起这些概念。另外我们的线性空间都是在 $\\field{k}$ 上的。对于了解范畴论的朋友，我们谈论的问题限定在范畴 $\\catname{Vect}_\\field{k}$ 上。最后，我们不区分线性空间和向量空间，不区分无限和无穷，写手写爽了会随便从两个词中取一个，还请见谅。\n线性空间的直积 我们先来看看直积，当然是从有限多个线性空间的直积开始。\n两个线性空间的直积，以及推广 假如我们有两个线性空间，我们可以从它们构造出一个更大的线性空间，方法即为所谓的 直积。直积的方式很简单：我们把线性空间看成集合，对这两个集合做笛卡尔积，它的每一个元素就都成为了一个二元组。然后我们在这个集合上定义加法和数乘：二元组的加法定义为对应位置上的元素在原线性空间中的加法，而数乘则同时乘给两个位置。可以验证这样的结果依旧会给我们一个线性空间。我们称这样的构造过程为所谓的 直积。\n举个例子，设我们有 $V$ 和 $W$ 两个 $\\field{k}$ 上的线性空间，我们要从它们构建所谓的直积，记为 $V\\times W$，它的元素形如 $(v,w)$，其中 $v\\in V$，$w\\in W$。为方便区分不同线性空间的加法和数乘，我们给它们的加法和数乘带一个下标。所以，根据上面所说的那样，直积的加法和数乘分别为：\n$$ \\begin{aligned} +_\\times \\colon \u0026(V \\times W) \\times (V \\times W) \\to V \\times W,\\\\ \u0026((v_1, w_1), (v_2, w_2)) \\longmapsto (v_1 +_V v_2,\\, w_1 +_W w_2); \\\\[1em] \\cdot_\\times \\colon \u0026\\Bbbk \\times (V \\times W) \\to V \\times W,\\\\ \u0026(\\lambda, (v, w)) \\longmapsto (\\lambda \\cdot_V v,\\, \\lambda \\cdot_W w). \\end{aligned} $$上面两个运算对任意的 $v_1,v_2,v\\in V$，$w_1,w_2,w\\in W$，$\\lambda\\in\\field{k}$ 都成立。\n我们可以自然地将这个概念向后延伸。我们可以考虑将多个线性空间做直积，得到一个更大的线性空间，它上面的加法和数乘和上面的定义类似。我们称这样定义的加法为 逐点加法，这样的数乘也叫 逐点数乘。\n我们甚至可以尝试把这个概念推广至无穷。\n无穷个线性空间的直积 设有一个指标集 $I$ 以及它上面的一个指标 $i\\in I$（什么是指标集？不关心具体元素，只关心大小的集合就是了，比如有限集合 $\\{1,2,3\\}$，自然数集 $\\field{N}$，不可数的集合比如 $\\field{R}$）。我们定义 $\\{V_i\\}_{i\\in I}$ 的直积 $\\prod_{i\\in I}V_i$ 为这样的集合：它在第 $i$ 个位置的元素从 $V_i$ 而来，它的加法和数乘按照逐点的形式定义。\n可以证明，它是满足线性空间的八条公理的：\n加法满足封闭性，因为每个 $i$ 上的逐点加法都是在 $V_i$ 上封闭的； 加法是结合且交换的，因为 $i$ 上的加法是结合且交换的； 加法有单位元，只需要让每个位置上的元素都成为 $\\zero$； 加法有逆元，也只需要让每个位置的元素都给出 $V_i$ 中的逆； 数乘在 $\\field{k}$ 上的乘法与 $\\prod_{i\\in I}V_i$ 上元素的乘法是可以交换次序的，因为在每个位置 $i$ 上都是有这样的结果的； 数乘对加法是分配的，只需要让数乘分配到每个 $V_i$ 的加法上； $\\field{k}$ 上的加法对数乘是封闭的，也只需要让每个加法结果先数乘到第 $i$ 个位置上，再按照 $V_i$ 上的加法分配即可。 直积就是这样的东西，非常地简单（单纯？单调？）。那么，直和又是什么样的呢？\n线性空间的直和 在讨论线性空间的直和之前，我们先讨论 线性空间的子空间 以及 子空间的和。\n线性子空间 在有一个线性空间之后，作为一个集合我们自然地可以讨论线性空间的子集。如果这个子集在赋予了原空间的加法和数乘之后，依旧能成为一个线性空间，我们就称这个子集是一个线性子空间。线性子空间的存在允许我们可以从一个线性空间里拆出来一个更小的空间。我们自然会考虑这样一个问题：如果我们按照集合的减法那样把子空间从原空间中减掉，剩下的东西是什么？\n我们这里指出：剩下的东西很奇怪，它是一个坏掉的线性空间：缺少了 $\\zero$。如果我们把 $\\zero$ 复制一份给剩下的东西，它又成为一个线性空间了，而且根据子空间的定义，剩下的部分带上 $\\zero$ 之后也是一个线性子空间。提前剧透一下：这个线性空间就这样拆成了这样的两个线性子空间的直和。\n不过直和并不是直接出现的，这样定义的直和必须得先有一个原空间和子空间，不太自由。直和的定义是在我们定义线性空间的和之后才出现的。\n子空间的和 借助线性空间上定义的向量加法，我们可以定义两个子空间的和：我们任意地取两个子空间中的向量，然后再以任意的方式进行线性组合，最后把它们收集起来。它会得到什么东西？它里面的元素是什么？我们不用担心新得到的东西没有定义，因为它就是原线性空间中的一个向量，而这个和我们可以放心地说，它也是原向量空间的一个子空间。\n我们记线性空间 $V$ 的子空间 $V_1$ 和 $V_2$ 的和为 $V_1+V_2$。根据定义，我们可以知道子空间的和是满足交换律的，我们可以交换两个线性子空间的次序，得到的子空间是相同的。另外，我们也可以自然地把 两个 线性空间的和推广到 多个 线性空间的和。这里不再赘述。\n不过，子空间的和有很有趣的特点，如果 $V_1 = V_2$，那么 $V_1+V_2 = V_1 = V_2$，这一点可以从其定义验证得到。或者说，子空间的和不一定给出更大的子空间，而当它能给出更大子空间的时候，两个子空间只能有一部分的重叠。那么？它们最少能重叠多少呢？它们能完全不重叠吗（没有任何公共向量）？答案是：最少必须都得有 $\\zero$，这是作为线性空间子空间的必要条件。\n终于，我们可以讨论子空间的直和了。\n子空间的直和 我们用不太严谨的方式来定义直和。当一个线性空间的若干个子空间们之间只有公共的 $\\zero$ 时，它们的和就可以被称为直和。这样的描述可以用线性组合的语言来表述：线性子空间的和成为直和，当且仅当从和中的向量能 唯一 表达为每个子空间中的向量的线性组合，或者说，零向量只能由每个线性子空间中的零向量组合得到。如果借助原空间以及线性无关的概念，我们可以得知，一组线性子空间的和能成为直和当且仅当每个线性子空间中的向量任取一个非零向量之后组成一个向量组，这个向量组是线性无关的。\n上面给出了四个线性子空间的和成为直和的条件。可以证明它们之间都是等价且相互推导的。\n最后，线性子空间的直和可以类比集合的无交并。比如一个线性空间 $V$ 里有一个线性子空间 $W$ ，我们可以从线性空间删掉这个子空间得到另外的东西 $V\\setminus W$，它自然和 $W$ 是不相交的，但是这个集合不是线性子空间；当我们给 $V\\setminus W$ 补上 $\\zero$ 之后，它能成为线性子空间，且和 $W$ 进行直和之后就能给出 $V$。或者我们这样说：由于线性子空间直和的性质，它们的公共子集只有一个 $\\{\\zero\\}$，而如果我们从每个线性子空间中都挖掉 $\\zero$ 之后，它们就成为交集为空的集合，做无交并之后我们再补上 $\\zero$ 就又能得到一个线性子空间，这个子空间不是别的，就是对前面的子空间们做直和得到的结果。\n可以看到，线性子空间的直和与集合的无交并之间只差了一个“集合成为线性空间的条件之一”：零向量。那么我们能把 子空间的直和 推广到 线性空间的直和 吗？利用上面的类比，实际上这是可以的。就像无交并那样，我们这样操作：\n从每个线性空间中挖掉 $\\zero$。这样它们就成为了一组集合； 给每个线性空间赋予一个指标，做无交并。 给无交并的结果补上 $\\zero$，根据直和的要求，令直和中的 $\\zero$ 为从每个线性空间中取 $\\zero$ 并放在对应的位置后得到的结果。 就这样，我们得到了线性空间的直和。它不再依赖某个线性空间的子空间存在，而且我们可以看到，如果按照上面的操作，得到的结果实际上和线性空间的直积是一样的：元素都是一个 $n$ 元组，第 $i$ 个位置都是从 $V_i$ 这个线性空间中得到的。\n那么我们能把线性空间的直和推广到无穷个线性空间的直和吗？这是个很有趣的问题，答案是可以，但不是任意的。无穷个线性空间的直和是它们的直积的子空间（集合上是子集），直和对无穷维元组的元素提出了要求：只能有有限个非零的向量，剩下的位置都必须是各自线性空间上的 $\\zero$。\n为什么？为什么会这样？线性空间的直和为什么和直积在有限维的时候是相同的，而在无限的情况下就不是一样的了？我们先把这个问题放在一边，先来看看范畴论中是怎么看待直积以及直和的。如果您对范畴论的语言比较了解，可以跳过下面的一章\n范畴 为什么从范畴论角度讨论这个问题？因为 直积 和 直和 这样的结构在数学的各个领域中都有出现，它们太普遍了，以至于我们可以从范畴论的角度把它们抽象出来统一研究。我们先来看看范畴是什么。\n范畴会收集一系列的数学 对象，以及这些数学对象之间的 箭头，也称它们为 态射，最后规定这些箭头们要满足的公理。\n范畴的定义 我这里借用李文威老师的《代数学方法》中对范畴的定义：\n[!DEF]{范畴}\n一个范畴 $\\cat{C}$ 是指以下的资料：\n一个集合 $\\Ob(\\cat{C})$，其元素称为 $\\cat{C}$ 的 对象； 另一个集合 $\\Mor(\\cat{C})$，其元素称为 $\\cat{C}$ 的 态射。 另外，对上面两个集合之间有这样的要求：\n两集合间有一对映射：$s\\vcentcolon\\space\\Mor(\\cat{C}) \\to \\Ob(\\cat{C})$ 和 $t\\vcentcolon\\space\\Mor(\\cat{C}) \\to \\Ob(\\cat{C})$，它们分别指出了态射的来源与目标。 对于态射而言，有这样的要求：\n针对某两个对象 $X,Y\\in\\Ob(\\cat{C})$，我们可以从上面这一对映射中得到这两个对象之间的所有态射的集合：$\\Hom_\\cat{C}(X,Y)\\vcentcolon=\\space s^{-1}(X)\\cap t^{-1}(Y)$。在明确所指范畴的情况下可简记为 $\\Hom(X,Y)$。这样的集合也被称为 $\\mathrm{Hom-}$ 集； 对于任意的一个对象 $X$，一定存在一个态射 $\\mathrm{id}_ {X} \\in \\Hom_{\\cat{C}}(X,X),$ 这个态射被称为 $X$ 到自身的恒等态射； 给定任意的三个对象 $X,Y,Z\\in\\Ob(\\cat{C})$，有这样在其 $\\mathrm{Hom-}$ 集之间的映射，称为合成映射，定义为： $$\\begin{align*} \\circ\\vcentcolon\\space\\Hom_\\cat{C}(Y,Z) \\times \\Hom_\\cat{C}(X,Y)\u0026\\to \\Hom_\\cat{C}(X,Z)\\\\ (f,g)\u0026\\mapsto f\\circ g\\\\ \\end{align*}$$ 且当不至于混淆时可以省略中间的 $\\circ$，将 $f\\circ g$ 简记为 $fg$。 最后，对上面的合成映射而言，有这样的两个要求：\n结合律：对于任意的态射 $h,g,f\\in\\Mor(\\cat{C})$，如果映射的合成 $f(gh)$ 和 $(fg)h$ 都有定义，那么 $$f(gh) = (fg)h.$$ 对于任意的态射 $f\\in\\Hom_\\cat{C}(X,Y)$，其与恒等映射之间的复合满足关系： $$f\\circ\\mathrm{id}_X = f = \\mathrm{id}_Y\\circ f.$$ 一个很不错的例子就是集合范畴 $\\catname{Set}$，它的对象自然是各种各样的集合，而态射或者箭头则是集合之间的映射，没有任何的附加结构。对于别的范畴，我们还要求态射是要保持结构的：不然态射的目标就不在范畴里面了。而在这里，线性空间自然也可以有一个范畴。我们一般会固定一个数域 $\\field{k}$ 后研究定义在它上面的线性空间，因此定义在 $\\field{k}$ 的线性空间们，随着它们之间的线性映射（同态）一起，成为一个范畴。我们称之为 $\\catname{Vect}_\\field{k}$，数域 $\\field{k}$ 上的线性范畴。可以说，我们学的线性代数这门课，就是在研究 $\\catname{Vect}_\\field{R}$ 或者 $\\catname{Vect}_\\field{C}$。\n交换图 范畴论中的重要研究对象之一即为交换图。交换图把 两个运算可交换顺序 这一性质给可视化了。以线性空间中的线性映射为例，对于线性空间范畴 $\\catname{Vect}_\\field{k}$ 中的两个线性空间 $V,W$ 以及它们之间从 $V$ 到 $W$ 的一个态射 $f\\vcentcolon V\\to W$，根据定义我们有下面两个交换图成立：\n对于向量空间加法，我们有：\n其中右下角的下标表示不同线性空间之间的向量加法。对于标量乘法，我们有：\n同样，右下角下标表示不同线性空间的数乘。我们说这两个图交换当且仅当有我们上面给出的那个关于线性映射的定义，或者使用范畴论的方式，用态射的复合，有下面两个式子成立：\n$$ +_W \\circ f\\times f = f\\circ +_V;$$ $$ \\cdot_W\\circ \\mathrm{id}_\\field{k}\\times f = f\\circ \\cdot_V.$$其中，$\\circ$ 代表的是态射的复合，第一个式子代表第一个图交换，第二个式子则为第二个图交换。\n泛性质 最后我们来谈谈范畴论中的所谓 泛性质。泛性质是这样的一种性质，当某些对象带着它们的态射之后和另一个对象之间有某种联系的时候，我们可以用具有泛性质的东西把它们联系起来。具体做法（按照抽象废话的方式）我们用一个例子说明，是这样的：\n我们首先声称有一些确定的对象，我们称之为 $A_i$，让 $i$ 是指标集 $I$ 中的一个指标。下一步比较有趣，我们先不讲我们要描述的那个特殊对象，而是讲 和这个特殊对象有一些关系的某个对象。我们称它为 $X$，且 $X$ 和 $A_i$ 之间应该有一些态射 $f_i\\vcentcolon X \\to A_i$。我们来想象一下 $X$ 和 $A_i$ 之间的态射，它们在这些态射的连接下能形成一个锥形的结构：把 $A_i$ 们排成一个圆圈，把 $X$ 放在上面之后引出许多的 $f_i$ 连到 $A_i$ 上。\n最后就轮到我们的主角出场。我们需要描述的对象，我们称之为 $S$，以及从 $S$ 到 $A_i$ 的一系列态射（我们称之为 $\\pi_i$），它们具有这样的特点：我们说存在 唯一 的态射 $f\\vcentcolon X\\to S$，使得 $\\pi_i \\circ f = f_i$ 始终成立，或者就是说这个交换图交换：\n我们把 $S$ 和 $\\pi_i$ 标红了，因为它们是我们要的特殊对象们；我们将底下的箭头标蓝，说明它是 $S$ 和 $\\pi_i$ 满足的特殊性质。\n值得注意的是，范畴论中我们最应关心的不是对象，而是对象之间的态射：态射会包含它的源与目标的信息，而单纯的对象不会有这么丰富的信息。如果我们将目光从 $S$ 和 $X$ 挪到三个对象之间的态射的话，可以看到我们现有 $A_i$，特殊的对象是 $\\pi_i$ 以及与之而来的 $S$，对于任意的 $f_i$ 我们都可以有一个唯一的 $f$ 让 $f_i$ 分解为 $\\pi_i$ 和 $f$。\n有了泛性质，我们终于可以开始进入正题了。\n积和余积 范畴论中有所谓的 积 和 余积。它们其实分属与所谓的 极限 和 余极限，然而我们不打算讨论这么深入。\n积 我们使用交换图来定义范畴论的积：\n[!DEF]{积}\n在一个范畴 $\\cat{C}$ 中，取其中的对象 $X_1$ 和 $X_2$，在 $\\cat{C}$ 中有另一个对象 $X$ 被称为 $X_1$ 和 $X_2$ 的积，我们通常记为 $X_1\\times X_2$，它是 $\\cat{C}$ 中的一个对象，带有一对投影态射 $\\pi_1\\vcentcolon X\\to X_1$ 和 $\\pi_2\\vcentcolon X\\to X_2$，满足如下的泛性质：\n对于任意 $\\cat{C}$ 中对象 $Y$ 以及态射 $f_1\\vcentcolon Y\\to X_1$ 以及 $f_2\\vcentcolon Y\\to X_2$，都存在唯一的一个态射 $f\\vcentcolon Y\\to X$，使得下面的图交换：\n将该构造推广到一族对象 $\\{X_1\\}_{i\\in I}$，则我们记它们的积为 $\\prod_{i\\in I}X_i$，带投影映射 $\\pi_i\\vcentcolon \\prod_{i\\in I} X_i \\to X_i$，它能令下图交换：\n从上面的交换图来看，我们可以对积做出这样的评论：一族数学对象的积是这样的一个特殊对象，当有一个对象 $Y$ 里包含了这一族数学对象的中的一个（记作 $X_i$）里的信息时，我们总可以先把这个对象映射到积上，然后再从积中还原出这些到 $X_i$ 的信息。或者说，我们不是从数学对象构建出的积，而是有一个对象名字叫积，它包含了这些数学对象的所有信息，也正因如此，任何对象和它到 $X_i$ 的一个映射都可以先在积中找到 $X_i$ 的信息，最后再从积传给 $X_i$ 自己。\n余积 而当我们反转上面定义中的所有箭头后，我们就得到了所谓的余积。\n[!DEF]{余积}\n在范畴 $\\cat{C}$ 中，给定对象 $X_1,X_2$，若存在对象 $X$（记作 $X_1 \\amalg X_2$）及插入态射 $\\iota_1\\vcentcolon X_1 \\to X$、$\\iota_2\\vcentcolon X_2 \\to X$，使得：\n对任意对象 $Y$ 与态射 $g_1\\vcentcolon X_1 \\to Y$、$g_2\\vcentcolon X_2 \\to Y$，存在唯一态射 $g\\vcentcolon X \\to Y$，满足 $g \\circ \\iota_1 = g_1,\\quad g \\circ \\iota_2 = g_2$， 则称 $X$ 为 $X_1$ 与 $X_2$ 的余积。\n使下图交换：\n推广到一族对象 $\\{X_i\\}_{i\\in I}$，其余积记作 $\\coprod_{i\\in I} X_i$，带插入态射 $\\iota_i\\vcentcolon X_i \\to \\coprod_{i\\in I} X_i$，满足：\n对任意对象 $Y$ 与一族态射 $g_i\\vcentcolon X_i \\to Y$，存在唯一态射 $g\\vcentcolon \\coprod_{i\\in I} X_i \\to Y$，使得对每个 $i$ 都有 $g \\circ \\iota_i = g_i$。\n使下图交换：\n而对于余积，仿照我们对积的评论，我们可以说余积包含了所有的从一族对象出发的信息。如果从某一族对象出发来组合里面的信息形成一个新的对象，我们总是可以先把它们的信息放在余积中，最后再从余积里面进行这个组合操作。\n小结：积与余积的区别 有了积和余积，我们指出：在集合范畴里，积就是笛卡尔积，余积则是集合的无交并，而在集合范畴中，它们二者是同构的。但是在别的大多数范畴中，例如今天的主角，线性空间范畴里，积和余积是不一样的。积在线性空间范畴中是直积，而余积则成为直和。\n积和余积区别在哪？其实就在于箭头的反转。我们知道，这些箭头一定是从 定义域 射向 陪域 的。我们仔细考察那个 存在唯一的映射 的部分，在积中这个箭头是从任意的一个对象映射到积，也就是说积是在陪域的位置的。作为被动接受的一方，映射到积时并不需要做什么特别的操作，把信息转发给原映射的目标，即那一族对象中的某个即可。然而在余积中，这个箭头是从余积指向某个任意的对象的。这意味着，我们在那一族对象中选取一个元素之后，它必须得先在余积中找到一个对应，然后再走那个唯一的映射对应到目标上，并保证和不经过自己得到的结果相同。\n举个可能不太恰当的例子，积的情况下，好比过去人们通过电话交流，你要与某人通信时可以花大价钱拉一条专线过去，但如果你要打给的人很多，这样就不太方便，此时可以先拨电话给总台，让总台的接线员帮你处理你要打给谁的问题。总台接线员那里的电话名册就相当于那个积。而余积的情况下，就像给班级的人们安排座位，可以让所有人直接去教室里坐下，也可以先拿出一张纸，让每个人在上面选好位置之后再让他们坐在纸上画好的位置。\n积和余积二者微妙的区别就成为了直积和直和的区别的根源。而导致它们区别的导火索则是数学中的常客：无限/无穷。\n无限维的噩梦 我们先来看看有限维下二者的情况。\n有限维的状况 在有限维时，直积自不必说，每个向量就是一个 $n$ 元组，每个位置上都是一个线性空间中的向量；直和则也类似，因为每个直和中的向量都可以写为这一族线性空间里每个线性空间中唯一一个向量的和，在我们给这些线性空间排好序之后依旧可以把它们表达为一个 $n$ 元组。一切都合情合理，非常符合我们的预期。\n然而到了无限维，事情就发生了变化。\n无限维的情况 在无限维下，直积我们已经验证过了，由于它的构造方式的原因，它的存在合情合理，只需要把每个向量空间中的向量拼起来就能得到直积中的向量，直积也同样是一个线性空间。\n然而对于直和，事情就没那么简单了。考虑直和的定义过程，我们是从线性子空间的和发展而来的线性子空间的直和，再将直和的含义抽离出“无交并类似物”的部分，发展出的线性空间的直和。这个过程中，构建直和元素的过程依旧是依赖 向量加法 的概念的，即便有了“无交”来避免两个相同线性空间无法直和，直和的产生依旧是需要对每个向量空间中向量做加法的。\n问题就出现在这里。我们定义向量加法的时候，只定义了两个向量的加法。而我们对它的推广只能推广到有限个向量之间的加法，是 没法自然地推广到无穷多个向量之间的加法的。而这一点就导致无穷多个向量空间做直和时，其元素中是不能有无限多个非零的向量的，因为那就代表了需要对无交的无穷多个向量空间中的 无穷多个非零向量做加法，而这是不被允许的，代数学中不允许这样奇怪的东西存在。试想，无限多个 $1$ 相加，结果是什么？在没有拓扑结构之前我们是没法下任何的结论的。\n所以说，只有有限多个元素非零，才能保证我们在直积的构造过程中不使用非法的“无穷多个非零向量相加”的情况出现。\n范畴论在哪里？ 那么，范畴论中积和余积的特点为什么是这个区别的根源呢？我们依旧采用上面的记号，考虑积的泛性质，从一个对象，这里是线性空间 $Y$，映射到一族线性空间中的第 $i$ 个即 $X_i$ 时，我们不太需要考虑什么特殊的规约，它在经过 $X$ 中转的时候也不需要有什么特殊的限制，因此只要 $X$ 的确是一个线性空间就可以了，无限引来的问题在 $X$ 成为接受 $Y$ 的信息的对象的时候被绕过去了（不管是不是无限，反正整个过程是合法的）；\n而考虑余积的泛性质，首先如果对任何的 $Y$ 为目标的箭头 $g_i\\vcentcolon X_i\\to Y$ 都能有一个对象来实现这个映射的分解，这个对象就必须得包含所有 $\\{X_i\\}_{i\\in I}$ 的信息，且每个 $X_i$ 都得能被区分开（两个空间不能被映射到同一个空间上）。否则这样的分解就无法成功进行：有的地方可能会缺一块。既然如此，每个空间中的元素都会（以某种形式）出现在余积中了，那么它们的出现形式呢？我们第一个想到的自然是和积同样的构造：即啥都不做限制。那么此时考虑那个 存在且唯一 的箭头，我们取一个有无限个非零分量的向量，它表示每个线性空间都贡献了一个非零的向量，它在被通过那个唯一的箭头带到 $Y$ 上的时候就出现了问题：从 $\\{X_i\\}_{i\\in I}$ 到 $Y$ 的时候，我们从 $X_i$ 中取一个元素之后它只能对应 $Y$ 中一个元素，而它也只能在余积（我们此时假设它是积）上对应一个元素，再把这个元素放到 $Y$ 中。但是 无限 个非零元素会导致 这个从余积到 $Y$ 的箭头不唯一，因为我们有无限多个槽位，可以任意安排除必须对应的向量之外的所有向量随心所欲地放在不同的 $Y$ 中，形成不同的态射。\n为了弥补这一点，就只能让余积中的元素里每个都只包含有限个 $X_i$ 中的非零元素，剩下的全都置零。这样我们还有办法且是唯一的办法，来将余积中的元素唯一映射到 $Y$ 上。\n另外，我们还可以从纯范畴论的角度来证明余积只能有有限个非零的分量。它的证明利用了所有 $g_i$ 的像可以张成余积的子空间的特点，并证明了这个张成的子空间其实就是余积本身，由此证明了余积的元素只能是有限个 $X_i$ 中的非零向量构成的（根据张成的定义，只能有有限个向量的线性组合）。这个方法由 MSE 的上 Martin Brandenburg 做的这个回答 给出的。有趣的点在于，这个回答其实是针对阿贝尔群范畴的，不过由于线性空间本身的确就是一个阿贝尔群，且这个问题的根源也在于阿贝尔群上的加法，所以这里贴的这个问题的解答实际上是完全适用于我们的问题的。同样，由于模本身也是一个阿贝尔群，所以模也有这样的构造，也有这样的问题，和这样的解答。\n后记 这个问题说实在的，困扰了我挺久的。在我知道线性空间的直积、直和以及范畴论之后，这个问题就时不时弹出来，困扰我几天。然而，偶然的机会，我又系统地了解了一下线性空间中的内容，（因为连续介质力学，谢谢你，线性代数？）了解了一些和线性空间的基、线性相关/无关、子空间的和之间的内容，对这个问题有了一些自己的看法。\n实际上这个问题在没有引入范畴论前单纯就是一个定义或者概念无法任意拓展产生的问题。由于直和是来源于子空间的直和，强依赖于 有限 线性组合，这个概念在代数上就是没法拓展到无限，因而连带着直和无论如何都没法自然地拓展到无限个线性空间的情况。而又由于直积的构造和线性空间的性质没有半点关系：他就是单纯地把所有的东西放在不同位置后捏在了一起，结果正好在最简单的逐点的加法定义和数乘定义下能成为一个合法的线性空间，就让它这么成为直积了。\n这个问题实际上在范畴论的框架下得到了新的解答，又或者是更复杂（更丰富）的诠释。范畴论下，线性空间的直积变成积，直和变成余积，它们是一个交换图的一体两面，区别来源于态射的 方向性，而泛性质的存在神奇地规约了积和余积的 形状。很难让人不感慨范畴论的神奇。而如果再深入范畴论，就我所了解到的而言，它们的特点的区别可以更进一步追溯到 极限 和 余极限 上面，其中的一个是“最大的”，另一个是“最小的”，不知道读到这里的您有没有这样的感觉。然而如果谈论极限和余极限的话，内容会变得更复杂，可能需要涉及函子和自然变换之类我都不好说我懂的东西。所以，我们就此打住，一个满意的答案已经不错了。\n在寻找这个问题的答案的过程中，我还发现了很多有趣的内容。比如“线性空间总有一组基”和著名的 AC，即选择公理，是等价的。当我们承认 AC 之后，所有的线性空间，即便是无限维的，也是存在一组基的。而如果我们能给一个无穷维线性空间找出一组基，我们就可以从这组基来构造别的基，而这个操作和某个在承认 AC 之后会出现的神奇现象：巴拿赫-塔斯基分球怪论的操作是非常类似的。另外，我们说的“总有一组基”依旧是在我们已有的 线性组合 的定义下的，即 线性空间中的所有元素都可以被唯一表达为基的线性组合，而这里的线性组合总是有限的。\n所以其实这篇文章我一开始是计划在最后写一些集合论和线性空间的基的内容的。不过总归它们是和主题偏离地有点多的，我还是放弃了。而这篇文章一开始还计划从线性空间的定义开始逐步介绍整个问题，后面我也否定了，因为那样的话这篇文章又会变成超级大长篇。关于线性代数的部分，还是整理一下放在计划中的 张量代数/张量微积分 系列里面吧。这篇文章的内容我也不是非常满意吧，写的过程中卡了很多壳，有些地方的表述并不令人足够满意。如果您在读的过程中发现有的地方并不通顺，请自由提出批评；如果您有任何的建议，也欢迎提出。\n另外还要感谢和推荐 quiver 这个工具，本篇文章中的交换图都是使用它绘制的，方便易用，实在是非常不错。它在 GitHub 开源了，感兴趣还可以看看它的 仓库；同时感谢 B站 UP 主 数学わかんない 的这一期 （科普）直积和直和的不同\u0026ndash;从线性代数到范畴论，讲得很好，帮了我很多。\n那么，非常感谢您能看到这里。这样又长又晦涩难懂的文章（也许）能让您愿意看到这儿，我也就心满意足了。最后一如既往地，祝您身体健康，双节愉快~（即便只剩一天了吧大概）\n","date":"2025-10-05T19:55:20+08:00","image":"/images/Eyjafjalla.jpg","permalink":"/zh/posts/math_note/product_and_coproduct_of_vector_space/","title":"线性空间的积与余积"},{"content":"我们平时经常会遇到所谓的 函数，不管是数学、物理，甚至是程序里，都能看到它的身影。然而，它到底是啥呢？你也许自己心里有一个答案。这里我也斗胆对比一下各个教材中对函数的定义，聊一聊这个我们或许很熟悉的概念。\n头图依旧选自 fasnakegod 大大的 笔中的呓喃之境，实在是非常好看，Aya 太可爱了。曲选自一首经典的吉他指弹曲：岸部眞明的 《Time Travel》，轻松愉快的一首，我很喜欢（甚至学过）（没学会）。\n印象中的函数 印象之中，函数其实应该是相当常见的。初中的时候就有这么个东西了。\n初中：一些奇怪的公式以及图像 没记错的话初中的时候，教科书上就会讲述所谓的一元函数了：长成 $y = kx+b$ 这样儿的东西，就是一次函数；而压轴题里的，长成 $y = a x^2 + b x + c$ 样子的东西就是二次函数。哦对了，第二个式子里的 $a$ 还不能是 $0$，不然会退化成一次函数。这还是个考点。那时的我天真地认为，函数就是这些东西，哦，还有一个三角函数，不过太高深了，我听不懂。总之，初中时的函数非常地简单：就是一条或者直或者弯的线条嘛，然后有个公式可以算来算去，还和方程有那么一点关系，考试就是算个交点，仅此而已了。\n高中：集合上的对应法则 进入高中之后，根据我手上这本 数学 必修1 人民教育出版社 B版 2007年4月第3版 的内容来看，我们在第一本书中几乎只学习什么是函数。在以非常具体的例子介绍完集合，集合的表示方法以及集合之间的关系与运算之后，我们在第二章就遇到了 函数 了。书上是这么开始介绍函数的，说在初中，同学们就已经学习了变量与函数的概念：\n[!DEF]{函数与变量（初中）}\n在一个变化过程中，有两个变量 $x$ 和 $y$，如果给定了一个 $x$ 值，相应地就确定唯一的一个 $y$ 值，那么我们称 $y$ 是 $x$ 的 函数，其中 $x$ 是 自变量，$y$ 是 因变量.\n随即表示，在数学发展中，函数的定义也随之发展，然后用第一章中的集合1：\n[!DEF]{函数、定义域与值域（高中）}\n设集合 $A$ 是一个非空的数集，对 $A$ 中的任意数 $x$，按照确定的法则 $f$，都有唯一确定的数 $y$ 与它对应，则这种对应关系叫做集合 $A$ 上的一个函数，记作\n$$y=f(x),x\\in A,$$其中 $x$ 叫做自变量，自变量取值的范围（数集 $A$）叫做这个函数的 定义域。\n如果自变量取值 $a$，则由法则 $f$ 确定的值 $y$ 称为函数在 $a$ 处的函数值，记作\n$$y = f(a)\\;\\;\\; \\text{或}\\;\\;\\; y\\vert_{x = a},$$所有函数值构成的集合\n$$\\left\\{y\\,\\vert\\,y = f(x),x\\in A\\right\\}$$叫做这个 函数的值域。\n函数 $y = f(x)$ 也经常写作函数 $f$ 或函数 $f(x)$。\n于是，教材做出这样的表述：\n[!REM]\n因为函数的值域被函数的定义域和对应法则完全确定，所以确定一个函数就只需要两个要素：定义域和对应法则。\n其实这个定义几乎就是我们平时谈论问题时会采用的定义。讨论一般问题的时候完全是够用的。不过，它足够精确吗？映射和函数是同一个东西吗？泛函是什么？不过无论如何，高中时的函数的定义绝对不是这个问题的终极答案。而要找这个答案，我们应该从初等数学迈向高等数学2。\n我很难不吐槽新版高中数雪教材 我们来看一看，新的人教版高中数学教材是怎么介绍函数的吧。要看一本书，肯定是首先看它的目录：\n不错，装帧精美，配图有趣，内容也相当详实，是进入新数学语境的好开端。\n我们来看第二章……等一下？啊？\n什么叫先介绍一元二次函数，再介绍函数的概念与性质？\n什么倒反天罡？\nWTF is that?\n我明白初中已经介绍了什么一次函数和二次函数，毕竟考题就考它们。但，是，这也不是能这样倒过来介绍函数这个概念的理由吧！唉，晕了。它上面关于函数的定义我们先不谈了。\n不是我喜欢的教材，直接不参考。\n高等数学中的函数 绝大多数国内高校理工科的同学们应该都是会学 高等数学 的。而这其中，又有许许多多的同学的学校（或者自己）选择的数学教材，是大名鼎鼎的 同济高数，即 高等数学 同济大学数学科学学院编 高等教育出版社（我以为一直是同济大学出版社来着，好像新版换了）。我们取第八版的上册，来看看它是怎么介绍函数的。\n同济高数的函数定义 非常有趣的是，同济高数一上来先确立了“初等数学”与“高等数学”的区别：\n[!REM]\n初等数学的研究对象基本上是不变的量，而高等数学的研究对象则是变动的量。\n以及单刀直入地引入函数，哦不，映射？在其 第一节 映射与函数 中，它首先介绍了二者：\n[!REM]\n映射是现代数学中的一个基本概念，而函数是微积分的研究对象，也是映射的一种。\n哦天哪，我们不是在聊函数吗？怎么跳到映射了？那既然我们讲函数是映射的一种，我们就来看看映射的定义：\n[!DEF]{映射（同济高数）}\n设 $X,Y$ 是两个非空集合，如果存在一个法则 $f$，使得对 $X$ 中每个元素 $x$，按法则 $f$，在 $Y$ 中有唯一确定的元素 $y$ 与之对应，那么称 $f$ 为 $X$ 到 $Y$ 的映射，记作\n$$ f\\colon X\\to Y,$$其中 $y$ 称为元素 $x$（在映射 $f$ 下）的像，并记作 $f(x)$，即\n$$y=f(x),$$而元素 $x$ 称为元素 $y$（在映射 $f$ 下）的一个原像；集合 $X$ 称为映射 $f$ 的定义域，记作 $D_f$，即 $D_f = X$；$X$ 中的所有元素的像所组成的集合称为映射 $f$ 的值域，记作 $R_f$ 或 $f(X)$，即\n$$R_f = f(X) = \\left\\{f(x)\\,\\vert\\, x\\in X\\right\\}.$$ 这个定义怎么感觉和函数很像？那函数，是什么呢？紧接着的定义就定义了函数：\n[!DEF]{函数（同济高数）}\n设数集 $D\\subset \\mathbb{R}$，则称映射 $f\\colon D\\to \\mathbb{R}$ 为定义在 $D$ 上的函数，通常简记为\n$$ y=f(x),\\, x\\in D,$$其中 $x$ 称为自变量，$y$ 称为因变量，$D$ 称为定义域，记作 $D_f$，即 $D_f = D$。\n从这里看，确实函数是一种特殊的映射：它把定义域和，emmm？映射定义中的 $Y$ 是什么？好像不是值域吧？无所谓了，里面的集合全都换成了 $\\mathbb{R}$ 上的东西了。定义域是 $\\mathbb{R}$ 的子集，而箭头另一端就是 $\\mathbb{R}$ 了。而映射呢？映射没管这俩是什么，只要是个集合就没问题。\n这个定义与高中的定义相比，它主要区别在于，这个定义是建立在映射之上的，而不是像高中那样直接给我们一个定义。另外，它 貌似 不再混淆 $y=f(x)$，$f(x)$ 和 $f$ 三者，而是表明 $f$ 是函数，$y = f(x)$ 和 $f(x)$ 是元素 $x$ 的像。为什么说是貌似呢？因为如果我们看映射，它是分清了像以及映射的，如果这套能直接套在函数上的话，那就是分清楚了的；然而，在函数的定义中，我们发现它又一次讲“简记为”。\n最后，同济高数对函数的定义（以及最根本的，映射的定义）中，没有说明一个很重要的问题：映射定义中的 $Y$ 跑哪里了，它是什么？\n然而，不只有同济高数上记载了函数的定义。高数教材那么多，我们再看看别的嘛。\n其他高数教材的函数定义 我从朋友那里拿到了湖南大学用的高等数学教材，即便这本书的名字叫 大学数学，全称为 大学数学系列教材（第四版）大学数学 湖南大学数学学院组编 高等教育出版社，也不妨碍它是用来给一般理工科同学上课。\n大学数学-湖南大学 这本书没有一上来默认一些集合的内容，而是在第一节介绍了什么是集合以及集合的表示法，随后立即介绍了 映射，在第二节才正式引入函数。我们来看看它上面的定义吧：\n[!DEF]{映射（湖大高数）}\n设 $A,B$ 是两个非空集合，若每一个元素 $x\\in A$，按照某种确定的法则 $f$，有唯一确定的 $y\\in B$ 与它相对应，则称 $f$ 为 从$A$ 到 $B$ 的一个映射（如图所示），记作\n$$ f\\colon X\\to Y\\;\\;\\;\\text{或}\\;\\;\\;f\\colon x\\mapsto y = f(x), x\\in A.$$其中，$y$ 称为 $x$ 在映射 $f$ 下的 像，$x$ 称为 $y$ 在映射 $f$ 下的一个 原像（或逆像），$A$ 称为映射 $f$ 的 定义域，记作 $D(f) = A$。$A$ 中所有元素 $x$ 的像 $y$ 的全体所构成的集合称为 $f$ 的 值域，记作 $R(f)$ 或者 $f(A)$，即\n$$ R(f) = f(A) = \\left\\{y\\,\\vert\\,y=f(x),\\, x\\in A\\right\\}$$称集合 $D(f)\\times R(f) = \\left\\{ (x,f(x))\\vert x\\in A \\right\\}$ 为映射 $f$ 的 图形。\n特别有趣的地方在于，湖大这本教材在这些定义结束后立刻提到：\n[!REM]\n根据集合 $A$、$B$ 的不同情况，在不同的数学分支中，“映射”有着不同的术语，如“函数”，“泛函”，“算子”等。\n所以？原来映射有这么多的身份？在不同的分支下，映射的含义是不一样的？我们再来看这本教材对函数的定义：\n[!DEF]{函数（湖大高数）}\n若 $f$ 是数集 $A\\subset \\mathbb{R}$ 到 $\\mathbb{R}$ 的映射，则称 $f$ 为数集 $A$ 上的 一元（实）函数（简称函数），通常把这个函数简记为\n$$ y=f(x),\\,x\\in A.$$$x$ 称为函数的 自变量，$y$ 称为函数的 因变量，$A$ 称为函数的 定义域，通常记为 $D(f)$，而 $f(A) = \\left\\{y\\,\\vert\\,y=f(x),\\, x\\in A\\right\\}$ 称为 $f$ 的 值域，记为 $R(f)$。点集 $C = \\left\\{(x,y)\\,\\vert y = f(x),\\, x\\in D(f) \\right\\}$ 称为 $y = f(x)$ 的图形（或图像）。\n这个定义和前面人教版数学必修一 A 的定义如出一辙……What can I say?\n对比湖大教材的定义以及同济教材的定义，我们发现其实大同小异。湖大教材的特点在于，它积极地运用了一些集合的符号，这也许是由于它第一章先提过了集合的内容的缘故；另外它提到了所谓的图形和图像，它们是 括号包起来的 $x$ 和 $y$，用逗号分隔。\n什么？你说这是图像点？不敢苟同。因为我不知道这个括号的含义具体是什么。虽然确实钻了牛角尖，但是说明湖大的这本教材依旧不够严谨。最重要的是，两本教材都同时有意地忽略了那个在箭头右边的可怜孩子。它到底是什么？\n高等数学-中南大学 令人悲哀的是，我本科时使用的这本书已经找不到了（因为我卖掉了）；更令人悲伤的是，二手书店已经不卖这本书了！我找遍了整个学校的书摊，都没找到，难受……我甚至在校园集市上找过这本书，结果得到的答案却是我早就已经否决的一本：它不是中南大学出版社出版的高等数学，而是高教社出版的高等数学。真是令人心灰意冷，太令人悲哀了。\n然而，得到这本并非我目标的书的同时，我得到了消息：其实这本高教社出版的高等数学，主编就是数统院的副院长，郑洲顺老师。此刻，死去的记忆疯狂攻击我，没错，我的高数教材好像就是他主编的！\n总之，绕了一大弯子，还是搞到了换皮版的 高等数学（上册）郑洲顺主编 任叶庆副主编 高等教育出版社。我们就当它是中南大学出版社出版的吧。废话太多了，我们直接看定义：\n[!DEF]{函数（中南高数）}\n设 $D$ 为非空实数集，若存在对应法则 $f$，使得对于任意的 $x\\in D$，按照对应法则 $f$，总有唯一确定的 $y\\in\\mathbb{R}$ 与之对应，则称 $f$ 为定义在 $D$ 上的一个 函数。通常简记为\n$$ y = f(x), x\\in D,\\ \\text{或}\\ f\\colon x\\mapsto y = f(x), x\\in D, $$其中 $x$ 称为 自变量，$y$ 称为 因变量，$D$ 称为 定义域，记作 $D(f)$，即 $D(f) = D$。\n在函数定义中，对每个 $x\\in D$，按对应法则 $f$，总有唯一确定的值 $y$ 与之对应，这个称为函数 $f$ 在 $x$ 处的 函数值，记作 $f(x)$，即 $y = f(x)$。因变量 $y$ 与自变量 $x$ 之间的这种依赖关系通常称为函数关系。函数值 $f(x)$ 的全体所构成的集合称为函数 $f$ 的值域，记作 $R(f)$ 或 $f(D)$，即\n$$R(f) = f(D) = \\left\\{y\\,\\vert\\,y=f(x),\\, x\\in D\\right\\}.$$ 其实记号有点凌乱……特别是简记的部分。同时，它没有先引入映射，而是直接将函数定义为实数集子集内的元素到实数集内的元素二者的对应关系。另外的部分也是大同小异，不过好处在于，没有用什么幽灵一般的箭头右边的东西，直接就说是非空实数集内的元素到实数集内的元素，绕过了这个问题，也算简洁明快吧。\n对了，除了国内教材，国外也应该学高数吧？没错，国外也学这些内容，不过一般叫它 Calculus，即 微积分。我们看看它们的情况：\n国外教材的函数定义 首先我们来看看广受赞誉的 普林斯顿微积分读本。它的英文其实是 The Calclus Lifesaver: All the Tools You Need to Excel at Calculus，并没有“普林斯顿”这个地名。\nAnyway, 我们来看看它怎么处理这些定义。鉴于这本书原版就是英文的，且我相信大家应该能看懂英文吧（也许吧），我就直接贴上英文原文内容了。\nThe Calculus Lifesaver 它开门见山，在几句介绍性的文字后，立刻给出了函数的……定义？\n[!DEF]{Function (The Calculus Lifesaver)}\nA function is a rule for transforming an object into another object. The object you start ith is called the input, and comes from some set called the domain. What you get back is called the output; it comes from some set called the codomain.\nEmmm，怎么说呢？非常的口语化。我很难说这个是“定义”，也许这是因为这本书本就是为了这样慢慢地读下去，用平易近人的方式来理解这些概念。不过我们依旧可以从中提取出一些不一样的东西：什么是 codomain？从它的说法中，好像 codomain 就是我们说的值域，因为给一个 $x$ 就会得到来自它的 $y$；然而，事情不是这样的，因为旋即它给出 range 的定义：\n[!DEF]{Range (The Calculus Lifesaver)}\nThe range is the set of all outputs that could possibly occur.\n紧接着有这样的描述：\n[!REM]{Codomain and Range}\nSo why isn\u0026rsquo;t the range the same thing as the codomain? Well, the range is actually a subset of the codomain. The codomain is a set of possible outputs, while the range is the set of actual outputs.\nThat explains all. Codomain 不是值域。值域是 range 才对，而 codomain 是元素对应函数值可能出现的一个大的集合，也就是说，值域是 codomain 的子集。我们这里正式揭晓所谓的 codomain 的中文翻译：陪域。\n虽然很口语化，但是它解决了我们之前没有解决的问题！而且，它这样口语化的内容，貌似隐藏了一些细节……先不管了。那么，其他教材呢？我们来看看群友在加拿大使用的一本微积分教材：由 Hughes-Hallett 主编的 Calculus: Single and Multivariable。\nCalculus: Single and Multivariable 这本书更是直接，第一句话就讲了函数是什么：\n[!REM]\nIn mathematics, a function is used to represent the dependence of one quantity upon another.\n且立刻就有了一个看起来很顺眼的定义：\n[!DEF]{Function (Calculus: Single and Multivariable)}\nA function is a rule that takes certain numbers as inputs and assigns to each a deﬁnite output number. The set of all input numbers is called the domain of the function and the set of resulting output numbers is called the range of the function.\nThe input is called the independent variable and the output is called the dependent variable.\n令人失望的是，这个定义没有提到陪域，而是像国内教材一样使用了定义域以及值域的方式。不过嘛，反倒是显得简洁。另外，在这部教材中，没有找到 codomain 的字眼，这一点是有点令人失望的。然而值得一提的是，上面介绍了陪域的普林斯顿微积分读本，依旧没有过多使用 codomain 一词，全文中只出现了约 5 次而已。然而我们还可以看看另一本鼎鼎大名的 Thomas\u0026rsquo; Calculus，由 George B. Thomas, Jr 所著，算是美式教材中的顶流之一了。\nThomas\u0026rsquo; Calculus 依旧是熟悉的函数开场，熟悉的先介绍一下函数的地位：\n[!REM]\nFunctions are fundamental to the study of calculus. \u0026hellip;\nFunctions are a tool for describing the real world in mathematical terms. \u0026hellip;\n逼格拉满了（复数 + are + a tool 这样的单数真的没问题吗（）），那么定义是什么呢？\n[!DEF]{Function (Thomas\u0026amp;rsquo; Calculus, full version)}\n\u0026hellip; the value of one variable quantity, say $y$, depends on the value of another variable quantity, which we often call $x$. We say that \u0026ldquo;$y$ is a function of $x$\u0026rdquo; and write this symbolically as\n$$ y = f(x) \\ \\ \\ \\text{(\"$y$ equals f of $x$\")} $$The symbol $f$ represents the function, the letter $x$ is the independent variable representing the input value to $f$, and $y$ is the dependent variable or output value of $f$ at $x$.\n（下一行是正式的定义 —— AMoment 留）\nA function $f$ from a set $D$ to a set $Y$ is a rule that assigns a unique value $f(x)$ in $Y$ to each $x$ in $D$.\nThe set $D$ of all possible input values is called the domain of the function. The set of all output values of $f(x)$ as $x$ varies throughout $D$ is called the range of the function.\n紧接前面的关于值域的内容（就在同一段）是关于值域与 $Y$ 以及函数这一概念的说明：\n[!REM]\nThe range might not include every element in the set Y. The domain and range of a function can be any sets of objects, but often in calculus they are sets of real numbers interpreted as points of a coordinate line.\n它依旧没有提多少陪域的事情，只是让幽灵一般的 $Y$ 出现了一下，并说明值域并不总是有所有 $Y$ 里面的元素。然而，有趣的地方来了：它的函数的定义没有把定义域和幽灵 $Y$ 限定在数域上，而是后面补充了说在微积分中函数一般是坐标轴上的实数。所以，它的函数是我们在国内高数教材中的 映射！我们再回看那本 The Calculus Lifesaver，它没有说 the object 是什么！只说了 input 和 output 是来自于两个 集合：domain 与 codomain，所以，The Calculus Lifesaver 的函数定义也是 偏向 映射的。\n看了这么多美国教材，我们再看看英国的教材吧\n英国的教材，吗？ ……哦不，我只有一篇朋友给的讲义可以参考。而且，这位授课的 Theodore Voronov. 还是一位莫斯科国立毕业的俄国人……Anyway，我们可以看看，他在讲义中怎么定义函数：\n[!DEF]{Functional dependence (From Prof. Theodore Voronov.)}\nIn many cases application of mathematics to natural science and engineering deals with analyzing functional dependence. What is a functional dependence? We have one varying quantity described by a variable, say, $x$, and another quantity described by a variable, say, $y$, so that to each value of the first quantity, $x$, by a certain given law there corresponds a particular value (which should be uniquely defined) of the second quantity, $y$. We write this as\n$$ x\\mapsto y \\ \\ \\text{or} \\ \\ y = f(x) \\ \\ or \\ \\ y = y(x) $$(here the letter $f$ is used to express a functional law by which y depends on $x$; but very often we simply write $y = y(x)$). The variable $x$ is called independent variable or the argument of the function. Respectively, $y$ is called dependent variable and $y = y(x)$ is called the value of the function (corresponding to a given value $x$ of the argument).\n与其说是给出了函数的定义，不如说这里描述了什么是 函数关系。鉴于此，我们得以看到也许更高的视角：这也许是一种普遍存在的关系，而非某个特定的事物（函数、映射）才能携带有的。另外我们可以看到，这里的记号相对比较随意：$y$ 既可以等于 $f(x)$，又可以等于 $y(x)$，都表达一种函数关系。我不评价这种记号的好坏，但是我不喜欢就是了。\n竟然还有日本的高中教材 没错，我莫名收藏了几本翻译的日本高中数学教材，原作者是著名日本数学家小平邦彦，采用的版本是 数学I 小平邦彦编 吉林人民出版社。至于它为什么出现在高等数学这门大学课程的教材集合里，我想也许你可以问问美国的微积分教材（）我们也来看看是怎么写的吧：\n[!DEF]{函数（日本高中教材）}\n设 $X$ 与 $Y$ 是数的集合，使 $X$ 的各个元素分别对应 $Y$ 的唯一的一个元素 $y$，这个对应叫做由 $X$ 到 $Y$ 的 函数。函数可用字母 $f$，$g$ 等表示。\n当 $f$ 是由 $X$ 到 $Y$ 的函数时，按 $f$ 使 $X$ 的元素 $x$ 所对应的 $Y$ 的元素 $y$，叫做 $f$ 在 $x$ 的值，写作\n$$ y = f(x)$$并且，$X$ 叫做函数 $f$ 的定义域。$f$ 的所有值 $f(x)$ 的集合\n$$\\left\\{f(x)\\ \\vert\\ x \\in X\\right\\} $$叫做 $f$ 的值域。\n一般地，$y = f(x)$ 则说 $y$ 是 $x$ 的函数。$x$ 叫 自变数，$y$ 叫做 因变数。\n也许是由于年代的原因，一些翻译，比如因变量，自变量，采用了不怎么寻常的翻译方式。另外这本书是直接跳过了映射，指明了函数是从数集到数集的一种对应，也不失为一种好的处理方法。最后，日本原来在高中第一本书就会学函数吗（）\n小结 相信看了这么多的高等数学教材中的函数定义，你一定累坏了：这些定义没有一个非常好地说明我们提出的所有问题，必须把它们组合起来，才能回答：\n函数是什么，记号怎么写； 映射是什么，映射和函数是什么关系； 那个神秘的，箭头右边的集合/数集，到底是什么东西。 这个问题的根源，也许是这些教材都是 高等数学 教材。它们不关心别的用处，只关心在本教材中使用的情况。我们可以看到，上面第三个问题鲜少有教材提及，这一点其实相当诡异：大家都知道那里有个东西，大家都当作没看见。然而这一点也是无可厚非的：我们在学习这些教材的过程中，几乎不需要用到这个房间中的大象。\n那么，既然普通理工科大学生学习的 高等数学 里的定义并不能让我们满意，也许我们应该将目光投向数学系的教材，看看数学系是怎么处理函数这个数学研究中近乎最最基础的概念的。我们首先从距离高等数学并不遥远的 数学分析 开始。\n数学分析中的函数 我知道的数学分析的教材相比高等数学而言真是多太多了。这里我们挑选这么几本来聊：\n数学分析讲义 - 陈天权 数学分析 - 梅加强 数学分析教程 - 常庚哲 史济怀 数学分析 - 陈纪修 数学分析之课程讲义 - 于品 Mathematical Analysis I - Vladimir A. Zorich Principles of Mathematical Analysis - Walter Rudin Analysis - Terence Tao Roger Godement - Analysis I Convergence, Elementary functions 其中，前四本书是国内广泛使用的教材；第五本其实算是讲义，但写的很全，我们也来参考；后几本则是国外的数学分析教材：大名鼎鼎的卓里奇，鲁丁，陶哲轩，戈德门特，分别代表俄、美、澳、法四个国家的数学教材。由于这些书名都太像了，全都是什么什么分析，且其作者都十分有名（几乎就是书的代名词），所以我们在书没有别名的情况下采用老师的姓名；有别名的情况下则使用更轻松幽默的别名。最后要提到的是，由于函数和映射的关联性，我们把映射也一并加入考察的对象。\n国内教材中的定义 我们首先指出，绝大多数的数学分析教材，都是要谈一个基本的问题的：实数如何构造。有一些书会选择放在第一章，而有的书会放在第二章，目的是在第一章先介绍一些更基础的概念，方便后续使用。比如我们下面的这本由北京大学陈天权老师编著的数学分析讲义，就采用了后聊实数的方式。我们得以直接先来看看我们最关心的函数的定义。\n陈天权 在简单介绍集合的基本情况之后，我们首先看到的即为映射的定义：\n[!DEF]{映射（陈天权）}\n从集合 $A$ 到集合 $B$ 的 映射 $\\varphi$ 是指一个规则，根据它，每一个元素 $x \\in A$ 有一个元素 $y \\in B$ 与之对应。常用以下两个记法中的任一个表示这样一个映射（对应关系）：\n$$ y = f(x) $$或\n$$ \\varphi\\ \\colon A\\to B,\\ \\varphi\\ \\colon x\\mapsto y.$$$A$ 称为映射 $\\varphi$ 的 定义域。$B$ 称为 $\\varphi$ 的 目标域。$A$ 中的元 $x$ 称为映射的 自变量，$y$ 称为 因变量。当映射的目标域是 $\\mathbb{C}$ 时，映射也常称为 函数。有时（如在代数或几何中），映射也称为 变换。映射或函数或变换常记作 $\\varphi$，有时也记作 $\\varphi(x)$。\n幻视数学原神了（）我们不写像、值域、原像之类的定义了。可以看到，这里直面那个我们在高等数学中感到躲躲闪闪的那个概念：那个奇怪的幽灵名字叫做 目标域。可惜这里没有使用 陪域 这个名称，不过目标域也是非常贴切了。而且，这里采用了 目标域是复数域时我们称映射为函数 的说法。这和我们以前看过的每一个都不一样：它只限定了映射的目标域，不考虑定义域；它还直接把目标域限制到了复数域，这从某种角度确实是没问题的，但是也是相当大胆的（）只可惜我没有仔细看过这本书，相信这本书一定也是非常精彩。\n让我们继续吧，看看另一本著名的分析学教材，由南京大学梅加强老师编著的数学分析，在2020年它出了第二版。\n梅加强 咚咚咚：\n[!REM]{第一章 引言 的引言}\n本章我们简要介绍分析学中的常用方法和常用概念。一些基本概念，比如集合与映射，大家已经在中学课程中学过，在此我们就不再详细介绍。……\n什……竟然，这样吗？然而好消息是，我们有梅老师的另一本书，或者说讲义：数学分析讲义。这本 2006 到 2010 年间编成的书中一板一眼地给出了映射的定义：\n[!DEF]{映射（梅加强）}\n设 $X$, $Y$ 为集合. 如果对于每一个元素 $x \\in X$, 都有 $Y$ 中 惟一元素 $y$ 与之对应, 则称这种对应关系为从 $X$ 到 $Y$ 的一个映射, 记为\n$$f\\ \\colon X\\to Y,\\ \\ y = f(x),$$或\n$$f\\ \\colon X\\to Y,\\ \\ x\\mapsto f(x),$$我们将 $y = f(x)$ 称为 $x$ 在 $f$ 下的象, 而将 $x$ 称为 $y$ 的一个原象或逆象. 集合 $X$ 称为映射 $f$ 的定义域, $f$ 的象的全体组成的集合 $f(X)$ 是 $Y$ 的子集, 称为 $f$ 的值域, 即\n$$f(X) = \\left\\{ f(x)\\ \\vert\\ x\\in X \\right\\}. $$ 以及一个注解：\n[!REM]{映射定义后的注解}\n映射有时也称为 函数, 特别是当 $Y \\subset \\mathbb{R}$ 是数集时更是如此. 通常也把映射写为 $y = f(x)$ 或 $f(x)$, 这时 $x$ 也称为变量或自变量, $y$ 也称为因变量. 如果 $X, Y \\subset \\mathbb{R}$ 均为数集, 映射 $f \\colon\\ X \\to Y$ 也称为一元函数或一元实值函数或一元实变函数.\n我们首先注意到一个有趣的地方：梅老师采用 象 而非我们前面看到的更多的 像。我承认这个写法是确实存在的，但我也不知道这样写的原因是什么。不过，我们也算是有现代通假字了吧，哈哈哈。回归正题，这里映射的定义中规中矩，不过记号上写的有点瘸腿（$Y$ 中的唯一元素 $y$，没有用逻辑记号），且很可惜没有提到 $Y$ 的名称。然而这里在注解中提到的函数的说法又不一样了：映射就是函数，在 $Y$（我们知道是陪域/目标域）是实数集的子集时则 更是如此，而如果 $X$ 和 $Y$ 都是实数的子集是就直接称为 一元函数 或 一元实值函数 或 一元实变函数。感觉像是误入 BOSS 房，怎么实变函数突然出现了？不过也能理解，这是从三个方面聊这个函数：只描述变元数量、强调陪域、强调定义域。\n那么，由中科大常庚哲和史济怀老师编著的数学分析教程，是怎么介绍这些概念的呢？\n史济怀 我必须首先提到，这本书即便有两个老师参与编著，然而大家都还是很喜欢叫这本书为史济怀。因此我们这里还是用大家更常用的叫法了。另外这本书是比较流行的一本教材，但这本书的风评又比较一般：据说有一些错漏，一些定义不够严谨。又但是，这本书的优点是习题质量很高，且史济怀老师在讲课时会提到哪些地方有错漏不严格（B站有网课）。鉴于这本书真的好多人用，我们很难不参考它对函数的定义。\n[!DEF]{映射（史济怀）}\n设 $A$，$B$ 是两个集合，如果 $f$ 是一种规律，使得对 $A$ 中的每一个元素 $x$，$B$ 中有唯一确定的元素——记为 $f(x)$——与 $x$ 对应，则称 $f$ 是一个从 $A$ 到 $B$ 的 映射，用\n$$ f\\colon\\ A\\to B$$来表示。集合 $A$ 叫做映射 $f$ 的 定义域；$f(x)\\in B$ 叫做 $x$ 在映射 $f$ 之下的 像 或 $f$ 在 $x$ 上的 值。\n很可惜，它的定义显得有点苍白了。我也查看了后面的内容，依旧没有谈到陪域的事。而函数的部分呢？\n[!REM]{函数}\n函数是一类特殊的映射。如果对映射 $f\\colon\\ X\\to Y$，$X$ 与 $Y$ 都由实数组成，则称 $f$ 为一个函数。简而言之，函数是从实数到实数的映射。说得更精确一点，$f$ 是单变量函数。\n我个人不是很喜欢 $X$ 与 $Y$ 都由实数组成 这样的说法。既然已经介绍了基本的集合论记号，为什么不用子集明确地表示出来呢？这样口语化的表述给我一种印象：$X$ 和 $Y$ 都是实数集。希望视频教程能弥补这些问题吧。\n下一本我们来看看复旦大学陈纪修老师编著的数学分析教材：\n陈纪修 介绍一些集合的内容之后，立刻我们就拿到了映射与函数的定义：\n[!DEF]{映射（陈纪修）}\n设 $X$，$Y$ 是两个给定的集合，若按照某种规则 $f$，使得对集合 $X$ 中的每一个元素 $x$，都可以找到集合 $Y$ 中惟一确定的元素 $y$ 与之对应，则称这个对应规则 $f$ 是集合 $X$ 到 集合 $Y$ 的一个 映射，记为\n$$\\begin{align*}f\\colon\\ \u0026X\\to Y \\\\ \u0026x\\mapsto y = f(x)\\end{align*}.$$其中 $y$ 称为在映射 $f$ 之下 $x$ 的 像，$x$ 称为在映射 $f$ 之下 $y$ 的一个 逆像（也称为 原像）。集合 $X$ 称为映射 $f$ 的 定义域，记为 $D_f$。而在映射 $f$ 之下，$X$ 中元素 $x$ 的像 $y$ 的全体称为映射 $f$ 的 值域，记为 $R_f$，即\n$$R_f = \\left\\{ y\\ \\vert\\ y\\in Y\\ \\ \\text{并且}\\ \\ y = f(x),\\ x\\in X \\right\\}.$$ 依旧没有提到陪域，令人失望。然而我很喜欢这里映射的记法：确定好映射的定义域与陪域之后，再确定好如何把变元 $x$ 映射到其像上。对了，其函数的定义为：\n[!DEF]{一元实函数（陈纪修}\n若在定义 1.2.1（映射定义）中特殊地取集合 $X\\subset\\mathbb{R}$，集合 $Y = \\mathbb{R}$，则映射\n$$\\begin{align*}f\\colon\\ \u0026X\\to Y \\\\ \u0026x\\mapsto y = f(x)\\end{align*}.$$称为 一元实函数，简称 函数。由于函数表示的必是实数集合与实数集合之间的对应关系，所以在其映射表示中，第一行是不需要的，只要写成\n$$ y = f(x), x\\in X(=D_f)$$ 就可以了，读作“函数 y= f(x)”或“函数 f”。这里 $f$ 表示一种对应规则，对于每一个 $x\\in D_f$，它确定了惟一的 $y=f(x)\\in \\mathbb{R}$ 与 $x$ 相对应。\n我们可以看到这里是直接将函数限定到一元的实函数上了。这样也许也无可厚非，毕竟根据使用需要来选择合适的定义也没什么问题。有趣的是，陈老师特别介绍了记号的读法，且这里出现了这样的记号：$y = f(x) \\in \\mathbb{R}$，这个记号表示右侧的 $f(x)$ 是实数集的元素。这个记号初读可能感觉一头雾水，然而只需要把 $f(x)\\in\\mathbb{R}$ 看作一个整体即可。\n最后我们撇一眼清华大学于品老师的数学分析讲义中怎么处理这些定义的。\n于品 坏消息，于品老师的这本讲义，不聊映射和函数这种太基础的东西。她从实数公理体系开讲，从代数的方法定义什么是域，这个过程中就非常自然地用上了函数和映射这两个概念了。应该说不愧是清华的讲义吗？然而我们依旧可以从里面观察一下，于品老师的讲义中是怎么处理映射和函数两个概念的。\n通过一些搜索，我们可以看到这本讲义中对“函数”一词的使用主要集中在幂函数、距离函数、指数函数、三角函数等词汇上，而映射则是什么集合都会用到。因此，映射的定义应该还是和我们早已熟悉的定义是相同的，而函数还是让它有特殊的陪域（或许也限定了定义域）。我们就不再多聊这本书中的内容了，非常现代化的表述，挺吓人的。\n品鉴完了国内的材料，我们来试试国外的这几本书，看看它们又是怎么处理这个问题的。\n国外教材中的定义 我们先来看看“俄罗斯最好的数学分析教材”，Zorich：\nZorich Zorich 在介绍完集合后，函数部分直接给出的是 The concpet of a Function (Mapping)，即“函数和映射的概念”。似乎，它直接把函数和映射直接联系起来了：\n[!DEF]{Function (Zorich)}\nLet $X$ and $Y$ be certain sets. We say that there is a function defined on $X$ with values in $Y$ if, by virtue of some rule $f$ , to each element $x \\in X$ there corresponds an element $y \\in Y$.\nIn this case the set $X$ is called the domain of definition of the function. The symbol $x$ used to denote a general element of the domain is called the argument of the function, or the independent variable. The element $y_0 \\in Y$ corresponding to a particular value $x_0 \\in X$ of the argument $x$ is called the value of the function at $x_0$, or the value of the function at the value $x = x_0$ of its argument, and is denoted $f (x_0)$. As the argument $x \\in X$ varies, the value $y = f (x) \\in Y$, in general, varies depending on the values of $x$. For that reason, the quantity $y = f (x)$ is often called the dependent variable.\nThe set\n$$ f(X)\\ \\coloneqq \\left\\{ y\\in Y \\ \\vert\\ \\exist x \\ \\left( (x\\in X) \\land (y = f(x)) \\right) \\right\\} $$of values assumed by a function on elements of the set $X$ will be called the set of values or the range of the function.\n它给出的是函数的定义诶！并没有给映射的定义，但是真的如此吗？仔细观察这个定义，我们能发现，这里定义的 函数 貌似应该是我们前面经常谈的那个 映射 才对。另外，我们可以看到这里采用了逻辑谓词：那个倒过来的 E 其实是存在的意思，而那个小帽子则是所谓的 且。这个集合的元素筛选条件就是：存在一个 $x$，它是 $X$ 中的元素的同时，把 $f(x)$ 的结果记为 $y$。这一点实在是非常新颖。不过，坏消息是这里的定义依旧没有提到 codomain，它依然没有明说这里的 $Y$ 是什么。\n有趣的是，它后面的内容像是剧透一样，给了我们很多新颖的东西，并指出它们都是这里定义的函数，只是领域与关心的具体集合不一样：\n[!REM]\nThe term “function” has a variety of useful synonyms in different areas of mathematics, depending on the nature of the sets $X$ and $Y$: mapping, transformation, morphism, operator, functional. The commonest is mapping, and we shall also use it frequently.\nFor a function (mapping) the following notations are standard:\n$$ f\\ \\colon\\ X\\to Y,\\ \\ \\ X \\xrightarrow[]{f} Y.$$When it is clear from the context what the domain and range of a function are, one also uses the notation $x \\mapsto f (x)$ or $y = f (x)$, but more frequently a function in general is simply denoted by the single symbol $f$.\n天哪，这么多奇怪的名词。我们干脆就在这里把它们的翻译给出来吧，它们分别是：mapping - 映射；transformation - 变换；morphism - 态射；operator - 算子/算符；functional：泛函。这里有点剧透了：它已经告诉了我们，许多概念背后的思想是一致的。它也提到了我们通常会用很多不同的记号来表达一个函数。\n总的来说，Zorich 对函数的定义给我们最大的启发来自于两点：使用逻辑谓词的集合，以及剧透般地阐述了诸多概念背后的关联。那么，同样大名鼎鼎的 Baby Rudin 又是怎么定义函数的呢？\nBaby Rudin 为什么这本 Principles of Mathemattical Analysis 会被称为 Baby Rudin 呢？其实是因为 Rudin 老爷子写了三本分析学的教材，一本就是这本我们要参考的书，另一本是 Real and Complex Analysis，最后一本则是 Functional Analysis。而又因为这三本书逐级递进的难度，它们便被冠以 Baby, Papa 和 Grandpa Rudin 的名号。\n虽说难度中 Baby Rudin 是最简单的，然而看完这本我的评价是并非简单。作为我第一本读完的数学教材，Rudin 的内容引入很有趣：他先介绍序结构，紧接着介绍代数域的概念，最后通过介绍的这两个东西引入了实数域（以及实数扩域）和复数域到欧氏空间。这个顺序我在初读时是没有想到的，然而更神奇的是，上面的这一切都没有依赖某种函数/映射关系。也正因如此，直到第二章它才引入了函数的概念，而这一章的内容，叫做拓扑。（更神奇的是，通篇在谈拓扑，一整本书只有目录有 Topology）\n那么它上面怎么定义的函数呢？\n[!DEF]{Function (Baby Rudin)}\nConsider two sets $A$ and $B$, whose elements may be any objects whatsoever, and suppose that with each element $x$ of $A$ there is associated, in some manner, an element of $B$, which we denote by $f(x)$. Then $f$ is said to be a function from $A$ to $B$ (or a mapping of $A$ into $B$). The set $A$ is called the domain of $f$ (we also say $f$ is defined on $A$), and the elements $f(x)$ are called the values of $f$. The set of all values off is called the range of $f$.\n它貌似和 Zorich 一样，采用了 function = mapping 的说法，且这本书并没有用形式化的语言来叙述这个定义，也许这就是美式教材的魅力吧。同样令人失望的是，它没有说明 $B$ 是什么。怎么说呢，多少还是有点点失望的。不过没有关系，我们继续前进，看看另一本著名分析教材：Terence Tao（陶哲轩）的教材怎么定义的函数。\nTerence Tao 虽然没有看过这本书，但是它的目录很吸引人：第一章首先介绍的不是什么“集合”，“数系”等内容，而是一个 \u0026ldquo;Introduction\u0026rdquo;，两个小节，\u0026ldquo;What is analysis?\u0026rdquo; 与 \u0026ldquo;Why do analysis\u0026rdquo;。我感觉我有必要抽时间看这个部分，一定会很有趣。而它在第二章也没有直接开始我们观念上很分析的内容，而是先介绍了所谓的 Peano axioms（皮亚诺公理），以及加法和乘法，明摆着是打算从自然数一步步走到实数系。终于，它在第三章谈集合论，在里面谈到了函数。首先，它先从观念上谈了函数是什么东西：\n[!REM]{Function}\nIn order to do analysis, it is not particularly useful to just have the notion of a set; we also need the notion of a function from one set to another. Informally, a function $f \\colon X \\to Y$ from one set $X$ to another set $Y$ is an operation which assigns to each element (or “input”) $x$ in $X$, a single element (or “output”) $f(x)$ in $Y$; we have already used this informal concept in the previous chapter when we discussed the natural numbers.\n哈，看来已经在自然数那里使用过这样不正式的定义了。不过从这儿也能看到，这本书也是走的 function = mapping 的路数，因为你可以看到，函数是从集合到集合的。不过下面就是正式定义了，我们来看一下：\n[!DEF]{Function (Terence Tao)}\nLet $X$, $Y$ be sets, and let $P(x, y)$ be a property pertaining to an object $x \\in X$ and an object $y \\in Y$ , such that for every $x \\in X$, there is exactly one $y \\in Y$ for which $P(x, y)$ is true (this is sometimes known as the vertical line test). Then we define the function $f \\colon X \\to Y$ defined by $P$ on the domain $X$ and range $Y$ to be the object which, given any input $x \\in X$, assigns an output $f(x) \\in Y$, defined to be the unique object $f(x)$ for which $P(x, f(x))$ is true. Thus, for any $x \\in X$ and $y \\in Y$,\n$$y = f(x) \\iff P(x, y)\\text{ is true.}$$ 它的定义很有趣，使用了一个我们此前没有用到的概念：\u0026ldquo;property\u0026rdquo;。它依旧从两个集合中取元素，随后要求 $P$ 是这样一个性质：每一个 $x\\in X$ 都得有一个唯一的 $y\\in Y$ 使得 $P(x,y)$ 这样的性质为真，且叫它“垂线测试”。最后，它让函数成为一个数学对象，它可以接收一个 $x \\in X$，结果在 $Y$ 中，然后让这个对象与 $x$ 和 $f(x)$ 能令 $P(x,f(x))$ 为真。这个定义还用一个表达式表达出来了。莫名有种脱裤子放屁的感觉，不过也是很好地体现了函数作为 数学对象 的特点：它不止是一种关系，更是一个可操作的数学对象。\n另外令人欣喜的是，它把那个 $Y$ 也给出来了。在这里它叫……range？啊？为什么是“值域”的单词？那这样的话，值域怎么办？貌似它放弃了值域这个概念，转而直接使用了 image（像）。从后续 Onto functions 的定义也能看出，似乎它确实是选择了使用 range 作为那个神秘集合的名字。虽然说可能和我们预期的不一样，且貌似大多数数学家没有把 range 用在这个概念上，但是这本书里的内容是自洽的，这也就够了。另外，它最后也同样有一个注解：\n[!REM]\nFunctions are also referred to as maps or transformations, depending on the context. They are also sometimes called morphisms, although to be more precise, a morphism refers to a more general class of object, which may or may not correspond to actual functions, depending on the context.\n很明确，它也是持 function = mapping 的观点的，只不过这里没有使用 mapping 而是使用了 map。可能澳洲是喜欢这么讲吧，但 map 一般用作动词表示映到，而 mapping 则是动名词形式，意为映射。嘛，没什么大问题。那就让我们最后来看一下法国数学家戈德门特的这本教材吧。\nGodement 我在我自己的书库里找这个人名时，只找到了一本 代数学教程，且是由国内老师翻译的；而我是怎么知道他写的分析教材呢？我是从清华大学刘思齐老师（他有自己的B站主页：我真的不懂分析）的视频 如何选择一本适合你的《数学分析》教科书？北京某高校数学老师为你揭示选书的秘密 中得知的。其实，前面选的一些教材，是从这个视频里了解到的，就包括这次选择的 Godement。考虑到我找到的是英译本，且我不会法语，我就还是以这本英译本为参考吧。\n从目录上看，这本书首先介绍的是集合论，紧接着就是函数了。然而它的语言，说实话比较长：它不只是定义了函数，它甚至给了一些函数这个定义发展的历史脉络。我们把它的描述放在下面，为了方便阅读我做了折叠，然后在下面进行了一些总结。不想看可以跳过这些文字，说实在的，确实挺长的。\n函数的定义：Godement The concept of the cartesian product allows one to introduce the general concept of a function or map, which is as fundamental as that of a set and which, as we shall see, reduces to it as do all others. In elementary education and in the whole history of mathematics up to the beginning of the XIXth century, a function was given by a \u0026ldquo;formula\u0026rdquo; such as $f (x ) = x^2 - 3$, $f (x) = \\sin x$, etc., but starting with Descartes one often also defined a function from a curve whose \u0026ldquo;equation\u0026rdquo; one sought. For experimental scientists and engineers a function is very often also given by its graph, the geometrical locus of those points $(x , y)$ in the plane such that $y = f( x)$ for a function $f$ which, quite often, one does not really know.\nStarting with the XIXth century the concept of a function ceased to be associated with a simple or complicated \u0026ldquo;formula\u0026rdquo;; the German Dirichlet for example speaks of the function equal to 0 if $x$ is a rational number and to 1 if $x$ is irrational, and one later envisaged much stranger functions, until the general and abstract concept emerged of a function defined on a set $X$ and having values in a set $Y$; such a function $f$ associates to every $x \\in X$ a well determined $y = f (x) \\in Y$ depending on $x$ according to a precise rule. The graph of $f$ is then the set of ordered pairs $(x , y) \\in X \\times Y$ such that $y = f (x)$ for every $x \\in X$. One encounters this in everyday life: if, in a monogamous society, one denotes by $H$ the set of married men and by $F$ the set of women, the relation \u0026ldquo;$y$ is the wife of $x$\u0026rdquo; is a function with values in $F$ defined on $H$. Its graph is clearly a set of \u0026hellip; couples.\nConversely, a subset $G$ of $X \\times Y$ is the graph of a function $f$ provided that $G$ has the following property: for every $x \\in X$ there exists one, and only one, $y \\in Y$ such that $(x , y) \\in G$; and then one writes $y = f( x)$ . This convention allows one to reduce the concept of a function to that of a set: by definition a function defined on X with values in $Y$ is a subset of $X \\times Y$ subject to the preceding condition; no longer is there a \u0026ldquo;formula\u0026rdquo;.\n它从笛卡尔积出发构建的函数的概念，先是介绍了十九世纪时人们对函数的定义停留在要有一个表达式，或者工程师们需要一个图像；随后德国数学家狄利克雷所定义的奇怪函数：在有理点取 0 而在无理点取 1 的函数（所谓狄利克雷函数）让人们重新思考函数的概念，确定为了要让一个集合中的 $x$ 有另一个集合的唯一对应。而利用笛卡尔积，我们则能构建出函数的图像：笛卡尔积 $X \\times Y$ 的一个子集，其元素（有序对）的第一个分量 $x\\in X$ 有唯一确定的一个 $Y$ 中的 $y$ 与之对应。实在是非常巧妙的做法：通过对函数图像的刻画同时反向地描述了函数应该怎么样定义。\n即便此前从未看过这本书，这段文字依旧非常吸引我。而我也大概看了看后续的内容，非常地细致入微，介绍了很多符号以及概念上的细节，包括“如果这个对应关系不唯一，那会怎么样”这样的问题。另外它也是声称 function = map 的，这从后面表述函数的记法与叫法能看出。可惜的是，它并没有指出 $X$ 或 $Y$ 有什么特殊的名称：函数就是单纯地 定义在 $X$ 上，且 其值在 $Y$ 里。\n鉴于 Godement 也有一本 代数学教程，我们可以在稍后的代数学部分也看看，他是怎样在代数学中定义函数的。\n小结 看完这里这些微积分、数学分析中对函数的定义，我们可以看到，它们主要持两种态度。第一种是国内教材所普遍使用的，比较精细的划分：映射是从集合到集合上的一种对应关系，而函数则是要把映射的或陪域，或定义域与陪域一起限定在某个数域上（实数或者复数）。可惜的是，国内教材大多都没有提及“陪域”这个词，反倒是国外的一些教材有提及这个在箭头右侧的集合的名称。而第二种态度，恰恰是国外教材普遍采用的，不那么精细的说法：函数就是映射，映射也就是函数。函数甚至可以是别的很多（更多）的东西：态射、变换、算符、泛函等等。不过在定义的细节上，几本教材似乎有所分歧：有的就和国内教材的差不多，比如 Zorich 和 Baby Rudin；Terence Tao 使用了关系-函数，而 Godement 最独特，使用了图像-函数的定义方法。\n然而不可否认的是，这两种定义都首先表示：函数一定是从集合到集合的一种对应关系。而如果您了解过代数学，特别是抽象代数，就一定明白，代数学一大特点就在于研究数学对象之间的关系。而函数，或者说，这种集合到集合之间的关系，也不可避免地成为代数学的基础研究对象之一。接下来，我们就看看代数学中都是怎么介绍函数的吧。\n代数学中的函数 由于代数学一般都从抽象代数/近世代数这些数学专业修习的专业课开始，我们就不需要再看普通理工科学生需要学习的内容了。这里我们选择的教材依旧从国内选择几本，再从国外选择若干本，尽可能涵盖较多的国家，领略一下各个国家的风格。这里我们选择这些书：\n丘维声 - 高等代数 姚慕生，吴泉水，谢启鸿 - 高等代数学 Roger Godement - Algebra Paolo Aluffi - Algebra: Chapter 0 Serge Lang - Undergraduate Algebra Thomas W. Hungerford - Algebra A. I. Kostrikin - Introduction to Algebra A. L. Grodentsev - Algebra I 我们选了这几本书，不过这里主要选择的是外国教材，国内只选择了使用者广泛的北京大学丘维声老师的高等代数，以及同样受欢迎的姚慕生，谢启鸿老师的高等代数学。而国外教材中，我们先看看我们已经见过的法国数学家 Godement 在他的代数学中会写一些什么，随后会看看三位美国数学家们的著作，他们分别是 Paolo Aluffi 所写的 Algebra: Chapter 0（著名的 Chapter 0，一本很不错的书）, Serge Lang（塞尔日·兰，法裔美籍数学家，著有著名的 GTM 211）写的 Undergraduate Algebra，以及鼎鼎大名的 GTM 73：由 Thomas Hungerford 所写的 Algebra。最后我们来领略一下两位俄国数学家的著作，他们分别是 Kostrikin（柯斯特利金，其著作有中译本，但翻译一般所以这里选择英译本）的 代数学引论，以及 Grodentsev 所著的 Algebra I，一本观点较高较新的书。\n说实在的，我想我们已经多很多概念比较熟悉了。因此这里我摘抄定义时，只摘抄逻辑关联性较强的部分内容，而当一些概念，比如“像”，“原像”等，如果和前后文联系不够紧密（不在同一段）的话，我们就不摘抄在这里了。最后我想说，这些代数教材我是推荐读一读这些定义的上下文环境的，有一些内容，由于我选择性的摘抄，它们会变得有些不知所以。因此，我强烈推荐还是看看它们的原文，至少这些定义部分的上下文是可以看一看的。\n国内 我知道的国内高等代数使用的教材里最有名的就是这两本了。这里选择高等代数的原因主要是因为国内代数学教材一般在抽象代数中就不再介绍“函数”这一基础的概念了，而这个概念在高等代数中还算有提及。因此，这里选择的是两本高等代数书，第一本北大丘老师的教材应该是很多学校的高代教材，而第二本则是著名的“谢帅”，复旦大学姚慕生，吴泉水，谢启鸿老师编著的高等代数教材。为什么叫“谢帅”？貌似是因为谢老师长得很帅，其次 B 站上有谢老师上的高等代数课，使用的就是他们编著的这本教材，因此这本书又是就会被简称为谢启鸿。\n我们闲言少叙，开始吧。\n丘维声 我们先来看看丘的处理。在这本 2013 版的教材中，映射的定义直接被放在了整个教材的第一个。有趣的是，我们一般会考虑先了解集合，然而这里直接表示“集合这个概念不能定义，只能描述”。某种角度上，如果不使用公理化的集合论的话，还真是如此。当然，代数学大概是可以不考虑这么深入的。\n[!DEF]{映射（丘维声）}\n设 $A$ 和 $B$ 是两个非空集合, 如果集合 $A$ 到集合 $B$ 有一个对应法则 $f$, 使得 $A$ 中每一个元素 $a$, 都有 $B$ 中唯一确定的元素 $b$ 与它对应, 那么称 $f$ 是集合 $A$ 到 $B$ 的一个 映射, 记作\n$$\\begin{align*} f\\colon \u0026A\\to B\\\\\u0026 a\\mapsto b, \\end{align*}$$其中 $b$ 成为 $a$ 在 $f$ 下的 像, $a$ 称为 $b$ 在 $f$ 下的一个 原像, $a$ 在 $f$ 下的像用符号 $f(a)$ 表示, 于是映射 $f$ 也可以记成\n$$f(a) = b\\ \\ \\text{或}\\ \\ b = f(a),\\ \\ \\ a\\in A$$事先给了两个集合 $A$ 和 $B$, 才能谈论 $A$ 到 $B$ 的映射. 设 $f$ 是集合 $A$ 到集合 $B$ 的一个 映射, 则把 $A$ 叫做 $f$ 的 定义域, 把 $B$ 叫做 $f$ 的 陪域, 一个映射 $f \\colon\\ A \\to B$ 由定义域、陪域和 对应法则组成. 因此, 如果映射 $f$ 与映射 $g$ 的定义域相等, 陪域也相等, 并且对应法则相同, 那么称 $f$ 与 $g$ 相等, 记作 $f = g$. 所谓 $f$ 与 $g$ 的对应法则相同, 是指对于定义域中的每一个 元素 $a$, 都有 $f(a) = g(a)$.\n设 $f$ 是集合 $A$ 到集合 $B$ 的一个映射, $A$ 的所有元素在 $f$ 下的像组成的集合称为 $f$ 的值域或像 (集), 记作 $f(A)$ 或 $\\operatorname{}{Im}(f)$, 即\n$$f(A) \\coloneqq \\left\\{f(a)\\ \\vert\\ a \\in A\\right\\}.$$式中的符号 “$\\coloneqq$” 表示定义”, 即挨近冒号的 $f(A)$ 用挨近等号的集合来定义, 从映射的定义和本式得出, $f(A) \\subseteq B$, 即 $f$ 的值域是 $f$ 的陪域的一个子集.\n可以看到，丘在这里很明确地讲出来了 $B$ 就是那个所谓的 陪域，同时指出了映射重要的三个要素：定义域，陪域和对应法则。当两个映射的这三要素相等时，我们就说两个函数相等。而且这里直接地指出了像集/值域是陪集的子集。在后续一大堆和映射相关的内容都叙述详尽后，补充了这样的两句：\n[!REM]{变换与函数}\n集合 $A$ 到自身的一个映射称为 $A$ 上的一个 变换\n集合 $A$ 到数集（数域 $K$ 的子集）的一个映射，称为 $A$ 上的一个 函数。\n没错，这里给出了两个概念：变换和函数。变换被定义为从集合到自身的映射，而函数则是从集合到数集的定义。这和我们之前看到的一些国内教科书上的定义是相容的。那么复旦大学出版社的高等代数学又如何呢？\n谢启鸿 这本书同样也很受欢迎，然而它对映射的引入就比较靠后了。由于它先从矩阵、行列式、线性方程组等等开始讲起，类似于介绍“如何在新空间做加减乘除的基础运算”，因此没有需要映射/函数的迫切的需要。而又因此，映射的概念直到第四章，线性映射的部分才被提起，且是以 复习 的名义来引出的：\n[!REM]{映射（谢启鸿）}\n读者已经学过映射的概念，我们现在来复习一下。所谓映射，是指从一个集合 $A$ 到另一个集合 $B$ 的对应 $\\varphi\\colon\\ A\\to B$。对 $A$ 中任一元素 $a$，均有唯一的元素 $b\\in B$ 与之对应，记之为 $b = \\varphi(a)$。元素 $b$ 称为 $a$ 在 $\\varphi$ 下的像，$a$ 称为元素 $b$ 的原像或者逆像。$A$ 中元素在 $\\varphi$ 下的像全体构成 $B$ 的一个子集，记之为 $\\varphi(A)$ 或 $\\operatorname{Im}\\varphi$。如果 $\\operatorname{Im}\\varphi = B$，即 $B$ 中任一元素 $b$ 均在 $A$ 中有元素 $a$，使 $b = \\varphi(a)$,则称 $\\varphi$ 是满映射或称 $\\varphi$ 是映上的映射。如果映射 $\\varphi$ 适合以下条件：若 $a\\neq a'$，则 $\\varphi(a)\\neq\\varphi(a')$，那么就称 $\\varphi$ 是单映射。单映射的另外一个等价说法是从 $\\varphi(a) = \\varphi(a')$ 可推出 $a = a'$。如果 $\\varphi$ 既是单映射又是满映射，则称 $\\varphi$ 是双射。这时不仅对 $A$ 中的任一元素，有且仅有 $B$ 中的一个元素与之对应；而且对 $B$ 中的任一元素，有且仅有 $A$ 中的一个元素与之对应。因此，双射又称为一一对应。\n这里的描述没有提到 $A$ 和 $B$ 的名称，而是引入了 $\\operatorname{Im}\\varphi$ 这样比较代数的记号来标记像集，不过，还是明示了 $\\operatorname{Im}\\varphi \\subseteq B$ 这样的事实，且指出当两者相等时，映射将称为所为的 满映射。顺带的，这里谈到了所谓的单映射，即 $A$ 上不同的元素都是有不同的 $f(a)$ 对应的；最后谈到双射即为两个性质同时满足。\n总的来说，讲的还是比较简单的，毕竟人也说了，是复习一下。主要目的还是为了引出主角 线性映射。接下来我们来看看法国数学家，在数学分析部分已经出现过的 Godement 是如何看待映射/函数的吧。\n法国 Godement 我们先回忆上面 Godement 是怎么介绍函数的。他说，函数一开始是某种公式，工程师们可能认为是某种图像，后来说这种公式的定义遇到了困难：狄利克雷发明了独特的狄利克雷函数，导致函数的定义被迫要有了更进一步的发展。最后他以笛卡尔积开始，考虑函数图像是这个笛卡尔积上的，一个特殊的子集，最后把函数规定为有这么个图像的东西。实在是很有趣的方法，那么这里他还是用的类似的方法吗？\n我这里人为地将他的叙述分为了注解和定义几部分，我们先看他的第一小段：\n[!REM]{Graphs and functions}\nLet $X$, $Y$ be two sets. A function on the set $X$, with values in the set $Y$, is any operation which makes correspond to each element $x \\in X$ an element $y$ of $Y$ depending on $x$ in accordance with some well-defined law: for example, the function $y = \\sin x$, when $X=Y=\\mathbb{R}$.\nThis \u0026ldquo;definition\u0026rdquo; unfortunately contains several words which have not been mathematically defined. For example, what does the expression \u0026ldquo;make correspond\u0026rdquo; mean? Taken at its face value, this definition is just another piece of word-juggling.\n他首先给出了我们常见的那个定义：俩集合，有个良定的规则，皆大欢喜。然而这个定义是有问题的：什么叫 对应？这个词语有合适的，好的数学定义吗？貌似没有，这似乎又是一种文字游戏，让人产生理解的感受，而不是真正的从逻辑上获得某个定义。\n那么我们要怎么搞呢？\n[!DEF]{Function (Godement)}\nA function is an ordered triple\n$$f = (G,X,Y)$$where $G$, $X$, $Y$ are sets satisfying the following conditions:\n(F 1): $G\\subset X \\times Y$; (F 2): for each $x\\in X$ therre is exactly one $y\\in Y$ such that $(x,y)\\in G$.\nConsider (F 1) means that $G$ is a graph (section 1), called the graph of the function $f$. By (F 2), for each $x\\in X$ there exists $z\\in G$ such that $x = \\operatorname{pr}_1(z)$, and therefore\n$$\\operatorname{pr}_1(G) = X,\\ \\ \\ \\ \\ \\operatorname{pr}_2(G) \\subset Y.$$Let $x$ be an element of $X$; then the unique element $y\\in Y$ such that $(x,y) \\in G$ is called the value of the function $f$ at $x$, and is denoted by\n$$y = f(x).$$Clearly the graph $G$ of $f$ is the set of all ordered pairs of the form $(x,f(x))$, in accordance with the intuitive idea of a function.\nIf $f = (G,X,Y)$ is a function, $X$ is called the domain or source of $f$ and $Y$ the target of $f$.\n相对来说，这个定义其实是发展了上面他在数学分析教材中的定义的。函数现在有了非常明确的 数据，或者说 信息，它是一个有序三元组 $(G,X,Y)$，其第一个分量是后两者的笛卡尔积的一个子集，且这个子集不是任意选取的，而是 $X$ 中的每一个元素都唯一有一个 $y \\in Y$ 来让 $(x,y)$ 是一个 $G$ 中的元素。我们这里看到提到了图的定义，在这章的第一部分有涉及，顺势就定义为了函数的图像；后面有 $\\operatorname{pr}_n$ 的出现，这个在书中稍前一些的部分介绍了这个记号，意为投影（Projection），是说“取有序对的第 $n$ 个分量”。\n我们关注一个细节：这里我们不是用 $x$ 构造出的 $z$，否则有循环定义的嫌疑；我们是说，对每一个 $x$ 都存在一个 $z$，而这个 $z$ 的第一个投影正巧是 $x$。又正因如此，我们自然地得到函数图像 $G$ 的第一个投影自然是 $X$ 集合，而第二个投影，我们则只能说是 $Y$ 的一个子集。另外我们熟知的记号，$y = f(x)$，在这里就不再有那些多重的含义了：$y = f(x)$ 是一个记号，表达了 $y$ 是唯一一个可以从 $x$ 获得的 $Y$ 中的元素，来让 $(x,y)$ 称为 $G$ 中元素的意思。那么很自然（且明显）地，函数 $f$ 的图像 $G$ 就是所有有着 $(x,f(x))$ 形式的有序对的集合，和我们已有的直观理解是相符的。\n这里的最后，指明了函数这个有序三元组中的 $X$ 是 $f$ 的定义域或者叫 源集，而 $Y$ 则是 目标集。也是给出了这两个重要的集合的名字。\n再次回顾这个定义，依旧是非常巧妙的：从图像这样一个由笛卡尔积的子集发展来的定义，添加上一些数理逻辑上的谓词，即可反过来地定义函数。这里的 $G$ 还是一个不依赖于 $X$ 和 $Y$ 的东西，只要求是它的子集：这一点十分宽松，也给足了函数定义为有序 三 元组的必要性，因为第三个元素是没法从图像 $G$ 和 $X$ 中直接得到的。\n然而这还没有结束。因为在这本书中作者更经常使用 mapping 这个更简单的词汇，而非 function 这个说起来麻烦的词汇，即便作者在这里将二者处理为近义词。为什么？\n[!REM]{Mapping of X into Y}\nIf $X$, $Y$ are two sets, a mapping of $X$ into $Y$ is a function with $X$ as source and $Y$ as target. The words \u0026ldquo;function\u0026rdquo; and \u0026ldquo;mapping\u0026rdquo; are thus synonyms, but in practice it is often more convenient to say \u0026ldquo;let $X$ be a mapping of $X$ into $Y$\u0026rdquo; rather than \u0026ldquo;let $f$ be a function defined on $X$ with values in $Y$\u0026rdquo;.\n原来，如果是说 mapping 映射，我们可以说是从 $X$ 到 $Y$ 的映射；而如果说 function 函数，严谨地讲，我们得说是 定义在 $X$ 上而取值在 $Y$ 中的函数。\n感受完法国数学的洗礼，我们来看看美国的教材吧：\n美国 出于或私心或者是定义的相似，我们先来介绍 Chapter 0 的定义。随后我们会介绍大名鼎鼎的 Serge Lang 和 Hungerford。\nChapter 0 这本书是由意大利裔美籍数学家 Paolo Aluffi 所著，语言幽默生动而又现代，大胆的直接从范畴论引入抽象代数，将整个抽象代数都构建在更加抽象的架构上，得以从更高的观点俯瞰整个代数学，特别是能将不同的代数结构自然地联系起来。说了这么多，我们还是看看作为本书的基石的函数，是怎么定义出来的吧。\n[!DEF]{Function (Chapter 0)}\nSets interact with each other through functions. It is tempting to think of a function $f$ from a set $A$ to a set $B$ in ‘dynamic’ terms, as a way to ‘go from $A$ to $B$’. Similarly to the business with relations, it is straightforward to formalize this notion in ways that do not need to invoke any deep ‘meaning’ of any given $f$: everything that can be known about a function $f$ is captured by the information of which element $b$ of $B$ is the image of any given element a of $A$. This information is nothing but a subset of $A × B$:\n$$ \\Gamma_f \\coloneqq \\left\\{ (a,b)\\in A\\times B\\ \\vert\\ b = f(a) \\right\\} \\subseteq A\\times B. $$This set $\\Gamma_f$ is the graph of $f$; officially, a function really \u0026lsquo;is\u0026rsquo; its graph.\n可以看到，首先介绍的是函数这一概念的意义：集合通过函数来联系（这里直译是互动，不过有点怪）。而我们要 定义函数时我们只捕捉这个概念最关键的，最必要的信息：*哪个 $B$ 中的元素 $b$ 是 $A$ 中给定元素 $a$ 的像？*而这个信息，正巧就被 $A\\times B$ 的子集完美表示出来了。我们来看这个集合，与上面使用的 $G$ 不同，这里使用了 $\\Gamma_f$（伽马-f）来表示这个集合，它的元素是在 $A$ 与 $B$ 的笛卡尔积的，且 $a$ 和 $b$ 之间满足 $b = f(a)$ 的这个条件，而这样一来，这个集合就成为了 $A\\times B$ 的子集了。旋即他就指出，这个集合就是函数 $f$ 的图像，而更正式地，进一步地说，这个函数就是它的图像。这里有一个注脚：\n[!REM]{Function and Graph}\nTo be precise, it is the graph $\\Gamma_f$ together with the information of the source $A$ and the target $B$ of $f$. These are part of the data of the function.\n这不就是上面 Godement 给的定义了吗？函数就是它的图、源和目标三者的集合体。不过，这个笛卡尔积的子集，我们还能对它说些别的什么吗？或者说，函数图像一定是什么样的，或一定不会是什么样的？我们来看：\n[!REM]\nNot all subsets $\\Gamma\\subseteq A\\times B$ correspond to (\u0026lsquo;are\u0026rsquo;) functions: we need to put one requirement on the graphs of functions, which can be expressed as follows:\n$$(\\forall a \\in A)(\\exist !b\\in B)\\ \\ \\ (a,b) \\in \\Gamma_f,$$or (\u0026lsquo;in functional notation\u0026rsquo;)\n$$(\\forall a \\in A)(\\exist !b\\in B)\\ \\ \\ f(a) = b.$$That is, a function must send each element $a$ of $A$ to exactly one element of $B$, depending on $a$. ‘Multivalued functions’ such as $\\pm \\sqrt{x}$ (which are very important in, e.g., the study of Riemann surfaces) are not functions in this sense.\n所以说，并不是随便的什么子集就能是一个函数的图像了。它得让任意的 $a\\in A$ 都得有，且有唯一一个 $b\\in B$ 来满足 $(a,b)\\in \\Gamma_f$ 的条件，或者用函数一些的写法，就是满足 $f(a) = b$ 的条件。在这样的意义下，$\\pm \\sqrt{x}$ 就不是一个函数图像了：它的一个 $x$ 是有目标集的元素能和 $x$ 一起满足这个条件，但是太多了，它有俩元素能和 $x$ 一起满足这个条件，一个是大于0 的，一个是小于 0 的。那这种情况下，这个集合它就不是函数图像了，自然出这个集合的表达式那也就不是函数了。另外，Aluffi 还指出这一点在复几何中的黎曼面里特别重要，因为复变函数就是很容易出多个值，这时候要想有个真正的 函数，就得只取其中一支。\n总的来说，这个定义和我们上面看过的 Godement 不谋而合。说不定，欧洲那片儿的代数教学里，函数都是这么从图像定义的？哈哈。我们转向下一个吧，同样名气很足的 Serge Lang。\nSerge Lang 其实这个名字的中文翻译（从 Wikipedia 上）应该是塞尔日·兰的，法语发音真是神奇呀。然而我总是读错……对不起。Lang 他最出名的著作当属大名鼎鼎的 GTM 211: Algebra，其中 GTM 是 Springer 出品的 Graduate Mathematical Textbook 系列。这个系列里有许多经典书目，比如 52（代数几何），73（下面会提到的 Hungerford），96（泛函分析教程），Lang 的 211，218（著名的流形导论），249 和 250（经典和现代傅里叶分析）等等。\n然而，这里选择的并不是他最著名的 211：211 根本没介绍函数/映射是啥，毕竟是 GTM。然而 Lang 还有一本 UTM，从名字也能看出，是给本科生写的，也就是这一本 Undergraduate Algebra。怎么翻译好？大学代数？明明是抽代……\n闲言少叙，我们快来看看 Lang 有没有给函数或者映射的定义带来一些新东西：\n[!DEF]{Mappings (Serge Lang)}\nLet $S$, $S'$ be sets. A mapping (or map) from $S$ to $S'$ is an association which to every element of $S$ associates an element of $S'$. Instead of saying that $f$ is a mapping of $S$ into $S'$, we shall often write the symbols $f \\colon\\ S \\to S'$.\nIf $f \\colon\\ S \\to S'$ is a mapping, and $x$ is an element of S, then we denote by $f(x)$ the element of $S'$ associated to $x$ by $f$. We call $f(x)$ the value of $f$ at $X$, or also the image of $f$ under $f$. The set of all elements $f(x)$, for all $x\\in S$, is called the image of $f$. If $T$ is a subset of $S$, then the set of elements $f(x)$ for all $x\\in T$ is called the image of $T$, and denoted by $f(T)$.\nIf $f$ is as above, we often write $x\\mapsto f(x)$ to denote the image of $x$ under $f$. Note that we distinguish two types of arrows, namely\n$$\\to\\ \\ \\ \\text{and}\\ \\ \\ \\mapsto.$$ 好吧，其实没有什么特别新的东西。它采用的应该是我们最最熟悉的那个定义。不过，他连着写了三个 image，这三个 image 的用法均有不同：可以是元素 $x$ 在 $f$ 下的像；可以是 $f$ 这个函数的像，也可以是 $f$ 下一个定义域的子集的像。这一点还挺有用的，因为人们确实总是会混用这三个概念，把它们都叫做像。而最后还强调了这两个箭头的不同：$\\to$ 是表达了集合间的关系，而 $\\mapsto$ 则是标记了 $x$ 在 $f$ 下的像是什么。其实这一点，敲这篇博客的我最清楚了，因为 $\\to$ 的 $\\LaTeX$ 代码是 \\to，而 $\\mapsto$ 的则是 \\mapsto。\n其实我觉得我们也许没必要对 GTM 73 抱太大期望：它可能也就是简单说两句，但是我们还是看一看吧。\nGTM 73 在 73 中函数的引入还挺快的。然而它的定义也是一段话就讲完了：\n[!DEF]{Functions (GTM 73)}\nGiven classes $A$ and $B$, a function (or map or mapping) $f$ from $A$ to $B$ (written $f\\colon\\ A \\to B$) assigns to each $a \\,\\varepsilon A$ exactly one element $b \\,\\varepsilon B$; $b$ is called the value of the function at $a$ or the image of $a$ and is usually written $f(a)$. $A$ is the domain of the function (sometimes written $\\operatorname{Dom} f$) and $B$ is the range or codomain. Sometimes it is convenient to denote the effect of the functionfon an element of $A$ by $a\\mapsto f(a)$. Two functions are equal if they have the same domain and range and have the same value for each element of their common domain.\n等一下，什么是 classes？是这样的：73 其实对集合论的讨论是比较深入的，它不是单纯的给一个集合定义之后就给一点集合的运算，然后就定义函数了；它从数理逻辑的命题逻辑引入，将 集合（set）与 类（class）严格区分，采用的是 G\u0026quot;odel-Bernays（书中如此，目前 Wikipedia 上给出的是 NBG，加上了冯·诺依曼的名字）形式的公理化集合论。我们不讨论这么深入，只指出：类是集合这一概念的拓展，我们分出一些小类叫他们集合，让剩下的特别大的类成为所谓真类。这样的区分主要是为了避免（绕开）朴素集合论中的著名问题（悖论）：理发师悖论（即罗素悖论）。感兴趣可以了解一下这些公理化集合论，有很多种公理化集合论的形式，比如这里的 NBG，还有著名的，很多人使用的 ZF 公理化集合论等，看看它们都是怎么绕开可怕的罗素悖论的。\nOK，那么也就是说，定义在类上的函数是更加广泛的，比定义在集合上的函数的适用范围更广。不考虑公理化集合论的问题的话，我们干脆就把它当作是定义在集合上好了，问题不大。解决了这个疑问，我们继续。\n不对，再等一下，$\\varepsilon$ 是啥？是 $\\in$ 吗？没错，这本书（在我看来）最有趣的一点在于，它使用 $\\varepsilon$ 作为 $\\in$ 的符号。也许是因为 $\\in$ 这个符号在那时候还没有这么容易就能打出来吧，这本书成书于 1974 年，年龄真的很大了。\n其实这个定义中，值得关注的地方只有几个点：首先选择将函数和映射（两个形式哦，map 和 mapping）作为同义词；其次给出 $A$ 的名称为 domain，而将 $B$ 直接叫做 range 或者 codomain 了；最后给出了两个函数相等的判断方法，不过是用纯文本描述的，只要两个函数有一样的定义域，一样的陪域，以及在它们的定义域上每个元素都有一样的值，两个函数就相等啦。\n总的来说，这个定义其实内容比较平淡，最引人注目的当属使用公理化集合论中的类，而非我们用惯了的集合。然而吧，也因为使用了公理化集合论，这本书从一开始就透露着强烈的严谨的气息。在这本书第一章的后面，你会遇到序结构，选择公理以及 Zorn\u0026rsquo;s Lemma，数学归纳法的证明等相当吓人的内容，而这才只是第一章。GTM 是这样的。\n俄国 俄国数学以计算量大闻名世界，著名的微积分习题册：吉米多维奇 也是许多人刷题的回忆，同时俄罗斯也是知名的数学强国，也是我国许多老一辈数学家留学的地方。如果有一些老教材你读着特别拗口，那很有可能是因为他们的翻译残留着俄罗斯的气息，哈哈哈（开个玩笑）。\n我们先来看看一本非常知名的，被称为 伪装成抽象代数的线性代数 的代数学教材：Kostrikin（柯斯特利金）的代数学引论吧。我们这里放出它中英文的对照版本，以供参考。\nKostrikin 我们为什么贴上中英对照版？因为……没什么特别的理由。我一开始找到的是中文版（毕竟很多人都用这版），但是在网上查找资料时发现，它的中文翻译好像比较拉……所以我又找到了其英文翻译，没想到英文翻译更是神秘：它是用打字机打的。\n[!DEF]{Mappings (Kostrikin)}\nThe notation of a function or mapping (also: \u0026ldquo;map\u0026rdquo;) plays a central role in mathematics. Given two sets $X$ and $Y$, a mapping f with domain of definition $X$ and range of values $Y$ associates to every element $x\\in X$ an element $f(x)\\in Y$, which can also be denoted $fx$. In the case $Y=X$ we also call $f$ a transformation of the set $X$ to itself. A mapping is written symbolically in the form $f\\ \\colon X\\to Y$ or $X\\xrightarrow{f}Y$.\n[!DEF]{映射（柯斯特利金）}\n映射 或 函数 的概念在数学中扮演者中心的角色。给定两个集合 $X$ 和 $Y$，以 $X$ 为 定义域，$Y$ 为 值域 的映射 $f$ 将每个元素 $x\\in X$ 都对应于一个元素 $f(x)\\in Y$，$f(x)$ 亦可记作 $fx$ 或 $f_x$。当 $X=Y$ 时，$f$ 也叫做集合 $X$ 到自身的一个 变换。用符号表示映射时写作 $f\\ \\colon X\\to Y$ 或 $X \\xrightarrow{f} Y$.\n我们看到，它的定义也是比较朴实无华的那种，但是有趣的地方在于，它有神奇的元素的函数值的记号：$fx$ 和 $f_x$（在英文版中只有 $fx$）。这个的原因主要在于，$fx$ 这样的记法是非常方便线性代数的讨论的。线性映射和向量之间相乘，其实就是 $fx$ 的记法。不愧是伪装成抽代的线代。还是来看看我们的最后一本书，Gorrodentsev 的 Algebra I 吧。\nGorodentsev 这本书相对较新，从属 Springer 的一个新系列：Textbook for Students of Mathematics。从它简短的前言可以看到，这本书是为了让数学专业的学生在两年内学会代数而写的，基于他们的课程讲义。它最出名的地方应该是有出色的习题，不过说实在的我也没看过。不多说了，我们看看它的函数的定义是什么样的吧：\n[!DEF]{Map (Gorodentsev)}\nA map (or function) $f\\ \\colon X\\to Y$ from a set $X$ to a set $Y$ is an assignment $x\\mapsto f(x)$ that relates each point $x\\in X$ with some point $y = f(x)\\in Y$ called the image of $x$ under $f$ or the value of $f$ at $x$. Note that $y$ must be uniquely determined by $x$ and $f$. Two maps $f\\ \\colon X \\to Y$ and $g\\ \\colon X\\to Y$ are said to be equal if $f(x) = g(x)$ for all $x\\in X$. We write $\\operatorname{Hom}(X,Y)$ for the set of all maps $X\\to Y$.\n可以看到，它的定义也是最朴素的那个，而它最有趣的地方则是最后一部分。$\\operatorname{Hom}$ 是什么东西？其实如果你有看一些上面提过的 Algebra: Chapter 0 的后续内容的话，很快就会发现，这个符号是 范畴论 中常用的符号，表示了所有从 $X$ 到 $Y$ 的 态射。态射又是什么？在范畴论中，态射是特殊的映射，它除了要满足最基础的映射关系以外，还得满足 保持对象之间的结构 的要求。好消息是，对于集合而言，态射和函数是一样的：集合就是最朴实无华的那个数学对象。因此这里说 $\\operatorname{Hom}(X,Y)$ 是所有从 $X\\to Y$ 映射的集合，也没什么问题。\n小结 代数教材中似乎普遍都将函数和映射作为同义词了，即便有的作者认为在这个对象叫做函数时和叫做映射时想强调的点是不一样的，对它的叫法也是不一样的，但是依旧还是作为同义词出现了，这一点与我们在分析学中遇到的情况非常不同。不过用法的不同也体现了我们对数学对象的态度：有时候我们需要的是定义在某个集合上的一个数学对象，而又有一些时候我们需要的是两个集合之间的一种关系，一个桥梁。Function 和 mapping 正是很好地捕捉了这个特点。至于为什么一边说着 映射，一边又用着 $f$ 来作为映射的记号，而不是用 $m$ 这样的记号呢？我个人倾向于认为，映射这个概念其实可能出现地比函数要晚一些，是从函数的概念上抽象出来的一个概念。而我们已经习惯了使用 $f$ 来代表许多“关联”或者“变换”的关系了，因此大家选择使用 $f$ 表示映射，且在代数领域将两个概念统一，是有历史原因的造成的。\n另外，代数学这里的定义里，有两本教材是从函数的图像来反过来定义函数的，这样能非常代数化地，形式化地定义函数为一个明确的数学对象，而非是某种虚无缥缈的规则：这个规则已经凝结在那个函数图像里面了。再者，代数学中对那个“箭头右边的幽灵”赋予了很明确的名称，基本都没有避讳这个问题。不过有趣的是，很多地方都用的我们在分析学中使用的 值域 的名字，即 range，但这里我认为这是没有歧义的：在代数学中，那个分析学里用的“值域”会被换成映射的 像，即 image，它直接会被记为 $f(A)$（如果定义在 A 上），或者 $\\operatorname{Im}f$，又或者 $\\operatorname{im}f$ 等等。这样一来即便我们使用 range 代表映射的陪域，我们依然能有一个合理的符号/概念来表达值域/像。至于为什么像会如此重要，因为……这里不展开了，但是它的确是重要的从函数中能提出来的代数对象，它会被用来做一些商，出一些更奇怪的代数对象，所以有必要给一个特殊的符号。感兴趣的话可以参考我之前写的一篇大长篇，写了一些如何证明蛇引理的内容。\n所以，函数是？ 转了一圈，是时候解答这个最初的问题了：函数，它到底是个啥？对应关系？数学对象？其实就是映射？必须从数域到数域？到数域就已经够了？态射？变换？怎么这么多名字？\n我对这些问题的解答是，函数是一系列数学概念的原型。我相信这个词汇的出现应该是比映射要出现地更早的，大家也许一开始也不怎么考虑这个东西的严格定义。后来有了奇怪的函数（是你，狄利克雷函数）之后，我们不得不面对没有严格定义的函数的问题。而又是在这样的背景下，出现了一系列的定义，它们大多在一开始给了两个集合，让一个集合中的每个元素在另一个集合中都得有，且又只能有一个元素能与之对应，最后称这种对应规则为所谓的 函数。\n然而也许是由于数学发展的需要，函数这么个词汇已经有点贫乏了，它看似就是一个依存于定义域和陪域的对象（可能还有认为不依存于陪域的），而 map 这样一个动词（或者名词）又能很好地描绘两个数学对象之间的关系，就让函数与映射之间划上了等号。这样的情况也许在分析学那里发生了进一步分化，让映射代表所有的集合之间的这层关系，让让函数成为那个最特殊的关系：从集合到数域，又或者必须二者都是数域。\n我相信映射这个说法的出现，也许就已经标示着范畴论等学科的发展了，进而出现了 态射，变换 之类复杂而又新奇的名词。我们说态射需要保持对象的数学结构，变换则专职于从对象到它本身的映射，泛函又是从函数空间到数域的映射，诸如此类，而有时候大家又还会说“xxx 是一个从 X 到 Y 的函数，然后 xxx”的说法，不将它们与映射做区分，就造成了今天混乱的局面。\n但是吧，这样其实问题也不算大。毕竟上面列举的这些教材其实都是对函数和映射做出了自己的定义的，而在使用过程中也都严格遵循了自己一开始设下的规矩，谁用做什么，而谁又用做什么。而如果一本书没有谈及映射的概念，这很有可能是它们已经是默认大家掌握了一定的代数基础，能明白书中说的那个“函数”究竟是什么意思，又或者后面不会再用“函数”这样一个没被赋予啥特殊结构的词汇，转而会大量使用诸如 线性变换，群同态，线性算子，微分算符 等更具有明确意义，赋予了复杂结构的“映射”。\n就是这样。函数其实又单纯又复杂，想要明白每个领域中用到的那个 function 究竟是啥几乎是不现实的。在这里我还是持实用主义：听我说话的人能听得懂就行，看我写字的人看得懂就行，如果听不懂，看不懂，那就再多掰扯掰扯这些定义好了。说到底，函数不就是一个吃完了才吐的奇怪机器嘛。\n后记 这篇文章又比我想象的要长得多。我一开始其实只是想大概比较一下各个教材对函数定义的异同，然后写一点和函数/映射相关的一些高阶的数学概念，比如什么算子，算符，泛函，变换这些乱七八糟的东西，瞅瞅它们衍生出来的奇怪的概念们（单、满、忠实、全等奇怪的描述词），看看它们都会被用在哪里，聊聊它们的作用都有什么。结果，写着写着发现，函数这个概念真的很大，而每个教材的定义也都多多少少有一点新鲜的东西在里面。写到后面就干脆往教材定义比较的内容去写了，毕竟如果读者真愿意读，那肯定还是能从这些教材的描述中感受到这些乱七八糟的定义都代表什么的，心里面也肯定会有一个自己的判断。\n另外我想提到的是，我删掉了很多本来打算放在这里的教材。分析学中我删掉了我一开始计划参考的 菲砖，即菲赫金哥尔茨所著的 微积分学教程。这本书虽说是微积分，却实打实地教的的数学分析的内容，只不过确实，学了这些会让你很会微积分，成为微积分领域大神。没有选择这本的主要原因在于它对函数的定义有点“慢”，它用了很多的笔墨来刻画函数是什么，我感觉不太适合放在这里。另外我还删掉了一些教材，比如说像 Artin 的代数，那本 GTM 211，甚至是我特别想放在这儿的李文威老师的 代数学方法 我都没能放在这里，因为它们都没有提及函数/映射的定义，毕竟多少这个概念还是有点太基础了。\n写这篇文章我也收获颇多：我本来是支持 函数必须从集合到数域，而映射没有这种要求 的，但看了这么多教材对函数和映射的定义之后，我发觉其实定义这个东西，自洽，好用就是最好的。我们没必要非得追求一个非常 fancy 的定义，即便那样做真的有很大的好处（对象的代数化，极强的描述性），也没必要追求一个适用于所有教材的定义，当我们需要它是什么的时候，它就是什么，然后在这个语境下此后不再改变，就已经是好的定义了。\n如果你注意到这些摘抄的内容的排版有的采用粗体做强调，有的采用斜体做强调，而又有一些使用楷书做强调，没有统一的格式的话，这是因为每本教材采用的方式不太一样。另外排版过程中，有一些东西我是删掉了的，比如公式的序号，又或者是一些注脚的标记。注脚的部分（在 Chapter 0 那里有）我改写成了 Remark。希望这能解答你对搬运内容的不一致性的困惑。\n我还要吐槽，怎么会有人在定义函数/映射的时候就喜欢用 $\\varphi$ 的，这个太难打了：\\varphi 真的比 f 要难打好多。唉，难受。\n最后，感谢您看到这里，希望这篇文章能带给你一些启发。那么，这里是与废话函数 $F()$ 同构的 $\\text{AMoment}$，一如既往地，祝您身体健康，天天开心~\n这也是一个很大的坑，学习数理逻辑一定绕不过公理化集合论。我们在这里首先采用传统的朴素集合论，随后在需要的时候不加提示地直接转变为 ZFC 公理化的集合论。这一点请注意。（总之就是我们不深入研究集合，它很好很对就OK）\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n其实，私心地讲，我不喜欢“高等数学”这样的说法。这样的说法总给我一种“高等数学比初等数学更高级”的印象，而在我看来，高等数学（大学后的数学）和初等数学（大学前，有时可以放宽到高中前）的主要区别在于，前者更加严格，从某些公理开始得到定理、引理、推论等结论，用定义简化描述；后者则多为介绍性的讲述，以及怎么在有限的知识框架下解决简单问题。不过，我也想不到更好的词汇来表述这两者了……\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-09-23T17:41:51+08:00","image":"/images/笔中的呓喃之境.jpg","permalink":"/zh/posts/math_note/concept_of_function/","title":"到底啥是函数！？"},{"content":"头图取自 アシマ 所绘的 bouquet，选曲为某游戏插曲 玉響時間，总之就是非常好听，能让整个人身体都松下来的一首，非常好，希望你也喜欢。\n我决定将 9 月 22 日作为我博客的生日。\nY? 这个博客的所有文章标注的日期里，最早的不是 24 年的 11 月 1 日吗？为什么要选择 9 月 22 日？其实吧，这得追溯到我这个博客的原型。如果您看过这篇关于 搭建这个博客 的经历的话，会发现我一开始是打算用 Hexo 作为构建工具的。可惜，我的 Hexo 之旅并不顺利，最后还是选择了 Hugo，而初次尝试 Hexo 的时候我虽然没有写，但是其实是在十一假期的时候。\n那么为什么不是十一假期，而是选择在了 9 月 22 日这个时间点呢？其实道理也很简单。这篇博客的第一篇文章，虽然没有在 9 月 22 日发在这个博客里，但是是在 9 月 22 日写成的，就是这篇 Baby Rudin 读后感。说到底，这个博客里的一切，都是源于我读完 Baby Rudin 之后的或者是成就感，或者是虚荣心，又或者是出于分享的冲动，而诞生的，而这篇文章则是这一切的开始，也算是给我这个小站定了一个基调：这会是一个貌似偏技术或者理论的博客。\n为什么是 貌似？我也很难讲我这个小站的内容里，技术含量有多么多么高，理论内容有多么多么深。我只是分享我喜欢的东西，也许是某天看到了某个 有趣的问题，也许是写程序遇到了什么我此前不了解的 有趣的语法特性，或者我感觉我应该记录一些 好用的命令行技巧，又或者就是跟一下风，在蛇年 证明一下蛇引理。我的博客里的文章的平均水平应该还到不了一种能让我自豪地说自己是 技术博客 的程度，只是自娱自乐而已。\n尽管如此，我依旧还是觉得这一年以来我的博客里有一些很令我自豪的内容。我最想提起的便是刚刚讲的，我跟风证明的 蛇引理。其实现在来看，这篇文章有很多的问题，其中最大的问题就是太**长了，很难有让人看下去的欲望。博客预估的阅读时间是 75 分钟，一个小时！而这样的数学内容，我相信如果真要一点点往下看，大概率是不止要一个多小时的。而即便我亲爱的读者拥有极强的包容心，愿意读这长长的一篇文章，他/她依旧很有可能会发现这篇文章的第二个问题：这个文章的内容太琐碎，也太繁杂了。它不适合一次读完，我明明应该把这篇的内容分为起码三部分，却依旧为了写的够爽，而一路写了下去，写到了要阅读 75 分钟的程度。那么再假设，这个读者有足够的时间与足够的精力去读这篇文章，他/她又可能会发现，这篇文章的具体内容组织上还是有问题：口水话显得很多；有一些概念反复提及，又没能一阵见血地说清楚；写法符号上也有一些不太标准，大多数地方用的不是 $\\operatorname{Ker}$，而是 $\\operatorname{ker}$。最后还有一些不太重点，但是很影响观感的问题，比如一些内容格式。如果您之前看过这篇的话，会发现里面有一些收缩的内容很难发现可以展开；有一些内容缩进怪怪的；有一些公式甚至渲染有问题。这些都是我这篇文章的硬伤，我也而希望有机会能重启这个系列。若能重启这个系列，我一定会把它写成一个由基础代数出发的，尽可能通俗易懂的蛇引理的证明。不过，到时候会不会需要重新学习了解这个引理怎么证明，就不好说了，哈哈哈。然而还是有现在可以改的：博客新增了一些数学环境的支持，我会尽快把它们更新上去的，让诸如引理、定理、定义等东西有更美观的样式。\n然而这篇文章不是我“最火”的文章，甚至也许是最冷清的一篇了。根据我的博客文章收到的评论来看，我的博客里最受欢迎的一篇是这篇 关于泛函导数与变分法 的文章。这篇文章本是放寒假前师兄问我的一个小问题，结果却引爆了我 根本不够了解变分法 的炸弹。为了解决我师兄的问题，顺带解决我遇到的问题，我就查阅了一些资料，写出了这篇文章。说实在的，其实就是拾人牙慧之作，没有老大中先生的 变分法基础 的话，我是万万没有办法能又快又好地写出这篇文章的。能得到这么多人的喜爱，能帮到大家，我还是很有成就感的。真的很感谢大家的捧场。\n另外我想提起的则是关于 Arch Linux 的一些文章。虽然我只写了 两篇 与 Arch 直接相关的文章，且都是我安装 Arch Linux 的过程，然而搭建起博客的这一年以来，我最大的收获除了这个博客之外，就是我使用 Arch Linux 的这些经历，以及在这过程中我学到的 Linux 操作系统的知识。而这个博客里，几乎所有和命令相关的内容，都是由于我真的开始使用 Arch Linux 的原因而接触到，并且有了写一些操作说明一般的文章来方便自己查询的想法。朋友们，Arch Linux 是对的！GNU/Linux 是对的！开源也是对的！\n最后我也想提一下我博客中的一个系列，也是这个博客一开始就也确立了的内容：Phase Field 相场模拟笔记系列。这个系列源于我给组内开设的相场法培训，不过说是培训其实也是一些方便自己备查的笔记。这些笔记里的内容基本是我从一开始的英文课件中转抄出来的，可能还是有一些错漏。如果这个学期有相场培训的计划的话，我也许会继续更新这个文章系列。\n目前我手上有一篇关于函数的概念解析的文章在写。与其说是概念的解析，不如说是找了一些教科书，来考察它们都是怎么介绍函数这个数学学习中最最基础的概念的。我也许会在最后做一些个人的总结，也有可能不会，但是目前写到现在，真的发现这是一个非常有趣的话题。希望这两天内我能写出来吧，如果你看了这篇纪念博客诞生一周年的文章，也请期待下一篇更新的文章。\n那么，一如既往地，祝读到这里的读者您，身体健康，生活愉快！\n","date":"2025-09-22T15:51:11+08:00","image":"/images/bouquet.png","permalink":"/zh/posts/others/blog_1st_anniversary/","title":"博客一周年啦！"},{"content":"虽然 VS Code1 严格来讲是编辑器（Editor），然而其实它更像是一个伪装起来的集成开发环境（IDE，Integrated Development Environment）。而且，它最强大的功能其实在于它强大的远程功能。这次我们就来聊聊怎么把 VS Code 配置成一个堪比 Visual Studio 的多用途 IDE 吧\n本次挑选的歌曲是光受好评，翻唱无数，且我自己很喜欢的一首术曲：由 P主 一二三 做词曲的 猛独が襲う。中午偶然听到了这首，感觉非常不错，就选择是她了。头图就顺手选择同样很好看的这首歌的 MV，由 休符 所绘，实在是非常好看~ 另外还要特别指出，这首歌无敌的吉他是由 じゅみ 演奏的。我必须立刻承认这首歌的吉他是我最喜欢的部分（）\n介绍一下先 VS Code，一款备受瞩目的编辑器，由 Eric Gamma 于 2011 年开始开发，并于 2015 年 4 月 29 号正式发布2。这款基于 Electron 框架（或者，浏览器）的编辑器目前已经成为几乎所有程序开发者工具库中必不可少的一部分了。它除了有美观、现代化的界面以外，开放的态度（VS Code 的基础功能是开源的，其开源打包称为 Code-OSS）更是吸引了许多开源爱好者的目光。而他杀手锏级别的特点，则是其极其丰富的插件市场以及生态环境，我只能说伟大，无需多言（可惜也只能伟大一点儿，因为插件市场很多插件是微软的版权，可惜）。\nVS Code 其本体已经提供了许多的功能了：好用的集成终端、输出窗口、调试台等，标签式的页面，丰富的侧边栏功能，集成的文件浏览器等等，甚至提供了原生的 JavaScript / TypeScript 支持（因为这个编辑器就是用它们写成的）。而有了插件的加持，它真的就已经几乎成为一款 IDE 了。比如装上微软的 C/C++ 插件，你就可以使用 VS Code 编写 C++ 代码的同时，直接运行与调试 C++ 代码。而装上 CMake 插件之后，你就可以使用 CMake 管理整个项目了，右侧侧边栏会提供比较详细的配置项。我们后面就来以此为例，配置如何使用 GCC + CMake + VS Code。\n编辑器，编译器和 IDE，它们到底是啥？ 某种程度上，这也是一个老生常谈的问题了。\n我们先说说（文本）编辑器，英文是 Editor。我们都不陌生，在电脑上写东西的时候经常会用到它们。它主要是为了编辑文本内容而生，最基础的功能就是换行，退格，保存等等这些也许你早已经习以为常的东西。我们介绍几个编辑器：VS Code，Vim，Emacs，微软记事本等等。总之，编辑器就是让你用来编辑文档的。除了文本编辑器之外，我们可能还会用到二进制编辑器以及其他的一些编辑器，它们的核心功能都是为了让用户编辑内容。\n而编译器就是一个完全不一样的东西了。编译器的英文是 Compiler，它的功能是把程序（文本文件）根据语法规则编译成二进制文件，来让机器读取。编译的产物可能是一个可执行的文件，也有可能是不可执行的东西，但是它们的特点都是没法直接用文本编辑器等打开，或者你得用专用的二进制编辑器打开并编辑。编译器也有很多种，就拿 C/C++ 这门语言来讲，著名的三大编译器就分别是 gcc/g++（同属 GCC 编译器工具链），clang/clang++（LLVM 的编译器前端，后端同为 llvm），cl + MSVC（从名字就能看出来，是微软的家伙）。而别的编译型语言几乎也都有自己的编译工具。有趣的地方是，大多数的编译器都是依托于 GNU 的 GCC 工具链（GNU Compiler Collection）或者依托于 LLVM 后端的。不过这都是后话了。\n最后就是 IDE。全称是 集成开发环境，英文全称则是 Integrated Development Environment。它是编辑器和编译器以及其他许多工具比如调试器，性能分析，静态检查工具的集合体（集成一词的说法）。C/C++ 最著名的 IDE 就是传说中的宇宙第一 IDE：Visual Studio 了，或者是同样备受赞誉的 JetBrains 家的 Clion。另外 JetBrains 开发了多款 IDE，几乎所有主流语言 JetBrains 都有对应的 IDE 可用。\n了解这些概念，在讨论相关话题的时候表述会清晰很多。不过即便不清楚，问题也不太大就是了（）这也是由于现在很多编辑器正朝着 IDE 的方向发展了，比如本期的主角，VS Code。所以也有开发者认为，VS Code 就是一款 IDE，我表示理解。\n首先当然是下载咯 其实关于 VS Code 的下载问题，我在 这篇文章 中已经提过了。不过如果你不想翻那里的内容，那也可以跟着下面的内容来。\n我们先下载程序本体 我们仅考虑 Windows 端的使用，请使用 GNU/Linux 的朋友自行选择自己家的包管理器以及想安装的版本（毕竟，使用以开源著称的 Linux 的你可能不希望使用 Microsoft 的专有软件：Visual Studio Code）。这里介绍两种下载方式，一种是从官网下载，点这个链接 就会直达下载页面。\n我们可以注意到这里有一个大大的下载按钮，底下还有几个选项，什么用户安装，系统安装，x64 啊 ARM 啊的。如果你不知道该选哪个，点那个大按钮。它会下载用户版的。如果你清楚自己需要什么版本，请自便。这里简单提一下用户安装和系统安装的区别。如果你的电脑只有你一个账户使用，用户安装方便又快捷，不会出错；如果你的电脑有多个账户且你想让每个账户都可以用上 VS Code，请使用系统安装版本。但是这样会有 UAC（User Access Control）的烦恼，且很多时候你修改个什么文件都会让你用管理员权限搞（即会弹出 UAC）。\n下好安装包之后打开安装包，先同意它的用户协议，然后是路径（可以不改），要不要添加开始菜单（可以不管），随后会遇到这个页面：\n这里我推荐选择把 使用 Code 打开 集成到文件浏览器的选项。不过如果你觉得你的文件管理器右键菜单栏已经够凌乱了，那就算了吧。之后就会让你确定你的安装选项，没问题就安装吧。\n另一种下载方式我个人不是特别地推荐，即使用 Windows 的包管理器下载。较新的 Windows 版本搭载了一款还不错的包管理器，WinGet，你可以使用 winget install Microsoft.VisualStudioCode 来直接使用 winget 的命令行安装，但是这样的安装方式有个问题：它没有文件浏览器集成（即右键菜单的“以 VS Code 打开”这样的选项）。WinGet 给了解决方案，但是有点丑陋：winget install Microsoft.VisualStudioCode --override '/SILENT /mergetasks=\u0026quot;!runcode,addcontextmenufiles,addcontextmenufolders\u0026quot;'，而这个问题的原因竟出奇地搞笑：传给 WinGet 的默认命令行参数里写错了，把 MERGETASKS 写成了 MERGETAKS（少写个 S）。以上信息来自 这个讨论串，感兴趣可以看看。\n不过无论如何，你这时应该已经安装好了。激动的心，颤抖的手，点开软件准备大展身手的你也许会发现：界面全是英文的。也许你觉得英文界面全对，但是这样还是比较小众的（）那怎么汉化呢？这么牛逼的软件应该有不错的本地化才对呀？别急，解决方案在下面：\n必须立刻开始安装插件 考虑到我们是第一次装 VS Code，应该不需要考虑从账户同步安装的插件。所以，我们的第一件事就是，点击左边的插件市场图标，或者快捷键 Ctrl+Shift+X，打开插件市场。\n如果你需要中文插件，请安装 Chinese (Simplified)（简体中文）Language Pack for Visual Studio Code 的插件（搜索 Chinese Language Pack 就会出来）。是的，VS Code 的所有本地化都是通过微软发布的这个本地化插件实现的。如果你喜欢异域风情，你也可以试试其他语言的本地化插件。\n下载插件时，如果你是第一次下载某个人/组织打包的插件，VS Code 会问你要不要信任这个插件作者。一般而言，不要瞎装的前提下，这些作者都是可信的。VS Code 的插件是有过投毒历史的，还是请小心行事。\n下来就是我们的主题了，我们需要在 VS Code 上开发 C++ 项目，为此我们需要下载 C/C++ Extension Pack 这个插件包，它算是微软的 C++ 插件全家桶，里面有 CMake Tools（用于集成 CMake 项目管理），C/C++（微软家实现的 C/C++ Language Server Protocol，包括代码高亮，连接编译器/调试器等），以及让你的代码有好看的语义高亮样式的 C/C++ Themes。有了它们，你就可以愉快地在 VS Code 里写 C++ 了！\n最后，注意到我们还需要在远程做开发，为此我们还得安装 Remote - SSH，Remote - SSH: Editing Configuration Files 以及 Remote Explorer 这三个插件。当这些都安装好之后，我们就可以开始准备工作啦。\n先在本地试试 C/C++ 开发吧！ 在直接开始在远程进行开发前，我们最好先熟悉怎么在本地进行 C/C++ 开发。原因很简单：远程配置我们还没搞，而 C/C++ 开发不管在本地还是在远程都是差不多的。\n实际上，VS Code 在它的官网上写了很多指南，比如这篇 C/C++ for Visual Studio Code 就大概介绍了一下怎么用 VS Code 编写、调试简单的 C/C++ 代码。而后面更是有专门的一部分（从在上面这篇文章的左侧栏就能找到）用多篇指南文章来介绍各个平台、各个编译器下的 C/C++ 开发配置。详细到我觉得我单独写一遍完全是画蛇添足，是没有必要的。\n那么，我们这里写什么呢？我们介绍一下 CMake。不过在这之前，我们不得不介绍一点点 C/C++ 项目构建这件事的历史。\nC/C++ 项目构建的小小历史 虽然在最最最开始的时候，一切都很简单：我们写好源码之后，告诉编译器“你给我编译了”就行了。拿我们亲爱的老牌编译器 gcc（或者 g++，这个是 C++ 专用的）来说吧，当我们要编译写好的程序的时候，我们只需要 gcc source-code.c 或者 g++ source-code.cpp 就可以编译出来一个 a.out 的文件了。\n然而，随着工程越来越复杂，我们已经没法让 gcc 一个个地编译这些文件了。也许我们可以用 Shell 的一些功能来批量选择文件编译，那要选择性编译的时候呢？对不同的对象要采用不同的编译选项的时候呢？如何自动处理大型项目里的依赖关系？这些问题当时就给出了一些解决方案，在 Linux 上这个方案是使用构建工具 Make。人们在项目文件夹里添加一个 Makefile 文件，在里面像是写脚本一样告诉编译器应该怎么编译文件夹中的文件，对它们中的哪些要做什么处理，等等。而当写好之后，只需要在命令行里使用 make 命令，就会自动读取 makefile 然后调用对应的工具，使用设定好的编译选项以及正确的编译顺序进行编译（或者从项目的角度，项目构建）了。\n那 CMake 又是怎么回事呢？是这样的，C/C++ 代码在设计上是能在多种架构的机器上都能运行的，但是需要有对应的编译方式。而且，Makefile 的语法还是有一点难学。为了实现一份代码、到处编译的这样崇高的理想，自然地就诞生了 CMake，这样一个“调用 Makefile 这样构建工具的构建系统生成器”。为了适应不同平台的各种构建工具，它必须有一套独立的配置方式。而又由于它是通过调用平台已有的构建器来编译代码的，你需要告诉它你要用哪个。如果是 Windows 平台，你也许会使用 MSVC 或者 微软打包的 NMake；如果你在 Linux 发行版上，你大概率会使用 Makefile；如果你想尝鲜，或者你的项目尤其庞大，希望提高构建速度，你可能会使用 Google 出品的 Ninja ……但是你需要告诉 CMake 你要用哪个。\n在你告诉 CMake 你要使用的构建工具后，CMake 就可以为你生成一个让你或者它用来构建项目的 build 文件夹，里面就是 CMake 生成好的用来描述你的项目的一大堆构建文件。这时候你可以使用构建工具从这个文件夹里进行构建，也可以让 CMake 帮你调用对应构建工具进行构建。我们简单说一下这几步用命令行怎么搞。\nCMake 怎么（手动）构建？ 首先我们介绍老派方法。我们用一行但是多个命令来做这件事，在我们的项目根目录下使用\n1mkdir build; cd build; cmake ..; make -j 就可以生成构建文件并构建项目了。我们一步步来：通常我们会新建一个 build 文件夹来让 CMake 把所有的构建文件相关的内容都放在里面；之后我们进入这个文件后告诉 CMake 我们要构建的项目是项目根目录，CMake 就会读取根目录的 CMakeLists.txt 后把构建文件统统写入当前的工作目录（也就是 build）。最后在构建文件生成结束之后，我们就调用构建工具来进行项目构建了。这里我们演示的是 make -j，会让 Make 以多线程方式进行编译工作。\n然而这一套体操实在是太丑陋了。我们得手动进入一个新建的 build 文件夹后进行构建，怎么想都很奇怪。虽然这样确实很灵活：不想起名叫 build 也是完全没问题的，但是总觉得不够现代。\n新派的方法是这样的，我们在项目文件夹下执行：\n1cmake -S . -B build 2cmake --build build 这第一行代码是说，我们首先用项目文件夹里的 CMakeLists.txt 来进行项目配置，把项目配置放在 build 文件夹里。其中 -S 就是指明项目根目录在哪里，这里选择当前目录；-B 是指明我们要把配置内容放在哪里，我们选择了 build 文件夹。这个命令会在有 build 文件夹的情况下直接放进去，在没有这个文件夹的条件下会自己乖乖创建一个出来。而第二行代码，就是告诉 CMake，我要构建这个项目了，你帮我读取 build 文件夹里已经有的项目配置，然后给我构建吧。可以看到这个方法是比较符合我们对 CMake 这个工具的预期的。至少它不需要外部命令来帮它做一些事了。不过如果你喜欢老方法，也没问题，CMake 的前向兼容还是不错的。\nCMake 其实是老油条（ CMake 目前已经逐渐成为事实标准：各大知名库都会提供 CMake 的模块来方便调用它们，而各大包管理器（没错，C++还是有一点包管理的，虽然不多且不统一）通常也提供了 CMake 的集成。虽然 CMake 的语法十分难评（怎么 if-elseif-endif 都像是用函数实现的？！？？！？），但是目前而言，受广泛检验的，比较好用的项目构建工具，还得是它。\nCMake 也是有在尝试变得好用的。比如 Presets 可以让我们快速应用某些构建选项，而 AI 的出现几乎完美解决了 CMakeLists.txt 难写的顽疾。所以，还是用吧。有 VS Code 的集成和 AI 的帮助，构建项目就是点点鼠标而已，还是挺方便的啦。然而有人说：我就是不用 AI！这又该如何呢？我们就再简单谈谈 CMakeLists.txt 的基本写法吧。\nCMakeLists.txt 怎么写？ 首先这个文件特别奇葩的一点是，名字一点儿不能差，不是 CMakeList，不是 CMakeList.txt，它就得是 CMakeLists.txt，注意大小写哦。如果你按照上面说的，安装了 C/C++ Extension Pack 的话，你的 CMakeLists.txt 应该是有语法高亮的。\n对于简单的项目，我们主要是要告诉 CMake 项目名称，编译对象以及输出的结果。我们首先在项目根目录创建这个文件，底下是一个简单的示例：\n1cmake_minimum_required(VERSION 3.15) 2project(HelloCMake VERSION 1.0 LANGUAGES CXX) 3 4# Set C++ standard 5set(CMAKE_CXX_STANDARD 17) 6set(CMAKE_CXX_STANDARD_REQUIRED ON) 7 8# Define the executable 9add_executable(hello src/main.cpp) 上面的内容，首先先告诉 CMake 这个 CMakeLists.txt 对 CMake 版本的最低要求。这是为了兼容性考虑的：有些平台可能并没有较新的 CMake 版本可用。这个东西自然是越低越好了，因为越低的话兼容性自然更强；但是你也可以使用很高的版本，强制要求使用高版本可以让你更放开手脚用新版本里更好用的配置。\n接下来是定义我们的项目属性。它得有个名字，放在第一个位置；得有个版本，用 VERSION 打头后面跟上具体版本号。这个版本号你自己决定，测试用的案例这个不是很重要；最后告诉 CMake 我这是个什么语言的项目，用 LANGUAGE 打头后面接上 CXX 作为语言类型，告诉它是 C++ 项目。为什么不是 CPP, C++ 而是 CXX 呢？这大概涉及到 C++ 诞生之初的一些设计名称的扯皮，不太重要。有趣的地方是，CXX 也是推荐的 C++ 源文件拓展名之一。希望这能让你接受这里使用 CXX 这一点。如果你的项目还要用上 C 的话，在 LANGUAGE 后面补上，用空格分开就好。\n接下来就要设置和 C++ 相关的内容了。我们首先指明要用的 C++ 标准版本，这里使用了 C++17 标准；其次我们在下一行表示这个标准是必须的。注意到我们用的是 set 这个很像函数的东西。实际上我们是在设置一些变量，这些变量会传递给 CMake 来控制 CMake 的配置和编译时的行为。\n最后我们就是告诉 CMake 这个项目里面的目标（target）了。我们说我们要给项目里加一个可执行文件，它的名字叫做 hello 而它的源码有 src/main.cpp 这些文件（好吧其实只有一个）。这样一来 CMake 就知道哪些东西要编译成谁了。这时我们把源码放在相对于根目录的 src/main.cpp 里，用上面的命令行跑一下，就能编译出一个可执行文件了。这个文件一般被放在 build 文件夹里的某个子文件夹下，可能是 Debug 或者 Release 等。按照你的需要来做就好。不过如果你是按照这个教程来的话，CMake 的默认编译模式是采用的 Debug 的。如果你需要编译出 Release 版本，请在项目配置的命令后面加上选项：-DCMAKE_BUILD_TYPE=Release，然后在执行项目构建的命令（较新的方式的第二个）时在后面补充选项 --config Release。这样就能保证你编译出来的是 Release 版本啦。\n我们多一点补充：注意到我们这个项目实在是非常地简单，但是麻雀虽小，五脏还是全的。它集齐了项目本身的配置内容，项目构建相关的配置内容，以及项目构建时的执行方式。首先我们声明和 CMake 息息相关的内容们，接着我们描述项目的全局配置，比如我们采用的语言标准。这里我们还可以准备好我们的项目需要使用的外部库，使用 find_package 在括号里按照需求写上库的名字就可以了。后面我们开始定义 target 们，它们用什么头文件，要编译哪些东西，以及用 target_xxxxx 等函数来指定某个目标需要的一些配置，比如 target_include_directories 呀，target_compile_definitions，target_link_libraries 之类。在这里我们得指明是给哪个目标的设置，以及这个配置会不会被使用它的项目继承，最后进行相应配置。最后我们可能还希望使用 CTest 或者 CPack 来进行测试和打包，我们这里就不再介绍了。\n唉，CMakeLists.txt 的写法确实是需要一些功夫学的，所以我的建议还是使用 AI，然而如果你能熟练掌握 CMakeLists.txt 的写法的话，那肯定也会成为团队不可多得的人才。我们关于项目配置这里就先到这里，因为我们的主要目的其实并不是对着 CMake 一顿操作就成了，我们还得上远程进行开发呢。\n试试连接远程服务器吧！ 连接远程服务器，其实是一个比较大的话题。一般来讲，我们默认连接的是一台 GNU/Linux 设备，比如红帽家的 CentOS，Rocky Linux，RHEL 等；Debian 系的 Ubuntu 等等。总之，很少是 Windows Server。不同 Linux 发行版之间的区别主要在于包管理以及一些些文件系统组织结构、系统配置上的区别。基本的 GNU 工具套件在使用层面上应该是没有什么大区别的。特别是我们要使用的，用来从终端上远程连接的工具，OpenSSH，几乎各个平台都是统一的（包括 Windows 哟）。\n我们先来介绍下 SSH 吧，后面再聊怎么用 VS Code 的插件来简单地做到这件事。不过还是有先后顺序的：在第一部分完成后，VS Code 用起来会更方便。\nSSH: Secure Shell SSH 是目前终端远程登录的金标准。你也许能看到很多的终端远程工具，比如（我知道的唯一一个）MobaXTerm，它其实也就是一个 SSH 的 GUI 包装。它在 1995 年被开发出来，用于在那个互联网的洪荒时代建立起一个值得信任的，用来连接两台设备的通道。\n使用 SSH 自然是需要有两台设备的，一台是你的本地设备，一台是你要连接上的远程设备。我们简单地称它们俩为本地机和服务器吧。服务器上肯定是要有安装上 sshd 的服务的（SSH Deamon，SSH 守护进程/服务进程），而且我们默认我们的服务器有一个稳定的公网 IP 可供我们连接。我们记这个 IP 为 \u0026lt;server_ip\u0026gt; 这样。请根据使用情况修改它（不要带那个尖括号）。最后，我们假定亲爱的服务器管理员已经告诉了你服务器的 SSH 端口是哪个（记作 \u0026lt;port\u0026gt; 如果没说就是 22），贴心地给你创建了一个账户，名称为 \u0026lt;user\u0026gt; 且你知道它的密码，这样你就可以使用自己的账户进行远程连接了。如果没有这样一个账户的话，请压力你的服务器管理员，或者你要是有 root 权限，请创建一个普通用户来让自己 SSH 上去。我们不推荐 SSH 到管理员账户，毕竟网络还是太危险，管理员账户被攻破就等于服务器烂掉。你也不想看到，服务器被坏蛋攻陷之后四处破坏吧（）\n那么我们开始吧。\n让我连连 其实连接要用的命令相当简单。\n1ssh \u0026lt;user\u0026gt;@\u0026lt;server_ip\u0026gt; -p \u0026lt;port\u0026gt; 也就是说，告诉 SSH 你要走哪个端口链接哪个服务器上的谁。还是挺直观的，对吧？如果你是第一次连接这个服务器的话，你的终端会告诉你，这个服务器我不认识，你要信任这个服务器吗？你得明确地写 yes 来把这个服务器的信息添加到你信任的服务器列表里，不然你就是不信任它，不会连上去的。后面一切顺利的话，你需要输入你要登录的账户的密码。要特别强调的是，你在输入密码的时候是看不到密码的（什么都不显示的），打密码时看不到任何的结果是正常现象。当你输入好密码之后，按下回车，密码正确就会把你放在远程服务器上啦。你可以用 who 或者 hostname 来确定你现在在哪里，这两个命令分别会告诉你你的账户名以及你的主机名分别是什么。\n在成功连接后，你可以试试别的命令，但是你也可以想办法绕过密码登录：改为使用密钥登录。\n用密钥让我连连 我们保持上面的会话不要关掉，另外打开一个窗口，此时新打开的窗口应该是我们自己电脑上的终端会话。此时我们使用命令：ssh-keygen 然后回车，就会进入 SSH 密钥生成的步骤。虽然这里的信息还是有点重要的，但是如果你嫌麻烦，就一路回车就行了。它会问你你想采用什么加密方式，一般有 RSA 或者 ED25519 之类的方式，默认一般就不错；另外它还会问你你要不要使用口令，也就是使用这个密钥时通过口令验证是不是你本人生成的密钥，如果你不输入任何东西直接回车则代表你不需要口令。最后它会显示出一大堆东西，什么图片指纹什么的，不太用管。总之这里就是已经生成了一对密钥了。\n此时我们进入家目录下的 .ssh 文件夹里：cd ~/.ssh，里面就有你刚刚生成好的密钥对，我们以采用 ED25519 加密方式生成的密钥为例，你会看到有 id_ed25519 和 id_ed25519.pub 两个文件，其中 id_ed25519 是你的私钥，一定要保护好不要给别人看，它代表了你的身份；id_ed25519.pub 则是对应的公钥，你应该把它交给你信任的服务器上。具体做法是：使用 cat ~/.ssh/id_ed25519.pub 来把你的公钥打印到屏幕上，然后用鼠标，或者什么方式，把它记录下来；随后我们到远程登录的那个会话，找到 ~/.ssh/authorized_keys 这个文件（没有就创建一个），打开它，把你的公钥放在最后，就 OK 啦。这样操作后，以后在登录这个服务器时，SSH 会让服务器验证你的签名，如果发现你对应的私钥能对上它存在 authorized_keys 里的公钥，那你就可以直接登录上服务器，无需输入密码了。甚至有的服务器强制要求你使用密钥登录，在第一次密码登录后就会要求你上传公钥后，关闭使用密码登录，只保留使用密钥验证登录。\n不管怎么说，在上传公钥后，你可以尝试重新打开一个窗口来连接服务器了。注意：先别急着关那个你已经打开的窗口，因为如果操作有问题导致没有成功配置密钥登录的话，你就得重新用密码登录了；如果你的服务器有上面说的安全设置，那么你可能就得找系统管理员帮帮你了。所以，不要轻易关闭已经打开的会话窗口。\n如果连接成功了，恭喜你，你的连接过程变顺利了一些！但是每次连接的时候，都得像念咒语一样念自己的名字，念IP地址甚至念自己要连接的端口号，太麻烦了。有什么更方便的办法吗？有的，兄弟，有的。\n配置服务器别名 我们可以把服务器 IP 地址、端口号和自己要登录的账户名等都打包起来，做成一个服务器别名。这样我们就可以直接用服务器别名来一次性填入这些预设。\n要实现这个，我们在 ~/.ssh 文件夹下创建一个 config 文件，在里面添加如下内容：\n1Host the_server 2 Hostname \u0026lt;server_ip\u0026gt; 3 User \u0026lt;user\u0026gt; 4 Port \u0026lt;port\u0026gt; 要记得把这些带尖括号的内容都换成你应该填的信息哦。上面的信息就让我们成功创建了一个服务器别名，名字叫 the_server，我们要连接这个服务器时我们就只需要：ssh the_server 就可以啦。\n为什么我们说它是 服务器别名 呢？实际上，我们确实 只 设定了服务器的别名，也就是给那一串 IP 或者某个域名起了一个自己的名字而已。那么那些 User，Port 之类呢？这些实际上是我们的默认配置。比如，如果我们还有一个用户 \u0026lt;user_2\u0026gt; 在这个服务器上，那我们临时起意，要登录这个账户时，我们只需要 ssh \u0026lt;user_2\u0026gt;@the_server 就行啦。\n我们现在已经了解怎么使用 SSH 了，也了解过怎么使用 CMake 了，那？是时候试试把它们和 VS Code 结合起来了！\nVS Code + CMake + SSH 虽说如此，其实真的就是点点点……我就大概敷衍一下吧（）\nCMake 集成 下面是我维护项目的 CMake 集成工具栏的截图页面：\n先看最左边：侧边栏有个 CMake 的 Logo，它就是 CMake 集成工具的入口，从这里就可以开始点点点了。左边的 PROJECT STATUS，它描述了现在 CMake 的一些配置。这个项目的文件夹是 TaskScheduler，选择的配置和构建选项我们都还没有选。\n中间的大页面就是我们的 CMakeLists 了。这个项目中我要编译两个程序：一个 main_cpp，一个 sim_kernel，而且下面还有条件编译：如果是 UNIX 平台且不是 APPLE 平台的话，要给 main_cpp 这个程序链接上 pthread 这个库。另外我们给项目起名为 SimLauncher，要求标准是 C++17，输出文件夹我们指定了是 build/bin 这个文件夹。这些都是比较一目了然的。\n为了编译这个项目，我们需要选择项目配置和编译配置。我们鼠标放在右边栏 Configure 里的那个 [No Kit Selected]，会有一个铅笔图标出现，点它，就会弹出一个顶部对话框，让你选择你要用什么编译套件：\n由于我们是在 Windows 上且我安装了 VS，自然我有一套 MSVC 的编译套件。这里第一和第二个选项是让扫描一下看看还有没有别的套件；第三个是不手动指定，不知道的话选它也行，我不推荐选就是了；底下四个里，他们的区别主要在于编译的平台架构不一样：amd64（有时你会看到所谓 x64，可以当作一个意思）就是说你的 CPU 架构是 64 位的架构，一般我们就选它没问题。而 x86 则是所谓的 Intel 8086 架构处理器，即 32 位处理器架构。我们不考虑这个，64 位挺好的；可以看到还有两个选项，里面两个都出现了，比如 amd64_x86，意思是自己的机器是 64 位架构，而编译目标则是 32 位架构。我们用不上这么高级的功能啦。\n选择好之后，CMake 就会开始进行配置了，结果默认会放在 build 文件夹里，顺带会把构建、调试和运行目标都设置为了 ALL_BUILD，即所有的目标都会进行，不止选择某一个。当然，鼠标放上去也会出现小铅笔，自己选就可以。build 文件夹中的内容我们就不用多管了，感兴趣的话可以自己查一查。\n配置结束后，我们就可以构建项目啦。鼠标放在 PROJECT OUTLINE 上就会出现一个小图标：\n旁边的小虫子是启动调试的按钮，最左边则是配置所有的目标。我们点这个像是往箱子里装东西的图标，它就是我们要的项目构建按钮。点了之后右边 OUTPUT 窗口就会出来一堆信息，告诉你你的构建过程执行得怎么样了：\n当你看到最底下出现了 [build] Build finished with exit code 0 的时候，就像许多程序以返回 0 表示执行无误那样，说明你的构建成功了。由于我们 Configure 那里选择了 Debug，以及我们的 CMakeLists.txt 中我们的编译结果设置在了 build/bin 文件夹中，我们可以在 build/bin/Debug 里找到我们的编译结果。\n当然，我们也可以不手动执行，毕竟手动执行的话得学会手动用调试器调试，我们还是借助 VS Code 集成的功能吧。点刚刚提到的那个小虫子，就可以进行调试了，调试前记得先打个断点，不然程序不会停下来等的。\n最后可以提一下，你的页面左下角的几个按钮也有个 Build，小虫子和一个播放键一样的东西，它们分别代表构建、调试和运行，是和 CMake 连着的。你也可以使用底边栏进行这些操作。\n介绍完 CMake 集成，我们下来就尝试连接到远程服务器吧！\nRemote 功能 实际上 VS Code 的远程功能很像预载的一样。记得上面的那个底边栏吗？最左边的那个蓝色的按钮就是启动远程链接的按钮。我们点击它就会出现类似这样的窗口：\n由于我的机子上有 WSL（Windows Subsystem Linux）所以这个页面多出来了链接 WSL 的选项。我们选择 Connect to Host...，会出现\n我已经有 5 个选项了，这是因为我的 .ssh/config 里有这 5 个配置：这个插件实际上会读取这个文件并放在这里。请选择你要连接的服务器，这样就会开始连接了。\n由于我们是用 VS Code 进行的连接，我们需要先等服务器那边装好 VS Code 的服务端程序，可能需要等一会儿。这个过程取决于网速，一般几分钟就搞好了。连接上了之后就会打开一个新的 VS Code 窗口，但是左下角那里会显示你登录的服务器的名称（别名），而你打开的远程的文件夹也会在标题栏显示服务器的名称（别名）。\n然后？然后就结束啦。你在本地的 VS Code 上怎么做开发，就怎么在远程的 VS Code 上面做开发咯。\n那么至此，希望你已经成功配置好了你的 VS Code，且能正常使用它来进行远程开发。\n后记 这篇文章实际上算是那篇关于 VS Code 和 Python 配置的升级版，在安装步骤上更详细了，且加入了 CMake 的一些介绍。上周刚刚在我手上的远程计算服务器上进行了本地编译，感觉挺好的，就记录了一下。毕竟程序这个东西还是本地编译的产物，在计算效率上更高吧（感觉上）。\n另外这个文章的诞生过程还产生了一个副产物，也许你已经发现了：我的一些截图是在 Windows 11 的虚拟机上截下来的。因为我自己用的版本已经充斥着各种各样的我自己的配置了，如果要聊怎么安装、怎么从零开始做配置的话，用我自己的电脑肯定是不合适的。\n然而在虚拟机上安装 Windows 比我想象中的麻烦的多：各种神秘小 Bug。一开始是安装在 Orcale VirtualBox 上的，开机经常黑屏，结果群友推荐说 Win 上装 Win 可以试试 Hyper-V；搞到 Hyper-V 上之后，虚拟机经常断联：不一会儿就会登出账户，很是恼火。最后经过一番努力（以及一些时间），终于是在 VirtualBox 上装好了，但是网络又不太对，后面网络又玄学般的好了。\n一想到搞了这么多只为了在干净的环境里装一个 VS Code 而已，就感觉很难绷……不过，结果总归是好的吧。一点碎碎念，希望博您一笑。\n那么一如既往地，祝您生活愉快，顺祝工作顺利吧。\n这款软件的全称应该叫做 Visual Studio Code，而日常我们会将它简称为 VS Code，VSCode，vscode 或者 VSC。我个人还是倾向于使用 VS Code 这样的写法。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n消息来源：The Untold Story of Visual Studio Code: A Revolution in Software Development ，讲了 VS Code 的故事，挺有意思的。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-09-17T14:35:38+08:00","image":"/images/猛独が襲う.png","permalink":"/zh/posts/tools_note/vsc_remote/","title":"VS Code 也许其实是 IDE"},{"content":"最后一节，聊聊如何在 GitHub 上进行多人协作吧~\n头图信息请参考第一节内容，谢谢~ 选曲依然选到了小伞的个人曲，希望你喜欢！\n所以，Git 的远程到底是什么东西？ 我们之前提过，Git 通过 远程 (Remote) 来实现和他人合作。我们甚至已经介绍了一些和远程进行交互的命令了。然而，Git 究竟是怎么实现这一功能的？这个功能有什么特点呢？我们和他人进行协作开发时，有什么要注意的点呢？我们一点点来介绍。我们先系统地介绍一下 远程 是什么东西吧。\n远程：不过是另一台装了 Git 的机器 实际上远程并不神秘。得益于 Git 分布式的特性，每一台安装了 Git 且拥有项目源码的机器都可以说是某个远程。或者说远程是相对的：有两台机器 A 和 B，都拥有同一个项目的源码并安装了 Git，那么对于 A 来讲 B就是远程，而对 B 来讲 A 也是远程。而不同机器之间的通信则可以通过 SSH 完成，如果是比较老的项目，或者不希望通过 SSH 来连接的情况，我们甚至可以使用邮件进行通信，传递代码，就像 Linux 内核，GCC 等项目那样。总之，通过这种 每个设备都存储一份代码 的形式，Git 就实现了分布式的代码存储，每个设备既可以是正在工作的仓库，又可以是为他人提供源码的仓库。\n然而，大量的实践证明，有时候有一个中心服务器真的会很省事。大家把代码的更新放在一台公开的服务器上，然后可以从这一个服务器上拉取代码，可以很好地保证开发进度以及代码的一致性。不过这样又失去了 Git 天生的分布式特性。所以，到底哪个更好呢？真分布式还是采用某个中心的代码服务器？\n对比：真分布式 vs 中心仓库 Git 一开始就是支持分布式存储的，这样最大的好处在于，每一个人拥有的仓库都是这个代码的一份备份。假如一个项目参与的人越多，这个项目的备份就会越多。而且每个人都是自己拥有的这份代码的主人，自己对代码拥有完全的掌控权，可以自行决定自己的代码要不要提交给别人，或者要不要接受来自他人的变更等。这种去中心化的，人人平等的思想非常符合自由开源的精神。另外这种方式不是很依赖某个中心设备，不会因为这一个设备断掉而直接完全不可用。如 Linux 内核，GCC 编译工具链等都没有一个一般意义上的中心代码服务器用来让所有合作者都往里面推送代码或者从里面拉取更新。\n然而中心化的服务器自然是有它的好处的。比如说有三个人在同一个项目工作，三人没有一个中心代码仓库而是互相传递更新的代码，如果三位都对代码进行了不同的且有一定冲突的代码，那么在同步时肯定会面临很多的麻烦。而如果他们的代码存放在一个中心服务器上，那这个服务器就会忠实地记录每个人对它的修改，进而避免所有人的代码一起涌上来的尴尬，同时保持大家的进度都在同一阶段，避免每个人有自己的想法进而没法合并（即便其实可以用分支也是可以的）。另外如果在某个项目里，开发团队是有明确分工的，那么这时候应该有一个人负责对代码进行审阅修改，而此时中心服务器的优势就体现出来了，让审阅者（们）单独拥有对中心服务器代码的修改权，然后审阅者（们）就可以直接在该服务器上进行审阅合并等操作，且所有的合作者都可以直接从该位置拉取最新的代码。也许有人说，把代码直接交给审阅者不就行了，为什么非得有个中心服务器的参与？然而如果真是这样的话，审阅者他自己的电脑也在某种程度上成为了那个中心服务器了。就我个人而言，我是想不到什么普通项目有必须使用去中心化工作流程的必要的。一般来讲都还是有一个（当然也可以多个）代码托管处会好很多。\n那么，谁是这个星球上最受欢迎的 中心代码仓库 呢？\n代码托管平台们 GitHub: The blessed one 向您隆重介绍，这个星球最受欢迎代码托管平台，汇集无数人智慧结晶，全世界最大同性（？）交友网站（迫真，95%以上的用户都是男性），GitHub。第一期其实已经有介绍过，GitHub 和 Git 之间的关系是什么样的，以及大家可以怎么使用 GitHub。这里我们再多聊两句。\n上面我们说 GitHub 是代码仓库，其实是不太严谨的，应该说它是一个代码托管平台，任何人都可以在上面创建自己的仓库，然后把代码放在上面。当然，GitHub 的功能肯定不仅限于此，它上面集成了很多很好用的功能，比如 Issues, Pull Requests, GitHub Actions 等，也提供了非常美观现代化的图形界面，能让大家直观地看到代码仓库的变更历史。它甚至提供了 GitHub Pages 来让开发者可以把静态的网页托管在上面，这让很多项目得以拥有自己的网页，或是用来做文档，或是用来宣传，功能非常多样。顺带一提，本博客就是托管在 GitHub Pages 服务上的。GitHub，伟大无需多言。\n目前 GitHub 上已经有了 2亿6千万以上（268 million+）的公开仓库了，仓库来源于全世界各地，且每天都有大量的信件仓库，大量的拉取请求，以及大量的代码提交。大家在 GitHub 上的活动已经远不止简单的提交自己的代码这么简单，开发者们会在一个项目内合作开发，设定开发目标，合并他人的拉取请求，为感兴趣的项目做贡献或者提出问题，帮助他人解答问题等等。其仓库类型也是多种多样，有各种语言写成的千奇百怪的项目，也有一些很不错的资源整合项目，甚至你还会看到很多令人难绷的小作文以及互联网记忆。总之，除了托管自己的代码之外，你还可以看看别人的代码，玩玩别人的项目，和别的开发者讨论（吵架），给别的项目做贡献（改错别字）。用途多种多样，就看怎么用了。\nGitHub 起源于 2007 年，至今已经 18 年历史了。18年来，GitHub 变得越来越完善，功能越来越丰富。2018 年的时候微软收购了它，在那之后就成了微软的一个子公司，而 2019 年的时候 GitHub 宣布它支持个人创建任意多个私有仓库，将 GitHub 的受欢迎程度推上了一个新台阶。而就在前不久（2025年8月11日），其 CEO 宣布卸任，GitHub 被合并在微软的 Core AI 部门。我个人认为这应该算是坏消息，毕竟 GitHub 作为全世界最大的代码托管平台，几乎已经是 开源精神 的代言人了。本来还是一个比较独立的子公司的 GitHub 现在被微软直接合并进一个部门内，怎么想都感觉其独立性要进一步下降了。是在令人感到可惜。但是就目前而言，GitHub 也依旧是同类产品中知名度最高的一个，甚至可以说是某种标准了。所以，还是先用着吧。如果 GitHub 哪一天真倒了的话，肯定还会出现新的继任者的。\nGitLab: 强大的自动化以及自托管 除了 GitHub 以外，当然还有别的托管平台。其中另一个比较受欢迎的项目就是 GitLab，它除了托管到 GitLab 的服务器上以外，还支持在自己的服务器上自行搭建代码托管平台，其中也支持和 GitHub 类似的很多功能，比如查看源码，Issues, Merge Request（对应 GitHub 中的 Pull Request），CI/CD（持续集成/持续部署）等等。其中的 CI/CD 是 GitLab 最出众的特点，以速度快效率高而著称。而且由于它提供自托管（通过 GitLab Community Edition）的特性，对于那些需要较强独立性的，不希望受到 GitHub 控制的（理由嘛……我也不知道），或者就是某些公司希望保持私密性的项目，GitLab 是一个很不错的选择。有很多项目都是使用 GitLab 进行托管的，比如 KDE，Paraview 等。相较于 GitHub 而言，GitLab 的隐私性还是更强一些。然而坏消息是，GitLab 在 24 年的时候宣布不再直接为中国地区提供服务，转而由本地服务提供商 极狐 提供。我也很难讲究竟应该用哪个……\nGitLab 的功能是非常全面的，如果你并不想要这么完善的功能，只是想在自己的服务器上搭一个轻量化的 Git 托管平台，你也可以选择 Gitea。它最大的特点就是轻量化了，拥有最基础的 Git 托管服务器所应该有的功能，不过随之而来的就是一些更复杂的功能可能没有，自动化相关的内容也许需要自己手动实现等等。\n讲了这么多，Git 到底应该怎么实现和远程服务器的交互呢？\nGit 与远程的交互方式 下面我们就来介绍一下 Git 和远程进行交互时会用到的一些命令吧。\ngit remote\n这个命令正如它的名字，是用来管理和远程相关内容的一个子命令。而且，就像 git branch 那样，如果你不带任何的参数，它会告诉你都有哪些远程是可用的。一般来讲，仓库会有一个名为 origin 的远程库，它一般就是你在 GitHub 上托管的位置了。如果你想确认远程的具体信息的话，你可以用 -v 或者 --verbose 来让 Git 把该仓库的所有远程地址都告诉你。你也可以用 show 子命令来让 Git 告诉你关于某个远程的详细信息，包括它会从哪里拉取代码，往哪里推送代码，当前分支和最新提交等等。\n而假如你的仓库还没有可用的远程，你可以使用 add 子命令来让 Git 添加一个远程仓库作为你这个仓库的远程位置。比如说你在个人 GitHub 账号下有一个空仓库，地址是 https://github.com/abc/test_repo，其中 abc 是用户名而 test_repo 则是仓库名，此时你就可以使用 git remote add origin https://github.com/abc/test_repo.git 来把这个仓库作为远程仓库添加到当前仓库名下，并给它以 origin 的名字。你当然也可以修改这个远程仓库的地址咯，使用 set-url 即可，用法和 add 差不多，只不过远程名必须是已经有了的远程名字。\n假如你感觉某个远程仓库名让你很不爽，你可以使用 rename \u0026lt;old-name\u0026gt; \u0026lt;new-name\u0026gt; 来修改它；如果这个远程已经不需要了，或者你因为什么其他的原因要删除它，也很简单，使用 remove \u0026lt;remote-name\u0026gt; 子命令就可以啦。还是比较简单易懂的。\ngit clone\n除了给现有的仓库添加远程，我们当然还可以从远程仓库复制一份到本地来呀。通过使用 git clone \u0026lt;repo-url\u0026gt; 命令就可以把一个远程仓库下载到本地。下载下来的仓库会默认放在当前文件夹下的和仓库名同名的文件夹里，且仓库名默认会是和远程仓库的名字一样的，且由于是从远程克隆下来的，它会自动设置好远程的位置（起名也是默认的 origin）。当然如果你想把该仓库下载到其他位置的话，也可以在仓库的 URL 后面添加上你要下载到的文件夹。\n一般来讲，使用这个命令就已经足够了。然而有时候，也许你会遇到仓库里使用了 Git Submodules（Git 子模块）的情况。我们这里不打算介绍 Git 子模块，然而如果你在克隆一个带有子模块的项目时没有顺带让仓库克隆其内部的子模块，后面在使用过程中又得重新搞一些有的没的。为了避免这样的麻烦，在克隆时可以直接带上 --recursive 的参数，告诉 Git“帮我把这个仓库里所有的 Git 子模块也一并下载下来”。这样就省去了后面重新配置 Git 子模块的麻烦。\n另外也许有时候你克隆一个仓库的目的并不是在仓库里做开发，而是直接使用它（像一些 ZSH 插件是托管在 GitHub 上的），此时你也许不关心它的提交历史，或者这个仓库太大了而你又不需要用上过去的所有提交历史的时候，你会希望只获取到最新的几个提交就够了。Git 非常贴心地提供了 --depth \u0026lt;number\u0026gt; 的参数，你可以指定你只想克隆到最近的多少个提交即可。这样能非常好地节约克隆的时间，对磁盘和网络都比较友好。\ngit push\n当我们本地做出一些修改，有了一些和远程不一样的提交之后，我们就可以把本地的提交推送到远程仓库了。当我们直接使用这个命令的时候，Git 会默认推送当前分支到远程仓库的对应分支上。默认的操作就是最高频的操作了，这一点很不错。而当我们需要推送到某个特定分支（比如远程分支和当前分支名称不一样的时候），我们可以先写上远程分支的名字，再写上当前分支的名字：git push \u0026lt;remote-branch\u0026gt; \u0026lt;local-branch\u0026gt;。我们也可以通过加上参数 -u 或者 --set-upstream 来修改推送的默认分支。\n也许我们的仓库拥有不止一个远程，这时候我们可能需要指定我们要推送的是哪个分支。这时候 push 的写法会有一定的变化：git push \u0026lt;remote-name\u0026gt; \u0026lt;local-branch\u0026gt;:\u0026lt;remote-branch\u0026gt;，即我们要先指定推送到哪个远程上，然后用冒号分割本地分支和远程分支。\n这个写法完全地指定了所有推送的信息，而且这个写法还有一个隐藏功能：如果我们不写本地分支，直接写 git push \u0026lt;remote-name\u0026gt; :\u0026lt;remote-branch\u0026gt;，意思就是告诉 Git“我要把空分支推送到远程覆盖那个远程分支”，结果就是让 Git 删除远程分支。当然，删除远程分支也可以使用 git push \u0026lt;remote-name\u0026gt; -d \u0026lt;remote-branch\u0026gt;（或者用 --delete）来实现，这样的语义更加明确，不过我猜也是给上面的方法一个新的包装而已。也许我们远程的分支太多了，我们不想要远程的那些没有本地对应的分支，此时我们就可以使用 git push --prune \u0026lt;remote-name\u0026gt; 来删掉（修剪）那些多出来的分支了。\n除了我们可以推送分支外，push 还兼并了推送标签（Tag）的功能。我们可以直接使用 git push --tags 来推送所有的标签，也可以 git push \u0026lt;remote-name\u0026gt; tag \u0026lt;tag-name\u0026gt; 来推送某一个标签。至于标签是什么你可以认为它是一个独立于分支树之外的持久性的快照，会保存某一时刻的信息，且不会强依附于某个分支，一般是在发行时会给某个版本打上一个标签，没错，就是那个 xx.xx.xx，一般标签都会这么打。我们这里就不过多介绍了。\n最后在这里要留一个提醒。有很多教程推荐在 push 出问题的时候使用 -f 或者 --force 参数。这个参数的功能是让 Git 不做任何的检查，用本地的状态去强行覆盖远程仓库的状态。如果你真的是在和别人一起协作的话，不要这么做。会被人移交蔡司。\ngit fetch\n这个命令是用来让我们从远程获取更新使用的。它默认会获取默认远程的所有分支的最新更新，如果我们要指定是从哪个远程获取，只需要把那个远程的名称放在后面即可。如果我们想从所有的远程都拉取更新，则可以使用 --all 来告诉 Git。类似于 git push，我们还可以在 git fetch 的后面加上 -t 或者 --tags 来让它从远程获取所有的标签信息，或者用 git fetch --prune 或 git fetch -p 来让 Git 删除掉本地多出来的，远程没有的分支。\n需要注意的是，Git 在设计上让 fetch 子命令不直接把远程的内容和本地进行合并。也就是说，git fetch 只会更新 本地的远程数据库，而不会把远程的数据直接放在本地的工作目录里。如果想要这么做的话，我们可以把远程的分支 通过 git merge 来 合并 到本地上。要指定远程分支，我们需要用 \u0026lt;remote-name\u0026gt;/\u0026lt;remote-branch\u0026gt; 来告诉 Git 我们要的是在 \u0026lt;remote-name\u0026gt; 上的 \u0026lt;remote-branch\u0026gt; 分支。\n如果你感觉这实在是太麻烦了（真的很麻烦），我们可以使用下面这个融合了 fetch 和 merge 的命令：\ngit pull\n当我们很明确地是要把远程的变更直接应用到本地仓库时，我们可以直接 git pull 来拉取远程更新。由于它涉及“合并”的步骤，所以它的默认行为是把默认远程的变更获取到本地后把当前分支对应的远程变更应用到本分支上。也就是说，默认行为是从远程更新当前分支状态。\n自然，我们可以指定它的更新对象。我们使用 git pull \u0026lt;remote-name\u0026gt; \u0026lt;branch\u0026gt; 就可以指定要拉取的远程是哪个，并且指定要更新的分支是谁。需要注意的是，新仓库里执行 git pull 时它可能并不知道要使用什么方式把远程的变更应用到本地分支，因为除了可以使用 merge 外，还可以用变基操作 rebase 来做这件事。因此可能 Git 会询问你，是要使用哪种方法。一般我们直接用 fast-forward 就可以了，最简单方便的方式，且不会让 Git 提交历史出现一大坨自己都不认识的分支。\nGit 的远程命令我们就介绍这么多。实际上在单人开发时，几乎不会用到这么复杂的命令。平时就是简单的 git pull 更新一下，然后做好变更之后就 git push 上去，就可以了。新仓库可能得用 remote 来添加一个远程仓库，平时都不怎么用管的。\n和别人协作时要注意些什么 然而，和他人共同协作时，并不是只要知道这些命令就 OK 了，光是知道这些命令并不能帮你成为开源领域大神，不慎的操作很有可能换来别人的嘲讽……下面就简单提个几点吧~\n另外，我其实也没有参与很多开源项目，如果你觉得我下面的建议不够权威，那确实是不好意思，实力不够（）不过我个人认为还算实用/中肯吧。总之希望能帮到你。\n阅读项目说明 参与到项目前首先应该尝试阅读这个项目的说明。如果你对某个项目感兴趣的话，相信 README 应该是已经看过了。然而如果要参与开发，光是读 README 很有可能不够。大型项目一般有所谓的 Code of Conduct，也就是所谓的行为准则。如果有这么个东西或者类似的要求的话，请阅读这些内容后再开始尝试给这个项目做贡献，不然项目的代码审核很有可能会直接拒绝你的 PR（Pull Request）。\n先查查 Issues 协作开发一般是以某个 Issues 开始的，毕竟有了这样的问题，就有了针对这个问题进行开发的目标了嘛。查看 Issues 的主要目的是看看当前项目有哪些问题等待改善，另外也是看看有没有人已经尝试解决某个问题了。如果你感兴趣的问题已经有人在跟进了，这时候最好和跟进的人商量一下看看有没有什么可以帮忙的。不要稀里糊涂地直接提交一个 PR 上去。\n也许你需要单开一个 Issue 有些项目会要求说，如果你想提交 PR，请给这个 PR 一个对应的 Issue，或者开启一个 Issue。这是一个不错的实践方式，让每个 PR 都有明确的目的性。之前我提交的 PR 就有被要求过开一个 Issue，并在 PR 里写明对应是要解决哪个 Issue。坏消息是我的 PR 好像没有通过自动检查……明明是一个很简单的 PR 的说……\n等下，PR 到底是啥？ 也许你早就想问这个问题了。我也是，经常听到别人说什么“欢迎 PR”，“提个 PR”，“合并 PR”之类的话，但是一直不清楚这到底是什么意思。我故意保留了这个风味，直到现在才解释，这样你才知道开发这块儿有多少谜语人 （bushi，补药打死窝呜呜呜）。\nPR 的全称是 Pull Request，也就是所谓的拉取请求。什么？拉取请求？我在请求什么东西？拉取？我一开始也想不通我要请求什么拉取，后来借助 AI 的伟力以及一些别的资料，我终于明白了：Pull Request 就是请别人审阅你提交的代码然后把它合并进项目里。所谓的“提 PR”就是发起合并请求罢了。\n那么，既然 PR 是合并请求，为什么不是 Merge Request 呢？没错，GitLab 就是这么干的。GitLab 里你不提 PR 而是提 MR。两个名词指代的是同一个操作。那么 PR 的 P 究竟是为什么呢？根据一些信源的解释，PR 的 Pull 的意思是说，你想让别人把你的修改 Pull 到他们那里。就好比说，我给项目搞了个很牛的特性，但是现在只在我自己的电脑里。我希望别人也能用上我写的这个特性，所以我要写个说明文字来介绍我这个修改都干了些啥，为什么牛逼，以及为什么推荐他们都把你的这份变更 pull 到他们那里。相比起来，Merge Request 就很直白了：我做了有一份更改，现在请你把这份更改合并到你们的分支里。\n从语气上来讲，PR 显然更客气一些。我在知道这个解释之后第一反应是，PR 想表达的是，如果你觉得我的变更很棒，你就可以把我的这份放到你那里。如果你觉得我的修改不好，你不 pull 就是了。而 MR 的话，相比之下就略显强硬了：你能不能把我的更改合并到你那里。也有可能是我神经过敏吧，反正在知道 PR 的词源之后，反而更喜欢这个名字了，感觉 MR 反而有点奇怪，哈哈。\n不过你也应该明白了，在提交 PR 时应该给你想提交的变更做出一些说明，比如你改的东西的简要总结，为什么要改，改了之后会怎么样之类的。如果只是光秃秃地要求把代码合并进去，代码审核者估计也懒得细看你的代码究竟都干了些啥，进而选择不管你的提交。\n为什么不直接把变更推送进仓库？ 一般而言，仓库都是有所有权的，即便它是公开仓库，它也不是允许所有人都直接向仓库里提交代码的，一般只有直接维护者有权利变更仓库内的内容。如果一个外来者希望修改仓库内容，基本都是通过 PR 的方式来把代码变更交给审核者，让审核者决定要不要合并进仓库里。当然，如果你就是仓库拥有者或者拥有仓库的编辑权限，那你也许是被允许直接提交代码的。然而，如果你是在和好几个人一起合作的话，最好还是有个 PR 的过程，毕竟 PR 提供了一个交流讨论的地方，直接提交代码的话也很难说这个提交就一定是最合适的。\n注重交流合作，语气这一块儿 可以发现，在 GitHub 上合作开发肯定是避免不了和别人交流沟通的。作为国际化的平台，一般而言还是推荐用英语交流，除非是一眼国人项目/国人特供项目/没有外国人用的项目，比如某些国内特产（科学上网），近乎是用不上英文的。如果您的英文不是很好，emmm，我想说现在的翻译软件都很好用，也不一定非得只能把英文翻译成中文，也可以把中文翻译成英文嘛。\n另外就是注意礼貌这块儿（）没人会喜欢和一个没素质的人合作的，一般而言。不过呢，有时候也不是说没礼貌吧，可能就是单纯的就事论事而已。记得之前看过的 黑客的自白 里说过，很多人其实单纯地就是懒得用那些套话，喜欢只针对问题做出提问，并解决问题。这一点我的建议是自行把握吧，我本人持“礼多人不怪”的态度，不过如果有人不喜欢这套，那我也无所谓，只要别骂人就行。\n别在 Issue 里面灌水 很明显的一点。GitHub Issue 提供的本来是一个关于问题的讨论平台，不宜在里面灌水聊天，搞一些跑题。然而这点好像在某些仓库里没有很重要？毕竟当大家发现跑题了的时候，一般也不是刚刚才开始跑题吧。\n目前就只想到这几点了。如果你有别的觉得可以补充的，欢迎告诉我。我会补在里面的！真的！\n后记 说实在的，写这篇的时候有点江郎才尽了。因为我本来也是正在学习 Git 的来着，写这些东西有一部分理由是为了复习巩固/趁机学习 Git 命令的。因此，如果里面有什么内容上的纰漏，还望海涵。\n也许有人会问，现在 Git 的 GUI 客户端已经这么多了，为什么还要学习 Git 的命令行操作？你说得很对：Git 现在有很多 GUI，甚至它本身已经带了一个 GUI 来着。但是，我还是觉得命令行最贴近 Git 在设计之初的使用方式。另外得益于 Git 命令行交互功能非常完善，有时候我们可以写一个简单的脚本来自动化 Git 的一些工作。而这些，GUI 是没法替我们办到的。然而不可否认的是，GUI 真的很不错。至少，Git 的分支状态在命令行下还是不够直观的，这一点必须承认。而有了 GUI 的 Git 它们的分支图一般都画的很漂亮。实不相瞒，有时候我也会直接用 VS Code 的 Git 集成来进行提交。因为很方便嘛，哈哈哈。\n还有人问，现在好用的 VCS（版本控制系统）也早已经不止 Git 一个了，为什么不介绍更新的工具，而是这个老套到甚至有点老掉牙的，对用户并不是非常友好的 Git 呢？我的回答是，它第一没有那么不堪，第二实际上它也许已经成为了某种事实标准，第三它真的很好用。再加上几乎所有的 VCS 都会提供从 Git 仓库转换到其他格式的仓库的功能，这更说明了 Git 在版本控制系统这个领域的重要地位。如果您会使用 Git 的话，相信其他的 VCS 系统也不会难到您。当然，我也很愿意尝试一下别的工具，比如最近风头正盛的，使用 Rust 编写的 Jujutsu。不过这也是后话了。（Git 真的很好用了）\n这篇都快写完的时候我才意识到，我还没有介绍 Git 怎么查看提交历史。感觉放在这一篇里还是有点尴尬，毕竟这一篇主要还是在讲怎么和其他人协作，而 Git 的历史查看功能放在这里就不太合适了。思来想去，我放在了上一篇里，因为它和分支还是有很大的关系的。如果你看过了上一篇，结果又没有收到上一篇的更新的话，我建议你可以回去看看（）\n另外，这个系列真的离不开一些工具，比如 tldr（包名是 tealdeer，不过其他版本的也很不错），以及 ChatGPT,DeepSeek 等 AI 工具的协助。当然 Git 自带的文档也很不错，在 Linux 上可以直接 man git 查看总的介绍，使用 man git-add 这样的格式来查看 Git 各个子命令（这里是 add）的使用方式。而在 Windows 上，由于没有 man，你可以使用 git help git-add 或者更简单的 git add --help 来查看在安装 Git 时就已经附赠的 Git 的文档。而且 Git 也有自己的“官方教材”: ProGit，它有多种语言的版本，作为教程而言自然是比 Git 的文档写的好懂的多。不过 Git 的文档写的也很不错就是了。\n最后，非常感谢您能看到这里。如果你看完了这三篇的话，我更是感激不尽。如果这些文字能帮到你那就太好了，如果没有起太大帮助的话，希望能逗你一笑。哎呀至少笑一下吧，显得这个系列的文章也不是一无是处嘛。（之前写的那个 关于蛇引理的文章 里也说如果能博读者一笑就好了，结果发现，根本没几个人看呀可恶……更别说逗大家笑了，唉，实在是太失败了。）\n那么，一如既往地，祝您身心愉快，工作顺利，少出 Bug ~！\n","date":"2025-08-26T18:28:16+08:00","image":"/images/Tatara-Kogasa.jpg","permalink":"/zh/posts/shell_note/git_how/git_3/","title":"（也许是）一个 Git 教程？其三"},{"content":"在知乎上看到了这样一个有趣的问题，以及很厉害的回答，实在是很有意思。这里就写一写我的解决这个问题的方法以及当时的心路历程吧。\n头图出自 fasnakegod 大大的 清水吟，搭配的曲子是 DDBY 的 Cramped space，笛声真的很棒，搭配轻快的鼓组和旋律，给人一种很悠闲放松的感觉呢。希望你也喜欢~\n问题介绍 如果您不想点开那个链接的话，这个问题实际上只有一行：求 2025! 从右向左起第一个不为0的数字是什么。这算是一道奥数题吧，同时也是某本书（具体数学）上的课后习题。\n由于 2025! 几乎是没法用计算器简单计算验证的（即便知乎上有神人算出来了这个值），我们可以权当这个问题是在问，某个大数字 $n$ 的阶乘：$n!$ 在十进制表示下的，从右至左数第一个非零数字是几。\n这个待求数字描述起来好麻烦。我们就称这个数字为 $A$ 好了。另外，我们稍微滥用一下符号，用 $A$ 来取出我们要的那个数字，比如数字 $12345000$ 的 $A$ 就记作 $A(12345000) = 5$ 了。另外我们为了方便讨论，把 $n!$ 的结果表示为 $a_k\\dots a_3 a_2 a_1$，即给每一位上的数字都编个号。如 $120$ 的 $a_3 = 1$，$a_2 = 2$，$a_1 = 0$。\n好了，我们现在开始吧，尝试解决这个问题。\n第一次尝试：肯定得和质数有关，吧？ 这个题肯定得有个通用算法，但是在发现这个通用解法前，我们还是手动尝试几个简单的值，观察下有没有什么规律吧。\n不要 $0$，谢谢。 比如我们计算 $5! = 1\\times 2\\times 3\\times 4\\times 5 = 120$，那么我们要的 $A$ 就等于 2 了。这个结果里有一个0，它源自于 $2$ 和 $5$ 的乘积。这一定很重要！我们还知道 $4\\times 25 = 100$，$8\\times 125 = 1000$ 等。我们肯定在求 $A$ 时肯定不希望考虑这些“没用”的数字，因为它们的结果对我们要求的 $A$ 没有任何的影响。\n我们更进一步，考虑上面几个乘积，实际上都是 $2$ 和 $5$ 的次幂相乘，或者说有几个 $2$ 和 $5$ 的配对。我们可以发现：如果阶乘在被质因数分解后，出现了若干 $2$ 和 $5$ 配对，那么这个配对就对最后的结果没有影响。\n好，那我们就把阶乘拆成质因数们吧！然后拆掉里面的每个 $2-5$ 对儿，最后就只剩下最后的一堆结果，我们把它们乘起来看最后一位数字，肯定就是 $A$ 了……\n对，对吗？算了，我们先算个简单的，我们让 $n = 10$ 看看结果吧。我们先找到质数们。10 以下的质数只有 $2, 3, 5, 7$ 四个，然后把 10 以下的数字们拆成质因数后统计它们的个数，得到：\n质数 个数 2 8 3 4 5 2 7 1 然后我们删掉俩 $2$-$5$ 对后把别的乘起来，得到结果是：$36288$ ，所以 $A = 8$\nOMG 好麻烦，怎么就两对儿？这还只是 $10$ 以下的质数，$100$ 以下的质数有 25 个嘞。这要怎么搞？\n其实我们应该只用算 $A$ 来着…… 但是我们好像也不用算整个结果吧，得到 $A=8$ 就已经 OK 了的样子。而我们想得到 $A$ 好像也只需要关注每一次乘出来的结果的个位数就好？\n我们看看这四个数字的次幂，它们的个位数都有什么特点：\n质数 1次 2次 3次 4次 5次 2 2 4 8 6 2 3 3 9 7 1 3 5 5 5 5 5 5 7 7 1 3 9 7 好像有点意思，它们的结果是循环的，且实际上只有 $2$-循环 和 $3$-循环两个循环。\n等一下，我们好像还没考虑别的质数，比如什么 $11$, $13$，$17$，$19$ 等。然而，嘛，我们可以发现，任何大于 $10$ 的质数们的末位只会是 $1, 3,7, 9$ 四个数字，而它们又恰好都在 $3$-循环的样子。\n可是，即便如此，我们要怎么数质数？这个方法强烈依赖把质数的个数数清楚这样的麻烦问题的解决。感觉还是不行……\n第二次尝试：至少，确实我们只用管最后一位 重复的 $1$ 到 $9$，是否预示了什么！？ 但是好消息也有，那就是我们锁定了 只管最后一位 的思路。假如我们只考虑最后一位数，我们又何必考虑什么质数什么的东西呢？比如我们考虑 $A(20!)$，那么我们就得计算 $1\\times 2\\times \\dots \\times 9 \\times 10\\times 11\\times 12\\times \\dots\\times 19\\times 20$ 的 非0个位 们的乘积结果，也就是反复计算 $A(9!)$。在计算结束后，我们还得考虑乘上 $10$ 里面的 $1$，以及 $20$ 里的 $2$。\n诶？好像我们计算 $A(n!)$ 可以简化为 计算小于 $n$ 每一个数对应的 $A$ ，然后计算它们的乘积的 $A$。写得更 数学 一点就是说：\n$$ A(n!) = A(\\prod_{i=1}^{n} i ) = A(\\prod_{i=1}^{n} A(i)). $$如果这个是成立的，那我们的计算完全可以只关心每个数字所对应的 $A$，因为我们始终只想要那个非零的最后一位数字。我们甚至可以有这样的等式：\n$$ A(A(xy))=A(A(x)A(y)). $$即任意两个数乘积所对应的 $A$ 等于两个数对应的 $A$ 相乘后再取 $A$。取两次 $A$ 的主要原因是为了规避可能存在的末位的零们。那么，照这个方法的话，也许我们可以重复计算很多次 $A(9!)$，其结果是 $8$；然后数清楚有多少个对应的 $1$-$9$，即有多少个 $8$ 相乘，取到它对应的 $A$，最后再乘上不够 $1$ 到 $9$ 的几个数字的阶乘对应的 $A$。当然，最最后再取一次 $A$，就是我们要求的结果了。\n我们用 $A(20!)$ 来验证一下我们的想法吧。由上面的过程，我们可以看到个位上的 $1$ 到 $9$ 一共出现了两次（即 $1$ 至 $9$ 和 $11$ 至 $19$）。那么按照我们的算法，$20!$ 对应的 $A$ 那就是 $A(8\\times 8\\times 1\\times 2) = 8$。\n我算的对吗？经过计算器的暴力计算，我们得到它的结果是 $2432902008176640000$，则其 $A = 4$。\n太棒了，我算错了。太坏了，怎么会这样！？\n$5$ 怎么这么坏 上面一套分析，竟然结果是错的？！？到底是哪里出问题了？经过仔细的验算以及一点点点点的细心，可以发现罪魁祸首是个位的 $5$，因为每当 $5$ 出现时总会让结果出现一个 $0$ 然后向前进一位，或者说，乘以 $10$ 之后再除以 $2$。\n在考虑到这点后，如果尝试把个位的 $5$ 从 $9!$ 抛掉的话，我们会发现结果的末位不会有 $0$。我们还很容易得出，事实上，如果我们抛去所有个位为 $5$ 或 $0$ 的数字的话，$n!$ 的末位就不会是0。那么我们好像可以重新组织一下这个乘积的样子，比如我们先计算个位不含有 $5$ 的数字们的乘积，对 $A(9)$ 来讲即 $4!$ 和 $9!/5!$，结果分别为 $24$ 和 $3024$。然后我们再把它们的个位相乘后乘 $5$ 或者除以 $2$，得到的结果就是我们需要的结果了。简单的计算即可知道答案是 $8$，没有问题。\n嗯？！？等一下，这两个乘积的末位依旧是同一个数字 $4$！貌似这样四个一组的数字乘积的个位一定是 $4$ 的样子！？我们好像又可以采用刚刚的思路了。之前我们是 9 个数字为一组，这种方法里包括了 $5$ 这个捣蛋鬼所以失败了，那么这次我们就采用 4 个数字为一组。为了方便，我们就叫这个组为 $S$ 好了。\n还是用 $20!$ 试一下，其中有 4 个 $S$，则我们得求 $ A(4^4) = 6$；里面有4个包含了 $5$ 为质因子的坏蛋，分别是 $1\\times 5$，$2\\times 5$，$3\\times 5$，$4\\times 5$，我们把它们乘起来，得到：\n$$ \\prod_{i=1}^{4} i \\times 5^4 = 4! \\times 5^4 = 15000 $$最后我们把它们俩乘起来得到……嗯？怎么是 $90000$ ！？又是哪里出问题了？如果考虑到乘以 $5$ 在我们的问题中实际上相当于除以 $2$ 的话，我们发现：\n$5$ 还会抢走别的 $2$，不行！ 太坏了，实在是太坏了。还好我们还有招：$S$ 里一定有多出来的 $2$ 喂给白眼狼 $5$，我们只需要考虑喂出去多少 $2$ 给不够的 $5$ 来凑。我们也许不应该急着计算 $A(4^4)$，而是把外面的 $A$ 给去掉，因为只剩下一位数字的时候肯定不够质因子 $2$ 喂给多出来的 $5$ 的（因为 $4$ 的幂次循环只有 $4$ 和 $6$）。经过这样的修正，我们可以得到：\n有 4 组 $S$，则这四组的个位数乘积为 $4^4 = 256$。把这个结果乘上 $4! \\times 5^4$，得到 $256\\times 15000 = 3840000$ 再取 $A$，就能得到结果是 $4$。这下应该没问题了。我们来把这个过程规范地描述一遍吧。\n要计算 $A(n!)$，首先我们要找出 $n$ 可以分出多少组 $S$。计算方法很简单，我们用带余除法即可。这样一来，我们就可以确定有多少个 $S$ 即多少个 $4$ 要相乘，以及剩下的余数是多少。我们记 $S$ 的组数为 $k$，记余数为 $r$。另外我们还可以知道剩下的含 $5$ 的数字都是什么，即 $5$ 的倍数都有谁。由于阶乘的特点，剩下的 $5$ 的倍数的乘积一定能写成 $k!\\times 5^k$，而这里的 $k$ 正是前面 $S$ 的个数。\n那么我们得到这样的式子：\n$$ A(n!) = A(A(4!)^k\\times k!\\times 5^k\\times r!) = A(4^k\\times k!\\times 5^k\\times r!), $$注意到 $4^k \\times 5^k = 20^k$，则 $A(4^k \\times 5^k) = A(2^k)$，我们把上式简化为：\n$$ A(n!) = A(4^k\\times k!\\times 5^k\\times r!) = A(2^k \\times k! \\times r!) = A(A(2^k)\\times A(r!)\\times A(k!)). $$其中由于 $r$ 是一个小于 $5$ 的数字，它的阶乘特别好算，我们甚至可以打表，而 $2$ 的幂次的个位也是以 $2,4,8,6$ 进行循环的，我们可以把注意力完全放在 $A(k!)$ 上，而 $k$ 则是原数字 $n$ 缩小了四倍后的结果。接下来我们故伎重施来求解 $A(k!)$，得到的结果带回给原式后又会得到相似的模式，我们不断重复这个过程，就能递归地得到全部化简后的式子，而以这个方法得到的结果最后会由 $2$ 的幂次和余数们的阶乘的乘积构成，我们只需要求这个式子的 $A$ 即可，这是完全可以做到的。\n我们总算得到了可用的算法了。太棒啦！然而，递归的算法总是在很精妙的同时给人以“我能再进行简化”的感觉。这里简化的重点应该在于 $k$。如果我们能提前把 $k!$ 解开，或者说在一开始就能有方法把 $n!$ 拆开成 $2$ 的幂次和一系列的 $r!$的话，就能不依赖递归的计算了。\n第三次尝试：算法一定还有提升空间！ 根据刚才的分析，很明显我们要首先得到我们得计算 $2$ 的多少次幂。而这个值又由 $S$ 组的数量控制。我们得给这个 $S$ 组一个比较明确的含义了，之前说什么 $4$ 个数字为一个 $S$ 组，这还是太模糊。我们所说的一个 $S$ 组应该是这样的连续数字组，它们的个位从 $1$ 到 $4$，或者从 $6$ 到 $9$ 为一组。$S$ 组有这样的特点，那就是它们均匀地分布在 $n!$ 中且数量极易统计，对于 $n!$ 的 $S$ 组，我们只需要把 $n$ 除以 $5$ 就能得到组数，而剩下的余数我们可以留作后用。另外每个 $S$ 组组内的乘积对应的 $A$ 值一定是 $4$。这是非常重要的特点，也就是这一点能让我们进行简化计算。\n由于在进行上述的算法计算时，我们必须不断地计算 $A(k!)$ 的值。而这也就意味着我们必须多次计算每次出现的 $S$ 组的数量。有什么办法能让 $k!$ 把 $S$ 组的数量一次全吐出来呢？\n假如 $5$ 不是坏蛋的话？ 那假如 $5$ 不是坏蛋，乘以 $5$ 不会让后面多 $0$ 进而影响结果，那样的话不就只有整 10 数会影响最后的结果了？这也许能给我们一些计算到底统共有多少组 $S$ 的启示。\n我们试试用那个本来错了的方法进行分组吧。按 $A(n)$ 从 1 到 9 来给 $n!$ 中的所有因数进行分组。假如我们凑齐了 9 个数字，让它们的 A 正好遍历 1 到 9, 我们就记这样一个组为 $S_{10}$。根据我们老早提过的记号，我们把 $n$ 记成 $\\dots a_3 a_2 a_1$ 的话，那么我们有：$\\dots a_3 a_2 0$ 个 个位数不为0 的组，有 $\\dots a_300$ 个 个位数为 0，但十位数不为 0 的组，有 $\\dots a_4000$ 个 个、十位数为 0 但百位不为 0 的数 ……\n注意到上面的分法下每个组在取 $A$ 后都是我们要的 $S_{10}$，我们就能很方便的计算 $S_{10}$ 的个数。假如 $n = 1234$，那么对应的我们要的 $S_{10}$ 的数目就是 $123+12+1 = 136$ 个了。这里值得注意的是一个边界情况，即假如我们的数字是 $9$，$90$，$990$ 这样的情况下，我们的 $S_{10}$ 实际上是 $0+1$，$9 + 1 = 10$ ，$99+9+1 = 109$ 组。如果是单纯统计 $S_{10}$ 组的话，为了解决这个纰漏，我们可以考虑检测第一位数字是否是 $9$，如果是的话就额外加上 1, 不是的话就说明没凑够所以不加。然而我们并不是单纯统计 $S_{10}$ 组，因此我们干脆不管这个边界情况，这一点我们后面再多做讨论。\n能观察到，在 10 进制下我们对 $A(n)$ 遍历 1 到 9 的分组是极为自然的过程。究其原因，这样的便利性是来源于十进制的表达方式。那么，如果要统计 五个一组 的情况呢？这给了我们尝试 5 进制的理由。我们来试试吧。\n5进制下统计所有的 $S$ 组 下面我们把 10 进制的数字直接简单地表示出来，而 5 进制的数字则会有个 5 的下标。我们还是把 5 进制下的数字表达为 $\\dots a_3 a_2 a_1$。这样一来，10进制下的 $1$ 到 $4$ 就是 5进制下的 $1_5$ 到 $4_5$, 而 $6$ 到 $9$ 就是 5 进制下的 $11_5$ 到 $14_5$ 了。那 十进制下的 $30$，在 5 进制下的表示是什么呢？由于 $30 = 1\\times 5^2 + 1\\times 5^1 + 0\\times 5^0$，其 5 进制表示则为 $110_5$。\n那么 $30!$ 里统共有多少 $S$ 组呢？我们类比上面的做法，我们首先有 $11_5$ 组个位不为 0 的组，其次有 $1_5$ 个十位不为 0 而个位为 0 的组，一共就是 $12_5$ 即 $7$ 组。如果我们手动统计的话，$30$ 首先除以 $5$ 得到 $6$，另外由于我们的算法会出现一个 $6!$，其 $S$ 组只有一个，这样一来，$30!$ 里一共应该有 $6+1=7$ 个 $S$ 组，结果和前面的算法是一致的。\n我们再来看看 $100!$ 里有多少 $S$ 组。写为 5 进制后它是 $400_5$，那么他就有 $40_5 + 4_5 = 44_5 = 24$ 个 $S$ 组了。而使用古法统计，可以得到其首先是 $20$ 个 $S$ 组，接下来对 $20!$ 而言一共有 $4$ 个 $S$ 组，则一共是有 $24$ 个。结果也是一致的。\n太棒了，我们现在能成功分析出一个数字拥有的 $S$ 组了。然后呢？\n还需要统计余数 在分析出一共到底有多少 $S$ 组后，我们就知道了 $A(n!)$ 计算式里 $2^k$ 中的 $k$ 了。然而，在拆出来 $S$ 组的过程中，我们还会得到一系列的余数。我们得想办法把这些余数留下来做计算。要怎么做呢？\n我们在十进制下每次除以 $10$ 的时候会得到商和余数，其余数就是右侧最后的一个数字，而商则是去掉最右侧一位数字后得到的剩余部分。这个规则对于 5 进制，甚至于任何进制，都是成立的。这样一来我们就很快能得到所有的余数了，就是它的每一位数字。比如有 5 进制数 $131423_5$，其所有的余数就是 $1,3,1,4,2,3$ 了。这里我们考虑了最左边的 $1$，原因有二：如果只考虑直接的余数的话，最后还是会碰到要乘以最左边一位数字的阶乘；另外，在做进制转换的过程中，我们是要除到结果为 $0$ 的，而最后一次的余数正是最左边的一位数字。\n那么这样一来，我们的算法就完善了。我们先统计出 $S$ 组总共的个数，得到 $k$，然后用 $k$ 模除 $4$ 得到余数，用这个余数 $r$ 计算 $2^r$ 后再乘上每一位余数的阶乘，最后取这个结果的 $A$ 即得到我们要求的 $A$ 了。\n我们尝试在新的算法下计算 $A(13!)$。它的 5 进制表达为 $23_5$，则一共有 $2_5 = 2$ 个 $S$ 组。那么它对应的 $A$ 就有 $A(13!) = A(4^2 \\times 5^2 \\times 2! \\times 3!) = 8$，容易验算，这个结果是没问题的。\n我们再尝试计算一下 $A(20!)$。我们把 $20$ 写为 5 进制后得到 $40_5$，则我们有 $4_5$ 即 $4$ 个 $S$ 组。则我们要的 $A$ 就可以是 $A(20!) = A(A(2^4)\\times 4!) = 4$，和我们上面的结果是一样的。我们再试试求 $A(63!)$，由于 $63 = 223_5$，则一共有 $24_5 = 14$ 组 $S$。此时我们要的 $A(63!)$ 就是 $A(A(2^14) \\times 3! \\times 2! \\times 2!) = 6$。我们用 Python 算一下这个值，结果是\n$$1982608315404440064116146708361898137544773690227268628106279599612729753600000000000000$$它的 $A$ 和我们的要求是一样的。好耶！我们成功地找到了一个可行的算法！那么既然是一个算法，我们是不是能写成程序呢？\n用 Python 实现一下吧~ 我们还是选择我们亲爱的 Python。虽然说是胶水语言，但是真的很好用，特别是在处理这种东西的时候，有很多已经内置了的方程。更不必提在 3.13 版本后 Python 的交互式界面好用了很多：支持自动缩进，支持 exit 退出等等。真的很不错。\n不多废话了。我们开始实现这个算法吧。首先自然是要把 10 进制数字转换为 5 进制。另外，待会儿我们还需要把 5 进制转换回 10 进制，所以一起实现了吧。为了某种“广泛性”，我们干脆让这样的进制转换支持 任意数字为底 好了。\n进制转换 注意到每一位数字就是除法的余数，我们可以让数字依次除以底数，最后把它们反方向拼接起来就可以了。具体实现如下：\n1def change_base(n:int, base): 2 \u0026#34;\u0026#34;\u0026#34; Converts a decimal number to its representation in a given base. \u0026#34;\u0026#34;\u0026#34; 3 if n == 0: 4 return \u0026#34;0\u0026#34; 5 6 digits = [] 7 while n: 8 digits.append(int(n % base)) 9 n //= base 10 return \u0026#39;\u0026#39;.join(str(x) for x in digits[::-1]) 值得注意的是我们这里返回的是字符串，而非数字。因为数字自动是以 10 为底的。为避免我们不想要的运算，我们还是使用字符串的稳妥一些。\n另外我们要把 5 进制数字（的字符串）转换回 10 进制。这点非常简单，实现如下：\n1def to_decimal(s:str, base): 2 \u0026#34;\u0026#34;\u0026#34; Converts a number in string representation from a given base to decimal. \u0026#34;\u0026#34;\u0026#34; 3 n = 0 4 length = len(s) 5 for i, digit in enumerate(s): 6 n += int(digit) * (base ** (length - i - 1)) 7 return n 这样一来，我们就能自由地在 10 进制和 5 进制之间转换了。当然，为了方便，我们定义 to_penta 函数：\n1def to_penta(s:str): 2 return change_base(s,5) 统计 $S$ 组个数 我们遇到的第一个比较难的点应该在于如何统计 $S$ 组的个数。我们之前是计算的 5 进制加法之后转换回 10 进制的。然而这样的算法不太适合计算机：它不熟悉怎么计算奇怪进制的加法。好消息是，对于加法而言，我们先加起来后进行进制转换，和先进行进制转换然后加起来是一样的效果。这里我就不证明这一点了。在知道这一点之后，我们就可以很简单地得到 $S$ 组的个数：\n1def num_S(s:str): 2 acu = 0 3 for i in range(len(s)): 4 d = (s[:len(s)-i-1]) 5 acu = acu + to_decimal(d,5) 6 return acu 然后我们还需要根据这个值来决定 $2$ 的幂次的个位结果。Python 提供了好用的 divmod 函数方便我们处理这个结果。待会儿我们就用它。而接下来的问题则是把所有的数位的阶乘都乘起来。\n处理阶乘们 在处理阶乘前，我们可以观察到这样一个神奇的现象：每一位上的数字只有从 $0$ 到 $4$ 五种结果，其中的 $0,1$ 的阶乘都是 $1$, 因此可以不考虑进去；$3!=6$ 的结果尤为特殊，因为它乘以偶数后取 $A$ 都是得到它本身，即若 $x\\in \\{0,2,4,6,8\\}$，则 $A(3!\\times x) = x$，然而又因为这个余数肯定得乘上前面 $2$ 的次幂，所以即便在余数中只有 $1$ 和 $3$，它的结果是 $6$, 随后又会被前面 $2$ 的次幂吸收。因此，我们可以不考虑余数中的奇数们，只考虑它们里面的偶数。再之后，$A(2!) = 2$，$A(4!) = 4$，我们可以把功夫全放在这两个数字上。\n1def res(s:str): 2 result = 1 3 for dig in s: 4 if int(dig)%2==0 and dig != \u0026#39;0\u0026#39;: 5 result *= int(dig) 6 return result 组合起来完成计算 最后，我们只需要把上面的几个函数组合一下就好了。\n1def last_nonzero_digit(n:int): 2 penta_n = to_penta(n) 3 k = num_S(penta_n) 4 resudal_4 = divmod(k,4)[1] 5 A_pow_2 = 6 if resudal_4 == 0 else 2**resudal_4 6 ress = res(penta_n) 7 result = str(int (A_pow_2 * ress))[-1] 8 return result 好，现在只需要运行这个脚本，就能解决我们一开始拿到的问题了。我们要求的是 $2025$，那么 $A(2025!)$ 经过计算得到的结果就是：$2$！太棒了。这个算法还挺快的，几乎是无感计算诶。我试了一下，在我自己的 PC 上进行计算，算 $A(2^{5000}!)$ 大概用了两秒就得到了结果。\n然而，我们的结果对吗？应该是有个答案吧，答案怎么做的呢？\n终章：神秘的算法，怎么能这么快？ 我们引入这个问题的时候，就说过知乎上有人发过一个很厉害的回答。这个算法令人惊讶的简单，不需要算若干次麻烦的除法，只需要算一次模除以 $4$ 就可以了。这个算法是这样的：\n首先写成 5 进制，然后把每一位偶数加起来得到一个结果 $t$，然后把每一位和自己的位数（从0开始）相乘后相加得到 $x$，最后计算一个判别式 $y = (x+t/2) \\mod 4$, 如果 $y = 0$ 则说明结果是 $6$，而剩下的情况则是 $2^{y}$ 即可。这个算法写成 Python 程序则为（借用上面的转 5 进制算法）\n1def quick_method(n): 2 s = to_penta(n) 3 t = 0 4 x = 0 5 i = len(s)-1 # index 6 for digit in s: 7 d = int(digit) 8 if d % 2 == 0: 9 t = t + d 10 x = x + i*d 11 i = i-1 12 13 z = (x + t/2) % 4 14 15 result = 6 if z==0 else int(2**z) 16 return result 这个算法太简洁了……对它的解释写在 这篇上古网页里。我实在是燃尽了，看不下去了。不过我认为其基本思路和我的算法应该是差不多的。它应该在求 $S$ 组这一步做出了很大的简化，并且把对余数的处理想办法捏进一个加和里。我也不知道他是怎么做到的。不过这个算法肯定是快的多的，因为它计算 $A(2^{5000}!)$ 是瞬间计算出来的。毕竟，时间复杂度在这里摆着……\n也许某一天我会回来看这个算法的具体实现是怎么做到的吧！希望我会记得回来看。暂且就到这里吧，这个问题还伤了我不少脑细胞来着。\n后记 其实，这个问题的解决并非一帆风顺。一开始我是在百无聊赖的状态下看到这个问题的，一下子就被吸引住了。这个问题实在是很有趣，而我一开始的思路，正如上面那样，尝试了质因数分解和一些有的没的，手动计算了 $A(20!)$ 来尝试寻找规律之类。然而，那天赶着吃饭，在发现可以以 9 个数字为一组进行操作之后就不再细想了。其实 9 个数字为一组的做法是错误的，错误原因直到后来我已经动笔开始写这篇文章的时候，我才后知后觉。好在很快意识到了问题，把 $5$ 这个绊脚石从脚边踢开后就能很好地进行计算了。\n事实上，我在计算时一直在用最后给出的这个 quick_method 做结果对照。令人欣喜的是，结果是没问题的，我的算法设计顶住了 没有答案 的压力。毕竟，从最后的这个答案上，能得到的有效信息几乎只有“记得使用 5 进制”。很难反推出来的啦，这套算法。当然，关于这个问题，我的终极目标当然是吃透这个算法究竟是怎么生效的。不过这也已经是后话了。\n很明显这个问题是和数论强相关的，尤其和取模运算有很大的关系。然而，这里并没有深究，主要原因一个在于进行进制转换已经很麻烦了，没必要介绍太多数论的内容，另一个也是我自己的问题：我不会数论，我讲个毛呀。因此，本着有啥写啥，用啥写啥的精神，最后只写出来这么个半吊子。希望看到这篇文章的你感觉还算有点意思吧。\n还有就是要感谢 柴（oneis2much） 佬的细心审稿。谢谢你！\n那么最后，一如既往地，祝您身心健康，工作顺利，生活愉快。\n","date":"2025-08-25T18:01:23+08:00","image":"/images/清水吟.jpg","permalink":"/zh/posts/math_note/factorial_last_digits/","title":"2025! 非零的最后一位数字是多少？"},{"content":"上一节已经介绍了平时会怎么用 Git 进行单分支仓库的管理，这一节就来讲讲 Git 要怎么进行多分支协作吧！\n头图信息请参考上一节内容，谢谢~ 选曲选到了知名社团 Foxtail-Grass-Studio 对小伞个人曲的翻调，请欣赏~\n分支，是那个分支吗？ 我们上一节已经了解过 Git 在单分支下的日常工作流了。值得注意的是，我们说的是“单分支”，那么自然，Git 是支持，同时鼓励使用多分支的。那么分支是什么呢？\n也许有过 Galgame 经验，或者玩过有分支剧情游戏的你已经想到所谓的“分支”是什么东西了。没错，很像这么回事儿，不过功能更丰富一些，因为你不止是体验若干个分支的剧情，Git 甚至可以允许你在没有冲突的前提下合并两个分支！如果有一款游戏支持用 Git 来操控分支的话，也许就可以手动后宫了……\n咳咳，不开玩笑了。我们来看看分支具体是什么样的。先来个分支图：\n一个也许简单的 Git 分支示意图 gitGraph commit id: \"initial commit\" commit branch feature1 checkout feature1 commit id: \"new feat1, first commit\" commit checkout main merge feature1 id: \"merge feature1\" branch feature2 checkout feature2 commit id: \"new feat2\" checkout main commit merge feature2 id: \"finish, merge feat2\" Git 分支示意图 （嘶，mermaid 竟然直接有 gitGraph 的功能，NB）\n那么可以看到，我们这里有三条分支：一条 main，一条 feature1 以及一条 feature2。有时我们开启了一个分支，有时我们又将两个分支进行了合并。上面的图是怎么生成的呢（双关意）？下面是用到的代码：\n1gitGraph 2 commit id: \u0026#34;initial commit\u0026#34; 3 commit 4 branch feature1 5 checkout feature1 6 commit id: \u0026#34;new feat1, first commit\u0026#34; 7 commit 8 checkout main 9 merge feature1 id: \u0026#34;merge feature1\u0026#34; 10 branch feature2 11 checkout feature2 12 commit id: \u0026#34;new feat2\u0026#34; 13 checkout main 14 commit 15 merge feature2 id: \u0026#34;finish, merge feat2\u0026#34; Mermaid 的 gitGraph 很有趣的地方在于，上面的代码几乎就是为了实现这样的提交树/分支形状所需要的 Git 命令。我们可以不管 id 后面的部分，因为这些在实际 commit 的时候应该是用 -m 来指定的提交信息才对。\n那么，这些命令都干嘛了？要怎么用命令来操控分支？\n和分支相关的命令们 下面来讲讲上面出现的（和没出现的一些）命令吧~\ngit checkout\n说实在的，这个命令真的是个很大的坑。git checkout 从 Git 诞生之初就已经存在，它是集创建、管理、变更分支或提交等功能为一体的一个命令。造成这个情况的主要原因在于 git checkout 实际上不是在我们现有的对 Git 存储模型的理解上进行操作，而是在 Git 更贴近实现层面的操作，即移动“指针”。\n然而，我们这里先不打算介绍这么深入/详细。我们还是从实用角度来聊聊这个命令。观察上面的 Mermaid 图，我们可以看到，好像 git checkout 的功能没有直接体现在图上。然而仔细观察的话可以猜到，git checkout 在这里的作用是更换分支。比如，git checkout main 就是告诉 Git“现在我要切换分支到 main 分支上”。这是 git checkout 的主要用途之一。另外我们还可以用 git checkout - 来像 cd - 一样切换到上一个分支。\n我们还可以对这个命令多讲一些。如果给它带上 -b 的参数则可以用来创建一个新分支。比如 git checkout -b new-branch 就可以创建一个新的名为 new-branch 的分支，同时你还会直接切换到该分支上。而如果你在后面带的参数是某个文件或者单纯的 .，则是要让 Git 该文件/所有文件里没有暂存的更改。\n上面说的都是比较老派的做法。相信你也一定从上面的 Mermaid 图中猜到了新式的创建新分支的方法，那就是：\ngit branch\n这个命令是用来管控和单个分支相关的操作的。我们简要介绍一下。\n如果后面不带任何的参数，则是会打印出可用分支。如果要创建一个新的分支，就可以用 git branch \u0026lt;another-branch\u0026gt;, 就是让 Git 尝试创建一个名为 \u0026lt;another-branch\u0026gt; 的分支。当这个分支已经存在的时候，Git 就会报错，告诉你已经有了叫这个名字的分支了。\n要注意的是，git branch \u0026lt;branch-name\u0026gt; 只会创建分支，并不会把当前分支更改到这个新分支上。要想在创建分支后切换分支，除了传统方式 git checkout 外，还可以使用更现代（？）的命令：git switch -c \u0026lt;branch-name\u0026gt;。我们后面会介绍到。\n除了创建分支以外，我们肯定还希望能实现查看/删除/重命名分支。我们干脆都列在下面吧。如果不想看可以跳过这一段。\n要查看分支，可以直接 git branch。如果要看所有的分支（包括远程的），可以使用 git branch -a 来查看。你还可以使用 -v 来输出上次提交的信息。 要创建分支，就像上面说的，在后面补上你要的分支名称，即 git branch \u0026lt;branch-name\u0026gt;。如果这个分支已经存在则会报错，另外这个命令只会创建，并不会切换过去。 如若要从某个提交上创建分支，还可以在 \u0026lt;branch-name\u0026gt; 后面添加上 \u0026lt;commit-hash\u0026gt;。至于 \u0026lt;commit-hash\u0026gt; 是什么，我们在后面关于 Git 的一些概念里进行介绍。 想要删除分支，可以用 git branch -d \u0026lt;branch-name\u0026gt; 来删掉它。要是你要删除当前分支，请先切换到别的分支哦。 要是打算重命名分支，可以考虑像操作文件一样 移动 它：git branch -m \u0026lt;branch-name\u0026gt; \u0026lt;new-name\u0026gt;。依旧，这个命令也只能更改别的分支。 So, that\u0026rsquo;s it! Git 针对单分支的操作都可以用 branch 子命令来做到。那么，我们要怎么切换分支呢？除了 checkout 以外，“比较现代”（存疑）的方法是使用：\ngit switch\n这个命令是相对较新的用来切换分支的命令。可以通过 git switch \u0026lt;branch-name\u0026gt; 来简单地实现切换。有趣的是，我们还可以用 git switch -c \u0026lt;branch-name\u0026gt; 来创建新分支的同时切换过去。也就是说，git switch -c 命令和 git checkout -b 几乎是等价的。另外我们可以使用 git switch - 来直接跳回上一个分支。\n另外还可以考虑使用 git switch -m \u0026lt;branch-name\u0026gt; 来在切换分支的同时把当前分支合并到要切换的分支上。这一点还是相当不错的，因为我们经常会遇到这样的情形：在 dev 分支上完成某个特性之后，经过测试希望能合并到 main 分支上。如果没有这条命令的话，我们可能需要先 git checkout main 之后再 git merge dev，而有了这条命令我们就可以简单地 git switch -m main 了。\n总之，如果你需要切换分支，你就可以使用 switch 这个命令。语义很明确，不是吗？\ngit merge\n这个命令，如它的名字一样，是用来合并分支的，或者，不那么明显地，合并到当前分支。它的使用方式相对而言比较简单，就是单纯的 git merge \u0026lt;branch-name\u0026gt;。\n这个命令的主要问题是，合并过程中会出现恶魔般的 冲突。解决冲突实在是一件令人头痛的事情（在我看来）。为了避免（逃避）合并冲突后的麻烦，你可以考虑 --abort 参数来告诉 Git 如果合并失败就什么都别动。然而，要是你真想合并，到底还是要解决冲突的。\n其实解决冲突就是一个“选择应用谁的代码”的过程。Git 会在发生冲突的地方用箭头标出来本分支和被合并分支的内容，你要做的就是把你不要的那个部分删掉然后保存。另外，合并会创建一个新的提交。如果你不喜欢默认提交信息，可以考虑使用 -e 参数来告诉 Git 你打算自己编辑合并产生的提交的提交信息。\n最后就是 Git 合并时有不同的策略。我们这里不多介绍，大部分情况可以使用 ff 模式，即 Fast Forward 模式。这个模式会让你的提交树看起来是一条直线，即如果历史提交相同的话就让两个分支有同样的提交了。\ngit log\n这个命令正如它名字所说的，会输出 Git 的分支记录。假如不跟任何的参数的话，它会简单地打印当前分支的提交记录们，信息包括提交的 SHA1 哈希结果，提交的作者/邮箱，提交日期，以及提交的信息。此时，Git 会进入自己的分页器方便你上下滚动浏览，支持 Vim 式的操作，比如 jk 翻页，/？ 查询等等。自然，退出这个状态则需要按下 q。\n这个命令当然没有这么枯燥。事实上，你可以自定义的部分特别多。例如，你可以使用 --graph 来在每个提交的最左边显示分支图（虽然不是很好辨认），你还可以使用 --all 来显示所有分支的历史记录。如果想让历史记录不要搞个好多行，而是只想看看每个提交的大概信息的话，你可以使用 --oneline 来让每个提交都变成短短的一行。上面说的这三个参数你可以组合在一起，来快速浏览提交历史是什么样的。而如果你希望显示全面的信息，比如哪些文件发生了什么更改，你可以使用 --stat ，这样 Git 就会有个统计信息，告诉你哪些文件发生了什么变化。\n你看到了提交的时间了吧？git log --before \u0026lt;date\u0026gt; --after \u0026lt;date\u0026gt; 还可以让你选定只查看某个时间段内的提交！时间的格式则是 yyyy-mm-dd，实在是非常方便的功能。\n然而这个命令最神奇的地方在于，你实际上可以自定义输出格式。使用 --pretty 格式，你就可以用一些字段来控制 Git 日志的输出格式。这里有一个参考表，感兴趣的话可以看看，试用一下。\n分支相关的基本命令我们就先介绍到这里吧。有了上面的介绍，相信你已经可以运用 Git 的分支功能了吧~\nGit 的概念们 然而，止步于介绍使用 Git 的方式，总是觉得不够透彻。知其然还要知其所以然，我们既然是在介绍 Git，那就尝试把 Git 更深一些（其实也没那么深）的概念多介绍一些吧。\n仓库 (Repository) 我们几乎所有的 Git 项目都是从建立或者克隆 Git 仓库开始的。仓库是一个比较大的概念，我们和 Git 相关的所有内容都是要从仓库出发的，所有的信息都会存储在仓库中。\n那么“所有信息”都有什么呢？这个问题会比较深，我们从表观的理解来讲，首先肯定得有我们工作内容息息相关的内容，毕竟 Git 就是用来管理它们的。另外就是和 Git 相关的内容了，大部分都存储在 .git 文件夹中，还有一些零散的 .gitignore 文件。其中 .git 存储了这个仓库的所有和 Git 直接相关的内容，例如文件快照，提交记录，不同的分支记录等等，都会以特殊的结构记录下来。这也意味着，如果你删了 .git 文件夹，那么这个仓库就没了，Git 的记录就全都消失啦。删除之前要好好想清楚咯~\n然后 .gitignore 也是能控制 Git 行为的文件。它能够让 Git 不记录某些文件。比如说你有一些测试文件，它们其实不应该被记录在仓库里，只希望在本地有一份方便测试而已，那么就可以把他们的名字或者所在文件夹写进 .gitignore 里。\n总之，Git 仓库就是这么个总的玩意儿了。有时我们会简称仓库英文为 repo，我还挺喜欢这个名字。\n工作目录 (Working directory / Working tree) 这实际上就是我们正在编辑的项目目录。比如说我们从网上克隆了一个仓库之后，我们会进入这个仓库的目录里。这个仓库的根目录就是所谓的工作目录了。至于为什么叫“工作树”，我个人看法是因为 Git 分支的存在让整个仓库像树一样伸展开，或者是说目录下的文件层级结构像树一样吧。不过怎么想都觉得有点怪，毕竟如果是说仓库分支的话，我们应该是在树叶上而不是在树上吧……\n然而，不深究的话，我们干活儿的地方就是工作目录。就是这样。\n暂存区（Staging Area） 其实我们应该已经介绍过暂存区了。就像它的名字一样，暂存区是用来暂时存下“觉得改的差不多了”的内容的地方。我们用 git add 命令来把修改好的内容放在暂存区内等待提交。如果感觉暂存区的内容有不妥的地方，我们可以随时打回来重新修改。我们也可以把一些内容从暂存区撤下来。总之，暂存区给了我们再次考虑的机会。而假如我们认为“暂存区的东西我很满意，可以提交了”，我们就可以用 git commit 来提交 暂存区 的内容到分支上（或者仓库，取决于你怎么看这个行为）。\n总之，暂存区就是一个介于“保存文件”和“保存整个工作目录状态”之间的一个地方。这也决定了 Git 的工作流是 修改文件 -\u0026gt; 保存文件 -\u0026gt; 交给暂存区 -\u0026gt; 提交至分支/仓库。\n分支 (Branch) 相信你已经对分支有所了解了。我们在创建仓库的同时，会创建一个主分支，曾经主分支名称为 master，后来因为一些政治原因，现在更多叫 main 了。除了主分支外，我们还可以有很多别的分支。这些分支允许我们在仓库里存储不同的信息，不同分支间不会产生干扰，而在我们希望的时候我们又可以对分支们做出诸如合并、删除等的改动。\n分支就像平行世界一样，我们可以让两个分支拥有同样的过去，在某个地方发生变化，最后独立演化下去。而分支胜过平行世界的地方在于，我们可以在没有直接分歧的情况下把两个分支合并在一起，而不会出现“我才是蜘蛛侠”的问题。\n分支可以说是 Git 的灵魂和精髓了。推荐多运用分支进行项目管理，相当好用。遇事不决开个分支先测试一下，这不失为一个好办法。\n提交 (Commit) 我们有了一个分支之后我们就需要向这个分支不断做出提交了。每一次的提交都会让这个分支的记录变多一些，分支实际上也是记录的每一次的提交。大白话讲，提交就是存档，只不过这些存档要依附在某个世界线（分支）上而已。\n提交可以说是组成分支的部分。当我们查看分支具体有什么的时候，映入我们眼帘的就是每一次的提交记录。所谓的合并分支，也不过是比较两个分支之间的提交情况，如果没有冲突的提交就可以顺利合并了。\n要注意的是，在 Git 里我们不提交文件本身，我们提交的是文件的变更。也正是由于 变更 这一关键特征，让 Git 可以高效地进行版本控制，不过坏处也有，那就是面对二进制文件就显得有点笨笨的了：二进制文件可以认为是一变全变的，不像文本那样可以有明显的局部改动。这也说明我们应该尽量让 Git 记录纯文本的文件而非二进制文件。\n另外，需要再提醒的是，提交只会提交暂存区内的内容。如果有改动发生但没有放在暂存区里的话，提交是不会搭理这些改动的。这一点还请注意。\n远程（Remote） 虽然我们还没有介绍太多和远程仓库/托管平台的内容，但远程仓库确实是在 Git 设计之初就已经有了的关键概念了。\n我们介绍过，Git 一开始的设计目的是所谓 分布式 版本管理系统。这个 分布式 就在于每个人都可以拥有一份源代码，然后大家可以互相传递自己的修改，也可以自由选择是否进行合并别人的修改。这样去中心化的特点是相当超前的设计。而为了实现这样的设想，我们必须让 Git 拥有连接到别人仓库的能力。远程也正是这么个东西。\nGit 可以把网络上的仓库作为自己的远程库来使用。我们通常不直接和远程库中的文件交互，而是把提交作为基础单元和远程库进行交互。当我们有了新的提交或者新的分支时，我们就可以把本地的这些改动 推送 (push) 到远程仓库；当远程仓库有了新的变动时，我们可以把新的变动 拉取 (pull) 到本地来。我们会在下一节对 Git 的远程功能进行更详细的介绍。\n总之，Git 的远程仓库让一份代码可以被保存在多个位置，并且让我们和这些位置的仓库进行交互，这样就能让我们和别人进行协作了。然而，由于现实协作的众多需求，最终 Git 还是发展出了很多代码托管平台，来方便大家存储 Git 的远程库，并让大家在远程库上进行协作，避免直接塞给别人电脑上。\n后记 我必须立刻承认我这篇文章离不开 tldr，准确来说是 tealdeer 的帮助。很难想象没有 tldr 我要怎么介绍可用命令。唉，我还是对 Git 不够熟悉。如果里面有任何的错漏，又或是对这个系列有什么建议，请直接告诉我，谢谢，我会及时修改的（球球了，告诉我哪里写的不好吧，呜呜呜）。\n另外我还想推荐一个很不错的网站，Learn Git Branching，一个让你在实际操作中练习 Git 分支管理的网页，从进行提交，创建分支，合并分支，到变基 (Rebase)，远程库协作等复杂操作，全都有涉猎。我花了一下午通关，收获很大，因此墙裂建议。\n下一节就是我们的最后一节内容，我打算聊聊 Git 的远程协作功能，以及协作时的注意事项等等。另外，由于深感 Git 命令之繁杂，我有计划做一个小工具来通过问答的方式给出合适的 Git 命令。我暂时将这个工具命名为 Giao，希望不会难产吧，哈哈。有兴趣的话也可以关注我/给我提建议，谢谢啦。\n","date":"2025-08-14T16:49:16+08:00","image":"/images/Tatara-Kogasa.jpg","permalink":"/zh/posts/shell_note/git_how/git_2/","title":"（也许是）一个 Git 教程？其二"},{"content":"Git 真的很好用，但是 Git 的命令真的好复杂。简单整理一下，就当写个教程好了~\n头图出自 夏空 太太所画的 多多良 小伞，可爱捏~ 那就来一曲小伞的个人曲吧\nGit，熟悉又陌生的名字 …… 也许是所处环境的原因，我身边有很多人不知道 Git 是什么。他们都听过 GitHub，但很多却只知道上面有好多程序和程序员。虽然也没错，但是并不准确；而当我说我在用 Git 的时候，会有人把 Git 和 GitHub 混为一谈；很多人觉得 Git 很复杂，顺带觉得 GitHub 也很复杂……为此，我想分享一下我对 Git 和 GitHub 的理解，聊聊 Git 和 GitHub 都是什么。\n所以，如果你不了解 Git 是什么，那我很荣幸能在这里向你简单介绍它。\n所以到底什么是 Git？版本控制？啊？ 所谓的 Git，它就是：\n一款为程序开发的存档系统。\n是的，事实就是这样。游戏存档。卡关的时候/做支线的时候/后悔的时候可以进度回溯的游戏存档。如果你在翻阅 ProGit 或者某些教程时不太明白什么是 版本控制系统，没关系，就是游戏存档（程序用）的比较花哨的名字。\n不过，为了能高效地，更好地服务程序员，Git 自然有了一大票复杂的功能，且每个子功能还会做特别多的细分，另外对每个存档都可以有非常复杂 （麻烦） 的，细致 （啰嗦） 的控制。然而，这依旧不能让它摆脱它就是个存档系统的事实。\n一旦你接受了这个设定，那么 Git 就其实没有多少秘密了。\nOK，但是听你说好像很麻烦…… 不得不承认的是，正如上面所说的那样，Git 的命令实际上可以非常地复杂。如果你愿意翻阅它的 man-page，你会发现内容出奇地长；而当你尝试用 git --help 来获取一些简单有效的信息的时候，很抱歉，git --help 只会告诉你你能怎么做，并伴随着看不太懂的 usage，却不太会告诉你怎么做能做什么。\n然而，转折来了。首先，如果你受环境所限，只能从命令行操作 Git，待会儿介绍的四五个命令几乎就能覆盖 80% 的使用场景了。而如果你的环境支持你使用图形化的界面，那么如果不是命令行的忠实用户，完全可以挑个 GUI 程序，比如和 GitHub 集成度高的 GitHub Desktop，界面美观现代，功能也已经足够丰富，没必要和自己过不去。\n所以，结论是：Git 很复杂，但是我们可以用的很简单呀。它很强大，很好，但这不影响我只需要那几个最基础的功能。最重要的是，当你需要更复杂的功能的时候，互联网永远是你的好朋友。你完全可以现场上网搜索，大概率会有来自 StackOverflow 的朋友向你答疑解惑（贴答案）（好几年前且点赞特别高的）。\nSo, don\u0026rsquo;t be afraid! Just try it!\n行，但是 Git 和 GitHub 到底是什么关系？ 这算是很常见的问题了。解释起来也很简单：GitHub 能提供云存档功能。就像 Steam 有游戏云存档一样，Git 也可以有个云存档。只不过，Steam 有个专门的服务器来帮你自动地存好你的游戏内容，而 Git 则可以允许你选择你喜欢的地方存你的代码存档。\n而 GitHub，正是那个大部分程序员都喜欢的选择。不仅如此，GitHub 上传的存档还兼具展示功能，大家可以在 GitHub 上给自己喜欢的代码存档投票，也可以把别人的存档下载到自己电脑上，甚至可以尝试和别人一起组排。所以，说是交友网站，也未尝不可（也许）\n那么我可以选择别的地方存放存档吗？当然可以！除了 GitHub，还有很多很多的 Git 服务提供商。你还可以 自建 Git 服务！甚至，GitHub 显得有些“违背”Git 的初衷：分布式的存档存储。什么意思呢？Git 一开始是打算，让所有的代码开发者（玩家）都留一份存档，然后大家就可以一起攻略组排了。大家都保留一份源码，这不就相当于大家都做存储功能了吗？只不过随着合作要求的提高和开源社区的扩大，GitHub 这样一个公开自己代码的地方就这么自发地出现了。\n总而言之，Git 是存档工具，GitHub 是大家上传/分享/讨论/合作云存档的地方。\n好耶，我逐渐理解一切！ 是这样的，Git 就是做这么个事儿。也许你会看到一些介绍一开始会提 Git 使用的技术多么先进，多么高效，多么体现开源精神，然后不明所以。然而 Git 就是做这么个代码存档的东西，为了使用它以期了解它的话，大框架就是这样的。\n然而这里还是要提个醒：上面也许的确抓住了 Git 的核心目的，但是依旧是很粗糙的，非常概括性的。上面的文字只能帮助 了解 Git 是什么，并不能告诉你 Git 怎么做的。另外，使用 Git 的命令完成最基础的工作是很简单，但是在切实明白一条命令到底在做什么前，请最好不要盲目运行这条命令。实际上，要想运用好 Git 管理你的代码/项目，还是需要了解一些关于 Git 究竟在背后怎么做的知识的。\n所以，如果你还对 Git 感兴趣，或者想把 Git 用起来的话，我们就来讲一些技术细节吧~\n要怎么用 Git 存档？ 想解答这个问题，我们不可避免地要接触一些没啥意思的概念。与其直接介绍它们，我们先来看看，日常开发会怎么使用 Git 吧。\nTig 的一天 Tig 是热爱 Minecraft 的忠实玩家。他很享受创造神的感觉，毕竟他就是被游戏名吸引而来的。今天他计划开展一个新的工作：制作一个百万刷铁机！\nOh no! Tig 的 Minecraft 除了点奇怪的问题！他被告知，Minecraft 的图形界面已经坏了，取而代之的，他可以用代码来操控角色并任意创造游戏中的物品，且他只能用 git 来做存档（究竟是谁干的，真坏呀）。Tig 感到心里五味杂陈：这还是 Minecraft 吗？然而他心中有一个信念：我一定要做好这个刷铁机，即便我能直接虚空点出来铁块！等游戏恢复的时候，就可以在这台刷铁机的基础上继续快乐玩耍啦！\n于是，Tig 用 git init 创建了一个空世界的存档。然后就开始在存档里用代码一行行写他在这个世界里要做些什么……\n过了一会儿，Tig 妈妈喊他要他吃午饭了。虽然不愿意，Tig 还是要先放下手上的工作。他打算先暂时保存一下，于是使用 git add . 来保存好自己手上的所有写好的代码。毕竟，他也不知道是不是有的地方有点问题，带会儿还要调一下，他现在也是被拉过去吃饭的。\n吃完饭后还睡了个午觉，Tig 回来又写了一会儿。他对自己的成果很满意，因为他已经想办法把村里的刁民挪到了高空中了。这实在是不太容易，他不希望待会儿犯蠢丢掉这几个村民。于是他决定要存档。他先用 git add . 来保存所有文件的所有改动，然后用 git status 查看了改动的文件们。感觉没什么问题，他使用 git commit 来正式保存了这个存档。存档系统问他要他给自己的改动写个简述，他写了 村民挪好了，准备搭框架。\n过了一个下午和一个晚上，Tig 终于在睡觉前把刷铁机搞好了！实在是一个无比伟大的创举，Tig 忍不住把它分享出去，也方便自己在其他电脑上继续工作。他创建了 GitHub 账号和一个仓库，并且用 git push 把这个存档放在了它的仓库里。然而睡前他还是想先在另一台电脑上先把存档下下来，于是使用 git clone \u0026lt;git-link\u0026gt; 来把仓库克隆到本地。\n晚上躺在床上，他一想到以后就可以把存档用 git push 方便地推送到 GitHub 上，并且用 git pull 在另一台电脑上来获取最新的改动了，他就不自觉地笑出声，心里盘算着怎么在明天做一些改善，给刷铁机套个好看的壳子之类的……\n可喜可贺，可喜可贺！~\n所以，他都干了些啥？ Tig 的故事貌似有点无聊，毕竟，给 Git 硬套个背景，貌似有点牵强；更重要的是，谁家好人这么玩 Minecraft 呀！然而他用到的命令，几乎就是我平时常用的所有命令了。我们来总结一下吧。我们就不再多提游戏的事，毕竟好像都戳穿了是在写代码……\ngit init 我们可以用 git init 来在本地创建/初始化一个 Git 仓库。这代表着，你打算用 Git 来管理这个文件夹了。很简单的命令，其实频率也很低，因为你很少反复初始化一个仓库。\ngit add . 一个频率还挺高的命令。你在仓库内的修改，Git 都不会立马记录下来。他怕他立马记下来之后，随后用户又马上反悔。另外，这样立马就记录下来，反而和单纯的文件保存功能有所重叠了。\n所以，当你觉得目前的进展还不错，你就可以用这个命令来 暂存 当前的所有修改。这里的“暂存”有两个意思：一是 Git 确实是把你的修改保存到了 暂存区 里，另一个则是你要是现在发现有个修改不太对，可以很方便的从暂存区里撤下来。\ngit add . 里的这个 . 就是当前目录的意思，也就是说这个目录下的所有文件我都要暂存起来。Git 会很聪明地只保存修改，这也是设计之初就确定的。如果你只想保存一部分，那就写他们的名字吧，或者写对应的目录，都可以，能定位到就好。\n不过，总之，这个命令就是让你暂存当前所有修改的。\ngit status 一个我很爱用的命令。可以向你报告当前暂存区的情况以及工作目录的情况。比如什么文件被修改了，哪些文件是新加的，谁被删除了，而这些改动里谁被暂存下来，又有哪些你没暂存下来。\n如果你的 Git 是默认配置，他还会提醒你可以怎么撤回某些修改。跟着做就好了。\ngit commit 当你对你的进度感到满意时，你就可以用 git commit 来提交你暂存区的东西了。所谓的提交，就是形成一个存档，你后续可以回来的一个存档。这个存档里你的仓库的模样会被冻结下来，当你回到这个提交时，一切都会回到当初的模样。非常的美好。\n要注意的有两点，一是 git commit 只提交 暂存区 的内容。没被暂存的，还会在原地等待你先用 git add 暂存起来，或者等你撤回那些修改。二是，git commit 会要求你给这个提交留个注释。请不要省事瞎写个什么东西，因为未来的你可能会对瞎写注释的现在的你感到伤心。默认情况下，git commit 会打开你的文本编辑器然后让你开写，而如果你觉得很麻烦不想开编辑器，可以用 git commit -m \u0026quot;messages\u0026quot; 来把这行 messages 作为提交注释。\n可以再补充两点：如果你提交过后发现因为小失误忘记暂存某些内容或者有些小改动的话，你可以在把改动加入暂存区后补充到这次提交里，用法则是 git commit --amend。另外，提交要慎重，因为提交过的内容就不是那么好修改了。你当然能改，但是相比 git add 到暂存区的内容而言，实在是要麻烦一些。\ngit push 把你当前的内容推送到远程仓库里。如果你的仓库是用 git clone 获得的且你拥有这个仓库的修改权限，那么 git push 就可以简单直接地把 这条分支 的修改推送到远程。\n我们这里还是先不讲什么分支，也先不谈远程协作之类的东西。不过就常用命令介绍来说，git push 算是比较常用且同样很简单的一个命令了。\ngit clone 把 git 仓库从远程下载到本地。后面跟上仓库的链接就好。如果你是从 GitHub 来克隆到本地的话，点绿色按钮的 Clone 就会看到你可以怎么做。你可以直接复制里面的命令然后执行。\ngit pull 把远程仓库的内容拉取到本地。和 push 的方向是近乎相反的。如果远程有个修改，你希望同步到本地，那就 git pull 一下吧。\n这个命令要注意的点是，不要在本地有修改没存的情况下执行 git pull。如果本地和远程起了冲突，会很麻烦。避免麻烦的最好方式是，先 git pull 之后再做自己的修改。\n画个流程图 flowchart LR A[开始] --\u003e B[git init\\n创建新仓库] A --\u003e C[git clone\\n克隆已有仓库] B --\u003e D[在仓库中做出变更] C --\u003e D D -- 暂时存下 --\u003e E[git add .\\n暂存已有变更] E -- 满意已暂存内容 --\u003e F[git commit\\n提交所有暂存的变更] F --\u003e G[git push\\n上传至远程仓库] G --\u003e H[其他设备: git pull\\n从远程获取最新变更] H --\u003e D F --\u003e D Git 日常工作流 好累，先聊到这里吧 我们已经介绍了 Git 是什么以及日常会用到的功能。我可以说，除了剩下关于 Git 另一个非常强大的功能：分支的两三个命令，以及一两个我觉得好用的命令以外，剩下的命令都是我很不常用的命令了。剩下的命令几乎只有在我搞砸了什么东西的时候临时从网上搜来救火用的，而保持良好的使用习惯的话真的是很少用到这些麻烦/复杂/难以理解的功能的。\n所以，如果你看到了这里，恭喜你已经掌握了 Git 单分支的工作流程了。就是改文件，暂存，提交，推送。而下一章我们会看看 Git 被吹的神乎其神的分支到底是个啥，再解释 Git 中的一些概念。\n这里要特别声明的是，这篇文章的比喻借鉴了 HDAlex_John 的 Git 教程系列：给傻子的 Git 教程，讲的相当好。好在我不是傻子，看着也不累，哈哈哈哈。（还是自己写起来比较累）\n那么最后，感谢你看到这里，祝你心情愉悦，生活顺遂！~\n","date":"2025-07-28T22:49:16+08:00","image":"/images/Tatara-Kogasa.jpg","permalink":"/zh/posts/shell_note/git_how/git_1/","title":"（也许是）一个 Git 教程？其一"},{"content":"有点受不太了 scp 和 sftp 了，也许是食用姿势不对吧，总之我选择 rsync！\n图源找不到诶……从朋友那里薅过来的图，很漂亮就放在这里了 小爷我找到啦！是出自 fasnakegod 大大的 贝加尔湖畔。既然如此就分享一首钢琴曲吧。一首 騎士王の誇り（骑士王的荣耀）送给大家。（好像毫无关联诶 kora!）\n为什么要选择 rsync 呢？ 有时候我们有多个远程电脑，或者是服务器，上面的文件内容我们希望下载到本地。我们通常有这么几个选择：使用一些功能成熟的，专用于 SSH 连接的终端模拟器，比如 MobaXTerm 这样的软件；或者我们可以使用 scp，sftp 这样的工具，但是界面有点简陋，特别是 sftp，需要反复确认文件名是否输入错误。而且有时我们只需要下载不同的部分，不希望重复下载已经有了的部分。这时候，rsync 作为 remote sync 的工具，就到了发挥其作用的地方了。\n使用方法 命令结构 rsync 命令使用方法是：\n1rsync --option1 --option2 /pass/files/from/this/ /path/files/to/here 所以大概就是遵循：命令，选项，从哪里来，到哪里去 这样的规则。另外，既然 rsync 是 remote sync 的简称，自然这个命令也是可以被用于远程服务器之间的文件传输的。方法也很简单，就是给对应的文件路径添加上使用 ssh 的用户名、服务器地址等信息。具体用法我们下面介绍。\n注意路径分隔符 / 首先，这里需要强调的是，请注意 从哪里来，也就是发送端的这一部分，这里明显是一个文件夹，因为路径的最后有一个 / 符号。也许有人会问：我知道它是文件夹，我能不要那个 / 吗？比如使用\n1rsync --opt1 --opt2 /pass/files/from/this /path/files/to/here 这样的命令，来把文件夹传过去，可以吗？\n答案很有趣：是的，你可以传过去，但是也许不会以你预期的方式传过去。由于 rsync 会默认传过去的位置是个文件夹，如果你不带上这个斜杠的话，rsync 会认为你打算把 /pass/files/from/this 这个文件夹 放在目标位置的里面。如果你的确打算这么做，那没什么问题。比如你在本地有一个文件夹 $HOME/mydocuments，你在远程的服务器的接收端上也有这么个文件夹，位置一模一样，那么就可以尝试\n1rsync -r $HOME/mydocuments me@remote:/home/me 这会直接把 $HOME/mydocuments 传到远程的 /home/me 文件夹下，形成 /home/me/mydocuments 这样的结构。\n那么假如你是想说，我要把 $HOME/mydocuments 里面的内容 传到 /home/me/another/position 的话，那你就需要带上这个斜杠了，因为 rsync 就会聪明地帮你把文件夹里面的所有内容传到目标位置的那个文件夹里。也许也算是符合“一切皆文件”的思想了吧，如果你不带分隔符，就会以文件形式把这个 文件 传到文件夹里；而如果带上路径分隔符，则说明你要传的是文件夹的内容。\n远程链接 作为一款远程同步软件，自然需要有办法告诉 rsync 要把文件从哪里发到哪里。好消息是，rsync 支持我们通过 SSH 传输文件，而方法也特别简单。只需要在文件路径前面添加上你的用户名和主机名就可以了。如果你设置了 SSH 的主机名，甚至可以更方便。\n这里举个很简单的例子，从本机传到本机，但是通过 SSH 进行。我们可以通过 ssh \u0026lt;user\u0026gt;@localhost 来登录到本机的本地账户上，我的用户名是 amoment，所以就会用 ssh amoment@localhost 来登录到本机。那么我们就可以这样告诉 rsync：\n1rsync -r /home/amoment/myfiles/ amoment@localhost:/home/amoment/somefolder 来把我家目录下的 myfiles 文件夹里的内容复制/同步到同在家目录下的 somefolder 文件夹下。有了这个例子，你应该也明白怎么跨设备使用 rsync 通过 SSH 进行连接与文件传输了吧。\n除了使用 SSH 协议以外，rsync 还支持一些其他的协议，比如所谓的 RSH，或者 rsync 自带的 rsync:// 协议。但是由于 SSH 的支持还是更加广泛，我们这里还是只介绍该方案。如果感兴趣的话，可以查阅 rsync 的手册或者文档等资料。\n一些重要的参数 下面列举一些重要的，可能会经常使用到的参数。我们按一个大致的类别做区分，方便查找。\n文件操作 -r --recursive: 递归模式 它的意思是 recursive，也就是递归地把所有内容都传过去。如果不加这个东西，会发生什么呢？好消息是你照样能完成传输，但是坏消息是，你 只传过去了文件夹。也就是说，如果你不是只想在目标位置创建一个可能是新的文件夹的话，而是想把文件都传过去，请记得带上 -r。\n-a -- archive: 存档模式 你也可以选择不使用 -r 而是使用 -a，使用 -a 会以存档方式传输文件，也就是说，文件夹内的所有东西都会 保持原样 地传过去：不论是文件，文件夹，还是链接，设备描述符等，全都会原样传过去。-a 实际上是一系列参数的总和。根据帮助文档所述，是 -rlptgoD。还挺多的……\n--delete: 允许删除不同步的内容 因为 rsync 如其名所示，是 同步软件，因此我们也许希望不是“上传”文件，而是 把本地文件结构同步到远程。此时，我们需要用到 --delete 这个参数，它给了 rsync 删除目标文件夹内多余文件的权利，从而保证你确实是在 同步 内容。\n--exclude --include: 按模式进行排除/包含 这两个参数我们放在一起讲。如其名称所述，是用来告诉 rsync 排除哪些文件或者包含哪些文件用的。如果你有些文件不想传/特意要传，请设置这两个参数。\n--ignore-existing: 跳过传输同名文件 加上这个参数会让 rsync 检查接收端已有文件的名字，如果本地和接收端都有这么个文件（名称相同），则会跳过这个文件不进行传输。\n-u --update: 只传输更新的内容 这个参数意味着你是打算 更新 文件们。那么，如果接收端的文件比发送端更新（还要新）呢？答案就是不会碰这些文件。\n-z --compress: 先压缩一下 这个参数会告诉 rsync 传输前先帮你把要传的东西压缩一下。rsync 会自己选择一个压缩方法，所以一般不用担心。\n信息提供 -n --dry-run: 试运行 你要是担心传过去的内容不是你实际打算传的东西，你可以先让 rsync 告诉你目前的命令会传些什么，且不真的开始工作，只需要加上 -n 就可以。你可以把它理解为 no，即便实际上它对应的长参数是 --dry-run。拿不准会传些什么过去的时候，这个命令会很有用。\n-v --verbose: 更啰嗦一些 几乎所有（较复杂）的命令行程序都会内置这样一个命令，来把工作信息“更啰嗦”地显示出来。如果你需要额外的信息，请使用这个参数。\n-P: 进度条 就是让 rsync 报告当前的传输进度。我很喜欢用。\n涉及 rsync 本身 / 远程协作 -e --rsh: 指定传输协议 可能我们要传输的设备开放的 SSH 端口不在默认的 22 而是一个自定义的端口。此时我们就需要 -e 然后在后面带上一个字符串来表示使用的 shell 是哪个。比如我的远程接收端接口是 1145，则我会使用\n1rsync -r -e \u0026#34;ssh -p 1145\u0026#34; /myfiles/ me@remote:/myfiles 来让 rsync 尝试使用 1145 端口进行 SSH 通信与文件传输。\n--rsync-path: rsync 在哪？ 有可能我们需要帮助本地的 rsync 来寻找到另一个 rsync 究竟在哪。此时我们就需要这个参数来发挥作用，在后面带上找到 rsync 的方法：不论是 rsync 的路径，还是别的方式，都可以。比如希望传输的设备有 rsync，但是在 WSL 上。此时我们就可以\n1rsync -r --rsync-path \u0026#39;wsl rsync\u0026#39; me@remote:/myfiles/ /myfiles 来让远程使用 WSL 上的 rsync 为我进行工作。\n后记 我一开始使用 rsync 的主要理由其实是为了在不同的设备之间同步我的歌曲库。由于我有一些歌曲通过移动硬盘已经移动了一部分，而还有一部分没有同步，在另一台电脑上我甚至新添加了一张专辑，所以感觉单纯地自己手动搜索要迁移的文件有点太累了。而此时，rsync 用它 增量同步 的特性吸引了我，我便使用这么个方式来把远程的歌曲同步到本地电脑上来。\nrsync 还是挺好用的，它的语法可能没有那么智能，但是已经足以应付我遇到的问题了。印象中还有一些别的同步软件，比如朋友推荐的 Syncthing，也许后面会尝试使用一下。\n另外不得不提的是我在准备该文章时查阅过的信息源。非常感谢！\n首先，ChatGPT 和 Deepseek，完全不了解的时候和这些 AI 问一下还是挺好用的； rsync tutorial: 一个简单的 rsync walkthrough，帮了我很多； rsync command in Linux with Examples: GeeksForGeeks 下的一个博客，内容很丰富。 最后，感谢您能看到这里，祝您身体健康，心情愉悦~\n","date":"2025-07-28T12:43:39+08:00","image":"/images/贝加尔湖畔.jpg","permalink":"/zh/posts/shell_note/use_rsync/","title":"使用 rsync 进行同步"},{"content":"曾经总会好奇：怎么获取上一个命令呢？应该很简单才对吧？简单的搜索后，下面是我得到的结果，就记录一下吧\n头图出自 Orangestar 的专辑 SEASIDE SOLILOQUIES, 好看又好听。所以这里贴曲就贴这个专辑的主打歌好了：一首 Alice in 冷凍庫，希望你喜欢。\n什么时候要用这个？ 有时候我们写了一长串命令，比如有很麻烦的路径之类的，这时候我们可能会希望用某个符号来自动地填上命令里的某些参数。一个最常见的例子，当我要安装某些软件包的时候，偶尔会忘记加上 sudo 来以管理员权限运行。这时候把上面的命令复制一遍再补上 sudo 实在是太慢了，而按下上箭头后在把光标挪到第一行，最后补上 sudo 总是感觉很累，手的移动距离感觉好远。除此之外，有时输入的一长串命令/参数并运行之后，我需要接着上面的参数继续运行别的命令，此时要是用命令行历史的话，就又得用光标定位之后，再删掉没有用的东西，最后再填上要替换的内容。这实在是太慢了。\n好在这时候，我们还可以使用 zsh 交互模式下的一个内置宏：使用 !，感叹号，以及其对应的一些变体，来获取上个命令中的参数/整个命令等。下面就来介绍怎么使用吧。\n我需要取整个命令 上个命令是什么？ 我们可以用 !!，或者 !-1，来获取“上一个执行了的命令”。比如如下操作：\n1$ echo hello bash world! 2hello bash world! 3$ echo !! # !! 替换了上面整个执行了的命令，也就是替换了 \u0026#34;echo hello bash world\u0026#34; 4echo hello bash world! 5$ echo !-1 # 同上,也是替换上面执行的命令，所以替换了 \u0026#34;echo echo hello bash world\u0026#34; 6echo echo hello bash world! 我要调用历史命令 我们还可以用 !\u0026lt;num\u0026gt; 来选择某个历史命令。我们可以先用 head 来查看一下我们的命令历史里最早有一些什么：\n1$ head ~/.zsh_history # 这里我的 zsh 命令历史存在这个文件里，可以用 head 查看前几个命令 2: 12345:0;clear 3: 12346:0;echo hello 4: 12347:0;ls 5## ... ... 随后我们可以使用 !1 来选择历史命令中的第一个命令，这里的第一个命令就是 clear：\n1$ !1 # 执行第一个历史命令，也就是 clear，会直接清空屏幕； 2$ !2 # 执行第二个历史命令，会打印 hello； 3hello 4$ !3 # 执行第三个历史命令，会打印当前文件夹下的内容 5file1 file2 file3 小结 我们可以看到，后面跟着的数字实际上表示了“第几个命令”，而举一反三，!-1 则代表的是“最后一个命令”，即上一个命令，那么 !-2 就是倒数第二个命令。\n有了这两个命令，我们可以很方便地在忘记使用 sudo 权限时，使用 sudo !! 或者选择某个历史命令，来快速使用 sudo 权限执行命令。\n我需要取几个参数 我需要某个参数 我们可以使用 :\u0026lt;num\u0026gt; 来选择第几个参数。它需要配合 ! 进行使用。参数从 1 开始，而 0 有特殊含义，代表命令。比如：\n1$ echo one two three 2one two three 3$ echo !-1:2 # 相当于 echo two 4two 5$ echo !:0 # 上个命令使用了 echo，所以 0 代表 echo，这个命令相当于 echo echo 6echo 当使用 : 来进行参数选择时，如果是从上一个命令中选择则可以简写为 !:\u0026lt;num1\u0026gt;-\u0026lt;num2\u0026gt;。\n我需要这几个参数 我们还能用 :\u0026lt;num1\u0026gt;-\u0026lt;num2\u0026gt; 来范围式地选择命令的参数。比如，使用 !!:1-2 就说明要取第一个和第二个参数。（注意这里是参数，不是空格分隔的字符串，也不包含第一个词（也就是命令））。比如：\n1$ echo one two three four 2one two three four 3$ echo !!:1-2 # 相当于 echo one two 4one two 5$ echo one two three four # 这行用来重置最后一个命令 6one two three four 7$ echo !!:-3 # 没有 \u0026lt;num1\u0026gt; 则会自动替换为0，相当于 echo echo one two three 8echo one two three 9$ echo !-2:1-2 # 配合 !\u0026lt;num\u0026gt; 使用，相当于 echo one two 10one two 11$ echo !-3:1- # 没有 \u0026lt;num2\u0026gt; 则会匹配到除了最后一个参数外的参数，相当于 echo one two three 12one two three 13$ echo !-4:$ # 使用 $ 来获取最后一个参数，相当于 echo four 14four 15$ echo !-5:3-$ # 同样 $ 也支持范围选择，相当于 echo three four 16three four 17$ echo !-6:* # 使用 * 来表示所有的参数，相当于 echo !-6:1-$，也就是 echo one two three four 18one two three four 19$ echo !:* # !: 是在使用冒号时 !!: 或者 !-1: 的简写，相当于 echo one two three four 20one two three four 如果没有 \u0026lt;num1\u0026gt;，则默认从 0 开始，也就是会包含所有内容；如果没有 \u0026lt;num2\u0026gt;，则默认停在最后一个参数前。可以使用 * 来选择所有的参数，使用 $ 选择最后一个参数。\n我要对字符串做处理 在冒号后使用一些字母来做相应的处理。假设有命令 ls /path/to/a/file.txt 并且我们使用 echo !:1 尝试调用这个 ls 的命令，则下面的参数选择器可以做到：\n:p (print) 只打印，不运行，或者说提供一个预览。ZSH 用户也许不需要担心这一点。 :q (quote) 对选中字段加上引号，结果为 '/path/to/a/file' :r（root）取文件的完整文件名，结果为 /path/to/a/file :e（extension）取文件的后缀名，结果为 txt :h（head）取文件路径的地址，结果为 /path/to/a/ :t（tail）取文件的名称，结果为 file.txt :s/to/has（search）可以在参数中寻找第一个 to 并替换为 has，结果为 /path/has/a/file.txt :gs/to/has（global search）同上，但是全局查找替换。 TL;DR 下面是一个表格简单描述这些用法\n命令选择（使用 !） 语法 含义 示例 !! 上一条命令 sudo !! !-n 倒数第 n 条命令 !-2 !n 第 n 条历史命令 !42 !字符串 最近以该字符串开头的命令 !ls !?字符串? 最近包含该字符串的命令 !?foo? ^旧^新 将上一条命令中第一个“旧”替换为“新” ^cat^bat 参数选择（使用 :） 下面的示例命令使用 echo file.txt 来做演示。\n语法 含义 示例 !!:0 上一条命令的命令名 !!:0 → echo !!:1 第一个参数 !!:1 → file.txt !!:2 第二个参数 !!:$ 最后一个参数 !!:$ !!:* 所有参数（等同于 !!:1-$） !!:* !!:1-3 第 1 到第 3 个参数 !!:1-3 !!:2-$ 从第 2 个到最后一个参数 !!:2-$ !$ 上一条命令的最后一个参数 (可以省略冒号) cat !$ !* 上一条命令的所有参数（可以省略冒号） rm !* 参数修饰 下面的示例命令使用 echo /path/to/file.txt 来做演示。\n修饰符 含义 示例 :p 只打印命令，不执行 sudo !!:p :q 给参数加引号，避免空格或特殊字符问题 echo !!:1:q :h 获取路径头部（类似 dirname） echo !!:1:h → /path/to :t 获取路径尾部（类似 basename） echo !!:1:t → file.txt :r 去掉文件扩展名（保留主名） echo !!:1:r → file :e 获取文件扩展名 echo !!:1:e → txt :s/旧/新/ 替换第一个出现的子串 !!:1:s/foo/bar/ :gs/旧/新/ 替换所有出现的子串 !!:1:gs/foo/bar/ 总结 这里其实应该没有写完，不过就这些已经列出来的方法而言，我个人感觉是已经挺够用的了。毕竟，平时最常用的也就是 sudo !! 来给 pamcan -Syu 补上管理员权限而已，或者是在 ls -l /path/to/file 确定文件/文件夹存在后用 vim 或者 cd 打开它罢了。\n还有一点要注意的是，bash 默认是不会像 zsh 一样先提供一个预览，让你看看会发生什么的，而是直接就运行命令了。所以也许在 bash 中使用这个功能时需要额外注意，特别是涉及一些比较危险的动作，比如 rm 这类命令。此时你可以尝试先用 :p 来打印出来要运行的命令，没啥问题就可以运行了。印象中应该还有一个办法，来让 bash 也先提供一个预览而非直接运行。不过，因为我用的是 zsh，就不纠结这个问题了。也许以后我还会更新这篇文章呢？哈哈。\n那么，感谢你看到这里，祝您身心愉悦，身体健康~\n","date":"2025-07-26T20:09:18+08:00","image":"/images/SEASIDE_SOLILOQUIES.png","permalink":"/zh/posts/shell_note/last_command/","title":"上一个命令是什么？"},{"content":"数学的一大特征大概就是多种多样的符号了吧。提到数学，大家总是能想起各种各样的公式，即便在我心目中，物理也许更能用各式各样的公式凸显自己的高深莫测，然而作为一种逻辑严密的学科，依旧少不了用各种符号来代指各种数学对象。本文就 微积分 这一个子方向，浅谈这些风格迥异的记号，也方便接触不同领域的文献。\n最近很喜欢橘星（Orangestar）的这首 《Postscript》，夏背画的 MV 也很好看。所以就都放上来吧~ 可惜这首歌是要 VIP 的，想畅听的话可以试试B站链接\n$$ \\gdef\\d{\\space\\mathrm{d}} \\gdef\\p{\\partial} \\gdef\\Del{\\nabla} \\gdef\\R{\\mathbb{R}} \\gdef\\pfrac#1#2{\\dfrac{\\p #1}{\\p #2}} \\gdef\\ddfrac#1#2{\\dfrac{\\mathrm{d} #1}{\\mathrm{d} #2}} $$积分符号：也许是混沌善？ 虽然微积分这门学科，从逻辑上讲是由导数和微分作为引入更合理，这一点从 微积分 的名称中也许也能略窥一二。然而，由于我们主要是来聊聊微积分中的符号，而积分的符号相对而言会更简单一些，因此我们先从它开始谈起。\n单个积分符号 $\\int$ 由莱布尼茨引入的 $\\int$，它几乎是最常见的积分符号了，其源自拉长的字母 $S$。当它孤零零地出现时，常常代表着求的是表达式的不定积分，即求表达式的原函数。当需要给定一个区域时，习惯上给一元函数的定积分写上上下限来表达积分的区域。而这个符号也不一定只用于一元函数：它可以表示 曲线积分，而且在表达一般情况下的积分时，也可以使用该符号。此时积分区域是一个一般意义的集合，放在积分符号的下标来表示积分区域。\n来几个例子吧。比如我们人见人爱的普通不定积分：\n$$ \\int f(x) \\d x, $$它就是说在尝试求 $f(x)$ 的原函数是什么，即尝试找到一个 函数族 $F(x)$ 使得其导函数为 $f(x)$。而下面则是一个定积分的例子：\n$$ \\int_0^{\\pi} \\sin (x) \\d x, $$它是在求 $\\sin (x)$ 在区间 $(0,\\pi)$ 的积分。我们也可以说是在区间 $[0,\\pi]$ 上的积分，这取决于你对积分的看法，由于 $\\sin(x)$ 的连续性，这两个积分结果是等价的。我们抓住 一维 这个特点，从而可以考虑对 一维流形（天哪真的可以这么说吗），也就是一般的曲线，进行积分：\n$$ \\int_l f \\d s, $$这里的 $l$ 自然表示的就是积分区域，一条曲线，而 $\\d s$ 就是积分的所谓 线元素了。具体的计算方式我们这里就不再提出。由于曲线是一维的（应该是叫内禀维度吧？），我们可以把在这条曲线上的积分直接看作是单纯的参数函数的积分，因此使用这样的积分符号完全是合理的。我们这里埋一个小坑，即为什么用了 $f$ 而非 $f(x)$ 或者 $f(s)$ 之类的东西作为被积函数。我们以后再谈这个问题。\n而当你有一个说不清是几维的函数，或者是一个一般意义下的多元函数（定）积分时，你也会使用这个积分符号，用法如：\n$$ \\int_{\\Omega} f({\\bf x}) \\d^n {\\bf x} $$这就是在说，给一个 $n$ 元函数 $f({\\bf x})$ 进行积分，积分区域为 $\\Omega$。这里有几个要点：首先，出现在后面表明被积分变量的 $\\d^n {\\bf x} $ 应该是\n$$ \\d x_1 \\wedge\\mathrm{d} x_2 \\dots \\wedge\\mathrm{d} x_n $$的缩写。我们暂且按下“$\\wedge$”这个符号不表，上面这串的含义是积分区域 $\\Omega$ 的一个微分元素。从稍微物理一点的角度去讲，它代表着一个微小体积。另外，$\\Omega$ 一般我们认为它是一个开集合：\n$$ \\Omega \\subseteq \\R^n . $$不过，在不那么严格的语境下，我们可能会考虑使用 $\\d v$ 来代表一个 体积元，用 $V$ 指代积分区域。这样一来，我们可以将这个积分像考虑“求面积”那样类比到“求体积”或者“求质量”上，方便理解。作为多元函数，其中的 ${\\bf x}$ 一般被理解为是位置向量，这样的方式更现代化一些，不过即便理解为是依赖 $n$ 个元素而非依赖一个 $n$ 维向量，也是可以的。结论上不会有什么差别。\n关于这个符号，我们先暂时到这里，因为积分符号还有另外挺常见的几个：\n重积分符号与环积分符号 我们常常还能见到多重积分的符号，比如 $\\iint$，$\\iiint$ 等。有时还能见到这种中间带一个小圈的积分符号，即代表对封闭区域积分的 $\\oint$，$\\oiint$ 等。我们先看看重积分。\n代表重积分的符号相对而言是含义比较清晰的，因为看上去有几个 $\\int$ 就代表了是几重积分。由于对高维空间不再有 区间 这个概念了，所以一般而言，重积分的积分区域都是直接写在积分符号下方，用一个符号表示出来。比如\n$$ \\iint\\limits_{A} f(x,y) \\d x\\wedge\\mathrm{d} y , $$其中的 $A$ 就代表了一个二维积分区域。有些地方还会把积分区域直接通过不等式表达出来写在积分符号的下方，比如\n$$ \\iint\\limits_{\\substack{0\\lt x\\lt 1\\\\ y\\gt x}} f(x,y) \\d x\\d y $$就是说这个函数的积分区域是一个小小的直角三角形。不过我个人是不太喜欢这种表达的，看上去很凌乱。\n需要说明的是，重积分 $\\neq\\;$ 积分多次，至少不能直接画上等号。然而吧，在一般的应用过程，没人会在意二者等价性的证明的……甚至于，即便你省略楔积符号（即那个 $\\wedge$），甚至看作乘法，都没人会管的。我们后面再吐槽吧。\n封闭区域的积分其实没什么特别的，就是在提醒读者，这个积分区域是封闭区域。这里所说的 封闭 应该理解为“图形是闭合的”，区别于数学上的 闭区间 的概念。按照比较 nerd 的说法，这个封闭应该是说该图形是某个图形的边界。Anyway，直观来看就是说是一个圈，或者一个气球，那样的东西（大概）。比如 $\\oint$ 就是说积分的区域应该是闭合曲线，而 $\\oiint$ 就是一个闭合的曲面，就像气球那样。对应的，积分元素也是线元或者面元了，这里就不再赘述。\n是积分，但不是拉长的 $S$ 除了上面常见的，使用拉长的 $S$ 来指代积分以外，还有另一条不怎么常见的分支，即用算符去指代积分，即用 $D^{-1}$ 这样一个符号来突出积分是求导的逆运算1（自然，$D$ 就被用作求导的算符了）。这样的记号也许在偏向代数的学科中会见到吧，或者是在微分方程中。毕竟这个符号非常简洁，且比起 $\\int$，更能让人接受它是一个算符。事实也确实如此：积分这个东西，就是可以理解为输入一个函数后输出一个实数/函数族（取决于定积分还是不定积分）。关于这个符号，我们就先只聊到这里。\n没有 $\\mathrm{d} x$，对吗？ 我想近乎所有初学微积分的人，都会有这样的一个疑问：$\\int$ 还不够说明这个东西是个积分吗？为什么非得要后面 $\\d x$ 这样一个尾巴！？然后随着后续的学习，比如换元法，多元积分，以及见识过不是被积变量出现在函数中的情况后，从心理上就接受了这样的写法。毕竟，指明这个积分是对谁做的积分，不也挺好？然后把只有 $\\int$ 而没有后面的 $\\d x$ 的写法认为是一种简写。这近乎是大多数人对积分符号的看法了吧。\n但是，我们能合法合规地不要后面的 $\\d x$ 吗？毕竟，它是微分呀！为什么非要把微分和积分符号放在一起？这个问题也许在勒贝格积分下或者在微分流形理论下能得到合理的答案，但是倘若我们只是讨论黎曼积分呢？\n好消息是，于品老师的数学分析讲义里就没有使用传统的 $\\int f(x) \\d x$ 的记号，而是采用 $\\int f$ 作为积分的记号。这里是这么做的：考虑 $\\int_I$ 是一个定义在 黎曼可积函数集合 $\\mathcal{R}(I)$ 上的，到实数域上的映射。那么对于任意一个黎曼可积函数 $f \\in \\mathcal{R}(I)$，我们都可以合法地写出 $\\int_I f$，并称其为 $f$ 的积分。\n可惜，我们不计划在这里深究这些技术上的细节，只是介绍一下这些数学符号。不过，感兴趣的话，于品老师的这份讲义写的很不错，很值得一看。关于这部分，可以参考 18. Riemann 积分的定义。除了于品老师的教材这样处理之外，Terence Tao（陶哲轩）所著的 Analysis 也采用了这种不在积分中使用 $\\d x$ 的做法。感兴趣的话也可以看一看。\n导数：混沌恶！ 谈完形式比较符号比较简单的积分，我们再来看看众说纷纭，略显混乱的求导符号们。混乱的原因主要是因为，很多数学家都发明了自己的求导符号。因此，干脆我们这里就以不同的数学家为轴，介绍一下这些符号。\n莱布尼茨：无心的发明，神秘的隐含 我们先看由莱布尼茨引入的记号，\n$$ \\ddfrac{f(x)}{x}, $$对高阶求导则是\n$$ \\ddfrac{^nf(x)}{x^n}. $$这个记号也许是高数中最令人熟悉的记号了，它明确地指出了上面的函数对下面的自变量求导，以及求导的次数。它还很好地捕捉了求导与微分的关系，这一点大概是莱布尼茨一开始没有料想到的吧。不过，我们也不太能直接把求导解释为“一个东西，除以另一个东西”，即便很多定理/定律都在这样的解释下还能正常工作，但还是不应该这么做。\n然而，我们当然可以用这个方法来助记，不是吗？比如复合函数 $g(f(x))$ 的导数，用链式法则就能得到：\n$$ \\ddfrac{g(f(x))}{x} = \\ddfrac{g(f(x))}{f(x)} \\ddfrac{f(x)}{x}, $$或者我们采用更加“阅读友好”的形式，令 $f(x) = y$，则有：\n$$ \\begin{align*} \\ddfrac{g(f(x))}{x} \u0026= \\ddfrac{g(y)}{x} \\\\ \u0026= \\ddfrac{g(y)}{y} \\ddfrac{y}{x} \\\\ \u0026= \\ddfrac{g(y)}{y} \\ddfrac{f(x)}{x}, \\end{align*} $$不得不说，真的很像单纯地把除法算式拆成两个除法相乘。\n然而这个记号也许会引起这样一些误会：为什么不是 $\\d f(x) ^n$“除以”$\\d x^n$？它和微分之间究竟有什么联系？为什么是 $\\d x^n$ 而非 $\\d^n x$？在我学习微积分时，这也是一个困扰了我很久的问题。\n为了解释这个问题，我们首先先回答为什么前面讲到：为什么不能把求导解释为“一个东西除以另一个东西”。你可能见过这样的求导记号：\n$$ \\ddfrac{}{x} f(x) $$这同样表示对 $f(x)$ 的求导，而且也许是最符合“严格”的要求的记法。因为这个符号中，我们能更明显地看出 $\\ddfrac{}{x}$ 是一个整体！而且我们还能更好地描述这个符号在做什么：我们对一个函数 $f(x)$ 进行了求导的操作（左边作用上 $\\ddfrac{}{x}$）。从这个角度，为什么高阶导数是 $\\ddfrac{^nf(x)}{x^n}$ 也能很好地解释了，因为我们可以写成：\n$$ \\ddfrac{^nf(x)}{x^n} = \\ddfrac{^n}{x^n} f(x) = \\left(\\ddfrac{}{x}\\right)^n f(x), $$也就是我们对一个函数 $f(x)$ 多次作用上 $\\ddfrac{}{x}$。\n实际上，这已经是从 算符 的角度来解释这个过程了。也正是因为 $\\ddfrac{}{x}$ 是一个完整的算符，我们不能把它单独拆开，表示成两个微分相除。这不是唯一的一个算符表示方法，然而其细节丰富，在需要展示所有的运算细节的时候，我会很喜欢使用这个符号。但是我们也不是什么时候都需要展示所有的细节，这时候莱布尼茨的符号就显得很啰嗦了。这就引出了另一个很受欢迎的求导记号：拉格朗日记号。\n拉格朗日：简洁的美 由拉格朗日引入的记号 $f'(x)$，$f''(x)$ 以及 $f^{(n)}(x)$ 同样是被广泛应用。这种记号在受欢迎程度上几乎与莱布尼茨的记号平分秋色，但拉格朗日记号胜在其形式简洁明了。然而，由于拉格朗日的记号太深入人心，导致假如希望给一个和 $f$ 相关，但实际上不同的函数一个记号，就不可能考虑 $f'(x)$ 这样已经广泛接受为导数的符号了，只能考虑再上面加小帽子：$\\hat{f}(x)$，或者加横杠：$\\bar{f}(x)$，等等。\n拉格朗日的记号最常见到的地方大概就是各类微分方程了。由于微分方程中，求导的变量通常都是很明确给出的，因此与其采用莱布尼茨那样的符号，拉格朗日的符号更容易书写也不会引起歧义。此外，在上下文明确的前提下，这个符号写起来也确实是很方便。如果要我给别人解释一个包含导数，但又不太需要太多导数的细节的东西，我会比较乐意写这个记号。然而，如果是写文的话，可能这个记号还是会往后排一排吧。\n说到简洁，拉格朗日的记号确实不错，但不是唯一的一个，然而它就不那么幸运了。\n牛顿：物理的传承 作为与莱布尼茨共同开创微积分的数学家，牛顿的记号就显得有点小众了。他使用点来代表求导，如 $\\dot{f}(x)$ 代表一阶导，$\\ddot{f}(x)$ 代表二阶导。但是这种记号没有一个很好的表示 $n$ 阶导的方法，高阶导也显得异常臃肿：单纯地堆砌点号。一些老的文献中通常能看到函数上堆满了点，想确定是几阶导数还需要一个个数一下。相比之下，拉格朗日的做法就聪明的多了，直接用一个数字代表，非常简单明了。\n不过，由于牛顿在近现代物理领域中近乎奠基人的地位，这个记号在物理学或与之相关的领域中也依旧被广泛应用。现在人们延续了牛顿引入这个记号时所设想的含义：对时间求导（也就是牛顿创立微积分时所说的 流数）。一个点就是对时间求一阶导，两个点就是二阶导。由于物理领域不太常遇到对时间求高阶导，这样的记号也显得还不错。\n举个例子来讲，物理中常用到的变分法需要欧拉-拉格朗日方程中，就有略显奇葩的做法，即对变量的导数再求导。具体还可以参考之前写的关于欧拉-拉格朗日方程的简介的内容，或者这篇关于变分法的内容。比如说有这样一个力学体系，它的状态可以由体系内粒子的位置以及其速度来决定。此时我们可以写出其拉格朗日量：\n$$ L = L(q,v,t), $$而使用了牛顿的记号的话，就可以写作如下的形式：\n$$ L = L(q,\\dot{q},t), $$这样能很好地体现位置和速度的关系。最重要的是，相比于用 $\\ddfrac{q}{t}$ 或者 $q'$ 这样的写法，这样的写法能很好地表达它就是对时间求导的关系。另外，也能让欧拉-拉格朗日方程的表达式变得很简洁，即可以写成下面的形式：\n$$ \\frac{\\partial L}{\\partial q}-\\frac{\\mathrm{d} }{\\mathrm{d} t}\\frac{\\partial L}{\\partial \\dot{q}} = 0. $$只能说牛爵爷心是好的，后面数学家/物理学家们也给执行好了。\n欧拉：算符化求导 另一位大数学家欧拉也引入了一个记号，使用 $D$ 代表对函数的求导。这也就是上面所说的，积分算符，$D^{-1}$，的逆算符了。它具有算符的特质，如求一阶导就是 $D f$，二阶导就是 $D^2 f$，而高阶导自然就记为 $D^n f$。也正是由于这样算符化的特点，这个记号现在在泛函分析领域中被广泛应用。\n另外，借助这样的记号，我们甚至可以对求导算符进行一些代数的操作。比如也许我们熟知的二阶常微分方程，就可以用算符的形式进行有理有据的“助记”。例如：\n$$ ay'' + by' + cy = 0 $$这样的二阶齐次线性微分方程，就可以拆成算符的形式：\n$$ (aD^2 + bD + c)y = 0, $$这提取出来的算符形式，和所谓的特征方程是否有几分神似？没错，求解这个微分方程实际上就是在求这个括号内的算符的本征函数（类比本征值）。\n围绕这个算符，我们甚至可以衍生出很多别的很有意思的讨论，比如量子力学中所谓的“对易”，或者是讨论“位置与动量的关系”等等。B站上有人搬运了关于 微分的导数 的视频，挺有意思的，里面涉及到一些把导数看作算符的观点，感兴趣可以看一下。另外，查阅 维基百科，可以看到还有一种表达算符的写法：$\\partial_x$。这种写法偶尔会在偏微分方程中见到，不过总归是不太常见，也可能是我很少遇到微分方程的缘故吧？\n雅可比：多元函数与矩阵 然而，看过上面的几个记号，竟然没有一个考虑过多元函数的情况。针对这个问题，首先引入符号的是雅克比，他引入四种记号来表示不同的导数：\n对于偏导数，使用 $\\pfrac{f(x,y,\\dots)}{x}$，$\\pfrac{^nf(x,y,\\dots)}{x^{m}\\p y^{n-m}}$ 这样与莱布尼茨记号类似的记号； 为简记上面的记号，引入了 $f_x = \\pfrac{f(x,y,\\dots)}{x}$，$f_{xy} = \\pfrac{^2f(x,y,\\dots)}{x\\p y}$ 这样的记号； 为了表达向量值函数的导数，引入了雅克比矩阵与雅克比行列式，来指代一阶导数。多元函数的雅克比矩阵的记号为 $\\mathbf{J}_\\mathbf{f(x)} = \\dfrac{\\p(f_1,\\dots,f_m)}{\\p(x_1,\\dots,x_n)}$； 为了表达多元函数的二阶全导数，引入了黑塞矩阵，记号为 $\\mathbf{H}_f$，其矩阵元素为 $(\\mathbf{H}_f)_{i,j} = \\pfrac{^2f(x)}{x_i\\p y_j}$。 这些符号极大地丰富了对多元函数的表达，可以说没有这些记号，多元函数的研究光写文字都得好久。而且最重要的是，矩阵 的形式很好的说明了求导这个操作的线性性。当我第一次知道，二元函数的全导数是一个矩阵，且表达的是一点的切面的时候，是有点被震撼到的。具体内容可以参考大名鼎鼎的 Baby Rudin，也就是 Walter Rudin 所著的 Principles of Mathematical Analysis。我就是从这本书知道这点的。\n另外，就像上面所说的，莱布尼茨的记号有对应的算符版本，雅克比的这些记号也是有自己的算符版本的，就是写作 $\\pfrac{}{x}$ 的形式。这样的形式的优点类似与莱布尼茨记号的方式，我们这里不再多提。不过值得一提的是，由于微分流形/微分几何中对高维几何体与微积分之间联系的研究，这一保留了算符性质的表达偏导数的符号被这两个学科大量地应用，甚至已经不仅仅用以表达单纯的偏导数了。比如从偏导/方向导数中抽象而来的 切向量，就直接使用了这样的偏导数记号来表示了。\n也许是国内教材干的？但是很无奈…… 多元函数在微积分中的研究，不仅仅是多了几个依赖的变量这么简单。考虑多元的复合函数的情况，我们在对多元函数进行求（偏）导时必须考虑所有的变量，这就给多元函数的求导引入了极大的复杂性，也正因如此，不注意求导过程中的记号将会引起很大的歧义。\n比如有这样一个函数：$f(u(x,y),x,y)$，这里 $x$ 与 $y$ 独立，而 $u$ 依赖这两个变量。现在想求 $f$ 对 $x$ 的偏导数，应该怎么做呢？由多元函数的求导法则和链式法则，我们应该写：\n$$ \\pfrac{f(u(x,y),x,y)}{x} = \\pfrac{f}{u}\\pfrac{u}{x} + \\pfrac{f}{x} $$等一下，这对吗？如果来个初学者尝试写这个问题，会不会出现把两边的 $\\pfrac{f}{x}$ 直接给消掉，最后得到一个 $\\pfrac{f}{u}\\pfrac{u}{x} = 0$ 的方程？这明显是有问题的，而这样的歧义主要出现在，我们尝试对多元函数求导时，首先是 对位置 求导的，而非对 变量 求的导。所以，上述等式第二个部分应该是想表达，这个函数需要对第二个位置求偏导才对。\n为了解决这样的歧义，也许是国内教材特供吧，我们会用 $f'$ 带上下标数字来表示“对几号位置求导”。比如写 $f'_1(u(x,y),x,y)$ 来代指对“一号位置”求导。有时候，可能还会把上面这一撇省略掉。这样一来，上面的算式就能写成：\n$$ \\pfrac{f(u(x,y),x,y)}{x} = f'_1 \\pfrac{u}{x} + f'_2\\pfrac{x}{x} = f'_1 \\pfrac{u}{x} + f'_2 $$这样也算是能解决问题吧。不过其实更好的方法是区分开函数和变量，比如一开始给函数记为：\n$$ \\begin{align*} w = f(g(x,y),x,y)\\\\ u = g(x,y) \\end{align*} $$并把偏导写为：\n$$ \\pfrac{w}{x} = \\pfrac{f}{u}\\pfrac{u}{x} + \\pfrac{f}{x}, $$这样也能有效避免歧义，但是对这个过程的解释就会变得比较复杂。我们要先明确一点，我们在进行求导时，有两层求导：\n对 函数本身 求导，求的就是这个函数对某个变量的导数。如这里的 $\\pfrac{w}{x}$ 或者 $\\pfrac{u}{x}$； 对 函数法则 求导，实际上是在套求导的公式，是纯粹形式上的求导。比如这里的 $\\pfrac{f}{u}$ 和 $\\pfrac{f}{x}$。 当我们应用求导法则时，我们的目的是对函数本身进行求导，而计算过程则是机械地运用纯符号的填空题法则，来把内容填进去。然后在具体计算时，尽可能不展开表达式，比如计算 $\\pfrac{f}{u}$ 的时候就将 $x$ 和 $y$ 作为非变量进行计算，而在计算 $\\pfrac{f}{x}$ 的时候就把 $u$ 和 $y$ 作为非变量进行计算。\n但是总的来说，还是非常混乱了……\n小结一下吧 可以看到，导数的符号，真的很混乱。每个领域几乎都有自己的写法。稍不留神可能就会引起歧义（说的就是你，多元求导）。也许在讨论多元函数微积分前，最好先规定好一套无歧义的符号标准？那做题怎么办呢？很难受了……混沌恶，当之无愧！\n微分记号：中立善 看了上面这些令人头晕的符号，我们还是来看点轻松的内容吧。微分记号几乎是大家一致认同的符号之一了，都选择用一个简单的 $\\d\\ $来表达微分……\n正体 VS 斜体 等一下，真的如此吗？这不是正体的 $\\d\\ $ 吗？为什么很多地方（包括维基百科）都是直接用的 $d$ 呢？\n这又是一个令人抓狂的故事了。实际上，很多地方的数学符号，都是应该写作正体的。比如三角函数，应该使用 $\\sin$，$\\cos$，$\\tan$ 这种。而 $sin$，$cos$，$tan$ 这样的写法则是不规范的；微分算符也不例外，$\\d\\,$ 应该是更加规范的写法。然而 AMS 出手了：在 AMS 的规范中，出现微分的地方是应该写成 斜体 的！所以你可以看到很多地方的写法，都是遵从 AMS 规范写的斜体 $d$ 而非正体的 $\\d$。\n不过还是有作者不同意这种写法的。比如著名的 Zorich 的 Mathematical Analysis 中就采用了正体的写法。所以这个符号也许主要还是看作者的想法吧。当然，你也可以看出，我是支持正体写法的。毕竟，斜体的 $d$，更像一个变量，不是吗？\n微分能直接乘吗？ 另外可能略有歧义的地方，在于“微分到底能不能相乘”这个点。这个问题也许主要来自于积分那边：我们经常可以看到把一个重积分写成对一个函数积分多次的形式。比如也许你经常见到：\n$$ \\iiint\\limits_{V} f(x,y,z) \\d v = \\int_{x_1}^{x_2} \\int_{y_1}^{y_2} \\int_{z_1}^{z_2} f(x,y,z) \\d x \\mathrm{d} y\\mathrm{d} z $$这样的写法。它没什么问题，但是重点在于，写下面的样子就不是很严谨了：\n$$ \\iiint\\limits_{V} f(x,y,z)\\d x \\mathrm{d} y \\mathrm{d} z. $$这是因为，一个多元函数是不能匹配上一个一维的微分元素的。这样的写法略有牛头不对马嘴的味道。那么正确的写法是什么呢？应该采用我们上面对积分符号的介绍时用到的 楔积 记号，即：\n$$ \\iiint\\limits_{V} f(x,y,z)\\d x \\wedge\\mathrm{d} y\\wedge\\mathrm{d} z. $$这个楔积是何许人也？我们不过多介绍，但是可以说的是，楔积是微分之间的一种运算，能把低阶的微分组合起来，使它成为高阶的微分。像这里所做的，把三个微分用楔积联系起来，得到的就是一个三阶的微分。这样，就可以和一个三元函数相匹配，并进行三重积分了。如果对这个问题感兴趣，可以参考数学分析教材或者微分流形的教材。上面会对“微分到底是什么”有从代数层面的详细的解释。\n然而你要是问我，平时在不那么严谨的语境下，怎么表达一个多重积分？那我可能也是会偷懒省掉这个楔积符号的。毕竟，上下文说明了一切嘛，要相信读者的阅读能力，不是吗？（逃）\n后记 首先想说的，是感谢群友 Harviiiii 为本文提供的意见和建议。谢谢你~！\n这篇短文是我在学习有限元方法时遇到的方程带来的问题。具体的内容我已经记不太清了，但是大概就是对某个符号产生了疑惑，然后就像这样，打破砂锅问到底了。其实说实在的，很少会有人对符号，特别是工程上常用的微积分的符号有这么大的疑惑，或者对其严谨性有这么高的要求的。毕竟当它是“微积分”而非“分析学”的时候，数学就更像是一种工具，好用才是第一要务。\n然而探索这些符号的过程也是挺有意思的吧，而且说不定也许有审稿人会因为我的符号使用比较规范而高看我一眼呢？哈哈哈。\n另外要补充的是，实际上这篇文章刻意隐藏了一个很大的坑，不知读到这里的您是否注意到了。那就是：到底那个符号是函数？比如 $y = f(x)$，这个或许初二还是初一就学到了的表达式里，究竟哪个部分是所谓的函数？$y$？$f$？$f(x)$？我们到底应该怎么写一个函数的微分/积分？而且，说到底，函数 这个概念，貌似也有很多不同的观点吧？是一般的映射？是特殊的映射？是 函数？\nOMG，这个话题说实在的，又能给我水一篇博客了。所以，我们有缘再见吧，说不定关于 函数 这个数学中司空见惯的对象的杂谈很快就会写出来呢？\n那么最后，一如既往地，祝您身体健康，身心愉悦，度过美好的一天~\n积分的逆运算是什么呢？这需要看是什么积分：如果是不定积分，那应该就是求导运算了；而如果是指定积分的话，则应该是微分运算了。我们这里不多纠结这个问题，也许以后会填上这个坑？\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-07-24T15:16:10+08:00","image":"/images/Postscript.png","permalink":"/zh/posts/math_note/calculus_notation/","title":"微积分的符号"},{"content":"怎么连接个服务器还得先用 ToDesk 连上个 Windows 电脑，再从这个电脑上 ssh 进服务器呀！？受不鸟，自己搭个 FRP 服务吧\n图为可爱的风笛小姐，据不可靠消息，应该是来自于画师 Liyu黎 老师画的 2022 音律联觉的贺图。既然如此，就配一首风笛小姐的个人 EP：《故乡的风》吧。\n引子：为什么要跳板机 为了方便提交任务，做相场计算，组里配了一台计算服务器，一个管理节点+两个计算节点，劲呀！然而坏消息是：组里没有多余的空间放置服务器了，只能托管到另一个老师那里。\nOK，没什么关系，给服务器配个公网IP，那不就和在自己组里一样咯？可是实际上并没有那样的好事，公网IP也不是想申请就申请的。课题组内貌似对网络配置这块不了解，也不打算了解，所以就只能交给装机的小哥处理。而他和那边老师协商后，决定采用的方案是：使用 ToDesk 连接到和服务器处于同一公网下的 Windows 电脑，再用那个 Windows 电脑 SSH 到服务器上。整体过程大概是这样的：\ngraph LR subgraph \"内网环境1\" Client[客户端我的电脑] end subgraph \"内网环境2\" OtherUser[其他用户] end subgraph \"内网环境3\" Windows[Windows电脑ToDesk远控] Compute[计算服务器无公网IP] end Client --\u003e|ToDesk远控| Windows Windows --\u003e|局域网SSH| Compute OtherUser -.-\u003e|❌无法连接| Windows classDef public fill:#e1f5fe classDef private fill:#fff3e0 classDef deprecated fill:#ffebee classDef forbidden stroke:#f44336,stroke-width:2px,stroke-dasharray:5 class Client public class Compute private class Windows deprecated class OtherUser forbidden 通过 Windows ToDesk 跳板连接示意图 这个方案，说实在的感觉很蠢。一个服务器，搭载着多用户操作系统，竟然必须用 Windows 做跳板然后跳过去！？这不就意味着，如果有两个人同时使用服务器，我就会和对方产生会话冲突？而且如果有人盯着那台 Windows 电脑的屏幕，我的操作不就暴露地清清楚楚了！？怎么想都是很愚蠢的做法，不过也能理解：这应该（也许）是一个临时的解决方案。而后面谁来解决这个问题呢？\n那必须是我了！我们可以搭建一个 FRP（快速反向代理）服务，让流量通过一个跳板服务器转发到计算服务器上，不再蠢蠢地堵在同一台 Windows 设备上。这样一来，每个人都可以自己自由地连接上这个服务器，只需要把流量交给反代服务器（跳板服务器），让它处理转发端口之类的，就可以啦。搞好之后的示意图大概是：\ngraph LR subgraph \"内网环境1\" Client[客户端我的电脑] end subgraph \"内网环境2\" OtherUser[其他用户] end subgraph \"外网环境\" FRPServer[FRP服务端公网IP中转] end subgraph \"内网环境3\" Compute[计算服务器无公网IP] end Client --\u003e|SSH| FRPServer OtherUser --\u003e|SSH| FRPServer FRPServer --\u003e|FRP客户端反向代理| Compute classDef public fill:#e1f5fe,stroke:#039be5 classDef private fill:#fff3e0,stroke:#fb8c00 classDef server fill:#e8f5e9,stroke:#43a047,stroke-width:2px class Client,OtherUser public class Compute private class FRPServer server FRP 内网穿透网络架构示意图 嗯哼，那就开始吧~\n搭建：也许需要个 TL;DR 我觉得也许应该先写一下 FRP 技术是什么以及介绍一下这中间的网络通信过程是什么样的，然而我相信，来看这个博文的朋友应该都是需要一份切实可行的执行过程的。所以下面的第一步是：\nTL;DR 下面的流程大量参考自开源教程：Frp内网穿透搭建教学，内容非常详细，感觉这里不清楚的可以去看看\n下面是我的解决过程：\n租个服务器：在阿里云用学生认证白嫖三个月的便宜服务器，有个公网IP就行，待会儿会用这个IP 先用 ToDesk 连到远程计算服务器上，然后用 curl ifconfig.me 得到服务器所在公网的公网IP，待会儿会用到 在计算服务器上下载 frp: 1# 如果有 wget 的话： 2wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz 3# 如果没有 wget，可以试试 curl： 4curl -LO https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz 用 tar 解压压缩包：tar xzf frp_0.61.1_linux_amd64.tar.gz 进入文件夹，配置 frpc.toml，内容为： 1# 服务端地址（这里要填你有公网IP的服务器的IP或者是服务器的域名） 2serverAddr = \u0026#34;192.xxx.x.x\u0026#34; 3# 服务器端口（Frp 服务端监听的端口） 4serverPort = 7000 5 6# 连接协议 7transport.protocol = \u0026#34;tcp\u0026#34; 8 9# 代理配置 10[[proxies]] 11# 代理名称（标识该代理的名称，根据你的喜好填写） 12name = \u0026#34;comp_server\u0026#34; 13type = \u0026#34;tcp\u0026#34; 14localIP = \u0026#34;127.0.0.1\u0026#34; #这里就是这个，代表本机IP 15localPort = 22 # 这个是 SSH 的默认端口 16remotePort = 6000 # 告诉 frps 把它收到的哪个端口流量转过来 启动 frpc：./frpc -c ./frpc.toml 在公网服务器上进行类似操作，这里我没有改 frps.toml，其中内容只有一行： 1bindPort = 7000 启动 frps: ./frps -c ./frps.toml\n从第三台电脑测试链接：ssh username@192.xxx.x.x -p 6000, 这会让你通过公网服务器的 6000 端口把访问转发到计算服务器上。\n整个流程大概就是这样啦，看起来挺长的，实际上只需要寥寥几步就OK了。需要注意的是，这样的服务器端配置显得有些简陋，不过目前来讲是完全够用的。然而如果你需要更详细的配置，或者更完善的配置的话，可以参考上述的开源教程。另外还有，这个地方的 7000 完全是默认的一个值，而这个值是可以自己选择的。一般来讲端口号会尽量选择比较大的数字（高位端口），目的主要是为了安全着想。如果这个地方你在上面的客户端使用的 ServerPort 是别的端口号，请在下面的 bindPort 中保持一致。\n流程图 这个人在尝试过 Mermaid 之后就什么都想画个图了，原谅他吧。\nflowchart TD B[使用ToDesk连接服务器\\n获取公网IP：curl ifconfig.me`] B --\u003e C[下载frp工具\\nwget/curl下载压缩包] C --\u003e D[解压frp压缩包\\ntar xzf命令] D --\u003e E[配置frpc.toml\\n设置serverAddr/serverPort等参数] E --\u003e F[启动frpc客户端\\n./frpc -c ./frpc.toml] A[租用阿里云服务器\\n获取公网IP] --\u003e H[配置frps.toml\\n仅设置bindPort=7000] H --\u003e I[启动frps服务端\\n./frps -c ./frps.toml] F --\u003e J[连接测试] I --\u003e J J --\u003e K[第三台电脑测试\\nssh username@公网IP -p 6000] style A fill:#f9f,stroke:#333 style B fill:#f9f,stroke:#333 style K fill:#bbf,stroke:#333 （感觉上面的流程描述还是不如图清晰呀，还是图好）\n所以，大概就是这样啦。如果你是误打误撞进了这个博客，正好想搭建一个 FRP 服务，上面的内容应该就足够啦。希望可以帮到你~\n解说环节 有了 TL;DR，也许你可以从这些步骤上看到整个搭建过程的轮廓。然而这样或许还是不能解答一些疑惑：为什么这样这样再这样，就好了？所以这里简单讲解一下，每一步都是在干什么，以及要注意的点。虽然说这里要做解说，实际上也只是拾人牙慧，再对上面的内容进行一些简单的补充而已。还望大佬手下留情。\nSo，什么是 FRP？ 当遇到一个奇怪的，有着英文缩写的概念时，最应该从这个缩写的含义来展开。FRP，全称 Fast Reverse Proxy，也就是“快速反向代理”。也许有人要问了，什么是代理，什么是反向代理，什么又是“快速反向代理”？\n很可惜，我也是超级小白，只能斗胆分享一下自己的看法。代理这个词，一听就知道大概是什么样的过程：代替某个东西来受理某项业务。实际上在我的理解里，就是这么回事。不过在谈“反向代理”前，还是先聊聊可能大家更熟一些的 正向代理 吧。它是指把流量交给某个服务，让所有服务的流量都从这里出去。大概就是：\ngraph TB %% 正向代理 subgraph \"正向代理\" User1[客户端] --\u003e|\"1. 主动配置代理 (如浏览器设置)\"| FProxy[正向代理服务器] FProxy --\u003e|\"2. 代访互联网\"| Internet[目标网站] end classDef proxy fill:#c8e6c9,stroke:#4caf50 class FProxy,RProxy proxy 正向代理示意图 这里正向代理服务器就是中间的一层马甲，代替客户端进行访问，访问后再把内容反传给客户端。这样一来，目标网站就不太容易知道代理服务器的背后是谁，形成了一定的匿名性。\n那么反向代理呢？与正向代理正好相反，正向代理是由代理服务器做客户端的马甲，而反向代理则是让代理服务器给目标服务器打工。反代服务器会接收到客户端的请求再告诉服务端，反代服务器会负责把内容转发到对应的位置，交给服务端，而服务端后面要与客户端通信，还是得走反代服务器。图形表示的话就是这样的：\ngraph TB %% 反向代理 subgraph \"反向代理\" User2[客户端] --\u003e|\"1. 直接访问\"| RProxy[反向代理服务器] RProxy --\u003e|\"2. 转发给内网\"| Backend[后端服务器] end classDef proxy fill:#c8e6c9,stroke:#4caf50 class FProxy,RProxy proxy 反向代理示意图 也就是说，正向代理的情况下，目标网站只知道有个服务器在访问它；反向代理情况下，客户端不直接连到后端服务器，而是直接连接到反代服务器上。我们的需求，是让自己的电脑能跨过计算服务器的内网屏障，用 SSH 连接上去。因此，我们要做的是让服务器想办法把我的请求告诉计算服务器，也就是采用反向代理，让反代服务器从一个端口接收我的请求流量，然后走另一个端口，把流量转发给计算服务器负责监听 SSH 请求的端口，就可以了。\n那么“快速反向代理”又是啥？就我浅薄的认知而言，“反向代理”不是一个特别特殊的东西，很多人都可以尝试自己的实现方式。而“FRP”是其中一个非常受欢迎的选择。至于“快速”，应该是说它速度快？由于我只知道这么一个，所以咱们还是不要深究了。\n当你下载好 FRP 的包后，你应该会看到里面没有多少文件。两个可执行文件：一个 frpc 作为客户端，一个 frps 作为服务端，以及对应的配置文件，几乎就这样，内容很简单。它的工作方式，就是在让 frps 接收流量，然后转发到拥有 frpc 的设备上。请注意，虽然这里说是“客户端”，但实际上是那个计算服务器，而非本地的电脑。本地电脑要做的几乎只有保证自己能 ssh 上别的机器，这就可以了。\n来个服务器 首先是租用服务器。只需要最低配置的服务器就可以运行 FRP 服务了（我猜，因为这个转发过程我很难想象需要多大的内存和多么强大的算力）。在租用的时候可以注意看看各家云服务器厂商都怎么提供的优惠，特别是学生优惠。一般来讲，学生都有一些不错的优惠或者白嫖额度，可以先用着试试看。服务器的密码要注意使用强密码，不要用什么个人信息之类的。因为公网服务器毕竟是暴露在危险的公网上的，简单的密码很容易被强行爆破，如果密码里面有一些个人信息（生日，电话，QQ什么的），那就一锅端了。总之，公网上一切小心，密码要搞复杂点，记在什么纸上或者什么密码服务器里都可以。\n配好服务器之后，可以考虑只使用 SSH 加密钥来登录。密钥最大的好处有两个，一个是可以免密码，另一个就是安全。由于 SSH 只会允许拥有通过验证的机器来登录，验证方式是查看是否具有可以匹配的私钥。本来想在这里大谈特谈“加密，私钥与 SSH”，后来想了想，几乎没什么太大关系呀！干脆算了，能正常登录，就是大成功！具体操作就是，首先先用服务器供应商提供的方式登录进去，然后打开一个叫 authorized_keys 的文件，它的路径是 ~/.ssh/authorized_keys（如果没有，也很正常，自己创建一个是对的），待会儿会往里面写你的公钥。接下来就是在你日常使用的电脑上进行操作，打开终端使用 ssh-keygen，然后一路回车，就可以创建一份独属于你的密钥对。这里一路默认会创建一个没有口令的，使用默认加密方式的密钥。\n接下来我们打开 公钥 的内容，比如用 cat ~/.ssh/id_ed25519.pub 等方式，把内容输出出来。要注意的是，你要打开的是 公钥，也就是文件后缀带个 .pub 的文件。走网络传递的信息应该是公钥这样即便被大家知道也没什么所谓的东西，而非你重要的，只能单向证明你身份的私钥。文件内容应该是好长的一行甚至好几行，大体结构应该是三段：\u0026lt;type\u0026gt; \u0026lt;key\u0026gt; \u0026lt;user\u0026gt;@\u0026lt;machine\u0026gt; 的形式。第一个 \u0026lt;type\u0026gt; 指明了是什么加密协议，中间是最主要的部分，而最后是为了方便用户辨认“这是从哪里来的公钥”的字段。如果你觉得最后一段说明力不强，可以大胆修改。然而当务之急，应该是把这段内容复制下来，然后粘贴在服务器端的 authorized_keys 文件里。\n这样就算搞定啦，可以试着从自己电脑来 ssh 上服务器了。如果没有问你要密码的话，那就一切 OK 了。不过要注意的是，如果你是第一次登录的话，你电脑端的 ssh 客户端会告诉你，你从来没有连接过这个主机，你是否要信任它？并且会让你输入 yes或no，或者是打印 finger print。作为安全保险，可以仔细思考一下你登录的位置对不对。没啥问题的话 输入 yes 来确认。这里默认的值是 no 哦，如果你手快/以为默认是 yes 的话，那就只能重连一次并且记得输入 yes 了。\n总之，服务器这块主要是要能搞到。登录什么的其实不太难，ssh 算是配置相对容易的，对用户比较友好的工具了。当前的最后一个要在服务器上做的事则是获取服务器的公网 IP。一般你的管理面板会告诉你对外 IP 是哪个。你可以记住它或者怎么样，总之待会儿要用。如果你喜欢命令行操作，那也可以试试 curl ifconfig.me 这个命令。ifconfig.me 提供了显示访问者公网 IP 的服务，你可以通过这个脚本拿到服务器的公网IP。那么，在能保证方便快捷地连接到跳板服务器之后，我们就要开始下一步：\n计算服务器配置 我们来配置好计算服务器。上面说可以用远控软件来操作远程服务器，其实那是我们一开始的工作方式。理论上来讲，我们是不需要计算服务器 被 外界访问到，而是通过搭建的 FRP 服务来 访问外界，再让外界传到别的地方，从而建立数据通路。所以，你只要能把 frpc 的客户端以及对应的配置文件塞到计算服务器上能上网且你喜欢的位置，就可以了。\n由于我们的目的就是通过 FRP 来通过跳板机访问计算服务器，因此自然不会考虑直接 ssh 上去。这里的做法是先走原来的老方法，用 ToDesk 来对远程服务器进行配置。这个商业软件我就不介绍了，总之就是一路操作到进入远程服务器。\n在这之后，就是要下载 FRP 了。我很难说下载方式简单，因为 curl 和 wget 命令我到现在还没有搞明白具体是个怎么个事儿……不过，这两行命令都是没有问题的，也就是：\n1# 如果有 wget 的话： 2wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz 3# 如果没有 wget，可以试试 curl： 4curl -LO https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz 其中的 wget 根据 man wget 的回答，它是\nWget - The non-interactive network downloader\n，即非交互式的网络下载器。它的参数就相对简单了，后面跟上要下载的内容的网址就可以了。而 curl 就更复杂一些。根据 man curl，它是\ncurl - transfer a URL\n，是传输 URL 连接的。默认情况下，它会把获取到的东西直接输出在屏幕上。而由于我们是要下载文件，所以需要指定 -O 参数来表示 把内容下载到本地的同名文件中。而这里的 -L 参数则是告诉 curl 跟随连接的重定向，因为可能这个连接实际上指向的资源不是这里，而是另一个地方。顺带一提，如果指定 -o（小写的 o）的话则是 把内容下载到下面这个文件里 的意思，也就是 -o 后面应该跟上一个自己指定的文件名。\n再下来就是解压缩。下载好的东西是一个由 tar 打包好并经过 gzip 压缩的文件。所以我们应该先解压缩为一个单纯的 .tar 文件，再解包开变成真实的内容。然而，好消息是，tar 这个命令已经内置了调用包括 gzip 在内的压缩/解压缩软件，我们只需要使用 tar -xzf frp_0.61.1_linux_amd64.tar.gz 就可以了。其中的 -xzf 分别代表 提取，调用gzip工具 以及 指定文件路径。\n随后我们就可以进入解包得到的文件夹内，里面的 frpc 就是我们要使用的软件，而 frpc.toml 则是对应的配置文件。剩下的内容可以删掉，也可以想办法提取出来一会儿挪给反代服务器。在计算服务器上我们只需要用到 frpc 和它的配置文件就可以了。\n上面的配置文件里有一些注释，其实写的挺详细的了。我也是只提供了最基础的信息，告诉 frpc 它对应的要连的 frps 在哪里，走哪个端口通信；frps 应该从哪里接收转发向这儿的流量，流量是什么类型的，转发给哪个端口，然后给这个小配置写个名字方便辨认。就是这样。\n到这里，计算服务器端基本就配置好了。我们可以暂时搁置，然后转向反代服务器（公网服务器）端的配置。\n反代服务器配置，以及尝试链接 一开始还是一样咯，下载好 frp 的包，然后解包出来，准备设置 frps.toml。然而对于 frps 来讲，它的设置就相对简单很多了。这里只有一行，告诉 frps 它应该监听用哪个端口和 frpc 进行通信，就可以了。是不是很简单？\n在这之后，我们就可以试着来启动这两个程序了。请先在反代服务器端启动 frps，命令是：\n1./frps -c ./frps.toml 这时候你应该能看到一些输出的内容，先不用管。紧接着在计算服务器端启动 frpc，命令是：\n1./frpc -c ./frpc.toml 这里的 -c 都是用来指定配置文件路径的。这时候如果顺利的话，你会看到计算服务器这里显示连接成功的信息，并且不会退出了。而反代服务器那边则同样会显示连接成功，同样，也不会退出。这样一来，就基本宣布大功告成了。\n然而，事事如意可太难了。最常见的问题就是 frpc 告诉你它连不上。这个时候请先检查反代服务器的防火墙设置。有很大的可能反代服务器屏蔽了 FRP 的通信端口，或者把你的地址排除在外了。这个时候请先把防火墙的规则放宽一些。\n如果 frpc 和 frps 连接成功了，我们就可以尝试用 ssh 访问反代服务器的对应端口，来尝试链接计算服务器了。参考上面的配置，我们要求反代服务器把它从 6000 端口接收到的流量转发给计算服务器。因此，我们使用\n1ssh \u0026lt;username\u0026gt;@\u0026lt;frps_ip\u0026gt; -p 6000 即可进行连接。其中的 -p 就是告诉 ssh 你要连接的是哪个端口，否则 ssh 会默认走 22 端口进行连接。这时候连接可能依然会让你输入密码，随后配置好密钥连接就可以了。至此，基本就已经是配置好 FRP 了。\n一点额外工作 把 FRP 注册为服务 然而这还是有一些问题。比如，当 frpc 没有连接上 frps 的时候，它会直接罢工，甚至不愿意尝试重连一下。而且，作为系统层面的一个应用，我们希望它持续挂载在后台运行。上面的方式会让 frpc 和 frps 占住当前的 shell，什么别的操作都不行了。考虑多种方案后，我认为最好的方式就是给二者注册 systemd 服务（如果两个机器都支持 systemd 的话）。下面是我给 frpc 写的 systemd 服务：\n1[Unit] 2Description=Frp Client Service 3After=network.target 4 5[Service] 6Type=simple 7User=root 8Restart=on-failure 9RestartSec=5s 10ExecStart = /root/frpc/frp_0.61.0_linux_amd64/frpc -c /root/frpc/frp_0.61.0_linux_amd64/frpc.toml 11ExecReload = /root/frpc/frp_0.61.0_linux_amd64/frpc reload -c /root/frpc/frp_0.61.0_linux_amd64/frpc.toml 12LimitNOFILE=65535 13 14 15NoNewPrivileges=true 16PrivateTmp=true 17 18[Install] 19WantedBy=multi-user.target 上面的内容基本就是在说，这个服务显示的名字是什么，启动前置需要什么，服务的类型，启动服务的用户，重启服务的条件和间隔时间，启动时要用什么命令；重启服务时要用什么命令，等等等等。这些内容被保存在了 /etc/systemd/system/frpc.service 中。为了方便管理，可以用 ln -s /etc/systemd/system/frpc.service \u0026lt;destination of link\u0026gt; 来把这个服务文件软连接到 frpc 所在的文件夹下。此处的 -s 是说创建的链接类型是软链接，否则 ln 默认创建的是硬链接，这就没什么必要了。\n写好了之后可以通过 systemctl enable --now \u0026lt;destination of link\u0026gt; 来启动这个服务。其中 enable 是说你要把这个服务注册进去，让系统启动的时候顺带启动这个服务，而 --now 的含义则是让 systemd 立刻启动这个服务。平时检查连接状态可以使用 journalctl -u frpc.service -f 来查看实时日志（也会打印出最近的几行），也可以使用 -a 参数替换 -f 参数来打开所有记录下的日志。\n在反代服务器上也类似，可以写这么一个服务然后启动。注意要把里面对应的内容替换掉，比如软件路径等。这时候再试试登录，应该没有什么阻碍。\n设置防火墙规则 上面这套默认的配置，应该是会允许 所有的IP 来访问公网 FRP 服务器的 所有的端口 的。如果你像我一样，这个服务只是自用来连接个内网服务器的，请对防火墙进行合理的设置，防止被暴力扫描端口并尝试密码爆破。具体设置方法请参考你租赁服务器的服务器提供商，不过大概都是让你选择某个 IP 给它禁止掉，或者允许它。\n现在的防火墙几乎都是支持白名单模式的。你可以像我一样，先禁用掉所有的 IP 访问任何一个端口，再允许任何的 IP 访问 SSH 的通信端口，再接着允许计算服务器的 IP 访问它与反代服务器交换信息的端口，按上面的例子的话就是 7000 端口，以及允许你平时尝试访问计算服务器的 IP 来访问 6000 端口，如果你是让 frps 用 6000 向 frpc 转发流量的话。\n这样配置好之后会把访问权限控制到近乎最小化。虽然会带来一定的麻烦（比如 IP 变动的话就需要上控制台修改防火墙规则），但是安全性上会很有保障。\n结尾 这个 FRP 服务我是在五一假期期间搭建起来的。本来说，一边搭建，一边写这个博客的。结果却变成了搭好之后懒得写，直到现在（7月23日，暑假）才想起写。唉，拖延症。\n实际上，FRP 的使用方式远不局限于我上面写的这些。甚至如果你愿意点进上面贴出的那个教程连接，就会发现他写的会更加详细，配置项会更加复杂。不过，因为我的需求足够简单，所以我的配置也相对简单很多。\n要提醒的是，防火墙其实也许不用设置得像我这里写的，这么严格。然而一定要留个心眼，毕竟网上坏人真的很多。比如我搭建好 FRP 服务的当天晚上就遭到了大洋彼岸朋友的亲切扫描，扫出端口之后就是一通尝试，用了什么 root，admin，user 等的账户名以及一先干就知道的一大堆弱密码来尝试连接进服务器。好消息是没有试出来，被我用防火墙给 ban 了。但还是给我惊出了一身冷汗。害人之心不可有，防人之心不可无呀。\n还有要说的是，有一些场景是不可以使用 FRP 服务或者类似的远程访问的服务的。没错，向日葵，ToDesk 这类也不行。网上是有使用类似服务结果酿成大祸的情况的。在使用这类服务前，还是要先三思呀。\n最后，一如既往地，辛苦你看到这里。感谢您的支持，也同时祝您生活愉快~\n","date":"2025-07-23T13:26:16+08:00","image":"/images/BagPipe.jpg","permalink":"/zh/posts/tools_note/frp_depoly/","title":"搭建 FRP 服务"},{"content":"探索一下 C++ 的容器 vector 的内存布局，也算是解答我自己的一些疑虑咯\n头图是从网上搜的，尝试寻找出处，未果。很可惜。选曲尝试选择了一首听着比较清淡的曲子 泪苔，感觉比较符合头图清新淡雅的神社氛围。希望你喜欢。\n先介绍一下 vector 的基本情况咯 vector 是由 C++ 标准库提供的一个容器模板类。这里不打算仔细介绍什么是容器，模板，什么是类，我们直接指出：vector 的作用就是一个更好用的数组。“类”是说明它自己带了一些好用的函数，称为“方法”，“模板”就是说它需要接受一个类型作为参数才能成为一个完整的类型，就像数组必须说明是什么东西的数组一样。最后“容器”就是说它是一类经过了特殊优化的模板类，和别的容器一起共用着一些方法与成员，且有一类公共的算法可以用在它们上面。\n和传统的数组相比，vector有这样的几个特点：首先它符合 RAII (Resource Allocation Is Initialization) 的要求，即自动管理内存，离开作用域时自动销毁，而传统的数组则不是很满足 RAII 的条件；其次就是 vector 是动态大小的，在使用时不需要在编译期就了解这个东西的大小，程序会根据需求自动分配内存。虽然后者在 C 中也能实现，比如指针+ malloc 或者指针+ new 的组合，然而这样的组合需要直接面对自己创建的裸指针 (Raw Pointer)，一个不小心就很容易造成内存泄漏，所以使用时要特别注意。最后就是，相比起数组这样较基础的数据类型，使用 vector 的内建函数（方法）可以避免自己造一些轮子，会比较方便。\n即便 vector 看起来这么好，其实还是有人会担心 vector 会引入额外的运行开销。特别是，有人可能会怀疑：我使用数组或者指针+malloc或者指针+new 得到的内存空间我是明确知道时连续的，那 vector 呢？它经过这样的包装之后，还拥有连续内存空间吗？这篇文章就是打算探讨这个问题。\nvector 为什么“可能”会比较低效？ 我们这里不打算介绍什么复杂的内容，比如什么 allocator 或者内存调度机制。我们只对“指针+malloc”、“指针+new”方法以及vector方法是怎么获取可用内存空间的方法进行简单说明。\n不过在进入具体的内存分配过程介绍之前，我们希望能先介绍几个概念：\n堆与栈 我们写好的程序在运行时，需要同系统进行交互，借由多种系统调用完成任务。而在程序运行的过程中需要的内存空间则也是由系统进行分配的。一般我们将系统分配的内存空间划分为两块，一块叫堆，而另一块儿叫栈。请注意这里的堆栈并不能直接对应数据结构，请仅将其看作内存空间的称呼。\n程序运行时，系统会将调用的函数一个一个压入调用栈中，栈空间内实行先进后出（也是栈这一称呼的来源），连带着函数需要的变量也是一样压入栈内的。然而，栈实际上相对比较小，如果在栈内存放了过多的资源导致栈内空间不足，程序则会出现所谓的栈溢出 (Stack Overflow)。不过好消息是，系统并不会傻傻地将任何东西都放在宝贵的栈空间内，在存储大量内容时，可以把这些内容存储在堆中。\n堆和栈都是由系统负责内存分配的，区别在于，栈是严格执行先进后出的，且空间有限，只负责函数调用等，资源会被自动回收；而堆则不同，堆相比栈而言会比较大，里面的资源不需要什么先进后出，然而在程序不再用到里面存储的资源时，系统也不会自动回收它们，取用这些内存资源的方式也是需要通过指针进行读取或写入的。所以相比栈空间，堆空间的运用更需要一些技巧，如果使用比较传统的方式的话。有句话很好地形容了堆：垃圾堆。如果能很好地管理这里的内容，那样就会很好用，否则就会让系统东留一块儿垃圾西留一块儿垃圾，最后变成垃圾堆。所以C/C++编程的一大技巧就是使用好堆上的空间。\n那么，我们应该如何，按照上面所述的方法，进行堆上的内存管理呢？\nmalloc 的内存分配方法 传统的指针+malloc方法大概是这样工作的：首先声明一个指针，它不指向什么具体的内存地址（空指针），然后再通过 malloc 中传入的参数来决定从这个指针开始要给它多大的连续空间（一个内存段），最后让这个指针指向这个内存段的头部，从而完成内存分配。这样的方法最大的特点是它不需要编译时就确定好需要多大的内存，而是通过 malloc“动态地”分配一段内存，然后交给这个指针进行管理。在 C 语言写的程序中，基本都是这么进行运行期间的内存分配的。\n这样的内存分配方法，在 C 语言兴起的时候，是非常伟大的。然而这个方法存在着很多的问题：首先就是指针操作的复杂性。使用 malloc 时必须留意管理内存资源的指针。在内存不再被使用时必须调用 free 函数来释放资源，且在 free 之后就不能再次调用这个资源来。很多程序运行崩溃，都是由指针造成的，或者是忘记删除已经不需要的资源，或者是引用了空指针或者悬垂指针。另外就是使用 malloc 分配的内存实际上也没有那么动态：如果你声明了 100 字节的内存，那就一定而且只有 100 字节的连续内存可以用：如果你实际上用不到 100 字节，那多余的空间会被浪费，不过这样还好；而当你用了超过 100 个字节的数据，却尝试将它们放在 100 个字节的内存段中时，多出来的部分会直接被截断，也就是说多出来的部分就消失了。最后，很致命的一点是，malloc 是一个很不智能的函数。它没有类型（返回 void*）,不调用构造函数，且必须要手动计算好分配的字节数，然后传给它。这实在是一个坏消息。而即便你注意了资源的声明与使用，使用裸指针管理资源的过程也比较繁琐：你需要使用一些诸如 memcpy 这样的函数来管理内存，这些函数操作非常精细，它会要求操作的字节数量。有机会在程序运行的时候分配内存，这很好，但也许会显得有点太麻烦了。\n所以，我更愿意称使用 malloc 进行内存管理更适合“高级用户”。一般而言，除非有很明确的需求，否则不会考虑使用老式的 malloc 进行内存分配，特别是在我们讨论的 C++ 语境下。那么 new 又如何呢？\nnew 的内存分配方法 相比于 malloc，new 更加智能，更加符合C++的思路。它会自动调用构造函数，不需要手动计算内存量（编译器会帮你计算好），且它是强类型的，它分配的内存空间会有一个明确的类型，而不是 void* 这样模棱两可的东西。然而，这里的“智能”，也只有这种程度了。究其原因，还是因为使用裸指针的原因。由于使用指针，就必须在不再使用资源时手动 delete 掉它（malloc 使用 free 释放，new 使用 delete 释放）。仅此一点就使 new 也不是一个特别理想的方法。它解决了一些 malloc 的痛点，但是没有解决使用裸指针带来的最根本的问题。\n也许有朋友会讲：你提到的是裸指针，而我记得 C++ 标准 在 C++11 时引入了智能指针。它是符合 RAII 规则的裸指针的包装，也就是说在不使用时可以自动销毁来释放资源。为什么不使用智能指针解决这些问题呢？\n没错，智能指针的引入确实能有效改善这个问题，如果需要使用指针进行操作时，换用智能指针确实是一种很好的方法。但是我们只是希望拿一块内存存储数组那样的东西，使用智能指针也许有点太重量级了。也许有人中意智能指针以及使用指针方式来进行内存管理，不过这里就不多介绍智能指针了。那既然 malloc 和 new 都不是非常令人满意的答案，vector 就能解决这些问题吗？\nvector 的内存分配方法 我们首先给出肯定的回复：Yes, vector 是 “我需要一块我不知道内存大小的连续内存段” 的非常好的解决方案。实际上也许 vector 会比想象中的更好用。vector 首先是符合 RAII 的，这使得我们不需要特别关注声明的资源：这些资源在离开其作用域时就自动被销毁了。不用再担心内存泄漏，也不用担心空指针之类。另外 vector 虽然实际上是将资源放在堆上的，对 vector 的操作实际上就像是在栈上操作它一样，对它的操作要比指针操作之类要直观的多。最后，使用 vector 不用担心空间不足：当 vector 内的空间不足以容纳新的东西时，vector 会自动增加其容量，来容纳这些新的东西。这个操作在编程侧是近乎无感的：你可以直接把 vector 当做一个无限容量的容器，你要做的事情就是往里装就 OK 了。使用 vector 是很符合直觉的，给代码作者的心智负担也比较小。毕竟，封装地这么好，你只管往里 push 就好了，不用操心什么多余的问题，vector 会帮你处理好的。\n也许有人会担心：vector 就一点问题都没有？很可惜，vector 也是需要正确使用的，否则就是会很低效。这一点主要体现在 vector 的自动扩容上。vector 的扩容机制是这样的：如果容量不够，就在当前容量上乘2（或1.5，取决于具体实现）来容纳新东西。乍一听没什么问题，而实际上扩容是一个很复杂也很慢的过程。我们下面会更深入地聊聊这个问题。另外，vector 的内存真的是连续的吗？可以通过什么方法来看到其内存布局吗？我们后面也会尝试使用程序来把内存地址打印到屏幕上，看看是个什么样子。最后，当你很明确自己需要的就是固定长度的内存区域时，vector 自动增长内存空间的做法可能就不合适了。这时你也许会更想使用数组的现代包装 std::array，而非 vector。\n最后，vector 实际上也提供了和 C 的裸指针相容的对象，通过调用 vector::data() 方法即可获得 vector 内存段对应的裸指针。这样一来，需要精细操作或者与老 API 做兼容时也很方便。\n连续的内存空间很重要吗？ 上面我们一直强调“连续的内存空间”，也许有人会好奇，连续的内存空间很重要吗？答案是肯定的：连续的内存空间可以有效提高内存寻址速度，从而提高访问和读写的速度。事实上，有连续内存空间，自然也就有非连续的内存空间。如果一个内存段是连续的，那么就意味着从内存段头部开始，需要取用第5个元素就只需要令头指针向右（或者某个方向，取决于你）偏移4个元素，就可以取到这个元素了。典型的拥有连续内存结构的数据结构有传统的数组，以及我们这里介绍的 vector。而非连续内存结构的数据结构里，非常有代表性的一个就是链表。使用链表上的第5个元素需要先从头节点向后寻找第一个节点，找到之后再跳转到第二个，不断进行这样的跳转直到找到第五个元素。使用链表的好处是链表可以极大程度利用内存空间，因为不受连续的大段内存空间的条件约束，代价便是寻址速度相比数组或 vector 会慢很多。\n另一个角度讲，vector 可能会低效的原因也在于此。由于 vector 需要保证内存是连续的，当它遇到内存不足时，便需要做下面的事：\n在内存中寻找一块新的地址，这个地址有一段连续的足够大的内存来存放老数据以及即将到来的新数据； 把老数据复制到新的地址下； 把新数据添加到老数据的后面。 这个过程最耗时的部分是第三步。设想这样一个情况，操作系统在给程序分配内存时分配地非常零散，且希望最最高效地利用内存，以至于内存空间内部只有长度为 1, 2, 4, 8, 16, 32 这6段长度的内存，它们的地址相隔甚远，且你拥有的一个 vector 目前只保存了长度为1的数据（也就被系统分配到长度为1的地址下）。现在你打算向里面补充新数据，比如，你要往里面添加31个新数据，但是这个过程中需要做一些特别的判断，以至于编译器不能帮你做优化，直接分配给你32位长度的内存。\n现在，在你向后补充第一个元素时，vector 会尝试寻找长度大于2的一个内存空间，它找到了第三块内存（长度为4）；你又向后补充了一个元素，此时 vector 发现内存不够，但是直接扩张大小也可以，此时就不需要寻址，直接声明后面的两位内存被使用即可；你又打算向后补充三位数据。这时 vector 发现内存又不够了，它寻址到第四块内存（长度为8），然后把第三块内存中的数据一个个复制到第四块内存中，然后再把新的三位数据补充到后面。\n发现问题了吗？vector 的机制让编译器不太愿意把本就很大的内存空间直接交给 vector。当你要往 vector 里填充大量数据时，让它这样自己一点点增长长度的做法会非常耗时。好消息是，我们可以通过 vector::reserve 提前告诉 vector 我们需要大概多少的内存，以便编译器一开始就找好一个够大的地方。而且这样的机制也算是强有力地说明了 vector 具有连续内存结构。\n在？看看内存结构 下面我们就来尝试用代码打印出 vector 里元素的内存地址吧。我们向一个 vector 中填充5个元素，每次填入时检查 vector 的状态：\n1#include \u0026lt;vector\u0026gt; 2#include \u0026lt;iostream\u0026gt; 3 4int main() { 5 std::vector\u0026lt;int\u0026gt; v; 6 7 for (int i = 0; i \u0026lt; 5; ++i) { 8 // We use \u0026#34;push_back\u0026#34; push an element to the back of a vector 9 v.push_back(i); 10 std::cout \u0026lt;\u0026lt; \u0026#34;Added: \u0026#34; \u0026lt;\u0026lt; i 11 \u0026lt;\u0026lt; \u0026#34;, Size: \u0026#34; \u0026lt;\u0026lt; v.size() 12 \u0026lt;\u0026lt; \u0026#34;, Capacity: \u0026#34; \u0026lt;\u0026lt; v.capacity() 13 \u0026lt;\u0026lt; \u0026#34;, Address of first element: \u0026#34; \u0026lt;\u0026lt; \u0026amp;v[0] \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; 14 } 15 16 // Check contiguity 17 std::cout \u0026lt;\u0026lt; \u0026#34;Contiguous memory check:\\n\u0026#34;; 18 for (size_t i = 0; i \u0026lt; v.size(); ++i) 19 std::cout \u0026lt;\u0026lt; \u0026#34;Address of v[\u0026#34; \u0026lt;\u0026lt; i \u0026lt;\u0026lt; \u0026#34;] = \u0026#34; \u0026lt;\u0026lt; \u0026amp;v[i] \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; 20 21 return 0; 22} 得到的结果如下：\n1Added: 0, Size: 1, Capacity: 1, Address of first element: 0x56041b4592b0 2Added: 1, Size: 2, Capacity: 2, Address of first element: 0x56041b4596e0 3Added: 2, Size: 3, Capacity: 4, Address of first element: 0x56041b4592b0 4Added: 3, Size: 4, Capacity: 4, Address of first element: 0x56041b4592b0 5Added: 4, Size: 5, Capacity: 8, Address of first element: 0x56041b459700 6Contiguous memory check: 7Address of v[0] = 0x56041b459700 8Address of v[1] = 0x56041b459704 9Address of v[2] = 0x56041b459708 10Address of v[3] = 0x56041b45970c 11Address of v[4] = 0x56041b459710 可以看到，在添加元素时，vector 的 size 指示 vector 有多少的元素，而 capacity 指示了 vector 还有多少的空间。当空间不足时，vector 的空间会扩大一倍来容纳新的元素，同时头元素的位置也会发生变化。而在元素填入结束后，通过检查地址可以发现这些元素在地址上是连续的（一个 int 的大小是4，注意到使用了16进制所以 8 后面是 c 也就是 12，c 后面就进一位因为达到了16）。\n这是一个很简单的小例子，但是用来说明 vector 的内存结构应该已经足够。\n做个 Benchmark 看看 也许一个简单的 Benchmark 可以展示一下 vector 和传统的数组相比效率如何。下面我们初始化一个 vector 和一个数组，它们有同样的大小，并且执行累加操作，最后记录累加的用时。\n1#include \u0026lt;iostream\u0026gt; 2#include \u0026lt;vector\u0026gt; 3#include \u0026lt;ctime\u0026gt; 4 5const int SIZE = 10000000; 6 7int main() { 8 std::vector\u0026lt;int\u0026gt; vec(SIZE, 1); 9 int* arr = new int[SIZE]; 10 for (int i = 0; i \u0026lt; SIZE; ++i) 11 arr[i] = 1; 12 13 // Benchmark vector 14 clock_t start_vec = clock(); 15 long long sum_vec = 0; 16 for (int i = 0; i \u0026lt; SIZE; ++i) 17 sum_vec += vec[i]; 18 clock_t end_vec = clock(); 19 20 // Benchmark array 21 clock_t start_arr = clock(); 22 long long sum_arr = 0; 23 for (int i = 0; i \u0026lt; SIZE; ++i) 24 sum_arr += arr[i]; 25 clock_t end_arr = clock(); 26 27 std::cout \u0026lt;\u0026lt; \u0026#34;Vector sum: \u0026#34; \u0026lt;\u0026lt; sum_vec 28 \u0026lt;\u0026lt; \u0026#34;, Time: \u0026#34; \u0026lt;\u0026lt; (end_vec - start_vec) \u0026lt;\u0026lt; \u0026#34; ticks\\n\u0026#34;; 29 30 std::cout \u0026lt;\u0026lt; \u0026#34;Array sum: \u0026#34; \u0026lt;\u0026lt; sum_arr 31 \u0026lt;\u0026lt; \u0026#34;, Time: \u0026#34; \u0026lt;\u0026lt; (end_arr - start_arr) \u0026lt;\u0026lt; \u0026#34; ticks\\n\u0026#34;; 32 33 // Don\u0026#39;t forget to delete[] the array! 34 delete[] arr; 35 return 0; 36} 我们先不开启优化并尝试运行几次，看看结果：\n1\u0026gt; g++ test.cpp -o test \u0026amp;\u0026amp; ./test 2Vector sum: 10000000, Time: 21530 ticks 3Array sum: 10000000, Time: 16693 ticks 4\u0026gt; ./test 5Vector sum: 10000000, Time: 21059 ticks 6Array sum: 10000000, Time: 16560 ticks 7\u0026gt; ./test 8Vector sum: 10000000, Time: 20729 ticks 9Array sum: 10000000, Time: 15812 ticks 我们再开启 O3 优化然后看看结果：\n1\u0026gt; g++ test.cpp -o test -O3 \u0026amp;\u0026amp; ./test 2Vector sum: 10000000, Time: 2684 ticks 3Array sum: 10000000, Time: 2122 ticks 4\u0026gt; ./test 5Vector sum: 10000000, Time: 4091 ticks 6Array sum: 10000000, Time: 3686 ticks 7\u0026gt; ./test 8Vector sum: 10000000, Time: 3205 ticks 9Array sum: 10000000, Time: 2813 ticks 看来不开启优化的时候，两个方法的差距还是比较明显的，而当开启优化之后，两种方法的差距并不大。然而，使用 vector 最大的优势在于心智负担小，不用担心奇怪的内存问题，而且如果使用 vector::at 方法还能自动进行边界检查，在遇到越界问题时会抛出异常，避免程序以奇怪的，错误的方式运行。\n总结 希望这篇小短文能帮助你了解 vector 的特点，或者打消你对 vector 性能的顾虑。vector 是用来说明 C++ Zero-overhead principle（零成本抽象原则）的一个很好的例子。vector 提供了一个动态数组的抽象，它会以最低成本来实现这个东西的特性，避免引入过多额外的性能开销，让调用者可以放心使用，不必担忧性能问题。对零成本抽象感兴趣可以查看 CppReference的介绍，里面介绍了这一原则的具体情况。\n当然，每次使用新的特性，总是会引入一点点的开销。或许你会考虑，辛苦一下自己，让程序能再跑快点。这本身没什么问题，但是要指出的是，警惕提前优化。如果 vector 并不是制约程序运行效率的关键部分（也就是所谓的性能瓶颈），那么就先不要管它。当程序遇到了这个瓶颈，且只能通过优化数据结构才能提高性能时，再考虑把 vector 修改为别的容器或者数据类型，这样做也许会更实际一些。\n当然，如果这个小短文有什么问题，请直接指出来。本人也不是科班出身，写这篇笔记纯粹是记录一下学习过程。欢迎交流讨论。欢迎大佬拷打，动作轻一些就更好了。\n那么最后，一如既往，祝您身心健康。\n","date":"2025-05-27T09:53:29+08:00","image":"/images/Bamboo_Reimu.jpg","permalink":"/zh/posts/cpp_note/vector_memory_layout/","title":"C++ Vector 的内存布局"},{"content":"之前在虚拟机上面装的 Arch Linux 根本不过瘾（搞笑，你根本就没更完（❌）），这次就把自己的小轻薄改成 Arch 好了。顺带，也记录一下实体机上安装可能会遇到的坑？\n头图出自 R Sound Design 的新曲 《アリス？》，一首很轻快的 V曲~ B站投稿\n很可惜 网易云/QQ音乐 都暂时没有这首歌，所以只能劳驾移步B站欣赏了。一旦有更新就会贴在这里的\n网易云有这首歌啦！~\n引子：我好急，怎么耗电这么大 亲爱的笔记本：\n插电如面，自从我们相逢已有一年有余。上次见面，仿佛还是上次。传统派的我那时我刚刚给你刷上 Windows 10 系统，因为 Windows 11 的审美实在是在狠狠强碱我的眼睛。我们一路克服了艰难险阻，安装了各种乱七八糟的驱动，最后终于是让你成功跑了起来。虽然你的内存不大，硬盘不多，CPU 一般，显卡集成，但你要相信我，我心里面是有你的。\n然而，你身上的 Windows 10 虽然让我倍感熟悉，你的耗电量实在是高得吓人。我接受不了一旦不插电就几乎是和时间赛跑的工作流。我将这一切归咎于可恶的微软，可恶的 Windows 10。这不是你的错，但是我还是想告诉你：你马上就会搭载一个新的系统。她轻便灵巧的同时，又大胆火辣，相信你一定会和她打成一片的。\nLove, A Moment\n美丽的五一假期，不折腾点狠活儿实在是说不过去。看着越来越不顺眼的 Windows 10 笔记本，以及我的心逐渐被 Linux（特指 Arch Linux）所俘获，我决定：干掉 Windows 10, 彻底迎接 Arch Linux。想必有了之前安装 Arch Linux 的经验，这次的安装之旅肯定是一马平川了。开始吧。\n准备：资料备份以及准备启动盘 首先肯定是先把电脑上已有的资料都备份好。其实说实话没太多文件，主要是两个没打完的 Gal 吧（心虚），因为大部分的文件都其实已经搞到台式机上面了。一开始是想着把这些文件放在一块精心规划的小硬盘上，安装的时候不格式化就行了，但是感觉还是有点点点点危险，所以干脆还是挪到另一台机器上，把这台小本的硬盘全部格式化了得了。不过也许我应该考虑更加智能的备份方案？算了，以后再考虑（挖坑）。\n其次就是准备启动盘。本来这次想要换一种安装媒介来着，比如光盘？（没错我有光盘刻录机，为了听 CD 买的 ()），结果还是嫌麻烦，放弃了。给虚拟机安装的时候不是已经有了镜像了吗？为什么不直接用呢？至于为什么没有下载最新的（最新的就是昨天刚出的，5月1日版本），是因为我刚准备下载的时候看到最上面一行小字：\nSo, why not?\n镜像依旧是用 rufus 烧录到陪伴了我6+年的小U盘上，什么格式化之类的 rufus 会自动帮我搞好的。中间有个小插曲好像是 rufus 不太支持最新的 syslinux 版本，需要额外下载两个小库。无所谓（）我选择相信。\n到这里，基本上就已经准备好安装工作了。相信根据这些东西，来个老手估计三下五除二就安装好整个系统了。当然，我是新手，还是一步步来吧。这大概也是这篇（以及上一篇）文章的目的：记录自己安装系统时蠢蠢的样子。\n开始：启动安装引导咯~ 先进安装引导再说吧 激动的心，颤抖的手，插入U盘后从U盘启动会不会有？\n太坏了，secure boot 没关，没有。上网搜索我的笔记本的 BIOS 设置方法，Redmi Book 14 需要开机后按下 F2 进入 BIOS，然后在启动设置里 先设置管理员密码 之后才能设置是否关闭安全启动。有一点点脱裤子放屁的感觉……算了。总之就是进来了，进到安装引导了。这下真是激动的心，颤抖的手了。\n（由于屏幕反光，就不拍屏了，其实和上次的屏幕一模一样咯）\n这次就得格外小心了：这可不是虚拟机。虽然说搞砸了也能重来（电脑上没什么别的重要文件了，打算全部格式化），但是一想到是实体机，还是有一些些的紧张。\n其实这篇文章是在我进来安装引导之后才开始动笔写的。一开始没打算写来着，不过鉴于好久没有更新博客了，还是水一篇吧（）\n还是先进行前期验证和网络配置 这次由于有上次安装的记录，所以其实可以直接参考以前写的那个东西。感谢代码高亮，我立马就敲下了 localectl list-keymaps。然而：没什么卵用。Bro，你就用的是美式键盘呀！？还列出来看个der呀……算了，不管了。不过验证启动模式也许还是有必要的？依旧，cat /sys/firmware/efi/fw_platform_size，结果是64。这个应该是说我的启动模式的系统是64位的吧？\n随后是验证网络。这步我其实是有点心虚来着，会不会这个安装引导不支持我的电脑网卡？害怕。但还是先试一试吧。\n从结果来看，我有一个 lo，virtual loopback interface，不管；一个 wlan0，看来是认出我的无线网卡了，好耶。然而它的 state 是 DOWN，emmm……\n（找找办法……）\n太愚蠢了，DOWN不就是说我没连上网嘛……不过根据 ArchWiki，还是先用 rfkill 命令检查我的无线网卡是不是被 block 了（屏蔽？也许？），好消息是没有；接着就使用 iwctl 工具进行联网。这个步骤我感觉有一点点繁琐，先要在交互界面使用 device list 列出设备，很幸运我这里是直接 wlan0 powered on 状态，这里的 wlan0 就是我的设备名了；接着就用 station wlan0 scan 扫描可用网络，然后用 station wlan0 get-networks 列出可用网络。这里有个很神奇的点：能连上我的校园网吗？用 station wlan0 connect CSU-WIFI 试试。这里 CSU-WIFI 就是我们的校园网了。好消息是成功了，不知道怎么做到的。本身链接我们学校校园网是需要使用一个网页进行验证的，不过也许是因为我在 Windows 系统上刚刚连过一次网络，所以成功重连上了？不清楚，不过也算可喜可贺。最后在用 exit 退出 iwctl 的交互界面后再 ip link 一下检查是否连上：没问题。绿色的 UP 真好看，诶嘿。\n动态IP应该是自动配置的（ArchWiki讲是 out of box），所以不管。也许后面我会想搞个静态的？唉，不懂网络真头痛呀。不管不管。直接 ping archlinux.org 试试。\n烂了，没有数据返回。烂完了。\n（找找办法x2……）\n顺从。又不是没有别的 WIFI 能用。直接连上办公室 WIFI好了。当然，这里也贴一下我参考了的连接校园网的方法的博文：链接1 以及 链接2。\n最后网络这里还有要设置一下系统时间。用 timedatectl 就可以。感谢 zsh 和这些工具带上的自动补全，timedatectl --help 一下，跟着感觉走，最后就得到了 timedatectl set-timezone Asia/Shanghai 了。相当简单。\n再见，我的（旧）文件们 又到了每次装系统最喜欢也最提心吊胆的磁盘分区了。总有一种破后而立的感觉，感觉在磁盘格式化之后，这台电脑就变成全新的了诶。还是一样，fdisk -l 列一下可用分区。一下出来了一堆呀，删了删了，全部删了。\n又看到一次我的硬盘大小，只有 476.94 GiB,Sad。不过，轻薄本，出差顺带干活用的，无所谓了吧？后面再考虑加容量之类的吧，也许还可以考虑直接换台新本（好奢侈（））。\n开始分区吧，直接 fdisk /dev/nvme0n1 进入交互模式（这里我的硬盘只有一个，就是在 fdisk -l 里列出的 /dev/nvme0n1，所以就把它传入参数就好）。由于硬盘太小，我也对文件管理没有什么特别多的想法，干脆就是一个 SWAP 一个 / 好了。至于分区表，依旧选用 GPT 分区表。貌似 GPT 的功能更加强大？已经完全超越了老旧的 MBR？其实按理来说我应该先多了解一下这些东西再下判断来着，不过这里就还是先相信互联网吧（）\n这里搞了个大乌龙：我不小心把启动盘 /dev/sda 给分区了。有一说一，挺愚蠢的……目前先这么搞吧，安装引导也没断，后面不碰它应该就没问题，吧？下次还是要注意：根据 ArchWiki 上的指导，实际上应该先将分区表进行备份才对，命令是 sfdisk -d /dev/sda \u0026gt; sda.dump（这里 /dev/sda 是要备份分区表的硬盘）。下次一定注意，唉。\n接下来就是使用 mkfs 等进行文件系统格式化了。这里很多人推荐 Btrfs，所谓的 B Tree File System（我一开始以为是 Better File System 来着），因为它貌似是支持自动压缩和别的一些高级功能，还有快照之类，很方便个人用户使用。这次就不用传统的 ext4 了，尝尝鲜。使用命令 mkfs.btrfs /dev/nvme0n1p1 就可以把第一个分区格式化为 Btrfs 格式了。我还留了一个分区作为 Swap 使用，大小设置为了8G。要创建 Swap 分区只需要 mkswap /dev/nvme0n1p2 即可。\n分好了区，就需要挂载文件系统了。用 mount /dev/nvme0n1p1 /mnt 就可以。Swap 则使用 swapon /dev/nvme0n1p2 就行了。这样一来，我们就在硬盘上做好了准备，马上就要把 Arch Linux 安装在这个临时挂载于 /mnt 的主硬盘了。\n安装：冲刺！冲刺！冲刺！ Arch Linux 的灵魂之一也许就是 pacman 包管理器了。安装 Arch Linux 实际上也是使用 Pacman 进行的。因此首先就是对 Pacman 进行必要的配置。\n首先还是要选择镜像，可选的镜像站放在了 /etc/pacman.d/mirrorlist 里。这里直接借鉴上次安装的经验好了，使用 reflector --latest 10 --sort rate 来排出最快的10个服务器。结果出来之后可以考虑在这条命令的后面加上 --save /etc/pacman.d/mirrorlist 来保存下来。当然，在这之前（吸取刚刚的教训）我把原文件复制了一份作为备份。\n下来就是安装必要的包，命令为 pacstrap -K /mnt base linux linux-firmware。这里的 -K 是指在目标处生成一空的 密钥环。（至于密钥环是什么，对不起，我不知道。后面会学的（））\n经过漫长的等待，我的安装它：报错了。先是安装的特别慢，可能是因为源的问题吧，我还是尝试了 reflector --country China --age 12 --sort rate 这个命令获取了国内的镜像源；后面是变快了，但是突然又报 error: GPGME error: No data，不管我怎么操作 pacman，都没有用。我估计是密钥环之类的东西坏掉了吧，看来是前面对U盘瞎JB分区导致的。这下只能关机拔掉U盘，重新烧录，格式化电脑硬盘然后重新安装。好在这次安装地很快，这个插曲也算是过去了吧。\n现在安装的应该是一些最最基础的软件包。为了安装好后有一些别的功能能用（比如联网），还是需要再安装一些别的软件包。这里我计划是安装 vim ，dhcpcd 以及 networkmanager。不过这些就等到之后 chroot 后再搞吧。\n（安装中……）\n我们就当这个傻孩子搞了一通之后算是安装好了吧，其实中间应该还有一些插曲，然而他安装好之后真的一路冲刺，就安装完了抱回宿舍继续折腾了。等他再想起来写这个博客的时候已经过了10天了。原谅他吧，好多细节他也记不清了。\n这下好了，安装成功咧，下面怎么装修好呢？\n装修：还是先试试 KDE吧，顺带处理一下输入法、字体、网络的坑 后面的内容都是这个傻孩子回忆出来的，很多都不对劲了（也许），请谨慎参考\n不知道，先看看 KDE Plasma 直接照搬上次安装的 KDE Plasma 的流程了。安装的东西，设置的玩意儿，几乎一模一样。可能区别是 loacle 的设置和上次相比更复杂一些？记不太清楚了。主要要处理的问题就是，每次使用 man 的时候，都会报 locale 的设置错误问题。解决方法也很简单：照着 ArchWiki 的 Installation Guide 的地区设置那里，再重新搞一次。剩下的什么设置 Taskbar 呀设置 Terminal 什么的，基本都没什么变化。\nKDE Plasma 很不错的一点就是，它几乎就是开箱即用的，除了两个很重要但是没有强制安装的东西：文件管理器和终端模拟器。理论上讲，应该是用同属 KDE 的 Dolphin 和 Konsole 的，这样能获得最好的体验（也许），然而在体验过 Konsole 略显（真的只是略显）老旧的 UI 之后，我还是选择了使用 kitty。它能原生支持查看图片，算是一个杀手锏级别的功能了吧，其次就是它自带多标签的功能，分栏也很方便，用着挺顺手的。至于文件管理器，目前还没有什么想法，先用着 Dolphin 好了。\n输入法：还是使用小企鹅（fcitx5）以及中州韵（Rime） 其实应该直接说“同上”或者什么的，因为实际上最后的效果和之前在虚拟机上安装的过程是一样的，除了最后我没有导入在 Winodws 上已经配置好的配置文件，仅此而已。然而这个过程还是感觉有一点坑呀，特别是不停地纠结输入法设置到底在哪里之类的问题的时候。实际上，根据 fcitx5 的文档，在使用 Wayland 的时候，直接按照教程设置变量之后重启电脑，就可以在输入法那里看到效果了。（也许不需要重启，只需要登出后重新登录就可以？）\n实际上默认的中州韵已经挺好用了。然而问题是，它的默认输入是中文，然而在 Linux 的命令行里几乎很少用到中文。每次的误输入都能让人燃气无名怒火，解决方案也很简单：把英文输入法放在首选。这里不是说让中州韵的英文成为首选，而是再装一个默认的英文输入法，并且把它放在首位。实际上我在 Winodws 上也是这么设置的。日用挺舒服。\n网络：科学上网不容易呀 在孜孜不倦的努力以及不厌其烦的打扰 AI 下，我成功找到了在我的小破本上科学上网的方法。这里不多讲，但是核心只有一个：使用 TUN 模式。启用 TUN 之后，一切都对了，全对！感谢 AI，感谢 DeepSeek，感谢 ChatGPT！伟大，无需多言。\n字体：照着教程开抄 之前在虚拟机上进行安装的时候实际上没太注意字体的问题。这次因为是日常自用，还是留意了一下，毕竟每天看着奇形怪状的汉字真的很别扭。字体的设置基本就是参考 这篇博文，谢谢你，大佬。不过也要注意，读的时候（或者，抄的时候）还是要仔细一些，有一些设置实际上不是最好的设置，可以用出现在底下的更好的配置替代。也算是挡住了一些些伸手党？也许？\n指纹：呜呜呜呜呜怎么硬件还能不开源呀 我的笔记本最让我自豪的一点就是她有非常好用的指纹识别。这个本来应该没什么要紧的，但是想到之后就很像折腾一下。特别是回回输入密码，真的有点累。虽然这个本是我自己用，里面也没啥东西，但是还是不太想无密码裸奔。而如果有了指纹，一切都变得熟悉了。啊，那该是多么美妙呀。\n直到我花了两个小时多把指纹识别都配置的差不多了的时候，我才发现，愚蠢的小米旗下的 Redmi Book 14 使用的指纹识别模块是闭源的，也没有相关的逆向工程尝试，现在没有任何驱动能启用它。\n泪就这样拉了出来。特别是愚蠢的 Firefox 还始终坚持认为我的笔记本是带了可用的指纹识别的，想生成个 Github 的 token 都不行，非得要我按指纹。我按个大头鬼。\n唉。\n收尾：又是一篇流水账，但是还是做一些总结 感觉这篇没写什么正经东西，又是纯粹地做了一些记录，然后就是磨磨叽叽自说自话了。鉴于此，我决定总结一下一路遇到的主要的坑在哪里，以及安装的大致流程，做一个 quick reference。\nQuick Reference: Installation 以下是从最初的准备工作到得到可用系统的过程：\n准备启动盘，备份，barabara 加载系统，从启动盘启动，进入 shell，执行基本检查（系统架构，键盘设置，网络验证，时区设置……） 磁盘分区（重要）并进行格式化，挂载文件系统 检测 pacman 镜像速度并选择，安装必要包（base, linux, linux-firmware） chroot 进挂载的文件系统，安装必要工具（网络管理，文本编辑器，pager,man-page） 设置 bootloader（重要且坑），需要仔细阅读文档 尝试重启并用 bootloader 启动，进入 tty1 Quick Reference: Customization 以下是得到我目前使用的环境的部分配置过程：\n安装好，保证能正确启动 Arch Linux 用 nmcli con up 启动网络（后面发现可以用 nmtui），保证网络畅通 添加 archlinuxcn 源 执行更新，安装软件包。我安装了 vi, sudo, git, eza, zsh, nvim, kitty, firefox 等基础工具 安装 oh-my-zsh 与 oh-my-posh，导入已有配置，添加常用别名如 l, la, l., ls. 等 安装窗口管理器，这里一开始使用 KDE plasma 作为“起码能用”的桌面环境，以及套件 dolphin 作文文件管理器 安装中文输入法 fcitx5-im 以及 fcitx5-rime，进行必要配置（XDG 配置等） 更改 localectl 以便正常使用 man 安装 yay 以及尝试科学上网 遇到的坑： 下面是花费时间比较多的部分，这里列举一下：\n分盘搞错盘了，本来内存是 nvme 结果分到 sda 了； 分盘的时候没想清楚到底该怎么分，瞎分最后还得重来 没有检查镜像速度导致龟速下载（唉，Arch Linux，必须依赖网络，可惜） umount 的时候没有 umount 干净导致烂掉 mount 的时候没有检查是否正确 mount 到挂载点 忘记安装网络管理器，编辑器等等 没有正确设置 bootloader（一定要读完 bootloader 的 ArchWiki 词条！） 没有搞好 localectl 和 local 安装 fcitx5 之后不重启（记得感觉配好之后就重启试试） 希望这些总结的东西会帮到你吧，让这篇文章不是那么水。\n后记：我一定是对 Linux 有什么奇怪的幻想 亲爱的 Arch Linux 笔记本：\n终于，BTW, I USE ARCH!!! 谢谢你和我走过的一路。自从安装了 Arch Linux，你真的跑的飞快。我还没有回过神来，你就已经启动了。KISS 的原则，pacman 与 AUR 达成的简洁与丰富的平衡，滚动发行带来的刺激，这一切都太令人兴奋了。先进的 Arch Linux 已经完全地超越了老旧的 Windows！\n然而，也许我还是对你有太多的误会。装上 Arch Linux 的你没有变得更加省电，反而似乎更加费电了？我希望这是我没有搞好电池方案配置的锅，但是为什么你不能帮我搞好呢？我懂，我们 Linux 是讲究过生日先从办养鸡场和农场开始的，但是为什么呢？还有，为什么搞不定闭源驱动呢？不能上兼容层吗？说起来就气，怎么你的配置文件还是能变得和 Windows 一样杂乱不堪？怎么软件包随处大小便的时候你还是不管管？道理我都懂，但是系统管理员也不能每天都被埋在这些东西里面吧？还有呀……\n-- 此处省略牢骚 2000 字 -- 但是，你懂的，你可是 Linux，对吧？你已经是一个成熟的操作系统了，应该学会自己面对这些问题了，对吧？ Yours, A Moment\n谢谢你能看到这里。看完这一大堆废话，说实话也挺累的。如果这些整活儿的内容让你能开心一下，那就太好了。最后，一如既往，就祝您身心健康吧。\n","date":"2025-05-02T16:12:04+08:00","image":"/images/Alice.png","permalink":"/zh/posts/others/arch_on_laptop/","title":"安装 Arch Linux，但是笔记本物理机"},{"content":"看了好多证明蛇引理的视频，我也来试试~ 蛇年到了，重在参与嘛\n头图出自 ぬくぬくにぎりめし 太太， 为 稲葉曇 所作的 ポストシェルター (Post Shelter)的曲绘。支持正版，就只有30秒试听了（）\n写在最前 本命年到啦~！作为一个代数爱好者（自称，其实是名词党），最近在B站看到了很多的关于怎么证明蛇引理（Snake Lemma）的视频，比如这个视频。以前在自学代数的时候也遇到过这么个引理，但是看到这个部分的时候已经人快晕了（大概就是看完这个之后就抛弃了那本书吧，Algebra: Chapter 0），所以几乎等于没学过。这次看到这么多关于蛇引理的视频，自然是学习一下，这里也做一个记录吧。在本文中你将看到：\n你在说些什么？ 这么简单的前置竟然也要？ 你这里跳步了吧？ 就算我证的不好，我证的很搞笑也不行吗？ 之类的高血压时刻。为了您的身心健康，如果你打算认真了解蛇引理的话，我还是不建议你深究这篇文章。当然，如果你是找乐子的话，我希望这篇文章能带给大家笑容。这篇文章的面向读者应该对最基础的代数有了解，比如集合啊，函数啊之类的，如果会线性代数就更好了，别的东西会中途提到，毕竟是名词党写的文章，当然起点会很低的吧（笑）。话不多说，开始吧。\n$$ \\gdef\\Ker{\\operatorname{Ker}} \\gdef\\Coker{\\operatorname{Coker}} \\gdef\\Img{\\operatorname{Im}} $$简单（？）介绍 蛇引理究竟是什么呢？这是一个代数学定理，简单来讲，它做的事情和很多代数学定理一样：从已有的两个东西来创造出新的东西。比如，如果我们有一个集合以及集合上的等价类/等价关系，我们就可以构建出来一个商集；给定一个群以及它的正规子群，我们就可以构建出商群；把两个空间 $\\mathbb{R}$ 叉乘起来（笛卡尔积），我们就得到了 $\\mathbb{R}^2$。\n那么蛇引理是针对什么样的代数对象呢？这里就要尝试引入我们的第一个概念：正合列 (Exact Sequence)\n正合列，但是先别急 正合列，同调代数中的重要对象，是由链复型添以特殊的条件而产生的。链复型又是什么？链复型是一系列的交换群或者模通过同态连接起来，且相邻两个同态的复合为0。\n也许你要说：天哪你在说什么鬼东西，这都是啥啥啥呀。既然我们假定读者只拥有最基础的代数知识，我们就从最基础的开始介绍吧。名词党最喜欢的名词介绍环节，启动！\n群，交换群 上面说链复型是由交换群或者模带上同态构成的，为了简单，我们就不介绍模 (Module) 了，专注于交换群。\n但是模是什么？我要看口牙！如果有人讲模之类的话，可以认为就是一个差一点的线性空间，它就差在标量不再是数域中的元素了，而是环 (Ring) ，一种乘法可能没有逆元的神奇代数结构，里面的元素。这里指出，环想要变成域（有的地方管域叫体，英文都是 Field）的话只需要让环满足交换律，并且它的每个非 0 元素都有乘法逆元就好了。 那么交换群，或者从头来讲，群，又是什么呢？有人会讲：群就是对称！有对称，就有群！挺好的，但是对称这种几何元素偏偏要符号化成群元素，这一步我倒是走了蛮久的。我们速通嘛，就说简单一点，尽可能地不丧失严谨性吧。群 (Group)，最为代数学中几乎是最基础的代数结构，和其余的许许多多数不清的代数结构类似，遵循这样的特点：\n从集合而来。它的“底下”一定是一个集合。这样我们就可以讨论这个代数对象中的元素了。 它的内部有一个或者多个“运算”。我们可以想象我们早已熟悉的乘法。既然是运算，我们对这么个东西有这样的要求： 首先运算是两个元素同时参与的。相乘的总是（起码）两个数。注意不一定非得是不一样的数哦。 两个元素经过运算之后应该得到一个元素。两个数相乘之后给出的也是一个数字。 这点不太明显，但是我们的运算总是应该从这个集合来，到这个集合里去。比如 $1\\times 1\\neq\\mathrm{苹果}$。 这样就可以有一个（很基础的一些）代数结构啦。而我们的群，也正是这样的一个代数结构。不过它还有这样的特点：\n群的运算必须要是可以结合的。这意味着如果 $abc \\neq (ab)c \\neq a(bc)$，那它就不是群。（天啊真的有这样的神经结构吗） 群的运算不需要是交换的。其实不交换的东西很常见，例如我们要先穿袜子再穿鞋，这肯定和光脚穿鞋后再套个袜子是不一样的啦。学过线性代数的朋友应该更有体会：矩阵乘法是不交换的。 群得有单位元。何谓单位元？这不是元素吗？这里的单位元是和运算强相关的，说的就是群里的任何元素和这个单位元做运算之后一定得到的是它们自己。 群中元素都得有逆元。没错，这里逆元的概念也是和运算相关的。所谓“逆”，就是要把一个元素“逆转”回单位元。可以想象单位元就是某个出发的位置，每个元素都代表着某个让你移动的方式。而某个元素对应的逆元，就像你移动之后让你移动回原点的移动方式。能走出去，也得能走回来。就是这样。 其实上面的这些内容，经过一些整理的话就可以变成比较严格的群的定义了。然而严格定义谁都能查，这里也就偷个懒啦~ 这里指的指出的是，群上的运算我们一般就叫它乘法。而且在代数的语境下，很多运算我们都叫它乘法！所以在讨论代数结构中的乘法时要注意上下文哦~\n所以群的对称意义究竟在哪？ 我们讲，集合中的元素位置其实是无所谓的，比如集合 $\\{1,2\\}$ 和集合 $\\{2,1\\}$ 是一模一样的。那么，群的对称的意义，就在于群中的元素有两重含义：集合内的一个小不点，以及代表了如何操作这个集合的一个符号。\n我们提到过，群的运算是需要满足上面一大堆条件的。这些条件指向了这样的一个神奇的结果：两个群中的元素相乘，我们可以有意识地将其中一个元素作为操作方式，将另一个元素看作群中茫茫多（或者很少，也许）元素中的某个元素。而这样的运算结果又是群中的某一个元素。\n然后我们再想象这样一副图景：桌子上有一副扑克牌，每一张都分开放，放的很整齐。现在你尝试把这些扑克牌重新排列，这个排列方式取决于你开始重排前看到的第一张牌。在重新排列时，你肯定需要一张一张地取，取到之后会根据你看到的第一张牌来思考应该把它放在哪里，最后你就把它放在了对应的位置。在重复54遍“取-看-放”的过程之后，你会惊奇地发现：天哪，竟然又得到了一副扑克牌（？）\n你可能觉得这个发现很无聊，但是这就是对称：在某种操作下又回到自身了。你也许会说：不！位置变了！但是还记得吗？集合中元素位置是无关紧要的。我们这里其实就是在讲群对自身的作用。那么群可以对别的集合进行作用吗？当然！只要某个作用方式满足群的条件，也就是说如果你先做了一个操作，又做了另一个操作（这样就操作两次了，对应群中的两个元素相乘）这俩操作实际上也是你可取操作的一种（群中元素运算后依旧在群里），以及别的条条框框，那么实际上你就是在对这个集合进行着群作用。\n群中蕴含的对称，不在于群自己，而在于它能操作的对象。笨笨的我花了好久才明白这个道理 QAQ。\n太棒了！群是什么，已经狠狠地理解了！那么交换群？诶！交换群一定是运算能满足交换律了吧！\n是的，答案就是这么简单，且无聊。交换群（Commutative Group，又称阿贝尔群 Abelian group，为了纪念伟大的挪威代数学家阿贝尔），就是能交换 (Commutativity) 的群 (Group)。你也许会对交换群感到失望，但是代数岂是如此无聊之物！？这一切的原因，其实是：我们还没有引入同态 (Homomorphism)。\n交换群和普通群还是有区别的吧？ 当给一个普通的群赋予交换性时，它身上所多出来的性质远不止交换性这一条。交换性赋予群的不止表面看起来的两个元素可以交换，更重要的是，给交换群内的结构更加严格的限制。比如后面会提到的，交换群的子群全都是正规子群，因此对于任何一个交换群的子群，都可以用来被模除掉而形成一个子群。\n交换群太特殊了，以至于人们给它划定了一个特别的范畴：阿贝尔范畴（Abelian Category）。事实上，交换群甚至子集就是一个模（也就是我们在介绍蛇引理时一开始所提到的那个代数结构）。然而我们这里不计划过多地介绍交换群有多么特别，而是将目光放在交换群上面所定义的运算。更具体地说，是交换群上面定义的运算的符号，以及相关的记号。\n我们前面提到，群里定义的运算被称为“乘法”。这是个很有趣的名字：为什么我们叫它乘法？我们熟悉的乘法，比如在 $\\mathbb{R}$ 上的乘法，也就是实数乘法，或者在线性代数里我们知道的矩阵乘法，和这里的“群乘法”之间有什么样的关系呢？我们指出：实数在作为集合的条件下，赋予我们已经熟悉的乘法后，得到的就是一个群；而线性代数中的矩阵乘法，在将所有的 $n\\times{n}$ 方阵看作一个集合时，赋予矩阵乘法后也能形成一个群。\n然而实数乘法和矩阵乘法是有区别的：实数乘法满足交换律，而一般的矩阵乘法并不满足交换律。因此，实数乘法实际上构成了交换群。不过由此我们也可以看到，无论是否满足交换律，我们经常给这样满足若干条件的运算起一个“乘法”的名字。这也是群运算一般被称为“乘法”的原因。\n但是，不论是实数，还是矩阵，甚至所整数集合、向量等等，它们都有这样的一个运算，我们更加熟悉，且常常称之为“加法”。这些运算和乘法相比有什么样的特点呢？它们中的一些，在不谈所谓“交换律”时，其实和乘法是类似的。然而，在考虑矩阵的加法和乘法时，区别立马就显现了出来：矩阵的加法是满足交换律的，而乘法不满足交换律。那么我们就这样规定：一般群（或者不满足交换律的群）的运算就称为乘法，而交换群上的运算则称为加法。从记号上来看，我们这里做一个小表格，方便更直接的对比。\n项目 一般群 交换群 运算 乘法 加法 记号 ab a+b 左陪集 aH a+H 单位元 1 0 交换律 不满足 满足 这里要提出的是，上面的区别在不考虑交换性的情况下，仅仅是记号的区别。事实上，如果你愿意，完全也可以使用乘法记号，不过这就需要在文中特别标注出来就是了。\n最后，这里引出这样一个观点：抽象代数，如研究群、环、域、模的代数结构性质的这些内容，在理解这些代数结构的过程中，最好的例子有两个：一是整数极其衍生结构；二是线性空间以及其上面的矩阵。当然，只是私货而已，如果有什么问题还请见谅。\n同态，同构，等价关系 首先，代数中［Homo-］的词头其实很常见（？）。这是代表着两个东西之间一定有什么相同的地方。而同态，正是指出了两个代数结构之间相同之处的东西。请注意这里用到的是 代数结构 而非 群 或者 交换群。同态广泛地存在于代数学中，到处都是同态。那么同态是什么呢？其实你早就见过了。对于 集合 这个最基础的代数结构而言，同态就是 函数，或者说 映射1。既然函数是对于集合而言特殊的同态，那么对于群而言，特殊的同态是什么呢？很可惜，没有一个特别的名字，或者大家就直接叫群同态了。然而群同态确实是有其特殊之处的。我们稍后再细讲这种特殊点在哪，以及何来的“同”一字。\n回忆我们很熟悉的集合上的函数，它有这样的特点：\n函数必须要有定义域，它是一个 集合，且这个集合里的每个元素都能被函数处理（作用）。不能说有个定义域的元素不能被函数吃掉，那就礼崩乐坏了。函数是不会剩饭的。 函数必须要有陪域。他也是一个 集合。请注意这里不是说不是值域，而是陪域。值域是函数能吐出来的东西组成的集合，而陪域则是函数吐出来的东西一定会存在的集合。所以，很自然的，会有一些陪域上的元素不会有任何定义域上的元素去对应。 定义域中的每个元素 能且只能 对应陪域上的一个元素，而陪域上的元素可以有0个，1个或者很多个定义域上的元素对应。这就像投篮，球可以投不中，可以一个球一个框，也可以很多球进一个大框里，但是不能一个球同时进两个框。 判断两个函数是否相等（没错，函数作为数学对象是可以判断是否与另一个相等的）的铁则是：定义域相同，陪域相同，定义域上的每个元素通过两个函数作用后得到的结果总是一样的。也就是说，要检测函数的三个要素都是一样的。表达式也许会骗人，但 函数的定义 永远是诚实的。 天啊我怎么又讲了一遍函数是什么？原因是：函数，作为同态的一个例子，自然就包括了同态的许多特点。然而同态还有一个重要的特性，也是被冠以“同”字的原因：同态必须保持结构！我们没有在集合中看到这样的特点，是因为集合里什么结构都没有。也许有人说：集合里的元素都是有名字的呀？什么 1 啊 2 啊的，这不就有结构那样的东西了嘛。这里要明确的是：集合里这些看似特殊的元素，它们的特殊性全都源自于我们为了能区分它们所给的，甚至就是为了能数清楚这些元素，不至于把它们搞混。So，集合真的很单纯，它上面的结构都是后面赋予的。当然，你也可以说“没有结构”也是一种结构，因为 函数不会把集合变成别的什么不是集合的东西，保持了“没有结构”的特点（结构）。\n哦，好，但是说了一圈，到底怎么保持结构？群同态到底是什么样的？观察上面函数的特点，我们提炼一下：\n同态要有来有去，且来去都是同一类东西，不能来去之后东西不一样了。这说明 同态不会给对象添加或删去任何结构。\n就是说，群同态只能连接两个群。或者，一个群上如果作用了一个群同态，那么它就必须给出一个群。这点对于其他所有的代数结构都是一样的。\n还是一头雾水？是不是觉得随便哪个集合上的函数都能在集合变身成函数后也跟着变成同态？没关系，就群同态而言，我们其实可以写出群同态需要满足的特点（多亏了运算的存在）。\n设有两个群 $G$ 和 $H$，它们之间有个从 $G$ 到 $H$ 的群同态 $\\varphi$。我们记群 $G$ 的运算为 $\\times_G$，记群 $H$ 的运算为 $\\times_H$，群 $G$ 中有俩元素 $g_1$ 和 $g_2$。这样一来，由于 $\\varphi$ 是群同态，有：\n$$\\varphi(g_1 \\times_G g_2) = \\varphi(g_1) \\times_H \\varphi(g_2)$$ 而且它有一个很神奇且重要的特点：群同态只能把一个群的单位元映射到另一个群的单位元。这点乍看很神奇甚至不可思议，但是经过简单的证明就可以得到这样的结论了。这也是为了保持群的结构而对群同态做出的一个很强的限制。这也说明了，代数结构越是复杂，同态的限制就会越大。\n最后我们讲一种特殊的同态（或者态射，我们这里不区分两者，后面在范畴部分会做出说明），它需要有一个态射作为基础。我们设有这样的一个态射 $f:\\\\,A\\to B$，且在 $A$ 中有一个保持原有 $A$ 结构的子结构 $A'$，在集合层次上则为包含关系。此时我们就可以定义所谓的 限制（Restrict），就是把定义域从 $A$ 换到了其子结构 $A'$ 上而已。它的记号为：$f|:\\\\, A'\\to B$。\n我们接下来介绍同构 (Isomorphism)。它在集合函数中的对应就是所谓的一一对应函数了。回忆所谓的单射和满射，单射说一个萝卜一个坑，满射说值域就是陪域。而同时满足这两个条件的话，这个函数就是一一对应的函数啦。我们立刻使用一些新词来讲这些事情，因为函数（映射）是集合间的同态嘛。\n同态中有单态 (Monomorphism)，也有满态 (Epimorphism)。而同时满足这两点的，即为所谓的同构了。它们的要求和集合函数是一模一样的。然而还有别的定义方法，使用态射的逆（啊没错它们都是态射但是这就留到范畴论再说吧）即可定义同态的单或满。回忆之前学过的逆函数这一存在，一个函数的逆函数再作用到函数的话就会变成恒同映射（把一个元素映射到它自己）。这是一种双边逆，更常见的情况则是一个态射只有左逆或者只有右逆。我们称有左逆的态射为单态，有右逆的态射为满态，有双边逆的则为同构。这个我们不证，有兴趣可以挑一些例子看看。请把重点放在“能不能找到原来的元素”以及“如果能找到原来的元素，那么一定会如何”，并注意函数的复合是从右到左的。\n这里顺带提出原像（Inverse image）的概念。原像是和某一个陪域中的元素，以及一个态射相关的。它本身是一个集合，记录了所经过该态射后能得到该陪域中元素的所有定义域中的元素。它的记号以及形式化的表达是：若存在一态射 $f\\vcentcolon\\space A\\to B$ 以及 $b\\in B$，则 $b$ 在 $f$ 下的原像记为 $f^{-1}$，定义为：\n$$ f^{-1}\\vcentcolon=\\left\\{\\space a \\space \\vert\\space\\forall a \\in A, f(a) = b\\space\\right\\}. $$那么这样一来，单态则是所有陪域上元素的原像只能是空的或者只能有一个元素的态射，而满态则是所有陪域上的元素都有非空原像的态射。利用这个概念，同构还可以定义为所有陪域上元素的原像有且只有唯一一个元素的态射。\n同构从字面意思来理解，是“保持结构”的映射。可是之前还说同态是保持结构的映射，这两个区别在哪里？事实上，同构比同态要求高多了。同构要求的是“构造完全相同”，而同态则只要求“是同一类东西，不会多结构，也不会丢结构”，却可以修改这个结构。比如，同态可以让一个大群变成一个小群，搞得里面的每个元素以前有更丰富的运算结果，结果到了小群里好多元素被捏在一起了，这些丰富的结果也就没了。而同构会很严格地将一个群变成另一个大小一模一样的群，它们结构的丰富程度或者精细程度是一模一样的。在只关心群这个整体以及它怎么与其他群发生转变，完全不关心群内部元素有什么特别之处时，我们可以说，同构的两个群，它们在同构意义下可以被视作是相同的。顺带一提，集合的同构就是映射到元素个数相同（集合的势相等）的另一个集合。这也是个大坑，感兴趣可以搜 Schröder–Bernstein 定理或者伯恩斯坦定理。\n对于群而言，群的同态会把群的一个或几个元素捏在一起形成新群的一个元素。同态是创造新群的一个重要方式。但是假如我们考虑 把几个元素捏在一起形成一个新的元素 实际上意味着 对原来的群中的元素进行分类，那我们就会形成很有趣的结构，商群 (Quotient Group)。我们不会深入这部分，但是这个思想是极其重要的，因此我们需要介绍另一个概念：等价关系与等价类。\n小学数学，甚至幼儿园数学，经常会遇到这样的题目：把一堆苹果分成若干份，每份有几个苹果；把苹果平均分成若干份，最后剩下几个苹果。这样的题目是为了让孩子熟悉除法，而我们这里则要指出，这就是除法，或者所谓的“商”所代表的含义。而我们在分苹果时所做的事情，就是在对苹果分类。\n我们要如何进行分类呢？特别是对一堆苹果而言，分成堆时我们做了什么？也许我们有某个标准，也许就是简单的“我乐意”，但分成堆的过程中每个苹果最终都有属于自己的一堆。假如我们要分成三堆，那么我们完全有理由将三堆起不同的名字，比如：科比，牢大，曼巴。这样一来，每个苹果就都有了一个属性，一个标签。而苹果之间有什么关系吗？有的。我们观察同一堆的苹果，如牢大这堆，会发现这样的（显而易见）的特点：\n一个属于牢大的苹果，那么他就属于牢大（？） 如果一个苹果在牢大里，另一个苹果也在牢大里，那么它们俩就都在牢大里，不论进入牢大这堆的顺序 如果苹果A和苹果B都在牢大里，苹果B和苹果C也在牢大里，那么苹果A和苹果C就一定在牢大这里。 不论分类手法如何，不管分类标准怎么样，上面这三条总是成立的。而在分好之后，对任何人都可以只宣称这堆苹果属于哪一堆，不用管它具体怎么样了。有人问这个苹果是哪个，都可以回答这个苹果是从科比或者牢大或者曼巴这堆里取出来的。\n还是一头雾水？上面的例子是想说明这样的一件事：只要你选了，那就会形成一个标准，这个标准内的每个成员都会接受这样的束缚，而这个约束是有 自反性，反身性 和 传递性的。这些性质就刻画了一个“关系”，称为 等价关系。我们刚刚用分好的类来说明这类关系一定存在，而反过来讲，根据这样的关系，也一定能进行这样的分类。最后分出来的“每一堆”，我们就称为等价类。\n分类是代数学中另一个极为重要的话题。有一些出色，重要且惊艳的研究正是建立在这样的分类问题上的，比如传说中的 有限单群分类，洋洋洒洒几千字的论文将整个单群分类问题整的明明白白。分类如此重要的原因还在于帮助我们创造新的代数结构，也就是所谓的 商。比如使用同态对群进行划分则会涉及著名的 群同构基本定理，描述了用同态下的等价关系创造出的商群有什么样的信息。\n我们这里先不深入介绍商群，因为它将涉及到子群 (Subgroup)，陪集 (Coset)，正规子群 (Normal subgroup) 等概念，太啰唆了。这里只指出商群的记号为 $G/H$，其中 $G$ 和 $H$ 都是群，且 $H$ 是 $G$ 的正规子群。这个商群的元素是这样的：每个元素都是一个集合，这个集合内是群 $G$ 中的元素，并且这些群 $G$ 中的元素都相互等价，而这个等价关系则由群 $H$ 这样确定：元素 $a$ 和 $b$ 等价由 $a^{-1}b \\in H$ 决定。换句话说，我们根据群 $H$ 制定了元素的分类标准，把分好类后的每个“元素堆”作为商群中的每个元素。能分多少堆，商群就有多少个元素。\n要注意的是由于等价关系，商群中每个元素（也就是 $G$ 中元素的集合）里都可以选出唯一的一个 $G$ 中元素来代表。那么既然如此，我们就使用在代表元的头顶加个尖尖的东西来代表这个集合了。比如有一个等价类 $A$ 中有一个元素 $a$，此时我们就可以用这个元素 $a$ 来代表这个等价类 $A$：$\\hat{a} = A$。这个记号还是比较重要的，所以这里提前介绍一下。\n太棒啦！感觉智慧满大脑了~ 但是这么多前置了，和蛇引理有关系吗？还有多少前置需要呢？答案令人振奋呀：还有一节就好了！我们已经明白了同态是什么样的，交换群又是啥，商群里的元素怎么确定，有什么样的特点。我们只需要再看一看最后两个和同态有千丝万缕联系，作为“群同构基本定理”中的 C 位的两个特殊的代数对象，核 (Kernel) 与像 (Image)，就可以开始一窥蛇引理的神秘了~。\n核与像 核的概念其实很简单，它高度依赖于同态，本身是一个特殊的集合（我们先看它单纯的集合结构）。它是同态的定义域上所有能被对应到陪域的 零元素 的元素，记号为 $\\Ker$。假设有某个同态 $\\phi$，那么在这个同态下的核就记为 $\\Ker\\phi$。这里的零元素应该是代数结构中普遍存在的单位元，而称为零元素的主要原因是因为对我们即将研究的许多代数结构而言，它们上面的结构实际上是交换的。交换的运算我们会叫它们 加法。而我们熟悉的加法的单位元就是 $0$。\n我们上面只说了核底下依赖的集合是怎样选取的，然而由于同态的性质，核上经常都会有额外的代数结构。这一点很容易确定：对群而言，单位元自己本身就是一个平凡群，其上的唯一运算就是单位元和单位元进行运算之后得到单位元自己。那么既然单位元是一个群，由同态的要求，我们马上就可以得知，群同态的核很自然地就拥有群结构。不但如此，我们在此不加说明地断言：群同态的核总是群的定义域的一个正规子群！而有了正规子群，我们马上就可以讨论定义域的群商去这个同态的核所得到的商群了。事实上，群同构基本定理中就和同态的核关系非常密切，且经常使用核来构造商群。\n对于核而言，我们还想提到这三点：首先核一定是依赖于某个同态的，没有同态是没有办法讨论核的。从它的记号就可以看出，我们选择使用 $\\Ker$ 记录同态的符号而非其定义域，然而也请切记核作为集合而言一定是定义域的子集。\n其次想要提到的是核在 同调代数（也许也不是？）中的意义：核衡量了同态的性质，告诉了我们一个同态距离单态究竟有多远。这是由于这样的定理：核中的元素只有一个（也就是单位元）当且仅当同态是单态。那么如果核越大，同态距离单态就越远了；核越小，同态就越像单态。\n最后一点也许会复杂一些，我们想提到的是：群同态的核由于一定是正规子群，而正规子群又一定能够被商掉。考虑我们上面提过的构造商结构的过程：被商的集合/结构是作为一个选择方式出现，而这个选择方式就是这个结构中的所有元素都被视为同一个元素。我们进行这样的猜测：这样用核来构造的商群中的元素，每个元素都是一个集合，而这些集合与核是相似的：它们都有同样的大小。幸运的是这样的猜测是成立的。最后也不卖关子了：商群中的元素就是正规子群的陪集，而每个陪集的大小都是相等的。所谓的陪集就是把群里面的子群用某个元素乘一下（移动一下）。这里说“集”有两个层面，一是我们不计划赋予它别的结构，他们就作为集合存在于商群；二是我们没法赋予群结构，除了最平凡的那个正规子群。更一般的陪集是没有办法满足单位元要求以及逆元要求的。\n核真的很重要，所以我们聊了许多。不过这主要是由于核与商群之间重要的联系。有了这样的铺垫，我们理解像将会更迅速：像也是一个子群，但不是更特殊的正规子群。\n像我们早就熟悉了，就集合层面而言，就是值域“更代数”的一个名字。而同样由于同态的存在，像也一定是一个群。但是不同于核，像并不总是正规子群。这真是一个悲伤的故事，我们不能再愉快地构造商群了。也许你之前幻想着，既然核可以衡量同态与单态的距离，是不是像也可以衡量同态与满态的距离呢？因为很显然可以看到，像越大越可能是满态，像与陪域相同那就是满态了。然而很可惜，我们不用这种方式。\n但是我们有三个好消息：第一条是，虽然像不是个正规子群，但是我们依旧可以用像构造商结构！第二条则是，虽然像不能衡量同态的信息，但是它构造的商结构可以！我们还给它一个特别的名字：余核 (Cokernel)。第三条则是，我们其实要研究的是交换群，而对交换群而言，所有的子群都是正规子群的！这样一来，前面讲的商结构也就可以是商群啦。\n子群，商群，陪集，商结构，到底是怎么回事？ 在讲商群时，终究还是无法避免陪集的概念。陪集和商群之间到底是什么样的关系？陪集之间又有什么样的联系？陪集到底是什么样子的东西？商 究竟是什么？我们讲了这么久的子群，正规子群，它们到底都是啥？这里我们斗胆写一写吧。\n先看看子群吧，其实子群的概念很简单：一个群的子群，实际上就是子集加上原群的运算。这样一来，子群的单位元一定就是原群的单位元，而子群的运算就是原群的运算了。这个还是相对比较简单的一个概念，麻烦的是所谓的正规子群。而为了讨论正规子群，必须要讨论所谓的陪集。我们把陪集往后放一放，先讲商群中的元素们：陪集。\n我们已经提到，商群就是对群按照其正规子群的需求进行分类从而得到的一个更小的群。这个更小的群里面是一个个的陪集，我们讲陪集中的元素都是相互等价的，因此，这个更小的群里的元素虽然都是集合，但是完全可以从每个集合中取一个元素来代表这个集合（由于等价关系），这个元素就被称为代表元。所以你可能会见到商群中的元素是用一个个原群中的元素带上标记构成的。但是还请记住，商群中的元素始终都是集合，也就是陪集。\n我们再谈谈陪集。陪集是这样一个集合：它必须依赖一个群里的元素，以及这个群的一个子群。我们记较大的群为 $G$，它的子群为 $H$。那么我们取 $G$ 中的一个元素 $g$ 之后，再和子群 $H$结合一下，就得到了所谓的陪集了。具体是这样的：\n还是先提醒：$H$ 是住在 $G$ 里面的，它们拥有一模一样的运算，所以 $G$ 中的元素是完全可以与 $H$ 中的元素运算的。 我们从群 $G$ 中取出一个元素 $g$。这个元素是任意的，只要在 $G$ 里就好。然后还要把 $H$ 中的元素一个一个地取出来，准备进行运算。我们要取出所有 $H$ 中的元素，不遗漏不重复。 用 $g$ 和 $H$ 中的元素依次进行运算。在做运算时，我们先把 $g$ 放在 $H$ 中元素的左边。最后得到的结果放在一个篮子里（或者框里，也可以）。 最后检查这个框子，我们给它贴上标签：$gH$。这个框就是我们想要的陪集，准确地说是 $H$ 在 $G$ 中元素 $g$ 作用下的 左 陪集。 自此，我们便成功得到了一个左陪集。如果在进行运算时将 $g$ 放在 $H$ 的右边，则称之为右陪集，记号也变为 $Hg$。注意到陪集中的元素一定是在 $G$ 中的元素，我们自然好奇：陪集内元素有什么样的特点呢？我们回顾上面的内容：陪集内元素相互等价，等价关系为 $a^{-1}b \\in H$。我们来看看是怎么回事。我们更多地关注左陪集，右陪集是类似的思路。\n证明：$a^{-1}b \\in H$ 当且仅当 a 与 b 等价，亦即 $aH = bH$。\n首先，$a$ 与 $b$ 都一定属于各自的陪集，因为 $H$ 是一个群，群里有单位元，则陪集 $aH$ 中肯定有 $a$，$bH$ 中也肯定有 $b$。\n既然 $a^{-1}b \\in H$，那肯定就有一个元素 $h$ 就是 $a^{-1}b$。由于乘法逆元的性质，我们给两边左乘 $a$，就有了 $b = ah$。回忆 $H$ 在 $a$ 的左陪集的定义，这就说明了：$b$ 也是 $aH$ 中的元素。\n此时我们想到，既然 $H$ 是一个群，$h$ 在 $H$ 里了，那 $h^{-1}$ 也肯定在里面。我们就给 $b=ah$ 的右边同时乘以 $h^{-1}$，就有得到了：$a=bh^{-1}$。这同时也说明了 $a$ 也是 $bH$ 中的元素。这样一来，我们就证明了 $aH = bH$，因为我们的 $a$, $b$ 是任意选择的 $G$ 中元素，这样的任意性保证了不会选取特殊的点。\n其次，当 $aH = bH$ 时，有这样的情况：$a$ 与 $b$ 相等，则结论自然；若是 $a$ 与 $b$ 不相等，这时由于群乘法的封闭性，一定要有一个 $h$ 满足这样的关系：$ah = b$。现在我们视线移向群 $G$ 后，便可以同时左乘 $a^{-1}$，这时就得到了我们想要的结论。至此，我们证明了这样的选择方式确实是构成了一个等价关系。\n最后我们关注陪集间的关系：左陪集是不一定等于右陪集的。这点如果能恒成立的话，那么这个群 $H$ 就一定是一个正规子群。另外，群 $H$ 的所有左陪集都有同样的大小。这一点的理由是：左乘群 $g$ 中的元素这个动作总是可逆的，再左乘回 $g^{-1}$ 就可以了。这样一来，左乘 $g$ 就实际形成了一个集合间的双射，也就是所谓的同构。它保证了元素个数相同。也正因如此，用左乘 $a$ 定义的 $H \\to aH$ 就保证了 $H$ 与 $aH$ 的元素个数相同了。由于 $g$ 是任意选取的，所以任意的左陪集都有相同个数的元素了。这个结论对右陪集而言也是显而易见的。\n另外我们提一下记号的问题。对于使用乘法记号的群而言，由于我们的子群本身就是一个群，所以一定有一个单位元。而根据左陪集的形态，我们就知道了：每个左陪集中一定有一个元素，这个元素就是子群 $H$ 的单位元乘上我们左陪集所左乘的元素。简单来说，如果有一个左陪集 $gH$，那么这个左陪集里面就一定有一个元素 $g 1_H$。而既然左陪集中每个元素之间都是等价的，我们很自然地就可以使用这个元素来代表这个左陪集。至于记号，我们上面已经介绍了：$\\hat{g}$ 就可以代表 $gH$。诶？那假如我用单位元去左乘以这个子群，得到的就是？没错，就是子群本身形成的陪集。而这个特殊的陪集在我们下面定义的商群乘法下自然就是我们需要的单位元了。\n我们定义正规子群为左陪集等于右陪集的子群。在这个定义下，很明显就可以看出，满足交换律的交换群里没有不正规的子群了，因为很轻易地就得到了左陪集等于右陪集，只需要把交换律下放到陪集内元素的计算过程中即可得到。那么对于交换群/阿贝尔群而言，陪集的记号是什么样的呢？我们很轻易就可以类比出来：既然乘法记号的群是用一个元素左乘子群得到左陪集，那么加法记号的群就用一个元素加上一个子群得到这个子群的陪集即可。同样，我们可以使用这个加上去的元素来代表这个陪集，方法也是在上面戴个小帽子。\n有了正规子群，我们就可以愉快地进行商群的构造了。然而，为什么必须是正规子群呢？不能商去一般的子群吗？答案藏在商群运算的合理性中2。\n为了尝试从普通的子群构造商群，我们取子群的左陪集们然后就可以形成一个集合了。这个集合内的每个元素都是子群的左陪集。现在我们希望给这个集合上面添加运算。由于左陪集的元素是形如 $gH$ 这样的，所以我们自然希望 $g_1H \\cdot g_2H = (g_1g_2)H$，也就是可以直接借用我们在群 $G$ 或 $H$ 中已经有的乘法了。这样定义的乘法满足了群运算的所有性质。然而，定义这个乘法不能靠我们一厢情愿，我们得检查定义的是否合理，即设 $a, a' ,b$ 是满足了 $aH = a' H$ 的任意的 $G$ 中的元素，我们要有 $(aH)(bH) = (ab)H = (a'b)H = (a'H)(bH)$。\n根据陪集定义，我们取任意的 $h_1$, $h_2$ 以及由它们决定的某个 $h_3$，则有 $ah_1bh_2 = a'bh_3$。由于 $aH=a'H$，根据之前的论述，我们指导一定有某个 $h_4$ 满足 $a = a'h_4$。我们带入前面式子，有 $a'h_4h_1bh_2 = a'bh_3$。根据群乘法可逆的条件，有 $h_4h_1bh_2 = bh_3$，我们再把 $h_2$ 的逆乘到等式右边，根据 $H$ 中乘法封闭性，就有：$h_5b=bh_6$。由于我们的 $h_1$，$h_2$ 是任意的，$a$，$b$ 也是任意的，所以 $h_3$ 和 $h_4$ 也不受额外条件的束缚，进而 $h_5$ 与 $h_6$。再回忆我们的左陪集和右陪集的定义，因此我们可以认为：为了满足我们的乘法条件，则必须要有 $Hb = bH$，这正说明了 $H$ 必须是正规的。至此，你应该已经发现：为满足运算的合理性，子群 $H$ 必须是正规子群。\n我们也可以这样理解。取 $G$ 中的任意两个元素 $g_1$与 $g_2$，再取 $H$ 中任意的两个元素 $h_1$ 与 $h_2$，我们要保证 $g_1h_1g_2h_2 = g_1g_2h_3$，其中 $h_3$ 可以是某个由计算过程得到的一个 $H$ 中的元素。要想把 $g_2$ 往左挪过去和 $g_1$ 凑成一对儿的形式，我们必须要让 $g_2$ 和 $h_1$ 存在某种形式的“交换律”，这样的交换律必须保证 $g_2$ 还是 $g_2$，$h_1$ 则必须还是 $H$ 中的元素。但是，很可惜，这样的“交换律”只能存在于真的交换群，或者最低限度的办法：让左陪集等于右陪集，也就是正规子群中。否则这两点无法保证。\n上面这个说明，也是为了指出证明定义合理的重要性。这一点在代数中是十分重要的。而在讨论完陪集和正规子群的重要性后，我们最后要讨论的是：商 到底是什么。\n我们其实已经指出过，商就是所谓的分类。小学学到的“分堆问题”就已经是对 商 这个字非常好的诠释了。至于为什么用了“商”这个字……首先，我不知道；其次，也许可以问商鞅？（什么地狱笑话）\n商结构远不止存在于群或者集合中。商结构几乎存在于任何代数对象里。我们可以对拓扑空间做商结构，就像是把纸/空间缝起来/黏起来一样，这样我们就可以得到各种有趣的拓扑空间，比如甜甜圈（环面）、克莱因瓶、莫比乌斯环带等；我们可以把整数轴折叠起来，这样可以得到一个有限群（还很有可能是循环群）；我们还可以把 $\\mathbb{R}$ 上多项式空间（就是所有以实数作系数的多项式组成的线性空间）商去多项式 $x^2+1$，这样得到的就是我们熟悉的复空间（复述域）。这里我们提出一种理解商空间的方法：把空间的某些点/线/面或者什么东西黏起来。这个“黏起来”的动作，实际上就是把某些点看作同一个点，而这样就等于定义了一个等价关系：黏起来后到同一个点的原空间内的点就在同一个等价类里面。\n此时你可以看到，如果你有一种分类方法，并且你可以用什么办法把代数对象里的元素放到不同的几堆儿里，那你就已经可以生成一个商结构了。它最最最最最起码也是一个商集，而要是你分类方法足够好，你得到的商结构就会更好。我们最后提一下商结构的记号，一般有两类表示方法：一是商去一个等价关系，二是商去用这个等价关系生成的等价类。这两种记号一般都是代表着同一个含义的。利用这个等价关系对原代数结构进行划分会得到若干等价类，其中的一个就是商去等价类记号中的那个等价类。\nSo，这就是对这么几个代数学结构的解释了。希望你不要因为这些文字而感到眩晕的同时，得到一些对这些代数结构直观的解释。我们回到主线吧。\n正合列，以及一点点点点范畴论 现在我们已经清楚了什么是交换群，什么是同态，什么是核，像，商群以及余核。是时候看看我们想要研究的结构了：正合列，以及对应的图。下面就是我们要研究的对象，也是一个图的例子：两个整合列所组成的图。\n两个整合列，通过整合列间的同态链接 你可以在图中看到两个虚箭头，这两个虚箭头我们先把它们看成实线的，也就是实际存在的。后面证明蛇引理的时候这两个箭头是可以不存在的（当然也就没有连接着的0了）。 链复型 我们先来说说正合列（Exact Sequence）。上图中的正合列有两个，分别是 $0\\to A \\to B\\to C\\to 0$ 以及 $0\\to A' \\to B'\\to C'\\to 0$。它们中的 $0$, $A$ 等我们称之为 点，实际上是一个个交换群（一般是模，我们这里取交换群即可），而每个箭头都代表着一个同态。这些同态有着特殊的要求，如果这些同态只是一般的同态，那它们就什么都不是。为了使之成为整个列，我们需要先得到所谓的链复型（Chain Complex，上下文明确时可能直接叫复型 Complex）。\n链复型要求使用同态将一系列的数学对象连接起来，通常这些数学对象以及对应同态还会有一定的顺序，且同态之间的复合还要满足特殊的要求。具体而言，链复型要求这样的序列：\n$$\\cdots\\xrightarrow{d_{i+2}} M_{i+1}\\xrightarrow{d_{i+1}} M_i \\xrightarrow{d_i} M_{i-1} \\xrightarrow{d_{i-1}} \\cdots$$满足条件：$d_{i+1}\\circ d_{i} = 0$ 对于所有的 $i$ 都成立。这样的链复型可以被记作 $(M_\\bullet,d_\\bullet)$。这样的定义蕴含了下面的信息：\n交换群的序号从高到低，同态序号也从高到低 对于所有的同态而言，左侧的同态复合上右侧同态得到的是零同态，也就是把所有的元素映射到单位元上（对于交换群，单位元就是0） 由上面一条，如果左侧同态复合右侧同态得到了恒通映射，就说明左侧的同态必须把元素映射到右侧同态的核里面。若不然，则无法达成两次复合后为零同态。 链复型的结构要求每个点都是同一种结构（交换群），且某个点里的任何一个元素沿着链复型移动两次后一定会映射到单位元（后面称零元）上。这样的代数结构是为了方便我们讨论所谓的 同调，也正因如此，链复型是同调代数中最基础也最重要的代数结构之一。\n同调群，正合，正合列 上面提到，正合列是在链复型商加条件得到的，而这个所谓的条件就是 正合 条件。而为了讨论正合，我们还要引入同调群的概念。有了同调群，正合就非常好判断了。\n我们还是用上面的链复型来举例，所谓的同调群是指这样的商结构：\n$$H_n(M_\\bullet) \\vcentcolon= \\Ker d_n/\\Img d_{n+1},$$即一个同态的核与上一个同态的像之间的商群。当链复型的某个点处（即某一个 $n$）的同调群是平凡群（即只有一个元素的群，记作 $0$）时，我们称这个点上是 正合的。而如果每个点都是正合的，我们就叫这个链复型为正合列。表达正合关系也可以不借助同调群，因为同调群等于平凡群就相当于说\n$$\\Ker d_n = \\Img{d_{n+1}},$$从这个角度来看也许更好理解正合是什么样的关系。仅从集合的角度来讲，链复型的要求就是在说 $\\Img d_{n+1}$ 必须在 $\\Ker d_n$ 的里面，它们之间可能有缝隙：$\\Img d_{n+1} \\subseteq \\Ker d_n $；而正合则表示，这两个集合之间是没有缝隙的。这也许也是正合这个字的来源吧。\n最后我们指出，我们上面的那个图片里所给出的两个正合列更为特殊，因为很短，所以叫它 短正合列。不难看到，由 $0$ 出发的态射是单态，到 $0$ 结束的态射则是满的。而又根据正合的条件，可以得到 $f$ 必须是单态（不然 $\\Ker f \\neq 0$），$g$ 则必须是满态（否则 $\\Img g \\neq 0$。\n图与交换图 学代数的时候会遇到许许多多用箭头代表的态射，而我们也常常需要将态射复合起来形成新的态射。有时我们又会发现，一个态射可以通过两种甚至多种不同的态射复合方式得到。单靠语言经常会感到乏力，自然而然地，我们想到用图（Diagram）来绘制出这样的想法，把一些态射按照对应的数学对象连接起来。上面链复型或者正合列的表示实际上已经是一副图了，但是这个图还是比较简单的。而当我们发现一个态射可以通过不同的态射复合方式得到时，我们就可以把它们画出来，这样的图我们称是交换的，这种图我们叫做交换图（Commutative Diagram）。\n以上面的用两个短正合列组成的那个图举例，如果有 $\\beta\\circ f = f'\\circ\\alpha$ 以及 $\\gamma\\circ g = g'\\circ\\beta$，那么它就是一个交换图。我们后面把态射复合时中间的圆圈 $\\circ$ 省略掉。\n一点点的范畴论 我们最后简单地提一嘴范畴论吧。范畴论是从拓扑那里来的，是根据不同的几何结构间精巧的关系而诞生的描述这种关系的语言，但是后来逐渐被大家发现，好像很多数学结构之间也是可以构建出类似关系的。自此，便有数学家开始建立范畴论，用以正式地，形式化地描述不同数学结构它们内部的或之间的关系。\n我们举一些简单的例子，来看看什么是一个 范畴（Category）。一个很简单的例子就是 所有 的集合以及集合之间的 所有 函数们所构成的范畴 $\\mathsf{Set}$ 了（具体某个范畴的记号一般使用无衬线体，根据情况省略部分字母），另一个例子则是所有群以及所有的群之间的同态所构成的范畴 $\\mathsf{Grp}$。可以看到很多都是“所有的数学对象以及它们之间所有的同态构成的范畴”这样的形式。这样的范畴还是比较基础且常见的，且根据这样的形式，我们可以很自然地总结出别的一些范畴，比如 $\\mathbb{R}$ 上的所有线性空间以及所有的线性映射构成的范畴 $\\mathsf{Vect_\\mathbb{R}}$，所有的环以及其同态构成的映射构成的范畴 $\\mathsf{Rng}$，等等等等3。\n范畴之间是可以相互联系起来的，这种联系我们也可以像箭头一样写出来，称为函子（Functor）。而函子之间也可以做出联系，称为所谓自然变换（Nature Transformation）。不过好消息是，我们不需要关注这些内容，而只需要关注某一个具体范畴（具体来讲，就是阿贝尔群范畴 $\\mathsf{Ab}$）的内部即可。\n范畴的作用除了给出不同类型的数学对象之间有什么样的联系之外，也给我们提供了一个讨论问题的舞台。我们可以直接讲我们在某个范畴中研究什么样的问题，此时范畴本身就给出了我们要研究内容的重要信息。另外，范畴论给了我们一些用以描述数学对象关系的语言，它们通常可以一针见血地指出数学对象间是什么样的关系，当然也因为过于抽象且过于具有总结性而被戏称为“抽象废话”。\n最后，借助范畴论中的一些内容，比如交换图，我们可以方便地描述数学对象之间的关系。\n所以什么是范畴呢？ 我们这里引入范畴的原因其实非常地单纯：希望能引入所谓的交换图这一概念。虽然它本身的引入其实用不太上范畴，但是也许是出于我的私心吧，感觉这里引入范畴也能更好地规范我们研究问题的范围。 那么什么是范畴呢？范畴其实就是一系列对象以及它们之间态射所构成的集合体。我们这里引用著名代数学教材，李文威老师的《代数学方法》中对于范畴的定义。\n范畴的定义：\n一个范畴 $\\mathcal{C}$ 是指以下的资料：\n一个集合 $\\mathrm{Ob}(\\mathcal{C})$，其元素称为 $\\mathcal{C}$ 的 对象； 另一个集合 $\\mathrm{Mor}(\\mathcal{C})$，其元素称为 $\\mathcal{C}$ 的 态射。 另外，对上面两个集合之间有这样的要求：\n两集合间有一对映射：$s\\vcentcolon\\space\\mathrm{Mor}(\\mathcal{C}) \\to \\mathrm{Ob}(\\mathcal{C})$ 和 $t\\vcentcolon\\space\\mathrm{Mor}(\\mathcal{C}) \\to \\mathrm{Ob}(\\mathcal{C})$，它们分别指出了态射的来源与目标。 对于态射而言，有这样的要求：\n针对某两个对象 $X,Y\\in\\mathrm{Ob}(\\mathcal{C})$，我们可以从上面这一对映射中得到这两个对象之间的所有态射的集合：$\\mathrm{Hom}_\\mathcal{C}(X,Y)\\vcentcolon=\\space s^{-1}(X)\\cap t^{-1}(Y)$。在明确所指范畴的情况下可简记为 $\\mathrm{Hom}(X,Y)$。这样的集合也被称为 $\\mathrm{Hom-}$ 集； 对于任意的一个对象 $X$，一定存在一个态射 $\\mathrm{id}_ {X} \\in \\mathrm{Hom}_{\\mathcal{C}}(X,X),$ 这个态射被称为 $X$ 到自身的恒等态射； 给定任意的三个对象 $X,Y,Z\\in\\mathrm{Ob}(\\mathcal{C})$，有这样在其 $\\mathrm{Hom-}$ 集之间的映射，称为合成映射，定义为： $$\\begin{align*} \\circ\\vcentcolon\\space\\mathrm{Hom}_\\mathcal{C}(Y,Z) \\times \\mathrm{Hom}_\\mathcal{C}(X,Y)\u0026\\to \\mathrm{Hom}_\\mathcal{C}(X,Z)\\\\ (f,g)\u0026\\mapsto f\\circ g\\\\ \\end{align*}$$ 且当不至于混淆时可以省略中间的 $\\circ$，将 $f\\circ g$ 简记为 $fg$。 最后，对上面的合成映射而言，有这样的两个要求：\n结合律：对于任意的态射 $h,g,f\\in\\mathrm{Mor}(\\mathcal{C})$，如果映射的合成 $f(gh)$ 和 $(fg)h$ 都有定义，那么 $$f(gh) = (fg)h.$$ 对于任意的态射 $f\\in\\mathrm{Hom}_\\mathcal{C}(X,Y)$，其与恒等映射之间的复合满足关系： $$f\\circ\\mathrm{id}_X = f = \\mathrm{id}_Y\\circ f.$$ 那么以上，就是范畴的比较正式的定义。可以看到它还是有依赖一些集合论的内容的，但这只依赖于对象集合和态射集合之间的映射，以及在 $\\mathrm{Hom-}$ 集之间的映射，并不涉及某个具体的代数结构，特别是没有涉及到在集合上添加运算得到的代数结构。我们一般称这样有集合作为“基底”的范畴为 具体范畴。另外，由于范畴的定义非常灵活，实际上可以定义出非常抽象的范畴，比如以态射作为对象的范畴。\n最后要指出的是，范畴最关键的应该是态射，而不是范畴内的对象。范畴论以研究对象间的态射来研究范畴的行为。从范畴的定义中也可以看到，众多的要求都是对态射提出的，而非对对象。在使用或研究范畴时，应注意这一点。\n所以，蛇引理到底讲了什么 终于，我们把为了描述蛇引理讲了什么而需要的一些基础内容介绍完了。可以看到，蛇引理还是需要比较多的前置的。下面就是这个所谓的蛇引理了。我们介绍的是建立在两个短正合列所构成的交换图上的简单版本的蛇引理。具体内容如下：\n蛇引理：\n设有如下图所给出的交换图：\n其中第一行和第二行均为正合列，每个点均为阿贝尔群（交换群）。由这样的两个正合列，我们可以构造出下面的正合列： 且当交换图中的虚线箭头成立时，对应的虚线箭头也成立。 这条引理由于构造出的正合列需要像蛇一样从交换图的左上角开始出发一路拐到右下角而得名。真是恰当的名字。由于我们已经知道所谓的余核，所以上面的正合列实际上还可以写成这样更加对称的形式：\n对称？对称在哪？ 我们常常讲“对称”，对称常常能带来强烈的美感。然而，对称到底是什么？ 我们从小就知道轴对称，稍晚会学到中心对称，旋转对称等等。然而这些对称始终没有一个综合的描述方法，它甚至不像是数学的内容，反而更像是美术的内容。然而，有了群，我们就可以描述这样的对称性了：对称，就是使用一个群对其进行作用后仍然能回到自身的性质。对称就蕴含于群内部。\n然而我们这里打算提到的对称，并不是和群相关的，而是和交换图相关的。从交换图上可以看到，如果把底下的链条用余核来代替，那么这个图就是非常对称的：上面是核构成的链条，下面是余核构成的链条；左下角是一个单态，而右上角则是一个满态。然而我们肯定不能单纯满足于这样的只从图上看到的对称，我们想问这样的问题：余核，它和核的定义区别如此之大，为什么会这么自然地存在于这个图内？它们俩之间究竟有什么样的关系，让最后的这个交换图呈现了这样的形状？或者问得更简单一些：余核，什么是 余？它好像是剩余的意思，但是从英文上来看又完全看不出这样的关系。余 到底是什么？\n我们做一点剧透：因为范畴和交换图，即因为核与余核之间定义的对称性。可能会有人有这样的疑问：核与余核之间的定义的对称性？从形式上来看完全没有任何的对称性呀？我们指出：在范畴论的语言下，两者完全可以使用 泛性质 进行定义。我们后面会提到所谓的泛性质是什么。\n我们观察核的定义：核是对一个同态定义的。比如有这样的（群）同态：$\\varphi \\vcentcolon G\\to H$，那么这个同态 $\\varphi$ 的核就是一些群 $G$ 中的元素所组成的集合，这些集合在同态 $\\varphi$ 的作用下会映射到群 $H$ 的单位元处。或者我们采用原像的写法，$\\Ker \\varphi = \\varphi^{-1} (1_H)$。\n那么我们应该怎么把他改写成使用范畴定义的东西呢？我们抓住范畴论的核心思想：使用态射来研究对象。作为一个同态的核，它在映射之后一定会到单位元上；作为一个群，它一定是同态的定义域的子群。我们可否用这个性质来做文章？答案是肯定的：我们就如此定义，但通过范畴论的语言来描述这个过程。\n我们定义态射 $\\varphi \\vcentcolon G\\to H$ 的核是这样的一个群范畴 $\\mathsf{Grp}$ 中的一个对象 $\\Ker \\varphi$，这个对象到同态 $\\varphi$ 的定义域 $G$ 之间存在一个包含同态\n$$\\begin{align*} \\iota \\vcentcolon \\Ker \\varphi \u0026\\hookrightarrow G\\\\ g \u0026\\mapsto g \\end{align*}$$（我们这里使用带钩箭头标明它是一个单态）；此外，这个对象满足这样的性质：对于任意的同态 $\\alpha\\vcentcolon X\\to G$，只要满足条件\n$$\\varphi\\circ\\alpha = 0,$$（此处 $0$ 代表零映射，或者叫平凡映射（Trivial Map），即将所有的元素都映射到单位元 $1_H$ 上），那么同态 $\\alpha$ 即可被唯一地分解，即对某个 $\\alpha$ 而言，存在唯一的一个同态 $\\overline{\\alpha}\\vcentcolon X\\to \\Ker \\varphi$，满足 $\\alpha = \\iota\\circ\\overline{\\alpha}$。将这些性质使用交换图来描述的话，就是说下面的这个交换图成立：\n核的定义 换句话来说，对于任意满足条件 $\\varphi\\circ\\alpha = 0$ 的态射 $\\alpha \\vcentcolon X \\to G$，它们都一定可以被分解成两个映射，且这个分解方式是固定的：先有一个唯一的映射 $\\overline{\\alpha}$ 将 $X$ 映射到一个群上，然后再从这个群出发，保持原样地通过包含映射 $\\iota$ 映射到原态射的陪域 $G$ 中。而这样的固定且特殊的元素，就是我们要找的映射 $\\varphi$ 的核，也就是 $\\Ker \\varphi$。\n我们观察这样的定义，它实际上确实定义出了我们熟悉的核，只不过是用了更加范畴论的形式，并没有研究元素内部是如何映射的，而是使用了 平凡映射 来包含所有我们需要的信息，再通过唯一分解的方式来确定它的地位。它只是换了一种更加 fancy 的说法而已。\n而接下来，我们就要仿照这样的形式，来定义余核。我们先来观察已有的余核定义，它被定义为同态的陪域模除掉同态的像得到的结构。为此，我们需要先来看看商的泛性质。我们依旧在群范畴内讨论这个问题，但是它很容易就可以推广到其他的结构中。\n从商的构造过程来看，构造商结构时需要取一个等价关系，然后根据这个等价关系进行划分，最后将所有的等价类放在一起，每个等价类作为一个商结构中的一个元素，这就是取商的过程。如果要在范畴论中讨论这个问题，那么就需要从与商相关的态射出发考虑这个问题。首先我们看取商的过程。\n鉴于上面的过程的统一性，我们将这个过程化为一个态射，称之为商映射 (Quotient Map)，记作 $\\pi$。当明确左或右陪的元素时，也可以在这个记号的左下标处记下该元素，如从整数群构造 n 阶循环群的过程，其商映射就可以记为 $\\pi_n\\vcentcolon\\mathbb{Z}\\to\\mathbb{Z} /n\\mathbb{Z}$。有了这样的记号，我们的讨论也会更加便利。\n既然从态射角度出发，我们想观察：假如从群 $G$ 到群 $G'$ 有一个同态 $\\varphi$，而群 $H$ 是 $G$ 中的正规子群（因此可以被模掉）且 $H\\subseteq \\Ker \\varphi$（为了保证商群依旧能映射到 $G'$ 上）。那么，这个同态 $\\varphi$ 与 $G$ 模 $H$ 得到的子群 $G/H$ 之间有什么样的联系呢？我们有这样的定理，同样，可以用交换图来表示：如果有上述条件存在，那么则存在一个唯一的映射 $\\overline{\\varphi}$，使得 $\\varphi = \\overline{\\varphi} \\circ \\pi$，即下面的图交换：\n商的泛性质 怎么理解这个图的交换性呢？当我们把 $G$ 映射到 $G'$ 时，由于该映射的核的限制，必须有和核的元素个数一样多的元素被映射到同一个 $G'$ 中的元素里（考虑我们定义的核，以及商映射的特点）；当将 $G$ 商映射到 $G/H$ 上面时，由于 $\\Ker \\varphi$ 的元素比 $H$ 中的元素数量要多（上面的子集关系），此时从 $G$ 到$G'$ 的同态 $\\varphi$ 对其定义域 $G$ 的“收缩力度”是一定不如商映射 $\\pi$ 的。因此，我们一定可以从商映射得到的 $G/H$ 中再做一次映射，从 $G/H$ 重映回 $G'$，使得 $\\varphi$ 最后被表示为 $\\overline{\\varphi}$ 与 $\\pi$ 的复合。也就是说，$\\varphi$ 被分解为了两步：首先，通过正规子群进行分类，由于我们取的正规子群比核小，所以商群内的每个元素必定被映射到同一个元素内；在进行这样的分类后，再进行一次映射，把分好的等价类按照其中元素原有的根据 $\\varphi$ 的映射方式来将这些等价类映射到对应的 $G'$ 中的元素里。由于拉格朗日定理，子群的关系保证了这样分类得到的等价类个数一定是整除态射 $\\varphi$ 的像的，这也就保证了这个同态是良好定义的，并不会出现一个等价类映射到两个 $G'$ 中元素，或者映射到同一 $G'$ 中元素的等价类数目不同这样的情况。\n在理解上面定理的含义后，我们指出：实际上我们可以借助商的这一泛性质来定义商结构和商映射，即：设 $G$ 是一个群，其有一正规子群 $H$，则通过如下两个泛性质即可定义商群 G/H 与商映射 $\\pi$：\n存在一个群 $G/H$ 和一个群同态 $\\pi\\vcentcolon G\\to G/H$，满足 $\\Ker \\pi = H$； 对任意的群 $G'$ 与 群同态 $\\varphi\\vcentcolon G\\to G'$，如果 $H \\subseteq \\Ker \\varphi$，则存在一个唯一的群同态 $\\overline{\\varphi} \\vcentcolon G/H \\to G'$，使得上面的交换图成立。 也许你有疑问：我们不是在看余核是怎么回事吗？你怎么扯到商结构和商映射用泛性质的定义了？正是由于有了商结构的泛性质，我们才能更好地定义我们已经知道的余核。\n这里也不继续卖关子了，为了定义余核，我们需要做的事情只有三件：一，将上面核的泛性质图里的所有箭头转向；二，把 $\\Ker \\varphi$ 换成 $\\Coker \\varphi$，把带钩箭头换成双箭头（代表满态），再把包含映射记号 $\\iota$ 换为商映射记号 $\\pi$；最后，我们再仿照核的定义，来讲所谓的余核是什么的时候，需要将群改为阿贝尔群（交换群）。我们先把表示它泛性质的图画出来；为了方便对照，我们把核对应的泛性质图用另一种形式画出并附上（两种交换图是完全等价的）：\n余核 核 相信到了这一步，你一定会相信所谓的“对称”绝非空穴来风。所谓的余核，说得简单点，就是把核的泛性质里的所有箭头都反转后定义出来的东西而已；甚至于对于范畴论而言，所谓的“余”就是将某个对象的泛性质里箭头全部反转后出现的对偶。一个著名的范畴论笑话是这么讲的：\nA mathematician is a device for turning coffee into theorems, and a comathematician is a device for turning cotheorems into ffee.\n我们再来看看余核的泛性质。很容易就可以看到余核的交换图的右侧出现了我们熟悉的身影：商结构和商映射的泛性质。通过这个小块我们得以了解到，$\\Coker \\varphi$ 应该具有某种商结构，需要用 $H$ 商去它的一个正规子群。那么它具体应该商去谁呢？注意到两点：\n第一点是，商结构的特点决定了它要商掉的那个正规子群本身是可以作为陪集存在于商群中的。举个简单快速的例子，$A/B$ 这个商群里 $B$ 本身就是一个陪集，是用 $A$ 中单位元 $1_A$ 去陪 $B$ 得到；而这个特殊的陪集，由于用来左陪的元素是 $A$ 中单位元，它在商群 $A/B$ 中一定也是担任单位元的责任；\n另一个点则来自于 $G\\to X$ 必须是平凡映射这一要求。我们根据余核的泛性质，可以轻松地取这样的 $\\beta$，让它就等于 $\\pi$，这样一来 $\\overline{\\beta}$ 就变成了恒等映射，$X$ 也就变成了我们研究的 $\\Coker \\varphi$。那么，从群 $G$ 出发，在映射到 $\\Coker \\varphi$ 时必须到它的单位元上，那么从商映射的特点看，所有的 $G$ 中的元素都必须在经过 $\\varphi$ 映射后出现到 $\\Coker\\varphi$ 商去的那个正规子群里。而满足这样条件的东西只有一个，即 $\\Img \\varphi$，且还有一个要求，就是 $\\Img \\varphi$ 必须是正规的，这就要求 $H$ 是交换群，进而要求整个泛性质中的群都得是交换群。\n如此，我们又成功地从余核的泛性质的定义里拿到了我们熟悉的，用商群定义的那个余核。这也进一步指明了核与余核在范畴论意义下的对偶关系。然而，对于核以及余核而言，它们的定义还可以更加 fancy 一些：我们可以使用极限和余极限来定义核与余核，这里就不过多展开了，毕竟这篇文章不是讲范畴论的，而是为了证明蛇引理的来着（）\n准备证明吧 我们的手牌已经集齐了，现在等待着我们的就是要证明这个引理。这个引理涉及到的阿贝尔群很多，同态也很多。我们需要一步一步地朝着目标前进，否则这个大个家伙是没办法一次搞定的。\n证明思路 从最后的结果来看，我们有这样的几个问题是需要验证的：\n$f|$ 的定义是否合理 $g|$ 的定义是否合理 $\\hat{f'}$ 如何定义，是否良定 $\\hat{g'}$ 如何定义，是否良定 $\\delta$ 如何定义，是否良定 每个点处是否正合 若原交换图虚线箭头成立，得到的正合列是否对应虚线箭头也成立 我们不计划纠结于为什么核与余核在这里出现的如此频繁，只将之作为待证明的结论；也就是说，我们不考虑为什么选择了这样的构造，只考虑证明这个构造为什么是正确的。另外，我们可以发现第1点与第2点是很相似的，同样第3点与第4点也是很相似的。\n在正式开始进行验证之前，我们做一些符号上的约定，以免待会儿晕符号。如果是 $A$，$B$，$C$ 中的元素，我们就用对应的小写字母代表；如果是 $A'$，$B'$，$C'$ 中的元素，我们就在对应小写字母的上面也对应地加上这个 $\\prime$。如果需要从同一个群中取两个元素，为了区分它们，我们会再在右上角添加上 $*$ 来表示。如果是经过了同态/映射的作用，在需要时会加上其属于的群以做提示，而不再用 $\\prime$ 或者别的字母做记号，除非这样的记号是必须的。\n好了，我们开始正式的证明过程吧。\n验证 $f|$ 的定义，然后 $g|$ 我们第一个要验证的是 $f|$ 的定义，或者说仔细考虑怎么样去定义它。从图上可以看到，这个同态是 $f$ 在 $\\Ker \\alpha$ 上的限制，对它的定义的验证则是要验证 $f|$ 是否真的能把 $\\Ker \\alpha$ 映射入 $\\Ker \\beta$ 中，即验证 $\\Img f| \\subseteq \\Ker \\beta$。\n为了验证这件事，我们只需要任意取 $\\Ker \\alpha$ 中的元素，如果这些点在 $f|$ 的映射下都属于 $\\Ker \\beta$，就可以验证这样的包含关系了（这也是子集的定义）。既然如此，我们取 $\\Ker \\alpha$ 中的一个元素 $a$，根据核的性质，既然 $a$ 在 $\\alpha$ 的核内，我们知道 $\\alpha (a) = 0 \\in A'$，再根据同态的性质，群同态只能将单位元/零元映射到单位元/零元上，我们知道 $f' \\alpha (a) = 0 \\in B'$。由于交换图的性质，我们有：$\\beta f (a) = f' \\alpha (a) = 0$。请注意 $\\beta (f(a)) = 0$ 就意味着 $f(a)$ 这一点位于 $\\Ker \\beta$，即 $f(a) \\in \\Ker \\beta$，而这恰恰就是我们要证明的：任意一个 $\\Ker \\alpha$ 中的元素 $a$ 在经过 $f|$ 映射之后，都位于 $\\Ker \\beta$ 中。\n这里有一个点需要指出：为什么我们明明要验证的同态是 $f|$，最后却直接使用了 $f$ 的性质？这是因为：$f|$ 除了更改了定义域的范围之外，所有的信息都得以保留。由于我们任取的 $a$ 满足 $a\\in\\Ker \\alpha \\subseteq A$，所以 $f|$ 对在 $\\Ker\\alpha$ 中语境下的 $a$ 所产生的影响，和 $f$ 对 $A$ 中语境下的 $a$ 产生的影响是一模一样的。这保证了我们可以放心大胆地使用 $f$ 的性质。\n最后我们指出，这里的验证过程没有借助图表交换以及核的性质以外的任何要素，因此这套证明也可以直接照搬到下一个交换块，也就是关于 $g|$ 的定义的验证问题上。这里就不啰嗦了。\n验证 $\\hat{f\u0026rsquo;}$ 的定义，顺带 $\\hat{g\u0026rsquo;}$ 接下来要验证的就是 $\\hat{f'}$ 的定义了。我们要验证的东西其实和上面类似，也是 $\\Img \\hat{f'} \\subseteq \\Coker \\beta$。然而我们现在还不知道 $\\hat{f'}$ 具体是怎样的，只知道它的定义域是 $\\Coker \\alpha$。所以我们先来看看 $\\Coker \\alpha$ 里都有什么，再看看 $\\hat{f'}$ 是一个什么样的同态，最后来考虑验证上面所要求的定义。\n$\\operatorname{Coker} \\alpha$ 里是什么样的 由于 $\\Coker \\alpha = A' / \\Img \\alpha$，其中的每个元素都应该是 $\\Img \\alpha$ 这种形式的陪集。\n我们先考察一般的阿贝尔群（交换群）里某个同态的余核是什么样的。对于一般的阿贝尔群 $A$ 以及其上的某个同态 $f\\vcentcolon\\\\,A\\to B$ 而言，这里的等价关系是这样定义的：若 $a,a^* \\in A$ 且 $a - a^* \\in \\Img f$，则认为 $a \\sim a^* $，即 $a$ 与 $a^* $ 等价。这里的减号应该与 $a^* $ 一起理解为 $a^* $ 的逆元。这就形同 $a^{-1}a^* $ 在一般的乘法群中判定元素是否等价时一样。\n与此同时，其他的等价类（陪集），按照加法记号，也应该可以写作这样的形式：$a+\\Img \\alpha$。（假如你没有点开哪些小箭头的话）我们使用代表元的记号来记录这个陪集，即 $\\hat{a} = a+\\Img \\alpha$。我们在余核中定义的运算，则是借助余核所在的群的运算所定义的：直接将代表元按照原群中的运算进行，最后给它带上帽子（找到对应的等价类）。写成符号形式则是：假设有同态 $f: A\\to B$，则这个同态的余核为 $\\Coker f = A/\\Img f \\subseteq A$，再设余核这个群中有两个元素 $\\hat{a}$ 与 $\\hat{a^* }$，则余核中的运算为：$\\hat{a}+_\\mathrm{Coker}\\hat{a^* } = \\widehat{a+_A a^* }$。\n现在一切都明了了。对于我们所要研究的问题而言，$\\Coker \\alpha$ 中的元素，就是一个个等价类，这些等价类用原群的元素作为代表元进行标记，如 $\\hat{0}$, $\\hat{a'}$ 这样。而其运算直接继承自群 $A'$，具体而言，只需要将用来与子群作用的元素相运算，最后再作用回子群即可。\n$\\hat{f\u0026rsquo;}$ 是什么样的 从交换图上可以看到，$\\hat{f'}$ 是从 $\\Coker \\alpha$ 到 $\\Coker \\beta$ 上的。而在 $\\Coker \\alpha$ 中的元素则是众多的以代表元所代表的等价类。那么，在使用 $\\hat{f'}$ 作用到 $\\Coker \\alpha$ 中的一个元素 $\\hat{a'}$ 后，得到的则应该是位于 $\\Coker \\beta$ 中的一个元素，这个元素应该是形如 $b'+\\Img \\beta$ 这样的等价类，自然也可以被表示为 $\\hat{b'}$。这就是我们需要验证的同态，$\\hat{f'}$，具体在做的事。\n让我们写的更加明确一些：我们要定义的 $\\hat{f'}$ 应该是这样的：\n$$\\begin{align*} \\hat{f'}\\vcentcolon\\space \\Coker \\alpha \u0026 \\to \\Coker \\beta \\\\ a' + \\Img \\alpha \u0026 \\mapsto b' + \\Img \\beta, \\end{align*}$$其中 $a'\\in A'$ 与 $b' \\in B'$ 之间的关系有：$f'(a') = b'$。\n那么就有值得注意的一些问题。两个元素等价时，它们自然属于同一个等价类，但这两个元素本身是可以不同的。假如两个不同但等价的元素在进入等价类后再被商群间的同态所映射，应该会得到一个目标群上的等价类。另外，自然，我们也要验证这个同态的像确实在陪域内。我们开始验证吧。\n开始验证 我们取 $\\Coker \\alpha$ 中的一个元素 $\\hat{a}$，这个元素是一个等价类，等价关系由 $a' - a'^* \\in \\Img \\alpha \\hArr a' \\sim a'^* $ 给出。此时我们就取这个等价类中的这两个元素 $a',\\\\, a'^* \\in A'$。那么，此时这两个元素在经过 $f'$ 作用后得到的就是 $f' (a')$ 以及 $f'(a'^* )$。这两个 $B'$ 中的元素应该依旧会被映射到同一个等价类中，也就是两个元素等价。判断两元素等价的条件则类似于前面的判断条件：$f' (a') - f'(a'^* ) \\in \\Img \\beta$。我们可以看到：由于 $f'$ 是一个同态，同态是保运算的，则 $f' (a') - f'(a'^* ) = f'(a' - a'^* )$。注意到 $a' - a'^* \\in \\Img \\alpha$，由像的性质，我们就一定可以找到一个存在于 $A$ 中的一个元素 $a$，使得 $\\alpha(a) = a' - a'^* $。\n我们理一下思路：我们先选取了两个在 $A'$ 中等价的元素；这两个元素的差，根据等价类划分的规则，必须是属于 $\\alpha$ 的像的，那么就一定有一个对应的 $A$ 中的元素 $a$ 在 $\\alpha$ 的作用下等于这两个 $A'$ 中元素的差。那么这时，我们就可以使用图表交换的性质了：$f'(\\alpha(a)) = \\beta(f(a))$。请注意这个地方：右侧显示 $\\beta(f(a))$ 是属于 $\\beta$ 的像的：$\\beta(f(a)) \\in \\Img \\beta$。这就说明了 $f'(\\alpha(a)) = f'(a' - a'^* ) \\in \\Img \\beta$。这样，我们就得到了我们所需要的：任取两个 $A'$ 中的等价元素，它们最终被映入了 $B'$ 的等价类，因为 $f' (a') - f'(a'^* ) \\in \\Img \\beta$。\n那么这样我们就可以进一步进入到对 $\\hat{f'}$ 的验证：如果 $a'\\in A'$ 且 $a'^* \\in A'$ 且二者等价，就有 $a' + \\Img \\alpha = a'^* + \\Img \\alpha$。我们希望这两个应该相等的等价类在经过 $\\hat{f'}$ 的映射后得到的是同一个 $\\Coker \\beta$ 中的元素。那我们就直接进行运算：\n$$\\begin{align*} \\hat{f'}(a' + \\Img \\alpha) \u0026= f'(a') + \\Img \\beta\\\\ \u0026= f' (a'^* +a'-a'^* ) + \\Img \\beta\\\\ \u0026= f' (a'^* ) + f'(a'-a'^* ) + \\Img \\beta \\\\ \u0026= f' (a'^* )+ \\Img \\beta\\\\ \u0026= \\hat{f'}(a'^* + \\Img \\alpha). \\end{align*}$$我们来解释一下上面的运算过程。第一步是使用了我们上面对函数做出的定义；第二步就是单纯地进行了个加减，不过这里的加减能成立必须利用交换群的性质；第三步则是利用了 $f'$ 作为阿贝尔群同态的定义；第四步则要应用到我们刚刚得到的结论：$f' (a') - f'(a'^* ) \\in \\Img \\beta$；第五步则就是单纯运算回以 $\\hat{f'}$ 表达的形式，完成我们的证明。\n自此，我们证明了：两个任意的 $A'$ 中元素，当它们等价时，会且总是会被 $\\hat{f'}$ 映射到同一个 $\\Img \\beta$ 中的元素。这句话还可以换个说法：我们定义的这个同态，是不依赖于等价类代表元的选取的（我们选了两个代表元，结果一样）；或者简单一些：这个同态是良定的。\n我们完全可以按照相似的逻辑处理 $\\hat{g'}$。这得益于我们上面的定义以及验证没有用到除了交换图提供的信息外的任何额外附加信息。所以我们就不特别定义并验证这个 $\\hat{g'}$ 了，直接借用这里的定义以及验证方法即可。\n如何验证一个同态是良定的 在代数学中，我们常常会尝试给某个数学对象附上一个同态，或者给两个数学对象之间定义同态。然而，这样的过程并不总是顺利的：可能我们定义的东西到实际验证时是有问题的。就我个人的观点而言，这些问题包括但不限于：\n定义域上同一个元素被映射到了不同的陪域中的元素（违反映射的定义）； 不保持对象间的结构（不保运算，不保连续等）； 定义域不对，超出或小于定义域； 超过了陪域的范围， 等等。而我们所说的验证一个同态是良定的，实际上就是在尝试验证上面的这些问题都不会出现。一般而言，后两个问题都不太容易出现，一般的验证过程都是在验证前两个问题是否存在。\n我们先看第一个，这个的验证方法非常地朴实无华，即通过验证两个定义域上相等的元素，它们在经过同态作用之后是否依旧相等。如果是保持相等的，则证明一个元素不会被映射到两个不同的元素上，从而完成第一个问题的验证。这里要提到的是，对于商群这样，元素是陪集这样集合的情况，还有必要验证一个陪集内的元素是否能被从商群出发的同态映射到定义域上的同一个元素中。不过这一点也可以归结为对第二个问题的验证，即同态是否能够保持对象间的结构。\n针对商群这样的结构，如果一个陪集内的元素被映射到了不同的定义域上的元素，那么就证明这样的映射并不能保持商群的元素，即陪集，内部所有元素等价的条件。除了这样的结构性质外，另一个常见的结构即定义好的代数运算，或者说是同态区别于函数的性质。我们也可以说，先对元素做运算再通过同态映射，其结果应该等同于先做完同态的映射，再在陪域内进行运算。这也许可以被称为同态和运算之间的“交换性”。\n虽然第三和第四个问题一般不会出现，但是在从零开始构造一个同态时，对它们的验证依旧是有必要的。特别是第四条，即对于陪域的验证，我们要求同态的像必须是陪域的子集，否则这个映射就不是良定义的。从上面的一系列验证过程中，我们也能够看到对于这一条件的验证。\n$\\delta$ 的定义与验证 这个 $\\delta$ 的定义算是证明蛇引理过程中的一个难点吧，这也是蛇引理的关键一步，也是“蛇”这个字的由来吧。我们应该如何从一个核映射到余核呢？从交换图上来看，是需要从 $C$ 中的子集映射到 $A'$ 上的等价类的。这应该如何是好呢？\n$\\delta$ 应该是什么样的 好消息是：我们对同态 $g$ 以及 $f'$ 是有一些说法的：$g$ 一定是满态，而 $f'$ 则一定是单态。这是根据这两个正合列的性质，或者说是正合列中 $C$ 点与 $A'$ 点的性质而得到的。我们前面也有提到这个结论，这里简单说明一下：由于 $C$ 映射到平凡群的同态一定是一个满态，这个满态的核就一定是 $C$ 本身；由于正合的要求，$g$ 的像就必须是 $C$ 了，也就是 $g$ 是满的；由于 $0$ 到 $A'$ 作为同态必须也只能映射到 $A'$ 中的单位元，所以这个映射的像就只能是那个单位元自己形成的平凡群；由于正合列的性质，$f'$ 的核则只能是这个平凡群，也就是说它是一个单态。\n这两个信息对于 $\\delta$ 的构造是必须的，否则我们没有一个很好的从 $C$ 一路走回 $A'$ 的方法。当然，有了上面的提示，我们很自然想到，这个 $\\delta$ 的构造应该是什么样的。它会从 $C$ 出发，从 $g$ 反着走到 $B$ 点，在经过 $\\beta$ 的映射之后，再通过 $f'$ 反过来到 $A'$ 上。我们来更细致地考察这个映射构造过程的每一步吧。\n$\\delta$ 的具体构造 我们的 $\\delta$ 是从 $\\Ker \\gamma$ 开始的，自然我们就取 $C$ 中的子集 $\\Ker \\gamma$ 里的一个元素 $c$。得益于满态的性质，我们一定是可以在 $B$ 中找到某个元素 $b$，使得 $g(b) = c$ 的。\n注意到我们对 $c$ 的选取，这个 $c$ 是在 $\\Ker \\gamma$ 内的，所以就会有：$\\gamma(g(b)) = 0$。再根据图的交换性，我们就有了 $g'(\\beta(b)) = \\gamma(g(b)) = 0$，也就是说 $\\beta(b) \\in \\Ker g'$。\n这时我们要根据正合列的性质来继续向 $A'$ 推进。由于正合性，我们有 $\\Img f' = \\Ker g'$，因此 $\\beta(b) \\in \\Img f'$，而既然 $\\beta(b)$ 出现在了 $f'$ 的像中，就一定会有一个 $A'$ 中的元素，我们记作 $a'$，一定会在 $f'$ 映射后到我们之前拿到的 $\\beta(b)$ 上。\n最后，由于 $f'$ 是单态，上面说的那个 $a'$ 在此时是唯一确定的。而这个唯一确定的元素在 $A'$ 对应的商群 $\\Coker \\alpha$ 中自然也是属于唯一的一个等价类的。\n我们现在回溯一下这个过程：我们取了 $\\Ker \\gamma$ 中的一个元素 $c$，它一定对应了某个 $B$ 中的 $b$。但要注意的是，因为仅有 $g$ 是满态的要求，这个 $b$ 可能不唯一。接着我们从这个 $b$ 自然地得到了 $\\beta(b)$，而它则有 $A'$ 中存在且唯一的对应元素 $a'$。这个元素，很自然地，在 $\\Coker \\alpha$ 中就有了唯一的等价类。总结下来就是：每个 $\\Ker \\gamma$ 中的元素经过我们上面的映射过程，都是可以得到 $\\Coker \\alpha$ 中的等价类的。\n然而这给了我们一个亟待解决的问题：这个映射链最后是需要组合成一个同态 $\\delta\\vcentcolon\\\\,\\Ker \\gamma \\to \\Coker \\gamma$ 的，而作为一个同态，每个定义域上的元素能且只能对应到陪域上的唯一一个元素。然而从我们刚刚的映射链过程来看，由于 $c$ 在 $B$ 中对应的元素个数是不确定的，虽然每个 $b$ 中元素都能对应到唯一的 $A'$ 中的等价类，但可能所有满足 $g(b) = c$ 的 $b$ 所能对应的 $A'$ 的等价类是不一样的。用更形式化的语言来讲，我们现在要解决，或者要证明的问题就是：\n设存在两个 $B$ 中的元素 $b,\\\\,b^* $，满足 $g(b) = g(b^* )$，求证这两个元素在经过映射链 $\\beta$ 与 $f'$ 的反向作用（我们以后就记这个反向作用为 $(f')^{-1}$ 了，这个记号是合理的，我们后面会提到）后得到的元素在 $A'$ 中是等价的，其中等价关系由 $a' - a'^* \\in \\Img \\alpha$ 给出。\n如果这个命题得到了验证，那么就说明这条路走得通，一个唯一的 $\\Ker \\gamma$ 中的 $c$，不论它在 $B$ 中对应有多少个元素，最后都会在 $A'$ 里对应到同一个等价类里，也就是在 $\\Coker \\alpha = A'/\\Img \\alpha$ 中有唯一的一个元素与之对应。这样就验证了 $\\delta$ 的定义。\n$\\delta$ 构造的验证 我们开始上面这个命题的证明吧。这里需要注意的第一个问题，同时也是指明了我们应该朝着哪个方向前进的信息，是：我们最后要得到的内容是和 $\\Img \\alpha$ 有关的。为此，我们一定是要用到 $\\alpha$ 这个映射的相关信息的，而这也不可避免地涉及到 $A$ 这个群。因此，我们得想办法先把 $g(b) = g(b^* )$ 这个信息反映到 $A$ 这个群内。\n好消息是，通过同态的性质，我们很容易就可以得到：$g(b-b^* ) = 0$，也就是说 $b-b^* \\in \\Ker g$。我们故技重施，得到 $b-b^* \\in \\Img f$，这让我们可以讨论已有的 $g(b)$，$g(b^* )$ 与 $A$ 中的元素的关系，即我们一定可以找到至少一个 $A$ 中的元素 $a$，使得 $f(a) = b-b^* $。\n我们通过图的交换性可以得知，$\\beta f (a) = \\beta (b-b^* ) = f'\\alpha(a)$。这时我们需要用到关于单态的一个性质。这个性质我们之前也提过，甚至这是关于单态的定义：具有左逆的态射为单态。如此一来，我们给第二个等号的左右两边同时作用上 $f'$ 的左逆。注意到左逆的定义，我们就有了：\n$$(f')^{-1} \\beta (b-b^* ) = (f')^{-1} f'\\alpha(a) = \\alpha(a)$$回忆我们之前所提到的，$(f')^{-1}$ 由于 $f'$ 是单态，所以能唯一地确定 $B'$ 中元素在 $A'$ 中所对应的元素；$\\beta$ 本身就是一个良定的同态，因此，以上这些就说明了这样一个事实：$(f')^{-1} \\beta$ 是一个良定的从 $B$ 到 $A'$ 的同态，且 $(f')^{-1} \\beta (b-b^* )$ 作为 $A'$ 的元素，它同时也是位于 $\\Img \\alpha$ 的。\n那么此时我们再使用 $(f')^{-1} \\beta$ 作为同态的性质，有：\n$$(f')^{-1} \\beta (b-b^* ) = (f')^{-1} \\beta (b)-(f')^{-1} \\beta(b^* ) \\in \\Img \\alpha$$至此，我们对上面进行总结：设有两个 $B$ 中的元素 $b$ 与 $b^* $，它们满足 $g(b) = g(b^* )$，则我们可以在 $A'$ 中找到这样对应的两个元素 $(f')^{-1} \\beta (b)$ 和 $(f')^{-1} \\beta (b^* )$，使得这两个元素在我们已经定义好的等价关系下是等价的。\n如此，我们的这个命题得证。进而，我们就成功地构造出了良定的 $\\delta$：\n$$ \\begin{align*} \\delta\\vcentcolon\\space\\Ker\\gamma\u0026\\to\\Coker\\alpha\\\\ c\u0026\\mapsto (f')^{-1} \\beta (b) + \\Img\\alpha, \\end{align*} $$其中，$c \\in \\Ker \\gamma$ 且 $c = g(b)$。\n一些旁注 我们可以看到，为了构造 $\\delta$，我们必须利用 $g$ 的满态性质以及 $f'$ 的单态性质，还需要利用交换图右侧部分的交换性。而在验证其定义的过程中，我们同样必须利用 $g$ 的满态性质以及 $f'$ 的单态性质（这里 $g$ 的满态性是直接作为命题的前置条件而存在的），然后还需要利用交换图左侧部分的交换性。$\\delta$ 的构造充分利用了我们已有的所有条件，因此算是这个引理证明比较困难的一部分，同时也是关键的部分。而关于前面 $f|$，$\\hat{f'}$ 的构造与验证过程中，都只用到了图的交换性，并没有利用 $g$ 是满态以及 $f'$ 是单态的条件。\n我们再来关注 $\\delta$ 的构造过程。我们从 $\\Ker \\gamma$ 中选择了任意的元素 $c$，它在 $g$ 的诸多原像 $b$ 们由于交换图的交换性质，在经过 $\\beta$ 的映射到达 $B'$ 后都是位于 $\\Img f'$ 上的。由于都在 $\\Img f'$ 上，讨论 $(f')^{-1}$ 自然也是有意义的。\n另外我们结合后面对该定义的验证过程，可以看到这个元素 $c$ 是怎么一步步抵达 $\\Coker \\alpha$ 的：这个元素 $c$ 在 $B$ 中的原像可能包含一个或者多个元素，这些元素经过 $\\beta$ 映射后都存在于 $\\Img f'$ 中，这个过程里可能有些元素映射到了 $B'$ 里的同一个元素上，也有可能并不是这样，不过这里没有关系。接着它们在单射 $(f')^{-1}$ 的影响下，各不相同地映射到 $A'$ 上，最后分类到同一个等价类中（纯符号地讲，其实就是加上了 $\\Img \\alpha$）。\n验证过程说明了，只要都是在 $c\\in\\Ker\\gamma$ 的原像里，注定都是会被分到同一个 $\\Coker\\alpha$ 的等价类里面的。然而，这个分类过程其实是在最后，在得到 $(f')^{-1}\\beta(b)$ 后才完成的。好在，由于左侧块的交换性，在从 $c$ 找 $B$ 中原像的过程中，所有符合条件的 $b$ 之间的等价关系（指最终分类到同一 $\\Coker \\alpha$ 的元素中）早已被确定好了。\n这里再重申一下单态和满态的性质。单态意味着若陪域中的元素的原像要么非空，要么则只有一个元素，同时单态拥有唯一的左逆；满态则意味着陪域中的所有元素的原像都不是空集。我们利用满态原像的性质得出，$\\Ker \\gamma$ 中的元素一定可以在 $B$ 中找到对应元素，而又通过单态的性质得出，在已知 $\\beta(b)$ 存在于 $f'^* $ 的条件下，$A'$ 中有且只有唯一一个元素与之对应，且通过给 $\\beta (b-b^* )$ 作用左逆得出它存在于 $\\alpha$ 的像内。\n$\\operatorname{Ker} \\beta$ 处正合性的证明 前面我们证明了我们要验证的点的 1-5，借此我们成功地将这些核呀余核呀之类的连起来了。然而，要成为正合列，它需要在中间的每个点上都是正合的。现在已经到手的链条是这样的：\n$$\\Ker \\alpha \\xrightarrow{f|} \\Ker \\beta \\xrightarrow{g|} \\Ker \\gamma \\xrightarrow{\\delta}\\Coker \\alpha \\xrightarrow{\\hat{f'}}\\Coker \\beta\\xrightarrow{\\hat{g'}} \\Coker \\gamma $$这条链条的中间一共有 $\\Ker\\beta$，$\\Ker\\gamma$，$\\Coker\\alpha$，$\\Coker\\beta$ 四个点，我们需要分别验证它们两边的态射在它们自身处都是正合的。鉴于 $\\delta$ 所连接的 $\\Ker\\gamma$，$\\Coker\\alpha$ 处的正合性会比较复杂（由于 $\\delta$ 比较复杂），我们先验证 $\\Ker \\beta$ 处的正合性，再验证 $\\Coker \\beta$ 的，最后到剩下的两个。\n为了验证正合性，我们需要验证：$\\Img f| = \\Ker g|$。由于链条上的都是态射，这个等号只需要集合意义上的成立即可在阿贝尔群意义上同样成立，而证明两个集合相等最常用的方法之一便是验证相互包含：$\\Img f| \\subseteq \\Ker g|$ 且 $\\Img f| \\supseteq \\Ker g|$，而为了实现这样的目的，我们会从待验证命题中较小的集合中取点，证明它一定在较大的那个集合中，即可验证这样的子集关系了。\n在我们开始之前，我们把 $\\Ker \\beta$ 所在短链条写出来，方便后面观察：\n$$ \\Ker \\alpha \\xrightarrow{f|} \\Ker \\beta \\xrightarrow{g|} \\Ker \\gamma $$那我们就开始吧，先从 $\\Img f| \\subseteq \\Ker g|$ 开始。\n证明 $\\operatorname{Im} f| \\subseteq \\operatorname{Ker} g|$ 我们就取 $\\Img f|$ 中的一个元素 $b$，根据像的性质，一定有一个 $a\\in \\Ker\\alpha$ 满足 $f|(a) = b$。而 $f|(a)$ 实际上就是 $f$ 在 $\\Ker \\alpha$ 上的一个限制，所以也就有 $f(a) = b$；同时，我们取 $g|(b) = c$，由于 $g|$ 也是 $g$ 在 $\\Ker\\beta$ 上的限制，所以 $g|(b) = g|(f(a)) = g(b) = g(f(a)) = c$。此时，我们考虑原正合列：\n$$A\\xrightarrow{f} B \\xrightarrow{g} C,$$ 我们得到：$f(g(a)) = 0$。由此就得到了 $c = 0$。由于这个结论不依赖于 $b$ 的选取方式，我们就以这种方式得到了 $g|(b) = c = 0$ 恒成立，进而 $b \\in \\Ker g|$。而这，正说明了这样一件事：如果一个 $\\Ker \\beta$ 中的元素 $b$ 在 $\\Img f|$ 里，那么它就一定在 $\\Ker g|$ 中。这就证明了 $\\operatorname{Im} f| \\subseteq \\operatorname{Ker} g|$。\n这个命题的得证完全依赖与原正合列的性质，且通过证明该命题，我们得知上面的短链条已经是一个链复型了。下面我们需要证明的就是另一个方向的包含性，也就是：\n证明 $\\operatorname{Im} f| \\supseteq \\operatorname{Ker} g|$ 我们故伎重施，取 $\\Ker g|$ 中的一个元素，也叫它 $b$ 好了。既然它在 $g|$ 的核中，那么就有 $g|(b) = 0$，我们此时把 $b$ 放到 $B$ 集合中，此时 $g|(b) = g(b) =0$，说明 $b\\in\\Ker g$。而根据原链条的正合性，我们有 $\\Ker g = \\Img f$，因此 $b\\in\\Img f$。\n然而到此依然不能证明 $b\\in\\Img f|$，因为 $\\Img f|$ 是 $\\Img f$ 的子集，无法从一个元素位于更大的集合中来判定它一定在更小的集合里。我们需要更多的信息。然而既然 $b\\in\\Img f$，我们就可以找到 $A$ 中的一个元素 $a$，使得 $f(a) = b$。又因为根据 $b$ 的取法，它在 $g|$ 的定义域 $\\Ker \\beta$ 上，一定就有 $\\beta(b) = \\beta(f(a)) = 0$。此时，我们根据交换图的性质，可以得到 $f'(\\alpha(a)) = \\beta(f(a)) = 0$。\n这样的结果有什么用处呢？回忆 $f'$ 的性质，它是一个单态，因此就一定有左逆 $(f')^{-1}$。我们给上式的左右两边同时左乘（左作用）上 $(f')^{-1}$，就得到：$(f')^{-1}f'(\\alpha(a)) = (f')^{-1}(0) = 0 = \\alpha(a)$。观察最后一个等号，这又说明了 $a \\in \\Ker \\alpha$。\n我们整理一下当前得到的信息，我们有 $\\Ker g|$ 中的一个元素 $b$，它在 $A$ 中有一个对应的元素 $a$，我们又得到了这个元素 $a\\in\\Ker\\alpha$。请注意 $f|$ 的定义域正是 $\\Ker\\alpha$。这就说明了：$b$ 在 $A$ 中对应的元素一定也在 $\\Ker\\alpha$ 里，也就是 $b\\in\\Img f|$。同样，由于 $b$ 的选取不依赖于任何的额外条件，我们就证明了 $\\operatorname{Im} f| \\supseteq \\operatorname{Ker} g|$。至此，联合上一小节的结论，我们得出结论：该短链在 $\\Ker\\beta$ 处正合。\n一点注解 可以看到，前半部分的证明非常简单，直接借助原正合列性质即可，这样直接就证明了这个链条是一个链复型；而后面为了证明正合性的部分则需要使用到 $f'$ 是单态的条件。也许我们在证明 $\\Coker \\beta$ 处的正合性时，也会遇到这样的特点？我们直接开始吧。\n$\\operatorname{Coker} \\beta$ 处正合性的证明 和上面一样，我们证明这样的正合性，会以对应映射的像与核相互包含为切入点进行。这次我们还是先证明这个短链：\n$$ \\Coker \\alpha \\xrightarrow{\\hat{f'}} \\Coker \\beta \\xrightarrow{\\hat{g'}} \\Coker \\gamma $$是一个链复型（$\\Img \\hat{f'} \\subseteq \\Ker \\hat{g'}$），再证明在中间的 $\\Coker \\beta$ 处是正合的（$\\Img \\hat{f'} \\supseteq \\Ker \\hat{g'}$）。\n证明 $\\operatorname{Im} \\hat{f\u0026rsquo;} \\subseteq \\operatorname{Ker} \\hat{g\u0026rsquo;}$ 照旧我们选择 $\\Img \\hat{f'}$ 中的一个元素，由于 $\\Img \\hat{f'} \\subseteq \\Coker \\beta$，这个元素将会是等价类 $\\hat{b'} = b' + \\Img \\beta$。我们想要证明，在前面这个条件下的任何 $\\hat{b'}$ 都会被 $\\hat{g'}$ 映射到 $\\hat{0} \\in \\Coker \\gamma$ 上。既然 $\\hat{b'}\\in\\Img\\hat{f'}$，我们就可以取到 $\\Coker \\alpha$ 中的一个元素 $\\hat{a'}$，使得 $\\hat{f'}(\\hat{a'}) = \\hat{b'}$。\n此时，请回忆我们是如何定义 $\\hat{f'}$ 的：我们直接借助了原有的同态 $f'$，使得具有了这样的性质：\n$$\\hat{f'}(\\hat{a'}) = \\hat{f'}(a'+\\Img \\alpha) = f'(a') + \\Img \\beta = b'+\\Img \\beta = \\hat{b'} = \\widehat{f'(a)}.$$带着这条性质，我们观察到：若是对 $\\hat{b'}$ 作用上 $\\hat{g'}$，就有：\n$$\\hat{g'}(\\hat{b'}) = \\hat{g'}(\\hat{f'}(\\hat{a'})) = \\hat{g'}(\\widehat{f'(a')}) = \\widehat{g'(f'(a'))} = \\hat{0},$$其中，第一个等式是我们一开始取到的 $\\hat{f'}(\\hat{a'}) = \\hat{b'}$，第二个等式是利用了我们上面给出的 $\\hat{f'}$ 的性质，而第三个等式则是同样，再次利用 $\\hat{g'}$ 它与 $\\hat{f'}$ 同样的性质（因为定义是类似的）。最后，第四个等式则利用了原正合列的性质，有 $g'(f'(a')) = 0$。由此，我们再一次地，像上面证明 $\\Ker \\beta$ 处正合的第一部分一样，证明了 $\\Coker \\beta$ 处正合的第一部分：无论 $\\hat{b}$ 如何取，只要它位于 $\\Img \\hat{f'}$ 中，就一定位于 $\\Ker \\hat{g'}$ 中。\n证明 $\\operatorname{Im} \\hat{f\u0026rsquo;} \\supseteq \\operatorname{Ker} \\hat{g\u0026rsquo;}$ 下来我们就证明 $\\Coker \\beta$ 处正合的第二部分。我们希望能从 $\\Ker \\hat{g'}$ 中取到的元素能够以某种方式放到 $B'$ 中去，然后借助原正合列的性质去取得在 $A'$ 中或者 $\\Coker \\alpha$ 中的一些结论。因此依旧，我们取 $\\hat{b'} \\in \\Ker \\hat{g'} \\subseteq \\Coker \\beta$。由于 $\\hat{b'}$ 在 $\\hat{g'}$ 的核中，我们有 $\\hat{g'}(\\hat{b'}) = \\hat{0}$。我们把这个运算拆开，有\n$$\\begin{align*} \\hat{g'}(\\hat{b'}) \u0026= \\hat{g'}(b' + \\Img \\beta) \\\\ \u0026= g'(b') + \\Img \\gamma\\\\ \u0026= \\hat{0} = \\Img \\gamma, \\end{align*}$$ 由此可以得知，$g'(b') \\in \\Img \\gamma$。然而，这和我们之前所做到的并不一样：$g'(b')$ 只是位于 $\\Img \\gamma$ 中，它并不等于 $0$。不过，我们依旧可以借助这个条件。\n从 $g'(b') \\in \\Img \\gamma$ 条件中可以得知，一定有一个 $c \\in C$ 使得 $\\gamma(c) = g'(b')$。而此时，又由于 $g$ 是满射，我们知道一定有一个 $b \\in B$ 使得 $g(b) = c$。把它们组合起来，就有了：$\\gamma(g(b)) = g'(b')$，此时根据交换图的性质，就有了 $g'(b') = \\gamma(c) = g'(\\beta(b))$。我们现在把最右边这项移项到最左边，就有了 $g'(b'-\\beta(b)) = 0$。\n通过上面的方式，我们成功构造出了一个位于 $g'$ 的核中的元素。因此，直接借助短链\n$$A'\\xrightarrow{f'} B' \\xrightarrow{g'} C'$$是正合的这一条件，就有：$b'-\\beta(b) \\in \\Ker g' = \\Img f'$。\n我们现在再看看这个新构造出的，位于 $\\Img f'$ 中的这个元素的等价类是什么样的。我们把 $\\hat{b'}$ 写成 $b' + \\Img \\beta$ 的形式，此时又由于 $\\beta(b)$ 自然就在 $\\Img \\beta$ 中：$\\beta(b) \\in \\Img \\beta$，我们就有 $b'+\\Img \\beta = b' - \\beta(b) + \\Img \\beta$。这意味这我们新构造出的这个更好的元素，它与我们一开始选择的 $b'$ 是等价的，都可以作为 $\\hat{b'}$ 的代表元。\n现在我们现在想知道的是，对于等价类 $\\hat{b'} = b'-\\beta(b) + \\Img \\beta$，是否一定存在 $\\Coker \\alpha$ 中的某个等价类，使得它在 $\\hat{f'}$ 的作用下就是我们已有的 $\\hat{b'}$。为此，我们回到刚刚构造出的，位于 $\\Img f'$ 中的这个元素 $b'-\\beta(b)$。既然在 $f'$ 的像内，就一定有一个或者几个元素 $a' $ 满足 $f'(a' ) = b'-\\beta(b)$。我们知道，$a'$ 在 $A'$ 中的等价类是 $\\hat{a'} = a'+\\Img \\alpha$。而该等价类经过 $\\hat{f'}$ 的作用后得到的结果是：\n$$ \\hat{f'}(a'+\\Img\\alpha) = f'(a') + \\Img\\beta = b'-\\beta(b) + \\Img\\beta = b'+\\Img\\beta. $$好，我们现在总结我们已有的信息。我们从一个任意的 $\\Coker \\beta$ 中的元素 $\\hat{b'}$ 出发，构建出了一个位于 $\\Ker g'$ 中的元素 $b'-\\beta(b)$，其中 $b$ 是直接根据 $b'$ 确定的。这个新的元素所处的等价类就是我们之前挑选的等价类。另外，我们从构造的元素出发，得到了若干个位于 $A'$ 中的元素 $a'$。它所在的等价类则是 $\\hat{a'}\\in\\Coker \\alpha$。现在，我们可以注意到：我们只挑出来了一个 $\\hat{b'}$，剩下的所有的东西都是由它以及它相关的量决定的。也就是说，$\\hat{b'}$ 决定了这些 $\\hat{a'}$。而经过上面式子的验证，有 $\\hat{f'}(\\hat{a'}) = \\hat{b'}$。这就说明了：任取一个位于 $\\hat{g'}$ 的核内的元素，我们都能确定出一些 $\\hat{a'}$，它们全都是满足 $\\hat{f'}(\\hat{a'}) = \\hat{b'}$ 的。\n这就证明了我们想要的结论：任意一个 $\\hat{g'}$ 的核内的元素都是 $\\Coker \\alpha$ 中元素的像，也就是 $\\operatorname{Im} \\hat{f'} \\supseteq \\operatorname{Ker} \\hat{g'}$。再结合上一节证明的内容，我们就证明了这个链条在 $\\operatorname{Coker} \\beta$ 处是正合的。\nCallback 可以看到，对于 $\\operatorname{Coker} \\beta$ 处正合性的证明，是和 $\\operatorname{Ker} \\beta$ 处正合性的证明很类似的。不过我个人感觉，后面证明的这个，相比于 $\\operatorname{Ker} \\beta$ 处正合性的证明是要难一些的。这可能是因为需要手动构造一个 $b'-\\beta(b)$ 来满足应用正合性的条件，以及对余核的性质的不熟悉吧。总之，顺利地证明了。而下面要证明的，就和我们自己构造出的 $\\delta$ 相关了。\n$\\operatorname{Ker} \\gamma$ 处正合性的证明 接下来我们尝试证明短链\n$$\\Ker\\beta\\xrightarrow{g|}\\Ker\\gamma\\xrightarrow{\\delta}\\Coker\\alpha $$在中间一点处的正合性。我们依旧采取原来的策略。\n证明 $\\operatorname{Im} g| \\subseteq \\operatorname{Ker} \\delta$ 照旧取一个 $\\Img g|$ 中的元素 $c\\in C$，我们希望能证明 $\\delta(c) = \\hat{0}$ 恒成立，这样一来自然就有 $\\Img g| \\subseteq \\Ker \\delta$ 了。\n既然 $c\\in\\Img g|$，就会有一个元素 $b\\in \\Ker\\beta$ 使得 $g|(b) = c$。又由于这个 $b$ 是在 $\\beta$ 的核中的，因此 $\\beta(b) = 0$。此时我们再作用上 $(f')^{-1}$，由于 $f'$ 是单态，所以把 $0\\in B'$ 作用上它的左逆只能得到唯一的元素 $0\\in A'$，而这对应的 $\\Coker \\alpha$ 中作为元素等价类正是 $\\hat{0}$。\n注意到我们上面的步骤，实际上就是在对 $c$ 作用 $\\delta$。因此，我们得到了我们想要的结论：$\\delta(c) = \\hat{0} \\in \\Coker \\alpha$，也就证明了本命题。\n证明 $\\operatorname{Im} g| \\supseteq \\operatorname{Ker} \\delta$ 我们还是取 $c\\in \\Ker \\delta$。因此，$\\delta(c) = \\hat{0} = 0 + \\Img \\alpha$。我们回顾 $\\delta$ 的构造，或者说从 $c\\in\\Ker\\gamma$ 出发抵达 $\\Coker \\alpha$ 的过程，如果 $\\delta(c) = \\Img \\alpha$，那么 $c$ 就一定会经理这样的过程：它首先在 $B$ 中找到原像中的元素 $b$ 们，然后把这些元素打包被 $\\beta$ 映射到 $B'$ 上，此时由于我们的构造，所有的 $\\beta(b) \\in \\Img f'$。此时就一定有许多对应的 $a' \\in A'$ 满足 $f'(a') = \\beta(b)$。最后由于 $\\delta(c) = \\hat{0} = \\Img \\alpha$，必须要有 $a' \\in \\Img\\alpha$（注意，不是 $a' = 0$，因为只需属于 $\\Img \\alpha$ 即可满足条件）。\n经过上面的过程，我们得到了这样和原条件等价的条件，即必须至少有一个 $a'\\in\\Img\\alpha$，它由 $f'(a') = \\beta(b)$ 确定，而 $\\beta(b)$ 中的 $b$ 则从 $c$ 的原像中找到。\n因此，我们先关注这个集众多条件于一身的 $a'$，由于其处在 $\\alpha$ 的像内，就一定有 $a\\in A$ 使得 $\\alpha(a) = a'$。此时我们从 $a$ 出发，利用交换图的性质，就有 $f'(\\alpha(a)) = \\beta(f(a))$。注意到我们 $a'$ 上的两个条件，将它们带入这个关系，就得到 $f'(\\alpha(a)) = f'(a') = \\beta(b) = \\beta(f(a))$。\n我们关注最后一个等号，它说明了这样的问题：$\\beta(b) = \\beta(f(a))$，则有 $\\beta(b-f(a)) = 0$，也就是 $b-f(a)$ 是属于 $\\Ker \\beta$ 的。回顾这个元素的构造过程，$b$ 是任意一个在 $c$ 的原像中的元素，这里的 $a$ 是根据 $a'$ 任意选取的在原像内的元素，$a'$ 又是 $\\beta(b)$ 在其原像内任意选取的元素。我们看到，$b$ 和 $a$ 都是除了利用 $c\\in\\Ker\\gamma$ 和交换图性质以外任意选取的符合条件的元素，如果对它作用 $g|$ 之后能够回到 $c$，就说明这样的一件事：$c$ 的原像内元素不管怎么选，总会以某些形式回到 $\\Ker\\beta$，进而映射到 $g|$ 的原像内。\n幸运的是，这很好验证：$g|(b-f(a)) = g(b-f(a)) = g(b) - g(f(a)) = g(b) = c$。第一个等号来自 $g|$ 的定义，当将之放入 $B$ 中考虑是就可以使用 $g$ 替代；第二个等号来自 $g$ 是同态的保运算性质；第三个等号来自链复型的要求；而最后一个等号就是我们一开始选取 $b$ 的方式。这正是说明了我们前面讲的：$c$ 原像中的 $b$，会以 $b-f(a)$（其中 $a$ 也是由 $c$ 间接决定的）的形式出现在 $\\Ker\\beta$ 里，最后被 $g|$ 映射回 $c$，而这就证明了任何一个 $\\Ker\\gamma$ 中的元素，其都是 $\\Img g|$ 中的元素，也就证明了本命题。再结合上一条命题的证明，我们成功得到了这条链条在 $\\Ker\\gamma$ 上的正合性。\n一点绕过的弯路 第二个命题的证明其实没有特别顺利。这主要是因为对 $\\delta$ 构造的理解不够导致的，或者说太过希望 $\\delta$ 有一个好的显式表达而造成的。在取到 $a' = \\alpha(a)$ 的时候，我希望直接得到 $b$ 是一定属于 $\\Ker\\beta$ 的结论，虽然感觉上会有和之前类似的从 $b$ 出发构造的属于 $\\Ker\\beta$ 的元素一样的桥段，但由于依赖 $\\delta$ 的显示表达，我卡在了 $(f')^{-1}$ 只是左逆而非右逆这一点上。因为如果按照我之前的思路，就一定要遇到 $f'((f')^{-1}(\\beta(b)))$ 这样的元素。而由于 $(f')^{-1}$ 并非右逆，这个式子是无法约化到 $\\beta(b)$ 上的。\n可以看到，最后解决这个问题的方法，是直接采用 $\\delta$ 在构造过程中的表现，在从 $B'$ 至 $A'$ 的过程中选择使用 $\\beta(b)$ 一定在 $f'$ 的像中的条件，从而绕过了这个问题。虽然说用形式化的思路，比如限定这里 $f'$ 的范围，从而让做出一些限定条件的 $f'$ 成为同构来解决这个问题，但这始终不是个很好的方案。\n另外，就是 $b$ 一定属于 $\\Ker\\beta$ 的错觉。实际上，$b$ 可以不在 $\\Ker\\beta$ 中，只需要 $b$ 和某个经过 $g|$ 映射后等于 $0$ 的东西结合之后位于 $\\Ker\\beta$ 即可。当然我们现在知道，这个东西就是从 $c$ 一路确定下来的 $f(a)$ 了。\n好了，我们开始准备证明蛇引理主体的最后一部分吧：$\\Coker\\alpha$ 处的正合性。\n$\\operatorname{Coker} \\alpha$ 处正合性的证明。 我们不多废话，还是先证明 $\\Img \\delta \\subseteq \\Ker \\hat{f'}$，后证明 $\\Img \\delta \\supseteq \\Ker \\hat{f'}$。对应的短链是：\n$$\\Ker \\gamma \\xrightarrow{\\delta} \\Coker \\alpha \\xrightarrow{\\hat{f'}} \\Coker \\beta$$证明 $\\operatorname{Im} \\delta \\subseteq \\operatorname{Ker} \\hat{f\u0026rsquo;}$ 取 $\\Img \\delta$ 中的元素 $\\hat{a'} \\in \\Coker\\alpha$，通过证明 $\\hat{f'}(\\hat{a'}) = \\hat{0} = \\Img \\beta \\in \\Coker\\beta$ 即可证明本命题。由于 $\\hat{f'}$ 的定义，我们有 $\\hat{f'}(\\hat{a'}) = \\widehat{f'(a')} = f'(a') + \\Img \\beta = 0 + \\Img \\beta$，也就是说只需要证明 $f'(a') \\in \\Img \\beta$，我们就能证明本命题。由于 $\\hat{a'}\\in\\Img\\delta$，一定有一个 $\\Ker\\gamma$ 中的元素 $c$ 满足 $\\delta(c) = \\hat{a'}$。\n我们这时使用 $\\delta$ 的定义：若 $\\delta(c) = \\hat{a'}$，就说明一定有一个 $b\\in B$，这个 $b$ 在 $c$ 的原像中，而且 $\\beta(b) = f'(a')$。啊，这不就是我们要的结论吗？既然 $f'(a') = \\beta(b)$ 了，那自然 $f'(a')$ 就在 $\\beta$ 的像里面了呀。就这样，我们证明了这个命题。\n证明 $\\operatorname{Im} \\delta \\supseteq \\operatorname{Ker} \\hat{f\u0026rsquo;}$ 还是一样，取 $\\Ker \\hat{f'}$ 中的元素 $\\hat{a'} \\in \\Coker\\alpha$。既然在 $\\Ker\\hat{f'}$ 里，我们就有 $\\hat{f'}(\\hat{a'}) = \\hat{0} = \\Img \\beta \\in \\Coker\\beta$（我们是不是刚刚见过这句，那就快进吧），进而 $f'(a') \\in \\Img \\beta \\subseteq B'$。那么，既然是在 $\\Img \\beta$ 中的，我们就取所有满足 $\\beta(b) = f'(a')$ 的 $b\\in B$。此时我们用交换图右侧的交换性，给这个式子左右两边左作用上 $g'$，就得到\n$$0 = g'(f'(a')) = g'(\\beta(b)) = \\gamma(g(b)),$$其中第一个等号来自交换图下面的链复型的性质，第二个等号就是作用 $g'$ 的结果，第三个等号则是交换图的性质。观察这个式子的左右两端，不难根据核的定义得到结论：只要是满足条件的 $b$，$g(b)$ 就全都在 $\\Ker \\gamma$ 中。\n到这里其实已经证明完了，因为我们仅从 $\\hat{a'}\\in\\Ker\\hat{f'}$ 出发，仅利用交换图就得到了所有满足条件的 $b$，证明了它们全都会在 $g$ 的作用下进入到 $\\Ker\\gamma$ 里，也就是说，$\\hat{a'}$ 确定了且一定对应到了 $\\Ker\\gamma$ 中的某些元素。这就说明了 $\\Img \\delta \\supseteq \\Ker \\hat{f'}$。\n我们写详细点，多写几步，那么让 $g(b) = c$，根据 $\\delta$ 的定义（或者作用过程），对 $c$ 作用上 $\\delta$ 后，将会先有若干个满足 $g(b) = c$ 的 $b$，紧接着这些 $b$ 将被 $\\beta$ 映射到 $B'$ 里，最后从 $A'$ 里找到对应的原像，用它们生成一个等价类。而由于我们上面的过程，最后找到的 $A'$ 中的元素所生成的等价类，就是我们一开始的 $\\hat{a'}$。这就说明 $\\delta(c) = \\hat{a'}$。由于 $\\hat{a'}$ 是我们随意取的在 $\\Ker\\hat{f'}$ 中的元素，都能得到 $\\delta(c) = \\hat{a'}$，因此 $\\hat{a'}$ 确实就存在于 $\\Img \\delta$ 里。\n至此，我们证明了该命题，并结合上一个证明的命题，一起证明了链条在 $\\Coker \\alpha$ 处是正合的。\n虚线箭头的相关证明 其实上面已经证明完了蛇引理的主体部分。下来我们将证明最后的两个命题：若交换图中的上下两个链条都是短正合列（也就是有 $0\\to A$ 和 $C' \\to 0$ 成立），那么在从该交换图中得到的正合列的头尾就可以补上 $0\\to\\Ker\\alpha$ 以及 $\\Coker\\gamma\\to 0$，使得 $\\Ker\\alpha$ 以及 $\\Coker\\gamma$ 处正合。\n我们还是先证第一个，也就是从 $0\\to A$ 的存在可以得到 $0\\to\\Ker\\alpha$ 且 $\\Ker\\alpha$ 处正合。\n证明 $\\operatorname{Ker}\\alpha$ 处的正合性 若有 $0\\to A$，则根据正合列的性质（或者直接看交换图下面的那个链条），我们就有 $f$ 也是个单态。因此，对 $f$ 做出在 $\\Ker\\alpha$ 上的限制得到的 $f|$ 并不会改变它是单态的事实（只缩小了定义域）。此时，由于 $f|$ 是单态，它的核根据单态的性质，就一定是一个平凡群。\n此时我们补上 $0\\to\\Ker\\alpha$，由于它一定是单态，它的像只能是平凡群。这自然地就证明了链条在 $\\Ker\\alpha$ 处是正合的。\n证明 $\\operatorname{Coker}\\gamma$ 处的正合性 如果有 $C'\\to 0$，则根据正合列的性质，我们知道 $g'$ 就必须是满态，这也就意味着 $\\Img g' = C'$。\n我们来看我们定义出的 $\\hat{g'}\\vcentcolon\\space\\Coker\\beta\\to\\Coker\\gamma$，任取它的定义域上的一个元素 $\\hat{b'} = b'+\\Img\\beta$，经过 $\\hat{g'}$ 映射后得到的 $\\Coker\\gamma$ 中对应的元素则是 $g'(b') + \\Img\\gamma$。然而由于 $\\Img g' = C'$，这说明 $g'(b')$ 会随着所有对 $\\hat{b'}$ 的选取而跑遍任何一个这里的 $C'$ 中的元素，进而使 $\\hat{g'}$ 也是一个满态。自此，我们就可以给 $\\Coker\\gamma$ 的右端补上 $\\Coker\\gamma\\to 0$ 的同时保持其正合性，因为补上的映射的核正是 $\\Coker\\gamma$。\n证完串起来 至此，我们完全证明了前面列出的所有结论，进而证明了蛇引理。简单盘点我们证明过的东西，我们做了这些事：\n构造了五个阿贝尔群间的同态，一一验证了它们的定义是 OK 的； 对四个点上的正合性做出证明，具体是先向前得到前一个同态的像在后一个的核内，再证明后一个的核再前一个的像内，从而得到正合 对得到的正合列在交换图上下链条都是短正合列的情况做出补充。 这里有几个值得一提的点。首先，我们在一开始就得到了 $f|$ 和 $g|$ 的定义，它貌似是直接给出的，但应该是由我们自己定义的，即便定义好之后也是同样的形式。由于阿贝尔群同态的核天生就是其定义域上的阿贝尔子群，除了使用原同态在核上的限制来定义以外，并没有什么更好的定义方法了。\n另外，我们尝试给定义的 $\\delta$ 一个具体的表达式，然而这个做法其实可能并不好（我们也应该已经看到了，$(f')^{-1}$ 可能会造成一些问题）。这个同态可以被称作 连接同态，是蛇引理中连接上下两个正合列的的很重要的一个同态。\n然后，就余核来看，如果对它的一些性质更加熟悉，可能证明过程会更加简单。不过我们也在证明过程中看到了关于它的一些性质，这里就不再赘述，只提一点，就是余核内的单位元（零元）代表的不是简单的 $0$，而是一个等价类。我们还可以看到核与余核之间的一些微妙的联系。然而这里就不过多讨论这些了，这些更多是属于范畴论的内容。从范畴的角度来看，它们的区别就是用来定义的交换图内的箭头方向不一样而已。\n还有就是，我们对正合性做出验证时，并没有按照从左向右的顺序，而是先验证了处于交换图中间位置对应的点的正合性，再验证了两边的。这也许是受到了我所看的视频的影响吧。我想在这里做出另一个推荐的证明顺序，即从链条的左边开始，先证明这个链条是一个链复型，再证明它的正合性。从上面的证明过程来看，其实证明它是链复型的过程非常简单，难点则是那个反包含的证明。当然，我暂时也不计划再证明一次，这次写的很多了，就这样吧。\n此外，基础版本的蛇引理不包含最后的两个同态，就是 $0\\to A$ 和 $C'\\to 0$。另一个角度来讲，为了从交换图中得到一条正合列，我们只需要这些最低限度的条件。不过，蛇引理还可以继续拓展下去，不过就不叫蛇引理了。\n最后，我们要指出，我们这里做证明的方法，就是所谓的追图（Diagram Chasing）。这种方法从交换图的某个点内的元素为起点，沿着交换图中的态射移动，最终“追”到我们需要的元素为止，从而证明某个结论。追图是同调代数中重要的证明方法，其中最基础的一个证明例子便是这里的蛇引理。不过，这里的证明确实较为冗长，如果借助更高级更复杂的数学工具/技巧，比如范畴论，那么证明应该会更加简短一些，形式也更精简一些，不过可能很难看懂，我也不会这些（）\n证毕后的一点感想 一开始动工的时候，我是没想到竟然能写这么多的。也许是因为我太罗嗦了吧，前面讲了很多的前置，也不知道讲清楚没有，而后面证明过程的很多话又都是套话；又或者我觉得写详细一些，易懂一些，可能会比较好，所以就把证明过程中我的一些想法以及口头的一些表达揉进去了。个人而言我还是挺喜欢思考这些东西并把思考过程写下来的。我也有想过把这篇拆成几个部分，不过目前先写在一起吧。即便看完这么多也需要好久好久。\n可以看到里面有一些可以点开的隐藏了的内容，有许多还没有写好，标记着“Under Construction~”。这些内容应该会在某天我心血来潮之后再次补好吧（补档：我写好了！）。不过也有可能会删掉，或者直接拆分出来？我也不好现在就下判断。另外我还计划在这篇文章的后面，也就是在证明结束后，再补一个不那么啰嗦的证明过程。这样的话，已经有代数基础的朋友也许就可以直接跳到精简版的来看？emmm不过既然已经有代数基础了，想必来看这篇也就是图一乐了。还是希望能嘴下留情~ 作为一个普通的数学爱好者，能证完这个我感觉还挺有成就感的啦。\n不过必须承认的是，写的过程中我还是回头修改了不少表述不太合适的部分，以及这篇证明是离不开网络上的众多优秀资料的帮助的。感谢互联网，互联网万岁！~\n最后，祝您身心健康，生活愉快~\n我其实挺纠结应该说 函数 还是 映射 的。函数我认为用以指代给集合上每个点指派一个数字的东西更合适，更符合我心目中对函数的想象。而映射又太广泛了，因为很多地方代数结构之间不会一板一眼地讲“同态”，而是直接就说映射了。思来想去，还是函数更合适，毕竟接触最多，接受程度也最广泛。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n这里所谓的合理性，在一般数学教材中称为良定义，而一个定义是合理的也被称为良定的。很奇怪的说法……良定……\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n有数学家表示应区别含幺环范畴 $\\mathsf{Ring}$ 与不含幺的环范畴 $\\mathsf{Rng}$，我觉得环应该含有乘法幺元，且应该省略元音字母 $\\mathsf{i}$，所以写成这样。请根据上下文确定环范畴具体是什么样的含义。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-02-27T00:00:00+08:00","image":"/images/Post Shelter-Inaba Kumori.png","permalink":"/zh/posts/math_note/snake_lemma/","title":"蛇年，Snake Lemma！"},{"content":"记录一下目前使用到的两个相场模型，包括它们的推导，假设和缺陷\n头图出自 かとうれい 太太，为 みきとP 所作的 少女レイ 的曲绘\n简介 目前在做的不连续析出的模拟，里面用到了这两个演化方程。之前一直没有仔细思考过这两个演化方程到底是什么来头，为什么这个体系适合使用这两个方程，导致现在想大概修改一下它们也无从下手。这里就作为笔记记录下这两个方程的推导方法，优缺点，以及我个人的一些看法吧。\n多相场模型 模型介绍 多相场模型（或者说是界面场模型，差不多吧）是适用于非保守场的演化方程，来自于 I. Steinbach 和 F. Pezzolla 的文章。它的形式为：\n$$ \\frac{\\partial \\phi_\\alpha}{\\partial t} = -\\frac{1}{\\tilde{N}}\\sum_{\\beta \\neq \\alpha} \\tilde{L}_{\\alpha\\beta}\\left(\\frac{\\delta }{\\delta \\phi_\\alpha} - \\frac{\\delta }{\\delta \\phi_\\beta} \\right)F, $$这里的 $F$ 是自由能泛函，$\\tilde{N}$ 是有效序参量的个数，$\\tilde{L}_{\\alpha\\beta}$ 是有效序参量里两相之间的界面移动参数，而括号内的差则是表示一种算符，即\n$$ \\left(\\frac{\\delta }{\\delta \\phi_\\beta} - \\frac{\\delta }{\\delta \\phi_\\alpha} \\right)F = \\frac{\\delta F}{\\delta \\phi_\\beta} - \\frac{\\delta F}{\\delta \\phi_\\alpha}. $$简单来说，这篇文章考虑了使用界面场来描述不同相之间的界面并且演化，而非使用相自身的序参量作为演化参量。虽然最后还是会落到使用相自身的序参量来演化，但是界面场的思想融入到来这个演化模型中。最主要的改进应该是在考虑界面场的同时，考虑每个点处的有效序参量，也就是不为 0 的相的序参量，这样一来还可以简化计算（虽然实际计算过程中也可以只用传统的所有相的计算就是了）。\n平心而论，这篇文章写的逻辑结构并不是非常清晰，公式推导过程更是灾难，甚至符号都有一些问题，但是谁让这个模型好用呢？那就不多讲废话了，直接开始推导这个方程吧。要注意的是，这里的推导过程和作者的推导过程略有出入，同时也参考了 Q. Huang et al 的这篇文章。\n模型推导 对多相问题而言，我们引入一个约束：每个点上的所有序参量之和为一常数 $1$。即：\n$$ \\sum_{\\alpha = 1}^{N} \\phi_\\alpha = 1, $$由于对时间求导的线性性，又有：\n$$ \\sum_{\\alpha = 1}^{N} \\frac{\\partial \\phi_\\alpha}{\\partial t} = 0. $$设我们现在已经有一个自由能泛函 $F[\\{\\phi\\},\\{\\nabla\\phi\\}]$，其形式为：\n$$ F[\\{\\phi\\},\\{\\nabla\\phi\\}] = \\int_\\Omega f\\left(\\{\\phi\\},\\{\\nabla\\phi\\}\\right) \\,\\mathrm{d}\\omega, $$即我们写出了其能量密度形式。我们现在希望能把上面引入的约束进一步引入这个能量泛函内，因此我们使用 Lagrange 乘数法，引入 Lagrange 乘数 $\\lambda$ 到自由能密度中，则有：\n$$ \\begin{aligned} l \\left(\\left\\{\\phi \\right\\},\\left\\{\\nabla\\phi \\right\\}, \\lambda\\right) \u0026 = f\\left(\\left\\{\\phi \\right\\},\\left\\{\\nabla\\phi \\right\\}\\right) - \\lambda\\left( \\sum_{\\alpha = 1}^{N} \\phi_\\alpha - 1 \\right); \\\\ \\mathcal{L}\\left[\\left\\{\\phi \\right\\},\\left\\{\\nabla\\phi \\right\\}, \\lambda\\right] \u0026= \\int_\\Omega l \\,\\mathrm{d}\\omega. \\end{aligned} $$然后我们令 $\\mathcal{L}$ 对 $\\phi_\\alpha$ 做变分，得到：\n$$ \\begin{aligned} \\frac{\\delta \\mathcal{L}}{\\delta \\phi_\\alpha} \u0026 = \\frac{\\partial l}{\\partial \\phi_\\alpha} - \\nabla \\cdot \\frac{\\partial l}{\\partial \\nabla \\phi_\\alpha} \\\\ \u0026 = \\frac{\\partial f}{\\partial \\phi_\\alpha} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla \\phi_\\alpha} - \\lambda \\\\ \u0026 = \\frac{\\delta F}{\\delta \\phi_\\alpha} - \\lambda . \\end{aligned} $$此时我们应用所谓的 “Relaxation Ansatz”，即这个变分导数值为 $\\phi_\\alpha$ 的演化速率，即：\n$$ \\begin{aligned} \\frac{\\partial \\phi_\\alpha}{\\partial t} \u0026= -\\frac{\\delta \\mathcal{L}}{\\delta \\phi_\\alpha}\\\\ \u0026= -\\frac{\\delta F}{\\delta \\phi_\\alpha} + \\lambda \\end{aligned} $$则根据上面的约束条件，我们能解出 $\\lambda$ 为：\n$$ \\lambda = \\frac{1}{N} \\sum_{\\alpha = 1}^{N} \\frac{\\delta F}{\\delta \\phi_\\alpha} $$此时将 $\\lambda$ 带入应用“Relaxation Ansatz”后的变分结果中，得到：\n$$ \\begin{aligned} \\frac{\\partial \\phi_\\alpha}{\\partial t} \u0026= -\\frac{\\delta F}{\\delta \\phi_\\alpha} + \\frac{1}{N} \\sum_{\\beta = 1}^{N} \\frac{\\delta F}{\\delta \\phi_\\beta} \\\\ \u0026= -\\frac{N-1}{N} \\frac{\\delta F}{\\delta \\phi_\\alpha} + \\frac{1}{N} \\sum_{\\beta = 1}^{N} \\frac{\\delta F}{\\delta \\phi_\\beta} - \\frac{\\delta F}{\\delta \\phi_\\alpha} \\\\ \u0026= -\\frac{N-1}{N} \\frac{\\delta F}{\\delta \\phi_\\alpha} + \\frac{1}{N} \\sum_{\\beta \\neq \\alpha} \\frac{\\delta F}{\\delta \\phi_\\beta} \\\\ \u0026= - \\frac{1}{N} \\sum_{\\beta \\neq \\alpha} \\left( \\frac{\\delta }{\\delta \\phi_\\alpha} -\\frac{\\delta }{\\delta \\phi_\\beta} \\right)F \\\\ \\end{aligned} $$最后，我们考虑到由于我们只考虑有效序参量，即不为 0 的序参量，这里的 $N$ 可以修改为 $\\tilde{N}$；括号内属于对两相间的界面场的驱动力描述，对于不同的两相驱动力，驱动力大小应该是不同的，所以我们给驱动力前面乘以和两相相关的界面移动参数，$\\tilde{L}_{\\alpha\\beta}$。这样一来结果为：\n$$ \\frac{\\partial \\phi_\\alpha}{\\partial t} = -\\frac{1}{\\tilde{N}}\\sum_{\\beta \\neq \\alpha} \\tilde{L}_{\\alpha\\beta}\\left(\\frac{\\delta }{\\delta \\phi_\\alpha} - \\frac{\\delta }{\\delta \\phi_\\beta} \\right)F, $$即我们的多相场模型。\n模型解释 上面的推导过程，在最后一步之前都是比较合理的。然而为什么最后能把 $\\tilde{L}_{\\alpha\\beta}$ 硬生生塞进求和里面呢？也许只能通过物理的角度去尝试解释。这个公式在考虑“Relaxation Ansatz”时没有引入移动性的一些参数，比如经典 Allen-Cahn 方程里的移动性矩阵，也是为了方便公式推导，否则会陷入求和地狱，得到的 $\\lambda$ 的值会变成：\n$$ \\lambda = \\frac{\\sum_\\alpha\\sum_\\beta{}L_{\\alpha\\beta}\\frac{\\delta F}{\\delta \\phi_\\beta}}{\\sum_\\alpha\\sum_\\beta{}L_{\\alpha\\beta}}, $$带入公式后会得到：\n$$ \\frac{\\partial \\phi_\\alpha}{\\partial t} = \\frac{\\sum_\\beta{L_{\\alpha\\beta}}}{\\sum_\\xi\\sum_\\zeta L_{\\xi\\zeta}}{\\sum_\\xi\\sum_{\\zeta\\neq\\beta} L_{\\xi\\zeta}\\left( \\frac{\\delta }{\\delta \\phi_\\beta} -\\frac{\\delta }{\\delta \\phi_\\zeta} \\right)F} $$虽然严谨，但是难以理解，而当考虑到这里的移动性参数可以直接集成在 $\\tilde{L}_{\\alpha\\beta}$ 和 $\\tilde{N}$ 后，整个式子都会变得更简洁，物理意义也更加明确。\n另外，在 I. Steinbach 和 F. Pezzolla 的文章 里，$\\left( \\frac{\\delta }{\\delta \\phi_\\alpha} -\\frac{\\delta }{\\delta \\phi_\\beta} \\right)F$ 被解释为界面场 $\\psi_{\\alpha\\beta}$，这也是为什么这个模型叫做界面场模型。而这篇文章中的推导过程里，如果考虑使用界面场进行推导的话，可以绕过求取 $\\lambda$ 的显式表达，因为这个 $\\lambda$ 对所有相都是相同的，而界面场这样差值的定义方式注定会消去 $\\lambda$ 的影响。\n最后，我们指出，这个演化方程并没有对自由能 $F$ 做出任何的约束，因此该模型适用性非常广。事实上，多相场模型的应用极为广泛，经常可以在近年的相场模拟文章中见到。所以，尽管看起来这个模型的推导（在我看来，也许是我的问题）并不足够可靠，但是它很好用。是的，很好用。\n巨势方程 模型介绍 为了演化保守场变量，我们经常需要使用 Cahn-Hilliard 方程。然而，为了得到更好的结果，又或者当我们遇到了一些由演化方程引入的数值上的问题，我们也许需要对这个经典的方程做一些改变，就像上面的 Allen-Cahn 方程和多相场模型之间的关系一样。对于浓度这个最经典的变量而言，我们有总浓度场模型（考虑整个模拟域的浓度），相浓度场模型（考虑每个相内部的物质浓度），以及我们这里要介绍的巨势方程（演化模拟域内的化学势）。\n在介绍巨势方程具体的表达式之前，我们先来看一下所谓的“相浓度”和“总浓度”吧。我们知道，对于整个体系而言，其组分数量（元素）是固定的，而一个体系中可能有多个晶粒，而每个晶粒又可能分属不同的相。对不同的相而言，其成分很有可能是不同的。因此，一个组分的浓度在每个相内应该是不变的（不随位置变化），而在整个模拟域内会发生改变（随着相的不同而变化）。另外，浓度的改变是依赖于扩散势的，扩散势梯度会引导浓度进行变化，从高势处流向低势处。因此，相生长过程中浓度的变化可以认为是相浓度不同所导致的相之间扩散势不同所引发的。根据这一点，我们还可以通过演化模拟域内扩散势的变化来间接地模拟浓度的变化。这里我们要介绍的巨势方程，就是这么一个用来模拟扩散势变化的方程。\n巨势方程的表达式如下：\n$$ \\frac{\\partial \\mu_i}{\\partial t} = \\left[\\phi_\\alpha \\frac{\\partial c_j^\\alpha}{\\partial \\mu_i} \\right]^{-1} \\left( \\nabla\\cdot \\bar{M}_{jk} \\nabla\\mu_k + R_j - c_j^\\alpha\\frac{\\partial \\phi_\\alpha}{\\partial t} \\right). $$我需要解释一下这个方程的记号。首先，和往常相似，$c$ 代表相浓度（即一个相内部的浓度），$\\phi$ 代表相。此外，这个公式中的 $\\mu$ 代表化学势（严格来讲是巨势，这也是这个方程名称的由来，但为方便理解我们就称为化学势），$M$ 代表浓度的移动性参数，$R$ 代表可能存在的浓度/物质源。再者，这个方程实际上使用了爱因斯坦求和约定，即如果一个乘积中一个指标出现了两次，那么就对这个指标求和。我们举个例子，比如方程右侧圆括号中的最后一项的记号代表的是：\n$$ c_j^\\alpha\\frac{\\partial \\phi_\\alpha}{\\partial t} \\coloneqq \\sum_{\\alpha}^{N}c_j^\\alpha\\frac{\\partial \\phi_\\alpha}{\\partial t}. $$因此，上面的方程实际上是一个复杂求和。另外，记号中的 $i,j,k$ 都是用以标记元素（组分）的，我们设一共有 $K$ 个组分，所以独立组分一共有 $K-1$ 个（最后一个的量可以用 1 减去其余所有的组分的量），同时 $\\alpha,\\beta$ 等是用来标记相的，我们设一共有 $N$ 个相。根据我们的记号，上面的公式中如果有某个量没有重复指标（重复指标通常也称为哑指标，dummy index），则说明这个变量实则是代表了一个向量，这个向量根据指标的记号区别有 $N$ 或者 $K-1$ 个分量。而如果一个变量有两个指标，则说明这个变量实则是一个矩阵。我们后文记 $K-1$ 为 $\\tilde{K}$ 以方便书写。\n最后我们要解释的是中括号和 $-1$ 的上标。这个记号是代表我们先以括号内的元素组成一个矩阵，然后对矩阵求逆。至此方程中的下标记号应该已经全部清晰明了了。\n方程推导 下面我们来尝试对这个方程进行推导。我们直接从 Cahn-Hilliard 方程出发：\n$$ \\frac{\\partial \\tilde{c}_i}{\\partial t} = \\nabla \\cdot \\sum_{j}^{\\tilde{K}}\\nabla M_{ij}\\nabla \\frac{\\delta F}{\\delta \\tilde{c}_j} + R_i. $$这里我们再次对记号做一些解释。这里我们先不使用爱因斯坦求和约定，方便解释方程内部发生了什么，另外这里的 $\\tilde{c}_i$ 代表的是体系内的总浓度。我们加上了波浪线是为了强调是整个体系内的总浓度，方便和后面的相浓度做出区分。\n由于我们这里使用了总浓度，它实际上可以使用相浓度和相分数来表示：$\\tilde c_i = \\sum_\\alpha^N \\phi_\\alpha c^\\alpha_i$。另外我们知道，$\\frac{\\delta F}{\\delta \\tilde{c}_j}$ 实际上是表示的体系内化学势（巨势）。所以我们直接用 $\\mu_j$ 来替代。这样就有：\n$$ \\frac{\\partial \\sum_\\alpha^N \\phi_\\alpha c^\\alpha_i}{\\partial t} = \\nabla \\cdot \\sum_{j}^{\\tilde{K}}\\nabla M_{ij}\\nabla \\mu_j + R_i. $$现在我们把目光聚焦在等式左侧，因为等式右侧，可以看到，其实已经是最终结果的一部分了。对于等式左侧，首先对有限求和而言，求导的线性性保证了我们可以把求导和求和交换次序。然后我们考虑使用对乘积偏导（求导）的规则，则有：\n$$ \\frac{\\partial \\sum_\\alpha^N \\phi_\\alpha c^\\alpha_i}{\\partial t} = \\sum_\\alpha^N\\left(\\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial t} + c^\\alpha_i \\frac{\\partial \\phi_\\alpha }{\\partial t} \\right) = \\nabla \\cdot \\sum_{j}^{\\tilde{K}}\\nabla M_{ij}\\nabla \\mu_j + R_i. $$我们考虑把求和拆开，把含有相分数对时间求偏导的部分挪到等式右侧，则有：\n$$ \\sum_\\alpha^N \\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial t} = \\nabla \\cdot \\sum_{j}^{\\tilde{K}}\\nabla M_{ij}\\nabla \\mu_j + R_i - \\sum_\\alpha^N c^\\alpha_i \\frac{\\partial \\phi_\\alpha }{\\partial t} . $$接下来是比较关键的一步，我们考虑把浓度和化学势联系起来。即考虑相浓度作为化学势的函数：$c_i^\\alpha = c_i^\\alpha\\left( \\mu_1, \\mu_2, \\cdots, \\mu_{\\tilde{K}} \\right)$。这样我们就可以使用求（偏）导的链式法则，有：\n$$ \\sum_\\alpha^N \\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial t} = \\sum_\\alpha^N \\phi_\\alpha \\sum_k^{\\tilde{K}}\\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t}, $$然后考虑到对成分求和实际上与相无关，我们把对成分求和的求和号挪到最外面，这样就得到了：\n$$ \\sum_\\alpha^N \\phi_\\alpha \\sum_k^{\\tilde{K}}\\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t} = \\sum_k^{\\tilde{K}} \\sum_\\alpha^N \\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t}. $$我们先在这里暂停一下，回忆矩阵乘法的记号。设我们有两个矩阵，一个 $n\\times m$ 矩阵 $A = \\{a_{ij}\\}$ 和一个 $m\\times p$ 矩阵 $B = \\{b_{jk}\\}$，则它们的乘积矩阵 $C$ 应该是一个 $n \\times p$ 矩阵，它的元素可以记为：$\\sum_j^m a_{ij}b_{jk}$。另外，我们考察偏导 $\\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}$ ，这个偏导在当 $i$ 和 $k$ 都在 $\\tilde{K}$ 个元素中取值时，实际上它组成了一个 $\\tilde{K} \\times \\tilde{K}$ 矩阵中的元素。对应的，我们可以把 $\\partial \\mu_k$ 看作一个具有 $\\tilde{K}$ 个分量的向量（或者 $\\tilde{K} \\times 1$ 的矩阵）。\n根据上面的内容，我们可以发现，实际上这里的求和可以写作两个矩阵的乘积（或者矩阵乘以一个向量）。至此我们采用爱因斯坦求和约定，则有：\n$$ \\sum_k^{\\tilde{K}} \\sum_\\alpha^N \\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t} \\coloneqq \\phi_\\alpha\\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t}. $$我们把上面等式右边的三个因子做简单的区分，前两个因子的乘积实际上由于 $\\alpha$ 指标重复的原因，代表了一个求和，而后又因为这个求和与第三个因子的 $k$ 指标重复，代表了矩阵的乘法。或者我们可以把 $\\sum_\\alpha^N \\phi_\\alpha \\frac{\\partial c^\\alpha_i}{\\partial \\mu_k}$ 理解为矩阵中的第 $\\left( i,k \\right)$ 个元素\n那么经过上面的说明，我们将等价变量依次带回，并对整个方程使用爱因斯坦求和约定重写，则有下面的结果：\n$$ \\phi_\\alpha \\frac{\\partial c_i^\\alpha}{\\partial \\mu_k}\\frac{\\partial \\mu_k}{\\partial t} = \\nabla\\cdot \\bar{M}_{ij} \\nabla\\mu_j + R_i - c_i^\\alpha\\frac{\\partial \\phi_\\alpha}{\\partial t}. $$现在我们可以将上式翻译为：一个 $\\tilde{K} \\times \\tilde{K}$ 的矩阵 $\\left\\{\\phi_\\alpha \\frac{\\partial c_i^\\alpha}{\\partial \\mu_k} \\right\\}$ 与一个 $\\tilde{K} \\times 1$ 矩阵 $\\frac{\\partial \\mu_k}{\\partial t}$ 相乘，得到的结果是三个 $\\tilde{K} \\times 1$ 矩阵相加。而我们希望的是能够得到演化体系扩散势变化的方程，这正好可以用 $\\frac{\\partial \\mu_k}{\\partial t}$ 来表示。所以我们的最后一步就是在等式两边同时左乘上这个 $\\tilde{K} \\times \\tilde{K}$ 矩阵的逆矩阵，得到了：\n$$ \\frac{\\partial \\mu_k}{\\partial t} = \\left[\\phi_\\alpha \\frac{\\partial c_i^\\alpha}{\\partial \\mu_k}\\right]^{-1}\\left(\\nabla\\cdot \\bar{M}_{ij} \\nabla\\mu_j + R_i - c_i^\\alpha\\frac{\\partial \\phi_\\alpha}{\\partial t}\\right). $$也许你会发现这个式子和我们一开始给出的式子在下标上有差别。这个实际上是为了公式美观而改变了下标的排列顺序。只要保证公式内部的记号顺序一致，就可以保证公式，或者说矩阵乘法的逻辑顺序一致，因此我们这里得到的结果和上面给出的公式是没有本质区别的。\n模型解释 我知道，这里其实留了很多的坑，比如说什么是巨势方程里的“巨势”？巨势和化学势有什么关系？为什么非要用化学势/巨势来演化整个体系，用总浓度不好吗？相浓度不行吗？我们来一个个解释这些问题。\n首先，巨势是什么呢？我们知道，热力学中有很多不同的热力学函数，比如焓 $H$，熵 $S$，内能 $U$，吉布斯自由能 $G$，亥姆霍兹自由能 $F$ 等等。巨势，又称朗道自由能也是一种热力学函数，其表达式为：\n$$ \\Omega \\coloneqq F-\\mu N = U-TS-\\mu N, $$其中 $F$ 是亥姆霍兹自由能，$U$ 是内能，$T$ 是体系温度，$S$ 是熵，$\\mu$ 是化学势，$N$ 是体系内的粒子数。巨势的微分形式为：\n$$ \\mathrm{d}\\Omega = \\mathrm{d}U-T\\mathrm{d}S-S\\mathrm{d}T-\\mu\\mathrm{d}N-N\\mathrm{d}\\mu = -P\\mathrm{d}V-S\\mathrm{d}T-N\\mathrm{d}\\mu. $$巨势在体系达到热力学平衡的时候会取到最小值。当体系内的其余变量 $V$，$T$ 不变时，巨势的变化实际上就反映了化学势的变化。另外我们还可以从这个公式中得到浓度的表达方式：考虑将巨势除以体系的体积得到能量密度，此时 $N$ 将从体系内粒子数量变为体系内的粒子浓度/数密度 $\\rho$。假设我们还得到了物质的原子体积 $V_a$，那么浓度 $c$ 就可以表达为：\n$$ c = V_a \\rho = V_a \\left(\\frac{\\partial \\Omega}{\\partial \\mu}\\right)_{V,T}. $$据此，我们可以考虑将浓度表达为化学势的函数。这也是前述的浓度能对化学势求导的一个佐证吧。\n那么，为什么要用巨势方程呢？它对比总浓度或者相浓度有什么优势呢？我们考虑一个多元多相体系，每个相内部都有多种组元，在相内部这些组元的浓度是固定的，而相与相之间的组元浓度一般是不同的。当发生相变时，相内物质浓度可能会发生变化。在这个情况下，我们如果想演化整个体系的浓度分布情况，就不可避免地必须演化每个相的浓度分布。\n我们首先会想到使用相浓度去演化整个体系，这样再将相浓度和相分数相结合就可以得到整个体系内的浓度分布。这个方法从理论上讲很不错，但从实际处理过程中会发生一些数值问题：在相界面处，特别是相分数较小的情况下，不可避免的要用一个数去除以一个非常小的（接近于 $0$）的数字。由于 Cahn-Hilliard 方程是直接对总浓度进行演化的，因此必须先从总浓度中拆分出相浓度才可以直接演化相浓度。从总浓度反推相浓度时，不可避免要处理在界面上的浓度分配，这时必须要借助某种假设来正确地把浓度分配到每个相中。一般采用的假设是假设界面上的每个点上，每个相的化学势都相等。根据这点，总浓度和相浓度的关系可以表达为：\n$$ c^i = \\sum_\\alpha\\phi_\\alpha c_\\alpha^i $$这里，相浓度前的 $\\phi_\\alpha$，相分数，就会引发问题。假设现在需要演化某个很靠近某个相内部的位置（或者说 $\\phi_\\alpha \\approx 1$ 的区域），此时将会有很多别的相的相分数约等于 $0$。为了演化各自的相浓度，就需要把这个相分数除过去，此时由于计算机精度问题，很容易造成结果不稳定。\n那如果直接考虑总浓度呢？总浓度实际上就是最传统的 Cahn-Hilliard 方程，而为了求得相的演化速率，还是需要通过某种方式去推出每个相中的浓度分配问题。这样会增加过多的计算量：反求相浓度的过程实际上是解线性方程组问题。也就是说，使用相浓度，会遇到数值问题，使用总浓度，又会增加很多的计算量，到头来不过是和相浓度方法的先后顺序调换一下，在反求相浓度的时候依旧可能遇到数值问题。\n然而，使用扩散势时，这个问题被巧妙地隐藏到了偏导数中。这样相当于用某种方法绕过了这样的数值问题，保持了合理的计算开销。简单来说就是，又快又好。\n总结 其实很不好意思地说，这篇内容实际上只是对这两个公式做了一些简单的推导，而后面的解释部分我自认为写的并不好。好像所有的解释最后都要归结到一个结论上：好用。这个点实际上在考虑纯理论时是没有什么用处的：我需要精准的理论来描述物理现象，结果你却告诉我 XXX 然后 YYY 最后得到这些东西，它的理论背景可能不够强，但是它好用就够了。我相信这样的解释是很难真正地打动某个人的心的。\n然而，好用其实就已经够了，因为这些理论到头来本就是为了能够帮助我们在某个假设的基础上能够更好地做模拟。在这里，这个基础假设可以说是 relaxation ansatz 以及等势假设。首先第一个假设能够让我们的体系从一个非平衡态 演化 到平衡态，而不是只能直接地给出一个平衡态下的数量场，而第二个假设则能够解决相场法中界面上物质分配的问题，让演化能够得以在多相的情况下正常进行下去。这些假设，不论从过程还是结果来看，都是很有必要的。而除了这些假设外，（在不考虑我自己推导过程不够严谨的情况下，）推导过程都是尽可能严谨的。得到的结果，也正如上面所说，好用。\n上面这一大段，我希望能传达到的意思就是，这些公式已经在较少的较宽松的假设的基础上用尽可能严谨的逻辑推导出了可用，好用的结果，那么作为使用这些公式的人来讲，它好用就够了，坚持实用主义也许是更实际的做法。当然了，阅读本段的您也可以认为是我对自己的推导过程没有什么自信的开脱就是了，嘿嘿嘿~\n那么最后，祝您生活愉快~\n","date":"2025-01-05T00:00:00+08:00","image":"/images/ShoujouRei_MikitoP.png","permalink":"/zh/posts/pf_note/mpf_gp/","title":"多相场模型与巨势方程"},{"content":"本文系拾人牙慧之作，仅为解决公式推导过程中的一些边角料的数学问题，内容如有错漏还请谅解。另外，感谢老大中先生的《变分法基础》第三版。本文的主要内容几乎全部参考本书。\n头图出自 雨野 太太，为 r-906 所作的 Hello World! 的曲绘\n2025.06.06 更新：感谢@which-is-my-way指正，公式 16 补上点乘单位法向量\n晶体相场公式带来的问题 在一个阳光明媚的晚上，师兄找到我问了一个问题：下面的这个相场公式是怎么组装起来的？具体来讲是：从下面的公式（2）和公式（3）是怎么得到公式（4）的： $$ \\begin{align} F \u0026= \\int_V f \\mathrm{d}v\\\\ \u0026= \\int_V \\left(\\frac{\\psi}{2} \\omega \\left(\\nabla ^2\\right)\\psi + \\frac{\\psi^4}{4}\\right) \\mathrm{d}v;\\\\ \\frac{\\partial \\psi}{\\partial t} \u0026= \\nabla^2 \\frac{\\delta F}{\\delta \\psi} + \\xi;\\\\ \\frac{\\partial \\psi}{\\partial t} \u0026= \\nabla^2 \\left( \\omega\\left( \\nabla^2 \\right) \\psi + \\psi^3 \\right)+ \\xi. \\end{align} $$ 这里我们做一些简单的背景介绍吧。这个公式来源于这篇文章，是提出晶体相场理论的文章，其重要性不言而喻，近乎所有的该领域的文章在使用这篇文章的结果时都需要引用这些个公式。我们这里不对晶体相场做太多介绍了（因为我也不了解，虽然也有相场两个字，但是几乎只有最最基础的假设相似而已了），简单介绍一下这些公式（名称）这些方便后面表述。其中公式（1）是指体系总能量可以表达为能量密度对体积的积分（这里先不给出能量和能量密度的参量），这里可以看到总能量实际上是一个泛函；（2）是指能量密度的具体构造，（3）是和传统相场形式相类似的一个演化方程，在传统相场里是 Cahn-Hilliard 方程。而（4）就是将（3）中的变分展开得到的结果，或者说是具体计算过程中使用的公式的显式表达。\n另外我必须提到的一点是，这里列出的公式并不完整，比如 $\\omega$ 是什么我并没有做说明，这是为了复述一下我的心路历程（即便是笔记，也不希望太死板，毕竟是从实际问题来的）。当然，后面会把完整的问题复述，以及推导过程完整地列出来的。\n传统相场公式，对吗？ 拿到这个公式的时候其实并不是直接从文献拿到的，而是几张图片（大概就是公式（2）（3）和（4））。而我看到公式的第一反应是：这符号不是很对吧？把 $\\psi$ 放到括号外面？这不太对吧？然后我便开始按照以往推导传统相场能量变分的方式推导了。我们来看看传统相场公式吧。 $$ \\begin{align} F(c, \\nabla c ) \u0026= \\int_{\\Omega} f(c, \\nabla c )\\, \\mathrm{d}\\omega = \\int_{\\Omega} f_b(c, \\eta) + \\kappa_c \\left| \\nabla c \\right|^2 \\mathrm{d}\\omega;\\\\ \\frac{\\partial c_i}{\\partial t} \u0026= \\nabla \\cdot M_{ij} \\nabla \\frac{\\delta F}{\\delta c_j \\left( r,t \\right)}, \\end{align} $$ 其中，公式（6）即为 Cahn-Hilliard 方程，而公式（5）则是传统相场中的总能构造的一种常见（最基础的）形式，其中 $f$ 是能量密度，$f_b$ 是体自由能密度。可以看到能量泛函是依赖于（?）浓度和浓度的梯度的。对这个公式的推导我们直接使用三维条件下的 Euler-Lagrange 方程： $$ \\begin{align} \\frac{\\delta F\\left[ x,y,y' \\right]}{\\delta x} = \\frac{\\partial f}{\\partial x} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla x}. \\end{align} $$ 这样一来，这个公式就可以被展开了，只需要按照能量泛函的具体表达形式带入，然后求一下偏导，很快就会得到结果。\n说实话，这是在太棒了，只需要用很多现成（?）的内容，做一些非常简单（?）的推导，就（?）可以得到最后体系的演化方程具体表达形式。那心动不如行动，直接把这一套挪到上面的原始问题吧。很好，我们先对 $\\psi$ 求偏导，得到（?）下面的东西： $$ \\frac{\\partial f}{\\partial \\psi} = \\frac{1}{2}\\omega\\left( \\nabla^2 \\right)\\psi + \\psi^3, $$ 然后，我们要对 $\\nabla \\psi$ 求偏导了。嗯，$\\nabla \\psi$ ……但是这里是 $\\nabla^2$ ？话说回来为什么要用 $\\omega$ 带括号把 Laplacian 算子包起来呀？啊？\n这对吗？这不对吧？\n重新审视问题，$\\omega$ 是什么？ 问题看来根本不是我想得那么简单。还是需要从零开始一步步建立起这个问题的合理描述，并找到真正的解决方法。首先要解决的，就是 $\\omega(\\nabla^2)$ 这个奇怪的写法。假如这个写法是对的，那 $\\omega$ 就不是什么参数之类的东西了，就应该是算符的一个函数或者别的什么东西了。\n找到原始文献，查看定义，我们得到了 $\\omega$ 的真面目： $$ \\begin{align} \\omega (\\nabla^2) = r + \\left(1 + \\nabla ^2\\right)^2, \\end{align} $$ 其中的 $r$ 是一个复杂的常数，不用关心。果不其然。$\\omega$ 应该解释为一个对 $\\nabla^2$ 算子做一种变换得到的新的算子。或者说，它是把算子映射到算子的一个映射。太棒了，我们把这个结果带入公式（1）中的 $f$ 吧： $$ \\begin{align*} f \u0026= \\frac{\\psi}{2} \\omega \\left(\\nabla ^2\\right)\\psi + \\frac{\\psi^4}{4}\\\\ \u0026= \\frac{\\psi}{2} \\left(r + \\left(1 + \\nabla ^2\\right)^2 \\right)\\psi + \\frac{\\psi^4}{4}. \\end{align*} $$ 等一下，算子中的平方应该怎么解释？常数作用于一个变量应该怎么解释？根据算符的运算规则，我们得知：算符的平方，应该解释为算符作用于被作用量两次，而常数作用应解释为标量乘法。那么我们得到： $$ \\begin{align*} f \u0026= \\frac{\\psi}{2} \\left(r\\psi + \\left(1 + \\nabla ^2\\right)^2\\psi \\right) + \\frac{\\psi^4}{4}\\\\ \u0026= r\\frac{\\psi^2}{2} + \\frac{\\psi^2}{2} + \\psi \\nabla^2\\psi + \\frac{1}{2}\\psi\\nabla^2\\nabla^2\\psi + \\frac{\\psi^4}{4}. \\end{align*} $$ 啊，看起来头好晕，怎么 Laplacian 也有个平方？我们更换符号：$\\Delta = \\nabla^2$，就有了： $$ \\begin{align} f \u0026= r\\frac{\\psi^2}{2} + \\frac{\\psi^2}{2} + \\psi \\Delta\\psi + \\frac{1}{2}\\psi\\Delta\\Delta\\psi + \\frac{\\psi^4}{4}. \\end{align} $$ 好了，这下我们搞清楚了 $\\omega$ 到底是什么以及它对公式有何影响，现在我们对 $\\psi$ 求的偏导应该就没问题了吧？\n等等，什么是 $\\Delta\\Delta\\psi$ ？对 $\\psi$ 求偏导的话要管它吗？就算不管这个东西，这个公式里没有熟悉的 $\\nabla\\psi$ 呀，那我们的能量密度对 $\\nabla\\psi$ 求偏导等于 0 ？这不太对吧？话说回来我们的总能量泛函到底依赖于什么变量？再等一下，依赖？对于一个泛函而言，我们只需要找到最符合要求的一个函数就好了呀？这个函数自然就可以通过对坐标求导得到自己的偏导数了，那偏导数就不应该是一个独立变量才对吧，我们对它做偏导数到底是为什么？\n完了，本来以为什么都知道，现在什么都不知道了。变分法，Euler-Lagrange 方程，这些都不应该是现成的吗？Laplacian，奇怪的 $\\Delta\\Delta\\psi$，这些都不是能直接套到已有公式里的吧？\n死胡同？从头开始吧！ 其实 $\\Delta\\Delta\\psi$ 或多或少能想到是怎么个形式，无非就是把 $\\Delta$ 作用两次就行了，关键在于这个变量，以及 $\\Delta \\psi$ 怎么参与到这个泛函构造中的，并且它们应该怎么参与到泛函导数里面。而为了搞清楚这个问题，我们也许必须明白这个泛函的“自变量”都有哪些，或者说，依赖于哪些变量，并且要搞清楚变量函数本身和它对位置的求导之间到底是有着什么样的关系。\n问题很多，我们干脆从头开始，一步步拆解吧，就从泛函是什么这个问题开始。\n泛函 我们讨论的泛函其实是一类特殊的映射，这个映射拥有定义域和陪域，其定义域为在某个空间上定义的全体函数组成的空间（比如，$\\mathbb{R} \\supseteq \\Omega\\to\\mathbb{R}$ 的函数组成的空间，或者 $\\mathbb{R}^3 \\supseteq \\Omega\\to\\mathbb{R}$ 的函数空间，根据我们的问题是几维的来确定这些函数的定义域），而泛函的陪域则是一个数域，对于能量而言我们就选择 $\\mathbb{R}$ 好了。所以这个映射，从形式上来写，应该就是：\n$$ F:\\left\\{ y \\;\\Big|\\; y: \\Omega \\to \\mathbb{R} \\right\\} \\to \\mathbb{R}. $$另外我们的泛函的另一个特殊之处在于，它常常可以写成这样一个积分的形式：\n$$ F = \\int_\\Omega f\\, \\mathrm{d}\\omega. $$我们常遇到的变分问题，也就是说在求什么样的函数 $\\phi \\in \\left\\{ y \\\\;\\Big|\\\\; y: \\Omega \\to \\mathbb{R} \\right\\}$ 能够使得将之带入泛函 $F$ 时能让这个泛函取到最小值1。甚至我们遇到的问题更加得特殊，因为我们要求函数族 $\\left\\{ y \\\\;\\Big|\\\\; y: \\Omega \\to \\mathbb{R} \\right\\}$ 满足这样的条件：在区域边界 $\\partial \\Omega$ 上这些函数族内的函数都必须相等，或者换句话说，就是我们的问题是固定边界问题。\n太棒了，但是上面这些叙述，对我们的问题有什么帮助呢？我们把目光聚焦到泛函积分形式中的这个 $f$ 上。它没这里有具体的表达式，只是说明了要对它做积分。它具有什么样的意义呢？\n被积函数（泛函的核） 我们这里指出：这个被积分的东西 $f$ 实际上是对泛函的要求。在部分文献中 $f$ 也称为泛函的核。$f$ 的具体表达形式，将会对最后得到的 $y$ 做出约束，使之满足泛函 $F$ 取到最小值的结果。那么，一个对 $y$ 的约束，要怎么表达它呢？或者说我们应该对 $y$ 做一些什么，来使之成为 $y$ 的约束呢？\n为了用 $f$ 来约束 $y$，我们考虑使用 $f$ 来描述 $y$ 的行为。$y$ 在什么情况下，会得到什么样子的结果，大概就是这样的方式去描述。而我们常常在描述 $y$ 的行为时，会考虑到它的导数的行为，将导数 $y'$ 和 $y$ 二者相互作用时得到的结果结合起来。最后考虑到我们描述 $y$ 时很难避免加入函数自变量 $x \\in \\Omega$，最后我们得到的 $f$ 就会变成这样的东西：它看起来像是一个关于 $x\\in \\Omega$，$y : \\Omega \\to \\mathbb{R}$ 以及 $y' : \\Omega \\to \\mathbb{R}^n$ 三个变量的函数（其中 $n$ 的取值取决于考虑的函数的定义域维数）。当存在更多高阶导数参与描述 $y$ 的行为时，这个函数 $f$ 所依赖的变量就更多了。在这个函数中，我们不考虑 $y$ 是和 $y'$ 或者更高阶的导数相关的，因为它们都独立地描述函数 $y$ 的行为。可以这样理解：$y'$ 对函数 $y$ 的约束作用是没法直接用 $y$ 自己或者 $x$ 自己单独去描述的，所以它的影响就应该是独立于 $y$ 和 $x$ 的。这样一来，令 $f$ 对 $y$，$y'$ 等求偏导也是可以理解的了。另外，我们只关注对 $y$ 起实际约束作用的量，假如 $f$ 中不含有 $y'$，我们认为 $f$ 是不显含 $y'$ 的，此时并不是说 $y'$ 不存在了，而是它不参与到对 $y$ 的行为约束中。\n当我们想要求取得到的函数的定义域从一维上升到我们更常遇到的三维时，函数 $y$ 所依赖的变量也就更加复杂了，可能包括 $\\nabla y$，$\\nabla \\cdot y$，$\\nabla \\cdot \\nabla y$ 等等。和上面类似，我们依旧将这些处理为独立存在于 $f$ 中的变量。有了上面这些的铺垫，我们至少能让我们的问题变得更加清楚一些：问题中的能量形式，将其变量依赖状态完整地写出，应该是以下的形式（这里我们按照惯例将双调和算子 $\\Delta\\Delta$ 写成 $\\Delta^2$ 的形式，它也可以写作 $\\nabla^4$）：\n$$ \\begin{align} F\\left[\\psi\\right] \u0026= \\int_V f \\left(\\psi,\\Delta\\psi,\\Delta\\Delta\\psi\\right) \\mathrm{d}v\\\\ \u0026= \\int_V r\\frac{\\psi^2}{2} + \\frac{\\psi^2}{2} + \\psi \\Delta\\psi + \\frac{1}{2}\\psi\\Delta\\Delta\\psi + \\frac{\\psi^4}{4} \\mathrm{d}v. \\end{align} $$再考察 Euler-Lagrange 方程 然而上面的一切似乎只是澄清了一些基本事实，并没有对解决这个问题起到非常实质的帮助呀。别灰心，至少我们知道了：上面的 Euler-Lagrange 方程，应该是只适用于 $f(x,y,\\nabla y)$的，而对于新的 $f$，我们需要自己想办法得到这样的方程。因此，我们必须深入到变分法的根本，去了解变分法到底是怎么推导出了上面我们用到的 Euler-Lagrange 方程的。为此，我们采用我们一开始认为非常轻易地获得的 Euler-Lagrange 方程所对应的泛函形式来作为例子，自己推导一下它对应的 Euler-Lagrange 方程。\n回忆我们面对的变分法的一般问题：在什么样子的函数 $y$ 下，我们构造出的泛函能够取最小值。我们的函数 $y$ 的定义域是固定的，所以我们要关心的是这个符合要求的函数在每一个点处的值应该是什么样的。不妨假设我们已经有了一个最佳的函数满足要求了，称这个函数为 $\\varphi$。此时，由于这个函数已经是最好的，最满足需求的函数了，任何对这个函数某个值的改变，都会让我们的泛函不能取最小值。\n我们来试着把这个结论写成更形式化一些的表达：假设函数 $\\varphi : \\Omega \\to \\mathbb{R}$ 是满足泛函 $F$ 的最小值需要的函数，则此时任意函数 $y \\neq \\varphi$ 都会造成这样的结果：$F[y] - F[\\varphi] = \\delta F \u003e 0$ ，这里的 $\\delta F$ 就是泛函 $F$ 的变分。这里大于 0 是因为我们已经知道了 $F[\\varphi]$ 是最小值。反过来讲，当 $\\delta F = 0$ 的时候，就能说明此时的函数 $y$ 就是我们需要的函数 $\\varphi$。\n这个表达是否让你感到一丝熟悉？我们先继续向下推进。\n可以看到，假如我们把这个不等式用我们之前熟悉的泛函的积分形式展开，并根据积分的线性性合并，得到的结果是：\n$$ \\delta F = \\int_\\Omega f(x,y, \\nabla y) - f(x,\\varphi,\\nabla\\varphi) \\mathrm{d} \\,\\omega = \\int_\\Omega \\delta f\\, \\mathrm{d}\\omega. $$上面的第二个等号是我们把被积函数的差记为了这样对函数的全变分。这个积分不等式的被积分项里，变量 $x$ 没有什么变化，那我们干脆将 $f$ 在现在看作一个二元函数。我们把 $\\varphi$ 改写为以 $y$ 为基础加上一个扰动的形式：$\\varphi = y+\\delta y$，那么我们可以模仿全微分那样，把这里对函数的全变分 $\\delta f$ 做全微分式的处理，就可以根据它的两个变量的偏导来写出其全变分的表达式。带入上式，则有：\n$$ \\delta F = \\int_\\Omega \\delta f\\, \\mathrm{d}\\omega = \\int_\\Omega \\left(\\frac{\\partial f}{\\partial y}\\delta y + \\frac{\\partial f}{\\partial \\nabla y}\\cdot\\delta\\nabla y \\right) \\, \\mathrm{d}\\omega. $$这个形式已经是我们很熟悉的形式了，但是还有一些区别。这里我们指出，函数对向量求偏导得到的也是一个向量，所以这里需要用向量内积，其中的技术细节我们不多赘述，我们更关注的是：怎么把 $\\delta \\nabla y$ 写成别的形式，来进一步向我们的结果前进。注意到 $\\nabla$ 是对坐标求导，而 $\\delta$ 则是在保持定义域不发生改变的情况下，改变了函数的值。因此二者应该是相互独立的，也意味着两个算符是可以相交换的。再使用点乘的乘积律：$\\nabla \\cdot (f{\\bf{}v}) = f\\nabla\\cdot{\\bf v}+{\\bf v}\\cdot\\nabla f$，这样一通操作，就得到：\n$$ \\begin{align} \\delta F = \\int_\\Omega \\delta f\\, \\mathrm{d}\\omega \u0026= \\int_\\Omega \\left(\\frac{\\partial f}{\\partial y}\\delta y + \\frac{\\partial f}{\\partial \\nabla y}\\cdot\\nabla\\delta y \\right) \\, \\mathrm{d}\\omega \\\\ \u0026= \\int_\\Omega \\left(\\frac{\\partial f}{\\partial y}\\delta y - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y}\\delta y \\right) \\, \\mathrm{d}\\omega + \\int_\\Omega \\nabla\\cdot\\left(\\frac{\\partial f}{\\partial \\nabla y}\\delta y\\right) \\, \\mathrm{d}\\omega\\\\ \u0026= \\int_\\Omega \\left(\\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y} \\right)\\delta y \\, \\mathrm{d}\\omega + \\int_\\Omega \\nabla\\cdot\\left(\\frac{\\partial f}{\\partial \\nabla y}\\delta y\\right) \\, \\mathrm{d}\\omega . \\end{align} $$而在式（13）中，最后的积分可以根据多元积分的 Green 公式，化成对区域 $\\Omega$ 的边界 $\\partial \\Omega$ 积分。而此时，由于所有的函数在边界上的值都要相等，即边界 $\\partial \\Omega$ 上 $\\delta y = 0$，这样最后一项积分就化为0了。我们写为下面的结果：\n$$ \\begin{align} \\delta F \u0026= \\int_\\Omega \\left(\\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y} \\right)\\delta y \\, \\mathrm{d}\\omega + \\int_\\Omega \\nabla\\cdot\\left(\\frac{\\partial f}{\\partial \\nabla y}\\delta y\\right) \\, \\mathrm{d}\\omega\\\\ \u0026=\\int_\\Omega \\left(\\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y} \\right)\\delta y \\, \\mathrm{d}\\omega + \\int_{\\partial\\Omega} \\left(\\frac{\\partial f}{\\partial \\nabla y}\\delta y\\right)\\cdot\\hat{n} \\, \\mathrm{d}A\\\\ \u0026=\\int_\\Omega \\left(\\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y} \\right)\\delta y \\, \\mathrm{d}\\omega. \\end{align} $$ 这样，我们就距离我们希望得到的形式，Euler-Lagrange 公式只差一步了。注意到这里使用的 $\\delta y$ 是任意的，假如 $\\delta F = 0$，从积分里的内容来看，只能是括号内的部分等于 0。 我们可以看到，上面的过程，可以分为大致四个部分：得到全变分形式，将非目标变分以变分和微分的交换律改写为目标函数变分，消去多余项，由变分任意性得到被积函数内部等于 0。我们因此，可以根据我们已经熟悉的函数导数的概念，将公式（18）中的被积函数括号内这个关键部分定义为泛函的导数，即： $$ \\frac{\\delta F}{\\delta y} = \\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y}, $$ 当其为 0 时， $$ \\frac{\\delta F}{\\delta y} = \\frac{\\partial f}{\\partial y} - \\nabla \\cdot \\frac{\\partial f}{\\partial \\nabla y} = 0, $$ 泛函即取到极值（在我们的情境下即为最小值）。这就是所谓的 Euler-Lagrange 方程。\n最后一步 现在，我们对泛函的概念做了一些解释，并从头建立起了我们之前使用的 Euler-Lagrange 公式。这里我希望做一些补充说明。可以看到这里的泛函导数并不是直接的“某些东西的商然后做极限”，而是将某个对我们有用的部分定义为了泛函导数。对这个概念最佳的解释，就是它等于 0 时代表泛函的极值，通过解这个方程就能得到令泛函取得极值的极限函数。它不应被解释为变化率或者什么别的内容。\n另外，我们上面用到了“二元函数全微分”这样的类比。平心而论，我自己并不是特别能接受这种说法。另一个可行的解释是，将函数 $y$ 化为 $y = \\varphi + \\varepsilon\\eta$，也就是说我们使用了一个任意函数 $\\eta : \\Omega \\to \\mathbb{R}$，让它乘上一个极小的量 $\\varepsilon$，这样就相当于用 $\\varepsilon\\eta$ 形成了一个函数的扰动，即 $\\delta y$。我们要求 $\\eta$ 是一个任意的函数，而在任何计算过程中都保持 $\\eta$ 不变。此时整个式子将会成为只关于 $\\varepsilon$ 的一元函数了。对于一个一元函数，其极值点就会出现在导数等于 0 的位置。那么此时对 $\\varepsilon$ 求偏导，也能得到和上面类似的结论，并且通过格林公式化简得到最后的结论。当然，这也只是另一种思路，仅供参考。\n最后要提出的是，上面的推导过程是和 $f$ 的表达式强相关的，尤其是其依赖的变量。然而当我们再考察其和变量之间的关系时，可以发现每个变量实际上对应到最后的 Euler-Lagrange 公式中都是相对独立的。比如，$x$ 这个部分没有在公式中出现，$y$ 的部分对应对 $y$ 求偏导，而 $\\nabla y$ 的部分则对应着对 $\\nabla y$ 求偏导后再对结果做散度。这个结果是可以预想到的：由于全微分公式，或者换成泛函的语境，全变分公式，的性质，是会出现这样的结果。那么我们也自然可以预想到，假如 $f$ 依赖的变量是别的变量，也应该有类似的结论才对。\n到这里，我们近乎完全搞通了我们最后想要解决问题的路径。我们已经得到了泛函具体的表达式，搞清楚了泛函的核（即那个被积函数 $f$）的参数表，得到了对泛函做变分法的具体思路。我们的下一步，或者最后一步，便是真的带进去算了。\n计算！ 为了读者的精神健康，我们隐藏当 $f$ 依赖情况为 $f(p,\\psi,\\Delta \\psi,\\Delta\\Delta \\psi)$（其中 $p \\in V$ 代表位置）时的 Euler-Lagrange 公式的推导，直接给出结果：\n$$ \\begin{equation} \\frac{\\delta F}{\\delta \\psi} = \\frac{\\partial f}{\\partial \\psi} + \\Delta \\left(\\frac{\\partial f}{\\partial \\Delta \\psi}\\right)+ \\Delta\\Delta \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta \\psi}\\right) \\end{equation} $$ 如果你愿意看推导过程的话： 不，你其实不想看，你只是好奇我到底有没有真的写这些推导过程。事实是：写了，下面就是。\n但是如果你真的想看这个部分，谢谢你，我的努力没有白费。\n我们先根据全变分，写出泛函的核函数变分后的结果：\n$$ \\begin{align*} \\delta F \u0026 = \\delta \\int_V f(p,\\psi,\\Delta\\psi,\\Delta\\Delta\\psi) \\,\\mathrm{d}v \\\\ \u0026 = \\delta \\int_V f(p,\\psi,\\Delta\\psi,\\Delta\\Delta\\psi) \\,\\mathrm{d}v \\\\ \u0026 = \\int_V \\delta f(p,\\psi,\\Delta\\psi,\\Delta\\Delta\\psi) \\,\\mathrm{d}v \\\\ \u0026 =\\int_V \\left(\\frac{\\partial f}{\\partial \\psi}\\right)\\delta \\psi + \\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right)\\delta \\Delta\\psi + \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right)\\delta \\Delta\\Delta\\psi \\,\\mathrm{d}v. \\\\ \\end{align*} $$接下来我们分别考察被积分的每一项。其中第一项的对 $\\psi$ 的变分 $\\delta\\psi$ 已经符合我们的要求了，第二项中的 $\\delta \\Delta \\psi$ 和第三项中的 $\\delta \\Delta\\Delta\\psi$ 则需要我们处理为某个函数乘以 $\\delta\\psi$ 的形式，以便于最后的逻辑处理。\n根据变分与求导和交换的关系，我们有：\n$$ \\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right)\\delta \\Delta\\psi = \\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right)\\Delta \\delta \\psi = f_1 \\Delta\\delta\\psi;\\\\ \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right)\\delta \\Delta\\Delta\\psi = \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right) \\Delta\\Delta\\delta\\psi = f_2\\Delta\\Delta\\delta\\psi, $$其中每行公式的第二个等号都是为了护眼做的处理，即将括号中的偏微分用记号表示。我们先看上面第一个式子，这是两个标量函数的乘积，其第二个因式展开应为：\n$$ \\Delta \\delta \\psi = \\nabla \\cdot \\nabla \\delta\\psi, $$注意到散度存在恒等式：$\\nabla \\cdot (f\\mathbf{v}) = f\\nabla\\cdot\\mathbf{v} + \\nabla f \\cdot \\mathbf{v}$，其中 $f$ 为标量函数或标量场，$v$ 为向量值函数或向量场，我们可以对上面的结果变换得到：\n$$ \\begin{align*} f_1\\nabla \\cdot \\nabla \\delta\\psi \u0026= \\nabla\\cdot(f_1\\nabla\\delta\\psi) - \\nabla f_1\\cdot \\nabla\\delta\\psi \\\\ \u0026= \\nabla\\cdot(f_1\\nabla\\delta\\psi) - \\nabla\\cdot(\\delta\\psi\\nabla f_1) + \\delta \\psi \\nabla\\cdot\\nabla f_1. \\end{align*} $$上式对一个三维区域 $\\Omega$ 的积分，根据散度定理，有：\n$$ \\begin{align*} \\int_V f_1 \\nabla\\cdot\\nabla\\delta\\psi \\,\\mathrm{d}v \u0026= \\int_V \\nabla\\cdot(f_1\\nabla\\delta\\psi)\\,\\mathrm{d}v -\\int_V \\nabla\\cdot(\\delta\\psi\\nabla f_1) \\,\\mathrm{d}v+\\int_V \\delta \\psi \\nabla\\cdot\\nabla f_1 \\,\\mathrm{d}v \\\\ \u0026=\\int_{\\partial V} f_1\\nabla\\delta\\psi\\cdot\\hat{n}\\,\\mathrm{d}s - \\int_{\\partial V} \\delta\\psi\\nabla f_1\\cdot\\hat{n} \\,\\mathrm{d}s + \\int_{V} \\delta \\psi \\nabla\\cdot\\nabla f_1 \\,\\mathrm{d}v\\\\ \u0026=\\int_{V} \\delta \\psi \\nabla\\cdot\\nabla f_1 \\,\\mathrm{d}v. \\end{align*} $$上式第二个等号使用了散度定理，第三个等号则是考虑到在边界处 $\\delta\\psi = 0$，$\\nabla\\delta\\psi = \\mathbf{0}$。这样我们就得到了原变分中被积函数第二项的表达形式。我们现在考虑其中的第三项，即 $f_2\\Delta\\Delta\\delta\\psi$。我们先将其中的 $\\Delta\\delta\\psi$ 看作函数标量函数 $\\varphi$，则原式写为 $f_2\\Delta\\varphi$。此时，套用我们上面已经得到的结果，有：\n$$ \\begin{align*} \\int_V f_2 \\Delta\\Delta\\delta\\psi \\,\\mathrm{d}v \u0026= \\int_V f_2 \\Delta\\varphi \\,\\mathrm{d}v\\\\ \u0026= \\int_{\\partial V} f_2\\nabla\\varphi\\cdot\\hat{n}\\,\\mathrm{d}s -\\int_{\\partial V} \\varphi\\nabla f_2\\cdot\\hat{n} \\,\\mathrm{d}s+\\int_V \\varphi \\nabla\\cdot\\nabla f_2 \\,\\mathrm{d}v \\\\ \u0026= \\int_V \\varphi \\nabla\\cdot\\nabla f_2 \\,\\mathrm{d}v = \\int_V \\varphi \\Delta f_2 \\,\\mathrm{d}v \\\\ \u0026= \\int_V \\Delta\\delta\\psi \\Delta f_2 \\,\\mathrm{d}v = \\int_V \\nabla\\cdot\\nabla\\delta\\psi\\, \\Delta f_2 \\,\\mathrm{d}v\\\\ \u0026= \\int_{\\partial V} \\Delta f_2\\nabla\\delta\\psi \\cdot\\hat{n}\\,\\mathrm{d}s -\\int_{\\partial V} \\delta\\psi\\,\\nabla (\\Delta f_2)\\cdot\\hat{n} \\,\\mathrm{d}s+\\int_V \\delta\\psi \\Delta \\Delta f_2 \\,\\mathrm{d}v \\\\ \u0026= \\int_V \\delta\\psi \\Delta \\Delta f_2 \\,\\mathrm{d}v, \\end{align*} $$其中所有的操作与前面是一样的，不断用恒等式拆开，然后由于在边界上的包含 $\\delta\\psi$ 的项全部归零，所有对 $V$ 的边界 $\\partial V$ 的积分都会变成 0，最后就得到了我们想要的结果。我们把这些积分再合起来，将为了方便所做的记号带回，就有：\n$$ \\begin{align*} \\delta F \u0026 = \\int_V \\left(\\frac{\\partial f}{\\partial \\psi}\\right)\\delta \\psi + \\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right)\\delta \\Delta\\psi + \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right)\\delta \\Delta\\Delta\\psi \\,\\mathrm{d}v \\\\ \u0026= \\int_V \\left(\\frac{\\partial f}{\\partial \\psi}\\right)\\delta \\psi + \\Delta\\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right)\\delta \\psi + \\Delta\\Delta\\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right)\\delta \\psi \\,\\mathrm{d}v \\\\ \u0026= \\int_V \\left(\\left(\\frac{\\partial f}{\\partial \\psi}\\right) + \\Delta\\left(\\frac{\\partial f}{\\partial \\Delta\\psi}\\right) + \\Delta\\Delta\\left(\\frac{\\partial f}{\\partial \\Delta\\Delta\\psi}\\right)\\right)\\delta \\psi \\,\\mathrm{d}v. \\end{align*} $$那么，由泛函导数的定义，我们就得到了 Euler-Lagrange 方程： $$ \\frac{\\delta F}{\\delta \\psi} = \\frac{\\partial f}{\\partial \\psi} + \\Delta \\left(\\frac{\\partial f}{\\partial \\Delta \\psi}\\right)+ \\Delta\\Delta \\left(\\frac{\\partial f}{\\partial \\Delta\\Delta \\psi}\\right). $$ 现在，我们来把式子带进去吧。为了方便，我们先把公式待带入的公式写在下面：\n$$ \\begin{align} F[\\psi] \u0026= \\int_V f(p,\\psi,\\Delta\\psi,\\Delta\\Delta\\psi) \\mathrm{d}v\\\\ \u0026= \\int_V \\left(\\frac{\\psi}{2} \\omega \\left(\\nabla ^2\\right)\\psi + \\frac{\\psi^4}{4}\\right) \\mathrm{d}v;\\\\ \\omega (\\nabla^2) \u0026= r + \\left(1 + \\nabla ^2\\right)^2;\\\\ \\frac{\\partial \\psi}{\\partial t} \u0026= \\nabla^2 \\frac{\\delta F}{\\delta \\psi} + \\xi.\\\\ \\end{align} $$我们的目的也就是将公式（21）先带入公式（20）得到能量的具体表达形式，然后将得到的结果带入公式（18）来计算能量变分，最后得到公式（22）的显式表达。其中第一步已经完成了，能量密度的具体表达形式为：\n$$ \\begin{equation} f = r\\frac{\\psi^2}{2} + \\frac{\\psi^2}{2} + \\psi \\Delta\\psi + \\frac{1}{2}\\psi\\Delta\\Delta\\psi + \\frac{\\psi^4}{4}. \\end{equation} $$我们先对公式（23）计算需要的这些偏导数，得到：\n$$ \\begin{align} \\frac{\\partial f}{\\partial \\psi} \u0026= r\\psi + \\psi + \\Delta\\psi + \\frac{1}{2}\\Delta\\Delta\\psi+\\psi^3;\\\\ \\frac{\\partial f}{\\partial \\Delta \\psi} \u0026= \\psi;\\\\ \\frac{\\partial f}{\\partial \\Delta\\Delta \\psi} \u0026=\\frac{1}{2}\\psi. \\end{align} $$现在把这些得到的结果，即公式（24-26）带入到我们得到的 Euler-Lagrange 方程（18）中。注意在前面加上对应的 Laplace 算子或者双调和算子。得到的结果为：\n$$ \\begin{align} \\frac{\\delta F}{\\delta \\psi} \u0026= r\\psi + \\psi + \\Delta\\psi + \\frac{1}{2}\\Delta\\Delta\\psi+\\psi^3 + \\Delta\\psi+\\frac{1}{2}\\Delta\\Delta\\psi \\\\ \u0026=r\\psi + \\psi + 2\\Delta\\psi + \\Delta\\Delta\\psi+\\psi^3\\\\ \u0026=\\left(r + \\left(1 + 2\\Delta + \\Delta\\Delta\\right)\\right)\\psi+\\psi^3\\\\ \u0026=\\omega(\\Delta)\\psi + \\psi^3.\\\\ \\end{align} $$那么最后，把式（30）带回到式（22）中。此时我们尊重原文，把符号统一，将 $\\Delta$ 重写回 $\\nabla^2$，就有：\n$$ \\begin{equation} \\frac{\\partial \\psi}{\\partial t} = \\nabla^2 \\left(\\omega(\\nabla^2)\\psi + \\psi^3\\right) + \\xi. \\end{equation} $$这就是我们一开始的目标，式（4）。\n后记 其实这个问题一开始就很清楚：只要找到正确的 Euler-Lagrange 公式，带入无脑计算就行了。但是如何找到正确的 Euler-Lagrange 公式则是一个比较棘手的问题。本文的思路启发自老大中先生的《变分法基础》，翻开书，几乎所有的笔墨全都放在了如何去根据泛函的形式来推导出对应的 Euler-Lagrange 方程上。所幸，我们的这个方程形式非常简单，且答案几乎是现成的，只需要找到正确的位置后取用即可。\n那么这篇文章前面的部分有什么用呢？像跳梁小丑一样跳来跳去，最后发现从一开始就不对劲，转而从头开始推导整个公式。如果一开始就找到这个合适的公式，不就好了吗？也许能够找到这个合适的公式确实能立马解决眼前的问题，但是以后呢？如果遇到了一个形式又不太一样的泛函，此时应该怎么推导出其对应的 Euler-Lagrange 方程呢？而且从文章前半部分可以看到：我对变分法的理解，在推导出这个公式以前，是有问题的。我机械地认为就是带入那个人尽皆知的 Euler-Lagrange 方程，然后算算算就好了。旋即就遇到了第一个问题：怎么让 Laplacian 对梯度求导。是的，我当时并不怀疑是公式问题，而是考虑怎么让这个公式能算下去。在网上搜索一段时间之后，我貌似得到了结果，但总归不太满意，因为带入后得不到最后的公式。\n一段迷茫过后，我突然对变量之间的依赖情况产生了疑惑。网上搜寻的结果表明，不能单纯地看作相互关联的变量，或者说单纯的求导关系。最后我得到了上文中的解释，也许我在这部分的解释是错误的，但我用这个方法说服了自己。希望这个观点没有问题。顺带，我得到这个解释或多或少受到了热力学的启发：热力学中的偏导数必须标明哪些变量是固定不变的，这时因为热力学参数张成了一个高维空间，而体系的热力学状态则是这个空间上的一个超平面，热力学状态函数则是这个超平面上定义的场。因此，对热力学状态函数求偏导的时候必须固定求导方向，也就是固定某些变量不变。也许是这样的理解让我将泛函的核理解为了对函数的约束（我也不知道怎么联系上去的，所以说只可谓之启发）。\n然而即便如此，我依旧没法得到最后最关键的公式。此时只能从头开始一步步推导 Euler-Lagrange 公式了。所幸，我找到了老大中先生的这本书，读过一部分之后，遍跳着找到了我需要的答案。感谢这本书，让我少走了不知道多少弯路。\n最后，感谢您能阅读到这里，看这么久的流水账也挺辛苦的。希望这篇流水账一样的文章也能帮助正在阅读的你增进对 Euler-Lagrange 公式和变分法或者泛函导数的理解。\n那么，祝您生活愉快~\n请容许我这里混淆最小值和极小值，以及最值和极值，因为我们默认需要这个泛函取到的是极小的部分，且这个极小值一定是全局的，即最小值。\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2025-01-04T00:00:00+08:00","image":"/images/HelloWorld-r-906.jpg","permalink":"/zh/posts/math_note/functional_derivative/","title":"关于泛函导数和变分法-来自相场公式推导的问题"},{"content":"其实这节就是换成 Allen-Cahn 方程，然后多个变量而已，主要是俺不想实现 Voronoi 结构（逃\n简介 上一部分我们以调幅分解为基础讨论了浓度场在 Cahn-Hilliard 方程下的演化过程。对相场方法而言，另一个无法绕开的演化方程则是针对非保守场变量的 Allen-Cahn 方程。这一部分我们将对晶粒长大过程进行分析，了解 Allen-Cahn 方程并使用它进行晶粒长大过程的模拟。\n晶粒长大 晶粒长大的过程各位材料学子应该已经很熟悉了，在这个过程中，由于晶界能量较高而体自由能相较之下较低，体系能量希望能够达到全局最低的情况下，需要尽可能地降低晶界能在总能中的占比，提高体自由能的占比。但是，晶界能的能量密度应该是某个变化不大的数值，几乎可以看作定值，因此为了降低晶界能，体系会倾向于降低晶界的体积，提高晶体的体积。这样一来，从宏观上的表现来看就是晶粒长大的过程。\n这样来看，晶粒长大过程对能量的需求是：晶粒内的能量较低，晶界处的能量较高。那么，之前的 Cahn-Hilliard 方程 + 浓度的组合是否可行呢？我们需要考察晶粒长大过程中涉及什么量。由于同一种相的不同的晶粒都具有相同的成分，故无法用浓度来表示某一个特定的晶粒；不考虑取向的话，晶粒与晶粒之间只存在位置，大小之间的差异，也就是单纯的几何差异。而在界面上，一个晶粒与另一个晶粒相接，还需要某种方法来表示某些晶粒之间的界面。而且在晶粒生长的过程中，小晶粒可能会变得更小最后消失。\n综合上面来看，Cahn-Hilliard 方程和浓度并不适合这个问题，那么应该如何处理呢？相场方法中我们经常使用场变量来表示某个区域存在某种性质，那么可以考虑为每个不同的晶粒赋予一个不同的变量，比如假设有 10 个小晶粒，那么就使用 10 个变量来分别代表这些晶粒区域。其次，考虑到这些晶粒仅存在位置上的差异，我们需要在他们参与热力学讨论时没有数值上的差异。我们可以让这些变量类比于浓度：存在于某区域时变量为 1，不存在则变量为 0。这样，界面部分也可以简单地表示出来：所有序参量都不为 0 的部分即为界面部分，这样也摆脱了追踪界面的麻烦。\n那么，我们需要使用什么样的方程来演化这样的场呢？这个场，根据上面的分析，是不满足守恒条件的。我们这里就不卖关子了（因为上个部分已经剧透了），答案就是使用 Allen-Cahn 方程。那么这个体系的能量呢？这个方程要怎么理解呢？\n模型分析 演化方程 我们先看看 Allen-Cahn 方程吧： $$ \\frac{\\partial \\eta_i}{\\partial t} = -L_{ij}\\frac{\\delta F}{\\delta \\eta_j} $$ 简单地令人发指（也许）。简单来说，就是某个变量的变化速率受到所有的势的加权求和影响。但是要如何理解这个公式呢？它是怎么来的呢？其实如果有看过上一篇内容的话，应该已经猜到了。如果物质不守恒，那么物质流的散度项就可以替换成某种别的形式。这个所谓“别的形式”需要满足这些条件：\n需要和能量/势相关以满足热力学要求 最终是令体系演化达到平衡的 那么干脆就让演化速率和势成正比好了，然后用符号调整演化方向最终是朝着体系稳定的方向发展的。这样就得到了上面的 Allen-Cahn 方程。\n除了这样解释之外，我们还可以采用更加数学一些的方式。当我们需要体系朝着稳定方向发展时，实际上也就是说我们希望体系自由能向着最小的方向发展。而当体系稳定时，我们有如下关系：\n$$ \\frac{\\delta F}{\\delta \\eta_i} = 0 $$这里需要运用我们已经了解到的泛函导数的相关内容，当泛函导数为 0 时，说明这个构型的 $\\eta_i$ 是令泛函 $F$ 取得极值的点。考虑到 $F$ 具有能量的物理意义，这里的极值自然是极小值。在热力学中，极小值表示体系至少出于亚稳态。当该构型下恰好能量是最低的时，则体系出于热力学稳定状态。\n这样一来，体系的稳态表示就没有任何问题，但是我们需要的是向着稳态演化，而不是直接求得稳态的状态。这时就需要使用经典的数值方法：弛豫法。我们给方程右边的 0 改变为某个微小变量。这个变量的意义是令体系向着平衡态发展，所以这个变量应该越来越小。通过不断迭代，最终这个微小变量将趋近于 0，此时我们便得到了平衡稳态的结果。那么这个微小变量，按照我们的预想，应该和场变量本身是相关的；不断迭代的过程又说明和时间相关。要让这个微小变量不断减小，根据能量函数（泛函）的特性，干脆就让弛豫变量设置为场变量的演化速率，再乘上一个弛豫常数。由于演化方向，弛豫常数应该是一个小于 0 的值，最后考虑所有项的影响，就得到了这个方程。\n最后要指出，上面这些都是从一些不够物理的，十分唯象的角度来提出的。实际上，Allen-Cahn 方程是建立晶界迁移速率与驱动力成正比这一结论得出的，而这一结论更是从晶体排列构型的基态得到的。\n关于 A-C 方程的一些碎碎念另外，这个形式的方程非常常见，或者说，水非常深。几乎可以在物理学的许多领域见到这个方程，而对这个方程的描述都各有千秋。有人称其为 Landau–Khalatnikov 方程（描述磁性），有人称之为 Model A（界面动力学，[Theory of dynamic critical phenomena](https://doi.org/10.1103/RevModPhys.49.435)），还有一些奇奇怪怪的名称，但是这些文章几乎都没有对这个方程做出详细的解释。也许这些方程是从某些物理直觉中得到的？又或者这些这些方程有其更深刻的数学/物理背景，但是这些我也无从得知。 能量构造 本模拟使用的能量模型来自D. Fan 与 LQ. Chen 的文章，其构造如下： $$ \\begin{align*} F \u0026= \\int_\\omega f_{bulk} + f_{int} \\,\\mathrm{d}\\Omega;\\\\ f_{bulk}\\left(\\eta_0,\\eta_1,\\cdots,\\eta_N\\right) \u0026= \\sum_{i}^{N}\\left( -\\frac{A}{2}\\eta_i^2 + \\frac{B}{4}\\eta_i^4 \\right) + \\sum_{i}^{N}\\sum_{j\\neq{}i}^{N}\\eta_i^2\\eta_j^2;\\\\ f_{int}\\left(\\nabla\\eta_0,\\nabla\\eta_1,\\cdots,\\nabla\\eta_N\\right) \u0026= \\sum_{i}^{N}\\frac{\\kappa_i}{2} \\left| \\nabla \\eta_i \\right|^2. \\end{align*} $$其中的界面能项我们不再赘述，因为它就是上一部分介绍过的能量而已，只不过这里要加上所有序参量的贡献而已。我们重点放在体能上。和上次相比，体能的部分变化很大，但是也有一些熟悉的部分。我们把这个体能重新整理一下： $$ \\begin{align} f_{bulk}\u0026= \\sum_{i}^{N}\\left( -\\frac{A}{2}\\eta_i^2 + \\frac{B}{4}\\eta_i^4 \\right) + \\sum_{i}^{N}\\sum_{j\\neq{}i}^{N}\\eta_i^2\\eta_j^2\\\\ \u0026=\\sum_{i}^{N}\\left( -\\frac{A}{2}\\eta_i^2 + \\frac{B}{4}\\eta_i^4 \\right) + \\sum_{i}^{N}\\eta_i^2 \\sum_{j\\neq{}i}^{N} \\eta_j^2\\\\ \u0026=\\sum_{i}^{N}\\left(\\left( -\\frac{A}{2}\\eta_i^2 + \\frac{B}{4}\\eta_i^4 \\right) + \\eta_i^2 \\sum_{j\\neq{}i}^{N} \\eta_j^2\\right)\\\\ \u0026=\\sum_{i}^{N}\\left( \\left(-\\frac{A}{2} + \\sum_{j\\neq{}i}^{N} \\eta_j^2\\right) \\eta_i^2 + \\frac{B}{4}\\eta_i^4 \\right)\\\\ \\end{align} $$ 上面第二个等号是由于对 $j (j\\neq i)$ 的求和的部分与 $i$ 无关，我们可以把 $\\eta_i^2$ 从二重求和中提出来，然后第三个等号中把对 $i$ 的求和的部分提取合并，最后第四个等号里提出 $\\eta_i^2$，把 $\\sum_{j\\neq{}i}^{N} \\eta_j^2$ 作为系数和 $-\\dfrac{A}{2}$ 合并。现在我们把目光放在求和里面的这个，关于 $\\eta_i$ 的多项式。为简单起见，我们设 $A = B = 1$。下面是这个函数在 $\\sum_{j\\neq{}i}^{N} \\eta_j^2 = 0$ 时候，即 方程（1）的第一项的图像（当然，这个假设不够合理，但是我们可以先看看）。\n是我们很熟系的双势阱，但是这里有一些问题：序参量实际上不能小于 $0$，也不应该大于 $1$。如果我们只关注 $0$ 到 $1$ 之间的值，不难发现现在这个能量的最低点处在 $\\eta = 1$ 的地方。这很合理：在其余的所有序参量（我们简称所有的 $j$ ，对应的本个序参量则为 $i$）都为 $0$ 的情况下，或者说在没有任何 $j$ 的点，$i$ 占据该点是理所应当的。考虑到每个序参量都是平权的，这说明了这个方程是符合体相内部的热力学要求的。接下来我们考察当这个求和项不为 $0$ 的情况。$j$ 求和的值的变化范围我们先设为 $0$ 到 $2$。\n可以看到，当 $j$ 不为 $0$ 时，结果也是合理的，这里 $i$ 的值也不在 $1$ 处取到最小值，而是在 $0$ 到 $1$ 之间的区域取到最小值了。感兴趣的话您可以自行尝试绘制这里的图像，然后调整这些值，观察序参量-能量曲线的最低点位置。当然，我们也可以使用更数学一些的方式来研究这里的最低点取值情况，不过图像方法更加直观一些就是了。最后来简单描述一下表达式 (1) 的物理意义：将每个参量都赋予一个类势阱的能量，然后通过第二项的交叉作用将这些能量结合在一起。第二项的二重和即是其他参量对本参量的影响。\n相场演化方程与扩散方程之间的关系 我们其实很早就发现了 Allen-Cahn 和 Cahn-Hilliard 两个方程与扩散方程（Fick 定律）之间的相似性。我们现在来更仔细地看看这些方程与 Fick 定律之间有什么关系吧。\n对于 Cahn-Hilliard 方程而言，可以看到它与 Fick 第二定律的形式非常地像。如何看待这种相似性呢？我们可以讲，对于 Fick 第二定律而言，其提供扩散驱动力的部分是浓度本身，或者说浓度的梯度。而当这个驱动力放在更加广阔的语境下时，例如，上坡扩散等现象发生时，我们必须根据热力学原理，使用扩散势来解释这类现象。因此，可以将 Cahn-Hilliard 方程看作是更加符合热力学原理的扩散方程。\n那么，Allen-Cahn 方程呢？我们需要把 Allen-Cahn 方程和经典的能量泛函构造联系起来，并展开公式。这时我们得到： $$ \\frac{\\partial \\eta_i}{\\partial t} = L_{ij}\\nabla^2 \\eta_j - L\\mu_i $$这个形式，熟悉吗？如果去掉第二项，那么这个方程就是 Fick 第二定律！那么第二项代表了什么呢？第二项实际上代表了某种界面上发生的反应。为什么说是界面上的？观察这个方程与 Fick 第二定律所代表的情况，第一项代表了某个变量是守恒的，然而第二项的化学势的存在打破了这种平衡。我们使用界面上的反应来解释这种情况的出现是最合适的：不守恒的序参量是被“消耗”掉了。实际上，按照这种思路，我们可以构造出更加复杂的演化方程，即根据体系内存在的反应，向能量中添加反应造成的能量变动，最后则会反映到 Allen-Cahn 方程的反应项中。\n问题分析 OK，现在我们应该对这次模拟所需要的演化方程以及能量构造有一定的理解了。这次我们要尝试的问题是：假设有两块单晶，一块在求解域的中心，形状是半径 $14$ 单元的一个圆盘，另一块则填充求解区域。现在需要通过模拟得到晶粒长大的过程。\n十分简单的问题，只需要创建两个序参量网格，然后对每个网格进行迭代即可。也许求和部分有一些问题，然而可以通过一些程序技巧简化一部分的运算。直接看代码吧。\n代码实现 我们依旧使用 C++ 实现，这里一次性全都贴出来。\n1#include \u0026lt;filesystem\u0026gt; 2#include \u0026lt;fstream\u0026gt; 3#include \u0026lt;iostream\u0026gt; 4#include \u0026lt;string\u0026gt; 5#include \u0026lt;vector\u0026gt; 6 7double laplacian(double eta_l, double eta_r, double eta_d, double eta_u, double eta_c, double dx) { 8 return (eta_l + eta_r + eta_d + eta_u - 4.0 * eta_c) / (dx * dx); 9} 10 11double df_deta(double A, double B, double eta_square_sum, double this_eta) { 12 return -1.0 * A * this_eta + B * this_eta * this_eta * this_eta + 2.0 * this_eta * (eta_square_sum - this_eta * this_eta); 13} 14 15std::ofstream create_vtk(std::string file_path, int time_step) { 16 std::filesystem::create_directory(file_path); 17 std::filesystem::path f_name{\u0026#34;step_\u0026#34; + std::to_string(time_step) + \u0026#34;.vtk\u0026#34;}; 18 f_name = file_path / f_name; 19 20 std::ofstream ofs{f_name}; 21 return ofs; 22} 23 24void write_vtk_head(std::ofstream \u0026amp;ofs, std::string filename, double dx, size_t Nx, size_t Ny) { 25 ofs \u0026lt;\u0026lt; \u0026#34;# vtk DataFile Version 3.0\\n\u0026#34;; 26 ofs \u0026lt;\u0026lt; filename \u0026lt;\u0026lt; std::endl; 27 ofs \u0026lt;\u0026lt; \u0026#34;ASCII\\n\u0026#34;; 28 ofs \u0026lt;\u0026lt; \u0026#34;DATASET STRUCTURED_GRID\\n\u0026#34;; 29 30 ofs \u0026lt;\u0026lt; \u0026#34;DIMENSIONS \u0026#34; \u0026lt;\u0026lt; Nx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; Ny \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; 31 ofs \u0026lt;\u0026lt; \u0026#34;POINTS \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; \u0026#34; float\\n\u0026#34;; 32 33 for (size_t i = 0; i \u0026lt; Nx; i++) { 34 for (size_t j = 0; j \u0026lt; Ny; j++) { 35 ofs \u0026lt;\u0026lt; (double)i * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; (double)j * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; std::endl; 36 } 37 } 38 ofs \u0026lt;\u0026lt; \u0026#34;POINT_DATA \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; std::endl; 39} 40 41void write_vtk_data(std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; mesh, std::ofstream \u0026amp;ofs, std::string data_label, double dx) { 42 size_t Nx{mesh.size()}, Ny{mesh.at(0).size()}; 43 ofs \u0026lt;\u0026lt; \u0026#34;SCALARS \u0026#34; \u0026lt;\u0026lt; data_label \u0026lt;\u0026lt; \u0026#34; float 1\\n\u0026#34;; 44 ofs \u0026lt;\u0026lt; \u0026#34;LOOKUP_TABLE default\\n\u0026#34;; 45 for (size_t i = 0; i \u0026lt; Nx; i++) { 46 for (size_t j = 0; j \u0026lt; Ny; j++) { 47 ofs \u0026lt;\u0026lt; mesh.at(i).at(j) \u0026lt;\u0026lt; std::endl; 48 } 49 } 50} 51 52int main() { 53 int Nx = 64; 54 double dx = 0.5, dt = 0.005; 55 int nstep = 20000, pstep = 100; 56 int radius = 14; 57 double mobility = 5.0, kappa = 0.1; 58 double A = 1.0, B = 1.0; 59 double eta_trun = 1e-6; 60 61 std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; grain_1(Nx, std::vector\u0026lt;double\u0026gt;(Nx, 0)); 62 auto grain_2 = grain_1; 63 64 for (int i = 0; i \u0026lt; Nx; i++) { 65 for (int j = 0; j \u0026lt; Nx; j++) { 66 if ((i - Nx / 2) * (i - Nx / 2) + (j - Nx / 2) * (j - Nx / 2) \u0026lt; radius * radius) { 67 grain_1.at(i).at(j) = 1.0; 68 grain_2.at(i).at(j) = 0.0; 69 } else { 70 grain_1.at(i).at(j) = 0.0; 71 grain_2.at(i).at(j) = 1.0; 72 } 73 } 74 } 75 76 std::vector\u0026lt;std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt;\u0026gt; grains = {grain_1, grain_2}; 77 auto grains_temp = grains; 78 79 for (int istep = 0; istep \u0026lt; nstep + 1; istep++) { 80 std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; grain_square_sum(Nx, std::vector\u0026lt;double\u0026gt;(Nx, 0)); 81 for (int igrain = 0; igrain \u0026lt; 2; igrain++) { 82 for (int i = 0; i \u0026lt; Nx; i++) { 83 for (int j = 0; j \u0026lt; Nx; j++) { 84 grain_square_sum.at(i).at(j) += grains.at(igrain).at(i).at(j) * grains.at(igrain).at(i).at(j); 85 } 86 } 87 } 88 for (int igrain = 0; igrain \u0026lt; 2; igrain++) { 89 for (int i = 0; i \u0026lt; Nx; i++) { 90 for (int j = 0; j \u0026lt; Nx; j++) { 91 int im = i - 1, jm = j - 1, ip = i + 1, jp = j + 1; 92 if (im == -1) { 93 im = Nx - 1; 94 } 95 if (jm == -1) { 96 jm = Nx - 1; 97 } 98 if (ip == Nx) { 99 ip = 0; 100 } 101 if (jp == Nx) { 102 jp = 0; 103 } 104 double eta_l = grains.at(igrain).at(im).at(j); 105 double eta_r = grains.at(igrain).at(ip).at(j); 106 double eta_d = grains.at(igrain).at(i).at(jm); 107 double eta_u = grains.at(igrain).at(i).at(jp); 108 double eta_c = grains.at(igrain).at(i).at(j); 109 110 grains_temp.at(igrain).at(i).at(j) = eta_c - mobility * dt * (df_deta(A, B, grain_square_sum.at(i).at(j), eta_c) - kappa * laplacian(eta_l, eta_r, eta_d, eta_u, eta_c, dx)); 111 112 if (grains_temp.at(igrain).at(i).at(j) \u0026gt; 1.0 - eta_trun) { 113 grains_temp.at(igrain).at(i).at(j) = 1.0; 114 } 115 if (grains_temp.at(igrain).at(i).at(j) \u0026lt; eta_trun) { 116 grains_temp.at(igrain).at(i).at(j) = 0.0; 117 } 118 } 119 } 120 } 121 grains = grains_temp; 122 if (istep % pstep == 0) { 123 auto ofs = create_vtk(\u0026#34;./result\u0026#34;, istep); 124 write_vtk_head(ofs, \u0026#34;step_\u0026#34; + std::to_string(istep), dx, Nx, Nx); 125 write_vtk_data(grains.at(0), ofs, \u0026#34;grain_1\u0026#34;, dx); 126 write_vtk_data(grains.at(1), ofs, \u0026#34;grain_2\u0026#34;, dx); 127 } 128 } 129} 这次我们优化了 vtk 文件的生成函数，使之能够分部写入。其余部分都是非常简单的。考虑到计算过程，这次的模拟甚至比上次的还要简单一些。运行这里的代码之后，程序会在其位置生成一个 result/ 文件夹并且把结果文件都放在里面。和之前一样，使用 Paraview 即可打开这些文件了。\n结果 和上次一样，这里就贴一下几张截图。\n第5步 第25步 第75步 第150步 可以看到，随着时间推进，小晶粒（中间红色部分）被大晶粒（蓝色部分）不断吞并。而且根据步数，可以看到一开始由于两个晶粒的体积近似，演化速率并不大；随着不断的演化，两个晶粒的体积差距越来越大，演化速率也变大了。这符合我们对晶粒长大过程的认知，小晶粒会非常快速地消失，而较大的晶粒则会演化地比较慢。\n结语 我刚开始写的时候也没有想到这里会写这么多的模型解析的内容。不过也算是补充了之前对相场模型介绍不足的问题吧。这里的模拟部分，因为是参考的 Programming Phase Field Modeling 的 Case Study II，实际上还应该实现一下 Voronoi 结构的模拟，然后把多序参量情况下的代码结构处理一下。这里的代码应该是没有完全支持多相场情况的。但，我实在不想手搓一个 Voronoi 结构生成的函数，而能生成这个结构的库都太大了，我也不想给这个示例/教学代码引入什么第三方库。所以，结果就是，这里只实现了两个晶粒的模拟。也许之后会突然对 Voronoi 结构生成算法开窍了，然后就写进这个程序里呢？那也是以后的事了。\n和上一个部分一样，对模型和模拟过程更深层的理解是离不开调整参数进行测试的。这两个案例都是比较简单的案例，可调的参数并不多，而且在模拟一开始的时候就已经有了参考的参数了，也不是面对的实际存在的体系，填入的数字的物理意义并没有很大，或者说是比较唯结果论的一些数据。在面对实际物理体系的时候，填参数这块儿是模拟过程最折磨的部分了。如何精确地控制这些参数，让他们配合起来形成一个符合物理特性，而且也能跑出合理结果，这也许是相场方法最麻烦的点。参数的可解释性经常会和参数的数值特性相悖，而能平衡二者的结果几乎都是经过精心设计的。总而言之，多调参数总没错。\n那么这就是这个教程（自称）系列的最后一部分了。相场方法作为一种材料模拟方法，能做的东西非常多，但是其本身也有一定的限制。它最大的限制就是所谓的扩散界面，这样的界面解决了微分方程不好解的问题，但是也让这个方法很容易滑向物理意义不明确的道路，也经常会因为界面的存在而导致一些模拟发生数值失稳（即便引入界面常常就是希望能解决数值失稳的）。这些特点注定让相场成为一门比较复杂的交叉学科：需要对材料科学有深入的理解，对材料的物理特性有清晰的物理图像，对数值方法有清晰的认知，明白各种方法之间的优缺点选择合适的方法，最后还需要有一定的程序能力来支撑实现模拟。这也许也是相场复杂的地方吧。\n相场方法并不是一个很新的模拟方法，但是它还有很大的发展空间。不论是比较传统的调幅分解的深入研究模拟，还是使用相场法研究固体力学、电磁学、流体力学这些更复杂的外场，又或者是开发新的程序软件来帮助进行相场模拟，甚至是使用机器学习来辅佐相场计算，这些都是相场正在发展的方向。这个系列的教程希望能够提供相场方法最基础的部分，比如相场的数学基础，程序基础等等。这些内容应该能够成为学习相场过程中比较重要的工具，以便于学习和发展更深层的更复杂的理论/实践。希望阅读本教程的您可以从中有所收获。\n那么就是这样，最后祝您生活愉快，科研顺利~\n","date":"2024-12-25T00:00:00+08:00","image":"/images/Skadi.png","permalink":"/zh/posts/pf_tutorial/pf_tutorial_5/","title":"Phase Field: 相场模拟学习笔记 V"},{"content":"终于，真的要做相场模拟了。先从最软的柿子，调幅分解开始吧\n简介 所以，经过前面三个部分的学习，利用 C++ 进行相场模拟的所有前置几乎全部获得了：公式推导，编程基础，基础算法等，几乎全都拿到手了。这部分开始，我们就正式开始用 C++ 实现相场模拟。我们先从一个很经典且简单的例子开始：A-B 合金的调幅分解。\n调幅分解 所以什么是调幅分解？它有什么特殊的地方？为什么相场法的第一个例子是算这么个有点陌生的东西？我们一个一个来解答这些问题。\n首先值得明确的是，调幅分解是一类相变过程，且这类过程非常适合使用相场法来计算其演化过程。作为相变，我们自然关心其相图和自由能曲线的情况。下面是一副调幅分解的示意图：\n从图上可以看到，调幅分解下的自由能曲线十分特殊，呈现一种双势阱的形貌。再观察这里的相图和自由能曲线，可以看到虚线部分对应于自由能曲线中比较平坦的区域。没错，这个点即为所谓的拐点（Spinodal Point），这也是调幅分解的英文 Spinodal Decomposition 中“Spinodal”的来由。由于调幅分解自由能曲线的特殊性，当自由能对成分的二阶偏导小于 0 时，如果成分正好出于混溶间隙里调幅线内，那么任何一点微小的成分扰动都会导致整个体系的稳定性被破坏，产生的自由能差（即所谓的相变驱动力）将增大并将体系演化至自由能曲线中“谷底”的位置，并形成所谓调幅分解的形貌。\n调幅分解最特殊的地方在于，相场法这个方法几乎可以说是起源于调幅分解过程的。观察调幅分解的自由能曲线，它描述了一个中间态物质由于自身能量最小化的要求从而分散为两个不同组分的物质的过程。这个能量曲线将会是相场法这一计算方法的核心之一，意义在于在给出体系的体自由能描述的情况下，体自由能的最小化将会推动整个体系发生演化。Cahn 和 Hilliard 两人在 Ginzburg-Landau 自由能模型的基础上建立了用来描述调幅分解过程的自由能泛函，并推导出了对这个泛函的演化方程，这个演化方程即为所谓的 Cahn-Hilliard 方程。因此，从调幅分解入手开始了解相场法也许是最理想的选择了。\n模型分析 能量构造 本次我们使用的自由能构造如下：\n$$ \\begin{align*} F \u0026= \\int_\\Omega f_\\mathrm{bulk} + f_\\mathrm{int}\\, \\mathrm{d}\\omega;\\\\ f_\\mathrm{bulk}\\left(c \\right) \u0026= Ac^2(1-c)^2; \\\\ f_\\mathrm{int}\\left(\\nabla c \\right) \u0026= \\kappa \\left|\\nabla c \\right| ^2. \\\\ \\end{align*} $$ 其中，$F$ 即为体系的总能量，由两部分的能量密度积分构成，第一部分为体自由能 $f_\\mathrm{bulk}$，其图像为一个双势阱：\n而第二部分则为界面能密度部分，这里采用界面能梯度内积的值。这个能量构造保证了体系有演化的趋势（由体自由能密度驱动），又保证了体系中存在稳定的相界面（由界面能提供，在存在界面区域（梯度不为 0 ）时提高能量从而迫使物质不倾向于汇集在界面处）。这样的总能 $F$ 构造是一类非常经典的构造方法，而能量密度的具体表达式则需要根据体系做更改。\n演化方程 接下来我们分析 Cahn-Hilliard 方程。我们之前有提到这个方程，但是没有仔细分析它。其形式如下：\n$$ \\frac{\\partial c_i}{\\partial t} = \\nabla\\cdot\\left(M_{ij}\\nabla\\frac{\\delta F}{\\delta c_j}\\right) $$值得注意的是，这里括号内的乘积实际上是使用了 Einstein 求和约定（对，就是那个 Albert Einstein）。这里不做过多解释，大概就是讲要把所有浓度的驱动力都算在一起作为总驱动力然后进行计算。\n那么如何理解这个公式呢？首先我们先搞清楚这个公式里的所有的变量的物理含义。括号内的梯度项应该是各个组分的化学势，而与化学势梯度相乘然后求和的张量 $M_{ij}$ 则是所谓的迁移率矩阵，它是用来平衡各个化学势梯度对体系的贡献的，这时，等式右侧变成了化学势的 Laplacian，从数学上看是一个对空间所有方向求二阶偏导后加在一起的量，可以用来表示空间平直程度或者起伏程度的量。如果 Laplacian 在某点极大，则代表这个点附近的值有极大的变化。考虑到这里计算的是化学势的 Laplacian，如果化学势变化激烈，则它在这一点的 Laplacian 的数值自然会很大。\n经过上面的分析，可以得到一个很初步但很重要的结论：当化学势变化越大时，变量的变化速率越大。这很合理，因为作为相变驱动力而言，浓度或物质的重新分配主要是由于化学势的变化而产生的，物质应该从化学势高的地方流向化学势更低的位置。那么，为什么必须是这样的散度套梯度的形式呢？这主要是因为浓度的守恒性，因为浓度是保守变量，不能随意产生和消失。根据物质守恒定律，有 $$ \\frac{\\partial c_i}{\\partial t} + \\nabla \\cdot J = 0, $$ 其中 $J$ 是浓度流。考察物质流，由于我们一直处于热力学语境下，物质流必须符合热力学定律，即只能从化学势高的区域流向化学势低的区域。这时我们尝试构造出一个形式最简单的，把化学势和物质流相关联的表达式，由于从浓度高的区域流向浓度低的区域这一现象凝聚在某一点时表现为反浓度梯度，再考虑到要求形式最简，我们能想到的最简单的形式即为： $$ J = - \\nabla \\frac{\\delta F}{\\delta c}. $$ 但是考虑到体系内可能存在多种物质，这些物质对化学势均有贡献，反过来所有的化学势都会对某一单一成分组元的演化情况产生影响，因此应该考虑所有的化学势的影响。然后由于不同物质的化学势贡献存在不同，我们使用 $M_{ij}$ 来对这些贡献进行配平。由此，我们便拼凑出了上面的 Cahn-Hilliard 方程。上面的分析和推导过程参考了 这篇 Review 和 这篇博文。\n那么，顺着这个思路，要是不要求物质守恒，那么最简形式是什么样的呢？答案已经呼之欲出了，那就是下一节会提到的 Allen-Cahn 方程。也许有人发现，可以把这两个方程与扩散方程做比较。这些内容放在下一个部分吧，要不然没字数水了（）\n问题分析 我们希望能模拟出调幅分解的过程，在二维条件下可以创建一个模拟域，规定其长宽后在其上每一个点赋予一个浓度 $c$，然后在每个点随机添加噪音来让初始浓度出现一个微小波动。随后我们便可以根据前面所列出的能量以及演化方程来演化该模拟域。考虑到该模拟需要保持物质守恒，我们采用周期性边界条件，即让最右端的点在取其右侧的点的时候反取到最左端的点，而最底端的点取其下方点时取到最上端的点，等。我们先从浓度 $c = 0.4$ 开始，考虑噪音大小为 $0.001$，处理边界条件时使用下标运算来保证获取的是在周期边界条件下的点。\n这里我们再推导一下前面用到的公式，将能量带入演化方程直接获得迭代浓度场所需要的表达式。 $$ \\frac{\\partial c}{\\partial t} = M \\nabla^2\\left( 2Ac(1-c)(1-2c)-\\kappa\\nabla^2c\\right) $$根据这个公式，我们需要先计算浓度的 Laplacian，然后计算出化学势后，计算括号内整体的 Laplacian，最后使用向前欧拉法迭代到浓度上。这里我们取用一些简单的值来进行计算，取 $A = 1.0$，$M = 1.0$，$\\kappa = 0.5$。然后考虑离散步长，取 $\\Delta t= 0.01$，$\\Delta x= 1.0$。\n代码实现 下面直接一口气给出所有的代码：\n1#include \u0026lt;filesystem\u0026gt; 2#include \u0026lt;fstream\u0026gt; 3#include \u0026lt;iostream\u0026gt; 4#include \u0026lt;string\u0026gt; 5#include \u0026lt;vector\u0026gt; 6 7void write_vtk(std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; mesh, std::string file_path, int time_step, double dx) { 8 std::filesystem::create_directory(file_path); 9 std::filesystem::path f_name{\u0026#34;step_\u0026#34; + std::to_string(time_step) + \u0026#34;.vtk\u0026#34;}; 10 f_name = file_path / f_name; 11 12 std::ofstream ofs{f_name}; 13 size_t Nx{mesh.size()}, Ny{mesh.at(0).size()}; 14 15 ofs \u0026lt;\u0026lt; \u0026#34;# vtk DataFile Version 3.0\\n\u0026#34;; 16 ofs \u0026lt;\u0026lt; f_name.string() \u0026lt;\u0026lt; std::endl; 17 ofs \u0026lt;\u0026lt; \u0026#34;ASCII\\n\u0026#34;; 18 ofs \u0026lt;\u0026lt; \u0026#34;DATASET STRUCTURED_GRID\\n\u0026#34;; 19 20 ofs \u0026lt;\u0026lt; \u0026#34;DIMENSIONS \u0026#34; \u0026lt;\u0026lt; Nx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; Ny \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; 21 ofs \u0026lt;\u0026lt; \u0026#34;POINTS \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; \u0026#34; float\\n\u0026#34;; 22 23 for (size_t i = 0; i \u0026lt; Nx; i++) { 24 for (size_t j = 0; j \u0026lt; Ny; j++) { 25 ofs \u0026lt;\u0026lt; (double)i * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; (double)j * dx \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; 1 \u0026lt;\u0026lt; std::endl; 26 } 27 } 28 ofs \u0026lt;\u0026lt; \u0026#34;POINT_DATA \u0026#34; \u0026lt;\u0026lt; Nx * Ny * 1 \u0026lt;\u0026lt; std::endl; 29 30 ofs \u0026lt;\u0026lt; \u0026#34;SCALARS \u0026#34; \u0026lt;\u0026lt; \u0026#34;CON \u0026#34; \u0026lt;\u0026lt; \u0026#34;float 1\\n\u0026#34;; 31 ofs \u0026lt;\u0026lt; \u0026#34;LOOKUP_TABLE default\\n\u0026#34;; 32 for (size_t i = 0; i \u0026lt; Nx; i++) { 33 for (size_t j = 0; j \u0026lt; Ny; j++) { 34 ofs \u0026lt;\u0026lt; mesh.at(i).at(j) \u0026lt;\u0026lt; std::endl; 35 } 36 } 37 38 ofs.close(); 39} 40 41void energy_curve(std::vector\u0026lt;double\u0026gt; f_list, double kappa, std::string file_path, int pstep) { 42 std::filesystem::create_directory(file_path); 43 std::filesystem::path f_name{\u0026#34;energy_time.csv\u0026#34;}; 44 f_name = file_path / f_name; 45 46 std::ofstream ofs; 47 ofs.open(f_name); 48 ofs \u0026lt;\u0026lt; \u0026#34;time\u0026#34; \u0026lt;\u0026lt; \u0026#34;,\u0026#34; \u0026lt;\u0026lt; \u0026#34;value\\n\u0026#34;; 49 for (size_t i = 0; i \u0026lt; f_list.size(); i++) { 50 ofs \u0026lt;\u0026lt; i * pstep \u0026lt;\u0026lt; \u0026#34;,\u0026#34; \u0026lt;\u0026lt; f_list.at(i) \u0026lt;\u0026lt; std::endl; 51 } 52 ofs.close(); 53} 54 55double laplacian(double cl, double cr, double cd, double cu, double cc, double dx) { 56 return (cl + cr + cd + cu - 4.0 * cc) / (dx * dx); 57} 58 59double df_dc(double mu, double kappa, double lap_c) { 60 return mu - kappa * lap_c; 61} 62 63double chem_potential(double A, double c) { 64 return 2.0 * A * (c * (1 - c) * (1 - c) - c * c * (1 - c)); 65} 66 67double chem_energy(double A, double c) { 68 return A * c * c * (1 - c) * (1 - c); 69} 70 71double F_total(std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; mesh, double kappa, double A) { 72 double energy{0}; 73 74 for (size_t i = 0; i \u0026lt; mesh.size() - 1; i++) { 75 for (size_t j = 0; j \u0026lt; mesh.at(0).size() - 1; j++) { 76 double cc = mesh.at(i).at(j); 77 double cr = mesh.at(i + 1).at(j); 78 double cu = mesh.at(i).at(j + 1); 79 80 energy += (cr - cc) * (cr - cc) * kappa / 2.0; 81 energy += (cu - cc) * (cu - cc) * kappa / 2.0; 82 energy += chem_energy(A, cc); 83 } 84 } 85 return energy; 86} 87 88const int Nx = 64; 89const double dx = 1.0; 90const double dt = 0.01; 91const int nstep = 10000; 92const int pstep = 50; 93const double c0 = 0.4; 94const double mobility = 1.0; 95const double kappa = 0.5; 96const double A = 1.0; 97 98int main() { 99 100 std::vector\u0026lt;std::vector\u0026lt;double\u0026gt;\u0026gt; mesh(Nx, std::vector\u0026lt;double\u0026gt;(Nx, 0)); 101 for (int i = 0; i \u0026lt; Nx; i++) { 102 for (int j = 0; j \u0026lt; Nx; j++) { 103 mesh.at(i).at(j) = c0 + (double)(100 - rand() % 200) / 1000.0; 104 } 105 } 106 std::vector\u0026lt;double\u0026gt; F_time_curve{}; 107 108 auto df_dc_mesh{mesh}; 109 110 for (int istep = 0; istep \u0026lt; nstep + 1; istep++) { 111 for (int i = 0; i \u0026lt; Nx; i++) { 112 for (int j = 0; j \u0026lt; Nx; j++) { 113 int im = i - 1; 114 if (im == -1) 115 im = Nx - 1; 116 int ip = i + 1; 117 if (ip == Nx) 118 ip = 0; 119 int jm = j - 1; 120 if (jm == -1) 121 jm = Nx - 1; 122 int jp = j + 1; 123 if (jp == Nx) 124 jp = 0; 125 double cl{mesh.at(im).at(j)}; 126 double cr{mesh.at(ip).at(j)}; 127 double cd{mesh.at(i).at(jm)}; 128 double cu{mesh.at(i).at(jp)}; 129 double cc{mesh.at(i).at(j)}; 130 131 df_dc_mesh.at(i).at(j) = df_dc(chem_potential(A, cc), kappa, laplacian(cl, cr, cd, cu, cc, dx)); 132 } 133 } 134 for (int i = 0; i \u0026lt; Nx; i++) { 135 for (int j = 0; j \u0026lt; Nx; j++) { 136 int im = i - 1; 137 int ip = i + 1; 138 int jm = j - 1; 139 int jp = j + 1; 140 if (im == -1) 141 im = Nx - 1; 142 if (ip == Nx) 143 ip = 0; 144 if (jm == -1) 145 jm = Nx - 1; 146 if (jp == Nx) 147 jp = 0; 148 double df_dc_l{df_dc_mesh.at(im).at(j)}; 149 double df_dc_r{df_dc_mesh.at(ip).at(j)}; 150 double df_dc_d{df_dc_mesh.at(i).at(jm)}; 151 double df_dc_u{df_dc_mesh.at(i).at(jp)}; 152 double df_dc_c{df_dc_mesh.at(i).at(j)}; 153 154 mesh.at(i).at(j) += dt * mobility * laplacian(df_dc_l, df_dc_r, df_dc_d, df_dc_u, df_dc_c, dx); 155 } 156 } 157 if (istep % pstep == 0) { 158 write_vtk(mesh, \u0026#34;./result\u0026#34;, istep, dx); 159 F_time_curve.push_back(F_total(mesh, kappa, A)); 160 } 161 } 162 energy_curve(F_time_curve, kappa, \u0026#34;./result\u0026#34;, pstep); 163} 这里再简单介绍一下 write_vtk 函数，这个函数参考 VTK 文件的标准，每个 pstep 步之后便输出一次 vtk 文件。其中的文件格式使用 std::fstream 来控制输入。\n结果 输出的 vtk 文件需要使用 Paraview 进行可视化。如果程序无误，那么执行程序后输出的结果将会保存在程序所在文件夹下新创建的子文件夹 result 中，里面应该是若干个 vtk 文件。使用 Paraview 打开这些文件之后，则能看到整个体系的演化。这里贴出一些截图。\n第5步 第25步 第75步 第150步 总结 这部分内容相对而言应该是比较少的，因为只要分析好了所使用的能量模型，理解使用的演化方程之后，剩下的工作几乎全都是不断调试，根据调试的结果来观察不同的参数会对模拟结果造成什么样的影响。这里提供几个调参思路吧：\n调整时空间步长。求解结果应该会随着两者的变大而变大 调整扩散速率（迁移率），更高的扩散速率会让相聚集会变得更迅速 调整自由能函数的参数，越强的势阱会让相分散更加迅速且边界更分明 调整初始浓度 调整界面能参数。越大的界面能参数会导致越宽的相界面。而该参数太小时可能会发生数值不稳定的现象 可以参考这些描述调参，观察参数的影响。调参几乎是相场模拟中必不可少的一环。\n那么，我们就下一节见吧。\n","date":"2024-12-24T00:00:00+08:00","image":"/images/Skadi.png","permalink":"/zh/posts/pf_tutorial/pf_tutorial_4/","title":"Phase Field: 相场模拟学习笔记 IV"},{"content":"接上一节内容，这节会简单介绍 C++ 的一些语法知识，然后用 C++ 实现一维传热方程的模拟。\nC++：一门高效的，适宜科学计算的程序语言 C++ 是一门经典的编程语言，于 1979 年由 Bjarne Stroustrup 设计，最初目的是为了成为更好的 C 语言，而后随着自身发展，成为了一门和 C 语言有许多相似之处，而又截然不同的一门语言。C++ 支持多种编程范式，包括但不限于面向过程，面向对象，函数式，模板元编程等等。其丰富的生态，高效的算法库以及零成本抽象的理念让 C++ 极为适合进行各类科学运算。此外，C++ 的语法较为亲民，其多种编程范式也便于不同背景的开发者上手，故我们在这里引入 C++ 作为后续计算使用的程序语言。\nC++ 简介 首先，我们对 C++ 的一些基础概念做出简单的介绍。这些概念你也许在上节中已经遇到过来，这里再做出进一步的解释。\nC++ 编译器 C++ 作为一门语言，当谈起 C++ 编程时，实际上我们只是书写了以 C++ 的格式书写的代码，而将这些代码翻译为机器能够阅读并执行的程序，需要许多道不同的工序。幸运的是，编译器（compiler）可以近乎一步到位地帮助我们完成这个过程。C++ 历史悠久，自然发展有多种编译器来编译源代码。这里列举其中三个较为知名的编译器（工具链）：\nGNU Compiler Collection (GCC) 以及 G++：来自 GNU 基金会的开源老牌编译器工具集合，其中用以编译 C++ 的编译器为 G++。G++ 编译器几乎是 Linux 平台的标准编译器，而 Windows 平台可以考虑使用一些迁移工程，如 Cygwin，MSYS2 或 MinGW（Minimalist GNU for Windows）。其链接器为 ld，调试器为 gdb。 Microsoft Visual C++ (MSVC)：微软开发的 C++ 编译器工具，除了编译 C++ 外还兼职编译其他的一些代码，如 C 等。其命令行工具名为 cl.exe，但只能通过微软的开发者命令行调用。使用 MSVC 的一般方式为使用微软开发的 IDE。其链接器为 LINK.exe，调试器为 vsdbg.exe。 Clang++ / LLVM：LLVM 组织开发的一款模块化的现代编译器工具集合，其中用以编译 C++ 的编译器前端为 Clang++。Clang++ 为 MacOS 系统默认的编译器。当然也可以安装在 Windows 平台或 Linux 平台上。其链接器为 lld，调试器为 lldb。 以上三款编译器近乎最受欢迎的 C++ 编译器，当然也有一些其他的 C++ 编译器，但由于不同编译器对语言的实现可能有所不同，依旧是建议没有特殊需求的开发者采用三大主流编译器编译 C++ 代码。 编译器负责将源码编译为二进制文件，而链接器（linker）则负责将不同的二进制文件按照要求链接起来，形成一个单独的二进制文件。调试器（debugger）则负责读取符号表后对二进制文件进行逐行运行与调试。至于编译器的前端，后端以及其具体运行超出了单纯运用的范围，这里不深入介绍（其实笔者也不太懂）。\n这里同时也稍微提一下 编译器，编辑器，解释器 和 IDE 的区别。其中，解释器我们已经在 Python 中遇到过，它负责将 Python 代码逐行解释给机器并令机器执行。其属于广义上的编译器，即将源代码（文本）转化为机器能识别的指令等的程序。而狭义的编译器则指将文件整体处理并编译为二进制文件的程序。由于 C++ 的执行必须先编译为二进制文件，故其编译器是必不可少的。编译器则与前两者完全区分开，是编辑文字的工具。常见的编辑器如 Windows 上的记事本，Linux 上常用的 Eamcs,Vim，较为现代的 VS Code 等。经常与编辑器搞混的概念则为IDE。IDE是指集成开发环境（Integrated Developing Enviroment），其兼具编辑器与编译器的功能，可以在其中编辑代码并编译为二进制文件后运行，且通常具有别的功能，如断点调试等等。这些概念是有一定区分的必要的，否则容易造成误解。\nC++ 编译链接 理解 C++ 的编译过程与链接过程对正确运用编译器编译 C++ 代码是必要的。这里不会过分深入，旨在介绍大致过程，以免出现一些常见问题（如找不到符号定义等）。\n在编译过程中，编译器会首先将所有的源文件（通常后缀为 .cpp，.cxx 等）按照要求编译为相对应的对象文件（Linux 上为 .o 文件，Windows上为 .obj 文件），并留下没有实现但是已经声明过的函数、类等，等待链接器链接至对应位置的静态或动态库。随后链接器将执行链接，即将对象文件，外部静态库（Static Library，Linux上的 .o 或 Windows 上的 .lib 文件）和外部的动态库（Shared Object 或 Dynamic Library,Linux上的 .so 或 Windows 上的 .dll 文件）链接至一起形成一个二进制文件。其中，静态库将和程序生成的对象文件合并到一起形成一个文件，动态链接库则不被合并到文件中。因此，使用动态链接库可以减少重复代码，降低程序的大小。随后在运行该程序时，当程序需要外部链接库中定义的内容时，操作系统将按照一定的顺序寻找动态链接库，并找到其中的定义然后执行。当没能找到动态链接库时，程序便会报出“未定义的符号 XXX”的错误。一般程序会到环境变量中的位置寻找动态链接库，随后在程序所在文件夹下寻找动态库。如果用到了动态链接库，请注意让程序能找到动态库，否则无法成功运行。\n上面仅为简单的介绍，其中编译过程还可以细分为若干步，链接过程也可以分为若干步骤。这里不再介绍。但是需要指出的是：编译过程中，根据编译的类型，会对代码进行不同程度的优化。常见的所谓 Release 版本即为打开所有优化选项，且不生成/加载符号表的程序版本，其程序体积小，运行速度快，但通常无法调试（因为缺少符号表）。与之对应的 Debug 版的优化则较少，但其含有的符号表可以在调试过程中逐步运行代码并查看变量值。编译程序时请注意这些区别。\n调试器与调试 调试器通常是一类独立的程序，其可以运行编译链接完成的可执行程序，并且加载程序对应的符号表后可以逐行运行程序。调试器还支持断点，在特定位置暂停程序的运行，并且显示当前位置程序中加载的变量和函数调用栈等。调试器的出现极大地方便了程序的调试，可以方便定位程序中存在的问题并做出修改，以编写出更加符合要求的代码。\n调试器常常拥有自己的界面，可以独立运行，然而目前常见的使用方式是使用一些外部程序调用调试器，捕获其输出并传递输入的参量，以便与源代码进行对照。常见的 IDE 均有此功能，而一些编译器经过合理的配置之后，也可以调用调试器进行使用。通常的调试过程为：添加断点，逐步运行，查看变量/修改变量值，步入函数/步出函数等。调试过程中可以多加探索。\nC++ 环境搭建 如果您使用 Windows 平台，最简单的方法即为考虑微软旗下的 Visual Studio。作为一款成熟的 IDE,Visual Studio 可以在简单的了解其操作之后便将注意力集中在编程解决问题本身而非工具的使用。只需要在官网下载 Visual Studio 的下载器，在下载器界面内选择 C++ 桌面开发的组件，便可以在安装后创建一个解决方案+一个项目，并在左边资源管理器中新建一个 C++ 源文件后开始编写程序了。所有的编译器选项等等都可以通过项目属性来管理配置。如果需要在 Windows 平台上编写大型程序的话，Visual Studio 近乎是 Windows 平台的不二之选。\n不过如果只需要编译运行单个 C++ 文件，又或者 Visual Studio 太过笨重，不适合您的电脑环境的话，可以考虑使用 MinGW-w64 或者 MSYS2 中的编译器与 C++ 运行时。在写好 C++ 源文件之后，像在 Linux 环境下一样调用 g++ 命令编译源文件，即可得到可执行的程序。除了在 Windows 上模拟 Linux 环境外，还可以考虑使用 WSL 来创建本地的轻量化 Linux 子系统，登录到子系统后就相当于打开了 Linux 虚拟机，此时便可在 Linux 环境下安装编译所需要的工具链并进行编译了。\n由于所谓源代码仅为有一定格式的文本文件，故而您可以使用任何喜欢的文本编辑工具来编写源代码。然而好的编辑器可以辅助编写，特别是代码高亮，自动补全，调用编译器等功能可以极大地方便代码编写。这里推荐使用 VS Code，其丰富的插件生态可以在安装好编译器与对应插件的情况下提供良好的代码高亮，定义跳转，自动补全的功能，且可以编译 C++ 源文件并调试/执行。具体内容由于 VS Code 提供了详尽的文档，这里不再赘述。\nC++ 标准 作为一门发展良久的语言，C++ 经历了数次版本迭代，也因此拥有多个语言版本。根据 ISO 的标准，C++ 委员会将对语言特性，语法规则等进行调整，对语言库做出提案，并由各大编译器厂商进行实现。不同的编译器厂商可能会采用不同的实现方式，且不同的编译器可能会添加不同的扩展，故而非标准的 C++ 代码可能需要根据平台和编译器进行编译。而当源码采用的标准与编译器标准不同时，也常常会出现编译错误。所以在编写/编译源文件时应明确采用的 C++ 标准。目前业界常用的且拥有广泛编译器支持的 C++ 标准为 C++14，但该标准较为老旧，缺少很多便利的库函数等。可以考虑使用 C++17 或者 C++20 标准以方便使用。本教程使用 C++17 标准。请注意 Visual Studio 默认的 C++ 标准为 C++14，如有必要请在项目属性中修改 C++ 标准。\nC++ 语法基础 经过上面的介绍，相信您已经对 C++ 所配套的工具和其必要的信息已经有所了解，而到目前为止，我们还没有介绍 C++ 具体的语法。那么我们接下来便开始 C++ 语法的介绍。\n注释，头文件和 #include 代码通常都有注释。C++ 中的单行注释以 // 开始，让编译器忽略一行中 // 后的所有内容。而多行注释（或者更准确地说，范围注释）则是以 /* 开始，以 */ 结束。简单更改程序时，单行注释很常用，而多行注释常用来书写大段说明性的文字，特别是版权信息等。\n打开一个 C++ 文件，首先看到的常常是各种 #include 开头的若干行。在 C++ 中，当需要使用外部的内容时（如，函数，类等），通常使用 #include 预处理命令来将对应库的头文件引入该文件。比如需要使用标准库的输入输出流时，则需要在源文件中使用 #include \u0026lt;iostream\u0026gt;。而在使用自建库时，通常使用冒号 \u0026quot;\u0026quot; 而非尖括号 \u0026lt;\u0026gt; 来引入头文件。\n所谓头文件，通常指以 .h 结尾的 C++ 代码，其中声明了一些函数或者类等，也可以在头文件中实现这些函数/类。在引入头文件后，便可以使用头文件中定义的名称，作用类似于 Python 中的 import，但更为原始一些，因为 #include 会令编译器直接将对应文件复制粘贴到对应位置。\n标准库的头文件常常与标准库相关联，所谓标准库，是指 C++ 标准所提供的一系列函数，类，函数模板以及类模板等内容。上面的 iostream 便是一个例子。标准库非常地多，当有需要时请自行搜索是否存在已有的库可以满足需求。\nmain 函数和小例子 对 C++ 程序而言，一个可执行文件必须要包含一个 main 函数作为程序的主入口。当程序执行时，会从 main 函数开始执行，并且逐行向下。一个 C++ 程序只能拥有一个程序入口，意即 main 函数。下面是一个简单的例子：hello_world.cpp\n1#include \u0026lt;iostream\u0026gt; 2 3int main(){ 4 std::cout\u0026lt;\u0026lt;\u0026#34;Hello C++ world!\u0026#34;\u0026lt;\u0026lt;std::endl; 5 return 0; 6} main 函数拥有以下几个特点：\nmain 函数必须拥有 int 返回值类型 main 函数的参数列表可以为空，也可以有两个参数：一个整值类型用以表示接受参数的个数，一个字符串数组/指针/容器用以存储接受的参数。 main 函数成功执行时应返回 0。标准允许不写返回值，默认返回 0。 main 函数除了作为程序入口以外，本身也是一个满足 C++ 语法的函数。我们后面会看到 main 函数作为函数的几个要素。\n变量类型 C++ 和 Python 最大的区别中，其中一个便是所有的变量具有静态类型（别的区别还有不需要代码缩进表示代码块等等）。在 C++ 中，声明变量需要首先声明变量的类型，然后是变量名。可以（也推荐）在声明变量时给变量初始化，通常只需在变量名后用等号 = 接上需要赋予的值即可。也可以通过初始化列表进行变量初始化，且对于类而言还可以使用合适的类构造函数进行变量初始化。下面是一个例子：\n1#include \u0026lt;vector\u0026gt; 2#include \u0026lt;string\u0026gt; 3int main(){ 4 int i = 0; 5 double j; 6 int k {1}; 7 bool yes = false; 8 9 std::vector\u0026lt;double\u0026gt; vd {0.1,0.2,0.4}; 10 std::string str = \u0026#34;I\u0026#39;m a string!\u0026#34;; 11} 为了简化内容，我们只介绍 int，double，bool，string 和 vector 五种类型，他们分别代表有符号整数，双精度浮点数，布尔值，字符串和向量。其中前三种是 C++ 的内置变量类型，在计算过程中常用到；后两者需要引入对应的头文件才能使用。这里有以下几点需要注意：\n变量不进行初始化时请不要使用。可能会带有垃圾数据。如上面示例中的 double j 并没有对变量 j 进行初始化，里面可能存有任何错误的数据，需要在初始化之后使用。 变量可以使用大括号 {} 进行初始化，称为初始化列表。该方式对多个数据的组合变量较为常用。 使用标准库内的变量/类/函数时，如果没有使用 using namespace std; 进行全局获取名称，请使用 std:: 来告诉编译器该名称的位置。这里不介绍命名空间的内容。 对于 vector 这种类模板，请在后面的尖括号中指明容器中数据的类型。如上的 std::vector\u0026lt;double\u0026gt; 意即声明一个内部变量类型为 double 的容器类 vector。 字符串以双引号\u0026quot;开头，以双引号\u0026quot;结尾。 请勿使用全角字符，C++ 文本使用半角字符作为其符号。 变量命名不能以数字开头，可以包含数字，下划线和英文字母。 请注意，C++ 是严格类型语言。当类型不匹配（且自动类型转换失败）时编译器会报错，在存在自动类型转换的情况下编译器可能会警告。请尽量不要让类型做自动转换，如使用 double 将整数变量强行转化为小数等。\n作用域 和 Python 相似，C++ 也有变量作用域的概念。在 C++ 中，代码块使用花括号（大括号）来区分，代码块可以嵌套于代码块内。代码块内的变量可以获取代码块外变量的信息，但代码块外的变量无法获取代码块内的信息。变量在离开自己定义位置所在代码块时，如果没有特殊情况，将会自动销毁。使用循环，判断语句以及声明函数时使用的花括号也是一个作用域。下面是一个小例子：\n1int main(){ 2 int i = 0; 3 { 4 int j = 1; 5 i = 888; // success 6 } 7 // i will be 888 here 8 9 // Below is an error: 10 // j = 666; 11} 控制流，循环和判断语句 首先介绍循环语句。这里仅介绍 for 循环与 while 循环。下面是使用 for 和 while 循环的例子。\n1for (int i = 0; i \u0026lt; 10; i ++){ 2 std::cout\u0026lt;\u0026lt; i \u0026lt;\u0026lt; std::endl; 3} 4 5{ 6 int j = 0; 7 while (j \u0026lt; 10){ 8 std::cout\u0026lt;\u0026lt; j \u0026lt;\u0026lt; std::endl; 9 j += 1; 10 } 11} 这两个循环都会将数字从 0 打印至 9，且其语法特征是完全相同的。其中 for 循环圆括号内第一项为循环前语句，会在循环开始前执行，常用来声明并初始化循环变量；第二项为循环条件，满足条件则继续循环；第三项为循环末尾语句，在执行完循环体内语句后将执行第三项中的语句。而 while 循环则显得简单很多：只要满足括号内的语句条件便可以一直进行下去。请注意下面的循环在套上一层代码块后才能与上方等同。换句话说，在 for 循环括号中定义的变量时临时变量，在离开循环后将会自动销毁。另外值得注意的是，for 循环的括号内是三个语句，使用分号 ; 分割，而非逗号 ,。另外，判断条件实际上是一个表达式，当表达式值为 true 时则继续循环，为 false 则停止。\n这里再介绍一下所谓的 range for 循环。当存在一个不变长的容器式的变量时，可以通过：\n1for (auto rep_elem : container){ 2 /* xxx */ 3} 这样的语法来用 rep_elem 依次从前向后地取用所有的元素，以此完成循环。\n判断语句这里仅介绍 if-else 语句。下面是一个例子：\n1if (3 \u0026gt; 4){ 2 std::cout\u0026lt;\u0026lt; \u0026#34;false\u0026#34; \u0026lt;\u0026lt; std::endl; 3} else if (3 \u0026lt; 4) { 4 std::cout\u0026lt;\u0026lt; \u0026#34;yes\u0026#34; \u0026lt;\u0026lt; std::endl; 5} else { 6 std::cout\u0026lt;\u0026lt; \u0026#34;?\u0026#34; \u0026lt;\u0026lt; std::endl; 7} 其语法特性一目了然，这里不再赘述。\n另外要介绍的是 break 和 continue 控制命令。当循环遇到 break 命令时将会立刻停止循环，而当执行到 continue 时则会结束本次循环，进入到下一次循环。对于嵌套循环，break 和 continue 只负责当前循环的控制，不会控制父循环。\n函数 C++ 中的函数包含五个要素：返回值类型，函数名，参数列表，函数体，返回值。下面是一个例子：\n1double my_add(double a, double b){ 2 return a+b; 3} 这里我们声明了一个函数：返回值类型为 double，函数名为 my_add，参数列表中接受两个 double 类型的参数。这三个要素即可声明一个函数的存在。后面的函数体和返回值则是对该函数的实现方法，这里只做了一件事，即返回了参数列表中两个值的和。\n这里要提出的是，C++ 中的函数允许不返回值，此时返回值类型为 void；函数也可以不接受任何参数，这时只需空置参数列表即可，但是圆括号是必须的。我个人建议声明函数时即将函数做出定义，但是在有必要时，可以列出声明后在另外的地方做出函数定义，例如将声明放在头文件中，函数定义则放在一个源文件中。\n另外，在这里我尝试提出另一种理解函数的方式：提供了对外通道的独立代码块。这个代码块可以把外部的数值通过参数列表交换给代码块内部，而后从代码块内部返回一个结果交给代码块外部。在需要使用该代码块时，只需使用代码块的名称即可。此外需要注意的时，在使用函数时，像上面例子所定义的函数是无法改变外部数据值的。可以理解为代码块内部的所有内容都独立于其他部分，不会对接外部的上下文，只会根据传入的数据进行处理。如果需要改变外部数据，则需要在参数列表中传入指针或者引用，这两个概念会在下一个部分介绍。\n最后要提出的是，函数允许递归调用，即函数调用自己。通过递归调用，可以将复杂的逻辑用较为简单的代码实现。C++ 函数还允许重载，即同一个函数名通过参数列表的不同来让编译器自动区分调用的是什么函数。请注意，仅返回值不同是无法区分的，只有参数列表才能让编译器对同名函数做出区分。也许可以考虑将参数列表纳入“函数名”的一部分，这可以为所谓的函数指针带来一定的解释，但可能有一些问题？所以仅供参考。\n指针 我们这里不会介绍指针太复杂的内容，仅对指针最基本的用法以及其背后（可能）的思想做出大致介绍。在介绍指针之前，有必要先简单介绍 C++ 语言下的内存逻辑。\n在程序运行过程中，由操作系统所管理的程序内存可以根据代码中的内容而区分为两个部分：堆和栈。其中的栈实际上是一种数据结构，指先入后出的队列，但这里我们只把它理解为一个由程序直接管理的内存。这些内存（比如某些变量）会由代码创建后存在栈上，当该程序的变量脱离某个部分时，由于变量的生存周期便会从栈上弹出销毁。这带来了一些好处，让程序的所有内存都得到恰当的管理，但是操作系统能分配给程序的栈空间大小是有限的，当栈空间不足以储存所有变量时，程序便会报出栈溢出的错误。\n为了解决这样的痛点，程序允许和操作系统沟通，拿到不直接属于程序栈空间内的内存。拿到的这些内存就保存在堆上，而声明或使用这些内存则可以使用指针来取得。需要注意的是，虽然堆空间很大，但是堆空间由于数据散乱，其速率可能不如栈上的内存；另外，即便空间很大，不加限制的创建内存且不加销毁，特别是运行时间较久的时候，程序可能会用光所有的内存，此时便会造成所谓的内存泄漏。由于使用堆上数据近乎只能依靠指针，所以使用指针时需要格外注意，特别是内存的释放。\n那么指针到底是什么？我们提取上面所给出的一些信息：指针需要能够获得堆上的内存，C++ 是强类型语言，需要使用指针获取并管理堆上内存。如果考虑 C++ 可以通过内存的地址管理内存，那么答案就呼之欲出了：指针，实际上是一种特别的变量。它会记录一个地址，并记录上这个地址下的数据的类型（同一数据，在不同类型解释下会给出不同的值，比如 0 在 int 下就是数字0，而在 bool 下则会解释为 false，所以指针的类型（大部分情况下）是必须的），随后在使用该地址内所存储的数据时，只需要对指针解引用便可以获得该值。下面是一个例子来说明如何声明指针，以及如何获取变量的地址：\n1int i = 0; 2int *p = \u0026amp;i; 3 4// Output i\u0026#39;s address 5std::cout\u0026lt;\u0026lt;p\u0026lt;\u0026lt;std::endl; 6 7// Modify i\u0026#39;s value 8*p = 1234; 9std::cout\u0026lt;\u0026lt;i\u0026lt;\u0026lt;std::endl; //1234 上面的例子中，我们先声明了（栈上）的一个变量 i，随后用 int * 作为数据类型名声明了一个指针 p，并用 \u0026amp;i 取得 i 的地址，然后给这个变量 p 赋予了 i 的地址的值（称为指针p指向i）。此时输出 p 的值时会打印出一些十六进制数字。之后，通过 * 运算符，取出了保存在 p 中的地址下存储的值，并直接对该内存地址覆写数据 1234。由于 p 保存的地址正是 i 的地址，所以对 i 的地址写入新数据即为给 i 重新赋值。这样一来，输出 i 的值时，得到的结果即为 1234。\n希望这个例子以及这里的简单介绍能帮助你理解指针是什么以及有什么作用。值得注意的是，这里指针指向的变量依旧是一个栈上的变量，而在很多需要使用指针的情况下，需要的常常是堆上的数据。为了在堆上创建变量，需要使用 new 关键字。而当在堆上创建变量后，如果不再使用变量时，必须使用 delete 关键字删除该变量。其主要原因是，当我们声明指针时，通常都是在栈上创建的数据；作为一个栈上的变量而言，当指针变量离开其作用域时便会被销毁。如果只有一个指针指向某个内存空间时，销毁该指针之后，内存中的数据便没有别的办法取到；而此时由于通过 new 创建了这个内存，操作系统会一直保留这个内存直到 delete 删除该内存，或者程序退出由操作系统销毁所有内存。这样一来，只 new 不 delete，当数据量较大时便会造成严重的内存泄漏；此外只 new 不 delete 还会把数据暴露在外，造成安全风险。然而要是使用了两个指针指向同一个内存时，如果在一个指针上销毁了内存，而另一个指针仍然认为内存没有销毁，那么该指针便会称为野指针，或者悬空指针。这时，当尝试使用该指针时，程序便会出错，轻则报错退出，重则产生难以排查的奇怪问题。\n上面这么一大段，其最终目的只为了说明一件事：请不要轻易使用指针。指针很好用，但是 C++ 中也提供了别的很多更友好的方式来管理并使用内存。一旦使用裸指针又不小心忘记删除或者出现空指针，程序便会出现很多奇怪的问题。所以，对自己技术没有绝对自信时，请不要轻易使用指针。\n最后我们提出如何使用指针来帮助函数改变外部变量的值：通过指针传入参数时，虽然函数无法改变参数的值，但是由于参数传入的指针指向的内存空间不会受到影响，所以可以在函数内部给传入的指针内保存的地址下的变量赋值，从而绕开函数的限制。然而，为了达成这一目的，有一种更加安全，且更加便于理解的方式：引用。\n引用 与指针相比较而言，引用就显得更加和蔼一些了，简单来讲，声明一个引用也就是声明了一个变量的别名。我们先看一个例子：\n1int i = 0; 2int \u0026amp;ri = i; 3 4std::cout\u0026lt;\u0026lt;ri\u0026lt;\u0026lt;std::endl; 5 6ri = 5678; 7std::cout\u0026lt;\u0026lt;i\u0026lt;\u0026lt;std::endl; //5678 这里我们首先声明了一个变量 i，然后创建了一个引用 ri 作为 i 的别名。这样一来，我们对 ri 所做出的任何操作（应该）都是相当于对 i 本身所做出的。当 i 被销毁时，变量 i 和它的引用 ri 会一起消失。和指针相比，引用显然要安全的多。但是与此同时，引用也有一些限制：引用不能改变它引用谁。一旦引用被创建，引用本身和引用所指向的内容就绑定死了。此外，由于引用别名的特性，引用不可能存在空引用，这也就要求了引用的“声明”必须立刻对其初始化，且一经初始化就不可改变。所以这里不应该使用“声明”，“赋值”等描述这一过程，最恰当的描述即为“初始化”。\n值得注意的时，引用，和指针类似，也可以作为函数参数传入函数内部。当作为函数参数把引用传入函数内部时，引用的“别名”特点依旧保持，函数内对参数的改变依旧会反映在函数外部。一种理解方式是，函数参数列表的默认传参方式是把参数的值复制一份，生成一个临时变量，然后使用该变量；指针传参时把指针的地址复制了一份，然后使用该临时地址可以在不改变地址的情况下改变地址内部的数据；引用传参时，创建了一个临时的引用，由于引用的特性，函数对引用的影响就相当于对变量本身的影响。\n总之，引用在 C++ 中是更加推荐的使用方式。当然，引用也有别的限制。引用由于终究是变量的“别名”，不会改变变量的内存布局，所以不存在“引用数组”这一数据结构。同时，也不会出现引用的引用，也不会有指针的引用。这主要是因为，引用并不是实际的对象，不会占据内存，因此也没有对应的地址（参考 Stack Overflow 上的回答）。\n最后，有人声称引用是必须初始化的常量指针。这一点见仁见智，个人认为可以这么去理解行为，但二者不能划等号，具体实现需要根据不同的编译器去考虑。\n类，模板，STL 前面的部分几乎涵盖了所有 C++ 的基础语法。这里再做出一些补充，比如 C++ 的面向对象（类），模板和标准模板库。\n类，面向对象 面向对象在 Python 已经做出了简单的介绍。这里在其基础上介绍 C++ 的面向对象的语法。\n首先需要注意的是，C++ 中存在结构体 struct 和 类 class 两种类似的数据结构。一般认为，struct 和 class 的区别仅在于默认的访问控制。struct 的默认访问是 public 的，而 class 则是默认 private 的。然而，也许二者也有一些微妙的区别，这里不加区分。（我也不知道）\n下面是一个简单的类声明的例子：\n1class my_class{ 2 private: 3 int value{0}; 4 bool is_true{false}; 5 public: 6 my_class(int v, bool is){ 7 this-\u0026gt;value = v; 8 s_value = is; 9 } 10 void print_info(){ 11 std::cout\u0026lt;\u0026lt; \u0026#34;Data is obtained? \u0026#34;\u0026lt;\u0026lt; is_true\u0026lt;\u0026lt;std::endl; 12 std::cout\u0026lt;\u0026lt; \u0026#34;Value is \u0026#34;\u0026lt;\u0026lt; value\u0026lt;\u0026lt;std::endl; 13 } 14} simple_class_sample ; 这里我们声明了一个类，名为 my_class，其中包含有两个数据，这些数据由 private 保护起来所以外界无法直接取得这两个数据；在公开的部分（public）中有两个函数，一个 my_class 和一个 print_info。由于是 public 的，可以在类外调用这两个函数。其中可以看到，my_class 的函数名和类名相同。这是一类特殊的函数，名为类的构造函数。在使用该类声明一个新的变量时，可以使用该函数来初始化变量。由于函数重载的特性，一个类可以拥有多个不同的构造函数以满足不同的构造条件。这里隐藏了一些函数，这些函数会自动定义好，包括析构函数（变量退出作用域时会调用析构函数销毁变量），拷贝赋值函数（将变量复制从而创建一个新变量），以及默认构造函数（什么都不做，采用默认值初始化类内的变量），等等。\n这里再次提醒，创建一个类实际上就是创建了一个新的复合数据类型，通过该复合数据类型以及其内部定义的函数（方法），可以实现通过方法来操控用该类型创建的变量（对象）。\n最后，类的定义必须在大括号后添加一个分号，否则编译器会报错；定义好一个类后可以立刻创建一个变量，这里创建了一个变量名为 simple_class_sample，使用了默认构造函数。\nC++ 的面向对象的特性十分丰富且比较完备，这里不再做出介绍。\n模板 模板是一类更加复杂的，更加高阶的编程方式。模板会在编译器即进行运算，因此模板从某种角度而言，可以说是用来生成代码的代码。这里提到模板，主要是引入后面的标准模板库，因此这里仅做出最简单的介绍。\n下面是一个类模板创建和函数模板创建的例子：\n1template\u0026lt;typename T\u0026gt; 2T my_add_t(T a, T b){ 3 return a+b; 4} 5 6template\u0026lt;typename T\u0026gt; 7class class_T{ 8 private: 9 T value{}; 10 int index{0}; 11 public: 12 void print_T_value(){ 13 std::cout\u0026lt;\u0026lt; T.text \u0026lt;\u0026lt;std::endl; 14 } 15}; 这里创建了一个函数模板 my_add_t 和一个类模板 class_T。在使用它们时，只需要在其模板名后面跟上一个尖括号，然后在尖括号内写上需要的类型名即可。这里需要注意的是，在使用这里定义的类模板时，要保证 T 类型中拥有 text 这个属性，否则会报错。\nSTL: 标准模板库 STL 是 Standard Template Library的缩写，即标准模板库，是一系列的函数和类库，允许使用库中的类模板和函数模板。科学计算中，最常见的类模板便是 std::vector 了。作为类模板，需要在使用它的时候用尖括号来放入变量的类型。这个类型没有太大的限制，除了因为历史遗留问题而不推荐 bool 作为函数模板外，几乎可以使用任何变量类型来实例化该类模板。像这样的容器模板类还有 std::deque，std::array 等，它们都有各自的特性，感兴趣请自行搜索。这里介绍这些容器类共有的一些常用方法。\n作为容器，一定存在一个方法来告知其内部元素的数量，这个方法为 size()。调用 size() 方法后会返回一个 size_t 类型的数字来表示容器内元素数量。其次，我们需要存取容器中的元素。取用元素时，STL 提供了 at() 方法来从容器的某个位置下取出元素，这个方法接受一个整型数据来取出容器中的元素，当这个数字超出了容器范围时将会报错。当然 STL 也提供了传统的下标方法 [] 取用，但是这个方法不安全，不会进行边界检查。对 std::vector 而言，可以通过 push_back() 方法来将参数列表中的元素添加入容器的最后。当需要删除最后一个元素时，可以使用 pop_back()。最后我们介绍这些 STL 容器都有的 iterator，即迭代器。通过使用 begin() 方法即可返回一个迭代器，这个迭代器的作用类似于指针，允许与整数做加减法，允许比较大小（第一个元素最小，最后一个最大）以及是否相等，而最后一个元素再向后一个位置的迭代器为 end()。因此，使用迭代器取用容器中的元素也是允许的。另外，使用迭代器比较时，建议尽量使用判断相等/不等，而非比较大小。因为有部分迭代器可能没有实现迭代器的大小比较。\nC++ 简单计算案例：传热方程 终于，经过漫长且枯燥的语法学习，我们终于可以看一些实际的问题，并尝试使用 C++ 来解决它们了。我们遇到的第一个案例，即为所谓的 Fourier 传热问题。\n问题描述 问题描述如下：\n[!QUESTION]\n设现在有一个热源，其中心处在 $x = 64$ 的位置，宽度为$40$，温度为 $1$，再设整个模拟域的宽度为 $128$，且边界上采用固定边界条件，除了热源外的位置温度为 $1$。先在已知传热方程如下： $$ \\dfrac{\\partial T}{\\partial t} = \\mu \\dfrac{\\partial^2 T}{\\partial x ^2},$$ 且在该问题中取 $\\mu = 1$，求算该体系在上述方程下的演化过程。\n其中的偏微分方程即为所谓的 Fourier 传热方程的简化版，将每一点的热导率看作一个定值并将所有其他参数合并后称为 $\\mu$。\n问题拆解 分析该问题，我们拥有的信息十分完全，再根据已知的 Laplacian 算符和向前欧拉法，我们很快就能构建出该问题对应的 C++ 代码。我们采用 $\\Delta x = 1$，$\\Delta t = 0.2$ 的空间和时间步长以计算时空间导数，并使用向前欧拉法迭代演化出该体系的演化过程。代码层面，我们考虑使用最基础的面向过程方法，并且注意到边界判断时固定边界上的温度值为 $0.0$。\n代码实现 下面是我编写的一个例子：\n1#include \u0026lt;chrono\u0026gt; 2#include \u0026lt;filesystem\u0026gt; 3#include \u0026lt;fstream\u0026gt; 4#include \u0026lt;iostream\u0026gt; 5#include \u0026lt;string\u0026gt; 6#include \u0026lt;vector\u0026gt; 7 8const int Nx = 128; 9const double dx = 1.0; 10const double dt = 0.2; 11const double mu = 1.0; 12const int nstep = 20000; // total iterate time 13const int pstep = 200; // print result every 200 step 14 15int main() { 16 auto begin_time{std::chrono::high_resolution_clock::now()}; 17 std::string result_dir{\u0026#34;./results/\u0026#34;}; 18 std::filesystem::create_directories(result_dir); 19 20 std::vector\u0026lt;double\u0026gt; mesh(Nx, 0.0); 21 for (int i = 0; i \u0026lt; Nx; i++) { 22 if (i \u0026gt;= 44 \u0026amp;\u0026amp; i \u0026lt;= 84) { 23 mesh.at(i) = 1.0; 24 } 25 } 26 27 // tempory mesh for value storage 28 std::vector\u0026lt;double\u0026gt; temp_mesh(mesh); 29 30 // ----- Begin Simulation ----- // 31 for (int istep = 0; istep \u0026lt; nstep + 1; istep++) { 32 33 for (int i = 0; i \u0026lt; Nx; i++) { 34 int im = i - 1; 35 int ip = i + 1; 36 double val_m{0.0}, val_p{0.0}; 37 38 // Fixed boundary condition (to 0) 39 if (-1 == im) { 40 val_m = 0.0; 41 } else { 42 val_m = mesh.at(im); 43 } 44 if (Nx == ip) { 45 val_p = 0.0; 46 } else { 47 val_p = mesh.at(ip); 48 } 49 50 temp_mesh.at(i) = mesh.at(i) + mu * dt * ((val_m + val_p - 2.0 * mesh.at(i)) / (dx * dx)); 51 } 52 // update the origin mesh 53 mesh = temp_mesh; 54 55 if (istep % pstep == 0) { 56 std::string of_name{result_dir + \u0026#34;fixed_step_\u0026#34; + std::to_string(istep) + \u0026#34;.csv\u0026#34;}; 57 std::ofstream ofs(of_name); 58 // if the file is indeed opened 59 if (ofs) { 60 ofs \u0026lt;\u0026lt; \u0026#34;\\\u0026#34;pos\\\u0026#34;\u0026#34; \u0026lt;\u0026lt; \u0026#34;,\u0026#34; \u0026lt;\u0026lt; \u0026#34;\\\u0026#34;val\\\u0026#34;\u0026#34; \u0026lt;\u0026lt; std::endl; 61 for (int i = 0.0; i \u0026lt; Nx; i += 1) { 62 ofs \u0026lt;\u0026lt; i \u0026lt;\u0026lt; \u0026#34;,\u0026#34; \u0026lt;\u0026lt; mesh.at(i) \u0026lt;\u0026lt; std::endl; 63 } 64 } 65 // close the file after write 66 ofs.close(); 67 } 68 } 69 // ----- End Simulation ----- // 70 71 auto end_time{std::chrono::high_resolution_clock::now()}; 72 std::cout \u0026lt;\u0026lt; \u0026#34;The time cost in this simulation is \u0026#34; \u0026lt;\u0026lt; std::chrono::duration\u0026lt;double\u0026gt;(end_time - begin_time).count() \u0026lt;\u0026lt; \u0026#34;s\\n\u0026#34;; 73 return 0; 74} 这里用到了一些技巧，在边界判断处使用下标运算是否越界来判断是否处于边界处，以及使用了 \u0026lt;chrono\u0026gt; 库来监测程序运行时间。在成功运行并且用输出的 csv 文件绘制曲线图后结果大致如下：\n可以看到这个结果还是比较符合预期的。\n结语 太久不更新，写起来有些不是很得心应手。写完这些回头一看，我竟然写了这么多东西？C++ 的基础知识点比我想象的要多很多呀。如果您看到了这里，感谢您的支持。C++ 平心而论，好上手是真的，在了解一定的编程相关知识后很快就能写出一份能跑的代码。但是，C++ 的语法特性繁杂，内容过于丰富导致的则是 C++ 极高的进阶难度。几乎没有人敢说自己精通 C++，因为总有一些奇怪的点会出现在这门历史丰富且悠久的语言上，而恰好提问者知道但回答者不知道。不过好在，如果仅仅是为了使用 C++ 进行简单的高效计算，那么它的入门内容应该只需要上面这些又臭又长的介绍就差不多够了。在实际编程过程中，大多数的函数和类的 API 都是需要现查的，甚至有时候确实还需要面对 CV 编程（复制粘贴）。此外，不论任何编程语言，语言本身只能提供一些好用的特性，或者一些包装好了的算法轮子。实际在编程时最重要的问题是怎么分析问题，并且就这个问题设计出一套算法来解决问题。希望语言不会称为您的绊脚石。\n下一节我会正式采用 C++ 来实现在 Cahn-Hilliard 方程引导下调幅分解的相场（其实是浓度场）模拟。再下一节则会进行 Allen-Cahn 方程下的晶粒长大模拟，并作为这个入门系列的最后一部分。敬请期待。\n","date":"2024-12-23T00:00:00+08:00","image":"/images/Skadi.png","permalink":"/zh/posts/pf_tutorial/pf_tutorial_3/","title":"Phase Field: 相场模拟学习笔记 III"},{"content":"接上一节内容，这节会简单介绍 Python 的一些语法知识，以及尝试使用 Python 实现上节所列出来的部分算法。\nPython 初探索 简介 Python 是一种蟒蛇，而在编程语境下，Python 则是一门十分受欢迎的编程语言。Python 具有语法友好（接近英语），功能强大（感谢开源与社区），社区活跃等优秀的特点，让 Python 成为入门编程的一个好选择。\n为什么选择 Python 来实现上节内容提到的算法呢？主要原因有二：一是 Python 的语法实在是太友好，对于没有学习过或者对编程不甚了解的同学而言，先尝试 Python 的话不容易因为语言的问题劝退。相比于直接介绍下一节要讲的 C++，先用 Python 熟悉一些编程中常见的概念也是有好处的。其二可能是出于我个人的私心吧，因为 Python 真的太好用了，我个人而言希望能稍微做一些推广。作为一门好用的工具语言，它在很多情况下都可以帮助完成一些琐碎的工作。特别是如画图，我很喜欢用 Python 绘制函数图像之类，非常好用。\n总之，这里选择使用 Python 来作为程序的入门。相信在通过 Python 了解一定的编程基础之后，再去了解别的语言也不会显得那么吃力了（比如，C++）。\n解释器安装与环境配置 Python 解释器 Python 的运行是需要其解释器的。目前最新版的 Python 解释器可以直接在 Python 官网 下载。Linux 平台用户可以考虑使用各自发行版的包管理器实现 Python 的安装。安装时请切记选择 ADD TO PATH，否则可能需要手动调整环境变量以让 Shell 能找到 Python。\n解释器是什么？简单来说，就是逐行把写的脚本翻译为机器所能理解的代码指令，然后执行。所以 Python 是逐行运行的，这点非常适合 Debug，也许也是 Python 受人欢迎的原因之一。与解释器相对的一个概念是编译器。这里所指的编译器应该是狭义上的编译器，广义上的编译器应该也包含 Python 这类的解释器。编译器不会逐行解释代码，而是将代码作为一个整体，然后处理翻译，最后形成机器能阅读并执行的内容后进行执行。这种方式让编译器可以为代码做出很多的优化，但是也一定程度上牺牲了 \u0026ldquo;逐行运行\u0026rdquo; 的便利。C/C++，Rust 等语言都是需要编译器进行编译的。为了弥补无法原生逐行运行的缺陷，这些语言使用了调试器 (Debugger) 以及调试符号 (Debug Symbol) 等技术来在编译完成后，根据符号表一一对照并运行代码，呈现出逐行运行的效果。然而这种方法依旧会损失一定的运行性能。\nPython 解释器拥有多个版本，每个版本对语言的语法都有一定的调整。有些调整影响巨大（比如从 Python2 到 Python3 的转变），另一些可能因为其语法特性不常用，不会直接影响到用户体验。Python 解释器也不一定是最新版就最好，需要考虑项目的适配以及对应包的版本需求。不过在这里我们并不太依赖 Python 解释器的版本，只要保证是比较新的 Python 解释器版本，并且主流的科学计算库，如 numpy，matplotlib，scipy 等即可。\n编辑器 与 Visual Studio Code 在安装好 Python 解释器之后其实就已经可以开始 Python 编程了（没错，就是传说中的记事本编程）。然而这当然不是最好的方法，这种方法光是考虑到没有代码高亮就让人很难以接受了。这里我个人推荐 Visual Studio Code（以下简称 VSC）。\nVSC 功能强大，安装方便，插件生态极其丰富，通过合理的配置近乎可以达到 IDE（Integrated Development Environment，集成开发环境）的水平。我个人在写简单的 Python 脚本时几乎都是使用 VSC 写的。一路默认安装后，根据需要安装中文插件，然后再在插件页面搜索 Python 即可安装 Python 插件全家桶，然后就可以开始使用 VSC 写 Python 代码（脚本）了。VSC 的安装与环境配置也可以参考我之前写的博客文章。\n这里没有推荐 IDE，因为 IDE 对这里仅仅使用 Python 做一些简单应用而言太过 \u0026ldquo;全能\u0026rdquo;，或者说，负担太重。当然，如果感兴趣，可以考虑使用大名鼎鼎的 PyCharm。这里不再赘述。\n虚拟环境，venv 和 pip 这里简单介绍一下虚拟环境。因为 Python 的生态丰富，可能会碰到某些依赖相互冲突的情况，尤其是在多人共同开发的情况下，每个人的开发环境配置不同，很容易导致依赖冲突。为解决这种情况，可以考虑使用 Python 的虚拟环境 virtualenv。创建的虚拟环境下有该虚拟环境所自有的一些包，并且和该虚拟环境以外的部分是相互独立的。使用 VSC 创建 virtualenv 虚拟环境非常简单，只需要 Ctrl+Shift+P 打开 VSC 的命令，然后搜索 Python: Create Environment 即可根据向导一步步搭建虚拟环境。\n搭建好的虚拟环境会存放在 .venv 的文件夹中。这里面将会包含所有该虚拟环境的内容，包括在该虚拟环境下安装的各种包。如果不想再使用该虚拟环境，只需要删除该文件夹即可。VSC 会自动检测是否存在虚拟环境，并且自动切换到虚拟环境下。如果您使用 Shell，可以手动在命令行中运行 .venv 文件夹内的 acitvate 脚本（Windows 在子文件夹 Scripts 中，Linux 则一般在 bin 子文件夹中），即可启动该虚拟环境。\n在搭建好虚拟环境（或者不使用虚拟环境）之后，需要从网上下载需要的包来帮助 Python 脚本的运行，实现各种功能。这时就需要用到包管理器。Python 默认的包管理器为 pip，使用 pip 安装或者更新包都十分简单，以安装 matplotlib 举例，输入命令 pip install matplotlib 即可。要更新包，则使用 pip install --upgrade matplotlib 就可以。如果有一份使用 pip freeze 所生成的软件包列表（一般该列表文件名为 requirements.txt），则可以使用命令 pip install -r requirements.txt 即可根据该列表中的内容进行安装。\nPython 语法基础 上面的废话可能有点多了，下面就介绍 Python 最主要的语法点，作为使用 Python 的基础，同时提出一些编程语言中所拥有的共性：\n类型 虽然 Python 是一门动态类型的语言，数据在 Python 中是根据上下文做出类型判断的，然而这里还是简单介绍一下 Python 中常用的变量类型。其中最常用的就是一些基础类型，如 int，float，str，bool 等，它们分别代表整数，浮点数，字符串，布尔值。这些类型是 Python 所天然支持的，也是一般语言中常常原生支持的类型。除了这些基础类型外，还有很多的组合类型，如 List（列表），Dict（字典），Tuple（元组）等等。这些类型通常是由一些基础类型所产生，比如列表，就是由不同类型的内容组合在一起形成的类似于容器的数据结构。\nPython 中的类型通常其本身也是一个类 (class)，意味着它们也有一些成员函数可以进行操作。这里就不详细叙述了。\n此外，尽管 Python 是动态类型语言，其依旧支持对类型的标注。Python 采取后置类型标记方法，在变量的后面添加 : 然后跟上对应的类型名即可标注其类型。值得注意的是，尽管有了类型标注，这个标注更应该作为仅对程序开发者或使用者的提示，这里标注的任何类型都没有任何的约束力。\n变量声明 Python 的变量声明非常简单，只需要遵循 name = value 的规则即可声明并初始化一个变量。顺带一提，Python 中变量的赋值也是同样的语法，而 Python 中的变量又具有唯一的名称，因此在使用 name = value 的语句时，如果前面已经声明了 name 这个变量，则会直接使用新的值覆盖掉原有的值。而且由于是动态类型语言，这里不会因为类型不匹配而报错。因此你可以随时让一个变量拥有别的类型。这一点十分灵活，尤其是在确定某个变量的值不再使用，而该变量的名称又很适合用作下一个值的名称时，即可立刻覆盖掉原有的值。\n作用域 编程语言中常常拥有作用域这一概念。这个概念可以认为是为了约束变量的生存周期而存在的。一般而言，一个变量的作用域在没有特殊声明的情况下，只能对自己所在的区域以及该区域下的子区域可见。\nPython 这门语言其中的一个特别之处就在于，Python 的作用域划分是通过缩进完成的。当代码顶格写成时，这些语句的作用域即为全局作用域。而如果有代码需要在某个作用域内时（比如，定义的函数内，for 循环中，条件判断中），则需要使用冒号 : 打开一个新的子区域，然后使用缩进去标识哪些部分是属于该作用域的。这一点褒贬不一，有人认为这个方法很简洁，避免了过多的符号；也有人认为这种风格让 Python 的代码逻辑可能不清晰，造成阅读困难。但是，无论如何，Python 的作用域是这样通过缩进定义的。那么，在上一层的作用域中所定义的变量对下一级的子作用域是可见的，而子作用域内定义的变量会在程序脱离该作用域之后消失，因此子作用域的变量对外部是不可见的。这一点几乎是所有编程语言所通用的。\n举个例子：\n1i = 10 2outside = 1 3if i \u0026gt; 0: 4 inside = 10 5 print(outside) 6# 下面这句会报错，找不到定义. 因为在前面离开作用域的时候，inside就被回收然后消失了。 7# print(inside) 其中 if 就开启了一个新的子作用域，其中定义的变量 inside 在外面是看不到的，而其中是可以看到 outside 变量的。\n控制流，循环和判断语句 Python 中可以使用 for 循环，while 循环以及其他的循环。其中，for 循环比较特殊，只能在某个范围内循环，而这个循环需要是 iterable 的。这个所谓的 iterable 可以翻译为可迭代的，比如 range 函数所生成的范围，一个 List，一个 Tuple 等等。其语法为：\n1for i in iterable: 2 # Do something 3 # And something more 4# Here is not inside the for loop. 其中的 i 会从 iterable 的第一个元素开始，每过一个循环体便会让 i 变成 iterable 中的下一个元素，直到 iterable 中的元素被取完。而 while 循环则比较简单，只要判断条件为真则一直循环，当检测到条件为假时则终止循环。语法为：\n1while something_is_true: 2 # Do something 3 # And something more 4# Here is not inside the while loop. 所以一般而言，使用 while 循环时需要在循环体中让循环条件在某时不满足，以跳出循环。\nPython 中的判断语句是较为通用的形式，这里只介绍 if else 循环：\n1if something_might_be_true : 2 # Do something 3elif something_might_also_be_true: 4 # Do another thing 5else: 6 # No other condition is satisfied 7# Not in condition 其语法也是十分的简单。Python 还支持一行式的判断，可以对标 C/C++ 的三元表达式：\n1do_something if condition_is_true else do_other_things 这个语法非常贴近英语语法，且避免了难以理解的三元表达式。但为了代码结构清晰，请尽量使用完整的 if else 判断语句。\n函数 函数是众多编程语言的一大组成部分。Python 由于对类型不敏感，Python 的函数定义非常地简单：\n1def Some_function (parameter_1, parameter_2, parameter_with_init_value = init_value): 2 # Do something 3 # Do other things 4 return some_value 像这样就能成功地定义了一个函数。其中 Some_function 为函数名，其本身也是一个变量，所以在重新定义时实际上是为这个变量赋了新的值。\nparameter* 即为函数参数，这些参数名将用作外界参数传入函数内时使用的占位符，并且这些参数名将用在函数体内部。且其中最后一个参数 parameter_with_init_value 是具有默认值的参数，其默认值为 init_value。具有默认值也就意味着这个函数可以不传入这个参数以代表传入默认参数。在向函数传参时，可以按照函数参数的顺序传入参数，也可以显式地指明某个参数的值是什么，如 Some_function(parameter_2 = 1, parameter_1 = 3) 这种写法是合法的。\n最后的 return 代表返回的值。所谓返回值，可以认为是函数运算的结果。这个结果需要手动通过 return 关键字指定，这里使用了变量 some_value 作为占位符。\n函数除了便于代码复用之外，还可以让代码结构更加清晰，以及控制一段逻辑的输入-输出结构。这里不介绍 lambda 表达式，这是一类匿名函数，没有函数名，但是具有函数的功能（参数列表，返回值），即便目前大部分编程语言已经支持这一特性。\nPython 面向对象，numpy，matplotlib 这里简单介绍一些进阶的语言特性，以及展示两个常用包的使用。\n面向对象与类 面向对象是目前十分热门的编程范式，其通过将数据以及对数据的操作等打包为一个对象，从而实现对数据的统筹管理。而为了实现面向对象，就需要某种方式实现这种打包，这一方法即为所谓的类 (class)。各个语言对面向对象的实现均有其特点，在 Python 中对类的声明与定义语法如下：\n1class some_class: 2 def __init__(self, param_1, param_2, param_default = default_val): 3 # Define class members 4 5 self.member_1 = param_1 6 self.member_2 = param_2 7 self.some_member = param_default 8 9 # Do something, just like in a function 10 11 self.do_something() 12 13 def do_something(): 14 # Do something 15 16# End of definition 17 18my_variable = some_class(val_1, val_2) 19 20# Use Inheritance from some_class 21class derived_class(some_class): 22 def __init__(self, para_1, para_2, sub_para, para_default = default): 23 # Must call parent class\u0026#39;s __init__ method to avoid overwritting __init__ of parent class. 24 some_class.__init__(self, para_1, para_2, para_default) 25 self.sub_member = sub_para 26 27 self.sub_class_method() 28 29 def sub_class_method(): 30 # Do sub_class things 31 32my_sub_variable = derived_class() 可以看到，Python 可以通过定义 __init__ 函数来定义类里面都有什么成员变量，并且调用一些成员函数。定义成员函数时语法同定义普通函数别无二致，而在调用类中的内容时需要使用关键字 self。并且在使用类定义变量时，直接可以通过类的名称来作为函数名并传入 __init__ 函数中规定的参数即可调用成员函数 __init__。最后这里要提到的是，Python 的类成员访问控制符通过变量的名字进行控制，如双下划线代表成员是私有 (private) 的，单下划线代表成员是保护 (protect) 的，而其余普通名称则为公开 (public) 的。\n所谓私有成员，即只有该类内部可以使用的成员变量或方法。这些变量或方法在类外是不可见的。而所谓保护成员则是只在类内部以及子类（派生类）内部可以使用的成员，公开成员即为没有访问限制的成员，无论是外部还是内部都可以取得。使用访问控制可以控制 \u0026ldquo;谁能取到类内的数据\u0026rdquo;，从而保护数据不会被意外读取或者篡改。对访问控制的理解也决定着对面向对象范式的理解。\n然而我们这里并不对面向对象做要求（主要是我也不太懂 Python 的面向对象），这里就仅作一个介绍，并使用其最基础的部分而已。\n包，numpy，matplotlib Python 最强大的部分当属其活跃的社区所贡献的大量好用的包。为了实现科学运算，常用的数学库即为 numpy，而画图则有 matplotlib。这里大概介绍二者的基础使用。\n为了引入包，需要使用关键字 import。通常，为了使用 numpy 与 matplotlib，有如下代码：\n1import numpy as np 2import matplotlib.pyplot as plt 3# from matplotlib import pyplot as plt 底下注释的内容和上一行内容的功能相同。可以看到使用 as 关键字可以为包引入别名，而为了导入子模块可以使用 from 关键字，也可以直接 . 出来并引入。\n首先介绍 numpy 的一些使用。numpy 主要提供了一种数据结构：numpy.array，这种结构可以用来存储数组，矩阵等数学对象，且支持对其进行遍历，切片以及常见数学运算等操作；numpy.array 可以通过 Python 原生的 List 来初始化一个数组。对于尺寸相符的数组，可以进行加减乘除等运算，包括数组间运算，数组与标量运算等，非常方便。除此之外，numpy 还提供了大量的数学函数以供使用，比如 numpy.exp，numpy.sin 等，以及对文件的一些操作，将文件中的数据加载为 numpy.array。\n然后介绍 matplotlib.pyplot，这是一个绘制图形的库，通常与 numpy 搭配使用，可以高质量地将数据可视化。下面举一个绘制 $y = sin(2x)+1$ 的图像的例子，作为 numpy 以及 matplotlib 的应用。\n1import numpy as np 2from matplotlib import pyplot as plt 3 4x = np.linspace(0, 2*np.pi, 10000) 5y = np.sin(2*x) + 1 6plt.plot(x,y,\u0026#34;-b\u0026#34;,label=\u0026#34;$y = \\sin(2x)+1$\u0026#34;) 7plt.xlabel(\u0026#34;x\u0026#34;) 8plt.ylabel(\u0026#34;y\u0026#34;) 9plt.legend(loc = 1) 10plt.show() 上述代码首先定义了一个从 $0$ 开始到 $2\\pi$ 结束的，总数据量为 10000 的一个 numpy.array 并命名为 x，然后使用 x 通过运算定义了名为 y 的数组，最后使用 matplotlib.pyplot.plot 函数进行绘制并进行图像处理。可以看到 matplotlib 是支持 $\\LaTeX$ 语法的。\nPython 还有海量的包可以调用，大多数都拥有友好的 API 且易于上手。这里就不再赘述。\n算法实现 Python 的基础语法以及进阶语法先告一段落。接下来会演示上一章节内容所展示的算法如何使用 Python 进行实现。要实现的算法如下：\n向前欧拉法 数值积分方法 有限差分法求梯度与拉普拉斯 向前欧拉法 向前欧拉法的实现主要依赖于其显式公式部分。设待求 ODE 为： $$ \\dfrac{\\partial y}{\\partial x} = F(x, y), $$ 且解满足初值 $(x_0, y_0)$，要求求解范围为 $[x_0, x_t]$， 则根据向前欧拉法，选择合适的步长 $\\Delta x$ 后，有： $$ y_{n+1} = y_{n} + \\Delta x \\cdot F(x_n, y_n) $$因此，为了实现这一算法，该算法实现的函数有如下几点：\n接收参数：\n$x$ 轴的离散信息（初始位置，结束位置，步长） 解的初始值 $y_0$ ODE 右端的函数 $F(x,y)$ 的显式表达 返回值：\n一个数组，作为解得的 $y$ 的函数值 则有如下 Python 实现：\n1from typing import Callable 2 3def forwardEuler( 4 x_0: float, 5 x_end: float, 6 dx: float, 7 y_0: float, 8 F_x_y: Callable[[float, float], float], 9) -\u0026gt; list[float]: 10 result: list[float] = [y_0] 11 this_x = x_0 12 this_y = y_0 13 while this_x \u0026lt;= x_end: 14 this_y = this_y + dx * F_x_y(this_x, this_y) 15 result.append(this_y) 16 this_x += dx 17 return result 数值积分 数值积分的实现同样比较简单，分析该算法的输入输出如下：\n接收参数：\n$x$ 轴的离散信息（初始位置，结束位置，步长） 被积函数 返回值：\n一个数，作为积分值 根据不同的积分算法，可以有多种不同的实现。下面实现四种算法：\u0026ldquo;黎曼\u0026quot;式积分法，梯形公式，Simpson 公式，Newton-Cotes 公式。\n1from typing import Callable 2 3def RiemannIntegral( 4 f: Callable[[float], float], x_start: float, x_end: float, dx: float 5) -\u0026gt; float: 6 sum = 0 7 x = x_start 8 while x \u0026lt; x_end: 9 sum += f(x) 10 x += dx 11 return sum * dx 12 13 14def QuadratureIntegral( 15 f: Callable[[float], float], x_start: float, x_end: float, dx: float 16) -\u0026gt; float: 17 sum = 0 18 x = x_start 19 while x \u0026lt; x_end: 20 sum += f(x) 21 x += dx 22 sum -= (f(x_start) + f(x_end)) / 2 23 return sum * dx 24 25 26def SimpsonIntegral( 27 f: Callable[[float], float], x_start: float, x_end: float, dx: float 28) -\u0026gt; float: 29 sum = 0 30 x = x_start 31 while x \u0026lt; x_end: 32 sum += 4 * f(x + dx / 2) 33 sum += 2 * f(x) 34 x += dx 35 sum -= f(x_start) + f(x_end) 36 return sum * dx / 6 37 38 39def N_C_Integral( 40 f: Callable[[float], float], x_start: float, x_end: float, dx: float 41) -\u0026gt; float: 42 sum = 0 43 x = x_start 44 while x \u0026lt; x_end: 45 sum += 32 * f(x + dx / 4) 46 sum += 12 * f(x + dx / 2) 47 sum += 32 * f(x + 3 * dx / 4) 48 sum += 14 * f(x) 49 x += dx 50 sum -= 7 * (f(x_start) + f(x_end)) 51 return sum * dx / 90 梯度与拉普拉斯 这里针对二维情况进行计算。同上，考虑算法的输入输出：\n梯度：\n输入\n待计算网格（二维列表） 网格步长 边界条件字段（这里固定为周期边界以便实现） 输出\n两个二维列表，分别为对 $x$ 方向的梯度和对 $y$ 方向的梯度 拉普拉斯\n输入\n同上 输出\n一个二维列表，存储每个网格点的拉普拉斯 以下是代码实现：\n1def calc_grad( 2 mesh: list[list[float]], dx: float, boundary: str = \u0026#34;Periodic\u0026#34; 3) -\u0026gt; tuple[list[list[float]], list[list[float]]]: 4 Nx = len(mesh) 5 Ny = len(mesh[0]) 6 grad_x = mesh 7 grad_y = mesh 8 for i in range(Nx): 9 for j in range(Ny): 10 v_l = 0 11 v_d = 0 12 v_r = 0 13 v_u = 0 14 if (boundary == \u0026#34;Periodic\u0026#34;): 15 v_l = mesh[i - 1][j] if i != 0 else mesh[Nx - 1][j] 16 v_d = mesh[i][j - 1] if j != 0 else mesh[i][Ny - 1] 17 v_r = mesh[i + 1][j] if i != Nx - 1 else mesh[0][j] 18 v_u = mesh[i][j + 1] if j != Nx - 1 else mesh[i][0] 19 # elif (boundary == \u0026#34;Fixed\u0026#34;): 20 # XXX 21 grad_x[i][j] = (v_r - v_l) / (2 * dx) 22 grad_y[i][j] = (v_u - v_d) / (2 * dx) 23 return grad_x, grad_y 24 25def calc_laps( 26 mesh: list[list[float]], dx: float, boundary: str = \u0026#34;Periodic\u0026#34; 27) -\u0026gt; list[list[float]]: 28 Nx = len(mesh) 29 Ny = len(mesh[0]) 30 laps = mesh 31 for i in range(Nx): 32 for j in range(Ny): 33 v_l = 0 34 v_d = 0 35 v_r = 0 36 v_u = 0 37 v_c = mesh[i][j] 38 if boundary == \u0026#34;Periodic\u0026#34;: 39 v_l = mesh[i - 1][j] if i != 0 else mesh[Nx - 1][j] 40 v_d = mesh[i][j - 1] if j != 0 else mesh[i][Ny - 1] 41 v_r = mesh[i + 1][j] if i != Nx - 1 else mesh[0][j] 42 v_u = mesh[i][j + 1] if j != Nx - 1 else mesh[i][0] 43 # elif (boundary == \u0026#34;Fixed\u0026#34;): 44 # XXX 45 laps[i][j] = (v_l + v_d + v_r + v_u - 4 * v_c) / (dx * dx) 46 return laps 至此，我们使用 Python 实现了我们将在相场模拟中使用的大部分算法。具体的模拟过程中，我们可能不需要用函数的方式将这些算法打包起来，只需要直接实现即可。\n总结 这部分内容希望能对上一章节中的算法内容有更进一步的补充，并且希望能对算法如何实现为代码的过程起到促进理解的作用。同时，希望这里介绍的 Python 能成为您日常学习生活中的另一件有利工具，并且能对编程这门技术有一定的入门理解，为后续的程序编写提供基本的认识。下一章节将会介绍如何使用 C++ 来实现这些算法，并使用 C++ 完成一个小型的模拟，尝试从这个小型模拟中了解模拟过程中会面临的问题，以及数据最后的可视化方法。\n","date":"2024-11-22T00:00:00+08:00","image":"/images/Skadi.png","permalink":"/zh/posts/pf_tutorial/pf_tutorial_2/","title":"Phase Field: 相场模拟学习笔记 II"},{"content":"久闻 Arch Linux 大名，以前尝试过在自己的电脑上安装 Arch Linux, 但是无情地失败了。最近又有了尝试安装的想法，故顺带做此记录，以便将来回头嘲讽自己的离谱操作。\n引子：我与 Linux 我认识 Linux 是从疫情在家无所适从的时候，在自己的戴尔笔记本上安装 Ubuntu 开始的。在那之前，我只知道在 Windows 的江山之外，还有一片名为 Linux 的世外桃源（是的，当时还不知道有 Mac OS,笑死）。而随着对编程兴趣的逐渐浓厚，我愈发好奇那个 \u0026ldquo;只有高手才能玩得转\u0026rdquo; 的操作系统究竟是长什么样子。于是，在父亲的帮助下，我在我的戴尔笔记本上划出来一小块硬盘留给 Linux（是的，那时想安装双系统）, 并且安装了 Ubuntu。这个过程花费了我整整一天，中间甚至和父亲闹了点矛盾，把为数不多的精力全都耗光了。自然，安装好之后除了打开看了一眼，安装了个 QQ 然后给同学炫耀之后，便没了下文，在随后的哪次格式化硬盘的时候跟着不见了。\n第二次尝试 Linux 是在英国百无聊赖的时候开始的。那时又是对电脑感兴趣，又是好奇 Linux 操作系统是什么样子，于是便又一次自己尝试安装 Linux。这次是跟着鸟叔开始的，故而安装了他的教程里的 CentOS。emmm 这个系统好像是比较老旧了还是怎么样，目前也不是很火的样子。鸟叔的教程还是相当细心认真的，我这种纯小白（也许）也懵懵懂懂地在虚拟机上安装好了，尝试了几个命令，也感受到了命令行的神奇之处。然而，可能是好奇心太旺盛，抑或是其他的原因，我的兴趣点很快跑掉了，因此这次对 Linux 的探索之旅止步于学会用命令行关机（当然，现在也已经忘了 hhh）\n第三次便是进入研究生之后。由于程序需要在 Linux 环境下运行，编译和调试，我再一次尝试起了 Linux。不过这一次，我使用了 WSL 来运行 Linux。一开始也是安装的 Ubuntu, 后来总是在网上听到什么 \u0026ldquo;BTW, I use Arch\u0026rdquo; 这样的段子，以及各路网友的推荐，我便尝试了一下在虚拟机上安装 Arch Linux。不过也许是心浮气躁，没能搞成，后来安装了网上大佬的 Arch WSL, 现在也随着老笔记本的退役而说再见了。\n目前我使用 Linux 系统还是主要通过 WSL, 毕竟真的很方便。但是，心里总是痒痒的：为什么我不能装个 Arch 呢？所以这一次，我一定要安装好 Arch Linux 口牙！即便可能后面还是会沦为文件夹角落的落灰软件，我也要骄傲地喊出：\u0026ldquo;BTW, I use Arch!\u0026quot;（下期可能是 Debian 也说不定，哈哈哈）\n准备：VirtualBox 和 Arch 镜像站 环顾电脑一圈，发现我以前用的 VMware Workspace 安装包没有导到这台新电脑上来，而且即便现在安装 VMware Workspace 17 Pro 是免费的，它竟然还要我注册……于是我还是选择了 Oracle 家开源的 VirtualBox。再下来便是 Arch Linux 的源了，我选择使用 ISO 镜像安装，下载是通过淘宝的阿里云镜像站（其实就是第一个而已，懒得往下翻了）。下载了大概40 来分钟吧，感觉速度还行，1G 的大小来讲感觉还不错。\nVirtualBox 里给 Arch 预留了 4096MB（4GB）内存和 8GB 的硬盘容量，希望这么多够 Arch 用。校验过 SHA256 之后，因为之前设置虚拟机的时候没有指定 Arch 的镜像文件（因为还没有下载好）, 所以在启动虚拟机之后会显示 \u0026ldquo;failed to boot\u0026rdquo; 并且要求指定 DVD 的路径。这里选好 Arch 的镜像之后直接 mount and reboot, 便会进入 Arch 的安装界面了。从这里开始也算是正式进入 Arch Linux 的安装环节了。\n开始（准备）安装 帅气的开屏，然后进入 Shell 这里我们打开 Arch Linux Installation Guide以便根据官方教程进行安装。我不打算用 Arch Install, 感觉那个没什么意思（上次也是这么说的）。按照小节 1.4.2, 我们使用了光盘介质（ISO 也算是光盘镜像）, 所以直接第一个选项就可以了。\n旋即屏幕闪过很有黑客感觉的画面（个人猜测是系统自检，感觉像是 systemd, 因为左边有很多绿色 OK 字样）, 然后便进入了如下画面：\n根据 1.4.3, 我们这是来到了第一个虚拟终端 (Virtual Console), 身份是管理员用户 root, 使用的 Shell 是 Zsh。感谢虚拟机，让我不用担心在 root 账户下做的愚蠢操作会害死我的电脑和我自己。那么我们继续吧~\n键盘映射和字体，以及验证启动模式 接下来要设置键盘映射。其实个人感觉美式键盘就不错，不过还是看一下吧。说不定以后会考虑搞点中文输入法，之前看到的小狼毫还不错的样子（现在也还再用）。\n扯远了，查看键位映射的命令是：localectl list-keymaps。这个命令感觉很容易拆分为 \u0026ldquo;locale ctl\u0026rdquo;, 本地化控制的感觉。很快阿，输入命令之后直接跳出来一个长长的列表！里面应该是所有 Arch Linux 支持的键盘映射选项。这里好像就是使用了 Vim 输出到屏幕上的，所以支持所有的 Vim 操作（当然，我就只会那几个，以及退出）。坏消息是，没有中文的选项，不过这里应该是我犯蠢了，键盘布局其实中文用的貌似就是美式布局……Anyway, 我们就直接接受最基本的设置即可，不考虑更改键盘布局了。后面可能我会考虑把 CapsLock 键映射为 ESC/Ctrl, 不过就现在而言还是省了吧。这里还可以设置键盘的字体，但是也省了吧，以后再说。感觉这个地方设置的主要目的还是为了能顺利安装 Arch Linux, 个性化之类的内容按理应该是放在装完系统之后的。\n接下来是验证启动模式。使用命令如下：cat /sys/firmware/efi/fw_platform_size。WTF? 竟然显示没有这个文件。按照说明，这里系统应该是使用 BIOS 或者 CSM 方式启动了。查看虚拟机设置里的母板（主板）部分，可以看到 启用 EFI 没有被勾选上。好吧，那就说明应该就是 BIOS 启动了。\n验证网路环境，然后更新系统时间 然后尝试联网。作为网络小白，我只能按照说明上的一步步来了。首先检查网络接口 (network interface) 有没有打开，使用命令 ip link, 得到了两行内容，一行是 lo, 另一行是 enp0s3。看不懂。查看 Arch Wiki 上关于网络接口的部分，lo 是 virtual loopback interface 的意思，且不会用在联网上。而另一个 enp0s3 看起来像是正确的网络接口。根据说明，en 代表的是以太网 (Ethernet), 而且只要显示了 UP 的字样，便表明该接口是已启用了的。很好，说明我们的网络接口设置没遇到什么阻碍。\n我的虚拟机是使用的 NAT, 这个 NAT 根据 Google 得到的结果来看，是 Network Address Translation 的缩写，是一种把 IP 地址重映射的技术。听起来很像是路由器在做的工作。根据安装引导的说明，我们需要做的是插好网线并且配置好动态 IP, 而动态 IP 又好像是会自动配置好的。所以实际上什么都不需要做就可以了其实。那么网络这块儿的最后一步便是尝试 ping archlinux.org。很不错，ping 出来结果了。这个命令就我的认知而言，是尝试向某个网址发送一些短数据包，然后让对应网址的服务器返回一个数据包，以此来检测网络延迟情况。除了尝试 archlinux.org, 我还试了试 ping B 站，Google, 百度。结果除了谷歌以外都不错。可能是因为代理没有代理虚拟机的端口吧（瞎猜）。无论如何，网络这块儿是搞定了。接下来是更新系统时间。这个简单，timedatectl 就可以。轻轻松松。看来这会儿是美国时间下午4 点半。\n磁盘分区咯，还要格式化并挂载 现在要进行的就是磁盘分区了。每次到了这里总会感觉紧张，不知道是不是因为之前搞坏过磁盘的缘故（虽然是物理损坏，和操作系统没关系）。先来看看都有哪些设备可用：fdisk -l。结果显示找到了两个设备：/dev/sda 和 /dev/loop0, 一个是我预留好的 8GB 固态虚拟文件系统，另一个是什么我不是很懂。教程上讲，以 loop 结尾的可以不用管。可是我这是以 loop 开头的呀……算了，应该没问题。这里提示如果没有显示硬盘，需要确保硬盘控制器没有处于 RAID 模式。RAID 阿，看来磁盘阵列这种好像还不太好直接搞 Arch? 因为我这里的硬盘是普通的 SATA, 所以就忽略 NVMe 等的提示了。\n接下来正式开始分区。这里指出了两个要划出来的分区：用于根目录 / 挂载的分区 以及 用以 UEFI 模式启动的 EFI 系统分区。这个 EFI 分区我有印象，在 Windows 系统的磁盘管理中，可以看到 C 盘里面就又一个 EFI 系统分区。看来 EFI 现在是比较通用的系统启动方法。这里我发现我好像设置的磁盘空间太小了，教程里给的是至少 23-32GB 留给根目录挂载的，唉。郁闷阿。看来要火速删机然后重新搞起。\nWaiting……\n还好之前基本都是检查性质的条目，直接可以跳过。重新分配硬盘空间到了 64G, 启动后就可以开始分区了。这里教程里有提到几个点：1。想好怎么分配空间；2。如果要组存储池之类现在就要搞；3。如果这个盘上已经有 EFI 了就不要重新建立 EFI 了；4。可以在支持 Swap 的文件系统设置 Swap。底下还有两个分区示例，我们就尝试最简单的那个，也就是 1.9.1 中的 第一个方案。个人而言这个方案也挺合适的。\n我们使用 fdisk 来创建硬盘分区：fdisk /dev/sda（我这里用来分区的磁盘是这个 /dev/sda, 所以命令后面跟着的是这个）。这个命令行工具我从来没用过（上次安装好像用的不是这个，有个 TUI。也有可能 fdisk 也有 TUI, 这次没搞出来吧）, 查阅 fdisk 的说明，直接从第四节开始，首先是说明创建分区会抹掉这个磁盘上的所有数据。很吓人，还好我们在虚拟机上。应该不会影响到我可爱的 C 盘的吧。\n首先创建分区表。这里使用 MBR 分区表，因为默认如此。根据网上的搜索结果，MBR 也适合我这种磁盘容量比较小的情况。然后按 n 进入创建分区引导界面。这里会询问你的分区类型（是初始还是拓展）, 分区的编号，以及起始和结束扇区。第一个分区分给 /boot 作为启动分区，结束扇区前的部分一律默认（初始分区，1 号，从 2048 扇区开始）, 然后通过命令 +1G 来给第一个分区 1G 的容量。然后创建第二个分区，也是前面全部默认，最后用 +4G 指定容量为 4G。这里我把这 4G 作为 Swap（好像就是虚拟内存）分区，先使用命令 l 查看每种分区类型的代码（Swap 的代码是 82）, 然后 t 开始改变分区类型，选择 2 号，类型写82。最后把所有的空间分给第三个区，然后给分区1 打上 bootable 的标签（用命令 a 然后选 1 号）。\n这个时候可以用 p 来查看分区结果，会有一个表格写着所有的内容。确认无误就可以 w 来写入分区结果了。接下来要格式化文件系统，不然操作系统不知道文件是怎么存放的。首先用命令 lsblk -f 来查看现在的磁盘信息（或者就是刚刚的分区情况）。这里我显示的结果如下：\n说明之前的 sda 磁盘已经被分成了三个区域，且都没有挂载。现在我发现了一个问题：我用的是 MBR 分区表，为什么使用了 GPT 分区表推荐的 /boot呢？而且之前还说没有开启 EFI, 现在又要搞 EFI 适用的 /boot, 离谱。很好，那就重新分区吧。\nWaiting\u0026hellip;\n很好，在熟练的操作下~~（指现学）~~, 先用 d 删除所有分区，然后创建 4G 的 Swap 分区，以及 bootable 的主目录分区。现在的分区结果是这样的：\n我们采用最经典的 ext4 文件格式（其实就是教程里这么推荐的）来格式化 /dev/sda2, 命令为：mkfs.ext4 /dev/sda2; 然后用命令 mkswap /dev/sda1 将 /dev/sda1格式化为 swap。整体结果如下：\n最后，我们终于要挂载文件系统了。这个我了解过，使用 mount 命令即可挂载硬件到某个目录下。首先我们挂载根目录，把根目录挂载到 /mnt 下：mount /dev/sda2 /mnt。因为我没有别的什么文件分区，只剩下一个 Swap 分区，所以我们直接使用命令：swapon /dev/sda1 来启动 Swap。\n真的要安装了 刚刚才发现，上一节内容是 \u0026ldquo;Pre-installation\u0026rdquo;。晕了，原来刚才的真的全都是准备工作吗？好像看起来确实如此，因为没有涉及到什么具体的软件安装之类的，更像是创造一个能让 Arch Linux 得以安装的环境。但是看安装说明，安装这一节只有两个小节，看来也不是很复杂的样子。\n首先是要选择镜像。这次选择的镜像感觉上是给系统使用的 pacman 的镜像源。Arch 已经有一份使用 Reflector 生成 的镜像服务器列表：/etc/pacman.d/mirrorlist, 可以查看或编辑这个文件以使地理位置最靠近的服务器地址可以被优先使用。这里我使用 reflector --latest 10 --sort rate 来按照响应速度排序最近更新的10 个服务器。结果好多都 timeout 了。尝试命令 refletor --country China --age 12 --sort rate, 试了两次，结果又是时好时坏。不管了，起码这个时好时坏也算是有源可用。使用命令 reflector --country China --age 12 --sort rate --save /etc/pacman.d/mirrorlist 即可把输出的结果保存到 /etc/pacman.d/mirrorlist 里面。\n接下来要安装必要的包。根据教程，这里安装的包有 base 包，Linux 内核以及一些常见的固件。使用命令：pacstrap -K /mnt base linux linux-firmware。之后便进入了安装界面。\n看来要安装 127 个包，不是个小数目。而且我这里的网速看起来也比较一般。慢慢等吧。这个安装进度让我莫名想起安装 $\\LaTeX$ 时候的样子。\nWaiting……\n? 后续过程这么快的吗？127 个包看来都不是很大的样子。安装好之后的样子是这样的：\n可以看到其实有一些部分是缺失的。这个应该没什么关系，毕竟虚拟机可能确实会缺一些不紧要的组件。这里还可以安装一些别的组件，比如 CPU 的指令集更新 (microcode), 使用 RAID 的工具之类。这里就先跳过了，之后使用 pacman 安装需要的内容。microcode 由于我使用的是虚拟机，指令集补丁应该存在于主机（这台 Windows）上， 所以不需要安装。\n安装在虚拟机上的时候好像不需要安装 linux-firmware, 额……无所谓了。现在才看到也是醉了。那么就下一步吧。\n设置系统吧！ 分区文件，chroot, 以及本地化 首先先生成一份分区表文件，使用命令：genfstab -U /mnt \u0026gt;\u0026gt; /mnt/etc/fstab。之后用 cat 看看结果是否正确，不对的话需要改一下。fstab 的 Arch Wiki 页面有一些例子，这里就不再赘述（因为我搞的好像没什么问题）。\n接下来 change root 到新系统下：arch-chroot /mnt。根据中文 Arch Wiki 的解释，chroot 是 \u0026ldquo;修改当前进程及其子进程的可见根目录的操作\u0026rdquo;。似乎修改之后进程就会以 /mnt 为根目录 /:\n我大胆猜测，现在就是把进程从 ISO 文件中的系统转移到了我虚拟机上的系统。不过怎么验证这个想法我没什么主意。下一步吧。\n现在要设置时区。国内应该是东8 区。根据教程，可以使用 timedatectl list-timezones 来列出可用时区。然而坏消息是，列出来的时区因为太多了，而不知道为什么，这个终端我没法滚动（鼠标或者键盘的 Shift + PgUP 好像都不可以）。经过一番查找，向上翻页的功能应该是被从内核中砍掉了，因为没多少人用了，目前几乎都在用终端模拟器。好吧，只好把结果重定向到文件里，然后再用 Vim 打开试试了。不过竟然新系统是真的什么都没有，包括 Vim 或者 Nano 都没有？？？ 只好退回到安装镜像里看看了。国内时区使用的是 Asia/Shanghai, 所以重新 arch-chroot /mnt 回到根目录，使用命令 ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 命令设置好时区，然后使用命令 hwclock --systohc 生成 /etc/adjtime。\n再下来是设置本地化。我个人倾向于不改中文使用，而且因为没有安装编辑器，现在要改变本地化设置也不行（太蠢了，为什么不安装编辑器！）。好吧，再憋一下。还是先设置主机名吧。因为没有编辑器，干脆直接使用 echo 重定向到 /etc/hostname 好了。那么在不使用 LVM 或者 RAID 的情况下，下一步便是设置密码。设置密码的时候输入的字符是隐形的，所以看不见是正常现象。好像 Linux 下的大多数密码输入的时候都会隐形，也算是保护隐私的一种惯例了。\nBoot loader, 然后决定命运的重启！ 最后，选择并安装 boot loader。这一步很重要，不然会导致无法启动 Arch Linux。根据 boot loader 的特性表，以及网上查找到的信息（其实是我没找到怎么搞 EFI boot stub 的安装）, 我选择使用 GRUB 作为 boot loader。首先安装 GRUB（算了，顺带这一步把 vim 也安装了吧，憋不住了）: pacman -S grub vim。根据 GRUB 的 Arch Wiki 页面，找到 BIOS System 的第二条 Master Boot Record (MBR)（我怎么在 UEFI 这里兜兜转转好久）。使用命令：grub-install --target=i386-pc /dev/sda。这里 --target=i386-pc 是固定的，而后面的 /dev/sda 是指的硬盘而非分区。\n安装好这一步之后就需要对 GRUB 进行配置了。使用命令：grub-mkconfig -o /boot/grub/grub.cfg。貌似这样就已经配置好了。感觉还是挺简单的。怎么心里毛毛的。好，重启吧！希望一切顺利。\n成功辣！好耶！但是迎接我的并不是美丽的图形化界面，而是简洁的 tty1, 甚至要我输入账户名来登录。好吧，看来起码安装是成功了，现在要做的就是后处理了。\n安装后的配置 其实要是较真地讲，现在已经把 Arch Linux 安装好了。但是我决定送佛送到西，配置一个能日常使用（玩耍）的系统出来。按照安装教程的说明，现在跳转到了 general recommendations 的页面。这也算是教程吧，就按着这个来吧。\n系统管理：添加用户和其他 首先是要学习两个概念，系统管理 (system administration) 和包管理 (package management)。这两个里第一个对任何的 Linux 系统而言都应该是重要的，而第二个应该是出于 Arch Linux 独特的滚动更新模式，所以要特别强调。\n第一点就是讲明 root 账户应该只应用于系统管理的情况，平时应该使用未经提权（提高权限）的普通用户。使用命令：useradd -m amoment 便可创建一个名为 amoment 的用户，并初始化这个账户对应的 /home/amoment 文件夹。随后使用 passwd amoment 来给这个账户一个密码。具体操作中，因为要先登入 root 账户进行操作，所以可以在执行完之后 logout 然后重新以新创建的 amoment 账户登录。\n接下来是安全问题。看不了一点儿，越看越觉得自己在互联网上裸奔（其实应该已经是了）。感觉自己的网络安全意识还有待提高。这一部分的文档很长，以后再细看吧。随后是服务管理，主要是说 systemd 的使用。也许以后会有需要用 systemd 来搞一些自动化的服务。最后是 Arch 滚动版本带来的系统维护的需要。由于是玩具系统，这步也暂时免了吧。\n包管理：pacman, 但是…… Arch Linux 默认使用 pacman 作为包管理器。用包管理器可以安装东西，前提是有网络。然而……好消息是，我新安装的 Arch Linux 莫名其妙没有网了。所以，包管理章节先暂停一下，先跳转至 网络设置 部分。\n有网络再说安装吧！ 问题的症状很奇怪，使用命令 ip link 之后显示的网络适配器都是未启动的状态，而使用 ip a 之后显示的内容都是没有 ipv4 地址的。\n猜测1: 虚拟机设置有问题 捣鼓了半天的 NAT 网络设置，但是感觉问题应该不是出现在了这里，因为之前就是使用的 NAT, 不然我的 vim 都安装不上去的 猜测2: ip 设置有问题 可是 ip 我也不懂啊，互联网（物理）小白是真的搞不懂这些网络协议之类的。回去翻看安装说明，也没有讲到这里呀。 回忆：安装的时候是有网络的，安装完好像没碰过网络环境。 猜测3: 该不会是我自己系统上没有装驱动吧 坏了，网上一通搜，真的是没有装网络服务 dhcpcd 和 networkmanager。乖乖回去用安装镜像进入，mount 根分区 /dev/sda2 到安装镜像的 /mnt 然后 arch-chroot /mnt, 开始老实安装 dhcpcd 和 networkmanager。这里要感谢讨论串和一篇博文。 回看安装指引，这时才明白，条目 1.7 最底下的 Note 是什么意思了：网络服务在新装的系统上面是通通没有滴！Okay, 安装完毕，继续回到 pacman 上。 pacman: 没错，孩子，又是我 首先，为了能让我以普通用户身份提权然后使用 pacman, 先在 root 账户下安装好 sudo 吧：pacman -S sudo。然后安装结束后会发现一个很尴尬的事情：我的普通账户不在 sudoers 文件中。查看指南，指出需要使用 visudo 来修改 /etc/sudoers 文件，但是 visudo 需要 vi。我安装了 vim, 但是这个不默认安装 vi。网上的解决方法看起来有点麻烦，所以干脆直接 pacman -S vi 安装。之后 visudo /etc/sudoers, 在某处（我在 root 开头那行的底下）插入 amoment ALL=(ALL:ALL) ALL , 即可在需要时提权我的个人账户。\n太棒了，但是这是否已经解释了如何使用 pacman? 好像还不够。查阅指南，指南中指出在安装软件包时，不要使用 pacman -Sy, 这样会造成部分更新，容易搞崩系统（俗称 \u0026ldquo;滚挂\u0026rdquo;）。安装软件包使用 pacman -S \u0026lt;pack name\u0026gt;; 升级系统使用 pacman -Syu。这里再解释一下 pacman 的命令行的意思吧，-S 代表的是 Sync, 是同步的意思，意即使用该命令是从 Arch Linux 的软件源服务器上把对应的软件包同步到本地。实在是很新颖的做法，起码概念已经甩开传统的安装了。-Syu 中的 y 是指 refresh, 从服务器上下载最新的包数据库，而 u 则代表 sysupgrade, 更新系统上的所有软件包。所以 -Syu 的意义就很明显了，不希望拉到了最新的软件包数据，却又没有实际更新软件。所以这两者放在一起最合适是有道理的。\n看一下删除包吧。我发现 networkmanager 好像是不必要的，Arch Linux 使用的 systemd 自带一个 systemd-networkd。删除软件包使用命令 pacman -R 即可删除包，但是这种情况下会留下这个跟着这个包一起下载到本地的依赖们。要顺带删掉空闲的依赖（可能有些依赖别的软件包也在用）, 使用 pacman -Rs 即可。其中 s 代表的是 \u0026ldquo;recursive\u0026rdquo;。R 的意义就很明显了，就是删除 (Remove)。所以为了删掉 networkmanager 且不影响到别的软件包依赖，使用命令 sudo pacman -Rs networkmanager 即可。\n最后了解一下如何列出安装好的软件包吧。使用命令 pacman -Q 即可列出所有已经安装好了的软件包（非常多，因为在安装系统的时候就已经在使用 pacman 了）。其他的设置可以通过 pacman -h -Q 来查看（-h 即为帮助的意思咯）。\n桌面环境：KDE 很好，先在这个系统距离可以让我谜之自信地喊出 \u0026ldquo;By the way, I use Arch\u0026rdquo; 感觉只剩下最后的一步：安装桌面环境。经过不细致的选择，我决定使用 KDE Plasma 作为桌面环境（Gnome 的拟物图标感觉不是很喜欢呀，虽然左侧栏的设计很喜欢，不过好像 Plasma 也可以搞？）。\n通过 pacman -S plasma-meta 安装 plasma。中间有几处需要选择一些诸如字体，解码器之类的供应源，网上没有多少讨论这个的，所以就基本全部默认了。然后顺手安装上 zsh, noto-fonts-cjk/emoji/extra, bluez-utils kitty, konsole 和 alacritty。这里 kitty, konsole, alacritty 三个重复了，因为我想都试试。\n首先打开蓝牙：sudo systemctl enable --now bluetooth, 然后通过 sudo systemctl enable --now sddm 即可进入 KDE Plasma 桌面。剩下的就是点点点了，点点点，爽！我必须立刻把任务栏（这里叫 panel）移至左边！\n安装别的工具…… 我中文输入法呢！ 为了实现中文输入法，我安装了 fcitx 大礼包：fcitx5, fcitx5-configtool, fcitx5-chinese-addons, fcitx-gtk。然而莫名其妙地遇到了几个奇怪的坑： 1。教程讲要把一些内容加入到文件 /etc/environment/ 里面，然而我用 vim 打开之后发现是只读的。虽然可以覆盖，但是总是感觉不对。经过网上的搜索才得知：没错，这就是权限控制。使用 ls -l /etc/environment 命令可以看到最左边的权限控制符，指明了这个文件是只有拥有者才可以读写，同组或其他人只能读，而这个文件的创建者正是 root。所以乖乖使用 sudo vim /etc/envirnment 就可以了，其实很简单。 2。为了配置中文输入法，我找到 KDE Plasma 的设置里面的 Input Method 部分，并且在右下角的 Add Input Method... 中选择了 Keyboard - Chinese。然而什么都没有发生。即便左下角的输入法显示的是 zh, 可依旧不是中文输出。很怪！然而解决方法出乎意料的简单：在仔细观察各路大佬博客之后，我发现中文输入法不叫这个名字，而是应该直接搜索 pinyin。无语了，心态有点小爆炸。 根据 fcitx 的官网教程，为了使 fcitx5生效，应该在路径 ~/.config/environment.d/ 下创建文件 im.conf, 并在其中输入：\n1XMODIFIERS=@im=fcitx 2SDL_IM_MODULE=fcitx 这样一来，重启之后就可以使用中文输入了\n还是想要用 fcitx5-rime 安装 fcitx5-rime 我是直接按照说明来的。直接用 pacman 就好：sudo pacman -S fcitx5-rime 即可。然后在输入法中直接搜索 rime 选中应用就好。但是这个时候的词库啊配置啊什么的都不太合意。然而我 Windows 端也用 的是 Rime 家族的输入法（具体来讲是小狼毫 weasel）而且有一套调教过的配置（使用 oh-my-rime, 也叫薄荷输入方案）。所以，干脆把配置从 Windows 上导入到虚拟机里好了。\n为了能把我在 Windows 上的配置文件直接导入到 Arch 里，需要在 Arch Linux 里面下载：virtualbox-guest-utils（不支持 X 的话要安装带个 -nox 后缀的版本）, 然后把它加入到 systemd 的服务中去：sudo systemctl enable --now vboxservice.service。然后再在虚拟机上打开 Drag and Drop 以及 Shared Folders。我将我用的 Rime 配置文件打包成 tar 之后放在了 Shared Folder 里，然后就可以从虚拟机上的指定位置取出来然后解压缩到需要的路径了。其实期间有考虑过使用 ssh 或者是其他的方式来传输这个压缩包，后面还是放弃了。反正能完成目标就好，ssh？ 不用也罢！（）\n不能科学上网吗？ 虽然是虚拟机，还是想试试安装一些科学上网的工具。目前 Windows 上有在用的工具，但是貌似在 Linux 上并不是很好用呀……经过一通搜索之后锁定到了 V2rayA, 使用 yay 就能很简单的安装（？ 代理？Github？）。\n实际尝试过后，发现这个工具好像和我目前在用的有点八字不合？在 Windows 上也尝试同款工具之后，发现确实是不太好用，唉。那就算了吧。不科学上网，那又能怎么样呢？\n终端字体怎么怪怪的？Alacritty？ 听闻 Alacritty 使用 Rust, 性能十分优异，然而在我满心欢喜地调整系统字体为中文之后，Alacritty 的字体变得惨不忍睹了……\n这究竟是怎么回事？在热心群友的帮助下，我查阅了 Alacritty 的 Arch Wiki, 得到了令人震惊的事实：我竟然没有配置字体文件。直接下载安装 ttf-cascadia-mono-nerd（其实不下载也可以）, 然后在家文件夹下创建新文件夹和文件：.config/alacritty/alacritty.toml 并使用 vim 修改内容。格式如下：\n保存的时候便会直接应用。其中 family 是可以从设置的字体管理部分看到字体族的名字，输入即可。这里字体族主要是需要等宽字体族才能正常显示，选择这款字体是因为我 Windows 上的终端字体也是用的这套 Cascadia, 很喜欢所以就干脆保持一致了。\n来自 2026 年的吐槽 这家伙现在都还没写 ZSH 的安装，但当时就又急着发出去了。他虽然现在确实一直在用 ZSH，但很明显没有继续把这篇写下去的必要了。如果您对此感到失望，请查看他比较新的一期文章，那里他在一台物理机上（一台红米的 Redmi Note 14）安装了 Arch Linux，尽管他甚至计划把 Arch 也换掉变成 NixOS……\n如果不是借着翻译全站的机会，估计这篇文章真的就这么半吊子着吧。如果您在这篇文章的这部分更新前就看到了这篇文章，那真抱歉没能让您看到本文的后续；如果您是现在看到了这篇文章的更新，请尽情地嘲讽他吧，哈哈哈！\n那么，一如既往，祝您身心愉悦，工作顺利！\n","date":"2024-11-10T22:34:55+08:00","image":"/images/Reimu_Water.jpg","permalink":"/zh/posts/others/arch_install/","title":"从零开始的 Arch Linux 安装"},{"content":"本文写于9月22日，因为这是我读完的第一本数学专业的书，故而感觉很有必要记下来点什么，于是就有了这么一篇流水账。作为 Mathematics 部分的第一篇博文正是再好不过\nPrinciples of Mathematical Analysis，Walter Rudin（May 2, 1921 – May 20, 2010）所写的一本“适合于高年级本科生或数学系一年级学生”的数学分析教材，因其作为 Rudin 所著的三本分析学教材（另外两本为 Real and ComplexAnalysis 与 Functional Analysis）中最“小”的一本，故而得名 Baby Rudin（另外两本也被分别称为 Papa/Big Rudin 与 Grandpa Rudin），作为我在课外自己读完（但几乎什么题都没写）的第一本数学专业书，在断断续续读了一年多以后终于读完了。说来惭愧，本来是适合本科生的数学书，却是拖到了研二才读完，还好我不是数学专业的。本人作为一个门外汉，抱着喜悦的心情，简单分享下自己读后的感想。\n即便从大三以后随着兴趣瞎读了很多数学书，在我看来我“认真读过”的数学书也许也只有微积分学教程（菲赫金哥尔茨著），Algebra: Chapter 0（PaoloAluffi 著），An Introduction to Manifolds（Loring W. Tu 著）以及这本Baby Rudin。除此之外的书几乎都是简单翻阅过，并没有细看。Thomas W. Hungerford 所写的大名鼎鼎的 GTM 73 我虽然想过仔细阅读，但是还是没有坚持下来。到头来唯有这本 Baby Rudin 是从头到尾几乎处处的看完了（这里指除了至多可数个的习题）。不过这些翻阅过的书也算是给了我一些勇气和底气，让我去对一本数学专业教材评头论足。\n这本书处处体现着“惜墨如金”四个字，而且不似其他很多作者那般喜欢使用比较形式化的语言（比如，用一些记号，如$\\left( X, A, \\mu \\right)$三元组来表示度量空间），反而对符号的使用相当克制。最令我惊讶的是，直到最后一章的第三小节，Rudin 才引入了用代表元来表示集合的记号 $\\\\{ x \\vert P \\\\}$ 。虽然分析学也许并不会像代数学那样大量使用元素性质各异的各类集合，但能把这么常用的符号放到这么后面才介绍，也许的确称得上是“惜字如金”。不过换个角度来讲，也许也正是不过多借助符号，反而更多采用文字描述的方式介绍数学概念，这本 Baby Rudin 才会有其独特的魅力。\n这本书的内容编排上，在我看来也与众不同。这本书在构造实数的过程中，捎带手把复数一并处理了，甚至还在第一章末尾专门单开了一个部分用来从头到尾地叙述实数的构造。随后第二章也并没有急于引入数列或者极限，而是这时才引入函数这一概念，再在集合和函数的基础上讲起点集拓扑，而且“limit”一词也是作为点集拓扑中的“极限点（limit points）”出现的，而非传统的序列极限引入。这里还要提一嘴，“Topology”这个词全文中只在标题和两处提到拓扑学的三角剖分的句子中出现了，而在主讲拓扑的第二章正文中更是一次也没有出现过。第二章中没有拓扑一词，但却通过引入距离（度量）而切实地讨论了对分析学而言更有意义的拓扑空间，实在是很新奇的阅读体验。拓扑概念的引入对后续的内容有极大的影响。提前引入拓扑语言的好处在于能更细致地刻画拓扑与分析学之间的关系。如后面函数的连续性一章，就积极地引入了“开集的原像是开集”这个与传统连续性定义等价的描述。\n在做完数域，拓扑等概念的铺垫后，迎接读者的不仅是数列这一常见的用以引入函数极限的概念，还顺势加入了级数的介绍。这与许多教材将级数等内容放置于教材内容偏后位置的做法不同，不仅更早引入收敛，而且更好地联系起了“序列极限”与“无穷级数”两者，并立刻用到上一章所介绍的拓扑概念，给出了完备性与序列之间的关系。\n在微积分的三大部分（微分，积分，函数序列/级数）中，最具特色的地方当属函数积分在简单引入黎曼积分后，更广泛地讨论黎曼-斯蒂尔切斯积分（Riemann-Stieltjes Integration），以及由于拓扑，完备等内容的引入而讨论的完备函数空间等。常见教材经常会更多地讨论黎曼积分的性质，并在靠后的内容中直接引入勒贝格积分。而本书则在定义了黎曼积分后直接给出了更加广泛的黎曼-斯蒂尔切斯积分，并更多地讨论它的性质。而且归功于度量，完备性等的引入，函数序列/极限部分还讨论了函数空间的拓扑性质，且这一部分最后的 Stone-Weierstrass 定理更是提出了从多项式逼近函数，从一些代数的角度研究了多项式空间和函数空间，这些特点无不令我大开眼界。\n而在讨论完这些微积分的常见内容后，Rudin终于决定讲一些常见的，比较特殊的函数（不是特殊函数论的特殊函数）。最有趣的应该是三角函数的定义并没有采用常见的定义方式，而是积极使用了本书中早早提到的复数/复变函数，采用复指数函数的方式定义了三角函数，用意想不到的方式给出了𝜋的定义，然后告诉读者我们现在处在一个可以简单证明复数域代数完备性的位置上，并用约一页的篇幅证明了这个著名的定理。\n多变量函数部分最让我印象深刻的是解决了我一个长久以来的疑问：多变量函数的导数（非偏导数）到底是什么？Rudin 在引入线性映射这一和微积分看似联系不大的概念后，给出了多变量函数求导的结果：一个 $\\mathbb{R}^n$ 到 $\\mathbb{R}^n$ 的线性映射！这一结果让我对数学概念推广的认知更进了一步。除了这点令我如醍醐灌顶的部分外，其余部分就显得有点晦涩难懂了。特别是在隐函数定理和秩定理两部分，Rudin的证法在我看来无疑是天书。最后还是在互联网的帮助下似懂非懂，逃离了这部分。\n最后两章算是一般数学分析教材的 One More Thing 部分，微分形式上的积分以及勒贝格积分。微分形式上的积分在没有流形工具的辅助下显得有点苍白，但作为对多变量函数和向量值函数积分的补充部分，Stokes’定理给出的结论还是一如既往的优美。而勒贝格积分（Lebesgue Integration）的内容总算是让我知道了鼎鼎大名的勒贝格积分与黎曼积分之间的异同。勒贝格积分部分的最后引入的 $\\mathcal{L}^2$ 空间部分也解答了我的疑问：为什么调和分析要从 $\\mathcal{L}^2$空间讲起，它究竟有何优越性。$\\mathcal{L}^2$ 空间下的函数总是在给定一组基底（正交函数类）后有一个平方收敛级数与之一一对应，或者说，$\\mathcal{L}^2$ 是一个无穷维的，元素为函数的线性空间，并配备有 $L^2$ 范数。\n总的来讲，Baby Rudin 是一本从各个方面都让我大开眼界的书。毫无疑问，它带我从一个新的角度去审视分析，无论是拓扑的引入，多元函数求导，还是微分形式上的积分，勒贝格积分，这本书带给我的新概念和该年间的新联系都丰富了我的视野。不过，这本书即便是正文，有一些内容依然是比较难以理解的，特别是对符号使用的克制，有时觉得有些古色古香，有时又让我感到有些找不着北。而且，据说本文最精华的是每章最后的习题，这些部分我都是扫了一眼，大概看看几个可能会提出较新概念新定义的问题，并没有深入去做。也许我就是所谓的名词党吧，不怎么做题，应该是学不到什么真材实料的。不过作为一个爱好者，感觉也没有什么太大的问题吧。下一本书可能是读完回国前正在看的 Intro to Manifolds，也可能是还在国外的时候看的 Chap 0，但是最有可能的应该是暂时放下数学。\n最后，我想对沃兹基德讨论组的小顾同学表达感谢，没有她组织的倒霉蛋抽奖环节，我不可能有机会一览这本经典分析学教材的风采。希望后来的倒霉蛋抽奖能帮助到更多人，也祝愿沃兹基德讨论组越来越好。\n","date":"2024-11-01T00:00:00+08:00","image":"/images/yoyoko.jpeg","permalink":"/zh/posts/book_review/baby_ruding/","title":"Baby Rudin 读后感"},{"content":"简单记录一下自己搭建博客的经历。\nBegin: 好想搭博客 网上高强度冲浪的过程中，发现很多人都有自己的博客，特别是在读过几篇博文之后，对会搭建博客的大佬的艳羡之情愈发激烈，同时也很想在网上搭一个自己的小窝，记录一下自己的学习和生活（以便于跻身大佬的行列（在心理上））。于是，在若干的纠结与选择之后，决定尝试用 Hexo + GitHub pages 的方式搭建一个自己的博客。\nTry：初试 Hexo 其实在10月份左右的时候，我就已经尝试开始搭建博客了。但是网上教程纷繁复杂，Hexo 的文档貌似也很久没有更新了，在写完 About 之后便陷入了各种方面的自定义，然后失败循环，结果便是迟迟没有推进博客搭建。期间找到过一个很喜欢的主题，结果因为中英文混排导致字符间距过大的问题，一怒之下怒删文件。结果便是把搭建博客的计划一拖再拖。（其实还是没有找到合适的喜欢的主题:P ）\n不过10月份失败的经历也算是一点点积累，给了我一点关于 YAML 和 TOML 的知识储备，以及让我（也许）学会了如何高效地搜索教程。不算太亏。\nAgain：再试 Hexo 转眼到了10月底，准确来讲就是昨天，10月31日。心血来潮的我再次向 Hexo 博客发起冲击。然而拦住我的不只是又没有找到好看的主题（这次是按照 Github 的 Star 数选的，也许是我审美太小众？），还有烦人的网络问题。一会儿是用 npm 拉取不到内容，一会儿又是 git 连接不到仓库。虽然后面网上搜索到是我科学上网姿势不对，需要跑全局，并且 npm 换成 淘宝源 就可以轻松解决，然而接连的 Error 还是耗尽了我的耐心。\n对吗？真的要搭博客吗？师兄这晚告诉我，可以考虑搞个微信公众号，要吗？\n?: 受不了，Hugo吧 还是不想搞公众号，感觉太公众了。虽然也有博客的功能，但是我的主题这块儿谁来给我补呀（？）是时，我又想起昨晚网上冲浪时发现的另一个构建框架：Hugo。于是当机立断，立刻冲向 Github 看看有什么好的主题，最后便相中了这款 Stack。简约界面深得我意的同时，又满足了我对页面版式的需求（好怪哦）。最重要的时，这个主题的模板目录结构很清晰！我也是摸索着添加了几个 icon 后配置出了令我满意的结果。感谢你，Jimmy Cai!\nEnd: 好耶 第一次搭建博客，踩的坑自己觉得不算少。不过大部分的坑都是因为不熟悉前端造成的。啊，要是我是一个搞计科软工前端 XXX 的学生那该多好啊，可惜，改不得。而且另一个角度来讲，踩坑也是一种学习过程嘛。日后也许会往博客上加点新的玩意儿，搞得更花哨一些~\n还有一件事让我背后直冒冷汗，在部署页面的时候犯蠢把github上的所有我自己的改动全给删了，差点以为全都找不回来了……好在 GitHub Desktop 在 Discard Stash 的时候是把改动文件全都删除到回收站，这才找回来这些配置。Git 操作还是要小心呀。\n也许会有人问我为什么花了大把时间在纠结主题上，说博客最重要的是内容。我也很同意内容为王的观点，但是用着不满意的主题，总感觉写起来不得劲儿。我比较相信“工欲善其事，必先利其器”，反正是第一次搭博客，对主题的试错成本几乎是无限低（不需要考虑迁移问题），那么为何不多尝试不同的主题，一次配好一劳永逸呢？\nSo, that\u0026rsquo;s it. Thanks for reading~!\n","date":"2024-11-01T00:00:00+08:00","image":"/images/Reimu.png","permalink":"/zh/posts/others/build_blog/","title":"First Blog: 搭建这个博客"},{"content":"这学期开了相场模拟培训，故尝试将相场培训笔记性质的内容记录下来，期望观感应该是目录式的笔记，外带可有可无的说明文字。那么就开始吧\nPhase Field Method 是什么？ Phase Field Method，直译为相场法，是一种材料模拟方法，其通过宽界面（平滑界面）的特点，克服了另一个模拟方法：Stefan 法的窄界面无法计算的缺点，实现了对材料中的相的演化的模拟。\n基本概念解析 Phase Field: 所谓的相场，可以理解为模拟域，给每个点赋予一个值来表示不同的相以及相界面 Order Parameter: 序参量，即上一条中用来表示不同相的变量。一般0代表没有这个相，1代表完全占据这个相，介于0到1之间的即为相界面。 Free Energy Functional: 自由能泛函，相场背后的热力学机理，通过系统对自由能最低构型方向的移动来演化出模拟域中每个点的值的变化。 Governing Equations: 演化方程，用来加工上述自由能泛函的方程。对不同特性的变量，需要选择不同的演化方程以进行演化： AC: Allen-Cahn方程，用来演化非保守场的方程（即变量之和可以不为某一定值，比如相序参量），可以认为是有源CH方程； CH: Cahn-Hilliard方程，用来演化保守场的方程（即变量值和为某个定值，比如浓度）. AC 和 CH 方程 AC方程的形式如下： $$ \\frac{\\partial \\eta_p}{\\partial t} = -L_{pq}\\frac{\\delta F}{\\delta\\eta_q\\left( r,t \\right)} $$CH方程形式如下： $$ \\frac{\\partial c_i}{\\partial t} = \\nabla \\cdot M_{ij} \\nabla \\frac{\\delta F}{\\delta c_j \\left( r,t \\right)} $$解两个方程需要的工具有：解ODE/PDE（有限差分法，FDM），求自由能的变分导数（欧拉-拉格朗日方程，E-L方程），向量微积分（$\\nabla$与$\\nabla^2$）\n解ODE: 有限差分法 数值方法解ODE有很多种不同的方法，比如傅里叶谱 (Fourier Spectrum) 方法，有限元法 (Finite Element Method，FEM)，以及这里讲到的有限差分法 (Finite Difference Method，FDM).\n有限差分法应该是最方便的一种求解方法，其基本思想便是简单地把\u0026quot;求导\u0026quot;过程中的\u0026quot;求极限\u0026quot;的步骤省略掉，用极小的区间上的商来替代导数。这样一来，复杂的求导运算即可通过简单的乘法和加法完成，而微分方程也就可以通过上一步（临近的上一个点）的值进行迭代来获得下一个点的结果，从而实现微分方程的求解。\n有限差分法相比与其他算法，其优势不仅在于求解逻辑简单，还在于该解法对于求解的区域的限制较小，对于多种边界条件下的微分方程都可以作出求解，因此是一种比较通用的解法。\n下面给出有限差分法的基本公式以及部分代码实现：\n对于如下的常微分方程初值问题： $$ \\dfrac{\\mathrm{d}\\,y}{\\mathrm{d}\\,x} = f(x,y);$$ $$ y(x_0) = y(a) = y_0, $$ 其中 $x \\in \\left[ a,b \\right] \\subseteq \\mathbb{R} $，$y : {\\mathbb{R} \\to \\mathbb{R}}$ 由此可以选定一大整数 $ N $，记 $h = \\dfrac{b-a}{N}$，$ x_0 = a, x_i = x_0 + ih, x_N = b, y_i = y(x_i).$ 则由有限差分，该初值问题方程可以改写为： $$ \\dfrac{y_i - y_{i-1}}{h} = f(x_{i-1},y_{i-1}); \\tag{显式欧拉法}$$ $$ \\dfrac{y_i - y_{i-1}}{h} = f(x_{i},y_{i}). \\tag{隐式欧拉法}$$ 其中显式方法可以直接求得： $$ y_i = h f(x_{i-1},y_{i-1}) + y_{i-1}. $$这里使用 Python 实现显式欧拉法：\n1\u0026#39;\u0026#39;\u0026#39; 2Explicit Euler Method 3list x and y should have an initial value. 4\u0026#39;\u0026#39;\u0026#39; 5from typing import Callable 6def explicit_euler( 7 x:list[float],y:list[float],h:float,N:int,f:Callable[[float,float],float] 8): 9 for i in range(N): 10 x.append(x[i]+h) 11 y.append(f(x[i],y[i])*h+y[i]) 对于隐式欧拉法，在给出$f(x,y)$的具体表达式的情况下，可以显式给出非递归的算法，否则由于等式右侧存在待求量，无法显式逐步解出。除了两种欧拉法，还有梯形公式（算术平均 $ f(x_i,y_i) $ 与 $f(x_{i-1},y_{i-1})$），通过预估-校正技术实现的改进欧拉公式，以及精度较高的 Runge-Kutta 方法。 这里给出四阶 Runge-Kutta 法的 Python 实现：\n1\u0026#34;\u0026#34;\u0026#34; 2Runge-Kutta Method 3\u0026#34;\u0026#34;\u0026#34; 4def runge_kutta( 5 x: list[float], y: list[float], h: float, N: int, f: Callable[[float, float], float] 6): 7 for i in range(N): 8 x.append(x[i] + h) 9 k_1: float = f(x[i], y[i]) 10 k_2: float = f(x[i] + h / 2, y[i] + h / 2 * k_1) 11 k_3: float = f(x[i] + h / 2, y[i] + h / 2 * k_2) 12 k_4: float = f(x[i] + h, y[i] + h * k_3) 13 y.append(y[i] + h / 6 * (k_1 + 2 * k_2 + 2 * k_3 + k_4)) 自由能泛函与变分导数：Euler-Lagrange 方程 自由能：引导体系演化的主趋力 先谈谈自由能。相场中使用的自由能主要是亥姆霍兹自由能。不过无论是亥姆霍兹自由能，还是吉布斯自由能，其作为自由能，都表明了一个体系的状态，且在自由能梯度的驱使下，体系将朝着体系自由能最低的方向发展。而这即为相场法背后的主要热力学依据。\n相场中所用的自由能通常具有以下的形式：\n$$ F(c, \\eta, \\nabla c, \\nabla \\eta) = \\int_{\\Omega} f(c, \\eta) + \\kappa_c (\\nabla c)^2 + \\kappa_\\eta (\\nabla \\eta)^2 + S\\; \\mathrm{d}\\omega.$$其中，$c$ 为浓度，$\\eta$ 为序参量（标示某个相区域的变量），$f(c,\\eta)$ 部分是体系的体自由能，可以认为是体系平衡时的自由能；两个梯度项 $\\kappa_c (\\nabla c)^2 $，$ \\kappa_\\eta (\\nabla \\eta)^2$ 为描述并控制相界面宽度与迁移速率的项，可以认为是界面能对总能量的贡献。最后的 $S$ 则是其余部分对体系自由能的贡献，如磁场，电场，温度场等等。这几个部分相互作用，共同指明了体系的演化方向，并标示了平衡状态。\n泛函：函数的函数 上面的自由能表达式实际上是一种泛函，其中的浓度和序参量均为模拟域位置的函数。除此之外，且是一类经典泛函： $$ J\\left[ y \\right]=\\int_{\\Omega} L(x,y(x),y'(x)) \\,\\mathrm{d}\\omega. $$ 的空间形式（即替换一般导数为梯度）. 其中 $\\Omega$ 是函数 $y$ 的定义域，函数 $y$ 在泛函中充当自变量的作用。这类泛函通常带有物理背景，因此得到了广泛研究，对其极限函数的研究（即能使 $J$ 取到极值的函数 $u$）也已经有一套成熟的方法。\nEuler-Lagrange 方程 所谓 Euler-Lagrange 方程，是指对上述类型的泛函的方程： $$ \\frac{\\partial L}{\\partial f}-\\frac{\\mathrm{d} }{\\mathrm{d} x}\\frac{\\partial L}{\\partial f'} = 0. \\tag{1} $$ 该方程的作用与一般函数 $y = \\phi(x)$的极值判断方程 $$ \\phi'(\\xi) = 0 \\tag{2} $$ 类似，都是指明了极值点出现的条件：(1) 指出极限函数 $f$ 应满足 E-L 方程，而 (2) 则指出极值点 $\\xi$ 应满足导数在该点处取值为0. 由此也不难理解泛函导数（或者叫变分导数）的形式应为方程（1）的左半部分： $$ \\frac{\\delta J[y]}{\\delta y} = \\frac{\\partial L}{\\partial y}-\\frac{\\mathrm{d} }{\\mathrm{d} x}\\frac{\\partial L}{\\partial y'}. $$ 当然，这里只给出了一阶导参与泛函定义的情况。对于更一般的情况 $$ J\\left[ y \\right]=\\int_{\\Omega} L(x,y(x),y'(x),\\dots,y^{(n)}(x)) \\,\\mathrm{d}\\omega, $$ 有如下表达式： $$ \\frac{\\delta J[y]}{\\delta y} = \\frac{\\partial L}{\\partial y}-\\frac{\\mathrm{d} }{\\mathrm{d} x}\\frac{\\partial L}{\\partial y'} + \\dots + (-1)^n \\frac{\\mathrm{d}^n }{\\mathrm{d} x^n}\\frac{\\partial L}{\\partial y^{(n)}}. $$向量微积分：$\\nabla$ 这里主要讨论 $\\nabla$ 算符运算法则，以及该算符与不同函数之间的作用结果。$\\nabla$ 算符定义如下：\n设一三维线性欧式空间 $\\mathbb{R}^3$ 的三个基向量分别为 $\\mathbf{x_1}$，$\\mathbf{x_2}$，$\\mathbf{x_3}$，则其上定义的 $\\nabla$ 算符为： $$ \\nabla = \\frac{\\partial}{\\partial x_1}\\mathbf{x_1}+ \\frac{\\partial}{\\partial x_2}\\mathbf{x_2}+\\frac{\\partial}{\\partial x_3}\\mathbf{x_3},$$ 写作向量形式则为： $$ \\nabla = \\left[ \\frac{\\partial}{\\partial x_1}, \\frac{\\partial}{\\partial x_2}, \\frac{\\partial}{\\partial x_3}\\right]^{\\mathsf{T}}.$$ 因此，$\\nabla$ 算符在直接作用于标量值函数（如 $f: \\mathbb{R}^3 \\to \\mathbb{R}$）时，结果为 $\\nabla f : \\mathbb{R}^3 \\to \\mathbb{R}^3$，得到该函数的梯度; 当其与向量值函数（如 $\\phi:\\mathbb{R}^3 \\to \\mathbb{R}^3 $ ）点乘时，结果为一标量值函数 $\\nabla \\cdot \\phi :\\mathbb{R}^3 \\to \\mathbb{R} $，得到该向量场（即为定义域每个点赋予一个向量而非标量值）的散度; 而当该算符与向量值函数叉乘时，得到的结果则为一向量值函数 $\\nabla \\times \\phi : \\mathbb{R}^3 \\to \\mathbb{R}^3$，是该向量场的旋度.\n如何考虑这样的算符运算结果呢？注意到不论如何作用，函数的定义域都是没有变化的，亦即：都是把一个向量映射到了某个值。既然如此，可以仅考虑其作用到的函数的定义域的影响，即：给每个坐标一个值，则形成一个向量；向量点乘给出一个标量；向量叉乘给出一个向量. 也可以把三种运算看作三种不同的\u0026quot;函数\u0026quot;: $$\\nabla:\\mathbb{R} \\to \\mathbb{R}^3 ;$$ $$\\nabla\\cdot: \\mathbb{R}^3 \\to \\mathbb{R}; $$ $$\\nabla\\times :\\mathbb{R}^3 \\to \\mathbb{R}^3$$与原函数相复合的结果。\n下面给出三种作用方式的具体表达式： $$\\nabla f = \\mathbf{x}_1 \\frac{\\partial f}{\\partial x_1} + \\mathbf{x}_2\\frac{\\partial f}{\\partial x_2}+\\mathbf{x}_3\\frac{\\partial f}{\\partial x_3};$$ $$\\nabla \\cdot \\mathbf{f} = \\frac{\\partial f_1}{\\partial x_1} + \\frac{\\partial f_2}{\\partial x_2}+\\frac{\\partial f_3}{\\partial x_3};$$ $$\\nabla \\times \\mathbf{f} = \\begin{vmatrix} \\mathbf{x}_1 \u0026 \\mathbf{x}_2 \u0026 \\mathbf{x}_3 \\\\ \\frac{\\partial }{\\partial x_1} \u0026 \\frac{\\partial }{\\partial x_2} \u0026 \\frac{\\partial }{\\partial x_3} \\\\ f_1 \u0026 f_2 \u0026 f_3 \\end{vmatrix}, $$ 其中 $f_i$ 表示 $\\mathbf{f}$ 的分量函数。下来再考察 $\\nabla$ 算符的运算性质。设 $a,b\\in\\mathbb{R}$ 为标量，$f,g : \\mathbb{R}^3 \\to \\mathbb{R}$ 为标量函数，$\\phi,\\psi : \\mathbb{R}^3 \\to \\mathbb{R}^3$ 为向量值函数。\n直接作用（梯度）:\n线性性：$\\nabla(a f+bg) = a\\nabla{f} + b\\nabla g$ 莱布尼兹律：$\\nabla(fg) = f\\nabla{g} + g\\nabla{f}$ 点乘（散度）:\n线性性：$\\nabla\\cdot(a\\phi+b\\psi) = a\\nabla\\cdot{\\phi} + b\\nabla\\cdot{\\psi}$ 乘法律：$\\nabla\\cdot(f\\phi) = f\\nabla\\cdot{\\phi} + \\phi\\cdot\\nabla{f}$ 叉乘（旋度）:\n线性性：$\\nabla\\times(a\\phi+b\\psi) = a\\nabla\\times{\\phi} + b\\nabla\\times{\\psi} $ 乘法律：$\\nabla\\times(f\\phi) = f\\nabla\\times{\\phi} + \\phi\\times\\nabla{f}$ 其他：\n梯度无旋：$\\nabla\\times(\\nabla{f}) = 0$ 旋度无散：$\\nabla\\cdot(\\nabla\\times{f}) = 0$ 拉普拉斯算符（先梯后散）: $\\nabla\\cdot\\nabla{f} = \\nabla^2{f} = \\Delta{f}$ 以上是比较常用的 $\\nabla$ 算符性质。实际上三种算符的组合恒等式非常多，而实际上常用的等式则为以上所列。\n总结 相场法作为一种材料模拟方法，其内容涉及范围广，包括材料学（热力学，动力学），数值方法，计算机编程等等相关内容。解决上述列出的若干问题是开始相场模拟所必须的数学方法基础。下一部分计划通过 Python 代码实现本文中的若干算法，包括数值解ODE，向量微积分的实现，以及数值积分方法。\n","date":"2024-11-01T00:00:00+08:00","image":"/images/Skadi.png","permalink":"/zh/posts/pf_tutorial/pf_tutorial_1/","title":"Phase Field: 相场模拟学习笔记 I"},{"content":"这是一篇写给初学 Python 的同学的教程，帮助使用 VS Code 快速配置好 Python 的开发环境，写于今年9月14日，先搬运至此并改为 Markdown，作为 Programming部分的第一篇博文\n简介 Python，一门伟大的语言。简易的，贴合人类语言的语法，丰富的生态，强大的功能让Python近几年来几乎稳坐最受欢迎编程语言的宝座。然而，对于刚开始接触编程语言的初学者而言，最麻烦的可能并非学习语法或者处理报错，而是搭建一个简单易用的开发环境。本文将尽笔者所能，介绍如何配置出 一套使用 VS Code + Python 的新手或轻度使用者适用的编程环境，以供新手平稳度过前期繁琐的边角料过程，尽快开始主菜。\n然而需要提醒各位读者的是，笔者本人并非 Python 主力用户，Python 于笔者而言仅为日常处理数据之用。因此如有不正之处，请与笔者联系修改，如有遗漏或不妥之处，欢迎联系笔者。在本文写作过程中，笔者并没有将自己搭建的环境删除后重新搭建以完成本文，因此可能会有很多与实际不相符之处。 笔者的新电脑很快就到了，届时会根据本文对照搭建对应环境以检测本文内容是否合适，还请读者朋友包容。本文也假设读者您使用的是 Windows 10 或以上的系统。如果您是 Linux 或其他系统的用户，我相信您不需要本文也可以快速搭建好环境。\nPython 解释器的下载 Python 语言的运行依靠 Python 解释器对编写好的 Python 脚本进行逐行解释，也可以通过交互式的方法，在解释器读取到输入内容后立即执行。因此，Python 的语言解释器对学习 Python 是必要的。下载 Python 解释器请前往 Python官网。初学者可以不用太过在意语言版本的问题（语言版本过新可能会导致某些未进行适配的库无法正常使用），只需要保证您下载的版本是 Python 3 即可（版本号以 3 开头）。Python 3 与 Python 2 有着很多基础语法上的区别，且很多库目前不怎么支持 Python 2。当然，为了省事，直接下载最新版也是没有任何问题的，在后续遇到实际需要时再进行版本修改也是没有问题的。\n在下载好 Python 解释器后，便可以进行安装。**请注意勾选添加到 PATH（ADD TO PATH）以避免后续复杂的手动添加环境变量的过程！**当然，如果不幸，您已经在没有勾选此选项的情况下安装了 Python 解释器，您可以考虑卸载后重装或者考虑手动添加 Python 路径到环境变量中。这里不再赘述。\n其余选项都可以一路默认。有个选项会提示您是否为所有用户安装，如果读者您使用的计算机内仅有一个账户（或者通俗而言，仅有您一人使用该计算机），那么是否选择此选项一般而言是无关紧要的。如果您使用公共电脑或者服务器，请不要勾选此选项，亦即仅为自己安装。\n最后，请检查您是否成功安装了 Python 解释器。您可以在键盘上按下 Win+R 键打开运行对话框，在对话框中输入 cmd 后确认，您将会进入一个“黑框”（命令提示符）中。此时在其中输入 python -V（请注意是大写的V）或者 python --version，如果成功安装了 Python 并添加到了环境变量中，则界面中将会出现您所安装的 Python 解释器的版本。否则，如果您看到类似于找不到 Python 定义之类的报错，那么有可能您的安装失败或者安装过程中没有将 Python 添加到环境变量中。\n以上便是安装 Python 解释器的过程。\nVS Code 的安装与配置 这一步与上一步是平行的，没有先后顺序一说，您可以自由选择先进行哪个部分。但建议您先进行上一部分，在本部分结束后您将可以直接在 VS Code 中开始 Python 编程。\nVS Code 是一个强大的文本编辑器。其最大的特点是其优秀的插件生态以及众多的语言支持（也是通过插件实现的）。通过 VS Code 与插件之间的配合，可以实现媲美 IDE 的开发环境搭建。笔者推荐由 VS Code 官方出品的在 VS Code 中使用 Python 的引导文档：Python in Visual Studio Code ，该文档详细介绍了如何从 0 开始在 VS Code 上使用 Python，除了是英文内容外几乎没有缺点（当然，您可以选择网页翻译）。下面笔者将自行介绍如何安装 VS Code 与相关插件。\n点击 此处 即可打开 VS Code 官网。VS Code 的安装可以全部选择默认安装，如此便可使用 VS Code 的基础功能。安装插件可以在侧边栏选择或者使用快捷键 Ctrl+Shift+X 打开插件市场，在页面上方框中输入相应关键词即可检索相关插件。如要进行 Python 开发，请安装如下插件。\nChinese (Simplified)（简体中文）Language Pack for Visual Studio Code: 可以使 VS Code 的语言显示变为中文显示。 Python Extension Pack: VS Code 上的 Python 插件全家桶。安装这个比较省事。 Python 脚本试运行 在以上所述的步骤完成后，您便可以开始编写您的第一个 Python 脚本以检测您的环境是否搭建完成。下面是一些简单的步骤：\n新建一个文件，将之按照自己喜欢的名字命名，并修改其后缀为 .py 。 右键该文件，选择 用 VS Code 打开。打开 VS Code 界面后，此时 VS Code 可能会询问您是否信任该文件夹。请选择信任以使您安装的插件正常运行，否则插件可能会被 VS Code 所屏蔽。 现在您可以编辑该文件了。输入一些 Python 代码，下面是一个简单的测试代码： 1print(\u0026#34;Hello Python!\u0026#34;) 请写好并保存该文件。 现在请尝试运行该脚本。如果您成功安装 Python 插件，该文件界面的右上角应该会出现一个小的向右的箭头。点击该箭头即可开始运行。由于您很有可能是第一次在 VS Code 中运行 Python 脚本，因此右下角会弹出一个通知框，通知您还未选择Python解释器。此时界面上方会出现一个对话框，让您选择您需要的Python解释器。您可能会看到多个解释器（如您下载了多个解释器版本）或者创建虚拟环境（Create Virtual Environment）。您可以先暂时不考虑设置虚拟环境，先使用已有的全局生效的Python 解释器。 选择好后，请再次重复上一步，按下小箭头。这是，VS Code 界面下方会出现一个新的窗口界面，显示的便是您程序运行的结果。此时您便已经成功运行了该 Python 脚本，也说明您的 Python 运行环境已经搭建成功了。 Python Debug，Pip，Jupyter Notebook 本节将简要介绍有关 Python 与 VS Code 的其他方面。\n调试（Debug） 调试是用以排查程序运行错漏的操作。代码一次写成，运行良好固然很好，但这种情况在实际开发中很难遇到。实际开发中常会遇到各种各样的问题阻碍开发进展。这些程序中或逻辑或语法的错误就被称为 Bug，在程序中排查 Bug 并修正以使程序得以正常运行的过程即是调试，亦即所谓的 Debug。\n最简单的调试方法即将程序在某一步的数据通过 cout（C++），printf（C）或 print（Python）输出到控制台上。但这种方法毕竟还是比较繁琐，特别是遇到难以输出到屏幕上的数据，此时输出的方式便会失灵。现代程序开发过程中，经常使用调试器（Debugger）来逐步运行程序，以此尝试发现程序中隐藏的问题。虽然 Python 本身已经是解释型语言，逐行运行已有的程序，但是通过调试器的诸多功能，仍可以为寻找程序漏洞问题提供帮助。\n首先介绍断点，程序在运行至断点后将会停在该处之前，等待用户的下一步命令。断点的插入在代码编辑器中一般处于左侧的行号附近（VS Code 在行号的左侧），插入成功后会出现一个小红点。当程序停在断点处时，您可以查看变量的值，函数调用栈等多种信息，随后您可以逐步向下运行程序，中断调试或者向步入函数内部（VS Code通过右上角小框控制）。\n要进入 VS Code 的 Debug 模式，请在运行 Python 脚本时，在右上角的代表运行的箭头旁找到一个向下的箭头，点击展开菜单后选择 Python 调试器：调试 Python 文件（Python Debugger: Debug Python File），随后便可进入调试模式。请注意此选项不仅会启动调试，也会改变右上角的默认启动模式为调试。调试模式下，该三角旁会出现一个小虫子，代表此时处于调试模式。\n请善用调试模式与调试器。\nPip Pip（Package Installer for Python）是 Python 的包管理器。所谓的“包”指的是 Python 运行过程中需要调用的函数库，类库等等。Python 的优点很大一部分来自于 Python 活跃的生态，指的便是丰富的第三方库，或者，第三方包。甚至于有人说，Python 是一门胶水语言，其就是用来将各种库粘合在一起以发挥作用。无论如何，包对于 Python 的意义是毋庸置疑的，而作为 Python 自带的默认包管理器，Pip 的基础操作也是值得简单学习的。下面介绍 Pip 的一些简单命令，并以安装 Python 下著名的科学运算库 Numpy 为例演示 Pip 的使用方法。Pip 的常用命令和参数有：\nhelp : 弹出帮助信息，会提示您命令的功能。 install :指示 Pip 进入下载模式。在 Pip 后附加包名称即可下载该包。如果需要更新某个包，请在包名前加上 --upgrade 以提示 Pip 更新此包而非安装。 uninstall : 卸载某个 Python 包。在该命令后附加包名即可。 list : 列出所有您已安装的 Python 包。 接下来介绍如何安装Numpy:\n请打开命令提示符，并输入 pip 以检查Pip是否正常可用。如果可用则会弹出部分帮助文本，不会有报错信息； 您可能会看到您的 Pip 有可用的更新。若在使用 pip 命令后，Pip 提示您可以更新到最新的版本，您可以选择根据提示输入命令进行更新。 输入 pip install numpy 以安装 Numpy。稍等片刻您便可以安装好 Numpy 以供全局使用。注意，您在全局环境下下载的 Numpy 将对全局生效。 Jupyter Notebook Python 脚本经常需要写好后一次性从头执行到尾，而使用交互模式（在命令提示符中打开 Python（python）将会进入交互模式）时 Python 会执行每次用户所输入的命令。前者不够灵活，而后者容易丧失上下文。是否有一种更加具有交互性的，但又不丧失上下文环境的 Python 使用方法呢？Jupyter Notebook 提供了这样的方法。\nJupyter Notebook 集成了 Python 环境和 Markdown，可以使您在代码框中使用并运行 Python 脚本，并在 Markdown 框中使用 Markdown 语法编辑文字。两种框的位置十分灵活，且 Notebook 可以打开在浏览器中直接使用，省去专门的编辑器的麻烦，也可以选择在 VS Code 中使用。下面将介绍如何安装和使用 Jupyter Notebook。\n请使用 Pip 安装 Jupyter: pip install jupyter 并等待安装完成。 输入 jupyter notebook 并回车。请注意不要关闭该窗口，该窗口将作为服务器运行，若关闭将会导致Jupyter Notebook无法使用。 稍等片刻，此时您的默认浏览器将会弹出一个窗口，左上角显示着 Jupyter，而下方主页面则是您的用户文件夹。您可以双击已有的以 .ipynb 后缀结尾的文件以打开一个已有的 Jupyter Notebook 文件，或者请点击右侧的新建 New → Notebook，便会在当前文件夹下新建一个 Jupyter Notebook 并打开在您的浏览器的新页面中。 此时新页面会请求选择一个 Python 内核。采用默认设置即可，此时您便已经新建了一个 Jupyter Notebook 了。默认的第一个框将是程序输入框，点击页面中央的框进入输入模式，输入 Python 代码后 Ctrl+Enter 以运行代码，结果将展现在该代码框的下方。 您可以通过上方的工具栏新建，插入，删除，运行代码框或者 Markdown 框。更多功能请自行探索。 以上，您便成功安装并试运行了 Jupyter Notebook。\n除了在浏览器中使用原生的 Jupyter Notebook 以外，您还可以在安装好 Jupyter 后在 VS Code 中启动。请安装好 Jupyter 的插件后，在 VS Code 中使用快捷键 Ctrl+Shift+P，或点击 VS Code 最上侧的搜索框后输入 \u0026gt; 以进入命令模式，然后输入 jupyter，此时对话框会提示您所有的可用命令，点击 **创建：新 Jupyter Notebook（Create: New Jupyter Notebook）**即可创建新的 Jupyter Notebook。后续操作类似于网页端操作。该方法不需要自行打开一个 Jupyter 服务器，VS Code 中安装的 Jupyter 插件将在 VS Code 的后台自行启动一个 Jupyter 服务器。\n后记 笔者希望通过该文章将笔者自认为好的且简单方便的 Python 使用开发环境介绍给本文的读者。然而作为一个非Python主力的用户，本文的内容纰漏在所难免，且 $\\LaTeX$ 的插图体验并不优秀，笔者没有向文章中插入图片而是采用语言描述的方法，希望读者能谅解。\n感谢您能读到这里。如果您对本文的内容有何看法或意见，欢迎联系笔者。最后，希望本文能真的实现，并帮助您实现 Python 的那句名言：\n人生苦短，我用Python。 Life is short，I use Python。\n祝您生活愉快。\n","date":"2024-11-01T00:00:00+08:00","image":"/images/妹红.jpeg","permalink":"/zh/posts/tools_note/vsc_py/","title":"Python + VS Code 快速配置"}]