『学习笔记』二叉堆

堆(Heap)是一类数据结构,它们是一种树形结构,而且父节点比子节点小(或大)。这样,堆的根节点就一定是所有元素中最小(或最大的)。父节点比子节点小的叫做小根堆,反之,叫做大根堆。

二叉堆是最常见的堆,它结构简单,代码也挺好写,可以 \(\mathcal{O}(\log n)\) 插入或删除某个值,\(\mathcal{O}(1)\) 查询所有值中最小(或最大)的值。

例如有 \(10\) 个元素 \(1,2,4,5,3,4,7,9,6,9\),我们将它们依次插入到一个空的小根堆,那么这个小根堆是这样的:

如何存储

可以发现,二叉堆是一颗完全二叉树。

于是,这棵树便可以用一个数组来存储。

对于一个下标为 \(x\) 的节点,它的左孩子的下标在 \(2x\),右孩子的下标在 \(2x+1\)

这样,在这个数组的末尾添加元素,就等于在这棵树上的末尾,按照『从上到下,从左到右』的顺序添加元素。

看下面这棵树,节点上标的是各个元素在这个数组中的下标。

在数组中则是这样:

\[\begin{matrix} &\;\;\begin{matrix} 1&2&3&4&5&6&7&8&9&10 \end{matrix}\\ &\begin{bmatrix} 1&2&3&4&4&5&6&7&9&9 \end{bmatrix} \end{matrix} \]

可见所有节点的左右孩子均为 \(2x\)\(2x+1\),现在的末项为 \(10\),若再加一项,\(11\) 便为数组末项,刚好是 \(5\) 的右儿子。

那么,如果第 \(11\) 个元素比 \(9\) 小,是 \(0\),怎么办?

为了维护堆的性质,我们需要用到两个操作:上浮和下沉。

上浮

上浮,顾名思义,就是要让某个元素,浮到自己应该到的位置。

什么时候要上浮呢?就当这个元素不满足堆的性质的时候。小根堆中,父节点必须比子节点小。

就拿上面的第 \(11\) 个元素做例子,是 \(0\),而它的父节点却是 \(4\),不满足父节点比子节点小的性质。

显然,可以让 \(0\) 和父节点 \(4\) 交换位置,尝试满足堆的性质。

可交换后发现,\(0>2\),还是比父节点大。所以再交换一次。

\(0\) 还是比父节点 \(1\) 大,所以再交换。

这时,\(0\) 到达了堆顶,终于使得堆满足了性质,所以对 \(0\) 的上浮操作结束。

void swim(int x){
    for(int i=x; i>1 && a[i]<a[i>>1]; i>>=1)
        swap(a[i],a[i>>1]);
}

上浮函数的参数 \(x\) 即为要上浮的节点在数组中的下标,上面例子中是 \(11\)

循环中的 \(i\) 即为要上浮的节点的下标,可能到父节点(也就是 \(\dfrac{i}{2}\),可以直接使用位运算 i>>1 来计算),当这个节点已经到了堆顶或是父节点比当前节点小了,循环就退出,操作结束。

每次循环都会交换 \(i\) 节点和 \(i\) 的父节点,最坏情况是从堆的底部上浮的堆顶,所以上浮的时间复杂度为 \(\mathcal{O}(\log n)\)

下沉

有上浮,自然就有下沉。

下沉和上浮很像,就不多说了,就是将一个节点与较小的子节点比较,如果它比这个子节点要大,就需要交换,直到这个节点的子节点不小于它。

注意要与较小的子节点比较,因为父节点可能大于较小的子节点,却小于较大的子节点,选了较大的就不会交换了。

inline int ls(int x){return x<<1;} // 计算左儿子
inline int rs(int x){return x<<1|1;} // 计算右儿子
// son 函数返回较小的子节点
inline int son(int x){return ls(x)+(rs(x)<=l && a[rs(x)]<a[ls(x)]);}
void sink(int x){
    // t 即为较小的子节点,需要小于或等于堆的长度,同时也要满足它比父节点要小,才能交换
    for(int i=x,t=son(i); t<=l && a[t]<a[i]; i=t,t=son(i))
    	swap(a[i],a[t]);
}

各种操作

构造

可以通过一个数组建立堆,先将这个数组直接复制到堆中,然后再考虑对元素上浮或下沉。

显然,一种暴力的做法就是把所有节点都上浮或下沉一遍,时间复杂度 \(\mathcal{O}(n \log n)\)

于是就不知道从哪里诞生了一种 \(\mathcal{O}(n)\) 的做法:从下标为 \(\dfrac{n}{2}\) 的节点开始,自底向上下沉,叶子节点不需要下沉。那么对于各个需要下沉的节点,最坏情况下

  • 最下层的 \(\dfrac{n}{2}\) 个叶子节点不需要下沉。
  • 次下层的 \(\dfrac{n}{4}\) 个节点需要下沉 \(1\) 层,即与较小的子节点交换。共下沉 \(1 \times \dfrac{n}{4}\) 次。
  • 倒数第 \(3\) 层的 \(\dfrac{n}{8}\) 个节点需要下沉 \(2\) 层。共下沉 \(2 \times \dfrac{n}{8}\) 次。
  • 倒数第 \(4\) 层的 \(\dfrac{n}{16}\) 个节点需要下沉 \(3\) 层。共下沉 \(3 \times \dfrac{n}{16}\) 次。
  • \(\dots\)

