图论模板题及分析

前言

经过之前的基础铺垫,现在进入了图论,从拓扑排序进场,然后将图论中的几种使用方法和对应算法复杂度进行拆分,其实在做了很多图论相关的提目后,会发现图论可以通过三种常见的模板解法进行解决。
存图:

  1. 使用邻接表,add(a,b),add(a,b,c)等也存在模板赋值。
  2. 使用typedef PII,也就是pair之后存点位及其距离,是邻接表的简易化。
  3. 另一种是struct结构体{。。。}的赋值。

解决图

  1. 层层筛选,也就是从源点找可到达的一堆点里对少的距离,然后加进去和对比直达。for套for
  2. 一点代面,源点接图中任一点,一点一点蚕食掉图,for,有选择的while筛选
  3. 结合小跟堆排序之后,的对接距离。

从整体来讲,结合实际应用题,要考虑的有图论建表,也要有dist的选择min比对。下图展示了基础图论中算法,之后以例题的形式进行讲解。
图论模板题及分析_第1张图片

/*
若E是有向边的有限集合,则图G被称为有向图,弧是顶点的有序对。
无向边的有限集合,无向图
一个图满足,不存在重复边,不存在顶点到自身的边,(重复和自身闭环)简单图正好相反的叫做复杂图
在任意两点之间都存在两个顶点之间的边叫做有向完全图,任意两点之间存在方向相反的图无向完全图
子集&衍生图,并非任何子集都能构成子图,因为这样的子集有可能不是图
任意两点是连通的叫做连通图,有向图任意两点之间路径可回溯,叫做强连通图,研究的是强连通性
无向图的极大连通子图称为连通分量 讨论的是连通性
*/
/*稀疏图的数据前提是E
#include 
#include
using namespace std;
#define maxsize 50
typedef struct Mgraph {
    char vex[maxsize];//顶点表,就是存顶点在插入的时候遍历的主体
    int edge[maxsize][maxsize];//数组存储
    int vexnum, arcnum;//对应的顶点数和弧数,邻接矩阵实现结构体
}Mgraph;
//邻接表3个结构体互相嵌套,实现的效果和矩阵一样,但是更节省空间,等一下展示用数组模拟的邻接表
typedef struct arcnode {
    int adjvex;//该弧顶点
    arcnode* next;//指向的下一个结点 ne[idx]=h[a];
}arcnode;//
typedef struct Vnode {
    int data;//顶点信息,指向第一条依附该顶点的弧指针 e[idx]=b;h[a]=idx++;
    arcnode* first;
}Vnode,Vnodelist[maxsize];
typedef struct {
    Vnodelist vertices;//比数组存的优势点,省得遍历计算其顶点数和弧数
    int vexnum, arcnum;
}Algraph;//表法边和点有关,矩阵法和点有关
/*
邻接表就是数组模拟链表,图中每个节点拉出一条链,存储其所有邻边,
e[i]表示邻边的另一个端点,w[i]表示邻边长度,
ne[i]表示链表的下一个节点下标,
idx表示当前用到第几个下标
邻接多重表是无向图的存储结构
十字链表是有向图的存储结构
*/
/*BFS与DFS一样
领接矩阵的时间复杂度是v^2最坏空间复杂度是v
邻接表时间复杂度是v+e,最坏空间复杂度是v

*/
const int N = 1e5 + 10;
int ne[N], idx, h[N], e[N],n;
void add(int a, int b) {
    e[idx] = b;
    ne[idx] = h[a];
    h[a] = idx++;
}
void Convert(Algraph  G, int arc[maxsize][maxsize]) {//将邻接表变成邻接矩阵
    for (int i = 0; i < maxsize; i++) {
        arcnode* p = G.vertices[i].first;//顶点i的第一条出边
        while (!p) {
            arc[i][p->adjvex] = 1;
            p = p->next;
        }
    }
}
//常用的是深搜作为检验表的算法
/*void DFS(Graph G, int v) {
    visit(v);				//访问且标记
    visited[v] = true;
    for (w = G, FirstNeighbor(G, v); w >= 0; w = G.NextNeighbor(G, v, w)) {	//后面有边就继续递归
        if (!visited[w])
            DFS(G, w);
    }
}
*/
//数组模拟的时候可以用topsort来确定是不是一个完整的闭环
int hh = 0, tt = -1, q[N], d[N];
void topsort() {//是否是闭环要看出入度的问题
    for (int i = 1; i <= n; i++) {
        if (d[i] == 0)q[++tt] = i;
    }
    while (tt >= hh) {
        int a = q[hh++];
        for (int i = h[a]; i != -1; i = ne[i]) {
            int b = e[i];
            d[b]--;
            if (d[b] == 0)q[++tt] = b;
        }
    }
    if (tt == n - 1) {
        for (int i = 0; i < n; i++)cout << q[i] << " ";
    }
    else cout << -1;
}
//最小生成树
/*generic_mst(G) {
    T = NULL;
    while T未形成树
        do 找到一条最小边加入且不成回路
            T中加入
}构建的最小生成树的总边数是顶点数-1
prim算法是从外集合中选取最小的值,稠密图
krustra是权值最小,去尝试构建最小连通图,稀疏图
*/
void Prim(int v)   //从顶点v出发
{
    int i, j, k;
    int adjvex[maxsize], lowcost[maxsize];
    for (i = 0; i < n; i++)    //初始化辅助数组
    {    // 先把与v相邻的边赋值到lowcost中, 在lowcost中寻找最短边 
        lowcost[i] = edge[v][i]; adjvex[i] = v;
    }
    lowcost[v] = 0;                //将顶点v加入集合U,lowcost[i]=0;表示将其加入到U中 U={v,i} 
    for (k = 1; k < n; k++)   //迭代n-1次
    {
        j = MinEdge(lowcost, vertexNum);//寻找最短边的邻接点j 
        cout << "(" << vertex[j] << "," << vertex[adjvex[j]] << ")" << lowcost[j] << endl;
        lowcost[j] = 0;                 //顶点j加入集合U,本身到本身的距离为0 
        for (i = 0; i < vertexNum; i++) //调整辅助数组
            if (edge[i][j] < lowcost[i]) { //将j[新增点]的相连边的距离更新到lowcost 
                lowcost[i] = edge[i][j];
                adjvex[i] = j;
            }
    }
}

