基礎圖論

名詞定義

圖 (graph):由 點 (vertex) 跟邊 (edge) 組成

有時候邊或是點可以有權重

有向/無向圖 (directed/undirected):邊是單向或是雙向

自環 (self-loop):邊的起點跟終點相同

重邊 (multiedge):重複的邊

簡單圖 (simple graph):沒有自環跟重邊的圖

路徑 (path):由 \(v_1, v_2, ..., v_n\) 組成的序列,滿足 \(v_i, v_i+1\) 之間有邊

環 (loop):\(v_1 = v_n\) 的路徑

名詞定義

點 \(v\) 的度數 (degree): \(v\) 連出去的邊的數量

\(v_1 \) 與 \( v_2\) 連通 (connected): 存在一條路徑滿足起點是 \(v_1\) 終點是 \(v_2\)

子圖 (subgraph):圖的某個子集,常見的有拔除一些點與拔除一些邊等等

連通分量/連通塊 (connected component, CC):一個子圖包含點與他們之間的所有在原圖上的邊,並且連通

名詞定義

特殊的圖

樹 (tree):沒有環的連通圖,之後會再專門講這個

二分圖 (bipartite graph):圖的頂點可以分成兩個子集 \(V_1, V_2\),滿足所有邊都是一個頂點在 \(V_1\) 內,另一個在 \(V_2\) 內
有向無環圖 (DAG):有向圖但是沒有環

稀疏/稠密:通常是看 \(\frac{|E|}{|V|}\) 而定,越大越稠密

圖的儲存

鄰接矩陣

直接開 n*n 的表格,每格存有沒有邊/邊權的值

(通常用 n 表示點的數量,用 m 表示邊的數量)

$$\begin{Bmatrix} 0&1&1&0 \\ 1&0&0&0 \\ 1&0&0&1 \\ 0&0&1&1\end{Bmatrix}$$

鄰接串列

存 n 個 vector,每個表示有哪些邊連出去

優點是只要用 O(m) 的空間

1: [2, 3]

2: [1]

3: [1,4]

4: [3,4]

主要就是使用這兩個

其他還有各種神奇的方法 e.g. 前向星

但是我不會,自己查

圖的遍歷

遞迴式把一個點的所有邊都走過一遍

DFS

不多說,上扣

vector<vector<int> > e;
//adjacency array
vector<int> vis;
void dfs(int r){
    vis[r]=1;
    for(auto h:e[r]){
        if(!vis[h]){
            dfs(h);
        }
    }
}

使用 queue,每次取出最前面的元素並且把他的所有相鄰的點都加進去

BFS

vector<vector<int> > e;
vector<int> vis;
void bfs(int r){
    queue<int> q;
    q.push(r);
    vis[r]=1;
    while(q.size()){
        auto h=q.front();
        q.pop();
        for(auto y:e[h]){
            if(!vis[y]){
                q.push(y);
                vis[y]=1;
            }
        }
    }
}

BFS - 錯誤示範

錯誤的扣

vector<vector<int> > e;
vector<int> vis;
void bfs(int r){
    queue<int> q;
    q.push(r);
    vis[r]=1;
    while(q.size()){
        auto h=q.front();
        q.pop();
        vis[h]=1;
        for(auto y:e[h]){
            if(!vis[y]){
                q.push(y);
            }
        }
    }
}

錯誤的地方:

在 pop 的時候才標記到 vis

考慮 1, 2, ..., 100000 -> 0

0 -> 100001, 100002, ... 200000

這樣會讓每條 0 -> 100000+k 的邊都跑過 100000 次,造成 TLE

 

前年校內初賽 pC 有抓過這個 XD

樹與他的 DP 們

剛剛的 size 就算是一種樹 DP

那還有什麼東西可以用

樹 DP 的核心:假設 DP[i] 表示 i 的子樹的答案

拿子樹的答案去更新 DP[i]

樹的最小支配集:點集中取出儘量少的點,使剩下的點與取出來的點都有邊相連

 

樹的最小點覆蓋:點集中取出儘量少的點,使得所有邊都與選出的點相連

 

 樹的最大獨立集:點集中取出儘量多的點,使得這些點兩兩之間沒有邊相連

例子

然而你會發現上面那些東西可以 Greedy 做

沒錯這邊是抄去年的...

Greedy 也是一種 DP,只要你用到了子樹傳上來的資訊都是一種廣義的 DP

第二點也是比較重要的點

邊帶權呢

\(dp[v][1]\) 表示當前的點一定選的最大值

