Data Structures & Algorithms

Complete study notes covering fundamental data structures, algorithms, and problem-solving patterns for tech interviews. All code examples are in Python.


BASIC

Arrays

Static Arrays

# Insert n into arr at the next open position.
# Length is the number of 'real' values in arr, and capacity
# is the size (aka memory allocated for the fixed size array).
def insertEnd(arr, n, length, capacity):
    if length < capacity:
        arr[length] = n

# Remove from the last position in the array if the array
# is not empty (i.e. length is non-zero).
def removeEnd(arr, length):
    if length > 0:
        # Overwrite last element with some default value.
        # We would also the length to decreased by 1.
        arr[length - 1] = 0

# Insert n into index i after shifting elements to the right.
# Assuming i is a valid index and arr is not full.
def insertMiddle(arr, i, n, length):
    # Shift starting from the end to i.
    for index in range(length - 1, i - 1, -1):
        arr[index + 1] = arr[index]
    # Insert at i
    arr[i] = n

# Remove value at index i before shifting elements to the left.
# Assuming i is a valid index.
def removeMiddle(arr, i, length):
    # Shift starting from i + 1 to end.
    for index in range(i + 1, length):
        arr[index - 1] = arr[index]
    # No need to 'remove' arr[i], since we already shifted

def printArr(arr, capacity):
    for i in range(capacity):
        print(arr[i])

Dynamic Arrays

# Python arrays are dynamic by default, but this is an example of resizing.
class Array:
    def __init__(self):
        self.capacity = 2
        self.length = 0
        self.arr = [0] * 2 # Array of capacity = 2

    # Insert n in the last position of the array
    def pushback(self, n):
        if self.length == self.capacity:
            self.resize()
        # insert at next empty position
        self.arr[self.length] = n
        self.length += 1

    def resize(self):
        # Create new array of double capacity
        self.capacity = 2 * self.capacity
        newArr = [0] * self.capacity 
        # Copy elements to newArr
        for i in range(self.length):
            newArr[i] = self.arr[i]
        self.arr = newArr

    # Remove the last element in the array
    def popback(self):
        if self.length > 0:
            self.length -= 1

    # Get value at i-th index
    def get(self, i):
        if i < self.length:
            return self.arr[i]
        # Here we would throw an out of bounds exception

    # Insert n at i-th index
    def insert(self, i, n):
        if i < self.length:
            self.arr[i] = n
            return
        # Here we would throw an out of bounds exception       

    def print(self):
        for i in range(self.length):
            print(self.arr[i])
        print()

Stacks

# Implementing a stack is trivial using a dynamic array
# (which we implemented earlier).
class Stack:
    def __init__(self):
        self.stack = []

    def push(self, n):
        self.stack.append(n)

    def pop(self):
        return self.stack.pop()

Linked Lists

Singly Linked List

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

# Implementation for Singly Linked List
class LinkedList:
    def __init__(self):
        # Init the list with a 'dummy' node which makes 
        # removing a node from the beginning of list easier.
        self.head = ListNode(-1)
        self.tail = self.head
    
    def insertEnd(self, val):
        self.tail.next = ListNode(val)
        self.tail = self.tail.next

    def remove(self, index):
        i = 0
        curr = self.head
        while i < index and curr:
            i += 1
            curr = curr.next
        # Remove the node ahead of curr
        if curr:
            curr.next = curr.next.next

    def print(self):
        curr = self.head.next
        while curr:
            print(curr.val, ' -> ')
            curr = curr.next
        print()

Doubly Linked List

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

# Implementation for Doubly Linked List
class LinkedList:
    def __init__(self):
        # Init the list with 'dummy' head and tail nodes which makes 
        # edge cases for insert & remove easier.
        self.head = ListNode(-1)
        self.tail = ListNode(-1)
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def insertFront(self, val):
        newNode = ListNode(val)
        newNode.prev = self.head
        newNode.next = self.head.next

        self.head.next.prev = newNode
        self.head.next = newNode

    def insertEnd(self, val):
        newNode = ListNode(val)
        newNode.next = self.tail
        newNode.prev = self.tail.prev

        self.tail.prev.next = newNode
        self.tail.prev = newNode

    # Remove first node after dummy head (assume it exists)
    def removeFront(self):
        self.head.next.next.prev = self.head
        self.head.next = self.head.next.next

    # Remove last node before dummy tail (assume it exists)
    def removeEnd(self):
        self.tail.prev.prev.next = self.tail
        self.tail.prev = self.tail.prev.prev

    def print(self):
        curr = self.head.next
        while curr != self.tail:
            print(curr.val, " -> ")
            curr = curr.next
        print()

Queues

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

class Queue:
    # Implementing this with dummy nodes would be easier!
    def __init__(self):
        self.left = self.right = None
    
    def enqueue(self, val):
        newNode = ListNode(val)
        # Queue is non-empty
        if self.right:
            self.right.next = newNode
            self.right = self.right.next
        # Queue is empty
        else:
            self.left = self.right = newNode

    def dequeue(self):
        # Queue is empty
        if not self.left:
            return None
        # Remove left node and return value
        val = self.left.val
        self.left = self.left.next
        return val

    def print(self):
        cur = self.left
        while cur:
            print(cur.val, ' -> ', end ="")
            cur = cur.next
        print() # new line

Linked List Extra

1. Single Linked List

Structure and Basic Operations

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

class SingleLinkedList:
    def __init__(self):
        self.head = None
    
    def insert_at_beginning(self, val):
        new_node = ListNode(val)
        new_node.next = self.head
        self.head = new_node
    
    ## NOTE WHEN YOU GO FOR INSERTION AND DELETION IN LINKED LIST YOU ALWAYS SEE current.next in while loop 


    def insert_at_end(self, val):
        new_node = ListNode(val)
        if not self.head:   // this means , if there is no self.head = NULL or false then the self.head=new_node will be executed 
            self.head = new_node
            return
        current = self.head
        while current.next:    // note to use these types in just traversal where you want to reach the end 
            current = current.next
        current.next = new_node
    
    def delete_node(self, val):
        if not self.head:
            return
        if self.head.val == val:
            self.head = self.head.next
            return
        current = self.head
        while current.next and current.next.val != val:
            current = current.next
        if current.next:
            current.next = current.next.next
    
    ## NOTE WHEN YOU GO FOR SEARCHING  AND DISPLAYING  IN LINKED LIST YOU ALWAYS SEE current in while loop 


    def search(self, val):
        current = self.head
        while current:
            if current.val == val:
                return True
            current = current.next
        return False
    
    def display(self):
        result = []
        current = self.head
        while current:  //   note to use these types in just traversal where you want to print value and traverse  till end
            result.append(current.val)    
            current = current.next
        return result

2. Double 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):
        self.head = None
        self.tail = None
    
    def insert_at_beginning(self, val):
        new_node = DoublyListNode(val)
        if not self.head:
            self.head = self.tail = new_node
        else:
            new_node.next = self.head
            self.head.prev = new_node
            self.head = new_node
    
    def insert_at_end(self, val):
        new_node = DoublyListNode(val)
        if not self.tail:
            self.head = self.tail = new_node
        else:
            self.tail.next = new_node
            new_node.prev = self.tail
            self.tail = new_node
    
    def delete_node(self, val):
        current = self.head
        while current:
            if current.val == val:
                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
                return
            current = current.next
    
    def display_forward(self):
        result = []
        current = self.head
        while current:
            result.append(current.val)
            current = current.next
        return result
    
    def display_backward(self):
        result = []
        current = self.tail
        while current:
            result.append(current.val)
            current = current.prev
        return result

3. Circular Linked List

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

class CircularLinkedList:
    def __init__(self):
        self.head = None
    
    def insert_at_beginning(self, val):
        new_node = CircularListNode(val)
        if not self.head:
            self.head = new_node
            new_node.next = new_node
        else:
            # Find the last node
            current = self.head
            while current.next != self.head:
                current = current.next
            new_node.next = self.head
            current.next = new_node
            self.head = new_node
    
    def insert_at_end(self, val):
        new_node = CircularListNode(val)
        if not self.head:
            self.head = new_node
            new_node.next = new_node
        else:
            current = self.head
            while current.next != self.head:
                current = current.next
            current.next = new_node
            new_node.next = self.head
    
    def delete_node(self, val):
        if not self.head:
            return
        
        # If only one node
        if self.head.next == self.head and self.head.val == val:
            self.head = None
            return
        
        # If head needs to be deleted
        if self.head.val == val:
            current = self.head
            while current.next != self.head:
                current = current.next
            current.next = self.head.next
            self.head = self.head.next
            return
        
        # Delete other nodes
        current = self.head
        while current.next != self.head:
            if current.next.val == val:
                current.next = current.next.next
                return
            current = current.next
    
    def display(self):
        if not self.head:
            return []
        result = []
        current = self.head
        while True:
            result.append(current.val)
            current = current.next
            if current == self.head:
                break
        return result

4. Most Asked LeetCode Questions by Category

Easy Level Questions:

  1. Reverse Linked List (206)
  2. Merge Two Sorted Lists (21)
  3. Remove Duplicates from Sorted List (83)
  4. Linked List Cycle (141)
  5. Palindrome Linked List (234)
  6. Delete Node in a Linked List (237)

Medium Level Questions:

  1. Add Two Numbers (2)
  2. Remove Nth Node From End (19)
  3. Swap Nodes in Pairs (24)
  4. Rotate List (61)
  5. Partition List (86)
  6. Linked List Cycle II (142)

Hard Level Questions:

  1. Merge k Sorted Lists (23)
  2. Reverse Nodes in k-Group (25)

5. Common Patterns and Solutions

Pattern 1: Two Pointers (Fast & Slow)

"if not head " this means it it used to check if the linked list is null that is empty , just to check for the edge case

# Detect cycle in linked list
def has_cycle(head):
    if not head or not head.next:
        return False
    
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            return True
    return False

# Find middle of linked list
def find_middle(head):
    if not head:
        return None
    
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

Pattern 2: Reverse Linked List Variations

# Reverse entire linked list
def reverse_list(head):
    prev = None
    current = head
    
    while current:
        next_temp = current.next
        current.next = prev
        prev = current
        current = next_temp
    
    return prev

# Reverse first n nodes
def reverse_n(head, n):
    if n == 1:
        return head
    
    prev = None
    current = head
    count = 0
    
    while current and count < n:
        next_temp = current.next
        current.next = prev
        prev = current
        current = next_temp
        count += 1
    
    if head:
        head.next = current
    
    return prev

Pattern 3: Merge Operations

# Merge two sorted lists
def merge_two_lists(l1, l2):
    dummy = ListNode(0)
    current = dummy
    
    while l1 and l2:
        if l1.val <= l2.val:
            current.next = l1
            l1 = l1.next
        else:
            current.next = l2
            l2 = l2.next
        current = current.next
    
    current.next = l1 or l2
    return dummy.next

6. Key Problem-Solving Techniques

1. Dummy Node Pattern

  • Use when you might need to modify the head
  • Simplifies edge cases

2. Two Pointers

  • Fast/Slow for cycle detection and finding middle
  • Distance-based for removing nth from end

3. Stack for Palindrome Check

  • Store first half, compare with second half

4. Recursion for Reversal

  • Clean recursive solutions for reversal problems

7. Time & Space Complexities

OperationSingle LLDouble LLCircular LL
Insert BeginningO(1)O(1)O(1)
Insert EndO(n)O(1)O(n)
DeleteO(n)O(n)O(n)
SearchO(n)O(n)O(n)
SpaceO(1)O(1)O(1)

Fibonacci

# Recursive implementation to calculate the n-th Fibonacci number
def fibonacci(n):
    # Base case: n = 0 or 1
    if n <= 1:
        return n
    # Recursive case: fib(n) = fib(n - 1) + fib(n - 2)
    return fibonacci(n - 1) + fibonacci(n - 2)

Sorting

Insertion Sort

# Python implementation of Insertion Sort
def insertionSort(arr):
    # Traverse through 1 to len(arr)
    for i in range(1, len(arr)):
        j = i - 1
        while j >= 0 and arr[j + 1] < arr[j]:
            # arr[j] and arr[j + 1] are out of order so swap them 
            tmp = arr[j + 1]
            arr[j + 1] = arr[j]
            arr[j] = tmp
            j -= 1
    return arr

Merge Sort

# Implementation of MergeSort
def mergeSort(arr, s, e):
    if e - s + 1 <= 1:
        return arr
    # The middle index of the array
    m = (s + e) // 2
    # Sort the left half
    mergeSort(arr, s, m)
    # Sort the right half
    mergeSort(arr, m + 1, e)
    # Merge sorted halfs
    merge(arr, s, m, e)
    return arr

# Merge in-place
def merge(arr, s, m, e):
    # Copy the sorted left & right halfs to temp arrays
    L = arr[s: m + 1]
    R = arr[m + 1: e + 1]
    i = 0 # index for L
    j = 0 # index for R
    k = 0 # index for arr
    # Merge the two sorted halfs into the original array
    while i < len(L) and j < len(R):
        if L[i] <= R[j]:
            arr[k] = L[i]
            i += 1
        else:
            arr[k] = R[j]
            j += 1
        k += 1
    # One of the halfs will have elements remaining
    while i < len(L):
        arr[k] = L[i]
        i += 1
        k += 1
    while j < len(R):
        arr[k] = R[j]
        j += 1
        k += 1

Quick Sort

# Implementation of QuickSort
def quickSort(arr, s, e):
    if e - s + 1 <= 1:
        return
    pivot = arr[e]
    left = s # pointer for left side
    # Partition: elements smaller than pivot on left side
    for i in range(s, e):
        if arr[i] < pivot:
            tmp = arr[left]
            arr[left] = arr[i]
            arr[i] = tmp
            left += 1
    # Move pivot in-between left & right sides
    arr[e] = arr[left]
    arr[left] = pivot
    # Quick sort left side
    quickSort(arr, s, left - 1)
    # Quick sort right side
    quickSort(arr, left + 1, e)
    return arr

Bucket Sort

def bucketSort(arr):
    # Assuming arr only contains 0, 1 or 2
    counts = [0, 0, 0]
    # Count the quantity of each val in arr
    for n in arr:
        counts[n] += 1
    # Fill each bucket in the original array
    i = 0
    for n in range(len(counts)):
        for j in range(counts[n]):
            arr[i] = n
            i += 1
    return arr


ADDITIONAL SORTING ALGORITHMS FROM MERGED FILES


def bubble_sort(arr):
    """
    Bubble Sort - Time Complexity: O(n²)
    Repeatedly steps through the list, compares adjacent elements and swaps them if in wrong order.
    """
    n = len(arr)
    for i in range(n):
        for j in range(0, n-i-1):
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
    return arr

