ACM图论全解(更新中)

图论

1.什么是图(Graph)?

图(Graph)是离散数学(Discrete mathematics)的一个分支,也是算法中的一个重要内容。c++

序言


基本上,图是代表主体(物品)(objects)之间的关系(relationships)的。咱们用一个比较浅显的例子来代表什么是图:git

img

这是一张人和人之间的关系图,咱们说每一个表明一我的(也就是Object),每条线表明他们是朋友关系(也就是relationships)。算法

这个图片很像【图】了,从通常性推断,咱们能够这样说:数组

图 = 点 + 线ide

这句话对,可是不彻底对oop

让咱们看看下面这个图学习

image-20210720131941418

你会发现图中的线多了箭头,咱们能够认为是谁关注了谁。它并不像刚才那样是双向的,而是单向的。spa

这两种图将会成咱们在咱们学习算法中遇到的接近90%的图设计

图的定义


接下来咱们给图一个标准的定义:3d

图 (Graph) 是一个二元组 \(G=(V(G), E(G))\)。其中 \(V(G)\) 是非空集,称为 点集 (Vertex set),对于 \(V\) 中的每一个元素,咱们称其为 顶点 (Vertex)节点 (Node),简称 ;\(E(G)\) 为 \(V(G)\) 各结点之间边的集合,称为 边集 (Edge set)

咱们来逐句理解一下这段话:

  1. 图 (Graph) 是一个二元组 \(G=(V(G), E(G))\)
    • 图是组成的集合
  2. \(V(G)\) 是非空集
  • 在图中,边能够没有,可是点不能没有(至少有一个)。也就是说最小的图是一个点
  1. \(V(G)\) (点集)
    • 点的集合
  2. \(E(G)\) 为 \(V(G)\) 各结点之间边的集合
    • 边是两个点才能造成的

咱们说,经常使用 \(G=(V,E)\) 表示图。

当 \(V,E\) 都是有限集合时,称 \(G\) 为 有限图。当 \(V\) 或 \(E\) 是无限集合时,称 \(G\) 为 无限图

在作题中遇到的,所有都是有限图,由于计算机只解决有限集合的问题

图的边权


若 \(G\) 的每条边 \(e_k=(u_k,v_k)\) 都被赋予一个数做为该边的 ,则称 \(G\) 为 赋权图。若是这些权都是正实数,就称 \(G\) 为 正权图

img

权值是很是重要的一个概念,他们老是在题目中成为最关键的条件。权可能以这样的形式出现:

  1. 从A到B要t小时时间
  2. 从A到B须要花费x元钱
  3. A到B之间你会付出q点法力值

2.无向图和有向图(Undirected graph & Directed graph)

无向图和有向图是咱们学习图论中的最重要的初始概念,搞清无向图和有向图,对咱们的作题有很是大的好处。

无向图的概念比有向图简单,因此咱们先学习一下无向图。

无向图的定义


若 \(G\) 为无向图,则 \(E\) 中的每一个元素为一个无序二元组 \((u, v)\),称做 无向边 (Undirected edge),简称 边 (Edge),其中 \(u, v \in V\)。设 \(e = (u, v)\),则 \(u\) 和 \(v\) 称为 \(e\) 的 端点 (Endpoint)

解释以下:

  1. 则 \(E\) 中的每一个元素为一个无序二元组 \((u, v)\),称做 无向边 (Undirected edge),简称 边 (Edge)
    • E是边的集合。这句话指任意一条边能够用两个顶点(u,v)相连表示。
    • 由于(u, v)和(v, u)表示的含义是彻底同样的,因此是无序的
  2. 其中 \(u, v \in V\)。设 \(e = (u, v)\),则 \(u\) 和 \(v\) 称为 \(e\) 的 端点 (Endpoint)
    • 顶点(Vertex)在无向图中有了新名字,u和v连成了边e,那么u和v被称为e的端点

有向图的定义


若 \(G\) 为有向图,则 \(E\) 中的每个元素为一个有序二元组 \((u, v)\),有时也写做 \(u \to v\),称做 有向边 (Directed edge)弧 (Arc),在不引发混淆的状况下也能够称做 边 (Edge)。设 \(e = u \to v\),则此时 \(u\) 称为 \(e\) 的 起点 (Tail),\(v\) 称为 \(e\) 的 终点 (Head),起点和终点也称为 \(e\) 的 端点 (Endpoint)。并称 \(u\) 是 \(v\) 的直接前驱,\(v\) 是 \(u\) 的直接后继。

