一、 貪心策略的定義
【定義1】 貪心策略是指從問題的初始狀態出發,通過若干次的貪心選擇而得出最優值(或較優解)的一種解題方法。
其實,從"貪心策略"一詞我們便可以看出,貪心策略總是做出在當前看來是最優的選擇,也就是說貪心策略並不是從整體上加以考慮,它所做出的選擇只是在某種意義上的局部最優解,而許多問題自身的特性決定了該題運用貪心策略可以得到最優解或較優解。
二、貪心算法的特點
通過上文的介紹,可能有人會問:貪心算法有什麼樣的特點呢?我認爲,適用於貪心算法解決的問題應具有以下2個特點:
1、貪心選擇性質:
所謂貪心選擇性質是指應用同一規則f, 將原問題變爲一個相似的、但規模更小的子問題、而後的每一步都是當前看似最佳的選擇。這種選擇依賴於已做出的選擇,但不依賴於未做出的選擇。從全局來看, 運用貪心策略解決的問題在程序的運行過程中無回溯過程。關於貪心選擇性質,讀者可在後文給出的貪心策略狀態空間圖中得到深刻地體會。
2、局部最優解:
我們通過特點2向大家介紹了貪心策略的數學描述。由於運用貪心策略解題在每一次都取得了最優解,但能夠保證局部最優解得不一定是貪心算法。如大家所熟悉得動態規劃算法就可以滿足局部最優解,在廣度優先搜索(BFS)中的解題過程亦可以滿足局部最優解。
在遇到具體問題時,往往分不清哪些題該用貪心策略求解,哪些題該用動態規劃法求解。在此,我們對兩種解題策略進行比較。
三、 貪心策略的理論基礎--矩陣胚
正如前文所說的那樣,貪心策略是最接近人類認知思維的一種解題策略。但是,越是顯而易見的方法往往越難以證明。下面我們就來介紹貪心策略的理論--矩陣胚。
"矩陣胚"理論是一種能夠確定貪心策略何時能夠產生最優解的理論,雖然這套理論還很不完善,但在求解最優化問題時發揮着越來越重要的作用。
【定義3】 矩陣胚是一個序對M=[S,I] ,其中S是一個有序非空集合,I是S的一個非空子集,成爲S的一個獨立子集。
如果M是一個N×M的矩陣的話,即:
若M是無向圖G的矩陣胚的話,則S爲圖的邊集,I是所有構成森林的一組邊的子集。
如果對S的每一個元素X(X∈S)賦予一個正的權值W(X),則稱矩陣胚M=(S,I)爲一個加權矩陣胚。
適宜於用貪心策略來求解的許多問題都可以歸結爲在加權矩陣胚中找一個具有最大權值的獨立子集的問題,即給定一個加權矩陣胚,M=(S,I),若能找出一個獨立且具有最大可能權值的子集A,且A不被M中比它更大的獨立子集所包含,那麼A爲最優子集,也是一個最大的獨立子集。
矩陣胚理論對於我們判斷貪心策略是否適用於某一複雜問題是十分有效的。
四、 幾種典型的貪心算法
貪心策略在圖論中有着極其重要的應用。諸如Kruskal、 Prim、 Dijkstra等體現“貪心”思想的圖形算法更是廣泛地應用於樹與圖的處理。下面就分別來介紹Kruskal算法、Prim算法和Dijkstra算法。
.
Ⅰ、庫魯斯卡爾(Kruskal)算法
【定義4】 設圖G=(V,E)是一簡單連通圖,|V| =n,|E|=m,每條邊ei都給以權W
kruskal算法的基本思想是:首先將賦權圖G的邊按權的升序排列,不失一般性爲:e
其流程如下:
(1) 對屬於E的邊進行排序得e
(2) 初始化操作 w←0,T←ф ,k←0,t←0;
(3) 若t=n-1,則轉(6),否則轉(4)
(4) 若T∪{e
【k←k+1,轉(4)】
(5) T←T∪{ e
(6) 輸出T,w,停止。
下面我們對這個算法的合理性進行證明。
設在最短樹中,有邊〈v
下面給出C語言描述的kruskal算法:
#define MAXE <最多的邊數>
typedef struct {
int u;// 邊的起始頂點
int v;// 邊的終止頂點
int w;// 邊的權值
} Edge;
void kruskal(Edge E[],int n,int e)//邊的權值從小到大排列
{
int i,j,m1,m2,sn1,sn2,k;
int vset[MAXV];
for(i=0;i<n;i++)
vset[i]=i;
k=1;j=0;
while(k<n)
{
m1=E[j].u;
m2=E[j].v;
sn1=vset[m1];
sn2=vset[m2];
if(sn1!=sn2)//兩頂點屬於不同的集合,是最小生成樹的一條邊
{
輸出這條邊;
k++;
for(i=0;i<n;i++)
if(vset[i]==sn2)
vset[i]=sn1;
}
j++;
}
}
kruskal算法對邊的稀疏圖比較合適,時間複雜度爲o(elog2e),e是邊數,與頂點無關.
Ⅱ、普林(Prim)算法:
Kruskal算法採取在不構成迴路的條件下,優先選擇長度最短的邊作爲最短樹的邊,而Prim則是採取了另一種貪心策略。
已知圖G=(V,E),V={v
Prim算法的基本思想是:從某一頂點(設爲v
流程如下:
(1) 初始化操作:T←ф,q(1)←-1,i從2到n作
【p(i)←1,q(i)←di1】,k←1
(2) 若k≥n,則作【輸出T,結束】
否則作【min←∞,j從2到n作
【若0<q(i)<min則作
【min←q(i) h←j】
】
】
(3) T←T∪{h,p(h)},q(h)←-1
(4) j從2到n作
【若d
(5) k←k+1,轉(2)
算法中數組p(i)是用以記錄和v
下面給出C語言描述的prim算法:
void prim(int cost[][MAXV],int n,int v)//v是起始頂點
{
int lowcost[MAXV],min;
int closest[MAXV],i,j,k;
/*closest[i]表示U中的一個頂點,該頂點和V-U中的一個頂點構成的邊(i,closese[i])具有最小的權 */
//lowcost[i]表示邊(i,closet[i])的權值
for(i=0;i<n;i++)
{
lowcost[i]=cost[v][i];
closest[i]=v;
}
for(i=1;i<n;i++)
{
min=INF;
for(j=0;j<n;j++)//在(V-U)中找出離U最近的頂點K.
if(lowcost[j]!=0&&lowcost[j]<min)
{
min=lowcost[j];
k=j;
}
輸出邊: closet[k]-->k;
lowcost[k]=0;//標記k已經加入U;
for(j=0;j<n;j++)//修改數組lowcost和closest
if(cost[k][j]!=0&&cost[k][j]<lowcost[j])
{
lowcost[j]=cost[k][j];
closest[j]=k;
}
}
}
Prim算法的時間複雜度爲O(n^2),與邊無關,適用於邊稠密的圖
Ⅲ、戴克斯德拉(Dijkstra)算法:
給定一個(無向)圖G,及G中的兩點s、t,確定一條從s到t的最短路徑。
a[i][j]記邊(i,j)的權,數組dist[u]記從源v到頂點u所對應的最短特殊路徑長度
算法描述如下:
S1:初始化,S, T,對每個yS,{dist[y]=a[v][y],prev[y]=nil}
S2:若S=V,則輸出dist,prev,結束
S3:uV/S中有最小dist值的點,SS{u}
S4:對u的每個相鄰頂點x,調整dist[x]:即
若dist[x]>dist[u]+a[u][x],則{dist[x]=dist[u]+a[u][x],prev[x]=u},轉S2
對於具有n個頂點和e條邊的帶權有向圖,如果用帶權鄰接矩陣表示這個圖,那麼Dijkstra算法的主循環體需要O(n)時間。這個循環需要執行n-1次,所以完成循環需要O(n2)時間。算法的其餘部分所需要時間不超過O(n2)。
五、貪心策略在P類問題求解中的應用
在現實世界中,我們可以將問題分爲兩大類。其中一類被稱爲P類問題,它存在有效算法,可求得最優解;另一類問題被稱爲NPC類問題,這類問題到目前爲止人們尚未找到求得最優解的有效算法,這就需要每一位程序設計人員根據自己對題目的理解設計出求較優解的方法。下面我們着重分析貪心策略在求解P類問題中的應用。
在現實生活中,P類問題是十分有限的,而NPC類問題則是普遍的、廣泛的。
[例1]刪數問題
試題描述 鍵盤輸入一個高精度的正整數N(不超過240位),去掉其中任意S個數字後剩下的數字按左右次序組成一個新的正整數。對給定的N和S,尋找一種刪數規則使得剩下得數字組成的新數最小。
試題背景 此題出自NOI94
試題分析 這是一道運用貪心策略求解的典型問題。此題所需處理的數據從表面上看是一個整數。其實,大家通過對此題得深入分析便知:本題所給出的高精度正整數在具體做題時將它看作由若干個數字所組成的一串數,這是求解本題的一個重要突破。這樣便建立起了貪心策略的數學描述。
每次刪除一個數字,選擇一個使剩下的數最小的數字作爲刪除對象,之所以選擇這樣”貪心”的操作,是因爲刪S個數字的全局最優解包含了刪一個數字的子問題的最優解.
當S=1時,在N中刪除哪一個數字能達到最小的目的?從左到右每相鄰的兩個數字比較:若出現左邊大於右邊,則刪除左邊的大數字.若不出現降序排列,即所有數字全部升序,則刪除最右邊的大數字.
當S>1,按上述操作一個一個刪除,刪除一個達到最小後,再從頭即從串首開始,刪除第2個,依次分解爲S次完成.
若刪除不到S個後已無左邊大於右邊的減序,則停止刪除操作,打印剩下串的左邊L-S個數字即可(相當於刪除了若干個最右邊的大數字,這裏L爲原數字N的位數).
附源程序:
#include<iostream>
#include<string>
using namespace std;
int main()
{
string n;
int s,i,x,l,m;
while(cin>>n>>s)
{
i=-1,m=0,x=0;
l=n.length();
while(x<s&&m==0)
{
i++;
if(n[i]>n[i+1])//出現遞減,刪除遞減的首數字
{
n=n.erase(i,1);
x++;// x統計刪除數字的個數
i=-1;//從頭開始查遞減區間
}
if(i==l-x-2&&x<s)
m=1;//已經無遞減區間,m=1脫離循環
}
cout<<n.substr(0,l-s+x);//只打印剩下的左邊l-(s-x)個數字
}
}
[例2]數列極差問題
試題描述 在黑板上寫了N個正整數作成的一個數列,進行如下操作:每一次擦去其中的兩個數a和b,然後在數列中加入一個數a×b+1,如此下去直至黑板上剩下一個數,在所有按這種操作方式最後得到的數中,最大的max,最小的爲min,則該數列的極差定義爲M=max-min。
編程任務:對於給定的數列,編程計算出極差M。
試題分析 當看到此題時,我們會發現求max與求min是兩個相似的過程。若我們把求解max與min的過程分開,着重探討求max的問題。
下面我們以求max爲例來討論此題用貪心策略求解的合理性。
討論:假設經(N-3)次變換後得到3個數:a,b,max'
(max'≥a≥b),其中max'是(N-2)個數經(N-3)次f變換後所得的最大值,此時有兩種求值方式,設其所求值分別爲Z
所以若使第k(1≤k≤N-1)次變換後所得值最大,必使(k-1)次變換後所得值最大(符合貪心策略的特點2),在進行第k次變換時,只需取在進行(k-1)次變換後所得數列中的兩最小數p,q施加f操作:p←p×q+1,q←∞即可(符合貪心策略特點1),因此此題可用貪心策略求解。討論完畢。
在求min時,我們只需在每次變換的數列中找到兩個最大數p,q施加作用f:p←p×q+1,q←-∞即可.原理同上
[例3]最優乘車問題
試題描述 H城是一個旅遊勝地,每年都有成千上萬的人前來觀光.爲方便遊客,巴士公司在各個旅遊景點及賓館、飯店等地都設置了巴士站,並開通了一些單向巴士線路。每條單向巴士線路從某個巴士站出發,依次途徑若干個巴士站,最終到達終點巴士站。
阿昌最近到H城旅遊,住在CUP飯店。他很想去S公園遊玩。聽人說,從CUP飯店到S公園可能有也可能沒有直通巴士。如果沒有,就要換乘不同線路的單向巴士,還有可能無法乘巴士到達。
現在用整數1,2,...,n給H城的所有巴士站編號,約定CUP飯店的巴士站編號爲1,S公園巴士站的編號爲N。
寫一個程序,幫助阿昌尋找一個最優乘車方案,使他在從CUP飯店到S公園的過程中換車的次數最少。
試題背景 出自NOI97
試題分析 此題看上去很像一道搜索問題。在搜索問題中,我們所求的使經過車站數最少的方案,而本題所求解的使換車次數最少的方案。這兩種情況的解是否完全相同呢?我們來看一個實例:
如圖5所 示:共有5個車站(分別爲a、b、c、d、e), 共有3條巴士線(線路A:a→d;線路B:a→b→c→e;線路C:d→e)。此時要使換車次數最少, 應乘坐線路B的巴士,路線爲:a→b→c→e,換車次數爲0;要使途經車站數最少,乘坐線路應爲a→d→e,換車次數爲1。所以說使換車次數最少的路線和 使途經車站數最少的方案不一定相同。這使不能用搜索發求解此問題的原因之一。
原因之二,來自對數學模型的分析。我們根據題中所給數據來建立一個圖後會發現該圖中存在大量的環,因而不適合用搜索法求解。
其實,此題完全可以套用上文所提到的Dijkstra算法來求解。
輸入數據:輸入文件INPUT.TXT。文件的第1行是一個數字M(1≤M≤100)表示開通了M條單向巴士線路,第2行是一個數字N(1<N≤500),表示共有N個車站。從第3行到第M+2行依次給出了第一條到第M條巴士線路的信息。其中第i+2行給出的是第i條巴士線路的信息,從左至右依次按行行駛順序給出了該線路上的所有站點,相鄰兩個站號之間用一個空格隔開。
輸出數據:輸出文件是OUTPUT.TXT。文件只有一行,爲最少換車次數(在0,1,…,M-1中取值),0表示不需換車即可達到。如果無法乘車達到S公園,則輸出"NO"。
源程序:
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
int m,n; //m爲開通的單向巴士線路數,n爲車站總數
int result[502]; //到某車站的最少換車數
int num[502][52]; //從某車站可達的所有車站序列
int sum[502]; //從某車站可達的車站總數
bool check[502]; //某車站是否已擴展完
const int INF=600;
int main()
{
int i,j,c,a,b,d,min;
int data[52];
char ch;
while(cin>>m>>n)
{
memset(sum,0,sizeof(sum));
memset(num,0,sizeof(num));
for(i=0;i<m;i++)
{
j=0;
memset(data,0,sizeof(data));
while(1)
{
j++;
cin>>data[j];
ch=getchar();
if(ch!=' ')
break;
}
for(c=1;c<=j-1;c++)
for(d=c+1;d<=j;d++)
{
sum[data[c]]++;
num[data[c]][sum[data[c]]]=data[d];
}
}
memset(result,-1,sizeof(result));
memset(check,0,sizeof(check));
result[1]=0;
for(c=1;c<=sum[1];c++)
result[num[1][c]]=0;
b=num[1][1];
do
{
check[b]=1;//某車站已擴展完
for(c=1;c<=sum[b];c++)
if(result[num[b][c]]==-1)
result[num[b][c]]=result[b]+1;
min=501; result[min]=INF;
/*貪心選擇目前經過最小換乘數就可以到達的某車站 */
for(c=1;c<=n;c++)
if(result[c]!=-1&&result[c]<result[min]&&check[c]==0)
min=c;
b=min;
}while((result[n]==-1)&&(min!=501));//min=501表示目前所有車站已擴展完
if(result[n]==-1)//站點n無法到達
cout<<"NO"<<endl;
else
cout<<result[n]<<endl;
}
return 0;
}
[例4]最佳瀏覽路線問題
[試題描述] 某旅遊區的街道成網格狀(見圖),其中東西向的街道都是旅遊街,南北向的街道都是林蔭道。由於遊客衆多,旅遊街被規定爲單行道。遊客在旅遊街上只能從西向東走,在林蔭道上既可以由南向北走,也可以從北向南走。
阿隆想到這個旅遊區遊玩。他的好友阿福給了他一些建議,用分值表示所有旅遊街相鄰兩個路口之間的道路值得瀏覽得程度,分值從-100到100的整數,所有林蔭道不打分。所有分值不可能全是負值。
例如下圖是被打過分的某旅遊區的街道圖:
阿隆可以從任一路口開始瀏覽,在任一路口結束瀏覽。請你寫一個程序,幫助阿隆尋找一條最佳的瀏覽路線,使得這條路線的所有分值總和最大。
試題背景 這道題同樣出自NOI97,'97國際大學生程序設計競賽的第二題(吉爾的又一個騎車問題)與本題是屬於本質相同的題目。
試題分析 由 於林蔭道不打分,也就是說,無論遊客在林蔭道中怎麼走,都不會影響得分。因題可知,若遊客需經過某一列的旅遊街,則他一定要經過這一列的M條旅遊街中分值 最大的一條,纔會使他所經路線的總分值最大。這是一種貪心策略。貪心策略的目的是降維,使題目所給出的一個矩陣便爲一個數列。下一步便是如何對這個數列進 行處理。在這一步,很多人用動態規劃法求解,這種算法的時間複雜度爲O(n
輸入數據:輸入文件是INPUT.TXT。文件的第一行是兩個整數M和N,之間用一個空格符隔開,M表示有多少條旅遊街(1≤M≤100),N表示有多少條林蔭道(1≤N≤20000)。接下里的M行依次給出了由北向南每條旅遊街的分值信息。每行有N-1個整數,依次表示了自西向東旅遊街每一小段的分值。同一行相鄰兩個數之間用一個空格隔開。
輸出文件:輸出文件是 OUTPUT.TXT。文件只有一行,是一個整數,表示你的程序找到的最佳瀏覽路線的總分值。
源程序:
#include<iostream>
#include<cstring>
using namespace std;
int m,n;//m爲旅遊街數,n爲林蔭道數
int data[20000];//data是由相鄰兩條林蔭道所分隔的旅遊街的最大分值
int MaxSum(int n, int *a)//求最大子段和函數
{
int sum=0;
int b=0;
for(int i=1;i<=n;i++)
{
b+=a[i];
if(b>sum) sum=b;
if(b<0) b=0;
}
return sum;
}
int main()
{
int i,j,c;
while(cin>>m>>n)
{
/*讀取每一段旅遊街的分值,並選擇讀取每一段旅遊街的分值,
並選擇到目前位置所在列的最大分值記入數組data*/
for(i=1;i<=n-1;i++)
cin>>data[i];
for(i=2;i<=m;i++)
for(j=1;j<=n-1;j++)
{
cin>>c;
if(c>data[j])
data[j]=c;
}
}
cout<<MaxSum(n-1,data)<<endl;
return 0;
}