\(dp[v][0]\) 表示一定不選的最大值

 

$$dp[v][1] = \sum_{u \rightarrow v} dp[u][0] + a[v]$$

$$dp[v][0] = \sum_{u \rightarrow v} max(dp[u][0], dp[u][1])$$

以最大獨立集為例

樹直徑

1. 維護從小孩走上來的最大值,注意到直徑一定會在某個子樹裡面,所以這方法是好的

也是最通用的方法

2. dfs 兩次,第一次從任意點走到最遠的點,第二次從最遠的點再找到離他最遠的點就是直徑

只能用在邊權都是正的的情況

樹上兩點距離的最大值,距離定義為簡單路徑經過的邊權

樹重心

雖然本質上不是 DP 但是我不知道要放哪裡然後他也會用到而且都講到直徑了不講重心嗎

 

以上幹話

總之是找到一個點 i 滿足將點 i 拔掉之後剩下的連通塊的頂點數量最小

 

這個大小 <= N/2,並且樹重心的數量 <= 2 且必為鄰居

left for readers as an exercise. :)

我懶得證明但是我還是要講怎麼求重心

 

其實上面的性質可以發現

1. 先亂定根,找到所有人的 size

2. dfs, start from root

if 子樹的 size > N/2 那麼跳到子樹上

if not, 那麼當前這個點就是重心

 

因為不會有兩個子樹的 size > N/2,注意到子樹不交

//node
struct no{
    vector<int> ch;
    int sz=1;
};
vector<no> v;
void dfs(int r,int f){
    for(auto h:v[r].ch){
        if(h!=f){
            dfs(h,r);
            v[r].sz+=v[h].sz;
        }
    }
}
int find_centroid(int r,int f,int n){
    for(auto h:v[r].ch){
        if(h!=f){
            if(v[h].sz>n/2){
                return find_centroid(h,r,n);
            }
        }
    }
    return r;
}

LCA

lowest common ancestor

最低共同祖先

作法 1. 倍增法

先將較深的點向上跳使兩個點高度相同

注意到現在的 LCA 不變

且 LCA = min k s.t. 兩個的 k 倍祖先相同

二分搜囉 

接下來的重點就是怎麼二分搜了

首先,我們要實作出向上跳 \(k\) 格怎麼做

做法是把它二進位分解

假設 \(as_{i,j}\) 表示 \(i\) 向上跳 \(2^j\) 格的結果

不難發現 \(as_{,j}\) 可以由 \(as_{,j-1}\) 推出來

 

那麼把 \(k\) 分解成 \(2^j\) 的和就可以用 O(logn) 算出來向上跳 \(k\) 格是什麼了

上面的 as 陣列其實不只這樣用

二分搜的時候還可以再用一次

把 \(j\) 從大到小跑,去看向上跳 \(2^j\) 是不是還是滿足兩個點不一樣,是的話就跳,不是的話就不要跳

跑完一次的結果會得到兩個點,這兩個點的父親相同且 = LCA = 答案

 

整個演算法 O(logn)

//node
struct no{
    vector<int> ch;
    int dep=-1;
    int as[20];
    no(){
        for(int i=0;i<20;i++){
            as[i]=0;
        }
    }
};
vector<no> v;
void dfs(int r,int f){
    v[r].dep=v[f].dep+1;
    v[r].as[0]=f;
    for(auto h:v[r].ch){
        if(h!=f){
            dfs(h,r);
        }
    }
}
int lca(int a,int b){
    if(v[a].dep>v[b].dep){
        swap(a,b);
    }
    for(int i=19;i>=0;i--){
        if(v[v[b].as[i]].dep>=v[a].dep){
            b=v[b].as[i];
        }
    }
    if(a==b){
        return a;
    }
    for(int i=19;i>=0;i--){
        if(v[b].as[i]!=v[a].as[i]){
            a=v[a].as[i];
            b=v[b].as[i];
        }
    }
    return v[a].as[0];
}
int main(){
    //input
    dfs(0,0);
    for(int i=1;i<20;i++){
        for(int j=0;j<n;j++){
            v[j].as[i]=v[v[j].as[i-1]].as[i-1];
        }
    }
    return 0;
}

作法 2: O(1) 判祖先

要進行這個做法之前,先來了解一下 dfs order

 

假設我們把所有點被 dfs 進來的順序叫做 in, 出去的叫做 out

 

那麼 a 是 b 的祖先 iff 

in[a] <= in[b] && out[a] >= out[b]

有了這個之後直接去用上面的 as 陣列看 a 的幾倍祖先是 b 的祖先就好

