图论基础(学习的笔记)

大一寒假because太无聊,下午2点肝到晚上10点,回头一看居然已经写了上万字,有点小开心,致敬坚持学习算法的我们

1.DFS的核心板块搭建

什么是DFS

概念:dfs全称叫做深度优先搜索,顾名思义:它是指优先考虑深层次的一种搜索方式(这里不理解也没有关系,后面会什么是深层次

上面的可能不是很好理解,这里我们引用y总的比喻:

dfs是一个很执着的人,它喜欢一条往下的一条路走到头,直到他无法往下走(即层数限定了)。然后掉头往回走,但走到过程中也不忘回头看,看看有没有另一条往下走的路并且不会走走过的路,一旦发现,那么继续刚才的过程…(直到全部的路走完)。

在这里插入图片描述

上面这张图希望帮助大家理解什么是执着的dfs,绿色代表向下一直走,紫色代表回溯(字面意思,就是往回走),它在每一个Y字形路口都会回头,继续向下走并且不会走之前的路(这里就需要标记之前的路有没有走过,这里我们暂时忘记并埋下一个伏笔:如何标记)

(2)什么时候用DFS

很多人包括我自己也经常在想什么时候用DFS,其实用DFS本身就是一种无奈之举(有时候想不到dp,贪心,递推等等方法),毕竟DFS是在牺牲时间复杂度的基础上换来的空间优势,这也侧面反映了DFS只能在一些小范围内查找,说到底只是一种暴力搜索。

(3)DFS怎么实现

其中核心是:

  • 判断到最底层的条件
  • 遍历每一层的所有可能并且把满足条件的“路”继续往下走
  • 标记和还原

我们后面的每一个例题都会以上面的核心来实现

我们还是用上面的比喻来形容一下:dfs是一个很执着的人,它喜欢一条往下的一条路走到头,直到他无法往下走(即层数限定了)。然后掉头往回走,但走到过程中也不忘回头看,看看有没有另一条往下走的路并且不会走走过的路,一旦发现,那么继续刚才的过程…(直到全部的路走完)。

首先要一条路一直往下走,并且到底部要往回走

void dfs(int step)            //step代表当前是在第几层
{
    if(step == n)    		  //当step在第n层时返回(判断到最底层的条件)
    {
        ....  				  //输出或者处理数据
        return;				  //返回上一层
    }
    
    if(cheak(...)) dfs(step+1) //当满足某一条件时进入下一层
}

接下来要往回走的过程中也不忘回头看,看看有没有另一条往下走的路并且不会走走过的路


int a[N];							//路径数据,用于储存你要保留的数据
bool p[N];							//这里定义bool数组作判断是否是走过的路径(bool数组默认为false)

void dfs(int step)
{
	if(step == n)
    {
		....
        return ;
    }
	
    for(int i = 0;i < n;i ++ )			//遍历所以的情况(遍历每一层的所有可能并且把满足条件的“路”继续往下走)
    {
		if(cheak(...))					//
        {
			a[step]处理					//将路径记录
            p[i]=true;					 //标记,表示这条路已经走过
            dfs(step+1);												//标记和还原
            a[step]还原					//将a[]和p[]还原,表示这层结束
            p[i]=false;
        }
    }
        
}

这样我们大概的模型就有了,只需要一些经典的题目填充我们的模块

基本例题

(1)八皇后:

*(每个棋子每行每列每个斜对角线不能出现另一个棋子)

一个如下的 6×6 的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。

img

上面的布局可以用序列 2 4 6 1 3 5来描述,第 i个数字表示在第 i 行的相应位置有一个棋子,如下:

行号 1 2 3 4 5 6

列号 2 4 6 1 3 5

这只是棋子放置的一个解。请编一个程序找出所有棋子放置的解。
并把它们以上面的序列方法输出,解按字典顺序排列。
请输出前 3 个解。最后一行是解的总个数。

输入格式

一行一个正整数 n,表示棋盘是 n×n 大小的。

输出格式

前三行为前三个解,每个解的两个数字之间用一个空格隔开。第四行只有一个数字,表示解的总数。

输入 #1

6

输出 #1

2 4 6 1 3 5
3 6 2 5 1 4
4 1 5 2 6 3
4

在做之前想想实现dfs的步骤

  • 判断到最底层的条件
  • 遍历每一层的所有可能并且把满足条件的“路”继续往下走
  • 标记和还原

代码实现:

#include<bits/stdc++.h>
using namespace std;
int n,cnt=0;

const int N = 30;

int a[N];
bool col[N],dg[N],udg[N];

void dfs(int u)
{
	if(u == n)								//判断到最底层的条件	
	{
		cnt++;
		if(cnt <= 3){ for(int i = 0;i < n;i ++ ) 
			cout << a[i] << " ";
			cout<<endl;
		}
		return;
	}
	for(int i = 1;i <= n;i ++ )
	{
		if(!col[i] && !dg[u+i] && !udg[n-u+i])		//遍历每一层的所有可能并且把满足条件的“路”继续往下走
		{
			a[u]=i;									//标记
			col[i]=dg[u+i]=udg[n-u+i]=true;			//标记
			dfs(u+1);								//往下走
			a[u]=0;									//还原
			col[i]=dg[u+i]=udg[n-u+i]=false;		//还原
		}
	}
	return ;
}


int main()
{
	cin>>n;
	
	dfs(0);
	
	cout<<cnt<<endl; 
	
	return 0;
}

(2)PERKET(蛋糕问题)

题目描述

Perket 是一种流行的美食。为了做好 Perket,厨师必须谨慎选择食材,以在保持传统风味的同时尽可能获得最全面的味道。你有 n 种可支配的配料。对于每一种配料,我们知道它们各自的酸度 s和苦度 b。当我们添加配料时,总的酸度为每一种配料的酸度总乘积;总的苦度为每一种配料的苦度的总和。

众所周知,美食应该做到口感适中,所以我们希望选取配料,以使得酸度苦度绝对差最小

另外,我们必须添加至少一种配料,因为没有任何食物以水为配料的。

输入格式

第一行一个整数 n,表示可供选用的食材种类数。

接下来 n* 行,每行 2 个整数 si和 bi,表示第 i种食材的酸度和苦度。

输出格式

一行一个整数,表示可能的总酸度和总苦度的最小绝对差。

输入

4
1 7
2 6
3 8
4 9

**输出 **

1

同样再做之前想想dfs实现的三个步骤

  • 判断到最底层的条件
  • 遍历每一层的所有可能并且把满足条件的“路”继续往下走
  • 标记和还原

代码实现:

#include<bits/stdc++.h>
#define int long long  
using namespace std;

int n,minn =2e9;		

const int N = 20;			
int s[N],b[N];
bool dis[N];

int add = 0,res = 1;			

void dfs(int step )							
{
	if(step > n){						//判断到最底层的条件(从一开始,所以结束条件是大于n)
		return;
	}
	for(int i = 0;i < n;i ++ )			//遍历每一层的所有可能并且把满足条件的“路”继续往下走
	{
		if(!dis[i])						
		{	
			res*=s[i];add+=b[i];				//计算酸度和苦度	
			dis[i]=true;						//标记
			minn=min(minn,abs(res-add));		//这里而外要注意的点是:边标记的过程中将数据记录下来
			dfs(step+1);						//向下走
						
			res/=s[i];add-=b[i];
			dis[i]=false;
		}
	}
	
}

signed main()
{
	cin >> n;
	for(int i = 0;i < n;i ++ )
	cin>>s[i]>>b[i];
	dfs(1);						//因为必须选择一种食材
	cout<<minn;
	return 0;
}

2.宽度优先搜索(bfs)

bfs官方解释:宽度优先搜索算法(又称广度优先搜索)是最简便的图的搜索算法之一,这一算法也是很多重要的图的算法的原型。Dijkstra单源最短路径算法和Prim最小生成树算法都采用了和宽度优先搜索类似的思想。其别名又叫BFS,属于一种盲目搜寻法,目的是系统地展开并检查图中的所有节点,以找寻结果。换句话说,它并不考虑结果的可能位置,彻底地搜索整张图,直到找到结果为止。

看起来很累对不对,现在让我们一起进入我视角下的bfs

bfs什么时候用?

最常用的情况当然是迷宫问题啦,比如求最短路之类的问题…当然说了和没说一样,但是值得注意的是,大多数情况下需要是边权为的情况下我们才来用bfs求最短路,否则就是其他算法了,比如下面会将的dijkstra,spfa之类的。

bfs如何理解

这个我相信是大家最想理解的,我当初学的时候也有点迷,但是其实就是模板的装饰

我们先来看一个bfs模板,然后我们来细说这个模板的意思:

struct node{
	int x,y,step...
	node(int cx,int cy,int cs)
    {
        x=cx,y=cy,step=cs;
    }					  
};

对结构体的理解:一般我们需要x,y,step这三个信息,但是可能还有其他情况需要枚举,这里就先省略,然后在结构体里写一个结构体是为了方便写代码,比如我们一般写x和y和step要写node.x,node.y,node.step,但是我这么写就可以变成写:node(x,y,step);当数据变多就体现了这么写的优势

bool vis[N];
int bfs()
{
	queue<int>q;
    vis[x][y]=true;
    q.push()//放入初始信息
	
    while(!q.empty())//只有当队列不空的时候循环才有意义
    {
        node tt=q.front();//取出队头
        q.pop();//将队头从队列中弹出
		if(...)//满足条件则退出bfs
        {
			return ...;
        }
        for(....)//枚举所有情况
        {
			if(...) 如果满足条件
            {
				vis[][]//标记
                q.push(node(...)) //将数据放入队列
            }
        }
    }
}

到这里可能还有写同学不太理解,那我们结合图来分析:
在这里插入图片描述

根据定义我们知道dfs是一层一层的遍历,那么我们来模拟一边队列进入元素的过程:

第一层元素1进入队列,此时队列不为空,while循环,tt取出对头元素1,往下遍历:在这里插入图片描述

第二层元素2 4进入队列,此时队列不为空,while循环,并且对头1弹出,tt取对头2:
在这里插入图片描述

第三层元素3 5 6 7进入队列,此时队列不为空,while循环,并且对头2弹出,tt取对头4:
在这里插入图片描述

之后我们只需要重复弹出队列操作,没有元素进入队列里面了。

我们来看一个例题来加深理解:

这里我们着重把模板和队列思想融入题目中:

题目描述 链接

一只羊驼与羊群走散,在慌乱中进入了猎人的迷宫陷阱中,猎人在这个n×m的迷宫内布置了大量的捕兽夹,但为了自己能出来,猎人留了一条自己出去的路。假设出口位于(x,y)羊驼目前位于(a,b)。

现用二维平面描述n×m的迷宫,用0表示没有捕兽夹,1表示有捕兽夹。羊驼只能向上下左右4个方向移动,并且不能移到放有捕兽夹的位置或移出边界。

现在拥有上帝视角的你,请你算出羊驼目前的位置到达出口的最短距离,从而逃出猎人的陷阱。

输入描述:

第一行输入n,m(1≤n,m≤100)。第二行输入a,b,x,y(a和x表示第几行,b和y表示第几列)。接下来n行,每行m个整数表示n×m的区域。

输出描述:

输出占一行,表示羊驼目前距离出口且不触碰捕兽夹的最短安全距离。

输入

5 5
2 1 4 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0

输出

6

我们可以看到一开始羊羊的初始位置是(2,1),终点是(4,5)。我们用图来看:
在这里插入图片描述

实际上就是把每一个坐标加入队列当中去:

在这里插入图片描述

这个就是小羊羊在队列里走的过程,那么我们现在来实现它:

#include<bits/stdc++.h>
using namespace std;
int n,m;
int a,b,x,y;
int dx[]={0,-1,0,1};//定义4个方向
int dy[]={1,0,-1,0};
bool vis[110][110];
int t[110][110];
struct node{//结构体记录数据
    int x,y,step;
    node(int cx,int cy,int cs)
    {
        x=cx,y=cy,step=cs;
    }
};
int bfs()
{
    queue<node>q;
    q.push(node(a,b,0));//初始小羊羊的位置
    vis[a][b]=true;  //标记,防止重复走 
    while(!q.empty())
    {
        node tt=q.front();
        q.pop();
        if(tt.x==x && tt.y==y)
    	{
    		return tt.step;
		}
        for(int i = 0;i < 4;i ++ )
        {
           int xx=tt.x+dx[i],yy=tt.y+dy[i];
            if(xx>0 && xx<=n && yy>0 && yy<=m && !vis[xx][yy] && t[xx][yy]!=1)
            { //不能越界,不能碰捕兽夹
				vis[xx][yy]=true;//标记,防止重复走
                q.push(node(xx,yy,tt.step+1));
            }
        }
    }
    return 0;
}

int main()
{
    cin >> n >> m >> a >> b >> x >> y;
    
    for(int i = 1;i <= n;i ++ )
    for(int j = 1;j <= m;j ++ )
    cin >> t[i][j];
   
    int cnt=bfs();
    cout<<cnt<<endl;;

    return 0;
}

其实了解了bfs的实质和背下了模板也不会很难,当然还有些双向bfs或者多重条件的bfs,最后给大家两个这样的例题练练手把:

双向bfs:小A与小B

多重条件bfs:迷宫与陷阱


3.拓扑排序

我们先看一下它的定义:

拓扑序列是顶点活动网中将活动按发生的先后次序进行的一种排列。 拓扑排序,是对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边(u,v)∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。

用人话说就是在一个给定的有向图内,找到所有的点都依次(依他们的方向)排序的序列,看图:
在这里插入图片描述

此时的拓扑序列就为:4->1->2->3

对于一个复杂度图,我们如何来在它的拓扑序呢?

这里我们引入一个概念入度:是图论算法中重要的概念之一,它通常指有向图中某点作为图中边的终点的次数之和。

对于一个合法的拓扑序,那么它的入度一定为零,否则就是一个环,那么我们是需要依次找出入度增大的点,那么就可以完成拓扑排序.但是由于依次找入度增大的点较为困难,所有我们再引入一个d[]数组来记录每一个点都入度数量,再遍历过程中每出现一次点,就把相应点的d减去,当该点入度为零时放入结果数组中.当所有点入度都为零后输出结果数组

那么我们来看一下寻找拓扑序的代码:

#include<bits/stdc++.h>
using namespace std;

const int N = 1e5+10;
int ne[N],e[N],h[N],idx;
int n,m;
int cnt;
int d[N],p[N];			//d[]为入度,p[]为结果数组
void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;//存储图
}

