数据结构总结

数据结构模块总结归纳

【前言】

临近CSP二轮,做一些总结归纳,就当作是复习吧。加油吧!

【目录】

(注:标*号为重要)

    • 单调栈
  • 队列
    • 单调队列
    • 双端队列
  • 邻接表
  • *堆
    • 对顶堆
    • 优先队列
  • 并查集
    • 扩展域
    • 边带权
    • 连通性
  • 树状数组
    • 权值树状数组
    • 二维树状数组
  • *线段树
    • 多标记下传
    • 权值线段树
    • 扫描线
    • 线段树合并
  • 分块
  • STL
    • set
    • vector
    • map
  • 题型总结和综合归纳

【栈】

最简单基础的一类数据结构,但是熟练掌握也能玩出朵花来。比如双栈排序之类的。

用途:一般用于一些先入后出的常规操作,如表达式计算、递归、找环等一系列常规操作。

实现:鉴于STL的stack常常爆空间,而且还不如手写快,因此我们更常使用手写栈。

代码就不贴了。

单调栈

【队列】

也是很基础的一个数据结构,适用范围及其广泛,用处花样繁多。

用途:太多了。比如BFS、各种序列问题、各种数据结构问题。

实现:上界较大时不宜使用STL的queue,不过一般情况下推荐使用,毕竟简洁且不易错。

初学的时候的手写队列BFS:

void bfs(int i,int j)
 {
     int head=0,tail=1;
     q[tail].x=i;q[tail].y=j;
     pre[tail]=tail;
     memset(vis,0,sizeof(vis));
     do
     {
         head++;
         for(int i=0;i<4;i++)
         {
             int nx=q[head].x+dir[i][0];
             int ny=q[head].y+dir[i][1];
             if(nx>0&&nx<=5&&ny>0&&ny<=5&&vis[nx][ny]==0&&a[nx][ny]!=1)
             {
                 tail++;
                 q[tail].x=nx;
                 q[tail].y=ny;
                 pre[tail]=head;
                 vis[nx][ny]=1;
             }
             if(nx==5&&ny==5){print(tail);return;}
         }
     }while(head<tail);
 }

STL的队列不再赘述。

单调队列

好东西,可以优化dp。

【邻接表】

很有意思的一个数据结构,设计精巧(至少我是这么认为的)。

用途:大概除了存图也没什么别的大用了。

实现:OI还是不推荐使用指针,毕竟容易瞎,难调试。个人也比较喜欢数组模拟指针。

可以看作多个有代表元的数组的集合,从表头开始,使用指针指向下一个元素。

下面代码实现中,\(head[x]\)为一个以\(x\)为代表元的表头,每个元素拥有一个指针指向它的下一个元素。

const int N=100010;
struct rec{
	int next,ver,edge;
}g[N<<1];
int head[N],tot;
inline void add(int x,int y)
{
	g[++tot].ver=y;
	g[tot].next=head[x],head[x]=tot;
}

【并查集】

总之,是一个非常好玩、用途广泛的数据结构。

用途:维护二元关系、维护连通性、维护其它数据结构(但是不讲其实是我没学)等。

原理:实质上,并查集维护的是一组集合。每个元素有一个tag,表示它所在的集合。我们在每个集合中选出一个代表元来作为这个tag,便于维护。并查集包括合并、查找两个操作,意为合并两个不相交集合、查找一个元素所在集合,这也是为什么它叫并查集。

实现:

初始化

初始化时,我们把每个元素的代表元设为自己。

const int N=100010;
int fa[N];
for(int i=1;i<=N;++i) fa[i]=i;

查找

int get(int x)
{
    if(x==fa[x]) return x;
    get(fa[x]);
}

合并

void Union(int x,int y){
    x=get(x),y=get(y);
    fa[x]=y;
}

路径压缩

容易发现上面那个查找算法对一条链会退化得很厉害。我们可以路径压缩,即让一个集合中得所有元素都指向同一个代表元,而不是指向它的父亲。时间复杂度\(O(nlogn)\),空间复杂度\(O(n)\)

int get(int x)
{
    return x==fa[x]?x:fa[x]=get(fa[x]);
}