def selection_sort(arr):
    """
    Selection Sort - Time Complexity: O(n²)
    Finds minimum element in unsorted region and swaps with first unsorted element.
    """
    n = len(arr)
    for i in range(n):
        min_idx = i
        for j in range(i+1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr

def merge_sort(arr):
    """
    Merge Sort - Time Complexity: O(n log n)
    Divides array into two halves, recursively sorts them and then merges the sorted halves.
    """
    if len(arr) <= 1:
        return arr

    # Divide array into two halves
    mid = len(arr) // 2
    left = arr[:mid]
    right = arr[mid:]

    # Recursively sort the two halves
    left = merge_sort(left)
    right = merge_sort(right)

    # Merge the sorted halves
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

def quick_sort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[0]
    less = [x for x in arr[1:] if x <= pivot]
    greater = [x for x in arr[1:] if x > pivot]
    return quick_sort(less) + [pivot] + quick_sort(greater)

def bucket_sort(arr):
    counts = [0, 0, 0]  # For values 0-2
    for n in arr:
        counts[n] += 1
    i = 0
    for n in range(len(counts)):
        for _ in range(counts[n]):
            arr[i] = n
            i += 1
    return arr
---

Binary Search

Search Array

arr = [1, 3, 3, 4, 5, 6, 7, 8]

# Python implementation of Binary Search
def binarySearch(arr, target):
    L, R = 0, len(arr) - 1
    while L <= R:
        mid = (L + R) // 2
        if target > arr[mid]:
            L = mid + 1
        elif target < arr[mid]:
            R = mid - 1
        else:
            return mid
    return -1

Trees

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

def search(root, target):
    if not root:        # this means if root is null or none return False
        return False
    if target > root.val:
        return search(root.right, target)
    elif target < root.val:
        return search(root.left, target)
    else:
        return True

BST Insert and Remove

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

# Insert a new node and return the root of the BST.
def insert(root, val):
    if not root:
        return TreeNode(val)
    if val > root.val:
        root.right = insert(root.right, val)
    elif val < root.val:
        root.left = insert(root.left, val)
    return root

# Return the minimum value node of the BST.
def minValueNode(root):
    curr = root
    while curr and curr.left:
        curr = curr.left
    return curr

# Remove a node and return the root of the BST.
def remove(root, val):
    if not root:
        return None
    if val > root.val:
        root.right = remove(root.right, val)
    elif val < root.val:
        root.left = remove(root.left, val)
    else:
        if not root.left:
            return root.right
        elif not root.right:
            return root.left
        else:
            minNode = minValueNode(root.right)
            root.val = minNode.val
            root.right = remove(root.right, minNode.val)
    return root
class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

def inorder(root):
    if not root:
        return    
    inorder(root.left)
    print(root.val)
    inorder(root.right)

def preorder(root):
    if not root:
        return    
    print(root.val)
    preorder(root.left)
    preorder(root.right)

def postorder(root):
    if not root:
        return    
    postorder(root.left)
    postorder(root.right)
    print(root.val)
from collections import deque

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

def bfs(root):
    queue = deque()
    if root:
        queue.append(root)
    level = 0
    while len(queue) > 0:
        print("level: ", level)
        for i in range(len(queue)):
            curr = queue.popleft()
            print(curr.val)
            if curr.left:
                queue.append(curr.left)
            if curr.right:
                queue.append(curr.right)
        level += 1


Understanding Trees in Data Structures

I'll explain the key concepts covered in the Trees section of the NeetCode course, with Python code examples for each topic.

1. Binary Search Trees (BST)

A Binary Search Tree is a tree data structure where each node has at most two children, and for any node:

  • All values in the left subtree are less than the node's value
  • All values in the right subtree are greater than the node's value

This property makes searching efficient with O(log n) time complexity in balanced trees.

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

def search(root, target):
    if not root:
        return False
    
    if target > root.val:
        return search(root.right, target)
    elif target < root.val:
        return search(root.left, target)
    else:
        return True

2. BST Insert and Remove Operations

Insert

To insert a value in a BST, we recursively traverse the tree to find the appropriate position:

def insert(root, val):
    if not root:
        return TreeNode(val)
    
    if val > root.val:
        root.right = insert(root.right, val)
    elif val < root.val:
        root.left = insert(root.left, val)
    return root

Remove

Removing a node is more complex, especially when the node has two children:

def minValueNode(root):
    curr = root
    while curr and curr.left:
        curr = curr.left
    return curr

def remove(root, val):
    if not root:
        return None
    
    if val > root.val:
        root.right = remove(root.right, val)
    elif val < root.val:
        root.left = remove(root.left, val)
    else:
        # Case 1: Leaf node or node with one child
        if not root.left:
            return root.right
        elif not root.right:
            return root.left
        
        # Case 2: Node with two children
        # Find inorder successor (smallest in right subtree)
        minNode = minValueNode(root.right)
        root.val = minNode.val
        # Delete the inorder successor
        root.right = remove(root.right, minNode.val)
    return root

3. Depth-First Search (DFS)

DFS explores as far as possible along each branch before backtracking. There are three main types of DFS traversals:

Inorder Traversal (Left → Root → Right)

def inorder(root):
    if not root:
        return    
    inorder(root.left)
    print(root.val)  # Process the node
    inorder(root.right)

Preorder Traversal (Root → Left → Right)

def preorder(root):
    if not root:
        return    
    print(root.val)  # Process the node
    preorder(root.left)
    preorder(root.right)

Postorder Traversal (Left → Right → Root)

def postorder(root):
    if not root:
        return    
    postorder(root.left)
    postorder(root.right)
    print(root.val)  # Process the node

4. Breadth-First Search (BFS)

BFS explores all nodes at the present depth before moving to nodes at the next depth level. It uses a queue to keep track of nodes to visit:

from collections import deque

def bfs(root):
    queue = deque()
    
    if root:
        queue.append(root)
    
    level = 0
    while queue:
        print(f"level: {level}")
        # Process all nodes at current level
        for i in range(len(queue)):
            curr = queue.popleft()
            print(curr.val)
            
            # Add children to queue for next level
            if curr.left:
                queue.append(curr.left)
            if curr.right:
                queue.append(curr.right)
        level += 1

Key Applications and Problems

  1. BST Operations:

    • Search in a Binary Search Tree
    • Finding the Lowest Common Ancestor in a BST
  2. BST Modifications:

    • Insert into a Binary Search Tree
    • Delete Node in a BST
  3. Tree Traversals:

    • Binary Tree Inorder Traversal
    • Finding the Kth Smallest Element in a BST
    • Constructing a Binary Tree from Preorder and Inorder Traversal
  4. Level Order Traversal:

    • Binary Tree Level Order Traversal
    • Binary Tree Right Side View

Time and Space Complexity

  • BST Operations (search, insert, delete):

    • Time: O(h) where h is the height of the tree (O(log n) for balanced trees, O(n) worst case)
    • Space: O(h) for recursion stack
  • DFS Traversals:

    • Time: O(n) where n is the number of nodes
    • Space: O(h) for recursion stack
  • BFS Traversal:

    • Time: O(n)
    • Space: O(w) where w is the maximum width of the tree

Understanding these tree operations and traversal methods is fundamental for solving many complex problems in computer science and forms the basis for more advanced tree-based data structures.

Backtracking

Tree Maze

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

def canReachLeaf(root):
    if not root or root.val == 0:
        return False
    if not root.left and not root.right:
        return True
    if canReachLeaf(root.left):
        return True
    if canReachLeaf(root.right):
        return True
    return False

def leafPath(root, path):
    if not root or root.val == 0:
        return False
    path.append(root.val)
    if not root.left and not root.right:
        return True
    if leafPath(root.left, path):
        return True
    if leafPath(root.right, path):
        return True
    path.pop()
    return False

Heap / Priority Queue

Push and Pop

# Min Heap
class Heap:
    def __init__(self):
        self.heap = [0]

    def push(self, val):
        self.heap.append(val)
        i = len(self.heap) - 1
        # Percolate up
        while i > 1 and self.heap[i] < self.heap[i // 2]:
            tmp = self.heap[i]
            self.heap[i] = self.heap[i // 2]
            self.heap[i // 2] = tmp
            i = i // 2

    def pop(self):
        if len(self.heap) == 1:
            return None
        if len(self.heap) == 2:
            return self.heap.pop()
        res = self.heap[1]   
        # Move last value to root
        self.heap[1] = self.heap.pop()
        i = 1
        # Percolate down
        while 2 * i < len(self.heap):
            if (2 * i + 1 < len(self.heap) and 
            self.heap[2 * i + 1] < self.heap[2 * i] and 
            self.heap[i] > self.heap[2 * i + 1]):
                # Swap right child
                tmp = self.heap[i]
                self.heap[i] = self.heap[2 * i + 1]
                self.heap[2 * i + 1] = tmp
                i = 2 * i + 1
            elif self.heap[i] > self.heap[2 * i]:
                # Swap left child
                tmp = self.heap[i]
                self.heap[i] = self.heap[2 * i]
                self.heap[2 * i] = tmp
                i = 2 * i
            else:
                break
        return res

    def top(self):
        if len(self.heap) > 1:
            return self.heap[1]
        return None

    def heapify(self, arr):
        # 0-th position is moved to the end
        arr.append(arr[0])
        self.heap = arr
        cur = (len(self.heap) - 1) // 2
        while cur > 0:
            # Percolate down
            i = cur
            while 2 * i < len(self.heap):
                if (2 * i + 1 < len(self.heap) and 
                self.heap[2 * i + 1] < self.heap[2 * i] and 
                self.heap[i] > self.heap[2 * i + 1]):
                    # Swap right child
                    tmp = self.heap[i]
                    self.heap[i] = self.heap[2 * i + 1]
                    self.heap[2 * i + 1] = tmp
                    i = 2 * i + 1
                elif self.heap[i] > self.heap[2 * i]:
                    # Swap left child
                    tmp = self.heap[i]
                    self.heap[i] = self.heap[2 * i]
                    self.heap[2 * i] = tmp
                    i = 2 * i
                else:
                    break
            cur -= 1

Heapify

import heapq

# Min Heap
class Heap:
    # ... not showing push, pop to save space.
    
    def heapify(self, arr):
        # 0-th position is moved to the end
        arr.append(arr[0])
        self.heap = arr
        cur = (len(self.heap) - 1) // 2
        while cur > 0:
            # Percolate down
            i = cur
            while 2 * i < len(self.heap):
                if (2 * i + 1 < len(self.heap) and 
                self.heap[2 * i + 1] < self.heap[2 * i] and 
                self.heap[i] > self.heap[2 * i + 1]):
                    # Swap right child
                    tmp = self.heap[i]
                    self.heap[i] = self.heap[2 * i + 1]
                    self.heap[2 * i + 1] = tmp
                    i = 2 * i + 1
                elif self.heap[i] > self.heap[2 * i]:
                    # Swap left child
                    tmp = self.heap[i]
                    self.heap[i] = self.heap[2 * i]
                    self.heap[2 * i] = tmp
                    i = 2 * i
                else:
                    break
            cur -= 1

Hashing

Hash Usage

names = ["alice", "brad", "collin", "brad", "dylan", "kim"]

countMap = {}
for name in names:
    # If countMap does not contain name
    if name not in countMap:
        countMap[name] = 1
    else:
        countMap[name] += 1

Hash Implementation

class Pair:
    def __init__(self, key, val):
        self.key = key
        self.val = val

class HashMap:
    def __init__(self):
        self.size = 0
        self.capacity = 2
        self.map = [None, None]
    
    def hash(self, key):
        index = 0
        for c in key:
            index += ord(c)
        return index % self.capacity

    def get(self, key):
        index = self.hash(key)
        while self.map[index] != None:
            if self.map[index].key == key:
                return self.map[index].val
            index += 1
            index = index % self.capacity
        return None

    def put(self, key, val):
        index = self.hash(key)
        while True:
            if self.map[index] == None:
                self.map[index] = Pair(key, val)
                self.size += 1
                if self.size >= self.capacity // 2:
                    self.rehash()
                return
            elif self.map[index].key == key:
                self.map[index].val = val
                return
            index += 1
            index = index % self.capacity
    
    def remove(self, key):
        if not self.get(key):
            return
        index = self.hash(key)
        while True:
            if self.map[index].key == key:
                self.map[index] = None
                self.size -= 1
                return
            index += 1
            index = index % self.capacity

    def rehash(self):
        self.capacity = 2 * self.capacity
        newMap = []
        for i in range(self.capacity):
            newMap.append(None)
        oldMap = self.map
        self.map = newMap
        self.size = 0
        for pair in oldMap:
            if pair:
                self.put(pair.key, pair.val)
    
    def print(self):
        for pair in self.map:
            if pair:
                print(pair.key, pair.val)

Graphs

Matrix DFS

# Matrix (2D Grid)
grid = [[0, 0, 0, 0],
        [1, 1, 0, 0],
        [0, 0, 0, 1],
        [0, 1, 0, 0]]
## This is used to find the number of unique paths 
# Count paths (backtracking)
def dfs(grid, r, c, visit):
    ROWS, COLS = len(grid), len(grid[0])
    if (min(r, c) < 0 or
        r == ROWS or c == COLS or
        (r, c) in visit or grid[r][c] == 1):
        return 0
    if r == ROWS - 1 and c == COLS - 1:
        return 1
    visit.add((r, c))
    count = 0
    count += dfs(grid, r + 1, c, visit)
    count += dfs(grid, r - 1, c, visit)
    count += dfs(grid, r, c + 1, visit)
    count += dfs(grid, r, c - 1, visit)
    visit.remove((r, c))
    return count

Matrix BFS

from collections import deque

# Matrix (2D Grid)
grid = [[0, 0, 0, 0],
        [1, 1, 0, 0],
        [0, 0, 0, 1],
        [0, 1, 0, 0]]

# Shortest path from top left to bottom right
def bfs(grid):
    ROWS, COLS = len(grid), len(grid[0])
    visit = set()
    queue = deque()
    queue.append((0, 0))
    visit.add((0, 0))
    length = 0
    while queue:
        for i in range(len(queue)):
            r, c = queue.popleft()
            if r == ROWS - 1 and c == COLS - 1:
                return length
            neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]]
            for dr, dc in neighbors:
                if (min(r + dr, c + dc) < 0 or
                    r + dr == ROWS or c + dc == COLS or
                    (r + dr, c + dc) in visit or grid[r + dr][c + dc] == 1):
                    continue
                queue.append((r + dr, c + dc))
                visit.add((r + dr, c + dc))
        length += 1

Adjacency List

from collections import deque

# GraphNode used for adjacency list
class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []

# Or use a HashMap
adjList = { "A": [], "B": [] }

# Given directed edges, build an adjacency list
edges = [["A", "B"], ["B", "C"], ["B", "E"], ["C", "E"], ["E", "D"]]

adjList = {}

for src, dst in edges:
    if src not in adjList:
        adjList[src] = []
    if dst not in adjList:
        adjList[dst] = []
    adjList[src].append(dst)

# Count paths (backtracking)
def dfs(node, target, adjList, visit):
    if node in visit:
        return 0
    if node == target:
        return 1
    count = 0
    visit.add(node)
    for neighbor in adjList[node]:
        count += dfs(neighbor, target, adjList, visit)
    visit.remove(node)
    return count

# Shortest path from node to target
def bfs(node, target, adjList):
    length = 0
    visit = set()
    visit.add(node)
    queue = deque()
    queue.append(node)
    while queue:
        for i in range(len(queue)):
            curr = queue.popleft()
            if curr == target:
                return length
            for neighbor in adjList[curr]:
                if neighbor not in visit:
                    visit.add(neighbor)
                    queue.append(neighbor)
        length += 1
    return length

Dynamic Programming

1D DP

# Brute Force
def bruteForce(n):
    if n <= 1:
        return n
    return bruteForce(n - 1) + bruteForce(n - 2)

# Memoization
def memoization(n, cache):
    if n <= 1:
        return n
    if n in cache:
        return cache[n]
    cache[n] = memoization(n - 1) + memoization(n - 2)
    return cache[n]

# Dynamic Programming
def dp(n):
    if n < 2:
        return n
    dp = [0, 1]
    i = 2
    while i <= n:
        tmp = dp[1]
        dp[1] = dp[0] + dp[1]
        dp[0] = tmp
        i += 1
    return dp[1]

2D DP

# Brute Force - Time: O(2 ^ (n + m)), Space: O(n + m)
def bruteForce(r, c, rows, cols):
    if r == rows or c == cols:
        return 0
    if r == rows - 1 and c == cols - 1:
        return 1
    return (bruteForce(r + 1, c, rows, cols) +  
            bruteForce(r, c + 1, rows, cols))

print(bruteForce(0, 0, 4, 4))

# Memoization - Time and Space: O(n * m)
def memoization(r, c, rows, cols, cache):
    if r == rows or c == cols:
        return 0
    if cache[r][c] > 0:
        return cache[r][c]
    if r == rows - 1 and c == cols - 1:
        return 1
    cache[r][c] = (memoization(r + 1, c, rows, cols, cache) +  
        memoization(r, c + 1, rows, cols, cache))
    return cache[r][c]

print(memoization(0, 0, 4, 4, [[0] * 4 for i in range(4)]))

# Dynamic Programming - Time: O(n * m), Space: O(m), where m is num of cols
def dp(rows, cols):
    prevRow = [0] * cols
    for r in range(rows - 1, -1, -1):
        curRow = [0] * cols
        curRow[cols - 1] = 1
        for c in range(cols - 2, -1, -1):
            curRow[c] = curRow[c + 1] + prevRow[c]
        prevRow = curRow
    return prevRow[0] 

Bit Manipulation

Bit Operator

# AND
n = 1 & 1

# OR
n = 1 | 0

# XOR  ## 0 is same else different 
n = 0 ^ 1

# NOT (negation)
n = ~n

# Bit shifting
n = 1
n = n << 1  # left shift , *2 (multiply by 2 ), 
n = n >> 1  #right shift , /2 (divide by 2 )

# Counting Bits
def countBits(n):
    count = 0
    while n > 0:
        if n & 1 == 1:
            count += 1
        n = n >> 1 # same as n // 2
    return count


ADVANCE


1. Arrays - 0. Kadane's Algo (and Variants)

# Brute Force: O(n^2)
def bruteForce(nums):
    maxSum = nums[0]

    for i in range(len(nums)):
        curSum = 0
        for j in range(i, len(nums)):
            curSum += nums[j]
            maxSum = max(maxSum, curSum)
    return maxSum

# Kadane's Algorithm: O(n)
def kadanes(nums):
    maxSum = nums[0]
    curSum = 0

    for n in nums:
        curSum = max(curSum, 0)
        curSum += n
        maxSum = max(maxSum, curSum)
    return maxSum

# Return the left and right index of the max subarray sum,
# assuming there's exactly one result (no ties).
# Sliding window variation of Kadane's: O(n)
def slidingWindow(nums):
    maxSum = nums[0]
    curSum = 0
    maxL, maxR = 0, 0
    L = 0

    for R in range(len(nums)):
        if curSum < 0:
            curSum = 0
            L = R

        curSum += nums[R]
        if curSum > maxSum:
            maxSum = curSum
            maxL, maxR = L, R 

    return [maxL, maxR]

1. Arrays - 1. Sliding Window Fixed

# Check if array contains a pair of duplicate values,
# where the two duplicates are no farther than k positions from 
# eachother (i.e. arr[i] == arr[j] and abs(i - j) + 1 <= k).
# O(n * k)
def closeDuplicatesBruteForce(nums, k):
    for L in range(len(nums)):
        for R in range(L + 1, min(len(nums), L + k)):
            if nums[L] == nums[R]:
                return True
    return False

# Same problem using sliding window.
# O(n)
def closeDuplicates(nums, k):
    window = set() # Cur window of size <= k
    L = 0

    for R in range(len(nums)):
        if R - L + 1 > k:
            window.remove(nums[L])
            L += 1
        if nums[R] in window:
            return True
        window.add(nums[R])

    return False

1. Arrays - 2. Sliding Window Variable

# Find the length of longest subarray with the same 
# value in each position: O(n)
def longestSubarray(nums):
    length = 0
    L = 0
    
    for R in range(len(nums)):
        if nums[L] != nums[R]:
            L = R 
        length = max(length, R - L + 1)
    return length

# Find length of the minimum size subarray where the sum is 
# greater than or equal to the target.
# Assume all values in the input are positive.
# O(n)
def shortestSubarray(nums, target):
    L, total = 0, 0
    length = float("inf")
    
    for R in range(len(nums)):
        total += nums[R]
        while total >= target:
            length = min(R - L + 1, length)
            total -= nums[L]
            L += 1
    return 0 if length == float("inf") else length

1. Arrays - 3. Two Pointers

# Given a string of characters, return true if it's a palindrome,
# return false otherwise: O(n)
def isPalindrome(word):
    L, R = 0, len(word) - 1
    while L < R:
        if word[L] != word[R]:
            return False
        L += 1
        R -= 1
    return True

# Given a sorted array of integers, return the indices
# of two elements (in different positions) that sum up to
# the target value. Assume there is exactly one solution.
# O(n)
def targetSum(nums, target):
    L, R = 0, len(nums) - 1
    while L < R:
        if nums[L] + nums[R] > target:
            R -= 1
        elif nums[L] + nums[R] < target:
            L += 1
        else:
            return [L, R]

1. Arrays - 4. Prefix Sums

class PrefixSum:

    def __init__(self, nums):
        self.prefix = []
        total = 0
        for n in nums:
            total += n
            self.prefix.append(total)
        
    def rangeSum(self, left, right):
        preRight = self.prefix[right]
        preLeft = self.prefix[left - 1] if left > 0 else 0
        return (preRight - preLeft)

2. Linked Lists - 1. Fast and Slow Pointers

# Find the middle of a linked list with two pointers.
# Time: O(n), Space: O(1)
def middleOfList(head):
    slow, fast = head, head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow

# Determine if the linked list contains a cycle.
# Time: O(n), Space: O(1)
def hasCycle(head):
    slow, fast = head, head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            return True
    return False

# Determine if the linked list contains a cycle and
# return the beginning of the cycle, otherwise return null.
# Time: O(n), Space: O(1)
def cycleStart(head):
    slow, fast = head, head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            break

    if not fast or not fast.next:
        return None
    
    slow2 = head
    while slow != slow2:
        slow = slow.next
        slow2 = slow2.next
    return slow

3. Tries - 1. Trie

USED IN PLACE OF HASHMAP WHERE THE INSERTETION, SEARCH AND FIND PREFIX IS O(1)

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

class Trie:
    def __init__(self):
        self.root = TrieNode()
    
    def insert(self, word):
        curr = self.root
        for c in word:
            if c not in curr.children:
                curr.children[c] = TrieNode()
            curr = curr.children[c]
        curr.word = True

    def search(self, word):
        curr = self.root
        for c in word:
            if c not in curr.children:
                return False
            curr = curr.children[c]
        return curr.word

    def startsWith(self, prefix):
        curr = self.root
        for c in prefix:
            if c not in curr.children:
                return False
            curr = curr.children[c]
        return True

3. Tries - 2. Union Find

To find:Disjoint set,connected components and cycle detection

class UnionFind:
    def __init__(self, n):
        self.par = {}
        self.rank = {}

        for i in range(1, n + 1):
            self.par[i] = i
            self.rank[i] = 0
    
    # Find parent of n, with path compression.    , used to find the parent 
    def find(self, n):
        p = self.par[n]
        while p != self.par[p]:
            self.par[p] = self.par[self.par[p]]
            p = self.par[p]
        return p

    # Union by height / rank.
    # Return false if already connected, true otherwise.
    def union(self, n1, n2):
        p1, p2 = self.find(n1), self.find(n2)
        if p1 == p2:
            return False
        
        if self.rank[p1] > self.rank[p2]:
            self.par[p2] = p1
        elif self.rank[p1] < self.rank[p2]:
            self.par[p1] = p2
        else:
            self.par[p1] = p2
            self.rank[p2] += 1
        return True

ARRAY TYPE UNION FIND

class UnionFind:
    def __init__(self, n):
        # parent[i] = parent of node i
        self.par = [i for i in range(n + 1)]
        # rank[i] = rank (approx. tree height) of node i
        self.rank = [0] * (n + 1)

    # Find with path compression
    def find(self, n):
        if self.par[n] != n:
            self.par[n] = self.find(self.par[n])  # path compression
        return self.par[n]

    # Union by rank
    def union(self, n1, n2):
        p1, p2 = self.find(n1), self.find(n2)
        if p1 == p2:
            return False  # already connected

        if self.rank[p1] > self.rank[p2]:
            self.par[p2] = p1
        elif self.rank[p1] < self.rank[p2]:
            self.par[p1] = p2
        else:
            self.par[p1] = p2
            self.rank[p2] += 1

        return True

3. Tries - 3. Segment Tree

class SegmentTree:
    def __init__(self, total, L, R):
        self.sum = total
        self.left = None
        self.right = None
        self.L = L
        self.R = R
        
    # O(n)
    @staticmethod
    def build(nums, L, R):
        if L == R:
            return SegmentTree(nums[L], L, R)

        M = (L + R) // 2
        root = SegmentTree(0, L, R)
        root.left = SegmentTree.build(nums, L, M)
        root.right = SegmentTree.build(nums, M + 1, R)
        root.sum = root.left.sum + root.right.sum
        return root

    # O(logn)
    def update(self, index, val):
        if self.L == self.R:
            self.sum = val
            return
        
        M = (self.L + self.R) // 2
        if index > M:
            self.right.update(index, val)
        else:
            self.left.update(index, val)
        self.sum = self.left.sum + self.right.sum
        
    # O(logn)
    def rangeQuery(self, L, R):
        if L == self.L and R == self.R:
            return self.sum
        
        M = (self.L + self.R) // 2
        if L > M:
            return self.right.rangeQuery(L, R)
        elif R <= M:
            return self.left.rangeQuery(L, R)
        else:
            return (self.left.rangeQuery(L, M) +
                    self.right.rangeQuery(M + 1, R))

3. Tries - 4. Iterative DFS

# Definition for a binary tree node.
class TreeNode:
    def __init__(self, val, left, right):
        self.val = val
        self.left = left
        self.right = right

# Time and space: O(n)
def inorder(root):
    stack = []
    curr = root

    while curr or stack:
        if curr:
            stack.append(curr)
            curr = curr.left
        else:
            curr = stack.pop()
            print(curr.val)
            curr = curr.right

# Time and space: O(n)
def preorder(root):
    stack = []
    curr = root
    while curr or stack:
        if curr:
            print(curr.val)
            if curr.right:
                stack.append(curr.right)
            curr = curr.left
        else:
            curr = stack.pop()

# Time and space: O(n)
def postorder(root):
    stack = [root]
    visit = [False]
    while stack:
        curr, visited = stack.pop(), visit.pop()
        if curr:
            if visited:
                print(curr.val)
            else:
                stack.append(curr)
                visit.append(True)
                stack.append(curr.right)
                visit.append(False)
                stack.append(curr.left)
                visit.append(False)

4. Heaps - 1. Heaps

import heapq

class Median:
    def __init__(self):
        self.small, self.large = [], []

    def insert(self, num):
        # Push to the max heap and swap with min heap if needed.
        heapq.heappush(self.small, -1 * num)
        if (self.small and self.large and (-1 * self.small[0]) > self.large[0]):
            val = -1 * heapq.heappop(self.small)
            heapq.heappush(self.large, val)
        
        # Handle uneven size
        if len(self.small) > len(self.large) + 1:
            val = -1 * heapq.heappop(self.small)
            heapq.heappush(self.large, val)
        if len(self.large) > len(self.small) + 1:
            val = heapq.heappop(self.large)
            heapq.heappush(self.small, -1 * val)
        
    def getMedian(self):
        if len(self.small) > len(self.large):
            return -1 * self.small[0]
        elif len(self.large) > len(self.small):
            return self.large[0]
        
        # Even # of elements, return avg of two middle nums
        return (-1 * self.small[0] + self.large[0]) / 2

5. Backtracking - 1. Subsets

# Time: O(n * 2^n), Space: O(n)
def subsetsWithoutDuplicates(nums):
    subsets, curSet = [], []
    helper(0, nums, curSet, subsets)
    return subsets

def helper(i, nums, curSet, subsets):
    if i >= len(nums):
        subsets.append(curSet.copy())
        return
    
    # decision to include nums[i]
    curSet.append(nums[i])
    helper(i + 1, nums, curSet, subsets)
    curSet.pop()

    # decision NOT to include nums[i]
    helper(i + 1, nums, curSet, subsets)

# Time: O(n * 2^n), Space: O(n)
def subsetsWithDuplicates(nums):
    nums.sort()
    subsets, curSet = [], []
    helper2(0, nums, curSet, subsets)
    return subsets

def helper2(i, nums, curSet, subsets):
    if i >= len(nums):
        subsets.append(curSet.copy())
        return
    
    # decision to include nums[i]
    curSet.append(nums[i])
    helper2(i + 1, nums, curSet, subsets)
    curSet.pop()

    # decision NOT to include nums[i]
    while i + 1 < len(nums) and nums[i] == nums[i + 1]:
        i += 1
    helper2(i + 1, nums, curSet, subsets)

5. Backtracking - 2. Combinations

# Given n numbers (1 - n), return all possible combinations
# of size k. (n choose k math problem).
# Time: O(k * 2^n)
def combinations(n, k):
    combs = []
    helper(1, [], combs, n, k)
    return combs

def helper(i, curComb, combs, n, k):
    if len(curComb) == k:
        combs.append(curComb.copy())
        return
    if i > n:
        return
    
    # decision to include i
    curComb.append(i)
    helper(i + 1, curComb, combs, n, k)
    curComb.pop()
    
    # decision to NOT include i
    helper(i + 1, curComb, combs, n, k)

# Time: O(k * C(n, k))
def combinations2(n, k):
    combs = []
    helper2(1, [], combs, n, k)
    return combs

def helper2(i, curComb, combs, n, k):
    if len(curComb) == k:
        combs.append(curComb.copy())
        return
    if i > n:
        return
    
    for j in range(i, n + 1):
        curComb.append(j)
        helper2(j + 1, curComb, combs, n, k)
        curComb.pop()

5. Backtracking - 3. Permutation

# Time: O(n^2 * n!)
def permutationsRecursive(nums):
    return helper(0, nums)
        
def helper(i, nums):   
    if i == len(nums):
        return [[]]
    
    resPerms = []
    perms = helper(i + 1, nums)
    for p in perms:
        for j in range(len(p) + 1):
            pCopy = p.copy()
            pCopy.insert(j, nums[i])
            resPerms.append(pCopy)
    return resPerms

# Time: O(n^2 * n!)
def permutationsIterative(nums):
    perms = [[]]

    for n in nums:
        nextPerms = []
        for p in perms:
            for i in range(len(p) + 1):
                pCopy = p.copy()
                pCopy.insert(i, n)
                nextPerms.append(pCopy)
        perms = nextPerms
    return perms

6. Graphs - 1. Dijkstra

import heapq

# Given a connected graph represented by a list of edges, where
# edge[0] = src, edge[1] = dst, and edge[2] = weight,
# find the shortest path from src to every other node in the 
# graph. There are n nodes in the graph.
# O(E * logV), O(E * logE) is also correct.
def shortestPath(edges, n, src):
    adj = {}
    for i in range(1, n + 1):
        adj[i] = []
        
    # s = src, d = dst, w = weight
    for s, d, w in edges:
        adj[s].append([d, w])

    shortest = {}
    minHeap = [[0, src]]
    while minHeap:
        w1, n1 = heapq.heappop(minHeap)
        if n1 in shortest:
            continue
        shortest[n1] = w1

        for n2, w2 in adj[n1]:
            if n2 not in shortest:
                heapq.heappush(minHeap, [w1 + w2, n2])
    return shortest

6. Graphs - 2. Prim's

import heapq

# Given a list of edges of a connected undirected graph,
# with nodes numbered from 1 to n,
# return a list edges making up the minimum spanning tree.
def minimumSpanningTree(edges, n):
    adj = {}
    for i in range(1, n + 1):
        adj[i] = []
    for n1, n2, weight in edges:
        adj[n1].append([n2, weight])
        adj[n2].append([n1, weight])

    # Initialize the heap by choosing a single node
    # (in this case 1) and pushing all its neighbors.
    minHeap = []
    for neighbor, weight in adj[1]:
        heapq.heappush(minHeap, [weight, 1, neighbor])

    mst = []
    visit = set()
    visit.add(1)
    while len(visit) < n:
        weight, n1, n2 = heapq.heappop(minHeap)
        if n2 in visit:
            continue

        mst.append([n1, n2])
        visit.add(n2)
        for neighbor, weight in adj[n2]:
            if neighbor not in visit:
                heapq.heappush(minHeap, [weight, n2, neighbor])
    return mst

6. Graphs - 3. Kruskal's

import heapq 

class UnionFind:
    def __init__(self, n):
        self.par = {}
        self.rank = {}

        for i in range(1, n + 1):
            self.par[i] = i
            self.rank[i] = 0
    
    # Find parent of n, with path compression.
    def find(self, n):
        p = self.par[n]
        while p != self.par[p]:
            self.par[p] = self.par[self.par[p]]
            p = self.par[p]
        return p

    # Union by height / rank.
    # Return false if already connected, true otherwise.
    def union(self, n1, n2):
        p1, p2 = self.find(n1), self.find(n2)
        if p1 == p2:
            return False
        
        if self.rank[p1] > self.rank[p2]:
            self.par[p2] = p1
        elif self.rank[p1] < self.rank[p2]:
            self.par[p1] = p2
        else:
            self.par[p1] = p2
            self.rank[p2] += 1
        return True

# Given an list of edges of a connected undirected graph,
# with nodes numbered from 1 to n,
# return a list edges making up the minimum spanning tree.
def minimumSpanningTree(edges, n):
    minHeap = []
    for n1, n2, weight in edges:
        heapq.heappush(minHeap, [weight, n1, n2])

    unionFind = UnionFind(n)
    mst = []
    while len(mst) < n - 1:
        weight, n1, n2 = heapq.heappop(minHeap)
        if not unionFind.union(n1, n2):
            continue
        mst.append([n1, n2])
    return mst

6. Graphs - 4. Topological Sort

# Given a directed acyclical graph, return a valid
# topological ordering of the graph. 
def topologicalSort(edges, n):
    adj = {}
    for i in range(1, n + 1):
        adj[i] = []
    for src, dst in edges:
        adj[src].append(dst)

    topSort = []
    visit = set()
    for i in range(1, n + 1):
        dfs(i, adj, visit, topSort)
    topSort.reverse()
    return topSort

def dfs(src, adj, visit, topSort):
    if src in visit:
        return
    visit.add(src)
    
    for neighbor in adj[src]:
        dfs(neighbor, adj, visit, topSort)
    topSort.append(src)

7. Dynamic Programming - 1. 0-1 Knapsack

# Brute force Solution
# Time: O(2^n), Space: O(n)
def dfs(profit, weight, capacity):
    return dfsHelper(0, profit, weight, capacity)

def dfsHelper(i, profit, weight, capacity):
    if i == len(profit):
        return 0

    # Skip item i
    maxProfit = dfsHelper(i + 1, profit, weight, capacity)

    # Include item i
    newCap = capacity - weight[i]
    if newCap >= 0:
        p = profit[i] + dfsHelper(i + 1, profit, weight, newCap)
        maxProfit = max(maxProfit, p)

    return maxProfit

# Memoization Solution
# Time: O(n * m), Space: O(n * m)
def memoization(profit, weight, capacity):
    N, M = len(profit), capacity
    cache = [[-1] * (M + 1) for _ in range(N)]
    return memoHelper(0, profit, weight, capacity, cache)

def memoHelper(i, profit, weight, capacity, cache):
    if i == len(profit):
        return 0
    if cache[i][capacity] != -1:
        return cache[i][capacity]

    cache[i][capacity] = memoHelper(i + 1, profit, weight, capacity, cache)
    
    newCap = capacity - weight[i]
    if newCap >= 0:
        p = profit[i] + memoHelper(i + 1, profit, weight, newCap, cache)
        cache[i][capacity] = max(cache[i][capacity], p)

    return cache[i][capacity]

# Dynamic Programming Solution
# Time: O(n * m), Space: O(n * m)
def dp(profit, weight, capacity):
    N, M = len(profit), capacity
    dp = [[0] * (M + 1) for _ in range(N)]

    for i in range(N):
        dp[i][0] = 0
    for c in range(M + 1):
        if weight[0] <= c:
            dp[0][c] = profit[0] 

    for i in range(1, N):
        for c in range(1, M + 1):
            skip = dp[i-1][c]
            include = 0
            if c - weight[i] >= 0:
                include = profit[i] + dp[i-1][c - weight[i]]
            dp[i][c] = max(include, skip)
    return dp[N-1][M]

# Memory optimized Dynamic Programming Solution
# Time: O(n * m), Space: O(m)
def optimizedDp(profit, weight, capacity):
    N, M = len(profit), capacity
    dp = [0] * (M + 1)

    for c in range(M + 1):
        if weight[0] <= c:
            dp[c] = profit[0] 

    for i in range(1, N):
        curRow = [0] * (M + 1)
        for c in range(1, M + 1):
            skip = dp[c]
            include = 0
            if c - weight[i] >= 0:
                include = profit[i] + dp[c - weight[i]]
            curRow[c] = max(include, skip)
        dp = curRow
    return dp[M]

7. Dynamic Programming - 2. Unbounded Knapsack

# Brute force Solution
# Time: O(2^m), Space: O(m)
def dfs(profit, weight, capacity):
    return dfsHelper(0, profit, weight, capacity)

def dfsHelper(i, profit, weight, capacity):
    if i == len(profit):
        return 0

    maxProfit = dfsHelper(i + 1, profit, weight, capacity)

    newCap = capacity - weight[i]
    if newCap >= 0:
        p = profit[i] + dfsHelper(i, profit, weight, newCap)
        maxProfit = max(maxProfit, p)

    return maxProfit

# Memoization Solution
# Time: O(n * m), Space: O(n * m)
def memoization(profit, weight, capacity):
    N, M = len(profit), capacity
    cache = [[-1] * (M + 1) for _ in range(N)]
    return memoHelper(0, profit, weight, capacity, cache)

def memoHelper(i, profit, weight, capacity, cache):
    if i == len(profit):
        return 0
    if cache[i][capacity] != -1:
        return cache[i][capacity]

    cache[i][capacity] = memoHelper(i + 1, profit, weight, capacity, cache)
    
    newCap = capacity - weight[i]
    if newCap >= 0:
        p = profit[i] + memoHelper(i, profit, weight, newCap, cache)
        cache[i][capacity] = max(cache[i][capacity], p)

    return cache[i][capacity]

# Dynamic Programming Solution
# Time: O(n * m), Space: O(n * m)
def dp(profit, weight, capacity):
    N, M = len(profit), capacity
    dp = [[0] * (M + 1) for _ in range(N)]

    for i in range(N):
        dp[i][0] = 0
    for c in range(M + 1):
        if weight[0] <= c:
            dp[0][c] = (c // weight[0]) * profit[0] 

    for i in range(1, N):
        for c in range(1, M + 1):
            skip = dp[i-1][c]
            include = 0
            if c - weight[i] >= 0:
                include = profit[i] + dp[i][c - weight[i]]
            dp[i][c] = max(include, skip)
    return dp[N-1][M]

# Memory optimized Dynamic Programming Solution
# Time: O(n * m), Space: O(m)
def optimizedDp(profit, weight, capacity):
    N, M = len(profit), capacity
    dp = [0] * (M + 1)

    for i in range(N):
        curRow = [0] * (M + 1)
        for c in range(1, M + 1):
            skip = dp[c]
            include = 0
            if c - weight[i] >= 0:
                include = profit[i] + curRow[c - weight[i]]
            curRow[c] = max(include, skip)
        dp = curRow
    return dp[M]

7. Dynamic Programming - 3. LCS (Longest Common Subsequence)

# Time: O(2^(n + m)), Space: O(n + m)
def dfs(s1, s2):
    return dfsHelper(s1, s2, 0, 0)

def dfsHelper(s1, s2, i1, i2):
    if i1 == len(s1) or i2 == len(s2):
        return 0
    
    if s1[i1] == s2[i2]:
        return 1 + dfsHelper(s1, s2, i1 + 1, i2 + 1)
    else:
        return max(dfsHelper(s1, s2, i1 + 1, i2),
                dfsHelper(s1, s2, i1, i2 + 1))

# Time: O(n * m), Space: O(n + m)
def memoization(s1, s2):
    N, M = len(s1), len(s2)
    cache = [[-1] * M for _ in range(N)]
    return memoHelper(s1, s2, 0, 0, cache)

def memoHelper(s1, s2, i1, i2, cache):
    if i1 == len(s1) or i2 == len(s2):
        return 0
    if cache[i1][i2] != -1:
        return cache[i1][i2]

    if s1[i1] == s2[i2]:
        cache[i1][i2] = 1 + memoHelper(s1, s2, i1 + 1, i2 + 1, cache)
    else:
        cache[i1][i2] = max(memoHelper(s1, s2, i1 + 1, i2, cache),
                memoHelper(s1, s2, i1, i2 + 1, cache))
    return cache[i1][i2]

# Time: O(n * m), Space: O(n + m)
def dp(s1, s2):
    N, M = len(s1), len(s2)
    dp = [[0] * (M+1) for _ in range(N+1)]

    for i in range(N):
        for j in range(M):
            if s1[i] == s2[j]:
                dp[i+1][j+1] = 1 + dp[i][j]
            else:
                dp[i+1][j+1] = max(dp[i][j+1], dp[i+1][j])
    return dp[N][M]

# Time: O(n * m), Space: O(m)
def optimizedDp(s1, s2):
    N, M = len(s1), len(s2)
    dp = [0] * (M + 1)

    for i in range(N):
        curRow = [0] * (M + 1)
        for j in range(M):
            if s1[i] == s2[j]:
                curRow[j+1] = 1 + dp[j]
            else:
                curRow[j+1] = max(dp[j + 1], curRow[j])
        dp = curRow
    return dp[M]

7. Dynamic Programming - 4. Palindromes

# Time: O(n^2), Space: O(n)
def longest(s):
    length = 0
    
    for i in range(len(s)):
        # odd length
        l, r = i, i
        while l >= 0 and r < len(s) and s[l] == s[r]:
            if (r - l + 1) > length:
                length = r - l + 1
            l -= 1
            r += 1
        
        # even length
        l, r = i, i + 1
        while l >= 0 and r < len(s) and s[l] == s[r]:
            if (r - l + 1) > length:
                length = r - l + 1
            l -= 1
            r += 1
            
    return length

# Same solution, without duplicate code.
# Time: O(n^2), Space: O(n)
def longest(s):
    length = 0
    for i in range(len(s)):
        # odd length
        length = max(length, helper(s, i, i))
        
        # even length
        length = max(length, helper(s, i, i + 1)) 
    return length

def helper(s, l, r):
    maxLength = 0
    while l >= 0 and r < len(s) and s[l] == s[r]:
        if (r - l + 1) > maxLength:
            maxLength = r - l + 1
        l -= 1
        r += 1
    return maxLength

Advanced Topics

Advanced algorithms and data structures including trees, graphs, and optimization techniques.

extra algo might need to learn

trees: AVL red black B tree(B+ and B-)

Graphs travel salesmen problem


BackTracking Extra


Full Permutations (all n numbers)

# Time: O(n! * n) - n! permutations, each taking O(n) to copy
def permutations(n):
    perms = []
    helper([], perms, n)
    return perms

def helper(curPerm, perms, n):
    if len(curPerm) == n:
        perms.append(curPerm.copy())
        return
    
    for j in range(1, n + 1):
        if j not in curPerm:  # Check if number is already used
            curPerm.append(j)
            helper(curPerm, perms, n)
            curPerm.pop()

k-Permutations (k numbers from 1 to n)

# Time: O(P(n,k) * k) where P(n,k) = n!/(n-k)!
def permutations_k(n, k):
    perms = []
    helper_k([], perms, n, k)
    return perms

def helper_k(curPerm, perms, n, k):
    if len(curPerm) == k:
        perms.append(curPerm.copy())
        return
        
    for j in range(1, n + 1):
        if j not in curPerm:  # Check if number is already used
            curPerm.append(j)
            helper_k(curPerm, perms, n, k)
            curPerm.pop()

Extra approch from yt


from typing import List

def combine(n: int, k: int) -> List[List[int]]:
    """
    Generate all combinations (order does NOT matter) of k numbers from [1..n]
    using the binary decision tree approach: at each index, either pick or skip.

    Time: O(C(n, k)) combinations; recursion overhead proportional to n
    Space: O(k) for current solution + O(n) recursion
    """
    res = []
    sol = []
    nums = list(range(1, n + 1))

    def backtrack(i: int) -> None:
        # If we have k elements, record one combination
        if len(sol) == k:
            res.append(sol[:])
            return

        # If we ran out of items, stop
        if i == n:
            return

        # Option 1: don't pick nums[i]
        backtrack(i + 1)

        # Option 2: pick nums[i]
        sol.append(nums[i])
        backtrack(i + 1)
        sol.pop()

    backtrack(0)
    return res


def permute(n: int, k: int) -> List[List[int]]:
    """
    Generate all permutations (order DOES matter) of length k from [1..n]
    using a binary-tree-like approach. The twist: when we "pick", we reset
    the index to 0 to allow picking any remaining element again (in any order).

    Time: O(P(n, k)) where P(n, k) = n! / (n-k)!
    Space: O(k) for current solution + recursion
    """
    res = []
    sol = []
    nums = list(range(1, n + 1))

    def backtrack(i: int) -> None:
        # If we have k elements, record one permutation
        if len(sol) == k:
            res.append(sol[:])
            return

        # If we exhausted indices without reaching k, stop
        if i == n:
            return

        # Option 1: don't pick nums[i] at this depth, move on
        backtrack(i + 1)

        # Option 2: pick nums[i] (if not already used),
        # then reset i to 0 to allow picking any element next
        if nums[i] not in sol:
            sol.append(nums[i])
            backtrack(0)   # reset to allow any index next
            sol.pop()

    backtrack(0)
    return res


def combine_with_loop(n: int, k: int) -> List[List[int]]:
    """
    Generate combinations using the for-loop style:
    - At each level, iterate candidates starting from 'start'
    - Ensures increasing indices -> avoids duplicates automatically

    Time: O(C(n, k))
    Space: O(k) + recursion
    """
    res = []
    sol = []
    nums = list(range(1, n + 1))

    def backtrack(start: int) -> None:
        if len(sol) == k:
            res.append(sol[:])
            return

        # Iterate indices from 'start' to end, ensuring combinations are unique
        for i in range(start, n):
            sol.append(nums[i])
            backtrack(i + 1)  # next choices must start after i
            sol.pop()

    backtrack(0)
    return res


def permute_with_loop(n: int, k: int) -> List[List[int]]:
    """
    Generate permutations using the for-loop style:
    - At each level, iterate through all nums
    - Use 'sol' membership to avoid reusing the same number in the current path
    - Order matters, so we always start from 0 at each depth

    Time: O(P(n, k))
    Space: O(k) + recursion
    """
    res = []
    sol = []
    nums = list(range(1, n + 1))

    def backtrack() -> None:
        if len(sol) == k:
            res.append(sol[:])
            return

        for i in range(n):
            if nums[i] in sol:
                continue
            sol.append(nums[i])
            backtrack()
            sol.pop()

    backtrack()
    return res

Linked List Extra


Trees

🌲 Binary Search Tree (BST) – Basics

In BST:

  • Left subtree has values < node
  • Right subtree has values > node

1. BST Node Class

Same as before:

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

2. Insert in BST

def insert(root, key):
    if not root:
        return Node(key)
    if key < root.val:
        root.left = insert(root.left, key)
    else:
        root.right = insert(root.right, key)
    return root

3. Search in BST

def search(root, key):
    if not root or root.val == key:
        return root
    if key < root.val:
        return search(root.left, key)
    else:
        return search(root.right, key)

4. Delete in BST

def delete(root, key):
    if not root:
        return None
    if key < root.val:
        root.left = delete(root.left, key)
    elif key > root.val:
        root.right = delete(root.right, key)
    else:
        # Found node
        if not root.left:
            return root.right
        elif not root.right:
            return root.left
        # Get inorder successor
        temp = find_min(root.right)
        root.val = temp.val
        root.right = delete(root.right, temp.val)
    return root

def find_min(root):
    while root.left:
        root = root.left
    return root

🧠 LeetCode Tree Problem Patterns

These are core problem types you’ll frequently face on LeetCode.


1. Recursive DFS Traversal (Inorder/Preorder/Postorder)

Use when: You need to visit all nodes or do some operation top-down or bottom-up.

def inorder(root):         # Left → Root → Right
    if not root: return
    inorder(root.left)
    print(root.val)
    inorder(root.right)

2. Iterative BFS / Level Order

Use when: You need shortest path or level-wise operations.

from collections import deque

def level_order(root):
    if not root: return
    q = deque([root])
    while q:
        node = q.popleft()
        print(node.val)
        if node.left: q.append(node.left)
        if node.right: q.append(node.right)

3. Max Depth of Tree

def maxDepth(root):
    if not root:
        return 0
    return 1 + max(maxDepth(root.left), maxDepth(root.right))

4. Check if Tree is Balanced

def isBalanced(root):
    def dfs(node):
        if not node:
            return 0
        left = dfs(node.left)
        right = dfs(node.right)
        if left == -1 or right == -1 or abs(left - right) > 1:
            return -1
        return 1 + max(left, right)
    
    return dfs(root) != -1

5. Lowest Common Ancestor (LCA) in BST

def lowestCommonAncestor(root, p, q):
    if p.val < root.val and q.val < root.val:
        return lowestCommonAncestor(root.left, p, q)
    elif p.val > root.val and q.val > root.val:
        return lowestCommonAncestor(root.right, p, q)
    else:
        return root

6. Check if Tree is Symmetric

def isSymmetric(root):
    def isMirror(t1, t2):
        if not t1 and not t2: return True
        if not t1 or not t2: return False
        return (t1.val == t2.val and 
                isMirror(t1.left, t2.right) and 
                isMirror(t1.right, t2.left))
    
    return isMirror(root, root)

7. Diameter of Binary Tree

Max path length between any two nodes.

def diameterOfBinaryTree(root):
    diameter = 0

    def dfs(node):
        nonlocal diameter
        if not node: return 0
        left = dfs(node.left)
        right = dfs(node.right)
        diameter = max(diameter, left + right)
        return 1 + max(left, right)
    
    dfs(root)
    return diameter

Graphs


0-1 knapsack

def knapsack_dfs(weights, values, capacity):
    n = len(weights)
    def dfs(index, current_weight, current_value):
        # Base case: no more items or capacity exceeded
        if index == n or current_weight > capacity:
            return 0 if current_weight > capacity else current_value
        
        # Decision 1: Skip current item
        skip = dfs(index + 1, current_weight, current_value)
        
        # Decision 2: Include current item (if it fits)
        take = 0
        if current_weight + weights[index] <= capacity:
            take = dfs(index + 1, current_weight + weights[index], current_value + values[index])
        
        return max(skip, take)
    
    return dfs(0, 0, 0)

# Example usage
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7

print(knapsack_dfs(weights, values, capacity))  # Output: 9

Unbounded knapsack

def unbounded_knapsack_dfs(weights, values, capacity):
    n = len(weights)
    def dfs(current_weight, current_value):
        max_value = current_value
        for i in range(n):
            if current_weight + weights[i] <= capacity:
                # Try taking item i again (unbounded)
                max_value = max(max_value, dfs(current_weight + weights[i], current_value + values[i]))
        return max_value

    return dfs(0, 0)

# Example usage
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7

print(unbounded_knapsack_dfs(weights, values, capacity))  # Output: 10


1. Cycle Detection in an Undirected Graph (DFS with Parent Tracking)

def has_cycle_undirected(graph):
    visited = set()

    def dfs(node, parent):
        # Mark the current node as visited
        visited.add(node)

        # Traverse all neighbors of the current node
        for neighbor in graph[node]:
            if neighbor not in visited:
                # Recurse for unvisited neighbor
                if dfs(neighbor, node):
                    return True
            elif neighbor != parent:
                # If the neighbor is visited and is not the parent,
                # then a cycle exists (back edge)
                return True

        return False

    # Check every connected component
    for node in graph:
        if node not in visited:
            if dfs(node, -1):  # Start DFS with parent as -1
                return True

    return False  # No cycles found

🧪 Example usage for undirected graph:

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

print(has_cycle_undirected(graph))  # Output: True

2. Cycle Detection in a Directed Graph (DFS with Recursion Stack)

def has_cycle_directed(graph):
    visited = set()      # Tracks all visited nodes
    rec_stack = set()    # Tracks nodes in the current DFS path

    def dfs(node):
        visited.add(node)
        rec_stack.add(node)

        for neighbor in graph[node]:
            if neighbor not in visited:
                if dfs(neighbor):  # Recurse
                    return True
            elif neighbor in rec_stack:
                # A back edge found, meaning a cycle exists
                return True

        # Backtrack: remove node from recursion stack
        rec_stack.remove(node)
        return False

    # Check every node (for disconnected components)
    for node in graph:
        if node not in visited:
            if dfs(node):
                return True

    return False  # No cycles found

🧪 Example usage for directed graph:

graph = {
    0: [1],
    1: [2],
    2: [3],
    3: [1]  # Cycle here: 1 → 2 → 3 → 1
}

print(has_cycle_directed(graph))  # Output: True

Python extras general


list.sort()
sorted(list)

both are same but written in diferent way 

list.index(<what index you want >)

Python Sorting with key Parameter and Lambda Functions

The key function is called once for each element and returns a value used for comparison:

# Basic syntax
sorted(iterable, key=function)
list.sort(key=function)

# The key function transforms each element for comparison
# Original: [element1, element2, element3]
# After key: [key(element1), key(key2), key(element3)]
# Then sorted based on these key values

Lambda Functions with Sorting

Basic Lambda Sorting

# Sort by absolute value
numbers = [-5, 2, -1, 8, -3]
sorted_abs = sorted(numbers, key=lambda x: abs(x))
print(sorted_abs)  # Output: [-1, 2, -3, -5, 8]

# Sort strings by length
words = ["python", "java", "c", "javascript"]
sorted_length = sorted(words, key=lambda x: len(x))
print(sorted_length)  # Output: ['c', 'java', 'python', 'javascript']

# Sort by last character
sorted_last = sorted(words, key=lambda x: x[-1])
print(sorted_last)  # Output: ['java', 'c', 'python', 'javascript']

Most used Python library

from functools import cache
from collections import defaultdict , Counter 
from math import random 

and others



🔑 1. collections module

One of the most important for DSA.

Counter

  • Counts frequency of elements quickly.
from collections import Counter

arr = [1,2,2,3,3,3]
cnt = Counter(arr)   # {3:3, 2:2, 1:1}
cnt.most_common(1)   # [(3,3)] → most frequent element

defaultdict

  • Dictionary with a default value type.
  • No KeyError on missing keys.

Types you’ll often use:

from collections import defaultdict

graph = defaultdict(list)   # adjacency list
graph[1].append(2)
graph[2].append(3)

freq = defaultdict(int)     # counts
freq["a"] += 1

visited = defaultdict(bool) # visited flags

deque

  • Double-ended queue. O(1) pops from left.
  • Great for BFS, sliding window problems.
from collections import deque

q = deque([1,2,3])
q.append(4)    # right
q.appendleft(0) # left
q.popleft()    # O(1), unlike list pop(0)

🔑 2. itertools module

permutations

from itertools import permutations
list(permutations([1,2,3], 2)) 
# [(1,2),(1,3),(2,1),(2,3),(3,1),(3,2)]

combinations

from itertools import combinations
list(combinations([1,2,3], 2)) 
# [(1,2),(1,3),(2,3)]

product (Cartesian product)

from itertools import product
list(product([1,2], [3,4]))  
# [(1,3),(1,4),(2,3),(2,4)]

accumulate (prefix sums)

from itertools import accumulate
list(accumulate([1,2,3,4]))  
# [1,3,6,10]

🔑 3. heapq module

Min-heaps (priority queues).

import heapq

arr = [5,1,8,3]
heapq.heapify(arr)  # arr becomes min-heap
heapq.heappop(arr)  # pops smallest
heapq.heappush(arr, 0)  # push new element

For max-heap, push negative values:

heap = []
heapq.heappush(heap, -5)

🔑 4. bisect module

Binary search helpers.

import bisect

arr = [1,3,4,7]
bisect.bisect_left(arr, 4)   # 2 (first index of 4)
bisect.bisect_right(arr, 4)  # 3 (after last 4)

Great for binary search problems.


🔑 5. functools module

lru_cache / cache

Memoization for recursion.

from functools import cache

@cache
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)

🔑 6. math module

Useful math functions.

import math

math.gcd(24, 36)   # 12
math.lcm(4, 6)     # 12
math.factorial(5)  # 120
math.comb(5,2)     # 10 (nCr)

🔑 7. random (sometimes for testing)

import random
random.choice([1,2,3])  # pick random

🔑 8. Built-in functions often used in DSA

  • sum(iterable) → prefix/sum calculations
  • sorted(iterable, key=...) → sorting with custom rule
  • enumerate(iterable) → index + value
  • zip(a,b) → combine lists
  • map, filter, any, all

Most common combos on LeetCode

  • Graphdefaultdict(list), deque
  • Freq / CountingCounter, defaultdict(int)
  • Heap problemsheapq
  • Subsets / Permutationsitertools
  • Binary Searchbisect
  • DP recursionfunctools.cache


1. i != len(s)

  • You use this when i is an index and you want to stop before going out of bounds.
  • Since the last valid index is len(s) - 1, we must stop when i == len(s).

✅ Example: iterating through a string/array

i = 0
while i != len(s):   # same as while i < len(s)
    print(s[i])      # safe, because i is always < len(s)
    i += 1

2. i != len(s) - 1

  • You use this when your code looks ahead one step (s[i+1]).
  • If i == len(s) - 1, then s[i+1] would be out of bounds ❌.
  • So you stop one step earlier.

✅ Example: checking pairs in a string

for i in range(len(s)):
    if i != len(s) - 1 and s[i] == s[i+1]:  # safe because i+1 won’t overflow
        print("pair:", s[i], s[i+1])

3. i != len(s) - 2

  • You use this when your code looks ahead two steps (s[i+2]).
  • If i == len(s) - 2 or more, then s[i+2] would overflow.
  • So you stop two steps earlier.

✅ Example: checking triplets in a string

for i in range(len(s)):
    if i != len(s) - 2 and s[i] == s[i+2]:  # safe for i+2
        print("triplet pattern")

4. i <= len(s)

  • Careful! Usually this is ❌ unless you’re using dummy index math.

  • Because the last valid index is len(s) - 1.

  • But sometimes it’s used when:

    • You’re counting characters (not indexing),
    • Or using dp arrays with size len(s) + 1 (like in DP problems for substrings).

✅ Example: DP on string (extra row/col for empty prefix)

dp = [[0] * (len(s)+1) for _ in range(len(t)+1)]
for i in range(len(s)+1):   # safe, we made dp size +1
    ...

5. i <= len(s) - 1

  • This is the same as i < len(s) or i != len(s).
  • You use it in normal loops when you want to include the last element.

✅ Example:

i = 0
while i <= len(s) - 1:   # same as while i < len(s)
    print(s[i])
    i += 1

🔑 Key Rule to Remember:

  • Normal traversal → i < len(s) (or i != len(s))
  • When you look ahead +k → stop at len(s) - k
  • When using DP with extra row/col → use <= len(s)


graphs notion page

https://gossamer-meter-104.notion.site/Graph-Cheatsheet-By-Sid-1a08d36d3d77806bbb0be158a67a907e

Cycle Directed and undirected in graphs :+1:


🚀 1. Cycle Detection in Undirected Graph

👉 In an undirected graph, a cycle exists if you can return to a vertex without repeating edges, except the first and last vertex being the same.

There are two classic approaches:


✅ Method 1: DFS with Parent Tracking

We check if a node is visited again during DFS and it is not the parent (to avoid false cycles due to the bidirectional edge).

from collections import defaultdict

class Graph:
    def __init__(self, V):
        self.V = V
        self.graph = defaultdict(list)

    def add_edge(self, u, v):
        self.graph[u].append(v)
        self.graph[v].append(u)  # undirected

    def is_cyclic_util(self, v, visited, parent):
        visited[v] = True
        for neighbor in self.graph[v]:
            if not visited[neighbor]:
                if self.is_cyclic_util(neighbor, visited, v):
                    return True
            elif neighbor != parent:
                return True
        return False

    def is_cyclic(self):
        visited = [False] * self.V
        for i in range(self.V):
            if not visited[i]:
                if self.is_cyclic_util(i, visited, -1):
                    return True
        return False

# Example
g = Graph(5)
g.add_edge(0, 1)
g.add_edge(1, 2)
g.add_edge(2, 0)  # cycle
print("Undirected Graph Cycle:", g.is_cyclic())  # True

✅ Method 2: Union-Find (Disjoint Set Union, DSU)

We try to join edges; if two vertices already belong to the same set, a cycle exists.

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(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):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # cycle
        self.parent[px] = py
        return True

def is_cyclic_undirected(edges, n):
    uf = UnionFind(n)
    for u, v in edges:
        if not uf.union(u, v):
            return True
    return False

# Example
edges = [(0, 1), (1, 2), (2, 0)]  # cycle
print("Undirected Graph Cycle:", is_cyclic_undirected(edges, 3))  # True

🚀 2. Cycle Detection in Directed Graph

👉 In directed graphs, a cycle exists if a vertex is visited again in the same DFS path (recursion stack). Simply revisiting a visited node is not enough (that could be a different path).


✅ Method 1: DFS + Recursion Stack

We maintain:

  • visited[v]: if a node has been visited at all
  • recStack[v]: if a node is in the current DFS path
from collections import defaultdict

class DirectedGraph:
    def __init__(self, V):
        self.V = V
        self.graph = defaultdict(list)

    def add_edge(self, u, v):
        self.graph[u].append(v)

    def is_cyclic_util(self, v, visited, recStack):
        visited[v] = True
        recStack[v] = True

        for neighbor in self.graph[v]:
            if not visited[neighbor]:
                if self.is_cyclic_util(neighbor, visited, recStack):
                    return True
            elif recStack[neighbor]:
                return True  # back edge → cycle

        recStack[v] = False
        return False

    def is_cyclic(self):
        visited = [False] * self.V
        recStack = [False] * self.V
        for node in range(self.V):
            if not visited[node]:
                if self.is_cyclic_util(node, visited, recStack):
                    return True
        return False

# Example
g = DirectedGraph(4)
g.add_edge(0, 1)
g.add_edge(1, 2)
g.add_edge(2, 0)  # cycle
print("Directed Graph Cycle:", g.is_cyclic())  # True

✅ Method 2: Kahn’s Algorithm (Topological Sort)

If a graph has a cycle, we cannot get a valid topological ordering (some nodes will always have non-zero in-degree).

from collections import deque, defaultdict

def is_cyclic_directed_kahn(V, edges):
    graph = defaultdict(list)
    indegree = [0] * V

    for u, v in edges:
        graph[u].append(v)
        indegree[v] += 1

    q = deque([i for i in range(V) if indegree[i] == 0])
    count = 0

    while q:
        node = q.popleft()
        count += 1
        for neighbor in graph[node]:
            indegree[neighbor] -= 1
            if indegree[neighbor] == 0:
                q.append(neighbor)

    return count != V  # if not all nodes processed → cycle

# Example
edges = [(0,1), (1,2), (2,0)]
print("Directed Graph Cycle (Kahn):", is_cyclic_directed_kahn(3, edges))  # True

🔑 Summary of Methods

Graph TypeMethodComplexity
UndirectedDFS with parent trackingO(V+E)
Union-Find (DSU)O(E α(V))
DirectedDFS with recursion stackO(V+E)
Kahn’s Algorithm (Topo sort)O(V+E)

👉 Variations covered:

  • Undirected: DFS, Union-Find
  • Directed: DFS (recStack), BFS (Kahn’s Topological sort)

✅ Method 1: DFS-based Topological Sort

We push nodes onto a stack after exploring all their neighbors (post-order).

from collections import defaultdict

class Graph:
    def __init__(self, V):
        self.V = V
        self.graph = defaultdict(list)

    def add_edge(self, u, v):
        self.graph[u].append(v)

    def topo_sort_dfs_util(self, v, visited, stack):
        visited[v] = True
        for neighbor in self.graph[v]:
            if not visited[neighbor]:
                self.topo_sort_dfs_util(neighbor, visited, stack)
        stack.append(v)  # push after visiting all children

    def topo_sort_dfs(self):
        visited = [False] * self.V
        stack = []
        for i in range(self.V):
            if not visited[i]:
                self.topo_sort_dfs_util(i, visited, stack)
        return stack[::-1]  # reverse gives correct order

# Example
g = Graph(6)
g.add_edge(5, 2)
g.add_edge(5, 0)
g.add_edge(4, 0)
g.add_edge(4, 1)
g.add_edge(2, 3)
g.add_edge(3, 1)

print("Topological Sort (DFS):", g.topo_sort_dfs())
# Possible Output: [5, 4, 2, 3, 1, 0]

✅ Method 2: Kahn’s Algorithm (BFS)

We use in-degree (number of incoming edges):

  1. Put all nodes with indegree = 0 into a queue.
  2. Repeatedly pop from queue, add to order, and decrease indegree of neighbors.
  3. If some nodes remain unprocessed → cycle exists.
from collections import defaultdict, deque

def topo_sort_kahn(V, edges):
    graph = defaultdict(list)
    indegree = [0] * V

    # Build graph + indegree
    for u, v in edges:
        graph[u].append(v)
        indegree[v] += 1

    # Start with all 0-indegree nodes
    q = deque([i for i in range(V) if indegree[i] == 0])
    topo_order = []

    while q:
        node = q.popleft()
        topo_order.append(node)
        for neighbor in graph[node]:
            indegree[neighbor] -= 1
            if indegree[neighbor] == 0:
                q.append(neighbor)

    if len(topo_order) != V:
        return "Cycle detected → no topo sort"
    return topo_order

# Example
edges = [(5,2), (5,0), (4,0), (4,1), (2,3), (3,1)]
print("Topological Sort (Kahn):", topo_sort_kahn(6, edges))
# Possible Output: [4, 5, 2, 3, 1, 0]

🔑 Key Points

  • DFS method → uses recursion & stack (post-order).
  • Kahn’s Algorithm → uses in-degree & BFS queue.
  • If graph has a cycle → no valid topo order exists.

Extra graph algo :+1:

1. Floyd–Warshall Algorithm

Idea:

  • It’s a Dynamic Programming algorithm.
  • Used to find the shortest path between all pairs of vertices in a weighted graph.
  • Works with negative edges (but not negative cycles).
  • Time complexity: O(V³), where V = number of vertices.

How it works:

  • Start with a dist matrix = adjacency matrix of the graph.

  • Iteratively try to improve distances by checking if going through an intermediate vertex k gives a shorter path.

  • Formula:

    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
    

Example:

Graph:

    0 → 1 (weight 3)
    0 → 2 (weight INF)
    1 → 2 (weight 1)
    2 → 0 (weight 2)

Initial matrix (INF = no edge):

   0   3   INF
   INF 0   1
   2   INF 0

After applying Floyd-Warshall:

   0   3   4
   3   0   1
   2   5   0

So shortest path between every pair is found.

Python Code:

def floyd_warshall(graph):
    V = len(graph)
    dist = [row[:] for row in graph]  # copy
    
    for k in range(V):
        for i in range(V):
            for j in range(V):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
    
    return dist


INF = float('inf')
graph = [
    [0,   3,   INF],
    [INF, 0,   1],
    [2,   INF, 0]
]

result = floyd_warshall(graph)
for row in result:
    print(row)

2. Bellman–Ford Algorithm

Idea:

  • Used to find shortest path from a single source to all vertices.
  • Works with negative edge weights and also detects negative cycles.
  • Time complexity: O(V × E), where V = vertices, E = edges.

How it works:

  1. Initialize distance array with dist[src] = 0, others = ∞.

  2. Relax all edges V-1 times: For each edge (u, v, w):

    if dist[u] + w < dist[v]:
        dist[v] = dist[u] + w
    
  3. Run one more iteration to check for negative cycles. If distances still improve → cycle exists.

Example:

Graph edges:

0 → 1 (4)
0 → 2 (5)
1 → 2 (-3)
2 → 3 (4)
3 → 1 (6)
  • Start from src = 0.
  • After Bellman-Ford:
dist = [0, 4, 1, 5]

Python Code:

def bellman_ford(edges, V, src):
    dist = [float('inf')] * V
    dist[src] = 0

    # Relax edges V-1 times
    for _ in range(V - 1):
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w

    # Check for negative cycles
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            print("Graph contains negative weight cycle")
            return None

    return dist


# Graph: (u, v, w)
edges = [
    (0, 1, 4),
    (0, 2, 5),
    (1, 2, -3),
    (2, 3, 4),
    (3, 1, 6)
]

print(bellman_ford(edges, 4, 0))

Key difference:

  • Floyd–Warshall → all-pairs shortest paths, dense graphs, O(V³).
  • Bellman–Ford → single-source shortest path, handles negative weights, O(V×E).

🔹 Dijkstra’s Algorithm

  • Goal: Shortest path from a single source → all vertices.

  • Works with: Only non-negative edge weights.

  • Time Complexity:

    • With simple array: O(V²)
    • With min-heap/priority queue: O((V + E) log V)
  • Approach: Greedy (always picks the nearest unvisited node).


🔹 Bellman–Ford Algorithm

  • Goal: Shortest path from a single source → all vertices.
  • Works with: Negative edge weights and can detect negative cycles.
  • Time Complexity: O(V × E) → slower than Dijkstra.
  • Approach: Dynamic Programming (relaxes edges repeatedly).

🔹 Floyd–Warshall Algorithm

  • Goal: Shortest paths between all pairs of vertices.
  • Works with: Negative edges, but no negative cycles.
  • Time Complexity: O(V³).
  • Approach: Dynamic Programming (updates adjacency matrix with intermediate vertices).

📊 Comparison Table

AlgorithmSingle-source or All-pairs?Handles Negative Weights?Detects Negative Cycles?ComplexityTypical Use
DijkstraSingle-source❌ No (only non-negative)❌ NoO((V+E)logV)Road networks, routing
Bellman–FordSingle-source✅ Yes✅ YesO(V×E)Finance (arbitrage), graphs with negatives
Floyd–WarshallAll-pairs✅ Yes❌ No (but breaks if present)O(V³)Dense graphs, APSP (all pairs shortest path)

🧠 How They’re Related

  1. All solve shortest path problems but under different constraints.

    • Dijkstra = fast but limited (no negatives).
    • Bellman–Ford = slower, but more general.
    • Floyd–Warshall = solves the most general case (all pairs).
  2. Bellman–Ford vs Dijkstra:

    • Both → single source shortest path.
    • Dijkstra is faster but limited to non-negative edges.
    • Bellman–Ford is slower but works with negatives.
  3. Floyd–Warshall vs Bellman–Ford:

    • Bellman–Ford runs from one source.
    • To get all-pairs shortest paths with Bellman–Ford, you’d need to run it V times (once from each source). That would be O(V² × E) → worse than Floyd–Warshall’s O(V³) for dense graphs.

👉 In practice:

  • If graph is small/dense and need all-pairs shortest pathsFloyd–Warshall.
  • If graph has negative edges but no negative cyclesBellman–Ford.
  • If graph has no negative edges and is large/sparseDijkstra.

🌳 Tree Algorithms

1. AVL Tree

  • A self-balancing Binary Search Tree (BST).
  • Maintains balance factor = height(left) – height(right) ∈ {–1, 0, 1}.
  • Ensures O(log N) insertion, deletion, and search.
  • Uses rotations (LL, RR, LR, RL) to stay balanced.

Python Implementation:

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 1

class AVLTree:
    def insert(self, root, key):
        if not root:
            return Node(key)
        elif key < root.key:
            root.left = self.insert(root.left, key)
        else:
            root.right = self.insert(root.right, key)

        root.height = 1 + max(self.getHeight(root.left),
                              self.getHeight(root.right))
        
        balance = self.getBalance(root)

        # Left Left
        if balance > 1 and key < root.left.key:
            return self.rightRotate(root)

        # Right Right
        if balance < -1 and key > root.right.key:
            return self.leftRotate(root)

        # Left Right
        if balance > 1 and key > root.left.key:
            root.left = self.leftRotate(root.left)
            return self.rightRotate(root)

        # Right Left
        if balance < -1 and key < root.right.key:
            root.right = self.rightRotate(root.right)
            return self.leftRotate(root)

        return root

    def leftRotate(self, z):
        y = z.right
        T2 = y.left
        y.left = z
        z.right = T2
        z.height = 1 + max(self.getHeight(z.left), self.getHeight(z.right))
        y.height = 1 + max(self.getHeight(y.left), self.getHeight(y.right))
        return y

    def rightRotate(self, z):
        y = z.left
        T3 = y.right
        y.right = z
        z.left = T3
        z.height = 1 + max(self.getHeight(z.left), self.getHeight(z.right))
        y.height = 1 + max(self.getHeight(y.left), self.getHeight(y.right))
        return y

    def getHeight(self, root):
        return root.height if root else 0

    def getBalance(self, root):
        return self.getHeight(root.left) - self.getHeight(root.right) if root else 0


# Example Usage
tree = AVLTree()
root = None
for key in [10, 20, 30, 40, 50, 25]:
    root = tree.insert(root, key)
print("AVL Tree balanced insertions done.")

2. Red–Black Tree

  • Another self-balancing BST.

  • Each node is either Red or Black.

  • Rules ensure balance:

    • Root is always black.
    • No two red nodes in a row.
    • Every path from root to null has same # of black nodes.
  • Complexity: O(log N).

  • Used in sets, maps in C++ STL, Java TreeMap.

👉 Full implementation is quite long. In Python, we usually use libraries like bintrees or simulate. Do you want me to write full Red-Black Tree code, or just a simplified insert with coloring?


3. B-Tree (and B+ Tree)

  • A multi-way search tree (not binary).
  • Used in databases and filesystems.
  • Each node can have multiple keys & children.
  • B+ Tree → stores all values in leaf nodes, internal nodes only store keys (better for range queries).

Simplified B-Tree in Python:

class BTreeNode:
    def __init__(self, t, leaf=False):
        self.t = t  # minimum degree
        self.keys = []
        self.children = []
        self.leaf = leaf

class BTree:
    def __init__(self, t):
        self.root = BTreeNode(t, True)

    def traverse(self, node=None):
        if node is None:
            node = self.root
        for i, key in enumerate(node.keys):
            if not node.leaf:
                self.traverse(node.children[i])
            print(key, end=" ")
        if not node.leaf:
            self.traverse(node.children[len(node.keys)])

    # Insert code is long; real DB engines use optimized versions.

👉 Implementing full B+ Tree is heavy; for interviews, you just need the concepts and operations (search, insert, split nodes).


🌐 Graph Algorithms


4. Traveling Salesman Problem (TSP)

  • Goal: Visit all cities exactly once and return to start with minimum cost.

  • NP-hard problem.

  • Approaches:

    • Brute Force → O(N!)
    • Dynamic Programming (Held-Karp) → O(N²·2^N)

Python (DP Bitmasking):

from functools import lru_cache

def tsp(dist):
    n = len(dist)

    @lru_cache(None)
    def dp(mask, pos):
        if mask == (1 << n) - 1:
            return dist[pos][0]  # return to start
        ans = float('inf')
        for city in range(n):
            if not mask & (1 << city):
                ans = min(ans, dist[pos][city] + dp(mask | (1 << city), city))
        return ans

    return dp(1, 0)  # start at city 0

# Example
dist = [
    [0, 20, 42, 35],
    [20, 0, 30, 34],
    [42, 30, 0, 12],
    [35, 34, 12, 0]
]
print("TSP Min Cost:", tsp(dist))

5. Kahn’s Algorithm (Topological Sort using BFS)

  • Works on Directed Acyclic Graphs (DAGs).

  • Uses in-degree array + queue.

  • Steps:

    1. Find nodes with in-degree = 0, push to queue.
    2. Pop node, add to result, reduce in-degree of neighbors.
    3. Repeat until done.

Python Code:

from collections import deque, defaultdict

def kahn_topological_sort(V, edges):
    indegree = [0] * V
    adj = defaultdict(list)

    for u, v in edges:
        adj[u].append(v)
        indegree[v] += 1

    q = deque([i for i in range(V) if indegree[i] == 0])
    topo = []

    while q:
        u = q.popleft()
        topo.append(u)
        for v in adj[u]:
            indegree[v] -= 1
            if indegree[v] == 0:
                q.append(v)

    if len(topo) == V:
        return topo
    else:
        return "Cycle detected → Not a DAG"

# Example
edges = [(5, 2), (5, 0), (4, 0), (4, 1), (2, 3), (3, 1)]
print("Topological Sort:", kahn_topological_sort(6, edges))

✅ Summary

  • AVL Tree → Self-balancing BST (rotations).
  • Red-Black Tree → Balanced BST using colors.
  • B-Tree / B+ Tree → Used in databases, multi-key nodes.
  • TSP → NP-hard, solved via DP bitmask.
  • Kahn’s Algorithm → Topological sort (DAG).

🔴 Red-Black Tree (RBT)

Key ideas:

  • Each node is RED or BLACK.
  • Root is BLACK; leaves (NIL sentinels) are BLACK.
  • No two consecutive REDs on any path.
  • Every path from a node to its descendant NILs has the same number of BLACK nodes.
  • Operations: BST insert/delete + fixups (rotations & recolors) to restore invariants.

Below is a compact, correct implementation using a single shared NIL sentinel. It supports: insert, delete, search, inorder.

class RBNode:
    __slots__ = ("key", "color", "left", "right", "parent")
    def __init__(self, key=None, color="BLACK", left=None, right=None, parent=None):
        self.key = key
        self.color = color
        self.left = left
        self.right = right
        self.parent = parent

class RedBlackTree:
    def __init__(self):
        self.NIL = RBNode()            # sentinel leaf (BLACK)
        self.root = self.NIL

    # ---------- Utility ----------
    def _left_rotate(self, x):
        y = x.right
        x.right = y.left
        if y.left is not self.NIL:
            y.left.parent = x
        y.parent = x.parent
        if x.parent is None:           # x was root
            self.root = y
        elif x is x.parent.left:
            x.parent.left = y
        else:
            x.parent.right = y
        y.left = x
        x.parent = y

    def _right_rotate(self, y):
        x = y.left
        y.left = x.right
        if x.right is not self.NIL:
            x.right.parent = y
        x.parent = y.parent
        if y.parent is None:
            self.root = x
        elif y is y.parent.right:
            y.parent.right = x
        else:
            y.parent.left = x
        x.right = y
        y.parent = x

    def search(self, key):
        cur = self.root
        while cur is not self.NIL and cur.key != key:
            cur = cur.left if key < cur.key else cur.right
        return None if cur is self.NIL else cur

    def inorder(self, node=None, out=None):
        if node is None:
            node, out = self.root, []
        if node is self.NIL:
            return out
        self.inorder(node.left, out)
        out.append((node.key, node.color))
        self.inorder(node.right, out)
        return out

    # ---------- Insert ----------
    def insert(self, key):
        node = RBNode(key=key, color="RED", left=self.NIL, right=self.NIL)
        y = None
        x = self.root
        while x is not self.NIL:
            y = x
            x = x.left if node.key < x.key else x.right
        node.parent = y
        if y is None:
            self.root = node
        elif node.key < y.key:
            y.left = node
        else:
            y.right = node
        self._insert_fixup(node)

    def _insert_fixup(self, z):
        while z.parent and z.parent.color == "RED":
            if z.parent is z.parent.parent.left:
                y = z.parent.parent.right  # uncle
                if y.color == "RED":  # case 1
                    z.parent.color = "BLACK"
                    y.color = "BLACK"
                    z.parent.parent.color = "RED"
                    z = z.parent.parent
                else:
                    if z is z.parent.right:  # case 2
                        z = z.parent
                        self._left_rotate(z)
                    # case 3
                    z.parent.color = "BLACK"
                    z.parent.parent.color = "RED"
                    self._right_rotate(z.parent.parent)
            else:
                y = z.parent.parent.left
                if y.color == "RED":
                    z.parent.color = "BLACK"
                    y.color = "BLACK"
                    z.parent.parent.color = "RED"
                    z = z.parent.parent
                else:
                    if z is z.parent.left:
                        z = z.parent
                        self._right_rotate(z)
                    z.parent.color = "BLACK"
                    z.parent.parent.color = "RED"
                    self._left_rotate(z.parent.parent)
        self.root.color = "BLACK"

    # ---------- Delete ----------
    def _transplant(self, u, v):
        if u.parent is None:
            self.root = v
        elif u is u.parent.left:
            u.parent.left = v
        else:
            u.parent.right = v
        v.parent = u.parent

    def _minimum(self, x):
        while x.left is not self.NIL:
            x = x.left
        return x

    def delete(self, key):
        z = self.search(key)
        if z is None:
            return False
        y = z
        y_original_color = y.color
        if z.left is self.NIL:
            x = z.right
            self._transplant(z, z.right)
        elif z.right is self.NIL:
            x = z.left
            self._transplant(z, z.left)
        else:
            y = self._minimum(z.right)
            y_original_color = y.color
            x = y.right
            if y.parent is z:
                x.parent = y
            else:
                self._transplant(y, y.right)
                y.right = z.right
                y.right.parent = y
            self._transplant(z, y)
            y.left = z.left
            y.left.parent = y
            y.color = z.color
        if y_original_color == "BLACK":
            self._delete_fixup(x)
        return True

    def _delete_fixup(self, x):
        while x is not self.root and x.color == "BLACK":
            if x is x.parent.left:
                w = x.parent.right
                if w.color == "RED":
                    w.color = "BLACK"
                    x.parent.color = "RED"
                    self._left_rotate(x.parent)
                    w = x.parent.right
                if w.left.color == "BLACK" and w.right.color == "BLACK":
                    w.color = "RED"
                    x = x.parent
                else:
                    if w.right.color == "BLACK":
                        w.left.color = "BLACK"
                        w.color = "RED"
                        self._right_rotate(w)
                        w = x.parent.right
                    w.color = x.parent.color
                    x.parent.color = "BLACK"
                    w.right.color = "BLACK"
                    self._left_rotate(x.parent)
                    x = self.root
            else:
                w = x.parent.left
                if w.color == "RED":
                    w.color = "BLACK"
                    x.parent.color = "RED"
                    self._right_rotate(x.parent)
                    w = x.parent.left
                if w.right.color == "BLACK" and w.left.color == "BLACK":
                    w.color = "RED"
                    x = x.parent
                else:
                    if w.left.color == "BLACK":
                        w.right.color = "BLACK"
                        w.color = "RED"
                        self._left_rotate(w)
                        w = x.parent.left
                    w.color = x.parent.color
                    x.parent.color = "BLACK"
                    w.left.color = "BLACK"
                    self._right_rotate(x.parent)
                    x = self.root
        x.color = "BLACK"

# --- quick demo ---
if __name__ == "__main__":
    rbt = RedBlackTree()
    for k in [7,3,18,10,22,8,11,26,2,6,13]:
        rbt.insert(k)
    # delete a couple
    rbt.delete(18)
    rbt.delete(11)
    print("RBT inorder (key,color):", rbt.inorder())

What to notice: insert/delete are standard BST ops followed by fixups. Rotations and recoloring keep the invariants.


🟦 B-Tree (B-) — balanced multiway search tree

Parameters:

  • Minimum degree t (t ≥ 2).
  • Each node has t-1 … 2t-1 keys (except root), and t … 2t children.
  • All leaves at the same depth.
  • Great for disks/DBs: large branching factor, few levels.

We’ll implement search, insert (with node splitting), and traverse. (Deletion is possible but long; happy to add if you want it too.)

class BTreeNode:
    def __init__(self, t, leaf=False):
        self.t = t
        self.leaf = leaf
        self.keys = []          # sorted
        self.children = []      # len = len(keys)+1 when internal

    def __repr__(self):
        return f"BTreeNode(leaf={self.leaf}, keys={self.keys})"

class BTree:
    def __init__(self, t=3):
        if t < 2: raise ValueError("t must be >= 2")
        self.t = t
        self.root = BTreeNode(t, leaf=True)

    def search(self, k, x=None):
        x = self.root if x is None else x
        i = 0
        while i < len(x.keys) and k > x.keys[i]:
            i += 1
        if i < len(x.keys) and k == x.keys[i]:
            return x, i
        if x.leaf:
            return None
        return self.search(k, x.children[i])

    def traverse(self, x=None, out=None):
        x = self.root if x is None else x
        out = [] if out is None else out
        i = 0
        for i in range(len(x.keys)):
            if not x.leaf:
                self.traverse(x.children[i], out)
            out.append(x.keys[i])
        if not x.leaf:
            self.traverse(x.children[i+1], out)
        return out

    # -------- insertion helpers --------
    def _split_child(self, x, i):
        t = self.t
        y = x.children[i]             # full child
        z = BTreeNode(t, leaf=y.leaf) # new node
        # move top half of y.keys to z
        z.keys = y.keys[t:]           # t .. 2t-2
        mid = y.keys[t-1]
        y.keys = y.keys[:t-1]
        if not y.leaf:
            z.children = y.children[t:]    # t .. 2t-1
            y.children = y.children[:t]
        # insert z into x
        x.children.insert(i+1, z)
        x.keys.insert(i, mid)

    def _insert_nonfull(self, x, k):
        i = len(x.keys) - 1
        if x.leaf:
            x.keys.append(None)
            while i >= 0 and k < x.keys[i]:
                x.keys[i+1] = x.keys[i]
                i -= 1
            x.keys[i+1] = k
        else:
            while i >= 0 and k < x.keys[i]:
                i -= 1
            i += 1
            if len(x.children[i].keys) == 2*self.t - 1:
                self._split_child(x, i)
                if k > x.keys[i]:
                    i += 1
            self._insert_nonfull(x.children[i], k)

    def insert(self, k):
        r = self.root
        if len(r.keys) == 2*self.t - 1:
            s = BTreeNode(self.t, leaf=False)
            s.children.append(r)
            self.root = s
            self._split_child(s, 0)
            self._insert_nonfull(s, k)
        else:
            self._insert_nonfull(r, k)

# --- quick demo ---
if __name__ == "__main__":
    bt = BTree(t=3)  # order up to 5 children
    for k in [10,20,5,6,12,30,7,17,3,4,50,60,1,2,8,9,11,13,14,15]:
        bt.insert(k)
    print("BTree traverse:", bt.traverse())
    print("Search 14:", bt.search(14))
    print("Search 99:", bt.search(99))

What to notice: inserts go into a non-full node; if a child is full, we split it first, pushing the median up.


🟩 B+ Tree — all values in leaves, great for range scans

Differences vs B-Tree:

  • All actual (key,value) records live in leaves; internal nodes only guide the search (store keys).

  • Leaves are linked as a doubly-linked list for fast range queries.

  • Here we’ll make a small educational implementation:

    • Order m (max keys per node) for simplicity (not tuned for IO pages).
    • Supports insert(key, value), search(key), and range_search(lo, hi).
class BPlusLeaf:
    def __init__(self, m):
        self.m = m
        self.keys = []
        self.vals = []
        self.next = None
        self.prev = None
        self.parent = None
        self.is_leaf = True

class BPlusInternal:
    def __init__(self, m):
        self.m = m
        self.keys = []
        self.children = []
        self.parent = None
        self.is_leaf = False

class BPlusTree:
    def __init__(self, m=4):
        if m < 3: raise ValueError("order m must be >= 3")
        self.m = m
        self.root = BPlusLeaf(m)

    def _find_leaf(self, key):
        x = self.root
        while not x.is_leaf:
            i = 0
            while i < len(x.keys) and key >= x.keys[i]:
                i += 1
            x = x.children[i]
        return x

    def search(self, key):
        leaf = self._find_leaf(key)
        for i, k in enumerate(leaf.keys):
            if k == key:
                return leaf.vals[i]
        return None

    def range_search(self, lo, hi):
        # find start leaf
        leaf = self._find_leaf(lo)
        out = []
        while leaf:
            for k, v in zip(leaf.keys, leaf.vals):
                if k > hi:
                    return out
                if lo <= k <= hi:
                    out.append((k, v))
            leaf = leaf.next
        return out

    def insert(self, key, value):
        leaf = self._find_leaf(key)
        # insert into leaf (sorted)
        i = 0
        while i < len(leaf.keys) and key > leaf.keys[i]:
            i += 1
        if i < len(leaf.keys) and leaf.keys[i] == key:
            leaf.vals[i] = value  # upsert
            return
        leaf.keys.insert(i, key)
        leaf.vals.insert(i, value)

        if len(leaf.keys) > self.m:  # split leaf
            self._split_leaf(leaf)

    def _split_leaf(self, leaf):
        mid = (len(leaf.keys) + 1) // 2
        new_leaf = BPlusLeaf(self.m)
        new_leaf.keys = leaf.keys[mid:]
        new_leaf.vals = leaf.vals[mid:]
        leaf.keys = leaf.keys[:mid]
        leaf.vals = leaf.vals[:mid]

        # link leaves
        new_leaf.next = leaf.next
        if new_leaf.next:
            new_leaf.next.prev = new_leaf
        leaf.next = new_leaf
        new_leaf.prev = leaf

        # push separator to parent
        sep = new_leaf.keys[0]
        if leaf.parent is None:
            new_root = BPlusInternal(self.m)
            new_root.keys = [sep]
            new_root.children = [leaf, new_leaf]
            leaf.parent = new_leaf.parent = new_root
            self.root = new_root
        else:
            self._insert_in_parent(leaf, sep, new_leaf)

    def _insert_in_parent(self, left, key, right):
        parent = left.parent
        # find position to insert key/child
        i = 0
        while i < len(parent.children) and parent.children[i] is not left:
            i += 1
        parent.keys.insert(i, key)
        parent.children.insert(i+1, right)
        right.parent = parent

        if len(parent.keys) > self.m:
            self._split_internal(parent)

    def _split_internal(self, node):
        mid = len(node.keys)//2
        sep_key = node.keys[mid]

        right = BPlusInternal(self.m)
        right.keys = node.keys[mid+1:]
        right.children = node.children[mid+1:]
        for c in right.children:
            c.parent = right

        node.keys = node.keys[:mid]
        node.children = node.children[:mid+1]

        if node.parent is None:
            new_root = BPlusInternal(self.m)
            new_root.keys = [sep_key]
            new_root.children = [node, right]
            node.parent = right.parent = new_root
            self.root = new_root
        else:
            self._insert_in_parent(node, sep_key, right)

# --- quick demo ---
if __name__ == "__main__":
    bpt = BPlusTree(m=4)
    data = [(5,"e"),(15,"o"),(25,"y"),(35,"cc"),(45,"mm"),(1,"a"),(2,"b"),(3,"c"),
            (4,"d"),(6,"f"),(7,"g"),(8,"h"),(9,"i"),(10,"j"),(11,"k"),(12,"l")]
    for k,v in data:
        bpt.insert(k,v)

    print("Search 10:", bpt.search(10))
    print("Range [4,11]:", bpt.range_search(4,11))

What to notice:

  • Internal nodes only direct search with separator keys.
  • All records live in leaves; leaf nodes are linked for efficient ranges.
  • Splits propagate up similarly to B-Tree.

🧭 Already shared (reposting quickly for completeness)

Traveling Salesman Problem (Held-Karp, DP with bitmask)

from functools import lru_cache

def tsp(dist):
    n = len(dist)
    @lru_cache(None)
    def dp(mask, i):
        if mask == (1 << n) - 1:
            return dist[i][0]
        best = float('inf')
        for j in range(n):
            if not (mask & (1 << j)):
                best = min(best, dist[i][j] + dp(mask | (1 << j), j))
        return best
    return dp(1, 0)

# demo
# dist = [[...]]
# print(tsp(dist))

Kahn’s Algorithm (Topological Sort, BFS indegrees)

from collections import defaultdict, deque

def kahn_topological_sort(V, edges):
    indeg = [0]*V
    adj = defaultdict(list)
    for u,v in edges:
        adj[u].append(v)
        indeg[v] += 1

    q = deque([i for i in range(V) if indeg[i]==0])
    topo = []
    while q:
        u = q.popleft()
        topo.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v]==0:
                q.append(v)
    return topo if len(topo)==V else "Cycle detected"

When to use what (quick guide)

  • AVL: frequent searches + many inserts/deletes in memory; strict height balance (lower lookup variance).
  • Red-Black: slightly faster inserts/deletes on average (fewer rotations); widely used in language libs (TreeMap/TreeSet).
  • B-Tree: on-disk indexes with large branching factor; internal nodes store keys+values.
  • B+ Tree: databases & filesystems; all data in leaves + linked leaves → blazing fast range scans and sequential reads.
  • TSP: small N exact solutions; for large N use heuristics (2-opt, 3-opt, Christofides).
  • Kahn: topological order for DAGs; course scheduling, build pipelines.

def process_status_code(status_code):
    match status_code:
        case 200:
            return "OK - Success"
        case 404:
            return "Not Found"
        case 500:
            return "Internal Server Error"
        case 403:
            return "Forbidden"
        case _:  # Default case (wildcard)
            return f"Unknown status code: {status_code}"

# Usage
print(process_status_code(200))  # OK - Success
print(process_status_code(404))  # Not Found
print(process_status_code(999))  # Unknown status code: 999


Threading allows programs to run multiple tasks simultaneously, improving performance especially for I/O-bound operations. Both Python and Java support threading, but with some key differences in syntax and behavior.[1][2][3]

Basic Thread Creation

Python Threading

import threading
import time

def worker(name, delay):
    print(f"Thread {name} starting")
    time.sleep(delay)
    print(f"Thread {name} finished")

# Create and start threads
t1 = threading.Thread(target=worker, args=("A", 2))
t2 = threading.Thread(target=worker, args=("B", 1))

t1.start()
t2.start()

# Wait for threads to complete
t1.join()
t2.join()
print("All threads completed")

Java Threading

class WorkerThread extends Thread {
    private String name;
    private int delay;
    
    public WorkerThread(String name, int delay) {
        this.name = name;
        this.delay = delay;
    }
    
    public void run() {
        System.out.println("Thread " + name + " starting");
        try {
            Thread.sleep(delay * 1000);
        } catch (InterruptedException e) {
            System.out.println("Thread interrupted");
        }
        System.out.println("Thread " + name + " finished");
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        WorkerThread t1 = new WorkerThread("A", 2);
        WorkerThread t2 = new WorkerThread("B", 1);
        
        t1.start();
        t2.start();
        
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted");
        }
        
        System.out.println("All threads completed");
    }
}

