题目大意
题目描述
有一棵包含 n n n 个节点的无向树,为了让它更有趣,你对它的 n − 1 n-1 n − 1 条边任意指定了方向,将其变成了一棵有向树。
现在你忘记了这棵树的结构和边的方向,但你找到了一份记录,上面写着给边指定方向后,所有节点对之间的可达性。具体来说,给定 n n n 个长度为 n n n 的 01 字符串。如果第 i i i 个字符串的第 j j j 个字符为 1,则表示在有向树中,节点 i i i 可以到达节点 j j j (必然存在一条从 i i i 到 j j j 的有向路径);如果为 0 则表示无法到达。特别地,一个节点总是可以到达它自己。
你的任务是根据这个可达性信息,判断是否存在合法的树结构与边的方向。如果存在,请构造并输出任意一个满足条件的解;如果不存在,请输出 No。本题为 Hard 版本,与 Easy 版本的唯一区别是 n n n 的数据范围更大。
输入格式
第一行包含一个整数 t t t (1 ≤ t ≤ 1 0 4 1 \le t \le 10^4 1 ≤ t ≤ 1 0 4 ),表示测试用例的数量。
每个测试用例的第一行包含一个整数 n n n (2 ≤ n ≤ 8000 2 \le n \le 8000 2 ≤ n ≤ 8 0 0 0 ),表示节点的数量。
接下来 n n n 行,每行包含一个长度为 n n n 的 01 字符串,表示可达性矩阵。
保证所有测试用例中 n 2 n^2 n 2 的总和不超过 800 0 2 8000^2 8 0 0 0 2 。
输出格式
对于每个测试用例,如果存在解,输出 Yes,并在接下来的 n − 1 n-1 n − 1 行中输出构造的有向边。每行包含两个整数 x x x 和 y y y ,表示存在一条从 x x x 指向 y y y 的有向边。如果有多个解,输出任意一个即可。
如果不存在解,输出 No。
(输出 Yes 和 No 时不区分大小写,例如 yEs、YES 都会被接受)。
样例输入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 11 4 1000 1111 1010 0001 4 1111 0111 0010 0111 4 0011 0111 0011 0001 4 1000 0110 0010 1111 4 1000 0110 1010 1111 5 10000 01011 00111 00010 00001 5 10000 11000 10101 10111 00001 5 10000 01101 00100 01110 10001 4 1100 0100 0011 0001 4 1110 0100 0010 0101 3 100 111 101
样例输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 Yes 2 3 2 4 3 1 No No Yes 2 3 4 1 4 2 No No Yes 2 1 3 1 3 5 4 3 No No Yes 1 2 1 3 4 2 Yes 2 3 3 1
样例解释
对于第一个测试用例,给定的矩阵表示:节点 1 1 1 和 4 4 4 只能到达它们自己,节点 2 2 2 可以到达所有节点,节点 3 3 3 只能到达节点 1 1 1 和 3 3 3 。输出构造的边为 2 → 3 2 \to 3 2 → 3 、2 → 4 2 \to 4 2 → 4 、3 → 1 3 \to 1 3 → 1 ,这三条边构成了一棵合法的有向树,并且恰好满足上述可达性限制。
对于第二个测试用例,可以证明不存在任何合法的一棵树能够满足给定的可达性要求,因此输出 No。
思路讲解
Codeforces Round 1086 (Div. 2)——CF-2208-D1. Tree Orientation (Easy Version)
这道题目的简单版本确实是比较 easy 的。
Codeforces Round 1085 (Div. 1 + Div. 2)——CF-2207-E1. N-MEX (Constructive Version)
我觉得和这道题目有一点像,都是要使用这个贪心方法,来解决一些问题 。
具体而言,我们要解决的,就是这个两个循环。我们发现,现在的问题在于,全部的 a,都要把全部的 b 给遍历一遍。这个太耗时耗力了。说白了,这个其实是一个连通性问题,即根节点 i,能不能通过一个中间点 b,访问到这个 a ?当然,我们可以把
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 for (int i=1 ;i<=N;++i) { vector<ll> nodes; for (int j=1 ;j<=N;++j) { if (j==i) continue ; if (Reach[i][j]=='1' ) { nodes.push_back (j); } } for (auto a:nodes) { bool ok=true ; for (auto b:nodes) { if (b==a) continue ; if (Reach[b][a]=='1' ) { ok=false ; break ; } } if (ok) { edges.push_back ({i,a}); } } }
我们可以使用离线思想和排序思想,找到一种顺序,使得先处理顺序靠前的,后处理顺序靠后的,不会造成问题 。
我们可以按照 cnt_reach (即所有可到达点的数量) 降序排序 。
为什么降序排列有效? cnt_reach 最大的节点,其子树规模最大 。在合法树中, i i i 的各个孩子的子树是互不相交的。最大子树的根必定是直系孩子(不可能被更小的子树"遮挡"),所以先处理最大的是安全的 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 for (int i=1 ;i<=N;++i) { vector<ll> nodes; dsu.init (N); for (int j=1 ;j<=N;++j) { if (j==i) continue ; if (Reach[i][j]=='1' ) { nodes.push_back (j); } } ll guard=0 ; sort (all (nodes),[&](const ll &a,const ll &b) { return cnt_reach[a]>cnt_reach[b]; }); for (auto a:nodes) { if (guard>N) { cout<<"No\n" ; return ; } if (!dsu.same (i,a)) { guard+=cnt_reach[a]; edges.emplace_back (i,a); dsu.merge (i,a); for (auto b:node_reach[a]) { dsu.merge (i,b); } } } }
AI对我们做法的总结
虽然我无法访问到当前的文件系统(worktree 似乎已被清理),但根据我们之前的深入讨论和我读过的代码,我可以完整总结我们的做法。
总结:D2. Tree Orientation (Hard Version) 的做法
核心思路
给定一个 n × n n \times n n × n 的可达性矩阵 Reach,要求还原一棵 n n n 个节点的有向树(n − 1 n-1 n − 1 条边)使得可达性与输入一致。
关键观察:如果 Reach[i][j] = '1',说明 i i i 能到达 j j j 。在有向树中,i i i 的直系孩子 c c c 满足:i i i 能到 c c c ,且不存在中间节点 b b b 使得 i → b → ⋯ → c i \to b \to \cdots \to c i → b → ⋯ → c 。
D1 的 O ( N 3 ) O(N^3) O ( N 3 ) 做法是暴力三重循环:对每个 i i i ,枚举候选孩子 a a a ,再枚举所有其他节点 b b b 检查 Reach[b][a] 来判断 a a a 是否被某个 b b b “遮挡”。
D2 的 O ( N 2 ) O(N^2) O ( N 2 ) 优化
第一步:预处理。 对每个节点 i i i ,计算 cnt_reach[i](i i i 能到达的节点数,不含自身)和 node_reach[i](i i i 能到达的节点列表)。
第二步:贪心找直系孩子。 对每个节点 i i i :
将 i i i 能到达的所有节点按 cnt_reach 降序 排列。
初始化一个局部 DSU。
按排好的顺序遍历候选节点 a a a :
若 dsu.same(i, a) 为真,说明 a a a 已经被某个先前确认的孩子的子树覆盖,跳过。
否则,a a a 是 i i i 的一个直系孩子。连边 ( i , a ) (i, a) ( i , a ) ,然后把 node_reach[a] 中的所有节点 merge 进 i i i 的 DSU 分量,同时把 a a a 本身也 merge 进去。
为什么降序排列有效? cnt_reach 最大的节点,其子树规模最大。在合法树中,i i i 的各个孩子的子树是互不相交的。最大子树的根必定是直系孩子(不可能被更小的子树"遮挡"),所以先处理最大的是安全的。
复杂度分析: 对合法输入,每个节点 i i i 的所有直系孩子的子树大小之和恰好等于 ∣ R ( i ) ∣ − 1 |R(i)| - 1 ∣ R ( i ) ∣ − 1 ,即 merge 操作的总量不超过 O ( N ) O(N) O ( N ) 。遍历候选也是 O ( ∣ R ( i ) ∣ ) O(|R(i)|) O ( ∣ R ( i ) ∣ ) 。因此所有节点加起来是 O ( N 2 ) O(N^2) O ( N 2 ) 。
第三步:防御性 guard。 累加实际被选为孩子的节点的 cnt_reach 值(仅对真正的孩子 累加,不是对所有候选节点)。如果这个累加和超过 N N N ,说明输入矩阵不合法,直接输出 “No”。这个 guard 同时也防止恶意输入把复杂度卡到 O ( N 3 ) O(N^3) O ( N 3 ) 。
第四步:验证。 构造完所有边后:
检查边数是否恰好 N − 1 N-1 N − 1 。
用 DSU 检查是否连通(构成一棵树)。
从每个节点 BFS,重建可达性矩阵 R R R ,与输入 Reach 逐位比较。不一致则输出 “No”。
关键修复点(相比 D1 代码的改动)
用 DSU 替代 O ( N 3 ) O(N^3) O ( N 3 ) 枚举 :这是核心优化。不再对每个候选 a a a 暴力枚举所有 b b b 判断 Reach[b][a],而是利用排序 + DSU 在 O ( N 2 ) O(N^2) O ( N 2 ) 内完成。
guard 位置修正 :guard += cnt_reach[a] 必须放在 if (!dsu.same(i,a)) 内部 ,只对实际选中的孩子计数。放在外面会对链状合法输入误判为 “No”。
自身 merge :确认孩子 a a a 后,除了 merge node_reach[a] 的所有节点,还需要 dsu.merge(i, a) 把 a a a 本身也并入。
一句话总结
按子树大小降序贪心 + DSU 快速跳过已覆盖节点 + BFS 回代验证 ,将 D1 的 O ( N 3 ) O(N^3) O ( N 3 ) 传递归约优化为 O ( N 2 ) O(N^2) O ( N 2 ) 。
AC代码
AC
https://codeforces.com/contest/2208/submission/366894699
源代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 #include <bits/stdc++.h> #define all(vec) vec.begin(),vec.end() #define lson(o) (o<<1) #define rson(o) (o<<1|1) #define SZ(a) ((long long) a.size()) #define debug(var) cerr << #var <<" = [" <<var<<"]" <<"\n" ; #define debug1d(a) \ cerr << #a << " = [" ; \ for (int i = 0; i < (int)(a).size(); i++) \ cerr << (i ? ", " : "" ) << a[i]; \ cerr << "]\n" ; #define debug2d(a) \ cerr << #a << " = [\n" ; \ for (int i = 0; i < (int)(a).size(); i++) \ { \ cerr << " [" ; \ for (int j = 0; j < (int)(a[i]).size(); j++) \ cerr << (j ? ", " : "" ) << a[i][j]; \ cerr << "]\n" ; \ } \ cerr << "]\n" ; #define cend cerr<<"\n-----------\n" #define fsp(x) fixed<<setprecision(x) using namespace std;using ll = long long ;using ull = unsigned long long ;using DB = double ;using i128 = __int128;using CD = complex<double >;static constexpr ll MAXN = (ll)1e6 +10 , INF = (1ll <<61 )-1 ;static constexpr ll mod = 998244353 ; static constexpr double eps = 1e-8 ;const double pi = acos (-1.0 );ll lT,testcase; struct DSU { vector<int > fa; ll n; DSU () {} DSU (ll n):n (n){ init (n); } void init (ll n) { n+=5 ; fa.resize (n); for (int i=0 ;i<SZ (fa);++i) { fa[i]=i; } } ll find (ll x) { while (x != fa[x]) { x = fa[x] = fa[fa[x]]; } return x; } bool same (ll x,ll y) { return find (x) == find (y); } bool merge (ll x, ll y) { x = find (x); y = find (y); if (x == y) { return false ; } fa[x] = y; return true ; } void taglr (ll l,ll r) { l=max (0ll ,l); r=min (SZ (fa)-2 ,r); l=find (l); while (l<=r) { merge (l,l+1 ); l+=1 ; l=find (l); } } ll countFa () { ll res=0 ; for (int i=1 ;i<=n;++i) { if (fa[i]==i) { ++res; } } return res; } }; void Solve () { ll N; cin >> N; vector<vector<char >> Reach (N+2 ,vector <char >(N+2 )); vector<vector<int >> node_reach (N+2 ); vector<ll> cnt_reach (N+2 ) ; for (int i=1 ;i<=N;++i) { for (int j=1 ;j<=N;++j) { cin>>Reach[i][j]; if (j==i) continue ; if (Reach[i][j]=='1' ) { cnt_reach[i]++; node_reach[i].push_back (j); } } } vector<pair<ll,ll>> edges; DSU dsu (N) ; for (int i=1 ;i<=N;++i) { vector<ll> nodes; dsu.init (N); for (int j=1 ;j<=N;++j) { if (j==i) continue ; if (Reach[i][j]=='1' ) { nodes.push_back (j); } } ll guard=0 ; sort (all (nodes),[&](const ll &a,const ll &b) { return cnt_reach[a]>cnt_reach[b]; }); for (auto a:nodes) { if (guard>N) { cout<<"No\n" ; return ; } if (!dsu.same (i,a)) { guard+=cnt_reach[a]; edges.emplace_back (i,a); dsu.merge (i,a); for (auto b:node_reach[a]) { dsu.merge (i,b); } } } } do { DSU dsu_check (N) ; if (SZ (edges)!=N-1 ) { cout<<"No\n" ; return ; } vector<vector<ll>> g (N+2 ); for (auto [u,v]:edges) { dsu_check.merge (u,v); g[u].push_back (v); } if (dsu_check.countFa ()!=1 ) { cout<<"No\n" ; return ; } vector<vector<char >> R (N+2 ,vector <char >(N+2 ,'0' )); for (int i=1 ;i<=N;++i) { queue<ll> q; q.push (i); R[i][i]='1' ; while (!q.empty ()) { ll a=q.front (); q.pop (); for (auto v:g[a]) { if (R[i][v]=='1' ) { continue ; } R[i][v]='1' ; q.push (v); } } } for (int i=1 ;i<=N;++i) { for (int j=1 ;j<=N;++j) { if (Reach[i][j]!=R[i][j]) { cout<<"No\n" ; return ; } } } }while (false ); cout<<"Yes\n" ; for (auto [u,v]:edges) { cout<<u<<" " <<v<<"\n" ; } } signed main () { ios::sync_with_stdio (false ); cin.tie (nullptr ); cout.tie (nullptr ); #ifdef LOCAL cout.setf (ios::unitbuf); #endif cin >> lT; for (testcase=1 ;testcase<=lT;++testcase) Solve (); return 0 ; }
心路历程(WA,TLE,MLE……)
1 2 3 4 5 6 7 8 9 10 11 _______________[ Stress test, testcase 6170 INPUT ]_______________ 1 4 1101 0100 1111 0001 _______________[ Stress test, testcase 6170 OUTPUT]_______________ No