解释以下:

  1. 则 \(E\) 中的每个元素为一个有序二元组 \((u, v)\),有时也写做 \(u \to v\),称做 有向边 (Directed edge)弧 (Arc)
    • 因为有向图是有方向的,(u, v)和(v, u)表示的含义是不同的,因此说是有序二元组,先后顺序影响结果。
    • 边在有向图中被称为弧(Arc)
  2. 设 \(e = u \to v\),则此时 \(u\) 称为 \(e\) 的 起点 (Tail),\(v\) 称为 \(e\) 的 终点 (Head)。起点和终点也称为 \(e\) 的 端点 (Endpoint)
    • 因为向量的概念,尾巴是起点,头是终点。可是咱们通常不会这么麻烦的去称呼它。
  3. 并称 \(u\) 是 \(v\) 的直接前驱,\(v\) 是 \(u\) 的直接后继。
    • 尾巴是头的前驱,头是尾巴的后继

3.存储图的方式(一)——邻接矩阵

使用一个二维数组 e 来存边,其中 e[u][v] 为 1 表示存在 \(u\) 到 \(v\) 的边,为 0 表示不存在。

若是是带边权的图,能够在 e[u][v] 中存储 \(u\) 到 \(v\) 的边的边权。

邻接矩阵是最基础的存图方式,数组的下标表示了两个,数组的表明了

无向图用邻接矩阵存图


无向图存图的特色是:对于每一个(u,v)对来讲,在相反的位置(v,u)获得的值是同样的。

image-20210720163601097

经过标绿的值能够看出来,无向图的存储是根据斜对角线对称的。

而且还能够发现,对角线(0,0),(1,1),(2,2)等的值所有都是0。这体现了一个概念:本身到本身走不通。

image-20210720172111452

image-20210720172223502

有向图用邻接矩阵存图


有向图存图的特色是:对于每一个(u,v)对来讲,矩阵中只有一个格子会对应。

image-20210720174759199

image-20210720174809756

image-20210720174818647

图论DFS/BFS:查找文献


小K 喜欢翻看博客获取知识。每篇文章可能会有若干个(也有可能没有)参考文献的连接指向别的博客文章。小K 求知欲旺盛,若是他看了某篇文章,那么他必定会去看这篇文章的参考文献(若是他以前已经看过这篇参考文献的话就不用再看它了)。

假设博客里面一共有 n篇文章(编号为 1 到 n)以及 m条参考文献引用关系。目前小 K 已经打开了编号为 1 的一篇文章,请帮助小 K 设计一种方法,使小 K 能够不重复、不遗漏的看完全部他能看到的文章。

这边是已经整理好的参考文献关系图,其中,文献 X → Y 表示文章 X 有参考文献 Y。不保证编号为 1 的文章没有被其余文章引用。

4img

请对这个图分别进行 DFS 和 BFS,并输出遍历结果。若是有不少篇文章能够参阅,请先看编号较小的那篇(所以你可能须要先排序)。

样例输入:
8 9
1 2
1 3
1 4
2 5
2 6
3 7
4 7
4 8
7 8
样例输出:
1 2 5 6 3 7 8 4 
1 2 3 4 5 6 7 8

image-20210723085233524

DFS实现说明


DFS是深度优先搜索的英文缩写(Depth First Search)。深度优先搜索基本上会采用递归的方式进行。

DFS“法”如其名,咱们老是先往深处进行搜索,在深度没法更加深时,会进行回溯。回溯,顾名思义,回到原来的位置。

当一个点经过后,咱们通常都不会重复进入,因此咱们会对其进行“标记”,使其没法再次进入。

【思路说明】:

  1. 每个点只须要走一次,因此和普通DFS搜索同样,走过一个节点就须要将其给标记,使其下次不能再走。
    0表示未标记,1表示已经标记了。

    image-20210722093927668

  2. 一个点可能有n条边,可是这个DFS须要先选择点较小的那个边。因此咱们在dfs多条路径时须要选择通往点最小的那条边。

    image-20210722092744644

  3. 须要输出从小到大的每一个节点,咱们在深搜的时候能够一边搜一边输出。

