Skip to content

基本概念

图的基本概念

图 (Graph) 是由若干给定的顶点及连接两顶点的所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系。顶点用于代表事物,连接两顶点的边则用于表示两个事物间具有这种关系。

图是一个二元组$G=(V(G),E(G))$,其中 $𝑉(𝐺)$ 是非空集,称为点集,对于$V$中的每个元素,我们称其为 顶点 (vertex)节点 (node),简称点;$E(G)$为$V(G)$各结点之间边的集合,称为 边集 (edge set)

常用$G=(V,E)$表示图。

图的种类

有向图、无向图和混合图

有向图指图中的边是有方向的,无向图则反之。如果一个图中既有无向边,也有有向边,则称为混合图

若$G$为无向图,则$E$中的每个元素为一个无序二元组$(u,v)$。

若$G$为有向图,则$E$中的每个元素为一个有序二元组组$(u,v)$。设组$e=u→v$,则此时$u$称为$e$的起点(tail),$v$称为$e$的终点(head),$e$也叫做

边通常用箭头表示,而箭头是从「尾」指向「头」的。

因此,起点是 tail,终点是 head。

加权图(赋权图)

即图中的边具有权值,也分为加权有向图和加权无向图。

如果所有的权值都是正数,也可以叫做正权图

有限图和无限图

当$V$或$E$是无限集合时,称图为无限图;当$V$或$E$都是有限集合时,称图为有限图。

与一个顶点$v$关联的边的条数称作该顶点的 度 (degree),记作$d(v)$。特别地,对于边 $(v,v)$,则每条这样的边要对 $𝑑(𝑣) $产生$2$的贡献。

在无向图中,连接该节点的边的条数即为该顶点的度数。

在有向图中,分为出度入度,分别表示从该顶点出发的边的条数,和指向该节点的边的条数。

由度衍生出了以下概念:

  • 孤立点:若$𝑑(𝑣) =0$,则称$v$为 孤立点 (isolated vertex)

  • 叶节点(悬挂点):若 $𝑑(𝑣) =1$,则称$v$为 叶节点 (leaf vertex)/悬挂点 (pendant vertex)

  • 偶点:若 $2|d(v)$,则称 $v$ 为 偶点 (even vertex)

  • 奇点:若$2 ∤𝑑(𝑣)$,则称$v$为 奇点 (odd vertex)。图中奇点的个数是偶数(详见握手定理)。

相邻

对于无向图,如果两个顶点间有边相连,则称这两个点是相邻的,而这条边分别和这两个点关联。其中,一个顶点的邻域是所有与它相邻的顶点构成的集合,记作$N(v)$;一个点集$S$的的邻域是所有与$S$中至少一个点相邻的点构成的集合,记作$N(S)$:

$$N(S) = \bigcup_{v \in S} N(v)$$

对于有向图,相邻的概念划分得更加具体。给定有向图中的边$e=u→v$:

  • 对于$u$,我们称$u$邻接于$v$,这表示$u$是起点,是$v$的入邻点
  • 对于$v$,我们称$v$从$u$邻接,这表示$v$是终点,是$u$的出邻点

对于邻点,入邻点的集合通常记作$N^-(v)$,出零点的集合通常记作$N^+(v)$。

简单图

若一个图中不存在自环和重边,则称其为简单图。

根据鸽巢原理,至少有两个顶点的简单无向图中,一定存在度相同的点。

自环

对于$E$中的边$e=(u,v)$,若$u=v$,则$e$被称作一个自环(Loop)。

自环即两个顶点是同一个点的边,尽管在理论看来定义它似乎多此一举,但它在实践中存在意义,如在状态机中表示状态不变。

重边

若$E$中存在两条完全相同的边,则它们是一组重边

路径

  • 途径:途径(Walk)是连接一连串顶点的边的序列,可以是有限或无线长度。

通常来说,途径的长度是组成途径的边的数量;如果是赋权图,则是边的权值之和。

  • 迹:如果组成一条途径的边均不重复,则称这条途径为迹(Trail)

  • 路径:如果一条迹连接的所有点中没有重复的,则称这条迹是路径(Path)

  • 回路:如果一条迹的长度为k,连接的端点集合$V$中有$v_0=v_k$,则称这条迹是一条回路(circuit)

  • 环:对于一条回路,如果$v_0=v_k$是集合$V$中唯一重复出现的点对,则称这条回路是一个环。

子图

