一块钉着密密麻麻钉子的板,从顶上丢一颗球下去,它每碰到一颗钉子就 50% 往左、50% 往右,撞完最后一排钉子落进底部的格子。连着丢 1000 颗,底部的柱子会自己堆成一条中间高、两边低的钟形曲线------没有谁在摆这个形状,每颗球也完全不认得别的球。这件事值得做成一个能动手的页面,是因为它把「正态分布」从一个统计公式变成了一颗一颗看着堆出来的现象。
静态图讲不清两件事:投得越多,实际柱子和理论曲线是怎么一步步贴合的?层数变了,曲线为什么会变矮变宽? 所以我把它做成了一个能点的网页:上面是三角形钉阵和正在下落的球,下面是实时直方图加一条橙色的理论曲线,旁边再摆一块统计面板,把实测均值、标准差和理论值并排放着对比。
几条关键信息先摆出来:
- 源码:Demo Park 公开仓库 → atomgit.com/deli007/dem...,
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,落进把它们加起来对应的那个格子。下面会如实写它的边界。

为什么值得看:一条钟形曲线,其实是 n 枚硬币
一颗粒子从顶上出发,每过一层钉子做一次二选一,走完 n 层,它一共做了 n 次独立的 ±1。落进哪个格子,只取决于这 n 次里有多少次向右:
text
格子编号 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算出来的。
页面里有三个实现决定值得说清楚:
- 每颗球的走法在投出时就抽完,下落只是动画。 点「投 1000 颗」时,
spawnMany一次性给 1000 颗球各抽好 n 个 ±1 存进offs,再让它们同时开始下落;球到底部时把offs的和换算成格子编号、计数加一。所以「落下去几颗」和「柱子涨几格」严格同步,不会出现动画和统计对不上。 - 随机数自己实现,种子固定。 用的是 mulberry32,种子写死 20240928;「重置」会把发生器拨回同一个种子。同一批投球重跑,每个格子的计数逐格一致------这不是嘴上说的,下面本地复现里有对比数字。
- 理论曲线按格子中心对齐。 用递推算出二项系数
C(n,k)、乘0.5^n归一化成P(k);画的时候横坐标取每个柱子的中心,所以曲线峰值正好压在中间那根柱子上(图 1 里计数 127 的那根)。
也说几处真实的边界:
- 只有「左右各 50%」这一条规则,没有钉子的形状、没有摩擦和弹性碰撞,也没有球与球之间的推挤。现实里的豆子机会因为钉阵不完美、板子不水平而偏斜,这里完全没有这些误差源。
- 画布是一块固定的逻辑画板,靠 CSS 缩放到容器宽度,所以拉宽窗口不会让钉阵重排------它只是被整体放大(或缩小)。
- 层数范围是 6~16。层数越大,格子越多、每格的期望球数越少,同样的投球数下柱子的涨落看起来就越明显。
任务描述
需求是直接粘进码道 Web 输入框的,原文如下:
text
开发一个单文件 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 格计数:
text
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%,两头那几格差得更多,但那是期望值本身就只有几个球的格子,涨落自然大。这就是二项分布的脾气:样本越小,尾巴越不稳。

把层数从 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 变大,分布就更散。有意思的是「往哪边变」这件事在画面上一眼能看出来,但用数字表达就得同时给均值和标准差两个量。

踩坑与边界:四处真实的不一致
一、「最大偏差」这个指标会被两头的格子绑架。 页面把它定义成「所有格子里,实际计数和理论期望的最大相对误差」。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 回车,红框加红字提示「层数无效:层数必须在 616 之间,当前输入为 99。已保留上一次有效值 n=12。」;填 16 的整数」。三种都保留了上一次的有效值,页面继续按 n=12 跑,不白屏。要注意的是输入框里留着的还是那段非法文本(只是变红),而不是自动回填成 12------「保留有效值」保的是内部状态,不是输入框的显示。abc、或者清空,提示分别是「层数无效:层数必须是整数(不能含小数、字母或空格)」「层数无效:层数不能为空,请输入 6
三、改层数会把已经投的球清空。 把 12 改成 16 会触发一次 reset():计数器归零、球清空、随机数发生器拨回种子,统计面板上刚跑出来的记录也一起没了。需求里没规定切层数时要不要留数据,可如果只是想「换个层数看看」,先把当前状态记下来再改比较稳妥。
四、可复现只对「投 N 颗」成立,自动连投不可复现。 点「投 100 颗 / 1000 颗」时,每颗球的 ±1 是连着从种子发生器里抽的,所以同一批球重跑计数一模一样(我实测 n=12、1000 颗两次的 13 格计数逐格相同)。但「自动连投」是按每秒落球数、结合每帧真实耗时来撒球的,帧率一抖,某一帧撒出去的球数就变,消耗掉的随机数个数也跟着变,于是结果依赖时序、不可复现。另外球的横向抖动用的是 Math.random(),只影响下落路径的动画,不影响最终落格。
自检 8/8,但自检不能当验收
点「运行自检」,8 条断言我本机重跑的原文是:
text
自检 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 通过」只说明机制没写错,不说明分布贴合得好。

