数组实现的静态数据结构
数组实现的静态数据结构
分类:数据结构
标签:静态链表/队列/栈/堆/并查集/哈希表/字符串哈希
C++STL为我们封装了很多数据结构,但我们依然要学习使用数组来自己实现静态数据结构,没有别的原因,就是运行速度更快而已。
静态链表
在求解题目时,时常会用到链表这种数据结构。如果已知链表插入操作的最大次数,次数很多时,每次使用\(new\)关键字构造节点会很费时间。
静态链表是用数组维护的链表,数组长度是算法发生链表插入操作的最大次数。下面我们来讨论它的实现方式:
静态单链表
首先我们需要开辟两个等长数组 \(e\)、 \(en\),其中 \(e\) 是储存每次插入操作插入的节点值, \(en\) 维护 \(e\)对应位置上的节点的\(next\) 下标。
使用两个变量\(head\)、\(idx\),\(head\)表示头哑节点的\(next\)下标,\(idx\)表示下一次插入操作将插入到的下标。
那么,逻辑链表\(head \to 1 \to 1 \to 4 \to 5 \to end\)将这样储存:
head = 0,idx = 4;
e = {1,1,4,5, 0,0,0,...,0};
en= {1,2,3,-1, 0,0,0,...,0};
我们定义它的头插入操作\(HeadInsert(val)\),它将节点值为\(val\)的节点插入到头哑节点之后。
void head_insert(int val){
e[idx] = val;
en[idx] = head;
head = idx++;
}
插入操作 \(Insert(k,val)\) 将 \(val\) 插入到数组\(e\)的下标 \(k\) 对应的节点之后
void insert(int k, int val){
e[idx] = val;
en[idx] = en[k];
en[k] = idx++;
}
头删除操作 \(HeadDel\) 将头结点删除
void head_del(void){
head = en[head];
}
删除操作 \(Del(k)\) 将数组\(e\)的下标 \(k\) 对应的节点之后的节点删除
void del(int k){
en[k] = en[en[k]];
}
静态双链表
静态双链表比单链表多了 \(pre\) 指针,因此,需要多开一个数组储存。
类似地,我们开辟三个数组 \(e\) 、 \(le\) 、 \(re\) ,分别储存节点值、前指针和后指针。不同的是,双链表的实现需要使用一头一尾两个伞兵节点
逻辑双链表 \(head \iff 1 \iff 1 \iff 4 \iff 5 \iff end\) 将这样储存:
head = 0, idx = 6;
//e[0] 头哨兵 e[1] 尾哨兵
// 0 1 2 3 4 5
e = { 0, 0, 1, 1, 4, 5,..};
le= {-1, 5, 0, 2, 3, 4};
re= { 2,-1, 3, 3, 4, 1};
同样定义它的操作:
插入操作 \(Insert(k,val)\) :
void insert(int k, int val){
e[idx] = val;
le[idx] = k;
re[idx] = re[k];
re[k] = idx;
le[re[idx]] = idx;
idx++;
}
删除操作 \(Del(k)\):
void del(int k, int val){
re[k] = re[re[k]];
le[re[k]] = k;
}
静态栈、队列
静态栈
用数组模拟栈非常简单,只需要一个维护顶元素位置的变量 \(top\) 即可
栈初始化:
const int N = 100010;
int stk[N],top = 0;
入栈 \(Push\):
void push(int val){
stk[top++] = val;
}
出栈 \(Pop\):
void pop(void){
--top;
}
查询栈顶元素 \(Top\):
int& top(void){
return stk[top-1];
}
判断是否为空栈 \(Empty\):
bool empty(void){
return top;
}
查询栈内元素个数 \(Size\):
int size(){
return top;
}
静态队列
静态队列需要两个变量 \(h\) 和 \(t\) 维护队头与队尾下标
const int N = 100010;
int queue[N], h=0, t=-1;
插入队尾 \(Push\)
void push(int val){
queue[++t] = val;
}
弹出队头 \(Pop\)
void pop(int val){
++h;
}
判断是否为空 \(Empty\)
bool empty(void){
return h<=t;
}
查询队头元素 \(Front\)
int& front(void){
return queue[h];
}
查询队尾元素 \(Back\)
int& back(void){
return queue[t];
}
单调栈、单调队列
单调栈、单调队列就是每次执行定义的操作之后,都能保证结构中的元素在原本顺序不破坏的情况下,使结构中的元素对于我们需要的一种偏序关系 \(\leq\) ,如果 \(A\) 比 \(B\) 先 \(Push\) , \(A \le B\),或者,如果 \(A\) 比 \(B\) 先 \(Push\) , \(A \ge B\)
方法都是不断地 \(Pop\) 使得性质满足
单调栈的入栈操作
假定我们需要保证栈内元素的单调递增关系
void push(int val){
while(top && stk[top]>val)--top;
stk[top++] = val;
}
单调队列的入队操作
void push(int val){
while( h<=t && queue[t]>val)++h;
queue[++t] = val;
}
不难发现,两者逻辑完全相同
静态树
静态树的实现很简单,假定节点个数范围已知且每个节点最大子节点数量已知,我们就可以在一个二维数组中构造树。
const int N,M;
int son[N][M],idx=0;
//son[i][j] 代表第i次插入的节点的第j个根节点的下标 son[i][j];
Trie树
Trie树是一种用于储存字符串集合的数据结构,集合中的每一个字符串都是从根节点出发的一条有向路径
例如,一个字符串集合内只包含由小写字母组成的字符串
这里我们将节点值为 \(a\) 的节点指定到 \(a-'a'\) 处。
int son[N][26],idx=0;
int cnt[N];//以下标为i的子节点为终点的字符串的个数
//向Trie树插入字符串
void insert(char str[]){
int p=0;
for(int i = 0; str[i]; i++){
int u = str[i] - 'a';
if(!son[p][u])son[p][u] = ++idx;
p = son[p][u];
}
cnt[p]++;
}
//查询Trie树中字符串A的个数
int query(char str[]){
int p=0;
for(int i=0; str[i]; i++){
int u = str[i]-'a';
if(!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
并查集
并查集是用树实现的数据结构,能够以几乎 \(O(1)\) 的时间执行以下操作:
- 查询两个元素是否属于同一个集合
- 合并两个集合
反应在树上是这样的:
- 如果两个元素属于同一个集合,那么他们的根节点相同
- 合并两个集合,就是把其中一个集合的根节点的父节点指定为另一个集合的根节点
在查询两个元素是否属于同一个集合时,我们从他出发,向上寻找根节点,其中,如果找到了,就把遍历到的所有节点的父节点指定为根节点。这样,就降低了树的高度。这叫做路径压缩。
我们用数组实现:
int s[N];
//元素a的父节点是元素x
s[a] = x;
//如果b是根节点,那么s[b]=b;
s[b]=b;
\(Find(x,y)\) 查询两个元素是否属于同一个集合(路径优化):
//返回节点x的根节点,顺便进行路径优化
int find(int x){
if(x!=s[x])s[x] = find(s[x]);
return s[x];
}
bool find(int x, int y){
return find(x)==find(y);
}
\(Comb(x,y)\) 将 \(x\), \(y\) 所在的集合合并
void comb(int x,int y){
s[find(y)] = find(x);
}
带边权的并查集
如果我们不仅需要维护集合信息,还要维护元素到根节点的权值和,需要使用到带边权的并查集
int s[N],d[N]; //d[i]是编号为i的节点到其父节点的权值
int root(int x){
int u = s[x];
if(x!=s[x])s[x] = root(s[x]);
d[x] += d[u];
return s[x];
}
//合并集合并指派y的根到x的根的边权
void comb(int x, int y, int val){
d[root(y)] = val;
s[root(x)] = root(y);
}
堆
堆是具有这样性质的二叉树:
- \(l\) 、\(r\) 是 \(m\) 的左、右子节点,那么 \(l \le m 且 r \le m\)
即根节点是所有节点中的最小值。
这样的堆叫小根堆,大根堆定义类似。下面只讲小根堆。
堆树的静态储存方式与并查集不同,它使用一维数组,储存方式如下:
- \(A[0]\)不存放数据
- \(A[1]\) 存放根节点
- 下标为 \(x\) 的节点的左子节点存储在 \(A[2x]\) ,右子节点存储在 \(A[2x+1]\)
int p[N];
int size=0;
堆有两个基本操作 \(Down\) 和 \(Up\)
\(Down\)操作是这样的:
- 如果堆中的某个元素大于左子元素或大于右子元素,那么,将该元素与其左右元素中的最小值交换。
同理,\(Up\) 操作是:
- 如果堆中的某个元素小于父节点元素,那么将其和父节点元素交换
\(Up\) 和 \(Down\) 可以实现堆的操作:
- \(Insert\) 向堆插入一个数
- \(Min\) 求堆中元素的最小值
- \(Pop\) 删除堆中的最小值
- \(Del\) 删除堆中的元素
- \(Change\) 修改堆中的某个元素
\(Down\) 时间复杂度\(O(n)\):
void down(int u){
int t = u;
//判断子节点是否存在
if(2*u <= size && p[2*u]< p[t])t = 2*u;
if(2*u+1 <= size && p[2*u+1]< p[t])t = 2*u+1;
if(u!=t){
swap(p[u],p[t]);
down(t);
}
}
\(Up\) 时间复杂度\(O(n)\)
void up(int u){
while(u/2 && p[u/2] > p[u]){
swap(h[u/2],h[u]);
u/=2;
}
}
有了这两个基本操作,我们就可以实现堆操作了:
堆初始化
int p[N],size = n;
//向p输入n个数
//从最后一个非叶子节点开始执行down操作
//时间复杂度可以证明为 O(n)
for(int i = n/2; i;i--)down(i);
\(Insert(x)\) \(O(\log{n})\)
void insert(int x){
p[++size] = x;
up(size);
}
\(Min\)
int min(void){
return p[1];
}
\(Pop\) \(\log{n}\)
void pop(void){
p[1]=p[size--];
down(1);
}
\(Del(t)\)
void del(int t){
p[t]=p[size--];
down(t);up(k);
}
\(Change(t,k)\)
void change(int t, int k){
p[t]=k;
down(k);up(k);
}
带次序标记的静态堆实现
如果我们要实现对第 \(i\) 次插入堆的元素的操作,我们需要将堆中第
\(k\) 个数的插入次序记录下来。
- 使用数组 \(ht\) 记录堆中下标为 \(i\) 的元素对应的插入次序
- 使用数组 \(th\) 记录插入次序为 \(k\) 的元素在堆中的下标
主要是使用特别的 \(Swap\) 操作在交换中维护这两个数组
// 将堆中第i个元素和第j个元素交换,同时维护ht和th
void heap_swap(int i,int j){
swap(th[ht[i]], th[ht[j]]);
swap(ht[i],ht[j]);
swap(h[i],h[j]);
}
哈希表
哈希表取模一般取离\(2\)的次方尽可能远的质数
拉链法实现哈希表
const int N = 100007;
int h[N],e[N],en[N],idx=0;
void init(){
memset(h,-1,sizeof h);
}
void insert(int x){
//加N模N将余数转换为非负数
int k = (x%N+N)%N;
e[idx] = x;
en[idx] = h[k];
h[k] = idx++;
}
bool find(int x){
int k = (x%N+N)%N;
for(int i=k;i!=-1;i = en[i]){
if(e[i] == x)return true;
}
return false;
}
开放寻址法(线性探测法)实现哈希表
开放寻址法关键在于\(find\) ,如果元素不在表中,那么返回元素应该插入的位置,如果找到了就返回下标。
数组长度和模应该取到数据量的2到3倍以上。
const int N = 200003;
int h[N];
const int null = 0x3f3f3f3f;
void init(){
//用无穷大(当数据范围小于1e10时)来表示空位
memset(h,0x3f,sizeof h);
}
int find(int x){
int k = (x%N+N)%N;
while(h[k]!=null&&h[k]!=x){
++k;
//循环探测
if(k == N)k=0;
}
return k;
}
字符串哈希
一个字符串映射为整数,只要将字符串视为一个P进制数即可,我们将字符集一一映射到不超过P的正整数集,那么一个字符串的哈希值为:
当然,这个字符串表示的P进制数的值会很大,我们需要对它取模\(M\)
数学表明:当\(P=131\)或\(P=13331\)、\(M = 2^{64}\)时,键冲突的概率为\(0.0001\%\)
字符串哈希算法的具体实现有个非常巧妙的做法:
我们将哈希值、P进制设置为无符号长整形(unsigned long long,简记为ULL),它能表示\(0 - 2^{64}-1\)之间的数。对于ULL之间的加和乘,都是模\(2^{64}\)加法和乘法,因此当\(M = 2^{64}\)时我们就不必取模了。
typedef unsigned long long int ULL;
const ULL P = 13331;
ULL p[LEN]; //p[i]代表P的i次方
ULL hashStr(char str[], int len){
ULL ans = 0;
for(int i = 0;i < len;i++){
ans += str[i]*p[i]; //这里我们选择将字符映射为ASCII码
}
return ans;
}
将字符串哈希值进行二次哈希就实现了字符串哈希。
前缀哈希法
由字符串哈希法可以得到一个近乎\(O(1)\)的算法判断某个字符串的两个子串是否相等,这个算法的正确性取决于你的运气。
对于串 \(Str\),我们计算它的所有前缀的哈希值。\(h[i]\) 表示长度为 \(i\) 的前缀的哈希值,一般地 \(h[0] = 0\)
那么对于任意一个子串 \(Str[l,r]\) ,它的哈希值为
任意进制数乘以进制等于在串末尾添加值为0的数码,我们假设P进制数表示为\((ABC)_{P}\),那么\((ABC0)_{P} = (ABC)_{P} \times P\)
那么子串 \(B\)的哈希值就是:
ULL p[N], h[N];
ULL get(int l, int r){
return h[r] - h[l-1]*p[r-l+1];
}
void init(char str[], int len){
p[0] = 1;
for(int i = 1;i<len;i++){
h[i] = h[i-1]*p[i]+str[i];
p[i] = p[i-1]*P;
}
}
bool same(int l1, int r1, int l2, int r2){
return get(l1,r1) == get(l2,r2);
}
本文来自博客园,作者:Sarfish,转载请注明原文链接:https://www.cnblogs.com/sarfish/articles/15964972.html

浙公网安备 33010602011771号