对于图$G=(V,E)$,若存在另一张图$H=(V',E')$满足$V' ⊆V$且$E'⊆E$,则称$H$是$G$的子图

导出子图(诱导子图)

若$H$是$G$的子图,$E'$包含且仅包含$E$中两个顶点都在$V'$中的边,则称$H$是$G$的导出子图。

导出子图由点决定,即在已知原图$G$和导出子图的点集$V'$的情况下,就可以确定$E'$。

生成子图(支撑子图)

若$H$是$G$的子图,且$V=V'$,则称若$H$是$G$的生成子图。


显然,对于任意图$G$都有:$G$是自身的子图、支撑子图和导出子图。

连通性

对于有向图和无向图,连通性的定义有所区别。

无向图

对于无向图$G=(V,E)$,其中$u,v∈𝑉$,若存在一条途径使得$v_0=u$,$v_k=v$,则称$u$和$v$是连通的。

如果无向图$G$满足任意两个顶点均连通,则称$G$是连通图,称$G$具有连通性。

若$H$是$G$的一个连通子图,且不存在连通图$F$满足$𝐻 ⊊𝐹 ⊆𝐺$,则称$H$是$G$的一个连通分量(极大连通子图)

有向图

对于有向图$G=(V,E)$,其中$u,v∈𝑉$,若存在一条途径使得$v_0=u$,$v_k=v$,则称$u$可达$v$的。

如果有向图$G$满足任意两个顶点均两两互相可达,则称$G$是连通图,称$G$是强连通的。

如果有向图$G$不是强连通的,但将所有边替换为无向边后,得到一张连通图,则称$G$是弱连通的

和无向图一样,有向图有弱连通分量(极大弱连通子图),和强连通分量(极大强连通子图)

图的存储

在代码中,一般使用邻接表、邻接矩阵或类表示。

在本篇中,规定$n$表示图的端点数目,$m$表示图的边数目,$d^+(u)$表示点$u$的出度。

朴素存储

参考代码
#include <iostream>
#include <vector>

using namespace std;

struct Edge {
  int u, v;
};

int n, m;
vector<Edge> e;
vector<bool> vis;

bool find_edge(int u, int v) {
  for (int i = 1; i <= m; ++i) {
    if (e[i].u == u && e[i].v == v) {
      return true;
    }
  }
  return false;
}

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int i = 1; i <= m; ++i) {
    if (e[i].u == u) {
      dfs(e[i].v);
    }
  }
}

int main() {
  cin >> n >> m;

  vis.resize(n + 1, false);
  e.resize(m + 1);

  for (int i = 1; i <= m; ++i) cin >> e[i].u >> e[i].v;

  return 0;
}

朴素存储即直接存边,使用一个数组存储所有的边,每个元素包括边的起点、终点和权值(如果存在)。

朴素存储的复杂度如下:

  • 查询是否存在某条边:$O(m)$
  • 遍历一个点的所有出边:$O(m)$
  • 遍历整张图:$O(nm)$
  • 空间复杂度:$O(m)$

朴素存储的遍历效率较低,通常不会使用。

邻接矩阵

参考代码
#include <iostream>
#include <vector>

using namespace std;

int n, m;
vector<bool> vis;
vector<vector<bool>> adj;

bool find_edge(int u, int v) { return adj[u][v]; }

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int v = 1; v <= n; ++v) {
    if (adj[u][v]) {
      dfs(v);
    }
  }
}

int main() {
  cin >> n >> m;

  vis.resize(n + 1);
  adj.resize(n + 1, vector<bool>(n + 1));

  for (int i = 1; i <= m; ++i) {
    int u, v;
    cin >> u >> v;
    adj[u][v] = true;
  }

  return 0;
}

邻接矩阵使用$n \cdot n$的二维数组表示图结构,这是从节点的角度来表示图的思路。

其中,g[u][v]为1表示存在$u$到$v$的边,为0表示不存在。如果是带边权的图,可以在g[u][v]中存储$u$到$v$的边权。

邻接矩阵的复杂度如下:

  • 查询是否存在某条边:$O(1)$
  • 遍历一个点的所有出边:$O(n)$
  • 遍历整张图:$O(n^2)$
  • 空间复杂度:$O(n^2)$

邻接矩阵只适用于没有重边(或重边可忽略)的情况,显著优点是查询某条边只需要$O(1)$时间。

邻接矩阵在稀疏图上表现较差,空间浪费的问题非常严重,因此一般只会在稠密图上使用。

邻接表

参考代码
#include <iostream>
#include <vector>