失败路径是唯一一处我要求得很死的地方,也是它第一次就完全达标的地方:三种非法输入都给了明确中文提示,不白屏、不静默忽略。
| 输入 | 提示原文 |
|---|---|
层数填 99 |
层数无效:层数必须在 6~16 之间,当前输入为 99。已保留上一次有效值 n=12。 |
层数填 abc |
层数无效:层数必须是整数(不能含小数、字母或空格)。已保留上一次有效值 n=12。 |
| 层数留空 | 层数无效:层数不能为空,请输入 6~16 的整数。已保留上一次有效值 n=12。 |

准备环境:进入码道 Web
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 本案例目录下载
index.html。 - 双击打开。默认 n=12、投放速度 10 颗/秒,点一下「投 1000 颗」等十几秒就能看到钟形堆起来。
- 想走静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 把窗口拉到一个较大的视口(我用的是 1920×1200),钉阵和直方图会一起放大;画布是固定逻辑尺寸,缩放不会让钉阵重排。
下面这些数是我本地逐条跑出来的,都能自己复验:
- 画布:视口 1920×1200 时,画布 CSS 尺寸 1038×664、后备缓冲 2500×1600(页面按
devicePixelRatio做了超采样),阵型和直方图没有重影、没有错位。 - 汇总统计:n=12、1500 颗,实测均值 6.009 / 标准差 1.730,理论 6.000 / 1.732。
- 逐格对比:n=12、1000 颗,13 格计数见上表,中间五格偏差 5%~13%。
- 换层数:n=16、2000 颗,实测均值 7.971 / 标准差 1.990,理论 8.000 / 2.000。
- 可复现:n=12、1000 颗,重置后重跑一次,13 格计数逐格一致。
- 自检:点「运行自检」8/8 通过,面板把每条的实测数字都打出来。
- 非法输入: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 说完成不算验收。
亲手点三下
页面打开就能验,三下够了:
- 看它堆起来:连点两次「投 1000 颗」(第一下点完稍等一下再点第二下),盯住统计面板里的「实际均值」和「实际标准差」,看它们怎么一步步贴住 6.000 和 1.732,同时看中间那根柱子越涨越高。
- 换层数:把层数改成 16 回车,再点两次「投 1000 颗」,画面会从 13 格变成 17 格、峰顶的位置从第 6 格挪到第 8 格,标准差从 1.732 涨到 2.000------「n 变大、分布更散」在画面上很直观。
- 看失败路径 :层数框填
99回车,必须看到红框和一行中文红字提示、有效值仍是 12、页面不白屏;再把99换成abc、再清空,两种提示分别换成「必须是整数」和「不能为空」。
直接下载试玩
- Demo Park 仓库:atomgit.com/deli007/dem...
- 本案例目录:atomgit.com/deli007/dem...
总结
把高尔顿板做成能点的页面之后,最值得记住的不是那堆钉子,而是那个组合数:一颗球走完 n 层,等于做 n 次 50/50 的二选一,于是它落在第 k 格的次数就只能是 C(n,k)。这条曲线不是被画出来的,是被数出来的------投得越多,柱子越像理论线,而两头的尾巴永远最不听话,因为那里的期望值本来就不到一颗球。想接着玩,可以把每层的偏转概率从 0.5 改成 0.6(分布会整体右移、不再是对称的二项分布),也可以直接把层数推到 16、投够几千颗,看看钟形是怎么随着 n 长大、越来越像正态曲线的。