5.图论.基础1

图论理论基础

基本概念

二维坐标中,两点可以连成线,多个点连成的线就构成了图;图也可以只有一个节点,甚至没有节点(空图)。

图的种类可以分为:

  1. 有向图
  2. 无向图
  3. 加权有向图:图中的边有权值
  4. 加权无向图


无向图中有几条边连接该节点,该节点就有几度
有向图中,根据边的箭头方向,可以分为出度,入度

##连通性
连通图
在无向图中,任何两个节点都是可以到达的(直接,间接都算),那么可称之为连通图;如下图左,边两块是隔断的,互不相通的。

强连通图
在有向图中,任何两个节点是可以互相到达的,可以称之为强连通图;如下图就不是,例如1是无法被到达的。
在这里插入图片描述

连通分量
在无向图中的极大连通子图称之为该图的一个连通分量。如下图1,2,3;3,4,6可以相互到达,而且是极大的,因此构成无向图中的一个连通分量;
在这里插入图片描述

强连通分量
在有向图中极大强连通子图称之为该图的强连通分量。1,2,3,4,5构成的子图是强连通图,也是极大图;6,7,8构成的子图不是强连通图,也不是强连通分量。
在这里插入图片描述


图的构造

如何用代码表示一个图呢;一般使用邻接表邻接矩阵 或者用来表示;主要是 朴素存储、邻接表和邻接矩阵

邻接矩阵
使用 二维数组来表示图结构,是从节点的角度来表示图,有多少节点就申请多大的二维数组
表示有向图: grid[2][5] = 6,表示 节点 2 连接 节点5 且边的权值为6 为有向图
表示无向图:grid[2][5] = 6,grid[5][2] = 6,表示节点2,5互相连通,权值为6

表示方法的特点:

  1. 在 边少,节点多的情况下,会导致申请过大的二维数组,造成空间浪费(遇到稀疏图)
  2. 在遍历节点的连接情况时,需要遍历整个矩阵,时间复杂度为n^2,造成时间浪费
  3. 表达方式简单,易于理解
  4. 便于检查两个顶点间是否存在边
  5. 适合稠密图(边多节点少),在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法

邻接表
在这里插入图片描述
数组存放的是所有节点,而且有多少边,邻接表才会申请多少个对应的链表节点。

表示方法的特点:

  1. 对于稀疏图,只需要存储边,空间利用率高
  2. 遍历节点连接情况相对容易
  3. 检查两个顶点间是否存在边,效率较低
  4. 实现相对复杂,不易理解

图的遍历方式

图的遍历方式基本是两大类

  • 深度优先搜索dfs
  • 广度优先搜索bfs
    在图论章节,则是在图(邻接表,邻接矩阵)上进行搜索。

图论章节代码随想录会深入讲解 深度优先搜索(DFS)、广度优先搜索(BFS)、并查集、拓扑排序、最小生成树系列、最短路算法系列等等


深度优先搜索理论基础

dfs与bfs区别

  • dfs是可一个方向去搜,直到遇到该路径的终点了,搜不下去了,再换方向(换方向的过程就涉及到了回溯)
  • bfs是先把本节点所连接的所有节点遍历一遍,走到下一个节点的时候,再把连接节点的所有节点遍历一遍,搜索方向更像是广度,四面八方的搜索过程

dfs搜索过程

在这里插入图片描述

  • 搜索方向,是认准一个方向搜,直到碰壁之后再换方向
  • 换方向是撤销原路径,改为节点链接的下一个路径,回溯的过程

dfs三部曲

因为dfs搜索可一个方向,并且需要回溯,所以递归的方式实现起来最方便。

// dfs搜索框架
void dfs(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本节点所连接的其他节点) {
        处理节点;
        dfs(图,选择的节点); // 递归
        回溯,撤销处理结果
    }
}

// 回溯算法框架
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }
    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

其实从上面dfs搜索框架和回溯算法框架可以看出两个是十分相似的,实现起来分为3步:

  1. 确认递归函数,参数
    在写递归函数的时候,发现需要什么参数,再去补充就可以;深搜需要 二维数组数组结构保存所有路径,需要一维数组保存单一路径,这种保存结果的数组,我们可以定义一个全局变量,避免让我们的函数参数过多
  2. 确认终止条件
    终止条件不仅是本层递归的结束标志,也是收获结果的标志;可能会看到其实很多dfs写法,没有写终止条件,其实终止条件写在了, 下面dfs递归的逻辑里了,也就是不符合条件,直接不会向下递归
  3. 处理目前搜索节点出发的路径(单层递归逻辑)
    一般这里就是一个for循环的操作,去遍历 目前搜索节点 所能到的所有节点

