数据结构与算法可视化

发布于 | 分类于 数据结构和算法

之前在博客里整理过一些数据结构和算法的笔记,包括二分查找、滑动窗口、动态规划、并查集等。阅读这些内容时,经常需要拿一组输入,在纸上记录变量和中间状态,才能确认每一步发生了什么。

最近做了一个数据结构与算法可视化网站,把原理、JavaScript 代码和执行动画放在同一个页面里,可以修改输入、单步执行,也可以自动播放。

全部的代码都是 codex 生成的,最后部署在 cloudflare 上面,我只是给出了大概的实现需求和技术选型~

网站地址:数据结构与算法可视化。

这篇文章介绍网站目前包含的内容,以及代码与动画同步、执行轨迹和静态预渲染的实现思路。

页面里可以看到什么 ​

每个课程页面都围绕一个算法或数据结构展开,包含原理、执行步骤、关键理解和简短示例。进入交互部分后,左侧显示 JavaScript 代码,右侧显示当前的数据状态,执行到某一步时会高亮对应的代码行。

对于排序,可以观察比较、交换和已经就位的元素;对于二分查找,可以观察左右边界和中点;对于图算法,可以观察节点、边、访问状态和输出结果;对于动态规划,可以观察状态表的更新。

播放部分支持自动播放、暂停、上一步、下一步、速度调整和进度拖动。遇到不容易理解的步骤,可以停下来检查当前状态,再向前或向后观察。

不同课程提供对应的输入配置。例如排序可以修改数组,查找可以设置目标值,图算法可以配置边和起点。链表、栈、队列等数据结构则提供插入、删除、查找等操作,展示一次操作中的状态变化。

页面还列出了对应题目或扩展练习。理解演示过程后,可以继续按照题目约束调整实现。

当前包含的课程 ​

目前项目整理了 44 个课程,分成八类:

分类部分课程
排序算法冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序、希尔排序、计数排序、基数排序
查找算法顺序查找、二分查找
图算法BFS、DFS、Dijkstra、拓扑排序、Kruskal、A*、Floyd
数组算法滑动窗口、前缀和、双指针、单调栈、单调队列
字符串算法KMP 字符串匹配
动态规划零钱兑换、0/1 背包、最长公共子序列、最长递增子序列
贪心与回溯区间调度、子集枚举、N 皇后、哈夫曼编码
数据结构数组、单链表、栈、循环队列、哈希表、二叉搜索树、最小堆、双端队列、并查集、字典树、树状数组、图

课程目录支持按中文名称或英文名称搜索,也可以按分类浏览。首页和分类页提供课程简介,每个课程都有独立的访问路径。

这些演示采用了适合观察的小规模输入,一些课程对节点数量、数值范围和字符串长度有明确限制,页面会给出相应提示。

用二分查找观察区间变化 ​

以二分查找为例,页面展示的是闭区间 [left, right] 的写法:

js
function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) left = mid + 1;
    else right = mid - 1;
  }
  return -1;
}

在数组 [1, 3, 5, 7, 9] 中查找 7,可以按步骤观察:

  1. 初始区间是 [0, 4],中点下标为 2,对应元素为 5。
  2. 5 < 7,左边界移动到 mid + 1,区间缩小为 [3, 4]。
  3. 新的中点下标为 3,对应元素为 7,查找结束。

动画会标记当前中点,并弱化已经排除的元素;步骤说明会列出 left、right 和 mid,代码区同步高亮比较或更新边界的行。

可以把目标改成 6,继续观察区间如何变为空,以及最后为什么返回 -1。

这个课程会在应用输入时先对数组进行升序排列,因此返回的是排序后的下标。重复值命中时,也不保证返回第一次出现的位置。页面给出的 O(log n) 是查找过程的时间复杂度,预排序成本单独考虑。

把执行过程保存成轨迹 ​

项目使用 React、TypeScript 和 Vite,样式由 UnoCSS 和主题变量共同维护,状态图主要使用 SVG 绘制。

交互部分的核心是执行轨迹。应用输入后,算法模型先生成一组状态快照,播放器再根据当前步骤展示其中一帧。

整体过程可以写成:

text
输入数据 → 生成执行轨迹 → 选择当前步骤 → 展示代码与状态

通用的一帧包含代码行、步骤说明、节点、边和输出等信息。下面是对现有帧结构的简化表示:

ts
interface IFrame {
  lines: number[]
  title: string
  description: string
  nodes: { id: string; label: string; x: number; y: number }[]
  edges: { from: string; to: string; directed?: boolean }[]
  badges: string[]
  output: string[]
}

其中,lines 对应需要高亮的代码行,title 和 description 解释当前步骤,nodes、edges 描述可视化状态,badges 和 output 展示辅助信息与结果。

排序使用单独的帧结构,还记录了比较次数、交换次数、写入次数和辅助缓冲区。

在算法中记录快照 ​

