【源码精讲+简历包装】LeetcodeRunner—手搓调试器轮子(20W字-下)

😊你好,我是小航,一个正在变秃、变强的文艺倾年。

🔔本文讲解【源码精讲+简历包装】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模糊搜索,但有几个问题:中文分词困难、无法相关性排序、组合查询复杂。

我的解决方案是:

  1. 引入Apache Lucene全文搜索引擎
  2. 自实现中文分词器LCAnalyzer,支持中文分词
  3. 使用字典树(Trie)优化分词性能
  4. 使用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. 最长匹配:以每个中文字符为起点,尝试匹配最长的词组
  2. 最细粒度:如果匹配到多字词(长度 > 1),同时保存单字和多字词
  3. 快照迭代器 :使用 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));
    }
}

注意几个关键点:

  1. 评级影响:答错时,稳定性只增长 40%;答对时,增长 100%;很容易答对时,增长 130%。这鼓励用户在合适的时间复习。

  2. 可提取性影响:可提取性越低,稳定性增长越多。这符合"快要忘记时复习效果最好"的原理。

  3. 难度影响:难度越高,稳定性增长越慢。这让难题的复习频率更高。

  4. 指数增长:稳定性是按倍数增长的,不是线性增长。这让复习间隔呈指数增长,符合遗忘曲线的特点。

状态机

一道题目可以处于四种状态之一: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
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!
相关推荐
EchoMind-Henry2 小时前
EchoMindBot:不只是聊天工具,而是你的 AI 超级智能终端
人工智能
技术传感器2 小时前
赋能智慧空间:看本体论如何破解城市更新运营难题
人工智能·深度学习·架构
七夜zippoe2 小时前
TensorFlow 2.x深度实战:从Keras API到自定义训练循环
人工智能·python·tensorflow·keras
励ℳ2 小时前
Python环境操作完全指南
开发语言·python
冬奇Lab2 小时前
Agent 系统详解:从使用到自定义开发
人工智能·ai编程·claude
海兰2 小时前
Elastic Stack 9.3.0 日志探索
java·服务器·前端
冬奇Lab2 小时前
一天一个开源项目(第24篇):OpenClawInstaller - 一键部署私人 AI 助手 OpenClaw
人工智能·开源·资讯
invicinble2 小时前
centos7系统安装jdk
java·开发语言
御坂10101号2 小时前
JIT 上的 JIT:Elysia JS 的优化实践与争议
开发语言·javascript·网络·性能优化·node.js·express