Alternative Java approach using Runnable

class Worker implements Runnable {
    private String name;
    private int delay;
    
    public Worker(String name, int delay) {
        this.name = name;
        this.delay = delay;
    }
    
    public void run() {
        System.out.println("Thread " + name + " starting");
        try {
            Thread.sleep(delay * 1000);
        } catch (InterruptedException e) {
            System.out.println("Thread interrupted");
        }
        System.out.println("Thread " + name + " finished");
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(new Worker("A", 2));
        Thread t2 = new Thread(new Worker("B", 1));
        
        t1.start();
        t2.start();
        
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted");
        }
        
        System.out.println("All threads completed");
    }
}

Thread Synchronization (Locks)

Python Synchronization

import threading
import time

counter = 0
lock = threading.Lock()

def increment(name, times):
    global counter
    for i in range(times):
        with lock:  # Acquire lock automatically
            temp = counter
            time.sleep(0.001)  # Simulate work
            counter = temp + 1
        print(f"{name}: {counter}")

# Create threads
t1 = threading.Thread(target=increment, args=("Thread-1", 5))
t2 = threading.Thread(target=increment, args=("Thread-2", 5))

t1.start()
t2.start()

t1.join()
t2.join()

print(f"Final counter: {counter}")

Java Synchronization

class Counter {
    private int count = 0;
    
