一、问题描述
在12个外观完全相同的小球中,有一个与其它球重量不同。如何只用一架天平找到这个球并判断它比其它球轻还是重?最少需要称几次?39个球呢?
二、问题分析
这是一个很经典的信息论问题,最开始的思路是用分组称重的方法,发现每次测量的次数不固定,而且当球数增多时称重次数会明显增加,所以不是次数最优的方法,于是参考了网上编码的方法并做了一点调整。
一共有12个小球,每个小球都可能偏轻或偏重,共有24种可能,其信息量为
log224≈4.58bit
l
o
g
2
24
≈
4.58
b
i
t
。利用天平进行称重,天平每次显示结果可能为左倾、右倾或平衡三种,假如出现每种情况的概率相同,则每次称重结果的信息量为
log23≈1.58bit
l
o
g
2
3
≈
1.58
b
i
t
。
4.58bit1.58bit≈3
4.58
b
i
t
1.58
b
i
t
≈
3
,所以理论上3次称重可以找出质量不同的球并判断出它是偏轻还是偏重。
根据文献The Problem of the Pennies, F. J. Dyson, The Mathematical Gazette , Vol. 30, No. 291 (Oct., 1946), pp. 231-234 的证明可知,N次称重最多可以从
3N−32
3
N
−
3
2
个小球中找出不同的球,当N=3时可以判断12个小球的质量。
三、算法设计
要以最少次数称重得到结果,则每次称重应获取尽可能多的信息,也就是每次天平称重结果都应包含左倾、右倾、平衡三种可能的情况。于是称重时小球应该分为三堆,特殊小球随机分布于其中一堆,选择两堆置于天平两侧。对此,可以将每个小球进行编码,每次称重时根据编码选择三分之二的小球,对称重结果进行记录,并根据每次称重结果,找出异常小球,
3.1 编码要求
由于天平结果有三种,我们选择 ‘0’,‘1’,‘2’ 三种字符对小球进行编码,12个小球三次可以测出,因此每个小球由三位编码构成,编码应满足以下三个条件:
- 编码互不相同;
保证编码唯一性,每个编码对应唯一小球。 - 编码每一位均含有4个‘0’,‘1’,‘2’;
例如12个小球对应12个编码,这12个编码的第一位中‘0’,‘1’,‘2’各出现四次,第二第三位同理。目的是为了保证每次称重小球均分为三份。 - 每个小球编码对其中的‘0’,‘1’同时取反后,反编码不存在于已编好的序列中。
取反指编码中的‘0’变为‘1’,‘1’变为‘0’。结合前两条,该条件可以保证每个小球至少上一次天平,同时保证根据结果能够正确找出异常小球。
下一节将具体介绍这样编码的原因,以及利用这些编码称重的过程。
3.2 称重方法
每次称重天平结果有左倾、右倾、平衡三种,因此需要三种字符对小球进行编码,这里使用 ‘0’,‘1’,‘2’ ;12个球需要称重三次,则每个编码用三位字符表示。根据小球编码判断每次称重时该小球的位置。第 i i 次称重,选择编码第 位为‘0’的小球置于左盘,第 i i 位为‘1’的小球置于右盘,记录天平结果并生成结果序列,为了保证每次称重左右两盘球数相同,编码序列每一位应均包含相同个个数的‘0’,‘1’,‘2’。我们用表示重球序列, light l i g h t 表示轻球序列,序列生成规则如下:
天平结果 | 可能原因 | 处理方式 |
左倾 | 左盘含有重球 | heavyi=0 h e a v y i = 0 |
右盘含有轻球 | lighti=1 l i g h t i = 1 | |
右倾 | 右盘含有重球 | heavyi=1 h e a v y i = 1 |
左盘含有轻球 | lighti=0 l i g h t i = 0 | |
平衡 | 非标准球不在盘上 | heavyi=2 h e a v y i = 2 |
lighti=2 l i g h t i = 2 |
如第1次称重结果为天平右倾,则表明天平右盘含有一个重球,或左盘含有一个轻球,而此时天平右盘小球的第一位编码均为‘1’,左盘小球第一位均为‘0’,因此证明:异常球为重球且第一位为‘1’( heavy1=1 h e a v y 1 = 1 ),或者异常球为轻球且第一位为‘0’( light1=0 l i g h t 1 = 0 )。根据该规则三次称重并记录完成后, heavy h e a v y 与 light l i g h t 均为一个三位的序列,且两个序列第 i i 位或‘0’,‘1’相反,或同时为‘2’,根据编码条件三可知,两个编码不可能同时存在,因此存在的编码所对应的小球即是异常球,且若该编码为 ,则异常小球偏重,否则偏轻。
3.3 一个例子
以下12个小球中有一个为异常球,下面对小球进行一组可行的编码并通过三次称重将其找出。首先对小球进行编码:
图1. 12个小球及其对应编码
进行第一次称重,将编码第一位为‘0’的小球(1,4,7,10)置于左盘,为‘1’的小球置于右盘(2,5,8,11),称重结果如下:
图2. 第一次称重结果
由于天平左倾,则表明左盘含有重球,或右盘含有轻球: heavy1=0 h e a v y 1 = 0 , light1=1 l i g h t 1 = 1 ;
第二次称重,将编码第二位为‘0’的小球置于左盘,为‘1’的置于右盘,结果如下:
图3. 第二次称重结果
天平右倾,则表明右盘含有重球,或左盘含有轻球: heavy2=1 h e a v y 2 = 1 , light2=0 l i g h t 2 = 0 ;
第三次称重,将编码第三位为‘0’的小球置于左盘,为‘1’的置于右盘,结果如下:
图4. 第三次称重结果
天平平衡,表示两边都为正常球,异常球第三位编码为‘2’: heavy3=2 h e a v y 3 = 2 , light3=2 l i g h t 3 = 2
根据三次称重可得出 heavy=012 h e a v y = 012 , light=102 l i g h t = 102 ,表明可能存在编码为‘012’的重球,或为‘102’的轻球。通过与图1中编码进行比较,发现‘012’对应10号小球,‘102’未对应小球,则证明10号小球偏重。这与称重结果一致。
3.4 编码方式
[原理][1]
在编程实现过程中,如果依照3.1中所列条件直接生成编码难度较大,因此可以引入下面的方法:
首次不同的相邻两个字符如果为01, 12, 20,则称这个编码为正序码,如 01 01 0, 1 12 12 0, 2 20 20 02均为正序码,若首次不同的字符为10, 21, 02,则为逆序码。将一个正序码中0,1取反后将变为一个逆序码,因此对于一系列N位的正序码(或逆序码)集合,会同时满足条件1,3 。对于N位编码,它的全排列集合中除去平序码(全为0,1或2)后,剩余编码或为正序或为逆序,且正序码与逆序码数量相同,各为一半。由于N位编码的全排列数目为 3N 3 N ,因此它的正序码数目为 3N−32 3 N − 3 2 ,当 N=3 N = 3 时,正序码数目为12,因此所有的三位正序码(或逆序码)恰好符合12个小球的编码要求,即为图1所列。算法
对于一个正序码,若将其中的0换为1、1换为2、2换为0,所得到的编码仍为正序,我们可以根据该特点生成编码。当 N=3 N = 3 时,首先生成两位编码的全排列集合,在每个编码前补一位0构成三位;再找出其中的正序码,判断方法为令每个编码的后一位减去前一位,当首次非零的值为1或-2时,表示该编码正序,否则为逆序。此时会得到所有首位为0的三位正序码,之后依次将每个编码中的字符按上述特点进行变换,并将新编码加入编码集合直到编码重复为止,至此将得到所有三位正序码。
四、算法推广
4.1 称重方法
以上方法可以完成12个小球的称重,进一步思考可知,3.1中规定的条件3是为了根据编码唯一确定异常小球的编号,条件2是为了保证每次称量时天平两边球数相等,因此可以修改条件2为“编码每一位所含‘0’,‘1’的数量相等”。在调整编码条件后,只要编码同时满足上述三个条件, 仍然可以完成称量。此时该算法可推广至球数为3的倍数的情况。
当球数不为3的倍数时,小球编码无法满足条件2,这将导致称重过程中左右两盘球数可能不同,无法完成称重。对于这种情况,可以采用补充法或去除法来解决,即当两个盘球数不等时,从球数多的盘中取出多余的标准球或将若干标准球放入球数少的盘。而标准球可以从第一次称重结果中获取,这时只需满足第一次称重左右两盘球数相同即可。
以补充法为例:当总球数除以3余1时,对于整除部分采用上述3的倍数进行编码,多余一个球的编码在满足条件1,3的同时,使其首位编码为2即可;当总球数除以3余2时,使多余两个球首位编码分别为0,1即可。由此该算法可推广至球数大于3的所有情况。
4.2 编码方法
3.4的算法是针对12个小球提出的,但稍加改进即可用于球数为3的倍数或者更广的情况。对于N位全排列编码,每一位均累计有 3N3 3 N 3 个0,1或2,去除其中平序码后,每位均有 3N3−3 3 N 3 − 3 个0,1,2,而对于所有正序码(或逆序码)而言,他们每一位均有 3N−36 3 N − 3 6 个0,1,2,显然0,1的数量是相等的,因此符合上述三个条件的编码可以从N位正序码中获得。
当球数为3的倍数时,首先根据问题分析中的方法计算所需称重次数,即小球编码位数N;之后生成N-1位编码的全排列集合,在每位编码前补0构成N位,删去其中的非正序码;此时得到所有首位为0的N位正序码,取其中一个进行两次轮换,将三个编码(每一位分别为0,1,2)并分别对应前三个小球,再取下一个进行两次轮换,对应之后三个小球,重复该步骤直到每个小球均对应一个编码为止,此时小球编码符合三个条件,可以进行称重。
当球数不为3的倍数时,同样先计算称重次数N,并生成所有首位为0的N位正序码,之后对3的整数倍的小球利用上述方法进行编码。当余下一个小球时,取下一个编码轮换两次之后首位为2的编码与之对应;余下两个小球时,取下一个首位为0的编码以及它轮换一次后首位为1的编码分别对应,此时可以运用补充法或去除法进行称重。
五、小结
这是去年信息论的一份作业,方法上借鉴了很多网上大神的想法,但应该是我用Python写的第一个比较复杂的项目,而且展示时候的效果还不错,所以感觉还是蛮有意义的。核心的地方应该还是生成正序编码部分,花的时间最长,主要思路是生成N-1位的全排列编码—>前面补0—>选出正序部分,生成全排列的时候用到了递归,复杂度比较高,而且写得很复杂,后面发现其实也可以用十进制和三进制转化来的,应该会简洁很多。当初前后写了三个版本,分别是12个球的、3的倍数的、任意球数的,这里把任意球数的代码贴在下面,欢迎讨论。
import random
#导入随机数模块,用于随机产生质量不同的小球
def InputBallnum():
#输入球数,并计算所需称重次数,返回球数,称重次数,除以三的余数
n = eval(input("请输入球数 >> "))
while not isinstance(n,int):
n = eval(input("输入有误,请重新输入 >> "))
m = n%3
for times in range(n):
if (3**times-3)/2 >= n:
print("%d个球%d次可以完成称重!"%(n,times))
print()
return times,n,m
def StarCode(t):
#输入称重次数即编码位数t,返回所有t位首位为0的正序编码
if t >= 3:
star = ['0','1','2'] #初始化列表,元素为三进制的三个字符
str1 = Make_Num(t-2,star) #产生一个t-1位由0,1,2组成的所有编码组合的列表
for i in range(len(str1)):
str1[i] = '0'+str1[i] #在t位所有编码前添加一个0
for string in str1[:]:
#从列表中排除所有非正序的编码
for i in range(t-1):
if (int(string[i+1]) - int(string[i]) == 1) or (int(string[i+1]) - int(string[i])) == -2:
#后一个数字减前一个数字为1或-2,则该编码为正序
break
elif (int(string[i+1]) - int(string[i]) == -1) or ((int(string[i+1]) - int(string[i])) == 2) or ((i == t-2) and (int(string[i+1]) - int(string[i])) == 0):
#后一个数字减前一个数字为-1或2,或所有数字相同,则该序列非正序
str1.remove(string)
break
return str1
else:
return star
def Make_Num(n,list):
#利用递归调用生成n+1位由0,1,2组成的所有编码组合的列表
if n:
for i in range(len(list)):
for j in range(3):
list.append(list[i]+str(j))
for i in range(int(len(list)/4)):
list.remove(list[0])
Make_Num(n-1,list)
return list
def FinalCode(n,list,m):
#输入球数n,首位为0的t位正序码列表,余数,返回n个符合条件的正序码
#该步骤为将0替换为1,1替换为2,2替换为0,并将新编码加入列表
if m == 0:
t = n//3
else:
t = n//3 + 1
templist = []
for i in range(t):
templist.append(list[i])
temp1 = temp2 = ''
for j in range(len(list[0])):
#第一层替换
l = int(list[i][j])+1
if l == 3:
l = 0
temp1 += str(l)
for k in range(len(temp1)):
#第二层替换
ll = int(temp1[k]) + 1
if ll == 3:
ll = 0
temp2 += str(ll)
templist.append(temp1)
templist.append(temp2)
return templist
def SetBall(n,list,m):
#生成表示小球的列表并返回,元素分别为编号、质量、编码
ball = []
for i in range(n-m):
fig = '('+str(i+1)+')' #小球编号表示如"(10)"
#按顺序将以上产生的正序编码赋给每个小球,质量设为10
ball.append([fig,10,list[i]])
if m == 1:
#当球数非三的倍数时,需额外生成多余的小球
ball.append(['('+str(n-m+1)+')',10,list[-1]])
elif m == 2:
ball.append(['('+str(n-m+1)+')',10,list[-3]])
ball.append(['('+str(n-m+2)+')',10,list[-2]])
return ball
def ChangeWeight(list):
#改变一个小球的质量,手动输入或随机生成
n = len(list)
select = input("手动选择(Y)或随机生成(N)质量不同的球 >> ")
while select not in 'YyNn':
select = input("输入有误!请重新输入 >> ")
if select == 'Y' or select == 'y':
num = int(input("请在1~%d中选择一个球,改变其质量 >> "%n))
while num not in range(1,n+1):
num = int(input("输入有误!请重新选择 >> "))
weit = eval(input("请选择增加质量(1)或减少质量(-1) >> "))
while weit != 1 and weit != -1:
weit = eval(input("输入有误!请重新输入 >> "))
list[num-1][1] += weit
elif select == 'N' or select =='n':
#随机取一个小球,将其质量+1或-1
list[random.randint(0,n-1)][1] += random.choice([1,-1])
return list
def Weight(n,list):
#对球按编码进行称重,第i次取第i位编码为0和1的小球并分别置于左右两盘,
#即每次称重左盘均表示0,右盘为1,根据每次称重天平的结果(轻、重、平
#衡)添加对应编码
print()
print("============================以下为称量结果============================")
print()
model = [] #生成标准球列表
light = heavy = ''
print("第1次称重结果为: ")
left = right = 0 #每次称重前将左右两盘清空
fig_left = fig_right = '' #编号清空
for j in range(len(list)):
#遍历每一个小球
if (list[j][2][0] == '0'):
#判断第j个小球编码的第1位是否为0,若是将质量加于左盘,记录编号
left += list[j][1]
fig_left += list[j][0]
elif (list[j][2][0] == '1'):
#判断第j个小球编码的第1位是否为1,若是将质量加于右盘,记录编号
right += list[j][1]
fig_right += list[j][0]
else:
continue
if left == right:
#若天平平衡,轻编码添加2,重编码添加2,打印称重结果,将盘中所有球
#设为标准球
light += '2'
heavy += '2'
print(fig_left,"=",fig_right)
for k in range(len(list)):
if list[k][2][0] == '0' or list[k][2][0] == '1':
model.append(list[k])
print()
elif left < right:
#若天平左倾,轻编码添加0,重编码添加1,打印称重结果,将盘外所有球
#设为标准球
light += '0'
heavy += '1'
print(fig_left,"<",fig_right)
for k in range(len(list)):
if list[k][2][0] == '2':
model.append(list[k])
print()
else:
#若天平右倾,轻编码添加1,重编码添加0,打印称重结果,将盘外所有球
#设为标准球
light += '1'
heavy += '0'
print(fig_left,">",fig_right)
for k in range(len(list)):
if list[k][2][0] == '2':
model.append(list[k])
print()
print('轻球编码为:'+ light,'或重球编码为:'+ heavy)
a = input()
for i in range(1,n):
ball_temp = []
print("第%d次称重结果为: "%(i+1))
left = right = 0 #每次称重前将左右两盘清空
count_l = count_r = 0 #用于对盘中小球计数
fig_left = fig_right = '' #清空编号
for j in range(len(list)):
#遍历每一个小球
if (list[j][2][i] == '0'):
#判断第j个小球编码的第i位是否为0,若是将质量加于左盘,记录编号
#左盘球数加一
ball_temp.append(list[j])
left += list[j][1]
fig_left += list[j][0]
count_l += 1
elif (list[j][2][i] == '1'):
#判断第j个小球编码的第i位是否为1,若是将质量加于右盘,记录编号
#右盘球数加一
ball_temp.append(list[j])
right += list[j][1]
fig_right += list[j][0]
count_r += 1
elif (list[j][2][i] == '2'):
continue
if count_l < count_r:
#若右盘球数多余左盘,则将一个标准球加于左盘
for k in model:
#检查标准球是否已在盘中
if k not in ball_temp:
left += k[1]
fig_left += k[0]
break
elif count_l > count_r:
for k in model:
if k not in ball_temp:
right += k[1]
fig_right += k[0]
break
if left < right:
#若天平左倾,轻编码添加0,重编码添加1,打印称重结果
light += '0'
heavy += '1'
print(fig_left,"<",fig_right)
print()
elif left > right:
#若天平右倾,轻编码添加1,重编码添加0,打印称重结果
light += '1'
heavy += '0'
print(fig_left,">",fig_right)
print()
elif left == right:
#若天平平衡,轻编码添加2,重编码添加2,打印称重结果
light += '2'
heavy += '2'
print(fig_left,"=",fig_right)
print()
print('轻球编码为:'+ light,'或重球编码为:'+ heavy)
print()
a = input()
print()
return light , heavy
def CompareWeight(light,heavy,list):
#根据轻重编码查找对应小球
n = len(list)
for i in range(n):
if light in list[i]:
print("对比小球编码可知:球 %s 较轻"%(list[i][0]))
if heavy in list[i]:
print("对比小球编码可知:球 %s 较重"%(list[i][0]))
a = input()
def PrintCode(list):
#打印所有小球编号及其编码,用于对比实验结果
print()
print("===========================以下为各球编码===========================")
print()
n = len(list)
for i in range(n):
print(list[i][0],":",list[i][2])
def PrintWeight(list):
#打印所有小球编号及其质量,用于对比实验结果
print()
print("=========================以下为各球实际质量=========================")
print()
n = len(list)
for i in range(n):
print(list[i][0],":",list[i][1])
def main():
#输入球数,并计算所需称重次数,球数除以三所得余数
n_times,n_balls,n_remain = InputBallnum()
#根据称重次数产生一个编码序列
Star_Code = StarCode(n_times)
Fina_Code = FinalCode(n_balls,Star_Code,n_remain)
#根据以上编码序列生成一个表示小球的列表,元素包含小球编码、质量、标号
init_ball = SetBall(n_balls,Fina_Code,n_remain)
#改变一个小球的质量,手动输入或随机生成
ball = ChangeWeight(init_ball)
#打印小球编码
PrintCode(ball)
a = input()
#根据编码逐次称重、返回称重结果的编码
light, heavy = Weight(n_times,ball)
#将结果编码与小球编码进行对比,找出质量不同的小球,打印其编号
CompareWeight(light,heavy,ball)
#打印所有小球编号及其质量
PrintWeight(ball)
a = input()
main()
参考资料
[1] https://funnyjs.com/ballweight/
[2] https://www.zhihu.com/question/23505253