图论总结
图论
写在前面:本文将从图论的几大板块来梳理图论方面的知识。由于本篇博客的某些部分写于 2022 年,所以可能观感不是很好,请谅解。
另外,有任何表述不清或者表述有误的情况请及时提出来。
图的定义
- 图由顶点集 和边集 组成,记为 。其中 是边的有限集合,边是顶点的无序对(无向图)或有序对(有向图)。
- DAG,即有向无环图,之后的拓扑排序、网络流都会用到。
- 其他基本定义可以参见 oi-wiki。
- 下文中,一般用 表示顶点数 ,用 表示边数 。
图的存储
- 邻接矩阵:。
- 邻接表:。
代码:
int hd[N],cnt;
struct node{int to,nex,w;}e[M];
void add(int u,int v,int w)//加边
{e[++cnt] = {to,hd[u],w};hd[u] = cnt;}
//遍历
for(int i = hd[u];i = e[i].nex)
{
int v = e[i].to,w = e[i].w;
...
}
在一般写题的时候都是用的邻接表。邻接表也可以用 vector 代替,两种各有各的优势。如果在边比较少且访问次数较少的情况下,使用数组的常数会小一些;如果边比较多且会大量访问一些点的出边时,尽量使用 vector,因为用 vector 访问一个点的出边在内存中是连续的。
最短路相关
最短路,顾名思义,即求一条从 到 边权和最小的路径。
求最短路一般有 Floyed,Dijkstra,SPFA 三种算法。
然后你需要知道一些定义,一般用 表示起点到点 的最短路。松弛操作:对于一条边 ,如果有 ,我们就更新 。显然如果整张图都不能进行松弛操作了,那么 就是最短路了。
Floyed
Floyed 是用来求任意两点之间最短路的一个算法。
优点是代码短,常数小容易实现。缺点是时间复杂度高。
具体实现
我们定义一个数组 ,表示只允许经过结点 到 , 到 的最短路。那么 就是结点 到结点 的最短路长度。
首先考虑初始化,显然有:
转移就是:。
我们发现第一维对结果无影响,所以可以直接省略数组的第一维,于是可以直接改成 。
代码
for(int k = 1;k <= n;k++)
for(int i = 1;i <= n;i++)for(int j = 1;j <= n;j++)
f[i][j] = min(f[i][j],f[i][k]+f[k][j]);
综上时间复杂度是 ,空间复杂度是 。
Floyed 的应用:求最小环
我们考虑枚举这个环上编号最大的点,那么 和 构成了一个环。
所以每次在更新点 之前,先用 更新答案即可。
另外,这题还有个加强版:对于每个点,求出包含这个点的最小环。
我们考虑要求出包含一个点的最小环,需要先用其它所有点更新 ,那么直接做就是 。
观察发现,对于一个点 ,每次要更新 的点,我们每次清空数组重新算显然很浪费。于是直接用线段树分治,每次递归左儿子时更新所有右儿子的点,递归右儿子时更新所有左儿子的点,然后在叶子节点统计答案,这样的时间复杂度是 的。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define clr(a) memset(a,0x3f,sizeof a)
#define ll long long
using namespace std;
const int N = 305,inf = 0x3f3f3f3f;
int e[N][N],f[N][N],g[12][N][N],ans[N],n,cnt;
inline void add(int k)
{
for(int i = 1;i <= n;i++)if(f[i][k] != inf)
for(int j = 1;j <= n;j++)if(f[k][j] != inf)
f[i][j] = min(f[i][j],f[i][k]+f[k][j]);
}
inline void solve(int l,int r)
{
if(l == r)
{
for(int i = 1;i <= n;i++)if(e[l][i])
for(int j = i+1;j <= n;j++)if(e[l][j])
ans[l] = min(ans[l],e[l][i]+e[l][j]+f[i][j]);
return ;
}
int mid = l+r>>1;
memcpy(g[++cnt],f,sizeof f);
for(int i = mid+1;i <= r;i++)add(i);
solve(l,mid);
memcpy(f,g[cnt],sizeof f);
for(int i = l;i <= mid;i++)add(i);
solve(mid+1,r);cnt--;
}
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
freopen("road.in","r",stdin);
freopen("road.out","w",stdout);
clr(f);clr(g);clr(ans);
rd();n = rd();
for(int i = 1,x;i <= n;i++)for(int j = i+1;j <= n;j++)
if(~(x = rd()))e[i][j] = e[j][i] = f[i][j] = f[j][i] = x;
solve(1,n);
for(int i = 1;i <= n;i++)
printf("%d ",ans[i]==inf?-1:ans[i]);
return 0;
}
Dijkstra
Dijkstra 算法是一种求解单源最短路的算法,可以在带权有向图中找到每一个点到起点的最短距离。
思路:首先把起点到所有点的距离存下来找个最短的,然后松弛一次再找出最短的,所谓的松弛操作就是,遍历一遍看通过刚刚找到的距离最短的点作为中转站会不会更近,如果更近了就更新距离,这样把所有的点找遍之后就存下了起点到其他所有点的最短距离。
注意事项:Dijkstra 不能处理负权边。
时间复杂度:
- 朴素 Dijkstra:
- 堆优化 Dijkstra:
代码
int dijkstra(int s,int t)
{
memset(dis,0x3f,sizeof dis);
q.push({dis[s] = 0,s});
while(!q.empty())
{
int u = q.top().second;q.pop();
if(vis[u])continue;vis[u] = 1;
for(int i = hd[u],v;i;i = e[i].nex)
if(dis[v = e[i].to] > dis[u]+e[i].w)
dis[v] = dis[u]+e[i].w,q.push({-dis[v],v});
}
return dis[t];
}
Bellman-Ford
思路:我们定义一次操作为,枚举所有边,看这条边能不能进行松弛操作,然后进行 次操作即可,同一个点肯定不会被松弛超过 次,时间复杂度为 ,一般用来判负环。
SPFA
思路:SPFA 本质上就是能进行松弛操作就松弛,直到无法松弛位置。具体做法是,维护一个队列,表示可能可以进行松弛操作的点,每次拿出队头,然后更新连接的点,如果成功进行了松弛操作,那么就把这个点加入队列。一直循环直到队列为空。
时间复杂度:SPFA 的时间复杂度非常玄学,平均是 ,其中 是一个较小的常数,但可能被特殊的图卡成 ,所以在要用最短路时,能不用 SPFA 就尽量不要用。另外,如果一道题标算要用到 SPFA,就必须要开到 能过的数据范围。
适用范围:SPFA 的一个重要功能就是用来找负权环,也可以用来处理有负权边的图,比如在求最小费用最大流时会用到。另外,SPFA 的运用不止局限于求最短路,像某些知道了如何设状态和如何转移,但是不知道转移的具体顺序的题,那就可以用 SPFA 一直转移直到不能转移为止。
代码
void spfa(int s)
{
for(int i = 1;i <= n;i++)dis[i] = inf;
q.push(s);dis[s] = 0;
while(!q.empty())
{
int u = q.front();q.pop();vis[u] = 0;
for(int i = hd[u],v;i;i = e[i].nex)
if(dis[v = e[i].to] > dis[u]+e[i].w)
{
dis[v] = dis[u]+e[i].w;
if(!vis[v])q.push(v),vis[v] = 1;
}
}
}
SPFA 求负环
如果图中存在一个负环的话,就会造成 SPFA 一直松弛。如何判断呢,只需要记录每个点的入队次数,如果一个点的入队次数超过了 ,那么图中一定存在一个负环。
当然还有还可以使用 Bellman–Ford,即在 次操作后看还能不能进行松弛操作,如果能就说明存在负环。因为要判负环的题目数据范围都允许 通过,所以可以直接使用 Bellman–Ford,代码好写而且常数小。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define ll long long
using namespace std;
const int N = 2005,inf = 0x3f3f3f3f;
int dis[N],hd[N],cnt,n,m;
struct node{int to,nex,w;}e[N << 2];
void add(int u,int v,int w)
{e[++cnt] = {v,hd[u],w};hd[u] = cnt;}
bool up()
{
bool flag = 0;
for(int u = 1;u <= n;u++)if(dis[u] != inf)
for(int i = hd[u],v;i;i = e[i].nex)
if(dis[u]+e[i].w < dis[v = e[i].to])
dis[v] = dis[u]+e[i].w,flag = 1;
return flag;
}
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
// freopen(".in", "r", stdin);
// freopen(".out", "w", stdout);
for(int t = rd();t--;)
{
n = rd();m = rd();cnt = 0;
for(int i = 1;i <= n;i++)dis[i] = inf,hd[i] = 0;
dis[1] = 0;
for(int i = 1;i <= m;i++)
{
int u = rd(),v = rd(),w = rd();
add(u,v,w);
if(w >= 0)add(v,u,w);
}
for(int i = 1;i <= n;i++)up();
puts(up()?"YES":"NO");
}
return 0;
}
总结:
Johnson 全源最短路径
Johnson 和 Floyd 一样,是一种能求出无负环图上任意两点间最短路径的算法。
如果是对于一个边权非负的图,我们可以直接对于每个点当作起点,跑一遍 Dijkstra,这样子时间复杂度是 ,但是这样处理不了带负权边的图。
我们考虑如下一种做法:新建一个虚拟节点,向所有点连一条长度为 的边,然后以这个点为起点跑一遍 SPFA,求出到每个点的最短路 。然后我们每条边 的边权设为 ,显然有 。这时候再对所有点跑 Dijkstra 即可。
证明:考虑任意一条从 到 的一条路径,它的边权和一定等于 , 表示原图中路径的边权和,因为 是固定的,所以求出来的最短路长度 一定就对应着原图中 到 的最短路 。
最短路树
最短路树,即一棵有边权的有根树,满足根节点到任意一个点的路径长度等于原图中根节点到这个点的最短路。具体求法:把所有满足 的边 拉出来,那么任意一棵生成树都是最短路树。至于这个有什么用,将在下面的例题中提到。
优化建图
优化建图一般适用于一些区间向区间连边,点集向点集连边等情况,这些情况下一般边数会很多,我们一般要加入一些虚点来减少边数。比如下面几个例子:
- 一个点向区间连边:我们考虑将所有点建一棵线段树,那么只需要将点向线段树上区间对应的节点连边即可。另外,为了满足原图的性质,一般还要所有线段树上的父亲节点向两个儿子节点连边。
- 区间向区间连边:仍然是用线段树,两个区间都拆成 个节点后,我们新建一个虚点,第一个区间对应的所有节点向区间连边,然后虚点向第二个区间对应的所有节点连边。
- 点集向点集连边:同上,第一个点集向虚点连边,然后虚点向第二个点集连边。边权应该视题目分析,比如点 到 的边权是 ,那么就应该 向虚点连 的边,虚点向 连 的边。
同余最短路
同余最短路一般用于解决类似“给定一些数,求这些数最大的不能拼出来的数”之类的问题。
先看一道例题:P3403 跳楼机
给定 ,求有多少个 满足
我们设 表示最小的能凑出来的满足 的数。那么有如下两种建边方式:
然后我们以 为源点,跑出到所有点的最短路,答案即为 。
如果有多个数的话是同理的,我们可以选择最小的数作为模数,这样时间复杂度更小。
注意,我们不用把所有边真正的建出来,到一个点时枚举所有的出边即可。然后由于同余最短路图的特殊性,SPFA 会比 Dijkstra 跑得快,所以一般写 SPFA。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <queue>
#define ll long long
using namespace std;
const int N = 1e5+5;
ll dis[N],h,ans;
bool vis[N];
int x,a[3];
queue<int> q;
void SPFA()
{
memset(dis,0x3f,sizeof dis);
dis[1%x] = vis[1%x] = 1;q.push(1%x);
while(!q.empty())
{
int u = q.front();q.pop();vis[u] = 0;
for(int i = 1;i < 3;i++)
{
int v = (u+a[i])%x;
if(dis[v] > dis[u]+a[i])
{dis[v] = dis[u]+a[i];if(!vis[v])vis[v] = 1,q.push(v);}
}
}
}
inline ll rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
ll x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
//freopen(".in", "r", stdin);
//freopen(".out", "w", stdout);
h = rd();x = rd();a[1] = rd();a[2] = rd();
SPFA();
for(int i = 0;i < x;i++)
if(dis[i] <= h)ans += (h-dis[i])/x+1;
cout << ans;
return 0;
}
类似的题目:P2371 [国家集训队] 墨墨的等式,P2662 牛场围栏。
差分约束
差分约束用于解决变量间有各种约束条件的问题。
现在有 个变量 和 条限制,每条限制形如 ,你需要求出一组合法的解,或报告无解。
我们注意到限制 与最短路中的三角不等式 非常相似,因此我们可以把每个变量看作一个节点,每个限制看做一条 的边。然后新建一个虚点向每个点连边权为 的边,跑单源最短路,如果原图存在负环就无解,否则 就是一组合法的解。判断负环可以使用 Bellman–Ford。当我们求出任意一组合法的解后,所有变量同时加上一个数 一定也是一组合法的解。
如果固定了某个变量的初始值,那么我们就将其作为起点,要求一个变量 最大的取值就是从起点出发的最短路长度 。证明就是对于任意一条从起点出发到 的路径,边权和为 ,那么一定有 ,所以 的上界就是所有 中最短的,即 ,又因为 就是原问题的一组解,于是我们知道了 的上界是 ,并构造出了一组合法解使得 ,即证。
同样的如果我们将所有限制转化为 ,然后也连边 。对原图跑一遍最长路求出每个点的 ,那么 就是每个变量的最小值。
结论就是求最大值用最短路,求最小值用最长路。
差分约束的例题主要难点在于意识到这是要用差分约束,然后合理转化限制,下面是一些常用的转化:
- ,即 ,连边 。
- ,即 ,连边 。
- ,即 ,连边 和 。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define ll long long
using namespace std;
const int N = 5005;
int d[N],hd[N],cnt,n,m;
struct node{int to,nex,w;}e[N << 2];
void add(int u,int v,int w)
{e[++cnt] = {v,hd[u],w};hd[u] = cnt;}
bool update()
{
bool ok = 0;
for(int u = 1;u <= n+1;u++)
for(int i = hd[u],v;i;i = e[i].nex)
if(d[v = e[i].to] > d[u]+e[i].w)
{ok = 1;d[v] = d[u]+e[i].w;}
return ok;
}
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
// freopen(".in", "r", stdin);
// freopen(".out", "w", stdout);
n = rd();m = rd();
for(int i = 1;i <= m;i++)
{
int op = rd(),u = rd(),v = rd(),c;
if(op == 1)add(u,v,-rd());
else if(op == 2)add(v,u,rd());
else add(u,v,0),add(v,u,0);
}
memset(d,0x3f,sizeof d);
for(int i = 1;i <= n;i++)add(n+1,i,0);
for(int i = 1;i <= n+1;i++)
if(!update())break;
puts(update()?"No":"Yes");
return 0;
}
其余例题放在下面。
例题
先是一些有关分层图和最短路的。
P4568 [JLOI2011] 飞行路线
给定一个带边权的图,可以免费走 条边,求 到 的最短路。
分层图模板题。把原图复制 次,每层中间连原图中的边,边权为 ,然后跑 到每一层 的最短路即可。
acwing340 通信线路
给定一个带边权的图,求出一条 到 的路径,使得第 大的边权尽量小。
考虑二分答案,将边权 的边设为 ,否则设为 ,那么就是要看是否存在一条 到 的路径走不超过 条边权为 的边,也就是求 到 的最短路,可以直接用 01bfs,时间复杂度 。
P3119 [USACO15JAN] Grass Cownoisseur G
给定一个有向图,从 出发,走一条路径,中途可以逆行一次,最后回到节点 ,求最多能经过多少个节点(重复经过算一次)。
先缩点,一个强连通分量里的点一定可以都经过完,然后问题就转化为在一个 DAG 上考虑。建出两层图,然后中间连反边,求从第一层 出发,到第二层 的最长路即可。
P6961 [NEERC2017] Journey from Petersburg to Moscow
给定一个带边权的图,求出一条 到 的路径,使得前 大的边权和尽量小。
如果我们知道了第 大的边权是 ,那么可以把所有边的边权减去 ,然后对 取 ,求出一条从 到 的最短路,再将答案加上 即可。
然后考虑如果 不是第 大的边权,这个做法会有什么问题。若 比实际的边权大,那么我们算出来的前 大的边权要比实际的大,所以答案会偏大;若 比实际的边权小,那么会将前 大以外的边也计入答案,所以答案还是会偏大。
现在我们知道了只有枚举的边权 是最终答案路径的第 大时,结果才是准确的,否则都会偏大。于是我们可以枚举所有边权和 (因为路径可能没有 条)作为第 大的边权,求一便答案,然后取 即可。时间复杂度 。
P5304 [GXOI/GZOI2019] 旅行者
给定一个带边权的图,有 个关键点,求任意两个关键点间的最短路的最小值。
首先这题有个乱搞,将 个关键点任意分成两组,然后建一个源点向一组的关键点连边权为 的边,另一组的关键点向汇点连边权为 的边,然后求一便源点到汇点和汇点到源点的最短路。设答案是从 到 的最短路,那么能求到答案当且仅当 和 被划分到了不同的组,概率为 ,多随机几次即可取最小值。
我们观察到 和 二进制上一定有某一位不一样,于是考虑枚举每一位,二进制分组,然后跑最短路即可,时间复杂度 。
其实这题有单 的做法,我们先在正向图上以 个点为起点,同时跑最短路,求得到每个点的最短路 ,再在反向图上求得 。然后枚举每一条边 ,用 更新答案。但这样有可能出现 和 都是同一个关键点更新的,这样就会有问题,所以还需要记录每个点是由哪个起点转移过来的,只有两个点的起点不一样才能更新答案。可以证明,在答案的最短路上,一定存在一条边,使得两个点的起点不一样,于是一定可以更新到答案。
P2685 [TJOI2012] 桥
给定一张带边权的无向图,求出删去每条边后的最短路长度。
首先求出一条 到 的最短路,如果删的边不在最短路上,那么最短路长度肯定不会变。
如果删去了一条最短路上的边,那么有个性质,新的最短路最多只有一段不在原来的最短路上,因为如果有多于一段不在的话总是可以把这段移到最短路上,这样一定更优。
先求出 到每个点的最短路 和 到每个点的最短路 ,然后枚举一条不在最短路上的边 。显然我们可以用 作为一条新的最短路长度,问题就是这个长度能用来更新哪些最短路上删去的边。
我们建出以 和 为根的最短路树,求出 在第一棵树上和 的 LCA ,以及 在第二棵树上和 的 LCA ,那么 到 之间的边被删去后都可以被这个长度更新到。于是问题就变成了区间取 ,最后单点查询,可以用线段树或 st 表维护。
CF1163F Indecisive Taxi Fee
给定一个带边权的无向图, 此询问,每次将一条边的边权改为 之后, 到 的最短路长度。询问间相互独立。
记 表示 到每个点的最短路, 表示 到每个点的最短路, 表示原来最短路长度,修改的边为 。
我们可以将询问分为三种情况:
- 修改的边不在最短路上,那么答案就是
- 修改的边在最短路上,且边权变小了,答案为
- 修改的边在最短路上,且边权变大了。这时候就分两种情况,是否经过这条边。如果经过,显然路径长度为 ;如果不经过,就相当于求删去了这条边最短路的长度,那么就跟上面的题一样了。
然后是优化建图的典题。
CF786B Legacy
给定一个 点的图,有以下三种连边方式:
1 u v w
,表示 向 连一条长度为 的边。2 u l r w
,表示 向区间 连一条长度为 的边3 l r v w
,表示区间 向 连一条长度为 的边求起点 到所有点的最短路长度。
线段树优化建图模板。考虑建两棵线段树,第一棵树所有儿子向父亲连边,第二棵树所有父亲向儿子连边,对应的叶子节点也要互相连边。
连边就是第一棵树上区间对应的节点连第二棵树上区间对应的叶子节点,然后跑最短路即可。
P6348 [PA2011] Journeys
给定一个 点的图,有 个区间到区间的无向边,求起点 到所有点的最短路
和上面的题一样,建两棵线段树。连边的话考虑新建一个虚点,第一个区间对应的所有节点向区间连边,然后虚点向第二个区间对应的所有节点连边。然后因为这题边权只有 01,所以跑 01bfs 即可。
下面是差分约束的题。
P3275 [SCOI2011] 糖果
个非负整数变量, 条限制,每条限制形如 ,求 个变量和最小是多少。
建出差分约束系统,因为是要求最小是多少,所以跑最长路即可。
CF241E Flights
有一张 DAG,你需要给每条边赋一个 或 的边权,使得所有从 到 的路径长度相同,或者报告无解。
处理出 能到达且能到达 的节点。设 表示 到 的最短路。那么对于一条边 ,有 ,跑差分约束看是否有解即可。
同样的题目还有 P5590 赛车游戏。
INTERVAL - Intervals
有 个区间,在区间 中至少取任意互不相同的 个整数。求在满足 个区间的情况下,至少要取多少个正整数。
设 表示前 个数中取了多少个数,那么有 。对于每个区间,有 ,建出差分约束,跑最长路即可。
[AGC056C] 01 Balanced
构造一个长度为 的 01 字符串,满足 个条件,每个条件给出一个长度为偶数的区间 ,要求区间内 0 和 1 的数量相同,求出字典序最小的字符串。
设 表示前 个数中 的个数减 的个数,那么有 ,这个条件不太好做,我们改成 。对于每个区间 ,有 。可以发现所有边权都是 01,于是跑一遍 01 bfs 即可。这样子即使有可能 ,也不是最优的,所以不影响答案。
P2474 [SCOI2008] 天平
有 个砝码,重量均为 克。你并不清楚每个砝码的重量,但知道其中一些砝码重量的大小关系。把其中两个砝码 A 和 B 放在天平的左边,需要另外选出两个砝码放在天平的右边。问:有多少种选法使得天平的左边重、一样重、右边重?(只有结果保证唯一确定的选法才统计在内)。
,保证至少存在一种情况符合该矩阵。
我们设 表示砝码 和 重量差的最大值, 表示最小值。初始值为:
- 如果两个砝码一样重,则
- 如果砝码 比砝码 重,则
- 如果砝码 比砝码 轻,则 ;
- 否则 。
然后我们需要更新所有的 ,即 。
最后统计答案,枚举 :
- 如果 或者 ,说明左边重。
- 如果 或者 ,说明两边一样重。
- 如果 或者 ,说明左边轻。
P3530 [POI2012] FES-Festival
有 个非负整数变量, 条限制,每条限制形如 或 ,求最多有多少个变量取值。
仍然是建出差分约束,然后考虑缩点,因为两个不同的强连通分量内,它们只有个相对大小,所以是互不影响的,所以答案是所有强连通分量内的答案的和。
而对于一个强连通分量,我们求出任意两点间的最短路,那么答案就是任意两点间的最短路的最大值 。求任意两点间的最短路可以直接用 Floyed 解决。
P4926 [1007] 倍杀测量者
有 位选手,每个选手有一个正实数分数 ,当一位选手 A 的分数不小于选手 B 的分数 倍时,我们称选手 A 倍杀了选手 B,选手 B 被选手 A 倍杀了。有不少选手立下了诸如 “我没 倍杀选手 X,我就女装”,“选手 Y 把我 倍杀,我就女装” 的 Flag。
为了维持机房秩序,教练放宽了选手们的 Flag 限制,设定了了一个正常数 ,立下 “我没 倍杀选手 X 就女装” 的选手只要成功 倍杀了选手 X,就不需要女装。同样的,立下 “选手 Y 把我 倍杀我就女装” 的选手只要没有成功被选手 Y 倍杀,也不需要女装。
现在已经知道了一些选手的分数,确定最大的实数 使得赛后一定有选手收 Flag 女装,或报告无解。
首先问题具有单调性,可以二分答案。我们把限制转化为 和 ,这个看起来长得有点像差分约束,但是又不太能做。
我们对等式两边同时取 ,得到 ,然后把 看成一个变量,就可以用差分约束了。(这题卡 Bellman-Ford,记得写 SPFA)
P7515 [省选联考 2021 A 卷] 矩阵游戏
有一个 的矩阵 ,满足 ,现在已知一个 的矩阵 ,满足 ,给定矩阵 ,你需要求一个满足条件的矩阵 ,或报告无解。
如果没有 的限制,我们可以把 的第一行和第一列都填上 ,那么剩下位置的数都可以算出来了。
但是这样有可能满足不了 的性质,我们考虑如果给一个位置加上一个数,怎样才能不造成影响。
可以发现,我们可以给一行的偶数位置加上 ,奇数位置减去 ,这样子仍然是满足条件的,一列也同理。于是可以设 表示给第 行的偶数位置加上 ,奇数位值减去 ; 表示给第 列的偶数位置加上 ,奇数位值减去 。我们设 表示 增加了多少,就要满足限制 ,即 ,似乎可以差分约束做了?
但其实还不能用差分约束,比如说 ,两个变量的和无法使用差分约束。
我们考虑将奇数行的 取反,偶数列的 取反,于是有 。每个位置都是两个变量相等,就可以使用差分约束了。
AGC036D Negative Cycle
有一个 个点的带边权的有向图,初始时有 条边,为 。
对于每一对 ,如果 ,会加入边 ,否则加入 。删除边 需要花费 的代价(不能删除初始的边)。
现在要花费最小的代价删边,使得图不存在负环。。
因为原图要求不存在负环,考虑转化为差分约束的模型,每个点代表一个变量 ,即是否存在一组解,满足原图中的限制。
因为有边 ,所以 ,我们设 ,则 。
如果有一条边 ,表示 ,即 ,即 。
如果有一条边 ,表示 ,即 ,即 。
反过来,如果确定了 ,那么就要删所有不满足条件的的边。我们发现如果存在一个 ,那么把这个 改成 一定不劣。所以 的取值一定只有 01。那么要删除的区间就是所有 连续段的任意一个区间和任意一个包含了至少两个 的区间。
考虑设 表示钦定 ,上一个值为 的是 ,转移就枚举倒数第二个 在哪里。转移的贡献可以写成 中一个矩形的和,于是可以先求一遍二位前缀和,就可以 转移了。时间复杂度 。
下面是关于同余最短路的题。
[ABC077D] Small Multiple
给定一个正整数 ,求一个 的倍数 ,使得 的数位累加和最小。
注意到任意一个数都可以通过从 开始,通过 操作得到。
那么设 表示 的数中,数位累加和最小是多少。
有边 。初始化 ,答案就是 。
P9140 [THUPC 2023 初赛] 背包
背包,有 个物品,每个物品体积为 ,价值为 , 次询问,每次给定 ,求在体积和 恰好 为 的情况下,价值最大是多少。
因为 远大于 ,所以最终方案一定是选了很多 最大的物品,我们设 最大的物品为基准物品,它的体积和价值分别为 。
设 表示体积 的背包中,价值减去全部用基准物品的价值最大是多少。那么答案就是 。
那么边就是 ,于是就可以同余最短路了。
[AGC057D] Sum Avoidance
给定一个数 ,称一个正整数集合 是好的,当且仅当 满足每个数都在 内,且不能通过多次相加 中的数得到 。考虑在 元素最多的情况下字典序最小的 ,多次询问,每次给定 ,求集合 第 小的数是多少。
引理 1:。
证明:我们考虑任何一对 ,这两个数中最多只有一个数会被选,如果 是偶数,那么还有 不能选。于是 有一个上界 。然后如果我们选择 中的所有数,这显然是一个合法的集合。于是我们就证明了 的上界,并且构造出了一组和法解,证毕。
所以 ,最终的答案集合中,一定是每对 中恰好有一个数出现在集合中。
现在,我们设集合 为最终的答案集合,,那么我们可以通过集合 来推出 。
引理 2:若集合 中的一些数相加能得到 ,且 ,则 。
证明:假设 ,则 ,因为 中的数可以凑出 ,又有一个数 ,那么就能凑出 了,所以 必须在集合 中。
引理 3:若 中的数不能凑出 ,那么 一定也不能凑出 。
证明:假设 中的数不能凑出 , 中的数能凑出 ,那么这些凑出 的数中一定有一个是 ,假设为 ,这就意味着还存在一些数能凑出 ,但是根据引理 2,,应该要加入集合 ,矛盾。
根据引理 3,我们只需要求出集合 的最小字典序,就等同于求出了答案 。这样有个好处就是 没有大小的限制,我们可以直接贪心看每个数能不能加入,这样子显然是对的。
然后看第一个应该加入的数是什么,假设这个数为 ,根据上面的贪心,显然就是第一个不是 的因数的数。根据直觉,这个数应该不会太大,实际上当 时,。
接下来我们把加入的数分为两类,第一类是能被 的数表示出来,第二类是加入了这个数后剩下的数仍然不能凑出 ,应该贪心加入。
从 的剩余系角度考虑,如果一个数是以第二类加入的,那么这个数一定和之前的数都不同余,所以以第二类加入的数不超过 个。这启发我们想到求出 的同余最短路。
我们设 表示能凑出的最小的 的数,第一类情况加入的数不会对数组产生影响,于是只需要考虑第二类数是哪些。
假设我们加入了一个第二类数 ,设 ,首先应该有有 ,其次就是再更新完整个数组后应该满足 。
如果加入了一个 ,那么最多会使用 个 来更新数组,因为 个 可以用 个 来代替。于是有更新:,因为要让 ,有 ,于是 。
于是我们能求出 的下界:。然后这一次应该加入的数就是 。于是我们就能在 的时间内求出集合 的同余最短路了。
然后要求出第 小的数,我们可以二分答案,求出一个 的排名,然后看与 的大小关系进行二分即可,最终时间复杂度 。
最小生成树
最小生成树(也叫 MST),指连通图上边权和最小的生成树。一般求最小生成树有这些算法:Kruskal、Prim、Boruvka。
Kruskal
将所有边按边权从小到大排序,然后依次加边,如果两个点不在同一个连通块内,则加入最小生成树,否则忽略。时间复杂度为 。
图示:
证明(搬自 oi-wiki)
思路很简单,为了造出一棵最小生成树,我们从最小边权的边开始,按边权从小到大依次加入,如果某次加边产生了环,就扔掉这条边,直到加入了 条边,即形成了一棵树。
证明:使用归纳法,证明任何时候 K 算法选择的边集都被某棵 MST 所包含。
基础:对于算法刚开始时,显然成立(最小生成树存在)。
归纳:假设某时刻成立,当前边集为 ,令 为这棵 MST,考虑下一条加入的边 。
如果 属于 ,那么成立。
否则, 一定存在一个环,考虑这个环上不属于 的另一条边 (一定只有一条)。
首先, 的权值一定不会比 小,不然 会在 之前被选取。
然后, 的权值一定不会比 大,不然 就是一棵比 还优的生成树了。
所以, 包含了 ,并且也是一棵最小生成树,归纳成立。
Prim
Prim 算法的思想是不断加点而不是加边。具体地,从一个点开始,每次选择距离最小的那个点加入最小生成树,然后更新其他点的距离。这个做法和 Dijkstra 比较类似,每次可以暴力找或者用堆优化。一般用于稠密图上,时间复杂度为 。
证明(搬自 oi-wiki)
从任意一个结点开始,将结点分成两类:已加入的,未加入的。每次从未加入的结点中,找一个与已加入的结点之间边权最小值最小的结点。
然后将这个结点加入,并连上那条边权最小的边。
重复 次即可。
证明:还是说明在每一步,都存在一棵最小生成树包含已选边集。
基础:只有一个结点的时候,显然成立。
归纳:如果某一步成立,当前边集为 ,属于 这棵 MST,接下来要加入边 。
如果 属于 ,那么成立。
否则考虑 中环上另一条可以加入当前边集的边 。
首先, 的权值一定不小于 的权值,否则就会选择 而不是 了。
然后, 的权值一定不大于 的权值,否则 就是一棵更小的生成树了。
因此, 和 的权值相等, 也是一棵最小生成树,且包含了 。
Boruvka
Boruvka 是另一种最小生成树算法,虽然在单纯求最小生成树时可能没有优势,但对于求一些完全图的最小生成树时有奇效。
算法流程:初始时每个点属于一个连通块,然后有很多轮,每一轮时枚举所有的边,如果这条边连接了两个不同的连通块,那么就更新这两个连通块连出去的最小的边。最后加入每个连通块连出去的最小的边,知道只剩一个连通块。
因为每次连通块大小都会减半,所以时间复杂度是 。
Boruvka 算法的优势就在于每次求出每个连通块连出去的最小边的时候,不一定需要枚举所有边,而是可以用数据结构来维护,这样我们就可以在较好的时间复杂度内求出完全图的最小生成树。
最小生成树的性质
- 如果一条边是一个环中边权严格最大的边。那么这条边一定不在最小生成树里。
- 最小生成树的唯一性:如果一条边不在最小生成树的边集中,并且可以替换与其权值相同、并且在最小生成树边集的另一条边。那么,这个最小生成树就是不唯一的。在 Kruskal 算法中,如果一个边权的实际边数要大于在最小生成树里的边数,就说明最小生成树不唯一。
- 对于一个图的 MST,我们可以先把图的边划分成几个集合,对于每个集合分别求 MST,只保留 MST 里的边,最后再做一次 MST,这样一定可以得到原图的 MST。证明的话就是每个边集中不是 MST 的边放到最后一定也不是 MST,所以可以直接舍去。
最小瓶颈路
最小瓶颈路指的是从 到 的所有简单路径中,最大值最小的路径。根据最小生成树定义,最小瓶颈路就是最小生成树上两点路径上边的最大值。如果要查询的话可以简单用树剖或者 st 表简单维护。
Kruskal 重构树
考虑 Kruskal 的过程,首先每个点都是一个连通块,根节点是它们自己。
然后从小到大加边,如果这条边连接了两个不同的连通块,则新建一个点,点权为这条边的边权,左右儿子分别为这两个连通块的根节点。然后合并这两个连通块,新建的点作为这个连通块的根节点。
经过 轮后,我们会得到一个大小为 、有 个叶子的二叉树,每个非叶子节点恰有两个儿子,这棵树就是 Kruskal 重构树。
Kruskal 重构树:
这棵树有一些性质:
- 两个点的最小瓶颈路权值等于这两个点在 Kruskal 重构树上的 LCA 的点权。
- 一个点走所有 的边能到达的点等同于这个点一直向上跳祖先,跳到最后一个点权 的节点,这个节点子树的所有叶子节点。因为父亲的点权一定不小于儿子的点权,所以可以倍增求解这个点。
- 如果要求点权的 Kruskal 重构树,应该把边权设为连接的两个点的点权的 ,然后再求 Kruskal 重构树。
注意:使用 Kruskal 重构树时记得把数组开两倍。
模板题:P1967 [NOIP2013 提高组] 货车运输。例题在下面提到。
例题
先是一些最小生成树的例题。
P4180 [BJWC2010] 严格次小生成树
给定一棵树,求严格次小生成树的大小,即权值严格大于 MST 中最小的生成树。
首先有个性质,次小生成树最多只有一条边与最小生成树不同,因为如果有两条那么一定可以把一条边换成更短的。
于是我们先求一遍 MST,然后枚举是哪一条非树边是次小生成树的。对于一条非树边 ,我们可以把最小生成树上 路径上的任意一条边替换为这条非树边。因为我们要让生成树权值尽可能小,所以要让替换的边的边权尽可能大。
我们求出这条路径上的最大值和严格次大值,如果这条边的边权大于最大值,就用最大值替换;否则只有可能等于最大值,用次大值替换。求出所有非树边的答案后取最小值即可。维护路径上的最大值和次大值可以使用倍增。
CF160D Edges in MST
给一个带边权的无向图,你需要确定每一条边是以下三种情况哪一个:一定在所有 MST 上、可能在某个 MST 上、一定不可能在任何 MST 上。
首先我们求出一棵最小生成树。
对于每一条非树边 ,这条边能出现在一棵最小生成树上当且仅当树上 这条路径上存在一条边的长度 ,那么这条边就可以替换掉它,答案为 "可能在某个 MST 上";否则就是“一定不可能在任何 MST 上”。然后就是一个求树上路径 的问题。
对于一条树边 ,就是要看这条边是否能被替换,即看是否有一条覆盖这条边的非树边的边权 。我们对于每一个非树边,让整个路径对其边权取 ,最后只需要看这个边权是不是等于树上的边权,如果是就说明这条树边 "可能在某个 MST 上";否则就是 "一定在所有 MST 上"。
对路径取 和求路径 都可以用 st 表维护,时间复杂度是 。
CF1184E3 Daleks' Invasion (hard)
给一张带边权的图,对于每条边,询问可以将其边权修改的最大值 ,使得修改后这条边可以在某个 MST 上。如果可以修改为任意值,输出 。
和上面的题非常像。仍然是求出一棵最小生成树。
对于一条非树边 ,答案就是树上路径 上边权的 。对于一条树边 ,答案就是所有能覆盖它的非树边的边权的 ,如果没有非树边能覆盖它,答案就是 ,即这条边是一条割边。仍然是使用数据结构维护即可,时间复杂度 。
还有一道类似的,留作习题:CF827D Best Edge Weight
CF1023F Mobile Phone Network
给定一张图,有 条边权未定的边和 条边权已定的边,你需要给这 条边都赋一个权值,使得这 条边都能出现在一棵 MST 里,求这 条边的边权和最大是多少。如果可能是无穷大,输出 。保证这 条边不会形成环。
先将这 条边拉出来,然后按照边权从小到大加边,求出一个包含这 条边的最小生成树。
然后对于所有的非树边 ,对树上路径 的边对 取 。最终 条边每条边最大的值就是取 的结果,如果有一条边没被取 过,答案就是 。
下面是关于完全图 MST 的。
CF888G Xor-MST
经典题。
一张 个点的完全图,每个点有点权 ,边 有边权 ,求这张图的 MST。
因为是异或,首先想到建出 Trie 树。
考虑模拟 Kruskal 的流程,我们发现对于一个有两个儿子的节点子树内的 MST,一定是这两个儿子的子树求 MST,然后加一条最短的连接两个子树的边。
于是我们可以遍历 Trie 树中的每个节点,如果这个节点有两个儿子,先递归求出两个儿子的 MST,然后找到这条连接两个子树的最短的边,这条边可以通过在 Trie 树上启发式合并来求;如果这个节点少于两个儿子,那么也是好做的。时间复杂度 。
AT_keyence2019_e Connecting Cities
一张 个点的完全图,每个点有点权 ,边 有边权 ,求这张图的 MST。
Boruvka 模板题。考虑模拟 Boruvka,每次如何快速找到一个连通块连出去的最小边。
考虑拆式子:
可以发现 都是独立的,我们用两棵线段树分别维护 ,支持区间差最小值,单点修改。
每次到一个连通块时,我们把这个连通块里的点在线段树上设为 ,然后查每个点的连出去的最小边,只需要求一个前缀 和一个后缀 即可。时间复杂度 。
还有另一种做法:
分治,假设现在是区间 ,先两边递归下去。我们考虑所有跨过 的连边 吗,求出 中最小的 和 中最小的 ,假设这两个点分别是 。
那么对于任意一对 ,在环 上, 的边权一定是最大的,可以直接舍去。于是我们只需要保留所有和 有连边的边即可。
最后会保留下来 条边,然后跑一遍 Kruskal 即可。时间复杂度 .
AT_cf17_final_j Tree MST
给定一棵 个节点的树,每个点有点权 ,有一张完全图,两点 之间的边长为 ,其中 表示树上两点的距离。求这张图的 MST。
我们固定一个根节点,然后只考虑所有跨过这个根节点的边,那么对于一条边 的边权就是 。我们求这些边的 MST,就是找出最小的 ,然后用这个点向其余点连边即可。
上面是固定了一个根节点的情况,回到原问题,可以直接用一个点分治,每个子问题都是上面的情况。这相当于把原图的边集划分为了几个集合,然后分别求 MST。
最终还要求一遍 MST,用个 Kruskal 即可。点分治一共有 条边,那么时间复杂度就是 。
二维平面曼哈顿距离 MST
给定 个二维平面上的点 ,有一张完全图,边 的边权是两个点在平面上的曼哈顿距离,求这张图的 MST。
(不知道题目来源)
仍然是考虑保留尽可能少的有用边。
假设现在枚举一个点 ,看和 的连边中哪些是有用的。我们把平面划分为上图所示的 个部分。对于每个部分,我们找出离 最近的点 。现在我们再考虑另外一个在这个部分内但不是最近的点 ,则一定有 。
证明的话考虑反证法:假设存在一个点 ,使得 ,于是有 ,那么 ,所以肯定不存在这样的三角形。
那么对于这个部分内任意一个不是最近的点 ,有 是 中最大的,于是可以直接舍去。
所以一个点 只有可能连每个部分中最近的点,可以使用数据结构找出这些点,于是最多只有 条边。然后再跑 Kruskal 即可,时间复杂度 。
总结一下:在做完全图 MST 时一般可以把原图划分为几个边集,然后分别求 MST。或者对于一条边,找到一个包含它的环,使得这条边是最大的,就可以舍去。大体思路都是保留尽量少的有用边,然后就可以使用普通最小生成树的求法了。
然后是 Kruskal 重构树
P4768 [NOI2018] 归程
给定一张无向图,每条边有海拔 ,边权 。有 次询问,每次给定起点 和水位线 ,表示从起点出发先可以经过所有海拔 的边并且不消耗时间,然后再出发到 ,花费时间为经过的边权。求出最少的时间。强制在线。
首先求出 到每个点的最短路 。
对于每个询问,只经过海拔 的边,可以建出最大生成树的 Kruskal 重构树,那么能到达的点就是某个点的子树,可以倍增求出这个点,答案就是这个子树所有叶子结点 的 ,这个可以预处理出来。
P4197 Peaks
给定一张带边权的无向图,每个点有点权 , 次询问,每次给定 ,表示从 出发,只经过边权 的边,能到达的所有点中点权第 大是多少。
,
建出 Kruskal 重构树,转化为一个点子树内所有叶子节点中点权第 大是多少,拍平到 序列上,就是求区间第 大,使用主席树即可。
然后这题还有个加强版,要求强制在线:P7834 [ONTAK2010] Peaks 加强版。
Qpwoeirut and Vertices
给定一张带边权的无向图, 次询问至少要加完编号前多少的边,才能使得 中的所有点两两连通。
按照编号建出 Kruskal 重构树,就是求区间 的 LCA,可以使用结论“一个集合的 LCA 等于 dfn 最小的和 dfn 最大的点的 LCA” 来做到 的时间复杂度。
P4899 [IOI2018] werewolf 狼人
给定一张 个点的无向图, 次询问,每次给定 ,表示先只能经过编号 的点,再只能经过编号 的点,问能不能从 走到 。
建出点编号最小生成树和最大生成树的 Kruskal 重构树,然后 在最大生成树的重构树上跳, 在最小生成树的重构树上跳。那么 可以到一个子树内的所有点, 可以到一个子树内的所有点,那么 能走到 当且仅当两棵子树有交。
还是把问题转化到 dfn 序列上,即询问两个区间是否有交,我们设 表示 这个数在两个序列中的位置,每次就是询问一个矩形中是否有点 ,于是就变成了一个二维数点。可以使用离线树状数组或者主席树。
CF1628E Groceries in Meteor Town
给定一棵大小为 个点的树,起初每个节点都是黑色。 次操作,每次操作有以下三种。
- 把下标为 的点染成白色;
- 把下标为 的点染成黑色;
- 询问从节点 出发到达任意一个白色节点的简单路径上经过的边,最大可能的权值。不存在则输出 。
最大可能的权值,还是肯定还是建出 Kruskal 重构树。因为深度越小的点点权越大,所以就是询问点 和所有白点的 LCA 中深度最小的,也就是 和所有白点的 LCA。
根据结论,一个集合的 LCA 等于 dfn 最小的和 dfn 最大的 LCA,所以我们使用一棵线段树来维护所有白点中 dfn 的 ,区间修改也是容易维护的,每次求一个 LCA 即可。
[AGC002D] Stamp Rally
一张带边权的无向连通图, 次询问从两个点 和 出发,希望经过的点数量等于 (每个点可以重复经过,但是重复经过只计算一次),经过的边最大编号最小是多少。
因为要点的数量刚好等于 ,考虑使用二分。
还是先建出 Kruskal 重构树。我们二分边的最大编号是多少,然后两个点 分别向上跳,那么能到达的点就是 两棵子树内的所有点(注意两个子树如果是包含关系就是取 大的那个而不是 加起来),然后根据这个大小与 的关系进行二分。时间复杂度 。
2024.11.11 GDF 联考 T3
给定一棵树,每个点有点权 ,从点 走到点 的代价是路径 上点权的最小值乘最大值,求从起点 出发到每个点的最短路长度。
手玩一下不难发现从起点 到任意一个点的最短路,最多走两次。证明可以自己手玩一下所有走三次的路径,能发现都可以被一条走两次的路径顶替掉,并且这个中转点一定是两条路径的最小值。(读者自证不难)
因为最多走两次,我们设 表示从 直接走到 的代价, 表示走两次到 的最短路,点 的答案就是 。现在要用所有 来更新 ,但是直接做显然复杂度不能接受。
因为中转点一定是两条路径的最小值,我们考虑错解不优,直接钦定一个点为中转点,且这个点是两条路径的最小值。那么有更新 , 表示路径 上的最大值,这个不太好处理。因为是路径最大值,于是想到建出 Kruskal 重构树。
现在转移就变成了 ,我们在 处理贡献,假设有 ,现在枚举 ,有 ,又因为错解不优,所以我们不用考虑 的情况,只需要 都在 的子树内就可以有更新。
那么 是独立的了,我们可以求出 ,然后再让整个子树的 对这个值取 即可。这个式子是一个一次函数的形式,所以我们用李超线段树来维护,然后每个点做一遍李超线段树的合并就可以求了。时间复杂度 。
代码:http://111.6.42.124:21212/submission/10398
网络流
相信大家已经听腻了,我的实力比较菜,也找不到什么好题,所以就不写了。
最小斯坦纳树
最小斯坦纳树用于解决指定图中几个点,选出边权和最小的边使得这些点连通的代价。这是一个 NP-Hard 问题,但即使是这样,我们也希望有一个时间复杂度较好的解决方法。
模板:P6192 【模板】最小斯坦纳树
给定一个 个点 条边的图,有 个点为关键点,你需要选出一些边,使得这 个点连通,且边权和最小。
首先这些边肯定构成一棵树,考虑状压 dp,设 表示以 为根的一棵树,包含关键点集合 中所有点的最小边权和。
那么有以下两种转移方式:
- 用 的某一个子集转移过来:
- 用一条边 进行转移:,这是一个三角形不等式,相当于对整张图进行一次松弛操作。
我们考虑从小到大枚举所有 ,先用 的所有子集更新 ,然后进行一次 SPFA 或 Dijkstra,时间复杂度为 。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <queue>
#define ll long long
using namespace std;
const int N = 1005,K = 15,inf = 0x3f3f3f3f;
int f[N][1<<K],hd[N],cnt,n,m,k,ans = inf;
bool vis[N];
struct node{int to,nex,w;}e[N << 1];
void add(int u,int v,int w)
{e[++cnt] = {v,hd[u],w};hd[u] = cnt;}
queue<int> q;
void spfa(int s)
{
while(!q.empty())
{
int u = q.front();q.pop();
vis[u] = 0;
for(int i = hd[u];i;i = e[i].nex)
{
int v = e[i].to,w = e[i].w;
if(f[v][s] > f[u][s]+w)
{
f[v][s] = f[u][s]+w;
if(!vis[v])vis[v] = 1,q.push(v);
}
}
}
}
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
// freopen(".in", "r", stdin);
// freopen(".out", "w", stdout);
n = rd();m = rd();k = rd();
for(int i = 1;i <= m;i++)
{
int u = rd(),v = rd(),w = rd();
add(u,v,w);add(v,u,w);
}
memset(f,inf,sizeof(f));
for(int i = 1;i <= k;i++)f[rd()][1<<i-1] = 0;
for(int s = 1;s < (1<<k);s++)
{
for(int i = 1;i <= n;i++)
{
for(int t = s;t;t = s&(t-1))
f[i][s] = min(f[i][s],f[i][t]+f[i][s^t]);
if(f[i][s] != inf)q.push(i),vis[i] = 1;
}
spfa(s);
}
for(int i = 1;i <= n;i++)ans = min(ans,f[i][(1<<k)-1]);
cout << ans << endl;
return 0;
}
另一个模板:P4784 [BalticOI 2016 Day2] 城市,这题卡 SPFA,记得写 Dijkstra。
最小斯坦纳树的应用并不是很多,这里再放几道例题来帮助大家理解。
例题
P4294 [WC2008] 游览计划
有一个 的矩阵,每个位置为 表示是一个经典,否则是一个正整数,表示这个点的点权。你需要选择一些点,使得所有景点连通,求最小的点权和。
设 表示景点数量,
点权最小斯坦纳树。
还是设状态 表示以 为根,包含了关键点集合 的树的最小点权。转移还是分两种:
- ,减去 是因为两个集合都包含了 ,有重复。
更新顺序还是一样的,与边权最小斯坦纳树一样,输出方案倒着递推一下即可。
P3264 [JLOI2015] 管道连接
给定一个带边权的图,有 个关键点,每个关键点有一个频道 ,你需要选出一些边,使得同一个频道下的点都连通。
最小斯坦纳森林。
我们先用求斯坦纳树的方法求出 ,然后设 表示连接 中的所有点的最小代价,显然 。
然后再使用一个子集 dp,设 表示 中所有相同频道的点都连通的最小代价。
每次枚举一个颜色子集,然后求出这个颜色子集组成的关键点集合,假设为 ,那么有转移 。最后答案就是 。
代码:https://www.luogu.com.cn/record/175414496
矩阵相关
这是一些用矩阵来求解图论的一些问题。在这之前,你需要先学会行列式。
LGV 引理(Lindstrom-Gessel-Viennot lemma)
LGV 引理用于处理如下问题:
在一张带边权的 DAG 上,有 个起点, 个终点,每个起点都需要走到一个终点,且所有路径上的点不能重复,一个方案的权值为所有路径上的边权的乘积。一个排列 的权值为起点 要走到 的所有方案的权值和乘上 ,求出所有排列的权值之和。
我们设 表示从 到 中所有路径的权值之和,设起点分别为 ,终点分别为 。
设矩阵:
那么答案就是 的行列式 。有个比较简单但不是很严谨的证明:
首先根据行列式的定义,如果所有路径不交,那么答案显然是对的。如果有两条路径相交了的话,如下图所示:
这两种情况对应的权值是一样的,而它们的排列正好有两个数不同,所以一个是正号一个是负号,恰好抵消了。所以对于所有路径有交的情况,一定都会被抵消掉。
另外,如果图没有边权,问的是一共有多少条路径,就相当于把每条边权设为 ,那么 就表示 到 一共有多少条路径。
矩阵树定理
咕咕咕。
例题
关于 LGV 引理的。
P6657 【模板】LGV 引理
有一个 的棋盘,一个棋子可以从一个点 走到 或 。现在有 个棋子,第 个棋子在 最终要走到 ,求有多少种走法,使得走过路径上的点互不相交,答案对 取模。
直接当成有 个起点, 个终点,最终走法要求路径没有交,显然是 走到 。
然后有 ,预处理出组合数,然后求出 的矩阵,就是 LGV 引理了。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <cmath>
#define ll long long
using namespace std;
const int N = 2e6+5,M = 105,mod = 998244353;
int a[M],b[M],n,m;
ll fac[N],inv[N],f[M][M];
ll calc(int x,int y){return fac[x+y]*inv[x]%mod*inv[y]%mod;}
ll qp(ll x,int y = mod-2)
{
ll ans = 1;
for(;y;y >>= 1,x = x*x%mod)
if(y&1)(ans *= x) %= mod;
return ans;
}
ll solve(int n)
{
ll ans = 1;bool tp = 0;
for(int i = 1;i <= n;i++)
{
int k = i;
for(int j = i;j <= n;j++)
if(f[j][i]){k = j;break;}
swap(f[i],f[k]);tp ^= i!=k;
if(!f[i][i])return 0;
(ans *= f[i][i]) %= mod;
ll inv = qp(f[i][i]);
for(k = i+1;k <= n;k++)
{
ll now = f[k][i]*inv%mod;
for(int j = i;j <= n;j++)
(f[k][j] -= f[i][j]*now) %= mod;
}
}
return ((tp?-ans:ans)+mod)%mod;
}
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
// freopen(".in","r",stdin);
// freopen(".out","w",stdout);
fac[0] = inv[0] = inv[1] = 1;
for(int i = 1;i < N;i++)fac[i] = fac[i-1]*i%mod;
for(int i = 2;i < N;i++)inv[i] = (mod-mod/i)*inv[mod%i]%mod;
for(int i = 2;i < N;i++)(inv[i] *= inv[i-1]) %= mod;
for(int t = rd();t--;)
{
n = rd();m = rd();
for(int i = 1;i <= m;i++)a[i] = rd(),b[i] = rd();
for(int i = 1;i <= m;i++)for(int j = 1;j <= m;j++)
f[i][j] = a[i]<=b[j]?calc(abs(a[i]-b[j]),n-1):0;
printf("%lld\n",solve(m));
}
return 0;
}
P7736 [NOI2021] 路径交点
题意比较复杂,建议直接看原题。
我们考虑如果只有两层,那么假设第 个起点走到了第 个终点,那么路径的交点数就是 的逆序对数,则一个排列 的贡献为 。于是答案就是邻接矩阵的行列式。
现在假设有三层且 ,假设 分别为第一层中路径交点为偶数和奇数的条数,那么第一层的答案就是 ;同理,设 为第二层中路径交点为偶数和奇数的条数,第二层的答案就是 。两层的答案就是 ,即两层分别的答案乘起来,原因就是 。
我们大胆猜测,如果有 层的话,答案就是这 层的邻接矩阵相乘,再求行列式。证明的话和上面是类似的。
弦图
推荐食用这篇博客:弦图:从入门到入入门,因为时间比较紧,而且在 OI 中的运用也不多,我就不准备讲了。
在这里提弦图主要是想讲一道我出的题。为了防止大家不知道弦图是啥,所以这里给出弦图的定义:
- 设弦表示连接一个简单环上不相邻的两个点的边。
- 弦图的定义为:对于任意一个长度 的简单环都有一条弦的无向图。
给定一个 个点, 条边的弦图,保证图连通, 次询问,每次给定 ,求有多少种删两条不同的边的方案,使得 不连通。
(有人准备在联考放这道题结果联考的前天突然发现不会造数据紧急换题)
这是一道比较人类智慧的题目。
我们假设整个图是一个点双的情况,则 和 之间至少有两条不交的路径。
假设删除了两条不与 相连的两条边后,能使得 不连通,那么这两条边一定分别在一条 到 的路径上。
我们可以发现,无论剩下的边如何连,一定会形成一个长度 的没有弦的环,这与弦图的定义冲突了。所以这种情况不能发生。
继续手玩一下,可以发现删两条边只有可能删和 相连的两条边,或和 相连的两条边。如果 或 的度数 ,那么也删不了。于是答案就是 。
这个是整个图是一个点双的情况,如果是一般图,就建出园方树,先简单处理一下 到 上的割边的答案。然后如果一个点 在一个包含它点双中有两条连接的边,那么就把 到这个方点的边权设为 ,剩下的答案就是 到 的边权和。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <vector>
#define ll long long
#define getchar() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++)
using namespace std;
const int N = 5e5+5;
int a[N],b[N],f[N][20],d[N],n,m,q;
int dfn[N],low[N],hd[N],cnt,tim,tot;
int du[N],st1[N],top1,top2;
struct edge{int u,v;}st2[N];
vector<int> vc[N];
struct node{int to,nex,w;}e[N << 1];
inline void add(int u,int v,int w)
{e[++cnt] = {v,hd[u],w};hd[u] = cnt;}
void init(int u,int v,int now)
{
a[++tot] = st1[top1]==v;
for(int i = now+1;i <= top2;i++)
du[st2[i].u]++,du[st2[i].v]++;
for(int x = 0;x != v;top1--)
{
int w = du[x = st1[top1]] == 2;
add(x,tot,w);add(tot,x,w);
}
int w = du[u] == 2;
add(u,tot,w);add(tot,u,w);
for(int i = now+1;i <= top2;i++)
du[st2[i].u]--,du[st2[i].v]--;
top2 = now;
}
void dfs(int u)
{
dfn[u] = low[u] = ++tim;st1[++top1] = u;
for(int v : vc[u])
if(!dfn[v])
{
int now = top2;dfs(v);
low[u] = min(low[u],low[v]);
if(dfn[u] == low[v])init(u,v,now);
}
else if(dfn[v] < dfn[u])
st2[++top2] = {u,v},low[u] = min(low[u],dfn[v]);
}
void dfs2(int u,int fa)
{
f[u][0] = fa;d[u] = d[fa]+1;a[u] += a[fa];
for(int i = 1;i < 20;i++)f[u][i] = f[f[u][i-1]][i-1];
for(int i = hd[u],v;i;i = e[i].nex)
if((v = e[i].to) != fa)
b[v] = b[u]+e[i].w,dfs2(v,u);
}
int lca(int u,int v)
{
if(d[u] < d[v])swap(u,v);
for(int i = 19;~i;i--)
if(d[f[u][i]] >= d[v])u = f[u][i];
if(u == v)return u;
for(int i = 19;~i;i--)
if(f[u][i] != f[v][i])u = f[u][i],v = f[v][i];
return f[u][0];
}
inline ll solve(int x,int y)
{
int z = lca(x,y);
if(!z)return 1ll*m*(m-1)/2;
ll w1 = a[x]+a[y]-2*a[z],w2 = b[x]+b[y]-2*b[z];
return w1*(w1-1)/2+w1*(m-w1)+w2;
}
char buf[1<<21],*p1,*p2;
inline int rd()
{
char c;int f = 1;
while(!isdigit(c = getchar()))if(c=='-')f = -1;
int x = c-'0';
while(isdigit(c = getchar()))x = x*10+(c^48);
return x*f;
}
int main()
{
// freopen(".in","r",stdin);
// freopen(".out","w",stdout);
tot = n = rd();m = rd();q = rd();
for(int i = 1;i <= m;i++)
{
int u = rd(),v = rd();
vc[u].push_back(v);vc[v].push_back(u);
}
for(int i = 1;i <= n;i++)
if(!dfn[i])dfs(i),dfs2(i,0),top1--;
while(q--)
{
int x = rd(),y = rd();
printf("%lld\n",solve(x,y));
}
return 0;
}
如果有愿意帮我造数据的可以联系我(
总结
图论是 oi 中一个很重要的板块,因为时间原因,本博客也只讲了图论的一部分内容。在平时做题中,要熟练运用最短路,Kruskal 重构树等算法,看到题目中的某些标志性提示应该能马上反应过来要用这个算法。然后板子啥的一定要背熟。
希望大家看了之后能有收获!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现