高级数据结构详解

前言

洛谷签到

高级数据结构难点很多,而且小编接近一年没有碰过代码了,简书一天能发布的文章数目有限😂,所以今天决定爆肝一个晚上来一个超长的博客。
但小编能力有限,只会讲解下列几个部分:

  • 树、图遍历的基础——搜索
  • 队列
  • 树的基本知识
  • 二叉树
  • 二叉排序树
  • 平衡树Treap
  • 红黑树(待更中……)
  • 树状数组
  • 线段树
  • 图论(待更中……)

实际上这都是我从网上找来的一大堆看似很高级,实则很高级的东西,但是小编会非常详细的记录经验和讲解知识,以及一些实际的例题。
不过图论内容较多,图论将会另更一篇(不过如果想立刻学的话可以看一下我昔日的博客:传送门),届时在章末补上链接。

一、树、图遍历的基础——搜索

在此部分,我们先讲解深度优先搜索(dfs),广度优先搜索就放到队列里面讲。
搜索是至关重要的,因为树,图在遍历时,使用的就是搜索算法,遍历是它们的基本操作之一。

1)前置基础

务必学会递归

2)核心思想

如果你现在正身处一个迷宫中,你想走出迷宫,那么方法主要有两种

  • 1>一条路走到黑,不撞南墙不回头,只要认定一个方向,就一直走到边界才掉头


    DFS
  • 2>环顾四周,不停向外扩展,先看离自己近的是不是出口,再看远的


    BFS

    第一种办法靠碰运气,说不准第一次方向就蒙对了,但也有可能运气差到遍历整个地图才能找到。
    第二种情况也靠碰运气,说不准终点就在起点旁边,但是也可能运气差到终点在边界处。
    我们现在将要讲解的就是第一种办法。

3)谈谈怎么代码实现吧

整个过程是这样的:

void dfs(一些需要传递的信息,来唯一识别当前的状态,例如当前走的次数,当前的位置等)
{
  if(判断当前状态是否是目标状态) 如果是就存下这个答案,返回
  for(逐个枚举可能的状态)
  {
    扩展新状态;
    if(新状态可以使用)
    {
      有时需要做标记;
      dfs(新状态);
      回溯,即撤回标记,防止下次不能再到达这个状态(有时不需要回溯)
    }
  }
}

具体一点,对于迷宫,要求输出最小步数,可以修改成这样:

void dfs(横坐标,纵坐标,当前步数)
{
  if(横坐标==终点横坐标&&纵坐标==终点纵坐标) 
  {
    ans=min(ans,当前步数);
    return;
  }
  for(枚举四个方向,即上下左右)
  {
    扩展出下一步的横坐标,纵坐标;
    if(横坐标、纵坐标没有超出地图范围且这个新地方没有走过)
    {
      标记这个点走过了;
      dfs(新横坐标,新纵坐标,步数+1);
      标记这个点没有走过;
    }
  }
}

例题

例1

对于上面的迷宫,正好小编最近刷了一道规规矩矩的迷宫题。
题目链接:https://www.luogu.com.cn/problem/P1605
对于不想抬起宝贵的手点击链接(因为这样手移动会做功消耗动能,与空气摩擦还要生热)的同志,直接看题吧:

P1605 迷宫

题目背景
给定一个N*M方格的迷宫,迷宫里有T处障碍,障碍处不可通过。给定起点坐标和终点坐标,问: 每个方格最多经过1次,有多少种从起点坐标到终点坐标的方案。在迷宫中移动有上下左右四种方式,每次只能移动一个方格。数据保证起点上没有障碍。

题目描述

输入格式
第一行N、M和T,N为行,M为列,T为障碍总数。第二行起点坐标SX,SY,终点坐标FX,FY。接下来T行,每行为障碍点的坐标。

输出格式
给定起点坐标和终点坐标,问每个方格最多经过1次,从起点坐标到终点坐标的方案总数。

输入输出样例
输入 #1
2 2 1
1 1 2 2
1 2
输出 #1
1
说明/提示
【数据规模】

1≤N,M≤5

AC代码

#include<bits/stdc++.h>
using namespace std;
int n,m,t,ans,sx,sy,fx,fy,a,b;
int edge[100][100],mapp[100][100];
int next[4][2]={{1,0},{-1,0},{0,1},{0,-1}};//下一步怎么走
void dfs(int x,int y)
{
    if(x==fx&&y==fy)  //判断目标状态
    {
        ans++;//解的个数增加
        return;
    }
    for(int i=0;i<4;i++)
    {
        int tx=x+next[i][0];
        int ty=y+next[i][1];//扩展出下一步的位置
        if(tx<1||ty<1||tx>n||ty>m||mapp[tx][ty]==1||edge[tx][ty]==1) continue; //判断下一步走这个点是否成立
        mapp[tx][ty]=1;//标记走过了,防止同一路径重复走造成死循环
        dfs(tx,ty);
        mapp[tx][ty]=0;//标记回没走过,因为可能另一条路径需要经过这个点
    }
}
int main()
{
    cin>>n>>m>>t;
    cin>>sx>>sy>>fx>>fy;
    for(int i=1;i<=t;i++)
    {
        cin>>a>>b;
        edge[a][b]=1;//标记障碍物
    }
    mapp[sx][sy]=1;
    dfs(sx,sy);
    cout<<ans;
    return 0;
}

