从两个桶兑水的问题说起(water jug puzzle)

问题描述

    也许我们在很小的时候就已经听说过了这个问题。假定我们有两个桶,一个容量为3升,一个容量为5升,我们怎么能够不通过其他度量工具的帮助兑出4升的水来。假定水是无限的。

 

问题分析

    如果单纯针对这个问题来看,相信我们还是可以很容易的得到一个推导过程。既然我们有两个桶,一个是3升,一个是5升,那么我们可能需要将一个桶装满水,然后倒到另外一个桶里,通过将一个桶倒满而另外一个桶可能还有盈余来达到最终兑换出期望容量的水的目的。按照这个思路,我们可以开始第一步分析。

 

初步分析

    假定我们定义这两个桶分别为a, b。那么它们这两个桶里水的容积可以表示为(x, y)这样一个数对。那么我们整个兑水的过程可以描述如下:

1. (0, 0)    最开始两个桶都为空。

2. (3, 0)    我们将桶a倒满

3. (0, 3)    将桶a的水倒入桶b

4. (3, 3)    将桶a倒满

5. (1, 5)    将桶a的水倒入桶b,注意,因为桶b的容量为5升,所以只能倒入2升后,桶a还剩余1升

6. (1, 0)    将桶b的水倒掉

7. (0, 1)    将桶a的水倒入桶b

8. (3, 1)    将桶a倒满

9. (0, 4)    将桶a的水倒入桶b

    我们可以看到,到第9步的时候,桶b里的水就正好是我们所期望的4升。我们成功的解决了这个问题。

    从这个特定的问题本身,似乎没什么特殊的,我们就这么来回的倒腾似乎有点撞大运,最后把期望容量的水给兑出来了。可是,如果我们再深层次的去想想。是不是任意一个给定数量的水我们都可以通过指定容量的两个桶给兑换出来呢?如果可以兑换的话,它们之间有没有什么规律?如果不行的话,问题的根源又是在哪里呢? 

 

进一步分析 

    我们带着前面的疑问再回顾一下这个问题。因为我们单单只知道桶的容量,所以如果不是将一个桶里的水倒满或者倒空,我们是没法知道桶里水的确切容量的。我们在前面能够兑出其他容量的水,很大一部分原因是利用两个桶之间的容积差。比如这个3升和5升的桶,如果将5升的桶倒满再倒入3升的桶,那么5升的桶里剩下的水就有2升。同样,我们将3升的桶装满,往5升的桶里倒两次,3升的桶里会剩下1升的水。前面我们这些倒水和兑换的过程,似乎总是要将一个桶装满,然后再倒入到另外一个桶,这样倒入的结果必然使得不是一个桶空就是一个桶满。

    嗯,到这一步的时候,我们似乎发现一点什么规律了。针对前面兑水的这个过程,如果我们用一个更加一般形式的数学表达式来描述的话,则形式如下:

假设两个桶容量分别为a, b

(0, 0) -> (a, 0)    装满第一个桶

         -> (0, a)    倒入第2个桶

        -> (a, a)     装满第一个桶

        -> (2a - b, b)    倒入第2个桶 (假定第二个桶容量 b < 2a)

        -> (2a - b, 0)    到空第2个桶

        -> (0, 2a - b)    倒入第2个桶

        -> (a, 2a - b)    倒满第1个桶

        -> (3a - 2b, b)   倒入第2个桶(假定3a > 2b)

     这个时候我们来看两个桶里水的容量,发现他们要么是0, a, b, 要么就是一些a,b的组合表示,我们可以将他们表示成sa + tb这样的形式。这个样式不就是一个线性组合的表达形式么?桶里水的容量不管是0, a, b都是对应着sa + tb这个表达式的一种情况。那么最终两个桶里水的容量是不是符合这个规律呢?我们这个时候就需要花点时间证明了。

    我们这里就请出数学归纳法:假设我们有两个桶,容量分别为a和b。那么按照前面兑水的过程,最后每个桶里的水总是a和b构成的一个线性组合。

     前面这部分就是我们要证明的命题。如果我们用P(n)来表示经过n步兑水的过程之后的结果,那么我们期望的结果是最终n + 1的情况也满足这个线性组合的特性。

     我们先来看最初始的情况,假设n = 0,这个时候表示最开始两个桶都是空的. 这个时候0a + 0b = 0,对于0这种情况命题成立。

    现在假设经过n步之后,每个桶里的水都是a, b的线性组合。我们要来推导第n +1步的结果。针对这个第n+1步有几种情况:

1. 如果我们将一个桶装满水或者倒空,那么这个时候这个桶里水的容量则为如下几种情况,0, a, b。显然,他们是a, b的一个线性组合。而对应的另外一个桶按照我们的假设它已经是一个线性组合了。所以这种情况下,命题成立。

2. 如果我们将一个桶的水倒入另外一个桶,使得一个桶为空或者一个桶满。针对这种情况,按照前面的假设,假定倒水前,两个桶的水容量分别为:

    j1 = s1 * a + t1 * b     j2 = s2 * a + t2 * b

那么在将一个桶的水倒入另外一个桶之后,则会出现一下几种情况。一个桶空了,这个时候,另外一个桶的水则为j1 + j2;或者一个桶满了,那么这个满了的桶可能是a,也可能是b,则另外一个桶里的水则可能是j1 + j2 - a或者j1 + j2 - b。而针对这几种情况,它们实际上还是可以最后表示成sa + tb这样的形式。

    所以,我们最终证明得出,以上倒水的过程最终使得每个桶里的水容量为a, b的一个线性组合

    可是,光有了这么一个结论似乎也没帮上多大的忙啊。比如说我们有两个桶,再给定一个需要兑出目标体积的水时,我们只知道可以兑出的水是我们两个桶容积的线性组合。对于目标体积的水能否兑换出来,怎么兑换还需要费一定的功夫来推导。那么还有没有更进一步的结论支持呢?

 

进一步引申

    在前面的问题中,我们分析出来目标体积的水必须是两个桶容积的一个线性组合。可是在实际中,如果给出一个目标体积的水,难道我们就按照sa + tb这样的表达式去一个一个整数的尝试吗?这肯定是一种比较愚蠢的办法。

    这个时候,我们似乎陷入了一个困境,看似没有什么有效的办法能够一步就判断出来某个给定的容积是否可行。我们再来看这个线性组合的表示方式sa + tb。我们要判定一个数字是否可以表示为sa + tb的时候会比较困难。如果我们来看看a, b之间有没有什么共同的关系呢?

    这一步会比较困难,不过如果我们想到最大公约数的话,这个问题就有了一个新的思路。我们知道,对于两个整数来说,它们的最大公约数GCD(a, b)同时整除这两个数字。那么对于数字a和b来说,他们可以分别表示为a = s * GCD(a, b), b = t * GCD(a, b)。那么对于a和b的线性组合sa + tb来说呢?很显然,它们也必然能够被GCD(a, b)整除。

这个时候,我们就发现了一个非常重要的结论: 对于a, b的线性组合sa + tb,它们能够被GCD(a, b)整除

     那么有了前面这个引申,我们可以发现,如果对于一个给定的目标水容量,如果它们不能被GCD(a, b)整除,那么它们肯定就不能够构成a, b的线性组合。可是,如果一个给定的目标水容量能够被GCD(a, b)整除了,那么它是不是就一定是a, b的线性组合呢?因为前面的证明结果相当于只是证明了一半。只有这部分也保证成立的话,我们才能说明一个给定的数字能够被GCD(a, b)整除<==> 这个数字是a, b的线性组合。好吧,看来我们就差这一点了。

    我们假定有一个最小的线性组合m = sa + tb。这里m > 0。那么我们a可以表示成如下的关系:

a = qm + r (r>=0 r < m)

    我们将m表达式代入到这个表达式里得到a = q(sa + tb) +r

    因此: r = (1 - qs)a + (-qt)b.  这里r可以表示为一个a, b的线性组合,而且还有r>=0 r < m。而根据前面的假设,m是最小的线性组合。所以这里r只能为0. 那这就说明了a = qm,也就是说a可以被m整除。而同理我们也可以证明b也可以被m整除。这里m能够同时整除a, b,而GCD是能整除a, b的最大整数,所以必然有m <= GCD(a, b)。可是从前面的讨论里我们知道所有a, b的线性组合可以被GCD(a, b)整除,也就说明了m也能被GCD(a, b)整除。这又说明了GCD(a, b) <= m。所以我们会发现对于最大公约数来说,它本身也是一个a, b的线性组合。只不过是一个最小线性组合。这样我们也就证明了如果一个给定的数字能够被GCD(a, b)整除,它一定就可以表示成a, b的线性组合。

 

综合

    前面我们花费了大量的篇幅只是为了证明从两个桶倒水得到的水容量的范围以及如何判断某些指定的值是否可以达到。结合前面的结论,我们可以发现,给定一个数字,如果我们需要证明它是否能被兑换出来只要看它是否能够被两个桶容量的最大公约数整除就可以了。那么,在我们的具体实现里,第一步就需要知道怎么来求得它们的最大公约数。在我的这篇文章里对怎么求最大公约数有了详细的说明。这里把实现求最大公约数的代码给贴过来:

递归版本:

public long gcd(long a, long b)
{
	if(b == 0)
		return a;
	else
		return gcd(b, a % b);
}

 非递归版本:

public long gcdIter(long a, long b)
{
	if(b == 0)
		return a;
	while(true)
	{
		a = a % b;
		if(a == 0)
			return b;
		b = b % a;
		if(b == 0)
			return a;
	}
}

    有了第一步计算出来了最大公约数之后,我们就可以以这个作为判断的依据了。那么,这个判断的代码则非常简单:

public boolean isReachable(int a, int b, int volume) {
    int gcd = gcd(a, b);
    return (volume % gcd == 0)
}

    现在,假设我们要进一步,根据判断,我们确实可以得到目标水量,那么我们该如何来用程序实现这个过程呢?代码实现如下:

public static void pourJugs(int a, int b, int volume) {
        if(isReachable(a, b, volume)) {
            int target = volume % b;
            int a1 = 0, b1 = 0;
            while(b1 != target) {
                if(a1 == 0) {
                    a1 = a;
                    System.out.printf("(%d, %d)\ta full\n", a1, b1);
                }else if(b1 + a1 < b) {
                    b1 += a1;
                    a1 = 0;
                    System.out.printf("(%d, %d)\tpour a into b\n", a1, b1);
                } else {
                    int c = a1 + b1;
                    a1 = c - b;
                    b1 = b;
                    System.out.printf("(%d, %d)\tpour a into b, b full\n", a1, b1);
                    b1 = a1;
                    a1 = 0;
                    System.out.printf("(%d, %d)\tempty b, pour a into b\n", a1, b1);
                }
            }
        } else
            System.out.println("Volume " + volume + " cannot be reached.");
    }

   这部分的代码写的比较急,还有一些细节的地方没有优化。不过其实质上就是通过不断将a的水倒满,然后再倒入b,在这个过程中判断b是否满。如果b已经满了,则将b清空,然后再将a倒入。否则将a的水继续倒入。每次要判断两个桶里的水。完整可编译运行的代码放在后面的附件里。

    如果我们运行pourJugs(3, 5, 4) ,则可以得到如下的输出:

(3, 0)	a full
(0, 3)	pour a into b
(3, 3)	a full
(1, 5)	pour a into b, b full
(0, 1)	empty b, pour a into b
(3, 1)	a full
(0, 4)	pour a into b

 

总结

    很多时候一个看似很小的问题,其实它的后面都蕴藏着一个有趣的数学思想。从两个桶兑水的问题,我们可以推导出水和桶容量之间构成的一个线性组合关系。再基于这个线性组合关系,我们又发现它们和最大公约数有密切的关系。而且,基于它们之间的关系我们可以很容易的判断出给定两个桶,是否能够兑换出给定容积水的结论。这些推导的结果对于我们后面用代码来实现最终的兑换也起到了指导作用。

 

参考材料

Mathematics for computer science 

http://shmilyaw-hotmail-com.iteye.com/blog/1752360

你可能感兴趣的:(water)