bool topsort()
{
    queue<int>q;//我们用队列来遍历所有的点
    
    for(int i = 1;i <= n;i ++ )
        if(d[i]==0) 				//将所有入度为零的点加入结果数组和队列中去
        {
            q.push(i);
            p[cnt++]=i;
        }
        
    while(!q.empty())
    {
      
        int t = q.front();
        q.pop();
        
        for(int i = h[t];i != -1;i = ne[i])
        {
            d[e[i]]--;
            if(d[e[i]]==0)
            {
                p[cnt++]=e[i];//当入度为零时放入结果数组中
                q.push(e[i]);//放入队列,当下一轮的起始位置
            }
        }
        
    }
    if(cnt==n) return true;//说明全部点都再结果数组中返回true
    else return false;
    
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    memset(h,-1,sizeof(h));
    cin >> n >> m;
    
    while(m--)
    {
        int a,b;
        cin >> a >> b;//表示有一条从a指向b的边
        add(a,b);d[b]++;//b点的入度加一
    }
    
    if(topsort())
    {
        for(int i = 0;i < cnt;i ++ ) cout<<p[i]<<" ";//拓扑序存在输出结果
    }
    else cout<<-1<<endl;
    return 0;
}

4.最短路径问题

这是一个大问题,包含了很多种算法,其中每种算法的时间复杂度应用情景不能混淆弄乱