例2

题目链接:https://www.luogu.com.cn/problem/P1135

P1135 奇怪的电梯

题目

解析

当然,深度优先搜索(DFS)不局限于迷宫一类的问题,凡是这种有其实状态和目标状态的,往往都可以套用之前的模板,将上下左右改为电梯的上升和下降就可以了。

错误的代码

#include<bits/stdc++.h>
using namespace std;
int a,b,n,ans=999999;
int skill[210];
int sign[210];
void dfs(int now,int step)
{
    if(now==b) //判断是否到达目标的那一层,并记录步数
    {
        ans=min(step,ans);
        return;
    }
    if(sign[now+skill[now]]==0&&now+skill[now]>0&&now+skill[now]<=n)//上升
    {
        sign[now+skill[now]]==1;//标记这一层来过了
        dfs(now+skill[now],step+1);
        sign[now+skill[now]]==0;//重置标记
    }
    if(sign[now-skill[now]]==0&&now-skill[now]>0&&now-skill[now]<=n)//下降
    {
        sign[now-skill[now]]==1;//标记这一层来过了
        dfs(now-skill[now],step+1);
        sign[now-skill[now]]==0;//重置标记
    }
}
int main()
{
    cin>>n>>a>>b;
    for(int i=1;i<=n;i++)
    cin>>skill[i];
    dfs(a,0);
    if(ans==999999) cout<<-1;
    else cout<<ans;
    return 0;
}

为什么我说它是错误的代码呢?因为它没有AC,让你们看一下我光荣的战绩。


若干个MLE

实际上,虽然深搜很简单,但是很多情况下不宜使用,因为递归涉及到爆栈的问题,如果你什么RE,什么MLE什么乱起八糟的,一般都是递归爆栈了,所以有必要使用广搜。
本题题解将在队列中呈现。

二、队列

1)核心思想

队列其实正如其名,就是排队吗,与栈(你们都这么强是吧,默认你们会了,不过不会也丝毫不影响阅读下文)不同的是,队列有两个关键点:队首和队尾。
例如我要去买一份麻辣拌,那么在购买时一定是这样的:


无聊的排队时光

然后……




……(繁琐的等待)
终于等到了,然后买它

显然我们会发现收银员会优先处理在队首的人的订单,后来的人则插入队尾。
队列也是这样的处理模式,时刻处理队首元素,然后队首元素出队,新元素在队尾入队。


队列是如何工作的

学到一个新的数据结构或算法,我们势必要考虑学习它的意义,它能解决什么样的问题。

2)广度优先搜索

这是运用队列最为标志性的一种算法,为什么广搜要用到队列呢?


DFS的顺序

BFS的顺序

显然,例如我们处于状态2时,对于状态2的处理,深搜会直接进行,而广搜会延后进行,先寻找状态3。
这样我们就不能直接想深搜一样递归了,但是这恰巧符合队列,从状态1扩展出状态2,3,4,5,这四个元素先入队,那么这四个元素就先处理,这样就解决了处理状态顺序的问题,然后再处理这四个子状态分别的子状态就可以了。

3)广度优先搜索的实现方法

  • 1>手写实现,也就是根据原理自行实现
  • 2>stl队列,本身C++里面是有专门的队列的,无需手写
    主要懒得写,详细参见:我们延老师的博客
    这是部分截图

    为了方便大家理解,实际上是我用惯手写的了,所以小编按照手写队列来说明。

4)例题

例1

刚才还留着一道没有说了,先搞完这个: https://www.luogu.com.cn/problem/P1135

P1135 奇怪的电梯

image.png

AC代码

