信奥赛C++提高组csp-s之FHQ Treap

信奥赛C++提高组csp-s之平衡树(FHQ Treap)

什么是FHQ Treap

FHQ Treap(无旋Treap)是一种基于Tree(二叉搜索树)+ Heap(堆)的数据结构。它的核心特点是不需要旋转操作,仅依靠**分裂(Split)合并(Merge)**两个核心操作就能实现所有平衡树功能。这也是它相比Splay和有旋Treap的最大优势------代码短小、易于理解、支持可持久化。

Treap中的每个节点都存储两个关键值:val (节点权值,满足BST性质:左子树所有节点val ≤ 当前val ≤ 右子树所有节点val)和key(随机优先级,满足堆性质)。随机优先级的引入使得树的高度在期望上保持O(log n),从而保证所有操作的复杂度为O(log n)。

FHQ Treap所有操作(插入、删除、查询排名、查询第k大、前驱、后继、区间翻转等)都可以通过组合split和merge来完成。

1.1 数据结构存储

每个节点存储:左右儿子编号、节点权值val、随机优先级key、子树大小size(用于按size分裂和排名查询)。

1.2 核心操作一:分裂(Split)

分裂操作将一棵树按权值(或按size)分成两棵树:左树包含所有val ≤ pivot的节点,右树包含所有val > pivot的节点。

实现逻辑:从根节点开始遍历,若当前节点val ≤ pivot,则将该节点及左子树归入左树,然后递归处理右子树;否则归入右树,递归处理左子树。每次递归后更新节点size。

1.3 核心操作二:合并(Merge)

合并操作将两棵树合并为一棵,前提是左树中所有节点的val都小于等于右树中所有节点的val。合并时比较两棵树根的key优先级,key小的作为新树的根(小根堆),然后递归合并子树。

1.4 为什么无旋Treap是平衡的

FHQ Treap的平衡性来源于随机优先级------合并时完全按照优先级大小决定树的形态,不受插入顺序的影响。由于优先级随机,树的期望高度为O(log n),保证了操作效率。

案例研究:普通平衡树

题目描述

您需要动态地维护一个可重集合 M M M,并且提供以下操作:

  1. 向 M M M 中插入一个数 x x x。
  2. 从 M M M 中删除一个数 x x x。(若有多个相同的数,应只删除一个)
  3. 查询 M M M 中有多少个数比 x x x 小,并且将得到的答案加 1 1 1。
  4. 查询如果将 M M M 从小到大排列后,排名位于第 x x x 位的数。
  5. 查询 M M M 中 x x x 的前驱(定义为 M M M 中小于 x x x,且最大的数)。
  6. 查询 M M M 中 x x x 的后继(定义为 M M M 中大于 x x x,且最小的数)。

对于操作 3 , 5 , 6 3,5,6 3,5,6,不保证 当前可重集中存在数 x x x。

对于操作 4 , 5 , 6 4,5,6 4,5,6,保证答案一定存在。

输入格式

第一行为 n n n,表示操作的个数,下面 n n n 行每行有两个数 opt \text{opt} opt 和 x x x, opt \text{opt} opt 表示操作的序号( 1 \\leq \\text{opt} \\leq 6 )。

输出格式

对于操作 3 , 4 , 5 , 6 3,4,5,6 3,4,5,6 每行输出一个数,表示对应答案。

输入输出样例 1
输入 1
复制代码
10
1 106465
4 1
1 317721
1 460929
1 644985
1 84185
1 89851
6 81968
1 492737
5 493598
输出 1
复制代码
106465
84185
492737
【数据范围】

对于 100 % 100\% 100% 的数据, 1 ≤ n ≤ 10 5 1\le n \le 10^5 1≤n≤105, ∣ x ∣ ≤ 10 7 |x| \le 10^7 ∣x∣≤107。

思路分析(FHQ Treap)

本题要求动态维护一个可重集,支持插入、删除、查排名、查第k小、查前驱、查后继六种操作。

使用 FHQ Treap(无旋 Treap) 实现,它基于分裂(split)和合并(merge)两个核心操作,代码短、易于理解,且能完美处理重复元素。

