数据结构总结
数据结构模块总结归纳
【前言】
临近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\)的前缀和,我们要维护这样一个东西
考察\(d[k]\)出现的次数,\(d[1]\)出现\(n\)次,\(d[2]\)出现\(n-1\)次,\(d[k]\)就出现\(n-k+1\)次。
所以我们要维护的东西变成
所以维护\(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);
}
区间查询+区间修改
维护这个式子
仍然是考察\(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)\)次。所以我们维护这个式子
整理得
维护四个东西\(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\)的元区间。
- 对于每个内部节点\([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;
}
}
多标记下传
多标记下传主要就是要注意一个优先级问题,绝对不能几个标记一起修改。
拿几道题出来,多练习的话,实际上并不难。主要还是要深刻理解懒标记的工作原理,以便更好地理解多标记下传。
权值线段树
也称为值域线段树,用于维护一段区间内的各种值的出现次数。
用途:动态查询第\(k\)小,值\(x\)出现的\(rank\),寻找\(x\)的前驱、后继,总结来说就是在一些没那么多操作的题里抢平衡树的饭碗。
原理:
内部实现几乎与普通线段树一致,只是改维护数组下标为维护值。即出现一个值\(val\)我们就在叶子节点\([val,val]\)处\(+1\),然后向上传递信息。懒标记也是一样的。
这种题目其实很常见:
把上面的权值树状数组也搬到这里:
其思想内核一致,都是在维护不同种类的值出现的次数或位置。
扫描线
用途:求坐标系中多个矩形面积并或周长覆盖。
原理:用一根扫描线(权值线段树)扫一遍整个坐标系,扫到某个位置时,遇到有矩形入边的地方就\(+1\),有出边的地方就\(-1\)。
空讲讲不清楚,看题
动态开点
有时候盲目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。