    public synchronized void increment(String name) {
        int temp = count;
        try {
            Thread.sleep(1); // Simulate work
        } catch (InterruptedException e) {
            System.out.println("Thread interrupted");
        }
        count = temp + 1;
        System.out.println(name + ": " + count);
    }
    
    public int getCount() {
        return count;
    }
}

class IncrementThread extends Thread {
    private Counter counter;
    private String name;
    private int times;
    
    public IncrementThread(Counter counter, String name, int times) {
        this.counter = counter;
        this.name = name;
        this.times = times;
    }
    
    public void run() {
        for (int i = 0; i < times; i++) {
            counter.increment(name);
        }
    }
}

public class SyncExample {
    public static void main(String[] args) {
        Counter counter = new Counter();
        
        IncrementThread t1 = new IncrementThread(counter, "Thread-1", 5);
        IncrementThread t2 = new IncrementThread(counter, "Thread-2", 5);
        
        t1.start();
        t2.start();
        
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted");
        }
        
        System.out.println("Final counter: " + counter.getCount());
    }
}

Producer-Consumer Pattern

Python Producer-Consumer

import threading
import queue
import time
import random

# Thread-safe queue
q = queue.Queue(maxsize=3)

def producer(name):
    for i in range(5):
        item = f"{name}-item-{i}"
        q.put(item)
        print(f"Producer {name}: Added {item}")
        time.sleep(random.uniform(0.1, 0.5))

