Skip to content

Commit 6a2b05e

Browse files
authored
Create luogu-P14761-splay-2.md
1 parent 14b1c0a commit 6a2b05e

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
Title: Luogu P14761 - CCD 的序列【动态序列统计】(之二)
2+
Date: 2025-12-27 21:00
3+
Category: 算法与数据结构
4+
Tags: Splay, 隐式Splay, 平衡树, 动态序列, 括号序列
5+
Slug: luogu-P14761-splay-2
6+
Author: Wizmann
7+
Summary: 本文围绕隐式 Splay 的基本原理与实现方式,重点说明 splay 操作、旋转模式以及区间隔离的工程实现细节,并展示其在动态序列问题中的实际用法。
8+
9+
在上一篇中,我们已经完成了整道题**最关键的建模工作**
10+
把“匹配关系发生变更”转化为区间统计问题。问题最终被化简为:**在一个动态变化的序列上,支持区间查询与任意位置插入**
11+
12+
这一篇我们将会讨论:**Splay 数据结构本身的功能与实现方式**,以及它为什么适合承载这个问题。
13+
14+
---
15+
16+
## 1. Splay 是“自调整”的,而不是“显式平衡”的
17+
18+
在 AVL、SBT 等平衡树中,我们的核心目标是维护某种平衡条件。一旦条件被破坏,就通过旋转把树“修回来”。也就是说,**平衡是目标,旋转是手段**
19+
20+
Splay 的思路是反过来的。它并不关心当前这棵树“是不是平衡”,而只关心一件事:**把刚刚访问过的节点,拉到树的上层**
21+
22+
直观理解就是:
23+
经常被访问的节点,就应该离根更近;这样后续再访问它,代价自然更小。因此在 Splay 中,**访问本身,就是结构调整的理由**。至于整棵树的形态是否平衡,并不是一个被显式维护的目标。
24+
25+
---
26+
27+
## 2. 什么是 splay 操作
28+
29+
`splay(x)` 的含义非常直接:
30+
**通过一系列旋转,把节点 `x` 拉到当前树的根节点(或某个指定节点之下)**
31+
32+
需要注意的是:
33+
34+
* `x` 不一定是叶子;
35+
* 整个过程中可能发生多次旋转;
36+
* 每次旋转都只是普通的左旋或右旋。
37+
38+
Splay 的复杂性不在于旋转本身,而在于**旋转顺序的选择**
39+
40+
---
41+
42+
## 3. Splay 中的三种经典旋转模式
43+
44+
设当前要 splay 的节点是 `x`,其父节点是 `p`,祖父节点是 `g`。根据 `x``p``g` 三者之间的位置关系,旋转会分为三种典型情况。
45+
46+
### Zig(单旋)
47+
48+
`p` 本身就是根节点(即不存在祖父 `g`)时,只需要一次旋转。
49+
50+
此时旋转的目标非常明确:
51+
**`x` 成为新的根节点**
52+
53+
```
54+
p x
55+
/ \ / \
56+
x C ==> A p
57+
/ \ / \
58+
A B B C
59+
```
60+
61+
旋转结束后,`x` 被直接提升到了根位置,这是最简单的一种情况。
62+
63+
---
64+
65+
### Zig-Zig(同向双旋)
66+
67+
`x``p` 方向相同(同为左儿子,或同为右儿子)时,就会出现一条“歪着的链”。
68+
69+
```
70+
g
71+
/
72+
p
73+
/
74+
x
75+
```
76+
77+
这种结构如果只旋一次,无法有效把 `x` 提到高位。因此需要连续两次旋转:
78+
79+
1. 先旋转 `p``g`
80+
2. 再旋转 `x``p`
81+
82+
目标并不是单纯把 `x` 提到根,而是**整体压缩这条同向的访问路径**,让常访问的节点整体靠上。
83+
84+
```
85+
g g x
86+
/ \ / \ / \
87+
p D (1) x D (2) A p
88+
/ \ ==> / \ ==> / \
89+
x C A p B g
90+
/ \ / \ / \
91+
A B B C C D
92+
```
93+
94+
---
95+
96+
### Zig-Zag(反向双旋)
97+
98+
`x``p` 方向相反时,结构呈现出“拧着”的形态:
99+
100+
```
101+
g
102+
/
103+
p
104+
\
105+
x
106+
```
107+
108+
这种情况下,如果直接旋 `p``g`,结构会变得更糟。因此正确的顺序是:
109+
110+
1. 先旋转 `x``p`
111+
2. 再旋转 `x``g`
112+
113+
目标是先把结构“拧正”,再把 `x` 提到更高的位置。
114+
115+
```
116+
g g x
117+
/ \ / \ / \
118+
p D (1) x D (2) p g
119+
/ \ ==> / \ ==> / \ / \
120+
A x p C A B C D
121+
/ \ / \
122+
B C A B
123+
```
124+
125+
---
126+
127+
## 4. 为什么 Splay 不需要维护平衡条件
128+
129+
直观地看:
130+
131+
* 如果一棵树长期非常“歪”,
132+
* 那说明你反复在访问某一侧的节点,
133+
* 而 Splay 会不断把这些节点旋转到上层,
134+
* 最终这条路径会被逐渐压缩。
135+
136+
因此,**坏结构往往对应“不常用”的访问路径,而常用路径会被不断优化**。这也是为什么:
137+
138+
* 单次操作的复杂度可能很高;
139+
* 但在一整段操作序列上,Splay 的**均摊复杂度是 `O(log n)`**
140+
141+
在竞赛环境中,这样的保证是足够可靠的。
142+
143+
---
144+
145+
## 5. 隐式 Splay 与普通 Splay 的区别
146+
147+
普通 Splay 是一棵标准的 BST,节点中有显式的 key,满足左小右大的性质。
148+
149+
而在本题中使用的是**隐式 Splay**。换句话说,**节点本身不存 key,而是把整个序列按顺序映射到一棵 BST 的中序遍历上**
150+
151+
于是:
152+
153+
* 中序遍历顺序 = 序列顺序;
154+
*`k` 个元素 = 中序遍历第 `k` 个节点。
155+
156+
这样一来:
157+
158+
* 查找第 `k` 个位置;
159+
* 在第 `k` 个位置插入;
160+
* 查询区间 `[l, r]`
161+
162+
都可以统一转化为**基于子树大小的树操作**
163+
164+
---
165+
166+
## 6. 区间信息的维护
167+
168+
在每个节点中,我们维护该节点所代表区间的信息。这一点与 AVL、SBT 等平衡树在旋转中维护区间信息并无本质区别。
169+
170+
旋转只会改变父子关系,不会破坏区间的连续性,因此只要在旋转后正确更新受影响节点的区间信息即可。
171+
172+
---
173+
174+
## 7. 为什么 `push_up` 是“三段合并”
175+
176+
在隐式 Splay 中,一个节点天然代表如下区间结构:
177+
178+
```
179+
[ 左子树 ] + [ 当前字符 ] + [ 右子树 ]
180+
```
181+
182+
因此在更新节点信息时,只需要按顺序合并这三段区间:
183+
184+
```
185+
info = merge( merge(left, self), right )
186+
size = size(left) + 1 + size(right)
187+
```
188+
189+
每一次旋转结束后,只需对被下沉和被提升的节点各执行一次 `push_up`,区间信息就会自动恢复正确。
190+
191+
---
192+
193+
## 8. 总结
194+
195+
到这里,Splay 的功能与实现已经足够清晰了。
196+
197+
在本题中,Splay 本身并不承担复杂的逻辑,它只是提供了一种能够支持动态序列插入与区间定位的结构。真正与题目模型直接相关的,是节点中维护的区间信息及其合并方式。
198+

0 commit comments

Comments
 (0)