关键词:DFS种子填充,BFS最短路树,拓补排序,欧拉回路,表达式树,有根树,最短路(Dijkstra / Bellman-ford / Floyd-Warshall),最小生成树(Kruskal),并查集
目录
六、最短路问题(Dijkstra / Bellman-ford / Floyd-Warshall)
和树不同,图(Graph)结构常用来存储逻辑关系为“多对多”的数据。
一、用DFS求连通块(种子填充)
典型例题 油田(Oil Deposits,Uva 572)说的是m行n列矩阵由字符“@”和“✳”组成,求@字符连通块。由于连通块元素之间是相互联系的,所以比较容易想到的方法就是使用递归遍历,也就是“图的DFS遍历”:
从每一个“@”格子出发,递归遍历周围的“@”格子,每次访问一个格子是就给它一个“连通分量编号”,避免访问多次。
具体代码如下,首先定义头文件和全局数据类型:
#include <cstdio>
#include <cstring>
const int maxn=100+5;
//定义全局数据类型
char pic[maxn][maxn]; //存储整个矩阵
int m,n,idx[maxn][maxn]; //存储每个元素的“连通分量”编号id
接着就是核心代码——递归函数的定义,这里发现把本次递归暂停的条件设置在递归函数一开始要比设置在下一次递归前方便得多。参考以下代码,把越界处理和重复访问判断放在一开始比最后递归前判断是否进行本次递归要方便的多(简单来说就是不管周围情况咋样都去递归,进入了递归函数再判断自身是否合法),代码如下:
void dfs(int r,int c,int id)
{
if(r<0||r>=m||c<0||c>=n)
return; //“出界”则暂停
if(idx[r][c]>0||pic[r][c]!='@')
return; //不是@或已经访问并赋id的不需要继续遍历
idx[r][c]=id; //联通分量编号
//下面开始递归八个周围元素
for(int dr=-1;dr<=1;dr++){
for(int dc=-1;dc<=1;dc++){
if(dr!=0||dc!=0) //同时为0就是本身,要避免
dfs(r+dr,c+dc,id);
}
}
}
最后是main函数,主要思路就是遍历每一个元素都DFS一下:
int main(){
while(scanf("%d%d",&m,&n)==2&&m&&n){ //确保输入行列数正确
for(int i=0;i<m;i++)
scanf("%s",pic[i]); //一行行输入,比一个个元素代码简便
memset(idx,0,sizeof(idx)); //id数组清零
int cnt=0;
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(idx[i][j]==0&&pic[i][j]=='@')
dfs(i,j,++cnt);
}
}
printf("%d\n",cnt);
}
return 0;
}
上题的算法(求多维数组连通块)也称作“种子填充”(floodfill),可以用DFS(递归遍历)实现也可以用BFS(队列)实现,有兴趣可以查一下维基百科:
维基百科 种子填充http://en.wikipedia.org/wiki/Floodfillwf中有一道题关于 古代象形符号(Ancient Messages,World Finals 2011,Uva 1103),题目要求是识别6个象形文字,且这些图案可以拉伸:
其实处理逻辑和上面的种子填充差不多,都是递归遍历找出黑色块,然后从黑色块组合中匹配对应的象形符号。由于“可拉伸”所以匹配时不是严格匹配,而是计数每一个黑色块组合中有多少个“空洞”(抓住性质——每一个象形符号的空洞数量不一致),就可以解决问题。
二、BFS最短路树
场景是有一个n行m列的网格迷宫,中间有数个障碍物,找寻找起点到终点的最短路径。可以联想到二叉树的BFS——也是从根节点距离从小到大的顺序遍历,所以这里也是使用相似的方法进行图的BFS遍历:
从起点开始遍历整个图,逐步计算出起点到每一个节点的最短记录(图a)
相同最短距离的放在一“层”,可以构造出指向起点的树(图b)
通过BFS遍历可以构造出上述树——也叫最短路树(BFS树),可以画成如下的结构:
(以上图片均参考《算法竞赛入门经典 第二版》)
经典例题 Abbott的复仇(Abbott's Revenge,ACM/ICPC World Finals 2000,Uva 816),讲的是一个最大为9*9个交叉点的迷宫,迷宫为方块型,只能沿着水平或竖直反向走。在迷宫的节点中移动时,有NEWS四个方向进入节点(北东西南),允许三个方式FLR出去(直行左转右转)。现在输入入口和出口,要求最短路。
本题中进入每个节点的三个性质特别重要——行位置、列位置和进入方向(NEWS)。所以可以定义每一个经过的节点为一个三元组(r,c,dir),由于dir为进入节点的方向,所以整条链的第一个节点不是(r0,c0,dir)而是(r1,c1,dir),是移动过一位后的节点作为链的首节点。出于节点的其他功能可以将三元组转化为结构体——这样可以形成一条完善的链表。
本题可以使用BFS最短路树的原因是,在迷宫中行走过程中,有些节点提供多个转向方向,多个分叉恰巧构成了二叉树的“分叉”,且节点之间相互联系,因此可以将图转化为树的BFS遍历。
首先由于4个进入方向和转向反向都是字母,可以转化为数字以便后期运算(反向数据化)。其中使用了strchr函数,格式为strchr(字符串,字符),返回值为字符在字符串中第一次出现的位置指针,所以如果要求下标时需要减去首地址(数组名)。代码如下:
const char* dirs="NEWS"; //默认为顺时针旋转
const char* turn="FLR"; //转向反向
//将朝向转换为数字格式
int dir_id(char c){
return strchr(dirs,c)-dirs;
}
//将转向转化为数字格式
int turn_id(char c){
return strchr(turns,c)-turns;
}
接着是行走函数(移动函数),根据当前的状态和转弯方式,计算出后继状态。这里函数的返回值就是节点结构体(结构体较简单故未给出)。注意返回值是新结构体,而不是返回传入Node u的引用,因为原节点数据不能被改变,只能在函数中创建新节点并返回:
const int dr[]={-1,0,1,0}; //上下左右移动对应的元素行变化
const int dc[]={0,1,0,-1}; //上下左右移动对应的元素列变化
Node walk(const Node& u,int turn){
int dir=u.dir; //取出方向
if(turn==1) //左转(逆)
dir=(dir+3)%4; //逆时针转动一个方向
if(turn==2) //右转(顺)
dir=(dir+1)%4; //顺时针转动一个方向
return Node(u.r+dr[dir],u.c+dc[dir],dir)
}
输入函数(略)作用是读取r0,c0,dir,并计算r1和c1,作为第一个节点。然后读入has_edge数组,这个数组的作用是储存每个节点从不同方向来的合法转动反向。
接着就是BFS的过程,依旧使用队列,并在遍历的同时计算该链的长度并储存本节点的父节点。其中使用两个数组:
d[r][c][dir] | 储存起点到(r,c,dir)的最短路长度 |
p[r][c][dir] | 储存本节点的父节点 |
BFS的主要代码如下:
void solve(){
queue<Node> q;
memset(d,-1,sizeof(d));
Node u(r1,c1,dir); //创建树的初始父节点(起点的后一个点)
d[u.r][u.c][dir]=0; //初始路径长度为0
q.push(u); //推入队列,开始遍历
while(!q.empty()){
Node u=q.front(); //存储队列最前的元素,并使其出队列
q.pop();
if(u.r==r2&&u.c==c2){
//表示已经到达终点
print_ans(u);
return;
}
for(int i=0;i<3;i++){
//对三个转向方向遍历,如果转向合理则进队列
Node v=walk(u,i);
if(has_edge[u.r][u.c][dir][i]&&inside(v.r,v.c)&&d[v.r][v.c][v.dir]<0){
//即严格控制 可以转向+该节点存在(因为迷宫不是每个转角都存在)+没有被遍历过
d[v.r][v.c][v.dir]=d[u.r][u.c][u.dir]+1; //节点串长度
p[v.r][v.c][v.dir]=u; //储存父节点
q.push(v);
}
}
}
printf("No Solution Possible\n");
}
最后是输出函数,只需要从目的地节点开始反方向遍历即可,推荐使用不定长数