Singly Linked List
This week I want to take a deep dive into Singly Linked Lists. A Singly Linked List is a linear data structure that uses pointers that reference the next element in the list.
Structure and Terminology
A single data item in a Singly Linked List is called a node
. A node
will store two things:
- A Value (I will use
val
), which is the thing you want stored - A Next reference (I will use
next
), which is the pointer that references the next item in the list
As for the list itself, it will always keep track of something called the head
, which is simply the node
that starts the Linked List. The head
's next
will point to the next item in the Linked List, and then that node
's next
will point to the next item in the Linked List, and so on. The final node
in the list is called the tail
, and its next
will simply be set to null
.
Sometimes, in addition to tracking the head
, a Linked List will also keep track of its tail
. This is definitely not necessary, and with certain Linked List implementations it may be more trouble than it's worth. While keeping track of the tail
doesn't take up a lot of memory, it does add extra steps when you're implementing certain operations, like insertion or deletion within the list. I will be implementing a Linked List that tracks its tail
.
Finally the last thing that most Linked Lists will keep track of is the size of the list, usually saved as size
or length
. I'll be using length
here. I think I like it as a carry-over from Arrays.
So what does a Singly Linked List look like? Here's a very basic example:
1 -> 10 -> 100 -> 1000
In this little example, each number represents a node
within the list, and for each node
, the number represents that node
's value, and the arrow represents the next
pointer reference. 1
would be the head
of the list. Its next
would reference 10
. Then 10
's next
would reference 100
. Then 100
's next
would reference 1000
. Finally, 1000
's next
would be set to null
, making it the list's tail
. The length
of the list is 4
, as there are 4 items in the list.
Linked List vs. Array
So how does a Linked List differ from an Array, and what are its benefits? The big, glaring difference is that there is no native Linked List data structure in JavaScript. Arrays come bundled in with the language, along with a host of useful methods that have been tried in real-world applications for decades. With a Linked List, you need to create the Linked List data structure yourself, as well as implementations for things like insertion, deletion, or fetching. All of this before you even have a Linked List that you can use.
Beyond that, an Array requires contiguous memory to store all of its items. This can be very demanding if you have a huge array with thousands of items. A Linked List, however, doesn't need all of its node
s to be store in the same place, they just need to point to one another. This allows for Linked Lists to have dynamic size within memory, which is usually a benefit.
Another huge difference between the two data structures is in how you access individual items in them. As a trivial example, say you have an Array that has 5 items, and a Linked List that has 5 items, each holding the same values.
Array:const arr = [10, 20, 30, 40, 50]
Linked List:const list = 10 -> 20 -> 30 -> 40 -> 50
Now say that you want to access the 4th item of the Array, which is the item at index
3. You can simply pass it that index
and retrieve it, which looks like arr[3]
.
There is no indexing in Linked Lists, though. Remember that a list only keeps track of its head
and its length
, and sometimes its tail
. Instead, you need to have some mechanism that starts at the head
, and keeps count of how many items you have looked at.
Imagine we have a while
loop, where each iteration looks at an item in the Linked List, starting with the head
. On the first iteration, 10
is the head
that we're looking at. We've looked at 1 item. On the second iteration, we move onto the next
value, which is 20
. We've now looked at 2 items. On the third iteration, we move onto the next
item, which is 30
. We've now looked at 3 items. On the fourth iteration, the next
item is 40, which is 4 items. We wanted the fourth item in the Linked List, so we can exit our while
loop and return 40
.
All of this is to illustrate that there is no random access in Linked Lists. Arrays index their items, making it easy to grab data from anywhere in the list, but in a Singly Linked List, in the worst case scenario the value you're looking for is found at the end of the list, so you have to iterate through the entire list, resulting in a time complexity of O(n)
for Access.
Speaking of indexing though, you may recall that indexing is what makes insertion into an Array an expensive operation, especially if you are adding items to the beginning of the Array. The .unshift()
method that adds something to the beginning of an Array requires re-indexing the entire rest of the Array.
let arr = [10, 20, 30, 40, 50];
arr.unshift(1); // we have to re-index everything in the array!!
The Linked List doesn't have this difficulty. If we're inserting at the start of the list, it's simply a matter of updating the list's head
property, or if we're working in the middle, the next
property of the node
that will be referencing the new node
. Since it's only a couple of operations, Insertion is incredibly easy in a Linked List, achieving a Time Complexity of O(1)
. Deletion is also very easy in a Linked List (and difficult in Arrays) for the same reason.
Linked List | Array |
---|---|
Doesn't exist natively in JS | Exists natively in JS |
Doesn't need contiguous memory | Needs contiguous memory |
No random access | Very easy random access |
Very easy insertion | Insertion requires re-indexing |
Singly Linked List vs. Doubly Linked List
Finally, I want to distinguish the Singly Linked List from a Doubly Linked List. A Singly Linked List always keeps track of a head
, and each node
has a next
pointer that references the next item in the list. That item has a next
that references another item, and so on until we reach the tail
, whose next
property is set to null
.
The node
s in a Doubly Linked List, on the other hand, will also have a prev
pointer, which points to the previous item in the list. So the second item in the list has a prev
reference that points to the head
as well as a next
reference that points to the third item. So in a Singly Linked List you can only start at the head
and work in the direction of the tail
, a Doubly Linked List allows you to also start at the tail
and work backwards towards the head
. This makes certain operations much easier, but it also adds to the memory needed for each node
and means you need to keep track of more pointers if you're adding or deleting items.
I'll likely be writing about Doubly Linked Lists soon, so I won't go into more detail here. For the purposes of this post when I say "Linked List" or "list" (outside of this section) I'll be referring to a Singly Linked List.
Big O of a Singly Linked List
Operation | Complexity |
---|---|
Insertion | O(1) |
Removal | It depends...O(1) or O(n) |
Searching | O(n) |
Access | O(n) |
Insertion: Inserting things is really easy on a Singly Linked List. If we're adding the new item to the end of the list, we simply reassign the tail
(that is, as long as the list keeps track of its tail
). If we're adding it to the beginning, we reassign the head
. Otherwise if we're inserting it into the middle of the list, we just need to make a new node
and reassign the next
pointer of the item before it. It doesn't matter if the list is 10 items or 10000 items. Contrast this with an Array, where adding something at the beginning means re-indexing every subsequent item in the Array.
Removal: It depends on where in the list we're removing the item from. If we're removing from the very beginning, it's O(1)
as it's a simple matter of rea-ssigning the head
. However, if we're removing from the very end, we need to traverse the entire Linked List to get to that last element. Even if we're keeping track of the tail
, we will need to find the item before the tail
, since that is what will become the new tail
. That requires traversing the whole list.
Searching: In the worst case scenario, we need to look at every single item in the list to see if the desired value is present.
Access: Same as searching. As the list grows, so does the number of operations. Compare this to an Array, where Random Access is allowed; the indexing takes time, but if you have the index
, it's much simpler to find an indexed item.
Singly Linked List Implementation
Here's the full implementation for future reference. Sections below will detail each method.
class Node { constructor(val) { this.val = val; this.next = null; }}
class SinglyLinkedList { constructor() { this.head = null; this.tail = null; this.length = 0; } get(index) { if (index < 0 || index >= this.length) { return undefined; } var counter = 0; var current = this.head; while (counter !== index) { current = current.next; counter++; } return current; } insert(index, val) { if (index < 0 || index > this.length) { return false; } if (index === this.length) { return !!this.push(val); } if (index === 0) { return !!this.unshift(val); } var newNode = new Node(val); var prev = this.get(index - 1); var temp = prev.next; prev.next = newNode; newNode.next = temp; this.length++; return true; } pop() { if (!this.head) { return undefined; } var currentTail = this.head; var newTail = currentTail; while (currentTail.next) { newTail = currentTail; currentTail = currentTail.next; } this.tail = newTail; this.tail.next = null; this.length--; if (this.length === 0) { this.head = null; this.tail = null; } return currentTail; } push(val) { var newTail = new Node(val); if (!this.head) { this.head = newTail; this.tail = this.head; } else { this.tail.next = newTail; this.tail = newTail; } this.length++; return this; } remove(index) { if (index < 0 || index >= this.length) { return undefined; } if (index === 0) { return this.shift(); } if (index === this.length - 1) { return this.pop(); } var prev = this.get(index - 1); var removedNode = prev.next; prev.next = removedNode.next; removedNode.next = null; this.length--; return removedNode; } reverse() { var node = this.head; this.head = this.tail; this.tail = node; var next; var prev = null; for (var i = 0; i < this.length; i++) { next = node.next; node.next = prev; prev = node; node = next; } return this; } set(index, val) { var foundNode = this.get(index); if (foundNode) { foundNode.val = val; return true; } return false; } shift() { if (!this.head) { return undefined; } var currentHead = this.head; this.head = currentHead.next; this.length--; if (this.length === 0) { this.tail = null; } return currentHead; } unshift(val) { if (!this.head) { this.push(val); } else { var newHead = new Node(val); newHead.next = this.head; this.head = newHead; this.length++; return this; } }}
I'd like to note that this is just one of many ways to implement a Linked List. Different implementations will have methods that work in different ways or return different values. .get()
for example here returns the node
itself that you're looking for. I have seen other .get()
methods that return the value of the node
instead. That's okay.
There is also optimization to be achieved here. What I'm aiming for is an exploration of the concepts behind a Linked List — if you know how to properly traverse the list using multiple pointers to find what you're looking for, and what it takes to add and remove values within the list then that is the important thing.
Operations like .pop()
, or .shift()
can be simplified with the use of the .get()
method (and I do use .get()
in the .set()
and .insert()
methods as an example), but I've written the methods here in a way (and detailed them below in an order) that I hope can help teach the fundamentals of working within a Singly Linked list.
Let's break it apart into smaller pieces and methods to figure out what's going on for each method.
Node Class
Each item within the Linked List is called a node
. Any time we add a node
into the Linked List, we'll do it using a Node Class. This class accepts a value, val
, and also has a next
property. By default next
will be assigned the value of null
, but we'll commonly be assigning next
to point to another node
in the list.
class Node { constructor(val) { this.val = val; this.next = null; }}
Singly Linked List Class
We'll also use a Singly Linked List Class to initialize the list itself. We're starting here just with its constructor, but we'll add in all of the needed methods as we go. On initialization, an empty Singly Linked List will have a head
set to null
, a tail
also set to null
, and a length
set to 0
.
class SinglyLinkedList { constructor() { this.head = null; this.tail = null; this.length = 0; }}
Methods and Operations
.push()
The first method we'll introduce is the .push()
method, which adds a new node
to the end of the linked list, in practical terms creating a new tail
. This method accepts a value, val
, that we want to attach to the end of the list.
Here are the steps we take to create the .push()
method:
- Create a new
node
with the valueval
. I'll store this in a variable callednewTail
- Check if the list is empty. We'll know we have an empty list if the list has no
head
- If the list is empty:
- Assign the new
node
as thehead
- Assign the
tail
to also equal thehead
- Assign the new
- If the list isn't empty:
- Set the current
tail
'snext
to equal the newnode
- Assign the new
node
as thetail
- Set the current
- Increment the
length
by 1 - Return the list
.push()
Implementation
push(val) { var newTail = new Node(val); if (!this.head) { this.head = newTail; this.tail = this.head; } else { this.tail.next = newTail; this.tail = newTail; } this.length++; return this;}
.pop()
Now let's look at the .pop()
method, which removes a node
from the end of the Linked List. Unfortunately this method is kind of complicated, but I'll walk you through it.
- Return
undefined
if the list is empty (again, if there's nohead
then it's an empty list) - We need to iterate through the list and find two things:
- The current
tail
- The item that comes right before the current
tail
. This will become our newtail
- The current
- Create two variables:
currentTail
- set this to equal the list'shead
newTail
- set this to equalcurrentTail
- Create a
while
loop that iterates as long as there is acurrentTail.next
. Within this loop:- set
newTail
to equalcurrentTail
- set
currentTail
to equalcurrentTail.next
- Once
currentTail.next = null
, our loop stops running.currentTail
is equal to the currenttail
andnewTail
is equal to the item right before the currenttail
.
- set
- Set the
tail
to equalnewTail
- Set
tail.next
to equalnull
, breaking the link between thisnode
and the oldtail
- Decrement the
length
by 1. - If
length
is now equal to 0, then we have now have an empty list, meaning we need to:- Set the
head
to equalnull
- Set the
tail
to equalnull
- Set the
- Finally, return the
node
that we have removed from the list. Remember that this oldtail
is stored in thecurrentTail
variable
.pop()
Implementation
pop() { if (!this.head) { return undefined; } var currentTail = this.head; var newTail = currentTail; while (currentTail.next) { newTail = currentTail; currentTail = currentTail.next; } this.tail = newTail; this.tail.next = null; this.length--; if (this.length === 0) { this.head = null; this.tail = null; } return currentTail;}
.unshift()
Let's now look at the .unshift()
method, which adds a new value to the beginning of the Linked List. Fortunately for us this is very similar to the .push()
method, but instead of reassigning the tail
, we're reassigning the head
.
- Create a new
node
with the valueval
- Check if the list is empty. We'll know we have an empty list if the list has no
head
- If the list is empty:
- Assign the new
node
as thehead
- Assign the
head
to also equal thetail
- Assign the new
- If the list isn't empty:
- Set the new
node
'snext
to point to the list's currenthead
- Assign the new
node
as thehead
- Set the new
- Increment the
length
by 1 - Return the list
.unshift()
Implementation
unshift(val) { var newHead = new Node(val); if (!this.head) { this.head = newHead; this.tail = this.head; } else { newHead.next = this.head; this.head = newHead; } this.length++; return this;}
.unshift()
Optimization
I want to go a step further and optimize this code a little bit. If there is no head
and the list is empty, we can simply use the .push()
method, since that would take care of the logic of assigning the head
and tail
. The rest of the logic would remain the same. This is completely optional, but I want to introduce the idea of using methods we've already written. This allows us to not have to repeat logic within our code, and some of the methods below will use several pre-written methods.
.unshift()
Optimized Implementation
unshift(val) { if (!this.head) { this.push(val); } else { var newHead = new Node(val); newHead.next = this.head; this.head = newHead; this.length++; return this; }}
.shift()
Now let's remove an item from the beginning of the list with a method called .shift()
. This is much, much easier than the .pop()
method.
- Check again if the list is empty (this should be familiar by now). If so, return
undefined
- Store the current list's
head
in a variable namedcurrentHead
- Set the list's
head
to equalcurrentHead
'snext
- Decrement the length
- If
length
is now equal to 0, then we now have an empty list, meaning we need to set thetail
to equalnull
- Return the old
head
that we have removed, which is stored incurrentHead
.shift()
Implementation
shift() { if (!this.head) { return undefined; } var currentHead = this.head; this.head = currentHead.next; this.length--; if (this.length === 0) { this.tail = null; } return currentHead;}
.get()
.get()
is a handy method that can retrieve a node at a specific index
within the list. We'll be using it below in the .set()
and .insert()
methods, and could even likely use it in some of the methods we've written above.
.get()
accepts an index
, which corresponds to a place within the list. Like an array, the first item in the Linked List is at index
0, the second item is at index
1, the third item is at index
2, and so on.
- Check to make sure the
index
is valid. If the index is invalid, returnundefined
. Theindex
is invalid if:- It is a negative value
- It is greater than or equal to the
length
of the list. If a list is 3 items long, then the highestindex
is 2. If the list is 100 items long, then the highest index is 99
- Now we need to traverse the list. Set a
counter
variable equal to 0, and acurrent
variable equal to the list'shead
- Within a
while
loop, check whethercounter
is equal toindex
. If not:- Set
current
equal tocurrent.next
- Increment
counter
by 1
- Set
- Once
counter
is equal toindex
,current
will be equal to thenode
that we're looking for. Return thisnode
.
.get()
Implementation
get(index) { if (index < 0 || index >= this.length) { return undefined; } var counter = 0; var current = this.head; while (counter !== index) { current = current.next; counter++; } return current;}
.set()
.set()
accepts an index
and a value, val
, and changes the value of the node
at that index
to val
. We once again need to make sure that the index
is valid, and then iterate through the list to the correct item. But luckily for us, the .get()
method takes care of that logic for us.
- Use the
.get()
method to retrieve thenode
at the providedindex
. Store this in a variable namedfoundNode
.get()
will returnundefined
for us if it's an invalidindex
.get()
will also do all of the iteration for us
- If
.get()
returns ourfoundNode
:- Set
foundNode
'sval
to theval
that was passed to the.set()
method - Return
true
- Set
- Otherwise, return
false
.set()
Implementation
set(index, val) { var foundNode = this.get(index); if (foundNode) { foundNode.val = val; return true; } return false;}
.insert()
The next method we'll work on is .insert()
, which takes an index
and a value, val
. This method will insert val
into the list at the desired index
, shifting the item that was previously at that index
later into the list. This method will return false
if the index
passed is invalid, or true
if the index
is valid and the insertion is done successfully.
- Check to make sure the
index
is a valid number. This is the same as above. Returnfalse
if it is an invalidindex
. - If the
index
is equal to the list'slength
property, then we can just use the.push()
method to addval
as the newtail
- We can use a
!!
double bang operator to returntrue
after using the.push()
method
- We can use a
- If
index
is 0, then we can just use the.unshift()
method to addval
as the newhead
- We can use a
!!
double bang operator to returntrue
after using the.unshift()
method
- We can use a
- If we've gotten this far, then the
index
is somewhere in the middle of the list and we can't just rely on the other methods we've written so far - Create a new
node
with the valueval
, and assign it to a variable namednewNode
- Retrieve the
node
right before the item currently located atindex
, and assign it to a variable namedprev
. We can use the.get()
method and pass itindex - 1
to achieve this - Retrieve the
node
currently at located atindex
(same asprev.next
), and assign it to a variable namedtemp
- Set
prev
'snext
pointer to referencenewNode
- Set
newNode
'snext
pointer to referencetemp
- Increment
length
by 1 - Return
true
.insert()
Implementation
insert(index, val) { if (index < 0 || index > this.length) { return false; } if (index === this.length) { return !!this.push(val); } if (index === 0) { return !!this.unshift(val); } var newNode = new Node(val); var prev = this.get(index - 1); var temp = prev.next; prev.next = newNode; newNode.next = temp; this.length++; return true;}
.remove()
Now let's look at the .remove()
method, which accepts an index
, removes the item at that index
from the list, and returns the removed node
. Fortunately this logic is very similar to .insert()
.
- Check if it's a valid
index
. If it's not, returnundefined
- If
index
is 0, we can just use the.shift()
method to remove the currenthead
- If the
index
is equal tolength - 1
, we can just use the.pop()
method to remove the currenttail
- Otherwise we need to go about removing a
node
from the middle of the list - Retrieve the
node
right before the item that we want to remove, and assign it to a variable namedprev
. We can use the.get()
method and pass itindex - 1
to achieve this - Retrieve the
node
currently at located atindex
(same asprev.next
), and assign it to a variable namedremovedNode
- Set
prev
'snext
pointer to refer toremovedNode.next
- Set
removedNode
'snext
tonull
, breaking the reference between thenode
you're removing and the list. This way theremovedNode
does not present a falsehead
- Decrement
length
by 1 - Return the
node
we removed, currently stored in theremovedNode
variable
.remove()
Implementation
remove(index) { if (index < 0 || index >= this.length) { return undefined; } if (index === 0) { return this.shift(); } if (index === this.length - 1) { return this.pop(); } var prev = this.get(index - 1); var removedNode = prev.next; prev.next = removedNode.next; removedNode.next = null; this.length--; return removedNode;}
.reverse()
Well here we go with our last method on the Singly Linked List. We're going to write the method to reverse our Linked List in place. This is a common tech interview question. It's not something that is likely done a whole lot in real world applications, but being able to illustrate how a list reversal is done demonstrates an understanding of the concepts behind a Singly Linked List and effectively using numerous pointers to keep track of data as you move it around. If SEO brought you here and you're reading this, welcome. I hope you find value in the rest of this post, as well as my other blog posts 🙂.
So here are some (probably poorly written) instructions for the .reverse()
method:
- Swap the
head
and thetail
:- Store
head
in a temporarynode
variable - Assign the
head
to equal thetail
- Assign the
tail
to equal the temporarynode
variable
- Store
- Create a
next
variable, but do not assign anything to it - Create a
prev
variable, assigned the value ofnull
- Loop through the length of the list. Within this loop:
- Set the
next
variable to equal the temporarynode
'snext
value - Set the temporary
node
'snext
to equalprev
- Set
prev
to equal what's currently in the temporarynode
variable - Assign the temporary
node
to equalnext
- Set the
- After the loop has finished, return the list, which will now be reversed
Step By Step Example of Linked List Reversal
So what's going on here? Let's slow down and walk through this with an example, following our variable names.
Here is our initial list:
10 -> 20 -> 30 -> 40 -> 50- head: 10- tail: 50
We start by swapping the head
and tail
, using a temporary node
variable along the way.
var node = this.head;this.head = this.tail;this.tail = node;
Our list stays the same, but head
and tail
assignments have swapped. node
still points to 10, which still has a next
property that points to 20, which still has a next
property that points to 30, and so on.
We also initialize prev
and next
variables. next
can be left as undefined
for now (but we immediately assign it at the start of each iteration of the loop), and prev
can be set to null
(this is where the new tail
's next
reference will point).
So as things currently stand, before we start our for
loop, here is our list, and each of the variables:
10 -> 20 -> 30 -> 40 -> 50- head: 50- tail: 10- node: 10- prev: null- next: (undefined)
Now we start looping. We immediately assign next
to be node.next
, storing the second item in the list into this temporary next
variable. We reassign node.next
to point to prev
, so 10's next
points to null
.
null <- 10 20 -> 30 -> 40 -> 50prev node next
So now all we have to do is move prev
up to equal node
, and move node
up to equal next
(this step is why we created the temporary next
variable), which leaves us with the following:
null <- 10 20 -> 30 -> 40 -> 50 prev next node- head: 50- tail: 10- node: 20- prev: 10- next: 20
That's it for our first iteration, and we move onto the second iteration. We once again assign next
to be node.next
, storing the third item in the list into the temporary variable. We reassign node.next
to point to prev
, so 20's next
points to 10.
null <- 10 <- 20 30 -> 40 -> 50 prev node next
Now we simply move prev
up by setting it equal to node
, and then move node
up by setting it equal to next
. This leaves us with the following:
null <- 10 <- 20 30 -> 40 -> 50 prev next node- head: 50- tail: 10- node: 30- prev: 20- next: 30
Now onto our third iteration. We reassign next
to equal node.next
, storing 40 into the temporary variable. We then reassign node.next
to point to prev
, so 30's next
points to 20.
null <- 10 <- 20 <- 30 40 -> 50 prev node next
With next
reassigned, it's time to move up prev
and node
by setting prev
equal to node
and node
equal to next
. This leaves us with the following:
null <- 10 <- 20 <- 30 40 -> 50 prev next node- head: 50- tail: 10- node: 40- prev: 30- next: 40
Hopefully the pattern is becoming clear now as we move onto the fourth iteration. We reassign next
to equal node.next
, storing 50 into the temporary variable. We then reassign node.next
to point to prev
, so 40's next
points to 30
.
null <- 10 <- 20 <- 30 <- 40 50 prev node next
Then we move prev
and node
up in the same manner as before.
null <- 10 <- 20 <- 30 <- 40 50 prev next node- head: 50- tail: 10- node: 50- prev: 40- next: 50
Now it's time for our last iteration!! Move next
. Remember that 50's next
was previously null
. Then set node.next
to equal prev
, making 50's next
point to 40.
null <- 10 <- 20 <- 30 <- 40 <- 50 (null) prev node next
Finally we move up prev
and node
one more time.
null <- 10 <- 20 <- 30 <- 40 <- 50 (null) prev next node- head: 50- tail: 10- node: null- prev: 50- next: null
That's the end of our loop, and since waaaaaay back before the loop we reassigned the head
to be 50 and the tail
to be 10, our list has been fully reversed in place. We simply need to return the list:
return this;
With that, we get a list that looks like the following:
10 <- 20 <- 30 <- 40 <- 50
Whew, that was a lot! Now let's put it all together. And remember, this Linked List Reversal implementation is entirely dependent on how we have set up our Linked List, which tracks not just the head
, but also the tail
and the length
. Other Linked Lists may not have these latter two properties, so solutions there would have some differences.
.reverse()
Implementation
reverse() { var node = this.head; this.head = this.tail; this.tail = node; var next; var prev = null; for (var i = 0; i < this.length; i++) { next = node.next; node.next = prev; prev = node; node = next; } return this;}