Cairn
data structures · one array, one unused gap parked at the cursor · O(1) local edit, O(distance) to move

↩ back to Linear

Gap Buffer

Every other Linear entry on this site answers "how should a sequence be stored" for a workload that either only ever touches the two ends, or splices in and out of the middle given a reference the caller already holds (a Linked List node). A text editor's real workload is neither: a person types at wherever the cursor currently sits, which is almost always the same spot several keystrokes in a row, and only occasionally jumps somewhere else with a click or an arrow key. A Dynamic Array makes every one of those keystrokes an O(n) shift to open up room; a Linked List needs a live reference to the cursor's exact node just to get O(1) insert there, and finding that reference from a numeric cursor position is itself O(n). A gap buffer gets O(1) insert and delete right at the cursor, no reference needed, no shifting — by keeping one array with a deliberately unused range of slots, the gap, parked exactly at the cursor at all times.

The array is split into three regions: [0, gapStart) holds the text before the cursor, [gapStart, gapEnd) is the gap — unused capacity, contents meaningless — and [gapEnd, capacity) holds the text after the cursor. The logical text is just those two real segments concatenated; the gap itself never appears in it. Typing a character writes into buf[gapStart] and grows the gap's left edge inward — no other cell in the array moves at all. Moving the cursor is what makes this work: it slides the gap itself, one cell at a time, by copying exactly one character across the gap per step. A keystroke right where the cursor already is costs one write; walking the cursor across k characters costs k copies first, and then typing there is cheap again.

Try it

Capacity starts at 4 and doubles when the gap fully closes, same amortized idea as Dynamic Array's own growth — except a gap buffer has two live segments to preserve on growth, not one, which is exactly where the first pitfall below comes from. Gap cells are shown dashed; the two real segments are highlighted. The cursor sits at the left edge of the gap — everything before it is the current line's text-before-cursor, everything after the gap is text-after-cursor.

buffer — dashed cells are the gap, capacity = 4
logical text, cursor shown as |
Type a character and click Insert, or press Enter. Try moving the cursor left a few times, then insert — notice only the gap moves, the text on both sides stays put.

Core operations

Reference implementation

class GapBuffer {
  #buf;
  #gapStart = 0;
  #gapEnd;

  constructor(capacity = 8) {
    this.#buf = new Array(capacity);
    this.#gapEnd = capacity;
  }

