算法可视化学习:从动画到可调试代码
发布日期: 2026/08/17 阅读总量: 1

先说我踩的坑

去年年初我准备跳槽,打开B站收藏夹里吃灰的"算法可视化"视频。UP主动画做得是真炫——红蓝方块跳来跳去,指针左移右移,评论区一片"懂了懂了"。我连看200集,自认为天下无敌,结果去面试字节,一道「合并K个有序链表」

我脑子里全是动画里那三个指针滑来滑去的画面,真到白板写代码,指针指向哪、什么时候移动、边界怎么处理——全忘了。

面试官看我卡了15分钟,叹了口气:"要不你讲讲思路?"我连思路都讲不利索,因为动画跳太快,每一帧的状态变化我当时根本没理解,只是"看个热闹"。

回来我复盘:光看动画学算法,等于看美食视频学做菜。你得自己上手炒,才知道油温多高、什么时候下葱姜蒜。做菜讲究实操,学算法讲究什么?调试。你得能看到每一行代码执行完,内存里的数据结构变成什么样。

这篇文章把我后来搭的一套"可视化学习环境"完整写出来。包含:
- 3种可视化学习方案的对比(VisuAlgo动态演示、LeetCode内置动画、ManimCE自建动画渲染)
- 一套可运行的自建可视化代码(Python + ManimCE + Matplotlib)
- 用SQLite记录学习路径和刷题数据
- 用Canvas做交互式二叉搜索树演示
- 渲染耗时、学习效率的真实对比数据
- 我踩过的坑

版本号先摆这儿:Python 3.12.2,ManimCE 0.18.1,Matplotlib 3.8.3,SQLite 3.45.1,Node.js 20.11.1。这些代码我全部在MacBook Pro M1 Pro(16GB)上跑过。

问题:可视化教程为什么看了等于白看

先说结论:99%的算法可视化视频只展示了"正确路径",没展示"错误路径"和"推理过程"

拿动态规划举例。绝大多数视频演示的是这样的流程:
1. 画一个DP表格
2. 填第一行,填第一列
3. 填中间每个格子,颜色高亮一下
4. 说"状态转移方程是dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + 1"
5. 没了

你看完记住了表格长什么样,但你不知道:为什么这个格子依赖左上角和上面?如果把min换成max会怎样?如果数组从0开始编号和从1开始编号有什么不同?边界条件漏掉一个格会出什么错?

动画只告诉你"答案",没告诉你"为什么是这个答案"。这就是看了等于白看的根源。

认知心理学有个概念叫「生成效应」(Generation Effect):自己动手生成的内容,记忆留存率比被动观看高约50%(根据Slamecka & Graf 1978年的实验数据,这个结论已被大量复现)。算法学习也一样——你得让代码在你眼前跑起来,亲手改一个参数,看结果怎么崩,才能建立真正的内在模型。

方案对比:三种可视化学习路线

我把市面上的可视化学习资源分成三类,都用了一轮,数据如下:

方案交互性可调试性成本内容质量我的评价
VisuAlgo(算法动态演示网站)中:能控制播放/暂停/步进差:看不到内存状态,不能改代码免费高:NUS教授做的,覆盖广适合入门理解流程,不适合面试冲刺
LeetCode内置动画(部分题解)低:只能看,不能操作极差:动画是提前录好的会员费半年$159中:只有热门题有动画看图还行,指望这个提升代码能力不现实
自建可视化环境(ManimCE + Matplotlib + Canvas)高:完全受控,可改参、可暂停、可调试强:动画直接从真实执行轨迹生成,不撒谎0成本,纯时间投入定制化:你自己决定展示什么最推荐。一次搭建,长期受益

VisuAlgo的动画是拿Java Applet和JavaScript手写的,逻辑上没问题,但它只展示"步骤",不展示"状态"。比如归并排序的合并过程,它把两个有序数组合并成一个有序数组的过程用方块高度表示——你看到两个序列的指针移动,但你看不到递归栈里到底压了几层函数调用、每个函数作用域里的局部变量值是多少。

归并排序的难点根本不在"合并",在递归栈的压栈/弹栈时机。VisuAlgo不画这个。

LeetCode的动画更鸡肋。它是在题解页面嵌入一段MP4,画的是"答案路径"。你没法让它走一步停一步,没法在某个状态插入print语句,没法把数组长度改成奇数看看边界情况。

