题单 · 习题解析

洛谷 P2712 摄像头

★ 入度 = 被几个摄像头监视着,Kahn 剥完剩下的就是环里的(判环白送);★★★ 「边反了」只污染输出的一半 —— 「能不能全砸完」这个是非题它 **300/300 答对**(反图的环是同一批);⚠⚠ 「顺手跳过自环」连着两轮撞见(P1330 之后);⚠ 顺带订正题单那句「编号很大要离散化」(题面 x, y ≤ 500)

原题:洛谷 P2712出自 第 31 章 拓扑排序 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

食品店里有 n 个摄像头,这种摄像头很笨拙,只能拍摄到固定位置。 现有一群胆大妄为的松鼠想要抢劫食品店,为了不让摄像头拍下他们犯罪的证据, 他们抢劫前的第一件事就是砸毁这些摄像头。

为了便于砸毁摄像头,松鼠歹徒们把所有摄像头和摄像头能监视到的地方统一编号, 一个摄像头能被砸毁的条件是该摄像头所在位置不被其他摄像头监视。

现在你的任务是帮松鼠们计算是否可以砸掉所有摄像头,如不能则输出还没砸掉的摄像头的数量。

输入格式

第 1 行,一个整数 n,表示摄像头的个数。

第 2 到 n+1 行是摄像头的信息,包括:摄像头的位置 x,以及这个摄像头可以监视到的位置数 m, 之后 m 个数 y 是此摄像头可以监视到的位置(砸了这些摄像头之后自然这些位置就监视不到了)。

输出格式

若可以砸掉所有摄像头则输出 YES,否则输出还没砸掉的摄像头的数量(不带引号)。

说明/提示

1 ≤ n ≤ 1000 ≤ m ≤ 1000 ≤ x, y ≤ 500

输入输出样例

输入

5
1 1 2
2 1 1
3 1 7
4 1 1
5 0

输出

2

1 号在位置 1、监视位置 2;2 号在位置 2、监视位置 1 —— 两个互相盯着,谁也砸不掉。 3 号监视位置 7(那儿没摄像头)、4 号监视位置 1、5 号什么都不监视 ⇒ 这三个都能砸。 ⇒ 剩 2 个。

1★ 关键的一步:入度 = 被几个摄像头监视着

★ 一句话把题面翻译成图

摄像头 A 监视位置 y;如果 y 正好是摄像头 B 所在的位置, 那么 B 必须等 A 被砸了才能砸 ⇒ 连边 A → BB 的入度 +1

能全砸完 ⟺ 这张图无环 ⟺ 拓扑排序能把 n 个点全排出来, 而剩几个就是「排不出来的点数」。

★ 这正是本章那句「判环不用另写一份代码」的直接应用: 队列空了、出队的点却不够 n 个 ⇔ 有环;而这道题连「剩几个」都是白送的。

p2712.cpp★ 这一版就能 AC
// ★ P2712 正解:**入度 = 被几个摄像头监视着**,然后 Kahn 一层层剥 —— 剥不掉的就是环里的。
//
// 题意翻译:摄像头 A 监视位置 y;如果 y 正好是摄像头 B 所在的位置,
// 那么**B 必须等 A 被砸了才能砸** ⇒ 连边 A → B,B 的入度 +1。
// ⇒ 能全砸完 ⟺ 这张图无环 ⟺ 拓扑排序能把 n 个点全排出来。
//
// ⇒ ★ 这就是本章那句「判环不用另写一份代码」的直接应用:
// **队列空了、出队的点却不够 n 个 ⇔ 有环**,而剩下几个就答几个。
//
// ⚠ 三处容易漏的:
// ① 摄像头监视的位置 y **可能根本没有摄像头** ⇒ 那条信息直接扔掉(`at[y] < 0`);
// ② 题面**没有排除自环**(一个摄像头监视自己所在的位置)—— 那它永远砸不掉;
// ③ ⚠ 题面写着 `0 ≤ x, y ≤ 500` ⇒ **位置编号本来就很小,根本不用离散化**,
// 开一个 501 的数组直接查就行(本章题单原来那句「编号很大要离散化」已订正)。
//
// 复杂度 O(n × m + 值域)。题面 n ≤ 100、m ≤ 100 ⇒ 一万条边,随便跑。
#include <bits/stdc++.h>
using namespace std;
const int V = 501; // 位置编号 0 ~ 500
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n), at(V, -1); // at[p] = 位置 p 上是几号摄像头
vector<vector<int>> watch(n);
for (int i = 0; i < n; i++) {
int x, m;
cin >> x >> m;
pos[i] = x;
at[x] = i;
watch[i].resize(m);
for (int& y : watch[i]) cin >> y;
}
vector<vector<int>> g(n);
vector<int> indeg(n, 0);
for (int i = 0; i < n; i++)
for (int y : watch[i]) {
int j = at[y];
if (j < 0) continue; // ① 那个位置没有摄像头,扔掉
g[i].push_back(j);
indeg[j]++; // ② i == j(自环)也照算,于是它永远砸不掉
}
vector<int> box;
for (int i = 0; i < n; i++) if (!indeg[i]) box.push_back(i);
size_t head = 0;
while (head < box.size()) {
int u = box[head++];
for (int v : g[u]) if (--indeg[v] == 0) box.push_back(v);
}
int left = n - (int)box.size();
if (left == 0) printf("YES\n");
else printf("%d\n", left);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 顺带订正本章题单里那句话

