摘要生成中...
AI 摘要
Hunyuan-lite

「小球称重问题」(又名「硬币称重问题」),旨在解决以下数学问题: N 个小球中,有一个小球质量不同,使用天平,用最少的次数确保找到不同的小球。主要包括三种形式:

  1. 有 N 个小球,已知有 1 个小球比其他小球偏重(或者偏轻),使用天平,最少要多少次才能确保找到那个小球?
  2. 有 N 个小球,已知有 1 个小球和其他小球质量不同,使用天平,最少要多少次才能确保找到那个质量不同的小球,并知道它比其他小球轻还是重?
  3. 有 N 个小球,已知有 1 个小球和其他小球质量不同,使用天平,最少要多少次才能确保找到那个质量不同的小球(无需知道它的轻重)?

本文以 12 个小球为例,提供两种可实操解决方法:

  • 提供「动态构造」解法,并给出基于控制论观点的解释;
  • 提供「平衡三进制编号」解法,并对这种编码方式进行稍微深入的探讨。

从 12 个小球开始谈起

12 个小球找出 1 个次品球,需要知道次品球是偏重还是偏轻

如何在一架没有砝码的天平秤上,通过秤 3 次,找出 12 个球中唯一的次品球(可能偏重可能偏轻),并且需要知道次品球是偏重还是偏轻。

下图展现了具体的可实操解法,也就是所谓的「动态构造解法」:

img

用控制论的观点看 12 个小球称重的问题

12 个小球,每个小球都有可能是次品,可能偏轻或偏重。用控制论的观点来看,次品球的可能性空间为二元向量:(12,2)(12,2),总的可能性空间为 24。

对一个事物的控制能否成功,有控制公式:

M/QmAM/Q \le m_A

其中:

  • MM 表示该事物的总的可能性空间;
  • QQ 表示工具或人对事物的控制能力;
  • mAm_A 表示事先预定的工作目标。

如果上面的控制公式可以成立,那么对于这个事物的控制能够成功,否则不能实现。

在「12 个小球称 3 次」这个例子中,有 M=24,mA=1,Q=3×3×3=27,M/Q=24/271M=24, m_A=1, Q=3\times 3 \times 3 =27, M/Q=24/27\le 1。因此,此题能解。

关于天平控制能力 QQ 的计算可在后文给出。

具体称法:

  1. 第一次称:先称 x 个球(天平左右各放 x/2 个球),剩下 y 个球。
    • 如果天平平衡,次品存在于 yy 个球中,且不知轻重,所以可能性空间为 2y2y。要想两次称出,必须有 2y/(3×3)12y/(3\times 3)\le 1
    • 如果天平不平衡,次品在 xx 个球中,但知道 x/2x/2 个球不会偏重,另外 x/2x/2 个球不会偏轻。那么剩下的可能性空间为 x/2+x/2=xx/2+x/2=x。要想两次称出,必须有 x/91x/9\le 1
    • 因为 x+y=12x+y=12。综合上面的不等式,解得 x=8,y=4x=8, y=4
  • 第二次称时,根据第一次结果,有两种可能。
    • 次品在 y 个球中。则与第一次相同,设 x1+y1=4,2y1/31,x1/31x_1+y_1=4, 2y_1/3\le 1, x_1/3\le 1。解得 x1=3,y1=1x_1=3, y_1=1
    • 次品在 x 个球中。设我们称的时候,从天平上拿掉 a 个球替换为 a 个正品球,交换位置 b 个球(从天平的左边交换到右边),不动 c 个球,a+b+c=x=8a+b+c=x=8。结果有三种可能,且我们都需要再称一次找到次品:
      • 如果平衡,次品在 a 中。a/31a/3\le 1
      • 天平倾向和上次一样,则次品在 c 中。c/31c/3\le 1
      • 天平倾向和上次相反,则次品在 b 中。b/31b/3\le 1
      • 综上各式,有三组解,称法见下图:
        • a=2,b=3,c=3a=2, b=3, c=3
        • a=3,b=2,c=3a=3, b=2, c=3
        • a=3,b=3,c=2a=3, b=3, c=2

620d100bd5ea78877dfbdae9b0deb294.jpeg

关键思路还是需要想到「从天平上拿掉 a 个球替换为 a 个正品球,交换位置 b 个球,不动 c 个球」这个步骤。

为什么天平的控制能力 Q=3 ?