我的结论:看别人做的动画,不如自己生成动画。自己生成的动画,每一帧都对应一行代码的execute结果,这才叫可视化学习。

我的方案:用真实代码的中间产物驱动可视化

核心思路一句话:写你的算法代码时,用关键帧记录每次"数据结构发生结构性变化"时的全量快照。然后用ManimCE把这个快照序列渲染成动画。

这个方案跟"手工画动画"的本质区别在于:动画里的每一个shape、每一个指针、每一条连线,都来自程序的真实运行时时数据,不是人脑事后补的。代码是唯一的信息源,所以动画永远不会"教错"。

我用它学了三个主题,效果显著:
- 归并排序的递归栈和合并过程
- DP状态转移(以"最小路径和"为例)
- 二叉搜索树的前序/中序遍历

下面是完整实现,每一步都能直接跑。

第一步:写一个被仪表化的归并排序(Python)

关键API:snapshot()。它在每次递归调用进入/退出、每次合并且数组状态变化时,记录当前数组、递归深度、以及本次操作类型。

这个代码直接保存为 merge_sort_instrumented.py,运行后输出一个JSON文件。

import json
import copy

# 用于记录动画帧的全局列表
frames = []
depth = 0

def snapshot(arr, depth, action, message=""):
    """记录当前数组快照。
    帧内容:当前深度 + 数组 + 操作类型 + 人话说明
    """
    frames.append({
        "depth": depth,
        "array": copy.deepcopy(arr),
        "action": action,          # split / merge / return
        "msg": message,
        "frame_id": len(frames)    # 帧编号,从0开始
    })

def merge_sort(arr, d=0):
    global depth
    depth = d
    n = len(arr)
    snapshot(arr, d, "call", f"进入merge_sort: {arr}")
    if n <= 1:
        snapshot(arr, d, "return", f"长度{n}直接返回")
        return arr
    mid = n // 2
    left = merge_sort(arr[:mid], d + 1)
    right = merge_sort(arr[mid:], d + 1)
    merged = merge(left, right, d)
    snapshot(merged, d, "merge", f"合并结果: {merged}")
    return merged

def merge(left, right, d):
    i = j = 0
    result = []
    # 记录合并过程的每一步
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
        snapshot(result + left[i:] + right[j:], d, "merge_step",
                 f"i={i} j={j} result={result}")
    result.extend(left[i:])
    result.extend(right[j:])
    return result

if __name__ == "__main__":
    test = [38, 27, 43, 3, 9, 82, 10]
    merge_sort(test)
    with open("merge_sort_frames.json", "w") as f:
        json.dump(frames, f, indent=2, ensure_ascii=False)
    print("总帧数:", len(frames))
    print("输出文件: merge_sort_frames.json")

跑一下:

python3 merge_sort_instrumented.py

输出节选:

[
  {
    "depth": 0,
    "array": [38, 27, 43, 3, 9, 82, 10],
    "action": "call",
    "msg": "进入merge_sort: [38, 27, 43, 3, 9, 82, 10]",
    "frame_id": 0
  },
  {
    "depth": 1,
    "array": [38, 27, 43],
    "action": "call",
    "msg": "进入merge_sort: [38, 27, 43]",
    "frame_id": 1
  }
]

这一步的核心价值:每一帧都不是人为设计的,它就是代码真实执行到某一刻的内存快照。你回头看任何一帧,都能找到对应的是哪一行代码。

第二步:用ManimCE渲染成动画

ManimCE是3Blue1Brown出品的动画引擎,版本0.18.1。安装:

pip install manim==0.18.1

渲染代码如下。它从JSON读取帧序列,每一帧用一个矩形列表+指针箭头展现:

from manim import *
import json

# 读取上一步生成的帧数据
with open("merge_sort_frames.json") as f:
    frames = json.load(f)

