跳转至

12 个球找假球

题

12 个外观相同的球,恰有一个是假球,重量和其他球不同——但不知道偏轻还是偏重。一架没有砝码的天平,最多称 3 次,找出假球并说出它偏轻还是偏重。

一句话

能做到,而且三次称法可以事先写死、不看前面的结果:给每个球发一个三位三进制码字,三次读数拼成的三位数直接报出是哪个球、偏重还是偏轻。

关键技巧

天平是三进制仪器。 每次读数只有左沉、平、右沉三种,3 次共 \(3^3 = 27\) 种读数。要分开的假设是"哪个球 × 偏轻或偏重" \(= 12 \times 2 = 24\) 个。\(24 \le 27\),信息量够,但只余 3——每次称重都得把剩余假设尽量三等分,这就是为什么 6 对 6 开局必败。

Dyson 的编码观点(1946)。 读数记 0 = 左沉、1 = 平、2 = 右沉。给球 \(k\) 一个码字 \(c_k \in \{0,1,2\}^3\):第 \(i\) 位是 0 就在第 \(i\) 次放左盘,是 2 就放右盘,是 1 就不上秤。

  • 假球偏重:它在左盘时左沉、右盘时右沉、不上秤时平——读数恰好等于它的码字 \(c_k\);
  • 假球偏轻:左右全反,读数是补码字 \(2 - c_k\)。

所以只要 12 个码字和它们的 12 个补码字共 24 个两两不同,读数就能唯一反解。27 个三位数里,\(111\) 是自己的补(假球从没上秤,轻重无从判断),\(000\) 和 \(222\) 这一对也剔掉、好让每次称重两边个数相等,去掉这 3 个,剩下 24 个正好配成 12 对。每对挑哪一个?Dyson 的规则:挑数字"首次变化"是 \(0 \to 1\)、\(1 \to 2\) 或 \(2 \to 0\) 的那个(循环向前一格)。这条规则让每一位上 0、1、2 各出现 4 次——每次称重恰好 4 对 4,不需要标准球。

解

按 Dyson 规则挑出的 12 个码字,依字典序发给球 1–12:

球 码字 三次位置 偏重时读数 偏轻时读数
1 001 左 左 — 左沉 左沉 平 右沉 右沉 平
2 010 左 — 左 左沉 平 左沉 右沉 平 右沉
3 011 左 — — 左沉 平 平 右沉 平 平
4 012 左 — 右 左沉 平 右沉 右沉 平 左沉
5 112 — — 右 平 平 右沉 平 平 左沉
6 120 — 右 左 平 右沉 左沉 平 左沉 右沉
7 121 — 右 — 平 右沉 平 平 左沉 平
8 122 — 右 右 平 右沉 右沉 平 左沉 左沉
9 200 右 左 左 右沉 左沉 左沉 左沉 右沉 右沉
10 201 右 左 — 右沉 左沉 平 左沉 右沉 平
11 202 右 左 右 右沉 左沉 右沉 左沉 右沉 左沉
12 220 右 右 左 右沉 右沉 左沉 左沉 左沉 右沉

读成三次称法:

  1. 第 1 次:\(\{1, 2, 3, 4\}\) 对 \(\{9, 10, 11, 12\}\)
  2. 第 2 次:\(\{1, 9, 10, 11\}\) 对 \(\{6, 7, 8, 12\}\)
  3. 第 3 次:\(\{2, 6, 9, 12\}\) 对 \(\{4, 5, 8, 11\}\)

称完把读数按"左沉 0、平 1、右沉 2"拼成三位数,在码字列里查到就是那个球偏重;查不到,就把每位换成 \(2 - x\) 再查,查到的球偏轻。例:读数"左沉 右沉 平" \(= 021\),码字里没有;补码 \(201\) 是球 10——球 10 偏轻。24 种合法读数各对应一个答案,\(000\)、\(111\)、\(222\) 永远不会出现。

上限为什么恰好是 12

\(n\) 次称重、没有标准球时,最多能处理 \(\frac{3^n - 3}{2}\) 个球:

  • 先看有标准球的子问题:\(m\) 个可疑球、手边有标准球、称 \(n'\) 次。\(2m\) 个假设要装进 \(3^{n'}\) 种读数,"全平"读数判不出轻重得去掉,\(2m \le 3^{n'} - 1\),即 \(m \le \frac{3^{n'} - 1}{2}\)。
  • 第一次称重没有标准球,两边只能各放 \(k\) 个可疑球。若失衡,剩 \(2k\) 个假设(左边 \(k\) 个可能偏重、右边 \(k\) 个可能偏轻)装进 \(3^{n-1}\) 种读数,\(2k \le 3^{n-1}\),\(k\) 是整数所以 \(k \le \frac{3^{n-1} - 1}{2}\)。若平衡,秤上的 \(2k\) 个全成了标准球,剩下的球落进上一条的子问题,至多 \(\frac{3^{n-1} - 1}{2}\) 个。
  • 合计 \(2k + \frac{3^{n-1}-1}{2} \le 3 \cdot \frac{3^{n-1}-1}{2} = \frac{3^n - 3}{2}\)。\(n = 3\) 时恰好 12。