void Kruskal()
{
    int num = 0, i, vex1, vex2;
    int parent[vertexNum];                   //双亲表示法存储并查集
    for (i = 0; i < vertexNum; i++)
        parent[i] = -1;                      //初始化n个连通分量
    for (num = 0, i = 0; num < vertexNum - 1; i++)//依次考察最短边
    {
        vex1 = FindRoot(parent, edge[i].from);
        vex2 = FindRoot(parent, edge[i].to);
        if (vex1 != vex2) {                  //位于不同的集合
            cout << "(" << vertex[edge[i].from] << "," << vertex[edge[i].to] << ")" << vertex[edge[i].weight] << endl;
            parent[vex2] = vex1;              //合并集合
            num++;
        }
    }
}

模板题

有向图拓扑存在

图论模板题及分析_第2张图片
拓扑图直观的理解是能否到达所有点,有源点到达最后一个点。
做题思路则是,入度为0的点作为开头进入队列里,将其所到点依靠的边依次加入,其入的下一个点的入度-1,然后弹出入度为零的点,所有点都进入过,并且操作后点队列无剩余则是一个完整的拓扑序列。
引入邻接表模板,在并查集那一章进行过解释。

int ne[N],idx,h[N],e[N];
void add(int a,int b){
    e[idx]=b;
    ne[idx]=h[a];
    h[a]=idx++;
}
邻接表就是数组模拟链表,图中每个节点拉出一条链,存储其所有邻边,
e[i]表示邻边的另一个端点,w[i]表示邻边长度,
ne[i]表示链表的下一个节点下标,
idx表示当前用到第几个下标

topsort为功能函数,执行了上面的算法解析

hh=0,tt=-1,q[N],d[N],;
/*说是队列,如果是需要输出拓扑经过的话,使用数组,
如果只是需要输出到底是不是一个拓扑序列,用队列更简洁*/
void topsort(){
    for(int i = 1; i <= n; i++){//遍历一遍顶点的入度。
        if(d[i] == 0)//如果入度为 0, 则可以入队列
            q[++tt] = i;
    }
    while(tt >= hh){//循环处理队列中点的
        int a = q[hh++];
        for(int i = h[a]; i != -1; i = ne[i]){//循环删除 a 发出的边
            int b = e[i];//a 有一条边指向b
            d[b]--;//删除边后,b的入度减1
            if(d[b] == 0)//如果b的入度减为 0,则 b 可以输出,入队列
                q[++tt] = b;
        }
    }
    if(tt == n - 1){//如果队列中的点的个数与图中点的个数相同,则可以进行拓扑排序
        for(int i = 0; i < n; i++){//队列中保存了所有入度为0的点,依次输出
            cout << q[i] << " ";
        }
    }
    else//如果队列中的点的个数与图中点的个数不相同,则可以进行拓扑排序
        cout << -1;//输出-1,代表错误
}

数据获取初始化

int main(){
    cin >> n >> m;//保存点的个数和边的个数
    memset(h, -1, sizeof h);//初始化邻接矩阵
    while (m -- ){//依次读入边
        int a, b;
        cin >> a >> b;
        d[b]++;//顶点b的入度+1
        add(a, b);//添加到邻接矩阵
    }
    topsort();//进行拓扑排序
    return 0;
}

