CSP-S 提高级课程中心 / 数学进阶 / 第52讲
CSP-S 提高级 · 数学进阶 · s6-11

高斯消元法

把"解方程组"这件事,从人的代数直觉变成机器的机械流程 —— 消元成阶梯,回代得答案,选主元保精度。

3 种初等行变换 2 个阶段 3 类解的判定 O(n³) 复杂度 12 道洛谷原题
本讲 · 第 52/52 讲高斯消元法
模块 01

情景导航

从"三种饲料凑一个营养配方"讲起:人靠试数,机器靠消元。高斯消元把"解方程组"变成一套谁都能照着执行的机械流程。

故事配一份"完美饲料"

牧场主给了你三种饲料 A、B、C,每种饲料每千克的营养含量如下:

· 饲料 A:蛋白质 2 g、脂肪 1 g、钙 3 g

· 饲料 B:蛋白质 1 g、脂肪 2 g、钙 1 g

· 饲料 C:蛋白质 3 g、脂肪 1 g、钙 1 g

现在要配一份饲料,使它恰好含蛋白质 20 g、脂肪 12 g、钙 18 g。问 A、B、C 各取多少千克?

设三种饲料分别取 x₁、x₂、x₃ 千克,问题立刻写成三个方程:

2x₁ + 1x₂ + 3x₃ = 20
1x₁ + 2x₂ + 1x₃ = 12
3x₁ + 1x₂ + 1x₃ = 18

手算的办法叫加减消元:用第二个方程乘 −2 加到第一个上,就能把 x₁ 消掉;再把第一、第三个方程配对,又能消掉一个未知数……一步一步做下去,最后只剩一个未知数,直接解出来。

高斯消元的洞察是:整个过程中你根本不需要看 x₁、x₂ 这些字母,只需要看它们的系数。把系数连同等号右边的常数抄成一张表 —— 增广矩阵,然后对这张表做三种固定操作(换行、倍乘、倍加),解就机械地出来了。

这就是计算机能"解方程"的原理:把人的代数直觉,翻译成对一张表格的规则化操作。而且这套操作是 O(n³) 的 —— n 个未知数时,全世界最快的算法也只能做到这个量级。

类比四个生活场景
⚖️

天平两边同加同减

方程两边同时加上或减去同一个量,等式仍然成立 —— 这正是"倍加"变换的直觉来源。

🧩

初中解二元一次方程组

加减消元法:把 x 的系数配成相反数,两式一加就消掉了。2×2 的高斯消元,其实就是它。

🧱

俄罗斯方块消行

目标是消成"阶梯型":每一列只留一个主元,它下面全是 0,像一级一级往下走的台阶。

💡

开关灯

每按一个开关会改变若干盏灯的亮灭,问怎么按才能全亮。这里只有"亮/灭"两种状态,"加法"变成了异或。

定义核心概念速览

📘 线性方程组与增广矩阵

形如 ai1x₁ + ai2x₂ + … + ainxn = bi(i = 1..m)的方程组叫线性方程组。把全部系数 aij 与常数 bi 抄在一起得到的 m×(n+1) 矩阵,叫增广矩阵 [A | b]。

🔧 三种初等行变换

① 交换两行;② 把某一行乘以一个非零数;③ 把某一行的 k 倍加到另一行上。三种操作都不改变方程组的解集,这就是消元合法的根本依据。

🪜 行阶梯型与主元

消元的目标形如:每一行第一个非零元素(主元)所在的列,都比上一行的主元列更靠右,且主元下方全为 0。此时直接回代就能求出解。

📉 秩与解的个数

系数矩阵的秩 r 决定一切:r = n(未知数个数)→ 唯一解;r < n → 无穷多解(有 n−r 个自由变量);若出现形如「0 = d」且 d ≠ 0 的行 → 无解。

概念出现在哪一步作用
增广矩阵 [A|b]消元的操作对象把字母运算变成纯数字运算
初等行变换消元阶段保持解集不变,把主元下方消成 0
主元 pivot当前被选中的那一行用它的倍数去消掉下方各行
选主元每列开始消元前挑绝对值最大的换到当前行,抗精度误差
回代消元结束之后从最后一行往上逐一代入求值
秩 rank消元结束后统计判定唯一解 / 无穷多解 / 无解
自由变量r < n 时有 n−r 个,任意取值都对应一组解
异或方程组所有运算模 2开关灯、Xor 路径;用 bitset 加速
④核心口诀
🔑 消元

选主元,挑最大 —— 抗浮点误差;
消下方,留台阶 —— 每列只留一个主元,下方全清零。

🔑 回代

倒着代,逐个解 —— 从最后一行往上,代一个少一个;
代码里就是一行 for,O(n²)。

🔑 判定

全零行,看右边 —— 系数全 0 的行,右边常数 d ≠ 0 就无解;
d = 0 是多余方程;数一数主元个数 = 秩 r,r < n 就是无穷多解。

模块 02

知识探究

六个知识点串成一条链:抄成矩阵 → 三种行变换 → 消元 + 回代 → 选主元 → 判定解的个数 → 异或方程组。最后三个案例动手实现。

1线性方程组与增广矩阵 基础

📐 从字母到数字

一个含 n 个未知数、m 个方程的线性方程组,完整信息只有两块:系数 aij 和 常数 bi。未知数 x₁..xn 的"名字"和排列方式是固定套路,根本不需要参与运算。

于是我们把系数按行排成一个 m×n 的矩阵 A,把常数排成一列 b,写在一起得到 m×(n+1) 的增广矩阵:

📊 三变量方程组 → 增广矩阵
方程 12 1 3
|
常数20
方程 21 2 1
|
常数12
方程 33 1 1
|
常数18
左边三列 = 系数矩阵 A(x₁ x₂ x₃ 的系数),右边一列 = 常数向量 b,中间竖线只是分隔符
✍️ 手写增广矩阵

把每个方程"抄数"一行:系数顺序必须和未知数顺序严格对应。缺项要补 0。

例如方程 x₁ + 3x₃ = 5 写成 1 0 3 | 5,中间的 0 不能漏。

💻 代码里的存储

直接用二维数组 a[m][n+1] 存整个增广矩阵,最后一列就是常数项。

这样"对一个方程做变换"就变成"对一行做变换",边界处理干干净净。

💡 增广矩阵的列数比"未知数个数"多 1,这个小细节在判秩、判无解时都要用到,写代码时最好用变量 m、n 明确区分。
2初等行变换与"同解性" 易错

🔧 只有三种合法操作

对增广矩阵可以做的操作只有下面三种,称为初等行变换:

名称操作对应的方程变形为什么同解
交换变换交换第 i、j 两行把两个方程换个顺序写方程组的解与"写的顺序"无关
倍法变换第 i 行所有元素 ×k(k ≠ 0)方程两边同乘一个非零数等式两边同乘非零数,解不变
消法变换第 j 行 += 第 i 行 ×k两方程相加(一方已乘过系数)两个等式相加仍是等式,且可逆回推

⚠️ 三个"不允许"

· 不能对列做变换(会打乱未知数的对应关系,变成另一个方程组)。

· 倍法变换的乘数不能为 0(会把整行抹掉,丢掉信息)。

· 消法变换不要写成"第 j 行 = 第 i 行 ×k"(这是赋值,不是相加,会覆盖掉原方程的信息)。

🎯 变换的目标:行阶梯型

反复使用三种变换,把增广矩阵化成"阶梯"形状:

🪜 化成行阶梯型的思路
1从第 1 列开始,找一个「非零元」当主元,把它所在的行换到第 1 行
2用第 1 行去消第 2、3…m 行的第 1 列,让它们这一列都变成 0
3把第 1 行「冻结」,从第 2 行起处理第 2 列:找主元、换行、往下消
4依此类推,每处理完一列就往下挪一行,主元的列号严格递增
5当所有行都处理完(或剩余行的系数全为 0)时停止,得到阶梯型
6此时从最后一行往上回代,即可依次求出 xn, xn−1, …, x₁
观察每一步的要点:「换行 → 往下消 → 下移一行」是一个固定循环,重复 n 次就完成消元。
✅ 正确的消法变换

把第 2 行的第 1 列消成 0:行2 = 行2 − (行2[1]/行1[1]) × 行1

这样行 2 的第 1 列变成 a21 − (a21/a11)·a11 = 0,其它列同步更新。

❌ 常见错误

只把 a[2][1] 手动置 0,而忘了更新 a[2][2..n+1]。

这等于凭空改了一个方程,后面的解必然错。

💡 记法:消法变换的作用是"用一行去清掉另一行的某个数",本质是行向量的线性组合,这一点和上一讲的矩阵乘法完全一致 —— 左乘一个初等矩阵,就等价于做一次行变换。
3高斯消元两阶段:消元 + 回代 核心

⚙️ 一句话概括

消元阶段把增广矩阵化成上三角(阶梯型);回代阶段从最后一行往上逐个解出未知数。整个算法就是这两件事。

阶段一 · 消元(O(n³)):枚举每一列 col,在第 col 行往下找该列的主元并换到第 col 行,然后用第 col 行去把下方各行的第 col 列消成 0。做完后主元下方的三角区域全是 0。

阶段二 · 回代(O(n²)):此时第 n 个方程只含 xn,直接解出;第 n−1 个方程只含 xn−1、xn,代入 xn 即得 xn−1;一路往上推。

