高尔顿板:把球一颗颗丢下去,为什么最后总堆成一座钟形山

举报
deli007 发表于 2026/09/29 11:15:44 2026/09/29
【摘要】 高尔顿板:把球一颗颗丢下去,为什么最后总堆成一座钟形山

一块钉着密密麻麻钉子的板,从顶上丢一颗球下去,它每碰到一颗钉子就 50% 往左、50% 往右,撞完最后一排钉子落进底部的格子。连着丢 1000 颗,底部的柱子会自己堆成一条中间高、两边低的钟形曲线——没有谁在摆这个形状,每颗球也完全不认得别的球。这件事值得做成一个能动手的页面,是因为它把「正态分布」从一个统计公式变成了一颗一颗看着堆出来的现象。

静态图讲不清两件事:投得越多,实际柱子和理论曲线是怎么一步步贴合的?层数变了,曲线为什么会变矮变宽? 所以我把它做成了一个能点的网页:上面是三角形钉阵和正在下落的球,下面是实时直方图加一条橙色的理论曲线,旁边再摆一块统计面板,把实测均值、标准差和理论值并排放着对比。

几条关键信息先摆出来:

  • 源码:Demo Park 公开仓库 → https://atomgit.com/deli007/demo_park/tree/main/codearts-galton-board,index.html 可直接下载
  • 生成物:单个 index.html(约 20 KB),纯前端、零依赖、不联网、不上传,全部计算在本地完成
  • 本地运行:双击文件即可;或 python -m http.server 8000
  • 本地验证:1920×1200 视口;n=12 投 1500 颗,实测均值 6.009(理论 6.000)、实测标准差 1.730(理论 1.732);自检 8/8 通过;同一种子重跑,13 个格子的计数逐格一致

它不是物理仿真。球和钉子之间没有碰撞力学,球和球之间也不相互作用,模型里每颗球只做一件事:在投出的一瞬间抽好 n 个 ±1,落进把它们加起来对应的那个格子。下面会如实写它的边界。

图 1:n=12,投了 500 颗。钉阵是 12 层三角形,底部 13 个格子的蓝色柱子已经堆出中间高、两边低的形状,橙色是理论分布 B(12, 0.5);统计面板里实测均值 5.974、标准差 1.752,理论值分别是 6.000 和 1.732

为什么值得看:一条钟形曲线,其实是 n 枚硬币

一颗粒子从顶上出发,每过一层钉子做一次二选一,走完 n 层,它一共做了 n 次独立的 ±1。落进哪个格子,只取决于这 n 次里有多少次向右:

格子编号 k = (n + Σ±1) / 2       Σ±1 是 n 个 ±1 的和
P(k) = C(n, k) / 2^n             这就是二项分布 B(n, 0.5)
  • 均值 n/2:每次 ±1 的期望是 0,n 次之后平均偏移还是 0,正好落在中间那一格;
  • 方差 n/4:每次 ±1 的方差是 1,n 次独立相加方差就是 n,标准差是 √(n/4) = √n / 2;
  • 为什么是钟形:把 n 个独立同分布的量加起来,分布就朝正态收敛——这就是中心极限定理。所以图里那条橙色曲线不是拟合出来的,是直接按 C(n,k)/2^n 算出来的。

页面里有三个实现决定值得说清楚:

  1. 每颗球的走法在投出时就抽完,下落只是动画。 点「投 1000 颗」时,spawnMany 一次性给 1000 颗球各抽好 n 个 ±1 存进 offs,再让它们同时开始下落;球到底部时把 offs 的和换算成格子编号、计数加一。所以「落下去几颗」和「柱子涨几格」严格同步,不会出现动画和统计对不上。
  2. 随机数自己实现,种子固定。 用的是 mulberry32,种子写死 20240928;「重置」会把发生器拨回同一个种子。同一批投球重跑,每个格子的计数逐格一致——这不是嘴上说的,下面本地复现里有对比数字。
  3. 理论曲线按格子中心对齐。 用递推算出二项系数 C(n,k)、乘 0.5^n 归一化成 P(k);画的时候横坐标取每个柱子的中心,所以曲线峰值正好压在中间那根柱子上(图 1 里计数 127 的那根)。

也说几处真实的边界:

  • 只有「左右各 50%」这一条规则,没有钉子的形状、没有摩擦和弹性碰撞,也没有球与球之间的推挤。现实里的豆子机会因为钉阵不完美、板子不水平而偏斜,这里完全没有这些误差源。
  • 画布是一块固定的逻辑画板,靠 CSS 缩放到容器宽度,所以拉宽窗口不会让钉阵重排——它只是被整体放大(或缩小)。
  • 层数范围是 6~16。层数越大,格子越多、每格的期望球数越少,同样的投球数下柱子的涨落看起来就越明显。