(1)dijkstra算法(处理正权边问题)

我们先来思考一个问题:当给我们一张地图时,地图上有许多的城市,城市之间连接着长度不同的道路,问你从1号城市到第n号城市的最短路径怎么办呢?

这里每条边的权重不为1,所有不可以采用bfs的方法求最短路,这里我们引入dijkstra算法求最短路.

一.什么是dijkstra算法: 迪杰斯特拉算法(Dijkstra)是由荷兰计算机科学家狄克斯特拉于1959年提出的,因此又叫狄克斯特拉算法。是从一个顶点到其余各顶点的最短路径算法,解决的是有权图中最短路径问题。迪杰斯特拉算法主要特点是从起始点开始,采用贪心算法策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止

二.贪心的理解:我们每一步都找到与1最近的点,用这个点来更新其他的点,那么更新完成之后的这个点就一定是最短距离(上次重复这个操作的时候就已经把比它离1距离更短的点更新完成了),也可以理解为数学研究中的数学归纳法.

三.代码的实际写法:我们始终是要找的是n号点离原点的最短距离,所以我们用dist[]表示每一个点到1的距离,规定dist[1]=0,因为1到1距离为0,然后再每次更新的时候取dist[x] = min(dist [x],dist [y]+d [y] [x]),这里的d [y] [x]是y到x的距离,所以dist[x]应该是在它本身离1的距离与(y到1的距离+y到x的距离)取最小值.