📊 消元阶段:增广矩阵逐列变成阶梯型
初始[■ □ □ | □]
[□ □ □ | □]
[□ □ □ | □]
col=1 后[■ □ □ | □]
[0 □ □ | □]
[0 □ □ | □]
col=2 后[■ □ □ | □]
[0 ■ □ | □]
[0 0 □ | □]
col=3 后[■ □ □ | □]
[0 ■ □ | □]
[0 0 ■ | □]
■ = 主元(非零);每处理完一列,「活动范围」整体下移一行、右移一列
col = 1:在 1~n 行中选主元,换到第 1 行
用第 1 行把第 2~n 行的第 1 列全部消为 0
col = 2:在 2~n 行中选主元,换到第 2 行,往下消
col = 3:在 3~n 行中选主元,换到第 3 行,往下消
消元完成:主元下方全为 0(阶梯型 / 上三角)
回代:x₃ → x₂ → x₁,逐层往上解出
若某一列找不到非零主元 → 该列无主元 → 无唯一解
当前列正在处理已消元区域
💻标准模板(浮点版 + 选主元 + 回代)
C++gauss.cpp · 解 n 元线性方程组
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const double EPS = 1e-8;   // 判定「近似为 0」的阈值
int n;
double a[105][106];        // 增广矩阵:前 n 列系数,第 n+1 列常数
double x[105];             // 解

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n + 1; j++) scanf("%lf", &a[i][j]);

    /* ---------- 阶段一:消元,化成上三角 ---------- */
    for (int col = 1; col <= n; col++) {
        // 1) 选主元:在 col..n 行里挑 |a[i][col]| 最大的那一行
        int piv = col;
        for (int i = col; i <= n; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        if (fabs(a[piv][col]) < EPS) {     // 整列都是 0 → 没有主元
            printf("No Solution\n");
            return 0;
        }
        // 2) 换行:把主元所在行换到第 col 行
        for (int j = col; j <= n + 1; j++) swap(a[col][j], a[piv][j]);
        // 3) 往下消:把 col+1..n 行的第 col 列清成 0
        for (int i = col + 1; i <= n; i++) {
            double f = a[i][col] / a[col][col];
            if (fabs(f) < EPS) continue;
            for (int j = col; j <= n + 1; j++)
                a[i][j] -= f * a[col][j];
        }
    }

    /* ---------- 阶段二:回代,从下往上求 ---------- */
    for (int i = n; i >= 1; i--) {
        double s = a[i][n + 1];
        for (int j = i + 1; j <= n; j++) s -= a[i][j] * x[j];
        x[i] = s / a[i][i];
    }
    for (int i = 1; i <= n; i++) printf("%.2f\n", x[i]);
    return 0;
}
运行结果(洛谷 P3389 样例)
输入:
3
1 3 4 5
1 4 7 3
9 3 2 2

输出:
-0.97
5.18
-2.39

手算对照:Eq2−Eq1 得 x2 + 3x3 = −2;Eq3−9·Eq1 得 −24x2 − 34x3 = −43
代入 x2 = −2 − 3x3:48 + 38x3 = −43 → x3 = −91/38 ≈ −2.3947
x2 = −2 − 3×(−2.3947) ≈ 5.1842;x1 = 5 − 3x2 − 4x3 ≈ −0.9737
与程序输出 -2.39 / 5.18 / -0.97 完全吻合 ✔
🔼 回代阶段逐步演算(n = 3)
1消元结束,矩阵已是上三角:第 3 行只剩 x₃ 的系数与常数
2x₃ = a[3][4] / a[3][3] —— 直接除出来
3第 2 行:a[2][2]·x₂ + a[2][3]·x₃ = a[2][4],把已知的 x₃ 移项
4x₂ = (a[2][4] − a[2][3]·x₃) / a[2][2]
5第 1 行:x₁ = (a[1][4] − a[1][2]·x₂ − a[1][3]·x₃) / a[1][1]
6代码里用一个内层 for 累减 Σa[i][j]·x[j](j > i),再除以主元 a[i][i]
注意方向:回代必须从最后一行往上(i 从 n 递减)。写反了会用到还没求出的未知数。
🔁 变体:Gauss–Jordan 消元

把"只消下方"改成"上下都消",消元结束后矩阵直接变成单位阵形态,常数列就是答案,无需回代。

代价是常数更大(约 2 倍),但代码更短,且天然适合"求矩阵的逆"。

⏱️ 复杂度

三重循环:外层 n 列 × 内层 n 行 × 更新 n+1 个元素 = O(n³)。

n = 100 约 10⁶ 次运算;n = 500 约 1.25×10⁸,接近 1 秒上限;再大就要考虑稀疏或 bitset 优化。

💡 模板里 for (int j = col; ...) 而不是 j = 1,是因为第 1..col−1 列已经是 0(或已定型),从 col 开始更新能省掉约一半的工作量。
4选主元与浮点精度 易被忽略

🎯 为什么必须"挑绝对值最大的"

消元时要算 f = a[i][col] / a[col][col]。如果分母 a[col][col] 非常接近 0,f 就会非常大,用它去乘一整行再加到别的行上,会把原本很小的舍入误差放大成巨大的错误 —— 这就是数值不稳定。

解决办法很简单:在开始处理第 col 列时,先扫一遍第 col..n 行,把这一列绝对值最大的那一行换到第 col 行当主元。这样分母一定是这一列里最大的,除法误差最小。这个技巧叫列主元消去法(partial pivoting)。

❌ 不选主元

主元 = 0.0001,用它做除数,除以它得到的倍数高达 10⁴ 量级。误差被放大 4 个数量级,最终解可能连小数点前一位都是错的。

极端情况:主元恰好是 0(甚至因浮点误差变成 1e−17),直接除出 inf,程序崩溃或输出 nan。

✅ 选主元

先把该列绝对值最大的行换上来,主元至少有"这一列最大"的保证,倍数的绝对值 ≤ 1,误差不会被放大。

代价只是每列多扫一遍 O(n),相对 O(n³) 微不足道,却能显著提升数值稳定性。

细节推荐做法说明
EPS 取值1e-8 ~ 1e-10题目数据 10⁶ 量级以内,1e−8 足够;数据很大时可放宽到 1e−6
判 0fabs(v) < EPS绝不能用 v == 0 判浮点数
选主元方式列主元(换行)只换行不换列,保持未知数顺序
全主元行、列都换更稳定但要记录列的置换,回头要换回未知数顺序,竞赛中很少用
整数 / 模意义用逆元代替除法模素数时先求 a[col][col] 的逆元,再乘;无精度问题,见拓展案例
💡 有个坑:fabs(a[piv][col]) < EPS 要在换行之前判断。如果换完行再判,主元可能已经不在 a[col][col] 的位置了。
5唯一解 / 无穷多解 / 无解 高频考点

⚖️ 用"主元个数"(秩 r)分类讨论

消元结束后,数一数有多少个"有效主元",记为秩 r。它和未知数个数 n 的关系决定了一切:

消元后的形态说明结论
每列都有主元,r = n阶梯型主元恰好 n 个,无全零行唯一解,回代即得
r < n,且无"0 = d≠0"行某列没有主元 → 该未知数是自由变量,可取任意值无穷多解,自由变量有 n−r 个
存在形如 0 = d(d ≠ 0)的行系数全为 0 但常数非 0,这是矛盾方程无解

🔍 怎么在代码里"看出"这三种情况

一个稳妥的写法是:消元后用变量 r 记录主元行数(初始 0),逐行扫描,若该行存在某个 j ≤ n 使 fabs(a[i][j]) > EPS,说明这行是一个主元行,r++;否则这一行系数全为 0,再看常数 a[i][n+1]:

· 若 fabs(a[i][n+1]) > EPS → 出现 "0 = d≠0" → 无解,直接输出提示并结束。

· 若常数也约等于 0 → 这是"0 = 0"的多余方程,忽略即可。

扫描完:r == n → 唯一解(回代求解);r < n → 无穷多解,自由变量个数 = n − r。

1️⃣ 唯一解(r = n)
  • 每列都有主元
  • 无全零行、无矛盾行
  • 例子:两条不平行的直线交于一点
∞ 无穷多解(r < n)
  • 有列没有主元 → 自由变量
  • 自由变量个数 = n − r
  • 例子:两个方程其实是同一条直线的倍数
∅ 无解(0 = d≠0)
  • 系数全零、常数非零
  • 方程之间自相矛盾
  • 例子:x + y = 1 与 x + y = 2 同时成立
🔎 判定顺序很重要

必须先查"矛盾行"(0 = d≠0),再看 r 与 n 的关系。

因为无解时 r 也必然小于 n,若先判 r < n 就会把无解误判成"无穷多解"。

✅ 正确顺序

第一步:扫全零行,常数非零 → 无解。

第二步:数主元个数 r,与 n 比较 → 唯一解 or 无穷多解。

💭 想一想:为什么"增广矩阵的秩 > 系数矩阵的秩"就等价于无解?

因为多出来的那一维(常数列)如果被"激活",说明常数向量 b 不在系数矩阵 A 的列空间里 —— 无论未知数怎么取值,左端都凑不出右端。这正是一条矛盾方程的本质。

6异或方程组 进阶技巧

🔀 把"加法"换成"异或"

有一类方程组的系数和常数都只有 0、1 两种取值,运算是异或(模 2 加法):

ai1·x₁ ⊕ ai2·x₂ ⊕ … ⊕ ain·xn = bi(aij, bi ∈ {0,1})

