算法教程里的 Dijkstra,是一张画满箭头的静态图 + 一段伪代码。看完当时懂了,第二天忘光。
于是我用 Vue3 + Leaflet 写了一个可以一步一步点着看的 Dijkstra 可视化项目:每点一次「下一步」,页面只往前走一小步,左侧同步告诉你「这一步在干什么、为什么」。
- 🔗 在线体验(免安装,点开就能玩):fate-ui.github.io/learn-dijks...
- ⭐ 源码仓库:github.com/Fate-ui/lea...
如果它帮你把这个算法彻底搞明白了,欢迎顺手点个 Star,这对我真的很有帮助 🙏
一、先认识一下 Dijkstra
在动手玩之前,先花两分钟把这个算法的来历和定位说清楚。
它解决什么问题? 一句话:在带权图中,求某一个起点到其它所有点的最短路径(单源最短路径问题)。
它由荷兰计算机科学家 Edsger W. Dijkstra 在 1956 年提出,据他本人回忆,这个算法是他在咖啡馆里花了大约二十分钟想出来的,当时甚至没有纸笔。而它至今仍然活跃在你每天都在用的地方:
- 🗺️ 地图导航:高德、百度规划路线的底层基础(实际会叠加 A*、双向搜索等优化)
- 🌐 网络路由:OSPF 协议就是用 Dijkstra 计算最优转发路径
- 🎮 游戏寻路:NPC 从 A 点走到 B 点
- 📦 物流调度、社交网络的最短关系链......
它的核心思想是什么? 一句话:贪心 + 松弛。
想象往起点滴一滴水,水波以「距离」为半径向外均匀扩散。水波先碰到哪个节点,哪个节点的最短距离就确定了 。算法要做的,就是不断从「还没确定的节点」里挑出当前距离最小的那个,宣布它「已确定」,然后用它去更新邻居的距离(这一步叫松弛 / relax),如此循环。
它有什么限制? 要求边权非负。因为算法一旦宣布某个节点「已确定」就不再回头,如果存在负权边,后来的路径可能反而更短,贪心的前提就被打破了。有负权边时要改用 Bellman-Ford 或 SPFA。
复杂度? 朴素实现(每轮线性扫描找最小值)是 O(V²);用优先队列(最小堆)维护待选节点可优化到 O(E log V)。本项目为了让每一步都看得清楚,用的是最朴素的那版。
道理听起来都不难,难的是让「水波扩散」这个过程在脑子里真的跑起来。所以,直接看。
二、先看效果
打开页面是一张带权无向图:圆圈是节点(编号 0 ~ 17),线上的数字是两点之间的距离,默认从「起点」找到「终点」。

点击「下一步」,算法开始逐步推进:
- 🟢 绿色:当前正在处理的节点
- 🟡 黄色:正在被遍历的邻边
- 🩷 粉色:距离被更新过的节点,气泡上直接写着「累计距离 + 来源节点」
- ⚪ 灰色:已关闭(确定最短距离)的节点
- 红色涟漪:下一轮将被选中的最小代价节点

左侧时间轴是重点,它不是干巴巴的日志,而是人话:
选择节点 1 为当前节点,当前累计距离:259.03 遍历节点 1 的相邻节点 再次计算节点 2 的累计距离为:520.91(大于原来的累计距离:371.32),不更新距离 已遍历完节点 1 的所有相邻节点,将节点 1 标记为已关闭,不再重复访问
算法结束时,沿着「来源」关系回溯,绿色高亮的就是最短路径:

