目录
深度优先搜索DFS
DFS的复杂度
DFS与递归
递归与暴力枚举
递归树
DFS与栈
DFS的搜索剪枝
搜索剪枝与优化
可行性剪枝
最优化剪枝
减少等效的分支
优化搜索顺序
搜索的记忆化
搜索的复杂度
大多时候,搜索的复杂度都是指数级的。各种剪枝方案,可以极大的提升搜索算法的效率,但却不容易真正改变算法的复杂度。唯有记忆化,可以真正的让一个指数复杂度的算法,转为多项式复杂度。而搜索的范围也从全部解空间,转为了状态数。
广度优先搜索BFS
什么是BFS
BFS的过程
搜索问题总结
桶排序
算法描述
算法实现
一个经典的问题
树的特性
树与链表
树的基本操作
树的存储与创建
删除树的点和边
树的遍历
树的遍历
树的高度与子树
求节点的深度
二叉树
B树
树的权值
树DP
父节点的状态转移
并查集
并查集处理的问题
路径压缩
按秩合并
并查集的复杂度
网格图与迷宫
网格图的DFS
网格图的BFS
图结构导论
基础概念
无向图
有向图
点权和边权
图的实际例子
无向图的术语:
连通图
度
重边
自环
有向图术语
图的存储
邻接矩阵
邻接表
特殊的图
完全图
二分图
DAG图
图的连通
无向图的连通
连通分量
相关算法
深度优先搜索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),直到栈为空。这是因为函数调用本身,在系统中也是通过一个类似栈的东西来实现的。
下面给出作为对比的伪代码:
//递归版本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<<" ";
}
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。
如果知道父子关系,可以只在父节点中添加儿子,而不用将边保存两份。
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。
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
vector<int> G[maxn];
int dep[maxn];
void dfs(int rt, int fa){//rt为当前节点编号,fa为当前节点的父节点编号//深度等于父节点深度+1dep[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来解决。树形DP准确的说是一种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,队列是先进先出的结构,可以保证我们按照从近到远的顺序逐个访问格子。
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)的。
邻接表
在存储树结构时已经用到了邻接表的方法:
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为边的个数。