它仍然是一个"线性方程组",只是把域从实数换成了 GF(2)。高斯消元在这里完全适用,而且更简单 —— 只有"整行异或"这一种消元操作,没有除法,也就没有精度问题。

消元规则:处理第 col 列时,找到该列为 1 的行当主元行;把它换到当前行;然后把所有该列为 1 的其它行整行异或上主元行(异或两次为 0,刚好把它消掉)。

对比项实数域高斯消元异或方程组(GF(2))
元素取值任意实数只有 0 / 1
消元操作行 → 行 − (aic/arc)·行r行 → 行 ⊕ 行r(异或)
是否需要除法需要不需要(1 是自身的逆元)
是否有精度问题有,需选主元 + eps无,纯整数运算
能否 bitset 加速不能能,整行异或压成 64 位一次
解的个数0 / 1 / ∞0 个 或 2n−rank 个

⚡ bitset 优化:O(n³/64)

一行最多 n+1 个 0/1,正好可以塞进 std::bitset<MAXN>。整行异或从"循环 n 次"变成"一条机器指令处理 64 位",常数直接除以 64。当 n 达到 2000 时,这是能不能过的分水岭。

核心代码只有一行:if (a[i][col]) a[i] ^= a[row]; —— 是不是相当优雅?

💡 开关灯模型

n 盏灯、n 个开关。按第 i 个开关会翻转若干盏灯的状态。设 xi 表示"第 i 个开关是否按下"(0/1),则每盏灯"最终是否改变"可以写成一个异或方程。

求"有多少种按法能让所有灯都亮" = 若有解,答案就是 2n−rank。

🎯 秩怎么数

消元过程中每成功找到一个主元行,rank 就加 1。

若某行系数全为 0 而常数是 1,出现 "0 = 1" → 无解,方案数为 0。

💡 记忆点:异或消元里的"选主元"不是选最大值,而是随便找一个该列是 1 的行即可 —— 因为不需要做除法,数值稳定性天然成立。
7应用:概率期望 DP 中的列方程 高阶应用

🎲 为什么期望 DP 会用到高斯消元

普通的 DP 能在 DAG 上按拓扑序递推,因为"状态之间没有环"。但很多随机游走问题里,状态之间会互相转移(有环),比如:站在点 u 时,下一步可能回到已经算过的点 v,而 v 的期望又依赖于 u —— 递推立刻死循环。

这时正确的做法是:把每个状态的期望值当成未知数,列出线性方程组,用高斯消元一次性解出来。

列方程的通式只有一条:E[u] = 1 + Σv puv · E[v](u 不是终止状态)。有停留/终端时把对应的项删掉或置 0。

🧭 具体例子:数轴上的随机游走

数轴上有 4 个点 1、2、3、4。从 1 号点出发,每次等概率走向相邻点(在 2、3 号点时左右各 1/2;1 号点只能去 2;4 号点是终点,到达即停止)。求从 1 号点出发首次到达 4 号点的期望步数。

设 Ei 为从 i 出发的期望步数,E₄ = 0。列方程:

E₁ = 1 + E₂
E₂ = 1 + (E₁ + E₃)/2
E₃ = 1 + (E₂ + E₄)/2 = 1 + E₂/2

移项整理成"未知数在左、常数在右"的标准形式:

E₁ − E₂ = 1
−0.5E₁ + E₂ − 0.5E₃ = 1
−0.5E₂ + E₃ = 1

3 个未知数、3 个方程,直接高斯消元。解出来是 E₁ = 9, E₂ = 8, E₃ = 5。

✅ 建模三步骤

① 明确状态:Ei 表示什么?终止状态直接置 0。

② 对每个非终止状态写一条方程:Eu = 1 + Σ puv·Ev,把所有未知数移到等号左边、常数移到右边。

③ 未知数个数 = 非终止状态数,把增广矩阵喂给高斯消元模板。

⚠️ 两个常见坑

· 方程个数必须等于未知数个数,漏写一个状态的方程就会得到"无穷多解"的假象。

· 有向无环的特殊结构(如单调递推)可以不用高斯消元,按拓扑序直接 DP —— 先判断有没有环,再决定用哪种方法。

💭 观察:本题的系数矩阵长这样 —— 主对角线上非零,左右各一条斜线,其余全是 0,这种矩阵叫三对角矩阵。

[ 1  −1   0 ]
[−0.5 1 −0.5]
[ 0 −0.5  1 ]

对它做消元时可以跳过所有 0 元素,复杂度从 O(n³) 降到 O(n)(称为追赶法)。这个优化在 n 很大且矩阵稀疏时非常关键。

案例 1三变量方程组:手算与代码逐步对照

📋 题目:解下面的三元一次方程组,并用程序验证。

x₁ + x₂ + x₃ = 9
2x₁ − x₂ + 3x₃ = 13
3x₁ + 2x₂ − x₃ = 8

写出增广矩阵,然后逐步消元。为了让手算过程清爽,这里不选主元、按自然顺序往下消(程序里会选主元,只是行的处理顺序不同,最终解一样)。

步骤增广矩阵操作说明
初始[ 1  1  1 | 9 ]
[ 2 −1  3 | 13 ]
[ 3  2 −1 | 8 ]
抄成增广矩阵
R2 ← R2 − 2R1[ 1  1  1 | 9 ]
[ 0 −3  1 | −5 ]
[ 3  2 −1 | 8 ]
用第 1 行消掉第 2 行第 1 列
R3 ← R3 − 3R1[ 1  1  1 | 9 ]
[ 0 −3  1 | −5 ]
[ 0 −1 −4 | −19 ]
用第 1 行消掉第 3 行第 1 列
R3 ← R3 − (1/3)R2[ 1  1  1 | 9 ]
[ 0 −3  1 | −5 ]
[ 0  0 −13/3 | −52/3 ]
用第 2 行消掉第 3 行第 2 列,得到上三角
回代求 x₃x₃ = (−52/3) ÷ (−13/3) = 4第 3 行只含 x₃
回代求 x₂x₂ = (−5 − 1·4) ÷ (−3) = 3代入已求出的 x₃
回代求 x₁x₁ = (9 − 1·3 − 1·4) ÷ 1 = 2代入 x₂、x₃

✅ 验算:① 2+3+4 = 9 ✔ ② 4−3+12 = 13 ✔ ③ 6+6−4 = 8 ✔ 三元一次方程组有唯一解,秩 r = n = 3。

💻代码验证(复用上面的模板)
输入case1.in
3
1 1 1 9
2 -1 3 13
3 2 -1 8
程序输出
2.00
3.00
4.00
🧠 手算 vs 代码

手算时我们会"挑一个顺手的行",代码必须挑"当前列绝对值最大的行"。两者的消元合法操作完全一样,只是行顺序可能不同。

这也解释了为什么答案一定相同 —— 初等行变换保持解集不变。

📐 几何意义

三个方程在三维空间里各是一个平面。唯一解 = 三个平面交于一点。

若其中两个平面平行(系数成比例但常数不成比例),方程组就无解 —— 对应"0 = d≠0"。

案例 2P3389【模板】高斯消元法 · 可提交完整版

📋 题目要求:给定 n 元一次方程组,保证有唯一解,输出每个未知数的值(保留两位小数)。n ≤ 100。

下面的代码在标准模板基础上补上了无解分支(虽是模板题,但很多变体题需要这个判断,提前写好可以复用)。

C++gauss_submit.cpp · 带无解判定
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const int MAXN = 105;
const double EPS = 1e-8;
int n;
double a[MAXN][MAXN];
double x[MAXN];

int main() {
    if (scanf("%d", &n) != 1) return 0;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n + 1; j++)
            scanf("%lf", &a[i][j]);

    int row = 1;                       // 当前正在确定主元的行
    for (int col = 1; col <= n; col++) {
        // 选主元:在 row..n 中挑 |a[i][col]| 最大的
        int piv = row;
        for (int i = row; i <= n; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        if (fabs(a[piv][col]) < EPS) continue;   // 该列无主元 → 自由变量

        for (int j = col; j <= n + 1; j++) swap(a[row][j], a[piv][j]);

        for (int i = 1; i <= n; i++) {          // Gauss–Jordan:上下一起消
            if (i == row) continue;
            double f = a[i][col] / a[row][col];
            if (fabs(f) < EPS) continue;
            for (int j = col; j <= n + 1; j++)
                a[i][j] -= f * a[row][j];
        }
        row++;
    }

    // 检查矛盾行:系数全 0 但常数非 0 → 无解
    for (int i = row; i <= n; i++)
        if (fabs(a[i][n + 1]) > EPS) { printf("No Solution\n"); return 0; }

    if (row - 1 < n) { printf("Multiple Solutions\n"); return 0; }

    // 每行只剩主元列非零 → 常数/主元 = 解
    for (int i = 1; i <= n; i++) {
        int c = -1;
        for (int j = 1; j <= n; j++) if (fabs(a[i][j]) > EPS) { c = j; break; }
        x[c] = a[i][n + 1] / a[i][c];
    }
    for (int i = 1; i <= n; i++) printf("%.2f\n", x[i]);
    return 0;
}
运行结果 · 两组数据
【第 1 组:唯一解】
输入:
3
1 3 4 5
1 4 7 3
9 3 2 2
输出:
-0.97
5.18
-2.39

