图
术语
俩个要素
顶点集和边集。分别使用V和E来表示
邻接关系: 指的是俩个顶点之间的关系。
关联关系: 指的是顶点和边之间的关系。
极大顶点: 图如果再加一个顶点,图就不连通了。
有向图和无向图
主要研究有向图,有向图可以转化为无向图
路径
简单路径:路径中不含重复节点。
普通路径:路径中可能含有重复节点。
环路:路径的起始点和终点相同。
简单环路:除了起始点外不包含任何重复的节点。
普通环路:同理
欧拉环路:经过每个边一次。
哈密尔顿环路:经过每个顶点一次。
实现
表示图的重要手段——邻接矩阵和关联矩阵
主要专注邻接矩阵
邻接矩阵图
邻接矩阵图采用二维矩阵来表示顶点与顶点之间的边。
matrix[i][j] = 1
代表俩个点是邻接关系。
matrix[i][j] = w
w可以表示权重
小结
对于无向图来说,邻接矩阵是对称阵。中间的对角线的点构成自环,一般不做讨论,所以认为其为0。
因此,无向图的邻接矩阵会存储俩份信息,存在冗余。
邻接矩阵的表示法缺点:
消耗更多空间,为 $\Theta(n^{2}) $
邻接矩阵表
邻接矩阵表,采用链表的结构,因此在边不存在的地方不需要存储。
如何改进? 使用邻接表。实现起来有点复杂,先抛在一边。
算法
化繁为简: 遍历, 在二叉树种,遍历可以将半线性结构转化为线性结构。在图中可以将非线性结构转化为
半线性结构。
在图中的遍历,更像是在搜索某个特点的节点。所以将遍历算法称为搜索算法。
顶点和边
在开始讲具体的算法之前,先来看顶点和边的状态设置。
// 定义顶点状态
typedef enum { UNDISCOVERED, DISCOVERED, VISITED } VStatus;
UNDISCOVERED 为顶点的初始状态
// 定义边在遍历树中的所属的类型
typedef enum { UNDETERMINED, TREE, CROSS, FORWARD, BACKWARD} EStatus;
UNDETERMINED 为边的初始状态
其他状态在具体的算法中谈
广度优先搜索 Breadth-First Search
算法过程文字描述如下:
- 访问节点S
- 依次访问S尚未访问的邻接节点。即枚举S的邻居,然后依次访问它们。
- 继续去访问邻接节点的所有尚未访问邻接节点。
- 依次列举
广度优先搜索 — 联系 — 树的层次遍历。树的层次遍历就是就是广度优先搜索的特例。
因此我借助一个队列就可以完成数的层次遍历。
广度优先搜索伪代码如下
void bfs(int v, int &clock, VST visit) // clock为计时器
{
queue<int > Q; // 建立一个辅助队列
statusOfVertex(v) = DISCOVERED;
dTime(v) = ++clock;
Q.push(v);
while(!Q.empty())
{
int x = Q.front();
Q.pop();
// visit 这里就代表访问图中的某个节点
visit(dataOfVertex(x));
// 在二叉树中,这里是二叉分支,图明显是多叉树
for(int x = firstNbrVertex(v); x > -1; x = nextNbrVertex(v, x))
{
if(statusOfVertex(x) == UNDISCOVERED)
{
statusOfVertex(x) = DISCOVERED;
dTime(x) = ++clock;
statusOfEdge(v, x) = TREE;
}
else{ // bfs中不需要处理 VISITED 和 DISCOVER 的区分
statusOfEdge(v, x) = CROSS;
}
}
statusOfVertex(v) = VISITED; // 处理完v节点的所有邻居节点认为访问完毕
fTime(v) = ++clock;
}
}
认为访问的时机其实算是在学习图算法中一个比较重要的概念,我在什么时候修改顶点的状态,什么时候
记录访问时间。我个人认为是从遇到顶点为undiscovered状态开始。 或者也可以认识真正的访问visit调用
之前也可以。根据代码来看,前者逻辑性更强些,原理二者都是相同的
以上代码只能应对图中的连通域是单连通域的情况下,如果连通域为多个,则无法搜索完毕。
修改很简单,就是多次启动即可。
void BFS(int s)
{
reset(); // 将所有节点和边的状态重置为最初的状态
int v = s;
int clock = 0, i = 0;
do{
if(statusOfVertex(v) == UNDISCOVERED)
{
printf("连通域: %d\n", ++i);
bfs(v, clock);
}
}while( s != (v = ++v % n));
}
有向图相较于无向图复杂的多。有向图的一个单连通域并不能保证所有节点遍历完毕。我之前考虑简单了
深度优先搜索 Deepth-First Search
更复杂,更加强大
算法过程文字描述如下:
- 访问节点S
- 若S尚有未被访问的邻居,则任取一u,递归执行DFS(u)
- 否则,返回
深度优先搜索伪代码如下:
void dfs(int v, int clock, VST visit)
{
queue<int> Q;
statusOfVertex(v) = DISCOVERED;
dTime(v) = ++clock;
Q.push(v);
while(!Q.empty())
{
v = Q.front();
Q.pop();
visit(dataOfVertex(x));
for(int x = firstNbrVertex(v); x > -1; x = nextNbrVertex(v, x))
{
switch(statusOfVertex(x))
{
case UNDISCOVERED:
statusOfEdge(v, x) = TREE;
parent(x) = v;
break;
case DISCOVERED:
statusOfEdge(v, x) = BACKWARD; // 语义很好了解,在同一连通域内,后发现的节点
// 一定是先发现的节点的孩子,这里有个猜测,会不会出现某个在搜索树中深度为3的节点
// 指向深度为4的节点,破:这种情况既然还是discovered的状态下,那么深度为3的节点
// 在必然是在深度为4的节点的嵌套域内,因此深度为3的节点的深度= 深度为4的节点+1 矛盾
break;
case VISITED:
statusOfEdge(v, x) = (dTime(v) < dTime(x)) ? FORWARD : CROSS;
// 根据嵌套域的概念很容易理解,只有节点x的dTime 和fTime 在v的dftime时间内,
// 二者才构成直系血缘关系, 否则或者为没有关系,或者为姑表亲关心
break;
}
}
}
}
比较重要的概念:
backward: 后向边,如果一条边是后向边,则代表这条边从后代指向祖先。只要一个图中有后向边,就会
一定存在回路。
forward: 前向边,如果一条边是前向边,则代表这条边从祖先指向后代。
cross: 交叉边,边的俩侧节点不存在任何直系血缘关系
括号引理或者嵌套引理
嵌套引理: 用来dTime和fTime来判断俩个节点之间的血缘关系
结论总结
深度优先遍历的第一个应用,可以用来判断有向图是否有环。
第二个应用,使用维护的dTime
和fTime
可以用来判断图中任意俩个顶点之间的血缘关系。
应用
拓扑排序
将图做成一个线性序列,这个线性序列的每一个顶点都不会通过边,指向其在此序列中的前驱节点。这样的
一个序列叫做拓扑排序。
根据概念,拓扑排序必定先保证是有向图。不然没有任何意义。
结论:任一有向无环图之间,必定存在拓扑排序。反之亦成立。
有向无环图中,也必定存在入度为0的点。
有向图极大顶点入度必然为0。
如何判断有环?查看是否存在backward。
DAG:有向无环图。
基于dfs的拓扑排序
直接写规律,就dfs而言,其visted发生时间的顺序恰恰刚好是图的拓扑排序的逆序, 因此我们用一个栈
结合dfs就可以完成这个排序
// 基于dfs实现的拓扑排序
template <typename Tv, typename Te>
stack<Tv> *
Graph<Tv, Te>::topoSort2(int v)
{
int clock = 0;
int x = v;
stack<Tv> * S = new stack<Tv>;
do{
if(status(x) == UNDISCOVERED)
if(!TSort(x, clock, S))//要求整个图无环
{
while(!S->empty())
S->pop();
break;
}
}while( v != (x = ++x % n));
return S;
}
// 单趟扫描
// 其实像dtime,等判断边的状态都可以忽略,我这里写是为了再次复习下dfs排序的过程
template <typename Tv, typename Te>
bool
Graph<Tv, Te>::TSort(int v, int &clock, stack<Tv> * S)
{
status(v) = DISCOVERED;
dTime(v) = ++clock;
for(int x = firstNbr(v); x > -1; x = nextNbr(v, x))
{
switch(status(x))
{
case UNDISCOVERED:
type(v, x) = TREE;
parent(x) = v;
if(!TSort(x, clock))
return false;
break;
case DISCOVERED:
type(v, x) = BACKWARD;
return false;
case VISITED:
type(v, x) = (dTime(v) < dTime(x)) ? FORWARD : CROSS;
break;
}
}
status(v) = VISITED; fTime(v) = ++clock; S->push(vertex(v));
return true;
}
基于bfs的拓扑排序
基于bfs拓扑排序算法描述如下:
- 从图中顶点的集和中取走一个入度为0的节点。
- 更新图中所有顶点的入度。得到新的集和。重复1。
- 优点:因为使基于bfs的算法,所需要的空间更少。相较于基于dfs的拓扑排序而言。
代码如下
// 基于bfs实现的拓扑排序
template <typename Tv, typename Te>
bool
Graph<Tv, Te>::topoSort1(int v)
{
// 首先一定要使用bfs算法或者dfs算法将图变成树, 因为这里只要形成树的结构,其实dfs,bfs都可以
// 但是bfs所占空间更少
int clock = 0;
size_t oldn = n;
BFS(v, clock);
auto m = findInDegree(0); // 需要入度为0的所有顶点
queue<int> q; // 在单连通域的情况下可以使用queue, 但是在多连通域的情况下需要使用stack
queue<int> result;
for(auto a : m)
{
q.push(a);
}
while(!q.empty())
{
int x = q.front();
q.pop();
result.push(x);
for(int u = 0; u < n; ++u)
{
if(statusOfEdge(x, u) == TREE && inDegree(u) == 1)
{
q.push(u);
}
}
// 删除节点x
remove(x);
}
return result.size() == oldn;
}
新的框架——优先级搜索
就以上图的搜索而言,发现它们大致成这样一种框架。都需要通过迭代的方式逐一发现各定点,将其纳入
遍历树种中,并做响应处理。主要区别的差异就是每一步迭代的时候新的顶点的选取策略不同。对于bfs 来说,会优先考查更早被发现的顶点。而对于dfs来说,会优先考查最后被发现的顶点。
对于这句话不是
很理解。大致意思就是for循环那里新的迭代点的选取。
这样一种选取策略等效于给与了各个顶点优先级,每一次迭代就是选取优先级最高的点。
基于这样一种框架,我们可以实现任意我们想要的算法,只要采取合适的优先级更新策略。比如
bfs算法,其实就是基于最短路径的算法,在所有的边的权重为1的情况下,bfs每次寻找的迭代点都是
距离起始点路径最短的点。
应用–最小支撑树
支撑树:就是连通图G中的一个无环子图,并覆盖G的所有节点。
显然一个图的支撑树有多颗。一颗树的成本即各个边的权重之和。成本最低的一颗支撑树我们就称其为
最小支撑树。
概念如下:
- 补集的表示:U的补集V表示为V\U
- 割: U和V\U构成G的一个割cut, 通俗理解就是U和V俩个集和中间隔开的那部分
- 跨越边:u属于U,v属于V,所构成的一天边叫做跨越边
Prim算法文字描述如下,假设\(G_n\)代表无向图G有n个顶点,目前已经得到最小支撑树\(T_k = (V_k;E_k)\), 此时我们采取贪心的策略,在\(V_k\)的补集\(L_{n-k}\)之中找一点l和在\(T_k\)中找一点t所构成的\(Min(Weight(t, l))\)
,然后将t加入到\(T_k\),构成\(T_{k+1}\)。这个迭代就是我们的思想。
算法的大致证明基于这样一个事实:最小支撑树总是会采用联割的最短跨越边。
因此我们的迭代就是每次在割上找最短跨越边,然后构成新的集和T (P.s其补集的vertex会减少),然后逐步重复
伪代码如下所示
void prim(int s, vector<vertex> G, vector<vector<Edge>> E)
{
vector<vertex> T;
statusOfVertex(s) = VISITED;
T.push_back(s);
while(s.size() != G.size())
{
for(int i = 0; i < T.size(); ++i) // 每一趟扫描都会找到一条最短跨越边
{
int prioMax = INT_MAX;
int record;
for(int x = firstNbrVertex(T[i]); x > -1 ; x = nextNbrVertex(T[i], x))
{
if(statusOfVertex(x) == UNDISCOVERED)
if(edge[T[i]][x] < prioMax)
{
prioMax = edge[T[i]][x];
record = x;
}
}
statusOfVertex(record) = VISITED;
T.push(record);
}
}
}
当然配合pfs框架的跟新器是更好的实现。不过这种方式更加抽象一点。没有上面实现的方式直观。
更新器代码如下所示
template <typename Tv, typename Te>
struct PrimPU{
void operator()(Graph<Tv, Te> *g, int v, int x)
{
if(g->status(x) == UNDISCOVERED)
{
if(g->priority(x) > g->weight(v, x))
{
g->priority(x) = g->weight(v, x);
g->parent(x) = v;
}
}
}
};
因为初始情况下我们将优先级别调到最低(数值最大),所以第一次更新完毕后的情况是所有v的邻接点x
的优先级都被设置为权重,因为Prim算法的本质就是在找权值最小的边。因此符合我们的目的。接下来我们看
什么时候会再次发生更新。当新边的权重小于原本的优先级的时候,证明我们找到本集合中更小权重的边,
什么时候不会发生更新,当新边的权重大于原本的优先级,我们原本的优先级设置已经是更小的权重边了,因此
不需要更新。
最短路径树
给定带权网络\(G = (U, E)\), 以及源点\((source) s\inV\),对于所有其他顶点v,s到v的最短通路有多长?
该通路由哪些边构成?
书上写的是依靠最短路径子序列的倒置思想。用我的语言大致描述如下。所有相关的证明都pass。
我们首先从s点出发,找到所有s的邻接节点,并记录所有邻接点到s的距离,记在distance数组中,
选取最短路径一点k加入s的集和V中。然后在k的所有未被访问的节点中,继续将其到s的距离添如其中,如果
distance数组中已经有的,而当前值又比其小,就会发生替换。
伪代码如下所示:
void Dijkstra(int s, vector<vertex> V, vector<vector<Edge>> E)
{
// assert(s < V.size());
vector distance;
for(int i = 0; i < V.size(); i++)
{
distance[i] = INT_MAX;
}
vector<int> T;
distance(s) = 0;
statusOfVertex(s) = VISITED;
T.push(s);
while(T.size() != V.size())
{
for(int x = record = firstNbrVertex(s); x > -1; x = nextNbrVertex(v, x))
{
if(distance[s] + weight(s, x) < distance[x])
{
distance[x] = distance[s] + weight(s, x);
}
}
int record;
int prioMax = INT_MAX;
for(int i = 0; i < V.size(); ++v)
{
if(statusOfVertex(i) == UNDISCOVERED)
if(distance[i] < prioMax)
{
prioMax = distance[i];
record = i;
}
}
T.push(record);
s = record;
statusOfVertex(s) = VISITED;
}
}
上下俩个代码二者的核心理念就是比较当前已有的权重值和当前节点的父亲节点已有的权重值+俩点的weight
所以在最开始初始化的时候,认为所有点到源的路径为无穷大是一个非常好的初始化设置。更好的让代码进入
循环状态。
pfs架构的更新器如下所示
template <typename Tv, typename Te>
struct PrimPU{
void operator()(Graph<Tv, Te> *g, int v, int x)
{
if(g->status(x) == UNDISCOVERED)
{
if(g->priority(x) > g->weight(v, x) + g.priority(v))
{
g->priority(x) = g->weight(v, x) + g.priority(v);
g->parent(x) = v;
}
}
}
};
学习提示
就目前来说,图论的学习对我的帮助不是太大,更多的是帮助我扩展视野。当然亲手敲一遍这些代码带来的
感悟会非常深刻。