算法基础课-树和图

第三章 搜索与图论


深搜与广搜

深搜:不断往前走,不撞南墙不回头
广搜:优先扩散

-数据结构空间-
DFSstack O ( h ) O(h) O(h)-
BFSqueue O ( 2 h ) O(2^h) O(2h)具备最短路径

深搜

  • 从解空间的搜索树进行理解,最重要的就是考虑一个能够全遍历的合理顺序

  • 需要注意回溯,要完全恢复现场

  • 很多时候都是递归的形式

y
n
递归调用
y
n
进入函数
到达终点/路径中断
return
进行当前步骤的操作
最外层
结束
进行恢复操作

请添加图片描述

本题突出一个剪枝,就是判断有一条路肯定是走不通了,那就果断放弃

第一种做法:按行进行深搜,这是因为我们事先知道每一行只能放一个: O ( N ! ) O(N!) O(N!)

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

const int N = 2*10;

int n;
char g[N][N];
bool col[N],dg[N],udg[N];//用于标记是否能走的布尔数组,分别是列,对角线,副对角线

void dfs(int u){//参数里需要放一个表示“当前搜索进行到了哪一步”的标度,比如这里是“枚举第u行”
	if(u == n){
		for(int i = 0;i<n;i++) puts(g[i]);
		puts("");
		return ;
	}
	
	for(int i = 0;i<n;i++){//枚举第u行的每一列
		if(!col[i] && !dg[u+i] && !udg[n-u+i]){//剪枝
			//如果选择在这个点放皇后呢
			g[u][i] = 'Q';
			col[i] = dg[u+i] = udg[n-u+i] = true;//标明哪些列和对角线已经被占用了
			
			dfs(u+1);//继续向前走
			
			col[i] = dg[u+i] = udg[n-u+i] = false;//回溯
			g[u][i] = '.';
		}
	}
}

int main(){
	cin >> n;
	for(int i = 0;i<n;i++){
		for(int j = 0;j < n;j++){
			g[i][j] = '.';
		}
	}
	dfs(0);
	
	return 0;
}

更原始的枚举顺序:一个格子一个格子进行枚举: O ( 2 n 2 ) O(2^{n^2}) O(2n2)

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

const int N = 2*10;

int n;
char g[N][N];
bool col[N],dg[N],udg[N],row[N];//用于标记是否能走的布尔数组,分别是列,对角线,副对角线,行

void dfs(int x,int y,int s){//这里的标度就很复杂了,分别代表我们位于哪一行哪一列,共放了几个皇后
	if(y == n) y = 0,x++;//表示已经到了行的最后一位
	if(x == n){//表示搜索已结束
		if(s == n){//如果皇后放够了,表示找到了一个解
			for(int i = 0;i < n;i++) puts(g[i]);
			puts("");
		}
		return ;
	}
	
	//不放皇后
	dfs(x,y+1,s);//直接走
	
	//放皇后
	if(!row[x] && !col[y] && !dg[x+y] && !udg[x-y+n]){
		g[x][y] = 'Q';
		row[x] = col[y] = dg[x+y] = udg[x-y+n] = true;
		dfs(x,y+1,s+1);
		
		//回溯
		row[x] = col[y] = dg[x+y] = udg[x-y+n] = false;
		g[x][y] = '.';
	}
}

int main(){
	cin >> n;
	for(int i = 0;i<n;i++){
		for(int j = 0;j < n;j++){
			g[i][j] = '.';
		}
	}
	dfs(0,0,0);
	
	return 0;
}

宽搜

  • 一层一层搜,所以可以找所谓的最短路径

  • 通常需要用到队列

模板:

//记录当前到达层数的宽搜
level = 0
初始值入队
while queue 不空:
    size = queue.size()
    while (size --) {
        cur = queue.pop()
        for 节点 in cur的所有相邻节点:
            if 该节点有效且未被访问过:
                queue.push(该节点)
    }
    level ++;

//给出的更一般的宽搜模板
初始值入队
while queue 不空{
	t = 队头
	“拓展”t //比如寻找相邻节点啥的
}

例题:
请添加图片描述

#include <cstdio>   
#include <cstring>   
#include <queue>   
#include <utility>   
using namespace std;   
const int N = 1010;   
typedef pair<int, int> PII;   

int g[N][N];   
bool flag[N][N];   //不回头条件,需要开一布尔数组来存哪些点已经到过了
int dx[4] = {0, 0, -1, 1}, dy[4] = {1, -1, 0, 0};   //用向量来表示向上下左右给
int n, m, k;   
PII st, ed;   