延伸

  • 13 个球:3 次能找出假球,但不能保证说出轻重。上限推导里第一次至多 4 对 4,平衡后剩 5 个可疑球,比有标准球的两次称重能判的 4 个多一个——这第 5 个球可以始终不上秤、靠排除法认出来,轻重却无从知道。
  • 多给一个标准球:第一次称重就能两边不等量、用标准球补齐,上限变成 \(\frac{3^n - 1}{2}\),13 个球可以找出并判轻重。
  • \(n\) 次的上限序列:没有标准球 3、12、39(\(n = 2, 3, 4\));有标准球 4、13、40。折叠块里的穷举搜索逐一核对过。
  • 常见错误:6 对 6 开局。一旦失衡,剩下 6 个可能偏重 + 6 个可能偏轻 = 12 个假设,两次称重只有 9 种读数,装不下。3 对 3 开局也不行——平衡时剩 6 个可疑球,有标准球的两次称重最多处理 \(\frac{9-1}{2} = 4\) 个。第一次必须 4 对 4。
  • 已知假球偏重:假设数就是球数,\(n\) 次可以从 \(3^n\) 个球里找出来——每次三等分,和 1000 瓶酒找毒酒 的"每次观测贡献一位"是同一回事,只是这里是三进制。
数值验证

Dyson 码字逐一核对 24 种情况,再用穷举搜索确认上限(状态只记"轻重都可能 / 只可能偏重 / 只可能偏轻"三类球的个数和手边有没有标准球):

from itertools import product
from functools import lru_cache

def clockwise(v):
    for a, b in zip(v, v[1:]):
        if a != b:
            return (b - a) % 3 == 1          # 首次变化是 0→1、1→2 或 2→0
    return None                               # 000/111/222 剔除

codes = [v for v in product(range(3), repeat=3) if clockwise(v)]
print(len(codes), codes)
# 12 [(0, 0, 1), (0, 1, 0), (0, 1, 1), (0, 1, 2), (1, 1, 2), (1, 2, 0), (1, 2, 1), (1, 2, 2), (2, 0, 0), (2, 0, 1), (2, 0, 2), (2, 2, 0)]
for i in range(3):
    left  = [k + 1 for k, c in enumerate(codes) if c[i] == 0]
    right = [k + 1 for k, c in enumerate(codes) if c[i] == 2]
    print(f"第 {i+1} 次: {left} vs {right}")
# 第 1 次: [1, 2, 3, 4] vs [9, 10, 11, 12]
# 第 2 次: [1, 9, 10, 11] vs [6, 7, 8, 12]
# 第 3 次: [2, 6, 9, 12] vs [4, 5, 8, 11]

def weigh(fake, heavy):
    out = []
    for i in range(3):
        d = codes[fake][i]
        side = 0 if d == 0 else (2 if d == 2 else None)
        if side is None: out.append(1)            # 假球不在秤上:平
        elif (side == 0) == heavy: out.append(0)  # 左沉
        else: out.append(2)                       # 右沉
    return tuple(out)

def decode(r):
    for k, c in enumerate(codes):
        if c == r: return k, "重"
        if tuple(2 - x for x in c) == r: return k, "轻"
all_ok = all(decode(weigh(k, h)) == (k, "重" if h else "轻") for k in range(12) for h in (True, False))
print(all_ok)                                          # True

@lru_cache(None)
def ok(u, h, l, g, n, need_dir=True):
    """u 颗轻重都可能、h 颗只可能偏重、l 颗只可能偏轻;g=手里有无标准球;还剩 n 次。"""
    if need_dir and 2 * u + h + l <= 1: return True
    if not need_dir and u + h + l <= 1: return True
    if n == 0: return False
    for u1, u2, h1, h2, l1, l2 in product(range(u + 1), range(u + 1), range(h + 1),
                                          range(h + 1), range(l + 1), range(l + 1)):
        if u1 + u2 > u or h1 + h2 > h or l1 + l2 > l: continue
        L, R = u1 + h1 + l1, u2 + h2 + l2
        if L == R == 0 or (L != R and not g): continue        # 两边个数不等时用标准球补
        rest = (u - u1 - u2, h - h1 - h2, l - l1 - l2)
        tip = [(0, u1 + h1, u2 + l2), (0, u2 + h2, u1 + l1)]   # 左沉 / 右沉
        cases = tip + [rest]
        total = u + h + l
        if all(ok(*c, g or (sum(c) < total), n - 1, need_dir) for c in cases):
            return True
    return False

print(ok(12, 0, 0, False, 3), ok(13, 0, 0, False, 3))   # True False
print(ok(13, 0, 0, False, 3, need_dir=False))           # True   13 球只找球、不判轻重
print(ok(13, 0, 0, True, 3), ok(14, 0, 0, True, 3))     # True False  带一个标准球
for n in (2, 3):
    print(n, max(N for N in range(1, 20) if ok(N, 0, 0, False, n)), (3**n - 3) // 2)
# 2 3 3
# 3 12 12
print(ok(39, 0, 0, False, 4), ok(40, 0, 0, False, 4))   # True False  跑约 10 秒

有标准球时 \(n = 2, 4\) 的上限 4、40 用同一个 ok(N, 0, 0, True, n) 查过。