def consumer(name):
    while True:
        try:
            item = q.get(timeout=2)
            print(f"Consumer {name}: Processing {item}")
            time.sleep(random.uniform(0.2, 0.8))
            q.task_done()
        except queue.Empty:
            print(f"Consumer {name}: No more items")
            break

# Start threads
producer_thread = threading.Thread(target=producer, args=("P1",))
consumer_thread = threading.Thread(target=consumer, args=("C1",))

producer_thread.start()
consumer_thread.start()

producer_thread.join()
consumer_thread.join()

Java Producer-Consumer

import java.util.LinkedList;
import java.util.Queue;

class Buffer {
    private Queue<String> queue = new LinkedList<>();
    private int capacity;
    
    public Buffer(int capacity) {
        this.capacity = capacity;
    }
    
    public synchronized void put(String item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait(); // Wait if buffer is full
        }
        queue.add(item);
        System.out.println("Produced: " + item);
        notifyAll(); // Notify waiting consumers
    }
    
    public synchronized String get() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // Wait if buffer is empty
        }
        String item = queue.poll();
        System.out.println("Consumed: " + item);
        notifyAll(); // Notify waiting producers
        return item;
    }
}

class Producer extends Thread {
    private Buffer buffer;
    private String name;
    
    public Producer(Buffer buffer, String name) {
        this.buffer = buffer;
        this.name = name;
    }
    
    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                String item = name + "-item-" + i;
                buffer.put(item);
                Thread.sleep(100);
            }
        } catch (InterruptedException e) {
            System.out.println("Producer interrupted");
        }
    }
}

