初学排序算法时,很多人都有过这样的体验:冒泡排序的代码不长,逻辑看起来也直白,可真要在脑子里模拟 24 个无序数字每轮交换后的样子,很快就跟不上了。外层循环进入第 3 轮时,数组的哪些区域已经稳定?内层循环比较到哪一位就可以停下来?如果某一轮一个交换都没发生,是不是说明整个数组已经有序?这些问题如果只看代码,回答起来并不轻松。
如果能把排序过程变成可视化的动画,这些疑问会清晰得多。本文就用纯 HTML + CSS + JavaScript 实现一个 24 个数字的冒泡排序可视化页面,整个页面不依赖任何框架和构建工具,保存成一个 HTML 文件后,浏览器双击就能运行。在动画里,你会清楚地看到每一轮比较发生在哪两个数字之间,哪些数字正在交换,哪些数字已经排好并进入绿色区域,以及理论上最多 276 次比较是如何一步步完成的。
1. 冒泡排序:一个“老牌但值得细看”的排序算法
1.1 冒泡排序的基本思想
冒泡排序(Bubble Sort)的核心思想是反复比较相邻的两个元素,如果顺序不符合要求,就把它们交换。以升序排序为例:
- 从数组的第一个元素开始,依次比较
arr[j]和arr[j + 1]。 - 如果
arr[j] > arr[j + 1],说明前一个数字比后一个大,交换它们。 - 继续移动到下一对相邻元素,重复上述比较与交换。
- 当一轮比较结束后,数组末尾一定是当前范围内最大的元素。
- 下一轮比较时,忽略已经排到末尾的元素,重复整个过程。
“冒泡”这个名字很形象:较小的数字会像气泡一样逐渐往前移动,较大的数字则会一层层“沉”到末尾。以数组[5, 1, 4, 2]为例,第一轮比较结束后,最大值 5 会被交换到索引3的位置,之后 5 就不再参与后续比较。
从算法性质来看,冒泡排序有以下几个关键点:
- 时间复杂度:最坏情况和平均情况都是
O(n^2);如果数组已经有序,并且代码加入了提前退出机制,最好情况可以优化到O(n)。 - 空间复杂度:
O(1),它只需要一个临时变量用来交换元素,属于原地排序。 - 稳定性:相邻元素只有在前一个大于后一个时才交换,相等元素不会交换相对顺序,因此冒泡排序是稳定排序。
1.2 为什么要把冒泡排序“可视化”
学习排序算法时,大部分人都会经历三个阶段:能看懂代码、能手动模拟、能根据场景选择算法。手动模拟是很多人卡住的一步,因为数据一旦多起来,大脑的工作记忆会很快被占满。
可视化最大的价值在于把时间维度拉进画面。你不再只是看到代码在反复跑循环,而是能直观看到:
- 黄色指针在相邻元素之间移动,代表正在进行比较;
- 红色闪烁的两个柱子代表正在发生交换;
- 绿色区域从右往左逐渐变长,代表已排序部分不断增加;
- 如果某一轮完全没有发生交换,动画可以提前结束,而不是继续傻跑。
很多同学在面试前临时背规范排序算法,却很难说出优化点在哪里。通过可视化理解冒泡排序的“提前退出”和“有序区边界”之后,这些细节会变成图形记忆,比死记硬背牢固得多。
1.3 为什么用 24 个数字来演示
24 是一个非常合适的可视化规模。
如果是 5 个数字,排序过程太短,还没来得及看清就结束了;如果是 1000 个数字,柱状图会被压缩得看不出细节,动画时间也会变得太长。24 个数字既有足够的排列组合空间,又能在普通屏幕上一根根清楚地展示。
从计算量来看,24 个数字最坏情况下需要比较:
23 + 22 + 21 + ... + 1 = 276 次如果在每一步之间等待约 80ms,整体动画过程大概需要二十多秒,这个时长对理解算法来说非常合适。你也可以通过速度滑块将其压缩到几秒,或者放慢到半分钟,观察每个细节。
2. 环境准备:只需要一个浏览器
2.1 为什么选择原生 Web 技术
排序可视化可以通过多种方案实现:命令行输出、Python matplotlib 动画、Java Swing、JavaScript Canvas 等。本文选择HTML + CSS + JavaScript,原因很实际:
- 零安装:任何有浏览器的电脑都能运行,不需要安装 Python、JDK 或任何包管理器。
- 跨平台:Windows、macOS、Linux 都能直接打开。
- 交互自然:按钮、滑块、暂停/继续功能用浏览器原生控件就能实现。
- 绘图高效:Canvas 2D 在 Web 端绘制几百根柱子非常流畅。
算法逻辑本身和语言无关,JavaScript 写的冒泡排序和其他语言并没有本质区别。如果你熟悉 C++ 或 Java,下面的实现依然很容易看懂。
2.2 目录结构与运行方式
整个项目只需要一个文件,例如bubble-sort-visualize.html。项目结构如下:
bubble-sort-visualize/ └── bubble-sort-visualize.html # 完整可视化页面运行方式有两种:
- 直接双击 HTML 文件,用浏览器打开。
- 在当前目录启动一个静态服务器,例如
python -m http.server 8080,然后访问http://localhost:8080。
后续代码都会围绕这一个文件展开。
3. 先用 JavaScript 实现冒泡排序核心逻辑
3.1 基础版冒泡排序
先看最标准的冒泡排序写法,这里用 JavaScript 来实现:
// 文件路径:src/bubbleSort.js function bubbleSort(arr) { const n = arr.length; for (let i = 0; i < n - 1; i++) { for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { // 交换相邻元素 [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; } } } return arr; } const data = [64, 34, 25, 12, 22, 11, 90]; console.log(bubbleSort(data)); // 输出:[11, 12, 22, 25, 34, 64, 90]代码中的[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]是 ES6 的解构赋值交换技巧,等价于:
let temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp;外层循环控制“总共需要跑多少轮”,内层循环控制“当前轮需要比较到哪个位置”。因为每跑完一轮,末尾就会多一个排好的元素,所以内层循环上限是n - 1 - i。
3.2 优化一:提前退出
如果原数组已经基本有序,基础版依然会老老实实跑完所有外层循环,造成大量无效比较。加入一个swapped标志,就可以在某一轮完全没有交换时提前结束:
function bubbleSortOptimized(arr) { const n = arr.length; for (let i = 0; i < n - 1; i++) { let swapped = false; for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; swapped = true; } } // 如果这一轮没有发生任何交换,说明数组已经有序 if (!swapped) { break; } } return arr; }在最好情况下,也就是数组已经有序时,这个版本只需要执行一轮比较,比较n - 1次,时间复杂度降到O(n)。可视化页面中,每当一轮没有发生交换,动画也会立即停止,用户能直观看到“提前退出”的意义。
3.3 优化二:记录最后一次交换位置
第二个优化思路是缩短下一轮的有序区边界。理论上,如果最后一对交换发生在j和j + 1之间,那么索引j + 1之后的所有元素已经在正确位置上,下一轮只需要比较到j + 1位置即可。
function bubbleSortBoundary(arr) { let n = arr.length; while (n > 1) { let lastSwapIndex = 0; for (let j = 0; j < n - 1; j++) { if (arr[j] > arr[j + 1]) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; lastSwapIndex = j + 1; } } // 把下一轮的边界收缩到最后一次交换的位置 n = lastSwapIndex; } return arr; }这种方式对“前半部分无序、后半部分已经有序”的数组非常友好。虽然本文的可视化页面还是采用“每轮末尾固定扩大一个绿色元素”的方式展示,但你可以在扩展练习中尝试把这种优化也做进动画,观察边界是如何一步步收缩的。
如果大家习惯 C++,可以对比一下同样的逻辑:
// 文件路径:bubbleSort.cpp void bubbleSort(vector<int>& arr) { int n = arr.size(); for (int i = 0; i < n - 1; i++) { bool swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; } }算法跨语言的核心逻辑是完全一致的,差别只在语法层面。因此,你在 HTML 页面里看到的动画过程,同样适合解释 C++ 或 Java 中的冒泡排序行为。
4. 可视化设计:让 24 个数字“跑”起来
4.1 可视化目标拆解
在写代码之前,先明确这个可视化页面需要满足哪些要求:
- 柱状图阵列:24 个数字用 24 根柱子表示,柱子的高度代表数字大小。
- 颜色状态区分:比较中的位置显示黄色,交换中的位置显示红色,已排序区域显示绿色,普通位置显示蓝色。
- 操作统计:实时显示当前轮次、比较次数、交换次数。
- 可控制性:支持开始排序、暂停/继续、打乱数组,以及调节动画速度。
- 防误操作:排序中不允许重复启动,重置数组时能安全中止旧任务。
4.2 使用 Canvas 绘制而不是操作 DOM
有人可能会问:为什么不直接创建 24 个<div>,每次排序时改它的高度和颜色?
用 DOM 当然可以实现,但存在两个问题:
- 频繁操作 DOM 开销大。排序每走一步,可能要同时改多个 div 的
height和background,如果步数达到几百次,页面很容易出现卡顿。 - 逻辑分散。颜色、高度、位置都要分别更新,代码可读性差。
Canvas 的做法是每次状态变化后整帧重绘,由clearRect + fillRect完成绘制。24 个柱子的重绘量非常小,性能完全不是问题,而且绘制逻辑集中在一个draw()函数里,数据状态与表现状态天然清晰分离。
4.3 核心难点:让排序过程可暂停、可继续
这是整个可视化页面最容易踩坑的地方。
如果直接调用同步的bubbleSort(),JavaScript 会一口气把排序执行完。由于浏览器的主线程被占住,页面无法重新绘制动画,你只能看到最终结果。这正是很多初学者在写可视化时“动画一闪而过”的根本原因。
解决思路是把排序函数改成异步函数,在每次比较和交换之后,await一个等待操作,把控制权交还给浏览器,让浏览器有机会绘制当前帧。
function sleep(ms) { return new Promise(resolve => { let remaining = ms; const timer = setInterval(() => { if (stopSignal) { clearInterval(timer); resolve(); return; } if (paused) { return; // 暂停状态,不减少 remaining } remaining -= 20; if (remaining <= 0) { clearInterval(timer); resolve(); } }, 20); }); }在这个sleep实现里,每次等待分成若干个 20ms 的小片段。如果暂停标志paused为true,剩余时间不减少,排序就“卡住”了;点击继续后,剩余时间继续减少。如果重置按钮设置了stopSignal,等待会立即结束,后续排序逻辑发现信号后主动退出,避免旧任务继续操作新数组。
4.4 状态字段设计
为了让绘制函数知道当前怎么上色,需要维护以下状态:
| 状态 | 字段 | 说明 |
|---|---|---|
| 普通状态 | - | 默认蓝色 |
| 比较中 | compareIndex | 黄色,存储[j, j + 1] |
| 交换中 | swapIndex | 红色,存储[j, j + 1] |
| 已排序区 | sortedCount | 绿色,用于计算已就位的柱子数量 |
| 排序标志 | isSorting | 防止重复启动 |
| 暂停标志 | paused | 暂停状态下等待不结束 |
| 停止信号 | stopSignal | 重置数组时中止排序任务 |
每次排序循环推进时,更新这些字段,然后调用draw(),页面就会立刻看到最新状态。
5. 完整实战:24 个数字冒泡排序可视化页面
5.1 页面 HTML 结构
页面分为四个区域:标题、画布、统计信息、控制按钮。
<div class="container"> <h2>冒泡排序可视化(24 个数字)</h2> <canvas id="sortCanvas" width="720" height="360"></canvas> <div class="stats"> <span>轮次:<b id="roundIndex">0</b></span> <span>比较次数:<b id="compareCount">0</b></span> <span>交换次数:<b id="swapCount">0</b></span> </div> <div class="controls"> <button id="startBtn">开始排序</button> <button id="pauseBtn" disabled>暂停</button> <button id="resetBtn">打乱数组</button> <label for="speedRange">速度: <input id="speedRange" type="range" min="10" max="300" value="80"> </label> </div> </div>速度滑块的值单位是毫秒。值越小,动画越快;值越大,动画越慢。默认 80ms,在 24 个数字场景下整体节奏比较适中。
5.2 CSS 样式
样式采用深色背景,重点突出画布区域和状态颜色。
* { box-sizing: border-box; } body { margin: 0; padding: 20px; background: #1e1e2e; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; color: #e0e0e0; display: flex; justify-content: center; align-items: center; min-height: 100vh; } .container { background: #282a36; border-radius: 12px; padding: 24px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); width: fit-content; } h2 { margin: 0 0 16px; font-size: 20px; text-align: center; } canvas { display: block; background: #1e1e28; border-radius: 8px; margin-bottom: 16px; } .stats { display: flex; justify-content: space-around; margin-bottom: 16px; font-size: 14px; } .stats b { color: #f8f8f2; } .controls { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; align-items: center; } button { padding: 8px 18px; font-size: 14px; border: none; border-radius: 6px; background: #6272a4; color: #f8f8f2; cursor: pointer; transition: background 0.2s; } button:hover { background: #7085c0; } button:disabled { opacity: 0.5; cursor: not-allowed; } input[type="range"] { vertical-align: middle; }5.3 JavaScript 核心代码
首先是全局变量和初始化函数。为了生成不重复的 24 个数字,使用 Fisher-Yates 洗牌算法生成 1 到 24 的随机排列。
const canvas = document.getElementById('sortCanvas'); const ctx = canvas.getContext('2d'); const startBtn = document.getElementById('startBtn'); const pauseBtn = document.getElementById('pauseBtn'); const resetBtn = document.getElementById('resetBtn'); const speedInput = document.getElementById('speedRange'); const roundSpan = document.getElementById('roundIndex'); const compareSpan = document.getElementById('compareCount'); const swapSpan = document.getElementById('swapCount'); const SIZE = 24; let arr = []; let compareIndex = null; let swapIndex = null; let sortedCount = 0; let compareCount = 0; let swapCount = 0; let roundIndex = 0; let isSorting = false; let paused = false; let stopSignal = false; function generateRandomArray(size) { const arr = []; for (let i = 1; i <= size; i++) { arr.push(i); } for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function updateStats() { roundSpan.textContent = roundIndex; compareSpan.textContent = compareCount; swapSpan.textContent = swapCount; } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); const barWidth = canvas.width / arr.length; const maxValue = Math.max(...arr); for (let i = 0; i < arr.length; i++) { const barHeight = (arr[i] / maxValue) * (canvas.height - 20); const x = i * barWidth; const y = canvas.height - barHeight; let color = '#4a9eff'; if (compareIndex && compareIndex.includes(i)) { color = '#ffc53d'; } if (swapIndex && swapIndex.includes(i)) { color = '#ff4d4f'; } if (i >= arr.length - sortedCount) { color = '#52c41a'; } ctx.fillStyle = color; ctx.fillRect(x + 1, y, barWidth - 2, barHeight); } updateStats(); }接下来是sleep函数和排序主逻辑。
function sleep(ms) { return new Promise(resolve => { let remaining = ms; const timer = setInterval(() => { if (stopSignal) { clearInterval(timer); resolve(); return; } if (paused) { return; } remaining -= 20; if (remaining <= 0) { clearInterval(timer); resolve(); } }, 20); }); } async function runBubbleSort() { if (isSorting) return; isSorting = true; stopSignal = false; startBtn.disabled = true; pauseBtn.disabled = false; const n = arr.length; for (let i = 0; i < n - 1; i++) { if (stopSignal) break; roundIndex = i + 1; let swapped = false; for (let j = 0; j < n - 1 - i; j++) { if (stopSignal) break; compareIndex = [j, j + 1]; swapIndex = null; draw(); await sleep(speedInput.value); if (stopSignal) return; compareCount++; if (arr[j] > arr[j + 1]) { swapIndex = [j, j + 1]; [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; swapCount++; draw(); await sleep(speedInput.value); if (stopSignal) return; swapped = true; } } sortedCount++; compareIndex = null; swapIndex = null; if (!swapped) break; } if (!stopSignal) { sortedCount = n; draw(); } isSorting = false; startBtn.disabled = false; pauseBtn.disabled = true; pauseBtn.textContent = '暂停'; paused = false; }最后是按钮事件绑定和初始化。
startBtn.addEventListener('click', runBubbleSort); pauseBtn.addEventListener('click', () => { if (!isSorting) return; paused = !paused; pauseBtn.textContent = paused ? '继续' : '暂停'; }); resetBtn.addEventListener('click', () => { stopSignal = true; init(); }); function init() { isSorting = false; paused = false; stopSignal = false; compareIndex = null; swapIndex = null; sortedCount = 0; compareCount = 0; swapCount = 0; roundIndex = 0; arr = generateRandomArray(SIZE); draw(); startBtn.disabled = false; pauseBtn.disabled = true; pauseBtn.textContent = '暂停'; } init();5.4 运行效果与关键交互
把bubble-sort-visualize.html保存到本地并用浏览器打开,页面会随机生成 24 根蓝色柱子。点击“开始排序”后,你会看到:
- 黄色指针从左侧开始,一步一步地在相邻两根柱子之间移动。
- 如果黄色指针指向的左侧柱子比右侧高,两根柱子会变红,并在画面中完成一次高度交换。
- 每当外层循环完成一轮,右侧就会出现一根绿色柱子,并且逐步向左蔓延。
- 如果某一轮没有任何交换,排序会立即停止,此时绿色区域可能尚未覆盖所有柱子,但数组已经有序。
- 点击“暂停”可以随时中断动画,再点击“继续”从当前位置恢复。
- 点击“打乱数组”会清空排序状态,重新生成一组随机数字。
整个过程中,统计区的“轮次、比较次数、交换次数”会同步刷新。你还能看到实际比较次数往往少于理论最大值的276次,因为提前退出机制会在数组有序后立刻收工。
6. 常见问题与排查思路
在实际开发或调试可视化页面时,比较容易遇到下面几个问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 点击开始排序没有反应 | isSorting一直为true,或按钮事件未绑定 | 检查事件监听是否注册,运行前打印isSorting状态 |
| 动画一闪而过,看不到过程 | 排序函数是同步的,主线程被阻塞 | 将排序函数改为async,每次比较和交换后await sleep() |
| 暂停后点击继续,没有恢复 | sleep函数没有检查paused标志 | 使用setInterval轮询paused状态,暂停期间不减少剩余时间 |
| 排序过程中打乱数组,旧任务继续修改数据 | 缺少停止信号 | 添加stopSignal,在sleep和循环中判断并主动退出 |
| 多次点击开始按钮,产生多个并发排序任务 | 缺少互斥判断 | 在排序函数开头判断isSorting,为true时直接返回 |
| 柱子底部没有对齐或高度越界 | 计算高度时没有考虑画布边距 | 使用canvas.height - 20作为最大高度,留出底部安全距离 |
| 绿色已排序区域显示错误 | sortedCount更新时机不对 | 每一轮外层循环结束后再sortedCount++,最后强制设为n |
其中最隐蔽的是第五个问题。如果用户快速双击“开始排序”,异步函数会同时启动多个任务,它们共同修改同一个数组,最终画面的颜色和统计数据都会乱掉。解决办法就是加一把简单的锁:
if (isSorting) return; isSorting = true;排序结束时再把isSorting置回false。这个思路在很多异步场景里都能复用。
7. 最佳实践与工程建议
7.1 数据、状态与视图分离
整个页面虽然只有一个 HTML 文件,但代码结构上应该划分为三层:
- 数据层:
arr数组。 - 状态层:
compareIndex、swapIndex、sortedCount、compareCount、swapCount、isSorting、paused、stopSignal。 - 视图层:
draw()函数。
draw()只负责根据当前数组和状态绘制画面,不负责修改数组。排序算法只负责修改数组和状态,不需要关心柱子的坐标。这种拆分方式在项目扩大后特别有用,比如以后要加入插入排序可视化,只需要实现新的排序函数,复用同一套draw()。
7.2 主动停止信号比强制刷新更安全
很多人在处理“重置按钮”时,会直接调用location.reload()来重载页面。这样虽然简单,但用户体验很差,而且会丢失排序统计信息。更好的做法是设置一个stopSignal,让正在运行的异步函数在下一个安全位置主动退出,然后调用init()重置页面状态。
这种“协作式取消”的思路在异步编程中非常常见,类似前端请求中的取消令牌(CancelToken)概念。
7.3 合理控制动画性能
在 24 个数字的场景下,每帧绘制 24 个矩形,性能完全不是问题。但如果以后要扩展到几百个数字,可以考虑以下优化:
- 将
draw()中的Math.max(...arr)改为排序前一次性计算,避免每帧扫描整个数组。 - 使用
requestAnimationFrame统一控制绘制频率,避免在极度快速的排序中重复绘制无效帧。 - 对柱子填充颜色做缓存,减少每次计算状态的开销。
不过对于本文的规模,保持代码简单更重要。
7.4 让排序算法可测试
可视化代码虽然好看,但排序逻辑本身也值得单独验证。实际操作中,可以把bubbleSort抽象成一个纯函数:
function bubbleSort(arr) { const result = [...arr]; // 排序逻辑... return result; }然后写一段简单的测试代码,验证排序结果和稳定性。这样当可视化页面出现异常时,你能快速确认是算法问题还是渲染问题。
7.5 后续扩展方向
这个可视化页面完成后,可以继续扩展,让它的价值更大:
- 增加更多排序算法:选择排序、插入排序、快速排序、归并排序,共用同一套状态管理和绘制逻辑。
- 增加单步模式:点击一次按钮,只执行一步比较或交换,适合课堂演示。
- 增加序列展示:每一轮结束后的数组状态可以记录到日志区域,方便回放。
- 增加逆序度显示:统计当前数组的逆序对数量,观察排序过程中逆序对如何逐步减少。
无论怎么扩展,核心思想不变:让算法过程可见、可交互、可解释。
8. 自然收尾的实践建议
冒泡排序在工业级项目中并不是首选算法,因为平均时间复杂度较高