数组实现的静态数据结构

数组实现的静态数据结构

分类:数据结构

标签:静态链表/队列/栈/堆/并查集/哈希表/字符串哈希

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的正整数集,那么一个字符串的哈希值为:

\[Hash = \sum_{i = 0}^n{P^{i}f(char)} \]

当然,这个字符串表示的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]\) ,它的哈希值为

\[h[l-1]\times P^{l-r+1} - h[r] \]

任意进制数乘以进制等于在串末尾添加值为0的数码,我们假设P进制数表示为\((ABC)_{P}\),那么\((ABC0)_{P} = (ABC)_{P} \times P\)

那么子串 \(B\)的哈希值就是:

\[(B)_{P} = (AB)_{P}-(A0)_{P} \]

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);
}
posted @ 2022-03-04 16:32  Sarfish  阅读(144)  评论(0)    收藏  举报