常用数据结构及其实现(链表、栈、队列、Trie树、哈希表)

数据结构

单链表

通常链表的实现方式

struct Node {
	int val;
	Node *next;
};

用结构体加指针实现单链表效率不高,因为每次添加一个节点,需要new一个 Node(),这个操作非常慢。

数组模拟单链表

用数组实现单链表,数组e[N]用来存储值val,数组 ne[N] 来表示next指针,这两个数组用下标关联,空节点用-1表示。

链表:
head -> Node(3) -> Node(5) -> Node(7) -> Node(9) -> Nullptr

e[N]:  e[0] = 3,  e[1] = 5,  e[2] = 7,  e[3] = 9;
ne[N]: ne[0] = 1, ne[1] = 2, ne[2] = 3, ne[3] = -1;

解释: 第n个节点的值为 e[n], 下一个节点的值为 e[ne[n]];  

image-20211113110943002

代码实现

const int N = 100010;
int e[N], ne[N], head, idx;
// head 表示头结点的下标
// e[i] 表示节点i的值
// ne[i] 表示节点i的next指针是多少
// idx 存储当前已经用到了哪个点,相当于新空间的地址

void init() // 链表的初始化
{
    idx = 0, head = -1;
}

void add_to_head(int x) // 将x插入到头节点
{  // 将x存入idx指向的空位,之前头节点的下标存入ne[idx],头节点下标等于idx,idx指向下一个空位
    e[idx] = x, ne[idx] = head, head = idx ++;
}

void add(int k, int x) // 将x插入到第k个节点之后
{
    e[idx] = x, ne[idx] = ne[k], ne[k] = idx ++; 
}

void remove(int k) // 删除k点后面的点
{
    ne[k] = ne[ne[k]];
}

双链表

双链表相比于单链表存储两个指针,一个指向前一个节点,一个指向后一个节点。用处:优化某些问题

数组模拟双链表

代码实现

const int N = 100010;
int e[N], l[N], r[N], idx;
// e存储节点值,l[N]用来存储左节点的下标,r[N]用来存储右节点的下标,idx上同
// 避免额外空间记录头节点和尾节点,用idx0代表左端点,idx1代表右端点

void init() // 链表的初始化
{
    l[1] = 0, r[0] = 1;
    idx = 2; // 新空间从2开始
}

void add_right(int k, int x) // 在第k个点右边插入x
{
    e[idx] = x; // 先把值存下来
    l[idx] = k, r[idx] = r[k]; // 将x连接左指针连接k,右指针连接k的下一个点
    l[r[k]] = idx; // 先将x的下一个节点左指针指向x
    r[k] = idx ++; // 再将k的右指针指向idx
    // 不能写反,因为要通过k的右指针找到插入前k的右节点
}

void add_left(int k, int x) // 在第k个点的左边插入x
{
    add_right(l[k], x); // 相当于在k - 1个点的右边插入一个点
}

void remove(int k) // 删除第k个节点
{
    r[l[k]] = r[k];
    l[r[k]] = l[k];
}

先进后出

数组模拟栈

|   |
| 3 |  stk[N]: stk[0] = 7, stk[1] = 5, stk[2] = 3;  
| 5 |  								  tt此时指向2
| 7 |   插入时 stk[++ tt] = x, 弹出时直接 tt --
—————

代码实现

const int N = 100010;
int stk[N], tt = 0;
// tt是栈顶的指针,后续插入直接覆盖原数组即可

void push(int x)
{
    stk[++ tt] = x;
}

void pop()
{
    tt --;
}

int top()
{
    return stk[tt];
}

bool empty()
{
    if (tt) return false;
    else return true;
}

队列

先进先出

数组模拟队列

   4	2	6	7     q[N]: q[0] = 4, q[1] = 2, q[2] = 6, q[3] = 7;
    hh		     tt	       hh = 0, tt = 3
  			      q[hh]是头节点, q[tt]是尾节点

代码实现

const int N = 100010;
int q[N], hh, tt = -1;

void push(int x)
{
    q[++ tt] = x;
}

void pop()
{
    hh ++;
}

bool empty()
{
    if (hh <= tt) return false;
    else return true;
}

单调栈

单调栈顾名思义就是在入栈时遵循单调原则,可以求出一个元素向左(或向右)所能扩展到的最大长度,并不是说在这一段区间内是单调的,而是保证在该区间内该元素一定是最大或最小。

场景:输出数列中第i个数左边最小或者最大的数

const int N = 100010;
vector<int> minStack(vector<int>& a)  // 求a数组中每位左边最小的数
{
	int stk[N], tt = 0;
	vector<int> ans;
	for (int i = 0; i < a.size(); i ++)
	{
		while (tt && stk[tt] >= a[i]) tt --;
		if (!tt) ans[i] = -1; // 如果栈空,则输出-1
		else ans[i] = stk[tt];// 否则,栈顶就是最小值
		stk[ ++ tt] = a[i]; 
	}
	return ans;
}

单调队列

滑动窗口求最大值或者最小值

const int N = 100010;
vector<int> minQueue(vector<int>& a, int window)
{
    int hh = 0, tt = -1; // 数组模拟队列
    int q[N];   // 存的是在原数组中的下标
    vector<int> ans;
    for (int i = 0; i < a.size(); i ++)
    {   // 队列不为空并且队头已经划出窗口
        if (hh <= tt && q[hh] < i - window + 1) hh ++;
        while (hh <= tt && a[q[tt]] >= a[i]) tt --;
        q[++ tt] = i;
        ans[i] = a[q[hh]];
    }
    return ans;
}

KMP

KMP算法应用于字符串匹配,并且可以快速在源字符串中找到所有与之匹配的模式串。