class Consumer extends Thread {
    private Buffer buffer;
    private String name;
    
    public Consumer(Buffer buffer, String name) {
        this.buffer = buffer;
        this.name = name;
    }
    
    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                buffer.get();
                Thread.sleep(200);
            }
        } catch (InterruptedException e) {
            System.out.println("Consumer interrupted");
        }
    }
}

public class ProducerConsumer {
    public static void main(String[] args) {
        Buffer buffer = new Buffer(3);
        
        Producer producer = new Producer(buffer, "P1");
        Consumer consumer = new Consumer(buffer, "C1");
        
        producer.start();
        consumer.start();
        
        try {
            producer.join();
            consumer.join();
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted");
        }
    }
}

Key Differences

FeaturePythonJava
Thread Creationthreading.Thread(target=func) [21]Extend Thread class or implement Runnable [6]
Synchronizationwith lock: or lock.acquire()/release() [21]synchronized keyword or explicit locks [4][5]
Built-in Queuesqueue.Queue() thread-safe [6]Manual implementation with wait()/notify() [8]
Exception HandlingAutomatic cleanup with with statement [3]Manual try-catch in threads [7]
GIL ImpactLimited CPU parallelism due to Global Interpreter Lock [8]True parallelism for CPU-bound tasks [9]

Passing objects as parameters in Java involves passing a copy of the object reference (not the object itself), which allows the method to access and modify the original object's properties. Java is always "pass-by-value," but for objects, the value being passed is the reference to the object in memory.[1][2][3][4]

