回溯法解決喝酒問題 (轉)

worldblog發表於2007-12-12
回溯法解決喝酒問題 (轉)[@more@]

回溯法解決喝酒問題

先介紹一下回溯法的理論:

可用回溯法解決的問題P,通常能夠表達為:

對於已知的、由n元組(x1,x2,……,xn)組成的一個狀態空間 E={(x1,x2,……,xn) | xi ∈Si, i=1,2,..,n},給定關於n元組中的分量的一個集D,要求E中滿足D的全部約束的所有n元組。其中Si是分量xi的定義域且|Si|有限,i=1,2,...n。我們稱E中滿足D的全部約束條件的任一n元組為問題P的一個解。

對於n元組(x1,x2,……,xn)中分量的約束,一般分為兩類,一類是顯約束,它給出對於n元組中分量的顯式限制,比如當i≠j時xi≠xj;另一類是隱約束,它給出對於n元組中分量的隱式限制,比如:f(x1,x2,……,xn)≠ 0,其中f是隱。不過隱式顯式並不絕對,兩者可以相互轉換。

解問題P的最樸素的方法是窮舉法,即對E中的所有n元組,逐一地檢測其是否滿足D的全部約束。全部滿足,才是問題p的解;只要有一個不滿足,就不是問題P的解。顯然,如果記m(i)=|S(i+1)|,i=0,1,...n-1,那麼,窮舉法需要對m=m(0)*m(1)*...*m(n-1)個n元組一個不漏地加以檢測。可想而知,其計算量是非常之大的。