以上就是拓扑排序的模板题,在呈现数据初始获取代码块部分之后就不会再次赘述,如果存在变化的话会特别提出对于数据的处理。
##dijkstra 求最短路径
dijksra用于求基于正数边的最短路径问题,有稠密图和稀疏图两种,同时根据不同的图分为两种解决方案,但是有3种模板供给选择

朴素Dijkstra

图论模板题及分析_第3张图片

  1. 用一个 dist 数组保存源点到其余各个节点的距离,dist[i] 表示源点到节点 i 的距离。初始时,dist 数组的各个元素为无穷大。
    用一个状态数组 state 记录是否找到了源点到该节点的最短距离,state[i] 如果为真,则表示找到了源点到节点 i 的最短距离,state[i] 如果为假,则表示源点到节点 i 的最短距离还没有找到。初始时,state 各个元素为假。
  2. 遍历 dist 数组,找到一个节点,这个节点是:没有确定最短路径的节点中距离源点最近的点。假设该节点编号为 i。此时就找到了源点到该节点的最短距离,state[i] 置为 1。
  3. 遍历 i 所有可以到达的节点 j,如果 dist[j] 大于 dist[i] 加上 i -> j 的距离,即 dist[j] > dist[i] + w[i][j](w[i][j] 为 i -> j 的距离) ,则更新 dist[j] = dist[i] + w[i][j]。
  4. 重复 2 3 步骤,直到所有节点的状态都被置为 1。
    dijkstra算法是从各个源节点互通点找最短,逻辑代码块
int dist[n],state[n];
dist[1] = 0, state[1] = 1;
for(i:1 ~ n)
{
    t <- 没有确定最短路径的节点中距离源点最近的点;
    state[t] = 1;
    更新 dist;
}

代码实现

int h[N], e[M], ne[M], w[M], idx;//邻接表存储图
int state[N];//state 记录是否找到了源点到该节点的最短距离
int dist[N];//dist 数组保存源点到其余各个节点的距离
int n, m;//图的节点个数和边数
void add(int a, int b, int c)//插入权重边,也是邻接表模板
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void Dijkstra()
{
    memset(dist, 0x3f, sizeof(dist));//dist 数组的各个元素为无穷大
    dist[1] = 0;//源点到源点的距离为置为 0
    for (int i = 0; i < n; i++)
    {
        int t = -1;
        for (int j = 1; j <= n; j++)//遍历 dist 数组,找到没有确定最短路径的节点中距离源点最近的点t
        {
            if (!state[j] && (t == -1 || dist[j] < dist[t]))
                t = j;
        }

        state[t] = 1;//state[i] 置为 1。因为我们将a1作为源点,已经遍历
        for (int j = h[t]; j != -1; j = ne[j])//遍历 t 所有可以到达的节点 i
        {
            int i = e[j];
            dist[i] = min(dist[i], dist[t] + w[j]);//更新 dist[j]
        }
    }
}

堆优化

在距离的比较选择中我们已经不能再优化,所以从最短边入手,采用对插入排序最短边,每次堆顶就是最小边,使用系统里内含的小跟堆函数操作,节省了很多时间,而且堆排序的时间复杂度是logn,适用于稀疏图

int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);//距离初始化为无穷大
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;//小根堆画重点
    heap.push({0, 1});//插入距离和节点编号
    while (heap.size())
    {
        auto t = heap.top();//取距离源点最近的点
        heap.pop();

        int ver = t.second, distance = t.first;//ver:节点编号,distance:源点距离ver 的距离

        if (st[ver]) continue;//如果距离已经确定,则跳过该点
        st[ver] = true;
        for (int i = h[ver]; i != -1; i = ne[i])//更新ver所指向的节点距离
        {
            int j = e[i];
            if (dist[j] > dist[ver] + w[i])
            {
                dist[j] = dist[ver] + w[i];
                heap.push({dist[j], j});//距离变小,则入堆
            }
        }
    }
    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

Bellman-ford

迪杰斯特拉算法适用于求正权有向图中,源点到其余各个节点的最短路径。如果遇到负数的回环就会直接循环下去变成无穷小。
所以在面对存在负数边的时候使用的bellman或者spfa。

  • 简化理解从i到j要走n-1个边,先置dist为无穷用起点去放缩,并且用flag来优化放缩的记录
  • 如果dist[i]+w[i]不在变动,那么之后的n-i+1次循环就可以勿略掉了,因为权值的负数存在,也就是spfa在dijskra算法的优势

图论模板题及分析_第4张图片
Bellman - ford 算法是求含负权图的单源最短路径的一种算法,效率较低,代码难度较小。其原理为连续进行松弛,在每次松弛时把每条边都更新一下,若在 n-1 次松弛后还能更新,则说明图中有负环,因此无法得出结果,否则就完成。
下面重点解释一下松弛操作

