Interactive Graph (Hard Version)
Interactive Graph (Hard Version)
字典序+记忆化优化
问题回顾
困难版中,\(n \le 30\),查询次数限制为 \(n+m\) 次。需要确定有向无环图中的所有边。可以询问第 \(k\) 条字典序路径。
核心思想:字典序路径列表 = 字典序树的 DFS 遍历
所有路径按字典序排序,其排列方式与一棵“字典序树”的深度优先遍历完全一致:
- 根节点是空路径。
- 每个顶点 \(u\) 对应一个节点,它的子节点是所有从 \(u\) 出发的边指向的顶点 \(v\)。
- 从根到某个节点的路径就是图中的一条路径。
- 字典序排序等价于对这棵树进行先序遍历(DFS),先访问节点本身,再按顶点编号从小到大访问子节点。
例如图中有边 \(1 \to 2\),\(1 \to 3\),\(2 \to 4\),则路径列表为:
观察相邻两条路径的变化:
- 有时是在上一条路径末尾添加一个顶点(进入子节点)。
- 有时是去掉末尾若干个顶点,再添加一个更大的顶点(回溯到某个祖先,然后进入下一个兄弟分支)。
关键量:\(cnt[v]\)
定义 \(cnt[v]\) 为从顶点 \(v\) 出发的所有路径总数(包括单点路径 \((v)\))。则:
如果我们知道了 \(cnt[v]\),那么在字典序列表中,所有以 \(v\) 为起点的路径占据连续的一段,长度为 \(cnt[v]\)。当我们需要跳过整个以 \(v\) 为起点的子树时,可以直接前进 \(cnt[v]\) 条路径,而不必逐条询问。
算法流程
维护以下变量:
pre:上一条查询到的路径(顶点编号已减 1,方便数组索引,1-based 存储,pre[1]对应第一个顶点)。path:当前查询到的路径。g:已发现的边(邻接表)。cnt[v]:从顶点 \(v\) 出发的路径总数,初始为-1表示未知。k:下一次要查询的路径编号。m:已发现的边数。
初始化
pre = {0, 1},表示第 1 条路径是单点路径(1)。k = 2,从第 2 条路径开始查询。
主循环
步骤1:查询第 k 条路径,得到 path。
步骤2:如果 path 为空(path.size() == 1,因为有一个占位的 path[0]),说明所有路径已遍历完,退出循环。
步骤3:计算 pre 和 path 的最长公共前缀长度 p:
int p = 0;
int lim = min(pre.size(), path.size());
for (int i = 1; i < lim; i ++) {
if (pre[i] != path[i]) break;
p ++;
}
步骤4:处理回溯。如果 pre.size() - 1 > p,说明上一条路径在公共前缀之后还有多余的顶点。这意味着我们刚刚完成了以 pre[p + 1] 为根的整个子树的遍历。此时调用 calc(pre[p + 1]) 计算该顶点的 cnt。
if (pre.size() - 1 > p) calc(pre[p + 1]);
calc(u) 递归计算 cnt[u] = 1 + sum(cnt[to]),其中 to 是 g[u] 中已发现的出边邻居。
步骤5:发现新边。如果 p > 0,说明当前路径与上一条路径共享前缀,并且在 p 位置出现了新顶点。这对应一条新边:
if (p > 0) {
g[path[p]].push_back(path[p + 1]);
m ++;
}
为什么?因为 pre 和 path 的前 p 个顶点相同,而 path 在第 p + 1 个位置有一个新顶点 path[p + 1],它一定是由前一个顶点 path[p] 通过一条边到达的。
步骤6:决定下一次查询的位置 k。
- 当前路径在公共前缀之后的第一个顶点是
nxt = path[p + 1]。 - 如果
cnt[nxt] == -1,说明这个顶点的子树还没探索过,我们需要逐条询问来探索它,所以k++(只前进 1 条路径)。 - 否则,
cnt[nxt]已知,我们可以直接跳过整个子树,所以k += cnt[nxt]。
int nxt = path[p + 1];
if(cnt[nxt] != -1) k += cnt[nxt];
else k ++;
步骤7:更新 pre = path,继续下一次循环。
为什么这样能发现所有边?
- 当
p > 0时,我们发现了边path[p] -> path[p + 1]。这是因为字典序树中,从上一个路径回溯到公共前缀后,进入了新的子节点,必然对应一条新边。 - 当
pre.size() - 1 > p时,说明上一个路径的子树已经遍历完毕,我们可以计算cnt,为后续跳过做准备。 - 通过
cnt跳过已知子树,避免逐条询问,从而将查询次数压缩到 \(n+m\)。
为什么查询次数是 \(n+m\)?
- 每次查询要么发现一条新边(
p > 0),要么首次遇到一个新顶点(cnt未知,k++只前进 1)。 - 每个顶点恰好被首次探索一次(共 \(n\) 次)。
- 每条边恰好被发现一次(共 \(m\) 次)。
- 其他情况(
cnt已知)会直接跳过整个子树,不消耗额外查询。 - 因此总查询次数恰好为 \(n + m\),满足困难版限制。
举例说明
假设图有边:\(1 \to 2\),\(1 \to 3\),\(2 \to 4\)。顶点编号 1,2,3,4。
路径列表(字典序):
(1)(1,2)(1,2,4)(1,3)(2)(2,4)(3)(4)
模拟算法(顶点编号已减 1,所以实际存储为 0,1,2,3):
- 初始
pre = {0, 1}(即(1)),k = 2。 - 查询
k=2,得到path = {0, 1, 2}即(1,2)(减 1 后)。p = 1(公共前缀(1))。pre.size() - 1 = 1,p = 1,所以pre.size() - 1 > p不成立。p > 0,发现边path[1] -> path[2]即1 -> 2,m++。nxt = path[2] = 2,cnt[2]未知,所以k++变成 3。pre = {0, 1, 2}。
- 查询
k=3,得到path = {0, 1, 2, 4}即(1,2,4)。p = 3(公共前缀(1,2,4)全部相同?不对,应该是p = 3表示前 3 个相同)。- 实际
pre = {0,1,2},path = {0,1,2,4},lim = 3,循环i=1,2都相同,p = 2。 pre.size() - 1 = 2,p = 2,不回溯。p > 0,发现边path[2] -> path[3]即2 -> 4,m++。nxt = path[3] = 4,cnt[4]未知,k++变成 4。pre = {0,1,2,4}。
- 查询
k=4,得到path = {0, 2}即(1,3)。lim = 2,i=1时pre[1]=1,path[1]=2,不同,p = 0。pre.size() - 1 = 3 > p = 0,回溯:calc(pre[1])即calc(1)。计算cnt[1] = 1 + cnt[2](此时g[1]只有边1->2)。cnt[2]未知,递归calc(2),cnt[2] = 1 + cnt[4],cnt[4]未知,递归calc(4),cnt[4] = 1(叶子)。所以cnt[2] = 2,cnt[1] = 3。p = 0,不发现新边。nxt = path[1] = 2,cnt[2] = 2已知,k += 2,k从 4 变成 6。pre = {0, 2}。
- 查询
k=6,得到path = {1}即(2)。lim = 1,不进入循环,p = 0。pre.size() - 1 = 1 > 0,回溯:calc(pre[1])即calc(2)。cnt[2]已知为 2。p = 0,不发现新边。nxt = path[1] = 1,cnt[1] = 3已知,k += 3,k从 6 变成 9。pre = {1}。
- 查询
k=9,返回空,结束。
最终发现所有边,查询次数:6 次。n=4,m=3,n+m=7,符合。
总结
这份题解的核心在于:
- 利用字典序路径列表与字典序树 DFS 遍历的对应关系。
- 通过相邻路径的最长公共前缀
p判断当前状态(回溯、发现新边、进入新子树)。 - 利用
cnt数组记录每个顶点的路径总数,从而跳过整个已探索的子树。 - 保证每个顶点和每条边只被“发现”一次,总查询次数恰好为 \(n+m\)。
这种思路非常巧妙,将交互查询转化为对字典序树的遍历,是解决困难版的关键。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
vector<ll> query(ll k) {
cout << "? " << k << "\n";
cout.flush();
int len; cin >> len;
if(len == -1) exit(0);
vector<ll> a(len + 1);
for(int i = 1; i <= len; i ++) cin >> a[i];
return a;
}
void solve() {
int n; cin >> n;
int m = 0;
vector<ll> cnt(n + 1, -1);
vector<vector<int>> g(n + 1);
function<void(int)> calc = [&](int u){
if(cnt[u] != -1) return;
cnt[u] = 1;
for(int to : g[u]){
calc(to); cnt[u] += cnt[to];
}
};
int k = 2;
vector<ll> pre = {0, 1};
while(1) {
vector<ll> path = query(k);
if(path.size() == 1) break;
int p = 0;
int lim = min(pre.size(), path.size());
for(int i = 1; i < lim; i ++) {
if(pre[i] != path[i]) break;
p ++;
}
if(pre.size() - 1 > p) calc(pre[p + 1]);
if(p > 0) {
g[path[p]].push_back(path[p + 1]);
m ++;
}
int nxt = path[p + 1];
if(cnt[nxt] != -1) k += cnt[nxt];
else k ++;
pre = path;
}
cout << "! " << m << "\n";
for(int i = 1; i <= n; i ++) {
for(int v : g[i]) cout << i << " " << v << "\n";
}
cout.flush();
}
int main() {
ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
int _ = 1; cin >> _;
while(_--) solve();
return 0;
}

浙公网安备 33010602011771号