Loading

OI 学习笔记 I:图论(更新中)

阅读时建议在右下角开启目录。

由于作者的数学水平限制和篇幅限制,有些结论可能仅给出感性理解或不给出证明,有疑惑的读者可以百度答案或者前往参考资料一栏查找。

另外,因为图论的内容比较杂,有些与树相关的算法可能会置于数据结构那类之中,若有问题可以在评论区留言或者私信作者的洛谷帐号,主页左上角那个 github 标志的就是我的主页链接。

本文涉及以下内容:

  • 树论相关:最小生成树,lca,dsu on tree,树哈希
  • 最短路相关:单源,全源最短路、差分约束、同余最短路
  • 联通性相关:传递闭包、割点与割边、圆方树、强联通分量以及2-SAT 问题
  • 网络流相关:二分图与网络流
  • 杂项:LGV 引理,矩阵树定理,

树论相关

最短路问题

求最短路径的长度是一个在现实生活中也非常常见的问题,主要表现为从 A 地到 B 地应该怎么走使得路径最短,而通常情况下地点间会有分叉路以及路口,使得路径不只一条,所以走最短路也就是对岔路的决策,优化决策也就成为了我们优化的算法关键。

值得注意的事,与现实中路径不同,我们解决的问题中可能会出现负权边,甚至有可能会出现负环,负环即为环上边权之和为负数的环。可以发现如果出现了这种环,任意两点只要联通并与负环相连,这两点间的最短路就是无穷小,原因在于可以在负环上一直走,不断减少路径长度。

那负权边呢?它不一定会招致负环,但有可能影响我们的决策,导致部分算法失效或复杂度超标,例如与逐层扩展的 BFS 相近的算法,如果有负权边就无法保证“逐层”这一特点,这点在之后思想相近的 Dijkstra 算法中体现尤为明显,因此,在实现过程中,我们通常也需要对这种情况进行判断并采取正确方式处理。

单源最短路

单源最短路顾名思义,即求出一个点对于每个点的最短路径,定义 \(dis_u\) 表示从源点到点 \(u\) 的最短路径,我们将在下文给出几种求解 \(dis\) 数组的方法。

Bellman-Ford

Bellman-Ford 算法主要依靠松弛操作,我们定义其为对于每一条边 \((u,v,w)\),都用 \(dis_u + w\) 尝试更新 \(dis_v + w\),可以发现当某轮松弛没有更新任何一个节点时就求出了最短路。

由于在无负环的图中,一个点的最短路路径中一定存在一条只有 \(n-1\) 条边和 \(n\) 个点的路径, 否则会形成简单环导致非最优。因此,考虑在这条最劣路径中边是从结尾到开头的顺序被遍历的,需要松弛 \(n-1\) 轮,而每轮松弛的时间复杂度是 \(O(m)\) 的,因此总时间复杂度就是 \(O(nm)\)

该算法同样可以判断有无负环,如果有负环则会松弛超过 \(n-1\) 轮,额外判断即可。

总结:存在负环的图也适用,时间复杂度 \(O(nm)\)

代码:

void bellmanford(){
	for(int i=1;i<=n;i++){
		int flag=0;
		for(int j=1;j<=m;j++){
			int u=e[j].u,v=e[j].v,w=e[j].w;
			if(dis[u]+w<dis[v]){
				flag++;
				dis[v]=dis[u]+w;
			}
		}
		if(i==n && flag){cout<<"exist";break;}
		else if(!flag) break;
	}
}

SPFA

关于 SPFA,它死了

SPFA 本质上是 Bellman-Ford 使用队列优化后的算法,优化点在于 Bellman-Ford 可能会进行很多实际上无效的松弛,SPFA 的具体操作如下:

  1. 将初始节点放入队列

  2. 从队列中取出一个节点,找到与它能到达的所有节点并尝试更新

  3. 如果可以更新,则将新节点放入队列

  4. 更新完后,如果队列里仍有节点,回到 2