int bfs() {   
	st.first -= 1;   
	st.second -= 1;   
	ed.first -= 1;   
	ed.second -= 1;   
	
	//队列内储存坐标
	queue<PII> q;   
	q.push(st);  //起点进队 
	flag[st.first][st.second] = true;   
	int sec = 0;   //记录层数
	
	while (q.size()) {   //当队列非空
		int sz = q.size();   
		for (int i = 0; i < sz; i++) {   //对当前这一层的每一个节点进行扩充
			auto cur = q.front();   //取出要处理的节点
			q.pop();   
			
			for (int o = 0; o < 4; o++) {   //遍历上下左右四个方向
				for (int t = 1; t <= k; t++) {   //遍历所有步数
					int x = cur.first + t * dx[o], y = cur.second + t * dy[o];   
					if (x < 0 || y < 0 || x >= m || y >= n || g[x][y]) {   
						break;   
					}   
					if (flag[x][y]) {   
						continue;   
					}   
					flag[x][y] = true;   
					PII ne = make_pair(x,y);  
					if(ne==ed){  //如果到达了终点
						return ++sec;  
					}  
					q.push(ne);   
				}   
			}   
		}   
		sec++;   //“层数”加一
	}   
	return -1;   
}   


int main() {   
	scanf("%d %d %d", &n, &m, &k);   
	getchar();   
	memset(g, 0, sizeof(g));   
	memset(flag, 0, sizeof(flag));   
	for (int i = 0; i < n; i++) {   
		for (int j = 0; j < m; j++) {   
			switch (getchar()) {   
				case '.':   
				g[j][i] = 0;   
				break;   
				case '#':   
				g[j][i] = 1;   
				break;   
			}   
		}   
		getchar();   
	}   
	scanf("%d %d %d %d", &(st.second), &(st.first), &(ed.second), &(ed.first));   
	printf("%d\n", bfs());   
}

如果需要记录一下路径,就开一个数组,专门记录当前点是由哪个点拓展来,增加后代码

int g[N][N];   
bool flag[N][N];   //不回头条件,需要开一布尔数组来存哪些点已经到过了
int dx[4] = {0, 0, -1, 1}, dy[4] = {1, -1, 0, 0};   //用向量来表示向上下左右给
int n, m, k;   
PII st, ed,pre[N][N];//pre数组记录从哪来的

int bfs() {   
	st.first -= 1;   
	st.second -= 1;   
	ed.first -= 1;   
	ed.second -= 1;   
	
	//队列内储存坐标
	queue<PII> q;   
	q.push(st);  //起点进队 
	flag[st.first][st.second] = true;   
	int sec = 0;   //记录层数
	
	while (q.size()) {   //当队列非空
		int sz = q.size();   
		for (int i = 0; i < sz; i++) {   //对当前这一层的每一个节点进行扩充
			auto cur = q.front();   //取出要处理的节点
			q.pop();   
			
			for (int o = 0; o < 4; o++) {   //遍历上下左右四个方向
				for (int t = 1; t <= k; t++) {   //遍历所有步数
					int x = cur.first + t * dx[o], y = cur.second + t * dy[o];   
					
					if (x < 0 || y < 0 || x >= m || y >= n || g[x][y]) {   
						break;   
					}   
					if (flag[x][y]) {   
						continue;   
					}   
					flag[x][y] = true; 
					pre[x][y] = cur;//表cur走到了(x,y)处
					PII ne = make_pair(x,y);  
					if(ne==ed){  //如果到达了终点
						return ++sec;  
					}  
					q.push(ne);   
				}   
			}   
		}   
		sec++;   //“层数”加一
	}   
	return -1;   
}   

树和图的存储

  • 无向图就存储为双向边的有向图
  • 邻接矩阵:用二维数组 g [ a ] [ b ] g[a][b] g[a][b]存储 a 到 b a到b ab这条边的信息,适合存储稠密的图
  • 邻接表:类似拉链法的哈希表,每个节点拉出一个单链表,用于存储当前这个节点的所有邻居

下面都在讨论邻接表
代码实现,ACWING讲的是用数组模拟,也可以用stl的vector数组vector<int> h[N]进行模拟,甚至用unordered_map<int,vector<int> > h进行模拟

基本操作代码模板

const int N = 100010,M = N*2;
// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点
int h[N], e[M], ne[M], idx;

// 添加一条边a->b
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

// 初始化
idx = 0;
memset(h, -1, sizeof h);

补充:带权重的邻接表:

int n,m;
int h[N],e[N],ne[N],w[N],idx;//邻接表,多开一个w[]数组来存储边的权重