启发式合并

不多讲,因为不常用。主要思路就是以合并时集合元素数量作为\(h(x)\)函数,启发式合并。具体实现其实差不了多少。

连通性

并查集还可以做一些图论有关连通性的题,吊打Tarjan

用途:找环、判断联通性等。

最好的例子就是最小生成树了。

扩展域

用途:维护二元关系。

原理:这里涉及到必修五的知识(雾,没学过可以去看一下必修五第一章逻辑用语。主要维护充要条件,大致意思是可以相互推导的关系。我们将并查集分为多个“域”,可以理解做不同种的逻辑命题,当我们合并不同域的集合的某两个元素\(p,q\)时,我们可以理解作\(p\Leftrightarrow q\)

这些关系具有传递性,意即若\(p\Leftrightarrow q,q\Leftrightarrow r\),则有\(p\Leftrightarrow r\)。因此,我们不妨把一个命题看作一个点,这种关系看作维护点与点之间的联通性,这就转换为了一个并查集可做的问题了。

实现:

拿一道例题吧,不然讲不清楚。

P1525 关押罪犯

题解

边带权

用途:动态统计链长、环长等。

原理:很简单,说白了就是动态统计集合元素数量。

实现:

看一道例题P1197 星球大战

题解

【堆】

很好用的辅助数据结构,很多问题可以借助堆优化。

用途:动态维护第\(k\)小,维护最小/大值。

原理:一句话,上小下大(小根堆)/上大下小(大根堆)。

实现:一般使用STL的优先队列,不排除卡常毒瘤题要求手写堆。

priority_queue<data_type> queue[N];

手写堆(搬的):

int n,m,y,ans,t; 
int tree[2000003];
void change(int a,int b)
{
    int temp=tree[a];
    tree[a]=tree[b];
    tree[b]=temp;
}
void insert(int x)
{
    int d,f=0;//作为判断新加入的节点与其根节点大小的标志 
    while(x!=1&&f==0)//边界条件 
    {
        if(tree[x/2]>tree[x])//新节点小于根节点则交换值 
        {
            d=x/2;
            change(x,d);
        }
        else f=1;//新节点大于根节点则不发生改变 
        x=x/2;//继续查找下一个根节点(大概是爷爷节点吧(雾)是否小于该新节点,不是则继续查找,直到下一个根节点值小于该新节点 
    }
}
void del(int x)//将tree[n]放在tree[1]不满足小根堆的性质,所以要进行调整 
{
    int d=x,f=0;
    while(x*2<=t&&f==0)//边界条件
    {
        if(tree[x]>tree[x*2]&&x*2<=t)
        {
            d=x*2;
        }
        if(tree[x]>tree[x*2+1]&&tree[x*2+1]<tree[x*2]&&x*2+1<=t) 
        {
            d=x*2+1;
        }
        if(x!=d)
        {
            change(x,d);
            x=d;
        }
        else f=1;
    } 
}
————————————————
版权声明:本文为CSDN博主「MerakAngel」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/MerakAngel/article/details/75434737

对顶堆

用途:动态维护第\(k\)小。

原理:对于一个序列\(a[1\sim n]\),建立一个大根堆一个小根堆,大根堆维护\(k\sim n\)大值,小根堆维护\(1\sim k-1\)大值。

【树状数组】

常数小,又好写,能用尽量用吧,虽然可扩展性差,但省事。

用途:解决一系列区间问题、二维区间问题、奇奇怪怪的数据结构问题。维护前缀和、二维偏序之类。

原理:

基于二进制划分。首先定义\(lowbit(x)\) 运算,含义是正整数\(x\)二进制表示下最低位的1表示的十进制数。

举个例子:\(lowbit(5)=1\)\((3)_{10}=(101)_2\)\(lowbit(12)=4\)\((12)_{10}=(1100)_2\)

关于\(lowbit(x)\)的实现,我们可以利用计算机的补码来做。比如\((1100)_2\)取反之后变成\((0011)_2\),加1之后与上\(x\)就是\(lowbit(x)\)了,这个操作正好是补码的操作。所以,\(lowbit(x)=x\&-x\)

树状数组将\(1\sim n\)这个区间划分为若干个以2的次幂为长度的小区间。对于任意位置\(i\),它维护一个长度为\(lowbit(i)\)的区间,即\(i-lowbit(i)+1\sim i\)这个区间的和。

时间复杂度为\(O(nlogn)\),空间复杂度\(O(n)\)

由于树状数组只能维护关于前缀和的信息,所以这些信息必须满足前缀和可减性。

实现:

单点修改

void add(int x,int y)
{
    for(;x<=N;x+=x&-x) c[x]+=y;
}

单点查询

int ask(int x)
{
    int ans=0;
    for(;x;x-=x&-x) ans+=c[x];
    return ans;
}

区间修改+单点查询

由于树状数组只能做单点修改,所以区间修改要用到差分。

int d[N];
void add(int x,int y)
{
    for(;x<=N;x+=x&-x) d[x]+=y;
}
int ask(int x)
{
    int ans=0;
    for(;x;x-=x&-x) ans+=d[x];
    return ans;
}
int main()
{
    //do something
        
    add(l,1),add(r,-1);//区间修改
    
    //do something
        
    cout<<ask(pos)<<endl;//pos为单点询问位置
}

区间修改+区间查询

对于\(1\sim n\)的前缀和,我们要维护这样一个东西

\[\sum_{i=1}^n\sum_{k=1}^i d[k] \]

考察\(d[k]\)出现的次数,\(d[1]\)出现\(n\)次,\(d[2]\)出现\(n-1\)次,\(d[k]\)就出现\(n-k+1\)次。

所以我们要维护的东西变成

\[\sum_{k=1}^n (n-k+1)*d[k] \]

所以维护\(d[k]*k,d[k]\)两个东西即可。

void add(int x,int y)
{
    for(int i=x;i<=N;i+=i&-i) c1[i]+=y,c2[i]+=x*y;
}
int ask1()
{
    int ans=0;
    for(int i=x;i<=N;i+=i&-i) ans+=c1[x];
    return ans;
}
int ask2()
{
    int ans=0;
    for(int i=x;i<=N;i+=i&-i) ans+=c2[x];
    return ans;
}
int main()
{
    //do something
        
    add(l,1),add(r,-1);//区间修改
    
    //do something
        
    cout<<ask2(pos)-r*ask1(pos)<<endl;//pos为单点询问位置
}

二维树状数组

单点查询+区间修改

不再赘述

int c[N][N],n;
inline void add(int x,int y,int val)
{
    for(;x<=n;x+=x&-x)
     for(int j=y;j<=n;j+=j&-j) c[x][j]+=val;
}
inline int ask(int x,int y)//请不要在意这个鬼畜的二维树状数组
{
    int ans=0;
    for(;x;x-=x&-x)
     for(int j=y;j;j-=j&-j) ans+=c[x][j];
    return ans;
}
inline void change(int x1,int y1,int x2,int y2)//要修改的左上角、右下角
{
    add(x1,y1,1),add(x1,y2+1,-1),add(x2+1,y1,-1),add(x2+1,y2+1,1);
}

区间查询+区间修改

维护这个式子

\[\sum_{i=1}^n\sum_{j=1}^m\sum_{k=1}^i\sum_{p=1}^j d[k][p] \]

仍然是考察\(d[k][p]\)出现多少次,\(d[1][1]\)出现\(n*m\)次,\(d[1][2]\)出现\(n*(m-1)\)\(d[2][1]\)出现\((n-1)*m\)次,\(d[k][p]\)出现\((n-k+1)*(m-p+1)\)次。所以我们维护这个式子

\[\sum_{k=1}^i\sum_{p=1}^j (n-k+1)*(m-p+1)*d[k][p] \]

整理得

\[\sum_{k=1}^i\sum_{p=1}^j (n-k+1)*(m-p+1)*d[k][p]\\ =\sum_{k=1}^i\sum_{p=1}^j ((n+1)*(m+1)*d[k][p]-k*(m+1)*d[k][p]-p*(n+1)*d[k][p]+k*p*d[k][p]) \]

维护四个东西\(d[p][k],k*d[k][p],p*d[k][p],k*p*d[k][p]\)

代码没写,摘自胡小兔的博客

#include <cstdio>
#include <cmath>
#include <cstring>
#include <algorithm>
#include <iostream>
using namespace std;
typedef long long ll;
ll read(){
    char c; bool op = 0;
    while((c = getchar()) < '0' || c > '9')
        if(c == '-') op = 1;
    ll res = c - '0';
    while((c = getchar()) >= '0' && c <= '9')
        res = res * 10 + c - '0';
    return op ? -res : res;
}
const int N = 205;
ll n, m, Q;
ll t1[N][N], t2[N][N], t3[N][N], t4[N][N];
void add(ll x, ll y, ll z){
    for(int X = x; X <= n; X += X & -X)
        for(int Y = y; Y <= m; Y += Y & -Y){
            t1[X][Y] += z;
            t2[X][Y] += z * x;
            t3[X][Y] += z * y;
            t4[X][Y] += z * x * y;
        }
}
void range_add(ll xa, ll ya, ll xb, ll yb, ll z){ //(xa, ya) 到 (xb, yb) 的矩形
    add(xa, ya, z);
    add(xa, yb + 1, -z);
    add(xb + 1, ya, -z);
    add(xb + 1, yb + 1, z);
}
ll ask(ll x, ll y){
    ll res = 0;
    for(int i = x; i; i -= i & -i)
        for(int j = y; j; j -= j & -j)
            res += (x + 1) * (y + 1) * t1[i][j]
                - (y + 1) * t2[i][j]
                - (x + 1) * t3[i][j]
                + t4[i][j];
    return res;
}
ll range_ask(ll xa, ll ya, ll xb, ll yb){
    return ask(xb, yb) - ask(xb, ya - 1) - ask(xa - 1, yb) + ask(xa - 1, ya - 1);
}
int main(){
    n = read(), m = read(), Q = read();
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            ll z = read();
            range_add(i, j, i, j, z);
        }
    }
    while(Q--){
        ll ya = read(), xa = read(), yb = read(), xb = read(), z = read(), a = read();
        if(range_ask(xa, ya, xb, yb) < z * (xb - xa + 1) * (yb - ya + 1))
            range_add(xa, ya, xb, yb, a);
    }
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++)
            printf("%lld ", range_ask(i, j, i, j));
        putchar('\n');
    }
    return 0;
}