那么接下来就是代码:

#include<bits/stdc++.h>
using namespace std;

const int N = 550;
int dg[N][N],dist[N];//dg[i][j]表示i点到j点的距离
bool st[N];//标记已经找到的最短距离
int n,m;

void dijkstra()
{
    memset(dist,0x3f3f3f3f,sizeof(dist));//每个点到1的距离初始化为正无穷
    dist[1]=0;
    for(int i = 1;i <= n;i ++ ){
        int t = -1;							
        for(int j = 1;j <= n;j ++ )
            if(!st[j] && (t==-1 || dist[t]>dist[j]))//首先要是不在已经找完的集合离,其次是要找到最短路径的点
            t=j;
        st[t]=true;//标记它为已经找到最短路径的点,把他放入集合当中去
        
        for(int j = 1;j <= n;j ++ )
            dist[j]=min(dist[j],dist[t]+dg[t][j]);//用这一点更新每一个点
    }
    if(dist[n]==0x3f3f3f3f) cout<<-1<<endl;说明1与n之间没有路径,所以是正无穷,输出-1
    else cout<<dist[n]<<endl;
}

int main()
{
    cin >> n >> m;
    for(int i = 1;i <= n;i ++ )
        for(int j = 1;j <= n;j ++ )
            dg[i][j]=0x3f3f3f3f;	//初始化正无穷
    while(m--)
    {
        int a,b,val;
        cin >> a >> b >> val;
        dg[a][b]=min(dg[a][b],val);//有重复的边,取最小值
    }
    dijkstra();
    return 0;
}

最后我们其实可以发现,这个算法的时间复杂度是O(n2)的,因为有两层for循环,那有没有可能优化呢?下面我们来看如何优化

堆优化的dijkstra算法:

其实不难发现:

(1)我们每次找那个最短距离的点的时候其实有多余的计算

(2)并且用已找好的离1距离最短的点来更新其他点和找那个最短距离的点的时候其实也有多余的计算

  • 比如在找点的过程中的多余的判断操作,并且我们已经确定了2在3之前就已经是最短的点,我们再用3来更新2的时候就多此一举了.

所以我们在这里用小根堆和队列来完成自动排序的过程,也就是优化第一步找点的过程,用链表来优化第二部过程(也就是不用重1到n枚举,而是从最短的那个点位起点来更新其它在它后面相连的点)

由于stl里面有自带的容器,所以我们不用手写小根堆了

typedef pair<int,int> PII;
griority_queue<PII,vvector<PII>,greater<PII>>q;

下面我们来看代码:

#include<bits/stdc++.h>
using namespace std;
#define PII pair<int,int>
const int N = 2e5+10;
int e[N],ne[N],h[N],w[N],idx;
int dist[N];
bool st[N];
int n,m;
void add(int a,int b,int c)
{
    e[idx] = b,ne[idx] = h[a],w[idx] = c,h[a] = idx++;
}

