1. 程式人生 > >回溯演算法超通俗易懂詳盡分析和例題

回溯演算法超通俗易懂詳盡分析和例題

        回溯法是很重要的一種演算法,在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]);
	}
}