void add(int a,int b,int c){//带权重的add算法
	e[idx] = b,w[idx] = c,ne[idx] = h[a],h[a] = idx++;
}

树和图的dfs

bool st[N];//记录已经遍历过的点

void dfs(int u){//从编号为u的点开始深搜
	//对u进行一些处理
	
	st[u] = true;//标记,已到达
	
	for(int i = h[u];i != -1;i = ne[i]){
		int j = e[j];
		if(!st[j]) dfs(j);
	}
}

例题
请添加图片描述

  • 先分析一下“把每个点删除后连通块的最大值”:其实就是要求子树的大小

    • 更换具体说,每个节点的子树大小可以由dfs自然求得
    • 但是除了该节点的所有子树,还有一个连通块,就是拔掉以当前节点为根的树后,剩下的部分。这部分大小是 n − Σ s o n i n- \Sigma son_i nΣsoni
  • 基本思路是对于每一个节点,都储存一下他的“最大模块数”。这样的话就需要用深搜来遍历整棵树

#include <bits/stdc++.h>
using namespace std;
const int N = 100010,M = N*2;

int n;
int h[N],e[M],ne[M],idx;
bool st[N];

int ans = N;//存储全局答案

void add(int a,int b){
	e[idx] = b,ne[idx] = h[a],h[a] = idx++;
}
int dfs(int u){//从编号为u的点开始深搜,返回以u为根的子树大小
	
	st[u] = true;//标记,已到达
	
	int sum  = 1,res = 0;//res是连通块最大数,sum是记数
	for(int i = h[u];i != -1;i = ne[i]){
		int j = e[j];
		if(!st[j]){
			int s = dfs(j);
			res = max(res,s);
			sum += s;//sum记录子树大小
		}
	}
	
	res = max(res,n-sum);
	
	ans = min(ans,res);
	return sum;
}


int main(){
	cin >>n;
	memset(h,-1,sizeof(h));
	
	for(int i = 0;i<n;i++){
		int a,b;
		cin >> a>> b;
		add(a,b),add(b,a);//存储无向图
	}
	dfs(1);
	
	cout << ans << endl;
}

树和图的bfs

套用模板

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

int level = 0;
queue<int> q;

int bfs(){
	level = 0;
	q.push(1);
	while(q.size()){
		int size = q.size();
		while (size --) {
			int cur = q.front();
			q.pop();
			
			for(int i = h[cur];i!=-1;i=ne[i]){
				int j = e[i];
				
				//进行一些处理
				
				if(!st[j]){
					if(check(j)){
						//满足终止条件,返回当前层数
						return ++level;
					}
					
					st[j] = true;
					q.push(j);//入队
				}
			}
		}
		level ++;
	}
	return -1;
}

例题

请添加图片描述

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

const int N = 100010;

int n, m;
int h[N], e[N], ne[N], idx;
int d[N];

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

int bfs() {
	memset(d, -1, sizeof d);//d用来存距离

	queue<int> q;
	d[1] = 0;
	q.push(1);

	while (q.size()) {
		int t = q.front();
		q.pop();

		for (int i = h[t]; i != -1; i = ne[i]) {
			int j = e[i];
			if (d[j] == -1) {
				d[j] = d[t] + 1;
				q.push(j);
			}
		}
	}

	return d[n];
}

int main() {
	scanf("%d%d", &n, &m);
	memset(h, -1, sizeof h);

	for (int i = 0; i < m; i ++ ) {
		int a, b;
		scanf("%d%d", &a, &b);
		add(a, b);
	}

	cout << bfs() << endl;

	return 0;
}

bfs应用:拓扑序

  • 只有无环图有拓扑序
  • 拓扑序列是这样的序列:在序列中存在这样的对子 ( a , b )   s . t .  在某条包含 a , b 的路径里, b 位于起点而 a 位于终点 (a,b) \ s.t. \ 在某条包含a,b的路径里,b位于起点而a位于终点 (a,b) s.t. 在某条包含a,b的路径里,b位于起点而a位于终点

几个基本概念

  • 某个点的度数:就是该点的边数
  • 入度:箭头指向该点的边数
  • 出度:箭头指出概念的边数

大致步骤

  • 首先找到入度为0的点,入队

  • 只要队不空,排出队头t,加入到拓扑序内

  • 枚举t的所有出边t—>j

    • 删除该边,并更新节点j的入度

    • 如果j的入度已经是0了

      • j入队
  • 可以证明,如果图中有环,则环中不可能有入度为0的点,所以如果用以上操作后,图中有节点剩余,则不可能有拓扑序列

