搜索的策略(2)——貪心策略

貪心策略

  不少時候,咱們只須要找到問題的最優解,若是使用盲目搜索策略,就必須先找出全部解,再進一步比較哪一個是最優的,當在解空間十分龐大時,不免有些浪費體力的感受。這時候,不妨試試更高效的貪心策略。算法

  貪心策略也叫貪心算法(greedy algorithm)或貪婪算法,是一種強有力的窮舉搜索策略,它經過一系列選擇來找到問題的最優解。在每一個決策點,它都會作出當時看來是最優的選擇,一旦選擇後就無需回溯。簡單來講,貪心策略是一種「步步爲營」的策略——只要作好眼前的每一步,就天然會在將來獲得最好的結果,而且作過的決策就是是最好的決策,無需再次檢查。編程

  不少時候,貪心法並不能保證獲得最優解,它能獲得的是較爲接近最優解的較好解,所以貪心法常常被用來解決一些對結果精度要求不高的問題。緩存

小偷的揹包

  一個小偷撬開了一個保險箱,發現裏面有N個大小和價值不一樣的東西,但本身只有一個容量是M的揹包,小偷怎樣選擇才能使偷走的物品總價值最大?數據結構

  假設有5個物品A,B,C,D,E,它們的體積分別是3,4,7,8,9,價值分別是4,5,10,11,13,能夠用矩形表示體積,將矩形旋轉90°後表示價值:post

  下圖展現了一個容量爲17的揹包的4中填充方式,其中有兩種方式的總價都是24:學習

  揹包問題有不少重要的實應用,好比長途運輸時,須要知道卡車裝載物品的最佳方式。spa

搜索策略

  咱們基於貪心策略去解決揹包問題:在取完一個物品後,找到填充揹包剩餘部分的最佳方法。對於一個容量爲M的揹包,須要對每一種類型的物品都推測一下,若是把它裝入揹包的話總價值是多少,依次遞歸下去就能找到最佳方案。這個方案的原理是,一旦作出了最佳選擇就無需更改,也就是說一旦知道了如何填充較小容量的揹包,則不管下一個物品是什麼,都無需再次檢驗已經放入揹包中的物品(已經放入揹包中的物品必定是最佳方案)。3d

尋找解決方案

  首先定義物品的數據模型:code

1 class Goods:
2     ''' 物品的數據結構  '''
3     def __init__(self, size, value):
4         '''
5         :param size: 物品的體積
6         :param value: 物品的價值
7         '''
8         self.size = size
9         self.value = value

  而後使用fill_into_bag方法尋找最佳填充方案。該方法接收揹包容量和物品清單兩個參數,返回揹包最大價值和最佳填充方案:orm

 1 def fill_into_bag(M, goods_list):
 2     '''
 3     填充一個容量是 M 的揹包
 4     :param M: 揹包的容量
 5     :param goods_list: 物品清單,包括每種物品的體積和價值,物品互不相同
 6     :return: (最大價值,最佳填充方案)
 7     '''
 8     space = 0       # 揹包的剩餘容量
 9     max = 0         # 揹包中物品的最大價值
10     plan = []       # 最佳填充方案
11
12     for goods in goods_list:
13         space = M - goods.size
14         if space >= 0:
15             # 在取完一個物品(goods)後,填充揹包剩餘部分的最佳方法
16             space_plan = fill_into_bag(space, goods_list)
17             if space_plan[0] + goods.value > max:
18                 max = space_plan[0] + goods.value
19                 plan = [goods] + space_plan[1]
20
21     return max, plan

  最後能夠看看小偷應該怎樣填充揹包:

 1 def paint(plan):
 2     print('最大價值:' + str(plan[0]))
 3     print('最佳方案:')
 4     for goods in plan[1]:
 5         print('\t大小:{0}\t價值:{1}'.format(goods.size, goods.value))
 6
 7 if __name__ == '__main__':
 8     goods_list = [Goods(3, 4), Goods(4, 5), Goods(7, 10), Goods(8, 11), Goods(9, 13)]
 9     plan = fill_into_bag(17, goods_list)
