用最通俗的話解釋回溯思想:
:盆友,玩過聯盟嗎?
:沒有啊,好玩嗎?
:好玩啊,你試試
:mmp,試過了,不好玩,還是吃雞去,吃雞使我快樂
你的目的是快樂,發現打聯盟不能使你快樂,馬上找另外一條能使你快樂的路
------------------------------(一條很正經的分割線)---------------------------------------------
回溯思想其實也可以叫做試探思想
有時候我們需要得到問題的解,先從其中某一種情況進行試探,在試探的過程中,一旦發現原來的選擇是錯
誤的,那么就退回一步重新選擇,然后繼續向前試探,反復這樣的過程直到求解出問題的解(試探思想充斥
在生活的各個地方)
其實一句話:回溯就是帶優化的窮舉,歸根結底,還是一種有暴力的影子在里面,只不過帶剪枝而已
牢記6個字:帶優化的窮舉,記住了回溯想不懂都難
看看最正統的回溯思想概念:
很官方很學術的一種解釋,但無疑是挑不出毛病的,但是第一次看的話,比較難懂,需要注意的是采用回溯
法的時候我們是邊搜索邊構造狀態空間樹,想想,如果我們一開始就是把狀態空間樹全部構造出來,然后再
搜索的話,會消耗很大的空間,且在Cutting的時候大部分的枝葉都剪掉了,全部構造出來完全沒有必要嘛
回溯與分支限界很類似,回溯是DFS+Cutting,而分支限界是BFS+Cutting
如果要用回溯求解問題的所有解,則要回溯到根,且根結點的所有子樹都已經被搜索才結束
如果只要求解任一解,則只要搜索到問題的一個解就可結束
來看幾個經典樣例:
經典樣例1:(01背包問題的回溯解法)
01背包問題真的是一個老生常談的問題,分治,dp,貪心,回溯到處都可以見到它,用分治的話效率低
下,用貪心的話解不出來,但是可以求出近似最優當作參考
第一步:畫解空間樹
A
1 / 0\
B B
1 / 0\ 1/ 0\
C C C C
A,B,C三個物品,1代表選擇,0代表不選
這就是所謂的解空間樹,搜索到葉子結點,問題也就結束了
第二步:設計Cutting函數
有時候做題,超時還是不超時,完全就是看你的Cutting函數設計的怎么樣,回溯的重點不在於解空間樹,
而是在於Cutting函數的設計
CUtting函數有兩個分類:
第一個:約束函數:即不滿足約束條件,比如01背包問題中的背包裝不下了
第二個:限界函數:即這條路走了,沒有走另外一條路好,少年,回頭吧,走另外一條路
下面開始我們Cutting函數的設計
最簡單的一個:背包裝不下的時候肯定不能再裝該物品
即:要求已經裝了的物品重量加上該物品的重量<=背包容量
即裝得下就裝,裝不下就剪
這個是考慮得裝不下的情況,即剪的是右枝
現在考慮剪左枝的情況
左枝:即當前考慮的物品不裝入背包的Cutting函數
如果當前已經選了的物品的總價值加上該物品后面可以裝的物品的價值大於最優總價值
那么當前物品就沒有必要裝
為什么呢?這個可能很難理解
因為我裝后面的物品得到的價值比當前的最優價值還大,那我當前物品完全就沒有裝的必要嘛
最優價值是更新的
貼個代碼:
#include<bits/stdc++.h> using namespace std; #define max_v 105 int n; int v[max_v],w[max_v]; int c;//背包容量 int cv=0;//當前裝的物品的總價值 int cw=0;//當前裝的物品的總重量 int bestv=0;//最優總價值 int bestx[max_v];//最優解 int x[max_v]={0};//當前解 int bound(int i) { int l=c-cw; int b=cv; while(w[i]<=l&&i<=n) { b+=v[i]; l-=w[i]; i++; } if(i<=n) b+=l*v[i]/w[i]; return b; } void dfs(int i) { if(i>n)//搜完了一條路 { for(int j=1;j<=n;j++) bestx[j]=x[j]; bestv=cv; }else { if(cw+w[i]<=c)//裝得下 { x[i]=1;//該物品裝 cw+=w[i]; cv+=v[i]; dfs(i+1);//搜索下一個物品 x[i]=0;//回退 cw-=w[i]; cv-=v[i]; } if(bound(i+1)>bestv)//最優總價值小於上界,當前已經選了的物品的總價值加上該物品后面可以裝的物品的價值大於最優總價值,那么當前物品就沒有必要裝 { x[i]=0; dfs(i+1); } } } void putout() { int sum=0; for(int i=1;i<=n;i++) { printf("%d ",bestx[i]); if(bestx[i]==1) sum+=w[i]; } printf("放入背包的物品重量為:%d 價值為:%d",sum,bestv); } int main() { scanf("%d %d",&n,&c); for(int i=1;i<=n;i++) { scanf("%d %d",&v[i],&w[i]); } dfs(1); putout(); return 0; } /* 輸入: 5 10 6 2 3 2 6 4 5 6 4 5 輸出: 1 1 1 0 0 放入背包的物品重量為:8 價值為:15 */
01背包問題屬於子集樹問題,即每次都只要兩種選擇,物品選還是不選,有2的n次方個葉子結點
而排列樹問題就是每次都有多種選擇,比如下面要講的n皇后問題,有n的階乘個葉子節點
經典樣例二:N皇后問題
在N*N的棋盤上,放置N個皇后,要求每一橫行,每一列,每一對角線上均只能放置一個皇后,求可能的方案及方案數。
怎么回溯:
比如先在空棋盤的第一行第一列放一個,然后看下一行,有沒有合法的位置
所以現在有兩種情況:
有合法的位置:那么就放,然后又看下一行,有沒有合法的位置
沒有合法的位置:回溯到上一個狀態,即上一行,比如你在(0,0)放了一個,在(1,2)放了一個,現
在你要在第3行放一個,因為你前面放的兩個皇后的位置,導致你在第三行無論放哪里都不合法,那這個時
候你就很尷尬了,你必須得回溯到第二行,本來是在(1,2)放的,你再在第二行找個合法的位置放一個
皇后,然后又繼續放第三行,持續這個過程,直到最后一行都在合法的位置放了一個皇后
相關數據結構:
a【i】【j】:棋盤,為0表示是空的,大於0表示放了皇后
M【i】,L【i】,R【i】:表示第i豎列,第i左斜列,第i右斜列有沒有放皇后,放了為1,沒有為0
行沒有皇后是通過直接循環控制的,看代碼即可
注意每次試探之后,都要進行回退操作,即去皇后
貼個代碼:
#include<bits/stdc++.h> using namespace std; #define max_v 105 int a[max_v][max_v]; int M[max_v],L[max_v],R[max_v]; int c=0; void p(int n)//輸出棋盤 { for(int i=0; i<n; i++) { for(int j=0; j<n; j++) { printf("%d ",a[i][j]); } printf("\n"); } printf("\n"); } void DFS(int i,int n) { for(int j=0; j<n; j++) { if(!M[j]&&!L[i-j+n]&&!R[i+j])//安全,可以放 { a[i][j]=i+1;//放皇后,i+1表示該皇后屬於第幾個放的皇后 M[j]=L[i-j+n]=R[i+j]=1; if(i==n-1)//已經到最后一行,每一行都放了皇后 { p(n);//輸出棋盤 c++;//方案數加1 } else { DFS(i+1,n);//繼續試探 } //試探完成后的回退 a[i][j]=0; M[j]=L[i-j+n]=R[i+j]=0; } } } int main() { int n; scanf("%d",&n); memset(a,0,sizeof(a));//初始化 memset(M,0,sizeof(M)); memset(L,0,sizeof(L)); memset(R,0,sizeof(R)); DFS(0,n); printf("Answer:%d\n",c); return 0; } /* 輸入: 4 輸出: 0 1 0 0 0 0 0 2 3 0 0 0 0 0 4 0 0 0 1 0 2 0 0 0 0 0 0 3 0 4 0 0 Answer:2 */
經典樣例三:油田問題
找到一個沒有用過的油田就向這個油田的8個方向繼續擴展,擴展的過程中遇到一個沒有用過的新油田就標記為用過,然后又繼續向它的8個方向擴展
就像*代表地面,油田代表地上有一個坑,你遍歷整個地面,遇到一個沒有水的坑你就往里面導水,水可以從8個方向流動,最后問你地上有幾個大水坑。。。。
一樣的問題
貼個代碼:
#include<bits/stdc++.h> using namespace std; #define max_v 105 int n,m; char a[max_v][max_v];//字符矩陣 int used[max_v][max_v];//該點被訪問過就置1,開始初始化全0 int dx[8]={1,1,0,-1,-1,-1,0,1};//方向引導數組 int dy[8]={0,-1,-1,-1,0,1,1,1}; void dfs(int x,int y,int z) { if(x<0||x>=n||y<0||y>=m)//越界 return ; if(used[x][y]>0||a[x][y]!='@')//被訪問過或者不是油田 return ; used[x][y]=z;//是油田,標記一下 for(int i=0;i<8;i++) dfs(x+dx[i],y+dy[i],z);//8個方向繼續找 } int main() { while(~scanf("%d %d",&n,&m)) { getchar(); for(int i=0;i<n;i++) { for(int j=0;j<m;j++) { scanf("%c",&a[i][j]); } getchar(); } memset(used,0,sizeof(used));//初始化全0 int c=0; for(int i=0;i<n;i++) { for(int j=0;j<n;j++) { if(used[i][j]==0&&a[i][j]=='@')//沒有訪問過的油田 dfs(i,j,++c); } } printf("%d\n",c); } } /* 5 5 ****@ *@@*@ *@**@ @@@*@ @@**@ 2 */
經典樣例四:素數環問題
題目要求你用1到20這20個數構成一個素數環,要求相鄰的兩個數的和是一個素數
把問題轉換一下,這個不就是要求符合素數環特征的這20個數字的全排列嗎?
其實就是一個全排列的思想加上一個素數的檢測問題嗎?
所以說,思想真的是很重要的東西,全排列的思想就是分治,讓每個數輪流做第一個
關於全排序的問題請參考我的分治思想的那篇博客:https://www.cnblogs.com/yinbiao/p/9215525.html
貼個代碼(所有知識都在代碼里了):
#include<bits/stdc++.h> using namespace std; #define max_v 105 int A[max_v]; int vis[max_v]={0}; int n; int isp(int x)//判斷x是不是一個素數 { for(int i=2;i<=sqrt(x);i++) { if(x%i==0) return 0; } return 1; } void dfs(int cur)//確定第cur個空填什么數字 { if(cur==n&&isp(A[0]+A[n-1]))//數字全部填完了且最后一個數加第一個數是一個素數,說明滿足素數環的特征 { for(int i=0;i<n;i++)//打印 printf("%d ",A[i]); printf("\n"); return ; }else { for(int i=2;i<=n;i++) { if(!vis[i]&&isp(i+A[cur-1]))//i這個數沒有被用過(因為素數環中每個數只可以出現一次)且它和前面填的那個數的和是素數 { A[cur]=i;//第cur個空填i vis[i]=1;//標記i用過 dfs(cur+1);//填下一個空 vis[i]=0;//回退 //回退的原因:每次回溯之后,要再進行搜索,要清除上次搜索做過的事情 } } } } int main() { scanf("%d",&n);//1~n個數 A[0]=1;//確定1的位置,要不然很麻煩,因為它是個環,環是可以轉動的,環如果不確定一個數字的位置的話,重復的結果就是很多 dfs(1);//1表示現在確定第一個數字填什么(默認第一個數是1) return 0; }
經典樣例五:馬的遍歷問題
在n*m的棋盤中,馬只能走“日” 字。馬從位置(x, y)處出發,把棋盤的每一格都走一次,且只走一次。找出所有路徑。(象棋棋盤)
這個問題跟油田問題沒有什么區別,都是同樣一類問題(可以說是模板代碼了)
不同的地方:
1.馬只能走日字,而油田問題可以走8個方向,所以需要改一下方向引導數組
Cutting函數:
1.別走出棋盤界限
2.每個點只能走一次
走過的點數==n*m的話,就是找到一種走法了,輸出這種走法
這個樣例我就是不放代碼了,因為這個問題和油田問題都屬於同一類型的問題,模板都是一樣的,要改的地
方主要就是方向引導數組
經典樣例六:圖m的着色問題
題目要求使得每個邊的兩個點都是不同的顏色
題目可以問你至少要多少種顏色才能給圖着色完畢,也可以問你具體的着色方案(本題)
設計數據結構:
用鄰接矩陣存無向圖
x【i】=j,第i個點用第j中顏色
CUtting函數:
條件:
相鄰的點不能使用同一種顏色,即同一條邊的點不能使用同一種顏色
即已經着色過的點的相鄰的點又用同一種顏色,那么這個時候就需要進行Cutting操作了
遞歸結束條件:最后一個點着色完畢,這個時候說明你找到一種着色的方案了,你可以繼續接着找(找全部解的話),也可以直接返回(找任意解的話)
貼個代碼(所有細節,思想在代碼李都有體現,代碼看懂了,然后刷幾道圖m着色的題,這個問題就是掌握了,但是這個問題的變形題的話,就要繼續多刷變形題了)
#include<bits/stdc++.h> using namespace std; #define max_v 105 int a[max_v][max_v]; int x[max_v+1];// x][i]=j 表示點i用j顏色 int sum=0;//着色方案數 int n,m;//點數,顏色數 int ok(int t,int i) { for(int j=1;j<t;j++) { if(a[t][j]&&x[j]==i)//t,j相鄰且同色 return 0;//這是t不能用i着色 } return 1; } void dfs(int t,int m) { if(t>n)//搜到了葉子節點 { sum++; printf("第%d種方案:",sum); for(int i=1;i<=n;i++)//打印具體着色方案 printf("%d ",x[i]); printf("\n"); }else { for(int i=1;i<=m;i++) { if(ok(t,i))//t能用i着色的話 { x[t]=i;//着色 dfs(t+1,m);//搜索給下一個點着什么色 } } } } int main() { scanf("%d %d",&n,&m); memset(a,0,sizeof(a));//初始化置0 for(int i=1;i<=n;i++) { for(int j=1;j<=n;j++) { scanf("%d",&a[i][j]);//構造圖,下標從1開始 } } for(int i=0;i<=n;i++) x[i]=0;//每個點初始化置0色 dfs(1,m);//開始給第一個點置色 if(sum==0) printf("%d種顏色不可着色成功\n",m); return 0; } /* 輸入: 3 3 1 1 1 1 1 1 1 1 1 輸出: 第1種方案:1 2 3 第2種方案:1 3 2 第3種方案:2 1 3 第4種方案:2 3 1 第5種方案:3 1 2 第6種方案:3 2 1 */
總結:DFS+Cutting可以說是明星算法了,用的非常非常多
這些例題僅僅只是DFS+Cutting的入門掃盲題了,雖然很基礎,但是很重要,有的時候也不能一味盲目的刷題
要多總結,回過頭來看看這些基礎的題,對算法的思想的理解就會越來越深,這就是所謂的復盤思想
這些天在對自己學過的算法知識進行系統的整理,進行復盤,發現自己對算法的理解還是太淺薄了,還有很多不懂的地方
明天就要考算法了,希望自己可以考個好成績!