Python Linked Lists : Easy Steps to Get Started

Python Linked Lists : Easy Steps to Get Started

Follow this guide and you'll never need to relearn Linked Lists again.

Ever wondered how efficiently Python handles data connections? Linked lists are a powerful data structure for managing connections and dynamic memory. Unlike arrays, they store elements in separate nodes linked together. Let's explore how Python efficiently handles linked lists!

What is linked list?

Linked lists are a linear data structure to store data in a linked system. It means, the store box of data are not connected in a contiguous way in memory. These are called nodes (elements of a linked list). In a linked list, the nodes can be shuffled at any point. To relate more think of a train and it its compartments. Each compartment are connected with it's next and previous one with a link. The compartments can be removed, can be added, can be repositioned and many more. And each of them are independent nodes. The first node is called head and the last node is called tail. Head is a crucial part because we access a linked list via its head. Each node contains two parts: data and a reference. The data part holds the node's value, while the reference part(location/address of the next node) points to the next node, creating a link between the current node and the next one

Difference between Linked list and Array

In a python list, elements have indexes. But in linked list, elements don't have any. In a python list, the elements are stored contiguously in memory but in linked list, the elements are scattered and connected with the next node through link. These are the main differences of python list and linked list

Types of linked list

There are four types of linked lists -

  1. Singly Linked list

  2. Circular Singly Linked List

  3. Doubly linked list

  4. Circular Doubly Linked List

In a singly linked list, the nodes are defined with data and the reference which stores the physical memory location of the next node. The last node has null pointer which indicates the end of the list, the reference is always NULL. In a circular linkedlist, the last node points back to its first node. It store the physical location of the first node. You can see clearly in the given graph. The last node pointing to the first node basically creates a circular formation.

Moving to the next, the Doubly linked list. It is similar to a singly linked list, but the only difference is, here we have also the reference to the the previous node. This means each node has two references : one to the next node and one to the previous node. Doubly linked list allows us to traverse back too. It provides a flexibility to traverse in both direction.

A Circular doubly linked list is similar to doubly linked list, but with a key difference with the first and the last node. Notice that, in circular doubly linked list, the last node's next reference points to the physical location of the first node, and the first node's previous reference points to the physical location of the last node. This creates a circular structure where you can traverse the list continuously in both directions.

Linked list in memory

Nodes in a linked list can't be accessed via indexing like elements in a python lists.

In memory, python list elements are stored sequentially. However, in a linked list, elements are stored randomly and connected through links.

As you can see, the nodes are stored separately. Each node requires an extra memory space for a reference to the next node. This random allocation allows for a insertion as many nodes as needed without reserving contiguous specific memory location. However, as we don't know the location, finding any node requires looping through the entire linked list, which is a disadvantage of linked list.

All about Nodes and Node class constructor

To create a node class, we need to understand the characteristics of a single node. I have attached a simpler formation to visualize easily.

