题单 · 习题解析

洛谷 P1955 [NOI2015] 程序自动分析

★★ 关键的一步:**先并完所有「相等」,再验所有「不等」** —— 还是那句「并查集只能合并不能拆」(和隔壁 [P1197](/sol/p1197/) 的「把时间倒过来」是同一句话的两个用法);★★★ 而「边读边判」那个错法**两组官方样例、四个问题一个都挡不住** —— 原因很具体:**它们的「不等」全都排在最后**,那正好是这个 bug 的盲区;★★★ 「拿取模当离散化」照题面随机**四个档全是 0**(是概率低不是结构性,加轮数没用),★ 而**专门造碰撞**(变量号取 v 和 v+1000003)当场 **297 / 300** ⇒ 又一次「抓不到时别加轮数,去想那条线在哪儿、照着它造」;⚠ 离散化的账:题面变量号 10⁹ vs 一组里用得到的 2n = 2×10⁵,差 **5000 倍**;★ 两个错法方向正好相反(「边读边判」恒把 NO 判成 YES,「多组没清空」恒把 YES 判成 NO)

原题:洛谷 P1955出自 第 36 章 并查集 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1955,日期见页头。两边不一致时信原站。

题目描述

考虑一个约束满足问题的简化版本:假设 x₁, x₂, x₃, ⋯ 代表程序中出现的变量, 给定 n 个形如 xᵢ = xⱼxᵢ ≠ xⱼ 的变量相等 / 不等的约束条件, 请判定是否可以分别为每一个变量赋予恰当的值,使得上述所有约束条件同时被满足。

例如,一个问题中的约束条件为 x₁ = x₂, x₂ = x₃, x₃ = x₄, x₄ ≠ x₁, 这些约束条件显然是不可能同时被满足的,因此这个问题应判定为不可被满足

输入格式

输入的第一行包含一个正整数 t,表示需要判定的问题个数。注意这些问题之间是相互独立的。

对于每个问题:第一行包含一个正整数 n,表示该问题中需要被满足的约束条件个数。 接下来 n 行,每行包括三个整数 i, j, e。 若 e = 1,则该约束条件为 xᵢ = xⱼ;若 e = 0,则该约束条件为 xᵢ ≠ xⱼ

输出格式

输出包括 t 行。第 k 行输出 YESNO(字母全部大写), YES 表示第 k 个问题判定为可以被满足,NO 表示不可被满足。

数据规模与约定

1 ≤ t ≤ 10;对于全部数据,1 ≤ n ≤ 10⁵1 ≤ i, j ≤ 10⁹e ∈ {0, 1}

时限 2 秒,内存 512 MB。

输入输出样例

输入

2
2
1 2 1
1 2 0
2
1 2 1
2 1 1

输出

NO
YES

第一个问题:x₁ = x₂x₁ ≠ x₂ 互相矛盾 ⇒ NO; 第二个问题:两个约束等价 ⇒ YES。 ⚠ 两组官方样例里的「不等」全都排在最后 —— 记住这句话,第 ② 步要用它。

1★★ 关键的一步:先把所有「相等」并完,再回头验所有「不等」

