-
-
Notifications
You must be signed in to change notification settings - Fork 0
Concepts: improving SLL
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
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.
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
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
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
A last pointer solves this:
flowchart LR
a((a))
b((b))
c((c))
d((d))
head --> a --> b --> c --> d --> null
last --> d
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
Popping from the back still requires a full traversal.
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
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
While doing that we can efficiently maintain the last pointer:
- if
p === last, the new node becomes the last - otherwise
lastis 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
While doing that we can efficiently maintain the last pointer:
- if
p.next === last,lastbecomesp - otherwise
lastis unchanged
Knowing the previous node enables removal, insertion before, and insertion after. The front node has no previous node — circular lists solve this.
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
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
This operation can maintain the last pointer in the constant time.
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
With last it looks like this:
flowchart LR
a((a))
b((b))
c((c))
d((d))
head --> a --> b --> c --> d --> a
last --> d
This way last is the previous node for the front node. We have all possible operations described above
for all nodes.
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
Added a new node:
flowchart LR
list((list))
x((x))
list --> x --> list
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.
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.
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}).
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).
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;
}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).
Concepts
DLL: doubly linked lists
SLL: singly linked lists
Unrolled list
List utilities
Caches
Heaps
Queue, Stack, and Deque
Trees
Skip list
Timer wheel
Free list