void dijkstra()
{
    memset(dist,0x3f,sizeof dist);
    priority_queue<PII,vector<PII>,greater<PII>>q;//建立小根堆
    q.push({0,1});//第一个表示距离,第二个表示点
    dist[1] = 0;
    while(!q.empty())
    {
        auto t = q.top();
        q.pop();
        int distance = t.first,ver = t.second;
        if(st[ver]) continue;//说明已经在最短距离的集合里面,不需要再更新了
        st[ver] = true;//标记为已经确定的点
        for(int i = h[ver];i != -1;i = ne[i]){
            if(dist[e[i]]>distance + w[i]){	//有更短的路径
                dist[e[i]] = distance + w[i];//更新最短路径
                q.push({dist[e[i]],e[i]});//放入队列
            }
        }

    }
    if(dist[n]==0x3f3f3f3f) cout<<-1<<endl;//说明没有1到n的路径
    else cout<<dist[n]<<endl;
}

int main(){
    cin >> n >> m;
    memset(h,-1,sizeof h);  //初始化
    while(m--)
    {
        int a,b,w;
        cin >> a >> b >> w;
        add(a,b,w);			//将图存入链表
    }
    dijkstra();
    return 0;
}

(2)bellman_ford算法(正负权边都可以):

一.什么是贝尔曼-福特算法(英语:Bellman–Ford algorithm):求解单源最短路径问题的一种算法,由理查德·贝尔曼(Richard Bellman) 和莱斯特·福特创立的。有时候这种算法也被称为 Moore-Bellman-Ford 算法,因为Edward F. Moore也为这个算法的发展做出了贡献。它的原理是对图进行[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b0jVDZeS-1671005902805)(null)]次松弛操作,得到所有可能的最短路径。其优于迪科斯彻算法的方面是边的权值可以为负数、实现简单.

二.其特点和缺陷:

优点:与dijkstra不同的是,它既可以求含有负权边的单源最短路问题,同时可以求有变数限制的单源最短路问题.其中含有松弛操作也很有特色和优势

但缺点就是时间复杂度过高,并且有一定局限(目前就只在有边限制的题目上出现)

三.松弛操作的理解:松弛操作是指对于每个顶点v∈V,都设置一个属性d[v],用来描述从源点s到v的最短路径上权值的上界,称为最短路径估计.用人话讲就在开始第k次更新最短路时,那么我们保留第k次所有1~n的点到1的距离,即为松弛操作

实现的过程需要而外开辟一个数组backup[]来储存第k次操作的dis[1~n]的初值

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 550;
int dist[N],backup[N];
int n,m,k;
bool st[N];

struct node{
    int a,b,w;//结构体来存储路径和权值
}d[N*N];

void bellman_ford()
{
    memset(dist,0x3f,sizeof dist);
    dist[1] = 0;
    for(int i = 1;i <= k;i ++ )//表示在第i条边内每个点到1的距离
    {
        memcpy(backup,dist,sizeof dist);//也就是备份第i次开始时的dist[1~n]的值
        
        for(int j = 1;j <= m;j ++ )
        {
            int a = d[j].a,b = d[j].b,w = d[j].w;
            dist[b] = min(dist[b],w + backup[a]);//用备份值来更新,防止在这个循环里面一个点被多余两个串联点更新
        }
    }
    if(dist[n]>0x3f3f3f3f/2) cout<<"impossible"<<endl;
    else cout<<dist[n]<<endl;
}

int main()
{
    cin >> n >> m >> k;
    
    for(int i = 1;i <= m;i ++ )
        cin >> d[i].a >> d[i].b >> d[i].w;
        
    bellman_ford();
    
    return 0;
}

(3)spfa算法(推荐负权边)

一什么是spfa算法:SPFA 算法是 Bellman-Ford算法队列优化算法的别称,通常用于求含负权边的单源最短路径,以及判负权环。SPFA 最坏情况下时间复杂度和朴素 Bellman-Ford 相同,为 O(nm)。

二.进行了那些优化:由于没有了边数的限制,我们将备份操作换成了队列的入队和出队,也就是说,只把减少了的距离放入队列,再来更新其他点

三.与dijkstra的比较:在求最短路方面(稀疏图),优化版的dijkstra肯定在时间复杂度方面碾压spfa,但是存在负权边时,spfa就派上了用武之地.

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+10;
int e[N],ne[N],h[N],w[N],idx;
int dist[N];
bool st[N];
int n,m;

void add(int a,int b,int c)
{
    e[idx] = b,ne[idx] = h[a],w[idx] = c,h[a] = idx++;
}

void spfa()
{
    queue<int>q;
    memset(dist,0x3f,sizeof dist);
    dist[1] = 0;
    q.push(1);//这里队列里面放的是每一个点

    while(!q.empty())
    {
        int t = q.front();
        q.pop();
        st[t] = false;

        for(int i = h[t];i != -1;i = ne[i])
        {
            if(dist[e[i]]>dist[t] + w[i]){
                dist[e[i]] = dist[t] + w[i];
                if(!st[e[i]])
                {
                    st[e[i]] = true;
                    q.push(e[i]);
                }
            }
        }
    }
    if(dist[n] == 0x3f3f3f3f) cout<<"impossible"<<endl;
    else cout<<dist[n]<<endl;
}

int main(){

    cin >> n >> m;
    memset(h,-1,sizeof h);

    while(m--)
    {
        int a,b,c;
        cin >> a >> b >> c;
        add(a,b,c);
    }

    spfa();

    return 0;
}
判断是否存在负环回路?

