第四章——蛮力法
蠻力法概述
蠻力法也稱窮舉法(枚舉法)或暴力法,是一種簡單的直接解決問題的方法,通常根據問題的描述和所涉及的概念定義,對問題所有可能的狀態(結果)一一進行測試,直到找到解或將全部可能的狀態都測試一遍為止。
蠻力法的“力”指的是計算機的運算能力,蠻力法是基于計算機運算速度快的特性,減少人的思考而把工作都交給計算機的“懶惰”策略(這里的懶惰指的是人懶,不是算法中的懶惰方法),一般而言是最容易實現的方法。
蠻力法的優點如下:
1.邏輯簡單清晰,根據邏輯編寫的程序簡單清晰。
2.對于一些需要高正確性的重要問題,它產生的算法一般而言是復雜度高但合理的。
3.解決問題的領域廣。
4.適用于一些規模較小,時間復雜度要求不高的問題。
5.可以作為其他高校算法的衡量標準,即作為被比較的例子。
主要缺點就是因為缺少人的思考,算法往往設計的簡單而缺乏高的效率。
蠻力法依賴的是將每個元素(狀態)處理一次的掃描技術,通常在以下幾種情況使用蠻力法:
搜索所有的解空間:問題的解存在于規模不大的解空間中。
搜索所有的路徑:這類問題中不同的路徑對應不同的解。
直接計算:按照基于問題的描述和所涉及的概念定義,直接進行計算。往往是一些簡單的題,不需要算法技巧的。
模擬和仿真:按照求解問題的要求直接模擬或仿真問題的過程即可。
作為算法的設計者,不要被這些人為設計的概念框住自己的手腳,通過自己的認識合理的利用資源高效地處理問題才是我們需要做到的。
蠻力法的直接應用
介紹一些直接采用蠻力法解決問題的例子。
直接采用蠻力法的一般格式
在直接采用蠻力法設計算法中,主要是使用循環語句和選擇語句:循環語句用于窮舉所有可能的情況,而選擇語句判定當前的條件是否為所求的解。
模型比較簡單就不看了(不要拘泥于模型,去體會思想)。
例題 4.1
編寫一個程序,輸出2到1000之間的所有完全數。完全數就是指該數的各因子(除該數本身外)之和正好等于該數本身。
直接采用蠻力法的方式就是將2到1000的所有數窮舉一遍,對每個可能是完全數的數,求解他們除本身之外的各因子之和與原數進行比較。
for (m=2;m<=1000;m++){ //求出m的所有因子之和s;if (m==s) 輸出s; }求解所有因子的過程依舊是一個窮舉的過程,除本身之外的因子大小不會超過本身的一半,窮舉1到本身的一半判斷能夠整除即可,結合起來對應的蠻力法如下:
void main(){ int m,i,s;//窮舉2-1000的所有整數 for (m=2;m<=1000;m++){s=0;//s為因子的和//窮舉1到m/2的所有可能為因子的整數 for (i=1;i<=m/2;i++) if (m%i==0) s+=i;//i是m的一個因子 if (m==s) printf("%d ",m);} }例 4.2
編寫一個程序,求這樣的四位數:這個四位數的十位是1,個位是2,這個四位數減去7就能被7整除,減去8就能被8整除,減去9就能被9整除。
設這個數的十進制表示為ab12,則數值n=1000*\a+100*b+12,采用窮舉法窮舉a和b:
int n,a,b; //窮舉a和b,即窮舉最后兩位為12的四位數 for (a=1;a<=9;a++) for (b=0;b<=9;b++){n=1000*a+100*b+12;//判斷n是否滿足題中的給定條件//輸出n }減去7被7整除,減去8被8整除,減去9被9整除就是簡單的基本運算的組合,完整的蠻力法如下:
int n,a,b; //窮舉a和b,即窮舉最后兩位為12的四位數 for (a=1;a<=9;a++) for (b=0;b<=9;b++){n=1000*a+100*b+12;//判斷n是否滿足題中的給定條件if ((n-7)%7==0&&(n-8)%8==0&&(n-9)%9==0) printf("n=%d\n",n); }例 4.3
在象棋算式里,不同的棋子代表不同的數,有以下算式,設計一個算法求這些棋子各代表哪些數字。
直接采用窮舉法的思想就是,對于五個棋子的取值分別進行枚舉,然后判斷對于一套取值是否能在不重復的前提下,滿足豎式的要求。
對應的蠻力法如下:
void fun(){ int a,b,c,d,e,m,n,s;//分別窮舉兵,炮,馬,卒,車的各種可能 for (a=1;a<=9;a++) for (b=0;b<=9;b++) for (c=0;c<=9;c++) for (d=0;d<=9;d++) for (e=0;e<=9;e++)//避免取值的重復 if (a==b||a==c||a==d||a==e||b==c||b==d||b==e||c==d||c==e||d==e) continue;//判斷是否滿足豎式的條件 else{ m=a*1000+b*100+c*10+d;n=a*1000+b*100+e*10+d;s=e*10000+d*1000+c*100+a*10+d;if (m+n==s)printf("兵:%d 炮:%d 馬:%d卒:%d 車:%d\n",a,b,c,d,e);} }例 4.4
有n個整數,存放在數組a中,設計一個算法從中選出3個正整數組成周長最長的三角形,并輸出該三角形的周長,若無法組成三角形就輸出0。
窮舉出n個整數中選擇三條邊的所有可能,然后對于每一種可能判斷能否組成三角形,在能夠組成三角形的前提下,更新最大的周長值。
對應的蠻力法如下:
void solve(int a[],int n){int i,j,k,len,ma,maxlen=0;for (i=0;i<n;i++) for (j=i+1;j<n;j++) for (k=j+1;k<n;k++){len=a[i]+a[j]+a[k];ma=max3(a[i],a[j],a[k]);//判斷能夠窮舉出來的一種可能的三條邊能否組成一個三角形 if (ma<len-ma){//更新最大周長 if (len>maxlen) maxlen=len; } }printf("最長三角形的周長=%d\n",maxlen); }簡單選擇排序和冒泡排序
遞歸那里介紹過了,這里不再做贅述(蠻力法本身是最簡單的方法,以蠻力法的模型再去解析簡單選擇
排序和冒泡排序不會有更好的收獲)。
字符串匹配
對于字符串s和t,若t是s子串,返回t在s中的位置(t的首字符在s中對應的下標),否則返回-1。
蠻力法的策略就是找到字符串s中所有長度為t的字符串的長度的連續子串,然后將連續子串與t進行比較,判斷t是否為s的子串,這個字符串匹配的算法稱為BF算法(Brute-Force算法)也就是暴力算法的意思。
程序的while循環是一位一位匹配的,實際上存在回退的過程,就是將一個一個的連續子串與t進行比較的過程。
//Brute-Force算法,字符串匹配 int BF(string s,string t){ int i=0,j=0;while (i<s.length()&&j<t.length()){ //匹配當前的連續子串 if (s[i]==t[j]){i++; j++;}//當前的連續子串匹配失敗,對s串進行回退,實際上就是匹配下一個字母開頭的連續子串 else{i=i-j+1; j=0;}}//t的字符比較完畢if (j==t.length()) return i-j;//t是s的子串,返回位置//t不是s的子串,返回-1else return -1; }例 4.5
有兩個字符串s和t,設計一個算法求t在s中出現的次數。
用蠻力法處理這個問題的策略和BF算法是一致的,將字符串s中所有與字符串t長度相等的連續子串拿出來進行比較,區別就在于BF算法中完成匹配后直接退出循環,而這里的問題需要計算出t在s中出現的次數,所以t字符串匹配到最后一位后還是要回退直到s字符串匹配完全。
對應的蠻力法算法如下:
//用蠻力法求t在s中出現的次數 int Count(string s,string t){ int num=0;//計數器累計出現次數int i=0,j=0;while (i<s.length()&&j<t.length()){//匹配當前的連續子串 if (s[i]==t[j]){i++; j++;}//當前的連續子串匹配失敗,對s串進行回退,實際上就是匹配下一個字母開頭的連續子串 else{i=i-j+1; j=0;}//匹配成功時,仍然進行回退,計時器+1 if(j==t.length()){num++; j=0; }}return num; }求解最大連續子序列和問題
就是分治法中求解連續最大子序列和的問題。
用蠻力法就是枚舉出所有連續的子序列的和,然后找到里面最大的那個子序列,書上的前兩種蠻力法都是基于的這種思路,解法二計算從第i個元素開始的連續子序列的和時,計算長度長的連續子序列的和繼承前面長度較短的連續子序列的計算結果,相當于減少了重復計算的內容,解法二從某種角度而言其實已經很傾向于動態規劃算法了。
蠻力法的兩個解法如下:
//蠻力法求解最大連續子序列和問題的解法一 int maxSubSum1(int a[],int n){ int i,j,k; int maxSum=a[0],thisSum;//窮舉所有的連續子序列 for (i=0;i<n;i++) for (j=i;j<n;j++){ //計算連續的子序列的和 thisSum=0;for (k=i;k<=j;k++) thisSum+=a[k];//通過比較求最大連續子序列之和if (thisSum>maxSum) maxSum=thisSum;}return maxSum; } //蠻力法求解最大連續子序列和問題的解法二 int maxSubSum2(int a[],int n){ int i,j;int maxSum=a[0],thisSum;//窮舉所有的連續子序列//thisSum記錄以i為首的連續子序列的和,有點滾動數組的意思 for (i=0;i<n;i++){thisSum=0;for (j=i;j<n;j++){thisSum+=a[j];//通過比較求最大連續子序列之和if (thisSum>maxSum) maxSum=thisSum;}}return maxSum; }前一種的復雜度為O(n3),后一種的復雜度為O(n2)。
解法3基于的一個事實就是最大的連續子序列一定不可能由一段和為負的連續子序列后面拼上別的連續子序列,因為舍棄了前面何為負的連續子序列,連續子序列的和變得更大就產生了矛盾。
于是在我們計算從第i個元素開始的連續子序列的和時,我們只需要計算到連續子序列的和小于0之前即可。
我們假設上面過程枚舉第i個元素開始的連續子序列的最后一個元素,枚舉到第j個元素為止,也就是說對于k(i≤k≤j),都有第i個元素到第k個元素的連續子序列的和非負,當我們考察第k個元素開始的連續子序列時,由于第i個元素到第k-1個元素的連續子序列的和非負,那么第k個元素到第j個元素的連續子序列的和一定小于等于第i個元素到第j個元素的連續子序列和。
也就是說我們枚舉第k個元素開始的連續子序列的最后一個元素,做多枚舉的不會超過第j個元素,且由于第i個元素到第k-1個元素的連續子序列的和非負,我們枚舉的第k個元素開始的連續子序列的值不會超過枚舉的第i個元素開始的連續子序列的值。
所以我們枚舉完第i個元素開始的連續子序列的最后一個元素,枚舉到第j個元素為止后,下一個枚舉的元素從第j+1個元素開始,也就是說只需要從前往后的遍歷一遍。
對應的算法如下:
//蠻力法求解最大連續子序列和問題的解法三 int maxSubSum3(int a[],int n){ int i,maxSum=0,thisSum=0;for (i=0;i<n;i++){thisSum+=a[i];//若當前以第i個元素開頭枚舉的子序列和為負數,重新開始枚舉下一元素開頭的連續子序列if (thisSum<0) thisSum=0;//比較求最大連續子序列和if (maxSum<thisSum) maxSum=thisSum;}return maxSum; }解法三相比解法二傾向于與貪心思想的結合,解法二和解法三嚴格意義上說都不能稱為傳統意義上的蠻力法了。
三個解法的合集如下:
//蠻力法求解最大連續子序列和問題的解法一 int maxSubSum1(int a[],int n){ int i,j,k; int maxSum=a[0],thisSum;//窮舉所有的連續子序列 for (i=0;i<n;i++) for (j=i;j<n;j++){ //計算連續的子序列的和 thisSum=0;for (k=i;k<=j;k++) thisSum+=a[k];//通過比較求最大連續子序列之和if (thisSum>maxSum) maxSum=thisSum;}return maxSum; } //蠻力法求解最大連續子序列和問題的解法二 int maxSubSum2(int a[],int n){ int i,j;int maxSum=a[0],thisSum;//窮舉所有的連續子序列//thisSum記錄以i為首的連續子序列的和,有點滾動數組的意思 for (i=0;i<n;i++){thisSum=0;for (j=i;j<n;j++){thisSum+=a[j];//通過比較求最大連續子序列之和if (thisSum>maxSum) maxSum=thisSum;}}return maxSum; } //蠻力法求解最大連續子序列和問題的解法三 int maxSubSum3(int a[],int n){ int i,maxSum=0,thisSum=0;for (i=0;i<n;i++){thisSum+=a[i];//若當前以第i個元素開頭枚舉的子序列和為負數,重新開始枚舉下一元素開頭的連續子序列if (thisSum<0) thisSum=0;//比較求最大連續子序列和if (maxSum<thisSum) maxSum=thisSum;}return maxSum; }求解冪集問題
對于給定的正整數n,求1到n構成的集合的所有子集(冪集)。例如n=3時,1到n構成的集合的所有子集為:{},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}。
這個問題用窮舉法解決的策略就是對每個元素考慮選取與不選取的兩個情況,即從全不選擇,到全都選擇窮舉所有的可能。
第i個數選擇為1不選擇為0,于是對于任意一個子集我們可以得到一個長度為n的二進制編碼,這些二進制編碼對應的十進制數為0到2n-1,例如n=3時對應的二進制編碼和十進制數如下:
這種窮舉法的思路就是將0到2n-1的十進制數全部轉化為二進制數,然后根據二進制數得到對應的子集,時間復雜度為2n*n(這種方法稱為二進制法,比較簡單,書上的寫法很糟糕這里就不多贅述了)。
另一種實現方法,設置n層循環,前i層循環得到前i個元素能夠構成的2i個冪集,然后根據第i+1個元素選擇和不選擇得到前i+1個元素能夠構成的2i+1個冪集。
vector<vector<int> >ps;//存放冪集 //求1~n的冪集ps void PSet(int n){ vector<vector<int> >ps1//子冪集vector<vector<int> >::iterator it;//冪集迭代器vector<int> s;ps.push_back(s);//添加{}空集合元素//i層循環考慮是否添加元素i進入現有的子集,從1~i-1的冪集得到1~i的冪集 for (int i=1;i<=n;i++){ ps1=ps;//ps存放上一層循環得到從1~i-1的冪集,ps1復制1~i-1的冪集//然后通過1~i-1的冪集后面擴展一個元素i,得到1~i的冪集比起1~i-1的冪集多出來的部分 for (it=ps1.begin();it!=ps1.end();++it) (*it).push_back(i); for (it=ps1.begin();it!=ps1.end();++it) ps.push_back(*it); } }這個復雜度相對前一種實現方法低,為O(2n),也是用了類似動態規劃的思想。
求解0/1背包問題
n個重量分別為w1,w2,…,wn的物品,它們的價值分別為v1,v2,…,vn,給定一個容量為W的背包。
設計從這些物品中選取一部分物品放入該背包的方案,每個物品要么選中要么不選中,要求選中的物品不僅能夠放到背包中,而且具有最大的價值。
使用蠻力法的策略就是枚舉出所有可能的方案,然后根據選擇物品的方案得到它們的重量和價值,然后再用蠻力法將每種方案排查一遍得到滿足容量條件中,價值最大的方案。
窮舉所有可能選取物品方案的方法和窮舉冪集的策略是一樣的,對應的蠻力法算法如下:
ector<vector<int> >ps;//存放冪集 //求1~n的冪集ps void PSet(int n){ vector<vector<int> >ps1//子冪集vector<vector<int> >::iterator it;//冪集迭代器vector<int> s;ps.push_back(s);//添加{}空集合元素//i層循環考慮是否添加元素i進入現有的子集,從1~i-1的冪集得到1~i的冪集 for (int i=1;i<=n;i++){ ps1=ps;//ps存放上一層循環得到從1~i-1的冪集,ps1復制1~i-1的冪集//然后通過1~i-1的冪集后面擴展一個元素i,得到1~i的冪集比起1~i-1的冪集多出來的部分 for (it=ps1.begin();it!=ps1.end();++it) (*it).push_back(i); for (it=ps1.begin();it!=ps1.end();++it) ps.push_back(*it); } } //用蠻力法求所有的方案和最佳方案 void Knap(int w[],int v[],int W){//用求解冪集的方法窮舉出所有的方案 PSet(n); int count=0;//窮舉過程中窮舉的方案的編號int sumw,sumv;//當前窮舉的方案的總重量和總價值int maxi,maxsumw=0,maxsumv=0;//最佳方案的編號,總重量和總價值vector<vector<int> >::iterator it;//訪問冪集中每個子集的迭代器vector<int>::iterator sit;//訪問冪集子集元素的迭代器 //冪集的每一個子集對應一套方案 for (it=ps.begin();it!=ps.end();++it){ sumw=sumv=0;for (sit=(*it).begin();sit!=(*it).end();++sit){ sumw+=w[*sit-1];//計算該套方案的重量 sumv+=v[*sit-1];//計算該套方案的價值 }//比較求滿足條件的最優方案if (sumw<=W&&sumv>maxsumv){ maxsumw=sumw;maxsumv=sumv;maxi=count; }count++; } }求解全排列問題
對于給定的正整數n,求1~n的所有全排列。
例如n=3,1~n的全排列為:123,132,213,231,312,321。
用蠻力法求解全排列問題是個比較經典的問題,一種思路是窮舉全排列中每一個排列每一位出現的數字,從第一位窮舉到最后一位。
另一種思路就是書上的思路:窮舉數字i可能出現的位置,從1窮舉到n。和求解冪集問題一樣,前i層窮舉數字1到i出現的位置,相當于窮舉出1到i的全排列,然后在第i+1層循環中窮舉數字i+1可能出現的位置,從1到i的全排列擴展到1到i+1的全排列(從思想上近似于動態規劃的思想)。
對應的蠻力法的算法如下:
vector<vector<int> >ps;//存放1到n的全排列 //在排列s中尋找能夠插入i的位置,將得到的新的s1插入到ps1中 vvoid Insert(vector<int> s,int i,vector<vector<int> > &ps1){ vector<int> s1;vector<int>::iterator it;//窮舉將i插入到排列s的位置 for (int j=0;j<i;j++){ s1=s;it=s1.begin()+j;//插入的位置 s1.insert(it,i);//插入整數ips1.push_back(s1);//將得到的新的排列加入ps1中 } } //求1~n的所有全排列 void Perm(int n){ vector<vector<int> >ps1;//存放1到i-1的全排列 vector<vector<int> >::iterator it;//全排列迭代器vector<int>s,s1;s.push_back(1); ps.push_back(s);//在ps中添加{1}集合元素,得到1到1的全排列 //從2開始窮舉每一個數字可能出現的位置 for (int i=2;i<=n;i++){ps1.clear();//ps存放1到i-1的全排列,在1到i-1的每個排列中尋找i能夠插入的位置//將得到的1到i的全排列中的排列插入到ps1中 for (it=ps.begin();it!=ps.end();++it) Insert(*it,i,ps1); ps=ps1;} }時間復雜度為O(n*n!)。
求解任務分配問題
有n個任務需要分配給n個人執行,每個任務只能分配給一個人,每個人只能執行一個任務。第i個人執行第j個任務的成本是c[i][j],求出總成本最小的分配方案。
蠻力法的策略就是窮舉出所有任務分配的方案,然后計算出每個任務分配的方案的成本,然后比較求解出總成本最小的分配方案。
窮舉出所有任務分配的方案,我們將第1個人到第n個人做的任務的編號連在一起得到一個n位的數字序列,所有任務分配的方案對應的n位數字序列的總集就是1~n的全排列,求解任務分配問題蠻力法解決實際上就是求解全排列問題(代碼就不做贅述了)。
上面介紹了用蠻力法求解找到滿足條件的數的簡單數學問題,排序問題,字符串匹配問題,最大連續子序列和問題,冪集問題,0/1背包問題,全排列問題,任務分配問題。
其中蠻力法求解0/1背包問題和任務分配問題分別屬于蠻力法求解冪集問題和全排列問題的衍生應用,分別成為子集問題和排列問題。蠻力法求解冪集問題和全排列問題的解法存在比較高的相似性,包括第二種優化的解法(提供給我們動態規劃在什么地方使用和框架設計的思路)。
遞歸在蠻力法中的應用
蠻力法是一種處理問題的算法思想,遞歸是一種算法的實現方式,很多用蠻力法求解問題的過程都可以采用遞歸方式來實現,遞歸算法的分解規模較小的問題(階段)的框架也給最基礎的蠻力法帶來一定優化方向上的啟發。
用遞歸方法求解冪集問題,全排列問題
求解冪集問題和全排列問題的第二種解法,都是將問題從最小的規模(空集,1到1的全排列)一步一步擴展(從前i個元素的冪集擴展到前i+1個元素的冪集,從1到i的全排列擴展到1到i+1的全排列),擴展到規模最大的原問題的解(n個元素的冪集,1到n的全排列),本身就是符合遞歸算法設計的一般步驟的。
我們前面是用循環去實現的,就相當于用循環結構替代遞歸過程的結果。替換回遞歸過程的策略是給函數添加一個·屬性,然后通過屬性值的遞減或遞增進行一個狀態是順序結構的遞歸,效果和循環結構是一樣的,這里不做贅述。
用遞歸方法求解組合問題
求從1~n的正整數中取k個不重復整數的所有組合。
因為只取k個不重復整數,而不是將n個整數全部不重復地取出,前面子問題為求1到i放置位置的可能(全排列)的設計在這里就不是很好用。
但我們前面還提到另外一種窮舉法的思路:窮舉每一個排列每一位出現的數字,從第一位窮舉到最后一位。
于是我們可以設規模最大的原問題為1到n中選取k個不重復的數字,規模較小的子問題為1到n中選取i個不重復的數字。但是這么設計需要注意一個問題,就是當我們從規模較小的問題的解去求解規模較大的問題時,選取的i個數字不能在規模較大的問題中進行選取,就是說實際上取數的范圍不是1到n,而是1到n中不包括前面選取過的i個數字,我們可以用標記的方法來解決這個問題。
但是不同于全排列1,2,3和1,3,2屬于兩種不同的排列,不重復數字的組合中的數字不存在次序關系,就是說1,4,7,8和4,8,7,1是一種組合,于是我們可以默認每一種組合中數字都是從小到大排列的。
有一種不使用標記的更好的設計方案:設規模最大的原問題為從1到n中從小到大的選取k個不重復的數字,選取的最大數為max_n。規模較小的子問題為1到n中從小到大的選取i個不重復的數字,選區的最大數為max_i,因為將組合中的元素從小到大的排序且添加了一個max_i的屬性,從規模為i的子問題擴展到規模為i+1的子問題,第i+1個數字的選取可以在max_i到n中選取,不用再通過標記的方法來判斷是否出現重復,第i+1次選取的數字為max_(i+1)。
前面提到“蠻力法是一種處理問題的算法思想,遞歸是一種算法的實現方式”,蠻力法算法指的是算法思想,遞歸算法指的是算法思想,兩者并不沖突,我們遞歸那一章中很多遞歸算法采用的算法思想也是蠻力法。
我們在使用遞歸去實現蠻力法算法時,會發現很多子問題(階段)是重復求解的,我們將子問題的解保存下來,重復求解時直接返回子問題的解能夠提高效率(求解全排列問題和冪集問題用到過這個方法),這是我們之后章節要介紹的內容。
圖的深度優先和廣度優先遍歷
圖的存儲結構
圖的存儲結構主要就是鄰接矩陣存儲方式和鄰接表存儲方式,這主要是數據結構篇章的內容,我們只做略過,不多做贅述。
鄰接矩陣存儲方法
鄰接矩陣作為一個矩陣,第行第j列的值表示結點i到結點是否存在邊(或者是帶權圖中邊的權)。
結構體定義如下:
鄰接表存儲方法
圖的鄰接表存儲方法是一種鏈式存儲結構。
我們對圖的每個頂點建立一個單鏈表,第i個單鏈表中存儲與頂點i鄰接的頂點。n個單鏈表的表頭結點再通過數組的方式存儲起來構成一個表頭結點數組。
typedef struct ANode{ int adjvex;//鄰接頂點的頂點編號 int weight;//結點i到鄰接頂點構成的邊的權值 struct ANode *nextarc;//指向下一鄰接頂點的指針 }ArcNode; //頂點結構體,存儲頂點信息和頂點對應單鏈表的表頭結點 typedef struct Vnode{ char data[MAXL];//頂點信息ArcNode *firstarc;//firstarc為頂點對應單鏈表的表頭結點 }VNode; typedef VNode AdjList[MAXV];//AdjList是鄰接表類型,存儲n個單鏈表的表頭結點 typedef struct{ AdjList adjlist;//鄰接表int n,e;//圖中頂點數n和邊數e }ALGraph;圖遍歷算法
從給定(連通)圖中任意指定的頂點(起始點)出發,按照某種順序沿著圖的邊訪問圖中的所有頂點,使每個頂點僅被訪問一次的過程稱為圖的遍歷,對應算法為圖遍歷算法。
圖遍歷算法是圖算法的設計基礎,根據不同的遍歷順序,最經典的兩種遍歷方式有深度優先遍歷和廣度優先遍歷,他們本質的算法思想都是蠻力法思路(作為搜索算法的算法思想是蠻力法思路,通過窮舉每個結點的可能來找到我們需要搜索的目標)。
深度優先遍歷
深度優先遍歷的策略,簡單來說就是在一條分支上將該分支的結點先遍歷完,再回溯(這是一個概念上的回溯,不需要進行另外的操作)到前面的祖先結點遍歷別的分支,直到所有的結點遍歷完全(見上科大課件的lecture 10)。
算法的過程如下:
1.從圖中某個初始頂點v出發,首先訪問初始頂點v。
2.選擇一個與當前正在訪問的結點相鄰且沒被訪問過的頂點為下一次訪問的當前結點(屬性),以該結點進行2的操作;當某個結點無法向下進行2的操作,回到上次訪問的結點選擇另外一個相鄰且沒有訪問過的頂點進行2的操作(就是遞歸堆頂元素求解成功,退棧之后代入回新的棧頂元素進行處理的過程),直到圖中與頂點v連通的所有頂點都被訪問過為止。
顯然,這個算法過程是個遞歸過程(算法正確性的證明過程略)。
以鄰接矩陣為存儲結構的深度優先遍歷算法如下:
//鄰接矩陣的DFS算法 void DFS(MGraph g,int v){ visited[v]=1;//標記當前正在訪問的結點v已經被訪問//找當前正在訪問v的所有相鄰點(矩陣值部不為0,不為INF) for (int w=0;w<g.n;w++)//且相鄰點不能在前面遍歷的過程中被訪問過 if (g.edges[v][w]!=0&&g.edges[v][w]!=INF&&visited[w]==0) DFS(g,w); }時間復雜度為O(n2)。
以鄰接表為存儲結構的深度優先遍歷算法如下(其實單鏈表也可以用for循環進行遍歷):
//鄰接表的DFS算法 void DFS(ALGraph *G,int v){ ArcNode *p;visited[v]=1; p=G->adjlist[v].firstarc;//p指向頂點v的第一個鄰接點while (p!=NULL){ //若頂點未訪問,以該頂點作為下一個訪問頂點 if (visited[p->adjvex]==0) DFS(G,p->adjvex);p=p->nextarc; } }時間復雜度為O(n+e)。
這里visited數組進行標記來避免圖的遍歷過程中結點被重復遍歷。而樹作為圖的一個特殊大類,樹遍歷算法不需要進行標記,所以我們往往會先介紹樹的遍歷算法再從樹的概念來解釋圖遍歷算法中為什么需要對結點進行標記的原因。
事實上,通過我們這里圖遍歷算法的遞歸過程對應的遞歸樹,也可以從這個遞歸樹的角度來解釋為什么樹遍歷算法不需要進行標記。
例 4.6
假設圖G采用鄰接表存儲,設計一個算法判斷圖G中從頂點u到v是否存在簡單路徑。
判斷點u到點v是否存在簡單路徑,就是判斷點u與點v是否連通。前面提到圖遍歷算法可以通過一個頂點訪問連通圖所有的頂點,就是訪問所有與該頂點連通的頂點。
對于這個問題,我們采用圖遍歷算法,如果存在簡單路徑,也就是連通,那么通過圖遍歷算法就能夠訪問到,否則不能。
對應的算法如下:
//判斷G中從頂點u到v是否存在簡單路徑 bool ExistPath(ALGraph *G,int u,int v){ int w; ArcNode *p;visited[u]=1; if (u==v) return true;//當前訪問的結點為v,說明u和v連通,返回true p=G->adjlist[u].firstarc; while (p!=NULL){//遞歸訪問未訪問過的相鄰結點w=p->adjvex; if (visited[w]==0){ bool flag=ExistPath(G,w,v);if (flag) return true;//當前分支連通,就不用回溯到該節點訪問下一個相鄰節點 }p=p->nextarc; }return false;//沒有找到v,返回false }例題4.7不僅需要判斷路徑,還需要輸出路徑,這個問題我們在前面遞歸法的章節中討論過二叉樹的版本,前面提到圖遍歷算法和樹遍歷算法只有標記的區別,所以不再做贅述(書上的方法是遞歸章節介紹的第二種方法)。
廣度優先遍歷
廣度優先遍歷,就是以到起始結點的邊的數量作為優先級,每一層訪問同一個優先級的節點,然后通過該層某優先級的結點訪問到下一優先級的結點,直到所有的結點全部被訪問過為止。
由于結點是按照優先級訪問,優先級低的先訪問,優先級的后訪問,且我們通過優先級擴展到的相鄰的結點一定是緊接著的下一優先級的結點(這一點保證添加進容器后,出容器的次序是按照優先級的次序出的),于是我們可以用隊列作為寬度優先遍歷訪問結點的容器。
以鄰接矩陣為存儲結構的廣度優先遍歷算法如下:
//鄰接矩陣的BFS算法 void BFS(MGraph g,int v){ queue<int>qu;//隊列存儲需要訪問的結點 int w,i;visited[v]=1;//標記初始結點v已經被訪問qu.push(v);//v進隊while (!qu.empty()){//出隊首結點進行訪問并進行標記 w=qu.top(); qu.pop(); visited[w]=1;//尋找相鄰且訪問的結點進行擴展(進隊),擴展到下一優先級 for (i=0;i<g.n;i++)if (g.edges[w][i]!=0&&g.edges[w][i]!=INF&&visited[i]==0) qu.push(i);} }復雜度為O(n2),鄰接表的廣度優先遍歷和鄰接表的深度優先遍歷一樣,就是在尋找相鄰結點時是遍歷一個單鏈表,復雜度為O(n+e)。
對于鄰接矩陣,每遍歷一個結點就需要將n個結點檢查一遍尋找相鄰且為訪問過的結點,所以復雜度為O(n2);而對于鄰接表,每遍歷一個結點也需要將與它相鄰的結點檢查一遍,但是相鄰的結點是通過單鏈表存儲起來的,所以復雜度為所有節點相鄰結點的個數之和即為O(e)(加上n是因為還要遍歷節點)。
例 4.8
假設圖G采用鄰接表存儲,設計一個算法,求不帶權無向連通圖G中從頂點u到頂點v的一條最短路徑。
例4.8屬于廣度優先遍歷的一個典型應用——無權圖中求兩個頂點之間的最短路徑,因為廣度優先遍歷是按照距離起始結點的邊數為優先級遍歷的,當遍歷到結點v時一定起始節點u到結點v經過邊數最少的一條路徑,即最短路徑。
對應的算法如下(這里求路徑的方式不同于前面的兩種方式,而是采用記錄擴展過程中前繼節點的方式,然后通過前繼節點找到最短路徑):
//求圖G中從頂點u到頂點v的最短路徑path void ShortPath(ALGraph *G,int u,int v,vector<int> &path){ ArcNode *p; int w;queue<int>qu;//存放需要訪問的結點 int pre[MAXV];//pre[i]表示編號為i的結點在廣度優先遍歷過程中,通過哪個結點擴展到編號為i的結點(前繼結點編號) int visited[MAXV];//標記結點是否被訪問過 memset(visited,0,sizeof(visited));//初始化標記用的數組 qu.push(u); visited[u]=1;//標記初始結點v已經被訪問pre[u]=-1;//初始結點作為遞歸樹的根,前繼結點的編號為-1while (!qu.empty()){ //出隊首結點進行訪問并進行標記w=qu.front(); qu.pop(); visited[w]=1; //當前訪問的結點為目標節點v,通過前繼結點找到路徑path if (w==v){int d=v;while (d!=-1){path.push_back(d); d=pre[d];} return;}//遍歷結點對應的單鏈表,尋找未訪問過的相鄰結點加入隊列//并且記錄前繼結點的編號 p=G->adjlist[w].firstarc;while (p!=NULL){if (visited[p->adjvex]==0){qu.push(p->adjvex); pre[p->adjvex]=w;}p=p->nextarc;}} }保存的路徑是逆序的,輸出路徑時需要反向輸出。
求解迷宮問題
給定一個n*n的迷宮圖,例如下面8*8的迷宮圖:
OXXXXXXX
OOOOOXXX
XOXXOOOX
XOXXOXXO
XOXXXXXX
XOXXOOOX
XOOOOXOO
XXXXXXXO
其中O表示可以走的方塊,X表示障礙方塊,我們假設入口為最左上角方塊,出口為最右下角方塊,設計一個程序求入口到出口的路徑。
我們將每個可走的方塊看作一個節點,如果兩個方塊上下左右相鄰則認為他們之間存在邊,于是這個迷宮就轉化成了圖結構,問題就轉化為了在圖結構中求解左上角方格對應結點到右下角方格對應結點的路徑。
求解路徑的方法就是廣度優先遍歷和寬度優先遍歷,因為是數據結構中介紹過的問題這里不再做贅述。
值得一提的是,迷宮問題標記策略是將訪問過的O方格修改成星號方格,然后在回溯時修改回O方格。
那為什么這里的標記需要在回溯時取消標記,而前面的BFS算法,DFS算法都不需要取消標記呢?
BFS和DFS的目標都是將與起始結點相通的結點按照一定的順序進行訪問,不會出現一條分支無法訪問,而另一條分支可以訪問卻被前一條無法訪問的分支“擋路”的情況。
求解迷宮問題用的也是BFS和DFS算法,只是我們這樣直接通過修改進行標記,就不需要將未標記過的結點存儲起來進行標記,可以節省一定的空間開銷。取消標記不是為了避免“擋路”情況的出現。
總結
- 上一篇: internal compiler er
- 下一篇: C语言,十进制转化为二进制。