A linked-list is a type of data structure that is made up of elements called  nodes. Unlike an array, where elements are stored in contagious memory locations, the nodes in a linked-list are scattered in memory. The nodes are connected through the use of pointers, each node contains a reference to the location of the next node in the list.

There are various types of linked lists. In this article we will focus on doubly-linked lists.

In a doubly linked list, each node contains a reference to both the next node and the previous node. This allows  for efficient traversal of the list in both forward and backward directions.

A node in a doubly-linked list

In most practical cases, doubly-linked lists are used for implementing  other data structures such as queues. In the next parts we will learn how to implement a double-ended queue using doubly-linked list.

The Node class 

To represent a node in a doubly linked lists, we will create a non-public class called _Node with the following attributes:

  • _element: Will store the value of the node.
  • _next: Will store a reference to the next node in the list.
  • _prev: Will store a reference to the previous node in the list. 
      

The implementation of the _Node class is as shown below

the _Node class

class _Node:
    def __init__(self, e, prev, next):
        self._element = e #The value stored by the node
        self._prev = prev #The prev node in the list
        self._next = next #The next node in the list

Linked double-ended Queue

A double-ended queue, popularly known as deque(pronounced as "deck"),  is a type of queue in which elements can be added or removed from either side. These makes it more versatile than a regular queue in which elements can only be added in one end and removed from the other.

A Double ended queue typically supports the following operations.

insert_front(e) Add element e at the front of the queue.
insert_back(e) Add element e at the back end of the queue.
remove_front() remove and return the element at front.
remove_back() remove and return the element at back.
front() Return the element at the front of the queue without removing it.
back() Return the element at the back without removing it.
is_empty() Return True if the queue is empty, otherwise False.
len() Return the number of elements in the queue.

In the following parts we will go on to implement the LinkedDeque class to represent a double-ended queue using doubly-linked list.

Implement the basic structure

In this part we will implement the basic structure of the LinkedDeque class. The _Node class will be implemented as a non-public subclass.

We will use sentinel/dummy nodes, _head and _tail at the front and the back of the list respectively, doing this simplifies the insertion and removal operations, as we will see in a while. The front sentinel will always point to the beginning of the list, and the back sentinel will always point to the end of the list.

The basic structure

class LinkedDeque:
    #the _Node subclass
    class _Node:
       def __init__(self, e, prev, next):
           self._element = e
           self._prev = prev
           self._next = next

    #The constructor
    def __init__(self):
        self._head = LinkedDeque._Node(None, None, None) #The front sentinel node.
        self._tail = LinkedDeque._Node(None, self._head, None) #The back sentinel node, with the _head as its previous node
        self._head._next = self._tail #update the _head sentinel so that _tail becomes the next node

        self._size = 0 # The number of non-sentinel nodes
 
    def __len__(self):
        return self._size # The number of elements in the deque

    def is_empty(self):
        return len(self) == 0 

Implementing the __len__() method makes it possible to use the builtin len() function to check the number of elements in the deque

Implement front() and back()

In this part we will implement front() and back() methods. The methods will raise an error if the deque is empty.

front() and back() 

#an exception to be raised if the deque is empty
class Empty(Exception):
    pass

class LinkedDeque:
    #this part is ommitted refer to the previous snippet
    def front(self):
        if self.is_empty():
            raise Empty("Deque is empty")
        return self._head._next._element # Return the element of the next node after the sentinel _head

    def back(self):
        if self.is_empty():
            raise Empty("Deque is empty")
        return self._tail._prev._element # Return the element of the previous node before the sentinel _tail.

Implement insert_front() and insert_back()

In this section we will implement the insert_front() and insert_back() methods. However to prevent redundancy, we will implement a more generic non-public method, _insert_between() which we will call within the insert_front() and insert_back() methods. 

insert_front() and insert_back()

class LinkedDeque:
    #this part is ommitted refer to the previous sections
    def _insert_between(self, e, node1, node2):
        newnode = LinkedDeque._Node(e, node1, node2) #create a node with node1 and node2 as previous and next, respectively
        node1._next = newnode
        node2._prev = newnode
        self._size += 1

    def insert_front(self, e):
        self._insert_between(e, self._head, self._head._next) # Call the _insert_between method

    def insert_back(self, e):
        self._insert_between(e, self._tail._prev, self._tail)

The _insert_between() method in the above example, helps us to avoid repetition when implementing the  insert_front() and insert_back() methods. By using _insert_between(), we are able to pass in the correct parameters to specify where the new node should be inserted without having to duplicate the code for creating and updating the pointers.

The implementation and usefulness of the _insert_between() method above illustrates the importance of using sentinel nodes.

Implement remove_front() and remove_back()

In this final step we will implement the remove_front() and remove_back() methods. Like in previous section, we will use a non-public methods _remove_node() that will be called by the two public methods.

remove_front() and remove_back()

class LinkedDeque:
    #this part is ommitted refer to the previous sections
    def _remove_node(self, node):
        prev = node._prev
        next = node._next
        
        #relink the remaining nodes
        prev._next = next 
        next._prev = prev

        self._size -= 1

        element = node._element

        #deprecate the removed node
        node._element = node._prev = node._next = None
        return element


    def remove_front(self):
        if self.is_empty():
            raise Empty("The deque is empty")

        return self._remove_node(self._head._next)

    def remove_back(self):
        if self.is_empty():
            raise Empty("The deque is empty")

        return self._remove_node(self._tail._prev)

Combining all the LinkedDeque segments

After Combining all the previous segments we get:

The complete LinkedDeque class

class Empty(Exception):
    pass

class LinkedDeque:
    class _Node:
       def __init__(self, e, prev, next):
           self._element = e
           self._prev = prev
           self._next = next
    
    def __init__(self):
        self._head = LinkedDeque._Node(None, None, None) 
        self._tail = LinkedDeque._Node(None, self._head, None) 
        self._head._next = self._tail 

        self._size = 0
 
    def __len__(self):
        return self._size 

    def is_empty(self):
        return len(self) == 0

    def front(self):
        if self.is_empty():
            raise Empty("Deque is empty")
        return self._head._next._element 

    def back(self):
        if self.is_empty():
            raise Empty("Deque is empty")
        return self._tail._prev._element 

    def _insert_between(self, e, node1, node2):
        newnode = LinkedDeque._Node(e, node1, node2)
        node1._next = newnode
        node2._prev = newnode
        self._size += 1

    def insert_front(self, e):
        self._insert_between(e, self._head, self._head._next) 

    def insert_back(self, e):
        self._insert_between(e, self._tail._prev, self._tail)

    def _remove_node(self, node):
        prev = node._prev
        next = node._next
        
        #relink the remaining nodes
        prev._next = next 
        next._prev = prev
        self._size -= 1

        element = node._element

        node._element = node._prev = node._next = None
        return element

    def remove_front(self):
        if self.is_empty():
            raise Empty("The deque is empty")

        return self._remove_node(self._head._next)

    def remove_back(self):
        if self.is_empty():
            raise Empty("The deque is empty")

        return self._remove_node(self._tail._prev)

Using the class

d = LinkedDeque()
d.insert_front("Python")
d.insert_front("Java")
d.insert_back("C++")
d.insert_back("PHP")
d.insert_front("Javascript")

print("front: ", d.front())
print("back: ", d.back())
print("Removing from back:")

while not d.is_empty():
    print(d.remove_back())

front:  Javascript
back:  PHP
Removing from back:
PHP
C++
Python
Java
Javascript

You can experiment with the class more on your own.