以冒泡排序为例,可以在比较和交换的位置插入 record,把当时的数组、正在处理的下标和对应代码行保存下来。下面是一个简化示例,只记录比较、交换和结束三个阶段。

先准备页面要展示的代码,后面的行号都以这段文本为准:

ts
const bubbleCode = `function bubbleSort(arr) {
  for (let end = arr.length - 1; end > 0; end--) {
    for (let j = 0; j < end; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
}`

再编写轨迹生成函数。这个示例与项目中的完整排序轨迹相比,省略了比较次数、已排序范围和辅助缓冲区等字段:

ts
interface ISortFrame {
  values: number[]
  active: number[]
  lines: number[]
  title: string
}

function buildBubbleTrace(input: readonly number[]): ISortFrame[] {
  const values = [...input]
  const frames: ISortFrame[] = []

  function record(title: string, active: number[], lines: number[]) {
    frames.push({ values: [...values], active, lines, title })
  }

  record('准备排序', [], [])
  for (let end = values.length - 1; end > 0; end--) {
    for (let j = 0; j < end; j++) {
      record(`比较 ${values[j]} 和 ${values[j + 1]}`, [j, j + 1], [3])
      if (values[j] > values[j + 1]) {
        [values[j], values[j + 1]] = [values[j + 1], values[j]]
        record('交换相邻元素', [j, j + 1], [4])
      }
    }
  }
  record('排序完成', [], [8])
  return frames
}

const frames = buildBubbleTrace([3, 1, 2])
console.log(frames[1].values) // [3, 1, 2],第一次比较
console.log(frames[2].values) // [1, 3, 2],第一次交换后

这里有两个细节:

  • values: [...values] 每次复制当前数组,保证后面的交换不会修改已经保存的帧。如果多帧共用同一个可变数组,最终看到的都会是修改后的结果。保存嵌套节点时,也需要复制会继续变化的对象。
  • lines 使用从 0 开始的下标。上面的 [3] 对应页面显示的第 4 行比较语句,[4] 对应第 5 行交换语句。

播放器选择当前帧 ​

生成轨迹后,播放器只需要保存当前步骤。下面接着使用同一组 ISortFrame,展示最基本的单步播放:

tsx
import { useState } from 'react'
import { CodePreview } from './CodePreview'

interface ITracePlayerProps {
  frames: ISortFrame[]
  code: string
}

function TracePlayer({ frames, code }: ITracePlayerProps) {
  const [step, setStep] = useState(0)
  const frame = frames[step]
  if (!frame) return <p>暂无执行轨迹</p>

  return <section>
    <CodePreview code={code} activeLines={frame.lines} />
    <div>
      {frame.values.map((value, index) => <span key={index}
        style={{ display: 'inline-block', padding: 12,
          background: frame.active.includes(index) ? '#ffd84d' : '#fff4dd' }}>
        {value}
      </span>)}
    </div>
    <p>{frame.title}</p>
    <button disabled={step === 0} onClick={() => setStep(step - 1)}>上一步</button>
    <button disabled={step === frames.length - 1}
      onClick={() => setStep(step + 1)}>下一步</button>
    <input aria-label="执行进度" type="range" min={0} max={frames.length - 1}
      value={step} onChange={(event) => setStep(Number(event.target.value))} />
  </section>
}

frame.values 决定画面中的数据,frame.active 决定当前强调的元素,frame.lines 决定代码区高亮的行。三个部分读取同一帧,切换步骤时一起更新。项目中的自动播放还会使用定时器递增 step。

这个示例假设一轮播放期间 frames 保持不变。应用新输入后,项目会重新生成轨迹并重置播放器,避免沿用旧的步骤下标。

有了完整轨迹,上一步、下一步和进度拖动都可以通过切换帧实现。播放速度只影响切换帧的间隔,修改输入后则重新生成轨迹。

这种方式适合当前的小规模教学演示。轨迹生成和保存会产生额外的时间、空间开销,页面展示的算法复杂度对应教学代码,不包含这些可视化开销。

页面中的代码文本和轨迹生成逻辑分别维护。增加或修改课程时,需要同时检查代码、轨迹和高亮行的对应关系,保证步骤说明与展示的实现一致。

代码高亮与步骤同步 ​

代码预览组件基于 Shiki,支持语法着色、行号、执行行标记、代码复制和当前执行行的滚动定位。

课程主体使用 JavaScript。项目还保留了一个组件示例页,用于查看不同语言和主题下的展示效果,包括 TypeScript、TSX、Python、HTML、CSS 和 JSON。

语言定义和高亮逻辑按需加载。预渲染时,代码以可阅读的文本写入 HTML;浏览器接管页面后,再进行语法着色。这样正文与代码的展示不需要等待高亮完成。

播放器更新当前步骤时,会把对应的行号传给代码组件。语法分词主要随代码、语言和主题变化执行,执行行变化只更新标记。

先把代码转换成 token ​

