Linked list
In short: A linked list is a chain of nodes, each pointing to the next: with head and tail, adding at either end and removing from the front are O(1), while access by index is O(n).
Problem
The queue that stops the sending
The server sends emails from a queue. New ones go in at the back, urgent ones (a password reset) at the front, and the worker always takes the first. The code uses the ordinary list:
const emails = [];emails.push("invoice");emails.push("newsletter");emails.push("report");emails.unshift("password");const next = emails.shift();console.log("Sending: " + next);console.log("Left: " + emails.join(", "));Sending: passwordLeft: invoice, newsletter, reportOn Monday morning the newsletter to all customers fills the queue with 100,000 emails. The worker sends slower than usual, then almost stops, and the password reset emails are minutes late.
The reason: the list is a dynamic array. Taking the first one moves all the others one place forward, and an urgent one at the front moves all of them back. Sending 100,000 emails one after another is 99,999 + 99,998 + … + 1 = 4,999,950,000 moves, just to take the first one.
The idea
Nodes, head and tail
You already saw the node and inserting at the front in the lesson "Stack, queue and linked list": every element is a separate object with a value and an arrow next to the following one. Here you write the whole class and work out what every operation costs.
The list keeps three things:
- head: the first node. Every walk starts there.
- tail: the last node. With it adding at the end does not go through the whole list.
- the size, so it is not counted every time.
Both ends have to be right after every operation. The empty list has head and tail null. A list with one node has head and tail that point to the same node. Almost every bug in a linked list is a forgotten end: you remove the last one, and tail still points to it.
See it
Your own linked list
Here is the whole class. The nodes never move: every operation changes only arrows.
class Node { constructor(value) { this.value = value; this.next = null; }}
class SimpleLinkedList { constructor() { this.head = null; this.tail = null; this.size = 0; }
addFirst(value) { const node = new Node(value); node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++; }
addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++; }
removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value; }
remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true; }
find(value) { let index = 0; for (let n = this.head; n !== null; n = n.next) { if (n.value === value) { return index; } index++; } return -1; }
get(index) { if (index < 0 || index >= this.size) { throw new RangeError(`index ${index}, size ${this.size}`); } let n = this.head; for (let i = 0; i < index; i++) { n = n.next; } return n.value; }
toString() { const values = []; for (let n = this.head; n !== null; n = n.next) { values.push(n.value); } return values.join(" → "); }}Watch which arrows change on every operation:
class Node { constructor(value) { this.value = value; this.next = null; }}
constructor() { this.head = null; this.tail = null; this.size = 0;}class Node { constructor(value) { this.value = value; this.next = null; }}
constructor() { this.head = null; this.tail = null; this.size = 0;}addFirst(value) { const node = new Node(value); node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++;}addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++;}removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value;}remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true;}Step by step, in words
- Three emails, each in its own node: the value and next, the arrow to the following one. head points to the first, tail to the last.
- addFirst("password"): a new node, its next points to the old first, and head moves to it. Two arrows, nothing moves: O(1).
- addLast("invite"): tail already knows the last, so nothing is walked. Its next points to the new node, and tail moves: O(1).
- removeFirst(): head moves to the next node. Nothing points to "password" any more, and its memory is freed by itself.
- remove("newsletter"): nodes have no index. n starts at head and walks until it finds it; prev stays one node behind. That is O(n).
- prev.next = n.next: one arrow goes around "newsletter". "report" and "invite" do not move.
- At the ends: two arrows, O(1). In the middle: one arrow, but you reach the place step by step, O(n).
const emails = new SimpleLinkedList();emails.addLast("invoice");emails.addLast("newsletter");emails.addLast("report");emails.addFirst("password");emails.addLast("invite");console.log(String(emails));console.log("Sending: " + emails.removeFirst());emails.remove("newsletter");console.log(String(emails));console.log("Position of report: " + emails.find("report"));console.log("Emails: " + emails.size);password → invoice → newsletter → report → inviteSending: passwordinvoice → report → invitePosition of report: 1Emails: 3removeFirst on an empty list throws Error. toString is what String(emails) and "…" + emails use.Try it
Predict the output
Seven operations in a row. Watch which one is the last after the remove:
class Node { constructor(value) { this.value = value; this.next = null; }}
class SimpleLinkedList { constructor() { this.head = null; this.tail = null; this.size = 0; }
addFirst(value) { const node = new Node(value); node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++; }
addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++; }
removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value; }
remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true; }
find(value) { let index = 0; for (let n = this.head; n !== null; n = n.next) { if (n.value === value) { return index; } index++; } return -1; }
get(index) { if (index < 0 || index >= this.size) { throw new RangeError(`index ${index}, size ${this.size}`); } let n = this.head; for (let i = 0; i < index; i++) { n = n.next; } return n.value; }
toString() { const values = []; for (let n = this.head; n !== null; n = n.next) { values.push(n.value); } return values.join(" → "); }}
const emails = new SimpleLinkedList();emails.addFirst("report");emails.addFirst("invoice");emails.addLast("invite");emails.removeFirst();emails.addFirst("password");emails.remove("invite");emails.addLast("newsletter");console.log(String(emails));console.log("Emails: " + emails.size);After the first five the list is password → report → invite. remove removes the last one and moves tail to report. So newsletter is attached after report. If tail still pointed to the removed one, newsletter would be attached to it and disappear from the list.
The idea
What costs what: array against linked list
| Operation | Dynamic array | Linked list |
|---|---|---|
| read by index | O(1) | O(n) |
| add at the front | O(n) | O(1) |
| add at the back | O(1) amortized | O(1) with tail |
| remove the first | O(n) | O(1) |
| remove the last | O(1) | O(n), doubly linked: O(1) |
| in the middle | O(n): moving | O(n): walking to the place |
The last row is the trap. The relinking in the middle is O(1) by itself, but you reach the place from head, step by step. The linked list wins in the middle only if you already hold the node, for example because you just found it with a hash table.
There is a cost in memory too. The array keeps the values (or the references) next to each other. Every node is a separate object: the value, the arrow and the header the runtime keeps for every object. With small values a node takes several times more memory than a place in the array.
See it
Measure it
Inserting at the front: the dynamic array from the previous lesson against the linked list. We count the writes of elements: for the array the new one and every move, for the list only the new node. The arrows that change are two for every insert and do not grow with n.
- Insert at the front
O(n²) - Linked list: at the front
O(n)
| Algorithm | n = 10 | n = 1,000 | n = 10,000 | n = 100,000 |
|---|---|---|---|---|
| Insert at the front | 63 | 501,516 | 50,021,376 | ≈ 5,002,137,600 |
| Linked list: at the front | 10 | 1,000 | 10,000 | 100,000 |
class Node { constructor(value) { this.value = value; this.next = null; }}
class SimpleLinkedList { constructor() { this.head = null; this.tail = null; this.size = 0; }
addFirst(value) { const node = new Node(value); node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++; }
addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++; }
removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value; }
remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true; }
find(value) { let index = 0; for (let n = this.head; n !== null; n = n.next) { if (n.value === value) { return index; } index++; } return -1; }
get(index) { if (index < 0 || index >= this.size) { throw new RangeError(`index ${index}, size ${this.size}`); } let n = this.head; for (let i = 0; i < index; i++) { n = n.next; } return n.value; }
toString() { const values = []; for (let n = this.head; n !== null; n = n.next) { values.push(n.value); } return values.join(" → "); }}With 10,000 emails the array makes 50,021,376 writes, and the linked list 10,000.
Now the other way round: reading every element by index, get(0), get(1) and so on to the end. We count the places you reach: in the array get(i) is one jump, and in the list it passes i + 1 nodes.
- Array: get(i) for every i
O(n) - Linked list: get(i) for every i
O(n²)
| Algorithm | n = 10 | n = 1,000 | n = 10,000 | n = 100,000 |
|---|---|---|---|---|
| Array: get(i) for every i | 10 | 1,000 | 10,000 | 100,000 |
| Linked list: get(i) for every i | 55 | 500,500 | 50,005,000 | ≈ 5,000,500,000 |
class Node { constructor(value) { this.value = value; this.next = null; }}
class SimpleLinkedList { constructor() { this.head = null; this.tail = null; this.size = 0; }
addFirst(value) { const node = new Node(value); node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++; }
addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++; }
removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value; }
remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true; }
find(value) { let index = 0; for (let n = this.head; n !== null; n = n.next) { if (n.value === value) { return index; } index++; } return -1; }
get(index) { if (index < 0 || index >= this.size) { throw new RangeError(`index ${index}, size ${this.size}`); } let n = this.head; for (let i = 0; i < index; i++) { n = n.next; } return n.value; }
toString() { const values = []; for (let n = this.head; n !== null; n = n.next) { values.push(n.value); } return values.join(" → "); }}With 10,000 elements the array makes 10,000 jumps, and the linked list 50,005,000 steps. A loop by index over a linked list is O(n²). Walk it with the loop along the arrows (or with for-each), as toString does: that is O(n).
More precisely, if you are curious
The same numbers in your language, with counters in the class:
class Node { constructor(value) { this.value = value; this.next = null; }}
class SimpleLinkedList { constructor() { this.head = null; this.tail = null; this.size = 0; this.writes = 0; this.steps = 0; }
addFirst(value) { const node = new Node(value); this.writes++; node.next = this.head; this.head = node; if (this.tail === null) { this.tail = node; } this.size++; }
addLast(value) { const node = new Node(value); if (this.tail === null) { this.head = node; } else { this.tail.next = node; } this.tail = node; this.size++; }
removeFirst() { if (this.head === null) { throw new Error("the list is empty"); } const value = this.head.value; this.head = this.head.next; if (this.head === null) { this.tail = null; } this.size--; return value; }
remove(value) { let prev = null; let n = this.head; while (n !== null && n.value !== value) { prev = n; n = n.next; } if (n === null) { return false; } if (prev === null) { this.head = n.next; } else { prev.next = n.next; } if (n === this.tail) { this.tail = prev; } this.size--; return true; }
find(value) { let index = 0; for (let n = this.head; n !== null; n = n.next) { if (n.value === value) { return index; } index++; } return -1; }
get(index) { if (index < 0 || index >= this.size) { throw new RangeError(`index ${index}, size ${this.size}`); } let n = this.head; this.steps++; for (let i = 0; i < index; i++) { n = n.next; this.steps++; } return n.value; }
toString() { const values = []; for (let n = this.head; n !== null; n = n.next) { values.push(n.value); } return values.join(" → "); }}
for (const n of [10, 1000, 100000]) { const list = new SimpleLinkedList(); for (let i = 0; i < n; i++) { list.addFirst(String(1001 + i)); } console.log(`at the front, n = ${n}: ${list.writes} writes`);}for (const n of [10, 1000]) { const list = new SimpleLinkedList(); for (let i = 0; i < n; i++) { list.addLast(String(1001 + i)); } for (let i = 0; i < n; i++) { list.get(i); } console.log(`get(i) for every i, n = ${n}: ${list.steps} steps`);}at the front, n = 10: 10 writesat the front, n = 1000: 1000 writesat the front, n = 100000: 100000 writesget(i) for every i, n = 10: 55 stepsget(i) for every i, n = 1000: 500500 stepsBench counts operations, not time. On the clock the difference when walking is even bigger in favour of the array because of the cache: the array is one block, and the nodes are scattered and every next one may not be in the cache. That is not measured here, only explained in the lesson on the dynamic array.
Try it
Find the bug
A colleague has written addLast. Three emails at the back, and the list comes out like this:
const emails = new SimpleLinkedList();emails.addLast("invoice");emails.addLast("newsletter");emails.addLast("report");console.log(String(emails));console.log("Emails: " + emails.size);invoice → reportEmails: 3The size is 3, and the emails are two. Which line is the bug on?
Pick the line with the bug.
The new node is attached to head, not to the last one. The second email goes in as the next of the first. The third again becomes the next of the first and replaces the second, which is no longer in the list. The new one has to be attached to tail, which is why tail is kept.
Try it
Fill in
remove has found the node n and the one before it, prev. Where does prev have to point now?
remove(value) {
let prev = null;
let n = this.head;
while (n !== null && n.value !== value) {
prev = n;
n = n.next;
}
if (n === null) {
return false;
}
if (prev === null) {
this.head = n.next;
} else {
prev.next = ;
}
if (n === this.tail) {
this.tail = prev;
}
this.size--;
return true;
}prev has to point to the node after n, to go around it: n.next. If n is the last one, that is null, and that is why tail moves to prev below.
The idea
The doubly linked list
A singly linked list cannot remove the last one fast: it needs the one before the last, and nothing points back to it. A doubly linked list keeps two arrows in every node, next and prev. Then both ends are O(1), and a node you already hold is removed with four arrows, without searching for the previous one.
The cost is one more arrow in every node and more to relink on every operation.
JavaScript has no built-in linked list. The array hasunshift and shift, but they move everything.The idea
Where the linked list really wins
For an ordinary queue the built-in one is better. JavaScript has none ready, but an array with an index of the first one moves nothing. How it works is in the lesson Stack and queue. The linked list wins when you already hold the node and have to take it out or move it:
- An LRU cache. The shop keeps the last opened products in memory and throws out the one opened longest ago. A hash table finds the node of the product in O(1), and a doubly linked list moves it to the back. The oldest is always at the front.
- History with back and forward. The browser and the editor keep the steps in a doubly linked list: back and forward are one arrow.
- The lists in the operating system kernel. Processes and buffers carry their own arrows and are taken out of the list in O(1).
Here is an LRU with the built-in tools, the last three opened products:
class Recent { constructor(limit) { this.limit = limit; this.items = new Set(); }
open(product) { if (this.items.has(product)) { this.items.delete(product); } else if (this.items.size === this.limit) { const oldest = this.items.values().next().value; this.items.delete(oldest); } this.items.add(product); }
toString() { return [...this.items].join(", "); }}
const recent = new Recent(3);for (const product of ["laptop", "mouse", "monitor", "laptop", "keyboard"]) { recent.open(product); console.log(String(recent));}laptoplaptop, mouselaptop, mouse, monitormouse, monitor, laptopmonitor, laptop, keyboardSet in JavaScript keeps the order of adding. Inside it is not a linked list but a hash table with an array of entries, yet delete and add still give O(1) on average. Your own LRU with a linked list is the last task below.Exercise
A doubly linked list
Solve it in your editor.
Removing from both ends. Make a DoublyLinkedList: the node has prev too. Write addFirst, addLast, removeFirst and removeLast, all in O(1). Keep both ends right: when the list becomes empty, both head and tail are null.
Add the three emails at the back and the password one at the front. Print the list, remove the last and the first, print it again. Remove two more from the back, print the size, then add the invite at the back:
password → invoice → newsletter → reportLast: reportFirst: passwordinvoice → newsletterEmails: 0inviteMore tasks
- The middle with two pointers. Find the middle node in one pass: one pointer goes one step at a time, the other two. When the fast one reaches the end, the slow one is in the middle.
- A cycle in the list. A bug has attached the last node to one of the previous ones. Detect it with the same two pointers: if there is a cycle, the fast one catches up with the slow one.
- Remove all occurrences.
removeAll(value)removes every node with that value in one pass and returns how many it removed. Do not forget tail. - LRU by hand. Make the LRU cache from the card above with your own doubly linked list and a
Mapfrom product to node.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Best, average and worst case · Space complexity · Amortized complexity · The dynamic array from the inside
- Continues in: Hash table · Binary search tree
- Tasks on this topic: Data structures