10     paint(plan)

  運行結果:

  遺憾的是,fill_into_bag方法只能做爲一個簡單的試驗樣品,它犯了一個嚴重的錯誤——第二次遞歸會忽略上一次所作的全部計算!這將致使要花指數級的時間才能計算出結果。爲了把時間降爲線性,須要使用動態編程技術對其進行改進,把計算過的值都緩存起來,由此獲得了揹包問題的2.0版:

 1 # 字典緩存,space:(max,plan)
 2 sd = {}
 3 def fill_into_bag_2(M, goods_list):
 4     '''
 5     填充一個容量是 M 的揹包
 6     :param M: 揹包的容量
 7     :param goods_list: 物品清單,包括每種物品的體積和價值,物品互不相同
 8     :return: (最大價值,最佳填充方案)
 9     '''
10     space = 0       # 揹包的剩餘容量
11     max = 0         # 揹包中物品的最大價值
12     plan = []       # 最佳填充方案
13
14     if M in sd:
15         return sd[M]
16
17     for goods in goods_list:
18         space = M - goods.size
19         if space >= 0:
20             # 在取完一個物品(goods)後,填充揹包剩餘部分的最佳方法
21             print(goods.size, space)
22             space_plan = fill_into_bag_2(space, goods_list)
23             if space_plan[0] + goods.value > max:
24                 max = space_plan[0] + goods.value
25                 plan = [goods] + space_plan[1]
26     # 設置緩存,M空間的最佳方案
27     sd[M] = max, plan
28
29     return max, plan

  此次能夠快速運行了,固然,咱們並不想把這個算法告訴小偷。

騎士旅行

  騎士旅行(Knight tour)問題是另外一個關於國際象棋的話題:騎士能夠由棋盤上的任一個方格出發,若是每一個方格只能到達一次,它要如何走完全部的位置?騎士旅行曾在十八世紀初倍受數學家與拼圖迷的注意,具體何時被提出已不可考。

  「騎士」的走法和吃子都和中國象棋的「馬」相似,遵循「馬走日」的原則,只不過沒有「蹩腿」的約束:

  在國際象棋中,騎士的價值爲3,雖然不算高,卻靈活、易調動、易雙抽,從這一點看,它的價值不亞於皇后。

5.5.1 構建數據模型

  咱們依然使用8×8的二維列表存儲棋盤信息,用0表示方格的初始狀態。使用一個從1開始的計數器記錄騎士旅行的軌跡,每走一步,計數器加1,同把騎士到達的方格狀態設置爲計數器的值,這些數值就是騎士的旅程軌跡:

  騎士從一個方格出發, 最多能夠向八個方向行進,怎樣方便地表示這八個方向呢?咱們都見識或棋譜,在棋譜上,把騎士能夠到達的八個方格依次編號:

  這像極了平面直角座標系,能夠把棋盤外圍的列序號看做y軸的座標,行序號看做x軸的座標,這樣棋盤上的每個方格就能夠用一個二維向量表示,向量的第一個份量是行號,第二個份量是列號。這其實是把咱們熟知的直角座標系順時針旋轉了90°,目的是爲了可以更方便地用二維列表表示。

  騎士的初始位置是(3,3),從這裏出發能夠到達的另外八個位置依次是:(2,1),(1,2),(1,4),(2,5),(4,5),(5,4),(5,2),(4,1)。它們與初始位置的差值是:(-1,-2),(-2,-1),(-2,1),(-1,2),(1,2),(2,1),(2,-1),(1,-2)。因爲向量是表示大小和方向的量,與具體位置無關,因此騎士從任意位置出發,加上差值向量後均可以到達另外八個位置(不考慮棋盤邊界)。以上圖爲例:

  用一個列表存儲這些差值向量。騎士旅行的數據模型:

 1 class KnightTour:
 2     def __init__(self):
 3         # 棋盤的行數和列數
 4         self.row_num, self.col_num = 8, 8
 5         # 方格的初始狀態
 6         self.s_init = 0
 7         # 棋盤
 8         self.chess_board = [[self.s_init] * self.row_num for i in range(self.row_num)]
 9         # 差值向量,表示騎士移動的八個方向
10         self.v_move = [(-1, -2), (-2, -1), (-2, 1), (-1, 2), (1, 2), (2, 1), (2, -1), (1, -2)]
11         # 計數器終點
12         self.max = self.row_num * self.col_num
13         # 解決方案
14         self.answer = None

