文章目录
高斯消元法异或版
- 将加法方程组改为异或方程组,比如说:
aiignore
求解以下包含 4 个变量的异或线性方程组:
1. x1 ∧ x2 ∧ x3 = 0
2. x1 ∧ x3 ∧ x4 = 1
3. x2 ∧ x3 ∧ x4 = 1
4. x3 ∧ x4 = 0
可以计算的结果为:
可以通过代入消元法快速得出结果:
* 由方程 4 可得:x3 = x4。
* 代入方程 2:x1 ∧ x3 ∧ x3 = x1 ∧ 0 = 1,解得 x1 = 1。
* 代入方程 3:x2 ∧ x3 ∧ x3 = x2 ∧ 0 = 1,解得 x2 = 1。
* 代入方程 1:1 ∧ 1 ∧ x3 = 0,解得 x3 = 0,由于 x3 = x4,故 x4 = 0。
最终解为:
* x1 = 1
* x2 = 1
* x3 = 0
* x4 = 0
其实也可以换为高斯方程组的形式,他们的系数为:
1 2 3 4 5
------------------------------------------------------------
1 | 1 1 1 0 0
2 | 1 0 1 1 1
3 | 0 1 1 1 1
4 | 0 0 1 1 0
对于这里的系数可以对他们进行和加法一样的高斯消元,可以得到:
1 2 3 4 5
------------------------------------------------------------
1 | 1 0 0 0 1
2 | 0 1 0 0 1 ===>主元(及对角线)都为1,说明有唯一解,就也可以得到以上结果
3 | 0 0 1 0 0
4 | 0 0 0 1 0
- 依据以上,可以写出模板代码:
算法复杂度分析
时间复杂度:O(n^3)
异或高斯消元的核心是三层嵌套循环:
- 外层循环遍历 n 个变量(主元);
- 中层循环遍历 n 行,寻找当前列的主元;
- 内层循环从第 i 列一直异或到第 n+1 列,逐列消元。
因此总操作次数约为 n × n × n = n^3,即时间复杂度为 O(n^3)。
空间复杂度:O(n^2)
模板代码使用二维数组 graph 存储增广矩阵,大小为 (n+1) × (n+2),因此空间复杂度为 O(n^2)。
为什么 n=1000 时需要 bitset 优化?
当 n=1000 时,朴素 O(n^3) 算法需要执行约 10^9 次异或操作,在竞赛环境下很容易超时。
bitset 的优化思路是:把矩阵的每一行看成一个长度为 n 的 0/1 位串,用 C++ 标准库的 bitset 存储。这样,原本需要循环 n 次才能完成的一整行异或操作,现在只需一条 CPU 指令即可完成(row1 ^= row2),速度提升约 32 倍甚至 64 倍,从而把 10^9 次操作压缩到可接受的范围。
这也是题目三(洛谷 P2447 外星千足虫)中 N 最大为 1000、M 最大为 2000 时,必须使用 bitset 加速的根本原因。
下面用一张 ASCII 示意图直观对比普通二维数组逐位异或与 bitset 整行异或的差异:
text
普通二维数组:逐位异或(需要 n 次循环)
┌─────────────────────────────────────────────────────────────┐
│ row1: 1 0 1 1 0 1 0 1 ... 1 0 1 1 0 1 0 1 │
│ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ ⊕ │
│ row2: 0 1 1 0 1 0 1 0 ... 0 1 1 0 1 0 1 0 │
│ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ │
│ result:1 1 0 1 1 1 1 1 ... 1 1 0 1 1 1 1 1 │
│ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ │
│ 第1位 第2位 第3位 第4位 ... 第n位(每个位置都要单独循环一次)│
└─────────────────────────────────────────────────────────────┘
循环次数 = n 次(n = 1000 时,一行就要循环 1000 次)
bitset:整行异或(只需 1 条指令)
┌─────────────────────────────────────────────────────────────┐
│ row1: 10110101...10110101 (一整行 0/1 串,打包成一个整数)│
│ ⊕ │
│ row2: 01101010...01101010 (一整行 0/1 串,打包成一个整数)│
│ ↓ │
│ result:11011111...11011111 (一次异或,整行同时完成) │
│ ↑ │
│ 一条 CPU 指令(row1 ^= row2),n 位同时参与运算 │
└─────────────────────────────────────────────────────────────┘
循环次数 = 1 次(n = 1000 时,一行只需 1 条指令)
加速倍数 ≈ 32 倍(32 位机器)~ 64 倍(64 位机器)
从图中可以直观看到:普通二维数组需要逐位循环 n 次才能完成一行异或,而 bitset 把整行 0/1 串打包成一个整数,一条指令即可完成全部 n 位的异或运算,这正是 n=1000 时性能提升 32~64 倍的关键所在。
cpp
#include <bits/stdc++.h>
using namespace std;
static int MAXN = 101;
static vector<vector<int>> graph;
// 高斯消元解决异或方程组模板
static void guass(int n) {
// i 代表当前正在处理哪一列(哪个变量),也是当前想要确定主元的行
for (int i = 1; i <= n; i++) {
// j 是用来寻找主元的行指针
for (int j = 1; j <= n; j++) {
// 核心技巧:如果 j 在 i 的上面(即 j < i),并且那一行已经被处理成对角矩阵了(mat[j][j]==1),
// 说明这行已经被用过了,直接跳过,继续往下找可以作为主元的行。
if (j < i && graph[j][j] == 1) {
continue;
}
// 找到当前列(第 i 列)为 1 的行,将这一行交换到当前第 i 行作为主元行
if (graph[j][i] == 1) {
swap(graph[i], graph[j]);
break; // 交换完主元后,跳出找主元的循环
}
}
// 如果第 i 行第 i 列是 1,说明找到了有效主元,开始消元
if (graph[i][i] == 1) {
// 遍历所有行,把其他行第 i 列的元素消成 0
for (int j = 1; j <= n; j++) {
// 跳过自己那一行,只要其他行第 i 列有 1,就用异或运算消掉它
if (i != j && graph[j][i] == 1) {
// 从第 i 列开始一直异或到最后一列(n+1列为常数项)
// 前面的列都是0,异或也没意义,所以 k 从 i 开始即可
for (int k = i; k <= n + 1; k++) {
graph[j][k] ^= graph[i][k];
}
}
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n; // 输入变量个数(也是方程个数)
// 开辟 n+1 行,n+2 列的二维数组(下标从1开始,最后一列 n+1 存常数项)
graph.assign(n + 1, vector<int>(n + 2, 0));
// 读入增广矩阵:前 n 列是系数,第 n+1 列是等号右边的常数
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n + 1; j++) {
cin >> graph[i][j];
}
}
// 调用高斯消元函数进行消元
guass(n);
// 输出消元后的矩阵,此时最后一列就是方程组的解
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n + 1; j++) {
cout << graph[i][j] << " ";
}
cout << endl;
}
return 0;
}
看几个好题
- 题目一:
模版题目一 - 题目描述如下:
高斯消元法求解完美平方数问题
问题描述
朱和 772002 都擅长数学。有一天,朱想测试 772002 的能力,于是请 772002 解一道数学题。
但 772002 和他的女朋友有个约会。所以 772002 把这个问题交给了你。
有个数字 a1, a2, ..., an。每个数字的质数值不超过 2000,你可以选一个数字并将其相乘,然后得到一个数字 b。
有多少种不同的选择方式可以使 b 成为一个完美平方数。答案可能太大了,所以你应该以模 1000000007 来输出。
输入
第一行是一个正整数 T,表示存在 T 个测试用例。
对于每个测试用例:
第一行包含一个数字 n(1 <= n <= 300),下一行有 n 个数字 a1, a2, ..., an(1 <= ai <= 10^18)。
输出
对于第 i 个测试用例,首次输出 Case #i: 在单行中。
然后将第 i 个测试用例的答案模除 1000000007。
采样输入
in
2
3
3 3 4
3
2 2 2
采样输出
out
Case #1:
3
Case #2:
3
解题思路
1. 将"乘法"转化为"异或"
一个数如果是完美平方数,那么它的所有质因数的指数必须是偶数。
例如:12 = 2^2 * 3^1,指数分别是 2(偶)和 3(奇)。如果要让它变成完全平方数,就必须再乘一个含有质因数3的数。
在模 2(异或)的世界里,我们只关心质因数的指数是奇数还是偶数。如果是奇数记为 1,偶数记为 0。
2. 构造 01 向量
因为 ai <= 10^18,且题目保证质因数不超过 2000。2000 以内的质数总共只有 303 个。
对于输入的每一个数字 ai,我们把它分解质因数,然后构造出一个只包含 0 和 1 的向量(长度为 303)。例如数字 3,它的向量就是 0, 1, 0, 0...(只有代表质数 3 的位置是 1)。
这 303 个质数就是方程的"变量"。
3. 建立异或方程组并消元
我们要从 n 个数中选出一部分相乘,相当于把这 n 个01向量进行异或操作。
要让乘积为完全平方数,就是要让选出的所有向量异或之后,结果为全0。
所以,我们把每个数字的01向量当成方程的一行,构成一个 n 行、303列的矩阵,然后直接套用左神的异或高斯消元法(guass)来化简矩阵,求出矩阵的秩(Rank)。
4. 计算最终答案
通过高斯消元,我们可以得到矩阵的秩 r。
一共有 n 个数,其中有 r 个是"关键变量"(主元),剩下的 n - r 个就是"自由变量"。
自由变量可以任意选或不选(有 2^(n-r) 种选法)。
一旦自由变量确定,主元的选择是唯一的(为了抵消掉自由变量产生的异或结果,让最终结果为全0)。
题目要求"不同的选择方式",且不能一个都不选(不选不构成有效的 b),所以答案就是:
2^(n-r) - 1
最后用快速幂计算 2^(n-r),再减1,并取模 1000000007 即可。
代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
static int MAXN = 305; // 题目要求 n <= 300
static vector<vector<int>> graph;
// 高斯消元解决异或方程组模板(完全保留原版变量名)
static void guass(int n) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (j < i && graph[j][j] == 1) {
continue;
}
if (graph[j][i] == 1) {
swap(graph[i], graph[j]);
break;
}
}
if (graph[i][i] == 1) {
for (int j = 1; j <= n; j++) {
if (i != j && graph[j][i] == 1) {
for (int k = i; k <= n + 1; k++) {
graph[j][k] ^= graph[i][k];
}
}
}
}
}
}
vector<int> primes;
bool is_composite[2005];
void init_primes() {
for (int i = 2; i <= 2000; i++) {
if (!is_composite[i]) {
primes.push_back(i);
if ((long long)i * i <= 2000) {
for (int j = i * i; j <= 2000; j += i) {
is_composite[j] = true;
}
}
}
}
}
long long quick_pow(long long a, long long b, long long mod) {
long long res = 1;
while (b) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
b >>= 1;
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
init_primes();
int T;
cin >> T;
for (int t = 1; t <= T; t++) {
int n;
cin >> n;
// 开辟 n+1 行,n+2 列的二维数组(下标从1开始)
graph.assign(n + 1, vector<int>(n + 2, 0));
for (int i = 1; i <= n; i++) {
long long a;
cin >> a;
for (int p = 0; p < primes.size(); p++) {
int cnt = 0;
while (a % primes[p] == 0) {
cnt ^= 1;
a /= primes[p];
}
if (cnt == 1) {
// 因为 guass(n) 只处理前 n 列,所以质数编号小于 n 的填入系数列
if (p < n) {
graph[i][p + 1] = 1;
} else {
// 超过前 n 列的质数,强行算作常数项(放在 n+1 列)进行处理
graph[i][n + 1] ^= 1;
}
}
}
}
guass(n);
int rank = 0;
for (int i = 1; i <= n; i++) {
bool all_zero = true;
for (int j = 1; j <= n + 1; j++) {
if (graph[i][j] == 1) {
all_zero = false;
break;
}
}
if (!all_zero) {
rank++;
}
}
int free_vars = n - rank;
long long ans = (quick_pow(2, free_vars, 1000000007) - 1 + 1000000007) % 1000000007;
cout << "Case #" << t << ":\n" << ans << "\n";
}
return 0;
}
- 题目二:
模版题目二
思考:
aiignore
首先我们根据题意可以假想出这样一个一共有n个点,m条无向边,每个点的初始状态都是0
根据题意可以操作任意一个点,操作后该点以及相邻点的状态都会改变
最终是希望所有点都变成1状态,那么可能会若干方案都可以做到
那么其中存在需要最少操作次数的方案,打印这个最少操作次数
题目保证一定能做到所有点都变成1状态,并且没有重边和自环,所以我们可以假想出这样一个图来:
1 --------------------------------------------- 4
\ \ / /
\ \ / /
\ \ / /
2 \ / /
\ \ / /
\ 5 /
\ | /
\ |/
3
对于以上这个图,我们1~5的原始状态均为0,可以操作任意一个点,操作后该点以及相邻点的状态都会改变,根据这句话,我们可以得到:
对于1这个点而言,他自己变会导致自己也改变2,5,4改变也会让他改变,然而1和他相邻的点同时改变就相当于没改变
所以我们可以用异或来形容这些状态的改变(由于所有点的初始状态均为 0,而最终要求所有点都变成 1,因此每个点必须恰好被改变奇数次。
对于任意一个点 i,能够影响它的操作包括:
* 操作点 i 自己;
* 操作所有与点 i 相邻的点。
由于状态改变两次会回到原状态,因此可以使用异或表示状态变化。):
x1 ⊕ x2 ⊕ x4 ⊕ x5 = 1;(点3的改变不会影响1的状态)
根据这样的规律,我们可以列出异或版的高斯消元方程:
* 点1 与 2、4、5 相连(自己也算一个,因为操作自己会改变自己)
* 点2 与 1、3相连
* 点3 与 2、4、5 相连
* 点4 与 1、3、5 相连
* 点5 与 1、3、4 相连
所以:
设 x_i 表示是否操作点 i(1 表示操作,0 表示不操作),每个点的最终状态要变成 1(初始为 0,所以需要改变奇数次)。
对于点 1,能影响它的点是:{1, 2, 4, 5},所以方程是:
x1 ⊕ x2 ⊕ x4 ⊕ x5 = 1
对于点 2,影响它的点是:{1, 2, 3},所以:
x1 ⊕ x2 ⊕ x3= 1
点 3,影响它的点是:{2, 3, 4, 5}:
x2 ⊕ x3 ⊕ x4 ⊕ x5 = 1
点 4,影响它的点是:{1, 3, 4, 5}:
x1 ⊕ x3 ⊕ x4 ⊕ x5 = 1
点 5,影响它的点是:{1, 3, 4, 5}:
x1 ⊕ x3 ⊕ x4 ⊕ x5 = 1
写成矩阵的形式就是:
[1 1 0 1 1 | 1]
[1 1 1 0 0 | 1]
[0 1 1 1 1 | 1]
[1 0 1 1 1 | 1]
[1 0 1 1 1 | 1]
根据这样的形式继续消元,就可以得到这种图形下的操作次数
而在高斯消元后:
* 如果没有自由变量,则方程组只有唯一解,直接统计所有 x_i 中 1 的个数即可;
* 如果存在自由变量,则说明存在多种操作方案。枚举自由变量(dfs)的取值,并通过回代求出对应的完整方案,统计其中操作次数最少的方案。
最终得到的最小操作次数就是答案。
所以代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
static int MAXN = 37;
static vector<vector<int>> graph; // 增广矩阵,graph[i][j]表示第i个方程中x_j的系数
static vector<int> op; // 存储每个变量的解(0或1)
static int n, ans;
// 异或高斯消元,将矩阵化为行阶梯形
static void guass(int n) {
for (int i = 1; i <= n; i++) { // i表示当前处理到第几个主元
// 寻找第i列的主元(值为1的行)
for (int j = 1; j <= n; j++) {
// 如果j < i且graph[j][j]已经作为主元,跳过已经处理过的行
if (j < i && graph[j][j] == 1) {
continue;
}
// 找到第i列为1的行,交换到第i行
if (graph[j][i] == 1) {
swap(graph[i], graph[j]);
break;
}
}
// 如果graph[i][i]==1,说明找到了主元,用它消去其他行的第i列
if (graph[i][i] == 1) {
for (int j = 1; j <= n; j++) {
if (i != j && graph[j][i] == 1) {
// 第j行异或第i行,消去第j行的第i列
for (int k = i; k <= n + 1; k++) {
graph[j][k] ^= graph[i][k];
}
}
}
}
// 如果graph[i][i]==0,说明第i列是自由变量,跳过
}
}
// 初始化矩阵
static void prepare() {
for (int i = 1; i <= n; i++) {
// 清空第i行
for (int j = 1; j <= n; j++) {
graph[i][j] = 0;
}
// graph[i][i] = 1:操作自己会影响自己
graph[i][i] = 1;
// 方程右边为1:目标是把灯从0变成1
graph[i][n + 1] = 1;
op[i] = 0;
}
}
// DFS枚举自由变量,从第n个变量往前搜索
// i: 当前处理的变量编号(从n到1)
// num: 当前已选择的操作次数
static void dfs(int i, int num) {
// 剪枝:如果当前操作数已经大于等于已知最优解,没必要继续
if (num >= ans) return;
// 所有变量都处理完了,更新答案
if (i == 0) {
ans = num;
return;
}
// 如果graph[i][i] == 0,说明第i个变量是自由变量(没有主元)
if (graph[i][i] == 0) {
// 自由变量可以取0或1,分别搜索
// 情况1:取0
op[i] = 0;
dfs(i - 1, num);
// 情况2:取1
op[i] = 1;
dfs(i - 1, num + 1);
} else {
// graph[i][i] == 1,说明第i个变量是非自由变量(有主元)
// 根据方程 graph[i][i]*x_i + ... = graph[i][n+1]
// 因为graph[i][i]=1,所以 x_i = graph[i][n+1] XOR (其他已知变量的和)
int res = graph[i][n + 1]; // 方程右侧的值
// 异或上所有已知变量(j > i的变量已经确定)
for (int j = i + 1; j <= n; j++) {
if (graph[i][j] == 1) {
res ^= op[j];
}
}
op[i] = res;
dfs(i - 1, num + res);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// 分配空间
graph.assign(MAXN, vector<int>(MAXN));
op.resize(MAXN);
// 输入灯的数量
cin >> n;
prepare();
// 输入边数
int m;
cin >> m;
for (int i = 1, u, v; i <= m; i++) {
cin >> u >> v;
// 无向边:操作u会影响v,操作v会影响u
graph[u][v] = 1;
graph[v][u] = 1;
}
// 高斯消元
guass(n);
// 检查是否有自由变量
// 如果所有graph[i][i]都是1,说明有唯一解,没有自由变量
int sign = 1;
for (int i = 1; i <= n; i++) {
if (graph[i][i] == 0) {
sign = 0;
break;
}
}
if (sign == 1) {
// 没有自由变量,唯一解
// 答案就是所有graph[i][n+1]为1的个数(因为每个变量取1就操作一次)
ans = 0;
for (int i = 1; i <= n; i++) {
if (graph[i][n + 1] == 1) {
ans++;
}
}
} else {
// 有自由变量,需要DFS枚举所有可能,找最少操作次数
ans = n; // 初始化答案为n(最大可能操作数)
dfs(n, 0);
}
cout << ans << endl;
return 0;
}
- 题目三:
模版题目三
思考:
aiignore
解题思路
这道题(洛谷 P2447 外星千足虫)的核心其实是"异或高斯消元"。
第一步:把"虫子"变成"方程"
题目说:足总数是奇数还是偶数(模 2)。
这本质上是告诉你:如果足数只有 0(偶数)和 1(奇数)两种状态,那"加法"就是"异或(XOR)"。
假设 x1, x2, ..., xN 代表第 i 只虫子,如果是奇数就是 1,偶数就是 0。
比如 Charles 选了虫子 1、2、3 放进机器,结果告诉你是 1(奇数)。
那么这个事实就可以写成方程:x1 异或 x2 异或 x3 = 1 (异或和为 1)。
我们一共有 M 条这样的方程,就有 M 行,每行包含 N 个系数(0 或 1)以及一个常数(0 或 1)。
第二步:利用异或的特性进行"高斯消元"
平时解线性方程组用的是加减乘除,这里只有 0 和 1,所以变成了按位异或:
1. 消元手段:把两行整体异或在一起(row1 异或 row2)。这就像把两个方程加起来。
2. 目标:像解二元一次方程组一样,把矩阵变成对角矩阵(即第一行只有 x1,第二行只有 x2...)。
3. 最终形态:如果消完后,对角线上全为 1,说明有唯一解。如果消着消着,发现某一列的主元找不到了(全为 0),那说明这个变量是"自由变量",也就是多解(Cannot Determine)。
第三步:巧妙地回答"第 K 次统计就能确定"(这是本题最妙的地方)
题目问:在第几次操作后,就足以确定唯一解了?
平时解方程,我们是等所有数据都给完了才消元。
但这道题,数据是一行一行顺序输入的。
所以做法是:边输入,边往上叠。我们在消元的过程中,如果为了得到主元,交换到了第 j 行,那说明我们必须用到第 j 次实验的结果。
所以,我们在寻找主元交换行时,记录下用到的最大的行号 j(need = max(need, j))。这个最大的行号,就是我们需要的最少实验次数!
第四步:巧用"位图(Bitset)加速"
因为 N 最大有 1000,M 最大有 2000。
如果是普通的二维数组,消元时每次异或都要循环 1000 次,复杂度 2000 * 1000 * 1000 = 2 * 10^9,必超时。
所以用了 bitset:
把每一行的系数(0/1 串)当成一整个长整数。
在 C++ 里,异或整个 bitset(row1 ^= row2)只需要 CPU 一条指令(甚至多条指令并行),速度直接快了 32 倍甚至 64 倍,完美卡进时间限制。
一句话总结解题思路:
把虫子的奇偶性当成未知数,把每次实验当成异或方程。用位图优化的异或高斯消元解方程。消元时记录要用的最大行号,消完后看对角线,对角线上全是 1 就是有唯一解(输出行号和答案),否则就是多解。
代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2002;
bitset<MAXN> mat[MAXN];
int n, m, s;
int need;
int get(int row, int col) {
return mat[row][col] == 1 ? 1 : 0;
}
void eor(int row1, int row2) {
mat[row2] ^= mat[row1];
}
void gauss(int n) {
need = 0;
for (int i = 1; i <= n; i++) {
for (int j = i; j <= n; j++) {
if (get(j, i) == 1) {
// 直接用 C++ 标准库自带的 swap 函数交换两行
swap(mat[i], mat[j]);
need = max(need, j);
break;
}
}
if (get(i, i) == 0) {
return;
}
for (int j = 1; j <= n; j++) {
if (i != j && get(j, i) == 1) {
eor(i, j);
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
s = max(n, m);
for (int i = 1; i <= m; i++) {
string line;
cin >> line;
for (int j = 1; j <= n; j++) {
if (line[j - 1] == '1') {
mat[i].set(j);
}
}
int val;
cin >> val;
if (val == 1) {
mat[i].set(s + 1);
}
}
gauss(s);
int sign = 1;
for (int i = 1; i <= n; i++) {
if (get(i, i) == 0) {
sign = 0;
break;
}
}
if (sign == 0) {
cout << "Cannot Determine" << endl;
} else {
cout << need << endl;
for (int i = 1; i <= n; i++) {
if (get(i, s + 1) == 1) {
cout << "?y7M#" << endl;
} else {
cout << "Earth" << endl;
}
}
}
return 0;
}