NOIP 图论[ZHX]
图
图 \(G\) 是一个有序二元组 \((V,E)\),其中 \(V\) 成为点集(\(Vertices\) \(Set\)),\(E\) 称为边集(\(Edges\) \(set\))。
-
有向边、无向边
如果边有方向,那么得到的图称为有向图。在有向图中,与一个节点相关联的有出边和入边之分。
相反,边没有方向的图称为无向图,即所有边都是无向边的图称为无向图。
-
度(\(Degree\))
一个顶点的度是指与该顶点相关联的边的条数,顶点 \(v\) 的度数记作 \(d_v\)。
-
入度(\(In-degree\))和出度(\(Out-degree\))
对于有向图来说,以该点为终点的边的数量为入度,以该点为起点的边的数量为出度。
-
自环(\(Loop\))
一条边的起点和终点为同一顶点。
-
路径(\(Path\))
从任意一点出发,在图上走过的过程的序列,称为路径。
简单路径:每个点最多走了一次的路径,即点不能够重复。
-
环(\(Ring\))
出发点和结束点一样的路径称为环。
简单环:去掉起点后,会变成简单路径的环。
特殊的图
-
树(\(Tree\))
无环无向的连通图,\(n\) 个点的树有 \(n-1\) 条边。
-
森林
无环无向图。
-
有向图的树
外向树:边从根指向叶子。
内向树:边从叶子指向根。
-
章鱼图/基环树
只有一个环的无向连通图,环上的点伸展出去为一棵树。\(n\) 点 \(n\) 边,删掉环上的任意一条边就会变成树(同理,随便加一条边即可使树变为章鱼图)。
树形 \(DP\) \(+\) 环形 \(DP\)
-
仙人掌图
把树的每一个点变成一个环,环叫做边仙人掌的叶片。
边仙人掌:用边连接不同的环。
点仙人掌:用点连接不同的环。
缩点之后的树进行树形 \(DP\),环进行环形 \(DP\)。
-
\(DAG\)(\(Directed\) \(Acyclic\) \(Graph\))
有向无环图。
把 \(DP\) 的状态看做点,转移看成边,所有的 \(DP\) 都是一个 \(DAG\)。
-
二分图
把图上的点分为左右两部分,所有的边都是由左边/右边连向右边/左边。
树是二分图,把深度为奇数的点放在左边,深度为偶数的点放在右边。
有奇环的图一定不是二分图,没有奇环的图一定是二分图。
判断二分图用 \(DFS\) 或 \(BFS\) 染色的方法。
-
图的存储方法
邻接矩阵
开一个 \(n \times m\) 大小的数组 \(a\),\(a_{i,j}\) 表示从 \(i\) 号点到 \(j\) 号点的边的长度。
好处:速度快,好写。
坏处:空间过大,没有办法处理重边。
边表(链式前向星)
对于每个点建立一个链表,共 \(n\) 个链表,把从同一个点出发的点串在一起,存着从这个点出发的所有边。
本质:用 \(n\) 个链表存储所有的边。
struct edge {
int e;//当前边终点
int nxt;//下一条边的编号
}ed[MAXN];
int cnt;
int fir[MAXN];//每个链表的第一条边的编号
void add_edge(int s,int e){
ed[++cnt].nxt=fir[s];
fir[s]=cnt;
ed[cnt].e=e;
}
for(int s=1;s<=n;s++)
for(int i=fir[s];i!=0;i=ed[i].nxt)
ed[i].e;//s -> e
最短路
多源最短路:\(Floyd\)
单源最短路:
无负边权:\(Dijkstra + Heap\)
有负边权:\(SPFA\)
\(Floyd\)
\(dist_{i,j,k}\) 代表从 \(j\) 走到 \(k\) 且走过的点的编号都 \(\leq i\) 的最短路。最后求出 \(dist_{n,j,k}\) 来作为我们的答案。
如果 \(j\) 到 \(k\) 有边,\(dist_{0,j,k}=d_{j,k}\)。
如果 \(j\) 到 \(k\) 无边,\(dist_{0,j,k}=∞\)。
\(dist_{i,j,k}=min(dist_{i-1,j,k},dist_{i-1,j,i}+dist_{i-1,i,k})\)
因为每个 \(i\) 都可以从 \(i-1\) 推来,一旦把 \(i\) 算出后,\(i-1\) 已经没用了,根据滚动数组的思想,我们就可以把这一维度删掉,以达到空间为 \(n^2\) 级别。
memset(dist,0x3f,sizeof(dist));//最短路赋值为无穷大
for(int i=1;i<=n;i++)dist[i][i]=0;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
for(int k=1;k<=n;k++)
dist[j][k]=std::min(dist[j][k]/*等于 i*/,dist[j][i]+dist[i][k]/*小于 i*/);
\(Dijkstra\)
限制:边的权值必须都是正数。
每次选取 dist
值最小的值,也就是选取已经求出最短路的点(因为边的权值都是正数,所以它当前是最小值,那后面也不会有),然后对其进行松弛操作(用自己的最短路去更新其他点的最短路)。
bool done[MAXN];//是否已经求出最短路
void Dijkstra(int s){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[s]=0;
for(int i=1;i<=N;i++){
//找还没有求出最短的 dist 值最小的那个点
int p=0;
for(int j=1;j<=N;j++)
if(!done[j]&&(p==0||dist[j]<dist[p]))p=j;
done[p]=1;
//松弛操作
for(int j=0;j<g[p].size();j++){
int q=g[p][j].first,d=g[p][j].second;
//这是一条从 p 到 q 长度为 d 的边
dist[q]=std::min(dist[q],dist[p]+d);
}
}
}
\(Dijkstra + Heap\)
取 dist
值最小的点都是一个 \(O(n)\) 的循环,维护一个堆来存储 dist
值的信息。
bool done[MAXN];//是否求过
void Dijkstra(int s){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[s]=0;
std::priority_queue<std::pair<int,int> > heap;
//first 为最短路的相反数,second 为点的编号
for(int i=1;i<=N;i++)
heap.push(std::make_pair(-dist[i],i));
for(int i=1;i<=N;i++){
while(done[heap.top().second])heap.pop();
//找还没有求出最短的 dist 值最小的那个点
int p=heap.top().second;
heap.pop();
done[p]=1;
//松弛操作
for(int j=0;j<g[p].size();j++){
int q=g[p][j].first,d=g[p][j].second;
//这是一条从 p 到 q 长度为 d 的边
if(dist[q]>dist[p]+d){
dist[q]=dist[p]+d;
heap.push(std::make_pair(-dist[q],q));
}
}
}
}
\(Bellman\)_\(ford\)
任意两点间的最短路路径上边的数量一定不会超过 \(n-1\)。
证明:如果超过,则说明有一个点经过了两次,也就是有环,那么删去环的路径一定更优。
memset(dist,0x3f,sizeof(dist));
dist[1]=0;
for(int i=1;i<n;i++)
for(int j=1;j<=m;j++)
dist[e[j]]=std::min(dist[e[j]],dist[s[j]]+d[j]);
\(SPFA\)
维护一个队列,表示可能改变其他点最短路的点。不断向队列中加入可能改变其他点的最短路,然后再把新的点加入队列中,直至队列为空。
//最坏 O(nm) 平均 O(km) k<20
bool inque[MAXN];//i 点是否在队列中
void SPFA(int S){//计算 s 到其他所有点的最短路
memset(dist,0x3f,sizeof(dist));
dist[S]=0;
std::queue<int> q;//用来存储可能改变其他点最短路的点
q.push(S);
inque[S]=true;
while(q.size()){//队列不为空
int s=q.front();
q.pop();
inque[s]=false;
for(int i=0;i<g[s].size();i++){
int e=g[s][i].first,d=g[s][i].second;
if(dist[e]>dist[s]+d){
dist[e]=dist[s]+d;
if(!inque[e])inque[e]=true,q.push(e);//有可能更新其他点
}
}
}
}
应用
负环判定
负环:边权总和为负数的环。
- 最短路经过边数超过 \(n-1\) 条。
最短路上存在环,因为已经是最短路,所以该环一定是负环。 - \(SPFA\) 一个点的入队次数超过了 \(n\)。
有至少一个点对该点有两次贡献,则代表出现负环。
差分约束
给定 \(n\) 个变量和 \(m\) 个不等式 \(x_i - x_j \le a_k\),求 \(x_n - x_1\) 的最大值。
对于求 \(x_n - x_1\) 的最大值,我们将原式子转化为 \(x_i \le x_j + a_k\) 的形式,此时创建一条从 \(j\) 到 \(i\) 长度为 \(a_k\) 的边,最终答案则为最短路答案。
相反,对于求最小值则转化为 \(x_i \ge x_j +a_k\) 的形式,同样建边,但是跑最长路。
- 扩展
很多时候差分约束的条件并不是简单的小于等于号,这时候我们需要稍微做点变形。
当不等号与我们所需相反时(即求最大值时的式子为 \(x_i - x_j \ge a_k\)),让等式两边同乘 \(-1\),将等号翻转过来。
当出现 \(x_i - x_j = a_k\) 时,则可以将式子转化为 \(x_i - x_j \le a_k\) 和 \(x_i - x_j \ge a_k\) 两个条件。
Code
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
const int MAXN=5e3+5;
int n,m;
std::vector<std::pair<int,int> > g[MAXN];
void add(int s,int e,int d){g[s].push_back({e,d});}
int dist[MAXN],tot[MAXN];
bool inq[MAXN];
bool SPFA(){
memset(dist,0x7f,sizeof(dist));
std::queue<int> q;
q.push(0);
inq[0]=1;
dist[0]=0;
while(q.size()){
int s=q.front();
q.pop();
inq[s]=0;
for(int i=0;i<g[s].size();i++){
int e=g[s][i].first;
int d=g[s][i].second;
if(dist[e]>dist[s]+d){
dist[e]=dist[s]+d;
if(inq[e]==0){
inq[e]=1;
tot[e]++;
if(tot[e]>n+1)return false;
q.push(e);
}
}
}
}
return true;
}
signed main(){
std::ios::sync_with_stdio(false);
std::cin.tie(0);
std::cin>>n>>m;
for(int i=1;i<=n;i++)add(0,i,0);
while(m--){
int s,e,d;
std::cin>>e>>s>>d;
add(s,e,d);
}
if(SPFA()==0)std::cout<<"NO\n";
else for(int i=1;i<=n;i++)std::cout<<dist[i]<<" \n"[i==n];
return 0;
}
一些牛站在数轴上,给定两种限制:
- 第 \(i\) 头牛和第 \(j\) 头牛满足 \(|x_i-x_j| \le a_k\);
- 第 \(i\) 头牛和第 \(j\) 头牛满足 \(|x_i-x_j| \ge a_k\);
求满足限定条件时的 \(1\) 号牛和 \(n\) 号牛之间的最长距离。
- 思路
求最大距离,我们需要跑最短路,且要将原式子转化为 \(x_i \le x_j + a_k\) 的形式。对于 \(|x_i-x_j| \le a_k\),我们转化为 \(x_i \le x_j + a_k\),并从 \(j\) 向 \(i\) 连一条长度为 \(a_k\) 的边;对于 \(|x_i-x_j| \ge a_k\),我们转化为 \(x_j \le x_i - a_k\),并从 \(i\) 向 \(j\) 连一条长度为 \(-a_k\) 的边。
对于题目假定的条件 \(x_1 \le x_2 \le \dots \le x_n\),我们将其看为 \(x_i \le x_{i+1} + 0\),从 \(i\) 向 \(i+1\) 连一条长度为 \(0\) 的边。
给定一个 \(n \times n\) 的矩阵,每次可以将任意一行或任意一列乘任意一个数,问能否使所有的数都在 \([l,r]\) 内。
- 思路
我们设置 \(2 \times n\) 个变量,\(a_i\) 和 \(b_j\) 分别代表将第 \(i\) 行和第 \(j\) 列所乘的数,\(c_{i,j}\) 为原先的数,则有 \(l \le c_{i,j} \times a_i \times b_j \le r\)。
因为差分约束需要 \(x_i - x_j \le c\) 的形式,那么我们将原式子转化为 \(\log\frac{l}{c_{i,j}} \le \log a_i - (- \log b_j) \le \log\frac{r}{c_{i,j}}\),由此建边判解即可。
【写出每个位置的约束条件 -> 取 \(\log\) 把两个变量拆开 -> 令一个变量取负数】
树上序列
\(LCA\) 问题
暴力做法:调整深度一致 -> 同时向上跳直至到达同一个点,复杂度 \(O(n)\)。
考虑优——倍增求 \(LCA\)。
提前进行预处理计算 \(f\) 数组,\(f_{i,j}\) 表示从点 \(i\) 向上跳 \(2^j\) 到达的点。
初始化 \(f_{i,0} = fa[i]\),转移式子为 \(f_{i,j} = f_{f_{i,j-1},j-1}\)。
void dfs(int now){
for(int i=0;i<v[now].size();i++){
int p=v[now][i];
if(p!=f[now][0]){//不是父亲
dep[p]=dep[now]+1;
f[p][0]=now;
for(int x=1;x<=20;x++)//枚举 2^x
f[p][x]=f[f[p][x-1]][x-1];//2^x=2^(x-1)+2^(x-1)
dfs(p);
}
}
}