蒟蒻的数据结构清单
前言:如果这个世界上有个人类伟大智慧的体现的清单,数据结构必然在其中有一席之地。
本文只是笔者学习数据结构之后的总结,因为学过,大部分都会了,所以没有详解,只会留下几个稍微能增加理解的注意点。
数组
最重要也最基础也最复杂的数据结构,不再赘述
栈 :
先进后出,c++的STL库中有现成的封装,没什么好说的
std::stack<type> st;
//type表示栈存储的数据类型
st.pop()
st.push()
st.size()
st.empty()
队列
先进先出,有现成封装,没什么好说的
std::queue<type> que;
//type表示队列存储的数据类型
que.size()
que.front()
que.push()
que.pop()
优先队列
一个最大(小,或者自定义)堆,堆顶为最大值,堆内无序
std::priority_queue<type> que;
que.push()
que.pop()
que.top()
que.size()
que.empty()
不可重集
查询一个数据在不在集合里,集合内部有序,数据唯一。
std::set<type> st;
st.insert()
st.clear()
st.find()//为真会返回该位置迭代器,为假会返回st.end()
st.size()
链表
c++有分装,但实际情况链表用的不多,绝大多数题目都是上数组模拟链表,导致链表地位很尴尬
std::list<type> lst;
//type表示链表存储的数据类型
lst.size()
lst.begin()
lst.end()
lst.insert(iter,key)
lst.clear()
二叉堆(红黑树实现)
一种平衡二叉查找树,能快速高效维护一个序列中的最大值,最小值,每个数的排名,每个排名的数字等复杂功能。
放在此处绝非其实现简单,而是因为有现成的库pbds(俗称平板电视)可以调用
#include "bits/extc++.h"
using namespace __gnu_pbds;
//注意头函数和命名空间
__gnu_pbds::tree<type,null_type,less<type>,rb_tree_tag,tree_order_statistics_node_update> tr;
// 存储类型 不管 排序方式 红黑树(不管) 更新方式(不管)
//insert 插入 erase 删除
// order_of_key 求排名,从0开始
// find_by_order 求排名第k的数,从0开始
// tr1.join(tr2) 合并两个红黑树,要求类型一样且不重复
// tr1.split(v,tr2) 拆分一课红黑树,小于等于v的元素保留在tr1,其他属于tr2
// lower_bound(x) 查询大于等于x的数
// upper_bound(x) 查询大于x的数
Hash表
通过设计一个Hash函数把范围较大或者教复杂的数据映射到较简单的位置,比如通过字符串hash把一个长字符串映射为一个数字,以方便键值对查询
映射的时候数据会失真,同时会产生映射冲突,所以基本上能不用还是不推荐使用
在字符串匹配的时候会用到字符串哈希
//史前屎山,因为没用到过就没有维护过
//给每一位设置权值的字符串双哈希
ll data1[MAXLEN] = {0},data2[MAXLEN]={0};
ll PowP1[MAXLEN] = {1},PowP2[MAXLEN]={1};
struct Hash {
const ll p1 = 131,p2 = 131;
const ll mod1 = 998244353,mod2 = 1e4 +7;
void init() {
for (int i = 1; i < MAXLEN; i++)
PowP1[i] = (PowP1[i - 1] * p1) % mod1,
PowP2[i] = (PowP2[i - 1] * p2) % mod2;
}
void CreatHash(string s) {
int len = s.size();
data1[0] = data2[0] = s[0];
for (int i = 1; i < len; i++)
data1[i] = (data1[i - 1] * p1 % mod1 + s[i]) % mod1,
data2[i] = (data2[i - 1] * p2 % mod2 + s[i]) % mod2;
}
int substr1(int x,int y) {
if (x == 0)
return data1[y];
return ((data1[y] - (data1[x - 1] * PowP1[y - x + 1]) % mod1) + mod1) % mod1;
}
int substr2(int x,int y) {
if (x == 0)
return data2[y];
return ((data2[y] - (data2[x - 1] * PowP2[y - x + 1]) % mod2) + mod2) % mod2;
}
}Hash;
关于树的一些杂记
树可以看做是一个没有环的联通图,n个节点的树有n-1条边
一般来说数据结构的内容为了方便数据的查询,基本上不会出现无根树
对于一棵有根树,
如果是区间信息维护,往往会采用递归查询的方式;
如果是单点查询,较常建立指针变量,从根采用递推的方式向下查询;
最能体现这点的是线段树,在查询区间和的普通线段树中是采用递归查询的,而在单点计数的权值线段树中,是采用递推查询的
字典树
字典树,一棵多叉树,用来存储一系列字符串,并且可以完成这个字符串集包括且不止以下内容的查询
1.某个字符串是否出现在字符串集中,出现了几次
2.某个字符串是否作为前缀出现在字符串集中,出现了几次
const int CHAR_NUM = 200;
const int MAXN = 50; //字典中字符串个数
const int MAXM = 1010; //字符串最长长度
const int NUM = MAXN * MAXM; //空间=个数*长度
struct Trie{
int data[NUM][CHAR_NUM];
int cnt=0;
bool val[NUM];//对于各种信息的维护和这个变量有关
void add(string s){
int p=0,len=s.size();
for(int i=0;i<len;i++){
if(!data[p][s[i]])
data[p][s[i]]=++cnt;
p=data[p][s[i]];
}
val[p]=true;
}
bool query(string s){
int p,len=s.size();
for(int i=0;i<len;i++){
p=data[p][s[i]];
if(!p)return false;
}
return val[p];
}
}trie;
字典树 -> 01字典树
字典树维护的是一个字符串,01字典树维护的是一些数字。
数字可以通过二进制的方式转换成一个质保函0和1的字符串(注意长度补齐),然后就可以对这些数据进行类似的查询。
因为01字符串的特殊性,01字典树常常用来进行数字集合之间的按位与(或)操作
The XOR Largest Pair.
内容不变,字符串的CHAR_NUM只有2
只要把数字转化成等长字符串即可
并查集
用来进行数据集,支持集合之间的合并。
集合的数据都记录在集合的根上,通过增加变量可以维护包括且不止以下内容的查询:
1.两个数据是否属于同一集合
食物链
2.一个集合的大小
3.一个数据在其集合中的位置
银河英雄传说
等等
并查集的花样很多,但是路径压缩的简单并查集代码短的惊人
int fa[maxn];
void init(int n){
for(int i = 1 ;i <= n; i++)
fa[i] = i;
}
int find(int x){
return fa[x] = (fa[x] == x? x:find(fa[x]));
}
void merge(int x,int y){
fa[find(x)] = find(y);
}
并查集 -> 可撤销并查集
可撤销并查集属于并查集的简单衍生。
可撤销并查集的目标是:
完成并查集的基本功能
能够将并查集的状态退回到第k个版本,k版本之后的合并结果全部删除
可撤销并查集基本思路是:
记录一个回退栈,当要回退到k版本的时候,将栈中k版本之后的修改进行逆操作。
因为路径压缩在压缩之后会丢失父节点的版本信息,所以不进行路径压缩而采用按秩合并
比如 fa[1] = 2,fa[2] = 2,fa[3] = 3;
现在合并2,3集合,fa[2] = 3;然后查询1点的根节点,那么fa[1]在路径压缩后会指向3
然后我要求撤销合并23,在有限的操作中只能将fa[2]还原,但是这时fa[1]仍然指向3
按秩合并的基本思路:将信息少的合并到信息多的,在回退之前版本的时候可以更改尽可能少的信息
注:在一些离线题目中可以先将输入排序,然后用可撤销并查集代替可持久化并查集
int fa[maxn],sz[maxn];//一般将集合大小看做秩
stack<pair<int,int>> st; // 版本号,修改位置
void init(int n){
for(int i = 1 ;i <= n; i++)
fa[i] = i,sz[i] = 1;
}
int find(int x){
return fa[x] == x? x:find(fa[x]);
}
void merge(int x,int y,int id){
x = find(x) ; y = find(y) ;
if(x == y)return ;
if(sz[x]>sz[y])swap(x,y);
fa[x] = y; sz[y] += sz[x];//将x合并到y中
st.push({id,x});
}
void cancel(int id){
while(st.top().first > id){
int p = st.top().second();
st.pop();
sz[fa[p]] -=sz[p];
fa[p] = p;
}
}
并查集 -> 删点并查集
并查集简单拓展,当删除一个点的时候,由于该点而合并的集合断开。
为了保留父节点信息,依然要采用按秩合并的方法。
如果一个点删除,将这个点的父节点连到一个不存在的点(如果下标是1开始的那么0是个好选择)
之后在每个点查询父节点的时候,如果fa[fa[x]] == 0说明这个他的父节点已经删除,这个时候更新fa[x]=[x],就能断开两个合并的集合
树状数组
树状数组是用来维护区间信息,支持单点修改的数据结构,利用了倍增的思想,翻倍的去维护一个区间,比如1-2维护1-4,1-4维护1-8,之后对数据的修改和查询都只需要log(n)的时间复杂的
然而树状数组能做到线段树都能做,但是,但是,但是
它真的短
lowbit这么写是一个神仙想出来的
int d[maxn];
int lowbit(int x) {return x & (-x);}
void edit(int x,int p){
while(p<maxn){
d[p] += x;
p = p + lowbit(p);
}
}
int pre(int p){
int sum = 0;
while(p>=0){
sum+=d[p];
p -= lowbit(p);
}
return sum;
}
线段树
人类对线段树的玩法就像人类玩迪杰斯特拉的玩法一样花里胡哨。
线段树主要维护区间信息的查询。
如果两个区间信息可以合并,那我们在查询大区间的时候就可以通过两个小区间的信息得到大区间信息。于是我们只需要预处理小区间,在后续查询中就可以跳过对小区间的计算,减少时间复杂度
然而事实上绝大多数的区间信息都是可以通过合并得到的,比如
区间和,区间最大值,区间是否覆盖,区间众数,等等等等
延迟标记的作用是修改范围覆盖了整个区间,为了减少时间复杂度而暂且保留再往下的修改。如果说修改的是单点修改,就不需要延迟标记,改到底了还延迟什么。
当小区间合并到大区间的时候,一些信息会丢失,这个时候需要在下放的时候使用pushup来更新上方节点。
最简单的例子是扫描线(下面就有)
//线段树维护区间和
struct SEG{
int l,r,tag;
int data;//
}nod[MAXN << 4];
int val[MAXN<<4];
void build(int l,int r,int p){// 函数入口 build(l,r,1)
nod[p] = {l,r,0,0};
int mid = (r+l) / 2 ,ls = p * 2,rs = p * 2 + 1;
if(l == r)
nod[p].data = val[l];
else {
build(l,mid,ls) ;
build(mid+1,r,rs);
nod[p].data = nod[ls].data + nod[rs].data;
}
}
void pushdown(int p){//懒惰标记表示当前位置已经修改,以下位置没有修改
int ls = p * 2,rs = p * 2 + 1 ;
nod[ls].data += nod[p].tag * (nod[ls].r - nod[ls].l + 1);
nod[rs].data += nod[p].tag * (nod[rs].r - nod[rs].l + 1);
nod[ls].tag += nod[p].tag, nod[rs].tag += nod[p].tag;
nod[p].tag = 0;
}
void edit(int l ,int r ,int num,int p){ //函数入口 edit(l,r,num,1)
int ls = p * 2 ,rs = p * 2 + 1;
if (nod[p].tag)pushdown(p);
//如果该区间信息更新的方式受自区间信息更新影响,在修改的时候必须先下放延迟标记
if (l <= nod[p].l && nod[p].r <= r) {//完全、包含
nod[p].data += num * (nod[p].r - nod[p].l + 1);
nod[p].tag += num;
return ;
}
if (l<= (nod[p].l+nod[p].r) / 2) edit(l,r,num,ls);
if (r > (nod[p].l+nod[p].r) / 2) edit(l,r,num,rs);
nod[p].data = nod[ls].data + nod[rs].data ;
}
int query(int l, int r, int p) {// 函数入口 query_sum(l,r,1)
int ls = p * 2 , rs = p * 2 + 1;
if (nod[p].tag)pushdown(p);
if (l <= nod[p].l && nod[p].r <= r)//完全包含
return nod[p].data;
int ans = 0,mid = (nod[p].l+nod[p].r) / 2;
if(l <= mid) ans += query(l,r,ls);
if(r > mid) ans += query(l,r,rs);
return ans;
}
离散化
离散化是在数据量增加和复杂化之后有效降低空间复杂度的方法。
新数据到原数据可以直接放数组;原数据到新数据只能放map,数组又放不进去
//将读入数据存放在num里,之后将直接把num中的值改变为离散化之后的结果
//lsh :新数据到原数据 mp原数据到新数据
int lsh[MAXN],num[MAXN];
map<int,int> mp;
void LSH(int n){
for(int i=1;i<=n;i++)
lsh[i]=num[i];
sort(lsh+1,lsh+1+n);
int cnt=unique(lsh+1,lsh+n+1)-lsh-1;
for(int i=1;i<=n;i++){
num[i]=lower_bound(lsh+1,lsh+cnt+1,num[i])-lsh;
mp.insert({lsh[i],i});
}
}
离散化 + 线段树 -> 扫描线
扫描线模板
扫描线是由线段树处理,在读入数据排序,离散化和一些乱七八糟的处理之后,扫描线需要处理的最大问题是:
在扫描线移动到某个位置的时候,有n个点,每个点有一个权值,每个点都可能被覆盖,覆盖是不连续的。如何求被覆盖的点的权值和。
于是在线段树的每一个节点上需要记录两个值:tag,sum
tag表示这一区间被完整覆盖的次数,如果当前有值,那么不需要往下维护,直接输出该区间长度
sum表示当前区间被覆盖的部分长度,如果区间没有被完全覆盖,那么sum应当是两个子区间的覆盖长度之和
对于一段区间的覆盖来说,tag只在相应的叶子节点有意义。上传无意义,下放为冗余。
比如2-3的tag,表示2-3被覆盖,但是23覆盖不能说明1-3被覆盖。
23覆盖下放到22,33覆盖,但是实际上查询我们如果在23读到tag就会结束递归,不在向下递归。
一般扫描线是成对出现,不移动tag的位置会大幅度的减少维护的难度。
//上述模板题核心代码
void edit(int l,int r,int num,int p){
if(l > nod[p].r || r < nod[p].l)
return ;
if (l <= nod[p].l && nod[p].r <= r) {//完全、包含
nod[p].tag += num;
if (nod[p].tag)
nod[p].dat = lsh[nod[p].r + 1] - lsh[nod[p].l];
else
nod[p].dat = (l == r?0:nod[p * 2].dat + nod[p * 2 + 1].dat);
return;
}
int mid = (nod[p].l + nod[p].r) / 2;
if( l <= mid )edit(l,r,num,p*2);
if( r > mid )edit(l,r,num,p*2+1);
if(!nod[p].tag)nod[p].dat = nod[p * 2].dat + nod[p * 2 + 1] .dat;
}
离散化 + 线段树 -> 权值线段树
如果有n个数,权值线段树可以用来维护1到m(m<=n)中第k大(小)的数字。
一般会和主席树配合求一个区间第k大的数字,这里先讲权值线段树
首先先把n个数字读入并离散化,得到离散化数组,离散化数组中保存着每个数原来的排名。
接下来按照原数组顺序从左到右插入,插入位置为这个数在原来数组的排名,同时递归使其所有父亲节点的权值+1。
当m个数字插入完成之后,我们就得到有这些特性这么一个线段树:
每个节点表示在这个范围之内有几个数字。
每个节点的左儿子代表的数字都比右儿子代表的数字小。
接下来只需要
1.设置一个指针变量指向根节点
2.查询左儿子大小。
3.如果左儿子的权值大于k,说明第k大在左儿子里,把指针左移,否则右移,并且将k减去左儿子大小
重复2-3,直到k为0
这个时候k指向的节点,表示的就是需要查询的数字在原来区间的排名
int query(int k){
int p = 1;
while(k){
if(nod[p * 2].dat >= k)
p = p*2;
else{
p = p * 2 + 1 ;
k -= nod[p*2].dat;
}
}
return nod[p].r;
}
分块点分支离线分治莫队算法
QAQ
可持久化思想
以上的数据结构维护的都是数据的现状,但是有时候需要查询的可能是在修改操作之前的数据,这个时候怎么办?
很自然地想得到,先开一个根节点以区别不同版本,然后把不变的数据拷贝过来,或者说使指针依然指向之前数据存放的位置,要改的新开空间保存。
因为指针指向会变动,所以一些约定成俗的指向方法就不行了,比如说线段树的ls = 2 * p;
可持久化就是一个附魔道具,让各种好好的数据结构变得阴间
可持久化 + 字典树 = 可持久化字典树
如同上面说所,对于每一个插入的字符串,先开一个root节点,然后拷贝接下来每一个可能出现的字符的信息,然后新插入的信息开新的节点。
没怎么见到过题目
//yy版本,没有经过验证和推敲,慎用
struct Trie{
int data[NUM][CHAR_NUM];
int cnt= 1;
int id = 1;
bool val[NUM];
int root[MAXN];
void add(string s){
int q = root[id] , p = root[++id] = ++cnt ,len=s.size();
for(int i=0;i<len;i++){
for(int j = 0;j<CHAR_NUM;j++)
data[p][j] = data[q][j];
if(!data[p][s[i]])
data[p][s[i]]=++cnt;
p = data[p][s[i]];
q = data[q][s[i]];
}
val[p]=true;
}
bool query(string s,int k){
int p = root[k],len=s.size();
for(int i=0;i<len;i++){
p=data[p][s[i]];
if(!p)return false;
}
return val[p];
}
}trie;
可持久化 + 线段树 -> 可持久化线段树
又叫主席树
基本思路类似,开根节点,拷贝信息,不同的信息重新开节点
因为要储存多个版本所以不能用单纯的乘2来确定下一个节点
至于查询方式,是看这个树的功能是用到区间维护还是单点查询
int root[maxn],val[maxn];
int cnt = 0;
struct SEG{
int l,r;
int dat;
int ls,rs;
}nod[maxn * 40];
void build(int l,int r ,int p){
nod[p] = {
l,r,0,0,0
};
if(l == r){
//nod[p].dat = val[p];看题目需求要不要初值
nod[p].rs = nod[p].ls = -1;
return ;
}
int mid = (l+r) /2;
build(l ,mid ,nod[p].ls = ++cnt);
build(mid + 1,r,nod[p].rs = ++cnt);
nod[p].dat = nod[nod[p].ls].dat + nod[nod[p].rs].dat;
}
void edit(int l ,int r ,int num ,int x ,int p){
nod[p] = nod [x];
if (nod[p].l == nod[p].r) { //区间修改和单点修改有区别
nod[p].dat += num * (nod[p].r - nod[p].l + 1);
return ;
}
if (l<= (nod[p].l+nod[p].r) / 2) edit(l,r,num,nod[x].ls,nod[p].ls = ++ cnt);
if (r > (nod[p].l+nod[p].r) / 2) edit(l,r,num,nod[x].rs,nod[p].rs = ++ cnt);
nod[p].dat = nod[nod[p].ls].dat + nod[nod[p].rs].dat ;
}
可持久化线段树 -> 可持久化数组
虽然叫做数组,但实际上是由主席树实现的,实际上只要主席树的操作都是单点操作,就变成了一个可以记录版本的数组
因为只是单点查询,所以节点可以删掉很多多余的信息
#define maxn 1000050
int val[maxn] ,root[maxn];
struct SEG{
int l,r;
int ls,rs;
int dat;
}nod[maxn * 40];
int id = 0;
void build(int l,int r,int p){//程序入口 build(1,n,root[0] = ++id);
nod[p] = {l,r,-1,-1,0};
if(l == r) {
nod[p].dat = val[r];
return ;
}
int mid = (l + r) / 2 ;
build(l,mid,nod[p].ls = ++id);
build(mid + 1, r,nod[p].rs = ++ id);
}
void edit(int d ,int num,int x,int p){ //程序入口edit(l,r,num,root[版本id],root[当前id])
nod[p] = nod[x];
if(nod[p].l == nod[p].r){
nod[p].dat = num;
return ;
}
if(d <= (nod[p].l + nod[p].r) /2)
edit(l,r,nod[x].ls,num, nod[p].ls = ++ id)
else
edit(l,r,nod[x].rs,num, nod[p].rs = ++ id);
}
int query(int d,int p){
if(nod[p].l == nod[p].r)
return nod[p].dat;
if(d<=(nod[p].l + nod[p].r)/2)
return query(d,nod[p].ls);
else
return query(d,nod[p].rs);
}
可持久化数组->可持久化并查集
说白了并查集搁这有用的就是一个fa数组,数组用可持久数组,并查集就变成了可持久并查集
find和merge全部挂上版本号,没了
主席树 + 树状数组 -> 动态主席树
QAQ
ST表
倍增求最大值或者最小值
//ST表用来求区间极值,以下代码为求区间最大值
//建立ST表的时间为O(nlogn),查询时间为O(1)
//数据无法改动,如果数据改动要重新建表
#define MAXN 1000050
int input[MAXN]={0};
int st[30][MAXN]={0};
//存放2的平方数方便调用
int bin[30]={1};
//st[i][j]指的是data[j]到data[j+2^i-1]的最大值,其长度为2^i
// j->j+2^i-1 == max{j->j+2^(i-1)-1 , j+2^(i-1) + 2^(i-1) -1)
//即 st[i][j]=max{st[i-1][j],st[i-1][j+2^(i-1)]}
void creat_ST(int n){
int N=(int)log2(n);
for(int i=1;i<=N;i++) bin[i]=bin[i-1]*2;
for(int i=1;i<=n;i++) st[0][i]=input[i];
for(int i=1;i<=N;i++){
for(int j=1;j<=n;j++){
st[i][j]=(j+bin[i-1]<=n?max(st[i-1][j],st[i-1][j+bin[i-1]]):st[i-1][j]);
//维护的
}
}
return ;
}
//求下标从start到end的最大值
int using_ST(int start,int end){
int N=(int)log2(end-start+1);
return max(st[N][start],st[N][end-bin[N]+1]);
}
未完成待更新