目錄
回溯算法簡介
回溯法是一種類似枚舉的搜索嘗試過程,既然是枚舉,那麼就會遍歷解空間樹中的所有解(或者是“路徑”),搜索的過程按照DFS原則,而嘗試就意味着,在遍歷的過程中,有可能到達某一個結點後,發現不能夠滿足約束條件,在這次嘗試中,這條“路”是不優的,將走不通,即無法找到所求的解,那麼就會回退到上一步的狀態,重新作出選擇。如果即滿足約束條件,但是依然沒有獲得有效的解,那麼我們就需要在此基礎上做下一步選擇,即將當前結點當做一個新的根結點。所以經常會使用遞歸的方法。如果一步步下來的選擇結果正好滿足我們所求的問題,那麼就是一個有效的解。
回溯法思想:在包含問題所有解的解空間樹中,按照深度優先搜索的策略,從根結點出發深度搜索解空間樹。當搜索到某一結點時,要先判斷該結點是否包含問題的解,如果包含,就從該結點出發繼續探索下去,如果該結點不包含問題的解,則逐層向其祖先結點回溯。(其實回溯法就是對隱式圖的深度優先搜索算法)。若用回溯法求問題的所有解時,要回溯到根,且根結點的所有可行的子樹都要已被檢索一遍才結束。而若使用回溯法求任一個解時,只要搜索到問題的一個解就可以結束。
若用回溯法求問題的所有解時,要回溯到根,且根結點的所有可行的子樹都要已被搜索遍才結束。
若使用回溯法求任一個解時,只要搜索到問題的一個解就可以結束。
回溯算法中的三個概念
(1)約束函數:約束函數是根據題意定出的。通過描述合法解的一般特徵用於去除不合法的解(即使這個解並不是完整的解),從而避免繼續搜索出這個不合法解的剩餘部分,起到了剪值函數的作用。因此,約束函數是對於解空間樹上的任何節點都有效、等價的。
(2)狀態空間樹:狀態空間樹是一個對所有解的圖形描述。
(3)擴展節點:當前正求出它的子節點的節點,在DFS中,只允許有一個擴展節點。
活節點:通過與約束函數對照,節點本身和其父節點均滿足約束函數要求的節點。
死節點: 與活節點正好相反,死節點是在與約束函數對照中,不滿足約束函數的節點。那麼就不需要求該節點的子節點情況了。
回溯算法的步驟
在回溯法執行時,應當:保存當前步驟,如果是一個解就輸出;維護狀態,使搜索路徑(含子路徑)儘量不重複。必要時,應該對不可能爲解的部分進行剪枝(pruning)。
(1)定義一個解空間,它包含問題的解。明確問題的解空間樹內容,比如,在求組合問題時,最後有效解中元素是否可重複的,元素個數是否是固定的等。且保證問題的解空間中應至少包含問題的一個解。
(2)利用適於搜索的方法組織解空間。確定結點的擴展搜索規則,在回溯法中,就是深度優先遍歷規則。擴展節點就是當前正在求出它的子節點的節點,在DFS中,只允許有一個擴展節點。
(3)利用深度優先法搜索解空間。
(4)利用限界函數避免移動到不可能產生解的子空間。 構造約束條件。作爲剪枝函數,避免無效搜索。
問題的解空間通常是在搜索問題的解的過程中動態產生的,這是回溯算法的一個重要特性。
具體流程
(1)設置初始化的方案(給變量賦初值,讀入已知數據等);
(2)選擇所到深度層(k)中其中的一個節點進行試探,如果k層的節點已經全部都試探完畢,則進入(7);
(3)如果試探不成功(不滿足約束條件),則轉入(2);
(4)如果試探成功,則進入下一個深度層進行試探。
(5)如果正確解還沒找到,則進入(2);
(6)如果找到了正解,如果問題只求其中的一個解,那麼可以退出程序了,如果是求一組解,那麼將該種解存儲(一般用一個類中的變量);
(7)退回上一步的狀態(深度爲k-1時,變量的狀態),如果沒有退到頭,則進入(2);
(8)已退到頭則結束或打印無解。
在使用回溯法解決實際問題時,往往會和遞歸算法結合,更容易解決問題,但是也可以使用非遞歸的方法實現,這就需要具體情況進行分析了。
代碼實現框架
bool finished = FALSE; /* 是否獲得全部解? */
backtrack(int a[], int k, data input)
{
int c[MAXCANDIDATES]; /*這次搜索的候選 */
int ncandidates; /* 候選數目 */
int i; /* counter */
if (is_a_solution(a,k,input))
process_solution(a,k,input);
else {
k = k+1;
construct_candidates(a,k,input,c,&ncandidates);
for (i=0; i<ncandidates; i++) {
a[k] = c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if (finished) return; /* 如果符合終止條件就提前退出 */
}
}
}
對於其中的函數和變量,解釋如下:
a[]表示當前獲得的部分解;
k表示搜索深度;
input表示用於傳遞的更多的參數;
is_a_solution(a,k,input)判斷當前的部分解向量a[1...k]是否是一個符合條件的解
construct_candidates(a,k,input,c,ncandidates)根據目前狀態,構造這一步可能的選擇,存入c[]數組,其長度存ncandidates
process_solution(a,k,input)對於符合條件的解進行處理,通常是輸出、計數等
make_move(a,k,input)和unmake_move(a,k,input)前者將採取的選擇更新到原始數據結構上,後者把這一行爲撤銷。
經典例子
選取數字集合
做一個白話版的描述,給你兩個整數 n和k,從1-n中選擇k個數字的組合。比如n=4,k=2,那麼從1,2,3,4中選取兩個數字的組合
可以看到上面的每個變量都出現了,用一個類變量保存結果
public class Solution {
List<List<Integer>> result=new ArrayList<List<Integer>>();
public List<List<Integer>> combine(int n, int k) {
List<Integer> list=new ArrayList<Integer>();
backtracking(n,k,1,list);
return result;
}
public void backtracking(int n,int k,int start,List<Integer>list){
if(k<0) return ;
else if(k==0){
result.add(new ArrayList(list));
}else{
for(int i=start;i<=n;i++){
list.add(i);
backtracking(n,k-1,i+1,list);
list.remove(list.size()-1);
}
}
}
}
皇后問題
N皇后問題是指在N*N的棋盤上放置N個皇后,使這N個皇后無法吃掉對方(也就是說兩兩不在一行,不在一列,也不在對角線上)。經典的是8皇后問題,這裏我們爲了簡單,以4皇后爲例。
首先利用回溯算法,先給第一個皇后安排位置,如下圖所示,安排在(1,1)然後給第二個皇后安排位置,可知(2,1),(2,2)都會產生衝突,因此可以安排在(2,3),然後安排第三個皇后,在第三行沒有合適的位置,因此回溯到第二個皇后,重新安排第二個皇后的位置,安排到(2,4),然後安排第三個皇后到(3,2),安排第四個皇后有衝突,因此要回溯到第三個皇后,可知第三個皇后也就僅此一個位置,無處可改,故繼續向上回溯到第二個皇后,也沒有位置可更改,因此回溯到第一個皇后,更改第一個皇后的位置,繼續上面的做法,直至找到所有皇后的位置,如下圖所示。
這裏爲什麼我們用4皇后做例子呢?因爲3皇后是無解的。同時我們也可以看到回溯算法雖然也是Brute-Force,但是它可以避免去搜索很多的不可能的情況,因此算法是優於Brute-Force的。
這裏是每次確定第col行的棋子的位置
public class NQueensII {
int[] x;//當前解
int N;//皇后個數
int sum = 0;//當前已找到的可行方案數
public int totalNQueens(int n) {
N = n;
x = new int[N+1];
backTrace(1);
return sum;
}
/**
* col行這個點,x[col]列這個點,與已經存在的幾個皇后,是否符合要求,放到這個位置上,
* @param col
* @return
*/
private boolean place(int col){
for(int i = 1; i < col; i++){
if(Math.abs(col - i)==Math.abs(x[col]-x[i])||x[col]==x[i]){
return false;
}
}
return true;
}
private void backTrace(int t) {
if(t>N){
sum++;
}else {
//第t行,遍歷所有的節點
for(int j = 1; j <= N; j++) {
x[t] = j ;
//如果第j個節點可以放下皇后
if(place(t)){
//接着放下一個
backTrace(t+1);
}
}
}
}
public static void main(String[] args) {
NQueensII n = new NQueensII();
System.out.println(n.totalNQueens(8));
}
}
最大 k 乘積問題
設I是一個 n 位十進制整數。如果將 I 劃分爲 k 段,則可得到 k 個整數。這 k 個整數的乘積稱爲 I 的一個 k 乘積。試設計一個算法,對於給定的 I 和 k ,求出 I 的最大 k 乘積。
import java.util.Scanner;
public class 最大k乘積 {
private static long ans;
private static Scanner cin;
static{
cin = new Scanner(System.in);
}
static void work(int cur, int i, int k, long v){
//System.out.println("i = " + i + " cur = " + cur + " k = " + k);
if(i == k){
ans = Math.max(ans, v);
return;
}
if(cur == 0){
return;
}
int MOD = 1;
while(cur / MOD != 0){
work(cur % MOD, i + 1, k, v * (cur / MOD));
MOD *= 10;
}
}
public static void main(String[] args) {
int num, k;
System.out.println("請輸入數字num和要分成的段數k: ");
while(cin.hasNext()){
num = cin.nextInt();
k = cin.nextInt();
ans = Long.MIN_VALUE;
work(num, 0, k, 1L);
if(ans == Long.MIN_VALUE){
System.out.println("整數" + num + "不能被分成" + k + "段");
}else{
System.out.println(num + "的最大" + k + "乘積是: " + ans);
}
System.out.println("請輸入數字num和要分成的段數k: ");
}
}
}