关于图论
图论算是比较重要的一部分吧,所以总结一下
最短路
基本知识
最短路主要分为单源最短路和多源最短路两种,单源最短路主要涉及Dijkstra算法和SPFA算法,前者的思想为贪心,每一次取出当前距离源点最短的点进行松弛,这样到不能松弛的时候就可以取得最短路径,好像看起来没错,但是是有问题的,比如当路径上的边权有负数的时候,这样做就可能会导致一个负的很多的数一直被压在最低下取不出来,所以它只适用与没有负权的图,而后者的本质则是队列优化的Bellman-Ford算法,随机图上的时间效率甚至会好过Dij,但是一旦被卡就。。。所以能不用则不用,因为很有可能被卡掉很多分,不过它有一个好处,可以求带负权的最短路,因为它会进行多次松弛操作,所以负权不会有影响。多源最短路常用的算法为Floyd,时间复杂度略高不过有的时候很好用,试想在一个点数很小的图上跑一遍Floyd和跑n遍Dij,当然是前者省心省力,它的本质是一个Dp,所以循环的顺序不能出错,最外层只能枚举中转点。
略高级一点的知识
- 分层图最短路
一些题目中有的时候会提到有一些边\(k\)有特殊性质,然后在有特殊性质的情况求出最短路,如果\(k\)不是很大的话,我们可以建立\(k\)层图,然后每用一次特殊性质就相当与走了一层图,这样就可以直接跑一个最短路了。
例题:
Flute市的Phlharmoniker乐团2000年准备到Harp市做一次大型演出,本着普及古典音乐的目的,乐团指挥L.Y.M准备在到达Harp市之前先在周围一些小城市作一段时间的巡回演出,此后的几天里,音乐家们将每天搭乘一个航班从一个城市飞到另一个城市,最后才到达目的地Harp市(乐团可多次在同一城市演出).
由于航线的费用和班次每天都在变,城市和城市之间都有一份循环的航班表,每一时间,每一方向,航班表循环的周期都可能不同.现要求寻找一张花费费用最小的演出表.
输入:
输入文件包括若干个场景.每个场景的描述由一对整数n(2<=n<=10)和k(1<=k<=1000)开始,音乐家们要在这n个城市作巡回演出,城市用1..n标号,其中1是起点Flute市,n是终点Harp市,接下来有n*(n-1)份航班表,一份航班表一行,描述每对城市之间的航线和价格,第一组n-1份航班表对应从城市1到其他城市(2,3,...n)的航班,接下的n-1行是从城市2到其他城市(1,3,4...n)的航班,如此下去.
每份航班又一个整数d(1<=d<=30)开始,表示航班表循环的周期,接下来的d个非负整数表示1,2...d天对应的两个城市的航班的价格,价格为零表示那天两个城市之间没有航班.例如"3 75 0 80"表示第一天机票价格是75KOI,第二天没有航班,第三天的机票是80KOI,然后循环:第四天又是75KOI,第五天没有航班,如此循环.输入文件由n=k=0的场景结束.
输出:
对每个场景如果乐团可能从城市1出发,每天都要飞往另一个城市,最后(经过k天)抵达城市n,则输出这k个航班价格之和的最小值.如果不可能存在这样的巡回演出路线,输出0
题目有点难懂,多读几遍就行,正解好像是DP然而我看见这个范围就懒得DP直接打了分层图。。。。
#include<queue>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define ll long long
const int N=1e3+100;
const ll INF=0x3f3f3f3f3f3f3f3f;
struct Edge{
int nxt,to;ll val;
}e[N*N];
int h[N*N],idx;
void Ins(int a,int b,ll c){
e[++idx].to=b;e[idx].nxt=h[a];h[a]=idx;e[idx].val=c;
}
ll dis[15][15][N],t[N][N];
ll diss[N*N];
struct Node{
int id;ll val;
bool operator <(const Node&A)const{
return val>A.val;
}
Node(){}
Node(int a,ll b){id=a;val=b;}
};
bool vis[N*N];
void spfa(){
priority_queue<Node > q;
memset(vis,0,sizeof(vis));
memset(diss,0x3f,sizeof(diss));
diss[1]=0;
q.push(Node(1,0));
while(!q.empty()){
Node u=q.top();q.pop();
if(vis[u.id])continue;
vis[u.id]=1;
for(int i=h[u.id];i;i=e[i].nxt){
int v=e[i].to;
if(diss[v]>diss[u.id]+e[i].val){
diss[v]=diss[u.id]+e[i].val;
q.push(Node(v,diss[v]));
}
}
}
}
int main(){
int n,m;
while(~scanf("%d%d",&n,&m)&&n&&m){
memset(h,0,sizeof(h));idx=0;
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j)continue;
scanf("%lld",&t[i][j]);
for(int k=0;k<t[i][j];k++){
scanf("%lld",&dis[i][j][k]);
if(dis[i][j][k]==0)
dis[i][j][k]=INF;
}
}
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
for(int k=0;k<m;k++){
if(i==j)continue;
Ins(i+k*m,j+(k+1)*m,dis[i][j][k%t[i][j]]);
}
}
spfa();
printf("%lld\n",diss[n+m*m]==INF?0:diss[n+m*m]);
}
}
\(k\)不是很大,直接分层图。
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int N=5e6+10;
struct Edge{
int next,to,val;
}e[N];
struct Node{
int id,dist;
Node(int a,int b){
id=a;dist=b;
}
bool operator <(const Node &A)const {
return dist>A.dist;
}
};
int Head[N],len,vis[N],dis[N];
void Ins(int a,int b,int c){
e[++len].to=b;e[len].val=c;
e[len].next=Head[a];Head[a]=len;
}
int m,n,k;
void spfa(){
queue<int> q;
memset(dis,0x3f,sizeof(dis));
q.push(1);
dis[1]=0;
while(!q.empty()){
int u=q.front();q.pop();
vis[u]=0;
for(int x=Head[u];x;x=e[x].next){
int v=e[x].to;
if(dis[v]>dis[u]+e[x].val){
dis[v]=dis[u]+e[x].val;
if(!vis[v]){
vis[v]=1;q.push(v);
}
}
}
}
}
int main(){
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++){
int x,y,w;
scanf("%d%d%d",&x,&y,&w);
Ins(x,y,w);Ins(y,x,w);
for(int j=1;j<=k;j++){
Ins(x+j*n,y+j*n,w);Ins(y+j*n,x+j*n,w);
Ins(x+(j-1)*n,y+j*n,w/2);Ins(y+(j-1)*n,x+j*n,w/2);
}
}
for(int i=1;i<=k;i++){
Ins(i*n,i*n+n,0);
}
spfa();
printf("%d\n",dis[k*n+n]);
}
这份代码是很久之前写的,目前看来有两个不是很好的地方,第一个使用了SPFA算法,有可能被卡,第二个没有return 0,这个不知道会不会出问题。
- 广义矩阵乘法加速
还是上边的例子,注意到我在上边用了一个词
如果k不是很大的话
那如果\(k\)很大呢,这时候我们往往可以使用另一个办法,即矩阵快速幂,直接把矩阵乘法的加改成取\(min\),然后根据矩阵运算的结合律做一下就行,注意把初始化做好。
例题:
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#define ll long long
using namespace std;
const int N=100+5;
ll dis[N][N],f[N][N];
struct Edge{
ll from,to,val;
}e[25*N];//因为要枚举边所以要开一个结构体
ll m,n,k;
void Ins(ll a,ll b,ll c,ll len){
e[len].to=b;e[len].val=c;e[len].from=a;
}
void Mul(ll d[N][N],ll a[N][N],ll b[N][N]){
ll t[N][N];
memset(t,0x3f,sizeof(t));
for(ll i=1;i<=n;i++)
for(ll j=1;j<=n;j++)
for(ll k=1;k<=n;k++)
t[i][j]=min(t[i][j],a[i][k]+b[k][j]);//重定义后的矩阵运算
memcpy(d,t,sizeof(t));
}
int main(){
scanf("%lld%lld%lld",&n,&m,&k);
memset(dis,0x3f,sizeof(dis));
for(ll i=1;i<=n;i++)//最开始除了到自己外全为正无穷
dis[i][i]=0;
for(ll i=1;i<=m;i++){
ll a,b,c;
scanf("%lld%lld%lld",&a,&b,&c);
Ins(a,b,c,i);
dis[a][b]=c;//无重边自环直接赋值就行
}
for(ll cc=1;cc<=n;cc++){
for(ll i=1;i<=n;i++){
for(ll j=1;j<=n;j++){
dis[i][j]=min(dis[i][j],dis[i][cc]+dis[cc][j]);//floyd
}
}
}
memcpy(f,dis,sizeof(dis));
for(ll i=1;i<=m;i++){
ll u=e[i].from,v=e[i].to,w=e[i].val;
for(ll j=1;j<=n;j++)
for(ll cc=1;cc<=n;cc++)
f[j][cc]=min(f[j][cc],dis[j][u]-w+dis[v][cc]);//F[1]
}
for(;k;k>>=1){//矩阵快速幂
if(k&1)Mul(dis,dis,f);
Mul(f,f,f);
}
printf("%lld\n",dis[1][n]);
return 0;
}
初始化矩阵为只用一次魔法每个点之间的最短路即可,在跑快速幂的时候我都是直接初始化矩阵为我要的那个而不是单位矩阵,这个应该看个人喜好了。
值得注意的是,上边的那道题也能用这种做法写过去。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=60;
int dis[N][N],f[N][N];
int m,n,k;
struct Edge{
int from,to,val;
}e[2000+5];int len;
void Ins(int a,int b,int c){
e[++len].to=b;e[len].val=c;e[len].from=a;
}
void Mul(int c[N][N],int a[N][N],int b[N][N]){
int temp[N][N];
memset(temp,0x3f,sizeof(temp));
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
for(int k=1;k<=n;k++)
temp[i][j]=min(temp[i][j],a[i][k]+b[k][j]);
memcpy(c,temp,sizeof(temp));
}
int main(){
scanf("%d%d%d",&n,&m,&k);
memset(dis,0x3f,sizeof(dis));
for(int i=1;i<=n;i++)
dis[i][i]=0;
for(int i=1;i<=m;i++){
int a,b,t;
scanf("%d%d%d",&a,&b,&t);
Ins(a,b,t);Ins(b,a,t);
dis[a][b]=min(dis[a][b],t);
dis[b][a]=min(dis[b][a],t);
}
for(int cc=1;cc<=n;cc++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
dis[i][j]=min(dis[i][j],dis[i][cc]+dis[cc][j]);
memcpy(f,dis,sizeof(f));
for(int i=1;i<=len;i++){
int u=e[i].from,v=e[i].to,w=e[i].val>>1;
for(int j=1;j<=n;j++)
for(int z=1;z<=n;z++)
f[j][z]=min(f[j][z],dis[j][u]+w+dis[v][z]);
}
for(;k;k>>=1){
if(k&1)Mul(dis,dis,f);
Mul(f,f,f);
}
printf("%d\n",dis[1][n]);
}
代码几乎是一样的。
有些题目中会有多种限制,然后一般是把边的限制去掉,即把边拆成点
边拆点板子。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=110;
const int p=2009;
int Mat[N][N],a[N][N],tmp[N][N],m;
void Mul(){
for(int i=1;i<=m;i++)
for(int j=1;j<=m;j++){
tmp[i][j]=0;
for(int k=1;k<=m;k++)
tmp[i][j]=(tmp[i][j]+a[i][k]*Mat[k][j])%p;
}
memcpy(a,tmp,sizeof(tmp));
}
void Mulself(){
for(int i=1;i<=m;i++)
for(int j=1;j<=m;j++){
tmp[i][j]=0;
for(int k=1;k<=m;k++)
tmp[i][j]=(tmp[i][j]+Mat[i][k]*Mat[k][j])%p;
}
memcpy(Mat,tmp,sizeof(tmp));
}
int main(){
int n,T;
scanf("%d%d",&n,&T);
m=n*9;
for(int i=1;i<=n;i++){
for(int j=1;j<=9;j++)
a[(j-1)*n+i][j*n+i]=1;
for(int j=1;j<=n;j++){
int x;
scanf("%1d",&x);
if(x)a[(x-1)*n+i][j]=1;
}
}
memcpy(Mat,a,sizeof(a));
T--;
while(T){
if(T&1)Mul();
Mulself();
T>>=1;
}
printf("%d\n",a[1][n]);
return 0;
}
- 传递闭包
也算是Floyd的一种应用吧,主要是通过几个点之间的联通判断别的点的联通,如果直接Floyd做是\(N^3\)的,但是有个东西叫做Bitset可以做的更快一些。
#include <bits/stdc++.h>
#define N 2010
#define LL long long
using namespace std;
char s[N];
bitset<N>f[N];
int main(){
int n,p,i,a,j;
LL sum=0;
scanf("%d",&n);
for(i=1;i<=n;++i){
scanf("\n%s",s+1);
for(j=1;j<=n;++j)
if(s[j]=='1') f[i][j]=1;
f[i][i]=1;
}
for(i=1;i<=n;++i)
for(j=1;j<=n;++j){
if(f[j][i]) f[j]|=f[i];//i,j别写反
}
for(i=1;i<=n;++i) sum+=f[i].count();
printf("%lld",sum);
}
然后算法是死的,题是活的,遇到一些特殊情况的题目要特殊考虑特殊性质,综合利用这些算法。
树
基本知识
- 最小生成树
两种算法,Kruskal和Prim,前者主要用于稀疏图后者主要用于稠密图尤其是完全图。
- 树的直径
两种求法,一种是DP另一种是DFS,各有各的好处吧,然后感觉最有用的是一句推论
距离树上某个点最远的点一定是直径的一个端点
这句话比一个裸的直径有用的多。
- 最近公共祖先
主要是用两种算法,倍增和树剖,如果求裸的LCA还是用树剖吧,毕竟这个会快很多,上限才和倍增一样,而如果要维护一些复杂信息的话还是倍增好点。
- 树上差分
边差分和点差分,挺简单的。
高级
- 基环树
主要思想基本上都是把环断开,听起来很简单但是往往可以把人们(蒟蒻)恶心死。
基环树的直径,利用单调队列优化做一下
由于题目比较灵活所以没有找什么题
Tarjan
一个算法单独拿出来可以见到它的重要性,反正在我看来挺重要的,不仅仅是好用,还有思想。
有向图
有向图的应用主要是强联通分量的处理
void tarjan(int u){
dfn[u]=low[u]=++Time;
stk[++tp]=u;
for(reg int i=h[u];i;i=e[i].nxt){
reg int v=e[i].to;
if(!dfn[v]){
tarjan(v);
low[u]=min(low[u],low[v]);
}else if(!col[v])low[u]=min(low[u],dfn[v]);
}
if(low[u]==dfn[u]){
cnt++;
while(1){
int x=stk[tp--];
col[x]=cnt;
if(x==u)break;
}
}
}
仔细分析一下这个代码其实还是可以得到很多有意思的东西,首先看
else if(!col[v])low[u]=min(low[u],dfn[v]);
为什么还要加一个\(if\)呢,直接更新不行吗,这个是不行的,考虑为什么,因为访问过的点有可能是同一棵搜索树上的也有可能不是,如果是的话直接更新没问题,如果不是那么low值的更新就会出问题,所以这里要保证它是同一棵搜索树上的才更新,这个的作用和其它代码中的\(instack\)的效果是一样的。
然后是
low[u]==dfn[u]
事实上,当一个点的\(low\)值不大于它的\(dfn\)值的时候,就可以判断这张图中存在环了,然而我们只在相等的时候才进行更新,这样求出来的强联通分量一定是极大的,所以如果单纯求一个环的话,tarjan只能求出极大的那个。
无向图
主要分为两个,点双和边双。
- 点双
网上的定义有很多,其实都是等价的,我更喜欢下边这个定义,比较简单易懂,去掉一个点之后仍然连通的分量叫做点双联通分量。
由定义可以看出,两个点双的交界点一定是一个割点,求割点的时候,可以略微改一下上边的代码
void tarjan(int u,int fa){
dfn[u]=low[u]=++num;
for(int x=Head[u];x;x=e[x].next){
int v=e[x].to;if(v==fa)continue;
if(!dfn[v]){
tarjan(v,u);
low[u]=min(low[v],low[u]);
if(low[v]>=dfn[u]&&(u!=rt||++cnt>1))cut[u]=1;
}else low[u]=min(low[u],dfn[v]);
}
}
值得关注的点只有一个
if(low[v]>=dfn[u]&&(u!=rt||++cnt>1))cut[u]=1;
根据上边的推理,只要有一个点满足low[v]>=dfn[u]即可判断割点,那么后边那句话是干什么的,如果一个点是根,那么你会发现这个式子是恒成立的,但是当且仅当根在搜索树上有至少两个儿子的时候它才是割点。
圆方树
点双的缩点一直很令人头疼,因为一个割点会缩到两个甚至更多的点双中,解决这个问题的办法之一就是不把割点缩进去,于是就有了圆方树。
void tarjan(int u,int lst){
dfn[u]=low[u]=++Time;
stk[++tp]=u;
for(int i=h[u];i;i=e[i].nxt){
if(i==(lst^1))continue;
int v=e[i].to;
if(!dfn[v]){
tarjan(v,i);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u]){
cut[u]=1;
fang++;
while(true){
int x=stk[tp--];
Ins2(x,fang);Ins2(fang,x);
if(x==v)break;
}
Ins2(u,fang);Ins2(fang,u);
}
}else {
low[u]=min(low[u],dfn[v]);
}
}
}
if(low[v]>=dfn[u])
注意这里并没有判断根结点,这是为什么呢,因为不管根是不是割点,都要缩到一个点双里边,所以直接缩就可。
- 边双
类似的,边双的定义是去掉一条边后仍然联通的分量,求发也比较简单,不再赘述。
void Tarjan(int u){
dfn[u]=low[u]=++num;
stk[++top]=u;
for(int i=Head[u];i;i=e[i].next){
if(!vis[i]){
vis[i]=vis[i^1]=1;
int v=e[i].to;
if(!dfn[v]){
Tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(!scc[v])low[u]=min(low[u],dfn[v]);
}
}
if(low[u]==dfn[u]){
siz++;
while(1){
int x=stk[top--];
scc[x]=siz;
if(x==u)break;
}
}
}