那么最坏情况下所有元素的总移动次数为 \(S=0 \times \dfrac{n}{2} + 1 \times \dfrac{n}{4} + 2 \times \dfrac{n}{8} + 3 \times \dfrac{n}{16} + \dots\)

显然,这是一个等差数列与等比数列逐项相乘后求和的问题,我们可以让其两边同时乘公比(这里为 \(\dfrac{n}{2}:\dfrac{n}{4}=\dfrac{n}{4}:\dfrac{n}{8}=\dots=2:1\),即为 \(2\)),然后与原式子错位相减。

\[2S=0 \times n + 1 \times \dfrac{n}{2} + 2 \times \dfrac{n}{4} + 3 \times \dfrac{n}{8} + \dots \]

\[\begin{array}{l} 2S-S &=&0 \times n + (1 \times \dfrac{n}{2} - 0 \times \dfrac{n}{2}) + (2 \times \dfrac{n}{4} - 1 \times \dfrac{n}{4}) + (3 \times \dfrac{n}{8} - 2 \times \dfrac{n}{8}) + \dots\\ &=&(1-0) \times \dfrac{n}{2} + (2-1) \times \dfrac{n}{4} + (3-2) \times \dfrac{n}{8} + (4-3) \times \dfrac{n}{16} + \dots\\ &=&1 \times \dfrac{n}{2} + 1 \times \dfrac{n}{4} + 1 \times \dfrac{n}{8} + 1 \times \dfrac{n}{16}+ \dots\\ &=&\dfrac{n}{2} + \dfrac{n}{4} + \dfrac{n}{8} + \dfrac{n}{16} + \dots\\ &\approx&n\\\end{array} \]

故最坏时间复杂度为 \(\mathcal{O}(n)\)

void build(int *_a,int n){
    memcpy(a+1,_a,sizeof(int)*n); // 将传入数组拷贝到堆数组
    l=n; // 记录现在堆的长度
    for(int i=n>>1; i; i--) // 从 n/2 开始自底向上下沉
        sink(i);
}

插入

  • 将一个元素 \(x\) 插入到堆中。

可以直接把 \(x\) 插入到堆的尾部,然后对 \(x\) 进行一次上浮。

void push(int x){
    a[++l]=x;
    swim(l);
}

时间复杂度 \(\mathcal{O}(\log n)\)

删除

  • 删除堆中最小(大根堆中为最大)的元素。

使最后一个元素与堆顶元素交换,然后对堆顶元素进行下沉操作即可。

void pop(){
    a[1]=a[l--];
    sink(1);
}

查询

  • 询问所有元素中最小(或最大)的元素。

直接返回根节点即可。

int top(){return a[1];}

P3378 【模板】堆

题面大意

给定一个序列,初始为空,需要支持三种操作:

  • 将一个数 \(x\) 加入序列。
  • 输出序列中最小的数。
  • 删除序列中最小的数(若有多个,只删一个)。

思路

使用小根堆,只需要 push,pop,top 三种操作即可。

我写了一个堆的 class,支持任何类型,只需要在构造函数中传入一个用于比对元素的函数即可。若需要加入堆中的类型为结构体或类,可以直接在结构体或类中重载运算符 <,就不用传入比较函数了。

这个比较函数和 sort 函数的 cmp 类似,可以直接传函数、lambda 表达式、仿函数等。

要指定类型的话,在定义一个堆的时候这样写:

Head<type> a;

其中的 type 即为你所需的类型,若不指定,即为 int,可以直接省略 <type>

若要指定比较函数,这样写:

Heap<type> a(1e6,![](type a,type b)->bool{return ...;})

第一个参数是堆最多可以达到的元素个数,传 \(10^6\) 一般没什么问题,第二个我这里写了一个 lambda 表达式,可以替换为你写好的函数名称(规则和 sort 一样)或仿函数(greater<type>() 之类的)。

代码

#include <iostream>
#include <cstring>
using namespace std;
template<typename T=int>
inline T read(){
    T X=0; bool flag=1; char ch=getchar();
    while(ch<'0' || ch>'9'){if(ch=='-') flag=0; ch=getchar();}
    while(ch>='0' && ch<='9') X=(X<<1)+(X<<3)+ch-'0',ch=getchar();
    if(flag) return X;
    return ~(X-1);
}

template<typename T=int>
inline void write(T X){
    if(X<0) putchar('-'),X=~(X-1);
    T s[20],size=0;
    while(X) s[++size]=X%10,X/=10;
    if(!size) s[++size]=0;
    while(size) putchar(s[size--]+'0');
    putchar('\n');
}

