以下是知识点部分
写到后面越写越水
全文约
50000
50000
50000 字,如果想查找某一个知识点,可以善用 Ctrl+F
1.1 并查集
并查集,顾名思义,它用于处理一些不交集的合并及查询问题。(废话)
一般来讲,某些题目会要求我们不断地将两个集合结合成一个集合,而且还要多次查询某两个元素是否处于同一个集合当中时,如果采用一般的数据结构,一般有两种死法:MLE 和 TLE
这时,我们就可以采用并查集来很好的解决这一问题。
所以,我们首先要学会并查集的板子。
那么,对于一个并查集,我们一般有三种操作:建并查集,查找祖先和合并集合。
1.1.1 建并查集
int fa[100005]; //fa[i] 存储第 i 号元素的父亲或祖先
void make(int num){
for(int i=1;i<=num;i++){
fa[i]=i; //因为一开始,每一个元素都是单独的一个集合,所以自己就是自己的父亲
}
}
对于不同的题目, m a k e make make 函数可能会有所差异,具体题目具体分析。
1.1.2 查找祖先及其优化
讲一个本班真实故事:
班上总有人喜欢认亲,A 认 B 爸爸,B 认 C 大爷……
这样乱认下去,自己的兄弟就成了自己的舅舅,A 有疑问了:我的祖先是谁呢?
可是家庭过于庞大,家谱(是的,我们有家谱)又不完整,所以查家谱是不可能的。
这时,A 就有方法了:
A 先问爸爸 B 的祖先是谁,B 又问大爷 C 的祖先是谁,C 又问……直到得到了准确答案之后,A 就知道了自己的祖先是谁。
并查集的查找祖先的原理和上述故事的思想是一样的:
int find(int num){
if(fa[num]==num){ //当前元素是祖先
return num; //返回祖先
}
return find(fa[num]); //当前元素不是祖先,返回父亲的祖先,一次向上爬,直到找到祖先为止
}
对于不同的题目, f i n d find find 函数可能会有所差异,具体题目具体分析。
关于 f i n d find find 函数的优化:
考虑这样的一个并查集:
假设当前并查集是一个链式结构,而如果我们要查询 10 10 10 号元素的祖先,我们就需要查询所有的点,如果数据范围太大,那么程序会非常的慢。
这时,我们引入第 1 1 1 种优化方法:路径压缩
因为我们在使用 f i n d find find 函数时,只关注所要查询的元素的祖先,而不关注并查集自身的形态。
换言之:下面这幅图和上面这幅图其实是等价的:
代码实现也是非常简单的:
int find(int num){
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]); //将 num 的祖先直接赋值给 fa[num]
}
1.1.3 合并集合及其优化
对于两个集合 M , N M,N M,N ,我们只需要让某个集合的一个元素存在于另一个集合当中,我们就可以合并两个集合。
那么,对于并查集而言,我们只需将其中一个集合的祖先的父亲设为另一个集合的祖父即可。
void build(int x,int y){
if(find(x)!=find(y)){ //两个元素不在同一集合内
fa[find(x)]=find(y); //含义见上
}
}
对于不同的题目, b u i l d build build 函数可能会有所差异,具体题目具体分析。
然而,对于 b u i l d build build 函数,我们依旧有优化方法——启发式合并。
考虑下面两个集合:
我们有两种方式合并这两个集合:
考虑第 1 1 1 种合并方式:如果我们要查询 5 5 5 号元素的祖先,则加上 5 5 5 号元素,我们一共要查询 4 4 4 个元素。
考虑第 2 2 2 种合并方式:如果我们要查询 5 5 5 号元素的祖先,则加上 5 5 5 号元素,我们一共要查询 5 5 5 个元素。
从优化时间复杂度来看,第 1 1 1 种合并方式更优。
我们可以猜想:把深度较小的集合塞进深度较大的集合里面,其时间复杂度更优。
证明?不好意思,我不会……
但是,你可以看看这句话:
一个祖先突然抖了个机灵:「你们家族人比较少,搬家到我们家族里比较方便,我们要是搬过去的话太费事了。」—— OI Wiki
相信大家都能理解了。这里开始解释代码:
由于引入了深度的概念,所以,我们要修改一下 m a k e make make 函数和 b u i l d build build 函数:。
void build(int x,int y){
int a=find(x),b=find(y);
if(a==b){ //x 和 y 处在同一集合中
return ;
}
if(deap[a]<=deap[b]){ //y 所处的集合的深度比 x 所处的集合的深度大
fa[a]=b; //把 x 塞入 y 中
}else{ //x 所处的集合的深度比 y 所处的集合的深度大
fa[b]=a; //把 y 塞入 x 中
}
if(deap[a]==deap[b]){ //x 所处的集合的深度和 y 所处的集合的深度一样
deap[b]++; //合并后必会加深 y 所处的集合的深度,需要加 1 (可以画图证明,也可数学证明)
}
}
int fa[100005],deap[100005]; //deap[i] 存储第 i 号元素所在
void make(int num){
for(int i=1;i<=num;i++){
fa[i]=i,deap[i]=1; //因为一开始,每一个集合只有1个元素,所以深度为1
}
}
当然启发式合并并不常用,一般来讲,有了路径压缩就够了。
1.1.4 例题分析
Just 一道裸题,没啥技术含量。(英语老师:不能裸奔)
#include<cstdio>
int fa[10005];
int n,m,x,y,z;
void make(){ //建并查集
for(int i=1;i<=n;i++){
fa[i]=i;
}
}
int find(int num){ //查找祖先
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]);
}
void build(int x,int y){ //合并集合
fa[find(x)]=find(y);
}
int main(){
scanf("%d%d",&n,&m);
make();
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
if(x==1){ //要求合并
build(y,z);
}else{ //要求查询
if(find(y)==find(z)){ //判断两个元素的祖先是否相同,即两个元素是否在同一集合内
printf("Y\n");
}else{
printf("N\n");
}
}
}
return 0;
}
当然,以上这些仅仅是最最基础的并查集,还有一些变式:
1.2 带权并查集
对于某些并查集,我们不仅需要处理各个元素,还需要对其某两个元素之间的连线所带的权值(边权)进行处理。(我认为应该可以听懂)
所以,我们需要一种带有边权的并查集,即带权并查集。(废话梅开二度)
1.2.1 带权并查集使用方法及例题分析
对于不同的题目,对其边权的处理也有所不同,举个栗子:
蒟蒻来教大家手切 NOI 啦 ![doge]
在此题中,我们需要处理某两个处于统一集合的元素的相对位置,但若直接求解难度大(路径压缩会改变并查集原有的形态),考虑将其简化:
考虑如上图的一个并查集,我们要求
x
x
x 与
y
y
y 的相对位置关系,肉眼观察得:两个元素之间间隔了
2
2
2 个元素。(可是计算机没有肉眼啊!)
由于路径压缩,并查集的形态势必会发生改变,但无论如何,我们都求得到某个元素与其祖先元素的相对位置关系。
那么,我们就可以简化问题了,即求出某个元素与祖先元素的相对位置关系即可。
那么,我们就需要在并查集模板的基础上增加 d e a p deap deap 数组和 s i z e size size 数组, d e a p deap deap 数组用于存储第 i i i 号元素到其祖先元素的相对位置,用于 s i z e size size 数组用于存储第 i i i 号元素所在集合的元素数量。
s i z e size size 数组的具体用处后面有介绍。
首先,是对 d e a p deap deap 数组和 s i z e size size 数组的初始化。
void make(int num){
for(int i=1;i<=30000;i++){ //依题意,30000列战舰都要初始化
fa[i]=i,deap[i]=0,size[i]=1; //每一个元素都是单独的一个集合,所以自己就是自己的祖先,相对位置为0,元素个数为0
}
}
接下来,解释本题的关键:如何更新 d e a p [ i ] deap[\ i\ ] deap[ i ] 里面的值呢?
很容易想到:当我们在进行 b u i l d build build 函数合并集合时,原先集合 M M M 的祖先 m m m 的父亲成了集合 N N N 的祖先 n n n 。自然,这时 d e a p [ m ] deap[\ m\ ] deap[ m ] 应该加上 s i z e [ n ] size[\ n\ ] size[ n ]。(题目所言, m m m 应该接在集合 N N N 的最后一个元素后面,虽然在并查集中并不是这样的一个结构)
void build(int x,int y){
if(find(x)!=find(y)){
deap[find(x)]+=size[find(y)]; //含义见上
size[yy]+=size[xx]; //合并集合后,新的集合的大小是原有两个集合的大小之和
fa[find(x)]=find(y);
}
}
可是,如果只有这样一点修改,是不行的:
假设有三个集合
A
,
B
,
C
A,B,C
A,B,C (元素里面的数字代表当前元素到其祖先元素的相对位置)
先将 A A A 集合塞给 B B B 集合。
按照我们修改的 b u i l d build build 函数,此时,原属于 A A A 集合的 a a a 元素塞进了 B B B 集合,同时 d e a p [ a ] + + deap[\ a\ ]++ deap[ a ]++ 。
再将 B B B 集合塞给 C C C 集合。
那么,按照我们所写的程序,并查集应该长这样。
但是,注意箭头所指的元素 a a a ,此时 d e a p [ a ] deap[\ a\ ] deap[ a ] 应该是 2 2 2 ,而不是 1 1 1 。
因为 b u i l d build build 函数里面的操作只能修改祖先元素,对于祖先元素下的子元素无法修改。
肿么办?
我们将邪恶的目光投向了 find
我们可以想:
一个元素到其祖先元素的相对位置=该元素在合并前到其祖先元素的相对位置+该元素的父亲到其祖先元素的相对位置
一个元素的父亲到其祖先元素的相对位置=该元素的父亲在合并前到其祖先元素的相对位置+该元素的父亲的父亲到其祖先元素的相对位置
一个元素的父亲的父亲到其祖先元素的相对位置=该元素的父亲的父亲在合并前到其祖先元素的相对位置+该元素的父亲的父亲的父亲到其祖先元素的相对位置
…
递归!
那么,我们自然就可以在 f i n d find find 函数里操作了。( f i n d find find 也是一个递归函数)
int find(int num){
if(fa[num]==num){
return num;
}
int roat=find(fa[num]); //先将结果保留
deap[num]+=deap[fa[num]]; //累加 deap ,原因见上
return fa[num]=roat;
}
这样,所有的元素到其祖先元素的相对位置我们都可以及时的更新到了。
下面是完整代码:
#include<cmath>
#include<cstdio>
int fa[30005];
int deap[30005];
int size[30005];
void make(){
for(int i=1;i<=30000;i++){
fa[i]=i,deap[i]=0,size[i]=1;
}
}
int find(int num){
if(fa[num]==num){
return num;
}
int roat=find(fa[num]);
deap[num]+=deap[fa[num]];
return fa[num]=roat;
}
void build(int x,int y){
int xx=find(x),yy=find(y);
if(xx!=yy){
fa[xx]=yy;
deap[xx]+=size[yy];
size[yy]+=size[xx];
}
} //三个有所修改的基本函数,上文已介绍
int main(){
int n,x,y;
char ch;
scanf("%d",&n);
make();
for(int i=1;i<=n;i++){
scanf("\n%c ",&ch); //输入特殊处理
if(ch=='M'){ //合并操作
scanf("%d%d",&x,&y);
build(x,y);
}else{ //查询操作
scanf("%d%d",&x,&y);
if(find(x)==find(y)){ //判断两个元素是否在同一集合内
printf("%d\n",abs(deap[x]-deap[y])-1); //由于我们不知道两个元素之间的先后顺序,所以采用绝对值
//注意,题目所求的是间隔了多少战舰,所以需要减1
}else{
printf("-1\n");
}
}
}
return 0;
}
1.3 扩展域并查集
我们知道,普通并查集里的元素都具有连通性、传递性的关系。比如:亲戚的亲戚是亲戚。
但是,某些关系不具有这种关系,比如:敌人的敌人是朋友,与同一个数不相等的两个数不一定相等。
这时,我们就需要使用扩展域并查集。
1.3.1 扩展域并查集基本使用方法
举个例子:
一共有 3 3 3 个人,其中,第 1 1 1 个人和第 2 2 2 个人是敌人,第 2 2 2 个人和第 3 3 3 个人是朋友。
如果我们直接将 [ 1 , 2 ] , [ 2 , 3 ] [\ 1,2\ ],[\ 2,3\ ] [ 1,2 ],[ 2,3 ] 塞到并查集里面,那么,我们就无法确定他们之间是朋友关系还是敌人关系。
这时,我们就可以开两倍空间大小的并查集,前一半空间处理朋友关系,后一半空间处理敌人关系。
所以,我们在处理数据时,若 [ a , b ] [\ a,b\ ] [ a,b ] 是一组朋友对,则应该直接塞入并查集,若 [ a , b ] [a,b] [a,b] 是一组敌人对,则应该将其中一个人与另一个人的虚拟敌人塞入并查集,即将 [ a , b + n ] [ a + n , b ] [\ a,b+n\ ][\ a+n,b\ ] [ a,b+n ][ a+n,b ] 塞入并查集。
在这个例子中,我们就不能直接将 [ 1 , 2 ] [1,2] [1,2] 塞进去,而是塞入 [ 1 , 2 + 3 ] , [ 1 + 3 , 2 ] [\ 1,2+3\ ],[\ 1+3,2\ ] [ 1,2+3 ],[ 1+3,2 ]。
为什么可以这么做呢?
考虑这样两个敌人对: [ a , b ] [ b , c ] [\ a,b\ ][\ b,c\ ] [ a,b ][ b,c ]
上文已说,我们应该将 [ a , b + n ] [ a + n , b ] [ b + n , c ] [ b , c + n ] [\ a,b+n\ ][\ a+n,b\ ][\ b+n,c\ ][\ b,c+n\ ] [ a,b+n ][ a+n,b ][ b+n,c ][ b,c+n ] 塞入并查集,现在,并查集应该长这样:
但是!
并查集中有一些虚拟的点,我们不需要管它。若将其删除,我们可以得到这样一个并查集:
这样,我们就自动的将
a
,
b
,
c
a,b,c
a,b,c 三个人进行了分类。
所以,遇到要处理不具有传递性关系的元素的集合的合并及查询时,扩展域并查集是不二选择。
1.3.2 例题分析
所以啥是 BOI
这题是一道扩展域并查集的裸题,主要注意一下团伙数量的计算。
团伙数量其实就是集合数量,集合数量就是祖先元素的数量。
所以,我们只需要统计祖先元素的数量即可。
#include<cstdio>
char x;
int fa[2005],n,m,y,z,ans;
bool flag[2005];
void make(){
for(int i=1;i<=n*2;i++){
fa[i]=i;
}
}
int find(int num){
if(fa[num]==num){
return num;
}
return fa[num]=find(fa[num]);
}
void build(int x,int y){
fa[find(x)]=find(y);
} //以上都是基本操作
int main(){
scanf("%d\n%d",&n,&m);
make();
for(int i=1;i<=m;i++){
scanf("\n%c %d %d",&x,&y,&z); //输入特殊,注意处理
if(x=='F'){ //建立朋友关系
build(y,z);
}else{ //建立敌人关系
build(y,z+n);
build(z,y+n);
}
}
for(int i=1;i<=n;i++){
if(flag[find(i)]==0){ //查找该元素的祖先是否被标记过
ans++; //未被标记,说明是一个新的集合里的元素,答案累加并标记
flag[find(i)]=1;
}
}
printf("%d",ans);
return 0;
}
2.1 树状数组
树状数组,顾名思义,长得像树的数组,用于处理一些单点修改以及区间查询的问题。其时间复杂度为
O
(
log
2
n
)
O(\log _2n)
O(log2n) 。如过我们使用一些一般的数据结构,那么一定会 TLE。这时,树状数组就可以帮助我们解决这些问题。
Tips:
树状数组和线段树具有相似的功能,但他俩毕竟还有一些区别:树状数组能有的操作,线段树一定有;线段树有的操作,树状数组不一定有。但是树状数组的代码要比线段树短,思维更清晰,速度也更快,在解决一些单点修改的问题时,树状数组是不二之选。——OI WiKi
在我正式介绍树状数组的三大基本操作之前,我们要先了解树状数组的内部结构及基本函数:
对于一个数组 A [ 6 ] = { 1 , 2 , 5 , 8 , 10 , 20 } A[\ 6\ ]=\left\{1,2,5,8,10,20\right\} A[ 6 ]={1,2,5,8,10,20} ,它所生成的树状数组为 B i t [ 6 ] = { 1 , 3 , 5 , 16 , 10 , 30 } Bit[\ 6\ ]=\left\{1,3,5,16,10,30\right\} Bit[ 6 ]={1,3,5,16,10,30} ,如上图所示。
那么,我们可以明显发现,对于 B i t [ i ] Bit[\ i\ ] Bit[ i ] 而言,它应该等于 a [ i ] + B i t [ k ] ( 1 ⩽ k < i ) a[\ i\ ]+Bit[\ k\ ](1 \leqslant k <i ) a[ i ]+Bit[ k ](1⩽k<i)。
那么,如何求得 i i i 呢?
这牵扯到树状数组的一个关键函数:
L
o
w
B
i
t
LowBit
LowBit 函数(烦诶,还能不能进正题!)
2.1.1 LowBit 函数
理解 LowBit 的含义:
L o w B i t [ i ] LowBit[\ i\ ] LowBit[ i ] 存储着 i i i 转化成二进制数之后,只保留最低位的 1 1 1 及其后面的 0 0 0 后,然后再转成十进制数后的书。(也就是树状数组中第 i i i 号位的子叶个数)(当然,在实际操作中,LowBit 往往以函数的形式出现)
那么,我们一般有两种方法求 LowBit:
int LowBit_1(int num){
return num&-num;
}
int LowBit_2(int num){
return num-(num&(num–1));
}
//我们在实际操作中往往使用第1种
下面,我来简单证明一下为什么第 1 1 1 种方法是可行的:
假设有一整数 N N N ,若我们直接将 N N N 按位取反得到 M M M 后,会有 N & M = 0 N \& M=0 N&M=0 。
但是,在二进制中, N N N 的相反数应该是 M + 1 M+1 M+1 那么答案就有所不同。
在假设 N N N 的最后一位 1 1 1 出现在第 p p p 位,一共有 q q q 位,那么 N N N 的第 p + 1 p+1 p+1 ,第 p + 2 p+2 p+2 位一直到第 q q q 位都应该是 0 0 0 。
易得: M M M 的第 p + 1 p+1 p+1 ,第 p + 2 p+2 p+2 位一直到第 q q q 位都应该是 1 1 1 ,而第 p p p 位是 0 0 0 。
因为有加 1 1 1 操作,所以,第 q q q 位就变为 2 2 2 ,向前进 1 1 1 ,第 q − 1 q-1 q−1 位就变为 2 2 2 ,向前进 1 1 1 …
如此相加,直到第 p p p 位变为 1 1 1 为止。
那么此时, M M M 的第 p + 1 p+1 p+1 ,第 p + 2 p+2 p+2 位一直到第 q q q 位都应该是 0 0 0 ,而第 p p p 位是 1 1 1 。
由于 N , M N,M N,M 的第 1 1 1 位到第 p − 1 p-1 p−1 位都未改变,所以只考虑第 p p p 位到第 q q q 位。
N N N 的第 p p p 位是 1 1 1 ,而第 p + 1 p+1 p+1 ,第 p + 2 p+2 p+2 位一直到第 q q q 位都是 0 0 0 。
M M M 的第 p p p 位是 1 1 1 ,而第 p + 1 p+1 p+1 ,第 p + 2 p+2 p+2 位一直到第 q q q 位都是 0 0 0 。
很明显,最终结果只有第 p p p 位是 1 1 1 ,而其余全部是 0 0 0 .
最终结果与假设相符,命题得证
为什么我看着像伪证?
有了 LowBit ,我们就可以进行其它操作了。
2.1.2 UpDate 函数
UpDate 就是将一个新元素 N N N 塞进 B i t Bit Bit 数组里的一个操作。
一般来讲,我们会在输入 A A A 数组时,通过 UpDate 初始化 B i t Bit Bit 数组,也会在修改 A A A 数组里面的值时,用 UpDate 来更新 B i t Bit Bit 数组
但是,由于树状数组的结构特殊,所以在处理时不能只修改 B i t [ i ] Bit[\ i\ ] Bit[ i ] 的值:
还是这个例子:假设我们要改变 A [ 1 ] A[\ 1\ ] A[ 1 ] 里的值,我们实际上要修改 B i t [ 1 ] , B i t [ 2 ] , B i t [ 4 ] Bit[\ 1\ ],Bit[\ 2\ ],Bit[\ 4\ ] Bit[ 1 ],Bit[ 2 ],Bit[ 4 ] 的值。
显然,对于 B i t [ i ] Bit[\ i\ ] Bit[ i ] ,它与 B i t [ i + L o w B i t ( i ) ] Bit[\ i+LowBit(\ i\ )\ ] Bit[ i+LowBit( i ) ] 之间有 1 1 1 条连线,自然,处理 B i t [ i ] Bit[\ i\ ] Bit[ i ] 时,我们也需要将 B i t [ i + L o w B i t ( i ) ] Bit[\ i+LowBit(\ i\ )\ ] Bit[ i+LowBit( i ) ] 处理了。
void UpDate(int num,int sum){
for(int i=num;i<=n;i+=LowBit(i)){
Bit[i]+=sum; //上文所述,与 Bit[i] 有关系的全部都要处理
}
}
2.1.3 Sum 函数
在实际操作中,往往需要求得区间和或某一点的值,一般情况下,我们会使用前缀和。但是,由于我们对 A A A 数组进行了修改,使用前缀和的时间复杂度会有所提升,这时,我们可以使用 Sum 函数来解决这一问题。
S u m ( i ) Sum(\ i\ ) Sum( i ) 用于求解 A [ 1 ] + A [ 2 ] + ⋯ + A [ i ] A[\ 1\ ]+A[\ 2\ ]+\cdots+A[\ i\ ] A[ 1 ]+A[ 2 ]+⋯+A[ i ] 的值。但是,我们可以观察下图,很明显, A [ 1 ] + A [ 2 ] + A [ 3 ] + A [ 4 ] = B i t [ 4 ] A[\ 1\ ]+A[\ 2\ ]+A[\ 3\ ]+A[\ 4\ ]=Bit[\ 4\ ] A[ 1 ]+A[ 2 ]+A[ 3 ]+A[ 4 ]=Bit[ 4 ] , A [ 5 ] + A [ 6 ] = B i t [ 6 ] A[\ 5\ ]+A[\ 6\ ]=Bit[\ 6\ ] A[ 5 ]+A[ 6 ]=Bit[ 6 ] 。而 L o w B i t ( 4 ) = 4 , L o w B i t ( 6 ) = 2 LowBit(\ 4\ )=4,LowBit(\ 6\ )=2 LowBit( 4 )=4,LowBit( 6 )=2,自然我们可以通过 LowBit 以及 B i t Bit Bit 数组来达到缩减时间复杂度的效果。
long long int Sum(int num){ //一般情况下,Sum 函数需要开 long long
long long int ans=0;
for(int i=num;i>=1;i-=LowBit(i)){
ans+=Bit[i]; //上文已提,可优化时间复杂度
}
return ans;
}
好的,三个基本函数已经具备了,我们可以欣赏三个基本操作了。
2.2 一维树状数组基本操作
接下来,我们将依次介绍单点修改,区间查询
,区间查询,单点修改
和区间修改,区间查询
三个基本操作
2.2.1 单点修改,区间查询
单点修改,区间查询应该是最简单的一个操作,直接套用三个函数即可,只是在进行区间查询时需要注意采用前缀和的思想,即 S u m ( r i g h t ) − S u m ( l e f t − 1 ) Sum(right)-Sum(left-1) Sum(right)−Sum(left−1)
这道题是就是一道裸题(英语老师:说了多少次了不能裸奔!)
#include<cstdio>
long long int a[500005],Bit[500005];
int n,m,x,y,z;
int Low_Bit(int num){
return num&-num;
}
void Up_Date(int num,int sum){
for(int i=num;i<=n;i+=Low_Bit(i)){
Bit[i]+=sum;
}
}
long long int Sum(int num){
long long int ans=0;
for(int i=num;i>=1;i-=Low_Bit(i)){
ans+=Bit[i];
}
return ans;
} //基本操作。注意 long long
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%lld",&a[i]);
Up_Date(i,a[i]); //对 Bit 进行初始化
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
if(x==1){ //修改操作
Up_Date(y,z);
}else{ //查询操作
printf("%lld\n",Sum(z)-Sum(y-1)); //前缀和求解
}
}
return 0;
}
2.2.2 区间修改,单点查询
如果抛开树状数组不看,我们一般用什么进行区间修改,单点查询呢?
…
是的,差分数组!
那么,我们需要简单回顾一下如何用差分数组进行区间修改,单点查询。
现有一数组 A [ 5 ] = { 1 , 2 , 3 , 4 , 5 } A[\ 5\ ]=\{1,2,3,4,5\} A[ 5 ]={1,2,3,4,5} ,其对应的差分数组为 P [ 5 ] = { 1 , 1 , 1 , 1 , 1 } P[\ 5\ ]=\{1,1,1,1,1\} P[ 5 ]={1,1,1,1,1} 。
现在,我们将区间 [ 2 , 4 ] [\ 2,4\ ] [ 2,4 ] 加上 1 1 1 那么我们在差分数组中,只需要 P [ 2 ] + = 1 , P [ 5 ] − = 1 P[\ 2\ ]+=1,P[\ 5\ ]-=1 P[ 2 ]+=1,P[ 5 ]−=1 即可。
我们都知道,差分数组的前缀和数组是原数组,当我们要将区间 [ l , r ] [\ l,r\ ] [ l,r ] 加上 x x x 时,我们需要将 P [ l ] P[\ l\ ] P[ l ] 加上 x x x ,这样,我们的数组从第 l l l 号元素开始都会增加 x x x ,同样,当我们将 P [ r ] P[\ r\ ] P[ r ] 减去 x x x 后,数组从第 r r r 号元素开始都会减去 x x x ,和前面的增加 x x x 刚好抵消,我们也i就达到了 P [ l ] P[\ l\ ] P[ l ] 加上 x x x 的效果。
那么,我们只需要将差分数组的操作塞进树状数组里就可以了。
裸题梅开二度
#include<cstdio>
long long int a[1000005],Bit[1000005],p[1000005];
long long int n,m,x,y,z;
long long int lowbit(long long int num){
return num&-num;
}
void update(long long int num,long long int sum){
for(int i=num;i<=n;i+=lowbit(i)){
Bit[i]+=sum;
}
}
long long int Sum(long long int num){
long long int ans=0;
for(int i=num;i>=1;i-=lowbit(i)){
ans+=Bit[i];
}
return ans;
} //基本操作
int main(){
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++){
scanf("%lld",&a[i]);
p[i]=a[i]-a[i-1]; //求得初始差分数组
update(i,p[i]); //塞入树状数组
}
for(int i=1;i<=m;i++){
scanf("%lld",&x);
if(x==1){ //更改操作
scanf("%lld%lld%lld",&x,&y,&z);
update(x,z); //修改 Bit[x]
update(y+1,-z); //修改 Bit[y+1]
}else{ //查询操作
scanf("%lld",&y);
printf("%lld\n",Sum(y)); //求差分数组的前缀和即可
}
}
return 0;
}
2.2.3 区间修改,区间查询
那么,对于区间修改
这一操作,我们依旧可以使用差分数组来完成,那么,我们如何进行区间查询操作呢?
设原数组为 A A A ,差分数组为 P P P ,那么,求区间 [ 1 , i ] [\ 1,i\ ] [ 1,i ] 的结果为:
S u m ( 1 , i ) = ∑ x = 1 i a [ x ] = ∑ x = 1 i ∑ y = 1 x P [ y ] = ∑ x = 1 i x × P [ i − x + 1 ] = i × ∑ x = 1 i P [ i ] − ∑ x = 1 i ( x − 1 ) × P [ x ] \begin{aligned}Sum(\ 1,i\ ) &=\sum_{x=1}^ia[\ x\ ]\\& = \sum_{x=1}^i\sum_{y=1}^xP[\ y\ ]\\ & =\sum_{x=1}^ix\times P[\ i-x+1\ ]\\&=i\times \sum_{x=1}^iP[\ i\ ]-\sum_{x=1}^i(x-1)\times P[\ x\ ]\end{aligned} Sum( 1,i )=x=1∑ia[ x ]=x=1∑iy=1∑xP[ y ]=x=1∑ix×P[ i−x+1 ]=i×x=1∑iP[ i ]−x=1∑i(x−1)×P[ x ]
观察减式两边,我们发现:左边式子的基本结构为 P [ i ] P[\ i\ ] P[ i ] ,而右边式子的基本结构为 i × P [ i + 1 ] i\times P[\ i+1\ ] i×P[ i+1 ] 。那么,我们就可以分别用两个树状数组进行维护。
在修改时,分别对两个树状数组进行更改,查询时,通过前缀和,套用上面的公式即可。
又双叒叕是道裸题
#include<cstdio>
long long int a[1000005],Bit[1000005],Bit_1[1000005],p[1000005];
long long int n,m,x,y,z;
long long int Low_Bit(long long int num){
return num&-num;
}
void Up_Date(long long int num,long long int sum){
for(int i=num;i<=n;i+=Low_Bit(i)){
Bit[i]+=sum;
Bit_1[i]+=sum*(num-1); //上文已提,分别维护 P[i] 和 P[i]*(i-1) 的值
}
}
long long int Sum(long long int num){
long long int ans=0;
for(int i=num;i>=1;i-=Low_Bit(i)){
ans+=Bit[i]*num-Bit_1[i]; //上文已提,套用公式即可
}
return ans;
}
int main(){
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++){
scanf("%lld",&a[i]);
p[i]=p[i-1]+a[i];
Up_Date(i,a[i]-a[i-1]); //塞入差分数组
}
for(int i=1;i<=m;i++){
scanf("%lld",&x);
if(x==1){ //修改
scanf("%lld%lld%lld",&x,&y,&z);
Up_Date(x,z);
Up_Date(y+1,-z); //按照差分思想进行修改
}else{ //查询
scanf("%lld%lld",&y,&z);
printf("%lld\n",Sum(z)-Sum(y-1)); //按照前缀和思想进行求解
}
}
return 0;
}
2.3 离散化
考虑到在用树状数组时,往往需要使用离散化,在这里简单介绍一下
离散化是程序设计中一个常用的技巧,它可以有效的降低时间复杂度。(其实就是哈希的一种)
有些数据本身很大,自身无法作为数组的下标保存对应的属性。如果这时只是需要这堆数据的相对属性,那么可以对其进行离散化处理。当数据只与它们之间的相对位置有关,而与具体是多少无关时,可以进行离散化。比如当数据个数很小,数据范围却很大时(超过1e9)就考虑离散化成更小的值,能够实现更多的算法。——PPT
举个例子:我们有数组 A [ 5 ] = { 114514 , 996 , 857 , 442759 , 2147483647 } A[\ 5\ ]=\{114514,996,857,442759,2147483647\} A[ 5 ]={114514,996,857,442759,2147483647} 。显然,如果要将 A [ i ] A[\ i\ ] A[ i ] 作为数组下标,显然是不行的。采用离散化,我们就可以将 A A A 数组改为 A [ 5 ] = { 3 , 2 , 1 , 4 , 5 } A[\ 5\ ]=\{3,2,1,4,5\} A[ 5 ]={3,2,1,4,5}
废话那么多,所以如何离散化怎么写呢?
2.3.1 结构体离散化
我们在输入数据时,可以通过结构体得到对应的下标。我们通过对数据进行从小到大的排序,会得到一个相关的下表的顺序,越靠前的下标所对应的数值越小,所以,在遍历时,我们也要将一个小一点的值给它。
#include<cstdio>
#include<algorithm>
using namespace std;
struct node{
int num,sum; //sum 存数值,num 存下标
}a[1005];
bool cmp(node a,node b){
return a.sum<b.sum; //按 sum 从小到大排序
}
int main(){
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i].sum);
a[i].num=i;
}
sort(a+1,a+1+n,cmp);
for(int i=1;i<=n;i++){
a[a[i].num].sum=i; //对对应的下标所对应的数值进行更改
}
for(int i=1;i<=n;i++){
printf("%d ",a[i].sum);
}
return 0;
}
运行结果如下:
2.3.2 STL+二分离散化(这个常用)
对于上面的离散化方法,有一点美中不足的地方:
如上图所示,有时候,我们希望它最后得到的结果是 1 , 1 , 2 , 3 , 3 1,1,2,3,3 1,1,2,3,3 ,怎么办呢?
那么,我们就需要两个函数: sort 和 lower_bound 函数
sort 函数大家都很熟悉,lower_bound 函数则用于查找有序序列中第一个大于或等于所要查找元素的位置
那么,我们就可以得到 STL 离散化的 0.1 版本
#include<cstdio>
#include<algorithm>
using namespace std;
int a[1005],b[1005];
int main(){
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
b[i]=a[i]; //复制一份原数组
}
sort(b+1,b+1+n); //排序,因为 lower_bound 必须在有序数列下进行
for(int i=1;i<=n;i++){
a[i]=lower_bound(b+1,b+1+n,a[i])-b; //查找该元素第一次出现的下标,赋值给原数组
}
for(int i=1;i<=n;i++){
printf("%d ",a[i]);
}
return 0;
}
程序结果:
很好,我们离想要的结果又近了一步。
观察样例,我们发现:对于数组中重复的元素,我们依旧给了它一个坑位占,所以接下来我们需要去重。
#include<cstdio>
#include<algorithm>
using namespace std;
int a[1005],b[1005];
int main(){
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
b[i]=a[i];
}
sort(b+1,b+1+n);
int len=unique(b+1,b+1+n)-b-1; //unique 是一个去重函数,同样要在排序之后才能使用
for(int i=1;i<=n;i++){
a[i]=lower_bound(b+1,b+1+len,a[i])-b; //由于去重后,数组大小减小了,需要注意
}
for(int i=1;i<=n;i++){
printf("%d ",a[i]);
}
return 0;
}
程序结果:
那么,我们就完成了离散化
2.4 一维树状数组运用
序列中每个数字不超过 1 0 9 10^9 109
自然,我们需要一个离散化
我们会得到一个离散化后的数组 B B B ,对于一个 B [ i ] B[\ i\ ] B[ i ] 我们可以将 B i t [ B [ 1 ] ] Bit[\ B[\ 1\ ]\ ] Bit[ B[ 1 ] ] 及其之后有关的元素全部加一,这时,因为下标比 i i i 小的数全部塞进了树状数组了,所以, S u m ( B [ i ] ) Sum(\ B[\ i\ ] \ ) Sum( B[ i ] ) 表示第一号元素到第 i i i 号与第 i i i 号元素所组成的数对中非逆序对的数量
显然,因为凡是比 B [ i ] B[\ i\ ] B[ i ] 小的而下标又比 i i i 小的都已经塞入了树状数组中,只需用 Sum 函数过一遍即可。
显然,有 i i i 表示第一号元素到第 i i i 号与第 i i i 号元素所组成的数对的数量,用总数对数量减去非逆序对对数的数量就有了逆序对的数量
#include<cstdio>
#include<algorithm>
using namespace std;
long long int a[500005],b[500005];
long long int Bit[500005];
long long int n,ans;
long long int Low_Bit(long long int num){
return num&-num;
}
void Up_Date(long long int num,long long int sum){
for(int i=num;i<=n;i+=Low_Bit(i)){
Bit[i]+=sum;
}
}
long long int Sum(long long int num){
long long int ans=0;
for(int i=num;i>=1;i-=Low_Bit(i)){
ans+=Bit[i];
}
return ans;
} //基本操作
int main(){
scanf("%lld",&n);
for(int i=1;i<=n;i++){
scanf("%lld",&a[i]);
b[i]=a[i];
}
sort(b+1,b+1+n);
for(int i=1;i<=n;i++){
a[i]=lower_bound(b+1,b+1+n,a[i])-b;
} //离散化,这里去重可有可无
for(int i=1;i<=n;i++){
Up_Date(a[i],1);
ans+=i-Sum(a[i]); //上文已提具体含义
}
printf("%lld",ans); //As we all know:不开 long long 见祖宗
return 0;
}
2.5 二维树状数组
二维树状数组和一维树状数组一样,同样有单点修改,区间查询
,区间查询,单点修改
和区间修改,区间查询
三个基本操作
2.5.1 单点修改,区间查询
二维树状数组的单点修改,区间查询和一维树状数组的单点修改,区间查询思想一致。
如果你忘了二维数组的前缀和…
总而言之,我们可以简单的概括一下:
若 P P P 数组代表二维数组 A A A 的前缀和,则有 P [ i ] [ j ] = P [ i − 1 ] [ j ] + P [ i ] [ j − 1 ] − P [ i − 1 ] [ j − 1 ] + A [ i ] [ j ] P[\ i\ ][\ j\ ]=P[\ i-1\ ][\ j\ ]+P[\ i\ ][\ j-1\ ]-P[\ i-1\ ][\ j-1\ ]+A[\ i\ ][\ j\ ] P[ i ][ j ]=P[ i−1 ][ j ]+P[ i ][ j−1 ]−P[ i−1 ][ j−1 ]+A[ i ][ j ]
如果要求左上角为 ( x 1 , y 1 ) (x_1,y_1) (x1,y1) ,右下角为 ( x 2 , y 2 ) (x_2,y_2) (x2,y2) 的矩阵的元素和,我们计算 P [ x 2 ] [ y 2 ] + P [ x 1 − 1 ] [ y 1 − 1 ] − P [ x 1 − 1 ] [ y 2 ] − P [ x 2 ] [ y 1 − 1 ] P[\ x_2\ ][\ y_2\ ]+P[\ x_1-1\ ][\ y_1-1\ ]-P[\ x_1-1\ ][\ y_2\ ]-P[\ x_2\ ][\ y_1-1\ ] P[ x2 ][ y2 ]+P[ x1−1 ][ y1−1 ]−P[ x1−1 ][ y2 ]−P[ x2 ][ y1−1 ] 即可。
那么,我们只需要将前缀和塞入树状数组里即可
裸题一个,代码奉上:
#include<cstdio>
int Bit[5005][5005];
int n,m,x,y,z,x1,y1,x2,y2;
int Low_Bit(int num){
return num&-num;
}
void Up_Date(int x,int y,int sum){
for(int i=x;i<=n;i+=Low_Bit(i)){
for(int j=y;j<=m;j+=Low_Bit(j)){
Bit[i][j]+=sum;
}
}
}
int Sum(int x,int y){
int ans=0;
for(int i=x;i>=1;i-=Low_Bit(i)){
for(int j=y;j>=1;j-=Low_Bit(j)){
ans+=Bit[i][j];
}
}
return ans;
} //因为是二维树状数组,所以 Up_Date 函数和 Sum 函数要改为二重循环
int main(){
scanf("%d%d",&n,&m);
while(scanf("%d",&x)!=EOF){
if(x==1){ //修改操作
scanf("%d%d%d",&x,&y,&z);
Up_Date(x,y,z);
}else{
scanf("%d%d%d%d",&x1,&y1,&x2,&y2);
printf("%d\n",Sum(x2,y2)+Sum(x1-1,y1-1)-Sum(x2,y1-1)-Sum(x1-1,y2)); //上文已提,前缀和思想
}
}
return 0;
}
2.5.2 区间修改,单点查询
跟一维树状数组一样,我们依旧要采用差分思想。
请看下面神奇的图片
设左上角的下标为 [ x 1 , y 1 ] [\ x_1,y_1\ ] [ x1,y1 ] ,右下角的下标为 [ x 2 , y 2 ] [\ x_2,y_2\ ] [ x2,y2 ]
我们要使红色区域的元素都加上
a
a
a ,那么,我们可以依葫芦画瓢的将红色区域的左上角
A
[
x
1
]
[
y
1
]
A[\ x_1\ ][\ y_1\ ]
A[ x1 ][ y1 ] 增加
a
a
a ,同样,为了可以抵消左上角所增加的
a
a
a ,我们需要在
A
[
x
1
]
[
y
2
+
1
]
A[\ x_1\ ][\ y_2+1\ ]
A[ x1 ][ y2+1 ] 以及
A
[
x
2
+
1
]
[
y
1
]
A[\ x_2+1\ ][\ y_1\ ]
A[ x2+1 ][ y1 ] 减去一个
a
a
a
考虑蓝色矩阵,我们多抵消了一个 a a a ,所以,我们在 A [ x 2 + 1 ] [ y 2 + 1 ] A[\ x_2+1\ ][\ y_2+1\ ] A[ x2+1 ][ y2+1 ] 再加上 a a a ,就完成了对二维数组的差分。
老觉得怪怪的,又说不出来……
那么,我们只需要往树状数组里塞入差分操作即可。
今天的裸题真多啊
#include<cstdio>
long long int a[10005],Bit[10005][10005];
long long int Low_Bit(long long int num){
return num&-num;
}
long long int n,m;
void Up_Date(long long int x,long long int y,long long int sum){
for(int i=x;i<=n;i+=Low_Bit(i)){
for(int j=y;j<=m;j+=Low_Bit(j)){
Bit[i][j]+=sum;
}
}
}
long long int Sum(long long int x,long long int y){
long long int ans=0;
for(int i=x;i>=1;i-=Low_Bit(i)){
for(int j=y;j>=1;j-=Low_Bit(j)){
ans+=Bit[i][j];
}
}
return ans;
}
long long int x,y,z,x1,y1,x2,y2,sum;
int main(){
scanf("%lld%lld",&n,&m);
while(scanf("%lld",&x)!=EOF){
if(x==1){
scanf("%lld%lld%lld%lld%lld",&x1,&y1,&x2,&y2,&sum);
Up_Date(x1,y1,sum);
Up_Date(x2+1,y2+1,sum);
Up_Date(x1,y2+1,-sum);
Up_Date(x2+1,y1,-sum);
}else{
scanf("%lld%lld",&x2,&y2);
printf("%lld\n",Sum(x2,y2)+Sum(0,0)-Sum(x2,0)-Sum(0,y2)); //前缀和思想
}
}
return 0;
}
2.5.3 区间修改,区间查询
与一维相类似,主要还是推公式
我们假设 a a a 数组为原数组, p p p 数组为差分数组
S u m ( x , y ) = ∑ i = 1 x ∑ j = 1 y a [ i ] [ j ] = ∑ i = 1 x ∑ j = 1 y ∑ k = 1 i ∑ h = 1 j p [ k ] [ h ] = ∑ i = 1 x ∑ j = 1 y p [ i ] [ j ] × ( x + 1 − i ) × ( y + 1 − j ) = ( x + 1 ) × ( y + 1 ) × ∑ i = 1 x ∑ j = 1 y p [ i ] [ j ] + ∑ i = 1 x ∑ j = 1 y p [ i ] [ j ] × i × j − ( x + 1 ) × ∑ i = 1 x ∑ j = 1 y p [ i ] [ j ] × j − ( y + 1 ) × ∑ i = 1 x ∑ j = 1 y p [ i ] [ j ] × i \begin{aligned}Sum(\ x,y\ )&=\sum_{i=1}^x\sum_{j=1}^ya[\ i\ ][\ j\ ]\\&=\sum_{i=1}^x\sum_{j=1}^y\sum_{k=1}^i\sum_{h=1}^jp[\ k\ ][\ h\ ]\\&=\sum_{i=1}^x\sum_{j=1}^yp[\ i\ ][\ j\ ]\times(x+1-i)\times(y+1-j)\\&=(x+1)\times(y+1)\times\sum_{i=1}^x\sum_{j=1}^yp[\ i\ ][\ j\ ]+\sum_{i=1}^x\sum_{j=1}^yp[\ i\ ][\ j\ ]\times i\times j-(x+1)\times\sum_{i=1}^x\sum_{j=1}^yp[\ i\ ][\ j\ ]\times j-(y+1)\times\sum_{i=1}^x\sum_{j=1}^yp[\ i\ ][\ j\ ]\times i\end{aligned} Sum( x,y )=i=1∑xj=1∑ya[ i ][ j ]=i=1∑xj=1∑yk=1∑ih=1∑jp[ k ][ h ]=i=1∑xj=1∑yp[ i ][ j ]×(x+1−i)×(y+1−j)=(x+1)×(y+1)×i=1∑xj=1∑yp[ i ][ j ]+i=1∑xj=1∑yp[ i ][ j ]×i×j−(x+1)×i=1∑xj=1∑yp[ i ][ j ]×j−(y+1)×i=1∑xj=1∑yp[ i ][ j ]×i
LaTeX
\LaTeX
LATEX 打得我心累
那么,我们就需要 4 4 4 个树状数组,分别维护 p [ i ] [ j ] , p [ i ] [ j ] × x , p [ i ] [ j ] × y , p [ i ] [ j ] × x × y p[\ i\ ][\ j\ ],p[\ i\ ][\ j\ ]\times x,p[\ i\ ][\ j\ ]\times y,p[\ i\ ][\ j\ ]\times x\times y p[ i ][ j ],p[ i ][ j ]×x,p[ i ][ j ]×y,p[ i ][ j ]×x×y
这应该是最后一道裸题了
#include<cstdio>
int read(){
int a=0,b=1;
char ch=getchar();
while(ch<'0'||ch>'9'){
if(ch=='-'){
b=-1;
}
ch=getchar();
}
while(ch>='0'&&ch<='9'){
a=a*10+ch-'0';
ch=getchar();
}
return a*b;
} //快读
int x1,y1,x2,y2,sum;
int n,m;
long long int Bit[2100][2100],Bit1[2100][2100],Bit2[2100][2100],Bit3[2100][2100];
int Low_Bit(int num){
return num&-num;
}
void Up_Date(int x,int y,int sum){
for(int i=x;i<=n;i+=Low_Bit(i)){
for(int j=y;j<=m;j+=Low_Bit(j)){
Bit[i][j]+=sum;
Bit1[i][j]+=sum*x;
Bit2[i][j]+=sum*y;
Bit3[i][j]+=sum*x*y;
} //上文已提,分别对4个值进行维护
}
}
long long int Sum(int x,int y){
long long int ans=0;
for(int i=x;i>=1;i-=Low_Bit(i)){
for(int j=y;j>=1;j-=Low_Bit(j)){
ans+=Bit[i][j]*(x+1)*(y+1)+Bit3[i][j]-Bit1[i][j]*(y+1)-Bit2[i][j]*(x+1);
} //上文已提,套公式
}
return ans;
}
int main(){
n=read(),m=read();
while(scanf("%d",&sum)!=EOF){
if(sum==1){
x1=read(),y1=read(),x2=read(),y2=read(),sum=read();
Up_Date(x1,y1,sum);
Up_Date(x2+1,y2+1,sum);
Up_Date(x1,y2+1,-sum);
Up_Date(x2+1,y1,-sum); //差分思想进行维护
}else{
x1=read(),y1=read(),x2=read(),y2=read();
printf("%lld\n",Sum(x2,y2)+Sum(x1-1,y1-1)-Sum(x2,y1-1)-Sum(x1-1,y2));
//上文已提,前缀和思想
}
}
return 0;
}
3.1 哈希
哈希,是一种神奇的数据结构。
其实就是手打 map
假设现有一数组 A A A ,你需要找到某个元素在数组中的位置,一般情况下,我们会采用线性筛,即用一层循环找到该元素,时间复杂度为 O ( n ) O(n) O(n)
当然,在 A A A 数组有序的情况下,我们可以采用二分法,快速的找到该元素,时间复杂度为 O ( log n ) O(\log n) O(logn)
那他要是无序呢?
那么,想要在无序的数组中快速找到某个元素,我们就可以运用哈希!
所谓哈希,其实就是将每一个元素通过一定方式(即哈希函数),映像到一个区间中,并以该元素在区间上的象
作为记录在表中的存储位置,这个表叫做哈希表。上述的整个过程叫哈希造表或散列,所得的存储位置叫哈哈希地址或散列地址。
当然,我们不能保证每一个元素的哈希地址绝对是唯一的。
例如,我们的哈希函数是 H a s h ( i ) = i ÷ 3 + 1 Hash(\ i\ )=i \div 3+1 Hash( i )=i÷3+1 那么,我们分别将 1 , 2 1,2 1,2 代入 i i i 中,我们会发现: H a s h ( 1 ) = 1 ÷ 3 + 1 = 1 Hash(\ 1\ )=1 \div 3+1=1 Hash( 1 )=1÷3+1=1 , H a s h ( 2 ) = 2 ÷ 3 + 1 = 1 Hash(\ 2\ )=2 \div 3+1=1 Hash( 2 )=2÷3+1=1
1 1 1 和 2 2 2 的哈希地址是相同的!
这时,我们就称这种情况为冲突
在哈希中,冲突不可避免,但是可以减少次数。
一般来说,冲突与如下方面有关:
- 装填因子 α \alpha α
- 哈希函数
- 解决冲突的办法
第二点和第三点很好理解,解释一下第一点:
装填因子是指哈希表中需要存入的元素个数 n n n 与哈希表的大小 m m m 的比值,即 α = n ÷ m \alpha=n \div m α=n÷m
显然,当 α \alpha α 越小,越不容易发生冲突;当 α \alpha α 越大,越容易发生冲突:
当然,如果 α \alpha α 过小,会导致空间的浪费,需要权衡。
接下来,我们思考一下哈希函数
3.2 哈希函数
哈希函数构造方法多样,下面介绍一些流行的和不流行的(神奇的废话文学)
3.2.1 直接定址法
直接定址法比较简单,直接欣赏一下其哈希函数: H a s h ( i ) = a i + b Hash(\ i\ )=ai+b Hash( i )=ai+b 或 H a s h ( i ) = i ÷ a + b Hash(\ i\ )=i\div a+b Hash( i )=i÷a+b
显然,在 i i i 无重复的情况下,我们是绝对可以避免冲突的发生的,然而,直接定址法在面对一些跨幅较大的数据时,浪费空间。
3.2.2 除后余数法
除后余数法比直接定址法更简单。对于一个长度为 m m m 的哈希表,其哈希函数: H a s h ( i ) = i m o d p ( p ⩽ m ) Hash(\ i\ )=i\bmod p(p\leqslant m) Hash( i )=imodp(p⩽m) 。
显然,如果
p
>
m
p>m
p>m 那么求得的
H
a
s
h
(
i
)
Hash(\ i\ )
Hash( i ) 可能大于
m
m
m ,进而导致数组越界。(RE达咩)
3.2.3 平方取中法
对于元素 i i i ,可以将 i 2 i^2 i2 中间几位作为哈希地址。(为啥子呢?)
假设 i = a b c d ‾ i=\overline{abcd} i=abcd
i 2 = a b c d ‾ 2 = ( 1000 a + 100 b + 10 c + d ) 2 = 1000000 a 2 + 100000 a b + 10000 a c + 1000 a d + 100000 a b + 10000 b 2 + 1000 b c + 100 b d + 10000 a c + 1000 b c + 100 c 2 + 10 c d + 1000 a d + 100 b d + 10 c d + d 2 = 1000000 a 2 + 100000 × ( 2 a b ) + 10000 × ( 2 a c + b 2 ) + 1000 × ( 2 a d + 2 b c ) + 100 × ( 2 b d + c 2 ) + 10 × ( 2 c d ) + d 2 i^2=\overline{abcd}^2=(1000a+100b+10c+d)^2=1000000a^2+100000ab+10000ac+1000ad+100000ab+10000b^2+1000bc+100bd+10000ac+1000bc+100c^2+10cd+1000ad+100bd+10cd+d^2=1000000a^2+100000\times(2ab)+10000\times(2ac+b^2)+1000\times(2ad+2bc)+100\times(2bd+c^2)+10\times(2cd)+d^2 i2=abcd2=(1000a+100b+10c+d)2=1000000a2+100000ab+10000ac+1000ad+100000ab+10000b2+1000bc+100bd+10000ac+1000bc+100c2+10cd+1000ad+100bd+10cd+d2=1000000a2+100000×(2ab)+10000×(2ac+b2)+1000×(2ad+2bc)+100×(2bd+c2)+10×(2cd)+d2
显然,中间的三都与 a , b , c , d a,b,c,d a,b,c,d 有关,唯一性高,自然可以用作哈希地址。
3.2.4 数字分析法
当元素 i i i 与元素 j j j 的部分数位上的数相同的频率较大时,我们可以将其它数位上的数摘下来作为哈希地址,即数字分析法。
举个例子:
第一位 第二位 第三位 第四位 第五位 第六位 1 1 4 5 1 4 1 1 5 0 4 8 1 1 4 4 1 2 1 1 4 9 9 6 1 1 5 2 3 5 1 1 5 1 0 0 \begin{array}{|c|c|c|c|c|c|}第一位&第二位&第三位&第四位&第五位&第六位\\1&1&4&5&1&4\\1&1&5&0&4&8\\1&1&4&4&1&2\\1&1&4&9&9&6\\1&1&5&2&3&5\\1&1&5&1&0&0\end{array} 第一位111111第二位111111第三位454455第四位504921第五位141930第六位482650
我们现在有六个数: 114514 , 115048 , 114412 , 114996 , 115100 114514,115048,114412,114996,115100 114514,115048,114412,114996,115100 ,如上表所示。我们发现:第一位和第二位都是 1 1 1 ,第三位要么是 4 4 4 要么是 5 5 5 ,而剩下的三位相对混乱,数字分布均匀,所以我们可以将其作为哈希地址,分别为 514 , 48 , 412 , 996 , 100 514,48,412,996,100 514,48,412,996,100 。
3.2.5 折叠法
我们将元素 i i i 按要求的长度分成位数相等的几段(最后一段可以略短),然后把各段重叠在一起相加,以所得的和作为地址。
适用于:每一位上各符号出现概率大致相同的情况。——PPT
不知道各位读者理不理解,反正我不理解为啥适用于【每一位上各符号出现概率大致相同的情况】,反正也不常用,理不理解倒也无所谓,如果有理解的也欢迎讨论!——蒟蒻
举个栗子(栗子:为啥老举我)
现在,我们有 i = 422759 i=422759 i=422759 ,那么,我们可以将其分为三段: 42 , 27 , 59 42,27,59 42,27,59 。将它们相加: 42 + 27 + 59 = 128 42+27+59=128 42+27+59=128 。我们就求到了 H a s h ( i ) = 128 Hash(\ i\ )=128 Hash( i )=128
3.2.5.1 间接叠加法
间接叠加法是折叠法的孪生兄弟,它在分段的基础上要将每一个奇数段进行反转。
在上面这个例子中, i i i 的哈希地址就应该是 H a s h ( i ) = 24 + 27 + 95 = 146 Hash(\ i\ )=24+27+95=146 Hash( i )=24+27+95=146 。
搞这么复杂,谁用啊
3.2.6 随机数法
将一个随机数作为 i i i 的哈希地址,即 H a s h ( i ) = r a n d ( i ) Hash(\ i\ )=rand(\ i\ ) Hash( i )=rand( i )
最简单,同时也很常用。
扩展小芝士:
我们在采用随机数法时,得到的数是伪随机数,是计算机通过算法算出来的!
我们在生成随机数时,首先,需要给计算机一个种子,计算机根据种子,通过算法,将其对应的伪随机数算出来。
注意:对于相同的种子,算出来的伪随机数一定是相同的!
但是,在实际操作中,我们不会手动输入种子,这时,我们需要一个随时变化的量作为种子,比如时间。
那么,我们就可以得到生成随机数的代码了:
#include<ctime> //打开time函数
#include<cstdio>
#include<cstdlib> //打开rand函数
int main(){
srand((unsigned)time(NULL)); //或者srand(time(0))
printf("%d",rand());
return 0;
}
那么,对于哈希函数的选取,我们一般从如下 5 5 5 个方面进行考虑:
- 计算哈希函数所需时间(包括硬件指令的因素)
- 关键字的长度
- 哈希表的大小
- 关键字的分布情况
- 记录的查找频率
对于不同的题目,哈希函数也有所不同,具体情况具体分析。
3.3 处理冲突
前文已提,冲突是不可避免的,有了冲突不可怕,可怕的是不会处理冲突
下面介绍一些处理冲突的方法
3.3.1 开放地址法
开放地址法就是寻找哈希表中未被占用的哈希地址,并让有冲突的元素塞进去
我们一般使用两种办法来寻找未被占用的哈希地址
3.3.1.1 线性探测法
线性探测法,顾名思义,我们以此遍历哈希表中的每一个哈希地址,直至找到未被占用的哈希地址,并让有冲突的元素塞进去
具体来说,设哈希函数为 H a s h ( i ) = i m o d m Hash(\ i\ )=i\bmod m Hash( i )=imodm 那么,我们对应的第 i i i 次计算所得的哈希地址为 H i = ( H a s h ( i ) + d i ) m o d m ( d i = i ) H_i=(Hash(\ i\ )+d_i)\bmod m\ (d_i=i) Hi=(Hash( i )+di)modm (di=i)
对于线性探测法,只要哈希表中还有空的哈希地址,那么,我们就一定找得到一个未被占用的哈希地址
但是,考虑如下一个最坏情况:
原本存在第 i i i 号哈希地址的元素因冲突存到了第 i + 1 i+1 i+1 号哈希地址,原本存在第 i + 1 i+1 i+1 号哈希地址的元素因冲突存到了第 i + 2 i+2 i+2 号哈希地址,原本存在第 i + 2 i+2 i+2 号哈希地址的元素因冲突存到了第 i + 3 i+3 i+3 号哈希地址……
这样下去,我们在查找时就大大的不方便(因为几乎所有的元素都是错位存放)!
所以,我们采用了新方法:二次探测法
3.3.1.2 二次探测法
二次探测法是一个小知识点,我们简单了解一下其计算公式:
设哈希函数为 H a s h ( i ) = i m o d m Hash(\ i\ )=i\bmod m Hash( i )=imodm 那么,我们对应的第 i i i 次计算所得的哈希地址为 H i = ( H a s h ( i ) + d i ) m o d m H_i=(Hash(\ i\ )+d_i)\bmod m Hi=(Hash( i )+di)modm ,其中, d i d_i di 分别表示 1 2 , − 1 2 , 2 2 , − 2 2 , ⋯ , j 2 , − j 2 ( j ≤ m ÷ 2 ) 1^2,-1^2,2^2,-2^2,\cdots,j^2,-j^2(j\le m\div 2) 12,−12,22,−22,⋯,j2,−j2(j≤m÷2)
其实就是左摸一下,右摸一下,摸到为止
3.3.2 链地址法
顾名思义,我们把每一个哈希地址都看作一个链结构,每当我们要塞入一个元素时,我们只需要将该元素塞入对应的链当中,也就不需要处理冲突了。
但是呢,因为我们不知道冲突发生的次数,即链表的一个具体长度,所以,除非使用 vector ,不然,很容易造成空间浪费
3.3.3 再哈希法
其实很简单,一种哈希算法不行,就换一种哈希算法接着搞,搞到一个未被占用的哈希地址为止
说实话,我觉得这种算法非常浪费时间(虽然很好想,但是很暴力)
3.3.4 公共溢出区法
当我们发现当前要塞入的元素与原有的元素产生了冲突,那么,我们就不把它塞进哈希表里,而是把它塞进一个公共溢出区里
这样,原哈希表里装着的就是“原住民”,而公共溢出区里装着的就是起了冲突的元素
3.4 字符串哈希
考虑到接下来的例题与字符串哈希有关,这里简单的介绍一下:
我们采用的是字母转化法(名字是我瞎编的)
简单来说,我们把每一个字母映射成一个数字,即 a → 1 , b → 2 , c → 3 , ⋯ , z → 26 a \rightarrow 1,b \rightarrow 2,c \rightarrow 3,\cdots,z\rightarrow 26 a→1,b→2,c→3,⋯,z→26
然后把他们转化成一个26进制的一个数字
严格来说,应该是按照26进制的计算方法将其转化成一个10进制数,因为两个不同的字符串具有唯一性,而最终算出来的结果也具有唯一性,即没有冲突的发生
那么,具体来说:我们有一个字符串 a b c abc abc ,按照如上的说法,他就应该等于 1 × 2 6 2 + 2 × 2 6 1 + 3 × 2 6 0 = 731 1\times 26^2+2\times 26^1+3\times 26^0=731 1×262+2×261+3×260=731 ,也就是说, a b c abc abc 被存储到了哈希表中的第 731 731 731 号哈希地址
还是 map 香
那么如果我们要求字符串当中字串的哈希值呢?
我们拿整数 123456 123456 123456 举个例子
我们要从 123456 123456 123456 节选出 456 456 456 ,我们可以用 123456 − 123 × 1000 123456-123\times1000 123456−123×1000 来达到这一目的
其中, 1000 1000 1000 的 0 0 0 的个数恰好是 456 456 456 的位数
由此启发,我们可以通过类似的方法,通过将一个大的子串的哈希值 − - − 不包含所求子串的子串的哈希值 × \times × 对应位数的进制就可以得到所求子串的哈希值了
3.5 小例题
模板,只需要判断字符串 a a a 长度为 s i z e ( b ) size(b) size(b) 的子串中,有多少个字串的哈希值与 b b b 的哈希值即可
所以在考场上那些人是怎么错的?
#include<cstdio>
#include<cstring>
char a[1000005],b[1000005];
long long int hash1[1000005],hash2[1000005],Pow[1000005],ans;
//hash1 存储 a 字符串的哈希值,hash2 存储 b 字符串的哈希值,Pow 存储进位(简省时间复杂度)
int main(){
scanf("%s%s",a+1,b+1);
int len1=strlen(a+1),len2=strlen(b+1);
for(int i=1;i<=len2;i++){
hash1[i]=hash1[i-1]*131+b[i]-'A';
}
Pow[0]=1;
for(int i=1;i<=len1;i++){
hash2[i]=hash2[i-1]*131+a[i]-'A';
Pow[i]=Pow[i-1]*131;
}
//以上分别初始化 a 字符串的哈希值, b 字符串的哈希值以及对应进制
for(int i=len2;i<=len1;i++){
if(hash1[len2]==hash2[i]-hash2[i-len2]*Pow[len2]){ //上文已提,判断哈希值是否相等
ans++;
}
}
printf("%d",ans);
return 0;
}
4.1 图论概念
图论是一个极为重要的知识点,我们需要一些篇幅去介绍它
本篇主要是一些基础知识
图的概念很多,听我细细道来
4.1.1 什么是图
截至目前,我们主要学了 3 3 3 种数据结构(不含图)
- 集合(变量应该算吧)
就是一群散点,元素与元素间没有什么联系
- 线性表(数组,队列,栈……)
如图,它们所组成的结构类似一条线,因此被称为线性结构
- 树形结构
如图,这种结构长的像颗树,因此被称为树形结构
好吧我也不知道为啥叫树形结构
那么,图应该是我们所学的第 4 4 4 种数据结构了
- 图
那么,我们现在要给图下一个定义了
很简单,点用边连起来就叫做图,严格意义上讲,图是一种数据结构,定义为 g r a p h = ( V , E ) graph=(\ V,E\ ) graph=( V,E ) . V V V 是一个非空优先集合,代表顶点(结点), E E E 代表边的集合。——《一本通》
图(Graph) 描述的是一些个体之间的关系。和线性表和二叉树不同的是:这些个体之间既不是前驱后继的顺序关系,也不是祖先后代的层次关系,而是错综复杂的网状关系。——《算法经典》
其实,我们只需要知道:
- 图描述的是各个元素之间的关系(废话)
- 图的关系很复杂
特殊的,线性表和树形结构也是一种图
4.1.2 图的分类
图分为三类:无向图,有向图和带权图
4.1.2.1 无向图
顾名思义,一个没方向的图,即边没有指定方向的图
举个栗子:在下图就有一张无向图
那么,对于无向图,下面有一些术语(一般来讲,xie’e的出题人会直接在题干中使用术语,请务必理解):
在无向图中,如果两个顶点之间有边连接,那么就视为两个顶点相邻。
举个栗子:在上图中,我们可以认为 1 1 1 号结点和 2 2 2 号结点相邻,但 1 1 1 号结点和第 3 3 3 号结点不相邻
那么,对于相邻顶点的序列,我们将其称为路径
举个栗子:在上图中,我们可以认为 1 − 2 − 3 − 4 1-2-3-4 1−2−3−4 是一条路径
特殊的:起点和终点重合的路径叫做圈(毕竟长的像)
举个栗子:在上图中,我们可以认为 1 − 2 − 3 − 4 − 1 1-2-3-4-1 1−2−3−4−1 是一个圈
对于一个单个顶点,该顶点连接的边数叫做该顶点的度
举个栗子:在上图中,我们可以认为 1 1 1 号结点的度为 2 2 2 ,而 2 2 2 号结点的度为 3 3 3
对于各种类型的图而言,任意两点之间都有路径连接的图称为连通图,反之,称为非连通图
举个栗子:上图就是连通图,而下图则是非连通图
这里,教一个装B的小技巧
我们可以将树称为没有圈的连通图,森林称为没有圈的非连通图
结合树的定义,应该好理解吧?
主要是我也不想写
4.1.2.2 有向图
顾名思义,一个有方向的图,即边有指定方向的图
特殊的:有向图中的边又称为弧,起点称为弧头,终点称为弧尾
那么,对于有向图,下面有一些术语:
在有向图中,边是单向的:每条边所连接的两个顶点之间的邻接性是单向的。
举个栗子:在上图中,我们可以认为 1 1 1 号结点和 2 2 2 号结点相邻 ,但 2 2 2 号结点不和 1 1 1 号结点相邻
那么,对于相邻顶点的序列,我们将其称为有向路径
举个栗子:在上图中,我们可以认为 1 − 2 − 3 − 4 1-2-3-4 1−2−3−4 是一条有向路径
特殊的:一条至少含有一条边且起点和终点相同的有向路径叫做有向环
举个栗子:在上图中,我们可以认为 2 − 3 − 4 − 2 2-3-4-2 2−3−4−2 是一条有向环
注意:为什么定义里会说至少含有一条边呢?路径不应该是两条起步吗?
对于一些聪(hun)明(zhang)出题人,可能会出现这样的图:
是的,自己连自己!
在这种情况下,我们也认为这是一个有向环
特殊的:我们把没有环的有向图称为有向无环图(DAG)
举个栗子:下图就是一个 DAG
Tips:在题目中,出题人可能会直接写 DAG ,而非有向无环图
在有向图中,度被分为了入度和出度
通俗来讲,一个顶点的入度指最终指向该顶点的边的数量,出度指从该顶点指出去的边的数量
举个栗子:在上图中,我们可以认为 2 2 2 号结点的入度为 1 1 1 ,而 出度为 2 2 2
4.1.2.3 带权图
带权图,指边上带有权值的图(不同问题中,权值意义不同,可以是距离、时间、价格、代价等)
下图就是一张带权图
关于带权图,暂时不需要介绍术语,掌握无向图和有向图的术语即可
4.2 图的存储方式
图的存储方式常见的有三种:邻接矩阵,邻接表和链式前向星
4.2.1 邻接矩阵
个人认为邻接矩阵是最简单的一种存储图的方式
对于有 n n n 个顶点的图,我们可以采用 f l a g [ n ] [ n ] flag[\ n\ ][\ n\ ] flag[ n ][ n ] 这样一个数组来表示它
具体含义:若 f l a g [ i ] [ j ] = 1 flag[\ i\ ][\ j\ ]=1 flag[ i ][ j ]=1 说明 i i i 和 j j j 之间有一条连线; 若 f l a g [ i ] [ j ] = 0 flag[\ i\ ][\ j\ ]=0 flag[ i ][ j ]=0 说明 i i i 和 j j j 之间没有一条连线
特殊的:在无向图中, f l a g [ i ] [ j ] = f l a g [ j ] [ i ] flag[\ i\ ][\ j\ ]=flag[\ j\ ][\ i\ ] flag[ i ][ j ]=flag[ j ][ i ]
举个栗子:
在上图中,如果我们用一张表来表示 f l a g flag flag 数组,应该是如下图所示:
[ NULL j = 1 j = 2 j = 3 i = 4 i = 1 0 1 0 1 i = 2 1 0 1 1 i = 3 0 1 0 1 i = 4 1 1 1 0 ] \begin{bmatrix}\operatorname{NULL}&j=1&j=2&j=3&i=4\\\ i=1&0&1&0&1\\\ i=2&1&0&1&1\\\ i=3&0&1&0&1\\\ i=4&1&1&1&0\end{bmatrix} ⎣ ⎡NULL i=1 i=2 i=3 i=4j=10101j=21011j=30101i=41110⎦ ⎤
同样,有向图和带权图也能用邻接矩阵来表示:
对于上图的有向图, f l a g flag flag 数组如下图所示:
[ NULL j = 1 j = 2 j = 3 j = 4 i = 1 0 1 0 1 i = 2 0 0 1 1 i = 3 0 0 0 1 i = 4 0 0 0 0 ] \begin{bmatrix}\operatorname{NULL}&j=1&j=2&j=3&j=4\\\ i=1&0&1&0&1\\\ i=2&0&0&1&1\\\ i=3&0&0&0&1\\\ i=4&0&0&0&0\end{bmatrix} ⎣ ⎡NULL i=1 i=2 i=3 i=4j=10000j=21000j=30100j=41110⎦ ⎤
在有向图的邻接矩阵中:顶点 i i i 的出度为:第 i i i 行所有非零元素的个数
显然,因为在有向图的邻接矩阵中, f l a g [ i ] [ j ] flag[\ i\ ][\ j\ ] flag[ i ][ j ] 表示以 i i i 为起点, j j j 为终点,是否存在一条边,则第 i i i 行所有非零元素的个数就代表有多少个点从第 i i i 号点出发的,即 i i i 号点的出度
同理可得:在有向图的邻接矩阵中:顶点 i i i 的入度为:第 i i i 列所有非零元素的个数
考虑一个带权图:
在上图中,如果我们用一张表来表示 f l a g flag flag 数组,应该是如下图所示:
[ NULL j = 1 j = 2 j = 3 j = 4 i = 1 ∞ 1 ∞ 4 i = 2 1 ∞ 2 5 i = 3 ∞ 2 ∞ 3 i = 4 4 5 3 ∞ ] \begin{bmatrix}\operatorname{NULL}&j=1&j=2&j=3&j=4\\\ i=1&\infty&1&\infty&4\\\ i=2&1&\infty&2&5\\\ i=3&\infty&2&\infty&3\\\ i=4&4&5&3&\infty\end{bmatrix} ⎣ ⎡NULL i=1 i=2 i=3 i=4j=1∞1∞4j=21∞25j=3∞2∞3j=4453∞⎦ ⎤
其中如果 f l a g [ i ] [ j ] = ∞ flag[\ i\ ][\ j\ ]=\infty flag[ i ][ j ]=∞ 就说明 i i i 号顶点和 j j j 号顶点间不存在一条边;反之,说明 i i i 号顶点和 j j j 号顶点间存在一条边,并用 f l a g [ i ] [ j ] flag[\ i\ ][\ j\ ] flag[ i ][ j ] 记录其边权
这是一道裸题
#include<cstdio>
#include<algorithm>
using namespace std;
bool a[2005][2005]; //邻接矩阵
int main(){
int n,m,x,y;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d",&x,&y);
a[x][y]=a[y][x]=1; //因为是无向图,所以需要双向存边
}
for(int i=1;i<=n;i++){ //一次枚举每个点
for(int j=1;j<=n;j++){
if(a[i][j]==1){ //判断是否有边相连
printf("%d ",j);
}
}
printf("\n");
}
return 0;
}
在用邻接矩阵存储图中,我们可以 O ( 1 ) O(1) O(1) 的时间复杂度判断 i i i 与 j j j 之间是否有边相连,但是,如果我们存储的是一个稀疏图(点多边少的图),就十分浪费空间,而且,在查找最短路中,也很浪费时间
所以,我们需要另外的方法存储图
4.2.2 邻接表
邻接表是最常用的存储图的方式,也是链式前向星的基础,相当重要
我们一般通过链表的方式(即使用不定长数组)实现邻接表
当然,由于我对结构体深沉的爱,我一般是通过结构体
以下有关邻接表的做法全部是通过结构体实现的,如果你不想看,Go to here
在结构体中 a [ i ] a[\ i\ ] a[ i ] 中,可以定义一个 l e n len len 变量和一个 s u m sum sum 数组,其中,用 s u m sum sum 数组来表示与第 i i i 号元素所相邻的元素, l e n len len 表示 s u m sum sum 数组的长度,即有多少个元素与第 i i i 号元素相邻
举个栗子:
如果我们要通过邻接表来存储上图,那么,实现结果应如下图所示:
a [ 1 ] . l e n = 2 , a [ 1 ] . s u m [ 2 ] = { 2 , 4 } a [ 2 ] . l e n = 3 , a [ 2 ] . s u m [ 3 ] = { 1 , 3 , 4 } a [ 3 ] . l e n = 2 , a [ 3 ] . s u m [ 2 ] = { 2 , 4 } a [ 4 ] . l e n = 3 , a [ 4 ] . s u m [ 3 ] = { 1 , 2 , 3 } a[\ 1\ ].len=2,a[\ 1\ ].sum[\ 2\ ]=\{2,4\}\\a[\ 2\ ].len=3,a[\ 2\ ].sum[\ 3\ ]=\{1,3,4\}\\a[\ 3\ ].len=2,a[\ 3\ ].sum[\ 2\ ]=\{2,4\}\\a[\ 4\ ].len=3,a[\ 4\ ].sum[\ 3\ ]=\{1,2,3\} a[ 1 ].len=2,a[ 1 ].sum[ 2 ]={2,4}a[ 2 ].len=3,a[ 2 ].sum[ 3 ]={1,3,4}a[ 3 ].len=2,a[ 3 ].sum[ 2 ]={2,4}a[ 4 ].len=3,a[ 4 ].sum[ 3 ]={1,2,3}
用邻接表存储有向图的方法与存储无向图的方法大同小异,不再赘述
那么,如果我们要存储一个带权图呢?
那么,我们就需要将
a
a
a 数组里的
s
u
m
sum
sum 数组定义成结构体数组(恐怕只有我这种天才才想得出结构体套结构体的做法吧?)
那么,在 s u m [ i ] sum[\ i\ ] sum[ i ] 中,我们包含了两个信息: s u m sum sum 和 n u m num num ,其中, n u m num num 表示当前元素的下标, s u m sum sum 表示该元素与第 i i i 号元素的权值
再举一个栗子:
如果我们要通过邻接表来存储上图,那么,实现结果应如下图所示:
a [ 1 ] . l e n = 2 , a [ 1 ] . s u m [ 2 ] . n u m = { 2 , 4 } , a [ 1 ] . s u m [ 2 ] . s u m = { 1 , 4 } a [ 2 ] . l e n = 3 , a [ 2 ] . s u m [ 3 ] . n u m = { 1 , 3 , 4 } , a [ 2 ] . s u m [ 3 ] . s u m = { 1 , 2 , 5 } a [ 3 ] . l e n = 2 , a [ 3 ] . s u m [ 2 ] . n u m = { 2 , 4 } , a [ 3 ] . s u m [ 2 ] . s u m = { 2 , 3 } a [ 4 ] . l e n = 3 , a [ 4 ] . s u m [ 3 ] . n u m = { 1 , 2 , 3 } , a [ 4 ] . s u m [ 3 ] . s u m = { 4 , 5 , 3 } a[\ 1\ ].len=2,a[\ 1\ ].sum[\ 2\ ].num=\{2,4\},\ \ \ \ a[\ 1\ ].sum[\ 2\ ].sum=\{1,4\}\\a[\ 2\ ].len=3,a[\ 2\ ].sum[\ 3\ ].num=\{1,3,4\},a[\ 2\ ].sum[\ 3\ ].sum=\{1,2,5\}\\a[\ 3\ ].len=2,a[\ 3\ ].sum[\ 2\ ].num=\{2,4\},\ \ \ \ a[\ 3\ ].sum[\ 2\ ].sum=\{2,3\}\\a[\ 4\ ].len=3,a[\ 4\ ].sum[\ 3\ ].num=\{1,2,3\},a[\ 4\ ].sum[\ 3\ ].sum=\{4,5,3\} a[ 1 ].len=2,a[ 1 ].sum[ 2 ].num={2,4}, a[ 1 ].sum[ 2 ].sum={1,4}a[ 2 ].len=3,a[ 2 ].sum[ 3 ].num={1,3,4},a[ 2 ].sum[ 3 ].sum={1,2,5}a[ 3 ].len=2,a[ 3 ].sum[ 2 ].num={2,4}, a[ 3 ].sum[ 2 ].sum={2,3}a[ 4 ].len=3,a[ 4 ].sum[ 3 ].num={1,2,3},a[ 4 ].sum[ 3 ].sum={4,5,3}
邻接点按照度数由小到大输出,如果度数相等,则按照编号有小到大输出。
右上,我们还需要在 a a a 数组中添加一个信息: s i z e size size ,表示度数
具体详情见代码:
#include<cstdio>
#include<algorithm>
using namespace std;
struct node{
int sum[1005],len,size;
}a[1005];
bool cmp(int x,int y){ //排序,以度数为第一关键字,数值为第二关键字
if(a[x].size!=a[y].size){
return a[x].size<a[y].size;
}
return x<y;
}
int main(){
int n,m,x,y;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d",&x,&y);
a[x].len++;
a[x].sum[a[x].len]=y; //处理新增的与第 i 号点连接的点
a[x].size++; //处理度数
a[y].len++;
a[y].sum[a[y].len]=x;
a[y].size++; //无向图,双向处理
}
for(int i=1;i<=n;i++){
sort(a[i].sum+1,a[i].sum+1+a[i].len,cmp); //排序
for(int j=1;j<=a[i].len;j++){
printf("%d ",a[i].sum[j]); //输出
}
printf("\n");
}
return 0;
}
4.2.3 链式前向星
上文已提,真正的邻接表是由不定长数组实现的,而链式前向星则是由数组
具体详情Go to here
其实我也不太懂链式前向星,不敢瞎BB
4.3 图的遍历
图的遍历有两种:DFS和BFS
如果就这还要详细讲解,你可以回家种田了
这里,在此给大家推荐一个强大的网址:
这是一个强大的网站,大家可以自行探索
我真的不是不想写!
那么,我们来看两道例题:
数据范围不大,可以采用邻接矩阵输入,操作起来也方便
注意:这里是有向图,不是无向图
#include<cstdio>
bool a[205][205];
bool flag[205];
int n,m,x,y;
void dfs(int num){
printf("%d ",num);
flag[num]=1; //标记为已遍历
for(int i=1;i<=n;i++){ //要求字典序最小,所以要按照顺序
if(a[num][i]==1&&flag[i]==0){ //如果两点之间有边连接且第 i 好点未被遍历
dfs(i); //遍历
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d",&x,&y);
a[x][y]=1; //建图
}
for(int i=1;i<=n;i++){ //要求字典序最小,所以要按照顺序
if(flag[i]==0){ //注意:这里不一定是连通图,所以,凡是还没有被遍历到的都要在进行一次DFS
dfs(i);
}
}
return 0;
}
#include<queue>
#include<cstdio>
using namespace std;
bool a[205][205];
bool flag[205];
int n,m,x,y;
void bfs(int num){
printf("%d ",num); //输出当前元素
flag[num]=1;
queue<int> q;
q.push(num);
while(!q.empty()){
int xx=q.front();
q.pop(); //以上为BFS基本操作
for(int i=1;i<=n;i++){
if(a[xx][i]&&!flag[i]){ //如果两点间有边连接且第 i 号点未被遍历
q.push(i);
printf("%d ",i);
flag[i]=1; //标记,输出,压队列
}
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d",&x,&y);
a[x][y]=1; //采用邻接矩阵建图
}
for(int i=1;i<=n;i++){ //与上题类似
if(flag[i]==0){ //与上题相类似,要考虑非连通图
bfs(i);
}
}
return 0;
}
栗子:你在这篇文章中一共举了我
14
14
14 次,你礼貌吗?
5.1 最短路
最短路应该是图论中非常经典的一个知识点了
那么,最短路是什么呢?
考虑下面这个无向图:
如果我们要求第 1 1 1 号结点到第 3 3 3 结点的最短距离,那么,我们就可以使用最短路算法
方法很多,我们依次了解
5.2 Floyd 算法
Flotd 算法应该是唯一一个可以处理多源最短路的的算法了
Floyd 本质上其实是一个 DP,那么,自然就有状态及其状态转移方程
定义状态:
d p [ i ] [ j ] dp[\ i\ ][\ j\ ] dp[ i ][ j ] 表示从第 i i i 号点到第 j j j 号点的最短路,那么,我们实际上是很容易想到状态转移方程式:
d p [ i ] [ j ] = min ( d p [ i ] [ j ] , d p [ i ] [ k ] + d p [ k ] [ j ] ) ( i ≤ k ≤ j ) dp[\ i\ ][\ j\ ]=\min(dp[\ i \ ][\ j\ ],dp[\ i\ ][\ k\ ]+dp[\ k\ ][\ j\ ])(i\le k\le j) dp[ i ][ j ]=min(dp[ i ][ j ],dp[ i ][ k ]+dp[ k ][ j ])(i≤k≤j)
初始化也是非常简单的:
d p [ i ] [ j ] = { 0 i = j ∞ i ≠ j and i 与 j 之间无连线 a [ i ] [ j ] i ≠ j and i 与 j 之间有连线 dp[\ i\ ][\ j\ ]=\begin{cases}0&i=j\\\infty&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间无连线}\\a[\ i\ ][\ j\ ]&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间有连线}\end{cases} dp[ i ][ j ]=⎩ ⎨ ⎧0∞a[ i ][ j ]i=ji=j and i 与 j 之间无连线i=j and i 与 j 之间有连线
那么,我们似乎可以很轻松的完成代码了
我们可以轻松的打出代码
#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int k=1;k<=n;k++){
a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
}
}
} //上文已提,套公式
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j){
a[i][j]=0;
}else{
a[i][j]=0x3f3f3f3f;
}
} //上文已提,初始化
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=z; //建图
}
Floyd();
printf("%d",a[Start][End]); //输出
return 0;
}
然后,我们轻松的将代码交给了评测姬
听取WA声一片
实际上,我们的状态定义是少了一维的(与背包类似)
那么,完整的状态长什么样?
定义状态:
d p [ k ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 表示经过前 k k k 个点(包含第 k k k 个点且不要求全部经过),从第 i i i 个点到达第 j j j 个点的最短路
那么,在讨论 d p [ k ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 时,我们有两种决策,一是不经过第 k k k 个点,二是经过第 k k k 个点
如果不经过第 k k k 个点,就有 d p [ k ] [ i ] [ j ] = d p [ k − 1 ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k−1 ][ i ][ j ]
如果经过第 k k k 个点,就有 d p [ k ] [ i ] [ j ] = d p [ k − 1 ] [ i ] [ k ] + d p [ k − 1 ] [ k ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k−1 ][ i ][ k ]+dp[ k−1 ][ k ][ j ]
综合一下:
d p [ k ] [ i ] [ j ] = min ( d p [ k − 1 ] [ i ] [ j ] , d p [ k − 1 ] [ i ] [ k ] + d p [ k − 1 ] [ k ] [ j ] ) ( i ≤ k ≤ j ) dp[\ k\ ][\ i\ ][\ j\ ]=\min(dp[\ k-1\ ][\ i \ ][\ j\ ],dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ])(i\le k\le j) dp[ k ][ i ][ j ]=min(dp[ k−1 ][ i ][ j ],dp[ k−1 ][ i ][ k ]+dp[ k−1 ][ k ][ j ])(i≤k≤j)
我们发现:第 k k k 维的状态全部都由第 k − 1 k-1 k−1 维的状态转移得到的
自然,我们可以舍去这一维,简化状态转移方程式
但是,因为进行了化简,所以要注意顺序,因为 k k k 原先是第一维,所以应该先遍历 k k k
那么,我们就有了正确的 Floyd 算法
#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int k=1;k<=n;k++){
a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
}
}
} //上文已提,套公式
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j){
a[i][j]=0;
}else{
a[i][j]=0x3f3f3f3f;
}
} //上文已提,初始化
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=z; //建图
}
Floyd();
printf("%d",a[Start][End]); //输出
return 0;
}
但是,由于 Floyd 算法时间复杂度极高,达到了 O ( n 3 ) O(n^3) O(n3) ,所以