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 nodes 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
nodewith 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
nodeas thehead - Assign the
tailto also equal thehead
- Assign the new
- If the list isn't empty:
- Set the current
tail'snextto equal the newnode - Set the new
node'sprevto equal the currenttail - Assign the new
nodeas thetail
- Set the current
- Increment the
lengthby 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
undefinedif the list is empty (again, if there's noheadthen it's an empty list) - Store the current
tailinto a variable, I'll call itcurrentTail - Check whether the list only has one item
- If the list only has one item:
- Set the
headto equalnull - Set the
tailto equalnull
- Set the
- Otherwise, if there is more than one item in the list
- Set the
tailto becurrentTail'sprev - Set the newly-set
tail'snexttonull(breaking the reference tocurrentTail) - Set
currentTail'sprevtonull
- Set the
- Decrement the
lengthby 1 - Finally, return the
nodethat we have removed from the list. Remember that this oldtailis stored in thecurrentTailvariable
.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
nodewith the valueval - Set the new
node'snextto point to the list's currenthead - Set the current
head'sprevto point to the newnode - Assign the new
nodeas thehead - Increment the
lengthby 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
headinto 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
headtonull - Set the
tailtonull
- Set the
- If there's more than one item in the list:
- Set the
headto becurrentHead'snext - Set the new
head'sprevto benull - Set
currentHead'snextto benull(breaking the reference to the newhead)
- Set the
- Decrement the
lengthby 1 - Return the old
headthat 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
indexis valid. If theindexis invalid, returnundefined. Theindexis invalid if:- It is a negative value
- It is greater than or equal to the
lengthof the list. If a list is 3 items long, then the highestindexis 2. If the list is 100 items long, then the highestindexis 99
- We need to find the
indexof the middle point of the list, and store this value in a variable calledmiddle. UsingMath.ceil(this.length / 2), we divide thelengthproperty of the list by 2, andMath.ceilwill round up if we wind up with a decimal (i.e., iflengthis 5, themiddlewill be 3). - We also need to create variables called
counterandcurrentthat will be used for iteration. We'll leave these variables declared but not assigned any value for now. - Now let's check whether
indexis less than or equal tomiddle - If
indexis less than or equal tomiddle, we will iterate up from the beginning of the list:- Set
counterto equal 0 - Set
currentto equal the list'shead - Begin a
whileLoop that checks ifcounteris still less thanindex. If so:- Set
currentequal tocurrent.next - Increment
counterby 1
- This loop stops running once
counteris equal toindex, meaningcurrenthas landed on the item we are looking for
- Set
- return
current
- Set
- If
indexis greater thanmiddle, we will iterate down starting from the end of the list:- Set
counterequal tothis.length - 1(i.e., theindexof the last item in the list) - Set
currentequal to the list'stail - Begin a
whileLoop that checks ifcounteris still greater thanindex. If so:- Set
currentequal tocurrent.prev - Decrement
counterby 1
- This loop stops running once
counteris equal toindex, meaningcurrenthas 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 thenodeat the providedindex. Store this in a variable namedfoundNode.get()will returnundefinedfor us if it's an invalidindex.get()will also do all of the (optimized!) iteration for us
- If
.get()returns ourfoundNode:- Set
foundNode'svalto thevalthat 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
indexis a valid number. This is the same as above. Returnfalseif it is an invalidindex. - If the
indexis equal to the list'slengthproperty, then we can just use the.push()method to addvalas the newtail- We can use a
!!double bang operator to returntrueafter using the.push()method
- We can use a
- If
indexis 0, then we can just use the.unshift()method to addvalas the newhead- We can use a
!!double bang operator to returntrueafter using the.unshift()method
- We can use a
- If we've gotten this far, then the
indexis 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
nodewith the valueval, and assign it to a variable namednewNode - Retrieve the
noderight before the item currently located atindex, and assign it to a variable namedprev. We can use the.get()method and pass itindex - 1to achieve this - Retrieve the
nodecurrently at located atindex(same asprev.next), and assign it to a variable namedtemp - Set
prev'snextpointer to referencenewNode - Set
newNode'sprevpointer to referenceprev - Set
newNode'snextpointer to referencetemp - Set
temp'sprevpointer to referencenewNode - Increment
lengthby 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
indexis 0, we can just use the.shift()method to remove the currenthead - If the
indexis equal tolength - 1, we can just use the.pop()method to remove the currenttail - Otherwise we need to go about removing a
nodefrom the middle of the list - Retrieve the
noderight before the item that we want to remove, and assign it to a variable namedprev. We can use the.get()method and pass itindex - 1to achieve this - Retrieve the
nodecurrently at located atindex(same asprev.next), and assign it to a variable namedremovedNode - Set
prev'snextpointer to refer toremovedNode.next - Set
removedNode.next.prevto equalprev. This is a little confusing, but we're basically assign the item afterremovedNode'sprevto point to the item beforeremovedNode - Set
removedNode'sprevtonull- This removes one of the connections from the removed
nodeto the rest of the list
- This removes one of the connections from the removed
- Set
removedNode'snexttonull- This removes the other connection from the removed
nodeto the rest of the list. This and Step 9 ensure thatremovedNodeisn't a falseheador falsetailthat connects to the list
- This removes the other connection from the removed
- Decrement
lengthby 1 - Return the
nodewe removed, currently stored in theremovedNodevariable
.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
headand thetail:- Store
headin a temporarynodevariable - Assign the
headto equal thetail - Assign the
tailto equal the temporarynodevariable
- Store
- Create a
nextvariable, but do not assign anything to it - Loop through the length of the list. Within this loop:
- Set the
nextvariable equal to the temporarynode'snextvalue - Set the temporary
node'snextto equal its ownprevreference - Set the temporary
node'sprevreference to point tonext - Assign the temporary
nodeto 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;}