Skip to content

Concepts: improving SLL

Eugene Lazutkin edited this page May 7, 2026 · 11 revisions

A classic null-terminated singly linked list looks something like that:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> null
Loading

Traversing the list or computing its length takes O(n). Caching the length avoids repeated counting but forces O(k) updates when splicing ranges. More on that later.

Operations on the front of the list

An efficient adding of a node to the list is possible at the head only:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  x((x))
  head --> x --> a --> b --> c --> d --> null
Loading

This is O(1) time.

Removing a node from the front is O(1) time too:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  x((x))
  head --> a --> b --> c --> d --> null
  removed --> x
Loading

Operations on the back of the list

Appending takes O(n) to find the last node, then O(1) to link:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  x((x))
  head --> a --> b --> c --> d --> x --> null
Loading

A last pointer solves this:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> null
  last --> d
Loading

Now adding a new node to the back will take O(1) time — the same as to the front:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  x((x))
  head --> a --> b --> c --> d --> x --> null
  last --> x
Loading

Popping from the back still requires a full traversal.

Operations on the middle of the list

For example, we have a pointer to a node in the middle of the list:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> null
  p --> b
Loading

We can add a new node in the middle of the list after the pointer p:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  x((x))
  head --> a --> b --> x --> c --> d --> null
  p --> b
Loading

While doing that we can efficiently maintain the last pointer:

  • if p === last, the new node becomes the last
  • otherwise last is unchanged

Or we can remove the next node after the pointer p:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> d --> null
  p --> b
  removed --> c
Loading

While doing that we can efficiently maintain the last pointer:

  • if p.next === last, last becomes p
  • otherwise last is unchanged

Knowing the previous node enables removal, insertion before, and insertion after. The front node has no previous node — circular lists solve this.

Extracting a range of nodes

Generalizes single-node removal. A range is defined by the previous node of the first node (prevFrom) and the last node (to):

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> null
  prevNode --> a
  to --> c
Loading

Extracting them will take O(1) time:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> d --> null
  last --> d
  extractedHead --> b --> c --> null
  extractedTail --> c
Loading

This operation can maintain the last pointer in the constant time.

Circular lists

The last node's next pointer points to the first node:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> a
Loading

With last it looks like this:

flowchart LR
  a((a))
  b((b))
  c((c))
  d((d))
  head --> a --> b --> c --> d --> a
  last --> d
Loading

This way last is the previous node for the front node. We have all possible operations described above for all nodes.

Hosted vs. headless lists

All examples above are headless: every node is equal, and the list is represented by external head/last pointers. Headless lists cannot be empty — that must be handled as a special case.

A sentinel head node simplifies things considerably:

The empty list:

flowchart LR
  list((list))
  list --> list
Loading

Added a new node:

flowchart LR
  list((list))
  x((x))
  list --> x --> list
Loading

The head is always both before the first and after the last node, eliminating edge cases. It can also carry bookkeeping data such as the last pointer.

General operations on circular singly linked lists

pop()

Having the previous node allows us to define the necessary operations:

const pop = prev => {
  const node = prev.next,
    next = node.next;

  // exclude the node
  prev.next = next;

  // circle the node
  node.next = node;

  return {extracted: {prevFrom: node, to: node}, rest: next === node ? null : next};
};

This function extracts the node and returns the rest of the list. It takes O(1) time.

extract()

This operation is generalization of pop() above. It takes O(1) time:

const extract = ({prevFrom, to = prevFrom.next}) => {
  const node = prevFrom.next,
    next = to.next;

  // exclude the range
  prevFrom.next = to.next;

  // circle the range
  to.next = node;

  return {extracted: {prevFrom: to, to}, rest: next === node ? null : next};
};

We can see that pop(prev) === extract({prevFrom: prev}).

splice()

export const splice = (target, {prevFrom, to = prevFrom.next}) => {
  // form the combined head
  const next = target.next;
  target.next = prevFrom.next;

  // finish the combined  tail
  prevFrom.next = to.next;
  to.next = next;

  return target;
};

Removes a range (defined by prevFrom and to) from its list and inserts it after target. O(1).

Traversal

Headless circular lists:

let node = head;
do {
  // do something with the node
  node = node.next;
} while (node !== head);

Hosted circular lists:

for (let node = list.next; node !== list; node = node.next) {
  // do something with the node
}

An inclusive range (from fromNode to toNode):

for (let node = fromNode; ; node = node.next) {
  // do something with the node
  if (node === toNode) break;
}

Complexity considerations

Maintaining a node count forces splice() to O(k) for counting transferred nodes. In practice, emptiness checks (O(1)) suffice — the exact size is rarely needed.

The same applies to per-node list-owner references: useful, but makes every splice O(k).

Clone this wiki locally