盲目的深度優先策略

  大概最容易想到的旅行方法就是深度優先搜索,基本思慮和八皇后相似:騎士從一個位置開始,向一個方向探索,沒法繼續前進時就「悔棋」,嘗試下一個方向,若是計數器能累加到64,說明騎士能夠完成旅行:

 1 import copy
 2
 3 class KnightTour:
 4     ……
 5     def enable(self, curr_board, x, y):
 6         ''' 判斷x,y位置是否可走 '''
 7         # 邊界條件判斷 and x,y位置是否曾經到達過
 8         return (0 <= x < self.col_num and 0 <= y < self.row_num) and curr_board[x][y] == self.s_init
 9
10     def move(self, curr_board, x, y, count):
11         '''
12         騎士從(x,y)位置開始旅行
13         :param curr_board: 當前棋盤
14         :param x: 起始位置行號
15         :param y: 起始位置列號
16         :param count: 當前計數
17         :return
18         '''
19         # 找到一種方法就退出
20         if self.answer is not None:
21             return
22         # 若是已經走遍了全部方格,該問題解決
23         if count > self.max:
24             self.answer = curr_board
25             return
26
27         if self.enable(curr_board, x, y):
28             curr_board[x][y] = count
29             # 繼續旅行,分別探測八個方向
30             for v_x, v_y in self.v_move:
31                 # 複製棋盤上的狀態, 以便回溯
32                 bord = copy.deepcopy(curr_board)
33                 self.move(bord, x + v_x, y + v_y, count + 1)

  這裏x是方格的行序號,y是方格列序號。Enable方法用於判斷(x,y)是否超出的棋盤邊界,同時也檢查了騎士是否已經到訪過(x,y)。move方法以遞歸的方式向下一步探索。悔棋的回溯操做使用了複製棋盤狀態的方式,這須要大量的內存,它有一個經過更改方格狀態的代替版本:

 1  def move2(self, x, y, count):
 2         '''
 3        騎士從(x,y)位置開始旅行
 4        :param x: 起始位置行號
 5        :param y: 起始位置列號
 6        :param count: 當前計數
 7        :return
 8         '''
 9         # 找到一種方法就退出
10         if self.answer is not None:
11             return
12         # 若是已經走遍了全部方格,該問題解決
13         if count > self.max:
14             self.answer = copy.deepcopy(self.chess_board)
15             return
16
17         if self.enable(self.chess_board, x, y):
18             self.chess_board[x][y] = count
19             # 繼續旅行,分別探測八個方向
20             for v_x, v_y in self.v_move:
21                 self.move2(x + v_x, y + v_y, count + 1)
22             # 將該位置設爲初始值,以便悔棋
23             self.chess_board[x][y] = self.s_init

  move2只使用了一個棋盤,爲了回到上一個方格,當騎士探索完八個方向後,須要將當前所在方格重置爲初始狀態。move2的改進僅僅是節省了一點內存,和move1並無本質的區別,它們在運行時都至關緩慢。騎士每到達一個位置後,都將向八個方向探索,棋盤上共有64個方格,探索的數量也會產生爆炸,所以咱們在找到一種方案後就立刻退出。

  完整代碼:

 1 import copy
 2
 3 class KnightTour:
 4     def __init__(self):
 5         # 棋盤的行數和列數
 6         self.row_num, self.col_num = 8, 8
 7         # 方格的初始狀態
 8         self.s_init = 0
 9         # 棋盤
