Skip to content

Latest commit

 

History

History
561 lines (455 loc) · 16.4 KB

File metadata and controls

561 lines (455 loc) · 16.4 KB

Exploring Linked Lists in Python

Slide 1: Node Class Implementation

A linked list fundamentally begins with the Node class, representing individual elements in the list. Each node contains two essential components: the data value and a reference to the next node, forming the building blocks of our linked list structure.

class Node:
    def __init__(self, data):
        self.data = data    # Store the actual data
        self.next = None    # Reference to the next node
        
    def __str__(self):
        return f"Node(data={self.data})"

# Example usage
node1 = Node(5)
node2 = Node(10)
node1.next = node2
print(node1)        # Output: Node(data=5)
print(node1.next)   # Output: Node(data=10)

Slide 2: LinkedList Class Foundation

The LinkedList class serves as a wrapper around our nodes, providing a clean interface for list operations. It maintains a reference to the head node and tracks the list's size, establishing the foundation for more complex operations.

class LinkedList:
    def __init__(self):
        self.head = None    # Initialize empty list
        self.size = 0       # Track list size
        
    def is_empty(self):
        return self.head is None
    
    def __len__(self):
        return self.size
    
    def __str__(self):
        if self.is_empty():
            return "[]"
        current = self.head
        result = []
        while current:
            result.append(str(current.data))
            current = current.next
        return "[" + " -> ".join(result) + "]"

Slide 3: Insertion Operations

Inserting elements into a linked list requires careful pointer manipulation. We implement three primary insertion methods: insert_front for beginning insertion, insert_end for appending, and insert_at for arbitrary position insertion.

def insert_front(self, data):
    new_node = Node(data)
    new_node.next = self.head
    self.head = new_node
    self.size += 1

def insert_end(self, data):
    if self.is_empty():
        self.head = Node(data)
    else:
        current = self.head
        while current.next:
            current = current.next
        current.next = Node(data)
    self.size += 1

def insert_at(self, data, position):
    if position < 0 or position > self.size:
        raise IndexError("Invalid position")
    if position == 0:
        self.insert_front(data)
        return
    current = self.head
    for _ in range(position - 1):
        current = current.next
    new_node = Node(data)
    new_node.next = current.next
    current.next = new_node
    self.size += 1

Slide 4: Deletion Operations

Deletion operations in linked lists require proper handling of node references to maintain list integrity. We implement methods for removing elements from the front, end, and specific positions while managing edge cases.

def delete_front(self):
    if self.is_empty():
        raise IndexError("Delete from empty list")
    data = self.head.data
    self.head = self.head.next
    self.size -= 1
    return data

def delete_end(self):
    if self.is_empty():
        raise IndexError("Delete from empty list")
    if self.head.next is None:
        data = self.head.data
        self.head = None
        self.size -= 1
        return data
    current = self.head
    while current.next.next:
        current = current.next
    data = current.next.data
    current.next = None
    self.size -= 1
    return data

def delete_at(self, position):
    if position < 0 or position >= self.size:
        raise IndexError("Invalid position")
    if position == 0:
        return self.delete_front()
    current = self.head
    for _ in range(position - 1):
        current = current.next
    data = current.next.data
    current.next = current.next.next
    self.size -= 1
    return data

Slide 5: Search and Access Operations

Efficient search and access operations are crucial for linked list functionality. We implement methods to find elements by value and position, with time complexity analysis showing linear search characteristics.

def find(self, value):
    current = self.head
    position = 0
    while current:
        if current.data == value:
            return position
        current = current.next
        position += 1
    return -1

def get_at(self, position):
    if position < 0 or position >= self.size:
        raise IndexError("Invalid position")
    current = self.head
    for _ in range(position):
        current = current.next
    return current.data

def contains(self, value):
    return self.find(value) != -1

Slide 6: Traversal and List Manipulation

Linked list traversal forms the basis for many operations like reversing, detecting cycles, and finding middle elements. We implement essential traversal-based operations using iterative and recursive approaches for optimal performance.

def reverse(self):
    previous = None
    current = self.head
    while current:
        next_node = current.next
        current.next = previous
        previous = current
        current = next_node
    self.head = previous

def find_middle(self):
    if self.is_empty():
        return None
    slow = fast = self.head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    return slow.data

