算法基础实验:Kruskal和Johnson
实验报告
1. 实验内容
- 实现求最小生成树的Kruskal算法。无向图的顶点数N的取值分别为: 8、 64、128、512,对每一顶点随机生成1~⌊N/2⌋条边,随机生成边的权重, 统计算法所需运行时间 ,画出时间曲线,分析程序性能。
- 实现求所有点对最短路径的Johnson算法。有向图的顶点数 N 的取值分别为: 27、81、243、729 ,每个顶点作为起点引出的边的条数取值分别 为:log5N、log7N(取下整)。图的输入规模总共有4*2=8个,若同一个 N,边的两种规模取值相等,则按后面输出要求输出两次,并在报告里 说明。(不允许多重边,可以有环。)
2. 实验设备环境
设备:2018 MacBook Pro 13-inch,2.3GHz 4核Intel Core i5,8GB 2133MHz LPDDR3内存
系统:macOS Big Sur 11.0 Beta
IDE:Visual Studio Code,CLion
3. 实验方法
在算法基础课本上已经给出相应算法的思想和伪代码,只需根据伪代码编写相应的C++代码,在代码中添加适当的注释、计算时间和输出到文件的部分即可。需要注意的是,本实验中采用的Dijkstra算法和书上的略有区别,所以Johnson算法的时间复杂度也略有出入。
4. 实验步骤
-
Kruskal:
-
编写生成数据的程序datagen.cpp。
//这个文件用于随机产生数据 #include <stdlib.h> #include <time.h> #include <fstream> using namespace std; int main() { ofstream fp; srand(time(NULL)); int edge[512][512] = {0}; int full[512] = {0}; int cur = 8; //当前生成点数为8的输入文件 fp.open("input1.txt", ios::out); for (int i = 0; i < cur; i++) { int x = 1 + rand()%(cur/2); //确定当前节点要有几条边 full[i] = x; //记录这个点的度数 for (int k = 0; k < cur; k++) { x -= edge[i][k]; //如果之前和别的点相连,那么就不需要生成这么多的边 } while(x > 0){ //到生成目标数的边之前是不会停下来的 for (int j = 0; j < cur; j++) { if(!x) break; if(j==i) continue; else if (edge[i][j] == 0 && full[j] < cur/2) //找一个度数未满而且不连的点 { if (rand()%2){ //随机数判断要不要连接 edge[i][j] = 1; edge[j][i] = 1; x--; int weight = 1+rand()%20; //产生权重,输出到文件 fp << i+1 << " " << j+1 << " " << weight << endl; } } } } } fp.close(); memset(full,0,sizeof(int)*cur); memset(edge,0,sizeof(int)*cur*cur); //重置所需要的数组元素 /**************************************************************************** // // // 64、128和512的都和上方类似,更改cur即可,此处省略 // // //*************************************************************************** */ return 0; }
-
包含头文件。同时为了方便程序操作,定义全局变量和边的数据结构。
//Kruskal Algorithm #include <algorithm> #include <chrono> #include <ratio> #include <iostream> #include <fstream> #define N 1000 #define M 100000 using namespace std; struct edge { int u, v, w; //一条边包括连接的两个点和权重,由于无方向所以不需要严格规定u、v的值 }Edge[M + 1]; //初始化一个结构数组,包含所有的边 bool compare(struct edge x, struct edge y){ return x.w < y.w; //用于后续给边进行排序用 } int PARENT[N + 1]; //用于集合操作的数组,采用路径压缩方法
-
完成路径压缩方法的查找集合函数。
int Find_Set(int x) //采用迭代方式,让同一个集合里面用到的点都指向同一个点,这样就可以用这个点作为集合的代表元素 { if (x != PARENT[x]) PARENT[x] = Find_Set(PARENT[x]); return PARENT[x]; }
-
完成主程序,负责读入文件和计时以及输出。也需要在最后关闭所有文件指针。
ifstream inf; ofstream ouf; ofstream outime; outime.open("../output/time.txt",ios::out); chrono::high_resolution_clock::time_point t1,t2; /* * scale: 8 * * * */ inf.open("../input/input1.txt",ios::in); ouf.open("../output/result1.txt",ios::out); //文件输入输出 int n = 8, m = 0, answer = 0; //n为顶点,m为边数 while (inf >> Edge[m].u >> Edge[m].v >> Edge[m].w){ m++; //读入 } for (int i = 1; i <= n; i++) //初始化集合 PARENT[i] = i; t1 = chrono::high_resolution_clock::now(); sort(Edge, Edge + m, compare); //将边进行排序 for (int i = 0, edgeCount = 0; i < m; i++) { int set_u = Find_Set(Edge[i].u), set_v = Find_Set(Edge[i].v); if (set_u == set_v) //不能有环 continue; edgeCount++; answer += Edge[i].w; //添加这条边 ouf << Edge[i].u << " " << Edge[i].v << " " << Edge[i].w << endl; PARENT[set_v] = set_u; //合并集合 if (edgeCount == n - 1) //已经达到要求,停止 break; } t2 = chrono::high_resolution_clock::now(); chrono::duration<double,std::milli> time_span = t2-t1; ouf << answer << endl ; outime << "scale = 8 : " << time_span.count() << "ms\n"; inf.close(); ouf.close(); /* * * * 64和之后的部分都是类似的,运行之前重新读入数据即可,此处省略 * * * */ outime.close(); return 0; }
-
-
Johnson算法
-
同第一个程序类似,编写产生数据的程序。
//这个文件用于随机产生数据 #include <stdlib.h> #include <time.h> #include <fstream> #include <algorithm> #include <iostream> #include <queue> #define N 100000 #define M 1000000 using namespace std; int main() { ofstream fp1, fp2; srand(time(NULL)); int cur,min_deg; /* scale = 27 */ cur = 27, min_deg = 1; fp1.open("input12.txt", ios::out); fp2.open("input11.txt", ios::out); for (int i = 0; i < cur; i++) { int x = min_deg, y = min_deg + 1; //相同顶点数有两种不同的边数要求 int used[729] = {0}; while (y) { int j = rand()%cur; if (i == j) continue; if (!y) break; if (used[j] == 0 && rand() % 2) //在一个循环中写入两个文件简化操作 { used[j] = 1; fp2 << i+1 << " " << j+1 << " " << rand() % 50 << endl; y--; if (x > 0) { fp1 << i+1 << " " << j+1 << " " << rand() % 50 << endl; x--; } } } } fp1.close(); fp2.close(); /* * * * 之后的部分都是类似的,只要改变min_deg以及x和y的值即可,此处省略 * * * */ return 0; }
-
同第一个程序类似。包含所需头文件,定义数据结构和全局变量、文件指针。这里注意到优先队列事实上就是用堆实现的,所以直接使用。
//Johnson Algorithm #include <algorithm> #include <chrono> #include <ratio> #include <iostream> #include <fstream> #include <queue> #include <climits> #define N 10000 #define M 100000 using namespace std; ifstream inf; //输入输出文件 ofstream ouf; int unchanged_weight[M] = {0}; //用来记录Johnson算法所需要的原始distance值 struct edge { int u, v, w; //边数据结构 } Edge[M + 1]; struct vertex { int distance; //计算的路径 int pi; //前驱节点 int deg_in, deg_out; //出入度数 int out[N]; //出边 int in[M]; //入边 } Vertex[N + 1]; struct QueueNode { //定义优先队列的数据结构 int position; int distance; // 重载操作符,权重和距离成反比,从而让优先队列按照从小到大顺序排列 bool operator<(const QueueNode &that) const { return this->distance > that.distance; } }; priority_queue<QueueNode> Q; //声明一个优先队列
-
设计Bellman-Ford、Dijkstra、Johnson算法,包括所需的Relax函数以及Initialize_Single_Source函数,如下。
void Relax(int m) { //relax函数按书上编写 for (int i = 0; i < m; i++) { if (Vertex[Edge[i].v].distance == INT_MAX && Vertex[Edge[i].u].distance == INT_MAX) continue; else if (Vertex[Edge[i].u].distance == INT_MAX) continue; else if (Vertex[Edge[i].v].distance > Vertex[Edge[i].u].distance + Edge[i].w) { Vertex[Edge[i].v].distance = Vertex[Edge[i].u].distance + Edge[i].w; Vertex[Edge[i].v].pi = Edge[i].u; } } } void Initialize_Single_Source(int vert, int s) { for (int i = 1; i <= vert; i++) { Vertex[i].distance = INT_MAX; Vertex[i].pi = 0; //初始化,和书上一样 } Vertex[s].distance = 0; } int Bellman_Ford(int vert, int edg, int s) { //和书上一样的Bellman——Ford Initialize_Single_Source(vert, s); for (int i = 1; i < vert; i++) { Relax(edg); } /*for (int i = 1; i < vert; i++) { if (Vertex[Edge[i].v].distance == INT_MAX && Vertex[Edge[i].u].distance == INT_MAX) continue; else if (Vertex[Edge[i].u].distance == INT_MAX) continue; else if (Vertex[Edge[i].v].distance > Vertex[Edge[i].u].distance + Edge[i].w) { return 0; } }*/ //上方这段可以不需要,因为必不会生成负环 return 1; } void Dijkstra(int vert, int s) { Initialize_Single_Source(vert, s); bool IfVisited[N] = {false}; //记录是否访问过 Q.push((QueueNode) {s, 0}); Vertex[s].pi = 0; while (!Q.empty()) { //采用类似广度优先搜索的方法编写 int x = Q.top().position; // 队头结点 Q.pop(); if (IfVisited[x]) // 已被访问过 continue; IfVisited[x] = true; for (int i = 0; i < Vertex[x].deg_out; i++) // 遍历以x为起点的边 { struct edge temp = Edge[Vertex[x].out[i]]; int y = temp.v; // 边的终点 if (Vertex[y].distance > Vertex[x].distance + temp.w) { Vertex[y].distance = Vertex[x].distance + temp.w; Vertex[y].pi = x; //设置节 if (!IfVisited[y]) Q.push((QueueNode) {y, Vertex[y].distance}); } } } } void Johnson(int scale, int edg) { //生成额外的节点,即图G’ Vertex[0].distance = 0; Vertex[0].deg_out = scale; for (int i = 0; i < scale; i++) { Edge[i + edg].w = Edge[i + edg].u = 0; Edge[i + edg].v = i + 1; Vertex[0].out[i] = i + edg; } Bellman_Ford(scale, edg + scale, 0); //增加边后运行一次BF //存储尚未修改的边权 for (int i = 0; i < scale; i++) { unchanged_weight[i] = Vertex[i].distance; } //修改边权为w’ for (int i = 0; i < edg; i++) { Edge[i].w = Edge[i].w + unchanged_weight[Edge[i].u] - unchanged_weight[Edge[i].v]; } //对每个节点运行Dijkstra算法并输出到文件 for (int i = 1; i <= scale; i++) { Dijkstra(scale, i); for (int x = 0; x < edg; x++) { Edge[x].w = Edge[x].w - unchanged_weight[Edge[x].u] + unchanged_weight[Edge[x].v]; } for (int j = 1; j <= scale; j++) { if (i == j) continue; if (Vertex[j].distance == INT_MAX) { //如果距离是无穷大,证明不连通 ouf << "(" << i << "," << j << " NULL)\n"; } else { int chain[N] = {0}; //否则从后向前记录前驱,之后正向输出 int p = 0; for (int l = j; Vertex[l].pi != 0; l = Vertex[l].pi) { chain[p++] = Vertex[l].pi; } ouf << "(" << i << ","; p = p - 2; while (p >= 0) { ouf << chain[p--] << ","; } ouf << j << " " << Vertex[j].distance << ")\n"; } } } } void Reset(int scale){ //初始化一遍后续使用 for (int i = 0; i <= scale; i++) { Vertex[i].deg_out = 0; Vertex[i].deg_in = 0; Vertex[i].distance = 0; } }
-
编写主程序,包括循环输入不同规模数据,计时,以及关闭文件。
int main() { int scale; //表示当前输入的点个数 int i, m, u, v, w; //初始化一些值 ofstream outime; outime.open("../output/time.txt", ios::out); chrono::high_resolution_clock::time_point t1, t2; //计时用 /* * scale = 27 */ inf.open("../input/input11.txt", ios::in); ouf.open("../output/result11.txt", ios::out); scale = 27; i = 0, m = 0; while (inf >> u >> v >> w) { //输入 Edge[i].u = u; Edge[i].v = v; Edge[i].w = w; Vertex[u].out[Vertex[u].deg_out++] = i; Vertex[v].in[Vertex[u].deg_in++] = i; m++; i++; } t1 = chrono::high_resolution_clock::now(); Johnson(scale, m); //运行Johnson算法 t2 = chrono::high_resolution_clock::now(); chrono::duration<double, std::milli> time_span = t2 - t1; outime << "scale: 1,1 : " << time_span.count() << "ms\n"; Reset(scale); inf.close(); ouf.close(); //对相同顶点数但是边数不同的数据重新输入运行 inf.open("../input/input12.txt", ios::in); ouf.open("../output/result12.txt", ios::out); i = 0, m = 0; while (inf >> u >> v >> w) { Edge[i].u = u; Edge[i].v = v; Edge[i].w = w; Vertex[u].out[Vertex[u].deg_out++] = i; Vertex[v].in[Vertex[u].deg_in++] = i; m++; i++; } t1 = chrono::high_resolution_clock::now(); Johnson(scale, m); t2 = chrono::high_resolution_clock::now(); time_span = t2 - t1; outime << "scale: 1,2 : " << time_span.count() << "ms\n"; Reset(scale); //使用完后一定要reset! inf.close(); //关闭文件 ouf.close(); /* * * * 之后的部分都是类似的,只要改变scale为相应的点个数即可,此处省略 * * * */ outime.close(); return 0; }
-
5. 结果分析
1.Kruskal算法时间复杂度是O(ElgV)的。通过对集合的处理,可以得到效率很高的算法。
2.理论上,BFS算法的复杂度是O(V+E),但是在这里使用的是优先队列,所以每次push和pop操作都需要O(lgV)时间,因此Dijkstra算法的复杂度相当于对每个点进行初始化的时间加上使用优先队列后的广度优先搜索时间,即:V+V*(lgV*2)+E。在Johnson算法中,执行一次BF算法需要V*E时间,之后进行V次Dijkstra,所以是2VE+V^2+2V^2(lgV),也就是O(VE+V^2*lgV),和斐波那契堆的复杂度类似。从算法运行的时间图上也可以知道结论正确。
3.采用了编译器优化可以减少时间,在CMakeList中加入:set(CMAKE_CXX_FLAGS_RELEASE:STRING=-O3 -DNDEBUG)
4.使用路径压缩的方法可以大大简化集合的查找和合并操作,只需要一个常数数组就可以实现,比传统的链表等形式更加优越,在适当地方使用效果极好。
5.C++都自带queue库,基于二叉堆实现,因此可以用优先队列来简化Dijkstra算法的操作。
6.灵活使用运算符重载可以很方便地制定最小或者最大堆等操作,也可以用在排序中。使用const可以保证操作对象不发生改变。
7.this是C++中自带的指向自身的指针,灵活运用可以使代码更易读更清晰。