標準迭代范式
[回溯算法] 五大常用算法之回溯法
本文轉自2018年02月12日
算法入門6:回溯法
一. 回溯法 – 深度優先搜素
1. 簡單概述
回溯法思路的簡單描述是:把問題的解空間轉化成了圖或者樹的結構表示,然后使用深度優先搜索策略進行遍歷,遍歷的過程中記錄和尋找所有可行解或者最優解。
基本思想類同于:
圖的深度優先搜索
-
二叉樹的后序遍歷
分支限界法:廣度優先搜索 思想類同于:圖的廣度優先遍歷 二叉樹的層序遍歷
2. 詳細描述
詳細的描述則為:
回溯法按深度優先策略搜索問題的解空間樹。首先從根節點出發搜索解空間樹,當算法搜索至解空間樹的某一節點時,先利用剪枝函數判斷該節點是否可行(即能得到問題的解)。如果不可行,則跳過對該節點為根的子樹的搜索,逐層向其祖先節點回溯;否則,進入該子樹,繼續按深度優先策略搜索。
回溯法的基本行為是搜索,搜索過程使用剪枝函數來為了避免無效的搜索。剪枝函數包括兩類:1\. 使用約束函數,剪去不滿足約束條件的路徑;2.使用限界函數,剪去不能得到最優解的路徑。
問題的關鍵在于如何定義問題的解空間,轉化成樹(即解空間樹)。解空間樹分為兩種:子集樹和排列樹。兩種在算法結構和思路上大體相同。
3. 回溯法應用
當問題是要求滿足某種性質(約束條件)的所有解或最優解時,往往使用回溯法。
它有“通用解題法”之美譽。
二. 回溯法實現 - 遞歸和遞推(迭代)
回溯法的實現方法有兩種:遞歸和遞推(也稱迭代)。一般來說,一個問題兩種方法都可以實現,只是在算法效率和設計復雜度上有區別。
【類比于圖深度遍歷的遞歸實現和非遞歸(遞推)實現】
1. 遞歸
思路簡單,設計容易,但效率低,其設計范式如下:
1. //針對N叉樹的遞歸回溯方法
2. void backtrack (int t)
3. {
4. if (t>n) output(x); //葉子節點,輸出結果,x是可行解
5. else
6. for i = 1 to k//當前節點的所有子節點
7. {
8. x[t]=value(i); //每個子節點的值賦值給x
9. //滿足約束條件和限界條件
10. if (constraint(t)&&bound(t))
11. backtrack(t+1); //遞歸下一層
12. }
13. }
2. 遞推
算法設計相對復雜,但效率高。
- //針對N叉樹的迭代回溯方法
- void iterativeBacktrack ()
- {
- int t=1;
- while (t>0) {
- if(ExistSubNode(t)) //當前節點的存在子節點
- {
- for i = 1 to k //遍歷當前節點的所有子節點
- {
- x[t]=value(i);//每個子節點的值賦值給x
- if (constraint(t)&&bound(t))//滿足約束條件和限界條件
- {
- //solution表示在節點t處得到了一個解
- if (solution(t)) output(x);//得到問題的一個可行解,輸出
- else t++;//沒有得到解,繼續向下搜索
- }
- }
- }
- else //不存在子節點,返回上一層
- {
- t--;
- }
- }
- }
三. 子集樹和排列樹
1. 子集樹
所給的問題是從n個元素的集合S中找出滿足某種性質的子集時,相應的解空間成為子集樹。
如0-1背包問題,從所給重量、價值不同的物品中挑選幾個物品放入背包,使得在滿足背包不超重的情況下,背包內物品價值最大。它的解空間就是一個典型的子集樹。
回溯法搜索子集樹的算法范式如下:
[cpp] view plain copy
- void backtrack (int t)
- {
- if (t>n) output(x);
- else
- for (int i=0;i<=1;i++) {
- x[t]=i;
- if (constraint(t)&&bound(t)) backtrack(t+1);
- }
- }
2. 排列樹
所給的問題是確定n個元素滿足某種性質的排列時,相應的解空間就是排列樹。
如旅行售貨員問題,一個售貨員把幾個城市旅行一遍,要求走的路程最小。它的解就是幾個城市的排列,解空間就是排列樹。
回溯法搜索排列樹的算法范式如下:
[cpp] view plain copy
- void backtrack (int t)
- {
- if (t>n) output(x);
- else
- for (int i=t;i<=n;i++) {
- swap(x[t], x[i]);
- if (constraint(t)&&bound(t)) backtrack(t+1);
- swap(x[t], x[i]);
- }
- }
四. 經典問題
(1)裝載問題
(2)0-1背包問題
(3)旅行售貨員問題
(4)八皇后問題
(5)迷宮問題
(6)圖的m著色問題
1. 0-1背包問題
問題:給定n種物品和一背包。物品i的重量是wi,其價值為pi,背包的容量為C。問應如何選擇裝入背包的物品,使得裝入背包中物品的總價值最大?
分析:問題是n個物品中選擇部分物品,可知,問題的解空間是子集樹。比如物品數目n=3時,其解空間樹如下圖,邊為1代表選擇該物品,邊為0代表不選擇該物品。使用x[i]表示物品i是否放入背包,x[i]=0表示不放,x[i]=1表示放入。回溯搜索過程,如果來到了葉子節點,表示一條搜索路徑結束,如果該路徑上存在更優的解,則保存下來。如果不是葉子節點,是中點的節點(如B),就遍歷其子節點(D和E),如果子節點滿足剪枝條件,就繼續回溯搜索子節點。
[圖片上傳失敗...(image-a1757f-1552436966753)]
代碼:
[cpp] view plain copy
-
include <stdio.h>
-
define N 3 //物品的數量
-
define C 16 //背包的容量
int w[N]={10,8,5}; //每個物品的重量
int v[N]={5,4,1}; //每個物品的價值
int x[N]={0,0,0}; //x[i]=1代表物品i放入背包,0代表不放入
int CurWeight = 0; //當前放入背包的物品總重量
int CurValue = 0; //當前放入背包的物品總價值
int BestValue = 0; //最優值;當前的最大價值,初始化為0
int BestX[N]; //最優解;BestX[i]=1代表物品i放入背包,0代表不放入
//t = 0 to N-1
void backtrack(int t)
{
//葉子節點,輸出結果
if(t>N-1)
{
//如果找到了一個更優的解
if(CurValue>BestValue)
{
//保存更優的值和解
BestValue = CurValue;
for(int i=0;i<N;++i) BestX[i] = x[i];
}
}
else
{
//遍歷當前節點的子節點:0 不放入背包,1放入背包
for(int i=0;i<=1;++i)
{
x[t]=i;
if(i==0) //不放入背包
{
backtrack(t+1);
}
else //放入背包
{
//約束條件:放的下
if((CurWeight+w[t])<=C)
{
CurWeight += w[t];
CurValue += v[t];
backtrack(t+1);
CurWeight -= w[t];
CurValue -= v[t];
}
}
}
//PS:上述代碼為了更符合遞歸回溯的范式,并不夠簡潔
}
}
int main(int argc, char* argv[])
{
backtrack(0);
printf("最優值:%d\n",BestValue);
for(int i=0;i<N;i++)
{
printf("最優解:%-3d",BestX[i]);
}
return 0;
}
2. 旅行售貨員問題
[回溯法----旅行售貨員問題](http://blog.csdn.net/jarvischu/article/details/6058931)
3. 詳細描述N皇后問題
問題:在n×n格的棋盤上放置彼此不受攻擊的n個皇后。按照國際象棋的規則,皇后可以攻擊與之處在同一行或同一列或同一斜線上的棋子。
N皇后問題等價于在n×n格的棋盤上放置n個皇后,任何2個皇后不放在同一行或同一列或同一斜線上。
分析:從n×n個格子中選擇n個格子擺放皇后。可見解空間樹為子集樹。
使用Board[N][N]來表示棋盤,Board[i][j]=0 表示(I,j)位置為空,Board[i][j]=1 表示(I,j)位置擺放有一個皇后。
全局變量way表示總共的擺放方法數目。
使用Queen(t)來擺放第t個皇后。Queen(t) 函數符合子集樹時的遞歸回溯范式。當t>N時,說明所有皇后都已經擺 放完成,這是一個可行的擺放方法,輸出結果;否則,遍歷棋盤,找皇后t所有可行的擺放位置,Feasible(i,j) 判斷皇后t能否擺放在位置(i,j)處,如果可以擺放則繼續遞歸擺放皇后t+1,如果不能擺放,則判斷下一個位置。
Feasible(row,col)函數首先判斷位置(row,col)是否合法,繼而判斷(row,col)處是否已有皇后,有則沖突,返回0,無則繼續判斷行、列、斜方向是否沖突。斜方向分為左上角、左下角、右上角、右下角四個方向,每次從(row,col)向四個方向延伸一個格子,判斷是否沖突。如果所有方向都沒有沖突,則返回1,表示此位置可以擺放一個皇后。
[圖片上傳失敗...(image-d4906e-1552436966752)]
代碼:
1. /************************************************************************
2. * 名 稱:NQueen.cpp
3. * 功 能:回溯算法實例:N皇后問題
4. * 作 者:JarvisChu
5. * 時 間:2013-11-13
6. ************************************************************************/
8. #include <stdio.h>
10. #define N 8
12. int Board[N][N];//棋盤 0表示空白 1表示有皇后
13. int way;//擺放的方法數
16. //判斷能否在(x,y)的位置擺放一個皇后;0不可以,1可以
17. int Feasible(int row,int col)
18. {
19. //位置不合法
20. if(row>N || row<0 || col >N || col<0)
21. return 0;
23. //該位置已經有皇后了,不能
24. if(Board[row][col] != 0)
25. { //在行列沖突判斷中也包含了該判斷,單獨提出來為了提高效率
26. return 0;
27. }
29. //////////////////////////////////////////////////
30. //下面判斷是否和已有的沖突
32. //行和列是否沖突
33. for(int i=0;i<N;++i)
34. {
35. if(Board[row][i] != 0 || Board[i][col]!=0)
36. return 0;
37. }
39. //斜線方向沖突
41. for(int i=1;i<N;++i)
42. {
43. /* i表示從當前點(row,col)向四個斜方向擴展的長度
45. 左上角 \ / 右上角 i=2
46. \/ i=1
47. /\ i=1
48. 左下角 / \ 右下角 i=2
49. */
50. //左上角
51. if((row-i)>=0 && (col-i)>=0) //位置合法
52. {
53. if(Board[row-i][col-i] != 0)//此處已有皇后,沖突
54. return 0;
55. }
57. //左下角
58. if((row+i)<N && (col-i)>=0)
59. {
60. if(Board[row+i][col-i] != 0)
61. return 0;
62. }
64. //右上角
65. if((row-i)>=0 && (col+i)<N)
66. {
67. if(Board[row-i][col+i] != 0)
68. return 0;
69. }
71. //右下角
72. if((row+i)<N && (col+i)<N)
73. {
74. if(Board[row+i][col+i] != 0)
75. return 0;
76. }
77. }
79. return 1; //不會發生沖突,返回1
80. }
83. //擺放第t個皇后 ;從1開始
84. void Queen(int t)
85. {
86. //擺放完成,輸出結果
87. if(t>N)
88. {
89. way++;
90. /*如果N較大,輸出結果會很慢;N較小時,可以用下面代碼輸出結果
91. for(int i=0;i<N;++i){
92. for(int j=0;j<N;++j)
93. printf("%-3d",Board[i][j]);
94. printf("\n");
95. }
96. printf("\n------------------------\n\n");
97. */
98. }
99. else
100. {
101. for(int i=0;i<N;++i)
102. {
103. for(int j=0;j<N;++j)
104. {
105. //(i,j)位置可以擺放皇后,不沖突
106. if(Feasible(i,j))
107. {
108. Board[i][j] = 1; //擺放皇后t
109. Queen(t+1); //遞歸擺放皇后t+1
110. Board[i][j] = 0; //恢復
111. }
112. }
113. }
114. }
115. }
117. //返回num的階乘,num!
118. int factorial(int num)
119. {
120. if(num==0 || num==1)
121. return 1;
122. return num*factorial(num-1);
123. }
126. int main(int argc, char* argv[])
127. {
128. //初始化
129. for(int i=0;i<N;++i)
130. {
131. for(int j=0;j<N;++j)
132. {
133. Board[i][j]=0;
134. }
135. }
137. way = 0;
139. Queen(1); //從第1個皇后開始擺放
141. //如果每個皇后都不同
142. printf("考慮每個皇后都不同,擺放方法:%d\n",way);//N=8時, way=3709440 種
144. //如果每個皇后都一樣,那么需要除以 N!出去重復的答案(因為相同,則每個皇后可任意調換位置)
145. printf("考慮每個皇后都不同,擺放方法:%d\n",way/factorial(N));//N=8時, way=3709440/8! = 92種
147. return 0;
148. }
PS:該問題還有更優的解法。充分利用問題隱藏的約束條件:每個皇后必然在不同的行(列),每個行(列)必然也只有一個皇后。這樣我們就可以把N個皇后放到N個行中,使用Pos[i]表示皇后i在i行中的位置(也就是列號)(i = 0 to N-1)。這樣代碼會大大的簡潔,因為節點的子節點數目會減少,判斷沖突也更簡單。
4. 迷宮問題
問題:給定一個迷宮,找到從入口到出口的所有可行路徑,并給出其中最短的路徑
分析:用二維數組來表示迷宮,則走迷宮問題用回溯法解決的的思想類似于圖的深度遍歷。從入口開始,選擇下一個可以走的位置,如果位置可走,則繼續往前,如果位置不可走,則返回上一個位置,重新選擇另一個位置作為下一步位置。
N表示迷宮的大小,使用Maze[N][N]表示迷宮,值為0表示通道(可走),值為1表示不可走(墻或者已走過);
Point結構體用來記錄路徑中每一步的坐標(x,y)
(ENTER_X,ENTER_Y) 是迷宮入口的坐標
(EXIT_X, EXIT _Y) 是迷宮出口的坐標
Path容器用來存放一條從入口到出口的通路路徑
BestPath用來存放所有路徑中最短的那條路徑
Maze()函數用來遞歸走迷宮,具體步驟為:
1\. 首先將當前點加入路徑,并設置為已走
2\. 判斷當前點是否為出口,是則輸出路徑,保存結果;跳轉到4
3\. 依次判斷當前點的上、下、左、右四個點是否可走,如果可走則遞歸走該點
4\. 當前點推出路徑,設置[為可]
PS:用WPF實現了一個簡單的圖形化迷宮程序。白色表示通道,紅色表示墻,最短的路徑用黃色顯示。目前實現了一個10*10的迷宮自動搜素最短通路,右側顯示搜索過程中得到的每一個可行通路。
由于構造一個迷宮比較復雜,所以暫時“迷宮設置”功能沒有做實現,至于手動一步步查看搜素過程的動畫也沒有做實現。