图的基本概念与操作
定义:
- 图G由顶点集V和边集E组成,记为
G = (V, E),定点数为图的阶;多重图:
- 有重复的边或者顶点到自身的边;
连通图:
- 无向图的概念,若任意结点之间都有路径则称为连通图;
强连通图:
- 有向图的概念,若任意两节点u,v都有:u到v的路径和v到u的路径,则称为强连通图;
连通分量:
- 连通分量仅存在于无向图中,指图中最大的、内部任意两点都能互相到达的子图。
- 若一个无向图本身就是连通的(任意两点间有路径),则它只有 1 个连通分量,即自身。
- 若一个无向图由两个不相连的三角形组成,则它有 2 个连通分量,每个三角形各自是一个连通分量。
强连通分量:
- 强连通分量仅存在于有向图中,指图中最大的、内部任意两点都能通过有向边互相到达的子图。
- 一个有向环(如 A→B→C→A)是一个强连通分量,因为环内任意两点可双向到达。
- 若有向图结构为 “A→B→C,C→B”,则 B 和 C 构成一个强连通分量(B 与 C 可双向到达),而 A 单独构成一个强连通分量(A 能到 B,但 B 无法到 A)。
生成树:
- 连通图的生成树是包含图中所有顶点的一个极小连通子图
图的存储
邻接矩阵存储法:


邻接矩阵储存法让我们可以通过二维数组存储图,但是如果图是稀疏图(边较少)则会有很多空间浪费,所以该方法适合储存稠密图;
无向图的邻接矩阵是一个对称矩阵,我们可以通过矩阵的压缩存储来减少程序的空间复杂度;
邻接矩阵法的应用:
- 顶点
i的度:第i行或者第i列非零元素的总个数;- 顶点
i的出度:第i行非零元素的总个数;- 顶点
i的入度:第i列非零元素的总个数;- 对于不带权且矩阵元素只有零和一的邻接矩阵有:
若图G的邻接矩阵为M,则M^n(矩阵相乘)中的元素M^n[i][j]表示从结点i到结点j长度为n的路径的个数;
邻接表储存法

该方法结合了顺序存储和链式存储,每个结点的链表存储的是和该结点相连的边的信息(有向图中是从该节点出发的边),可以认为链表的节点就代表图的边;
链表节点的结构:
struct ArcNode
{int adjvex; //记录这条边连接的另一个结点ArcNode* Next; //指向下一条边
}
顶点的结构:
struct VNode
{Elemtype data; //储存的数据ArcNode *first; //指向连接的第一条边
}
找无向图顶点的度:
- 遍历对应的顶点的链表,链表的节点个数就是该节点的度;
找有向图顶点的出度:
- 遍历对应的顶点的链表,链表的节点个数就是该节点的度;
找有向图顶点的入度:
- 遍历表中所有的元素的链表,记录指向该顶点的链表结点的数量;
十字链表储存法:
十字链表法只能用于存储有向图;

从顶点结点出发,顺着黄色部分的指针遍历就可以找到所有指向该顶点的边。顺着绿色部分的指针遍历就可以找到所有从该结点出发的边。寻找出度和入度的时间复杂度都比较低;
邻接多重表储存无向图
用邻接表储存无向图会出现空间冗余的情况:一条边会在两个顶点的链表中都存储一次,也就是重复存储了一次;并且删除某个顶点也会比较麻烦。邻接多重表则优化了储存结构;

