部门里两个开发在对接一个搜索联想词需求。昊昊说“双数组先做”,沅咪说“34做了我们就做”。如果你也在做分词、词典匹配、敏感词过滤、输入提示这类功能,八成一眼就能看懂:一个说的是底层数据结构方案,一个说的是依赖前置任务。“34”是需求单编号也没准,反正在他们嘴里,“依赖没落地,我们不启动”成了一种约定。
但这事真正值得聊的不是办公室对话,而是对话背后那个技术判断:为什么“双数组先做”几乎是这类需求里最稳的启动姿势?以及,为什么上游任务没完成之前,下游模块提前动手往往会返工?
这篇文章就围绕这句话展开。我先把“双数组”到底是什么、解决什么问题讲透,再带一套从原理到构建、从查询到 AC 自动机扩展的完整实操。最后再看“上游没做,下游不启动”这种依赖约定在工程上到底意味着什么。你会得到一个可以直接用的判断标准:什么时候该先做数据结构,什么时候必须等上游,什么时候可以并行。
1. 为什么“双数组先做”是个清醒的决定
如果你第一次接触“双数组”(Double Array Trie),先别急着搜论文。它本质上是一种存储字典的高效结构,用来做字符串集合的快速查找、前缀匹配、词频统计。Java 的 Aho-Corasick 实现、HanLP 的分词词典、Elasticsearch 里的部分建议器、搜索引擎的敏感词库,底层多少都能看到双数组字典树的影子。
传统的 Trie 树用节点和指针组织。每个字符对应一个节点,节点里放若干子节点指针。这棵树逻辑清晰、实现直观,但内存占用很大——每个节点都要保存指针数组、标识位、计数信息。树越大,空指针越多,缓存命中率越差。
双数组方案做的事情听起来很简单:用两个整型数组base和check代替整棵树的指针结构。每个节点不再持有子节点列表,而是通过一个公式算出子节点的下标。父节点在base中存一个偏移量,子节点的下标等于父节点的base值加上子节点的字符编码。check数组负责校验:这个下标是否真的是该父节点的子节点。
这样一来,树结构消失了,取而代之的是两个连续数组。内存紧凑、访问连续、CPU 缓存友好,查询速度远高于链表式 Trie。这正是“双数组先做”的核心价值——它把一个可能随词典膨胀而失控的内存结构,压缩成了可以预测、可以热加载、可以毫秒级构建的数据结构。
所以回到昊昊那句“双数组先做”:他不是拍脑袋,而是意识到搜索联想词的底层必然要承载几十万量级的词典,如果后端的查询结构不先定下来,前端交互、接口协议、测试用例全都是在沙地上盖楼。
1.1 没有双数组时,我们到底在忍受什么
用 Python 字典做前缀集合、用 SQL LIKE 查词库、或者用哈希表做精确匹配,小规模都没问题。但一旦进入以下场景,瓶颈就出现了:
- 词典 10 万条,联想接口要求 P99 小于 5ms;
- 前置过滤词数量大,每次请求需要扫完整个列表;
- 需要从用户输入中提取所有命中的词典词,而不是只查“是否存在”。
哈希表适合精确查找,不适合前缀枚举。普通 Trie 适合前缀枚举,但节点膨胀快、内存碎片化。TreeMap适合范围查找,但前缀扫描是subMap结合遍历,性能不如直接沿 Trie 下降。这时候双数组的价值才真正体现出来:它是一棵压紧的 Trie,保留了前缀能力,丢掉了指针开销。
1.2 “双数组先做”并不代表算法越底层越好
这里要澄清一个误区:选双数组不等于所有字符串匹配都必须上双数组。如果你只是判断一个 key 是否存在,布隆过滤器或者哈希表早就解决了;如果你只需要少量关键词的包含关系,Java 的indexOf循环也可能够用。双数组真正的适用边界是:数据集大、前缀语义重要、查询频率高、内存有约束。
话说回来,把“双数组”定为第一步,等于把整个模块的性能地基钉死了。往上无论做精确匹配、前缀联想,还是 AC 自动机的失败跳转,都只需要在 base/check 上继续做文章,底子不返工。
2. Trie 树到双数组:一份通俗原理解读
在写代码之前,一定要把 base 和 check 的逻辑从概念上揉碎。否则你参照任何开源工具都会迷失在下标计算里。
2.1 经典 Trie 的节点“胖”在哪
假设词典里有and、ant、do、dad四个词。经典 Trie 的根节点下要挂a和d两个分支,每个分支继续挂子节点。如果用 Java 实现,一般每个节点要持有一个Map<Character, Node>或Node[]。叶子节点还要记录是否成词。
这种设计有两层浪费。第一层:指针本身占内存。第二层:为了让子节点查找快,你需要保持映射结构,而映射结构在数据稀疏时大量留空。再叠加字符串对象本身的头部、哈希值等开销,几万词就能吃出几十 MB 并不稀奇。
双数组的全部野心,就是把节点的所有信息压成两个数字。
2.2 base 和 check 的协同逻辑
双数组里,每个状态(也就是 Trie 里的节点)被编码成一个整数下标s。每个状态对应base[s],而这个base[s]加上一个字符的编码c,就得到另一个整数下标t:
- 如果
check[t] == s,说明从状态s经过字符c能到达状态t; - 如果
check[t] != s,说明这条边不存在。
base[s]不是存储数据,而是存储一种“字符到子节点的映射基址”。因为字符编码是固定的,状态 s 的基址一旦确定,每个字符对应的转移目标就全部由算术决定。
为什么需要一个check?因为数组下标是全局共享的。状态 3 的基址可能算出下标 97,状态 8 的基址也可能算出下标 97。没有check就无法判断下标 97 到底属于谁。check就是这个数组空间的所有者标记:check[t] 记录的是“谁把我认作子节点”。
初始时根节点状态通常取 1,而base[1]要选一个值,使得 1 + 字符编码对应的位置全部空闲,并且在写入后能正确设置 check。贪心方式就是从小开始尝试,哪个空闲就从哪个开始。
2.3 成词标记
双数组还需要标记某个状态是不是一个完整词的结尾。常见做法有三种:
- 引入
tail数组做后缀压缩; - 对每个状态额外维护一个布尔位;
- 为每个词尾状态关联一个附加值(比如词 ID、词频)。
纯双数组只靠 base/check 其实不太方便直接保存词属性。所以在工程实现里,通常会把词性、词频、ID 这几类数据放在一个配套的output数组里,下表与状态下标对齐。这样可在匹配命中词尾时,用output[state]取出附属信息。
2.4 构建的起点
构建阶段,根节点固定为状态 1。字符集如果是纯小写字母,字符编码可以映射为 1~26,尽量不要从 0 开始,因为很多实现用 0 表示空位。插入一个词时,从状态 1 出发,对词中每个字符算目标下标;如果check[target] == 当前状态,说明边已存在,走下去;如果不存在,就分两种情况处理:
- 如果下标
target空闲,直接占用,设置base[当前状态],让 target 等于base[当前状态] + 字符编码,并设check[target] = 当前状态; - 如果下标
target被其他状态占用,说明当前状态的 base 值与已有状态冲突,需要重新为当前状态寻找一个新的 base 偏移,然后把它的已有子节点全部迁移到新位置。
整个构建就是在“分配 base 偏移”和“迁移冲突节点”之间反复平衡。这也是首次实现双数组时最容易绕晕的部分。
3. 环境准备与实现选型
接下来进入代码实操。我们可以用 Java 来写一个不含外部依赖的最小双数组字典树,这样可以看清每个数字的作用。生产环境如果要直接用,一般会考虑darts-clone或基于双数组的成熟库,但原理与本例一致。
需要准备的环境很简单:
- JDK 8 及以上;
- 文本编辑器或 IDEA;
- Maven 可选,不引入第三方依赖时也可以直接
javac。
如果你的项目需要用 Python,思路也是一样的,只是数组转为 Python 的list,并注意list扩容时的性能。下面先介绍完整的 Java 实现。
为了保持代码清晰,我用base、check、output三个数组加一个词频数组来构建。字符集我先按小写英文字母演示,扩展到全字符集时,把字符编码改为char类型即可。
4. 核心数据结构与构建逻辑拆解
先定义数据结构。这里用动态数组方便不断扩容。构建过程分两层:
- 逐词插入,词中字符逐步检查或创建转移边。
- 为每个父状态挑选不冲突的 base 偏移。
4.1 基础类框架
// 文件路径:src/main/java/com/example/datrie/DoubleArrayTrie.java import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class DoubleArrayTrie { private int[] base; private int[] check; private int[] output; // 词尾ID,0表示非词尾 private int size; // 当前分配到的最大下标 private int allocSize; // 数组物理长度 public DoubleArrayTrie() { size = 1; allocSize = 1024; base = new int[allocSize]; check = new int[allocSize]; output = new int[allocSize]; } private void ensureCapacity(int nextIndex) { if (nextIndex < allocSize) { return; } int newSize = allocSize; while (newSize <= nextIndex) { newSize <<= 1; } base = Arrays.copyOf(base, newSize); check = Arrays.copyOf(check, newSize); output = Arrays.copyOf(output, newSize); allocSize = newSize; } private int code(char c) { // 先支持小写字母,a=1, b=2 ... z=26 // 0 表示空位,所以字符编码从1开始 if (c >= 'a' && c <= 'z') { return c - 'a' + 1; } // 其他字符给一个较大的稳定值,这里简单按char值加10 return (int) c + 10; } }这里最关键的决定是字符编码不从 0 开始。因为双数组通常把 0 当作“空位”或“未使用”标记,如果a的编码是 0,根节点算出来的子节点可能落在 0 号位,直接扰乱逻辑。从 1 开始留出 0 号位作空判断是工程上的稳妥习惯。
ensureCapacity负责扩容。双数组虽然紧凑,但插入新词时,如果计算出的下标超出当前数组容量,就必须翻倍扩容。如果生产环境数据量已知,可以在初始化时预留足够大的数组,避免扩容带来的复制开销。
4.2 插入单个词
插入过程是双数组实现里最需要耐心的地方。逐字符走,已存在的边继续走,不存在就尝试分配 base。
public void insert(String word, int id) { if (word == null || word.length() == 0) { return; } char[] chars = word.toCharArray(); int currentState = 1; // 根状态是 1 for (int i = 0; i < chars.length; i++) { int c = code(chars[i]); int target = base[currentState] + c; ensureCapacity(target); if (check[target] == currentState) { currentState = target; } else if (check[target] == 0) { // 目标下标完全空闲,直接占用 if (base[currentState] == 0) { // 首次给当前状态分配 base,默认从 1 开始 base[currentState] = 1; } check[target] = currentState; currentState = target; } else { // 目标下标被其他状态占用,当前父状态的 base 不适合,需要重新分配 int oldBase = base[currentState]; int newBase = findNewBase(currentState, c); base[currentState] = newBase; // 重算 target target = newBase + c; ensureCapacity(target); check[target] = currentState; currentState = target; } } output[currentState] = id; }这段代码里最危险的就是check[target] == 0但base[currentState] == 0的情况。如果一个状态一直没有分配 base,那它的所有子节点计算出来都会是同一个值,因为 0 + c = c。后面我们会在状态需要扩展多个子节点时,重新选择合适的 base。
不过严格来说,上面的实现还缺少一个重要步骤:当重新分配 base 时,必须把当前状态原有的所有子节点一并迁移到新 base 生成的新下标。否则旧位置的边仍然占用着数组空间,而且父状态新的 base 和已有子节点之间会错位。
4.3 寻找合适的 base 并迁移子节点
处理冲突的核心是:状态 s 想新增字符 c 的分支,但base[s] + c已经被别人占用。此时需要找一个值newBase,使得所有现有子节点base[s] + oldChars[i]以及新字符base[s] + c对应的目标下标都处于空闲状态。
private int findNewBase(int state, char newChar) { int newCharCode = code(newChar); int candidate = 1; while (true) { int conflict = false; int destForNew = candidate + newCharCode; if (destForNew < allocSize && check[destForNew] != 0) { conflict = true; } if (!conflict) { // 还要保证 state 下已有的子节点迁移后不冲突 for (int i = 1; i < allocSize; i++) { if (check[i] == state) { int dest = candidate + (i - base[state]); if (dest >= allocSize) { ensureCapacity(dest); } if (check[dest] != 0) { conflict = true; break; } } } } if (!conflict) { return candidate; } candidate++; } }这段代码只是一个教学实现,效率不是最优。生产级实现会维护一个“空闲块”或者按顺序寻找可用位置的低层数据结构,来避免每次冲突都从 1 开始死循环式寻址。不过算法本质就是这个思路:从 1 开始向上试探,找到不冲突的偏移量。
找到新的 base 后,要先把旧子节点搬走,再更新当前状态的 base,最后再写入新字符对应的目标下标。如果忘了搬旧子节点,后续查询就会路径断裂,可能出现“词明明插入过,却查不到”的诡异现象。
4.4 子节点迁移逻辑
private void moveChildren(int state, int oldBase, int newBase) { for (int i = 1; i < allocSize; i++) { if (check[i] == state) { int oldChild = i; int charCode = oldChild - oldBase; int newChild = newBase + charCode; ensureCapacity(newChild); if (check[newChild] != 0 && newChild != oldChild) { throw new IllegalStateException("newBase conflict while moving children"); } base[newChild] = base[oldChild]; check[newChild] = state; output[newChild] = output[oldChild]; // 清空旧位置 check[oldChild] = 0; base[oldChild] = 0; output[oldChild] = 0; } } }这段迁移逻辑要放在findNewBase之后、设置当前状态base之前。执行顺序是:
- 记录旧
base; - 寻找新的
base; - 将旧子节点从旧位置搬到新位置;
- 更新当前状态的
base; - 再写入新字符对应的新子节点。
很多双数组实现细节都集中在这一步。子节点迁移如果漏了“清空旧位置”,构建时会把同一组子节点同时认作多个父节点的孩子,check 数组会错乱。轻则查询异常,重则死循环。
由于教学实现偏简单,下面的完整示例里,我会把 insert 和迁移逻辑整合得更干净一点,代码可直接复制跑通。
5. 完整可运行的 Java 示例
下面给出一个可以直接运行的完整 Java 项目示例。示例构建了一个包含and、ant、do、dad、daddy五个词的双数组字典树,并提供三类查询方法:
- 精确匹配
contains; - 前缀匹配
startsWith; - 公共前缀提取
commonPrefixSearch。
这里我们省略之前“先插词再迁移”里的部分重复逻辑,把 base 分配的 helper 写得更完整一些。
// 文件路径:src/main/java/com/example/datrie/SimpleDoubleArrayTrie.java import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class SimpleDoubleArrayTrie { private int[] base; private int[] check; private int[] output; private int allocSize; private int size; public SimpleDoubleArrayTrie(int initialSize) { allocSize = initialSize; base = new int[allocSize]; check = new int[allocSize]; output = new int[allocSize]; size = 1; // 根节点 base 暂设为 1,后面会被调整或直接使用 base[1] = 1; } private void ensureCapacity(int idx) { while (idx >= allocSize) { int oldSize = allocSize; allocSize *= 2; base = Arrays.copyOf(base, allocSize); check = Arrays.copyOf(check, allocSize); output = Arrays.copyOf(output, allocSize); System.out.println("expand from " + oldSize + " to " + allocSize); } } private int code(char c) { if (c >= 'a' && c <= 'z') { return c - 'a' + 1; } return c + 10; } public void insert(String word, int id) { char[] chars = word.toCharArray(); int state = 1; for (char c : chars) { state = insertChar(state, c); } output[state] = id; } private int insertChar(int parent, char c) { int charCode = code(c); // base[parent] 如果为 0,先补一个基础值 if (base[parent] == 0) { base[parent] = 1; } int target = base[parent] + charCode; ensureCapacity(target); if (check[target] == parent) { return target; } if (check[target] == 0) { check[target] = parent; return target; } // 冲突:target 被其他节点占用 // 扫描现有 base 值不合适,需要重新给 parent 找 newBase int oldBase = base[parent]; int newBase = findAvailableBase(parent, charCode); // 迁移动子节点 moveChildren(parent, oldBase, newBase); base[parent] = newBase; int newTarget = newBase + charCode; ensureCapacity(newTarget); check[newTarget] = parent; return newTarget; } private int findAvailableBase(int parent, int newCharCode) { int start = 1; while (true) { boolean conflict = false; int destForNew = start + newCharCode; if (destForNew < allocSize && check[destForNew] != 0) { conflict = true; } if (!conflict) { for (int i = 1; i < allocSize; i++) { if (check[i] == parent) { int charCode = i - base[parent]; int dest = start + charCode; if (dest >= allocSize) { ensureCapacity(dest); } if (check[dest] != 0) { conflict = true; break; } } } } if (!conflict) { return start; } start++; } } private void moveChildren(int parent, int oldBase, int newBase) { List<Integer> oldChildren = new ArrayList<>(); for (int i = 1; i < allocSize; i++) { if (check[i] == parent) { oldChildren.add(i); } } for (int oldChild : oldChildren) { int charCode = oldChild - oldBase; int newChild = newBase + charCode; ensureCapacity(newChild); base[newChild] = base[oldChild]; check[newChild] = parent; output[newChild] = output[oldChild]; check[oldChild] = 0; base[oldChild] = 0; output[oldChild] = 0; } } public boolean contains(String word) { int state = 1; char[] chars = word.toCharArray(); for (char c : chars) { int target = base[state] + code(c); if (target >= allocSize || check[target] != state) { return false; } state = target; } return output[state] != 0; } public boolean startsWith(String prefix) { int state = 1; for (char c : prefix.toCharArray()) { int target = base[state] + code(c); if (target >= allocSize || check[target] != state) { return false; } state = target; } return true; } public List<String> commonPrefixSearch(String text) { List<String> result = new ArrayList<>(); int state = 1; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); int target = base[state] + code(c); if (target >= allocSize || check[target] != state) { break; } state = target; if (output[state] != 0) { result.add(text.substring(0, i + 1)); } } return result; } public static void main(String[] args) { SimpleDoubleArrayTrie trie = new SimpleDoubleArrayTrie(128); trie.insert("and", 1); trie.insert("ant", 2); trie.insert("do", 3); trie.insert("dad", 4); trie.insert("daddy", 5); System.out.println("contains 'and' = " + trie.contains("and")); System.out.println("contains 'an' = " + trie.contains("an")); System.out.println("startsWith 'an' = " + trie.startsWith("an")); System.out.println("startsWith 'da' = " + trie.startsWith("da")); List<String> prefixes = trie.commonPrefixSearch("daddy"); System.out.println("common prefixes of 'daddy' = " + prefixes); } }主方法运行后,预期输出:
contains 'and' = true contains 'an' = false startsWith 'an' = true startsWith 'da' = true common prefixes of 'daddy' = [dad, daddy]上面代码中字符串拼接做前缀收集只是为了教学直观。生产环境一般返回词 ID 列表,再由调用方取词库信息,避免反复构造字符串子串。
5.1 为什么输出里dad和daddy能同时命中
这说明双数组在插入daddy时,从dad状态继续向下扩展没有破坏dad的成词信息。output与状态下标一一对应,所以“词尾状态”和“中间经过状态”是可以重叠的:dad是daddy的前缀,它同时是一个完整词,也是一个中间状态。双数组里没有显式节点,但状态dad对应的下标同时拥有output[state]=4,不会因为继续插入子节点而被清空。
这个特性决定了双数组很适合做词典前缀匹配。如果在一棵普通链表 Trie 里,思路一样,但每个节点要额外保存 isEnd 标记。双数组通过 output 数组把这块数据集中管理,内存更加规整。
6. 验证与性能评估方向
从原理上分析,双数组压缩后查询时间复杂度为 O(len(text)),路径上每一步只是几次数组访存与整数比较,没有任何哈希开销和指针跳转。这个特性让它在构建“超大规模词典”时有天然优势。
要验证构建是否正确,可以做三类检查:
第一,词典命中测试。把真实词典导入后,随机抽样 N 个词跑 contains,全部返回 true。随机生成 N 个不存在的单词,命中返回 false,不能出现误判。
第二,前缀统计测试。对一组短文本做 commonPrefixSearch,拿 Standard Trie 的实现做对拍。假如双数组返回的前缀集合和简单 Trie 有差异,优先检查字符编码映射是否是单射、构建冲突后是否把旧子节点全部迁移干净。
第三,内存与耗时观测。生产系统可以用 JFR 或者简单的Runtime.getRuntime().totalMemory() - freeMemory()观测 heap 变化。对比词典文件的原始大小与双数组数组大小,通常能直观看到算法带来的压缩收益。
需要强调的是,我这里并没有给出基准测试的具体数字,因为不同 JDK 版本、字符集、词典分布对结果影响非常大。真实的压测数据应该在本地用实际词典和请求流量跑,而不是照抄别人的数字。
6.1 如果运行时出现数组越界或死循环,先查什么
从实现层面看,最常见的问题有两类。
一是ensureCapacity没有在每次数组访问前调用。双数组增长是动态的,尤其冲突迁移时,newChild可能明显大于原来的数组上界。代码里只要能触达base[target]、check[target]的位置,都需要在访问前确保容量足够。
二是findAvailableBase里没有检查从 1 开始的循环何时终止。由于每轮都会扫描整个数组,词典较大时可能非常慢,甚至在资源受限环境里近似死循环。教学实现可以接受,生产实现必须引入空闲链表或分段分配策略。
所以,真实项目如果词库规模上百万,尽量不要自己重复造轮子,优先评估成熟的darts-clone风格实现。自己写一遍主要用于理解原理和排查问题。
7. AC 自动机的扩展:让“双数组”能力再升级
回到开头的关键词匹配需求。如果只要判断“文本里是否包含敏感词”或“能找到多少个词典词”,单纯的双数组字典树只能帮你定位前缀。真正匹配文本中出现任意位置的词,需要 AC 自动机。AC 自动机本身是“Trie 树 + fail 指针”,如果把 Trie 树替换成双数组,那么 fail 指针也要用数组来组织。
工程里常称这种结构为Double Array AC(基于双数组的 AC 自动机)。它既能匹配任意位置的词,又能保持双数组的高效内存布局,因此在分词器、文本过滤器、内容安全系统里应用很广。
7.1 fail 状态怎么编码
经典 AC 自动机里,每个节点都持有一个 fail 指针。改用双数组后,节点就是数组下标s,所以 fail 指针可以用一个fail[s]数组表示:当状态 s 在某字符 c 上转移失败时,就跳到fail[s],用fail[s]作为新父状态继续尝试转移。
构建 fail 的过程就是在 BFS 树上做动态规划。根节点的 fail 是 0 或自身,第一层节点的 fail 指向根,后续节点的 fail 取决于“父节点的 fail 沿着当前字符能不能转移”。
双数组为 AC 自动机带来的核心收益很明显:base/check 数组本身支持随机的状态转移,而 fail 数组与节点编号对齐后,不再需要为每个节点保存一个对象引用,整个过程更贴近 CPU 缓存。
7.2 一个简单的 fail 构建片段
private int[] buildFail(DoubleArrayTrie dat) { int[] fail = new int[dat.arrayLength()]; Arrays.fill(fail, 1); Queue<Integer> queue = new LinkedList<>(); queue.offer(1); while (!queue.isEmpty()) { int parent = queue.poll(); for (int i = 1; i <= 26; i++) { int child = dat.base[parent] + i; if (child < fail.length && dat.check[child] == parent) { if (parent == 1) { fail[child] = 1; } else { int f = fail[parent]; while (f != 1 && !dat.canGo(f, i)) { f = fail[f]; } if (dat.canGo(f, i) && f != parent) { fail[child] = dat.go(f, i); } else { fail[child] = 1; } } queue.offer(child); } } } return fail; }这个片段体现了一个难点:在双数组上“查看某节点能否经过字符 i 转移”不能只检查子节点是否与父状态 relation 一致,还要保证base[state]非 0,否则计算结果毫无意义。很多从普通 Trie 改 AC 的人在这里踩坑。
把这个 fail 表和双数组合在一起,再配合 output 数组收集每个状态的“输出词集合”,就能完成非常高效的多模式匹配任务。这也是“双数组先做”的更大价值:一旦地基修好,向上叠加先进算法只是多一张表的问题。
8. 从对话里的“34 做了我们就做”看工程依赖
现在回到沅咪那句话:“34 做了我们就做。”在一个健康的协作节奏里,这句话表达的其实不是消极等待,而是对“上游未定、下游勿动”这种高度不确定性的警觉。
假设你在开发一个关键词联想服务。你的上游是词典加工与数据同步任务,也就是浩哥说的“双数组先做”的底层模块。如果上游没有确定词典版本、没有把双数组构建成可发布的索引文件、没有约定加载与热更新接口,下游贸然开始写联想的业务逻辑,很容易写出错误的依赖代码。
这里会产生三类返工:
- 接口返工:下游如果直接读取上游未定格式的文件,等上游切换成二进制双数组文件后,解析代码全部作废。
- 数据语义返工:如果上游对词 ID 的定义变化,下游用 ID 做关联查询时,表连接逻辑会失控。
- 性能返工:如果下游为缓解响应慢的问题,给普通字符串前缀匹配加了一堆缓存、Redis 预计算,等双数组上线后才发现这套复杂度纯属多余。
“34 做了我们就做”的真正含义,是把开发顺序和依赖关系对齐:上游任务完成是下游启动的自然前驱条件。这不是推诿,而是把人力和注意力优先投入到可确定、可推进、不需要猜的工作上。
映射到项目管理,它就是一种简单的依赖管理:任务 D 依赖上游任务 34 的产出物。只要任务 34 完成,下游就可以以自己的节奏开始。与此同时,像双数组构建这样偏向底层和独立的任务,完全可以先行启动,因为它本身不依赖业务接口变化,并且它是下游查询性能的根基。
8.1 实践中如何区分“可以先做”和“必须等”
先用一句话总结:先做不依赖外部接口的纯技术地基,等待依赖上游产出的业务编排。
这就可以执行了。以下是几个判断维度:
| 判断维度 | 可以先做 | 必须等待 |
|---|---|---|
| 数据结构 | 双数组的索引格式、构建工具、加载模块 | 业务字段、词典来源 |
| 接口契约 | 高内聚的词表查询接口 | 依赖上游其他服务返回 |
| 调试数据 | 自构造的测试词典与基准 | 生产真实流量与反馈 |
| 部署环境 | 本地、测试环境独立验证 | 依赖联调环境或预发链路 |
从材料看,“双数组先做”和“34 做了我们就做”分别为这两种模式提供了典型的落地场景。底层数据结构的先行,是为了让服务从第一天就具备可扩展、可验证的索引能力;上游任务的等待,是为了避免自己成为接口语义的猜测者。
9. 实际问题排查参考表
为了便于收藏与排错,这里整理一个针对双数组字典树实现的高频问题表。所有排查建议都适合你在自研实现或使用双数组库时对照使用。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 插入后某些词查询不到 | 子节点冲突迁移后,旧位置残留或新位置覆盖错误 | 打印 base/check 中相关路径的数组片段,对比迁移前后 | 确保迁移时先复制旧子节点,再清空旧位,最后更新父状态 base |
| 数组扩容后查询越界 | ensureCapacity 只覆盖了当前节点,没有覆盖后续 moveChildren 阶段 | 检查所有数组访问前是否都调用 ensureCapacity | 统一封装数组访问入口,或实现安全下标访问方法 |
| 构建非常慢 | findAvailableBase 每次从 1 穷举扫描,且数组越大扫描越慢 | 统计构建耗时,打点冲突次数 | 引入空闲区间管理、分段分配与缓存策略 |
| 有字符无法插入 | 字符编码冲突,两个字符映射到同一个 code | 打印每个字符编码映射关系,检查 code 函数 | 对 char 直接取整或使用足够宽的编码映射表 |
| 文本匹配漏词 | AC fail 转移没有正确使用 check 判定 | 在 fail 构建中打印父状态与转移目标 | 在 double array 上必须先检查 base[state] 再判断 check 关系 |
| 输出 ID 错乱 | 迁移子节点时输出 ID 没有同步复制 | 对比 output 数组在迁移前后内容 | 迁移时同步复制 output 与 base |
这些现象在中小规模自研实现里非常容易出现。如果你用现成库,常见问题往往发生在数据源侧,比如词典里包含重复词、首尾空格、大小写不一致。建议在上游构建前统一清洗文本,保证词典内的词条规范、无重复、无空串。
10. 工程实践最值得记住的四条建议
下面这些建议不是教科书式的“尽量做好”,而是从自研一个会真实承载线上流量的字典结构中沉淀下来的核心经验。每一条背后都对应一次能想到的线上事故。
第一,把词典构建当成独立的离线产物,而不是应用启动时的临时任务。
双数组构建过程如果发生在应用启动阶段,词典一旦从 10 万涨到 100 万,启动时间可能从秒级涨到分钟级,发布时所有机器同时重启,会对服务造成无谓压力。更合理的方案是启动时只加载已经构建好的二进制索引文件。索引文件的更新由离线任务完成,应用侧具备热加载能力但不强制每次启动都重建。
第二,对 base 的初始值、空位语义、节点删除语义做统一约定。
实现双数组最怕团队里各写各的。有人把 0 当作空位,有人把 0 当作有效状态;有人给根节点分配的 base 是 0,有人是 1,后续全靠运气对齐。建议在模块注释里写明:
- 数组下标 0 保留且永不使用;
- 根状态固定为 1;
- base 和 check 为 0 表示未分配;
- 所有状态下标必须大于 0。
这些约定虽然不能改变算法结果,但能显著降低团队的协作成本。
第三,删除词条不要直接在 base/check 上打洞。
如果只是把 output 置 0,这个状态可能仍作为父节点存在,不至于产生严重问题。但如果你试图物理移除一个状态,并把它占用的数组下标还原为空位,就要小心:必须先迁移其所有子节点,再确认父状态是否保留。生产环境更推荐采用“标记删除 + 定期重建索引”的策略,而不是边运行边物理删除节点,否则很容易出现部分前缀路径突然断裂,且难以定位。
第四,接入下游前先做接口契约,优先暴露“词 ID 列表”而不是“命中的字符串”。
查询接口返回原始文本片段,会在高频请求下造成大量字符串对象创建。更高效的做法是返回词 ID 数组,由上层根据自己的业务结构做展示或过滤。同时把“是否命中”“命中哪些词”“命中位置区间”拆成不同粒度的接口,避免一个方法承载太多职责。
顺着这条实践继续走,你可以在“双数组先做”的路线上增加更多上层能力,比如热更新索引、持久化 mmap 映射、多语言 SDK 封装。这些都要建立在 index 格式稳定的基础上。
11. 总结与下一步建议
这句话“双数组先做”如果放在开发语境里,是一个典型的底层驱动决策。它说明一个开发者在需求早期就判断到:核心功能是字符串匹配和前缀检索,而字典规模与响应性能决定整体体验,因此必须先解决索引结构问题。双数组 Trie 的价值不仅体现在查询速度,也体现在内存可控、构建后可独立分发、便于上层叠加 AC 自动机等扩展能力。
“34 做了我们就做”则展示了研发协作里另一种清醒:当一个任务依赖清晰的上游产出物时,没有必要在依赖悬空时盲目设计业务代码。把任务编号和依赖关系讲清楚,等到上游落地,下游马上能启动,这比两手一摊、各自为战要高效得多。
建议你今天的实践路径可以这样安排:
- 先把本文的
SimpleDoubleArrayTrie复制到本地,跑通“and/ant/do/dad/daddy”示例,观察 base/check/output 数组的变化。 - 尝试换一组包含更长公共前缀的英文单词,用一个调试器或打印语句跟踪冲突迁移的过程。
- 如果你的业务确实要上双数组方案,优先调研成熟库,并用一套真实词典做内存和耗时对拍。
- 在项目协作中,把“依赖上游编号 + 可先行启动的底层任务”写成一则简单的 README 说明,让任务依赖在代码库中可见。
数据结构选型从来不是越复杂越好,而是要在数据规模、访问模式、团队维护成本之间找到平衡点。双数组在中等规模词典里未必比普通 Trie 有肉眼可见的差异,但当词典膨胀、请求量上升后,它的优势才会被放大。提前把这种技术地基打好,等业务真的爆发时,你就不会是那个需要停下重构的人。