Skip to content

Implementing Complex Data Structures and Operations in Python

Updated: at 02:01 AM

Python is a high-level, general-purpose programming language that is widely used for various applications including web development, data analysis, artificial intelligence, and scientific computing. One of the key strengths of Python is its robust in-built data structures and algorithms that enable programmers to implement complex data structures and perform efficient operations on data. This comprehensive guide will examine some of the common complex data structures in Python and how to implement them along with operations for real-world usage.

Table of Contents

Open Table of Contents

Overview of Complex Data Structures

Some of the complex data structures available in Python include stacks, queues, linked lists, trees, graphs, and hash tables. These data structures provide efficient ways of organizing and accessing data for different use cases. For instance, stacks and queues implement a LIFO and FIFO system respectively and are useful when order needs to be maintained. Trees and graphs can model hierarchical relationships efficiently. Hash tables offer fast lookup times through the use of hash functions.

When implementing complex data structures in Python, developers have the flexibility to choose between using the built-in data structures from the collections module or implementing their own customized versions. The built-in data structures are optimized for performance but may lack some customization features. Implementing them from scratch gives more flexibility but requires more work.

In this guide, we will explore both approaches - utilizing built-in data structures and implementing from scratch using lists, dictionaries, sets or tuples.

Stacks

Stacks follow the LIFO (Last In First Out) order for accessing elements. The last element inserted into a stack is removed first. Some key operations associated with stack are:

Python has a built-in deque class that can be used as a stack. It is part of the collections module:

from collections import deque

stack = deque()

# Push items onto the stack
stack.append(1)
stack.append(2)

# Print stack contents
print(stack) # deque([1, 2])

# Pop item from stack
x = stack.pop()
print(x) # 2

# Check top element
print(stack[-1]) # 1

We can also implement a stack from scratch using a Python list:

class Stack:
    def __init__(self):
        self.list = []

    def push(self, item):
        self.list.append(item)

    def pop(self):
        if len(self.list) > 0:
            return self.list.pop()
        else:
            return None

    def peek(self):
        if len(self.list) > 0:
            return self.list[-1]
        else:
            return None

    def isEmpty(self):
        return self.list == []

customStack = Stack()
customStack.push(1)
customStack.push(2)
print(customStack.list) # [1, 2]

The push() and pop() methods respectively add and remove elements from the end of the list, delivering LIFO behavior.

Queues

Queues implement FIFO (First In First Out) ordering. The first element added to the queue is removed first. Operations for queues include:

Python’s deque class can serve as a queue:

from collections import deque

q = deque()

q.append(1)
q.append(2)

print(q) # deque([1, 2])

x = q.popleft()
print(x) # 1

print(q[0]) # 2

We can build a queue from scratch using a Python list:

class Queue:
    def __init__(self):
        self.list = []

    def enqueue(self, item):
        self.list.append(item)

    def dequeue(self):
        if len(self.list) > 0:
            return self.list.pop(0)
        else:
            return None

    def peek(self):
        if len(self.list) > 0:
            return self.list[0]
        else:
            return None

    def isEmpty(self):
        return self.list == []

q = Queue()
q.enqueue(1)
q.enqueue(2)
print(q.list) # [1, 2]

The enqueue() and dequeue() methods respectively append and pop from the front of the list to deliver FIFO behavior.

Linked Lists

Linked lists consist of nodes that contain data and a pointer to the next node in the list. This allows for efficient insertion and deletion of nodes. Operations on linked lists include:

In Python, we can implement linked lists using classes:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def insert(self, new_node):
        new_node.next = self.head
        self.head = new_node

    def delete(self, value):
        curr = self.head
        prev = None
        while curr is not None:
            if curr.data == value:
                if prev is None:
                    self.head = curr.next
                else:
                    prev.next = curr.next
                return
            prev = curr
            curr = curr.next

    def search(self, value):
        curr = self.head
        while curr is not None:
            if curr.data == value:
                return True
            curr = curr.next
        return False

    def display(self):
        elems = []
        curr = self.head
        while curr is not None:
            elems.append(curr.data)
            curr = curr.next
        print(elems)

linkedlist = LinkedList()

first_node = Node(1)
linkedlist.insert(first_node)

second_node = Node(2)
linkedlist.insert(second_node)

linkedlist.display() # [2, 1]

This implements a singly linked list. The nodes only reference the next node. For doubly linked lists, each node also maintains a reference to the previous node.

Trees

Trees are hierarchical data structures where data points are connected like branches and leaves of a tree. Key aspects include:

Some common operations on trees involve traversal, insertion, deletion, etc. Popular tree data structures are binary trees, binary search trees, AVL trees, and B-trees.

We can implement a simple binary tree in Python as:

class TreeNode:
   def __init__(self, data):
      self.data = data
      self.left = None
      self.right = None

