Treap(树堆)入门
极难2Treap(树堆)轻松入门:让书架自己变平衡
你有没有遇到过这种情况:考试前想按成绩排座位,但怕有人插队导致队伍越拉越长?生活中,如果每次新来一个人就直接按身高插进队伍,不重新排序,队伍很快就会变得偏斜——有的人站前面,有的人站后面,查找某个人就要从头走到尾,慢得很。
计算机里也有类似的烦恼:二叉搜索树(BST)的插入和查询本来很快,但如果数据是按顺序(比如1,2,3,4…)插入的,树就会变成一条歪歪扭扭的“长链”,查找速度从O(log n)直接掉到O(n)。程序员们想了很多办法让树自动保持平衡,比如AVL树(严格旋转)、红黑树(复杂颜色),但实现起来挺麻烦。
有没有一种方法,既简单又高效,还能用概率保证大致平衡?这就是今天的主角——Treap(树堆)。
1 从扔硬币决定书架位置说起
想象你有一个“盲目”的图书管理员,他不想记复杂的平衡规则(像AVL那样),但他又希望书架上的书总是大致平衡。他想到一个办法:每次来一本新书,除了书名(键值),他还随机扔一个骰子得到一个数字作为“优先级”。然后他按BST规则放书,但额外要求:每个父节点的优先级必须比子节点的优先级大(或小)。如果新书插进去破坏了这条规则,他就通过旋转来调整,把优先级更大的书往上旋。
由于随机数的分布是均匀的,这样得到的树在期望上就是平衡的,高度约为O(log n)。这就是Treap(Tree + Heap,树堆)的核心思想:它同时是一棵二叉搜索树(按键值)和一棵堆(按优先级)。通常我们使用最大堆性质:父节点的优先级大于两个子节点的优先级。
为什么要用随机优先级?
因为优先级是随机生成的,所以树的结构不会完全依赖输入数据的顺序。即使你按顺序插入1、2、3……,由于随机优先级,树仍然会大致平衡——就像每次新书都有一个随机分配的“插队号”,高号的书会往上挤,把树压扁。这有点像班级里老师随机发一个排队号,号小的站前面,号大的站后面,但中间如果发现顺序不对,就互相交换位置。
2 Treap 的数据结构原理
2.1 定义:一个节点里藏着两个“身份”
每个节点需要存储两个关键信息:
- 键值(key):用于BST的排序规则。比如学生的学号、书的书名拼音顺序、游戏中的得分。
- 优先级(priority):一个随机生成的整数,用于堆的排序规则。优先级越大,节点就越“重要”,会尽量往上浮。
此外,每个节点还有指向左孩子和右孩子的指针(或引用)。
Treap同时满足两条性质:
- BST性质:左子树所有节点的键值 < 当前节点的键值 < 右子树所有节点的键值。
- 堆性质(最大堆):每个节点的优先级 > 它的左右子节点的优先级(如果有的话)。
因为优先级是随机分配的,所以Treap的期望高度为O(log n)。打个比方:家谱里有两条规则,一条按年龄(键值)排辈分,左孩子比爸爸小,右孩子比爸爸大;另一条按随机抽取的“大嗓门值”排,嗓门大的必须在上面的位置。如果新来的小孩嗓门比爸爸还大,就要把爸爸往下拽,自己往上爬,通过旋转来调整。
2.2 插入操作:新书来了,先按名字放,再按优先级调
插入步骤:
- 首先像普通BST一样,根据键值找到合适的位置,创建一个新节点,并赋予一个随机优先级。
- 然后,沿着插入路径向上回溯(递归返回时检查):如果当前节点的优先级小于其父节点的优先级(对于最大堆,应该父节点优先更大),说明违反了堆性质,需要进行旋转:
- 如果新节点是左孩子,就对父节点进行右旋(right rotate)。
- 如果新节点是右孩子,就对父节点进行左旋(left rotate)。
- 旋转后,新节点上升到父节点的位置,继续检查它(现在它在新位置)与新的父节点是否满足堆性质,直到到达根或满足条件。
生活例子:假设班上有10个同学,按学号排队(BST规则)。老师突然说:“现在我要按随机号重新排列,号大的站前面!”你插进去后,发现自己的随机号比你前面的同学大,于是你们俩交换位置(旋转),然后你继续和更前面的同学比较,直到排到合适位置。
旋转的直观理解:
- 右旋:把父节点向右下方转,让左孩子升上来。就像两个肩并肩的人,左边的人往上挤,右边的人往左下方蹲。
- 左旋:把父节点向左下方转,让右孩子升上来。
下面的ASCII示意图展示了右旋(y是当前父节点,x是它的左孩子):
y x
/ \ / \
x T3 右旋→ T1 y
/ \ / \
T1 T2 T2 T3
左旋同理,镜像对称。
2.3 删除操作:把要删的节点旋转到叶子再剪掉
删除操作比普通BST的删除要简单,因为可以利用旋转将目标节点旋转到叶子位置再删除,无需处理复杂的两个孩子情况。
方法:
- 先用BST搜索找到要删除的节点。
- 如果它已经是叶子(没有子节点),直接删除。
- 否则,比较它左右子节点的优先级,选择优先级较大的子节点作为旋转方向:
- 如果左孩子优先级更大(或只有左孩子),对当前节点进行右旋,使左孩子上升,目标节点下降到右子树。
- 如果右孩子优先级更大(或只有右孩子),对当前节点进行左旋,使右孩子上升,目标节点下降到左子树。
- 旋转后,目标节点下降了一层,继续重复步骤2~3,直到目标节点变成叶子,然后直接删除。
为什么选择优先级较大的子节点?
因为旋转后,我们希望树仍然满足最大堆性质。把高优先级的子节点旋转到父位置,可以保证父节点的优先级最大。而目标节点被旋转到下层,最终成为叶子。
生活例子:假如你要把一个名声不好的同学从队伍里开除。你让号大的同学和他换位置(旋转),每次换都把他往下挤,直到他成为最后一名(叶子),然后直接把他请出队伍。
2.4 ASCII示意
假设我们有一棵Treap,键值用字母表示,优先级用数字(越大优先级越高)。例如:
(D,9)
/ \
(B,7) (F,8)
/ \ / \
(A,4) (C,5)(E,2)(G,3)
每个节点的表示是(键值, 优先级)。检查:D的优先级9大于B的7和F的8,B的7大于A的4和C的5,F的8大于E的2和G的3,满足最大堆性质。同时BST性质也成立:A<B<C<D<E<F<G(按字母顺序)。
现在插入一个新节点(H,10),键值H大于G(假设G键值为6,H为7?为了例子,假设键值顺序为A,B,C,D,E,F,G,H。H应插入到G的右孩子。创建新节点优先级10,比G的3大,所以进行左旋:G(父)和H(右子)交换位置,最终H(10)上升,G(3)成为H的左孩子。同时还要向上继续检查H是否比F大?F优先级8 < 10,所以还要对F进行左旋……直到根。
更详细的旋转过程读者可以画图体会,重要的是理解旋转维护堆性质。
3 常见错误与注意事项
新手写Treap时容易犯以下几个错误:
-
忘记更新根节点
插入或删除操作后,根节点可能发生变化(因为旋转)。一定要把函数返回的新根赋值给原来的根变量,例如:root = insert(root, key);。在递归函数中,也要确保返回正确的子树根。 -
旋转方向判断错误
旋转的条件是“子节点优先级 > 父节点优先级”。容易混淆的是:插入时,如果子节点在左边,应该对父节点右旋;子节点在右边,对父节点左旋。删除时,选择优先级较大的孩子旋转上去——如果右孩子优先级大,就左旋;左孩子优先级大,就右旋。 -
删除时未处理只有一个孩子的情况
代码中需要先判断root->left == nullptr(没有左孩子)或root->right == nullptr(没有右孩子)。如果只有一个孩子,直接旋转那个孩子上来,不需要比较优先级(因为只有一个)。 -
递归深度过大导致栈溢出
虽然Treap期望高度O(log n),但最坏情况可能较深(概率极低)。实际竞赛中,可以设置较大的栈空间,或使用非递归实现(但递归实现更简洁)。 -
随机种子未初始化
在任何语言中,如果不初始化随机种子,每次运行得到的随机数序列相同,导致树结构一样,失去了随机化的意义。C++中应调用srand(time(0)),Python中random.seed()或默认自动初始化。 -
重复键值的处理
通常Treap不允许重复键值,处理方式有:不插入、节点内计数、或改用多重集。上述代码中,相等时直接忽略,可根据需求改为计数。
4 C++完整代码实现
下面用C++实现一个Treap,包含插入和删除操作。每行变量定义都有中文注释,方便理解。
#include <iostream>
#include <cstdlib> // for rand, srand
#include <ctime> // for time
using namespace std;
// 树堆节点结构
struct TreapNode {
int key; // 键值(用于BST排序)
int priority; // 优先级(随机生成,用于堆排序)
TreapNode* left; // 左孩子指针
TreapNode* right; // 右孩子指针
// 构造函数:创建新节点,自动生成随机优先级(0~99)
TreapNode(int k) : key(k), left(nullptr), right(nullptr) {
priority = rand() % 100;
}
};
// 右旋:以y为轴,将它的左孩子x旋上去,返回新的根
TreapNode* rightRotate(TreapNode* y) {
TreapNode* x = y->left; // 左孩子
TreapNode* T2 = x->right; // 左孩子的右子树
// 旋转
x->right = y;
y->left = T2;
return x; // 新根是x
}
// 左旋:以x为轴,将它的右孩子y旋上去,返回新的根
TreapNode* leftRotate(TreapNode* x) {
TreapNode* y = x->right; // 右孩子
TreapNode* T2 = y->left; // 右孩子的左子树
// 旋转
y->left = x;
x->right = T2;
return y;
}
// 插入节点(递归方式)
TreapNode* insert(TreapNode* root, int key) {
// 1. 如果树为空,直接创建新节点返回
if (root == nullptr) return new TreapNode(key);
// 2. 根据BST性质决定插入左子树还是右子树
if (key < root->key) {
root->left = insert(root->left, key);
// 3. 插入后检查:如果左孩子的优先级大于根,需要右旋
if (root->left && root->left->priority > root->priority)
root = rightRotate(root);
} else if (key > root->key) {
root->right = insert(root->right, key);
// 如果右孩子的优先级大于根,需要左旋
if (root->right && root->right->priority > root->priority)
root = leftRotate(root);
}
// 若键值相等,不处理(本实现不插入重复键)
return root;
}
// 删除节点(递归方式)
TreapNode* erase(TreapNode* root, int key) {
if (root == nullptr) return nullptr;
// 先在子树中查找
if (key < root->key) {
root->left = erase(root->left, key);
} else if (key > root->key) {
root->right = erase(root->right, key);
} else {
// 找到要删除的节点
// 情况1:左右孩子都为空,直接删除
if (root->left == nullptr && root->right == nullptr) {
delete root;
return nullptr;
}
// 情况2:只有一个孩子或两个孩子
// 选择优先级较大的孩子旋转上去(如果某个孩子为空,则另一个孩子优先级视为无限大)
// 注意:这里先判断左孩子为空,或右孩子优先级大于左孩子,则左旋
if (root->left == nullptr || (root->right && root->right->priority > root->left->priority)) {
// 左旋后原来的root变为左孩子
root = leftRotate(root);
// 现在要删除的节点(原root)跑到左子树,递归删除
root->left = erase(root->left, key);
} else {
// 否则右旋
root = rightRotate(root);
// 要删除的节点跑到右子树
root->right = erase(root->right, key);
}
}
return root;
}
// 中序遍历,输出 (键值,优先级) 序列
void inorder(TreapNode* root) {
if (root == nullptr) return;
inorder(root->left);
cout << "(" << root->key << ", " << root->priority << ") ";
inorder(root->right);
}
int main() {
srand(time(nullptr)); // 用当前时间初始化随机种子
TreapNode* root = nullptr;
// 插入一些键值(可以是学号、分数等)
int keys[] = {5, 3, 7, 2, 4, 6, 8};
for (int key : keys) {
root = insert(root, key);
}
cout << "中序遍历(键值,优先级): ";
inorder(root);
cout << endl;
// 删除键值5
root = erase(root, 5);
cout << "删除5后: ";
inorder(root);
cout << endl;
// 再插入重复键看看(不会插入)
root = insert(root, 5);
cout << "重新插入5后: ";
inorder(root);
cout << endl;
return 0;
}
代码要点说明:
- 每个节点创建时自动生成随机优先级(0~99),为了方便教学,实际可调范围。
- 插入后立即检查子节点优先级,若违反堆性质则旋转,注意旋转后
root要更新。 - 删除时利用旋转把目标节点转移到叶子,再删除,避免了复杂的双孩子处理。
- 中序遍历可验证BST性质:输出为升序序列。
- 代码末尾添加了一个重新插入5的操作,展示不处理重复键(结果不变)。
5 Python完整代码实现
以下是Python版本,逻辑与C++完全一致,适合在在线评测平台或本地测试。
import random
random.seed() # 初始化随机种子(通常自动根据时间初始化)
class TreapNode:
def __init__(self, key):
self.key = key # 键值
self.priority = random.randint(0, 99) # 随机优先级
self.left = None # 左孩子
self.right = None # 右孩子
def right_rotate(y):
"""右旋,返回新的根"""
x = y.left
T2 = x.right
x.right = y
y.left = T2
return x
def left_rotate(x):
"""左旋,返回新的根"""
y = x.right
T2 = y.left
y.left = x
x.right = T2
return y
def insert(root, key):
"""递归插入,返回新的根"""
if root is None:
return TreapNode(key)
if key < root.key:
root.left = insert(root.left, key)
if root.left.priority > root.priority:
root = right_rotate(root)
elif key > root.key:
root.right = insert(root.right, key)
if root.right.priority > root.priority:
root = left_rotate(root)
# 键相等时不做插入
return root
def erase(root, key):
"""递归删除,返回新的根"""
if root is None:
return None
if key < root.key:
root.left = erase(root.left, key)
elif key > root.key:
root.right = erase(root.right, key)
else:
# 找到要删除的节点
if root.left is None and root.right is None:
return None
# 选择优先级较大的孩子旋转上来
if root.left is None or (root.right and root.right.priority > root.left.priority):
root = left_rotate(root) # 左旋后原来的root变为左孩子
root.left = erase(root.left, key)
else:
root = right_rotate(root)
root.right = erase(root.right, key)
return root
def inorder(root):
"""中序遍历,打印节点信息"""
if root:
inorder(root.left)
print(f"({root.key},{root.priority})", end=" ")
inorder(root.right)
# 测试
if __name__ == "__main__":
root = None
for key in [5, 3, 7, 2, 4, 6, 8]:
root = insert(root, key)
print("中序遍历:", end=" ")
inorder(root)
print()
root = erase(root, 5)
print("删除5后:", end=" ")
inorder(root)
print()
root = insert(root, 5) # 尝试重复插入,不会生效
print("重复插入5后:", end=" ")
inorder(root)
print()
6 Treap的特点总结
| 特点 | 说明 |
|---|---|
| 实现简单 | 不需要像AVL那样存储高度或计算平衡因子,只需比较优先级并旋转。 |
| 期望高效 | 平均时间复杂度O(log n),最坏情况O(n)但概率极低(≈0)。 |
| 随机化 | 同组数据每次运行结果可能不同,但树高期望低,可对抗恶意输入。 |
| 支持区间操作 | 通过split(分裂成两棵树)和merge(合并两棵树),Treap可以轻松实现区间反转、插入、删除等,这是AVL或红黑树很难做到的。 |
适用场景:
- 竞赛编程中需要快速实现平衡树的地方。
- 工程中需要插入、删除、查找,但不想引入复杂库(如std::map)时。
- 学习平衡树原理的入门神器。
7 总结要点与下一步
- Treap = Tree(二叉搜索树)+ Heap(堆),用随机优先级保证期望平衡。
- 插入后通过旋转保持最大堆性质(父优先级 > 子优先级)。
- 删除时把目标旋转到叶子再删除。
- 编码容易,适合初学者快速掌握平衡树的核心思想。
常见错误再提醒:
- 忘记更新根节点。
- 旋转方向搞反。
- 忘记初始化随机种子。
- 删除时未正确处理只有一个孩子的情况。
相关知识点指引:
- 二叉搜索树基础:如果没有掌握BST的插入、删除、查找,建议先回顾。
- AVL树:另一种严格平衡的树,旋转更复杂但高度保证O(log n)。
- 红黑树:C++ STL中
std::map和std::set的底层实现,平衡条件更宽松,但实现更复杂。 - Splay树:基于“操作后旋转到根”的调节树,某些场景特别快。
- FHQ Treap(无旋Treap):用split和merge代替旋转,支持区间操作,是面试和竞赛的新宠。
现在你已经理解了Treap的奥秘,快去写一个属于自己的图书管理程序,让书架自动变平衡吧!
例题精讲
Treap(树堆)的核心思想是通过什么机制来保证树的平衡性?
在Treap的插入过程中,若新插入节点的优先级大于其父节点的优先级,则需要进行旋转操作。若新节点是父节点的左孩子,应进行哪种旋转?
在Treap中删除一个节点时,如果该节点有两个非空子节点,可以先将节点与其中序遍历后继(或前驱)交换,然后删除后继(或前驱)节点,再通过旋转调整堆性质。
以下是一个Treap节点定义及左旋操作的代码片段,请补全旋转函数中的空缺部分。
struct Node {
int key, prio;
Node *left, *right;
Node(int k) : key(k), prio(rand()), left(nullptr), right(nullptr) {}
};
void rotateLeft(Node* &root) {
Node* newRoot = root->right;
root->right = ___;
newRoot->left = root;
root = newRoot;
}下面是Treap中查找第k小元素的递归函数(假设每个节点维护子树大小size),请补全空缺处的代码。
struct Node {
int key, prio, size;
Node *left, *right;
// 构造函数及更新size函数省略
};
int kth(Node* root, int k) {
if (root == nullptr) return -1; // 假设不存在返回-1
int leftSize = root->left ? root->left->size : 0;
if (k <= leftSize) {
return ___;
} else if (k == leftSize + 1) {
return root->key;
} else {
return kth(root->right, k - leftSize - 1);
}
}