搜索的策略(2)——贪心策略
贪心策略
很多时候,我们只需要找到问题的最优解,如果使用盲目搜索策略,就必须先找出所有解,再进一步比较哪个是最优的,当在解空间十分庞大时,难免有些浪费体力的感觉。这时候,不妨试试更高效的贪心策略。
贪心策略也叫贪心算法(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位的