# Insert node
def insert(root, new_val):
   if root is None:
      return TreeNode(new_val)
   else:
      if new_val < root.data:
         root.left = insert(root.left, new_val)
      else:
         root.right = insert(root.right, new_val)
   return root

# Find node
def find(root, target):
   if root is None:
      return False
   if root.data == target:
      return True
   if target < root.data:
      return find(root.left, target)
   if target > root.data:
      return find(root.right, target)

r = TreeNode(50)
insert(r, 30)
insert(r, 20)
insert(r, 40)
insert(r, 70)
insert(r, 60)
insert(r, 80)

print(find(r, 70)) # True

This implements a binary search tree, enabling efficient searching.

Graphs

Graphs consist of nodes connected by edges and are used to model relationships. Operations on graphs involve traversal, adding/removing nodes and edges. Graphs are categorized as:

Some ways to represent graphs in Python include adjacency matrices and lists.

Adjacency Matrix:

graph = [[0, 1, 1],
         [1, 0, 0],
         [1, 0, 0]]

print(graph[0][1]) # 1 (edge from vertex 0 to 1)

Adjacency List:

graph = {
  0: [1, 2],
  1: [0, 2],
  2: [0, 1]
}

print(graph[0]) # [1, 2] (vertices adjacent to 0)

Additional data structures like edge lists and incidence matrices can also model graphs. The choice depends on the operations needed.

Hash Tables

Hash tables provide fast O(1) lookup by generating a unique hash code for each value and mapping it to array indexes.

In Python, dictionaries serve as hash tables:

sample_dict = {'a': 1, 'b': 2}

print(sample_dict['a']) # Key lookup is O(1)

We can implement our own hash table in Python using a list of buckets with linked lists:

class LinkedListNode:
  def __init__(self, key, value):
     self.key = key
     self.value = value
     self.next = None

class HashTable:
  def __init__(self):
     self.num_buckets = 10
     self.buckets = [None] * self.num_buckets

  def insert(self, key, value):
     bucket_idx = hash(key) % self.num_buckets
     node = self.buckets[bucket_idx]

     if node is None:
        self.buckets[bucket_idx] = LinkedListNode(key, value)
        return

     prev = None
     while node is not None:
         prev = node
         node = node.next

     prev.next = LinkedListNode(key, value)

  def search(self, key):
     bucket_idx = hash(key) % self.num_buckets
     head = self.buckets[bucket_idx]

     while head is not None:
       if head.key == key:
         return head.value
       head = head.next

     return None # Key not found

This implements chaining to handle collisions. The hash table has O(1) lookup with load factor < 1.

Operations on Complex Data Structures

Along with implementing complex data structures like the examples above, Python’s built-in features and modules provide many additional capabilities to carry out complex operations on them:

Here are some examples of operations using built-in functions:

# Set union
set1 = {1, 2, 3}
set2 = {3, 4, 5}
set3 = set1.union(set2)

# Heap sort
import heapq
li = [5, 7, 9, 1, 3]
heapq.heapify(li)
print(heapq.nsmallest(len(li), li)) # [1, 3, 5, 7, 9]

# Recursive factorial
def factorial(n):
    if n == 1:
        return 1
    else:
        return n * factorial(n-1)

print(factorial(5)) # 120

Python’s versatile data structures and algorithms empower developers to implement and optimize complex data pipelines and operations.

Case Study: Graph Analysis of Social Networks

Analyzing graphs is a common use case for complex data structures and algorithms. Let’s go through an example of implementing a graph-based social network analysis in Python.

We will build an undirected graph from a dataset of user connections. Then we can run analysis to:

# Read dataset and build graph
import networkx as nx

data = [
  (1, 2), (2, 3), (1, 4), (5, 6),
  (5, 7), (7, 5), (6, 8), (8, 9)
]

graph = nx.Graph()
graph.add_edges_from(data)

# Find number of connections
for node in graph:
  print(f"{node} has {graph.degree(node)} connections")

# Identify influential users - most connections
print(f"Most influential user is {max(nx.degree(graph), key=lambda x: x[1])}")

# Recommend connections - friends of friends
print("Recommendations for user 1:")
friends = set(graph.neighbors(1))
fof = set(nx.non_neighbors(graph, 1))
print(fof & friends)

This demonstrates how various graph algorithms can provide insights for data-driven applications.

Conclusion

Python provides excellent support for implementing complex data structures like stacks, queues, linked lists, trees, graphs and hash tables. Built-in data structures and modules enable efficient operations like sorting, searching and graph algorithms.

Developers have the flexibility to utilize Python’s built-in data structures from the collections module or build their own custom versions using lists, dictionaries etc. Based on the use cases, appropriate data structures and algorithms can be selected to optimize performance.

With its versatility, Python is well suited for projects that involve significant data manipulation and analysis. Libraries like NumPy and pandas build on Python’s strengths to enable scientific computing and data analytics applications. By mastering complex data structures and operations in Python, programmers can develop and enhance solutions for today’s data-driven world.