核心设计
  • 每个节点存储:值 val、随机优先级 pri、重复次数 cnt、子树总大小 sz(即 cnt 之和)、左右儿子 l, r
  • 用数组模拟节点,0 表示空节点。
  • 分裂 split(u, key, &x, &y) :将树 u 按值 key 分成两棵树 xy,其中 x 中所有节点的值 ≤ keyy 中所有节点的值 > key
  • 合并 merge(x, y) :合并两棵树,要求 x 中所有值 ≤ y 中所有值,按优先级维持堆性质。
  • 更新 update(u) :计算 sz[u] = sz[l] + sz[r] + cnt[u]
各操作实现方法
  1. 插入 x

    x-1x 分裂成三部分:L(< x)、M(= x)、R(> x)。

    M 非空,则增加 cnt;否则新建节点。最后合并 L, M, R

  2. 删除 x

    同样分裂成 L, M, R。若 Mcnt > 1,则 cnt--;否则丢弃 M(不合并)。最后合并 L, M, R

  3. 查询比 x 小的数的个数 +1

    x-1 分裂成 L(< x)和 R(≥ x)。答案 = sz[L] + 1。合并 L, R

  4. 查询排名为 k 的数

    从根开始递归:若左子树大小 ≥ k,则进入左子树;否则减去左子树大小和当前节点重复次数,进入右子树。

  5. 查询 x 的前驱(小于 x 的最大数)

    x-1 分裂成 L(< x)和 R(≥ x)。在 L 中一直向右走找到最大值。合并。

  6. 查询 x 的后继(大于 x 的最小数)

    x 分裂成 L(≤ x)和 R(> x)。在 R 中一直向左走找到最小值。合并。

所有操作时间复杂度均为 O(log n) ,空间复杂度 O(n)


代码实现

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;  // 最大操作次数
int n, rt; // rt 为根节点编号
struct Node {
    int l, r; // 左右儿子编号
    int val, pri; // 值、随机优先级
    int cnt, sz; // 重复次数、子树大小(cnt之和)
} tr[N];

// 更新节点 u 的子树大小
void upd(int u) {
    tr[u].sz = tr[tr[u].l].sz + tr[tr[u].r].sz + tr[u].cnt;
}

// 创建新节点,返回编号
int add(int v) {
    static int idx = 0;
    int u = ++idx;
    tr[u] = {0, 0, v, rand(), 1, 1};
    return u;
}

// 分裂:将以 u 为根的树按值 key 分成 x(≤key)和 y(>key)
void split(int u, int key, int &x, int &y) {
    if (!u) {
        x = y = 0;
        return;
    }
    if (tr[u].val <= key) {
        x = u;
        split(tr[u].r, key, tr[u].r, y);
    } else {
        y = u;
        split(tr[u].l, key, x, tr[u].l);
    }
    upd(u);
}

// 合并:将 x 和 y 两棵树合并(x 中所有值 ≤ y 中所有值)
int merge(int x, int y) {
    if (!x || !y) return x | y;
    if (tr[x].pri < tr[y].pri) {// 优先级小的作为根(小根堆)
        tr[x].r = merge(tr[x].r, y);
        upd(x);
        return x;
    } else {
        tr[y].l = merge(x, tr[y].l);
        upd(y);
        return y;
    }
}

// 查询第 k 小的数(k 从 1 开始)
int kth(int u, int k) {
    while (u) {
        int lsz = tr[tr[u].l].sz;
        if (k <= lsz)
            u = tr[u].l;
        else if (k <= lsz + tr[u].cnt)
            return tr[u].val;
        else {
            k -= lsz + tr[u].cnt;
            u = tr[u].r;
        }
    }
    return 0; // 不会执行到这里,因为保证有答案
}

