数据结构:图(一)---- 图的基础和遍历

图的概念

图是由顶点集合及顶点间的关系组成的一种数据结构:G = (V, E),其中:

顶点集合V = {x|x属于某个数据对象集}是有穷非空集合;

E = {(x,y)|x,y属于V}或者E = {<x, y>|x,y属于V && Path(x, y)}是顶点间关系的有穷集合,也叫

做边的集合。

简单来说,图(graph)包含两个部分:顶点(vertex)和边(edge),所以很多地方将图写成G = (V,E)

老规矩,讲完定义,上图看看

图的基本概念

  1. 顶点和边:图中结点称为顶点,第i个顶点记作vi。两个顶点vi和vj相关联称作顶点vi和顶点vj之间

    有一条边,图中的第k条边记作ek,ek = (vi,vj)或<vi,vj>。

  2. 有向图和无向图:在有向图中,顶点对<x, y>是有序的,顶点对<x,y>称为顶点x到顶点y的一条

    边(弧),<x, y>和<y, x>是两条不同的边,比如上面G3和G4为有向图。在无向图中,顶点对(x, y)

    是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定方向,(x, y)和(y,x)

    是同一条边,比如上面G1和G2为无向图。注意:无向边(x, y)等于有向边<x, y>和<y, x>。

  3. 完全图:在有n个顶点的无向图中,若有n * (n-1)/2条边,即任意两个顶点之间有且仅有一条边,

    则称此图为无向完全图,比如上图G1;在n个顶点的有向图中,若有n * (n-1)条边,即任意两个

    顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图G4。

  4. 邻接顶点:在无向图中G中,若(u, v)是E(G)中的一条边,则称u和v互为邻接顶点,并称边(u,v)依

    附于顶点u和v;在有向图G中,若<u, v>是E(G)中的一条边,则称顶点u邻接到v,顶点v邻接自顶

    点u,并称边<u, v>与顶点u和顶点v相关联。

  5. 顶点的度:顶点v的度是指与它相关联的边的条数,记作deg(v)。在有向图中,顶点的度等于该顶

    点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点v的出度

    是以v为起始点的有向边的条数,记作outdev(v)。因此:dev(v) = indev(v) + outdev(v)。注

    意:对于无向图,顶点的度等于该顶点的入度和出度,即dev(v) = indev(v) = outdev(v)。

  6. 路径:在图G = (V, E)中,若从顶点vi出发有一组边使其可到达顶点vj,则称顶点vi到顶点vj的顶

    点序列为从顶点vi到顶点vj的路径。

  7. 路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一

    条路径的路径长度是指该路径上各个边权值的总和。

  8. 权值:边上附带的数据信息

  9. 简单路径与回路:若路径上各顶点v1,v2,v3,...,vm均不重复,则称这样的路径为简单路

    径。若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。

  10. 子图:设图G = {V, E}和图G1 = {V1,E1},若V1属于V且E1属于E,则称G1是G的子图。

  11. 连通图:在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任

    意一对顶点都是连通的,则称此图为连通图。

  12. 强连通图:在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj

    到vi的路径,则称此图是强连通图。

  13. 生成树:一个连通图(无向图)的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n1条边。


图的基本结构

因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和

边关系即可。节点保存比较简单,只需要一段连续空间即可(顺序表,数组等都可),那边关系该怎么保存呢?

图的结构可以分为两种,邻接表和邻接矩阵,各自的适用场景不同,各自的优劣也不同,在本系列博客中,我们将以邻接矩阵为主要形式讲解后面的算法。


邻接矩阵和邻接表必备

  1. 首先我们需要用vector将节点存起来,我们取名为==_vertexs ==,

    同时,每一个顶点都可以映射一个下标,这样通过下标就可以直接找到顶点

  2. 通过下标很容易找到顶点,那么怎么通过顶点来找下标呢?

    显然要再次建立一组映射,直接拿出map即可,我们取名为==_indexMap==,

邻接矩阵

邻接矩阵基础

邻接矩阵顾名思义就是用矩阵(命名为weights)来表示边,矩阵的行列都表示节点,正好是vector中建立的下标和顶点的映射

那么矩阵中就可以存储权值了

这样我们就可以通过读取矩阵来获取边的信息

例如:_weights[i][j] = 6 就表示以_vertexs[i] 为起点,以_vertexs[j]为终点的边,这条边的权值为6

两条边连通,直接在矩阵中存权值即可,如果不连通,那该怎么办呢?
很简单,我们用无穷大(∞)来替代即可,int 类型我们就可以用INT_MAX来替代,其余类型类似。

邻接矩阵示例