代码

int d[N];//d数组存放入度
queue<int> top;//该队列存储拓扑序的输出
bool topsort() {
	queue<int> q;
	for(int i = 1;i <= n;i++){
		if(!d[i]) q.push(i);
	}

	while (q.size()) {
		int t = q.front();
		q.pop();
		top.push(t);
		
		for (int i = h[t]; i != -1; i = ne[i]) {
			int j = e[i];
			d[j]--;
			if(!d[j]) q.push(j);
		}
	}

	return (q.size() == n-1);
}

最短路问题

分类

  • 单源最短路:一个起点
    • 无负权边
      • 朴素Dijkstra算法 O ( n 2 ) O(n^2) O(n2)适合稠密图
      • 堆优化Dijkstra算法 O ( m l o g n ) O(mlogn) O(mlogn)适合稀疏图
    • 有负权边
      • Bellman-Ford O ( n m ) O(nm) O(nm)
      • SPFA 一般 O ( m ) ,最坏 O ( n m ) 一般O(m),最坏O(nm) 一般O(m),最坏O(nm)
  • 多源汇最短路:Floyd O ( n 3 ) O(n^3) O(n3)

难点:建图


朴素Dijkstra算法

大致步骤

  • 初始化距离 :以起点为一号点,初始化: d s t [ 1 ] = 0  ; d i s t [ . . . ] = + ∞ dst[1] = 0 \ ;dist[...] = + \infty dst[1]=0 dist[...]=+
  • 初始化集合 s s s:s是所有当前已经确定最短距离的点
  • 循环: f o r   i   i n   r a n g e   ( 1... n ) for \ i \ in \ range \ (1...n) for i in range (1...n)就是循环n次
    • 拿出 t t t :不在集合 s s s中的 d s t [ t ] dst[t] dst[t]值最小的点
    • t t t加入到 s s s之中
    • t t t更新别的点的距离
      • 对于所有 t t t的出边连接的节点 x x x
        • i f   d s t [ x ] > d i s t [ t ] + w if \ dst[x]>dist[t]+w if dst[x]>dist[t]+w;其中 w w w t − > x t->x t>x边的权重:
        • 更新 d i s t [ x ] dist[x] dist[x]为更小值

数据结构:由于适合稠密图,所以一般用邻接矩阵来存储

代码
请添加图片描述

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

const int N = 510;

int n,m;
int g[N][N];//邻接矩阵
int dist[N];//记录距离
bool st[N];//表示哪些点还未计算过最短路

int dijkstra(){
	//初始化距离
	memset(dist,0x3f,sizeof(dist));
	dist[1] = 0;
	
	for(int i = 0;i<n;i++){//迭代n次,以确保遍历所有的点
		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[j] + g[t][j]);
		}
	}
	
	if(dist[n] == 0x3f3f3f3f) return -1;//距离仍是正无穷,说明不连通
	return dist[n];
}


int main(){
	scanf("%d%d",&n,&m);
	
	memset(g,0x3f,sizeof(g));//初始化邻接矩阵全为正无穷,为什么初始化成正无穷,则与算法的实现有关
	
	while(m--){//读入图
		int a,b,c;
		scanf("%d%d%d",&a,&b,&c);
		g[a][b] = min(g[a][b],c);//面对重边 和 自环 的处理方式:每两点只保留最短的边
	}
	
	int t = dijkstra();
	printf("%d\n",t);
	
	return 0;
	
}

堆优化的Dijkstra算法

大致步骤

  • 初始化距离 :以起点为一号点,初始化: d s t [ 1 ] = 0  ; d i s t [ . . . ] = + ∞ dst[1] = 0 \ ;dist[...] = + \infty dst[1]=0 dist[...]=+
  • 初始化集合 s s s:s是所有当前已经确定最短距离的点
  • 循环: f o r   i   i n   r a n g e   ( 1... n ) for \ i \ in \ range \ (1...n) for i in range (1...n)
    • 拿出 t t t :不在集合 s s s中的 d s t [ t ] dst[t] dst[t]值最小的点 在这里用小根堆优化,则减少了时间
    • t t t加入到 s s s之中
    • t t t更新别的点的距离
      • 对于所有 t t t的出边连接的节点 x x x
        • i f   d s t [ x ] > d i s t [ t ] + w if \ dst[x]>dist[t]+w if dst[x]>dist[t]+w;其中 w w w t − > x t->x t>x边的权重:
        • 更新 d i s t [ x ] dist[x] dist[x]为更小值