class MergeSortAnimation(Scene):
    def construct(self):
        # 预加载所有帧的Mobject,避免渲染卡顿
        mobs = []
        for idx, frame in enumerate(frames):
            arr = frame["array"]
            bars = VGroup()
            depth_text = Text(f"递归深度: {frame['depth']}", font_size=20)
            action_text = Text(frame["msg"], font_size=20)
            # 左侧显示递归深度和操作描述
            depth_text.to_corner(UL)
            action_text.to_corner(UL).shift(DOWN * 0.5)
            # 画方块图
            for i, val in enumerate(arr):
                rect = Rectangle(
                    width=0.8,
                    height=val * 0.06,
                    fill_opacity=0.8,
                    fill_color=BLUE if frame["action"] != "merge_step" else YELLOW,
                    stroke_width=2,
                )
                rect.next_to(ORIGIN, direction=UP, buff=val * 0.03)
                rect.shift(RIGHT * (i - len(arr) / 2) * 0.9)
                # 方块底部对齐
                rect.align_to(ORIGIN, DOWN)
                bars.add(rect)
            # 整体居中
            group = VGroup(bars, depth_text, action_text)
            group.move_to(ORIGIN)
            mobs.append(group)

        # 连续播放每一帧,帧间用FadeIn/FadeOut过渡
        for i, mob in enumerate(mobs):
            if i == 0:
                self.add(mob)
                self.wait(0.3)
            else:
                # 只更新bars部分,减少画面闪烁
                self.play(
                    FadeOut(mobs[i - 1][0]),
                    FadeIn(mob[0]),
                    run_time=0.2,
                )
                # 更新文字(小延时)
                self.remove(mobs[i - 1][1], mobs[i - 1][2])
                self.add(mob[1], mob[2])
        self.wait(1)

# 命令行执行:
# manim render -r 1280,720 --fps 30 merge_sort_animation.py MergeSortAnimation

渲染命令:

manim render -r 1280,720 --fps 30 merge_sort_animation.py MergeSortAnimation

这个生成的是MP4。跑完你会得到一个精确对应递归调用顺序的归并排序动画。我拿它跟VisuAlgo的动画对比,最大的区别是:我的动画里每一帧左上角都写着当前递归深度和操作描述,比如"进入merge_sort: [38, 27, 43]"、"i=1 j=0 result=[27]"。你按空格暂停,能看清楚每一个状态的来龙去脉。

VisuAlgo的动画没有这些细节。它就是一个大数组+两个指针,指针移来移去,你根本不知道指针为什么移。

第三步:用Matplotlib做交互式DP可视化

对于动态规划,我最开始也用Manim,后来发现不合适——DP的核心是表格填充,你需要"点一下、填一格、看看依赖关系"的交互式体验,而不是线性播放的动画。

这里用Matplotlib写了一个「最小路径和」DP表格可视化。核心是:用matplotlib的button和widgets实现步进。

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.widgets import Button

# 输入:3x4网格,求左上到右下最小路径和(只能向右/向下)
grid = np.array([
    [1, 3, 1, 2],
    [1, 5, 1, 3],
    [4, 2, 1, 6]
])
rows, cols = grid.shape
dp = np.zeros_like(grid, dtype=int)
dp[0][0] = grid[0][0]

step = 0
cell_cursor = []  # (r, c, from_r, from_c)

def compute_dp():
    """预计算所有状态转移步骤"""
    global cell_cursor
    for i in range(rows):
        for j in range(cols):
            if i == 0 and j == 0:
                continue
            if i == 0:
                dp[i][j] = dp[i][j-1] + grid[i][j]
                cell_cursor.append((i, j, i, j-1))
            elif j == 0:
                dp[i][j] = dp[i-1][j] + grid[i][j]
                cell_cursor.append((i, j, i-1, j))
            else:
                if dp[i-1][j] <= dp[i][j-1]:
                    dp[i][j] = dp[i-1][j] + grid[i][j]
                    cell_cursor.append((i, j, i-1, j))
                else:
                    dp[i][j] = dp[i][j-1] + grid[i][j]
                    cell_cursor.append((i, j, i, j-1))

compute_dp()

fig, ax = plt.subplots(figsize=(8, 6))
plt.subplots_adjust(bottom=0.2)

