Doubly-Linked-Lists

7 minute read

References for this lecture

Problem Solving with Algorithms and Data Structures using Python

Section 4.10-4.23: https://runestone.academy/ns/books/published/pythonds/BasicDS/toctree.html

Group Activity Problem 1

The UnorderedList ADT is also supposed to have an append method (see https://runestone.academy/ns/books/published/pythonds/BasicDS/TheUnorderedListAbstractDataType.html )

We could make one easily in the array version by using the Python list’s append method. What would the complexity of this be?

It is possible to do this in the linked list by traversing the list all the way to the end of the list and then putting the new item there - this would take $O(n)$.

It might be possible to do it in $O(1)$ time by adding a self.tail attribute that keeps track of the last item. Can you think of any problems that would arise from a strategy like this?

class UnorderedList:

    def __init__(self):
        self.head = None
        self.tail = None
    
    def append(self,item):
        if self.isEmpty():
            temp = Node(item)
            temp.setNext(self.head)
            self.head = temp
            self.tail = temp
        else:
            temp = Node(item)
            self.tail.setNext(temp)
            self.tail = temp

Group Activity Problem 2

Which other methods would need to change to make sure that self.tail always continues referring to the last item in the list?

Group Activity Problem 3

Using self.tail, can we now implement a version of pop that would be able to remove the last item in $O(1)$ time? If yes, write down some pseudocode of how that would work. If no, why not - what else would we need?

Doubly Linked List

A doubly linked list is like a linked list, except each node keeps track of the item both before and after it in the list.

We can also keep track of the tail - the end of the list.

doublylinkedlist.png

This gives us O(1) access to both ends of the list - for adding or removing items from the ends!

We could use this to implement a queue in which both enqueue and dequeue are O(1). Hooray!

Coding up a doubly linked list

The code for a doubly linked list is similar to a linked list, except

  • You need to manage both the prev and next references for each node any time you add or remove an item
  • Many things have to be broken down into several cases for when there are 0 items, 1 item, or more items

This can be a headache to implement - so I’m not going to make you add code to this. But, here’s what the code looks like. Caveat: I wrote this from scratch, and I hope I caught all the bugs…

class DoubleNode:
    def __init__(self,initdata):
        self.__data = initdata
        self.__next = None
        self.__prev = None

    def get_data(self):
        return self.__data

    def get_next(self):
        return self.__next
    
    def get_prev(self):
        return self.__prev

    def set_data(self,newdata):
        self.__data = newdata

    def set_next(self,newnext):
        self.__next = newnext
        
    def set_prev(self,newprev):
        self.__prev = newprev

class DoublyLinkedList:

    def __init__(self):
        """a DoublyLinkedList is initially empty"""
        self.__head = None
        self.__tail = None
        self.__length = 0
        
    def is_empty(self):
        return self.__head == None
    
    def size(self):
        return self.__length
    
    def append(self,item):
        #create the new node
        temp = DoubleNode(item)
        
        #case 1: the list is empty
        if self.is_empty():
            
            self.__head = temp
            self.__tail = temp
            
        #case 2: the list has 1 item 
        elif self.__length == 1:
                
            self.__tail = temp
            self.__tail.set_prev(self.__head)
            self.__head.set_next(self.__tail)
            
        #case 3: the list has 2 or more items
        else:

            self.__tail.set_next(temp)
            temp.set_prev(self.__tail)
            self.__tail = temp
            
        #update the length counter because we added an item
        self.__length += 1
    
    def prepend(self,item):
        #create the new node
        temp = DoubleNode(item)
        
        #case 1: the list is empty
        if self.is_empty():
            
            self.__head = temp
            self.__tail = temp
            
        #case 2: the list has 1 item 
        elif self.__length == 1:
                
            self.__head = temp
            self.__head.set_next(self.__tail)
            self.__tail.set_prev(self.__head)
            
        #case 3: the list has 2 or more items
        else:

            self.__head.set_prev(temp)
            temp.set_next(self.__head) 
            self.__head = temp 
            
        #update the length counter because we added an item
        self.__length += 1
        
    def __repr__(self):
        list_representation = "None <-> "
        current = self.__head #start with the Node at the head
        while current: #this will keep going until current equals None
            list_representation += str(current.get_data())+" <-> "
            current = current.get_next() #move on to the next Node in the list
        list_representation += "None" #the last one in the list points to None
        return list_representation
    
    def __getitem__(self,index):
        
        #case 1: the index is out of range - error
        if index >= self.__length or index < (-1)*self.__length:
            raise Exception("list index "+str(index)+" out of range")
        
        #case 2: positive index - count starting at the head
        elif index >= 0:
            current = self.__head
            item_counter = 0
            
            while current and item_counter < index:
                current = current.get_next()
                item_counter += 1
            
        #case 3: negative index - count starting at the tail
        else:
            current = self.__tail
            item_counter = -1
            
            while current and item_counter > index:
                current = current.get_prev()
                item_counter -= 1
                    
        return current.get_data()
    
    
    def pop(self,index):
        
        #case 1: the index is out of range - error
        if index >= self.__length or index < (-1)*self.__length:
            raise Exception("list index "+str(index)+" out of range")
        
        #case 2: the list is empty - error
        elif self.__head == None:
            raise Exception("You cannot pop from an empty list")
        
        #case 3: there is only one item
        elif self.__head == self.__tail and (index == 0 or index == -1):
            self.__length -= 1
            temp = self.__head
            self.__head = None
            self.__tail = None
            return temp.get_data()
        
        #case 4: 2 or more items, popping the first item
        elif index == 0 or index == self.__length*(-1):
            self.__length -= 1
            temp = self.__head
            self.__head.get_next().set_prev(None)
            self.__head = self.__head.get_next()
            return temp.get_data()
        
        #case 5: 2 or more items, popping the last item
        elif index == -1 or index == self.__length-1:
            self.__length -= 1
            temp = self.__tail
            self.__tail.get_prev().set_next(None)
            self.__tail = self.__tail.get_prev()
            return temp.get_data()
            
        #case 6: 2 or more items, popping a non-end, counting from left
        elif index > 0:
            self.__length -= 1
            
            current = self.__head
            trailing = None
            item_counter = 0
            
            while current and item_counter < index:
                trailing = current
                current = current.get_next()
                item_counter += 1

            trailing.set_next(current.get_next())
            current.get_next().set_prev(trailing)
            
            return current.get_data()
        
        #case 7: 2 or more items, popping a non-end, counting from right
        elif index < 0:
            self.__length -= 1
            
            current = self.__tail
            trailing = None
            item_counter = -1
            
            while current and item_counter > index:
                trailing = current
                current = current.get_prev()
                item_counter -= 1
                
            trailing.set_prev(current.get_prev())
            current.get_prev().set_next(trailing)
   
            return current.get_data()    
        
dll = DoublyLinkedList()
dll.append(1)
dll.append(2)
dll.append(3)
dll.prepend(4)
dll.prepend(5)
print("Here's the list:",dll)
print("Item at index 3:",dll[3])
print("Item at index -1:",dll[-1])
print("Popping index 3:",dll.pop(3))
print("Popping index -1:",dll.pop(-1))
print("Here's the resulting list:",dll)
print("Its size is",dll.size())
Here's the list: None <-> 5 <-> 4 <-> 1 <-> 2 <-> 3 <-> None
Item at index 3: 2
Item at index -1: 3
Popping index 3: 2
Popping index -1: 3
Here's the resulting list: None <-> 5 <-> 4 <-> 1 <-> None
Its size is 3

Implementing a Queue using a Doubly Linked List

Recall: when we made our Queue ADT using a Python list as the underlying data structure, one of enqueue and dequeue had to be $O(n)$.

We can now use a doubly linked list instead, and all the operations will be $O(1)$.

class Queue:
    def __init__(self):
        self.__dll = DoublyLinkedList()

    def is_empty(self):
        return self.__dll.is_empty()
    
    def enqueue(self,item):
        self.__dll.append(item)

    def dequeue(self):
        return self.__dll.pop(0)

    def size(self):
        return self.__dll.size()
    
    def __repr__(self):
        rval = "Front|"
        for i in range(self.size()):
            rval += str(self.__dll[i])+","
        rval = rval.strip(",")
        rval += "|Back"
        return rval
        
    
q = Queue()
q.enqueue(1)
q.enqueue(2)
q.enqueue(3)
q.enqueue(4)
q.enqueue(5)
print("Starting queue:",q)
print( q.dequeue() )
print( q.dequeue() )
print( q.dequeue() )
print("Ending queue:",q)
print("Size:",q.size() )
Starting queue: Front|1,2,3,4,5|Back
1
2
3
Ending queue: Front|4,5|Back
Size: 2

Group Activity Problem 4

Chapter 4 of the book has implementations for two more Abstract Data Types that we haven’t looked at yet:

The book uses an array (i.e., a Python list) to implement the Deque and a linked list to implement the OrderedList. However, like their implementation of the Queue, these may not necessarily be the best choices.

For each one, discuss, which would be the better choice to implement that ADT: array, linked list, or doubly-linked list. Give reasons to back up your choice. Time complexity of the different methods should be the main consideration, so start there, but you may be able to come up with some other reasons as well.

Group Activity Problem 5

While Python doesn’t have a deque built-in, there is a Collections library which contains a deque that you can read about here: https://docs.python.org/3/library/collections.html

Look at the description on that page, paying special attention to the Big O it gives for various operations. What is the Big O of

  • appending on the right
  • appending on the left
  • popping from the right
  • popping from the left
  • accessing an item in the middle of the deque by its index, like d[10000]

Based on what you know, how do you think they implement this deque class?