数据结构

  • 稀疏矩阵:邻接表
  • 堆:用STL里的优先队列
    • 由于优先队列不存在修改的操作,所以修改时就是往堆里插入一个新元素,但是这就可能造成冗余
    • 冗余的处理方式就是去查询 b o o l   s t [ ] bool \ st[] bool st[]来判断是不是已经得到最小值了

代码

#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
using namespace std;

typedef pair<int ,int> PII;//由于既需要存距离,又需要存编号,所以搞一个有序对
//由于有序对的比大小函数是先比第一个再比第二个,所以第一个放距离

const int N = 10010;

int n,m;
int h[N],e[N],ne[N],w[N],idx;//邻接表,多开一个w[]数组来存储边的权重
int dist[N];//记录距离
bool st[N];//表示哪些点还未计算过最短路

void add(int a,int b,int c){//带权重的add算法
	e[idx] = b,w[idx] = c,ne[idx] = h[a],h[a] = idx++;
}


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;
		if(st[ver]) continue;//发现冗余,跳过
		st[ver] = true;//进入集合
		
		for(int i = h[ver];i != -1;i = ne[i]){
			int j = e[i];
			if(dist[j] > distance + w[i]){//更新距离
				dist[j] = distance + w[i];
				heap.push({dist[j],j});
			}
		}
	}
	
	if(dist[n] == 0x3f3f3f3f) return -1;//距离仍是正无穷,说明不连通
	return dist[n];
}


int main(){
	scanf("%d%d",&n,&m);
	
	memset(h,-1,sizeof(h));//初始化邻接表
	
	while(m--){//读入图
		int a,b,c;
		scanf("%d%d%d",&a,&b,&c);
		add(a,b,c);
	}
	
	int t = dijkstra();
	printf("%d\n",t);
	
	return 0;
	
}

Bellman-Ford 算法

大体步骤:

  • 循环 n 次
    • f o r  所有边 ( a , b , w ) for \ 所有边 (a,b,w) for 所有边(a,b,w) (意思是从a走向b权重为w)
      • 更新距离 d i s t [ b ] = m i n ( d i s t ( n o w ) [ b ] , d i s t ( p r e v i o u s ) [ a ] + w ) dist[b] = min(dist^{(now)}[b],dist^{(previous)}[a]+w) dist[b]=min(dist(now)[b],dist(previous)[a]+w) 思路上其实差不多

注:

  • 可以想到,如果图中存在负权环,则最短距离不存在(因为可以一直绕着负权环走)
  • 循环第k次的意义:从起点开始,经过不超过k条边的时候,最短距离是多少
  • 注意:更新距离的时候用的是上一次迭代的结果,而非当前
    • 这是为了保持”循环k次“的意义,防止边之间”串联 “

进一步解释:比如给出这样的图

1
1
3
1
2
3

第一次迭代后,如果是一边遍历一边更新,则效果是 d i s t [ 1 ] = 0 , d i s t [ 2 ] = 1 , d i s t [ 3 ] = 2 dist[1] = 0,dist[2] = 1,dist[3] = 2 dist[1]=0,dist[2]=1,dist[3]=2
但是这不是我们期望的结果,当改成用上一次的迭代结果,则效果是 d i s t [ 1 ] = 0 , d i s t [ 2 ] = 1 , d i s t [ 3 ] = 3 dist[1] = 0,dist[2] = 1,dist[3] = 3 dist[1]=0,dist[2]=1,dist[3]=3

数据结构

  • 直接开个结构,三元组,存 ( a , b , w ) (a,b,w) (a,b,w)
  • 然后开个数组,把边存下来就可

代码
请添加图片描述

本题是限制边数的,正好与算法中的循环次数k相对应

#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510,M = 10010;

int n,m,k;//点数,边数,路径长度限制
int dist[N],backup[N];//距离,备份数组

struct Edge{
	int a,b,w;
}edges[M];

int bellman_ford(){
	memset(dist,0x3f,sizeof dist);
	dist[1] = 0;
	memset(backup,0x3f,sizeof backup);
	backup[1] = 0;
	
	for(int i = 0;i<k;i++){
		memcpy(backup,dist,sizeof dist);
		for(int j = 0;j<m;j++){
			int a = edges[j].a,b = edges[j].b,w = edges[j].w;
			dist[b] = min(dist[b],backup[a]+w);//只用上次迭代的结果进行备份
		}
	}
	
	if(dist[n] > 0x3f3f3f3f/2) return -1;//判断大于一个比较大的数,这是因为负权边的存在,会导致无穷大被更新
	return dist[n];
}

