幽灵堵车:前面没事故,为什么车流还是自己堵成一串

举报
deli007 发表于 2026/09/29 10:42:11 2026/09/29
【摘要】 幽灵堵车:前面没事故,为什么车流还是自己堵成一串

高速上开着开着突然慢下来,挪过一段又莫名其妙畅通了——前面没有事故,没有施工,也没有红灯。这种堵车叫「幽灵堵车」,它的成因不是某个障碍,而是刹车这件事会自己传染:一个人因为任何理由轻点一下刹车,后面的人就得跟着刹得更多,这一小段减速会顺着车流往上游滚,最后滚成一串停停走走的车龙。

静态图讲不清两件事:它到底需要多高的车流密度才会自己长出来?把它里面的一辆车直接拿掉,拥堵会不会散? 所以我把这件事做成了一个能拖的网页:8 条互相独立的环形车道同屏,每辆车按 Nagel-Schreckenberg 规则逐格更新;下方是一张实测画出来的「基本图」(密度 → 流量),边上可以一键把整条曲线扫出来。

几条关键信息先摆出来:

  • 源码:Demo Park 公开仓库 → https://atomgit.com/deli007/demo_park/tree/main/codearts-phantom-jam,index.html 可直接下载
  • 生成物:单个 index.html(约 30 KB),纯前端、零依赖、不联网、不上传,全部计算在本地完成
  • 本地运行:双击文件即可;或 python -m http.server 8000
  • 本地验证:1920×1200 视口下画布 1890×477;自检 2/2 通过;密度扫了 6 档,表在下面;实测流量峰值 0.691 落在 ρ≈0.18

它不是交通工程仿真。模型里没有换道、没有匝道汇入、没有驾驶员的反应时间差异,只有「加速—跟车减速—随机刹车—前进」四步,而且道路是环形的、第 i 辆车只看得见正前方那一辆。下面会如实写它的边界。

图 1:密度 0.20、p=0.10 跑了 1540 步之后。8 条环形车道上有 284 辆车,红色的都是速度 ≤ 1 的车,它们连成一段一段的拥堵带:平均速度 2.82(上限 5)、流量 0.626、拥堵比例 32.3%、最长拥堵段 33 格

为什么值得看:堵车的种子是一次随机刹车,而它会自己长大

Nagel-Schreckenberg 模型每个时间步只做四件事,每辆车各自执行:

1. 加速:v ← min(v + 1, vmax)
2. 跟车减速:v ← min(v, 与前车的空格数)
3. 随机刹车:以概率 p,v ← max(v - 1, 0)
4. 前进:车向前移动 v 格

第 1 步让车想跑快,第 2 步保证不追尾(这里面已经藏着「一慢就传染」的机制:前车慢,后车被迫跟着慢),第 3 步是关键——没有任何外部原因,纯粹是概率上的随机减速。这三步合起来的结果,就是自由流会自己退化成一串一串的停停走走。

真正值得做成能拖的页面的,是下面两个量:

  • 流量 Q = 实际密度 ρ × 平均速度 v̄。堵得很死的路和空得很的路,流量都不高;中间那个凸起的峰值才是路能送走的最大车流。页面里的「基本图」就是把每档密度跑出来的 (ρ, Q) 连成一条曲线,峰值标在曲线上。
  • 拥堵比例:速度 ≤ 1 的车占全场的百分比。它把「堵」这个感觉变成了一个可以盯着看的数字——我这次实验里它能在 1 秒内从 33.5% 涨到 34.4%,也能一直挂在 84%。

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

  1. 随机数自己实现,种子可复现。 用的是 xorshift32,种子显示在状态条上(默认 12345)。同一个种子重置后重跑,每一步每辆车的速度序列逐位一致——这不是嘴上说的,页面里那两条自检断言之一就是干这个的。
  2. 每个格子是「空」或「一辆 0~vmax 的车」,速度用整数存。 环形道路 160 个格点、8 条独立环线,一辆车就是一个整数,整场 1280 个格子每步全量更新,所以它跑得动。
  3. 状态条上的「流量」用的是实测密度,不是滑杆上的密度。 落车是按每个格点独立掷骰子撒的,滑杆填 0.20 时实际落下来的是 0.2227(285 辆 / 1280 格),页面老老实实按 0.2227 算 Q。这一点看下面的表就明白了。

也说几处真实的边界:

  • 只有单车道,没有换道:现实中「换道加塞」既是拥堵的原因之一,也是消散途径之一,这里完全没有。
  • 每辆车只看正前方一辆车,没有任何预判;随机刹车概率 p 是全场统一的,不分车型、不分驾驶员。
  • 道路是环形的,所以堵车波绕一圈会回来;这也是它能稳定停在一个「有堵但不全堵」状态的原因之一,和真实高速出口下游的排队不完全一样。

