具代表性的面試主題

如何為文字編輯器實作 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 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具