def draw_table(step_idx):
    ax.clear()
    # 画DP表数值
    for i in range(rows):
        for j in range(cols):
            color = '#f0f0f0'
            if dp[i][j] > 0:
                color = '#a3d3ff'
            ax.text(j, rows - 1 - i, f'{dp[i][j]}',
                    ha='center', va='center', fontsize=14,
                    bbox=dict(boxstyle='round,pad=0.4', facecolor=color))
    # 画当前操作格子为红色方框
    if step_idx < len(cell_cursor):
        r, c, fr, fc = cell_cursor[step_idx]
        ax.add_patch(plt.Rectangle((c - 0.4, rows - 1 - r - 0.35), 0.8, 0.7,
                                    fill=False, edgecolor='red', linewidth=2.5))
        # 画依赖箭头
        ax.annotate('', xy=(c, rows - 1 - r), xytext=(fc, rows - 1 - fr),
                    arrowprops=dict(arrowstyle='->', color='green', lw=2))
        ax.set_title(f'Step {step_idx+1}/{len(cell_cursor)}: dp[{r}][{c}] 依赖 dp[{fr}][{fc}]')
    else:
        ax.set_title(f'完成!最短路径和 = {dp[rows-1][cols-1]}')
    ax.set_xlim(-0.5, cols - 0.5)
    ax.set_ylim(-0.5, rows - 0.5)
    ax.axis('off')
    ax.grid(False)
    plt.draw()

def on_next(_):
    global step
    if step < len(cell_cursor):
        draw_table(step)
        step += 1

def on_reset(_):
    global step
    step = 0
    draw_table(0)

ax_next = plt.axes([0.7, 0.05, 0.15, 0.06])
ax_reset = plt.axes([0.5, 0.05, 0.15, 0.06])
btn_next = Button(ax_next, '下一步')
btn_reset = Button(ax_reset, '重置')
btn_next.on_clicked(on_next)
btn_reset.on_clicked(on_reset)

draw_table(0)
plt.show()

这个代码直接跑 python dp_visualization.py。每一步点一下,你能看到:
1. 当前填的是哪个格子(红色框)
2. 它依赖哪个格子(绿色箭头)
3. 当前依赖的dp值(蓝色底色的数字)
4. 状态转移方程的文字提示

这才是学DP的正确方式。你亲手点完10个格子的转移过程,再去看状态转移方程,它就不再是神秘的符号,而是你亲手验证过的步骤。

第四步:用SQLite管理学习路径

看了几十个动画之后我还有个问题:不复习就忘。于是我用SQLite建了一张表,记录每章学习时间、刷题数、错误次数。每周跑一次统计,看自己的短板在哪。

-- 学习进度表
CREATE TABLE study_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    topic TEXT NOT NULL,           -- 主题:如 '归并排序'
    source TEXT NOT NULL,          -- 来源:如 'manim_local'
    learn_date DATE NOT NULL,
    minutes_spent INTEGER NOT NULL, -- 学习分钟数
    problems_solved INTEGER DEFAULT 0,
    first_pass_accuracy REAL DEFAULT 0,  -- 第一次提交通过率, 0~1
    revisit_count INTEGER DEFAULT 0      -- 复习次数
);

-- 插入一条记录
INSERT INTO study_log (topic, source, learn_date, minutes_spent, problems_solved, first_pass_accuracy, revisit_count)
VALUES ('归并排序', 'manim_local', '2024-03-15', 45, 3, 0.67, 0);

-- 复习统计:每周学习时长与正确率趋势
SELECT strftime('%W', learn_date) AS week,
       SUM(minutes_spent) AS total_minutes,
       AVG(first_pass_accuracy) AS avg_accuracy,
       COUNT(*) AS sessions
FROM study_log
GROUP BY week
ORDER BY week;

这个表在SQLite里建完,用sqlite3 study_log.db跑。它服务于一个朴素的管理思想:如果某个主题的first_pass_accuracy低于50%,那说明你看的动画没真正进脑子,需要重新过一遍。

我用了一个月,发现「并查集」的首次通过率只有23%,远低于其他主题。这个数据直接告诉我:光看动画学并查集是不够的,需要换一种学习方式。

第五步:Canvas交互式二叉搜索树

二叉树的可视化做静态动画没意义,你需要的是用自己的输入构造一棵树,然后亲眼看到前序遍历的递归轨迹。我用纯JavaScript + Canvas写了一个最小实现。

// 插入节点 + 动态显示前序遍历的递归轨迹
const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');

let root = null;
let traverseSteps = [];  // 存储遍历步骤

class Node {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
        this.x = 0;
        this.y = 0;
    }
}

function insert(node, val) {
    if (!node) return new Node(val);
    if (val < node.val) node.left = insert(node.left, val);
    else if (val > node.val) node.right = insert(node.right, val);
    return node;
}

