图(Graph)是由顶点和链接顶点的边构成的离散结构。在计算机科学中,图是最灵活的数据结构之一,不少问题均可以使用图模型进行建模求解。例如:生态环境中不一样物种的相互竞争、人与人之间的社交与关系网络、化学上用图区分结构不一样但分子式相同的同分异构体、分析计算机网络的拓扑结构肯定两台计算机是否能够通讯、找到两个城市之间的最短路径等等。
额,我都不研究这些问题。之因此从新回顾数据结构,仅仅是为了好玩。图(Graph)一般会放在树(Tree)后面介绍,树能够说只是图的特例,可是我以为就基础算法而言,树比图复杂不少,并且听起来也没什么好玩的(左左旋、左右旋、右右旋、右左旋,好无聊~)。所以,我写的第一篇数据结构的笔记就从图开始。ios
图的结构很简单,就是由顶点$V$集和边$E$集构成,所以图能够表示成$G=(V, E)$。
图1-1:无向图1
图1-1就是无向图,咱们能够说这张图中,有点集$V=\{1, 2, 3, 4, 5, 6\}$,边集$E=\{(1, 2), (1, 5), (2, 3), (2, 5), (3, 4), (4, 5), (4, 6)\}$。在无向图中,边$(u, v)$和边$(v, u)$是同样的,所以只要记录一个就好了。简而言之,对称。
图1-2:有向图 2
有向图也很好理解,就是加上了方向性,顶点$(u, v)$之间的关系和顶点$(v,u)$之间的关系不一样,后者或许不存在。例如,地图应用中必须存储单行道的信息,避免给出错误的方向。
加权图:与加权图对应的就是无权图,若是以为很差听,那就叫等权图。若是一张图不含权重信息,咱们就认为边与边之间没有差异。不过,具体建模的时候,不少时候都须要有权重,好比对中国重要城市间道路联系的建模,总不能认为从北京去上海和从北京去广州同样远(等权)。
还有不少细化的概念,有兴趣的本身了解咯。我以为就不必单独拎出来写,好比:无向图中,任意两个顶点间都有边,称为无向彻底图;加权图起一个新名字,叫网(network)……然而,如无必要,毋增实体。
两个重要关系:c++
路径(path):依次遍历顶点序列之间的边所造成的轨迹。注意,依次就意味着有序,先1后2和先2后1不同。
简单路径:没有重复顶点的路径称为简单路径。说白了,这一趟路里没有出现绕了一圈回到同一点的状况,也就是没有环。
图1-3:四顶点的有向带环图3
环:包含相同的顶点两次或者两次以上。图1-3中的顶点序列$<1,2,4,3,1>$,1出现了两次,固然还有其它的环,好比$<1,4,3,1>$。
无环图:没有环的图,其中,有向无环图有特殊的名称,叫作DAG(Directed Acyline Graph)(最好记住,DAG具备一些很好性质,好比不少动态规划的问题均可以转化成DAG中的最长路径、最短路径或者路径计数的问题)。
下面这个概念很重要:
图1-4:两个连通分支
连通的:无向图中每一对不一样的顶点之间都有路径。若是这个条件在有向图里也成立,那么就是强连通的。图1-4中的图不是连通的,我丝毫没有侮辱你智商的意思,我只是想和你说,这图是我画的,顶点标签有点小,应该看到a和d之间没有通路。算法
图1-5:有向图的连通分支数组
图1-6:关节点
关节点(割点):某些特定的顶点对于保持图或连通分支的连通性有特殊的重要意义。若是移除某个顶点将使图或者分支失去连通性,则称该顶点为关节点。如图1-6中的c。
双连通图:不含任何关节点的图。
关节点的重要性不言而喻。若是你想要破坏互联网,你就应该找到它的关节点。一样,要防范敌人的攻击,首要保护的也应该是关节点。在资源总量有限的前提下,找出关节点并给予特别保障,是提升系统总体稳定性和鲁棒性的基本策略。
桥(割边):和关节点相似,删除一条边,就产生比原图更多的连通分支的子图,这条边就称为割边或者桥。安全
这一部分属于图论的内容,基础图算法不会用到,可是我以为挺有意思的,小记以下。
同构4:图看起来结构不同,但它是同样的。假定有$G_1$和$G_2$,那么你只要确认对于$G_1$中的全部的两个相邻点$a$和$b$,能够经过某种方式$f$映射到$G_2$,映射后的两个点$f(a)$、$f(b)$也是相邻的。换句话说,当两个简单图同构时,两个图的顶点之间保持相邻关系的一一对应。
图1-7:图的同构
图1-7就展现了图的同构,这里顶点个数不多判断图的同构很简单。咱们能够把v1当作u1,天然咱们会把u3看出v3。用数学的语言就是$f(u_1)=v_1$,$f(u_3)=v_3$。u1的另一个链接是到u2,v1的另一个链接是到v4,不难从相邻顶点的关系验证$f(u_2)=v_4$,$f(u_4)=v_2$。
欧拉回路(Euler Circuit):小学数学课本上的哥尼斯堡七桥问题,能不能从镇里的某个位置出发不重复的通过全部桥(边)而且返回出发点。这也就小学的一笔画问题,欧拉大神解决里这个问题,开创了图论。结论很简单:至少2个顶点的连通多重图存在欧拉回路的充要条件是每一个顶点的度都是偶数。证实也很容易,你们有兴趣能够阅读相关资料。结论也很好理解,从某个起点出发,最后要回起点,中间不管路过多少次起点,都会再次离开,进、出的数目必然相等,故必定是偶数。
哈密顿回路(Hamilton Circuit):哈密顿回路条件就比欧拉回路严格一点,不能重复通过点。你可能会感到意外,对于欧拉回路,咱们能够垂手可得地回答,可是咱们却很难解决哈密顿回路问题,实际上它是一个NP彻底问题。这个术语源自1857年爱尔兰数学家威廉·罗万·哈密顿爵士发明的智力题。哈密顿的智力题用到了木质十二面体(如图1-8(a)所示,十二面体有12个正五边形表面)、十二面体每一个顶点上的钉子、以及细线。十二面体的20个顶点用世界上的不一样城市标记。智力题要求从一个城市开始,沿十二面体的边旅行,访问其余19个城市,每一个刚好一次,最终回到第一个城市。
图1-8:哈密顿回路问题
由于做者不可能向每位读者提供带钉子和细线的木质十二面体,因此考虑了一个等价的问题:对图1-8(b)的图是否具备刚好通过每一个顶点一次的回路?它就是对原题的解,由于这个平面图同构于十二面体顶点和边。
著名的旅行商问题(TSP)要求旅行商访问一组城市所应当选取的最短路线。这个问题能够归结为求彻底图的哈密顿回路,使这个回路的边的权重和尽量的小。一样,由于这是个NP彻底问题,最直截了当的方法就检查全部可能的哈密顿回路,而后选择权重和最小的。固然这样效率几乎难以忍受,时间复杂度高达$O(n!)$。在实际应用中,咱们使用的启发式搜索等近似算法,能够彻底求解城市数量上万的实例,而且甚至能在偏差1%范围内估计上百万个城市的问题。网络
关于旅行商问题目前的研究进展,能够到http://www.math.uwaterloo.ca/...。数据结构
觉得能够一带而过,结果写了那么多。也没什么好总结的了,固然这些也至是图论概念的一小部分,还有一些图可能咱们之后也会见到,好比顺着图到网络流,就会涉及二分图,不过都很好理解,毕竟有图。ide
图最多见的表示形式为邻接链表和邻接矩阵。邻接连接在表示稀疏图时很是紧凑而成为了一般的选择,相比之下,若是在稀疏图表示时使用邻接矩阵,会浪费不少内存空间,遍历的时候也会增长开销。可是,这不是绝对的。若是图是稠密图,邻接链表的优点就不明显了,那么就能够选择更加方便的邻接矩阵。
还有,顶点之间有多种关系的时候,也不适合使用矩阵。由于表示的时候,矩阵中的每个元素都会被看成一个表。函数
若是使用邻接矩阵还要注意存储问题。矩阵须要$n^2$个元素的存储空间,声明的又是连续的空间地址。因为计算机内存的限制,存储的顶点数目也是有限的,例如:Java的虚拟机的堆的默认大小是物理内存的1/4,或者1G。以1G计算,那么建立一个二维的int[16384][16384]
的邻接矩阵就已经超出内存限制了。含有上百万个顶点的图是很常见的,$V^2$的空间是不能知足的。
所以,偷个懒,若是对邻接矩阵感兴趣,能够本身找点资料。很容易理解的。性能
邻接链表的实现会比邻接矩阵麻烦一点,可是邻接链表的综合能力,包括鲁棒性、拓展性都比邻接矩阵强不少。没办法,只能忍了。
图1-9:邻接链表示意图
从图1-9不能看出邻接链表能够用线性表构成。顶点能够保持在数组或者向量(vector)中,邻接关系则用链表实现,利用链表高效的插入和删除,实现内存的充分利用。有利必有弊,邻接矩阵能够高效的断定两个顶点之间是否有邻接关系,邻接链表无疑要遍历一次链表。
邻接链表的瓶颈在于链表的查找上,若是换成高效的查找结构,就能够进一步地提升性能。例如,把保存顶点邻接关系的链表换成一般以红黑树为基础set
。若是必定要名副其实,就要叫成邻接集。相似的,顶点的保存也有“改进”方案。好比,使用vector
一般用int
表示顶点,也没法高效地进行顶点的插入删除。若是把顶点的保存换成链表,无疑能够高效地进行顶点的插入和删除,可是访问能力又会大打折扣。没错,咱们可使用set
或者map
来保存顶点信息。
C++11中引入了以散列表为基础unordered_set
和unordered_map
,就查找和插入而言,统计性能可能会高于红黑树,然而,散列表会带来额外的内存开销,这是值得注意的。
具体问题,具体分析,图的结构不一样,实现图的结构也应该随之不一样。大概也是这个缘由,像C++、Java、Python等语言,都不提供具体的Graph
。举个例子,直接使用vector
保存顶点信息,list
保存邻接关系,使用的顶点id连续5。那么在添加边$O(1)$,遍历顶点的邻接关系$O(V)$还有空间消耗$O(V+E)$上都是最优的。固然,相似频繁删除边,添加边(不容许平行边),删除顶点,添加顶点,那么这种比较简易的结构就不太适合了。
咱们稍微量化一下稀疏图和稠密图的标准。当咱们声称图的是稀疏的,咱们近似地认为边的数量$|E|$大体等于顶点的个数$|V|$,在稠密图中,咱们能够不可贵到$|E|$近似为$|V^2|$。在此,咱们不妨定义均衡图是边的数量为$|V^2|/\log |V|$的图。
图算法中,根据图的结构,常常会有两个算法变种,时间复杂度也不尽相同。可能有一个是$O((V+E)\log V)$,另外一个是$O(V^2+E)$。选择哪一个算法更为高效取决于图是不是稀疏的。
图类型 | $O((V+E)\log V)$ | 比较关系 | $O(V^2+E)$ |
---|---|---|---|
稀疏图:$E$是$O(V)$ | $O(V\log V)$ | < | $O(V^2)$ |
均衡图:$E$是$O(V^2/\log V)$ | $O(V^2+V\log V)=O(V^2)$ | = | $O(V^2+V^2/\log V)=O(V^2)$ |
稠密图:$E$是$O(V^2)$ | $O(V^2\log V)$ | > | $O(V^2)$ |
由于用Markdown
,因此我怕有时候排版的时候空格出现问题,4空格调整太麻烦,加上可能4空格有时候不是特别紧凑,因此代码所有是2空格缩进。另外,我就不打算像教科书同样写那种一本正经的代码,拆成头文件加源文件。还有不少偷懒和不负责的地方,不过,换来了性能。还有,auto
仍是挺好用的,所以代码会用到少许C++11。// TODO(千凡): 回头能够改用Go语言实现一次
就学习算法的目的而言,频繁添加和删除顶点是不须要的,所以代码实现时,为方便起见顶点仍然使用vector
保存,边的话进阶点,使用set
,这样就防止出现平行边了。还有,我比较放心本身,不少方法不加检查。仍是那句话,具体问题,具体分析,具体实现。
既然选择用vector
+set
,咱们来考虑一下基本操做,至于那些后来算法用到的,后面再补充实现。
数据成员:
vector
和set
构成的图结构功能:
begin
、cbegin
end
、cend
其它
n
个顶点#include <iostream> #include <vector> #include <set> #include <list> #include <fstream> #include <limits> #include <queue> // 邻接集合 typedef std::set<int> AdjSet; // 邻接集 class Graph { protected: // 邻接表向量 std::vector<AdjSet> vertices_; // 顶点数量 int vcount_; // 边的数量 int ecount_; bool directed_; public: Graph(bool directed = false) : ecount_(0), vcount_(0), vertices_(0), directed_(directed) {}; Graph(int n, bool directed) : ecount_(0), vcount_(n), vertices_(n), directed_(directed) {}; // 从文件中初始化 Graph(const char *filename, bool directed); virtual ~Graph() { vertices_.clear(); vcount_ = 0; ecount_ = 0; } // 取值函数 virtual int vcount() const { return vcount_; }; virtual int ecount() const { return ecount_; }; virtual bool directed() const { return directed_; }; // 某条边是否存在 virtual bool IsAdjacent(const int &u, const int &v); // 约定:成功返回 0,不存在 -1,已存在 1 // 添加边 virtual int AddEdge(const int &u, const int &v); // 添加顶点 virtual int AddVertex(); // 删除边 virtual int RemoveEdge(const int &u, const int &v); // 删除顶点 virtual int RemoveVertex(const int &u); // 返回顶点的邻接集 virtual std::set<int>& Adj(const int &u) { return vertices_[u]; } // 迭代器 virtual AdjSet::const_iterator begin(const int u) { return vertices_[u].begin(); }; virtual AdjSet::const_iterator end(const int u) { return vertices_[u].end(); }; virtual AdjSet::const_iterator cbegin(const int u) const { return vertices_[u].cbegin(); }; virtual AdjSet::const_iterator cend(const int u) const { return vertices_[u].cend(); }; }; // class Graph
由于图结构实现仍是比较简单的,代码都很短。
文件格式,先顶点数量、边数量,而后顶点对表示边。缺省bool
值默认无向
例如
6 8 0 1 0 2 0 5 2 3 2 4 2 1 3 5 3 4
代码实现:
Graph::Graph(const char *filename, bool directed = false) { directed_ = directed; int a, b; // 默认能打开,若是想安全,使用if (!infile.is_open())做进一步处理 std::ifstream infile(filename, std::ios_base::in); // 节点和边数量 infile >> a >> b; vcount_ = a; ecount_ = b; vertices_.resize(vcount_); // 读取边 for (int i = 0; i < ecount_; ++i) { infile >> a >> b; int v = a; int w = b; vertices_[v].insert(w); if (!directed_) { vertices_[w].insert(v); } } infile.close(); }
// 添加顶点 int Graph::AddVertex() { std::set<int> temp; vertices_.push_back(temp); ++vcount_; return 0; } // 删除顶点 int Graph::RemoveVertex(const int &u) { if (u > vertices_.size()) { return -1; } // 遍历图,寻找与顶点的相关的边 // 无向图,有关的边必定在该顶点的邻接关系中 if (!directed_) { int e = vertices_[u].size(); vertices_.erase(vertices_.begin() + u); ecount_ -= e; --vcount_; return 0; } else { // 遍历图 for (int i = 0; i < vertices_.size(); ++i) { RemoveEdge(i, u); } vertices_.erase(vertices_.begin() + u); --vcount_; return 0; } return -1; }
// 添加边 int Graph::AddEdge(const int &u, const int &v) { // 不绑安全带,使用需谨慎 vertices_[u].insert(v); if (!directed_) { vertices_[v].insert(u); } ++ecount_; return 0; } // 删除边 int Graph::RemoveEdge(const int &u, const int &v) { auto it_find = vertices_[u].find(v); if (it_find != vertices_[u].end()) { vertices_[u].erase(v); --ecount_; } else { return -1; } if (directed_) { return 0; } // 无向图删除反向边 it_find = vertices_[v].find(u); if (it_find != vertices_[u].end()) { vertices_[v].erase(u); } else { // 人和人之间的信任呢? return -1; } return 0; }
// 检查两个顶点之间是否有邻接关系 bool Graph::IsAdjacent(const int &u, const int &v) { if (vertices_[u].count(v) == 1) { return true; } return false; }
这个用到了cout
,又考虑到非功能性方法,不建议放在类中。
// 打印图 void PrintGraph(const Graph &graph) { for (int i = 0; i < graph.vcount(); i++) { std::cout << i << " -->"; for (auto it = graph.cbegin(i); it != graph.cend(i); ++it) { std::cout << " " << *it; } std::cout << std::endl; } }
图是至关灵活的,我想这也是为何STL库不提供Graph
的缘由。咱们能够发现,利用STL的基础设施,咱们能够很快的搭建Graph
。至于选择什么基础设施,没有标准答案。对于不一样的问题会有不一样的最佳答案。咱们只是演示,不对特定问题进行进行建模,能够无论什么性能,也没打算泛化(不造库,谢谢),不过度考虑实现和图操做分离问题。嗯,就这样咯,仍是赶忙进入更加激动人心的图算法吧。
我水平有限,错误不免,还望各位加以指正。