CC++ & Algorithm

Treap(树堆)入门

极难2
语言版本:通用
概述:用扔硬币决定书架位置的有趣比喻引入Treap,讲解它如何结合二叉搜索树和堆(随机优先级),通过旋转维持堆性质,并给出完整代码。

Treap(树堆)轻松入门:让书架自己变平衡

你有没有遇到过这种情况:考试前想按成绩排座位,但怕有人插队导致队伍越拉越长?生活中,如果每次新来一个人就直接按身高插进队伍,不重新排序,队伍很快就会变得偏斜——有的人站前面,有的人站后面,查找某个人就要从头走到尾,慢得很。

计算机里也有类似的烦恼:二叉搜索树(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同时满足两条性质:

  1. BST性质:左子树所有节点的键值 < 当前节点的键值 < 右子树所有节点的键值。
  2. 堆性质(最大堆):每个节点的优先级 > 它的左右子节点的优先级(如果有的话)。

因为优先级是随机分配的,所以Treap的期望高度为O(log n)。打个比方:家谱里有两条规则,一条按年龄(键值)排辈分,左孩子比爸爸小,右孩子比爸爸大;另一条按随机抽取的“大嗓门值”排,嗓门大的必须在上面的位置。如果新来的小孩嗓门比爸爸还大,就要把爸爸往下拽,自己往上爬,通过旋转来调整。

2.2 插入操作:新书来了,先按名字放,再按优先级调

插入步骤:

  1. 首先像普通BST一样,根据键值找到合适的位置,创建一个新节点,并赋予一个随机优先级。
  2. 然后,沿着插入路径向上回溯(递归返回时检查):如果当前节点的优先级小于其父节点的优先级(对于最大堆,应该父节点优先更大),说明违反了堆性质,需要进行旋转
    • 如果新节点是左孩子,就对父节点进行右旋(right rotate)。
    • 如果新节点是右孩子,就对父节点进行左旋(left rotate)。
  3. 旋转后,新节点上升到父节点的位置,继续检查它(现在它在新位置)与新的父节点是否满足堆性质,直到到达根或满足条件。

生活例子:假设班上有10个同学,按学号排队(BST规则)。老师突然说:“现在我要按随机号重新排列,号大的站前面!”你插进去后,发现自己的随机号比你前面的同学大,于是你们俩交换位置(旋转),然后你继续和更前面的同学比较,直到排到合适位置。

旋转的直观理解

  • 右旋:把父节点向右下方转,让左孩子升上来。就像两个肩并肩的人,左边的人往上挤,右边的人往左下方蹲。
  • 左旋:把父节点向左下方转,让右孩子升上来。

下面的ASCII示意图展示了右旋(y是当前父节点,x是它的左孩子):

       y                     x
      / \                   / \
     x   T3   右旋→        T1  y
    / \                       / \
   T1 T2                     T2 T3

左旋同理,镜像对称。

2.3 删除操作:把要删的节点旋转到叶子再剪掉

删除操作比普通BST的删除要简单,因为可以利用旋转将目标节点旋转到叶子位置再删除,无需处理复杂的两个孩子情况。

方法

  1. 先用BST搜索找到要删除的节点。
  2. 如果它已经是叶子(没有子节点),直接删除。
  3. 否则,比较它左右子节点的优先级,选择优先级较大的子节点作为旋转方向:
    • 如果左孩子优先级更大(或只有左孩子),对当前节点进行右旋,使左孩子上升,目标节点下降到右子树。
    • 如果右孩子优先级更大(或只有右孩子),对当前节点进行左旋,使右孩子上升,目标节点下降到左子树。
  4. 旋转后,目标节点下降了一层,继续重复步骤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时容易犯以下几个错误:

  1. 忘记更新根节点
    插入或删除操作后,根节点可能发生变化(因为旋转)。一定要把函数返回的新根赋值给原来的根变量,例如:root = insert(root, key);。在递归函数中,也要确保返回正确的子树根。

  2. 旋转方向判断错误
    旋转的条件是“子节点优先级 > 父节点优先级”。容易混淆的是:插入时,如果子节点在左边,应该对父节点右旋;子节点在右边,对父节点左旋。删除时,选择优先级较大的孩子旋转上去——如果右孩子优先级大,就左旋;左孩子优先级大,就右旋。

  3. 删除时未处理只有一个孩子的情况
    代码中需要先判断root->left == nullptr(没有左孩子)或root->right == nullptr(没有右孩子)。如果只有一个孩子,直接旋转那个孩子上来,不需要比较优先级(因为只有一个)。

  4. 递归深度过大导致栈溢出
    虽然Treap期望高度O(log n),但最坏情况可能较深(概率极低)。实际竞赛中,可以设置较大的栈空间,或使用非递归实现(但递归实现更简洁)。

  5. 随机种子未初始化
    在任何语言中,如果不初始化随机种子,每次运行得到的随机数序列相同,导致树结构一样,失去了随机化的意义。C++中应调用srand(time(0)),Python中random.seed()或默认自动初始化。

  6. 重复键值的处理
    通常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::mapstd::set的底层实现,平衡条件更宽松,但实现更复杂。
  • Splay树:基于“操作后旋转到根”的调节树,某些场景特别快。
  • FHQ Treap(无旋Treap):用split和merge代替旋转,支持区间操作,是面试和竞赛的新宠。

现在你已经理解了Treap的奥秘,快去写一个属于自己的图书管理程序,让书架自动变平衡吧!

例题精讲

1单选题

Treap(树堆)的核心思想是通过什么机制来保证树的平衡性?

A强制维护左右子树高度差不超过1
B为每个节点赋予随机优先级,并通过旋转维持堆性质
C使用红黑树中的染色和旋转
D根据插入顺序自动平衡,无需额外操作
2单选题

在Treap的插入过程中,若新插入节点的优先级大于其父节点的优先级,则需要进行旋转操作。若新节点是父节点的左孩子,应进行哪种旋转?

A左旋
B右旋
C先左旋后右旋
D先右旋后左旋
3判断题

在Treap中删除一个节点时,如果该节点有两个非空子节点,可以先将节点与其中序遍历后继(或前驱)交换,然后删除后继(或前驱)节点,再通过旋转调整堆性质。

4填空题
以下是一个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;
}
5填空题
下面是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);
    }
}