// 计算每个节点的绘制坐标(借用中序遍历生成x坐标,深度生成y坐标)
function layout(node, depth = 0, range = { min: 0, max: 800 }, result = []) {
    if (!node) return;
    layout(node.left, depth + 1, { min: range.min, max: (range.min + range.max) / 2 }, result);
    node.x = (range.min + range.max) / 2;
    node.y = depth * 80 + 50;
    layout(node.right, depth + 1, { min: (range.min + range.max) / 2, max: range.max }, result);
}

function drawTree(node) {
    if (!node) return;
    ctx.clearRect(0, 0, 800, 600);
    function draw(n) {
        if (!n) return;
        if (n.left) {
            ctx.beginPath();
            ctx.moveTo(n.x, n.y);
            ctx.lineTo(n.left.x, n.left.y);
            ctx.stroke();
            draw(n.left);
        }
        if (n.right) {
            ctx.beginPath();
            ctx.moveTo(n.x, n.y);
            ctx.lineTo(n.right.x, n.right.y);
            ctx.stroke();
            draw(n.right);
        }
        ctx.beginPath();
        ctx.arc(n.x, n.y, 18, 0, Math.PI * 2);
        ctx.fillStyle = '#4a90d9';
        ctx.fill();
        ctx.fillStyle = '#fff';
        ctx.font = 'bold 14px monospace';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(n.val, n.x, n.y);
    }
    draw(node);
}

// 前序遍历的递归过程,每一步记录当前节点和调用栈深度
function preorderTraverse(node, depth = 0, steps = []) {
    if (!node) {
        steps.push({ type: 'null', depth });
        return steps;
    }
    steps.push({ type: 'visit', val: node.val, depth });
    preorderTraverse(node.left, depth + 1, steps);
    preorderTraverse(node.right, depth + 1, steps);
    return steps;
}

// 按钮事件
document.getElementById('insertBtn').addEventListener('click', () => {
    const val = parseInt(document.getElementById('valInput').value);
    if (isNaN(val)) return;
    root = insert(root, val);
    layout(root);
    drawTree(root);
});

document.getElementById('traverseBtn').addEventListener('click', () => {
    if (!root) return;
    traverseSteps = preorderTraverse(root);
    let i = 0;
    const timer = setInterval(() => {
        if (i >= traverseSteps.length) { clearInterval(timer); return; }
        const s = traverseSteps[i];
        if (s.type === 'visit') {
            document.getElementById('traverseLog').innerText +=
                `访问节点 ${s.val}(深度${s.depth})\n`;
        } else {
            document.getElementById('traverseLog').innerText +=
                `走到 null(深度${s.depth})\n`;
        }
        i++;
    }, 500);
});

配合的HTML长这样:

<input id="valInput" placeholder="输入数字" />
<button id="insertBtn">插入</button>
<button id="traverseBtn">前序遍历</button>
<canvas id="treeCanvas" width="800" height="600" style="border:1px solid #ccc"></canvas>
<pre id="traverseLog" style="height:180px; overflow-y:auto; font-size:12px"></pre>

这段代码不需要服务器,直接双击HTML文件就能跑。它让遍历过程变得可观测:每访问一个节点,日志区追加一行,包含节点值和递归深度。你能清楚看到递归是怎么一层层向下,再一层层回溯的。

效果数据:真的有效果吗

这套方法我用了一个半月(2024.2.20 - 2024.4.5),对比过去两个月(2023.12 - 2024.2)的学习数据:

指标旧方法(纯看动画+刷题)新方法(可视化+调试+SQLite复盘)变化
每月刷题数38道29道-23%。题刷少了,因为每道题都在可视化环境里跑过
首次提交通过率41%67%+26个百分点
周赛3题率(1小时内)0次3次(最近4周)有本质提升
面试算法题通过率(模拟面试)2/8 = 25%5/7 = 71%+46个百分点

数据样本不大(我自己的记录),但趋势很明确。关键是:减少刷题量,增加对每个算法主题的深度可视化和调试,确实能提升真实场景下的表现。

另外我统计了ManimCE渲染耗时。归并排序7个元素、52帧的动画:
- 渲染时间:32秒(720p 30fps,默认质量)
- 文件大小:3.1MB MP4
- 内存峰值:482MB(ManimCE渲染器本身开销)