int main() {
    srand(time(0)); // 初始化随机种子
    scanf("%d", &n);
    rt = 0;

    while (n--) {
        int op, x;
        scanf("%d%d", &op, &x);

        if (op == 1) {// 插入 x
            int L, M, R;
            split(rt, x - 1, L, R);
            split(R, x, M, R);  // M 中为值等于 x 的节点
            if (M) tr[M].cnt++, upd(M);
            else M = add(x);
            rt = merge(merge(L, M), R);
        }
        else if (op == 2) {// 删除 x
            int L, M, R;
            split(rt, x - 1, L, R);
            split(R, x, M, R);
            if (tr[M].cnt > 1) tr[M].cnt--, upd(M);
            else M = 0; // 丢弃该节点
            rt = merge(merge(L, M), R);
        }
        else if (op == 3) {// 查询比 x 小的数的个数 +1
            int L, R;
            split(rt, x - 1, L, R);
            printf("%d\n", tr[L].sz + 1);
            rt = merge(L, R);
        }
        else if (op == 4) { // 查询第 x 小的数
            printf("%d\n", kth(rt, x));
        }
        else if (op == 5) { // 查询 x 的前驱
            int L, R;
            split(rt, x - 1, L, R);
            int u = L;
            while (tr[u].r) u = tr[u].r;  // 左树的最大值
            printf("%d\n", tr[u].val);
            rt = merge(L, R);
        }
        else if (op == 6) { // 查询 x 的后继
            int L, R;
            split(rt, x, L, R);
            int u = R;
            while (tr[u].l) u = tr[u].l;  // 右树的最小值
            printf("%d\n", tr[u].val);
            rt = merge(L, R);
        }
    }
    return 0;
}

功能分析

操作 描述 实现方式 时间复杂度
1 插入 x 分裂成 <x=x>x,增加重复次数或新建节点 O(log n)
2 删除 x 同上,减少重复次数或丢弃节点 O(log n)
3 查询比 x 小的个数 +1 分裂成 <x≥x,输出左子树大小 +1 O(log n)
4 查询第 k 小的数 递归/循环根据子树大小查找 O(log n)
5 查询 x 的前驱 分裂成 <x≥x,在左子树中找最大值 O(log n)
6 查询 x 的后继 分裂成 ≤x>x,在右子树中找最小值 O(log n)

空间复杂度:O(n),最多存储 n 个节点(每个插入操作最多新增一个节点)。

普通Treap vs FHQ Treap(以本题为案例)

1. 核心操作实现
操作 普通Treap(旋转版) FHQ Treap(分裂/合并版)
插入 递归插入后通过旋转调整堆性质 split 成 ≤x 和 >x,再 merge
删除 递归找到节点,合并左右子树(或降优先级后旋转) split 成 <x、=x、>x,减少计数或丢弃节点
查排名 递归根据大小累加 split 成 <x 和 ≥x,输出左子树大小+1
查第k小 递归查找 与普通Treap相同,但不需要旋转
前驱 递归:若当前≤x则往右,否则往左 split 成 <x 和 ≥x,取左子树最大值
后继 递归:若当前≥x则往左,否则往右 split 成 ≤x 和 >x,取右子树最小值
2. 代码复杂度
  • 普通Treap:需要实现左旋、右旋,插入/删除函数中旋转逻辑较复杂,且删除操作通常要处理将节点旋转到叶子再删除,代码量较多。
  • FHQ Treap :只需实现 splitmerge 两个核心函数,所有操作都基于它们,逻辑清晰统一,代码量少。代码更简洁,不易出错
3. 性能
  • 时间常数 :两者均为 O(log n),但普通Treap的旋转操作涉及多次指针/索引修改,常数略小;FHQ Treap的 splitmerge 每次操作会递归分裂或合并,常数稍大,但在 n ≤ 1e5 时差距可忽略。
  • 空间:两者都是 O(n),每个节点存储左右儿子、值、优先级、大小、重复次数等,基本相同。
4. 扩展性
特性 普通Treap FHQ Treap
区间操作(如翻转) 难以实现,需要维护懒标记且旋转会破坏区间结构 天然支持,可通过 split 按排名分裂,打标记后合并
可持久化 需要复制节点并旋转,实现复杂 只需在 split/merge 时新建节点,可轻松实现可持久化
多重集 需额外存储 cnt,或允许相同值节点 同样用 cnt 处理,且分裂策略明确(≤x 与 >x)

本题仅需基础操作,两者均可胜任。但FHQ Treap的代码模式更通用,稍加修改就能应对更复杂的问题(如文艺平衡树、可持久化平衡树)。

