题干与适用场景
实现一个 Adaptive Radix Tree(ART)保存可变长度字节键及值,操作包括插入、精确查找、最长前缀匹配和删除。节点应按孩子数量在 Node4、Node16、Node48、Node256 间自适应,路径压缩不能改变键语义。题目适合存储、索引、路由和内存数据结构岗位,核心考察压缩树与内存布局,归为 coding。
面试官考察点
- 能否处理压缩路径、键结束标记和二进制字节,而不是只实现字符 Trie。
- 能否正确维护 Node4/16/48/256 的升级和降级。
- 能否实现最长前缀匹配并区分精确命中与祖先值。
- 能否证明删除不会破坏压缩路径或遗留空节点。
- 能否说明复杂度、内存权衡和并发策略。
回答前需要澄清的问题
- 键是否是不透明字节串,是否允许包含零字节?
- 值是否可以存储在内部节点,还是只在叶子?
- 最长前缀匹配的前缀长度如何定义,是否需要返回剩余键?
- 删除后是否要求立即收缩,还是允许延迟回收?
- 读多写少时是否需要无锁读取或快照?
30 秒回答框架
“我把键按字节比较,在内部节点保存压缩前缀和终止值标记。孩子少时使用 Node4/16,增多时升级到 Node48/256,删除后按阈值降级并合并单孩子路径。查找沿压缩前缀逐字节比较;最长前缀匹配记录最近一个带值节点。先实现单线程不变量和随机对照,再讨论读写锁或不可变快照。”
分步骤深入解答
第一步:定义节点和叶子
每个内部节点保存压缩前缀、前缀长度、是否有值,以及按下一字节索引的孩子。叶子保存完整键或唯一值引用,用于处理一个键是另一个键前缀的情况。
Node { prefix, prefixLen, hasValue, value, children }
Leaf { key, value }前缀长度必须独立于字符串终止符,因为键是任意字节。
第二步:插入和分裂
沿路径比较当前节点前缀与剩余键。若完全匹配,继续孩子或更新值;若只匹配一部分,创建新父节点保存公共前缀,把旧节点和新叶子按分歧字节挂载。若新键在公共前缀处结束,父节点设置 hasValue。
第三步:自适应节点布局
Node4/16 保存紧凑的键数组和指针数组,查找可线性扫描或 SIMD 比较。Node48 用 256 字节映射把字节映射到 48 个指针槽,避免 256 个指针常驻。Node256 直接按字节索引。升级复制孩子后再发布新节点,不能丢失值或前缀。
第四步:精确查找与最长前缀
精确查找要求完整消费键并命中 hasValue 或相同叶子键。最长前缀匹配每次通过节点前缀后,若节点带值就记录候选;继续沿下一字节走,直到失败或耗尽,最后返回最近候选及其长度。
第五步:删除和收缩
删除值后若节点没有孩子则移除;若只剩一个孩子且自身无值,可把自身前缀与孩子边标签合并。Node256、Node48、Node16、Node4 按孩子数量阈值降级。合并时检查前缀长度上限,并保留叶子完整键。
第六步:不变量和复杂度
每条根到叶路径拼接前缀、边字节和叶子后必须等于原键;同一内部节点不能有重复边字节;hasValue 表示恰好在该路径结束的键。令键长为 L,查找比较 O(L),节点内扫描最多 16 个键,内存随节点和键总字节增长而非固定 256 倍。
第七步:测试与并发
用随机键与参考 Map 对照插入、查找、删除和最长前缀结果,加入空键、零字节、前缀键和全 256 字节分支。并发版本先用读写锁保证结构一致,再考虑 copy-on-write、epoch 回收或 RCU;不能在未解决回收前暴露无锁指针。
高质量示范回答
“我把键视为任意字节串,内部节点保存压缩前缀、终止值和按下一字节索引的孩子。插入遇到部分匹配就拆出公共前缀;孩子数跨阈值时在 Node4、16、48、256 间升级,删除反向降级并合并单孩子路径。精确查找必须消费完整键,最长前缀匹配记录最近一个带值节点。
每条路径拼接后都要等于原键,节点边字节不能重复。测试用随机 Map 对照并覆盖零字节、空键、前缀键和全分支;单线程正确后再用读写锁,若采用无锁读必须配合安全内存回收。”
常见错误
- 按字符而非字节处理 → 非文本键和零字节失败 → 使用字节长度和比较。
- 忘记内部节点可存值 → 前缀键无法命中 → 维护
hasValue。 - Node48 直接分配 256 指针 → 失去稀疏节省 → 使用索引映射。
- 删除只清空值不合并 → 空节点和长路径积累 → 按阈值收缩。
- 最长前缀只看叶子 → 漏掉祖先值 → 记录最近候选节点。
- 无锁读配裸指针 → 回收后使用已释放内存 → 先有锁,再设计 epoch/RCU。
追问及应对
追问一:为什么 ART 不总是用 Node256?
多数节点分支稀疏,Node256 的 256 槽位浪费内存;自适应布局在稀疏和密集区域取得平衡。
追问二:键是另一个键的前缀怎么办?
在对应内部节点设置 hasValue 和值,同时继续保存更长键的孩子,不能强制所有值都在叶子。
追问三:删除后什么时候合并节点?
节点无值且只有一个孩子时可合并;合并要拼接前缀和边字节,并保留孩子叶子的完整键。
追问四:如何支持并发快照?
用 copy-on-write 发布不可变根,并通过 epoch 或引用计数延迟回收旧树;在此之前使用读写锁更安全。