回溯演算法超通俗易懂詳盡分析和例題
回溯法是很重要的一種演算法,在it企業筆試中經常會遇到。事實上,在各種程式設計題中,大家或多或少都會接觸到這些題目,但是很多人沒有對這類題目有個系統性的總結。接下來就對回溯法進行詳盡,通俗易懂的分析。
回溯法有通用解法的美稱,對於很多問題,如迷宮等都有很好的效果。回溯演算法實際上一個類似列舉的深度優先搜尋嘗試過程,主要是在搜尋嘗試過程中尋找問題的解,當發現已不滿足求解條件時,就“回溯”返回(也就是遞迴返回),嘗試別的路徑。許多複雜的,規模較大的問題都可以使用回溯法,有“通用解題方法”的美稱。回溯法說白了就是窮舉法。回溯法一般用遞迴來解決。
回溯法一般都用在要給出多個可以實現最終條件的解的最終形式。回溯法要求對解要新增一些約束條件。總的來說,如果要解決一個回溯法的問題,通常要確定三個元素:
1、選擇。對於每個特定的解,肯定是由一步步構建而來的,而每一步怎麼構建,肯定都是有限個選擇,要怎麼選擇,這個要知道;同時,在程式設計時候要定下,優先或合法的每一步選擇的順序,一般是通過多個if或者for迴圈來排列。
2、條件。對於每個特定的解的某一步,他必然要符合某個解要求符合的條件,如果不符合條件,就要回溯,其實回溯也就是遞迴呼叫的返回。
3、結束。當到達一個特定結束條件時候,就認為這個一步步構建的解是符合要求的解了。把解存下來或者打印出來。對於這一步來說,有時候也可以另外寫一個issolution函式來進行判斷。注意,當到達第三步後,有時候還需要構建一個數據結構,把符合要求的解存起來,便於當得到所有解後,把解空間輸出來。這個資料結構必須是全域性的,作為引數之一傳遞給遞迴函式。
對於回溯法來說,每次遞迴呼叫,很重要的一點是把每次遞迴的不同資訊傳遞給遞迴呼叫的函式。而這裡最重要的要傳遞給遞迴呼叫函式的資訊,就是把上一步做過的某些事情的這個選擇排除,避免重複和無限遞迴。另外還有一個資訊必須傳遞給遞迴函式,就是進行了每一步選擇後,暫時還沒構成完整的解,這個時候前面所有選擇的彙總也要傳遞進去。而且一般情況下,都是能從傳遞給遞迴函式的引數處,得到結束條件的。
遞迴函式的引數的選擇,要遵循四個原則:
1、必須要有一個臨時變數(可以就直接傳遞一個字面量或者常量進去)傳遞不完整的解,因為每一步選擇後,暫時還沒構成完整的解,這個時候這個選擇的不完整解,也要想辦法傳遞給遞迴函式。也就是,把每次遞迴的不同情況傳遞給遞迴呼叫的函式。
2、可以有一個全域性變數,用來儲存完整的每個解,一般是個集合容器(也不一定要有這樣一個變數,因為每次符合結束條件,不完整解就是完整解了,直接列印即可)。
3、最重要的一點,一定要在引數設計中,可以得到結束條件。一個選擇是可以傳遞一個量n,也許是陣列的長度,也許是數量,等等。
4、要保證遞迴函式返回後,狀態可以恢復到遞迴前,以此達到真正回溯。
結合幾個例題來分析。
一、給出n對括號,求括號排列的所有可能性。
這是一個很經典的回溯法問題,程式碼如下,結合程式碼,對上面總結出的特點進行分析。
public class BackTracking {
public static void main(String[] args) {
int n=3;
int leftnum=n,rightnum=n;//左括號和右括號都各有n個
ArrayList<String> results=new ArrayList<String>();//用於存放解空間
parentheses("", results, leftnum, rightnum);
for(String s:results)
System.out.println(s);
}
public static void parentheses(String sublist, ArrayList<String> results, int leftnum, int rightnum){
if(leftnum==0&&rightnum==0)//結束
results.add(sublist);
if(rightnum>leftnum)//選擇和條件。對於不同的if順序,輸出的結果順序是不一樣的,但是構成一樣的解空間
parentheses(sublist+")", results, leftnum, rightnum-1);
if(leftnum>0)
parentheses(sublist+"(", results, leftnum-1, rightnum);
}
}
class Solution {
public:
int num;
vector<string> generateParenthesis(int n) {
vector<string> res;
if(n<=0){
return res;
}
num=n;
generate(0,0,"",res);
return res;
}
// num 代表有多少對
void generate(int left,int right,string str,vector<string> &res){
if(left==num&&right==num){
res.push_back(str);
return ;
}
if(left<num){
generate(left+1,right,str+'(',res);
}
if(right<num&&left>right){
generate(left,right+1,str+')',res);
}
}
};
輸出如下:
()()()
()(())
(())()
(()())
((()))
對於回溯法來說,必須齊備的三要素:
1、選擇。在這個例子中,解就是一個合法的括號組合形式,而選擇無非是放入左括號,還是放入右括號;
2、條件。在這個例子中,選擇是放入左括號,還是放入右括號,是有條件約束的,不是隨便放的。而這個約束就是括號的數量。只有剩下的右括號比左括號多,才能放右括號。只有左括號數量大於0才能放入左括號。這裡if的順序會影響輸出的順序,但是不影響最終解;
3、結束。這裡的結束條件很顯然就是,左右括號都放完了。
回溯法中,引數的設計是一大難點,也是很重要的地方。而遞迴引數的設計要注意的四個點:
1、用了一個空字串來作為臨時變數儲存不完整解;
2、用了一個ArrayList<String> results來存放符合要求的解。在後面可以看到,不一定要這樣做,也可以直接列印結果;
3、把leftnum和rightnum傳入給遞迴函式,這樣可以用於判斷結束條件;
4、這個例子不明顯。但是事實上也符合這個條件。可以仔細觀察程式碼,可以發現由於使用了兩個if,所以當一次遞迴退出後,例如從第一個if退出,第二個遞迴直接遞迴的是leftnum-1和rightnum,這其實是已經恢復狀態了(如果沒有恢復狀態,那就是leftnum, rightnum-1)。因此不需要人為讓他恢復狀態。但是恢復狀態這點是很重要的,因為回溯法,顧名思義要回溯,不恢復狀態,怎麼回溯呢。
if(rightnum>leftnum)//選擇和條件。對於不同的if順序,輸出的結果順序是不一樣的,但是構成一樣的解空間
parentheses(sublist+")", results, leftnum, rightnum-1);
if(leftnum>0)
parentheses(sublist+"(", results, leftnum-1, rightnum);
從後面的例子可以看出,對於一些題,是必須要恢復遞迴前狀態的。
陣列的和為等於target
class Solution {
public:
vector<vector<int> > combinationSum(vector<int> &candidates, int target) {
vector<int> ivec;
vector<vector<int> > res;
if(target<=0||candidates.size()==0){
return res;
}
sort(candidates.begin(),candidates.end());
dfs(candidates,0,target,ivec,res);
return res;
}
void dfs(vector<int> &cond,int k,int target,vector<int> temp,vector<vector<int> > &res){
if(target==0){
res.push_back(temp);
return ;
}else if(target<0){
return ;
}
for(int i=k;i<cond.size();++i){ //不能再往回找了
temp.push_back(cond[i]);
dfs(cond,i,target-cond[i],temp,res);
temp.pop_back();
}
}
};
字串全排列
void permutation(string &str,int k){
if(k==(str.size())){
cout<<str<<endl;
return ;
}
for(int i=k;i<str.size();++i){
swap(str[k],str[i]);
permutation(str,k+1); // 全排列用k // pos++ i++ 全是錯的
swap(str[k],str[i]);
}
}