p1955.cpp★ 这一版就能 AC(顶格 t = 10 / 每组 n = 10⁵,本机 294 毫秒 / 时限 2 秒)
// P1955 [NOI2015] 程序自动分析 —— ★ 这一版就能 AC
//
// ★★ 关键的一步是**顺序**:**先把所有 `xᵢ = xⱼ` 全部合并完,再回头验所有 `xᵢ ≠ xⱼ`。**
// 理由一句话:并查集只能合并、不能拆 ——
// 一条「不等」约束在**它出现的那一刻**可能还看不出矛盾,
// 而后面来的「相等」会把两边并到一起。⇒ 边读边判就是错的(本页第 ② 步)。
//
// ⚠ 另一件事:`i, j ≤ 10⁹` 而 `n ≤ 10⁵` ⇒ **必须离散化**(本页第 ③ 步)。
// ⚠ 多组数据,每组之间要清空(本页第 ④ 步)。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005; // 每组最多 2n 个不同的变量
static int fa[N];
static int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r];
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
if (!(cin >> t)) return 0;
string out;
while (t--) {
int n;
cin >> n;
vector<array<int, 3>> c(n);
vector<int> vals;
vals.reserve(2 * n);
for (int k = 0; k < n; k++) {
int i, j, e;
cin >> i >> j >> e;
c[k] = {i, j, e};
vals.push_back(i);
vals.push_back(j);
}
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
int cnt = (int)vals.size();
for (int i = 0; i < cnt; i++) fa[i] = i; // ★ 只清用到的那一段
auto id = [&](int v) {
return (int)(lower_bound(vals.begin(), vals.end(), v) - vals.begin());
};
for (auto& q : c) if (q[2] == 1) fa[find(id(q[0]))] = find(id(q[1])); // ① 先并
bool ok = true;
for (auto& q : c)
if (q[2] == 0 && find(id(q[0])) == find(id(q[1]))) { ok = false; break; } // ② 再验
out += ok ? "YES\n" : "NO\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 为什么顺序不能反:还是那句「并查集只能合并、不能拆」

一条「不等」约束在它出现的那一刻可能还看不出矛盾 —— 把它两边连起来的那些「相等」还在后面没读到。 而并查集只能合并、不能拆 ⇒ 后来的合并没法回头把它判成矛盾。

⇒ 所以必须两趟:第一趟把 e = 1 全部合并,第二趟才逐条验 e = 0。 ★ 这和隔壁 P1197 是同一句话的两个用法: 那道题靠把时间倒过来,这道题靠把约束分成两趟

2★★★ 而「边读边判」那一版,两组官方样例一个都挡不住

p1955Online.cpp✗ 一趟走完,读到哪判到哪(⚠ 两组官方样例照样全对)
// P1955 · 错法 ①:边读边判 —— 顺序错了就全错
//
// e = 1 ⇒ 立刻合并;e = 0 ⇒ **立刻**看两边是不是已经同根,是就判 NO。
// ⚠ 问题在于:一条「不等」出现的那一刻,把它俩连起来的那些「相等」可能**还没读到**。
// 而并查集只能合并、不能拆 ⇒ 后来的合并没法回头把它判成矛盾。
// ★ 它**恒把 NO 判成 YES**(漏判),答案只会偏「宽松」。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005; // 每组最多 2n 个不同的变量
static int fa[N];
static int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r];
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
if (!(cin >> t)) return 0;
string out;
while (t--) {
int n;
cin >> n;
vector<array<int, 3>> c(n);
vector<int> vals;
vals.reserve(2 * n);
for (int k = 0; k < n; k++) {
int i, j, e;
cin >> i >> j >> e;
c[k] = {i, j, e};
vals.push_back(i);
vals.push_back(j);
}
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
int cnt = (int)vals.size();
for (int i = 0; i < cnt; i++) fa[i] = i; // ★ 只清用到的那一段
auto id = [&](int v) {
return (int)(lower_bound(vals.begin(), vals.end(), v) - vals.begin());
};
bool ok = true;
for (auto& q : c) { // ✗ 一趟走完,读到哪判到哪
if (q[2] == 1) fa[find(id(q[0]))] = find(id(q[1]));
else if (find(id(q[0])) == find(id(q[1]))) { ok = false; break; }
}
out += ok ? "YES\n" : "NO\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 原因很具体:两组样例里的「不等」全都排在最后
官方样例 那一组的约束顺序 「边读边判」对不对
样例 1 · 问题一 = 然后 (不等在最后)
样例 1 · 问题二 两条都是 = (一条不等都没有)
样例 2 · 问题一 三条都是 =
样例 2 · 问题二 三条 = 然后一条 (不等在最后)

四个问题全部「不等在最后」 —— 而那正好是这个 bug 的盲区: 不等排在最后时,读到它时相等已经全并完了,一趟和两趟是同一件事。

★ 专门造一档「不等全排在最后」来印证这件事:

300 轮 档 0 顺序打乱 ★★ 档 1 不等全排最后 档 2 只有一组 档 3 变量号 10⁹
「边读边判」被抓 37 0(能证) 27 0

⇒ 又一次「它过了样例的第三种原因:这组样例在结构上问不出这个问题」—— ⚠ 而这一次连两组样例、四个问题都是同一个结构。 ★ 档 3 那个 0 是另一回事:变量号铺到 10⁹ 之后约束几乎互不相干, 两版一起答 YES —— 那是在验零,不是它对。

3⚠ 离散化:题面的 10⁹ 和 n 的 10⁵ 差 5000 倍

题面变量号上限 10⁹
一组里用得到的变量最多 2n = 2 × 10⁵
⇒ 差 5000 倍

⇒ 数组开不到 10⁹,必须离散化(排序 + 去重 + lower_bound)。

p1955Mod.cpp✗ 拿取模当离散化(⚠ 照题面随机的四个档全是 0)
// P1955 · 错法 ②:拿取模当离散化
//
// `fa[i % 1000003]` —— 「反正 i ≤ 10⁹,模一个大质数就当下标好了」。
// ⚠ 这是**哈希**,不是离散化:两个不同的变量模出同一个下标,就被当成了同一个变量。
// ⇒ 又一次[第 49 章那条](/ch/49-hashing/):**哈希会错,而且它错得很安静**。
// ★ 顺手写的生成器变量号只有几十个 ⇒ 这一档是**结构性的精确 0**(本页第 ③ 步)。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005; // 每组最多 2n 个不同的变量
static int fa[N];
static int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r];
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
if (!(cin >> t)) return 0;
string out;
while (t--) {
int n;
cin >> n;
vector<array<int, 3>> c(n);
vector<int> vals;
vals.reserve(2 * n);
for (int k = 0; k < n; k++) {
int i, j, e;
cin >> i >> j >> e;
c[k] = {i, j, e};
vals.push_back(i);
vals.push_back(j);
}
const int MOD = 1000003; // ✗ 拿取模当离散化
for (int v : vals) fa[v % MOD % N] = v % MOD % N;
auto id = [&](int v) { return v % MOD % N; };
for (auto& q : c) if (q[2] == 1) fa[find(id(q[0]))] = find(id(q[1])); // ① 先并
bool ok = true;
for (auto& q : c)
if (q[2] == 0 && find(id(q[0])) == find(id(q[1]))) { ok = false; break; } // ② 再验
out += ok ? "YES\n" : "NO\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 「拿取模当离散化」四个档全抓不到 —— 要专门造碰撞才逼得出来

fa[i % 1000003] —— 「反正 i ≤ 10⁹,模一个大质数就当下标好了」。 ⚠ 这是哈希,不是离散化:两个不同的变量模出同一个下标就被当成了同一个变量 (又一次第 49 章那条:哈希会错,而且错得很安静)。

300 轮 档 0 档 1 档 2 档 3 变量号 10⁹ ★★★ 档 5 专门造碰撞
「取模当离散化」被抓 0 0 0 0 297

★★ 四个档的 0 都不是结构性的,是概率低:一组里只有十几个变量, 从 10⁹ 里随机撞上同余的概率是百万分之几 —— 加多少轮都没用。 ★ 而造它只要一行:变量号成对地取 vv + 1000003。当场 297 / 300。 ⇒ 又一次「抓不到时别加轮数,去想那条线在哪儿、然后照着它造」

4⚠ 多组数据:题面第一句就写着「这些问题之间是相互独立的」

p1955Clear.cpp✗ fa[] 只在开头初始化一次

上一组并出来的关系留在数组里,而下一组离散化之后用的还是 0, 1, 2, … 这些下标 ⇒ 上一组的合并直接污染下一组

300 轮 档 0 档 1 ★ 档 2 只有一组 档 3 档 5
t == 1 的轮数 96 96 300 96
⇒ 「没清空」漏掉的轮数 216 208 300 248 244

★ 档 2 那一列 300 ≡ 300 是能证的(只有一组,没有上一组可以污染); ⚠ 而别的档差得远 —— 上一组的合并不一定改变这一组的结论。 ★ 它恒把 YES 判成 NO(多出来的合并只会制造矛盾), 和第 ② 步那个「边读边判」(恒把 NO 判成 YES)方向正好相反。

★ 顺带一条:清空要「只清用到的那一段」

正解里那一行是 for (int i = 0; i < cnt; i++) fa[i] = i; —— cnt这一组离散化后的变量个数。 ★ 换成每组 memset 整个 2 × 10⁵ 的数组也不慢(t ≤ 10,一共才 2 × 10⁶); ⚠ 但如果数组是按 10⁹ 开的(那本来就不该),每组清一遍就是灾难。 ⇒ 「多组数据要清空」和「清多大一段」是两个问题,后者由你选的存法决定。

5★ 对拍这一页

300 轮(t 随机 13,每组 n 随机 38) 档 0 ★★ 档 1 不等在最后 ★ 档 2 只有一组 档 3 变量号 10⁹ ★★★ 档 5 造碰撞
边读边判 37 0 27 0 0
取模当离散化 0 0 0 0 297
多组没清空 84 92 0 52 56

★★★ 官方样例三个错法一个都没挡住 —— 三个原因各不相同: 「边读边判」是结构上问不出(四个问题的不等全在最后)、 「取模当离散化」是规模不够(变量号才 1、2)、 「多组没清空」是运气(那两组恰好互不干扰)。

6度量程序和生成器

p1955Count.cpp度量程序(本页所有数字都出自它)
p1955Gen.cpp(六个档位)数据生成器

7一页纸

★★ 关键的一步 先并完所有「相等」,再验所有「不等」 —— 还是那句「并查集只能合并不能拆」
★★★ 而样例问不出来 两组样例、四个问题的「不等」全都排在最后 ⇒ 「边读边判」全对
★ 专门造一档才看得见 「不等全排最后」那一档 0(能证),顺序打乱的档抓 37
⚠ 离散化 题面 10⁹ vs 用得到的 2n = 2×10⁵ —— 差 5000 倍
★★★ 拿取模当离散化 照题面随机四个档全是 0(概率低,不是结构性),★ 专门造碰撞当场 297 / 300
⚠ 多组没清空 恒把 YES 判成 NO;t == 1 那一档 300 ≡ 300 是能证的 0
★ 两个错法方向相反 「边读边判」恒把 NO 判成 YES,「没清空」恒把 YES 判成 NO