基礎圖論
圖的儲存方式
無向邊可以拆成兩條有向邊
1. 鄰接矩陣
鄰接矩陣:若 \(𝑖→𝑗\) 存在有向邊,則令矩陣 \(𝐴[𝑖][𝑗]=1\)。
遍歷一個點的所有出邊是 \(𝑂(𝑛)\) 的。
空間複雜度 \(𝑂(𝑛^2 )\)。
總結:複雜度太高,儘量不使用
bool hasEdge[MAXN][MAXN];
int n,m;
signed main(){
cin>>n>>m;
for(int i=0,u,v;i<m;i++){
int u, v;
cin >> u >> v;
hasEdge[u][v]=true;
}
for(int v=0;v<n;v++){
if(hasEdge[u][v]){
//......
}
}
return 0;
}
2. 鄰接表
方法:每個節點開一個連結串列,儲存所有連出去的邊。
遍歷一個點 \(𝑥\) 的所有出邊是 \(𝑂(𝑜𝑢𝑡_𝑥 )\) 的,其中 \(𝑜𝑢𝑡_𝑥\) 為 \(𝑥\) 的出度。
空間複雜度 \(𝑂(𝑛+𝑚)\)。
const int N = 1e5 + 10, M = 1e5 + 10;
int head[N], nxt[M], point[M];
int n, m, totEdge;
void addEdge(int u, int v){
totEdge++;
nxt[totEdge]= head[u];
point[totEdge]=v;
head[u]= totEdge;
}
int main(){
cin >> n >> m;
for(int i=0;i < m; i++){
int u, v;
cin >> u >> v;
addEdge(u, v);
}
for(int i= head[u]; i; i = nxt[i]){
int v = point[i];
//......
}
return 0;
}
3. vector
每個節點開一個 vector ,儲存所有連出去邊的終點。
遍歷一個點 \(𝑥\) 的所有出邊是 \(𝑂(𝑜𝑢𝑡_𝑥 )\) 的,其中 \(𝑜𝑢𝑡_𝑥\) 為 \(𝑥\) 的出度。
空間複雜度 \(𝑂(𝑛+𝑚)\)。
實際上與鄰接表類似。
int n,m;
vector<int> nextPoints[MAXN];
signed main(){
cin>>n>>m;
for(int i=0,u,v;i<m;i++){
cin>>u>>v;
nextPoints[u].push_back(v);
}
for(int v=0;v<n;v++){
//......
}
return 0;
}
總結:使用鄰接表或 vector 來存,複雜度小
DFS 找環
維護一個 DFS 的棧。
在列舉下一個點時檢查是否已在棧中。
複雜度 \(𝑂(𝑛+𝑚)\)。
如果是無向圖,由於需要排除大小為 \(2\) 的環,可以記錄 \(𝑥\) 的上一個點 \(𝑙𝑎𝑠𝑡\),\(𝑦=𝑙𝑎𝑠𝑡\) 時直接跳過。
const int MAXN=100005;
bool vis[MAXN],inStack[MAXN];
int n,m;
vector<int> nextPoints[MAXN];
bool dfs(int x){
vis[x]= true;
inStack[x]= true;
bool result = false;
for(auto y: nextPoints[x]){
if(inStack[y])
result = true;
if(!vis[y])
result |= dfs(y);
}
inStack[x]= false;
return result;
}
signed main(){
cin >>n >> m;
for(int i=0,u, v; i< m; i++){
cin >>u >> v;
nextPoints[u].push_back(v);
}
bool hasLoop = false;
for(int i=0;i<n; i++){
if(!vis[i])
hasLoop |= dfs(i);
}
return 0;
}
圖的連通性問題
問題:加入 \(𝑚\) 條邊,問兩點之間是否連通。
可以使用並查集維護。
int fa[N];
int n, m;
int find(int x){
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
void merge(int u, int v){
fa[find(u)]= find(v);
}
bool isConnected(int u,int v){
return find(u)== find(v);
}
int main(){
cin >> n >> m;
for(int i = 0; i < n; i++){
fa[i] = i;
}
for(int i = 0; i < m; i++){
int u, v;
cin >> u >> v;
if(!isconnected(u, v))
merge(u, v);
}
return 0;
}
並查集
適用於:合併兩個連通塊、查詢兩個節點是否位於同一連通塊問題
對於每個節點 \(𝑥\),記錄所在集合的代表元素 \(𝑓[𝑥]\)。如果在節點 \(𝑥\) 與 \(𝑦\) 之間連一條邊,只需要令 \(𝑓[𝑓[𝑥]]=𝑓[𝑦]\)。
最佳化
考慮最佳化
方法:路徑壓縮,按秩合併。
只用其一則複雜度為 \(𝑂(𝑛 \log 𝑛 )\),二者並用複雜度為 \(O(nα(n))\)(都為均攤)。
注意:
int find(int x){
if(f[x] == x){
return x;
}
return f[x] = find(f[x]);// 此處為路徑壓縮
}
void merge(int x4, int y){
x = find(x), y = find(y);
if(size[x] > size[y]){
swap(x, y);
}
f[x] = y, size[y] += size[x];// 此處為按秩合併
}
T1 程式自動分析
問題簡述:
給定 \(𝑛\) 個變數 \(𝑥_𝑖\),以及 \(𝑚\) 個限制,形如 $𝑥_𝑖=𝑥_𝑗 $ 或者 \(𝑥_𝑖≠𝑥_𝑗\),問這些限制是否能夠全部滿足。
思路:並查集和離散化直接用
#include<bits/stdc++.h>
using namespace std;
const int M = 100000005;
struct opt{
int x, y, e;
} nn[M];
int t, n;
int num[M];
int cnt[M];
inline int read(){
int x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int find(int x) {
return x==num[x] ? x:num[x] = find(num[x]);
}
bool cmp(opt a, opt b) {
return a.e > b.e;
}
int main() {
t = read();
while(t--) {
int tot = 0;
n = read();
for (int i = 0; i < n; i++) {
nn[i].x = read();nn[i].y = read();nn[i].e = read();
cnt[tot++] = nn[i].x; cnt[tot++] = nn[i].y;
}
sort(cnt, cnt+tot);
int len = unique(cnt, cnt+tot) - cnt;
for (int i = 0; i < n; i++) {
nn[i].x = lower_bound(cnt, cnt+len, nn[i].x) - cnt;
nn[i].y = lower_bound(cnt, cnt+len, nn[i].y) - cnt;
}
for (int i = 0; i < len; i++) {
num[i] = i;
}
sort(nn, nn+n, cmp);
int flag = 1;
for (int i = 0; i < n; i++) {
int f1 = find(nn[i].x), f2 = find(nn[i].y);
if (nn[i].e == 1) {
if (f1 != f2) num[f1] = f2;
}
else {
if (f1 == f2) {
flag = 0;
break;
}
}
}
if (flag) printf("YES\n");
else printf("NO\n");
}
return 0;
}
並查集 – 擴充套件域
T2 食物鏈
問題簡述:
有三種動物 \(𝐴,𝐵,𝐶\),\(𝐴\) 吃 \(𝐵\),\(𝐵\) 吃 \(𝐶\),\(𝐶\) 吃 \(𝐴\)。
現在有 \(𝑛\) 只動物,每隻屬於 \(𝐴,𝐵,𝐶\) 中的一種。
給出 \(𝑚\) 條限制,形如 \(𝑥,𝑦\) 品種相同,或者 \(𝑥\) 吃 \(𝑦\) 。
詢問有哪些限制與前面的限制衝突(如果衝突,這條限制就失效)
思路:見圖
最小生成樹
定義:無向圖中所有生成樹中,邊權和最小的。
最小生成樹:Kruskal
從“邊權最小的邊一定在最小生成樹中”的想法入手,每次加入邊權最小的邊 \((𝑢,𝑣,𝑤)\),並把 \(𝑢\) 和 \(𝑣\) 合併為一個連通塊。
也即,把所有的邊按照邊權排序,檢視每條邊是否與之前的邊成環(使用並查集),若不成環則加入最小生成樹。
複雜度 \(𝑂(𝑚 log𝑚 )\)。
#include<iostream>
#include<cstring>
#include<cstdio>
#include<cmath>
#include<algorithm>
#include<map>
#include<vector>
#include<queue>
#include<set>
#include<unordered_map>
#include<bitset>
#define int long long
using namespace std;
const int MAXN=100005;
struct edge {
int u, v, w;
}edges[MAXN];
bool compare(edge A,edge B){
return A.w < B.w;
}
int fa[MAXN];
int n, m;
int find(int x){
return fa[x]==x?x: fa[x]= find(fa[x]);
}
void merge(int u, int v){
fa[find(u)]=find(v);
}
signed main(){
cin >> n >> m;
for(int i=0; i < m; i++){
cin >>edges[i].u >> edges[i].v >> edges[i].w;
}
sort(edges,edges + m,compare);
for(int i=0; i <n; i++){
fa[i]=i;
}
int totWeight =0;
for(int i =0; i < m; i++){
if(find(edges[i].u)!= find(edges[i].v)){
merge(edges[i].u, edges[i].v);
totWeight + edges[i].w;
}
}
}
最小生成樹:Prim
或者維護一個連通塊,每次向外延伸一條最短邊,將新的點合併進連通塊內。
時間複雜度 \(𝑂(𝑛𝑚)\),使用優先佇列進行優後 \(𝑂(𝑚 \log𝑛 )\)。
#include <iostream>
#include <queue>
#include <cmath>
#include <algorithm>
#include <vector>
#include <cstring>
#include <cstdio>
#include <set>
#include <map>
#include <unordered_map>
#include <bitset>
using namespace std;
const int N = 1e5 + 10;
int dis[N];
int n, m;
bool vis[N];
vector<pair<int, int> >edges[N];
int main(){
cin >> n >> m;
for(int i = 0,u , v, w; i< m; i++){
cin >> u >> v >> w;
edges[u].emplace_back(v, w);
edges[v].emplace_back(u, w);
}
vis[1]= true;
int totWeight=0;
for(int T = 0;T < n - 1; T++){
memset(dis,0x3f,sizeof(dis));
for(int i=0;i<n; i++){
if(!vis[i]){
continue;
}
for(auto edge : edges[i]){
if(!vis[edge.first]){
dis[edge.first] = min(dis[edge.first], edge.second);
}
}
}
int min_dis = 1 << 30, min_id = 0;
for(int i = 0;i < n; i++){
if(!vis[i] && dis[i] < min_dis){
min_dis = dis[i], min_id = i;
}
totWeight += min_dis;
vis[min_id]= true;
}
}
return 0;
}
Prim 的最佳化
每次找出向外“延伸的最短邊”可以用優先佇列最佳化
其實這樣寫複雜度不是嚴格 \(𝑂(𝑚 \log𝑛 )\),因為佇列大小可能 \(>𝑛\) (但是是 \(𝑂(𝑚 \log𝑚 )\))。使用平衡樹可以變為嚴格 \(𝑂(𝑚 \log𝑛 )\)。
void dfs(int x,int parent,int depth){
pos[x]=++time;
f[0][time]=make_pair(depth,x);
for(auto &y:son[x]){
if(y!=parent){
dfs(y,x,depth+1);
f[0][++time]=make_pair(depth,x);
}
}
}
for(int i=2;i<=time;i++){
high_bit[i]=high_bit[i>>1]+1;
}
for(int i=1;i<=high_bit[time];i++){
for(int j=1;j+(1<<i)-1<=time;j++){
f[i][j]=min(f[i-1][j],f[i-1][j+(1<<i-1)]);
}
}
int lca(int x,int y){
int L=min(pos[x],pos[y]),R=max(pos[x],pos[y]);
int x=high_bit[R-L+1];
return min(f[x][L],f[x][R-(1<<x)+1]).second;
}
最小生成樹
如果圖中某些邊邊權相等,則最小生成樹可能不唯一。使用 Kruskal 演算法或者 Prim 演算法可以求出某個最小生成樹。
RMQ LCA
利用 st 表可以 \(𝑂(𝑛 \log𝑛 )−𝑂(1)\) 求 LCA 。
觀察尤拉序中 \(𝑥\) 走到 \(𝑦\) 的過程(假設 \(𝑥\) 尤拉序小於 \(𝑦\)),發現在這段區間內,深度最小的點就是 LCA 。
記 \(𝑓(𝑖,𝑗)\) 為尤拉序中 \([𝑖,𝑖+2^𝑗−1]\) 的最淺深度,\(𝑔(𝑖,𝑗)\) 為其編號。
dfs 預處理。
\((𝑓,𝑔)\) 記為一個 \(pair<int, int>\) 更方便。
const int MAXX = 1e5 + 10, MAXN = 1e9 + 10;
inline int read(){
int x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int f[MAXN][MAXN];
int fa[MAXN];
int dep[MAXN];
int lca(int x,int y){
if(dep[x]>dep[y])
swap(x,y);
for(int j=20;j>=0; j--)
if(dep[f[y][j]]>= dep[x])
y=f[y][j];
if(x == y)
return x;
for(int j=20;j>=0; j--){
if(f[x][j]!= f[y][j]){
x=f[x][j];
y=f[y][j];
}
}
return f[x][0]; //f[x][e] == f[y][e]
}
signed main(){
f[i][0] = fa[i];
for(int j = 1;j <= 20;j++){
for(int i = 1;i <= n;i++){
f[i][j] = f[f[i][j - 1]][j - 1];
}
}
return 0;
}
最短路徑問題
單源最短路:給定起點 \(𝑠\),求出 \(𝑠\) 到其它所有點的最短路。
邊權任意(Bellman−Ford,SPFA) / 邊權非負(Dijkstra)。
多源最短路:求出所有點對之間的最短路。邊權任意
(Floyd)。
多源最短路:Floyd
設 \(𝑓(𝑘,𝑖,𝑗)\) 為,在僅考慮編號 \(≤𝑘\) 的節點時,\(𝑖→𝑗\) 的最短距離(路徑上除了 \(𝑖,𝑗\) 外節點編號都 \(≤𝑘\))。
複雜度 \(𝑂(𝑛^3 )\)。
#include <bits/stdc++.h>
using namespace std;
int n,m,dis[...][...];
int main(){
cin >> n >> m;
memset(dis,0x3f,sizeof(dis));
for(int i=0,u,v,w;i<m;i++){
cin >> u >> v >> w;
dis[u][v]=min(dis[u][v],w);
}
for(int k=0;k<n;k++)
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
dis[i][j]=min(dis[i][j],dis[i][j]+dis[k][j]);
return 0;
}
以 \(𝑖,𝑗,𝑘\) 順序列舉錯誤的例子:
由於是按照 \(𝑖,𝑗,𝑘\) 順序列舉的,則 \(𝑑𝑖𝑠[0][1]\) 只會在第4一輪更新。但此時 \(𝑑𝑖𝑠[2][1]\) 和 \(𝑑𝑖𝑠[0][3]\) 都是 \(INF\) ,導致無法更新到 \(𝑑𝑖𝑠[0][1]\)。
/*wrong*/
#include <bits/stdc++.h>
using namespace std;
int n,m,dis[...][...];
int main(){
cin >> n >> m;
memset(dis,0x3f,sizeof(dis));
for(int i=0,u,v,w;i<m;i++){
cin >> u >> v >> w;
dis[u][v]=min(dis[u][v],w);
}
for(int i=0;k<n;k++)
for(int j=0;i<n;i++)
for(int k=0;j<n;j++)
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
return 0;
}
單源最短路:Bellman−Ford
當圖中存在負環時,可以先從 \(𝑠\) 走到負環,然後無限繞圈,則 \(𝑠→𝑖\) 的路徑可以無限小。
此時表現為一直有某些個 \(𝑑𝑖𝑠\) 被成功更新。因為如果不存在負環,那麼 \(𝑠→𝑖\) 的最短路邊數一定 \(≤𝑛−1\),則更新完 \(𝑛−1\) 輪後,\(𝑑𝑖𝑠\) 不再變化。
所以可以額外檢查是否一直有 \(𝑑𝑖𝑠\) 被更新 ,以此來判斷圖中是否有負環。
#include <bits/stdc++.h>
using namespace std;
struct edge{
int u,v,w;
}edges[...];
int n,m,s,dis[...];
int main(){
cin >> n >> m >> s;
memset(dis,0x3f,sizeof(dis));
for(int i=0,u,v,w;i<m;i++)cin >> edges[i].u >> edges[i].v >> edges[i].w;
dis[s]=0;
for(int T=0;T<n;T++)
for(int i=0;i<m;i++)
dis[edges[i].v]=min(dis[edges[i].v],dis[edges[i].u]+edges[i].w);
return 0;
}
單源最短路:SPFA
SPFA 演算法是佇列最佳化的 Bellman−Ford 演算法。
一開始將 \(𝑠\) 置入佇列,每次取出隊頭,並將有效鬆弛後的點重新置入佇列,表示可以用這個點更新其它點。
由於無負環時,每個點最多入隊 \(𝑛−1\) 次,則複雜度 \(𝑂(𝑛𝑚)\)。在不被特意卡的隨機情況下複雜度約為 \(𝑂(𝑘𝑚)\) ,\(𝑘\) 為某常數。
不要使用 SLF, LLL 等“最佳化”,會被卡到指數級。
#include <bits/stdc++.h>
using namespace std;
int n,m,s,dis[...];
bool inqueue[...];
queue<int> Q;
vector<pair<int,int> > edges[...];
int main(){
cin >> n >> m >> s;
memset(dis,0x3f,sizeof(dis));
for(int i=0,u,v,w;i<m;i++){
cin >> u >> v >> w;
edges[u].emplace_back(v,w);
}
dis[s]=0;
Q.push(s);
inqueue[s]=true;
while(!Q.empty()){
int x=Q.front();
Q.pop();
inqueue[x]=false;
for(auto edge:edges[x]){
if(dis[edge.first]<=dis[x]+edge.second)continue;
dis[edge.first]=dis[x]+edge.second;
if(!inqueue[edge.first]){
Q.push(edge.first);
inqueue[edge.first]=true;
}
}
}
return 0;
}
如果某個節點入隊次數 \(≥𝑛\),則表示其被成功鬆弛 \(≥𝑛\) 次。
與 Bellman−Ford 類似,此時說明圖中存在負環。
所以可以額外記錄每個節點的入隊次數。
或者可以記錄每個節點的最短路邊數 \(𝑐𝑛𝑡[𝑖]\),當用 \(𝑢\) 更新 \(𝑣\) 時,令 \(𝑐𝑛𝑡[𝑣]=𝑐𝑛𝑡[𝑢]+1\)。如果 \(𝑐𝑛𝑡[𝑖]≥𝑛\) ,則存在負環。
單源最短路:Dijkstra
在邊權非負的情況下,可以使用 Dijkstra 演算法。
類似於 Prim 演算法,維護一個已經考慮完畢點的集合,每次向外擴充最短距離的點。
使用優先佇列最佳化,複雜度為 \(𝑂((𝑛+𝑚) \log𝑛 )\)。
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> pii;
int n,m,s,dis[...];
bool vis[...];
vector<pii> edges[...];
priority_queue<pii,vector<pii>,greater<pii> > Q;
int main(){
cin >> n >> m >> s;
for(int i=0,u,v,w;i<m;i++){
cin >> u >> v >> w;
edges[u].emplace_back(v,w);
}
Q.push({0,s});
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
while(!Q.empty()){
pii info=Q.top();
Q.pop();
int x=info.second;
if(dis[x]!=info.first)continue;
for(auto e:edges[x]){
if(dis[e.first]<=dis[x]+e.second)
continue;
dis[e.first]=dis[x]+e.second;
Q.push({dis[e.first],edge.first});
}
}
return 0;
}
Dijkstra 是一個貪心演算法。與 SPFA 演算法不同的是,ijkstra 過程中每個節點只會入隊一次。
其正確性建立在邊權非負的基礎上。假設 \(1\) 號點到 \(5\) 號點的最短路徑具體為 \(1→2→4→5\),由於 \(4→5\) 這條邊權非負,那麼在加入 \(5\) 號點之前,\(4\) 號點一定被加入過了。從而最短路徑一定會被考慮到。
如果邊權可以為負,則不能使用 Dijkstra 演算法。
一個反例:
在這個反例中,會首先從 \(0\) 號點擴充 \(1\) 號點,錯誤。
單源最短路:01−BFS
當邊權僅為 $0/1¥ 時,可以使用 01−BFS 演算法計算單元最短路。
維護雙端佇列,遇到 \(0\) 邊時將下一個點加到隊頭,遇到 \(1\) 邊時將下一個點加到隊尾。
這樣在 BFS 到某個點時,會先訪問所有與其距離為 \(0\) 的點。
相當於先把所有 \(0\) 邊縮起來,再跑一般的 BFS 。
複雜度 \(𝑂(𝑛+𝑚)\)。
(右圖中的寫法可能一個點入隊多次,不過由於 \(𝑑𝑖𝑠\) 小的情況一定在前,所以不影響複雜度)