简介:面向数值计算初学者与需要对照教材动手实践的C语言开发者,这份资料包提供甄西丰《实用数值计算方法》的全套配套源码,可配合矩阵运算、数值积分、微分方程求解、插值与拟合、非线性方程组及最优化等章节逐项运行验证。压缩包共702个文件、约13.83MB,以239个C源文件为核心,辅以txt说明、exe可执行程序及dsp、dsw、vcxproj等工程配置与调试中间文件,多数示例保留完整编译环境,便于直接打开、修改和复现实验。目前已吸引619人学习下载。读者可从中获得按章节组织的算法实现模板、函数接口与迭代求解思路,既能对照教材理解梯形法则、龙格-库塔法、牛顿法等经典方法的代码落地,也可将源码作为课程实验、毕业设计或科研原型的基础骨架,通过调试与扩展加深对数值计算流程和C语言工程组织的理解。
1. 教材配套的 C 源码包,值不值得花一晚上拆
教材配套的数值计算源码,真正的价值不是省下敲代码的时间,而是给你一套能对着课本公式逐行核对的参照实现。这份《实用数值计算方法》配套的 C 语言源码合集,覆盖线性方程组、插值拟合、数值积分、非线性求根和常微分方程初值问题几个大块,每个算法基本都是独立可编译的小程序。它适合三类人:正在上这门课、需要把例题跑出数的学生;要把教材算法搬进工程代码的开发者;以及手上有自己写的库、想找个干净基准做对拍的工程师。我拆这类包的习惯是先编一遍、再对拍一遍,能过这两关的才留下,下面按这个顺序讲。
2. 编译链路先立住:从散落的单文件 main 到能批跑的工程
教材源码最典型的形态是"一个例题一个 .c 文件,每个文件里一个 main,函数全在文件内静态定义"。这种结构对教学友好,对复用极不友好:函数名重复、大量scanf交互输入、没有头文件、数组长度写死成教材例题的规模。直接拿单个文件改,改到第三个例题就会开始怀疑人生。先把工程壳搭起来,后面每拆一个算法都省事。
2.1 先判断源码的组织形态
动手改之前先摸清楚包里到底是什么结构。两条命令足够:数一数独立入口有多少个,再看同名函数重复定义的情况有多严重。这决定了你是"直接编"还是"先重排目录"。
# 1) 统计有多少个独立入口(每个 main 就是一个可独立的例题程序) grep -rl "int main" --include="*.c" . | wc -l # 2) 粗略列出顶层函数定义名,重名多的说明模块之间不能直接链接 grep -rhoE "^[a-zA-Z_][a-zA-Z_0-9 ]*\**[a-zA-Z_][a-zA-Z_0-9]*\(" --include="*.c" . \ | sed 's/[ (]//g' | sort | uniq -c | sort -rn | head -20第一条命令的输出如果是几十,说明这是例题集合,正确做法是"一个 .c 编一个可执行文件",不要去搞静态库。第二条命令里如果lagrange、gauss这类名字出现多次,说明同名函数在不同文件里实现不同,更不能合并编译,否则链接期直接报重复定义。我一般会按算法类别重排成ch02_linear/、ch03_interp/这样的目录,文件名保留原例题编号,方便回头对教材页码。
提示:重排目录时别改 .c 文件里的内容,先让它编过、跑出数,再谈改造。顺序反过来,你会分不清是源码的错还是你改的错。
2.2 交互式 main 改成可脚本化调用
教材例题大量使用scanf从键盘读数,这意味着你没法批量跑、没法对拍、没法进 CI。最省事的改造不是把每个scanf都重写,而是给原文件套一层入口壳:把原来的main改名为run_example,新写一个main用freopen把标准输入重定向到数据文件。原文件只改一行函数名,其余逻辑一行不动。
/* run_example.c —— 给交互式例题套壳,原文件只改 main 的函数名 */ #include <stdio.h> int run_example(void); /* 原 main 改名后的函数,此处前置声明 */ int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "用法: %s <输入数据文件>\n", argv[0]); return 2; } /* 把 stdin 换成文件,原代码里的 scanf 完全不用改 */ if (!freopen(argv[1], "r", stdin)) { perror("freopen"); return 1; } return run_example(); }这段壳的关键点有三个。freopen的第二个参数是打开模式,读数据固定用"r";返回值必须判空,否则文件路径写错时程序会静默地从空输入读,拿到一堆未定义值;run_example的返回值原样透传,这样例题自己返回的错误码还能被脚本捕获。配套的数据文件按例题编号命名,比如data/ex2_3.txt里就按原来scanf的顺序一行一个数。做完这一步,整个包就能用一条 shell 循环跑完,输出统一重定向到out/目录,后面做误差对拍时非常省事。
2.3 用 Makefile 把几十个例题一次编出来
手敲gcc编几十个文件不现实,写个最小 Makefile。核心是-lm必须放在源文件后面,数学库链接顺序错了会报一堆undefined reference to 'sqrt',这是新手最容易卡住的地方。
CC := gcc CFLAGS := -std=c11 -O2 -Wall -Wextra LDFLAGS := -lm SRCS := $(wildcard ch*/*.c) BINS := $(patsubst %.c,%,$(SRCS)) all: $(BINS) # 每个 .c 单独编一个可执行文件,避免同名函数互相冲突 %: %.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) clean: rm -f $(BINS) .PHONY: all clean参数逐个说清楚。-std=c11是为了让变长数组和inline的行为可预期,教材代码里常见double a[n]这种写法,老标准下行为不一致;-O2是日常优化档,但做浮点对拍时要小心,见第 5 章的坑;-Wall -Wextra必须开,数值代码里"变量未初始化"这类警告往往就是结果不可复现的根源;$(LDFLAGS)放在$@之后,这是链接顺序的硬要求。模式规则%: %.c让每个源文件独立成程序,天然规避了函数名冲突。
2.4 第一次对拍:用解析解校准编译环境
工程能编只是第一步,还得确认算出来的数是对的。挑一个解析解已知的例子最稳:计算 4/(1+x²) 在 [0,1] 上的积分,精确值是 π。复化梯形公式的误差按 h² 收敛,步长每次减半,误差应该掉到四分之一。这个性质一验,编译器、数学库、浮点精度三件事同时被确认。
#include <stdio.h> #include <math.h> /* 被积函数:4/(1+x^2),在 [0,1] 上的定积分等于 π */ static double f(double x) { return 4.0 / (1.0 + x * x); } /* 复化梯形公式:n 为等分区间数,h=(b-a)/n 为步长 */ static double trap(double a, double b, int n) { double h = (b - a) / n; double s = 0.5 * (f(a) + f(b)); /* 两个端点各取一半权重 */ for (int i = 1; i < n; ++i) s += f(a + i * h); return s * h; } int main(void) { const double PI = acos(-1.0); int ns[] = {10, 20, 40, 80, 160}; /* 步长逐次减半 */ for (int k = 0; k < 5; ++k) { double v = trap(0.0, 1.0, ns[k]); double err = fabs(v - PI); /* err 每次应约降为 1/4,乘 4^k 后应大致是常数 */ printf("n=%3d I=%.12f err=%.3e err*4^k=%.3e\n", ns[k], v, err, err * (double)(1L << (2 * k))); } return 0; }编译跑一遍,重点看最后一列。如果err每次减半而不是减到四分之一,说明积分公式写错了(比如端点权重漏了 0.5);如果最后一列上下抖动两三个数量级,说明你的 n 还没进渐近区,把 ns 整体乘 10 再试;如果第一行就出现nan,先查头文件里f和数学库函数有没有重名。这一步过不了,后面所有算法都别急着往下拆。
3. 线性方程组模块:主元阈值、松弛因子与残差验收
线性方程组是整个包里代码量最大的一块,也是教材例题最容易"骗人"的一块:书上的矩阵都是精心构造的良态矩阵,你照着写永远不会发现主元选取、收敛判据这些问题。这一章的重点不是把算法抄一遍,而是把参数边界和验收标准定下来。
3.1 高斯消元为什么必须做列主元
教材讲原理时用朴素消元,代码里如果也照抄,遇到主元接近零的矩阵就会放大舍入误差,甚至直接除零。列主元消元的改动只有两处:每次消元前在第 k 列的第 k 到 n-1 行里挑绝对值最大的行换上来,换行时右端项必须跟着换。
/* 列主元高斯消元:A 为 n*n 行主序,b 为右端项,解原地写回 b * 返回 0 成功,-1 表示主元过小(矩阵近似奇异) */ int gauss_pivot(int n, double *A, double *b) { for (int k = 0; k < n; ++k) { int p = k; for (int i = k + 1; i < n; ++i) if (fabs(A[i * n + k]) > fabs(A[p * n + k])) p = i; /* 阈值不能写死成 1e-14,量级不同的矩阵要改成相对判据 */ if (fabs(A[p * n + k]) < 1e-14) return -1; if (p != k) { for (int j = k; j < n; ++j) { double t = A[k * n + j]; A[k * n + j] = A[p * n + j]; A[p * n + j] = t; } double t = b[k]; b[k] = b[p]; b[p] = t; } for (int i = k + 1; i < n; ++i) { double m = A[i * n + k] / A[k * n + k]; A[i * n + k] = 0.0; /* 显式置零,避免残留下三角垃圾 */ for (int j = k + 1; j < n; ++j) A[i * n + j] -= m * A[k * n + j]; b[i] -= m * b[k]; } } /* 回代:b 后面存的是已经算出的解分量 */ for (int i = n - 1; i >= 0; --i) { double s = b[i]; for (int j = i + 1; j < n; ++j) s -= A[i * n + j] * b[j]; b[i] = s / A[i * n + i]; } return 0; }参数说明:n是阶数,A用一维数组按行主序存,b既作输入也作输出,函数返回后b里就是解。1e-14这个绝对阈值只适合教材那种系数在 1 量级的矩阵,工程里更稳妥的做法是拿当前列的列范数做分母,改成相对判据。
提示:教材例题测不出主元问题。自己造一个 [[1e-20, 1], [1, 1]] 这样的矩阵,把朴素消元和列主元消元各跑一遍,差多少个数量级一目了然。这个矩阵建议留在你的测试集里,以后改一次代码跑一次。
3.2 雅可比、高斯-赛德尔与 SOR:松弛因子怎么定
迭代法的收敛性靠矩阵性质保证:严格对角占优是充分条件,真正的判据是迭代矩阵谱半径小于 1。教材一般会证明一遍,代码里你要关心的是三件事——初值、停止判据、松弛因子。
/* SOR 迭代:omega 为松弛因子,tol 为相邻两次迭代的无穷范数差,maxit 为上限 * 返回实际迭代次数,-1 表示未收敛 */ int sor(int n, const double *A, const double *b, double *x, double omega, double tol, int maxit) { double *xn = (double *)malloc(sizeof(double) * n); if (!xn) return -1; for (int i = 0; i < n; ++i) { x[i] = 0.0; xn[i] = 0.0; } /* 两个数组都要清零 */ for (int it = 1; it <= maxit; ++it) { double diff = 0.0; for (int i = 0; i < n; ++i) { double s = b[i]; for (int j = 0; j < n; ++j) { if (j == i) continue; /* j<i 用本轮新值,j>i 用上一轮旧值,这是 Gauss-Seidel 的时序 */ s -= A[i * n + j] * (j < i ? x[j] : xn[j]); } xn[i] = (1.0 - omega) * x[i] + omega * s / A[i * n + i]; double d = fabs(xn[i] - x[i]); if (d > diff) diff = d; } for (int i = 0; i < n; ++i) x[i] = xn[i]; if (diff < tol) { free(xn); return it; } } free(xn); return -1; }参数含义和取值边界:omega = 1.0时退化为标准高斯-赛德尔,omega在 (0,2) 之外必发散,omega < 1是低松弛,适合非线性问题或不稳定迭代,omega > 1是超松弛,用来加速。理论最优值由雅可比迭代矩阵的谱半径 ρ 决定,ω = 2/(1+√(1-ρ²)),但实际工程里没人真的去算谱半径,常见做法是取 1.0、1.2、1.5、1.8 各跑一遍,看迭代次数曲线哪里最低。tol建议按 1e-10 起步,maxit给 1000 到 10000,跑不到就说明矩阵性质不支持这个方法,别硬调参数。
三种方法的差别一句话说清:雅可比每轮用全套旧值,能并行但收敛慢;高斯-赛德尔用已更新的新值,收敛快一倍左右但不能并行;SOR 在 GS 基础上加松弛,调好了再快一截,调不好直接发散。
3.3 验收看残差,不看"和答案对上几位"
教材答案通常只给三四位小数,你看到 3.1416 就以为对了,这在小规模例题上没问题,规模一大就会翻车。正确的验收标准是相对残差,而不是解分量逐个比对。
/* 计算无穷范数下的相对残差 ||Ax-b||_inf / (||A||_inf*||x||_inf + ||b||_inf) */ double rel_residual(int n, const double *A, const double *x, const double *b) { double r = 0.0, Ainf = 0.0, xinf = 0.0, binf = 0.0; for (int i = 0; i < n; ++i) { double s = -b[i]; for (int j = 0; j < n; ++j) s += A[i * n + j] * x[j]; if (fabs(s) > r) r = fabs(s); if (fabs(x[i]) > xinf) xinf = fabs(x[i]); if (fabs(b[i]) > binf) binf = fabs(b[i]); double row = 0.0; for (int j = 0; j < n; ++j) row += fabs(A[i * n + j]); if (row > Ainf) Ainf = row; } return r / (Ainf * xinf + binf + 1e-300); /* 分母加极小量防除零 */ }分母里同时出现 ‖A‖·‖x‖ 和 ‖b‖ 是为了兼容解接近零向量的情况,只除 ‖b‖ 会在齐次问题附近炸掉。判别标准:双精度下相对残差能做到 1e-12 到 1e-14 属于正常,到 1e-8 就要看一眼矩阵条件数,到 1e-4 以上基本可以判定实现有 bug 或者矩阵本身就病态。注意残差小不等于解准,病态矩阵上残差可以很小而解的误差很大,这两件事必须分开看。
4. 插值、拟合与数值积分:节点选择、差商表和正规方程的病态
这一块是教材里公式最漂亮、实际用起来坑最多的部分。插值公式本身没几行,问题全在节点怎么选、系数怎么算、阶数给到多高。
4.1 拉格朗日插值:等距节点的龙格现象
拉格朗日插值的实现直白,双重循环,O(n²)。教材例题一般给五六个节点,插出来曲线很漂亮,于是很多人以为节点越多越准。拿龙格函数 1/(1+25x²) 在 [-1,1] 上试一次就清醒了:等距节点加到十来个,端点附近的插值曲线会剧烈振荡,误差比中间大两个数量级。
/* 拉格朗日插值:xs/ys 为 n+1 个节点,返回 x 处的插值结果 */ double lagrange(int n, const double *xs, const double *ys, double x) { double s = 0.0; for (int i = 0; i <= n; ++i) { double p = 1.0; for (int j = 0; j <= n; ++j) if (j != i) p *= (x - xs[j]) / (xs[i] - xs[j]); /* 基函数分母是节点差 */ s += ys[i] * p; } return s; }参数上唯一需要注意的是n是区间数不是节点数,节点共 n+1 个,两者差一,这个 off-by-one 在对拍时会导致整条曲线错位。分母xs[i] - xs[j]在节点重复时直接除零,所以输入端要做一次去重检查。
改进办法有两个:一是换切比雪夫节点,x_k = cos((2k+1)π/(2n+2)),端点附近节点自动加密,振荡被压下去;二是阶数别贪高,超过 10 个节点就考虑分段低次插值或者三次样条。常见做法是先看误差随节点数的变化趋势,如果加大节点数误差反而上升,那就是龙格现象无疑。
4.2 牛顿差商与增量求值
牛顿插值的优势在于新增一个节点时不用重算全部基函数,差商表可以增量更新。代价是差商表必须从后往前算,否则前一项会被覆盖,算出来全是错的。
/* 原地构造差商表:ys 长度 n+1,返回后 ys[i] 即 f[x_0..x_i] */ void divided_diff(int n, double *xs, double *ys) { for (int j = 1; j <= n; ++j) for (int i = n; i >= j; --i) /* 必须倒序,正序会污染下一轮 */ ys[i] = (ys[i] - ys[i - 1]) / (xs[i] - xs[i - j]); } /* 嵌套乘法求值:c 为差商表首列,等价于 c0 + c1(x-x0) + c2(x-x0)(x-x1) + ... */ double newton_eval(int n, const double *xs, const double *c, double x) { double s = c[n]; for (int i = n - 1; i >= 0; --i) s = c[i] + (x - xs[i]) * s; return s; }divided_diff的倒序循环是整个函数的关键,i从 n 递减到 j,每次只用上一轮还在原位的两个值,写完后ys[i]就是这一阶差商。newton_eval用嵌套乘法把 O(n²) 的求值降到 O(n),这是教材上经常一笔带过但实际很值得写进代码的优化。测试方法很简单:拿同一组节点分别跑拉格朗日和牛顿,在随机点上求值,两者差值应该在 1e-12 以内,对不上就是差商表被污染了。
4.3 最小二乘多项式拟合:正规方程的病态
拟合和插值的区别是点数远多于阶数,解的是超定方程。教材讲正规方程 AᵀA c = Aᵀy,实现起来最省事,但 AᵀA 的条件数是 A 的平方,阶数一高就病态到没法用。
/* 多项式拟合:m 个数据点,deg 次多项式,系数 c 长度 deg+1(升幂) * 构造正规方程后复用第 3 章的 gauss_pivot 求解 */ int polyfit(int m, const double *x, const double *y, int deg, double *c) { int p = deg + 1; double *M = (double *)calloc(p * p, sizeof(double)); double *rhs = (double *)calloc(p, sizeof(double)); if (!M || !rhs) { free(M); free(rhs); return -1; } for (int k = 0; k < m; ++k) { double xp = 1.0; /* x^i */ for (int i = 0; i < p; ++i) { double xq = 1.0; /* x^j,每个 i 重新起算 */ for (int j = 0; j < p; ++j) { M[i * p + j] += xp * xq; xq *= x[k]; } rhs[i] += xp * y[k]; xp *= x[k]; } } int rc = gauss_pivot(p, M, rhs); if (rc == 0) for (int i = 0; i < p; ++i) c[i] = rhs[i]; free(M); free(rhs); return rc; }参数说明:m是数据点数,deg是多项式次数,c按升幂存(c[0] 是常数项)。这个实现的时间复杂度是 O(m·deg²),对教材规模的题完全够用。适用范围要明确写死:deg不超过 6,且x做过中心化和归一化。原因是范德蒙矩阵的条件数随阶数指数增长,直接解正规方程在 deg 达到 8 以上时,双精度已经给不出可信结果。真要高阶拟合,走正交多项式(勒让德或切比雪夫基)或者 Householder QR,别在正规方程上硬顶。
4.4 复化辛普森与龙贝格外推
积分部分教材一般给三个层次:梯形、辛普森、龙贝格。梯形在第 2 章已经对拍过了,这里补辛普森和龙贝格。辛普森的代数精度是三次,误差按 h⁴ 收敛;龙贝格是在梯形序列上做理查森外推,只多几行代码就能把精度再抬两阶。
/* 复化辛普森:n 必须为偶数,h=(b-a)/n */ double simpson(double a, double b, int n) { if (n % 2) n++; /* 防御性处理,奇数等分辛普森公式不成立 */ double h = (b - a) / n, s = f(a) + f(b); for (int i = 1; i < n; ++i) s += f(a + i * h) * ((i & 1) ? 4.0 : 2.0); /* 奇数点权 4,偶数点权 2 */ return s * h / 3.0; } /* 龙贝格积分:R[k][0] 是梯形序列,逐列外推,tol 为相邻两行差 */ double romberg(double a, double b, double tol, int kmax) { double R[16][16] = {{0}}; double h = b - a; R[0][0] = 0.5 * h * (f(a) + f(b)); for (int k = 1; k < kmax; ++k) { h *= 0.5; double sum = 0.0; for (int i = 1; i <= (1 << (k - 1)); ++i) sum += f(a + (2 * i - 1) * h); R[k][0] = 0.5 * R[k - 1][0] + h * sum; /* 梯形递推,复用上一轮结果 */ for (int j = 1; j <= k; ++j) { double p = (double)(1 << (2 * j)); R[k][j] = R[k][j - 1] + (R[k][j - 1] - R[k - 1][j - 1]) / (p - 1); } if (fabs(R[k][k] - R[k - 1][k - 1]) < tol) return R[k][k]; } return R[kmax - 1][kmax - 1]; }辛普森那两个权重 4 和 2 别记反,判断条件是i & 1:i 为奇数时是区间的中点,权重 4;i 为偶数时是区间端点,权重 2。龙贝格的外推因子分母是 4^j - 1,j 从 1 开始,这个公式在教材上一般写成表格形式,落到代码里就是那个内层循环。两者对拍的方法:拿同一个积分跑一遍,龙贝格用 5 行应该就能到 1e-12,辛普森要 n 到几百才行,如果龙贝格收敛比辛普森还慢,基本可以确定外推系数写错了。
5. 避坑与排查:数值源码最容易翻车的五类现象
数值代码的坑有个共同特点:不报错、不崩溃,就是给你一个看起来像那么回事的错误结果。下面这五类是我拆包时反复遇到的,按出现频率排。
5.1 同一份输入跑两次结果不一样
现象:程序第一次跑出 3.1415926,再跑一次变成 3.1416011,重新编译又变回第一个值。
原因:数组或局部变量没有初始化,读到了栈上的残留值。教材源码里大量使用固定长度数组,作者默认"反正会被赋值填满",但一旦循环边界少跑一次,末尾那个元素就是垃圾。更隐蔽的是全局变量跨例题复用,前一个例题留下的状态带进了下一个。
解决:编译时把-Wall -Wextra打开,盯着may be used uninitialized这类警告;对可疑程序用gcc -fsanitize=address,undefined -g重新编一遍再跑,未初始化读和越界都会在第一次触及时报出来,比事后查快得多。养成习惯:所有malloc出来的数组一律用calloc,栈上的数组在声明处就写= {0}。
5.2 和教材答案差在第三四位
现象:例题算出来是 2.71828,书上印的是 2.71829,差在末位;或者干脆差到小数点后三位。
原因有三个,按可能性排:一是教材例题用的是float而非double,中间结果累积误差大;二是编译时开了-ffast-math或-Ofast,浮点重排和近似函数把结果带偏;三是你看的教材答案是手算或旧版程序的,本身就只有四位有效数字。
解决:先确认编译选项,做数值对拍时用-O0或-O2,绝对不要用-ffast-math,它会让a+b+c的结合顺序变得不可预测;再确认原代码用的是float还是double,教材源码里两种混用的情况很常见;最后以更高精度的参考值为准,比如用解析解或者long double版本对照。差在末位是正常的,差到第三位必须查。
5.3 迭代法残差先降后升
现象:SOR 或牛顿迭代跑了二三十步,残
本文还有配套的精品资源,点击获取