// 求ne数组
char p[N];
int ne[N];
for (int i = 2, j = 0; i <= n; i ++)
{
	while (j && p[i] != p[j + 1]) j = ne[j];
	if (p[i] == p[j + 1]) j ++;
	ne[i] = j;
}

Trie树

是用来快速存储和查找字符串集合的一种数据结构。

数组模拟Trie树

const int N = 100010;
// son数组用来存储字符串集合。cnt表示当前节点是否存在字符串
int son[N][26], cnt[N], idx;
char str[N];

void insert(char *str)
{
    int p = 0;   // 从头节点开始遍历
    for (int i = 0; str[i]; i ++)
    {
        int u = str[i] - 'a';  // 取出每个字符对应的数值。 0~25
        if (!son[p][u]) son[p][u] = ++ idx; // 如果当前没有节点,则创建一个节点
        p = son[p][u];  // 走到下一层
    }
    cnt[p] ++; // 结束时的标记,也是记录以此节点结束的字符串个数
}

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]; // 返回字符串出现的次数
}

Trie树是从0层开始,但是下一层不一定是1层,这个是由son[p][u]里的编号idx的值决定

Trie2.PNG

并查集

支持两个重要操作:

  1. 将两个集合合并
  2. 询问两个元素是否在一个集合当中

基本原理:每个集合用一颗树来表示。树根的编号就是整个集合的编号。每个节点存储它的父节点。p[x]表示x的父节点

const int N=100010;
int p[N];  // 定义多个集合

int find(int x)
{
    if(p[x] != x) p[x] = find(p[x]);
    /*
    经上述可以发现,每个集合中只有祖宗节点的p[x]值等于他自己,即:
    p[x]=x;
    */
    return p[x];
    // 找到了便返回祖宗节点的值
}

本质维护一个数据集合,最大或最小的数据$O(1)$获得

可以实现如下操作:

  1. 插入一个数值 heap[size ++] = x;
  2. 求集合当中的最小值 heap[1];
  3. 删除最小值 heap[1] = heap[size --]; down(1);
  4. 删除任意一个元素 heap[k] = heap[size --]; down(k); up(k);
  5. 修改任意一个元素 heap[k] = x; down(k); up(k);

后两个操作STL无法直接实现。

两种建堆的方法:

  1. 一种是从一个空树开始,每次输入一个数,就按照插入操作插入这个树,时间复杂度nlogn(一共n个元素,每个元素logn)。
  2. 还有一种建堆方法是已经把数据都输入到了数组a[N],怎么根据这个数组直接建堆。就是从n/2开始down(),因为n/2是倒数第二层,如果是倒数第一层其实每个节点自然成堆了,所以从倒数第二层开始,是最简单的一个二层树结构,down完形成一个堆。从右下角往左边开始建堆,这样从下往上之后,轮到每个节点他下面的子树肯定已经成堆了,满足down的条件。
const int N = 100010;

int h[N], ph[N], hp[N], cnt;
// h[N]是存储用数组模拟的堆:下标是大小顺序,值是实际的数;
// ph[N]是存储第K个插入元素存在的下标:下标是插入顺序,值是当前存在堆的下标
// hp[N]是存储当前堆的数是第几个插入的下标:下标是当前的堆大小顺序,值是插入的顺序

void heap_swap(int a, int b)
{
    swap(ph[hp[a]], ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

哈希表

哈希函数可以把 -109 ~ 109的数映射到 0 ~ 105 之间,解决哈希冲突的两种方法开放寻址法和拉链法。

拉链法就是将哈希冲突的数依然放在对应的槽上,可以放置在已有的数的前面或后边。一般只用支持插入和查找操作,如果要实现删除操作可用额外的布尔变量标记该数是否删除。

拉链法模拟散列表

const int N = 100003;

int h[N], e[N], ne[N], idx; // 用数组模拟单链表拉链
memset(h, -1, sizeof h);  // 单链表初始化为-1
void insert(int x)
{
    int k = (x % N + N) % N; // 为什么要 + N 再 % N,为的是让它的余数变成正数,因为负数 % N的余数仍为负数
    e[idx] = x;           
    ne[idx] = h[k];		
    h[k] = idx ++;  // h[k]是散列后对应的槽,存的是该槽上链表第一个点的下标
}

bool find(int x)
{
    int k = (x % N + N) % N;
    for (int i = h[k]; i != -1; i = ne[i])
        if (e[i] == x)
            return true;
    
    return false;
}

开放寻址法模拟散列表

只用开一个数组,不用模拟单链表。但是数组的范围一般开到题目的2 ~ 3倍。

const int N = 200003, null = 0x3f3f3f3f;

int h[N];
memset(h, 0x3f, sizeof h);

int find(int x)  // find函数找到x所在的位置
{
    int k = (x % N + N) % N;
    
    while (h[k] != null && h[k] != x)
    {
        k ++;
        if (k == N) k = 0;
    }
    
    return k;
}
// 插入的时候
int k = find(x);  // 先找到对应的槽
if (insert) h[k] = x;

字符串哈希

把字符串变成一个p进制数字(哈希值),实现不同的字符串映射到不同的数字。

typedef unsigned long long ULL;
const int N = 100010, P = 131;

int n; // 字符数组的长度
char str[N];
ULL h[N], p[N]; // p数组用来存储P的多少次方

p[0] = 1;
for (int i = 1; i <= n; i ++)
{
    p[i] = p[i - 1] * P;  // 预处理出来P的多少次方
    h[i] = h[i - 1] * P + str[i];
}

ULL get(int l, int r)  // 获取区间的字符串哈希值
{
    return h[r] - h[l - 1] * p[r - l + 1]; 
}
posted @ 2022-05-09 14:02  FailBetter  阅读(63)  评论(0编辑  收藏  举报