任务描述

需求是直接粘进码道 Web 输入框的,原文如下:

开发一个单文件 index.html 的「高尔顿板(豆子机)模拟器」网页应用,纯前端、零依赖、不联网、不上传,双击就能打开。

物理过程:顶部一颗一颗落球,小球每经过一层钉子就 50% 概率向左、50% 概率向右(用可复现的伪随机数发生器,固定种子,保证同一批投球结果可重复),最后落入底部对应编号的格子。钉子层数 n 可调(6~16,默认 12),底部格子数 n+1。

界面与交互:
1. 用 Canvas 画三角形排列的钉阵、正在下落的小球,底部用直方图柱子显示每个格子累计的小球数,柱子顶部显示计数。
2. 直方图上叠加一条理论曲线:二项分布 B(n, 0.5) 的概率质量函数,画成一条平滑的钟形曲线,让人一眼看出「实际分布 vs 理论分布」。
3. 控件:钉子层数输入框(整数 6~16)、投放速度滑杆(每秒落球数 1~60)、「投 1 颗 / 投 100 颗 / 投 1000 颗」三个按钮、「自动连投」开关、「重置」按钮。
4. 实时统计面板:总球数、格子数、实际均值、理论均值 n/2、实际标准差、理论标准差 sqrt(n)/4、最大偏差(实际柱高与理论期望的最大相对误差)。
5. 深色简洁风格,中文界面。

再加一个「运行自检」按钮,点下去跑 8 条断言并把结果显示在页面上(形如「自检 8/8 通过」或逐条列出失败项):
- 二项系数 C(n,k) 对 k 求和等于 2^n;
- 分布对称:C(n,k) == C(n,n-k);
- 理论均值为 n/2;
- 理论方差为 n/4;
- 归一化:单球落入所有格子的概率之和等于 1(误差 < 1e-9);
- 蒙特卡洛:用固定种子投 2000 颗球,实测均值与 n/2 的偏差小于 0.15*n;
- 直方图计数总和等于已投球数;
- 输入校验:层数非法值被拒绝。

输入校验:层数必须是 6~16 的整数,空值/越界/非数字时输入框变红并在页面给出明确提示,保留上一次有效值,页面不崩、不白屏。

完成后打开预览,让我直接看到运行效果。

码道 Web 交回的是单文件 index.html,自己起了内置预览。下面的数字全部来自我在本机浏览器里对这个文件的重跑,不是转述它的总结。

它实际长成了什么样:多投一点,柱子和曲线是怎么贴上的

把 n 固定在 12,先投 500 颗,再补到 1500 颗,同一块板上两次读数:

投球数 实测均值 理论均值 实测标准差 理论标准差 峰值格子计数
500 5.974 6.000 1.752 1.732 127
1500 6.009 6.000 1.730 1.732 370

均值从 5.974 走到 6.009,标准差从 1.752 收到 1.730(理论 1.732),都在往理论值上靠。但真正能看清「贴合」的地方不在两个汇总数字,而在每一根柱子——投 1000 颗、n=12 时我读出了完整的 13 格计数:

k    =   0     1     2      3      4      5      6      7      8      9     10     11     12
实际 =   0     4    19     60    130    176    237    170    136     43     23      2      0
理论 = 0.24  2.93 16.11  53.71 120.85 193.36 225.59 193.36 120.85  53.71  16.11   2.93   0.24

中间五格(k=4~8)的实际值和理论值差 5%~13%,两头那几格差得更多,但那是期望值本身就只有几个球的格子,涨落自然大。这就是二项分布的脾气:样本越小,尾巴越不稳。

图 2:n=12,投满 1500 颗。橙色理论曲线的两条尾巴几乎贴在横轴上,蓝色柱子只在中间几格站起来;面板读数 6.009 / 1.730 已经非常贴近理论值 6.000 / 1.732

把层数从 12 加到 16,曲线会换个形状。 同样投 2000 颗,17 个格子的柱阵明显更矮更宽:

层数 n 投球数 格子数 实测均值 理论均值 实测标准差 理论标准差
12 1500 13 6.009 6.000 1.730 1.732
16 2000 17 7.971 8.000 1.990 2.000

均值往右挪(6 → 8),标准差也从 1.732 涨到 2.000——√n / 2 里的 n 变大,分布就更散。有意思的是「往哪边变」这件事在画面上一眼能看出来,但用数字表达就得同时给均值和标准差两个量。

