图的基本概念
图是由顶点集合和边的集合组成的一种数据结构,记作 G = ( V , E ) G=(V, E) G=(V,E) 。
有向图和无向图:
如下图:
完全图:
如下图:
邻接顶点:
顶点的度:
路径与路径长度:
带权图示例:
简单路径与回路:
如下图:
子图:
如下图:
连通图和强连通图:
生成树与最小生成树:
图的相关应用场景
图常见的表示场景如下:
关于有向图和无向图:
图的其他相关作用:
图与树的联系与区别
图与树的主要联系与区别如下:
图由顶点和边组成,存储图本质就是将图中的顶点和边存储起来。
邻接矩阵
邻接矩阵存储图的方式如下:
如下图:
说明一下:
邻接矩阵的优缺点
邻接矩阵的优点:
邻接矩阵的缺点:
邻接矩阵的实现
邻接矩阵所需成员变量:
邻接矩阵的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//构造函数
Graph(const V* vertexs, int n)
:_vertexs(vertexs, vertexs + n) //设置顶点集合
,_matrix(n, vector<int>(n, MAX_W)) { //开辟二维数组空间
//建立顶点与下标的映射关系
for (int i = 0; i < n; i++) {
_vIndexMap[vertexs[i]] = i;
}
}
//获取顶点对应的下标
int getVertexIndex(const V& v) {
auto iter = _vIndexMap.find(v);
if (iter != _vIndexMap.end()) { //顶点存在
return iter->second;
}
else { //顶点不存在
throw invalid_argument("不存在的顶点");
return -1;
}
}
//添加边
void addEdge(const V& src, const V& dst, const W& weight) {
int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标
_matrix[srci][dsti] = weight; //设置邻接矩阵中对应的值
if (Direction == false) { //无向图
_matrix[dsti][srci] = weight; //添加从目标顶点到源顶点的边
}
}
//打印顶点集合和邻接矩阵
void print() {
int n = _vertexs.size();
//打印顶点集合
for (int i = 0; i < n; i++) {
cout << "[" << i << "]->" << _vertexs[i] << endl;
}
cout << endl;
//打印邻接矩阵
//横下标
cout << " ";
for (int i = 0; i < n; i++) {
//cout << i << " ";
printf("%4d", i);
}
cout << endl;
for (int i = 0; i < n; i++) {
cout << i << " "; //竖下标
for (int j = 0; j < n; j++) {
if (_matrix[i][j] == MAX_W) {
printf("%4c", '*');
}
else {
printf("%4d", _matrix[i][j]);
}
}
cout << endl;
}
cout << endl;
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
邻接表
邻接表存储图的方式如下:
如下图:
说明一下:
邻接表的优缺点
邻接表的优点:
邻接表的缺点:
邻接表的实现
链表结点所需成员变量:
代码如下:
//邻接表
namespace LinkTable {
//链表结点定义
template<class W>
struct Edge {
//int _srci; //源顶点的下标(可选)
int _dsti; //目标顶点的下标
W _weight; //边的权值
Edge<W>* _next; //连接指针
Edge(int dsti, const W& weight)
:_dsti(dsti)
,_weight(weight)
,_next(nullptr)
{}
};
}
说明一下:
邻接表所需成员变量:
邻接表的实现:
代码如下:
//邻接表
namespace LinkTable {
template<class V, class W, bool Direction = false>
class Graph {
typedef Edge<W> Edge;
public:
//构造函数
Graph(const V* vertexs, int n)
:_vertexs(vertexs, vertexs + n) //设置顶点集合
, _linkTable(n, nullptr) { //开辟邻接表的空间
//建立顶点与下标的映射关系
for (int i = 0; i < n; i++) {
_vIndexMap[vertexs[i]] = i;
}
}
//获取顶点对应的下标
int getVertexIndex(const V& v) {
auto iter = _vIndexMap.find(v);
if (iter != _vIndexMap.end()) { //顶点存在
return iter->second;
}
else { //顶点不存在
throw invalid_argument("不存在的顶点");
return -1;
}
}
//添加边
void addEdge(const V& src, const V& dst, const W& weight) {
int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标
//添加从源顶点到目标顶点的边
Edge* sdEdge = new Edge(dsti, weight);
sdEdge->_next = _linkTable[srci];
_linkTable[srci] = sdEdge;
if (Direction == false) { //无向图
//添加从目标顶点到源顶点的边
Edge* dsEdge = new Edge(srci, weight);
dsEdge->_next = _linkTable[dsti];
_linkTable[dsti] = dsEdge;
}
}
//打印顶点集合和邻接表
void print() {
int n = _vertexs.size();
//打印顶点集合
for (int i = 0; i < n; i++) {
cout << "[" << i << "]->" << _vertexs[i] << " ";
}
cout << endl << endl;
//打印邻接表
for (int i = 0; i < n; i++) {
Edge* cur = _linkTable[i];
cout << "[" << i << ":" << _vertexs[i] << "]->";
while (cur) {
cout << "[" << cur->_dsti << ":" << _vertexs[cur->_dsti] << ":" << cur->_weight << "]->";
cur = cur->_next;
}
cout << "nullptr" << endl;
}
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<Edge*> _linkTable; //邻接表(出边表)
};
}
说明一下:
图的遍历指的是遍历图中的顶点,主要有广度优先遍历和深度优先遍历两种方式。
广度优先遍历
广度优先遍历又称BFS,其遍历过程类似于二叉树的层序遍历,从起始顶点开始一层一层向外进行遍历。
如下图:
广度优先遍历的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//广度优先遍历
void bfs(const V& src) {
int srci = getVertexIndex(src); //起始顶点的下标
queue<int> q; //队列
vector<bool> visited(_vertexs.size(), false); //标记数组
q.push(srci); //起始顶点入队列
visited[srci] = true; //起始顶点标记为访问过
while (!q.empty()) {
int front = q.front();
q.pop();
cout << _vertexs[front] << " ";
for (int i = 0; i < _vertexs.size(); i++) { //找出从front连接出去的顶点
if (_matrix[front][i] != MAX_W && visited[i] == false) { //是邻接顶点,并且没有被访问过
q.push(i); //入队列
visited[i] = true; //标记为访问过
}
}
}
cout << endl;
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
深度优先遍历
深度优先遍历又称DFS,其遍历过程类似于二叉树的先序遍历,从起始顶点开始不断对顶点进行深入遍历。
如下图:
深度优先遍历的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//深度优先遍历(子函数)
void _dfs(int srci, vector<bool>& visited) {
cout << _vertexs[srci] << " "; //访问
visited[srci] = true; //标记为访问过
for (int i = 0; i < _vertexs.size(); i++) { //找从srci连接出去的顶点
if (_matrix[srci][i] != MAX_W && visited[i] == false) { //是邻接顶点,并且没有被访问过
_dfs(i, visited); //递归遍历
}
}
}
//深度优先遍历
void dfs(const V& src) {
int srci = getVertexIndex(src); //起始顶点的下标
vector<bool> visited(_vertexs.size(), false); //标记数组
_dfs(srci, visited); //递归遍历
cout << endl;
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
最小生成树
关于最小生成树:
说明一下:
构成最小生成树的准则
构造最小生成树的准则如下:
构造最小生成树的算法有Kruskal算法和Prim算法,这两个算法都采用了逐步求解的贪心策略。
Kruskal算法(克鲁斯卡尔算法)
Kruskal算法的基本思想如下:
动图演示:
Kruskal算法的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//强制生成默认构造
Graph() = default;
void _addEdge(int srci, int dsti, const W& weight) {
_matrix[srci][dsti] = weight; //设置邻接矩阵中对应的值
if (Direction == false) { //无向图
_matrix[dsti][srci] = weight; //添加从目标顶点到源顶点的边
}
}
//添加边
void addEdge(const V& src, const V& dst, const W& weight) {
int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标
_addEdge(srci, dsti, weight);
}
//边
struct Edge {
int _srci; //源顶点的下标
int _dsti; //目标顶点的下标
W _weight; //边的权值
Edge(int srci, int dsti, const W& weight)
:_srci(srci)
, _dsti(dsti)
, _weight(weight)
{}
bool operator>(const Edge& edge) const{
return _weight > edge._weight;
}
};
//获取当前图的最小生成树(Kruskal算法)
W Kruskal(Graph<V, W, MAX_W, Direction>& minTree) {
int n = _vertexs.size();
//设置最小生成树的各个成员变量
minTree._vertexs = _vertexs; //设置最小生成树的顶点集合
minTree._vIndexMap = _vIndexMap; //设置最小生成树顶点与下标的映射
minTree._matrix.resize(n, vector<W>(n, MAX_W)); //开辟最小生成树的二维数组空间
priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap; //优先级队列(小堆)
//将所有边添加到优先级队列
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) { //只遍历矩阵的一半,避免重复添加相同的边
if (_matrix[i][j] != MAX_W)
minHeap.push(Edge(i, j, _matrix[i][j]));
}
}
UnionFindSet ufs(n); //n个顶点的并查集
int count = 0; //已选边的数量
W totalWeight = W(); //最小生成树的总权值
while (!minHeap.empty() && count < n - 1) {
//从优先级队列中获取一个权值最小的边
Edge minEdge = minHeap.top();
minHeap.pop();
int srci = minEdge._srci, dsti = minEdge._dsti;
W weight = minEdge._weight;
if (!ufs.inSameSet(srci, dsti)) { //边的源顶点和目标顶点不在同一个集合
minTree._addEdge(srci, dsti, weight); //在最小生成树中添加边
ufs.unionSet(srci, dsti); //合并源顶点和目标顶点对应的集合
count++;
totalWeight += weight;
cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
else { //边的源顶点和目标顶点在同一个集合,加入这条边会构成环
cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
}
if (count == n - 1) {
cout << "构建最小生成树成功" << endl;
return totalWeight;
}
else {
cout << "无法构成最小生成树" << endl;
return W();
}
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
Prim算法(普里姆算法)
Prim算法的基本思想如下:
动图演示:
Prim算法的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//边
struct Edge {
int _srci; //源顶点的下标
int _dsti; //目标顶点的下标
W _weight; //边的权值
Edge(int srci, int dsti, const W& weight)
:_srci(srci)
, _dsti(dsti)
, _weight(weight)
{}
bool operator>(const Edge& edge) const{
return _weight > edge._weight;
}
};
//获取当前图的最小生成树(Prim算法)
W Prim(Graph<V, W, MAX_W, Direction>& minTree, const V& start) {
int n = _vertexs.size();
//设置最小生成树的各个成员变量
minTree._vertexs = _vertexs; //设置最小生成树的顶点集合
minTree._vIndexMap = _vIndexMap; //设置最小生成树顶点与下标的映射
minTree._matrix.resize(n, vector<W>(n, MAX_W)); //开辟最小生成树的二维数组空间
int starti = getVertexIndex(start); //获取起始顶点的下标
vector<bool> forest(n, false);
forest[starti] = true;
priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap; //优先级队列(小堆)
//将从起始顶点连接出去的边加入优先级队列
for (int i = 0; i < n; i++) {
if (_matrix[starti][i] != MAX_W)
minHeap.push(Edge(starti, i, _matrix[starti][i]));
}
int count = 0; //已选边的数量
W totalWeight = W(); //最小生成树的总权值
while (!minHeap.empty() && count < n - 1) {
//从优先级队列中获取一个权值最小的边
Edge minEdge = minHeap.top();
minHeap.pop();
int srci = minEdge._srci, dsti = minEdge._dsti;
W weight = minEdge._weight;
if (forest[dsti] == false) { //边的目标顶点还没有被加入到forest集合中
//将从目标顶点连接出去的边加入优先级队列
for (int i = 0; i < n; i++) {
if (_matrix[dsti][i] != MAX_W && forest[i] == false) //加入的边的目标顶点不能在forest集合中
minHeap.push(Edge(dsti, i, _matrix[dsti][i]));
}
minTree._addEdge(srci, dsti, weight); //在最小生成树中添加边
forest[dsti] = true; //将边的目标顶点加入forest集合
count++;
totalWeight += weight;
cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
else { //边的目标顶点已经在forest集合中,加入这条边会构成环
cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
}
}
if (count == n - 1) {
cout << "构建最小生成树成功" << endl;
return totalWeight;
}
else {
cout << "无法构成最小生成树" << endl;
return W();
}
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
最短路径
关于最短路径:
Dijkstra算法(迪杰斯特拉算法)
使用前提:图中所有边的权值非负。
Dijkstra算法的基本思想如下:
动图演示:
Dijkstra算法的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//获取单源最短路径(Dijkstra算法)
void Dijkstra(const V& src, vector<W>& dist, vector<int>& parentPath) {
int n = _vertexs.size();
int srci = getVertexIndex(src); //获取源顶点的下标
dist.resize(n, MAX_W); //各个顶点的估计值初始化为MAX_W
parentPath.resize(n, -1); //各个顶点的前驱顶点初始化为-1
dist[srci] = W(); //源顶点的估计值设置为权值的缺省值
vector<bool> S(n, false); //已经确定最短路径的顶点集合
for (int i = 0; i < n; i++) { //将Q集合中的n个顶点全部加入到S集合
//从集合Q中选出一个估计值最小的顶点
W minW = MAX_W; //最小估计值
int u = -1; //估计值最小的顶点
for (int j = 0; j < n; j++) {
if (S[j] == false && dist[j] < minW) {
minW = dist[j];
u = j;
}
}
S[u] = true; //将选出的顶点加入到S集合
//对u连接出去的顶点进行松弛更新
for (int v = 0; v < n; v++) {
if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v]) { //松弛的顶点不能在S集合
dist[v] = dist[u] + _matrix[u][v]; //松弛更新出更小的估计值
parentPath[v] = u; //更新路径的前驱顶点
}
}
}
}
//打印最短路径及路径权值
void printShortPath(const V& src, const vector<W>& dist, const vector<int>& parentPath) {
int n = _vertexs.size();
int srci = getVertexIndex(src); //获取源顶点的下标
for (int i = 0; i < n; i++) {
vector<int> path;
int cur = i;
while (cur != -1) { //源顶点的前驱顶点为-1
path.push_back(cur);
cur = parentPath[cur];
}
reverse(path.begin(), path.end()); //逆置
for (int j = 0; j < path.size(); j++) {
cout << _vertexs[path[j]] << "->";
}
cout << "路径权值: " << dist[i] << "" << endl;
}
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
Dijkstra算法的原理
Bellman-Ford算法(贝尔曼福特算法)
Bellman-Ford算法的基本思想如下:
Bellman-Ford算法的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//获取单源最短路径(BellmanFord算法)
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& parentPath) {
int n = _vertexs.size();
int srci = getVertexIndex(src); //获取源顶点的下标
dist.resize(n, MAX_W); //各个顶点的估计值初始化为MAX_W
parentPath.resize(n, -1); //各个顶点的前驱顶点初始化为-1
dist[srci] = W(); //源顶点的估计值设置为权值的缺省值
for (int k = 0; k < n - 1; k++) { //最多更新n-1轮
bool update = false; //记录本轮是否更新过
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (_matrix[i][j] != MAX_W && dist[i] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
dist[j] = dist[i] + _matrix[i][j]; //松弛更新出更小的路径权值
parentPath[j] = i; //更新路径的前驱顶点
update = true;
}
}
}
if (update == false) { //本轮没有更新过,不必进行后续轮次的更新
break;
}
}
//更新n-1轮后如果还能更新,则说明带有负权回路
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
return false; //带有负权回路的图无法求出最短路径
}
}
}
return true;
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下:
为什么最多进行 n − 1 n-1 n−1 轮松弛更新?
从一个顶点到另一个顶点的最短路径中不能包含回路:
在每一轮松弛过程中,后面路径的更新可能会影响到前面已经更新过的路径,比如使得前面已经更新过的路径的长度可以变得更短,或者使得某些源顶点之前不可达的顶点变得可达,但每一轮松弛至少能确定最短路径中的一条边,如果图中有 n n n 个顶点,那么两个顶点之间的最短路径最多有 n − 1 n-1 n−1 条边,因此最多需要进行 n − 1 n-1 n−1 次松弛更新。
例如下图中,顶点 A A A、 B B B、 C C C、 D D D、 E E E 的下标分别是0、1、2、3、4,现在要计算以顶点 E E E 为源顶点的单源最短路径。
对于上述图来说,Bellman-Ford算法在第一轮松弛的时候只能更新出 E − > D E->D E−>D 这条边,在第二轮的时候只能更新出 D − > C D->C D−>C ,以此类推,最终就会进行4轮松弛更新(建议通过代码调试观察)。
说明一下:
Floyd-Warshall算法(弗洛伊德算法)
Floyd-Warshall算法的基本思想如下:
Floyd-Warshall算法的实现:
代码如下:
//邻接矩阵
namespace Matrix {
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
//获取多源最短路径(FloydWarshall算法)
void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvParentPath) {
int n = _vertexs.size();
vvDist.resize(n, vector<W>(n, MAX_W)); //任意两个顶点直接的路径权值初始化为MAX_W
vvParentPath.resize(n, vector<int>(n, -1)); //各个顶点的前驱顶点初始化为-1
//根据邻接矩阵初始化直接相连的顶点
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (_matrix[i][j] != MAX_W) { //i->j有边
vvDist[i][j] = _matrix[i][j]; //i->j的路径权值
vvParentPath[i][j] = i; //i->j路径的前驱顶点为i
}
if (i == j) { //i->i
vvDist[i][j] = W(); //i->i的路径权值设置为权值的缺省值
}
}
}
for (int k = 0; k < n; k++) { //依次取各个顶点作为i->j路径的中间顶点
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W &&
vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) { //存在i->k和k->j的路径,并且这两条路径的权值之和小于当前i->j路径的权值
vvDist[i][j] = vvDist[i][k] + vvDist[k][j]; //松弛更新出更小的路径权值
vvParentPath[i][j] = vvParentPath[k][j]; //更小路径的前驱顶点
}
}
}
}
}
private:
vector<V> _vertexs; //顶点集合
unordered_map<V, int> _vIndexMap; //顶点映射下标
vector<vector<W>> _matrix; //邻接矩阵
};
}
说明一下: