图论小学习(一)
图的存储
邻接矩阵
使用二维数组存储,\(i\) 到 \(j\) 有连边则标记为 \(1\),否则置 \(0\)
参考代码
vector<vector<int> >adj;
void init(int n){//n个点
adj.resize(n);
for(int i=0;i<n;i++) adj[i].resize(n);
}
void add(int u,int v){
adj[u][v]++;
//adj[v][u]++;无向图时添加
}
链式前向星
类似于链表的方式存储每条边的终点,长度和下标
参考代码
int h[N<<1],cnt;
struct node{
int to,nxt;
ll w;
}adj[N<<1];
void add(int u,int v,ll w){
cnt++;
adj[cnt].to=v;
adj[cnt].w=w;
adj[cnt].nxt=h[u];
h[u]=cnt;
}//单向加边
邻接表
利用vector容器的性质存储
参考代码
vector<vector< pair<int,ll> > >adj;
//vector< pair<int,ll> >adj[N];有时使用这种简便写法
void add(int u,int v,ll w){
adj[u].push_back({v,w});
//adj[v].push_back({u,w});无向图建双边
}
图的遍历
DFS深度优先搜索
一直遍历到图中"最深"的节点,当某个节点的连接节点均已遍历过时回溯
void dfs(int u){
for(auto &v:G[u]){
if(vis[v]) continue;
vis[v]=1;
dfs(v);
}
}
BFS广度优先搜索
优先遍历每个点的所有连点,然后再考虑下一层
void bfs(int s){
queue<int>Q;
memset(tim,0x3f,sizeof tim);
tim[s]=0;//到起点的最早时间是0
Q.push(s);//起点入队
while(!Q.empty()){
int u=Q.front();
Q.pop();
for(auto &v:G[u]){
if(tim[u]+1<tim[v]){//只有到v的最早时间小于tim[v]才考虑加入队列
Q.push(v);
tim[v]=tim[u]+1;
}
}
}
}
拓扑排序
解决有向无环图顺序的一种算法
步骤
构造拓扑序列步骤
1.从图中选择一个入度为零的点。
2.输出该顶点,从图中删除此顶点及其所有的出边。
3.重复上面两步,直到所有顶点都输出,拓扑排序完成,或者图中不存在入度为零的点,此时说明图是有环图,拓扑排序无法完成,陷入死锁。
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int num,n,in[N];
queue<int>Q;
vector<int>G[N];
void add(int x,int y){
G[x].push_back(y);
}
void dfs(int u){
in[u]--;
for(int i=0;i<G[u].size();i++){
int v=G[u][i];
in[v]--;
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
while(cin>>num&&num!=0){
in[num]++;
add(i,num);
}
}
for(int i=1;i<=n;i++){
if(in[i]==0){
Q.push(i);
break;
}
}
while(!Q.empty()){
int s=Q.front();
cout<<s<<" ";
dfs(s);
for(int i=1;i<=n;i++){
if(in[i]==0){
Q.push(i);
break;
}
}
Q.pop();
}
cout<<endl;
return 0;
}
应用
dp计算依赖状态时考虑拓扑序的转移,内向基环树类似简单图找环,任务调度
欧拉图
欧拉路径
欧拉路径(Eulerian path)是经过图中每条边恰好一次的路径。如果一个图中不存在欧拉回路但是存在欧拉路径,则这个图被称为半欧拉图(semi-Eulerian graph)。
欧拉回路
欧拉回路(Eulerian circuit)是经过图中每条边恰好一次的回路。 如果一个图中存在欧拉回路,则这个图被称为欧拉图(Eulerian graph)。
性质
对于连通图 G,以下三个性质是互相等价的:
G 是欧拉图;
G 中所有顶点的度数都是偶数(对于有向图,每个顶点的入度等于出度);
G 可被分解为若干条不共边回路的并。
参考代码
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
const int N=2e5+5;
int d[N];
vector<pair<int,int>>G[N];
bool vis[N];
vector<int>path;
void dfs(int u){
for(auto &[v,i]:G[u]){
if(vis[i]) continue;
vis[i]=1;
dfs(v);
}
path.push_back(u);
}
int main(){
cin.tie(0)->ios::sync_with_stdio(false);
int n,m,s=1;
cin>>n>>m;
for(int i=1;i<=m;i++){
int x,y;cin>>x>>y;
++d[x],++d[y];
G[x].push_back({y,i});
G[y].push_back({x,i});
}
for(int i=1;i<=n;i++){
if(d[i]&1) {s=i;break;}
}
dfs(s);
for(auto &x:path) cout<<x<<' ';
cout<<endl;
return 0;
}
最小生成树
我们定义无向连通图的 最小生成树(Minimum Spanning Tree,MST)为边权和最小的生成树
Prim
Prim 算法是另一种常见并且好写的最小生成树算法。该算法的基本思想是从一个结点开始,不断加点
参考代码
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
const int inf=0x3f3f3f3f;
const int N=5e5+10;
int dis[N];
int n,m,cntn;
bool vis[N];
ll ans;
struct edge{
int u,w;
};
vector<edge>G[N];
void add(int x,int y,int w){
G[x].push_back({y,w});
G[y].push_back({x,w});
}
struct node{
int u,w;
friend bool operator <(node a,node b){
return a.w>b.w;
}
}a[N];
priority_queue<node>Q;
void Prim(){
cntn=n;
dis[1]=0;
Q.push({1,0});
while(!Q.empty()){
auto [u,w]=Q.top();
Q.pop();
if(vis[u]) continue;
cntn--;
ans+=w;
vis[u]=1;
for(int i=0;i<G[u].size();i++){
auto [v,w]=G[u][i];
if(vis[v]) continue;
if(w<dis[v]){
dis[v]=w;
Q.push({v,dis[v]});
}
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n>>m;
memset(dis,0x3f,sizeof(dis));
for(int i=1;i<=m;i++){
int x,y,w;cin>>x>>y>>w;
add(x,y,w);
add(y,x,w);
}
Prim();
if(cntn==0) cout<<ans<<endl;
else cout<<"orz"<<endl;//无解
return 0;
}
Kruskal
Kruskal 算法是一种常见并且好写的最小生成树算法,由 Kruskal 发明。该算法的基本思想是从小到大加入边,是个贪心算法。
参考代码
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
ll ans;int n,m,cntn;
const int N=2e5+10;
int f[N];
int find(int x){
if(x==f[x]) return x;
return f[x]=find(f[x]);
}
struct node{
int x,y,w;
friend bool operator <(node a,node b){
return a.w<b.w;
}
}a[N];
void kruskal(){
cntn=n;
for(int i=1;i<=m;i++){
int fx=find(a[i].x);
int fy=find(a[i].y);
if(fx!=fy){
f[fx]=fy;
cntn--;
ans+=a[i].w;
if(cntn==1) break;
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++) f[i]=i;
for(int i=1;i<=m;i++){
int x,y,w;
cin>>a[i].x>>a[i].y>>a[i].w;
}
sort(a+1,a+1+m);
kruskal();
if(cntn==1){
cout<<ans<<endl;
}
else cout<<"orz"<<endl;//无解
return 0;
}
最短路
求解图上两点最短路径的算法
Bellman–Ford
Bellman–Ford 算法是一种基于松弛(relax)操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。基于公式\(dis(v) = \min(dis(v), dis(u) + w(u, v))\)。在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 +1,而最短路的边数最多为 \(n-1\),因此整个算法最多执行 \(n-1\) 轮松弛操作。故总时间复杂度为 \(O(nm)\)。
参考代码
struct edge{
int u,v,w;
};
vector<edge>E;
int dis[N],n;
bool bellmanford(int s){
memset(dis,0x3f,sizeof(int)*(n+1));
dis[s]=0;
bool f=false;//当前是否发生松弛操作
for(int i=1;i<=n;i++){
f=false;
for(int j=0;j<E.size();j++){
int u=E[j].u;
int v=E[j].v;
int w=E[j].w;
if(dis[u]==0x3f3f3f3f) continue;
if(dis[v]>dis[u]+w){
dis[v]=dis[u]+w;
f=true;
}
}
if(!f) break;
}
return f;
}
Spfa
很多时候我们并不需要那么多无用的松弛操作。很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。
那么我们用队列来维护「哪些结点可能会引起松弛操作」,就能只访问必要的边了。
ps:Spfa还有栈,堆,双端队列等优化形式,但最劣均是O(n·m)的复杂度
参考代码
struct edge{
int v,w;
};
vector<edge>E[N];
int dis[N],cnt[N],n;
bool vis[N];//记录每个点是否在队列中
queue<int>Q;
bool spfa(int s){
memset(dis,0x3f, (n+1)*sizeof(int));
dis[s]=0;vis[s]=1;
Q.push(s);
while(!Q.empty()){
int u=Q.front();
Q.pop();
vis[u]=0;
for(auto &e:E[u]){
int v=e.v;
int w=e.w;
if(dis[v]>dis[u]+w){
dis[v]=dis[u]+w;
cnt[v]=cnt[u]+1;
if(cnt[v]>=n) return false;//存在负环
if(!vis[v]) Q.push(v),vis[v]=1;
}
}
}
return true;
}
卡spfa
负环判定
SPFA 也可以用于判断 s 点是否能抵达一个负环,只需记录最短路经过了多少条边,当经过了至少 n 条边时,说明 s 点可以抵达一个负环。
Dijkstra(非负权边单源最短路径)
将结点分成两个集合:已确定最短路长度的点集(记为 S 集合)的和未确定最短路长度的点集(记为 T 集合)。一开始所有的点都属于 T 集合。
初始化 \(dis(s)=0\),其他点的 \(dis\) 均为 \(+\infty\)。
然后重复这些操作:
1.从 T 集合中,选取一个最短路长度最小的结点,移到 S 集合中。
2.对那些刚刚被加入 S 集合的结点的所有出边执行松弛操作。
3.直到 T 集合为空,算法结束。
时间复杂度
朴素的实现方法为每次 \(2\) 操作执行完毕后,直接在 \(T\) 集合中暴力寻找最短路长度最小的结点。2 操作总时间复杂度为 \(O(m)\),1 操作总时间复杂度为 \(O(n^2)\),全过程的时间复杂度为 \(O(n^2 + m) = O(n^2)\)。
可以用堆来优化这一过程:每成功松弛一条边 \((u,v)\),就将 \(v\) 插入堆中(如果 \(v\) 已经在堆中,直接执行 Decrease-key),1 操作直接取堆顶结点即可。共计 \(O(m)\) 次 Decrease-key,\(O(n)\) 次 pop,选择不同堆可以取到不同的复杂度。堆优化能做到的最优复杂度为 \(O(n\log n+m)\),能做到这一复杂度的有斐波那契堆等。
特别地,可以使用优先队列维护,此时无法执行 Decrease-key 操作,但可以通过每次松弛时重新插入该结点,且弹出时检查该结点是否已被松弛过,若是则跳过,复杂度 \(O(m\log n)\),优点是实现较简单。
这里的堆也可以用线段树来实现,复杂度为 \(O(m\log n)\),在一些特殊的非递归线段树实现下,该做法常数比堆更小。并且线段树支持的操作更多,在一些特殊图问题上只能用线段树来维护。
在稀疏图中,\(m = O(n)\),堆优化的 Dijkstra 算法具有较大的效率优势;而在稠密图中,\(m = O(n^2)\),这时候使用朴素实现更优。
参考代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=5e5+10;
const int inf=0x7fffffff;
struct edge{
int nxt,to,w;
}a[N];
int cnt,h[N],dis[N];
bool vis[N];
int n,m,s;
void add(int x,int y,int w){
cnt++;
a[cnt].to=y;
a[cnt].w=w;
a[cnt].nxt=h[x];
h[x]=cnt;
}
struct node{
int u,w;
friend bool operator<(node a,node b){
return a.w>b.w;
}
};
priority_queue<node>Q;
void Dijkstra(){
for(int i=1;i<=n;i++) dis[i]=inf;
dis[s]=0;
Q.push({s,0});
while(!Q.empty()){
auto [u,w]=Q.top();
Q.pop();
if(vis[u]) continue;
vis[u]=1;
for(int i=h[u];i;i=a[i].nxt){
int v=a[i].to;
if(dis[v]>dis[u]+a[i].w){
dis[v]=dis[u]+a[i].w;
Q.push({v,dis[v]});
}
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n>>m>>s;
for(int i=1;i<=m;i++){
int x,y,w;
cin>>x>>y>>w;
add(x,y,w);
}
Dijkstra();
for(int i=1;i<=n;i++) cout<<dis[i]<<' ';
cout<<endl;
return 0;
}
Floyed(多源最短路径)
类似于动态规划的思想,第 \(k\)层 \(x\) 到 \(y\) 的最短路径是由上一层 \(dis_{x,y} = 经过方程min(dis_{x,y}, dis_{x,k} + dis_{k,y})\)转移得到,每层转移复杂度是 \(O(n^2)\) 的,一共转移 \(n\) 次综上时间复杂度是 \(O(N^3)\),空间复杂度是 \(O(N^2)\)。
参考代码
for (k = 1; k <= n; k++) {
for (x = 1; x <= n; x++) {
for (y = 1; y <= n; y++) {
f[x][y] = min(f[x][y], f[x][k] + f[k][y]);
}
}
}
求有向图的传递闭包
我们只需要按照 Floyd 的过程,逐个加入点判断一下。
只是此时的边的边权变为 \(1/0\),而取 \(\min\) 变成了
或运算。再进一步用 \(bitset\) 优化,复杂度可以到
\(O(\frac{n^3}{w})\)
// std::bitset<SIZE> f[SIZE];
for (k = 1; k <= n; k++)
for (i = 1; i <= n; i++)
if (f[i][k]) f[i] = f[i] | f[k];
二分图
二分图,又称二部图,英文名叫 Bipartite graph。
二分图是什么?节点由两个集合组成,且两个集合内部没有边的图。
换言之,存在一种方案,将节点划分成满足以上性质的两个集合。
二分图的判定
一个图是二分图,当且仅当不存在不存在长度为奇数的环
二分图的最大匹配
给定一个二分图 G,即分左右两部分,各部分之间的点没有边连接,要求选出一些边,使得这些边没有公共顶点,且边的数量最大。
参考代码
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
const int N=505;
vector<int>G[N];
int match[N];
bool vis[N];
bool dfs(int u){
for(auto &v:G[u]){
if(vis[v]) continue;
vis[v]=1;
if(!match[v]||dfs(match[v])){//女方没有配偶或者女方的配偶可以拥有其他配偶,那就和她组成配偶关系
match[v]=u;
return 1;
}
}
return 0;
}
int main(){
cin.tie(0)->ios::sync_with_stdio(false);
int n,m,e;cin>>n>>m>>e;
for(int i=1;i<=e;i++){
int u,v;
cin>>u>>v;
G[u].push_back(v);
}
ll ans=0;
for(int i=1;i<=n;i++) {
ans+=dfs(i);
fill(vis+1,vis+1+n,false);
}
cout<<ans<<endl;
return 0;
}

浙公网安备 33010602011771号