距离上次写博客已有很长的时间,我也已经近一年没学算法了。最近期末考突然捡起算法与数据结构,以往的痛苦又开始折磨我,使我意识到算法可视化的迫切需求。于是我建了一个这样的项目——一个能让我"看见"算法执行过程的平台。
学算法的新手大概都有这种感觉:看书上的代码,脑子里推演着循环和递归,脑海里理解的很模糊,实际实现一堆错。指针怎么跳的、数组怎么换的、队列怎么进的——这些过程光靠想象,总归是差了点什么。
所以我就想,能不能做一个东西,让算法真的能被看见?而且不是那种柱状图往上冒一下的"看见",是把算法映射到真实世界场景里——比如Dijkstra算法在城市街道上跑,排序算法在工厂流水线上走,并查集在一片社区里建连接。
于是就有了这个项目。
项目已经开源在GitHub上(地址见文末),完整代码、全部场景、所有算法模板都在里面。
项目概览
先放一张整体的效果:
整个平台基于React + Three.js + TypeScript构建,支持JavaScript和Python双语言执行。核心功能一句话说就是:左侧跑朴素算法,右侧跑优化算法,两边的每一步都在3D场景里呈现出来,你可以逐帧播放对比。例如左边是冒泡排序在笨拙地来回搬运,右边是快速排序在利落分治。这就是我想写的东西:算法的执行,不只是时间复杂度的改进,更是一种过程化的升维。
技术栈
项目依赖的核心库:
{"react":"^18.3.1","three":"^0.169.0","@react-three/fiber":"^8.17.0","@react-three/drei":"^9.114.0","zustand":"^5.0.0","codemirror":"^6.0.1"}- React 18+Three.js(通过 @react-three/fiber) 构建3D场景
- Zustand管理播放状态(步骤进退、速度控制)
- CodeMirror 6作为代码编辑器,支持JS/Python语法高亮
- Web Worker沙箱执行用户代码,不阻塞UI
- Tailwind CSS做暗色主题UI
架构设计
整个项目分了几个核心层:
1. 执行引擎层
用户点击"运行"后,代码会被送到Web Worker里执行。执行过程中,代码里的trace()调用会把每一步的快照发回主线程:
// src/core/types.ts — 核心类型定义exportinterfaceTraceSnapshot{label:string;data:Record<string,unknown>;highlights?:number[];pointers?:Record<string,number>;description?:string;timestamp:number;}exportinterfaceAlgorithmTemplate{id:string;name:string;category:string;language:'js'|'python';naiveCode:string;// 朴素实现optimizedCode:string;// 优化实现defaultData:unknown;}执行引擎通过Web Worker做沙箱隔离:
// src/core/executor.ts — 沙箱执行exportfunctionexecuteCode(code:string,data:unknown,timeoutMs:number=5000):Promise<{success:boolean;traces:TraceSnapshot[];error?:string}>{returnnewPromise((resolve,reject)=>{constw=getWorker();constrequestId=`req_${++requestIdCounter}`;// ... 通过 postMessage 通信});}这套架构支持JavaScript原生沙箱执行和Python(通过Pyodide在Worker中运行),未来要扩展C语言也不难,加个executor就行。
2. 场景映射层
每个算法类别都对应一个或多个3D场景。比如图算法映射到城市场景(街道=边,路口=节点),排序算法映射到工厂场景(传送带=数组)。系统会自动检测当前运行的数据结构类型,切换到对应的场景。
场景分为两类:
- 抽象场景:柱状图、节点连线图——清晰展示算法逻辑
- 工程场景:城市、工厂、森林、图书馆——沉浸式体验
这也是这个项目最耗时的部分——光城市场景就写了5000多行代码。
算法模板一览
目前内置了六大类40多种算法的朴素vs优化对比模板,每种都有JavaScript和Python双版本:
排序类
| 朴素 | 优化 | 改进点 |
|---|---|---|
| 冒泡排序 O(n²) | 快速排序(三数取中) O(n log n) | 分治+尾递归 |
| 选择排序 O(n²) | 堆排序 O(n log n) | 二叉堆 |
| 插入排序 O(n²) | 希尔排序 | 步长分组 |
| 归并排序(递归) | 归并排序(迭代) | 栈溢出规避 |
| 计数排序 | 基数排序(LSD) | 多关键字 |
图算法
| 朴素 | 优化 | 改进点 |
|---|---|---|
| Dijkstra 朴素 O(V²) | Dijkstra 优先队列 O(E log V) | 最小堆 |
| Prim 朴素 O(V²) | Prim 堆优化 O(E log V) | 最小堆 |
| QuickFind | 路径压缩+按秩合并 | 摊还α(n) |
字符串
| 朴素 | 优化 |
|---|---|
| 朴素匹配 O(nm) | KMP O(n+m) |
| 最长回文暴力 O(n³) | 中心扩展 O(n²) |
| 异位词排序 | 异位词哈希 |
| 无重复子串暴力 O(n²) | 滑动窗口 O(n) |
还有动态规划(0-1背包、LCS、编辑距离、Coin Change等)、树算法(BFS/DFS、验证BST、LCA、迭代遍历)、搜索算法(二分搜索、Two Sum哈希、旋转数组二分)——总共约40种对比模板。
关键算法代码
贴几个算法实现。
1. KMP字符串匹配
KMP的核心是那张LPS表(最长公共前后缀)。理解了LPS怎么构建,就理解了KMP。
// 朴素 → KMP 的优化版本functionkmpSearch(input){consttext=typeofinput==='object'&&input.text?input.text:input;constpattern=typeofinput==='object'&&input.pattern?input.pattern:'';constn=text.length,m=pattern.length;// 构建 LPS 表constlps=newArray(m).fill(0);letlen=0,i=1;while(i<m){if(pattern[i]===pattern[len]){len++;lps[i]=len;i++;}else{if(len!==0)len=lps[len-1];else{lps[i]=0;i++;}}}trace('lps',{data:{array:lps},description:`LPS表: [${lps}]`});// KMP 搜索letj=0;i=0;while(i<n){if(pattern[j]===text[i]){i++;j++;}if(j===m){trace('found',{data:{text,pattern},highlights:[i-j],description:`在位置${i-j}找到匹配!`});j=lps[j-1];}elseif(i<n&&pattern[j]!==text[i]){if(j!==0){j=lps[j-1];// 利用LPS跳过已匹配部分trace('skip',{data:{text,pattern},highlights:[i],description:`失配, j->${j}`});}else{i++;}}}}LPS表的构建是整个KMP最巧妙的部分——它利用前缀的自相似性,避免了朴素匹配中"匹配到一半发现不对,从头再来"的悲剧。
2. Dijkstra + 优先队列
朴素Dijkstra每次都要线性扫描找最小dist节点,用堆优化后效率翻倍:
// 优先队列优化的 DijkstrafunctiondijkstraPQ(graph,start){const{nodes,edges}=graph;constn=nodes.length;constdist=newArray(n).fill(Infinity);dist[start]=0;constadj=Array.from({length:n},()=>[]);for(consteofedges)adj[e.from].push({to:e.to,w:e.weight});// 用数组模拟最小堆(实际生产环境用二叉堆)constpq=[[0,start]];while(pq.length>0){pq.sort((a,b)=>a[0]-b[0]);// 按距离排序const[d,u]=pq.shift();if(d>dist[u])continue;// 惰性删除trace('visit',{data:{...graph},highlights:[u],description:`弹出${nodes[u].label}, dist=${d}`});for(const{to,w}ofadj[u]){if(dist[u]+w<dist[to]){dist[to]=dist[u]+w;pq.push([dist[to],to]);trace('relax',{data:{...graph},highlights:[u,to],description:`更新${nodes[to].label}->${dist[to]}`});}}}}在3D场景中,Dijkstra算法的执行过程是这样的:一辆货车从起点出发,每到一个路口(节点),所有相邻路口的路标会更新距离。货车总是沿着当前已知最短的路走——这就是"贪心"的具象化。
3. 并查集 路径压缩
从QuickFind到路径压缩,代码量没增加多少,性能却从O(n)降到了摊还α(n):
// 路径压缩 + 按秩合并functionufPC(ops){constn=8;constparent=Array.from({length:n},(_,i)=>i);constrank=newArray(n).fill(0);functionfind(x){if(parent[x]!==x){parent[x]=find(parent[x]);// 路径压缩trace('compress',{data:{array:parent},highlights:[x],description:`路径压缩:${x}->${parent[x]}`});}returnparent[x];}functionunion(x,y){letrx=find(x),ry=find(y);if(rx===ry)return;if(rank[rx]<rank[ry])[rx,ry]=[ry,rx];parent[ry]=rx;if(rank[rx]===rank[ry])rank[rx]++;trace('union',{data:{array:parent},highlights:[x,y],description:`合并${x}和${y}`});}for(const[a,b]ofops)union(a,b);}在城市场景中,并查集的每个集合对应一个社区。两个社区合并时,会有一座桥(或一条路)把两个区域连接起来。路径压缩的过程看起来像是"直接拉了一条捷径直通社区中心"——非常直观。
4. 快速排序 — 三数取中
优化版的快排,用三数取中避免选到最差pivot:
functionquickSort(arr,low,high){if(low>=high)returnarr;// 三数取中:arr[low]、arr[mid]、arr[high] 的中位数做 pivotconstmid=low+((high-low)>>1);if(arr[mid]<arr[low])[arr[low],arr[mid]]=[arr[mid],arr[low]];if(arr[high]<arr[low])[arr[low],arr[high]]=[arr[high],arr[low]];if(arr[high]<arr[mid])[arr[mid],arr[high]]=[arr[high],arr[mid]];constpivot=arr[high];leti=low-1;for(letj=low;j<high;j++){if(arr[j]<pivot){i++;[arr[i],arr[j]]=[arr[j],arr[i]];}}[arr[i+1],arr[high]]=[arr[high],arr[i+1]];// 尾递归优化:优先处理较短的子数组quickSort(arr,low,i);// 左半部分quickSort(arr,i+2,high);// 右半部分}在工厂场景中,快排的pivot就像传送带上的分拣标准——所有小于pivot的往左送,大于pivot的往右送,然后左右两条传送带各自递归。冒泡排序则是两个工人来来回回地对比交换——你一眼就能看出哪个效率高。
3D城市场景
这部分是我花时间最多的。
整个城市由以下几个部分组成:
- 街道网格:构成了图的边
- 建筑物/路口:构成了图的节点
- Dijkstra园区:有专门的公园路径,算法运行时长椅上会出现路径标记
- Prim变电站:电网拓扑结构展示最小生成树的构建
- 并查集社区:一片住宅区,合并时"破墙通路"
- 排序分拣中心:传送带和货架构成数组
- 森林苗圃:树结构可视化,每个节点是一棵树
- 图书馆:字符串匹配的场景,书架上的书就是字符
每个场景都配了完整的相机控制——可以用鼠标旋转、缩放、平移。算法运行时,相机会自动对准当前操作的元素。
底部工具栏
运行算法后,底部的控制面板支持:
- ▶️逐帧播放:一步步看算法的每一次比较和交换
- ⏸️自动播放:连续播放,速度可调(0.5x ~ 4x)
- ⏪回退:回到上一步
- 📊统计面板:显示朴素vs优化的步骤数、执行时间、加速比
- 以及算法改进必备的时间执行耗时对比
开发感受
这个项目从一开始的"我就试试Three.js能不能做算法可视化",一步步变成了一个完整的平台。中间经历了无数次:
- 场景太卡 → 优化Draw Calls
- Python执行器跑不起来 → 换Pyodide
- 城市太空旷 → 加建筑、路灯、河流、桥
- 截图太丑 → 重写材质和光照
我每次启动项目看3D动画时都会感到震惊,并不是我写的算法有多好,而是意识到算法真正的的作用——并不是单纯为了刷题,而是应用于实际。你可以任意修改算法,然后现实就会为你改变。我想写的从来不是单纯的炫酷场景,而是让算法与现实碰撞出火花。
不管是在城市地铁
还是警察抓小偷
亦或者是叉车装货
又或者是电网连接等等等等
我想要构建的是一个思维改变世界的项目。
当然不得不承认我的城市建模还是有点粗糙,因此我开源出来希望大家能共同努力。
项目地址(GitHub):https://github.com/gu860/dsa-improvement-range
最后
算法学习没有捷径,但有更好的方式。光看书上的O(n²)和O(n log n),你可能知道快排比冒泡快,但你不会真正感受到这种差距。当你在3D场景里看到冒泡排序的元素像蜗牛一样一格一格挪动,而快速排序像流水线一样干脆利落地分治时——那种体验是完全不一样的。