可以看出这样的操作避免了很多的无效松弛,尽量保证了松弛的有效,能减少松弛次数。

SPFA 在随机图中复杂度是 \(O(kn)\) 的,其中可以将 \(k\) 看作一个常数,而且优点在于可以用于有负边的图,但是仍有可能被特殊图卡成 \(O(nm)\),不建议在一般图中使用。

代码:

void spfa(ll s){
	memset(dis,0x3f,sizeof(dis));
	dis[s]=0;v[s]=1;t.push(s);
	while(t.size()){
		ll u=t.front();t.pop();v[u]=0;
		for(ll i=0;i<l[u].size();i++){
			ll to=l[u][i].v,w=l[u][i].w;
			if(dis[to]>dis[u]+w){
				dis[to]=dis[u]+w;
				if(!v[to]){t.push(to);v[to]=1;}
			}
		}
	}
}

Dijkstra

如同我们在导言中提到的那样,Dijstra 算法的思想实际上与 BFS 相近,都是从浅层开始扩展,但与之不同的是,Dijstra 的“层”表示为离源点最近的若干节点,距离不固定,而 BFS 中边权恒为 1,因此每层距离固定,队列中也只有最多两层节点。

但是 Dijstra 的队列因为距离不固定,可能同时存在多个节点,而模仿 BFS,我们每次又要取出最前面的一层节点,此时优先队列就派上用场了,我们以 $ dis $ 数组为关键字,建立一个小根堆,优化后的具体步骤如下:

  1. 将源点放入堆,其 \(dis\) 为 0

  2. 取出堆顶,若曾经取出过这个节点则继续尝试直到节点有效或堆为空,并用此节点尝试更新其他节点

  3. 若更新成功,则将新节点和它目前的 \(dis\) 放入堆中

  4. 更新完后,如果堆里仍有节点,回到 2

可以看到,如同导言中提到的,由于 Dijstra 的本质是逐层扩展,所以只要一个节点第一次被取出,就无法被后面取出的节点更新(因为后面取出的节点 \(dis\) 肯定比它大),这既保证其复杂度为 \(O(m \log m)\),同样也会让负边权影响我们的决策,导致有可能后层先扩展。

读者同样也可以看出 Dijstra 与 SPFA 最大的差别在于堆和队列的使用,进而导致多次和一次更新的不同,进一步导致能否处理正负边权。

需要注意的是,在第 2 步中必须保证每个节点最多只更新其他节点一次(即第一次取出时),否则复杂度将会退化至 \(O(m \log^2 m)\)

代码实现:

void dij(int s){
	memset(dis,0x3f,sizeof(dis));dis[s]=0;
	t.push((Node){s,0});
	while(!t.empty()){
		int u=t.top().v;t.pop();
		if(vis[u]) continue;vis[u]++;
		for(int i=0;i<l[u].size();i++){
                        int w=l[u][i].second,v=l[u][i].first;
			if(!vis[v] && dis[u]+w<dis[v]){
				dis[v]=dis[u]+w;
				t.push((Node){v,dis[v]});
			}
		}
	}
}

单源最短路

Floyd

Floyd 算法的核心思想是动态规划,可以解决带有负边权的全源最短路问题,具体流程如下:

  1. 枚举中转点 \(k\) 和两点 \(u\)\(v\)

  2. 尝试用 \(dis_{u,k}+dis_{k,v} 更新 dis_{u,v}\)

  3. 没了

可以看到,Floyd 的实现过程其实非常简单,虽然复杂度为 \(O(n^3)\),不及 Johnson 算法,但胜在代码短,使用性高,而且在稠密图的表现更好,因此我们求全源最短路只要不是刻意卡时间,一般都用 Floyd。