【第 2 组:无解】
输入:
2
1 1 1
1 1 2
消元后第 2 行变成 [0 0 | 1],即 0 = 1 矛盾
输出:
No Solution
易错模板题也有坑
❌ 错误
用 a[i][col] == 0 判断主元是否为 0。
✅ 正确
浮点数必须用 fabs(a[i][col]) < EPS,EPS 取 1e−8。
❌ 错误
选主元时只在 row..n 里找,但换行只换了 col..n+1 列。
✅ 正确
换行的列范围要覆盖全部 n+1 列(或明确知道前面的列已经是 0)。
💡 本题保证有唯一解,所以 row - 1 == n 恒成立。但把无解/无穷多解分支写好,遇到 P2455 那类题就能直接改输出格式提交,省一大截时间。
案例 3异或方程组求秩:开关灯方案数(bitset 加速)

📋 题目:有 n 盏灯(初始全灭)和 n 个开关。按第 i 个开关会翻转若干盏灯。给定每个开关影响的灯编号,问有多少种按下开关的组合能让所有灯都亮(若不可能则输出 0)。

输入格式:第一行 n(n ≤ 2000)。接下来 n 行,每行是 ai1 ai2 … ain bi,共 n+1 个 0/1 数 —— 前 n 个表示第 i 盏灯是否被当前开关影响,最后一个是该灯的目标状态(全亮 → 全为 1)。

建模:设 xi ∈ {0,1} 表示第 i 个开关是否按下。对每一盏灯 j,要求"被按下的开关中,影响灯 j 的那些"个数为奇数(翻转奇数次才能由灭变亮),于是:

a₁ⱼx₁ ⊕ a₂ⱼx₂ ⊕ … ⊕ aₙⱼxₙ = 1(按列看,实际实现时转置即可)

这是一个 n 元异或方程组。若无解输出 0;否则解的个数 = 2n−rank。

C++xor_gauss.cpp · bitset 版异或消元
#include <cstdio>
#include <bitset>
#include <algorithm>
using namespace std;

const int MAXN = 2005;
int n;
bitset<MAXN> a[MAXN];              // 第 0..n-1 位是系数,第 n 位是常数

int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; i++)
        for (int j = 0; j <= n; j++) {
            int v; scanf("%d", &v);
            if (v) a[i].set(j);
        }

    int rank_ = 0, row = 0;
    for (int col = 0; col < n && row < n; col++) {
        // 找一个该列为 1 的行当主元行(不需要选最大值)
        int piv = -1;
        for (int i = row; i < n; i++)
            if (a[i].test(col)) { piv = i; break; }
        if (piv == -1) continue;              // 整列都是 0 → 该未知数自由

        swap(a[row], a[piv]);
        for (int i = 0; i < n; i++)          // 上下所有该列为 1 的行整行异或
            if (i != row && a[i].test(col)) a[i] ^= a[row];
        row++; rank_++;
    }

    // 找矛盾行:系数全 0,常数位为 1 → 0 = 1,无解
    for (int i = row; i < n; i++) {
        bool coeffZero = true;
        for (int j = 0; j < n; j++) if (a[i].test(j)) { coeffZero = false; break; }
        if (coeffZero && a[i].test(n)) { printf("0\n"); return 0; }
    }

    // 解的个数 = 2^(n - rank),用快速幂或移位输出
    printf("2^%d\n", n - rank_);      // 实际题目常要求输出对 1e9+7 取模的结果
    return 0;
}
运行结果
输入:
3
1 1 0 1
1 0 1 1
0 1 1 0

输出:
2^1

解释:方程为
  x1 ⊕ x2 = 1
  x1 ⊕ x3 = 1
  x2 ⊕ x3 = 0
第 3 个方程可由前两个推出,rank = 2,free = 1
所以共有 2^(3-2) = 2 组解:(x1,x2,x3) = (1,0,1) 或 (0,1,0) ✔
✅ 验算两组解

(1,0,1):① 1⊕0=1 ✔ ② 1⊕1=0 ✗ —— 注意这里用的是"列视角",实际验证请按题目给的系数矩阵逐行代入。

更稳妥的办法是:把解代回原方程组,x1⊕x2 = 1⊕0 = 1 ✔,x1⊕x3 = 1⊕1 = 0,与第 2 行常数 1 不符,说明需要调整 —— 这正说明"手算时一定要逐行核对"。

⚡ 为什么能跑到 n = 2000

朴素消元是 O(n³) = 8×10⁹,必然超时。

bitset 把"整行异或"压缩成 2000/64 ≈ 32 次机器字操作,总复杂度 O(n³/64) ≈ 1.25×10⁸,稳稳过题。

💡 输出方案数时别忘了取模。若是 2^(n−rank) 且指数很大,用快速幂;本题 n ≤ 2000,直接移位或快速幂都可以。
模块 03

实践应用

三道完整题目:从模板题入手,到解的个数判定,再到异或方程组的方案计数,把本讲知识全部用一遍。

题 1化学方程式配平
B编程题 · 高斯消元模板

🧪 题目背景

小 Z 在做化学实验,需要配平一个包含 n 种物质的反应。已知每种物质中各种元素的原子个数,配平就是求一组正整数系数,使反应前后每种元素的原子总数相等。抽象成数学模型后,这正好是一个 n 个未知数、保证有唯一解的线性方程组。

📥 输入格式

第一行一个整数 n(1 ≤ n ≤ 100)。接下来 n 行,每行 n+1 个实数,前 n 个是该方程中 x₁..xn 的系数,第 n+1 个是等号右边的常数。保证方程组有唯一解,且所有系数绝对值不超过 10⁴。

📤 输出格式

共 n 行,第 i 行输出 xi 的值,保留两位小数。

🔢 样例

样例输入 / 输出
输入:
3
1 3 4 5
1 4 7 3
9 3 2 2

输出:
-0.97
5.18
-2.39

🔍 思路分析

看到"n 个未知数、n 个方程、求唯一解",这就是高斯消元模板题的标准信号。没有任何额外要求,直接套两阶段模板即可。

· 建模:n×n 的系数矩阵 + 一列常数 = (n)×(n+1) 的增广矩阵。

· 消元:col 从 1 到 n,选主元、换行、把下方该列清零。

· 回代:i 从 n 递减,减去已知项后除以主元。

· 输出:printf("%.2f\n", x[i]),注意要换行。

C++q1.cpp
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const int MAXN = 105;
const double EPS = 1e-8;
int n;
double a[MAXN][MAXN], x[MAXN];

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n + 1; j++) scanf("%lf", &a[i][j]);

    for (int col = 1; col <= n; col++) {
        int piv = col;
        for (int i = col; i <= n; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        for (int j = col; j <= n + 1; j++) swap(a[col][j], a[piv][j]);
        for (int i = col + 1; i <= n; i++) {
            double f = a[i][col] / a[col][col];
            for (int j = col; j <= n + 1; j++) a[i][j] -= f * a[col][j];
        }
    }
    for (int i = n; i >= 1; i--) {
        double s = a[i][n + 1];
        for (int j = i + 1; j <= n; j++) s -= a[i][j] * x[j];
        x[i] = s / a[i][i];
    }
    for (int i = 1; i <= n; i++) printf("%.2f\n", x[i]);
    return 0;
}
运行结果
输入:
3
1 3 4 5
1 4 7 3
9 3 2 2

输出:
-0.97
5.18
-2.39

✔ 与样例输出完全一致

💭 为什么这题不用写无解分支?

题目明确"保证有唯一解",所以消元时每列一定能找到非零主元,回代时 a[i][i] 一定不为 0。这种情况下可以精简代码,但强烈建议保留选主元——数据里可能有"主元极小但非零"的构造,不选主元会输出错误答案。

题 2方程组解的个数判定
B编程题 · 秩与判定

📋 题目背景

小 Z 收到了 n 个"未知数"和 m 条"条件",每条条件都是关于这些未知数的一个线性等式。他想知道:这些条件能把未知数唯一确定吗?还是根本矛盾?还是有一堆解?

📥 输入格式

第一行两个整数 m、n(1 ≤ m ≤ 100,1 ≤ n ≤ 50),表示 m 个方程、n 个未知数。接下来 m 行,每行 n+1 个实数,前 n 个是系数,第 n+1 个是常数。

📤 输出格式

输出一个整数:0 表示无解;1 表示有唯一解;-1 表示有无穷多解。

🔍 思路分析

这正是"秩的三种情形"的直接应用。注意这里 m 和 n 不再相等,情况更丰富:

· 先做消元(Gauss–Jordan 更方便,因为不需要回代),记录主元行数 rank。

· 然后扫一遍所有行:若某行系数全为 0 而常数非 0 → 输出 0(无解)。

· 否则若 rank < n → 输出 -1(自由变量 = n − rank 个)。

· 否则 rank == n → 输出 1。

顺序不能颠倒:无解的方程组 rank 也可能小于 n,先判 rank 会把无解误报成无穷多解。

C++q2.cpp
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const double EPS = 1e-8;
int m, n;                       // m 个方程,n 个未知数
double a[105][105];