10         self.chess_board = [[self.s_init] * self.row_num for i in range(self.row_num)]
11         # 差值向量,表示騎士移動的八個方向
12         self.v_move = [(-1, -2), (-2, -1), (-2, 1), (-1, 2), (1, 2), (2, 1), (2, -1), (1, -2)]
13         # 計數器終點
14         self.max = self.row_num * self.col_num
15         # 解決方案
16         self.answer = None
17
18     def start(self, x, y):
19         '''
20          旅行開始
21         :param x: 起始位置行號
22         :param y: 起始位置列號
23         :return:
24         '''
25         # self.move(self.chess_board, x, y, 1)
26         self.move2(x, y, 1)
27
28     def enable(self, curr_board, x, y):
29         ''' 判斷x,y位置是否可走 '''
30         # 邊界條件判斷 and x,y位置是否曾經到達過
31         return (0 <= x < self.col_num and 0 <= y < self.row_num) and curr_board[x][y] == self.s_init
32
33     def move(self, curr_board, x, y, count):
34         '''
35         騎士從(x,y)位置開始旅行
36         :param curr_board: 當前棋盤
37         :param x: 起始位置行號
38         :param y: 起始位置列號
39         :param count: 當前計數
40         :return
41         '''
42         # 找到一種方法就退出
43         if self.answer is not None:
44             return
45         # 若是已經走遍了全部方格,該問題解決
46         if count > self.max:
47             self.answer = curr_board
48             return
49
50         if self.enable(curr_board, x, y):
51             curr_board[x][y] = count
52             # 繼續旅行,分別探測八個方向
53             for v_x, v_y in self.v_move:
54                 # 複製棋盤上的狀態, 以便回溯
55                 bord = copy.deepcopy(curr_board)
56                 self.move(bord, x + v_x, y + v_y, count + 1)
57
58     def move2(self, x, y, count):
59         '''
60        騎士從(x,y)位置開始旅行
61        :param x: 起始位置行號
62        :param y: 起始位置列號
63        :param count: 當前計數
64        :return
65         '''
66         # 找到一種方法就退出
67         if self.answer is not None:
68             return
69         # 若是已經走遍了全部方格,該問題解決
70         if count > self.max:
71             self.answer = copy.deepcopy(self.chess_board)
72             return
73
74         if self.enable(self.chess_board, x, y):
75             self.chess_board[x][y] = count
76             # 繼續旅行,分別探測八個方向
77             for v_x, v_y in self.v_move:
78                 self.move2(x + v_x, y + v_y, count + 1)
79             # 將該位置設爲初始值,以便悔棋
80             self.chess_board[x][y] = self.s_init
81
82     def display(self):
83         if self.answer is None:
84             print('No answers!')
85             return
86
87         for row in  self.answer:
88             for c in row:
89                 print('%4d' % c, end='')
90             print()
91
92 if __name__ == '__main__':
93     kt = KnightTour()
94     kt.start(7, 7)
95     kt.display()

  若是騎士從(7, 7)出發,是可以完成旅行的:

  騎士的初始位置和探測方向的順序都會對運算時間產生極大的影響,若是把起始位置改爲(0,0),那麼上面的程序將運行至關長的時間。

  並非在全部棋盤都能完成旅行,在3×3的棋盤上,騎士永遠都沒法到達中心位置:

帶有預見性的貪心策略

  因爲每步試探的隨機性和盲目性,使得基於深度優先策略的盲目搜索效率低下。若是可以找到一種克服這種隨機性和盲目性的辦法,按照必定規律選擇前進的方向,則成功的可能性將大大增長。J.C. Warnsdorff在1823年提出一個聰明的解法:有選擇地走下一步,先將最難的位置走完,既然每一格早晚都要走到,與其把困難留在後面,不如先走困難的路,這樣後面的路纔會寬闊,成功的機會也增大。

  爲了簡單起見,咱們的騎士先在5×5的棋盤上旅行。他的初始位置是(0,0),這也是旅途的第一站,用「①」表示:

  騎士的下一站只可能有兩個,(1,2)和(2,1),用深色方格表示:

  若是騎士的下一站是(1,2),那麼從(1,2)出發,再下一站可以到達(0,4),(2,4),(3,3),(3,1),(2,0)這5個位置,將數字5標記在(1,2)中,用於表示路的寬窄,數字越小,路越窄,表示這條路線越困難。若是從(2,1)出發,再下一站可以到達另外五個位置:

  第二站的「寬度」都是5。咱們已經在圖5.13中爲八個方向編好了序號,從位於十點鐘方向的1號開始,按照順時針順序逐一探索,選擇最窄目的地當中的第一個做爲下一站。按照這種方式,這裏選擇(1,2)做爲下一站,併爲該方格標記序號:

  接下來從位置②繼續探測,尋找最窄的第三站:

  每一個方格只能到達一次,因此不能再回到①,這也是貪心法和深度優先搜索的重要緣由之一——在貪心法中,每一步決策都是當下最好的,一旦作出選擇就再也不回溯。從位置②出發,到達的最窄第三站是(0,4):

  按照這種方式繼續向前探測,騎士最終可以順利完成旅程:

  按照這種思路使用貪心策略編寫代碼:

 1 class KnightTourGreedy:
 2     def __init__(self):
 3         # 棋盤的行數和列數
 4         self.row_num, self.col_num = 8, 8
 5         # 方格的初始狀態
 6         self.s_init = 0
 7         # 棋盤
 8         self.chess_board = [[self.s_init] * self.row_num for i in range(self.row_num)]
 9         # 差值向量,表示騎士移動的八個方向
