Data Structures

Last updated: December 2024

Disclaimer: These are my personal notes compiled for my own reference and learning. They may contain errors, incomplete information, or personal interpretations. While I strive for accuracy, these notes are not peer-reviewed and should not be considered authoritative sources. Please consult official textbooks, research papers, or other reliable sources for academic or professional purposes.

1. Introduction to Data Structures

Data structures are ways of organizing and storing data to enable efficient access and modification. The choice of data structure affects the complexity of algorithms that operate on the data.

2. Arrays and Dynamic Arrays

2.1 Static Arrays

2.2 Dynamic Arrays (Python Lists)

class DynamicArray:
    def __init__(self):
        self.capacity = 1
        self.size = 0
        self.data = [None] * self.capacity
    
    def __getitem__(self, index):
        if not 0 <= index < self.size:
            raise IndexError("Index out of range")
        return self.data[index]
    
    def __setitem__(self, index, value):
        if not 0 <= index < self.size:
            raise IndexError("Index out of range")
        self.data[index] = value
    
    def append(self, value):
        if self.size == self.capacity:
            self._resize()
        self.data[self.size] = value
        self.size += 1
    
    def insert(self, index, value):
        if not 0 <= index <= self.size:
            raise IndexError("Index out of range")
        
        if self.size == self.capacity:
            self._resize()
        
        # Shift elements to the right
        for i in range(self.size, index, -1):
            self.data[i] = self.data[i-1]
        
        self.data[index] = value
        self.size += 1
    
    def delete(self, index):
        if not 0 <= index < self.size:
            raise IndexError("Index out of range")
        
        # Shift elements to the left
        for i in range(index, self.size - 1):
            self.data[i] = self.data[i+1]
        
        self.size -= 1
    
    def _resize(self):
        old_capacity = self.capacity
        self.capacity *= 2
        new_data = [None] * self.capacity
        
        for i in range(self.size):
            new_data[i] = self.data[i]
        
        self.data = new_data
    
    def __len__(self):
        return self.size
    
    def __str__(self):
        return str([self.data[i] for i in range(self.size)])

# Usage example
arr = DynamicArray()
for i in range(5):
    arr.append(i)
print(arr)  # [0, 1, 2, 3, 4]

3. Linked Lists

3.1 Singly Linked List

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

class LinkedList:
    def __init__(self):
        self.head = None
        self.size = 0
    
    def append(self, val):
        new_node = ListNode(val)
        if not self.head:
            self.head = new_node
        else:
            current = self.head
            while current.next:
                current = current.next
            current.next = new_node
        self.size += 1
    
    def prepend(self, val):
        new_node = ListNode(val, self.head)
        self.head = new_node
        self.size += 1
    
    def insert(self, index, val):
        if index == 0:
            self.prepend(val)
            return
        
        if index < 0 or index > self.size:
            raise IndexError("Index out of range")
        
        current = self.head
        for _ in range(index - 1):
            current = current.next
        
        new_node = ListNode(val, current.next)
        current.next = new_node
        self.size += 1
    
    def delete(self, index):
        if index < 0 or index >= self.size:
            raise IndexError("Index out of range")
        
        if index == 0:
            self.head = self.head.next
            self.size -= 1
            return
        
        current = self.head
        for _ in range(index - 1):
            current = current.next
        
        current.next = current.next.next
        self.size -= 1
    
    def find(self, val):
        current = self.head
        index = 0
        while current:
            if current.val == val:
                return index
            current = current.next
            index += 1
        return -1
    
    def __len__(self):
        return self.size
    
    def __str__(self):
        result = []
        current = self.head
        while current:
            result.append(str(current.val))
            current = current.next
        return " -> ".join(result)

# Complexity Analysis:
# Access: O(n) - must traverse from head
# Search: O(n) - linear scan
# Insertion: O(1) at head, O(n) at arbitrary position
# Deletion: O(1) at head, O(n) at arbitrary position
# Space: O(n)

3.2 Doubly Linked List

class DoublyListNode:
    def __init__(self, val=0, prev=None, next=None):
        self.val = val
        self.prev = prev
        self.next = next

