算法笔记之数据结构

数据结构

1.链表

  • 这里主要讲用数组来模拟链表
  • 用数组模拟,速度更快,效率高

1.1单链表

存储
  • 习惯用 e[N] 表示链表结点的值(value) ,用 ne[N] 表示该节点的next(即右边的结点的下标)
  • idx 表示目前已经用了第几个结点,(类似于计数器)
  • 用数组的下标对上面两个数组进行关联
代码表示
初始化
int head,idx, e[N], ne[N];
//初始化
//下标从1开始
void init()
{
	head = 0;
	idx = 1;
}
插入到头结点
  • 注意对插入的结点,要先连接后面的 即 ne[idx] = head
  • 再连接前面的,即 head = idx
  • 不能颠倒,否则原来头结点所指向的后面的链就会没有指针指向,也就是找不到了
//将x插入到头结点
void add_to_head(int x)
{
	e[idx] = x;
    //下面两步不能颠倒
	ne[idx] = head;	//第idx个结点指向head所指的值
	head = idx;		//head指向第idx个结点
	idx++;
}
插入到第k个位置后面
  • 插入时需要注意的地方同上
//把x插入到下标为k的结点的后面
void add_to_k(int k,int x)
{
	e[idx] = x;
    //下面两步不能颠倒
    //否则,ne[k]就会改变,将无法完成待插入结点与右边结点的连接
	ne[idx] = ne[k];
	ne[k] = idx;
	idx++;
}
删除第k个后面一个结点
//把下标为k的后面的结点删掉
void remove(int k)
{
    //注意删除头结点要特判,因为head并没有存在ne[]数组里面
	ne[k] = ne[ne[k]];
}

1.2 双链表

  • 同样也是用数组模拟
存储
  • l[N] 表示每个结点左边指向的结点下标r[N] 表示每个结点右边指向的结点下标
  • e[N] 记录结点的值
  • idx 表示此时用到了第几个结点(类似于计数器)
代码
初始化(注意看此时的 idx
 int l[N], r[N], e[N],idx;
//初始化
//下标从0开始
void init()
{
	r[0] = 1;//第一个结点的右端为第二个结点
	l[1] = 0;//第二个结点的左端为第一个结点
	idx = 2;//此时已经有了两个结点(idx=0,idx=1),相当于头指针尾指针,
    //虽然idx为1和0时e[i]并没有存值,但是会影响下标(也就是,开始插入的idx要初始化为2,不能和r[0]或者l[1]相等)
}
插入第k个结点的右边
//在下标是k的点的右边插入
void add_to_k(int k,int x)
{
	e[idx] = x;
	l[idx] = k;
	r[idx] = r[k];
	//注意下面两步不能颠倒,原因同上面单链表的插入
	l[r[k]] = idx;
	r[k] = idx;
    idx++;
}
删除第k个结点
//删除第k个点
void remove(int k)
{
	r[l[k]] = r[k];//即第k个点左边的结点所指向的右边,直接跳过k指向r[k];
	l[r[k]] = l[k];//同理;
}

2. 栈

  • 一种先进先出的数据结构

2.1数组模拟栈

  • 效率更高
2.1.1 存储
  • stk[N] 作为栈来存储
  • tt 作为栈顶,初始化为0
2.1.2 代码
int stk[N], tt;
//插入
void insert(int x)
{
	stk[++tt] = x;
}
//弹出
void remove()
{
	tt--;
}
//返回栈顶
int top()
{
	return stk[tt];
}
//判断是否为空
bool isempty()
{
	if (tt > 0)
		return 0;
	else
		return 1;
}

2.2单调栈

  • 即单调递增 或 递减的栈

例题:找到每个数的左边第一个比它小的数

思路:

我们很快的能够想到用暴力的方法来解题,但是不难发现虽然实现简单,但是暴力方法复杂度太高,不是最优解;

可是我们没有一个很好的算法适合此题,所以我们决定对暴力进行优化

优化如下:
  • 观察可知,当从左到右遍历数组时,如果出现左边的数大于等于右边的数(即逆序对),则左边较大的数一定不是最优解(因为右边一定有数比它小,更可能是要求的解)

  • 所以,我们不妨用栈这个数据结构,首先一个个压栈,在压栈过程中,如果满足左边的数大于等于右边的数,就把左边的数从栈中弹出(即 如果栈中出现不是最优解,那么我们就索性不把它放在栈中,这样就能保证栈中的元素一定会是最优解),直到出现不满足这种情况为止

  • 经过上述处理,栈顶元素就是每个数左边第一个最小的数

栈里面不能有逆序对,(不是最优情况)

int n;
int stk[N], tt;

int main()
{
	cin >> n;
	for (int i = 0; i < n; i++)
	{
		int x;
		cin >> x;
		while (tt && stk[tt] >= x)//如果栈不空并且栈顶元素大于等于x
			tt--;//就把栈顶的元素弹出,直到找到栈顶元素小于x的
		if (tt)
			cout << stk[tt] << " ";
		else
			cout << "-1" << " ";
		stk[++tt] = x;
	}
	return 0;
}

3.队列

3.1数组模拟队列

存储:
  • q[N] 存储队列
  • hh 表示队头(初始化为0)
  • tt 表示队尾(初始化为 -1)
代码实现:
//队列
int q[N], hh, tt = -1;//hh:队头,tt:队尾

//插入
void insert(int x)
{
	q[++tt] = x;
}
//弹出
void remove()
{
	hh++;
}
//判断队列是否为空
bool isempty()
{
	if (hh <= tt)
		return 0; //不空
	else
		return 1;
}
//取出队头
int top()
{
	return q[hh];
}

//取出队尾
int last()
{
	return q[tt];
}

3.2 单调队列

即单调递增 或 递减的队列

例如:给定一个长度为 n 的字符串,从左到右,判断每 k 个数中的最小值是多少 (k<=n)

思路跟单调栈类似

注意:

  • 思路跟上面的单调栈例题类似
  • 需要注意,要判断现存队列的长度,如果发现比 k 要大,就要从队头出队
int n, k;
int a[N], q[N];	//a[N]是原数组; q[N]代表队列,存的是数组的下标

int main()
{
	cin >> n >> k;
	for (int i = 1; i <= n; i++)
	{
		cin >> a[i];
	}
	int hh = 0, tt = -1;
    //
	for (int i = 1; i <= n; i++)
	{
        //如果长度大于等于k,就要从队头出队
		if (hh <= tt && i - k + 1 > q[hh])
			hh++;
        //如果队列前面有比后面大的元素,就把它弹出(注意用的是tt--),保证存入队列的数一定是单调的!
		while (hh <= tt && a[q[tt]] >= a[i])
			tt--;
		q[++tt] = i;//把满足条件的元素下标存进去
		
		if (i >= k)
			cout << a[q[hh]] << " ";
	}
    return 0;
}

4. KMP算法

  • KMP 算法是一种快速在原字符串中匹配子串的算法,即查找原字符串是否存在某子串

思路:

要查找子串,易知可以用暴力解法(从左往右依次匹配)

但是无疑,这种算法效率太低了。

所以就有了kmp算法:

  • 设置 ne[] 数组,记录以第 i 个字符为结尾的子串以从头开始的子串 相等最大子串的长度
  • 通过 ne[] 数组,就不用从左往右一个个遍历
  • 即如果原字符串从第 p 个元素开始遍历,遍历到的第 p+ k 个数,发现第 p+k+1 个不符合,则不需要再从子串的第一个元素、原字符串的 p+1 个元素开始遍历,直接利用 ne[] 数组,从 ne[k] 开始遍历
  • 若还没有理解,可以手动模拟一遍

代码实现:

int n, m;
char p[N], s[M]; //p[]表示子串,s[]表示原字符串
int ne[N];		

int main()
{
	cin >> n >> p + 1 >> m >> s + 1;	//从下标为1开始输入
	
	//	求next数组,令ne[1]=0,即第一个元素没有符合条件的next
	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;
	}
    
	//kmp匹配过程
	for (int i = 1, j = 0; i <= m; i++)
	{
		while (j && s[i] != p[j + 1])//即如果出现不匹配,就从ne[j]开始再次匹配
			j = ne[j];
		if (s[i] == p[j + 1])		//如果符合条件,就比较下一个
			j++;
		if (j == n)
		{
			cout << i - n << " ";
			j = ne[j]; 				//因为要输出所有满足条件的,所以当找到匹配的之后,再 j=ne[j],即从子串开头重新找
		}
	}
	return 0;
}

5. Trie树

  • 用来高效地存储、查找 字符串集合的数据结构

存储:

  • 首先从第一个字符串开始存入树
  • 然后在第一串存入的基础上,继续存入第二串(也就是图中的在b处出现了c,d两个分支)
  • 以此类推
  • 注意把每一串的结尾的字母做上标记(方便查找)

查找:

  • 首先,如果发现要查找的字符串没有完全对应Trie树,肯定是不存在该串
  • 其次,如果完全匹配,但是结尾字符并没有标记,也是不存在该串的
    在这里插入图片描述

代码表示