右上角还能自定义起点和终点序号,同一张图可以反复换着玩,观察不同起终点下算法的行为差异。
三、对照界面,把算法拆成四句话
有了上面的画面感,再回头看流程就清楚多了。对照界面上的颜色,Dijkstra 只有四步:
- 给每个节点一个「从起点到它的累计距离」,起点为
0,其余为「未知」(代码里是null)。 - 从所有未关闭的节点里,挑出累计距离最小的那个,作为当前节点。
- 遍历当前节点的所有邻居,尝试「松弛」:如果
当前节点的累计距离 + 边长比邻居已知的累计距离更小,就更新它,并把邻居的「来源」记为当前节点。 - 当前节点标记为已关闭,回到第 2 步。直到取出的最小节点就是终点 ------ 此时沿着「来源」一路回溯,就是最短路径。
回到开头那个「水波扩散」的比喻:第 2 步取出的最小值,就是水波下一个碰到的节点。为什么它一定是对的? 因为边权非负 ------ 当一个节点是所有未关闭节点里累计距离最小的,任何绕道其它未关闭节点再走过来的路径都只会更长,所以它当前的距离已经不可能再被改进了。这就是 Dijkstra 的贪心内核。
四、代码实现
1. 把线段数据变成图结构
项目里的原始数据是一堆线段 { start, end },需要先转成邻接表。这里用 x-y 坐标拼出的字符串当节点 id,天然完成去重;因为是无向图,所以正反两个方向都要建边:
ts
export function toGraphStructure(list: ISegment[]) {
for (const item of list) {
item.start.id = `${item.start.x}-${item.start.y}`
item.end.id = `${item.end.x}-${item.end.y}`
}
const graphList: IGraph[] = []
for (const item of list) {
const { start, end } = item
const distance = getDistance(start, end)
// 正向建边
const startGraph = graphList.find((el) => el.id === start.id)
if (startGraph) {
startGraph.children.push({ id: end.id, distance })
} else {
graphList.push({ point: { x: start.x, y: start.y }, children: [{ id: end.id, distance }], id: start.id })
}
// 反向建边(无向图)
// ...同理
}
return graphList
}
2. 算法主体
去掉可视化之后,核心逻辑非常短:
ts
foundRoute() {
const current = this.currentNode
// 取出当前节点所有「未关闭」的邻居
const nodes = this.nodes.filter(
(item) => !this.closeList.includes(item) && current.children.some((el) => el.id === item.id)
)
// 松弛
for (const node of nodes) {
const index = current.children.findIndex((item) => item.id === node.id)
const cost = current.cost + current.children[index].distance
if (node.cost === null || cost < node.cost) {
node.cost = cost
node.parent = current // 记录来源,最后靠它回溯出路径
}
}
this.closeList.push(current)
this.#foundSmallestCostRoute()
}
回溯路径就是顺着 parent 往上爬再反转:
ts
#generatePath(node: Node) {
const path = [node.point]
let current = node
while (current.parent) {
path.push(current.parent.point)
current = current.parent
}
return path.reverse()
}
3. 本项目最有意思的一点:用 Generator 实现「单步执行」
做可视化最麻烦的从来不是算法本身,而是怎么把一段一口气跑到底的循环,拆成可以被外部一步一步驱动的过程。
注意这里要控制的是执行的步骤,而不是执行的快慢 ------ 我们要的不是「放慢播放」,而是「停在第 N 步,等一个信号再走第 N+1 步」。
常见做法是把算法状态拆成一个数组、每步存一份快照再回放,或者塞满 setTimeout 拿延时冒充步进,代码会被切得面目全非。
而 JS 的Generator函数 天生就是干这个的 ------ yield 会把函数「冻结」在原地,保留所有局部变量和循环进度,直到外部再调用一次 next()。于是算法可以按最自然的方式写,只在想暂停的位置插一行 yield:
ts
foundRoute = function* (this: StepNavigateRoute) {
yield
if (this.currentNode) {
const current = this.currentNode
mapStore.timelineNodes.push({ content: `选择节点${index}为当前节点,当前累计距离:${current.cost}` })
iconElement.style.backgroundColor = '#53e553' // 点亮当前节点
yield // ← 停在这里,等用户点「下一步」
mapStore.timelineNodes.push({ content: `遍历节点${index}的相邻节点` })
yield
for (const node of nodes) {
const cost = current.cost + current.children[nodeIndex].distance
this.lines.push(L.polyline([currentPoint, nodePoint], { color: '#FFFF00', weight: 5 }).addTo(map))
if (node.cost === null || cost < node.cost) {
mapStore.timelineNodes.push({ content: `计算从起点到节点${tempIndex}的累计距离为:${cost}...` })
node.cost = cost
node.parent = current
marker.bindPopup(`累计距离:${node.cost} 来源:节点${index}`).openPopup()
} else {
mapStore.timelineNodes.push({ content: `再次计算节点${tempIndex}的累计距离为:${cost}(大于原来的...),不更新距离` })
}
yield // ← 每检查一条邻边,暂停一次
}
this.closeList.push(current)
yield
this.#foundSmallestCostRoute()
}
}
注意 for 循环里那个 yield:循环内部暂停 ,如果不用 Generator,这里就得手动把循环变量提升成实例属性、再拆出一个 stepNext() 方法,可读性直接崩掉。
而视图层要做的事,简单到只有一行:
ts
function next() {
stepNavigateRoute.generator.next()
}
算法逻辑与「暂停/继续」的控制流被彻底解耦 ------ 这也是我个人最推荐的可视化写法:算法怎么写就怎么写,只是多了几个 yield。
4. 用 Leaflet 渲染一张「不是地图」的图
节点坐标是自定义的平面坐标(不是经纬度),如果直接用地图库,会因为墨卡托投影导致图形变形。
Leaflet 提供了 L.CRS.Simple:直接把 y/x 当作平面坐标使用,一行配置就把地图库变成了一个自带缩放、拖拽、图层管理的 2D 画布,比手写 Canvas 交互省事太多:
ts
const map = L.map(id, {
center: this.center,
zoom: 0,
crs: L.CRS.Simple, // ← 关键:平面坐标系
minZoom: -1,
maxZoom: 6,
zoomControl: false,
attributionControl: false
})
节点用 L.divIcon 渲染,好处是它就是普通 DOM,可以直接用 CSS 控制状态色和涟漪动画:
scss
.ripple {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
animation: Ripple 1.5s ease-out 0s infinite;
background-color: rgba(238, 86, 86, 0.3);
}
}
首屏则用 fitBounds 把所有线段的包围盒算出来,保证任何图形数据都能自适应铺满视口:
ts
const bounds = new L.LatLngBounds([])
for (const line of lineList) {
bounds.extend([line.start.y, line.start.x])
bounds.extend([line.end.y, line.end.x])
}
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 })
五、技术栈
| 类型 | 选型 |
|---|---|
| 框架 | Vue 3(组合式 API)+ TypeScript |
| 构建 | Vite 5 |
| 状态管理 | Pinia |
| UI | Element Plus |
| 样式 | UnoCSS + SCSS |
| 图形渲染 | Leaflet(CRS.Simple) |
| 工具库 | VueUse |
六、本地跑起来
bash
git clone https://github.com/Fate-ui/learn-dijkstra-algorithm-online.git
cd learn-dijkstra-algorithm-online
pnpm install
pnpm run dev
算法源码集中在两个文件,非常好读:
src/views/NavigateRoute.ts------ 纯算法实现(不含可视化,想抄走直接用)src/views/StepNavigateRoute.ts------ Generator 版单步执行 + 地图渲染
七、还能怎么玩
目前项目还有不少可以继续折腾的方向,欢迎提 Issue / PR 一起来玩:
- 把现在「线性扫描找最小值」换成优先队列(最小堆),复杂度从 O(V²) 降到 O(E log V)
- 支持在页面上手动画图:拖拽增删节点和边,自定义权重
- 加上 A* 对照模式,直观展示启发式函数是怎么减少搜索范围的
- 增加「自动播放 / 调速」,以及单步回退
最后
比起看十遍伪代码,不如亲手点十次「下一步」。
- 🎮 在线体验:fate-ui.github.io/learn-dijks...
- ⭐ GitHub:github.com/Fate-ui/lea...
觉得有用的话,点个 Star 支持一下吧,你的 Star 是我继续更新的最大动力 ⭐