广度优先搜索理论基础

bfs使用场景

广搜的搜索方式就适合于解决两个点之间的最短路径问题,因为bfs是从起点出发,以起点为中心一圈一圈进行搜索,一旦到达终点,记录之前走过的节点就是一条最短路。
在这里插入图片描述
网上的资料都是直接说用队列来实现,事实上仅仅需要一个容器,能保存我们要遍历过的元素就可以,那么用队列,还是用栈,甚至用数组,都是可以的。
这是什么情况下会使用的?

  • 用队列,保证每一圈都是一个方向去转,例如统一顺时针或者逆时针;
  • 用栈,第一圈顺时针遍历,第二圈逆时针遍历,第三圈有顺时针遍历(栈是先进后出,加入元素和弹出元素的顺序改变了);

而bfs不需要注意转圈的顺序

并查集理论基础

并查集常用来解决连通性问题;需要判断两个元素是否在同一个集合里的时候

  • 将两个元素添加到一个集合中
  • 判断两个元素在不在一个集合里

我们将三个元素A,B,C (分别是数字)放在同一个集合,其实就是将三个元素连通在一起,可以使用一维数组表示他们之间的连通性,即:father[A] = B,father[B] = C 这样就表述 A 与 B 与 C连通了(有向连通图)。

// 将v,u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

以上体现的是这里要讲到寻根思路,只要 A ,B,C 在同一个根下就是同一个集合,find函数的实现就是通过数组下标找到数组元素,另外根的father就是它自己(这一点体现在father数组的初始化中)。

// 并查集里寻根的过程
int find(int u) {
    if (u == father[u]) return u; // 如果根就是自己,直接返回
    else return find(father[u]); // 如果根不是自己,就根据数组下标一层一层向下找
}

路径压缩

除了根节点其他所有节点都挂载根节点下,这样我们在寻根的时候就很快,只需要一步,如果我们想达到这样的效果,就需要 路径压缩,将非根节点的所有节点直接指向根节点。

int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 修改了father[]数组的结构
}

代码模板

整体模板C++代码如下

int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

通过该并查集模板,我们可以知道并查集的三个功能;

  • 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
  • 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
  • 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点

误区

为什么join函数在加入并集前需要寻根?而不是直接通过isSame判断?

// 将v->u 这条边加入并查集
void join(int u, int v) {
    if (isSame(u, v)) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

join(1, 2);
join(3, 2);

在这里插入图片描述
此时问 1,3是否在同一个集合,我们调用 join(1, 2); join(3, 2); 很明显本意要表示 1,3是在同一个集合;但以上代码所得出的结果并不相符合。
在这里插入图片描述
通过正确的join函数得出的结果应该如上图,我们有find函数进行寻根的过程,这样就保证元素 1,2,3在这个有向图里是强连通的。find(1) 返回的是3,find(3) 返回的也是3,return 3 == 3 返回的是true,即告诉我们 元素 1 和 元素3 是 在同一个集合里的。

扩展

在「路径压缩」讲解中,我们知道如何靠压缩路径来缩短查询根节点的时间,
其实还有另外一种方法:按秩(rank)合并。rank表示树的高度,即树中结点层次的最大值。

例如两个集合(多叉树)需要合并,如图所示
在这里插入图片描述
当我们需要将两个不同rank的树合并时,有两种不同的做法
在这里插入图片描述
一定是 rank 小的树合入 到 rank大 的树,这样可以保证最后合成的树rank 最小,降低在树上查询的路径长度。

合并代码如下

int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构
vector<int> rank = vector<int> (n, 1); // 初始每棵树的高度都为1

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
        rank[i] = 1; // 也可以不写
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : find(father[u]);// 注意这里不做路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根

    if (rank[u] <= rank[v]) father[u] = v; // rank小的树合入到rank大的树
    else father[v] = u;

    if (rank[u] == rank[v] && u != v) rank[v]++; // 如果两棵树高度相同,则v的高度+1,因为上面 if (rank[u] <= rank[v]) father[u] = v; 注意是 <=
}

