简单の暑假总结——最终版本(知识点)

以下是知识点部分

写到后面越写越水

全文约 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 例题分析

Eg1_【模板】并查集

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 带权并查集使用方法及例题分析

对于不同的题目,对其边权的处理也有所不同,举个栗子:

Eg_2 [NOI2002] 银河英雄传说

蒟蒻来教大家手切 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 例题分析

Eg_3 [BOI2003]团伙

所以啥是 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 ](1k<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 q1 位就变为 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 p1 位都未改变,所以只考虑第 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(left1)

Eg_1 【模板】树状数组 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 的效果。

那么,我们只需要将差分数组的操作塞进树状数组里就可以了。

Eg_2 【模板】树状数组 2

裸题梅开二度

#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=1ia[ x ]=x=1iy=1xP[ y ]=x=1ix×P[ ix+1 ]=i×x=1iP[ i ]x=1i(x1)×P[ x ]

观察减式两边,我们发现:左边式子的基本结构为 P [   i   ] P[\ i\ ] P[ i ] ,而右边式子的基本结构为 i × P [   i + 1   ] i\times P[\ i+1\ ] i×P[ i+1 ] 。那么,我们就可以分别用两个树状数组进行维护。

在修改时,分别对两个树状数组进行更改,查询时,通过前缀和,套用上面的公式即可。

Eg_3 区间修改,区间查询

又双叒叕是道裸题

#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 一维树状数组运用

Eg_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 单点修改,区间查询

二维树状数组的单点修改,区间查询和一维树状数组的单点修改,区间查询思想一致。

如果你忘了二维数组的前缀和…

Practice [HNOI2003]激光炸弹

总而言之,我们可以简单的概括一下:

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[ i1 ][ j ]+P[ i ][ j1 ]P[ i1 ][ j1 ]+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[ x11 ][ y11 ]P[ x11 ][ y2 ]P[ x2 ][ y11 ] 即可。

那么,我们只需要将前缀和塞入树状数组里即可

Eg_4 二维树状数组 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 ,就完成了对二维数组的差分。

老觉得怪怪的,又说不出来……

那么,我们只需要往树状数组里塞入差分操作即可。

Eg_5 二维树状数组 2:区间修改,单点查询

今天的裸题真多啊

#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=1xj=1ya[ i ][ j ]=i=1xj=1yk=1ih=1jp[ k ][ h ]=i=1xj=1yp[ i ][ j ]×(x+1i)×(y+1j)=(x+1)×(y+1)×i=1xj=1yp[ i ][ j ]+i=1xj=1yp[ i ][ j ]×i×j(x+1)×i=1xj=1yp[ i ][ j ]×j(y+1)×i=1xj=1yp[ 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

Eg_6 二维树状数组 3:区间修改,区间查询

这应该是最后一道裸题了

#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 的哈希地址是相同的!

这时,我们就称这种情况为冲突

在哈希中,冲突不可避免,但是可以减少次数

一般来说,冲突与如下方面有关:

  1. 装填因子 α \alpha α
  2. 哈希函数
  3. 解决冲突的办法

第二点和第三点很好理解,解释一下第一点:

装填因子是指哈希表中需要存入的元素个数 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(pm)

显然,如果 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 个方面进行考虑:

  1. 计算哈希函数所需时间(包括硬件指令的因素)
  2. 关键字的长度
  3. 哈希表的大小
  4. 关键字的分布情况
  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(jm÷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 a1,b2,c3,,z26

然后把他们转化成一个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 123456123×1000 来达到这一目的

其中, 1000 1000 1000 0 0 0 的个数恰好是 456 456 456 的位数

由此启发,我们可以通过类似的方法,通过将一个大的子串的哈希值 − - 不包含所求子串的子串的哈希值 × \times × 对应位数的进制就可以得到所求子串的哈希值了

3.5 小例题

Eg 子串查找

模板,只需要判断字符串 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 种数据结构(不含图)

  1. 集合(变量应该算吧)

在这里插入图片描述

就是一群散点,元素与元素间没有什么联系

  1. 线性表(数组,队列,栈……)

在这里插入图片描述

如图,它们所组成的结构类似一条线,因此被称为线性结构

  1. 树形结构

在这里插入图片描述

如图,这种结构长的像颗树,因此被称为树形结构

好吧我也不知道为啥叫树形结构

那么,图应该是我们所学的第 4 4 4 种数据结构了

在这里插入图片描述

那么,我们现在要给图下一个定义了

很简单,点用边连起来就叫做图,严格意义上讲,图是一种数据结构,定义为 g r a p h = (   V , E   ) graph=(\ V,E\ ) graph=( V,E ) . V V V 是一个非空优先集合,代表顶点(结点), E E E 代表边的集合。——《一本通》

图(Graph) 描述的是一些个体之间的关系。和线性表和二叉树不同的是:这些个体之间既不是前驱后继的顺序关系,也不是祖先后代的层次关系,而是错综复杂的网状关系。——《算法经典》

其实,我们只需要知道:

  1. 图描述的是各个元素之间的关系(废话)
  2. 图的关系很复杂

特殊的,线性表和树形结构也是一种图

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 1234 是一条路径

特殊的:起点和终点重合的路径叫做(毕竟长的像)

举个栗子:在上图中,我们可以认为 1 − 2 − 3 − 4 − 1 1-2-3-4-1 12341 是一个圈

对于一个单个顶点,该顶点连接的边数叫做该顶点的

举个栗子:在上图中,我们可以认为 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 1234 是一条有向路径

特殊的:一条至少含有一条边且起点和终点相同的有向路径叫做有向环

举个栗子:在上图中,我们可以认为 2 − 3 − 4 − 2 2-3-4-2 2342 是一条有向环

注意:为什么定义里会说至少含有一条边呢?路径不应该是两条起步吗?

对于一些聪(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=114j=2125j=323j=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 ] 记录其边权

Eg_1 邻接矩阵存储图

这是一道裸题

#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}

Eg_2 邻接表存储图

邻接点按照度数由小到大输出,如果度数相等,则按照编号有小到大输出。

右上,我们还需要在 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

如果就这还要详细讲解,你可以回家种田了

这里,在此给大家推荐一个强大的网址:

程序可视化

这是一个强大的网站,大家可以自行探索

我真的不是不想写!

那么,我们来看两道例题:

Eg_3 有向图的DFS

数据范围不大,可以采用邻接矩阵输入,操作起来也方便

注意:这里是有向图,不是无向图

#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;
}

Eg_4 有向图的BFS

#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 ])(ikj)

初始化也是非常简单的:

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 ]= 0a[ i ][ j ]i=ji=j and i  j 之间无连线i=j and i  j 之间有连线

那么,我们似乎可以很轻松的完成代码了

Eg_1 非负权单源最短路

我们可以轻松的打出代码

#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[ k1 ][ 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[ k1 ][ i ][ k ]+dp[ k1 ][ 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[ k1 ][ i ][ j ],dp[ k1 ][ i ][ k ]+dp[ k1 ][ k ][ j ])(ikj)

我们发现: k k k 维的状态全部都由第 k − 1 k-1 k1 维的状态转移得到的

自然,我们可以舍去这一维,简化状态转移方程式

但是,因为进行了化简,所以要注意顺序,因为 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) ,所以

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值