M 把一整列接到另一列尾部,列内顺序不变,名次只需整体加一个偏移:带权并查集把「前面有几艘舰」记在 front 上、列长记在根上,同列询问答 |front[i]-front[j]|-1。
OJ: roj
题目 ID: 3106
难度:普及+/提高-
标签:并查集带权并查集python
创建: 2026-10-01 17:27
更新: 2026-10-07 13:50
形式化题目
把
M i j:设所在列是 , 所在列是 ,把 整段保持内部顺序拼接到 的尾部(即拼接后 的元素在前、 的元素在后),列数因此减少 。 C i j:若与 不在同一列,回答 -1;否则回答二者之间夹着的元素个数, 即「不含两端的距离」。
约束为
样例输入:
4
M 2 3
C 1 2
M 2 4
C 4 2按「
| 指令 | 操作 | 得到的列 | 输出 |
|---|---|---|---|
M 2 3 |
把 |
3, 2 |
— |
C 1 2 |
1 在 3, 2 |
不变 | -1 |
M 2 4 |
把 3, 2 接到 |
4, 3, 2 |
— |
C 4 2 |
同列,4 与 2 之间夹着 3 | 不变 | 1 |
样例输出为 -1 与 1。注意第三行:M 2 4 是「把 2 所在列接到 4 所在列尾部」,
所以 4 排在最前面,中间夹着的正是 3。
正解
思路
朴素做法与它的瓶颈。 最直接的想法是用 list 真的维护每一列,
再用一个数组 belong[舰] = 列编号:
M i j:cols[belong[j]] += cols[belong[i]],然后遍历被搬走的那一列, 把其中每艘舰的belong改成新的列;C i j:同列时用list.index求两个位置再相减。
做法正确,但对本题的规模完全不够。设一列长度为 M 要整体搬运并逐个改写归属,
代价 C 要在列里线性查找位置,代价也是 index 两次查找合计约等于一列长度,
| 数据 | M 搬运量 |
C 的 index 扫描量 |
合计 |
|---|---|---|---|
input5( |
|||
input9( |
|||
input10( |
可见真正压垮朴素做法的不是 M 而是 C:C 有 47 万条,
而每次同列询问都要在 input9 上就累积到
更值得琢磨的是:朴素做法搬运的整列元素本身,对题目要回答的两个问题毫无用处。 题目只问「在不在同一列」和「相隔几艘」,从来不问列里到底有谁、谁紧挨着谁。 真正需要维护的信息其实只有两条:每艘舰在第几位,以及每一列有多长。 所以应该把「真的去搬、真的去数」换成「把位置信息压缩成可累加的增量」。
关键观察一:M 不改变列内相对顺序。 M i j 把
关键观察二:「在同一列」是等价关系。 M 只会把两个等价类并成一个,等价类只会越变越少,
C 只问两个元素是否同类。这正是并查集的用武之地:每一列用一个根(该列的队首)代表,
find(i) == find(j) 就等价于两舰同列。
把两个观察合起来,就是带权并查集。 在每个结点上只存两条信息:
front[x]:相对父结点还多排着几艘舰(只算父结点那一段,不是整列)。 沿 fa路径把front累加起来,就得到在该列中的下标 (0 基)。 size[r]:根那一列的战舰总数(只有根上有效)。
于是两个操作都变成
为什么边权取
find 顺便把增量摊平。 路径压缩时不能只改 fa 而不改 front,
否则「路径和 = 下标」这条不变量会被破坏。做法是从根往叶回溯:
depth = 0
for node in reversed(path): # path 是从 x 上溯到根的结点序列
depth += front[node] # depth = node 到根的路径和 = node 的下标
front[node] = depth
fa[node] = root这样压缩完成后 front[x] 直接就是
C 的答案。 同列时,两舰下标之差的绝对值
有一个语义边界要顺势处理:题面没有排除 -1,与「0。
用样例核对一遍。 四条指令按顺序执行,列状态与并查集里变化的量如下
(
| 操作 | 真实列 | fa / front / size 的变化 |
pos |
|---|---|---|---|
| 初始 | 每列单元素 | fa[x] = x,front[x] = 0,size[x] = 1 |
|
M 2 3 |
3, 2 |
front[2] = size[3] = 1,fa[2] = 3,size[3] = 2 |
|
C 1 2 |
— | find(1) = 1 \neq find(2) = 3 |
不同列 → -1 |
M 2 4 |
4, 3, 2 |
find(2) = 3,所以 front[3] = size[4] = 1,fa[3] = 4,size[4] = 3 |
|
C 4 2 |
同列 | find(4) = 4;find(2) 顺带把 front[2] 压成 front[3] + front[2] = 2 |
最后一步 M 2 4 那一行:代码里操作的是 find(2) 也就是 3,
而不是 2 本身——front[3] = 1 记录了「3 在 4 后面第一位」,
而 front[2] = 1 始终表示「2 在 3 后面第一位」,
两个增量各自独立,路径和才拼出完整下标。
代码
Python 版:
#!/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-01 17:27
# update_at: 2026-10-01 17:46
import sys
LIMIT = 30000 # 题面 N <= 30000,战舰编号固定落在这个范围内
def solve() -> None:
stdin = sys.stdin.buffer
T = int(stdin.readline())
out = bytearray() # C 指令最多 50 万条,攒进 bytearray 一次写出,省内存也省时间
# 带权并查集:fa[i] 是 i 的父结点,front[i] 是 i 前面还排着多少艘战舰
# (只统计到父结点这一段,find 时沿路径累加就得到整列里的下标)。
# 每列的队首(根)额外记一个 size:该列当前的战舰总数。
fa = list(range(LIMIT + 1))
front = [0] * (LIMIT + 1) # 初始每列只有自己,前面没有战舰
size = [1] * (LIMIT + 1)
def find(x: int) -> int:
"""返回 x 所在列的队首,并把 x 到队首的 front 累加好、路径压平。
必须迭代:M 指令能把列接成一条长链,递归会超过 Python 的递归深度。
"""
root, path = x, []
while fa[root] != root: # 先向上走到队首,沿路记下经过的结点
path.append(root)
root = fa[root]
depth = 0
for node in reversed(path): # 再从队首一侧往回累加,顺手挂到队首上
depth += front[node]
front[node] = depth
fa[node] = root
return root
for _ in range(T):
op, x, y = stdin.readline().split()
x, y = int(x), int(y)
rx, ry = find(x), find(y)
if op == b"M": # 指令 M:把 x 所在列整列接到 y 所在列尾部
if rx != ry: # 两舰本就在同一列时是空操作,跳过更省时间
front[rx] = size[ry] # x 那列的首舰前方正好是 y 那列的全体
size[ry] += size[rx]
fa[rx] = ry
elif rx != ry: # 指令 C,且两舰不在同一列
out += b"-1\n"
else: # 指令 C,同一列:下标差扣掉对方那艘;问自己时差为 0 不扣
gap = abs(front[x] - front[y])
out += str(gap - 1 if gap else 0).encode() + b"\n"
sys.stdout.buffer.write(out)
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-06 18:57
* update_at: 2026-10-06 18:57
*/
#include <cstdio>
const int MAXN = 30005; // 战舰编号 1..N,N <= 30000,多开几位防越界
int fa[MAXN]; // fa[i] 是 i 的父结点
int front_w[MAXN]; // front_w[i] 是 i 相对父结点排在前面的战舰数(只算到父结点这一段)
int size_w[MAXN]; // size_w[r] 是根 r 那一列的战舰总数,只有根上有效
int stk[MAXN]; // find_root 上溯时按顺序记下路径上的结点
int top; // stk 的有效长度
// 返回 x 所在列的根(队首),同时把 x 到根的 front_w 累加好并做路径压缩。
// 必须迭代:M 指令能把列接成长链,递归有爆栈风险。
int find_root(int x) {
int root = x;
top = 0;
while (fa[root] != root) { // 先向上走到根,沿路记下经过的结点
stk[++top] = root;
root = fa[root];
}
int depth = 0;
for (int i = top; i >= 1; --i) { // 从靠近根的一端往回累加,得到每点的列内下标
depth += front_w[stk[i]];
front_w[stk[i]] = depth;
fa[stk[i]] = root;
}
return root;
}
int main() {
int T;
scanf("%d", &T);
for (int i = 1; i <= 30000; ++i) { // 初始每艘舰独占一列
fa[i] = i;
front_w[i] = 0;
size_w[i] = 1;
}
char op[5];
int x, y;
for (int q = 1; q <= T; ++q) {
scanf("%s %d %d", op, &x, &y);
int rx = find_root(x);
int ry = find_root(y);
if (op[0] == 'M') {
// 把 x 所在列整列接到 y 所在列尾部:x 列每艘舰的名次整体增加 y 列的长度
if (rx != ry) { // 本就在同一列时是空操作,跳过以免把根挂到自己身上
front_w[rx] = size_w[ry];
size_w[ry] += size_w[rx];
fa[rx] = ry;
}
} else if (rx != ry) {
printf("-1\n");
} else {
// 同列:下标差的绝对值是间隔距离,扣掉端点之一就是中间夹着的战舰数
int gap = front_w[x] - front_w[y];
if (gap < 0) gap = -gap;
if (gap == 0) printf("0\n"); // i == j 时差为 0,中间没有战舰
else printf("%d\n", gap - 1);
}
}
return 0;
}三段结构与上面的推导一一对应:
find(x)的while上溯负责找根,for node in reversed(path)负责把路径和摊平到front[node]。这里必须写成迭代而不是递归:数据里 3 万条M足以把链接成长链, 递归会触到 Python 的递归深度上限。- 循环体内的
rx, ry = find(x), find(y)是「同列判定」;M分支的if rx != ry拦住同列的空操作(否则会把根挂到自己身上并弄坏size), 三行赋值分别对应整体偏移、列长累加、挂树。 C分支的三个出口:不同列写b"-1\n",同列写|front[i] - front[j]| - 1, 其中gap - 1 if gap else 0单独处理。
输入用 sys.stdin.buffer.readline() 逐行读、输出用 bytearray 攒行后一次写出:
这两处安排都是为 128 MB 上限服务的——把 6.6 MB 输入一次性 read().split()
会生成数十万个 bytes 对象,实测峰值从 21 MB 涨到 113 MB,
而逐行读不损失任何时间。
复杂度
- 时间复杂度:每条指令两次
find,路径压缩下均摊( 是反阿克曼函数,实际不超过 5),合并与求差都是 。 总计 ,约 次近常数操作。 实测真实数据最慢的 input9、input10()各约 0.29 s, 远低于 1000 ms。作为对比,朴素做法在同一数据上需要 量级的元素访问 (实测约 次),差了三到四个数量级。 - 空间复杂度:
fa、front、size各长,加上 bytearray形式的输出, 共。实测峰值 RSS 约 21 MB,在 128 MB 以内。
总结
- 题目的两个问题(同列判定、名次差)都不需要知道列里有哪些元素,
朴素做法真正浪费的是「把同一个偏移抄给整列」的那
次操作, 以及每次询问都线性数一遍名次。 - 关键观察一:
M保持列内相对顺序 ⇒ 名次只在合并时整体加同一个偏移。 - 关键观察二:并列是等价类合并 ⇒ 用根代表一列,
find判断同列。 - 合并观察:带权并查集,
front[x]记「相对父结点多排了几艘」,size[root]记列长;M时front[ri] = size[rj]、size[rj] += size[ri]、fa[ri] = rj; 路径压缩时从根向叶累加front,压缩后front[x]即 0 基下标。 - 询问答案:不同列
-1,同列,且 时输出 0。 - 复杂度
时间、 空间。
图示解析
先看合并过程中增量是怎么被「只记一次」的。下图画的是样例执行完 M 2 3、M 2 4
之后仍然保留的指针结构(此时 fa 还未被压缩:2 指向 3、3 再指向 4):
flowchart LR
N4["舰 4(根)<br/>front = 0, pos = 0<br/>size = 3"]
N3["舰 3<br/>front = 1, pos = 1"] -->|"fa[3] = 4,新边权 L_j = size[4] = 1"| N4
N2["舰 2<br/>front = 1, pos = 2"] -->|"fa[2] = 3,2 只在 3 后面一位"| N3
观察重点:图中的列是 4, 3, 2,可整列名次只由两条边共同描述——
3 相对根 4 的偏移 1(这一列之所以整段靠右,就是因为合并时加了 2 相对 3 的偏移 1。2 的完整下标是沿路径的和 2 直接连到 4;size 只挂在根 4 上,
这也解释了为什么只有根上的 size 才有意义。
再看 find 的路径压缩把 front 改成了什么,以及它对询问的影响:
压缩前:fa[2]=3 front[2]=1 询问 C 4 2 时会执行 find(2)
fa[3]=4 front[3]=1 从根往叶累加(path = [2, 3],reversed 后是 3, 2)
depth = 1 → front[3] = 1, fa[3] = 4
depth = 1 + 1 → front[2] = 2, fa[2] = 4
压缩后:fa[2]=4 front[2]=2 pos_2 = front[2] = 2,pos_4 = 0
fa[3]=4 front[3]=1 答案 |2 - 0| - 1 = 1压缩只是把同一段路径和换了个存放方式:1 + 1 从「两条边各自记一半」变成「一条边记全部」,
路径和始终是 2,所以不变量没有被破坏。这正是「路径压缩必须从根向叶累加」的原因——
反过来加会把子结点相对父结点的增量赋给父结点,路径和就错了。
而答案总是 0 而不是 -1。