using namespace std;

int n, m;
vector<bool> vis;
vector<vector<int>> adj;

bool find_edge(int u, int v) {
  for (int i = 0; i < adj[u].size(); ++i) {
    if (adj[u][i] == v) {
      return true;
    }
  }
  return false;
}

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int i = 0; i < adj[u].size(); ++i) dfs(adj[u][i]);
}

int main() {
  cin >> n >> m;

  vis.resize(n + 1);
  adj.resize(n + 1);

  for (int i = 1; i <= m; ++i) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
  }

  return 0;
}

邻接表使用数组+vector来表示图结构:使用一个数组存储所有点,数组的每个元素是一个存储对应点出边的vector

邻接表的复杂度如下:

  • 查询是否存在某条边:$O(d^+(u))$,如果事先进行了排序就可以使用做到$O(\log (d^+(u))$
  • 遍历一个点的所有出边:$O(d^+(u))$
  • 遍历整张图:$O(m+n)$
  • 空间复杂度:$O(m)$

邻接表在点数较少的图中需要快速查询一条边是否存在时,表现较为劣势,这种情况可以使用邻接矩阵。

除此以外,邻接表是一种较为通用的方法,尤其适用于需要对一个点的所有出边进行排序的场合。

链式前向星

参考代码
#include <iostream>
#include <vector>

using namespace std;

int n, m;
vector<bool> vis;
vector<int> head, nxt, to;

void add(int u, int v) {
  nxt.push_back(head[u]);
  head[u] = to.size();
  to.push_back(v);
}

bool find_edge(int u, int v) {
  for (int i = head[u]; ~i; i = nxt[i]) {  // ~i 表示 i != -1
    if (to[i] == v) {
      return true;
    }
  }
  return false;
}

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int i = head[u]; ~i; i = nxt[i]) dfs(to[i]);
}

int main() {
  cin >> n >> m;

  vis.resize(n + 1, false);
  head.resize(n + 1, -1);

  for (int i = 1; i <= m; ++i) {
    int u, v;
    cin >> u >> v;
    add(u, v);
  }

  return 0;
}

链式前向星本质是链表实现的邻接表,使用链表存储每个点的出边。

链式前向星的复杂度如下:

  • 查询是否存在某条边:$O(d^+(u))$
  • 遍历一个点的所有出边:$O(d^+(u))$
  • 遍历整张图:$O(m+n)$
  • 空间复杂度:$O(m)$

链式前向星的缺陷在于不能快速询问一条边是否存在,也不能在方便地对一个点的出边进行排序。

链式前向星依赖一个存储每个点的入口(即其出边链表的首个节点)的整型数组和一个存边的结构体数组实现。 其中,结构体的定义如下:

struct Edge{
    int to;   //这条边的终点在点数组中的索引
    int next;  //同一起点的上一条边在边数组中的索引
    int w;    //(如果存在)这条边的权重
}edge[MAXM];    

其中edge的大小由题目规定的边的最大数目决定。

对于无向图,edge数组的大小应该开到边的最大数目*2。

为了便于存储,定义以下方法新增边:

void add_edge(int u,int v,int w){
    //u表示边的起点,v表示边的终点,w表示边的权值(如果存在
    cnt++;   //cnt是边的总数目,也是当前边的索引;
    edge[cnt].to=v;
    edge[cnt].next=head[u];
    edge[cnt].w=w;
    head[u]=cnt;
}

对于无向图,应该调用两次add_edge()方法,即add_edge(u,v)add_edge(v,u)

需要注意,cnthead都应该手动初始化为$0$ :

int head[MAXM];
memset(head,0,sizeof(head));

int cnt=0;

遍历出边时,方法和遍历链表一致:

// 遍历点 u 的所有出边
for (int i = head[u]; i != 0; i = edge[i].next) {
    int v = edge[i].to;
    int weight = edge[i].w;
    // 在这里处理边 (u, v)
}

树的基本概念

主要概念见树基础 - OI Wiki,此处只整理其它理论的前置或引用内容。

生成树

生成树是一个针对连通图的特定子图,它保留了原图的所有点,同时保证没有任何环。

其中,最小生成树仅针对带边权的图,指在所有生成树中边权和最小的一个。

推论与定理

握手定理

给定任意图$G=(V,E)$,由于图每条边实际上都由两个顶点连接而成,因此任意图的度数之和必定为$2E$,奇点的个数必定为偶数,记作:

$$∑deg(V)=2|E|$$