學習算法的最好方式,我認爲是應從思考一些有趣的題目開始。學習一個算法之前要知道這個算法的價值是什麼。
題目大意:詢問你在各個村莊之間修公路,最少的花費是多少。
我們可以把“村莊"抽象成一個一個點,把”修公路的費用"抽象成連接兩點的邊值(權重)。
我們要求解決的是求出 把這些點(村莊)都連起來(修公路)時的所構成的樹的權重總和最少(最少費用)是多少,即最小生成樹的權重和。
如圖:
注:生成樹不含環路,且在有相同權重得情況下最小生成樹可能不止一顆,但權重和一定相同。
則如何構建最小生成樹成爲重中之重!
下面介紹兩種算法:Prim算法 和 Kruskal算法。
算法原理先介紹,證明會放在最後,因爲這兩種算法簡單且看上去就像是那麼回事~
Prim算法:
Prim算法的思想是:取圖中任意一點作爲起點放入樹中,並向鄰近樹的點不斷延伸,每次延伸的點要求滿足到樹的距離最短。
我們採用dis[]記錄點離樹的距離,並採用val[]標記在樹中的點,兩數組實時更新。
如圖:
1:
2:
3:
4:
5:
這裏省略一步連接點四的圖,所以佈置共6步,對應的圖中6個點。
所以你知道所構建的最小生成樹的權重和是多少嗎?沒錯就是把每幅圖中dis[]中紅色數字加起來即可,即對應上面的圖MinDis=2+4+5+3+7=21。其實dis[]不用在每步更新在樹中的點,而是保留下來,這樣最後遍歷加一遍dis[]也可以得到答案(前提是所以點都可以連成一棵樹)。
很可惜C++中沒有帶有索引功能的優先隊列,減少查找和更新權重邊的時間,所以在一般的算法題上,可用數組代替,只是時間複雜度會達到N^2。
下面是用Prim算法寫上題的代碼,此代碼可能沒有別人的簡練,但思路清晰,便於理解與修改。
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
#define INF 0x3f3f3f3f
const int _max=105;
int city[_max][_max]; //city記錄兩城市之間的距離
int val[_max],dis[_max]; //val:標記在樹中的點 dis:記錄點離樹的距離
void Prim( int x , int City )
{
int ans = 0;
for( int i=1; i<=City; i++ ) //初始化起點
dis[i] = city[x][i] ;
val[x] = 1 ;
int MinDis,atPoint;
for( int i=2; i<=City; i++ ) //循環次數爲City-1次
{
//找到Dis[]最小權重值
MinDis = INF ;
for( int j=1; j<=City; j++ ) //尋找下一個城市
{
if( !val[j] && MinDis > dis[j] ) //沒在樹中且距離短
{
MinDis = dis[j];
atPoint = j;
}
}
if( MinDis == INF ) //如果在所有點進入前無路可連,則不連通
{
printf("?\n");
return ;
}
val[atPoint] = 1; //標記
ans+=MinDis;
//更新DIS[]
for( int j=1; j<=City; j++ )
dis[j] = min( dis[j], city[atPoint][j] );
}
printf("%d\n",ans);
}
void init( )
{
for( int i=1; i<=_max; i++ )
{
for( int j=1; j<=_max; j++ )
{
if( i==j ) city[i][j] = 0;
else city[i][j] = INF ;
}
}
//memset(dis,INF,sizeof(dis)); //dis[]也可不初始化
memset(val,0,sizeof(val));
}
int main()
{
int Road,City;
int x,y,w;
while( ~scanf("%d%d",&Road,&City) )
{
if( !Road ) break;
init();
for( int i=1; i<=Road; i++ )
{
scanf("%d%d%d",&x,&y,&w);
city[x][y] = city[y][x] = min( city[x][y], w ); //選取兩點最短邊
}
Prim(1,City); //選取點一做起點,在City裏生成最小生成樹
}
return 0;
}
與之對應的Kruskal算法,兩者不同將在文後比較。
Kruskal算法:
Kruskal算法的思想是:將邊按權重從小到大排序,依次選取邊上的兩點進行連接,只要滿足邊上兩點不在同一樹中就行。
這裏我們需要邊(Edge)結構,包含數據a,b兩端點,以及距離dis;和判斷兩點是非在一顆樹裏的並查集數組UF[]。
//不知道並查集的同學不用着急,這裏你只要知道它的用處是連接兩點,並可判斷兩點是否在一顆樹。
//以後我會附上並查集詳解的鏈接,因爲很容易,當然是要用自己的咯,哈哈。
如圖:
1:
2:
3:
4:
5:
6:
因爲所有點已經連接完成,所有這裏省略剩下步驟。
正如圖所示,Kruskal算法運算步驟取決於邊,算法複雜度(排序+並查集連接+查找+訪問Edge[])在ElogE,所需空間與E成正比(E代表邊的數量),因爲少點可以連多邊,所有在開Edge[]時,數組的時候往往需要開的很大。
下面是上題的代碼:
#include <cstdio>
#include <algorithm>
using namespace std;
const int _max=10005;
int UF[_max/100+5],sz[_max/100+5];
struct Edge
{
int a,b,dis;
}E[_max],e;
bool E_cmp( const Edge e1, const Edge e2 ) //從大到小排序Edge
{
return e1.dis < e2.dis;
}
void init()
{
for( int i=1; i<=_max/100+5; i++ ) //初始化
{
UF[i]=i;
sz[i]=1;
}
}
int Find( int p ) //並查集查找樹根(父節點)
{
while( p!=UF[p] ) p=UF[p];
return p;
}
void Union(int p, int q ) //連接兩點
{
int i = Find(p);
int j = Find(q);
//if( i==j ) return; // 這步在此可省略
if( sz[i]<sz[j] ) { UF[i]=j; sz[i]+=sz[j]; } //這裏是優化樹結構,可以節省查找時間,並可計算出子節點個數
else { UF[j]=i; sz[j]+=sz[i]; }
}
bool hadConnected( int p,int q ) //判斷兩點是否在同一樹中
{
if( Find(p) == Find(q) )
return true;
else return false;
}
void Kruskal( int Road ,int City )
{
int cnt = City ; // 用cnt(連通分量)判斷圖是否完全連通
int ans = 0 ;
for( int i=1; i<=Road; i++ )
{
if( hadConnected(E[i].a,E[i].b) ) continue; //如果在同一樹中則不需連接
else
{
cnt--; //沒連通兩個不同樹的節點,cnt-1;
Union(E[i].a,E[i].b);
ans+=E[i].dis;
}
}
if( cnt>1 ) printf("?\n"); //連通分量>1說明圖沒完全連通
else printf("%d\n",ans);
}
int main()
{
int Road,City;
while( ~scanf("%d%d",&Road,&City) )
{
if( !Road ) break;
init();
for( int i=1; i<=Road; i++ )
scanf("%d%d%d",&E[i].a,&E[i].b,&E[i].dis);
sort(E+1,E+Road+1,E_cmp);
Kruskal(Road,City);
}
return 0;
}
簡單總結:
Prim算法和Kruskal算法都是一種貪心算法,都是每次取最小邊進行連接。所不同的是Prim算法是從一點出發,找相鄰的點中離樹最近的加入樹中,循環只到所有點都加入了樹中爲止;而Kruskal算法是完全在找最小的邊,唯一要判斷的是,兩點是不是在同一樹中,如果是則跳過,循環只到只有一顆樹爲止。(假設圖是連通的).
在沒有用優先隊列優化Prim的前提下,Prim算法較慢但空間比Kruskal算法小,在題目中如何取捨要具體題目。
說明:
1:最小生成樹不止能解決距離,費用等問題,對於任意一個能抽象成加權無向圖的題目或是問題,求最小權重,最小生成樹算法:Prim算法和Kruskal算法都能解決。
2:如果所給出的圖並不連通,那麼會算出多顆由最小生成樹組成的森林。
3:最好保持所有邊的權重都不相同,這樣才能保證最小生成樹有且僅有一顆。
應用領域 | 頂點 | 邊 |
---|---|---|
電路 | 元器件 | 導線 |
航空 | 機場 | 航線 |
電力分配 | 電站 | 輸電線 |
( 摘錄自《算法》,感謝 )
證明: //可跳過
定義1:圖的一種切分是將圖的所有頂點分爲兩個非空不重疊的兩個集合。橫切邊是一條連接兩個不同集合的頂點的邊。
切分定理:
命題1:在一份加權有向圖中,給定任意的切分,它的橫切邊中的權重最小者必然屬於最小生成樹。
證明:
令e爲權重最小的橫切邊,T爲圖的最小生成樹。我們採用反證法:假設T不包含e。那麼如果將e加入T,得到的圖必然是一個含有一條經過e的環,且這個環至少含有另一條橫切邊—設爲f,f的權重必然大於e(因爲e是最小的且所有邊權重都不同)。那麼我們刪掉f保留e可以得到一條權重更小的最小生成樹。這與我們的假設T矛盾。
最小生成樹貪心算法
命題2:下面這種方法會將含有V個頂點的任意加權連通圖中屬於最小生成樹的邊標記爲黑色:初始狀態下都爲灰色,在到一種切分,它產生的橫切邊都不玩黑色。將它的最小橫切邊標記爲黑色。反覆,直到標記了V-1條黑色邊爲止。這些邊所組成的就是一顆最小生成樹。
證明:
爲了簡單,我們假設所有邊的權重都不相等,儘管沒有這個命題同樣成立(-_-||),根據切分定理,如果黑色邊的數量小於V-1,必然還存在不會產生黑色橫切邊的邊(假設圖是連通的)。只要找到了V-1條黑色的邊,就找到了最小生成樹。
命題3:Prim算法能夠得到任意加權連通圖的最小生成樹
證明:
由命題2可知,這顆不斷生長的樹,定義了一個切分且不存在黑色的橫切邊。該算法會選擇權重最小的橫切邊並根據貪心算法不斷將他們標記爲黑色。
命題4:Kruskal算法能夠計算任意加權連通圖的最小生成樹。
證明:
用命題2可知,如果下一條將被加入的最小生成樹中的邊不會和已有的黑色邊構成環,那麼它跨越了由所有樹頂點相鄰的頂點組成的集合,以及它們的補集所構成的一個切分。因爲加入的這條邊不會構成環,它是目前已知唯一一條橫切邊且是按照權重順序選擇的邊,因此,該算法能夠連續選擇權重最小的橫切邊,和貪心算法一致。
最後:
本文到此結束,若有不足歡迎指出。後續還將不斷更正本文錯誤,添加一些遺漏。