|
| 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