【线段树】

最常用的数据结构,可扩展性强,直观,缺点是常数大、码量大,不过练熟之后还是用处很大的。

用途:解决各种区间问题(基本上除了区间众数)、树剖、优化dp。用于维护满足结合律的信息。

原理:

这里只讲数组下标实现的线段树。

基于完全二叉树和分治思想,将区间(线段)逐层二分为小段,并维护每一小段的信息,主要考虑每层直接的信息传递与推导的具体做法。线段树是递归定义的。

线段树的特征

  1. 线段树的每个节点都代表一个区间
  2. 线段树具有唯一的根节点,代表的区间是整个统计范围。
  3. 线段树的每个叶节点都代表一个长度为\(1\)的元区间。
  4. 对于每个内部节点\([l,r]\)它的左子节点是\([l,mid]\),右子节点是\([mid+1,r]\),其中\(mid=(l+r)/2(floor)\);

下面以加法操作为例,实现了一个单标记线段树。

建树

#define LL long long
void build(LL p,LL l,LL r)
{
	t[p].l=l;t[p].r=r;
	if(l==r){
		t[p].sum=a[l];//前提题目有需求初始化数列,否则空线段树无需sum值
		return;
	}
	LL mid=(l+r)>>1;
	built(p<<1,l,mid);
	built((p<<1)|1,mid+1,r);
	t[p].sum=t[p<<1].sum+t[(p<<1)|1].sum;
}

