到底什麼叫貪心策略(內含幾個經典貪心樣例和三大圖論演算法)
昨天和前天寫完了分治和dp,感覺收穫真的挺大的,複習絕不是簡單的重複記憶,而是將所學知識融會
貫通的過程,分析各種思想的異同,這些都是在平時學習和刷題的時候沒有認真考慮的問題
好了,扯遠了
今天分析一下到底什麼叫貪心策略
怎麼理解貪心:貪心在解決問題上是目光短淺的,僅僅根據當前的已知資訊就做出選擇,並且一旦做了選擇,就不再更改
比如01揹包問題,用貪心的話是不可解決的,因為貪心每次只顧眼前最優,即每次選擇價值最大的,而忽
略了另外一個變數,物品的重量,如果還考慮物品的重量的話,那就是dp了
貪心和dp的聯絡是非常緊密的,我們先來分析一下貪心和dp的不同之處:
dp是根據遷移過程的狀態去推導下一個過程的狀態,是有理論依據的,是講道理的,通過每次完美的檢驗
而得到最優解,關鍵是找最優子結構和重複子問題,書上一句原話:dp的子結構必須的獨立的,而且是重
疊的,雖然有點矛盾,但確實是這樣,扯遠了
而貪心每次都只顧眼前最優,目光短淺,這種方式是不講道理的,不想dp一樣,還根據前面的遷移狀態推
導後面的子問題,比如最經典的01揹包問題(真的是理解dp和貪心的經典例題啊)
根據貪心策略,每次放進去的都是目前最優的,即目前價值最大的,直到揹包裝不下,但是這樣放的話肯定
是不如人意的,因為沒有考慮到揹包容量的問題,為什麼呢?因為前面說過了,貪心策略只考慮當前最優
解,它才不會去考慮什麼揹包容量的問題呢,它只管裝價值最大的物品,這樣是得不到最優解的,必須再加
一個約束條件:揹包容量,那麼這個做法就變成了dp的做法了
說的再多不如看幾個例題,這樣才能更好的體會貪心思想
經典樣例1:最優裝載問題
這個問題很容易,貪心策略就是每次裝價值最大的物品即可,因為物品是不考慮重量和箱子容量的
這個問題我就是懶得貼程式碼了,實在是太容易了
這種貪心的策略一眼就能看出來,沒有什麼可以講的,但是下面這個問題的貪心策略一眼可能是看不出來的
經典樣例二:活動安排問題(屬於安排策略,競爭某一公共資源問題)
活動安排問題就是要在所給的活動集合中選出最大的相容活動子集和
貪心策略:使得剩餘的時間最大化,先安排最早結束的活動
為什麼是這樣呢?可能有朋友會覺得貪心策略應該是:先安排最早開始的活動,但是這樣是不行的,你要是
這麼貪心的話,如果一個活動是最先開始的,但是它的結束時間超級超級長,那你這樣貪心的話,豈不是隻
能安排它一個活動了嗎?
所以我們要使得剩餘的時間最大化,就是先安排最早結束的活動,因為你這個活動最早結束的話,你留給其
他活動的剩餘的安排活動時間就最多呀,何況你還安排完了自己,一舉兩得,何樂而不為呢?
具體做法:每個活動是一個結構體:包含開始時間,結束時間,是否被安排過三個屬性,按照結束時間升序
排序,每次都選擇可以相容的,結束時間最早的活動(只有想到了貪心策略,程式碼還是很容易寫的)
每次選取一個活動要考慮兩個問題:結束時間是目前沒有安排的活動中最早的,相容性
相容性:在安排了上一個活動的基礎上,這個活動還能安排得進去,這個活動的開始時間大於或者等於上一個已經安排好的活動的結束時間
貼個程式碼:
#include<bits/stdc++.h> using namespace std; #define max_v 100 struct node { int i; int end_time; int start_time; int flag; }; bool cmp(node a,node b) { return a.end_time<b.end_time; } int main() { struct node p[max_v]; int n; scanf("%d",&n); for(int i=0;i<n;i++) { scanf("%d %d %d",&p[i].i,&p[i].start_time,&p[i].end_time); p[i].i=i+1; p[i].flag=0; } sort(p,p+n,cmp); int sum=1; int end_time; end_time=p[0].end_time; p[0].flag=1; for(int i=1;i<n;i++) { if(p[i].start_time>=end_time) { sum++; p[i].flag=1; end_time=p[i].end_time; } } printf("sum=%d\n",sum); printf("活動編號:\n"); for(int i=0;i<n;i++) { printf("%d ",p[i].i); } printf("\n"); }
經典樣例三:最小生成樹問題(MST問題)
對於一個帶權的五向連通圖,其每個生成樹所有變上的權值之和都可能不同,我們把所有邊上權值之和最小的生成樹稱為最小生成樹
1.Kruskal演算法
因為這個演算法需要用到一種資料結構:並查集
所以我先分析一下什麼叫並查集,其實並查集我更願意叫它查並集
查:查詢根結點
並:結點合併
並查集是用來區分圖和樹的一種資料結構
圖:可以有環
樹:不可以有環
如果兩個樹的根結點是同一個的話,則他們屬於同一個樹,如果他們再合併的話,就會形成環,從而變成一個圖
並查集步驟
1.初始化
一開始每個根結點的父結點都是自己
2.查(帶路徑壓縮的查詢)
根據根結點的父結點是自己確定這個點是不是根結點
3.並
需要合併的兩個結點的根結點不是同一個的話,就可以合併,這樣就不會形成環,否則不合並
#include<stdio.h> #include<iostream> using namespace std; #define max_v 50005 int pa[max_v];//pa[x] 表示x的父節點 int rk[max_v];//rk[x] 表示以x為根結點的樹的高度 int n,ans; void make_set(int x) { pa[x]=x;//一開始每個節點的父節點都是自己 rk[x]=0; } int find_set(int x)//帶路徑壓縮的查詢 { if(x!=pa[x]) pa[x]=find_set(pa[x]); return pa[x]; } void union_set(int x,int y) { x=find_set(x);//找到x的根結點 y=find_set(y); if(x==y)//根結點相同 同一棵樹 return ; ans--; if(rk[x]>rk[y]) { pa[y]=x; }else { pa[x]=y; if(rk[x]==rk[y]) rk[y]++; } } int main() { int n,m,j=0; while(~scanf("%d %d",&n,&m)) { if(m+n==0) break; for(int i=1;i<=n;i++) { make_set(i); } ans=n; for(int i=0;i<m;i++) { int x,y; scanf("%d %d",&x,&y); union_set(x,y); } printf("Case %d: %d\n",++j,ans); } return 0; }
ok,現在瞭解了並查集是個什麼東西
現在我們可以看Kruskal演算法了
Kruskal演算法的核心思想:
Kruskal其實就是對邊的權值排序,利用貪心的思想,貪心的策略就是:每次選擇權值最小的邊,在選擇該
邊之後不構成環的基礎上
適用於稀疏圖,點多的情況,無向圖(可以處理負權變情況,只有迪傑斯特拉演算法求單源最短路徑的時候不
能處理負權邊)
結束條件就是成功的選擇了n-1條邊,因為只有n個點嘛,n-1條邊可以使得這n個點變成連通圖,在沒有環
的基礎上
貼個程式碼(以上面的圖為例)
#include<bits/stdc++.h> using namespace std; #define max_v 10005 struct edge//邊的結構體 { int x,y;//兩點 int w;//權值 }; edge e[max_v];//邊的結構體陣列 int rk[max_v]; int pa[max_v]; int sum; bool cmp(edge a,edge b)//結構體排序陣列,按照權值升序排序 { return a.w<b.w;//升序 } void make_set(int x) { pa[x]=x; rk[x]=0; } int find_set(int x) { if(x!=pa[x]) pa[x]=find_set(pa[x]); return pa[x]; } void union_set(int x,int y,int w) { x=find_set(x); y=find_set(y); if(x==y) return ; if(rk[x]>rk[y]) { pa[y]=x; }else { if(rk[x]==rk[y]) rk[y]++; pa[x]=y; } sum+=w; printf("%d-->%d 權重:%d\n",x,y,w); return ; } int main() { int n,m; while(~scanf("%d %d",&n,&m))//n個點,m條邊 { sum=0; if(n==0) break; for(int i=0;i<n;i++) make_set(i);//並查集初始化 for(int i=0;i<m;i++) { scanf("%d %d %d",&e[i].x,&e[i].y,&e[i].w); } sort(e,e+m,cmp);//排序,直接呼叫函式庫裡面的sort函式(快速排序) for(int i=0;i<m;i++) { union_set(e[i].x,e[i].y,e[i].w);//兩點的合併 } printf("最小的權值之和是:%d\n",sum); } } /*按照邊的權重排序,每次選擇max/min 選擇某編的時候如果構成了環,就不選 //解決:加權無向圖*/ /* 輸入: 7 9 0 1 28 1 2 16 2 3 12 3 4 22 4 5 25 5 0 10 1 6 14 4 6 24 6 3 18 輸出: 5-->0 權重:10 2-->3 權重:12 1-->6 權重:14 6-->3 權重:16 3-->4 權重:22 3-->0 權重:25 最小的權值之和是:99 */
2.prim演算法
解決稠密圖問題,邊多的情況
核心思想:
1.先任意選擇一點加入s集合
2.從不在s集合中的點裡面,選擇一個點j使得j於s內的某一點的距離最小
3.重複這個過程,直到每個點都加入s集合
其實總的來說的話,很簡單,理解了的話
1.找j
2.鬆弛(因為有新的點加入了s集合的話,其他沒有加入s集合的點到s集合的距離也會隨著新點的加入而變
化)
還是這個例子,我覺得好好用啊
#include<bits/stdc++.h> using namespace std; #define INF 1000000 #define max_v 105 int g[max_v][max_v];//g[i][j] 表示i點到j點的距離 int n,sum; void init() { for(int i=0; i<n; i++) for(int j=0; j<n; j++) g[i][j]=INF; } void prim() { int close[n];//記錄不在s中的點在s中的最近鄰接點 int lowcost[n];//記錄不在s中的點到s的最短距離,即到最近鄰接點的權值 int used[n];//點在s中為1,否則為0 for(int i=0; i<n; i++) { //初始化,s中只有一個點(0)//任意選擇 lowcost[i]=g[0][i];//獲取其他點到0點的距離,不相鄰的點距離無窮大 close[i]=0;//初始化所有點的最近鄰接點都為0點 used[i]=0;//初始化所有點都沒有被訪問過 } used[0]=1; for(int i=1; i<n; i++) { //找點 int j=0; for(int k=0; k<n; k++) //找到沒有用過的且到s距離最小的點 { if(!used[k]&&lowcost[k]<lowcost[j]) j=k; } printf("%d-->%d 權值:%d\n",close[j],j,lowcost[j]); sum+=lowcost[j]; used[j]=1;//j點加入到s中 //鬆弛 for(int k=0; k<n; k++) { if(!used[k]&&g[j][k]<lowcost[k]) { lowcost[k]=g[j][k]; close[k]=j; } } } } int main() { int m; while(~scanf("%d %d",&n,&m)) { init(); for(int i=0; i<m; i++) { int x,y,z; scanf("%d %d %d",&x,&y,&z); g[x][y]=z; g[y][x]=z; } sum=0; prim(); printf("最小生成樹的權值之和為:%d\n",sum); } } /* 輸入: 7 9 0 1 28 1 2 16 2 3 12 3 4 22 4 5 25 5 0 10 1 6 14 4 6 24 6 3 18 輸出: 0-->5 權值:10 5-->4 權值:25 4-->3 權值:22 3-->2 權值:12 2-->1 權值:16 1-->6 權值:14 最小生成樹的權值之和為:99 */
3.單源最短路徑問題
指起點到某點或者所有點的最短路徑
迪傑斯特拉演算法(跟prim演算法真的超級像)
但是迪傑斯特拉演算法不能處理負數權邊的情況,至於為什麼?看看迪傑斯特拉的核心思想就知道了
核心思想:
也是貪心,貪心策略:在沒有算過的點中找一個到源點距離最小的點
最重要的資料結構:dis【i】:表示i點到源點的距離
迪傑斯特拉演算法的核心就是圍繞這dis陣列展開的
步驟:
1.初始化
源點到其他點的距離為0,源點到其他點的距離為無窮大,隨著邊的輸入,一些無窮大大數會被權值代替,
這個就是圖的構造,同時一開始標記所有的點都沒有被算過
2.在沒有算過的點裡面找到最小的dis,然後標記為算過
3.鬆弛(最重要的一步)
為什麼要進行鬆弛:因為隨著點被用到,被用到的點到源點的距離加上圖中該被用到的點到其他點的距離竟
然小於從其他點到源點的距離,就是說其他點到源點的距離隨著該點的被用有一個更小的值,將其他點到源
點的距離更新一下即可
老師的ppt真的賊好用啊
樣例(還是用它,哈哈哈哈哈哈哈)
貼個程式碼:
#include<bits/stdc++.h> using namespace std; #define max_v 205 #define INF 99999 int edge[max_v][max_v]; int n,m; int used[max_v]; int dis[max_v]; void init()//初始化 { memset(used,0,sizeof(used)); for(int i=1; i<=n; i++) { for(int j=1; j<=n; j++) { edge[i][j]=INF; } dis[i]=INF; } } void Dijkstra(int s) { for(int i=1; i<=n; i++) { dis[i]=edge[s][i];//構圖 } dis[s]=0; for(int i=1; i<=n; i++)//找到源點到每個點的最短路徑 { int index,mindis=INF; for(int j=1; j<=n; j++) { if(used[j]==0&&dis[j]<mindis)//找到沒有用過的dis值最小的j點 { mindis=dis[j]; index=j; } } used[index]=1;//j加入 for(int j=1; j<=n; j++)//鬆弛 { if(dis[index]+edge[index][j]<dis[j]) dis[j]=dis[index]+edge[index][j]; } } for(int i=1;i<=n;i++)//輸出結果 printf("%d到%d的最短路徑是:%d\n",s-1,i-1,dis[i]); } int main() { while(~scanf("%d %d",&n,&m))//n個點,m條邊 { init(); for(int i=0; i<m; i++) { int a,b,c; scanf("%d %d %d",&a,&b,&c);//邊 edge[a+1][b+1]=edge[b+1][a+1]=c; } int s; scanf("%d",&s);//源點 Dijkstra(s+1); } } /* 輸入: 7 9 0 1 28 1 2 16 2 3 12 3 4 22 4 5 25 5 0 10 1 6 14 4 6 24 6 3 18 0 輸出: 0到0的最短路徑是:0 0到1的最短路徑是:28 0到2的最短路徑是:44 0到3的最短路徑是:56 0到4的最短路徑是:35 0到5的最短路徑是:10 0到6的最短路徑是:42 */
總結:其實這些演算法還有很多可以優化的地方,比如prim演算法可以採用堆優化,迪傑斯特拉演算法能用二叉堆優化呀,還能用斐波那契堆優化啊,因為時間有限,所以沒有一一例舉出來