我写了个能一步步点着看的 Dijkstra 可视化项目(Vue3 + Leaflet + Generator)

算法教程里的 Dijkstra,是一张画满箭头的静态图 + 一段伪代码。看完当时懂了,第二天忘光。

于是我用 Vue3 + Leaflet 写了一个可以一步一步点着看的 Dijkstra 可视化项目:每点一次「下一步」,页面只往前走一小步,左侧同步告诉你「这一步在干什么、为什么」。

如果它帮你把这个算法彻底搞明白了,欢迎顺手点个 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 只有四步:

  1. 给每个节点一个「从起点到它的累计距离」,起点为 0,其余为「未知」(代码里是 null)。
  2. 所有未关闭的节点里,挑出累计距离最小的那个,作为当前节点。
  3. 遍历当前节点的所有邻居,尝试「松弛」:如果当前节点的累计距离 + 边长 比邻居已知的累计距离更小,就更新它,并把邻居的「来源」记为当前节点。
  4. 当前节点标记为已关闭,回到第 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* 对照模式,直观展示启发式函数是怎么减少搜索范围的
  • 增加「自动播放 / 调速」,以及单步回退

最后

比起看十遍伪代码,不如亲手点十次「下一步」。

觉得有用的话,点个 Star 支持一下吧,你的 Star 是我继续更新的最大动力 ⭐

相关推荐
爱勇宝1 小时前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
观远数据1 小时前
Excel到数据资产池:文件数据入湖的治理规范怎么建
前端·javascript·excel
两点王爷1 小时前
一个快速加载面和多面数据的html测试页面(可以导出geojson数据文件)
前端·html·gis
热爱前端的小张1 小时前
第四章 接口和类型兼容性
前端
hunterandroid1 小时前
[鸿蒙从零到一] HarmonyOS 资源管理与多语言适配实战
前端
用户69371750013842 小时前
Claude Code终端日志Token占用实测:一个过滤器砍掉60%-90%
android·前端·后端
无人生还2 小时前
第 1 篇:从 Vue3 到 React 的思维转变
前端·vue.js·react.js
就业发动机2 小时前
我用 Tauri 做了一个约 1.5 MB 的开源屏幕标注工具:MarkerOn
vue.js·rust
爱分享的程序猿-Clark2 小时前
【前端分享】vue开发项目,如何实现 从A页面跳转到B页面,并传值!
前端·javascript·vue.js