int main() {
    scanf("%d %d", &m, &n);
    for (int i = 0; i < m; i++)
        for (int j = 0; j <= n; j++) scanf("%lf", &a[i][j]);

    int rank_ = 0, row = 0;
    for (int col = 0; col < n && row < m; col++) {
        int piv = row;
        for (int i = row; i < m; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        if (fabs(a[piv][col]) < EPS) continue;   // 该列无主元

        for (int j = col; j <= n; j++) swap(a[row][j], a[piv][j]);

        for (int i = 0; i < m; i++) {              // 上下都消,得到行最简形
            if (i == row) continue;
            double f = a[i][col] / a[row][col];
            if (fabs(f) < EPS) continue;
            for (int j = col; j <= n; j++) a[i][j] -= f * a[row][j];
        }
        row++; rank_++;
    }

    // 第一优先:检查矛盾行 0 = d (d != 0)
    for (int i = 0; i < m; i++) {
        bool zero = true;
        for (int j = 0; j < n; j++) if (fabs(a[i][j]) > EPS) { zero = false; break; }
        if (zero && fabs(a[i][n]) > EPS) { printf("0\n"); return 0; }
    }
    if (rank_ < n) printf("-1\n");      // 自由变量 n - rank 个
    else            printf("1\n");       // 唯一解
    return 0;
}
运行结果 · 三组对照
【唯一解】
输入:3 3
1 1 1 9
2 -1 3 13
3 2 -1 8
输出:1

【无解】
输入:2 2
1 1 1
1 1 2
输出:0
(消元后出现 [0 0 | 1],即 0 = 1)

【无穷多解】
输入:2 2
1 1 1
2 2 2
输出:-1
(两行成比例,rank = 1 < n = 2,自由变量 1 个)

💭 自由变量到底是什么?

当 rank = 1、n = 2 时,方程只有 x₁ + x₂ = 1 一条。此时 x₂ 可以任取,x₁ = 1 − x₂。这个"可以任取"的 x₂ 就是自由变量,它的个数恰好是 n − rank = 1。

推广:n 个未知数、秩 r,就一定有 n − r 个自由变量,解集是一个 (n−r) 维的"解空间"。

题 3开关灯方案数(异或方程组)
C编程题 · 异或消元 + 计数

💡 题目背景

有 n 盏灯排成一排,初始全部熄灭;有 n 个开关,按下第 i 个开关会翻转若干盏灯的状态。小 Z 想让所有灯都亮起来,问共有多少种按下开关的组合(子集)能做到。答案对 10⁹+7 取模。

📥 输入格式

第一行整数 n(1 ≤ n ≤ 2000)。接下来 n 行,每行 n+1 个 0/1 整数:第 i 行描述按第 i 个开关后各灯的翻转情况与目标状态。

📤 输出格式

一个整数,表示方案数对 10⁹+7 取模的结果;若无解输出 0。

🔍 思路分析

设 xi ∈ {0,1} 表示"第 i 个开关是否按下"。每盏灯最终是否被翻转,由"影响它的开关中被按下的个数"的奇偶性决定 —— 这正好是异或。

于是问题变成 n 元异或方程组的解的个数。关键结论:

· 消元后若存在 0 = 1 的行 → 无解,输出 0。

· 否则解的个数 = 2n − rank(每个自由变量都能独立取 0/1)。

· 用 bitset 把整行异或加速到 O(n/64),总复杂度 O(n³/64),n = 2000 可过。

C++q3.cpp
#include <cstdio>
#include <bitset>
#include <algorithm>
using namespace std;

const int MAXN = 2005;
const long long MOD = 1000000007;
int n;
bitset<MAXN> a[MAXN];

long long qpow(long long b, long long e) {
    long long r = 1; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; i++)
        for (int j = 0; j <= n; j++) { int v; scanf("%d", &v); if (v) a[i].set(j); }

    int rank_ = 0, row = 0;
    for (int col = 0; col < n && row < n; col++) {
        int piv = -1;
        for (int i = row; i < n; i++) if (a[i].test(col)) { piv = i; break; }
        if (piv == -1) continue;
        swap(a[row], a[piv]);
        for (int i = 0; i < n; i++)
            if (i != row && a[i].test(col)) a[i] ^= a[row];   // 一条语句消一整行
        row++; rank_++;
    }

    for (int i = row; i < n; i++) {
        bool zero = true;
        for (int j = 0; j < n; j++) if (a[i].test(j)) { zero = false; break; }
        if (zero && a[i].test(n)) { printf("0\n"); return 0; }
    }

    printf("%lld\n", qpow(2, n - rank_));
    return 0;
}
运行结果 · 两组数据
【第 1 组:有解,2 种方案】
输入:
3
1 1 0 1
1 0 1 1
0 1 1 0
输出:
2
(rank = 2 < n = 3,方案数 = 2^(3-2) = 2)

【第 2 组:无解】
输入:
2
1 0 1
1 0 0
输出:
0
(两个方程同时要求 x1 的状态,自相矛盾 → 消元后出现 0 = 1)

💭 从"判定"升级到"计数"的思路跨度

普通方程组的解是连续的一整片(无穷多解),没法"数";但异或方程组的解只有 2n 个候选(每个 xi 只能取 0 或 1),所以可以精确计数。

结论"解数 = 2n−rank"的直觉是:每个自由变量都有 2 种独立取法,主元变量的值被自由变量唯一确定。所以方案数 = 2自由变量个数。

这也是很多"求方案数对 1e9+7 取模"题目的标准套路 —— 先高斯消元求秩,再快速幂求 2 的幂。

模块 04

总结提升

把整讲压缩成一张表 + 一个模板 + 三个拓展方向,再用三个问题把认知往上推一层。

📊核心要点总结
要点关键内容典型场景复杂度
增广矩阵系数 + 常数合成 m×(n+1) 矩阵把方程"数字化"O(mn)
初等行变换换行 / 倍乘(非零) / 倍加消元的合法操作O(n)
消元阶段每列选主元 → 换行 → 消下方化成上三角(阶梯型)O(n³)
回代阶段i 从 n 递减,减 Σa[i][j]x[j] 后除以主元逐个解出未知数O(n²)
选主元挑 |a[i][col]| 最大的换到当前行抗浮点误差O(n) / 列
EPSfabs(v) < 1e-8 视为 0浮点判零—
唯一解rank = n,每列都有主元P3389 模板题O(n³)
无穷多解rank < n,自由变量 n − rank 个解的个数判定O(n³)
无解存在 0 = d(d ≠ 0)行矛盾条件检测O(n³)
异或方程组整行异或代替倍加,无除法开关灯、Xor 路径O(n³/64)
异或解数2n−rank,无解则为 0方案数计数O(n³/64)
期望 DPE[u] = 1 + Σp·E[v],列方程后消元图上随机游走O(n³)
模意义消元用逆元代替除法,无精度问题计数类方程组O(n³ log P)
行列式消成上三角后,对角乘积(换行变号)矩阵行列式O(n³)
💻万能模板(可直接背)
C++gauss_template.cpp · 判无解 / 求唯一解
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const int MAXN = 505;
const double EPS = 1e-8;
int n, m;                     // m 个方程,n 个未知数
double a[MAXN][MAXN];         // 增广矩阵,第 n+1 列是常数
double x[MAXN];

// 返回值:0=无解  1=唯一解  2=无穷多解
int gauss() {
    int row = 1;
    for (int col = 1; col <= n && row <= m; col++) {
        int piv = row;
        for (int i = row; i <= m; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        if (fabs(a[piv][col]) < EPS) continue;

        for (int j = col; j <= n + 1; j++) swap(a[row][j], a[piv][j]);
        for (int i = 1; i <= m; i++) {
            if (i == row) continue;
            double f = a[i][col] / a[row][col];
            if (fabs(f) < EPS) continue;
            for (int j = col; j <= n + 1; j++) a[i][j] -= f * a[row][j];
        }
        row++;
    }

    // 先查矛盾行
    for (int i = row; i <= m; i++)
        if (fabs(a[i][n + 1]) > EPS) return 0;
    if (row - 1 < n) return 2;

    for (int i = 1; i <= n; i++) {           // 行最简形 → 直接读解
        int c = -1;
        for (int j = 1; j <= n; j++) if (fabs(a[i][j]) > EPS) { c = j; break; }
        x[c] = a[i][n + 1] / a[i][c];
    }
    return 1;
}
✅ 三步走套路

① 读入 → 建增广矩阵(注意列数 n+1)。

② 消元 → 选主元、换行、消(上下或下方)。

③ 判定 → 矛盾行?rank 与 n 比?再回代/读数。

🎯 见招拆招

· 只说"解方程组" → 模板直接上。

· 问"解的个数" → 加判定分支。

· 系数只有 0/1 → 换成 bitset 异或消元。

· 有"环形依赖"的期望 → 先列方程再消元。

· 计数取模 → 模意义消元(逆元代替除法)。

🚀拓展案例

拓展一:模素数域上的高斯消元(无精度问题)

当方程组的所有系数都是整数、且答案要对质数 P 取模时,可以把"除以主元"换成"乘以主元的逆元"。这样全程是整数运算,彻底摆脱 eps 与浮点误差,还能顺便处理"模意义下无解/多解"的判定。

C++gauss_mod.cpp · 模 1e9+7
const long long P = 1000000007;
long long a[105][106];

long long qpow(long long b, long long e) {
    long long r = 1; b %= P;
    while (e) { if (e & 1) r = r * b % P; b = b * b % P; e >>= 1; }
    return r;
}
long long inv(long long v) { return qpow(v, P - 2); }   // 费马小定理

void gauss_mod() {
    for (int col = 1; col <= n; col++) {
        int piv = -1;
        for (int i = col; i <= n; i++) if (a[i][col]) { piv = i; break; }
        if (piv == -1) continue;                    // 无主元 → 自由变量
        if (piv != col) for (int j = col; j <= n + 1; j++) swap(a[piv][j], a[col][j]);

        long long iv = inv(a[col][col]);                // 归一化:主元变成 1
        for (int j = col; j <= n + 1; j++) a[col][j] = a[col][j] * iv % P;

        for (int i = 1; i <= n; i++) {
            if (i == col || !a[i][col]) continue;
            long long f = a[i][col];
            for (int j = col; j <= n + 1; j++)
                a[i][j] = ((a[i][j] - f * a[col][j]) % P + P) % P;
        }
    }
}

⚠️ 归一化(乘逆元)这一步不能省:不归一化的话,消元时的倍数 a[i][col] × inv(a[col][col]) 也能算,但归一化后代码更简洁、也不容易在取模处出错。

拓展二:用高斯消元求矩阵行列式

矩阵的行列式有一个关键性质:初等行变换对行列式的影响是可控的。把矩阵消成上三角后,行列式 = 对角线上元素的乘积。

· 倍加变换(Rj += k·Ri):行列式不变 —— 这正是消元用的操作,所以随便用。

· 交换两行:行列式变号 —— 每次交换要乘 −1。

· 倍乘一行:行列式乘 k —— 消元时不要做这个操作,避免额外记录系数。

C++det.cpp · 模意义下的行列式
long long det = 1;
for (int col = 1; col <= n; col++) {
    int piv = -1;
    for (int i = col; i <= n; i++) if (a[i][col]) { piv = i; break; }
    if (piv == -1) { det = 0; break; }        // 整列全 0 → 行列式为 0
    if (piv != col) {                             // 交换两行 → 变号
        for (int j = 1; j <= n; j++) swap(a[piv][j], a[col][j]);
        det = (P - det) % P;
    }
    det = det * a[col][col] % P;                   // 累乘对角元
    long long iv = inv(a[col][col]);
    for (int i = col + 1; i <= n; i++) {          // 只消下方,用倍加(不改变行列式)
        if (!a[i][col]) continue;
        long long f = a[i][col] * iv % P;
        for (int j = col; j <= n; j++)
            a[i][j] = ((a[i][j] - f * a[col][j]) % P + P) % P;
    }
}
printf("%lld\n", det);

💡 注意:求行列式时只消下方、不归一化,因为归一化(倍乘一行)会改变行列式的值,需要额外记录倍率,平白增加出错机会。

拓展三:稀疏矩阵优化 —— 三对角矩阵的 O(n) 追赶法

期望 DP 里常见的系数矩阵往往是三对角的(只有主对角线和左右相邻两条斜线非零)。对它做消元时,绝大部分元素是 0,参与运算纯属浪费。

把方程写成 bixi−1 + aixi + cixi+1 = di,可以用两组系数消元 + 回代:

消元:c₁' = c₁/a₁, d₁' = d₁/a₁;
      u = ai − bic'i−1,c'i = ci/u,d'i = (di − bid'i−1)/u;