我们都知道当我们求最短距离时如果存在负权回路,那么我们的最短路会变成负无穷.然而spfa算法就可以帮助我们判别一个图中是否有负权回路.

我们只需要再spfa算法的基础上记录每一个点到1的边数,也就是cnt[e[i]]来记录,那么它的变换过程就是cnt[e[i]] = cnt[t] + 1;这里的t表示该次最短距离的点.

不用初始化的原因:

  • 1,先理解初始化的意义即作用是判断是否可以从1到n这个点,也就是1到n的过程中是否有负权回路
  • 2,不初始化是每个点都是起点,到可以到达的任意一条边,判断在这个过程中是否存在负环回路
  • 3,但是实际上是否初始化都不会影响结果,因为是判断是否有负环,而不是求最短路
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+10;
int e[N],h[N],ne[N],w[N],idx;
int dist[N],cnt[N];
bool st[N];

void add(int a,int b,int c)
{
    e[idx] = b,ne[idx] = h[a],w[idx] = c,h[a] = idx++;
}
int n,m;

bool spfa()
{
    queue<int>q;
    for(int i = 1;i <= n;i ++ ) {
        q.push(i);
        st[i] = true;
    }
    while(!q.empty()){
        int t = q.front();
        q.pop();
        
        st[t] = false;
        
        for(int i = h[t];i != -1;i = ne[i]){
            if(dist[e[i]]>dist[t]+w[i]){
                dist[e[i]] = dist[t] + w[i];
                cnt[e[i]] = cnt[t] +1;		//唯一与spfa不同的地方
                if(cnt[e[i]]>=n) return true;
                if(!st[e[i]]){
                    st[e[i]] = true;
                    q.push(e[i]);
                }
            }
        }
    }
    return false;
}
int main()
{
    cin >> n >> m;
    memset(h,-1,sizeof h);
    for(int i = 1;i <= m;i ++ )
    {
        int a,b,c;
        cin >> a >> b >> c;
        add(a,b,c);
    }
    if(spfa()) cout<<"Yes"<<endl;
    else cout<<"No"<<endl;
    return 0;
}

(4)floyd算法(多源最短路算法)

我们上面写的都是单源最短路问题,现在我们再来看看多源最短路问题(即任意一点到另一点的最小距离)

一.什么是floyed算法:Floyd算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似。该算法名称以创始人之一、1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。

二.形式上怎么表达:(我的理解)在folyed算法上我们要三层循环,第一层为k,第二层为i,第三层为j:表示为以k为跳板,看从i到j是否有dg[i][j]>d[i][k]+d[k][j],这样的话我们就早到,每一条边的最短距离

三.动态规划怎么理解:其状态转移方程如下: map[i,j]=min[map[i,k]+map[k,j],map[i,j]];对于每一对顶点 u 和 v,看看是否存在一个顶点 w 使得从 u 到 w 再到 v 比已知的路径更短。如果是更新它。

四.优缺点:

  • 优点:好理解,可以算出任意两个节点之间的最短距离,代码编写简单。

  • 缺点:时间复杂度为n3,空间复杂度为n2,只能用于稠密图中.

代码:

#include<bits/stdc++.h>
using  namespace std;
const int N = 210;
const int INF = 1e9+10;
int g[N][N];				//用临接矩阵存图
int n,m,q;

void floyd()
{
    for(int k = 1;k <= n;k ++ )
        for(int i = 1;i <= n;i ++ )
            for(int j = 1;j <= n;j ++ )
                g[i][j] = min(g[i][j],g[i][k] + g[k][j]);//核心更新
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    
    cin >> n >> m >> q;
    
    for(int i = 1;i <= n;i ++ )
        for(int j = 1;j <= n;j ++ )
            if(i==j) g[i][j] = 0;
            else g[i][j] = INF;
            
    while(m--)
    {
        int a,b,val;
        cin >> a >> b >> val;
        g[a][b] = min(g[a][b],val);
    }
    
    floyd();
    
    while(q--)
    {
        int a,b;
        cin >> a >> b;
        if(g[a][b] > INF / 2) cout<<"impossible"<<endl;
        else cout<<g[a][b]<<endl;
    }
    
    return 0;
}

5.最小生成树问题

(1)什么是最小树:1941年, Courant和Robbins在《什么是数学》一书中对Fermat问题进行了一种更有意义的推广:假设在平面上有a1,a2,…an这n,(n > 3)个点,怎样寻找另外的若干个点,使得原有的n个点与后来加入的点组成的网络的总边长最小。他们把这个问题称为Steiner树问题,并给出了一些基本性质。

**用人话讲就是:**将n个相连的点分解为树的形式,如果他们之间没有点相连,那么就无法生成树

另一种情况就是n个点不相连,求使得它们互相联通的最短距离和.(当然也是上面的问题,我们只需要求出两两的位置距离就可以转变为最小生成树问题)

(2)什么是最小生成树:一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。 [1] 最小生成树可以用kruskal(克鲁斯卡尔)算法或prim(普里姆)算法求出。

(1)prim算法(推荐稠密图)

由于与dijkstra算法及其相似,所有我们来对比着讲

(1)dist[]的区别:在dijkstradist表示每一个点到1的最短距离,在prim中表示的是最小距离的集合,也就是不在dist集合每一个点距离该集合最小的距离.