注意:此处我的命名和图中有些区别,相信聪明的读者肯定能够发现这个问题,并自行理解

邻接矩阵的性质和优缺点

  1. 对于无向图,邻接矩阵是一个对称矩阵,第i行(列)元素之和,就是顶点i的度
  2. 邻接矩阵的优点是能够快速知道两个顶点是否连通,读邻接矩阵,如果权值是无穷大,就不相连,否则就是相连,判断两个顶点是否相连的时间复杂度是O(1)
  3. 邻接矩阵的缺点是如果顶点比较多,边比较少时,矩阵中存储了大量无穷大成为系数矩阵,比较浪费空间,并且要求与一个顶点相连的顶点不好求,需要遍历一行的所有节点才知道,时间复杂度是O(N),比较低效。

因此,对于邻接矩阵,比较适合用来存储稠密图(顶点非常多,边非常多)


邻接矩阵的结构代码示例

C++ 复制代码
template<class V,class W,W MAX_W = INT_MAX,bool DIRECTION = false>
class graph
{
private:
	vector<V> _vertexs;//顶点
	map<V, int> _indexMap;//顶点和下标的映射
	vector<vector<W>> _weights;//矩阵
};

对代码中模版参数的说明:

V是顶点的类型,W是权值的类型,MAX_W是表示无穷大的非类型参数,默认为INT_MAX,DIRECTION是用来标识是有向图还是无向图的非类型参数,默认为无向图


邻接矩阵的初始化与添加边

构造图主要可以通过三种方法

  1. 从文件读取
  2. oj题直接给出
  3. 为了方便演示,我们在这里直接用手动添加边的方法来进行讲解

直接看代码就ok了,这一块还是比较简单的

C++ 复制代码
graph(const V* v, size_t n)//初始化
{
	_vertexs.resize(n);
	_weights.resize(n);
	for (int i = 0; i < n; ++i)
	{
		_vertexs[i] = v[i];//将顶点存入数组
		_indexMap[v[i]] = i;//建立映射
		_weights[i].resize(n, MAX_W);
	}
}

int getindex(const V& v)//获取下标,包含安全检查
{
	auto it = _indexMap.find(v);

	if (it == _indexMap.end())
	{
		throw invalid_argument("该顶点不存在");//抛异常
		return -1;
	}

	return it->second;
}

void AddEdge(const V& src,const V& dst,W w)//手动添加边
{
	int srci = getindex(src);
	int dsti = getindex(dst);

	_weights[srci][dsti] = w;

	if (DIRECTION == false)//无向图需要双向添加边
	{
		_weights[dsti][srci] = w;
	}
}

邻接矩阵演示

这里写了一个打印函数来打印一下图

C++ 复制代码
void Print()
{
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		cout << _vertexs[i] << ":" << _indexMap[_vertexs[i]] << endl;
	}

	cout << endl;

	cout << "  ";
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		cout << _vertexs[i] << " ";
	}

	cout << endl;
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		cout << _vertexs[i] << " ";
		for (int j = 0; j < _weights[i].size(); ++j)
		{
			if (_weights[i][j] == MAX_W)
			{
				cout << "*" << " ";
			}
			else cout << _weights[i][j] << " ";
		}
		cout << endl;
	}
}

这是测试代码

C++ 复制代码
void TestGraph1()
{
	graph<char, int, INT_MAX, true> g("0123", 4);
	g.AddEdge('0', '1', 1);
	g.AddEdge('0', '3', 4);
	g.AddEdge('1', '3', 2);
	g.AddEdge('1', '2', 9);
	g.AddEdge('2', '3', 8);
	g.AddEdge('2', '1', 5);
	g.AddEdge('2', '0', 3);
	g.AddEdge('3', '2', 6);

	g.Print();
}

测试结果

邻接表

邻接表基础

邻接表和哈希桶的实现思路比较像,在一个顶点下面挂一个顶点链表形成边

以下图为例,我们来看看邻接表

图中A B C D E分别映射到0 1 2 3 4 5

我们看图(注意图中示例是有向图),主要是使用出边表,入边表比较少用

以A为起点连接到B和D,那么就在A下面挂一个链表,链接B和D,即1 和 3,

同时我们可以使用自定义结构,定义出一个边的类型作为链表的节点

这个边类型只需存储三个东西即可:

1、边终点的下标(_dsti) 2、边的权值(_w) 3、在链表中的下一个节点的指针(_next)

这样,像hash桶一样链接起来就成了邻接表。