int son[N][26];//用来表示每个结点的所有子节点
int cnt[N];		//用来表示以当前结点结尾的串的个数
int idx;		//表示用到了第几个结点
插入操作
void insert(string s)
{
    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]++;			//记录以该节点结尾的个数
}
查询操作
int query(string s)
{
	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];		//如果全部结点都能找到,也不一定存在,需要看是否有以该节点结尾的字符串
}

6. 并查集

在O(1)的时间内维护一下两个操作

  • 将两个集合合并
  • 询问两个元素是否在一个集合中

基本原理:

  • 每个集合用一棵树来表示,树根的编号就代表该集合的编号
  • 每个结点存储它的父节点,即fa[x] 表示 x 的父节点
  • 注意对每个结点的父节点初始化!!!,即 fa[i] = i

实现前提:

如何判断树根?

if(fa[x]==x)

如何判断集合编号?

whlie(fa[x] != x) x = fa[x];

  • 也可以用递归

如何合并两个集合?

fa[x] 表示 x 的编号,fa[x] = y

  • 即 x 的祖宗的父节点指向 y 的祖宗

优化:路径压缩

代码实现

//找到该节点的祖宗节点 + 路径压缩
int find(int x)
{
	if (fa[x] != x)
		fa[x] = find(fa[x]);	//递归的过程就实现了路径压缩
	return fa[x];
}

//合并操作,即把a的祖宗的父节点指向b的祖宗
fa[find(a)] = find(b);

7. 堆排序

  • 堆是一个完全二叉树

  • 根节点小于等于两个子节点(小根堆);

  • 根节点大于等于两个子节点(大根堆);

存储:

  • 用一个一维数组来存 h[N] , 用 siz 表示数组实时大小

  • 下标为 x左儿子就是 下标 2 * x右儿子2 * x + 1

  • 下标要从 1 开始

基本操作

up() 即上调
  • 直接和父节点交换就行
void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        swap(u, u / 2);
        u >>= 1;
    }
}
down() 即下调
  • 要把左右儿子中最小的那个和父节点交换
void down(int u)
{
	int t = u;
    //如果左儿子存在,并且比父节点小
	if (u * 2 <= siz && h[u * 2] < h[t])	//注意这里是和 h[t] 比较
		t = u * 2;
    //如果右儿子存在,并且比此时的父节点小
	if (u * 2 + 1 <= siz && h[u * 2 + 1] < h[t])
		t = u * 2 + 1;
	if (u != t)
	{
		swap(h[u], h[t]);
		down(t);//递归操作
	}
}
建堆
// O(n)建堆
for (int i = n / 2; i; i -- )
    down(i);
插入一个数
heep[++size] = x;
up(size);
求集合当中的最小值
heep[1];
删除最小值
heep[1]=heep[size];
size--;
down(1);
删除任意一个元素
heep[k] = heep[size];
size--;
down(k);
up(k);
修改任意一个元素
heep[k] = x;
down(k);
up(k);

8. 哈希表(散列表)

  • 哈希表就是集查找、插入和删除于一身的一种数据结构(算法题里一般只有插入和查找操作),非常适用于字符串的匹配查找问题(比kmp效率更高)

  • 哈希的过程就是把一个大的数据范围映射到一个较小的数据范围内的过程。

  • 与哈希不同,离散化需要保序的(单调),是一种特殊的哈希方式

哈希过程:

  • 首先需要找一个哈希函数,即将数据进行映射的函数
  • 但是会产生冲突:将两个不同的数映射成相同的数(哈希冲突)
  • 接着利用特殊的存储结构来处理哈希冲突
哈希函数

$k = ( x \ \ % \ \ N + N )\ % \ N $

  • k k k 表示数 x x x 经过哈希之后,存放在数组的位置(下标)
  • N N N 表示哈希数组的范围(一般为满足条件的最小质数

存储结构

拉链法:
思路
  • 首先开一个一维数组(注意数组长度一般取质数,而且该质数应该距离2的整次幂尽可能远),

用来存储映射后的数据,其长度代表映射后的数据范围,注意对数组赋初值,即代表该链上的头结点

  • 每一个下标视作一个堆,也就是说每个下标可以存储好多个符合条件的数(即映射到该下标位置的数),相当于拉个链串起来(用单链表实现),也就是拉链法

  • 删除操作一般不是真的删除,而是在需要删除的下标做个标记

代码表示
#include<bits/stdc++.h>
#define N 100003	//N是符合条件的第一个质数

using namespace std;

int h[N], e[N], ne[N], idx;

//插入操作
void insert(int x)
{
    //哈希函数
	int k = (x % N + N) % N;	//x%N 可能是负数,所以需要(x % N + N )%N,从而保证k是正数
    //下面是单链表的操作,即拉链法
	e[idx] = x;
	ne[idx] = h[k];
	h[k] = idx++;
}
//寻找是否存在 x
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 1;
	}
	return 0;
}
//主函数
int main()
{
	int n;
	cin >> n;
	memset(h,-1,sizeof h);//注意对哈希数组赋初值为-1,即每个数组元素都代表该链上的头结点
	while (n--)
	{
		char c; int x;
		cin >> c;cin >> x;
		if (c == 'I')
		{
			insert(x);
		}
		else
		{
			if (find(x))
			{
				cout << "Yes\n";
			}
			else
			{
				cout << "No\n";
			}
		}
	}
	return 0;
}
开放寻址法:
  • 只开一个一维数组即可,但是数组范围 需要是题目所给范围的2–3倍的最小质数(为了减少哈希冲突)