十字链表法和邻接多重表法的代码实现较为复杂,考研中不会考察它的代码实现,只需要理解原理即可;
考研当中最常考的是前两种存储结构;
图的广度优先遍历
与树的广度优先遍历(层序遍历)类似,即在遍历过程中待访问的结点是正在访问的结点的邻接结点。需要借助队列实现;
由于图是存在回路的,为了避免重复访问还需要定义一个布尔类型的数组来判断某节点是否已经被访问过;
#include <iostream>
#include <queue>
using namsspace std;bool Visited[100];//初始化全部为false//BFS需要的函数:
int first_neigbor(Graph G, int x) //返回顶点x的第一个邻接顶点,所不存在则返回-1;
int next_neigbor(Graph G, int x, int y) //y是x的一个邻接顶点,返回除y之外的下一个邻接顶点,若不存在则返回-1;void BFS(Graph G, int x)
{queue<int> q;visit(x);visited[x] = true;q.push(x);while(!q.empty){int w = q.top(); //w记录正在访问的顶点//for循环用来将正在访问的顶点的所连接的所有为访问顶点入栈for(int y = first_neighor(G, w); y >= 0; y = next_neighor(G, w, y)){if(!visited[y]){q.push(y);vistied[y] = true;visit(y);}//if}//for//访问完成后弹出顶点q.pop()}//while
}//对图进行广度优先遍历 避免了BFS不能全部遍历非连通图的问题
void BFSTraverse(Graph G)
{for(int i = 0; i < g.vexnum; i++){if(!visited[i]){BFS(G, i);}}
}
图的深度优先遍历
同样与树类似,图的DFS采用递归的方式实现,并且与BFS相同定义布尔数组防止重复访问;
#include <iostream>
using namespace std;bool visited[100];void DFS(Graph G, x)
{visit(x);visited[x] = true;for(int y = first_neighor(G, x); y >= 0; y = next_neighor(G, x, y)){if(!visited[y]){DFS(G, y);}}
}void DFSTraverse(Graph G)
{for(int i = 0; i <= G.nexnum; i++){if(!visited[i]){DFS(G, i);}}
}
最小生成树
最小生成树:
- 对于一颗带权的无向图,每条路径加起来总权值最小的生成树即为最小生成树;
-最小生成树不是唯一的;
Prim算法
从某个顶点开始构建生成树,每次将所连接的边权值最小的顶点纳入生成树,直到所有顶点都纳入生成树;
时间复杂度O(|V|^2),只与节点数有关,适合边稠密图;
Kruskal算法
每次选择权值最小的一条边,使这条边的两头连同,如果两头的顶点已经连同(包括间接连同)则不用这条边,直到所有结点都连同
该算法的实现需要借助并查集,时间复杂度为O(|E| * log2^|E|),只与边数有关,适合边稀疏图;
最短路径问题
BFS
BFS算法可以求在无权图中以某个顶点为起点到其他各个顶点的最短路径:
- 无权图可以看作是一种特殊的带权图,每条边的权值都为1;
- 从起点顶点开始BFS,第一层遍历到的结点到起点的路径长度都为一,第二层遍历到的结点到起点的路径长度都为二,以此类推;
void BFS_MIN_PATH(Graph G, int x)
{//编号从1开始//存储其他顶点到顶点x的最短路径长度,-1表示不可到达;int len[G.vexnum + 1] = -1;//存储每个节点在路径上的直接前驱,-1表示没有前驱;int path[G.vexnum + 1] = -1;len[x] = 0;queue<int> q;q.push(x);while(!q.empty()){int y = q.top()for(int w = first_neighor(G, y); w >= 0; w = next_neighor(G, y, w)){if(!visited[w]){path[w] = y;len[w] = len[y] + 1;visited[w] = true;q.push(w);}}p.pop();}
Dijkstar算法

如图,迪杰斯特拉算法需要定义如图的三个数组:
- 第一个数组为布尔类型用来记录其他顶点是否找到到目标顶点的最短路径
- 第二个数组用来记录最短路径的长度,在代码中初始化中没有与目标顶点直接连接的顶点用-1初始化;
- 第三个数组用来记录该顶点在最短路径上的直接前驱;
实现过程:
- 初始化三个数组;
- 遍历final数组和dist数组,找出没有找到最短路径并且对应dist数组中值最小的顶点,将该顶点的final数组元素定义为真;
- 遍历该顶点能到达的顶点,同时计算这些顶点通过该顶点到达目标顶点的路径长度再与dist数组中原有的数据对比,取更小的路径长度;
- 重复二三步骤直到所有顶点的final数组都为真;
//代码中图的存储方式为邻接表存储法,该算法仅适用于连通图
struct link_node //边结点
{int target; //到达的顶点int len; //边的权值link_node* next;
}struct Graph_node //图的顶点
{int pos //顶点编号link_node* first;
}class Graph
{
private:int vexnum;Graph_node* head;
public://该算法用不到的函数定义省略;graph(int vexnum){this->vexnum = vexnum;//编号从零开始head = new Graph_node[vexnum];}int num(){return vexnum;}Graph_node& operator[](int num){reutrn *(head + num)}
}//计算图G中各顶点到顶点x的最短路径
void DIJKSTAR_MIN_PATH(Graph G, int x)
{bool final[G.num()] = false;int dist[G.num()] = -1;int path[G.num()] = -1;//记录还没有找到最短路径的顶点的数量int rest = G.num();fianl[x] = true;dist[x] = 0;rest--;//先记录可以直接到达x的顶点for(link_node* i = G[x].head; i != NULL; i = i->next){dist[i->traget] = i->len;path[i->target] = x;}int min_dist;int vertex; // 记录正在访问的顶点while(rest){//先将min_dist随便指向一个有可走路径并且还未找到最短路径的顶点for(int i = 0; i < G.num(); i++){if(!final[i] && dist[i] > 0){min_dist = dist[i];vertex = i;break;}}//找到还未找到最短路径并且有可行路径的定点中可行路径最短的;for(int i = 0; i < G.num(); i++){if(!final[i] && dist[i] > 0){if(dist[i] < min_dist){min_dist = dist[i];vertex = i;}}}final[i] = true;rest--;//寻找剩余节点的最短路径(范围是上面找到的顶点可以到达的顶点)for(link_node* i = G[i].first; i != NULL; i = i->next){if(dist[i->target] != -1 && !fianl[i->target]){if((min_dist + i->len) < target){dist[i->target] = min_dist + i->len;path[i->target] = vertex;}}else if(dist[i->target] == -1){dist[i->target] = min_dist + i;path[i->target] = vertex;}//else if}//for}//while
Floyd算法
采用动态规划思想,将一个问题拆分为多个问题;
对于有n个结点的图G,求各个顶点之间的最短路径可以将问题拆解为:
- #初始:若不允许中转,各个顶点之间的最短路径为?
- #0:若允许在v0顶点中转,各个顶点之间的最短路径为?
- #1:若允许在v0,v1顶点中转,各个顶点之间的最短路径为?
- …
- #n-1:若允许在v0,v1,…,vn-1顶点中转,各个顶点之间的最短路径为?
以上就是Floyd算法的实现步骤;
Floyd算法看起来只是选择在一个顶点之间中转,但是实际上在本次计算对比路径长度的时候,我们正在使用的“最短”路径可能就是上一次已经中转过的路径。所以实际上该算法是在最终完成的路径是在多个路径上中转的;

如上图,该算法的实现需要我们定义两个数组,一个用来存储在当前可中转的结点下的最短路径,另一个用来存储各顶点在最短路径上的直接前驱;
//代码中图的存储方式为邻接表存储法,该算法仅适用于连通图
struct link_node //边结点
{int target; //到达的顶点int len; //边的权值link_node* next;
}struct Graph_node //图的顶点
{int pos //顶点编号link_node* first;
}class Graph
{
private:int vexnum;Graph_node* head;
public://该算法用不到的函数定义省略;graph(int vexnum){this->vexnum = vexnum;//编号从零开始head = new Graph_node[vexnum];}int num(){return vexnum;}Graph_node& operator[](int num){reutrn *(head + num)}
}void FLOYD_MIN_PATH(Graph G)
{int n = G.num();int** A = new int[n * n];int** path = new int [n * n];//初始化数组for(int i = 0; i < n; i++)for(int j = 0; j < n; j++){A[i][j] = 1e18;path[i][j] = -1;if(i == j)a[i][j] = 0;}//写入已有路径for(int i = 0; i < n; i++){link_node* temp = G[i].first;while(temp != NULL){A[i][temp->target] = G[i].len;temp = temp->next;}}//Floyd算法for(int k = 0; k < n; k+++) // 考虑通过顶点k中转for(int i = 0; i < n; i++) // 遍历所有路径;for(int j = 0; j < n; j++){if(A[i][j] > A[i][k] + A[k][i]){A[i][j] = A[i][k] + A[k][j];path[i][j] = k;}}delete A[];delete path[];
}
总结
Dijkstar算法:
- 该算法本质可以看作是贪心算法,而贪心算法的基础又是默认数据是不减的,所以这个特性也导致了该算法不用于含有负权的路径图的;
- 该算法求的是其他顶点到某个顶点的最短路径,是单源的;
Floyd算法:
- 该算法用来求各个顶点之间的最短路径;
- 该算法可以用于有负权路径的图,但是不能用于带负权回路的图;
有向无环图描述表达式
我们可以用二叉树来表示表达式,当我们将二叉树中相同的部分和并之后就可以得到有向无环图表示的表达式
二叉树:
合并相同部分之后得到有向无环图:
用有环无向图描述表达式的方法
- 把所有操作数不重复的排成一排;
- 标出各个运算符的生效顺序;
- 按顺序加入操作符,注意分层;
- 从最底层操作符开始逐层检查同层的操作符是否可以合并;
拓扑排序
- AOV网:用顶点表示活动的网;
- 用DAG图(有向无环图)表示一个工程,顶点表示活动,有向边<Vi, Vj>表示活动Vi一定先于活动Vj进行;
拓扑排序:
- 找到做事的先后顺序;
拓扑排序的实现:
- 从AOV网中选取一个没有前驱(入度为零)的顶点并输出;
- 从网中删除该结点和所有以它为起点的有向边;
- 重复上述步骤直到网中没有入度为零的顶点或者网为空;
代码实现
拓扑排序需要我们定义两个数组,
indegree[]和print[];分别用来记录每个顶点的度数和拓扑排序的顺序;
另外,我们还需要一个数据结构储存每次遍历找到的度数为零的结点,栈,队列或者数组都可以;
struct link_node //边结点
{int target; //到达的顶点int len; //边的权值link_node* next;
}struct Graph_node //图的顶点
{int pos //顶点编号string thing; //结点记录的事件link_node* first;
}class Graph
{
private:int vexnum;Graph_node* head;
public://该算法用不到的函数定义省略;graph(int vexnum){this->vexnum = vexnum;//编号从零开始head = new Graph_node[vexnum];}int num(){return vexnum;}Graph_node& operator[](int num){reutrn *(head + num)}
}void TopologicalSort(Graph G)
{int n = G.num();int indegree[n] = 0;int print[n] = -1;queue<int> q;int count = 0; //记录已经拓扑排序的顶点数;//记录各个顶点的入度;for(int i = 0; i < n; i++)for(link_node* j = G[i].fisrt; j; j = j->next){indegree[j->target]++;}//将度数为零的结点入栈for(int i = 0; i < n; i++){if(!indegree[i]){q.push(i)}}while(!q.empty()){//一轮循环弹出一个入度为零的顶点print[count++] = q.front();//弹出的顶点指向的所有顶点入度减一同时检查是否有新的入度为零的顶点for(link_node* j = G[q.front].first; j; j = j->next){if(!(--indegree[j->target])){q.push(j->target);}}q.pop();}if(count < n){cout << "排序失败,图中含有回路" << endl;}
}
逆拓扑排序:
- 与拓扑排序相似,只不过逆拓扑排序每次选择的是出度为0的结点;
- 代码实现也与拓扑排序类似,在
while循环之前入队的顶点满足G[i].fisrt == NULL;即为出度为零的结点;
while循环中同样使用逻辑上的删除,结合indegree数组来判断哪些顶点的出度为零;- 逆拓扑排序的结果是拓扑排序结果的逆序
DFS实现逆拓扑排序
//代码中图的存储方式为邻接表存储法,该算法仅适用于连通图
struct link_node //边结点
{int target; //到达的顶点int len; //边的权值link_node* next;
}struct Graph_node //图的顶点
{int pos //顶点编号link_node* first;
}class Graph
{
private:int vexnum;Graph_node* head;
public://该算法用不到的函数定义省略;graph(int vexnum){this->vexnum = vexnum;//编号从零开始head = new Graph_node[vexnum];}int num(){return vexnum;}Graph_node& operator[](int num){reutrn *(head + num)}
}bool visited[G.num()];
void DFS(Graph G, int i);void DFSTraverse(Graph G)
{for(int i = 0; i < G.num(); i++){visite[i] = false;}for(int i = 0; i < G.num(); i++){if(!visited[i]){DFS(G, i);}}
}void DFS(Graph G, int x)
{visited[x] = true;for(link_node* i = G[x].first; i; i = i->next){if(!visited[i->target]){DFS(G, i->target);}else{cout << "存在回路" << endl;return;}}cout << x << " "; //输出顶点
}
逆拓扑排序实际上就是按照有向图的反方向遍历,这与图的深度优先遍历类似;
不难发现DFS程序递归的过程中,进入程序调用栈的每一个函数,它们能够弹出栈执行结束的条件就是
i == NULL即出度为零;(指向已经被访问过的顶点不算出度);
因为逆拓扑排序的结果是拓扑排序的逆序,所以我们将上述程序的输出部分改为先入栈再输出,这样就可以得到拓扑排序的结果;
关键路径
AOE网
在带权有向图中,以顶点表示事件,以有向边表示活动,有向边上的权值表示完成该活动的开销
- 在AOE网中只有一个入度为零的点,表示整个工程的开始,称为源点;
- 同时仅有一个出度为零的点,表示整个工程结束,称为汇点;
- 从源点到汇点总权值最大的路径称为关键路径,关键路径上的活动称为关键活动;
从一个事件(顶点)开始的活动是可以并行的,这也是为什么权值最大的路径称为关键路径,关键路径上的活动结束之后其他路径上的活动也一定结束了,也就是说只有关键路径完成了整个事件才可以结束;
关键路径的求解方法
- 求所有事件的最早发生时间
ve();- 求所有事件的最迟发生时间
vl();- 求所有活动的最早发生时间
e();- 求所有活动的最迟发生时间
l();- 求所有活动的时间余量
d();
d(i) = 0的活动就是关键活动,关键活动所在的边构成了关键路径;
ve() =上一个事件的最早发生时间加上连接它们的路径的权值,若有多个结果,取最大值;vl() =从汇点逆向推(逆拓扑排序),该顶点的最晚发生时间减去路径的权值就是上一个顶点的最晚发生时间(汇点的最早发生时间和最晚发生时间一致)若有多个结果选择最小的;e() =该活动所在路径出发的结点的最早发生时间l() =该活动的权值减去该活动指向的事件的最晚发生事件d() =l() -e()
第一个为什么选择最大值:
- 因为指向该事件的所有活动执行完之后才可以开始该事件,和拓扑排序的原理类似;
第二个选择最小值也是同理,要保证即使取最小值,它之后的活动也要在最晚的开始事件前开始;为什么
d() = 0的路径是关键路径:
- 因为关键路径上的任意一个时间延时都会导致整个事件延时,所以关键路径上的事件是没有时间余量的;
struct link_node //边结点
{int start; //起始的顶点编号int link_pos; //边编号int target; //到达的顶点int len; //边的权值link_node* next;
}struct Graph_node //图的顶点
{int pos //顶点编号link_node* first;
}class Graph
{
private:int vexnum;Graph_node* head;
public://该算法用不到的函数定义省略;graph(int vexnum){this->vexnum = vexnum;//编号从零开始head = new Graph_node[vexnum];}int num(){return vexnum;}Graph_node& operator[](int num){reutrn *(head + num)}Graph_node* operator[](int n){return head + n;}
}//利用拓扑排序求出ve[]
void TopologicalSort(Graph G, int& ve[])
{int n = G.num();int indegree[n] = 0;int max[n] = 0;queue<int> q;int count = 0; //记录已经拓扑排序的顶点数;//记录各个顶点的入度;for(int i = 0; i < n; i++)for(link_node* j = G[i].fisrt; j; j = j->next){indegree[j->target]++;}//将源点入队ve[0] = 0;q.push(0);while(!q.empty()){//一轮循环弹出一个入度为零的顶点int pos = q.front();//弹出的顶点指向的所有顶点入度减一同时检查是否有新的入度为零的顶点for(link_node* j = G[pos].first; j; j = j->next){//找到最大的开始时间if(max[j->target] < max[j->start] + j->len){max[j->target] = max[j->start] + j->len;}if(!(--indegree[j->target])){q.push(j->target);}}count++;q.pop();}if(count < n){cout << "排序失败,图中含有回路" << endl;}for(int i = 0; i < n; i++){ve[i] = max[i]}
}//利用逆拓扑排序求出vl
void ReverseTopologicalSort(Graph G, int ve[], int& vl[])
{int n = G.num();int outdegree[n] = 0;int min[n] = 1e18;queue<int> q;int count = 0; //记录已经拓扑排序的顶点数;//记录各个顶点的入度;for(int i = 0; i < n; i++){for(link_node* j = G[i].fisrt; j; j = j->next){outdegree[i]++;}//便利的同时寻找汇点if(!outdegree[i]){vl[i] = ve[i];q.push[i];}}while(!q.empty()){//一轮循环弹出一个出度为零的顶点int pos = q.front();//弹出的顶点指向的所有顶点入度减一同时检查是否有新的入度为零的顶点for(link_node* j = G[pos].first; j; j = j->next){if(min[j->start] > min[j->target] - j->len){min[j->start] = min[j->target] - j->len;}if(!(--outdegree[j->start])){q.push(j->start);}count++;q.pop();}if(count < n){cout << "排序失败,图中含有回路" << endl;}for(int i = 0; i < n; i++){vl[i] = min[i];}
}void critical_path(Graph G)
{int n = G.num();int ve[n];int vl[n];int e[n];int l[n];int d[n];TopologicalSort(G, ve[]);ReverseTopologicalSort(G, ve[], vl[]);for(int i = 0; i < n; i++)for(link_node* j = G[i].first; j; j = j->next){e[j->link_pos] = ve[j->start]l[j->link_pos] = vl[j->target] - j->len;}//输出关键路径for(int i = 0; i < n - 1; i++){d[i] = e[i] - l[i];if(!d[i]){cout << i << " ";}}
}