任务描述

请用单个 index.html 文件(内联 CSS 和 JS,不依赖任何外部库、不发任何网络请求)做一个【幽灵堵车实验室】网页应用,深色科技风、全屏自适应:上方是路网画布,下方是实时图表和控制面板。要求:
1) 用 Nagel-Schreckenberg 元胞自动机模型模拟单车道环形道路上的车流……每一步按四步更新(加速 +1 到不超过 vmax;与前车距离不够就减速到与距离相等;以概率 p 随机减速 1;按当前速度向前移动 v 格)。
2) 同时跑 8 条互相独立的环形车道,每条默认 160 个格点,各自独立随机初始化……车辆画成圆角矩形,颜色按速度映射(0 = 红 → vmax = 青绿),速度 ≤ 1 的车额外加一层红色发光。
3) 控制面板提供五个控件:车流密度(0.02~0.60)、最大速度 vmax(1~7 的整数)、随机减速概率 p(0~0.50)、车道数量(1~12)、每帧步数(1~20);三个按钮「暂停 / 继续」「重置」「单步」;再加一个「固定随机种子」输入框(1~999999)和一个「p = 0 确定性模式」快捷按钮。
4) 随机数必须可复现:自己实现一个可复现的伪随机数发生器(例如 xorshift32)……同一个种子下重置后重跑,每一步每辆车的速度序列必须逐位一致。
5) 顶部状态条实时显示:迭代步数、车辆总数、平均速度、流量(= 密度 × 平均速度,单位车/步)、拥堵车比例(速度 ≤ 1 的车辆占比)、最长连续拥堵长度(格点数)、当前种子。
6) 底部画一张「基本图」……把「扫描基本图」按钮扫出来的实测 (密度, 流量) 点连成曲线,标出峰值流量落在哪个密度上。
7) 内置【自检】按钮:(a) 把 p 设为 0、密度设为 0.05 跑 300 步,稳态下每辆车速度都应等于 vmax,平均速度误差 < 1e-9;(b) 用同一个种子跑 2000 步两次,比较两次记录的平均速度序列是否逐位一致。
8) 输入校验:密度、vmax、p、车道数、种子任意一个超出范围或不是数字时,在对应输入框下方用中文红字提示「请输入 X ~ Y 之间的数字(当前保留上一次的有效值)」,并保留上一次的有效值继续运行。
9) 鼠标在画布上点一下,可以移除点击位置附近(同一车道前后各 3 个格点内)最近的一辆车,用来观察「拿掉一辆车之后拥堵怎么消散」。

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

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

它实际长成了什么样:密度扫一遍,看拥堵怎么长出来

先把随机刹车概率固定在 p=0.10、vmax=5、8 条车道、种子 12345,每档都按「重置」重新撒车、等 14 秒再读状态条:

滑杆密度 实测密度 ρ 车辆数 平均速度 v̄ 流量 Q 拥堵比例 最长拥堵段
0.06 0.064 82 4.89 0.313 0.0% 0 格
0.10 0.116 148 4.82 0.558 0.3% 4 格
0.15 0.166 212 4.05 0.671 10.3% 19 格
0.20 0.223 285 2.83 0.630 31.9% 28 格
0.25 0.274 351 2.15 0.591 49.0% 33 格
0.35 0.373 478 1.38 0.514 66.8% 58 格

这张表有两个地方值得盯:

  • 流量先涨后落。 从 0.313 涨到 0.671,然后掉到 0.514。页面里的「扫描基本图」扫了 30 档(0.02→0.60)得到更细的结论:峰值 0.691 落在 ρ≈0.18,和我手扫的这两档(0.166 时 0.671、0.223 时 0.630)围出来的区间对得上。
  • 拥堵比例的抬头比流量转折更陡。 ρ 从 0.166 到 0.223 只多了 0.057,平均速度却从 4.05 掉到 2.83,拥堵比例从 10.3% 跳到 31.9%。也就是说车没多多少,路却塌了一大半——这段陡坡就是「幽灵堵车」的相变区。

图 2:点「扫描基本图」,页面自己扫 30 档密度并把实测点连成曲线,峰值 0.691 标在 ρ=0.18 上;虚线高亮点是当前状态(ρ=0.223,Q=0.627),已经在峰值的右边下坡段

ρ=0.20 这个默认状态我还盯了一段连续演化(每 5 秒采一次):前 5 步平均速度只有 2.04、拥堵比例 39.5%,之后稳定在 2.75~2.85、33.4%~36.1%。也就是说拥堵不是慢慢长出来的,而是几秒钟内就成形,然后卡在这个水平上下浮动。下面是第三次采样点附近的样子:

把里面一辆车拿掉,拥堵会散吗? 我做了这个实验:跑到拥堵稳定在 32.9%~33.5%、最长拥堵段 29 格时按「暂停」,在路面上点掉一辆红车(车辆总数 285 → 284,点中有效),再按「继续」:

时间 车辆数 平均速度 流量 拥堵比例 最长拥堵段
删车前(基线) 285 2.86 0.637 33.5% 29 格
删车后 +0 秒 284 2.78 0.617 34.4% 34 格
删车后 +8 秒 284 2.78 0.617 34.4% 34 格
删车后 +20 秒 284 2.78 0.616 34.5% 34 格

少了一辆车,拥堵比例没有降,反而略涨,最长拥堵段从 29 格变成 34 格。这不是程序出错,而是这个模型最想说明的事:堵车不是一个「卡在那里的东西」,它是一个持续被随机刹车重新生产出来的过程。你拿掉的是结果,不是原因,所以下一脚随机刹车又会把它长回来。

再看两个极端档,边界就清楚了:

  • ρ=0.05、p=0 的「自由流」:64 辆车,平均速度 5.00(= vmax),拥堵比例 0.0%,最长拥堵段 0 格。
  • ρ=0.45、p=0.30 的「大堵车」:591 辆车,平均速度 0.69,流量 0.317,拥堵比例 84.4%,最长拥堵段 98 格——整条环路上三分之一还多的车连成一队。
  • 一个容易误解的点:p=0 的「确定性模式」不是「不堵车」。 ρ=0.30、p=0 时实测平均速度 1.72、拥堵比例 56.8%、最长拥堵段 37 格。随机刹车只是堵车的催化剂之一,初始撒车的不均匀照样能长出拥堵,只是重跑同一个种子会得到一模一样的堵法。

图 3:ρ=0.05、p=0 的自由流。8 条车道上 64 辆车清一色是速度 5 的青色,平均速度刚好等于 vmax、拥堵比例 0.0%、最长拥堵段 0 格;和上面图 1 同一个视口,对照着看就知道密度一上去,青色是怎么被红色吃掉一段一段的

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

一、滑杆写 0.20,状态条说 0.223。 密度滑杆只是「撒车时每个格点的落车概率」,实际密度由落下来的车数决定:densityNow() = 车辆总数 / (格点数 × 车道数),285 / (160 × 8) = 0.2227。基本图上的高亮点、峰值标注用的都是这个实测密度,所以滑杆和读数对不上是设计使然。但这确实容易被误读成 bug——如果需求里写一句「同时显示设定密度与实测密度」,这个歧义就不会存在。

二、暂停只冻结推进,不冻结点击。 「暂停」按钮改的是一个 running 标志,主循环里只有运行时才推进格点;但画布上的 pointerdown 处理函数完全不看这个标志。我上面那次删车就是在暂停状态下完成的:读数一步不动,车却真的少了一辆。要修的话,在 pointerdown 开头加一句 if (!running) return; 就行——这一版我没改,因为截图和上面的数字都是在这个行为下取的。

三、自检只验了两件事,不验曲线形状。 页面自检(我本机重跑)结果是:

✓ 通过
(a) 自由流 ✓  p=0、ρ=0.05、300 步后 57 辆车全部 v = vmax = 5
(b) 确定性 ✓  同种子两次各 2000 步,平均速度序列 2000/2000 逐位一致

两条都是「机制对不对」,没有一条验证「基本图的峰值应该出现在中等密度」。所以「自检 2/2 通过」不能当成「交通流结果可信」——曲线形状是我自己扫出来的,也只能自己判断。

四、帧率不是 60。 主循环是朴素的 requestAnimationFrame,每帧推进「每帧步数」个时间步,没有节流也没有降采样:本机 1920×1200(路网画布 1890×477、8 条车道 × 160 格)实测 6.03 秒推进 630 步 = 每帧 5 步、约 21 帧/秒。车少的时候也差不多快(82 辆和 478 辆都是约 105 步/秒),说明开销主要花在每帧重绘整幅画布上,而不是花在逐格更新上。这个数字看机器和窗口大小,不当性能结论。

图 4:点「自检」,两条断言都通过:自由流下 57 辆车全部达到 vmax;同一个种子跑两次,平均速度序列 2000/2000 逐位一致

失败路径是这份需求里写得最细的一条,也是它第一次就完全达标的地方。 六个输入框(密度、vmax、p、车道数、每帧步数、种子)共用同一个校验器:先解析成数字,再判断是否要求整数,最后判断是否落在范围内;任何一步不过,就在对应输入框下方写一行中文红字,并把输入框回退到上一次生效的值。我逐条试了四个边界:

输入 提示原文
密度填 0.9 请输入 0.02 ~ 0.6 之间的数字(当前保留上一次的有效值)
p 填 0.9 请输入 0 ~ 0.5 之间的数字(当前保留上一次的有效值)
车道数填 3.5 请输入 1 ~ 12 之间的数字(当前保留上一次的有效值)
种子留空 请输入 1 ~ 999999 之间的数字(当前保留上一次的有效值)