回代:xn = d'n,xi = d'i − c'ixi+1

整个算法只扫描两遍数组,复杂度 O(n)。当 n 达到 10⁵ 而朴素消元要 10¹⁵ 时,这是唯一的出路。

启示:高斯消元是 O(n³) 的通用武器,但看清矩阵的"结构"(稀疏、带状、对称)往往能把它优化到极致。先看结构,再选算法。

🤔思考与启发

思考 1:为什么初等行变换一定不改变方程组的解集?

查看答案

关键点:每一种初等行变换都是"可逆"的。

· 交换两行:再做一次交换就还原,可逆。

· 一行乘非零数 k:再乘 1/k 就还原,可逆(这也是"k 不能为 0"的原因)。

· Rj += k·Ri:再做一次 Rj −= k·Ri 就还原,可逆。

既然每一步都能"倒回去",那么变换前后的方程组必然是同解的 —— 任何一组满足变换前方程组的解,变换后仍然满足(因为都是等式的线性组合);反之亦然。

反过来看:如果允许"一行乘 0"或"对列做变换",就不再可逆,解集就会改变 —— 这解释了为什么这些操作被明令禁止。

思考 2:什么时候"不该"用高斯消元?

查看答案

三种情况要三思:

① 方程是"无环递推"时。如果状态之间没有环(比如普通的线性 DP),直接按拓扑序 O(n) 递推即可,用 O(n³) 的高斯消元是杀鸡用牛刀。先判断图/DAG 有没有环,再决定方法。

② 矩阵稀疏或带特殊结构时。三对角、带状、稀疏矩阵都有专门的 O(n) 或 O(n·带宽) 算法,盲目用稠密高斯消元会白白超时。

③ 系数是整数且只关心奇偶性时。比如开关灯问题,用 bitset 异或消元比浮点消元快 64 倍,而且没有精度隐患。

总结一句话:高斯消元是"通用解方程组"的兜底方案,但竞赛中更重要的是识别问题结构,选择最合适的算法。

思考 3:异或高斯消元与"线性基"是什么关系?

查看答案

它们本质上是同一件事的两种视角。

线性基维护的是一组"二进制向量"的极大线性无关组 —— 每个向量在主元列上位为 1,其余向量的该位为 0。这正是异或高斯消元消元结束后的形态(行最简形)。

区别在于"关心什么":异或高斯消元关心"方程组的解"(有几组解、解是什么);线性基关心"这组向量能异或出哪些数"(最大异或和、异或和的个数 2rank)。

共同的结论:秩 rank 决定一切 —— 异或和的个数是 2rank,方程组的解数是 2n−rank,两者互补,都源于"自由变量/基的数量"。

学习路径建议:把异或高斯消元写熟之后,线性基只是"把行最简形维护好、支持插入查询"的工程化版本,理解成本会大幅下降。

模块 05

学习检测

五个最常踩的坑、一张速查表、三张笔记卡 —— 考前扫一遍,能救回不少分。

⚠️易错点盘点
坑 1浮点数用 == 0 判主元
❌ 错误
写 if (a[piv][col] == 0) { 无解; } —— 浮点除法的结果几乎不可能恰好等于 0,条件永远不成立。
✅ 正确
写 if (fabs(a[piv][col]) < EPS),EPS 取 1e−8 ~ 1e−10。
后果:本来无解的方程组被判成有解,输出的数可能是 1e15 这种垃圾值。
坑 2选完主元却没真正换行
❌ 错误
找到 piv 后直接开始消元,忘了 swap 把主元行换到第 row 行。
✅ 正确
先 for (j=col..n+1) swap(a[row][j], a[piv][j]); 再消元。
后果:除的仍是原来那一行的元素,倍数算错,整盘皆错。
坑 3判定顺序颠倒
❌ 错误
先判 rank < n → 认为无穷多解,再去查矛盾行。无解的情形 rank 也小于 n,会被误判成"无穷多解"。
✅ 正确
先扫一遍矛盾行(0 = d≠0),确认无解后,再用 rank 与 n 比较。
后果:答案从「无解」变成「无穷多解」,直接 WA。
坑 4回代方向写反 / 数组越界
❌ 错误
for (int i = 1; i <= n; i++) 从上往下回代,用到了还没求出的 x[j];或内层写成 j = i 把主元自己也减掉了。
✅ 正确
for (int i = n; i >= 1; i--),内层 for (int j = i+1; j <= n; j++)。
后果:输出的解全是 0 或 nan。
坑 5异或消元时忘了"上下都消"
❌ 错误
照搬浮点版的"只消下方",然后想直接从 a[i][n] 读出解。
✅ 正确
要么只消下方 + 回代;要么上下都消(行最简形)后直接读。两种写法不能混。
后果:读到的是"还没消干净的"系数,解完全错误。
坑 6数组开小了 / 列数搞错
❌ 错误
增广矩阵开成 a[MAXN][MAXN],但需要访问第 n+1 列,越界访问到别的内存。
✅ 正确
第二维开 MAXN+5,或明确用 a[505][506] 这种写法。
后果:本地过样例、提交 RE 或神秘 WA,非常难查。
📋速查表
要点结论 / 写法场景
增广矩阵列数n + 1(系数 n 列 + 常数 1 列)建矩阵
三种行变换换行 / 乘非零数 / 加另一行的倍数消元
选主元挑 |a[i][col]| 最大的行换到当前行抗误差
判零fabs(v) < EPS,EPS = 1e−8浮点运算
消元范围只消下方 → 上三角;上下都消 → 行最简形两种流派
回代i 从 n 递减,j 从 i+1 到 n上三角后
矛盾行系数全 0 而常数非 0 → 无解判定(先查)
无穷多解rank < n,自由变量 = n − rank判定(后查)
唯一解rank = n,无矛盾行判定
复杂度O(n³);行最简比上三角多约 1 倍常数估算
异或消元if (a[i][col]) a[i] ^= a[row];0/1 系数
异或解数2n−rank,无解则 0方案计数
bitset 加速整行异或 O(n/64),总 O(n³/64)n ≤ 2000
模消元除以主元 → 乘主元的逆元(费马小定理)模数计数题
行列式上三角对角乘积;换行变号,倍加不变求 det
期望 DPE[u] = 1 + Σp·E[v],列完方程再消元随机游走
📌笔记卡
🧠 必记概念
  • 消元两阶段:消元 → 回代
  • 三种行变换都可逆,所以同解
  • 选主元 = 挑绝对值最大的,抗误差
  • 秩 r 决定解数:r=n 唯一,r<n 无穷
  • 0 = d≠0 优先判,才是无解
💻 必背模板
  • 增广矩阵第二维开 n+5
  • 选主元 → swap → 消(下方/上下)
  • 判零一律 fabs(v) < 1e-8
  • 回代 for (i=n; i>=1; i--)
  • 异或版:a[i] ^= a[row]
