图论(从数据结构的三要素出发)

文章目录

逻辑结构

以下图片来源于王道的数据结构








物理结构

邻接矩阵

定义

顶点数为 n n n的图 G = ( V , E ) G=(V,E) G=(V,E)的邻接矩阵 A A A是 n x n nxn nxn的,将 G G G的顶点编号为 v 1 , v 2 , ⋯ , v n v_1,v_2,⋯,v_n v1,v2,⋯,vn,则
A [ i ] [ j ] = { 1 , ( v i , v j ) 或 ⟨ v i , v j ⟩ 是 E ( G ) 中的边 0 , ( v i , v j ) 或 ⟨ v i , v j ⟩ 不是 E ( G ) 中的边 A[i][j]= \begin{cases}1, & \left(v_i, v_j\right) \text { 或 }\left\langle v_i, v_j\right\rangle \text { 是 } E(G) \text { 中的边 } \\ 0, & \left(v_i, v_j\right) \text { 或 }\left\langle v_i, v_j\right\rangle \text { 不是 } E(G) \text { 中的边 }\end{cases} A[i][j]={1,0,(vi,vj) 或 ⟨vi,vj⟩ 是 E(G) 中的边 (vi,vj) 或 ⟨vi,vj⟩ 不是 E(G) 中的边

对带权图而言,若顶点 v i v_i vi和 v j v_j vj,之间有边相连,则邻接矩阵中对应项存放着该边对应的权值,若顶点 V i V_i Vi和 V j V_j Vj不相连,则通常用0或 ∞ ∞ ∞来代表这两个顶点之间不存在边:
A [ i ] [ j ] = { w i j , ( v i , v j ) 或 ⟨ v i , v j ⟩ 是 E ( G ) 中的边 0 或 ∞ , ( v i , v j ) 或 ⟨ v i , v j ⟩ 不是 E ( G ) 中的边 A[i][j]= \begin{cases}w_{i j}, & \left(v_i, v_j\right) \text { 或 }\left\langle v_i, v_j\right\rangle \text { 是 } E(G) \text { 中的边 } \\ 0 \text { 或 } \infty, & \left(v_i, v_j\right) \text { 或 }\left\langle v_i, v_j\right\rangle \text { 不是 } E(G) \text { 中的边 }\end{cases} A[i][j]={wij,0 或 ∞,(vi,vj) 或 ⟨vi,vj⟩ 是 E(G) 中的边 (vi,vj) 或 ⟨vi,vj⟩ 不是 E(G) 中的边

c 复制代码
typedef char VertexType;							// 顶点数据类型
typedef int EdgeType;								// 边数据类型

typedef struct {
		VertexType vex[MaxVertexNum];				// 顶点集
		EdgeType edge[MaxVertexNum][MaxVertexNum];	// 边集
		int vexnum, arcnum;							// 当前顶点数和边数
}MGraph;

性能分析

  • 空间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),只和顶点数相关,和实际的边数无关。
  • 适合用于存储稠密图。
  • 无向图的邻接矩阵是对称矩阵,可以压缩存储,只需要 n ( n + 1 ) 2 − 1 \frac{n(n+1)}{2}-1 2n(n+1)−1个存储空间。

性质

存在的问题

存储空间极大的浪费了,且删除顶点和边的时间复杂度高。

邻接表

定义

图 G G G中的每个顶点 v i v_i vi建立一个单链表,第 i i i个单链表中的结点表示依附于顶点 v i v_i vi的边(对于有向图则是以顶点 v i v_i vi为尾的弧),这个单链表就称为顶点v_i$的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储,称为顶点表,所以在邻接表中存在两种结点:顶点表结点边表结点 。(类似于树的孩子表示法)

c 复制代码
typedef struct ArcNode {			// 边表结点
	int adjvex;						// 该弧所指向的顶点的位置
	struct ArcNode *nextarc;		// 指向下一条弧的指针
	InfoType info;					// 权值
} ArcNode;

typedef struct VNode {				// 顶点表结点
	VertexType data;				// 顶点信息
	ArcNode *firstarc;				// 指向第一条依附该顶点的弧的指针
} VNode, AdjList[MaxVertexNum];

typedef struct {					
	AdjList vertices;				// 邻接表
	int vernum, arcnum;				// 图的顶点数和弧数
} ALGraph;

性能分析

  • 空间复杂度:有向图 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣),无向图 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
  • 适合用于存储稀疏图。

存在的问题

对于无向图而言,需要存储两份边,产生冗余数据,在计算入度和入边时间复杂度高。

十字链表(有向图)

定义

为了解决邻接表法中计算入度入边时间复杂度高 的问题,我们引入了十字链表法存储有向图

c 复制代码
typedef struct ArcNode {                // 边表结点
    int tailvex;                        // 该弧的起始顶点的位置
    int headvex;                        // 该弧的终止顶点的位置
    struct ArcNode *hlink;              // 指向下一条终止于同一顶点的弧的指针
    struct ArcNode *tlink;              // 指向下一条起始于同一顶点的弧的指针
    InfoType info;                      // 权值信息
} ArcNode;

typedef struct VNode {                  // 顶点表结点
    VertexType data;                    // 顶点信息
    ArcNode *firstin;                   // 指向第一条入弧的指针
    ArcNode *firstout;                  // 指向第一条出弧的指针
} VNode, OLAdjList[MaxVertexNum];

typedef struct {
    OLAdjList vertices;                 // 十字链表的顶点表
    int vernum, arcnum;                 // 图的顶点数和弧数
} OLGraph;

性能分析

  • 空间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
  • 只能存储有向图

邻接多重表(无向图)

定义

为了解决

  • 邻接表法中存储无向图需要保存两份边会产生数据冗余的问题
  • 邻接矩阵中删除边和结点复杂度高的问题

我们引入了邻接多重表存储无向图

c 复制代码
typedef struct ENode {                    // 边表结点
    int ivex, jvex;                       // 该边依附的两个顶点的位置
    struct ENode *ilink, *jlink;          // 分别指向依附于顶点ivex和jvex的下一条边
    InfoType info;                        // 边的信息(如权值)
} ENode;

typedef struct VNode {                    // 顶点表结点
    VertexType data;                      // 顶点信息
    ENode *firstedge;                     // 指向第一条依附该顶点的边的指针
} VNode;

typedef struct {
    VNode adjmulist[MaxVertexNum];        // 顶点表数组
    int vernum, edgenum;                  // 图的顶点数和边数
} AMLGraph;

性能分析

  • 空间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
  • 只能存储有向图
  • 删除边、删除节点等操作很方便

数据的操作

图的基本操作

判断图G是否存在边<x, y>或(x, y)

Neighbors(G,x):列出图G中与结点x邻接的边


InsertVertex(G,x):在图G中插入顶点x

DeleteVertex(G,x):从图G中删除顶点x


AddEdge(G,x,y):若无向边(x, y)或有向边<x, y>不存在,则向图G中添加该边。

RemoveEdge(G,x,y):若无向边(x, y)或有向边<x, y>存在,则从图G中删除该边。

FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1。

NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。

图的遍历

广度优先遍历(BFS)

算法思想和实现
c 复制代码
bool visited[Max_Vertex_Num];			// 初始全为false

void BFSTraverse(Graph g)					
{
	for (int i = 0; i < g.vexnum; i ++ )
		visited[i] = false;

	for (int i = 0; i < g.vexnum; i ++ )			// 针对非连通图
		if (!visited[i])
			BFS(g, i);
}

void BFS(Graph g, int v)
{
	Queue q, InitQueue(q);
	Enqueue(q, v);
	visit(v), visited[v] = true;

	while (!isEmpty(q))
	{
		DeQueue(q, v);

		for (int w = FirstNeighbor(g, v); w != -1; w = NextNeighbor(g, v, w))
			if (!visit[w])
			{
				visit(w), visited[w] = true;
				EnQueue(q, w);
			}
	}
}

结论:

  • 对于无向图,BFS调用的次数=连通分量数。
  • 对于有向图,BFS调用一次不能访问所有结点,可以得出该子图是非强连通分量。
性能分析
深度优先最小生成树

在广度遍历的过程中,我们可以得到一棵遍历树,称为广度优先生成树。同一个图的邻接矩阵存储表示是唯一的,所以其广度优先生成树也是唯一的,但因为邻接表存储表示不唯一,所以其广度优先生成树也是不唯一的。

深度优先遍历(DFS)

算法思想和实现
c 复制代码
bool visited[Max_Vertex_Num];			// 初始全为false

void DFSTraverse(Graph g)					
{
	for (int i = 0; i < g.vexnum; i ++ )
		visited[i] = false;

	for (int i = 0; i < g.vexnum; i ++ )			// 针对非连通图
		if (!visited[i])
			DFS(g, i);
}

void DFS(Graph g, int v)
{
	visit(v);
	visited[v] = true;

	for (int w = FirstNeighbor(g, v); w != -1; w = NextNeighbor(g, v, w))
		if (!visit[w])
			DFS(g, w);
}

结论:

  • 对于无向图,DFS调用的次数=连通分量数。
  • 对于有向图,DFS调用一次不能访问所有结点,可以得出该子图是非强连通分量。
性能分析
深度优先的生成树和生成森林

与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。当然,这是有条件的,即对连通图调用 DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林,与 BFS类似,基于邻接表存储的深度优先生成树是不唯一的。

数据结构的应用

最小生成树

问题描述

对于⼀个带权连通无向图 G = ( V , E ) G = (V, E) G=(V,E),⽣成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设 R R R为 G G G的所有⽣成树的集合,若 T T T为 R R R中边的权值之和最小的生成树,则 T T T称为 G G G的最小生成树。

  • 如果⼀个连通图本身就是⼀棵树,则其最小生成树就是它本身。
  • 最小生成树可能有多个,但边的权值之和总是唯⼀且最小的。
  • 最小生成树的边数 = 顶点数 - 1。砍掉⼀条则不连通,增加⼀条边则会出现回路。
  • 只有连通图才有生成树,非连通图只有生成森林。

Prim算法(结点)

从某⼀个顶点 开始构建⽣成树;每次将代价最小的新顶点 纳⼊生成树,直到所有顶点都纳入为止。

c 复制代码
int dist[MaxVertexNum];						// 结点距离目标生成树的最小距离
bool st[MaxVertexNum];						// 结点是否已经被加入到目标生成树中
int INF = 0x3f3f3f3f;						// 设定最大值

int prim(MGraph g)
{
	memset(dist, 0x3f, sizeof dist);		// 初始化n个互补相连的结点
	
	int res = 0;							// 计算最小生成树的权值
	
	for (int i = 0; i < g.vexnum; i ++ )
	{
		/*寻找距离目标生成树最小距离*/
		int t = -1;
		for (int j = 0; j < g.vexnum; j ++ )
			if (t != -1 || dist[t] > dist[j])
				t = j;

		if (i && dist[t] == INF) return INF;// 证明原图是一个非连通图

		if (i) res += dist[t];				// 第一次当然不用计算权值
		st[t] = true;						// 将该结点加入到目标生成树中去

		/*用已加入目标生成树的结点去更新其他待加入目标生成树结点的距离*/
		for (int j = 0; j < g.vexnum; j ++ )
			dist[j] = min(dist[j], g.edge[t][j]);
	}
	return res;
}

时间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),适用于稠密图,用邻接矩阵的方式存储图。(可以用最小堆的方式改进Prim算法中的找距离目标生成树最小距离的结点)

Kruskal算法(边)

每次选择一条权值最小的 ,使这条的两头连通(原本已经连通的就不选)直到所有结点都连通。

c 复制代码
int p[MaxVertexNum];					// 并查集

struct Edge
{
    int a, b;							// 边(a,b)
    int w;								// 权值
} edges[MaxArcNum];

int find(int x)							// 路径压缩的并查集
{
	if (p[x] != -1) p[x] = find(p[x]);
	return p[x];
}

int kruskal(ALGraph g)
{
	quickSort(edges);					// 快速排序,时间复杂度O(nlogn)

	memset(p, -1, sizeof p);			// 初始化并查集

	int res = 0, cnt = 0;				// 记录最小生成树的权值和记录当前最小生成树的边的个数

	for (int i = 0; i < g.arcnum; i ++ )
	{
		int a = edges[i].a, b = edges[i].b, w = edges[i].w;
		
		a = find(a), b = find(b);		// 查找结点a和b是否在同一棵目标生成子树中
		if (a != b)						// 若不在
		{
			p[a] = b;					// 将两个子树合并成一棵生成子树
            res += w;					// 更新最小生成树的权值
            cnt ++ ;					// 更新当前生成树的边个数
		}
	}
	if (cnt < n - 1) return INF;		// 边数小于结点数-1一定是非连通图
    return res;
}

时间复杂度: O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2|E|) O(∣E∣log2∣E∣),(主要是快速排序的时间复杂度: O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2|E|) O(∣E∣log2∣E∣)+遍历所有边 O ( ∣ E ∣ ) O(|E|) O(∣E∣) × \times ×并查集Find操作的时间复杂度 O ( α ( ∣ E ∣ ≤ 4 ) → O ( 1 ) O(\alpha(|E|\leq4)\rightarrow O(1) O(α(∣E∣≤4)→O(1)故总的时间复杂度是 O ( ∣ E ∣ l o g 2 ∣ E ∣ + ∣ E ∣ × α ( ∣ E ∣ ) O(|E|log_2|E|+|E|\times\alpha(|E|) O(∣E∣log2∣E∣+∣E∣×α(∣E∣))适用于稀疏图,用邻接表的方式存储图。

最短路径

BFS算法(不带权)

若图 G = ( V , E ) G=(V,E) G=(V,E)为非带权图 ,定义从顶点 u u u到顶点 v v v的最短路径 d ( u , v ) d(u,v) d(u,v)为从 u u u到 v v v的任何路径中最少的边数;若从 u u u到 v v v没有通路,则 d ( u , v ) = ∞ d(u,v)=∞ d(u,v)=∞。

因为BFS算法是逐层遍历的,所以最先被访问的结点一定距离最短。

c 复制代码
bool visited[Max_Vertex_Num];			// 初始全为false
int d[Max_Vertex_Num];					
int path[Max_Vertex_Num];
int INF = 0x3f3f3f3f;

void BFS_MIN_Distance(Graph g, int u)
{
	for (int i = 0; i < g.vernum; i ++ )
	{
		d[i] = INF;						// 初始化路径长度
		path[i] = -1;					// 最短路径从哪个顶点过来
	}

	d[u] = 0;
	
	Queue q, InitQueue(q);
	Enqueue(q, u);
	visit(u), visited[u] = true;

	while (!isEmpty(q))
	{
		DeQueue(q, u);

		for (int w = FirstNeighbor(g, u); w != -1; w = NextNeighbor(g, u, w))
			if (!visit[w])
			{
				d[w] = d[u] + 1;
				path[w] = u;
				visit(w), visited[w] = true;
				EnQueue(q, w);
			}
	}
}

Dijkstra算法(只能是正权图)

Dijkstra 算法设置一个集合 S S S记录已求得的最短路径的顶点,初始时把源点 v 0 v_0 v0放入 S S S,集合
S S S每并入一个新顶点 v i v_i vi,都要修改源点 v 0 v_0 v0到集合 V − S V-S V−S中顶点当前的最短路径长度值。

带权路径⻓度------当图是带权图时,⼀条路径上所有边的权值之和,称为该路径的带权路径⻓度。

c 复制代码
int dist[MaxVertexNum];
bool st[MaxVertexNum];
int path[MaxVertexNum];

void dijkstra(Graph g, int u)
{
	memset(dist, 0x3f, sizeof dist);
    dist[u] = 0;
    /*遍历n边,每一遍都会更新一个节点到1节点的最小值,由于是正权图,故局部最优,就为全局最优*/
    for (int i = 0; i < g.vernum; i ++ ) 
    {
        /*寻找到距离u最短的点*/
        int t = -1;
        for (int j = 0; j < g.vernum; j ++ ) 
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        st[t] = true;

        /*用t节点更新其余未更新的点(邻接矩阵版本)*/
        for (int j = 0; j < g.vernum; j ++ )
            if (!st[j])
                dist[j] = min(dist[j], dist[t] + g.edge[t][j]), path[j] = t;

		/*用t节点更新其余未更新的点(邻接表版本)*/
		ArcNode *p = g.vertices[t];
        while (p)
        {
        	if (!st[p->adjvex])
            {
            	dist[p->adjvex] = min(dist[p->adjvex], dist[t] + p->info);
                path[p->adjvex] = t;
            }
            p = p->nextarc;
        }
    }
}

时间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)

Bellman Ford算法(可以是负权图)

为了解决Dijkstra 算法不能处理负权图,我们引入Bellman - ford 算法。

Bellman-Ford算法的设计理念基于图的性质,特别是路径上的边数的限制。以下是对为什么Bellman-Ford算法在进行 (V-1) 次松弛操作后就能确定最短路径的详细解释。

对于一个包含 (V) 个顶点的有向图,最短路径的一个重要性质是:从源点到任何其他顶点的最短路径最多包含 ( ∣ V ∣ − 1 ) (|V|-1) (∣V∣−1) 条边 。这是因为如果一个路径包含 V V V 条边或更多,它必然会包含一个环(根据图论中的路径定义和鸽巢原理),而在最短路径中不应该包含环,因为环只会增加路径的总长度(除非是负权环,但这会使路径总长度趋向负无穷)。

为什么 ( V − 1 ) (V-1) (V−1) 次松弛操作足够?

基例:0次松弛操作,在0次松弛操作之后:

  • 源点 s s s的距离 dist ( s ) \text{dist}(s) dist(s)初始化为0。
  • 其他所有顶点的距离初始化为正无穷大。

显然,此时从源点到自身的最短路径已经正确确定,其他顶点的最短路径尚未确定。

归纳假设:假设在第 k k k次松弛操作之后,从源点出发最多经过 k k k条边的最短路径已经正确确定。

归纳步骤

现在,我们需要证明在第 k + 1 k+1 k+1次松弛操作之后,从源点出发最多经过 k + 1 k+1 k+1条边的最短路径也能正确确定。

在第 k + 1 k+1 k+1次松弛操作中,对于每一条边 ( u , v ) (u, v) (u,v),我们尝试进行松弛操作:
dist ( v ) = min ⁡ ( dist ( v ) , dist ( u ) + w ( u , v ) ) \text{dist}(v) = \min(\text{dist}(v), \text{dist}(u) + w(u, v)) dist(v)=min(dist(v),dist(u)+w(u,v))

我们需要证明,从源点到任意顶点 v v v的最短路径最多经过 k + 1 k+1 k+1条边的距离被正确计算。

情况分析

  1. 如果从源点到顶点 v v v的最短路径最多经过 k + 1 k+1 k+1条边,则该路径可以表示为:
    s → u 1 → u 2 → ⋯ → u k → v s \rightarrow u_1 \rightarrow u_2 \rightarrow \cdots \rightarrow u_k \rightarrow v s→u1→u2→⋯→uk→v

    这里, s → u 1 → u 2 → ⋯ → u k s \rightarrow u_1 \rightarrow u_2 \rightarrow \cdots \rightarrow u_k s→u1→u2→⋯→uk 是一条从源点 s s s到顶点 u k u_k uk的最短路径,且这条路径最多经过 k k k条边。在第 k k k次松弛操作之后,这条路径的最短距离已经正确确定。

  2. 在第 k + 1 k+1 k+1次松弛操作中,通过边 ( u k , v ) (u_k, v) (uk,v)进行松弛操作,可以更新顶点 v v v的最短距离:
    dist ( v ) = min ⁡ ( dist ( v ) , dist ( u k ) + w ( u k , v ) ) \text{dist}(v) = \min(\text{dist}(v), \text{dist}(u_k) + w(u_k, v)) dist(v)=min(dist(v),dist(uk)+w(uk,v))

    由于根据归纳假设, dist ( u k ) \text{dist}(u_k) dist(uk)已经正确表示了从源点到顶点 u k u_k uk的最短距离,所以在第 k + 1 k+1 k+1次松弛操作后,顶点 v v v的距离也会被更新为从源点出发最多经过 k + 1 k+1 k+1条边的最短路径距离。

负权环的检测

在进行完 V − 1 V-1 V−1 次松弛操作之后,Bellman-Ford算法再进行一次全图边的松弛操作。如果在这次操作中仍然有边能够被松弛(即存在一条边 ( u , v ) (u, v) (u,v) 使得 dist ( v ) > dist ( u ) + weight ( u , v ) \text{dist}(v) > \text{dist}(u) + \text{weight}(u, v) dist(v)>dist(u)+weight(u,v)),则说明图中存在负权环,因为理论上在 V − 1 V-1 V−1次松弛操作之后,所有最短路径应该已经稳定,不应该再有进一步的改进。

总结

  • 最多 V − 1 V-1 V−1 条边 :从源点到任意顶点的最短路径最多包含 V − 1 V-1 V−1条边。
  • 逐步松弛:每次松弛操作最多确保路径增加一条边的最短路径被正确计算。
  • 负权环检测 :通过第 V V V次松弛操作检测是否存在负权环。
c 复制代码
typedef struct {
    int a, b, c;
} edge, Edges[M];

int dist[MaxVertexNum];
int last[MaxVertexNum];

void bellman_ford(Edges e, int u)
{
    memset(dist, 0x3f, sizeof dist);

    dist[u] = 0;
    for (int i = 0; i < vernum - 1; i ++ )
    {
        memcpy(last, dist, sizeof dist);
        for (int j = 0; j < arcnum; j ++ )
        {
            edge t = e[j];
            dist[t.b] = min(dist[t.b], last[t.a] + t.c);
        }
    }
}

时间复杂度: O ( ∣ V ∣ ∣ E ∣ ) O(|V||E|) O(∣V∣∣E∣)

SPFA算法(可以是负权图)

在Bellman-Ford算法中,不必要的松弛操作有以下几种情况:

  1. 已确定最短路径的顶点:某些顶点的最短路径在某轮松弛操作后已经确定,在后续的迭代中,对这些顶点的边进行松弛操作是多余的,因为它们的距离值不再改变。

  2. 无效松弛 :对于某些边 ( u , v ) (u, v) (u,v),如果 d i s t a n c e [ u ] + w > = d i s t a n c e [ v ] distance[u] + w >= distance[v] distance[u]+w>=distance[v],即使继续松弛也不会更新 d i s t a n c e [ v ] distance[v] distance[v],这意味着这些松弛操作是无效的。

从而引入SPFA算法对Bellman-Ford算法进行改进,减少不必要的松弛操作。(只有该结点上一轮已经被松弛过,下一轮才会去更新与该结点相邻的所有结点)具体来说,SPFA算法的优化主要体现在:

  1. 队列管理 :只对可能导致更新的顶点进行松弛操作。例如,在一次松弛操作中, 如果 d i s t a n c e [ u ] 如果distance[u] 如果distance[u]发生了变化,则将与 u u u相邻的顶点 v v v加入队列,以便后续检查是否需要松弛。

  2. 减少迭代次数 :因为队列中只包含需要松弛的顶点,SPFA算法可能会在队列处理完之前结束,不必进行固定的 V − 1 V-1 V−1轮松弛操作。

c 复制代码
int dist[MaxVertexNum];
bool st[MaxVertexNum];

void spfa(ALGraph g, int u)
{
    memset(dist, 0x3f, sizeof dist);
    dist[u] = 0;

    Queue q, InitQueue(q);				// 在队列内的是待松弛结点
    EnQueue(q, u);
    st[u] = true;

    while (!isEmpty(q))
    {
        DeQueue(q, u)

        st[u] = false;

        for (ArcNode *p = g.vertices[u].firstarc; p; p = p->nextarc)
        {
            if (dist[p->adjvex] > dist[u] + p->info)		// 若可以松弛
            {
                dist[p->adjvex] = dist[u] + p->info
                if (!st[p->adjvex])			// 若不在松弛队列中,加入
                {
                    q.push(p->adjvex);
                    st[p->adjvex] = true;
                }
            }
        }
    }
}

时间复杂度: O ( ∣ V ∣ ∣ E ∣ ) O(|V||E|) O(∣V∣∣E∣),但是SPFA算法操作次数通常要比Bellman-Ford算法要少的多。

Floyd算法(点与点之间的最短路径)

f[i, j, k]表示从i走到j的路径上除ij点外只经过1k的点(作为中转点)的所有路径的最短距离。

那么f[i, j, k]一定是从这两个状态转换过来的

  • ij不经过k(作为中转点):f[i, j, k - 1]
  • ij经过k(作为中转点):f[i, k, k - 1] + f[k, j, k - 1]

因此在计算第k层的f[i, j]的时候必须先将第k - 1层的所有状态计算出来,所以需要把k放在最外层。

c 复制代码
int dist[MaxVertexNum][MaxVertexNum];

void floyd(){
    for(int k = 1; k <= MaxVertexNum; k ++)
        for(int i = 1; i <= MaxVertexNum; i ++)
            for(int j = 1; j <= MaxVertexNum; j ++)
                dist[i][j] = min(dist[i][j],dist[i][k] + dist[k][j]);
}

时间复杂度: O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)

有向无环图(DAG)

有向无环图:若一个有向图中不存在环,则称为有向无环图,简称 DAG图。


构建表达式的有向无环图

解题方法

  • Step 1:把各个操作数不重复地排成⼀排
  • Step 2:标出各个运算符的生效顺序(先后顺序有点出入无所谓)
  • Step 3:按顺序加⼊运算符,注意"分层"
  • Step 4:从底向上逐层检查同层的运算符是否可以合体

拓扑排序

AOV网(Activity On Vertex NetWork) :若用**有向无环图(DAG)**表示一个工程,其顶点表示活动,用有向边 < V i , V j > <V_i,V_j> <Vi,Vj>表示活动 V i V_i Vi必须先于活动 V j V_j Vj进行的这样一种关系,则将这种有向图称为顶点表示活动的网络,简称 AOV网即找到做事的先后顺序)。


拓扑排序的实现:

① 从AOV网中选择⼀个没有前驱(⼊度为0)的顶点并输出。

② 从网中删除该顶点和所有以它为起点的有向边。

③ 重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点(说明有回路,入度全部>0)为止。

c 复制代码
int indegree[MaxVertexNum];

bool ToplogicalSort(Graph g)
{
	Queue q, InitQueue(q);
	
	for (int i = 0; i < g.vexnum; i ++ )
		if (indergee[i] == 0)
			EnQueue(q, i);

	int cnt = 0;							// 记录当前已输出的结点数
	while (!isEmpty(q))
	{
		DeQueue(q, i);
		visit(i), cnt ++ ;
		
		for (ArcNode *p = g.vertices[i].firstarc; p; p = p->nextarc)
		{
			int v = p->adjvex;
			if (!(-- indegree[v]))
				EnQueue(v, q);
		}
	}

	if (cnt < g.vexnum) return false;
	return true;
}
  • 用邻接表,时间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
  • 用邻接矩阵,时间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)

逆拓扑排序

对⼀个AOV网,如果采⽤下列步骤进行排序,则称之为逆拓扑排序

① 从AOV网中选择⼀个没有后继 (出度为0) 的顶点并输出。

② 从网中删除该顶点和所有以它为终点的有向边。

③ 重复①和②直到当前的AOV网为空


用队列实现(非递归)

c 复制代码
int outdegree[MaxVertexNum];

bool NevToplogicalSort(Graph g)
{
	Queue q, InitQueue(q);
	
	for (int i = 0; i < g.vexnum; i ++ )
		if (outdegree[i] == 0)
			EnQueue(q, i);

	int cnt = 0;							// 记录当前已输出的结点数
	while (!isEmpty(q))
	{
		DeQueue(q, i);
		visit(i), cnt ++ ;
		
		for (ArcNode *p = g.vertices[i].firstarc; p; p = p->nextarc)
		{
			int v = p->adjvex;
			if (!(-- outdegree[v]))
				EnQueue(v, q);
		}
	}

	if (cnt < g.vexnum) return false;
	return true;
}
  • 用邻接表,时间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
  • 用邻接矩阵,时间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)

