C语言实现SVD奇异值分解:单边Jacobi算法详解与完整代码
2026/9/8 11:01:32 网站建设 项目流程

简介:SVD奇异值分解的C语言实现面向需要手写矩阵分解算法的研发人员,适合在Visual Studio环境下学习奇异值分解的数值化过程。代码涵盖特征值求解、特征值排序、构造对角矩阵Σ以及生成正交矩阵U和V等关键步骤,并附带main.cpp测试文件,可参考标准库结果进行对比验证。压缩包共80个文件,包含2个C++源文件、1个C头文件以及完整的VS工程文件(sln/vcxproj),另有生成的可执行程序、编译日志和调试信息文件,整体大小约1.12MB,既可直接运行查看SVD输出,也可逐段调试研究迭代算法的数值稳定性。已有1087人学习下载,对正在学习数值分析、机器学习PCA或图像压缩技术的读者而言,这份代码能够把SVD的数学定义转化为实际可编译运行的算法实例,有助于理解主成分分析、矩阵近似与降维的底层实现。

引子:为什么我最后选了C来写SVD

如果你在搜索引擎里敲下“SVD奇异值分解C代码”,大概率是遇到了两类事:要么是课程作业或头歌平台上的实验题,要么是在做嵌入式、图像处理、推荐系统之类的实际项目时需要把矩阵分解跑起来。我当初属于后者,做一个图像压缩的小工具,Python原型跑通了,但要搬到一个资源受限的板子上,必须用C重写。查了一圈资料,要么是伪代码讲得云里雾里,要么是数学公式堆了一屏但没法直接编译,要么是LAPACK那套接口封装太重,交叉编译都费劲。干脆自己动手把SVD从零撸了一遍。

这篇文章就把我踩过的坑、试过的算法取舍、最终能跑的C代码结构一起梳理一遍。不保证是性能最优解,但保证每个环节都能对上号:为什么要这么做、内存怎么省、精度怎么控、遇到不收敛怎么处理。适合三类人看:正在做课程设计需要交SVD实验报告的同学、想把Python里的numpy.linalg.svd移植到C环境下的工程师、以及纯粹想把矩阵分解这层窗户纸捅破的爱好者。

1. SVD的基础认知与C语言实现的选型逻辑

1.1 SVD到底是什么,以及它在C环境里解决什么问题

先把概念压缩成一句话:SVD把任意一个m×n的实数矩阵A分解成A = UΣV^T,其中U是m×m的正交矩阵,Σ是对角矩阵(对角线上的元素叫奇异值,非负且从大到小排列),V是n×n的正交矩阵。从几何上看,它把一个线性变换分解成“旋转-缩放-旋转”三步,所以它在数据降维、图像压缩、推荐系统、最小二乘拟合这些场景里都是常客。

在C语言里实现它,核心问题不是“怎么算矩阵乘法”,而是“怎么把特征值问题在有限内存和浮点精度下稳定地解出来”。Python里你一行np.linalg.svd完事,底层是LAPACK用Fortran写的经过几十年调优的代码。C这边,如果没有外部库,一切都得自己来:内存分配、迭代收敛判断、异常处理、数值稳定性,全都要从底层考虑。这不是坏事,反而是个重新理解数值计算的机会。

1.2 主流实现路线对比:为什么选单边Jacobi

C语言实现SVD的路子大致有下面几条,我实际对比过:

方法基本思路优点缺点适用场景
双边Jacobi旋转通过一系列平面旋转同时对角化A^TA精度高,代码结构清晰每步迭代开销较大,列数多时慢中小规模矩阵,对精度要求高
单边Jacobi旋转只对A的列做正交变换,间接得到奇异值和右奇异向量不显式计算A^TA,数值稳定性更好实现细节比双边稍绕矩阵条件数较大时特别合适
Golub-Kahan双对角化+QR迭代先用Householder变换双对角化,再迭代对角化收敛快,适合大型稠密矩阵代码量大,前置知识要求高科学计算场景,生产级需求
调用LAPACK/BLAS用现成库性能最优,参数全交叉编译麻烦,依赖体积大桌面端开发,不愁依赖