第 31 章题单原来给这道题写的是「编号很大要离散化」。 可题面白纸黑字写着 0 ≤ x, y ≤ 500 —— 位置编号本来就小, 开一个 501 的数组直接查就行,一点离散化都不用

⇒ 题单那句已改。★ 又一次「本章原题的解析页该回去查一遍正文 / 题单」—— 上一轮 P1330 刚订正过一句,这一轮又一句。 ⚠ 而真正没被题面排除的是自环(摄像头监视自己所在的位置),见第 ④ 步。

2★ 对拍的参照物:照定义反复扫,不建图

p2712Brute.cpp参照物:反复扫(300 轮不一致 0 轮)

题面怎么说就怎么做:反复扫所有还没砸的摄像头,谁所在的位置不被任何还没砸的摄像头监视就砸掉, 一整轮砸不动就停。O(n²m) = 一百万,随便跑,而且和正解一行代码都不共享

3★★★ 第一个错法:边反了 —— 而它在「能不能全砸完」这个是非题上永远答对

p2712Rev.cpp✗ 边的方向反了(样例打 3)
// ✗ P2712 错法一:**边的方向反了**。
//
// 「摄像头 A 监视着 B 所在的位置」意味着 **B 要等 A 被砸了才能砸** ⇒ 边 `A → B`。
// 反着连就成了「A 等 B」,整张依赖关系倒了个个儿。
//
// ★ 说清楚它算了什么:它解的是**反图**上的同一道题 ——
// 而反图和原图的**环是同一批**(一个环反过来还是环)⇒
// ⚠ **它对「能不能全砸完」这个是非题永远答得对**,只在「剩几个」上才可能错。
// 解析页第 ④ 步量了这件事。
#include <bits/stdc++.h>
using namespace std;
const int V = 501; // 位置编号 0 ~ 500
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n), at(V, -1); // at[p] = 位置 p 上是几号摄像头
vector<vector<int>> watch(n);
for (int i = 0; i < n; i++) {
int x, m;
cin >> x >> m;
pos[i] = x;
at[x] = i;
watch[i].resize(m);
for (int& y : watch[i]) cin >> y;
}
vector<vector<int>> g(n);
vector<int> indeg(n, 0);
for (int i = 0; i < n; i++)
for (int y : watch[i]) {
int j = at[y];
if (j < 0) continue; // ① 那个位置没有摄像头,扔掉
g[j].push_back(i); // ★ 反了
indeg[i]++;
}
vector<int> box;
for (int i = 0; i < n; i++) if (!indeg[i]) box.push_back(i);
size_t head = 0;
while (head < box.size()) {
int u = box[head++];
for (int v : g[u]) if (--indeg[v] == 0) box.push_back(v);
}
int left = n - (int)box.size();
if (left == 0) printf("YES\n");
else printf("%d\n", left);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 300 / 300 —— 是非题它一次都没答错,只在「剩几个」上错
300 轮
「边反了」被抓 73
★ 而它在「能不能全砸完」上答对 300 / 300