for n次
for 所有边 a,b,w (松弛操作)
dist[b] = min(dist[b],back[a] + w)

注意:back[] 数组是上一次迭代后 dist[] 数组的备份,由于是每个点同时向外出发,因此需要对 dist[] 数组进行备份,若不进行备份会因此发生串联效应,影响到下一个点。backup是要上一组的数据,这时候引入一个系统自带的函数,memcpy
图论模板题及分析_第5张图片
同时在0x3f3f3f3f等涉及到INF时,为什么有/2的操作,因为负数操作虽然在理解中是无限的,但是在实际操作中也会涉及其循环迭代的变换,也许几个+或者-的细微操作也会踩雷,故用/2的直接大跨步解决这种可能性。
bellman算法时,我们采用了struct结构体的方式初始化存储数据

struct Edge {//只需要将边遍历一遍,用结构体数组储存即可
    int a, b, c;
} edges[M];
int main()
for (int i = 0; i < m; i++) {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        edges[i] = {x, y, z};
    }

    bellman_ford();

bellman算法模板题

void bellman_ford(){
    memset(dist,0x3f,sizeof dist);
    dist[1]=0;
    for(int i=0;i<k;i++){
    //保证第k次时,dist[n]是走过k步及以内的最短路。
    //第k+5次时dist[n]的最短路第k+5次时再确定!!!
        memcpy(backup,dist,sizeof dist);//保留上一次迭代的dist的副本,防止跨层数更新
        for(int j=0;j<m;j++){
            int a=edge[j].a,b=edge[j].b,w=edge[j].w;
            dist[b]=min(dist[b],backup[a]+w);
            //用已确定最短路的点向外延申,类似于dijkstra
        }
    }
}

dist[N]表示从起点到当前点的当前最短距离
backup[j]表示每次进入第2重循环的dist数组的备份

  1. (外重循环)循环i从1到n,遍历n次指的:是不经过i条边到达终点的最短距离
    经过n次操作n个点的最短距离也就确定了;n-1个边
  2. (内重循环)循环j从1到m,遍历m条边,把所有边都进行松弛操作;
    每次取出两点以及他们连接的边的权重(a,b,w表示a—>b的一条边);
    用从起点到a的当前最短距离+权重来更新从起点到b的当前最短距离;
    dist[b]=min(dist[b],dist[a]+w);
  3. 为什么跑完算法就能算出最短距离呢
    因为第二重循环遍历了m条边,每条都被遍历了n次;
    所以这n个点的所有他的前驱后继相对应的边权一定都被遍历到了
    又因为他是有松弛操作的,所以只要上一个点(前驱)的当前最短路求出来了
    这个点就可以用他的前驱来更新他的最短距离,从而他的后继又可以用它来更新最短距离了
int Bellman_ford(){
    memset(d,0x3f,sizeof d);
    d[1]=0;
    for(int i=0;i<k;i++){
        memcpy(backup,d,sizeof d);//备份数组
        int falg=0;
        for(int j=0;j<m;j++){
            int u=edges[j].u,v=edges[j].v,w=edges[j].w;
            if(执行了松弛d[v]=min(d[v],backup[u]+w))falg=1}
        //如果执行了这个点后,flag没被更新,就说明已经是最优解了,所以加一个判断就能优化时间
        if(flag!=1) break;
    }
    if(d[n]>INF/2) return -1;
    return d[n];
}

SPFA求最短路径(负)

spfa算法中,采用邻接表的方式,只用到有效的点(更新了临边的点),直到每个点都是最短距离为止。采用队列优化的方式存储每次更新了的点,每条边最多遍历一次。如果存在负权回路,从起点1出发,回到1距离会变小, 会一直在三个点中循环。
可以理解为队列+邻接表,但我们可以发现堆优化过后的dijstra算法也是队列+邻接表,如果深对比一下会发现,堆优化过后一直呈现的是已经有单调性的队列,SPFA中涉及到负数边,排序没有什么意义,所以用到topsort排列过程中的出入队的思想

  1. 建立一个队列,初始时队列里只有起始点。
  2. 再建立一个数组记录起始点到所有点的最短路径(该表格的初始值要赋为极大值,该点到他本身的路径赋为0)。
  3. 再建立一个数组,标记点是否在队列中。
  4. 队头不断出队,计算始点起点经过队头到其他点的距离是否变短,如果变短且被点不在队列中,则把该点加入到队尾。重复执行直到队列为空。
  5. 在保存最短路径的数组中,就得到了最短路径。
  • 结合bellman-ford算法的,spfa在收缩的时候也是dist于0和0x3f之间的重新确定长度,但是优化的点是在已经放缩过的点基础上进行bellman-ford,并且在队列里可以重复入队的融合BFS,也就是面临负权值图的解决方法。