【代码模拟实现】


  1. 选定起始点s,由题意可得起始点是st = 1。
    初始化vis数组,让每个点都没有被标记。

    memset(vis, 0, sizeof(vis));

    image-20210723090125330

  2. 从1开始dfs,在每一层dfs中,咱们将当前的点设为x

    1. 咱们标记1,并进行输出。循环查找从1点到n点每一个点,也就是e[x][i]

      void dfs(int x) { 
      	vis[x] = 1;
      	cout << x << " ";
          for(int i = 1; i <= n; i++) {
      		if(e[x][i] == 1 && !vis[i]) {
      			dfs(i);
      		}
          }
      }

      image-20210723090227274

    2. 因为咱们是从小到大对数组进行遍历的,因此确定是最小的。咱们找到第一个能够和x连的点,这里是2

      image-20210723090245708

    3. 接着咱们从2节点开始往下深搜,仍是同样的操做,标记2并输出,循环查找从1点到n点每一个点

      image-20210723090255712

    4. 找到第一个和2相连的点5。

      image-20210723090311092

    5. 接着咱们从5节点开始往下深搜,仍是同样的操做,标记5并输出,循环查找从1点到n点每一个点

      image-20210723090322843

    6. 咱们发现5下面并无能够链接的节点,因此咱们返回递归的上一层,也就是2节点这一层,在2节点中从新寻找

      image-20210723090450981

    7. 接着咱们从6节点开始往下深搜,标记6并输出,可是6下面并无节点。

      image-20210723090500811

    8. 因此咱们继续回溯到根的位置

      image-20210723090733878

    9. 咱们选择3这个点进行搜索

      image-20210723090745835

    10. 接着咱们从3节点开始往下深搜,标记3并输出

      image-20210723090757886

    11. 接下来是7,标记7并输出

      image-20210723090808855

    12. 接着回溯后标记4并输出

      image-20210723090818983

    13. 值得注意的是,4 -> 7这条路径因为7这个点被标记了,因此是走不通的

      image-20210723090833654

    14. 最后到8之后输出,最后7到8也是没法实现的。程序结束

      image-20210723090845395

DFS代码实现:

#include <bits/stdc++.h>

using namespace std;

int e[1005][1005];
int vis[1005];
int n, m, a, b, c;
void dfs(int x) {
    vis[x] = 1;
    cout << x << " ";
    for(int i = 1; i <= n; i++) {
        if(e[x][i] == 1 && !vis[i]) {
            dfs(i);
        }
    }

}
int main() {
    cin >> n >> m;
    for(int i = 0; i < m; i++) {
        cin >> a >> b;
        e[a][b] = 1;
    }
    dfs(1);
}

BFS实现说明


【代码模拟实现】

BFS是广度优先搜索的英语缩写(Breadth First Search),要实现BFS,须要使用队列(queue)。队列有先进先出(FIFO First In First Out)的特色。

【思路说明】:

  1. 每个点只须要走一次,因此和普通BFS搜索同样,走过一个节点就须要将其给标记,使其下次不能再走。
    0表示未标记,1表示已经标记了。

    image-20210722093927668

  2. 一个点可能有n条边,可是这个DFS须要先选择点较小的那个边。因此咱们在搜索的时候,要将连着的边按从小到大的顺序放入。

    image-20210722162912424

  3. 队列里面须要顺序存放后面应该有的数

    image-20210722161239137

  4. 须要输出从小到大的每一个节点,在广搜时一边搜索一边输出

【代码模拟实现】


  1. 对于整个代码,咱们须要重置vis数组,而且将队列生成。

    memset(vis, 0, sizeof(vis));
    queue<int> Q;
  2. 和DFS不一样,咱们开始就须要对1号节点进行vis的记录,而且压入队列(push)

    Q.push(1);
    vis[1] = 1;

    image-20210723090903629

  3. 循环至队列中没有元素为止:注意这里的操做,拿到队列头的数据后,当即弹出。
    (由于数据拿到之后这个头就没有任何意义了,先弹出有助于代码的连贯性,很是推荐这种写法)

    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
    }
  4. 弹出队头后输出
    循环塞入在矩阵中相连的全部没有被标记过的点(固然须要从小到大循环)
    假如这个点没有标记,那么标记它

    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
        cout << u << " ";
        for(int i = 1; i <= n; i++) {
            if(e[u][i] == 1 && !vis[i]) {
                Q.push(i);
                vis[i] = 1;
            }
        }
    }

    image-20210723090914640

  5. 接着进入下一轮循环,咱们将2获得后弹出队列,标记与2相连的矩阵元素

    image-20210723091352533

  6. 接着咱们将3弹出队列,标记与3相连的矩阵元素

    image-20210723091404421

  7. 接着咱们将4弹出队列,标记与4相连的矩阵元素:
    可是注意,这里7被标记过了,所以不能再加

    image-20210723091418661

  8. 最后顺序输出队列中的剩余元素

    image-20210723092032065

    image-20210723092045030

    image-20210723092223680

    image-20210723092306011

BFS代码:

memset(vis, 0, sizeof(vis));
queue<int> Q;
Q.push(1);
vis[1] = 1;
while(!Q.empty()) {
    int u = Q.front(); Q.pop();
    cout << u << " ";
    for(int i = 1; i <= n; i++) {
        if(e[u][i] == 1 && !vis[i]) {
            Q.push(i);
            vis[i] = 1;
        }
    }
}

重要拓展:BFS的标记方式

咱们刚才使用的BFS标记方式是常规的标记方式。思路实现和DFS基本一致。

