代表性面试主题

如何为文本编辑器实现 Gap Buffer?

编程题中等
Offer.cc 编辑团队发布 更新

题干

设计一个支持左移、右移、插入和删除的可变文本缓冲区。请使用 Gap Buffer,说明不变量,证明每个操作如何保持不变量,并解释移动 gap 与扩容的成本。

1. 题干与适用场景

实现单光标文本编辑器的核心缓冲区。逻辑文本是一串字符,光标位于两个字符之间。支持 left()right()insert(ch)delete()text()

存储使用带有未使用区间的数组,这个区间称为 gap。gapStart 为包含端,gapEnd 为排除端。可见文本是 gapStart 之前的前缀,加上 gapEnd 及之后的后缀。ETH Zurich 的练习使用这套表示,并要求验证行为和边界。公开的 Google L4 面经也提到 Text Editor/Bookkeeping 实现题,关注数据结构取舍、手动演算和准确的复杂度。

2. 面试官考察点

  • 状态建模: 能否准确说明两个索引,不混淆逻辑长度和数组容量。
  • 不变量: 每个操作和扩容是否都保持边界合法以及同一份逻辑文本。
  • 边界纪律: 是否明确处理空缓冲区、满 gap、左右端点和单字符文本。
  • 复杂度推理: 能否解释相邻编辑为何便宜,以及长距离移动为何与距离线性相关。
  • 设计判断: 能否说明大文件、多光标或协同编辑何时让 Gap Buffer 不再合适。

弱回答先写数组移动,之后才发现越界。强回答从表示法推导每次移动,并用简单字符串模型测试。

3. 回答前需要澄清的问题

光标是字符索引还是边界?

采用边界定义:cursor 等于光标左侧的逻辑字符数。这样 cursor=0 是左端,cursor=length 是右端,delete() 可以定义为删除光标左侧的字符。

delete 表示光标前删还是光标后删?

确认是 Backspace 还是 Delete。本文采用 Backspace:让 gap 向左扩展一个字符。若是前向删除,则应消费 gap 右侧的第一个字符。

存储和文本模型有什么要求?

澄清按字节还是 Unicode 标量值处理、文档最大大小,以及是否需要撤销、随机行定位、多光标或并发编辑。这些要求可能改变数据结构,而不只是增加方法。

4. 30 秒回答框架

“我会在数组中维护一个位于光标处的 gap。gapStart 是光标边界,gapEnd 是第一个后缀字符;逻辑文本是前缀加后缀。插入写入 gapStart 并递增它。退格把一个前缀字符移过 gap,同时递减两个索引。右移把一个后缀字符复制到前缀侧,再递增两个索引。gap 为空时扩容。每个操作后用字符串模型检查边界和等价性。相邻编辑是摊销 O(1);移动 gap 与距离线性相关,因此大文件或多光标场景可能需要 piece table 或 rope。”

5. 分步骤深入解答

步骤一:写出表示不变量

容量为 n 时必须满足 0 ≤ gapStart ≤ gapEnd ≤ n。逻辑长度为 n - (gapEnd - gapStart)。逻辑序列是 buffer[0:gapStart]buffer[gapEnd:n] 的拼接。gap 内的值被忽略,不必初始化。

步骤二:左移

gapStart == 0,光标已经在左端。否则先递减 gapStartgapEnd,再把原来位于光标左侧的字符复制到新的 gap 尾部。前缀少一个字符,序列顺序保持不变。

步骤三:右移

gapEnd == n,光标已经在右端。否则把 buffer[gapEnd] 复制到 buffer[gapStart],然后递增两个索引。第一个后缀字符跨过 gap,顺序不变。当 gap 只有一个槽位时,复制和更新索引的顺序尤其重要。

步骤四:插入

gapStart == gapEnd,先调用 grow()。把字符写入 buffer[gapStart],再递增 gapStart。新字符成为原光标边界处前缀的最后一项。

步骤五:向后删除

gapStart == 0,左侧没有字符。否则递减 gapStart,被删除字符就进入 gap。无需移动数组,逻辑序列只减少前缀末尾字符。

步骤六:保持文本不变地扩容

分配更大数组,把前缀复制到相同索引,并把后缀复制到新数组尾部。保持 gapStart 不变,设置新的 gapEnd 使后缀长度不变。容量按几何比例增长可让 gap 附近插入达到摊销常数时间,但内存限制可能要求更小的增长因子。

步骤七:有依据地选择下一种结构

单光标、局部编辑适合 Gap Buffer,因为热点区域保持连续。piece table 保留原始缓冲区和只追加的新增缓冲区,适合强调撤销的编辑器。rope 或分块树适合大文档和分散位置的编辑。协同编辑还需要操作转换或 CRDT,Gap Buffer 本身无法解决合并语义。

6. 高质量示范回答

“我把光标建模成边界,并在数组中维护两个索引包围的未使用 gap。不变量是 0 ≤ gapStart ≤ gapEnd ≤ capacity;逻辑文本是 gap 前缀与 gap 后缀的拼接。插入消耗一个 gap 槽位。退格递减 gapStart;右移把一个后缀字符复制到前缀侧并递增两个索引;左移做反向复制。gap 为空时,将后缀复制到新数组尾部并扩容,逻辑序列不会改变。

我会用字符串和光标组成的简单模型做对照测试,覆盖空缓冲区、满 gap、两端、单字符、反复往返和扩容。局部编辑是摊销 O(1);移动光标每跨一个字符是 O(1),长距离移动和扩容分别是线性成本。大文件、多光标或协同编辑应考虑 piece table 或 rope,因为单一连续 gap 会成为瓶颈。”

7. 常见错误

  • gapEnd 当成包含端 → 复制或边界检查多一个单元 → 明确 gap 是 [gapStart, gapEnd),并测试空 gap。
  • 先递增再右移 → 读取错误的后缀单元 → 先复制 buffer[gapEnd]buffer[gapStart],再更新索引。
  • 删除时只清空数组单元 → 逻辑长度和光标不变 → 通过递减 gapStart 扩大 gap。
  • 扩容只移动 gap → 可能改变顺序或丢失后缀 → 把后缀整体复制到新数组尾部。
  • 不约定字节或字符 → 可能拆开多字节字符 → 在实现前声明字节还是标量值语义。
  • 声称所有编辑都是 O(1) 忽略长距离移动和扩容 → 说明局部编辑的摊销成本及线性情况。
  • 用 Gap Buffer 直接做协同编辑 → 混淆本地存储和合并语义 → 根据需求选择 piece table、rope 或 CRDT。

8. 追问及应对

如何实现前向 Delete?

gapEnd == capacity,光标后没有字符。否则递增 gapEnd;第一个后缀字符进入 gap 并从逻辑序列消失。这是 Backspace 的镜像,保持同一不变量。

移动光标的最坏复杂度是什么?

跨过 k 个字符要做 k 次常数时间复制,因此是 O(k)。从一端跳到另一端是 O(length)。如果编辑器经常跳到远处,可加行索引或分块结构降低导航成本。

如何加入撤销?

记录编辑命令或逆向范围,而不是保存整个数组快照。piece table 可让新增文本只追加并保留历史引用;Gap Buffer 则需要显式操作日志和光标位置。

如何验证实现?

(string, cursor) 参考模型运行随机操作序列。每次操作后比较 text()、光标位置和边界,并断言所有数组访问都在容量内;ETH Zurich 练习明确要求验证行为和边界。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具