求解线性方程组的高斯方法(Gauss method for solving system of linear equations)
给出一个有m个未知数的n个线性代数方程组(SLAE, System of Linear Algebraic Equations)。你需要解决的是:确定它是否无解,是否仅有一个解或有无限多的解。如果它至少有一个解,请找出其中任何一个。
a 11 x 1 + a 12 x 2 + ⋯ + a 1 m x m = b 1 a 21 x 1 + a 22 x 2 + ⋯ + a 2 m x m = b 2 ⋮ a n 1 x 1 + a n 2 x 2 + ⋯ + a n m x m = b n \begin{aligned} a_{11} x_1 + a_{12} x_2 + &\dots + a_{1m} x_m = b_1\\ a_{21} x_1 + a_{22} x_2 + &\dots + a_{2m} x_m = b_2\\ &\vdots \\ a_{n1} x_1 + a_{n2} x_2 + &\dots + a_{nm} x_m = b_n \end{aligned} a11x1+a12x2+a21x1+a22x2+an1x1+an2x2+⋯+a1mxm=b1⋯+a2mxm=b2⋮⋯+anmxm=bn
其中系数 a i j a_{ij} aij ( i i i 从 1 1 1 到 n n n , j j j 从 1 1 1 到 m m m )和 b i b_i bi ( i i i 从 1 1 1 到 n n n )是已知的,变量 x i x_i xi ( i i i 从 1 1 1 到 m m m )是未知的。
这个问题也有一个简单的矩阵表示:
A x = b Ax = b Ax=b
其中 A A A 是系数 a i j a_{ij} aij 的大小为 n × m n×m n×m 的矩阵, b b b 是大小为 n n n 的列向量。
值得注意的是,本文介绍的方法也可以用来解以任意数 p p p 为模的方程,即:
a 11 x 1 + a 12 x 2 + ⋯ + a 1 m x m ≡ b 1 ( m o d p ) a 21 x 1 + a 22 x 2 + ⋯ + a 2 m x m ≡ b 2 ( m o d p ) ⋮ a n 1 x 1 + a n 2 x 2 + ⋯ + a n m x m ≡ b n ( m o d p ) \begin{aligned} a_{11} x_1 + a_{12} x_2 + &\dots + a_{1m} x_m \equiv b_1 \pmod p \\ a_{21} x_1 + a_{22} x_2 + &\dots + a_{2m} x_m \equiv b_2 \pmod p \\ &\vdots \\ a_{n1} x_1 + a_{n2} x_2 + &\dots + a_{nm} x_m \equiv b_n \pmod p \end{aligned} a11x1+a12x2+a21x1+a22x2+an1x1+an2x2+⋯+a1mxm≡b1(modp)⋯+a2mxm≡b2(modp)⋮⋯+anmxm≡bn(modp)
高斯(Gauss)
严格来说,下面描述的方法应该称为“Gauss-Jordan”,或 Gauss-Jordan 消元法,因为它是 Jordan 在 1887 年描述的 Gauss 方法的一种变体。
概述
该算法是对每个方程中的变量进行 “顺序消除”,直到每个方程只剩下一个变量。如果 n = m n=m n=m ,你可以认为它是将矩阵 A A A 转化为单位矩阵,并在这种明显的情况下解决方程,其解是唯一的且等于系数 b i b_i bi 。
高斯消元法基于两个简单的变换:
- 可以交换两个方程
- 任何方程都可以用该行(具有非零系数)和其他一些行(具有任意系数)的线性组合代替
在第一步, Gauss-Jordan 算法将第一行除以 a 11 a_{11} a11 。然后,该算法将第一行与其余各行相加,使第一列中的系数成为全部为零。为此,在第 i i i 行,我们必须把第一行乘以 − a i 1 -a_{i1} −ai1 。注意,这个操作也必须在向量 b b b 上执行。在某种意义上,它的表现就像向量 b b b 是矩阵 A A A 的第 m m m 列一样。
因此,第一步之后,矩阵 A A A 的第一列将由第一行的 1 1 1 和其他行的 0 0 0 组成。
同样地,我们执行算法的第二步,我们考虑第二行的第二列。首先,该行被 a 22 a_{22} a22 除以,然后从其他行中减去,这样所有第二列就变成了 0 0 0 (除了第二行)。
我们对矩阵 A A A 的所有列继续这个过程。如果 n = m n=m n=m ,那么 A A A 将成为单位矩阵。
寻找基准元素(Search for the pivoting element)
所描述的方案遗漏了许多细节。在第
i
i
i 步,如果
a
i
i
a_{ii}
aii 为零,我们不能直接应用描述的方法。相反,我们必须首先 选择一个基准行
:找到矩阵中第
i
i
i 列非零的一行,然后交换这两行。
请注意,这里我们交换行而不是列。这是因为如果您交换列,那么当您找到解决方案时,您必须记住交换回正确的位置。因此,交换行要容易得多。
在许多实现中,当 a i i ≠ 0 a_{ii} \neq 0 aii=0 时,你可以看到仍然用一些启发式行来交换第 i i i 行,使用一些启发式的方法,如选择绝对值最大的 a j i a_{ji} aji 的启发式行。这种启发式方法是用来在后面的步骤中减少矩阵的取值范围。如果没有这个启发式方法,即使是大小为 20 20 20 的矩阵,误差也会过大,并可能导致 C++ 的浮点数据类型溢出。
降阶案例(Degenerate cases)
在 m = n m=n m=n 和系统非降阶的情况下(即它有非零行列式,并有唯一的解决方案),上述算法将把 A A A 转化为单位矩阵。
现在我们考虑 一般情况
,即
n
n
n 和
m
m
m 不一定相等,而且方程组是可能降阶的。在这些情况下,第
i
i
i 步中的基准元素可能找不到。这意味着在第
i
i
i 列,从当前行开始,全部包含零。在这种情况下,要么变量
x
i
x_i
xi 没有可能的值(意味着 SLAE 没有解决方案),要么
x
i
x_i
xi 是一个独立变量,可以取任意值。在实现 Gauss-Jordan 算法时,你应该继续后续变量的工作,只是跳过第
i
i
i 列(这相当于删除矩阵的第
i
i
i 列)。
因此,可以发现过程中的一些变量是独立的。当变量的数量 m m m 大于方程的数量 n n n 时,那么至少可以找到 m − n m-n m−n 个独立变量。
一般来说,如果你发现至少有一个自变量,它可以取任何任意的值,而其他(因)变量则通过它来表达。这意味着,当我们在实数领域工作时,方程组可能有无无穷多的解。但你应该记住,当有独立变量时,SLAE可能根本没有解。当剩下的未处理的方程至少有一个非零常数项时,就会发生这种情况。你可以通过给所有独立变量赋零来检查这一点,计算其他变量,然后插入到原来的SLAE中,检查它们是否满足。
实现
以下是 Gauss-Jordan 的实现。选择基准行是通过启发式完成的:在当前列中选择最大值。
函数 Gauss
的输入是矩阵组
a
a
a ,该矩阵的最后一列是向量
b
b
b 。
该函数返回系统的解的数量 ( 0 , 1 , or ∞ ) (0, 1,\textrm{or } \infty) (0,1,or ∞) 。如果至少有一个解存在,那么它将被返回到向量 A A A 中。
const double EPS = 1e-9;
const int INF = 2; // it doesn't actually have to be infinity or a big number
int gauss (vector < vector<double> > a, vector<double> & ans) {
int n = (int) a.size();
int m = (int) a[0].size() - 1;
vector<int> where (m, -1);
for (int col=0, row=0; col<m && row<n; ++col) {
int sel = row;
for (int i=row; i<n; ++i)
if (abs (a[i][col]) > abs (a[sel][col]))
sel = i;
if (abs (a[sel][col]) < EPS)
continue;
for (int i=col; i<=m; ++i)
swap (a[sel][i], a[row][i]);
where[col] = row;
for (int i=0; i<n; ++i)
if (i != row) {
double c = a[i][col] / a[row][col];
for (int j=col; j<=m; ++j)
a[i][j] -= a[row][j] * c;
}
++row;
}
ans.assign (m, 0);
for (int i=0; i<m; ++i)
if (where[i] != -1)
ans[i] = a[where[i]][m] / a[where[i]][i];
for (int i=0; i<n; ++i) {
double sum = 0;
for (int j=0; j<m; ++j)
sum += ans[j] * a[i][j];
if (abs (sum - a[i][m]) > EPS)
return 0;
}
for (int i=0; i<m; ++i)
if (where[i] == -1)
return INF;
return 1;
}
实现说明:
- 该函数使用两个指针——当前列 c o l col col 和当前行 r o w row row 。
- 对于每个变量 x i x_i xi ,值 w h e r e ( i ) where(i) where(i) 是该列不为零的行。需要这个向量是因为有些变量可以是独立的。
- 在这个实现中,当前的第 i i i 行并没有像上面描述的那样除以 a i i a_{ii} aii ,所以最后的矩阵并不是单位矩阵(尽管显然除掉第 i i i 行可以帮助减少错误)。
- 找到解决方案后,将其重新插入矩阵以检查系统是否至少有一组解。如果测试解决方案成功,则函数返回 1 1 1 或 i n f inf inf ,取决于是否存在至少一个自变量。
时间复杂度
现在我们应该估计一下这个算法的复杂性。该算法由 m m m 个阶段组成,在每个阶段:
- 搜索并重新排列基准行。使用上面提到的启发式方法时需要 O ( n + m ) O(n+m) O(n+m)
- 如果找到当前列中的基准元素,那么我们必须将此等式添加到所有其他等式中,这需要 O ( n m ) O(nm) O(nm) 的时间
因此,算法的最终复杂度为 O ( m i n ( n , m ) ⋅ n m ) O(min(n, m)\cdot nm) O(min(n,m)⋅nm) 。如果 n = m n=m n=m , 复杂度即为 O ( n 3 ) O(n^3) O(n3) 。
请注意,当SLAE不在实数上,而是在模二中时,那么系统的求解速度就会快得多,这将在下面说明。
算法加速
通过将算法分为两个阶段:正向和反向,之前的实现可以加快2倍。
- 前进阶段。与之前的实现类似,但当前行只加到它之后的行上。因此,我们得到一个三角矩阵,而不是对角矩阵。
- 反向阶段。当矩阵为三角矩阵时,我们首先计算出最后一个变量的值。然后将此值插入,找到下一个变量的值。然后插入这两个值以查找下一个变量…
反相只需要 O ( n m ) O(nm) O(nm) ,这比正向阶段快得多。在前向阶段,我们将操作数量减少了一半,从而减少了实现的运行时间。
求解模块化 SLAE
为了解决某些模块中的 SLAE,我们仍然可以使用所描述的算法。但是,如果模块等于 2 2 2 ,我们可以使用按位运算和 C++ 位集数据类型更有效地执行 Gauss-Jordan 消除:
int gauss (vector < bitset<N> > a, int n, int m, bitset<N> & ans) {
vector<int> where (m, -1);
for (int col=0, row=0; col<m && row<n; ++col) {
for (int i=row; i<n; ++i)
if (a[i][col]) {
swap (a[i], a[row]);
break;
}
if (! a[row][col])
continue;
where[col] = row;
for (int i=0; i<n; ++i)
if (i != row && a[i][col])
a[i] ^= a[row];
++row;
}
// The rest of implementation is the same as above
}
由于我们使用位压缩,因此实现不仅更短,而且速度提高了 32 32 32 倍。
关于选择基准行的不同启发式方法的一点说明
对于使用什么启发式方法没有一般规则。
先前实现中使用的启发式方法在实践中效果很好。事实证明,它给出的答案几乎与“完全旋转”相同 - 其中旋转行是在其子矩阵的所有元素中搜索(来自当前行和当前列)。
不过,您应该注意,这两种启发式方法都取决于原始方程的缩放程度。例如,如果等式之一乘以
1
0
6
10^6
106 ,那么这个方程几乎肯定会在第一步中被选为基准。这似乎很奇怪,因此更改为更复杂的启发式算法似乎是合乎逻辑的,称为implicit pivoting
。
隐式旋转比较元素,就好像两条线都被规范化了,所以最大元素是统一的。为了实现这一技术,需要在每一行中保持最大值(或保持每一行以使最大值为一,但这会导致累积误差的增加)。
改进方案
尽管有各种启发式方法, Gauss-Jordan 算法在特殊矩阵中仍会导致大的误差,即使是在大小为 50 − 100 50-100 50−100 的矩阵。
因此,有时必须通过应用简单的数值方法来改进得到的 Gauss-Jordan 解——例如,简单迭代的方法。
这样,求解就变成了两步:首先应用 Gauss-Jordan 算法,然后在第一步中采用以初始解为解的数值方法。