class DoublyLinkedList:
    def __init__(self):
        # Sentinel nodes to simplify edge cases
        self.head = DoublyListNode()
        self.tail = DoublyListNode()
        self.head.next = self.tail
        self.tail.prev = self.head
        self.size = 0
    
    def _add_node(self, node, prev_node):
        """Add node after prev_node"""
        next_node = prev_node.next
        prev_node.next = node
        node.prev = prev_node
        node.next = next_node
        next_node.prev = node
        self.size += 1
    
    def _remove_node(self, node):
        """Remove node from list"""
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node
        self.size -= 1
    
    def append(self, val):
        new_node = DoublyListNode(val)
        self._add_node(new_node, self.tail.prev)
    
    def prepend(self, val):
        new_node = DoublyListNode(val)
        self._add_node(new_node, self.head)
    
    def delete_val(self, val):
        current = self.head.next
        while current != self.tail:
            if current.val == val:
                self._remove_node(current)
                return True
            current = current.next
        return False
    
    def __len__(self):
        return self.size
    
    def __str__(self):
        result = []
        current = self.head.next
        while current != self.tail:
            result.append(str(current.val))
            current = current.next
        return " <-> ".join(result)

4. Stacks and Queues

4.1 Stack (LIFO - Last In, First Out)

class Stack:
    def __init__(self):
        self.items = []
    
    def push(self, item):
        self.items.append(item)  # O(1) amortized
    
    def pop(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self.items.pop()  # O(1)
    
    def peek(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self.items[-1]  # O(1)
    
    def is_empty(self):
        return len(self.items) == 0
    
    def size(self):
        return len(self.items)

# Applications: Expression evaluation, function calls, undo operations
def is_valid_parentheses(s):
    """Check if parentheses are balanced using stack"""
    stack = Stack()
    pairs = {'(': ')', '[': ']', '{': '}'}
    
    for char in s:
        if char in pairs:
            stack.push(char)
        elif char in pairs.values():
            if stack.is_empty() or pairs[stack.pop()] != char:
                return False
    
    return stack.is_empty()

4.2 Queue (FIFO - First In, First Out)

from collections import deque

class Queue:
    def __init__(self):
        self.items = deque()
    
    def enqueue(self, item):
        self.items.append(item)  # O(1)
    
    def dequeue(self):
        if self.is_empty():
            raise IndexError("Queue is empty")
        return self.items.popleft()  # O(1)
    
    def front(self):
        if self.is_empty():
            raise IndexError("Queue is empty")
        return self.items[0]  # O(1)
    
    def is_empty(self):
        return len(self.items) == 0
    
    def size(self):
        return len(self.items)

# Circular Queue implementation
class CircularQueue:
    def __init__(self, capacity):
        self.capacity = capacity
        self.queue = [None] * capacity
        self.head = 0
        self.tail = 0
        self.size = 0
    
    def enqueue(self, item):
        if self.is_full():
            raise OverflowError("Queue is full")
        
        self.queue[self.tail] = item
        self.tail = (self.tail + 1) % self.capacity
        self.size += 1
    
    def dequeue(self):
        if self.is_empty():
            raise IndexError("Queue is empty")
        
        item = self.queue[self.head]
        self.queue[self.head] = None
        self.head = (self.head + 1) % self.capacity
        self.size -= 1
        return item
    
    def is_empty(self):
        return self.size == 0
    
    def is_full(self):
        return self.size == self.capacity

5. Trees

5.1 Binary Tree

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

class BinaryTree:
    def __init__(self, root=None):
        self.root = root
    
    def inorder_traversal(self, node=None):
        """Left -> Root -> Right"""
        if node is None:
            node = self.root
        
        result = []
        if node:
            result.extend(self.inorder_traversal(node.left))
            result.append(node.val)
            result.extend(self.inorder_traversal(node.right))
        return result
    
    def preorder_traversal(self, node=None):
        """Root -> Left -> Right"""
        if node is None:
            node = self.root
        
        result = []
        if node:
            result.append(node.val)
            result.extend(self.preorder_traversal(node.left))
            result.extend(self.preorder_traversal(node.right))
        return result
    
    def postorder_traversal(self, node=None):
        """Left -> Right -> Root"""
        if node is None:
            node = self.root
        
        result = []
        if node:
            result.extend(self.postorder_traversal(node.left))
            result.extend(self.postorder_traversal(node.right))
            result.append(node.val)
        return result
    
    def level_order_traversal(self):
        """Breadth-first traversal"""
        if not self.root:
            return []
        
        result = []
        queue = Queue()
        queue.enqueue(self.root)
        
        while not queue.is_empty():
            node = queue.dequeue()
            result.append(node.val)
            
            if node.left:
                queue.enqueue(node.left)
            if node.right:
                queue.enqueue(node.right)
        
        return result
    
    def height(self, node=None):
        """Calculate height of tree"""
        if node is None:
            node = self.root
        
        if not node:
            return -1
        
        return 1 + max(self.height(node.left), self.height(node.right))
    
    def is_balanced(self, node=None):
        """Check if tree is height-balanced"""
        if node is None:
            node = self.root
        
        def check_balance(node):
            if not node:
                return 0, True
            
            left_height, left_balanced = check_balance(node.left)
            right_height, right_balanced = check_balance(node.right)
            
            balanced = (left_balanced and right_balanced and 
                       abs(left_height - right_height) <= 1)
            height = 1 + max(left_height, right_height)
            
            return height, balanced
        
        _, balanced = check_balance(node)
        return balanced

5.2 Binary Search Tree

class BST:
    def __init__(self):
        self.root = None
    
    def insert(self, val):
        self.root = self._insert_recursive(self.root, val)
    
    def _insert_recursive(self, node, val):
        if not node:
            return TreeNode(val)
        
        if val < node.val:
            node.left = self._insert_recursive(node.left, val)
        elif val > node.val:
            node.right = self._insert_recursive(node.right, val)
        
        return node
    
    def search(self, val):
        return self._search_recursive(self.root, val)
    
    def _search_recursive(self, node, val):
        if not node or node.val == val:
            return node
        
        if val < node.val:
            return self._search_recursive(node.left, val)
        else:
            return self._search_recursive(node.right, val)
    
    def delete(self, val):
        self.root = self._delete_recursive(self.root, val)
    
    def _delete_recursive(self, node, val):
        if not node:
            return node
        
        if val < node.val:
            node.left = self._delete_recursive(node.left, val)
        elif val > node.val:
            node.right = self._delete_recursive(node.right, val)
        else:
            # Node to be deleted found
            if not node.left:
                return node.right
            elif not node.right:
                return node.left
            
            # Node has two children
            # Find inorder successor (smallest in right subtree)
            successor = self._find_min(node.right)
            node.val = successor.val
            node.right = self._delete_recursive(node.right, successor.val)
        
        return node
    
    def _find_min(self, node):
        while node.left:
            node = node.left
        return node
    
    def inorder(self):
        """Returns sorted order for BST"""
        result = []
        self._inorder_recursive(self.root, result)
        return result
    
    def _inorder_recursive(self, node, result):
        if node:
            self._inorder_recursive(node.left, result)
            result.append(node.val)
            self._inorder_recursive(node.right, result)

# Complexity Analysis:
# Average case: O(log n) for search, insert, delete
# Worst case: O(n) when tree becomes skewed (like a linked list)
# Space: O(n)

5.3 AVL Tree (Self-balancing BST)

class AVLNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None
        self.height = 1

class AVLTree:
    def __init__(self):
        self.root = None
    
    def _get_height(self, node):
        if not node:
            return 0
        return node.height
    
    def _get_balance(self, node):
        if not node:
            return 0
        return self._get_height(node.left) - self._get_height(node.right)
    
    def _update_height(self, node):
        if node:
            node.height = 1 + max(self._get_height(node.left), 
                                self._get_height(node.right))
    
    def _rotate_right(self, y):
        x = y.left
        T2 = x.right
        
        # Perform rotation
        x.right = y
        y.left = T2
        
        # Update heights
        self._update_height(y)
        self._update_height(x)
        
        return x
    
    def _rotate_left(self, x):
        y = x.right
        T2 = y.left
        
        # Perform rotation
        y.left = x
        x.right = T2
        
        # Update heights
        self._update_height(x)
        self._update_height(y)
        
        return y
    
    def insert(self, val):
        self.root = self._insert_recursive(self.root, val)
    
    def _insert_recursive(self, node, val):
        # Standard BST insertion
        if not node:
            return AVLNode(val)
        
        if val < node.val:
            node.left = self._insert_recursive(node.left, val)
        elif val > node.val:
            node.right = self._insert_recursive(node.right, val)
        else:
            return node  # Duplicate values not allowed
        
        # Update height
        self._update_height(node)
        
        # Get balance factor
        balance = self._get_balance(node)
        
        # Left Left Case
        if balance > 1 and val < node.left.val:
            return self._rotate_right(node)
        
        # Right Right Case
        if balance < -1 and val > node.right.val:
            return self._rotate_left(node)
        
        # Left Right Case
        if balance > 1 and val > node.left.val:
            node.left = self._rotate_left(node.left)
            return self._rotate_right(node)
        
        # Right Left Case
        if balance < -1 and val < node.right.val:
            node.right = self._rotate_right(node.right)
            return self._rotate_left(node)
        
        return node

# AVL Tree guarantees O(log n) for all operations

6. Heaps

6.1 Binary Heap (Min-Heap)

class MinHeap:
    def __init__(self):
        self.heap = []
    
    def _parent(self, i):
        return (i - 1) // 2
    
    def _left_child(self, i):
        return 2 * i + 1
    
    def _right_child(self, i):
        return 2 * i + 2
    
    def _swap(self, i, j):
        self.heap[i], self.heap[j] = self.heap[j], self.heap[i]
    
    def insert(self, val):
        self.heap.append(val)
        self._heapify_up(len(self.heap) - 1)
    
    def _heapify_up(self, i):
        parent = self._parent(i)
        if i > 0 and self.heap[i] < self.heap[parent]:
            self._swap(i, parent)
            self._heapify_up(parent)
    
    def extract_min(self):
        if not self.heap:
            raise IndexError("Heap is empty")
        
        if len(self.heap) == 1:
            return self.heap.pop()
        
        min_val = self.heap[0]
        self.heap[0] = self.heap.pop()
        self._heapify_down(0)
        return min_val
    
    def _heapify_down(self, i):
        left = self._left_child(i)
        right = self._right_child(i)
        smallest = i
        
        if left < len(self.heap) and self.heap[left] < self.heap[smallest]:
            smallest = left
        
        if right < len(self.heap) and self.heap[right] < self.heap[smallest]:
            smallest = right
        
        if smallest != i:
            self._swap(i, smallest)
            self._heapify_down(smallest)
    
    def peek(self):
        if not self.heap:
            raise IndexError("Heap is empty")
        return self.heap[0]
    
    def size(self):
        return len(self.heap)
    
    def is_empty(self):
        return len(self.heap) == 0

# Heap Sort implementation
def heap_sort(arr):
    """Sort array using heap sort - O(n log n)"""
    heap = MinHeap()
    
    # Insert all elements
    for num in arr:
        heap.insert(num)
    
    # Extract all elements in sorted order
    sorted_arr = []
    while not heap.is_empty():
        sorted_arr.append(heap.extract_min())
    
    return sorted_arr

# Priority Queue using heap
import heapq

class PriorityQueue:
    def __init__(self):
        self.heap = []
        self.index = 0
    
    def push(self, item, priority):
        heapq.heappush(self.heap, (priority, self.index, item))
        self.index += 1
    
    def pop(self):
        if not self.heap:
            raise IndexError("Priority queue is empty")
        return heapq.heappop(self.heap)[2]
    
    def is_empty(self):
        return len(self.heap) == 0

# Complexity:
# Insert: O(log n)
# Extract min/max: O(log n)
# Peek: O(1)
# Build heap: O(n)

7. Hash Tables

7.1 Hash Table with Chaining

class HashTable:
    def __init__(self, capacity=10):
        self.capacity = capacity
        self.size = 0
        self.buckets = [[] for _ in range(capacity)]
    
    def _hash(self, key):
        """Simple hash function"""
        if isinstance(key, int):
            return key % self.capacity
        elif isinstance(key, str):
            return sum(ord(c) for c in key) % self.capacity
        else:
            return hash(key) % self.capacity
    
    def put(self, key, value):
        index = self._hash(key)
        bucket = self.buckets[index]
        
        # Check if key already exists
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)
                return
        
        # Add new key-value pair
        bucket.append((key, value))
        self.size += 1
        
        # Resize if load factor is too high
        if self.size > self.capacity * 0.75:
            self._resize()
    
    def get(self, key):
        index = self._hash(key)
        bucket = self.buckets[index]
        
        for k, v in bucket:
            if k == key:
                return v
        
        raise KeyError(key)
    
    def delete(self, key):
        index = self._hash(key)
        bucket = self.buckets[index]
        
        for i, (k, v) in enumerate(bucket):
            if k == key:
                del bucket[i]
                self.size -= 1
                return v
        
        raise KeyError(key)
    
    def _resize(self):
        old_buckets = self.buckets
        self.capacity *= 2
        self.size = 0
        self.buckets = [[] for _ in range(self.capacity)]
        
        # Rehash all elements
        for bucket in old_buckets:
            for key, value in bucket:
                self.put(key, value)
    
    def __contains__(self, key):
        try:
            self.get(key)
            return True
        except KeyError:
            return False
    
    def keys(self):
        result = []
        for bucket in self.buckets:
            for key, _ in bucket:
                result.append(key)
        return result
    
    def values(self):
        result = []
        for bucket in self.buckets:
            for _, value in bucket:
                result.append(value)
        return result
    
    def items(self):
        result = []
        for bucket in self.buckets:
            for item in bucket:
                result.append(item)
        return result

# Hash table with open addressing (linear probing)
class OpenAddressingHashTable:
    def __init__(self, capacity=10):
        self.capacity = capacity
        self.size = 0
        self.keys = [None] * capacity
        self.values = [None] * capacity
        self.deleted = [False] * capacity
    
    def _hash(self, key):
        return hash(key) % self.capacity
    
    def _find_slot(self, key):
        index = self._hash(key)
        original_index = index
        
        while (self.keys[index] is not None and 
               self.keys[index] != key and 
               not self.deleted[index]):
            index = (index + 1) % self.capacity
            if index == original_index:
                raise Exception("Hash table is full")
        
        return index
    
    def put(self, key, value):
        if self.size >= self.capacity * 0.75:
            self._resize()
        
        index = self._find_slot(key)
        
        if self.keys[index] is None or self.deleted[index]:
            self.size += 1
        
        self.keys[index] = key
        self.values[index] = value
        self.deleted[index] = False
    
    def get(self, key):
        index = self._find_slot(key)
        
        if self.keys[index] == key and not self.deleted[index]:
            return self.values[index]
        
        raise KeyError(key)
    
    def delete(self, key):
        index = self._find_slot(key)
        
        if self.keys[index] == key and not self.deleted[index]:
            self.deleted[index] = True
            self.size -= 1
            return self.values[index]
        
        raise KeyError(key)
    
    def _resize(self):
        old_keys = self.keys[:]
        old_values = self.values[:]
        old_deleted = self.deleted[:]
        
        self.capacity *= 2
        self.size = 0
        self.keys = [None] * self.capacity
        self.values = [None] * self.capacity
        self.deleted = [False] * self.capacity
        
        for i in range(len(old_keys)):
            if old_keys[i] is not None and not old_deleted[i]:
                self.put(old_keys[i], old_values[i])

# Average case complexity:
# Search, Insert, Delete: O(1)
# Worst case: O(n) when many collisions occur

8. Graphs

8.1 Graph Representations

class Graph:
    def __init__(self, directed=False):
        self.directed = directed
        self.adj_list = {}
    
    def add_vertex(self, vertex):
        if vertex not in self.adj_list:
            self.adj_list[vertex] = []
    
    def add_edge(self, u, v, weight=1):
        self.add_vertex(u)
        self.add_vertex(v)
        
        self.adj_list[u].append((v, weight))
        if not self.directed:
            self.adj_list[v].append((u, weight))
    
    def remove_edge(self, u, v):
        if u in self.adj_list:
            self.adj_list[u] = [(vertex, weight) for vertex, weight in self.adj_list[u] if vertex != v]
        
        if not self.directed and v in self.adj_list:
            self.adj_list[v] = [(vertex, weight) for vertex, weight in self.adj_list[v] if vertex != u]
    
    def get_neighbors(self, vertex):
        return self.adj_list.get(vertex, [])
    
    def has_edge(self, u, v):
        if u not in self.adj_list:
            return False
        return any(vertex == v for vertex, _ in self.adj_list[u])
    
    def vertices(self):
        return list(self.adj_list.keys())
    
    def edges(self):
        edges = []
        for u in self.adj_list:
            for v, weight in self.adj_list[u]:
                if self.directed or u <= v:  # Avoid duplicates in undirected graph
                    edges.append((u, v, weight))
        return edges

# Matrix representation for dense graphs
class GraphMatrix:
    def __init__(self, num_vertices, directed=False):
        self.num_vertices = num_vertices
        self.directed = directed
        self.matrix = [[0] * num_vertices for _ in range(num_vertices)]
    
    def add_edge(self, u, v, weight=1):
        self.matrix[u][v] = weight
        if not self.directed:
            self.matrix[v][u] = weight
    
    def remove_edge(self, u, v):
        self.matrix[u][v] = 0
        if not self.directed:
            self.matrix[v][u] = 0
    
    def has_edge(self, u, v):
        return self.matrix[u][v] != 0
    
    def get_neighbors(self, vertex):
        neighbors = []
        for i in range(self.num_vertices):
            if self.matrix[vertex][i] != 0:
                neighbors.append((i, self.matrix[vertex][i]))
        return neighbors

8.2 Graph Traversal Algorithms

# Depth-First Search (DFS)
def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    
    visited.add(start)
    result = [start]
    
    for neighbor, _ in graph.get_neighbors(start):
        if neighbor not in visited:
            result.extend(dfs(graph, neighbor, visited))
    
    return result

def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    result = []
    
    while stack:
        vertex = stack.pop()
        if vertex not in visited:
            visited.add(vertex)
            result.append(vertex)
            
            # Add neighbors to stack (reverse order for consistent traversal)
            neighbors = [neighbor for neighbor, _ in graph.get_neighbors(vertex)]
            for neighbor in reversed(neighbors):
                if neighbor not in visited:
                    stack.append(neighbor)
    
    return result

# Breadth-First Search (BFS)
def bfs(graph, start):
    visited = set()
    queue = Queue()
    queue.enqueue(start)
    visited.add(start)
    result = []
    
    while not queue.is_empty():
        vertex = queue.dequeue()
        result.append(vertex)
        
        for neighbor, _ in graph.get_neighbors(vertex):
            if neighbor not in visited:
                visited.add(neighbor)
                queue.enqueue(neighbor)
    
    return result

# Shortest path in unweighted graph using BFS
def shortest_path_bfs(graph, start, end):
    if start == end:
        return [start]
    
    visited = set()
    queue = Queue()
    queue.enqueue((start, [start]))
    visited.add(start)
    
    while not queue.is_empty():
        vertex, path = queue.dequeue()
        
        for neighbor, _ in graph.get_neighbors(vertex):
            if neighbor == end:
                return path + [neighbor]
            
            if neighbor not in visited:
                visited.add(neighbor)
                queue.enqueue((neighbor, path + [neighbor]))
    
    return None  # No path found

# Dijkstra's Algorithm for shortest path in weighted graph
def dijkstra(graph, start):
    distances = {vertex: float('inf') for vertex in graph.vertices()}
    distances[start] = 0
    previous = {}
    
    pq = PriorityQueue()
    pq.push(start, 0)
    
    while not pq.is_empty():
        current = pq.pop()
        
        for neighbor, weight in graph.get_neighbors(current):
            distance = distances[current] + weight
            
            if distance < distances[neighbor]:
                distances[neighbor] = distance
                previous[neighbor] = current
                pq.push(neighbor, distance)
    
    return distances, previous

def reconstruct_path(previous, start, end):
    path = []
    current = end
    
    while current is not None:
        path.append(current)
        current = previous.get(current)
    
    path.reverse()
    
    if path[0] == start:
        return path
    else:
        return None  # No path exists

9. Advanced Data Structures

9.1 Trie (Prefix Tree)

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end_of_word = False

class Trie:
    def __init__(self):
        self.root = TrieNode()
    
    def insert(self, word):
        current = self.root
        for char in word:
            if char not in current.children:
                current.children[char] = TrieNode()
            current = current.children[char]
        current.is_end_of_word = True
    
    def search(self, word):
        current = self.root
        for char in word:
            if char not in current.children:
                return False
            current = current.children[char]
        return current.is_end_of_word
    
    def starts_with(self, prefix):
        current = self.root
        for char in prefix:
            if char not in current.children:
                return False
            current = current.children[char]
        return True
    
    def delete(self, word):
        def _delete_helper(node, word, index):
            if index == len(word):
                if not node.is_end_of_word:
                    return False
                node.is_end_of_word = False
                return len(node.children) == 0
            
            char = word[index]
            if char not in node.children:
                return False
            
            should_delete_child = _delete_helper(node.children[char], word, index + 1)
            
            if should_delete_child:
                del node.children[char]
                return not node.is_end_of_word and len(node.children) == 0
            
            return False
        
        _delete_helper(self.root, word, 0)
    
    def get_all_words_with_prefix(self, prefix):
        current = self.root
        for char in prefix:
            if char not in current.children:
                return []
            current = current.children[char]
        
        words = []
        self._dfs_words(current, prefix, words)
        return words
    
    def _dfs_words(self, node, current_word, words):
        if node.is_end_of_word:
            words.append(current_word)
        
        for char, child_node in node.children.items():
            self._dfs_words(child_node, current_word + char, words)

# Applications: Autocomplete, spell checker, IP routing

9.2 Union-Find (Disjoint Set)

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.components = n
    
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # Path compression
        return self.parent[x]
    
    def union(self, x, y):
        root_x = self.find(x)
        root_y = self.find(y)
        
        if root_x != root_y:
            # Union by rank
            if self.rank[root_x] < self.rank[root_y]:
                self.parent[root_x] = root_y
            elif self.rank[root_x] > self.rank[root_y]:
                self.parent[root_y] = root_x
            else:
                self.parent[root_y] = root_x
                self.rank[root_x] += 1
            
            self.components -= 1
            return True
        return False
    
    def connected(self, x, y):
        return self.find(x) == self.find(y)
    
    def count_components(self):
        return self.components

# Application: Kruskal's Minimum Spanning Tree
def kruskal_mst(graph):
    edges = graph.edges()
    edges.sort(key=lambda x: x[2])  # Sort by weight
    
    vertices = graph.vertices()
    vertex_to_index = {v: i for i, v in enumerate(vertices)}
    uf = UnionFind(len(vertices))
    
    mst_edges = []
    total_weight = 0
    
    for u, v, weight in edges:
        u_idx = vertex_to_index[u]
        v_idx = vertex_to_index[v]
        
        if uf.union(u_idx, v_idx):
            mst_edges.append((u, v, weight))
            total_weight += weight
    
    return mst_edges, total_weight

10. Complexity Summary

Data Structure Search Insert Delete Space
Array O(n) O(n) O(n) O(n)
Dynamic Array O(n) O(1) amortized O(n) O(n)
Linked List O(n) O(1) O(1) O(n)
Stack O(n) O(1) O(1) O(n)
Queue O(n) O(1) O(1) O(n)
BST (Average) O(log n) O(log n) O(log n) O(n)
AVL Tree O(log n) O(log n) O(log n) O(n)
Hash Table (Average) O(1) O(1) O(1) O(n)
Binary Heap O(n) O(log n) O(log n) O(n)
Trie O(m) O(m) O(m) O(ALPHABET_SIZE × N × M)

Where n is the number of elements and m is the length of the string for Trie operations.

11. References