11月13, 2019

广度优先遍历 VS 水壶问题

推荐书籍

title

这是我从大量算法书籍中精心筛选出来的,它

  • 重视算法原理的理解,
  • 用生动的例子替代晦涩的公式证明,
  • 绘图也非常有趣,

​对非专业开发者十分友好​。 我的算法教程,也是以《算法图解》 为蓝本展开​。

获取渠道

除了购买纸质图书外,你还可以从微信读书搜索到,领用无限读书券非常划算。

准备开始

本节是对《算法图解》第六章的拓展,算法图解对广度优先搜索的解释非常清晰,我几乎无处插嘴。偶然看到知乎老钱提到字节跳动的面试题,顺手解决一下。

水壶问题

这个问题是这样的:

给你一个装满水的 8 升满壶和两个分别是 5 升、3 升的空壶,请想个优雅的办法,使得其中一个水壶恰好装 4 升水,每一步的操作只能是倒空或倒满。

title

(图非常简单,直接引用了老钱的图)

初始条件是一个满 8L 的水壶,还有两个空的 5L 和 3L 的水壶。这样的问题在小学的时候没少算,我们需要做的就是:

  • 先这样
  • 再这样
  • 最后这样

凭借聪明的大脑,很容易就能 “凑出” 答案。为什么说是 “凑出” 呢?因为这个问题有更科学、优雅的解决方案:广度优先遍历。

一图足矣

如果我们讲这个问题转换为数学模型,用 (8,0,0) 这样的元组表示 3 个水桶内的水量。其实这道题的答案就藏在这样的图里,说藏可能都有点过分了,因为太明显。

title

我已经将合理的节点用橙色标出,从它就可以确认一条路径,你找到答案了。

图中每一个子节点都是对上一层级的状态,做一次操作更新得到的:

  • 从一个壶里向另一个壶注水
  • 直到这个壶的水注完,或者另一个壶注满。

这张图相当于从(8,0,0) 开始,推演其后所有可能状态及其路径,是不是有点像《算法图解》里提到的路径规划问题,这里是寻找从(8,0,0)到下面任意一个状态的转换路径:

  • (4, , )
  • (, 4, )
  • (, , 4)

(星号表示不关心这里的数据)

有朋友就会很好奇,明明 (5,3,0) 的下一个转换可能是(5,0,3),为什么图里没有画呢?确实,但是 (5,0,3) 已经在之前出现过了,如果这样做了,就会出现“回头路”,形成循环,永远也不会找到答案了。

或者说,这样做是为了使得问题 “收敛”,不至于无限制发散。

计算新状态的时候,丢弃已经出现过的状态称之为“剪枝”。

广度优先和深度优先

以简单一些的二叉树来说明。

title

广度优先遍历: ABCDEFG... 广度优先遍历需要按照层次遍历,遍历完一层所有节点才继续深入下一层。

深度优先遍历: ABDEICF... 深度优先会直接遍历,探到树的末梢。

对于水桶问题的路径,我们需要找到尽量快的方法得到 4L 水,对于一个路径,每深入一层就是一个步骤。另一方面,出于剪枝的要求,先出现的路径会占据优势,后出现的路径会被淘汰。因此我们需要选用广度优先的遍历方法,在同一层内尽可能寻找答案,不得已的时候,再下探一层,这样可以得到尽可能短的路径。

水桶的实现

这里的水桶本身有约束条件:

  • 只能倒空
  • 或者倒满

因此我实现了一个水杯类,使用 fillup 或者 fill 方法向水桶注入水,以便作为初始条件。

一个特别注意的点,水杯类也允许从一个桶里向另一个桶倒水,只要 fill 的参数是水源的桶就行。

剪枝

剪枝保证了结果的收敛,每当推演出一个状态,就需要检查这个状态是否已经出现过,如果出现过就需要丢弃。这里使用 python 自带的 set —— 集合。集合是无序元素的组合,可以快速的增删成员、判断成员,用在这里正合适。

初始化状态

  • 在 cup_map 中保存了每一步所有可能的出水和入水桶索引。
  • cups 中初始化了 3 个容量分别为 8、5、3 的桶。
  • result_set 记录已经出现过的状态,(8,0,0) 表示最开始第一个桶里有 8L 水,以后当然也不能出现这种情况了。

代码在这里:

title

广度遍历

为了一层一层遍历路径,这里使用 End_point 表示路径中的一个节点,每个节点记录了从初始状态 (8,0,0) 到当前状态的路径,以及该节点所有水桶的状态。多个 End_point 构成的数组,表示一个遍历层。当遍历到某一层没有数据了,就可以结束遍历了。

注意这里使用了递归:

  • 如果当前层没有节点,退出。
  • 遍历当前层
  • 再遍历下一层

最后的结果

$ python water_cup.py 
cup size:
8
5
3
('ok path:', [(8, 0, 0), (3, 5, 0), (3, 2, 3), (6, 2, 0), (6, 0, 2), (1, 5, 2), (1, 4, 3)])
('ok path:', [(8, 0, 0), (5, 0, 3), (5, 3, 0), (2, 3, 3), (2, 5, 1), (7, 0, 1), (7, 1, 0), (4, 1, 3)])

看来最开始我绘制的路线图不完整,有两条合理的结果。这也许就是算法和计算机超越人脑的地方吧。

完整的代码在这里

本文链接:http://www.thinkinpython.com/post/BFS_VS_WATER_CUP.html

-- EOF --