点击蓝字 关注我们
图论算法在计算机科学中扮演着很重要的角色,它提供了对很多问题都有效的一种简单而系统的建模方式。很多问题都可以转化为图论问题,然后用图论的基本算法加以解决。
图论算法是我们经常用来求解实际问题的一种方法,在数学建模的求解过程中也经常应用。
下面就通过一个例子,来让大家快速地知道什么是图,如下图所示:
G1 是有向图,G2 是无向图,每个数据元素称为顶点,在有向图中,从 V1 到 V3 称为一条弧,V3 到 V1 为另一条弧,V1 称为弧尾,V3 称为弧头,在无向图中,从 V1 到 V3 称为一条边。
(G1 有向图、G2 无向图)
有 n 个顶点,n(n-1)/2 条边的无向图称为完全图,有 n*(n-1)条弧有向图称为有向完全图,有很少条边或图称为稀疏图,反之称为稠密图。
在 G2 无向图中,类似 V3 与 V1、V2 和 V4 之间有边的互称为邻接点,与顶点相关联的边数称为顶点的度。
例如 V3 顶点的度为 3,而在 G1 有向图中,顶点的度是顶点的出度和入度之和,以顶点为头的弧的数目称为入度,为尾的弧的数目称为出度,例如 V1 顶点的出度为 2,入度为 1,它的度为 1+2=3。
1.图的存储、遍历
图的常见存储方式有邻接矩阵存储、邻接表存储,又可扩展为十字链表存储、多重表存储。
邻接矩阵存储
对于 N 个顶点的图,需创建一个 N 个空间的一维数组及 N*N 个空间的二维数组。一维数组负责存储每个顶点信息,二维数组则负责存储每条边的信息。
参考代码为:
class Graph:
def __init__(self, vertex):
self.vertex = vertex
self.graph = [[0] * vertex for i in range(vertex)]
def insert(self, u, v):
# 对存在连接关系的两个点,在矩阵里置1代表存在连接关系,没有连接关系则置0
self.graph[u - 1][v - 1] = 1
self.graph[v - 1][u - 1] = 1
def show(self): # 展示图
for i in self.graph:
for j in i:
print(j, end=' ')
print(' ')
graph = Graph(5)
graph.insert(1, 4)
graph.insert(4, 2)
graph.insert(4, 5)
graph.insert(2, 5)
graph.insert(5, 3)
graph.show()
该 graph 储存形式为:
0 0 0 1 0
0 0 0 1 1
0 0 0 0 1
1 1 0 0 1
0 1 1 1 0
邻接表存储
对N个顶点的图,邻接表存储需要创建N个空间的数组,且每个空间都会存放一个链表节点(数据域与邻边节点指针)。
而邻边节点又存放了邻点下标和关于头节点的另一个邻边节点指针。整体看上去是一个数组,每个数组元素又是一个单链表。
参考代码为(以无向图为例):
class Graph(object):
def __init__(self):
self.nodes = [] # 表示图的点集
self.edge = {} # 表示图的边集
def insert(self, a, b):
# 如果 a 不在图的点集里,则添加 a
if not(a in self.nodes):
self.nodes.append(a)
self.edge[a] = list()
# 如果 b 不在图的点集里,则添加 b
if not(b in self.nodes):
self.nodes.append(b)
self.edge[b] = list()
# a 连接 b
self.edge[a].append(b)
# b 连接 a
self.edge[b].append(a)
def succ(self, a):
# 返回与 a 连接的点
return self.edge[a]
def show_nodes(self):
# 返回图的点集
return self.nodes
def show_edge(self):
print(self.edge)
graph = Graph()
graph.insert('0', '1')
graph.insert('0', '2')
graph.insert('0', '3')
graph.insert('1', '3')
graph.insert('2', '3')
graph.show_edge()
该 graph 储存形式为:
{'0': ['1', '2', '3'], '1': ['0', '3'], '2': ['0', '3'], '3': ['0', '1', '2']}
图的遍历,又 bfs 广度优先遍历,dfs 深度优先遍历。
其中广度优先遍历,类似于二叉树遍历中的层次遍历,是采用队列先把根节点旁边的都遍历一遍,在一层一层的往下扩展;而深度优先遍历,则像二叉树中的栈遍历,上去先走到最底部,发现没路了再往后退退,看见旁边有路了又往最深处钻。
参考代码如下:
def dfs(G,s,S=None,res=None):
if S is None:
# 储存已经访问节点
S=set()
if res is None:
# 存储遍历顺序
res=[]
res.append(s)
S.add(s)
for u in G[s]:
if u in S:
continue
S.add(u)
dfs(G,u,S,res)
return res
G = {'0': ['1', '2'],
'1': ['2', '3'],
'2': ['3', '5'],
'3': ['4'],
'4': [],
'5': []}
print(dfs(G, '0'))
2.连通性问题
如果需求需要输出 N 个对象(整数)中,存在连接的数对。那么最多只能输出 N-1 个数对。如果能够输出 N-1 个数对,那说明给定的所有对象都是连通的。
1)快速查找算法
利用整数数组,每个整数对应一个对象,使用数组下标表示新建的数对。
#include <stdio.h>
#define N (1000)
int main()
{
int i, p, q, t;
int id[N];
//自然数列,所有对象的数值互不相等,则表示大家之间都没有连接
for (i = 0; i < N; i++)
{
id[i] = i;
}
//循环读入整数对
while (scanf("%d-%d", &p, &q) == 2)
{
//如果对象p与q是连通的,则读取下一对数对
if (id[p] == id[q])
continue;
//如果id[p]与id[q]的值不相等,则说明p-q是新对,就是没有连接
//则将所有原本与id[p]元素值相等的所有元素连接到q,即建立连接
for (t = id[p], i = 0; i < N; i++)
{
if (id[i] == t)
id[i] = id[q];
}
//因为p-q是新对,所以输出这个对
printf("New pair: %d-%d\n", p, q);
}
return 0;
}
2)快速合并算法
通过两个 for 循环,去查找数对 p 和 q 的根节点,并合并该节点。
#include <stdio.h>
#define N (10)
int main()
{
int i, p, q, j;
int id[N];
//初始化对象集合中元素的初始值
for (i = 0; i < N; i++) id[i] = i;
//循环读入整数对
while (scanf("%d-%d", &p, &q) == 2)
{
//从位置P读取值,即读取p的根节点
for (i = p; i != id[i]; i = id[i]);
for (j = q; j != id[j]; j = id[j]);
if (i == j)
{
//i、j 位置对象的值相等则是已存在的连接
//即p和q的根节点相同
continue;
}
else
{
//不相等则说明是新连接
id[i] = j;
//因为p-q是新对,所以输出这个对
printf("New pair: %d-%d\n", p, q);
}
}
return 0;
}
3.最短路
最短路径问题是图论研究中的一个经典算法问题,旨在寻找图(由结点和路径组成的)中两结点之间的最短路径。
给定一个图,和一个源顶点 src,找到从 src 到其它所有所有顶点的最短路径,图中可能含有负权值的边。
1)floyd
又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法。
它适用于 APSP(多源最短路径),是一种动态规划算法,稠密图效果最佳,边权可正可负。
此算法简单有效,由于三重循环结构紧凑,对于稠密图,效率要高于执行 |V| 次 Dijkstra 算法,也要高于执行 |V| 次 SPFA 算法。
优点
容易理解,可以算出任意两个节点之间的最短距离,代码编写简单。
缺点
时间复杂度比较高,不适合计算大量数据。
参考代码如下(以 C++ 为例):
#include<iostream>
#include<vector>
using namespace std;
const int &INF=100000000;
void floyd(vector<vector<int> > &distmap,//可被更新的邻接矩阵,更新后不能确定原有边
vector<vector<int> > &path)//路径上到达该点的中转点
//福利:这个函数没有用除INF外的任何全局量,可以直接复制!
{
const int &NODE=distmap.size();//用邻接矩阵的大小传递顶点个数,减少参数传递
path.assign(NODE,vector<int>(NODE,-1));//初始化路径数组
for(int k=1; k!=NODE; ++k)//对于每一个中转点
for(int i=0; i!=NODE; ++i)//枚举源点
for(int j=0; j!=NODE; ++j)//枚举终点
if(distmap[i][j]>distmap[i][k]+distmap[k][j])//不满足三角不等式
{
distmap[i][j]=distmap[i][k]+distmap[k][j];//更新
path[i][j]=k;//记录路径
}
}
void print(const int &beg,const int &end,
const vector<vector<int> > &path)//传引用,避免拷贝,不占用内存空间
//也可以用栈结构先进后出的特性来代替函数递归
{
if(path[beg][end]>=0)
{
print(beg,path[beg][end],path);
print(path[beg][end],end,path);
}
else cout<<"->"<<end;
}
int main()
{
int n_num,e_num,beg,end;//含义见下
cout<<"(不处理负权回路)输入点数、边数:";
cin>>n_num>>e_num;
vector<vector<int> > path,
distmap(n_num,vector<int>(n_num,INF));//默认初始化邻接矩阵
for(int i=0,p,q; i!=e_num; ++i)
{
cout<<"输入第"<<i+1<<"条边的起点、终点、长度(100000000代表无穷大,不联通):";
cin>>p>>q;
cin>>distmap[p][q];
}
floyd(distmap,path);
cout<<"计算完毕,可以开始查询,请输入出发点和终点:";
cin>>beg>>end;
cout<<"最短距离为"<<distmap[beg][end]<<",打印路径:"<<beg;
print(beg,end,path);
}
练习题指路→https://www.lanqiao.cn/problems/1121/learning/
2)SPFA
Bellman-Ford 算法的队列优化算法的别称,通常用于求含负权边的单源最短路径,以及判负权环。SPFA 最坏情况下复杂度和朴素 Bellman-Ford 相同,为 O(VE)。
参考代码如下(以 C++ 为例):
#include<iostream>
#include<vector>
#include<list>
using namespace std;
struct Edge
{
int to,len;
};
bool spfa(const int &beg,//出发点
const vector<list<Edge> > &adjlist,//邻接表,通过传引用避免拷贝
vector<int> &dist,//出发点到各点的最短路径长度
vector<int> &path)//路径上到达该点的前一个点
//没有负权回路返回0
//福利:这个函数没有调用任何全局变量,可以直接复制!
{
const int INF=0x7FFFFFFF,NODE=adjlist.size();//用邻接表的大小传递顶点个数,减少参数传递
dist.assign(NODE,INF);//初始化距离为无穷大
path.assign(NODE,-1);//初始化路径为未知
list<int> que(1,beg);//处理队列
vector<int> cnt(NODE,0);//记录各点入队次数,用于判断负权回路
vector<bool> flag(NODE,0);//标志数组,判断是否在队列中
dist[beg]=0;//出发点到自身路径长度为0
cnt[beg]=flag[beg]=1;//入队并开始计数
while(!que.empty())
{
const int now=que.front();
que.pop_front();
flag[now]=0;//将当前处理的点出队
for(list<Edge>::const_iterator//用常量迭代器遍历邻接表
i=adjlist[now].begin(); i!=adjlist[now].end(); ++i)
if(dist[i->to]>dist[now]+i->len)//不满足三角不等式
{
dist[i->to]=dist[now]+i->len;//更新
path[i->to]=now;//记录路径
if(!flag[i->to])//若未在处理队列中
{
if(NODE==++cnt[i->to])return 1;//计数后出现负权回路
if(!que.empty()&&dist[i->to]<dist[que.front()])//队列非空且优于队首(SLF)
que.push_front(i->to);//放在队首
else que.push_back(i->to);//否则放在队尾
flag[i->to]=1;//入队
}
}
}
return 0;
}
int main()
{
int n_num,e_num,beg;//含义见下
cout<<"输入点数、边数、出发点:";
cin>>n_num>>e_num>>beg;
vector<list<Edge> > adjlist(n_num,list<Edge>());//默认初始化邻接表
for(int i=0,p; i!=e_num; ++i)
{
Edge tmp;
cout<<"输入第"<<i+1<<"条边的起点、终点、长度:";
cin>>p>>tmp.to>>tmp.len;
adjlist[p].push_back(tmp);
}
vector<int> dist,path;//用于接收最短路径长度及路径各点
if(spfa(beg,adjlist,dist,path))cout<<"图中存在负权回路\n";
else for(int i=0; i!=n_num; ++i)
{
cout<<beg<<"到"<<i<<"的最短距离为"<<dist[i]<<",反向打印路径:";
for(int w=i; path[w]>=0; w=path[w])cout<<w<<"<-";
cout<<beg<<'\n';
}
}
练习题指路→https://www.lanqiao.cn/problems/1366/learning/
3)Dijkstra
使用了广度优先搜索解决赋权有向图或者无向图的单源最短路径问题,算法最终得到一个最短路径树。该算法常用于路由算法或者作为其他图算法的一个子模块。
(PS:Dijkstra 算法不能处理包含负边的图!)
具体代码如下:
import heapq
def dijkstra(graph, start, end):
heap = [(0, start)] # cost from start node,end node
visited = []
while heap:
(cost, u) = heapq.heappop(heap)
if u in visited:
continue
visited.append(u)
if u == end:
return cost
for v, c in G[u]:
if v in visited:
continue
next = cost + c
heapq.heappush(heap, (next, v))
return (-1, -1)
G = {'0': [['1', 2], ['2', 5]],
'1': [['0', 2], ['3', 3], ['4', 1]],
'2': [['0', 5], ['5', 3]],
'3': [['1', 3]],
'4': [['1', 1], ['5', 3]],
'5': [['2', 3], ['4', 3]]}
shortDistance = dijkstra(G, '4', '2')
print(shortDistance)
练习题指路→https://www.lanqiao.cn/problems/1122/learning/
4.最小生成树
一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。
最小生成树可以用 kruskal 算法或 prim 算法求出。
1)prim
由此算法搜索到的边子集所构成的树中,不但包括了连通图里的所有顶点,且其所有边的权值之和亦为最小。
具体代码如下(以 C++ 为例):
#define MAXN 1000
#define INF 1<<30
int closest[MAXN],lowcost[MAXN],m;//m为节点的个数
int G[MAXN][MAXN];//邻接矩阵
int prim()
{
for(int i=0;i<m;i++)
{
lowcost[i] = INF;
}
for(int i=0;i<m;i++)
{
closest[i] = 0;
}
closest[0] = -1;//加入第一个点,-1表示该点在集合U中,否则在集合V中
int num = 0,ans = 0,e = 0;//e为最新加入集合的点
while (num < m-1)//加入m-1条边
{
int micost = INF,miedge = -1;
for(int i=0;i<m;i++)
if(closest[i] != -1)
{
int temp = G[e][i];
if(temp < lowcost[i])
{
lowcost[i] = temp;
closest[i] = e;
}
if(lowcost[i] < micost)
micost = lowcost[miedge=i];
}
ans += micost;
closest[e = miedge] = -1;
num++;
}
return ans;
}
练习题指路→https://www.lanqiao.cn/problems/1124/learning/
2)kruskal
求连通网的最小生成树的另一种方法。
与普里姆算法不同,它的时间复杂度为O(eloge)(e为网中的边数),所以,适合于求边稀疏的网的最小生成树。
5.拓扑排序
对一个有向无环图 G 进行拓扑排序,是将 G 中所有顶点排成一个线性序列,使得图中任意一对顶点 u 和 v,若边 <u,v>∈E(G),则 u 在线性序列中出现在 v 之前。
通常,这样的线性序列称为满足拓扑次序的序列,简称拓扑序列。
简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
具体代码如下(以 C++ 为例):
queue<int>q;
//priority_queue<int,vector<int>,greater<int>>q;
//优先队列的话,会按照数值大小有顺序的输出
//此处为了理解,暂时就用简单队列
int topo()
{
for(inti=1;i<=n;i++)
{
if(indegree[i]==0)
{
q.push(i);
}
}
int temp;
while(!q.empty())
{
temp=q.front();//如果是优先队列,这里可以是top()
printf("%d->",temp);
q.pop();
for(inti=1;i<=n;i++)//遍历从temp出发的每一条边,入度--
{
if(map[temp][i])
{
indegree[i]--;
if(indegree[i]==0)q.push(i);
}
}
}
}
练习题指路→https://www.lanqiao.cn/problems/1337/learning/
本周的【算法学与练】就结束啦~如果你想持续算法,欢迎加入专属算法刷题群~回复【算法】即可免费获取题解哦~
▼算法刷题群等你来▼