上面的模板代码中,我是没有做路径压缩的,因为一旦做路径压缩,rank记录的高度就不准了,根据rank来判断如何合并就没有意义。其实我们在优化并查集查询效率的时候,只用路径压缩的思路就够了,不仅代码实现精简,而且效率足够高。按秩合并的思路并没有将树形结构尽可能的扁平化,所以在整理效率上是没有路径压缩高的

prim算法

最小生成树是所有节点的最小连通子图, 即:以最小的成本(边的权值)将图中所有节点链接到一起。图中有n个节点,那么一定可以用 n - 1 条边将所有节点连接到一起,就是 最小生成树算法的任务所在。

prim算法 是从节点的角度 采用贪心的策略 每次寻找距离 最小生成树最近的节点 并加入到最小生成树中:

  1. 选距离生成树最近节点——从mindist数组中选择距离 最小生成树(已经遍历过的节点) 最近的非生成树里的节点
  2. 最近节点加入生成树
  3. 更新非生成树节点到生成树的距离(即更新minDist数组)

minDist数组 用来记录 每一个节点距离最小生成树的最近距离。 理解这一点非常重要,这也是 prim算法最核心要点所在
在这里插入图片描述
出于堆该假设“图中有n个节点,那么一定可以用 n - 1 条边将所有节点连接到一起”的考虑,当你选择根节点后,假定每个子节点都有唯一父节点指向,那么采用贪心算法,考虑从根节点出发,每一次被连接子节点都采用能够被到达的节点的边的最小权值即可,而mindist上记录的正是每次在考虑到达i节点下,对应mindist下标节点到达i的最短距离。

代码框架如下:

    // 所有节点到最小生成树的最小距离
    vector<int> minDist(v + 1, 10001);

    // 这个节点是否在树里
    vector<bool> isInTree(v + 1, false);

    // 我们只需要循环 n-1次,建立 n - 1条边,就可以把n个节点的图连在一起
    for (int i = 1; i < v; i++) {
        // 1、prim三部曲,第一步:选距离生成树最近节点
        int cur = -1; // 选中哪个节点 加入最小生成树
        int minVal = INT_MAX;
        for (int j = 1; j <= v; j++) { // 1 - v,顶点编号,这里下标从1开始
            //  选取最小生成树节点的条件:
            //  (1)不在最小生成树里
            //  (2)距离最小生成树最近的节点
            if (!isInTree[j] &&  minDist[j] < minVal) {
                minVal = minDist[j];
                cur = j;
            }
        }
        // 2、prim三部曲,第二步:最近节点(cur)加入生成树
        isInTree[cur] = true;

        // 3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)
        // cur节点加入之后, 最小生成树加入了新的节点,那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下
        // 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢
        for (int j = 1; j <= v; j++) {
            // 更新的条件:
            // (1)节点是 非生成树里的节点
            // (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小
            // 很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了
            if (!isInTree[j] && grid[cur][j] < minDist[j]) {
                minDist[j] = grid[cur][j];
            }
        }

Kruskal算法

与prim 算法是维护节点的集合不同,Kruskal 是维护边的集合。Kruscal的思路:

  • 边的权值排序,因为要优先选择最小的边加入到生成树里
  • 遍历排序后面的边:1)如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环;如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合。

将图中的边按照权值有小到大排序,这样从贪心的角度来说,优先选 权值小的边加入到 最小生成树中:1.开始从头遍历排序后的边;2.判断两个节点是否在同一个集合(使用到并查集),就看图中两个节点是否有绿色的粗线连着就行。当遍历完所有边之后,我们就能就能得到一个最小生成树(前提是所给的边足够)

    // 执行Kruskal算法
    // 按边的权值对边进行从小到大排序
    sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
            return a.val < b.val;
    });

    // 并查集初始化
    init();

    // 从头开始遍历边
    for (Edge edge : edges) {
        // 并查集,搜出两个节点的祖先
        int x = find(edge.l);
        int y = find(edge.r);

        // 如果祖先不同,则不在同一个集合
        if (x != y) {
            result_val += edge.val; // 这条边可以作为生成树的边
            join(x, y); // 两个节点加入到同一个集合
        }
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值