#include<bits/stdc++.h>
using namespace std;
struct node{
    int now;//当前位置
    int step;//到达这一位置的步数
}q[1000000];
int n,a,b,skill[210000],head=0,tail=1,ans=999999,mapp[100000];//head是队首指针,tail是队尾指针(此处指针不是语法的指针,而是一种比喻)
void bfs()
{
    q[tail].now=a;
    q[tail].step=0;//初始元素入队
    while(tail>head)//判断队列内是否还有元素
    {
        head++;
        int now=q[head].now;
        int step=q[head].step;//队首出队
        int now1,step1;
        for(int i=1;i<=2;i++)
        {
            if(i==1) now1=now+skill[now];//上升
            else now1=now-skill[now];//下降
            step1=step+1;//走了这一步
            if(now1>n||now1<1||mapp[now1]==1) continue;//判断新位置是否可行
            q[++tail].now=now1;
            q[tail].step=step1;//新元素入队
            mapp[now1]=1;//标记走过此地
            if(now1==b)//按照广搜的原理:先找近的再找远的,所以第一个找到的就是最优解,无需回溯
            {
                ans=step1;
                break;
            }
        }
        
        
    }
}
int main()
{
    cin>>n>>a>>b;
    for(int i=1;i<=n;i++)
    cin>>skill[i];
    if(a==b) cout<<0;
    else
    {
        bfs();
        if(ans==999999) cout<<-1;
        else cout<<ans;
    }
    return 0;
}

例2

题目链接:https://www.luogu.com.cn/problem/P1443

P1443 马的遍历

题目描述
有一个n*m的棋盘(1<n,m<=400),在某个点上有一个马,要求你计算出马到达棋盘上任意一个点最少要走几步

输入格式
一行四个数据,棋盘的大小和马的坐标

输出格式
一个n*m的矩阵,代表马到达某个点最少要走几步(左对齐,宽5格,不能到达则输出-1)

输入输出样例
输入 #1
3 3 1 1
输出 #1
0 3 2
3 -1 1
2 1 4

解析

首先,这道题变了,不是迷宫那种上下左右的那种走法,所以对于每次扩展坐标的更改方法,需要改动;
其次,这道题不同的是,没有一个确定的结束位置,深搜难以实现,而广搜就是单纯的由近到远,全搜一遍,所以此题适宜使用广搜。

AC代码

#include<iostream>
#include<cmath>
#include<queue>
#include<iomanip>
using namespace std;
int n,m,a,b;int head=0,tail=1;
int edge[410][410]={0},mapp[410][410];
int next[8][2]={{2,1},{2,-1},{-2,1},{-2,-1},{1,2},{-1,2},{1,-2},{-1,-2}};//马移动,坐标的更改情况
struct node{
    int x,y;//位置
    int step;//步数
}q[161000];
void bfs()
{
    q[tail].x=a;q[tail].y=b;q[tail].step=0;
    mapp[a][b]=1;edge[a][b]=0;//放入第一个点 
    while(tail>head)
    {
        head++;
        int x=q[head].x;
        int y=q[head].y;//队首出队
        for(int i=0;i<8;i++)
        {
            int tx=x+next[i][0];
            int ty=y+next[i][1]; //扩展 
            if(tx<=0||ty<=0||tx>n||ty>m||mapp[tx][ty]==1) continue;//判断出界&已走过的点 
            q[++tail].x=tx;
            q[tail].y=ty;//新的位置入队 
            q[tail].step=edge[x][y]+1;
            mapp[tx][ty]=1;//标记走过了
            edge[tx][ty]=q[tail].step;
        }
    }
}
int main()
{
    cin>>n>>m>>a>>b;
    bfs();
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=m;j++)
        {
            if(edge[i][j]==0&&mapp[i][j]!=1) cout<<left<<setw(5)<<-1;
            else cout<<left<<setw(5)<<edge[i][j];
        }
        cout<<endl;
    }
    return 0;
}

三、树的基本知识

emmmmm,树好麻烦的,一上来就是无数个新的需要掌握的关键词,小编当年被整出心理阴影了。
所以一些不怎么常用的,要么是懒的写,要么是懒得学,小编就不讲解了。
非要学的话,看百度吧:https://baike.baidu.com/item/%E6%A0%91/2699484?fr=aladdin

首先,这是我们生活中常见的树该有的形状:


图1

这是数据结构中树的形状:


图2

倒不是不能像正常的生活中的树一样正放,但是还是图二在讲解中看的更方便。

最重要的几个术语

  • 在树最顶端的节点叫做根节点

  • 每个节点又生出若干个子节点

  • 而这个节点又叫做这些子节点的父亲节点

  • 没有子节点的节点叫做叶子节点
    如图2所示,0节点是根节点,1是0的子节点,0是1的父亲节点,3,4,5,6都是叶子节点;
    还有:

  • 树的层次:从根节点开始算,根节点算第一层,如上图有3层;

  • 树的高度:从叶子节点开始,自底向上增加;

  • 树的深度:与树的深度相反,自顶向下增加;
    不过后续用树时小编懒得用这三个词,太高级,我不配使用……

四、二叉树

1)定义

二叉树与普通的树不同的是,二叉树是指每一个节点的子节点个数都小于等于2的树。
如果某一棵二叉树每一个节点(不包括叶子节点)都有左子树和右子树,那么则称这个二叉树为满二叉树
例如

图2

之前这个图二就是一个满二叉树。
但是这个就不是满二叉树:
一个很随意的树