Basic Object Parameter Passing

Simple object passing

class Person {
    private String name;
    private int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() { return name; }
    public int getAge() { return age; }
    public void setName(String name) { this.name = name; }
    public void setAge(int age) { this.age = age; }
    
    public void displayInfo() {
        System.out.println("Name: " + name + ", Age: " + age);
    }
}

public class ObjectParameterExample {
    // Method that takes a Person object as parameter
    public static void displayPersonInfo(Person p) {
        System.out.println("Displaying person info:");
        p.displayInfo();
    }
    
    // Method that modifies the object
    public static void updatePersonAge(Person p, int newAge) {
        p.setAge(newAge);
        System.out.println("Updated age to: " + newAge);
    }
    
    public static void main(String[] args) {
        Person person1 = new Person("John", 25);
        
        // Pass object to method
        displayPersonInfo(person1);
        
        // Modify object through method
        updatePersonAge(person1, 30);
        
        // Check if original object was modified
        person1.displayInfo(); // Age will be 30
    }
}

Multiple object parameters

class Student {
    private String name;
    private double grade;
    
    public Student(String name, double grade) {
        this.name = name;
        this.grade = grade;
    }
    
    public String getName() { return name; }
    public double getGrade() { return grade; }
    public void setGrade(double grade) { this.grade = grade; }
}