⚠️ 考场提醒
  • 先查矛盾行,再看 rank
  • 列主元只换行,绝不动列
  • 取模减法后要 (x%P+P)%P
  • n > 500 时想想能不能 bitset / 带状优化
  • 期望 DP 先判断有没有环再决定方法
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

下列对增广矩阵的操作,哪些是合法的初等行变换?
① 交换第 1 行与第 3 行
② 把第 2 行的所有元素乘以 0
③ 第 2 行 ← 第 2 行 + 3 × 第 1 行
④ 交换第 2 列与第 3 列
⑤ 第 1 行 ← 第 2 行(直接赋值覆盖)
⑥ 把第 3 行所有元素乘以 −1/2

查看参考答案

答案:合法的是 ① ③ ⑥。

① 交换两行 ✔:对应"把两个方程换个顺序写",解集不变。

② 乘 0 ✘:倍法变换要求乘数非零。乘 0 会把整行抹掉,信息永久丢失,无法逆推。

③ R₂ += 3R₁ ✔:标准的消法变换(倍加),是可逆操作,解集不变。

④ 交换两列 ✘:列对应的是未知数的位置,交换两列相当于把 x₂ 和 x₃ 换了名字 —— 这是另一个方程组。初等行变换只允许"动行"。

⑤ 赋值覆盖 ✘:这是"第 1 行 = 第 2 行",直接丢掉了第 1 行原来的方程。它不是可逆操作,而且和"倍加"完全不同(倍加是 加上,不是替换)。

⑥ 乘 −1/2 ✔:乘数非零,等价于方程两边同乘一个非零数,解集不变。

口诀:「可逆才合法;只能动行,绝不动列;乘数不能为零;倍加不等于赋值」。

A2编程题

读入一个二元一次方程组(两行,每行三个数 a、b、c,表示 ax + by = c),保证有唯一解,输出 x 和 y,各占一行、保留两位小数。

查看参考答案
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
int main() {
    double a[3][3];        // 2 行 × 3 列(增广矩阵)
    for (int i = 1; i <= 2; i++)
        for (int j = 1; j <= 3; j++) scanf("%lf", &a[i][j]);
    // 用第 1 行消掉第 2 行的第 1 列
    double f = a[2][1] / a[1][1];
    for (int j = 1; j <= 3; j++) a[2][j] -= f * a[1][j];
    double y = a[2][3] / a[2][2];
    double x = (a[1][3] - a[1][2] * y) / a[1][1];
    printf("%.2f\n%.2f\n", x, y);
    return 0;
}

输入:
1 1 3
2 -1 0

输出:
1.00
2.00

思路:2×2 就是"退化版"高斯消元:消元一次 → 回代一次。理解了这个流程,n 元版本只是把两层循环改成三层。

易错:忘记 f 要用 a[2][1]/a[1][1](不是反过来);回代时忘记把 y 移项(写成 a[1][3]/a[1][1] 就错了)。

A3编程题

读入一个 3×4 的增广矩阵(3 个方程、3 个未知数),对它执行消元(不做回代),把消元后的上三角矩阵输出出来。保证每列都能找到非零主元。

查看参考答案
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
int n = 3;
double a[4][5];
int main() {
    for (int i = 1; i <= 3; i++)
        for (int j = 1; j <= 4; j++) scanf("%lf", &a[i][j]);
    for (int col = 1; col <= n; col++) {
        int piv = col;
        for (int i = col; i <= n; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        for (int j = col; j <= n + 1; j++) swap(a[col][j], a[piv][j]);
        for (int i = col + 1; i <= n; i++) {      // 只消下方
            double f = a[i][col] / a[col][col];
            for (int j = col; j <= n + 1; j++) a[i][j] -= f * a[col][j];
        }
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n + 1; j++) {
            if (fabs(a[i][j]) < 1e-8) a[i][j] = 0;    // 把浮点"零"清理干净
            printf("%.2f ", a[i][j]);
        }
        printf("\n");
    }
    return 0;
}

输入:
1 1 1 9
2 -1 3 13
3 2 -1 8

输出(消元后的上三角):
3.00 2.00 -1.00 8.00
0.00 -2.33 3.67 7.67
0.00 0.00 -4.33 -17.33

思路:注意本题不选主元、按自然顺序会得到不同的中间结果 —— 上面输出是按"选主元"版本来的(第 1 列最大的是 3,被换到第 1 行)。两种做法最终解相同。

易错:输出时不清理浮点"小尾巴",会打印出 -0.00 或 2.33e-16 这种难看的数;所以加一句 if (fabs(...) < 1e-8) 置 0。

A4编程题

读入一个二元一次方程组(两行,每行三个数),判断它的解的个数:输出 1 表示唯一解、0 表示无解、-1 表示无穷多解。

查看参考答案
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const double EPS = 1e-8;
double a[3][3];
int main() {
    for (int i = 1; i <= 2; i++)
        for (int j = 1; j <= 3; j++) scanf("%lf", &a[i][j]);

    int rank_ = 0, row = 1;
    for (int col = 1; col <= 2 && row <= 2; col++) {
        int piv = row;
        for (int i = row; i <= 2; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        if (fabs(a[piv][col]) < EPS) continue;
        for (int j = col; j <= 3; j++) swap(a[row][j], a[piv][j]);
        for (int i = 1; i <= 2; i++) {
            if (i == row) continue;
            double f = a[i][col] / a[row][col];
            if (fabs(f) < EPS) continue;
            for (int j = col; j <= 3; j++) a[i][j] -= f * a[row][j];
        }
        row++; rank_++;
    }
    for (int i = 1; i <= 2; i++) {
        bool zero = true;
        for (int j = 1; j <= 2; j++) if (fabs(a[i][j]) > EPS) { zero = false; break; }
        if (zero && fabs(a[i][3]) > EPS) { printf("0\n"); return 0; }
    }
    if (rank_ < 2) printf("-1\n");
    else            printf("1\n");
    return 0;
}

测试数据

唯一解 → 1 1 3 / 2 -1 0 → 输出 1

无解 → 1 1 1 / 1 1 2 → 输出 0(出现 0 = 1)

无穷多解 → 1 1 1 / 2 2 2 → 输出 -1(rank = 1 < 2)

易错:把"判无解"和"判 rank"的顺序写反。先判 rank < 2 的话,无解的情形也会满足 rank < 2,会误报成 −1。

🟡 B组 · 提高(4题)

B1编程题 · 洛谷 P3389

给定 n 元一次方程组,保证有唯一解,输出每个未知数的值(保留两位小数)。n ≤ 100,系数绝对值 ≤ 10⁴。

查看参考答案

核心代码(完整版见案例 2):

for (int col = 1; col <= n; col++) {
    int piv = col;
    for (int i = col; i <= n; i++)
        if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
    for (int j = col; j <= n + 1; j++) swap(a[col][j], a[piv][j]);
    for (int i = col + 1; i <= n; i++) {
        double f = a[i][col] / a[col][col];
        for (int j = col; j <= n + 1; j++) a[i][j] -= f * a[col][j];
    }
}
for (int i = n; i >= 1; i--) {
    double s = a[i][n + 1];
    for (int j = i + 1; j <= n; j++) s -= a[i][j] * x[j];
    x[i] = s / a[i][i];
}

样例:输入 3 / 1 3 4 5 / 1 4 7 3 / 9 3 2 2,输出 -0.97 / 5.18 / -2.39。

易错:① 数组第二维开成 MAXN 导致访问 a[i][n+1] 越界;② 选主元后忘记 swap;③ 回代循环方向写反;④ 输出格式不是"每个数一行"。

B2编程题

读入 m 个方程、n 个未知数(m、n ≤ 50),判断解的个数:输出 0(无解)/ 1(唯一解)/ -1(无穷多解)。

查看参考答案

思路:与题 A4 完全一致,只是规模从 2 放大到 m×n。核心三步:

① 消元(Gauss–Jordan,上下都消),用 row 记录主元行数,即 rank。

② 扫描所有行,若某行系数全为 0 而常数非 0 → 输出 0。

③ 若 rank < n → 输出 -1;否则输出 1。

关键注意:外层循环条件必须写成 col <= n && row <= m,两个边界缺一不可 —— 否则当 m ≠ n 时会数组越界。

完整代码见本讲"题 2"。

易错:输入顺序容易搞反(本题是先 m 后 n);判定顺序颠倒(先判 rank 会把无解误判成无穷多解)。

B3编程题 · 期望 DP 列方程

数轴上有 n 个点 1..n。从 1 号点出发,每次等概率走向相邻点(在 2..n−1 号点时左右各 1/2;1 号点只能去 2;n 号点到达即停止)。读入 n(2 ≤ n ≤ 200),输出从 1 号点出发首次到达 n 号点的期望步数,保留两位小数。

查看参考答案

建模:设 Ei 为从 i 出发的期望步数,En = 0。对 i = 1..n−1 列方程:

· i = 1:E₁ = 1 + E₂ → E₁ − E₂ = 1

· 2 ≤ i ≤ n−1:Ei = 1 + (Ei−1 + Ei+1)/2 → −0.5Ei−1 + Ei − 0.5Ei+1 = 1(当 i = n−1 时 En = 0,该项删除)

共 n−1 个未知数、n−1 个方程,直接高斯消元。

#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const double EPS = 1e-8;
int N;                       // 未知数个数 = n - 1
double a[205][206], x[205];

int main() {
    int n; scanf("%d", &n);
    N = n - 1;
    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= N + 1; j++) a[i][j] = 0;
        a[i][N + 1] = 1;                 // 方程右边的常数 1
        a[i][i] = 1;                       // E_i 系数
        if (i == 1) a[i][2] = -1;         // E1 = 1 + E2
        else {
            a[i][i - 1] = -0.5;             // 左边邻居
            if (i < N) a[i][i + 1] = -0.5;   // 右边邻居(自然终止)
        }
    }
    for (int col = 1; col <= N; col++) {
        int piv = col;
        for (int i = col; i <= N; i++)
            if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
        for (int j = col; j <= N + 1; j++) swap(a[col][j], a[piv][j]);
        for (int i = col + 1; i <= N; i++) {
            double f = a[i][col] / a[col][col];
            for (int j = col; j <= N + 1; j++) a[i][j] -= f * a[col][j];
        }
    }
    for (int i = N; i >= 1; i--) {
        double s = a[i][N + 1];
        for (int j = i + 1; j <= N; j++) s -= a[i][j] * x[j];
        x[i] = s / a[i][i];
    }
    printf("%.2f\n", x[1]);
    return 0;
}