图 3:n=16,投了 2000 颗。17 个格子里峰值 400 落在中间第 8 格,实测均值 7.971、标准差 1.990,分别对着理论值 8.000 和 2.000

踩坑与边界:四处真实的不一致

一、「最大偏差」这个指标会被两头的格子绑架。 页面把它定义成「所有格子里,实际计数和理论期望的最大相对误差」。n=12、1000 颗时它显示 100.00%:第 0 格和第 12 格的期望都是 0.244 颗、实际是 0 颗,相对误差就是 100%。n=16、2000 颗时更夸张,显示 3176.80%:第 0 格期望 0.0305 颗、实际 1 颗,(1 − 0.0305) / 0.0305 ≈ 31.77,也就是 3176.8%。所以这个数大不代表分布不对,它只说明「拿相对误差去衡量期望不足 1 颗的格子」没有意义。要判断贴合度得看中间几格(n=12、1000 颗时中间五格在 5%~13% 以内),或者干脆多投球,把尾巴的期望值抬起来。

二、非法输入:输入框变红,但不回填旧值。 层数框填 99 回车,红框加红字提示「层数无效:层数必须在 6~16 之间,当前输入为 99。已保留上一次有效值 n=12。」;填 abc、或者清空,提示分别是「层数无效:层数必须是整数(不能含小数、字母或空格)」「层数无效:层数不能为空,请输入 6~16 的整数」。三种都保留了上一次的有效值,页面继续按 n=12 跑,不白屏。要注意的是输入框里留着的还是那段非法文本(只是变红),而不是自动回填成 12——「保留有效值」保的是内部状态,不是输入框的显示。

三、改层数会把已经投的球清空。 把 12 改成 16 会触发一次 reset():计数器归零、球清空、随机数发生器拨回种子,统计面板上刚跑出来的记录也一起没了。需求里没规定切层数时要不要留数据,可如果只是想「换个层数看看」,先把当前状态记下来再改比较稳妥。

四、可复现只对「投 N 颗」成立,自动连投不可复现。 点「投 100 颗 / 1000 颗」时,每颗球的 ±1 是连着从种子发生器里抽的,所以同一批球重跑计数一模一样(我实测 n=12、1000 颗两次的 13 格计数逐格相同)。但「自动连投」是按每秒落球数、结合每帧真实耗时来撒球的,帧率一抖,某一帧撒出去的球数就变,消耗掉的随机数个数也跟着变,于是结果依赖时序、不可复现。另外球的横向抖动用的是 Math.random(),只影响下落路径的动画,不影响最终落格。

自检 8/8,但自检不能当验收

点「运行自检」,8 条断言我本机重跑的原文是:

自检 8/8 通过 ✓
✓ 二项系数求和:ΣC(n,k) = 2^n(Σ=4096,2^12=4096)
✓ 分布对称:C(n,k) == C(n,n-k)
✓ 理论均值 = n/2(均值=6.000000000000,n/2=6.000000000000)
✓ 理论方差 = n/4(方差=3.000000000000,n/4=3.000000000000)
✓ 归一化:单球落入所有格子概率之和为 1(误差 < 1e-9)(ΣP(k)=1.000000000000)
✓ 蒙特卡洛:固定种子投 2000 颗,实测均值偏差 < 0.15·n(实测均值=5.9745,|Δ|=0.0255)
✓ 直方图计数总和 = 已投球数(Σcounts=1000,total=1000)
✓ 输入校验:空值 / 越界 / 非数字被拒绝

八条里前四条是数学恒等式(求和、对称、均值、方差),第五条是归一化,第六条是蒙特卡洛,第七条是账目对齐,第八条是边界校验。没有一条在验「直方图形状像不像二项分布」——上面那张逐格对比是我自己数出来的。所以「自检 8/8 通过」只说明机制没写错,不说明分布贴合得好。

图 4:点「运行自检」,8 条断言逐条列出并全部打勾,每条后面还带着实测数字(ΣC=4096、ΣP(k)=1.000000000000、蒙特卡洛实测均值 5.9745)

失败路径是唯一一处我要求得很死的地方,也是它第一次就完全达标的地方:三种非法输入都给了明确中文提示,不白屏、不静默忽略。

输入 提示原文
层数填 99 层数无效:层数必须在 6~16 之间,当前输入为 99。已保留上一次有效值 n=12。
层数填 abc 层数无效:层数必须是整数(不能含小数、字母或空格)。已保留上一次有效值 n=12。
层数留空 层数无效:层数不能为空,请输入 6~16 的整数。已保留上一次有效值 n=12。

图 5:失败路径。层数框填成 99 后输入框变红,下方出现一行中文红字提示,有效值保留在 n=12,页面其余部分照常运行

