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\),则路径列表为:

\[(1), (1,2), (1,2,4), (1,3), (2), (2,4), (3), (4) \]

观察相邻两条路径的变化:

  • 有时是在上一条路径末尾添加一个顶点(进入子节点)。
  • 有时是去掉末尾若干个顶点,再添加一个更大的顶点(回溯到某个祖先,然后进入下一个兄弟分支)。

关键量:\(cnt[v]\)

定义 \(cnt[v]\) 为从顶点 \(v\) 出发的所有路径总数(包括单点路径 \((v)\))。则:

\[cnt[v] = 1 + \sum_{v \to u} cnt[u] \]

如果我们知道了 \(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)
  3. (1,2,4)
  4. (1,3)
  5. (2)
  6. (2,4)
  7. (3)
  8. (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;
}
posted @ 2026-10-11 21:05  thenrify  阅读(15)  评论(0)    收藏  举报