图的存储方式
邻接矩阵:
即使用二维数组来表示各个顶点之间的关系:若A[i][j] = 1,表示节点i 与节点j之间有边。
易知:
无向图的邻接矩阵是对称的,有向图的邻接矩阵可能是对称的。
有权图:
即:
A[i][j] = inf ,表示i与j之间无边;
A[i][j] = w, 表示i与j之间边权重为w;
A[i][j] = 0, 表示i=j;
邻接表:
无向图的邻接表:
即将从一个点发出的所有边表示在同一个边链表之中。
边链表中的每一个点代表每一条边,存储与之相连的节点与这条边的link
有向图的邻接表:
有向图中对于每一个顶点所发出的边,分为入边表 和出边表分别存储。
图的遍历:
图的遍历通常会设置辅助数组visiter[],用来保证每个节点均访问一次。
图的遍历主要分为dfs和bfs两种。
dfs:
基本流程:
从一个初始顶点出发,访问它的任一邻接节点W1,再从W1出发,访问W1的任一邻接节点,直至到达一个顶点M,满足M的所有邻接顶尖均被访问过。
然后再回退一步,访问前一个节点中没被遍历到的邻接节点。
算法上通常采用递归实现
bfs:
基本流程:
从起始顶点出发,依次访问起始顶点的所有邻接顶点。再从这些邻接顶点出发,各个依次再访问它们的邻接顶点。
即是一种逐层遍历,搜索的流程。
通常采用队列实现。
最小生成树:
- 仅仅针对于无向图;
- 使用且仅能使用n-1条边
- 包含了所有的顶点;
- 符合树的定义:连通,无环。
- 最小:使得边权相加的结果最小。
最短路径树与源起点紧密相关;而最小生成树是一个全局概念,与从何处开始无关。
切割属性:
对于图集(V, E),将V个点分给两个非空集合,构成两个点集S,(V - S)。
切割属性的定义:
跨越连接两个点集的最小权重边必然包含于最小生成树中。
证明:
设最小生成树T不包含最小权重边S = (u, v)。取而代之的是F边。
-
现在构造一颗新的生成树T':
-
从T中移除F边;
-
然后加入我们讨论的最小权重边S;
现在T'的总权重 = T的总权重 - weight(F) + weight(S);
又因为weight(S) < weight(F)。故有T'的总权重小于T的总权重。
但我们假设的是T未最小生成树,故与题意相违背。
接下来介绍几种查找MST的算法:
Prim算法:
核心思想:
在每一步,逐步连接当前已在MST中的顶点与尚未在MST中的顶点集合间的最小权重边,直至所有顶点都在MST中。
算法流程:
两个关键数组:
dist数组:维护每个顶点到当前MST集合的最小边权重;
++(注意此点与dijkstra算法的区别,dikstra算法是维护到起点单点的最短距离,prim算法维护的是到MST集合的距离。但代码其余部分基本与dijkstra算法相似)++
parent数组:记录每个新节点加入后是和哪个节点相连的;
typedef pair<int, int> PII; //(distance, node)
void prim(int start int V){
memset(dist, int, sizeof dist);
memset(parent, -1, sizeof parent);
bool inMST[N];
memset(inMST, false, sizeof inMST);
dist[start] = 0;
priority_queue<PII, vector<PII>, greater<PII>> pq;
pq.push({0, start});
while(pq.size()){
//选取当前对MST集合距离最近的点,贪心的体现。
int cur_node = pq.top().second;
pq.pop();
if(inMST[cur_node]) continue;
inMST[cur_node] = true;
for(auto& neighbor: graph[cur_node]){
int v = neighbor.first;
int weight = neight.second;
//不在MST中,且找到了更小权边。
if(!inMST[v] && weight < dist[v]){
dist[v] = weight;
parent[v] = u;
pq.push({dist[v], v});
}
}
}
}
时间复杂度分析:
- 初始化:O(V);
- push操作:O(VlogV)[优先队列是基于二叉堆实现的]
- pop操作:O (VlogV)
- 遍历相连边:O(E)
- push更新操作:O(ElogV)
故总复杂度为:O((V + E) logV)
Kruskal算法:
核心思想:
- 将图中的所有边按权重从小到大进行排序;
- 从权重最小的边开始,一次考虑每一条边;
- 若加入当前的环会与已选择的边之间形成环,则跳过;
- 当已选边数 = 顶点数 - 1时,停止循环。
数据结构实现基础(并查集):
并查集用来判断两个顶点是否连通,以及用来合并两个连通分量。
主循环流程:
-
取出最小权重边;
-
检查环(使用并查集的find函数):
- 若根节点相同,说明u与v已经连通,加入会构成环;
- 若根节点不同,说明不在同一连通快,可以加入;
-
合并:
- 加入MST集合;
- 使用union操作,将u和v的集合合并。
-
结束:
当MST集合中包含V - 1条边时,结束。
代码实现:
struct Edge{
int u, v, weight;
bool operator<(const Edge& other){
return weight < other.weight;
}
};
int find(int x){
if(parent[x] != x){
parent[x] = find(parent[x]);
}
return parent[x];
}
void union(int x, int y){
int rootx = find(x);
int rooty = find(y);
if(rootx == rooty) return;
//按秩合并
if(rank_[rootx] < rank_[rooty]){
parent[rootx] = rooty;
}
else if(rank_[rootx] > rank_[rooty]){
parent[rooty] = rootx;
}
else{ //相等时,任意合并,但秩要增加
parent[rootx] = rooty;
rank_[rooty]++;
}
}
int kruskal(int V, vector<Edge>& edges){
sort(edges.begin(), edges.end());
//初始化并查集相关数组
for(int i = 0; i < V; ++i){
parent[i] = i;
rank_[i] = 0;
}
int mstweight = 0;
int usededges = 0;
for(Edge& e: edges){
if(usededges == V - 1){
break;
}
int rootU = find(e.u);
int rootV = find(e.v);
if(rootU != rootV){ //合并
union(rootU, rootV);
mstweight += e.weight;
usededges ++;
}
}
return mstweight;
}
时间复杂度分析:
由于压缩路径后的并查集操作均为反阿克曼函数,均可视为常数
故主要是排序的O(ElogV);
最短路径:
Dijkstra算法:
算法步骤:
1,创建优先级队列;
2,将起点s以优先级0加入priority_queue;
将其他所有点以优先级inf加入priority_queue;
3,当队列不为空时,弹出顶点,并松弛从顶点出发的所有边。
核心思想:
贪心算法+松弛操作;
1,全局的最优路径可以不断地通过局部最优路径累计得到;
原理:当目前弹出的顶点是V1,说明V1目前是距离start最近的点。若后续操作中V1到start的距离可以被进一步更新,则说明必定存在V2到start的距离更短,前后相矛盾。
2,松弛操作:通过不断更新和改进估计的距离最优值,逼近最优策略;
具体:记现在的dist[now]为最佳距离,遍历当前队列弹出的顶点ver的所有边,检查是否有dist[ver] + weight[ver] [now] < dist[now],然后用来更新dist[now]的值。
NOTICE: 我们不会再用其他顶点来松弛已访问过的顶点的,因为它的最短距离已经确定过了,无需再次更新。
代码框架实现:
typedef pair<int, int> PII; //(distance, node)
vector<vector<PII>> graph;
bool st[N];
void dijkstra(int s, vector<int>& dist){
int n = graph.size();
for(int i = 0; i < n;++i){
dist[i] = inf;
}
dist[s] = 0;
priority_queue<PII, vector<PII>, greater<PII>> pq;
pq.push({0, s});
while(pq.size()){
int node = pq.top().second;
int dis = pq.top().first;
pq.pop();
if(st[node]) continue;
st[node] = true;
for(int i = 0; i < graph[node].size(); ++i){
int cur_node = graph[node][i].second;
int cur_weight = graph[node][i].first;
if(dist[node] + cur_weight < dist[cur_node]){
dist[cur_node] = dist[node] + cur_weight;
pq.push({dist[cur_node], cur_node});
}
}
}
}
特殊情况:
当出现负权边时,该算法不成立。因为负权边的存在使得 **"""当前弹出的点的最小距离不会再次改变""**这点不成立。因为有可能前半段距离很长,但突然经过一个负权边补偿后,举例又变小。
A*算法:
A *本质上是Dijkstra的一种启发式改进。如果说Dijkstra算法是一种从source以同心圆的形式逐步一圈圈向外扩散。那么A *算法则是给定一个大致的前进方向,大大缩减了需要遍历检查的节点数量。
核心思想:
为每一个节点计算一个代价函数F(n),并以代价函数作为优先级来比较。
F(n) = G(n) + H(n);
- G(n):从起始点到当前节点的准确距离;
- H(n):从当前节点n到目标节点的预估成本。通常采用直线距离或曼哈顿距离。
- F(n):通过节点n的路径的预估总成本。
具体代码框架:
typedef pair<double, int> PII; //(f_score, 节点)
vector<vector<PII>> graph;
bool st[N];
int g[N];
int came_from[N];
double get_dist(int a, int b){
return num ; //结合具体计算直线距离...
}
void A_star(int start, int goal, vector<int>& path){
int n = graph.size();
memset(g, inf, sizeof g);
memset(came_from, -1, sizeof came_from);
g[start] = 0;
double f_score = g[start] + get_dist(start, goal);
priority_queue<PII, vector<PII>, greater<PII>> pq;
pq.push({f_score, start});
while(pq.size()){
double current_f = pq.top().first;
int node = pq.top().second;
pq.pop();
if(st[node]) continue;
st[node] = true;
//记录路径
if(node == goal){
path.clear();
for(int node = goal; node != -1; node = came_from[node]){
path.push_back(node);
}
reverse(path.begin(), path.end());
return;
}
for(int i = 0; i < graph[node].size(); ++i){
int edge_weight = graph[node][i].first;
int neighbor = graph[node][i].second;
if(st[neighbor]) continue;
if(g[node] + edge_weight < g[neighbor]){
g[neighbor] = g[node] + edge_Weight;
double f_score = g[neighbor] + get_dist(neighbor, goal);
pq.push({f_score, neighbor});
}
}
}
path.clear();
}
注意:A*算法同样无法处理负权边的图。