我们知道控制力 Q=M/mQ=M/m。其中,MM 表示总的可能性的空间,mm 表示经过控制后所剩下来的可能性空间,比值 QQ 表示控制前后次品球的可能性空间的变化程度,QQ 越大,选择能力越强。

假设我们需要称 n 个球,且把 n 个球分为 3 组:aabbcc,有 a+b+c=na+b+c=n。次品球或轻或重,可能性空间为 2n,其他 3 组可能性空间为 2a、2b、2c。

假设我们将 a 组放到天平的左边,b 组放到天平的右边(a 不一定等于 b),有以下三种情况:

  • 情况 1:a 组重。意味着要么次品球在 a 组且偏重;要么次品球在 b 组且偏轻。所以次品的可能性空间从 2n 缩小到 a+b。
  • 情况 2:b 组重。同理,次品的可能性空间从 2n 缩小到 a+b。
  • 情况 3:a、b 平衡。表明次品球在 c 组重。所以次品的可能性空间从 2n 缩小到 2c。

image.png

由于 a、b、c 不一定相等,因此我们得到以下三个不同的选择能力:

  • Q1=2n/(a+b)Q_1 = 2n/(a+b)
  • Q2=2n/(a+b)Q_2 = 2n/(a+b)
  • Q3=2n/(2c)Q_3 = 2n/(2c)

我们假设,如果我们做了许多次同样的实验,统计三种情况的频率。那么可以在平均值的意义上,求出一个 Q 作为代表。这里引入控制率(选择率)P 的概念,P=1/Q=m/MP=1/Q=m/M,它是选择能力的倒数,值越小,选择能力越大。因此对于上面三种情况,有:

  • P1=(a+b)/(2n)P_1 = (a+b)/(2n)
  • P2=(a+b)/(2n)P_2 = (a+b)/(2n)
  • P3=2c/(2n)P_3 = 2c/(2n)

假设做了 A 次实验后,3 种情况出现的次数分别为 A1,A2,A3A_1, A_2, A_3,那么平均选择力 Pˉ\bar{P} 为:

Pˉ=(A1P1+A2P2+A3P3)/A=(A1/A)P1+(A2/A)P2+(A3/A)P3\bar{P}=(A_1P_1+A_2P_2+A_3P_3)/A = (A_1/A)P_1+(A_2/A)P_2+(A_3/A)P_3

当 A 趋于无穷时,A1/A,A2/A,A3/AA_1/A, A_2/A, A_3/A 就分别等于三种情况发生的概率:

  • A1/A=(a+b)/(2n)A_1/A = (a+b)/(2n)。思考方式:次品可能轻可能重,且可能出现在 12 个小球中。出现情况 1(a 组重)的可能性为:
    • 小球偏重,在 a 组。次品落在 a 组的概率为 a/na/n,次品为偏重的概率为 1/21/2
    • 小球偏轻,在 b 组。次品落在 b 组的概率为 b/nb/n,次品为偏重的概率为 1/21/2
    • 综上,概率为 (a+b)/(2n)(a+b)/(2n)
  • A2/A=(a+b)/(2n)A_2/A = (a+b)/(2n)
  • A3/A=2c/(2n)A_3/A = 2c/(2n)

即:

Pˉ=(A1P1+A2P2+A3P3)/A=(A1/A)P1+(A2/A)P2+(A3/A)P3=[(a+b)/(2n)]2+[(a+b)/(2n)]2+[2c/(2n)]2=14n2[2(a+b)2+4c2]\begin{aligned} \bar{P} =& (A_1P_1+A_2P_2+A_3P_3)/A \\ =& (A_1/A)P_1+(A_2/A)P_2+(A_3/A)P_3 \\ =& [(a+b)/(2n)]^2+[(a+b)/(2n)]^2+[2c/(2n)]^2 \\ =& \frac{1}{4n^2}[2(a+b)^2+4c^2] \end{aligned}

可以看出,对于不同的 a、b、c,得到的 P 值不同。即对于不同的称法,将使天平得出不同的选择率。现在我们尝试计算 Pˉ\bar{P} 的极值。因为 a+b=nca+b=n-c,利用配方法有:

Pˉ=14n2[2(a+b)2+4c2]=12n2[n22nc+3c2]=32[29+(cn13)2]\begin{aligned} \bar{P} =& \frac{1}{4n^2}[2(a+b)^2+4c^2] \\ = & \frac{1}{2n^2}[n^2-2nc+3c^2] \\ = & \frac{3}{2}[\frac{2}{9}+(\frac{c}{n}-\frac{1}{3})^2] \end{aligned}