我最终选了单边Jacobi。原因有三:第一,它绕过了构造A^TA这一步。A^TA的条件数是A的条件数的平方,如果A本身就是病态矩阵,构造A^TA会直接把数值误差放大到不可收拾。第二,单边Jacobi的代码量控制在200行以内,逻辑链条短,容易调试。第三,它对“哪个奇异值对应哪个向量”这种映射关系很直观,出问题时好排查。代价是收敛速度一般,但实测下来对几百阶以内的矩阵完全够用。

1.3 单边Jacobi的核心数学直觉

单边Jacobi的思路可以这样理解:我们要找一组正交变换V,使得变换后的矩阵B = A·V的列向量两两正交。一旦B的列互相正交,那么B^T·B就成了对角阵,而B^T·B = V^T·(A^T·A)·V,所以V恰好就是A^T·A的特征向量矩阵,B每列的范数就是对应的奇异值,再对B的每列做归一化就得到U。

那怎么让B的列两两正交呢?每次挑两列,做一次平面旋转,让这两列变成正交的。就像你手里有两根歪着的棍子,每次转动其中一个角度,让它们垂直。转动所有列对一遍叫一次sweep,多来几次sweep,所有列对都趋近正交。这就是“旋转-缩放-旋转”这层几何含义在算法层面的落地。

2. C代码实现的核心细节解析

2.1 矩阵结构和内存布局的选择

写C的SVD,第一步不是写算法,而是定矩阵的数据结构。我推荐直接用一维数组加行列信息,而不是用二维指针数组。原因很实际:

  • 一维数组内存连续,后续做缓存优化、传参、和外部数据对接都方便。
  • 二维指针数组在动态分配时容易产生内存碎片,释放时也容易漏。
  • 很多数据源(比如图像像素、传感器数据)本身就是连续存储的。

我的结构体设计长这样:

typedef struct { int rows; int cols; double *data; // 按行主序存储,data[i * cols + j] 对应第 i 行第 j 列 } Matrix;

如果矩阵是从外部读进来的,建议直接在读数据时就按这个结构填好,省掉一次拷贝。行主序这点要格外注意,因为后面所有下标运算都依赖它,错一个符号整个结果就废了。

2.2 核心迭代:一次旋转到底做了什么

单边Jacobi每次迭代的动作核心是:找到矩阵当前的第p列和第q列,计算这两个列向量的夹角信息,然后构造一个2×2的旋转矩阵,作用在这两列上,使它们正交。

具体到代码层面,我先算两列的内积a_pp、a_qq和a_pq,然后根据这三个值算出旋转角度的四个三角函数值:

double beta = (a_qq - a_pp) / (2.0 * a_pq); double t = (beta >= 0) ? 1.0 / (beta + sqrt(beta * beta + 1.0)) : -1.0 / (-beta + sqrt(beta * beta + 1.0)); double c = 1.0 / sqrt(t * t + 1.0); double s = t * c;

这段代码里有几个关键点值得展开讲讲:

第一,为什么用beta和t这套公式而不是直接用atan2?因为SVD应用里经常遇到列向量模长差异悬殊的情况,直接用角度计算会损失精度。用这个“去除开根号重排”的计算方式,在beta非常大或非常小的时候都能维持相对稳定。

第二,为什么当a_pq为0时要直接跳过?因为这两列已经正交了,不需要旋转。这时候不做任何操作,直接进入下一个列对。

第三,t的计算公式里为什么根据beta的符号分成两种情况?这是为了防止beta的绝对值太大时,sqrt(beta^2 + 1)和beta直接相减造成灾难性抵消。数值分析里这叫“避免减法消去”,是浮点计算里最常见的坑之一。

2.3 奇异值、左奇异向量和右奇异向量的提取顺序

很多人写到这里容易乱:旋转是对A的列做的,那U、Σ、V分别在哪里?我建议按这个顺序捋:

  1. 初始化单位阵作为V矩阵(维度n×n)。每一步旋转时,不仅更新A的列,还要给V右乘一个同样的旋转矩阵。这个直接用C代码表示就是:后续的每个旋转矩阵都要累乘到V上。

  2. 所有sweep跑完之后,A变成了列正交的矩阵B。B每一列的2-范数就是奇异值,按数值从大到小排个序。

  3. 左奇异向量U怎么来?B每一列除以该列的范数就得到U的对应列。但这里要注意一个关键点:B的列向量互相正交,所以U的列也是正交的,但U的各列必须对应正确的奇异值顺序。也就是说,排序时要同步交换U的列和V的列,不然还原A时结果全是错的。

  4. 还有一个容易忽略的细节:奇异值的符号。理论上奇异值非负,但如果某一列的范数算出来是0(也就是说B该列全零),那这个位置的奇异值是0,对应的U列向量可以随便取一个单位向量,一般取标准基向量就行。

下面是我实现的提取逻辑的骨架,结合了排序和同步交换:

// 计算每列范数并存入 singular_values for (int j = 0; j < n; j++) { double norm = 0.0; for (int i = 0; i < m; i++) { norm += A.data[i * n + j] * A.data[i * n + j]; } singular_values[j] = sqrt(norm); } // 按奇异值降序排列(简单选择排序,同时交换V列) for (int i = 0; i < n; i++) { int max_idx = i; for (int j = i + 1; j < n; j++) { if (singular_values[j] > singular_values[max_idx]) max_idx = j; } if (max_idx != i) { swap_double(&singular_values[i], &singular_values[max_idx]); swap_cols(V, i, max_idx); } }

2.4 收敛判据和sweep次数怎么定

单边Jacobi的收敛判断最笨的办法是定死迭代次数。但不同矩阵收敛速度差异很大,定死次数要么浪费算力,要么没法保证精度。我用的方法是每个sweep结束之后,扫描一遍当前所有列对,统计最大的|a_pq|相对于对角元素的比例。这个值小于某个阈值(比如1e-10)时就认为收敛,跳出循环。

代码里可以这样组织:

for (int sweep = 0; sweep < max_sweeps; sweep++) { double max_off = 0.0; for (int p = 0; p < n - 1; p++) { for (int q = p + 1; q < n; q++) { double apq = col_inner_product(A, p, q); double app = col_norm_sq(A, p); double aqq = col_norm_sq(A, q); double scale = sqrt(app * aqq); if (scale == 0.0) continue; double ratio = fabs(apq) / scale; if (ratio > max_off) max_off = ratio; if (ratio > tol) { jacobi_rotate(A, V, p, q); } } } if (max_off <= tol) break; }

我实测的经验值:max_sweeps设10到15之间,tol设1e-10,对大多数中等问题矩阵已经收敛。如果矩阵列数超过200,或者条件数超过1e6,建议把max_sweeps提高到30,同时把tol放宽到1e-8,不然可能徒增无谓的旋转运算。

3. 完整C代码实现与实操过程

3.1 完整代码清单(可直接编译运行)

我直接贴一个可用的完整版本,基于标准C99编写,不依赖任何第三方库,单文件就能跑。代码里我保留了必要的注释,方便对照上面讲的逻辑看。

#include <stdio.h> #include <stdlib.h> #include <math.h> #include <string.h> #define MAX_SWEEPS 30 #define TOLERANCE 1e-10 typedef struct { int rows; int cols; double *data; } Matrix; // 创建一个 rows x cols 的矩阵并清零 Matrix mat_create(int rows, int cols) { Matrix m; m.rows = rows; m.cols = cols; m.data = (double *)calloc(rows * cols, sizeof(double)); if (!m.data) { fprintf(stderr, "内存分配失败\n"); exit(1); } return m; } // 释放矩阵内存 void mat_free(Matrix *m) { if (m->data) { free(m->data); m->data = NULL; } m->rows = m->cols = 0; } // 设置矩阵元素 void mat_set(Matrix *m, int i, int j, double val) { m->data[i * m->cols + j] = val; } // 获取矩阵元素 double mat_get(const Matrix *m, int i, int j) { return m->data[i * m->cols + j]; } // 计算第p列的平方范数 static double col_norm_sq(const Matrix *A, int p) { double sum = 0.0; for (int i = 0; i < A->rows; i++) { double v = mat_get(A, i, p); sum += v * v; } return sum; } // 计算第p列和第q列的内积 static double col_inner_product(const Matrix *A, int p, int q) { double sum = 0.0; for (int i = 0; i < A->rows; i++) { sum += mat_get(A, i, p) * mat_get(A, i, q); } return sum; } // 交换两列 static void swap_cols(Matrix *A, int p, int q) { for (int i = 0; i < A->rows; i++) { double tmp = mat_get(A, i, p); mat_set(A, i, p, mat_get(A, i, q)); mat_set(A, i, q, tmp); } } // 在列p和列q上执行一次Jacobi旋转 static void jacobi_rotate(Matrix *A, Matrix *V, int p, int q) { double app = col_norm_sq(A, p); double aqq = col_norm_sq(A, q); double apq = col_inner_product(A, p, q); if (fabs(apq) < TOLERANCE * sqrt(app * aqq)) { return; } double beta = (aqq - app) / (2.0 * apq); double t; if (beta >= 0) { t = 1.0 / (beta + sqrt(beta * beta + 1.0)); } else { t = -1.0 / (-beta + sqrt(beta * beta + 1.0)); } double c = 1.0 / sqrt(t * t + 1.0); double s = t * c; // 更新A的第p列和第q列 for (int i = 0; i < A->rows; i++) { double aip = mat_get(A, i, p); double aiq = mat_get(A, i, q); mat_set(A, i, p, c * aip - s * aiq); mat_set(A, i, q, s * aip + c * aiq); } // 更新V的第p列和第q列 for (int i = 0; i < V->rows; i++) { double vip = mat_get(V, i, p); double viq = mat_get(V, i, q); mat_set(V, i, p, c * vip - s * viq); mat_set(V, i, q, s * vip + c * viq); } } // 单边Jacobi SVD主函数 int svd_jacobi(const Matrix *A, Matrix *U, double *singular_values, Matrix *V) { int m = A->rows; int n = A->cols; if (n == 0 || m == 0) return -1; // 拷贝A到工作矩阵B Matrix B = mat_create(m, n); memcpy(B.data, A->data, m * n * sizeof(double)); // V初始化为单位阵 *V = mat_create(n, n); for (int i = 0; i < n; i++) { mat_set(V, i, i, 1.0); } int converged = 0; for (int sweep = 0; sweep < MAX_SWEEPS; sweep++) { double max_off = 0.0; for (int p = 0; p < n - 1; p++) { for (int q = p + 1; q < n; q++) { double app = col_norm_sq(&B, p); double aqq = col_norm_sq(&B, q); double apq = col_inner_product(&B, p, q); double scale = sqrt(app * aqq); if (scale == 0.0) continue; double ratio = fabs(apq) / scale; if (ratio > max_off) max_off = ratio; if (ratio > TOLERANCE) { jacobi_rotate(&B, V, p, q); } } } if (max_off <= TOLERANCE) { converged = 1; break; } } // 提取奇异值并构建U矩阵 for (int j = 0; j < n; j++) { singular_values[j] = sqrt(col_norm_sq(&B, j)); } *U = mat_create(m, n); for (int j = 0; j < n; j++) { double norm = singular_values[j]; if (norm > TOLERANCE) { for (int i = 0; i < m; i++) { mat_set(U, i, j, mat_get(&B, i, j) / norm); } } else { // 零奇异值对应的列直接设为基向量 for (int i = 0; i < m; i++) { mat_set(U, i, j, (i == j) ? 1.0 : 0.0); } } } // 按奇异值降序排列 for (int i = 0; i < n; i++) { int max_idx = i; for (int j = i + 1; j < n; j++) { if (singular_values[j] > singular_values[max_idx]) { max_idx = j; } } if (max_idx != i) { double tmp = singular_values[i]; singular_values[i] = singular_values[max_idx]; singular_values[max_idx] = tmp; swap_cols(U, i, max_idx); swap_cols(V, i, max_idx); } } mat_free(&B); return converged ? 0 : 1; // 返回0表示收敛,1表示达到最大sweep数 } // 简单测试函数:用3x2矩阵验证 int main() { Matrix A = mat_create(3, 2); mat_set(&A, 0, 0, 1.0); mat_set(&A, 0, 1, 2.0); mat_set(&A, 1, 0, 3.0); mat_set(&A, 1, 1, 4.0); mat_set(&A, 2, 0, 5.0); mat_set(&A, 2, 1, 6.0); Matrix U, V; double s[2]; int ret = svd_jacobi(&A, &U, s, &V); printf("SVD返回码: %d\n", ret); printf("奇异值: %f, %f\n", s[0], s[1]); printf("U矩阵:\n"); for (int i = 0; i < U.rows; i++) { for (int j = 0; j < U.cols; j++) { printf("%8.5f ", mat_get(&U, i, j)); } printf("\n"); } printf("V矩阵:\n"); for (int i = 0; i < V.rows; i++) { for (int j = 0; j < V.cols; j++) { printf("%8.5f ", mat_get(&V, i, j)); } printf("\n"); } mat_free(&A); mat_free(&U); mat_free(&V); return 0; }

3.2 测试结果与验证方法

用上面这个3×2矩阵跑一遍,输出奇异值应该和Python的np.linalg.svd结果一致:约9.5255和0.5143。验证的方式很简单,把U、Σ、V^T乘回去,看能不能还原成原始A。这是检验SVD实现正确与否最直接的手段。

我在实际项目里还加了一个自动校验函数,乘回去之后逐元素比较,误差超过1e-8就报警。这个动作看起来简单,但能帮我抓出很多排序错误、符号错位的问题。

3.3 如何从命令行或文件读入矩阵

实际使用中,矩阵一般不是写死在代码里的。我习惯让程序从文本文件读矩阵,每行一组数据、空格分隔。读取代码不多,但有几个要点:第一,判断行数和列数时,第一遍扫描是必要的,不然没法预分配内存;第二,要容忍末尾换行符和多余空格;第三,如果数据量很大,建议用fgets按行读取而不是用fscanf,性能差距还是挺明显的。

3.4 如何与C++/Python工程集成

这段代码本身就是纯C风格,放进C++工程完全没问题,只要把文件后缀改成.c然后用extern "C"包裹接口即可。如果想在Python里快速验证算法正确性,可以先用ctypes把这个C函数编成动态库,再和numpy的结果做对比。这个过程能帮你快速定位到底是算法写错了还是集成环节出了问题。

4. 常见问题与排查技巧实录

4.1 不收敛怎么办

这是最让人头皮发麻的问题。症状是sweep跑满了MAX_SWEEPS但max_off还是大于TOLERANCE。排查思路有三步:

第一,先检查输入矩阵是否包含NaN或Inf。这个很基础,但真出现过。一旦矩阵里有NaN,内积计算会一直传播NaN,收敛判断直接失效。

第二,把TOLERANCE放宽一个数量级再试。有些病态矩阵确实很难把列对完全变成正交,1e-10太严格,1e-8就能过。

第三,检查是不是列数远大于行数。如果m < n,这个矩阵是“高瘦”的,SVD结果理论上只有m个非零奇异值,但算法迭代的是n列之间的正交化,会有很多列最终变成零列。这种情况下,我建议交换转置:对A^T做SVD,再对结果做转置映射,性能会好很多。

4.2 结果符号不对、还原矩阵对不上

SVD的U和V列本身可以同时变号,这是数学上允许的自由度。也就是说,U的第k列和V的第k列同时乘以-1,A不会变。所以如果你和numpy的结果对比,发现某些列整体差个负号,不用慌,这是正常的,不代表代码错了。

但如果还原出来的A和原始A对不上,那大概率是排序时没有同步交换U和V的列。我犯过这个错:只对奇异值排序,忘了交换V的列,结果乘积完全错乱。建议把“排序+交换U列+V列”写成一个独立函数,单步调试时只在这个函数里下断点。

4.3 内存越界和悬挂指针

C语言写数值计算,内存问题防不胜防。我的排查经验是:不要用printf到处打log,而是启用gcc的AddressSanitizer编译选项,一行命令就能跑出越界位置:

gcc -g -fsanitize=address -o svd_test svd_test.c

用这个方式跑一遍测试用例,绝大多数越界问题直接报出具体行号。我在写这个SVD时,靠它抓到一个在swap_cols里下标算错的bug,比肉眼找节省了至少半小时。

4.4 性能不够用的优化思路

如果矩阵规模几百阶往上,单边Jacobi会明显变慢。三条优化路径供参考:

第一,开启编译器优化,-O2基本无损,-O3在某些平台上有效。这是零成本的。

第二,利用矩阵的稀疏性。如果矩阵大部分元素是0,可以只存储非零元素,再做Jacobi旋转时跳过不需要更新的行。对图像处理这类场景提升显著。

第三,换用分块策略。把大矩阵分成多个小矩阵,先对子块做SVD,再合并结果。这个思路也叫分块Jacobi,实现复杂度高一个级别,但对几百阶以上的稠密矩阵效果立竿见影。

4.5 零奇异值导致除零

在提取U矩阵时,如果奇异值为0,那一列的U是未定义的。我的做法是直接放单位向量。但如果你要计算伪逆,就得注意:零奇异值对应的奇异值倒数不能参与运算,否则产生无穷大。建议把小于最大奇异值乘以1e-12的元素视为0,这是一个非常实用的阈值策略。

4.6 动态内存释放遗漏

这个代码里每一步矩阵创建都要有对应的释放。我在svd_jacobi里创建了B、U、V三块内存,但调用方要记得在测试完手动释放U和V。如果工程里用的矩阵很多,建议统一封装一个矩阵池管理接口,或者直接改成传入预分配的缓冲区,减少malloc/free的频次。

5. 实测数据与方法对比心得

拿一个我自己常用的50×30随机矩阵做测试,最大sweep数设为15,收敛阈值1e-10,实测3次sweep就收敛了,奇异值结果和numpy.linalg.svd对比,最大相对误差在1e-12量级。换成100×80矩阵,同样参数下5次sweep收敛,耗时在PC上是毫秒级。这说明对中小规模矩阵,单边Jacobi的实际收敛速度比理论上看起来快得多,因为很多列对早早就满足了正交条件,后面的sweep基本在空转。

但如果拿一个条件数特别大的矩阵(比如希尔伯特矩阵),收敛就明显变慢。我试过10阶希尔伯特矩阵,TOLERANCE设1e-10时跑了满30次sweep还没达到阈值,但奇异值结果依然和numpy吻合到1e-8级别,只是代价是多跑了不少迭代。这说明经验阈值在遇到极端情况时需要动态调整,不应该“一组参数打天下”。

6. 一个更稳的做法:先用实对称矩阵热身

如果你写完之后总觉得心里没底,我有个偏方:先用对称正定矩阵做测试。因为对称正定矩阵的SVD等于其特征分解,而特征分解可以用Jacobi特征值算法验证。我写SVD之前先在同样的代码框架上写了一个对称矩阵Jacobi特征值分解,验证特征值结果和numpy一致后,才把它改成单边Jacobi SVD。这样做的好处是,一旦出错,排错范围缩小了一半——至少先把旋转模块的基本逻辑验证对了。

7. 写在最后的经验之谈

个人观点:C语言写SVD,最大的价值不在于“写出来”,而在于被迫把每一个数值计算的细节都弄清楚。Python里一个函数解决的问题,在C里拆成二十个步骤,每一步都是一次对数学定义和浮点运算的检验。我强烈建议你自己动手敲一遍,而不是直接复制粘贴上面这段代码。敲的过程中你会发现,算法和数据结构之间的耦合关系、数值误差的放大路径、收敛条件对整个流程的影响,这些光看别人代码很难感受到。

另外一个小建议:代码里用到的所有阈值、最大迭代次数这类魔术数字,最好统一用宏定义或常量放在文件顶部,后续调参只需要改一处。这个习惯在算法原型阶段可能感觉多余,但一旦矩阵规模从几十阶涨到几千阶,你一定会回来感谢自己的这个决定。

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

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

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

立即咨询