(2)表达式计算上的区别:由于dijkstra的目的是为求n到1的距离,所有每次更新为dist[j]=min(dist[j],dist[t]+g[t][j]),但是我们prim中是求每一个点到集合的最短距离,所有:dist[j]=min(dist[j],d[t][j]).

(3)判断是否可以生成树:那就是看有没有孤立的点,也就是出现不是第一个点的情况下有dist[t]==0x3f3f3f3f.说明有孤立点,无法生成树.

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 550;
int g[N][N],dist[N];
bool st[N];
int n,m;
int prim()
{
    int res = 0;
    memset(dist,0x3f,sizeof dist);
    for(int i = 1;i <= n;i ++ ){
        int t = -1;
        for(int j = 1;j <= n;j ++ )
            if(!st[j] && (t==-1 || dist[t]>dist[j]))
                t = j;
        
        st[t] = true;
        if(i!=1&&dist[t]==0x3f3f3f3f) return 0x3f3f3f3f;//当出现不是第一个点且dist[t]为正无穷说明为孤立点
        if(i!=1) res += dist[t];
        for(int j = 1;j <= n;j ++ )
        {
            dist[j] = min(dist[j],g[t][j]);//用prim的更新思路
        }
        
    }
    return res;
}

int main()
{
    cin >> n >> m;
    for(int i = 1;i <= n; i++ )
        for(int j = 1;j <= n;j ++ )
            if(i!=j) g[i][j] = 0x3f3f3f3f;
            else g[i][j] = 0;
    while(m--)
    {
        int a,b,val;
        cin >> a >> b >> val;
        g[a][b] = min(g[a][b],val);
        g[b][a] = g[a][b];//无向图需要两边同时指向
    }
    
    int t = prim();
    
    if(t > 0x3f3f3f3f / 2) cout<<"impossible"<<endl;//有负权边,所有要大于0x3f3f3f3f/2;
    else cout<<t<<endl;
    
    return 0;
}

(2)kruskal算法(稠密图推荐)

虽然prim生成最小树可以用类似dijkstra的算法思路进行优化,使其也可以适用于稠密图,但是我们的kruskal算法明显强于prim,无论的代码的长度和思路上都优与prim的优化,所有直接把prim算法的优化跳过就可以了.

(1)将每条边的权重排序:用结构体的重载进行排序,时间复杂度为(nlogn),目的:我在prim算法里讲过",但是我们prim中是求每一个点到集合的最短距离",也就是有贪心的思想,我们只需要枚举每一个点的权重就可以了.

(2)枚举每条边(并查集):这里的枚举由于前面已经排过序了,所以我们一定是从最短的边开始的,如何并查集操作把两个不是同一集合的连通块判断并连接.

(3)判断是否可以生成最小树:因为树的节点数和边数的关系一定是边数=节点数-1,所以当我们枚举每一次结束后,如果边数小于n-,那么就说明无法生成最小树.

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 2e5+10;
int n,m;
int p[N];
struct node{
    int a,b,w;
    bool operator<(const node&A)const //重载进行边长的排序
    {
        return w<A.w;
    }
}edge[N];

int find(int x)
{
    if(p[x] != x) p[x] = find(p[x]);//并查集模板
    return p[x];
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    
    for(int i = 1;i <= m; i++ ) 
        cin >> edge[i].a >> edge[i].b >> edge[i].w;
        
    sort(edge+1,edge+m+1);
    
    for(int i = 1;i <= n;i ++ ) p[i] = i;
    
    int res = 0,cnt  =0;
    for(int i = 1;i <= m;i ++ )
    {
        int a = edge[i].a,b = edge[i].b,w = edge[i].w;
        if(find(a)!=find(b))//检查是否是同一个连通块
        {
            p[find(a)] = find(b);//将两个连通块合并
            cnt++;//边数加一
            res+=w;//最小树结果
        }
    }
    
    if(cnt<n-1) cout<<"impossible"<<endl;//当边数小于节点数-1说明无生成树
    else cout<<res<<endl;
    
    return 0;
}

6.二分图问题

(1)什么是二分图?

定义:二分图又称作二部图,是图论中的一种特殊模型。 设G=(V,E)是一个无向图,如果顶点V可分割为两个互不相交的子集(A,B),并且图中的每条边(i,j)所关联的两个顶点i和j分别属于这两个不同的顶点集(i in A,j in B),则称图G为一个二分图。

又是看不懂的专业属于,那么还是看我们的人话吧:就是在一个给定的无向图中,可以将互相连接的两个点分成两个部分,最终划分好的图如下图部分:img

(2)判别二分图的条件(包含证明):

一个图是二分图的充分必要条件是该图不存在奇数环(一个环的边长为奇数的环)

易知:任何无回路的的图均是二分图

我们先来证明一下它的充分必要性:

(1)充分性:不存在奇数环的图为二分图:

​ 我们假设它存在奇数环,那么令该环为x1,x2,...xn,x1我们假设一共有n个点,因为n为奇数,那么可以表示为n=2*k+1(k=0,1,…),我们假设x1在左部分,标记为1,那么x2必定为右部分,标记为2,那么第n个点应该是(2*k+1)%2==1,有因为xn与x1相连,那么出现了他们两在同一边的情况,与二分图的定义矛盾,所以假设不成立,充分性得证.