准备环境:进入码道 Web

浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后就能在对话窗口输入需求,不需要装软件。

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。

本地复现

生成物只有一个 index.html,复现没有依赖:

  1. 从 Demo Park 本案例目录下载 index.html。
  2. 双击打开。默认 n=12、投放速度 10 颗/秒,点一下「投 1000 颗」等十几秒就能看到钟形堆起来。
  3. 想走静态服务也行:python -m http.server 8000,然后访问 http://localhost:8000。
  4. 把窗口拉到一个较大的视口(我用的是 1920×1200),钉阵和直方图会一起放大;画布是固定逻辑尺寸,缩放不会让钉阵重排。

下面这些数是我本地逐条跑出来的,都能自己复验:

  1. 画布:视口 1920×1200 时,画布 CSS 尺寸 1038×664、后备缓冲 2500×1600(页面按 devicePixelRatio 做了超采样),阵型和直方图没有重影、没有错位。
  2. 汇总统计:n=12、1500 颗,实测均值 6.009 / 标准差 1.730,理论 6.000 / 1.732。
  3. 逐格对比:n=12、1000 颗,13 格计数见上表,中间五格偏差 5%~13%。
  4. 换层数:n=16、2000 颗,实测均值 7.971 / 标准差 1.990,理论 8.000 / 2.000。
  5. 可复现:n=12、1000 颗,重置后重跑一次,13 格计数逐格一致。
  6. 自检:点「运行自检」8/8 通过,面板把每条的实测数字都打出来。
  7. 非法输入:99 / abc / 空值三种都变红并给出中文提示,有效值保持 n=12,不白屏。

使用码道体会

  • 把公式写进需求,别只写画面。 需求里点名了「二项分布 B(n,0.5) 的概率质量函数」「理论均值 n/2」「理论标准差」,它生成的就是真的二项分布板,而不是「一堆球随机往下掉」的动画。上面那张逐格对比表能列出来,前提就是模型是二项分布。
  • 要求它把理论值摆在旁边。 「实际均值 / 理论均值 / 实际标准差 / 理论标准差 / 最大偏差」五个数字并排放在一块面板上,这一个要求在本文里回报最大:它把「看着像」变成了「差多少」,也让我一眼看出「最大偏差」这个指标本身有问题。
  • 需求里的公式写错了,它按对的实现了。 我在需求里把理论标准差写成了 sqrt(n)/4,其实应该是 √(n/4) = √n/2;它没有照抄,面板上「理论标准差 √(n/4)」显示 1.732(n=12),和实测 1.730 对得上。这说明它会检查一遍公式的合理性——但也提醒我:需求里自己写的公式,仍然要自己核一遍。
  • 没写进需求的一致性,默认不会被检查。 「最大偏差」被尾部格子绑架这件事,需求里只写了「最大相对误差」,它就照字面实现了。下次我会补一句「期望不足 1 颗的格子不参与最大偏差」,或者干脆改成卡方检验。
  • 验收要自己动手。 自检 8/8 是它自己写的断言:前七条验机制、第八条验边界,没有一条验分布形状;形状是我自己一根一根数柱子数出来的。Agent 说完成不算验收。

亲手点三下

页面打开就能验,三下够了:

  1. 看它堆起来:连点两次「投 1000 颗」(第一下点完稍等一下再点第二下),盯住统计面板里的「实际均值」和「实际标准差」,看它们怎么一步步贴住 6.000 和 1.732,同时看中间那根柱子越涨越高。
  2. 换层数:把层数改成 16 回车,再点两次「投 1000 颗」,画面会从 13 格变成 17 格、峰顶的位置从第 6 格挪到第 8 格,标准差从 1.732 涨到 2.000——「n 变大、分布更散」在画面上很直观。
  3. 看失败路径:层数框填 99 回车,必须看到红框和一行中文红字提示、有效值仍是 12、页面不白屏;再把 99 换成 abc、再清空,两种提示分别换成「必须是整数」和「不能为空」。

直接下载试玩

总结

把高尔顿板做成能点的页面之后,最值得记住的不是那堆钉子,而是那个组合数:一颗球走完 n 层,等于做 n 次 50/50 的二选一,于是它落在第 k 格的次数就只能是 C(n,k)。这条曲线不是被画出来的,是被数出来的——投得越多,柱子越像理论线,而两头的尾巴永远最不听话,因为那里的期望值本来就不到一颗球。想接着玩,可以把每层的偏转概率从 0.5 改成 0.6(分布会整体右移、不再是对称的二项分布),也可以直接把层数推到 16、投够几千颗,看看钟形是怎么随着 n 长大、越来越像正态曲线的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。