注意:

  • memset()函数是按字节来 初始化的,一个int有四个字节,即 memset(a,0x3f,sizeof a) 等价于把数组中每个元素初始化为 0x3f3f3f3f

  • 0x3f3f3f3f > 1e9;

代码表示:
#include<bits/stdc++.h>
#define N 200003
#define null 0x3f3f3f3f
using namespace std;

int h[N], e[N], ne[N], idx;
//开放寻址法的关键就是find函数
int find(int x)
{
	int k = (x % N + N) % N;//哈希函数
	//如果找到一个已经存过数的位置,但是存的数不是x,就继续往后挪
	while (h[k] != null && h[k] != x)
	{
		k++;
		if (k == N)//如果到结尾了就再从头开始
			k = 0;
	}
	return k;//返回下标
}
int main()
{
	int n;
	cin >> n;
    memset(h,0x3f,sizeof h);
	while (n--)
	{
		char c; int x;
		cin >> c;cin >> x;
		int k = find(x);//找到适合存放x的位置
		if (c == 'I')
		{
			h[k] = x;//存入x
		}
		else
		{
			if (h[k]!=null)
			{
				cout << "Yes\n";
			}
			else
			{
				cout << "No\n";
			}
		}
	}
	return 0;
}

字符串哈希方式

  • 这里讲的是字符串前缀哈希法
操作方式
  • 把每个字母分别映射为 p p p 进制中的不同的数字
  • 然后转换为 10 进制,并取模于 Q Q Q ,得到字符串的前缀哈希值
  • h[i] 数组表示字符串前 i 位字符串的哈希值
  • P[i] 代表转化为10进制时,第 i i i 位需要乘上的进制 i i i 次方

注意:

  • 一般不能把字母映射为0
  • 一般不考虑冲突情况
  • 有经验值: p p p 取 131 或者 13331 Q Q Q 取2的64次方,基本没有冲突出现
  • 对于 2的64次方,可以利用 unsigned long long 越界溢出来实现
哈希函数:

h [ i ] = h [ i − 1 ] ∗ p + s t r [ i ] h[i]=h[i-1]*p + str[i] h[i]=h[i1]p+str[i]

  • 上式表示从第 0 0 0位到第 i i i 位字符串,转化为 p p p 进制的数值

h [ r ] − h [ l − 1 ] ∗ P [ r − l + 1 ] h[r] - h[l-1] * P[r-l+1] h[r]h[l1]P[rl+1]

  • 上式表示,从第 l l l 位到第 r r r 位字符串,转换为 p p p 进制的数值
  • r − l + 1 = r − 1 − ( l − 2 ) r-l+1 = r - 1 - (l - 2 ) rl+1=r1(l2)即把 [ 1 , l ] [1,l] [1,l] 哈希值 移动到 [ 1 , r ] [1,r] [1,r] (对齐,相减) 所需乘上的 p p p 的次幂注意转换成哈希值,计算时次幂增加的顺序与字符串的下标是反着的
代码表示:
#include<bits/stdc++.h>
#define ull unsigned long long //溢出就相当于取模

using namespace std;
const int N = 100010, p = 131;
int n, m;
ull h[N], P[N];
char str[N];

int gets(int l, int r)
{
	return h[r] - h[l - 1] * P[r - l + 1];
}
int main()
{
	cin >> n >> m >> str + 1;
	P[0] = 1;
	for (int i = 1; i <= n; i++)
	{
		P[i] = P[i - 1] * p;
		h[i] = h[i - 1] * p + str[i];//相当于12 = 1 * 10 + 2;

	}
	while (m--)
	{
		int l1, l2, r1, r2;
		cin >> l1 >> r1 >> l2 >> r2;
		if (gets(l1, r1) == gets(l2, r2))
			cout << "Yes\n";
		else
			cout << "No\n";
	}
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值