具体二叉树有什么特殊意义,在讲二叉排序树时再仔细讲。

2)二叉树的存储与建立

对于树的存储,很麻烦;但是二叉树就不同了,它能确定每个节点的子节点的个数,那么我们可以用一个结构体来表示。

struct node{//定义每个节点的一些必要的信息 
    int left;//左子树编号 
    int right;//右子树编号 
}tree[10000];

这样,在访问每一个节点的时候,就能快速知道其子节点的编号,并进入其左子树或右子树继续访问。
二叉树的建立,就不需要我多说了吧,直接代码呈上:

void build()
{
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>x>>l>>r;
        tree[x].left=l;
        tree[x].right=r;
    }
}

3)二叉树的遍历

二叉树既然建立了,就要有什么查找,修改之类的操作,那么遍历就是最为基础的。
对于二叉树的遍历,最常见的也就是以下几种:

  • 层序遍历
  • 先序遍历
  • 中序遍历
  • 后序遍历

先说层序遍历,就是下面这一个二叉树,一共有四层:


二叉树

那么就一层一层搜,从根节点开始由近到远,这像极了什么呢?广度优先搜索!
只要一层一层放入队列里面,再一个一个扩展出子节点就可以了,但是层序遍历貌似不怎么常用,广搜的代码已经讲过了,所以小编就不提供代码了。

然后就是先序遍历、中序遍历、后序遍历了,其实他们的本质都是深度优先搜索。

  • 先序遍历的顺序是:根节点=>左子树=>右子树;
  • 中序遍历的顺序是:左子树=>根节点=>右子树;
  • 后序遍历的顺序是:左子树=>右子树=>根节点;

所以说先、中、后其实就是指根节点的顺序。
按照这个原理,写成代码:

void first_order(int i)
{
    if(i==0) return;
    cout<<i<<" ";
    first_order(tree[i].left);
    first_order(tree[i].right);
}
void mid_order(int i)
{
    if(i==0) return;
    mid_order(tree[i].left);
    cout<<i<<" ";
    mid_order(tree[i].right);
}
void last_order(int i)
{
    if(i==0) return;
    last_order(tree[i].left);
    last_order(tree[i].right);
    cout<<i<<" ";
}
二叉树

就这个二叉树,代入进去之后顺序就是这样的:


运行结果

其中我的完整代码如下:

#include<bits/stdc++.h>
using namespace std;
int n,x,l,r;
struct node{//定义每个节点的一些必要的信息 
    int left;//左子树编号 
    int right;//右子树编号 
}tree[10000];
void build()
{
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>x>>l>>r;
        tree[x].left=l;
        tree[x].right=r;
    }
}
void first_order(int i)
{
    if(i==0) return;
    cout<<i<<" ";
    first_order(tree[i].left);
    first_order(tree[i].right);
}
void mid_order(int i)
{
    if(i==0) return;
    mid_order(tree[i].left);
    cout<<i<<" ";
    mid_order(tree[i].right);
}
void last_order(int i)
{
    if(i==0) return;
    last_order(tree[i].left);
    last_order(tree[i].right);
    cout<<i<<" ";
}
int main()
{
    build();
    cout<<"先序:";
    first_order(1);
    cout<<endl;
    cout<<"中序:";
    mid_order(1);
    cout<<endl;
    cout<<"后序:";
    last_order(1);
    return 0;
}
//3
//1 2 7
//2 3 6
//3 4 5

五、二叉排序树

什么也别说,敢在这一部分混得先做好心里准备,因为从这里开始,各种操作都上线了,代码量不仅大还不易查错,所以这里的东西尽量练出自己的模板并背会。

1)引入

之前我们学了二叉树,但是二叉树“每一个节点的子节点个数都小于等于2的树”的定义貌似没有什么实际意义,但是在二叉排序树上,就能看出它的好处。
二叉排序树的原理很简单,就是把一堆数据放到一棵树中,起初树是空的,任一元素为树的根节点,然后不停的执行这样的操作:小于当前节点的放在左子树上,否则放在右子树上。
例如这串数据:8,3,6,5,10,12,9,13
首先,8为根节点


8

然后是3,3<8,放在左子树


3

然后是6
6

……
最终

这样查询的话只要从根节点开始一路比大小就可以了,比如我想知道9是否存在,9>8,所以访问根节点的右子树,9<10,所以访问10的左子树,9=9,说明9存在在这一串数中。

这种方法有点类似于二分,查询次数不会超过树的深度,时间复杂度大约就是O(log n)。

2)例题

题目链接:https://www.luogu.com.cn/problem/P5076

P5076 【深基16.例7】普通二叉树(简化版)

题目

恕小编无此能耐,如果后面改好了代码我会更新的,先看看别人的题解吧:传送门
小编的代码不是MLE就是TLE,不知道哪里的问题,望大佬看到评论区留言。
惨不忍睹