記得先判 a 已經是 b 的祖先

//node
struct no{
    vector<int> ch;
    int dep=-1;
    int in=0,out=0;
    int as[20];
    no(){
        for(int i=0;i<20;i++){
            as[i]=0;
        }
    }
};
int cnt=0;
vector<no> v;
void dfs(int r,int f){
    v[r].dep=v[f].dep+1;
    v[r].as[0]=f;
    cnt++;
    v[r].in=cnt;
    for(auto h:v[r].ch){
        if(h!=f){
            dfs(h,r);
        }
    }
    cnt++;
    v[r].out=cnt;
}
int is_as(int a,int b){
    return v[a].in<=v[b].in && v[a].out>=v[b].out;
}
int lca(int a,int b){
    if(is_as(a,b)){
        return a;
    }
    for(int i=19;i>=0;i--){
        if(!is_as(v[a].as[i],b)){
            a=v[a].as[i];
        }
    }
    return v[a].as[0];
}
int main(){
    //input
    dfs(0,0);
    for(int i=1;i<20;i++){
        for(int j=0;j<n;j++){
            v[j].as[i]=v[v[j].as[i-1]].as[i-1];
        }
    }
    return 0;
}

作法 3. O(NlogN)/O(1) 的 LCA 

再活用一下 in 跟 out,但是這次改成按照邊的遍歷順序

你應該會得到一個長度是 2n-1 的陣列,因為每條邊剛好走過兩次

這東西又叫做 euler tour

struct no{
    vector<int> ch;
    int dep=-1;
};
vector<no> v;
vector<int> eu;//euler tour
void dfs(int r,int f){
    v[r].dep=v[f].dep+1;
    eu.push_back(v[r].dep);
    for(auto h:v[r].ch){
        if(h!=f){
            dfs(h,r);
            eu.push_back(v[r].dep);
        }
    }
}
int main(){
    //input
    dfs(0,0);
    return 0;
}

如果你看的夠細的話可能會發現上面我 push 進去的是深度

如果不夠細也沒關係,我現在講了

 

因為有個性質:LCA 是這兩點之間在euler tour 上的深度最小值的點

如果我們只要求深度的話這樣就夠了

所以區間最小 -> RMQ -> sparse table

 

這樣就可以查詢 O(1) 了

完整的 code

//sparse table
struct ST{
    
}st;
//node
struct no{
    vector<int> ch;
    int dep=-1;
    int in=0;
}st;
vector<no> v;
vector<int> eu;//euler tour
void dfs(int r,int f){
    v[r].in=eu.size();
    eu.push_back(v[r].dep);
    v[r].dep=v[f].dep+1;
    for(auto h:v[r].ch){
        if(h!=f){
            dfs(h,r);
            eu.push_back(v[r].dep);
        }
    }
}
int lca(int a,int b){
	if(v[a].in>v[b],in){
    	swap(a,b);
    }
    return st.query(v[a].in,v[b].in);
}
int main(){
    //input
    dfs(0,0);
    st.init(eu);
    return 0;
}

然而這還不是 LCA 的極限...

作法 4. O(N)/O(1) LCA

因為實作過於複雜加上其實常數很大所以沒有聽過有實際用途,所以我不講

你要學當然不反對,畢竟選訓會教

選訓還會教怎麼 O(1) 往上跳 k 格

並查集

DSU

DSU 是一個資料結構,可以做兩件事情:

1. 對每個元素查詢它是屬於哪個集合 (find)

2. 把兩個集合合併 (merge)

 

最 naive 的方式是維護 boss[i] 表示當前 i 的上級 (father) 是誰

find 的時候不停地找,直到 boss[i] = i

merge x, y 就把 boss[x]=y

這樣會噴到 O(n^2)

然而我們可以做一些優化

1. 路徑壓縮

觀察到如果 find(i) = k 那下次 find(i) 還是 = k 

所以我們不如乾脆把 boss[i] 改成 k

畫成樹狀結構就可以發現就是把 i 的整個子樹都接到 k 的下方

2. 啟發式合併

如果在 merge 的時候改成把小的樹接到大的樹的下方,這樣即使沒有做 1. 樹高還是 O(log n)

所以查詢的時間是 O(logn)

 

把 1 跟 2 合併起來就可以達到 O(α(n)) 的複雜度 (幾乎是常數)

但是實際上聽說做了 1 之後做 2 不見得會更快,我也不知道為什麼
所以懶得寫就懶得寫吧