int main(){
	scanf("%d%d%d",&n,&m,&k);
	
	for(int i = 0;i<m;i++){
		int a,b,w;
		scanf("%d%d%d",&a,&b,&w);
		edges[i] = {a,b,w};
	}
	
	int t = bellman_ford();
	
	if(t == -1) puts("impossible");
	else printf("%d\n",t);
}

SPFA算法

用队列对Bellman-Ford算法优化

  • 观察我们用于更新的式子 d i s t [ b ] = m i n ( d i s t ( n o w ) [ b ] , d i s t ( p r e v i o u s ) [ a ] + w ) dist[b] = min(dist^{(now)}[b],dist^{(previous)}[a]+w) dist[b]=min(dist(now)[b],dist(previous)[a]+w)
  • 我们发现,事实上,在k,k+1次迭代之间(而非第k次迭代内), d i s t k + 1 [ b ] dist^{k+1}[b] distk+1[b]变小的唯一可行能性是 d i s t i [ a ] dist^{i}[a] disti[a]变小了
  • 这样追溯下去,实际上只需要自己变小的节点去更新其他节点

大致思路:

  • 先把起点入队
  • 循环:直到队列为空
    • 取出队头
    • 用队头更新所有队头的出边
      • 如果更新成功,则把更新成功的节点加入队列

请添加图片描述

#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>

using namespace std;

typedef pair<int,int> PII;

const int N = 100010;

int n,m;
int h[N],w[N],e[N],ne[N],idx;//邻接表
int dist[N];
bool st[N];//防止有重复的边放入

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

int spfa(){
	memset(dist,0x3f,sizeof dist);
	dist[1] = 0;
	
	queue<int> q;
	q.push(1);
	st[1] = true;
	
	while(q.size()){
		int t = q.front();
		q.pop();
		
		st[t] = false;
		
		for(int i = h[i];i!=-1;i = ne[i]){
			int j = e[i];
			if(dist[j]>dist[t]+w[i]){
				dist[j] = dist[t]+w[i];
				if(!st[j]){//防止重复放入
					q.push(j);
					st[j] = true;
				}
			}
		}
	}
	
	if(dist[n] == 0x3f3f3f3f) return -1;
	return dist[n];
}

int main(){
	scanf("%d%d",&n,&m);
	
	memset(h,-1,sizeof h);
	
	while(m--){
		int a,b,c;
		scanf("%d%d%d",&a,&b,&c);
		add(a,b,c);
	}
	
	int t = spfa();
	
	if(t == -1) puts("impossible");
	else printf("%d\n",t);
}
spfa判断负环:
  • 维护一个cnt数组,表示从起点到当前点走了几条边
  • cnt数组和dist数组同步更新
  • 为了寻找起点可能在任意处的负环,需要在一开始把所有点都放入队列
int cnt[N];
bool spfa(){
	queue<int> q;
	for(int i = 0;i<n;i++){
		q.push(i);
		st[i] = true;
	}
	
	while(q.size()){
		int t = q.front();
		q.pop();
		
		st[t] = false;
		
		for(int i = h[i];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;//同步更新cnt数组
				
				if(cnt[j] >= n) return true;//抽屉原理
				
				if(!st[j]){//防止重复放入
					q.push(j);
					st[j] = true;
				}
			}
		}
	}
	
	return false;
}

Floyd算法

数据结构

  • 邻接矩阵

大体思路:

  • f o r   k   i n   r a n g e ( 1... n ) for \ k \ in \ range(1...n) for k in range(1...n)
    • f o r   i   i n   r a n g e ( 1... n ) for \ i \ in \ range(1...n) for i in range(1...n)
      • f o r   j   i n   r a n g e ( 1... n ) for \ j \ in \ range(1...n) for j in range(1...n)
        • d [ i , j ] = m i n ( d [ i , j ] , d [ i , k ] + d [ k , j ] ) d[i,j] = min(d[i,j],d[i,k]+d[k,j]) d[i,j]=min(d[i,j],d[i,k]+d[k,j])

原理:动态规划

  • d p ( i , j , k ) dp(i,j,k) dp(i,j,k)表示从 i i i开始,只经过 1.. k 1..k 1..k这些点,到达 j j j的最短路径

请添加图片描述

#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 210,INF = 1e9;

int n,m,Q;
int d[N][N];//邻接矩阵

void floyd(){
	for(int k = 1;k <= n;k++){
		for(int i = 1;i <= n;i++){
			for(int j = 1;j <= n;j++){
				d[i][j] = min(d[i][j],d[i][k]+d[k][j]);
			}
		}
	}
}