10         self.v_move = [(-1, -2), (-2, -1), (-2, 1), (-1, 2), (1, 2), (2, 1), (2, -1), (1, -2)]
11         # 計數器終點
12         self.max = self.row_num * self.col_num
13         # 解決方案
14         self.answer = None
15
16     def enable(self, x, y):
17         ''' 判斷x,y位置是否可走 '''
18         # 邊界條件判斷 and x,y位置是否曾經到達過
19         return 0 <= x < self.col_num and 0 <= y < self.row_num and self.chess_board[x][y] == self.s_init
20
21     def get_width(self, x, y):
22         '''  x,y位置的「寬度」,數值越小,後面的路越窄  '''
23         # 若是(x, y)位置曾經達到過,返回9(比八個方向多1)
24         if self.enable(x, y) == False:
25             return 9
26         n = 0
27         for v_x, v_y in self.v_move:
28             if self.enable(x + v_x, y + v_y):
29                 n += 1
30         return n
31
32     def find_min(self, x, y):
33         ''' 找到從(x,y)出發,路「最窄」的下一個位置(下一個位置可到達的「不曾到訪」方格數最少)  '''
34         min_x, min_y, min_n = -1, -1, 100
35         for v_x, v_y in self.v_move:
36             n = self.get_width(x + v_x, y + v_y)
37             if n < min_n:
38                 min_x, min_y, min_n = x + v_x, y + v_y, n
39         return min_x, min_y
40
41     def move(self, x, y, count):
42         ''' 騎士從(x,y)位置開始旅行  '''
43         # 找到一種方法就退出
44         if self.answer is not None:
45             return
46         # 若是已經走遍了全部方格,該問題解決
47         if count > self.max:
48             self.answer = self.chess_board
49             return
50
51         if self.enable(x, y):
52             self.chess_board[x][y] = count
53             # 找出八個方向中,路「最窄」的一個
54             next_x, next_y = self.find_min(x, y)
55             # 向路「最窄」的方向繼續前進
56             self.move(next_x, next_y, count + 1)
57
58     def start(self, x, y):
59         ''' 旅行開始 '''
60         self.move(x, y, 1)
61
62     def display(self):
63         if self.answer is None:
64             print('No answers!')
65             return
66
67         for row in self.answer:
68             for c in row:
69                 print('%4d' % c, end='')
70             print()
71
72 if __name__ == '__main__':
73     kt = KnightTourGreedy()
74     kt.start(0, 0)
75     kt.display()

  KnightTourGreedy的基本數據模型、棋盤邊界判斷和打印方法都和KnightTour一致。get_width用於計算從(x,y)位置的寬度,數值越小,該位置後面的路越「窄」,越難以到達。

  對於路的寬窄來講,最窄是0,表示無路可走;最大是8,能夠向8個方向前進(不能回到出發的位置)。爲了讓更便於find_min方法選擇「最窄」的路,若是(x,y)曾經到訪過,則(x,y)的寬度是9(能夠選擇大於8而且小於min_n初始值的任何數),從而保證曾經到訪過的方格必定寬於不曾到訪的方格,以使得find_min不會選中曾經到訪過的方格。move方法沒有任何回溯,只是簡單地向最窄的方向一步步走下去:

  改爲8×8或16×16的大棋盤後,KnightTourGreedy也能夠快速得出結果:

  對於一些更大的棋盤,KnightTourGreedy運行時可能會出現「RecursionError: maximum recursion depth exceeded in comparison」,這是因爲遞歸深度超過了Python的默認限制。解決這一問題有兩種方法,一種是經過sys.setrecursionlimit()修改遞歸的默認深度,另外一種是將遞歸改爲循環。

  


   做者:我是8位的

  出處:http://www.cnblogs.com/bigmonkey

  本文以學習、研究和分享爲主,如需轉載,請聯繫本人,標明做者和出處,非商業用途! 

  掃描二維碼關注公衆號「我是8位的」

相關文章
相關標籤/搜索