void spfa(){
    q[++tt] = 1;//从1号顶点开始松弛,1号顶点入队
    dist[1] = 0;//1号到1号的距离为 0
    st[1] = 1;//1号顶点在队列中
    while(tt >= hh){//不断进行松弛只要队列没空
        int a = q[hh++];//取对头记作a,进行松弛
        st[a] = 0;//取完队头后,a不在队列中了
        for(int i = h[a]; i != -1; i = ne[i])//遍历所有和a相连的点
        {
            int b = e[i], c = w[i];//获得和a相连的点和边
            if(dist[b] > dist[a] + c){//如果可以距离变得更短,则更新距离
                dist[b] = dist[a] + c;//更新距离
                if(!st[b]){//如果没在队列中
                    q[++tt] = b;//入队
                    st[b] = 1;
 //打标记,这个可以重复入队,弹出了也可以,但不能同时存在,如果已经存在的话那就不加入队列
                }
            }
        }
    }
}

SPFA判断负环

图论模板题及分析_第6张图片
统计当前每个点的最短路中所包含的边数,如果某点的最短路所包含的边数大于等于n,则也说明存在环

int spfa(){
    queue<int> q;
    //1不一定是通过负环到达所有点, 将所有可能的点都加入
    //注意:第一次出队的,自己到自己也可能存在负环
    for(int i = 1; i <= n; i ++){
        st[i] = true;
        q.push(i);
    }

    while(q.size()){
        int t = q.front();
        q.pop();
        st[t] = false; //取出的元素不在队列
        for(int i = h[t]; i != -1; i = ne[i]){
            int j = e[i];
            if(dist[j] > dist[t] + w[i]){
                dist[j] = dist[t] + w[i];  //更新距离
                cnt[j] = cnt[t] + 1; 
//如果大于等于n,则出现自己到自己的负环情况,则终止循环,表示存在负环
                if(cnt[j] >= n) return true;
                if(!st[j]){ //如果不在队列中
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }
    return false;
}

cnt[x] 记录当前x点到虚拟源点最短路的边数,初始每个点到虚拟源点的距离为0,只要他能再走n步,即cnt[x] >= n,则表示该图中一定存在负环,由于从虚拟源点到x至少经过n条边时,则说明图中至少有n + 1个点,表示一定有点是重复使用。
该题是判断是否存在负环,并非判断是否存在从1开始的负环,因此需要将所有的点都加入队列中,更新周围的点

Floyd 求最短路

图论模板题及分析_第7张图片
手算迭代,第一步是存图,每一个点存进去,然后以某一点为轴进行交叉计算最短路径去更新。迭代的图表到最后就是整个最短路径了,是比较全面但是复杂度很高。
多源汇最短路径问题,floyd更像是dp包对于状态的考虑
图论模板题及分析_第8张图片

核心状态转换 dist[i][j] = min(dist[i][j],dist[i][k] + dist[k][j])
void floyd(){
    for(int k = 1; k <= n; k ++){
        for(int i = 1; i <= n; i ++){
            for(int j = 1; j <= n; j ++){
                dist[i][j] = min(dist[i][j],dist[i][k] + dist[k][j]);
            }
        }
    }
}

int main(){
    cin >> n >> m >> q;
    memset(dist,0x3f,sizeof dist);
    for(int i = 1; i <= n; i ++) dist[i][i] = 0;
    //自身到自身应该是0,闭环时会冲突
    while(m --){
        int a,b,c;
        cin >> a >> b >> c;
        dist[a][b] = min(dist[a][b],c);//保证每条边都是现行的最短边
    }

Prim算法求最小生成树

图论模板题及分析_第9张图片
Prim像是由源点逐点入侵生成自己的点集然后构建一棵树

prim大体上来看是这样干活的:

1.找到集合外与集合最近的一个点
2.用他更新其他点与集合的距离
3.将他放到集合中,更新答案

边界条件处理

1.开始时集合内没有点,要将第一个点放进去(其实那个都可以)
2.更新答案前要注意,如果现在的距离为无穷大,那么说明没有最小生成树
3.更新答案要放在更新其他点之前,否则若是有自环就会自己更新自己,除非特判

对于基础图论点集的处理以及伪代码

/*
S:当前已经在联通块中的所有点的集合
1. dist[i] = inf
2. for n 次
    t<-S外离S最近的点
    利用t更新S外点到S的距离
    st[t] = true
n次迭代之后所有点都已加入到S中
联系:Dijkstra算法是更新到起始点的距离,Prim是更新到集合S的距离
*/
int main()
{
    cin >> n >> m;
    memset(g, 0x3f, sizeof g);
    while (m -- ) {
        int a, b, c;
        cin >> a >> b >> c;
        g[a][b] = g[b][a] = min(g[a][b], c);//无向图里只保留最短路径
    }

在通过思路和代码的对比,我们会发现

Prim算法与Dijkstra算法的区别
Dijkstra算法是更新不在集合中的点离起点的距离也就是线长
dist[j]=min(dist[j], dist[t]+g[t][j])
Prim是更新不在集合中的点 离集合S的距离
dist[j] = min(dist[j], g[t][j])
void prim()
{
    memset(dt,0x3f, sizeof(dt));//初始化距离数组为一个很大的数(10亿左右)
    int res= 0;
    dt[1] = 0;//从 1 号节点开始生成 
    for(int i = 0; i < n; i++)//每次循环选出一个点加入到生成树加入n个结点
    {
        int t = -1;
        for(int j = 1; j <= n; j++)//每个节点一次判断
        {
            if(!st[j] && (t == -1 || dt[j] < dt[t]))//如果没有在树中,且到树的距离最短,则选择该点
                t = j;
        }
 //如果孤立点,直返输出不能,然后退出,例如有7个点一个图,另一个单点但也是完整的闭环,无法搭建成生成树
        if(dt[t] == 0x3f3f3f3f) {
            cout << "impossible";
            return;
        }
        st[t] = 1;// 选择该点存放后生成新的树
        res += dt[t];
        for(int i = 1; i <= n; i++)//更新生成树外的点到生成树的距离
        {
            if(dt[i] > g[t][i] && !st[i])//从 t 到节点 i 的距离小于原来距离,则更新。
            {
                dt[i] = g[t][i];//更新距离
                pre[i] = t;//从 t 到 i 的距离更短,i 的前驱变为 t.
            }
        }
    }
    cout << res;
}

Kruskal算法求最小生成树

图论模板题及分析_第10张图片
划重点是最小生成树的边路和
将所有边按照权值的大小进行升序排序,然后从小到大一一判断。

如果这个边与之前选择的所有边不会组成回路,就选择这条边分;反之,舍去。

直到具有 n 个顶点的连通网筛选出来 n-1 条边为止。

筛选出来的边和所有的顶点构成此连通网的最小生成树。

判断是否会产生回路的方法为:使用并查集。

在初始状态下给各个个顶点在不同的集合中。、

遍历过程的每条边,判断这两个顶点的是否在一个集合中。

如果边上的这两个顶点在一个集合中,说明两个顶点已经连通,这条边不要。如果不在一个集合中,则要这条边。
并查集线路冲突,而且从小的边入手,并且在边与边联通的时候要避免闭环的出现

#include 
#include 
#include 

using namespace std;
const int N = 100010;
int p[N];//保存并查集

struct E{
    int a;
    int b;
    int w;
    bool operator < (const E& rhs){//通过边长进行排序
        return this->w < rhs.w;
    }

}edg[N * 2];
int res = 0;

int n, m;
int cnt = 0;
int find(int a){//并查集找祖宗
    if(p[a] != a) p[a] = find(p[a]);
    return p[a];
}
void klskr(){
    for(int i = 1; i <= m; i++)//依次尝试加入每条边
    {
        int pa = find(edg[i].a)// a 点所在的集合
        int pb = find(edg[i].b);// b 点所在的集合
        if(pa != pb){//如果 a b 不在一个集合中
            res += edg[i].w;//a b 之间这条边要
            p[pa] = pb;// 合并a b
            cnt ++; // 保留的边数量+1
        }
    }
}
int main()
{

    cin >> n >> m;
    for(int i = 1; i <= n; i++) p[i] = i;//初始化并查集
    for(int i = 1; i <= m; i++){//读入每条边
        int a, b , c;
        cin >> a >> b >>c;
        edg[i] = {a, b, c};
    }
    sort(edg + 1, edg + m + 1);//按边长排序
    klskr();
    if(cnt < n - 1) {//如果保留的边小于点数-1,则不能连通
        cout<< "impossible";
        return 0;
    }
    cout << res;
    return 0;
}

染色法

图论模板题及分析_第11张图片
有两顶点集且图中每条边的的两个顶点分别位于两个顶点集中,每个顶点集中没有边直接相连接!
也就是一刀劈两半,分庭抗礼
判断二分图算法思路

开始对任意一未染色的顶点染色。
判断其相邻的顶点中,若未染色则将其染上和相邻顶点不同的颜色。
若已经染色且颜色和相邻顶点的颜色相同则说明不是二分图,若颜色不同则继续判断。
bfs和dfs可以搞定!
染色的话奇数偶数,各自一个色,若3-1=2,3-2=1,来回递归作为下一步的染色对照组
如果不是染色交叉,或者出现了闭环首尾相连反而颜色不同的问题就不是二分图。

bool dfs(int u, int c)//深度优先遍历
{
    color[u] = c;//u的点成 c 染色

    //遍历和 u 相邻的点
    for(int i = h[u]; i!= -1; i = ne[i])
    {
        int b = e[i];                   
        if(!color[b])//相邻的点没有颜色,则递归处理这个相邻点
        {
            if(!dfs(b, 3 - c)) return false;
            //(3 - 1 = 2, 如果 u 的颜色是2,则和 u 相邻的染成 1)
            //(3 - 2 = 1, 如果 u 的颜色是1,则和 u 相邻的染成 2)
        }
        else if(color[b] && color[b] != 3 - c)//如果已经染色,判断颜色是否为 3 - c
        {                                     
            return false;//如果不是,说明冲突,返回                   
        }
    }
    return true;
}

匈牙利算法

图论模板题及分析_第12张图片
二分图最大匹配
存图使用邻接表

//邻接表写法,存稀疏图
int h[N],ne[N],e[N],idx;
//n1,n2分别是两个点集的点的个数
int n1,n2,m;
void add(int a , int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void init()
{
    memset(h,-1,sizeof h);
}
//存边只存一边就行了,虽然是无向图。
for(int i = 0 ; i < n1 ; i ++)
{
    int a,b;
    cin>>a>>b;
    add(a,b);
}

以男女生生舞会约会为例子

//match[j]=a,表示女孩j的现有配对男友是a
int match[N];
//st[]数组我称为临时预定数组,st[j]=a表示一轮模拟匹配中,女孩j被男孩a预定了。
int st[N];

//这个函数的作用是用来判断,如果加入x来参与模拟配对,会不会使匹配数增多
int find(int x)
{
    //遍历自己喜欢的女孩
    for(int i = h[x] ; i != -1 ;i = ne[i])
    {
        int j = e[i];
        if(!st[j])//如果在这一轮模拟匹配中,这个女孩尚未被预定
        {
            st[j] = true;//那x就预定这个女孩了
            //如果女孩j没有男朋友,或者她原来的男朋友能够预定其它喜欢的女孩。配对成功,更新match
            if(!match[j]||find(match[j]))
            {
                match[j] = x;
                return true;
            }

        }
    }
    //自己中意的全部都被预定了。配对失败。
    return false;
}

//记录最大匹配
int res = 0;
for(int i = 1; i <= n1 ;i ++)
{  
    //因为每次模拟匹配的预定情况都是不一样的所以每轮模拟都要初始化
    memset(st,false,sizeof st);
    if(find(i)) 
        res++;
}  

这个模板题也是如此。

#include 
using namespace std;
const int N = 510;
int n1, n2, m;      // 有 n1 个男生和 n2 个女生 (n1 ≤ 500, n2 ≤ 500)。
                    // 他们之间可以匹配的关系有 m 个 (m ≤ 1e5)。

vector<int> g[N];   // g[a] 为一个动态数组vector,储存了男生 a 可以匹配的所有女生。

int match[N]; //  match[a] = b: 女生 a 目前匹配了男生 b
bool st[N]; // st[a] = true 说明女生 a 目前被一个男生预定了

bool find(int x) { // 为单身狗 x 找一个对象, (或) x的女朋友被别人预定,给x换一个对象
    // 如果成功,返回true
    for (int j: g[x]) { // j 是可以与男生 x 匹配的女生之一
        if (st[j]) continue; // 女生 j 目前被一个男生预定了,跳过它
        st[j] = true; // 将女生 j 预定给男生 x

        // 如果女生 j 没有对象, 或者
        // 女生 j 在前几轮深搜中已预定有对象,但我们成功给她的对象换了个新对象
        if (match[j] == 0 || find(match[j])) {
            match[j] = x;
            return true;
        }
    }
    return false;
}

二分法的时间复杂度是nm,为什么不是用回溯,以及其他细节问题下面进行总结
让我们仔细看一下 find(x) 函数。我们为男生 x 找/换一个对象,尝试将女生 j 预订给他。因此,用 st[j] = true 记录。这样其他男生不会同时尝试匹配女生 j。同学们不难意识到这和 DFS 中的记录数组的作用差不多。简单来说,避免搜索过程构成环路,导致无限循环和冲突。但是,当 if (match[j] == 0 || find(match[j])) 中 find 函数递归结果返回后,为什么不需要像很多 DFS 题目那样用 st[j] = false 回溯呢?(也即是说,将女生 j 匹配给男生 x 失败,取消预订)

如果我们使用 st[j] = false 进行回溯,算法其实仍然是正确的。但是复杂度会变成指数级。
如果考虑到了这一层,那么我们就可以展示出完整的模板

#include 
using namespace std;
const int N = 510;
int n1, n2, m;      // 有 n1 个男生和 n2 个女生 (n1 ≤ 500, n2 ≤ 500)。
                    // 他们之间可以匹配的关系有 m 个 (m ≤ 1e5)。

vector<int> g[N];   // g[a] 为一个动态数组vector,储存了男生 a 可以匹配的所有女生。

int match[N]; //  match[a] = b: 女生 a 目前匹配了男生 b
bool st1[N]; // st[a] = true 说明女生 a 目前被一个男生预定了
bool st2[N]; // st[a] = true 我们曾经尝试为已经有男朋友的女生 a 换对象,但是失败了

bool find(int x) { // 为单身狗 x 找一个对象, (或) x的女朋友被别人预定,给x换一个对象
    // 如果成功,返回true
    for (int j: g[x]) { // j 是可以与男生 x 匹配的女生之一

//***   // 我们曾经尝试为已经有男朋友的女生 j 换对象,但是失败了。
        // 我们可以由此声称之后永远无法成功(后文详证),所以跳过以避免重复运算
        if(st2[j]) continue;

        if (st1[j]) continue; // 女生 j 目前被一个男生预定了,跳过
        st1[j] = true; // 将女生 j 预定给男生 x

        // 如果女生 j 没有对象, 或者
        // 女生 j 在前几轮深搜中已预定有对象,但我们成功给她的对象换了个新对象
        if (match[j] == 0 || find(match[j])) {
            match[j] = x;
//***   // 我们有了 st2 用来记录换对象失败的女生。这里 st1 回溯不影响复杂度
            st1[j] = false; // 回溯
            return true;
        }

//***   // 我们有了 st2 用来记录换对象失败的女生。这里 st1 回溯不影响复杂度
        st1[j] = false; // 回溯
        st2[j] = true;
    }
    return false;
}

int main() {
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    cin >> n1 >> n2 >> m;
    for (int i = 1; i <= m; i++) {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b); // 读取一条数据,男生 a 可以匹配女生 b
    }

    int res = 0;
    for (int i = 1; i <= n1; i++) { // 尝试为每个男生做一轮深搜找对象(要求成功后总匹配数增加1)。
//***   // st2 不需要重置,因为换对象失败一次的女生到程序结束都不可能成功换对象
        // 对于所有的预定 st1[j] = true,我们都用 st1[j] = false 回溯了。st1本身就是默认状态。
        // 所以和标准模板不同,st1 也不需要重置。
        if (find(i)) res++;
    }

    cout << res;
    return 0;
}

最重要的两个要点:

  1. 匈牙利算法中,一个有伴侣的人,无论男女,不会重新变成单身狗
  2. 若我们尝试给一个有对象的女生换个对象,如果成功,整个交换链条终止于一个单身女性。

根据 2,如果给女生 B 换对象失败,说明经过她搜索不到结束于一个单身女性的交换链条。我们只有等到一个新的单身女性出现才可能由失败转向成功。又根据 1,不会凭空出现一个新的单生狗,所以交换链条永远等不到一个新的单身女性。

由此,换对象失败了一次的女生,到程序结束也都不可能成功换对象。我们可以跳过她们避免重复运算。这样就证明了上面的代码的算法正确性。

技术总结

图论

  1. Dijkstra-朴素O(n^2)
    初始化距离数组, dist[1] = 0, dist[i] = inf;
    for n次循环 每次循环确定一个min加入S集合中,n次之后就得出所有的最短距离
    将不在S中dist_min的点->t
    t->S加入最短路集合
    用t更新到其他点的距离
  2. Dijkstra-堆优化O(mlogm)
    利用邻接表,优先队列
    在priority_queue[HTML_REMOVED], greater[HTML_REMOVED] > heap;中将返回堆顶
    利用堆顶来更新其他点,并加入堆中类似宽搜
  3. Bellman_fordO(nm)
    注意连锁想象需要备份, struct Edge{inta,b,c} Edge[M];
    初始化dist, 松弛dist[x.b] = min(dist[x.b], backup[x.a]+x.w);
    松弛k次,每次访问m条边
  4. Spfa O(n)~O(nm)
    利用队列优化仅加入修改过的地方
    for k次
    for 所有边利用宽搜模型去优化bellman_ford算法
    更新队列中当前点的所有出边
  5. Floyd O(n^3)
    初始化d
    k, i, j 去更新d
  6. 匈牙利算法
    理解为指派任务算法,在约束任务的前提下,获取最高的执行效率。以二分图为分支的图论
    匹配边所连接的点被称为匹配点。同样可以定义非匹配边和非匹配点。
    如果二分图里的某一个匹配包含的边的数量,在该二分图的所有匹配中最大,那么这个匹配称为最大匹配(Maximum Matching)有机会连接,没机会创造机会也要连接。
    毕竟在很多事情上可以错,但不能错过。

你可能感兴趣的:(算法,基础,图论,算法,数据结构)