c/n=1/3c/n=1/3 时,Pˉ\bar{P} 能取到极小值 1/31/3。这意味着天平的最大选择能力是 3。并且,当我们编排乒乓球时,应尽量将 a、b、c 三组的数值分得一样,才能最大限度发挥天平秤的选择能力。

使用平衡三进制的思路

除了上面的解法外,还有一种为小球编号,按次序称量的思路。

从「老鼠试毒」讲起

我们先从更经典且相对容易理解的「老鼠试毒」的例子引入。

老鼠试毒问题

1000 桶酒,其中 1 桶有毒。而一旦吃了,毒性会在 1 周后发作。问最少需要多少只老鼠可在一周内找出毒酒?

我们可以使用二进制编码法解决这个问题。首先对 1000 桶酒按照 1~1000 进行二进制编码,每只老鼠只喝其对应位数为 1(从左往右数,从右往左数都行)的编号的酒。因为 210=1024>10002^{10} = 1024 > 1000,因此需要 10 位二进制,也就是只需要取 10 只老鼠。

假设编号为 1000100011 的酒是毒酒,那么我们的测试结果为:第一只,第五只,第九只,第十只老鼠死亡。也就是说,我们可以通过判断哪几只老鼠死亡,而推出毒酒的编号是多少。

天平当「老鼠」

我们换个角度进行这样的思考:第 k 只老鼠进行一次试毒时,可以判断出编号第 k 位为 1 的酒是否有毒,这相当与天平的一次「称量」,只不过称量的结果只有两种——有毒或没毒。这一次「称量」意味着什么?

  • 如果第 k 个老鼠死了,代表毒酒在编号第 k 位为 1 的酒中,毒酒不在编号第 k 位为 0 的酒中;
  • 如果第 k 个老鼠没死,代表毒酒不在编号第 k 位为 1 的酒中,毒酒在编号第 k 位为 0 的酒中。

虽然对于其中的一只老鼠来说,只有部分酒被实际测试(品尝)了,但是老鼠的测试结果,已经是对这 1000 桶酒都作出了一次有毒/无毒的判断尝试。

我们可不可以借鉴这种编码的方法应用到天平秤小球的思路呢?在天平称小球的问题中,天平的称量结果有三种:左边重、平衡、右边重。因此在问题的构造中,「3」应该是解决问题的关键点。

  • 我们尝试将小球进行适当的编码,编码或许是 3 进制的;
  • 我们将进行 k 次称量,称量总次数对应着小球最大编码的位数;
  • 第 k 次称量时,我们应该关注小球的第 k 位的编码,并进行对应的操作。

我们为十二个小球进行编码,这里我用符号 1、0、-1 组成小球的编号(站内文章平衡三进制),它们都分别表示一种天平的姿态:左高右低、左低右高、平衡。

我们像要这样的结果:每一次称量时,将天平的结果按序记录,如果左高右低,记录 1;左低右高,记录 -1;天平平衡,记录 0。这样得到的结果组成了一个序号。那么这个序号就是次品小球的序号(并且根据序号的特点确定小球是偏轻还是偏重)。

怎么编码和称量

在上面我们讨论的平衡三进制编码方案中,我们有 27 个可用的编码,但这些编码使用是有一定讲究的。根据前人的研究,我们将每个小球编号如下:

image.png

我们实际上是将小球 以正序码编号 。所谓正序码就是:编号从左到右,遇到的第一个不同的数字时,这个数字与前面的数字构成 01 或 1(-1) 或 (-1)0。其实就是看这个数第一次发生变化时的趋势。

至于为什么这么做,可看后文章节讨论的关于正序码的本质。

称量方法为,第 k 次称量时,观察小球正序码的第 k 位:

  • 如果小球第 k 位为 1,则放天平左盘;
  • 如果小球第 k 位为 -1,则放天平右盘。

三次称量如下左右盘中小球分布如下:

第 k 次称量 左盘 右盘
1 5,6,7,11 12,8,10,9
2 11,4,2,3 7,5,6,12
3 7,8,4,1 11,5,10,2

三次称量的结果记下的编码的绝对值即为次品编号。如果是编码是正序码,则该次品偏轻;如果是逆序码,则偏重。