区间修改

void change(LL p,LL l,LL r,LL k)
{
	if(l<=t[p].l&&t[p].r<=r){
		t[p].sum+=k*(t[p].r-t[p].l+1);
		return;
	}
    spread(p);
	LL mid=(t[p].l+t[p].r)>>1;
	if(l<=mid) change(p<<1,l,r,k);
	if(r>mid) change((p<<1)|1,l,r,k);
	t[p].sum=t[p<<1].sum+t[(p<<1)|1].sum;
}

区间查询

LL ask(LL p,LL l,LL r)
{
	if(l<=t[p].l&&t[p].r<=r) return t[p].sum;
    spread(p);
	LL mid=(t[p].l+t[p].r)>>1;
	LL val=0;
	if(l<=mid) val+=ask(p<<1,l,r);
	if(r>mid) val+=ask((p<<1)|1,l,r);
	return val;
}

标记下传

void spread(LL p)
{
	if(t[p].add){
		t[(p<<1)|1].sum+=t[p].add*(t[(p<<1)|1].r-t[(p<<1)|1].l+1);
		t[p<<1].sum+=t[p].add*(t[p<<1].r-t[p<<1].l+1);
		t[(p<<1)|1].add+=t[p].add;
		t[p<<1].add+=t[p].add;
		t[p].add=0;
	}
}

