Chico 和 Dico 是两名魔术师,他们有一个经典纸牌魔术:抽五张扑克牌,根据前四张,猜第五张是什么。完成这个魔术不需要任何魔术师的技巧,它完全建立在数学原理之上。只需要一点数学知识和充分的练习,你也能表演这个魔术。
GitHub 项目地址:card-magic-mcp
五年前,我写了一篇文章《用魔法打败魔法》,介绍 Chico & Dico 魔术的数学原理,并用 Python 实现了这个魔术。随着时代的发展、科技的进步,今天我们有更简单的方法来玩这个魔术,不需要运行 Python,不需要看懂公式,只需要打开 ModelScope 和大模型对话,就可以轻松体验这个魔术↓↓↓
访问 ModelScope Playground
: card-magic-mcp
下面我将介绍这个魔术的数学原理和算法实现,然后讲解如何用 FastMCP 将魔术流程包装成大语言模型可以调用的工具(MCP Server)。
Chico & Dico 的魔术
Chico 和 Dico 是两位魔术师,他们经常表演的一个魔术是这样的:Chico 将一副 52 张的扑克牌交给观众,让他抽取其中的 5 张。然后 Chico 将这 5 张牌按一定的顺序重新排列,再将其还给观众。上述整个过程 Dico 都是看不见的。然而,当观众按 Chico 排好的顺序依次展示前四张牌之后,Dico 却能准确地说出第五张牌是什么。
乍一看这个魔术很神奇。随机抽取 5 张牌,你能从前 4 张牌中看出第 5 张牌是什么吗?对于普通人来说,当然不行。Chico 和 Dico 的秘诀在于 Chico 有机会将扑克重新排序。如果两人事先约定好某种协议,Chico 有可能通过前四张扑克排列的顺序,向 Dico 传递关于第 5 张牌的信息。
关键在于如何实现这种协议。
数学原理
在进行分析之前,我们需要把 Chico & Dico 的魔术抽象成一个数学模型。
魔术本身已经提供了一些建模信息。假设一共有 $n$
张牌,抽取其中的 $k$
张。在对牌进行某种排序之后,我们需要利用前 $(k - j)$
张牌的信息,推测后 $j$
张牌是什么。
现在我们有 $n, j, k$
三个变量,下面我们来进一步探究三个变量之间服从何种关系。
首先,我们知道从 $n$
张牌中抽取 $k$
张牌一共有 $C(n, k)$
种组合方式,而 $(k - j)$
张牌的排列方式则有 $A(n, k-j)$
种。按照模型的要求,我们要用后者对前者的信息进行编码。根据信息论中的信源编码理论,至少有:
$$C(n, k) \leq A(n, k-j)$$
根据上述不等式,有:
$$ C(n, k) = \frac{n!}{(n-k)!k!} \leq \frac{n!}{(n-k+j)!} = A(n, k-j), \\ \text{即} \quad (n - k + j)! \leq k!(n - k)! $$
故 $n, j, k$
三个变量至少需要满足:
$$ (n - k + j)! \leq k!(n - k)! $$
特别地,当 $j = 1$
时,上述不等式等价于:
$$ (n - k + 1)! \leq k!(n - k)! $$
可以简化为:
$$ (n - k + 1) \cdot (n - k)! \leq k! \cdot (n - k)! \\ \text{即} \quad n \leq k! + k - 1 $$
根据本魔术的设定,可知 $n = 52$
, $k = 5$
。代入上述不等式,可得:
$$ 52 \leq 5! + 5 - 1 = 124 $$
也就是说,用前四张牌编码最后一张牌,理论上是可以实现的!
甚至有很大的冗余空间,在用前四张牌编码最后一张牌的设定下,最大可编码数量是 124,文末我们会推广到 124 张的极限情况。相比起来,Chico & Dico 的魔术仅需编码 52 张牌,根本不在话下。
算法实现
目前为止,一切还很抽象。
为了更好地理解 Chico 编码牌组,以及 Dico 解码牌组的方法。我们用一个实例复现魔术现场。
根据我们的模型和魔术的设定,显然有 $n = 52, k = 5, j = 1$
。
此外我们还作如下约定:
cards
代表观众选出的 5 张牌组成的列表- 被 Chico 重新排列过的牌组放入列表
reordered_cards
中 - 约定
s = sum(cards) mod k!
- 一般情况下,
$q$
代表商(初始化那次除外),$r$
代表余数
好了,现在魔术正式开始
第一步,我们让观众取出 5 张牌。假设观众取出的是 $[18, 50, 12, 19, 4]$
,我们要对这 5 张牌从小到大进行排序。
cards = [18, 50, 12, 19, 4]
cards = sorted(cards)
# cards = [4, 12, 18, 19, 50]
第二步,计算 s = sum(cards) mod k!
的值。
s = sum(cards) % factorial(5)
# s = 103
第三步,对这 5 张牌按如下规则进行编码:
q = s
for i in range(5, 0, -1):
q, r = divmod(q, i)
reordered_cards.append(cards.pop(r))
为了理解这一步做了什么,我们将上述循环展开。
点击查看每轮迭代中的细节
初始化,s = 103:
i | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
card | NULL | NULL | NULL | NULL | NULL | |
q | NULL | NULL | NULL | NULL | NULL | s = 103 |
r | NULL | NULL | NULL | NULL | NULL |
当 i = 5 时,被除数初始化为 s。因此有 $q_5 = s / i = 103/5 = 20, r_5 = s % i = 103%5 = 3$
。将列表 cards 中的索引为 $r_5$
(= 3)的扑克,放入列表 reordered_cards 中。现在列表 cards 为 $[4, 12, 18, 50]$
,列表 reordered_cards 为 $[19]$
。具体结果如下:
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | NULL | NULL | NULL | NULL | reordered_cards[0] = 19 | |
q | NULL | NULL | NULL | NULL | q_5 = s / i_5 = 20 | s = 103 |
r | NULL | NULL | NULL | NULL | r_5 = s % i_5 = 3 |
当 i = 4 时,有 $q_4 = q_5 / i = 20/4 = 5, r_4 = q_5 % i = 20%4 = 0$
。也就是把上一轮的商作为本轮的被除数。本轮之后列表 cards 为 $[12, 18, 50]$
。
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | NULL | NULL | NULL | reordered_cards[1] = 4 | 19 | |
q | NULL | NULL | NULL | q_4 = q_5 / i_4 = 5 | q_5 = 20 | s = 103 |
r | NULL | NULL | NULL | r_4 = q_5 % i_4 = 0 | r_5 = 3 |
当 i = 3 时,有 $q_3 = q_4 / i = 5/3 = 1, r_3 = q_4 % i = 5%3 = 2$
。本轮之后列表 cards 为 $[12, 18]$
。
i | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
card | NULL | NULL | 50 | 4 | 19 | |
q | NULL | NULL | q_3 = 1 | q_4 = 5 | q_5 = 20 | s = 103 |
r | NULL | NULL | r_3 = 2 | r_4 = 0 | r_5 = 3 |
当 i = 2 时,有 $q_2 = q_3 / i = 1/2 = 0, r_2 = q_3 % i = 1%2 = 1$
。本轮之后列表 cards 为 $[12]$
。
i | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
card | NULL | 18 | 50 | 4 | 19 | |
q | NULL | q_2 = 0 | q_3 = 1 | q_4 = 5 | q_5 = 20 | s = 103 |
r | NULL | r_2 = 1 | r_3 = 2 | r_4 = 0 | r_5 = 3 |
当 i = 1 时,有 $q_1 = q_2 / i = 0/1 = 0, r_1 = q_2 % i = 0%1 = 0$
。本轮之后列表 cards 为 []
。
i | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | 19 | |
q | q_1 = 0 | q_2 = 0 | q_3 = 1 | q_4 = 5 | q_5 = 20 | s = 103 |
r | r_1 = 0 | r_2 = 1 | r_3 = 2 | r_4 = 0 | r_5 = 3 |
最终,我们得到一个列表 reordered_cards = [12, 18, 50, 4, 19]
。
第四步,我们对上一步中得到的列表 $[12, 18, 50, 4, 19]$
进行分割,将前 4 张牌 $[12, 18, 50, 4]$
送入解码器中,最后一个值 19 作为答案保存下来,用于最后魔术成功与否的判定。
第五步,将前 4 张牌 $[12, 18, 50, 4]$
解码。此步相当于是第三步的逆过程。我们将牌组按如下规则进行解码:
for i in range(1, 5, 1):
q_list[i] = i * q_list[i-1] + r_list[i-1]
if i != 4:
visible_cards.append(first_four[i])
r_list[i] = sorted(visible_cards).index(first_four[i])
为了理解这一步做了什么,我们将上述循环展开。
点击查看每轮迭代中的细节
当 i = 1 时,有 q_1 = 0, r_1 = 0
(证明略,提示:$s < 5!$
)。其次,由已知的前 4 张牌 $[12, 18, 50, 4]$
,我们知道:
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | NULL | |
q | q_1 = 0 | NULL | NULL | NULL | NULL | s = NULL |
r | r_1 = 0 | NULL | NULL | NULL | NULL |
当 i = 2 时,有 q_2 = i_1 * q_1 + r_1 = 0, r_2 = [12, 18].index(18) = 1
。
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | NULL | |
q | q_1 = 0 | q_2 = 0 | NULL | NULL | NULL | s = NULL |
r | r_1 = 0 | r_2 = 1 | NULL | NULL | NULL |
当 i = 3 时,有 q_3 = i_2 * q_2 + r_2 = 1, r_3 = [12, 18, 50].index(50) = 2
。
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | NULL | |
q | q_1 = 0 | q_2 = 0 | q_3 = 1 | NULL | NULL | s = NULL |
r | r_1 = 0 | r_2 = 1 | r_3 = 2 | NULL | NULL |
当 i = 4 时,有 q_4 = i_3 * q_3 + r_3 = 5, r_4 = [4, 12, 18, 50].index(4) = 0
。
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | NULL | |
q | q_1 = 0 | q_2 = 0 | q_3 = 1 | q_4 = 5 | NULL | s = NULL |
r | r_1 = 0 | r_2 = 1 | r_3 = 2 | r_4 = 0 | NULL |
当 i = 5 时,有 q_5 = i_4 * q_4 + r_4 = 20
。
i | i_1 = 1 | i_2 = 2 | i_3 = 3 | i_4 = 4 | i_5 = 5 | |
---|---|---|---|---|---|---|
card | 12 | 18 | 50 | 4 | NULL | |
q | q_1 = 0 | q_2 = 0 | q_3 = 1 | q_4 = 5 | q_5 = 20 | s = NULL |
r | r_1 = 0 | r_2 = 1 | r_3 = 2 | r_4 = 0 | r_5 = NULL |
第六步,推测第五张牌的值的取值范围。
经过迭代后,我们知道 $ q_5 = 20 $
,虽然不知道 $ r_5 $
等于多少,但是我们知道 $ r_5 $
的范围为 $ 0 \leq r_5 \leq 4 $
。又因为 $s = 5 * q_5 + r_5 $
, 故有 $ 100 \leq s \leq 104 $
。
此外,我们还知道 s = (v + sum([12, 18, 50, 4]))。设第五张牌的值为 v,有:
$$ 100 \leq (v + sum([12, 18, 50, 4])) \bmod 5! \leq 104 \\ 100 \leq (v + 84) \bmod 5! \leq 104 \\ 16 \leq v \bmod 120 \leq 20 $$
又 $ 1 \leq v \leq 52 $
,故:
$$ 16 \leq v \leq 20 $$
上述过程的代码如下:
# 总结当前信息
sum_of_first_four = sum(first_four)
q = q_list[-1]
# 计算编码时q的初始值s的范围
range_l = 5 * q_list[-1] # range_l <= s的值
range_r = 5 * q_list[-1] + 4 # s的值 <= range_r
# 计算第五张牌的值v的范围
times = 0
while True:
v_l = range_l + times * factorial(5) - sum_of_first_four
v_r = range_r + times * factorial(5) - sum_of_first_four
times += 1
if (v_r >= 1) and (v_l <= 52):
break
elif times > 2:
raise ValueError('can not find the range of v.')
第七步,尝试可行域中的每一种取值。唯一可行的取值即为第五张牌的值。
# 猜解码时第五次迭代的余数r
for v in range(v_l, v_r+1):
if v not in first_four:
temp = first_four[:]
temp.append(v)
test_r = sorted(temp).index(v)
s = 5 * q + test_r
guess_v = s + (times - 1) * factorial(5) - sum_of_first_four
if v == guess_v:
return v
MCP 实现
我们可以通过 MCP,实现可供大模型调用的外部工具。
在 MCP 出现以前,大模型就可以通过 Function Calling 的方式调用外部工具。但是每个大模型有每个模型的 Function Calling,相互之间并不兼容,缺少统一的、标准化的工具调用方法。于是 MCP 应运而生。
MCP(Model Context Protocol,模型上下文协议)是一种通信协议,它统一了大模型与外部工具的交互方法。目前 MCP 提供三种通信方式:
- Stdio(标准输入/输出)
- SSE(Server-Sent Events)
- Streamable HTTP
Stdio 适合本地运行,每次运行需要重新加载依赖的资源。SSE 和 Streamable HTTP 是提供远程 MCP 服务的方式。关于两者的区别,可以参考阿里云的文章 MCP 协议:为什么 Streamable HTTP 是最佳选择?
FastMCP 是当下编写 MCP Server 的最佳方式。FastMCP 能够处理与 LLM 通信过程中的复杂性,让开发者得以聚焦功能性代码。只需要写非常少的代码,就可以实现完整的 MCP 功能。
在我的 代码仓库 中,MCP 的实现只有寥寥几行。
from fastmcp import FastMCP
from .magic import decode_cards, encode_cards
mcp = FastMCP("card_magic_sse")
@mcp.tool
async def encode_cards_tool(cards: str) -> str:
"""本函数实现魔术师 Chico 的操作:将五张牌重新排序,
并在排序过程中将第五张牌的信息编码在前四张的排序信息中
:param cards: 该参数接受 5 张牌,形如 ♥K ♣3 ♠7 ♦5 ♠A
每张牌由【花色】和【数字】组成:
- 可选的花色:
- ♠ (黑桃, Spades)
- ♥ (红心, Hearts)
- ♦ (方块/方片, Diamonds)
- ♣ (梅花, Clubs)
- 可选的数字: A 2 3 4 5 6 7 8 9 10 J Q K
注意:请将花色和数字标准化后再作为参数传入
:return: 本函数返回前四张牌和第五张牌的值
"""
return encode_cards(cards)
@mcp.tool
async def decode_cards_tool(cards: str) -> str:
"""本函数实现魔术师 Dico 的操作:根据前四张牌猜第五张牌
:param cards: 该参数接受 4 张牌,形如 ♠A ♥3 ♣10 ♦K
每张牌由【花色】和【数字】组成:
- 可选的花色:
- ♠ (黑桃, Spades)
- ♥ (红心, Hearts)
- ♦ (方块/方片, Diamonds)
- ♣ (梅花, Clubs)
- 可选的数字: A 2 3 4 5 6 7 8 9 10 J Q K
注意:请将花色和数字标准化后再作为参数传入
:return: 本函数返回第五张牌的值
"""
return decode_cards(cards)
if __name__ == "__main__":
mcp.run()
只需要将装饰器 @mcp.tool
添加到工具函数上,FastMCP 会自动读取函数的 docstring 作为该工具函数的解释文本。
如果你希望改变通信方式,也非常简单。只需要修改 mcp.run()
的 transport
参数就可以。transport
参数默认为 stdio
,可以通过指定不同的 transport
值,改变 FastMCP 的通信方式。
# Stdio
mcp.run(transport="stdio")
# SSE
mcp.run(transport="sse",
host='0.0.0.0',
port='8000')
# Streamable HTTP
mcp.run(transport="http",
host='0.0.0.0',
port='8000',
path="/mcp")
目前,使用 MCP 的最佳方式依然是使用 Agent。主流的 Agent 框架全都支持 MCP,比如 Qwen Agent。直接使用大语言模型,也可以调用 MCP Server,但是需要一定的开发量,不如直接用 Agent 省事。
推至极限
作为项目升级的一部分,我将五年前特定情形的算法($n = 52, k = 5, j = 1$
),推广到任意 $n, k, j$
的情形。当然,参数 $n, k, j$
依然受信息论的约束:
$$ n \leq k! + k - j $$
算法实现如下:
from functools import lru_cache
@lru_cache(maxsize=128)
def factorial(n):
"""计算n的阶乘"""
if n == 0 or n == 1:
return 1
else:
return n * factorial(n-1)
class MagicMax:
"""Chico 和 Dico 的魔术 - 推广版"""
def __init__(self, n, k):
assert 3 <= k
assert k <= n < factorial(k) + k
self.n = n # 整个牌组有多少牌
self.k = k # 从牌组中抽几张牌
@staticmethod
def reverse(cards):
"""翻转牌组,分出前 k - 1 张牌"""
reversed_cards = cards[::-1]
return reversed_cards[:-1], reversed_cards[-1]
def encoder(self, cards):
"""将第 k 张牌的牌面信息编码到前 k-1 张的顺序中"""
res = []
cards = sorted(cards)
s = sum(cards) % factorial(self.k)
q = s
for i in range(self.k, 0, -1):
q, r = divmod(q, i)
res.append(cards.pop(r))
return res
def decoder(self, visible_cards):
"""将第 k 张牌的牌面信息从前 k-1 张牌的排列信息中解码出来"""
# 逆向求解编码过程
q, r = 0, 0
for i in range(1, self.k):
q = i * q + r
if i < self.k - 1: # 前 k-2 步需要计算余数
r = sorted(visible_cards[:i+1]).index(visible_cards[i])
# 判断模 k! 的偏离量 t
sum_visible_cards = sum(visible_cards)
factorial_k = factorial(self.k)
epoch = sum([self.n - i for i in range(self.k)]) // factorial_k
tt = None
for t in range(epoch + 1):
v_guess = self.k * q + t * factorial_k - sum_visible_cards
if 1 <= v_guess <= self.n:
tt = t
break
for r in range(self.k):
# 线索1:牌组总和为 s + k! * t
s = self.k * q + r
v = s + factorial_k * tt - sum_visible_cards
# 线索2:第 k 张牌的值 v 放入牌组中获得正确的余数 r
if 1 <= v <= self.n and v not in visible_cards:
real_r = sorted(visible_cards + [v]).index(v)
if real_r == r:
return v
decoder
部分的逻辑还是复杂了些,如果有大佬写出更简洁的实现,请随时向我提 Pull Request。
在工程实现上,我还编写了两个核心类:CardMapper
和 CardGame
,分别实现了 扑克牌和数字编码的双向转换 和 魔术流程编排 的功能。感兴趣的话,可以查阅项目的 GitHub 代码仓库 luochang212/card-magic-mcp。
以下是本项目的全部资源列表:
- 哔哩哔哩: Chico & Dico 的纸牌魔术【MCP】
- GitHub: card-magic-mcp
- PyPI: card-magic-mcp
- ModelScope: card-magic-mcp
- Smithery: card-magic-mcp