邻接表优缺点

  1. 邻接表的优点:邻接表用来查找一个顶点与哪些顶点相连是非常方便,直接将链表走到尾即可。而且邻接表用来存储很多不相连的边的时候很方便,比较节省空间
  2. 邻接表的缺点:邻接表想要直接查找任意两个顶点是否相连时并不好用,还是需要将链表走到尾,比较麻烦。

因此,邻接表比较适合用来存储稀疏图(相连的边很少)。

邻接表的结构代码示例

C++ 复制代码
template<class W>
struct Edge//边类
{
	int _dsti;//目标顶点下标
	W _weight;//边的权值
	Edge<W>* _next;//指向下一个节点

	Edge(int i,W weight)
		:_dsti(i)
		,_weight(weight)
		,_next(nullptr)
	{}
};

template<class V, class W, bool DIRECTION = false>
class graph
{
	typedef Edge<W> Edge;
private:
	vector<V> _vertexs;
	map<V, int> _indexMap;
	vector<Edge*> _weights;//这里的不再是矩阵,而是指针数组,用来存储链表
};

注意,邻接表和邻接矩阵相比,

邻接表里面需要额外定义一个边类,但是邻接表少了一个非类型参数MAX_W,因为在邻接表中不需要使用MAX_W来标识两个顶点是否相连。

另外,邻接表中不需要使用矩阵,直接用一个指针数组来存链表即可。


邻接表的初始化和添加边

与邻接矩阵一样,为了方便演示,我们还是使用手动添加边的方法

这里添加边和hash桶一样,相连的边直接在相应的链表中头插即可

还是比较简单的,不多解释

C++ 复制代码
graph(const V* v, size_t n)//初始化
{
	_vertexs.resize(n);
	_weights.resize(n, nullptr);
	for (int i = 0; i < n; ++i)
	{
		_vertexs[i] = v[i];
		_indexMap[_vertexs[i]] = i;
	}
}

int getindex(const V& v)//获取下标
{
	auto it = _indexMap.find(v);

	if (it == _indexMap.end())
	{
		throw invalid_argument("该顶点不存在");//抛异常
		return -1;
	}

	return it->second;
}

void AddEdge(const V& src, const V& dst, W w)//手动添加边
{
	int srci = getindex(src);
	int dsti = getindex(dst);

	Edge* newedge = new Edge(dsti, w);
	newedge->_next = _weights[srci];
	_weights[srci] = newedge;

	if (DIRECTION == false)//无向图要双向添加边
	{
		Edge* newedge2 = new Edge(srci, w);
		newedge2->_next = _weights[dsti];
		_weights[dsti] = newedge2;
	}
}

邻接表演示

这里写了一个打印函数来打印一下图

C++ 复制代码
void Print()
{
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		cout << _vertexs[i] << ":" << i << endl;
	}

	cout << endl;
	/*cout << "  ";
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		cout << _vertexs[i] << " ";
	}
	cout << endl;*/

	for (int i = 0; i < _weights.size(); ++i)
	{
		cout << _vertexs[i] << "->";
		Edge* cur = _weights[i];

		while (cur)
		{
			cout << "[" << _vertexs[cur->_dsti] << ":" << cur->_weight << "]->";
			cur = cur->_next;
		}
		cout << "nullptr" << endl;
	}
}

测试代码,两组测试用例都可以用

C++ 复制代码
void TestGraph1()
{
	graph<char, int, true> g("0123", 4);
	g.AddEdge('0', '1', 1);
	g.AddEdge('0', '3', 4);
	g.AddEdge('1', '3', 2);
	g.AddEdge('1', '2', 9);
	g.AddEdge('2', '3', 8);
	g.AddEdge('2', '1', 5);
	g.AddEdge('2', '0', 3);
	g.AddEdge('3', '2', 6);

	g.Print();

	/*string a[] = { "张三", "李四", "王五", "赵六" };
	graph<string, int, true> g1(a, 4);
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);
	g1.Print();*/
}

测试结果


图的遍历

图的遍历分为深度优先遍历(DFS)和广度优先遍历(BFS)

分别从两个不同的纬度实现遍历,

深度优先遍历和树的前序遍历很像

广度优先遍历和树的层序遍历很像

只是到图的层面更加复杂一些而已


深度优先遍历(DFS)

深度优先遍历思路

深度优先遍历采用递归的思想实现,

需要使用一个visited数组来标记已经访问过的顶点(存bool值即可),已经访问过就不在访问

遍历需要一个起点,我们将其命名为src

我们从顶点src开始,先把src顶点给读一遍,并将其标记为已经访问状态

接下来,去邻接矩阵中遍历src顶点所在的一行,当遇到与其相连的顶点并且该顶点还没有被访问的时候,直接以该相连顶点为起点去再次进行递归进行DFS。