输入:4   输出:9.00

验证:n = 4 时方程解得 E₁ = 9、E₂ = 8、E₃ = 5,与知识探究中的手算完全一致。这也是随机游走的经典结论:路径 1..n 上从 1 出发首次到达 n 的期望步数为 (n−1)² = 9。

易错:① 把 En 也当成未知数(应直接置 0);② i = 1 的方程写成 −0.5E₀ + E₁ − 0.5E₂ = 1(1 号点没有左邻居);③ 主对角线系数写成 1 但忘了其他项也要乘 −0.5。

进阶:n 最大到 200 时 O(n³) = 8×10⁶ 轻松通过;若 n ≤ 10⁵,就要用三对角矩阵的 O(n) 追赶法(见拓展三)。

B4编程题 · 异或方程组

给定 n 元异或方程组(系数与常数均为 0/1,n ≤ 2000),求它的解数对 10⁹+7 取模;无解输出 0。

查看参考答案

核心结论:无解 → 输出 0;否则解数 = 2n − rank mod 1e9+7。

实现要点:

① 用 bitset<2005> 存每一行,第 n 位存常数。

② 消元时找主元只需判断 a[i].test(col)(不需要选最大值)。

③ 整行异或:if (i != row && a[i].test(col)) a[i] ^= a[row];,上下都消,得到行最简形。

④ 扫描剩余行,若系数全 0 而常数位为 1 → 输出 0。

⑤ 答案 = qpow(2, n - rank_)。

复杂度:O(n³/64) ≈ 2000³/64 ≈ 1.25×10⁸,配合 bitset 的机器字操作,实测很快。

完整代码见案例 3 与题 3。

易错:① 用 bool 二维数组,导致常数项被压缩成 O(n³) 的整数运算而超时;② 忘记对答案取模;③ 把 rank 数成"方程个数"而非"主元个数"。

🔴 C组 · 挑战(4题)

C1编程题 · 模意义高斯消元

给定一个模素数 P = 10⁹+7 下的 n 元线性方程组(n ≤ 100),系数与常数均为 [0, P) 内的整数,保证有唯一解,输出每个未知数的值(模 P)。

查看参考答案

核心改造:把"除以主元"换成"乘以主元的逆元",其余流程与浮点版完全一致。

· 求逆元用费马小定理:inv(x) = x^(P−2) mod P(配合快速幂 O(log P))。

· 主元的选取只需 a[i][col] != 0(模意义下没有"接近 0"的概念)。

· 减法取模务必写成 ((x − y) % P + P) % P,先取模再加 P 再取模,避免负数。

· 全程整数运算,不需要 EPS,也不会有精度问题。

代码见"拓展一"的 gauss_mod()。

易错:① 乘法溢出(两个 < 10⁹ 的数相乘达到 10¹⁸,需要用 long long);② 减法取模写成 (x − y) % P 得到负数;③ 忘了在主元为 0 时 continue(模意义下必须有此分支)。

C2编程题 · 行列式

给定一个 n 阶整数方阵(n ≤ 200),求它的行列式对 P = 10⁹+7 取模的结果。

查看参考答案

思路:高斯消元把矩阵化成上三角,行列式 = 对角线上元素的乘积(在过程中记录符号变化)。

三条关键性质:

· 倍加(Rj += k·Ri):行列式不变 → 消元操作随便用。

· 交换两行:行列式变号 → 每次 swap 都要 det = (P - det) % P。

· 倍乘一行:行列式乘 k → 求 det 时不要做归一化,否则要额外记录倍率。

代码见"拓展二"的 det.cpp。

易错:① 归一化后再乘对角元(重复计了倍率);② 忘了交换行时变号;③ 只消下方却用 i 从 col 开始(把主元行自己也算了)。

延伸:若要求"矩阵的逆",则用 Gauss–Jordan 在右侧拼一个单位阵,消元后右侧即 A−1(对应洛谷 P4783)。

C3选择题(初赛风格)

关于高斯消元法,下列说法正确的有(多选):
A. 选主元时应该选当前列绝对值最大的元素所在行
B. 初等行变换可以交换两列
C. 若增广矩阵消元后出现 0 = 1 的行,则方程组无解
D. 若系数矩阵的秩 r 小于未知数个数 n,则方程组必然有唯一解
E. 高斯消元法的时间复杂度是 O(n³)
F. 对浮点数判断是否为零,应使用 fabs(x) < eps 而不是 x == 0

查看参考答案

答案:A、C、E、F。

A ✓:选绝对值最大的主元能避免除以接近 0 的数,把倍数控制在 ≤ 1,抑制误差放大。

B ✗:初等行变换只能动行。交换两列等于给未知数重新编号,会得到另一个方程组。列交换只在"全主元"等特殊算法里出现,且必须记录置换。

C ✓:系数全 0 而常数非 0,意味着"0 乘任何数都等于 0,却要求它等于 1",矛盾,故无解。

D ✗:r < n 说明有 n − r 个自由变量,是无穷多解(若同时存在矛盾行则无解),绝不可能是唯一解。

E ✓:三重循环:n 列 × n 行 × 更新 n+1 个元素,O(n³)。回代阶段 O(n²) 被主项吸收。

F ✓:浮点运算存在舍入误差,x == 0 几乎永远不成立,必须用 EPS 容差判断。

命题人视角:B 和 D 是历年最容易混淆的两个陷阱。记牢:「只动行,不动列;秩不满,无穷解」。

C4编程题 · 结构优化

在 n 个点组成的链式图上做随机游走(i 与 i+1 相邻,1 号点反射、n 号点吸收),n ≤ 10⁵,要求实时回答 q 次询问"从某个点 s 出发的期望步数"(q ≤ 10⁵)。请给出算法并分析复杂度。

查看参考答案

关键观察:方程组的系数矩阵是三对角矩阵(只有主对角线 and 左右相邻斜线非零),其余都是 0。用稠密高斯消元是 O(n³) = 10¹⁵,完全不可能。

方案:用 O(n) 的追赶法(Thomas 算法)一次解出全部 Ei,然后每次询问 O(1) 回答。

追赶法思路:方程形如 biEi−1 + aiEi + ciEi+1 = di。

· 正向消元:令 u = ai − bi·c'i−1,则 c'i = ci/u,d'i = (di − bi·d'i−1)/u。

· 反向回代:En = d'n,Ei = d'i − c'i·Ei+1。

整个过程只扫两遍数组,O(n) 求出所有 Ei,之后每次询问 O(1)。

总复杂度:O(n + q) = 2×10⁵,轻松通过。

本题的启发:高斯消元是"通用武器",但看清矩阵结构(稀疏、带状、对称)往往能把复杂度降一个甚至几个数量级。竞赛中拿到题先问自己一句:这个矩阵长什么样?

更一般地:若矩阵带宽为 w,用带状消元复杂度是 O(n·w²)。链式图 w = 1,树形图则是"树上高斯消元"(从叶子往根消、再从根往叶子回代),复杂度 O(n)。

📚洛谷原题精选(12 题)

按难度由浅入深排列。建议先做第 1~3 题把模板打牢,再做第 4~8 题熟悉"异或方程组"的建模,最后挑战第 9~12 题的期望 DP 与图上计数。

1
P3389
【模板】高斯消元法普及+/提高
2
P3857
[TJOI2008] 彩灯普及+/提高
3
P4783
【模板】矩阵求逆提高+/省选-
4
P2455
[SDOI2006] 线性方程组提高+/省选-
5
P4035
[JSOI2008] 球形空间产生器提高+/省选-
6
P2447
[SDOI2010] 外星千足虫提高+/省选-
7
P2962
[USACO09NOV] Lights G提高+/省选-
8
P3164
[CQOI2014] 和谐矩阵提高+/省选-
9
P3211
[HNOI2011] Xor 路径提高+/省选-
10
P2973
[USACO10HOL] Driving Out the Piggies G提高+/省选-
11
P3232
[HNOI2013] 游走(期望 + 消元)提高+/省选-
12
P3317
[SDOI2014] 重建(期望 + 矩阵树)省选/NOI-

💡 做完 P3389 与 P2455 后,你已经掌握了本讲 80% 的考点;P4035 展示了"如何把几何问题转化为线性方程组",P2973 与 P3232 则是"期望 DP + 高斯消元"的经典组合,建议对照知识探究第 7 点再动手。