OI中的线性代数
向量和矩阵
Part 1:向量 (Vector) —— 是“坐标”,也是“状态”
想象一下你在玩一个2D游戏,你的角色在地图上。
- “你在哪?” → 你需要一个坐标来表示,比如
(10, 5)
。 - “你要往哪走一步?” → 你需要一个方向和距离,比如“向右3步,向上4步”。
这两种信息,我们都可以用一个东西来表示,它就是向量。
在编程里,向量本质上就是一个数组。
vector_pos = [10, 5]
可以表示你当前的位置。vector_move = [3, 4]
可以表示你下一步的移动。
小结一下,对我们Oier来说,向量就是:
- 一个点/坐标 (Position): 描述一个东西在空间里的位置。可以是二维
[x, y]
、三维[x, y, z]
,甚至更高维(比如一个英雄有多个属性:[血量, 攻击, 防御, 敏捷]
,这也是一个四维向量!)。 - 一个位移/状态变化 (Displacement / State Change): 描述一个“操作”,比如“x增加3,y增加4”。
所以,别把向量想得太复杂,它就是一排有序的数字,用来打包表示一个位置或者一个状态。
Part 2:矩阵 (Matrix) —— 超级的“变换器”
如果说向量是一个“状态”,那矩阵就是一个“状态转移的规则”。它是一个“变换器”或者叫“函数”。你给它一个旧的状态(一个向量),它能给你算出新的状态(另一个向量)。
在编程里,矩阵就是一个二维数组。
比如我们有一个2x2的矩阵:
M = [[a, b],
[c, d]]
这玩意儿有什么用?它最核心的作用就是“变换”向量。
想象一个机器,你把一个向量 v_old = [x, y]
从左边扔进去,这个机器(矩阵M)对它进行一通操作,然后从右边吐出一个新的向量 v_new = [x', y']
。
这个过程,就叫矩阵乘以向量。
举几个直观的例子:
- 旋转变换器: 有一个特定的矩阵,你把角色的坐标向量
[x, y]
扔进去,出来的就是旋转了90度之后的新坐标。 - 缩放变换器: 另一个特定的矩阵,可以把所有坐标都放大2倍。
- 镜像变换器: 还有一个矩阵,可以把角色关于y轴做个对称。
小结一下,矩阵是什么:
- 一个二维的数字表格。
- 它最牛的地方在于,它不只是一个数据表格,它是一个操作,一个变换器。
- 它的工作方式是:矩阵 * 向量 = 新向量。
Part 3:矩阵乘法 —— “变换”的叠加
好了,最关键的地方来了。
如果一个矩阵 M1
代表“旋转90度”,另一个矩阵 M2
代表“放大2倍”。
我现在想对一个角色先旋转90度,再放大2倍,该怎么做?
-
笨办法: 先用
M1
乘以角色的向量v
,得到一个中间向量v_temp
。再用M2
乘以v_temp
得到最终结果v_final
。
v_final = M2 * (M1 * v)
-
聪明办法: 我能不能造一个“超级变换器”
M_super
,它本身就等于“先旋转90度再放大2倍”这个复合操作?
可以!这个超级变换器就是 M1
和 M2
的乘积:
M_super = M2 * M1
(注意顺序!先做的变换在右边)
这就是矩阵乘法的精髓:它是变换的叠加/复合。两个矩阵相乘,会得到一个新的矩阵,这个新矩阵代表了前两个矩阵依次施加的变换效果的总和。
Part 4:为什么OI需要它?—— 矩阵快速幂
现在进入正题,这东西在OI里到底有什么用?
想象一个递推问题,比如最经典的斐波那契数列:
f(n) = f(n-1) + f(n-2)
我们每次的状态是什么?是 [f(n), f(n-1)]
这一个向量。
我们想从 [f(n-1), f(n-2)]
这个旧状态,得到 [f(n), f(n-1)]
这个新状态。
能不能找到一个“变换器”(矩阵),让这件事发生?
[新状态] = 矩阵 * [旧状态]
也就是:
[ f(n) ] = [ ? ? ] * [ f(n-1) ]
[f(n-1)] [ ? ? ] [ f(n-2) ]
根据递推公式 f(n) = 1*f(n-1) + 1*f(n-2)
和 f(n-1) = 1*f(n-1) + 0*f(n-2)
,我们很容易就能构造出这个神奇的矩阵:
[ f(n) ] = [ 1 1 ] * [ f(n-1) ]
[f(n-1)] [ 1 0 ] [ f(n-2) ]
我们把这个 [[1, 1], [1, 0]]
矩阵叫做 T
(Transition Matrix, 转移矩阵)。
你看,每一次递推,就等于乘以一次矩阵 T
。
那么,要求 f(n)
,就相当于从初始状态 [f(1), f(0)] = [1, 0]
开始,连续乘以 n-1
次这个矩阵 T
。
[f(n), f(n-1)] = T * T * ... * T (n-1次) * [f(1), f(0)]
也就是:
[f(n), f(n-1)] = T^(n-1) * [f(1), f(0)]
现在问题来了:如果 n
非常大,比如 10^18
,你循环 n-1
次肯定超时。
但是,你对快速幂一定很熟吧?求 a^n
可以在 O(log n)
时间内搞定。
矩阵的乘法也满足结合律 (A*B)*C = A*(B*C)
,所以矩阵也可以用快速幂!
我们可以用 O(k^3 * log n)
的时间计算出 T^(n-1)
(其中k是矩阵的边长,这里是2)。然后再用这个最终的“超级无敌变换矩阵”乘以初始状态向量,一步到位,直接算出 f(n)
!
总结一下给Oier的最终版:
- 向量:一个一维数组,用来表示一个状态(比如
[f(n), f(n-1)]
)。 - 矩阵:一个二维数组,用来表示一次状态转移(从
n-1
到n
的变换规则)。 - 矩阵乘法:将多次连续的状态转移合并成一次等效的转移。
- 矩阵快速幂:当你要执行海量(比如
10^18
次)相同的状态转移时,用快速幂算法把这些转移合并成最终的“终极转移”,然后一次性作用在初始状态上,实现O(log n)
级别的加速。
所有可以用矩阵快速幂优化的DP或递推问题,本质都是在做这件事。希望这个解释对你有帮助!去刷题试试吧,你会发现新世界的!
矩阵的初等变换
好的,各位Oier同学,我们继续用大白话聊聊矩阵。
上次我们把矩阵看作一个“变换器”,它通过乘法来改变一个状态(向量)。今天我们来聊点别的,不把它当“变换器”,而是把它当成一个“谜题板”。这个谜题板上写满了各种方程,我们的任务是解开它。
Part 1: 矩阵的核心谜题 —— 解方程组
大家在初中就学过解二元一次方程组,比如:
2x + 3y = 8
x - y = 1
我们用加减消元法或者代入消元法就能解出 x 和 y。
如果方程有10个,未知数也有10个呢?手算就太痛苦了。在计算机里,我们用矩阵来表示和解决这个问题。
上面的方程组可以写成矩阵的形式: A * x = b
- A (系数矩阵): 就是所有未知数前面的系数组成的“表格”。
A = [[2, 3], [1,-1]]
- x (未知数向量): 就是我们要解的未知数。
x = [[x], [y]]
- b (常数向量): 就是等号右边的结果。
b = [[8], [1]]
所以,解线性方程组,在计算机眼中,就是已知矩阵 A 和向量 b,求向量 x 的过程。
怎么求呢?这就需要我们对这个“谜题板”(矩阵A)进行一些操作,让它变得简单,直到答案一目了然。这些操作,就是初等行变换。
Part 2: 解谜的三大神器 —— 初等行变换
想象一下,你在解方程组时会做什么?无非三件事:
- 把两个方程交换一下位置。 (这显然不影响最终答案)
- 给某个方程的两边同时乘以一个非零的数。 (比如
x - y = 1
变成2x - 2y = 2
) - 把一个方程的k倍加到另一个方程上。 (这是加减消元法的精髓)
这三个操作,对应到矩阵里,就是大名鼎鼎的初等行变换 (Elementary Row Operations),它们是我们解谜的瑞士军刀,只有这三招,但足够解万题。
- 交换两行 (Swap):
swap(row_i, row_j)
- 给某一行乘以一个非零常数 (Scale):
multiply(row_i, k)
- 把第 j 行的 k 倍加到第 i 行上 (Add):
add(row_i, row_j, k)
核心思想: 对矩阵A进行这些操作,只要我们同时对向量b也进行同样的操作,方程组的解是不会变的!
Part 3: 算法登场 —— 高斯消元法 (Gaussian Elimination)
高斯消元法,就是一套标准化的、利用上述三大神器来解方程组的算法流程。它的目标非常明确:把系数矩阵 A 变成一个 “上三角矩阵”(或者叫“阶梯形矩阵”)。
啥是上三角矩阵?就是长这样的,左下角全都是0:
[[a, b, c],
[0, d, e],
[0, 0, f]]
为什么变成这样就好解了?你看最后一行,它对应的方程是 f * z = ...
,z
一下就解出来了。然后把 z
的值代入倒数第二行的方程 d * y + e * z = ...
,又能解出 y
。最后把 y
和 z
都代入第一行,解出 x
。这个过程叫回代 (Back Substitution)。
高斯消元的算法步骤 (就像一份菜谱):
为了方便,我们通常把矩阵 A 和向量 b 拼在一起,形成一个增广矩阵 [A | b]
,然后对整个增广矩阵进行操作。
目标: 把 A
的部分变成上三角。
流程: (假设我们有一个 n x n 的矩阵)
- 枚举主元列: 从第
c = 0
列开始,到第n-1
列。 - 找到当前列的“主元”(Pivot): 在当前第
c
列,从第r
行(r
初始为0)往下,找到一个绝对值最大的元素,把它所在的那一行和第r
行交换。- 为啥要找最大的? 为了减少计算中的浮点数误差,让结果更精确。这个技巧叫“列主元高斯消去法”。
- 如果这一列从第
r
行往下全都是0,说明这一列搞不定了,直接跳到下一列(c++
),行r
不变。
- 归一化 (可选,但推荐): 将新的第
r
行整体除以A[r][c]
的值。这样主元就变成了1,方便后续计算。 - 消元: 对于第
r
行以外的所有其他行i
(从0到n-1,i != r
),我们要把它们第c
列的元素变成0。怎么做?很简单:add(row_i, row_r, -A[i][c])
- 也就是,把第
r
行(主元行)乘以-A[i][c]
,然后加到第i
行上。这样第i
行的第c
列就变成A[i][c] - A[i][c] = 0
了。
- 前进: 当前行和当前列都处理完了,让
r++
,然后回到步骤1处理下一列。
等所有列都处理完,你的增广矩阵 [A | b]
就变成了 [上三角 | 新的b]
。然后从最后一行开始回代,就能求出所有未知数。
高斯-若尔当消元法 (Gauss-Jordan Elimination):
这是高斯消元的“豪华版”。它在第4步消元时,不仅仅是把主元下面的元素变成0,而是把主元所在列的所有其他元素(包括上面的)都变成0。这样搞完之后,矩阵A的部分会直接变成一个单位矩阵(对角线全是1,其他全是0)。
[[1, 0, 0],
[0, 1, 0],
[0, 0, 1]]
这样做的好处是,你连回代都省了!最后增广矩阵右边的b向量,直接就是你的答案x向量!代码写起来更无脑。
Part 4: 在OI中的应用
高斯消元法这套东西,在OI里非常有用:
- 解线性方程组: 这是它的本职工作。有些看起来是图论、DP或者网络流的问题,最后可以抽象成一个n元一次方程组,然后用高斯消元一波带走。
- 求逆矩阵: 如果你想求矩阵 A 的逆矩阵
A⁻¹
。你可以构造一个增广矩阵[A | I]
,其中 I 是单位矩阵。然后对这个大家伙进行高斯-若尔当消元,目标是把左边的 A 变成 I。当左边成功变成 I 时,右边剩下的部分,就神奇地变成了A⁻¹
!即[I | A⁻¹]
。 - 求行列式: 初等行变换对行列式的值有明确的影响:
- 交换两行,行列式值取反。
- 某一行乘以k,行列式值也乘以k。
- 把一行加到另一行上,行列式值不变。
利用这个性质,我们可以用高斯消元把矩阵变成上三角矩阵。上三角矩阵的行列式就是对角线上所有元素的乘积。所以,在消元过程中记录下交换次数和乘法因子,就能轻松求出原矩阵的行列式。
- 线性基 (Advanced): 在异或空间中,求一组数的线性基的过程,本质上就是在一个只有0和1的矩阵上做高斯消元。每一位可以看作一个维度,每个数可以看作一个向量,目标也是把矩阵变成“阶梯型”。
总结
- 初等行变换是解方程组时那三种最基本、最合法的操作。
- 高斯消元是把这些操作流程化、算法化,专门用来把矩阵变成简单的上三角/阶梯型,从而方便求解。
- 代码实现时,通常用一个二维数组模拟增广矩阵,然后按照高斯(-若尔当)消元的“菜谱”一步步循环操作即可。
希望这个解释能帮你把抽象的数学概念,和你熟悉的算法思维联系起来!
高斯消元法
好的,各位Oier,咱们今天来把“高斯消元法”这个听起来很高端的东西,拆解成一个你闭着眼睛都能写出来的算法。
忘了线性代数,忘了什么向量空间。咱们就把它当成一个解谜游戏。
游戏背景:解方程
你一定解过这种初中级别的方程组:
2x + 3y = 8 (方程①)
x - y = 1 (方程②)
你是怎么解的?通常是“加减消元法”:
- 把方程②乘以2,得到
2x - 2y = 2
(方程③)。 - 用方程①减去方程③,
(2x - 2x) + (3y - (-2y)) = 8 - 2
,得到5y = 6
。 - 瞬间解出
y = 1.2
。 - 再把
y
代回任意一个方程,解出x
。
发现了吗?核心操作就是:通过把一个方程乘以某个数,然后加到另一个方程上,来“干掉”一个未知数。
高斯消元,就是把这个过程标准化、流程化,让计算机也能轻松执行。
游戏目标:把谜题变简单
想象一下,我们有n个方程,n个未知数。在程序里,我们用一个二维数组(矩阵) A
来存所有系数,用一个一维数组 b
来存等号右边的结果。
比如上面的例子,就存成:
A = [[2, 3], [1, -1]]
b = [8, 1]
高斯消元的目标非常纯粹:通过一系列合法的变换,把系数矩阵 A
变成一个 “上三角矩阵”。
啥是上三角矩阵?就是长这样的,主对角线(左上到右下)的左下方全都是0:
[[a, b, c],
[0, d, e],
[0, 0, f]]
为什么要变成这样?因为一旦变成这样,谜题就解开了!
- 最后一行对应的方程是:
0*x + 0*y + f*z = ...
,z
一下就算出来了。 - 知道了
z
,看倒数第二行:0*x + d*y + e*z = ...
,y
也算出来了。 - 一路往上代,所有未知数都迎刃而解。这个过程叫回代。
游戏规则:三大神器(初等行变换)
你在解谜过程中,只有三种合法的操作:
- 交换两行: 相当于交换两个方程的位置,答案不变。
- 给某一行整体乘以一个非零数: 相当于给一个方程两边同乘以一个数,答案不变。
- 把一行的k倍加到另一行上: 这就是我们“消元”的核心武器。
游戏攻略:高斯消元算法“菜谱”
好了,上菜谱!这是一个可以被完美翻译成代码的流程。为了方便,我们把 A
和 b
拼在一起,叫增广矩阵 [A | b]
,所有操作对一整行生效。
目标: 把 [A | b]
的 A
部分变成上三角。
流程 (假设有 n 行 n+1 列的增广矩阵):
第一步:枚举“主心骨”
我们一列一列地处理。从第 c = 0
列开始。我们的目标是,利用第 r
行(r
初始为0),把下面所有行的第 c
列都变成0。
for (int c = 0, r = 0; c < n; ++c) {
// c 是当前处理的列
// r 是当前处理的行 (也叫主元行)
第二步:找到这一列的“老大” (选主元)
在第 c
列,从第 r
行往下,找到绝对值最大的那个元素。比如它在第 max_r
行。然后,交换第 r
行和第 max_r
行。
- 为什么要找最大的? 防止除以一个很小的数,导致精度爆炸。这是OI中写高斯消元必须有的好习惯!
- 如果这一列从第
r
行往下全都是0怎么办? 说明这个未知数无法被确定(或者有无穷解/无解),我们搞不定它,就跳过这一列 (continue
),让c
增加,但r
不变,继续处理下一列。
int max_r = r;
for (int i = r + 1; i < n; ++i) {
if (abs(matrix[i][c]) > abs(matrix[max_r][c])) {
max_r = i;
}
}
swap(matrix[r], matrix[max_r]); // 交换两行
第三步:清理门户 (消元)
现在,第 r
行的第 c
个元素 matrix[r][c]
就是我们的“老大”(主元)了。我们要用它来“干掉”它下面所有的“小弟”——也就是把第 r+1
行到第 n-1
行的第 c
列元素全部变成0。
怎么做?对于 r
下面的每一行 i
:
- 算出比例
factor = matrix[i][c] / matrix[r][c]
。 - 让第
i
行的所有元素,都减去第r
行对应元素乘以这个比例factor
。
matrix[i][j] -= matrix[r][j] * factor
(对j
从c
到n
循环)
这样操作完,matrix[i][c]
就变成了 matrix[i][c] - matrix[r][c] * (matrix[i][c] / matrix[r][c])
,正好等于0!
// 消元
for (int i = r + 1; i < n; ++i) {
double factor = matrix[i][c] / matrix[r][c];
for (int j = c; j <= n; ++j) { // 注意要到最后一列(b)
matrix[i][j] -= matrix[r][j] * factor;
}
}
r++; // 当前行处理完毕,主元行下移
}
第四步:收割胜利果实 (回代)
经过上面的一通操作,矩阵 A
的部分已经变成了漂亮的上三角。现在从最后一行开始,把答案一个个算出来。
- 最后一行
n-1
:A[n-1][n-1] * x[n-1] = b[n-1]
,所以x[n-1] = b[n-1] / A[n-1][n-1]
。 - 倒数第二行
n-2
:A[n-2][n-2] * x[n-2] + A[n-2][n-1] * x[n-1] = b[n-2]
。因为x[n-1]
已经知道了,代入就能求出x[n-2]
。 - ...依此类推,从下往上,直到解出所有未知数。
总结一下给你的代码模板思路:
- 外层循环
c
从0
到n-1
,表示当前要消掉哪一列。r
表示当前用哪一行做主元。 - 找主元:在
c
列的r
行及以下,找到绝对值最大的,换到r
行。 - 判断无解/多解:如果找到的主元是0,说明有问题。
- 消元:用一个内层循环
i
从r+1
到n-1
,遍历r
行下面的所有行。 - 在内层循环里,再用一个最内层循环
j
从c
到n
,更新这一行i
的所有元素。 - 所有循环结束后,矩阵变成上三角。
- 回代:再写一个从
n-1
到0
的循环,依次求解。
高斯消元法就是这么一个朴实无华但极其强大的算法,它把人类的直觉(加减消元)变成了一套计算机可以执行的、固定的机械化流程。搞懂它,你就能解决一大票和解方程相关的OI题了!