序列会被翻转、轮换、插入、删除,按位置组织的静态结构全部失效;用平衡树按中序维护显式序列,两次 splay 切出区间子树后打整树加与翻转懒标记,轮换化为三次翻转,每个操作均摊 O(log n)。
OJ: roj
题目 ID: 3129
难度:省选/NOI-
标签:平衡树Splay懒标记区间操作python
创建: 2026-10-01 18:41
更新: 2026-10-10 11:30
形式化题目
给定一个长度为
ADD x y D:每个数加 ; REVERSE x y:翻转; REVOLVE x y T:把右轮换 次(每轮把区间最后一个数移到区间最前); INSERT x P:在后面插入一个数 (序列长度加一); DELETE x:删除(序列长度减一); MIN x y:输出中的最小值。
MIN 输出一行答案。
正解
思路
先用数组朴素模拟找找瓶颈:ADD 和 MIN 用线段树或差分都能到 REVERSE、REVOLVE 要把区间逐个元素翻过来,INSERT、DELETE 要搬动后半段所有元素,单次最坏
所以要换一种组织方式:用平衡树(Splay)保存序列,但不按值、而按中序位置理解它——把树中序遍历一遍,得到的恰好就是当前序列。这样:
- "序列的一段
“对应"树中连续一段中序”,而树中连续一段中序恰好组成一颗完整子树; - 只要能把这颗子树完整地"切"出来,区间操作就变成对单个节点的懒标记。
区间切分:在序列两端各加一个值为 [左哨兵, A₁, …, Aₙ, 右哨兵],这样
切出子树后,六种操作全部落在子树根这一个节点上:
| 操作 | 在切出的子树根上做什么 |
|---|---|
ADD x y D |
打 add 懒标记,同步修正该点的 val/mn |
REVERSE x y |
打 rev 懒标记(下传时交换左右子树) |
REVOLVE x y T |
转化为三次 REVERSE(见下) |
INSERT x P |
定位 |
DELETE x |
定位 |
MIN x y |
直接读子树根维护的子树最小值 mn |
轮换化翻转:设区间为 REVERSE 的懒标记。
正确性要点:懒标记可以一直"趴"在子树根上不下传——因为整树加 mn 在打标记处就能算对;只有真正要进入子树(kth、切分)时才沿路径下传,均摊不改变复杂度。
用样例演示 ADD 2 4 1 的切树过程(树只画中序骨架,
flowchart TB
subgraph G0["初始:中序 = ∞ 1 2 3 4 5 ∞"]
R0["∞(根)"] --- A0["1 2 3 4 5 ∞"]
end
subgraph G1["第 1 次 splay:A₁=1 转到根"]
R1["1(根)"] --- L1["∞"] --- C1["2 3 4 5 ∞"]
end
subgraph G2["第 2 次 splay:A₄ 的后继 5 转到 1 的孩子"]
R2["1(根)"] --- C2["2 3 4(完整子树)"] --- S2["5"] --- T2["∞"]
end
subgraph G3["对子树根打 add=+1,MIN 4 5 再切一次读 mn"]
R3["1"] --- C3["3 4 5<br/>add=+1,mn=3"] --- S3["5"] --- T3["∞"]
end
G0 --> G1 --> G2 --> G3
观察重点:第 2 步之后"2 3 4"成为一颗父子关系完整的子树,此后加标记只碰它的根,翻转也只碰它的根,区间外的任何节点都不再被触及——这就是所有操作
再看 REVOLVE 2 4 2 的三次翻转(区间是 3 4 5,
| 步骤 | 区间内容 | 动作 |
|---|---|---|
| 初始 | 3 4 5 |
目标:尾部 2 个移到前面,即 4 5 3 |
| 翻前 |
3 → 3 |
|
| 翻后 |
4 5 → 5 4 |
|
| 整段翻转 | 3 5 4 → 4 5 3 |
三次懒标记全部打在同一颗子树的根上,push 时一次性生效。
代码
/**
* 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-10 11:30
* update_at: 2026-10-10 11:30
*/
// 文艺平衡树扩展(ADD / REVERSE / REVOLVE / INSERT / DELETE / MIN):Splay 维护区间。
#include <cstdio>
#include <vector>
#include <string>
#include <iostream>
typedef long long ll;
const ll INF = 1000000000000000000LL;
struct Node {
int ch[2]; // 左 / 右孩子
int fa; // 父亲
ll val; // 节点自身的值
ll mn; // 子树最小值
ll add; // 子树整体加法的懒标记
char rev; // 子树待翻转的懒标记
int siz; // 子树大小
};
std::vector<Node> nd;
ll cnt = 0; // 已用节点数
ll root = 0; // 根
std::vector<ll> A;
// 分配一个值为 v、父亲为 par 的新节点
ll new_node(ll v, ll par) {
cnt++;
nd[cnt].ch[0] = nd[cnt].ch[1] = 0;
nd[cnt].fa = (int)par;
nd[cnt].val = nd[cnt].mn = v;
nd[cnt].add = 0;
nd[cnt].rev = 0;
nd[cnt].siz = 1;
return cnt;
}
// 由左右孩子重算 x 的子树大小与最小值
void pull(ll x) {
ll l = nd[x].ch[0], r = nd[x].ch[1];
nd[x].siz = nd[l].siz + nd[r].siz + 1;
ll m = nd[x].val;
if (nd[l].mn < m) m = nd[l].mn;
if (nd[r].mn < m) m = nd[r].mn;
nd[x].mn = m;
}
// 从 x 往上逐层重算聚合值,直到根
void pull_up(ll x) {
while (x) {
pull(x);
x = nd[x].fa;
}
}
// 把 x 的懒标记下传一层:整子树加 add、整子树翻转
void push(ll x) {
if (nd[x].add) {
ll d = nd[x].add;
nd[x].add = 0;
for (int s = 0; s < 2; s++) {
ll y = nd[x].ch[s];
if (y) {
nd[y].val += d;
nd[y].mn += d;
nd[y].add += d;
}
}
}
if (nd[x].rev) {
nd[x].rev = 0;
ll l = nd[x].ch[0], r = nd[x].ch[1];
nd[x].ch[0] = (int)r;
nd[x].ch[1] = (int)l;
if (l) nd[l].rev ^= 1;
if (r) nd[r].rev ^= 1;
}
}
std::vector<ll> pathbuf;
// 把根到 x 路径上的懒标记自上而下清空
void push_path(ll x) {
pathbuf.clear();
while (x) {
pathbuf.push_back(x);
x = nd[x].fa;
}
for (size_t i = pathbuf.size(); i-- > 0;) {
push(pathbuf[i]);
}
}
// 把 x 上旋一层(路径上的懒标记已被 push_path 清空)
void rotate(ll x) {
ll y = nd[x].fa;
ll z = nd[y].fa;
int side = (nd[y].ch[1] == (int)x) ? 1 : 0; // x 是 y 的哪一侧孩子
ll w = nd[x].ch[side ^ 1];
if (w) nd[w].fa = (int)y;
nd[y].ch[side] = (int)w;
nd[x].ch[side ^ 1] = (int)y;
nd[y].fa = (int)x;
nd[x].fa = (int)z;
if (z) nd[z].ch[nd[z].ch[1] == (int)y ? 1 : 0] = (int)x;
pull(y);
}
// 把 x 转成 goal 的孩子(goal=0 表示转成根)
void splay_to(ll x, ll goal) {
push_path(x);
while (nd[x].fa != goal) {
ll y = nd[x].fa;
ll z = nd[y].fa;
if (z != goal && ((nd[z].ch[1] == (int)y) == (nd[y].ch[1] == (int)x))) {
rotate(y); // 一字型先转父亲,避免退化成链
}
if (nd[x].fa != goal) rotate(x);
}
if (!goal) root = x; // 转到根后同步全局根指针
pull(x);
pull_up(nd[x].fa);
}
// 从 start 出发找中序第 k 个元素(沿途把懒标记下传)
ll kth(ll start, ll k) {
ll x = start;
while (true) {
push(x);
ll left = nd[nd[x].ch[0]].siz;
if (k == left + 1) return x;
if (k <= left) {
x = nd[x].ch[0];
} else {
k -= left + 1;
x = nd[x].ch[1];
}
}
}
// 把 A 的区间 [x, y] 切成一颗完整子树,返回 (a, 子树根)
void segment(ll x, ll y, ll& a, ll& key) {
a = kth(root, x);
splay_to(a, 0);
ll b = kth(a, y + 2); // a 在根上用全局位置:A[y] 在 y+1,后继在 y+2
splay_to(b, a);
key = nd[b].ch[0];
}
// 翻转 A 的区间 [x, y]
void reverse_seg(ll x, ll y) {
if (x >= y) return;
ll a, key;
segment(x, y, a, key);
nd[key].rev ^= 1;
pull_up(a);
}
// 区间右轮换 t 次:尾部 t 个移到前面,等价于三次翻转
void revolve(ll x, ll y, ll t) {
t %= (y - x + 1);
if (t) {
reverse_seg(x, y - t);
reverse_seg(y - t + 1, y);
reverse_seg(x, y);
}
}
// 把 A[lo..hi] 建成尽量平衡的子树,返回子树根
ll build(ll lo, ll hi, ll par) {
if (lo > hi) return 0;
ll mid = (lo + hi) >> 1;
ll x = new_node(A[mid], par);
nd[x].ch[0] = (int)build(lo, mid - 1, x);
nd[x].ch[1] = (int)build(mid + 1, hi, x);
pull(x);
return x;
}
int main() {
ll n;
if (scanf("%lld", &n) != 1) return 0;
A.assign(n, 0);
for (ll i = 0; i < n; i++) {
scanf("%lld", &A[i]);
}
ll m;
scanf("%lld", &m);
ll cap = n + m + 5; // 左右哨兵 2 个 + 原序列 n 个 + 每次 INSERT 最多新增 1 个
nd.assign(2 * cap + 10, Node());
nd[0].mn = INF; // 空节点不能把子树最小值拉成 0
A.push_back(INF); // 右哨兵并进建树列表
ll head = new_node(INF, 0); // 左哨兵:树中第 1 个位置
ll mid_root = build(0, n, head);
nd[head].ch[1] = (int)mid_root;
pull(head);
root = head;
std::vector<char> opname(16);
for (ll q = 0; q < m; q++) {
char buf[16];
scanf("%s", buf);
std::string op(buf);
if (op == "ADD") {
ll x, y, d;
scanf("%lld %lld %lld", &x, &y, &d);
ll a, key;
segment(x, y, a, key);
// 懒标记只打在子树根上:子树自身的最小值立即修正
nd[key].val += d;
nd[key].mn += d;
nd[key].add += d;
pull_up(a);
} else if (op == "REVERSE") {
ll x, y;
scanf("%lld %lld", &x, &y);
reverse_seg(x, y);
} else if (op == "REVOLVE") {
ll x, y, t;
scanf("%lld %lld %lld", &x, &y, &t);
revolve(x, y, t);
} else if (op == "INSERT") {
ll x, p;
scanf("%lld %lld", &x, &p);
ll a = kth(root, x + 1); // A 的第 x 个元素
splay_to(a, 0);
ll b = kth(a, x + 2); // 全局位置:A[x] 在 x+1,后继在 x+2
splay_to(b, a);
ll c = new_node(p, b);
nd[b].ch[0] = (int)c; // 此刻 b 的左子树恰好为空
pull(b);
pull_up(a);
} else if (op == "DELETE") {
ll x;
scanf("%lld", &x);
ll a = kth(root, x); // 树中第 x 个 = A 的第 x-1 个(可能是左哨兵)
splay_to(a, 0);
ll b = kth(a, x + 2); // 全局位置:A[x] 在 x+1,后继在 x+2
splay_to(b, a);
nd[b].ch[0] = 0; // 此刻 b 的左子树恰好只有 A[x]
pull(b);
pull_up(a);
} else { // MIN
ll x, y;
scanf("%lld %lld", &x, &y);
ll a, key;
segment(x, y, a, key);
printf("%lld\n", nd[key].mn);
}
}
return 0;
}#!/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 18:41
# update_at: 2026-10-01 18:41
import sys
INF = 10**18 # 空子树与两个哨兵的最小值,保证永远不会成为 MIN 答案
# 用平行数组存 splay:0 号点表示空;树的中序 = [左哨兵, A1..An, 右哨兵]
ch: list[list[int]] = [] # ch[x] = [左孩子, 右孩子]
fa: list[int] = [] # 父亲
val: list[int] = [] # 节点自身的值
mn: list[int] = [] # 子树最小值
add: list[int] = [] # 子树整体加法的懒标记
rev: list[int] = [] # 子树待翻转的懒标记(push 时换左右孩子)
siz: list[int] = [] # 子树大小
cnt = 0 # 已用节点数
root = 0
A: list[int] = []
def new_node(v: int, par: int) -> int:
"""分配一个值为 v、父亲为 par 的新节点。"""
global cnt
cnt += 1
val[cnt] = mn[cnt] = v
siz[cnt] = 1
fa[cnt] = par
ch[cnt] = [0, 0]
return cnt
def pull(x: int) -> None:
"""由左右孩子重算 x 的子树大小与最小值。"""
l, r = ch[x]
siz[x] = siz[l] + siz[r] + 1
mn[x] = min(val[x], mn[l], mn[r])
def pull_up(x: int) -> None:
"""从 x 往上逐层重算聚合值,直到根。"""
while x:
pull(x)
x = fa[x]
def push(x: int) -> None:
"""把 x 的懒标记下传一层:整子树加 D、整子树翻转。"""
if add[x]:
d = add[x]
add[x] = 0
for s in ch[x]:
if s:
val[s] += d
mn[s] += d
add[s] += d
if rev[x]:
rev[x] = 0
l, r = ch[x]
ch[x] = [r, l]
if l:
rev[l] ^= 1
if r:
rev[r] ^= 1
def push_path(x: int) -> None:
"""把根到 x 路径上的懒标记自上而下清空。"""
path = []
while x:
path.append(x)
x = fa[x]
for y in reversed(path):
push(y)
def rotate(x: int) -> None:
"""把 x 上旋一层(x 及其祖先的懒标记此时都已被 push_path 清空)。"""
y = fa[x]
z = fa[y]
side = ch[y][1] == x # x 是 y 的哪一侧孩子
w = ch[x][side ^ 1]
if w:
fa[w] = y
ch[y][side] = w
ch[x][side ^ 1] = y
fa[y] = x
fa[x] = z
if z:
ch[z][ch[z][1] == y] = x
pull(y)
def splay_to(x: int, goal: int) -> None:
"""把 x 转成 goal 的孩子(goal=0 表示转成根),并把路径聚合值重算干净。"""
global root
push_path(x)
while fa[x] != goal:
y = fa[x]
z = fa[y]
if z != goal and (ch[z][1] == y) == (ch[y][1] == x):
rotate(y) # 一字型先转父亲,避免退化成链
if fa[x] != goal:
rotate(x)
if not goal:
root = x # 转到根后同步全局根指针
pull(x)
pull_up(fa[x])
def kth(start: int, k: int) -> int:
"""从 start 出发找中序第 k 个元素(沿途把懒标记下传)。"""
x = start
while True:
push(x)
left = siz[ch[x][0]]
if k == left + 1:
return x
if k <= left:
x = ch[x][0]
else:
k -= left + 1
x = ch[x][1]
def segment(x: int, y: int) -> tuple[int, int]:
"""把 A 的区间 [x, y] 切成一颗完整子树,返回 (a, 子树根)。
左哨兵占去树中第 1 个位置,A 的第 i 个元素在树中第 i+1 个。把
a = 树中第 x 个(A[x-1] 或左哨兵)转到根,b = 树中第 y+2 个(A[y] 的后继)
转成 a 的孩子,b 的左子树恰好就是整个区间。
"""
a = kth(root, x)
splay_to(a, 0)
b = kth(a, y + 2) # a 在根上用全局位置:A[y] 在 y+1,后继在 y+2
splay_to(b, a)
return a, ch[b][0]
def reverse_seg(x: int, y: int) -> None:
"""翻转 A 的区间 [x, y]。"""
if x >= y:
return
a, key = segment(x, y)
rev[key] ^= 1
pull_up(a)
def revolve(x: int, y: int, t: int) -> None:
"""区间右轮换 t 次:尾部 t 个移到前面,等价于三次翻转。
设段为 A B(A 是前 len-t 个,B 是后 t 个),目标是 B A;
而 (A^r B^r)^r = B A,所以依次翻 A、翻 B、整段再翻。
"""
t %= y - x + 1
if t:
reverse_seg(x, y - t)
reverse_seg(y - t + 1, y)
reverse_seg(x, y)
def build(lo: int, hi: int, par: int) -> int:
"""把 A[lo..hi] 建成尽量平衡的子树,返回子树根。"""
if lo > hi:
return 0
mid = (lo + hi) >> 1
x = new_node(A[mid], par)
ch[x][0] = build(lo, mid - 1, x)
ch[x][1] = build(mid + 1, hi, x)
pull(x)
return x
def solve() -> None:
global root, cnt, A
it = iter(sys.stdin.buffer.read().split())
out: list[str] = []
n = int(next(it))
A = [int(next(it)) for _ in range(n)]
m = int(next(it))
# 容量:左右哨兵 2 个 + 原序列 n 个 + 每次 INSERT 最多新增 1 个
cap = n + m + 5
for arr in (ch, fa, val, mn, add, rev, siz):
arr.extend([0] * (2 * cap)) # ch 需要成对占位,多开一倍更省心
mn[0] = INF # 空节点不能把子树最小值拉成 0
A.append(INF) # 右哨兵并进建树列表,保证任何后继都存在
head = new_node(INF, 0) # 左哨兵:树中第 1 个位置
mid_root = build(0, n, head) # 依次为 A1..An、右哨兵
ch[head][1] = mid_root
pull(head)
root = head
for _ in range(m):
op = next(it)
if op == b'ADD':
x, y, d = int(next(it)), int(next(it)), int(next(it))
a, key = segment(x, y)
# 懒标记只打在子树根上:子树自身的最小值立即修正
val[key] += d
mn[key] += d
add[key] += d
pull_up(a)
elif op == b'REVERSE':
reverse_seg(int(next(it)), int(next(it)))
elif op == b'REVOLVE':
revolve(int(next(it)), int(next(it)), int(next(it)))
elif op == b'INSERT':
x, p = int(next(it)), int(next(it))
a = kth(root, x + 1) # A 的第 x 个元素
splay_to(a, 0)
b = kth(a, x + 2) # 全局位置:A[x] 在 x+1,后继在 x+2(可能是右哨兵)
splay_to(b, a)
c = new_node(p, b)
ch[b][0] = c # 此刻 b 的左子树恰好为空
pull(b)
pull_up(a)
elif op == b'DELETE':
x = int(next(it))
a = kth(root, x) # 树中第 x 个 = A 的第 x-1 个(可能是左哨兵)
splay_to(a, 0)
b = kth(a, x + 2) # 全局位置:A[x] 在 x+1,后继在 x+2
splay_to(b, a)
ch[b][0] = 0 # 此刻 b 的左子树恰好只有 A[x]
pull(b)
pull_up(a)
else: # MIN
x, y = int(next(it)), int(next(it))
_, key = segment(x, y)
out.append(str(mn[key]))
print('\n'.join(out))
if __name__ == "__main__":
solve()Python 版按教学短解法风格实现:平行数组存树、迭代 splay(免递归爆栈)、两个哨兵。同样的算法落地到 C++ 就是常见的 ch/fa/val/mn/add/rev/siz 数组 + rotate/splay/push/pull 模板,逻辑一一对应。
复杂度
- 时间:建树
;每次操作常数次 splay/kth,均摊,总计 ,与 C++ 正解同阶。Python 常数较大,OJ 1s 限内可能 TLE(本地放宽 10 倍限时后最强数据 4.3s 通过),按约定接受,不改为暴力。 - 空间:
个节点(每次 INSERT至多新增一个),平行数组存下,最强大的测点峰值约 92MB。
总结
- 序列被翻转、轮换、插删,位置信息不可靠 ⇒ 用平衡树的中序表示序列,“区间”=“完整子树”。
- 两次 splay 切出区间子树后,
ADD/REVERSE打懒标记,MIN读根,INSERT/DELETE在相邻位置间修补指针。 REVOLVE用恒等式化成三次翻转,不写任何循环移位。 - 两个
哨兵让"前驱/后继"永远存在,边界统一;空节点 mn也置,防止污染子树最小值。
图示解析
整体路线:一段序列在树中始终是"中序连续段",任何区间操作都走同一条"定位 → 切出 → 碰根 → 合回"的流水线:
flowchart LR
K["kth 定位<br/>(沿途 push 懒标记)"] --> S["两次 splay<br/>切出区间子树"]
S --> T{"操作类型"}
T -- ADD/REVERSE --> L["子树根打懒标记"]
T -- REVOLVE --> V["三次 REVERSE 复用翻转"]
T -- MIN --> M["读子树根 mn"]
T -- INSERT/DELETE --> P["前驱-后继之间<br/>O(1) 接/摘叶子"]
L --> U["pull 上收,哨兵复位"]
V --> U
M --> U
P --> U
读者只需盯住一点:无论哪种操作,被修改的都只有"切出来的那颗子树的根"(插删是它旁边的两个指针),其余节点原封不动。这正是均摊