两周做了 34 个算法可视化
我教算法。最难的不是数学——而是让学生建立起对算法行为的直觉。你可以在白板上讲红黑树旋转,但在他们亲眼看到树实时重组之前,一切都是抽象的。
所以我做了 34 个交互式算法可视化。两周。每个都是独立的 HTML 文件,零依赖。暗色主题,统一的控制栏,任何浏览器打开就能跑。项目是 visual-cs。
技术栈
没有 React。没有构建步骤。没有 npm。每个可视化是一个 .html 文件,内联 CSS 和 JavaScript。浏览器打开直接运行。
这是刻意的选择。算法可视化有个分发问题:它们活在课程网站上、被邮件发给学生、嵌入 PPT。每一个依赖都是摩擦。一个 HTML 文件能在任何场景下存活——U 盘、GitHub Pages、桌面双击打开。
统一的视觉风格:暗色背景(#1a1a2e),状态转换用强调色,数值用等宽字体,底部控制栏有播放/暂停/单步/重置。每个可视化看起来都属于同一个家族。
34 个文件总计约 30,000 行 JavaScript。没有共享库。文件之间有些复制粘贴,但这正是重点——每个文件都自包含,可以独立理解。
最难的几个
大部分排序和搜索可视化都很直白。画条形或节点,动画交换和比较。真正有趣的工程挑战来自状态复杂、交织运行的算法。
Raft 共识
Raft 是分布式共识协议。多台服务器各有自己的状态(follower/candidate/leader),通过带网络延迟的消息通信。可视化意味着:
- 多个独立状态机并发运行
- 节点间的在途消息(可配置延迟)
- 选举超时、心跳间隔、日志复制
- 可以手动触发的脑裂场景
可视化展示 5 个节点排成圆形。每个节点有彩色环显示状态。消息以动画弧线在节点间飞行。点击节点可以杀掉它,再点恢复。右侧日志面板记录每次状态转换。
最难的是时间。Raft 是时间依赖的——选举超时、心跳间隔。我用虚拟时钟代替 setTimeout,可以暂停、单步或加速。每个事件都调度在虚拟时钟上。这意味着逐事件步进 Raft 是真的可行的,对教学至关重要。
class VirtualClock {
constructor() {
this.time = 0;
this.events = []; // 按调度时间的最小堆
}
schedule(delay, callback) {
this.events.push({ time: this.time + delay, callback });
this.events.sort((a, b) => a.time - b.time);
}
tick(dt) {
this.time += dt;
while (this.events.length && this.events[0].time <= this.time) {
this.events.shift().callback();
}
}
step() {
if (this.events.length) {
this.time = this.events[0].time;
this.events.shift().callback();
}
}
}step 函数直接跳到下一个事件。学生可以逐 tick 走过一次 leader 选举,精确看到每个节点何时以及为何转换状态。
红黑树
红黑树是最多学生放弃的算法。旋转规则在亲眼看到发生之前感觉像是任意的。可视化画出带动画布局的树(节点在插入后平滑滑动到新位置),给每个节点着色,并且——关键功能——展示旋转回放面板。
旋转发生时主树暂停。侧面板弹出,只展示涉及的三个节点。慢动作播放旋转:父子指针交换、颜色变更、子树重新挂载。然后淡回全树视图,此时树已经更新完毕。
布局算法是 Reingold-Tilford 加动画插值。每个节点存储目标 (x, y) 并在 20 帧内 lerp 过去。树流动到新形状而非瞬移。
function animateLayout(root, canvas) {
computeReingoldTilford(root); // 设置目标位置
requestAnimationFrame(function step() {
let settled = true;
forEachNode(root, node => {
node.x += (node.targetX - node.x) * 0.15;
node.y += (node.targetY - node.y) * 0.15;
if (Math.abs(node.x - node.targetX) > 0.5) settled = false;
});
draw(canvas, root);
if (!settled) requestAnimationFrame(step);
});
}斐波那契堆
没人在生产环境实现斐波那契堆。它存在是为了让均摊分析变得有趣。但级联剪切操作——标记一个节点然后剪切可以触发一连串向上的剪切——在纸上真的很难跟。
可视化将堆表示为水平排列的树列表(根链表)。每个节点显示键值和是否被标记。调用 decreaseKey 时,剪切以 500ms 动画发生:节点脱离,浮到根链表,如果父节点已经被标记,父节点也被剪切——连锁反应,每步依次动画。
挑战在于画一个形状任意的、会合并和分裂的森林。我放弃了巧妙的布局算法,用了简单的递归方式:每棵树有个包围盒,根链表的树从左到右排列,子节点纵向堆叠。大堆的时候不好看,但到 30 个节点都是可读的,教学够了。
Ford-Fulkerson(最大流)
流网络需要两样大多数图可视化没有的东西:显示容量/流量的边标签,以及展示残差图的方式。
可视化有两个模式:原始图带流量值更新,以及切换到残差图显示正向/反向边。找到增广路径时绿色高亮,动画「流量」粒子沿路径移动。瓶颈边闪红。
我实现了 BFS(Edmonds-Karp)和 DFS 两种寻路。下拉框切换。看 DFS 找到更长的增广路径 vs BFS 找最短路径,复杂度差异变得直观。
边的渲染出乎意料地麻烦。双向连接用二次贝塞尔曲线,标签放在曲线中点,旋转角度跟随边的方向。让标签不和边重叠需要沿切线的垂直方向偏移。
AI 辅助开发
一个人从头写 34 个可视化,两周完成,这个速度不合理。我大量使用了 Claude Code 和 Codex。
模式:每个类别的第一个可视化我手写——排序的、树的、图的。这建立了视觉风格、控制栏接口和动画模式。然后把参考文件丢给 Claude Code 说「按同样的风格做一个斐波那契堆可视化」。
AI 擅长的:
- 生成样板代码(canvas 设置、控制栏、暗色主题 CSS)
- 正确实现知名算法
- 把已有可视化的结构适配到新算法
AI 不擅长的:
- 复杂结构的布局算法(每个树布局我都手动重写了)
- 动画时序和缓动(它会把所有东西做成线性的,看起来很机械)
- 知道何时打破模板(有些算法需要根本不同的 UI)
我的估计:AI 写了约 60% 的代码。我重写了它产出的约 30%。剩下 40% 从头写。净生产力倍数:大约 3 倍。没有 AI 辅助,这会是一个 6 周项目。
并行工作流才是真正的赢面。当我在调试 Raft 可视化的选举超时逻辑时,Claude Code 在生成三个排序可视化的初始版本。等我搞定 Raft,我面前是三个需要打磨的可视化而不是三个空白文件。
什么样的算法可视化才是好的
做了 34 个之后,一些规律浮现了:
逐步优于连续动画。 播放按钮适合演示,但学生真正用的是单步按钮。每个可视化都有 step 函数,精确前进一个逻辑操作。
颜色编码状态,不是装饰。 红色表示「正在被比较」。绿色表示「已到最终位置」。黄色表示「交换候选」。这在所有 34 个可视化中一致。学生的直觉可以从一个迁移到下一个。
展示数据结构,不只是算法。 只展示增广路径的 Ford-Fulkerson 可视化不如同时展示完整残差图的有用。上下文比聚焦更重要。
让用户搞破坏。 Raft 可视化让你在选举中杀掉节点。BST 可视化让你插入病态序列。哈希表让你把负载因子设到 0.99。有趣的行为永远在边界。
速度控制是必须的。 有些学生需要 0.25 倍速跟一个旋转。有些需要 4 倍速看总体模式。0.1x 到 10x 的滑块覆盖所有人。
数字
- 34 个可视化
- 约 30,000 行 HTML/CSS/JS
- 0 个外部依赖
- 每个文件浏览器直接打开
- 平均文件大小:约 880 行
- 最大:Raft 共识 2,400 行
- 最小:线性搜索 280 行
覆盖类别:排序(8)、树(6)、图(7)、堆(3)、哈希(2)、字符串匹配(3)、动态规划(2)、分布式系统(2)、其他(1)。
最自豪的是 Raft。学生用得最多的是红黑树。让我意外的是哈希表可视化——看着线性探测在负载因子超过 0.7 后退化成 O(n),比任何一堂课都有说服力。
