【NOI2024 D1T3】树的定向 题解(树剖,拓扑排序)
自己想出了正解, 但是写了一天并且写了 10k,考场上肯定写不出来。
但是 \(64pts\) 是简单并且好写的。
考察能力:猜结论,较强的优化码量的能力或较强的码力。
题意
给你一棵 \(n\) 个点的树,第 \(i\) 条边为 \((u_i, v_i)\)。
你需要给每条边定向,其中第 \(i\) 条边的方向为 \(dir_i\),满足 \(dir_i \in \{0, 1\}\)。当 \(dir_i = 0\) 时,表示这条边的方向为 \(u_i \to v_i\),为 \(1\) 则表示 \(v_i \to u_i\)。
有 \(m\) 条限制,第 \(i\) 条限制用有序二元组 \((a_i, b_i)\) 描述,表示给所有边定向后 \(a_i\) 不能 到达 \(b_i\)。
你需要求出满足所有限制的条件下 \(dir\) 数组的最小字典序字符串是什么。
【数据范围】
对于所有测试数据保证 \(2\leq n\leq 5\times 10^5\),\(1\leq m\leq 5\times 10^5\),\(1\leq u_i,v_i\leq n\) 且所有的边构成了一棵树,\(1\leq a_i,b_i \leq n\) 且 \(a_i\neq b_i\)。
数据保证存在至少一个完美定向。
| 测试点编号 | \(n\) | \(m\) | 特殊性质 |
|---|---|---|---|
| \(1\sim 3\) | \(\leq 15\) | \(\leq 50\) | 无 |
| \(4\sim 6\) | \(\leq 300\) | \(\leq 300\) | 无 |
| \(7,8\) | \(\leq 400\) | \(=(n-1)(n-2)\) | A |
| \(9,10\) | \(\leq 2\,000\) | \(\leq 2\,000\) | B |
| \(11\sim 14\) | \(\leq 2\,000\) | \(\leq 2\,000\) | 无 |
| \(15,16\) | \(\leq 10^5\) | \(\leq 10^5\) | B |
| \(17,18\) | \(\leq 10^5\) | \(\leq 10^5\) | 无 |
| \(19\sim 21\) | \(\leq 2\times 10^5\) | \(\leq 2\times 10^5\) | 无 |
| \(22\sim 25\) | \(\leq 5\times 10^5\) | \(\leq 5\times 10^5\) | 无 |
- 特殊性质 A:保证 \((a,b)\) 出现在 \((a_i,b_i)\) 中当且仅当 \(a\neq b\) 且 \(a,b\) 在树上不相邻。
- 特殊性质 B:保证树上编号为 \(1\) 的顶点与其他每个顶点均相邻。
分析
\(n \leq 15\) 时直接状压枚举即可。期望得分 \(12pts\)。
接下来考虑 \(A\) 性质:
不难发现限制等价于不存在一条长度 \(\geq 2\) 的链。
有一种比较强的构造:
任意指定一个点为根,然后从根开始对树黑白染色,让所有边都是由黑色点指向白色点。
那么任意一条长度大于等于 \(2\) 的路径上边的方向都不可能相同。
由于我们要求字典序最小,因此钦定第一条边的 \(u_i\) 为 黑色,\(v_i\) 为白色然后二分图染色就行了。
那么基于这个构造我们能不能去推出一些性质呢?
考虑 \(Ploy(n)\) 复杂度的算法:
由于要求字典序最小,因此套路的想到逐位确定。
那么问题就变成了在一些边已经确定的情况下是否存在一种给其他边定向的方式满足所有限制。
首先如果确定的边已经让某个限制不合法了就不行。否则这些边可以排除掉一些限制,这些限制是已经不满足的。
然后考虑剩下来的限制:发现这些路径上的边的方向都是相同的。那么如果有一条路径上只剩下一条边未被定向,显然这条边的方向是唯一确定的。我们将它定向后就可以把这个限制删去。
但是新定向的边会导致别的限制达到 临界 或者不满足或者被排除掉。如果不合法了,我们判断出来,否则就接着去处理达到临界的限制。直到任意一个限制都至少存在两条边未被定向。
那么这时候怎么办呢??我们大胆猜测,此时一定存在合法解!!
证明一下:将已经定向的边的两个端点缩成一个点,那么原来的树会是会被缩成一棵树,新得到的树的每一条边都未被定向。由于每个限制都至少有 \(\geq 2\) 条边,我们直接按照 \(A\) 性质的构造方法将新树定向,就一定能满足所有限制了。
由此可以得到一个 \(O(n^3)\) 的做法:
按位确定每一条边的方向,然后 \(O(n^2)\) 的 \(check\)。
这个是不难写的,可以通过测试点 \(4 \sim 6\),期望得分 \(12pts\)。
加上 \(A\) 性质我们已经有 \(12 + 12 + 8 = 32pts\)了。
怎么做到 \(O(n^2)\) ?
发现按位确定是没有必要的。假设我们已经确定了一些边的方向,并且保证当前局面一定存在解。那么只需要将所有达到临界的限制的支配边定向,知道所有限制都为达到临界。此时我们知道无论给哪一条边定怎样的方向都一定是有解的,因为相当于确定了白点黑点的集合。所以我们直接贪心将编号最小的边定向成 \(0\) 即可。
开一个队列放已经达到临界的限制,然后每次从中取出队头暴力找到路径上那条还未定向的边,然后将这条边定向。将一条边定向时可以暴力将所有经过它的路径的未定向边数量 \(cnt--\),并把排除掉的路径标记下来。然后将达到临界的限制入队。由于一条边只会被标记 \(1\) 次,一个限制只会进队 \(1\) 次,因此复杂度 \(O(n^2)\)。代码比较好写:
namespace Part1 {
const int M = 2010;
int idx[M][M];
int dir[M], dep[M], fat[N]; // 第 i 条边的方向 -1 表示还没定向
int tot, cnt[M], F[M][M]; // 第 i 条路径还没有确定的边数
bool vis[M]; // 第 i 条路径是否已经不用管了
vector< int > p[M][2];
queue< int > q; // 表示当前需要确定边的路径编号
void dfs(int x, int fa) {
dep[x] = dep[fa] + 1; fat[x] = fa;
for(auto v : E[x]) {
if(v == fa) continue;
dfs(v, x);
}
}
void turn(int x, int f) { // 将编号为 x 的边的方向设成 f
tot ++;
dir[x] = f;
for(auto v : p[x][0]) {
cnt[v] --;
if(cnt[v] == 1) q.push(v);
}
for(auto v : p[x][1]) {
cnt[v] --;
if(cnt[v] == 1) q.push(v);
}
for(auto v : p[x][f ^ 1]) vis[v] = 1;
}
void work() {
while(!q.empty()) {
int x = q.front(); q.pop();
if(vis[x]) continue;
int nu = a[x], nv = b[x], id = -1;
while(nu != nv) {
int fa;
if(dep[nu] >= dep[nv]) {
fa = fat[nu];
if(dir[idx[fa][nu]] == -1) {id = idx[fa][nu]; break;}
nu = fa;
}
else {
fa = fat[nv];
if(dir[idx[fa][nv]] == -1) {id = idx[fa][nv]; break;}
nv = fa;
}
}
if(id != -1) turn(id, F[x][id] ^ 1);
}
}
void solve() {
dfs(1, 0);
for(int i = 1; i < n; i ++ ) dir[i] = -1, idx[u[i]][v[i]] = idx[v[i]][u[i]] = i;
for(int i = 1; i <= m; i ++ ) {
int nu = a[i], nv = b[i];
while(nu != nv) {
cnt[i] ++; int fa;
if(dep[nu] >= dep[nv]) {
fa = fat[nu]; int id = idx[nu][fa];
int pp = !(nu == u[id]);
p[id][pp].pb(i); F[i][id] = pp;
nu = fa;
}
else {
fa = fat[nv]; int id = idx[nv][fa];
int pp = (nv == u[id]);
p[id][pp].pb(i); F[i][id] = pp;
nv = fa;
}
}
}
for(int i = 1; i <= m; i ++ ) {
if(cnt[i] == 1) q.push(i);
}
while(tot < n - 1) {
work();
for(int i = 1; i < n; i ++ ) {
if(dir[i] == -1) {turn(i, 0); break;}
}
}
for(int i = 1; i < n; i ++ ) printf("%d", dir[i]);
puts("");
}
}
期望通过测试点 \(9 \sim 14\),可以拿到 \(24pts\)。
加上之前的已经有 \(56pts\) 了。
再来看性质 \(B\):树的形态是菊花。
实际上直接思考怎么做也是容易的。但是我们发现刚才的做法的复杂度是 \(O(m \times d_{max})\),\(d_{max}\) 是最长路径长度,而菊花上最长路径是 \(2\),因此复杂度是 \(O(n)\) 的,只需要改一下数组大小即可通过。
现在又拿了 \(8pts\),总得分 \(64pts\) 。已经相当不少了。
接下来就是去思考正解了,怎么将复杂度优化到 \(Polylog(n)\)?
回顾一下我们需要支持的操作:
- 找到编号最小的还未定向的边。
- 将边定向后找到达到临界的路径并放入队列中。
- 对于达到临界的路径,判断当前定向的边是否已经满足它的限制,如果不满足,找到那条还未定向的边。
第一个问题写一个链表就行。第三个问题实际上也可以树剖加线段树双 \(\log\) 维护,但是关键是第二个问题,我们无法树剖解决。
下面说一下我的做法:
考虑对每个限制设置报警器,每次报警后检查限制是否临界。树剖之后每条路径都会被拆成 \(\log n\) 条重链上的区间,具体的说,只有一段区间以及 \(\log n\) 条前缀。
注意到每条路径上未定向边的数量为 \(1\) 的时刻一定是某段区间的数量变成 \(0\) 或者 \(1\) 的时刻。因此如果对每个区间都拆成线段树上 \(\log\) 个节点,那么一个限制就会有 \(\log^2n\) 个报警器,这样时空都是 \(O(m \log^2 n)\) 的。
能不能优化?
刚才说了,实际上只有一条重链上是一段区间,别的都是前缀,因此我们设置 \(\log n\) 个前缀报警器,对于那单独的一个我们把它放在线段树上。这样总共就只有 \(O(m \log n)\) 个报警器了。
还有一个问题,就是第三个问题的复杂度还是 \(O(n \log^2 n)\) 的,我们想让再快一点。
实际上只需要查 \(\log n\) 个前缀上每种方向边的数量和一个区间上每种边的数量。前缀数量显然可以前缀和后 \(O(1)\) 查,剩下那个 \(\log n\) 查就行了。
时空复杂度 \(O(n \log n)\),代码很难写。
namespace Part3 { // 树剖,然后报警器 + 指针可以做到一个 log
unordered_map< int, int > idx[N];
int fat[N], big[N], sz[N], dep[N], top[N], bot[N], dfn[N], ID[N], dfc;
int val[N], sum[N][2], p[N][2], cnt[N];
int dir[N]; // 方向
int nxt[N], pre[N];
vector< int > t_bell[N * 4]; // 这是树上节点报警器,当大小变成 0 或者 1 也报警
vector< int > bell[N]; // 这是前缀报警器,当 1 指针扫到这里或 0 指针扫到这里就报警
queue< int > qq;
struct BIT {
int c[N];
inline int lowbit(int x) {return x & -x;};
inline void add(int x, int y) {for(; x < N; x += lowbit(x)) c[x] += y;};
inline int ask(int x) {int res = 0; for(; x; x -= lowbit(x)) res += c[x]; return res;};
}T[2];
struct SegmentTree {
int l, r, cnt;
#define l(x) tree[x].l
#define r(x) tree[x].r
#define cnt(x) tree[x].cnt
}tree[N * 4];
int node;
inline void alarm(int x, int tp, int c) {
if(tp == 1 && (x == -1 || x == n + 1)) return ;
if(tp == 1) { // 链上报警
int L = (c == 1 ? dfn[x] - dfn[top[x]] : 1);
for(auto v : bell[x]) {
cnt[v] -= L;
if(cnt[v] == 1) qq.push(v);
}
}
else { // 树上
int L = (c == 1 ? r(x) - l(x) : 1);
for(auto v : t_bell[x]) {
cnt[v] -= L;
if(cnt[v] == 1) qq.push(v);
}
}
}
void update(int p) {
cnt(p) = cnt(p << 1) + cnt(p << 1 | 1);
if(cnt(p) <= 1) alarm(p, 0, cnt(p));
};
void build(int p, int l, int r) {
l(p) = l; r(p) = r;
if(l == r) {node = max(node, p); cnt(p) = 1; return ;}
int mid = (l + r >> 1);
build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
update(p);
}
void Push_into(int p, int l, int r, int idx) {
if(l <= l(p) && r >= r(p)) {t_bell[p].pb(idx); return ;}
int mid = (l(p) + r(p) >> 1);
if(l <= mid) Push_into(p << 1, l, r, idx);
if(r > mid) Push_into(p << 1 | 1, l, r, idx);
}
void ins(int p, int pos) {
if(l(p) == r(p)) {
cnt(p) --;
if(cnt(p) <= 1) alarm(p, 0, cnt(p));
return ;
}
int mid = (l(p) + r(p) >> 1);
if(pos <= mid) ins(p << 1, pos);
else ins(p << 1 | 1, pos);
update(p);
}
int get(int p, int l, int r) {
if(l <= l(p) && r >= r(p)) {
if(cnt(p) == 0) return -1;
if(l(p) == r(p)) return l(p);
else {
if(cnt(p << 1) > 0) return get(p << 1, l, r);
else return get(p << 1 | 1, l, r);
}
}
int mid = (l(p) + r(p) >> 1);
if(r <= mid) return get(p << 1, l, r);
else if(l > mid) return get(p << 1 | 1, l, r);
else {
int v = get(p << 1, l, r);
if(v == -1) return get(p << 1 | 1, l, r);
else return v;
}
}
void change(int x, int v) {
ins(1, x); T[v].add(x, 1);
}
void dfs0(int x, int fa) {
sz[x] = 1;
dep[x] = dep[fa] + 1; fat[x] = fa;
for(auto v : E[x]) {
if(v == fa) continue;
dfs0(v, x); sz[x] += sz[v];
if(sz[v] > sz[big[x]]) big[x] = v;
}
}
void dfs1(int x, int bel) {
dfn[x] = ++ dfc; ID[dfc] = x; top[x] = bel; bot[bel] = x;
if(big[x]) dfs1(big[x], bel);
for(auto v : E[x]) {
if(v == big[x] || v == fat[x]) continue;
dfs1(v, v);
}
}
int lca(int x, int y) {
while(top[x] != top[y]) {
if(dep[top[x]] >= dep[top[y]]) x = fat[top[x]];
else y = fat[top[y]];
}
return dep[x] <= dep[y] ? x : y;
}
int dis(int x, int y) {int lc = lca(x, y); return dep[x] + dep[y] - 2 * dep[lc];};
void Set(int x) {
int u = a[x], v = b[x];
int lc = lca(u, v);
while(top[u] != top[lc]) {
bell[u].pb(x);
cnt[x] += dfn[u] - dfn[top[u]] + 1;
u = fat[top[u]];
}
if(u != lc) cnt[x] += dfn[u] - dfn[lc], Push_into(1, dfn[lc] + 1, dfn[u], x);
while(top[v] != top[lc]) {
bell[v].pb(x);
cnt[x] += dfn[v] - dfn[top[v]] + 1;
v = fat[top[v]];
}
if(v != lc) cnt[x] += dfn[v] - dfn[lc], Push_into(1, dfn[lc] + 1, dfn[v], x);
}
inline int Nxt(int h, int x) {
if(x == n + 1) return n + 1;
return x == -1 ? h : (dfn[x] + 1 <= dfn[bot[h]] ? ID[dfn[x] + 1] : n + 1);
}
inline void turn(int x, int f) { // 将 x 边变成 f
dir[x] = f; nxt[pre[x]] = nxt[x]; pre[nxt[x]] = pre[x];
int a = (dep[u[x]] > dep[v[x]] ? u[x] : v[x]);
int h = top[a]; int v;
if(f == 0) {
if(a == u[x]) v = 1;
else v = 0;
}
else {
if(a == u[x]) v = 0;
else v = 1;
}
change(dfn[a], v); // 更新树上报警
val[a] = v;
// 先更新 0 指针, 再更新 1 更新 1 指针
while(val[Nxt(h, p[h][0])] != -1) {
int t = Nxt(h, p[h][0]);
sum[t][0] = sum[p[h][0] == -1 ? 0 : p[h][0]][0];
sum[t][1] = sum[p[h][0] == -1 ? 0 : p[h][0]][1];
sum[t][val[t]] ++;
p[h][0] = t; alarm(t, 1, 0);
}
if(p[h][0] == p[h][1])
if(Nxt(h, p[h][1]) != n + 1) p[h][1] = Nxt(h, p[h][1]), alarm(p[h][1], 1, 1);
while(val[Nxt(h, p[h][1])] != -1) p[h][1] = Nxt(h, p[h][1]), alarm(p[h][1], 1, 1);
}
inline int F(int x, int id) { // 在 x 这条路径上 id 应是什么方向
return dis(u[id], b[x]) <= dis(v[id], b[x]) ? 1 : 0;
}
PII Find(int x) { // 判断是否还需要定向以及找到还未定向的边
if(cnt[x] == 0) return MP(0, -1); // 不用找了
int u = a[x], v = b[x], lc = lca(u, v);
int pp = -1; int f = 1;
while(top[u] != top[lc]) {
if(p[top[u]][0] == -1 || dfn[p[top[u]][0]] < dfn[u]) { // 找到 1 了 可以暴力查
pp = (p[top[u]][0] == -1 ? top[u] : ID[dfn[p[top[u]][0]] + 1]);
f &= (T[0].ask(dfn[u]) - T[0].ask(dfn[top[u]] - 1) == 0);
}
else { // 找不到 1
f &= (sum[u][1] == dfn[u] - dfn[top[u]] + 1);
}
u = fat[top[u]];
}
if(u != lc) {
int pos = get(1, dfn[lc] + 1, dfn[u]); // 找到一个 1
int cc = T[0].ask(dfn[u]) - T[0].ask(dfn[lc]);
if(pos != -1) pp = ID[pos];;
if(cc != 0) f = 0;
}
while(top[v] != top[lc]) {
if(p[top[v]][0] == -1 || dfn[p[top[v]][0]] < dfn[v]) {
pp = (p[top[v]][0] == -1 ? top[v] : ID[dfn[p[top[v]][0]] + 1]);
f &= (T[1].ask(dfn[v]) - T[1].ask(dfn[top[v]] - 1) == 0);
}
else {
f &= (sum[v][0] == dfn[v] - dfn[top[v]] + 1);
}
v = fat[top[v]];
}
if(v != lc) {
int pos = get(1, dfn[lc] + 1, dfn[v]);
int cc = T[1].ask(dfn[v]) - T[1].ask(dfn[lc]);
if(pos != -1) {if(pp != -1) cout << "wrnm" << endl; pp = ID[pos];}
if(cc != 0) f = 0;
}
return MP(f, f == 1 ? idx[pp][fat[pp]] : 0);
}
inline void work() { // 将可以定向的边定向
while(!qq.empty()) {
int u = qq.front(); qq.pop();
// 需要找还未定向的边在哪以及是否需要定向
PII ret = Find(u);
if(ret.first == 0) continue; // 已经满足了,不用定向了
else turn(ret.second, F(u, ret.second) ^ 1);
}
}
void solve() {
memset(dir, -1, sizeof dir);
memset(val, -1, sizeof val);
for(int i = 1; i < n; i ++ ) idx[u[i]][v[i]] = idx[v[i]][u[i]] = i;
dfs0(1, 0);
dfs1(1, 1);
build(1, 1, n); // 每个位置代表父亲的边
for(int i = 1; i <= m; i ++ ) Set(i); // 设置报警器
for(int i = 0; i < n; i ++ ) nxt[i] = i + 1, pre[i + 1] = i;
ID[n + 1] = n + 1; dfn[n + 1] = n + 1;
for(int i = 1; i <= n; i ++ ) { // 设置每条链的指针
if(top[i] == i) {
if(i == 1) p[i][0] = i, p[i][1] = ID[dfn[i] + 1 <= dfn[bot[i]] ? dfn[i] + 1 : n + 1];
else p[i][0] = -1, p[i][1] = i;
alarm(p[i][0], 1, 0); alarm(p[i][1], 1, 1);
}
}
for(int i = 1; i <= node; i ++ )
if(cnt(i) <= 1) alarm(i, 0, cnt(i));
while(nxt[0] != n) {
work(); // 将被确定的边定向
int p = nxt[0];
if(p == n) break;
turn(p, 0);
}
for(int i = 1; i < n; i ++ ) printf("%d", dir[i]);
puts("");
}
}
实际上还有一种做法:
我们的过程相当于一个拓扑排序,如果让路径上的每条边都指向这个限制。那么当这个限制只有当这个限制入度为 \(1\) 时我们需要把它取出来。考虑树剖优化建图就可以将边数优化到 \(O(m \log n)\) 了。

浙公网安备 33010602011771号