目录
深度优先搜索DFS
搜索算法是利用计算机的高性能,来有目的的穷举一个问题解空间(所有可能的组合)的部分或所有的可能情况,从而求出问题的解的一种方法。在我们遇到的一些问题当中,有些问题不能够确切的找出数学模型,即找不出一种直接求解的方法,解决这一类问题,我们一般采用搜索解决。搜索就是用所有可能去尝试,按照一定的顺序、规则,不断去试探,直到找到问题的解,如果最终也没有找到解,那就是无解。所以搜索本质是一种++++暴力枚举++++ ,遍历所有可能的情况,求得其中的最优解。如果真的能够遍历所有情况,得出的解一定是最优的,只是时间复杂度往往是指数级的,只能处理小规模的数据。Depth First Search英文的缩写,翻译过来就是"深度优先搜索"。
走不通就退回再走的回溯法,这就是 DFS 的核心思想,不撞南墙不回头。常见的走迷宫问题,我们知道如果一直沿着右边或者左边走,就一定能够找到一条合法的路径,这其中用到的就是 DFSDFS 的思想。
DFS也常用于解决树和图上的问题。
树上的DFS演示:
DFS的复杂度
DFSDFS 的时间复杂度差别很大,假如搜索的状态有限,自然可以通过记忆化搜索来解决,假如没有有效的控制状态数量的方法,那么DFS的复杂度往往是指数级的。在某些问题上,我们只要求出1个有效解。那么假如有效解很多,按照平摊来算,找到一个解所需的时间大概是O(SC),其中S为整个解空间的大小,C为解的数量。我们在写DFS程序时,经常直接采用递归来处理,看起来好像没有额外的占用空间,但实际上,每次一函数调用,系统都会进行压栈处理。因此也是要占用内存空间的。如果递归的层数过深,会出现Stack overflow的错误。递归的深度,实际就是递归树的高度,按照之前求解斐波那契数列的递归来看,树的高度为n,这就是那个递归程序的空间复杂度。
DFS与递归
递归与暴力枚举
在之前的暴力枚举章节,我们学习过如何使用循环来枚举所有可能。但更多情况下,枚举所有可能性,需要依靠DFS来实现。
用递归过程定义的函数,称为递归函数,例如连加、连乘及阶乘等。凡是递归的函数,都是可计算的。
递归函数的格式
函数不在递归地情况称作基本情形(base case,也称基本情况)。函数调用自身来执行子任务的情况就称作递归情形(recursive caserecursive case)。
if(判断是否为基本情况)
return 该基本情况时的函数值;
else if(判断是否为另一种基本情况)
return 另一种基本情况时的函数值;
......
Else
return 执行操作并进行递归调用;
递归树
递归的执行的过程,都可以画成一棵树。这就是我们所说的递归树。下面我们以计算斐波那契为例,看一下递归树的样子:
设f(x)为斐波那契第x项的值
基本情形:f(1)=1,f(2)=1递归情形:f(i)=f(i−1)+f(i−2)
int fib(int x){
if(x == 1)
return 1;
else if(x == 2)
return 1;
else
return fib(x - 1) + fib(x - 2);
}
上面这个计算斐波那契数列算法的复杂度为O(fib(x)),即使只是计算fib(50)也要花费很长时间。可以看到,其中节点2出现了3次,也就是说fib(2)的值被重复计算了3次。因为有着大量的重复计算,所以算法效率很低。
这棵递归树中有多少个点,就表示递归函数被执行了多少次。
DFS与栈
当递归深度太深的时候,我们需要改写程序,以便能够正常运行。所有递归的调用,都可以通过我们之前学习过的栈来模拟。
先将计算的部分压入栈。
每次弹出栈顶元素,进行操作处理,再将需要递归处理的部分(recursive caserecursive case)压入栈。
重复(2),直到栈为空。这是因为函数调用本身,在系统中也是通过一个类似栈的东西来实现的。
下面给出作为对比的伪代码:
cpp
//递归版本
void Dfs(int index, int sum){
if(index == 10)
return;
Dfs(index+1, sum);
Dfs(index+1, sum + a[index]);
}
void main(){
Dfs(0,0);
}//栈版本
struct {
int index;
int sum;
} item;
void stack(){ //node 包括子段
stack<item> stack;
item s;
stack.push(s);
while(stack.size() > 0) {
item c = stack.pop();
if(c.index <= 10) {
item c1,c2;
c1.index = c.index + 1;
c1.sum = c.sum;
stack.push(c1);
c2.index = c.index + 1;
c2.sum = c.sum + a[c.index];
stack.push(c2);
}
}
}
DFS的搜索剪枝
FS的搜索剪枝
搜索剪枝与优化
在利用DFS求解的过程,有时要遍历所有的解空间,如果我们在明确知道不会丢解的情况下,跳过某些搜索范围,则可以大大提高搜索的效率。这个方法就叫做搜索剪枝。作为信息学中最重要的骗分技巧,搜索和剪枝是最基础的生存技能。
可行性剪枝
在明知沿着当前的搜索分支继续下去,不会有解的时候,我们可以提前回溯。这种剪枝我们在之前学习8皇后问题时已经使用过的,即当前棋子已经没有地方可放。
最优化剪枝
某些问题并不只满足于求出一个解,还要求是最优解,这种情况下,如果沿着当前的搜索分支继续下去,不会有更好的解的时候,我们可以提前回溯(return)。
那么这个提前回溯的条件如何定制呢?可以是一个固定的条件,例如:我们提前已经算好的值,超过这个界限就不再处理了。也可以是一个动态的条件,例如:我在之前的搜索过程中,得到的最优的解。如果当前搜索分支不能提供比之前更好的解,则不再继续。
从剪枝效果来看,方法2,应该会更好。
减少等效的分支
等效的分支是指有多种搜索的分支,对应同样的结果,这种情况下,只选择一个进行搜索即可。还是以上面这个分组的题目为例,假如n个数中,有不少相同的数字,假设数字k出现了4次,按照上面的递归来看,那么会枚举24=16种选择方式,但本质只有5种不同的选择,即一个都不选到选4个。还有一种等效的分支是这样的,假如我们要将n个数分为3组。如果完全枚举所有分组情况,复杂度为O(3n)。但其中每种情况,实际上会被枚举6次。即1,2,3的全排列数量,这种情况下,我们可以设定一些搜索规则,例如:如果前面某组未分配任何数字,则当前组不能分配数字,可以让搜索更为高效。以上面分为2组的题为例,我们可以强制第一个数必须选。
优化搜索顺序
搜索顺序是比较玄学的东西,所有针对搜索顺序的优化,都是希望能够最先找到最优解,然后就可以结束搜索。
搜索的记忆化
在动态规划初步中,我们曾讲过记忆化搜索。如果在搜索过程中某些值重复计算多遍,可以用数组把这个值存下来,下次递归的时候直接返回之前的计算结果。在未来学习树和图知识时,我们经常会对点添加访问标记,这样同一个点不会被重复处理多次。这使得搜索的复杂度真正有了保障。即从指数复杂度变为了多项式复杂度。
搜索的复杂度
大多时候,搜索的复杂度都是指数级的。各种剪枝方案,可以极大的提升搜索算法的效率,但却不容易真正改变算法的复杂度。唯有记忆化,可以真正的让一个指数复杂度的算法,转为多项式复杂度。而搜索的范围也从全部解空间,转为了状态数。
广度优先搜索BFS
什么是BFS
我们之前接触到的搜索,只有DFS一种,但实际上搜索算法有各种各样的思路,除DFS之外,还包括:
- BFS广度优先搜索
- A∗(一种启发式搜索)
- 双向搜索
- 迭代加深搜索
其中除DFS之外,应用最为广泛的是BFS,也是本章讲解的重点。BFS其英文全称是Breadth First Search,就是广度优先搜索,是一个逐层遍历的过程,BFS的过程一般是从一个点出发,将当前节点所能到达的所有节点标记,并从这些节点分别出发,将这些节点能到的节点继续标记,循环这个过程直到所有的节点被访问。同样是迷宫问题,BFS常被用来求解最短的路径,也就是迷宫的最优解。
BFS的过程
BFS的时间复杂度与DFS并无不同。但在一些求最优的问题上,BFS可以做到第一个出现的解,就是最优的。因此两种搜索适用的场景不太相同。BFS的空间复杂度一般较高,他是由每个层次包含的搜索空间数量的最大值决定的。从递归树来看,一般是最后一层叶子结点的数量。
搜索问题总结
DFS的访问顺序就是沿着一条路走到不能走,再回到上一步,向另一个没有走过的方向继续走,不断重复这个过程,直到所有节点被访问。而BFS的搜索过程为从一个点开始进行层次遍历,在一个层次内的点将被一同访问到。所以DFS和BFS的访问顺序是完全不同的,访问顺序的不同决定了两者被使用的情景。DFS(深度优先算法)适合目标比较明确,以找到目标为主要目的的情况。比如寻找一个问题的某一个合法解。BFS(广度优先算法)适合在不断扩大遍历范围时找到相对最优解的情况。比如寻找一个问题的最优合法解等情景。
|-------|-------------|--------------|
| | DFS | BFS |
| 访问顺序 | 一直到叶子节点才返回 | 先近后远 |
| 空间复杂度 | 递归深度 | 一个层次中包括的节点数量 |
| 适用情况 | 找到1个合法解 | |
桶排序
算法描述
- 将数组比作"桶",数组下标为桶号,若待排序列中数字的最大值为max,则创建max个桶。
用数组拟桶a[max+1]并初始化为0.
- 将待排序的值n,存入第n个桶中,更新桶值为1(数值元素值)。
- 按顺序输出桶值有更新的桶编号,便得到有序数列。
桶排序是这样一个排序算法,它将原序列中的数据所属范围划分为若干个(假设为k个)区间,准备k个桶(数组等容器),对每个桶内的数据进行排序,之后按从小到大的枚举每个桶并按从小到大的顺序输出其中的数据,即对原序列排好了序。可以发现,在每个桶内的数据量相对均匀时桶排序比较高效,若数据分布不均匀,桶排序算法复杂度会变差。假设我们对每个桶内的数据选择快速排序等O(nlogn)的算法进行排序。那么,当数据均匀分布时,每个桶内约有n/k个数据待排序,对其排序的时间复杂度为 O((n/k)∗log(n/k)),总时间复杂度为:
O(n)+O(k)×O((n/k)×log(n/k))=O(n+n×log(n/k))=O(n+nlogn−nlogk)
当k接近n时,时间复杂度接近O(n)。但是桶排序有个非常明显的缺陷,即当数据分布极其不均匀时,其时间效率非常低。因此,在算法竞赛中,我们通常使用更"低端化"但也更靠谱的简化桶排序------计数排序。即直接将原序列数据所属的范围划分为每个数单独作为一个区间,这样每个区间中只会有相同的数存在了,我们直接从小到大枚举范围中的每个值,如果序列中有k个这样的值,就输出k次,这样就能对原序列排好序了。
算法实现
定义数组cnt,用cnt[i]记录序列中值为i+min的值有多少个,其中min表示序列中最小的数。
按顺序枚举0~max−min,输出cnt[i]个i。
int n,num ,ans[11]={} ;
cin>>n;
for(int i= 1 ;i<= n ;i++){
cin>>num;
ans[num]=1;
}
for(int i=1;i<=10;i++){
if(ans[i]!=0)
cout<<i<<" ";
}
cpp
int n;
int a[100010], mx = -2000000000, mn = 2000000000;
int cnt[1000010];
int main(){
cin >> n;
for (int i = 1; i <= n; i++)
cin >> a[i], mn = min(mn, a[i]), mx = max(mx, a[i]);
for (int i = 1; i <= n; i++)
cnt[a[i] - mn]++;
for (int i = 0; i <= mx - mn; i++)
for (int j = 1; j <= cnt[i]; j++)
cout << i + mn;
}
一个经典的问题
给出n个未经过排序的实数,我们想知道这n个数字经过排序后,相邻2个数之间的最大间隔是多少?
对于这个问题,我们只需要对这n个数进行排序,然后遍历这些数,统计相邻最大间隔即可,这个算法的复杂度为排序的复杂度nlog(n)。
树
基础定义
树是一种数据结构,它是由n(n>=1)个有限节点组成一个具有层次关系的集合。把它叫做"树"是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。在这种层次结构中有一个节点具有特殊的地位,这个节点称为该树的根节点,或称为树根。
|------------|----------------------------|---------------------|
| 术语 | 描述 | |
| 根节点 | 每棵树都有一个根节点 | 550 为这棵树的根节点 |
| 子节点 | 一个节点的后继节点被称为子节点 | 50的子节点为 27,75 |
| 父节点 | 若一个节点含有子节点,则这个节点称为其子节点的父节点 | 50是27,75 的父节点 |
| 兄弟节点 | 具有相同父节点的不同节点 | 61和85是兄弟节点 |
| 节点的度 | 一个节点含有的子节点的个数称为该节点的度 | 75的度为2,61的度为1 |
| 叶节点 | 度为0的节点称为叶节点 | 67、81都是叶子节点 |
| 树的深度 | 树中节点的最大层次 | 最深的节点为67,深度为4 |
| 节点的祖先 | 从根到该节点所经分支上的所有节点 | 50,75,61,71 都是67的祖先 |
| 子孙 | 所有子节点以及子节点的子节点以及... | 所有节点都是5的子孙 |
| 子树 | 以某节点为根的树 | 以75为根的子树包括7个节点 |
| 边 | 父子节点直接存在一条连边 | 50和27之间有条边 |
树的特性
一棵树中任意两个结点有且仅有唯一的一条路径连通。一棵树如果有 n个节点, 那么它一定有n−1条边。在一棵树中添加一条边将会构成一个回路。一棵有n个节点的树,所有节点的度数和为2×(n−1)。树形结构以树和二叉树最为常用,直观来看,树是以分支关系定义的层次结构。树形结构中元素之间有着明显的层次关系,每一个元素可以和下层的多个元素相关, 但只能和上层中一个元素相关。
树与链表
链表是一棵特殊的树。我们可以将链表的头结点看作树的根。树的每个节点可以有多个next,我们称之为子节点。所以链表是一棵每个节点都只有一个子节点的树。
|-----------------|-----------------------------|
| 链表 | 树 |
| 头节点 | 根结点 |
| 只有一个后继节点(next) | 有一个或多个子节点(child) |
| 双向链表包括前驱结点(pre) | 除了根结点外,每个节点都有唯一的父节点(parent) |
| 一对一关系 | 一对多关系 |
树的基本操作
树的存储与创建
在创建一棵树的时候, 使用什么方法去存储呢?
可以采用与链表类似的方法。但由于子节点的数量不确定, 因此我们想到用vector来存储树的子节点。
在一般树中,子节点的数量没有限制,所以常用的存储方法是使用vector数组G,G[0]保存编号为0的顶点连接到的所有顶点, 由于n个结点的树只有n−1条边,vector数组实际占用的空间为O(n)。
每读到一条边(u,v),如果不知道谁是父亲谁是儿子,我们可以先在G[u]中添加一个v,再在G[v]中添加一个u。
如果知道父子关系,可以只在父节点中添加儿子,而不用将边保存两份。
cpp
vector<int> G[100005]; //每个vector用来记录所有子节点的编号
int n;
int main(){
cin >> n;
for(int i = 1; i < n; i++){
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}
return 0;
}
删除树的点和边
删除节点 uu 时需要删除该节点的所有边。
Del(当前节点u){
for(u有连边的节点v){
从G[v]中删除u;
}
清空G[u];
}删除边 (u,v)(u,v) 。
Del(边(u,v)){
从G[v]中删除u;
从G[u]中删除v;
}
树的遍历
树的遍历
遍历树的方式有很多,最常用的方式是 DFSDFS 。
DFS(当前节点u){
for(u的所有子节点v){
DFS(v);
}
}
如果是在不知父子的情况下,保存了双向边,那么需要做如下处理。
DFS(当前节点u, 父节点p){
for(u有连边的节点v){
if(v不是p)
DFS(v, u);
}
}
如果想要在树上求解一些问题的答案,有时需要递归地去求解,也就是从根出发,不断地递归求解,我们把这个过程叫做树上的DFS。
首先我们要把树上所有边以双向边的形式存储起来,之后进行DFS。
cpp
vector<int> G[maxn];
void dfs(int rt, int fa)//rt为当前节点编号,fa为当前节点的父节点编号
{ //在这里进行计算
for(int i = 0; i < G[rt].size(); i++) {
int to = G[rt][i];
if(to == fa) continue; //不走父节点那条边
dfs(to, rt);
}
}
|----|----------------|
| | 遍历方式对比 |
| 数组 | 从下标 00 开始遍历 |
| 链表 | 从头节点开始遍历 |
| 树 | 从根节点开始递归遍历 |
树的高度与子树
求节点的深度
在这个问题中,我们最初可以知道根节点的深度为1,而且除了根节点以外的每个节点的深度都等于他父节点的深度加1。所以我们需要再DFS的过程中记录两个变量,一个变量是当前节点的编号,另一个是当前节点父节点的编号,这样我们就可以开始树上DFS
cpp
vector<int> G[maxn];
int dep[maxn];
void dfs(int rt, int fa){//rt为当前节点编号,fa为当前节点的父节点编号
//深度等于父节点深度+1
dep[rt] = dep[fa] + 1;
for(int i = 0; i < G[rt].size(); i++)
{
int to = G[rt][i];
if(to == fa) continue; //不走父节点那条边
dfs(to, rt);
}
}
树模型 特殊的树
二叉树
在计算机科学中,二叉树是每个结点最多有两个子树的树结构。通常子树被称作"左子树"(left subtree)和"右子树"(right subtree)。
B树
1970年,R.Bayer和E.mccreight提出了一种适用于外查找的,它是一种平衡的多叉树,称为B树。
与平衡二叉树类似,只是他是多叉的。
实际上是因为对于很多存储设备来说,顺序读的速度经常是随机读的数十倍,比如我们的机械硬盘,在这种设备上,用二叉的方式来做查询,时间将全部花在寻道上。这时B−Tree的优势就体现出来了。
树的权值
在之前的内容中,叶子的深度代表从根到叶子,经过的边的数量。假如我们认为每条边的长度是不同的,那么叶子的深度就是从根到叶子的所有边长度的和。
在这里边的长度称为边权(边的权值)。
同样,在计算子树大小时,如果每一个点都有不同的大小,那么子树的大小就是当前节点大小 + 所有子孙的大小之和。
在这里点的大小称为点权(点的权值)。
之前所学的一些模型是比较特殊的模型,即边权和点权都等于11。作为更一般的情况,则可能边有边权,点有点权。
树DP
树上DFS总是伴随着一种状态转移,有从父节点向子节点的转移,也有子节点向父节点的转移,这和我们一些DP的过程很相似,所有很多树上的题目我们都可以通过树上DP来解决。++++树形++++ ++++D++++ ++++P++++ ++++准确的说是一种++++ ++++DP++++ ++++的思想,++++ DP建立在树状结构的基础上。
树DP
父节点的状态转移
在某些树DP中,你需要考虑来自父节点的状态转移,但在DFS没有结束之前,可能父节点的最优状态还处于未知的情况。这时候你可以通过2次DFS来解决这个问题。
并查集
并查集处理的问题
有了树结构的基础知识后,我们来思考这样一个问题。
有很多棵树,每棵树有自己的节点,这些树组成了一个森林。
我们进行多次询问,每次询问两个节点是否在同一棵树上。
例如:我们询问 (20,21) 是否在同一棵树上?
我们可能会对每棵树做一个编号,然后对树进行 DFS ,为树的每个节点进行编号,之后处理每个询问时,我们只要比较2个节点的编号是否相同即可。
树的合并问题。我们可以暴力的把一棵树中所有节点的编号改成另一棵树的编号。
这个方法最坏情况的复杂度是O(n2),即每次都将一个较大的树合并到较小的树上。
如果我们规定,每次将较小的树合并到较大的树上,那么算法的复杂度为O(nlog(n)),这个方法也叫做启发式合并,未来在其他结构的合并中也会用到。
对于这个问题,我们还有更优的解法,这就需要使用并查集了。
路径压缩
并查集顾名思义,包括并和查2个部分,并(union)即合并两个集合(树),查(search)检查2个点是否在同一个集合(树)内。
我们不需要做任何的预处理,每次查询的时候,我们从2个节点分别向他们的根结点走,如果最终根节点是同一个,则表明两个节点在同一棵树中,否则是不同的两棵树。例如:20的根为1,21的根为2,所以不在同一棵树中。
由于这类查询的特殊性,我们对树的存储方式进行一下修改,不用保存每个节点有哪些子节点,转为只保存每个节点的父节点是谁,这样并不影响上面的查询过程,并且更为灵活。
在大部分情况下,从节点走到根是很快的。但存在极为特殊的情况,也就是说这棵树退化为一个链表,这样从节点到根的距离,最坏就是n。如果我们重复多次对这2个节点进行查询,那么算法是非常低效的。既然是多次对同一对节点进行查询,简单来讲,我们直接将节点的父节点设为他的根节点即可,虽然改变了树的形态,但并不影响查询结果
例:将20的父节点设为1,21的父节点设为2。
我们考虑对上面的方法做进一步的优化。除了将节点自己的父节点设为根节点,所有从节点到根的路径上的节点,我们都可以使用同样的方法,将他们的父节点设为根结点。
例:将20,16,10,4的父节点设为1,21,17,13,7 的父节点设为2。我们将这个过程称为路径压缩。路径压缩有效的提高了并查集的效率。
按秩合并
那么如何解决树的合并呢?这个其实很简单,我们只需要将原来某棵树的根节点,修改为另一棵树的根节点即可。这样合并也是O(1)的。
以上就是带路径压缩的并查集的过程,下面动图演示了合并0,7两点的过程
并查集的复杂度低于O(nlog(n)),这部分内容我们会在后面继续讲解。
除了路径压缩,并查集还有一个重要的约束:按秩合并。
该方法使用秩(rank)来表示树高度的上界,在合并时,总是将具有较小秩的树根指向具有较大秩的树根。简单的说,就是总是将 rank 值小的作为子树,添加到rank值大的树中(这个rank可以近似看为树的高度)。
如果我们在合并两棵树时没有任何限制,在某些数据下,并查集的复杂度很有可能降到O(nlog(n))。
例如下图这种情况:
第一次合并大的集合和绿色这个点,之后查询红点,红点路径的长度为O(log(n)),所以查询一次的复杂度为O(log(n)),而路径压缩之后整棵树又变成左下角的结构,如果再一次和一个绿点合并之后再查询红点,复杂度还是O(log(n))的,这就导致产生一个循环,用这样的合并和查询就可能导致不按秩合并的并查集的复杂度降到O(nlog(n))。并查集是我们解决题目过程中常用的数据结构,通常用来解决连通性判定问题。最后给出带按秩合并的并查集的完整模板。
int f[maxn];
int rank[maxn];
void init() {
for (int i = 1; i <= n; i++) {
f[i] = i;
rank[i] = 1;
}
}//查
int find(int x){
if (x != f[x])
f[x] = find(f[x]);
return f[x];//并
void union(int x, int y){
x = find(x);
y = find(y);
if (x != y)
{
if (rank[x] > rank[y])
f[y] = x;
else {
f[x] = y;
if (rank[x] == rank[y])
rank[y]++;
} }}
并查集的复杂度
通过按秩合并和路径压缩两个操作,并查集的复杂度可以达到O(Alpha(n)), 在这里,Alpha(n)是阿克曼(Ackermann)函数的反函数。这比O(log(n))还要快。
不过,这是"均摊复杂度"。也就是说,并不是每一次操作都满足这个复杂度,而是多次操作之后平均每一次操作的复杂度是O(Alpha(n))的意思。
网格图就是矩形的方格。
网格图与迷宫
在传统2D 游戏里,地图经常就是上面这种网格图。一般分为8连通和4连通两种。4连通是指每个格子可以直接走到上下左右,4个相邻的格子。8连通除了上下左右之外,还可以直接走到左上、右上、左下、右下。
DFS 处理迷宫:
在游戏中,我们经常会进入一些迷宫,而找到迷宫的出口,本身就是一个搜索的过程。其中既可以用DFS来描述(撞到墙转弯),也可以用BFS来描述。在处理网格图迷宫时,DFS更适合去找到出口,BFS则不仅仅限于找出口,还能够帮你找到到出口最近的路。
BFS处理迷宫:
网格图的DFS
在网格图中进行搜索,如果不对走过的点进行标注,那么可以走的路有无数条,甚至可以在2个格子之间不停的循环。为了让程序的复杂度有保证,在进行DFS的时候,我们要对走过的点(格子)进行标注,不再重复的走。因为假如上次DFS未能通过当前点找到出口,现在仍然找不到。
4 连通的网格图DFS大概可以写为这样:
DFS(int x, int y){
if(map[x][y] 已访问)
return;
map[x][y]标为已访问;
DFS(x+1,y);
DFS(x-1,y);
DFS(x,y+1);
DFS(x,y-1);
}
由于每个点(格子)不会重复走多次,所以单次DFS的复杂度为O(mn)(长度和宽度的乘积)。
利用DFS,可以判断两个点之间是否存在一条路(是否连通)。我们可以通过一次DFS,判断多个点之间是否都能互相连通。
网格图的BFS
在网格图的题目中,我们经常会遇到一些,求最短、最快的问题。对于这类问题,我们可以用BFS来处理,因为BFS遍历点(格子)的顺序就是由近及远,找到的第一个解,往往就是最优解。我们一般使用队列来做BFS,队列是先进先出的结构,可以保证我们按照从近到远的顺序逐个访问格子。
cpp
SetPoint(int u, int v){
if((map[u][y] 未访问) {
把点 (u,y) 加入队列尾部
map[x][y] 标为已访问;
}
}
BFS(int x, int y){
把点 (x,y) 加入队列 Queue 尾部
while(Queue 不为空) {
从 Queue 头部取出点(u,v)
SetPoint(u - 1, v);
SetPoint(u + 1, v);
SetPoint(u, v - 1);
SetPoint(u, v + 1);
}
}
BFS在具体处理每个点(格子)时与DFS相同,走过的需要标注一下,不要重复走,因为不可能是最优解,同时这也是复杂度的保证, 这类方格问题单次BFS的复杂度为O(mn)。
图结构导论
基础概念
图是一种比线性表和树更复杂的数据结构。 在图结构中, 结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
图由顶点(Vertex)和边(edge)组成。边用来连接2个顶点。顶点的集合是V,边的集合是E的图记为G=(V,E),连接两点u,v的边用 e=(u,v) 表示。
图大体上分为两种:边没有指向性的图叫做无向图 ,边具有指向性的图叫做有向图 (有个箭头)。
无向图
边没有方向的图称为无向图。
有向图
在有向图中,边是单向的。每条边连接的两个顶点都是一个有序对。
点权和边权
点和边都可以有各种属性, 一般常见的是权值。 点和边的权值通常称为点权和边权。边上带权值的图被称为带权图。有向图和无向图都可以拥有点权和边权。
图的实际例子
地铁交通图,每个地铁站可以被看作图的顶点 ,两站之间的地铁线路,可以被看作边 。两站之间的距离,可以用边权 表示,每站的客流量可以用点权 表示。
无向图的术语 :
连通图
任意两点之间都有路径的图叫做连通图
对于一个无向图来说,如果它是连通的,那么它的任意两个顶点之问必存在一条路径,因此,通过这一路径可从一个顶点"到达"另一个顶点,若从顶点可以到达u,则从u也可以到达该点,也即v和u之间是互相可以到达的。
对于有向图,情形就不同了,因为存在从u到v的路径,并不蕴涵也存在从v到u的路径。
设D是一个有向图,且u、v∈D,若存在从顶点u到顶点v的一条路径,则称从顶点u到顶点v可达。
可达的概念与从u到v的各种路径的数目及路径的长度无关。另外,为了完备起见,规定任一顶点到达它自身的是可达的。
度
在无向图中,每个节点连边的条数就是该节点的度数。
重边
图中如果存在两条u−>v的边,则称为这两条边为一对重边。
自环
起点和终点相同的边(v−>v),可以被看作指向自己的一个环,称为自环。
有向图术语
出度和入度
从点u指出的边的个数称为点u的出度,指向点u的边的个数称为u的入度。
图的存储
在图的表示方法中,常用的方法有邻接矩阵和邻接表
邻接矩阵
邻接矩阵使用|V|∗|V|的二维数组来表示图。G[u][v]表示顶点u和顶点v的关系。 在有向图中,G[u][v]=1表示有一条从u到v的边。在无向图中,G[u][v]=G[v][u] 。
用邻接矩阵来保存图的信息,空间复杂度是 O(n2) 的。
邻接表
在存储树结构时已经用到了邻接表的方法:
cpp
vector<int> G[100005];
int n;
int main(){
cin >> n;
for(int i = 1; i < n; i++){
int u, v;
cin >> u >> v;
G[u].push_back(v);
}
return 0;
}
使用vector数组保存邻接表,vector[0]保存编号为0的顶点连接到的所有顶点 。因为树本身就是一种特殊的图,并且树是很稀疏的图,因此使用邻接表的方式储存。
在带权图中,通常需要将边权以结构体的形式存储。 邻接表虽然在边数稀少时只占用少量的内存, 但是相比较邻接矩阵实现复杂, 并且查询两点之间是否有连边, 只能通过遍历查找才知道。
特殊的图
完全图
在图论的数学领域,完全图是一个简单的无向图,其中每对不同的顶点之间都恰连有一条边相连。完整的有向图又是一个有向图,其中每对不同的顶点通过一对唯一的边缘(每个方向一个)连接。n个端点的完全图有n个端点以及n(n−1)/2 条边。
二分图
二分图又称作二部图,是图论中的一种特殊模型。设G=(V,E) 是一个无向图,如果顶点V可分割为两个互不相交的子集(A,B),并且图中的每条边(i,j)所关联的两个顶点i和j分别属于这两个不同的顶点集(i∈A,j∈B),则称图G为一个二分图。
DAG图
DAG 意思是有向无环图,所谓有向无环图是指任意一条边有方向,且不存在环路的图。如果有一个非有向无环图,且A点出发向B经C可回到A,形成一个环。将从C到A的边方向改为从A到C,则变成有向无环图。有向无环图的生成树个数等于入度非零的节点的入度积。
图的连通
无向图的连通
我们称一张无向图是连通的,当且仅当其中所有点对都连通。
连通分量
无向图G的一个极大连通子图称为G的一个连通分量(或连通分支)。连通图只有一个连通分量,即其自身。非连通的无向图有多个连通分量。且连通分量之间没有公共点。
图1中包括2个连通分量,(0,1,2,3,4)和(5,6)。
图2中包括1个连通分量,(0,1,2,3,4,5,6)。
相关算法
求无向图连通分量的方法很多,:
- 以每个点为根做DFS,DFS过程中将访问到的点进行标记,标记位设为本次DFS根结点的编号。如果遇到某个点已经访问过,则跳过。最终统计一下有多少个不同的根,就有多少个连通分量。复杂度O(n),其中n为点的个数。
- 使用并查集,枚举每一条边,合并边的两个端点,最终有多少未合并在一起的集合,就有多少个连通分量。复杂度O(Alpha(n)×m),其中n为点的个数,m为边的个数。