- Identify the use cases for a doubly linked list.
- Demonstrate common methods for a doubly linked list.
- Differentiate between a doubly linked list and a singly linked list.
- Sequence: a data structure in which data is stored and accessed in a specific order.
- Stack is a linear data structure that follows the principle of Last In First Out (LIFO).
- Index: the location, represented by an integer, of an element in a sequence.
- Iterable: able to be broken down into smaller parts of equal size that can be processed in turn. You can loop through any iterable object.
- Slice: a group of neighboring elements in a sequence.
- List: a mutable data type in Python that can store many types of data. The most common data structure in Python.
- Tuple: an immutable data type in Python that can store many types of data.
- Range: a data type in Python that stores integers in a fixed pattern.
- String: an immutable data type in Python that stores unicode characters in a fixed pattern. Iterable and indexed, just like other sequences.
We learned in the last lesson about singly linked lists, their use cases, and the general concept behind linked lists. In this lesson, we're going to dive into what a doubly linked list is, and what the difference is between singly and doubly linked lists.
In the previous lessons we learned how unlike lists, linked lists are not
indexed, and in order to search from node to node, we need use pointers to go
from one node onto the next node. But what if we wanted to go back a node? In a
singly linked list, a node doesn't know which node came before it, because it
doesn't have a pointer pointing to the previous node. Doubly linked lists have
pointers to the next node as well as the previous node. Let's take a look at
how we would build this out in our Node
class:
class Node:
def __init__(self, data, next_node = None, prev_node = None):
self.data = data
self.next_node = next_node
self.prev_node = prev_node
All we really had to do was a prev_node
attribute, and now we have two
pointers on our Node
class, so that each node points in two directions: to the
next node in the list, and to the previous node. While this is a really small
and easy change to make to the structure of our node, by doing this change we
are able to make our linked list much more useful, efficient, and dynamic!
One way we can improve the time complexity of our singly linked list
implementation is by adding additional references to nodes in the list. For
example, consider the following SinglyLinkedList
class:
class SinglyLinkedList:
def __init__(self, head = None):
self.head = head
def append(self, node):
# Add element to the beginning of the list if the list is empty
if self.head == None:
self.head = node
return
# Otherwise, traverse the list to find the last node
last_node = self.head
while last_node.next_node:
last_node = last_node.next_node
# and add the node to the end
# 1 -> 2 -> 3
last_node.next_node = node
# 1 -> 2 -> 3 -> 4
The time complexity of its append
method is O(n), since we need to traverse
each element of the linked list in order to find the last node and remove it. We
can make the append
method efficient by keeping track of the list's tail
node in addition to the head:
class SinglyLinkedList:
def __init__(self, head = None, tail = None):
self.head = head
self.tail = tail
def append(self, node):
# Add element to the beginning of the list if the list is empty
if self.head == None:
self.head = node
self.tail = node
return
# no need to traverse! we can access the last node directly (self.tail)
# 1 -> 2 -> 3
self.tail.next_node = node
# 1 -> 2 -> 3 -> 4
# we also need to make sure to keep track of the new tail
self.tail = node
After this refactor, the time complexity of our append
method is O(1), since
we no longer need to traverse the list in order to find the tail before
appending a new node. The tradeoff to keeping references to additional nodes in
our list, like the tail, is it takes more space to keep track of these
additional references. Adding additional reference can also increase the written
complexity of our code for certain methods, since we need to maintain those
references correctly.
A doubly linked list makes insertion and removal more efficient in certain cases by keeping references to the previous node in addition to the next node.
Let's say we have a singly linked list, and we wanted to remove the last item. We would have to traverse the entire list in order to find the second to last node in the list and assign it as the new tail, since we can't go directly to the tail and work backwards:
class SinglyLinkedList:
def __init__(self, head = None, tail = None):
self.head = head
def delete_tail(self):
if self.head == None:
return
# traverse the entire list to find the second-to-last node (prev)
curr = self.head
prev = None
while curr.next_node:
prev = curr
curr = curr.next_node
# remove the last node by removing the link between the second-to-last node and the tail
# 1 -> 2 -> 3
prev.next_node = None
# 1 -> 2
In this case, the time complexity for removing a node from the end of the list is O(n) since we need to traverse the list to find the second-to-last node.
With a doubly linked list, we already have a pointer on each node pointing
to the previous node, so we can just take one step backwards from the tail
by
using .prev_node
without needing to iterate:
class DoublyLinkedList:
def __init__(self, head = None, tail = None):
self.head = head
def delete_tail(self):
if self.head == self.tail:
self.head = None
self.tail = None
# traverse the entire list to find the second-to-last node (prev)
else:
# access the second-to-last node (self.tail.prev_node)
prev = self.tail.prev_node
# update the tail and next_node pointers
# 1 <-> 2 <-> 3
prev.next_node = None
self.tail = prev
# 1 <-> 2
After this refactor, our time complexity for removing a node from the end of the list is O(1), since we don't need to traverse the entire list to find the new tail.
The tradeoff is that we now need to maintain these references between nodes
correctly for all our linked list methods. For example, the append
method
for a doubly linked list is more complicated than for a singly linked list,
since we have to keep track of the next_node
and prev_node
correctly any
time a node is added:
class DoublyLinkedList:
def __init__(self, head = None, tail = None):
self.head = head
self.tail = tail
def append(self, node):
if self.head == None:
self.head = node
self.tail = node
return
node.prev_node = self.tail
self.tail.next_node = node
self.tail = node
Singly linked lists and doubly linked lists are very similar. Unlike lists,
they are not indexed, and they use pointers to reference their nodes. Singly
linked lists are one-directional, while doubly linked lists go both ways. Having
the extra prev_node
pointer in a doubly linked list can be useful in the
following scenarios:
- Removing an item form the end of a list.
- Reversing the list (traversal from tail to head).
- Implementing "previous/next" operations or "undo/redo" operations (like building a playlist or a text editor).
The trade-offs are that a doubly linked list takes up more memory than to a
singly linked list, since we have to keep track of multiple pointers on each
node; and the code for a doubly linked list is also more complicated to write
and maintain because of the added complexity of keeping both the next_node
and
prev_node
references.