public class MultipleObjectParameters {
    // Method taking multiple object parameters
    public static void compareStudents(Student s1, Student s2) {
        System.out.println("Comparing students:");
        System.out.println(s1.getName() + ": " + s1.getGrade());
        System.out.println(s2.getName() + ": " + s2.getGrade());
        
        if (s1.getGrade() > s2.getGrade()) {
            System.out.println(s1.getName() + " has higher grade");
        } else if (s2.getGrade() > s1.getGrade()) {
            System.out.println(s2.getName() + " has higher grade");
        } else {
            System.out.println("Both have same grade");
        }
    }
    
    // Method that swaps grades between students
    public static void swapGrades(Student s1, Student s2) {
        double temp = s1.getGrade();
        s1.setGrade(s2.getGrade());
        s2.setGrade(temp);
        System.out.println("Grades swapped!");
    }
    
    public static void main(String[] args) {
        Student alice = new Student("Alice", 85.5);
        Student bob = new Student("Bob", 92.0);
        
        compareStudents(alice, bob);
        
        System.out.println("\nBefore swap:");
        System.out.println("Alice: " + alice.getGrade());
        System.out.println("Bob: " + bob.getGrade());
        
        swapGrades(alice, bob);
        
        System.out.println("\nAfter swap:");
        System.out.println("Alice: " + alice.getGrade());
        System.out.println("Bob: " + bob.getGrade());
    }
}

Object Modification vs Reference Change

What works - Modifying object properties

class Counter {
    private int count;
    
    public Counter(int count) {
        this.count = count;
    }
    
    public int getCount() { return count; }
    public void setCount(int count) { this.count = count; }
    public void increment() { this.count++; }
}

public class ObjectModification {
    // This WORKS - modifies the object's state
    public static void incrementCounter(Counter c) {
        c.increment();
        System.out.println("Counter incremented inside method: " + c.getCount());
    }
    
    // This WORKS - changes object properties
    public static void resetCounter(Counter c, int newValue) {
        c.setCount(newValue);
        System.out.println("Counter reset to: " + newValue);
    }
    
    public static void main(String[] args) {
        Counter myCounter = new Counter(5);
        
        System.out.println("Original count: " + myCounter.getCount());
        
        incrementCounter(myCounter);
        System.out.println("After increment: " + myCounter.getCount()); // Will be 6
        
        resetCounter(myCounter, 10);
        System.out.println("After reset: " + myCounter.getCount()); // Will be 10
    }
}

What doesn't work - Reassigning the reference

class Box {
    private String contents;
    
    public Box(String contents) {
        this.contents = contents;
    }
    
    public String getContents() { return contents; }
    public void setContents(String contents) { this.contents = contents; }
}

public class ReferenceReassignment {
    // This DOESN'T work - cannot change what the original reference points to
    public static void tryToChangeReference(Box b) {
        b = new Box("New Box"); // This creates a new object but doesn't change original
        System.out.println("Inside method: " + b.getContents());
    }
    
    // This WORKS - modifies the existing object
    public static void changeContents(Box b) {
        b.setContents("Modified Contents");
    }
    
    public static void main(String[] args) {
        Box originalBox = new Box("Original Contents");
        
        System.out.println("Before tryToChangeReference: " + originalBox.getContents());
        tryToChangeReference(originalBox);
        System.out.println("After tryToChangeReference: " + originalBox.getContents()); 
        // Still "Original Contents" - reference wasn't changed
        
        System.out.println("\nBefore changeContents: " + originalBox.getContents());
        changeContents(originalBox);
        System.out.println("After changeContents: " + originalBox.getContents()); 
        // Now "Modified Contents" - object was modified
    }
}

Array as Object Parameter

public class ArrayParameter {
    // Arrays are objects, so same rules apply
    public static void modifyArray(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            arr[i] = arr[i] * 2;
        }
        System.out.println("Array modified inside method");
    }
    
    // This won't change the original array reference
    public static void tryToReplaceArray(int[] arr) {
        arr = new int[]{100, 200, 300}; // Creates new array, doesn't affect original
        System.out.println("New array created inside method");
    }
    
    public static void displayArray(int[] arr) {
        System.out.print("Array contents: ");
        for (int value : arr) {
            System.out.print(value + " ");
        }
        System.out.println();
    }
    
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5};
        
        System.out.println("Original array:");
        displayArray(numbers);
        
        modifyArray(numbers);
        System.out.println("After modification:");
        displayArray(numbers); // Values will be doubled
        
        tryToReplaceArray(numbers);
        System.out.println("After trying to replace:");
        displayArray(numbers); // Still the doubled values, not {100, 200, 300}
    }
}

Custom Objects with Complex Operations

class BankAccount {
    private String accountNumber;
    private double balance;
    
    public BankAccount(String accountNumber, double balance) {
        this.accountNumber = accountNumber;
        this.balance = balance;
    }
    
    public String getAccountNumber() { return accountNumber; }
    public double getBalance() { return balance; }
    
    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            System.out.println("Deposited: $" + amount);
        }
    }
    
    public boolean withdraw(double amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
            System.out.println("Withdrew: $" + amount);
            return true;
        }
        System.out.println("Insufficient funds or invalid amount");
        return false;
    }
    
    public void displayAccount() {
        System.out.println("Account: " + accountNumber + ", Balance: $" + balance);
    }
}

public class BankOperations {
    // Transfer money between accounts
    public static boolean transferMoney(BankAccount from, BankAccount to, double amount) {
        System.out.println("Attempting to transfer $" + amount);
        
        if (from.withdraw(amount)) {
            to.deposit(amount);
            System.out.println("Transfer successful!");
            return true;
        } else {
            System.out.println("Transfer failed!");
            return false;
        }
    }
    
    // Apply interest to an account
    public static void applyInterest(BankAccount account, double interestRate) {
        double interest = account.getBalance() * interestRate / 100;
        account.deposit(interest);
        System.out.println("Interest applied: " + interestRate + "%");
    }
    
    // Compare two accounts
    public static void compareAccounts(BankAccount acc1, BankAccount acc2) {
        System.out.println("\nAccount comparison:");
        acc1.displayAccount();
        acc2.displayAccount();
        
        if (acc1.getBalance() > acc2.getBalance()) {
            System.out.println(acc1.getAccountNumber() + " has higher balance");
        } else if (acc2.getBalance() > acc1.getBalance()) {
            System.out.println(acc2.getAccountNumber() + " has higher balance");
        } else {
            System.out.println("Both accounts have equal balance");
        }
    }
    
    public static void main(String[] args) {
        BankAccount alice = new BankAccount("ACC001", 1000.0);
        BankAccount bob = new BankAccount("ACC002", 500.0);
        
        System.out.println("Initial state:");
        alice.displayAccount();
        bob.displayAccount();
        
        // Transfer money
        transferMoney(alice, bob, 200.0);
        
        System.out.println("\nAfter transfer:");
        alice.displayAccount();
        bob.displayAccount();
        
        // Apply interest
        applyInterest(alice, 2.5);
        applyInterest(bob, 2.5);
        
        System.out.println("\nAfter interest:");
        alice.displayAccount();
        bob.displayAccount();
        
        // Compare accounts
        compareAccounts(alice, bob);
    }
}

Key Rules for Object Parameters

What you can do

  • Modify object properties: Change field values using setter methods[3][1]
  • Call object methods: Invoke any public methods on the passed object[2]
  • Access object data: Read object properties through getter methods[5]
  • Modify object state: Change the internal state of the object[3]

What you cannot do

  • Change the reference: Cannot make the original variable point to a different object[4][6]
  • Reassign the parameter: obj = new Object() inside method won't affect original[4]

Memory explanation

When you pass an object to a method, Java copies the reference value (memory address) to the method parameter, so both the original variable and the parameter point to the same object in memory. This means:[7][3]

  • Changes to the object affect the original (same object in memory)[1]
  • Reassigning the parameter creates a new reference but doesn't change the original variable[4]

This behavior makes object passing in Java very powerful for modifying objects while maintaining clear boundaries about what can and cannot be changed.[1][3]