def has_cycle(self):
    if self.is_empty():
        return False
    slow = fast = self.head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            return True
    return False

Slide 7: List Merging and Sorting

Understanding list merging and sorting operations is crucial for handling complex data manipulations. We implement merge sort for linked lists, demonstrating efficient sorting with O(nlog⁡n)O(n \log n)O(nlogn) time complexity.

def merge_sorted_lists(list1, list2):
    dummy = Node(0)
    current = dummy
    
    while list1 and list2:
        if list1.data <= list2.data:
            current.next = list1
            list1 = list1.next
        else:
            current.next = list2
            list2 = list2.next
        current = current.next
    
    current.next = list1 or list2
    return dummy.next

def merge_sort(self):
    if not self.head or not self.head.next:
        return self.head
    
    # Find middle
    middle = self.get_middle_node()
    next_to_middle = middle.next
    middle.next = None
    
    # Recursive sort
    left = LinkedList()
    left.head = self.head
    right = LinkedList()
    right.head = next_to_middle
    
    left.merge_sort()
    right.merge_sort()
    
    # Merge sorted halves
    self.head = merge_sorted_lists(left.head, right.head)

Slide 8: Advanced List Operations

Complex list operations like finding intersections, removing duplicates, and creating circular lists demonstrate advanced pointer manipulation and algorithm design principles in linked list implementations.

def remove_duplicates(self):
    if self.is_empty():
        return
    
    seen = set()
    current = self.head
    seen.add(current.data)
    
    while current.next:
        if current.next.data in seen:
            current.next = current.next.next
            self.size -= 1
        else:
            seen.add(current.next.data)
            current = current.next

def find_intersection(self, other_list):
    if self.is_empty() or other_list.is_empty():
        return None
        
    ptr1 = self.head
    ptr2 = other_list.head
    
    while ptr1 != ptr2:
        ptr1 = ptr1.next if ptr1 else other_list.head
        ptr2 = ptr2.next if ptr2 else self.head
        
    return ptr1

def make_circular(self, position):
    if position >= self.size:
        return False
        
    current = self.head
    target = None
    
    for i in range(self.size):
        if i == position:
            target = current
        if not current.next:
            current.next = target
            break
        current = current.next
    
    return True

Slide 9: Memory Efficient Implementation

Memory management is crucial in linked list implementations. This optimized version uses slots for memory efficiency and implements memory-conscious methods for large-scale data handling scenarios.

class OptimizedNode:
    __slots__ = ['data', 'next']
    
    def __init__(self, data):
        self.data = data
        self.next = None

class MemoryEfficientList:
    __slots__ = ['head', 'size']
    
    def __init__(self):
        self.head = None
        self.size = 0
    
    def memory_usage(self):
        import sys
        current = self.head
        total_bytes = sys.getsizeof(self)
        while current:
            total_bytes += sys.getsizeof(current)
            current = current.next
        return total_bytes

# Example usage and memory comparison
regular_list = [i for i in range(1000)]
efficient_list = MemoryEfficientList()
for i in range(1000):
    efficient_list.insert_front(i)

print(f"Regular List Memory: {sys.getsizeof(regular_list)} bytes")
print(f"Efficient List Memory: {efficient_list.memory_usage()} bytes")

Slide 10: Real-world Application - LRU Cache Implementation

Implementing a Least Recently Used (LRU) cache demonstrates a practical application of linked lists in system design. This implementation combines hash maps with doubly linked lists for O(1) access time.

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

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}
        self.head = LRUNode(0, 0)  # Dummy head
        self.tail = LRUNode(0, 0)  # Dummy tail
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev
    
    def _add(self, node):
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
    
    def get(self, key):
        if key in self.cache:
            node = self.cache[key]
            self._remove(node)
            self._add(node)
            return node.value
        return -1
    
    def put(self, key, value):
        if key in self.cache:
            self._remove(self.cache[key])
        node = LRUNode(key, value)
        self._add(node)
        self.cache[key] = node
        if len(self.cache) > self.capacity:
            lru = self.tail.prev
            self._remove(lru)
            del self.cache[lru.key]

# Usage example
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1))       # returns 1
cache.put(3, 3)          # evicts key 2
print(cache.get(2))       # returns -1 (not found)

Slide 11: Performance Analysis and Big-O Complexity