下面展示了一段 Python 程序,模拟在 24 种情况下,利用上面的方法是否能正确找出次品小球:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# -*- coding: utf-8 -*-

class BalanceTernaryWeigher:
def __init__(self):
# 定义合法的「初次变化趋势」。如材料所述:遇到第一个不同的数字时构成的组合
self.allowed_transitions = [(0, 1), (1, -1), (-1, 0)]

# 显式生成 1 到 12 号小球的正序码。
self.forward_codes = self._generate_forward_codes()

# 根据正序码自动生成三次称量计划
self.weighing_plan = self._generate_weighing_plan()

def _decimal_to_balanced_ternary(self, n):
"""辅助函数:将十进制 1-12 转换为 3 位平衡三进制元组(高位在前)"""
res = []
for _ in range(3):
rem = n % 3
if rem == 2:
res.append(-1)
n = (n + 1) // 3
else:
res.append(rem)
n = n // 3
return tuple(res[::-1])

def _generate_forward_codes(self):
"""核心逻辑:根据正序码定义,筛选并生成 12 个小球的编码"""
codes = {}
for i in range(1, 13):
# 1. 先获取该编号对应的原生平衡三进制
pos_code = self._decimal_to_balanced_ternary(i)

# 2. 寻找第一次变化的趋势
first_transition = None
for j in range(2):
if pos_code[j] != pos_code[j + 1]:
first_transition = (pos_code[j], pos_code[j + 1])
break

# 3. 判断是否符合正序码的规则
if first_transition in self.allowed_transitions:
codes[i] = pos_code
else:
# 不符合,则取逆序码作为该球的正序码 (如图片中8、9、10、12号球的处理)
codes[i] = tuple(-x for x in pos_code)
return codes

def _generate_weighing_plan(self):
"""根据 12 个球的正序码生成天平的左右盘名单"""
plan = {0: {'left': [], 'right': []},
1: {'left': [], 'right': []},
2: {'left': [], 'right': []}}

for ball_id, code in self.forward_codes.items():
for k in range(3):
# 编码第 k 位为 1,放左盘;为 -1,放右盘;为 0,不放。
if code[k] == 1:
plan[k]['left'].append(ball_id)
elif code[k] == -1:
plan[k]['right'].append(ball_id)
return plan

def weigh(self, left_pan, right_pan, balls_weight):
"""模拟一次天平称量。返回: 1(左高右低), -1(左低右高), 0(平衡)"""
left_weight = sum(balls_weight[b] for b in left_pan)
right_weight = sum(balls_weight[b] for b in right_pan)

if left_weight < right_weight:
return 1 # 左边轻,左高右低
elif left_weight > right_weight:
return -1 # 左边重,左低右高
else:
return 0 # 平衡

def simulate(self, counterfeit_id, is_lighter, verbose=False):
"""
执行单个找次品模拟
verbose: 是否打印单次模拟的详细称量过程
"""
if verbose:
print(f"--- 正在模拟: {counterfeit_id}号球 偏{'light' if is_lighter else 'heavy'} ---")

balls_weight = {i: 1.0 for i in range(1, 13)}
balls_weight[counterfeit_id] = 0.5 if is_lighter else 1.5

result_code = []
for k in range(3):
left_pan = self.weighing_plan[k]['left']
right_pan = self.weighing_plan[k]['right']
res = self.weigh(left_pan, right_pan, balls_weight)
result_code.append(res)

if verbose:
status = "左高(1)" if res == 1 else "右高(-1)" if res == -1 else "平衡(0)"
print(f" 第 {k + 1} 次称量结果: {status}")

result_code = tuple(result_code)
inverse_code = tuple(-x for x in result_code)

# 反查推导结论
for ball_id, code in self.forward_codes.items():
if result_code == code:
return result_code, ball_id, "light"
elif inverse_code == code:
return result_code, ball_id, "heavy"

return result_code, None, "unknown"

# 运行模拟测试
if __name__ == "__main__":
weigher = BalanceTernaryWeigher()

print("=== 系统生成的正序码映射表 ===")
for k, v in weigher.forward_codes.items():
print(f"球 {k:2}: {v}")
print("\n" + "=" * 60 + "\n")

# 存储所有模拟结果用于最后列表展示
summary_results = []
success_count = 0

# 双重循环全面遍历 24 种情况(12个球 × 2种轻重状态)
for ball in range(1, 13):
for is_lighter in [True, False]:
# 执行模拟
feat_code, detected_id, weight_status = weigher.simulate(ball, is_lighter, verbose=False)