struct DSU{
    vector<int> bo,ss;
    void init(int n){
        bo.resize(n);
        iota(bo.begin(),bo.end(),0);
        ss.resize(n,1);
    }
    int find(int x){
        return bo[x]==x?x:bo[x]=find(bo[x]);
    }
    void merge(int x,int y){
        x=find(x);
        y=find(y);
        if(x==y){
            return;
        }
        if(ss[x]>ss[y]){
            swap(x,y);
        }
        bo[x]=y;
        ss[y]+=ss[x];
    }
};

最短路

問題定義:給定一張圖,邊有長度,再給兩個點 S, T,求 S 走到 T 的最短的路徑

Dijkstra

肯定先從複雜度最好的開始

首先考慮邊權都是正的的情況

定義 dis[i] 表示 S 走到 i 的最短路

注意到 

如果目前的 dis[a] + e_ab < dis[b]

那麼可以把 dis[b] 改成 dis[a] + e_ab

這東西又叫做鬆弛 

首先有 dis[S] = 0

再來,考慮 S 出去的邊的長度最小值,假設 e = (S->a with cost c)

dis[a] 就肯定 = c

為什麼?

Dijkstra

 

因為存在一條由 S 走到 a 且長度 = dis[a] 的路徑

另外 S 走出去的所有路的長度都 >= dis[a]

所以不可能有其他路比 dis[a] 好

以此類推,每次取出還沒有距離的點的距離最小值出來

那我們就確定了這個點的距離

實際要怎麼做呢

操作有 加一個距離進去,取出距離最小的點

=> heap

 

複雜度 O(mlogn)

Dijkstra


struct edge{
    int t,cost;
};
int main(){
    vector<vector<edge> > e;
    //input
    vector<int> dis(n,inf);
    priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > > pq;
    //[dis,index]
    pq.push({0,s});
    dis[s]=0;
    while(pq.size()){
        auto h=pq.top();
        pq.pop();
        if(dis[h.sc]!=h.fs){
            continue;
        }
        for(auto y:e[h]){
            if(dis[y.to]>h.fs+y.cost){
                dis[y.to]=h.fs+y.cost;
                pq.push({dis[y.to],y.to});
            }
        }
    }
    cout << dis[t] << '\n';
    return 0;
}

Dijkstra

Bellman-Ford

當有負權的時候呢

我們就不能每次取最小值出來更新了

因為你有負的長度就可以讓 dis[a] + e_ab < dis[a] => dis[b] 可能 <= dis[a]

所以我們要改一下作法

Bellman-Ford

作法:每次跑過所有可能的邊,並且能更新就更新

可以證明只要跑 n-1 次就一定會把所有 dis[] 值算對

我沒有要在這裡證因為好像也沒有很重要(?

但是要注意,有負環的時候是可以定義不出來最短路的

而如果你跑了 n 次之後還是可以繼續鬆弛那麼就代表有負環

這也同時帶給我們一個找負環的演算法

複雜度 O(nm)


struct edge{
    int s,t,cost;
};
int main(){
    vector<edge> e;
    //input
    vector<int> dis(n,inf);
    dis[s]=0;
    while(1){
        auto dis2=dis;
        for(auto h:e){
            if(dis[h.t]>dis[h.s]+h.cost){
                dis[h.t]=dis[h.s]+h.cost;
            }
        }
        if(dis==dis2){
            break;
        }
    }
    cout << dis[t] << '\n';
    return 0;
}

Bellman-Ford

Floyd-Warshall 

全點對最短路徑

當你需要同時得到所有點對的距離的時候使用

現在的 dis 陣列改成二維,dp[i][j] 表示 i 走到 j 的最短路徑

演算法本身是枚舉 k,用上面的方法更新所有用到 k 的路徑的值

Floyd-Warshall 

Floyd-Warshall 

vector<vector<int> > dis(n,vector<int>(n));
//input
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][k]+dis[k][j]);
        }
    }
}

最小生成樹

對於連通圖,存在一個子圖涵蓋所有頂點剛好是一棵樹,稱為生成樹

Kruskal

按照邊權從小到大加邊,如果加進去沒有環的話就加,直到所有邊都被考慮過一次

可以用一些 Greedy 驗證他是對的

如何判有沒有環?DSU

複雜度 O(mlogn)

Prim

固定一個頂點 0,仿造 dijkstra 的方式,每次考慮連出去的邊中的最小值

有 O(mlogn) 跟 O(n^2) 的版本,記得後面那種方式,2020 校內賽考過

Made with Slides.com