第七次作业第2题 独立路径数计算(DFS)
第七次作业第2题 独立路径数计算(DFS)
为什么是DFS?
已经对DFS有良好理解的同学可以跳过这一部分。
首先回顾一下生成全排列的算法:
int visit[n], path[n];
void dfs(int level) {
if (level == n) { // 有明确的终止条件
print(path);
return ;
}
for (int i = 0; i < n; i++) { // 搜索所有可行选择
if (!visit[i]) { // 不会做出重复的选择
visit[i] = 1;
path[level] = i; // 进行记录
dfs(level + 1);
visit[i] = 0;
}
}
}
int main() { dfs(0); }
借助全排列的过程,分析一下DFS的要素:
- 每次都面临多种选择,能够通过搜索找到所有可行的选择;
- 每次都遍历visit数组,从小到大寻找还未被使用过的数。
- 不会做出重复的选择;
- 每选择一个数,就将visit数组中的对应元素标为1;返回时则清空该标记。
- 能够记录曾经做出的选择,且该记录随着递归的调用而推进、随着递归的返回而回退;
- path数组记录了当前level之前选择的数,并将当前的选择记录在第level位。
- 有明确的终止条件;
- 每当填充到第n位时,一次排列生成,输出结果,并返回。
对应到本题的计算路径:
- 每个点都和多个点邻接;通过按边的序号从小到大搜索邻接的结点,恰好能按照路径边字典序找到所有路径。
- 每经过一个结点,就用visit做标记,使得之后不能再次经过;当从该结点搜索完毕返回时,又清空该标记,使得之后从别的路径又可以再次经过。
- 用path数组记录每个经过的边的编号,恰好就能保存路径。
- 每当搜索到终点时,找到一条路径,输出该路径,并返回。
总结一下,DFS的功能就是不重不漏地找到一个问题的所有可行解,这可能需要在熟练掌握递归的基础上有一定感性的认识。本次的作业题,其实就是对很多问题的高度抽象,即用暴力搜索的手段来求得一个难题的解。
本题存图的数据结构
邻接矩阵 or 链式边表?
- 邻接矩阵的含义是,用一个二维数组
map[n][n]
表示任意两点间的边关系。显然,在这种情况下,任意两点间只能存储至多一条边的信息;本题提到了,两个顶点之间有多条边时,边的序号会不同;因此,本题不能使用邻接矩阵。- 邻接矩阵的优点:能够方便地按照点的序号有序地访问;能快速地查询任意两点间的边信息,查询效率高;只用数组,代码复杂度低。
- 邻接矩阵的缺点:遍历时要对图中所有结点的邻接性进行判断,遍历效率低;并且需要\(n^2\)的空间复杂度来存储。
- 链式边表的含义是,为每个结点建立一个链表,其中存储与该结点邻接的所有边。显然,这种方法对两点间边的数量没有限制,适用于本题。
- 链式边表的优点:每次访问都只访问到邻接的边,遍历效率高;占用的内存大小只和边数有关,空间复杂度低。
- 链式边表的缺点:每个点邻接的边的顺序需要手动维护;查询任意两点间的边信息需要遍历该点所有的邻接边,查询效率低;需要使用链表的数据结构,代码复杂度高。
静态数组 or malloc+指针?
使用数组和指针存储的实质都是一样的,都是从索引到数据的映射。区别在于:数组是从下标到数据的映射,指针是从地址到数据的映射。
在本题中,题目用标号描述结点和边,恰好就是整数到数据的映射;并且给定了标号的范围,因此使用静态数组是最优解。
建图注意事项&小技巧
用边表存图时,我们往往将一条无向边转化为两条有向边处理。对于每一条有向边,可以只存储这条边指向的终点。
struct Edge{
int to; // 终点的标号
// 其他属于边的信息,如长度、路费等
int next; // 链表中下一条边的标号,需初始化为-1
}edges[M * 2];
struct Node{
int first; // 边链表中第一个结点的边标号,初始化为-1
// 其他属于点的信息,如坐标、温度、人口数量等
}nodes[N];
但本题给每条边都标好了号,而我们又要用数组存图,没法让两条有向边共用一个下标,这该怎么办呢?
一种通用的思路,是采用两套标号系统。一套系统用来表示题目中的标号,只用该标号排序和输出;另一套系统用于我们的链式边表。但这样就丧失了用数组存储的意义,直接用malloc+指针反而更为方便。
对于本题来说,一种巧妙的做法,是将题目给定的边号乘上2,再分别+0和+1后,作为两条有向边的新编号;排序时原先的大小关系没有改变,输出时只要将新编号除以2就能得到原编号,可谓是一举两得。
int main() {
...
// 读入信息:边标号为e,两点标号为v1 v2
// 转化为两条有向边
buildEdge(v1, v2, e*2+0); // 从v1到v2,边标号转化为e*2+0
buildEdge(v2, v1, e*2+1); // 从v2到v1,边标号转化为e*2+1
}
下面的关键就是书写buildEdge(from, to, e)
函数了,它的含义是增加一条从from到to的标号为e的边
。由于是链式边表存图,该函数就是需要将边e插入到from结点存储的边链表中。又由于题目要求按字典序输出所有路径,我们需要保证每个链表中边的标号都从小到大排列。
为了保证链表元素的有序性,有两种做法:一是每次都找到正确的位置插入;二是先将所有边按标号排序,再依次插入到每个链表头部。由于后者可以借助qsort降低排序和插入的代码复杂度,且时间复杂度也十分优秀,这里用后者为例。
struct Tmp{
int e, vi, vj;
}tmpData[1005];
int main() {
...
for (int i = 0; i < e; ++i)
scanf("%d%d%d", &tmpData[i].e, &tmpData[i].vi, &tmpData[i].vj);
qsort(tmpData, e, sizeof Tmp, cmp); //将读入的所有边按e值从大到小排列
for (int i = 0; i < e; ++i) {
buildEdge(tmpData[i].vi, tmpData[i].vj, tmpData[i].e * 2 + 0);
buildEdge(tmpData[i].vj, tmpData[i].vi, tmpData[i].e * 2 + 1);
}
...
}
void buildEdge(int from, int to, int e) {
edges[e].to = to;
// 直接插入到链表头部
edges[e].next = nodes[from].first;
nodes[from].first = e;
}
到这里,本题建图的部分就大功告成;接下来就是参考全排列的算法依葫芦画瓢,书写DFS。
用DFS进行搜索
显然,dfs函数需要至少两个参数:一是当前搜索到的结点,二是当前的深度(用来记录路径)。
int path[N], visit[N];
void dfs(int now, int depth);
int main() {
...
visit[0] = 1;
dfs(0, 0);
...
}
- 终止条件:
- 当前搜索到的结点now为终点时,表示找到一条路径;用
for (int i = 0; i < depth; ++i) printf("...", path[i]);
输出路径记录数组中记录的路径(标号要除以二再输出),并返回。
- 当前搜索到的结点now为终点时,表示找到一条路径;用
- 遍历选择:
- 用
for(int e = nodes[now].first; e != -1; e = edges[e].next)
遍历当前now结点邻接的所有边,并用int to = edges[e].to
访问该边到达的结点。
- 用
- 保证不重:
- 先判断
visit[to]
,只有当未访问过时才访问to。
- 先判断
- 记录路径
- 如果未访问过,则立即将visit[to]置为1,同时记录路径
path[depth] = e
; - 进行递归调用
dfs(to, depth+1);
; - 返回后,将visit[to]恢复为0,继续搜索下一个可行的邻接结点。
- 如果未访问过,则立即将visit[to]置为1,同时记录路径
参考递归实现的全排列,即可轻松完成上面的算法。相信你已经能够掌握DFS的奥妙了。
posted on 2020-06-01 01:53 Stitch11752 阅读(644) 评论(0) 编辑 收藏 举报