
1. 项目背景为什么我会造出“ACehomoxue”这个东西先交代一下来龙去脉。我在过去一年里高强度刷算法题前后刷了快四百道但很快就发现一个让人非常沮丧的问题同样的知识点换个包装我照样卡壳。比如二叉树的中序遍历我会写递归版本但换成一题“二叉搜索树转双向链表”我愣是想不起来这其实是同一个东西。更糟的是我记笔记的方式也很原始——每道题一个 Markdown 文件按来源平台命名时间一长整个文件夹就像一个塞满杂物的仓库要找“所有跟双指针有关的题”只能靠肉眼一页页翻。那阵子我正好在琢磨“同构”这个概念。数学里讲的同构是指两个结构在某种对应关系下等价放在做题场景里就是表面上完全不同、底层解法逻辑却高度一致的问题。如果我能把自己做过的题按照知识点和解题模式自动聚类把“披着三张皮的同一种题”归到同一个簇里那复习效率不就高多了于是就有了 ACehomoxue。这个项目名分三段看Ace是想表达“攻克、拿下”的劲头homo取自homogeneous强调同类聚合xue就是“学”的拼音。合在一起大概可以翻译成“用同构聚合的方式打造王牌学习库”。名字是我随手拼的叫起来有点怪但反而很好记代码仓库里用它做项目代号也不会和现有的工具重名。这个项目的定位很明确一个本地优先的、面向个人算法复习的题目聚合工具。它不上服务器、不做多人协同、不搞花哨的可视化只做三件事——录入题目的关键信息把信息转成向量算出题与题之间的相似度然后告诉我哪些题其实是一家人。适合谁用适合那些刷题之后会回头复习的人适合笔记混乱到想重建体系的人也适合想搞明白“向量化到底能怎么落到日常工具里”的开发者。这不是一个商业产品更像是我给自己的学习流程做的一次手术。2. 技术选型不折腾框架先让逻辑通起来动工之前我先把技术方案捋了一遍。选项其实很多但我刻意做了一个“减法版”的选型原因很简单这种工具的核心价值在相似度计算逻辑不在框架表演。2.1 为什么用 Python 而不是 Node.js 或 Go写这种偏数据处理的小工具Python 是最省事的。numpy 提供向量运算内置的 sqlite3 零依赖就能操作数据库typer 和 rich 两个库加起来就能把命令行体验做得像模像样。Go 和 Node 当然也能做但它们的优势主要体现在高并发和生态上我这个场景完全用不到。项目跑在一台普通的 MacBook 上数据库里最多几千条题目记录任何语言在性能上都没有瓶颈选型唯一的标准就是个人效率。实际开发中我确实没走弯路Python 3.11 的环境里核心模块一共就四个文件前后写了不到两天首版就能正常跑通全流程。如果是用 Go 重写光结构体定义和数据库驱动可能就要多花一倍时间。2.2 数据存哪SQLite 是最不容易后悔的选择我在 JSON 文件和 SQLite 之间犹豫了十分钟。JSON 看着简单读文件、改文件、再写回去好像也够了。但一旦数据量上来你会发现两个问题一是每次都要把整个文件加载到内存数据到几千条的时候明显变慢二是没有事务万一写到一半程序崩了整个笔记库就废了。SQLite 就没这些毛病。它是单文件数据库备份只要复制一个.db文件支持事务安全性有保障更重要的是我能用 SQL 直接做很多统计——比如“哪个知识点标签出现次数最多”“哪类难度的题相似度最高”。这些查询如果用 JSON就得自己写一堆 Python 逻辑纯属浪费时间。2.3 相似度计算为什么是向量加余弦计算题目相似度最简单粗暴的方案是做标签重叠度两题的标签重合数除以总标签数。这个方案我试过效果很差。举个实际例子一道“两数之和”和一道“三数之和”标签分别是[数组, 哈希表]和[数组, 双指针, 排序]标签只有“数组”一个交集重叠度 0.2算法会认为它们关系很弱。但做过题的人都知道这两题的思考路径其实高度相关——先确定用哈希还是双指针再考虑去重和边界条件。向量加余弦相似度的做法是把每道题变成一个高维向量然后看两个向量的夹角有多小。这个方案的妙处在于它不要求标签完全一致只要两个向量在方向上足够接近哪怕它们没有共享任何标签也能给出高分。我把难度和题型也编码进向量里进一步放大了“同类题”的信号。2.4 项目结构一览最终的项目目录长这样acehomoxue/ ├── cli.py # 命令行入口 ├── db.py # 数据库表结构与基本操作 ├── feature.py # 向量化构建 ├── cluster.py # 相似度计算与聚类 ├── data/ │ └── ace.db # SQLite 数据库文件 └── seeds.json # 初始种子数据没有复杂的包管理没有前后端分离没有 Dockerfile。你把它 clone 下来就能跑这一点恰恰是我最满意的。工具本身是给人用的不是用来展示工程能力的。3. 核心细节解析数据库设计、向量化和相似度计算3.1 数据库表结构设计我建了四张表question存题目主信息tag存标签note存我自己的复习笔记relation存题与题之间的相似度结果。建表语句如下CREATE TABLE IF NOT EXISTS question ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, source TEXT DEFAULT , difficulty INTEGER DEFAULT 1, content TEXT DEFAULT , created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, name TEXT NOT NULL, category TEXT DEFAULT knowledge ); CREATE TABLE IF NOT EXISTS note ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS relation ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, related_id INTEGER NOT NULL, score REAL NOT NULL, relation_type TEXT DEFAULT homology );难度字段我用了整数1 代表简单2 代表中等3 代表困难。source字段记录题目来源平台方便之后按来源筛选。标签表里的category字段是用来区分标签类别的比如knowledge表示知识点数组、哈希表、algorithm表示算法范式双指针、回溯、datastructure表示数据结构链表、二叉树。区分这些类别是为了后面做加权向量时能给不同类别的标签不同的权重。3.2 向量化思路向量化的关键是把“题目的特征”压缩成一个等长的数字列表。我用的是最朴素的 One-Hot 加权法从标签表里取出所有唯一的标签名按字典序排成一个固定词汇表。对每一道题初始化一个长度等于词汇表大小的全零向量。循环这道题的所有标签在词汇表对应位置把值加 1。额外追加 3 个维度表示难度再追加 1 个维度表示是否来自高频来源。这个方案的优点是好理解、好调试缺点也很明显——稀疏。几千条标签里面一道题可能只有四五个标签向量绝大多数位置都是 0。不过实际使用下来这个缺点对余弦相似度的影响并没有想象中那么大因为稀疏向量之间算余弦只要同标签的数量一多分数立刻能拉开差距。真正的坑不在稀疏而在标签噪声这个我在后面的问题排查章节会细说。核心的向量化代码只有十几行import sqlite3 from collections import defaultdict def build_tag_vocab(): db sqlite3.connect(data/ace.db) tags [r[0] for r in db.execute(SELECT DISTINCT name FROM tag)] db.close() return sorted(tags) def build_question_vector(qid, vocab): db sqlite3.connect(data/ace.db) tag_list [r[0] for r in db.execute( SELECT name FROM tag WHERE question_id?, (qid,))] difficulty db.execute( SELECT difficulty FROM question WHERE id?, (qid,)).fetchone()[0] db.close() vec [0.0] * len(vocab) for t in tag_list: if t in vocab: vec[vocab.index(t)] 1.0 diff_onehot [0.0, 0.0, 0.0] diff_onehot[difficulty - 1] 1.0 return vec diff_onehot标签在词汇表里的位置我每次用vocab.index(t)现查数据量小的时候没问题但如果你导入了上千道题建议改成字典映射一次查询从 O(n) 变成 O(1)。3.3 余弦相似度与预计算有了向量相似度就是一个标准余弦公式两个向量的点积除以两个向量的模长乘积。结果在 0 到 1 之间越接近 1 表示方向越一致。我抽了一段实现import math import sqlite3 def cosine_sim(a, b): dot sum(x*y for x, y in zip(a, b)) norm_a math.sqrt(sum(x*x for x in a)) norm_b math.sqrt(sum(y*y for y in b)) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def precompute_relations(threshold0.45): db sqlite3.connect(data/ace.db) vocab build_tag_vocab() qids [r[0] for r in db.execute(SELECT id FROM question)] vectors {qid: build_question_vector(qid, vocab) for qid in qids} db.execute(DELETE FROM relation) for i in range(len(qids)): for j in range(i 1, len(qids)): score cosine_sim(vectors[qids[i]], vectors[qids[j]]) if score threshold: db.execute( INSERT INTO relation (question_id, related_id, score) VALUES (?,?,?), (qids[i], qids[j], round(score, 4))) db.commit() db.close()关于阈值为什么选 0.45我测试过几组数据阈值低于 0.3 会把“数组题”和“动态规划题”都连到一起出现大杂烩簇高于 0.6 则过于严格很多一眼看得出同源的题会被分开。0.45 到 0.55 这个区间对于标签向量来说比较稳。当然这个数字不是一个理论最优值它和数据分布强相关最好还是根据自己题库的实际效果微调。4. 实操过程与关键功能实现4.1 环境准备与项目初始化先把环境搭起来。我用的是 Python 3.11依赖库只有typer、rich、numpy三个前两个负责命令行和输出美化第三个用来加速向量运算其实不装也能跑纯属我写顺手了。mkdir acehomoxue cd acehomoxue python -m venv venv source venv/bin/activate pip install typer rich numpy然后把上面的建表语句写进db.py再写一个init命令import sqlite3 from pathlib import Path def init_db(): Path(data).mkdir(exist_okTrue) db sqlite3.connect(data/ace.db) db.executescript( 建表语句放这里 ) db.commit() db.close()运行python cli.py init之后项目目录下就会生成data/ace.db。4.2 用种子数据快速验证我不想手动一条条录入就准备了一个seeds.json里面放十道经典题目覆盖数组、链表、二叉树、动态规划、回溯五类。这里贴出部分数据[ { title: 两数之和, source: LeetCode, difficulty: 1, tags: [数组, 哈希表] }, { title: 三数之和, source: LeetCode, difficulty: 2, tags: [数组, 双指针, 排序] }, { title: 反转链表, source: LeetCode, difficulty: 1, tags: [链表, 递归] }, { title: 二叉树的最大深度, source: LeetCode, difficulty: 1, tags: [二叉树, DFS] }, { title: 爬楼梯, source: LeetCode, difficulty: 1, tags: [动态规划, 记忆化] } ]保存好之后命令行里加一个seed命令读 JSON 依次写入数据库。录入之后立刻跑一遍聚类我预期“两数之和”和“三数之和”应该有不错的相似度“反转链表”则应该和其他树形题区分开。4.3 核心命令query 查单题第一个实用命令是query传入题目 ID 后命令行会展示这道题的基本信息以及按相似度排序的前 10 个相关题。输出长这样$ python cli.py query --id 1 题目 两数之和 难度 简单 标签 数组, 哈希表 来源 LeetCode 相关题相似度 0.45 三数之和 相似度 0.58 [数组, 哈希表, 双指针] 四数之和 相似度 0.63 [数组, 哈希表, 双指针]这个功能的实现重点在查询逻辑先取出目标题的向量再遍历relation表里凡是question_id等于目标 ID 的关联记录用 JOIN 连出对应题目的标题和标签。4.4 核心命令cluster 全局聚类query只能看单一题目的周边关系cluster才是真正的全局聚合。它做的事情是把题看成图的节点把相似度超过阈值的题用线连起来然后用并查集把连通的节点归成一个簇。每个簇内部的题互相之间都有关系路径不要求两两直接相连这能避免漏掉那些通过中间题传递的相似性。$ python cli.py cluster --threshold 0.45 共 10 道题聚类结果如下 簇 14 题 两数之和 / 三数之和 / 四数之和 / 最接近的三数之和 主题数组 哈希 / 双指针 簇 23 题 反转链表 / 合并两个有序链表 / 两两交换链表中的节点 主题链表 递归 簇 32 题 爬楼梯 / 零钱兑换 主题动态规划 孤立题目 二叉树的最大深度 验证二叉搜索树并查集实现很简单几十行代码就够class UnionFind: def __init__(self, n): self.parent list(range(n)) def find(self, x): while self.parent[x] ! x: self.parent[x] self.parent[self.parent[x]] x self.parent[x] return x def union(self, a, b): ra, rb self.find(a), self.find(b) if ra ! rb: self.parent[ra] rb然后对题目 ID 列表做双重循环分数超过阈值就执行union。这里有个实操经验不要试图让聚类结果一次就完美。第一次跑出来簇内可能混进了某道不和谐的题比如“验证二叉搜索树”因为标签里带了个“二叉树”就和“二叉树的最大深度”挂到一起了。但仔细想想它们一个考 BST 的中序单调性一个考递归高度其实不该进同一簇。解决办法不是改阈值而是给它补一个“BST”标签同时把“二叉树”这个标签改成更细的分类让特征更精确。4.5 核心命令note 与导出光有聚类还不够我还需要复习的时候能快速看到曾经的思考过程。所以加了一个note命令往关联表写入个人笔记$ python cli.py note --id 1 --content 关键是补一个虚拟头节点反转时注意不能让链表断掉接着是export命令把聚类结果和笔记导出成一个 Markdown 笔记文件方便放进思源笔记或者 Obsidian 里做二次整理。导出的格式很简单每个簇一个二级标题簇里的每道题列出标题、来源、我的笔记和相关题目。Markdown 导出其实是最实用的功能我经常在复习完一个簇之后把导出的内容整体搬到自己的笔记库里形成一个“专题式”的复习卡片。4.6 推荐功能recommend 每日一练最后加了一个小功能每天从相似度聚类结果里随机挑一个簇再从簇里挑一道做过的题提示我今天该复习这个知识点。这有点像用算法对抗遗忘曲线$ python cli.py recommend 今日推荐复习簇 动态规划 包含题目爬楼梯已做、零钱兑换已做 建议先五秒写出爬楼梯的状态转移方程再尝试用相同套路解零钱兑换。这个功能不复杂但对日常学习习惯的养成帮助很大。它会把注意力从“我今天该学什么”这种纠结里拉出来直接给出一个可操作的目标。5. 常见问题与排查技巧这个工具看起来简单真正用起来还是会遇到不少问题。我把调试过程中踩过的坑列成一张表越靠前的越容易遇到。5.1 高频问题速查表表现原因排查方法解决方案所有相似度都是 0标签词汇表构建失败或者标签列表为空先运行一个 SQL 查询统计 tag 表的行数确认build_tag_vocab里查询语句正确重新执行seed相似度普遍偏低标签太稀疏一道题只有一两个标签打印几个样本题的向量和标签列表给题目补充更丰富的知识点标签相似度普遍偏高标签太宽泛比如每题都有一堆“算法”这种大词统计高频标签删掉“算法”“经典”这类缺乏区分度的标签聚类结果把完全无关的题连在一起阈值太低图形成过多桥梁观察簇的大小和跨簇边界的分数提高阈值到 0.5 重新测试SQLite 报 database is locked多个命令行同时操作数据库检查是否后台还挂着未关闭的进程每次连接数据库后必须 close必要时用check_same_threadFalse中文标签出现乱码Python 文件编码格式问题查看终端输出文件头加# -*- coding: utf-8 -*-确保控制台支持 UTF-85.2 稀疏向量引发的误判我实际使用中最大的坑就是稀疏向量。第一次导入 50 道题后我跑cluster发现“两数之和”和“爬楼梯”的相似度竟然高达 0.6当时的标签是这样的前者是[数组, 哈希表]后者是[动态规划, 记忆化]。没有任何重叠标签哪来的高分后来一查发现两道题的难度都是 1我在向量尾部追加的难度维度是三个 one-hot 位它们相同而标签长度又太短导致难度维度在余弦计算里占比太高。这暴露了一个设计问题one-hot 向量的不同维度之间在相似度计算中天然是平等的但它们的语义重要性完全不同。解决思路有两个一是把高区分度的标签维度放大权重比如数据结构和算法范式的权重设为 2难度和来源权重设为 0.5二是对向量做归一化处理后再按权重缩放。我最终用的是前者原因是实现简单调试时一眼就能看出权重的影响。5.3 冷启动问题新工具刚上手时库里只有十几道题聚类出来的簇特别碎不太有参考价值。这个时期我建议只做录入不要急着看聚类结果。至少积累到四五十题cluster的输出才有统计意义否则两个簇各只有两三道题很容易让人误判工具效果不好。5.4 标签命名规范用脏数据比没数据更可怕。我一开始图省事标签里既有“二叉树”又有“BinaryTree”还有“二叉树/递归”一个最朴素的去重词汇表直接被干出三种不同维度相似度计算直接失真。后来我在seed命令里加了标签清洗逻辑统一走一个小词典做映射“二叉树”和“BinaryTree”都归一化到“二叉树”“动态规划”和“DP”也都归一到“动态规划”。这属于数据治理的活听起来不酷但能决定整个项目的成败。5.5 备份与迁移数据库文件本身就一个data/ace.db我养成了定期复制到网盘的习惯。迁移到新电脑只要环境装好依赖把数据库文件放回原目录就能无缝使用。如果你想彻底重来直接删除data目录再跑一次init即可不会留下任何残留。6. 一点心得和后续扩展这个项目从有想法到第一版能用前后花了两天但它给我省下的复习时间远远不止两天。最大的收益还不是“自动聚类”本身而是它逼着我规范了记录格式。以前我记笔记随心所欲想到什么写什么现在每次录入一道题都必须想清楚它涉及哪些知识点、属于什么算法范式、应该贴哪些标签。这个过程本身就是一次高质量的复习。另一个心得是工具的价值取决于你愿不愿意喂它数据。我并不是一次性把四百道题全部导进去而是坚持每天刷完新题后顺手录入两分钟的事积少成多。一个月后cluster的结果已经开始反映我的薄弱环节——动态规划那个簇尤其大因为我在那类题上做的练习确实多标签也详细。后续我想做几个扩展。第一个是把向量化升级成 TF-IDF 权重让高频的宽泛标签比如“数组”自动降权让低频的精准标签比如“状态压缩”自动提权。第二个是接入本地大模型做自动打标签让录入变成只贴一个网址或标题剩下的交给模型。第三个是增加间隔复习提醒功能根据相似度分数分配复习频率——关联度越高的题越应该在同一轮复习里一起看。如果你也有同样的刷题笔记困扰与其花钱买各种花哨的刷题应用不如花一个周末自己写一个这种只有自己能完全理解的数据结构。毕竟它服务的不是大众而是你自己的思考方式。