回 溯 法
回溯算法實際是一個類似枚舉的搜索嘗試方法,它的主題思想是在搜索嘗試中找問題的解,當不滿足求解條件就”回溯”返回,嘗試別的路徑。回溯算法是嘗試搜索算法中最為基本的一種算法,其采用了一種“走不通就掉頭”的思想,作為其控制結構。
【例1】八皇后問題模型建立
要在8*8的國際象棋棋盤中放八個皇后,使任意兩個皇后都不能互相吃掉。規則:皇后能吃掉同一行、同一列、同一對角線的任意棋子。如圖5-12為一種方案,求所有的解。
模型建立
不妨設八個皇后為xi,她們分別在第i行(i=1,2,3,4……,8),這樣問題的解空間,就是一個八個皇后所在列的序號,為n元一維向量(x1,x2,x3,x4,x5,x6,x7,x8),搜索空間是1≤xi≤8(i=1,2,3,4……,8),共88個狀態。約束條件是八個(1,x1),(2,x2) ,(3,x3),(4,x4) ,(5,x5), (6,x6) , (7,x7), (8,x8)不在同一行、同一列和同一對角線上。
雖然問題共有88個狀態,但算法不會真正地搜索這么多的狀態,因為前面已經說明,回溯法采用的是“走不通就掉頭”的策略,而形如(1,1,x3,x4, x5,x6,x7,x8)的狀態共有86個,由於1,2號皇后
在同一列不滿足約束條件,回溯后這86個狀態是不會搜索的。
算法設計1:加約束條件的枚舉算法
最簡單的算法就是通過八重循環模擬搜索空間中的88個狀態,按深度優先思想,從第一個皇后從第一列開始搜索,每前進一步檢查是否滿足約束條件,不滿足時,用continue語句回溯,滿足滿足約束條件,開始下一層循環,直到找出問題的解。
約束條件不在同一列的表達式為xi xj;而在同一主對角線上時xi-i=xj-j, 在同一負對角線上時xi+i=xj+j,因此,不在同一對角線上的約束條件表示為abs(xi-xj) abs(i-j)(abs()取絕對值)。
算法1:
queen1( ) {int a[9]; for (a[1]=1;a[1]<=8;a[1]++) for (a[2]=1;a[2]<=8;a[2]++) {if ( check(a,2)=0 ) continue; for (a[3]=1;a[3]<=8;a[3]++) {if(check(a,3)=0) continue; for (a[4]=1;a[4]<=8;a[4]++) {if (check(a,4)=0) continue; for (a[5]=1;a[5]<=8;a[5]++) {if (check(a,5)=0) continue; for (a[6]=1;a[6]<=8;a[6]++) {if (check(a,6)=0) continue; for(a[7]=1;a[7]<=8;a[7]++) {if (check(a,7)=0) continue; for(a[8]=1;a[8]<=8;a[8]++) {if (check(a,8)=0) continue; else for(i=1;i<=8;i++) print(a[i]); } } } } } } } } check(int a[ ],int n) {int i; for(i=1;i<=n-1;i++) if (abs(a[i]-a[n])=abs(i-n)) or (a[i]=a[n]) return(0); return(1); }
算法分析1:
若將算法中循環嵌套間的檢查是否滿足約束條件的:
“if (check(a[],i)=0)continue;
i=2,3,4,5,6,7“
語句都去掉,只保留最后一個檢查語句:
“if (check(a[],8)=0)continue;”
相應地check()函數修改成:
check*(a[],n)
{int i,j;
for(i=2;i<=n;i++)
for(j=1;j<=i-1;j++)
if(abs(a[i]-a[j])=abs(i-j))or(a[i]=a[j])
return(0);
return(1);
}
則算法退化成完全的盲目搜索,復雜性就是88了
算法設計2:非遞歸回溯算法
以上的枚舉算法可讀性很好,但它只能解決八皇后問題,而不能解決任意的n皇后問題。下面的非遞歸算法可以說是典型的回溯算法模型。
算法2:
int a[20],n; queen2( ) { input(n); backdate(n); } backdate (int n) { int k; a[1]=0; k=1; while( k>0 ) {a[k]=a[k]+1; while ((a[k]<=n) and (check(k)=0)) /搜索第k個皇后位置/ a[k]=a[k]+1; if( a[k]<=n) if(k=n ) output(n); / 找到一組解/ else {k=k+1; 繼續為第k+1個皇后找到位置/ a[k]=0;}/注意下一個皇后一定要從頭開始搜索/ else k=k-1; /回溯/ } } check(int k) { int i; for(i=1;i<=k-1;i++) if (abs(a[i]-a[k])=abs(i-k)) or (a[i]=a[k]) return(0); return(1); } output( ) { int i; for(i=1;i<=n;i++) print(a[i]); }
算法設計3:遞歸算法
這種方式也可以解決任意的n皇后問題。
這里我們用第三章3.2.3 “利用數組記錄狀態信息”的技巧,用三個數組c,b,d分別記錄棋盤上的n個列、n個主對角線和n個負對角線的占用情況。
以四階棋盤為例,如圖5-13,共有2n-1=7個主對角線,對應地也有7個負對角線。
用i,j分別表示皇后所在的行列,同一主對角線上的行列下標的差一樣,若用表達式i-j編號,則范圍為-n+1——n-1,所以我們用表達式i-j+n對主對角線編號,范圍就是1——2n-1。同樣地,負對角線上行列下標的和一樣,用表達式i+j編號,則范圍為2——2n。
算法3:
int a[20],b[20],c[40],d[40]; int n,t,i,j,k; /t記錄解的個數/ queen3( ) { int i, input(n); for(i=1;i<=n;i++) { b[i]=0; c[i]=0; c[n+i]=0; d[i]=0; d[n+i]=0; } try(1); } try(int i) {int j; for(j=1;j<=n;j++) /第i個皇后有n種可能位置/ if (b[j]=0) and (c[i+j]=0) and (d[i-j+n]=0) {a[i]=j; /擺放皇后/ b[j]=1; /占領第j列/ c[i+j]=1; d[i-j+n]=1; /占領兩個對角線/ if (i<n) try(i+1); /n個皇后沒有擺完,遞歸擺放下一皇后/ else output( ); /完成任務,打印結果/ b[j]=0; c[i+j]=0; d[i-j+n]=0; /回溯/ } } output( ) { t=t+1; print(t,' '); for( k=1;k<=n;k++) print(a[k],' '); print(“ 換行符”); }
遞歸算法的回溯是由函數調用結束自動完成的,也不需要指出回溯點,但也需要“清理現場”——將當前點占用的位置釋放,也就是算法try()中的后三個賦值語句。
1. 回溯法基本思想
回溯法是在包含問題的所有解的解空間樹中。按照深度優先的策略,從根結點出發搜索解空間樹,算法搜索至解空間樹的任一結點時,總是先判斷該結點是否滿足問題的約束條件。如果滿足進入該子樹,繼續按深度優先的策略進行搜索。否則,不去搜索以該結點為根的子樹,而是逐層向其祖先結點回溯。
回溯法就是對隱式圖的深度優先搜索算法。
如圖5-14是四皇后問題的搜索過程
圖5-14四皇后問題的解空間樹
2.算法設計過程
1)確定問題的解空間
問題的解空間應只至少包含問題的一個解。
2)確定結點的擴展規則
如每個皇后在一行中的不同位置移動,而象棋中的馬只能走“日”字等。
3) 搜索解空間
回溯算法從開始結點出發,以深度優先的方式搜索整個解空間。這個開始結點就成為一個活結點,同時也成為當前的擴展結點。在當前的擴展結點處,搜索向縱深方向移至一個新結點。這個新結點就成為一個新的活結點,並成為當前擴展結點。如果在當前的擴展結點處不能再向縱深方向移動,則當前擴展結點就成為死結點。此時,應往回移動至最近的一個活結點處,並使這個活結點成為當前的擴展結點。回溯法即以這種工作方式遞歸地在解空間中搜索,直至找到所要求的解或解空間中已沒有活結點時為止。
3.算法框架
1)問題框架
設問題的解是一個n維向量(a1,a2,……,an),約束條件是ai(i=1,2,3……n)之間滿足某種條件,記為f(ai)
2)非遞歸回溯框架
int a[n],i;
初始化數組a[ ];
i=1;
While (i>0(有路可走)) and ([未達到目標]) /還未回溯到頭/
{if (i>n) /正在處理第i個元素/
搜索到一個解,輸出;
else
{a[i]第一個可能的值;
while (a[i]在不滿足約束條件 且 在在搜索空間內)
a[i]下一個可能的值;
if (a[i]在搜索空間內)
{標識占用的資源; i=i+1;} /擴展下一個結點/
else {清理所占的狀態空間; i=i-1;}/回溯/
}}
3)遞歸算法框架
一般情況下用遞歸函數來實現回溯法比較簡單,其中i為搜索深度。
int a[n];
try(int i)
{if (i>n) 輸出結果;
else
for( j=下界 ; j<=上界; j++) /枚舉i所有可能的路徑/
{ if ( f(j) ) /滿足限界函數和約束條件/
{ a[i]=j;
…… /其它操作/
try(i+ 1);}
}
回溯前的清理工作(如a[i]置空值等);
}
}
應用1 ——基本的回溯搜索
【例2】馬的遍歷問題
在n*m的棋盤中,馬只能走日字。馬從位置(x,y)處出發,把棋盤的每一格都走一次,且只走一次。找出所有路徑。
1、問題分析
馬是在棋盤的點上行走的,所以這里的棋盤是指行有N條邊、列有M條邊。而一個馬在不出邊界的情況下有八個方向可以行走,如當前坐標為(x,y)則行走后的坐標可以為:
(x+1,y+2) ,( x+1, y-2),( x+2, y+1),
( x+2, y-1),(x-1, y -2),(x -1, y+2),
( x -2, y -1),( x -2, y+1)
2、算法設計
搜索空間是整個n*m個棋盤上的點。約束條件是不出邊界且每個點只經過一次。結點的擴展規則如問題分析中所述。
搜索過程是從任一點(x,y)出發,按深度優先的原則,從八個方向中嘗試一個可以走的點,直到走過棋盤上所有n*m個點。用遞歸算法易實現此過程。
注意問題要求找出全部可能的解,就要注意回溯過程的清理現場工作,也就是置當前位置為未經過。
3、數據結構設計
1)用一個變量dep記錄遞歸深度,也就是走過的點數,當dep=n*m時,找到一組解。
2)用n*m的二維數組記錄馬行走的過程,初始值為0表示未經過。搜索完畢后,起點存儲的是“1”,終點存儲的是 “n*m”。
4、算法
int n=5 , m=4, dep , i, x , y , count; int fx[8]={1,2,2,1,-1,-2,-2,-1} , fy[8]={2,1,-1,-2,-2,-1,1,2} , a[n][m]; main( ) {count=0; dep=1; print('input x,y'); input(x,y); if (y>n or x>m or x<1 or y<1) { print('x,y error!'); return;} for(i=1;i<=;i++) for(j=1;j<=;j++) a[i][j]=0; a[x][y]=1; find(x,y,2); if (count=0 ) print(“No answer!”); else print(“count=!”,count); } find(int y,int x,int dep) {int i , xx , yy ; for i=1 to 8 do /加上方向增量,形成新的坐標/ {xx=x+fx[i]; yy=y+fy[i]; if (check(xx,yy)=1) /判斷新坐標是否出界,是否已走過?/ {a[xx,yy]=dep; /走向新的坐標/ if (dep=n*m) output( ); else find(xx,yy,dep+1); /從新坐標出發,遞歸下一層/ a[xx,yy]=0; /回溯,恢復未走標志/ } } } output( ) { count=count+1; print(“換行符”); print('count=',count); for y=1 to n do {print(“換行符”); for x=1 to m do print(a[y,x]:3); } }
【例3】素數環問題
把從1到20這20個數擺成一個環,要求相鄰的兩個數的和是一個素數。
1、算法設計
嘗試搜索從1開始,每個空位有2——20共19種可能,約束條件就是填進去的數滿足:與前面的數不相同;與前面相鄰數據的和是一個素數。第20個數還要判斷和第1個數的和是否素數。
2、算法
main() { int a[20],k; for (k=1;k<=20;k++) a[k]=0; a[1]=1; try(2); } try(int i) { int k for (k=2;k<=20;k++) if (check1(k,i)=1 and check3(k,i)=1 ) { a[i]=k; if (i=20) output( ); else {try(i+1); a[i]=0;} } } check1(int j,int i) { int k; for (k=1;k<=i-1;k++) if (a[k]=j ) return(0); return(1); } check2(int x) { int k,n; n= sqrt(x); for (k=2;k<=n;k++) if (x mod k=0 ) return(0); return(1); } check3(int j,int i) { if (i<20) return(check2(j+a[i-1])); else return(check2(j+a[i-1]) and check2(j+a[1])); } output( ) { int k; for (k=1;k<=20;k++) print(a[k]); print(“換行符”); }
3、算法說明
這個算法中也要注意在回溯前要“清理現場”,也就是置a[i]為0。
【例4】找n個數中r個數的組合。
1、算法設計 先分析數據的特點,以n=5,r=3為例
在數組a中: a[1] a[2] a[3]
5 4 3
5 4 2
5 4 1
5 3 2
5 3 1
5 2 1
4 3 2
4 3 1
4 2 1
3 2 1
分析數據的特點,搜索時依次對數組(一維向量)元素a[1]、a[2]、a[3]進行嘗試,
a[ri] i1——i2
a[1]嘗試范圍5——3
a[2]嘗試范圍4——2
a[3]嘗試范圍3——1
且有這樣的規律:“后一個元素至少比前一個數小1”,ri+i2均為4。
歸納為一般情況:
a[1]嘗試范圍n——r,a[2]嘗試范圍n-1——r-1,……,a[r]嘗試范圍r——1.
由此,搜索過程中的約束條件為ri+a[ri]>=r+1,若ri+a[ri]<r就要回溯到元素a[ri-1]搜索,特別地a[r]=1時,回溯到元素a[r-1]搜索。
2、算法
main( ); { int n,r,a[20] ; print(“n,r=”); input(n,r); if (r>n) print(“Input n,r error!”); else {a[0]=r; comb(n,r);} /調用遞歸過程/ } comb2(int n,int r,int a[]) {int i,ri; ri=1; a[1]=n; while(a[1]<>r-1) if (ri<>r) /沒有搜索到底/ if (ri+a[ri]>=r+1){a[ri+1]=a[ri]-1; ri=ri+1;} else {ri=ri-1; a[ri]=a[ri]-1;} /回溯/ else {for (j=1;j<=r;j++) print(a[j]); print(“換行符”); /輸出組合數/ if (a[r]=1) {ri=ri-1;a[ri]=a[ri]-1;} /回溯/ else a[ri]=a[ri]-1; /搜索到下一個數/ } }
應用2——排列及排列樹的回溯搜索
【例5】輸出自然數1到n所有不重復的排列,即n的全排列
1、算法設計
n的全排列是一組n元一維向量:(x1,x2,x3,……,xn),搜索空間是:1≤xi≤n i=1,2,3,……n,約束條件很簡單,xi互不相同。
這里我們采用第三章“3.2.3 利用數組記錄狀態信息”的技巧,設置n個元素的數組d,其中的n個元素用來記錄數據1——n的使用情況,已使用置1,未使用置0。
2、算法
main( ) { int j,n, print(‘Input n=’ ‘); input(n); for(j=1;j<=n;j++) d[j]=0; try(1); } try(int k) { int j; for(j=1;j<=n;j++) {if (d[j]=0) {a[k]=j; d[j]=1;} else continue; if (k<n) try(k+1); else {p=p+1; output(k);} d[a[k]]=0; } } output( ) { int j; print(p,”:”) for(j=1;j<=n;j++) print(a[j]); print(“換行符”); }
3、算法說明:變量p記錄排列的組數,k為當前處理的第k個元素
4、算法分析
全排列問題的復雜度為O(nn),不是一個好的算法。因此不可能用它的結果去搜索排列樹。
【例6】全排列算法另一解法——搜索排列樹的算法框架
1、算法設計
根據全排列的概念,定義數組初始值為(1,2,3,4,……,n),這是全排列中的一種,然后通過數據間的交換,則可產生所有的不同排列。
2、算法
int a[100],n,s=0; main( ) { int i,; input(n); for(i=1;i<=n;i++) a[i]=i; try(1); print(“換行符”,“s=”,s); } try(int t) { int j; if (t>n) {output( );} else for(j=t;j<=n;j++) {swap(t,j); try(t+1); swap(t,j); /回溯時,恢復原來的排列/ } } output( ) { int j; printf("\n"); for( j=1;j<=n;j++) printf(" mod d",a[j]); s=s+1; } swap(int t1,int t2) { int t; t=a[t1]; a[t1]= a[t2]; a[t2]=t; }
3、算法說明
1)有的讀者可能會想try( )函數中,不應該出現自身之間的交換,for循環是否應該改為for(j=t+1;j<=n;j++)?回答是否定的。當n=3時,算法的輸出是:123,132,213,231,321,312。123的輸出說明第一次到達葉結點是不經過數據交換的,而132的排列也是1不進行交換的結果。
2)for循環體中的第二個swap( )調用,是用來恢復原順序的。為什么要有如此操作呢?還是通過實例進行說明,排列“213”是由“123”進行1,2交換等到的所以在回溯時要將“132” 恢復為“123”。
4、算法分析
全排列算法的復雜度為O(n!), 其結果可以為搜索排列樹所用。
【例7】按排列樹回溯搜索解決八皇后問題
1、算法設計
利用例6“枚舉”所有1-n的排列,從中選出滿足約束條件的解來。這時的約束條件只有不在同一對角線,而不需要不同列的約束了。和例1的算法3一樣,我們用數組c,d記錄每個對角線的占用情況。
2、算法
int a[100],n,s=0,c[20],d[20]; main( ) { int i; input(n); for(i=1;i<=n;i++) a[i]=i; for (i=1;i<=n;i++) { c[i]=0; c[n+i]=0; d[i]=0; d[n+i]=0;} try(1); print("s=",s); } try(int t) { int j; if (t>n) {output( );} else for(j=t;j<=n;j++) { swap(t,j); if (c[t+a[t]]=0 and d[t-a[t]+n]=0) { c[t+a[t]]=1; d[t-a[t]+n]=1; try(t+1); c[t+a[t]]=0; d[t-a[t]+n]=0;} swap(t,j); } } output( ) { int j; print("換行符"); for( j=1;j<=n;j++) print(a[j]); s=s+1; } swap(int t1,int t2) { int t; t=a[t1]; a[t1]= a[t2]; a[t2]=t; }
應用3——最優化問題的回溯搜索
【例8】一個有趣的高精度數據
構造一個盡可能大的數,使其從高到低前一位能被1整除,前2位能被2整除,……,前n位能被n整除。
1、數學模型
記高精度數據為a1 a2……an,題目很明確有兩個要求:
1)a1整除1且
(a1*10+a2)整除2且……
(a1*10n-1+a210n-2+……+an) 整除n;
2)求最大的這樣的數。
2、算法設計
此數只能用從高位到低位逐位嘗試失敗回溯的算法策略求解,生成的高精度數據用數組的從高位到低位存儲,1號元素開始存儲最高位。此數的大小無法估計不妨為數組開辟100個空間。
算法中數組A為當前求解的高精度數據的暫存處,數組B為當前最大的滿足條件的數。
算法的首位A[1]從1開始枚舉。以后各位從0開始枚舉。所以求解出的滿足條件的數據之間只需要比較位數就能確定大小。n 為當前滿足條件的最大數據的位數,當i>=n就認為找到了更大的解,i>n不必解釋位數多數據一定大;i=n時,由於嘗試是由小到大進行的,位數相等時后來滿足條件的菀歡ū惹懊嫻拇蟆
3、算法
main( ) { int A[101],B[101]; int i,j,k,n,r; A[1]=1; for(i=2;i<=100;i++) /置初值:首位為1 其余為0/ A[i]=0; n=1; i=1; while(A[1]<=9) {if (i>=n) /發現有更大的滿足條件的高精度數據/ {n=i; /轉存到數組B中/ for (k=1;k<=n;k++) B[k]=A[k]; } i=i+1;r=0; for(j=1;j<=i;j++) /檢查第i位是否滿足條件/ {r=r*10+A[j]; r=r mod i;} if(r<>0) /若不滿足條件/ {A[i]=A[i]+i-r ; /第i位可能的解/ while (A[i]>9 and i>1) /搜索完第i位的解,回 溯到前一位/ {A[i]=0; i=i-1; A[i]=A[i]+i;} } } }
4、算法說明
1)從A[1]=1開始,每增加一位A[i](初值為0)先計算r=(A[1]*10i-1+A[2]*10i-2+……+A[i]),再測試r=r mod i是否為0。
2)r=0表示增加第i位后,滿足條件,與原有滿足條件的數(存在數組B中)比較,若前者大,則更新后者(數組B),繼續增加下一位。
3)r≠0表示增加i位后不滿足整除條件,接下來算法中並不是繼續嘗試A[i]= A[i]+1,而是繼續嘗試A[i]= A[i]+i-r,因為若A[i]= A[i]+i-r<=9時,(A[1]*10i-1+A[2]*10i-2+……+A[i]杛+i) mod i肯定為0,這樣可減少嘗試次數。如:17除5余2,15-2+5肯定能被5整除。
4)同理,當A[i] -r +i>9時,要進位也不能算滿足條件。這時,只能將此位恢復初值0且回退到前一位(i=i-1)嘗試A[i]= A[i] +i……。這正是最后一個while循環所做的工作。
5)當回溯到i=1時,A[1]加1開始嘗試首位為2的情況,最后直到將A[1]=9的情況嘗試完畢,算法結束。
【例9】流水作業車間調度
n個作業要在由2台機器M1和M2組成的流水線上完成加工。每個作業加工的順序都是先在M1上加工,然后在M2上加工。M1和M2加工作業i所需的時間分別為ai和bi。流水作業調度問題要求確定這n個作業的最優加工順序,使得從第一個作業在機器M1上開始加工,到最后一個作業在機器M2上加工完成所需的時間最少。作業在機器M1、M2的加工順序相同。
1、算法設計
1)問題的解空間是一棵排列樹,簡單的解決方法就是在搜索排列樹的同時,不斷更新最優解,最后找到問題的解。算法框架和例6完全相同,用數組x(初值為1,2,3,……,n)模擬不同的排列,在不同排列下計算各種排列下的加工耗時情況。
2)機器M1進行順序加工,其加工f1時間是固定的,f1[i]= f1[i-1]+a[x[i]]。機器M2則有空閑(圖5-19(1))或積壓(圖5-19(2))的情況,總加工時間f2,當機器M2空閑時,f2[i]=f1+ b[x[i]];當機器M2有積壓情況出現時,f2[i]= f2[i-1]+ b[x[i]]。總加工時間就是f2[n]。
3)一個最優調度應使機器M1沒有空閑時間,且機器M2的空閑時間最少。在一般情況下,當作業按在機器M1上由小到大排列后,機器M2的空閑時間較少,當然最少情況一定還與M2上的加工時間有關,所以還需要對解空間進行搜索。排序后可以盡快地找到接近最優的解,再加入下一步限界操作就能加速搜索速度。
4)經過以上排序后,在自然數列的排列下,就是一個接近最優的解。因此,在以后的搜索過程中,一當某一排列的前面幾步的加工時間已經大於當前的最小值,就無需進行進一步的搜索計算,從而可以提高算法效率。
2、數據結構設計
1)用二維數組job[100][2]存儲作業在M1、M2上的加工時間。
2)由於f1在計算中,只需要當前值,所以用變量存儲即可;而f2在計算中,還依賴前一個作業的數據,所以有必要用數組存儲。
3)考慮到回溯過程的需要,用變量f存儲當前加工所需要的全部時間。
3、算法
int job[100][2],x[100],n,f1=0,f=0,f2[100]=0; main( ) { int j; input(n); for(i=1;i<=2;i++) for(j=1;j<=n;j++) input(job[j][i]); try( ); } try(int i) { int j; if (i=n+1) {for(j=1;j<=n;j++) bestx[j]=x[j]; bestf=f; } else for(j=1;j<=n;j++) { f1= f1+ job[x[j]][1]; if (f2[i-1]>f1) f2[i]= f2[i-1]+job[x[j]][2]; else f2[i]= f1+job[x[j]][2]; f=f+f2[i]; if (f<bestf) { swap(x[i],x[j]); try(i+1); swap(x[i],x[j]);} f1= f1-job[x[j]][1]; f=f-f2[i]; } }
解空間為排列樹的最優化類問題,都可以依此算法解決。而對於解空間為子集樹的最優化類問題,類似本節例題1、2、3枚舉元素的選取或不選取兩種情況進行搜索就能找出最優解,同時也可以加入相應的限界策略。