天梯赛L3-026传送门:用Splay树模拟‘交换后缀’,保姆级代码逐行解析
天梯赛L3-026传送门:用Splay树模拟‘交换后缀’,保姆级代码逐行解析
在算法竞赛中,数据结构的选择往往决定了解决问题的效率与优雅程度。天梯赛L3-026传送门这道题目,表面上看是一个关于路径操作的模拟题,实则暗藏了对高级数据结构应用的深度考察。本文将带您深入剖析如何用Splay树这一灵活的自平衡二叉搜索树,来高效模拟题目中的"交换后缀"操作。
1. 问题重述与核心思路
题目描述了一个由n条平行路径构成的世界,每条路径上有若干个传送门。关键操作是"架设传送门"——这实际上等价于交换两条路径上从某个坐标开始的后缀部分。这种操作会动态改变路径结构,因此需要一种能够高效支持区间操作的数据结构。
核心观察点:
- 每次操作只影响两条路径的特定后缀
- 操作可能嵌套,即被交换的后缀本身包含其他传送门
- 需要动态维护路径结构并快速计算影响
经过分析,Splay树的以下特性使其成为理想选择:
- 动态性:支持在O(log n)时间内完成子树分裂与合并
- 灵活性:无需严格的平衡条件,实现相对简单
- 功能性:天然支持区间操作
2. 解题框架与关键组件
2.1 离散化处理
由于坐标范围可能很大(到1e9),直接建树不现实。我们需要先对每条路径上的坐标进行离散化:
vector<vector<int>> bt(n+1); for(int i = 1; i <= n; i++){ bt[i].push_back(0); // 左哨兵 bt[i].push_back(inf); // 右哨兵 } // 收集所有出现的y值 for(auto &[op,x1,x2,y]:q){ bt[x1].push_back(y); bt[x2].push_back(y); } // 去重排序完成离散化 for(int i =1 ; i <= n; i++){ sort(bt[i].begin(),bt[i].end()); bt[i].erase(unique(bt[i].begin(),bt[i].end()), bt[i].end()); }2.2 Splay树节点设计
每个节点需要维护以下信息:
- 左右子节点指针
- 父节点指针
- 节点值(离散化后的坐标)
- 子树大小
struct node{ int s[2], v, p; // 左右孩子、值、父节点 int size; // 子树大小 void init(int _v, int _p){ v = _v, p = _p, size = 1; } }tr[N<<2]; // 需要开四倍空间2.3 建树与预处理
采用递归方式建树,同时记录每个离散化值对应的节点指针:
int build(int l, int r, int p, int id) { int mid = l + r >> 1; int u = ++idx; tr[u].init(bt[id][mid], p); ver[id][mid] = u; // 记录该离散化值对应的节点 if(l < mid) ls(u) = build(l, mid-1, u, id); if(r > mid) rs(u) = build(mid+1, r, u, id); pushup(u); return u; }3. 核心操作实现
3.1 Splay操作
Splay操作是保持树平衡的关键,包含旋转和双旋转两种情况:
void rotate(int x) { int y = tr[x].p, z = tr[y].p; int k = tr[y].s[1] == x; // 判断x是y的哪个孩子 tr[z].s[tr[z].s[1] == y] = x, tr[x].p = z; tr[y].s[k] = tr[x].s[k^1], tr[tr[x].s[k^1]].p = y; tr[x].s[k^1] = y, tr[y].p = x; pushup(y), pushup(x); } void splay(int x, int k, int id) { while(tr[x].p != k) { int y = tr[x].p, z = tr[y].p; if(z != k) { if((tr[z].s[0] == y) == (tr[y].s[0] == x)) rotate(y); else rotate(x); } rotate(x); } if(!k) root[id] = x; }3.2 交换后缀操作
这是本题最核心的部分,需要将两条路径从指定位置开始的后缀交换:
for(auto [op,x1,x2,y]:q){ // 找到离散化后的位置 int l1 = lower_bound(bt[x1].begin(),bt[x1].end(),y)-bt[x1].begin(); int l2 = lower_bound(bt[x2].begin(),bt[x2].end(),y)-bt[x2].begin(); // 获取对应节点 int u1 = ver[x1][l1], u2 = ver[x2][l2]; // 将两个节点splay到根 splay(u1, 0, x1), splay(u2, 0, x2); // 交换右子树(即后缀) swap(rs(u1), rs(u2)); // 更新父指针和size tr[rs(u1)].p = u1, tr[rs(u2)].p = u2; pushup(u1), pushup(u2); // 计算答案变化 int st1 = get_k(1,u1), ed1 = get_k(tr[u1].size,u1); int st2 = get_k(1,u2), ed2 = get_k(tr[u2].size,u2); ans += 1ll*st1*ed1 + 1ll*st2*ed2; ans -= 1ll*st1*ed2 + 1ll*st2*ed1; cout << ans << "\n"; }4. 实现细节与优化技巧
4.1 哨兵节点的作用
在离散化时添加0和inf作为哨兵,确保:
- 总能找到小于/大于任何实际坐标的边界值
- 简化边界条件的处理
- 保证树结构的完整性
4.2 空间复杂度分析
为什么需要开四倍空间?
- 原始n个路径
- 每个路径添加2个哨兵节点
- 每个查询涉及2个路径,可能新增2个节点
因此最坏情况下总节点数约为4n量级。
4.3 时间复杂度保证
每个操作主要包含:
- 两次二分查找(O(log m))
- 两次splay操作(均摊O(log m))
- 子树交换(O(1))
因此单次操作时间复杂度为O(log m),m为路径上的节点数。
5. 常见错误与调试技巧
在实现这类复杂数据结构题目时,容易遇到以下问题:
内存越界:
- 检查空间是否足够(特别是四倍空间规则)
- 确认节点编号从1开始还是0开始
旋转错误:
- 确保在rotate后正确更新所有相关指针
- 特别注意父指针的更新顺序
size维护错误:
- 在任何可能改变树结构的操作后调用pushup
- 验证旋转、合并、分裂后size的正确性
调试建议:
- 先实现并验证splay基本操作的正确性
- 用小数据测试建树过程
- 逐步添加操作,验证每个步骤
- 使用assert检查不变量
// 示例调试代码 void check(int u) { if(!u) return; assert(tr[u].size == 1 + tr[ls(u)].size + tr[rs(u)].size); if(ls(u)) assert(tr[ls(u)].p == u); if(rs(u)) assert(tr[rs(u)].p == u); check(ls(u)); check(rs(u)); }6. 扩展思考与变式
掌握了这个解法后,可以思考以下变式问题:
- 如果传送门有方向性(单向传送),如何修改模型?
- 如果每次操作影响的是一个区间而非后缀,如何调整?
- 能否用其他平衡树(如Treap)实现相同功能?
- 如果要求支持撤销操作,该如何设计?
这些思考可以帮助深化对Splay树和区间操作的理解。在实际比赛中,选择合适的数据结构并正确实现其核心操作,往往是解决复杂问题的关键。