这个开销不算小,但一次性投入可以接受。如果动画更长,我建议分帧渲染+后期合成,或者用低分辨率渲染剧本,最终再渲染成品。

Matplotlib的DP可视化更轻:
- 启动时间0.8秒
- 常驻内存87MB
- 30步点完无卡顿感

避坑指南

这些坑全是我实际踩过的,写出来省你几天时间。

坑1:Manim版本分裂

ManimGL和ManimCE的API完全不同。网上很多教程写的是from manimlib import *,那是ManimGL,是旧版。最新版是ManimCE,导入方式是from manim import *。装错了跑不起来,报错信息还是中文乱码。用我上面的命令装:pip install manim==0.18.1

坑2:Manim渲染中文乱码

ManimCE默认字体不支持中文。你直接往Text()里写"递归深度"会渲染成方框。解决办法是指定一个支持中文的字体:

from manim import *

class ChineseText(Scene):
    def construct(self):
        t = Text("递归深度: 3", font_size=24, font="PingFang SC")
        self.add(t)

macOS用"PingFang SC"或"Songti SC",Linux用"Noto Sans CJK SC"。Windows用"Microsoft YaHei"。你需要在系统里装好这些字体。

坑3:Manim渲染速度极慢,别浪费时间去跑全量

我用的是-r 1280,720 --fps 30,渲染52帧花了32秒。调试阶段用-ql低质量模式manim render -ql,速度快5倍以上。确认动画逻辑没问题,再用高质量渲染最终版。别在调试阶段用1080p60fps,一次渲染10分钟起步。

坑4:Manim每次修改代码都要重新渲染

这是个大坑:你改一行代码,整个动画重新渲染一遍。所以第一步的instrumented设计很重要——把"数据的产生"和"动画的渲染"彻底分离。你只改Python脚本生成JSON,再把JSON喂给Manim脚本,就不需要重渲染已有部分。

坑5:Canvas在高DPI屏幕上模糊

MacBook的Retina屏下,canvas默认会被拉伸。解决办法是按devicePixelRatio缩放画布:

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);

不缩放的话,写800x600的canvas在Retina屏幕上会糊成一片。

坑6:Matplotlib Button事件只触发一次

这个坑很隐蔽。如果你用on_clicked绑定事件,但事件函数内部用了全局变量且没声明global,Python会创建一个局部变量,导致按钮点了没反应或者状态不更新。我的代码里global step是关键,漏了这行,步进永远停在第一步。

坑7:SQLite日期格式统一

我最早录数据时日期写的格式乱七八糟:'2024-3-5'、'20240305'、'2024/03/05'都有。SQLite的strftime('%W', learn_date)只认YYYY-MM-DD。最后用一条SQL清洗:

UPDATE study_log SET learn_date = substr(learn_date, 1, 4) || '-' ||
    printf('%02d', CAST(substr(learn_date, 6, 2) AS INTEGER)) || '-' ||
    printf('%02d', CAST(substr(learn_date, 9, 2) AS INTEGER));

不如一开始就统一格式。

一点补充:UI/UX层面的设计思路

写这个环境时我有一个原则:每一次状态变化都要有颜色变化或文字标注。单调的颜色切换会让人脑自动忽略,但配合"递归深度3,访问节点值43"这样的描述,注意力和长期记忆都会更好。

后来我在Colab上把同样的思路复现了一次,把动画输出成GIF嵌入到Jupyter Notebook里。Manim渲染MP4导出GIF:

ffmpeg -i MergeSortAnimation.mp4 -vf "fps=15,scale=640:-1" merged.gif

这个命令在Linux/macOS上跑通。Windows需要给ffmpeg加路径。GIF体积小,方便分享给同学或者在手机上看。

总结

数据结构和算法可视化的终极形态,不是看别人的动画,而是自己写代码生成自己算法的动画。你亲手生成的每一帧都是调试过程的副产品,比任何现成动画都更有学习价值。

这套环境的代码全部在我的GitHub仓库algo-visual-lab里,所有代码在这个博客里都能复制。安装依赖只需要:pip install manim==0.18.1 matplotlib==3.8.3,和系统自带的SQLite、浏览器。

学算法没有捷径,但有坑可绕。