多标记下传

多标记下传主要就是要注意一个优先级问题,绝对不能几个标记一起修改。

拿几道题出来,多练习的话,实际上并不难。主要还是要深刻理解懒标记的工作原理,以便更好地理解多标记下传。

P3373 【模板】线段树 2

题解

P3932 浮游大陆的68号岛

题解

P4560 [IOI2014]Wall 砖墙

题解

权值线段树

也称为值域线段树,用于维护一段区间内的各种值的出现次数。

用途:动态查询第\(k\)小,值\(x\)出现的\(rank\),寻找\(x\)的前驱、后继,总结来说就是在一些没那么多操作的题里抢平衡树的饭碗。

原理:

内部实现几乎与普通线段树一致,只是改维护数组下标为维护值。即出现一个值\(val\)我们就在叶子节点\([val,val]\)\(+1\),然后向上传递信息。懒标记也是一样的。


这种题目其实很常见:

P1486 [NOI2004]郁闷的出纳员

题解

把上面的权值树状数组也搬到这里:

P1972 [SDOI2009]HH的项链

题解

其思想内核一致,都是在维护不同种类的值出现的次数或位置。

扫描线

用途:求坐标系中多个矩形面积并或周长覆盖。

原理:用一根扫描线(权值线段树)扫一遍整个坐标系,扫到某个位置时,遇到有矩形入边的地方就\(+1\),有出边的地方就\(-1\)

空讲讲不清楚,看题

HDU - 1542

题解

P1856 [USACO5.5]矩形周长Picture

题解

动态开点

有时候盲目build整颗线段树会浪费很多时空间,于是就有了动态开点。

原理:当一个点需要时(被修改、被查询)才去把它建出来,因此这种结构的线段树要用指针实现。

实现:除了改数组下标式为指针式,动态分配节点编号,其它实现细节没有区别。

线段树合并

有时候我们要先对很多个\([1,n]\)的区间分别进行一些操作,这时普通的线段树无法胜任。

原理:使用基于指针实现的线段树,建立多棵线段树(类似主席树),分别统计多个\([1,n]\)区间的操作,最后将每个线段树表示同一段线段的节点的权值累加得到最终答案。

实现:直接把所有线段树两个两个合并就得了,具体合并就是同步递归,累加当前节点的权值。

【分块】

我们常听人说,分块大法好。实际上鄙人不是很喜欢分块,毕竟既暴力又不优雅,还容易出错。

用途:几乎全能,可扩展性最强。

原理:将待维护序列\([1,n]\)分为\(\sqrt n\)端,大段维护,小段暴力。

实现:不详细讨论,随性写。

【STL】

好东西,就是没有\(O2\)的CSP容易T。

vector

vector<type_name> vec;

变长数组。

set

set<type_name> s;

内部是一颗红黑树,一般用来代替平衡树。

map

map<type_name,type_name> mp;

内部实现是红黑树,一般用于hash。

posted @ 2019-11-14 12:03  DarkValkyrie  阅读(362)  评论(0编辑  收藏  举报