无可奈何

#include<bits/stdc++.h>
using namespace std;
int q,x,y,cnt;bool flag=0;
struct node{
    int value;
    int left; 
    int right;
    int left_number;
    //int right_number;
}tree[1000];
void push(int i,int num)//插入
{
    if(tree[i].value==0)
    {
        tree[i].value=num;
        tree[i].left=i*2;
        tree[i].right=i*2+1;
        return;
    }
    if(num>=tree[i].value) 
    {
        //tree[i].right_number++;
        push(tree[i].right,num);
    }
    else 
    {
        tree[i].left_number++;
        push(tree[i].left,num);
    }
} 
void check_number_y(int i,int num)
{
    if(num>=tree[i].value)
    {
        cnt+=tree[i].left_number;
        cnt+=1;
        check_number_y(tree[i].right,num);
    }
}
void check_y(int i,int num)
{
    if(num==cnt)
    {
        cout<<tree[i].value<<endl;
        return;
    }
    else if(num<(cnt+tree[i].left_number)) 
    {
        cnt++;
        check_y(tree[i].left,num);
    }
    else 
    {
        cnt++;
        cnt+=tree[i].left_number;
        check_y(tree[i].right,num);
    }
}
void check_last(int i,int num)
{
    if(tree[i].value==0) return;
    if((tree[tree[i].left].value==num)||(tree[tree[i].right].value==num))
    {
        cout<<tree[i].value<<endl;
        flag=1;
        return;
    }
    if(num<tree[i].value) check_last(tree[i].left,num);
    else check_last(tree[i].right,num);
}
void check_next(int i,int num)
{
    if(tree[i].value==0) return;
    if(num==tree[i].value)
    {
        cout<<tree[tree[i].right].value<<endl;
        flag=1;
        return;
    }
    else if(num<tree[i].value) check_next(tree[i].left,num);
    else check_next(tree[i].right,num);
}
int main()
{
    cin>>q;
    for(int i=1;i<=q;q++)
    {
        cin>>x>>y;
        if(x==1) 
        {
            cnt=0;
            check_number_y(1,y);
            cout<<cnt<<endl;
        }
        else if(x==2) 
        {
            cnt=1;
            check_y(1,y);
        }
        else if(x==3) 
        {
            flag=0;
            check_last(1,y);
            if(flag==0) cout<<"-2147483647";
        }
        else if(x==4)
        {
            flag=0;
            check_next(1,y);
            if(flag==0) cout<<"2147483647";
        }
        else push(1,y);
    }
    return 0;
}

早知道我就找个模板题了……
不过写了这么多代码,我才发现这道题没必要用二叉排序树……
看看人家的博客,AC的多轻松:https://www.luogu.com.cn/blog/yuan-xin/solution-p5076#

3)为什么需要使用二叉排序树

因为通常我们都会用到二分,代码量很少,但是一扯到树,代码量就上来了。
二分和二叉排序树都要求数据有序,但是二分时刻要求数列有序,二叉排序树时刻要求树本身有序。
所以二分的缺点在于动态插入效率不高,每新插入一个数,就要重新排序一次,时间复杂度为O(n);
二叉排序树在插入一个数时,只要将这个节点找到正确位置插入就可以了,时间复杂度为O(log n)。

六、改进型1:*平衡树Treap

前言:事先说明一点,平衡树treap是二叉排序树的升级版,但在使用过程中有使用到随机数生成器,NOIP/CSP不能使用,所以小编会简要说明,不会贴上代码。此部分的作用在于为红黑树做铺垫。

1)引入

首先,二叉排序树存在严重的缺陷。


对于以下数据:
3,2,4,1,7,8,6,5
按顺序组成的二叉排序树长这样:
还能看得下去的一棵树

但是如果我是从小到大按顺序输入呢?:1,2,3,4,5,6,7,8
看不下去的二叉排序树

显然,按照顺序输入的二叉排序树会退化成链状,查询的时间复杂度就从O(log n)变成了O(n)。
辛辛苦苦多年写了不少代码建起来的代码,和人家直接for循环的时间复杂度一致,所以就得想办法改变一下。
比较一下之前两个图,第一个图是我很随机的打乱了这组数的顺序,所以树状结构很明显,而第二个图就是刻意输入的有序数据,那么说明:数据的顺序越随机,二叉排序树就越不容易退化成链
怎样做到随机呢?一定要把数组打乱,但为了防止链的情况尽量不发生,平衡树treap用到了优先级这一方法,优先级是用随机数生成器随机生成的,每一个点都有自己的优先级。
平衡树有很多种,现在讨论的只是其中一种,但是凡是平衡树一般都要在插入时进行改变树结构的操作,以维持树的平衡,几乎都需要左旋右旋这两种最重要的操作。

2)左旋&右旋

