0.PTA得分截图
1.本周学习总结
1.1 总结图内容
-
图的定义:顶点集V和顶点间的关系:边集合E组成的数据结构,包括有向图和无向图。
- 有向图是由顶点集和弧集构成的图,其中“弧”是有方向的边,用尖括号表示
- 无向图中没有方向边,每条边用圆括号表示
- 有向图是由顶点集和弧集构成的图,其中“弧”是有方向的边,用尖括号表示
-
图存储结构
- 邻接矩阵存储结构:无向图的邻接矩阵是对称的,有向图的邻接矩阵可能不对称,完全有向图的邻接矩阵对称。
- 邻接矩阵的主要特点:一个图的邻接矩阵表示是唯一的;邻接矩阵适合于稠密图的存储,存储空间为O(n2),n为图中顶点的个数。
- 无向图中,统计邻接矩阵中第i行(列)1的个数可得顶点i的度。有向图中,统计邻接矩阵中第i行1的个数,可得顶点i的出度;统计邻接矩阵中第j列1的个数,可得顶点j的入度
- 图的定义
#define MAXV 20 typedef struct { int edges[MAXV][MAXV]; //邻接矩阵 int n,e; //顶点数,弧数 } MGraph; //图的邻接矩阵表示类型
- 邻接矩阵存储结构:无向图的邻接矩阵是对称的,有向图的邻接矩阵可能不对称,完全有向图的邻接矩阵对称。
- 邻接表存储结构:对图中每个顶点i建立一个单链表,将顶点i的所有邻接点链起来,图的邻接表存储方法是一种顺序分配与链式分配相结合的存储方法
#define MAXV 20
typedef struct ANode {
int adjvex; //该边的终点编号
struct ANode *nextarc; //指向下一条边的指针
int info; //该边的相关信息,如权重
} ArcNode; //边表节点类型
typedef struct Vnode {
int data; //顶点信息
ArcNode *firstarc; //指向第一条边
} VNode; //邻接表头节点类型
typedef struct {
VNode adjlist[MAXV]; //邻接表
int n,e; //图中顶点数n和边数e
} AdjGraph;
-
创建图的运算算法
- 根据邻接矩阵存储结构创建无向图
void CreateMGraph(MGraph& g, int n, int e) { int i, a, b, j; for (i = 1; i <= n; i++) { for (j = 1; j <= n; j++) { g.edges[i][j] = 0; } } for (i = 1; i <= e; i++) { cin >> a >> b; g.edges[a][b] = 1; g.edges[b][a] = 1; } g.n = n; g.e = e; }
- 根据邻接表存储结构创建无向图
void CreateAdj(AdjGraph*& G, int n, int e) { int i, numOne, numTwo; ArcNode* ptr; G = new AdjGraph; for (i = 1; i <= n; i++) { G->adjlist[i].firstarc = NULL; } for (i = 1; i <= e; i++) { cin >> numOne >> numTwo; ptr = new ArcNode; ptr->adjvex = numTwo; ptr->nextarc = G->adjlist[numOne].firstarc; G->adjlist[numOne].firstarc = ptr; ptr = new ArcNode; ptr->adjvex = numOne; ptr->nextarc = G->adjlist[numTwo].firstarc; G->adjlist[numTwo].firstarc = ptr; } G->n = n; G->e = e; }
-
图遍历及应用
-
图遍历:从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次
- 深度优先遍历(DFS):使用递归,从当前节点访问其邻接点,再从邻接点开始继续递归访问下去
- 根据邻接矩阵存储结构实现图的深度遍历
void DFS(MGraph g, int v) { int i; if (!visited[v]) //visited[]数组用以表示节点是否访问过 { cout << v << " "; visited[v] = 1; //节点访问过,数组的值置为1 } for (i = 1; i <= g.n; i++) { if (g.edges[v][i] == 1&&!visited[i]) //节点v,i间有边且节点i未访问过 { DFS(g, i); } } }
- 根据邻接表存储结构实现图的深度遍历
void DFS(AdjGraph* G, int v) { ArcNode* p; visited[v] = 1; //当前节点已访问过,数组值置为1 cout << v << " "; p = G->adjlist[v].firstarc; //边指针p指向邻接表中当前节点所连的第一条边 while (p != NULL) { if (visited[p->adjvex] == 0) //该边的终点未访问过 { visited[p->adjvex] = 1; DFS(G, p->adjvex); } p = p->nextarc; } }
- 广度优先遍历(BFS):借助队列,依次访问当前节点的所有邻接点,再从其邻接点开始访问其相邻的点,类似树的层次遍历
- 根据邻接矩阵存储结构实现图的广度遍历
void BFS(MGraph g, int v) { visited[v] = 1; //当前节点已访问过,数组值置为1 queue<int>qu; qu.push(v); //节点入队列 cout << v << " "; int item, i; while (!qu.empty()) { item = qu.front(); qu.pop(); for (i = 1; i <= g.n; i++) { if (!visited[i]&&g.edges[item][i]==1) //节点i未访问过,且队头元素item表示的节点与节点i间有边 { qu.push(i); cout << v << " "; visited[i] = 1; } } } }
- 根据邻接表存储结构实现图的广度遍历
void BFS(AdjGraph* G, int v) { queue<int>qu; qu.push(v); ArcNode* ptr; int item; cout << v << " "; visited[v] = 1; //当前节点已访问过,数组值置为1 while (!qu.empty()) { item = qu.front(); ptr = G->adjlist[item].firstarc; //边指针ptr指向item表示的节点所连的第一条边 while (ptr != NULL) { if (visited[ptr->adjvex] == 0) //该边的终点还未被访问 { qu.push(ptr->adjvex); visited[ptr->adjvex] = 1; cout << ptr->adjvex << " "; } ptr = ptr->nextarc; } qu.pop(); } }
- 深度优先遍历(DFS):使用递归,从当前节点访问其邻接点,再从邻接点开始继续递归访问下去
-
最小生成树相关算法及应用
-
生成树的概念:一个连通图的生成树是一个极小连通子图,它含有图中全部n个顶点和构成一棵树的(n-1)条边,不存在回路。其中权值之和最小的生成树称为图的最小生成树。对于非连通图,每个连通分量中的顶点集和遍历时走过的边一起构成一棵生成树,所有连通分量的生成树组成非连通图的生成森林。
-
克鲁斯卡尔算法:按权值的递增顺序选择合适的边来构造最小生成树,选取的边不能使生成树形成回路。克鲁斯卡尔算法的时间复杂度为O(elog2e)。由于它只与边的条数e有关,所以克鲁斯卡尔算法适合于稀疏图,图的存储结构为邻接表
typedef struct { int u; //边的起始顶点 int v; //边的终止顶点 int w; //边的权值 }Edge; //改进的克鲁斯卡尔算法(使用了堆排序,并查集) void Kruskal(AdjGraph* g) { int i,j,k,u1,v1,sn1,sn2; UFSTree t[MAXSize]; //并查集,树结构 ArcNode* p; Edge E[MAXSize]; k=1; // E数组的下标从1开始计 for(i = 0; i < g.n; i++) { p=g->adjlist[i].firstarc; while(p!=NULL) { E[k].u=i; E[k].v=p->adjvex; E[k].w=p->weight; k++; p=p->nextarc; } } HeapSort(E,g.e); //采用堆排序对E数组按权值递增排序 MAKE_SET(t,g.n); //初始化并查集树t k=1; //k表示当前构造生成树的第几条边,初值为1 j=1; //E中边的下标,初值为1 while(k<g.n) //生成的边数为n-1 { u1=E[j].u; v1=E[j].v; //取一条边的头尾顶点编号u1和v1 sn1=FIND_SET(t,u1); sn2=FIND_SET(t,v1); //分别得到两个顶点所属的集合编号 if(sn1!=sn2) //两顶点属不同集合 { k++; //生成边数增1 UNION(t, u1, v1); //将u1和v1两个顶点合并 } j++; //下一条边 } }
- 普里姆算法:构造的生成树不一定唯一,但最小生成树的权值之和一定相同。普里姆算法的时间复杂度为O(n2)。由于它只与顶点的个数n有关,所以普里姆算法适合于稠密图,图的存储结构为邻接矩阵
void prim(MGraph g, int v) { int lowcost[MAXV], min, i, j, k = 0; int closest[MAXV]; int sum = 0; for(i = 1; i <= g.n; i++) //给数组lowcost[]和closest[]置初值 { lowcost[i] = g.edges[v][i]; closest[i] = v; } lowcost[v] = 0; //顶点v已经加入树中 for (i = 1; i < g.n; i++) //找出(n-1)个顶点 { min = 10000; k = 0; for (j = 1; j <= g.n; j++) //找出离树中节点最近的顶点k { if (lowcost[j] != 0 && lowcost[j] < min) { min = lowcost[j]; k = j; //k记录最近顶点的编号 } } if (k == 0) //不是连通图 { cout << "-1" << endl; return; } sum += min; //变量sum存储最小生成树中边的权值 lowcost[k] = 0; //顶点k已经加入树中 for (j = 1; j <= g.n; j++) { if (lowcost[j] != 0 && g.edges[k][j] < lowcost[j]) { lowcost[j] = g.edges[k][j]; closest[j] = k; } } } cout << sum << endl; }
-
-
最短路径相关算法及应用
- 迪杰斯特拉算法:用于求一顶点到其余各顶点的最短路径,其中最短路径中的所有顶点都是最短路径。迪杰斯特拉算法的时间复杂度为O(n2)。由于它只与顶点的个数n有关,所以普里姆算法适合于稠密图,图的存储结构为邻接矩阵
#define INF 32767 void Dijkstra(MatGraph g, int v) { int dist[MAXV], path[MAXV]; int s[MAXV]; int mindis ,i,j,u; for(i=0; i<g.n; i++) { dist[i] = g.edges[v][i]; //距离初始化 s[i] = 0; //s[]置空 if(g.edges[v][i] < INF) //路径初始化 { path[i] = v; //顶点v到i有边时 } else { path[i] = -1; //顶点v到i没边时 } } s[v] = 1; for(i=0;i<g.n;i++) { mindis = INF; for(j=0;j<g.n;j++) //找最小路径长度顶点u { if(s[j] == 0&&dist[j] < mindis) { u=j; mindis=dist[j]; } } s[u]=1; for(j = 0;j < g.n;j++) { if(s[j]==0) { if(g.edges[u][j] < INF&&dist[u] + g.edges[u][j] < dist[j]) { dist[j] = dist[u] + g.edges[u][j]; path[j]=u; } } } } }
- 弗洛伊德算法:用于求任意两顶点间的最短路径,时间复杂度为O(n3),图的存储结构为邻接矩阵
#define MAXVEX 20 #define INF 32767 void Floyd(MatGraph g) //求每对顶点之间的最短路径 { int A[MAXVEX][MAXVEX]; int path[MAXVEX][MAXVEX]; int i, j, k; for (i = 0; i < g.n; i++) { for (j = 0; j < g.n; j++) { A[i][j] = g.edges[i][j]; if (i != j && g.edges[i][j] < INF) { path[i][j] = i; //i和j顶点之间有一条边时 } else { path[i][j] = -1; } } } for (k = 0; k < g.n; k++) { for (i = 0; i < g.n; i++) { for (j = 0; j < g.n; j++) { if (A[i][j] > A[i][k] + A[k][j]) //找到更短路径 { A[i][j] = A[i][k] + A[k][j]; //修改路径长度 path[i][j] = k; } } } } }
-
拓扑排序、关键路径
- 拓扑排序:在一个有向无环图中找一个拓扑序列的过程称为拓扑排序,有向无环图(DAG)才有拓扑排序,所以拓扑排序可以用来检测图中是否有回路
- 拓扑序列的条件:每个顶点出现且只出现一次;若存在一条从顶点A到顶点B的路径,那么在序列中顶点A出现在顶点B的前面
- 拓扑排序的过程:1.从有向图中选取一个没有前驱的顶点,并输出;2.从有向图中删去此顶点以及所有以它为尾的弧;3.重复上述两步,直至图为空,或者图不空但找不到无前驱的顶点为止
- 拓扑排序算法
#define MAXV 20 typedef struct { //表头节点类型 int data; //顶点信息 int count; //存放顶点入度 ArcNode* firstarc; //指向第一条弧 }VNode; void TopSort(AdjGraph* G) //拓扑排序算法 { int i, j; int St[MAXV], top = -1; //栈St的指针为top ArcNode* p; for (i = 0; i < G.n; i++) //入度置初值为0 { G->adjlist[i].count = 0; } for (i = 0; i < G.n; i++) //求所有顶点的入度 { p = G->adjlist[i].firstarc; while (p != NULL) { G->adjlist[p->adjvex].count++; p = p->nextarc; } } for (i = 0; i < G.n; i++) //将入度为0的顶点进栈 { if (G->adjlist[i].count == 0) { top++; St[top] = i; } } while (top > -1) //栈不空循环 { i = St[top]; //出栈一个顶点i top--; printf("%d ", i); p = G->adjlist[i].firstarc; //找第一个邻接点 while (p != NULL) //将顶点i的出边邻接点的入度减1 { j = p->adjvex; G->adjlist[j].count--; if (G->adjlist[j].count == 0) //入度为0的邻接点进栈 { top++; St[top] = j; } p = p->nextarc; } } }
- 关键路径:从有向图的源点到汇点的最长路径,关键路径中的边称为关键活动,顶点称为事件
- 求关键路径步骤
1.对有向图拓扑排序
2.根据拓扑序列计算事件(顶点)的ve,vl数组。注意:ve和vl相同,只能找出关键路径上的点,而仅通过一个点的集合无法获得组成关键路径的边的集合
3.计算关键活动的e[],l[]。即边的最早、最迟时间
4.找e=l边即为关键活动
5.关键活动连接起来就是关键路径
- 求关键路径步骤
- 拓扑排序:在一个有向无环图中找一个拓扑序列的过程称为拓扑排序,有向无环图(DAG)才有拓扑排序,所以拓扑排序可以用来检测图中是否有回路
1.2.谈谈你对图的认识及学习体会
-
图这一章的学习过程和前面树的学习过程类似,都是先学存储结构,再学相关的遍历。但是图的学习中学习了四种著名的算法。其中两种算法用来解决最小生成树问题:普里姆算法和克鲁斯卡尔算法。两种算法用来解决最短路径问题:迪杰斯特拉算法和弗洛伊德算法。图和树一样都是非线性结构,图中元素的关系为“多对多”,存储结构可选择邻接矩阵或邻接表。图的遍历有深度优先遍历(DFS)和广度优先遍历(BFS),图可通过遍历方法产生生成树,分别为深度优先生成树和广度优先生成树。最后还学习了拓扑排序算法及相关概念,以及关键路径的求法和相关概念。
-
图的应用:路由路径搜索,其中设备到设备之间寻求最短路径传输数据包时有应用到迪杰斯特拉算法:GPS定位中寻求两点间的最短路径问题中使用的数据结构就为图。现实生活中每个人的社交群都可用图结构来表示;知识图谱中很多知识点之间互相有关联,可用图结构来表示;外出旅游,选择最短路线或最便宜路线时,也有应用图结构,其中不同道路的路径长度或者驾车费用都可以表示为图结构中边的权值。
2.阅读代码
2.1 克隆图
- 解题代码
Node* used[101]; //创建一个节点(指针)数组记录每个拷贝过的节点
Node* cloneGraph(Node* node)
{
if (!node)
{
return node; //如果是空指针,则返回空
}
if (used[node->val])
{
return used[node->val]; //该节点已经拷贝,直接返回该节点的指针即可
}
Node* p = new Node(node->val); //创建拷贝节点
used[node->val] = p; //递归会遍历每一个原有节点,然后将拷贝后的指针放入used
vector<Node*> tp = node->neighbors;
for (int i = 0; i < tp.size(); i++) //将该节点的邻接节点放入拷贝节点邻接数组
{
p->neighbors.push_back(cloneGraph(tp[i])); //递归实现每一个节点的更新
}
return p; //返回拷贝后的节点
}
2.1.1 该题的设计思路
- 通过一个节点(指针)数组来判断每个节点是否已被克隆,若节点已被克隆,直接返回节点指针;否则创建一个新节点,通过递归访问每一个节点,并将节点的邻接点放入节点(指针)数组中
- 算法的时间复杂度为O(n),n为节点数;空间复杂度为O(n),n为节点数
2.1.2 该题的伪代码
创建一个节点(指针)数组used记录每个拷贝过的节点
Node* cloneGraph(Node* node)
{
if (指针为空)
then 返回空指针
if (节点已经拷贝)
then 返回节点的指针
创建拷贝节点,并将新创建的节点放入used数组中
构造动态数组tp,用于存放节点的邻接点
for (int i = 0; i < tp.size(); i++) //将该节点的邻接节点放入拷贝节点邻接数组
{
p->neighbors.push_back(cloneGraph(tp[i])); //递归实现每一个节点的更新
}
返回拷贝后的节点p
}
2.1.3 运行结果
2.1.4 分析该题目解题优势及难点
- 该算法借助一个节点(指针)数组来记录每个拷贝过的节点,并构造了动态数组来存放节点的邻接点。关键是用递归实现每一个节点的更新,每一次递归调用函数时都会拷贝新的节点,最终实现图的克隆
2.2 判断二分图
- 解题代码
bool isBipartiteDFS(vector<vector<int>>& graph, vector<int>& visited, int cur, int color)
{
if (visited[cur] != -1 && visited[cur] != color)
{
return false;
}
visited[cur] = color;
for (auto i : graph[cur])
{
if (visited[i] == visited[cur]) //子节点的颜色和自己相同,说明已经不是二分图了
{
return false;
}
else if (visited[i] == -1)
{
if (!isBipartiteDFS(graph, visited, i, (color + 1) % 2)) // 这里取不同的颜色
{
return false;
}
}
}
return true;
}
bool isBipartite(vector<vector<int>>& graph)
{
vector<int> visited = vector<int>(graph.size(), -1);
for (int i = 0; i < graph.size(); i++)
{
if (visited[i] == -1) //对仍没有染色的节点进行染色,这里是可以随便取初始的染色值的,因为如果之前的遍历都没有将这个节点染色,那么这个节点肯定是在另外的连通图里
{
if (!isBipartiteDFS(graph, visited, i, 0))
{
return false;
}
}
}
return true;
}
2.2.1 该题的设计思路
- 没有访问的节点的颜色初始化为-1,然后访问的时候,将它染成0或者1,遍历它相连的节点,将它的相连的节点染成不同的颜色。如果深度优先遍历图的时候碰到了和该节点染的颜色不一样的且已经染了色的节点,说明就不是二分图了。
- 该算法的时间复杂度为O(e),e为边数;空间复杂度为O(n),n为顶点数
2.2.2 该题的伪代码
bool isBipartite()函数
{
构造动态数组f,数组大小为函数参数graph的大小
for (int i = 0; i < graph.size(); i++)
{
if (元素i还未访问)
then if (顶点i与第一个顶点都着了色且颜色相同),return false
}
return true;
}
bool Recur()函数
{
for (int idx = 0; idx < graph[i].size(); idx++)
{
if (顶点idx还未访问)
{
if (顶点idx与邻接点都着了色且颜色相同)
then return false
}
else if (顶点idx的颜色值与函数参数target不同)
then return false
}
return true;
}
2.2.3 运行结果
2.2.4 分析该题目解题优势及难点
- 该算法通过给每个节点赋上颜色值,巧妙地通过比较节点和邻接点的颜色值是否相同,最终在递归遍历节点的过程中进行二分图的判断:如果某一节点和其相邻节点都已着色且两节点的颜色值相同,则不是二分图。
2.3 冗余连接
- 解题代码
vector<int> findRedundantConnection(vector<vector<int>>& edges)
{
vector<int> rp(1001);
int sz = edges.size();
for (int i = 0; i < sz; i++)
{
rp[i] = i;
}
for (int j = 0; j < sz; j++)
{
int set1 = find(edges[j][0], rp);
int set2 = find(edges[j][1], rp);
if (set1 == set2)
return edges[j];
}
else
{
rp[set1] = set2;
}
}
return { 0, 0 };
}
int find(int n, vector<int>& rp)
{
int num = n;
while (rp[num] != num)
{
num = rp[num];
}
return num;
}
2.3.1 该题的设计思路
- 使用并查集,先初始化各元素为单独的集合,代表节点就是其本身。再遍历每一条边,找到边上两个节点所在集合的代表节点。若两个集合代表节点相同,说明出现环,返回答案;否则两个集合独立,合并集合,将前一个集合代表节点戳到后一个集合代表节点上
- 该算法的时间复杂度为O(n),n为图中顶点的个数;空间复杂度为O(n),n为图中顶点的个数
2.3.2 该题的伪代码
vector<int> findRedundantConnection()函数
{
初始化一个容器rp,使得rp[i] = i
for (int j = 0; j < sz; j++) //遍历每一条边
{
调用find()函数,找到边上两个节点所在集合的代表节点,用set1和set2表示
if (set1 == set2)
then 返回这条边
else
rp[set1] = set2; //将前一个集合代表节点戳到后一个集合代表节点上
}
}
int find()函数
{
参数中的n赋值给变量num
while (rp[num] != num)
num = rp[num]; //节点往上递推到父节点,就为代表节点
end while
返回变量num的值
}
2.3.3 运行结果
2.3.4 分析该题目解题优势及难点
- 该算法借助并查集,通过比较每条边上两个节点所属的集合的代表节点是否相同,来进行判断图中是否出现环。若出现的话,则直接返回当前的边;否则把前一个集合的代表节点改为后一个集合的代表节点。关键是find()函数中的查找路径并返回代表节点这一步,它是通过节点往上递推到父节点来实现的。