OK,完了,DFS就是这么多,本质上DFS就是一个多路递归。

嘿嘿,虚晃一招,其实还没有遍历完,

大家想一想,如果遇到了孤岛节点怎么办(孤岛节点就是与任何节点都不相连的节点)?

所以啊,DFS并没有结束,需要在最后再遍历一遍visited数组,遇到未访问的节点,以该顶点为起点再次进行DFS即可。


深度优先遍历代码

C++ 复制代码
void _DFS(const V& src, vector<bool>& visited)
{
	int srci = getindex(src);
	cout << _vertexs[srci] << endl;
	visited[srci] = true;
	for (int i = 0; i < _vertexs.size(); ++i)
	{
		if (_weights[srci][i] != MAX_W && visited[i] == false)
		{
			_DFS(_vertexs[i], visited);
		}
	}
}

void DFS(const V& src)
{
	vector<bool> visited(_vertexs.size(), false);
	_DFS(src, visited);
	for (int i = 0; i < visited.size(); ++i)//防止有孤岛遗漏
	{
		if (visited[i] == false)
		{
			_DFS(_vertexs[i], visited);
		}
	}
}

深度优先遍历演示

测试代码

C++ 复制代码
void TestBDFS()
{
	string a[] = { "张三",  "王五","李四", "赵六", "周七" };
	graph<string, int> g1(a, sizeof(a) / sizeof(string));
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);
	g1.AddEdge("王五", "周七", 30);
	g1.Print();
	cout << endl;
	//g1.BFS("张三");
	cout << endl;
	g1.DFS("张三");
}

测试结果


广度优先遍历(BFS)

广度优先遍历思路

广度优先遍历和层序遍历一样,需要借助队列这一数据结构

另外需要借助一个标记数组,记作visited

也是采用出一个,带下一层的思路

首先将起点进入队列

当队列不空的时候,

每次取队头元素,进行访问,并标记为已经访问

接着,在矩阵中遍历改行元素,遇见相连的并且未访问的顶点的时候,将其入队列即可。

与DFS类似,也需要处理一下孤岛节点

最后再遍历一遍visited数组,遇到未访问的节点,以该顶点为起点进行BFS即可。


广度优先遍历代码

C++ 复制代码
void _BFS(const V& src,vector<bool>& visited)
{
	int srci = getindex(src);
	queue<int> q;

	q.push(srci);

	while (!q.empty())
	{
		int front = q.front();
		q.pop();
		cout << _vertexs[front] << endl;
		visited[front] = true;

		for (int i = 0; i < _vertexs.size(); ++i)
		{
			if (_weights[front][i] != MAX_W && visited[i] == false)
			{
				q.push(i);
			}
		}
	}
}

void BFS(const V& src)
{
	int n = _vertexs.size();
	vector<bool> visited(n,false);
	_BFS(src, visited);

	for (int i = 0; i < n; ++i)//再次遍历,防止孤岛节点
	{
		if (visited[i] == false)
		{
			_BFS(_vertexs[i], visited);
		}
	}
}

广度优先遍历演示

测试代码

C++ 复制代码
void TestBDFS()
{
	string a[] = { "张三",  "王五","李四", "赵六", "周七" };
	graph<string, int> g1(a, sizeof(a) / sizeof(string));
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);
	g1.AddEdge("王五", "周七", 30);
	g1.Print();
	cout << endl;
	g1.BFS("张三");
	cout << endl;
	//g1.DFS("张三");
}

测试结果


图的分享还没有结束,后续 还会继续更新最小生成树和最短路径相关的知识,期待大家继续阅读。

相关推荐
yangmc041 小时前
判断子序列
开发语言·数据结构·c++·算法·矩阵·图论
席万里1 小时前
C++图案例大全
数据结构·c++·算法
李小白661 小时前
二叉树的练习题(中)
java·数据结构·算法
原来是猿4 小时前
类和对象(上)
c语言·开发语言·数据结构·c++·算法
醇醛酸醚酮酯5 小时前
二叉树遍历的非递归实现和复杂度分析
数据结构·算法·ducker成长之路
六点零6 小时前
数据结构-线性表-具有独立头节点的双向循环链表
数据结构·链表
坟头种朵喇叭花7 小时前
LinkedList与链表
java·数据结构·链表
CodeAllen嵌入式7 小时前
嵌入式面试题练习 - 2024/11/15
数据结构·windows·嵌入式硬件·算法·嵌入式·嵌入式系统
起名字真南8 小时前
【C++】深入理解自定义 list 容器中的 list_iterator:迭代器实现详解
数据结构·c++·windows·list