1. 程式人生 > 其它 >【演算法筆記】最小生成樹

【演算法筆記】最小生成樹

前言

這個東西是老早就該補的坑了……

所以現在有時間了。

回來寫一寫。


最小生成樹

定義:

簡單來說,最小生成樹是一張帶權無向圖裡面由全部 \(n\) 個頂點,和邊集中的 \(n-1\) 條邊構成的原圖的一顆生成樹,且所有邊權之和是所有生成樹中最小的(可能有多個情況)。

而且它一定包含原圖中最小的邊。

推論:

設一張無向圖 \(G=(V,E)\) ,從 \(E\) 中選出 \(k<|V|-1\)條邊構成 \(G\) 的一個生成森林,然後再從剩餘的 \(|E|-k\) 條邊中選出 \(|V|-1-k\) 條邊加入森林中,讓它成為 \(G\) 的生成樹,並且選出的\(\sum w\)最小。

那麼,這個生成樹一定包含 \(|E|-k\) 條邊裡面連線生成森林的兩個不連通節點的權值最小的邊。

很拗口對吧?

不過請好好理解,因為這個推論就是接下來的 \(\text{Kruskal}\) 的基礎了。

Kruskal

這是一種求最小生成樹的高效演算法。

它維護圖的最小生成森林。

開始的時候生成森林是空的,每一個節點就是一顆獨立的樹。

然後我們用上述推論維護森林就好了。

首先先搞一個並查集出來。對於每一個點初始化。

接下來按照邊權升序排個序。

然後掃一遍每個邊。

如果這一條邊所連得兩條邊\((u,v)\)已經聯通了。那麼繼續。

but,如果沒有聯通,根據這一條:

這個生成樹一定包含 \(|E|-k\)

條邊裡面連線生成森林的兩個不連通節點的權值最小的邊。

所以滿足了條件,那麼我們就把這一條邊加入到最小生成樹裡。

順便合併一下 \((u,v)\) (相當於讓它聯通)

程式碼:

#include<bits/stdc++.h>
using namespace std;

const int si=2e6+2;

int n,m;
int ans;
int pa[si];

struct edge{
    int x,y,val;
    bool operator < (const edge &u)const{
        return val<u.val;
    }
}ed[si];

int root(int x){
    if(pa[x]!=x) pa[x]=root(pa[x]);
    return pa[x];
}

bool Union(int x,int y){
    int rx=root(x),ry=root(y);
    if(rx==ry){
        return 0;
    }
    pa[rx]=ry;
    return 1;
}

int main(){
    cin>>n>>m;
    for(register int i=1;i<=m;++i){
        cin>>ed[i].x>>ed[i].y>>ed[i].val;
    }
    sort(ed+1,ed+m+1);
    for(register int i=1;i<=n;++i){
        pa[i]=i;
    }
    for(register int i=1;i<=m;++i){
        if(Union(ed[i].x,ed[i].y)) ans+=ed[i].val;
    }
    cout<<ans<<endl;
    return 0;
}


Prim

這個演算法實際上不是很常用。

因為在沒有堆優化的情況下,\(\text{Prim}\) 的複雜度是 \(\text{O} (n^2)\)
的。

而且寫了堆優化以後會比 \(\text{Kruskal}\) 麻煩的多(某次十一集訓的時候xzq就是想寫 \(\text{Prim}\) ,結果我用 \(\text{Kruskal}\) A了幾道題了他還沒調好/xyx)

那麼大概說一下思路。

現在已經知道 \(\text{Kruskal}\) 是維護一整個生成森林。

其實 \(\text{Prim}\) 就和它稍微有點不同而已,它只是維護了最小生成樹的一部分。而且它最開始確定 \(1\) 號節點屬於最小生成樹。

它每一次找到一條權值最小的,且滿足它連線的其中一個點 \(u\) 已經被選入最小生成樹裡,另一個點 \(v\) 則未被選中的邊。

具體實現是這樣子的:

維護一個數組 \(dis[\ ]\) ,如果 \(u\) 沒有被選入,那麼 \(dis[u]\) 就等於 \(u\) 和已經被選中點之間的連邊中權值最小的邊的權值。

反之 \(dis[u]\) 就等於 \(u\) 被選中的時候選出來那條權值最小邊的權值。

然後怎麼判是否選中呢?

維護一個\(vis[]\) 即可。然後從 \(vis[]=\text{false}\)的節點中 (也就是未被選中的節點) 選出一個 \(dis[]\) 最小的標記它

然後掃描和這個被選點的所有直接連它的邊,更新另外一個端點的 \(dis\)

最後生成樹的權值和就是 \(\sum^{n}_{i=2} dis[i]\)

程式碼:



#include<bits/stdc++.h>
using namespace std;

const int si=5e3+10;
int n,m;
int a[si][si];
int dis[si];
bool vis[si];

int main(){
	cin>>n>>m;
	memset(a,0x3f,sizeof a);
	for(register int i=1;i<=n;++i){
		a[i][i]=0;
	}
	for(register int i=1,u,v,w;i<=m;++i){
		cin>>u>>v>>w;
		a[v][u]=a[u][v]=min(a[u][v],w);
	}
	//Prim
	memset(dis,0x3f,sizeof dis);
	memset(vis,false,sizeof vis);
	dis[1]=0;
	for(register int i=1,u;i<n;++i){
		u=0;
		for(register int j=1;j<=n;++j){
			if(!vis[j]&&(u==0||dis[j]<dis[u])) u=j;
		}
		vis[u]=1;
		for(register int v=1;v<=n;++v){
			if(!vis[v]) dis[v]=min(dis[v],a[u][v]);
		}
	}
	//answer
	int ans=0;
	for(register int i=2;i<=n;++i){
		ans+=dis[i];
	}
	cout<<ans<<endl;
	return 0;
}


Boruvka

該演算法並不常用,以後有時間會填坑。


習題:


關於Kruskal 演算法過程的一個shabi理解

昨天(21/5/17) 晚上yl一直在糾結一道題(CF891C)。

然後在一番討論之後,我有了一個對於 $\text{Kruskal} $ 的新理解

就是對於原圖,我們將它劃分為 \(n\) 個相互連通的子集(有多種方式)。

且每一個子集原圖的一個生成森林。

然後對於任意兩個生成森林之間,我們找出所有連通他們的邊。

取出有最小權值的邊。

然後對於取出這 \(C^n_2\) 條邊,我們從小到大取就行(當然這裡是動態過程所以不一定一直是原來最初的劃分情況)(直到構成生成樹也就是選完所有情況)(演算法裡的那個for)。

(這裡利用並查集判斷這個邊是不是可以選出來的邊(也就是連通兩個生成森林的邊),從小到大的話就根據最開始的排序來就行)(演算法裡的for當中的if和最開始的sort

至於為什麼用並查集判是否連通兩個森林選出來的那個邊一定是連線那兩個生成森林的最小邊呢?

因為排了序啊,所以我們取到的第一個連通這兩個森林的邊一定是連通他們的邊當中最小的。

(有點混亂但是解釋了一點神奇的東西)

對於最小生成樹性質的證明:

By : ciwei

(這位dalao的證明真的很有用!)

原文:

https://zhuanlan.zhihu.com/p/340438116 https://zhuanlan.zhihu.com/p/340411111

引理部分:


Prim 的證明

By : ciwei

原文:https://zhuanlan.zhihu.com/p/340464163


kruskal 的證明

By:ciwei

原文:https://zhuanlan.zhihu.com/p/340628568



其他的一些東西:

ciwei:最小生成樹相關定理和演算法正確性證明