memset(vis, 0, sizeof(vis));
queue<int> Q;
Q.push(1);
while(!Q.empty()) {
    int u = Q.front(); Q.pop();
    if(vis[u]) continue; //注意这两行
    vis[u] = 1; //注意这两行
    cout << u << " ";
    for(int i = 1; i <= n; i++) {
        if(e[u][i] == 1 && !vis[i]) {
            Q.push(i);
        }
    }
}

image-20210723093857420

经过代码比较能够直观的看出,这两份代码的不一样之处就是标记的位置不一样。若是一开始对起始点进行标记,再继续经过内循环标记,这样的方法会致使代码的紊乱。可是咱们一开始不得不用这种方法来标记BFS。
这里牵扯到一个比较细致的问题:

为何咱们从DFS的for循环外标记,变成了for循环内标记呢?

由于DFS每次向下一层,只会拿到一个点,可是BFS却须要在一个循环中搜到多个点

可是这里有一个新的方法,在队列弹出时标记,也就是咱们新的写法。这个写法是通用写法,能够减小你的思考量级

这个写法的惟一坏处就是:队列内的点可能会重复出现

image-20210723113538662

就像这张图同样,1,2,3,4都会使5加入队列中,可是因为5号点并无标记,因此会持续加入队列中。

虽然看上去慢了,可是实际问题中不可能有如此多的边,因此咱们认为这种写法是常量偏大,可是不会影响整体速度

所有代码:

#include <bits/stdc++.h>

using namespace std;

int e[1005][1005];
int vis[1005];
int n, m, a, b, c;
void dfs(int x) {
    cout << x << " ";
    for(int i = 1; i <= n; i++) {
        if(e[x][i] == 1 && !vis[i]) {
            vis[i] = 1;
            dfs(i);
        }
    }
}
int main() {
    cin >> n >> m;
    for(int i = 0; i < m; i++) {
        cin >> a >> b;
        e[a][b] = 1;
    }
    dfs(1);
    cout << endl;
    memset(vis, 0, sizeof(vis));
    queue<int> Q;
    Q.push(1);
    vis[1] = 1;
    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
        cout << u << " ";
        for(int i = 1; i <= n; i++) {
            if(e[u][i] == 1 && !vis[i]) {
                Q.push(i);
                vis[i] = 1;
            }
        }
    }
}

4.单源最短路径Dijkstra的邻接矩阵实现

松弛

"松弛"是一个很是理论性的概念,但总得说来就是三个字:

抄近道

譬如你要从杭州上城区去往绍兴北站,你能够选择直接坐大巴直达,须要2个小时的路程。可是若是你选择地铁转高铁,那可能只须要40分钟。

在图论中,若是不指出点权的话,那么默认换乘这种操做是不须要时间的,也就是说咱们若是能够经过一个中转站到达指定地点,可是比原来快的话,咱们就会选择换乘的这条路线。形式以下:

image-20210723125317685

在这张图中,咱们会选择20 + 40 分钟的这条路

原理说明

单源最短路径是什么意思?

表示从一个点出发到除这个点外的距离

代码实现

5.存储图的方式(二)——邻接表

什么是邻接表?

image-20210723133151360

邻接表长这样,咱们通常分为Head(头)和Node(节点)

头和节点用一句话概况就是:

头连向节点中的每个点。

有向图用邻接表存图

与邻接矩阵相反,咱们先来看有向图的实现方式。

image-20210723133345499

如何理解头连向节点中的每个点。这句话呢?咱们用两幅图看看

image-20210723133732542

image-20210723133743186

0连向2和5两个点。0做为头(Head),而节点(Node)跟在后面。默认,邻接表是由链表构成的。

邻接表的一个特性是:节点值(Node)是不能随意读取的,譬如0到5是否能连上,你只能遍历全部节点。

无向图用邻接表存图

image-20210723134243332

image-20210723134252962

STL库:vector的用法

vector是可变数组

咱们都知道c++中的数组是不能变化容量的,那么也就致使了空间上你无从知晓该开多大

可是vector能够随意的进行插入,容量随即变大。对vector的尾部插入一个数,就是push_back();

读取方式与数组彻底同样。

size()能够拿到当前vector的容量。

vector<int> v;
v.push_back(12);
int c = v[0];
int sz = v.size();

vector数组实现邻接表

咱们用vector的数组形式实现邻接表

每个Head后的Node,都是一组vector

vector<int> e[3];

image-20210723134834209

e[0].push_back(7);

image-20210723134952288

e[1].push_back(9)

image-20210723135058997

e[0].push_back(12);

image-20210723135203596

遍历从i点出发的邻接表

邻接表不能任意读取,通常邻接表用来遍历找出从i点出发能通往的全部点。

int i = st;
for(int j = 0; j < e[i].size(); j++) {
	int v = e[i][j];
}

使用邻接表完成单元最短路径Dijkstra

相关文章
相关标签/搜索