Each node contains two parts : data, and the reference to the next node (and prolly a reference to the first node too if it's a doubly linked list).

To create a node class, we need to define its attributes and behaviors. Each node holds data and a reference to the next node. The node class only concentrates with storing its own value and a reference to the next node. It does not know anything about the next node.

class Node: 
    def __init__(self, value): 
        self.value = value #data storing
        self.next = None #reference 
new_node = Node(10)
print(new_node)

Here, the Node class has two attributes:

  1. value: Stores the data of the node.

  2. next: A reference to the next node in the linked list, initially set to None.

It will print out no value because we have not created any method to print the value. It will just show the memory location. This class will serve as the building block for the linked list class we'll create later.

Linked list constructor - creating a Singly linked list

We will create a linked list using the Node class we defined just now where we had the value and the pointer as attributes.

Let's name the class LinkedList. The LinkedList class will include further attributes for the head and tail of the list. Let's make one for a very simple linked list with one element.

#creating linked list with one node
class LinkedList: 
    def __init__(self, value): 
        new_node = Node(value) 
        self.head = new_node #it's going to create a link from head to new node
        self.tail = new_node #it's going to create a link from tail to new node
        self.length = 1 #makes more efficient
new_linked_list = LinkedList(10)
print(new_linked_list) #memory location
print(new_linked_list.head.value) #we are accessing value from the node class
print(new_linked_list.tail.value) # accessing tail value
print(new_linked_list.length) 

#creating empty linked list
class LinkedList: 
    def __init__(self): 
        self.head = None 
        self.tail = None 
        self.length = 0

By writing self.head, we are creating the head attribute. Then, when we put self.head = new_node it is going to create a link from head to the new node that we have created. Same thing applies with the tail attribute.

And, If you want to initialize an empty linked list, in this case we won't be providing any parameters. We are going to create an empty linked list with both head and tail pointing to None.

if we just print new_linked_list it will only show us the memory location. To get the value, we have to access the object attribute using the dot. Here, by print(new_linked_list.head), we are accessing the head from LinkedLink and by adding .value, we are accessing the value from the Node class.

Insertion in Singly linked list

We can insert a node at various position within a linked list, such as -

  1. at the beginning of a linked list [prepend]

  2. at the end of a linked list [append]

  3. after a node in any place in the middle of the linked list [insert]

Append method

Too add a node at the end of the linked list, we need to follow three steps :

  • create a new node. [ 50 --> None ]

  • update the current last node's next pointer to point to the new node

    [self.tail refers to the tail's vertical arrow. self.tail.next points to the yellow arrow]

  • update the "tail ->" to point to the new node

    [self.tail = new_node]

If you want to append an element in empty linked list, you need to create a link where head and tail pointing to none.

class LinkedList:
    def __init__(self):
        self.head = None 
        self.tail = None
        self.length = 0
    def append(self, value): # time + space complexxity: o(1) 
        new_node = Node(value) #using Node class
        if self.head = None: #o(1) time complexity
            self.head = new_node #o(1) time complexity
            self.tail = new_node #o(1) time complexity
        else: #o(1) time complexity
            self.tail.next = new_node #o(1) time complexity
            self.tail = new_node #o(1) time complexity           
        self.lenght += 1 #o(1) time complexity

How do we know if a linked list is empty? To check if a linked list is empty, you can look at the head attribute. If the head is None, the list is empty. Another way to check is by observing the length of the list. If the length is zero, it means there are no elements in the linked list. However, if the linked list is not empty, we put else condition and here we are going to implement our logic by following the pre-stated three rules.

Proper way to print a linked list " __str__ "

To see the linked list in a proper way after appending elements, you need to implement a "__str__" method in the LinkedLink class. It will return a string representation of the linked list class.

let's create a temp_node pointing to the head. temp_node will be iterating through the entire linked list while collecting the node values in str form in the result variable unless it finds tail.next = None

Here's the code of __str__ method:

def __str__(self): 
    result = ''
    temp_node = self.head 
    while temp_node is not None: 
        result += str(node.value)
        if temp_node.next is not None: 
            result += "->" 
        temp_node = temp_node.next #it will break from the loop
    return result

To continue to the next node, we set temp_node = temp_node.next and check the while loop condition. This process stores string values until we reach the last node. At the last node, it stores the final string value. When temp_node.next is None, the loop breaks, and the string representation of the linked list is returned.

Prepend method

Too add a node at the starting, we need to follow three steps :

  • create a new node [50 -> None]

  • updating new node's next reference to point to the head

  • update the head to point to the new node

    Again. if you have elements in the linked list the logics will be different. if you have no elements in the linked list the logic will be different.

      class LinkedList: 
          def __init__(self): 
              self.head = None 
              self.tail = None 
              self.length = 0
    
         def prepend(self,value): 
              new_node = Node(value) #cons time complexity, O(1)
              if self.head == None: # O(1) time complexity
                  self.head = new_node # O(1) time complexity
                  self.tail = new_node # O(1) time complexity
              else: 
                  new_node.next = self.head # O(1) time complexity
                  self.head = new_node # O(1) time complexity
              self.length += 1 # O(1) time complexity 
    
      # they don't requires any additional space 
      # time + space complexity = O(1)
    

Create a new node. check if the linked list is empty or not, write two conditions for this. Other operations are same as the append method.

Insert method

Now we will learn how to insert randomly.

First we will create a node. Let's say we want to insert it at the index of two. We will set a temp_node and start a loop to find two, and we will stop at the 1st index (index-1)

Then we will update the new nodes next reference to point to the node that is located after index-1

We are going to update the current nodes next reference to point to the new node.

Thus, the new node will be implanted at the 2nd index.

Now, for the code

def insert(self, index, value): 
    new_node = Node(value) # --------> O(1) time complexity
    #to avoid edge case
    if index < 0 or index > self.length: # --------> O(1) time complexity
        return False # --------> O(1) time complexity
    #if the linked list is empty 
    elif self.length == 0: # --------> O(1) time complexity
        self.head = new_node # --------> O(1) time complexity
        self.tail = new_node # --------> O(1) time complexity
    elif index == 0: #if you want to add at index of 0
        new_node.next = self.head # --------> O(1) time complexity
        self.head = new_node # --------> O(1) time complexity
    else: # --------> O(1) time complexity
        temp_node = self.head # --------> O(1) time complexity
        for _ in range(index-1): # --------> O(n) time complexity
            temp_node = temp_node.next # --------> O(1) time complexity
        new_node.next = temp_node.next # --------> O(1) time complexity
        temp_node.next = new_node # --------> O(1) time complexity
    self.length += 1 # --------> O(1) time complexity
    return True 

# Time complexity : O(n) 
# space cpmplexity : O(1)

I have used _ to look instead of i. because I am not going to use the iteration variable anywhere.

Traversal method

Traversing is necessary to access or search for a specific element. Specially for linked list because we can't access directly using index, we have to traverse starting with the head.

def traverse(self): 
    current = self.head # --------> O(1) time complexity
    # while current != None: 
    while current: # --------> O(n) time complexity
        print(current.value) # --------> O(1) time complexity
        current = current.next # --------> O(1) time complexity
# time complexity - O(n) 
# space complexity - o(1)

Search method

def search(self, target): 
    current = self.head 
    while current: 
        if current != target: 
            current = current.next 
        else: 
            print(current.value)
#or 
def search(self,target): 
    current = self.head # --------> O(1) time complexity
    idx = 0 #in case if you wanna get the index
    while current: # --------> O(N) time complexity
        if current.value == target: # --------> O(1) time complexity
            return True # --------> O(1) time complexity
        current = current.next # --------> O(1) time complexity
        idx += 1 # --------> O(1) time complexity
    return False

# time complexity - O(n) 
# space complexity - o(1), no additional space in needed

Get method

The get method in a linked list is used to retrieve the value of a node at a specific position (index) in the list. Here’s how it works:

def get(self, index):
    if index == -1: 
        return self.tail
    elif index < -1 or index > self.length: #edge case
        return None
    current = self.head 
        for _ in range(index):
            current = current.next 
        return current 
# time complexity - O(n) 
# space complexity - o(1)

Set method

The set method in a linked list is used to update the value of a node at a specific position (index) in the list. This method allows you to modify the data stored in a particular node without changing the structure of the list.

def set_value(self, index, value): 
    temp = self.get(index) 
    if temp: 
        temp.value = value
        return True 
    return False
# time complexity - O(n) 
# space complexity - o(1)

Pop first method

Pop first method removes the first node from the linked list and returns it.

def pop_first(self): 
    popped_node = self.head 
    if self.length == 0 
        return None
    if self.length ==1: 
        self.head = None
        self.tail = None
    else:
        self.head = popped_node.next #self.head = self.head.next
        popped_node.next = None
        self.length -= 1
    return pooped_node.value
# time complexity - O(1) 
# space complexity - o(1)

Pop method

Pop method removes the last node from the linked list and returns it.

def pop(self): 
    popped_node = self.tail
    if self.length == 0 :
        return None
    elif self.length == 1: 
        self.head = self.tail = None
    else:   
        temp = self.head
        while temp.next is not self.tail: # --------> O(N) time complexity
            temp = temp.next 
        self.tail = temp
        temp.next = None
    self.length -= 1
    return popped_node

# time complexity - O(N) 
# space complexity - o(1)

Remove method

remove any node you want.

def remove(self, index): 
    if index == 0: 
        return self.pop_first()
    if index > self.length or index < -1 : 
        return None 
    if index == self.length -1 or index == -1: 
        return self.pop() # --------> O(N) time complexity
    prev_node = self.get(index-1) # --------> O(N) time complexity
    popped_node = prev_node.next 
    prev_node.next = popped_node.next 
    popped.next = None
    self.length -= 1
    return popped_node

# time complexity - O(N) 
# space complexity - o(1)

Delete

How to delete all nodes? Answer is simple. If you set the head and tail pointer to None, it will remove all the references to the nodes in the linked list allowing the garbage collector to reclaim the memory used by those nodes.

def delete_all(self) :
    self.head = self.tail = None
    self.length = 0
# time complexity - O(1) 
# space complexity - o(1)

Time and space complexity

methodTime complexityspace complexity
createO(1)O(1)
appendO(1)O(1)
prependO(1)O(1)
insertO(N)O(1)
searchO(NO(1)
traverseO(N)O(1)
getO(N)O(1)
setO(N)O(1)
pop firstO(1)O(1)
popO(N)O(1)
removeO(N)O(1)
delete all nodesO(1)O(1)

The entire code

class Node: 
    def __init__(self, value): 
        self.value = value #data storing
        self.next = None #reference point to the next

# Node done!! Now head, tail creating 
class LinkedList: 
    def __init__(self, value) 
    new_node = Node(value)
    self.head = new_node 
    self.tail = new_node
    self.length = 1 
#OR, 
class LinkedList: 
    def __init__(self):
        self.head = None 
        self.head = None
        self.length = 0 
#you can use any of them!

    def __str__(self): 
        result = ''
        temp_node = self.head 
        while temp_node in not None: 
            result += str(temp_node.value) 
            if temp_node.next != None: 
                result += "->" 
            temp_node = temp_node.next #continuing to the next node
        return result

#append method
    def append(self,value): 
        new_node = Node(value)
        if self.head = None: 
            self.head = new_node
            self.tail = new_node 
        else: 
            self.tail.next = new_node 
            self.tail = new_node 
        self.length += 1

#prepend method
   def prepend(self,value): 
        new_node = Node(value)
        if self.head == None: 
            self.head = new_node 
            self.tail = new_node 
        else: 
            new_node.next = self.head 
            self.head = new_node
        self.length += 1        

#insert method 
    def insert(self, index, value): 
        new_node = Node(value) 
        if index < 0 or index > self.length: 
            return False 
        elif index == 0: 
            new_node.next = self.head
            self.head = new_node
        else:
            temp_node = self.head
            for _ in range(index-1): 
                temp_node = temp_node.next 
            new_node.next = temp_node.next 
            temp_node.next = new_node 
        self.length += 1    
        return True

#traversing 
    def traverse(self): 
        current = self.head 
        while current: 
            print(current.value) 
            current = current.next

#search method 
    def search(self,target): 
        current = self.head 
        idx = 0 #in case if you wanna get the index
        while current: 
            if current.value == target: 
                return idx #i want to get the idx
            current = current.next 
            idx += 1
        return False

#get method 
    def get(self, index):
        if index == -1: 
            return self.tail
        elif index < -1 or index > self.length: #edge case
            return None
        current = self.head 
        for _ in range(index):
            current = current.next 
        return current 
#set value 
    def set_value(self, index, value): 
        temp = self.get(index) 
        if temp: 
            temp.value = value
            return True 
        return False
#pop first method
    def pop_first(self): 
        popped_node = self.head 
        if self.length == 0 
            return None
        if self.length ==1: 
            self.head = None
            self.tail = None
        else:
            self.head = popped_node.next #self.head = self.head.next
            popped_node.next = None
            self.length -= 1
        return pooped_node.value

#pop method
    def pop(self): 
    popped_node = self.tail
    if self.length == 0 :
        return None
    elif self.length == 1: 
        self.head = self.tail = None
    else:   
        temp = self.head
        while temp.next is not self.tail: # --------> O(N) time complexity
            temp = temp.next 
        self.tail = temp
        temp.next = None
    self.length -= 1
    return popped_node

#remove method 
    def remove(self, index): 
    if index == 0: 
        return self.pop_first()
    if index > self.length or index < -1 : 
        return None 
    if index == self.length -1 or index == -1: 
        return self.pop() 
    prev_node = self.get(index-1) 
    popped_node = prev_node.next 
    prev_node.next = popped_node.next 
    popped.next = None
    self.length -= 1
    return popped_node

#delete method 
    def delete_all(self) :
        self.head = self.tail = None
        self.length = 0


new_linked_list = LinkedList()
new_linked_list.append(10) 
new_linked_list.append(20) 
new_linked_list.append(30) 
print(new_linked_list) #output: 10->20->30 
new_linked_list.prepend(40) #output: 40->10->20->30 
new_linked_list.insert(2,50)
new_linked_list.traverse
new_linked_list.search(70) #output False
new_linked_list.search(20) #output 1
print(new_linked_list.get(2)) #output mempry location
print(new_linked_list) #output 10 -> 20 -> 30
print(new_linked_list.set_value(2,50))
print(new_linked_list) #output 10 -> 20 -> 50