Doubly Linked List
A few weeks ago I did a post focused on that classic data structure, the Singly Linked List. Today I want to focus on a similar structure, the Doubly Linked List. If you haven't already, I highly recommend that you read the Singly Linked List post first. It probably isn't a surprise that the two structures are very similar, and many of the concepts carry over from one to the other.
A Doubly Linked List is a linear data structure that uses pointers to reference adjacent items within the list.
Structure and Terminology
A single data item in a Doubly Linked List is called a node
. A node
in a Doubly Linked List stores three things:
- A Value (I use
val
), which is the thing you want stored - A Next reference (I use
next
), which is the pointer that references the next item in the list - A Previous reference (I use
prev
), which is the pointer that references the previous item in the list
So where a node
in a Singly Linked List only has a next
pointer that points to the subsequent item in the list, an item in a Doubly Linked List will also have a prev
or "previous" pointer, which points to the previous item in the list.
In practical terms, the addition of the previous pointer means that traversing the list in two directions is possible, making certain operations like .pop()
or .reverse()
a little bit easier. The trade-off is that each list has to store a new pointer, so more memory is needed, and when items are removed from or added to the list, this additional pointer needs to be taken into consideration.
This previous pointer is the main difference between a Singly and a Doubly Linked List. Everything else is otherwise the same. The list will always keep track of the head
, which is the node
that starts the Linked List.
A Doubly Linked List may also keep track of its tail
- the last node
in the list. I imagine this is more common with a Doubly Linked List than a Singly Linked List. Accessing items from the end of the list using the prev
pointers provides some optimization that would otherwise be impossible without quick access to the tail
. A Doubly Linked List might also keep track of the length
- the number of items in the list. The Doubly Linked List I will implement in this post will keep track of these two things.
Here is a rough visualization of a Doubly Linked List:
1 <-> 10 <-> 100 <-> 1000
In this basic example, each number represents a node
, and the arrows between the two number represent the next
and prev
pointers that link the two numbers. 1
would be the head
of the list, its prev
pointer would point to null
(since there's no preceding item for the head
) and its next
pointer would point to 10
. 10
's prev
would point to 1
and its next
would point to 100
. 100
's prev
would point to 10
and its next
would point to 1000
. 1000
's prev
would point to 100
and its next
would point to null
, making it the tail
. This list's length
would be 4
, since there are four items in the list.
Practical Examples of a Doubly Linked List
So where might you encounter a Doubly Linked List? You could think of a basic implementation of your browser history as a sort of Doubly Linked List. The page you're currently on would be the tail
, and as you navigate with the "Back" and "Forward" buttons, they present a sequential linked view of your browsing history that can be navigated in two directions.
The way I've laid this blog out could also be seen as a Doubly Linked List. For the newest blog post (which might be the head
), you can click through to the next, "Older Post", and then for each older blog post you can navigate both to the previous "Newer Post" or the next "Older Post" until you get to my oldest blog post (not recommended reading), which would be the tail
of the list.
Big O of a Doubly Linked List
Operation | Complexity |
---|---|
Insertion | O(1) |
Removal | O(1) |
Searching | O(n) |
Access | O(n) |
Insertion: Insertion is where Linked Lists excel, whether it's a Singly or Doubly Linked List. Unlike an Array, which has to re-index everything and can be extremely inefficient when inserting at the beginning of the Array, inserting at the beginning of a Linked List (or really anywhere) is just a matter of updating a pointer or two.
Removal: Removal in a Doubly Linked List is always constant, which is not the case for a Singly Linked List, especially at the end of the Singly Linked List. Remember that for a Singly Linked List it has to iterate all the way from the beginning to get to the end. With a Doubly Linked List, you can start from the end and easily get to the item you are removing.
Searching: Technically Searching is O(n/2)
since we optimized the .get()
operation to be able to start from both the head
and the tail
, but that's still O(n)
.
Access: Same as searching.
Doubly 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; this.prev = null; }}
class DoublyLinkedList { constructor() { this.head = null; this.tail = null; this.length = 0; } get(index) { if (index < 0 || index >= this.length) { return null; } var middle = Math.ceil(this.length / 2); var counter; var current; if (index <= middle) { counter = 0; current = this.head; while (counter < index) { current = current.next; counter++; } return current; } else { counter = this.length - 1; current = this.tail; while (counter > index) { current = current.prev; 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.prev = prev; newNode.next = temp; temp.prev = newNode; this.length++; return true; } pop() { if (!this.head) { return undefined; } var currentTail = this.tail; if (this.length === 1) { this.head = null; this.tail = null; } else { this.tail = currentTail.prev; this.tail.next = null; currentTail.prev = null; } this.length--; return currentTail; } push(val) { var newTail = new Node(val); if (!this.head) { this.head = newTail; this.tail = this.head; } else { this.tail.next = newTail; newTail.prev = this.tail; 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.prev = prev; removedNode.prev = null; removedNode.next = null; this.length--; return removedNode; } reverse() { var node = this.head; this.head = this.tail; this.tail = node; var next; for (var i = 0; i < this.length; i++) { next = node.next; node.next = node.prev; node.prev = next; 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; if (this.length === 1) { this.head = null; this.tail = null; } else { this.head = currentHead.next; this.head.prev = null; currentHead.next = null; } this.length--; return currentHead; } unshift(val) { if (!this.head) { this.push(val); } else { var newHead = new Node(val); newHead.next = this.head; this.head.prev = newHead; this.head = newHead; this.length++; return this; } }}
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 next
and prev
properties. By default next
and prev
will be assigned the value of null
, but we'll commonly be assigning these properties to point to other node
s in the list. next
will point to the next item in the list, in the direction of the tail
, and prev
will point to the previous item in the list, in the direction of the head
. The prev
reference will remain null
for the head
, and the next
reference will remain null
for the tail
.
class Node { constructor(val) { this.val = val; this.next = null; this.prev = null; }}
Doubly Linked List Class
We'll also use a Doubly 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 Doubly Linked List will have a head
set to null
, a tail
also set to null
, and a length
set to 0
.
class DoublyLinkedList { 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
- Set the new
node
'sprev
to equal the currenttail
- 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; newTail.prev = this.tail; this.tail = newTail; } this.length++; return this;}
.pop()
Now let's look at the .pop()
method, which removes the last item from the list. For a Singly Linked List, this method was made more complicated by the fact that in order to reassign the new tail
to be the item before the current tail
, we needed to iterate all the way up through the list.
This is significantly easier in a Doubly Linked List now that the current tail
has a prev
reference that we can use.
- Return
undefined
if the list is empty (again, if there's nohead
then it's an empty list) - Store the current
tail
into a variable, I'll call itcurrentTail
- Check whether the list only has one item
- If the list only has one item:
- Set the
head
to equalnull
- Set the
tail
to equalnull
- Set the
- Otherwise, if there is more than one item in the list
- Set the
tail
to becurrentTail
'sprev
- Set the newly-set
tail
'snext
tonull
(breaking the reference tocurrentTail
) - Set
currentTail
'sprev
tonull
- Set the
- Decrement the
length
by 1 - 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.tail; if (this.length === 1) { this.head = null; this.tail = null; } else { this.tail = currentTail.prev; this.tail.next = null; currentTail.prev = null; } this.length--; return currentTail;}
.unshift()
Now we'll look at the .unshift()
method, which adds a new value, val
, to the beginning of the list.
- If the list is empty, simply
.push(val)
. This allows us to reuse some logic we've already written - Otherwise:
- Create a new
node
with the valueval
- Set the new
node
'snext
to point to the list's currenthead
- Set the current
head
'sprev
to point to the newnode
- Assign the new
node
as thehead
- Increment the
length
by 1 - Return the list
- Create a new
.unshift()
Implementation
unshift(val) { if (!this.head) { this.push(val); } else { var newHead = new Node(val); newHead.next = this.head; this.head.prev = newHead; 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()
.
- If the list is empty return
undefined
- Store the current
head
into a variable, I'll usecurrentHead
- Check whether there is only one item in the list.
- If there's only one item in the list:
- Set the
head
tonull
- Set the
tail
tonull
- Set the
- If there's more than one item in the list:
- Set the
head
to becurrentHead
'snext
- Set the new
head
'sprev
to benull
- Set
currentHead
'snext
to benull
(breaking the reference to the newhead
)
- Set the
- Decrement the
length
by 1 - Return the old
head
that we have removed, which is stored incurrentHead
.shift()
Implementation
shift() { if (!this.head) { return undefined; } var currentHead = this.head; if (this.length === 1) { this.head = null; this.tail = null; } else { this.head = currentHead.next; this.head.prev = null; currentHead.next = null; } this.length--; 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.
.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.
A big difference in this method between a Singly Linked List and a Doubly Linked List is that a Doubly Linked List, with its tail
and prev
pointers, allows us to start at the end of a list and move backwards, rather than only being able to start at the beginning of the list and move forwards. This achieves some optimization as we can cut down the necessary steps to access data by half. I'll walk through how this gets accomplished.
- Check to make sure the
index
is valid. If theindex
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 highestindex
is 99
- We need to find the
index
of the middle point of the list, and store this value in a variable calledmiddle
. UsingMath.ceil(this.length / 2)
, we divide thelength
property of the list by 2, andMath.ceil
will round up if we wind up with a decimal (i.e., iflength
is 5, themiddle
will be 3). - We also need to create variables called
counter
andcurrent
that will be used for iteration. We'll leave these variables declared but not assigned any value for now. - Now let's check whether
index
is less than or equal tomiddle
- If
index
is less than or equal tomiddle
, we will iterate up from the beginning of the list:- Set
counter
to equal 0 - Set
current
to equal the list'shead
- Begin a
while
Loop that checks ifcounter
is still less thanindex
. If so:- Set
current
equal tocurrent.next
- Increment
counter
by 1
- This loop stops running once
counter
is equal toindex
, meaningcurrent
has landed on the item we are looking for
- Set
- return
current
- Set
- If
index
is greater thanmiddle
, we will iterate down starting from the end of the list:- Set
counter
equal tothis.length - 1
(i.e., theindex
of the last item in the list) - Set
current
equal to the list'stail
- Begin a
while
Loop that checks ifcounter
is still greater thanindex
. If so:- Set
current
equal tocurrent.prev
- Decrement
counter
by 1
- This loop stops running once
counter
is equal toindex
, meaningcurrent
has landed on the item we are looking for
- Set
- return
current
- Set
.get()
Implementation
get(index) { if (index < 0 || index >= this.length) { return null; } var middle = Math.ceil(this.length / 2); var counter; var current; if (index <= middle) { counter = 0; current = this.head; while (counter < index) { current = current.next; counter++; } return current; } else { counter = this.length - 1; current = this.tail; while (counter > index) { current = current.prev; 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 (optimized!) 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
'sprev
pointer to referenceprev
- Set
newNode
'snext
pointer to referencetemp
- Set
temp
'sprev
pointer to referencenewNode
- 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.prev = prev; newNode.next = temp; temp.prev = newNode; 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.next.prev
to equalprev
. This is a little confusing, but we're basically assign the item afterremovedNode
'sprev
to point to the item beforeremovedNode
- Set
removedNode
'sprev
tonull
- This removes one of the connections from the removed
node
to the rest of the list
- This removes one of the connections from the removed
- Set
removedNode
'snext
tonull
- This removes the other connection from the removed
node
to the rest of the list. This and Step 9 ensure thatremovedNode
isn't a falsehead
or falsetail
that connects to the list
- This removes the other connection from the removed
- 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.prev = prev; removedNode.prev = null; removedNode.next = null; this.length--; return removedNode;}
.reverse()
The last method we'll look at today is .reverse()
that reverses the Doubly Linked List in place. This is much, much easier than reversing a Singly Linked List in place (something that took up half off the Singly Linked List post, and that I later broke out into its own blog post to answer a LeetCode question).
.reverse()
Implementation
- 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 - Loop through the length of the list. Within this loop:
- Set the
next
variable equal to the temporarynode
'snext
value - Set the temporary
node
'snext
to equal its ownprev
reference - Set the temporary
node
'sprev
reference to point tonext
- Assign the temporary
node
to equalnext
- Set the
- After the loop has finished, return the list, which will now be reversed
The logic here is pretty much identical to what I've written out twice now in step by step breakdowns (here and here). The only difference is that each node
has a prev
reference we use instead of a prev
variable that we created elsewhere. Hopefully it suffices to point you to those other two previous blog posts that go into step by step detail at each iteration of the loop, rather than basically rewrite that all out here.
reverse() { var node = this.head; this.head = this.tail; this.tail = node; var next; for (var i = 0; i < this.length; i++) { next = node.next; node.next = node.prev; node.prev = next; node = next; } return this;}