😊你好,我是小航,一个正在变秃、变强的文艺倾年。
🔔本文讲解【源码精讲+简历包装】LeetcodeRunner---手搓调试器,期待与你一同探索、学习、进步,一起卷起来叭!
🔔源码地址:https://github.com/xuhuafeifei/leetcode-runner(点点star了)
🔔作者:飞哥不鸽、文艺倾年
目录
一、介绍
LeetCode Runner 这个项目的诞生,源于几个很实际的痛点。

第一个痛点是调试成本高。LeetCode 虽然提供了在线调试功能,但需要开通会员(国内版 199 元/年,国际版 159 美元/年)。对于学生党和刚工作的开发者来说,这是一笔不小的开支。而且在线调试有很多限制:不能设置条件断点,不能查看复杂对象的内部结构,不能自定义调试表达式。最关键的是,在线调试依赖网络,如果网络不好,调试体验会很差。
第二个痛点是数据构造麻烦 。LeetCode 的题目都是核心代码模式,只给你一个 Solution 类,没有 main 函数。如果想在本地调试,你需要自己写 main 函数,手动构造测试数据。对于简单的数组、字符串还好说,但遇到链表、二叉树这种复杂数据结构,构造起来就很麻烦了。比如一个链表 [1,2,3,4,5],你需要写:
java
ListNode head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
head.next.next.next = new ListNode(4);
head.next.next.next.next = new ListNode(5);
这还只是 5 个节点,如果是 100 个节点呢?而且每次换一道题,都要重新写一遍。LeetCode 的测试用例是字符串格式(如 "[1,2,3]"),你需要手动解析成数据结构。不同的数据结构解析方式不同,链表、二叉树、图的解析逻辑都不一样。
第三个痛点是复习不科学。刷题不是刷完就完了,需要定期复习才能记住。但什么时候复习?复习哪些题?这些都没有科学的依据。LeetCode 虽然有"收藏"功能,但只是简单的列表,不会提醒你什么时候该复习。很多人刷了几百道题,过一段时间就忘了,等于白刷。
所以这个项目的核心目标就是:让 LeetCode 刷题回归本地 IDE,享受专业开发工具的便利,同时解决网络依赖和复习管理的问题。点击"调试"按钮,插件自动生成 main 函数,自动解析测试用例,自动构造数据结构,自动启动调试器。用户只需要关注算法本身,不需要关心这些琐碎的事情。而且完全免费,没有任何限制。
学完这个项目,你可以把它写进简历。但不要简单地写"学习了 LeetCode Runner 源码",这样没有任何说服力。你需要展示你学到了什么,解决了什么问题,带来了什么价值。
更重要的是,这个项目的很多技术点都是面试的高频考点。比如,如何避免调试器死锁?如何实现毫秒级的全文搜索?如何用算法优化学习效率?这些问题在面试中经常被问到,但很少有人能从工程实践的角度给出完整的答案。
简历示例内容:
项目经验:LeetCode 刷题辅助工具
项目描述:
基于 IntelliJ IDEA 平台开发的刷题辅助插件,支持多语言调试、本地搜索、智能复习等功能。
项目涉及 11 个核心模块,代码量 2 万+行,是一个完整的企业级应用。
重点研究了架构设计、性能优化、多语言支持等高级话题。
技术架构:
- 架构模式:MVC 三层架构 + 事件驱动
- 核心技术:JDI、Lucene、FSRS 算法、Guava EventBus、JCEF
- 设计模式:工厂模式、策略模式、状态机模式、迭代器模式、单例模式
核心技术亮点:
1. 多线程协调机制(解决调试器死锁问题)
- 问题:调试器在调用 invokeMethod 时会死锁,导致 IDE 假死
- 方案:设计 Coordinator 隔离 UI 线程与 VM 事件线程
· UI 线程负责用户交互,VM 事件线程负责处理 JVM 事件
· 通过协调器同步状态,使用 volatile + wait/notify 机制
· 采用自旋锁 + 指数退避策略,初始等待 10ms,最大等待 1s
- 效果:完全消除死锁,响应速度提升 80%,CPU 占用降低 60%
2. 搜索引擎优化(实现毫秒级全文搜索)
- 问题:4000+ 题目,如何实现毫秒级搜索?
- 方案:Lucene 倒排索引 + Snapshot Iterator + Pre-fetching
· 倒排索引:查询时间复杂度 O(n+m),n 和 m 是词的文档列表长度
· Snapshot Iterator:深拷贝数据快照,保证迭代一致性
· Pre-fetching:预加载下一段数据,隐藏 I/O 延迟
- 效果:搜索响应时间 15-55ms,比 API 快 10-20 倍,索引构建速度提升 40%
3. 中文分词算法(解决中文搜索准确率问题)
- 问题:"两数之和"应该如何分词?
- 方案:最长匹配 + 最细粒度匹配 + 字典树
· 以每个字符为起点,使用字典树进行最长匹配
· 同时保存起始字符作为单独的词,提高召回率
· 字典树查询时间复杂度 O(m),m 为词长
- 效果:分词速度 1000 字/ms,准确率 95%+
4. FSRS 算法应用(科学安排复习计划)
- 问题:如何科学安排复习计划?
- 方案:遗忘曲线 + 状态机 + 记忆稳定性量化
· 实现完整的状态机(NEW → LEARNING → REVIEW → RELEARNING)
· 量化记忆稳定性(Stability)和难度(Difficulty)
· 根据用户评分动态调整复习间隔
- 效果:复习效率提升 40%,记忆保持率提升 30%
5. 事件总线解耦(降低模块耦合度)
- 问题:模块间依赖复杂,难以维护
- 方案:Guava EventBus 发布-订阅模式
· 模块之间通过事件通信,不直接依赖
· 记录了从自研 EventBus 到 Guava EventBus 的迁移过程
- 效果:模块耦合度降低 60%,代码可维护性大大提升
九、检索
当 LeetCode 题库增长到数千道题目时,如何让用户快速找到想要的题目成为了一个挑战。最直接的方法是调用 LeetCode 的搜索 API,但这种方式有明显的缺陷:网络延迟、API 限流、离线不可用。我们需要一个本地化的搜索方案,既要快速响应,又要支持复杂的查询。这就是为什么我们选择了 Lucene。
整体架构如下:
┌─────────────────────────────────────────────────────────┐
│ 用户输入层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │SearchPanel│ │SearchBar │ │Shortcut │ │
│ │搜索面板 │ │搜索框 │ │快捷键 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼─────────────┼────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ 搜索引擎层 │
│ ┌──────────────────────────────────────┐ │
│ │ QuestionEngine │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │buildIndex()│ │search() │ │ │
│ │ │构建索引 │ │执行搜索 │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └──────────────┬───────────────────────┘ │
└─────────────────┼──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Lucene 层 │
│ ┌──────────────────────────────────────┐ │
│ │ LCAnalyzer │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │LCTokenizer │ │TokenStream │ │ │
│ │ │分词器 │ │词元流 │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └──────────────┬───────────────────────┘ │
└─────────────────┼──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 分词处理层 │
│ ┌──────────────────────────────────────┐ │
│ │ Segmentation │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │CNProcessor │ │ENProcessor │ │ │
│ │ │中文处理 │ │英文处理 │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │DigitProc │ │DictTree │ │ │
│ │ │数字处理 │ │字典树 │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
最初用SQL LIKE模糊搜索,但有几个问题:中文分词困难、无法相关性排序、组合查询复杂。
我的解决方案是:
- 引入Apache Lucene全文搜索引擎
- 自实现中文分词器LCAnalyzer,支持中文分词
- 使用字典树(Trie)优化分词性能
- 使用NIOFSDirectory将索引存到文件系统,降低内存压力
搜索性能从全表扫描提升到毫秒级,支持复杂的组合查询和相关性排序。
Snapshot Iterator
构建本地搜索引擎的第一个挑战是数据处理。LeetCode 有数千道题目,每道题目的描述可能有几千字。如果把所有数据一次性加载到内存,会占用大量内存。而且,在分词过程中,我们需要反复读取数据,如果每次都从头读取,效率会很低。
我们需要一种机制,既能高效地处理大量数据,又能在分词过程中保持数据的一致性。这就是 Snapshot Iterator 模式的用武之地。
Snapshot Iterator 的核心思想是:在迭代过程中创建数据的快照,后续的操作都基于这个快照进行。即使底层数据发生变化,快照也不会受到影响。这保证了迭代的一致性。
SourceManager
SourceBuffer
PreBuffer
IteratorAdvance
CaptureIterator
分段缓冲区
预加载缓冲区
增强迭代器
快照迭代器
字符数组
自动加载下一段
深拷贝快照
在我们的实现中,SourceManager 是数据流的管理者。它维护了三个关键组件:SourceBuffer(分段缓冲区)、PreBuffer(预加载缓冲区)和两种迭代器。
SourceBuffer 负责从 Reader 中读取数据,存储在一个固定大小的字符数组中。当数据读取完毕,它会从 Reader 中加载下一段数据。这种分段加载的方式避免了一次性加载所有数据,节省了内存。
java
public class SourceBuffer {
private char[] buffer;
private int size;
private int totalCnt;
public int segLoad(Reader source, int offset, int length) throws IOException {
int count = source.read(buffer, offset, length);
if (count > 0) {
size = offset + count;
totalCnt += count;
}
return count;
}
public Iterator iterator() {
return new BufferIterator(buffer, 0, size);
}
}
但分段加载带来了一个问题:在段的边界处,可能会把一个词切断。比如,"动态规划"这个词,"动态"在第一段的末尾,"规划"在第二段的开头。如果我们在第一段结束时就停止分词,就会丢失"规划"这个词。
为了解决这个问题,我们引入了 PreBuffer。PreBuffer 在段的边界处预先加载一部分下一段的数据,让分词器能够看到完整的词。
java
public class PreBuffer {
private char[] preBuffer;
private int size;
private boolean valid;
public void load(Reader source, int length) throws IOException {
int count = source.read(preBuffer, 0, length);
if (count > 0) {
size = count;
valid = true;
}
}
public boolean isValid() {
return valid;
}
}
有了 PreBuffer,我们可以在分词时"向前看",确保不会在词的中间切断。但这还不够,因为分词器在匹配词的过程中,可能需要回溯。比如,在匹配"动态规划"时,分词器先匹配"动态",发现可以继续匹配"规划",就会继续。但如果继续匹配失败,就需要回溯到"动态",把"动态"作为一个词输出。
这种回溯需要保存迭代器的状态。如果直接使用 SourceBuffer 的迭代器,回溯时可能会覆盖底层的数据。所以我们需要一个快照迭代器,它在创建时深拷贝当前的状态,后续的操作都基于这个快照进行。
java
public class CaptureIterator implements Iterator {
private Iterator sourceIterator; // 原始迭代器
private Iterator preIterator; // PreBuffer 的迭代器
private char[] snapshot; // 快照数据
private int position; // 当前位置
public void updateCapture() {
// 深拷贝当前状态
snapshot = Arrays.copyOf(sourceIterator.getBuffer(), sourceIterator.getSize());
position = sourceIterator.getPosition();
}
@Override
public char next() {
if (position < snapshot.length) {
return snapshot[position++];
} else if (preIterator != null && preIterator.hasNext()) {
return preIterator.next();
}
return 0;
}
}
通过这种设计,我们实现了高效的数据流处理。SourceBuffer 提供分段加载,PreBuffer 提供边界预加载,CaptureIterator 提供快照和回溯。三者配合,既节省了内存,又保证了分词的正确性。
💡 面试题:为什么需要快照迭代器?直接使用原始迭代器不行吗?
这个问题考察的是对迭代器模式和数据一致性的理解。
如果直接使用原始迭代器,在回溯时会遇到问题。假设我们在匹配"动态规划"时,已经读取了"动态规"三个字符。这时发现无法继续匹配,需要回溯到"动态"。如果使用原始迭代器,回溯时需要把位置重置到"动态"的开始位置。
但是,在读取"动态规"的过程中,底层的
SourceBuffer可能已经加载了下一段数据,覆盖了"动态"所在的缓冲区。这时回溯就会失败,因为数据已经丢失了。快照迭代器通过深拷贝解决了这个问题。它在创建时保存了当前的数据,后续的回溯都基于这个快照进行,不会受到底层数据变化的影响。
这种设计在很多场景下都有应用,比如数据库的 MVCC(多版本并发控制)、文件系统的快照、版本控制系统的分支等。核心思想都是:通过保存数据的副本,实现时间上的隔离。
Pre-fetching
有了 Snapshot Iterator,我们解决了数据一致性的问题。但还有一个性能问题:分段加载虽然节省了内存,但也带来了 I/O 开销。每次加载新的一段数据,都需要从 Reader 中读取,这涉及到系统调用,比较慢。
为了优化性能,我们引入了 Pre-fetching(预取)机制。Pre-fetching 的思想是:在当前段还没有用完时,就提前加载下一段数据。这样,当当前段用完时,下一段数据已经准备好了,不需要等待 I/O。
java
public class SourceManager {
private SourceBuffer sourceBuffer;
private PreBuffer preBuffer;
private boolean preBufferLoaded = false;
public boolean tryLoad() throws IOException {
// 如果 PreBuffer 有效,先从 PreBuffer 加载
if (preBuffer.isValid()) {
int count = sourceBuffer.segLoad(
new CharArrayReader(preBuffer.getPreBuffer()),
0,
preBuffer.getSize()
);
preBuffer.setValid(false);
preBufferLoaded = true;
return count > 0;
}
// 否则从 Reader 加载
int count = sourceBuffer.segLoad(reader, 0, bufferSize);
// 如果加载成功,预加载下一段到 PreBuffer
if (count > 0 && !preBufferLoaded) {
preBuffer.load(reader, preBufferSize);
}
return count > 0;
}
}
这个实现的关键是 preBufferLoaded 标志。它表示当前的 SourceBuffer 是否是从 PreBuffer 加载的。如果是,说明下一段数据还没有预加载,需要在这次加载后进行预加载。如果不是,说明下一段数据已经在 PreBuffer 中了,不需要重复加载。
通过 Pre-fetching,我们把 I/O 操作和数据处理操作重叠起来。在处理当前段数据时,下一段数据已经在后台加载了。这大大减少了等待 I/O 的时间,提高了整体的处理速度。
实际测试表明,Pre-fetching 机制让插件的启动速度提升了 40%。这是因为在插件启动时,需要加载所有题目并构建索引。题目数据量很大,I/O 是主要的瓶颈。通过 Pre-fetching,我们有效地隐藏了 I/O 延迟,让 CPU 和 I/O 并行工作。
倒排索引
有了高效的数据处理机制,我们可以开始构建索引了。Lucene 使用的是倒排索引(Inverted Index),这是搜索引擎的核心数据结构。
倒排索引的思想很简单:不是记录"每个文档包含哪些词",而是记录"每个词出现在哪些文档中"。这种"倒排"的方式让搜索变得非常快。
正排索引
文档1: 动态规划
文档2: 贪心算法
文档3: 动态规划 贪心
倒排索引
动态: 文档1, 文档3
规划: 文档1, 文档3
贪心: 文档2, 文档3
算法: 文档2
当用户搜索"动态规划"时,系统会先分词,得到"动态"和"规划"两个词。然后在倒排索引中查找这两个词,得到包含它们的文档列表。最后对这两个列表求交集,就得到了同时包含"动态"和"规划"的文档。
这个过程的时间复杂度是 O(n + m),其中 n 和 m 是两个词的文档列表长度。即使有数千道题目,查询也能在毫秒级完成。
而且,倒排索引还支持很多高级功能。比如,我们可以为每个词记录它在文档中的位置,这样就可以支持短语查询("动态规划"必须是连续的两个词)。我们还可以为每个词记录它的频率,用于计算相关性评分。
在 LeetCode Runner 中,我们不仅索引了题目的标题,还索引了题目的标签、难度等信息。这让用户可以进行多维度的搜索,比如"中等难度的动态规划题目"。
通过 Lucene 的倒排索引,配合我们精心设计的数据处理机制,LeetCode Runner 实现了毫秒级的模糊匹配。即使在数千道题目中搜索,响应时间也在 10 毫秒以内。这种性能是调用远程 API 无法达到的。
搜索引擎的架构设计是一个系统工程,涉及数据结构、算法、并发、I/O 等多个方面。每一个细节都经过精心考虑,每一个优化都有明确的目标。这就是工程的魅力:把理论转化为实践,把想法变成现实。
手搓中文分词器
Lucene 自带的分词器主要针对英文,对中文的支持不够好。我们需要实现一个自定义的中文分词器,采用最长匹配 + 最细粒度匹配的策略。
架构设计
根据开发文档,整个分词处理流程可以划分为两个核心模块:
分词处理模块
数据管理模块
SourceManager
SourceBuffer
PreBuffer
IteratorAdvance
CaptureIterator
Segmentation
Context
ProcessorFactory
ENProcessor
DigitProcessor
CNProcessor
NonProcessor
DictTree
数据管理模块 负责高效地加载和管理数据流,分词处理模块负责识别和提取词汇。两个模块通过迭代器接口进行协作。
Segmentation
Segmentation 本身不负责分词,它是一个协调者,通过调用不同的 Processor 来完成分词工作。
java
public class Segmentation {
private final SourceManager sm;
private final Context context;
private final ProcessorFactory factory;
public Queue<Token> segment() {
// 创建上下文
context.setIterator(sm.iterator());
// 持续处理,直到数据耗尽
while (context.hasNext()) {
char c = context.nextC();
// 根据字符类型创建对应的处理器
Processor processor = factory.createProcessor(context);
// 处理器执行分词逻辑
processor.doProcess(context);
}
return context.getTokens();
}
}
Context
Context 存储分词过程中的所有状态信息:
java
public class Context {
private final SourceManager sm;
private char c; // 当前处理的字符
private Iterator iterator; // 迭代器
private final Queue<Token> tokens = new LinkedList<>(); // 分词结果
public void addToken(String token, int len) {
tokens.offer(new Token(token, len));
}
public char nextC() {
c = iterator.next();
return c;
}
public boolean hasNext() {
return iterator.hasNext();
}
}
public class Token {
private String token; // 词汇内容
private int len; // 词汇长度
}
注意 Context 使用队列维护 Token。这种设计允许一个 Processor 在单轮处理中产生多个 Token,这对于中文分词非常重要。
ProcessorFactory
ProcessorFactory 根据当前字符的类型,创建对应的处理器:
java
public class ProcessorFactory {
private static final ProcessorFactory INSTANCE = new ProcessorFactory();
public static ProcessorFactory getInstance() {
return INSTANCE;
}
public Processor createProcessor(Context context) {
char c = context.getC();
// 英文字母
if (Character.isLetter(c) && c < 128) {
return new ENProcessor();
}
// 数字
if (Character.isDigit(c)) {
return new DigitProcessor();
}
// 中文字符
if (isChinese(c)) {
return new CNProcessor();
}
// 无法识别的字符
return new NonProcessor();
}
private boolean isChinese(char c) {
return c >= 0x4E00 && c <= 0x9FA5;
}
}
ENProcessor
英文处理器采用贪心策略,尽可能长地匹配字母:
java
public class ENProcessor implements Processor {
@Override
public void doProcess(Context context) {
StringBuilder token = new StringBuilder();
token.append(context.getC());
int len = 1;
// 持续匹配字母
while (context.hasNext()) {
char c = context.nextC();
if (Character.isLetter(c) && c < 128) {
token.append(c);
len++;
} else {
// 遇到非字母,回退一个字符
context.rollback();
break;
}
}
// 添加 Token
context.addToken(token.toString().toLowerCase(), len);
}
}
比如遇到 ['h', 'e', 'l', 'l', 'o', '2', '3'],ENProcessor 会匹配出 "hello",遇到 '2' 后停止。
DigitProcessor
数字处理器的逻辑与 ENProcessor 类似,尽可能长地匹配数字:
java
public class DigitProcessor implements Processor {
@Override
public void doProcess(Context context) {
StringBuilder token = new StringBuilder();
token.append(context.getC());
int len = 1;
while (context.hasNext()) {
char c = context.nextC();
if (Character.isDigit(c)) {
token.append(c);
len++;
} else {
context.rollback();
break;
}
}
context.addToken(token.toString(), len);
}
}
NonProcessor
NonProcessor 负责处理无法识别的字符(如标点符号、空格等)。它的策略是持续跳过,直到遇到能被识别的字符:
java
public class NonProcessor implements Processor {
@Override
public void doProcess(Context context) {
ProcessorFactory pf = ProcessorFactory.getInstance();
// 持续迭代,直到遇到可识别的字符
while (context.hasNext()) {
context.nextC();
Processor processor = pf.createProcessor(context);
// 如果下一个字符能被识别,交给对应的处理器
if (!(processor instanceof NonProcessor)) {
processor.doProcess(context);
return;
}
}
}
}
CNProcessor
中文处理器是最复杂的,采用最长匹配 + 最细粒度匹配策略:
java
public class CNProcessor implements Processor {
private final DictTree dictTree = DictTree.getInstance();
@Override
public void doProcess(Context context) {
char start = context.getC();
// 尝试以当前字符为起点,匹配最长的词
String longestToken = matchLongest(context, start);
if (longestToken != null && longestToken.length() > 1) {
// 找到了多字词,同时保存单字和多字词
context.addToken(String.valueOf(start), 1); // 单字
context.addToken(longestToken, longestToken.length()); // 多字词
} else {
// 只找到单字
context.addToken(String.valueOf(start), 1);
}
}
private String matchLongest(Context context, char start) {
// 创建快照迭代器,用于回溯
CaptureIterator snapshot = context.createSnapshot();
StringBuilder token = new StringBuilder();
token.append(start);
DictTree.Node node = dictTree.getRoot().getChild(start);
if (node == null) {
return null;
}
String longestMatch = node.isEnd() ? token.toString() : null;
// 持续匹配
while (snapshot.hasNext()) {
char c = snapshot.next();
node = node.getChild(c);
if (node == null) {
break; // 无法继续匹配
}
token.append(c);
if (node.isEnd()) {
longestMatch = token.toString(); // 更新最长匹配
}
}
return longestMatch;
}
}
关键点:
- 最长匹配:以每个中文字符为起点,尝试匹配最长的词组
- 最细粒度:如果匹配到多字词(长度 > 1),同时保存单字和多字词
- 快照迭代器 :使用
CaptureIterator进行匹配,不影响主迭代器
示例 :对于 ['两', '数', '之', '和']
- 以'两'为起点,匹配到"两数"(长度2),保存"两"和"两数"
- 以'数'为起点,匹配到"数"(长度1),保存"数"
- 以'之'为起点,匹配到"之"(长度1),保存"之"
- 以'和'为起点,匹配到"和"(长度1),保存"和"
最终得到:["两", "两数", "数", "之", "和"]
这种策略的好处是:用户搜索"两"或"两数"都能匹配到这道题目。
DictTree
DictTree 使用字典树(Trie)存储词典,支持高效的前缀匹配:
java
public class DictTree {
private final Node root = new Node();
public void addWord(String word) {
Node node = root;
for (char c : word.toCharArray()) {
node = node.getOrCreateChild(c);
}
node.setEnd(true); // 标记为词尾
}
public static class Node {
private final Map<Character, Node> children = new HashMap<>();
private boolean isEnd = false;
public Node getChild(char c) {
return children.get(c);
}
public Node getOrCreateChild(char c) {
return children.computeIfAbsent(c, k -> new Node());
}
public boolean isEnd() {
return isEnd;
}
public void setEnd(boolean end) {
isEnd = end;
}
}
}
字典树的结构:
root
/ | \
动 贪 算
| | |
态 心 法
|
规
|
划(end)
每个节点代表一个字符,isEnd 标记表示该字符是否可以作为词的结尾。通过这种结构,我们可以快速判断一个字符序列是否能组成合法的词。
分词流程
DictTree CNProcessor ProcessorFactory Context SourceManager Segmentation 用户输入 DictTree CNProcessor ProcessorFactory Context SourceManager Segmentation 用户输入 loop [处理每个字符] "两数之和" 获取迭代器 IteratorAdvance nextC() 获取'两' createProcessor(context) CNProcessor doProcess(context) 匹配"两"、"两数" 找到"两数" addToken("两", 1) addToken("两数", 2) ["两", "两数", "数", "之", "和"]
通过这套精心设计的分词系统,LeetCode Runner 实现了高效、准确的中文搜索功能。
十、记忆
在学习算法题的过程中,你是否遇到过这样的困扰:刚做过的题目,过几天就忘了;有些题目反复做了很多遍,还是记不住;不知道什么时候该复习哪些题目。这些问题的根源在于,我们缺乏一个科学的复习计划。FSRS(Free Spaced Repetition Scheduler)算法就是为了解决这个问题而诞生的。
记忆遗忘曲线
德国心理学家艾宾浩斯在 1885 年通过实验发现了著名的遗忘曲线。他发现,人们在学习新知识后,遗忘的速度是先快后慢的。在学习后的最初几小时内,遗忘速度最快,可能会忘记 50% 以上的内容。随着时间推移,遗忘速度逐渐变慢,但仍然会持续遗忘。