  get length() { return this.#buf.length - (this.#gapEnd - this.#gapStart); }
  get cursor() { return this.#gapStart; }

  #grow() {
    const oldCap = this.#buf.length;
    const newCap = Math.max(oldCap * 2, 1);
    const newBuf = new Array(newCap);
    for (let i = 0; i < this.#gapStart; i++) newBuf[i] = this.#buf[i];
    const postLen = oldCap - this.#gapEnd;
    // the after-cursor segment moves to the NEW array's tail, not the same offset
    for (let i = 0; i < postLen; i++) newBuf[newCap - postLen + i] = this.#buf[this.#gapEnd + i];
    this.#buf = newBuf;
    this.#gapEnd = newCap - postLen;
  }

  insertChar(c) {
    if (this.#gapStart === this.#gapEnd) this.#grow();
    this.#buf[this.#gapStart] = c;
    this.#gapStart++;
  }

  deleteBackward() {
    if (this.#gapStart === 0) return false;
    this.#gapStart--;
    return true;
  }

  deleteForward() {
    if (this.#gapEnd === this.#buf.length) return false;
    this.#gapEnd++;
    return true;
  }

  moveLeft() {
    if (this.#gapStart === 0) return false;
    this.#gapEnd--;
    this.#buf[this.#gapEnd] = this.#buf[this.#gapStart - 1];
    this.#gapStart--;
    return true;
  }

  moveRight() {
    if (this.#gapEnd === this.#buf.length) return false;
    this.#buf[this.#gapStart] = this.#buf[this.#gapEnd];
    this.#gapStart++;
    this.#gapEnd++;
    return true;
  }

  text() {
    let s = '';
    for (let i = 0; i < this.#gapStart; i++) s += this.#buf[i];
    for (let i = this.#gapEnd; i < this.#buf.length; i++) s += this.#buf[i];
    return s;
  }
}

Verified against a plain string-splice model (text.slice(0,c)+ch+text.slice(c) for every operation, cursor tracked as a separate integer) over 5,000 randomized trials of 200 mixed insert/backspace/delete/move operations each — comparing text(), cursor, and length after every single operation, not just at the end — 0 mismatches across all 1,000,000 operations. See /tmp/gapbuf_test/verify.js, not committed, it's scratch.

Pitfalls

Growing the buffer like a Dynamic Array. Dynamic Array's amortized doubling copies the old array into a bigger one and extends usable capacity at the end — there's only one segment, so that's the whole story. A gap buffer has two real segments, and copying the old array verbatim into a bigger one (then just setting gapEnd to the new capacity, the way a straight port of Dynamic Array's recipe would) leaves the after-cursor segment sitting exactly where it was in the old, smaller index range — which the buggy gapEnd now falsely claims is part of the gap. The very next character written at buf[gapStart] overwrites into that range, and text()'s second loop starts reading from the new (much larger) gapEnd instead, silently dropping everything that used to be after the cursor. Concretely: capacity 4, type "abcd" (buffer now full, gap width 0), move the cursor left twice (between b and c), then insert X — the gap is empty so this triggers growth. Correct result: "abXcd". Buggy result: "abX" — "cd" is gone, no error, no crash. Stress-tested across 3,000 trials of 60 random operations on small buffers: every single growth event that happened with the cursor not at the very end of the text (7,218 of them) corrupted the text immediately, dropping 26,843 characters combined across the run — 100%, not an occasional edge case, because the bug fires deterministically whenever growth and a nonempty after-cursor segment coincide.

No bounds check on moveLeft/moveRight. Both guard clauses (gapStart === 0, gapEnd === buf.length) look skippable, since moving the cursor past either end of the text "obviously" shouldn't happen — until a caller does it anyway (a held-down arrow key, an off-by-one in whatever code tracks how far the cursor can move). Without the guard, moveLeft reads buf[gapStart - 1] at gapStart = 0, which JavaScript happily returns as undefined rather than throwing, and decrements gapStart to -1. Concretely: capacity 4, type "ab", move left twice (cursor now correctly at position 0), then one extra unguarded moveLeft — gapStart goes to -1. The very next character typed is written to buf[-1], which JavaScript stores as a stray non-index property, not a real array slot — it never appears in text()'s output again, vanishing with no error at all. Oddly, the structure then "self-heals": that same insert increments gapStart back to 0, so every keystroke after the corrupted one behaves normally — exactly one character is silently eaten per out-of-bounds move, not an escalating corruption. Stress-tested across 3,000 trials: every one of 2,478 unguarded moveLeft calls at gapStart = 0 corrupted the text immediately (100%), and 7,273 of 7,288 unguarded moveRight calls at the opposite edge did the same (99.8%).

Where gap buffers show up

Complexity

Time: insert and delete at the current cursor position are O(1) amortized (amortized only because of the doubling grow() step — otherwise strictly O(1)). Moving the cursor k positions away costs O(k), since the gap has to slide one cell at a time. A workload that inserts and deletes near a slowly-moving cursor (typing, editing near where you're looking) is effectively O(1) per keystroke; a workload that jumps to a random position before every single edit degrades to O(n) per edit, no better than a Dynamic Array's naive middle-insert. Space: O(capacity), same amortized-doubling shape as Dynamic Array, with the gap itself counting as unused overhead that's usually small relative to the document.

This site's guide, Choosing a Linear Data Structure, sets this entry aside for a fifth reason distinct from Monotonic Stack, Monotonic Deque, XOR Linked List, and Sparse Set: it's built for a workload with a single moving cursor that most edits happen right next to, not indexed random access, not ends-only access, and not a caller who already holds a node reference.