Shiki 可以把每行代码拆成带颜色信息的 token。下面的 highlighter.ts 只保留 JavaScript 和一个浅色主题,便于说明基本流程;项目中的组件还支持多种语言与主题。

ts
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'

let highlighterPromise: ReturnType<typeof createHighlighterCore> | undefined

export async function highlightJavaScript(code: string) {
  highlighterPromise ??= createHighlighterCore({
    langs: [import('@shikijs/langs/javascript')],
    themes: [import('@shikijs/themes/github-light')],
    engine: createJavaScriptRegexEngine(),
  }).catch((error) => {
    highlighterPromise = undefined
    throw error
  })
  const highlighter = await highlighterPromise
  return highlighter.codeToTokens(code, {
    lang: 'javascript',
    theme: 'github-light',
  }).tokens
}

这里复用初始化得到的 highlighter,每次传入代码后获取二维 token 数组:第一层对应代码行,第二层对应这一行的文本片段。组件可以把每个 token 的 content 和 color 转成 <span>。

再用当前帧标记执行行 ​

下面的 CodePreview.tsx 可以与前面的播放器配合使用。它只保留语法颜色和执行行背景,省略了工具栏、复制、片段标记及字体样式等功能:

tsx
import { useEffect, useState } from 'react'
import { highlightJavaScript } from './highlighter'

interface ICodePreviewProps {
  code: string
  activeLines: readonly number[]
}
interface IHighlightResult {
  code: string
  tokens: Awaited<ReturnType<typeof highlightJavaScript>>
}

export function CodePreview({ code, activeLines }: ICodePreviewProps) {
  const [result, setResult] = useState<IHighlightResult | null>(null)

  useEffect(() => {
    let stale = false
    highlightJavaScript(code).then((tokens) => {
      if (!stale) setResult({ code, tokens })
    }).catch(() => {
      if (!stale) setResult(null)
    })
    return () => { stale = true }
  }, [code])

  const tokens = result?.code === code ? result.tokens : undefined
  return <pre style={{ background: '#fff', color: '#24292e' }}><code>
    {code.split('\n').map((line, index) => <span key={index}
      aria-current={activeLines.includes(index) ? 'step' : undefined}
      style={{ display: 'block',
        background: activeLines.includes(index) ? '#fff0bd' : undefined }}>
      <span aria-hidden="true">{index + 1} </span>
      {(tokens?.[index] ?? [{ content: line, color: undefined }]).map((token, tokenIndex) =>
        <span key={tokenIndex} style={{ color: token.color }}>{token.content}</span>)}
    </span>)}
  </code></pre>
}

这个组件的 useEffect 只依赖 code。播放器切换步骤时,改变的是 activeLines,因此只更新行背景,不重复分词。异步高亮还未完成或加载失败时,组件使用原始文本作为回退内容;stale 用来忽略代码变化后返回的旧结果。

把前面的示例连起来,调用方式就是:

tsx
<TracePlayer frames={frames} code={bubbleCode} />

当播放器切换到第一次交换后的帧时,数组显示为 [1, 3, 2],前两个元素被强调,代码区同步标记第 5 行交换语句。

为课程生成独立 HTML ​

项目最初通过 React 状态切换课程,多个算法共用一个地址。后来把课程映射到独立路径,并在构建阶段生成对应的 HTML,例如:

text
/
/topics/sorting/
/algorithms/bubble-sort/
/algorithms/binary-search/
/data-structures/linked-list/

构建流程先由 Vite 生成客户端资源,再构建用于预渲染的入口,通过 React 的 renderToString 输出每个页面的内容,并写入对应目录的 index.html。

生成的 HTML 包含课程标题、原理、步骤、代码和初始画面。浏览器通过 hydrateRoot 接管这些内容,继续提供播放和输入操作。课程切换使用普通链接,加载对应的静态页面。

首页、分类页和课程页分别生成标题、描述与关键词,同时补充 canonical、分享元信息、站点地图和面包屑结构化数据。组件示例页标记为不索引,构建产物中也包含独立的 404 页面。

网站使用 Cloudflare Pages 托管静态文件,课程内容在构建时生成,用户操作由浏览器处理。

怎么使用这个网站 ​

学习一个课程时,可以先阅读原理和示例,再用默认输入逐步执行。观察过程中重点记录:当前处理的元素是什么、哪些状态发生了变化、下一步为什么这样执行。

理解默认示例后,再修改输入,观察不同数据下的执行过程。例如给排序加入重复元素,为图配置环或不可达节点,为查找设置不存在的目标。

最后可以自己写一遍实现,再对照页面里的代码和相关练习检查边界条件。演示中的输入约束和实现说明也需要一起阅读,例如二分查找的有序前提、Dijkstra 的非负权限制,以及不同数据结构的容量限制。

网站地址:数据结构与算法可视化。可以从目录选择一个课程,结合代码逐步观察它的执行过程。

如果在使用过程中遇到问题,或者有功能、交互和课程内容方面的建议,都可以在本文下方留言。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。