Understanding the time and space complexity of linked list operations is crucial for efficient implementation. Here we implement a performance testing framework with mathematical analysis of complexity bounds.

def analyze_performance():
    """
    Time Complexity Analysis:
    Access: O(n)
    Search: O(n)
    Insertion: O(1) at head, O(n) at tail
    Deletion: O(1) at head, O(n) at tail
    
    Space Complexity: O(n)
    """
    import time
    import random
    
    def measure_operation(func, size):
        start_time = time.time()
        func(size)
        return time.time() - start_time
    
    def test_insertion(size):
        lst = LinkedList()
        for _ in range(size):
            lst.insert_front(random.randint(1, 1000))
    
    sizes = [1000, 5000, 10000, 50000]
    results = {}
    
    for size in sizes:
        results[size] = measure_operation(test_insertion, size)
        
    return results

# Run analysis
performance_results = analyze_performance()
for size, time_taken in performance_results.items():
    print(f"Size {size}: {time_taken:.4f} seconds")

Slide 12: Real-world Application - Transaction Log System

A transaction log system demonstrates linked lists' practical application in financial systems. This implementation handles transaction recording, rollback capabilities, and maintains temporal ordering of operations.

class Transaction:
    def __init__(self, tx_id, amount, timestamp):
        self.tx_id = tx_id
        self.amount = amount
        self.timestamp = timestamp
        self.next = None
        self.prev = None

class TransactionLog:
    def __init__(self):
        self.head = None
        self.tail = None
        self.tx_count = 0
        self.total_amount = 0
    
    def add_transaction(self, amount):
        import time
        tx_id = f"TX{self.tx_count + 1}"
        tx = Transaction(tx_id, amount, time.time())
        
        if not self.head:
            self.head = self.tail = tx
        else:
            tx.prev = self.tail
            self.tail.next = tx
            self.tail = tx
        
        self.tx_count += 1
        self.total_amount += amount
        return tx_id
    
    def rollback_transaction(self, tx_id):
        current = self.head
        while current:
            if current.tx_id == tx_id:
                self.total_amount -= current.amount
                if current.prev:
                    current.prev.next = current.next
                else:
                    self.head = current.next
                if current.next:
                    current.next.prev = current.prev
                else:
                    self.tail = current.prev
                self.tx_count -= 1
                return True
            current = current.next
        return False

# Example usage
log = TransactionLog()
tx1 = log.add_transaction(100.00)
tx2 = log.add_transaction(50.50)
tx3 = log.add_transaction(-25.25)

print(f"Total transactions: {log.tx_count}")
print(f"Total amount: ${log.total_amount:.2f}")
log.rollback_transaction(tx2)
print(f"After rollback - Total amount: ${log.total_amount:.2f}")

Slide 13: Advanced List Algorithms - Skip List Implementation

Skip lists enhance linked list performance by maintaining multiple layers of connections, providing O(log⁡n)O(\log n)O(logn) average case complexity for search operations, making them competitive with balanced trees.

import random

class SkipNode:
    def __init__(self, value, level):
        self.value = value
        self.forward = [None] * (level + 1)

class SkipList:
    def __init__(self, max_level=16, p=0.5):
        self.max_level = max_level
        self.p = p
        self.level = 0
        self.header = SkipNode(-float('inf'), max_level)
    
    def random_level(self):
        level = 0
        while random.random() < self.p and level < self.max_level:
            level += 1
        return level
    
    def insert(self, value):
        update = [None] * (self.max_level + 1)
        current = self.header
        
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].value < value:
                current = current.forward[i]
            update[i] = current
        
        level = self.random_level()
        if level > self.level:
            for i in range(self.level + 1, level + 1):
                update[i] = self.header
            self.level = level
        
        new_node = SkipNode(value, level)
        for i in range(level + 1):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node
    
    def search(self, value):
        current = self.header
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].value < value:
                current = current.forward[i]
        current = current.forward[0]
        return current and current.value == value

# Performance demonstration
skip_list = SkipList()
values = [3, 6, 9, 2, 1, 7, 8, 4, 5]
for value in values:
    skip_list.insert(value)
print(f"Search for 6: {skip_list.search(6)}")
print(f"Search for 10: {skip_list.search(10)}")

Slide 14: Additional Resources