代表性面试主题

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

编程题中等
OfferCC 编辑团队发布 更新

题干

设计一个支持左移、右移、插入和删除的可变文本缓冲区。请使用 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,光标已经在左端。否则先递减 gapStart 和 gapEnd,再把原来位于光标左侧的字符复制到新的 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 处理算法题

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

查看工具