const int N=1e6+5;
int n;

template<class T=int>
class Heap{
    public:
        Heap(int n=1e6,bool (*_cmp)(T,T)=[](T a,T b)->bool{return a<b;}):
            a(new T[n+5]),l(0),cmp(_cmp){memset(a,0,sizeof(a));}
        ~Heap(){delete[] a;} // 构造函数和析构函数
        void build(T *_a,int n){
            memcpy(a+1,_a,sizeof(int)*n);
            l=n;
            for(int i=n>>1; i; i--)
                sink(i);
        }
        void push(T x){
            a[++l]=x;
            swim(l);
        }
        void pop(){
            a[1]=a[l--];
            sink(1);
        }
        T top(){return a[1];}
        bool empty(){return l==0;} // 判断是否为空
        int size(){return l;} // 获取元素个数
    private:
        T *a; // 存储堆的数组
        int l; // 元素个数
        bool (*cmp)(T,T); // 不用管它,用来存储比较函数,想了解的话可以自行百度 “函数指针”
        void swim(int x){ // 上浮
            for(int i=x; i>1 && cmp(a[i],a[i>>1]); i>>=1)
                swap(a[i],a[i>>1]);
        }
        void sink(int x){ // 下沉
            for(int i=x,t=son(i); t<=l && cmp(a[t],a[i]); i=t,t=son(i))
                swap(a[i],a[t]);
        }
        inline int ls(int x){return x<<1;}
        inline int rs(int x){return x<<1|1;} // 分别获取左儿子和右儿子
        // 获取较小的儿子(对于小根堆而言)
        inline int son(int x){return ls(x)+(rs(x)<=l && cmp(a[rs(x)],a[ls(x)]));}
};
Heap h;

int main(){
    n=read();
    while(n--)
        switch(read()){
            case 1: h.push(read()); break;
            case 2: write(h.top()); break;
            case 3: h.pop(); break;
            default: break;
        }
    return 0;
}

P1628 合并序列

题目大意

给定 \(n\) 个单词和一个字符串 \(T\),按照字典序从小到大输出以 \(T\) 为前缀的所有单词。

思路

因为要按照字典序排序,直接用一个字符串 string 类型的堆。

将输入的单词都 push 进去,不用动比较函数,因为本来就要按照字典序,直接用小于号也就是按字典序比较。

然后取 \(n\) 次堆顶元素,并删除。对于每个堆顶元素,先判断长度有没有 \(T\) 长,如果有,那么遍历字符串 \(T\),看看 \(T\) 是不是这个单词的前缀。是的话就输出。

代码

#include <iostream>
#include <cstring>
using namespace std;
int n,cnt;
string s,x;

template<class T=int>
class Heap{
    public:
        Heap(int n=1e6,bool (*_cmp)(T,T)=[](T a,T b)->bool{return a<b;}):
            a(new T[n+5]),l(0),cmp(_cmp){memset(a,0,sizeof(a));}
        ~Heap(){delete[] a;}
        void build(T *_a,int n){
            memcpy(a+1,_a,sizeof(int)*n);
            l=n;
            for(int i=n>>1; i; i--)
                sink(i);
        }
        void push(T x){
            a[++l]=x;
            swim(l);
        }
        void pop(){
            a[1]=a[l--];
            sink(1);
        }
        T top(){return a[1];}
        bool empty(){return l==0;}
        int size(){return l;}
    private:
        T *a;
        int l;
        bool (*cmp)(T,T);
        void swim(int x){
            for(int i=x; i>1 && cmp(a[i],a[i>>1]); i>>=1)
                swap(a[i],a[i>>1]);
        }
        void sink(int x){
            for(int i=x,t=son(i); t<=l && cmp(a[t],a[i]); i=t,t=son(i))
                swap(a[i],a[t]);
        }
        inline int ls(int x){return x<<1;}
        inline int rs(int x){return x<<1|1;}
        inline int son(int x){return ls(x)+(rs(x)<=l && cmp(a[rs(x)],a[ls(x)]));}
};
Heap<string> h;

int main(){
    scanf("%d",&n);
    for(int i=1; i<=n; i++){
        cin >> s;
        h.push(s);
    }
    cin >> s;
    for(int i=1; i<=n; i++,cnt=0){ // 别忘了将 cnt 置为 0
        x=h.top();
        h.pop();
        // 当前字符串的长度连前缀串的长度都达不到,就可以跳过了
        if(x.size()<s.size()) continue;
        for(int i=0; i<s.size(); i++)
            if(s[i]==x[i]) // 依次判断
                cnt++; // 若相等,匹配字符数量+1
        if(cnt==s.size()) // 很暴力的方法(
            printf("%s\n",x.c_str());
    }
    return 0;
}

推荐习题

posted @ 2022-06-24 15:11  仙山有茗  阅读(63)  评论(0编辑  收藏  举报