Stitch11752

第七次作业第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]);输出路径记录数组中记录的路径(标号要除以二再输出),并返回。
  • 遍历选择:
    • 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,继续搜索下一个可行的邻接结点。

参考递归实现的全排列,即可轻松完成上面的算法。相信你已经能够掌握DFS的奥妙了。

posted on 2020-06-01 01:53  Stitch11752  阅读(644)  评论(0编辑  收藏  举报

导航