判断环路

用DFS实现(递归)

判断一个有向图中是否存在回路(环)的一个有效方法是使用深度优先搜索(DFS)来检测是否存在后向边(back edge)。在深度优先搜索的过程中,如果从一个顶点访问到已经在当前递归堆栈中的顶点,则说明存在环。

在进行逆拓扑排序时,DFS的实现可以通过以下方式判断回路:

  1. 使用递归堆栈标记 :在DFS过程中,除了visited数组外,还使用一个recStack数组来标记当前递归堆栈中的顶点。
  2. 检查后向边:在访问邻接顶点时,如果邻接顶点已经在递归堆栈中,则说明存在回路。
c 复制代码
bool visited[MaxVertexNum];
bool recStack[MaxVertexNum];

bool DFSTraverse(Graph g)
{
	for (int i = 0; i < g.vexnum; i ++ )
		visited[i] = false, recStack[i] = false;

	for (int i = 0; i < g.vexnum; i ++ )
		if (!visited[i] && DFS(g, i))
			return true;						// 有回路

	return false;								// 无回路
}

bool DFS(Graph g, int u)
{
	visited[u] = true;
	recStack[u] = true;
	for (w = FirstNeigbor(g, v); w != -1; w = NextNeigbor(g, v, w))
	{
		if (!visited[w] && DFS(g, w))
			return true;
		else if (recStack[w])					// 检测到后向边
			return true;
	}
	
	recStack[u] = false;
    visit(u);
    return false;
}
  1. visited数组:用于标记每个顶点是否被访问过。
  2. recStack数组:用于标记当前递归堆栈中的顶点,检测后向边。
  3. DFS函数 :在递归调用DFS时,设置recStack[u]为true。在访问邻接顶点时,如果发现邻接顶点已经在递归堆栈中(recStack[w]为true),则说明存在回路。
  4. DFSTraverse函数:对图中的每个顶点进行DFS,如果DFS过程中发现回路,则返回true。

