目录
0x01 链表
0x02 栈与队列
0x03 KMP字符串
0x04 Trie树
0x05 并查集
0x06 堆
0x07 hashtable
0x08 STL库的使用
0x01 链表
链表可以分为:单链表、双链表。
-
单链表主要用于邻接表(存储图和树)
-
双链表主要用于优化某些问题。
当我们想用普通的构建链表的形式去创建节点时,往往会超出时间的限制,所以以下的算法的都是基于数组来实现的(万物皆可用数组实现)。
也就是下图的这个意思:
所以我们需要声明两个数组去表达我们这个链表。
那就先看看题目一吧:单链表
实现一个单链表,链表初始为空,支持三种 *** 作:
向链表头插入一个数;
删除第 k 个插入的数后面的数;
在第 k 个插入的数后插入一个数。
现在要对该链表进行 M 次 *** 作,进行完所有 *** 作后,从头到尾输出整个链表。
注意:题目中第 k 个插入的数并不是指当前链表的第 k 个数。
例如 *** 作过程中一共插入了 n 个数,则按照插入的时间顺序,这 n 个数依次为:第 1 个插入的数,第 2个插入的数,…第 n 个插入的数。
输入格式
第一行包含整数 M,表示 *** 作次数。
接下来 M 行,每行包含一个 *** 作命令, *** 作命令可能为以下几种:
H x
,表示向链表头插入一个数 x。
D k
,表示删除第 k 个插入的数后面的数(当 k 为 0 时,表示删除头结点)。
I k x
,表示在第 k 个插入的数后面插入一个数 x(此 *** 作中 k 均大于 0)。输出格式
共一行,将整个链表从头到尾输出。
数据范围
1≤M≤100000 所有 *** 作保证合法。
输入样例:
10 H 9 I 1 1 D 1 D 0 H 6 I 3 6 I 4 5 I 4 5 I 3 4 D 6输出样例:
6 4 6 5
步骤:
-
创建head表示头节点下标
-
创建数组val表示存储值
-
创建数组next表示指向下一个节点的指针式多少
-
变量idx存储当前已用了哪个节点
-
初始化链表init和idx
代码:
#include
using namespace std;
const int N = 100010;
int head,e[N],ne[N],idx;
//初始化
void init()
{
head = -1;
idx = 0;
}
//将x插到头节点
void add_to_head(int x)
{
//存值
e[idx]=x;
//下一个指针指向上一次head指针指向的那个点
ne[idx]=head;
//把head指向当前指针
head=idx;
idx++;
}
//将x插到下标为k的点的后面
void add(int k,int x)
{
e[idx] = x;
ne[idx] = ne[k];
ne[k]=idx;
idx++;
}
//将k点后面的那个节点删掉
void remove(int k)
{
//将下一个指针指向下一个点所指向的指针,直接跳过这个点
ne[k] = ne[ne[k]];
}
int main()
{
int m;
cin>>m;
while(m--)
{
int k,x;
char op;
cin >> op;
if(op=='H')
{
cin>>x;
add_to_head(x);
}
else if(op=='D')
{
cin >> k;
//指向头节点的话,那么头节点就要指向当前头节点指向的下一个点,否则会出错
if(!k) head=ne[head];
remove(k-1);
}
else
{
cin>>k>>x;
add(k-1,x);
}
}
for(int i=headl;i!=-1;i=ne[i]) cout<
题意的解释:在最开始时,head指向的是空,也就是-1。
当我们插入值时,我们将下标定为,并且值为9,之后我们再插入一个点,那么就是下标为1,值为1的点,那么下一步是要删除头节点,那么就是把整个链表都删除了,那就前面都在做无用功,此刻的idx为2。
之后再向头节点插入一个6,idx为2,再向k-1插入一个数字6,idx为3(k插入表示是在数组的k-1),之后再插入5,idx为4,之后下一个指向空集。
之后再向4也就是idx为4之前插入一个5,此刻idx为5,之后再向idx为3的前面插入一个数字4,此刻idx为6。
那么最后输出的图是如下的:
需要注意的是,我们所指向的k值,其实就是idx-1。
题目二:双链表
双链表,顾名思义就是有一个指向前面的节点的指针,以及有一个指向后面元素的指针。
那么就像上面的,我们需要构建三个数组,一个是存放元素的值,一个是存放指向前面节点的下标数组,一个是存放指向后面节点的下标数组,一个是存放值的数组。
实现一个双链表,双链表初始为空,支持 55 种 *** 作:
在最左侧插入一个数;
在最右侧插入一个数;
将第 k个插入的数删除;
在第 k个插入的数左侧插入一个数;
在第 k 个插入的数右侧插入一个数
现在要对该链表进行 M 次 *** 作,进行完所有 *** 作后,从左到右输出整个链表。
注意:题目中第 k 个插入的数并不是指当前链表的第 k 个数。
例如 *** 作过程中一共插入了 n 个数,则按照插入的时间顺序,这 n 个数依次为:第 1 个插入的数,第 2 个插入的数,…第 n个插入的数。
输入格式
第一行包含整数 M,表示 *** 作次数。
接下来 M 行,每行包含一个 *** 作命令, *** 作命令可能为以下几种:
L x
,表示在链表的最左端插入数 x。
R x
,表示在链表的最右端插入数 x。
D k
,表示将第 k 个插入的数删除。
IL k x
,表示在第 k 个插入的数左侧插入一个数。
IR k x
,表示在第 k 个插入的数右侧插入一个数。输出格式
共一行,将整个链表从左到右输出。
数据范围
1≤M≤100000 所有 *** 作保证合法。
输入样例:
10 R 7 D 1 L 3 IL 2 10 D 3 IL 2 7 L 8 R 9 IL 4 7 IR 2 2输出样例:
8 7 7 3 2 9
那么就先看看代码:
#include
using namespace std;
const int N = 100010;
int m;
int e[N],l[N],r[N],idx;
//初始化
void init()
{
//0表示左端点,1表示右端点
r[0]=1;//head->next=tail
l[1]=0;//tail->pre=head
idx=2;//add two nodes
}
//插入右值那么就输入(k,x),如果是插入左值,那么就输入(l[k],x)
void add(int k,int x)
{
e[idx]=x; //新节点存放值
r[idx]=r[k];//新节点->next=上一个节点指向next的值
l[idx]=k; //新节点->next->pre=上一个节点的右边界,其实就是k
//下面这两步不可以反
l[r[k]]=idx;//新节点的右边界也就是下一个节点的左边界,需要指向我们这个新节点的有边界
r[k]=idx; //旧节点的右边界要指向新节点
}
//删除第k个点
void remove(int k)
{
r[l[k]]=r[k];//让当前的这个节点的左边,也就是上一个节点的右边,直接等于这个节点的右边
l[r[k]]=l[k];//让当前的这个节点的右边,也就是下一个节点的左边,直接等于这个节点的左边
}
其实idx表达的就是现在是第几个节点,也就是我们所表达的k,每次新增加一个节点,idx就++。
使用图来表示上面的ADD与REMOVE:
0x02 栈与队列对于栈跟队列,记清以下两点:
-
栈:先进后出
-
队列:先进先出
那首先想想如何使用数组模拟单调栈:
-
初始化一个数组,以及我们的指针tt=0
-
如何执行插入:stk[++tt]=x
-
访问下一个:tt--。
-
判断是否为空:if(tt>0) not empty;else empyt;
-
如何访问栈顶?stk[tt];
那如何使用数组模拟队列?
-
初始化一个数组,以及指向队列尾hh和队列头tt=-1。
-
如何插入?q[++tt]=x
-
如何调出最开始的那个数?使h++,使其往右移动后,上一个数值掉出
-
判断是否为空:if(hh<=tt) not empty;else empty。
-
取出最头元素:q[hh]
-
取出队尾元素:q[tt]
接下来看看单调栈和单调队列吧:
单调栈:
算法原理:用单调递增栈,当该元素可以入栈的时候,栈顶元素就是它左侧第一个比它肖的元素。
题意:
给定一个长度为 N的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1。
输入格式
第一行包含整数 N,表示数列长度。
第二行包含 N 个整数,表示整数数列。
输出格式
共一行,包含 N 个整数,其中第 i 个数表示第 i 个数的左边第一个比它小的数,如果不存在则输出 −1。
数据范围
1≤N≤10^5 1≤数列中元素≤10^9
输入样例:
5 3 4 2 7 5输出样例:
-1 3 -1 2 2
我们可以设置一个单调栈,对于一个数组比如是: 3 4 2 7 5。
当我们第一个数3进入栈时,我们可以发现该栈中只有3一个元素,所以没有比其小的数,所以3这个元素返回-1,当再指向下一个元素,那么就将4压入栈中,与栈中下面的元素进行比较,比较出3比4小,那么返回3;那么接下来是数字2,将其2压入栈中,将比2大的元素出栈,那么最后只剩下2一个元素在栈中,那么2返回-1,接下来是将7压入栈中,返回2,最后是将9压入栈中,返回7。
那么接下来看看代码吧:
#include
using namespace std;
const int N = 100010;
int n;
int stk[N],tt;
int main()
{
ios::sync_with_stdio(false);
cin>>n;
for(int i=0;i>x;
//压入栈中
while(tt&&stk[tt]>=x) tt--;
//如果栈中存在元素
if(tt) cout<
单调队列:
给定一个大小为 n≤106 的数组。
有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。
你只能在窗口中看到 k 个数字。
每次滑动窗口向右移动一个位置。
以下是一个例子:
该数组为
[1 3 -1 -3 5 3 6 7]
,k 为 3。
窗口位置 最小值 最大值 [1 3 -1] -3 5 3 6 7 -1 3 1 [3 -1 -3] 5 3 6 7 -3 3 1 3 [-1 -3 5] 3 6 7 -3 5 1 3 -1 [-3 5 3] 6 7 -3 5 1 3 -1 -3 [5 3 6] 7 3 6 1 3 -1 -3 5 [3 6 7] 3 7 你的任务是确定滑动窗口位于每个位置时,窗口中的最大值和最小值。
输入格式
输入包含两行。
第一行包含两个整数 n和k,分别代表数组长度和滑动窗口的长度。
第二行有 n 个整数,代表数组的具体数值。
同行数据之间用空格隔开。
输出格式
输出包含两个。
第一行输出,从左至右,每个位置滑动窗口中的最小值。
第二行输出,从左至右,每个位置滑动窗口中的最大值。
输入样例:
8 3 1 3 -1 -3 5 3 6 7输出样例:
-1 -3 -3 -3 3 3 3 3 5 5 6 7
其实以上的题可以使用暴力解法来求解,但是使用单调队列效率高一些:
我们先看看什么是滑动窗口,在示例中,我们从数组中的第一个元素开始遍历,由于窗口的大小是3,因此当遍历到第三个元素时,窗口就形成了。
之后继续遍历元素时,为了保持窗口的大小为3,左侧元素就要从窗口中剔除,这样使得窗口一直在向右移动,直到考察到最后一个元素结束,这就是所谓的滑动窗口。
那么解题思路是如下的:(以求最大值为例)
-
如果当前的滑动窗口中有两个下标i和j,其中i在j的左侧,并且i对应的元素不大于j对应的元素(num[i]<=nums[j]),则:
-
当滑动窗口向右移动时,只要i还在窗口中,那么j一定也还在窗口中。
这是由于i在j的左侧所保证的。
-
因此,由于nums[j]存在,nums[i]一定不是滑动窗口的最大值了,我们可以将nums[i]永久地移除了。
-
因此我们可以使用一个队列储存所有还没有被移除地下标,在队列中,这些下标按照从小到大地顺序被存储,并且它们在数组nums中对应的值是严格单调递减的。
-
当滑动窗口向右移动时,我们需要把一个新的元素放入队列中。
为了保持队列的性质,我们会不断地将新的元素与队尾的元素相比较,如果新元素大于等于队尾元素,那么队尾元素就可以永久地被移除。
我们将其d出队列。
我们需要不断地进行此项 *** 作,直到队列为空或者新的元素小于队尾的元素。
-
由于队列中下标对应的元素是严格单调递减的,因此队首下标对应的元素就是滑动窗口的最大值。
-
窗口向右移动时,我们还需要不断从队首d出元素保证队列中所有元素都是窗口中的,因此当队头元素在窗口的左边时,d出队头。
其中最大值以及最小值要分开来做,两个for循环是完全类似的,都要做到以下四步:
-
解决队首已经出窗口的问题。
-
解决队尾与当前元素A[i]不满足单调性的问题
-
将当前元素下标加入队尾
-
如果满足条件则输出结果
需要注意的是:
-
步骤一定要先是第三步后再第四步,因为有可能输出的正是新添加的那个数。
-
我们需要注意的是队列中存的原数组的下标,取值时要再套一层A[q[]]
-
算最大值前的时候一定要记得将hh和tt重置
-
hh要从0开始,数组下标也要从0开始。
最后看看代码:
#include
using namespace std;
const int N=1e6+10;
int n,k,q[N],a[N]; //q[N]存的是数组下标
int main()
{
int tt=-1,hh=0; //hh队列头 tt队列尾
cin.tie(0);
ios::sync_with_stdio(false);
cin>>n>>k;
for(int i=0;i>a[i];
//寻找最小值
for(int i=0;i我们设定的滑动窗口大小k,队列d出队列头元素以维持滑动窗口的大小,队列d出队列头元素以维持滑动窗口的大小
if(hh<=tt&&k=当前元素(a[i])时,那么队尾元素就一定不是当前窗口最小值,删去队尾元素,加入当前元素(q[++tt]=i)
while(hh<=tt && a[q[tt]]>=a[i]) tt--;
q[++tt] = i;
//打出那个d出来的值
if(i+1>k) printf("%d ",a[q[hh]]);
}
puts("");
hh=0,tt=-1;
//寻找最大值
for(int i=0;i=k) printf("%d ",a[q[hh]]);
}
return 0;
}
0x03 KMP字符串
先看看题目:
给定一个模式串 S,以及一个模板串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。
模板串 P 在模式串 S 中多次作为子串出现。
求出模板串 P 在模式串 S 中所有出现的位置的起始下标。
输入格式
第一行输入整数 N,表示字符串 P 的长度。
第二行输入字符串 P。
第三行输入整数 M,表示字符串 S 的长度。
第四行输入字符串 S。
输出格式
共一行,输出所有出现位置的起始下标(下标从 0开始计数),整数之间用空格隔开。
数据范围
1≤N≤10^5 1≤M≤10^6
输入样例:
3 aba 5 ababa输出样例:
0 2
其实归根到底就是想要求出输入的子字符串在某个字符串里面出现的次数,之后返回下标,那么我们先想想暴力的做法:
int s[N],p[M];
for(int i=1;i<=n;i++)
{
bool flag=true;
for(int j=1;j<=m;j++)
{
if(s[i]!=p[j])
{
flag=false;
break;
}
}
}
首先我们先得知什么是KMP算法吧,这是一个字符串匹配算法,它的基本概念如下:
-
s[ ]是模式串,是比较长的那个字符串
-
p[ ]是模板串,是比较短的那个字符串
-
非平凡前缀:指除了最后一个字符以外,一个字符串的全部头部组合。
-
非平凡后缀:指除了第一个字符以外,一个字符串的全部尾部组合。
-
部分匹配值:前缀和后缀的最长共有元素的长度。
-
next[ ]是部分匹配值表,即next数组,它存储的每一个下标对应的“部分匹配值”,是KMP算法的核心。
其核心的思想是在于:在每次失去匹配时,不是把p串往后移动一位,而是把p串往后移动至下一次可以和前面部分匹配的位置,这样就可以跳过大多数的失配步骤。
而每次p串移动过的部署就是通过查找next[]数组确定的。
对于next数组,它其实是存放了像p[1,j]串中前缀和后缀相同的最大长度,也就是部分匹配值,即p[1,next[j]]=p[j-next[j]+1,j]。
那么先试着求一下next数组:
p a b c a b 下标 1 2 3 4 5 next[ ] 0 0 0 1 2
对next[ 1 ] :前缀 = 空集—————后缀 = 空集—————next[ 1 ] = 0;
对next[ 2 ] :前缀 = { a }—————后缀 = { b }—————next[ 2 ] = 0;
对next[ 3 ] :前缀 = { a , ab }—————后缀 = { c , bc}—————next[ 3 ] = 0;
对next[ 4 ] :前缀 = { a , ab , abc }—————后缀 = { a , ca , bca }—————next[ 4 ] = 1;
对next[ 5 ] :前缀 = { a , ab , abc , abca }————后缀 = { b , ab , cab , bcab}————next[ 5 ] = 2;
之后就想想代码的思路:
KMP主要分两步:求next数组、匹配字符串。
对于s串和p串都是从1开始的,i从1开始,j从0开始,每次s[i]和p[j+1]比较。
当匹配到上面的那种情况的时候,s[a,b]=p[1,j]&&s[i]!=p[j+1]此时要移动p串(不是移动一格,而是直接移动到下次能匹配的位置)
由上面的匹配我们可以直到的是,1串对于3串,3串等于2串,所以直接移动p串使得1到3的位置即可,这个 *** 作可以由j=next[j]直接完成,如此往复下去,直到j==m时匹配成功。
那么就看看代码吧:
for(int i=1,j=0;i<=n;i++)
{
while(j&&s[i]!=p[j+1]) j=ne[j];
//如果j有对应p串的元素,且s[i]!=p[j+1],则失配,移动p串
//用while是由于移动后可能仍然失配,所以要继续移动直到匹配或整个p串一道后面(j=0)
if(s[i]==p[j+1]) j++;
//当前元素匹配,j移向p串下一位
if(j==m)
{
//匹配成功,进行相关操作
j = next[j]; //继续匹配下一个子串
}
}
那么对于next数组怎么求:next数组的求法是通过模板串自己与自己进行匹配 *** 作的出来的。
比如说我们得到了p[a,b]=p[1,j],以及if(p[i]=p[j+1]) j++;后,此刻next[i]=j,也就是说此刻下面的p串移动后的情况,即在代码中if后面的。
那么此刻len(前缀)=len(后缀)=j。
那么对于代码的处理可以如下:
for(int i=2,j=0;i<=m;i++)
{
while(j&&p[i]!=p[j+1]) j=next[j];
if(p[i]==p[j+1]) j++;
next[i]=j;
}
那么对于完整的代码可以如下:
#include
using namespace std;
const int N=10010,M=100010;
int n,m;
char p[N],s[M];
int ne[N];
int main()
{
cin>>n>>p+1>>m>>s+1; //下标均从1开始
//求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]) j=ne[j];
if(s[i]==p[j+1]) j++;
if(j==m)
{
printf("%d",i-m);
j=ne[j];
}
}
return 0;
}
其实这个思想主要是想要快速找到匹配的字符串,当在匹配的过程中发现已经不匹配了,那么这个时候就要将指针的箭头指向最长公共前后缀的那个地方,再进行匹配。
若最长的公共前后缀等于当前指针指向的地方,即上下两个数组匹配相同,那么在这个子串中继续寻找子串已经没有意义了,我们应该找的是对应的小于这个长度的位置的后缀。
Trie可以支持两个 *** 作,就是实现高效的存储和查找字符串集合的一个结构。
那么Trie树到底是什么结构?举个例子:
假如我们要把下面几个单词存储在Trie树中:
abcdef
abdef
accd
bcdf
bcff
cdaa
首先声明一个头节点root,之后遍历单词,一个一个查找树中是否存在这样的排序:
之后的 *** 作就是在某个单词结尾后面加入一个标记,表示在这有一个单词。
所以我们可以根据我们输入的单词去判断这个字典里面是否存在这个单词,实现快速的查找。
可以看一下这道题:
维护一个字符串集合,支持两种 *** 作:
I x
向集合中插入一个字符串 x;
Q x
询问一个字符串在集合中出现了多少次。共有 N 个 *** 作,输入的字符串总长度不超过 10^5,字符串仅包含小写英文字母。
输入格式
第一行包含整数 N,表示 *** 作数。
接下来 N 行,每行包含一个 *** 作指令,指令为
I x
或Q x
中的一种。输出格式
对于每个询问指令
Q x
,都要输出一个整数作为结果,表示 xx 在集合中出现的次数。每个结果占一行。
数据范围
1≤N≤2∗10^4
输入样例:
5 I abc Q abc Q ab I ab Q ab输出样例:
1 0 1
看看代码:
#include
using namespace std;
const int N = 100010;
int son[N][26]; //这么声明是因为我们有N个节点,都是小写字母的话就最多只有二十六个分支
int cnt[N]; //以当前这个结点结尾的单词有多少个
int idx; //当前用到的是哪个下标
//我们需要注意的是 下标是0的点,既可能是根节点,又是空节点
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
p=son[p][u];
}
cnt[p]++; //当前结点所拥有的单词数量+1
}
//写一个插入的函数
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];
}
int main()
{
int n;
scanf("%d",&n);
while(n--)
{
char op[2]; //读取 *** 作用的
scanf("%s%s",op,str);
if(op[0]=='I') insert(str);
else printf("%d\n",query(str));
}
return 0;
}
0x05 并查集
并查集有什么作用?可以快速地处理如下问题:
-
将两个集合合并
-
询问两个元素是否在一个集合当中(近乎O(1))
基本原理:用树的形式来维护所有的集合。
每个集合用一棵树来表示,树根的编号就是整个集合的编号。
每个节点存储它的父节点,其中p[x]表示x的父节点。
那么就存在以下问题:
-
问题1:如何判断树根 if(p[x]==x)
-
问题2:如何求x的集合编号 while(p[x]!=x) x=p[x];
-
问题3: 如何合并两个集合 px是x的集合编号,py是y的集合编号,直接使一颗树插在一棵树的树根上,也就是p[x]=y。
看看题目:
一共有 n 个数,编号是 1∼n,最开始每个数各自在一个集合中。
现在要进行 m 个 *** 作, *** 作共有两种:
M a b
,将编号为 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个 *** 作;
Q a b
,询问编号为 a 和 b 的两个数是否在同一个集合中;一共有 n 个数,编号是 1∼n,最开始每个数各自在一个集合中。
现在要进行 m 个 *** 作, *** 作共有两种:
M a b
,将编号为 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个 *** 作;
Q a b
,询问编号为 a 和 b 的两个数是否在同一个集合中;输入格式
第一行输入整数 n 和 m。
接下来 m 行,每行包含一个 *** 作指令,指令为
M a b
或Q a b
中的一种。输出格式
对于每个询问指令
Q a b
,都要输出一个结果,如果 a 和 b 在同一集合内,则输出Yes
,否则输出No
。每个结果占一行。
数据范围
1≤n,m≤10^5
输入样例:
4 5 M 1 2 M 3 4 Q 1 2 Q 1 3 Q 3 4输出样例:
Yes No Yes第一行输入整数 n 和 m。
接下来 m 行,每行包含一个 *** 作指令,指令为
M a b
或Q a b
中的一种。输出格式
对于每个询问指令
Q a b
,都要输出一个结果,如果 a 和 b 在同一集合内,则输出Yes
,否则输出No
。每个结果占一行。
数据范围
1≤n,m≤10^5
输入样例:
4 5 M 1 2 M 3 4 Q 1 2 Q 1 3 Q 3 4输出样例:
Yes No Yes
代码如下:
#include
using namespace std;
const int N = 100010;
int n,m;
int p[N];
int find(int x) //返回x的祖宗节点+路径压缩
{
if(p[x]!=x) p[x]=find(p[x]);
return p[x];
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=0;i<=n;i++) p[i]=i;
while(m--)
{
char op[2];
int a,b;
scanf("%s%d%d",op,&a,&b);
//将其父节点挂载到另外一棵树的父节点下
if(op[0]=='M') p[find(a)]=find(b);
else
{
if(find(a)==find(b)) puts("Yes");
else puts("No");
}
}
return 0;
}
接下来的题型是算出并查集中节点的数量:
题目:连通块的数量
给定一个包含 n 个点(编号为 1∼n1)的无向图,初始时图中没有边。
现在要进行 m 个 *** 作, *** 作共有三种:
C a b
,在点 a 和点 b 之间连一条边,a 和 b 可能相等;
Q1 a b
,询问点 a 和点 b 是否在同一个连通块中,a 和 b 可能相等;
Q2 a
,询问点 a 所在连通块中点的数量;输入格式
第一行输入整数 n 和 m。
接下来 m 行,每行包含一个 *** 作指令,指令为
C a b
,Q1 a b
或Q2 a
中的一种。输出格式
对于每个询问指令
Q1 a b
,如果 a 和 b 在同一个连通块中,则输出Yes
,否则输出No
。对于每个询问指令
Q2 a
,输出一个整数表示点 a 所在连通块中点的数量每个结果占一行。
数据范围
1≤n,m≤10^5
输入样例:
5 5 C 1 2 Q1 1 2 Q2 1 C 2 5 Q2 5输出样例:
Yes 2 3
其实当我们在两个连通块之间连一条边的时候,其实就是像上边合并树的 *** 作。
只不过比上一题目多了一个统计点的数量。
#include
using namespace std;
const int N = 100010;
int n,m;
int p[N],size[N]; //表示祖宗节点所在集合中的点的数量
//需要注意的是 size只有父节点有意义,要特别注意处理size的地方,都要归根结底
//返回x的祖宗节点
int find(int x)
{
if(p[x]!=x) p[x]=find(p[x]);
return p[x];
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
p[i]=i;
//现在导入的每一个离散的点所存在的集合都为1
size[i]=1;
}
while(m--)
{
char op[5];
int a,b;
scanf("%s",op);
if(op[0]=='C')
{
scanf("%d%d",&a,&b);
//将两个点连接起来,就是相当于把散的点进行合并,也就是将a的父节点挂载在b的父节点上
if(find(a)==find(b)) continue;
size[find(b)] += size[find(a)];
p[find(a)]=find(b);
}
else if(op[1]=='1')
{
scanf("%d%d",&a,&b);
//是否存在于同一个父节点
if(find(a)==find(b)) puts("Yes");
else puts("No");
}
else
{
scanf("%d",&a);
printf("%d\n",size[find(a)]);
}
}
}
0x06 堆
如何手写一个堆?其实堆是维护一个数据集合。
二叉堆是一种支持插入、删除、查询最值的数据结构。
他其实是一棵满足“堆性质”的完全二叉树,树上的每个节点都带有一个权值。
若树中的任意一个节点的权值都小于等于其父节点的权值,则称该二叉树满足“大根堆性质”。
若树中任意一个节点的权值都大于等于其父节点的权值,则称该二叉树满足“小根堆性质”。
满足“大根堆性质”的完全二叉树就是“大根堆”,而满足小根堆性质的二叉树就是“小根堆”,二者都是二叉堆的形态之一。
根据完全二叉树的性质,我们可以采用层次序列存储方式。
直接用一个数组来保存二叉堆。
层次序列存储方式。
就是逐层从左到右为树中的节点依次编号,把此编号作为节点在数组中存储的位置(下标)。
在这种存储方式中,父节点编号等于子节点编号除以2,左子节点编号等于父节点编号乘以2,右子节点左子节点编号乘2+1。
关于根节点、子节点以及叶子节点的概念:
-
根节点就是树的最顶端的节点
-
继续往下分为子节点
-
当不断细分直到不再有子节点时为叶子节点
堆的话其实是一棵二叉树,或者是一个完全二叉树。
小根堆有什么样的特点?它的节点下连接的两个子节点都比其大,所以其祖宗节点为最小值。
当我们移动二叉树时,比如说我们将根节点改为一个较大的树,那我们就需要去编写一个使其向下移或者是向上移的 *** 作,我们首先要将这个数与下面两个节点的数字中比较小的那个数字交换,以此类推,直到找到比下面两个数字小为止。
若要进行往上移动的时候,我们只需要比较我们现在需要往上移动的点,以及对应的根节点,较小的那个值往上移动。
那么对于堆可以实现的功能可以如下:
-
插入一个数 heap[++size] = x;up(size);
-
求集合当中的最小值 heap[1];
-
删除最小值 heap[1]=heap[size];size--;down(1);
-
删除任意一个元素 heap[k]=heap[size];size--;down(k);up(k);
-
修改任意一个元素 heap[k]=x;down(k);up(k);
其中的up函数以及down函数其实就是对数字在堆中进行上下移动。
看看代码比较易懂:
先看题目:堆排序
输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。
输入格式
第一行包含整数 n 和 m。
第二行包含 n个整数,表示整数数列。
输出格式
共一行,包含 m 个整数,表示整数数列中前 m 小的数。
数据范围
1≤m≤n≤10^5 1≤数列中元素≤10^9
输入样例:
5 3 4 5 1 3 2输出样例:
1 2 3
那么先开始创建堆吧,创建一个堆正常要满足如下的条件:
-
首先要明确进行down *** 作时必须满足左儿子和右儿子已经是个堆。
-
开始创建堆的时候,元素是随机插入的,所以不可以从根节点开始down,而是要满足下面三个性质的结点
-
左右儿子满足堆的性质。
-
下标最大(因为要往上遍历)
-
不是叶节点(叶节点一定满足堆的性质)
-
那么我们要从哪开始创建这个堆?答案是这个数组的一半开始分,也就是n/2:
叶子节点一定满足是因为,它这个节点无非就两种情况,就是左儿子或者是右儿子,他们的父亲都是n/2,所以就直接从n/2开始生成堆。
那么看看程序:
#include
using namespace std;
const int N=100010;
int n,m;
//h[i]表示第i个结点存储的值,i从1开始,2*i是左子节点,2*i+1是右节点
//size既表示堆里存储的元素个数,又表示最后一个结点的下标
int h[N],size;
void down(int u)
{
int t = u; //t存储三个结点中存在的最小的结点的下标,初始化为当前结点u
//左子节点存在并且小于当前结点,更新t的下标
if(u*2<=size && h[u*2]那么u不用调整了,但t情况不明,可能需要调整。
}
}
void up(int n)
{
while(u/2 && h[u/2]>h[u])
{
swap(h[u/2],h[u]);
u/=2;
}
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&h[i]);
size=n; //表示堆中有n个元素
//把堆初始化成小根堆,从二叉树的倒数第二行开始,把数字大的往下沉
for(int i=n/2;i;i--) down(i);
while(m--)
{
printf("%d",h[1]);
h[1]=h[size];
size--;
down(1);
}
return 0;
}
题目二:模拟堆
维护一个集合,初始时集合为空,支持如下几种 *** 作:
I x
,插入一个数 x;
PM
,输出当前集合中的最小值;
DM
,删除当前集合中的最小值(数据保证此时的最小值唯一);
D k
,删除第 k 个插入的数;
C k x
,修改第 k 个插入的数,将其变为 x;现在要进行 N 次 *** 作,对于所有第 2个 *** 作,输出当前集合的最小值。
输入格式
第一行包含整数 N。
接下来 N 行,每行包含一个 *** 作指令, *** 作指令为
I x
,PM
,DM
,D k
或C k x
中的一种。输出格式
对于每个输出指令
PM
,输出一个结果,表示当前集合中的最小值。每个结果占一行。
数据范围
1≤N≤10^5 −10^9≤x≤10^9 数据保证合法。
输入样例:
8 I -10 PM I -10 D 1 C 2 8 I 6 PM DM输出样例:
-10 6
这道题与上道题不一样的地方是它需要将我们需要定位的位置k,得知k位置存储的是哪个结点,那么需要两个数组,一个是存放这个元素所在的位置,一个是存放这个元素的值,这样我们交换的时候,就可以既交换指针,又可以交换值。
代码如下:
#include
#include
#include
using namespace std;
const int N = 100010;
int h[N], ph[N], hp[N], cnt;
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;
}
}
int main()
{
int n, m = 0;
scanf("%d", &n);
while (n -- )
{
char op[5];
int k, x;
scanf("%s", op);
if (!strcmp(op, "I"))
{
scanf("%d", &x);
cnt ++ ;
m ++ ;
ph[m] = cnt, hp[cnt] = m;
h[cnt] = x;
up(cnt);
}
else if (!strcmp(op, "PM")) printf("%d\n", h[1]);
else if (!strcmp(op, "DM"))
{
heap_swap(1, cnt);
cnt -- ;
down(1);
}
else if (!strcmp(op, "D"))
{
scanf("%d", &k);
k = ph[k];
heap_swap(k, cnt);
cnt -- ;
up(k);
down(k);
}
else
{
scanf("%d%d", &k, &x);
k = ph[k];
h[k] = x;
up(k);
down(k);
}
}
return 0;
}
0x07 hashtable
哈希表主要有两大块内容,第一块是哈希表的存储结构,第二块是字符串的哈希方式,存储结构分为两部分,一部分是开放寻址法,另外一部分是拉链法。
哈希表其实就是把一个很长的数据,进行哈希映射,把它映射到有限的区间中,这是比较常见的情景。
哈希函数需要考虑这些问题:
-
哈希函数怎么写 x mod 质数(这样冲突概率小)
-
会产生哈希冲突(两个数通过哈希函数映射到同一个地方)
对于拉链法:其实就是对于有哈希冲突的两个数,使用链表将其存起来。
哈希表有什么用?可以实现快速添加、查找、删除。
看看题目:模拟散列表
维护一个集合,支持如下几种 *** 作:
I x
,插入一个数 x;
Q x
,询问数 x 是否在集合中出现过;现在要进行 N 次 *** 作,对于每个询问 *** 作输出对应的结果。
输入格式
第一行包含整数 N,表示 *** 作数量。
接下来 N 行,每行包含一个 *** 作指令, *** 作指令为
I x
,Q x
中的一种。输出格式
对于每个询问指令
Q x
,输出一个询问结果,如果 xx 在集合中出现过,则输出Yes
,否则输出No
。每个结果占一行。
数据范围
1≤N≤10^5 −10^9≤x≤10^9
输入样例:
5 I 1 I 2 I 3 Q 2 Q 5输出样例:
Yes No
看看代码:
#include
#include
using namespace std;
const int N = 100003;
int h[N],e[N],ne[N],idx;
//声明函数
void insert(int x)
{
int k = (x%N+N)%N; //使余数变为正数
e[idx]=x,ne[idx]=h[k],h[k]=idx++; //将值存入其中,并指向下一个待入的值,将下标的索引+1
}
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;
}
int main()
{
int n;
scanf("%d",&n);
memset(h,-1,sizeof h);
while(n--)
{
char op[2];
int x;
scanf("%s%d",op,&x);
if(op='I') insert(x);
else
{
if(find(x)) puts("Yes");
else puts("No");
}
}
return 0;
}
关于开发寻址法:
如何处理冲突?它是开了一个一维数组,很长的数组,一般是题目范围的2到3倍。
这样冲突的概率比较低。
这个算法在插入的时候,是一直在找有没有空位的位置 ,有就插入,无就继续遍历。
查找也是一样的,一个一个地查找。
关于怎么找质数:
for(int i=20000;i++)
{
bool flag = true;
for(int j=2;j*j
关于我下面程序用的初始化:
const int N = 20003;
-
开放寻址 *** 作过程中会出现冲突的情况,一般会开成两倍的空间,可减少数据的冲突。
-
如果使用%来计算索引,把哈希表的长度设计为质数,可以大大减小哈希冲突
const int null = 0x3f3f3f3f; ... memset(h,0x3f,sizeof h)
-
memset函数到底是如何工作的,为什么memset初始化比循环更快,其实memset是直接对内存进行 *** 作的,是按字节(byte)进行复制的。
-
对于memset的函数声明:
第一个参数为一个指针,即要进行初始化的首地址。
第二个参数是初始化值,并不是直接把这个值赋给一个数组单元(对int来说并不是这样)
第三个参数是要初始化首地址后多少个字节
那么对于一个int类型的数组,需要四个字节,所以第二个参数0x3f为一个字节,传进去后0x3f*4(从高到低为0x3f3f3f3f)
这也就说明了为什么在memset函数中不设置除了-1、0以外常见的值,比如说1,字节表示00000001,那么memset(h,1,4)则表示为0x01010101。
-
为什么要取0x3f3f3f3f?
0x3f3f3f3f其十进制为1061109567,也就是10^9级别的。
输入进来的数据都是小于10^9的,所以它可以作为无穷大使用而不至于出现数据大于无穷大的情况。
而且这样还方便我们进行初始化。
代码如下:
#include
using namespace std;
const int N = 200003,null = 0x3f3f3f3f;
int h[N];
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;
}
int main()
{
int n;
scanf("%d",&n);
memset(h,0x3f,sizeof h);
while(n--)
{
char op[2];
int x;
scanf("%s%d",op,&x);
int k=find(x);
if(op=='I') h[k]=x;
else
{
if(h[k]!=null) puts("Yes");
else puts("No");
}
}
return 0;
}
接下来看看字符串哈希,其实字符串处理也可以不使用KMP去处理。
这个哈希是指字符串前缀哈希法。
怎么去哈希呢,我们需要先去处理所有前缀的哈希。
但是这个哈希,并不是说是所有前缀字母的组合:
str = "ABCABCDE"
h[0]=0;
h[1]="A"的hash值;
h[2]="AB"的hash值;
h[3]=“ABC”的hash值;
h[4]="ABCA"的hash值;
...
那么如何去定义某一个前缀的哈希值?可以把字符串看出p进制的数。
相当于一个ABCD(1 2 3 4 )p,使其转换的时候要看作是1*p^3+2 * p^2 + 3 * p^1+4*p^0。
把我们的字符串,转换为一个数字,这个数字可能会非常大,那么就将其取模(x mod Q),获得另外一个数字,就可以将其映射到0~Q这个区间内。
需要注意几点:
-
不能映射成0,字母映射从1开始
-
关于经验值 取p=131或13331,Q=2^64不容易产生哈希冲突。
看看题目:字符串哈希
给定一个长度为 n 的字符串,再给定 m 个询问,每个询问包含四个整数 l1,r1,l2,r2,请你判断 [l1,r1]和 [l2,r2] 这两个区间所包含的字符串子串是否完全相同。
字符串中只包含大小写英文字母和数字。
输入格式
第一行包含整数 n 和 m,表示字符串长度和询问次数。
第二行包含一个长度为 n 的字符串,字符串中只包含大小写英文字母和数字。
接下来 m 行,每行包含四个整数 l1,r1,l2,r2,表示一次询问所涉及的两个区间。
注意,字符串的位置从 1 开始编号。
输出格式
对于每个询问输出一个结果,如果两个字符串子串完全相同则输出
Yes
,否则输出No
。每个结果占一行。
数据范围
1≤n,m≤10^5
输入样例:
8 3 aabbaabb 1 3 5 7 1 3 6 8 1 2 1 2输出样例:
Yes No Yes
比较不同区间的子串是否相同,就转化为相应的哈希值是否相同。
就转化为对应的哈希值是否相同。
求一个字符串的哈希值就是相当于求前缀和,求一个字符串的子串哈希值就相当于求部分和。
前缀和公式 h[i+1]=h[i]×P+s[i]h[i+1]=h[i]×P+s[i] i∈[0,n−1]i∈[0,n−1] h为前缀和数组,s为字符串数组 区间和公式 h[l,r]=h[r]−h[l−1]×P[r−l+1]
区间和公式的理解: ABCDE 与 ABC 的前三个字符值是一样,只差两位, 乘上 P^2 把 ABC 变为 ABC00,再用 ABCDE - ABC00 得到 DE 的哈希值。
看看代码:
#include
using namespace std;
typedef unsigned long long ULL;
const int N = 100010,P=131;
int n,m;
char str[N];
ULL h[N],p[N];
ULL get(int l,int r)
{
return h[r]-h[l-1]*p[r-l+1];
}
int main()
{
scanf("%d%d%s",&n,&m,str+1);
p[0]=1; //不使用第0个下标的元素
for(int i=1;i<=n;i++)
{
//算出前缀和
p[i] = p[i-1]*P;
h[i] = h[i-1]*P+str[i];
}
while(m--)
{
int l1,r1,l2,r2;
scanf("%d%d%d%d",&l1,&r1,&l2,&r2);
if(get(l1,r1)==get(l2,r2)) puts("Yes");
else puts("No");
}
return 0;
}
0x08 STL库的使用
vector
变长数组,倍增的思想
string
字符串:substr(),c_str(),size()/length()
queue
队列:push(),front(),pop()
priority_queue:
优先队列,push(),top(),pop()
stack()
栈,push(),top(),pop()
deque
双端队列
set,map,multiset,multimap:
基于平衡二叉树(红黑树),动态维护有序序列
unordered_set,unordered_map,unordered_multiset,unordered_multimap:
哈希表,增删改减时间复杂度是O(1),但是不支持lower_bound()/upper_bound()
bitset
压位
bitset<10000> s;
~ & | ^
<< >>
== !=
[ ]
count() 返回有多少个1
none() 判断是否全为0
set 把所有位置置一
set(k,v) 将第k位变成v
reset() 把所有位变成0
flip() 等价于~
flip(k) 把第k位取反
一、vector使用
#include
#include
#include
#include
#include
using namespace std;
int main()
{
//声明一个容器 有十个数为-3
vector a(10,-3);
vector a[10];
//获取元素个数
a.size();
//容器中是否为空,是的话返回true,否则返回false
a.empty();
//清空,队列中没有清空这个函数
a.clear();
//遍历
vector a;
for(int i=0;i<10;i++) a.push_back(i);
for(int i=0;i::iterator i=a.begin();i!=a.end();i++)
cout << *i <<' ';
cout<
由于vector是变长数组,所以我们需要了解系统为某一程序分配空间时,所需的时间,与空间大小无关,只与增减次数有关。
所以我们需要减少申请空间的次数。
当每一次空间大小不够的时候,那我们就将个数扩大两倍。
再将原来的元素放进去,每次都扩大两倍。
每插入一个数,时间复杂度为O(1)。
front():返回容器的第一个数。
back():返回容器中的最后一个数。
push_back():在最后增加一个数。
pop_back():在最后删除一个数。
迭代器:begin()、end()
支持比较运算:可以声 明两个vector 进行数据和的比较
关于pair
可以声明这么一个pair
使用的时候p.first以及p.second;
pair也支持比较运算,以Frist为第一个关键字,以second为第二关键字(字典序)。
使用如下:
p = make_pair(10,"zzxxtt");
p = {100,"zzxxxtt"};
二、string的使用
//同样可以使用size()、empty()、clear()
#include
#include
#include
#include
#include
using namespace std;
int main()
{
string a = 'zzxxtt';
a += 'hhhh' //合并
//返回某一段子串
a.substr(1,2); //zx 超过字符串范围会输出到最后一个字母为止
//显示字符数组
printf("%s\n",a.c_str());
return 0;
}
三、队列
push() 向队尾插入一个元素
front() 返回队头元素
back() 返回队尾元素
pop() d出队头元素
size()
empty()
无clear()
如何构造一个空的队列:
#include
#include
#include
#include
#include
using namespace std;
int main()
{
queue q;
q=queue();
return 0;
}
四、优先队列(堆)——默认是大根堆
push() 插入一个元素
top() 返回堆顶元素
pop() d出堆顶元素
#include
#include
#include
#include
#include
#include
using namespace std;
//定义一个大根堆
int main()
{
priority_queue heap;
//如果想插入小根堆怎么办,方法一
heap.push(-x);
//方法二,在定义的时候定义小根堆
priority_queue,greater> heap;
}
五、栈
push() 向栈顶输入一个元素
top() 返回栈顶元素
pop() d出栈顶元素
无clear()
六、deque双端队列
size()
empty()
clear()
front()/back()
push_back()/pop_back()
push_front()/pop_front()
begin()/end()
[ ]
七、set
size()
empty()
clear()
begin()/end()
也可算前驱和后继
set——不允许有重复元素
multiset——允许有重复元素
insert()——插入一个数
find()——查找一个数
count()——返回某一个数的个数
erase()——输入是一个x,删除所有x(O(k+logn));输入一个迭代器,删除这个迭代器;
lower_bound()/upper_bound()
lower_bound(x) 返回大于等于x的最小的迭代器
upper_bound(x) 返回大于x的最小的迭代器
map/multimap
insert() 插入的数是一个pair
erase() 输入的参数是pair或迭代器
find()
[ ] 可以向数组一样使用,时间复杂度O(logn)
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)