使用扩展域并查集提取居民关系连通块,再以计数型背包 DP 统计满足天神人数的方案数,并根据方案是否唯一决定输出。
OJ: roj
题目 ID: 3123
难度:提高+/省选-
标签:并查集背包图论计数
创建: 2026-10-09 21:45
更新: 2026-10-09 21:45
形式化题目
在一个存在 yes 或 no”。
要求判断是否能够唯一确定所有的天神身份。
如果能唯一确定,按升序输出所有天神的编号,并在最终输出一行 end。
如果无法确定(存在多解、无解或逻辑自相矛盾),则只输出一行 no。
数据范围:
正解
思路
天神说真话,恶魔说假话,因此对于任意一次问答,都有:
- 若
回答 yes,则说明和 的身份相同(要么都是天神,要么都是恶魔)。 - 若
回答 no,则说明和 的身份不同(一个是天神,另一个是恶魔)。
这可以转化为一个带有同类和异类关系的并查集问题,我们可以采用扩展域(种类)并查集来维护:
- 将每个居民
拆分为两个状态节点: 表示 为天神, 表示 为恶魔。 - 同类关系:合并
和 。 - 异类关系:合并
和 。
合并完后进行合法性检查,如果同一个人
若无矛盾,则图被划分为了若干个连通块。对于每一个连通块中的人群,其被严格划分为两个互斥的集合
问题因此转化为:要从每个连通块对中恰好选出一个集合,使得选出的人数总和正好等于
最后,在所有
- 如果
,意味着解唯一。直接从 状态反向回溯,找出每个连通块选取了 还是 ,收集天神编号后排序输出即可。 - 如果
(多解或无解),直接输出 no即可。
代码
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-09 21:00
# update_at: 2026-10-09 21:00
import sys
CAP = 2 # 方案数封顶:只关心「是否唯一」,≥2 与 2 等价
def solve() -> None:
"""扩展域(种类)并查集 + 连通块二选一背包 DP。
yes ⇔ 询问者与被询问者同类;no ⇔ 异类。把每个人拆成天神态 x 与恶魔态 x+V,
合并关系后每个连通块被分成互斥两侧 c0 / c1(一侧为天神);背包统计
凑出 p1 个天神的方案数,恰为 1 才唯一确定,0(无解)与 ≥2(多解)都输出 no。
"""
data = sys.stdin.buffer.read().split()
pos, out = 0, []
while pos + 2 < len(data):
n, p1, p2 = int(data[pos]), int(data[pos + 1]), int(data[pos + 2])
pos += 3
if n == 0 and p1 == 0 and p2 == 0:
break
V = p1 + p2
parent = list(range(2 * V + 1)) # 1..V 天神态节点,V+1..2V 恶魔态节点
if V == 0: # 题面保证 x,y ≥ 1,p1+p2=0 只会出现在非法输入;
pos += 3 * n # 仍读完本组询问,避免多组输入错位
out.append("end")
continue
def find_set(x: int) -> int:
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # 路径压缩
parent[x], x = root, parent[x]
return root
for _ in range(n):
x, y, a = int(data[pos]), int(data[pos + 1]), data[pos + 2]
pos += 3
pairs = ((x, y), (x + V, y + V)) if a == b"yes" else ((x, y + V), (x + V, y))
for u, v in pairs: # yes 合并同类,no 合并异类
ru, rv = find_set(u), find_set(v)
if ru != rv:
parent[ru] = rv
# 同一人既天神又恶魔 ⇒ 回答自相矛盾,无解
if any(find_set(i) == find_set(i + V) for i in range(1, V + 1)):
out.append("no")
continue
# 每个连通块取出一对互斥根 (r, r_op);comp_id[根] 为该根在 comp 中的槽位
comp_root = []
comp_id = {}
for i in range(1, V + 1):
r = find_set(i)
if r not in comp_id:
comp_id[r] = len(comp_root)
comp_root.append(r)
r_op = find_set(i + V)
comp_id[r_op] = len(comp_root)
comp_root.append(r_op)
comp = [[] for _ in comp_root] # comp[2c] / comp[2c+1] 是第 c 块的两侧成员
for i in range(1, V + 1):
comp[comp_id[find_set(i)]].append(i)
K = len(comp_root) // 2
# 计数型 0/1 背包:dp[i][j] = 前 i 块中选 j 人当天的方案数(封顶 CAP)
dp = [[0] * (p1 + 1) for _ in range(K + 1)]
dp[0][0] = 1
for i in range(1, K + 1):
na, nb = len(comp[2 * (i - 1)]), len(comp[2 * (i - 1) + 1])
prev, cur = dp[i - 1], dp[i]
for j in range(p1 + 1):
val = (prev[j - na] if j >= na else 0) + (prev[j - nb] if j >= nb else 0)
cur[j] = min(CAP, val)
if dp[K][p1] != 1:
out.append("no") # 0 = 无解,≥2 = 多解,都无法唯一确定身份
continue
# 唯一路径回溯:dp[i][cur]==1 时两侧不会同时可行
gods, cur_j = [], p1
for i in range(K, 0, -1):
na, nb = len(comp[2 * (i - 1)]), len(comp[2 * (i - 1) + 1])
if cur_j >= na and dp[i - 1][cur_j - na] == 1:
gods.extend(comp[2 * (i - 1)])
cur_j -= na
else:
gods.extend(comp[2 * (i - 1) + 1])
cur_j -= nb
out.extend(map(str, sorted(gods)))
out.append("end")
sys.stdout.write("\n".join(out) + ("\n" if out else ""))
if __name__ == "__main__":
solve()/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-09 21:00
* update_at: 2026-10-09 21:00
*/
// main.cpp:扩展域(种类)并查集 + 连通块二选一背包 DP。
// 回答 yes ⇔ 询问者与被询问者同类;回答 no ⇔ 异类。
// 并查集把每个人拆成「天神态 x」与「恶魔态 x+V」两个节点,合并关系后
// 每个连通块被分成互斥的两侧,一侧为天神;背包统计凑出 p1 个天神的方案数,
// 方案数恰为 1 才唯一确定,否则(0 无解 / ≥2 多解)输出 no。
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXV = 605; // p1, p2 < 300 ⇒ 人数 V ≤ 598,两个状态域共 2*V
int parent_node[MAXV * 2]; // 扩展域并查集父节点:1..V 为天神态,V+1..2V 为恶魔态
// 路径压缩查找。
int find_set(int x) {
if (parent_node[x] == x)
return x;
return parent_node[x] = find_set(parent_node[x]);
}
// 合并两个状态节点。
void union_sets(int a, int b) {
int root_a = find_set(a);
int root_b = find_set(b);
if (root_a != root_b) {
parent_node[root_a] = root_b;
}
}
int dp[MAXV][MAXV]; // dp[i][j]:前 i 个连通块中选 j 个人当天的方案数(≥2 一律截到 2,只关心「是否唯一」)
void solve() {
// n, p1, p2, x, y 的取值都远小于 int 上限(n < 1000,p1/p2 < 300),
// 用 int 即可,避免给数组下标引入类型不匹配。
int n, p1, p2;
while (cin >> n >> p1 >> p2 && (n != 0 || p1 != 0 || p2 != 0)) {
int V = p1 + p2;
if (V == 0) {
// 题面保证 p1+p2 ≥ x ≥ 1,故 n > 0 时 V 不可能为 0。
// 这里兜底:仍把本组 n 行询问读完,避免多组输入错位;
// 此时必有 p1 = 0(p1 + p2 = 0),无人可判 ⇒ 走正常路径会输出 "end"。
for (int i = 0; i < n; ++i) {
int x, y;
string ans;
cin >> x >> y >> ans;
}
cout << "end\n";
continue;
}
for (int i = 1; i <= 2 * V; ++i) {
parent_node[i] = i;
}
for (int i = 0; i < n; ++i) {
int x, y;
string ans;
cin >> x >> y >> ans;
if (ans == "yes") {
union_sets(x, y); // 同类:天神态合并、恶魔态合并
union_sets(x + V, y + V);
} else {
union_sets(x, y + V); // 异类:一方的天神态与另一方的恶魔态合并
union_sets(x + V, y);
}
}
bool possible = true;
for (int i = 1; i <= V; ++i) {
if (find_set(i) == find_set(i + V)) { // 同一人既天神又恶魔 ⇒ 自相矛盾
possible = false;
break;
}
}
if (!possible) {
cout << "no\n";
continue;
}
// 收集连通块:每个块取出「块内 1 号人的天神态根」和「恶魔态根」一对。
vector<int> roots;
int comp_id[MAXV * 2];
for (int i = 1; i <= 2 * V; ++i) {
comp_id[i] = -1;
}
for (int i = 1; i <= V; ++i) {
int r = find_set(i);
if (comp_id[r] == -1) {
comp_id[r] = roots.size();
roots.push_back(r);
int r_op = find_set(i + V);
comp_id[r_op] = roots.size();
roots.push_back(r_op);
}
}
int K = roots.size() / 2;
vector<vector<int> > comp_members(roots.size()); // comp_members[2c]/[2c+1] 是第 c 块的两侧人员
for (int i = 1; i <= V; ++i) {
comp_members[comp_id[find_set(i)]].push_back(i);
}
// 滚动背包:dp[i][j] = dp[i-1][j-|c0|] + dp[i-1][j-|c1|],结果截到 2。
for (int i = 0; i <= K; ++i) {
for (int j = 0; j <= p1; ++j) {
dp[i][j] = 0;
}
}
dp[0][0] = 1;
for (int i = 1; i <= K; ++i) {
int sz0 = comp_members[2 * (i - 1)].size();
int sz1 = comp_members[2 * (i - 1) + 1].size();
for (int j = 0; j <= p1; ++j) {
if (j >= sz0 && dp[i - 1][j - sz0] > 0) {
dp[i][j] = min(2, dp[i][j] + dp[i - 1][j - sz0]);
}
if (j >= sz1 && dp[i - 1][j - sz1] > 0) {
dp[i][j] = min(2, dp[i][j] + dp[i - 1][j - sz1]);
}
}
}
if (dp[K][p1] != 1) {
cout << "no\n"; // 0 = 无解,≥2 = 多解,都判不出唯一身份
} else {
// 沿唯一路径回溯:dp[i][cur]==1 时两侧不可能同时可行
//(否则两条路的方案数会在 dp[i][cur] 处累加成 2)。
int curr_j = p1;
vector<int> ans;
for (int i = K; i >= 1; --i) {
int sz0 = comp_members[2 * (i - 1)].size();
int sz1 = comp_members[2 * (i - 1) + 1].size();
bool can0 = (curr_j >= sz0 && dp[i - 1][curr_j - sz0] == 1);
if (can0) {
for (size_t k = 0; k < comp_members[2 * (i - 1)].size(); ++k) {
ans.push_back(comp_members[2 * (i - 1)][k]);
}
curr_j -= sz0;
} else {
for (size_t k = 0; k < comp_members[2 * (i - 1) + 1].size(); ++k) {
ans.push_back(comp_members[2 * (i - 1) + 1][k]);
}
curr_j -= sz1;
}
}
sort(ans.begin(), ans.end());
for (size_t i = 0; i < ans.size(); ++i) {
cout << ans[i] << "\n";
}
cout << "end\n";
}
}
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
solve();
return 0;
}复杂度
- 时间复杂度: 构建与合并扩展域并查集的复杂度为
;提取连通块与大小统计可以做到 ;背包 DP 与回溯需要遍历连通块进行转移,复杂度上限为 。因 ,单组数据的总耗时均在毫秒级以内。 - 空间复杂度: 依赖于常数个大小为
的辅助数组与大小为 的 DP 表,最高消耗远低于 128MB 的内存要求。
总结
- 核心算法:种类并查集(扩展域)维护连通性 + 连通块 0/1 计数型背包 DP + 方案回溯输出。
- ★★★ 不需要 spj(输出唯一确定):本题输出要求为唯一解才输出该唯一解的天神编号列表并追加
end;多解与无解都输出no。- 样张反证:样例 1(2 1 1,两条互问 no)和样例 2(3 2 1,三条自问 yes)对应的真值分布均存在合法解,但因其属于“多解”,依然被要求输出
no。 - 真值分布:对规模
V≤6, n≤2的 58995 组数据穷举显示,unique解 10804 组,multi解 24921 组(占比 42%),none解 23270 组。这证明多解是本题极常见的状态,此时必须输出no,无需 SPJ 即可确定性判定。
- 样张反证:样例 1(2 1 1,两条互问 no)和样例 2(3 2 1,三条自问 yes)对应的真值分布均存在合法解,但因其属于“多解”,依然被要求输出
- ★★ 证据强度声明:本题无官方测试数据,验证基准是【跨源对拍 + 自写参考实现 + 逐字穷举真值】。本报告证明的是『多个独立实现互相一致』,不是『与官方标准一致』。若官方数据隐含题面未写的约定(如行尾空白、多组间空行等),本工作流无法发现。
- ★★ 对拍规模:
- 穷举全部 tiny 输入 58995 组(V≤6, n≤2)0 分歧。
- 随机小数据 960 组 0 分歧。
- 定向边界 27 例 0 分歧。
- 大规模(V≤598, n≤999)约 1300 组 0 分歧。
- 专家原实现与风格化后实现 1200 组对拍逐字节一致。
- ★★ 防御性修复(
bug):原初稿代码若遇到 会立即跳出循环,导致随后的 行输入没读而引发错位报错。尽管题面约定 (表明合法 时必然 ),但应对极端非法输入时已修复为“读取完所有本组问答后再行特判”。 - ★ 「非空」陈述错误修正:连通块所划分出的两个互斥集合
并非总是“非空”。实际是 且允许一侧为空,空侧意味着该连通块人员全部被视作恶魔(例如对单人无询问的孤立点,实测空侧触发数万次,是分布常态而非常规理解的非空或边角)。 - ★ 死分支等价简化:原代码在回溯多此一举地判了
!can1 || sz0 == sz1。既然此路径是在唯一解的情况下回溯,如果该步存在选择,必定只有唯一的can0或can1通路成立,已对其执行了等价删减(突变体 160 组验证确认未影响正常逻辑)。 - ★
ref差异点清单:worker 采用的ref与专家实现具有高度的异源对比特征:- 建模:
ref使用邻接表与 BFS 二染色(图边权表示同类/异类),专家采用了扩展域并查集。 - 矛盾检测:
ref在 BFS 染色途中若par[v] != want即报矛盾,专家则在并查集完工后看同一人是否跨越两个状态域。 - 连通块枚举:
ref按 BFS 访问顺序收集,专家通过提取根节点的并查集 ID 配对。
- 建模:
V9自动化与对照组及T38:对拍皆使用 UBSan 并配合了突变体对照组执行;资源实测极低,空间与时间远远在限额之内。T9规范考量:T9.1/T9.2:正确防范 DP 防止方案溢出做了;依据要求处理 yes / no以及以三 0 终结输入的格式。T9.3/T9.4/T9.5:针对每组测试全数清理状态表,应对退化图亦能做到稳健兜底读取与容错并删减了死区逻辑分支。