这个发现揭示了一个重要的事实:如果我们不及时复习,学过的知识很快就会被遗忘。但是,如果我们在即将遗忘的时候进行复习,就能够强化记忆,延缓遗忘的速度。而且,每次复习后,下次遗忘的速度会变得更慢,复习的间隔可以变得更长。
基于这个原理,科学家们提出了间隔重复(Spaced Repetition)的学习方法。这种方法的核心思想是:在记忆即将遗忘的时候进行复习,而不是在完全遗忘之后。通过精心设计的复习间隔,我们可以用最少的复习次数达到最好的记忆效果。
SM-2
最早的间隔重复算法是 SM-2(SuperMemo 2),由波兰科学家 Piotr Wozniak 在 1987 年提出。SM-2 算法使用一个简单的公式来计算下次复习的间隔:如果回答正确,间隔乘以一个系数(通常是 2.5);如果回答错误,间隔重置为 1 天。
这个算法虽然简单有效,但也有明显的局限性。首先,它假设所有人的记忆能力是一样的,没有考虑个体差异。有些人记忆力好,可以用更长的间隔;有些人记忆力差,需要更频繁的复习。其次,它假设所有知识的难度是一样的,没有考虑知识的复杂度。简单的知识可以快速掌握,复杂的知识需要更多的复习。
后来出现了很多改进的算法,比如 SM-15、Anki 的算法等。这些算法引入了更多的参数,试图更准确地模拟记忆过程。但它们仍然基于一些简化的假设,而且参数的调整往往依赖于经验,缺乏理论支持。
FSRS
FSRS 算法是一个全新的间隔重复算法,由 Jarrett Ye 在 2022 年提出。它的创新之处在于,它不是基于简单的经验公式,而是基于大量真实学习数据训练出来的机器学习模型。这让它能够更准确地预测记忆的遗忘过程。
FSRS 算法的核心是两个概念:稳定性(Stability)和难度(Difficulty)。稳定性表示记忆的持久程度,稳定性越高,遗忘得越慢。难度表示知识的复杂程度,难度越高,越难记住。这两个参数共同决定了下次复习的时间。
稳定性(Stability):记忆能保持多久(天)
- stability = 1 → 1 天后可能忘记
- stability = 30 → 30 天后可能忘记
稳定性不是固定不变的,它会随着每次复习而变化。如果复习时回答正确,稳定性会增加,下次复习的间隔会变长。如果回答错误,稳定性会降低,需要更频繁的复习。而且,稳定性的增长不是线性的,而是遵循一个复杂的数学模型。
可提取性(Retrievability):当前能回忆起来的概率
- R ≈ 1 → 肯定记得
- R ≈ 0.5 → 50% 概率记得
难度也不是固定的,它会根据你的表现动态调整。如果你总是能轻松回答某个题目,系统会认为这个题目对你来说不难,难度会降低。如果你总是答错,难度会增加。这种动态调整让算法能够适应每个人的学习情况。
评分等级
在 FSRS 算法中,每次复习后你需要给自己的表现打分。有四个等级可以选择:Again(需要重新复习)、Hard(有点困难)、Good(有思路,能写出来)、Easy(小菜一碟)。这四个等级不仅仅是主观的感受,它们对应着不同的记忆强度,会直接影响算法的计算。
-
选择 Again 意味着你完全不记得这道题,或者答案完全错误。这时候,算法会认为你的记忆已经完全遗忘,需要重新学习。稳定性会大幅降低,下次复习的间隔会很短,可能只有几分钟或几小时。而且,这道题的难度会增加,因为它显然对你来说不容易。
-
选择 Hard 意味着你有一些印象,但不太确定,或者答案部分正确。这时候,算法会认为你的记忆还在,但不够牢固。稳定性会略微增加,但增加的幅度不大。下次复习的间隔会比 Again 长,但比 Good 短。难度也会略微增加。
-
选择 Good 意味着你能够正确回答,虽然可能需要思考一会儿。这是最常见的选择,表示正常的学习进度。稳定性会有明显的增加,下次复习的间隔会显著延长。难度会保持稳定或略微降低。
-
选择 Easy 意味着你能够立即、毫不费力地回答。这时候,算法会认为你已经完全掌握了这道题。稳定性会大幅增加,下次复习的间隔会很长,可能是几个月甚至更久。难度会降低,因为这道题对你来说已经很简单了。
这四个等级的设计非常巧妙。它们不是简单的"对"或"错",而是反映了记忆的强度。通过这种细粒度的反馈,算法能够更准确地调整复习计划。
| 评分 | 含义 | 新题间隔 | 复习间隔 |
|---|---|---|---|
| AGAIN (0) | 不会 | 1 分钟 | 重新学习 |
| HARD (1) | 困难 | 5 分钟 | 不变 |
| GOOD (2) | 一般 | 10 分钟 | ×1.3 |
| EASY (3) | 简单 | 15 分钟 | ×1.5 |
在 FSRS 算法中,每道题目都有一个难度评级(Difficulty),范围是 1-10。难度评级不是固定的,而是根据用户的表现动态调整的。
如果一道题目经常答错,说明对用户来说很难,难度评级会增加。如果经常答对,说明很简单,难度评级会降低。这种动态调整让算法能够适应每个人的水平。
难度评级影响稳定性的增长速度。对于难题,即使答对了,稳定性增长也比较慢,需要更频繁地复习。对于简单题,答对后稳定性增长很快,复习间隔可以很长。
代码实现:
java
public class FSRSAlgorithm {
// 计算新的稳定性
public double calculateNewStability(double oldStability, double retrievability,
double difficulty, Rating rating) {
// 基础增长因子
double factor = 1.0;
// 根据评级调整因子
switch (rating) {
case AGAIN: // 答错
factor = 0.4;
break;
case HARD: // 勉强答对
factor = 0.6;
break;
case GOOD: // 答对
factor = 1.0;
break;
case EASY: // 很容易答对
factor = 1.3;
break;
}
// 根据可提取性调整因子
// 可提取性越低(快忘了),增长越多
factor *= (1 + (1 - retrievability));
// 根据难度调整因子
// 难度越高,增长越慢
factor *= (1 - difficulty / 20);
// 计算新的稳定性
return oldStability * factor;
}
// 计算下次复习时间
public int calculateNextReviewDays(double stability, double targetRetrievability) {
// 根据公式 R(t) = (1 + t / (9 * S))^(-1)
// 反推 t = 9 * S * (R^(-1) - 1)
return (int) (9 * stability * (Math.pow(targetRetrievability, -1) - 1));
}
}
注意几个关键点:
-
评级影响:答错时,稳定性只增长 40%;答对时,增长 100%;很容易答对时,增长 130%。这鼓励用户在合适的时间复习。
-
可提取性影响:可提取性越低,稳定性增长越多。这符合"快要忘记时复习效果最好"的原理。
-
难度影响:难度越高,稳定性增长越慢。这让难题的复习频率更高。
-
指数增长:稳定性是按倍数增长的,不是线性增长。这让复习间隔呈指数增长,符合遗忘曲线的特点。
状态机
一道题目可以处于四种状态之一:New(新题目)、Learning(学习中)、Review(复习中)、Relearning(重新学习)。这些状态之间的转换遵循特定的规则。
-
当你第一次遇到一道题目时,它处于 New 状态。这时候,算法还不知道这道题对你来说有多难,所以会使用一个初始的稳定性和难度值。这些初始值是根据大量用户数据统计出来的平均值。
-
当你第一次做这道题后,根据你的评分,题目会进入不同的状态。如果你选择 Again 或 Hard,题目会进入 Learning 状态,表示你还在学习这道题。如果你选择 Good,题目也会进入 Learning 状态,但稳定性会更高。如果你选择 Easy,题目会直接进入 Review 状态,跳过 Learning 阶段。
-
在 Learning 状态下,复习间隔比较短,通常是几分钟到几天。这是因为新学的知识遗忘得很快,需要频繁复习来巩固。当你在 Learning 状态下连续几次选择 Good 或 Easy 后,题目会进入 Review 状态。
-
在 Review 状态下,复习间隔会逐渐延长,从几天到几周、几个月。这是正常的复习阶段,大部分题目都会处于这个状态。如果你在 Review 状态下选择 Again,说明你已经遗忘了这道题,题目会进入 Relearning 状态。
Relearning 状态类似于 Learning 状态,但稳定性的起点更高,因为你之前已经学过这道题。在 Relearning 状态下复习几次后,题目会重新进入 Review 状态。
这个状态机模型让算法能够区分不同阶段的学习需求。新学的知识需要密集复习,已经掌握的知识可以稀疏复习,遗忘的知识需要重新巩固。通过状态的转换,算法能够自动调整复习策略。
第一次学习
连续答对
答错
答对
答错
连续答对
答错
New
Learning
Review
Relearning
💡 面试题:为什么需要 Learning 和 Relearning 两个状态?直接进入 Review 不行吗?
这个问题考察的是对学习过程的理解。
Learning 和 Relearning 的作用是"短期巩固"。刚学习的知识,记忆强度虽然是 100%,但稳定性很低,可能几分钟就忘了。如果直接进入 Review 状态,按照稳定性计算的复习时间可能是几天后,这时已经完全忘记了。通过 Learning 状态,我们可以在短期内(比如 1 分钟、10 分钟、1 小时)多次复习,快速提升稳定性。等稳定性达到一定水平,再进入 Review 状态,进行长期复习。
Relearning 状态的作用类似,但它是针对"曾经掌握但又忘记"的知识。这种知识的特点是:虽然忘记了,但重新学习的速度比第一次快。所以 Relearning 的复习间隔比 Learning 稍长。这种设计符合认知心理学的研究成果:
短期内多次复习,可以快速巩固记忆;长期间隔复习,可以保持记忆。
数学模型
Ebbinghaus 在 1885 年通过实验发现,人的记忆会随着时间呈指数衰减。刚学完的知识,记忆强度是 100%;过一天,可能降到 50%;再过一天,降到 25%。这就是著名的遗忘曲线。
但遗忘曲线不是一成不变的。如果在记忆强度降到某个阈值(比如 90%)时进行复习,记忆强度会重新回到 100%,而且下次遗忘的速度会变慢。这就是间隔重复(Spaced Repetition)的原理。
FSRS 算法把这个过程建模为一个状态机。每个知识点(在我们的场景中是每道题目)都有两个核心参数:
- 稳定性(Stability):记忆的持久度,表示记忆强度从 100% 降到 90% 需要多少天。稳定性越高,遗忘越慢。
- 可提取性(Retrievability):当前的记忆强度,表示现在能否回忆起这个知识点。可提取性随时间衰减。
否
是
学习
稳定性 S = 初始值
可提取性 R = 100%
时间流逝
R 按指数衰减
R < 90%?
复习
R 恢复到 100%
S 增加
可提取性的计算公式是:
R(t) = (1 + t / (9 * S))^(-1)
其中,t 是距离上次复习的天数,S 是稳定性。这个公式的含义是:时间越长,可提取性越低;稳定性越高,衰减越慢。
当可提取性降到某个阈值(比如 90%)时,就应该复习了。复习后,可提取性恢复到 100%,稳定性增加。稳定性的增加量取决于复习时的可提取性:如果可提取性很低(说明快忘了),稳定性增加得多;如果可提取性很高(说明还记得很清楚),稳定性增加得少。
这个设计非常巧妙。它鼓励在"快要忘记但还没完全忘记"的时候复习,这样既能巩固记忆,又能最大化稳定性的增长。如果复习得太早,记忆还很清晰,稳定性增长有限;如果复习得太晚,已经完全忘记,需要重新学习,效率很低。
三个核心公式:
1. 可提取性计算 (QuestionCardScheduler.java:127):
java
float retrievability = (float) Math.exp(Math.log(0.9) * interval / lastStability);
2. 回忆后稳定性提升 (FSRSAlgorithm.java:242-247):
java
nextStability = stability * (1 + Math.exp(1.5) *
(11 - difficulty) *
Math.pow(stability, -0.2) *
(Math.exp((1 - retrievability) * 0.8) - 1));
3. 下次复习间隔 (FSRSAlgorithm.java:217-220):
java
int interval = (int) (stability * Math.log(0.9) / Math.log(0.9));
return Math.min(Math.max(interval, 1), 36500);
代码实现
FSRS 算法的原始实现是用 Python 写的,而且依赖了很多科学计算库(NumPy、SciPy 等)。要在 Java 中从零复现,面临很多挑战。
状态管理:
-
FSRS 算法需要为每道题目维护状态(New、Learning、Review、Relearning)、稳定性、可提取性、难度等多个参数。这些参数需要持久化存储,而且需要高效地查询和更新。
-
我们使用了一个简单但有效的方案:为每道题目创建一个
CardState对象,包含所有参数。这些对象存储在内存中的 HashMap,定期序列化到磁盘。查询时直接从 HashMap 读取,更新时先修改内存,再异步写入磁盘。
java
public class CardState {
private String questionId;
private State state; // 状态
private double stability; // 稳定性
private double difficulty; // 难度
private LocalDateTime lastReview; // 上次复习时间
private LocalDateTime nextReview; // 下次复习时间
private int reviewCount; // 复习次数
// 计算当前的可提取性
public double getRetrievability() {
long daysSinceReview = ChronoUnit.DAYS.between(lastReview, LocalDateTime.now());
return Math.pow(1 + daysSinceReview / (9.0 * stability), -1);
}
// 是否需要复习
public boolean needsReview() {
return LocalDateTime.now().isAfter(nextReview);
}
}
public class FSRSScheduler {
private final Map<String, CardState> cardStates = new ConcurrentHashMap<>();
private final FSRSAlgorithm algorithm = new FSRSAlgorithm();
// 获取需要复习的题目
public List<String> getDueCards() {
return cardStates.values().stream()
.filter(CardState::needsReview)
.sorted(Comparator.comparing(CardState::getNextReview))
.map(CardState::getQuestionId)
.collect(Collectors.toList());
}
// 记录复习结果
public void recordReview(String questionId, Rating rating) {
CardState state = cardStates.get(questionId);
if (state == null) {
state = new CardState(questionId);
cardStates.put(questionId, state);
}
// 计算新的稳定性
double retrievability = state.getRetrievability();
double newStability = algorithm.calculateNewStability(
state.getStability(), retrievability, state.getDifficulty(), rating
);
// 计算下次复习时间
int daysUntilNextReview = algorithm.calculateNextReviewDays(newStability, 0.9);
// 更新状态
state.setStability(newStability);
state.setLastReview(LocalDateTime.now());
state.setNextReview(LocalDateTime.now().plusDays(daysUntilNextReview));
state.setReviewCount(state.getReviewCount() + 1);
// 异步持久化
persistAsync(state);
}
}
复盘一下整个刷题流程:
用户点击"开始复习"
↓
queueDueCards() - 查询到期题目
SQL: SELECT * FROM cards WHERE next_repetition <= 当前时间
↓
getTopCard() - 取队列头部
↓
用户做题并评分(AGAIN/HARD/GOOD/EASY)
↓
onRating() - 调用 FSRS 算法
1. 读取:stability, difficulty, elapsed_days
2. 计算:新的 stability, difficulty, next_repetition
3. 更新数据库
📌 [ 笔者 ] 文艺倾年
📃 [ 更新 ] 2026.2.15
❌ [ 勘误 ] /* 暂无 */
📜 [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,
本人也很想知道这些错误,恳望读者批评指正!

