图论
定义
一张图G是由顶点的集合V和边的集合E组成的。每条边连接了两个顶点v和w,其中v,w∈V。图分为有向图和无向图,无向图也可以理解成双向图,就是通过一条边连接的两点都可以到达对方,而有向图的话就规定了只能从一个顶点到达另一个顶点。另外,每条边还可以拥有自己的属性,称为权。
图中的一条路径就是从一个顶点依次经过若干不重复顶点到达目标顶点的序列,这条路径的长是经过的边的数量,也等于总的顶点的数量减一,规定没有边的路径长度为0,如果一个顶点通过一条路径能回到自身,且路径长度不为0,也称这条路径为环。
一条简单路径是这样的一条路径:其上的所有顶点都是互异的,但第一个顶点和最后一个顶点可能相同。在有向图中,如果第一个顶点和最后一个顶点相同,且路径长度大于0,也称其为圈,如果是简单路径,也称为简单圈。而对于无向图来说,要要求边是互异的,因为无向图中相邻两点可以互相到达,但是将这看作圈没有意义。
在无向图中,如果任意一个顶点都存在一条路径通向其他顶点,则称这个无向图是连通的。具有这个性质的有向图称为强连通的,如果有向图不是强连通的,但是其无向图版本具有这个性质的话,称这个有向图是弱连通的。
完全图是图中每一对顶点间都存在一条边的图。
图在实际生活中具有诸多应用,比如应用在飞机航线控制、交通管控等。
图的表示
图可以有两种表示方式——邻接矩阵和邻接表。邻接矩阵就是使用二维数组来表示顶点间的连接情况,如果没有权的话,可以认为M[i][j]=1
表示可以从i顶点到达j节点,即存在从i顶点到j顶点的一条边。邻接矩阵适用于稠密的图关系,因为无论两个顶点间是否存在边,都需要在矩阵中表示出来,这无疑会在稀疏的图关系中造成大的浪费。而邻接表就比较适合稀疏的图关系,邻接表可以由两层数组组成(也可以用其他结构),第一层数组的下标表示了各顶点的索引,第一层数组中的每个元素又保存了当前顶点可以到达的顶点。不能到达的顶点无需保存,可以节约一定的空间。
邻接矩阵 | a | b | c | d | e | f | g |
---|---|---|---|---|---|---|---|
a | 1 | 1 | 1 | 1 | 0 | 0 | 0 |
b | 0 | 1 | 0 | 1 | 1 | 0 | 0 |
c | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
d | 0 | 0 | 1 | 1 | 0 | 1 | 1 |
e | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
f | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
g | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
邻接表 | |
---|---|
a | b,d,c |
b | d,e |
c | f |
d | f,g,c |
e | d,g |
f | |
g | f |
表述相同的图,可以看出来,如果是稀疏的图,邻接表只需要较少的空间即可。
对于图中的各顶点,还需要两个属性来描述,一个是入度,定义为由其他节点通过一条边可以直接到达该点的边的数量;一个是出度,定义为由该节点通过一条边可以到达其他节点的边的数量。
对于有向无圈图,其顶点存在一种排序方式称为拓扑排序,如果存在一条由i
到j
的路径,那么就认为i
排在j
的前面。这种排序对于多文件编译这种场景具有极大的用处,通过这种排序,就可以从海量复杂的文件中决定各个文件的编译顺序。
做法就是先找到一个入度为0的顶点,然后根据其出度将它和它的边一起删除,之后重复这一过程。这也是#include
不可以循环引用的原因,因为循环引用后,拓扑关系将会形成一个圈,拓扑排序就失效了。
最短路径算法
单源最短路径问题
给定一个赋权图G = (V,E)和一个特定顶点s作为输入,找出从s到G中每一个其他顶点的最短赋权路径。例如两台计算机间通信,如果流量收费的话,就需要计算最省钱的方法。
无权最短路径
先思考一个简化后的问题,如果图G是无权的,或者权都是1,我们如何求得最短路径。
还是上面的图,我们把v3看作开始位置,那v3的路径长度就是0,然后搜索所有路径长度为1的顶点,即从v3出发可以立刻得到v1和v6。同样再搜索路径长度为2的顶点,只要搜索从v1和v6出发可以到达的顶点即可,后面如法炮制再搜索路径长度为3的顶点直到遍历完全部顶点,这种一层一层遍历的方法称为广度优先搜索。
完成这一过程,我们需要如下的一张表来记录每一次搜索的结果。
顶点 | 能否到达 | 距离 | 上个顶点 |
---|---|---|---|
v1 | F | ∞ | 0 |
v2 | F | ∞ | 0 |
v3 | F | 0 | 0 |
v4 | F | ∞ | 0 |
v5 | F | ∞ | 0 |
v6 | F | ∞ | 0 |
v7 | F | ∞ | 0 |
对于每个顶点记录3个信息:
- 顶点是否能够到达。
- 从s到达该点的路径长度。
- 途径的顶点,通过回溯上个顶点可以得到整条完整的路径
一开始只有s点是已知的,即可以到达的,每次我们遍历到顶点后,就更新表中对应的信息。
constexpr int UNREACHABLE = INT_MIN;
struct Vertex {
int some_else{ 42 };
bool known{ false };
int dist{ UNREACHABLE };
int node{ 0 };
bool operator==(const Vertex& other) {
return this->some_else == other.some_else;
}
};
vector<Vertex> table{};
vector<vector<int>> graph{};
void shortestPath(Vertex s) {
int position;
for(size_t i = 0; i < table.size(); i++) {
table[i].known = false;
table[i].dist = UNREACHABLE;
if(table[i] == s) {
position = i;
table[i].known = true;
table[i].dist = 0;
}
}
queue<int> q;
q.push(position);
while(!q.empty()) {
position = q.front();
q.pop();
for(auto pos : graph[position]) {
if(table[pos].dist == UNREACHABLE) {
table[pos].known = true;
table[pos].dist = table[position].dist + 1;
table[pos].node = position;
q.push(pos);
}
}
}
}
不使用队列也可以通过遍历数组来得到对应的顶点,不过队列可以加速这一过程,所以我们使用队列来做。table
是一张记录顶点状态的表,graph
是使用邻接表来表示的一张图,第一维的下标表示顶点的索引,第二维表示当前顶点可以到达的顶点的索引,这里是假设图已经生成了。
我们首先将要查询的顶点初始化并入队,然后按照广度优先搜索来依次更新查找到的顶点,并将其也入队,当队列再次为空时说明遍历结束,退出循环。
Dijkstra算法
如果加上权这一条件,问题就明显变得复杂了,我们仍然可以先按照无权最短路径的思路来做,不过和无权图在设置了距离和上个顶点就不再改变不同,还需要不断地更新每个顶点的最短距离和最短路径的顶点。
这种解决单源最短路径问题的一般方法叫做Dijkstra算法,是一个很好的贪婪算法应用。
constexpr int UNREACHABLE = INT_MIN;
struct Vertex {
int some_else{ 42 };
bool operator==(const Vertex& other) {
return this->some_else == other.some_else;
}
};
struct Edge {
int weight{ 0 };
int out{ 0 };
};
vector<Vertex> nodes{}; // 所有的顶点
vector<bool> knowns{}; // 对应的状态
vector<int> distances{}; // 对应的距离
vector<int> prevs{}; // 对应的前驱节点
vector<vector<Edge>> graph{}; // 邻接表
void dijkstra(Vertex s) {
queue<int> q;
// 先初始化
for(size_t i = 0; i < nodes.size(); i++) {
knowns[i] = false;
distances[i] = UNREACHABLE;
prevs[i] = -1;
if(nodes[i] == s) {
knowns[i] = true;
distances[i] = 0;
q.push(i);
}
}
set<int> visited;
while(!q.empty()) {
if(visited.find(q.front()) != visited.end()) {
q.pop();
continue;
}
// 遍历连接的所有边
for(auto& edge : graph[q.front()]) {
// 如果还未被访问到,修改为已知
if(distances[edge.out] == UNREACHABLE) {
knowns[edge.out] = true;
distances[edge.out] = distances[q.front()] + edge.weight;
prevs[edge.out] = q.front();
}
// 如果连接的顶点是已知的,但是比现在的路径长,也需要更新
else if(distances[edge.out] > distances[q.front()] + edge.weight) {
distances[edge.out] = distances[q.front()] + edge.weight;
prevs[edge.out] = q.front();
}
}
// 然后寻找当前距离最短的作为下个搜索的顶点
int next = graph[q.front()][0].out;
int min_dist = distances[next];
for(size_t i = 1; i < graph[q.front()].size(); i++) {
if(min_dist > distances[graph[q.front()][i].out]) {
next = graph[q.front()][i].out;
min_dist = distances[next];
}
}
q.push(next);
visited.insert(q.front());
q.pop();
}
}
这次我们使用了几个数据来记录表的状态,这样看起来更清晰。nodes
记录了各个顶点,distances
记录了到达各顶点的最短距离,knowns
记录了顶点是否被找到,prevs
记录各个顶点的前驱节点,另外在Edge
结构中保存了各顶点相连的其他顶点以及对应的权值。首先和无权图类似,在拿到一个顶点后更新和其相连的顶点,不过在有权的情况下,还存在连接的顶点虽然已经被设置过了,但是当前的路径比其记录的路径更短的情况,这些顶点也需要更新。
在更新后的操作就是dijkstra如何使用贪心算法的了,不是将所有顶点都放入到要查询的队列中,而是进行一轮比较后,选择将当前最短路径的顶点放入队列中,这样保证了每一步的选择都是局部最优的情况,事实上经过这样的遍历后,最后所有的顶点都会是最短的路径。另外,还需要一个集合保存已经是最短路径的顶点,防止在有圈时陷入无效的循环。
你也许已经意识到了另一个问题,如果权是负数的话,岂不是只要不停的走这条边其距离就越来越小?没错,如果存在权是负数的情况下,dijkstra算法是行不通的。这种情况下只能根据实际情况来添加结束的条件了,比如如果顶点被标记后就不允许再减小,最终目标是让函数能够终止。
网络流问题
网络流是这样一个问题:在有向图G = (V,E)中,每条边有最大的容量,比如水管的流量或马路的车流量,进入的节点s称为发点,出去的节点t称为收点,对于任意一条边(v,w),最多有c(v,w)个单位的流量可以通过,在s到t过程中的中间节点其进入的流量等于出去的流量,那么就需要确定从s到t可以通过的最大流量是多少。
例如这张图中,s有两条容量分别为4和2的流出边,而t有两条容量都是3的流入边,所以我们可以猜测最大的流量是6,但事实上这个图的最大流量是5。最简单的方法是把c和t看作整体,那么流入这个整体的流量最大也就是ac边和dt边的流量之和,也就是5。这种将发点和收点分割成两部分的方式,会使得两部分的流量取决于切口处边的流量之和,任何的图都会有许多的切口,而具有最小总容量的切口提供了整张图的最大流的上限。
一个简单的最大流算法
根据需要,我们构建两张图,一张存放了在任意阶段图中各边当前使用的流,另一张存放图中各边剩余的流,存放剩余流的图我们称为残余图,而第一张图将在结束时包含了图的最大流。
在每个阶段,我们都要寻找一条从s通到t的路径,称为增长通路,并在第一张图中标记使用到的路径和流量,而在残余图中减去这些路径和流量,直到再也找不到可以从s通到t的路径时算法结束。
第一印象我们会按照贪心算法的思路进行选择,但在最大流问题中可能无法得到最优解,例如我们第一次就选择sa这条从s发出的最大流量的边,可是这样我们发现无法获得最大流量5。为了使算法继续有效,我们不得不添加一个允许反悔的机会,于是我们每次按照贪心的思路分配路径和流量时,都假设建立一条反向的路径,其流量和我们分配出去的一样大,这样就相当于在之后的选择中可以选择反向的路径来修正之前的选择。
我们首先选择s->a->d->t这条增长通路,残余图变为了左图这样,每条边在减去流量的同时也增加一条相同流量的反向边,然后我们再选择s->b->d->a->c->t这条增长通路,从而再次得到了右边这样的残余图,此时我们再也找不出一条连通s和t的路径了,算法结束,切割残余图,将s出发能到达的顶点归为一组,不能到达的归为一组,于是得到了最大流。增长通路的选择有其他方式,但最终都会得到一个类似s和t不相通的残余图。
struct Node {
string name{};
int long long id{ -1 };
bool operator==(const Node& other) const;
};
struct Edge {
int out{ -1 };
int weight{ 1 };
};
class Graph {
public:
Graph() = default;
explicit Graph(const vector<Node>& elems);
virtual ~Graph() = default;
void addNode(const Node& node);
void addNodes(const vector<Node>& nodes);
bool buildEdge(const Node& in, const Node& out, int weight = 1);
bool buildEdges(const Node& in, const vector<Node>& outs, const vector<int>& weights = {});
vector<Node> elements() const;
vector<vector<Edge>> table() const;
vector<vector<int>> metrix() const;
void show(ostream& os = cout) const;
int find(const Node& node);
private:
bool buildEdge(int in_pos, int out_pos, int weight = 0);
bool buildEdges(int in_pos, vector<int> out_poses, const vector<int>& weights = {});
private:
vector<Node> m_elems{};
vector<vector<Edge>> m_table{};
};
int maxStream(const Graph& g, Node& s, Node& t) {
auto nodes = g.elements();
auto table = g.table();
int s_pos;
int t_pos;
vector<int> dist(nodes.size(), INT_MIN);
vector<int> prev(nodes.size(), -1);
for(int i = 0; i < nodes.size(); i++) {
if(nodes[i] == s) {
dist[i] = 0;
s_pos = i;
}
else if(nodes[i] == t) {
t_pos = i;
}
}
set<int> visited;
int max_stream{ 0 };
while(max_stream == 0 || visited.find(t_pos) != visited.end()) {
visited.clear();
dist = vector<int>(nodes.size(), INT_MIN);
prev = vector<int>(nodes.size(), -1);
dist[s_pos] = INT_MAX;
queue<int> q;
q.push(s_pos);
// dijkstra
while(!q.empty()) {
if(visited.find(q.front()) != visited.end()) {
q.pop();
continue;
}
int cur_stream = 0;
for(auto& edge : table[q.front()]) {
if(dist[edge.out] == INT_MIN) {
prev[edge.out] = q.front();
dist[edge.out] = std::min(edge.weight, dist[q.front()]);
}
else if(dist[edge.out] < std::min(edge.weight, dist[q.front()])) {
dist[edge.out] = std::min(edge.weight, dist[q.front()]);
prev[edge.out] = q.front();
}
}
int next = table[q.front()][0].out;
int min_dist = dist[next];
for(size_t i = 1; i < table[q.front()].size(); i++) {
if(min_dist > dist[table[q.front()][i].out]) {
next = table[q.front()][i].out;
min_dist = dist[next];
}
}
q.push(next);
visited.insert(q.front());
q.pop();
}
// 更新图
if(visited.find(t_pos) != visited.end()) {
int update = t_pos;
int cur_stream = dist[t_pos];
while(prev[update] != -1) {
int i = 0;
for(; i < table[update].size(); i++) {
if(table[update][i].out == prev[update]) {
table[update][i].weight += cur_stream;
break;
}
}
if(i == table[update].size()) {
table[update].push_back({ prev[update], cur_stream });
}
update = prev[update];
}
max_stream += cur_stream;
}
}
return max_stream;
}
上述是一种简单的实现方式,其中顶点和边被分别定义为了Node
类型和Edge
类型。寻找增长通路的过程类似于一个有权最短路径问题,可以参考dijkstra算法的思路来解决,在寻找到增长通路后,按照前面的分析,更新残余图,即按照增长通路扣除相关的流量,再添加上对应的反向路径。然后重复找寻下一条增长通路,直到发点和收点再以无法连通为止,此时计算得到的就是从发点到收点的最大流量。
选择增长通路的方法有很多,可以完全按照dijkstra算法去一点一点地求最小赋权路径,也可以按照贪心的思路每次都选择当前能取的最大流量,还可以按照无权最短路径的思路总是选取最少边的路径,不同方法之间是有着优劣之分,但是分析过程太复杂了,就留给以后再说吧。
最小生成树
最小生成树是这样的一类问题:在一张无向图中,如何去掉某些边后,所有顶点仍然是相通的,且剩余图中边的总权值最小,或者说,如何用最小的代价将所有的顶点都联系起来。例如在装修房子时,如何用最少的电线使家里的所有电器都接通。
在最小生成树中,顶点的数量为V,则边的数量为V-1。任意的生成树T,如果将一条不属于T的边加入进来,那么就会产生一个圈,而将这个圈中的任意一条边去掉,又恢复了生成树,如果e的权值比去掉的边的权值低,那么新的生成树的总权值就会比旧的低,于是最小生成树可以这样描述,若生成树无法在加入新边后形成的圈中去掉任意边获得比旧的生成树更低的总权值,就是最小生成树,因为任意一条代替边都是至少和当前生成树中相关的边具有相同的权。
贪心算法仍然是适用的,Prim算法和Kruskal算法都是一种贪心的思路,区别在于最小边的选取思路。
Prim算法
最小生成树可以是一步一步长成的,在每一步都是将一个节点作为根并加上一条边,这样就把相关联的顶点加入到了生成树中。于是,在算法的任一时刻,都可以看作是有两个集合,一个是生成树中顶点的集合,另一个是还未添加到生成树的顶点构成的集合,那么我们在每个阶段都是选择边(u,v)加入到生成树中,其中u是已经在树中的顶点,v是不在树中但边的权值最小的顶点。
void prim(const Graph& g, Node& s) {
auto nodes = g.elements();
auto table = g.table();
vector<int> dist(nodes.size(), INT_MAX);
vector<int> prev(nodes.size(), -1);
vector<bool> knows(nodes.size(), false);
int cur;
for(int i = 0; i < nodes.size(); i++) {
if(nodes[i] == s) {
dist[i] = 0;
cur = i;
break;
}
}
while(knows[cur] == false) {
// 更新连接的顶点
for(auto& edge : table[cur]) {
if(dist[edge.out] > edge.weight) {
dist[edge.out] = edge.weight;
prev[edge.out] = cur;
}
}
knows[cur] = true;
// 寻找下一个要遍历的顶点
cur = table[cur][0].out;
for(int i = 0; i < nodes.size(); i++) {
if(knows[i] == false && cur == -1 || dist[i] < dist[cur]) {
cur = i;
}
}
}
}
Prim算法的思路基本和dijkstra算法的一样,只有一点不同,dist
表示连接前驱节点和当前节点的最短边,因此更新方法也有所不同。更新方法甚至更简单:在每一个顶点v被选取后,对于每个和v连通的未知顶点w,distw = min(distw, cv,w),cv,w表示顶点v和顶点w连接的边的权。
Kruskal算法
Kruskal的策略是连续地按照最小权的顺序选择边,并且如果选择的边不产生圈就把它加入生成树。形式上,Kruskal是在处理一个森林(树的集合),当算法结束时,森林合并成了一棵树。处理森林这种数据结构,自然就要想到了使用并查集。
vector<pair<int, Edge>> kruskal(const Graph& g, Node& s) {
vector<pair<int, Edge>> res{};
auto table = g.table();
priority_queue<pair<int, Edge>> pq;
for(int i = 0; i < table.size(); i++) {
for(auto& edge : table[i]) {
pq.push(std::make_pair(i, edge));
}
}
UnionSets union_set(table.size());
while(pq.empty()) {
if(union_set.find(pq.top().first) != union_set.find(pq.top().second.out)) {
union_set.union(pq.top().first, pq.top().second.out);
res.push_back(pq.top());
}
pq.pop();
}
return res;
}
大致思路如代码演示的这样,使用优先队列将所有边都存下来,然后依次取出,使用并查集检查边的两个顶点是否连通,如果不连通就保存下来作为生成树的一条边,遍历完成后就完成了最小生成树的生成。