int main(){
	scanf("%d%d%d",&n,&m,&Q);
	
	for(int i = 1;i <= n;i++){
		for(int j = 1;j <= m;j++){
			if(i == j) d[i][j] = 0;
			else d[i][j] = INF;
		}
	}
	
	while(m--){
		int a,b,w;
		scanf("%d%d%d",&a,&b,&w);
		
		d[a][b] = min(d[a][b],w);//别忘记处理重边
	}
	
	floyd();
	
	while(Q--){
		int a,b;
		scanf("%d%d",&a,&b);
		if(d[a][b] > INF/2) printf("%d\n",d[a][b]);
		else puts("impossible");
	}
	
	return 0;
}

小总结:关于一些细节的判定

  • 用邻接矩阵注意处理重边
  • 遇到负权的图,最判断是否有路径是判断一个较大的数,不是直接与正无穷比较

最小生成树

最主要是无向图

  • Prim算法:与Dijkstra算法很像
    • 朴素版Prim算法: O ( n 2 ) O(n^2) O(n2):稠密图
    • 堆优化版Prim算法: O ( m l o g n ) O(mlogn) O(mlogn):稀疏图(不常用)
  • Kruskal算法: O ( m l o g m ) O(mlogm) O(mlogm):稀疏图(常用)

朴素版Prim算法

一个贪心算法

  • 把所有点到集合的距离初始化为正无穷,初始化一个集合,表示当前在连通块内的点
  • n次迭代
    • 找到集合 外 距离最近的点t
    • 把t加入到集合之中,并把最短距离对应的边,加入到生成树
    • 用t更新其他点到集合的距离

基本上和Dijkstra一样

到集合距离的定义:

  • 就是某点 到 集合内 所有边中,长度最小的边,的长度
    请添加图片描述
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 510;

int n,m;
int g[N][N];
int dist[N];
bool st[N];

int prim(){
	memset(dist,0x3f,sizeof dist);
	
	int res = 0;
	for(int i = 0;i < n;i++){
		int t = -1;
		for(int j = 1;j<=n;j++){
			if(!st[i] && (t == -1 || dist[t] > dist[j])){
				t = j;
			}
		}//选点
		st[t] = true;//入集合
		
		//在这里判断第是否是第一个点,其实意思是默认第一个点一开始就在集合中   
		if(i && dist[t] == 0x3f3f3f3f) return 0x3f3f3f3f;
		if(i) res += dist[t];//先累加
		
		for(int j = 1;j <= n;j++) dist[j] = min(dist[j],g[t][j]);//再更新,这是防止负权自环的扰乱
	}
}

int main(){
	scanf("%d%d",&n,&m);
	
	memset(g,0x3f,sizeof g);
	
	while(m--){
		int a,b,c;
		scanf("%d%d%d",&a,&b,&c);
		g[a][b] = g[b][a] = min(g[a][b],c);
	}
	
	int t = prim();
	
	if(t == 0x3f3f3f3f) puts("impossible");
	else printf("%d\n",t);
	
	return 0;
}

堆优化版的Prim算法(见堆优化的Dijkstra算法)


Kruskal算法

大致步骤

  • 将所有边按权重从小到大排序
  • 枚举所有边 ( a , b , c ) (a,b,c) (a,b,c)
    • 如果a,b不在同一个连通块内
      • 将这条边加入集合
        请添加图片描述
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 100010;

int n,m;
int p[N];//并查集

struct Edge{
	int a,b,w;
	
	bool operator< (const Edge &W)const {//重载运算符:按照边值大小排序
		return w<W.w;
	}
}edges[2*N];

int find(int x){//并查集功能:实现祖宗追溯
	if(p[x] != x) p[x] = find(p[x]);
	return p[x];
}

int kruskal(){
	//事先准备:排序边,构建并查集
	sort(edges,edges+m);
	for(int i = 1;i <= n;i++) p[i] = i;//全部孤立点
	
	int res = 0,cnt = 0;//分别是最小生成树的权值和,以及树中节点个数
	for(int  i = 0;i < m;i++){
		int a = edges[i].a,b = edges[i].b,w = edges[i].w;
		
		a = find(a),b = find(b);//寻找祖宗
		if(a!=b){//ab尚未在一个连通块内
			p[a] = b;//加入同一个连通块
			res += w;
			cnt++;
		}
	}
	
	if(cnt < n-1) return -1;
	else return res;
}