两行就能证:把每条边反过来,环还是那些环(一个环反着走仍然是环) ⇒ 「图里有没有环」这个问题对反图和原图是同一个答案 ⇒ 它永远答得对是不是 YES。 它错的只是环外那部分点能不能被剥掉(剥的方向反了)。

⇒ ★★ 这是本书「说清楚一个 bug 算了什么,比说它错了有用得多」的又一个形状: 这个 bug 只污染了输出的一半。 ⚠ 而官方样例恰好抓的就是错的那一半(打 3,答案是 2)。

4⚠⚠ 第二个错法:顺手跳过自环 —— 默认档是精确的 0

p2712Self.cpp✗ if (i == j) continue;(样例照样打 2)
// ✗ P2712 错法二:**顺手把自环跳过了**(`if (i == j) continue;`)。
//
// 一个摄像头如果**监视着自己所在的位置**,它就永远砸不掉(砸它的人会被它拍下来)。
// 题面 `0 ≤ x, y ≤ 500` 从头到尾**没有排除**这种情况。
//
// ⚠⚠ 这和[上一轮 P1330](/sol/p1330/) 那个「跳过自环」是**同一个动作**,
// 而且**同样是题面没排除、生成器又不爱造**的那一类。
// ⇒ ★★ 连着两轮撞见同一个坑:**「顺手写一句无害的防御」是有代价的,
// 那句 `continue` 到底扔掉了什么,得先问一句。**
#include <bits/stdc++.h>
using namespace std;
const int V = 501; // 位置编号 0 ~ 500
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n), at(V, -1); // at[p] = 位置 p 上是几号摄像头
vector<vector<int>> watch(n);
for (int i = 0; i < n; i++) {
int x, m;
cin >> x >> m;
pos[i] = x;
at[x] = i;
watch[i].resize(m);
for (int& y : watch[i]) cin >> y;
}
vector<vector<int>> g(n);
vector<int> indeg(n, 0);
for (int i = 0; i < n; i++)
for (int y : watch[i]) {
int j = at[y];
if (j < 0) continue; // ① 那个位置没有摄像头,扔掉
if (i == j) continue; // ★ 就是这一句
g[i].push_back(j);
indeg[j]++; // ② i == j(自环)也照算,于是它永远砸不掉
}
vector<int> box;
for (int i = 0; i < n; i++) if (!indeg[i]) box.push_back(i);
size_t head = 0;
while (head < box.size()) {
int u = box[head++];
for (int v : g[u]) if (--indeg[v] == 0) box.push_back(v);
}
int left = n - (int)box.size();
if (left == 0) printf("YES\n");
else printf("%d\n", left);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

一个摄像头如果监视着自己所在的位置,它就永远砸不掉。 题面 0 ≤ x, y ≤ 500 从头到尾没有排除这件事。

300 轮 顺手随机(不造自环) 允许自环那一档
真的有自环的轮数 0 200
「跳过自环」被抓 精确的 0 157
⚠⚠ 连着两轮撞见同一个坑

上一轮 P1330 那个「跳过自环」是同一个动作: 题面只保证了别的(那道题是「没有重边」,这道题是位置范围),都没排除自环, 而顺手写的生成器也都不爱造自环

⇒ ★★ 「顺手写一句无害的防御」是有代价的 —— 写下 if (i == j) continue; 之前,先问一句:这道题里「自己指向自己」是什么意思?

★ 而被抓 157 < 有自环 200:差的那 43 轮里,那个摄像头本来就在别的环里, 跳不跳自环它都砸不掉 —— 又一次「满足触发条件 ≠ 一定被抓」。

5⚠ 第三个错法:把「位置编号」直接当成「摄像头编号」