另外,实现流程中还为我们提供了另外一个有用的信息:如果把转移公式变为:\(dis_{k,u,v}=\min(dis_{k-1,u,v},dis_{k-1,u,k}+dis_{k-1,k,v}\),就可以发现在第 \(k\) 轮之后 \(dis_{i,j}\) 储存的是经过编号不超过 \(k\) 的节点两点间最短路,而简化的柿子其实就是省掉阶段那一维之后的结果。

由于实现就是转移方程就不给代码了。

一些例题

差分约束算法

差分约束算法通常用于解决一组形如 $ x_a-x_b \leq c$ 或 $ x_a-x_b \geq c$ 的不等式的一组可行解。

首先先把问题弱化一下,对于一个未知数,通常我们是怎样求解集的?答案显然是把每个不等式解出来之后求交集,但是现在问题变成了求可行解且任意不等式都包含两个不相等的未知数。

考虑这样一种变换:假设已经求出了一组解,将这组解全部加上一个实数 \(x\),可以发现关系仍然成立,得到的数仍然是原来的一组解,这启发我们求出一组好求的,在一种特定情况下的解,在差分约束算法中,我们通常求的是一组最大非正整数解。

联系刚才的思考,因为任意一个形似 \(x_a-x_b \leq c\) 的不等式都可以化为 \(x_b-x_a \geq -c\) 进而化为 $ x_b+c \geq x_a $,而对于小于 0 的约束和后一种情况也可以化为类似的形式。

转化完毕后,看到这个大于等于号,有没有觉得有点熟悉?对,Bellman-Ford 算法不能松弛时 \(dis\) 间的关系!因此我们建出每个节点,让每个 $ a $ 向 \(b\) 连一条权为 \(c\) 的边,再建出超级源点 0,向每个点连一条权值为 0 的边,跑一遍最短路后即可得到答案。

上面做法建出 0 节点连边的目的在于保证每个点权小于 0,方便我们整体操作,又因为我们是用最短路求解,所以点权都会尽量接近于 0。

当最短路跑完时,按照 Bellman-Ford 算法的结论每条边都不能被松弛,即每个点都满足约束条件,于是题目得解,从这种方法理解差分约束更为自然流畅。

还有两点要注意,一是由于边有可能为负,应该用 SPFA 跑最短路,二是如果该不等式组无解,在图中表示为有负环出现,如果题目没说无解,还得注意判断。

同余最短路

一般解法

名字听着很高大上,但是其实不难而且很有意思。

在学习背包的过程中,我们有时候会遇到以下问题:给出一组物品和背包的容量,求把背包装满有多少种方法,同余最短路与这种问题有些相像,但是求解对象从计算数量变成判断可行性,一个基本的形式为:给出一组物品,求在一个范围内有多少种容量的背包可以被恰好装满。

考虑暴力,自然是 01 背包,每个容量都是一个状态然后转移,但是只要上限一大就不行了。

那这个问题又是怎么扯到最短路上的呢,故事还要从前面的同余开始说起,此算法的核心思想在于我们确定一个物品的体积为模数,设其为 \(m\),把它的完全剩余系拉出来,并把每个容量按照模 \(m\) 的值分类,求出每个同余类最小能够被表示出的容量。

举个例子,我们有两种物品,体积分别为 2 和 3,以 2 为 \(m\) 分类,模 \(m\) 为 0 的数最小可以被表示的为 0(一个东西都不拿),模 \(m\) 为 0 的数最小可以被表示的为 3。

为什么要这么干呢,因为如果一个容量 \(V\)\(m\)\(v\),剩余类 \(v\) 最小能被表示的数为 \(k\),只要 \(V \geq k\),那么 \(V\) 就可以被表示 \(k\) 的那几个物品和若干个标准物品加起来表示,而比 \(v\) 更小与其同余的显然不行,否则不符合定义。

接着就是跟最短路相关的部分了,我们对于每个同余类建点,设 \(a_i\) 为 第 \(i\) 个物品的体积,如果存在一个 \(a_i\) 使得 $ i + a_j \equiv k (\mod m)$,则从 \(i\)\(k\) 连一条边权为 \(a_j\) 的边,以 0 为源点跑一遍最短路即可。

需要注意的是有些题目源点的初值不一定是 0,还要根据具体题目具体分析。

最后,对于模数的选择,我们通常选最小体积,可以优化一些时间。

转圈解法

同余最短路实际上还有一个转圈解法,它比最短路解法要好写很多,但是貌似不太出名。

我们考虑对于每个物品 \(i\),它总共在图中创造出 \(m\) 条属于它的边(每个点都要连出去一条),而总共会形成 \(\gcd(m,a_i)\) 个简单环。

上面的结论是怎么来的呢,给两个图就理解了:

这个结论也可以说明每个简单环的长度是 $ \frac{m}{\gcd(m,a_i)} $ 的,因为每个点都属于一个简单环且环长度相同,因此 $ k + \frac{a_i m}{\gcd(m,a_i)} \equiv k (mod m)$。

这说明对于每个体积不为 \(m\) 的物品,我们最多用 \(\gcd(m,a_i)-1\) 个,否则可以换成若干个体积为 \(m\) 的物品,所以我们只要绕着环转两圈,每次从上一个转移过来,就可以考虑到所有转移。

简单的代码实现:

for(ll i=2;i<=n;i++){
 for(ll j=0,lim=gcd(a[i],m);j<lim;j++)
  for(ll k=j,c=0;c<2;c+=(k==j))
    ll p=(k+a[i])%m,f[p]=min(f[p],f[k]+a[i]),k=p;

例题

联通性相关

传递闭包

其实是很简单的一个小技巧,我们需要求出一个矩阵,如果 \(i\) 能到达 \(j\) 则它的第 \(i\) 行第 \(j\) 列为 1。

使用 Floyd 实现即可,不需要累加只需要判断 \(i\)\(k\)\(k\)\(j\) 是否联通即可。

值得一提的是,在普通的 Floyd 中,我们引入第三维 \(j\) 的目的是找到 \(i\) 前往的终点和判断长度关系,但是在传递闭包中,由于联通性只有联通与否,没有长短之分,而且,只要 \(i\) 能到 \(k\) 那么 \(k\) 能到的一切它都可以到。

因此,我们可以把数组的第二维压成一个 bitset,每次判断如果 \(i\) 可以到达 \(k\) 直接让 \(i\) 或上它即可,时间复杂度优化为 \(O(\frac{n^3}{w})\)

割点和割边

定义

思考这样一个问题:比特国有若干座城市,城市间有若干条道路,而且城市间都是联通的,不幸的是,有时其他国家会对比特国发动战争,这将导致有时一个城市或一条边被摧毁,但是好在比特国的基建发达,同一时间只会有一条道路或者城市被摧毁,现在比特国的国君有若干个问题,分别是在第 \(t\) 个时刻 \(a\) 城市和 \(b\) 城市联通吗。

发现这种问题在生活中也非常常见,但是实际问题往往更复杂,这里我们把模型理想化并加入了一些限制,方便我们进行归纳和求解。

那我们怎样解决呢?考虑只有一条道路的情况,发现这时只要城市间存在一条不一定经过这条道路的路径即可,换句话说:这条边不是两个城市间的必经边,而对于点的情况也相似,只要它不是两个点之间的必经点即可。

而将两个点之间的必经点和必经边,我们将它的概念扩充到整个图和联通块之中,便可以得到桥和割点的定义:如果去掉一条边后,整个图分为两个不联通的联通块,那么这条边是这个图的一个割边(有时也叫做桥);如果去掉一个点后,整个图分为两个不联通的联通块,那么这个点是这个图的一个割点。

结合上述题目,发现如果两个点在删掉一条边后不联通,那么此边一定是这个图的一座割边,因为如果不是,那么图仍联通,对于割点同理,因此,利用割点和割边我们可以解决许多实际的问题,我们将在下文给出两种求解方法。

求解

tarjan 求割点

我们从割边的定义出发,假设割掉这个点,那么这条边所连深度较深的节点所在的联通块一定到达不了上面的联通块,而如果一个节点搜索树的子树不经过此节点就到不了其他节点,那么这个点割掉后一定会形成两个联通块,因此,我们得到了一个割点判定的充要条件。

那我们怎么知道一个节点的子树能不能从另一个点处出这颗子树呢,注意到子树中每个节点的 dfs 序都大于根节点,考虑设 \(low_u\) 表示节点 \(u\) 能到达的 dfs 序最小的节点的 dfs 序,因此,只要子树内存在 \(low_v < dfn_rt\),那么该节点就不是割点。

现在问题转化为了如何求出 \(low\) 数组,我们模拟搜索的过程,为了方便统计,当我们遍历到一个没有访问过的节点时,访问它并在回溯时使 $ low_u \gets \min(low_v,low_u) $,如果仅更新 dfs 序(下文我们称作 dfn),就得在判断割点时遍历一遍子树,并且可以看到此操作不会影响正确性。

对于访问过的节点,我们仅使 $ low_u \gets \min(dfn_v,low_u) $,现在为什么又使用 dfn 呢?上面我们已经由搜索树的父节点继承子节点的答案了,只要有一条出去的边,就会被对应节点记录并上传该割点,不影响正确性。

而如果采取和上文一样的写法,我们就会造成误判,如下图所示:

红色的是树边,蓝色的是根节点,考虑由三条红色边相连的节点,存在一种情况,当它先将自己的 \(low\) 改为根节点的 \(low\) 而之后再遍历子树时(\(low\) 初始肯定为自己的 dfn,此编号最小,如果不采取这种写法也是错的,只需再给根节点加一颗子树即可),这个节点右下角的节点就会因为遍历到这个节点而把自己的 \(low\) 改为根节点的 \(low\),传到该节点时会误判其不是割点,但实际上它就是割点。

最后,只要在每个节点处判断,只要存在 \(v\) 使得 \(dfn_u = low_v\) 就说明这是个割点,但是根节点的情况有些不同,它之上没有节点了,我们改为判断它的子树个数是不是大于等于 2,由于子树具有独立性,每一条不在搜索树上的边都是连接一个节点和它的祖先,否则在搜索当前子树的时候必然会搜索到另外一颗子树,可知此做法正确。

代码实现:

void tarjan(int x){
	dfn[x]=low[x]=++tot;
	int flag=0;
	for(int i=0;i<l[x].size();i++){
		int v=l[x][i];
		if(!dfn[v]){
			tarjan(v);
			low[x]=min(low[x],low[v]);
			if(low[v]>=dfn[x]){
				flag++;
				if(x!=root || flag>1) vis[x]=1;
			}
		}
		else low[x]=min(low[x],dfn[v]);
	}
}
tarjan 求割边

割边于割点的情况类似,也是求出 \(low\) 数组之后判断,不过因为不能越过的不是点而是边,就不存在上图举的反例,因此可以写成 \(low[u] \gets low[v]\),不过为了方便记忆,我们还是写成一样的形式。

实现:

void tarjan(ll x){
	dfn[x]=low[x]=++tot;
	for(ll i=0;i<l[x].size();i++){
		ll v=l[x][i].v;
		if(!dfn[v]){
			tarjan(v);
			if(low[v]>=dfn[x]) vis[l[x][i].num]++;
			low[x]=min(low[x],low[v]);
		}
		else low[x]=min(low[x],dfn[v]);
	}
}
树上差分求解割边

首先,我们考虑如果要遍历整张图,那我们必然会走过每一条割边,也就是说,对于图 \(G\) 的任意一个生成树 \(T\),割边一定属于 \(T\)

知道了这点就为我们求出割边缩小了范围,考虑把求解转化为排除错误选项,由于每一条不在搜索树上的边都是连接一个节点和它的祖先,而这一条边会形成一个包含这条链上所有节点的环,因此这条链上的所有边都不是割边,我们可以直接使用树上差分进行标记,如下图所示。

上图中红色的边和黑色的树边形成了环,因此这之间所有的边都不是割边,在图上表示为任意两个蓝色节点之间的树边。

需要注意的是,这个方法仅能用于求解割边,在环中的点也有可能是割点,考虑一个三元环,而环上的一个节点 \(u\) 向另一个节点连了一条边,此时 \(u\) 就是割点,但它在环上。

代码与树上差分相似因此就不放了。

网络流相关

网络流

前言

在 oi 中,网络流常被认定为一个复杂且高深的问题,精通网络流貌似成了高水平的标志,但实际上网络流的定义和其中问题是所有图论中与实际生活联系最紧密和最形象的,最生动且易于理解的,希望读者在阅读此部分内容时可以抛弃畏难心理,仔细研究。

回到正题,在日常生活中,我们通常会遇到一系列与水流有关的问题,解决问题的限制通常是管道的容量限制和水流流过的价格限制,假如你是自来水厂的员工,那么如何使单位时间内流过的水流越多和使花费越少便成了一个不可避免的问题,这就是网络流的生活背景。

我们将学习解决此类问题的算法,不过网络流的精髓不在于解决,而在于建图,建图是人类智慧,上述问题只是一种最简单的版本,因此网络流的建模也成了我们尚需解决的问题。

定义

网络是指一张有向图 \(G=(V,E)\),对于每条有向边 \((u,v) \in E\) 存在一个容量限制 \(c(u,v)\),特别的,当 $(u,v) \notin E $ 时 \(c(u,v)=0\)

而在网络中通常有两个特殊的节点,源点 \(S \in V\) 和 汇点 \(T \in V\),无源汇的网络我们会在后面进行讨论。

无论哪一种网络,定义在其上的流函数总满足一下三个特征:

  • 容量限制:对于每条边,它流过的流量不超过它的容量,即 \(f(u,v) \leq c(u,v)\)

  • 斜对称:\(f(u,v) = -f(v,u)\),如果 \(v\)\(u\) 有 1 的流量,也可以说 \(u\)\(v\) 有 -1 的流量。

  • 流量守恒:除源点和汇点外,任意节点都满足 \(\sum f(i,u) = \sum f(u,j)\) 即流入总流量等于流出总流量,源点随便流,汇点随便汇。

而我们也有一些关于图和路径的定义,对于一条从源点到汇点且路径上仍有剩余容量(没流满)的边,我们称其为增广路,而所有没流满的边和所有节点所构成的网络叫做原网络的残量网络。

最大流

我们接下来要介绍的两个最短路算法采用了贪心的思想,即不断寻找增广路,找到就流,我们前面引入的反向边保证了这种贪心的正确性。

我们把反向边看作一种“退流”操作,如果有流量为 1 的流流经反向边,那么代表反向边的反向边(即正向边)剩余容量加 1,反向边剩余容量 -1,即退回原先增加过的流量,本质上是一种反悔贪心的操作。

我们举个例子,例如下图的网络:

图中红色的路径是我们一开始流的增广路,此时残量网络中源点和汇点已经不连通,但是此时仍不是最优答案,若我们增加上反向边,则还可以流出 一条增广路:

多出的增广路就是上图的蓝色部分,可以看到除了原本的边外,我们设置的反向边多余的容量支持了中间那条边逆着流,此时也相当于将原本中间的流退流,原本的一条流和当前流前后部分互换(实际情况可能更复杂),就成了新的两条流。

可以感性理解一下,只要还没到达最大流,那么就一定存在一条增广路使得通过路上的反悔边和正常边后,我们可以增加当前流量。

EK 算法

知道了贪心算法的正确性后,我们就可以来设计算法了,一个显然的暴力想法是每次 dfs 找增广路,直到无路可走,其时间复杂度为 \(O(|E| maxflow )\),那有没有更优的解法呢?

EK 算法在找增广路方面作了改动,它每次寻找一条最短的增广路进行增广,

参考资料

  1. oi-wiki 图论部分

  2. 网络流,二分图与图的匹配 - Alex_Wei

posted @ 2023-04-10 13:35  eastcloud  阅读(238)  评论(0)    收藏  举报