
很多人在初学排序算法时都会遇到同一个问题代码能看懂变量也能跟踪但一闭眼想象整个排序过程脑子里只有一团乱麻。尤其对于冒泡排序这种“相邻交换”的算法光是理解“谁和谁比较、什么时候交换、哪一轮把哪个数字送到最终位置”就可能绕晕半天。可视化正是把这种抽象过程变成“看得见的操作序列”的最佳方式这也是本文想把 24 个数字的冒泡排序过程逐帧展示出来的原因。我的判断很明确冒泡排序可视化的核心价值不在于让动画“好看”而在于把算法执行路径拆成一帧一帧可观察的状态变化。对于初学者它解决的是“背完代码却讲不清过程”的认知断层对于教学或演示场景它解决的是“光靠语言描述不够直观”的表达瓶颈对于工程人员它还能帮助快速定位边界条件写错、比较次数异常等隐蔽问题。因此本文会先用最朴素的方式讲清冒泡排序原理再分别用 Python Matplotlib 和 HTML JavaScript 两种技术方案实现 24 个数字的可视化排序。两种方案都提供完整代码、运行方式和预期效果最后再补充真实的踩坑记录与工程建议。不想只用一种语言实现是因为可视化本身不该被某一种技术栈绑死你可以选择在本地脚本里生成动画也可以选择做一个能放进网页的交互式可视化。1. 这篇文章真正要解决的问题排序算法教学里长期存在一个反直觉的现象学生能在纸上写出冒泡排序也能说出“每一轮把最大值冒到末尾”但一旦追问“第 3 轮结束后数组变成什么样”“最后一次交换发生在第几轮”很多人立刻卡壳。这说明多数人对算法的理解停留在“语法正确”并没有真正建立“过程模型”。传统调试方式在这里也帮不上大忙。打印出每轮数组状态虽然信息完整但人脑并不擅长从一长串数字里快速捕捉交换规律。尤其当数据从 8 个变成 24 个时打印结果已经是几十行肉眼几乎无法定位问题。可视化的价值就是把“比较、交换、移动”这些高频操作变成明显的视觉信号柱子上升下降、颜色变化、位置交换一眼就能看出来。这篇文章要解决的问题很具体怎么把“24 个数字排好”的过程转换成一组连续的动画帧具体来说有三层问题。第一层是算法层冒泡排序的每一轮比较范围是什么、交换条件是什么、循环变量怎么变化。第二层是数据层如何把排序中间状态记录下来而不是只记录最终结果。第三层是渲染层如何用 Matplotlib 或浏览器把状态数组渲染成柱状图并按正确节奏播放。如果只写一个排好序的静态柱状图那没有任何学习价值如果只贴一段冒泡排序代码也没有解决“看不清过程”的痛点。所以本文会把重心放在“排序状态如何产生、如何保存、如何播放”这条完整链路上。需要说明的是本文不会停留在“跑一个动画就行”的层面。一个合格的可视化项目还应该考虑速度控制、比较和交换次数统计、已排序区域标记、随机数据生成等细节。如果打算把可视化放到大屏或网页里展示这些点就更加重要。2. 冒泡排序的核心原理与“24个数字”的选取逻辑2.1 冒泡排序的基本过程冒泡排序每次从数组起点开始依次比较相邻的两个元素。如果前一个比后一个大就交换它们。这样一趟结束后当前未排序范围内的最大值会被“冒泡”到最右边也就是它的最终位置。下一轮只需要对前面还没排序的部分重复这个过程。以数组[5, 3, 8, 1]为例第一轮比较过程是比较 5 和 3交换得到[3, 5, 8, 1]。比较 5 和 8不交换。比较 8 和 1交换得到[3, 5, 1, 8]。第一轮结束后8 已经到达最终位置。第二轮处理[3, 5, 1]比较 3 和 5 不交换比较 5 和 1 交换得到[3, 1, 5, 8]。最后一轮处理[3, 1]交换得到[1, 3, 5, 8]。整个过程很像气泡从水底逐步上浮大数不断向右移动直到浮出水面到达数组末尾所以叫冒泡排序。最坏情况下需要执行约n*(n-1)/2次比较时间复杂度为O(n^2)是一种典型的原地稳定排序算法。2.2 基础代码实现先用最基础的方式实现一次冒泡排序后面所有可视化版本都会基于这个逻辑。这里用 Python 写一个完整函数# 文件路径bubble_sort_base.py def bubble_sort(arr): n len(arr) # 每轮把一个最大值放到末尾 for i in range(n - 1): # 已经排好的末尾元素不需要再参与比较 for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] # 每轮结束可以打印当前状态方便观察 print(f第 {i 1} 轮: {arr}) return arr if __name__ __main__: data [5, 3, 8, 1, 9, 2] print(排序前:, data) bubble_sort(data) print(排序后:, data)运行这段代码会输出每一轮的数组状态。但你应该能感觉到当n24时打印 23 行数组其实已经不太直观了。这正是要做可视化的原因。同样的逻辑用 C 写也差不多很多算法竞赛选手和 C 学习者会关心// 文件路径bubble_sort.cpp #include iostream #include vector using namespace std; void bubbleSort(vectorint arr) { int n arr.size(); for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { swap(arr[j], arr[j 1]); } } } } int main() { vectorint arr {5, 3, 8, 1, 9, 2}; bubbleSort(arr); for (int x : arr) cout x ; return 0; }这里不展开 C 的可视化因为终端环境很难直接输出动画但如果你更熟悉 C可以先跑通这段基础算法再去看后面的 Python 或 JavaScript 可视化版本理解逻辑本身是一致的。2.3 为什么选 24 个数字选 24 个数字不是因为这是标准答案而是因为它处在一个比较舒服的区间。如果只有 8 个或 10 个数字排序很快结束观众还没来得及看清“每一轮发生了什么”就已经排完视觉效果不够充分。如果直接上 100 个数字柱子会非常密视觉上每条柱子的高度变化难以被准确感知尤其在小尺寸屏幕上会显得拥挤。24 个数字的柱状图无论放在 800px 宽度的网页里还是放在常见的演示窗口里每根柱子都能有足够的宽度和间隔。与此同时24 个数字需要 23 轮冒泡整个过程足够展示“多轮推进、范围收缩、最大值逐步右移”的规律。相比 5 个数字的“一下就结束”24 个数字的节奏更适合教学演示。我建议你后续可以自由调整数字个数但第一次跑可视化时先把n24作为默认值。这样既能观察足够多的轮次又不会因为柱子太密而看不清交换。3. 环境准备与前置条件在开始写代码之前需要准备两套环境分别对应两种可视化方案。如果你本机已经有 Python 环境方案一很快就能跑通如果只想做网页里的交互可视化方案二只需要一个现代浏览器基本零安装成本。3.1 Python 环境建议使用 Python 3.8 或更高版本因为高版本对 type hint 和 f-string 的支持更完善。核心依赖是matplotlib它能绘制柱状图并生成动画。安装依赖的命令pip install matplotlib如果网络环境较慢可以换成国内镜像源pip install matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple如果你的 Python 环境使用虚拟环境建议先创建并激活虚拟环境python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install matplotlib这里不指定具体 matplotlib 版本因为不同项目对版本要求不同。你只需要确保matplotlib.animation.FuncAnimation可用这个接口在常见的 3.x 版本中都存在。3.2 浏览器环境第二种方案是纯前端实现使用 HTML、CSS、JavaScript。它不需要安装 Node.js不需要打包工具只要把代码保存为.html文件用 Chrome、Edge 或 Firefox 直接打开即可。如果你需要使用本地静态服务器可以在项目目录下执行python -m http.server 8080然后访问http://localhost:8080。这不是必须步骤但如果你想把页面交接到其他前端工程中养成“静态服务器”习惯会更好。3.3 可选工具如果你想把 Python 生成的动画保存为 GIF 或 MP4需要额外安装ffmpeg或imagemagick。保存 GIF 时Matplotlib 默认可能使用 Pillow但保存视频格式通常需要 ffmpeg。简单起见本文默认只在窗口中展示动画不强制保存文件。4. 方案一Python Matplotlib 实现冒泡排序动画4.1 核心设计思路很多初学者写可视化时容易把排序和动画搅在一起一边排序一边调用plt.pause()更新画面。这种写法短小但问题很多比如帧率不稳定、排序中途不能暂停、动画结束后无法统一保存。更合适的做法是把“排序执行”和“动画渲染”解耦。具体来说我们可以先在被排序数组上模拟一遍完整排序把每一步结束后的数组状态保存到一个列表里。然后由FuncAnimation逐帧读取这个列表更新柱状图。这样排序过程只依赖纯 Python 逻辑动画只负责渲染出错时更容易定位。注意在保存中间状态时必须对数组做深拷贝否则保存的是同一个对象的引用等排序全部结束后再回放所有帧看到的都是最终有序状态。这是最容易踩的坑之一。4.2 完整代码实现下面是一个可以直接运行的 Python 脚本。它按以下流程工作生成 24 个 0 到 100 之间不重复的随机整数。执行冒泡排序每次比较或交换后都把数组的副本保存到frames中。用bar()函数绘制柱状图。用FuncAnimation逐帧更新柱状图高度和颜色。图中用蓝色表示正在比较或交换的柱子用灰色表示已经到达最终位置的柱子用绿色表示本轮正在处理的柱子。# 文件路径bubble_sort_visual.py import random import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation def generate_data(size24): 生成 size 个 0~100 之间不重复的随机整数。 return random.sample(range(1, 101), size) def bubble_sort_frames(data): 执行冒泡排序并记录每一步之后的数组状态。 返回值是一个列表列表中的每个元素是 (数组副本, 当前比较索引, 当前交换索引, 已排序前缀长度)。 这样动画渲染时才知道高亮哪些柱子以及哪些柱子已经就位。 arr data[:] n len(arr) frames [] # 记录初始状态让动画从排序前开始 frames.append((arr[:], -1, -1, 0)) for i in range(n - 1): swapped False for j in range(n - 1 - i): # 记录比较位置 j不交换 frames.append((arr[:], j, -1, i)) if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True # 记录交换位置 j 和 j1 frames.append((arr[:], j, j 1, i)) else: # 即使不交换也记录当前比较对 frames.append((arr[:], j, j 1, i)) # 每轮结束记录已排序前缀长度增加 1 frames.append((arr[:], -1, -1, i 1)) if not swapped: break # 最后一轮结束后整个数组有序再记录一帧 frames.append((arr[:], -1, -1, n)) return frames def update(frame_data, bars, text_comp, text_swap, text_round): 动画更新函数frame_data 是由 bubble_sort_frames 生成的一帧。 arr, compare_idx, swap_idx, sorted_len frame_data n len(arr) heights arr colors [#1f77b4] * n # 已排序区域标灰 for idx in range(n - sorted_len, n): colors[idx] #9e9e9e # 当前比较或交换的柱子高亮 if compare_idx 0: colors[compare_idx] #ff7f0e if swap_idx 0: colors[swap_idx] #d62728 for bar, h, c in zip(bars, heights, colors): bar.set_height(h) bar.set_color(c) text_comp.set_text(f比较次数: {compare_count(frame_data)}) text_swap.set_text(交换状态: 交换中 if compare_idx 0 and swap_idx 0 else 比较中) text_round.set_text(f数组长度: {n}) return bars, text_comp, text_swap, text_round def compare_count(frame_data): 从帧数据里其实无法直接算出累计次数这里为了简化返回当前比较对信息。 arr, compare_idx, swap_idx, sorted_len frame_data return compare_idx 1 if compare_idx 0 else 0 def animate_bubble_sort(): data generate_data(24) frames bubble_sort_frames(data) fig, ax plt.subplots(figsize(10, 6)) plt.subplots_adjust(left0.05, right0.98, top0.92, bottom0.10) bars ax.bar(range(len(data)), data, color#1f77b4, width0.65) ax.set_ylim(0, 110) ax.set_xticks(range(len(data))) ax.set_xticklabels([str(x) for x in data], fontsize8) ax.set_title(冒泡排序可视化 - 24 个数字) text_comp ax.text(0.02, 1.05, , transformax.transAxes) text_swap ax.text(0.40, 1.05, , transformax.transAxes) text_round ax.text(0.75, 1.05, , transformax.transAxes) anim FuncAnimation( fig, update, framesframes, fargs(bars, text_comp, text_swap, text_round), interval120, repeatFalse, cache_frame_dataFalse, ) plt.show() return anim if __name__ __main__: animate_bubble_sort()这段代码里bubble_sort_frames是核心。它记录了每一步数组变化且不依赖 Matplotlib因此可以直接用单元测试验证排序功能不需要打开图形界面。4.3 代码关键逻辑解释frames列表中的每一个元素都是一个四元组分别是数组副本、当前比较索引、当前交换索引、已排序长度。这样动画渲染时可以直接根据这些信息决定柱子颜色。在update函数中我们先把所有柱子设为蓝色再把已排序长度对应的右侧柱子设为灰色。接着高亮当前比较位置如果是交换则用红色区分两个柱子。通过set_height更新柱高。这里有一个值得注意的简化compare_count函数只是把当前索引做成了数字并没有累计比较次数。这是因为我刻意想让动画渲染与排序逻辑解耦。如果想统计完整次数建议在bubble_sort_frames里同步维护一个计数器把它放进帧数据里而不是在渲染层临时计算。4.4 如何运行与保存运行脚本python bubble_sort_visual.py如果一切正常会弹出一个 Matplotlib 窗口柱状图开始动态变化。按CtrlC或关闭窗口可退出。如果你希望保存为 GIF可以修改FuncAnimation参数并在plt.show()之前调用anim.save()。示例anim.save(bubble_sort.gif, writerpillow, fps10)如果你没有安装 Pillow也可以尝试anim.save(bubble_sort.mp4, writerffmpeg)这一步不是必需的保存时遇到MovieWriter相关报错通常就是缺少 ffmpeg 或 Pillow。5. 方案二HTML JavaScript CSS 实现可交互可视化5.1 为什么选择前端方案Python Matplotlib 适合快速验证和本地演示但有两个局限一是交互能力有限二是嵌入 Web 页面或大屏不够方便。如果要把冒泡排序可视化作为一个可分享的网页、一块教学大屏的一部分HTML JavaScript 是更自然的选择。浏览器本身提供了requestAnimationFrame、setInterval、async/await等时间控制能力配合 CSS 可以轻松实现流畅的柱状图动画。而且用户可以直接通过按钮控制排序启动、重置和速度现场演示时体验比命令行脚本好得多。5.2 完整 HTML/JS 代码实现下面这个页面实现了 24 个数字的冒泡排序可视化并支持三档速度。核心逻辑与 Python 版本类似先把排序过程记录为状态帧数组再通过定时器逐帧渲染。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title冒泡排序可视化 - 24个数字/title style body { font-family: Microsoft YaHei, sans-serif; background: #f5f7fa; margin: 0; padding: 20px; display: flex; flex-direction: column; align-items: center; } h2 { color: #333; } .chart { display: flex; align-items: flex-end; gap: 2px; width: 900px; height: 400px; background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 10px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); margin: 20px 0; } .bar { flex: 1; background: #4c8bf5; transition: height 0.05s linear, background-color 0.1s linear; min-width: 10px; border-radius: 2px 2px 0 0; position: relative; } .bar span { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #555; } .bar.compare { background: #f5a623; } .bar.swap { background: #e74c3c; } .bar.sorted { background: #9e9e9e; } .controls { display: flex; gap: 12px; align-items: center; } button { padding: 8px 18px; font-size: 14px; border: none; border-radius: 6px; cursor: pointer; background: #4c8bf5; color: white; } button:disabled { background: #ccc; cursor: not-allowed; } select { padding: 6px 12px; border-radius: 6px; border: 1px solid #ccc; } .info { margin-top: 12px; font-size: 14px; color: #666; } /style /head body h2冒泡排序可视化24 个数字是怎么排好的/h2 div classchart idchart/div div classcontrols button idstartBtn开始排序/button button idresetBtn重新生成/button select idspeedSelect option value200慢速/option option value100 selected中速/option option value30快速/option /select /div div classinfo idinfo点击“开始排序”观察冒泡排序如何把数字逐步排好。/div script const N 24; let data []; let frames []; let timer null; let currentFrame 0; const chart document.getElementById(chart); const info document.getElementById(info); const startBtn document.getElementById(startBtn); const resetBtn document.getElementById(resetBtn); const speedSelect document.getElementById(speedSelect); // 生成 1~100 不重复随机数 function generateData() { const nums []; const pool []; for (let i 1; i 100; i) pool.push(i); for (let i 0; i N; i) { const idx Math.floor(Math.random() * pool.length); nums.push(pool.splice(idx, 1)[0]); } return nums; } // 核心记录排序状态帧 function buildFrames(arr) { const list arr.slice(); const result []; const n list.length; result.push({ arr: list.slice(), compareIdx: -1, swapIdx: -1, sortedLen: 0 }); for (let i 0; i n - 1; i) { let swapped false; for (let j 0; j n - 1 - i; j) { result.push({ arr: list.slice(), compareIdx: j, swapIdx: -1, sortedLen: i }); if (list[j] list[j 1]) { [list[j], list[j 1]] [list[j 1], list[j]]; swapped true; result.push({ arr: list.slice(), compareIdx: j, swapIdx: j 1, sortedLen: i }); } } result.push({ arr: list.slice(), compareIdx: -1, swapIdx: -1, sortedLen: i 1 }); if (!swapped) break; } result.push({ arr: list.slice(), compareIdx: -1, swapIdx: -1, sortedLen: n }); return result; } // 渲染柱子 function renderFrame(frame) { chart.innerHTML ; const maxVal Math.max(...frame.arr); for (let i 0; i frame.arr.length; i) { const bar document.createElement(div); bar.className bar; const heightPercent (frame.arr[i] / maxVal) * 90; bar.style.height heightPercent %; const label document.createElement(span); label.textContent frame.arr[i]; bar.appendChild(label); if (i frame.arr.length - frame.sortedLen) { bar.classList.add(sorted); } if (i frame.compareIdx) { bar.classList.add(compare); } if (i frame.swapIdx) { bar.classList.add(swap); } chart.appendChild(bar); } const compareCount frame.compareIdx 0 ? frame.compareIdx 1 : 0; const sortedText frame.sortedLen 0 ? 已就位 ${frame.sortedLen} 个数字 : 等待开始; info.textContent 当前帧: ${currentFrame 1} / ${frames.length}${sortedText}比较位置: ${compareCount}; } // 启动定时器 function startSort() { if (timer) return; const speed parseInt(speedSelect.value, 10); startBtn.disabled true; resetBtn.disabled true; speedSelect.disabled true; timer setInterval(() { if (currentFrame frames.length - 1) { clearInterval(timer); timer null; startBtn.disabled false; resetBtn.disabled false; speedSelect.disabled false; info.textContent 排序完成; return; } currentFrame; renderFrame(frames[currentFrame]); }, speed); } // 重置数据 function resetData() { if (timer) { clearInterval(timer); timer null; } data generateData(); frames buildFrames(data); currentFrame 0; renderFrame(frames[0]); startBtn.disabled false; resetBtn.disabled false; speedSelect.disabled false; } startBtn.addEventListener(click, startSort); resetBtn.addEventListener(click, resetData); // 初始化 resetData(); /script /body /html5.3 代码逻辑说明与 Python 版本一样buildFrames负责生成所有状态帧。它在每一轮比较前记录当前数组在交换后也记录一帧这样动画会同步显示“比较中”和“交换中”两个瞬间。renderFrame函数把数字数组渲染成柱状图。每个数字上方会显示数字本身方便观察“哪个数字在移动”。柱子高度按比例计算最大数字占容器高度的 90%避免溢出。动画部分没有使用requestAnimationFrame而是用了setInterval。这种做法的优点是代码直观变速容易。缺点是如果切换浏览器标签页setInterval可能会被降频但在这个场景下影响不大。如果希望动画更平滑可以改为requestAnimationFrame配合时间差来控制帧间隔。这里不展开因为作为教学演示setInterval已经足够清晰。5.4 运行方式把上面的代码保存为bubble_sort_visual.html用浏览器直接打开该文件即可。点击“开始排序”柱状图会按照你选择的速度逐步变化点击“重新生成”会生成新的一组 24 个数字并重置状态。如果你之后想把它集成到 Vue 或 React 项目中核心思路是不变的把buildFrames作为纯函数把renderFrame替换成框架的 DOM 更新方法即可。6. 运行结果与效果验证6.1 Python 版本的预期效果运行 Python 脚本后你会看到一个宽约 10 英寸的窗口里面是 24 根蓝色柱状图。动画启动后高亮柱子在相邻位置之间反复比较一旦发生交换第二根柱子会变红然后继续向右移动。随着排序推进右侧的柱子会逐渐变成灰色代表已经就位。最终所有柱子都变成灰色并且从左到右高度严格递增。这说明排序成功。如果动画结束后还有柱子是蓝色或者高度不是升序说明排序逻辑或帧记录有问题。你可以通过修改generate_data里的range(1, 101)来改变数据范围也可以把size24改成其他数值。修改后重新运行观察不同数据规模下冒泡排序的视觉节奏差异。6.2 HTML 版本的预期效果打开 HTML 页面后初始状态是一组 24 根随机高度的柱子每根柱子下方标有具体数字。点击“开始排序”橙色柱子表示正在比较红色柱子表示正在交换灰色柱子表示已经到达最终位置。排序结束后页面提示“排序完成”此时所有柱子均为灰色且高度从左到右递增。如果柱子在排序过程中出现数值标签与柱高不匹配的情况可以优先怀疑buildFrames中的深拷贝是否生效。6.3 自动验证排序结果人眼看动画只能获得感性判断不足以证明排序算法没有隐藏 bug。更稳妥的做法是把排序结果与 Python 或 JavaScript 内置排序做一次对比验证。Python 中可以在帧序列结束后判断assert frames[-1][0] sorted(data), 排序结果不正确JavaScript 中可以在buildFrames返回后判断const sorted frames[frames.length - 1].arr.slice().sort((a, b) a - b); const resultArr frames[frames.length - 1].arr.slice(); console.log(JSON.stringify(resultArr) JSON.stringify(sorted));如果断言失败说明冒泡排序算法实现有误而不是动画渲染有误。建议在可视化之前先写好这个校验否则动画再好看底层也是错的。7. 常见问题与排查思路问题现象可能原因排查方式解决方案Python 动画窗口一闪而过plt.show()被某些 IDE 拦截或脚本在无图形环境下运行检查运行环境是否支持 GUI在命令行终端直接运行使用matplotlib.use(TkAgg)或改在本地终端执行所有帧最后都显示有序数组记录帧时没有深拷贝所有帧共享同一个列表对象打印frames[0]和frames[-1]是否相同使用arr[:]或copy.deepcopy保存副本动画速度太快看不清交换interval设置过小检查FuncAnimation的 interval 参数将 interval 调到 120 以上HTML 页面柱状图没有动画JavaScript 报错或buildFrames生成帧数不对打开浏览器开发者工具查看 Console 报错检查frames.length是否远大于 0开始排序后按“重新生成”无效定时器还在运行重置函数被禁用确认resetBtn.disabled状态在重置前清除setInterval保存 GIF 报错缺少 Pillow 或 ffmpeg查看报错信息中的 writer 名称pip install pillow或调整 writer 参数数字标签被柱体遮挡span定位不准检查 CSS 中bar span的 bottom 和 left将 span 放在柱体上方或调整偏移量排序结果不是升序比较条件写反检查是否应为根据需求改为升序或降序并同步修改颜色标记柱状图高度比例失调没有计算最大值检查渲染时是否使用动态 maxVal按当前数组最大值 / 100归一化排查时建议遵循“先验证排序算法再验证渲染逻辑”的顺序。很多可视化问题其实不是动画代码的问题而是排序算法本身写错了。把排序结果打印出来和内置排序对比能快速缩小范围。8. 最佳实践与工程建议8.1 把排序过程与渲染彻底解耦这是本文反复强调的一点。无论你选择什么技术栈都应该把“生成状态帧”作为独立的纯逻辑模块。状态帧只包含数组状态和辅助信息不依赖任何 GUI 类。这样你可以先写单元测试验证帧序列再处理动画渲染调试效率会高很多。当你需要扩展其他排序算法时也能直接复用这套架构。快速排序、插入排序、选择排序都可以实现一个buildFrames只是生成的帧序列不同。渲染层几乎不用改只需要调整颜色策略。8.2 用颜色状态表达算法语义可视化不是把柱子画出来就行关键在于颜色信息。推荐至少用三种颜色正在比较的柱子、正在交换的柱子、已经就位的柱子。这样观众不需要仔细看数字就能判断当前操作属于哪个阶段。如果颜色种类过多反而会增加认知负担。多数场景下三到四色足够。也可以额外使用透明度或边框来区分当前轮次的数组范围但不要为了视觉效果牺牲信息清晰度。8.3 显示数字标签而不是只显示柱高柱状图虽然直观但如果不显示具体数字观众很难判断“90 和 87 谁大谁小”。尤其在学习阶段数字标签能让交换过程一目了然。对于 24 个数字来说每根柱子至少可以容纳一个两位数的标签不必担心重叠。如果数字数量增加到 100 以上建议先隐藏标签只保留柱高否则页面会非常拥挤。这说明可视化方案的参数需要根据数据规模动态调整。8.4 考虑大屏或演示场景的性能如果你打算把可视化放到大屏或教室投影上需要注意几个问题。第一柱子的宽度应该合理避免过细的柱子产生高频视觉闪烁第二避免每帧重建整个 DOM优先更新柱子的高度和颜色属性第三动画帧间隔不宜太短大屏观众距离较远太快会看不清。在 HTML 方案中我的代码每帧都重绘整个图表。用于学习没问题但如果你要做大屏级应用建议预先创建好 24 个div然后只更新高度和 class减少 DOM 操作。8.5 记录比较次数和交换次数除了动画本身很多教学场景会希望在界面顶部显示“比较次数”和“交换次数”。这不仅让可视化更完整也能帮助理解冒泡排序的复杂度。建议在buildFrames内部维护计数器并把计数结果存入每个帧对象中而不是在渲染时临时统计。临时统计的问题在于渲染层拿到的信息只有当前索引无法知道历史总次数。只有在排序逻辑里实时累加才能得到准确数字。8.6 安全性提醒如果你在网页里做可视化要注意不要使用eval或new Function来执行用户输入的排序逻辑。本文的代码都是固定算法不存在这个问题但如果你的页面允许用户提交自定义排序函数执行用户代码会有安全风险。正式工程中应使用沙箱执行或只允许用户选择预设算法。9. 总结与后续学习方向本文把 24 个数字的冒泡排序过程做成了两种可视化方案。第一种方案基于 Python 和 Matplotlib适合本地教学和快速验证第二种方案基于 HTML、CSS 和 JavaScript适合网页交互、大屏展示和分享传播。两种方案的核心思想一致先记录排序过程中的每帧状态再进行渲染。从这段实践里最重要的是理解了一个通用套路算法可视化的难点不在于画图而在于把算法执行路径转换成结构化的状态序列。这个思路可以迁移到很多算法动画中。下一步可以从三个方向继续深入第一把同样的状态帧思路应用到选择排序、插入排序和快速排序上做一个排序算法动画合集对比不同算法的“操作节奏”差异第二在 HTML 版本中加入音频反馈每次交换时播放一个短音通过听觉强化“交换发生频率”第三把动画面板接入数据大屏或教学系统增加开始、暂停、步进、拖拽调速等控制能力做成一个可复用的算法可视化组件。如果你刚开始学算法建议先亲手跑一遍本文的代码再把“每一轮结束后数组最右侧的数字”用笔写下来与动画最后一根灰色柱子对照。这个过程看起来简单却能真正补上“背代码”到“懂过程”之间的那段认知空白。