int main(){
	scanf("%d%d",&n,&m);
	
	for(int i = 0;i < m;i++){
		int a,b,w;
		scanf("%d%d%d",&a,&b,&w);
		edges[i] = {a,b,w};
	}
	
	int t = kruskal();
	if(t == -1) puts("impossible");
	else printf("%d\n",t);
}

二分图

什么叫二分图?如下图,把节点划分为两个集合,且所有边都在集合之间请添加图片描述

  • 染色法:dfs O ( m + n ) O(m+n) O(m+n)
  • 匈牙利算法:最坏 O ( m n ) O(mn) O(mn),实际运行时间远小于理论最坏

染色法

  1. 一个图是 二分图:当且仅当图中不含有奇数环
    • 必要性:在奇数环中,任意选取一点作为起点,让其属于集合1.由于二分的要求,则其后继一定属于集合2。一直走下去,由于走过了奇数个点,得到起点必须属于集合2。矛盾。
    • 充分性:进行一个构造,就是染色,给点染色,保证一条边两侧的颜色不同。具体步骤就是,对点进行遍历,如果遇到没有被染色的点,将其染成白色,再将其所有的邻居染成黑色。这就变成了,证明:只要图中不含有奇数环,则染色过程不会出现矛盾
      • 反证:结论的否命题引入:染色过程中出现了矛盾。这是因为,矛盾如果发生,则必须发生在一个点将要去染色一个已染色的邻居,这说明该点和即将下一个染色的点,一定处于一个环之中(易证)。这个环是奇数环是显然的,与前提矛盾。证毕
  2. 由以上论证,有 一个图是二分图,当且仅当其能被二染色

大致步骤:

  • 遍历所有点 i i i
    • if i i i 没有染色
      • 递归调用染色(dfs)

请添加图片描述

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 100010,M = 200020;

int n,m;
int h[N],e[M],ne[M],idx;
int color[N];//存储染色

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

bool dfs(int u,int c){//对一整个连通块染色
	color[u] = c;//记录当前点颜色
	
	for(int i = h[u];i!=-1;i = ne[i]){//遍历所有邻居
		int j = e[i];
		if(!color[j]){//如果没被染色
			if(!dfs(j,3-c)) return false;//递归调用,颜色1变2,2变1
		}
		else if(color[j] == c) return false;//如果被染色,且矛盾
	}
	
	return true;
}

bool dye(){//对一整个图进行染色
	for(int i = 1;i <= n;i++){
		if(!color[i]){
			if(!dfs(i,1)) return false;
		}
	}
	return true;
}

int main(){
	scanf("%d%d",&n,&m);
	
	memset(h,-1,sizeof h);
	
	while(m--){
		int a,b;
		scanf("%d%d",&a,&b);
		add(a,b),add(b,a);
	}
	
	bool flag = dye();
	if(flag) puts("Yes");
	else puts("No");
}

匈牙利算法

给定一个二分图,去找其最大匹配

匹配数量:选到边的数量
成功匹配:没有共用同一个点的边(不许搞三角关系)如下图,选择三条黑色的边,就是成功匹配请添加图片描述

大体思路:贪心

  • 从左1开始匹配
    • 依次遍历出边,如果“出边”的点未被占用,则直接匹配,寻匹配左侧下一个节点
    • 如果遍历该点所有出边,都被占用了,则进行协调:
      • 找到第一个出边对应的点,该点被占用,则去找占用该点的左点去“协商”,看他能不能换一个
      • 换不了?进行递归协商
      • 如果都达到不了,放弃了请添加图片描述
#include <bits/stdc++.h>
using namespace std;
const int N = 510,M = 1e5+10;
int n1,n2,m;//左点,右点,边数

int h[N],e[N],ne[N],idx;//邻接表
int match[N];//记录右侧点匹配了谁,即match[j] = i 表示右侧j匹配了i
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]){
		int j = e[i];
		if(!st[j]){//未被占用
			st[j] = true;
			
			if(match[j] == 0 || find(match[j])){//如果右侧点尚未匹配 或者 右侧匹配上的可以换人
				match[j] = x;//直接匹配喵
				return true;
			}
		}
	}
	
	return false;
}
int Xmacth(){
	int res = 0;
	for(int i = 1;i<=n1;i++){//遍历所有男生
		memset(st,0,sizeof st);//初始化
		if(find(i)) res++;
	}
	return res;
}

int main(){
	scanf("%d%d%d",&n1,&n2,&m);
	
	memset(h,-1,sizeof h);
	
	while(m--){
		int a,b;
		scanf("%d%d",&a,&b);
		add(a,b);
	}
	
	int t = Xmacth();
	printf("%d\n",t);
}

证明见《算法导论》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值