p2712Index.cpp✗ 两套编号搞混了(样例当场越界)
// ✗ P2712 错法三:**把「位置编号」直接当成了「摄像头编号」**。
//
// 输入里的 `x`、`y` 是**位置**,而摄像头是按输入顺序 0..n−1 编号的 —— 两套编号。
// 这一版省掉了 `at[]` 这张查找表,直接拿 `y` 当摄像头下标用。
//
// ⚠ 位置编号能到 500 而摄像头只有 100 个 ⇒ 它还会越界;
// 这一版用 `.at()` 把那个 UB 换成确定的异常,好让它真的现形一次。
// ★ 而**当所有摄像头恰好摆在位置 0..n−1 上时,两套编号重合,它就蒙对了** ——
// 解析页第 ④ 步造了这么一档来验这句话。
#include <bits/stdc++.h>
using namespace std;
const int V = 501; // 位置编号 0 ~ 500
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> pos(n), at(V, -1); // ★ 这一版根本不用 at[]
vector<vector<int>> watch(n);
for (int i = 0; i < n; i++) {
int x, m;
cin >> x >> m;
pos[i] = x;
at[x] = i;
watch[i].resize(m);
for (int& y : watch[i]) cin >> y;
}
vector<vector<int>> g(n);
vector<int> indeg(n, 0);
for (int i = 0; i < n; i++)
for (int y : watch[i]) {
try {
g.at(i).push_back(y); // ★ 直接拿位置编号当摄像头下标
indeg.at(y)++;
} catch (const std::out_of_range&) {
printf("越界了(真机上这里是未定义行为)\n");
return 0;
}
}
vector<int> box;
for (int i = 0; i < n; i++) if (!indeg[i]) box.push_back(i);
size_t head = 0;
while (head < box.size()) {
int u = box[head++];
for (int v : g[u]) if (--indeg[v] == 0) box.push_back(v);
}
int left = n - (int)box.size();
if (left == 0) printf("YES\n");
else printf("%d\n", left);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

输入里的 xy位置(0500),而摄像头是按输入顺序编号的(0n−1)—— 两套编号

300 轮
顺手随机 297
★ 造一档「摄像头恰好摆在位置 0..n−1、且只监视有摄像头的位置」 精确的 0

⇒ ★ 那个 0 不是运气:那一档里两套编号完全重合at[y] 就是 y,两版做的是同一件事。 ⚠ 而这也说明:如果出题人恰好把摄像头摆在 0..n−1 上,这个 bug 就一分不扣 —— 它能不能咬到你,取决于数据长什么样,不取决于代码。

6⚠ 顺带数一数:这一档在验什么

默认档 300 轮里,193 轮的答案是 YES(一大半的图本来就无环)。

⇒ 又一次那条老规矩:「一致」有两种,都算对了和都没算 —— 先数一数这一档里有多少轮问得出你想问的那个问题。 ⚠ 这也解释了为什么「边反了」只被抓 73 次:它在 YES 那 193 轮里必然一致(是非题它永远对)。

7★ 对拍这一页

300 轮(n 随机 3~8,参照物 = 照定义反复扫)
正解 ≡ 暴力 不一致 0 轮
边的方向反了 73(★ 而是非题上 300/300 全对)
跳过自环 精确的 0 →(允许自环那档)157
位置当摄像头编号 297 →(两套编号重合那档)★ 精确的 0
⚠ 这一档答案是 YES 的轮数 193

顶格 n = 100、每个摄像头监视 100 个位置 ⇒ 至多 10 000 条边; 位置编号只到 500 ⇒ 开个 501 的数组就够

8度量程序和生成器

p2712Count.cpp度量程序(本页所有数字都出自它)
p2712Gen.cpp数据生成器

9一页纸

★ 关键的一步 入度 = 被几个摄像头监视着 ⇒ Kahn 剥完,剩下的就是环里的(判环白送)
★★★ 边反了 只污染输出的一半:「能不能全砸完」300/300 答对(反图的环是同一批),只在数量上错
⚠⚠ 跳过自环 默认档精确的 0 —— ★ 连着两轮(P1330)撞见同一个坑
⚠ 两套编号 位置 vs 摄像头下标;造一档让它们重合 ⇒ 那个 bug 精确的 0
⚠ 顺带订正题单 「编号很大要离散化」是错的:题面 x, y ≤ 500开个 501 的数组就够
⚠ 先数一数 默认档 193 / 300 轮答案就是 YES —— 一大半轮次问不出「剩几个」