左旋:将原根节点变成新左子树的节点,将原右子树节点变成新根节点


左旋

右旋:讲原根节点变成新右子树的节点,将原左子树节点变成新根节点


右旋

举个麻烦一点的例子:


就这棵树,右旋一下:
首先如图所示挪位置:
1

然后左子树的地方把左子树的左子树补上去:
2

左子树的右子树补到右子树的左子树;
和根的右子树补到右子树的右子树:
最终

多试几次,就会发现这个移法一定在操作后这棵树仍是一棵二叉排序树。
因此,左旋和右旋都不会改变这棵树本质是二叉排序树这一事实。
听起来感觉很麻烦,其实代码很简单,小编懒得整了,自己去搜吧,反正noip也不能用

3)插入

先讲一下最小堆,就像摞东西一样,不过是一种树,这种树要求子节点的值都大于父节点的值。
例如:


最小堆

首先,我们就像堆一样构建这棵树,随时保证这棵树的优先级的分布是最小堆。(反正这也没有什么原因,就是为了看起来更随机一点)
那么具体方法如下:

  • 从根节点开始插入,显然优先级最小的,一定是根节点
  • 插入值<=当前节点的值,进入左子树
  • 插入值>当前节点的值,进入右子树
  • 若当前节点为空,说明这里没有插过,那么这里插入新节点
  • 插入新节点前没有考虑过优先级,所以插入后优先级之间如果有冲突,那么回溯时就要左旋或右旋
  • 若左子节点优先级小于当前节点优先级,右旋
  • 若右子节点优先级小于当前节点优先级,左旋

好了,我懒得配图了,一下图解来源于《信息学奥赛一本通(提高篇)》,此处致敬作者




4)删除
当我们遇到一个节点想要删除它的时候,我们需要考虑实际情况,其实归纳下来只有这三种:

  • 这个节点是叶子节点:直接删除
  • 这个节点只有一个子树:把这个节点删了,把子树补上来
  • 这个节点有两个子树:若左子节点优先级小于右子节点,右旋;否则左旋(保证优先级小的往上走)。一路左、右旋这个节点,直至变成前两种情况,这样可以维护这个这个最小堆依旧成立。

至于图解吗,就再次致敬作者吧:

好了,平衡树treap就到此位置吧。

七、改进型2:红黑树

未完待续……
先看一篇很敷衍的吧:传送门

八、树状数组

很多东西都是明摆着要用一棵树来维护的,但是树状数组正如其名,是为了优化暴力做法而构建的树,因为这样的数组就像树一样(其实就是树),因此得名。

1)引入

直接上问题,要求:给出数列,支持单点加,求区间和
只有单点加不难,O(1)就可以了
只求区间和也不难,用前缀和查分一下就ok了,照样O(1)
但是两个如果放在一起就不能用前缀和了,单点加照样O(1),但是区间和就变成了O(n),当询问次数多了的时候就超时了……
所以我们就请出了树状数组。

2)核心原理

咱们先放个图,先看看它的样子。


树状数组

其中最下面灰色,标注着A的是原数组,橙色的部分是另一个新开的数组,通常名称为C。
虽然是一个一个橙条,但是其实是每一个节点保存着对应橙条内的区间和(每个节点的编号为区间的结束编号,仔细观察,就会发现各区间的结束位置编号都各不相同),并且只靠橙条,就可以拼出区间内任意数的前缀和,例如随便找个数:7,对应的就是下面三部分:


需要用到的区间

将这三个值拼起来,就是这一前缀和。

3)单点加

这种棘手的问题一定不会有十全十美的做法,既然现在要优化区间和,那么单点加就麻烦了。
原来只需要加一次,加在原数组上,但是现在需要修改log n次,例如我要修改a[1],那么我就要一路扶摇直上,因为上面的区间和也需要刷新:

需要刷新的区间

看到这里,觉得很麻烦吗?不代码很简单,因为任何数都可以分解为的形式,例如还是a[1],那么它需要修改的区间长度很直观,分别是:



...
1需要加的区间长度很容易看出来在不停乘以2,但是其他的显然不完全符合,例如3。
渐渐地,我们会发现一个规律:设我们现在在n号节点所代表的区间,下一次区间增加的长度就是,这个大小记为lowbit(n),例如3吧,二进制下是,那么lowbit(n)=,(以下二进制忽略下标2和括号,原因:懒得写,好麻烦)然后,11+1=100,十进制表示数为4,接着lowbit(4)=100,100+100=1000,十进制为8,然后……
所以这个顺序就是3=>4=>8=>16,恰好和图上是一致的。
接下来的问题:lowbit怎么求?听好,前方高能
假设要求lowbit的这个数是x,假设x最小的一位1是在第k位,那么对x取反,此时:k+1位及之前全部1变0,0变1,第k位变成了0,第k-1位及以后全部0变1;此时再+1,k+1位及之前不变,第k-1位及以后所有1遇见加的1不断进位至第k位,第k位由于进位变成了1。整个操作过程数学语言为:~x+1。
用这玩意去&x,就会发现k+1位及之前恰好相反(因为之前有过取反操作),于是抵消全部为0;第k-1位及以后两者全部是0,于是这一部分也全是0;只有第k位,x中是1,~x+1中也是1,&运算后这一位就是1,再看其它位,全部是0,于是这个1就提出来了,表示成10进制的值就已经是2的次幂。
在二进制补码中,~x+1写作-x。
于是:lowbit(x)=x&-x 。
于是就有了超级简洁的代码实现