四种都命中红字、输入框回退、页面不白屏、不静默忽略。

图 5:失败路径。密度、p、种子三个框同时填了非法值,三行中文红字分别落在各自输入框下方,输入框一起退回上一次的有效值(0.2 / 0.1 / 12345),页面其余部分照常运行

准备环境:进入码道 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. 双击打开。默认密度 0.20、vmax 5、p 0.10、8 条车道、每帧 5 步、种子 12345,跑十几秒就能看到拥堵比例稳在 33%~36%。
  3. 想走静态服务也行:python -m http.server 8000,然后访问 http://localhost:8000。
  4. 把窗口拉到一个较大的视口(我用的是 1920×1200),路网画布会按宽度铺满、8 条车道自动分行;画面右侧和下方不留黑边,就说明画布尺寸重建对了。

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

  1. 画布尺寸:视口 1920×1200 时,路网画布实测 1890×477,基本图 1052×598,devicePixelRatio = 1。
  2. 自检:点「🔬 自检」,两条断言都通过(2/2 通过),面板直接把实测数值写出来(上图 4)。
  3. 密度扫描:上表 6 档,ρ=0.064 时空路很畅,ρ=0.373 时平均速度掉到 1.38、拥堵比例 66.8%。
  4. 峰值:点「📈 扫描基本图」自己扫 30 档,峰值流量 0.691 落在 ρ=0.18。
  5. 删车干预:暂停中点掉一辆车(285 → 284),继续跑 20 秒,拥堵比例不降反略升(见上表)。
  6. 帧率:1920×1200 下实测约 21 帧/秒(每帧推进 5 步、约 105 步/秒),这个数随机器和窗口大小变化。

使用码道体会

  • 把模型的更新规则写进需求正文,而不是描述画面。 需求把「加速 +1 → 跟车减速 → 随机减速 1 → 前进」四步逐条写死,还点名了 vmax、p 的含义,它交回来的就是货真价实的 Nagel-Schreckenberg 元胞自动机,而不是「一堆方块来回动」的动画。本文能列出密度表和基本图,前提就是模型本身是对的。
  • 要求它把中间量显示出来。 「状态条实时显示流量、拥堵比例、最长拥堵段」这一条回报最大:拥堵比例从 10.3% 跳到 31.9% 那段陡坡,就是盯着这个数字看出来的。
  • 把可复现性写成硬指标。 需求里点名「同一个种子重置后每一步速度序列逐位一致」,它就自己实现了 xorshift32 并把种子显示在状态条上——这条后来直接变成了自检断言 (b)。
  • 没写进需求的一致性,默认不会被检查。 密度滑杆写 0.20、状态条却读到 0.223,这处歧义需求里没提,它就没管。下次我会补一句「同时显示设定密度与实测密度」。
  • 验收要自己动手。 它自检 2/2 通过,但两条都只验「机制对不对」,不验曲线形状;峰值该落在哪个密度上,是我自己扫出来、自己判断的。Agent 说完成不算验收。

亲手点三下

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

  1. 看相变:把「车流密度」滑杆从 0.05 慢慢推到 0.40,盯住状态条里的「平均速度」和「拥堵比例」。低密度下平均速度贴着 5、拥堵接近 0;过了 0.16 左右,速度开始往下掉、拥堵比例往上抬,这就是幽灵堵车的相变区。
  2. 扫曲线:点「📈 扫描基本图」,等它把 30 档跑完,看那条先升后降的曲线。峰值 0.691 标在 ρ=0.18 上;再把视线移到峰值右边的高亮点,那里密度更高、流量反而更低——路已经堵到自己送不出车了。
  3. 看失败路径:在密度框里填 0.9 回车,必须看到一行中文红字提示、输入框退回上一次的有效值、页面不白屏;再填 0.35 回车,提示消失、画面按新密度重撒车。

直接下载试玩

总结

把「幽灵堵车」做成一个能拖的网页之后,最值得记住的不是那 8 条环形车道,而是第 3 步那一次「没有理由的随机刹车」:它不停地往车流里投新的减速种子,再被「前车慢、后车更慢」这条规则放大成一段一段的拥堵带。所以堵车不是一个卡在那里的东西,而是一个被持续生产出来的过程——你把里面一辆车拿掉,下一脚随机刹车又会把它长回来。想接着玩,可以试试把随机刹车概率 p 设成 0(确定性模式),看初始的不均匀会长出什么样的堵法;把车道扩到 12 条,比较不同随机种子的差异;或者给模型加一条「换道」规则,看看现实里既是拥堵成因、又是消散途径的那件事,会怎样改写这条基本图。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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