三只水桶二等分水问题
试着划出一些自己的时间,学习一些算法,当知底蕴需要沉淀!
学习:转载地址
感谢无数的大神愿意分享自己的经验与感悟,也同样利用无数开源的项目去学习进步,甚至是生存,在此一并感谢!
问题:如何将容积为8升的水桶里装满水,且利用一个容积为3升和5升的空桶,均分这8升水?
答案:共需要7次倒水动作:
1. 从容积是8升的桶中倒5升水到容积是5升的桶中
2. 从容积是5升的桶中倒3升水到容积是3升的桶中
3. 从容积是3升的桶中倒3升水到容积是8升的桶中
4. 从容积是5升的桶中倒2升水到容积是3升的桶中
5. 从容积是8升的桶中倒5升水到容积是5升的桶中
6. 从容积是5升的桶中倒1升水到容积是3升的桶中
7. 从容积是3升的桶中倒3升水到容积是8升的桶中
<结束>
解决问题的思路
如果用人的思维方式,那么解决这个问题的关键是怎么通过倒水凑出确定的1升水或能容纳1升水的空间,考察三只水桶的容积分别是3、5和8,用这三个数做加减运算,可以得到很多组答案,例如:
3 – (5 - 3) = 1
这个策略对应了上面提到的第一种解决方法,而另一组运算:
(3 + 3)- 5 = 1
则对应了上面提到的第二种解决方法。
计算机解决这个问题,通常会选择使用“穷举法”。题意而言,并不关心什么方法最快,能求出全部等分水的方法可能更符合题意。
如果我们把某一时刻三个水桶中存水的容积称为一个状态,则问题的初始状态是8升的水桶装满水,求解的解出状态(最终状态)是8升水桶中4升水,5升水桶中4升水。穷举法的实质就是把从初始状态开始,根据某种状态变化的规则搜索全部可能的状态,每当找到一个从初始状态到最终状态的变化路径,就可以理解为找到了一种答案。这样的状态变化搜索的结果通常是得到一棵状态搜索树,根节点是初始状态,叶子节点可能是最终状态,也可能是某个无法转换到最终状态的中间状态,状态树有多少个最终状态的叶子节点,就有多少种答案。根据以上分析结果,解决本问题的算法关键有三点:首先,建立算法的状态模型;其次,确定状态树的搜索算法(暗含状态转换的规则);最后,需要一些提高算法效率的手段,比如应用“剪枝”条件避免重复的状态搜索,还要避免状态的循环生成导致搜索算法在若干个状态之间无限循环。
第一步:状态和动作的数学模型
建立状态模型是整个算法的关键,这个状态模型不仅要能够描述静止状态,还要能够描述并记录状态转换动作,尤其是对状态转换的描述,因为这会影响到状态树搜索算法的设计。所谓的静止状态,就是某一时刻三个水桶中存水的容积,我们采用长度为3的一维向量描述这个状态。这组向量的三个值分别是容积为8升的桶中的水量、容积为5升的桶中的水量和容积为3升的桶中的水量。因此算法的初始状态就可以描述为[8 ,0, 0],则终止状态为[4, 4, 0]。
对状态转换的描述就是在两个状态之间建立关联,在本算法中这个关联就是一个合法的倒水动作。某一时刻三个水桶中的存水状态,经过某个倒水动作后演变到一个新的存水状态,这是对状态转换的文字描述,对算法来讲,倒水状态描述就是“静止状态”+“倒水动作”。我们用一个三元组来描述倒水动作:{from, to, water},from是指从哪个桶中倒水,to是指将水倒向哪个桶,water是此次倒水动作所倒的水量。本模型的特例就是第一个状态如何得到,也就是[8, 0, 0]这个状态对应的倒水动作如何描述?我们用-1表示未知的水桶编号(上帝水桶),因此第一个状态对应的倒水动作就是{-1, 1, 8}。应用本模型对前面提到的第一种解决方法进行状态转换描述,整个过程如图(1)所示:
图1 一个解决方法的状态转换图
为了算法实现过程中方便数据管理,用C/C++语言描述的倒水动作三元组是一个struct,定义如下:
struct Action{
int from;
int to;
int water;
};
Action数据结构的三个属性分别对应动作三元组中的三个成员。BucketState是状态模型的C/C++语言描述,一维向量bucket_s是三个水桶中水的状态,curAction是与之对应的倒水动作,在状态模型中增加倒水动作对本题的数学模型来说不是必需的,它的存在只是为了算法结果输出的需要,就是要能够描述并记录状态转换动作。BucketState的C/C++语言描述如下所示:
struct BucketState {
......
int bucket_s[buckets_count]; /*状态向量*/
Action curAction; /*倒水动作*/
......
};
第二步:状态树搜索算法
确定了状态模型后,就需要解决算法面临的第二个问题:状态树的搜索算法。一个静止状态结合不同的倒水动作会迁移到不同的状态,所有状态转换所展示的就是一棵以状态[8, 0, 0]为根的状态搜索树,图(2)画出了这个状态搜索树的一部分,其中一个用不同颜色标识出来的状态转换过程(状态树的一个分支)就是本问题的一个解:
图2状态树一部分的展示
状态树的搜索就是对整个状态树进行遍历,这中间其实暗含了状态的生成,因为状态树一开始并不完整,只有一个初始状态的根节点,当搜索(也就是遍历)操作完成时,状态树才完整。树的遍历可以采用广度优先遍历算法,也可以采用深度优先遍历算法,就本题而言,要求解所有可能的等分水的方法,暗含了要记录从初始状态到最终状态,所以更适合使用深度优先遍历算法。状态树的遍历暗含了一个状态生成的过程,就是促使状态树上的一个状态向下一个状态转换的驱动过程,这是一个很重要的部分,如果不能正确地驱动状态变化,就不能实现状态树的遍历(搜索)。
建立状态模型一节中提到的动作模型,就是驱动状态变化的关键因子。对一个状态来说,它能转换到哪些新状态,取决于它能应用哪些倒水动作,一个倒水动作能够在原状态的基础上“生成”一个新状态,不同的倒水动作可以“生成”不同的新状态。由此可知,状态树遍历的关键是找到三个水桶之间所有合法的倒水动作,用这些倒水动作分别“生成”各自相应的新状态。遍历三个水桶的所有可能动作,就是对三个水桶任取两个进行全排列,共有6种水桶的排列组合,也就是说有6种可能的倒水动作。将这6种倒水动作依次应用到当前状态,就可以“生成”6种新状态,从而驱动状态发生变化(有些排列并不能组合出合法的倒水动作,关于这一点后面“算法优化”部分会介绍)。
第三步:算法优化和避免状态循环
从图(2)可以看出来,对于三个水桶这样小规模的题目,其整个状态树的规模也是相当大的,更何况是复杂一点的情况,因此类似本文这样对搜索整个状态树求解问题的算法都不得不面对一个算法效率的问题,必须要考虑如何进行优化,减少一些明显不必要的搜索,加快求解的过程。
前文讲过,状态搜索的核心是对三个水桶进行两两排列组合得到6种倒水动作,但是并不是每种倒水动作都是合法的,例如:需要倒出水的桶中没有水的情况和需要倒进水的桶中已经满的情况下,都组合不出合法的倒水动作。因为水桶是没有刻度的,因此倒水动作也是受限制的,也就是说合法的倒水动作只能有两种结果:需要倒出水的桶被倒空和需要倒进水的桶被倒满。加上这些限制之后,每次组合其实只有少数倒水动作是合法的,可以驱动当前的状态到下一个状态。利用这一点,就可以对状态树进行“剪枝”,避免对无效(非法)的状态分支进行搜索。
除了通过“剪枝”提高算法效率,对于深度优先的状态搜索还需要防止因状态的循环生成造成深度优先搜索无法终止的问题。状态的循环生成有两种表现形式:一种是在两个桶之间互相倒水;另一种就是图(2)中展示的一个例子,[3, 5, 0] -> [3, 2, 3] -> [6, 2, 0] -> [3, 5, 0]形成一个状态环。要避免出现状态环,就需要记录一次深度遍历过程中所有已经搜索过的状态,形成一个当前搜索已经处理过的状态表,每当生成一个新状态,就先检查是否是状态表中已经存在的状态,如果是则放弃这个状态,回溯到上一步继续搜索。如果新状态是状态表中没有的状态,则将新状态加入到状态表,然后从新状态开始继续深度优先遍历。在这个过程中因重复出现被放弃的状态,可以理解为另一种形式的“剪枝”,可以使一次深度优先遍历很快收敛到初始状态。
Last but not least:算法实现
解决了算法的三个关键点后,剩下的问题就是写出算法了。先看看“剪枝”的实现:
bool IsCurrentActionValid(BucketState& current, int from, int to){
/*从from到to倒水,如果成功,返回倒水后的状态*/
if( (from != to)&& !current.IsBucketEmpty(from)&& !current.IsBucketFull(to) ){
return true;
}
return false;
}
正如前文分析的那样,当需要倒出的水桶是空的或需要倒入的水桶已满时,from->to就不是合法的倒水动作,当然,任何一个水桶也不能向自身倒水,这个是常识,但是计算机不知道,所以 (from != to) 就是告诉它这样不行。
在状态搜索的过程中需要维护一张已经搜索过的状态列表,算法实现采用STL::Deque来组织这个列表。因为搜索算法的关系,这个列表中的状态是有顺序的,并且每个状态数据内部都记录有这个状态对应的倒水动作,所以遍历这个列表就可以知道当前状态是从初始状态经过怎样的一个倒水过程。如果当前状态就是结果状态,就可以根据这个列表输出整个倒水动作序列。PrintResult()函数输出整个倒水动作序列的:
void PrintResult(deque<BucketState>& states){
cout << "Find Result : " << endl;
for_each(states.begin(), states.end(),
mem_fun_ref(&BucketState::PrintStates));
cout << endl << endl;
}
PrintStates()函数是BucketState的成员函数,负责打印一个状态自身,包括当前桶中水的状态以及倒水动作:
void BucketState::PrintStates(){
cout << "Dump " << curAction.water << " water from "
<< curAction.from + 1 << " to " << curAction.to + 1 << ", ";
cout << "buckets water states is : ";
for(int i = 0; i < buckets_count; ++i)
{
cout << bucket_s[i] << " ";
}
cout << endl;
}
IsProcessedState()函数判断一个状态是否是状态列表中已经存在状态,利用STL库的便利,这个函数的实现也很简单:
bool IsProcessedState(deque<BucketState>& states, const BucketState& newState)
{
deque<BucketState>::iterator it = states.end();
it = find_if( states.begin(), states.end(),
bind2nd(ptr_fun(IsSameBucketState), newState) );
return (it != states.end());
}
实现了“剪枝”判断函数,又知道了状态列表是以什么形式组织的,剩下的工作就是完成状态树的搜索了。状态树的搜索是一个递归的过程:从初始状态开始,由第一个合法的倒水动作得到一个新的状态,记录这个状态,并从这个新状态开始遍历穷举,穷举完成后(无论是否得到结果),取消这个状态,然后从下一个合法的倒水动作再得到一个新状态,然后从这个状态开始遍历穷举,直到遍历完所有合法的倒水动作。
状态搜索的结束条件是什么?算法的广度搜索结束条件是遍历完所有的合法倒水动作,深度搜索的结束条件有两个:一个是得到最终状态(成功的情况),另一个是从某个状态开始所有合法倒水动作得到的新状态都和与已经遍历过的状态列表中的状态重复(失败的情况)。
SearchState()函数就是状态搜索算法的核心,这个函数首先检查当前状态列表的最后一个状态是否是结果需要的最终状态([4, 4, 0]),如果是最终状态,就表示搜索到一个结果,通过PrintResult()函数遍历状态列表,输出当前结果状态转换的整个过程(倒水动作序列)。如果当前状态不是最终状态,就通过一个两重循环遍历6种可能的倒水动作。
void SearchState(deque<BucketState>& states){
BucketState current = states.back(); /*每次都从当前状态开始*/
if(current.IsFinalState()){
PrintResult(states);
return;
}
/*使用两重循环排列组合6种倒水状态*/
for(int j = 0; j < buckets_count; ++j){
for(int i = 0; i < buckets_count; ++i){
SearchStateOnAction(states, current, i, j);
}
}
}
SearchStateOnAction()函数对每种可能的倒水动作检查是否是合法,如果是合法动作就生成一个新的状态,如果这个状态是状态列表中不存在的新状态,则将之加入到状态列表,然后递归地调用SearchState()函数继续对新状态进行深度优先搜索。
SearchStateOnAction()函数的实现如下:
void SearchStateOnAction(deque<BucketState>& states, BucketState& current, int from,int to{
if(IsCurrentActionValid(current, from, to)){
BucketState next;
/*从from到to倒水,如果成功,返回倒水后的状态*/
bool bDump = current.DumpWater(from, to, next);
if(bDump && !IsProcessedState(states, next)){
states.push_back(next);
SearchState(states);
states.pop_back();
}
}
}
SearchStateOnAction()函数调用了一个很有意思的函数:DumpWater()。DumpWater()函数的主要作用是模拟一次倒水动作,确定能够从from桶中倒多少水到to桶,从而得到一个完整的动作三元组并根据这个动作从current状态生成新的状态next。从from桶向to桶倒水只能有两种情况,一种是from的水被倒空(to桶可能满,也可能不满),另一种是to桶被装满(from桶可能还剩一些水,也可能被倒空),这两个约束在DumpWater()函数中得到了体现:
bool BucketState::DumpWater(int from, int to, BucketState& next){
int bucket_water[buckets_count] = { 0 };
GetBuckets(bucket_water);
int dump_water = bucket_capicity[to] - bucket_water[to];
if(bucket_water[from] >= dump_water){
bucket_water[to] += dump_water;
bucket_water[from] -= dump_water;
}else{
bucket_water[to] += bucket_water[from];
dump_water = bucket_water[from];
bucket_water[from] = 0;
}
/*是'ca?一'd2?个'b8?有'd3?效'd0?的'b5?倒'b5?水'cb?动'b6?作'd7??*/
if(dump_water > 0){
next.SetBuckets(bucket_water);
next.SetAction(dump_water, from, to);
}
return (dump_water > 0);
}
至此,整个算法就完成,算法得到的全部16种答案的倒水过程:
#include <iostream>
#include <cstdlib>
#include <vector>
#include <algorithm>
using namespace std;
vector<int> bucket_size;
struct action {
int from;
int to;
int water;
};
//每个桶状态和倒水动作(倒水动作是由上一个状态到这个状态的倒水动作)
struct bucket_states {
vector<int> states_vector;
action ac;
bucket_states(int a, int b, int c, int from, int to, int water) {
states_vector.resize(3);
states_vector[0] = a;
states_vector[1] = b;
states_vector[2] = c;
ac.from = from;
ac.to = to;
ac.water = water;
}
bucket_states(){
states_vector.resize(3);
}
void set_action(int dump_water, int from, int to) {
ac.from = from;
ac.to = to;
ac.water = dump_water;
}
bool is_empty(int bucket_idx) {
if(bucket_idx > 2) {
return false;
}
return (states_vector[bucket_idx] == 0);
}
bool is_full(int bucket_idx) {
return (states_vector[bucket_idx] >= bucket_size[bucket_idx]);
}
bool is_final() {
return (states_vector[0] == 4 && states_vector[1] == 4);
}
bool dump_water(int from, int to, bucket_states& next) {
vector<int> bucket_water(this->states_vector);
//倒水数目是to桶剩余的空间
int dump_water = bucket_size[to] - states_vector[to];
//如果from中的水大于to桶剩余的空间,则倒出dunp_water的水量
if(bucket_water[from] >= dump_water) {
bucket_water[to] += dump_water;
bucket_water[from] -= dump_water;
} else {//否则倒出from桶中的所有水
dump_water = bucket_water[from];
bucket_water[to] += dump_water;
bucket_water[from] = 0;
}
if(dump_water > 0) {
next.states_vector = bucket_water;
next.set_action(dump_water, from, to);
return true;
}
return false;
}
};
void print_states(bucket_states& bucket){
cout << "bucket_states : " << bucket.states_vector[0] << " " <<
bucket.states_vector[1] << " " << bucket.states_vector[2] <<
" from " << bucket.ac.from << " to " << bucket.ac.to <<
" dump " << bucket.ac.water << "L water." << endl;
}
//禁止给自己倒水,禁止从空桶取水,禁止给满桶倒水
bool is_action_valid(bucket_states& cur, int from, int to) {
if((from != to) && !cur.is_empty(from) && !cur.is_full(to))
return true;
return false;
}
bool is_loop(vector<bucket_states>& states, bucket_states& next) {
int i = 0;
for(; i < states.size(); ++i) {
if(equal(next.states_vector.begin(), next.states_vector.end(),
states[i].states_vector.begin()))
return true;
}
return false;
}
//搜索算法
void DFS(vector<bucket_states>& states, int& cnt, int& shortest) {
bucket_states cur = states.back();
//判断是否是最终状态, 打印倒水过程,记录方案数目以及需要最少操作的数目
if(cur.is_final()) {
++cnt;
shortest = min(shortest, static_cast<int>(states.size()));
for_each(states.begin(), states.end(), print_states);
cout << "=====================" << endl;
return;
}
for(int i = 0; i < 3; ++i)
for(int j = 0; j < 3; ++j) {
if(is_action_valid(cur, i, j)) {
bucket_states next_states;
//进行状态转移,执行倒水动作,并判断倒水动作是否有效
bool is_dump = cur.dump_water(i, j, next_states);
//检查倒水动作是否可以驱动到下一个有效状态,以及下一个状态是否已经重复
if(is_dump && !is_loop(states, next_states)) {
states.push_back(next_states);
DFS(states, cnt, shortest);
states.pop_back();
}
}
}
}
int main() {
bucket_size.push_back(8);
bucket_size.push_back(5);
bucket_size.push_back(3);
vector<bucket_states> states;
bucket_states start(8, 0, 0, -1, 0, 8);
states.push_back(start);
int cnt = 0, shortest = INT_MAX;
DFS(states, cnt, shortest);
cout << "result size : " << cnt << " shortest : " << shortest <<endl;
}