# 校验算法判断是否准确
expected_status = "light" if is_lighter else "heavy"
is_correct = (detected_id == ball) and (weight_status == expected_status)
if is_correct:
success_count += 1

# 将每次的特征码转为更易读的字符串,如 [1, 0, -1] -> " 1, 0, -1"
code_str = ", ".join(f"{x:2}" for x in feat_code)

summary_results.append({
"target_ball": ball,
"target_status": expected_status,
"feat_code": f"[{code_str}]",
"detected_ball": detected_id,
"detected_status": weight_status,
"result": "SUCCESS" if is_correct else "FAILED"
})

# ==================== 列表打印部分 ====================
print(f"展现 24 种情况模拟清单 (成功率: {success_count}/24):")
print("-" * 72)
# 打印表头
print(
f"{'target_ball' :^10} | {'target_status' :^8} || {'feat_code' :^14} || {'detected_ball' :^10} | {'detected_status' :^8} | {'result' :^8}")
print("-" * 72)

# 循环打印每一行
for item in summary_results:
print(
f"{item['target_ball']:^12} | {item['target_status']:^10} || {item['feat_code']:^15} || {item['detected_ball']:^12} | {item['detected_status']:^10} | {item['result']:^8}")

print("-" * 72)

用正序码对小球进行编号的本质

用平衡三进制的 3 位数位进行编码,共有 27 种可能。当我们进行 3 次称量,记录的下的结果数字,都代表一种可能。在 12 个小球问题中,共有 24 种不同的可能。现在我们要将 24 个编码分配到 12 个小球中。注意到:

  • 每个次品小球对应这一对编码。这对编码成对存在(共轭),且互为相反数。比如,假设 1 号球 (0,0,1)(0, 0, 1) 偏轻,那么测量结果的特征码就是 (0,0,1)(0, 0, 1);如果一号球偏重,那么测量结果则相反,为 (0,0,1)(0, 0, -1)。这两个特征码都对应着这个小球是次品。
  • 每一轮称量时,天平的左右两边的小球个数应当相等。因为我们是按照小球第 k 位数字安排这一轮中,小球应当放在哪个盘的。所以,虽然小球对应两个编码,但是我们需要以其中一个为标准,来确定小球放盘时机(从而也就成了小球「明面」上的编码)。

如果我们直接使用平衡三进制中的「正数」部分直接为小球编码,会发现在某一轮称量中,天平左右小球的数量并不平衡。而依据「正序码」构造规则得到的 12 个编码,恰好就满足了上面的两个条件。我们就以此作为小球「明面」上的编码,这些编码有正也有负。

那么,我们可不可以不按照「正序码」的方法,为小球进行编码呢?答案是可以的。下面是一些独创自己编码前的理论准备:

  • 首先,像 (0,0,0)(0,0,0) 这样的编码不能用,因为它不存在共轭的另一个编码;
  • 这样的话,我们就有 26 个编码,且两两成对,共 13 对;
  • 在前面「正序码」构造规则中,用了其中 12 对,没用到的一对编码为 (1,1,1),(1,1,1)(1, 1, 1), (-1, -1, -1)

我们将独创一套新的编码方法(我起名为「Qin 码」)。Qin 码没有任何构造规律,纯人工设计(毕竟也没有什么施展空间了),从 13 对编码中选 12 对为小球编号。做法:

  • 选取的 12 对中引入了正序码没用到 (1,1,1),(1,1,1)(1, 1, 1), (-1, -1, -1),并舍弃正则码中使用到的一对。如果不引入新的一对的话,Qin 码就和正序码没有什么本质区别了。
  • 适当调整每个小球「明面」上的编码,以符合天平数量平衡的要求。

image.png

编码的分配都是任意的

不管是正序码还是 Qin 码,对于某一对编号分配给哪个小球都是任意的。

将上面的 Python 代码中「正序码」定义部分替换为以下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.forward_codes = {
1: (0, 0, -1),
2: (0, 1, -1),
3: (0, -1, 0),
4: (0, -1, -1),
5: (-1, 1, 1),
6: (1, -1, 0),
7: (1, -1, 1),
8: (-1, 0, 1),
9: (-1, 0, 0),
10: (-1, 0, -1),
11: (1, 1, 1),
12: (1, 1, 0)
}

运行程序,验证的结果为,这套编码能够区分出 24 种可能的情况。

本文参考