我們發現,對於許多問題,所給定的約束集D具有完備性,即i元組(x1,x2,……,xi)滿足D中僅涉及到x1,x2,……,xi的所有約束意味著j(j

這個發現告訴我們,對於約束集D具有完備性的問題P,一旦檢測斷定某個j元組(x1,x2,……,xj)違反D中僅涉及x1,x2,……,xj的一個約束,就可以肯定,以(x1,x2,……,xj)為字首的任何n元組(x1,x2,……,xj,……,xn)都不會是問題的解,因而就不必去搜尋它們、檢測它們。回溯法正是針對這類問題,利用這類問題的上述性質而提出來的比窮舉法高得多的演算法。

回溯法首先將問題P的n元組的狀態空間E表示成一棵高為n的帶權有序樹T,把在E中求問題P的所有解轉化為在T中搜尋問題P的所有解。樹T類似於檢索樹。它可這樣構造:設Si中的元素可排成x(i,1),x(i,2),……,x(i,m(i-1)),i=1,2,……,n。從根開始,讓T的第i層的每一個結點都有m(i)個兒子。這m(i)個兒子到它們的共同父親的邊,按從左到右的次序分別帶權x(i+1,1),x(i+1,2),……,x(i+1,m(i)),i=0,1,2,……,n-1。照這種構造方式,E中的一個n元組(x1,x2,……,xn)對應於T中的一個葉結點,T的根到這個葉結點的路上依次的n條邊分別以x1,x2,……,xn為其權,反之亦然。另外,對於任意的0≤i≤n-1,E中n元組(x1,x2,……,xn)的一個字首i元組(x1,x2,……,xi)對應於T中的一個非葉結點,T的根到這個非葉結點的路上依次的i條邊分別以了x1,x2,……,xi為其權,反之亦然。特別,E中的任意一個n元組的空字首(),對應於T的根。

因而,在E中尋找問題P的一個解等價於在T中搜尋一個葉結點,要求從T的根到該葉結點的路上依次的n條邊相應帶的n個權x1,x2,……,xn滿足約束集D的全部約束。在T中搜尋所要求的葉結點,很自然的一種方式是從根出發逐步深入,讓路逐步延伸,即依次搜尋滿足約柬條件的字首1元組(xl),字首2元組(xl,x2),字首i元組(x1,x2,……,xi),……,直到i=n為止。注意,在這裡,我們把(x1,x2,……,xi)應該滿足的D中僅涉及x1,x2,……,xi的所有約束當做判斷(x1,x2,……,xi)是問題p的解的必要條件,只有當這個必要條件加上條件i=n才是充要條件。為了區別,我們稱使積累的判別條件成為充要條件的那個條件(如條件i=n)為終結條件。

在回溯法中,上面引入的樹T被稱為問題P的狀態空間樹;樹T上的任意一個結點被稱為問題p的狀態結點;樹T上的任意一個葉結點被稱為問題P的一個解狀態結點;樹T上滿足約束集D的全部約柬的任意一個葉結點被稱為問題P的一個回答狀態結點,簡稱為回答結點或回答狀態,它對應於問題P的一個解。

例如8皇后問題,就是要確定一個8元組(x1,x2,..,x8),xi表示第i行的皇后所在的列,這樣的問題很容易應用上面的搜尋樹模型;然而,有些問題的解無法表示成一個n元組,因為事先無法確定這個n是多少,比如這個喝酒問題,問題的解就是一系列的倒酒喝酒策略,但是事先無法確定究竟需要進行多少步;還有著名的8數碼問題(文曲星上的那個9x9方格中移數字的遊戲),那個問題也是預先不知道需要移動多少步才能達到目標。不過這並不影響回溯法的使用,只要該問題有解,一定可以將解用有限的變元來表示,我們可以假設n就是問題的一個解的變元的個數,這樣就可以繼續利用上面的搜尋樹模型了。事實上,這棵搜尋樹並非預先生成的,而是在搜尋的過程中逐步生成的,所以不知道樹的深度n並不影響在樹中搜尋葉子節點。但是有一點很重要,如果問題根本不存在有限的解,或者問題的狀態空間無窮大,那麼沿著某條道路從根出發搜尋葉節點,可能永遠無法達到葉結點,因為搜尋樹會不斷地擴充套件,然而葉結點也許是確實存在的,只要換一條道路就可以找到,只不過一開始就走錯了路,而這條錯路是永遠無法終止的。為了避免這種情況我們一般都規定狀態空間是有限的,這樣即使搜尋整個狀態空間的每個狀態也可以在有限時間內完成,否則的話回溯法很可能不適用。

搜尋樹的每一個節點表示一個狀態,節點i要生成節點j必須滿足約束集D中的約束條件,我們也可以將這個約束條件稱為“狀態轉移規則”或者“產生規則”(意指從節點i產生節點j的規則,這是從“產生式”理論的角度來解釋回溯法)。因此回溯法的實質是在一個狀態空間中,從起始狀態(搜尋樹的根)搜尋到一條到達目標狀態(搜尋樹的葉結點)的路徑(就和走迷宮差不多,這是從圖論的角度來解釋回溯法)。一般來說,為了防止搜尋的過程中出現迴路,必須記錄已經走過的節點(狀態),在同一條路徑中不能重複走過的節點(狀態),這樣只要狀態空間是有限的,回溯法總是可以終止的。

===========================================================================================

下面我們就根據回溯法來解決這個喝酒問題

(1)狀態的表示
一個狀態用一個7元組表示 X=(x1,x2,x3,x4,x5,x6,x7);,其中x1~x3分別表示a,b,c三個酒瓶中的酒,x4~x7分別表示A,B,C,D四個人已經喝的酒;

(2)約束條件
1。每個人喝的酒不能超過4兩;
2。每個瓶中容納的酒不能超過該瓶的容量;
為了方便設第k個人喝的酒不超過C[k], 第i個酒瓶的容量為C[i], 則
C[1]=C[2]=8, C[3]=3, C[4]=C[5]=C[6]=C[7]=4;
約束條件為
 0<= X[i] <= C[i];

(3)狀態的轉移規則(狀態產生規則)
從某個狀態X轉移到另一個狀態Y有以下幾種情況:
1。i瓶中的酒倒入j瓶中,並將j瓶裝滿:  Y[i] = X[i] - (C[j]-X[j]) ,  Y[j] = C[j],  i,j∈[1,3]
2。i瓶中的酒倒入j瓶中,並將i瓶倒空:  Y[i] = 0 ,  Y[j] = X[j] + X[i]  ,  i,j∈[1,3] 
3。某個人j喝光了i瓶中的酒: Y[i] = 0; Y[j] = X[j] +  X[i],  i∈[1,3], j∈[4,7]
當然狀態Y必須滿足(2)中的約束條件;

(4)初始狀態
a,b兩個瓶中裝滿酒,c中為空:  X0[1]=C[1], X0[2]=C[2], X0[3]=C[3], X0[4]=X0[5]=X0[6]=X0[7]=0;

(5)目標狀態
所有的瓶中的酒為空,每個人都喝飽了酒:  Xn[1]=Xn[2]=Xn[3]=0 , Xn[4]=C[4],  Xn[5]=C[5], Xn[6]=C[6], Xn[7]=C[7];


下面給出一個通用的回溯法虛擬碼:

void DFS_TRY( s )
{
  if (狀態s是目標狀態) {
  列印結果;
  退出;  // 如果要求輸出所有的解,這裡退出函式,如果只要求輸出一組解,這裡退出整個
  }
  for 從狀態s根據產生規則產生的每個狀態t
  if (t不在堆疊中) { 
  狀態t壓入堆疊;
  DFS_TRY(t);
  狀態t彈出堆疊;
  }
}

主程式為:

初始狀態s0壓入堆疊;
DFS_TRY(s0);


然而,對於這個問題,如果單純地用上面的回溯法解決效率非常的低,幾乎無法忍受。所以要改進一下。我們注意到每個狀態是一個7元組,而且根據約束條件,所有的合法的狀態的個數是8*8*3*4*4*4*4 =49152個,完全可以將所有的狀態記錄下來,即使窮舉所有的狀態也是可以忍受的。所以在上面的DFS_TRY中,我們不是在堆疊中尋找已經搜尋過的狀態,而是在一個狀態表中找已經搜尋過的狀態,如果某個狀態在狀態表中的標誌表明該狀態已經搜尋過了,就沒有必要再搜尋一遍。比如,單純的回溯法搜尋出來的搜尋樹如下所示:

  a
  /
  / 
  b  c
    /
  /
  d
  /
  / 

從a出發,搜尋 a - b - d - ... 然後回溯到a, 又搜尋到 a - c - d - ..., 因為d在搜尋的路徑上並沒有重複,所以在堆疊中是發現不了d節點被重複搜尋的,這樣就重複搜尋了d和它的子樹;如果用一個表格紀錄每個節點是否被搜尋過了,這樣搜尋 a - b - d - ...回溯到a, 又搜尋到 a - c - d ,這時候查表發現d已經搜尋過了,就可以不用再搜尋d和它的子樹了。

這種用一個表格來記錄狀態的搜尋策略叫做“備忘錄法”,是動態規劃的一種變形,關於動態規劃和備忘錄法,請參見:


備忘錄法的虛擬碼:

bool Memoire_TRY( s )
{
  if (狀態s是目標狀態) {
  記錄狀態s;
  return true;  // 這裡假設只要求輸出一組解
  }
  for 從狀態s根據產生規則產生的每個狀態t
  if (狀態t沒有被搜尋過) { // 注意這裡的改變
  標記狀態t被訪問過;
 if (DFS_TRY(t)) {
  記錄狀態s;
  return true;
 } 
  }
  return false;
}

主程式為:

初始化設定狀態表中的所有狀態未被訪問過
初始狀態設為s0;
if (Memoire_TRY(s0))
  列印記錄下來的解;

這樣就不需要自己設定堆疊了,但是需要維護一個狀態訪問表。

下面是按照這種思路寫的程式,注意,求出來的不是最優解,但是很容易修改該程式求出最優解。

#include
#include

const int CUP_COUNT  = 3; // 酒杯的數目
const int STATE_COUNT = 7; // 狀態變數的維數
typedef int State[STATE_COUNT]; // 記錄狀態的型別
const State CONSTR = {8, 8, 3, 4, 4, 4, 4}; // 約束條件
const State START = {8, 8, 0, 0, 0, 0, 0}; // 初始狀態
const State GOAL = {0, 0, 0, 4, 4, 4, 4}; // 目標狀態
const int MAX_STATE_COUNT = 10*10*10*10*10*10*10; //態空間的狀態數目
const MAX_STEP = 50; // 假設最多需要50步就可以找到目標

const State key = {3, 5, 3, 3, 2, 0, 0};

bool visited[MAX_STATE_COUNT]; // 用來標記訪問過的狀態

State result[MAX_STEP]; // 記錄結果;
int step_count = 0; // 達到目標所用的步數


// 計算狀態s在狀態表中的位置
int pos(const State &s)
{
 int p = 0;
 for (int i=0; i p = p*10 + s[i];
 }
 return p;
}

// 判斷狀態a,b是否相等
bool equal(const State &a, const State &b) {
 for (int i=0; i if (a[i]!=b[i]) return false;
 return true;
}

void printState(const State &s) {
 for (int i=0; i cout << s[i] << " ";
 cout << endl;
}

// 備忘錄法搜尋
bool Memoire_TRY(const State &s, int step)
{
 if (memcmp(s,GOAL,sizeof(s))==0) { // 如果是目標狀態
 step_count = step; 
 memcpy(result[step-1],s, sizeof(s)); //  記錄狀態s
 return true;
 }
 
 int i, j;

 // 第一種規則,第i個人喝光杯子j中的酒
 for (i=CUP_COUNT; i if (s[i] < CONSTR[i]) // 如果第i個人還可以喝
 for (j=0; j if (s[j]>0 && s[i] + s[j] <= CONSTR[i]) { // 如果第i個人可以喝光第j杯中的酒
 State t;
 memcpy(t, s, sizeof(s));
 t[i] += t[j]; // 第i個人喝光第j杯的酒
 t[j] = 0;
 int tmp = pos(t);
 if (!visited[pos(t)]) { // 如果狀態t沒有訪問過
 visited[pos(t)] =true; // 標記狀態t訪問過了
 if (Memoire_TRY(t, step+1)) { // 從狀態t出發搜尋
 memcpy(result[step-1],s, sizeof(s)); //  記錄狀態s
 return true;
 } // end of if (Memoire_TRY(t, step+1))
 } // end of if (!visited[pos(t)])
 } // end of if (s[i] + s[j] <= CONSTR[i])

 // 第二種規則,將杯子i中的酒倒入到杯子j中去
 for (i=0; i for (j=0; j if (i != j) { 
 int k = (CONSTR[j] - s[j] < s[i] ? CONSTR[j] - s[j] : s[i] ); // 計算出可以從i中倒入j中的酒的數量
 if (k > 0) { // 如果可以倒 
 State t; // 生成新的狀態t
 memcpy(t, s, sizeof(s));
 t[i] -= k;
 t[j] += k; 
 int tmp = pos(t);
 if (!visited[pos(t)]) { // 如果狀態t沒有訪問過
 visited[pos(t)] =true; // 標記狀態t訪問過了
 if (Memoire_TRY(t, step+1)) { // 從狀態t出發搜尋
 memcpy(result[step-1],s, sizeof(s)); //  記錄狀態s
 return true;
 } // end of if (Memoire_TRY(t, step+1))
 } // end of if (!visited[pos(t)])
 } // end of if (k > 0)
 } // end of if (i != j)

 return false;
} // end of Memoire_TRY

 

void main()
{
 memset(visited, false, sizeof(visited));
 if (Memoire_TRY(START,1)) {
 cout << "find a solution: " << endl;
 for (int i=0; i for (int j=0; j cout << result[i][j] << " ";
 cout << endl;
 }
 } else
 cout << "no solution." << endl;
}


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-991971/,如需轉載,請註明出處,否則將追究法律責任。

相關文章