本篇是数据结构与算法《图》的第二部分,主要介绍两大遍历、两大算法构造最小生成树、一大重要算法求最短路径 这三大重要问题。
本篇属于数据结构与算法中最困难的部分。从基本的角度,具体算法的实现原理可以不需要细节掌握,但是必须要知道文中整理的每个封装函数的具体功能和参数。方便能够快速调用和实现。
文章目录
图
图的遍历—均不唯一
深度优先相当于二叉树的先序遍历。广度优先相当于二叉树的层次遍历。
深度优先遍历DFS
从图中某个指定的顶点v出发,先访问顶点v,然后从顶点v的邻接表中未被访问过的第一个邻接点出发,继续进行深度优先遍历,直到图中与v相通的所有顶点都被访问;若此时图中还有未被访问过的顶点, 则从另一个未被访问过的顶点出发重复上述过程,直到遍历全图。——深度优先遍历不唯一!递归!
时间复杂度
代码
这里只介绍邻接表方法。
int visited[n] = {0}; //n为顶点数。记录一个顶点是否被访问过,这个数组与Vlink记录头结点的数组形成一一对应。
void travelDFS(Vlink G[], int n){
//这是主函数
int i;
memset(visited, 0, sizeof(visited));
for(i = 0; i < n; i++){
if(!visited[i]){
DFS(G, i);
}
}
}
void DFS(Vlink G[], int v){
//这是辅助函数
visited[v] = 1;
VISIT(G,v); //根据题目要求访问节点数据。可能是输出值......
Elink* p;
for(p = G[v].next; p != NULL; p = p->next){
if(!visited[p->adj]){
DFS(G, p->adj);
}
}
}
注意:这里辅助函数中也要调用visited。非常建议把两个链表一个数组、visited函数全部设为全局变量!以及,memset记得引入相应的宏包。注意,这里memset第三个元素是sizeof。在字符数组中,可以直接写成字符个数,因为一个字符占1个字节。
遍历时的特殊需求
上面只给出了基础情况,即:按照Elink链表中的顺序进行深度优先遍历。但是事实经常没有这么简单。可能通常要访问相邻顶点时按照编号从小到大的顺序访问。比如按照上述要求(即固定一个结点,看这个结点所连接的所有节点中编号最小的,从这个最小的往深了遍历)广度遍历如下图的结果是013684257.
我们来看一下这种需求下的DFS函数。第一种方法是要增加一个minNode。从大到小同理。
void DFS(Vlink G[], int v) {
visited[v] = 1;
int minNode;
printf("%d ", v);
Elink* p, *q;
while (1) {
minNode = -1;
for (p = G[v].next; p != NULL; p = p->next) {
if (!visited[p->adj] && (minNode == -1 || p->adj < minNode)) {
minNode = p->adj;
}
}
if (minNode == -1) {
break; //如果没有未访问的节点则退出
}
DFS(G, minNode);
}
}
这个了解一下即可。
但是这种方法并不是一劳永逸。如果在BFS中也要这样从小到大,就会很麻烦。一劳永逸的方法是对每个顶点的邻接表进行从小到大排序,这样不论何种遍历,都可以实现访问相邻结点时按照编号(或其他的,权值等等)从小到大:
void sortAdjList(Vlink G[], int n) {
int cnt = 0;
Elink* p, *q;
int i;
int temp;
for (i = 0; i < n; i++) {
cnt = 0;
for (p = G[i].next; p != NULL; p = p->next) {
cnt++;
}
for (p = G[i].next; p != NULL; p = p->next) {
for (q = p->next; q != NULL; q = q->next) {
if (p->adj > q->adj) {
temp = p->adj;
p->adj = q->adj;
q->adj = temp;
}
}
}
}
}
对每个顶点的邻接表进行排序后,能够确保在遍历时按从小到大的顺序访问节点的内在机理主要基于以下几点:
因此,对图的顶点序号的排序可以完全转化为对每个顶点的邻接表序号的排序。这一点非常重要。
广度优先遍历BFS
从图中某个指定的顶点v出发,先访问顶点v,然后依次访问顶点v的各个未被访问过的邻接点(这是一个阶段过程,只在这一层把所有的都访问完了才进入下一个步骤,而不是遇到第一个未访问的结点就往深了走。这是和DFS的本质区别),然后又从这些邻接点出发, 按照同样的规则访问它们的那些未被访问过的邻接点,如此下去,直到图中与v 相通的所有顶点都被访问;若此时图中还有未被访问过的顶点, 则从另一个未被访问过的顶点出发重复上述过程, 直到遍历全。
时间复杂度
代码
这里仍然是只介绍邻接表方法。
int visited[n] = {0};
int Q[500];
int top = -1;
void travelBFS(Vlink G[], int n){
//这是主函数
int i;
memset(visited, 0, sizeof(visited));
for(i = 0; i < n; i++){
if(!visited[i]){
BFS(G, i);
}
}
}
void BFS(Vlink G[], int v){
//这是辅助函数
Elink* p;
visited[v] = 1;
enQueue(v); //标识某顶点已入队
while(!isEmptyQueue()){
v = deQueue();
VISIT(G, v); //根据题目要求访问节点数据
for(p = G[v].next; p!=NULL; p = p->next){
if(!visited[p->adj]){
visited[p->adj] = 1;
enQueue(p->adj);
}
}
}
}
void enQueue(int num) {
if (rear == 500) {
return;
}
if (front == -1) {
front = 0;
}
Q[++rear] = num;
}
int deQueue() {
if (front == -1 || front > rear) {
return -1; //underflow
}
return Q[front++];
}
int isEmptyQueue() {
return front == -1 || front > rear;
}
要把队列和visited都设置成全局变量。这里队列的长度足够大,可以暂时不设置成循环队列。同样,如果有特殊需求,加一个sortAdjList函数就好。
删除某节点
本质上就是遍历的时候无视这个节点和他所有的边。最简单的方式就是直接visited[节点序号]=1即可。
两种方式比较
具体用哪个取决于具体问题。通常DFS(深度)更适合目标比较明确,以找目标为主要目的的情况;而BFS(广度)更适合在不断扩大遍历范围时找到相对最优解的情况。
遍历的应用1—独立路径
要求:输出起点到终点的所有不带环的不同路径。并且按照字典顺序(路径编号从小到大)。
例:
这里有两种方式,一种是输出所有路径上的顶点;一种是输出所有边的序号(或权值)。
我们首先来看输出所有顶点:
typedef struct edge {
int adj; // 顶点的序号
int wei; // 边的权值
struct edge* next;
} Elink;
typedef struct ver {
Elink* next;
} Vlink;
Vlink G[1000];
int visited[1000]; // 记录每个顶点是否被访问过
int path[1000]; // 记录当前路径的权值
int pathIndex = 0; // 当前路径的长度
int n, e; // n是顶点数,e是边的数量,由输入输出中获得
void sortAdjList(Vlink G[], int n) {
//把邻接表按照路径权值从小到大排序
Elink* p, *q;
int i;
for (i = 0; i < n; i++) {
for (p = G[i].next; p != NULL; p = p->next) {
for (q = p->next; q != NULL; q = q->next) {
if (p->wei > q->wei) {
Elink temp = *p;
*p = *q;
*q = temp;
// 保持正确的链表结构
Elink* tempNext = p->next;
p->next = q->next;
q->next = tempNext;
}
}
}
}
}
void findAllPaths(Vlink G[], int start, int end) {
//start:路径起点的顶点序号
//end:路径终点的顶点编号。一般是0和n - 1
memset(visited, 0, sizeof(visited));
pathIndex = 0;
DFS(G, start, end);
}
void DFS(Vlink G[], int start, int end) {
visited[start] = 1;
path[pathIndex++] = start;
if (start == end) {
for (int i = 0; i < pathIndex; i++) {
printf("%d ", path[i]);
}
printf("\n");
} else {
Elink* p;
for (p = G[start].next; p != NULL; p = p->next) {
if (!visited[p->adj]) {
DFS(G, p->adj, end);
}
}
}
pathIndex--;
visited[start] = 0;
}
那么,如果要输出路径的编号(或者权值),DFS函数应该更改成:
void DFS(Vlink G[], int start, int end) {
visited[start] = 1; // 标记当前顶点已访问
if (start == end) { // 如果当前顶点是目标顶点
for (int i = 0; i < pathIndex; i++) {
printf("%d ", path[i]); // 输出当前路径的权值
}
printf("\n");
} else {
Elink* p;
for (p = G[start].next; p != NULL; p = p->next) { // 遍历所有邻接顶点
if (!visited[p->adj]) { // 如果邻接顶点未被访问
path[pathIndex++] = p->wei; // 将边的权值加入路径数组
DFS(G, p->adj, end); // 递归访问邻接顶点
pathIndex--; // 回溯时减少路径长度
}
}
}
visited[start] = 0; // 回溯时取消标记当前顶点为已访问
}
我们以一个例子为例,看一下全部代码:
BUAA数据结构第七次作业 2、独立路径计算
老张和老王酷爱爬山,每周必爬一次香山。有次两人为从东门到香炉峰共有多少条路径发生争执,于是约定一段时间内谁走过对方没有走过的路线多谁胜。
给定一线路图(无向连通图,两顶点之间可能有多条边),编程计算从起始点至终点共有多少条独立路径,并输出相关路径信息。
注:独立路径指的是从起点至终点的一条路径中至少有一条边是与别的路径中所不同的,同时路径中不存在环路。
【输入形式】
图的顶点按照自然数(0,1,2,…,n)进行编号,其中顶点0表示起点,顶点n-1表示终点。从标准输入中首先输入两个正整数n,e,分别表示线路图的顶点的数目和边的数目,然后在接下的e行中输入每条边的信息,具体形式如下:
…
说明:第一行为图的顶点数,表示图的边数;第二行 分别为边的序号(边序号的范围在[0,1000)之间,即包括0不包括1000)和这条边的两个顶点(两个顶点之间有多条边时,边的序号会不同),中间由一个空格分隔;其它类推。
【输出形式】
输出从起点0到终点n-1的所有路径(用边序号的序列表示路径且路径中不能有环),每行表示一条由起点到终点的路径(由边序号组成,中间有一个空格分隔,最后一个数字后跟回车),并且所有路径按照字典序输出。
【样例输入】
6 8
1 0 1
2 1 2
3 2 3
4 2 4
5 3 5
6 4 5
7 0 5
8 0 1
【样例输出】
1 2 3 5
1 2 4 6
7
8 2 3 5
8 2 4 6
【样例说明】
样例输入构成的图如下:
输出的第一个路径1 2 3 5,表示一条路径,先走1号边(顶点0到顶点1),然后走2号边(顶点1到顶点2),然后走3号边(顶点2到顶点3),然后走5号边(顶点3到顶点5)到达终点。
【评分标准】
通过所有测试点得满分。
完整代码示例:
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
typedef struct edge {
int adj; //顶点的序号
int wei; //边的序号
struct edge* next;
}Elink;
typedef struct ver {
Elink* next;
}Vlink;
Vlink G[1000];
int visited[1000]; //记录每个边是否被访问过
int path[1000];
int e, n;
int pathIndex;
Elink* insertEdge(Elink* head, int avex, int wei);
void sortAdjList(Vlink G[], int n);
void findAllPaths(Vlink G[], int start, int end);
void DFS(Vlink G[], int start, int end);
int main()
{
int i;
int wei, v1, v2;
scanf("%d%d", &n, &e);
for (i = 0; i < e; i++) {
scanf("%d%d%d", &wei, &v1, &v2);
G[v1].next = insertEdge(G[v1].next, v2, wei);
G[v2].next = insertEdge(G[v2].next, v1, wei);
}
sortAdjList(G, n);
findAllPaths(G, 0, n - 1);
return 0;
}
Elink* insertEdge(Elink* head, int avex, int wei) {
//head为头结点结构体中的指针,指向头结点的下一个元素,即第一个边结点
//avex为要插入的元素在头结点数组中的下标序号
Elink* p = (Elink*)malloc(sizeof(Elink));
p->adj = avex;
p->wei = wei;
p->next = NULL;
if (head == NULL) {
head = p;
return head;
}
Elink* last = head;
while (last->next != NULL) {
last = last->next;
}
last->next = p;
return head;
}
void sortAdjList(Vlink G[], int n) {
int cnt = 0;
Elink* p, * q;
int i;
int temp;
for (i = 0; i < n; i++) {
cnt = 0;
for (p = G[i].next; p != NULL; p = p->next) {
cnt++;
}
for (p = G[i].next; p != NULL; p = p->next) {
for (q = p->next; q != NULL; q = q->next) {
if (p->wei > q->wei) {
Elink temp = *p;
*p = *q;
*q = temp;
}
}
}
}
}
void findAllPaths(Vlink G[], int start, int end) {
memset(visited, 0, sizeof(visited));
pathIndex = 0;
DFS(G, start, end);
}
void DFS(Vlink G[], int start, int end) {
visited[start] = 1;
if (start == end) {
for (int i = 0; i < pathIndex; i++) {
printf("%d ", path[i]);
}
printf("\n");
}
else {
Elink* p;
for (p = G[start].next; p != NULL; p = p->next) {
if (!visited[p->adj]) {
path[pathIndex++] = p->wei;
DFS(G, p->adj, end);
pathIndex--; // 回溯时减少路径长度
}
}
}
visited[start] = 0;
}
把我们之前封装起来的代码整合起来就能实现功能。
拓展一下,如果要打印出所有路径中路径的序号(权值)之和最小的一条路,就再新建一个二维数组把所有成立的路径记录下来(先别急着输出),然后每条路分别算个加和比较一下就行。
最小生成树—不唯一
情境:北航网络中心要给北航主要办公楼间铺设光缆以构建网络。如何以最小的成本完成网络铺设?抽象:连通图,且路径权值之和最小。
生成树vs最小生成树
1、生成树:包含具有n个顶点的连通图G的全部n个顶点,仅包含其n-1条边的极小连通子图称为G的一个生成树。
2、最小生成树:带权连通图中,总的权值之和最小的带权生成树为最小生成树。最小生成树也称最小代价生成树,或最小花费生成树。所以,最小生成成熟一定面向带权联通图。
构造最小生成树的原则:图中n个顶点必须全部用到,只能使用、且仅能使用图中的n-1条边来连接图中的n个顶点;不能使用图中产生回路的边。
构造最小生成树
Prim算法
prim算法基于贪心,我们每次总是选出一个离生成树距离最小的点去加入生成树,最后实现最小生成树。因为贪心算法的策略就是在每一步的选择中选择最优的,在当前看是最好的选择,这种策略虽然一般不能在全局中寻找到最优解,但是对于最小生成树问题来说,它的确可以找到一颗权重最小的树。
算法描述(用自己的话):算法的核心是两个集合,一个是包含所有遍历到的顶点的集合,一个是还没有遍历到的顶点的集合。首先,选取一个顶点,从这个顶点开始遍历。把从这个点出发的所有路径中权值最小的路径找出来,这就是要选取的路径。这个路径通往另一个顶点,把这个顶点也加入已经遍历过的集合中。此时,已经遍历过得集合中有两个顶点。然后,看这两个顶点,作为一个整体,所有分出去的路径。再从其中找最小的权值,记录这条路径通向的顶点,把这个顶点加入集合,这样集合中就有三个顶点。直到集合中包含全部n个顶点。此时必有n-1条边、注意,每一步都不能构成一个环形。
下面看一个具体的例子:
到此为止,全部结束。
说明
代码
我们对这种问题使用邻接矩阵。简单介绍一下邻接表。
1、邻接表:
#include <stdlib.h>
#define MAXV 512 // 假设最大顶点数为512
#define INFINITY 32767 // 定义一个足够大的数表示无穷大
// 找到key值最小的顶点
int minKey(int key[], int mstSet[], int n) {
int min = INFINITY, min_index;
for (int v = 0; v < n; v++) {
if (mstSet[v] == 0 && key[v] < min) {
min = key[v], min_index = v;
}
}
return min_index;
}
void primMST(int n) {
int parent[MAXV]; // 保存最小生成树的结构
int key[MAXV]; // 保存每个顶点到当前生成树的最小权重
int mstSet[MAXV]; // 保存顶点是否已经包含在最小生成树中
// 初始化
for (int i = 0; i < n; i++) {
key[i] = INFINITY;
mstSet[i] = 0;
}
// 从第一个顶点开始
key[0] = 0;
parent[0] = -1; // 第一个节点没有父节点
for (int count = 0; count < n - 1; count++) {
int u = minKey(key, mstSet, n);
mstSet[u] = 1;
Elink* pCrawl = G[u].next;
while (pCrawl != NULL) {
int v = pCrawl->adjvex;
if (mstSet[v] == 0 && pCrawl->weigh < key[v]) {
parent[v] = u;
key[v] = pCrawl->weigh;
}
pCrawl = pCrawl->next;
}
}
printMST(parent, n);
}
// 打印最小生成树
void printMST(int parent[], int n) {
printf("Edge \tWeight\n");
for (int i = 1; i < n; i++) {
Elink* p = G[i].next;
while (p != NULL) {
if (p->adjvex == parent[i]) {
printf("%d - %d \t%d \n", parent[i], i, p->weigh);
break;
}
p = p->next;
}
}
}
其中n是顶点的个数。G要定义成全局变量。
2、邻接矩阵:
#define MAXVER 512
#define INFINITY 32767
void initGraph(int n){
//n为顶点数量
int i, j;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
if (G[i][j] == 0) {
G[i][j] = INFINITY;
}
}
}
}
void Prim(int weights[][MAXVER], int n, int src, int edges[])
{ //weights为权重数组、n为顶点个数、src为最小树的第一个顶点、edge为最小生成树边
int minweight [MAXVER], min;
int i, j, k;
for(i = 0; i < n; i++){ //初始化相关数组
minweight[i] = weights[src][i]; //将src顶点与之有边的权值存入数组
edges[i] = src; //初始时所有顶点的前序顶点设为src,(src,i)
}
minweight[src] = 0; //将第一个顶点src顶点加入生成树
for(i=1; i < n; i++){
min = INFINITY;
for(j = 0, k = 0; j < n; j++){
if(minweight[j] != 0 && minweight[j] < min) { //在数组中找最小值,其下标为k
min = minweight[j];
k = j;
}
}
minweight[k] = 0; //找到最小树的一个顶点
for(j = 0; j < n; j++){
if(minweight[j] != 0 && weights[k][j] < minweight[j] ) {
minweight[j] = weights[k][j]; //将小于当前权值的边(k,j)权值加入数组中
edges[j] = k; //将边(j,k)信息存入边数组中
}
}
}
}
for (i = 1; i < n; i++) {
//i一定要从1开始!
printf("Edge from %d to %d, weight: %d\n", edges[i], i, graph[i][edges[i]]);
}
这里主要的问题是,没有顶点的边权值应该设为INFINITY,而不是0.因此我们需要上面代码块中的initGraph函数。
这里一定要清楚edges是如何用一个一维数组存储两个顶点之间的一条边的。若i是一个节点,则edges[i]存储的是i的父亲节点。这点一定要注意。上面给了一个输出示例。
这里非常要注意的是:上面输出时i应该从1开始。因为原点0没有父亲节点,输出是INFINITY.
这一点非常“聪明”,因为一个父亲节点可能对应着很多个孩子节点,但是一个孩子节点有且只有一个父亲节点(孩子到父亲是满射而不是单射),故这种遍历方法遍历所有节点,不会产生重复。具体的一个输出结果:
用这种复杂问题用邻接矩阵比邻接表直观容易得多。一般遍历使用邻接表,其他复杂问题都是用邻接矩阵。
时间复杂度
克鲁斯卡尔算法
时间复杂度
克鲁斯卡尔的时间复杂度是O(elog2e)。其中e是边的个数。
最短路径问题(Dijkstra)
用于寻找图中从一点到另一点的最小路径,路径上所有权值之和最小。你可能要问:这个跟我们之前说的DFS遍历然后找到路径权值之和最小的那条路径这种方法有什么区别?那还要Dijkstra算法有什么用?
Dijkstra算法和深度优先搜索(DFS)在解决路径问题时有一些根本性的区别。了解这些区别有助于理解为什么在某些情况下Dijkstra算法是必要的,而DFS可能并不适用。
1、适用范围:
Dijkstra算法是专门用于解决单源最短路径问题的算法。也就是说,**给定一个起点节点,Dijkstra算法能够找到从这个节点到图中所有其他节点的最短路径。**当然,我们通常规定最终结点。Dijkstra适用于加权有向图和无向图,且所有边的权值都是非负的。
DFS本身并不是设计来解决最短路径问题的,尽管可以通过修改和结合其他技术来解决特定的路径问题。
2、工作原理与复杂度
总结一下:
Dijkstra算法:适合需要快速找到单源最短路径的场景,如导航系统、网络路由。在处理具有非负权重的图时效率高且保证找到最短路径。
DFS遍历:更适合用于图的遍历、检测连通性、生成树、拓扑排序等问题,而不是最短路径问题。使用DFS找最短路径需要遍历所有可能路径,效率低且不适合大规模图。
下面我们来介绍Dijkstra算法。本质上是一种贪心算法:
代码
对于遍历问题,我们使用邻接表就可以轻易完成。但是对于最小生成树、最短路径这种复杂问题,我们更推荐邻接矩阵这种更直观的方式。
#include <stdio.h>
#include <stdlib.h>
#define VNUM 5 // 可根据实际需要修改顶点数
#define INFINITY 32767 // 表示无穷大的一个较大值
void Dijkstra(int Weights[VNUM][VNUM], int v0, int Sweight[VNUM], int Spath[VNUM]) {
int i, j, v, minweight;
char wfound[VNUM] = {0}; // 用于标记从v0到相应顶点是否找到最短路径,0未找到,1找到
// 初始化数组Sweight和Spath
for(i = 0; i < VNUM; i++) {
Sweight[i] = Weights[v0][i];
Spath[i] = v0;
}
Sweight[v0] = 0;
wfound[v0] = 1;
// 迭代VNUM-1次
for(i = 0; i < VNUM-1; i++) {
minweight = INFINITY;
// 找到未标记的最小权重值顶点
for(j = 0; j < VNUM; j++) {
if(!wfound[j] && (Sweight[j] < minweight)) {
v = j;
minweight = Sweight[v];
}
}
wfound[v] = 1; // 标记该顶点为已找到最短路径
// 找到未标记顶点且其权值大于v的权值+(v,j)的权值,更新其权值
for(j = 0; j < VNUM; j++) {
if(!wfound[j] && (minweight + Weights[v][j] < Sweight[j])) {
Sweight[j] = minweight + Weights[v][j];
Spath[j] = v; // 记录前驱
Spath[j] = v; // 记录前驱顶点
}
}
}
}
// 测试函数
void printResult(int Sweight[VNUM], int Spath[VNUM], int startVertex) {
printf("Vertex\tDistance from Source\tPath\n");
for(int i = 0; i < VNUM; i++) {
if (i != startVertex) {
printf("%d\t\t%d\t\t\t%d\n", i, Sweight[i], Spath[i]);
}
}
}
int main() {
int Weights[VNUM][VNUM] = {
{0, 1, 4, INFINITY, INFINITY},
{1, 0, 2, 5, INFINITY},
{4, 2, 0, 1, INFINITY},
{INFINITY, 5, 1, 0, 3},
{INFINITY, INFINITY, INFINITY, 3, 0}
};
int Sweight[VNUM]; // 存储最短路径长度
int Spath[VNUM]; // 存储路径中的前驱节点
int startVertex = 0; // 起始顶点
// 调用Dijkstra算法
Dijkstra(Weights, startVertex, Sweight, Spath);
// 输出结果
printResult(Sweight, Spath, startVertex);
return 0;
}
邻接矩阵的具体构造见前篇。可以适当定义全局变量简化函数调用。
解释
这里重点不是会怎么写,而是怎么作为模板函数在不同具体情况下正确调用。我们首先看一下Dijkstra函数的参数:
下面看一下这个函数的返回值:
代码中给了一个示范输出:
我们要对这种输出做一点解释,这样面对其他的输出需求就可以得心应手。其中参数startVertex是起始顶点的序号。下面给出一种输出样例:
还是上述的一种“聪明”的做法,用一个数组SPath[]来存储一条路径,对于一个节点i,则SPath[i]存储的是i的前驱节点。上面的输出实质上提供了整条路径。
这里要明确一下,最短路径长度指的是路径上的所有边的权值之和。如果要其他需求(比如,路径中边的个数最小),那些都可以通过深度优先遍历解决。那就没有必要用Dijkstra算法。
例-北京地铁乘坐线路查询
BUAA图作业——编程题
4. 北京地铁乘坐线路查询(202205)
【问题描述】
编写一个程序实现北京地铁最短乘坐(站)线路查询,输入为起始站名和目的站名,输出为从起始站到目的站的最短乘坐站换乘线路。
注:1. 要求采用Dijkstra算法实现;2)如果两站间存在多条最短路径,找出其中的一条即可。
【输入形式】
文件bgstations.txt为数据文件(可从课程网站中课程信息处下载),包含了北京地铁的线路及车站信息。其格式如下:
<地铁线路总条数>
<线路1> <线路1站数>
<站名1> <换乘状态>
<站名2> <换乘状态>
…
<线路2> <线路2站数>
<站名1> <换乘状态>
<站名2> <换乘状态>
…
说明:文件第一行为地铁总线路数;第二行第一个数为某条地铁线线号(如,1为1号线),第二个数为该条地铁线的总站数(如1号线共有35站),两数之间由一个空格分隔;第三行两个数据分别为地铁站名及换乘状态(0为非换乘站,1为换乘站),两数据间由一个空格分隔;以下同,依次为该线地铁其它站信息。在一条线路信息之后是下条地铁线路信息,格式相同。若某条地铁线为环线,则首站与末站信息相同(如北京地铁2号线,首站信息“西直门 1” ,末站信息为“西直门 1”)。例如本题提供的bgstations.txt文件(可从课程网站中课程信息处下载)内容如下:
16
1 35
苹果园 1
古城 0
八角游乐园 0
八宝山 0
玉泉路 0
五棵松 0
万寿路 0
公主坟 1
军事博物馆 1
木樨地 0
南礼士路 0
复兴门 1
西单 1
…
2 19
西直门 1
积水潭 0
鼓楼大街 1
…
西直门 1
…
该文件表明当前北京地铁共有16条线路(不含郊区线路),接着为每条线路信息。
打开当前目录下文件bgstations.txt,读入地铁线路信息,并从标准输入中读入起始站和目的站名(均为字符串,各占一行)。
【输出形式】
输出从起始站到目的站的乘坐信息,要求乘坐站数最少。换乘信息格式如下:
SSN-n1(m1)-S1-n2(m2)-…-ESN
其中:SSN和ESN分别为起始站名和目的站名;n为乘坐的地铁线路号,m为乘坐站数;其它字符都是英文字符。
【样例输入】
西土城
北京西站
【样例输出】
西土城-10(1)-知春路-13(2)-西直门-4(2)-国家图书馆-9(4)-北京西站
(或西土城-10(1)-知春路-13(2)-西直门-2(1)-车公庄-6(2)-白石桥南-9(3)-北京西站)
【样例说明】
打开文件bgstations.txt,读入地铁线路信息,并从标准输入中读入查询起始站名为“西土城”,目的站名为“北京西站”。程序运行结果两站间最少乘坐站数的乘坐方式为“西土城站乘坐10号线1站至知春路站换乘13号线乘坐2站至西直门站换乘4号线乘坐2站至国家图书馆站换乘9号线乘坐4站至北京西站”。本样例存在两条最少站数的乘坐方式,只要找出一条就可以。
【评分标准】
对于同一个起始站和目的站,如果存在多条最少站数的乘坐方式,只要找出其中一条就可以。测试点全通过得满分。
完整代码:
#define _CRT_SECURE_NO_WARNINGS
#define MAX_VERTICES 512
#define INFINITY 32767
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct _station {
char stationName[100];
int transferLine[5];
int cntTransferLine;
}Station;
Station stationInfo[MAX_VERTICES];
int graph[MAX_VERTICES][MAX_VERTICES];
int numVertices; //所有站点的个数
int cntLine; //所有线路的总个数
int Sweight[MAX_VERTICES]; // 存储最短路径长度
int Spath[MAX_VERTICES]; // 存储路径中的前驱节点
int vStart, vEnd;
char startStation[100];
char endStation[100];
void initGraph();
int searchStation(char src[]);
void createGraph(FILE* fp);
void Dijkstra(int Weights[MAX_VERTICES][MAX_VERTICES], int v0, int Sweight[], int Spath[]);
void printPath(int v);
int main()
{
FILE* fp = fopen("bgstations.txt", "r");
initGraph();
createGraph(fp);
Dijkstra(graph, vStart, Sweight, Spath);
printPath(vEnd);
fclose(fp);
return 0;
}
void initGraph() {
int i, j;
for (i = 0; i < MAX_VERTICES; i++) {
for (j = 0; j < MAX_VERTICES; j++) {
graph[i][j] = INFINITY;
}
}
}
int searchStation(char src[]) {
//搜索某一个站名是否已在图中有。如果有,返回在stationinfo数组中的序号
int i;
for (i = 0; i < numVertices; i++) {
if (strcmp(stationInfo[i].stationName, src) == 0) {
return i;
}
}
return -1;
}
void createGraph(FILE* fp) {
int line;
int cntStation;
int i, j;
char stationName1[20] = { 0 };
char stationName2[20] = { 0 };
int transferSituation1; //第一个站的换乘情况
int transferSituation2; //第二个站的换乘情况
fscanf(fp, "%d", &cntLine);
for (j = 0; j < cntLine; j++) {
fscanf(fp, "%d%d", &line, &cntStation);
fgetc(fp);
fscanf(fp, "%s", stationName1);
fscanf(fp, "%d", &transferSituation1);
fgetc(fp);
if (searchStation(stationName1) == -1) {
strcpy(stationInfo[numVertices++].stationName, stationName1);
}
stationInfo[searchStation(stationName1)].transferLine[stationInfo[searchStation(stationName1)].cntTransferLine++] = line;
for (i = 1; i < cntStation; i++) {
fscanf(fp, "%s", stationName2);
fscanf(fp, "%d", &transferSituation2);
fgetc(fp);
if (searchStation(stationName2) == -1) {
strcpy(stationInfo[numVertices++].stationName, stationName2);
}
stationInfo[searchStation(stationName2)].transferLine[stationInfo[searchStation(stationName2)].cntTransferLine++] = line;
graph[searchStation(stationName1)][searchStation(stationName2)] = 1;
graph[searchStation(stationName2)][searchStation(stationName1)] = 1;
strcpy(stationName1, stationName2);
transferSituation1 = transferSituation2;
}
}
scanf("%s", startStation);
scanf("%s", endStation);
vStart = searchStation(startStation);
vEnd = searchStation(endStation);
}
void Dijkstra(int Weights[MAX_VERTICES][MAX_VERTICES], int v0, int Sweight[], int Spath[]) {
int i, j, v, minweight;
char wfound[MAX_VERTICES] = { 0 }; // 用于标记从v0到相应顶点是否找到最短路径,0未找到,1找到
// 初始化数组Sweight和Spath
for (i = 0; i < MAX_VERTICES; i++) {
Sweight[i] = Weights[v0][i];
Spath[i] = v0;
}
Sweight[v0] = 0;
wfound[v0] = 1;
// 迭代VNUM-1次
for (i = 0; i < MAX_VERTICES - 1; i++) {
minweight = INFINITY;
// 找到未标记的最小权重值顶点
for (j = 0; j < MAX_VERTICES; j++) {
if (!wfound[j] && (Sweight[j] < minweight)) {
v = j;
minweight = Sweight[v];
}
}
wfound[v] = 1; // 标记该顶点为已找到最短路径
// 找到未标记顶点且其权值大于v的权值+(v,j)的权值,更新其权值
for (j = 0; j < MAX_VERTICES; j++) {
if (!wfound[j] && (minweight + Weights[v][j] < Sweight[j])) {
Sweight[j] = minweight + Weights[v][j];
Spath[j] = v; // 记录前驱
Spath[j] = v; // 记录前驱顶点
}
}
}
}
void printPath(int v) {
static int lastLine = -1;
static int stationCount = 0;
static int firstCall = 1;
if (v == vStart) {
if (!firstCall) {
printf("-%d(%d)-%s", lastLine, stationCount, stationInfo[v].stationName);
}
else {
printf("%s", stationInfo[v].stationName);
firstCall = 0;
}
return;
}
printPath(Spath[v]);
int currentLine = -1;
int previousStation = Spath[v];
// 找到当前站和前一个站之间的线路
for (int i = 0; i < stationInfo[v].cntTransferLine; i++) {
for (int j = 0; j < stationInfo[previousStation].cntTransferLine; j++) {
if (stationInfo[v].transferLine[i] == stationInfo[previousStation].transferLine[j]) {
currentLine = stationInfo[v].transferLine[i];
break;
}
}
if (currentLine != -1) break;
}
if (lastLine == -1) {
lastLine = currentLine;
stationCount = 1;
}
else if (currentLine == lastLine) {
stationCount++;
}
else {
printf("-%d(%d)-%s", lastLine, stationCount, stationInfo[previousStation].stationName);
lastLine = currentLine;
stationCount = 1;
}
// 处理最后一站
if (v == vEnd) {
printf("-%d(%d)-%s", lastLine, stationCount, stationInfo[v].stationName);
}
}
其他部分比较简单,主要的也是最复杂的部分就是这个PrintPath函数。我们来详细的解释一下:
这个函数 printPath用于输出地铁路径,并且在路径改变时打印换乘的信息。函数通过递归来遍历路径,并且跟踪和输出换乘线路和站点数量。一定要注意,函数需要的参数v是终点站vEnd,而不是起点站。
int v
: 当前站点的索引。
static int lastLine = -1
: 静态变量,记录上一次访问的线路号,初始值为 -1。
static int stationCount = 0
: 静态变量,记录当前线路上经过的站点数,初始值为 0。
static int firstCall = 1
: 静态变量,标识是否是第一次调用该函数,初始值为 1。
全程使用递归。当 v
为起点 vStart
时,表示路径递归到达了起点,停止递归。如果是第一次调用,直接打印起点站名。如果不是第一次调用,打印换乘信息以及起点站名。返回结束当前递归。
找到当前站和前一个站之间的线路:初始化 currentLine
为 -1。获取当前站点的前一个站点 previousStation
。嵌套循环查找当前站点和前一个站点之间的公共线路,将其存入 currentLine
。
处理线路和站点计数:如果 lastLine
为 -1,表示第一次赋值,将 currentLine
赋给 lastLine
并将 stationCount
置为 1。如果 currentLine
和 lastLine
相同,表示没有换乘,增加 stationCount
。如果 currentLine
和 lastLine
不同,表示换乘线路,打印换乘信息,更新 lastLine
并重置 stationCount
为 1。
处理最后一站:如果 v
为终点 vEnd
,表示路径已经遍历完,打印最后一段路径的换乘信息和终点站名。
拓展:如果换个需求,要求打印其中所有的站点名称,比如“知春路 -> 知春里…”该如何修改printPath函数?
void printPath(int v) {
if (v == vStart) {
printf("%s", stationInfo[v].stationName);
} else if (Spath[v] == -1) {
printf("No path found from %s to %s.\n", startStation, endStation);
} else {
printPath(Spath[v]);
printf(" -> %s", stationInfo[v].stationName);
}
}
总结—三者重要对比
曾经有一道非常经典的考试题:
答案是D,因为ABC都非常正确。D中“从某原点到图中各顶点的最短路径构成的生成树”实际上就是我们的Dijkstra算法,生成的最小路径。那问题就来了:这个Dijkstra搞出来的生成树和最小生成树,和我们之前说的独立路径到底三者有什么区别?这里我们非常细致的进行梳理。
- 独立路径。也就是我们很早就说的:给定一张图,输出一个点到另一个点的最小带权路径。这里已经很清楚了,带权路径。也就是每条边都有不同的权值,找一条路,保证是所有路中权值加起来最小的就好了。也就是全程**只涉及两个点之间的事情,**给出来的路径是一条“曲线”,是一个点到另一个点的通路,不是一个“树”。
- 最小生成树。我们介绍了Prim和克鲁斯卡尔两种算法。一定要搞明白最终目的:最小生成树是为了把一张很庞大错综复杂的图中提炼出一张最小带权连通图。也就是有全部n个顶点,但只有n-1条边,且边权值之和,也就是整个树的所有边权值加起来最小。所以,如果一张图有唯一的最小生成树(通常不是唯一的),那么Prim和克鲁斯卡尔两种算法两种算法给出的最小生成树相同。只是构造步骤不同。
- 最短路径。也就是Dijkstra算法,最大的特点就是优先选择权重最小的路径。Dijkstra算法是通过优先选择权重最小的路径逐步扩展生成树,确保每一步选择的路径是当前已知的最短路径。当然,如果所有边权值为1,则这样生成出来的最短路径生成树和最小生成树是一样的。
应用1-图遍历(图-基本题)
【问题描述】
给定一个无向图和一个图顶点,编程输出该图删除给定顶点前后按深度优先遍历及广度优先遍历方式遍历的图顶点序列。
给定的无向图和图顶点满足以下要求:
1、无向图的顶点个数n大于等于3,小于等于100,输入时顶点编号用整数0~n-1表示;
2、无向图在删除给定顶点前后都是连通的;
3、无论何种遍历,都是从编号为0的顶点开始遍历,访问相邻顶点时按照编号从小到大的顺序访问;
4、删除的顶点编号不为0。
【输入形式】
先从标准输入中输入图的顶点个数和边的个数,两整数之间以一个空格分隔,然后从下一行开始分行输入每条边的信息(用边两端的顶点编号表示一条边,以一个空格分隔顶点编号,边的输入次序和每条边两端顶点编号的输入次序可以是任意的,但边不会重复输入),最后在新的一行上输入要删除的顶点编号。
【输出形式】
分行输出各遍历顶点序列,顶点编号之间以一个空格分隔。先输出删除给定顶点前的深度优先遍历顶点序列和广度优先遍历顶点序列,再输出删除给定顶点后的深度优先遍历顶点序列和广度优先遍历顶点序列。
【样例输入】
9 10
0 1
0 2
1 4
1 3
1 8
8 6
3 6
7 2
7 5
5 2
3
【样例输出】
0 1 3 6 8 4 2 5 7
0 1 2 3 4 8 5 7 6
0 1 4 8 6 2 5 7
0 1 2 4 8 5 7 6
【样例说明】
输入的无向图有9个顶点,10条边(如下图所示),要删除的顶点编号为3。
从顶点0开始,按照深度优先和广度优先遍历的顶点序列分别为:0 1 3 6 8 4 2 5 7和0 1 2 3 4 8 5 7 6。删除编号为3的顶点后,按照深度优先和广度优先遍历的顶点序列分别为:0 1 4 8 6 2 5 7和0 1 2 4 8 5 7 6。
【评分标准】
该题要求按照深度优先和广度优先遍历方式输出删除给定顶点前后的遍历序列,提交程序名为graphSearch.c。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
int v; //顶点
int e; //边
int Q[500] = { -1 }; //队列
int front = -1;
int rear = -1;
typedef struct edge {
int adj;
struct edge* next;
}Elink;
typedef struct ver {
Elink* next;
}Vlink;
Vlink G[100];
int visited[100];
Elink* insertEdge(Elink* head, int avex);
void sortAdjList(Vlink G[], int n);
void DFS(Vlink G[], int v);
void travelDFS(Vlink G[], int n);
void BFS(Vlink G[], int v);
void travelBFS(Vlink G[], int n);
int isEmptyQueue();
void enQueue(int num);
int deQueue();
void deleteNode(int vDel);
int main()
{
int i;
int v1, v2;
int vDel;
scanf("%d%d", &v, &e);
for (i = 0; i < e; i++) {
scanf("%d%d", &v1, &v2);
G[v1].next = insertEdge(G[v1].next, v2);
G[v2].next = insertEdge(G[v2].next, v1);
}
scanf("%d", &vDel);
sortAdjList(G, v);
memset(visited, 0, sizeof(visited));
travelDFS(G, v);
memset(visited, 0, sizeof(visited));
travelBFS(G, v);
memset(visited, 0, sizeof(visited));
visited[vDel] = 1;
travelDFS(G, v);
memset(visited, 0, sizeof(visited));
visited[vDel] = 1;
travelBFS(G, v);
return 0;
}
Elink* insertEdge(Elink* head, int avex) {
Elink* p = (Elink*)malloc(sizeof(Elink));
p->adj = avex;
p->next = NULL;
if (head == NULL) {
head = p;
return head;
}
Elink* last = head;
while (last->next != NULL) {
last = last->next;
}
last->next = p;
return head;
}
void travelDFS(Vlink G[], int n) {
int i;
for (i = 0; i < n; i++) {
if (!visited[i]) {
DFS(G, i);
}
}
printf("\n");
}
void DFS(Vlink G[], int v) {
//这是辅助函数
visited[v] = 1;
printf("%d ", v);
Elink* p;
for (p = G[v].next; p != NULL; p = p->next) {
if (!visited[p->adj]) {
DFS(G, p->adj);
}
}
}
void travelBFS(Vlink G[], int n) {
int i;
for (i = 0; i < n; i++) {
if (!visited[i]) {
BFS(G, i);
}
}
printf("\n");
}
void BFS(Vlink G[], int v) {
Elink* p;
visited[v] = 1;
enQueue(v);
while (!isEmptyQueue()) {
v = deQueue();
printf("%d ", v);
for (p = G[v].next; p != NULL; p = p->next) {
if (!visited[p->adj]) {
visited[p->adj] = 1;
enQueue(p->adj);
}
}
}
}
int isEmptyQueue() {
return front == -1 || front > rear;
}
void enQueue(int num) {
if (rear == 500) {
return;
}
if (front == -1) {
front = 0;
}
Q[++rear] = num;
}
int deQueue() {
if (front == -1 || front > rear) {
return -1; //underflow
}
return Q[front++];
}
void sortAdjList(Vlink G[], int n) {
int cnt = 0;
Elink* p, *q;
int i;
int temp;
for (i = 0; i < n; i++) {
cnt = 0;
for (p = G[i].next; p != NULL; p = p->next) {
cnt++;
}
for (p = G[i].next; p != NULL; p = p->next) {
for (q = p->next; q != NULL; q = q->next) {
if (p->adj > q->adj) {
temp = p->adj;
p->adj = q->adj;
q->adj = temp;
}
}
}
}
}
void deleteNode(int vDel) {
Elink* p, * prev;
// 删除该节点的所有出边
p = G[vDel].next;
while (p != NULL) {
Elink* temp = p;
p = p->next;
free(temp);
}
G[vDel].next = NULL;
// 删除所有指向该节点的入边
for (int i = 0; i < v; i++) {
if (i == vDel) continue;
prev = NULL;
p = G[i].next;
while (p != NULL) {
if (p->adj == vDel) {
if (prev == NULL) {
G[i].next = p->next;
}
else {
prev->next = p->next;
}
free(p);
break;
}
prev = p;
p = p->next;
}
}
}
应用2-最小布线(图)
【问题描述】
北航主要办公科研楼有新主楼、逸夫楼、如心楼、办公楼、图书馆、主楼、一号楼等等;。北航网络中心计划要给相关建筑物间铺设光缆进行网络连通,请给出用料最少的铺设方案。
编写程序输入一个办公区域分布图及建筑物之间的距离,计算出用料最少的铺设方案(只有一组最优解,不用考虑多组解)。要求采用Prim或Kruskal算法实现。
【输入形式】
办公区域分布图的顶点(即建筑物)按照自然数(0,1,2,n-1)进行编号,从标准输入中首先输入两个正整数,分别表示线路图的顶点的数目和边的数目,然后在接下的行中输入每条边的信息,每条边占一行,具体形式如下:
<n> <e>
<id> <vi> <vj> <weight>
…
即顶点vi和vj之间边的权重是weight,边的编号是id。
【输出形式】
输出铺设光缆的最小用料数,然后另起一行输出需要铺设的边的id,并且输出的id值按照升序输出。
【样例输入】
6 10
1 0 1 600
2 0 2 100
3 0 3 500
4 1 2 500
5 2 3 500
6 1 4 300
7 2 4 600
8 2 5 400
9 3 5 200
10 4 5 600
【样例输出】
1500
2 4 6 8 9
【样例说明】
样例输入说明该分布图有6个顶点,10条边;顶点0和1之间有条边,边的编号为1,权重为600;顶点0和2之间有条边,权重为100,其它类推。其对应图如下:
经计算此图的最少用料是1500,可以使图连通,边的编号是2 4 6 8 9。其对应的最小生成树如下:
【评分标准】
通过所有测试点满分。
#define _CRT_SECURE_NO_WARNINGS
#define MAX_VERTICES 100
#define INFINITY 32767
#include <stdio.h>
#include <stdlib.h>
typedef struct _Edge {
int id;
int wei;
}Edge;
Edge graph[MAX_VERTICES][MAX_VERTICES];
int numVertices;
void initGraph(int e, int n);
void Prim(Edge weights[][MAX_VERTICES], int n, int src, int edges[]);
int cmp(const void* a, const void* b);
void print(int edges[], int n);
int main()
{
int n, e;
int i;
int edges[100] = { 0 };
scanf("%d%d", &n, &e);
numVertices = n; //顶点的个数
initGraph(e, n);
Prim(graph, n, 0, edges);
print(edges, n);
return 0;
}
void initGraph(int e, int n) {
int i, j;
int id, v1, v2, wei;
for (i = 0; i < e; i++) {
scanf("%d%d%d%d", &id, &v1, &v2, &wei);
graph[v1][v2].id = id;
graph[v1][v2].wei = wei;
graph[v2][v1].id = id;
graph[v2][v1].wei = wei;
}
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
if (graph[i][j].wei == 0) {
graph[i][j].wei = INFINITY;
}
}
}
}
void Prim(Edge weights[][MAX_VERTICES], int n, int src, int edges[])
{ //weights为权重数组、n为顶点个数、src为最小树的第一个顶点、edge为最小生成树边
int minweight[MAX_VERTICES], min;
int i, j, k;
for (i = 0; i < n; i++) { //初始化相关数组
minweight[i] = weights[src][i].wei; //将src顶点与之有边的权值存入数组
edges[i] = src; //初始时所有顶点的前序顶点设为src,(src,i)
}
minweight[src] = 0; //将第一个顶点src顶点加入生成树
for (i = 1; i < n; i++) {
min = INFINITY;
for (j = 0, k = 0; j < n; j++) {
if (minweight[j] != 0 && minweight[j] < min) { //在数组中找最小值,其下标为k
min = minweight[j];
k = j;
}
}
minweight[k] = 0; //找到最小树的一个顶点
for (j = 0; j < n; j++) {
if (minweight[j] != 0 && weights[k][j].wei < minweight[j]) {
minweight[j] = weights[k][j].wei; //将小于当前权值的边(k,j)权值加入数组中
edges[j] = k; //将边(j,k)信息存入边数组中
}
}
}
}
int cmp(const void* a, const void* b) {
return *(int*)a - *(int*)b;
}
void print(int edges[], int n) {
int i, j = 0;
int sum = 0;
int e[100] = { 0 };
for (i = 1; i < n; i++) {
sum += graph[i][edges[i]].wei;
}
printf("%d\n", sum);
for (i = 1; i < n; i++) {
e[j++] = graph[i][edges[i]].id;
}
qsort(e, n - 1, sizeof(int), cmp);
for (i = 0; i < n - 1; i++) {
printf("%d ", e[i]);
}
}
写在最后的话
数据结构的所有基本内容就到此结束了,但只是最基本的内容,一切技巧和能力需要充足的经验和操练。图被认为是最困难的内容,许多学校(如BUAA)可能期末考试不考关于图的编程题。但是其中的思想都是相通的。所以有必要进行详细总结和解释。学习计算机不是一帆风顺的,因为计算思维的很多东西与人类思维就是背道而驰的,需要适应这种扭曲和矛盾。
本人在数据结构与算法课程中曾经遇到一些重大挫折。在非常简单的期中考试中,我出现考场心态问题和技术能力问题只考了23/30,而理论上这场考试是为了保证减少挂科率而设置的一场非常水的考试。同学们纷纷代码改改就很快交卷了。这次对我的打击非常大,但更让我知道了平时的训练手感、经验以及代码封装的重要性。在学期后期,我大力弥补数据结构,形成了全面的基本理论体系,形成了这一系列的数据结构精简经验。实践证明,这取得了非常好的效果,能提高代码能力和正确性,尤其是在工程中和考试中。
学习计算机更像一场修行,外界所有帮助都是点到为止,深还是浅,完全靠自己的实践。在人工智能的背景下,应用能力是非常重要的。要快速学会代码封装和迁移,快速进行问题分析,而不是死死的写代码。在这场漫长而艰辛的旅途中,我们同舟共济,也能时不时欣赏到路途中出乎意料的绚丽风景。