void add(int x,int y)
{
    for(int i=x;i<=n;i+=lowbit(i))
    c[i]+=y;
}

4)前缀和

只要找出前缀和后就能差分得到区间和,所以当务之急是求出前缀和。
根据单点的方法,同理就有前缀和的求法:

int pre(int x)
{
    int ans=0;
    for(int i=x;i>0;i-=lowbit(i))
    ans+=c[i];
    return ans;
}

不过由于区间的长度不一定是2的整数次幂,所以,i循环时一定要从大到小。

5)上例题

P3374 【模板】树状数组 1

part1

part2

AC代码

这已经是模板了,那么呈上完整代码:

#include<cstdio>
using namespace std;
int m,n,num[10000000],c[1000000],number,a,b,k; 
int lowbit(int x) {return x&-x;}
void add(int x,int y)
{
    for(int i=x;i<=n;i+=lowbit(i))
    c[i]+=y;
}
int pre(int x)
{
    int ans=0;
    for(int i=x;i>0;i-=lowbit(i))
    ans+=c[i];
    return ans;
}
int main()
{
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++)
    {
        scanf("%d",&num[i]);
        add(i,num[i]);
    }
    for(int i=1;i<=m;i++)
    {
        scanf("%d%d%d",&number,&a,&b);
        if(number==1) add(a,b);
        else printf("%d \n",pre(b)-pre(a-1));
    }
    return 0;
}

其实树状数组的能力不局限于此,可以实现:

  • 单点修改,前缀和查询
  • 单点修改,区间查询
  • 区间修改,单点查询
  • 区间修改,区间查询

(其中区间修改只支持可逆操作,例如加减乘除,但最大值最小值等不可以,否则无法查询)
但是剩下没讲的两种实现起来都很麻烦,不过我们可以使用线段树

九、线段树

1)核心思想

处理一下上面没有解决的问题,最大最麻烦的问题有两个,一个是树状数组不支持不可逆操作,另一个是区间修改,区间查询。
先放个图(这是特殊情况下的线段树):

线段树

显然,这棵树的存储更占空间,树状数组只需要开n那么大,但是线段树通常要开到4×n。
对于各节点的编号,由于线段树的原理是分治,所以线段树是完全二叉树,不懂的去看看百度吧:传送门
可以把完全二叉树当作满二叉树,把不存在的节点当作不留信息的空节点,此时满足左子节点编号=当前节点2,右子节点编号=当前节点2+1,尽管浪费了很多编号,但是这样能快速准确地找到某个节点的左子节点和右子节点。
我们约定sum(l,r)表示数列num区间[l,r]的区间和/区间最小值/最大值等(线段树支持不可逆操作),此处及以后以区间和为例。
那么每一个节点的信息分别是:
举例

其中:红色代表这个节点是怎么算的,蓝色代表该节点的准确值,黄色代表该节点编号。
由此可见:线段树的规律就是,根节点的值=左子节点的值(任意操作,例如:+,max,min等)右子节点的值。

2)建立

这很简单的吧,只要找到叶子节点就把数列中对应的数插入进去就可以了。
不过,有一个频繁使用的操作就是在回溯的过程中更新根节点的值,因为我们是先知道叶子节点的值的,然后再向上更新。


希望能看懂

那么这个东西就作为一个函数提出来:

void pushup(int i) {sum[i]=sum[i*2]+sum[i*2+1];}

于是这个建立过程就是这样的:

void build(int i,int l,int r)
{
    if(l==r) 
    {
        sum[i]=num[l];
        return;
    }
    int mid=(l+r)/2;
    build(i*2,l,mid);
    build(i*2+1,mid+1,r);
    pushup(i);
}

3)区间加

单点加太简单了,就不说了,不过区间加呢?
难道是要遍历一遍区间,然后不断单点加吗?这样的时间复杂度还不如暴力解决。
我们只能找其他办法解决这个问题————懒!
整个程序当然有大量节点等待更新信息,我们的一致处理方法就是能不更新就不更新,非要更新的时候把多年来没有更新的一次性全部更新了。
怎样能尽量不更新呢,那么当然是打标记(可以称之为懒标记),比如说我要给这个节点所代表的区间整个加3:



我就总想着很懒,下一次查询区间和一定不会查询到我的~~~
于是我停止向下继续让子树内各节点区间加3,打个标记:+3,记录一下我身上的任务sum[i]=3*区间长度。


打标记

这时,假如这个节点很不幸,正好在区间查询或区间加发现它懒到没有让子树的节点们增加,就要赶在没有发生什么错误之前,标记下放:
void pushdown(int i,int l,int r)
{
    if(tag[i])//tag用来标记
    {
        int mid=(l+r)/2;
        tag[i*2]+=tag[i];//同一个节点只要不被调用,就可以一直被标记,所以不是赋值,而是+=
        sum[i*2]+=(long long)(tag[i]*(mid-l+1));//让左子节点处理这件事
        tag[i*2+1]+=tag[i];
        sum[i*2+1]+=(long long)(tag[i]*(r-mid));//让右子节点处理这件事
        tag[i]=0;//然后自己又是一个干净的结点啦,无官一身轻
    }
}

这样就能得到区间加的代码了:

void update(int i,int l,int r,int L,int R,int x)
{
    if(l>=L&&r<=R)//当当前区间正好在给定区间内时
    {
        tag[i]+=x;//放个懒标记
        sum[i]+=(long long)(x*(r-l+1));//再暂时处理一下自己的信息
        return;//然后就罢工了
    }
    pushdown(i,l,r);//若正好路过,以后需要调用到当前这个点的子树的信息,那么立刻标记下放
    int mid=(l+r)/2;
    if(mid>=L) update(i*2,l,mid,L,R,x);
    if(mid<R) update(i*2+1,mid+1,r,L,R,x);
    pushup(i);//之前标记下放可能会影响一些值,需要更新一下
}

最终,标记会一直下放到叶子节点的子节点,即空白节点,空白的节点是永远不会访问到的,无需考虑。

4)区间查询

查询部分就很简单了,直接呈上代码:

long long query(int i,int l,int r,int L,int R)
{
    if(l>=L&&r<=R) return sum[i]; //叶子节点直接返回
    int mid=(l+r)/2;
    long long ans=0;//有时可能返回值很大,建议用long long
    pushdown(i,l,r);//标记下放
    if(mid>=L) ans+=query(i*2,l,mid,L,R);
    if(mid<R) ans+=query(i*2+1,mid+1,r,L,R);
    return ans;//这里其实进不进行pushup更新无所谓
}

5)例题

题目链接:https://www.luogu.com.cn/problem/P3372

P3372 【模板】线段树 1


AC代码

#include<bits/stdc++.h>
using namespace std;
long long n,m,num[100010],sum[1000000],tag[1000000],number,a,b,k;
void pushup(int i) {sum[i]=sum[i*2]+sum[i*2+1];}
void pushdown(int i,int l,int r)
{
    if(tag[i])
    {
        int mid=(l+r)/2;
        tag[i*2]+=tag[i];
        sum[i*2]+=(long long)(tag[i]*(mid-l+1));
        tag[i*2+1]+=tag[i];
        sum[i*2+1]+=(long long)(tag[i]*(r-mid));
        tag[i]=0;
    }
}
void build(int i,int l,int r)
{
    if(l==r) 
    {
        sum[i]=num[l];
        return;
    }
    int mid=(l+r)/2;
    build(i*2,l,mid);
    build(i*2+1,mid+1,r);
    pushup(i);
}
void update(int i,int l,int r,int L,int R,int x)
{
    if(l>=L&&r<=R)
    {
        tag[i]+=x;
        sum[i]+=(long long)(x*(r-l+1));
        return;
    }
    pushdown(i,l,r);
    int mid=(l+r)/2;
    if(mid>=L) update(i*2,l,mid,L,R,x);
    if(mid<R) update(i*2+1,mid+1,r,L,R,x);
    pushup(i);
}
long long query(int i,int l,int r,int L,int R)
{
    if(l>=L&&r<=R) return sum[i];
    int mid=(l+r)/2;
    long long ans=0;
    pushdown(i,l,r);
    if(mid>=L) ans+=query(i*2,l,mid,L,R);
    if(mid<R) ans+=query(i*2+1,mid+1,r,L,R);
    return ans;
}
int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    cin>>num[i];
    build(1,1,n);
    for(int i=1;i<=m;i++)
    {
        cin>>number;
        if(number==1)
        {
            cin>>a>>b>>k;
            update(1,1,n,a,b,k);
        }
        else 
        {
            cin>>a>>b;
            cout<<query(1,1,n,a,b)<<endl;
        }
    }
    return 0;
}

6)划分树

划分树是线段树的升级版,比线段树要快一些。
传送门

十、基础图论

未完待续……
不过你可以在这里寻宝:传送门
这是我曾经写过的有关图的博客。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259