关键路径

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)

AOE网具有以下两个性质:

① 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;

② 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。另外,有些活动是可以并行进行的。

从源点到汇点的有向路径可能有多条,所有路径中,具有最大径长度的路径称为关键路径 ,而把关键路径上的活动称为关键活动

完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。



求关键路径的算法步骤如下:

①从源点出发,令 v e ( 源点 ) = 0 v_e(源点)=0 ve(源点)=0,按拓扑有序求其余顶点的最早发生时间 v e ( ) v_e() ve()。

②从汇点出发,令 v l ( 汇点 ) = v e ( 汇点 ) v_l(汇点)=v_e(汇点) vl(汇点)=ve(汇点),按逆拓扑有序求其余顶点的最迟发生时间 v l ( ) v_l() vl()。

③根据各顶点的 v e v_e ve值求所有弧(弧头)的最早开始时间 e ( ) e() e()。

④根据各顶点的 v l ( ) v_l() vl()值求所有弧(弧头)的最迟开始时间 l ( ) l() l()。

⑤求AOE网中所有活动的差额 d ( ) d() d(),找出所有 d ( ) = 0 d()=0 d()=0的活动构成关键路径。


关键活动、关键路径的特性

  • 若关键活动耗时增加,则整个工程的工期将增长
  • 缩短关键活动的时间,可以缩短整个工程的工期
  • 当缩短到⼀定程度时,关键活动可能会变成非关键活动
  • 可能有多条关键路径,只提高⼀条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。


各种图算法在采用邻接矩阵或邻接表存储时的时间复杂度如下所示:

相关推荐
no_play_no_games1 小时前
「3.3」虫洞 Wormholes
数据结构·c++·算法·图论
PYSpring1 小时前
数据结构-LRU缓存(C语言实现)
c语言·数据结构·缓存
Mr Aokey2 小时前
双向无头非循环链表的简单实现及介绍
数据结构
狐小粟同学3 小时前
链表面试编程题
数据结构·链表·面试
CyberMuse4 小时前
数值计算的程序设计问题举例
数据结构
陈奕迅本讯4 小时前
数据结构-链表笔记
数据结构·笔记·链表
江奖蒋犟4 小时前
【初阶数据结构】排序——归并排序
c语言·数据结构·算法
cdut_suye4 小时前
STL之list篇(下)(从底层分析实现list容器,逐步剥开list的外表)
开发语言·数据结构·c++·学习·算法·stl·list
Felix Du5 小时前
数据结构与算法(七)静态链表
c语言·数据结构·学习·算法·链表
所待.3835 小时前
设计循环队列
java·开发语言·数据结构