5. 易调试性
  • 普通Treap:旋转容易写错方向,调试时需画图检查堆性质是否保持。
  • FHQ Treapsplitmerge 逻辑直观,可以分段测试(例如先测试 split 再测试 merge),且不需要处理旋转带来的父子关系错乱。调试更容易
6. 处理重复元素

两者都可通过在节点中增加 cnt 计数来处理重复值。

但普通Treap的删除操作如果采用合并左右子树的方法,会丢失重复计数,需特殊处理;而FHQ Treap通过将相同值节点单独分离,直接修改 cnt 即可,逻辑更自然。

7. 总结
维度 普通Treap FHQ Treap
代码长度
实现难度 中等(需理解旋转) 低(只需 split/merge)
常数 较小 略大(可接受)
可扩展性 差(难以区间操作/可持久化) 优秀(天然支持区间和可持久化)
调试难度 较高

更多系列知识,请查看专栏:《信奥赛C++提高组csp-s知识详解及案例实践》:

https://blog.csdn.net/weixin_66461496/category_13113932.html


各种学习资料,助力大家一站式学习和提升!!!

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int main(){
	cout<<"##########  一站式掌握信奥赛知识!  ##########";
	cout<<"#############  冲刺信奥赛拿奖!  #############";
	cout<<"######  课程购买后永久学习,不受限制!   ######";
	return 0;
}

1、csp信奥赛高频考点知识详解及案例实践:

CSP信奥赛C++动态规划:

https://blog.csdn.net/weixin_66461496/category_13096895.html点击跳转

CSP信奥赛C++标准模板库STL:

https://blog.csdn.net/weixin_66461496/category_13108077.html 点击跳转

信奥赛C++提高组csp-s知识详解及案例实践:

https://blog.csdn.net/weixin_66461496/category_13113932.html

2、csp信奥赛冲刺一等奖有效刷题题解:

信奥赛C++普及组csp-j初赛&复赛真题题解(持续更新) https://blog.csdn.net/weixin_66461496/category_12808781.html 点击跳转

信奥赛C++提高组csp-s初赛&复赛真题题解(持续更新)

https://blog.csdn.net/weixin_66461496/category_13125089.html

3、GESP C++考级真题题解:

GESP(C++ 一级+二级+三级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12858102.html 点击跳转

GESP(C++ 四级+五级+六级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12869848.html 点击跳转

GESP(C++ 七级+八级)真题题解(持续更新):

https://blog.csdn.net/weixin_66461496/category_13117178.html

4、csp/信奥赛C++,完整信奥赛系列课程(永久学习):

https://edu.csdn.net/lecturer/7901 点击跳转

· 文末祝福 ·

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int main(){
	cout<<"跟着王老师一起学习信奥赛C++";
	cout<<"    成就更好的自己!       ";
	cout<<"  csp信奥赛一等奖属于你!   ";
	return 0;
}
相关推荐
QiLinkOS2 小时前
《打破“用爱发电”:一种基于 Gitee 与时间戳的开源权益分配机制探索》
c语言·数据结构·c++·科技·算法·gitee·开源
Irissgwe3 小时前
c++STL--string类
c++·stl·string
Irissgwe3 小时前
c++类型转换
c++·类型转换·explicit·static_cast·const_cast·dynamic_cast·rtti
智者知已应修善业3 小时前
【51单片机用T0定时器方式1,实现0.5S的时间间隔实现第一次一个灯亮、第二次二个灯亮,直到全部灯亮,然后重复整个过程】2023-12-29
c++·经验分享·笔记·算法·51单片机
智者知已应修善业4 小时前
【51单片机4位静态数码管显示1234】2023-11-14
c++·经验分享·笔记·算法·51单片机
抓虾爪4 小时前
ST意法代理商粤科源兴丨LSM6DS3全系列现货库存,LSM6DS3TR-C当天可发
c++
妙为4 小时前
unreal engine5.7.4,创建ThirdPerson第三人称模版,类型是c++崩溃
c++·ue5·虚幻·unreal engine5
郝学胜_神的一滴4 小时前
Qt 高级开发 021:零基础吃透 QVBoxLayout 垂直布局
c++·qt
Boom_Shu4 小时前
长方形的关系
数据结构·c++·算法