1. 程式人生 > >平衡樹中的神兵利器——非旋Treap(樹堆)學習小記

平衡樹中的神兵利器——非旋Treap(樹堆)學習小記

前言

  之前A組有一道題目,要你維護一個字串的插一個字元、刪一段字元、複製並貼上一段字元、翻轉一段字元、查詢一個字元。我當然想到了splay。但是看了眼資料範圍:字串的總長度不超過2311,我就知道splay沒戲,同時也很驚異這題連splay都切不了,那還能做嗎?
  於是我就打了暴力。
  於是那題我至今未切。
  大佬們估計都一眼看出是可持久化Treap了。但是我此前只是聽說過Treap,並不瞭解它的做法和功能。
  所以我只能從treap開始學起。這裡寫一篇部落格,記錄一下。

簡介

  樹堆,在資料結構中也稱Treap,是指有一個隨機附加域滿足堆的性質的二叉搜尋樹,其結構相當於以隨機資料插入的二叉搜尋樹。其基本操作的期望時間複雜度為

O(log2n)。相對於其他的平衡二叉搜尋樹,Treap的特點是實現簡單,且能基本實現隨機平衡的結構。
  如果一個二叉排序樹節點插入的順序是隨機的,這樣我們得到的二叉排序樹大多數情況下是平衡的,即使存在一些極端情況,但是這種情況發生的概率很小,所以我們可以這樣建立一顆二叉排序樹,而不必要像AVL那樣旋轉,可以證明隨機順序建立的二叉排序樹在期望高度是O(log2n),但是某些時候我們並不能得知所有的待插入節點,打亂以後再插入。所以我們需要一種規則來實現這種想法,並且不必要所有節點。也就是說節點是順序輸入的,我們實現這一點可以用Treap。
  Treap=Tree+Heap
  Treap是一棵二叉排序樹,不過不能算是真正的二叉排序樹。它的左子樹和右子樹分別是一個Treap,和一般的二叉排序樹不同的是,Treap記錄一個額外的資料,就是優先順序。Treap在以關鍵字(它的某個值)構成二叉排序樹的同時,它的優先順序(隨機分配的那個key值)還滿足堆的性質(可以是大根堆,也可以是小根堆)。但是這裡要注意的是Treap和二叉堆有一點不同,就是二叉堆必須是完全二叉樹,而Treap可以並不一定是。
  我們知道,Treap維護堆性質的方法可以運用旋轉,而且只有左右旋轉。但此法無法進行可持久化,所以我沒有屑於學它,而是直接跑向了非旋的方法。

定義

  我們可以像splay一樣,用一個數組來表示一棵Treap。程式碼如下:

struct Treap
{
    int pri;//隨機分配的優先順序,須滿足堆的性質
    int key;//鍵值,須滿足二叉排序樹的性質
    int l,r;//左右兒子
    int sz;//子樹大小
    bool tag;//翻轉標記
    inline void newnode(int _key)//新建一個鍵值為_key的點
    {
        pri=rand();
        key=_key;
        l=r=0;
        sz=1;
    }
}t[N];

核心操作:merge、split

merge

  merge函式是用來合併a、b兩棵子樹,然後返回合併後的一整棵樹。
  如果我們維護的是小根堆,在合併時,我們首先看它們的根節點誰的鍵值比較小,並且建立對應的父子關係。
  又由於平衡樹的中序遍歷不變,我們又要把b插在a後面,維持一個確定的中序遍歷,
  所以我們應該一直把a作為merge函式的前一個引數,b作為後一個引數,這個順序不能換。
  這一點應該很顯然。

int merge(int a,int b)
{
    push(a); push(b);  //下傳翻轉標記
    return !a||!b?a+b  //如果a和b當中有空樹則返回另一棵樹
    : (t[a].pri<t[b].pri?
    (link(a,merge(t[a].r,b),1),update(a),a):
    (link(b,merge(a,t[b].l),0),update(b),b));
}

split

  split函式正好和merge相反,它的作用是分裂出以o為根的子樹的前k個節點,然後作為兩棵樹返回。
  我們首先定義一個pair,兩個引數分別為分裂出來的兩棵樹的根節點。我們設第一個是分離出去的樹,第二個是剩下的原樹。
  然後考慮分離前k個的過程:如果o的左兒子有k個以上節點,我們顯然應該去左兒子分離。
  然後我們會得到分離完成的樹和左兒子剩下的樹,這時候把左兒子剩下的部分接回節點o,並把新的o作為分離o剩下的原樹。
  如果左兒子節點個數不夠,我們就去右兒子分離,但是由於k肯定≥size[左兒子]+1,所以整棵左子樹以及根節點都會被分裂出去,右子樹中的一部分也會受到波及。所以我們要分裂出右子樹中的前k-size[左兒子]-1個節點,分裂出來的點接上已經只剩下左子樹和根節點的原樹,作為此次分裂出來的樹;而從右子樹分離後的原樹,則作為剩下的原樹。

#define mp make_pair
typedef pair<int,int> P;
P split(int o,int k)
{
    if(!o)return mp(0,0);
    push(o);
    if(!k)return mp(0,o);
    P y;
    return t[t[o].l].sz>=k?
    (y=split(t[o].l,k               ),link(o,y.se,0),update(o),y.se=o,y):
    (y=split(t[o].r,k-t[t[o].l].sz-1),link(o,y.fi,1),update(o),y.fi=o,y);
}

  有了這兩個核心操作之後,實現一些常用操作如insert、delete就易如反掌了。

常用操作:insert、delete

insert

  insert操作的作用是將某個值為v的點插進Treap裡。這個很簡單,直接上程式碼。

inline void Insert(int v)
{
    int k=Getkth(root,v);//在Treap上二分出最後一個值小於v的點的位置
    P x=Split(root,k);//位置即點數,所以把所有值小於v的點先分裂出來
    t[++cnt].newnode(v);//新建一個值為v的節點
    root=merge(merge(x.first,cnt),x.second);//先合併所有值小於v的點和新建的點,再合併上其他點
}

delete

  delete操作也和insert正好相反,它的作用是刪掉Treap中第一個值為v的點。

void Delete(int v) 
{
    int k=Getkth(root,v);
    P x=Split(root,k);//把所有值小於v的點先分裂出來
    P y=Split(x.second,1);//把剩下的原樹中第一個點即值等於v的點分裂出來
    root=merge(x.first,y.second);//合併所有值小於v的點和所有值大於v的點
}

高階操作:翻轉、平移、求和

  解決這三個操作,我們可以將所需區間截取出來,然後那塊區間就任我們擺佈了。但是翻轉、區間修改等操作需要打懶標記,方法與splay大同小異。

時間複雜度

  在《簡介》中就已提到過,你不知道肯定是因為你沒認真看。

例題

洛谷P3369 這道題是來自模板OJ洛谷的一道模板題,涉及到插入、刪除、查排名、查排名對應的數這些操作。
洛谷P3391 這道題是區間翻轉,其實也很簡單。比如他要翻轉區間[l,r],你可以先把[1..l-1]分離出來,設其為a,[l..n]為b;再將b中的前r-l+1個(即原序列中的區間[l..r])分離出來,設為c,[r+1..n]設為d;我們在c上打上標記,最後令a、c、d合併。
JZOJ3599 這道題也是區間翻轉,其實更適合splay做,但是treap也是可以的。我寫了篇部落格,讀者不妨去瞭解一下