数据结构之图
图的定义
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中G表示图,V表示顶点的集合,E是图G中边的集合。 图分为有向图和无向图,图中的边分为有向边和无向边,无向边用()表示,有向边用<>表示。
- 在图中,如果不存在顶点到其自身的边,且同一条边不重复出现,则称为简单图。
- 在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)条边。
- 在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图,
含有n个顶点的有向完全图有 n ( n − 1 ) n(n-1) n(n−1)条边。 - 有些图的边或弧具有与它相关的数字,叫做权(Weight),带权的图称为网(Network)。
- 假设有两个图 G = ( V , E ) G = (V,{E}) G=(V,E)和 G ′ = ( V ′ , E ′ ) G' =(V' , {E'}) G′=(V′,E′),如果 V ′ ⊆ V V' \subseteq V V′⊆V 且 E ′ ⊆ E E' \subseteq E E′⊆E ,则称G’为G的子图(subgraph)。
- 顶点v的**度(Degree)**是和v相关联的边的数目。对于有向图,又分为出度和入度。
- 在图中,从顶点v到v’的路径的长度是路径上边或弧的数目,第一个顶点到最后一个顶点相同的路径称为回路或环,序列中顶点不重复的路径称为简单路径。
- 在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点都是连通的,则G是连通图(Connected Graph)。无向图中的极大连通子图称为连通分量。
- 在有向图G中,如果每一对顶点之间都存在路径,则称G是强连通图。有向图中的极大强连通子图称做有向图的强连通分量。
- 无向图中连通且n个顶点n-1条边叫做生成树。有向图总一顶点入度为0,其余顶点入度为1的叫做有向树。一个有向图由若干棵有向树构成生成森林。
图的存储结构
邻接矩阵
图的临接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息,结构代码:
#include <iostream>
#include <vector>
using namespace std;
struct Mgraph {
char vexs[MAXVEX];
int arc[MAXVEX][MAXVEX];
int numVertexes,numEdges;
Mgraph(int v, int e) : numVertexes(v), numEdges(e) {}
};
建立图代码:
Mgraph* CreateMGraph()
{
Mgraph* m;
m = (Mgraph*)malloc(sizeof(Mgraph));
cout << "输入顶点数和边数:";
cin >> m->numVertexes;
cin >> m->numEdges;
char c;
cout<< "输入各节点名称:";
for(int i = 0; i < m->numVertexes; i++)
cin>>m->vexs[i];
for(int i = 0; i < m->numVertexes; i++){
for(int j = 0; j < m->numVertexes; j++)
{
if(i == j) m->arc[i][j] = 0;
else
m->arc[i][j] = INT_MAX;
}
}
cout<< "输入各边上标下标和权重:";
int b,e,w;
for(int i = 0; i < m->numEdges; i++)
{
cin >> b >> e >> w;
m -> arc[b][e] = w;
//m -> arc[e][b] = w; 如果说明是无向图,则这一句也要加上
}
for(int i = 0; i < m->numEdges; i++){
for(int j = 0; j < m->numEdges; j++)
cout << m->arc[i][j] << " ";
cout << endl;
}
return m;
}
int main()
{
Mgraph* G = CreateMGraph();
return 0;
}
邻接矩阵操作简单, 但会造成很多存储空间的浪费,因为如果图非常稀疏的话,邻接矩阵所占用的大部分空间都被浪费了。
邻接表
邻接表使用将数组和链表相结合的方法存储图,具体处理方法:
- 图中顶点用一个一维数组存储,每个数据元素还要存储指向第一个邻接点的指针,以便查找该顶点的边信息。
- 图中每个顶点 V i V_i Vi的所有临接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点 V i V_i Vi的边表,有向图则成为顶点 V i V_i Vi的出边表。
结构代码:
struct EdgeNode
{
int adjvex;
int weight;
EdgeNode * next;
};
typedef struct VertexNode
{
char name;
EdgeNode *firstedge;
}AdjList[MAXVEX];
struct GraphAdjList
{
AdjList adjList;
int numVertexes, numEdges;
};
建立图代码:
GraphAdjList* CreateALGraph()
{
GraphAdjList* G = (GraphAdjList*)malloc(sizeof(GraphAdjList));
cout << "输入顶点数和边数:";
cin >> G->numVertexes >> G->numEdges;
cout<< "输入各节点信息:";
for(int i = 0; i < G->numVertexes; i++)
{
cin >> G->adjList[i].name;
G->adjList[i].firstedge = NULL;
}
cout<< "输入各边上标下标和权重:";
EdgeNode *E;
int b,e,w;
for(int i = 0; i < G->numEdges; i++)
{
E= (EdgeNode*)malloc(sizeof(EdgeNode));
cin>>b>>e>>w;
E->adjvex = e;
E->next = G->adjList[b].firstedge;
E->weight = w;
G->adjList[b].firstedge = E;
}
for(int i = 0; i < G->numVertexes; i++)
{
EdgeNode* temp = G->adjList[i].firstedge;
while(temp != NULL)
{
cout << G->adjList[i].name << " "<< G->adjList[temp->adjvex].name<<endl;
temp = temp -> next;
}
}
return G;
}
十字链表
邻接表的缺陷在于只能了解图中结点的出度,而了解入度则需要遍历整个图,逆邻接表可以了解入度,将邻接表和逆邻接表结合起来,就形成了十字链表。
重新定义顶点表结构:
data | firstin | firstout |
---|
重新定义边表结点结构:
tailvex | headvex | headlink | taillink |
---|
临接多重表
临接多重表是对无向图的优化存储结构,类似于十字链表:
ivex | ilink | jvex | jlink |
---|
其中ivex和jvex是与某条边依附的两个顶点在顶点表的下标,ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。
临接多重表的欧典在于同一条边在临接多重表里只需要一个边表结点,而在普通临接表里需要两个结点。
图的遍历
深度优先遍历
我选择用邻接表实现,主要的思路就是递归,如果要用迭代写的话,需要用到栈,邻接矩阵思路也一样:
void DFS(GraphAdjList G, int i, vector<bool> &visited)
{
visited[i] = true;
cout << G.adjList[i].name;
EdgeNode *p = G.adjList[i].firstedge;
while(p!=NULL)
{
if(!visited[p->adjvex])
DFS(G, p->adjvex,visited);
p = p->next;
}
return;
}
void DFSTraverse(GraphAdjList G)
{
vector<bool> visited(G.numVertexes, false);
for(int i = 0; i < G.numVertexes; i++)
if(!visited[i])
DFS(G,i,visited);
return;
}
广度优先遍历
广度优先遍历和树的层序遍历差不多,都要用到队列的思想:
void BFSTraverse(GraphAdjList G)
{
queue<int> q;
vector<bool> visited(G.numVertexes, false);
for(int i = 0; i < G.numVertexes; i++)
{
if(!visited[i])
{
q.push(i);
while(!q.empty())
{
int j = q.front();
cout<<G.adjList[j].name<<endl;
visited[j] = true;
q.pop();
EdgeNode *p = G.adjList[j].firstedge;
while(p != NULL)
{
if(!visited[p->adjvex])
q.push(p->adjvex);
p = p -> next;
}
}
}
}
return;
}
最小生成树
回顾一下生成树的概念,无向图中连通且n个顶点n-1条边叫做生成树。有向图总一顶点入度为0,其余顶点入度为1的叫做有向树。最小生成树就是找到一颗总权重最小的生成树。
一个连通图的生成树是一个极小的连通子图,把构造连通网的最小代价生成树称为最小生成树。
Prim算法
假设N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={
u
0
u_0
u0},TE={}开始。重复执行下述操作:
在所有
u
′
⊆
U
u' \subseteq U
u′⊆U,
v
⊆
U
−
V
v \subseteq U-V
v⊆U−V的边
(
u
,
v
)
⊆
E
(u,v) \subseteq E
(u,v)⊆E中找一条代价最小的边(
u
0
,
v
0
u_0,v_0
u0,v0)加入TE,同时
v
0
v_0
v0并入U,直至U=V为止。此时T为N的最小生成树。
用两个数组来执行Prim算法,具体的做法是lowcost记录在当前已经访问点到未访问点的最短距离,adjvex记录这个距离是由哪个点出发访问到的,每次都对未访问的结点进行遍历,找到lowcost最小的结点输出,然后用这个结点对lowcost进行更新,即对它的临接结点遍历,如果距离小于lowcost中相应值,就更新lowcost和adjvex相应位置的信息,Code:
void MiniSpanTree_Prim(Mgraph G)
{
int min, i, j, k;
int adjvex[MAXVEX];
int lowcost[MAXVEX];
/* 初始化 */
lowcost[0] = 0;
adjvex[0] = 0;
for(i = 1; i < G.numVertexes; i++)
{
lowcost[i] = G.arc[0][i];
adjvex[i] = 0;
}
/*lowcost就是在当前已经访问点到未访问点的最短距离,adjvex记录这个距离是由哪个点出发访问到的*/
for(i = 1; i < G.numVertexes; i++)
{
min = INT_MAX;
j = 1; k = 0;
while(j < G.numVertexes)
{
if(lowcost[j] != 0 && lowcost[j] < min)
{
min = lowcost[j];
k = j;
}
j++;
}
cout << adjvex[k] << " " << k << endl;
lowcost[k] = 0;
for(j = 1; j < G.numVertexes; j++)
{
if(lowcost[j] != 0 && G.arc[k][j] < lowcost[j])
{
lowcost[j] = G.arc[k][j];
adjvex[j] = k;
}
}
}
}
Kruskal算法
假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则加入T中,否则选择下一条代价最小的边,以此类推,直至T中所有顶点都在同一连通分量上。
把临接矩阵转换为边集数组来实现kruskal算法,边集数组结构:
typedef struct
{
int begin;
int end;
int weight;
}Edge;
注意这里还要把边集数组进行一下排序。
在具体的算法中,声明parent数组用来标记数组中的连通子图,具体做法是对一条新加入的边做判断,分别查找两个顶点的连线的尾部下标,这个查找操作用find函数实现:
int Find(int *parent, int f)
{
while(parent[f] > 0)
f = parent[f];
return f;
}
如果相同,说明在同一个连通子图中,否则,加入这条边,并将这两个尾部下标连接起来,这样如果以后有这个子图的结点,通过调用find函数,就可以找到相同的尾部下标,从而判断是否在同一子图中。Code:
void MiniSpanTree_Kruskal(Mgraph G)
{
int k = 0;
Edge temp,edges[G.numEdges];
int parent[G.numVertexes];
for(int i = 0; i < G.numVertexes; i++)
{
for(int j = i + 1; j < G.numVertexes; j++)
{
if(G.arc[i][j] < INT_MAX)
{
edges[k].begin = i;
edges[k].end = j;
edges[k].weight = G.arc[i][j];
k++;
}
}
}
for(int i = 0; i < G.numEdges; i++)
{
for(int j = i + 1; j < G.numEdges; j++)
{
if(edges[i].weight > edges[j].weight)
{
temp = edges[i];
edges[i] = edges[j];
edges[j] = temp;
}
}
}
for(int i = 0; i < G.numVertexes; i++)
parent[i] = 0;
int m,n;
for(int i = 0; i < G.numEdges; i++)
{
n = Find(parent,edges[i].begin);
m = Find(parent,edges[i].end);
if(n != m)
{
parent[n] = m;
cout << edges[i].begin << " " << edges[i].end <<endl;
}
}
}
最短路径问题
最短路径问题就是在网中两顶点之间经过的边上权值之和最少的路径。
Dijkstra算法
Dijkstra算法用来求从某个源点到其余各顶点的最短路径问题,它通过对图中每个顶点进行遍历,一步步得到各顶点 的最短路径,换句话说,基于已经求出的最短路径的基础上,求得更远顶点的最短路径。Code:
void ShortestPath_Dijkstra(Mgraph G, int v)
{
bool visited[G.numVertexes] = {false};
int Final[G.numVertexes] = {INT_MAX};
int Path[G.numVertexes] = {0};
visited[v] = true;
for(int i = 0; i < G.numVertexes; i++)
Final[i] = G.arc[v][i];
Final[v] = 0;
for(int i = 1; i < G.numVertexes; i++)
{
for(int j = 0; j < G.numVertexes; j++)
cout << Final[j]<< " " ;
cout<<endl;
int k, Min = INT_MAX;
for(int j = 0; j < G.numVertexes; j++)
{
if(!visited[j] && Final[j] < Min)
{
k = j;
Min = Final[j];
}
}
visited[k] = true;
for(int j = 0; j < G.numVertexes; j++)
{
if(!visited[j] && Final[j] > Min+ G.arc[k][j] && G.arc[k][j] != INT_MAX)
{
Final[j] = Min + G.arc[k][j];
Path[j] = k;
}
}
}
for(int i = 0; i < G.numVertexes; i++)
{
cout << Final[i] << " " << Path[i] <<endl;
}
}
Floyd算法
Floyd可以在O(N^3)的时间内计算出图中所有结点之间的最短距离,具体做法是动态维护一个最短路径矩阵,这个矩阵的初始值就是图的邻接矩阵,动态规划的状态转移方程为:d[i][j]=min(d[i][j],d[i][k]+d[k][j]),具体floyd的原理,我认为这篇博文讲的非常好→传送门
Code:
void ShortestPath_Floyd(Mgraph G)
{
int Path[G.numVertexes][G.numVertexes];
int Final[G.numVertexes][G.numVertexes];
for(int i = 0; i < G.numVertexes; i++)
{
for(int j = 0; j < G.numVertexes; j++)
{
Final[i][j] = G.arc[i][j];
Path[i][j] = j;
}
}
for(int k = 0; k < G.numVertexes; k++)
{
for(int i = 0; i < G.numVertexes; i++)
{
for(int j = 0; j < G.numVertexes; j++)
{
if(Final[i][j] > Final[i][k] + Final[k][j] && Final[i][k] != INT_MAX &&Final[k][j] != INT_MAX)
{
Final[i][j] = Final[i][k] + Final[k][j];
Path[i][j] = k;
}
}
}
}
for(int i = 0; i < G.numVertexes; i++)
{
for(int j = i + 1; j < G.numVertexes; j++)
{
int k = Path[i][j];
cout << "path" << i << "->";
while(k != j)
{
cout << k << "->";
k = Path[k][j];
}
cout << j << endl;
}
}
}
拓扑排序与关键路径问题
拓扑排序
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,称为AOV网,设G = (V , E)是一个具有n个顶点的有向图,V 中的顶点序列v1,v2, … , vn,满足若从顶点vi带vj有一条路径,则在顶点序列中vi必须再vj之前,我们称这样的顶点序列为一个拓扑序列。
拓扑排序就是对一个有向图构造拓扑序列的过程。
拓扑排序的思路:
从AOV网中选择一个入度为0的顶点输出,上删除次顶点,并删除以此顶点为尾的弧,重复此步骤,直到输出全部顶点或AOV网中不存在入度为0的顶点为止。
关键路径问题
在一个表示工程的带权有向图中,用顶点表示活动,用弧表示活动之间的优先关系,用边上的权值表示活动的持续时间,这样的有向图为顶点表示活动的网,称为AOE网。
我们把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。为了确定关键路径,需要定义几个参数:
- 事件的最早发生时间etv
- 事件的最晚发生时间ltv
- 活动的最早开工时间ete
- 活动的最晚开工时间lte
由1、 2可以求得3、 4,然后根据ete[k]和lte[k]是否相等来判断a[k]是否是关键活动。