貪心策略
很多時候,我們只需要找到問題的最優解,如果使用盲目搜索策略,就必須先找出所有解,再進一步比較哪個是最優的,當在解空間十分龐大時,難免有些浪費體力的感覺。這時候,不妨試試更高效的貪心策略。
貪心策略也叫貪心算法(greedy algorithm)或貪婪算法,是一種強有力的窮舉搜索策略,它通過一系列選擇來找到問題的最優解。在每個決策點,它都會做出當時看來是最優的選擇,一旦選擇后就無需回溯。簡單來說,貪心策略是一種“步步為營”的策略——只要做好眼前的每一步,就自然會在未來得到最好的結果,並且做過的決策就是是最好的決策,無需再次檢查。
很多時候,貪心法並不能保證得到最優解,它能得到的是較為接近最優解的較好解,因此貪心法經常被用來解決一些對結果精度要求不高的問題。
小偷的背包
一個小偷撬開了一個保險箱,發現里面有N個大小和價值不同的東西,但自己只有一個容量是M的背包,小偷怎樣選擇才能使偷走的物品總價值最大?
假設有5個物品A,B,C,D,E,它們的體積分別是3,4,7,8,9,價值分別是4,5,10,11,13,可以用矩形表示體積,將矩形旋轉90°后表示價值:

下圖展示了一個容量為17的背包的4中填充方式,其中有兩種方式的總價都是24:

背包問題有很多重要的實應用,比如長途運輸時,需要知道卡車裝載物品的最佳方式。
搜索策略
我們基於貪心策略去解決背包問題:在取完一個物品后,找到填充背包剩余部分的最佳方法。對於一個容量為M的背包,需要對每一種類型的物品都推測一下,如果把它裝入背包的話總價值是多少,依次遞歸下去就能找到最佳方案。這個方案的原理是,一旦做出了最佳選擇就無需更改,也就是說一旦知道了如何填充較小容量的背包,則無論下一個物品是什么,都無需再次檢驗已經放入背包中的物品(已經放入背包中的物品一定是最佳方案)。
尋找解決方案
首先定義物品的數據模型:
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方法尋找最佳填充方案。該方法接收背包容量和物品清單兩個參數,返回背包最大價值和最佳填充方案:
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位的