(2)必要性:一个二分图不存在奇数环:

​ 我们同样设一个二分图为x1,x2...xn由于它是一个二分图,那么就有x1,x3,…在左边,x2,x4,…在右边,那么又因为xn-1与xn在不同一边,那么n%2必定位0,也就是n位偶数,说明二分图如果有环一定是偶数环.

染色法判定二分图

这里我们如何进行染色操作呢?

(1)搜索方式:其实dfs和bfs都可以,所以就调了一个短一点的dfs来写我们的染色过程,我们把左边标记为1,右边标记为2

(2)标记方式:由于是左右交替,也就是1,2,1,2的形式,所以我们用3-c(c代表当前的点),那么下一个点一定与这个点不同

(3)判断方式:也就是当搜索过程中出现了颜色重复的时候,我们就认为它不是二分图,因为当重复的颜色出现,说明在遍历的过程中出现了环,而环分奇数环和偶数环,在上面的证明过程中讨论了此类情况.

我们上面的证明其实就是用染色法来证明的二分图,所以这里就不加累赘,直接上代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+10,M = 2e5+20;
int h[N],e[M],ne[M],idx;
int n,m;
int color[N];

void add(int a,int b)
{
    e[idx] = b,ne[idx] = h[a],h[a] = idx++; 
}

bool dfs(int x,int c){
    color[x] = c;
    
    for(int i = h[x];i != -1;i = ne[i]){
        if(!color[e[i]]) 
        {
        	if(!dfs(e[i],3-c))
			return false;
		}
        else 
        {
            if(color[e[i]] == c) return false;
        }
        
    }
    return true;
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    memset(h,-1,sizeof h);
    
    cin >> n >> m;
    
    for(int i = 1;i <= m;i ++ )
    {
        int a,b;
        cin >> a >> b;
        add(a,b),add(b,a);
    }
    
    bool flag = true;
    for(int i = 1;i <= n;i ++ )
    {
        if(!color[i]){
            if(!dfs(i,1)) {
                flag = false;
                break;
            }
        }
    }
    
    if(flag) cout<<"Yes"<<endl;
    else cout<<"No"<<endl;
    
    return 0;
}

(3)匈牙利算法(二分图的最大匹配)

1.什么是二分图的匹配?

给定一个二分图G(X,E,Y),F为边集E的一个子集。如果F中任意两条边都没有公共端点,则称F是图G的一个匹配。

用人话说就是一个给定的二分图,在左边和右边找到存在连线的点,将两点连接后这两点不在与其他的点相连。

在这里插入图片描述

2.二分图的最大匹配:所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。

用什么思想来实现?

直观来看,如果给定我们二分图,我们首先肯定是自己试错,从左边第一个枚举,看它能不能与右边的匹配

1.如果右边与它相连的点没有与其他点连线,那么我们就说这两个的匹配了,那么右边用match[](值为左边的点)来记录右边与这个点匹配。

2.如果右边的点已经有匹配了,那么我们找到与右边那个点匹配的左边,让左边的点重新匹配右边相连的点,若找不到,说明右边这个点无法与我们枚举到的左边这个点相连,若早到了,我们就改变连接关系,使得左右两对点重心匹配。

解释在递归过程的st[]的作用重新找点的过程就是一个递归,所以我们可以用递归来写,但是这里还要引入一个st[]数组,它的作用是确定右边的点是否被选中,也就是防止出现串联死循环的情况,也就是相当于走过了的那些路径,走过的路径不能重复再走,右边每一个点在一次匹配中都中科院选择一次的意思。

**这里再来说一下为什么每一次枚举左边都要还原st数组:**因为st[]记录的是每一次枚举左边点递归过程中的走过路径,也就是一个左边点的匹配路径,所以在不同的左边点,都要还原一次。

下面是代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 550,M = 1e5+10;
int h[N],e[M],ne[M],idx;
int n1,n2,m;
int match[N];
bool st[N];

void add(int a,int b){
    e[idx] = b,ne[idx] = h[a],h[a] = idx++;//邻接表存图
}

bool find(int x)
{
    for(int i = h[x];i != -1;i = ne[i]){
        if(!st[e[i]]){//说明右边这个点没有被匹配过
            st[e[i]] = true;//标记匹配了
            if(match[e[i]] == 0 || find(match[e[i]])){//(1)当右边这个点没有匹配(为空),那么就找到
                match[e[i]] = x;					  //(2)当有右边点有匹配,我们找右边对应的左边点能不能更改匹配对象
                return true;//找到返回true
            }
        }
    }
    return false;//没找到返回false
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cin >> n1 >> n2 >> m;
    memset(h,-1,sizeof h);
    while(m--){
        int a,b;
        cin >> a >> b;
        add(a,b);
    }
    
    int res = 0;			//用res存储匹配数
    for(int i = 1;i <= n1;i ++ ){
        memset(st,false,sizeof st);//每一次还原st[]数组,就是更新每一个左边点匹配右边点的路径
        if(find(i)) res++;//如果找到右边可以匹配的点就+1
    }
    
    cout<<res<<endl;
        
    return 0;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三 七

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值