【源码精讲+简历包装】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
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!
相关推荐
2601_959986241 分钟前
M4Markets:把信息透明度做到位——路径分析与提示整理
大数据·人工智能
YueJoy.AI2 分钟前
敏捷需求优先级矩阵驱动迭代规划
人工智能·ai·语言模型
i220818 Faiz Ul2 分钟前
民谣网站|基于Springboot的民谣网站管理系统(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·民谣网站
z落落2 分钟前
C# 继承基础详解(代码实战+权限规则)
java·开发语言
豆豆3 分钟前
当GEO遇见CMS:企业网站管理系统如何适配AI大模型?
人工智能·cms·ai大模型·seo优化·geo优化·企业建站·企业网站管理系统
techdashen3 分钟前
你想在 Rust 中实现动态库热重载?
开发语言·chrome·rust
不会C语言的男孩4 分钟前
C++ Primer 第5章:语句
开发语言·c++
酉鬼女又兒6 分钟前
零基础入门计算机网络:从基本概念到核心交换技术
开发语言·计算机网络·考研·职场和发展·php
程序猿乐锅7 分钟前
吴恩达Prompt提示词课有感
人工智能·prompt
爱喝水的鱼丶9 分钟前
SAP-ABAP:SAP 简单报表输出开发系列(共6篇)第三篇:SAP ALV 报表样式定制:字段布局与交互功能配置
服务器·开发语言·学习·交互·sap·abap